From 8c0edfa92c11537714a146de54ac12a333fb23d4 Mon Sep 17 00:00:00 2001 From: vvbbnn00 Date: Tue, 24 Mar 2026 04:21:43 +0800 Subject: [PATCH 001/614] feat: add persistent gallery downloads with cache-aware offline reading, repair, and update support --- .gitignore | 3 +- EhPanda.xcodeproj/project.pbxproj | 144 +- EhPanda/App/Generated/Strings.swift | 317 + EhPanda/App/Tools/Clients/CookieClient.swift | 61 +- .../App/Tools/Clients/DownloadClient.swift | 3941 +++++++++++ EhPanda/App/Tools/Clients/ImageClient.swift | 19 +- EhPanda/App/Tools/Defaults.swift | 5 + EhPanda/App/Tools/Extensions/Extensions.swift | 65 + EhPanda/App/Tools/Parser.swift | 113 + EhPanda/App/Tools/Utilities/AppUtil.swift | 80 + EhPanda/App/Tools/Utilities/CookieUtil.swift | 3 +- .../Tools/Utilities/DownloadFileStorage.swift | 400 ++ EhPanda/App/Tools/Utilities/FileUtil.swift | 6 + EhPanda/App/de.lproj/Localizable.strings | 109 + EhPanda/App/en.lproj/Localizable.strings | 123 + EhPanda/App/ja.lproj/Localizable.strings | 109 + EhPanda/App/ko.lproj/Localizable.strings | 109 + EhPanda/App/zh-Hans.lproj/Localizable.strings | 123 + .../App/zh-Hant-HK.lproj/Localizable.strings | 107 + .../App/zh-Hant-TW.lproj/Localizable.strings | 107 + EhPanda/App/zh-Hant.lproj/Localizable.strings | 107 + EhPanda/DataFlow/AppReducer.swift | 74 +- EhPanda/DataFlow/AppRouteReducer.swift | 2 +- .../DownloadedGalleryMO+CoreDataClass.swift | 37 + ...wnloadedGalleryMO+CoreDataProperties.swift | 35 + .../Migration/CoreDataMigrationVersion.swift | 4 +- .../Model.xcdatamodeld/.xccurrentversion | 2 +- .../Model 8.xcdatamodel/contents | 96 + EhPanda/Database/Persistence.swift | 2 + .../Models/Persistent/DownloadedGallery.swift | 944 +++ EhPanda/Models/Persistent/Setting.swift | 22 + EhPanda/Models/Support/AppError.swift | 50 +- EhPanda/Models/Support/EhSetting.swift | 2 +- EhPanda/Models/Support/Misc.swift | 4 + EhPanda/Network/Request.swift | 111 +- EhPanda/View/Detail/DetailReducer.swift | 373 +- EhPanda/View/Detail/DetailView.swift | 505 +- .../Detail/Previews/PreviewsReducer.swift | 99 +- .../View/Detail/Previews/PreviewsView.swift | 19 +- .../View/Downloads/DownloadFiltersView.swift | 133 + EhPanda/View/Downloads/DownloadsReducer.swift | 475 ++ EhPanda/View/Downloads/DownloadsView.swift | 524 ++ EhPanda/View/Favorites/FavoritesReducer.swift | 47 +- EhPanda/View/Favorites/FavoritesView.swift | 4 +- .../View/Home/History/HistoryReducer.swift | 41 + EhPanda/View/Home/History/HistoryView.swift | 4 +- EhPanda/View/Home/HomeReducer.swift | 65 +- EhPanda/View/Home/HomeView.swift | 78 +- .../View/Home/Watched/WatchedReducer.swift | 45 +- EhPanda/View/Home/Watched/WatchedView.swift | 4 +- EhPanda/View/Reading/ReadingReducer.swift | 236 +- EhPanda/View/Reading/ReadingView.swift | 125 +- .../View/Reading/Support/ControlPanel.swift | 9 +- EhPanda/View/Search/SearchReducer.swift | 45 +- EhPanda/View/Search/SearchRootView.swift | 7 +- EhPanda/View/Search/SearchView.swift | 4 +- .../View/Search/Support/QuickSearchView.swift | 9 +- .../Components/DownloadSettingView.swift | 66 + EhPanda/View/Setting/SettingReducer.swift | 10 +- EhPanda/View/Setting/SettingView.swift | 12 + .../Components/Cells/GalleryCardCell.swift | 17 +- .../Components/Cells/GalleryDetailCell.swift | 96 +- .../Components/Cells/GalleryHistoryCell.swift | 8 +- .../Components/Cells/GalleryRankingCell.swift | 13 +- .../Cells/GalleryThumbnailCell.swift | 23 +- .../Components/DownloadBadgeLabel.swift | 58 + .../Components/DownloadBadgeStore.swift | 48 + .../View/Support/Components/GenericList.swift | 35 +- .../Support/Components/PreviewImageView.swift | 152 + EhPanda/View/TabBar/TabBarView.swift | 13 + EhPandaTests/Models/HTMLFilename.swift | 2 + .../Parser/Other/BandwidthExceeded.html | Bin 0 -> 28658 bytes .../Parser/Other/ExLoginRequired.html | 0 .../Resources/Parser/Other/Kokomade.jpg | Bin 0 -> 144844 bytes .../DownloadFeatureReducerTests.swift | 6089 +++++++++++++++++ .../Download/DownloadFileStorageTests.swift | 463 ++ .../DownloadSignatureBuilderTests.swift | 308 + .../Other/DownloadPageErrorParserTests.swift | 66 + .../Parser/Other/SettingDownloadTests.swift | 113 + ShareExtension/Info.plist | 2 - 80 files changed, 17642 insertions(+), 239 deletions(-) create mode 100644 EhPanda/App/Tools/Clients/DownloadClient.swift create mode 100644 EhPanda/App/Tools/Utilities/DownloadFileStorage.swift create mode 100644 EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift create mode 100644 EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataProperties.swift create mode 100644 EhPanda/Database/Model.xcdatamodeld/Model 8.xcdatamodel/contents create mode 100644 EhPanda/Models/Persistent/DownloadedGallery.swift create mode 100644 EhPanda/View/Downloads/DownloadFiltersView.swift create mode 100644 EhPanda/View/Downloads/DownloadsReducer.swift create mode 100644 EhPanda/View/Downloads/DownloadsView.swift create mode 100644 EhPanda/View/Setting/Components/DownloadSettingView.swift create mode 100644 EhPanda/View/Support/Components/DownloadBadgeLabel.swift create mode 100644 EhPanda/View/Support/Components/DownloadBadgeStore.swift create mode 100644 EhPanda/View/Support/Components/PreviewImageView.swift create mode 100644 EhPandaTests/Resources/Parser/Other/BandwidthExceeded.html create mode 100644 EhPandaTests/Resources/Parser/Other/ExLoginRequired.html create mode 100644 EhPandaTests/Resources/Parser/Other/Kokomade.jpg create mode 100644 EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadFileStorageTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift create mode 100644 EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift create mode 100644 EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift diff --git a/.gitignore b/.gitignore index ee8327b39..962609f19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store EhPanda.xcodeproj/xcuserdata -EhPanda.xcodeproj/project.xcworkspace/xcuserdata \ No newline at end of file +EhPanda.xcodeproj/project.xcworkspace/xcuserdata +Config/LocalSigning.xcconfig diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 15c18f3b5..0e012ea48 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -282,6 +282,26 @@ EA5AA4AA2EA9149E00BC2B5C /* AutoPlayHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A32EA9149E00BC2B5C /* AutoPlayHandler.swift */; }; EA698C032CCDD2FB0058BC19 /* EquatableVoid.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA698C022CCDD2FB0058BC19 /* EquatableVoid.swift */; }; EA698C092CCDE7090058BC19 /* IdentifiableBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA698C082CCDE7050058BC19 /* IdentifiableBox.swift */; }; + EA8C4D262F0E100100000001 /* DownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8C4D262F0E100100000011 /* DownloadClient.swift */; }; + EA8C4D262F0E100100000002 /* DownloadFileStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8C4D262F0E100100000012 /* DownloadFileStorage.swift */; }; + EA8C4D262F0E100100000003 /* DownloadedGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8C4D262F0E100100000013 /* DownloadedGallery.swift */; }; + EA8C4D262F0E100100000004 /* DownloadedGalleryMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8C4D262F0E100100000014 /* DownloadedGalleryMO+CoreDataClass.swift */; }; + EA8C4D262F0E100100000005 /* DownloadedGalleryMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8C4D262F0E100100000015 /* DownloadedGalleryMO+CoreDataProperties.swift */; }; + EAA100012F1D000100000001 /* DownloadsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D000100000011 /* DownloadsReducer.swift */; }; + EAA100012F1D000100000002 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D000100000012 /* DownloadsView.swift */; }; + EAA100012F1D000100000005 /* DownloadSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D000100000015 /* DownloadSettingView.swift */; }; + EAA100012F1D000100000006 /* DownloadBadgeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D000100000016 /* DownloadBadgeLabel.swift */; }; + EAA100012F1D000100000007 /* DownloadFiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D000100000017 /* DownloadFiltersView.swift */; }; + EAA100012F1D000100000008 /* DownloadBadgeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D000100000018 /* DownloadBadgeStore.swift */; }; + EAA100012F1D000100000019 /* PreviewImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D00010000001A /* PreviewImageView.swift */; }; + EAB100012F1E000100000001 /* DownloadPageErrorParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000011 /* DownloadPageErrorParserTests.swift */; }; + EAB100012F1E000100000002 /* SettingDownloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000012 /* SettingDownloadTests.swift */; }; + EAB100012F1E000100000003 /* DownloadFileStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000013 /* DownloadFileStorageTests.swift */; }; + EAB100012F1E000100000004 /* DownloadFeatureReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000014 /* DownloadFeatureReducerTests.swift */; }; + EAB100012F1E000100000005 /* BandwidthExceeded.html in Resources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000015 /* BandwidthExceeded.html */; }; + EAB100012F1E000100000006 /* ExLoginRequired.html in Resources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000016 /* ExLoginRequired.html */; }; + EAB100012F1E000100000007 /* DownloadSignatureBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000017 /* DownloadSignatureBuilderTests.swift */; }; + EAB100012F1E000100000018 /* Kokomade.jpg in Resources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000019 /* Kokomade.jpg */; }; EAE63E2129E2A6330048C601 /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = EAE63E2029E2A6330048C601 /* SwiftyBeaver */; }; /* End PBXBuildFile section */ @@ -608,6 +628,27 @@ EA5AA4A62EA9149E00BC2B5C /* PageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHandler.swift; sourceTree = ""; }; EA698C022CCDD2FB0058BC19 /* EquatableVoid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EquatableVoid.swift; sourceTree = ""; }; EA698C082CCDE7050058BC19 /* IdentifiableBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiableBox.swift; sourceTree = ""; }; + EA8C4D262F0E100100000011 /* DownloadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadClient.swift; sourceTree = ""; }; + EA8C4D262F0E100100000012 /* DownloadFileStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileStorage.swift; sourceTree = ""; }; + EA8C4D262F0E100100000013 /* DownloadedGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedGallery.swift; sourceTree = ""; }; + EA8C4D262F0E100100000014 /* DownloadedGalleryMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DownloadedGalleryMO+CoreDataClass.swift"; sourceTree = ""; }; + EA8C4D262F0E100100000015 /* DownloadedGalleryMO+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DownloadedGalleryMO+CoreDataProperties.swift"; sourceTree = ""; }; + EA8C4D262F0E100100000016 /* Model 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 8.xcdatamodel"; sourceTree = ""; }; + EAA100012F1D000100000011 /* DownloadsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsReducer.swift; sourceTree = ""; }; + EAA100012F1D000100000012 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; + EAA100012F1D000100000015 /* DownloadSettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadSettingView.swift; sourceTree = ""; }; + EAA100012F1D000100000016 /* DownloadBadgeLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadBadgeLabel.swift; sourceTree = ""; }; + EAA100012F1D000100000017 /* DownloadFiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFiltersView.swift; sourceTree = ""; }; + EAA100012F1D000100000018 /* DownloadBadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadBadgeStore.swift; sourceTree = ""; }; + EAA100012F1D00010000001A /* PreviewImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewImageView.swift; sourceTree = ""; }; + EAB100012F1E000100000011 /* DownloadPageErrorParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadPageErrorParserTests.swift; sourceTree = ""; }; + EAB100012F1E000100000012 /* SettingDownloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingDownloadTests.swift; sourceTree = ""; }; + EAB100012F1E000100000013 /* DownloadFileStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileStorageTests.swift; sourceTree = ""; }; + EAB100012F1E000100000014 /* DownloadFeatureReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFeatureReducerTests.swift; sourceTree = ""; }; + EAB100012F1E000100000015 /* BandwidthExceeded.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = BandwidthExceeded.html; sourceTree = ""; }; + EAB100012F1E000100000016 /* ExLoginRequired.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = ExLoginRequired.html; sourceTree = ""; }; + EAB100012F1E000100000017 /* DownloadSignatureBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadSignatureBuilderTests.swift; sourceTree = ""; }; + EAB100012F1E000100000019 /* Kokomade.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = Kokomade.jpg; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -664,6 +705,7 @@ AB0929C5278160AE00F107CA /* LibraryClient.swift */, AB0929C9278196ED00F107CA /* CookieClient.swift */, AB0929CD2781AADA00F107CA /* DatabaseClient.swift */, + EA8C4D262F0E100100000011 /* DownloadClient.swift */, ABBB266B2797E882007B6149 /* ClipboardClient.swift */, AB706F8F278A5F680025A48A /* AppDelegateClient.swift */, AB0929D32781EDDC00F107CA /* UserDefaultsClient.swift */, @@ -787,6 +829,8 @@ ABD9771227B6612400983DE7 /* GreetingParserTests.swift */, AB0CFB8127BBBFCE004BD372 /* EhSettingParserTests.swift */, AB31CD3127B6671400F40E0A /* BanIntervalParserTests.swift */, + EAB100012F1E000100000011 /* DownloadPageErrorParserTests.swift */, + EAB100012F1E000100000012 /* SettingDownloadTests.swift */, ); path = Other; sourceTree = ""; @@ -948,6 +992,8 @@ AB4FD2C0268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift */, AB10117F26986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift */, AB10117D26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift */, + EA8C4D262F0E100100000014 /* DownloadedGalleryMO+CoreDataClass.swift */, + EA8C4D262F0E100100000015 /* DownloadedGalleryMO+CoreDataProperties.swift */, ); path = MODefinition; sourceTree = ""; @@ -986,6 +1032,7 @@ ABA732DE25A852D800B3D9AB /* Filter.swift */, ABEA1FE525A9B40B002966B9 /* Setting.swift */, AB26F59527ACCA1800AB3468 /* AppEnv.swift */, + EA8C4D262F0E100100000013 /* DownloadedGallery.swift */, AB7BF2B627A9652F001865A3 /* Greeting.swift */, ); path = Persistent; @@ -1098,6 +1145,7 @@ isa = PBXGroup; children = ( ABD9770C27B65A5300983DE7 /* Parser */, + EAB100012F1E000100000021 /* Download */, ); path = Tests; sourceTree = ""; @@ -1205,6 +1253,7 @@ ABD49D66277EAC90003D1A07 /* URLUtil.swift */, AB7BF2CD27AA3E58001865A3 /* AppUtil.swift */, AB7BF2D527AA3F4C001865A3 /* FileUtil.swift */, + EA8C4D262F0E100100000012 /* DownloadFileStorage.swift */, AB7BF2CF27AA3E75001865A3 /* DeviceUtil.swift */, AB7BF2D127AA3EDC001865A3 /* HapticsUtil.swift */, AB7BF2D327AA3F12001865A3 /* CookieUtil.swift */, @@ -1230,6 +1279,9 @@ AB0CFB7F27BBBFA0004BD372 /* EhSetting.html */, ABC0A8D026F7037F008EC24C /* IPBanned.html */, ABF9720926DE6E1300118887 /* GalleryDetailWithGreeting.html */, + EAB100012F1E000100000015 /* BandwidthExceeded.html */, + EAB100012F1E000100000016 /* ExLoginRequired.html */, + EAB100012F1E000100000019 /* Kokomade.jpg */, ); path = Other; sourceTree = ""; @@ -1254,6 +1306,7 @@ ABF45AC025F3313D00ECB568 /* Home */, AB24C55F276757240085C33A /* Favorites */, AB86AC112783226100E61E6A /* Search */, + EAA100002F1D000100000001 /* Downloads */, ABF45AD125F3313D00ECB568 /* Detail */, ABF45ACF25F3313D00ECB568 /* Reading */, ABF45AD725F3313D00ECB568 /* Setting */, @@ -1280,6 +1333,9 @@ isa = PBXGroup; children = ( AB24C564276758D00085C33A /* Cells */, + EAA100012F1D000100000016 /* DownloadBadgeLabel.swift */, + EAA100012F1D000100000018 /* DownloadBadgeStore.swift */, + EAA100012F1D00010000001A /* PreviewImageView.swift */, AB7B29F526AC741600EE1F14 /* GenericList.swift */, ABD4032726B7967F00001B8C /* CategoryView.swift */, ABF45ACD25F3313D00ECB568 /* Placeholder.swift */, @@ -1420,6 +1476,7 @@ EA2E2E802A1F7F2A0038A261 /* Components */ = { isa = PBXGroup; children = ( + EAA100012F1D000100000015 /* DownloadSettingView.swift */, ABF45ADB25F3313D00ECB568 /* ReadingSettingView.swift */, ABE1867726A1733000689FDC /* LaboratorySettingView.swift */, AB86ABF82782EC0D00E61E6A /* AboutView.swift */, @@ -1527,6 +1584,26 @@ path = History; sourceTree = ""; }; + EAA100002F1D000100000001 /* Downloads */ = { + isa = PBXGroup; + children = ( + EAA100012F1D000100000012 /* DownloadsView.swift */, + EAA100012F1D000100000011 /* DownloadsReducer.swift */, + EAA100012F1D000100000017 /* DownloadFiltersView.swift */, + ); + path = Downloads; + sourceTree = ""; + }; + EAB100012F1E000100000021 /* Download */ = { + isa = PBXGroup; + children = ( + EAB100012F1E000100000013 /* DownloadFileStorageTests.swift */, + EAB100012F1E000100000014 /* DownloadFeatureReducerTests.swift */, + EAB100012F1E000100000017 /* DownloadSignatureBuilderTests.swift */, + ); + path = Download; + sourceTree = ""; + }; EAEC870B2A1F74D500E1A97A /* EhSetting */ = { isa = PBXGroup; children = ( @@ -1757,6 +1834,9 @@ AB41DB4B27B760D700DD3604 /* FrontPageThumbnailList.html in Resources */, AB41DB4327B760D700DD3604 /* ToplistsCompactList.html in Resources */, AB0CFB8027BBBFA0004BD372 /* EhSetting.html in Resources */, + EAB100012F1E000100000005 /* BandwidthExceeded.html in Resources */, + EAB100012F1E000100000006 /* ExLoginRequired.html in Resources */, + EAB100012F1E000100000018 /* Kokomade.jpg in Resources */, AB41DB5127B760D700DD3604 /* PopularMinimalList.html in Resources */, AB41DB4627B760D700DD3604 /* FrontPageCompactList.html in Resources */, AB41DB4127B760D700DD3604 /* PopularExtendedList.html in Resources */, @@ -1925,6 +2005,14 @@ AB4FD2C1268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift in Sources */, AB26F59427ACC6CD00AB3468 /* TagTranslator.swift in Sources */, AB0929CE2781AADA00F107CA /* DatabaseClient.swift in Sources */, + EA8C4D262F0E100100000001 /* DownloadClient.swift in Sources */, + EAA100012F1D000100000001 /* DownloadsReducer.swift in Sources */, + EAA100012F1D000100000002 /* DownloadsView.swift in Sources */, + EAA100012F1D000100000005 /* DownloadSettingView.swift in Sources */, + EAA100012F1D000100000006 /* DownloadBadgeLabel.swift in Sources */, + EAA100012F1D000100000007 /* DownloadFiltersView.swift in Sources */, + EAA100012F1D000100000008 /* DownloadBadgeStore.swift in Sources */, + EAA100012F1D000100000019 /* PreviewImageView.swift in Sources */, AB6DE897268822390087C579 /* LogsView.swift in Sources */, AB7BF31D27ABE028001865A3 /* FileManager+ApplicationSupport.swift in Sources */, AB706F7B278937500025A48A /* FrontpageReducer.swift in Sources */, @@ -1932,6 +2020,8 @@ AB7B29F226AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift in Sources */, AB706F7927890A6C0025A48A /* AppRouteReducer.swift in Sources */, AB706F88278A4C8A0025A48A /* PopularView.swift in Sources */, + EA8C4D262F0E100100000005 /* DownloadedGalleryMO+CoreDataProperties.swift in Sources */, + EA8C4D262F0E100100000003 /* DownloadedGallery.swift in Sources */, AB706FA1278BCEC60025A48A /* DetailView.swift in Sources */, ABE9401526FF158D0085E158 /* QuickSearchView.swift in Sources */, AB706F9B278AC5A30025A48A /* SearchRootView.swift in Sources */, @@ -1943,6 +2033,8 @@ ABBD2B602768D7AD0072AED2 /* GalleryRankingCell.swift in Sources */, ABBB263A2792588F007B6149 /* TTProgressHUD_Extension.swift in Sources */, AB7BF2D627AA3F4C001865A3 /* FileUtil.swift in Sources */, + EA8C4D262F0E100100000004 /* DownloadedGalleryMO+CoreDataClass.swift in Sources */, + EA8C4D262F0E100100000002 /* DownloadFileStorage.swift in Sources */, AB0ABCB726C541A400AD970F /* WaveForm.swift in Sources */, AB0929D62782A65F00F107CA /* GeneralSettingReducer.swift in Sources */, AB706FA3278BCF2F0025A48A /* DetailReducer.swift in Sources */, @@ -2012,6 +2104,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + EAB100012F1E000100000003 /* DownloadFileStorageTests.swift in Sources */, + EAB100012F1E000100000004 /* DownloadFeatureReducerTests.swift in Sources */, + EAB100012F1E000100000007 /* DownloadSignatureBuilderTests.swift in Sources */, AB31CD3D27B66F7D00F40E0A /* GalleryImageURLParserTests.swift in Sources */, AB0CFB8227BBBFCE004BD372 /* EhSettingParserTests.swift in Sources */, AB31CD4327B676C300F40E0A /* GalleryMPVKeysParserTests.swift in Sources */, @@ -2019,10 +2114,12 @@ ABD9771027B65E3400983DE7 /* GalleryDetailParserTests.swift in Sources */, AB31CD3227B6671400F40E0A /* BanIntervalParserTests.swift in Sources */, ABD9771327B6612400983DE7 /* GreetingParserTests.swift in Sources */, + EAB100012F1E000100000001 /* DownloadPageErrorParserTests.swift in Sources */, AB31CD3727B6695800F40E0A /* HTMLFilename.swift in Sources */, AB3E9E7426D210B1008FE518 /* TestHelper.swift in Sources */, AB31CD3B27B66E0300F40E0A /* ListParserTestType.swift in Sources */, ABD9770E27B65A7300983DE7 /* ListParserTests.swift in Sources */, + EAB100012F1E000100000002 /* SettingDownloadTests.swift in Sources */, ABAB5B9527EF023300198597 /* Extensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2088,10 +2185,10 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = 2U4DN3V26P; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2104,7 +2201,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -2116,10 +2213,10 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = 2U4DN3V26P; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2132,7 +2229,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -2267,11 +2364,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 157; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = 2U4DN3V26P; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -2282,7 +2379,7 @@ OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = App_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2296,11 +2393,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 157; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = 2U4DN3V26P; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -2311,7 +2408,7 @@ OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = App_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2323,9 +2420,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = 2U4DN3V26P; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2337,7 +2435,6 @@ PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.tests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2350,9 +2447,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = 2U4DN3V26P; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2364,7 +2462,6 @@ PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.tests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2631,6 +2728,7 @@ ABC681F126898D46007BBD69 /* Model.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + EA8C4D262F0E100100000016 /* Model 8.xcdatamodel */, AB41DB5227B7EC5500DD3604 /* Model 7.xcdatamodel */, AB706F93278A6F2B0025A48A /* Model 6.xcdatamodel */, ABC4A07A2753084100968A4F /* Model 5.xcdatamodel */, @@ -2639,7 +2737,7 @@ AB48BCF626D2539B0021A06C /* Model 2.xcdatamodel */, ABC681F226898D46007BBD69 /* Model.xcdatamodel */, ); - currentVersion = AB41DB5227B7EC5500DD3604 /* Model 7.xcdatamodel */; + currentVersion = EA8C4D262F0E100100000016 /* Model 8.xcdatamodel */; path = Model.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 1af4a1778..a7b3500f6 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -301,6 +301,45 @@ internal enum L10n { internal static let archives = L10n.tr("Localizable", "archives_view.title.archives", fallback: "Archives") } } + internal enum AppError { + internal enum Alert { + /// Login required to access this download. + internal static let authenticationRequired = L10n.tr("Localizable", "app_error.alert.authentication_required", fallback: "Login required to access this download.") + /// Local file operation failed. + internal static let localFileOperationFailed = L10n.tr("Localizable", "app_error.alert.local_file_operation_failed", fallback: "Local file operation failed.") + /// Image quota exceeded. + /// Please wait and try again later. + internal static let quotaExceeded = L10n.tr("Localizable", "app_error.alert.quota_exceeded", fallback: "Image quota exceeded.\nPlease wait and try again later.") + } + internal enum LocalizedDescription { + /// Authentication Required + internal static let authenticationRequired = L10n.tr("Localizable", "app_error.localized_description.authentication_required", fallback: "Authentication Required") + /// Copyright Claim + internal static let copyrightClaim = L10n.tr("Localizable", "app_error.localized_description.copyright_claim", fallback: "Copyright Claim") + /// Database Corrupted + internal static let databaseCorrupted = L10n.tr("Localizable", "app_error.localized_description.database_corrupted", fallback: "Database Corrupted") + /// File Operation Failed + internal static let fileOperationFailed = L10n.tr("Localizable", "app_error.localized_description.file_operation_failed", fallback: "File Operation Failed") + /// Gallery Expunged + internal static let galleryExpunged = L10n.tr("Localizable", "app_error.localized_description.gallery_expunged", fallback: "Gallery Expunged") + /// IP Banned + internal static let ipBanned = L10n.tr("Localizable", "app_error.localized_description.ip_banned", fallback: "IP Banned") + /// Network Error + internal static let networkError = L10n.tr("Localizable", "app_error.localized_description.network_error", fallback: "Network Error") + /// No updates available + internal static let noUpdatesAvailable = L10n.tr("Localizable", "app_error.localized_description.no_updates_available", fallback: "No updates available") + /// Not found + internal static let notFound = L10n.tr("Localizable", "app_error.localized_description.not_found", fallback: "Not found") + /// Parse Error + internal static let parseError = L10n.tr("Localizable", "app_error.localized_description.parse_error", fallback: "Parse Error") + /// Quota Exceeded + internal static let quotaExceeded = L10n.tr("Localizable", "app_error.localized_description.quota_exceeded", fallback: "Quota Exceeded") + /// Unknown Error + internal static let unknownError = L10n.tr("Localizable", "app_error.localized_description.unknown_error", fallback: "Unknown Error") + /// Web image loading error + internal static let webImageLoadingError = L10n.tr("Localizable", "app_error.localized_description.web_image_loading_error", fallback: "Web image loading error") + } + } internal enum CommentsView { internal enum Title { /// Comments @@ -308,6 +347,10 @@ internal enum L10n { } } internal enum Common { + internal enum Button { + /// Cancel + internal static let cancel = L10n.tr("Localizable", "common.button.cancel", fallback: "Cancel") + } internal enum Value { /// %@ day internal static func day(_ p1: Any) -> String { @@ -400,11 +443,93 @@ internal enum L10n { } } internal enum Button { + /// DONE + internal static let downloadDone = L10n.tr("Localizable", "detail_view.button.download_done", fallback: "DONE") + /// GET + internal static let downloadGet = L10n.tr("Localizable", "detail_view.button.download_get", fallback: "GET") + /// LOG IN + internal static let downloadLogin = L10n.tr("Localizable", "detail_view.button.download_login", fallback: "LOG IN") + /// REPAIR + internal static let downloadRepair = L10n.tr("Localizable", "detail_view.button.download_repair", fallback: "REPAIR") + /// RETRY + internal static let downloadRetry = L10n.tr("Localizable", "detail_view.button.download_retry", fallback: "RETRY") + /// UPDATE + internal static let downloadUpdate = L10n.tr("Localizable", "detail_view.button.download_update", fallback: "UPDATE") + /// WAIT + internal static let downloadWait = L10n.tr("Localizable", "detail_view.button.download_wait", fallback: "WAIT") /// Post comment internal static let postComment = L10n.tr("Localizable", "detail_view.button.post_comment", fallback: "Post comment") /// Read internal static let read = L10n.tr("Localizable", "detail_view.button.read", fallback: "Read") } + internal enum Accessibility { + /// Download + internal static let downloadButtonDownload = L10n.tr("Localizable", "detail_view.accessibility.download_button.download", fallback: "Download") + /// Delete downloaded gallery + internal static let downloadButtonDownloaded = L10n.tr("Localizable", "detail_view.accessibility.download_button.downloaded", fallback: "Delete downloaded gallery") + /// Downloading %d of %d + internal static func downloadButtonDownloading(_ p1: Int, _ p2: Int) -> String { + L10n.tr("Localizable", "detail_view.accessibility.download_button.downloading", p1, p2, fallback: "Downloading %d of %d") + } + /// Log in to download + internal static let downloadButtonLogin = L10n.tr("Localizable", "detail_view.accessibility.download_button.login", fallback: "Log in to download") + /// Preparing download + internal static let downloadButtonPreparing = L10n.tr("Localizable", "detail_view.accessibility.download_button.preparing", fallback: "Preparing download") + /// Queued + internal static let downloadButtonQueued = L10n.tr("Localizable", "detail_view.accessibility.download_button.queued", fallback: "Queued") + /// Retry download. %d of %d pages are already available. + internal static func downloadButtonPartial(_ p1: Int, _ p2: Int) -> String { + L10n.tr("Localizable", "detail_view.accessibility.download_button.partial", p1, p2, fallback: "Retry download. %d of %d pages are already available.") + } + /// Pause download + internal static let downloadButtonPauseAction = L10n.tr("Localizable", "detail_view.accessibility.download_button.pause_action", fallback: "Pause download") + /// Resume download. Paused at %d of %d + internal static func downloadButtonPaused(_ p1: Int, _ p2: Int) -> String { + L10n.tr("Localizable", "detail_view.accessibility.download_button.paused", p1, p2, fallback: "Resume download. Paused at %d of %d") + } + /// Repair download + internal static let downloadButtonRepair = L10n.tr("Localizable", "detail_view.accessibility.download_button.repair", fallback: "Repair download") + /// Retry download + internal static let downloadButtonRetry = L10n.tr("Localizable", "detail_view.accessibility.download_button.retry", fallback: "Retry download") + /// Update download + internal static let downloadButtonUpdate = L10n.tr("Localizable", "detail_view.accessibility.download_button.update", fallback: "Update download") + } + internal enum Dialog { + internal enum Button { + /// Redownload + internal static let redownload = L10n.tr("Localizable", "detail_view.dialog.button.redownload", fallback: "Redownload") + /// Repair + internal static let repair = L10n.tr("Localizable", "detail_view.dialog.button.repair", fallback: "Repair") + /// Update + internal static let update = L10n.tr("Localizable", "detail_view.dialog.button.update", fallback: "Update") + } + internal enum Message { + /// This will stop the current download and remove the gallery from this device. + internal static let deleteActiveDownload = L10n.tr("Localizable", "detail_view.dialog.message.delete_active_download", fallback: "This will stop the current download and remove the gallery from this device.") + /// This will remove the downloaded gallery from this device. + internal static let deleteDownloadedGallery = L10n.tr("Localizable", "detail_view.dialog.message.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") + /// Repair the offline files for this gallery now? + internal static let repairDownload = L10n.tr("Localizable", "detail_view.dialog.message.repair_download", fallback: "Repair the offline files for this gallery now?") + /// Start a fresh download for this gallery now? + internal static let redownloadGallery = L10n.tr("Localizable", "detail_view.dialog.message.redownload_gallery", fallback: "Start a fresh download for this gallery now?") + /// Update this gallery to the newest online version now? + internal static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.message.update_download", fallback: "Update this gallery to the newest online version now?") + } + internal enum Title { + /// Delete Download? + internal static let deleteDownload = L10n.tr("Localizable", "detail_view.dialog.title.delete_download", fallback: "Delete Download?") + /// Repair Download? + internal static let repairDownload = L10n.tr("Localizable", "detail_view.dialog.title.repair_download", fallback: "Repair Download?") + /// Redownload Gallery? + internal static let redownloadGallery = L10n.tr("Localizable", "detail_view.dialog.title.redownload_gallery", fallback: "Redownload Gallery?") + /// Update Download? + internal static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.title.update_download", fallback: "Update Download?") + } + } + internal enum OfflineNotice { + /// Couldn't refresh online details. Showing saved details instead. + internal static let savedDetails = L10n.tr("Localizable", "detail_view.offline_notice.saved_details", fallback: "Couldn't refresh online details. Showing saved details instead.") + } internal enum ContextMenu { internal enum Button { /// Detail @@ -458,6 +583,130 @@ internal enum L10n { } } } + internal enum DownloadFileStorage { + internal enum Error { + /// Asset file is unreadable: %@ + internal static func assetUnreadable(_ p1: Any) -> String { + return L10n.tr("Localizable", "download_file_storage.error.asset_unreadable", String(describing: p1), fallback: "Asset file is unreadable: %@") + } + } + internal enum Validation { + /// Cover image is missing. + internal static let coverImageMissing = L10n.tr("Localizable", "download_file_storage.validation.cover_image_missing", fallback: "Cover image is missing.") + /// Download folder is missing. + internal static let downloadFolderMissing = L10n.tr("Localizable", "download_file_storage.validation.download_folder_missing", fallback: "Download folder is missing.") + /// Download folder could not be resolved. + internal static let downloadFolderUnresolved = L10n.tr("Localizable", "download_file_storage.validation.download_folder_unresolved", fallback: "Download folder could not be resolved.") + /// Downloaded pages are incomplete. + internal static let downloadedPagesIncomplete = L10n.tr("Localizable", "download_file_storage.validation.downloaded_pages_incomplete", fallback: "Downloaded pages are incomplete.") + /// Manifest file is corrupted. + internal static let manifestCorrupted = L10n.tr("Localizable", "download_file_storage.validation.manifest_corrupted", fallback: "Manifest file is corrupted.") + /// Manifest file is missing. + internal static let manifestMissing = L10n.tr("Localizable", "download_file_storage.validation.manifest_missing", fallback: "Manifest file is missing.") + /// Page %d is missing. + internal static func pageMissing(_ p1: Int) -> String { + L10n.tr("Localizable", "download_file_storage.validation.page_missing", p1, fallback: "Page %d is missing.") + } + } + } + internal enum DownloadSettingView { + internal enum Footer { + /// Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder. + internal static let network = L10n.tr("Localizable", "download_setting_view.footer.network", fallback: "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder.") + } + internal enum Section { + internal enum Title { + /// Download Queue + internal static let downloadQueue = L10n.tr("Localizable", "download_setting_view.section.title.download_queue", fallback: "Download Queue") + /// Network + internal static let network = L10n.tr("Localizable", "download_setting_view.section.title.network", fallback: "Network") + } + } + internal enum Title { + /// Allow cellular downloads + internal static let allowCellularDownloads = L10n.tr("Localizable", "download_setting_view.title.allow_cellular_downloads", fallback: "Allow cellular downloads") + /// Concurrent image downloads + internal static let concurrentImageDownloads = L10n.tr("Localizable", "download_setting_view.title.concurrent_image_downloads", fallback: "Concurrent image downloads") + /// Retry failed pages automatically + internal static let retryFailedPagesAutomatically = L10n.tr("Localizable", "download_setting_view.title.retry_failed_pages_automatically", fallback: "Retry failed pages automatically") + } + } + internal enum DownloadsView { + internal enum Button { + /// Clear Filters + internal static let clearFilters = L10n.tr("Localizable", "downloads_view.button.clear_filters", fallback: "Clear Filters") + } + internal enum Dialog { + internal enum Message { + /// This will cancel the current download and remove it from this device. + internal static let deleteActiveDownload = L10n.tr("Localizable", "downloads_view.dialog.message.delete_active_download", fallback: "This will cancel the current download and remove it from this device.") + /// This will remove the downloaded gallery from this device. + internal static let deleteDownloadedGallery = L10n.tr("Localizable", "downloads_view.dialog.message.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") + } + internal enum Title { + /// Delete Download? + internal static let deleteDownload = L10n.tr("Localizable", "downloads_view.dialog.title.delete_download", fallback: "Delete Download?") + } + } + internal enum EmptyState { + /// Downloaded galleries will appear here. + internal static let downloads = L10n.tr("Localizable", "downloads_view.empty_state.downloads", fallback: "Downloaded galleries will appear here.") + /// No downloads match the current filters. + internal static let noMatchingFilters = L10n.tr("Localizable", "downloads_view.empty_state.no_matching_filters", fallback: "No downloads match the current filters.") + } + internal enum Inspector { + internal enum Button { + /// Retry Failed Pages (%d) + internal static func retryFailedPages(_ p1: Int) -> String { + L10n.tr("Localizable", "downloads_view.inspector.button.retry_failed_pages", p1, fallback: "Retry Failed Pages (%d)") + } + /// Update Download + internal static let updateDownload = L10n.tr("Localizable", "downloads_view.inspector.button.update_download", fallback: "Update Download") + } + internal enum Page { + /// Pending + internal static let pending = L10n.tr("Localizable", "downloads_view.inspector.page.pending", fallback: "Pending") + /// Tap to retry this page + internal static let tapToRetry = L10n.tr("Localizable", "downloads_view.inspector.page.tap_to_retry", fallback: "Tap to retry this page") + /// Page %d + internal static func title(_ p1: Int) -> String { + L10n.tr("Localizable", "downloads_view.inspector.page.title", p1, fallback: "Page %d") + } + } + internal enum Section { + /// Actions + internal static let actions = L10n.tr("Localizable", "downloads_view.inspector.section.actions", fallback: "Actions") + /// Pages + internal static let pages = L10n.tr("Localizable", "downloads_view.inspector.section.pages", fallback: "Pages") + } + internal enum Title { + /// Download Status + internal static let downloadStatus = L10n.tr("Localizable", "downloads_view.inspector.title.download_status", fallback: "Download Status") + } + } + internal enum Search { + internal enum Prompt { + /// Search downloads + internal static let downloads = L10n.tr("Localizable", "downloads_view.search.prompt.downloads", fallback: "Search downloads") + } + } + internal enum Swipe { + internal enum Button { + /// Pages + internal static let pages = L10n.tr("Localizable", "downloads_view.swipe.button.pages", fallback: "Pages") + /// Pause + internal static let pause = L10n.tr("Localizable", "downloads_view.swipe.button.pause", fallback: "Pause") + /// Resume + internal static let resume = L10n.tr("Localizable", "downloads_view.swipe.button.resume", fallback: "Resume") + /// Update + internal static let update = L10n.tr("Localizable", "downloads_view.swipe.button.update", fallback: "Update") + } + } + internal enum Title { + /// Downloads + internal static let downloads = L10n.tr("Localizable", "downloads_view.title.downloads", fallback: "Downloads") + } + } internal enum EhSettingView { internal enum Button { /// Create new @@ -693,6 +942,34 @@ internal enum L10n { internal static let off = L10n.tr("Localizable", "enum.auto_play_policy.value.off", fallback: "Off") } } + internal enum DownloadListFilter { + internal enum Title { + /// Active + internal static let active = L10n.tr("Localizable", "enum.download_list_filter.title.active", fallback: "Active") + /// All + internal static let all = L10n.tr("Localizable", "enum.download_list_filter.title.all", fallback: "All") + /// Downloaded + internal static let completed = L10n.tr("Localizable", "enum.download_list_filter.title.completed", fallback: "Downloaded") + /// Needs Attention + internal static let failed = L10n.tr("Localizable", "enum.download_list_filter.title.failed", fallback: "Needs Attention") + /// Update Available + internal static let update = L10n.tr("Localizable", "enum.download_list_filter.title.update", fallback: "Update Available") + } + } + internal enum DownloadThreadMode { + internal enum Value { + /// 2 images at a time + internal static let double = L10n.tr("Localizable", "enum.download_thread_mode.value.double", fallback: "2 images at a time") + /// 4 images at a time + internal static let quadruple = L10n.tr("Localizable", "enum.download_thread_mode.value.quadruple", fallback: "4 images at a time") + /// 5 images at a time + internal static let quintuple = L10n.tr("Localizable", "enum.download_thread_mode.value.quintuple", fallback: "5 images at a time") + /// 1 image at a time + internal static let single = L10n.tr("Localizable", "enum.download_thread_mode.value.single", fallback: "1 image at a time") + /// 3 images at a time + internal static let triple = L10n.tr("Localizable", "enum.download_thread_mode.value.triple", fallback: "3 images at a time") + } + } internal enum BanInterval { internal enum Description { /// Localizable.strings @@ -1602,6 +1879,8 @@ internal enum L10n { internal static let appearance = L10n.tr("Localizable", "enum.setting_state_route.value.appearance", fallback: "Appearance") /// General internal static let general = L10n.tr("Localizable", "enum.setting_state_route.value.general", fallback: "General") + /// Downloads + internal static let downloads = L10n.tr("Localizable", "enum.setting_state_route.value.downloads", fallback: "Downloads") /// Laboratory internal static let laboratory = L10n.tr("Localizable", "enum.setting_state_route.value.laboratory", fallback: "Laboratory") /// Reading @@ -2119,6 +2398,42 @@ internal enum L10n { internal static let notAvailable = L10n.tr("Localizable", "struct.hath_archive.price.not_available", fallback: "N/A") } } + internal enum DownloadBadge { + internal enum Compact { + /// DL + internal static let downloading = L10n.tr("Localizable", "struct.download_badge.compact.downloading", fallback: "DL") + /// Done + internal static let done = L10n.tr("Localizable", "struct.download_badge.compact.done", fallback: "Done") + /// Needs Attention + internal static let needsAttention = L10n.tr("Localizable", "struct.download_badge.compact.needs_attention", fallback: "Needs Attention") + /// Pause + internal static let paused = L10n.tr("Localizable", "struct.download_badge.compact.paused", fallback: "Pause") + } + internal enum Text { + /// Downloaded + internal static let downloaded = L10n.tr("Localizable", "struct.download_badge.text.downloaded", fallback: "Downloaded") + /// Downloading %d/%d + internal static func downloading(_ p1: Int, _ p2: Int) -> String { + L10n.tr("Localizable", "struct.download_badge.text.downloading", p1, p2, fallback: "Downloading %d/%d") + } + /// Needs Attention + internal static let needsAttention = L10n.tr("Localizable", "struct.download_badge.text.needs_attention", fallback: "Needs Attention") + /// Needs Attention %d/%d + internal static func needsAttentionProgress(_ p1: Int, _ p2: Int) -> String { + L10n.tr("Localizable", "struct.download_badge.text.needs_attention_progress", p1, p2, fallback: "Needs Attention %d/%d") + } + /// Needs Repair + internal static let needsRepair = L10n.tr("Localizable", "struct.download_badge.text.needs_repair", fallback: "Needs Repair") + /// Paused %d/%d + internal static func paused(_ p1: Int, _ p2: Int) -> String { + L10n.tr("Localizable", "struct.download_badge.text.paused", p1, p2, fallback: "Paused %d/%d") + } + /// Queued + internal static let queued = L10n.tr("Localizable", "struct.download_badge.text.queued", fallback: "Queued") + /// Update Available + internal static let updateAvailable = L10n.tr("Localizable", "struct.download_badge.text.update_available", fallback: "Update Available") + } + } internal enum User { internal enum FavoriteCategory { /// All @@ -2138,6 +2453,8 @@ internal enum L10n { } internal enum TabItem { internal enum Title { + /// Downloads + internal static let downloads = L10n.tr("Localizable", "tab_item.title.downloads", fallback: "Downloads") /// Favorites internal static let favorites = L10n.tr("Localizable", "tab_item.title.favorites", fallback: "Favorites") /// Home diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift index b2d89ff4b..cc6a8d3fa 100644 --- a/EhPanda/App/Tools/Clients/CookieClient.swift +++ b/EhPanda/App/Tools/Clients/CookieClient.swift @@ -30,8 +30,8 @@ extension CookieClient { guard let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty else { return value } cookies.forEach { cookie in - guard let expiresDate = cookie.expiresDate, cookie.name == key && !cookie.value.isEmpty else { return } - guard expiresDate > .now else { + guard cookie.name == key && !cookie.value.isEmpty else { return } + guard cookie.expiresDate == nil || cookie.expiresDate! > .now else { value = CookieValue( rawValue: "", localizedString: L10n.Localizable.Struct.CookieValue.LocalizedString.expired ) @@ -79,16 +79,65 @@ extension CookieClient { // MARK: Foundation extension CookieClient { + func importAutomationCookies(memberID: String, passHash: String, igneous: String?) { + let urls = [Defaults.URL.ehentai, Defaults.URL.exhentai, Defaults.URL.sexhentai] + let authKeys = [Defaults.Cookie.ipbMemberId, Defaults.Cookie.ipbPassHash] + + urls.forEach { url in + authKeys.forEach { key in + removeCookie(url, key) + } + } + [Defaults.URL.exhentai, Defaults.URL.sexhentai].forEach { url in + removeCookie(url, Defaults.Cookie.igneous) + } + + urls.forEach { url in + setCookie( + for: url, + key: Defaults.Cookie.ipbMemberId, + value: memberID, + sessionOnly: true + ) + setCookie( + for: url, + key: Defaults.Cookie.ipbPassHash, + value: passHash, + sessionOnly: true + ) + } + + if let igneous, igneous.notEmpty { + [Defaults.URL.exhentai, Defaults.URL.sexhentai].forEach { url in + setCookie( + for: url, + key: Defaults.Cookie.igneous, + value: igneous, + sessionOnly: true + ) + } + } + + ignoreOffensive() + fulfillAnotherHostField() + } + private func setCookie( for url: URL, key: String, value: String, path: String = "/", - expiresTime: TimeInterval = .oneYear + expiresTime: TimeInterval = .oneYear, + sessionOnly: Bool = false ) { - let expiredDate = Date(timeIntervalSinceNow: expiresTime) let properties: [HTTPCookiePropertyKey: Any] = [ .path: path, .name: key, .value: value, - .originURL: url, .expires: expiredDate + .originURL: url ] - if let cookie = HTTPCookie(properties: properties) { + var mutableProperties = properties + if sessionOnly { + mutableProperties[.discard] = "TRUE" + } else { + mutableProperties[.expires] = Date(timeIntervalSinceNow: expiresTime) + } + if let cookie = HTTPCookie(properties: mutableProperties) { HTTPCookieStorage.shared.setCookie(cookie) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift new file mode 100644 index 000000000..e8eb63528 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -0,0 +1,3941 @@ +// +// DownloadClient.swift +// EhPanda +// + +import Kanna +import CryptoKit +import CoreData +import Foundation +import ImageIO +import Kingfisher +import ComposableArchitecture + +struct DownloadClient { + let observeDownloads: () -> AsyncStream<[DownloadedGallery]> + let fetchDownloads: () async -> [DownloadedGallery] + let fetchDownload: (String) async -> DownloadedGallery? + let reconcileDownloads: () async -> Void + let refreshDownloads: () async -> Void + let resumeQueue: () async -> Void + let badges: ([String]) async -> [String: DownloadBadge] + let updateRemoteSignature: (String, String?) async -> DownloadBadge + let enqueue: (DownloadRequestPayload) async -> Result + let togglePause: (String) async -> Result + let retry: (String, DownloadStartMode) async -> Result + let retryPages: (String, [Int]) async -> Result + let delete: (String) async -> Result + let loadManifest: (String) async -> Result<(DownloadedGallery, DownloadManifest), AppError> + let loadLocalPageURLs: (String) async -> Result<[Int: URL], AppError> + let captureCachedPage: (String, Int, URL?) async -> Void + let loadInspection: (String) async -> Result + + init( + observeDownloads: @escaping () -> AsyncStream<[DownloadedGallery]>, + fetchDownloads: @escaping () async -> [DownloadedGallery], + fetchDownload: @escaping (String) async -> DownloadedGallery?, + reconcileDownloads: @escaping () async -> Void = {}, + refreshDownloads: @escaping () async -> Void, + resumeQueue: @escaping () async -> Void, + badges: @escaping ([String]) async -> [String: DownloadBadge], + updateRemoteSignature: @escaping (String, String?) async -> DownloadBadge, + enqueue: @escaping (DownloadRequestPayload) async -> Result, + togglePause: @escaping (String) async -> Result, + retry: @escaping (String, DownloadStartMode) async -> Result, + retryPages: @escaping (String, [Int]) async -> Result = { _, _ in .success(()) }, + delete: @escaping (String) async -> Result, + loadManifest: @escaping (String) async -> Result<(DownloadedGallery, DownloadManifest), AppError>, + loadLocalPageURLs: @escaping (String) async -> Result<[Int: URL], AppError> = { _ in .failure(.notFound) }, + captureCachedPage: @escaping (String, Int, URL?) async -> Void = { _, _, _ in }, + loadInspection: @escaping (String) async -> Result = { _ in .failure(.notFound) } + ) { + self.observeDownloads = observeDownloads + self.fetchDownloads = fetchDownloads + self.fetchDownload = fetchDownload + self.reconcileDownloads = reconcileDownloads + self.refreshDownloads = refreshDownloads + self.resumeQueue = resumeQueue + self.badges = badges + self.updateRemoteSignature = updateRemoteSignature + self.enqueue = enqueue + self.togglePause = togglePause + self.retry = retry + self.retryPages = retryPages + self.delete = delete + self.loadManifest = loadManifest + self.loadLocalPageURLs = loadLocalPageURLs + self.captureCachedPage = captureCachedPage + self.loadInspection = loadInspection + } +} + +extension DownloadClient { + static func live( + rootURL: URL? = FileUtil.downloadsDirectoryURL, + urlSession: URLSession = .shared, + fileManager: FileManager = .default + ) -> Self { + let manager = DownloadManager( + storage: .init(rootURL: rootURL, fileManager: fileManager), + urlSession: urlSession + ) + Task { + await manager.reconcileDownloads() + await manager.resumeQueue() + } + return .init( + observeDownloads: { + AsyncStream { continuation in + let task = Task { + let stream = await manager.observeDownloads() + for await downloads in stream { + continuation.yield(downloads) + } + continuation.finish() + } + continuation.onTermination = { _ in + task.cancel() + } + } + }, + fetchDownloads: { + await manager.fetchDownloads() + }, + fetchDownload: { gid in + await manager.fetchDownload(gid: gid) + }, + reconcileDownloads: { + await manager.reconcileDownloads() + }, + refreshDownloads: { + await manager.refreshDownloads() + }, + resumeQueue: { + await manager.resumeQueue() + }, + badges: { gids in + await manager.badges(for: gids) + }, + updateRemoteSignature: { gid, signature in + await manager.updateRemoteSignature(gid: gid, latestSignature: signature) + }, + enqueue: { payload in + await manager.enqueue(payload: payload) + }, + togglePause: { gid in + await manager.togglePause(gid: gid) + }, + retry: { gid, mode in + await manager.retry(gid: gid, mode: mode) + }, + retryPages: { gid, pageIndices in + await manager.retryPages(gid: gid, pageIndices: pageIndices) + }, + delete: { gid in + await manager.delete(gid: gid) + }, + loadManifest: { gid in + await manager.loadManifest(gid: gid) + }, + loadLocalPageURLs: { gid in + await manager.loadLocalPageURLs(gid: gid) + }, + captureCachedPage: { gid, index, imageURL in + await manager.captureCachedPage( + gid: gid, + index: index, + imageURL: imageURL + ) + }, + loadInspection: { gid in + await manager.loadInspection(gid: gid) + } + ) + } +} + +actor DownloadManager { + private static let retryLimit = 3 + private static let progressFlushPageInterval = 8 + private static let progressFlushMinimumInterval: TimeInterval = 0.4 + private static let responseInspectionPrefixLength = 4096 + private static let kokomadeImageByteCount = 144844 + private static let kokomadeImageSHA1 = "e48ed350e902a51581246d2a764fa7827e8e6988" + private static let kokomadeImageURLSuffixes = [ + "exhentai.org/img/kokomade.jpg" + ] + private static let quotaExceededImageByteCount = 28658 + private static let quotaExceededImageSHA1 = "f54b887b017694dc25eb1a1404f71981885f8ed9" + private static let quotaExceededImageURLSuffixes = [ + "exhentai.org/img/509.gif", + "ehgt.org/g/509.gif" + ] + + private struct PageResult: Sendable { + let index: Int + let relativePath: String + let imageURL: URL? + } + + private struct PageFailure: Error, Sendable { + let index: Int + let relativePath: String? + let error: AppError + } + + private struct DownloadBatchResult: Sendable { + let pages: [PageResult] + let failedPages: [DownloadFailedPagesSnapshot.Page] + } + + private enum PageTaskOutcome: Sendable { + case success(PageResult) + case failure(PageFailure) + case cancelled + } + + private struct RepairSeed: Sendable { + let folderURL: URL + let manifest: DownloadManifest + } + + private struct WorkingSeed: Sendable { + let folderURL: URL + let manifest: DownloadManifest? + let existingPages: [Int: String] + let coverRelativePath: String? + } + + private enum ResolvedSource: Sendable { + case normal([Int: URL]) + case mpv(String, [Int: String]) + } + + private struct ResolvedImageSource: Sendable { + let imageURL: URL + } + + private struct CachedGalleryImageState: Sendable { + let previewURLs: [Int: URL] + let imageURLs: [Int: URL] + } + + private struct PartialDownloadError: Error, Sendable { + let failedPages: [DownloadFailedPagesSnapshot.Page] + } + + private let storage: DownloadFileStorage + private let urlSession: URLSession + private var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() + private var lastObservedDownloads = [DownloadedGallery]() + private var activeGalleryID: String? + private var activeTask: Task? + private var schedulingBlockedGalleryIDs = Set() + + init(storage: DownloadFileStorage, urlSession: URLSession) { + self.storage = storage + self.urlSession = urlSession + } + + func observeDownloads() -> AsyncStream<[DownloadedGallery]> { + let identifier = UUID() + return AsyncStream { continuation in + continuation.onTermination = { [weak self] _ in + guard let self else { return } + Task { + await self.removeObserver(id: identifier) + } + } + Task { + await self.addObserver(id: identifier, continuation: continuation) + } + } + } + + func fetchDownloads() async -> [DownloadedGallery] { + sortDownloads(await fetchDownloadsFromStore()) + } + + func reconcileDownloads() async { + await syncDownloadsState(scheduleNext: false) + } + + func refreshDownloads() async { + await syncDownloadsState(scheduleNext: true) + } + + private func syncDownloadsState(scheduleNext: Bool) async { + let downloads = await fetchDownloadsFromStore() + // Normalize legacy failures before temp cleanup so recoverable working sets are not + // deleted just because older records still say `.failed`. + await normalizeNeedsAttentionDownloads(downloads) + await normalizeInterruptedDownloads(downloads) + + let normalizedDownloads = await fetchDownloadsFromStore() + do { + try storage.ensureRootDirectory() + try storage.cleanupTemporaryFolders( + preservingGIDs: Set( + normalizedDownloads.compactMap { download in + download.shouldPreserveTemporaryWorkingSet + ? download.gid + : nil + } + ) + ) + } catch { + Logger.error(error) + } + await reconcileActiveDownloadState() + await validateDownloads() + await notifyObservers() + guard scheduleNext else { return } + await scheduleNextIfNeeded() + } + + func resumeQueue() async { + await scheduleNextIfNeeded() + } + + func badges(for gids: [String]) async -> [String: DownloadBadge] { + guard !gids.isEmpty else { return [:] } + let downloads = await fetchDownloadsFromStore(gids: gids) + return Dictionary(uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) }) + } + + func updateRemoteSignature(gid: String, latestSignature: String?) async -> DownloadBadge { + guard let download = await fetchDownload(gid: gid) else { return .none } + let comparison = DownloadSignatureBuilder.hasUpdateComparison( + remoteVersionSignature: download.remoteVersionSignature, + latestRemoteVersionSignature: latestSignature, + gid: download.gid, + token: download.token + ) + let canonicalizedSignature = DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( + remoteVersionSignature: download.remoteVersionSignature, + latestRemoteVersionSignature: latestSignature, + gid: download.gid, + token: download.token + ) + var didChange = false + + do { + try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in + if download.latestRemoteVersionSignature != latestSignature { + record.latestRemoteVersionSignature = latestSignature + didChange = true + } + + if let canonicalizedSignature, + canonicalizedSignature != download.remoteVersionSignature + { + record.remoteVersionSignature = canonicalizedSignature + didChange = true + } + + guard latestSignature?.notEmpty == true, + [.completed, .updateAvailable].contains(download.status) + else { return } + + let desiredStatus: DownloadStatus? + switch comparison { + case .different: + desiredStatus = .updateAvailable + case .same: + desiredStatus = .completed + case .incomparable: + desiredStatus = nil + } + + if let desiredStatus, + desiredStatus != download.status + { + record.status = desiredStatus.rawValue + didChange = true + } + } + } catch { + Logger.error(error) + } + + if didChange { + await notifyObservers() + } + return (await fetchDownload(gid: gid))?.badge ?? .none + } + + func enqueue(payload: DownloadRequestPayload) async -> Result { + do { + try storage.ensureRootDirectory() + let versionSignature = DownloadSignatureBuilder.make( + gallery: payload.gallery, + detail: payload.galleryDetail, + host: payload.host, + previewURLs: payload.previewURLs, + versionMetadata: payload.versionMetadata + ) + let folderRelativePath = storage.makeFolderRelativePath( + gid: payload.gallery.gid, + title: payload.galleryDetail.trimmedTitle.isEmpty + ? payload.gallery.title + : payload.galleryDetail.trimmedTitle + ) + try await updateDownloadRecord(gid: payload.gallery.gid) { record in + record.gid = payload.gallery.gid + record.host = payload.host.rawValue + record.token = payload.gallery.token + record.title = payload.gallery.title + record.jpnTitle = payload.galleryDetail.jpnTitle + record.uploader = payload.galleryDetail.uploader + record.category = payload.gallery.category.rawValue + record.tags = payload.gallery.tags.toData() + record.pageCount = Int64(payload.galleryDetail.pageCount) + record.postedDate = payload.galleryDetail.postedDate + record.rating = payload.galleryDetail.rating + record.onlineCoverURL = payload.galleryDetail.coverURL ?? payload.gallery.coverURL + record.folderRelativePath = folderRelativePath + record.downloadOptionsSnapshot = payload.options.toData() + record.completedPageCount = 0 + record.lastDownloadedAt = .now + record.lastError = nil + record.latestRemoteVersionSignature = versionSignature + record.pendingOperation = nil + record.status = DownloadStatus.queued.rawValue + } + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.unknown) + } + } + + func retry(gid: String, mode: DownloadStartMode) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + do { + let resolvedMode = effectiveRetryMode(for: download, requestedMode: mode) + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let existingResumeState = fileManager().fileExists(atPath: temporaryFolderURL.path) + ? (try? storage.readResumeState(folderURL: temporaryFolderURL)) + : nil + let shouldResumeExistingWork = shouldResumeExistingWorkingSet( + for: download, + mode: resolvedMode, + resumeState: existingResumeState + ) + let shouldStartImmediately = activeTask == nil || activeGalleryID == gid + let resumedStatus: DownloadStatus + let completedPageCount: Int + let pendingOperation: DownloadStartMode? + + if shouldResumeExistingWork { + resumedStatus = shouldStartImmediately ? .downloading : .queued + completedPageCount = download.completedPageCount + pendingOperation = nil + } else if shouldStartImmediately { + resumedStatus = .downloading + completedPageCount = validatedCompletedPageCount(download) + pendingOperation = nil + } else { + resumedStatus = download.status + completedPageCount = validatedCompletedPageCount(download) + pendingOperation = resolvedMode + } + + if !shouldResumeExistingWork { + try? storage.removeTemporaryFolder(gid: gid) + } + try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in + record.status = resumedStatus.rawValue + record.completedPageCount = Int64(completedPageCount) + record.lastDownloadedAt = .now + record.lastError = nil + record.pendingOperation = pendingOperation?.rawValue + } + if fileManager().fileExists(atPath: temporaryFolderURL.path) { + let downloadOptions = download.downloadOptionsSnapshot + let versionSignature = preferredVersionSignature( + for: download, + mode: resolvedMode, + resumeState: existingResumeState + ) + let pageCount = preferredWorkingPageCount( + for: download, + mode: resolvedMode, + versionSignature: versionSignature, + resumeState: existingResumeState + ) + try? storage.writeResumeState( + .init( + mode: resolvedMode, + versionSignature: versionSignature, + pageCount: pageCount, + downloadOptions: downloadOptions + ), + folderURL: temporaryFolderURL + ) + } + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.unknown) + } + } + + func retryPages(gid: String, pageIndices: [Int]) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + + let mode = resumeMode(for: download) + if mode == .update { + return await retry(gid: gid, mode: .update) + } + + let selectedPageIndices = Array(Set(pageIndices)).sorted() + guard !selectedPageIndices.isEmpty else { return .success(()) } + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + guard fileManager().fileExists(atPath: temporaryFolderURL.path) else { + return .failure(.notFound) + } + + let existingResumeState = try? storage.readResumeState(folderURL: temporaryFolderURL) + let versionSignature = preferredVersionSignature( + for: download, + mode: mode, + resumeState: existingResumeState + ) + let pageCount = preferredWorkingPageCount( + for: download, + mode: mode, + versionSignature: versionSignature, + resumeState: existingResumeState + ) + let resumedStatus: DownloadStatus = activeTask == nil || activeGalleryID == gid + ? .downloading + : .queued + + do { + if let failedSnapshot = try? storage.readFailedPages(folderURL: temporaryFolderURL) { + let remainingPages = failedSnapshot.pages.filter { !selectedPageIndices.contains($0.index) } + if remainingPages.isEmpty { + try? storage.removeFailedPages(folderURL: temporaryFolderURL) + } else { + try storage.writeFailedPages(.init(pages: remainingPages), folderURL: temporaryFolderURL) + } + } + try storage.writeResumeState( + .init( + mode: mode, + versionSignature: versionSignature, + pageCount: pageCount, + downloadOptions: download.downloadOptionsSnapshot, + pageSelection: selectedPageIndices + ), + folderURL: temporaryFolderURL + ) + try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in + record.status = resumedStatus.rawValue + record.lastDownloadedAt = .now + record.lastError = nil + record.pendingOperation = nil + } + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.unknown) + } + } + + func togglePause(gid: String) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + + if let pendingMode = download.pendingOperation { + return await cancelQueuedWorkItem(download, mode: pendingMode) + } + + switch download.status { + case .queued, .downloading: + return await pause(gid: gid) + case .paused: + return await resume(gid: gid) + case .partial, .completed, .failed, .updateAvailable, .missingFiles: + return .failure(.unknown) + } + } + + func delete(gid: String) async -> Result { + let taskToCancel: Task? + schedulingBlockedGalleryIDs.insert(gid) + defer { + schedulingBlockedGalleryIDs.remove(gid) + } + if activeGalleryID == gid { + taskToCancel = activeTask + activeTask?.cancel() + activeTask = nil + activeGalleryID = nil + } else { + taskToCancel = nil + } + await taskToCancel?.value + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + do { + try? storage.removeTemporaryFolder(gid: gid) + try storage.removeFolder(relativePath: download.folderRelativePath) + try await deleteDownloadRecord(gid: gid) + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.fileOperationFailed(error.localizedDescription)) + } + } + + func loadManifest(gid: String) async -> Result<(DownloadedGallery, DownloadManifest), AppError> { + let sanitizedDownload = await sanitizeLocalFilesIfNeeded(gid: gid) + let resolvedDownload: DownloadedGallery? + if let sanitizedDownload { + resolvedDownload = sanitizedDownload + } else { + resolvedDownload = await fetchDownload(gid: gid) + } + guard let download = resolvedDownload, + let folderURL = download.resolvedFolderURL(rootURL: storage.rootURL) + else { + return .failure(.notFound) + } + switch storage.validate(download: download) { + case .valid: + break + case .missingFiles(let message): + return .failure(.fileOperationFailed(message)) + } + do { + let manifest = try storage.readManifest(folderURL: folderURL) + return .success((download, manifest)) + } catch { + return .failure(.fileOperationFailed(error.localizedDescription)) + } + } + + func loadLocalPageURLs(gid: String) async -> Result<[Int: URL], AppError> { + let sanitizedDownload = await sanitizeLocalFilesIfNeeded(gid: gid) + let resolvedDownload: DownloadedGallery? + if let sanitizedDownload { + resolvedDownload = sanitizedDownload + } else { + resolvedDownload = await fetchDownload(gid: gid) + } + guard let download = resolvedDownload else { + return .failure(.notFound) + } + + let completedFolderURL = download.resolvedFolderURL(rootURL: storage.rootURL) + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let hasTemporaryFolder = fileManager().fileExists(atPath: temporaryFolderURL.path) + let shouldExposeTemporaryWorkingSet = hasTemporaryFolder + && self.shouldExposeTemporaryWorkingSet(for: download) + let completedValidation = storage.validate(download: download) + + let completedPageRelativePaths = completedFolderURL.map { + storage.existingPageRelativePaths( + folderURL: $0, + expectedPageCount: download.pageCount + ) + } ?? [:] + let temporaryPageRelativePaths = hasTemporaryFolder + ? storage.existingPageRelativePaths( + folderURL: temporaryFolderURL, + expectedPageCount: download.pageCount + ) + : [:] + + let completedPageURLs = completedPageRelativePaths.reduce(into: [Int: URL]()) { result, entry in + guard let folderURL = completedFolderURL else { return } + result[entry.key] = folderURL.appendingPathComponent(entry.value) + } + let temporaryPageURLs = temporaryPageRelativePaths.reduce(into: [Int: URL]()) { result, entry in + result[entry.key] = temporaryFolderURL.appendingPathComponent(entry.value) + } + + if completedValidation == .valid, + let completedFolderURL, + fileManager().fileExists(atPath: completedFolderURL.path), + let manifest = try? storage.readManifest(folderURL: completedFolderURL) + { + let completedManifestPageURLs = manifest.imageURLs(folderURL: completedFolderURL) + guard shouldExposeTemporaryWorkingSet else { + return .success(completedManifestPageURLs) + } + return .success( + completedManifestPageURLs.merging( + temporaryPageURLs, + uniquingKeysWith: { _, temporary in temporary } + ) + ) + } + + guard shouldExposeTemporaryWorkingSet else { + return .success(completedPageURLs) + } + + if !completedPageURLs.isEmpty, !temporaryPageURLs.isEmpty { + return .success( + completedPageURLs.merging( + temporaryPageURLs, + uniquingKeysWith: { _, temporary in temporary } + ) + ) + } + + if !temporaryPageURLs.isEmpty { + return .success(temporaryPageURLs) + } + + return .success(completedPageURLs) + } + + func captureCachedPage( + gid: String, + index: Int, + imageURL: URL? + ) async { + guard let download = await fetchDownload(gid: gid), + index >= 1, + index <= max(download.pageCount, 1) + else { + return + } + + guard let captureTarget = captureTarget( + for: download, + index: index + ) else { + return + } + + let existingPages = storage.existingPageRelativePaths( + folderURL: captureTarget.folderURL, + expectedPageCount: download.pageCount + ) + do { + let cacheURLs = pageImageCacheURLs(imageURL: imageURL) + guard let pageResult = try await restorePageFromCache( + index: index, + cacheURLs: cacheURLs, + folderURL: captureTarget.folderURL, + preferredRelativePath: captureTarget.preferredRelativePath ?? existingPages[index], + referenceURL: preferredPageReferenceURL(imageURL: imageURL), + imageURL: imageURL, + overwriteExistingFile: true + ) else { + return + } + + await persistResolvedImageURLs( + gid: gid, + index: index, + imageURL: pageResult.imageURL + ) + if captureTarget.isTemporary { + try clearFailedPage(index: index, folderURL: captureTarget.folderURL) + } + _ = await sanitizeLocalFilesIfNeeded(gid: gid, clearingLastError: true) + } catch { + Logger.error(error) + } + } + + func loadInspection(gid: String) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + + let activeFolderURL = activeInspectionFolderURL(for: download) + + let existingRelativePaths = activeFolderURL.map { + storage.existingPageRelativePaths(folderURL: $0, expectedPageCount: download.pageCount) + } ?? [:] + let failedPages = activeFolderURL.map(sanitizedFailedPages(folderURL:)) ?? [:] + + let pages = (1...download.pageCount).map { index -> DownloadPageInspection in + if let relativePath = existingRelativePaths[index], let folderURL = activeFolderURL { + let fileURL = folderURL.appendingPathComponent(relativePath) + if fileManager().fileExists(atPath: fileURL.path) { + return .init( + index: index, + status: .downloaded, + relativePath: relativePath, + fileURL: fileURL, + failure: nil + ) + } + } + + if let failedPage = failedPages[index] { + return .init( + index: index, + status: .failed, + relativePath: failedPage.relativePath, + fileURL: nil, + failure: failedPage.failure + ) + } + + return .init( + index: index, + status: .pending, + relativePath: nil, + fileURL: nil, + failure: nil + ) + } + + let coverURL = activeFolderURL.flatMap { folderURL in + storage.existingCoverRelativePath(folderURL: folderURL).map { + folderURL.appendingPathComponent($0) + } + } ?? download.coverURL + + return .success( + .init( + download: download, + coverURL: coverURL, + pages: pages + ) + ) + } + + private func addObserver(id: UUID, continuation: AsyncStream<[DownloadedGallery]>.Continuation) async { + observers[id] = continuation + let downloads = await fetchDownloads() + lastObservedDownloads = downloads + continuation.yield(downloads) + } + + private func removeObserver(id: UUID) { + observers[id] = nil + } + + private func notifyObservers() async { + let downloads = await fetchDownloads() + guard downloads != lastObservedDownloads else { return } + lastObservedDownloads = downloads + observers.values.forEach { $0.yield(downloads) } + } + + private func scheduleNextIfNeeded() async { + guard activeTask == nil else { + await reconcileActiveDownloadState() + return + } + let downloads = await fetchDownloadsFromStore() + let nextDownload = downloads + .filter { + !schedulingBlockedGalleryIDs.contains($0.gid) + && shouldSchedule(download: $0) + } + .sorted { lhs, rhs in + let lhsIsDownloading = lhs.status == .downloading + let rhsIsDownloading = rhs.status == .downloading + if lhsIsDownloading != rhsIsDownloading { + return lhsIsDownloading + } + return (lhs.lastDownloadedAt ?? .distantPast) < (rhs.lastDownloadedAt ?? .distantPast) + } + .first + guard let nextDownload else { return } + + activeGalleryID = nextDownload.gid + activeTask = Task { [weak self] in + guard let self else { return } + await self.processDownload(gid: nextDownload.gid) + } + } + + private func shouldSchedule(download: DownloadedGallery) -> Bool { + if download.status == .downloading || download.isQueuedWorkItem { + return true + } + + guard download.status == .partial else { + return false + } + + let temporaryFolderURL = storage.temporaryFolderURL(gid: download.gid) + guard let resumeState = try? storage.readResumeState(folderURL: temporaryFolderURL), + let pageSelection = resumeState.pageSelection + else { + return false + } + return !pageSelection.isEmpty + } + + private func processDownload(gid: String) async { + defer { + activeTask = nil + activeGalleryID = nil + Task { + await self.scheduleNextIfNeeded() + } + } + + guard let download = await fetchDownload(gid: gid) else { return } + let mode = queuedMode(for: download) + let previousFolderRelativePath = download.folderRelativePath + let hadReadableFiles = storage.validate(download: download) == .valid + var fetchedVersionSignature: String? + + do { + try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in + record.status = DownloadStatus.downloading.rawValue + record.completedPageCount = Int64(download.completedPageCount) + record.lastError = nil + record.pendingOperation = nil + } + await notifyObservers() + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let existingResumeState = try? storage.readResumeState(folderURL: temporaryFolderURL) + let rawPageSelection = existingResumeState?.pageSelection + let (fetchedPayload, versionSignature) = try await fetchLatestPayload( + for: download, + mode: mode, + pageSelection: rawPageSelection + ) + fetchedVersionSignature = versionSignature + let payload = normalizeFetchedPayload( + fetchedPayload, + mode: mode, + versionSignature: versionSignature, + existingResumeState: existingResumeState, + rawPageSelection: rawPageSelection + ) + let folderRelativePath = storage.makeFolderRelativePath( + gid: payload.gallery.gid, + title: payload.galleryDetail.trimmedTitle.isEmpty + ? payload.gallery.title + : payload.galleryDetail.trimmedTitle + ) + let downloadResult = try await performDownload( + payload: payload, + versionSignature: versionSignature, + folderRelativePath: folderRelativePath, + existingDownload: download + ) + + guard !Task.isCancelled else { return } + + try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in + record.host = payload.host.rawValue + record.token = payload.gallery.token + record.title = payload.gallery.title + record.jpnTitle = payload.galleryDetail.jpnTitle + record.uploader = payload.galleryDetail.uploader + record.category = payload.gallery.category.rawValue + record.tags = payload.gallery.tags.toData() + record.pageCount = Int64(payload.galleryDetail.pageCount) + record.postedDate = payload.galleryDetail.postedDate + record.rating = payload.galleryDetail.rating + record.onlineCoverURL = payload.galleryDetail.coverURL ?? payload.gallery.coverURL + record.folderRelativePath = folderRelativePath + record.coverRelativePath = downloadResult.coverRelativePath + record.downloadOptionsSnapshot = payload.options.toData() + record.completedPageCount = Int64(payload.galleryDetail.pageCount) + record.lastDownloadedAt = .now + record.lastError = nil + record.remoteVersionSignature = versionSignature + record.latestRemoteVersionSignature = versionSignature + record.pendingOperation = nil + record.status = DownloadStatus.completed.rawValue + } + if previousFolderRelativePath != folderRelativePath { + try? storage.removeFolder(relativePath: previousFolderRelativePath) + } + await notifyObservers() + } catch is CancellationError { + return + } catch let error as AppError { + guard !isCancellationLikeAppError(error) else { return } + guard !shouldSuppressFailurePersistence(for: gid) else { return } + Logger.error( + "Download failed.", + context: [ + "gid": gid, + "mode": mode.rawValue, + "error": error.localizedDescription + ] + ) + await persistFailure( + gid: gid, + error: error, + originalDownload: download, + mode: mode, + hadReadableFiles: hadReadableFiles, + latestSignature: fetchedVersionSignature + ) + await notifyObservers() + } catch let error as PartialDownloadError { + let pageError = error.failedPages.first?.failure.appError ?? .unknown + guard !isCancellationLikeAppError(pageError) else { return } + guard !shouldSuppressFailurePersistence(for: gid) else { return } + Logger.error( + "Download partially failed.", + context: [ + "gid": gid, + "mode": mode.rawValue, + "failedPages": error.failedPages.map(\.index) + ] + ) + await persistFailure( + gid: gid, + error: pageError, + originalDownload: download, + mode: mode, + hadReadableFiles: hadReadableFiles, + latestSignature: fetchedVersionSignature + ) + await notifyObservers() + } catch { + let appError = AppError.fileOperationFailed(error.localizedDescription) + guard !isCancellationLikeAppError(appError) else { return } + guard !shouldSuppressFailurePersistence(for: gid) else { return } + Logger.error(error) + await persistFailure( + gid: gid, + error: appError, + originalDownload: download, + mode: mode, + hadReadableFiles: hadReadableFiles, + latestSignature: fetchedVersionSignature + ) + await notifyObservers() + } + } + + private func queuedMode(for download: DownloadedGallery) -> DownloadStartMode { + if let pendingOperation = download.pendingOperation { + return pendingOperation + } + switch download.status { + case .missingFiles: + return effectiveRetryMode(for: download, requestedMode: .repair) + case .updateAvailable: + return .update + case .partial: + return resumeMode(for: download) + case .completed: + return effectiveRetryMode(for: download, requestedMode: .redownload) + case .failed: + return effectiveRetryMode( + for: download, + requestedMode: download.remoteVersionSignature.isEmpty ? .initial : .redownload + ) + case .paused: + return resumeMode(for: download) + case .queued, .downloading: + return readResumeMode(gid: download.gid) + ?? effectiveRetryMode( + for: download, + requestedMode: download.remoteVersionSignature.isEmpty ? .initial : .redownload + ) + } + } + + private func pause(gid: String) async -> Result { + let taskToCancel: Task? + do { + schedulingBlockedGalleryIDs.insert(gid) + defer { + schedulingBlockedGalleryIDs.remove(gid) + } + guard let currentDownload = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + guard [.queued, .downloading].contains(currentDownload.status) else { + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } + + let initialCompletedPageCount = max( + currentDownload.completedPageCount, + temporaryCompletedPageCount( + gid: gid, + expectedPageCount: max(currentDownload.pageCount, 1) + ) + ) + try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in + record.status = DownloadStatus.paused.rawValue + record.completedPageCount = Int64(initialCompletedPageCount) + record.lastError = nil + record.lastDownloadedAt = .now + } + await notifyObservers() + + if activeGalleryID == gid { + taskToCancel = activeTask + activeTask?.cancel() + activeTask = nil + activeGalleryID = nil + } else { + taskToCancel = nil + } + await taskToCancel?.value + let settledCompletedPageCount = max( + currentDownload.completedPageCount, + temporaryCompletedPageCount( + gid: gid, + expectedPageCount: max(currentDownload.pageCount, 1) + ) + ) + try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in + record.status = DownloadStatus.paused.rawValue + record.completedPageCount = Int64(settledCompletedPageCount) + record.lastError = nil + record.lastDownloadedAt = .now + } + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.unknown) + } + } + + private func cancelQueuedWorkItem( + _ download: DownloadedGallery, + mode: DownloadStartMode + ) async -> Result { + switch mode { + case .initial: + return await pause(gid: download.gid) + case .redownload, .update, .repair: + break + } + + let restoredStatus = download.status + let restoredCompletedPageCount = validatedCompletedPageCount(download) + do { + try await updateDownloadRecord(gid: download.gid, createIfMissing: false) { record in + record.status = restoredStatus.rawValue + record.completedPageCount = Int64(restoredCompletedPageCount) + record.lastDownloadedAt = .now + record.pendingOperation = nil + } + await notifyObservers() + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.unknown) + } + } + + private func resume(gid: String) async -> Result { + guard await fetchDownload(gid: gid) != nil else { + return .failure(.notFound) + } + + do { + // If another gallery is already active, keep this task in the queue and let the + // temporary resume state decide whether it resumes an update/redownload/repair later. + let resumedStatus: DownloadStatus = activeTask == nil ? .downloading : .queued + try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in + record.status = resumedStatus.rawValue + record.lastError = nil + record.lastDownloadedAt = .now + record.pendingOperation = nil + } + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.unknown) + } + } + + private func resumeMode(for download: DownloadedGallery) -> DownloadStartMode { + if download.remoteVersionSignature.isEmpty { + return .initial + } + if download.hasUpdate { + return .update + } + if let mode = readResumeMode(gid: download.gid) { + return effectiveRetryMode(for: download, requestedMode: mode) + } + if download.status == .partial { + return effectiveRetryMode( + for: download, + requestedMode: download.remoteVersionSignature.isEmpty ? .initial : .redownload + ) + } + if case .missingFiles = storage.validate(download: download) { + return .repair + } + return .redownload + } + + private func effectiveRetryMode( + for download: DownloadedGallery, + requestedMode: DownloadStartMode + ) -> DownloadStartMode { + guard requestedMode != .initial, download.hasUpdate else { + return requestedMode + } + return .update + } + + private func preferredVersionSignature( + for download: DownloadedGallery, + mode: DownloadStartMode, + resumeState: DownloadResumeState? + ) -> String { + switch mode { + case .update: + if let latestSignature = download.latestRemoteVersionSignature, + latestSignature.notEmpty + { + return latestSignature + } + case .initial, .redownload, .repair: + break + } + + if let resumeState, + resumeState.versionSignature.notEmpty + { + return resumeState.versionSignature + } + + if download.remoteVersionSignature.notEmpty { + return download.remoteVersionSignature + } + + return download.latestRemoteVersionSignature ?? "" + } + + private func preferredWorkingPageCount( + for download: DownloadedGallery, + mode: DownloadStartMode, + versionSignature: String, + resumeState: DownloadResumeState? + ) -> Int { + guard mode == .update else { + return download.pageCount + } + + let temporaryFolderURL = storage.temporaryFolderURL(gid: download.gid) + guard fileManager().fileExists(atPath: temporaryFolderURL.path) else { + return download.pageCount + } + + if let manifest = try? storage.readManifest(folderURL: temporaryFolderURL), + manifest.gid == download.gid, + manifest.versionSignature == versionSignature + { + return manifest.pageCount + } + + if let resumeState, + resumeState.versionSignature == versionSignature + { + return resumeState.pageCount + } + + return download.pageCount + } + + private func shouldResumeExistingWorkingSet( + for download: DownloadedGallery, + mode: DownloadStartMode, + resumeState: DownloadResumeState? + ) -> Bool { + guard download.status == .failed || storage.temporaryFolderExists(gid: download.gid), + let resumeState + else { + return false + } + + let versionSignature = preferredVersionSignature( + for: download, + mode: mode, + resumeState: resumeState + ) + let pageCount = preferredWorkingPageCount( + for: download, + mode: mode, + versionSignature: versionSignature, + resumeState: resumeState + ) + + guard resumeState.mode == mode, + resumeState.versionSignature == versionSignature, + resumeState.downloadOptions == download.downloadOptionsSnapshot + else { + return false + } + + if mode == .update, + let manifest = try? storage.readManifest( + folderURL: storage.temporaryFolderURL(gid: download.gid) + ), + manifest.gid == download.gid, + manifest.versionSignature == versionSignature + { + return manifest.pageCount == pageCount + } + + return resumeState.pageCount == pageCount + } + + private func readResumeMode(gid: String) -> DownloadStartMode? { + let folderURL = storage.temporaryFolderURL(gid: gid) + return try? storage.readResumeState(folderURL: folderURL).mode + } + + private func fallbackStatus( + for download: DownloadedGallery, + mode: DownloadStartMode, + latestSignature: String? + ) -> DownloadStatus { + let comparison = DownloadSignatureBuilder.hasUpdateComparison( + remoteVersionSignature: download.remoteVersionSignature, + latestRemoteVersionSignature: latestSignature, + gid: download.gid, + token: download.token + ) + let shouldKeepUpdateBadge = mode == .update + || download.status == .updateAvailable + || comparison == .different + return shouldKeepUpdateBadge ? .updateAvailable : .completed + } + + private func persistFailure( + gid: String, + error: AppError, + originalDownload: DownloadedGallery, + mode: DownloadStartMode, + hadReadableFiles: Bool, + latestSignature: String? + ) async { + let workingCompletedPageCount = temporaryCompletedPageCount( + gid: gid, + expectedPageCount: originalDownload.pageCount + ) + let hasTemporaryWorkingSet = storage.temporaryFolderExists(gid: gid) + let recoveredCompletedPageCount = hasTemporaryWorkingSet + ? workingCompletedPageCount + : max(originalDownload.completedPageCount, workingCompletedPageCount) + do { + try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in + record.lastError = DownloadFailure(error: error).toData() + record.pendingOperation = nil + if mode == .repair { + record.status = DownloadStatus.missingFiles.rawValue + record.completedPageCount = Int64(originalDownload.completedPageCount) + record.folderRelativePath = originalDownload.folderRelativePath + record.coverRelativePath = originalDownload.coverRelativePath + record.remoteVersionSignature = originalDownload.remoteVersionSignature + record.latestRemoteVersionSignature = latestSignature + ?? originalDownload.latestRemoteVersionSignature + } else if hadReadableFiles, [.update, .redownload].contains(mode) { + record.status = self.fallbackStatus( + for: originalDownload, + mode: mode, + latestSignature: latestSignature + ) + .rawValue + record.completedPageCount = Int64(originalDownload.pageCount) + record.folderRelativePath = originalDownload.folderRelativePath + record.coverRelativePath = originalDownload.coverRelativePath + record.remoteVersionSignature = originalDownload.remoteVersionSignature + record.latestRemoteVersionSignature = latestSignature + ?? originalDownload.latestRemoteVersionSignature + } else if workingCompletedPageCount > 0 { + record.status = DownloadStatus.partial.rawValue + record.completedPageCount = Int64(workingCompletedPageCount) + record.latestRemoteVersionSignature = latestSignature + ?? originalDownload.latestRemoteVersionSignature + } else { + record.status = DownloadStatus.partial.rawValue + record.completedPageCount = Int64(recoveredCompletedPageCount) + record.latestRemoteVersionSignature = latestSignature + ?? originalDownload.latestRemoteVersionSignature + } + } + } catch { + Logger.error(error) + } + } + + private func fetchLatestPayload( + for download: DownloadedGallery, + mode: DownloadStartMode, + pageSelection: [Int]? + ) async throws -> (DownloadRequestPayload, String) { + let galleryURL = download.gallery.galleryURL + guard let galleryURL else { throw AppError.notFound } + let (detail, galleryState) = try await withRetry( + operation: "fetchLatestPayload", + context: [ + "gid": download.gid, + "mode": mode.rawValue, + "galleryURL": galleryURL.absoluteString + ] + ) { + let doc = try await htmlDocument( + url: URLUtil.galleryDetail(url: galleryURL), + allowsCellular: download.downloadOptionsSnapshot.allowCellular, + retriesRequest: false + ) + return try Parser.parseGalleryDetail(doc: doc, gid: download.gid) + } + let gallery = Gallery( + gid: download.gid, + token: download.token, + title: detail.title, + rating: detail.rating, + tags: galleryState.tags, + category: detail.category, + uploader: detail.uploader, + pageCount: detail.pageCount, + postedDate: detail.postedDate, + coverURL: detail.coverURL ?? download.onlineCoverURL, + galleryURL: galleryURL + ) + let previewConfig = galleryState.previewConfig ?? .normal(rows: 4) + let previewURLs = galleryState.previewURLs + let versionMetadata: DownloadVersionMetadata? + switch await GalleryVersionMetadataRequest(gid: download.gid, token: download.token).response() { + case .success(let metadata): + versionMetadata = metadata + case .failure: + versionMetadata = nil + } + let versionSignature = DownloadSignatureBuilder.make( + gallery: gallery, + detail: detail, + host: download.host, + previewURLs: previewURLs, + versionMetadata: versionMetadata + ) + return ( + .init( + gallery: gallery, + galleryDetail: detail, + previewURLs: previewURLs, + previewConfig: previewConfig, + host: download.host, + versionMetadata: versionMetadata, + options: download.downloadOptionsSnapshot, + mode: mode, + pageSelection: pageSelection.map(Set.init) + ), + versionSignature + ) + } + + private func normalizeFetchedPayload( + _ payload: DownloadRequestPayload, + mode: DownloadStartMode, + versionSignature: String, + existingResumeState: DownloadResumeState?, + rawPageSelection: [Int]? + ) -> DownloadRequestPayload { + let shouldPreservePageSelection = rawPageSelection?.isEmpty == false + && existingResumeState?.matches( + mode: mode, + versionSignature: versionSignature, + pageCount: payload.galleryDetail.pageCount, + downloadOptions: payload.options + ) == true + && mode != .update + + guard !shouldPreservePageSelection else { + return payload + } + + return .init( + gallery: payload.gallery, + galleryDetail: payload.galleryDetail, + previewURLs: payload.previewURLs, + previewConfig: payload.previewConfig, + host: payload.host, + versionMetadata: payload.versionMetadata, + options: payload.options, + mode: payload.mode, + pageSelection: nil + ) + } + + private func performDownload( + payload: DownloadRequestPayload, + versionSignature: String, + folderRelativePath: String, + existingDownload: DownloadedGallery + ) async throws -> (coverRelativePath: String?, pages: [PageResult]) { + try storage.ensureRootDirectory() + + let temporaryFolderURL = storage.temporaryFolderURL(gid: payload.gallery.gid) + let workingSeed = try prepareWorkingSeed( + payload: payload, + existingDownload: existingDownload, + temporaryFolderURL: temporaryFolderURL, + versionSignature: versionSignature + ) + let pendingPageIndices = pendingPageIndices( + payload: payload, + folderURL: temporaryFolderURL, + existingPageRelativePaths: workingSeed.existingPages + ) + try storage.writeResumeState( + .init( + mode: payload.mode, + versionSignature: versionSignature, + pageCount: payload.galleryDetail.pageCount, + downloadOptions: payload.options, + pageSelection: payload.pageSelection?.sorted() + ), + folderURL: temporaryFolderURL + ) + + do { + let storedGalleryImageState = await fetchCachedGalleryImageState(gid: payload.gallery.gid) + let coverRelativePath = try await downloadCoverImage( + payload: payload, + temporaryFolderURL: temporaryFolderURL, + existingCoverRelativePath: workingSeed.coverRelativePath + ) + if coverRelativePath != existingDownload.coverRelativePath { + try? await updateDownloadRecord( + gid: payload.gallery.gid, + createIfMissing: false + ) { record in + record.coverRelativePath = coverRelativePath + } + } + let canSatisfyPendingPagesFromCache = await canSatisfyPendingPageDownloadsFromCache( + pendingPageIndices: pendingPageIndices, + temporaryFolderURL: temporaryFolderURL, + existingPageRelativePaths: workingSeed.existingPages, + storedGalleryImageState: storedGalleryImageState + ) + let source: ResolvedSource? + if pendingPageIndices.isEmpty || canSatisfyPendingPagesFromCache { + source = nil + } else { + source = try await resolveSource( + payload: payload, + requiredPageIndices: pendingPageIndices + ) + } + let batchResult = try await downloadPages( + payload: payload, + pendingPageIndices: pendingPageIndices, + source: source, + temporaryFolderURL: temporaryFolderURL, + existingManifest: workingSeed.manifest, + existingPageRelativePaths: workingSeed.existingPages, + storedGalleryImageState: storedGalleryImageState + ) + if payload.pageSelection != nil { + try? storage.writeResumeState( + .init( + mode: payload.mode, + versionSignature: versionSignature, + pageCount: payload.galleryDetail.pageCount, + downloadOptions: payload.options + ), + folderURL: temporaryFolderURL + ) + } + if !batchResult.failedPages.isEmpty { + throw PartialDownloadError(failedPages: batchResult.failedPages) + } + + let manifest = DownloadManifest( + gid: payload.gallery.gid, + host: payload.host, + token: payload.gallery.token, + title: payload.gallery.title, + jpnTitle: payload.galleryDetail.jpnTitle, + category: payload.gallery.category, + language: payload.galleryDetail.language, + uploader: payload.galleryDetail.uploader, + tags: payload.gallery.tags, + postedDate: payload.galleryDetail.postedDate, + pageCount: payload.galleryDetail.pageCount, + coverRelativePath: coverRelativePath, + galleryURL: payload.gallery.galleryURL.forceUnwrapped, + rating: payload.galleryDetail.rating, + downloadOptions: payload.options, + versionSignature: versionSignature, + downloadedAt: .now, + pages: batchResult.pages + .sorted(by: { $0.index < $1.index }) + .map { .init(index: $0.index, relativePath: $0.relativePath) } + ) + try storage.writeManifest(manifest, folderURL: temporaryFolderURL) + try? storage.removeFailedPages(folderURL: temporaryFolderURL) + try storage.replaceFolder( + relativePath: folderRelativePath, + with: temporaryFolderURL + ) + cleanupCachedRemoteAssetsAfterSuccessfulDownload( + payload: payload, + storedGalleryImageState: storedGalleryImageState, + pages: batchResult.pages, + existingDownload: existingDownload + ) + return (coverRelativePath, batchResult.pages) + } catch is CancellationError { + throw CancellationError() + } catch { + throw error + } + } + + private func downloadCoverImage( + payload: DownloadRequestPayload, + temporaryFolderURL: URL, + existingCoverRelativePath: String? + ) async throws -> String? { + if let coverRelativePath = existingCoverRelativePath, + !coverRelativePath.isEmpty + { + let localCoverURL = temporaryFolderURL.appendingPathComponent(coverRelativePath) + if fileManager().fileExists(atPath: localCoverURL.path) { + return coverRelativePath + } + } + guard let coverURL = payload.galleryDetail.coverURL ?? payload.gallery.coverURL else { + return nil + } + if let cachedData = await validatedCachedAssetData(for: [coverURL]) { + let fileExtension = fileExtension(for: coverURL, response: nil, prefixData: cachedData) + let relativePath = storage.makeCoverRelativePath(fileExtension: fileExtension) + let fileURL = temporaryFolderURL.appendingPathComponent(relativePath) + try write(data: cachedData, to: fileURL) + return relativePath + } + let (downloadedFileURL, response) = try await downloadResponse( + url: coverURL, + allowsCellular: payload.options.allowCellular + ) + let prefixData = try readResponsePrefixData(at: downloadedFileURL) + let fileExtension = fileExtension( + for: coverURL, + response: response, + prefixData: prefixData + ) + let relativePath = storage.makeCoverRelativePath(fileExtension: fileExtension) + let fileURL = temporaryFolderURL.appendingPathComponent(relativePath) + try moveDownloadedFile(from: downloadedFileURL, to: fileURL) + return relativePath + } + + private func cleanupCachedRemoteAssetsAfterSuccessfulDownload( + payload: DownloadRequestPayload, + storedGalleryImageState: CachedGalleryImageState?, + pages: [PageResult], + existingDownload: DownloadedGallery + ) { + let previewURLs = ( + Array(payload.previewURLs.values) + + (storedGalleryImageState.map { Array($0.previewURLs.values) } ?? []) + ) + .flatMap { $0.previewCacheCleanupURLs() } + let pageURLs = pages.compactMap(\.imageURL) + + (storedGalleryImageState.map { Array($0.imageURLs.values) } ?? []) + let coverURLs = [ + payload.galleryDetail.coverURL, + payload.gallery.coverURL, + existingDownload.onlineCoverURL + ] + .compactMap(\.self) + + let urls = Array(Set(previewURLs + pageURLs + coverURLs)).map(Optional.some) + removeCachedImages(for: urls, includeStableAlias: true) + } + + private func resolveSource( + payload: DownloadRequestPayload, + requiredPageIndices: [Int] + ) async throws -> ResolvedSource { + let requiredPageNumbers = Array( + Set(requiredPageIndices.map { payload.previewConfig.pageNumber(index: $0) }) + ) + .sorted() + var thumbnailURLs = [Int: URL]() + for pageNumber in requiredPageNumbers { + let pageURLs = try await fetchThumbnailURLs( + galleryURL: payload.gallery.galleryURL.forceUnwrapped, + pageNum: pageNumber, + allowsCellular: payload.options.allowCellular + ) + thumbnailURLs.merge(pageURLs, uniquingKeysWith: { _, new in new }) + } + guard let firstURL = requiredPageIndices.lazy.compactMap({ thumbnailURLs[$0] }).first + ?? thumbnailURLs.values.first + else { + throw AppError.notFound + } + if firstURL.pathComponents.count > 1, firstURL.pathComponents[1] == "mpv" { + let (mpvKey, imageKeys) = try await fetchMPVKeys( + mpvURL: firstURL, + allowsCellular: payload.options.allowCellular + ) + return .mpv(mpvKey, imageKeys) + } else { + return .normal(thumbnailURLs) + } + } + + private func downloadPages( + payload: DownloadRequestPayload, + pendingPageIndices: [Int], + source: ResolvedSource?, + temporaryFolderURL: URL, + existingManifest: DownloadManifest?, + existingPageRelativePaths: [Int: String], + storedGalleryImageState: CachedGalleryImageState? + ) async throws -> DownloadBatchResult { + let manifestPages = Dictionary( + uniqueKeysWithValues: (existingManifest?.pages ?? []).map { ($0.index, $0.relativePath) } + ) + let existingPages = manifestPages.merging( + existingPageRelativePaths, + uniquingKeysWith: { manifestPath, _ in manifestPath } + ) + var failedPages = (try? storage.readFailedPages(folderURL: temporaryFolderURL).map) ?? [:] + let pageIndices = Array(1...payload.galleryDetail.pageCount) + var results = [PageResult]() + for index in pageIndices { + guard let relativePath = existingPages[index] else { continue } + let fileURL = temporaryFolderURL.appendingPathComponent(relativePath) + guard fileManager().fileExists(atPath: fileURL.path) else { continue } + failedPages[index] = nil + results.append( + .init( + index: index, + relativePath: relativePath, + imageURL: storedGalleryImageState?.imageURLs[index] + ) + ) + } + var completedCount = results.count + var pendingResolvedPages = [PageResult]() + var lastFlushDate = Date() + + if completedCount > 0 { + try await updateDownloadRecord(gid: payload.gallery.gid, createIfMissing: false) { record in + record.completedPageCount = Int64(completedCount) + } + await notifyObservers() + } + + let restoredCachedPages = try await restorePendingPagesFromStoredCache( + indices: pendingPageIndices, + temporaryFolderURL: temporaryFolderURL, + existingPages: existingPages, + storedGalleryImageState: storedGalleryImageState + ) + if !restoredCachedPages.isEmpty { + restoredCachedPages.forEach { + failedPages[$0.index] = nil + results.append($0) + } + completedCount += restoredCachedPages.count + pendingResolvedPages.append(contentsOf: restoredCachedPages) + try await flushDownloadProgress( + gid: payload.gallery.gid, + pendingResolvedPages: &pendingResolvedPages, + completedCount: completedCount, + lastFlushDate: &lastFlushDate, + force: true + ) + } + + let restoredIndices = Set(restoredCachedPages.map(\.index)) + let remainingPageIndices = pendingPageIndices.filter { !restoredIndices.contains($0) } + var wasCancelled = false + await withTaskGroup(of: PageTaskOutcome.self) { group in + var pendingIterator = remainingPageIndices.makeIterator() + for _ in 0.. PageResult { + let attempts = payload.options.autoRetryFailedPages ? 2 : 1 + var capturedError: AppError = .unknown + + for _ in 0.. WorkingSeed { + let fileManager = fileManager() + let resumeState = try? storage.readResumeState(folderURL: temporaryFolderURL) + let shouldReuseTemporaryFolder = resumeState?.matches( + mode: payload.mode, + versionSignature: versionSignature, + pageCount: payload.galleryDetail.pageCount, + downloadOptions: payload.options + ) == true + && fileManager.fileExists(atPath: temporaryFolderURL.path) + + if !shouldReuseTemporaryFolder { + try? fileManager.removeItem(at: temporaryFolderURL) + } + + if !fileManager.fileExists(atPath: temporaryFolderURL.path) { + if let repairSeed = repairSeed( + for: existingDownload, + payload: payload, + versionSignature: versionSignature + ) { + try storage.materializeRepairSeed( + from: repairSeed.folderURL, + manifest: repairSeed.manifest, + to: temporaryFolderURL + ) + } else { + try createDirectory(at: temporaryFolderURL) + } + } + + let pagesFolderURL = temporaryFolderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, + isDirectory: true + ) + try createDirectory(at: pagesFolderURL) + + let manifest = validatedManifest( + at: temporaryFolderURL, + gid: payload.gallery.gid, + pageCount: payload.galleryDetail.pageCount, + versionSignature: versionSignature, + downloadOptions: payload.options + ) + let existingPages = storage.existingPageRelativePaths( + folderURL: temporaryFolderURL, + expectedPageCount: payload.galleryDetail.pageCount + ) + let coverRelativePath = manifest?.coverRelativePath + ?? storage.existingCoverRelativePath(folderURL: temporaryFolderURL) + + return .init( + folderURL: temporaryFolderURL, + manifest: manifest, + existingPages: existingPages, + coverRelativePath: coverRelativePath + ) + } + + private func resolvedImageSource( + index: Int, + payload: DownloadRequestPayload, + source: ResolvedSource, + retriesRequest: Bool + ) async throws -> ResolvedImageSource { + switch source { + case .normal(let thumbnailURLs): + guard let thumbnailURL = thumbnailURLs[index] else { throw AppError.notFound } + let doc = try await htmlDocument( + url: thumbnailURL, + allowsCellular: payload.options.allowCellular, + retriesRequest: retriesRequest + ) + let (_, imageURL, _) = try Parser.parseGalleryNormalImageURL( + doc: doc, + index: index + ) + return .init(imageURL: imageURL) + + case .mpv(let mpvKey, let imageKeys): + guard let imageKey = imageKeys[index] else { throw AppError.notFound } + let imageURL = try await fetchMPVImageURL( + host: payload.host, + gid: payload.gallery.gid, + index: index, + mpvKey: mpvKey, + imageKey: imageKey, + allowsCellular: payload.options.allowCellular, + retriesRequest: retriesRequest + ) + return .init(imageURL: imageURL) + } + } + + private func repairSeed( + for download: DownloadedGallery, + payload: DownloadRequestPayload, + versionSignature: String + ) -> RepairSeed? { + guard payload.mode == .repair, + let folderURL = download.resolvedFolderURL(rootURL: storage.rootURL), + fileManager().fileExists(atPath: folderURL.path), + let manifest = try? storage.readManifest(folderURL: folderURL), + manifest.gid == download.gid, + manifest.pageCount == payload.galleryDetail.pageCount, + manifest.pages.count == manifest.pageCount, + manifest.versionSignature == versionSignature + else { + return nil + } + return .init(folderURL: folderURL, manifest: manifest) + } + + private func fetchThumbnailURLs( + galleryURL: URL, + pageNum: Int, + allowsCellular: Bool + ) async throws -> [Int: URL] { + let detailPageURL = URLUtil.detailPage(url: galleryURL, pageNum: pageNum) + let urls = try await withRetry( + operation: "fetchThumbnailURLs", + context: [ + "galleryURL": galleryURL.absoluteString, + "detailPageURL": detailPageURL.absoluteString, + "pageNum": pageNum + ] + ) { + let doc = try await htmlDocument( + url: detailPageURL, + allowsCellular: allowsCellular, + retriesRequest: false + ) + return try Parser.parseThumbnailURLs(doc: doc) + } + guard !urls.isEmpty else { throw AppError.notFound } + return urls + } + + private func fetchMPVKeys( + mpvURL: URL, + allowsCellular: Bool + ) async throws -> (String, [Int: String]) { + try await withRetry( + operation: "fetchMPVKeys", + context: [ + "mpvURL": mpvURL.absoluteString + ] + ) { + let doc = try await htmlDocument( + url: mpvURL, + allowsCellular: allowsCellular, + retriesRequest: false + ) + return try Parser.parseMPVKeys(doc: doc) + } + } + + private func fetchMPVImageURL( + host: GalleryHost, + gid: String, + index: Int, + mpvKey: String, + imageKey: String, + allowsCellular: Bool, + retriesRequest: Bool = true + ) async throws -> URL { + guard let gidInteger = Int(gid) else { throw AppError.notFound } + let params: [String: Any] = [ + "method": "imagedispatch", + "gid": gidInteger, + "page": index, + "imgkey": imageKey, + "mpvkey": mpvKey + ] + + var request = URLRequest(url: host.url.appendingPathComponent("api.php")) + request.httpMethod = "POST" + request.httpBody = try JSONSerialization.data(withJSONObject: params) + request.allowsCellularAccess = allowsCellular + + let (data, response) = try await dataResponse(for: request, retriesRequest: retriesRequest) + if let error = detectResponseError( + data: data, + response: response, + requestURL: request.url + ) { + throw error + } + guard let dictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let imageURLString = dictionary["i"] as? String, + let imageURL = URL(string: imageURLString) + else { + throw AppError.parseFailed + } + return imageURL + } + + private func htmlDocument( + url: URL, + allowsCellular: Bool, + retriesRequest: Bool = true + ) async throws -> HTMLDocument { + var request = URLRequest(url: url) + request.allowsCellularAccess = allowsCellular + let (data, response) = try await dataResponse(for: request, retriesRequest: retriesRequest) + if let error = detectResponseError( + data: data, + response: response, + requestURL: request.url, + expectsHTML: true + ) { + throw error + } + if let document = try? Kanna.HTML(html: data, encoding: .utf8) { + return document + } + if let document = try? Kanna.HTML( + html: data.utf8InvalidCharactersRipped, + encoding: .utf8 + ) { + return document + } + throw AppError.parseFailed + } + + private func downloadResponse( + url: URL, + allowsCellular: Bool, + retriesRequest: Bool = true + ) async throws -> (URL, URLResponse) { + var request = URLRequest(url: url) + request.allowsCellularAccess = allowsCellular + return try await downloadResponse(for: request, retriesRequest: retriesRequest) + } + + private func downloadResponse( + for request: URLRequest, + retriesRequest: Bool = true + ) async throws -> (URL, URLResponse) { + let performRequest = { + try await self.rawDownloadResponse(for: request) + } + + let response: (URL, URLResponse) + if retriesRequest { + response = try await withRetry( + operation: "downloadResponse", + context: [ + "url": request.url?.absoluteString ?? "" + ] + ) { + try await performRequest() + } + } else { + response = try await performRequest() + } + + if let error = detectResponseError( + fileURL: response.0, + response: response.1, + requestURL: request.url + ) { + try? fileManager().removeItem(at: response.0) + throw error + } + + return response + } + + private func dataResponse( + for request: URLRequest, + retriesRequest: Bool = true + ) async throws -> (Data, URLResponse) { + if retriesRequest { + return try await withRetry( + operation: "dataResponse", + context: [ + "url": request.url?.absoluteString ?? "" + ] + ) { + try await rawDataResponse(for: request) + } + } + return try await rawDataResponse(for: request) + } + + private func rawDataResponse(for request: URLRequest) async throws -> (Data, URLResponse) { + do { + return try await urlSession.data(for: request) + } catch let error as AppError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + if Self.isCancellationLikeError(error) { + throw CancellationError() + } + if error is URLError { + throw AppError.networkingFailed + } + throw AppError.unknown + } + } + + private func rawDownloadResponse(for request: URLRequest) async throws -> (URL, URLResponse) { + do { + return try await urlSession.download(for: request) + } catch let error as AppError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + if Self.isCancellationLikeError(error) { + throw CancellationError() + } + if error is URLError { + throw AppError.networkingFailed + } + throw AppError.unknown + } + } + + private func detectResponseError( + data: Data, + response: URLResponse, + requestURL: URL?, + expectsHTML: Bool = false + ) -> AppError? { + detectResponseError( + prefixData: Data(data.prefix(Self.responseInspectionPrefixLength)), + fullData: data, + response: response, + requestURL: requestURL, + expectsHTML: expectsHTML + ) + } + + private func detectResponseError( + fileURL: URL, + response: URLResponse, + requestURL: URL? + ) -> AppError? { + let prefixData = (try? readResponsePrefixData(at: fileURL)) ?? Data() + let placeholderData: Data? + if let byteCount = responseContentLength(response) ?? fileSize(at: fileURL), + byteCount == Self.kokomadeImageByteCount + || byteCount == Self.quotaExceededImageByteCount + { + placeholderData = try? Data(contentsOf: fileURL, options: .mappedIfSafe) + } else { + placeholderData = nil + } + if let placeholderData { + if isAuthenticationRequiredPlaceholderImageData(placeholderData) { + return .authenticationRequired + } + if isQuotaExceededAssetData(placeholderData) { + return .quotaExceeded + } + } + if isAuthenticationRequiredPlaceholderResponse( + response: response, + requestURL: requestURL + ) { + return .authenticationRequired + } + if isQuotaExceededResponse( + fullData: nil, + fileURL: fileURL, + response: response, + requestURL: requestURL + ) { + return .quotaExceeded + } + let mimeType = normalizedMimeType(response) + let shouldInspect = shouldInspectTextResponse( + mimeType: mimeType, + prefixData: prefixData + ) + guard shouldInspect else { + if statusCode(for: response) == 404 { + return .notFound + } + return nil + } + + let looksLikeHTML = responseLooksLikeHTML( + mimeType: mimeType, + prefixData: prefixData, + expectsHTML: false + ) + let fullData = looksLikeHTML + ? placeholderData ?? (try? Data(contentsOf: fileURL, options: .mappedIfSafe)) + : placeholderData + + return detectResponseError( + prefixData: prefixData, + fullData: fullData, + response: response, + requestURL: requestURL, + expectsHTML: false + ) + } + + private func detectResponseError( + prefixData: Data, + fullData: Data?, + response: URLResponse, + requestURL: URL?, + expectsHTML: Bool + ) -> AppError? { + if let fullData { + if isAuthenticationRequiredPlaceholderImageData(fullData) { + return .authenticationRequired + } + if isQuotaExceededAssetData(fullData) { + return .quotaExceeded + } + } + if isAuthenticationRequiredPlaceholderResponse( + response: response, + requestURL: requestURL + ) { + return .authenticationRequired + } + if isQuotaExceededResponse( + fullData: fullData, + fileURL: nil, + response: response, + requestURL: requestURL + ) { + return .quotaExceeded + } + + let mimeType = normalizedMimeType(response) + let shouldInspect = expectsHTML || shouldInspectTextResponse( + mimeType: mimeType, + prefixData: prefixData + ) + if shouldInspect { + let inspectedData = fullData ?? prefixData + if let error = detectTextualDownloadError( + data: inspectedData, + looksLikeHTML: responseLooksLikeHTML( + mimeType: mimeType, + prefixData: prefixData, + expectsHTML: expectsHTML + ) + ) { + return error + } + } + if isAuthenticationRequiredResponse( + prefixData: prefixData, + fullData: fullData, + response: response, + requestURL: requestURL + ) { + return .authenticationRequired + } + guard shouldInspect else { return nil } + + let textPrefix = String(bytes: prefixData, encoding: .utf8) ?? "" + + let looksLikeHTML = responseLooksLikeHTML( + mimeType: mimeType, + prefixData: prefixData, + expectsHTML: expectsHTML + ) + guard looksLikeHTML else { + if statusCode(for: response) == 404 { + return .notFound + } + return nil + } + + if let fullData, + let document = try? Kanna.HTML( + html: fullData.utf8InvalidCharactersRipped, + encoding: .utf8 + ), + let error = Parser.parseDownloadPageError(doc: document) + { + return error + } + if expectsHTML { + if statusCode(for: response) == 404 { + return .notFound + } + return nil + } + Logger.error( + "Download received unexpected HTML response.", + context: [ + "url": requestURL?.absoluteString ?? "", + "snippet": String(textPrefix.prefix(240)) + ] + ) + if statusCode(for: response) == 404 { + return .notFound + } + return .parseFailed + } + + private func withRetry( + operation: String, + context: [String: Any], + maxAttempts: Int = retryLimit, + body: () async throws -> T + ) async throws -> T { + var attempt = 1 + while true { + do { + return try await body() + } catch is CancellationError { + throw CancellationError() + } catch let error as AppError { + guard error.isRetryable, attempt < maxAttempts else { + throw error + } + Logger.error( + "Download operation will retry.", + context: context.merging([ + "operation": operation, + "attempt": attempt, + "error": error.localizedDescription + ], uniquingKeysWith: { _, new in new }) + ) + attempt += 1 + } catch { + guard attempt < maxAttempts else { + throw error + } + Logger.error( + "Download operation will retry after unexpected error.", + context: context.merging([ + "operation": operation, + "attempt": attempt, + "error": error.localizedDescription + ], uniquingKeysWith: { _, new in new }) + ) + attempt += 1 + } + } + } + + private func fileExtension( + for url: URL, + response: URLResponse?, + prefixData: Data + ) -> String { + if url.pathExtension.notEmpty { + return url.pathExtension.lowercased() + } + if let mimeType = response?.mimeType?.lowercased() { + switch mimeType { + case "image/jpeg": + return "jpg" + case "image/png": + return "png" + case "image/gif": + return "gif" + case "image/webp": + return "webp" + default: + break + } + } + if prefixData.starts(with: [0x47, 0x49, 0x46]) { + return "gif" + } + if prefixData.starts(with: [0x89, 0x50, 0x4E, 0x47]) { + return "png" + } + if prefixData.starts(with: [0x52, 0x49, 0x46, 0x46]), + prefixData.count >= 12, + String(bytes: prefixData[8..<12], encoding: .utf8) == "WEBP" + { + return "webp" + } + return "jpg" + } + + private func createDirectory(at url: URL) throws { + try fileManager().createDirectory(at: url, withIntermediateDirectories: true) + } + + private func write(data: Data, to url: URL) throws { + try createDirectory(at: url.deletingLastPathComponent()) + try data.write(to: url, options: .atomic) + } + + private func moveDownloadedFile(from sourceURL: URL, to destinationURL: URL) throws { + try createDirectory(at: destinationURL.deletingLastPathComponent()) + if fileManager().fileExists(atPath: destinationURL.path) { + try fileManager().removeItem(at: destinationURL) + } + try fileManager().moveItem(at: sourceURL, to: destinationURL) + } + + private func readResponsePrefixData(at fileURL: URL) throws -> Data { + let handle = try FileHandle(forReadingFrom: fileURL) + defer { try? handle.close() } + return try handle.read(upToCount: Self.responseInspectionPrefixLength) ?? Data() + } + + private func normalizedMimeType(_ response: URLResponse) -> String? { + if let mimeType = response.mimeType?.lowercased(), mimeType.notEmpty { + return mimeType + } + if let httpResponse = response as? HTTPURLResponse, + let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type")?.lowercased(), + let mimeType = contentType.split(separator: ";").first, + !mimeType.isEmpty + { + return String(mimeType) + } + return nil + } + + private func shouldInspectTextResponse( + mimeType: String?, + prefixData: Data + ) -> Bool { + if let mimeType { + if mimeType.hasPrefix("image/") { + return prefixLooksLikeHTML(prefixData) + } + if mimeType == "text/html" || mimeType == "text/plain" { + return true + } + return prefixLooksLikeHTML(prefixData) + } + + guard !prefixIsKnownBinaryImage(prefixData) else { + return false + } + return true + } + + private func prefixLooksLikeHTML(_ prefixData: Data) -> Bool { + let prefix = String(bytes: prefixData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + guard prefix.notEmpty else { return false } + + let htmlMarkers = [ + " Bool { + expectsHTML + || mimeType == "text/html" + || prefixLooksLikeHTML(prefixData) + } + + private func detectTextualDownloadError( + data: Data, + looksLikeHTML: Bool + ) -> AppError? { + let normalizedData = data.utf8InvalidCharactersRipped + let rawContent = String(data: normalizedData, encoding: .utf8) ?? "" + if !looksLikeHTML { + return Parser.parseDownloadPageError(content: rawContent) + } + + if let document = try? Kanna.HTML( + html: normalizedData, + encoding: .utf8 + ), + let error = Parser.parseDownloadPageError(doc: document) + { + return error + } + + guard rawContent.count <= 1024 else { + return nil + } + return Parser.parseDownloadPageError(content: rawContent) + } + + private func prefixIsKnownBinaryImage(_ prefixData: Data) -> Bool { + prefixData.starts(with: [0xFF, 0xD8, 0xFF]) + || prefixData.starts(with: [0x89, 0x50, 0x4E, 0x47]) + || prefixData.starts(with: [0x47, 0x49, 0x46]) + || ( + prefixData.starts(with: [0x52, 0x49, 0x46, 0x46]) + && prefixData.count >= 12 + && String(bytes: prefixData[8..<12], encoding: .utf8) == "WEBP" + ) + } + + private func isQuotaExceededResponse( + fullData: Data?, + fileURL: URL?, + response: URLResponse, + requestURL: URL? + ) -> Bool { + let urls = [requestURL, response.url].compactMap(\.self) + let lowercasedURLs = urls.map { $0.absoluteString.lowercased() } + guard lowercasedURLs.contains(where: { url in + Self.quotaExceededImageURLSuffixes.contains(where: url.hasSuffix) + }) else { + return false + } + + let byteCount = fullData?.count ?? responseContentLength(response) ?? fileSize(at: fileURL) + guard byteCount == Self.quotaExceededImageByteCount else { + return false + } + + let data: Data? + if let fullData { + data = fullData + } else if let fileURL { + data = try? Data(contentsOf: fileURL, options: .mappedIfSafe) + } else { + data = nil + } + guard let data else { return false } + return isQuotaExceededAssetData(data) + } + + private func isAuthenticationRequiredPlaceholderResponse( + response: URLResponse, + requestURL: URL? + ) -> Bool { + [requestURL, response.url].contains { isAuthenticationRequiredPlaceholderURL($0) } + } + + private func isAuthenticationRequiredPlaceholderURL(_ url: URL?) -> Bool { + guard let url else { return false } + let normalizedURL = url.absoluteString.lowercased() + // JDownloader treats `bounce_login.php` as an account / re-login required signal for EH/EX. + // Reference: https://github.com/mirror/jdownloader/blob/master/src/jd/plugins/hoster/EHentaiOrg.java + if normalizedURL.contains("bounce_login.php") { + return true + } + return isKokomadePlaceholderURL(url) + } + + private func isKokomadePlaceholderURL(_ url: URL?) -> Bool { + guard let url else { return false } + let normalizedURL = url.absoluteString.lowercased() + // Ex login failures commonly surface as a kokomade placeholder wall when `igneous` is missing. + // Reference: https://github.com/OpportunityLiu/E-Viewer/issues/124 + return isExHentaiURL(url) + && Self.kokomadeImageURLSuffixes.contains(where: normalizedURL.hasSuffix) + } + + private func isAuthenticationRequiredResponse( + prefixData: Data, + fullData: Data?, + response: URLResponse, + requestURL: URL? + ) -> Bool { + guard isExHentaiURL(requestURL) || isExHentaiURL(response.url) else { + return false + } + guard normalizedMimeType(response) == "text/html" else { + return false + } + guard fullData?.isEmpty ?? prefixData.isEmpty else { + return false + } + + let cookies = responseCookies(response: response, requestURL: requestURL) + let hasYay = cookies.contains { + $0.name == Defaults.Cookie.yay && $0.value.notEmpty + } + let hasValidIgneous = cookies.contains { + $0.name == Defaults.Cookie.igneous + && $0.value.notEmpty + && $0.value != Defaults.Cookie.mystery + } + return hasYay && !hasValidIgneous + } + + private func responseCookies( + response: URLResponse, + requestURL: URL? + ) -> [HTTPCookie] { + let urls = [response.url, requestURL, Defaults.URL.exhentai, Defaults.URL.sexhentai] + .compactMap(\.self) + var uniqueURLs = [URL]() + for url in urls where !uniqueURLs.contains(url) { + uniqueURLs.append(url) + } + + var cookies = [HTTPCookie]() + if let httpResponse = response as? HTTPURLResponse, + let responseURL = httpResponse.url + { + let headerFields = httpResponse.allHeaderFields.reduce(into: [String: String]()) { partial, item in + guard let key = item.key as? String, + let value = item.value as? String + else { return } + partial[key] = value + } + cookies += HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: responseURL) + } + + for url in uniqueURLs { + cookies += HTTPCookieStorage.shared.cookies(for: url) ?? [] + } + return cookies + } + + private func isExHentaiURL(_ url: URL?) -> Bool { + guard let host = url?.host?.lowercased() else { + return false + } + return host == "exhentai.org" || host.hasSuffix(".exhentai.org") + } + + private func statusCode(for response: URLResponse) -> Int? { + (response as? HTTPURLResponse)?.statusCode + } + + private func responseContentLength(_ response: URLResponse) -> Int? { + if response.expectedContentLength > 0 { + return Int(response.expectedContentLength) + } + if let httpResponse = response as? HTTPURLResponse, + let header = httpResponse.value(forHTTPHeaderField: "Content-Length"), + let contentLength = Int(header) + { + return contentLength + } + return nil + } + + private func fileSize(at fileURL: URL?) -> Int? { + guard let fileURL else { return nil } + let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]) + return values?.fileSize + } + + private func fileManager() -> FileManager { + storage.fileManager + } + + private func cachedImageData(for url: URL) async -> Data? { + await cachedImageData(for: [url], includeStableAlias: false) + } + + private func cachedImageData( + for urls: [URL?], + includeStableAlias: Bool + ) async -> Data? { + let allKeys = urls + .compactMap { $0 } + .flatMap { cacheKeys(for: $0, includeStableAlias: includeStableAlias) } + let keys = allKeys.reduce(into: [String]()) { partialResult, key in + guard !partialResult.contains(key) else { return } + partialResult.append(key) + } + + for key in keys { + if let data = await cachedImageData(forKey: key) { + return data + } + } + return nil + } + + private func cachedImageData(forKey key: String) async -> Data? { + if let image = KingfisherManager.shared.cache.retrieveImageInMemoryCache(forKey: key), + let data = image.kf.data(format: .unknown) + { + return data + } + + if let data = try? KingfisherManager.shared.cache.diskStorage.value(forKey: key) { + return data + } + + return await withCheckedContinuation { continuation in + KingfisherManager.shared.cache.retrieveImage(forKey: key) { result in + switch result { + case .success(let value): + guard let image = value.image, + let data = image.kf.data(format: .unknown) + else { + continuation.resume(returning: nil) + return + } + continuation.resume(returning: data) + + case .failure: + continuation.resume(returning: nil) + } + } + } + } + + private func validatedCachedAssetData(for urls: [URL?]) async -> Data? { + guard let cachedData = await cachedImageData(for: urls, includeStableAlias: true) else { + return nil + } + guard detectCachedAssetError(data: cachedData, referenceURLs: urls) == nil else { + removeCachedImages(for: urls, includeStableAlias: true) + return nil + } + return cachedData + } + + private func detectCachedAssetError( + data: Data, + referenceURLs _: [URL?] + ) -> AppError? { + guard !data.isEmpty else { return .parseFailed } + if isAuthenticationRequiredPlaceholderImageData(data) { + return .authenticationRequired + } + if isQuotaExceededAssetData(data) { + return .quotaExceeded + } + + let looksLikeHTML = prefixLooksLikeHTML(Data(data.prefix(Self.responseInspectionPrefixLength))) + if let error = detectTextualDownloadError(data: data, looksLikeHTML: looksLikeHTML) { + return error + } + + return isDecodableImageData(data) ? nil : .parseFailed + } + + // Cached assets may be keyed by the original image URL even when the response was redirected + // to a placeholder image, so placeholder detection must rely on content fingerprints instead + // of the cache key alone. + // Observed in our own tests by fetching the live kokomade placeholder asset: + // https://exhentai.org/img/kokomade.jpg + private func isAuthenticationRequiredPlaceholderImageData(_ data: Data) -> Bool { + guard data.count == Self.kokomadeImageByteCount else { + return false + } + return sha1Hex(for: data) == Self.kokomadeImageSHA1 + } + + // Verified from the live 509 placeholder asset captured in our own tests. + private func isQuotaExceededAssetData(_ data: Data) -> Bool { + guard data.count == Self.quotaExceededImageByteCount else { + return false + } + return sha1Hex(for: data) == Self.quotaExceededImageSHA1 + } + + private func sha1Hex(for data: Data) -> String { + let digest = Insecure.SHA1.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private func isDecodableImageData(_ data: Data) -> Bool { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return false + } + return CGImageSourceGetCount(source) > 0 + } + + private func shouldSuppressFailurePersistence(for gid: String) -> Bool { + schedulingBlockedGalleryIDs.contains(gid) || Task.isCancelled + } + + nonisolated private static func isCancellationLikeError(_ error: Error) -> Bool { + if error is CancellationError { + return true + } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, + nsError.code == URLError.cancelled.rawValue + { + return true + } + + let message = nsError.localizedDescription.lowercased() + return message.contains("cancellation") + || message.contains("cancelled") + || message.contains("canceled") + } + + private func isCancellationLikeAppError(_ error: AppError) -> Bool { + guard case .fileOperationFailed(let reason) = error else { return false } + return Self.isCancellationLikeError(NSError( + domain: NSCocoaErrorDomain, + code: NSUserCancelledError, + userInfo: [NSLocalizedDescriptionKey: reason] + )) + } + + private func cacheKeys(for url: URL, includeStableAlias: Bool) -> [String] { + url.imageCacheKeys(includeStableAlias: includeStableAlias) + } + + private func removeCachedImages( + for urls: [URL?], + includeStableAlias: Bool + ) { + let keys = urls + .compactMap(\.self) + .flatMap { cacheKeys(for: $0, includeStableAlias: includeStableAlias) } + + for key in Set(keys) { + KingfisherManager.shared.cache.removeImage(forKey: key) + } + } + + private func pageImageCacheURLs( + resolvedImageSource: ResolvedImageSource?, + index: Int, + storedGalleryImageState: CachedGalleryImageState? + ) -> [URL?] { + [resolvedImageSource?.imageURL, storedGalleryImageState?.imageURLs[index]] + } + + private func pageImageCacheURLs( + imageURL: URL? + ) -> [URL?] { + [imageURL] + } + + private func canSatisfyPendingPageDownloadsFromCache( + pendingPageIndices: [Int], + temporaryFolderURL: URL, + existingPageRelativePaths: [Int: String], + storedGalleryImageState: CachedGalleryImageState? + ) async -> Bool { + guard !pendingPageIndices.isEmpty else { return true } + for index in pendingPageIndices { + if let relativePath = existingPageRelativePaths[index] { + let fileURL = temporaryFolderURL.appendingPathComponent(relativePath) + if fileManager().fileExists(atPath: fileURL.path) { + continue + } + } + guard await validatedCachedAssetData( + for: pageImageCacheURLs( + resolvedImageSource: nil, + index: index, + storedGalleryImageState: storedGalleryImageState + ) + ) != nil else { + return false + } + } + return true + } + + private func restorePendingPagesFromStoredCache( + indices: [Int], + temporaryFolderURL: URL, + existingPages: [Int: String], + storedGalleryImageState: CachedGalleryImageState? + ) async throws -> [PageResult] { + var restoredPages = [PageResult]() + for index in indices { + let cacheURLs = pageImageCacheURLs( + resolvedImageSource: nil, + index: index, + storedGalleryImageState: storedGalleryImageState + ) + guard let pageResult = try await restorePageFromCache( + index: index, + cacheURLs: cacheURLs, + folderURL: temporaryFolderURL, + preferredRelativePath: existingPages[index], + referenceURL: cacheURLs.compactMap(\.self).first, + imageURL: storedGalleryImageState?.imageURLs[index] + ) else { + continue + } + restoredPages.append(pageResult) + } + return restoredPages + } + + private func pendingPageIndices( + payload: DownloadRequestPayload, + folderURL: URL, + existingPageRelativePaths: [Int: String] + ) -> [Int] { + let selectedIndices = payload.pageSelection.map(Set.init) + return (1...payload.galleryDetail.pageCount).filter { index in + if let selectedIndices, !selectedIndices.contains(index) { + return false + } + guard let relativePath = existingPageRelativePaths[index] else { + return true + } + let fileURL = folderURL.appendingPathComponent(relativePath) + return !fileManager().fileExists(atPath: fileURL.path) + } + } + + private func shouldExposeTemporaryWorkingSet(for download: DownloadedGallery) -> Bool { + download.shouldPreserveTemporaryWorkingSet || download.status == .failed + } + + private func restorePageFromCache( + index: Int, + cacheURLs: [URL?], + folderURL: URL, + preferredRelativePath: String?, + referenceURL: URL?, + imageURL: URL?, + overwriteExistingFile: Bool = false + ) async throws -> PageResult? { + // Cache-assisted restores must reject known placeholder images before promoting them to offline files. + guard let cachedData = await validatedCachedAssetData(for: cacheURLs) + else { + return nil + } + + let relativePath: String + if let preferredRelativePath { + relativePath = preferredRelativePath + } else { + let fallbackURL = referenceURL ?? URL(string: "https://example.com/\(index).jpg")! + let fileExtension = fileExtension( + for: fallbackURL, + response: nil, + prefixData: cachedData + ) + relativePath = storage.makePageRelativePath( + index: index, + fileExtension: fileExtension + ) + } + + let fileURL = folderURL.appendingPathComponent(relativePath) + if overwriteExistingFile || !fileManager().fileExists(atPath: fileURL.path) { + try write(data: cachedData, to: fileURL) + } + + return .init( + index: index, + relativePath: relativePath, + imageURL: imageURL + ) + } + + private func preferredPageReferenceURL( + resolvedImageSource: ResolvedImageSource + ) -> URL? { + resolvedImageSource.imageURL + } + + private func preferredPageReferenceURL( + imageURL: URL? + ) -> URL? { + imageURL + } + + private func clearFailedPage(index: Int, folderURL: URL) throws { + guard let failedSnapshot = try? storage.readFailedPages(folderURL: folderURL) else { return } + let remainingPages = failedSnapshot.pages.filter { $0.index != index } + if remainingPages.count == failedSnapshot.pages.count { + return + } + if remainingPages.isEmpty { + try? storage.removeFailedPages(folderURL: folderURL) + } else { + try storage.writeFailedPages(.init(pages: remainingPages), folderURL: folderURL) + } + } + + private func temporaryCompletedPageCount( + gid: String, + expectedPageCount: Int + ) -> Int { + let folderURL = storage.temporaryFolderURL(gid: gid) + guard fileManager().fileExists(atPath: folderURL.path) else { return 0 } + return storage.existingPageRelativePaths( + folderURL: folderURL, + expectedPageCount: expectedPageCount + ) + .count + } + + private func validatedCompletedPageCount(_ download: DownloadedGallery) -> Int { + guard let folderURL = download.resolvedFolderURL(rootURL: storage.rootURL), + fileManager().fileExists(atPath: folderURL.path) + else { + return 0 + } + + guard let manifest = try? storage.readManifest(folderURL: folderURL) else { + return storage.existingPageRelativePaths( + folderURL: folderURL, + expectedPageCount: download.pageCount + ) + .count + } + + return storage.validPageCount(folderURL: folderURL, manifest: manifest) + } + + @discardableResult + private func sanitizeLocalFilesIfNeeded( + gid: String, + clearingLastError: Bool = false + ) async -> DownloadedGallery? { + guard let download = await fetchDownload(gid: gid) else { return nil } + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let hasTemporaryFolder = fileManager().fileExists(atPath: temporaryFolderURL.path) + let temporaryCompletedCount = hasTemporaryFolder + ? storage.existingPageRelativePaths( + folderURL: temporaryFolderURL, + expectedPageCount: download.pageCount + ) + .count + : 0 + if hasTemporaryFolder { + _ = storage.existingCoverRelativePath(folderURL: temporaryFolderURL) + } + + if let completedFolderURL = download.resolvedFolderURL(rootURL: storage.rootURL), + fileManager().fileExists(atPath: completedFolderURL.path) + { + _ = storage.existingPageRelativePaths( + folderURL: completedFolderURL, + expectedPageCount: download.pageCount + ) + _ = storage.existingCoverRelativePath(folderURL: completedFolderURL) + } + + var needsUpdate = false + var updatedStatus = download.status + var updatedCompletedPageCount = download.completedPageCount + var updatedLastError = download.lastError + + if hasTemporaryFolder, + shouldExposeTemporaryWorkingSet(for: download) + { + if updatedCompletedPageCount != temporaryCompletedCount { + updatedCompletedPageCount = temporaryCompletedCount + needsUpdate = true + } + if download.status == .failed { + updatedStatus = .partial + needsUpdate = true + } + } + + if [.completed, .updateAvailable, .missingFiles].contains(download.status) { + let validation = storage.validate(download: download) + let completedPageCount = validatedCompletedPageCount(download) + switch validation { + case .valid: + let expectedStatus: DownloadStatus = download.hasUpdate ? .updateAvailable : .completed + if updatedStatus != expectedStatus { + updatedStatus = expectedStatus + needsUpdate = true + } + if updatedCompletedPageCount != completedPageCount { + updatedCompletedPageCount = completedPageCount + needsUpdate = true + } + if clearingLastError || updatedLastError != nil { + updatedLastError = nil + needsUpdate = true + } + + case .missingFiles(let message): + if updatedStatus != .missingFiles { + updatedStatus = .missingFiles + needsUpdate = true + } + if updatedCompletedPageCount != completedPageCount { + updatedCompletedPageCount = completedPageCount + needsUpdate = true + } + let failure = DownloadFailure( + code: .fileOperationFailed, + message: message + ) + if updatedLastError != failure { + updatedLastError = failure + needsUpdate = true + } + } + } else if clearingLastError, updatedLastError != nil { + updatedLastError = nil + needsUpdate = true + } + + guard needsUpdate else { return download } + + do { + try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in + record.status = updatedStatus.rawValue + record.completedPageCount = Int64(updatedCompletedPageCount) + record.lastError = updatedLastError?.toData() + } + await notifyObservers() + } catch { + Logger.error(error) + } + + return await fetchDownload(gid: gid) + } + + private func captureTarget( + for download: DownloadedGallery, + index: Int + ) -> (folderURL: URL, preferredRelativePath: String?, isTemporary: Bool)? { + let temporaryFolderURL = storage.temporaryFolderURL(gid: download.gid) + if shouldExposeTemporaryWorkingSet(for: download), + fileManager().fileExists(atPath: temporaryFolderURL.path) + { + let temporaryPages = storage.existingPageRelativePaths( + folderURL: temporaryFolderURL, + expectedPageCount: download.pageCount + ) + let manifestRelativePath = (try? storage.readManifest(folderURL: temporaryFolderURL))? + .pages + .first(where: { $0.index == index })? + .relativePath + let preferredRelativePath = temporaryPages[index] + ?? manifestRelativePath + return (temporaryFolderURL, preferredRelativePath, true) + } + + guard let completedFolderURL = download.resolvedFolderURL(rootURL: storage.rootURL), + fileManager().fileExists(atPath: completedFolderURL.path) + else { + return nil + } + + let completedPages = storage.existingPageRelativePaths( + folderURL: completedFolderURL, + expectedPageCount: download.pageCount + ) + let manifestRelativePath = (try? storage.readManifest(folderURL: completedFolderURL))? + .pages + .first(where: { $0.index == index })? + .relativePath + let preferredRelativePath = completedPages[index] + ?? manifestRelativePath + return (completedFolderURL, preferredRelativePath, false) + } + + private func flushDownloadProgress( + gid: String, + pendingResolvedPages: inout [PageResult], + completedCount: Int, + lastFlushDate: inout Date, + force: Bool + ) async throws { + let shouldFlush = force + || pendingResolvedPages.count >= Self.progressFlushPageInterval + || Date().timeIntervalSince(lastFlushDate) >= Self.progressFlushMinimumInterval + guard shouldFlush else { return } + + let resolvedPages = pendingResolvedPages + pendingResolvedPages.removeAll(keepingCapacity: true) + await persistResolvedImageURLs(gid: gid, entries: resolvedPages) + try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in + record.completedPageCount = Int64(completedCount) + } + lastFlushDate = Date() + await notifyObservers() + } + + private func persistResolvedImageURLs( + gid: String, + index: Int, + imageURL: URL? + ) async { + await persistResolvedImageURLs( + gid: gid, + entries: [ + .init( + index: index, + relativePath: "", + imageURL: imageURL + ) + ] + ) + } + + private func persistResolvedImageURLs( + gid: String, + entries: [PageResult] + ) async { + guard gid.isValidGID else { return } + let validEntries = entries.filter { $0.imageURL != nil } + guard !validEntries.isEmpty else { return } + + await MainActor.run { + let context = PersistenceController.shared.container.viewContext + let request = NSFetchRequest(entityName: "GalleryStateMO") + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", gid) + + let object: GalleryStateMO + if let stored = try? context.fetch(request).first { + object = stored + } else { + object = GalleryStateMO(context: context) + object.gid = gid + } + + var imageURLs = (object.imageURLs?.toObject() as [Int: URL]?) ?? [:] + var hasChanges = false + + for entry in validEntries { + if let imageURL = entry.imageURL, + imageURLs[entry.index] != imageURL + { + imageURLs[entry.index] = imageURL + hasChanges = true + } + } + + guard hasChanges else { + return + } + + object.imageURLs = imageURLs.toData() + + guard context.hasChanges else { return } + try? context.save() + } + } + + private func fetchCachedGalleryImageState(gid: String) async -> CachedGalleryImageState? { + await MainActor.run { + guard gid.isValidGID else { return nil } + let context = PersistenceController.shared.container.viewContext + let request = NSFetchRequest(entityName: "GalleryStateMO") + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", gid) + guard let object = try? context.fetch(request).first else { return nil } + let state = object.toEntity() + return .init( + previewURLs: state.previewURLs, + imageURLs: state.imageURLs + ) + } + } + + private func validatedManifest( + at folderURL: URL, + gid: String, + pageCount: Int, + versionSignature: String, + downloadOptions: DownloadOptionsSnapshot + ) -> DownloadManifest? { + guard let manifest = try? storage.readManifest(folderURL: folderURL), + manifest.gid == gid, + manifest.pageCount == pageCount, + manifest.pages.count == pageCount, + manifest.versionSignature == versionSignature, + manifest.downloadOptions == downloadOptions + else { + return nil + } + return manifest + } + + private func activeInspectionFolderURL(for download: DownloadedGallery) -> URL? { + let temporaryFolderURL = storage.temporaryFolderURL(gid: download.gid) + let completedFolderURL = download.resolvedFolderURL(rootURL: storage.rootURL) + let temporaryFolderExists = fileManager().fileExists(atPath: temporaryFolderURL.path) + let completedFolderExists = completedFolderURL.map { fileManager().fileExists(atPath: $0.path) } ?? false + + if shouldExposeTemporaryWorkingSet(for: download) { + return temporaryFolderExists + ? temporaryFolderURL + : completedFolderURL + } + if completedFolderExists { + return completedFolderURL + } + if temporaryFolderExists { + return temporaryFolderURL + } + return nil + } + + private func sanitizedFailedPages(folderURL: URL) -> [Int: DownloadFailedPagesSnapshot.Page] { + guard var snapshot = try? storage.readFailedPages(folderURL: folderURL) else { + return [:] + } + let filteredPages = snapshot.pages.filter { !isCancellationLikeAppError($0.failure.appError) } + guard filteredPages.count != snapshot.pages.count else { + return snapshot.map + } + + snapshot.pages = filteredPages + if filteredPages.isEmpty { + try? storage.removeFailedPages(folderURL: folderURL) + } else { + try? storage.writeFailedPages(snapshot, folderURL: folderURL) + } + return snapshot.map + } + + private func normalizeNeedsAttentionDownloads(_ downloads: [DownloadedGallery]) async { + for download in downloads { + let shouldClearCancellationError = download.lastError.map { + isCancellationLikeAppError($0.appError) + } ?? false + guard download.status == .failed || shouldClearCancellationError else { continue } + + let normalizedCompletedPageCount = max( + download.completedPageCount, + temporaryCompletedPageCount( + gid: download.gid, + expectedPageCount: max(download.pageCount, 1) + ) + ) + do { + try await updateDownloadRecord(gid: download.gid, createIfMissing: false) { record in + if download.status == .failed { + record.status = DownloadStatus.partial.rawValue + record.completedPageCount = Int64(normalizedCompletedPageCount) + } + if shouldClearCancellationError { + record.lastError = nil + } + } + } catch { + Logger.error(error) + } + } + } + + private func normalizeInterruptedDownloads(_ downloads: [DownloadedGallery]) async { + let hasActiveTask = activeTask != nil + let activeGalleryID = activeGalleryID + for download in downloads where + download.needsInterruptedDownloadNormalization( + activeGalleryID: activeGalleryID, + hasActiveTask: hasActiveTask + ) + { + do { + try await updateDownloadRecord(gid: download.gid, createIfMissing: false) { record in + record.status = DownloadStatus.paused.rawValue + } + } catch { + Logger.error(error) + } + } + } + + private func reconcileActiveDownloadState() async { + guard activeTask != nil, + let activeGalleryID, + let activeDownload = await fetchDownload(gid: activeGalleryID), + activeDownload.status != .downloading + else { return } + + do { + try await updateDownloadRecord(gid: activeGalleryID, createIfMissing: false) { record in + record.status = DownloadStatus.downloading.rawValue + record.lastError = nil + } + } catch { + Logger.error(error) + } + } + + private func validateDownloads() async { + let downloads = await fetchDownloadsFromStore() + for download in downloads + where [.completed, .updateAvailable, .missingFiles].contains(download.status) { + let validation = storage.validate(download: download) + switch validation { + case .valid: + let expectedStatus: DownloadStatus = download.hasUpdate ? .updateAvailable : .completed + guard download.status != expectedStatus else { continue } + do { + try await updateDownloadRecord(gid: download.gid, createIfMissing: false) { record in + record.status = expectedStatus.rawValue + } + } catch { + Logger.error(error) + } + + case .missingFiles(let message): + do { + try await updateDownloadRecord(gid: download.gid, createIfMissing: false) { record in + record.status = DownloadStatus.missingFiles.rawValue + record.lastError = DownloadFailure( + code: .fileOperationFailed, + message: message + ) + .toData() + } + } catch { + Logger.error(error) + } + } + } + } + + private func sortDownloads(_ downloads: [DownloadedGallery]) -> [DownloadedGallery] { + downloads.sorted { lhs, rhs in + let lhsPriority = lhs.sortPriority + let rhsPriority = rhs.sortPriority + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + return (lhs.lastDownloadedAt ?? .distantPast) > (rhs.lastDownloadedAt ?? .distantPast) + } + } + + fileprivate func fetchDownload(gid: String) async -> DownloadedGallery? { + await MainActor.run { + let context = PersistenceController.shared.container.viewContext + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", gid) + return try? context.fetch(request).first?.toEntity() + } + } + + private func fetchDownloadsFromStore() async -> [DownloadedGallery] { + await MainActor.run { + let context = PersistenceController.shared.container.viewContext + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.sortDescriptors = [ + NSSortDescriptor( + keyPath: \DownloadedGalleryMO.lastDownloadedAt, + ascending: false + ) + ] + let objects = (try? context.fetch(request)) ?? [] + return objects.map { $0.toEntity() } + } + } + + private func fetchDownloadsFromStore(gids: [String]) async -> [DownloadedGallery] { + await MainActor.run { + let context = PersistenceController.shared.container.viewContext + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.predicate = NSPredicate(format: "gid IN %@", gids) + request.sortDescriptors = [ + NSSortDescriptor( + keyPath: \DownloadedGalleryMO.lastDownloadedAt, + ascending: false + ) + ] + let objects = (try? context.fetch(request)) ?? [] + return objects.map { $0.toEntity() } + } + } + + private func updateDownloadRecord( + gid: String, + createIfMissing: Bool = true, + update: @escaping (DownloadedGalleryMO) -> Void + ) async throws { + try await MainActor.run { + let context = PersistenceController.shared.container.viewContext + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", gid) + + let object: DownloadedGalleryMO + if let storedObject = try context.fetch(request).first { + object = storedObject + } else if !createIfMissing { + return + } else { + object = DownloadedGalleryMO(context: context) + object.gid = gid + object.host = GalleryHost.ehentai.rawValue + object.token = "" + object.title = "" + object.category = Category.private.rawValue + object.pageCount = 0 + object.postedDate = .now + object.rating = 0 + object.folderRelativePath = gid + object.status = DownloadStatus.queued.rawValue + object.remoteVersionSignature = "" + object.completedPageCount = 0 + } + + update(object) + guard context.hasChanges else { return } + do { + try context.save() + } catch { + throw AppError.databaseCorrupted(error.localizedDescription) + } + } + } + + private func deleteDownloadRecord(gid: String) async throws { + try await MainActor.run { + let context = PersistenceController.shared.container.viewContext + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", gid) + guard let object = try context.fetch(request).first else { return } + context.delete(object) + guard context.hasChanges else { return } + do { + try context.save() + } catch { + throw AppError.databaseCorrupted(error.localizedDescription) + } + } + } + +#if DEBUG + func testingInstallActiveTask(gid: String, task: Task) { + activeGalleryID = gid + activeTask = task + } + + func testingScheduleNextIfNeeded() async { + await scheduleNextIfNeeded() + } + + func testingFetchDownload(gid: String) async -> DownloadedGallery? { + await fetchDownload(gid: gid) + } + + func testingActiveGalleryID() -> String? { + activeGalleryID + } + + func testingRestoreCachedPages(payload: DownloadRequestPayload) async throws -> Int { + try storage.ensureRootDirectory() + let temporaryFolderURL = storage.temporaryFolderURL(gid: payload.gallery.gid) + try? fileManager().removeItem(at: temporaryFolderURL) + try createDirectory(at: temporaryFolderURL) + try createDirectory( + at: temporaryFolderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, + isDirectory: true + ) + ) + + let batchResult = try await downloadPages( + payload: payload, + pendingPageIndices: pendingPageIndices( + payload: payload, + folderURL: temporaryFolderURL, + existingPageRelativePaths: [:] + ), + source: nil, + temporaryFolderURL: temporaryFolderURL, + existingManifest: nil, + existingPageRelativePaths: [:], + storedGalleryImageState: await fetchCachedGalleryImageState(gid: payload.gallery.gid) + ) + return batchResult.pages.count + } + + func testingFetchLatestPayload( + for download: DownloadedGallery, + mode: DownloadStartMode, + pageSelection: [Int]? = nil + ) async throws -> (DownloadRequestPayload, String) { + try await fetchLatestPayload( + for: download, + mode: mode, + pageSelection: pageSelection + ) + } + + func testingPrepareWorkingSeed( + payload: DownloadRequestPayload, + existingDownload: DownloadedGallery, + versionSignature: String + ) throws -> ( + folderURL: URL, + manifest: DownloadManifest?, + existingPages: [Int: String], + coverRelativePath: String? + ) { + let temporaryFolderURL = storage.temporaryFolderURL(gid: payload.gallery.gid) + try? fileManager().removeItem(at: temporaryFolderURL) + let workingSeed = try prepareWorkingSeed( + payload: payload, + existingDownload: existingDownload, + temporaryFolderURL: temporaryFolderURL, + versionSignature: versionSignature + ) + return ( + folderURL: workingSeed.folderURL, + manifest: workingSeed.manifest, + existingPages: workingSeed.existingPages, + coverRelativePath: workingSeed.coverRelativePath + ) + } + + func testingProcessDownload(gid: String) async { + await processDownload(gid: gid) + } + + func testingDetectResponseError( + fileURL: URL, + response: URLResponse, + requestURL: URL? + ) -> AppError? { + detectResponseError( + fileURL: fileURL, + response: response, + requestURL: requestURL + ) + } +#endif +} + +// MARK: API +enum DownloadClientKey: DependencyKey { + static let liveValue = DownloadClient.live() + static let previewValue = DownloadClient.noop + static let testValue = DownloadClient.unimplemented +} + +extension DependencyValues { + var downloadClient: DownloadClient { + get { self[DownloadClientKey.self] } + set { self[DownloadClientKey.self] = newValue } + } +} + +// MARK: Test +extension DownloadClient { + static let noop: Self = .init( + observeDownloads: { + .init { continuation in + continuation.yield([]) + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + reconcileDownloads: {}, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in .failure(.notFound) }, + captureCachedPage: { _, _, _ in }, + loadInspection: { _ in .failure(.notFound) } + ) + + static func placeholder() -> Result { fatalError() } + + static let unimplemented: Self = .init( + observeDownloads: IssueReporting.unimplemented(placeholder: placeholder()), + fetchDownloads: IssueReporting.unimplemented(placeholder: placeholder()), + fetchDownload: IssueReporting.unimplemented(placeholder: placeholder()), + reconcileDownloads: IssueReporting.unimplemented(placeholder: placeholder()), + refreshDownloads: IssueReporting.unimplemented(placeholder: placeholder()), + resumeQueue: IssueReporting.unimplemented(placeholder: placeholder()), + badges: IssueReporting.unimplemented(placeholder: placeholder()), + updateRemoteSignature: IssueReporting.unimplemented(placeholder: placeholder()), + enqueue: IssueReporting.unimplemented(placeholder: placeholder()), + togglePause: IssueReporting.unimplemented(placeholder: placeholder()), + retry: IssueReporting.unimplemented(placeholder: placeholder()), + retryPages: IssueReporting.unimplemented(placeholder: placeholder()), + delete: IssueReporting.unimplemented(placeholder: placeholder()), + loadManifest: IssueReporting.unimplemented(placeholder: placeholder()), + loadLocalPageURLs: IssueReporting.unimplemented(placeholder: placeholder()), + captureCachedPage: IssueReporting.unimplemented(placeholder: placeholder()), + loadInspection: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 1f14343b0..b206efe80 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -66,11 +66,22 @@ extension ImageClient { ) func fetchImage(url: URL) async -> Result { - if KingfisherManager.shared.cache.isCached(forKey: url.absoluteString) { - return await retrieveImage(url.absoluteString) - } else { - return await downloadImage(url) + if url.isFileURL { + if let image = UIImage(contentsOfFile: url.path) { + return .success(image) + } + if let data = try? Data(contentsOf: url), + let image = UIImage(data: data) { + return .success(image) + } + return .failure(AppError.notFound) + } + for key in url.imageCacheKeys(includeStableAlias: true) { + if KingfisherManager.shared.cache.isCached(forKey: key) { + return await retrieveImage(key) + } } + return await downloadImage(url) } } diff --git a/EhPanda/App/Tools/Defaults.swift b/EhPanda/App/Tools/Defaults.swift index e13c56ccd..6823c3637 100644 --- a/EhPanda/App/Tools/Defaults.swift +++ b/EhPanda/App/Tools/Defaults.swift @@ -61,6 +61,11 @@ struct Defaults { struct FilePath { static let logs = "logs" static let ehpandaLog = "EhPanda.log" + static let downloads = "Downloads" + static let downloadPages = "pages" + static let downloadManifest = "manifest.json" + static let downloadResumeState = ".resume.json" + static let downloadFailedPages = ".failed-pages.json" } struct Regex { static let tagSuggestion: NSRegularExpression? = try? .init(pattern: "(\\S+:\".+?\"|\".+?\"|\\S+:\\S+|\\S+)") diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift index 00d8275b2..2db14f637 100644 --- a/EhPanda/App/Tools/Extensions/Extensions.swift +++ b/EhPanda/App/Tools/Extensions/Extensions.swift @@ -60,11 +60,53 @@ extension Float { // MARK: URL extension URL { static let mock = Defaults.URL.ehentai + private static let ignoredStableCacheQueryNames: Set = [ + "dl", "download", "source", "from", "view" + ] + private static let preferredStableCacheQueryNames: Set = [ + "gid", "page", "imgkey", "fileindex", "xres", "p", "key" + ] var isGIF: Bool { pathExtension == "gif" } + var stableImageCacheKey: String? { + let normalizedPath = pathComponents + .filter { $0 != "/" && $0.notEmpty } + .joined(separator: "/") + guard normalizedPath.notEmpty else { return nil } + + let queryItems = normalizedStableCacheQueryItems + guard !queryItems.isEmpty else { + return "download::\(normalizedPath)" + } + + let normalizedQuery = queryItems + .map { "\($0.name)=\($0.value ?? "")" } + .joined(separator: "&") + return "download::\(normalizedPath)?\(normalizedQuery)" + } + + func imageCacheKeys(includeStableAlias: Bool) -> [String] { + var keys = [String]() + if includeStableAlias, let stableImageCacheKey { + keys.append(stableImageCacheKey) + } + keys.append(absoluteString) + return keys + } + + func previewCacheCleanupURLs() -> [URL] { + guard let (plainURL, _, _) = Parser.parsePreviewConfigs(url: self), + plainURL != self + else { + return [self] + } + + return [self, plainURL] + } + func appending(queryItems: [URLQueryItem]) -> URL { guard !queryItems.isEmpty else { return self } var components: URLComponents = .init( @@ -98,6 +140,29 @@ extension URL { mutating func append(queryItems: [Defaults.URL.Component.Key: String]) { self = appending(queryItems: queryItems) } + + private var normalizedStableCacheQueryItems: [URLQueryItem] { + guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems? + .filter({ ($0.value ?? "").notEmpty }) + else { + return [] + } + + let preferredQueryItems = queryItems.filter { + Self.preferredStableCacheQueryNames.contains($0.name.lowercased()) + } + let filteredQueryItems = preferredQueryItems.isEmpty + ? queryItems.filter { !Self.ignoredStableCacheQueryNames.contains($0.name.lowercased()) } + : preferredQueryItems + + return filteredQueryItems.sorted { lhs, rhs in + if lhs.name == rhs.name { + return (lhs.value ?? "") < (rhs.value ?? "") + } + return lhs.name < rhs.name + } + } } // MARK: String diff --git a/EhPanda/App/Tools/Parser.swift b/EhPanda/App/Tools/Parser.swift index 7782db410..2afd68065 100644 --- a/EhPanda/App/Tools/Parser.swift +++ b/EhPanda/App/Tools/Parser.swift @@ -1825,4 +1825,117 @@ extension Parser { return .unrecognized(content: expireDescription) } } + + static func parseDownloadPageError(doc: HTMLDocument) -> AppError? { + if let banInterval = parseBanInterval(doc: doc) { + return .ipBanned(banInterval) + } + // Ex login failures commonly surface as a kokomade placeholder wall when `igneous` is missing. + // Reference: https://github.com/OpportunityLiu/E-Viewer/issues/124 + if doc.at_xpath("//img[contains(@src, 'kokomade.jpg')]") != nil { + return .authenticationRequired + } + + for candidate in downloadErrorCandidates(doc: doc) { + if let error = parseDownloadPageError(content: candidate) { + return error + } + } + return nil + } + + static func parseDownloadPageError(content: String) -> AppError? { + let normalizedContent = content.lowercased() + guard normalizedContent.notEmpty else { return nil } + + // Ex login failures commonly surface as a kokomade placeholder wall when `igneous` is missing. + // Reference: https://github.com/OpportunityLiu/E-Viewer/issues/124 + if normalizedContent.contains("kokomade.jpg") + || normalizedContent.contains("access to exhentai.org is restricted") + { + return .authenticationRequired + } + // JDownloader matches these image-limit texts to distinguish quota exhaustion from generic HTML failures. + // Reference: https://github.com/mirror/jdownloader/blob/master/src/jd/plugins/hoster/EHentaiOrg.java + if normalizedContent.contains("you have exceeded your image viewing limits") + || normalizedContent.contains("you have reached the image limit, and do not have sufficient gp to buy a download quota") + { + return .quotaExceeded + } + // `Gallery Not Available` is intentionally not mapped to `.expunged` in the download parser. + // gallery-dl treats `404 + Gallery Not Available` as an authorization-like unavailable state: + // https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py + if normalizedContent.contains("gallery not available") + || normalizedContent.contains(L10n.Constant.Website.Response.galleryUnavailable.lowercased()) + { + return nil + } + // JDownloader treats `bounce_login.php` as an account / re-login required signal for EH/EX. + // Reference: https://github.com/mirror/jdownloader/blob/master/src/jd/plugins/hoster/EHentaiOrg.java + if normalizedContent.contains("bounce_login.php"), + !looksLikeGalleryDetailMarkup(normalizedContent) + { + return .authenticationRequired + } + // gallery-dl treats `Key missing` and `Gallery not found` as gallery-level not-found conditions. + // Reference: https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py + if normalizedContent.contains("gallery not found") + || normalizedContent.contains("key missing") + { + return .notFound + } + // gallery-dl treats `Invalid page` and `Keep trying` as image-page not-found conditions. + // Reference: https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py + if normalizedContent.contains("invalid page") + || normalizedContent.contains("keep trying") + { + return .notFound + } + return nil + } + + private static func downloadErrorCandidates(doc: HTMLDocument) -> [String] { + var candidates = [String]() + + let directCandidates = [ + doc.at_xpath("//title")?.text, + doc.at_xpath("//h1")?.text, + doc.at_xpath("//div[@class='d']//p")?.text + ] + for candidate in directCandidates.compactMap(\.self) { + let trimmedCandidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedCandidate.notEmpty, !candidates.contains(trimmedCandidate) else { continue } + candidates.append(trimmedCandidate) + } + + if let bodyText = doc.body?.text?.trimmingCharacters(in: .whitespacesAndNewlines), + bodyText.notEmpty, + bodyText.count <= 1024, + !candidates.contains(bodyText) + { + candidates.append(bodyText) + } + + if let bodyContent = doc.body?.innerHTML?.trimmingCharacters(in: .whitespacesAndNewlines), + bodyContent.notEmpty, + bodyContent.count <= 2048, + !candidates.contains(bodyContent) + { + candidates.append(bodyContent) + } + + return candidates + } + + private static func looksLikeGalleryDetailMarkup(_ normalizedContent: String) -> Bool { + normalizedContent.contains(#"id="gd1""#) + || normalizedContent.contains(#"id='gd1'"#) + || normalizedContent.contains(#"id="gdt""#) + || normalizedContent.contains(#"id='gdt'"#) + || normalizedContent.contains(#"id="taglist""#) + || normalizedContent.contains(#"id='taglist'"#) + || normalizedContent.contains("gallerypopups.php") + || normalizedContent.contains("api.e-hentai.org/api.php") + || normalizedContent.contains("api.exhentai.org/api.php") + } } diff --git a/EhPanda/App/Tools/Utilities/AppUtil.swift b/EhPanda/App/Tools/Utilities/AppUtil.swift index a73c66018..02c9a234b 100644 --- a/EhPanda/App/Tools/Utilities/AppUtil.swift +++ b/EhPanda/App/Tools/Utilities/AppUtil.swift @@ -35,3 +35,83 @@ struct AppUtil { } } } + +struct AppLaunchAutomation { + struct LoginCookies { + let memberID: String + let passHash: String + let igneous: String? + } + + let initialTab: TabBarItemType? + let autoDownloadGID: String? + let loginCookies: LoginCookies? + let galleryURL: URL? + + static var current: Self? { + #if DEBUG + resolve(environment: ProcessInfo.processInfo.environment) + #else + nil + #endif + } + + static func resolve(environment: [String: String]) -> Self? { + #if DEBUG + let initialTab = environment["EHPANDA_AUTOMATION_TAB"] + .flatMap(parseTab(rawValue:)) + let autoDownloadGID = trimmedValue(environment: environment, key: "EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID") + let galleryURL = trimmedValue(environment: environment, key: "EHPANDA_AUTOMATION_GALLERY_URL") + .flatMap(URL.init(string:)) + let memberID = trimmedValue(environment: environment, key: "EHPANDA_AUTOMATION_IPB_MEMBER_ID") + let passHash = trimmedValue(environment: environment, key: "EHPANDA_AUTOMATION_IPB_PASS_HASH") + let igneous = trimmedValue(environment: environment, key: "EHPANDA_AUTOMATION_IGNEOUS") + let loginCookies: LoginCookies? = if let memberID, let passHash { + LoginCookies(memberID: memberID, passHash: passHash, igneous: igneous) + } else { + nil + } + + guard initialTab != nil || autoDownloadGID != nil || loginCookies != nil || galleryURL != nil else { + return nil + } + return .init( + initialTab: initialTab, + autoDownloadGID: autoDownloadGID, + loginCookies: loginCookies, + galleryURL: galleryURL + ) + #else + nil + #endif + } + + private static func parseTab(rawValue: String) -> TabBarItemType? { + switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "home": + return .home + case "favorites": + return .favorites + case "search": + return .search + case "downloads": + return .downloads + case "setting", "settings": + return .setting + default: + return nil + } + } + + private static func trimmedValue(environment: [String: String], key: String) -> String? { + environment[key] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .flatMap(\.nilIfEmpty) + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/EhPanda/App/Tools/Utilities/CookieUtil.swift b/EhPanda/App/Tools/Utilities/CookieUtil.swift index c7a0f58f3..ac3830c51 100644 --- a/EhPanda/App/Tools/Utilities/CookieUtil.swift +++ b/EhPanda/App/Tools/Utilities/CookieUtil.swift @@ -17,7 +17,8 @@ struct CookieUtil { var igneous, memberID, passHash: String? cookies.forEach { cookie in - guard let expiresDate = cookie.expiresDate, expiresDate > .now, !cookie.value.isEmpty else { return } + guard !cookie.value.isEmpty, + cookie.expiresDate.map({ $0 > .now }) != false else { return } if cookie.name == Defaults.Cookie.igneous && cookie.value != Defaults.Cookie.mystery { igneous = cookie.value } diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift new file mode 100644 index 000000000..8f543bf46 --- /dev/null +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -0,0 +1,400 @@ +// +// DownloadFileStorage.swift +// EhPanda +// + +import Foundation + +enum DownloadValidationState: Equatable { + case valid + case missingFiles(String) +} + +struct DownloadResumeState: Codable, Equatable { + let mode: DownloadStartMode + let versionSignature: String + let pageCount: Int + let downloadOptions: DownloadOptionsSnapshot + let pageSelection: [Int]? + + init( + mode: DownloadStartMode, + versionSignature: String, + pageCount: Int, + downloadOptions: DownloadOptionsSnapshot, + pageSelection: [Int]? = nil + ) { + self.mode = mode + self.versionSignature = versionSignature + self.pageCount = pageCount + self.downloadOptions = downloadOptions + self.pageSelection = pageSelection + } + + func matches( + mode: DownloadStartMode, + versionSignature: String, + pageCount: Int, + downloadOptions: DownloadOptionsSnapshot + ) -> Bool { + self.mode == mode + && self.versionSignature == versionSignature + && self.pageCount == pageCount + && self.downloadOptions == downloadOptions + } +} + +struct DownloadFileStorage { + let rootURL: URL + let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init( + rootURL: URL? = FileUtil.downloadsDirectoryURL, + fileManager: FileManager = .default + ) { + self.rootURL = rootURL + ?? FileUtil.temporaryDirectory.appendingPathComponent( + Defaults.FilePath.downloads, + isDirectory: true + ) + self.fileManager = fileManager + encoder = JSONEncoder() + decoder = JSONDecoder() + } + + func ensureRootDirectory() throws { + try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + var mutableRootURL = rootURL + try? mutableRootURL.setResourceValues(resourceValues) + } + + func folderURL(relativePath: String) -> URL { + rootURL.appendingPathComponent(relativePath, isDirectory: true) + } + + func manifestURL(relativePath: String) -> URL { + folderURL(relativePath: relativePath) + .appendingPathComponent(Defaults.FilePath.downloadManifest) + } + + func temporaryFolderURL(gid: String) -> URL { + rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) + } + + func temporaryFolderExists(gid: String) -> Bool { + fileManager.fileExists(atPath: temporaryFolderURL(gid: gid).path) + } + + func removeTemporaryFolder(gid: String) throws { + let targetURL = temporaryFolderURL(gid: gid) + guard fileManager.fileExists(atPath: targetURL.path) else { return } + try fileManager.removeItem(at: targetURL) + } + + func resumeStateURL(folderURL: URL) -> URL { + folderURL.appendingPathComponent(Defaults.FilePath.downloadResumeState) + } + + func failedPagesURL(folderURL: URL) -> URL { + folderURL.appendingPathComponent(Defaults.FilePath.downloadFailedPages) + } + + func writeResumeState(_ state: DownloadResumeState, folderURL: URL) throws { + let data = try encoder.encode(state) + try data.write(to: resumeStateURL(folderURL: folderURL), options: .atomic) + } + + func readResumeState(folderURL: URL) throws -> DownloadResumeState { + let data = try Data(contentsOf: resumeStateURL(folderURL: folderURL)) + return try decoder.decode(DownloadResumeState.self, from: data) + } + + func writeFailedPages(_ snapshot: DownloadFailedPagesSnapshot, folderURL: URL) throws { + let data = try encoder.encode(snapshot) + try data.write(to: failedPagesURL(folderURL: folderURL), options: .atomic) + } + + func readFailedPages(folderURL: URL) throws -> DownloadFailedPagesSnapshot { + let data = try Data(contentsOf: failedPagesURL(folderURL: folderURL)) + return try decoder.decode(DownloadFailedPagesSnapshot.self, from: data) + } + + func removeFailedPages(folderURL: URL) throws { + let url = failedPagesURL(folderURL: folderURL) + guard fileManager.fileExists(atPath: url.path) else { return } + try fileManager.removeItem(at: url) + } + + func existingPageRelativePaths( + folderURL: URL, + expectedPageCount: Int + ) -> [Int: String] { + let pagesFolderURL = folderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, + isDirectory: true + ) + guard let pageURLs = try? fileManager.contentsOfDirectory( + at: pagesFolderURL, + includingPropertiesForKeys: nil + ) else { + return [:] + } + + var relativePaths = [Int: String]() + for pageURL in pageURLs { + guard sanitizeAssetFileIfNeeded(at: pageURL) else { + continue + } + let filename = pageURL.deletingPathExtension().lastPathComponent + guard let index = Int(filename), + index >= 1, + index <= expectedPageCount + else { + continue + } + relativePaths[index] = Defaults.FilePath.downloadPages + "/\(pageURL.lastPathComponent)" + } + return relativePaths + } + + func existingCoverRelativePath(folderURL: URL) -> String? { + guard let fileURLs = try? fileManager.contentsOfDirectory( + at: folderURL, + includingPropertiesForKeys: nil + ) else { + return nil + } + + return fileURLs + .first(where: { + $0.lastPathComponent.hasPrefix("cover.") + && sanitizeAssetFileIfNeeded(at: $0) + })? + .lastPathComponent + } + + func makeFolderRelativePath(gid: String, title: String) -> String { + let invalidCharacters = CharacterSet(charactersIn: "/\\:") + .union(.controlCharacters) + let sanitizedScalars = title + .trimmingCharacters(in: .whitespacesAndNewlines) + .unicodeScalars + .map { invalidCharacters.contains($0) ? " " : String($0) } + .joined() + let collapsedWhitespace = sanitizedScalars.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ) + let trimmedSlug = collapsedWhitespace + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences( + of: "[\\s.]+$", + with: "", + options: .regularExpression + ) + let limitedSlug = String(trimmedSlug.prefix(96)) + .replacingOccurrences( + of: "[\\s.]+$", + with: "", + options: .regularExpression + ) + let fallbackTitle = limitedSlug.isEmpty ? "Gallery" : limitedSlug + return "\(gid) - \(fallbackTitle)" + } + + func makePageRelativePath(index: Int, fileExtension: String) -> String { + let ext = fileExtension.lowercased() + let paddedIndex = String(format: "%04d", index) + return Defaults.FilePath.downloadPages + "/\(paddedIndex).\(ext)" + } + + func makeCoverRelativePath(fileExtension: String) -> String { + "cover.\(fileExtension.lowercased())" + } + + func writeManifest(_ manifest: DownloadManifest, folderURL: URL) throws { + let data = try encoder.encode(manifest) + let fileURL = folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) + try data.write(to: fileURL, options: .atomic) + } + + func readManifest(folderURL: URL) throws -> DownloadManifest { + let manifestURL = folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) + let data = try Data(contentsOf: manifestURL) + return try decoder.decode(DownloadManifest.self, from: data) + } + + func replaceFolder(relativePath: String, with temporaryFolderURL: URL) throws { + let targetURL = folderURL(relativePath: relativePath) + if fileManager.fileExists(atPath: targetURL.path) { + _ = try fileManager.replaceItemAt( + targetURL, + withItemAt: temporaryFolderURL + ) + } else { + try fileManager.moveItem(at: temporaryFolderURL, to: targetURL) + } + } + + func linkOrCopyReadableAsset(at sourceURL: URL, to destinationURL: URL) throws { + guard sanitizeAssetFileIfNeeded(at: sourceURL) else { + throw AppError.fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Error.assetUnreadable(sourceURL.lastPathComponent) + ) + } + + try fileManager.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + + do { + try fileManager.linkItem(at: sourceURL, to: destinationURL) + } catch { + try fileManager.copyItem(at: sourceURL, to: destinationURL) + } + } + + func materializeRepairSeed( + from sourceFolderURL: URL, + manifest: DownloadManifest, + to temporaryFolderURL: URL + ) throws { + try fileManager.createDirectory(at: temporaryFolderURL, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: temporaryFolderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, + isDirectory: true + ), + withIntermediateDirectories: true + ) + + try linkOrCopyReadableAsset( + at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) + ) + + if let coverRelativePath = manifest.coverRelativePath, + coverRelativePath.notEmpty + { + let sourceCoverURL = sourceFolderURL.appendingPathComponent(coverRelativePath) + if sanitizeAssetFileIfNeeded(at: sourceCoverURL) { + try linkOrCopyReadableAsset( + at: sourceCoverURL, + to: temporaryFolderURL.appendingPathComponent(coverRelativePath) + ) + } + } + + for page in manifest.pages { + let sourcePageURL = sourceFolderURL.appendingPathComponent(page.relativePath) + guard sanitizeAssetFileIfNeeded(at: sourcePageURL) else { continue } + try linkOrCopyReadableAsset( + at: sourcePageURL, + to: temporaryFolderURL.appendingPathComponent(page.relativePath) + ) + } + } + + func removeFolder(relativePath: String) throws { + let targetURL = folderURL(relativePath: relativePath) + guard fileManager.fileExists(atPath: targetURL.path) else { return } + try fileManager.removeItem(at: targetURL) + } + + func cleanupTemporaryFolders(preservingGIDs: Set = []) throws { + guard fileManager.fileExists(atPath: rootURL.path) else { return } + let urls = try fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: nil + ) + for url in urls where url.lastPathComponent.hasPrefix(".tmp-") { + let gid = String(url.lastPathComponent.dropFirst(".tmp-".count)) + if preservingGIDs.contains(gid) { + continue + } + try? fileManager.removeItem(at: url) + } + } + + func validate(download: DownloadedGallery) -> DownloadValidationState { + guard let folderURL = download.resolvedFolderURL(rootURL: rootURL) else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadFolderUnresolved) + } + guard fileManager.fileExists(atPath: folderURL.path) else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadFolderMissing) + } + guard let manifestURL = download.resolvedManifestURL(rootURL: rootURL), + fileManager.fileExists(atPath: manifestURL.path) + else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestMissing) + } + guard let manifest = try? readManifest(folderURL: folderURL) else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestCorrupted) + } + guard manifest.pageCount == manifest.pages.count else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadedPagesIncomplete) + } + if let coverRelativePath = manifest.coverRelativePath, + !coverRelativePath.isEmpty + { + let coverURL = folderURL.appendingPathComponent(coverRelativePath) + guard sanitizeAssetFileIfNeeded(at: coverURL) else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.coverImageMissing) + } + } + for page in manifest.pages { + let pageURL = folderURL.appendingPathComponent(page.relativePath) + guard sanitizeAssetFileIfNeeded(at: pageURL) else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.pageMissing(page.index)) + } + } + return .valid + } + + func validPageCount(folderURL: URL, manifest: DownloadManifest) -> Int { + manifest.pages.reduce(into: 0) { count, page in + let pageURL = folderURL.appendingPathComponent(page.relativePath) + if sanitizeAssetFileIfNeeded(at: pageURL) { + count += 1 + } + } + } + + func isReadableAssetFile(at url: URL) -> Bool { + sanitizeAssetFileIfNeeded(at: url) + } + + @discardableResult + private func sanitizeAssetFileIfNeeded(at url: URL) -> Bool { + guard fileManager.fileExists(atPath: url.path) else { return false } + + let attributes: [FileAttributeKey: Any] + do { + attributes = try fileManager.attributesOfItem(atPath: url.path) + } catch { + return true + } + + let isRegularFile = (attributes[.type] as? FileAttributeType).map { $0 == .typeRegular } ?? true + guard isRegularFile else { + try? fileManager.removeItem(at: url) + return false + } + guard let fileSize = (attributes[.size] as? NSNumber)?.intValue else { return true } + guard fileSize > 0 else { + try? fileManager.removeItem(at: url) + return false + } + + return true + } +} diff --git a/EhPanda/App/Tools/Utilities/FileUtil.swift b/EhPanda/App/Tools/Utilities/FileUtil.swift index 6521d3c33..48f33a333 100644 --- a/EhPanda/App/Tools/Utilities/FileUtil.swift +++ b/EhPanda/App/Tools/Utilities/FileUtil.swift @@ -15,6 +15,12 @@ struct FileUtil { static var logsDirectoryURL: URL? { documentDirectory?.appendingPathComponent(Defaults.FilePath.logs) } + static var downloadsDirectoryURL: URL? { + documentDirectory?.appendingPathComponent( + Defaults.FilePath.downloads, + isDirectory: true + ) + } static var temporaryDirectory: URL { .init(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) } diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index b505d8d49..0956f74fb 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -155,6 +155,7 @@ "enum.setting_state_route.value.general" = "Allgemein"; "enum.setting_state_route.value.appearance" = "Oberfläche"; "enum.setting_state_route.value.reading" = "Am Lesen"; +"enum.setting_state_route.value.downloads" = "Downloads"; "enum.setting_state_route.value.laboratory" = "Experimentelles"; "enum.setting_state_route.value.about" = "Über EhPanda"; @@ -262,8 +263,24 @@ "about_view.section.title.acknowledgements" = "OK"; // MARK: DetailView +"detail_view.button.download_login" = "LOGIN"; +"detail_view.button.download_get" = "HOLEN"; +"detail_view.button.download_wait" = "WARTEN"; +"detail_view.button.download_done" = "FERTIG"; +"detail_view.button.download_update" = "UPDATE"; +"detail_view.button.download_retry" = "ERNEUT"; +"detail_view.button.download_repair" = "REPAR."; "detail_view.button.read" = "Lesen"; "detail_view.button.post_comment" = "Kommentar abgeben"; +"detail_view.accessibility.download_button.login" = "Zum Herunterladen anmelden"; +"detail_view.accessibility.download_button.download" = "Herunterladen"; +"detail_view.accessibility.download_button.queued" = "In Warteschlange"; +"detail_view.accessibility.download_button.downloading" = "Lädt %d von %d herunter"; +"detail_view.accessibility.download_button.downloaded" = "Heruntergeladene Galerie löschen"; +"detail_view.accessibility.download_button.update" = "Download aktualisieren"; +"detail_view.accessibility.download_button.retry" = "Download erneut versuchen"; +"detail_view.accessibility.download_button.repair" = "Download reparieren"; +"detail_view.accessibility.download_button.preparing" = "Download-Informationen werden geladen"; "detail_view.toolbar_item.button.archives" = "Archiv"; "detail_view.toolbar_item.button.torrents" = "Torrents"; "detail_view.toolbar_item.button.share" = "Teilen"; @@ -904,3 +921,95 @@ "enum.browsing_country.name.yemen" = "Yemen"; "enum.browsing_country.name.zambia" = "Zambia"; "enum.browsing_country.name.zimbabwe" = "Zimbabwe"; + +// MARK: Download Localization Additions +"common.button.cancel" = "Abbrechen"; +"tab_item.title.downloads" = "Downloads"; +"app_error.localized_description.database_corrupted" = "Datenbank beschädigt"; +"app_error.localized_description.copyright_claim" = "Urheberrechtsanspruch"; +"app_error.localized_description.ip_banned" = "IP-Adresse gesperrt"; +"app_error.localized_description.gallery_expunged" = "Galerie entfernt"; +"app_error.localized_description.network_error" = "Netzwerkfehler"; +"app_error.localized_description.web_image_loading_error" = "Fehler beim Laden des Webbilds"; +"app_error.localized_description.parse_error" = "Parserfehler"; +"app_error.localized_description.quota_exceeded" = "Kontingent überschritten"; +"app_error.localized_description.authentication_required" = "Authentifizierung erforderlich"; +"app_error.localized_description.file_operation_failed" = "Dateivorgang fehlgeschlagen"; +"app_error.localized_description.no_updates_available" = "Keine Updates verfügbar"; +"app_error.localized_description.not_found" = "Nicht gefunden"; +"app_error.localized_description.unknown_error" = "Unbekannter Fehler"; +"app_error.alert.quota_exceeded" = "Bildkontingent überschritten.\nBitte warte einen Moment und versuche es dann erneut."; +"app_error.alert.authentication_required" = "Für diesen Download ist eine Anmeldung erforderlich."; +"app_error.alert.local_file_operation_failed" = "Lokaler Dateivorgang fehlgeschlagen."; +"detail_view.accessibility.download_button.pause_action" = "Download pausieren"; +"detail_view.accessibility.download_button.paused" = "Download fortsetzen. Pausiert bei %d von %d"; +"detail_view.accessibility.download_button.partial" = "Download erneut versuchen. %d von %d Seiten sind bereits verfügbar."; +"detail_view.dialog.title.delete_download" = "Download löschen?"; +"detail_view.dialog.title.repair_download" = "Download reparieren?"; +"detail_view.dialog.title.update_download" = "Download aktualisieren?"; +"detail_view.dialog.title.redownload_gallery" = "Galerie erneut herunterladen?"; +"detail_view.dialog.message.delete_active_download" = "Der aktuelle Download wird gestoppt und die Galerie von diesem Gerät entfernt."; +"detail_view.dialog.message.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; +"detail_view.dialog.message.repair_download" = "Die Offline-Dateien dieser Galerie jetzt reparieren?"; +"detail_view.dialog.message.update_download" = "Diese Galerie jetzt auf die neueste Online-Version aktualisieren?"; +"detail_view.dialog.message.redownload_gallery" = "Diese Galerie jetzt vollständig neu herunterladen?"; +"detail_view.dialog.button.repair" = "Reparieren"; +"detail_view.dialog.button.update" = "Aktualisieren"; +"detail_view.dialog.button.redownload" = "Erneut laden"; +"detail_view.offline_notice.saved_details" = "Online-Details konnten nicht aktualisiert werden. Stattdessen werden gespeicherte Details angezeigt."; +"downloads_view.title.downloads" = "Downloads"; +"downloads_view.search.prompt.downloads" = "Downloads durchsuchen"; +"downloads_view.dialog.title.delete_download" = "Download löschen?"; +"downloads_view.dialog.message.delete_active_download" = "Der aktuelle Download wird abgebrochen und von diesem Gerät entfernt."; +"downloads_view.dialog.message.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; +"downloads_view.swipe.button.pages" = "Seiten"; +"downloads_view.swipe.button.update" = "Aktualisieren"; +"downloads_view.swipe.button.resume" = "Fortsetzen"; +"downloads_view.swipe.button.pause" = "Pausieren"; +"downloads_view.empty_state.downloads" = "Heruntergeladene Galerien werden hier angezeigt."; +"downloads_view.empty_state.no_matching_filters" = "Keine Downloads entsprechen den aktuellen Filtern."; +"downloads_view.button.clear_filters" = "Filter löschen"; +"downloads_view.inspector.section.actions" = "Aktionen"; +"downloads_view.inspector.section.pages" = "Seiten"; +"downloads_view.inspector.button.retry_failed_pages" = "Fehlgeschlagene Seiten erneut versuchen (%d)"; +"downloads_view.inspector.button.update_download" = "Download aktualisieren"; +"downloads_view.inspector.title.download_status" = "Downloadstatus"; +"downloads_view.inspector.page.pending" = "Ausstehend"; +"downloads_view.inspector.page.tap_to_retry" = "Tippen, um diese Seite erneut zu versuchen"; +"downloads_view.inspector.page.title" = "Seite %d"; +"download_setting_view.section.title.download_queue" = "Download-Warteschlange"; +"download_setting_view.section.title.network" = "Netzwerk"; +"download_setting_view.title.concurrent_image_downloads" = "Gleichzeitige Bilddownloads"; +"download_setting_view.title.retry_failed_pages_automatically" = "Fehlgeschlagene Seiten automatisch erneut versuchen"; +"download_setting_view.title.allow_cellular_downloads" = "Downloads über Mobilfunk erlauben"; +"download_setting_view.footer.network" = "Es wird immer nur eine Galerie gleichzeitig heruntergeladen. Mit dieser Einstellung steuerst du, wie viele Galerieseiten parallel geladen werden, ob Mobilfunk erlaubt ist und dass Dateien im Downloads-Ordner der App gespeichert werden."; +"enum.download_thread_mode.value.single" = "1 Bild gleichzeitig"; +"enum.download_thread_mode.value.double" = "2 Bilder gleichzeitig"; +"enum.download_thread_mode.value.triple" = "3 Bilder gleichzeitig"; +"enum.download_thread_mode.value.quadruple" = "4 Bilder gleichzeitig"; +"enum.download_thread_mode.value.quintuple" = "5 Bilder gleichzeitig"; +"enum.download_list_filter.title.all" = "Alle"; +"enum.download_list_filter.title.active" = "Aktiv"; +"enum.download_list_filter.title.completed" = "Heruntergeladen"; +"enum.download_list_filter.title.failed" = "Benötigt Aufmerksamkeit"; +"enum.download_list_filter.title.update" = "Update verfügbar"; +"struct.download_badge.text.queued" = "In Warteschlange"; +"struct.download_badge.text.downloading" = "Lädt %d/%d herunter"; +"struct.download_badge.text.paused" = "Pausiert %d/%d"; +"struct.download_badge.text.needs_attention_progress" = "Benötigt Aufmerksamkeit %d/%d"; +"struct.download_badge.text.downloaded" = "Heruntergeladen"; +"struct.download_badge.text.needs_attention" = "Benötigt Aufmerksamkeit"; +"struct.download_badge.text.update_available" = "Update verfügbar"; +"struct.download_badge.text.needs_repair" = "Reparatur nötig"; +"struct.download_badge.compact.downloading" = "DL"; +"struct.download_badge.compact.paused" = "Pause"; +"struct.download_badge.compact.needs_attention" = "Achtung"; +"struct.download_badge.compact.done" = "Fertig"; +"download_file_storage.error.asset_unreadable" = "Asset-Datei ist nicht lesbar: %@"; +"download_file_storage.validation.download_folder_unresolved" = "Download-Ordner konnte nicht aufgelöst werden."; +"download_file_storage.validation.download_folder_missing" = "Download-Ordner fehlt."; +"download_file_storage.validation.manifest_missing" = "Manifest-Datei fehlt."; +"download_file_storage.validation.manifest_corrupted" = "Manifest-Datei ist beschädigt."; +"download_file_storage.validation.downloaded_pages_incomplete" = "Heruntergeladene Seiten sind unvollständig."; +"download_file_storage.validation.cover_image_missing" = "Coverbild fehlt."; +"download_file_storage.validation.page_missing" = "Seite %d fehlt."; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index a3ca9bacc..73a039f0f 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -42,10 +42,14 @@ "common.value.seconds" = "%@ seconds"; "common.value.records" = "%@ records"; +// MARK: Common button +"common.button.cancel" = "Cancel"; + // MARK: TabItem "tab_item.title.home" = "Home"; "tab_item.title.favorites" = "Favorites"; "tab_item.title.search" = "Search"; +"tab_item.title.downloads" = "Downloads"; "tab_item.title.setting" = "Setting"; // MARK: ToolbarItem @@ -74,6 +78,24 @@ "error_view.title.copyright_claim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; "error_view.title.gallery_unavailable" = "This gallery has been removed or is unavailable."; +// MARK: AppError +"app_error.localized_description.database_corrupted" = "Database Corrupted"; +"app_error.localized_description.copyright_claim" = "Copyright Claim"; +"app_error.localized_description.ip_banned" = "IP Banned"; +"app_error.localized_description.gallery_expunged" = "Gallery Expunged"; +"app_error.localized_description.network_error" = "Network Error"; +"app_error.localized_description.web_image_loading_error" = "Web image loading error"; +"app_error.localized_description.parse_error" = "Parse Error"; +"app_error.localized_description.quota_exceeded" = "Quota Exceeded"; +"app_error.localized_description.authentication_required" = "Authentication Required"; +"app_error.localized_description.file_operation_failed" = "File Operation Failed"; +"app_error.localized_description.no_updates_available" = "No updates available"; +"app_error.localized_description.not_found" = "Not found"; +"app_error.localized_description.unknown_error" = "Unknown Error"; +"app_error.alert.quota_exceeded" = "Image quota exceeded.\nPlease wait and try again later."; +"app_error.alert.authentication_required" = "Login required to access this download."; +"app_error.alert.local_file_operation_failed" = "Local file operation failed."; + // MARK: ConfirmationDialog "confirmation_dialog.title.drop_database" = "You will lose all your data in this app.\nAre you sure to drop the database?"; "confirmation_dialog.title.remove_custom_translations" = "Are you sure to remove your custom translations?"; @@ -155,6 +177,7 @@ "enum.setting_state_route.value.general" = "General"; "enum.setting_state_route.value.appearance" = "Appearance"; "enum.setting_state_route.value.reading" = "Reading"; +"enum.setting_state_route.value.downloads" = "Downloads"; "enum.setting_state_route.value.laboratory" = "Laboratory"; "enum.setting_state_route.value.about" = "About EhPanda"; @@ -262,8 +285,27 @@ "about_view.section.title.acknowledgements" = "Acknowledgements"; // MARK: DetailView +"detail_view.button.download_login" = "LOG IN"; +"detail_view.button.download_get" = "GET"; +"detail_view.button.download_wait" = "WAIT"; +"detail_view.button.download_done" = "DONE"; +"detail_view.button.download_update" = "UPDATE"; +"detail_view.button.download_retry" = "RETRY"; +"detail_view.button.download_repair" = "REPAIR"; "detail_view.button.read" = "Read"; "detail_view.button.post_comment" = "Post comment"; +"detail_view.accessibility.download_button.login" = "Log in to download"; +"detail_view.accessibility.download_button.download" = "Download"; +"detail_view.accessibility.download_button.queued" = "Queued"; +"detail_view.accessibility.download_button.downloading" = "Downloading %d of %d"; +"detail_view.accessibility.download_button.downloaded" = "Delete downloaded gallery"; +"detail_view.accessibility.download_button.update" = "Update download"; +"detail_view.accessibility.download_button.retry" = "Retry download"; +"detail_view.accessibility.download_button.repair" = "Repair download"; +"detail_view.accessibility.download_button.preparing" = "Preparing download"; +"detail_view.accessibility.download_button.pause_action" = "Pause download"; +"detail_view.accessibility.download_button.paused" = "Resume download. Paused at %d of %d"; +"detail_view.accessibility.download_button.partial" = "Retry download. %d of %d pages are already available."; "detail_view.toolbar_item.button.archives" = "Archives"; "detail_view.toolbar_item.button.torrents" = "Torrents"; "detail_view.toolbar_item.button.share" = "Share"; @@ -282,6 +324,19 @@ "detail_view.action_section.button.similar_gallery" = "Similar Gallery"; "detail_view.section.title.previews" = "Previews"; "detail_view.section.title.comments" = "Comments"; +"detail_view.dialog.title.delete_download" = "Delete Download?"; +"detail_view.dialog.title.repair_download" = "Repair Download?"; +"detail_view.dialog.title.update_download" = "Update Download?"; +"detail_view.dialog.title.redownload_gallery" = "Redownload Gallery?"; +"detail_view.dialog.message.delete_active_download" = "This will stop the current download and remove the gallery from this device."; +"detail_view.dialog.message.delete_downloaded_gallery" = "This will remove the downloaded gallery from this device."; +"detail_view.dialog.message.repair_download" = "Repair the offline files for this gallery now?"; +"detail_view.dialog.message.update_download" = "Update this gallery to the newest online version now?"; +"detail_view.dialog.message.redownload_gallery" = "Start a fresh download for this gallery now?"; +"detail_view.dialog.button.repair" = "Repair"; +"detail_view.dialog.button.update" = "Update"; +"detail_view.dialog.button.redownload" = "Redownload"; +"detail_view.offline_notice.saved_details" = "Couldn't refresh online details. Showing saved details instead."; // MARK: ArchivesView "archives_view.title.archives" = "Archives"; @@ -331,6 +386,36 @@ "tag_detail_view.section.title.images" = "Images"; "tag_detail_view.section.title.links" = "Links"; +// MARK: DownloadsView +"downloads_view.title.downloads" = "Downloads"; +"downloads_view.search.prompt.downloads" = "Search downloads"; +"downloads_view.dialog.title.delete_download" = "Delete Download?"; +"downloads_view.dialog.message.delete_active_download" = "This will cancel the current download and remove it from this device."; +"downloads_view.dialog.message.delete_downloaded_gallery" = "This will remove the downloaded gallery from this device."; +"downloads_view.swipe.button.pages" = "Pages"; +"downloads_view.swipe.button.update" = "Update"; +"downloads_view.swipe.button.resume" = "Resume"; +"downloads_view.swipe.button.pause" = "Pause"; +"downloads_view.empty_state.downloads" = "Downloaded galleries will appear here."; +"downloads_view.empty_state.no_matching_filters" = "No downloads match the current filters."; +"downloads_view.button.clear_filters" = "Clear Filters"; +"downloads_view.inspector.section.actions" = "Actions"; +"downloads_view.inspector.section.pages" = "Pages"; +"downloads_view.inspector.button.retry_failed_pages" = "Retry Failed Pages (%d)"; +"downloads_view.inspector.button.update_download" = "Update Download"; +"downloads_view.inspector.title.download_status" = "Download Status"; +"downloads_view.inspector.page.pending" = "Pending"; +"downloads_view.inspector.page.tap_to_retry" = "Tap to retry this page"; +"downloads_view.inspector.page.title" = "Page %d"; + +// MARK: DownloadSettingView +"download_setting_view.section.title.download_queue" = "Download Queue"; +"download_setting_view.section.title.network" = "Network"; +"download_setting_view.title.concurrent_image_downloads" = "Concurrent image downloads"; +"download_setting_view.title.retry_failed_pages_automatically" = "Retry failed pages automatically"; +"download_setting_view.title.allow_cellular_downloads" = "Allow cellular downloads"; +"download_setting_view.footer.network" = "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder."; + // MARK: CommentsView "comments_view.title.comments" = "Comments"; @@ -356,6 +441,44 @@ // AutoPlayPolicy "enum.auto_play_policy.value.off" = "Off"; +// MARK: DownloadThreadMode +"enum.download_thread_mode.value.single" = "1 image at a time"; +"enum.download_thread_mode.value.double" = "2 images at a time"; +"enum.download_thread_mode.value.triple" = "3 images at a time"; +"enum.download_thread_mode.value.quadruple" = "4 images at a time"; +"enum.download_thread_mode.value.quintuple" = "5 images at a time"; + +// MARK: DownloadListFilter +"enum.download_list_filter.title.all" = "All"; +"enum.download_list_filter.title.active" = "Active"; +"enum.download_list_filter.title.completed" = "Downloaded"; +"enum.download_list_filter.title.failed" = "Needs Attention"; +"enum.download_list_filter.title.update" = "Update Available"; + +// MARK: DownloadBadge +"struct.download_badge.text.queued" = "Queued"; +"struct.download_badge.text.downloading" = "Downloading %d/%d"; +"struct.download_badge.text.paused" = "Paused %d/%d"; +"struct.download_badge.text.needs_attention_progress" = "Needs Attention %d/%d"; +"struct.download_badge.text.downloaded" = "Downloaded"; +"struct.download_badge.text.needs_attention" = "Needs Attention"; +"struct.download_badge.text.update_available" = "Update Available"; +"struct.download_badge.text.needs_repair" = "Needs Repair"; +"struct.download_badge.compact.downloading" = "DL"; +"struct.download_badge.compact.paused" = "Pause"; +"struct.download_badge.compact.needs_attention" = "Needs Attention"; +"struct.download_badge.compact.done" = "Done"; + +// MARK: DownloadFileStorage +"download_file_storage.error.asset_unreadable" = "Asset file is unreadable: %@"; +"download_file_storage.validation.download_folder_unresolved" = "Download folder could not be resolved."; +"download_file_storage.validation.download_folder_missing" = "Download folder is missing."; +"download_file_storage.validation.manifest_missing" = "Manifest file is missing."; +"download_file_storage.validation.manifest_corrupted" = "Manifest file is corrupted."; +"download_file_storage.validation.downloaded_pages_incomplete" = "Downloaded pages are incomplete."; +"download_file_storage.validation.cover_image_missing" = "Cover image is missing."; +"download_file_storage.validation.page_missing" = "Page %d is missing."; + // MARK: FiltersView "filters_view.title.filters" = "Filters"; "filters_view.title.advanced_settings" = "Advanced settings"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index fa68dba1a..f1ad08ffe 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -155,6 +155,7 @@ "enum.setting_state_route.value.general" = "一般"; "enum.setting_state_route.value.appearance" = "外観"; "enum.setting_state_route.value.reading" = "閲覧"; +"enum.setting_state_route.value.downloads" = "ダウンロード"; "enum.setting_state_route.value.laboratory" = "ラボ"; "enum.setting_state_route.value.about" = "EhPanda について"; @@ -262,8 +263,24 @@ "about_view.section.title.acknowledgements" = "謝辞"; // MARK: DetailView +"detail_view.button.download_login" = "ログイン"; +"detail_view.button.download_get" = "入手"; +"detail_view.button.download_wait" = "待機"; +"detail_view.button.download_done" = "完了"; +"detail_view.button.download_update" = "更新"; +"detail_view.button.download_retry" = "再試行"; +"detail_view.button.download_repair" = "修復"; "detail_view.button.read" = "閲覧"; "detail_view.button.post_comment" = "コメントを書く"; +"detail_view.accessibility.download_button.login" = "ダウンロードするにはログインが必要です"; +"detail_view.accessibility.download_button.download" = "ダウンロード"; +"detail_view.accessibility.download_button.queued" = "ダウンロード待ち"; +"detail_view.accessibility.download_button.downloading" = "%d / %d ページをダウンロード中"; +"detail_view.accessibility.download_button.downloaded" = "ダウンロード済みのギャラリーを削除"; +"detail_view.accessibility.download_button.update" = "ダウンロードを更新"; +"detail_view.accessibility.download_button.retry" = "ダウンロードを再試行"; +"detail_view.accessibility.download_button.repair" = "ダウンロードを修復"; +"detail_view.accessibility.download_button.preparing" = "ダウンロード情報を取得中"; "detail_view.toolbar_item.button.archives" = "アーカイブ"; "detail_view.toolbar_item.button.torrents" = "トレント"; "detail_view.toolbar_item.button.share" = "共有"; @@ -904,3 +921,95 @@ "enum.browsing_country.name.yemen" = "イエメン"; "enum.browsing_country.name.zambia" = "ザンビア"; "enum.browsing_country.name.zimbabwe" = "ジンバブエ"; + +// MARK: Download Localization Additions +"common.button.cancel" = "キャンセル"; +"tab_item.title.downloads" = "ダウンロード"; +"app_error.localized_description.database_corrupted" = "データベース破損"; +"app_error.localized_description.copyright_claim" = "著作権侵害の申し立て"; +"app_error.localized_description.ip_banned" = "IP アドレスがブロックされました"; +"app_error.localized_description.gallery_expunged" = "ギャラリー削除済み"; +"app_error.localized_description.network_error" = "ネットワークエラー"; +"app_error.localized_description.web_image_loading_error" = "Web 画像の読み込みエラー"; +"app_error.localized_description.parse_error" = "解析エラー"; +"app_error.localized_description.quota_exceeded" = "画像割り当て超過"; +"app_error.localized_description.authentication_required" = "認証が必要です"; +"app_error.localized_description.file_operation_failed" = "ファイル操作に失敗しました"; +"app_error.localized_description.no_updates_available" = "利用可能な更新はありません"; +"app_error.localized_description.not_found" = "見つかりません"; +"app_error.localized_description.unknown_error" = "不明なエラー"; +"app_error.alert.quota_exceeded" = "画像の帯域割り当てを使い切りました。\nしばらく待ってからもう一度お試しください。"; +"app_error.alert.authentication_required" = "このダウンロードにアクセスするにはログインが必要です。"; +"app_error.alert.local_file_operation_failed" = "ローカルファイルの操作に失敗しました。"; +"detail_view.accessibility.download_button.pause_action" = "ダウンロードを一時停止"; +"detail_view.accessibility.download_button.paused" = "ダウンロードを再開。%d / %d ページで停止中"; +"detail_view.accessibility.download_button.partial" = "ダウンロードを再試行。すでに %d / %d ページが利用可能です。"; +"detail_view.dialog.title.delete_download" = "ダウンロードを削除しますか?"; +"detail_view.dialog.title.repair_download" = "ダウンロードを修復しますか?"; +"detail_view.dialog.title.update_download" = "ダウンロードを更新しますか?"; +"detail_view.dialog.title.redownload_gallery" = "ギャラリーを再ダウンロードしますか?"; +"detail_view.dialog.message.delete_active_download" = "現在のダウンロードを停止し、このデバイスからギャラリーを削除します。"; +"detail_view.dialog.message.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; +"detail_view.dialog.message.repair_download" = "このギャラリーのオフラインファイルを今すぐ修復しますか?"; +"detail_view.dialog.message.update_download" = "このギャラリーを今すぐオンラインの最新バージョンに更新しますか?"; +"detail_view.dialog.message.redownload_gallery" = "このギャラリーを今すぐ最初から再ダウンロードしますか?"; +"detail_view.dialog.button.repair" = "修復"; +"detail_view.dialog.button.update" = "更新"; +"detail_view.dialog.button.redownload" = "再ダウンロード"; +"detail_view.offline_notice.saved_details" = "オンラインの詳細を更新できなかったため、保存済みの詳細を表示しています。"; +"downloads_view.title.downloads" = "ダウンロード"; +"downloads_view.search.prompt.downloads" = "ダウンロードを検索"; +"downloads_view.dialog.title.delete_download" = "ダウンロードを削除しますか?"; +"downloads_view.dialog.message.delete_active_download" = "現在のダウンロードをキャンセルし、このデバイスから削除します。"; +"downloads_view.dialog.message.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; +"downloads_view.swipe.button.pages" = "ページ"; +"downloads_view.swipe.button.update" = "更新"; +"downloads_view.swipe.button.resume" = "再開"; +"downloads_view.swipe.button.pause" = "一時停止"; +"downloads_view.empty_state.downloads" = "ダウンロードしたギャラリーはここに表示されます。"; +"downloads_view.empty_state.no_matching_filters" = "現在のフィルターに一致するダウンロードはありません。"; +"downloads_view.button.clear_filters" = "フィルターをクリア"; +"downloads_view.inspector.section.actions" = "操作"; +"downloads_view.inspector.section.pages" = "ページ"; +"downloads_view.inspector.button.retry_failed_pages" = "失敗したページを再試行 (%d)"; +"downloads_view.inspector.button.update_download" = "ダウンロードを更新"; +"downloads_view.inspector.title.download_status" = "ダウンロード状況"; +"downloads_view.inspector.page.pending" = "待機中"; +"downloads_view.inspector.page.tap_to_retry" = "タップしてこのページを再試行"; +"downloads_view.inspector.page.title" = "ページ %d"; +"download_setting_view.section.title.download_queue" = "ダウンロードキュー"; +"download_setting_view.section.title.network" = "ネットワーク"; +"download_setting_view.title.concurrent_image_downloads" = "同時画像ダウンロード数"; +"download_setting_view.title.retry_failed_pages_automatically" = "失敗したページを自動で再試行"; +"download_setting_view.title.allow_cellular_downloads" = "モバイル通信でのダウンロードを許可"; +"download_setting_view.footer.network" = "一度にダウンロードされるギャラリーは 1 件だけです。この設定では、1 つのギャラリー内で同時にダウンロードするページ数、モバイル通信の許可または禁止、そしてファイルをアプリの Downloads フォルダに保存する動作を管理します。"; +"enum.download_thread_mode.value.single" = "1 枚ずつダウンロード"; +"enum.download_thread_mode.value.double" = "2 枚ずつダウンロード"; +"enum.download_thread_mode.value.triple" = "3 枚ずつダウンロード"; +"enum.download_thread_mode.value.quadruple" = "4 枚ずつダウンロード"; +"enum.download_thread_mode.value.quintuple" = "5 枚ずつダウンロード"; +"enum.download_list_filter.title.all" = "すべて"; +"enum.download_list_filter.title.active" = "進行中"; +"enum.download_list_filter.title.completed" = "ダウンロード済み"; +"enum.download_list_filter.title.failed" = "要対応"; +"enum.download_list_filter.title.update" = "更新あり"; +"struct.download_badge.text.queued" = "待機中"; +"struct.download_badge.text.downloading" = "%d/%d をダウンロード中"; +"struct.download_badge.text.paused" = "%d/%d で一時停止"; +"struct.download_badge.text.needs_attention_progress" = "要対応 %d/%d"; +"struct.download_badge.text.downloaded" = "ダウンロード済み"; +"struct.download_badge.text.needs_attention" = "要対応"; +"struct.download_badge.text.update_available" = "更新あり"; +"struct.download_badge.text.needs_repair" = "要修復"; +"struct.download_badge.compact.downloading" = "DL"; +"struct.download_badge.compact.paused" = "一時停止"; +"struct.download_badge.compact.needs_attention" = "要対応"; +"struct.download_badge.compact.done" = "完了"; +"download_file_storage.error.asset_unreadable" = "アセットファイルを読み取れません: %@"; +"download_file_storage.validation.download_folder_unresolved" = "ダウンロードフォルダを解決できませんでした。"; +"download_file_storage.validation.download_folder_missing" = "ダウンロードフォルダが見つかりません。"; +"download_file_storage.validation.manifest_missing" = "マニフェストファイルが見つかりません。"; +"download_file_storage.validation.manifest_corrupted" = "マニフェストファイルが破損しています。"; +"download_file_storage.validation.downloaded_pages_incomplete" = "ダウンロード済みページが不完全です。"; +"download_file_storage.validation.cover_image_missing" = "表紙画像が見つかりません。"; +"download_file_storage.validation.page_missing" = "ページ %d が見つかりません。"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index b92668981..503cb0f73 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -155,6 +155,7 @@ "enum.setting_state_route.value.general" = "일반"; "enum.setting_state_route.value.appearance" = "외관"; "enum.setting_state_route.value.reading" = "읽기"; +"enum.setting_state_route.value.downloads" = "다운로드"; "enum.setting_state_route.value.laboratory" = "실험실"; "enum.setting_state_route.value.about" = "EhPanda 정보"; @@ -262,8 +263,24 @@ "about_view.section.title.acknowledgements" = "도움을 주신 분들"; // MARK: DetailView +"detail_view.button.download_login" = "로그인"; +"detail_view.button.download_get" = "받기"; +"detail_view.button.download_wait" = "대기"; +"detail_view.button.download_done" = "완료"; +"detail_view.button.download_update" = "업데이트"; +"detail_view.button.download_retry" = "재시도"; +"detail_view.button.download_repair" = "복구"; "detail_view.button.read" = "읽기"; "detail_view.button.post_comment" = "평가 남기기"; +"detail_view.accessibility.download_button.login" = "다운로드하려면 로그인해야 합니다"; +"detail_view.accessibility.download_button.download" = "다운로드"; +"detail_view.accessibility.download_button.queued" = "다운로드 대기 중"; +"detail_view.accessibility.download_button.downloading" = "%d / %d 페이지 다운로드 중"; +"detail_view.accessibility.download_button.downloaded" = "다운로드한 갤러리 삭제"; +"detail_view.accessibility.download_button.update" = "다운로드 업데이트"; +"detail_view.accessibility.download_button.retry" = "다운로드 다시 시도"; +"detail_view.accessibility.download_button.repair" = "다운로드 복구"; +"detail_view.accessibility.download_button.preparing" = "다운로드 정보를 불러오는 중"; "detail_view.toolbar_item.button.archives" = "아카이브"; "detail_view.toolbar_item.button.torrents" = "토렌트"; "detail_view.toolbar_item.button.share" = "공유"; @@ -904,3 +921,95 @@ "enum.browsing_country.name.yemen" = "예멘"; "enum.browsing_country.name.zambia" = "잠비아"; "enum.browsing_country.name.zimbabwe" = "짐바브웨"; + +// MARK: Download Localization Additions +"common.button.cancel" = "취소"; +"tab_item.title.downloads" = "다운로드"; +"app_error.localized_description.database_corrupted" = "데이터베이스 손상"; +"app_error.localized_description.copyright_claim" = "저작권 신고"; +"app_error.localized_description.ip_banned" = "IP 차단됨"; +"app_error.localized_description.gallery_expunged" = "갤러리 삭제됨"; +"app_error.localized_description.network_error" = "네트워크 오류"; +"app_error.localized_description.web_image_loading_error" = "웹 이미지 로드 오류"; +"app_error.localized_description.parse_error" = "파싱 오류"; +"app_error.localized_description.quota_exceeded" = "할당량 초과"; +"app_error.localized_description.authentication_required" = "인증 필요"; +"app_error.localized_description.file_operation_failed" = "파일 작업 실패"; +"app_error.localized_description.no_updates_available" = "사용 가능한 업데이트 없음"; +"app_error.localized_description.not_found" = "찾을 수 없음"; +"app_error.localized_description.unknown_error" = "알 수 없는 오류"; +"app_error.alert.quota_exceeded" = "이미지 할당량을 모두 사용했습니다.\n잠시 후 다시 시도해 주세요."; +"app_error.alert.authentication_required" = "이 다운로드에 접근하려면 로그인해야 합니다."; +"app_error.alert.local_file_operation_failed" = "로컬 파일 작업에 실패했습니다."; +"detail_view.accessibility.download_button.pause_action" = "다운로드 일시 정지"; +"detail_view.accessibility.download_button.paused" = "다운로드 다시 시작. %d / %d 페이지에서 일시 정지됨"; +"detail_view.accessibility.download_button.partial" = "다운로드 다시 시도. 이미 %d / %d 페이지를 사용할 수 있습니다."; +"detail_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; +"detail_view.dialog.title.repair_download" = "다운로드를 복구할까요?"; +"detail_view.dialog.title.update_download" = "다운로드를 업데이트할까요?"; +"detail_view.dialog.title.redownload_gallery" = "갤러리를 다시 다운로드할까요?"; +"detail_view.dialog.message.delete_active_download" = "현재 다운로드를 중지하고 이 기기에서 갤러리를 삭제합니다."; +"detail_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; +"detail_view.dialog.message.repair_download" = "이 갤러리의 오프라인 파일을 지금 복구할까요?"; +"detail_view.dialog.message.update_download" = "이 갤러리를 지금 온라인 최신 버전으로 업데이트할까요?"; +"detail_view.dialog.message.redownload_gallery" = "이 갤러리를 지금 처음부터 다시 다운로드할까요?"; +"detail_view.dialog.button.repair" = "복구"; +"detail_view.dialog.button.update" = "업데이트"; +"detail_view.dialog.button.redownload" = "다시 다운로드"; +"detail_view.offline_notice.saved_details" = "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다."; +"downloads_view.title.downloads" = "다운로드"; +"downloads_view.search.prompt.downloads" = "다운로드 검색"; +"downloads_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; +"downloads_view.dialog.message.delete_active_download" = "현재 다운로드를 취소하고 이 기기에서 삭제합니다."; +"downloads_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; +"downloads_view.swipe.button.pages" = "페이지"; +"downloads_view.swipe.button.update" = "업데이트"; +"downloads_view.swipe.button.resume" = "재개"; +"downloads_view.swipe.button.pause" = "일시 정지"; +"downloads_view.empty_state.downloads" = "다운로드한 갤러리가 여기에 표시됩니다."; +"downloads_view.empty_state.no_matching_filters" = "현재 필터와 일치하는 다운로드가 없습니다."; +"downloads_view.button.clear_filters" = "필터 지우기"; +"downloads_view.inspector.section.actions" = "동작"; +"downloads_view.inspector.section.pages" = "페이지"; +"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도 (%d)"; +"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; +"downloads_view.inspector.title.download_status" = "다운로드 상태"; +"downloads_view.inspector.page.pending" = "대기 중"; +"downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; +"downloads_view.inspector.page.title" = "페이지 %d"; +"download_setting_view.section.title.download_queue" = "다운로드 대기열"; +"download_setting_view.section.title.network" = "네트워크"; +"download_setting_view.title.concurrent_image_downloads" = "동시 이미지 다운로드 수"; +"download_setting_view.title.retry_failed_pages_automatically" = "실패한 페이지 자동 재시도"; +"download_setting_view.title.allow_cellular_downloads" = "셀룰러 다운로드 허용"; +"download_setting_view.footer.network" = "한 번에 하나의 갤러리만 다운로드됩니다. 이 설정으로 한 갤러리 안에서 동시에 다운로드할 페이지 수, 셀룰러 다운로드 허용 여부, 그리고 파일을 앱의 Downloads 폴더에 저장하는 방식을 제어합니다."; +"enum.download_thread_mode.value.single" = "한 번에 1장 다운로드"; +"enum.download_thread_mode.value.double" = "한 번에 2장 다운로드"; +"enum.download_thread_mode.value.triple" = "한 번에 3장 다운로드"; +"enum.download_thread_mode.value.quadruple" = "한 번에 4장 다운로드"; +"enum.download_thread_mode.value.quintuple" = "한 번에 5장 다운로드"; +"enum.download_list_filter.title.all" = "전체"; +"enum.download_list_filter.title.active" = "진행 중"; +"enum.download_list_filter.title.completed" = "다운로드됨"; +"enum.download_list_filter.title.failed" = "조치 필요"; +"enum.download_list_filter.title.update" = "업데이트 가능"; +"struct.download_badge.text.queued" = "대기 중"; +"struct.download_badge.text.downloading" = "다운로드 중 %d/%d"; +"struct.download_badge.text.paused" = "일시 정지 %d/%d"; +"struct.download_badge.text.needs_attention_progress" = "조치 필요 %d/%d"; +"struct.download_badge.text.downloaded" = "다운로드됨"; +"struct.download_badge.text.needs_attention" = "조치 필요"; +"struct.download_badge.text.update_available" = "업데이트 가능"; +"struct.download_badge.text.needs_repair" = "복구 필요"; +"struct.download_badge.compact.downloading" = "DL"; +"struct.download_badge.compact.paused" = "일시정지"; +"struct.download_badge.compact.needs_attention" = "조치 필요"; +"struct.download_badge.compact.done" = "완료"; +"download_file_storage.error.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; +"download_file_storage.validation.download_folder_unresolved" = "다운로드 폴더를 확인할 수 없습니다."; +"download_file_storage.validation.download_folder_missing" = "다운로드 폴더가 없습니다."; +"download_file_storage.validation.manifest_missing" = "매니페스트 파일이 없습니다."; +"download_file_storage.validation.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; +"download_file_storage.validation.downloaded_pages_incomplete" = "다운로드한 페이지가 불완전합니다."; +"download_file_storage.validation.cover_image_missing" = "표지 이미지가 없습니다."; +"download_file_storage.validation.page_missing" = "페이지 %d가 없습니다."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 19a4d6b0d..0592c6d52 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -42,10 +42,14 @@ "common.value.seconds" = "%@ 秒"; "common.value.records" = "%@ 条记录"; +// MARK: Common button +"common.button.cancel" = "取消"; + // MARK: TabItem "tab_item.title.home" = "主页"; "tab_item.title.favorites" = "收藏"; "tab_item.title.search" = "搜索"; +"tab_item.title.downloads" = "下载"; "tab_item.title.setting" = "设置"; // MARK: ToolbarItem @@ -74,6 +78,24 @@ "error_view.title.copyright_claim" = "抱歉,该画廊因 %@ 的版权主张已无法访问。"; "error_view.title.gallery_unavailable" = "该画廊已被移除或不可用。"; +// MARK: AppError +"app_error.localized_description.database_corrupted" = "数据库损坏"; +"app_error.localized_description.copyright_claim" = "版权声明"; +"app_error.localized_description.ip_banned" = "IP 已封禁"; +"app_error.localized_description.gallery_expunged" = "画廊已删除"; +"app_error.localized_description.network_error" = "网络错误"; +"app_error.localized_description.web_image_loading_error" = "网页图片加载错误"; +"app_error.localized_description.parse_error" = "解析错误"; +"app_error.localized_description.quota_exceeded" = "流量额度已用尽"; +"app_error.localized_description.authentication_required" = "需要登录"; +"app_error.localized_description.file_operation_failed" = "文件操作失败"; +"app_error.localized_description.no_updates_available" = "没有可用更新"; +"app_error.localized_description.not_found" = "未找到"; +"app_error.localized_description.unknown_error" = "未知错误"; +"app_error.alert.quota_exceeded" = "图片流量额度已用尽。\n请稍后再试。"; +"app_error.alert.authentication_required" = "访问此下载内容需要登录。"; +"app_error.alert.local_file_operation_failed" = "本地文件操作失败。"; + // MARK: ConfirmationDialog "confirmation_dialog.title.drop_database" = "你将失去这个 App 中所有的数据。\n确定要丢弃数据库吗?"; "confirmation_dialog.title.remove_custom_translations" = "确定要移除自定义翻译吗?"; @@ -155,6 +177,7 @@ "enum.setting_state_route.value.general" = "一般"; "enum.setting_state_route.value.appearance" = "外观"; "enum.setting_state_route.value.reading" = "阅读"; +"enum.setting_state_route.value.downloads" = "下载"; "enum.setting_state_route.value.laboratory" = "实验室"; "enum.setting_state_route.value.about" = "关于 EhPanda"; @@ -262,8 +285,27 @@ "about_view.section.title.acknowledgements" = "致谢"; // MARK: DetailView +"detail_view.button.download_login" = "登录"; +"detail_view.button.download_get" = "获取"; +"detail_view.button.download_wait" = "等待"; +"detail_view.button.download_done" = "完成"; +"detail_view.button.download_update" = "更新"; +"detail_view.button.download_retry" = "重试"; +"detail_view.button.download_repair" = "修复"; "detail_view.button.read" = "阅读"; "detail_view.button.post_comment" = "发布评论"; +"detail_view.accessibility.download_button.login" = "登录后即可下载"; +"detail_view.accessibility.download_button.download" = "下载"; +"detail_view.accessibility.download_button.queued" = "已加入下载队列"; +"detail_view.accessibility.download_button.downloading" = "正在下载第 %d / %d 页"; +"detail_view.accessibility.download_button.downloaded" = "删除已下载画廊"; +"detail_view.accessibility.download_button.update" = "更新下载内容"; +"detail_view.accessibility.download_button.retry" = "重新下载"; +"detail_view.accessibility.download_button.repair" = "修复下载文件"; +"detail_view.accessibility.download_button.preparing" = "正在获取下载信息"; +"detail_view.accessibility.download_button.pause_action" = "暂停下载"; +"detail_view.accessibility.download_button.paused" = "继续下载,当前暂停在第 %d / %d 页"; +"detail_view.accessibility.download_button.partial" = "重新下载,已有 %d / %d 页可用。"; "detail_view.toolbar_item.button.archives" = "归档"; "detail_view.toolbar_item.button.torrents" = "种子"; "detail_view.toolbar_item.button.share" = "分享"; @@ -282,6 +324,19 @@ "detail_view.action_section.button.similar_gallery" = "相似画廊"; "detail_view.section.title.previews" = "预览"; "detail_view.section.title.comments" = "评论"; +"detail_view.dialog.title.delete_download" = "删除下载?"; +"detail_view.dialog.title.repair_download" = "修复下载?"; +"detail_view.dialog.title.update_download" = "更新下载?"; +"detail_view.dialog.title.redownload_gallery" = "重新下载画廊?"; +"detail_view.dialog.message.delete_active_download" = "这将停止当前下载并从此设备移除该画廊。"; +"detail_view.dialog.message.delete_downloaded_gallery" = "这将从此设备移除已下载的画廊。"; +"detail_view.dialog.message.repair_download" = "现在修复此画廊的离线文件吗?"; +"detail_view.dialog.message.update_download" = "现在将此画廊更新到线上最新版本吗?"; +"detail_view.dialog.message.redownload_gallery" = "现在重新完整下载此画廊吗?"; +"detail_view.dialog.button.repair" = "修复"; +"detail_view.dialog.button.update" = "更新"; +"detail_view.dialog.button.redownload" = "重新下载"; +"detail_view.offline_notice.saved_details" = "无法刷新在线详情,现显示已保存的详情。"; // MARK: ArchivesView "archives_view.title.archives" = "归档"; @@ -331,6 +386,36 @@ "tag_detail_view.section.title.images" = "图片"; "tag_detail_view.section.title.links" = "链接"; +// MARK: DownloadsView +"downloads_view.title.downloads" = "下载"; +"downloads_view.search.prompt.downloads" = "搜索下载"; +"downloads_view.dialog.title.delete_download" = "删除下载?"; +"downloads_view.dialog.message.delete_active_download" = "这将取消当前下载并从此设备移除它。"; +"downloads_view.dialog.message.delete_downloaded_gallery" = "这将从此设备移除已下载的画廊。"; +"downloads_view.swipe.button.pages" = "页面"; +"downloads_view.swipe.button.update" = "更新"; +"downloads_view.swipe.button.resume" = "继续"; +"downloads_view.swipe.button.pause" = "暂停"; +"downloads_view.empty_state.downloads" = "已下载的画廊会显示在这里。"; +"downloads_view.empty_state.no_matching_filters" = "没有下载项符合当前筛选条件。"; +"downloads_view.button.clear_filters" = "清除筛选"; +"downloads_view.inspector.section.actions" = "操作"; +"downloads_view.inspector.section.pages" = "页面"; +"downloads_view.inspector.button.retry_failed_pages" = "重试失败页面(%d)"; +"downloads_view.inspector.button.update_download" = "更新下载"; +"downloads_view.inspector.title.download_status" = "下载状态"; +"downloads_view.inspector.page.pending" = "等待中"; +"downloads_view.inspector.page.tap_to_retry" = "点按以重试此页"; +"downloads_view.inspector.page.title" = "第 %d 页"; + +// MARK: DownloadSettingView +"download_setting_view.section.title.download_queue" = "下载队列"; +"download_setting_view.section.title.network" = "网络"; +"download_setting_view.title.concurrent_image_downloads" = "并发图片下载"; +"download_setting_view.title.retry_failed_pages_automatically" = "自动重试失败页面"; +"download_setting_view.title.allow_cellular_downloads" = "允许蜂窝网络下载"; +"download_setting_view.footer.network" = "每次只会下载一个画廊。这个设置用于控制单个画廊内页面的并行下载数量、是否允许蜂窝网络下载,以及文件在应用 Downloads 文件夹中的存储方式。"; + // MARK: CommentsView "comments_view.title.comments" = "评论"; @@ -356,6 +441,44 @@ // AutoPlayPolicy "enum.auto_play_policy.value.off" = "不启用"; +// MARK: DownloadThreadMode +"enum.download_thread_mode.value.single" = "同时下载 1 张图片"; +"enum.download_thread_mode.value.double" = "同时下载 2 张图片"; +"enum.download_thread_mode.value.triple" = "同时下载 3 张图片"; +"enum.download_thread_mode.value.quadruple" = "同时下载 4 张图片"; +"enum.download_thread_mode.value.quintuple" = "同时下载 5 张图片"; + +// MARK: DownloadListFilter +"enum.download_list_filter.title.all" = "全部"; +"enum.download_list_filter.title.active" = "进行中"; +"enum.download_list_filter.title.completed" = "已下载"; +"enum.download_list_filter.title.failed" = "需处理"; +"enum.download_list_filter.title.update" = "有可更新"; + +// MARK: DownloadBadge +"struct.download_badge.text.queued" = "已排队"; +"struct.download_badge.text.downloading" = "下载中 %d/%d"; +"struct.download_badge.text.paused" = "已暂停 %d/%d"; +"struct.download_badge.text.needs_attention_progress" = "需处理 %d/%d"; +"struct.download_badge.text.downloaded" = "已下载"; +"struct.download_badge.text.needs_attention" = "需处理"; +"struct.download_badge.text.update_available" = "有可更新"; +"struct.download_badge.text.needs_repair" = "需修复"; +"struct.download_badge.compact.downloading" = "下载中"; +"struct.download_badge.compact.paused" = "暂停"; +"struct.download_badge.compact.needs_attention" = "需处理"; +"struct.download_badge.compact.done" = "完成"; + +// MARK: DownloadFileStorage +"download_file_storage.error.asset_unreadable" = "资源文件无法读取:%@"; +"download_file_storage.validation.download_folder_unresolved" = "无法解析下载文件夹。"; +"download_file_storage.validation.download_folder_missing" = "下载文件夹缺失。"; +"download_file_storage.validation.manifest_missing" = "Manifest 文件缺失。"; +"download_file_storage.validation.manifest_corrupted" = "Manifest 文件已损坏。"; +"download_file_storage.validation.downloaded_pages_incomplete" = "下载页面不完整。"; +"download_file_storage.validation.cover_image_missing" = "封面图片缺失。"; +"download_file_storage.validation.page_missing" = "第 %d 页缺失。"; + // MARK: FiltersView "filters_view.title.filters" = "筛选"; "filters_view.title.advanced_settings" = "高级选项"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 948d5eb2f..e50bc7173 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -155,6 +155,7 @@ "enum.setting_state_route.value.general" = "一般"; "enum.setting_state_route.value.appearance" = "外觀"; "enum.setting_state_route.value.reading" = "閱讀"; +"enum.setting_state_route.value.downloads" = "下載"; "enum.setting_state_route.value.laboratory" = "實驗性功能"; "enum.setting_state_route.value.about" = "關於 EhPanda"; @@ -262,8 +263,24 @@ "about_view.section.title.acknowledgements" = "致謝"; // MARK: DetailView +"detail_view.button.download_login" = "登入"; +"detail_view.button.download_get" = "取得"; +"detail_view.button.download_wait" = "等待"; +"detail_view.button.download_done" = "完成"; +"detail_view.button.download_update" = "更新"; +"detail_view.button.download_retry" = "重試"; +"detail_view.button.download_repair" = "修復"; "detail_view.button.read" = "閱讀"; "detail_view.button.post_comment" = "發表留言"; +"detail_view.accessibility.download_button.login" = "登入後即可下載"; +"detail_view.accessibility.download_button.download" = "下載"; +"detail_view.accessibility.download_button.queued" = "已加入下載佇列"; +"detail_view.accessibility.download_button.downloading" = "正在下載第 %d / %d 頁"; +"detail_view.accessibility.download_button.downloaded" = "刪除已下載畫廊"; +"detail_view.accessibility.download_button.update" = "更新下載內容"; +"detail_view.accessibility.download_button.retry" = "重新下載"; +"detail_view.accessibility.download_button.repair" = "修復下載檔案"; +"detail_view.accessibility.download_button.preparing" = "正在取得下載資訊"; "detail_view.toolbar_item.button.archives" = "存檔至 H@H 用戶端"; "detail_view.toolbar_item.button.torrents" = "種子"; "detail_view.toolbar_item.button.share" = "分享"; @@ -903,3 +920,93 @@ "enum.browsing_country.name.yemen" = "也門"; "enum.browsing_country.name.zambia" = "贊比亞"; "enum.browsing_country.name.zimbabwe" = "津巴布韋"; +"common.button.cancel" = "取消"; +"tab_item.title.downloads" = "下載"; +"app_error.localized_description.database_corrupted" = "資料庫損壞"; +"app_error.localized_description.copyright_claim" = "版權聲明"; +"app_error.localized_description.ip_banned" = "IP 已封禁"; +"app_error.localized_description.gallery_expunged" = "畫廊已刪除"; +"app_error.localized_description.network_error" = "網絡錯誤"; +"app_error.localized_description.web_image_loading_error" = "網頁圖片載入錯誤"; +"app_error.localized_description.parse_error" = "解析錯誤"; +"app_error.localized_description.quota_exceeded" = "流量額度已用盡"; +"app_error.localized_description.authentication_required" = "需要登入"; +"app_error.localized_description.file_operation_failed" = "檔案操作失敗"; +"app_error.localized_description.no_updates_available" = "沒有可用更新"; +"app_error.localized_description.not_found" = "未找到"; +"app_error.localized_description.unknown_error" = "未知錯誤"; +"app_error.alert.quota_exceeded" = "圖片流量額度已用盡。\n請稍後再試。"; +"app_error.alert.authentication_required" = "存取此下載內容需要登入。"; +"app_error.alert.local_file_operation_failed" = "本機檔案操作失敗。"; +"detail_view.accessibility.download_button.pause_action" = "暫停下載"; +"detail_view.accessibility.download_button.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; +"detail_view.accessibility.download_button.partial" = "重新下載,已有 %d / %d 頁可用。"; +"detail_view.dialog.title.delete_download" = "刪除下載?"; +"detail_view.dialog.title.repair_download" = "修復下載?"; +"detail_view.dialog.title.update_download" = "更新下載?"; +"detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; +"detail_view.dialog.message.delete_active_download" = "這將停止目前下載並從此裝置移除此畫廊。"; +"detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; +"detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; +"detail_view.dialog.message.redownload_gallery" = "現在重新完整下載此畫廊嗎?"; +"detail_view.dialog.button.repair" = "修復"; +"detail_view.dialog.button.update" = "更新"; +"detail_view.dialog.button.redownload" = "重新下載"; +"detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; +"downloads_view.title.downloads" = "下載"; +"downloads_view.search.prompt.downloads" = "搜尋下載"; +"downloads_view.dialog.title.delete_download" = "刪除下載?"; +"downloads_view.dialog.message.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; +"downloads_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"downloads_view.swipe.button.pages" = "頁面"; +"downloads_view.swipe.button.update" = "更新"; +"downloads_view.swipe.button.resume" = "繼續"; +"downloads_view.swipe.button.pause" = "暫停"; +"downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; +"downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; +"downloads_view.button.clear_filters" = "清除篩選"; +"downloads_view.inspector.section.actions" = "操作"; +"downloads_view.inspector.section.pages" = "頁面"; +"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面(%d)"; +"downloads_view.inspector.button.update_download" = "更新下載"; +"downloads_view.inspector.title.download_status" = "下載狀態"; +"downloads_view.inspector.page.pending" = "等待中"; +"downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; +"downloads_view.inspector.page.title" = "第 %d 頁"; +"download_setting_view.section.title.download_queue" = "下載佇列"; +"download_setting_view.section.title.network" = "網絡"; +"download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; +"download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; +"download_setting_view.title.allow_cellular_downloads" = "允許流動網絡下載"; +"download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許流動網絡下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; +"enum.download_thread_mode.value.single" = "同時下載 1 張圖片"; +"enum.download_thread_mode.value.double" = "同時下載 2 張圖片"; +"enum.download_thread_mode.value.triple" = "同時下載 3 張圖片"; +"enum.download_thread_mode.value.quadruple" = "同時下載 4 張圖片"; +"enum.download_thread_mode.value.quintuple" = "同時下載 5 張圖片"; +"enum.download_list_filter.title.all" = "全部"; +"enum.download_list_filter.title.active" = "進行中"; +"enum.download_list_filter.title.completed" = "已下載"; +"enum.download_list_filter.title.failed" = "需處理"; +"enum.download_list_filter.title.update" = "有可更新"; +"struct.download_badge.text.queued" = "已排隊"; +"struct.download_badge.text.downloading" = "下載中 %d/%d"; +"struct.download_badge.text.paused" = "已暫停 %d/%d"; +"struct.download_badge.text.needs_attention_progress" = "需處理 %d/%d"; +"struct.download_badge.text.downloaded" = "已下載"; +"struct.download_badge.text.needs_attention" = "需處理"; +"struct.download_badge.text.update_available" = "有可更新"; +"struct.download_badge.text.needs_repair" = "需修復"; +"struct.download_badge.compact.downloading" = "下載中"; +"struct.download_badge.compact.paused" = "暫停"; +"struct.download_badge.compact.needs_attention" = "需處理"; +"struct.download_badge.compact.done" = "完成"; +"download_file_storage.error.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_file_storage.validation.download_folder_unresolved" = "無法解析下載資料夾。"; +"download_file_storage.validation.download_folder_missing" = "下載資料夾缺失。"; +"download_file_storage.validation.manifest_missing" = "Manifest 檔案缺失。"; +"download_file_storage.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; +"download_file_storage.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; +"download_file_storage.validation.cover_image_missing" = "封面圖片缺失。"; +"download_file_storage.validation.page_missing" = "第 %d 頁缺失。"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 0c4f150d2..d5fa84ce0 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -155,6 +155,7 @@ "enum.setting_state_route.value.general" = "一般"; "enum.setting_state_route.value.appearance" = "外觀"; "enum.setting_state_route.value.reading" = "閱讀"; +"enum.setting_state_route.value.downloads" = "下載"; "enum.setting_state_route.value.laboratory" = "實驗性功能"; "enum.setting_state_route.value.about" = "關於 EhPanda"; @@ -262,8 +263,24 @@ "about_view.section.title.acknowledgements" = "致謝"; // MARK: DetailView +"detail_view.button.download_login" = "登入"; +"detail_view.button.download_get" = "取得"; +"detail_view.button.download_wait" = "等待"; +"detail_view.button.download_done" = "完成"; +"detail_view.button.download_update" = "更新"; +"detail_view.button.download_retry" = "重試"; +"detail_view.button.download_repair" = "修復"; "detail_view.button.read" = "閱讀"; "detail_view.button.post_comment" = "發表留言"; +"detail_view.accessibility.download_button.login" = "登入後即可下載"; +"detail_view.accessibility.download_button.download" = "下載"; +"detail_view.accessibility.download_button.queued" = "已加入下載佇列"; +"detail_view.accessibility.download_button.downloading" = "正在下載第 %d / %d 頁"; +"detail_view.accessibility.download_button.downloaded" = "刪除已下載畫廊"; +"detail_view.accessibility.download_button.update" = "更新下載內容"; +"detail_view.accessibility.download_button.retry" = "重新下載"; +"detail_view.accessibility.download_button.repair" = "修復下載檔案"; +"detail_view.accessibility.download_button.preparing" = "正在取得下載資訊"; "detail_view.toolbar_item.button.archives" = "存檔至 H@H 用戶端"; "detail_view.toolbar_item.button.torrents" = "種子"; "detail_view.toolbar_item.button.share" = "分享"; @@ -904,3 +921,93 @@ "enum.browsing_country.name.yemen" = "葉門"; "enum.browsing_country.name.zambia" = "尚比亞"; "enum.browsing_country.name.zimbabwe" = "辛巴威"; +"common.button.cancel" = "取消"; +"tab_item.title.downloads" = "下載"; +"app_error.localized_description.database_corrupted" = "資料庫損壞"; +"app_error.localized_description.copyright_claim" = "版權聲明"; +"app_error.localized_description.ip_banned" = "IP 已封禁"; +"app_error.localized_description.gallery_expunged" = "畫廊已刪除"; +"app_error.localized_description.network_error" = "網路錯誤"; +"app_error.localized_description.web_image_loading_error" = "網頁圖片載入錯誤"; +"app_error.localized_description.parse_error" = "解析錯誤"; +"app_error.localized_description.quota_exceeded" = "流量額度已用盡"; +"app_error.localized_description.authentication_required" = "需要登入"; +"app_error.localized_description.file_operation_failed" = "檔案操作失敗"; +"app_error.localized_description.no_updates_available" = "沒有可用更新"; +"app_error.localized_description.not_found" = "未找到"; +"app_error.localized_description.unknown_error" = "未知錯誤"; +"app_error.alert.quota_exceeded" = "圖片流量額度已用盡。\n請稍後再試。"; +"app_error.alert.authentication_required" = "存取此下載內容需要登入。"; +"app_error.alert.local_file_operation_failed" = "本機檔案操作失敗。"; +"detail_view.accessibility.download_button.pause_action" = "暫停下載"; +"detail_view.accessibility.download_button.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; +"detail_view.accessibility.download_button.partial" = "重新下載,已有 %d / %d 頁可用。"; +"detail_view.dialog.title.delete_download" = "刪除下載?"; +"detail_view.dialog.title.repair_download" = "修復下載?"; +"detail_view.dialog.title.update_download" = "更新下載?"; +"detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; +"detail_view.dialog.message.delete_active_download" = "這將停止目前下載並從此裝置移除此畫廊。"; +"detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; +"detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; +"detail_view.dialog.message.redownload_gallery" = "現在重新完整下載此畫廊嗎?"; +"detail_view.dialog.button.repair" = "修復"; +"detail_view.dialog.button.update" = "更新"; +"detail_view.dialog.button.redownload" = "重新下載"; +"detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; +"downloads_view.title.downloads" = "下載"; +"downloads_view.search.prompt.downloads" = "搜尋下載"; +"downloads_view.dialog.title.delete_download" = "刪除下載?"; +"downloads_view.dialog.message.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; +"downloads_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"downloads_view.swipe.button.pages" = "頁面"; +"downloads_view.swipe.button.update" = "更新"; +"downloads_view.swipe.button.resume" = "繼續"; +"downloads_view.swipe.button.pause" = "暫停"; +"downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; +"downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; +"downloads_view.button.clear_filters" = "清除篩選"; +"downloads_view.inspector.section.actions" = "操作"; +"downloads_view.inspector.section.pages" = "頁面"; +"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面(%d)"; +"downloads_view.inspector.button.update_download" = "更新下載"; +"downloads_view.inspector.title.download_status" = "下載狀態"; +"downloads_view.inspector.page.pending" = "等待中"; +"downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; +"downloads_view.inspector.page.title" = "第 %d 頁"; +"download_setting_view.section.title.download_queue" = "下載佇列"; +"download_setting_view.section.title.network" = "網路"; +"download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; +"download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; +"download_setting_view.title.allow_cellular_downloads" = "允許行動網路下載"; +"download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許行動網路下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; +"enum.download_thread_mode.value.single" = "同時下載 1 張圖片"; +"enum.download_thread_mode.value.double" = "同時下載 2 張圖片"; +"enum.download_thread_mode.value.triple" = "同時下載 3 張圖片"; +"enum.download_thread_mode.value.quadruple" = "同時下載 4 張圖片"; +"enum.download_thread_mode.value.quintuple" = "同時下載 5 張圖片"; +"enum.download_list_filter.title.all" = "全部"; +"enum.download_list_filter.title.active" = "進行中"; +"enum.download_list_filter.title.completed" = "已下載"; +"enum.download_list_filter.title.failed" = "需處理"; +"enum.download_list_filter.title.update" = "有可更新"; +"struct.download_badge.text.queued" = "已排隊"; +"struct.download_badge.text.downloading" = "下載中 %d/%d"; +"struct.download_badge.text.paused" = "已暫停 %d/%d"; +"struct.download_badge.text.needs_attention_progress" = "需處理 %d/%d"; +"struct.download_badge.text.downloaded" = "已下載"; +"struct.download_badge.text.needs_attention" = "需處理"; +"struct.download_badge.text.update_available" = "有可更新"; +"struct.download_badge.text.needs_repair" = "需修復"; +"struct.download_badge.compact.downloading" = "下載中"; +"struct.download_badge.compact.paused" = "暫停"; +"struct.download_badge.compact.needs_attention" = "需處理"; +"struct.download_badge.compact.done" = "完成"; +"download_file_storage.error.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_file_storage.validation.download_folder_unresolved" = "無法解析下載資料夾。"; +"download_file_storage.validation.download_folder_missing" = "下載資料夾缺失。"; +"download_file_storage.validation.manifest_missing" = "Manifest 檔案缺失。"; +"download_file_storage.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; +"download_file_storage.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; +"download_file_storage.validation.cover_image_missing" = "封面圖片缺失。"; +"download_file_storage.validation.page_missing" = "第 %d 頁缺失。"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index ad77e1673..c75a43a29 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -155,6 +155,7 @@ "enum.setting_state_route.value.general" = "一般"; "enum.setting_state_route.value.appearance" = "外觀"; "enum.setting_state_route.value.reading" = "閱讀"; +"enum.setting_state_route.value.downloads" = "下載"; "enum.setting_state_route.value.laboratory" = "實驗性功能"; "enum.setting_state_route.value.about" = "關於 EhPanda"; @@ -262,8 +263,24 @@ "about_view.section.title.acknowledgements" = "致謝"; // MARK: DetailView +"detail_view.button.download_login" = "登入"; +"detail_view.button.download_get" = "取得"; +"detail_view.button.download_wait" = "等待"; +"detail_view.button.download_done" = "完成"; +"detail_view.button.download_update" = "更新"; +"detail_view.button.download_retry" = "重試"; +"detail_view.button.download_repair" = "修復"; "detail_view.button.read" = "閱讀"; "detail_view.button.post_comment" = "發表留言"; +"detail_view.accessibility.download_button.login" = "登入後即可下載"; +"detail_view.accessibility.download_button.download" = "下載"; +"detail_view.accessibility.download_button.queued" = "已加入下載佇列"; +"detail_view.accessibility.download_button.downloading" = "正在下載第 %d / %d 頁"; +"detail_view.accessibility.download_button.downloaded" = "刪除已下載畫廊"; +"detail_view.accessibility.download_button.update" = "更新下載內容"; +"detail_view.accessibility.download_button.retry" = "重新下載"; +"detail_view.accessibility.download_button.repair" = "修復下載檔案"; +"detail_view.accessibility.download_button.preparing" = "正在取得下載資訊"; "detail_view.toolbar_item.button.archives" = "存檔至 H@H 用戶端"; "detail_view.toolbar_item.button.torrents" = "種子"; "detail_view.toolbar_item.button.share" = "分享"; @@ -904,3 +921,93 @@ "enum.browsing_country.name.yemen" = "葉門"; "enum.browsing_country.name.zambia" = "尚比亞"; "enum.browsing_country.name.zimbabwe" = "辛巴威"; +"common.button.cancel" = "取消"; +"tab_item.title.downloads" = "下載"; +"app_error.localized_description.database_corrupted" = "資料庫損壞"; +"app_error.localized_description.copyright_claim" = "版權聲明"; +"app_error.localized_description.ip_banned" = "IP 已封禁"; +"app_error.localized_description.gallery_expunged" = "畫廊已刪除"; +"app_error.localized_description.network_error" = "網絡錯誤"; +"app_error.localized_description.web_image_loading_error" = "網頁圖片載入錯誤"; +"app_error.localized_description.parse_error" = "解析錯誤"; +"app_error.localized_description.quota_exceeded" = "流量額度已用盡"; +"app_error.localized_description.authentication_required" = "需要登入"; +"app_error.localized_description.file_operation_failed" = "檔案操作失敗"; +"app_error.localized_description.no_updates_available" = "沒有可用更新"; +"app_error.localized_description.not_found" = "未找到"; +"app_error.localized_description.unknown_error" = "未知錯誤"; +"app_error.alert.quota_exceeded" = "圖片流量額度已用盡。\n請稍後再試。"; +"app_error.alert.authentication_required" = "存取此下載內容需要登入。"; +"app_error.alert.local_file_operation_failed" = "本機檔案操作失敗。"; +"detail_view.accessibility.download_button.pause_action" = "暫停下載"; +"detail_view.accessibility.download_button.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; +"detail_view.accessibility.download_button.partial" = "重新下載,已有 %d / %d 頁可用。"; +"detail_view.dialog.title.delete_download" = "刪除下載?"; +"detail_view.dialog.title.repair_download" = "修復下載?"; +"detail_view.dialog.title.update_download" = "更新下載?"; +"detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; +"detail_view.dialog.message.delete_active_download" = "這將停止目前下載並從此裝置移除此畫廊。"; +"detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; +"detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; +"detail_view.dialog.message.redownload_gallery" = "現在重新完整下載此畫廊嗎?"; +"detail_view.dialog.button.repair" = "修復"; +"detail_view.dialog.button.update" = "更新"; +"detail_view.dialog.button.redownload" = "重新下載"; +"detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; +"downloads_view.title.downloads" = "下載"; +"downloads_view.search.prompt.downloads" = "搜尋下載"; +"downloads_view.dialog.title.delete_download" = "刪除下載?"; +"downloads_view.dialog.message.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; +"downloads_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"downloads_view.swipe.button.pages" = "頁面"; +"downloads_view.swipe.button.update" = "更新"; +"downloads_view.swipe.button.resume" = "繼續"; +"downloads_view.swipe.button.pause" = "暫停"; +"downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; +"downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; +"downloads_view.button.clear_filters" = "清除篩選"; +"downloads_view.inspector.section.actions" = "操作"; +"downloads_view.inspector.section.pages" = "頁面"; +"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面(%d)"; +"downloads_view.inspector.button.update_download" = "更新下載"; +"downloads_view.inspector.title.download_status" = "下載狀態"; +"downloads_view.inspector.page.pending" = "等待中"; +"downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; +"downloads_view.inspector.page.title" = "第 %d 頁"; +"download_setting_view.section.title.download_queue" = "下載佇列"; +"download_setting_view.section.title.network" = "網絡"; +"download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; +"download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; +"download_setting_view.title.allow_cellular_downloads" = "允許流動網絡下載"; +"download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許流動網絡下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; +"enum.download_thread_mode.value.single" = "同時下載 1 張圖片"; +"enum.download_thread_mode.value.double" = "同時下載 2 張圖片"; +"enum.download_thread_mode.value.triple" = "同時下載 3 張圖片"; +"enum.download_thread_mode.value.quadruple" = "同時下載 4 張圖片"; +"enum.download_thread_mode.value.quintuple" = "同時下載 5 張圖片"; +"enum.download_list_filter.title.all" = "全部"; +"enum.download_list_filter.title.active" = "進行中"; +"enum.download_list_filter.title.completed" = "已下載"; +"enum.download_list_filter.title.failed" = "需處理"; +"enum.download_list_filter.title.update" = "有可更新"; +"struct.download_badge.text.queued" = "已排隊"; +"struct.download_badge.text.downloading" = "下載中 %d/%d"; +"struct.download_badge.text.paused" = "已暫停 %d/%d"; +"struct.download_badge.text.needs_attention_progress" = "需處理 %d/%d"; +"struct.download_badge.text.downloaded" = "已下載"; +"struct.download_badge.text.needs_attention" = "需處理"; +"struct.download_badge.text.update_available" = "有可更新"; +"struct.download_badge.text.needs_repair" = "需修復"; +"struct.download_badge.compact.downloading" = "下載中"; +"struct.download_badge.compact.paused" = "暫停"; +"struct.download_badge.compact.needs_attention" = "需處理"; +"struct.download_badge.compact.done" = "完成"; +"download_file_storage.error.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_file_storage.validation.download_folder_unresolved" = "無法解析下載資料夾。"; +"download_file_storage.validation.download_folder_missing" = "下載資料夾缺失。"; +"download_file_storage.validation.manifest_missing" = "Manifest 檔案缺失。"; +"download_file_storage.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; +"download_file_storage.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; +"download_file_storage.validation.cover_image_missing" = "封面圖片缺失。"; +"download_file_storage.validation.page_missing" = "第 %d 頁缺失。"; diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index 5333c8a2a..ff6a57497 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -17,12 +17,16 @@ struct AppReducer { var homeState = HomeReducer.State() var favoritesState = FavoritesReducer.State() var searchRootState = SearchRootReducer.State() + var downloadsState = DownloadsReducer.State() var settingState = SettingReducer.State() + var didRunLaunchAutomation = false + var isWaitingForIgneousBeforeLaunchAutomation = false } enum Action: BindableAction { case binding(BindingAction) case onScenePhaseChange(ScenePhase) + case runLaunchAutomation case appDelegate(AppDelegateReducer.Action) case appRoute(AppRouteReducer.Action) @@ -33,12 +37,14 @@ struct AppReducer { case home(HomeReducer.Action) case favorites(FavoritesReducer.Action) case searchRoot(SearchRootReducer.Action) + case downloads(DownloadsReducer.Action) case setting(SettingReducer.Action) } @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient @Dependency(\.deviceClient) private var deviceClient + @Dependency(\.urlClient) private var urlClient var body: some Reducer { LoggingReducer { @@ -72,8 +78,32 @@ struct AppReducer { return .none } + case .runLaunchAutomation: + guard !state.didRunLaunchAutomation, + let automation = AppLaunchAutomation.current + else { return .none } + + state.didRunLaunchAutomation = true + return .run { send in + if let galleryURL = automation.galleryURL, + urlClient.checkIfHandleable(galleryURL) { + await send(.appRoute(.handleDeepLink(galleryURL))) + } else if let initialTab = automation.initialTab { + await send(.tabBar(.setTabBarItemType(initialTab))) + } + } + case .appDelegate(.migration(.onDatabasePreparationSuccess)): - return .merge( + return .concatenate( + .run { _ in + if let loginCookies = AppLaunchAutomation.current?.loginCookies { + cookieClient.importAutomationCookies( + memberID: loginCookies.memberID, + passHash: loginCookies.passHash, + igneous: loginCookies.igneous + ) + } + }, .send(.appDelegate(.removeExpiredImageURLs)), .send(.setting(.loadUserSettings)) ) @@ -129,6 +159,13 @@ struct AppReducer { } else { effects.append(.send(.searchRoot(.fetchDatabaseInfos))) } + case .downloads: + if state.downloadsState.route != nil { + effects.append(.send(.downloads(.setNavigation(nil)))) + } else { + effects.append(.send(.downloads(.refreshDownloads))) + } + effects.append(hapticEffect) case .setting: if state.settingState.route != nil { effects.append(.send(.setting(.setNavigation(nil)))) @@ -173,6 +210,9 @@ struct AppReducer { case .searchRoot: return .none + case .downloads: + return .none + case .setting(.loadUserSettingsDone): var effects = [Effect]() let threshold = state.settingState.setting.autoLockPolicy.rawValue @@ -184,8 +224,21 @@ struct AppReducer { if state.settingState.setting.detectsLinksFromClipboard { effects.append(.send(.appRoute(.detectClipboardURL))) } + state.isWaitingForIgneousBeforeLaunchAutomation = shouldDelayLaunchAutomationUntilIgneous( + state: state + ) + if !state.isWaitingForIgneousBeforeLaunchAutomation { + effects.append(.send(.runLaunchAutomation)) + } return effects.isEmpty ? .none : .merge(effects) + case .setting(.account(.loadCookies)): + guard state.isWaitingForIgneousBeforeLaunchAutomation, + !shouldDelayLaunchAutomationUntilIgneous(state: state) + else { return .none } + state.isWaitingForIgneousBeforeLaunchAutomation = false + return .send(.runLaunchAutomation) + case .setting(.fetchGreetingDone(let result)): return .send(.appRoute(.fetchGreetingDone(result))) @@ -201,7 +254,26 @@ struct AppReducer { Scope(state: \.homeState, action: \.home, child: HomeReducer.init) Scope(state: \.favoritesState, action: \.favorites, child: FavoritesReducer.init) Scope(state: \.searchRootState, action: \.searchRoot, child: SearchRootReducer.init) + Scope(state: \.downloadsState, action: \.downloads, child: DownloadsReducer.init) Scope(state: \.settingState, action: \.setting, child: SettingReducer.init) } } } + +private extension AppReducer { + func shouldDelayLaunchAutomationUntilIgneous(state: State) -> Bool { + guard !state.didRunLaunchAutomation, + cookieClient.shouldFetchIgneous, + let automation = AppLaunchAutomation.current + else { return false } + + if let galleryURL = automation.galleryURL, + galleryURL.host?.contains("exhentai.org") == true + { + return true + } + + return automation.autoDownloadGID != nil + && state.settingState.setting.galleryHost == .exhentai + } +} diff --git a/EhPanda/DataFlow/AppRouteReducer.swift b/EhPanda/DataFlow/AppRouteReducer.swift index 94c04e573..8d6b9ce92 100644 --- a/EhPanda/DataFlow/AppRouteReducer.swift +++ b/EhPanda/DataFlow/AppRouteReducer.swift @@ -157,7 +157,7 @@ struct AppRouteReducer { state.route = nil switch result { case .success(let gallery): - return .merge( + return .concatenate( .run(operation: { _ in await databaseClient.cacheGalleries([gallery]) }), .send(.handleGalleryLink(url)) ) diff --git a/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift new file mode 100644 index 000000000..191158c10 --- /dev/null +++ b/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift @@ -0,0 +1,37 @@ +// +// DownloadedGalleryMO+CoreDataClass.swift +// EhPanda +// + +import CoreData + +public class DownloadedGalleryMO: NSManagedObject {} + +extension DownloadedGalleryMO: ManagedObjectProtocol { + func toEntity() -> DownloadedGallery { + DownloadedGallery( + gid: gid, + host: GalleryHost(rawValue: host) ?? .ehentai, + token: token, + title: title, + jpnTitle: jpnTitle, + uploader: uploader, + category: Category(rawValue: category) ?? .private, + tags: tags?.toObject() ?? [], + pageCount: Int(pageCount), + postedDate: postedDate, + rating: rating, + onlineCoverURL: onlineCoverURL, + folderRelativePath: folderRelativePath, + coverRelativePath: coverRelativePath, + status: DownloadStatus(rawValue: status) ?? .queued, + completedPageCount: Int(completedPageCount), + lastDownloadedAt: lastDownloadedAt, + lastError: lastError?.toObject(), + downloadOptionsSnapshot: downloadOptionsSnapshot?.toObject() ?? .init(), + remoteVersionSignature: remoteVersionSignature, + latestRemoteVersionSignature: latestRemoteVersionSignature, + pendingOperation: pendingOperation.flatMap(DownloadStartMode.init(rawValue:)) + ) + } +} diff --git a/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataProperties.swift new file mode 100644 index 000000000..b3f10ed1c --- /dev/null +++ b/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataProperties.swift @@ -0,0 +1,35 @@ +// +// DownloadedGalleryMO+CoreDataProperties.swift +// EhPanda +// + +import CoreData + +extension DownloadedGalleryMO: GalleryIdentifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "DownloadedGalleryMO") + } + + @NSManaged public var category: String + @NSManaged public var completedPageCount: Int64 + @NSManaged public var coverRelativePath: String? + @NSManaged public var downloadOptionsSnapshot: Data? + @NSManaged public var folderRelativePath: String + @NSManaged public var gid: String + @NSManaged public var host: String + @NSManaged public var jpnTitle: String? + @NSManaged public var lastDownloadedAt: Date? + @NSManaged public var lastError: Data? + @NSManaged public var latestRemoteVersionSignature: String? + @NSManaged public var onlineCoverURL: URL? + @NSManaged public var pageCount: Int64 + @NSManaged public var pendingOperation: String? + @NSManaged public var postedDate: Date + @NSManaged public var rating: Float + @NSManaged public var remoteVersionSignature: String + @NSManaged public var status: String + @NSManaged public var tags: Data? + @NSManaged public var title: String + @NSManaged public var token: String + @NSManaged public var uploader: String? +} diff --git a/EhPanda/Database/Migration/CoreDataMigrationVersion.swift b/EhPanda/Database/Migration/CoreDataMigrationVersion.swift index b34968416..ad0b4d8e5 100755 --- a/EhPanda/Database/Migration/CoreDataMigrationVersion.swift +++ b/EhPanda/Database/Migration/CoreDataMigrationVersion.swift @@ -14,6 +14,7 @@ enum CoreDataMigrationVersion: String, CaseIterable { case version5 = "Model 5" case version6 = "Model 6" case version7 = "Model 7" + case version8 = "Model 8" static func current() throws -> CoreDataMigrationVersion { guard let latest = allCases.last else { @@ -30,7 +31,8 @@ enum CoreDataMigrationVersion: String, CaseIterable { case .version4: return .version5 case .version5: return .version6 case .version6: return .version7 - case .version7: return nil + case .version7: return .version8 + case .version8: return nil } } } diff --git a/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion b/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion index f5b3fac01..e46b68c8e 100644 --- a/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion +++ b/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Model 7.xcdatamodel + Model 8.xcdatamodel diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 8.xcdatamodel/contents b/EhPanda/Database/Model.xcdatamodeld/Model 8.xcdatamodel/contents new file mode 100644 index 000000000..b6dbe1d3a --- /dev/null +++ b/EhPanda/Database/Model.xcdatamodeld/Model 8.xcdatamodel/contents @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EhPanda/Database/Persistence.swift b/EhPanda/Database/Persistence.swift index 434f903c1..9f8748ae5 100644 --- a/EhPanda/Database/Persistence.swift +++ b/EhPanda/Database/Persistence.swift @@ -37,6 +37,7 @@ extension PersistenceController { try NSPersistentStoreCoordinator.destroyStore(at: storeURL) } catch { completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) + return } container.loadPersistentStores { _, error in guard error == nil else { @@ -76,6 +77,7 @@ extension PersistenceController { try migrator.migrateStore(at: storeURL, toVersion: try CoreDataMigrationVersion.current()) } catch { completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) + return } completion(.success(())) } diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift new file mode 100644 index 000000000..3e8e1daba --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -0,0 +1,944 @@ +// +// DownloadedGallery.swift +// EhPanda +// + +import SwiftUI +import CryptoKit + +enum DownloadThreadMode: Codable, CaseIterable, Identifiable, Sendable { + case single + case double + case triple + case quadruple + case quintuple + + var id: Int { workerCount } + + var value: String { + switch self { + case .single: + return L10n.Localizable.Enum.DownloadThreadMode.Value.single + case .double: + return L10n.Localizable.Enum.DownloadThreadMode.Value.double + case .triple: + return L10n.Localizable.Enum.DownloadThreadMode.Value.triple + case .quadruple: + return L10n.Localizable.Enum.DownloadThreadMode.Value.quadruple + case .quintuple: + return L10n.Localizable.Enum.DownloadThreadMode.Value.quintuple + } + } + + var workerCount: Int { + switch self { + case .single: + return 1 + case .double: + return 2 + case .triple: + return 3 + case .quadruple: + return 4 + case .quintuple: + return 5 + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let storedValue = (try? container.decode(String.self)) ?? "" + switch storedValue { + case "single": + self = .single + case "double": + self = .double + case "triple": + self = .triple + case "quadruple": + self = .quadruple + case "quintuple": + self = .quintuple + default: + self = .single + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .single: + try container.encode("single") + case .double: + try container.encode("double") + case .triple: + try container.encode("triple") + case .quadruple: + try container.encode("quadruple") + case .quintuple: + try container.encode("quintuple") + } + } +} + +struct DownloadOptionsSnapshot: Codable, Equatable, Sendable { + var threadMode: DownloadThreadMode = .single + var allowCellular = true + var autoRetryFailedPages = true + + var workerCount: Int { + threadMode.workerCount + } + + private enum CodingKeys: String, CodingKey { + case threadMode + case allowCellular + case autoRetryFailedPages + } + + init( + threadMode: DownloadThreadMode = .single, + allowCellular: Bool = true, + autoRetryFailedPages: Bool = true + ) { + self.threadMode = threadMode + self.allowCellular = allowCellular + self.autoRetryFailedPages = autoRetryFailedPages + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + threadMode = try container.decodeIfPresent(DownloadThreadMode.self, forKey: .threadMode) ?? .single + allowCellular = try container.decodeIfPresent(Bool.self, forKey: .allowCellular) ?? true + autoRetryFailedPages = try container.decodeIfPresent(Bool.self, forKey: .autoRetryFailedPages) ?? true + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(threadMode, forKey: .threadMode) + try container.encode(allowCellular, forKey: .allowCellular) + try container.encode(autoRetryFailedPages, forKey: .autoRetryFailedPages) + } +} + +enum DownloadStatus: String, Codable, Equatable, CaseIterable, Sendable { + case queued + case downloading + case paused + case partial + case completed + case failed + case updateAvailable + case missingFiles +} + +enum DownloadFailureCode: String, Codable, Equatable, Sendable { + case quotaExceeded + case authenticationRequired + case fileOperationFailed + case ipBanned + case networkingFailed + case parseFailed + case notFound + case unknown +} + +struct DownloadFailure: Codable, Equatable, Sendable { + var code: DownloadFailureCode + var message: String + + init(code: DownloadFailureCode, message: String) { + self.code = code + self.message = message + } + + init(error: AppError) { + switch error { + case .quotaExceeded: + self = .init(code: .quotaExceeded, message: error.alertText) + case .authenticationRequired: + self = .init(code: .authenticationRequired, message: error.alertText) + case .fileOperationFailed(let reason): + self = .init(code: .fileOperationFailed, message: reason) + case .ipBanned(let interval): + self = .init(code: .ipBanned, message: interval.description) + case .networkingFailed: + self = .init(code: .networkingFailed, message: error.alertText) + case .parseFailed: + self = .init(code: .parseFailed, message: error.alertText) + case .notFound: + self = .init(code: .notFound, message: error.alertText) + default: + self = .init(code: .unknown, message: error.alertText) + } + } + + var appError: AppError { + switch code { + case .quotaExceeded: + return .quotaExceeded + case .authenticationRequired: + return .authenticationRequired + case .fileOperationFailed: + return .fileOperationFailed(message) + case .ipBanned: + return .ipBanned(.unrecognized(content: message)) + case .networkingFailed: + return .networkingFailed + case .parseFailed: + return .parseFailed + case .notFound: + return .notFound + case .unknown: + return .unknown + } + } +} + +enum DownloadStartMode: String, Codable, Equatable, Sendable { + case initial + case update + case redownload + case repair +} + +struct DownloadManifest: Codable, Equatable { + struct Page: Codable, Equatable, Identifiable { + var id: Int { index } + + let index: Int + let relativePath: String + } + + let gid: String + let host: GalleryHost + let token: String + let title: String + let jpnTitle: String? + let category: Category + let language: Language + let uploader: String? + let tags: [GalleryTag] + let postedDate: Date + let pageCount: Int + let coverRelativePath: String? + let galleryURL: URL + let rating: Float + let downloadOptions: DownloadOptionsSnapshot + let versionSignature: String + let downloadedAt: Date + let pages: [Page] + + func imageURLs(folderURL: URL) -> [Int: URL] { + Dictionary(uniqueKeysWithValues: pages.map { + ($0.index, folderURL.appendingPathComponent($0.relativePath)) + }) + } +} + +struct DownloadFailedPagesSnapshot: Codable, Equatable, Sendable { + struct Page: Codable, Equatable, Identifiable, Sendable { + var id: Int { index } + + let index: Int + let relativePath: String? + let failure: DownloadFailure + } + + var pages: [Page] + + var map: [Int: Page] { + Dictionary(uniqueKeysWithValues: pages.map { ($0.index, $0) }) + } +} + +enum DownloadPageStatus: String, Equatable, Sendable { + case pending + case downloaded + case failed +} + +struct DownloadPageInspection: Equatable, Identifiable, Sendable { + var id: Int { index } + + let index: Int + let status: DownloadPageStatus + let relativePath: String? + let fileURL: URL? + let failure: DownloadFailure? +} + +struct DownloadInspection: Equatable, Sendable { + let download: DownloadedGallery + let coverURL: URL? + let pages: [DownloadPageInspection] + + var failedPageIndices: [Int] { + pages.filter { $0.status == .failed }.map(\.index) + } +} + +enum DownloadBadge: Equatable { + case none + case queued + case downloading(Int, Int) + case paused(Int, Int) + case partial(Int, Int) + case downloaded + case failed + case updateAvailable + case missingFiles +} + +extension DownloadBadge { + var text: String { + switch self { + case .none: + return "" + case .queued: + return L10n.Localizable.Struct.DownloadBadge.Text.queued + case .downloading(let completed, let total): + return L10n.Localizable.Struct.DownloadBadge.Text.downloading(completed, max(total, 1)) + case .paused(let completed, let total): + return L10n.Localizable.Struct.DownloadBadge.Text.paused(completed, max(total, 1)) + case .partial(let completed, let total): + return L10n.Localizable.Struct.DownloadBadge.Text.needsAttentionProgress( + completed, + max(total, 1) + ) + case .downloaded: + return L10n.Localizable.Struct.DownloadBadge.Text.downloaded + case .failed: + return L10n.Localizable.Struct.DownloadBadge.Text.needsAttention + case .updateAvailable: + return L10n.Localizable.Struct.DownloadBadge.Text.updateAvailable + case .missingFiles: + return L10n.Localizable.Struct.DownloadBadge.Text.needsRepair + } + } + + var color: Color { + switch self { + case .none: + return .clear + case .queued: + return .orange + case .downloading: + return .blue + case .paused: + return .indigo + case .partial: + return .orange + case .downloaded: + return .green + case .failed: + return .orange + case .updateAvailable: + return .yellow + case .missingFiles: + return .pink + } + } +} + +enum DownloadListFilter: String, CaseIterable, Identifiable { + case all + case active + case completed + case failed + case update + + var id: String { rawValue } + + var title: String { + switch self { + case .all: + return L10n.Localizable.Enum.DownloadListFilter.Title.all + case .active: + return L10n.Localizable.Enum.DownloadListFilter.Title.active + case .completed: + return L10n.Localizable.Enum.DownloadListFilter.Title.completed + case .failed: + return L10n.Localizable.Enum.DownloadListFilter.Title.failed + case .update: + return L10n.Localizable.Enum.DownloadListFilter.Title.update + } + } +} + +struct DownloadGalleryFilter: Equatable { + var excludedCategories = Set() + var minimumRatingActivated = false + var minimumRating = 2 + var pageRangeActivated = false + var pageLowerBound = "" + var pageUpperBound = "" + + mutating func fixInvalidData() { + if !pageLowerBound.isEmpty && Int(pageLowerBound) == nil { + pageLowerBound = "" + } + if !pageUpperBound.isEmpty && Int(pageUpperBound) == nil { + pageUpperBound = "" + } + } + + mutating func reset() { + self = .init() + } + + var hasActiveValues: Bool { + !excludedCategories.isEmpty + || minimumRatingActivated + || pageRangeActivated + || pageLowerBound.notEmpty + || pageUpperBound.notEmpty + } +} + +struct DownloadRequestPayload: Equatable, @unchecked Sendable { + let gallery: Gallery + let galleryDetail: GalleryDetail + let previewURLs: [Int: URL] + let previewConfig: PreviewConfig + let host: GalleryHost + let versionMetadata: DownloadVersionMetadata? + let options: DownloadOptionsSnapshot + let mode: DownloadStartMode + let pageSelection: Set? + + init( + gallery: Gallery, + galleryDetail: GalleryDetail, + previewURLs: [Int: URL], + previewConfig: PreviewConfig, + host: GalleryHost, + versionMetadata: DownloadVersionMetadata? = nil, + options: DownloadOptionsSnapshot, + mode: DownloadStartMode, + pageSelection: Set? = nil + ) { + self.gallery = gallery + self.galleryDetail = galleryDetail + self.previewURLs = previewURLs + self.previewConfig = previewConfig + self.host = host + self.versionMetadata = versionMetadata + self.options = options + self.mode = mode + self.pageSelection = pageSelection + } +} + +struct DownloadedGallery: Identifiable, Equatable { + var id: String { gid } + + let gid: String + let host: GalleryHost + let token: String + let title: String + let jpnTitle: String? + let uploader: String? + let category: Category + let tags: [GalleryTag] + let pageCount: Int + let postedDate: Date + let rating: Float + let onlineCoverURL: URL? + let folderRelativePath: String + let coverRelativePath: String? + let status: DownloadStatus + let completedPageCount: Int + let lastDownloadedAt: Date? + let lastError: DownloadFailure? + let downloadOptionsSnapshot: DownloadOptionsSnapshot + let remoteVersionSignature: String + let latestRemoteVersionSignature: String? + let pendingOperation: DownloadStartMode? + + init( + gid: String, + host: GalleryHost, + token: String, + title: String, + jpnTitle: String?, + uploader: String?, + category: Category, + tags: [GalleryTag], + pageCount: Int, + postedDate: Date, + rating: Float, + onlineCoverURL: URL?, + folderRelativePath: String, + coverRelativePath: String?, + status: DownloadStatus, + completedPageCount: Int, + lastDownloadedAt: Date?, + lastError: DownloadFailure?, + downloadOptionsSnapshot: DownloadOptionsSnapshot, + remoteVersionSignature: String, + latestRemoteVersionSignature: String?, + pendingOperation: DownloadStartMode? = nil + ) { + self.gid = gid + self.host = host + self.token = token + self.title = title + self.jpnTitle = jpnTitle + self.uploader = uploader + self.category = category + self.tags = tags + self.pageCount = pageCount + self.postedDate = postedDate + self.rating = rating + self.onlineCoverURL = onlineCoverURL + self.folderRelativePath = folderRelativePath + self.coverRelativePath = coverRelativePath + self.status = status + self.completedPageCount = completedPageCount + self.lastDownloadedAt = lastDownloadedAt + self.lastError = lastError + self.downloadOptionsSnapshot = downloadOptionsSnapshot + self.remoteVersionSignature = remoteVersionSignature + self.latestRemoteVersionSignature = latestRemoteVersionSignature + self.pendingOperation = pendingOperation + } + + var displayTitle: String { + jpnTitle?.notEmpty == true ? jpnTitle.forceUnwrapped : title + } + + var searchableText: String { + [ + title, + jpnTitle ?? "", + uploader ?? "", + category.value, + tags.flatMap(\.contents).map(\.text).joined(separator: " ") + ] + .joined(separator: " ") + } + + func resolvedFolderURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { + rootURL?.appendingPathComponent(folderRelativePath, isDirectory: true) + } + + func resolvedManifestURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { + resolvedFolderURL(rootURL: rootURL)? + .appendingPathComponent(Defaults.FilePath.downloadManifest) + } + + func resolvedLocalCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { + guard let folderURL = resolvedFolderURL(rootURL: rootURL), + let coverRelativePath, + coverRelativePath.notEmpty + else { return nil } + let coverURL = folderURL.appendingPathComponent(coverRelativePath) + guard isReadableLocalAssetFile(coverURL) else { + return nil + } + return coverURL + } + + func resolvedTemporaryCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { + guard shouldPreserveTemporaryWorkingSet, + let rootURL + else { + return nil + } + + let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) + guard FileManager.default.fileExists(atPath: temporaryFolderURL.path) else { + return nil + } + + if let coverRelativePath, + coverRelativePath.notEmpty + { + let coverURL = temporaryFolderURL.appendingPathComponent(coverRelativePath) + if isReadableLocalAssetFile(coverURL) { + return coverURL + } + } + + guard let fileURLs = try? FileManager.default.contentsOfDirectory( + at: temporaryFolderURL, + includingPropertiesForKeys: nil + ) else { + return nil + } + + return fileURLs.first(where: { + $0.lastPathComponent.hasPrefix("cover.") && isReadableLocalAssetFile($0) + }) + } + + func resolvedCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { + resolvedLocalCoverURL(rootURL: rootURL) + ?? resolvedTemporaryCoverURL(rootURL: rootURL) + ?? onlineCoverURL + } + + var folderURL: URL? { + resolvedFolderURL() + } + + var manifestURL: URL? { + resolvedManifestURL() + } + + var localCoverURL: URL? { + resolvedLocalCoverURL() + } + + var coverURL: URL? { + resolvedCoverURL() + } + + var badge: DownloadBadge { + if isQueuedWorkItem { + return .queued + } + switch status { + case .queued: + return .queued + case .downloading: + return .downloading(completedPageCount, pageCount) + case .paused: + return .paused(completedPageCount, pageCount) + case .partial: + return .partial(completedPageCount, pageCount) + case .completed: + return .downloaded + case .failed: + return .failed + case .updateAvailable: + return .updateAvailable + case .missingFiles: + return .missingFiles + } + } + + var sortPriority: Int { + if isQueuedWorkItem { + return 1 + } + + switch status { + case .downloading: + return 0 + case .paused: + return 1 + case .queued: + return 2 + case .partial: + return 3 + case .updateAvailable: + return 4 + case .missingFiles: + return 5 + case .failed: + return 6 + case .completed: + return 7 + } + } + + var gallery: Gallery { + Gallery( + gid: gid, + token: token, + title: displayTitle, + rating: rating, + tags: tags, + category: category, + uploader: uploader, + pageCount: pageCount, + postedDate: postedDate, + coverURL: coverURL, + galleryURL: host.url + .appendingPathComponent("g") + .appendingPathComponent(gid) + .appendingPathComponent(token) + ) + } + + var canRetry: Bool { + [.partial, .failed, .missingFiles].contains(status) + } + + var canPauseOrResume: Bool { + [.downloading, .paused].contains(status) + } + + var shouldPreserveTemporaryWorkingSet: Bool { + pendingOperation != nil + || [.queued, .downloading, .paused, .partial].contains(status) + } + + var isPendingQueue: Bool { + badge == .queued + } + + var canCancelFromDetailAction: Bool { + isPendingQueue || canPauseOrResume || [.partial, .completed].contains(status) + } + + var canTriggerUpdate: Bool { + guard !isQueuedWorkItem, !canPauseOrResume else { return false } + return status == .updateAvailable || ([.completed, .missingFiles].contains(status) && hasUpdate) + } + + var isQueuedWorkItem: Bool { + status == .queued || pendingOperation != nil + } + + var hasUpdate: Bool { + DownloadSignatureBuilder.hasUpdateComparison( + remoteVersionSignature: remoteVersionSignature, + latestRemoteVersionSignature: latestRemoteVersionSignature, + gid: gid, + token: token + ) == .different + } + + func needsInterruptedDownloadNormalization( + activeGalleryID: String?, + hasActiveTask: Bool + ) -> Bool { + status == .downloading && !(hasActiveTask && activeGalleryID == gid) + } + + func matches(filter: DownloadListFilter) -> Bool { + if isQueuedWorkItem { + return filter == .all || filter == .active + } + + switch filter { + case .all: + return true + case .active: + return [.downloading, .paused].contains(status) + case .completed: + return status == .completed + case .failed: + return [.partial, .failed, .missingFiles].contains(status) + case .update: + return status == .updateAvailable || hasUpdate + } + } + + func matches(queryFilter: DownloadGalleryFilter) -> Bool { + if queryFilter.excludedCategories.contains(category) { + return false + } + + if queryFilter.minimumRatingActivated && rating < Float(queryFilter.minimumRating) { + return false + } + + guard queryFilter.pageRangeActivated else { return true } + + if let lowerBound = Int(queryFilter.pageLowerBound), pageCount < lowerBound { + return false + } + if let upperBound = Int(queryFilter.pageUpperBound), pageCount > upperBound { + return false + } + + return true + } +} + +private extension DownloadedGallery { + func isReadableLocalAssetFile(_ url: URL) -> Bool { + guard FileManager.default.fileExists(atPath: url.path) else { return false } + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + let isRegularFile = values?.isRegularFile ?? true + let fileSize = values?.fileSize ?? 0 + return isRegularFile && fileSize > 0 + } +} + +enum ReadingContentSource: Equatable { + case remote + case local(DownloadedGallery, DownloadManifest) +} + +struct DownloadVersionMetadata: Equatable, Codable, Sendable { + let gid: String + let token: String + let currentGID: String? + let currentKey: String? + let parentGID: String? + let parentKey: String? + let firstGID: String? + let firstKey: String? + + var versionIdentifier: String? { + DownloadSignatureBuilder.chainVersionIdentifier( + gid: resolvedCurrentGID, + token: resolvedCurrentKey + ) + } + + private var resolvedCurrentGID: String { + currentGID?.notEmpty == true ? currentGID.forceUnwrapped : gid + } + + private var resolvedCurrentKey: String { + currentKey?.notEmpty == true ? currentKey.forceUnwrapped : token + } +} + +enum DownloadSignatureBuilder { + enum SignatureKind: Equatable { + case chain(gid: String, token: String) + case hash(String) + } + + enum Comparison: Equatable { + case same + case different + case incomparable + } + + static func make( + gallery: Gallery, + detail: GalleryDetail, + host _: GalleryHost, + previewURLs: [Int: URL], + versionMetadata: DownloadVersionMetadata? = nil + ) -> String { + if let versionIdentifier = versionMetadata?.versionIdentifier { + return versionIdentifier + } + + let previewHash = SHA256.hash( + data: previewURLs + .sorted(by: { $0.key < $1.key }) + .map { "\($0.key)=\(normalizedPreviewSignatureValue(url: $0.value))" } + .joined(separator: "|") + .data(using: .utf8) ?? Data() + ) + + let payload = [ + gallery.gid, + gallery.token, + gallery.title, + detail.jpnTitle ?? "", + String(detail.pageCount), + normalizedCoverSignatureValue(url: detail.coverURL ?? gallery.coverURL), + detail.formattedDateString, + previewHash.compactMap { String(format: "%02x", $0) }.joined() + ] + .joined(separator: "::") + + let digest = SHA256.hash( + data: payload.data(using: String.Encoding.utf8) ?? Data() + ) + let hash = digest.compactMap { String(format: "%02x", $0) }.joined() + return "hash:\(hash)" + } + + static func chainVersionIdentifier(gid: String, token: String) -> String? { + guard gid.notEmpty, token.notEmpty else { return nil } + return "chain:\(gid):\(token)" + } + + static func parse(_ value: String?) -> SignatureKind? { + guard let value, value.notEmpty else { return nil } + + if value.hasPrefix("chain:") { + let components = value.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) + guard components.count == 3, + !components[1].isEmpty, + !components[2].isEmpty + else { + return nil + } + return .chain(gid: String(components[1]), token: String(components[2])) + } + + if value.hasPrefix("hash:") { + let hash = String(value.dropFirst("hash:".count)) + guard hash.notEmpty else { return nil } + return .hash(hash) + } + + return nil + } + + static func compare( + remoteVersionSignature: String, + latestRemoteVersionSignature: String?, + gid: String, + token: String + ) -> Comparison { + guard let storedSignature = parse(remoteVersionSignature), + let latestSignature = parse(latestRemoteVersionSignature) + else { + return .incomparable + } + + switch (storedSignature, latestSignature) { + case let (.chain(storedGID, storedToken), .chain(latestGID, latestToken)): + return storedGID == latestGID && storedToken == latestToken ? .same : .different + + case let (.hash(storedHash), .hash(latestHash)): + return storedHash == latestHash ? .same : .different + + case (.hash, .chain): + return latestRemoteVersionSignature == chainVersionIdentifier(gid: gid, token: token) + ? .same + : .incomparable + + case (.chain, .hash): + return .incomparable + } + } + + static func canonicalizeStoredSignatureIfSafe( + remoteVersionSignature: String, + latestRemoteVersionSignature: String?, + gid: String, + token: String + ) -> String? { + guard case .hash = parse(remoteVersionSignature), + case .chain = parse(latestRemoteVersionSignature), + latestRemoteVersionSignature == chainVersionIdentifier(gid: gid, token: token) + else { + return nil + } + return latestRemoteVersionSignature + } + + static func hasUpdateComparison( + remoteVersionSignature: String, + latestRemoteVersionSignature: String?, + gid: String, + token: String + ) -> Comparison { + compare( + remoteVersionSignature: remoteVersionSignature, + latestRemoteVersionSignature: latestRemoteVersionSignature, + gid: gid, + token: token + ) + } + + private static func normalizedPreviewSignatureValue(url: URL) -> String { + let lastPathComponent = url.lastPathComponent + guard lastPathComponent.notEmpty else { + return normalizedCoverSignatureValue(url: url) + } + return lastPathComponent + } + + private static func normalizedCoverSignatureValue(url: URL?) -> String { + guard let url else { return "" } + let stablePathComponents = url.pathComponents + .filter { $0 != "/" && $0.notEmpty } + return stablePathComponents.joined(separator: "/") + } +} diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift index 4ea020c01..580691db6 100644 --- a/EhPanda/Models/Persistent/Setting.swift +++ b/EhPanda/Models/Persistent/Setting.swift @@ -49,10 +49,25 @@ struct Setting: Codable, Equatable { var maximumScaleFactor: Double = 3 var doubleTapScaleFactor: Double = 2 + // Downloads + var downloadThreadMode: DownloadThreadMode = .single + var downloadAllowCellular = true + var downloadAutoRetryFailedPages = true + // Laboratory var bypassesSNIFiltering = false } +extension Setting { + var downloadOptionsSnapshot: DownloadOptionsSnapshot { + .init( + threadMode: downloadThreadMode, + allowCellular: downloadAllowCellular, + autoRetryFailedPages: downloadAutoRetryFailedPages + ) + } +} + enum GalleryHost: String, Codable, Equatable, CaseIterable, Identifiable { case ehentai = "E-Hentai" case exhentai = "ExHentai" @@ -215,6 +230,13 @@ extension Setting { contentDividerHeight = (try? container?.decodeIfPresent(Double.self, forKey: .contentDividerHeight)) ?? 0 maximumScaleFactor = (try? container?.decodeIfPresent(Double.self, forKey: .maximumScaleFactor)) ?? 3 doubleTapScaleFactor = (try? container?.decodeIfPresent(Double.self, forKey: .doubleTapScaleFactor)) ?? 2 + // Downloads + downloadThreadMode = (try? container?.decodeIfPresent(DownloadThreadMode.self, forKey: .downloadThreadMode)) + ?? .single + downloadAllowCellular = (try? container?.decodeIfPresent(Bool.self, forKey: .downloadAllowCellular)) ?? true + downloadAutoRetryFailedPages = ( + try? container?.decodeIfPresent(Bool.self, forKey: .downloadAutoRetryFailedPages) + ) ?? true // Laboratory bypassesSNIFiltering = (try? container?.decodeIfPresent(Bool.self, forKey: .bypassesSNIFiltering)) ?? false } diff --git a/EhPanda/Models/Support/AppError.swift b/EhPanda/Models/Support/AppError.swift index d315149f5..94b738cd3 100644 --- a/EhPanda/Models/Support/AppError.swift +++ b/EhPanda/Models/Support/AppError.swift @@ -16,6 +16,9 @@ enum AppError: Error, Identifiable, Equatable, Hashable { case networkingFailed case webImageFailed case parseFailed + case quotaExceeded + case authenticationRequired + case fileOperationFailed(String) case noUpdates case notFound case unknown @@ -24,35 +27,42 @@ enum AppError: Error, Identifiable, Equatable, Hashable { extension AppError { var isRetryable: Bool { switch self { - case .databaseCorrupted, .ipBanned, .networkingFailed, .parseFailed, - .noUpdates, .notFound, .unknown, .webImageFailed: + case .databaseCorrupted, .networkingFailed, .parseFailed, + .fileOperationFailed, .noUpdates, .unknown, .webImageFailed: return true - case .copyrightClaim, .expunged: + case .copyrightClaim, .expunged, .quotaExceeded, .authenticationRequired, .notFound, + .ipBanned: return false } } var localizedDescription: String { switch self { case .databaseCorrupted: - return "Database Corrupted" + return L10n.Localizable.AppError.LocalizedDescription.databaseCorrupted case .copyrightClaim: - return "Copyright Claim" + return L10n.Localizable.AppError.LocalizedDescription.copyrightClaim case .ipBanned: - return "IP Banned" + return L10n.Localizable.AppError.LocalizedDescription.ipBanned case .expunged: - return "Gallery Expunged" + return L10n.Localizable.AppError.LocalizedDescription.galleryExpunged case .networkingFailed: - return "Network Error" + return L10n.Localizable.AppError.LocalizedDescription.networkError case .webImageFailed: - return "Web image loading error" + return L10n.Localizable.AppError.LocalizedDescription.webImageLoadingError case .parseFailed: - return "Parse Error" + return L10n.Localizable.AppError.LocalizedDescription.parseError + case .quotaExceeded: + return L10n.Localizable.AppError.LocalizedDescription.quotaExceeded + case .authenticationRequired: + return L10n.Localizable.AppError.LocalizedDescription.authenticationRequired + case .fileOperationFailed: + return L10n.Localizable.AppError.LocalizedDescription.fileOperationFailed case .noUpdates: - return "No updates available" + return L10n.Localizable.AppError.LocalizedDescription.noUpdatesAvailable case .notFound: - return "Not found" + return L10n.Localizable.AppError.LocalizedDescription.notFound case .unknown: - return "Unknown Error" + return L10n.Localizable.AppError.LocalizedDescription.unknownError } } var symbol: SFSymbol { @@ -67,6 +77,12 @@ extension AppError { return .wifiExclamationmark case .parseFailed: return .rectangleAndTextMagnifyingglass + case .quotaExceeded: + return .speedometer + case .authenticationRequired: + return .lockCircleFill + case .fileOperationFailed: + return .folderFill case .notFound, .unknown, .noUpdates, .webImageFailed: return .questionmarkCircleFill } @@ -95,6 +111,14 @@ extension AppError { return [L10n.Localizable.ErrorView.Title.network, tryLater].joined(separator: "\n") case .parseFailed: return [L10n.Localizable.ErrorView.Title.parsing, tryLater].joined(separator: "\n") + case .quotaExceeded: + return L10n.Localizable.AppError.Alert.quotaExceeded + case .authenticationRequired: + return L10n.Localizable.AppError.Alert.authenticationRequired + case .fileOperationFailed(let reason): + return [L10n.Localizable.AppError.Alert.localFileOperationFailed, reason] + .filter(\.notEmpty) + .joined(separator: "\n") case .noUpdates, .webImageFailed: return "" case .notFound: diff --git a/EhPanda/Models/Support/EhSetting.swift b/EhPanda/Models/Support/EhSetting.swift index 1d5a19447..d7fc9aad8 100644 --- a/EhPanda/Models/Support/EhSetting.swift +++ b/EhPanda/Models/Support/EhSetting.swift @@ -166,7 +166,7 @@ extension EhSetting.LoadThroughHathSetting { // MARK: ImageResolution extension EhSetting { - enum ImageResolution: Int, CaseIterable, Identifiable, Comparable { + enum ImageResolution: Int, CaseIterable, Identifiable, Comparable, Codable { case auto case x780 /// Deprecated diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift index 46d141190..92586bb66 100644 --- a/EhPanda/Models/Support/Misc.swift +++ b/EhPanda/Models/Support/Misc.swift @@ -47,6 +47,10 @@ struct QuickSearchWord: Codable, Equatable, Identifiable { var id: UUID = .init() var name: String var content: String + + var effectiveSearchText: String { + content.notEmpty ? content : name + } } @dynamicMemberLookup @CasePathable diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index f70b746a3..f19fbf192 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -77,6 +77,14 @@ private extension Dictionary where Key == String, Value == String { } } +private extension URL { + var galleryToken: String? { + let filteredComponents = pathComponents.filter { $0 != "/" && $0.notEmpty } + guard filteredComponents.count >= 3 else { return nil } + return filteredComponents[2] + } +} + // MARK: Routine struct GreetingRequest: Request { var publisher: AnyPublisher { @@ -350,24 +358,107 @@ struct GalleryDetailRequest: Request { var publisher: AnyPublisher<(GalleryDetail, GalleryState, String, Greeting?), AppError> { URLSession.shared.dataTaskPublisher(for: URLUtil.galleryDetail(url: galleryURL)) .genericRetry() - .compactMap { resp -> HTMLDocument? in - var htmlDocument: HTMLDocument? + .tryMap { resp -> HTMLDocument in do { - htmlDocument = try Kanna.HTML(html: resp.data, encoding: .utf8) + return try Kanna.HTML(html: resp.data, encoding: .utf8) } catch { guard let parseError = error as? ParseError, parseError == .EncodingMismatch - else { return htmlDocument } + else { throw error } - htmlDocument = try? Kanna.HTML(html: resp.data.utf8InvalidCharactersRipped, encoding: .utf8) + guard let htmlDocument = try? Kanna.HTML( + html: resp.data.utf8InvalidCharactersRipped, + encoding: .utf8 + ) else { + throw error + } + return htmlDocument } - return htmlDocument } - .tryMap { - let (detail, state) = try Parser.parseGalleryDetail(doc: $0, gid: gid) - return ($0, detail, state, try Parser.parseAPIKey(doc: $0)) + .tryMap { doc in + let (detail, state) = try Parser.parseGalleryDetail(doc: doc, gid: gid) + return (doc, detail, state, try Parser.parseAPIKey(doc: doc)) } + .mapError(mapAppError) .map { doc, detail, state, apiKey in - (detail, state, apiKey, try? Parser.parseGreeting(doc: doc)) + ( + detail, + state, + apiKey, + try? Parser.parseGreeting(doc: doc) + ) + } + .eraseToAnyPublisher() + } +} + +private struct GalleryVersionMetadataAPIResponse: Decodable { + struct GalleryMetadata: Decodable { + let gid: Int + let token: String + let currentGID: Int? + let currentKey: String? + let parentGID: Int? + let parentKey: String? + let firstGID: Int? + let firstKey: String? + + enum CodingKeys: String, CodingKey { + case gid + case token + case currentGID = "current_gid" + case currentKey = "current_key" + case parentGID = "parent_gid" + case parentKey = "parent_key" + case firstGID = "first_gid" + case firstKey = "first_key" + } + + var versionMetadata: DownloadVersionMetadata { + DownloadVersionMetadata( + gid: String(gid), + token: token, + currentGID: currentGID.map(String.init), + currentKey: currentKey, + parentGID: parentGID.map(String.init), + parentKey: parentKey, + firstGID: firstGID.map(String.init), + firstKey: firstKey + ) + } + } + + let gmetadata: [GalleryMetadata] +} + +struct GalleryVersionMetadataRequest: Request { + let gid: String + let token: String + + var publisher: AnyPublisher { + guard let gid = Int(gid) else { + return Fail(error: AppError.notFound) + .eraseToAnyPublisher() + } + + let params: [String: Any] = [ + "method": "gdata", + "gidlist": [[gid, token]], + "namespace": 1 + ] + + var request = URLRequest(url: Defaults.URL.api) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map(\.data) + .tryMap { data in + let response = try JSONDecoder().decode(GalleryVersionMetadataAPIResponse.self, from: data) + guard let metadata = response.gmetadata.first?.versionMetadata else { + throw AppError.notFound + } + return metadata } .mapError(mapAppError) .eraseToAnyPublisher() diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 5e6c98230..3d9f487dc 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -25,7 +25,17 @@ struct DetailReducer { } private enum CancelID: CaseIterable { - case fetchDatabaseInfos, fetchGalleryDetail, rateGallery, favorGallery, unfavorGallery, postComment, voteTag + case fetchDatabaseInfos + case fetchGalleryDetail + case fetchVersionMetadata + case fetchDownloadBadge + case observeDownload + case loadLocalPreviewURLs + case rateGallery + case favorGallery + case unfavorGallery + case postComment + case voteTag } @ObservableState @@ -40,12 +50,24 @@ struct DetailReducer { var userRating = 0 var apiKey = "" + var gid = "" var loadingState: LoadingState = .idle var gallery: Gallery = .empty var galleryDetail: GalleryDetail? + var galleryVersionMetadata: DownloadVersionMetadata? var galleryTags = [GalleryTag]() var galleryPreviewURLs = [Int: URL]() + var localPreviewURLs = [Int: URL]() var galleryComments = [GalleryComment]() + var previewConfig: PreviewConfig = .normal(rows: 4) + var downloadBadge: DownloadBadge = .none + var isPreparingDownload = false + var hasLoadedDownloadBadge = false + var didRunLaunchAutomation = false + var isDownloadContext = false + var shouldCheckForRemoteUpdates = false + var didRequestVersionMetadata = false + var localPreviewRequestID = UUID() var readingState = ReadingReducer.State() var archivesState = ArchivesReducer.State() @@ -60,6 +82,37 @@ struct DetailReducer { detailSearchState = .init(nil) } + init(download: DownloadedGallery) { + self.init() + gid = download.gid + gallery = download.gallery + galleryDetail = GalleryDetail( + gid: download.gid, + title: download.title, + jpnTitle: download.jpnTitle, + isFavorited: false, + visibility: .yes, + rating: download.rating, + userRating: 0, + ratingCount: 0, + category: download.category, + language: .japanese, + uploader: download.uploader ?? "", + postedDate: download.postedDate, + coverURL: download.coverURL, + favoritedCount: 0, + pageCount: download.pageCount, + sizeCount: 0, + sizeType: "", + torrentCount: 0 + ) + downloadBadge = download.badge + hasLoadedDownloadBadge = download.badge != .none + isDownloadContext = true + shouldCheckForRemoteUpdates = true + didRequestVersionMetadata = false + } + mutating func updateRating(value: DragGesture.Value) { let rating = Int(value.location.x / 31 * 2) + 1 userRating = min(max(rating, 1), 10) @@ -89,12 +142,33 @@ struct DetailReducer { case syncPreviewConfig(PreviewConfig) case saveGalleryHistory case updateReadingProgress(Int) + case fetchDownloadBadge + case fetchDownloadBadgeDone(DownloadBadge) + case observeDownload + case observeDownloadDone(DownloadBadge) + case loadLocalPreviewURLs + case loadLocalPreviewURLsDone(UUID, [Int: URL]) + case openReading + case openReadingDone(Result<(DownloadedGallery, DownloadManifest), AppError>) + case runLaunchAutomationIfNeeded(DownloadOptionsSnapshot) + case startDownload(DownloadOptionsSnapshot) + case startDownloadDone(Result) + case toggleDownloadPause + case toggleDownloadPauseDone(Result) + case retryDownload(DownloadStartMode) + case retryDownloadDone(Result) + case deleteDownload + case deleteDownloadDone(Result) case teardown case fetchDatabaseInfos(String) case fetchDatabaseInfosDone(GalleryState) case fetchGalleryDetail - case fetchGalleryDetailDone(Result<(GalleryDetail, GalleryState, String, Greeting?), AppError>) + case fetchGalleryDetailDone( + Result<(GalleryDetail, GalleryState, String, Greeting?), AppError> + ) + case fetchVersionMetadataIfNeeded + case fetchVersionMetadataDone(Result) case rateGallery case favorGallery(Int) @@ -113,6 +187,7 @@ struct DetailReducer { } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient @@ -152,14 +227,24 @@ struct DetailReducer { } case .onAppear(let gid, let showsNewDawnGreeting): + state.gid = gid state.showsNewDawnGreeting = showsNewDawnGreeting + state.isPreparingDownload = false + state.hasLoadedDownloadBadge = false + state.didRunLaunchAutomation = false + state.localPreviewURLs = .init() if state.detailSearchState.wrappedValue == nil { state.detailSearchState.wrappedValue = .init() } if state.commentsState.wrappedValue == nil { state.commentsState.wrappedValue = .init() } - return .send(.fetchDatabaseInfos(gid)) + return .merge( + .send(.fetchDatabaseInfos(gid)), + .send(.fetchDownloadBadge), + .send(.observeDownload), + .send(.loadLocalPreviewURLs) + ) case .toggleShowFullTitle: state.showsFullTitle.toggle() @@ -234,16 +319,212 @@ struct DetailReducer { await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) } + case .fetchDownloadBadge: + guard state.gid.isValidGID else { return .none } + return .run { [galleryID = state.gid] send in + let badge = await downloadClient.badges([galleryID])[galleryID] ?? .none + await send(.fetchDownloadBadgeDone(badge)) + } + .cancellable(id: CancelID.fetchDownloadBadge, cancelInFlight: true) + + case .fetchDownloadBadgeDone(let badge): + _ = applyDownloadBadge(badge, state: &state) + + var effects: [Effect] = [ + .send(.loadLocalPreviewURLs) + ] + if shouldRequestVersionMetadata(state: state) { + effects.append(.send(.fetchVersionMetadataIfNeeded)) + } + return .merge(effects) + + case .observeDownload: + guard state.gid.isValidGID else { return .none } + return .run { [galleryID = state.gid] send in + for await downloads in downloadClient.observeDownloads() { + let badge = downloads.first(where: { $0.gid == galleryID })?.badge ?? .none + await send(.observeDownloadDone(badge)) + } + } + .cancellable(id: CancelID.observeDownload, cancelInFlight: true) + + case .observeDownloadDone(let badge): + let didChangeBadge = applyDownloadBadge(badge, state: &state) + guard didChangeBadge else { return .none } + + var effects: [Effect] = [ + .send(.loadLocalPreviewURLs) + ] + if shouldRequestVersionMetadata(state: state) { + effects.append(.send(.fetchVersionMetadataIfNeeded)) + } + return .merge(effects) + + case .loadLocalPreviewURLs: + guard state.gid.isValidGID else { + state.localPreviewRequestID = UUID() + state.localPreviewURLs = .init() + return .none + } + let requestID = UUID() + state.localPreviewRequestID = requestID + return .run { [galleryID = state.gid] send in + let localPreviewURLs: [Int: URL] + switch await downloadClient.loadLocalPageURLs(galleryID) { + case .success(let pageURLs): + localPreviewURLs = pageURLs + case .failure: + localPreviewURLs = [:] + } + await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) + } + .cancellable(id: CancelID.loadLocalPreviewURLs, cancelInFlight: true) + + case .loadLocalPreviewURLsDone(let requestID, let localPreviewURLs): + guard state.localPreviewRequestID == requestID else { return .none } + guard state.localPreviewURLs != localPreviewURLs else { return .none } + state.localPreviewURLs = localPreviewURLs + return .none + + case .openReading: + state.readingState = .init(contentSource: .remote) + return .run { [galleryID = state.gallery.id] send in + guard galleryID.isValidGID else { + await send(.openReadingDone(.failure(.notFound))) + return + } + await send(.openReadingDone(await downloadClient.loadManifest(galleryID))) + } + + case .openReadingDone(let result): + if case .success(let (download, manifest)) = result { + state.readingState = .init(contentSource: .local(download, manifest)) + } else { + state.readingState.contentSource = .remote + state.readingState.localPageURLs = state.localPreviewURLs + } + state.route = .reading() + return .none + + case .runLaunchAutomationIfNeeded(let options): + guard !state.didRunLaunchAutomation, + AppLaunchAutomation.current?.autoDownloadGID == state.gallery.id, + state.galleryDetail != nil, + state.hasLoadedDownloadBadge + else { return .none } + + state.didRunLaunchAutomation = true + guard state.downloadBadge == .none else { return .none } + return .send(.startDownload(options)) + + case .startDownload(let options): + guard !state.isPreparingDownload else { return .none } + state.didRunLaunchAutomation = true + guard let detail = state.galleryDetail else { return .none } + state.isPreparingDownload = true + let payload = DownloadRequestPayload( + gallery: state.gallery, + galleryDetail: detail, + previewURLs: state.galleryPreviewURLs, + previewConfig: state.previewConfig, + host: AppUtil.galleryHost, + versionMetadata: state.galleryVersionMetadata, + options: options, + mode: .initial + ) + return .run { send in + await send(.startDownloadDone(await downloadClient.enqueue(payload))) + } + + case .startDownloadDone(let result): + state.isPreparingDownload = false + if case .success = result { + state.downloadBadge = .queued + state.hasLoadedDownloadBadge = true + return .merge( + .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + + case .toggleDownloadPause: + guard !state.isPreparingDownload else { return .none } + state.isPreparingDownload = true + return .run { [galleryID = state.gallery.id] send in + await send(.toggleDownloadPauseDone(await downloadClient.togglePause(galleryID))) + } + + case .toggleDownloadPauseDone(let result): + state.isPreparingDownload = false + if case .success = result { + switch state.downloadBadge { + case .downloading(let completed, let total): + state.downloadBadge = .paused(completed, total) + case .paused: + state.downloadBadge = .queued + default: + break + } + state.hasLoadedDownloadBadge = state.downloadBadge != .none + return .merge( + .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + + case .retryDownload(let mode): + guard !state.isPreparingDownload else { return .none } + state.isPreparingDownload = true + return .run { [galleryID = state.gallery.id] send in + await send(.retryDownloadDone(await downloadClient.retry(galleryID, mode))) + } + + case .retryDownloadDone(let result): + state.isPreparingDownload = false + if case .success = result { + state.downloadBadge = .queued + state.hasLoadedDownloadBadge = true + return .merge( + .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + + case .deleteDownload: + return .run { [galleryID = state.gallery.id] send in + await send(.deleteDownloadDone(await downloadClient.delete(galleryID))) + } + + case .deleteDownloadDone(let result): + if case .success = result { + state.galleryVersionMetadata = nil + state.didRequestVersionMetadata = false + state.isDownloadContext = false + state.shouldCheckForRemoteUpdates = false + return .merge( + .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + case .teardown: return .merge(CancelID.allCases.map(Effect.cancel(id:))) case .fetchDatabaseInfos(let gid): - guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery + if let gallery = databaseClient.fetchGallery(gid: gid) { + state.gallery = gallery + } else if state.gallery.id != gid { + return .none + } if let detail = databaseClient.fetchGalleryDetail(gid: gid) { state.galleryDetail = detail } return .merge( + .send(.fetchDownloadBadge), .send(.saveGalleryHistory), .run { [galleryID = state.gallery.id] send in guard let dbState = await databaseClient.fetchGalleryState(gid: galleryID) else { return } @@ -256,6 +537,9 @@ struct DetailReducer { state.galleryTags = galleryState.tags state.galleryPreviewURLs = galleryState.previewURLs state.galleryComments = galleryState.comments + if let previewConfig = galleryState.previewConfig { + state.previewConfig = previewConfig + } return .send(.fetchGalleryDetail) case .fetchGalleryDetail: @@ -263,6 +547,8 @@ struct DetailReducer { let galleryURL = state.gallery.galleryURL else { return .none } state.loadingState = .loading + state.didRequestVersionMetadata = false + state.galleryVersionMetadata = nil return .run { [galleryID = state.gallery.id] send in let response = await GalleryDetailRequest(gid: galleryID, galleryURL: galleryURL).response() await send(.fetchGalleryDetailDone(response)) @@ -277,14 +563,21 @@ struct DetailReducer { .send(.syncGalleryTags), .send(.syncGalleryDetail), .send(.syncGalleryPreviewURLs), - .send(.syncGalleryComments) + .send(.syncGalleryComments), + .send(.fetchDownloadBadge) ] state.apiKey = apiKey state.galleryDetail = galleryDetail state.galleryTags = galleryState.tags state.galleryPreviewURLs = galleryState.previewURLs state.galleryComments = galleryState.comments + if let config = galleryState.previewConfig { + state.previewConfig = config + } state.userRating = Int(galleryDetail.userRating) * 2 + if shouldRequestVersionMetadata(state: state) { + effects.append(.send(.fetchVersionMetadataIfNeeded)) + } if let greeting = greeting { effects.append(.send(.syncGreeting(greeting))) if !greeting.gainedNothing && state.showsNewDawnGreeting { @@ -300,6 +593,47 @@ struct DetailReducer { } return .none + case .fetchVersionMetadataIfNeeded: + guard state.shouldCheckForRemoteUpdates, + !state.didRequestVersionMetadata, + let detail = state.galleryDetail + else { + return .none + } + state.didRequestVersionMetadata = true + return .run { [gallery = state.gallery, previewURLs = state.galleryPreviewURLs, detail] send in + let metadata: DownloadVersionMetadata? + switch await GalleryVersionMetadataRequest(gid: gallery.gid, token: gallery.token).response() { + case .success(let fetchedMetadata): + metadata = fetchedMetadata + case .failure: + metadata = nil + } + + await send(.fetchVersionMetadataDone(.success(metadata))) + + guard let metadata else { return } + let latestSignature = DownloadSignatureBuilder.make( + gallery: gallery, + detail: detail, + host: AppUtil.galleryHost, + previewURLs: previewURLs, + versionMetadata: metadata + ) + let badge = await downloadClient.updateRemoteSignature( + gallery.gid, + latestSignature + ) + await send(.fetchDownloadBadgeDone(badge)) + } + .cancellable(id: CancelID.fetchVersionMetadata, cancelInFlight: true) + + case .fetchVersionMetadataDone(let result): + if case .success(let metadata) = result { + state.galleryVersionMetadata = metadata + } + return .none + case .rateGallery: guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) else { return .none } @@ -483,4 +817,31 @@ struct DetailReducer { Scope(state: \.galleryInfosState, action: \.galleryInfos, child: GalleryInfosReducer.init) } } + + private func applyDownloadBadge( + _ badge: DownloadBadge, + state: inout State + ) -> Bool { + let didChangeBadge = badge != state.downloadBadge || !state.hasLoadedDownloadBadge + + state.downloadBadge = badge + if badge != .none { + state.isPreparingDownload = false + } + state.hasLoadedDownloadBadge = true + state.shouldCheckForRemoteUpdates = state.isDownloadContext || badge != .none + + if badge == .none && !state.isDownloadContext { + state.galleryVersionMetadata = nil + state.didRequestVersionMetadata = false + } + + return didChangeBadge + } + + private func shouldRequestVersionMetadata(state: State) -> Bool { + state.galleryDetail != nil + && state.shouldCheckForRemoteUpdates + && !state.didRequestVersionMetadata + } } diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 21d5d72fe..f295f6e9c 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -9,7 +9,72 @@ import ComposableArchitecture import CommonMark struct DetailView: View { + private enum DownloadDialog: Equatable { + case delete(isActiveDownload: Bool) + case retry(DownloadStartMode) + + var title: String { + switch self { + case .delete: + return L10n.Localizable.DetailView.Dialog.Title.deleteDownload + case .retry(let mode): + switch mode { + case .repair: + return L10n.Localizable.DetailView.Dialog.Title.repairDownload + case .update: + return L10n.Localizable.DetailView.Dialog.Title.updateDownload + case .initial, .redownload: + return L10n.Localizable.DetailView.Dialog.Title.redownloadGallery + } + } + } + + var message: String { + switch self { + case .delete(let isActiveDownload): + return isActiveDownload + ? L10n.Localizable.DetailView.Dialog.Message.deleteActiveDownload + : L10n.Localizable.DetailView.Dialog.Message.deleteDownloadedGallery + case .retry(let mode): + switch mode { + case .repair: + return L10n.Localizable.DetailView.Dialog.Message.repairDownload + case .update: + return L10n.Localizable.DetailView.Dialog.Message.updateDownload + case .initial, .redownload: + return L10n.Localizable.DetailView.Dialog.Message.redownloadGallery + } + } + } + + var confirmTitle: String { + switch self { + case .delete: + return L10n.Localizable.ConfirmationDialog.Button.delete + case .retry(let mode): + switch mode { + case .repair: + return L10n.Localizable.DetailView.Dialog.Button.repair + case .update: + return L10n.Localizable.DetailView.Dialog.Button.update + case .initial, .redownload: + return L10n.Localizable.DetailView.Dialog.Button.redownload + } + } + } + + var confirmRole: ButtonRole? { + switch self { + case .delete: + return .destructive + case .retry: + return nil + } + } + } + @Bindable private var store: StoreOf + @State private var downloadDialog: DownloadDialog? private let gid: String private let user: User @Binding private var setting: Setting @@ -33,16 +98,26 @@ struct DetailView: View { ScrollView(showsIndicators: false) { let content = VStack(spacing: 30) { + if let error = store.loadingState.failed, + store.galleryDetail != nil { + offlineFallbackNotice(error: error) + .padding(.horizontal) + } HeaderSection( gallery: store.gallery, galleryDetail: store.galleryDetail ?? .empty, user: user, + downloadBadge: store.downloadBadge, + isPreparingDownload: store.isPreparingDownload, + canDownload: !store.gallery.id.isEmpty + && (AppUtil.galleryHost == .ehentai || CookieUtil.didLogin), displaysJapaneseTitle: setting.displaysJapaneseTitle, showFullTitle: store.showsFullTitle, showFullTitleAction: { store.send(.toggleShowFullTitle) }, + downloadAction: { handleDownloadAction() }, favorAction: { store.send(.favorGallery($0)) }, unfavorAction: { store.send(.unfavorGallery) }, - navigateReadingAction: { store.send(.setNavigation(.reading())) }, + navigateReadingAction: { store.send(.openReading) }, navigateUploaderAction: { if let uploader = store.galleryDetail?.uploader { let keyword = "uploader:" + "\"\(uploader)\"" @@ -83,14 +158,18 @@ struct DetailView: View { ) .padding(.horizontal) } - if !store.galleryPreviewURLs.isEmpty { + let displayPreviewURLs = store.localPreviewURLs.merging( + store.galleryPreviewURLs, + uniquingKeysWith: { local, _ in local } + ) + if !displayPreviewURLs.isEmpty { PreviewsSection( pageCount: store.galleryDetail?.pageCount ?? 0, - previewURLs: store.galleryPreviewURLs, + previewURLs: displayPreviewURLs, navigatePreviewsAction: { store.send(.setNavigation(.previews)) }, navigateReadingAction: { store.send(.updateReadingProgress($0)) - store.send(.setNavigation(.reading())) + store.send(.openReading) } ) } @@ -204,9 +283,83 @@ struct DetailView: View { store.send(.onAppear(gid, setting.showsNewDawnGreeting)) } } + .onChange(of: store.galleryDetail) { _, _ in + runLaunchAutomationIfNeeded() + } + .onChange(of: store.hasLoadedDownloadBadge) { _, _ in + runLaunchAutomationIfNeeded() + } + .alert( + downloadDialog?.title ?? "", + isPresented: Binding( + get: { downloadDialog != nil }, + set: { if !$0 { downloadDialog = nil } } + ), + presenting: downloadDialog + ) { dialog in + Button(dialog.confirmTitle, role: dialog.confirmRole) { + switch dialog { + case .delete: + store.send(.deleteDownload) + case .retry(let mode): + store.send(.retryDownload(mode)) + } + downloadDialog = nil + } + Button(L10n.Localizable.Common.Button.cancel, role: .cancel) { + downloadDialog = nil + } + } message: { dialog in + Text(dialog.message) + } .background(navigationLinks) .toolbar(content: toolbar) } + + private func handleDownloadAction() { + let options = setting.downloadOptionsSnapshot + switch store.downloadBadge { + case .none: + store.send(.startDownload(options)) + case .queued: + break + case .downloading, .paused: + store.send(.toggleDownloadPause) + case .downloaded: + downloadDialog = .delete(isActiveDownload: false) + case .failed, .partial: + downloadDialog = .retry(.redownload) + case .updateAvailable: + downloadDialog = .retry(.update) + case .missingFiles: + downloadDialog = .retry(.repair) + } + } + + private func runLaunchAutomationIfNeeded() { + store.send(.runLaunchAutomationIfNeeded(setting.downloadOptionsSnapshot)) + } + + @ViewBuilder private func offlineFallbackNotice(error: AppError) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label( + L10n.Localizable.DetailView.OfflineNotice.savedDetails, + systemImage: "wifi.exclamationmark" + ) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.orange) + if error.isRetryable != false { + Button(L10n.Localizable.ErrorView.Button.retry) { + store.send(.fetchGalleryDetail) + } + .buttonStyle(.glass) + .buttonBorderShape(.capsule) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } } // MARK: NavigationLinks @@ -285,21 +438,32 @@ private extension DetailView { // MARK: HeaderSection private struct HeaderSection: View { + @ObservedObject private var downloadStore = DownloadBadgeStore.shared + private let gallery: Gallery private let galleryDetail: GalleryDetail private let user: User + private let downloadBadge: DownloadBadge + private let isPreparingDownload: Bool + private let canDownload: Bool private let displaysJapaneseTitle: Bool private let showFullTitle: Bool private let showFullTitleAction: () -> Void + private let downloadAction: () -> Void private let favorAction: (Int) -> Void private let unfavorAction: () -> Void private let navigateReadingAction: () -> Void private let navigateUploaderAction: () -> Void + private let actionIconButtonSize: CGFloat = 32 + private let actionIconFont: Font = .system(size: 16, weight: .semibold) + init( gallery: Gallery, galleryDetail: GalleryDetail, - user: User, displaysJapaneseTitle: Bool, showFullTitle: Bool, + user: User, downloadBadge: DownloadBadge, isPreparingDownload: Bool, canDownload: Bool, + displaysJapaneseTitle: Bool, showFullTitle: Bool, showFullTitleAction: @escaping () -> Void, + downloadAction: @escaping () -> Void, favorAction: @escaping (Int) -> Void, unfavorAction: @escaping () -> Void, navigateReadingAction: @escaping () -> Void, @@ -308,9 +472,13 @@ private struct HeaderSection: View { self.gallery = gallery self.galleryDetail = galleryDetail self.user = user + self.downloadBadge = downloadBadge + self.isPreparingDownload = isPreparingDownload + self.canDownload = canDownload self.displaysJapaneseTitle = displaysJapaneseTitle self.showFullTitle = showFullTitle self.showFullTitleAction = showFullTitleAction + self.downloadAction = downloadAction self.favorAction = favorAction self.unfavorAction = unfavorAction self.navigateReadingAction = navigateReadingAction @@ -321,10 +489,286 @@ private struct HeaderSection: View { let normalTitle = galleryDetail.title return displaysJapaneseTitle ? galleryDetail.jpnTitle ?? normalTitle : normalTitle } + private var downloadButtonTint: Color { + switch downloadBadge { + case .updateAvailable: + return .orange + case .downloaded: + return .red + case .partial: + return .orange + case .failed, .missingFiles: + return .red + default: + return .accentColor + } + } + private var downloadButtonAccessibilityLabel: String { + guard canDownload else { return L10n.Localizable.DetailView.Accessibility.downloadButtonLogin } + guard !showsMetadataPreparation else { + return L10n.Localizable.DetailView.Accessibility.downloadButtonPreparing + } + switch downloadBadge { + case .none: + return L10n.Localizable.DetailView.Accessibility.downloadButtonDownload + case .queued: + return L10n.Localizable.DetailView.Accessibility.downloadButtonQueued + case .downloading(let completed, let total): + let progress = L10n.Localizable.DetailView.Accessibility.downloadButtonDownloading( + completed, + max(total, 1) + ) + return [progress, L10n.Localizable.DetailView.Accessibility.downloadButtonPauseAction] + .joined(separator: ". ") + case .paused(let completed, let total): + return L10n.Localizable.DetailView.Accessibility.downloadButtonPaused( + completed, + max(total, 1) + ) + case .downloaded: + return L10n.Localizable.DetailView.Accessibility.downloadButtonDownloaded + case .updateAvailable: + return L10n.Localizable.DetailView.Accessibility.downloadButtonUpdate + case .partial(let completed, let total): + return L10n.Localizable.DetailView.Accessibility.downloadButtonPartial( + completed, + max(total, 1) + ) + case .failed: + return L10n.Localizable.DetailView.Accessibility.downloadButtonRetry + case .missingFiles: + return L10n.Localizable.DetailView.Accessibility.downloadButtonRepair + } + } + private var showsMetadataPreparation: Bool { + isPreparingDownload && downloadBadge == .none + } + private var queuedDownloadProgress: Double? { + if case .queued = downloadBadge { + return 0 + } + return nil + } + private var activeDownloadProgress: Double? { + if case .downloading(let completed, let total) = downloadBadge { + return Double(completed) / Double(max(total, 1)) + } + if case .paused(let completed, let total) = downloadBadge { + return Double(completed) / Double(max(total, 1)) + } + return nil + } + private var activeDownloadIconSystemName: String { + switch downloadBadge { + case .paused: + return "play.fill" + case .downloading: + return "pause.fill" + default: + return downloadIconSystemName + } + } + private var downloadIconSystemName: String { + switch downloadBadge { + case .downloaded: + return "trash" + case .updateAvailable: + return "arrow.triangle.2.circlepath" + case .partial: + return "exclamationmark.circle" + case .failed: + return "exclamationmark.circle" + case .missingFiles: + return "wrench.and.screwdriver" + case .paused: + return "play.fill" + default: + return "icloud.and.arrow.down" + } + } + private var isDownloadActionDisabled: Bool { + guard canDownload else { return true } + return isPreparingDownload + } + private var categoryLabel: some View { + CategoryLabel( + text: gallery.category.value, + color: gallery.color, + font: .headline, + insets: .init(top: 2, leading: 4, bottom: 2, trailing: 4), + cornerRadius: 3 + ) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + private var downloadButton: some View { + Group { + if let progress = activeDownloadProgress { + Button(action: downloadAction) { + progressIndicator( + progress: progress, + isDeterminate: true, + centerSystemName: activeDownloadIconSystemName + ) + } + .buttonStyle(.plain) + } else if let progress = queuedDownloadProgress { + Button(action: downloadAction) { + progressIndicator( + progress: progress, + isDeterminate: false, + centerSystemName: activeDownloadIconSystemName + ) + } + .buttonStyle(.plain) + } else { + Button(action: downloadAction) { + Image(systemName: downloadIconSystemName) + .font(actionIconFont) + .foregroundStyle(canDownload ? downloadButtonTint : .secondary) + .rotationEffect(.degrees(showsMetadataPreparation ? 360 : 0)) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + .contentShape(Circle()) + } + .buttonStyle(.glass(.regular.interactive())) + .buttonBorderShape(.circle) + .animation( + showsMetadataPreparation + ? .linear(duration: 0.9).repeatForever(autoreverses: false) + : .default, + value: showsMetadataPreparation + ) + } + } + .disabled(isDownloadActionDisabled) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + .accessibilityLabel(downloadButtonAccessibilityLabel) + } + private var favoriteButton: some View { + ZStack { + Button(action: unfavorAction) { + Image(systemSymbol: .heartFill) + .font(actionIconFont) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + .opacity(galleryDetail.isFavorited ? 1 : 0) + + Menu { + ForEach(0..<10) { index in + Button(user.getFavoriteCategory(index: index)) { + favorAction(index) + } + } + } label: { + Image(systemSymbol: .heart) + .font(actionIconFont) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + .opacity(galleryDetail.isFavorited ? 0 : 1) + } + .foregroundStyle(.tint) + .buttonStyle(.glass(.regular.interactive())) + .buttonBorderShape(.circle) + .disabled(!CookieUtil.didLogin) + } + private var readButton: some View { + Button(action: navigateReadingAction) { + Image(systemSymbol: .bookFill) + .font(actionIconFont) + .foregroundStyle(.white) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + .buttonStyle(.glassProminent) + .buttonBorderShape(.circle) + .accessibilityLabel(L10n.Localizable.DetailView.Button.read) + } + private func progressIndicator( + progress: Double, + isDeterminate: Bool, + centerSystemName: String + ) -> some View { + ZStack { + Circle() + .fill(.ultraThinMaterial) + .overlay( + Circle() + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.75) + ) + + if isDeterminate { + Circle() + .stroke(downloadButtonTint.opacity(0.18), lineWidth: 2.5) + .padding(3) + Circle() + .trim(from: 0, to: progress) + .stroke( + downloadButtonTint, + style: .init(lineWidth: 2.5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .padding(3) + } else { + ProgressView() + .progressViewStyle(.circular) + .tint(downloadButtonTint) + .controlSize(.small) + } + + Image(systemName: centerSystemName) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(downloadButtonTint) + } + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + private var actionButtons: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 6) { + downloadButton + favoriteButton + readButton + } + .fixedSize(horizontal: true, vertical: false) + + VStack(alignment: .trailing, spacing: 6) { + HStack(spacing: 6) { + downloadButton + favoriteButton + } + readButton + } + .fixedSize(horizontal: true, vertical: false) + + VStack(alignment: .trailing, spacing: 6) { + downloadButton + favoriteButton + readButton + } + .fixedSize(horizontal: true, vertical: false) + } + .layoutPriority(1) + } + private var bottomActionRow: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + categoryLabel + Spacer(minLength: 8) + actionButtons + } + + VStack(alignment: .leading, spacing: 8) { + categoryLabel + actionButtons + } + } + } + + private var resolvedCoverURL: URL? { + downloadStore.resolvedCoverURL(for: gallery) + } var body: some View { HStack { - KFImage(gallery.coverURL) + KFImage(resolvedCoverURL) .placeholder({ Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }) .defaultModifier() .scaledToFit() @@ -350,49 +794,7 @@ private struct HeaderSection: View { Spacer() - HStack { - CategoryLabel( - text: gallery.category.value, - color: gallery.color, - font: .headline, - insets: .init(top: 2, leading: 4, bottom: 2, trailing: 4), - cornerRadius: 3 - ) - - Spacer() - - ZStack { - Button(action: unfavorAction) { - Image(systemSymbol: .heartFill) - } - .opacity(galleryDetail.isFavorited ? 1 : 0) - - Menu { - ForEach(0..<10) { index in - Button(user.getFavoriteCategory(index: index)) { - favorAction(index) - } - } - } label: { - Image(systemSymbol: .heart) - } - .opacity(galleryDetail.isFavorited ? 0 : 1) - } - .imageScale(.large) - .foregroundStyle(.tint) - .buttonStyle(.glass(.regular.interactive())) - .disabled(!CookieUtil.didLogin) - - Button(action: navigateReadingAction) { - Text(L10n.Localizable.DetailView.Button.read) - .bold().textCase(.uppercase).font(.headline) - .foregroundColor(.white).padding(.vertical, -2) - .padding(.horizontal, 2).lineLimit(1) - } - .buttonStyle(.glassProminent) - .buttonBorderShape(.capsule) - } - .minimumScaleFactor(0.5) + bottomActionRow } .padding(.horizontal, 10) .frame(minHeight: Defaults.ImageSize.headerH) @@ -760,13 +1162,10 @@ private struct PreviewsSection: View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack { ForEach(previewURLs.tuples.sorted(by: { $0.0 < $1.0 }), id: \.0) { index, previewURL in - let (url, modifier) = PreviewResolver.getPreviewConfigs(originalURL: previewURL) Button { navigateReadingAction(index) } label: { - KFImage.url(url, cacheKey: previewURL.absoluteString) - .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) } - .imageModifier(modifier).fade(duration: 0.25).resizable().scaledToFit() + PreviewImageView(originalURL: previewURL) .frame(width: width, height: height) } } diff --git a/EhPanda/View/Detail/Previews/PreviewsReducer.swift b/EhPanda/View/Detail/Previews/PreviewsReducer.swift index fa41832d0..e0ebd788e 100644 --- a/EhPanda/View/Detail/Previews/PreviewsReducer.swift +++ b/EhPanda/View/Detail/Previews/PreviewsReducer.swift @@ -14,7 +14,10 @@ struct PreviewsReducer { } private enum CancelID: CaseIterable { - case fetchDatabaseInfos, fetchPreviewURLs + case fetchDatabaseInfos + case observeDownloads + case loadLocalPreviewURLs + case fetchPreviewURLs } @ObservableState @@ -26,7 +29,9 @@ struct PreviewsReducer { var databaseLoadingState: LoadingState = .loading var previewURLs = [Int: URL]() + var localPreviewURLs = [Int: URL]() var previewConfig: PreviewConfig = .normal(rows: 4) + var localPreviewRequestID = UUID() var readingState = ReadingReducer.State() @@ -48,6 +53,12 @@ struct PreviewsReducer { case teardown case fetchDatabaseInfos(String) case fetchDatabaseInfosDone(GalleryState) + case observeDownloads(String) + case observeDownloadsDone([DownloadedGallery]) + case loadLocalPreviewURLs(String) + case loadLocalPreviewURLsDone(UUID, [Int: URL]) + case openReading(Int) + case openReadingDone(Result<(DownloadedGallery, DownloadManifest), AppError>) case fetchPreviewURLs(Int) case fetchPreviewURLsDone(Result<[Int: URL], AppError>) @@ -55,6 +66,7 @@ struct PreviewsReducer { } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient var body: some Reducer { @@ -92,11 +104,17 @@ struct PreviewsReducer { case .fetchDatabaseInfos(let gid): guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } state.gallery = gallery - return .run { [state] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: state.gallery.id) else { return } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: CancelID.fetchDatabaseInfos) + return .merge( + .run { [state] send in + guard let dbState = await databaseClient.fetchGalleryState( + gid: state.gallery.id + ) else { return } + await send(.fetchDatabaseInfosDone(dbState)) + } + .cancellable(id: CancelID.fetchDatabaseInfos), + .send(.observeDownloads(gid)), + .send(.loadLocalPreviewURLs(gid)) + ) case .fetchDatabaseInfosDone(let galleryState): if let previewConfig = galleryState.previewConfig { @@ -106,6 +124,75 @@ struct PreviewsReducer { state.databaseLoadingState = .idle return .none + case .observeDownloads(let gid): + guard gid.isValidGID else { return .none } + return .run { send in + var previousRelevantDownloads = [DownloadedGallery]() + var hadRelevantDownloads = false + for await downloads in downloadClient.observeDownloads() { + let relevantDownloads = downloads.filter { $0.gid == gid } + let hasRelevantDownloads = !relevantDownloads.isEmpty + guard hasRelevantDownloads || hadRelevantDownloads else { continue } + if relevantDownloads == previousRelevantDownloads { + hadRelevantDownloads = hasRelevantDownloads + continue + } + previousRelevantDownloads = relevantDownloads + hadRelevantDownloads = hasRelevantDownloads + await send(.observeDownloadsDone(relevantDownloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone: + return .send(.loadLocalPreviewURLs(state.gallery.id)) + + case .loadLocalPreviewURLs(let gid): + guard gid.isValidGID else { + state.localPreviewRequestID = UUID() + state.localPreviewURLs = .init() + return .none + } + let requestID = UUID() + state.localPreviewRequestID = requestID + return .run { send in + let localPreviewURLs: [Int: URL] + switch await downloadClient.loadLocalPageURLs(gid) { + case .success(let pageURLs): + localPreviewURLs = pageURLs + case .failure: + localPreviewURLs = [:] + } + await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) + } + .cancellable(id: CancelID.loadLocalPreviewURLs, cancelInFlight: true) + + case .loadLocalPreviewURLsDone(let requestID, let localPreviewURLs): + guard state.localPreviewRequestID == requestID else { return .none } + guard state.localPreviewURLs != localPreviewURLs else { return .none } + state.localPreviewURLs = localPreviewURLs + return .none + + case .openReading: + state.readingState = .init(contentSource: .remote) + return .run { [galleryID = state.gallery.id] send in + guard galleryID.isValidGID else { + await send(.openReadingDone(.failure(.notFound))) + return + } + await send(.openReadingDone(await downloadClient.loadManifest(galleryID))) + } + + case .openReadingDone(let result): + if case .success(let (download, manifest)) = result { + state.readingState = .init(contentSource: .local(download, manifest)) + } else { + state.readingState.contentSource = .remote + state.readingState.localPageURLs = state.localPreviewURLs + } + state.route = .reading() + return .none + case .fetchPreviewURLs(let index): guard state.loadingState != .loading, let galleryURL = state.gallery.galleryURL diff --git a/EhPanda/View/Detail/Previews/PreviewsView.swift b/EhPanda/View/Detail/Previews/PreviewsView.swift index 8df2e8a96..16fc45177 100644 --- a/EhPanda/View/Detail/Previews/PreviewsView.swift +++ b/EhPanda/View/Detail/Previews/PreviewsView.swift @@ -4,7 +4,6 @@ // import SwiftUI -import Kingfisher import ComposableArchitecture struct PreviewsView: View { @@ -34,23 +33,19 @@ struct PreviewsView: View { } var body: some View { + let displayPreviewURLs = store.localPreviewURLs.merging( + store.previewURLs, + uniquingKeysWith: { local, _ in local } + ) ScrollView { LazyVGrid(columns: gridItems) { ForEach(1.. Void + + init(filter: Binding, resetAction: @escaping () -> Void) { + _filter = filter + self.resetAction = resetAction + } + + private var categoryBindings: [Binding] { + Category.allFiltersCases.map(categoryBinding) + } + + private func categoryBinding(_ category: Category) -> Binding { + .init( + get: { + filter.excludedCategories.contains(category) + }, + set: { isExcluded in + if isExcluded { + filter.excludedCategories.insert(category) + } else { + filter.excludedCategories.remove(category) + } + } + ) + } + + var body: some View { + NavigationView { + Form { + Section { + CategoryView(bindings: categoryBindings) + } + + Section(L10n.Localizable.FiltersView.Section.Title.advanced) { + Toggle( + L10n.Localizable.FiltersView.Title.setMinimumRating, + isOn: $filter.minimumRatingActivated + ) + DownloadMinimumRatingSetter(minimum: $filter.minimumRating) + .disabled(!filter.minimumRatingActivated) + Toggle( + L10n.Localizable.FiltersView.Title.setPagesRange, + isOn: $filter.pageRangeActivated + ) + .disabled(focusedBound != nil) + DownloadPagesRangeSetter( + lowerBound: $filter.pageLowerBound, + upperBound: $filter.pageUpperBound, + focusedBound: $focusedBound + ) + .disabled(!filter.pageRangeActivated) + } + + Section { + Button(role: .destructive, action: resetAction) { + Text(L10n.Localizable.FiltersView.Button.resetFilters) + } + } + } + .navigationTitle(L10n.Localizable.FiltersView.Title.filters) + } + } +} + +private struct DownloadMinimumRatingSetter: View { + @Binding private var minimum: Int + + init(minimum: Binding) { + _minimum = minimum + } + + var body: some View { + Picker(L10n.Localizable.FiltersView.Title.minimumRating, selection: $minimum) { + ForEach(Array(2...5), id: \.self) { number in + Text(L10n.Localizable.Common.Value.stars("\(number)")).tag(number) + } + } + .pickerStyle(.menu) + } +} + +private struct DownloadPagesRangeSetter: View { + @Binding private var lowerBound: String + @Binding private var upperBound: String + private let focusedBound: FocusState.Binding + + init( + lowerBound: Binding, + upperBound: Binding, + focusedBound: FocusState.Binding + ) { + _lowerBound = lowerBound + _upperBound = upperBound + self.focusedBound = focusedBound + } + + var body: some View { + HStack { + Text(L10n.Localizable.FiltersView.Title.pagesRange) + Spacer() + SettingTextField(text: $lowerBound) + .focused(focusedBound, equals: .lower) + .submitLabel(.next) + Text("-") + SettingTextField(text: $upperBound) + .focused(focusedBound, equals: .upper) + .submitLabel(.done) + } + .onSubmit { + switch focusedBound.wrappedValue { + case .lower: + focusedBound.wrappedValue = .upper + default: + focusedBound.wrappedValue = nil + } + } + } +} diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift new file mode 100644 index 000000000..5173b8b38 --- /dev/null +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -0,0 +1,475 @@ +// +// DownloadsReducer.swift +// EhPanda +// + +import Foundation +import ComposableArchitecture + +@Reducer +struct DownloadsReducer { + @CasePathable + enum Route: Equatable { + case quickSearch(EquatableVoid = .init()) + case filters(EquatableVoid = .init()) + case inspector(String) + case detail(String) + } + + private enum CancelID { + case observeDownloads + } + + @ObservableState + struct State: Equatable { + var route: Route? + var keyword = "" + var filter: DownloadListFilter = .all + var galleryFilter = DownloadGalleryFilter() + var downloads = [DownloadedGallery]() + var loadingState: LoadingState = .loading + var hasLoadedInitialDownloads = false + + var detailState: Heap + var inspectorState = DownloadInspectorReducer.State() + var quickSearchState = QuickSearchReducer.State() + + init() { + detailState = .init(.init()) + } + + var filteredDownloads: [DownloadedGallery] { + downloads.filter { + $0.matches(filter: filter) + && $0.matches(queryFilter: galleryFilter) + && ( + keyword.isEmpty + || $0.searchableText.caseInsensitiveContains(keyword) + ) + } + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + + case onAppear + case teardown + case bootstrapDownloads + case fetchDownloads + case fetchDownloadsDone([DownloadedGallery]) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) + case refreshDownloads + case refreshDownloadsDone + case toggleDownloadPause(String) + case toggleDownloadPauseDone(Result) + case updateDownload(String) + case updateDownloadDone(Result) + case deleteDownload(String) + case deleteDownloadDone(Result) + + case detail(DetailReducer.Action) + case inspector(DownloadInspectorReducer.Action) + case quickSearch(QuickSearchReducer.Action) + } + + @Dependency(\.downloadClient) private var downloadClient + + var body: some Reducer { + BindingReducer() + .onChange(of: \.route) { _, newValue in + Reduce { _, _ in + newValue == nil ? .send(.clearSubStates) : .none + } + } + .onChange(of: \.galleryFilter) { _, _ in + Reduce { state, _ in + state.galleryFilter.fixInvalidData() + return .none + } + } + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + if case .detail(let gid) = route, + let download = state.downloads.first(where: { $0.gid == gid }) + { + state.detailState.wrappedValue = .init(download: download) + } else if case .inspector(let gid) = route { + state.inspectorState = .init(gid: gid) + } + return route == nil ? .send(.clearSubStates) : .none + + case .clearSubStates: + state.detailState.wrappedValue = .init() + state.inspectorState = .init() + state.quickSearchState = .init() + return .merge( + .send(.detail(.teardown)), + .send(.inspector(.teardown)), + .send(.quickSearch(.teardown)) + ) + + case .onAppear: + guard !state.hasLoadedInitialDownloads else { return .none } + state.hasLoadedInitialDownloads = true + return .merge( + .send(.fetchDownloads), + .send(.observeDownloads), + .send(.bootstrapDownloads) + ) + + case .teardown: + return .cancel(id: CancelID.observeDownloads) + + case .bootstrapDownloads: + return .run { send in + await downloadClient.refreshDownloads() + await send(.refreshDownloadsDone) + } + + case .fetchDownloads: + state.loadingState = .loading + return .run { send in + await send(.fetchDownloadsDone(await downloadClient.fetchDownloads())) + } + + case .fetchDownloadsDone(let downloads), .observeDownloadsDone(let downloads): + guard state.downloads != downloads || state.loadingState != .idle else { + return .none + } + state.downloads = downloads + state.loadingState = .idle + return .none + + case .observeDownloads: + return .run { send in + for await downloads in downloadClient.observeDownloads() { + await send(.observeDownloadsDone(downloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .refreshDownloads: + return .run { send in + await downloadClient.refreshDownloads() + await send(.refreshDownloadsDone) + } + + case .refreshDownloadsDone: + return .none + + case .toggleDownloadPause(let gid): + return .run { send in + await send(.toggleDownloadPauseDone(await downloadClient.togglePause(gid))) + } + + case .toggleDownloadPauseDone(let result): + if case .failure = result { + return .run { _ in + await downloadClient.reconcileDownloads() + } + } + return .none + + case .updateDownload(let gid): + return .run { send in + await send(.updateDownloadDone(await downloadClient.retry(gid, .update))) + } + + case .updateDownloadDone: + return .none + + case .deleteDownload(let gid): + return .run { send in + await send(.deleteDownloadDone(await downloadClient.delete(gid))) + } + + case .deleteDownloadDone: + return .none + + case .detail: + return .none + + case .inspector: + return .none + + case .quickSearch: + return .none + } + } + + Scope(state: \.detailState.wrappedValue!, action: \.detail) { + DetailReducer() + } + Scope(state: \.inspectorState, action: \.inspector) { + DownloadInspectorReducer() + } + Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) + } +} + +@Reducer +struct DownloadInspectorReducer { + private enum CancelID { + case observeDownloads + case loadInspection + } + + @ObservableState + struct State: Equatable { + var gid = "" + var inspection: DownloadInspection? + var stableInspection: DownloadInspection? + var loadingState: LoadingState = .loading + var inspectionRequestID = UUID() + var retryingPageIndices = Set() + + init(gid: String = "") { + self.gid = gid + loadingState = gid.isEmpty ? .idle : .loading + } + } + + enum Action { + case onAppear + case teardown + case loadInspection + case loadInspectionDone(UUID, Result) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) + case retryPage(Int) + case retryPageDone(Result) + case retryFailedPages + case retryFailedPagesDone(Result) + case updateDownload + case updateDownloadDone(Result) + } + + @Dependency(\.downloadClient) private var downloadClient + + var body: some Reducer { + Reduce { state, action in + switch action { + case .onAppear: + guard state.gid.notEmpty else { return .none } + return .merge( + .send(.loadInspection), + .send(.observeDownloads) + ) + + case .teardown: + return .merge( + .cancel(id: CancelID.observeDownloads), + .cancel(id: CancelID.loadInspection) + ) + + case .loadInspection: + guard state.gid.notEmpty else { return .none } + if state.inspection == nil { + state.loadingState = .loading + } + let requestID = UUID() + state.inspectionRequestID = requestID + return .run { [gid = state.gid] send in + await send(.loadInspectionDone(requestID, await downloadClient.loadInspection(gid))) + } + .cancellable(id: CancelID.loadInspection, cancelInFlight: true) + + case .loadInspectionDone(let requestID, let result): + guard state.inspectionRequestID == requestID else { return .none } + switch result { + case .success(let inspection): + state.stableInspection = inspection + let inspection = state.overlayRetryingPages(in: inspection) + state.inspection = inspection + state.loadingState = .idle + state.retryingPageIndices = state.reconciledRetryingPageIndices( + for: inspection + ) + case .failure(let error): + state.retryingPageIndices = .init() + if let stableInspection = state.stableInspection { + state.inspection = stableInspection + } + state.loadingState = .failed(error) + } + return .none + + case .observeDownloads: + guard state.gid.notEmpty else { return .none } + return .run { [gid = state.gid] send in + var hadRelevantDownloads = false + for await downloads in downloadClient.observeDownloads() { + let relevantDownloads = downloads.filter { $0.gid == gid } + let hasRelevantDownloads = !relevantDownloads.isEmpty + guard hasRelevantDownloads || hadRelevantDownloads else { continue } + hadRelevantDownloads = hasRelevantDownloads + await send(.observeDownloadsDone(relevantDownloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone(let downloads): + guard !downloads.isEmpty else { + state.inspection = nil + state.stableInspection = nil + state.retryingPageIndices = .init() + state.loadingState = .idle + return .none + } + guard let latestDownload = downloads.first else { return .none } + let previousDownload = state.inspection?.download + if let inspection = state.inspection, + state.retryingPageIndices.isEmpty || state.shouldKeepRetryPending(for: latestDownload) + { + state.inspection = state.overlayRetryingPages(in: .init( + download: latestDownload, + coverURL: inspection.coverURL, + pages: inspection.pages + )) + } + guard previousDownload != latestDownload else { return .none } + return .send(.loadInspection) + + case .retryPage(let index): + guard state.gid.notEmpty else { return .none } + state.inspectionRequestID = UUID() + state.retryingPageIndices.insert(index) + state.stableInspection = state.inspection ?? state.stableInspection + if let inspection = state.inspection { + state.inspection = .init( + download: inspection.download, + coverURL: inspection.coverURL, + pages: inspection.pages.map { page in + guard page.index == index else { return page } + return .init( + index: index, + status: .pending, + relativePath: page.relativePath, + fileURL: nil, + failure: nil + ) + } + ) + } + return .merge( + .cancel(id: CancelID.loadInspection), + .run { [gid = state.gid] send in + await send(.retryPageDone(await downloadClient.retryPages(gid, [index]))) + } + ) + + case .retryPageDone(let result): + if case .failure = result { + state.retryingPageIndices = .init() + return .send(.loadInspection) + } + return .none + + case .retryFailedPages: + guard let failedPageIndices = state.inspection?.failedPageIndices, + let gid = state.inspection?.download.gid, + !failedPageIndices.isEmpty + else { + return .none + } + state.inspectionRequestID = UUID() + state.retryingPageIndices.formUnion(failedPageIndices) + state.stableInspection = state.inspection ?? state.stableInspection + if let inspection = state.inspection { + state.inspection = .init( + download: inspection.download, + coverURL: inspection.coverURL, + pages: inspection.pages.map { page in + guard failedPageIndices.contains(page.index) else { return page } + return .init( + index: page.index, + status: .pending, + relativePath: page.relativePath, + fileURL: nil, + failure: nil + ) + } + ) + } + return .merge( + .cancel(id: CancelID.loadInspection), + .run { send in + await send(.retryFailedPagesDone(await downloadClient.retryPages(gid, failedPageIndices))) + } + ) + + case .retryFailedPagesDone(let result): + if case .failure = result { + state.retryingPageIndices = .init() + return .send(.loadInspection) + } + return .none + + case .updateDownload: + guard let gid = state.inspection?.download.gid else { return .none } + return .run { send in + await send(.updateDownloadDone(await downloadClient.retry(gid, .update))) + } + + case .updateDownloadDone(let result): + if case .failure = result { + return .send(.loadInspection) + } + return .none + } + } + } +} + +private extension DownloadInspectorReducer.State { + func shouldKeepRetryPending(for download: DownloadedGallery) -> Bool { + download.canPauseOrResume + || download.isPendingQueue + || (download.status == .partial && download.lastError == nil) + } + + func overlayRetryingPages(in inspection: DownloadInspection) -> DownloadInspection { + guard !retryingPageIndices.isEmpty else { return inspection } + + guard shouldKeepRetryPending(for: inspection.download) else { return inspection } + + return .init( + download: inspection.download, + coverURL: inspection.coverURL, + pages: inspection.pages.map { page in + guard retryingPageIndices.contains(page.index), + page.status != .downloaded + else { + return page + } + return .init( + index: page.index, + status: .pending, + relativePath: page.relativePath, + fileURL: page.fileURL, + failure: nil + ) + } + ) + } + + func reconciledRetryingPageIndices(for inspection: DownloadInspection) -> Set { + guard !retryingPageIndices.isEmpty else { return .init() } + + guard shouldKeepRetryPending(for: inspection.download) else { return .init() } + + return retryingPageIndices.filter { index in + inspection.pages.first(where: { $0.index == index })?.status != .downloaded + } + } +} diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift new file mode 100644 index 000000000..01c3f1587 --- /dev/null +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -0,0 +1,524 @@ +// +// DownloadsView.swift +// EhPanda +// + +import SwiftUI +import SFSafeSymbols +import ComposableArchitecture + +struct DownloadsView: View { + private enum RowDialog: Identifiable { + case delete(DownloadedGallery) + + var id: String { + switch self { + case .delete(let download): + return "delete-\(download.gid)" + } + } + } + + @Bindable private var store: StoreOf + @State private var rowDialog: RowDialog? + @Binding private var setting: Setting + private let user: User + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: StoreOf, + user: User, + setting: Binding, + blurRadius: Double, + tagTranslator: TagTranslator + ) { + self.store = store + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + var body: some View { + NavigationView { + if DeviceUtil.isPad { + contentView + .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in + NavigationView { + DetailView( + store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), + gid: route.wrappedValue, + user: user, + setting: $setting, + blurRadius: blurRadius, + tagTranslator: tagTranslator + ) + } + .autoBlur(radius: blurRadius) + .environment(\.inSheet, true) + .navigationViewStyle(.stack) + } + } else { + contentView + } + } + } + + private var contentView: some View { + let showsEmptyState = store.loadingState == .idle && store.filteredDownloads.isEmpty + return ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + downloadsList + .allowsHitTesting(!showsEmptyState) + + if showsEmptyState { + VStack { + Spacer() + emptyStateView + Spacer() + } + .padding(.horizontal, 24) + } + } + .searchable( + text: $store.keyword, + placement: .navigationBarDrawer(displayMode: .automatic), + prompt: L10n.Localizable.DownloadsView.Search.Prompt.downloads + ) + .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in + QuickSearchView( + store: store.scope(state: \.quickSearchState, action: \.quickSearch) + ) { keyword in + store.keyword = keyword + store.send(.setNavigation(nil)) + } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(item: $store.route.sending(\.setNavigation).inspector, id: \.self) { _ in + NavigationView { + DownloadInspectorView( + store: store.scope(state: \.inspectorState, action: \.inspector), + setting: setting, + blurRadius: blurRadius, + tagTranslator: tagTranslator + ) + } + .autoBlur(radius: blurRadius) + .navigationViewStyle(.stack) + } + .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in + DownloadFiltersView( + filter: $store.galleryFilter, + resetAction: { + store.galleryFilter.reset() + } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .onAppear { + store.send(.onAppear) + } + .alert( + L10n.Localizable.DownloadsView.Dialog.Title.deleteDownload, + isPresented: Binding( + get: { rowDialog != nil }, + set: { if !$0 { rowDialog = nil } } + ), + presenting: rowDialog + ) { dialog in + switch dialog { + case .delete(let download): + Button(L10n.Localizable.ConfirmationDialog.Button.delete, role: .destructive) { + store.send(.deleteDownload(download.gid)) + rowDialog = nil + } + Button(L10n.Localizable.Common.Button.cancel, role: .cancel) { + rowDialog = nil + } + } + } message: { dialog in + switch dialog { + case .delete(let download): + Text( + download.canPauseOrResume || download.isPendingQueue + ? L10n.Localizable.DownloadsView.Dialog.Message.deleteActiveDownload + : L10n.Localizable.DownloadsView.Dialog.Message.deleteDownloadedGallery + ) + } + } + .background(navigationLink) + .navigationTitle(L10n.Localizable.DownloadsView.Title.downloads) + .navigationBarTitleDisplayMode(.large) + .toolbar(content: toolbar) + } + + @ViewBuilder private var downloadsList: some View { + switch store.loadingState { + case .loading where store.downloads.isEmpty: + LoadingView() + + case .failed(let error) where store.downloads.isEmpty: + ErrorView(error: error, action: { store.send(.refreshDownloads) }) + + default: + List { + ForEach(store.filteredDownloads) { download in + DownloadListRow( + download: download, + setting: setting, + tagTranslator: tagTranslator + ) { + store.send(.setNavigation(.detail(download.gid))) + } + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button { + store.send(.setNavigation(.inspector(download.gid))) + } label: { + Label( + L10n.Localizable.DownloadsView.Swipe.Button.pages, + systemImage: "list.bullet.rectangle.portrait" + ) + } + .tint(setting.accentColor) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if download.canTriggerUpdate { + Button { + store.send(.updateDownload(download.gid)) + } label: { + Label( + L10n.Localizable.DownloadsView.Swipe.Button.update, + systemImage: "arrow.triangle.2.circlepath" + ) + } + .tint(.orange) + } + + if download.canPauseOrResume || download.isPendingQueue { + Button { + store.send(.toggleDownloadPause(download.gid)) + } label: { + Label( + download.status == .paused + ? L10n.Localizable.DownloadsView.Swipe.Button.resume + : L10n.Localizable.DownloadsView.Swipe.Button.pause, + systemImage: download.status == .paused + ? "play.fill" + : "pause.fill" + ) + } + .tint(download.status == .paused ? .green : .indigo) + } + + Button(role: .destructive) { + rowDialog = .delete(download) + } label: { + Label(L10n.Localizable.ConfirmationDialog.Button.delete, systemSymbol: .trash) + } + } + } + } + .listStyle(.plain) + .refreshable { store.send(.refreshDownloads) } + } + } + + @ViewBuilder private var navigationLink: some View { + if DeviceUtil.isPhone { + NavigationLink(unwrapping: $store.route, case: \.detail) { route in + DetailView( + store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), + gid: route.wrappedValue, + user: user, + setting: $setting, + blurRadius: blurRadius, + tagTranslator: tagTranslator + ) + } + } + } + + @ViewBuilder private var emptyStateView: some View { + if store.downloads.isEmpty { + AlertView( + symbol: .squareAndArrowDown, + message: L10n.Localizable.DownloadsView.EmptyState.downloads + ) { + EmptyView() + } + } else { + AlertView( + symbol: .line3HorizontalDecreaseCircle, + message: L10n.Localizable.DownloadsView.EmptyState.noMatchingFilters + ) { + AlertViewButton(title: L10n.Localizable.DownloadsView.Button.clearFilters) { + store.keyword = "" + store.filter = .all + store.galleryFilter.reset() + } + } + } + } + + @ToolbarContentBuilder private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + Menu { + ForEach(DownloadListFilter.allCases) { filter in + Button { + store.filter = filter + } label: { + Text(filter.title) + if store.filter == filter { + Image(systemSymbol: .checkmark) + } + } + } + } label: { + Image(systemSymbol: .line3HorizontalDecreaseCircle) + .symbolRenderingMode(.hierarchical) + } + + ToolbarFeaturesMenu { + FiltersButton { + store.send(.setNavigation(.filters())) + } + QuickSearchButton { + store.send(.setNavigation(.quickSearch())) + } + if store.filter != .all || store.keyword.notEmpty || store.galleryFilter.hasActiveValues { + Button { + store.filter = .all + store.keyword = "" + store.galleryFilter.reset() + } label: { + Label( + L10n.Localizable.DownloadsView.Button.clearFilters, + systemSymbol: .arrowCounterclockwise + ) + } + } + } + } + } +} + +private struct DownloadInspectorView: View { + @Environment(\.dismiss) private var dismiss + + @Bindable private var store: StoreOf + private let setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: StoreOf, + setting: Setting, + blurRadius: Double, + tagTranslator: TagTranslator + ) { + self.store = store + self.setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + var body: some View { + Group { + switch store.loadingState { + case .loading where store.inspection == nil: + LoadingView() + + case .failed(let error) where store.inspection == nil: + ErrorView(error: error, action: { store.send(.loadInspection) }) + + default: + List { + if let inspection = store.inspection { + Section { + StaticGalleryDetailCell( + gallery: inspection.download.gallery, + resolvedCoverURL: inspection.coverURL, + setting: setting, + translateAction: { + tagTranslator.lookup( + word: $0, + returnOriginal: !setting.translatesTags + ) + }, + downloadBadge: inspection.download.badge + ) + .listRowInsets(.init(top: 10, leading: 10, bottom: 10, trailing: 10)) + .listRowBackground(Color.clear) + } + + if !inspection.failedPageIndices.isEmpty || inspection.download.canTriggerUpdate { + Section(L10n.Localizable.DownloadsView.Inspector.Section.actions) { + if !inspection.failedPageIndices.isEmpty { + Button { + store.send(.retryFailedPages) + } label: { + Label( + L10n.Localizable.DownloadsView.Inspector.Button.retryFailedPages( + inspection.failedPageIndices.count + ), + systemImage: "arrow.clockwise.circle" + ) + } + } + + if inspection.download.canTriggerUpdate { + Button { + store.send(.updateDownload) + } label: { + Label( + L10n.Localizable.DownloadsView.Inspector.Button.updateDownload, + systemImage: "arrow.triangle.2.circlepath" + ) + } + } + } + } + + Section(L10n.Localizable.DownloadsView.Inspector.Section.pages) { + ForEach(inspection.pages) { page in + DownloadInspectorPageRow(page: page) { + store.send(.retryPage(page.index)) + } + } + } + } + } + .listStyle(.insetGrouped) + } + } + .autoBlur(radius: blurRadius) + .navigationTitle(L10n.Localizable.DownloadsView.Inspector.Title.downloadStatus) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + CustomToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.EhSettingView.ToolbarItem.Button.done) { + dismiss() + } + } + } + .onAppear { + store.send(.onAppear) + } + } +} + +private struct DownloadListRow: View { + let download: DownloadedGallery + let setting: Setting + let tagTranslator: TagTranslator + let openAction: () -> Void + + var body: some View { + HStack(spacing: 0) { + StaticGalleryDetailCell( + gallery: download.gallery, + resolvedCoverURL: download.coverURL, + setting: setting, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + }, + downloadBadge: download.badge + ) + .allowsHitTesting(false) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture(perform: openAction) + } +} + +private struct DownloadInspectorPageRow: View { + let page: DownloadPageInspection + let retryAction: () -> Void + + private var symbolName: String { + switch page.status { + case .pending: + return "clock" + case .downloaded: + return "checkmark.circle.fill" + case .failed: + return "exclamationmark.circle.fill" + } + } + + private var tint: Color { + switch page.status { + case .pending: + return .secondary + case .downloaded: + return .green + case .failed: + return .red + } + } + + private var subtitle: String { + switch page.status { + case .pending: + return L10n.Localizable.DownloadsView.Inspector.Page.pending + case .downloaded: + return page.relativePath ?? L10n.Localizable.Struct.DownloadBadge.Text.downloaded + case .failed: + return page.failure?.message ?? L10n.Localizable.DownloadsView.Inspector.Page.tapToRetry + } + } + + var body: some View { + Group { + if page.status == .failed { + Button(action: retryAction) { + rowContent + } + .buttonStyle(.plain) + } else { + rowContent + } + } + } + + private var rowContent: some View { + HStack(spacing: 12) { + Image(systemName: symbolName) + .foregroundStyle(tint) + .font(.title3) + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Localizable.DownloadsView.Inspector.Page.title(page.index)) + .font(.body.weight(.medium)) + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + if page.status == .failed { + Image(systemSymbol: .arrowClockwise) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +} + +struct DownloadsView_Previews: PreviewProvider { + static var previews: some View { + DownloadsView( + store: .init(initialState: .init(), reducer: DownloadsReducer.init), + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) + } +} diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift index 0ccf39e5f..c9301be9b 100644 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -9,6 +9,10 @@ import ComposableArchitecture @Reducer struct FavoritesReducer { + private enum CancelID { + case observeDownloads + } + @CasePathable enum Route: Equatable { case quickSearch(EquatableVoid = .init()) @@ -27,6 +31,7 @@ struct FavoritesReducer { var rawPageNumber = [Int: PageNumber]() var rawLoadingState = [Int: LoadingState]() var rawFooterLoadingState = [Int: LoadingState]() + var downloadBadges = [String: DownloadBadge]() var galleries: [Gallery]? { rawGalleries[index] @@ -59,6 +64,7 @@ struct FavoritesReducer { enum Action: BindableAction { case binding(BindingAction) + case onAppear case setNavigation(Route?) case setFavoritesIndex(Int) case clearSubStates @@ -68,12 +74,17 @@ struct FavoritesReducer { case fetchGalleriesDone(Int, Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(Int, Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) + case fetchDownloadBadges([String]) + case fetchDownloadBadgesDone([String: DownloadBadge]) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) case detail(DetailReducer.Action) case quickSearch(QuickSearchReducer.Action) } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient var body: some Reducer { @@ -87,6 +98,9 @@ struct FavoritesReducer { case .binding: return .none + case .onAppear: + return .send(.observeDownloads) + case .setNavigation(let route): state.route = route return route == nil ? .send(.clearSubStates) : .none @@ -134,7 +148,10 @@ struct FavoritesReducer { state.rawPageNumber[targetFavIndex] = pageNumber state.rawGalleries[targetFavIndex] = galleries state.sortOrder = sortOrder - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .merge( + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), + .send(.fetchDownloadBadges(galleries.map(\.gid))) + ) case .failure(let error): state.rawLoadingState[targetFavIndex] = .failed(error) } @@ -174,6 +191,7 @@ struct FavoritesReducer { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.rawLoadingState[targetFavIndex] = .idle + effects.append(.send(.fetchDownloadBadges((state.galleries ?? []).map(\.gid)))) } return .merge(effects) @@ -182,6 +200,33 @@ struct FavoritesReducer { } return .none + case .fetchDownloadBadges(let gids): + return .run { send in + await send(.fetchDownloadBadgesDone(await downloadClient.badges(gids))) + } + + case .fetchDownloadBadgesDone(let badges): + state.downloadBadges.merge(badges, uniquingKeysWith: { _, new in new }) + return .none + + case .observeDownloads: + return .run { send in + for await downloads in downloadClient.observeDownloads() { + await send(.observeDownloadsDone(downloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone(let downloads): + let visibleGIDs = Set((state.galleries ?? []).map(\.gid)) + state.downloadBadges = Dictionary( + uniqueKeysWithValues: downloads.compactMap { download in + guard visibleGIDs.contains(download.gid) else { return nil } + return (download.gid, download.badge) + } + ) + return .none + case .detail: return .none diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index b864bd9d3..e72f4c050 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -46,7 +46,8 @@ struct FavoritesView: View { navigateAction: { store.send(.setNavigation(.detail($0))) }, translateAction: { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } + }, + downloadBadges: store.downloadBadges ) } else { NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) @@ -73,6 +74,7 @@ struct FavoritesView: View { store.send(.fetchGalleries()) } .onAppear { + store.send(.onAppear) if store.galleries?.isEmpty != false && CookieUtil.didLogin { DispatchQueue.main.async { store.send(.fetchGalleries()) diff --git a/EhPanda/View/Home/History/HistoryReducer.swift b/EhPanda/View/Home/History/HistoryReducer.swift index df4ffc279..5c8c89ba4 100644 --- a/EhPanda/View/Home/History/HistoryReducer.swift +++ b/EhPanda/View/Home/History/HistoryReducer.swift @@ -8,6 +8,10 @@ import ComposableArchitecture @Reducer struct HistoryReducer { + private enum CancelID { + case observeDownloads + } + @CasePathable enum Route: Equatable { case detail(String) @@ -19,6 +23,7 @@ struct HistoryReducer { var route: Route? var keyword = "" var clearDialogPresented = false + var downloadBadges = [String: DownloadBadge]() var filteredGalleries: [Gallery] { guard !keyword.isEmpty else { return galleries } @@ -36,17 +41,23 @@ struct HistoryReducer { enum Action: BindableAction { case binding(BindingAction) + case onAppear case setNavigation(Route?) case clearSubStates case clearHistoryGalleries case fetchGalleries case fetchGalleriesDone([Gallery]) + case fetchDownloadBadges([String]) + case fetchDownloadBadgesDone([String: DownloadBadge]) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) case detail(DetailReducer.Action) } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient var body: some Reducer { @@ -60,6 +71,9 @@ struct HistoryReducer { case .binding: return .none + case .onAppear: + return .send(.observeDownloads) + case .setNavigation(let route): state.route = route return route == nil ? .send(.clearSubStates) : .none @@ -92,6 +106,33 @@ struct HistoryReducer { } else { state.galleries = galleries } + return .send(.fetchDownloadBadges(galleries.map(\.gid))) + + case .fetchDownloadBadges(let gids): + return .run { send in + await send(.fetchDownloadBadgesDone(await downloadClient.badges(gids))) + } + + case .fetchDownloadBadgesDone(let badges): + state.downloadBadges = badges + return .none + + case .observeDownloads: + return .run { send in + for await downloads in downloadClient.observeDownloads() { + await send(.observeDownloadsDone(downloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone(let downloads): + let visibleGIDs = Set(state.galleries.map(\.gid)) + state.downloadBadges = Dictionary( + uniqueKeysWithValues: downloads.compactMap { download in + guard visibleGIDs.contains(download.gid) else { return nil } + return (download.gid, download.badge) + } + ) return .none case .detail: diff --git a/EhPanda/View/Home/History/HistoryView.swift b/EhPanda/View/Home/History/HistoryView.swift index da046d569..f106ada10 100644 --- a/EhPanda/View/Home/History/HistoryView.swift +++ b/EhPanda/View/Home/History/HistoryView.swift @@ -36,10 +36,12 @@ struct HistoryView: View { navigateAction: { store.send(.setNavigation(.detail($0))) }, translateAction: { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } + }, + downloadBadges: store.downloadBadges ) .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) .onAppear { + store.send(.onAppear) if store.galleries.isEmpty { DispatchQueue.main.async { store.send(.fetchGalleries) diff --git a/EhPanda/View/Home/HomeReducer.swift b/EhPanda/View/Home/HomeReducer.swift index 6165d918e..4f2551597 100644 --- a/EhPanda/View/Home/HomeReducer.swift +++ b/EhPanda/View/Home/HomeReducer.swift @@ -10,6 +10,10 @@ import ComposableArchitecture @Reducer struct HomeReducer { + private enum CancelID { + case observeDownloads + } + @CasePathable enum Route: Equatable, Hashable { case detail(String) @@ -34,6 +38,7 @@ struct HomeReducer { var frontpageLoadingState: LoadingState = .idle var toplistsGalleries = [Int: [Gallery]]() var toplistsLoadingState = [Int: LoadingState]() + var downloadBadges = [String: DownloadBadge]() var frontpageState = FrontpageReducer.State() var toplistsState = ToplistsReducer.State() @@ -64,10 +69,20 @@ struct HomeReducer { frontpageGalleries = Array(galleries.prefix(min(galleries.count, 25))) .removeDuplicates(by: \.trimmedTitle) } + + var visibleGalleryIDs: Set { + var gids = Set(popularGalleries.map(\.gid)) + gids.formUnion(frontpageGalleries.map(\.gid)) + toplistsGalleries.values.flatMap(\.self).forEach { + gids.insert($0.gid) + } + return gids + } } enum Action: BindableAction { case binding(BindingAction) + case onAppear case setNavigation(Route?) case clearSubStates case setAllowsCardHitTesting(Bool) @@ -82,6 +97,10 @@ struct HomeReducer { case fetchFrontpageGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchToplistsGalleries(Int, Int? = nil) case fetchToplistsGalleriesDone(Int, Result<(PageNumber, [Gallery]), AppError>) + case fetchDownloadBadges([String]) + case fetchDownloadBadgesDone([String: DownloadBadge]) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) case frontpage(FrontpageReducer.Action) case toplists(ToplistsReducer.Action) @@ -92,6 +111,7 @@ struct HomeReducer { } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.downloadClient) private var downloadClient @Dependency(\.libraryClient) private var libraryClient var body: some Reducer { @@ -116,6 +136,9 @@ struct HomeReducer { case .binding: return .none + case .onAppear: + return .send(.observeDownloads) + case .setNavigation(let route): state.route = route return route == nil ? .send(.clearSubStates) : .none @@ -172,7 +195,10 @@ struct HomeReducer { return .none } state.setPopularGalleries(galleries) - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .merge( + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), + .send(.fetchDownloadBadges(galleries.map(\.gid))) + ) case .failure(let error): state.popularLoadingState = .failed(error) } @@ -196,7 +222,10 @@ struct HomeReducer { return .none } state.setFrontpageGalleries(galleries) - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .merge( + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), + .send(.fetchDownloadBadges(galleries.map(\.gid))) + ) case .failure(let error): state.frontpageLoadingState = .failed(error) } @@ -219,7 +248,10 @@ struct HomeReducer { return .none } state.toplistsGalleries[index] = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .merge( + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), + .send(.fetchDownloadBadges(galleries.map(\.gid))) + ) case .failure(let error): state.toplistsLoadingState[index] = .failed(error) } @@ -242,6 +274,33 @@ struct HomeReducer { } return .none + case .fetchDownloadBadges(let gids): + return .run { send in + await send(.fetchDownloadBadgesDone(await downloadClient.badges(gids))) + } + + case .fetchDownloadBadgesDone(let badges): + state.downloadBadges.merge(badges, uniquingKeysWith: { _, new in new }) + return .none + + case .observeDownloads: + return .run { send in + for await downloads in downloadClient.observeDownloads() { + await send(.observeDownloadsDone(downloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone(let downloads): + let visibleGIDs = state.visibleGalleryIDs + state.downloadBadges = Dictionary( + uniqueKeysWithValues: downloads.compactMap { download in + guard visibleGIDs.contains(download.gid) else { return nil } + return (download.gid, download.badge) + } + ) + return .none + case .frontpage: return .none diff --git a/EhPanda/View/Home/HomeView.swift b/EhPanda/View/Home/HomeView.swift index 09d1940c2..e7406305f 100644 --- a/EhPanda/View/Home/HomeView.swift +++ b/EhPanda/View/Home/HomeView.swift @@ -40,6 +40,7 @@ struct HomeView: View { pageIndex: $store.cardPageIndex, currentID: store.currentCardID, colors: store.cardColors, + downloadBadges: store.downloadBadges, navigateAction: navigateTo(gid:), webImageSuccessAction: { gid, result in store.send(.analyzeImageColors(gid, result)) @@ -52,6 +53,7 @@ struct HomeView: View { CoverWallSection( galleries: store.frontpageGalleries, isLoading: store.frontpageLoadingState == .loading, + downloadBadges: store.downloadBadges, navigateAction: navigateTo(gid:), showAllAction: { store.send(.setNavigation(.section(.frontpage))) }, reloadAction: { store.send(.fetchFrontpageGalleries) } @@ -61,6 +63,7 @@ struct HomeView: View { galleries: store.toplistsGalleries, isLoading: !store.toplistsLoadingState .values.allSatisfy({ $0 != .loading }), + downloadBadges: store.downloadBadges, navigateAction: navigateTo(gid:), showAllAction: { store.send(.setNavigation(.section(.toplists))) }, reloadAction: { store.send(.fetchAllToplistsGalleries) } @@ -88,6 +91,7 @@ struct HomeView: View { } .animation(.default, value: store.popularLoadingState) .onAppear { + store.send(.onAppear) if store.popularGalleries.isEmpty { store.send(.fetchAllGalleries) } @@ -198,18 +202,21 @@ private struct CardSlideSection: View, Equatable { private let galleries: [Gallery] private let currentID: String private let colors: [Color] + private let downloadBadges: [String: DownloadBadge] private let navigateAction: (String) -> Void private let webImageSuccessAction: (String, RetrieveImageResult) -> Void init( galleries: [Gallery], pageIndex: Binding, currentID: String, - colors: [Color], navigateAction: @escaping (String) -> Void, + colors: [Color], downloadBadges: [String: DownloadBadge], + navigateAction: @escaping (String) -> Void, webImageSuccessAction: @escaping (String, RetrieveImageResult) -> Void ) { self.galleries = galleries _pageIndex = pageIndex self.currentID = currentID self.colors = colors + self.downloadBadges = downloadBadges self.navigateAction = navigateAction self.webImageSuccessAction = webImageSuccessAction } @@ -218,6 +225,7 @@ private struct CardSlideSection: View, Equatable { lhs.galleries == rhs.galleries && lhs.currentID == rhs.currentID && lhs.colors == rhs.colors + && lhs.downloadBadges == rhs.downloadBadges } var body: some View { @@ -225,10 +233,17 @@ private struct CardSlideSection: View, Equatable { Button { navigateAction(gallery.id) } label: { - GalleryCardCell(gallery: gallery, currentID: currentID, colors: colors) { - webImageSuccessAction(gallery.gid, $0) - } - .tint(.primary).multilineTextAlignment(.leading) + GalleryCardCell( + gallery: gallery, + currentID: currentID, + colors: colors, + webImageSuccessAction: { + webImageSuccessAction(gallery.gid, $0) + }, + downloadBadge: downloadBadges[gallery.gid] ?? .none + ) + .tint(.primary) + .multilineTextAlignment(.leading) } } .preferredItemSize(Defaults.FrameSize.cardCellSize) @@ -243,18 +258,20 @@ private struct CardSlideSection: View, Equatable { private struct CoverWallSection: View { private let galleries: [Gallery] private let isLoading: Bool + private let downloadBadges: [String: DownloadBadge] private let navigateAction: (String) -> Void private let showAllAction: () -> Void private let reloadAction: () -> Void init( - galleries: [Gallery], isLoading: Bool, + galleries: [Gallery], isLoading: Bool, downloadBadges: [String: DownloadBadge], navigateAction: @escaping (String) -> Void, showAllAction: @escaping () -> Void, reloadAction: @escaping () -> Void ) { self.galleries = galleries self.isLoading = isLoading + self.downloadBadges = downloadBadges self.navigateAction = navigateAction self.showAllAction = showAllAction self.reloadAction = reloadAction @@ -281,7 +298,11 @@ private struct CoverWallSection: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 20) { ForEach(dataSource, id: \.first) { - VerticalCoverStack(galleries: $0, navigateAction: navigateAction) + VerticalCoverStack( + galleries: $0, + downloadBadges: downloadBadges, + navigateAction: navigateAction + ) } .withHorizontalSpacing(width: 0) } @@ -292,11 +313,19 @@ private struct CoverWallSection: View { } private struct VerticalCoverStack: View { + @ObservedObject private var downloadStore = DownloadBadgeStore.shared + private let galleries: [Gallery] + private let downloadBadges: [String: DownloadBadge] private let navigateAction: (String) -> Void - init(galleries: [Gallery], navigateAction: @escaping (String) -> Void) { + init( + galleries: [Gallery], + downloadBadges: [String: DownloadBadge], + navigateAction: @escaping (String) -> Void + ) { self.galleries = galleries + self.downloadBadges = downloadBadges self.navigateAction = navigateAction } @@ -307,8 +336,18 @@ private struct VerticalCoverStack: View { Button { navigateAction(gallery.id) } label: { - KFImage(gallery.coverURL).placeholder(placeholder).defaultModifier().scaledToFill() + KFImage(downloadStore.resolvedCoverURL(for: gallery)) + .placeholder(placeholder) + .defaultModifier() + .scaledToFill() .frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH).cornerRadius(2) + .overlay(alignment: .topTrailing) { + DownloadBadgeLabel( + badge: downloadBadges[gallery.gid] ?? .none, + compact: true + ) + .padding(6) + } } } @@ -323,18 +362,20 @@ private struct VerticalCoverStack: View { private struct ToplistsSection: View { private let galleries: [Int: [Gallery]] private let isLoading: Bool + private let downloadBadges: [String: DownloadBadge] private let navigateAction: (String) -> Void private let showAllAction: () -> Void private let reloadAction: () -> Void init( - galleries: [Int: [Gallery]], isLoading: Bool, + galleries: [Int: [Gallery]], isLoading: Bool, downloadBadges: [String: DownloadBadge], navigateAction: @escaping (String) -> Void, showAllAction: @escaping () -> Void, reloadAction: @escaping () -> Void ) { self.galleries = galleries self.isLoading = isLoading + self.downloadBadges = downloadBadges self.navigateAction = navigateAction self.showAllAction = showAllAction self.reloadAction = reloadAction @@ -381,11 +422,13 @@ private struct ToplistsSection: View { HStack { VerticalToplistsStack( galleries: galleries(type: type, range: 0...2), startRanking: 1, + downloadBadges: downloadBadges, navigateAction: navigateAction ) if DeviceUtil.isPad { VerticalToplistsStack( galleries: galleries(type: type, range: 3...5), startRanking: 4, + downloadBadges: downloadBadges, navigateAction: navigateAction ) } @@ -398,11 +441,18 @@ private struct ToplistsSection: View { private struct VerticalToplistsStack: View { private let galleries: [Gallery] private let startRanking: Int + private let downloadBadges: [String: DownloadBadge] private let navigateAction: (String) -> Void - init(galleries: [Gallery], startRanking: Int, navigateAction: @escaping (String) -> Void) { + init( + galleries: [Gallery], + startRanking: Int, + downloadBadges: [String: DownloadBadge], + navigateAction: @escaping (String) -> Void + ) { self.galleries = galleries self.startRanking = startRanking + self.downloadBadges = downloadBadges self.navigateAction = navigateAction } @@ -413,7 +463,11 @@ private struct VerticalToplistsStack: View { Button { navigateAction(galleries[index].id) } label: { - GalleryRankingCell(gallery: galleries[index], ranking: startRanking + index) + GalleryRankingCell( + gallery: galleries[index], + ranking: startRanking + index, + downloadBadge: downloadBadges[galleries[index].gid] ?? .none + ) .tint(.primary).multilineTextAlignment(.leading) } Divider().opacity(index == galleries.count - 1 ? 0 : 1) diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift index 374139728..d84a476c1 100644 --- a/EhPanda/View/Home/Watched/WatchedReducer.swift +++ b/EhPanda/View/Home/Watched/WatchedReducer.swift @@ -15,7 +15,7 @@ struct WatchedReducer { } private enum CancelID: CaseIterable { - case fetchGalleries, fetchMoreGalleries + case fetchGalleries, fetchMoreGalleries, observeDownloads } @ObservableState @@ -27,6 +27,7 @@ struct WatchedReducer { var pageNumber = PageNumber() var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle + var downloadBadges = [String: DownloadBadge]() var filtersState = FiltersReducer.State() var quickSearchState = QuickSearchReducer.State() @@ -47,6 +48,7 @@ struct WatchedReducer { enum Action: BindableAction { case binding(BindingAction) + case onAppear case setNavigation(Route?) case clearSubStates case onNotLoginViewButtonTapped @@ -56,6 +58,10 @@ struct WatchedReducer { case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchDownloadBadges([String]) + case fetchDownloadBadgesDone([String: DownloadBadge]) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) case filters(FiltersReducer.Action) case detail(DetailReducer.Action) @@ -63,6 +69,7 @@ struct WatchedReducer { } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient var body: some Reducer { @@ -76,6 +83,9 @@ struct WatchedReducer { case .binding: return .none + case .onAppear: + return .send(.observeDownloads) + case .setNavigation(let route): state.route = route return route == nil ? .send(.clearSubStates) : .none @@ -120,7 +130,10 @@ struct WatchedReducer { } state.pageNumber = pageNumber state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .merge( + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), + .send(.fetchDownloadBadges(galleries.map(\.gid))) + ) case .failure(let error): state.loadingState = .failed(error) } @@ -157,6 +170,7 @@ struct WatchedReducer { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.loadingState = .idle + effects.append(.send(.fetchDownloadBadges(state.galleries.map(\.gid)))) } return .merge(effects) @@ -165,6 +179,33 @@ struct WatchedReducer { } return .none + case .fetchDownloadBadges(let gids): + return .run { send in + await send(.fetchDownloadBadgesDone(await downloadClient.badges(gids))) + } + + case .fetchDownloadBadgesDone(let badges): + state.downloadBadges.merge(badges, uniquingKeysWith: { _, new in new }) + return .none + + case .observeDownloads: + return .run { send in + for await downloads in downloadClient.observeDownloads() { + await send(.observeDownloadsDone(downloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone(let downloads): + let visibleGIDs = Set(state.galleries.map(\.gid)) + state.downloadBadges = Dictionary( + uniqueKeysWithValues: downloads.compactMap { download in + guard visibleGIDs.contains(download.gid) else { return nil } + return (download.gid, download.badge) + } + ) + return .none + case .quickSearch: return .none diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift index 3a59da563..ef3b1cedb 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -39,7 +39,8 @@ struct WatchedView: View { navigateAction: { store.send(.setNavigation(.detail($0))) }, translateAction: { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } + }, + downloadBadges: store.downloadBadges ) } else { NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) @@ -70,6 +71,7 @@ struct WatchedView: View { store.send(.fetchGalleries()) } .onAppear { + store.send(.onAppear) if store.galleries.isEmpty && CookieUtil.didLogin { DispatchQueue.main.async { store.send(.fetchGalleries()) diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index a0a0fd3f9..5987bd90d 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -38,6 +38,8 @@ struct ReadingReducer { private enum CancelID: CaseIterable { case fetchImage case fetchDatabaseInfos + case observeDownloads + case loadLocalPageURLs case fetchPreviewURLs case fetchThumbnailURLs case fetchNormalImageURLs @@ -49,6 +51,7 @@ struct ReadingReducer { @ObservableState struct State: Equatable { var route: Route? + var contentSource: ReadingContentSource = .remote var gallery: Gallery = .empty var galleryDetail: GalleryDetail? @@ -63,6 +66,8 @@ struct ReadingReducer { var previewConfig: PreviewConfig = .normal(rows: 4) var previewURLs = [Int: URL]() + var localPageURLs = [Int: URL]() + var localPageRequestID = UUID() var thumbnailURLs = [Int: URL]() var imageURLs = [Int: URL]() @@ -75,6 +80,10 @@ struct ReadingReducer { var showsPanel = false var showsSliderPreview = false + init(contentSource: ReadingContentSource = .remote) { + self.contentSource = contentSource + } + // Update func update(stored: inout [Int: T], new: [Int: T], replaceExisting: Bool = true) { guard !new.isEmpty else { return } @@ -155,6 +164,10 @@ struct ReadingReducer { case teardown case fetchDatabaseInfos(String) case fetchDatabaseInfosDone(GalleryState) + case observeDownloads(String) + case observeDownloadsDone([DownloadedGallery]) + case loadLocalPageURLs(String) + case loadLocalPageURLsDone(UUID, [Int: URL]) case fetchPreviewURLs(Int) case fetchPreviewURLsDone(Int, Result<[Int: URL], AppError>) @@ -174,11 +187,13 @@ struct ReadingReducer { case fetchMPVKeysDone(Int, Result<(String, [Int: String]), AppError>) case fetchMPVImageURL(Int, Bool) case fetchMPVImageURLDone(Int, Result<(URL, URL?, String), AppError>) + case captureCachedPage(Int) } @Dependency(\.appDelegateClient) private var appDelegateClient @Dependency(\.clipboardClient) private var clipboardClient @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient @Dependency(\.deviceClient) private var deviceClient @@ -219,7 +234,9 @@ struct ReadingReducer { case .onAppear(let gid, let enablesLandscape): var effects: [Effect] = [ - .send(.fetchDatabaseInfos(gid)) + .send(.fetchDatabaseInfos(gid)), + .send(.observeDownloads(gid)), + .send(.loadLocalPageURLs(gid)) ] if enablesLandscape { effects.append(.send(.setOrientationPortrait(false))) @@ -233,13 +250,29 @@ struct ReadingReducer { case .onWebImageSucceeded(let index): state.imageURLLoadingStates[index] = .idle state.webImageLoadSuccessIndices.insert(index) - return .none + guard state.contentSource == .remote, + state.gallery.id.isValidGID, + state.localPageURLs[index] == nil + else { + return .none + } + return .send(.captureCachedPage(index)) case .onWebImageFailed(let index): state.imageURLLoadingStates[index] = .failed(.webImageFailed) return .none case .reloadAllWebImages: + guard state.contentSource == .remote else { + if case .local(let download, let manifest) = state.contentSource { + applyLocalSource( + state: &state, + download: download, + manifest: manifest + ) + } + return .none + } state.previewURLs = .init() state.thumbnailURLs = .init() state.imageURLs = .init() @@ -253,6 +286,7 @@ struct ReadingReducer { } case .retryAllFailedWebImages: + guard state.contentSource == .remote else { return .none } state.imageURLLoadingStates.forEach { (index, loadingState) in if case .failed = loadingState { state.imageURLLoadingStates[index] = .idle @@ -317,16 +351,19 @@ struct ReadingReducer { } case .syncPreviewURLs(let previewURLs): + guard state.contentSource == .remote else { return .none } return .run { [state] _ in await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) } case .syncThumbnailURLs(let thumbnailURLs): + guard state.contentSource == .remote else { return .none } return .run { [state] _ in await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) } case .syncImageURLs(let imageURLs, let originalImageURLs): + guard state.contentSource == .remote else { return .none } return .run { [state] _ in await databaseClient.updateImageURLs( gid: state.gallery.id, @@ -345,9 +382,17 @@ struct ReadingReducer { return .merge(effects) case .fetchDatabaseInfos(let gid): - guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery - state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) + if case .local(let download, let manifest) = state.contentSource { + applyLocalSource( + state: &state, + download: download, + manifest: manifest + ) + } else { + guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } + state.gallery = gallery + state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) + } return .run { [state] send in guard let dbState = await databaseClient.fetchGalleryState(gid: state.gallery.id) else { return } await send(.fetchDatabaseInfosDone(dbState)) @@ -355,18 +400,83 @@ struct ReadingReducer { .cancellable(id: CancelID.fetchDatabaseInfos) case .fetchDatabaseInfosDone(let galleryState): - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig + if state.contentSource == .remote { + if let previewConfig = galleryState.previewConfig { + state.previewConfig = previewConfig + } + state.previewURLs = galleryState.previewURLs + state.imageURLs = galleryState.imageURLs + state.thumbnailURLs = galleryState.thumbnailURLs + state.originalImageURLs = galleryState.originalImageURLs } - state.previewURLs = galleryState.previewURLs - state.imageURLs = galleryState.imageURLs - state.thumbnailURLs = galleryState.thumbnailURLs - state.originalImageURLs = galleryState.originalImageURLs state.readingProgress = galleryState.readingProgress state.databaseLoadingState = .idle return .none + case .observeDownloads(let gid): + guard gid.isValidGID else { return .none } + return .run { send in + var previousRelevantDownloads = [DownloadedGallery]() + var hadRelevantDownloads = false + for await downloads in downloadClient.observeDownloads() { + let relevantDownloads = downloads.filter { $0.gid == gid } + let hasRelevantDownloads = !relevantDownloads.isEmpty + guard hasRelevantDownloads || hadRelevantDownloads else { continue } + if relevantDownloads == previousRelevantDownloads { + hadRelevantDownloads = hasRelevantDownloads + continue + } + previousRelevantDownloads = relevantDownloads + hadRelevantDownloads = hasRelevantDownloads + await send(.observeDownloadsDone(relevantDownloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone: + guard state.gallery.id.isValidGID else { return .none } + return .send(.loadLocalPageURLs(state.gallery.id)) + + case .loadLocalPageURLs(let gid): + guard gid.isValidGID else { + state.localPageRequestID = UUID() + state.localPageURLs = .init() + return .none + } + let requestID = UUID() + state.localPageRequestID = requestID + return .run { send in + let localPageURLs: [Int: URL] + switch await downloadClient.loadLocalPageURLs(gid) { + case .success(let pageURLs): + localPageURLs = pageURLs + case .failure: + localPageURLs = [:] + } + await send(.loadLocalPageURLsDone(requestID, localPageURLs)) + } + .cancellable(id: CancelID.loadLocalPageURLs, cancelInFlight: true) + + case .loadLocalPageURLsDone(let requestID, let localPageURLs): + guard state.localPageRequestID == requestID else { return .none } + if case .local = state.contentSource, + localPageURLs.isEmpty + { + state.contentSource = .remote + state.previewURLs = .init() + state.thumbnailURLs = .init() + state.imageURLs = .init() + state.originalImageURLs = .init() + state.forceRefreshID = .init() + } + state.localPageURLs = localPageURLs + return .none + case .fetchPreviewURLs(let index): + guard state.contentSource == .remote else { + state.previewLoadingStates[index] = .idle + return .none + } guard state.previewLoadingStates[index] != .loading, let galleryURL = state.gallery.galleryURL else { return .none } @@ -394,6 +504,14 @@ struct ReadingReducer { return .none case .fetchImageURLs(let index): + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.localPageURLs[index] == nil else { + state.imageURLLoadingStates[index] = .idle + return .none + } if state.mpvKey != nil { return .send(.fetchMPVImageURL(index, false)) } else { @@ -401,6 +519,14 @@ struct ReadingReducer { } case .refetchImageURLs(let index): + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.localPageURLs[index] == nil else { + state.imageURLLoadingStates[index] = .idle + return .none + } if state.mpvKey != nil { return .send(.fetchMPVImageURL(index, true)) } else { @@ -408,8 +534,12 @@ struct ReadingReducer { } case .prefetchImages(let index, let prefetchLimit): + guard state.contentSource == .remote else { return .none } func getPrefetchImageURLs(range: ClosedRange) -> [URL] { (range.lowerBound...range.upperBound).compactMap { index in + if let url = state.localPageURLs[index], !url.isFileURL { + return url + } if let url = state.imageURLs[index] { return url } @@ -418,6 +548,9 @@ struct ReadingReducer { } func getFetchImageURLIndices(range: ClosedRange) -> [Int] { (range.lowerBound...range.upperBound).compactMap { index in + if state.localPageURLs[index] != nil { + return nil + } if state.imageURLs[index] == nil, state.imageURLLoadingStates[index] != .loading { return index } @@ -450,6 +583,10 @@ struct ReadingReducer { return .merge(effects) case .fetchThumbnailURLs(let index): + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } guard state.imageURLLoadingStates[index] != .loading, let galleryURL = state.gallery.galleryURL else { return .none } @@ -490,6 +627,10 @@ struct ReadingReducer { return .none case .fetchNormalImageURLs(let index, let thumbnailURLs): + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } return .run { send in let response = await GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs).response() await send(.fetchNormalImageURLsDone(index, response)) @@ -519,6 +660,10 @@ struct ReadingReducer { return .none case .refetchNormalImageURLs(let index): + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } guard state.imageURLLoadingStates[index] != .loading, let galleryURL = state.gallery.galleryURL, let imageURL = state.imageURLs[index] @@ -559,6 +704,10 @@ struct ReadingReducer { return .none case .fetchMPVKeys(let index, let mpvURL): + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } return .run { send in let response = await MPVKeysRequest(mpvURL: mpvURL).response() await send(.fetchMPVKeysDone(index, response)) @@ -594,6 +743,10 @@ struct ReadingReducer { return .none case .fetchMPVImageURL(let index, let isRefresh): + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, let mpvImageKey = state.mpvImageKeys[index], state.imageURLLoadingStates[index] != .loading @@ -629,6 +782,22 @@ struct ReadingReducer { state.imageURLLoadingStates[index] = .failed(error) } return .none + + case .captureCachedPage(let index): + guard state.contentSource == .remote, + state.gallery.id.isValidGID + else { + return .none + } + let gid = state.gallery.id + let imageURL = state.imageURLs[index] + return .run { _ in + await downloadClient.captureCachedPage( + gid, + index, + imageURL + ) + } } } .haptics( @@ -643,3 +812,48 @@ struct ReadingReducer { ) } } + +private extension ReadingReducer { + func applyLocalSource( + state: inout State, + download: DownloadedGallery, + manifest: DownloadManifest + ) { + guard let folderURL = download.folderURL else { return } + + state.gallery = download.gallery + state.galleryDetail = GalleryDetail( + gid: download.gid, + title: download.title, + jpnTitle: download.jpnTitle, + isFavorited: false, + visibility: .yes, + rating: download.rating, + userRating: 0, + ratingCount: 0, + category: download.category, + language: manifest.language, + uploader: download.uploader ?? "", + postedDate: download.postedDate, + coverURL: download.coverURL, + favoritedCount: 0, + pageCount: download.pageCount, + sizeCount: 0, + sizeType: "", + torrentCount: 0 + ) + let imageURLs = manifest.imageURLs(folderURL: folderURL) + state.localPageURLs = imageURLs + state.previewConfig = .normal(rows: 4) + state.previewURLs = imageURLs + state.thumbnailURLs = imageURLs + state.imageURLs = imageURLs + state.originalImageURLs = imageURLs + state.mpvKey = nil + state.mpvImageKeys = .init() + state.mpvSkipServerIdentifiers = .init() + state.imageURLLoadingStates = .init() + state.previewLoadingStates = .init() + state.databaseLoadingState = .idle + } +} diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 504213a30..1c42f69eb 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -36,6 +36,21 @@ struct ReadingView: View { colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) } + private var displayPreviewURLs: [Int: URL] { + store.localPageURLs.merging(store.previewURLs, uniquingKeysWith: { local, _ in local }) + } + + private var displayImageURLs: [Int: URL] { + store.localPageURLs.merging(store.imageURLs, uniquingKeysWith: { local, _ in local }) + } + + private var displayOriginalImageURLs: [Int: URL] { + if store.contentSource == .remote { + return store.originalImageURLs + } + return store.localPageURLs.merging(store.originalImageURLs, uniquingKeysWith: { local, _ in local }) + } + var body: some View { changeTriggers(content: { content }) .sheet(item: $store.route.sending(\.setNavigation).readingSetting) { _ in @@ -135,7 +150,7 @@ struct ReadingView: View { enablesLiveText: $liveTextHandler.enablesLiveText, autoPlayPolicy: .init(get: { autoPlayHandler.policy }, set: { setAutoPlayPolocy($0) }), range: 1...Float(store.gallery.pageCount), - previewURLs: store.previewURLs, + previewURLs: displayPreviewURLs, dismissGesture: controlPanelDismissGesture, dismissAction: { store.send(.onPerformDismiss) }, navigateSettingAction: { store.send(.setNavigation(.readingSetting())) }, @@ -214,8 +229,8 @@ struct ReadingView: View { isDatabaseLoading: store.databaseLoadingState != .idle, backgroundColor: backgroundColor, config: imageStackConfig, - imageURLs: store.imageURLs, - originalImageURLs: store.originalImageURLs, + imageURLs: displayImageURLs, + originalImageURLs: displayOriginalImageURLs, loadingStates: store.imageURLLoadingStates, enablesLiveText: liveTextHandler.enablesLiveText, liveTextGroups: liveTextHandler.liveTextGroups, @@ -257,32 +272,60 @@ extension ReadingView { Logger.info("analyzeImageForLiveText duplicated", context: ["index": index]) return } - guard let key = store.imageURLs[index]?.absoluteString else { + guard let imageURL = displayImageURLs[index] else { Logger.info("analyzeImageForLiveText URL not found", context: ["index": index]) return } - KingfisherManager.shared.cache.retrieveImage(forKey: key) { result in - switch result { - case .success(let result): - if let image = result.image, let cgImage = image.cgImage { - liveTextHandler.analyzeImage( - cgImage, size: image.size, index: index, recognitionLanguages: - store.galleryDetail?.language.codes - ) - } else { - Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) - } - case .failure(let error): - Logger.info( - "analyzeImageForLiveText failed", - context: [ - "index": index, - "error": error - ] - as [String: Any] + if imageURL.isFileURL { + if let image = UIImage(contentsOfFile: imageURL.path) + ?? ((try? Data(contentsOf: imageURL)).flatMap(UIImage.init(data:))), + let cgImage = image.cgImage + { + liveTextHandler.analyzeImage( + cgImage, size: image.size, index: index, recognitionLanguages: + store.galleryDetail?.language.codes ) + } else { + Logger.info("analyzeImageForLiveText local image not found", context: ["index": index]) } + return } + let cacheKeys = imageURL.imageCacheKeys(includeStableAlias: true) + + func retrieveImage(cacheKeys: ArraySlice) { + guard let cacheKey = cacheKeys.first else { + Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) + return + } + KingfisherManager.shared.cache.retrieveImage(forKey: cacheKey) { result in + switch result { + case .success(let result): + if let image = result.image, let cgImage = image.cgImage { + liveTextHandler.analyzeImage( + cgImage, size: image.size, index: index, recognitionLanguages: + store.galleryDetail?.language.codes + ) + } else { + retrieveImage(cacheKeys: cacheKeys.dropFirst()) + } + case .failure(let error): + if cacheKeys.count > 1 { + retrieveImage(cacheKeys: cacheKeys.dropFirst()) + } else { + Logger.info( + "analyzeImageForLiveText failed", + context: [ + "index": index, + "error": error + ] + as [String: Any] + ) + } + } + } + } + + retrieveImage(cacheKeys: ArraySlice(cacheKeys)) } } @@ -529,8 +572,27 @@ private struct ImageContainer: View { .frame(width: width, height: height) } @ViewBuilder private func image(url: URL?) -> some View { - if url?.isGIF != true { - KFImage(url) + if let url, url.isFileURL { + if url.isGIF { + KFAnimatedImage(url) + .cacheMemoryOnly() + .placeholder(placeholder).fade(duration: 0.25) + .onSuccess(onSuccess).onFailure(onFailure) + } else { + KFImage.url( + url, + cacheKey: localFileCacheKey(url) + ) + .cacheMemoryOnly() + .placeholder(placeholder) + .defaultModifier(withRoundedCorners: false) + .onSuccess(onSuccess).onFailure(onFailure) + } + } else if url?.isGIF != true { + KFImage.url( + url, + cacheKey: url?.stableImageCacheKey ?? url?.absoluteString + ) .placeholder(placeholder) .defaultModifier(withRoundedCorners: false) .onSuccess(onSuccess).onFailure(onFailure) @@ -587,6 +649,17 @@ private struct ImageContainer: View { loadFailedAction(index) } } + + private func localFileCacheKey(_ url: URL) -> String { + let resourceValues = try? url.resourceValues(forKeys: [ + .contentModificationDateKey, + .fileSizeKey + ]) + let modificationStamp = resourceValues?.contentModificationDate? + .timeIntervalSinceReferenceDate ?? .zero + let fileSize = resourceValues?.fileSize ?? 0 + return "local::\(url.path)#\(fileSize)#\(modificationStamp)" + } } // MARK: Definition @@ -625,7 +698,7 @@ struct ReadingView_Previews: PreviewProvider { Text("") .fullScreenCover(isPresented: .constant(true)) { ReadingView( - store: .init(initialState: .init(gallery: .empty), reducer: ReadingReducer.init), + store: .init(initialState: .init(), reducer: ReadingReducer.init), gid: .init(), setting: .constant(.init()), blurRadius: 0 diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift index f0fcdf0be..8945b2191 100644 --- a/EhPanda/View/Reading/Support/ControlPanel.swift +++ b/EhPanda/View/Reading/Support/ControlPanel.swift @@ -4,7 +4,6 @@ // import SwiftUI -import Kingfisher // MARK: ControlPanel struct ControlPanel: View { @@ -326,14 +325,8 @@ private struct SliderPreivew: View { var body: some View { HStack(spacing: previewSpacing) { ForEach(previewsIndices, id: \.self) { index in - let (url, modifier) = PreviewResolver.getPreviewConfigs(originalURL: previewURLs[index]) VStack { - KFImage.url(url, cacheKey: previewURLs[index]?.absoluteString) - .placeholder({ Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) }) - .fade(duration: 0.25) - .imageModifier(modifier) - .resizable() - .scaledToFit() + PreviewImageView(originalURL: previewURLs[index]) .frame(width: previewWidth, height: showsSliderPreview ? previewHeight : 0) Text("\(index)") diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index 79604aa63..4e8dffb6b 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -15,7 +15,7 @@ struct SearchReducer { } private enum CancelID: CaseIterable { - case fetchGalleries, fetchMoreGalleries + case fetchGalleries, fetchMoreGalleries, observeDownloads } @ObservableState @@ -28,6 +28,7 @@ struct SearchReducer { var pageNumber = PageNumber() var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle + var downloadBadges = [String: DownloadBadge]() var filtersState = FiltersReducer.State() var detailState: Heap @@ -48,6 +49,7 @@ struct SearchReducer { enum Action: BindableAction { case binding(BindingAction) + case onAppear case setNavigation(Route?) case clearSubStates @@ -56,6 +58,10 @@ struct SearchReducer { case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchDownloadBadges([String]) + case fetchDownloadBadgesDone([String: DownloadBadge]) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) case detail(DetailReducer.Action) case filters(FiltersReducer.Action) @@ -63,6 +69,7 @@ struct SearchReducer { } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient var body: some Reducer { @@ -84,6 +91,9 @@ struct SearchReducer { case .binding: return .none + case .onAppear: + return .send(.observeDownloads) + case .setNavigation(let route): state.route = route return route == nil ? .send(.clearSubStates) : .none @@ -126,7 +136,10 @@ struct SearchReducer { } state.pageNumber = pageNumber state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .merge( + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), + .send(.fetchDownloadBadges(galleries.map(\.gid))) + ) case .failure(let error): state.loadingState = .failed(error) } @@ -163,6 +176,7 @@ struct SearchReducer { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.loadingState = .idle + effects.append(.send(.fetchDownloadBadges(state.galleries.map(\.gid)))) } return .merge(effects) @@ -171,6 +185,33 @@ struct SearchReducer { } return .none + case .fetchDownloadBadges(let gids): + return .run { send in + await send(.fetchDownloadBadgesDone(await downloadClient.badges(gids))) + } + + case .fetchDownloadBadgesDone(let badges): + state.downloadBadges.merge(badges, uniquingKeysWith: { _, new in new }) + return .none + + case .observeDownloads: + return .run { send in + for await downloads in downloadClient.observeDownloads() { + await send(.observeDownloadsDone(downloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone(let downloads): + let visibleGIDs = Set(state.galleries.map(\.gid)) + state.downloadBadges = Dictionary( + uniqueKeysWithValues: downloads.compactMap { download in + guard visibleGIDs.contains(download.gid) else { return nil } + return (download.gid, download.badge) + } + ) + return .none + case .detail: return .none diff --git a/EhPanda/View/Search/SearchRootView.swift b/EhPanda/View/Search/SearchRootView.swift index e2cd9eb3a..23622e3df 100644 --- a/EhPanda/View/Search/SearchRootView.swift +++ b/EhPanda/View/Search/SearchRootView.swift @@ -221,7 +221,12 @@ private struct QuickSearchWordsSection: View { private var keywords: [WrappedKeyword] { quickSearchWords - .map({ .init(keyword: $0.content, displayText: $0.name) }) + .map { + .init( + keyword: $0.effectiveSearchText, + displayText: $0.content.notEmpty ? $0.name : "" + ) + } .removeDuplicates() } diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index 0ba8c1d26..cb78abdcf 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -39,7 +39,8 @@ struct SearchView: View { navigateAction: { store.send(.setNavigation(.detail($0))) }, translateAction: { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } + }, + downloadBadges: store.downloadBadges ) .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in QuickSearchView( @@ -66,6 +67,7 @@ struct SearchView: View { store.send(.fetchGalleries()) } .onAppear { + store.send(.onAppear) if store.galleries.isEmpty { DispatchQueue.main.async { store.send(.fetchGalleries(keyword)) diff --git a/EhPanda/View/Search/Support/QuickSearchView.swift b/EhPanda/View/Search/Support/QuickSearchView.swift index 5c60c1601..c4acd8ce6 100644 --- a/EhPanda/View/Search/Support/QuickSearchView.swift +++ b/EhPanda/View/Search/Support/QuickSearchView.swift @@ -23,13 +23,16 @@ struct QuickSearchView: View { List { ForEach(store.quickSearchWords) { word in Button { - searchAction(word.content) + searchAction(word.effectiveSearchText) } label: { VStack(alignment: .leading, spacing: 5) { - if !word.name.isEmpty { + if !word.name.isEmpty, word.content.notEmpty { Text(word.name).font(.subheadline).foregroundColor(.secondary).lineLimit(1) } - Text(word.content).fontWeight(.medium).font(.title3).lineLimit(2) + Text(word.effectiveSearchText) + .fontWeight(.medium) + .font(.title3) + .lineLimit(2) } .tint(.primary) } diff --git a/EhPanda/View/Setting/Components/DownloadSettingView.swift b/EhPanda/View/Setting/Components/DownloadSettingView.swift new file mode 100644 index 000000000..7e3560da3 --- /dev/null +++ b/EhPanda/View/Setting/Components/DownloadSettingView.swift @@ -0,0 +1,66 @@ +// +// DownloadSettingView.swift +// EhPanda +// + +import SwiftUI + +struct DownloadSettingView: View { + @Binding private var downloadThreadMode: DownloadThreadMode + @Binding private var downloadAllowCellular: Bool + @Binding private var downloadAutoRetryFailedPages: Bool + + init( + downloadThreadMode: Binding, + downloadAllowCellular: Binding, + downloadAutoRetryFailedPages: Binding + ) { + _downloadThreadMode = downloadThreadMode + _downloadAllowCellular = downloadAllowCellular + _downloadAutoRetryFailedPages = downloadAutoRetryFailedPages + } + + var body: some View { + Form { + Section(L10n.Localizable.DownloadSettingView.Section.Title.downloadQueue) { + Picker( + L10n.Localizable.DownloadSettingView.Title.concurrentImageDownloads, + selection: $downloadThreadMode + ) { + ForEach(DownloadThreadMode.allCases) { + Text($0.value).tag($0) + } + } + .pickerStyle(.menu) + Toggle( + L10n.Localizable.DownloadSettingView.Title.retryFailedPagesAutomatically, + isOn: $downloadAutoRetryFailedPages + ) + } + + Section { + Toggle( + L10n.Localizable.DownloadSettingView.Title.allowCellularDownloads, + isOn: $downloadAllowCellular + ) + } header: { + Text(L10n.Localizable.DownloadSettingView.Section.Title.network) + } footer: { + Text(L10n.Localizable.DownloadSettingView.Footer.network) + } + } + .navigationTitle(L10n.Localizable.DownloadsView.Title.downloads) + } +} + +struct DownloadSettingView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + DownloadSettingView( + downloadThreadMode: .constant(.single), + downloadAllowCellular: .constant(true), + downloadAutoRetryFailedPages: .constant(true) + ) + } + } +} diff --git a/EhPanda/View/Setting/SettingReducer.swift b/EhPanda/View/Setting/SettingReducer.swift index 34af255a2..d9eb84e1d 100644 --- a/EhPanda/View/Setting/SettingReducer.swift +++ b/EhPanda/View/Setting/SettingReducer.swift @@ -16,6 +16,7 @@ struct SettingReducer { case general case appearance case reading + case downloads case laboratory case about } @@ -306,12 +307,13 @@ struct SettingReducer { } case .fetchIgneousDone(let result): - var effects = [Effect]() if case .success(let response) = result { - effects.append(.run(operation: { _ in cookieClient.setCredentials(response: response) })) + return .concatenate( + .run(operation: { _ in cookieClient.setCredentials(response: response) }), + .send(.account(.loadCookies)) + ) } - effects.append(.send(.account(.loadCookies))) - return .merge(effects) + return .send(.account(.loadCookies)) case .fetchUserInfo: guard cookieClient.didLogin else { return .none } diff --git a/EhPanda/View/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift index ccfcabc13..035583bb9 100644 --- a/EhPanda/View/Setting/SettingView.swift +++ b/EhPanda/View/Setting/SettingView.swift @@ -89,6 +89,14 @@ private extension SettingView { ) .tint(store.setting.accentColor) } + NavigationLink(unwrapping: $store.route, case: \.downloads) { _ in + DownloadSettingView( + downloadThreadMode: $store.setting.downloadThreadMode, + downloadAllowCellular: $store.setting.downloadAllowCellular, + downloadAutoRetryFailedPages: $store.setting.downloadAutoRetryFailedPages + ) + .tint(store.setting.accentColor) + } NavigationLink(unwrapping: $store.route, case: \.laboratory) { _ in LaboratorySettingView( bypassesSNIFiltering: $store.setting.bypassesSNIFiltering @@ -152,6 +160,8 @@ extension SettingReducer.Route { return L10n.Localizable.Enum.SettingStateRoute.Value.appearance case .reading: return L10n.Localizable.Enum.SettingStateRoute.Value.reading + case .downloads: + return L10n.Localizable.Enum.SettingStateRoute.Value.downloads case .laboratory: return L10n.Localizable.Enum.SettingStateRoute.Value.laboratory case .about: @@ -168,6 +178,8 @@ extension SettingReducer.Route { return .circleRighthalfFilled case .reading: return .newspaperFill + case .downloads: + return .arrowDownCircle case .laboratory: return .testtube2 case .about: diff --git a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift index 5eb828b79..020d323a0 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift @@ -10,24 +10,28 @@ import UIImageColors struct GalleryCardCell: View { @Environment(\.colorScheme) private var colorScheme + @ObservedObject private var downloadStore = DownloadBadgeStore.shared private let currentID: String private let colors: [Color] private let webImageSuccessAction: (RetrieveImageResult) -> Void private let gallery: Gallery + private let downloadBadge: DownloadBadge private let animation: Animation = .interpolatingSpring(stiffness: 50, damping: 1).speed(0.2) init( gallery: Gallery, currentID: String, colors: [Color], - webImageSuccessAction: @escaping (RetrieveImageResult) -> Void + webImageSuccessAction: @escaping (RetrieveImageResult) -> Void, + downloadBadge: DownloadBadge = .none ) { self.gallery = gallery self.currentID = currentID self.colors = colors self.webImageSuccessAction = webImageSuccessAction + self.downloadBadge = downloadBadge } private var animated: Bool { @@ -42,19 +46,26 @@ struct GalleryCardCell: View { return trimmedTitle } + private var resolvedCoverURL: URL? { + downloadStore.resolvedCoverURL(for: gallery) + } + var body: some View { ZStack { Color.gray.opacity(0.2) ColorfulView(animated: animated, animation: animation, colors: colors) .id(currentID + animated.description) HStack { - KFImage(gallery.coverURL) + KFImage(resolvedCoverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) } .onSuccess(webImageSuccessAction).defaultModifier().scaledToFill() .frame(width: Defaults.ImageSize.headerW, height: Defaults.ImageSize.headerH) .cornerRadius(5) VStack(alignment: .leading) { - Text(title).font(.title3.bold()).lineLimit(4) + Text(title) + .font(.title3.bold()) + .lineLimit(downloadBadge == .none ? 4 : 2) + DownloadBadgeLabel(badge: downloadBadge, compact: true) Spacer() RatingView(rating: gallery.rating).foregroundColor(.yellow) } diff --git a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift index 95948467b..a563ea39c 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift @@ -8,15 +8,101 @@ import Kingfisher struct GalleryDetailCell: View { @Environment(\.colorScheme) private var colorScheme + @ObservedObject private var downloadStore = DownloadBadgeStore.shared private let gallery: Gallery + private let coverURLOverride: URL? private let setting: Setting private let translateAction: ((String) -> (String, TagTranslation?))? + private let downloadBadge: DownloadBadge - init(gallery: Gallery, setting: Setting, translateAction: ((String) -> (String, TagTranslation?))? = nil) { + init( + gallery: Gallery, + coverURLOverride: URL? = nil, + setting: Setting, + translateAction: ((String) -> (String, TagTranslation?))? = nil, + downloadBadge: DownloadBadge = .none + ) { self.gallery = gallery + self.coverURLOverride = coverURLOverride self.setting = setting self.translateAction = translateAction + self.downloadBadge = downloadBadge + } + + private var resolvedCoverURL: URL? { + coverURLOverride ?? downloadStore.resolvedCoverURL(for: gallery) + } + + var body: some View { + GalleryDetailCellContent( + gallery: gallery, + resolvedCoverURL: resolvedCoverURL, + setting: setting, + colorScheme: colorScheme, + translateAction: translateAction, + downloadBadge: downloadBadge + ) + } +} + +struct StaticGalleryDetailCell: View { + @Environment(\.colorScheme) private var colorScheme + + private let gallery: Gallery + private let resolvedCoverURL: URL? + private let setting: Setting + private let translateAction: ((String) -> (String, TagTranslation?))? + private let downloadBadge: DownloadBadge + + init( + gallery: Gallery, + resolvedCoverURL: URL?, + setting: Setting, + translateAction: ((String) -> (String, TagTranslation?))? = nil, + downloadBadge: DownloadBadge = .none + ) { + self.gallery = gallery + self.resolvedCoverURL = resolvedCoverURL + self.setting = setting + self.translateAction = translateAction + self.downloadBadge = downloadBadge + } + + var body: some View { + GalleryDetailCellContent( + gallery: gallery, + resolvedCoverURL: resolvedCoverURL, + setting: setting, + colorScheme: colorScheme, + translateAction: translateAction, + downloadBadge: downloadBadge + ) + } +} + +private struct GalleryDetailCellContent: View { + private let gallery: Gallery + private let resolvedCoverURL: URL? + private let setting: Setting + private let colorScheme: ColorScheme + private let translateAction: ((String) -> (String, TagTranslation?))? + private let downloadBadge: DownloadBadge + + init( + gallery: Gallery, + resolvedCoverURL: URL?, + setting: Setting, + colorScheme: ColorScheme, + translateAction: ((String) -> (String, TagTranslation?))?, + downloadBadge: DownloadBadge + ) { + self.gallery = gallery + self.resolvedCoverURL = resolvedCoverURL + self.setting = setting + self.colorScheme = colorScheme + self.translateAction = translateAction + self.downloadBadge = downloadBadge } private var tagColor: Color { @@ -25,12 +111,16 @@ struct GalleryDetailCell: View { var body: some View { HStack(spacing: 10) { - KFImage(gallery.coverURL) + KFImage(resolvedCoverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.rowAspect)) } .defaultModifier().scaledToFit().frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH) VStack(alignment: .leading, spacing: 5) { - Text(gallery.title).lineLimit(3).font(.headline).foregroundStyle(.primary) + Text(gallery.title) + .lineLimit(downloadBadge == .none ? 3 : 2) + .font(.headline) + .foregroundStyle(.primary) .fixedSize(horizontal: false, vertical: true) + DownloadBadgeLabel(badge: downloadBadge) Text(gallery.uploader ?? "").lineLimit(1).font(.subheadline).foregroundStyle(.secondary) let tagContents = gallery.tagContents(maximum: setting.listTagsNumberMaximum) if setting.showsTagsInList, !tagContents.isEmpty { diff --git a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift index 96be0877b..d055ac82e 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift @@ -7,15 +7,21 @@ import SwiftUI import Kingfisher struct GalleryHistoryCell: View { + @ObservedObject private var downloadStore = DownloadBadgeStore.shared + private let gallery: Gallery init(gallery: Gallery) { self.gallery = gallery } + private var resolvedCoverURL: URL? { + downloadStore.resolvedCoverURL(for: gallery) + } + var body: some View { HStack(spacing: 20) { - KFImage(gallery.coverURL) + KFImage(resolvedCoverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }.defaultModifier() .scaledToFill().frame(width: Defaults.ImageSize.rowW * 0.75, height: Defaults.ImageSize.rowH * 0.75) .cornerRadius(2) diff --git a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift index ff688ce54..4589c3285 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift @@ -7,23 +7,32 @@ import SwiftUI import Kingfisher struct GalleryRankingCell: View { + @ObservedObject private var downloadStore = DownloadBadgeStore.shared + private let gallery: Gallery private let ranking: Int + private let downloadBadge: DownloadBadge - init(gallery: Gallery, ranking: Int) { + init(gallery: Gallery, ranking: Int, downloadBadge: DownloadBadge = .none) { self.gallery = gallery self.ranking = ranking + self.downloadBadge = downloadBadge + } + + private var resolvedCoverURL: URL? { + downloadStore.resolvedCoverURL(for: gallery) } var body: some View { HStack { - KFImage(gallery.coverURL) + KFImage(resolvedCoverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }.defaultModifier() .scaledToFill().frame(width: Defaults.ImageSize.rowW * 0.75, height: Defaults.ImageSize.rowH * 0.75) .cornerRadius(2) Text(String(ranking)).fontWeight(.medium).font(.title2).padding(.horizontal) VStack(alignment: .leading) { Text(gallery.trimmedTitle).bold().lineLimit(2).fixedSize(horizontal: false, vertical: true) + DownloadBadgeLabel(badge: downloadBadge, compact: true) if let uploader = gallery.uploader { Text(uploader).foregroundColor(.secondary).lineLimit(1) } diff --git a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift index 784ffab3c..19032e382 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -8,15 +8,23 @@ import Kingfisher struct GalleryThumbnailCell: View { @Environment(\.colorScheme) private var colorScheme + @ObservedObject private var downloadStore = DownloadBadgeStore.shared private let gallery: Gallery private let setting: Setting private let translateAction: ((String) -> (String, TagTranslation?))? + private let downloadBadge: DownloadBadge - init(gallery: Gallery, setting: Setting, translateAction: ((String) -> (String, TagTranslation?))? = nil) { + init( + gallery: Gallery, + setting: Setting, + translateAction: ((String) -> (String, TagTranslation?))? = nil, + downloadBadge: DownloadBadge = .none + ) { self.gallery = gallery self.setting = setting self.translateAction = translateAction + self.downloadBadge = downloadBadge } private var backgroundColor: Color { @@ -26,9 +34,13 @@ struct GalleryThumbnailCell: View { colorScheme == .light ? Color(.systemGray5) : Color(.systemGray4) } + private var resolvedCoverURL: URL? { + downloadStore.resolvedCoverURL(for: gallery) + } + var body: some View { VStack(alignment: .leading, spacing: 0) { - KFImage(gallery.coverURL) + KFImage(resolvedCoverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.rowAspect)) } .imageModifier(WebtoonModifier( minAspect: Defaults.ImageSize.webtoonMinAspect, @@ -37,6 +49,7 @@ struct GalleryThumbnailCell: View { .fade(duration: 0.25).resizable().scaledToFit().overlay { VStack { HStack { + DownloadBadgeLabel(badge: downloadBadge, compact: true) Spacer() CategoryLabel( text: gallery.category.value, color: gallery.color, @@ -46,9 +59,11 @@ struct GalleryThumbnailCell: View { } Spacer() } - } + } VStack(alignment: .leading, spacing: 5) { - Text(gallery.title).font(.callout.bold()).lineLimit(3) + Text(gallery.title) + .font(.callout.bold()) + .lineLimit(downloadBadge == .none ? 3 : 2) let tagContents = gallery.tagContents(maximum: setting.listTagsNumberMaximum) if setting.showsTagsInList, !tagContents.isEmpty { TagCloudView(data: tagContents) { content in diff --git a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift new file mode 100644 index 000000000..06914e2fa --- /dev/null +++ b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift @@ -0,0 +1,58 @@ +// +// DownloadBadgeLabel.swift +// EhPanda +// + +import SwiftUI + +struct DownloadBadgeLabel: View { + private let badge: DownloadBadge + private let compact: Bool + + init(badge: DownloadBadge, compact: Bool = false) { + self.badge = badge + self.compact = compact + } + + var body: some View { + if badge != .none { + Text(compact ? compactText : badge.text) + .font(compact ? .caption2.bold() : .caption.bold()) + .foregroundStyle(foregroundColor) + .padding(.horizontal, compact ? 6 : 8) + .padding(.vertical, compact ? 3 : 4) + .background(backgroundColor) + .clipShape(Capsule()) + } + } + + private var compactText: String { + switch badge { + case .downloading: + return L10n.Localizable.Struct.DownloadBadge.Compact.downloading + case .paused: + return L10n.Localizable.Struct.DownloadBadge.Compact.paused + case .partial: + return L10n.Localizable.Struct.DownloadBadge.Compact.needsAttention + case .downloaded: + return L10n.Localizable.Struct.DownloadBadge.Compact.done + case .failed: + return L10n.Localizable.Struct.DownloadBadge.Compact.needsAttention + default: + return badge.text + } + } + + private var backgroundColor: Color { + badge.color.opacity(0.15) + } + + private var foregroundColor: Color { + switch badge { + case .updateAvailable: + return .orange + default: + return badge.color + } + } +} diff --git a/EhPanda/View/Support/Components/DownloadBadgeStore.swift b/EhPanda/View/Support/Components/DownloadBadgeStore.swift new file mode 100644 index 000000000..4a7ff6cf6 --- /dev/null +++ b/EhPanda/View/Support/Components/DownloadBadgeStore.swift @@ -0,0 +1,48 @@ +// +// DownloadBadgeStore.swift +// EhPanda +// + +import Foundation + +@MainActor +final class DownloadBadgeStore: ObservableObject { + static let shared = DownloadBadgeStore(client: DownloadClientKey.liveValue) + + @Published private(set) var badges = [String: DownloadBadge]() + @Published private(set) var downloads = [String: DownloadedGallery]() + + private let client: DownloadClient + private var observeTask: Task? + + init(client: DownloadClient) { + self.client = client + observeTask = Task { [weak self] in + guard let self else { return } + await self.apply(downloads: client.fetchDownloads()) + for await downloads in client.observeDownloads() { + await self.apply(downloads: downloads) + } + } + } + + func resolvedCoverURL(for gallery: Gallery) -> URL? { + downloads[gallery.gid]?.coverURL ?? gallery.coverURL + } + + private func apply(downloads: [DownloadedGallery]) { + let resolvedDownloads = Dictionary(uniqueKeysWithValues: downloads.map { ($0.gid, $0) }) + let resolvedBadges = Dictionary(uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) }) + + guard self.downloads != resolvedDownloads || badges != resolvedBadges else { + return + } + + self.downloads = resolvedDownloads + badges = resolvedBadges + } + + deinit { + observeTask?.cancel() + } +} diff --git a/EhPanda/View/Support/Components/GenericList.swift b/EhPanda/View/Support/Components/GenericList.swift index 3014dffeb..3857bda0c 100644 --- a/EhPanda/View/Support/Components/GenericList.swift +++ b/EhPanda/View/Support/Components/GenericList.swift @@ -10,6 +10,7 @@ import ComposableArchitecture struct GenericList: View { private let galleries: [Gallery] private let setting: Setting + private let downloadBadges: [String: DownloadBadge] private let pageNumber: PageNumber? private let loadingState: LoadingState private let footerLoadingState: LoadingState @@ -24,10 +25,12 @@ struct GenericList: View { fetchAction: (() -> Void)? = nil, fetchMoreAction: (() -> Void)? = nil, navigateAction: ((String) -> Void)? = nil, - translateAction: ((String) -> (String, TagTranslation?))? = nil + translateAction: ((String) -> (String, TagTranslation?))? = nil, + downloadBadges: [String: DownloadBadge] = [:] ) { self.galleries = galleries self.setting = setting + self.downloadBadges = downloadBadges self.pageNumber = pageNumber self.loadingState = loadingState self.footerLoadingState = footerLoadingState @@ -45,13 +48,15 @@ struct GenericList: View { DetailList( galleries: galleries, setting: setting, pageNumber: pageNumber, footerLoadingState: footerLoadingState, fetchMoreAction: fetchMoreAction, - navigateAction: navigateAction, translateAction: translateAction + navigateAction: navigateAction, translateAction: translateAction, + downloadBadges: downloadBadges ) case .thumbnail: WaterfallList( galleries: galleries, setting: setting, pageNumber: pageNumber, footerLoadingState: footerLoadingState, fetchMoreAction: fetchMoreAction, - navigateAction: navigateAction, translateAction: translateAction + navigateAction: navigateAction, translateAction: translateAction, + downloadBadges: downloadBadges ) } } @@ -74,6 +79,7 @@ struct GenericList: View { private struct DetailList: View { private let galleries: [Gallery] private let setting: Setting + private let downloadBadges: [String: DownloadBadge] private let pageNumber: PageNumber? private let footerLoadingState: LoadingState private let fetchMoreAction: (() -> Void)? @@ -85,10 +91,12 @@ private struct DetailList: View { footerLoadingState: LoadingState, fetchMoreAction: (() -> Void)?, navigateAction: ((String) -> Void)? = nil, - translateAction: ((String) -> (String, TagTranslation?))? = nil + translateAction: ((String) -> (String, TagTranslation?))? = nil, + downloadBadges: [String: DownloadBadge] = [:] ) { self.galleries = galleries self.setting = setting + self.downloadBadges = downloadBadges self.pageNumber = pageNumber self.footerLoadingState = footerLoadingState self.fetchMoreAction = fetchMoreAction @@ -111,7 +119,12 @@ private struct DetailList: View { Button { navigateAction?(gallery.id) } label: { - GalleryDetailCell(gallery: gallery, setting: setting, translateAction: translateAction) + GalleryDetailCell( + gallery: gallery, + setting: setting, + translateAction: translateAction, + downloadBadge: downloadBadges[gallery.gid] ?? .none + ) } .foregroundColor(.primary) .onAppear { @@ -130,6 +143,7 @@ private struct DetailList: View { private struct WaterfallList: View { private let galleries: [Gallery] private let setting: Setting + private let downloadBadges: [String: DownloadBadge] private let pageNumber: PageNumber? private let footerLoadingState: LoadingState private let fetchMoreAction: (() -> Void)? @@ -157,10 +171,12 @@ private struct WaterfallList: View { footerLoadingState: LoadingState, fetchMoreAction: (() -> Void)?, navigateAction: ((String) -> Void)? = nil, - translateAction: ((String) -> (String, TagTranslation?))? = nil + translateAction: ((String) -> (String, TagTranslation?))? = nil, + downloadBadges: [String: DownloadBadge] = [:] ) { self.galleries = galleries self.setting = setting + self.downloadBadges = downloadBadges self.pageNumber = pageNumber self.footerLoadingState = footerLoadingState self.fetchMoreAction = fetchMoreAction @@ -174,7 +190,12 @@ private struct WaterfallList: View { Button { navigateAction?(gallery.id) } label: { - GalleryThumbnailCell(gallery: gallery, setting: setting, translateAction: translateAction) + GalleryThumbnailCell( + gallery: gallery, + setting: setting, + translateAction: translateAction, + downloadBadge: downloadBadges[gallery.gid] ?? .none + ) .tint(.primary).multilineTextAlignment(.leading) } .buttonStyle(.borderless) diff --git a/EhPanda/View/Support/Components/PreviewImageView.swift b/EhPanda/View/Support/Components/PreviewImageView.swift new file mode 100644 index 000000000..1e0a548d9 --- /dev/null +++ b/EhPanda/View/Support/Components/PreviewImageView.swift @@ -0,0 +1,152 @@ +// +// PreviewImageView.swift +// EhPanda +// + +import SwiftUI +import ImageIO +import Kingfisher + +struct PreviewImageView: View { + private let originalURL: URL? + private let maxPixelSize: CGFloat + private static let defaultMaxPixelSize = Defaults.ImageSize.previewMaxW * 3 + + init( + originalURL: URL?, + maxPixelSize: CGFloat = PreviewImageView.defaultMaxPixelSize + ) { + self.originalURL = originalURL + self.maxPixelSize = maxPixelSize + } + + var body: some View { + if let originalURL, originalURL.isFileURL { + LocalPreviewImageView(fileURL: originalURL, maxPixelSize: maxPixelSize) { + Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) + } + } else { + let (url, modifier) = PreviewResolver.getPreviewConfigs(originalURL: originalURL) + KFImage.url( + url, + cacheKey: url?.stableImageCacheKey + ?? originalURL?.stableImageCacheKey + ?? originalURL?.absoluteString + ) + .placeholder { + Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) + } + .imageModifier(modifier) + .fade(duration: 0.25) + .resizable() + .scaledToFit() + } + } +} + +private struct LocalPreviewImageView: View { + private let fileURL: URL + private let maxPixelSize: CGFloat + private let placeholder: Placeholder + + @State private var thumbnail: UIImage? + + init( + fileURL: URL, + maxPixelSize: CGFloat, + @ViewBuilder placeholder: () -> Placeholder + ) { + self.fileURL = fileURL + self.maxPixelSize = maxPixelSize + self.placeholder = placeholder() + } + + private var cacheKey: String { + let resourceValues = try? fileURL.resourceValues(forKeys: [ + .contentModificationDateKey, + .fileSizeKey + ]) + let modificationStamp = resourceValues?.contentModificationDate? + .timeIntervalSinceReferenceDate ?? .zero + let fileSize = resourceValues?.fileSize ?? 0 + return "\(fileURL.path)#\(Int(maxPixelSize))#\(fileSize)#\(modificationStamp)" + } + + var body: some View { + Group { + if let thumbnail { + Image(uiImage: thumbnail) + .resizable() + .scaledToFit() + .clipShape( + RoundedRectangle( + cornerRadius: 5, + style: .continuous + ) + ) + } else { + placeholder + } + } + .task(id: cacheKey) { + await loadThumbnail() + } + } + + @MainActor + private func loadThumbnail() async { + if let cachedThumbnail = LocalPreviewThumbnailCache.shared.image(forKey: cacheKey) { + thumbnail = cachedThumbnail + return + } + + let fileURL = fileURL + let maxPixelSize = maxPixelSize + let generatedThumbnail = await Task.detached(priority: .utility) { + Self.makeThumbnail(fileURL: fileURL, maxPixelSize: maxPixelSize) + } + .value + + if let generatedThumbnail { + LocalPreviewThumbnailCache.shared.store(generatedThumbnail, forKey: cacheKey) + } + thumbnail = generatedThumbnail + } + + nonisolated private static func makeThumbnail(fileURL: URL, maxPixelSize: CGFloat) -> UIImage? { + guard let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil) else { + return nil + } + + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceShouldCacheImmediately: false, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: max(Int(maxPixelSize.rounded(.up)), 1) + ] + + guard let imageRef = CGImageSourceCreateThumbnailAtIndex( + imageSource, + .zero, + options as CFDictionary + ) else { + return nil + } + + return UIImage(cgImage: imageRef) + } +} + +private final class LocalPreviewThumbnailCache { + static let shared = LocalPreviewThumbnailCache() + + private let cache = NSCache() + + func image(forKey key: String) -> UIImage? { + cache.object(forKey: key as NSString) + } + + func store(_ image: UIImage, forKey key: String) { + cache.setObject(image, forKey: key as NSString) + } +} diff --git a/EhPanda/View/TabBar/TabBarView.swift b/EhPanda/View/TabBar/TabBarView.swift index ecaba9e50..ae294b555 100644 --- a/EhPanda/View/TabBar/TabBarView.swift +++ b/EhPanda/View/TabBar/TabBarView.swift @@ -50,6 +50,14 @@ struct TabBarView: View { blurRadius: store.appLockState.blurRadius, tagTranslator: store.settingState.tagTranslator ) + case .downloads: + DownloadsView( + store: store.scope(state: \.downloadsState, action: \.downloads), + user: store.settingState.user, + setting: $store.settingState.setting, + blurRadius: store.appLockState.blurRadius, + tagTranslator: store.settingState.tagTranslator + ) case .setting: SettingView( store: store.scope(state: \.settingState, action: \.setting), @@ -116,6 +124,7 @@ enum TabBarItemType: Int, CaseIterable, Identifiable { case home case favorites case search + case downloads case setting } @@ -128,6 +137,8 @@ extension TabBarItemType { return L10n.Localizable.TabItem.Title.favorites case .search: return L10n.Localizable.TabItem.Title.search + case .downloads: + return L10n.Localizable.TabItem.Title.downloads case .setting: return L10n.Localizable.TabItem.Title.setting } @@ -140,6 +151,8 @@ extension TabBarItemType { return .heartCircle case .search: return .magnifyingglassCircle + case .downloads: + return .arrowDownCircle case .setting: return .gearshapeCircle } diff --git a/EhPandaTests/Models/HTMLFilename.swift b/EhPandaTests/Models/HTMLFilename.swift index 243eee1bb..481bf53f7 100644 --- a/EhPandaTests/Models/HTMLFilename.swift +++ b/EhPandaTests/Models/HTMLFilename.swift @@ -43,6 +43,8 @@ enum HTMLFilename: String { // Other case ipBanned = "IPBanned" + case bandwidthExceeded = "BandwidthExceeded" + case exLoginRequired = "ExLoginRequired" case ehSetting = "EhSetting" case galleryDetailWithGreeting = "GalleryDetailWithGreeting" } diff --git a/EhPandaTests/Resources/Parser/Other/BandwidthExceeded.html b/EhPandaTests/Resources/Parser/Other/BandwidthExceeded.html new file mode 100644 index 0000000000000000000000000000000000000000..28d269f0bcec0a1dcb0bdf166d223d257efd011a GIT binary patch literal 28658 zcma%?tLIlwP+h`atIt1xZ=~hRFbf=7xRzONc9o-0mius{S zR8S-o-TnRj2lw^-JkIOcbsoq0y7Uc+8W-J~K;OW_>_Atv&dmL|%>~-&;rD7g4*C?s zkDdSaKF_`Dr_yrV8a%|>ZYnQyPoW?HUBULbqCE_fn9vuds})Tqtj;Zke( z)Ys@2%k7aSisdi2yP{`qJpFIKFV^hghwmr-w_kq$L#M>WCnP2%r=+H(-_6L(%FfBn z%P%M_DlRE4E3c@$S4FL^sjaJzLp3%vx3soBc-Y?2F5lVR)7#hD^|=4pbJTtP7em93 zIv?fWNl_z(ZJyj%XTy7u$qT&x=V)C%S6@>(x(?D)tQVsI1Jy!Q)b&#pP+&SaKy7R&A znR#9$I?yn=PF40p?QPu5vdE}#OFYjJafxV6r-{P$`3J6|^FW64IuaV+n zAuzjT*`Q8!XqzNABiiQsuVt>Fs&uU_9@b!{0t0M%hQe7uP6VDCxN9}B-o0>QM zb9nme{8+K*=)L4W$6tqM^eZms|BX^w$rr!+&82(qP1yIB5w)M|{vFbDQ;2s288EH^ zFxj!I^jlQPMpq>3w+6w4Vgg1ak^RSdWf+gTL|HQ9?YC~Xf5&Zqh$*d(#AZ1|AHuu*Mj37s5s{wb4Gnou{za*WUem5bW7v{?(Ef&1NNEuDRuIcFO^i|Me_+FWql*8|)WZSRyIRW30@D4> zVzTHdaY8lR9|26{u;D!oVBA;EAx^PG<`m!?dG`0&~(`Q-rJu-mON{3buI;;W4_+$ zfBfgL`R;9#A8#Dig!`tWww^?E-_k#en4-MBwKFBf>7?<2>*+JqkIE$qQJYz{tv##x zG0!#V^|+V95gUzDt*1j(DYHKpuQ)zXTkFzuQ5&;$6xLkr8M@K8m-%+~@T=}tmev9B z`-8I|(-*#<{hZTR`g^!&d-LzFCGUrSk3L3x|9iZgqV(@%z4+$8)9r?b|9b(I0c7b@u9()a{w^)8i~C}#^tL}RsewHEQ3Jk zIDjuO9&!l)(CbtLGmQeHpaA|13WOt27lhjf#O)|>0+zxJ20$Dc0GNk@1WTY(z^*G0 zSA%$v>T(=XRTLtxF#s&)q>5|Mm!iU zHb6EjrW|Mh+%6vnxsfIW!!2iSXHXQa4+!j7O67e2DzHB>2|)q*VknBtohBq0ZwW;i zP!Q^+?9gUXvSOl@wJEYxJF0Tyi-egqW| z*=4Vj;BEx-1(IkUDg{6$z~*K^l5%(jD48}&QYQvF)0fL6_dQ?)0n%(1rH&o$XVHL+ zW0XgCteGq;z3`bpXrcj7$r=KRB%toIgW4H3WJPmmH8-rv*|;#kXsI201}7L=NYx@> z=3?H26pp-Zw$c88%hmnaPQMqstOP=nKv3=;kgJ%wuO`j3S-J6j*D`2U6Zr63skgdS z+FZ$FmMJY55KIIZqwZdX(T=E#1PqRmf@JNnPHYnfSt)e@;6BbwsG_2|@{awSAFA@C zzzh1(mlC3zn=eTGrebeTY7Z}hvPd=jLpD;BWT}lpdk?^-z7rL)N0oCDaf)enG&mRO zj)Cc(i8%Jt(Bwkn{G|ce{~~9B*1RgQL@5Me&%L&cmUoXl!g^{r!Qi3Nj6q=#>V`A7 z^X$00@!M{OQM1n{LqO}V@5^N}BOsXkv_bAVk0wD^aIIAaK{&2le0hjn0w_wz+Pynt zH1<-LatG>6r$K~pvRviG42=+}9bM-ezo6v5 zZHUpGLDmS(jMe}E(yHkN`u3U>I<~8|H+~e4n`KONMEIcK}e>%s9oF^6Y$Sd$^ zO*;m8TMrh8-PhWYCuw`VpEqlbL~v1@O*l?K?4R%BI3sV0U5(|?lhPqK_-q9i9X0dc zJciR(1zrO}0^Dh)4D1Rr|J$_Zk^1ZQAO$<}Uw#s0?PHj#v}wHbR{KX$#dZbXEK9Nn zb_$91@FzSUd&|)=kV(WWN{$DesXM)3;QFFB|Miu3$j6?CiByK`f)38R)-TnadkmWV zui;+Jee3y!&5ilJ7x?7^M-h3apQ-gqwZLI#Gsdq>@Sa`7?}t>W*oK~C_X#M}^}|6$ zTcHJ;V=5h0B1Y+epS=Ib85s;x3~lnzLmmvku2h&`ph!RJA1PEnDSW+HJ?#$hwvhso z2g812MFnSJ`y^m4HmE2_kyL0FJp7EVnR|75h@ruC%eeQum&Ld)nG%+_)N7-}=uJQB zibanF7T1Rr&`45%SrgW2mB-W=?~{ET#YxtAvhEQ{1pGoRvXZ@Qc^&SlS!f^XtwD{1 zcw&bnLx^NjLsy8}dML{nx&OW%GKWB$b7KRHKsdvnp@w=A;K&GeaF-4n4c|h+mD^rF zTO-?SIMV8fJjq5#Lj33BK$5hhEGhPg=!q}Fo!~>H_7n2n__af?adySPmrcte?5ETb z>=IUB6aYuX%LM|yNd$Epuy2_4Yp-kZ|2U!%^wjj+zH8mHXri3%Fx!_phlV(u>W~J&ol%&!dQ?Io1-R<;|`>Pg* z3XRccb@6XN@}55B}pEeoFr!FU@a6jJ?N^#dSV{HFX^nILx2rs?P~4Lt`W^d z!!H!+%+(P$$E>#ODAWRHAQ8zitcUb5iW)U(>5WR0@P~zx(rv*1pkb)j_VXT@9m9s; z{o7I`GP5dR@mjxp7rH(lh7h_HJwdv5-icM zMMC#m-IcXN0y#|Y+b~YjAZ2X{F5(R?@HGCNWNo5q*1}lepk{SZ^*V&&H|D7$8U@a3 zh~O;_Q7H(DuD>cisSjok6dxK3Y}RF_6?5Cv#*EyP!dbn58iB*S z8xwqhNeMOo^2+J$021oL%CoD}k&pVM5&`^BSfMEE`2JF)t zf?+Uq^EPS284lOg4fVmrqur1v)u~}I`AyykS`iHuEwyYbW@od7r%2DaD;-^XG3@ko ztLFEnBhEJ*LyMF$a)qYc5iN>MdULxR3hIrgL1B_w%6Gkf~W%@S3;l55+ zF4VBzMf@nc;qB`aS{yB^5loAT_j<8SxYr}ft!Ynu2KRF6gkifx+h z)EQ5XAxqY4yhZ>*H?ZtY*74K_PsD*n^s@O{6-&*dnT(26&0)jV#dzPyLQ=;YlhdP8 z%vg{_m{V(LBKwJ+J%8bQTNCjnYKE#O_l67Wz79PBz`TW@80yV>#aKvQHRH!SoN`4o zR3{jg#c~_|nY3y=sYtD-GSl=_M-rQcOJYKAJD(H@Aeq&*%(%nM%(zYGyiCVElk^*? zEfhn#dkijoJJ6MU2Z1W0mLkuGhTsIv5Q$H<_;f1OleP+;w$dydH-J9goNcBelw|aa zb>l%tFIJv71_r%7CWISTycbmW`P0zQ`|kP-1p83k9UpJ2U#U~&2C_+dy^KWqLXk(E zrB*ALasLV%R&QKi5C1D9lOG;zyEl9x_$C28eQu2yVD7%NajOmt z=W2lWl|m7-6ARzNUc@XC6a&)O%MA&KI@by+A`Wr=cH&PzpR<$4)j@OwybC zCtE*GJ~*3f7nD~#=n6m?)8_qW8HN{^d zuui8 zdJ>9*0&HD+lH=Ky0Zzuz;2?4@Ul&*_5j@NcY;0T7BaP<9BUGT7C(T4g>OBQ!g9$tM z^+KeF11YNS0bg@jMQDujpZef#Qd)i6zZU(tWCE+reA$GF)w;|5;itpv_n4YpF}$<@ zW72SeO)8U)rV3ZiCt&3@-6g~{dMg! ztV%HHwG6fS?c?Z#i#EE^Hi_I1swB#cw)+DdbG0<*HQpz2Ta)z*$?p$|Kcq_9(ty$C zUTWG~<GJS1K@C(0@hf~F?Fk|=@{$FS?-!VWLhabOj{*RC%CKK#q>*-496L}I# z(J9w~mYGHQ2+QhLzIEvRinkxVC&oQf9R-#uGdbgQecNsO@t0XOlcUv!p3;Swiv^a} z#FjxhtJv-eK?PFFap}7EtHTYerzr-SBf1W6KjOJuTdc3Pbp=b}9@8%{y@pBXT(s$R zOuE|lt1AIb;E)bj?WpGyuK!jS#vJKZ;*sCc;icG&6zDN3fR#O~yM+ifBZbGdas?B4 zfceQfIJyg8WZL+~{%y1u(}b12Q!OYx{Kaoe1?uNq7N-vae!Lvi_jqp+^|$4=cclJt z&yw$zH<*vFwD)v3{gc2!71!kcGWw?)Gk-3=+~ps|>%U*H8nxNZ{Z{YEX7mkAV%xFL z%O+!`&5cHVYaz)H?MAS(wE2$e4Y6FEicR0gHrIWV9K${X z-{nfnJ@3hZ%wabbPPn->fT}-xoyG2hE}OFwv6m+5z=wHui_|-1LG91B3UQ8=zh*G% z-H$F*qPimi5^*RoxJNODzi)aQX7TiG1XjYYH+1!TTQB1!ll_Uv6YJ}2B{l_PF;f|a zh;VDauhvRNdQmA)6r#h$UOU$oA#D#AVips~H;j1C-EfbwQ}A*Em`pi%-nDi6F(#It zK}=`w-U#XQ8`(%p&$(LaMfhNQ)yWgbJ5l)5wxD>C`e|p6ekZ-|374m}Tt{zl(7oeQ z`RTvC0@(Uuc_AAo{OO~s>eEhV{lm(!)mUV54gIG zh0Si(8Rzvx#_YU(b-b-1>GIpxiSzQWjuulA*K?_M4~t45Y}>k`h4!}D4w!y?+_oZw z_iIUgC44zNsnDbSUTMnf_~FF4&%CP$BfU6V>tmO*=2HiR(I3BG0T_QLPn$7qfy44uK(AId}v_#H3^u&#P-*)+HL5S=uZOx3pR!Ap z8N@J=`aIcsbU`Whk}9-dV)eX)#K$5ymO&y@BcNw{@dkgsl+Z=ybH&Wq@hmIbgBQDa2h&9f;ILp&0-w7>?qS5M5ScjDv}Z z@Ig;#lkurgDgk3htcJk(xJ`uEfp|STD4H&>fe`f|a3Fbzd{Nk%DnM3z_bvs{Rl%Ud zVW;E?lyQg9o3j!QRKA(47J zh8t>iMD5Lo_Jn>l0mo~@J7H4F0IJsPjYcvtlM?((Hx-e{Y~Y&Xh`$GF{QB#+o(2Hb z>d1ipbQ-plC)zVMHoAHs85*|0%(1~w_r3HsSVNiXu7Md*`OI7=y{k+;%>&shO^#P? zvulBDxGWI|ne89$ATE*UIZk5ZT{yrLiwF3?73=1n9NK{#m$G*(-bIN2l5jH9t{5J? zRS~Mr1!#=J^5Mb=4V(@b`IctGa5S*OU3d48=Nz)y19o|WTZ6QEPxxTHdwW==aU2b z_bEo&A;h%<--4ZA3k+C-a1sKJg$@D+)W*(@)w{owWFI<-{flp|k^GuC*rjp#b$npT zfC>s(Q?T=XOrzQVdK|4&GC;2zAr~JwLh!(~I%g&V5NK$k?(=|)+rfEI1(jDITa+@h zqnAjCZIC0s(Qu`A81XHRcjQaMg+~XcA0Y|ChwmT!tI&8AuOJwDgC(?{8a`cDhj$!` zkx%j%4svXb??0l-_>6iS$;X2)gty-0BsR3VRGT4t69PaREg$fZiTE#>WFM#U%SuT> z^dueS9k_)ji~KUm+DYte1DG*VDdpC_BqAmV9z!*vBA8V>%+6ux=;sZBg_?AW!a-&VQZ#I}azwflAE z(2hl2`-pq^rt+VkOqwngdPn?|Nxi~)r1V9{75|x^5@RMN55cyobEaAUG$@Wo( z48VJh)W#1%UZATd_NR@IXIvUMZ9bI(ST8UDtuZIEE8p`|RMjjm(8WMgsX&+|d)Hrv zi?InhktTs9ptE$fHu!(%h}Pg0iBYd(or!B^90lcF>5P}yaq$M%psjz?BtmuQc6NMR z_3j1Z%x?U=#T?YU-X(7?uJoFNocKqdN+{C0b0CqzO<5H_JWMYryU?gTpE0w|ikJ;v2sG-qHZFA$iT?R`8(%BF#J4!6m+*BzQ+sfYtw22yEhA;b z6Giz{lA7|fl!k>{&cVr@!p>j60f@%bwIlZ&SnB4LEnOV5dM!%ZIZ3c{15AG2^r1CoP{Hiijba5#S;KYO(_Sj{Nl4<9?fU!J(5AKLc0*AcvHI zbd^?9V*#t+lfBEbV`-3o%if(D6vCrM@C5<9)KOz zJO9F3I65AZx(iQ!g@iyelwBj8b$%Nq;a}b5bcVt&h5OS{$rqR9p>}hXth!SosyNx9 z8H5Tq0mI0geE+UVzAE1`uMZk6Z+Vfjczdl%J+6k4&%T)G{#5Abhyud9GTRzDreqc5 zgfgpq?U6!27q~n^?!_ABZmY{NCNeOPPg6c?<_nBs@5l_BEFQY1m`1EQQFuOVz_i8#=wv@d`?n+H z5p@sS6dSF%`#A|8Kiw1`QbP<2D(Jq1)}gwgdvtI`LBK}FaXB$*cM~7 zugm@rcP6*&Lzb1N^S|yOnf@^XH&Cu(%hB=D31EuF7g#EmTv0VJoKQWzN7#yQEY*VI zjy{jDeb|W0jQ1iw9+$$XY0mQHp$vZ<$h%jGFzj^_i7{9^B4Ha|{_N|seH**rk^q{JjNJ;_;!B?RcRv2nV$fsyb@nraRnFs52n<-`xLPqtBDnd5!jufN;pJN3 zH-GjLQ=A9FlroQ6L^#OKOxO%f)%yo`C~9vY!4{!7Ge(B-Y-6wCb%NhzZk?hWV@MW< z&)TYXLhQA-r<*EMzaJf^#X$uP!JkNFOhl3e& zFv=eg=9i!CwobHp<;vP~K;5-i(Dx0lMHN}HtcgtRaYoELs$b1z(D-vO{aJf%-b{1i zeI;Y?x41w%S|!tgrNGUmnTq%7%fgHDk`F($*FZY=0-HCIf_Ky>A0=qnu&&g>9vLj& zg6h7glEtP=fW&?G7@bbzK=SmFi9RCrBtg&uMAWbtH5op6C-BY`a*02W*$tn|KZWF~ zy5f`qv7TTXPo`JsAC}O32>-Eh4P%u@PV7tm%S^1`N}0pw_GRX~lh2_mQBq$i)fJRu zS}NqD$$LBF_EON_J!Z&KJXcWB&r)_=o$hn_0j5+dULUYXk zgE#7}H8wA7Y@bqsZtap(8Ty1GlA1PU7Nl+Fc=B zT%jw|#}ZxiwAR;^u-44fO5?M}sql~YnlK1VRogy=v5fjdy_K5&M2fKU*WB;N$H&G=kl}}bk%cg()&;-Pb=bqDsBA` zT*(JfeV1^vZgj<|7*&#dRbp~~2>?gl8&V!!BF#36+MSmyz0%F{BsKrx&L{K}5I)g` z4ZsQbFyCS~vL(OQ+oN4OBm8|g$2tK<5Ui{+-`UQrJf@`xRn9r9LYe*-021F`H-K{$ ztoe-N(7TX@+{AMz56I#wf6$0)z*|n}2X4N&3iA7wFes~pfmH|X4i##v&8T`<=q!=# z17A?iH4Nm^SH6!dC3vXVm6`!u=}R-B9TL4DJ50%%;NmN_uDIxTAWsIck1*IJibn?T zDbxtLo}Q`v@hGRukmzf-f-yjbR@>lj?<8ldP)Ez z%f9YkP#mw*21uirDbbb<3wp8~ZyMwj(-g;U5Qof};Gyzfj?8xGh(E5I609S!V&k;u z7PYac!9!jCKz0F|o=e=i6jsMF7hpLVWJjku5~~6X2%%^@)5(P!m+XVatB~Vny)@)3 zdD38b_3HoHVbyo6%t=1XXjB2JBL^^2G%DoMFBKiZp4>IT<5N+*XoOzAT0`>5%-6`g ziV`A+5b&le?eewt`Hxcmx;#}fnSrP|%@e8Y)**UVx=BE)lt_*qb7Dg3P(6QK^K1Gj z&tDVv#mb>~>2NZ%cV;2*)9mfr12%bCJ|;p&m$s)e9HxQtSdou+;BdzZuKVp|Ha-{! zIn^p7jVl-5P>(yqRYfcQ&xVa7(Q;_Rc(`eXnJShp0TT%yRQ1g84{YEcN$R#M<-hG_ zj>_~n*tY$Fivus@>8^zRN}2nZm5Fv;lho%wGk2_n4=^(sN$6Z68r*%-c#XhZh97Qx z_u*rhYvCSnBX^L!l%`h1_fCRF(lGNm5zriDjax?cHcn;+t{P(^YhKROmo;>ZYMdSFV$=sP{mqLr^eL&l4t{F)+~0oLIDT?bebaYvN`#-RZgN-9RbW zsc=KCY2K{cm_WwWnG%zh=L~z8?5(a5?AKwYea}uV{TGRA0^>Ay)?@}Z#?SN3+UPF_ zXN=&`MEGPgvufQh?B_=p^ME?MK=5h=*(JJY*L^4VN7LQ24!?7XW$a`J!OQ7)TZR8y zZz^mwuuFtK+=au`%v)C`ue0(JA5kjV0Z$y*}9M@Fn=RtnzlsJp&^~vjpq*kNC61 zIoEfo6pUY#C+4%;x1?8WMGSO+8BBYVX_q~#qc_iQWiIQ)O3p*b&^^Z$ZAN$+IWAu- zTr{zrD_qt7K*45xnIW-xp_)dnthreOFgUIL`&TYmp7Y5S!MHkC`Ec+Z6N6kt^5ZDr_EKJrl@ z)6;#Xp+M%TeddKgmJjQs_?m;~M2RtD`yfFv72|;{!5BTzf_{$IYYl8$@4g?+r2|hg#910Se zIuKe268>-?e1+(xqs!{X!+D@ABF|D94HonOHGS5xe%~Hb089nPFHQ%TbQwB#GnFZ( zLgeezmIuSh3kTiV0@vf5$0_YTPJxvS_W}~sFmxL03Dw$lgx&RKa^rVbsOy4Xu;V8v zOBr9q5iRukl4`)f%SK!>cErR6S%rD+oFm6uKWqZhF&CqCe&)Ri(pk3Sc0#97I$%5* z#cFnSX4Iy-2S2PU;@LF?CQECltqm{KODL|gZ*Mln;uHJR7oJ^YS!!OpYT)~3aFo=Q zCM)~yN}Iuxd4}S8Gc}K|H-_y(Jkmz{(o3E}(-ru4)3#YhitnuKtl2F=0KMb^dA?!U zFj;)G%j(SmrsfRhylPxPc&Xl5N-aa)4Jg!f7V6UOO~!8Nh1jRI>DTqJ*!xY26`B=c*XsT>w^aFhx;f9}R|zMC=La(!s`QtRP9(^}DdD&L z%%wYS99;P9x>4O}LC!Z80KW^yR%=tF9=DcSIa&yn;y6I|cD%R^9w$)lE_-T$RP z_v-k}rI`k@{F%L*g}aOlmj%-_of)>nRCbg)`m4#G`K0F6)M=X!%5SUX(|nnYVLoW4 zmgmq2e~dsi4Mz&UXwNXO&*s({7MN!GTm5LMh7xe{Ig0BQn+rqQvt%fzwX9fp)K(`UMZuxUXj-z3x4~&%Sj-{4FZn4oD%Ruc zT0d?q4EAb;5K_p?rqQeTq4!TNGD^N3*woxlM5nCW`&0Bz?RawBXN_b5`%`pfv6<^SsbseA9y+7iT^ z{#*IWK=A8(G60BKYoJ*wll}W&Vyg|zTrZyPM3hBV6Ti|}bIth3vx{%S8@23u>vu4G z=_dV&;r$}q!@FW%)7Sb$?yFw4(`<|BMch9;YGWuCzKqDTa|btly|4vanwiUfFwBY9 z-R^qRnPL2dU^7?s2;9b~zC~NDw(<4d(6(i?oJaYR!t1`h1`*bA{ zYS-mlmi3SeBq7kwWfcCii5aqswD`V+)_Z;Z$(-$ITFZh87oX7ZmtsHLZ#rg~L)K3Q z1+gewMTpo3dLehJEF2%tUbvZ-z9S2Z%V%wk%GkygC1<=jV`c{585EmCYw#6M-~Wlo zG-_CE{*^xC*Zc9oF%#?EvGvZ=46~NEdp&`-N&vi=W#0k57|Re zRmF2Cj8?%VeUZV-Vy>$=56j~@6i(Id@p)`X4(iDmg>jz*50z2bDdP{K`HG=vIWbKB z?(zF@pnMfK&9~A7->d0=99%j#r&Sw3#U0Q2Jxdt}w3hZ!7b7lv0v)qAUd^EqpzIpJ z?ZzOECPEsdJ-@6(Ot=##20f8Bw~W7ogb7h-524^l2wi!uWBD9~3yjj83pYL|atm5O z--!?G7^dmLc|XRxen|vw?R`^7Xzv&vN`;*7T(CmYUZgT1j5sD|J@rG_IPm+Y`AM2Y zMt33(M*;4IA%mxd1O`B+54ItX<X3zle`9dWapW^`o??S|rliDdM zaT$vLt%g#l_u>+wPX$z%UW`-TJr%`Fuz=nSe!Rk(tf+$&(LC_vpkUhQ=w$`y4MrN< zFMJMQ@WE48_^SB2`dibB_8dC#l5o#gfNwRa5W)ZKJ|yn2l8UqaM(Zk%5O$U*FqLCo ze1|~1!45jdpr|w8iyK7Uq?)$&9Aoy>&{Dsm)({-A^7w;(^V`jbRAy2giVc8s!Rk&}Vy+=s|=bVN~6foc9 zt`j$66? zXJy(^cGf^af1n)GU2Q$^7DdDHDZ-xH{9)3jM>y@Al^BKMbDOG^D~Fx3;u&enE4!L9 z?eY6vd#(PEu(g4c9LUX`4q->ChO3>^v&*DB?T2c=1;R(txm^fMb$OrK6>Di5dS&lm z1Hf4m@>K?`5KkKgdxYY^X6JLi0qmP#ip^~40^nIf+}}c;YfIzoks4=mK|YSs#IP|E z0)O5p>?{?S$cu_NK;9fM3UH(t&3I`8m|8$qO)&TYiit26`pH-`4E4bF!`s}P-xBDA zE8m|@pBS5LTyTCP>=vtd{#WDu6A>oR4Nnx5M(=b+Uu~cifYq-z&>jiP`V9`*eCcyr zx6x4#l|H(y{rkqI&@v5|WjF@DdAIFRYMzw5hgAVy9xwhya;^>+ht?;yqqJ+EWQpi% zg>L2xhIP%#m8qNkLwp*vDl=Lvl)7fT)m%$?$g-VpX`MQKv$2^;g91&4At|YK1LA7! zGKrcTKdR?Wj4-@zQy%?s=-1C+E>q*FnX@(z^D-d@c_5m&PiHT>%kMnOumBR7Qp-n4 zx$|twF$E6L@JpLXC5W+S{SXllb{S-;I}m;th~qfhw`3Xz`7Xa^FH;3DDgyeulhUoB zZQ{zBLn7j0iBA~O6!y16=G_HBsAT#Q#@)(f!k;fMEzT)PFmf_Af}(bzQ7sje-A9ch zk&647Q>+CiQ$XY6+}dZg-cQG~;qQV4nR$}rVxZ-G*>5={TX#^|aeBHCCY{(qzj0s_7QPk zbbRq~R}T5XO3TQIE}iL}vZY!WI&m^6o3cy$ zHAAj4FD4Aqs+W!0bB>wOzyl-0xlvsut&X(@2V*|7WX*aA2#sbnD0$aBk8qn#2i7R1 z2l3QaKW7$^g8un|nChX^*)Cd^GCgy-kTl&NllX#R(fx5~M zv9qgZHL$q y$|)`=c|Qo>16f+a}0XcO+I#f+O&!xPK$M1ENJ*Q-fbg^F5sS+cfq zpe;}J;-Z=h(|ZFN@eT2B{BHe^?^9>Q$Tg+clc2BEJ?p~+-4>Q{7u81U(4oI35eWs$ zz%ZpHq?KOw#TvhwZ<1rz~tbl#%lU<0QilV8YI(xxOVr%qUJ7f0`pK(s@jD=#lt_@wPvu`1dR92!(?TxjcOa=Km4hQ@b@@CPDyk5HS0aok6jP?SR#Q{n+%!bWiL9}$~OC$0yNev_&T zKa~d%T(F=g55SI+jkqTQdSCKYMQIAD}Mj zc^>LSsR-Y!9=uSjTAYK5q2ER= z_6b&^g1AK#Gj*PqZsKw#n9Zc^;7Rj`S>GU8j&j z3k|w%MG7$or|(`pi+?Jivg>gDhfV?Zae08FgyiLmS9(H(dVJiqu%r|##h_;Aus!b; znb<2g=4!N6en*cbv)}AIMQz=Z_Fq>!Z*trJH0$Pp>f_v-_%r1kp~y-GvSu8E;qX!g zpR4Gsv8Pyd{n1KMWtW5PL~>*b_)ZDS72YN|Tl>+^nQx%d*nhs8nz?{e-9y%MxuBbk zuW!!&a5qgUbxS=x%I#QkmyX53CgS|li!A#ua<#^M=4ZJzfu2ngHSAer4aLzfVFmpM z!-ETXfMGojs2b~$|8Ib6^p{L~o~q_D;6O%}M%eq;bMF1rh0jSnE521KCAG!C)wHRo zYGK~|lSjEjxVqb}WDxBu?GoHYDGfx}E5nnA^&Q^=lwOqcfXMC`+v0%(QxfMc3B?HF z!u`ZL;N5p46kQ-*#v29>LEaf80+ZGMNCG*{5ZUK?Y9m;PZZ2fuO_c7j?0TV5<|-k- znpq@|9h56*=Bdt@Yr1_b*=Hd@&gByE3I5%n{&oNv_(mtm;JRjGfH7FakTXdh=XbDA zY*xWg-z4jw2rIiK_gs+C$l*dzd4@*r@IN9U+7Vof?3XAaFIHse`>JG|uGB8s<%m&K zp%Z^gGWit={|%+}X4yEBL82QD#n&U2<`Tq{jbVX|v_-Sa61aOcc4Kbel>E=N3T)$d|I_Bw~j$52$ETkh+K`kf;C%akH@v2zjpxnxX*HaKjZF-jOZ zw&Pdb7$d2M7{ElXozJ8OA~`{Jg)pso5EUeN|M{5H^+w<@8jE<7Y3p3p;J(jPAa)ba zY(upFe&DaElW)?c|Bi-0C9<8^!JF$4RjSNw`v7B84uwmevB2oNg&2K7(u(W0r7Gbc zT)u^i7$?adUYME4=Vz{?labKN-$_+7TWxq{q?rum+JaMa5MeeIs4@M4+f^Sr`wazosh?$X>3 zxqPg-QAzG0OMOW?8wS_x;wts|KNfIDFT+XkF~Rkin-&FI)>>^UH@+#Y2B~iL@SZ)rP4Z=4n=7#V|R3`SaCASXAneTij1^8yM%$m70bQhjsny)$`{o zFMyxo;1}9~Dm^U;DrRf@|Xn)~Ikb}Ge0hFX4vP`s{e z*Na8+9`In>QO(t?@1U;vOJx!y_KUz(peXlFDa(IrI64+9nXI+prD>=tb8|)S(r-}) z6|A^QnVy}k**v?_O8n@XsNRD{`W}{zvon4dA`RZ>Aw{OtbpVbFY?qK8PCwLUuA%R7 zC`#o7DY+@K>$oc?8fP??mOgR)iAFD|7{qnlVU4`C2C0cv5mU8_7p!UIyQmZ_RaON; zsub$1?bc*3hq`b)aB&NM#-YTZcOH7&ylHgl z&Tg9UBQU>;W7erhxg|BcSEOPrPz)O&v@3rI=On>7PvV8M_=Io$R!VQ=6})Sg!5E>a z(TL3pHvP%N*e+{c-G(g7ZCsaCR2DW>e~?TieqX6df6tyY$N5iRQ|U|mIOoIuTxIFJ z25mIIfg01VC=QuiL1i9qx-iotd8){-LGo^gSb2}koL-LMT4)bexMfmMw*0}BmQX^w zJQJuP3m>zyWnd;(v6c|2(8z+Wl~QUk=k~3*=$aDS02VES-t|yT)@`}qM*JySu_Ic1 zz@hcqm7D7M(4x3xiIa2G;~X1=$=8#KnM?FqJv=K+w)RN#=MRb8H0>HBCk4k@F2?J= zq;hsDfWQWOpKmj&_u>c@l3QlWSG$sSVp;Pm@-u%;uB+v!lw@3X>4k1*6kp2)C0y13i!5Vqke zo)7j1<3U>s-a>z19Ul?Ep+dP*SJq5Z6;(P<#Ka>%CgUMJW4mfQX-27 zxg%e3V1dhFk9v3WV|P=02*zDV@->SFCaj9XC^kAsVP zB8OaFs4P2t;)CX4XMJQE>C$%v-P-8}X=bVI)wQuXFv`S@3HGEBro6q>dKyqz?PAIg>E|j}H-=onHKJvvhx&6* zspG>L(-W=JQ`6Hkd()mI2$~^!)d8uA7d%@_eIw6u0%wIa>8X2rUGOvC8qYUb_U3_G zJ#}TKEkI+J^?s|eN`%Zlm!F;UK&~ny$+3{mFAlm4xC*M?%SKa{*S2=)3QPSnur8+@ zX2L@C)7KgrdBAz{cIw?j>ksn|#C$H>6|v`Ovb$sCF5ep+W3D|K21Pm8Hw>n4UBF`(HxCZZ%WRh##`cBx63j|c|Dsa}0thuokam*nLU1LM(~;zYRq*nGsb5AJkSM6#nJfam#DG% zHptQI^1K<^Y9W2ax;da5dL~+_97zsQ zjI&zBQe*kFA^3QAEiVjPJg2aTgT)6yvgPVS+ZEAi&0jf)>IL?z{GNC40@*nQt1uCll;W^uo%0JELN2rW2_-3V3d`ri}P?L_gW>qV(`)0rxHHgVdiMMr!u5PxdR2C8-sYKnyXw1w!0#fkLixAR z=G||z5B+ZCw<+rN$v*Su`JUX+5-zH-?UzuE7~!T2^?2XF-3CvHJdk}sH#OV3m@2F; z7CM-6^18@yNi*4i`Ijr~mf$OmYPT%|v90_ef}*Zzdba(M`Dmz3j=S|(l&s7oubU(M z^ejj}$(|;mHyJf}`tMu$W@e3S@?^6J|LSVnM1$-MsC^seCSv@JQmYmPtH&_%yO%%{ zK@z~E6w;8I7An0T#G-KFPfr_^{~}au?#YbGfcm$4XSo`$bgnIY5#~gxEjAmU&>CWD zezXqVH#vPLy5OkeOYC(M!XhR~M$oFd;SuV{0!yr2lPR#ijxUjZcj@Z}SLk6nr1RrB zps|7`{i^66O7|iL4hQH^!ryGg20cLCoIpa>4oLBy{~g==G{cWI`ypf_{QqI?`oaVQ^C)u3?e=WT~V0%Ymm^L%+t&a{Xkzc+TjX z5!}un%v(~(LIa|-g2}LKqyNen0rOm4lBk}eBtYL3m*VOZ9|A=!)3g;k*x~? zjz%s3h7#r=8x-YQh~3l?wK~|h-6VkOrTqAcD;bM16S8|3S{xE<`%YPs1LWZOI>!Vw z3pVkw$jU1kNo}9CsPqKr2TCw3>pOhoIKdVIcd{Mn{ z^XrMIUZz@f-=2C*|Gq}-t#6uf0|#31gNND)Lq`dk^*6tFlgZmBl1g-kZ&HSD>BR_L zyk~e|(#bwXpaL2wAgSj6^?$3AG8jNeEY~!aOqXYnVGszxqj!s3$R{+Pw5dS`lnRK5 zvK+#1ohW*MJ1fh<871F{2dS0;inYk_3}~4UpwU8#!KVs`*I5Jj2pK>*R~p(+OZE;d zjvL>5@~fhoKNKUK?(t+C)QkkwApux`Yc4|IS;spS02S>$bE7&tkPd=-A-B6Z&;+4j zChmYXhH<85!RK(19_n9hK*YOswwoS}Ab|c0kjF9JZzTh{mMV>)gxJpkBydLli*QJk zw?bjJCEob??7)gu^DpF=u`b4y-(F+`#yK-@&4PIq*n9cmd0Xk}DxBWoEEU*-$?;ap zU#&E^7YU(~mu7=~Z#HV4X*e?R6j;!vMBQSa%7BHqi#6#1{>r1>y=z!Z!8LX+EoKe> z*P5LrkCyF~wS*t2i`c9IwPFVd-{z1%`&osFbe~`fiVntW89Tb;(G)@8N0&fJ_Zap# z0I8IIhy&(W+$I(=@1PgJKxLgm_IY+X9)uRye8UcGeIlJMiw`B01_GnW;x2RaGkBu5 zG&V>+dsw=HrIOX0+lxwE3lyH!JBRXCCp-8EdCCb#ppNt^`AF>J@bVZ`FW#pus)}Im zgA8F5nre}+f|5QPzsivO6Dk2v?%3h>Kb&`k5Dx2mHvtJ=mTtabD&xLJ`~|_~*x>HX z4m_fI`J8|(5?>02j|TDAPw?J$EA!U=>0pmAq%4&3vyArKTtx|L*&WRjFo_mdQ;zp7 z>73hR%FlmOosdUuOVE}_HRhHRdONP+Zv!pMQ_#l-|LB6ae@o$P4;kL>fO zQX)(^Y?Nxj9Lnv0u1T*tEXlZZ+r9nKpOa-F*!oIONl9^7&!}Sc3Q`=jy|Wm~-pXH_ ztO4+lyo_lhn@Y6VCaDOXcdhNMJbb0;aH^qX<%*{pI`_*IsXT8Tp94u$e>V1cglH1& z)2%_poTzyvAS(K2#l9TnXIW-loeA(oQ);W?fvuMWT;1p%gtFid`2;|8Bx>vMV5bU_Mu5;_Rc^#%AsQVsJXMjwkc$k{ED5ud?U1|;Wzgplb}*h*gz}LVPaQHSC!-qM@$k%kf7O4-GHMwL zbN;-yswWL)HOe~Xf;4W`JW7?-YEhUE-QCxuIOK{IPQ!rPO4+(h1|m`eA(Ki$KrKY| zz`Z6;rv7I#Q2&MzO( zv5#&6k~Yh-UFai%wMFr5*w$7pP#IvNRW4X#K9tu_`hxDR`}`gE6)&JGz@gmx#dB__ zHvq+jZ8NbuBjDlLU~!gmkrHmhfg>T)3FR};;VVP%-4;R0uUlJmv{;+u%hu17%ATk^ zd~SNUPo9^sF|Y+a-^VXy<`if8dQ`xZX=l;rU(WPl0kFH(wz^TB7#l=N<d^$cQm47XP?1h1n`vWRwqQb~fqoV{Qz0pf)o-ut_;>&uG~oliW)mXKH_-K29RTKSxQuXCkE?p7LJ zivzwb7LQ^ZzKL2fU~Or7GPO_cd-MDMYu3%C#z%8xUF#QeD=FMH21u8@&pIV!G&WJA zm&$+PbE&jWqazzobZwEAi~KzOD8vvsG88yHfVJw%R5o67aCSL$f`ja1?A&zf9ZrmC3Qmqkjog@i1Z}(_s8p$M`X(2`GyuZV0 zk@cE7D#3}n1*#jOP<42z1%e}6;H4LiJVqtU*ZLu+R6Ul^a|_ZVfb#?%WAMVXO4DOy zd`9w&s`cr+>SY&0G=YwG=M-)wYS+|8Fa0=iiT8e)2%_ZlavPQ>G+Q|Q<7thFlMJKT zj@MFn$+l{NFNkSU&e9`HufvO58r3&~x5|WHwF#1f;d!$f{^Wa&=-a1fYQVBPRqK5r z!xHeT0()5NlbIQi`%K)q~6UO5Al{Cb*# z`p=P%<9Z`U&xWLeY0oBu{m7U{T}WlY?*za_8Cjw_M^PKb{I@xdC|v>?;(f#@9EReS zKaJy4bIp)=44U+#Bw6V4DAj|71f*dU!x5h^C0 z!`qkGq{|#+SOciD_la!p=O_~uGRM|Q7I4H4koVdo@4Y6;>g9i8ucVB}z2jyil}1)j z-`AVnjLWWz_J8dtO1b2jm~Wa@Q-)`bW(^AuZ%+{ia^D$44&zy~fnC)cU2Abv9EXE_ zw5KJB6aNoDF}aA4$r^nT_1a5%)guw=-%WaI{OmFYN}#!TQ7KBnlXq;V5h|7nyrV?= z!bQ*53YW6$ zp1f*tsS^_*9=Bj~e0F$vx@yN#F?G9i^h$m)i%)=a=D}h8gZKHC3iAkgI*~kf z6Jn=N7rqu$r>jFe<>J5|wN5*Ok!P>-)HiKV_I+qeTFUp_2?_A(^*R3@X*1c9a3@>t z;E^BRwShe9nMyU?MhGyMIdqABBgFWLfR-e|Tc!m*HfK$cEiAFo=Edo^6XqtENiSW< z2<_#p9=;2e^~hzCNUqhx&6f7bkg|A5yKGU>Y?30h@%k#~&(nH6CVaj4>L^O0BkXuP zJB||s3$iX-&CuOO+^=UFxmVD3VA@z6<4rTksmd*WG{+^+gJay#XhgkH)2l|0?JOAPbn z{bF&cFgy51gQQ^*4U_3PWAVJ5MANm8^z0z4MOB zl=J3(BwT*gi$1o%G!Mc+xT>FLCS}l@Lhh(%vW#bZvVdOKcu6#}`g3e03i>lWhu8U47;00%GEd&n#fkraLyl$^%(t z9(jB9z;D@Li^2Mv^5jc@itZayoonrO_9`wsf9t)hC>mAf{srIkZfcSGLEiKOI3ILr z$BJE15|9{jBxCmCl3DPNyr_=MQpIW+n^#FC>DJUY661_@8GxSrf+6>~&a)t_)3xAF zWg<@Y%JL4D+@4OwNLv@AwXE1Kk|x~sj6I*8MRdRDP`L4~&CC(v?O|!oMF5vV`esXQ zcN@9(8Hh)C&a@2kIRBYd0%R?n`#M3=#({Ue(LsXh3UD9mZ+!e$c>&tJz^$?<(7h<3 zvLxHRq^j~nr~An%m8WK`?x%Jt%WmDvJ}S?GyPrj>tR!@=q^YcCb*~nxJg@A2eobZV zdiPq7%8Q}y7yqfOPj;_AQhE8T`{gT@SG(P>zNoxD>VExKWdqu?!L7O}(6cF_`etp3 zQ!9X=M=@BHB_8yuqmVbrQ9SsHl9?Wq6KnFLL9qf}2WIxb^TO=Ti+o}E@ zdqO>GbJGd%L2Y(UVb*SFb%s*z)+4IfGKxQ3L3&h?QKN+QoSn)bxZ zWO^1n2Z~Lcha-Rw3(RO2(AomNqlLbQTg-gF-x@DgwF|$}CfRueAhN-{&vwo+D=tlB z2l88rYE_;tM;wn=3W%MP)}J$3bdR!_0;48OXI#UcT6Z!l5C;%uwUt6{n00ke4JVcR zYZ&L1c7Z%3@0BZG3U2txz>Q>|$&5n+ZPpKT>O2#l^n7~8y-+ol|I!z;D+pd7#&sf1 zN}{P0CP`-a6Y@UO&hQPM5po_yT8cn1~+mam{rF+PRc!?Du6 z{|GFfa`!Y?;?scblqmT?AgXY^swQx`cY+zvxGS60mKWvxXw}AEOdar8h-3Yrxz{4xQrOiBj~D=^iJbr70>N@V=7mi25;7BKSMoU^ zyy4mR=_V=NLKV0PrmcmTML@xz+>%ic$ii!~yd#fkqYR2FRcoQ{ROi^vn!B*4i?|yI zHk?8ln{E@kuGt%WbG?chZQ_PzQ(u&sMY2Q)Iu}jVFVy)Y3Zt^}{NR(hef*a5LfF;k zGfOk~^4S<&qwTZ&p{t64XZU~ij~|8a={lROF4!sEmcH=I`W#0k&DJ+RarrtGN=l!m zEwb21x_Ev>@KAYg4Fp=dC;rp|qANCVzdg%ubTm$u@PjZJWRdS@dgyXBSC`xN9`0Qh zCjEf%mNLL*_fqgOC$xd9i2G6=K+MYepQha*JF#XeGVRE{2q&7kK4+}l57RRZ8H0eT z9}Gs(op+{9F5{zM(%e3mdf+j+rwAUm)?gEOP$q(#G zI{aBZ79L(ix=(IA^D3mPS}&11NkWlxqO&9@F8#+Co|ETG1-_a;&Re&T0No8!TCdL=~X;=!-&Lk_IL*$ zRDf0;Ks;@jx5MPQ52&R$`)Z(QantR}p^mW*MZtShS> zIY})S3DpyjOTriLJHuYa`ip<-pf8vX9@HtzUsxNB+qHNGeBWL8fkHbg&uhH%DZ@l1 zCUkO+ni4o}M9I1p7{FW4yo_IXAxd!DXwq3Ph3?daKntN~lQN4gRM zRNwNJGgZEv+TQBHy5zwHD7Dv1haYP9)~C?GpSq`1{s)%U{_3^xrp{NE*{pERQL9wX z^auM|%3z>zoZc$KG)&s7xKj@v2AvL<571#7e;hs9fTUtmAJfWyso!=VNH>kO&$9$G zt+3}4-s|%oj9+yhA9(mL;S(d^M-+b@yzZ#`V%< z0~uwm!RoKg9&fxBn69{Dr;Ow*vff#fj5`nP3p^bKp(a#K=ug~x%rGgDFBi`Xhfta>!+O)s=4O`|9~g@ZS4+_0d@h-HA;K(%5S_|MnmQ+!mAEsD zBJeAJqu-dPZui?owR=5KHeNFCIW>38agfPVp11rY-NR?oxZ2}~Hljv|tex@u_nZ$< z0<%%#NEq^O2qq%rLC0B2=$&7B#1*JPytzn_bZO}ZH0My`KB*v=HF zvDvwXJLb#7*~5gX$b&WBuO?v*`z>_SE8?elKkazLg}?VswCO=#$|vOfB!!Jqt?~SJ zzj9gmN*%N_(2I3;Vwa_|98)(fbq_oLuA4#LXUfngXdhT#34f(I+VJ5NGSYNT9RxX> z-(rlPQN-g1o|XX30b2UVRp7gS+TBId$RQ&Iy6{N-QnT6uV^tJM1`L2o7jNTc?IQY5 zz5xvsvk{DHwZ*K|@!RRy&m8=0Ij!Xse54RsI4}ePmRGcjj?I(;Be~>|iTHC`iIx8Y z0bg%x@37Vc79e?ma1bcl8o+_SY~=)n1K9z7Y0COsn%vwE09>$p|8YGWG_2{nWdQ)d z9s}5u&!2Kb@gSABP8C2#H0oyPDXj<&TMH*+_6Y4=D-I+z^Xzzte01x_@pNGBmjWRv z3>_Q}EYwbg(}6=L04p0F9u6%4sNWZs?x;NA1+aLP*Dp6)W3?0liCN79#BOHftP(ob zW^T3FJ5O=ngA>r{=>+|0`GtW%(qgM-oaH|_nmk3#E=Mu3it6jw3`H9S4Q&|HMPRz7 z9$phygfy(k9$Y4hDp(K)B?_3RPAbp020S{e%oGYm&1uVz;yxf% zzptyww}eJEm3M?lt`Pf?VTyp5M(`$MCGX%OLa6B;2t-JAx zU|~c=8x{D_Ga;9oBPY5j!tM+|#n&AknBxaXnAkq4R`-4O^j|-s3umUo5c0Cs@K1MS zVp0j<98YCB;q>`}1KB(>R8iEVU1mrm%E`~!#oTCDkOZ zpSle36QPjmJfnRPr2Oe~;+*XiKokk@{1j;P#nnU?MjP=-3$~P(X^krM`7(XpAyl?RfIq=Cq2dwh#`*Zh7$W8=+ScGtZURr7k&oYZVihd_eY%%B6bQKe zek_jsa8TgY;IKb>xR*@+&;7vRT#%ReqFJjY07Xt%?JM;F2hKTo$ru&=9t+VF)bK@c z4%0LXSXo><)Wq`SyLsDY55faCGeyWYgT~aRiQb~zS_n|wWEK>h=z8h!%e@m=eZ{v+ z9Cgf7^Zg(17ztwlX)Xf$zX9{j*2i{>rtOCJ+#1dw<8M{-8aX}uuKGU^@Z*3vdd>d} z0{-##ChPC&DvVL$5a2$WdGAf`rJA~nu^9Epp3Q$X1d4OhPku~iTD;F5Ly3J}bN<{wv zr)$I$sb_F}$_p@&?pEr3bS5yq)8^EFpm9usF?(o}dDFoz#B{T-H~a{_JQS0=n$P%y4B3w8=5vlJpa#YTZ|X3kL#E$1w=9jBq8P5;1I>4x7q2 zr}I{$izE4&sel5b(%8X7Y(FDILP!cEXH9kVTevSRX2P!duDXGYXM`RoVCU;zYE(&q z8dPXd(!*dZeOX)mM++>JkuEZnpj2Yngl*3!*q|x%HwwTy&-9#mpaVvdnG9~J;d2si zLfg%2{ISs+vg$rjx=ruw&TDpn#jk2{U#4XOSaip8c|Hq|t_lZ=ARIzYfcbA~l9kX0 zICq1jq#S;tQ?s}wf)wl^-oa+`7Rg~853Q>?-xNP;X$iJ? zUw_!kX?G^KU!ZlsTD^(0yKx%w&|=ggSM7t>k3+5(TpIzG(~BJ1)J+|u{Kdi=JIwRY z<0gIa>;(=0=Zp;(IZ_MMYjD!S^C_f7+fV!ch;iEV#vKI!IY%OM_vVs5_u6wny`uTb4R$|}UW85~?%%0NbU}L_g=p8& zi9FXdB~}x7VYfnd`G_y7=Kz?D0tc3*nb?$j^afrY0Z3Oir+di^yK&^w<#~jTs5j-K zdyN!sphZT>&A|zMEexnkPIKOLvI;@ilVdS%gUqHjF~oD&qS}wnzqzO&)NkRN@~j_a zuazBgr_sebMJ=ywcIbf_g&G`hzA1G`@$2^}iclLRGv~(2)D}VBA#Y#{KMv{39zda9 zb&)2N6GS1aNOI2D`=o2ofORp0`lr_?nULPsoq|tayoy7%PKd3^mtR%udnv{QqfYEt z_)Tx%?r*?*>~7dd4z3H-*Rfa9=)?+TXFhVxj?M2-6+Mx7$<;{3f!gj5XXH?22A39e z9_&5xkHBZ{W=7tViL3&!#5RPX;0gIm2LL<2V1AWLIC1h>az$CvggEkg47^Fzr3-+Z%$cO5M#K#T2m$*<8%8{^d4w>8FG z;r~iMtHI|L<~7)R^o3OU89IAH6%+DgUYe=iJomlSVdRT5wr`hFQfERGe= zOs%kmXQ(atvx3WK~WRa;&{>M*idtrhb}10QwjCuPPrLD-aTjKKP(8CXgY?gf`+e-fz7B(vzWiub>pb z1lhu9_n-ycWwbIBCNCk+rhT)%g>3LRzk3!!gE12*MmtFtm(Gl>O{?nZd^||2g>7{gBN8{zmQWUUjknXJh@{ zoOE54VYf|*Z;CWPgOJz5VY`8{{B^Y(0;$tkPac&W^Zdk}1c}9pt3B%rhFF|`QQ!iQ z?)$UOifeeq{EiJL5ak?@daV|3U3KrEPLhV>0J{fl=81hxRq)J&i<}3CIvek6XT!NI z1nc4@>)zDnBSZG#j6RefpAb>!9d*uKuvsSzxWS)3257|cn?%E!T+nYFr3;&&onzQ! zSwL@#c&UJC#92w5A%W|0(EJ2|otB8pf=^Bp^jL-?gzCtRw{qSFW#3YS(MhNA-noI< znPmSoULp-`$qk2CM7hPS5;w90-V|R(Hb#kzRKR2$E~rOc z@&4<;}39>7L;wUlM^zp{zHIMS$57t?>H|8hg(C_U5dpPd+_ zcJ3Rii=|jR_h-v4l#?Fm zj9U`2==DI#gJ;5HWbPJt=tmi|OgfvUbAZ=^f9`>KIRicK7)&chvV1l!$ZZJYojuQC z07ijqc7aLe8eCCvAjFCc@Jey9l;fHlHn>UXk-4tmZ~`kB!seVLE-ftwA$OheG`!MLx+3aBx#;EGIy!dXF`vt|@ncb^~|E&{o)`B}yn)eM)zChU%z z=`v*aS1chj*cXFVvs5YJv(f};4MfeWOm7%1wm$vc&QDdcydq#t1@((>0I8gah{@) zdBw)YpRN5O_=*W%vUc9fjml;};Si~MRH?glyvtR~Ko&u>7R-muXo76vp%{%AE+=Qx z(QD!**36Q~(sJj{7>85oLX~6?SV|JVW0?A;lw~FYKSCGDfF7WD9)$V|z*TOt@p0gl z8jHjK6muRoD$E?3YBhpEErR&vxM+Igc|f2w1{a~`<%W^;-1WV-AWfo^%?~*00DKv4 z97nuT=Y&m`v1TX+Py7{c3N|T%qCvudY>@&#Ky2qU8iAE5+KSRP*vQ-DVt*(2mms#o zVv7@_(jr%&pYN2jGc$N`Y*uFA#yE<8nRci)duU7I4=l6+)1bVYKnm7~fU7LT@z@qu zSbAMdaB;+0K+?YLmcyI#2gL$TrrY9qKrSf$~pEvBgR~20{>scI-trE?fqj&NDue z`98KQ;J|6uL7j+I9MM0+sYc$y6G)g6o85Yy4R{_wHQ!lFb^&%$%`rX*&A9=H#MtyY z^3#I&69KC3dlv)LPK-3dS$XfvdiU&}nu?SL3K@n)Q~UIa$z>-QPG8|IH=kRgw~%G7 z{9b!7ZYD^pTu-d|Pr+xTbvH3vo;0o?7{7QRP$LJ^C@7 zhd6_W1AX;Rl`iH|ERuSok5{~^A5(ncax0E+YrmzYaol~hb>M1rWep04>i)?JfVi} zrhmXaV2X-5|KMY3gmC=4YWV&41ArJEWJrgQ=+IO;ypGQCi4tSMIqVJV{YIBYN+%_| zt*1!PKJqv3rfgNpUK*D5p^xMk|5sIP#UIT!&-gF-3DscTrv0aRR?nETQvepRqxIR;1Wd`T&{S;J%&uJcm#3P0SjdIWWJfF`=*(0^7l-=3 z;AKDDolSmEg#>*w==tAhDpCFR#Q6Y-V8Wb<-=Mmt#7ecz<`aB*)*?0u^Wv zO$*wbi8lvJoioK6ToO$=Z*9Q9OoD##Z{g61j%p}=S+O}bs2X>pb=P+bu1>dFmTXdx zSj^GY3DzM)vKrPC!i48aIbwyu%5j=394L5rwDS#DIY{)Mt)4eNVI3wEt#oml(3PTn zLyI4rq3b2SMD5(gD8-hBzAG(^;%f$Y;YBd^c$h0HBX@zLYOdF6B1b5!Y3+I}dB?q? zL^MK|i@RSN@2t*W${ynpuxvG314GVkH4ccFs*lX-2WR1=NUF?Q`ybdV>D&y#l!gP8 zfB@F4ET4KL`zo~AI3s^d4Y~;AwbDnbVLE1Md=EBDi`XQ;o*e(n5oA1Oz4&WJSynN! zIQ~f#n$E81vw(S(GfT0#@W;;+L6Ota&N!$OGIKn8gW(jcs4NQ4_OHGM@aRclx}b`|xf2t&odQJ177#0`Y}V*LEC^hnmo6`qu3wj=|J)#|tB>URJRR)C=D4NUdQuv|;t zM@ma;RM6$VYWHVSU_Wlx*!=zEN17O}${dG+-)kS|XI*X=7o;G4pL&tP*jdF`ZEI#t z8CijwWBSZsaOm*d*Fo*q{2g~>_@tkT)~kEP1Uz|U%W-k#ajqjWlmb4x(PPrr&MwH7 z$S73nBARVzx981HZmQkhIeALKHug764%K+$R@TqW9C{*8{+#P;CW3kWtk^xtv_+%< zR*EaXxCE<$zY`R{pk~gdb=4{Lg{B?WaI6EpG?)_ok@7 zsBe%E{m0svg&eQBYUz=lEK`dvfK&RV+;%JUV@cQLD2x}fUaNE?q6xX>;G5!b2Yb@; zT83nv8Umy(&AlhtoIdo>I?_xH!Khi*A`A0GH`SFSBJ?Ozeq3T!wB{oRDxJW>JH#tUMUCQFVDJPH6&X3O> zP2tJD{rCD~0ou^|_&EJ*=H;bYc(1;@%~c5r7d1$(0bkbikEcGyNBb;%yLjp)&T7Cl zUh$3UJ4EUYj@K(hy87zFHY!(Npv9uOH-Dx|uf!Kc1Yct5iY%o?ZM%5^sp6sAp`~E4 z$Ha`vOWR(8KRz8)^3Cw|w^OWdc*z68=>wwgflj_Ve8E_n1Y?okYVE8%Fq1q)siHk2 z&juNa9GYK|rH9;?IrNb{&{SBZj-PyIHyQL|i8ZPVV;zPUF&#dNR9|hdo)IZIY+V2y zMl^hfcL|yM+MOzS&{-ktaL9XE#jLQbjJ}pkB<@>d%6!%W{?psX3Q_vVZa! zh}EEb)_=IGd=@W$f!R>)`-_7U2iD{a_kVbpo^qbo`?}mUxaQ3%-@JtTIun5kj|x)p!~c)_;IssXTraM1s~yLau`y?f8TJ>Yj> z@18w-4;F~irCM(>?8x3-LZt3HNseGUj*#p;LbCN0tHMY~upK+WKfe>n9@3q=cJJ8x zp9+A|pkNQ_VQj|^k{!Et?%G4TXU85=i~8GxAW(?9sM(^fv9wz_G^JD+)}n?(?9SC`-I|5vU4-&8(z?7!{T{nme5^xqZy z|Md3XHTmD<|JU9Bf8qQ`ZgGqsm}(bEC5D{^+t>@YxvSeIA`y1)*@u1eBmg~tXHere z4?)Gl*fCs4Sa&-CS$3gup~}E3(LE@H`4|)($Oq-!3$I8Pyd|uTLCrv?Ai+oQ!^cH6 zkyfxZNa6=!ty37rk7q#eaLExupbymbBQ*R2*aFxCfdFL&?V*jZ9S^|x-!U8+Y}Yp; zXhd)Th_DjSA?g}YC@3F7AB~QW0gMi1Kx*SHcOmP5*0_2QR-gugQp!A zfYiXmyMP!V3xK(8RqzCJp~~Q&-&6u3|F!_^2o=LBI8}aw0F8uG3;~F13p-*V=5BWc zOT^FOq9TjHN>ExztVhINaOt52Fdu0E2F8i!9)))S3wBsQAfPPp!JUYeyFCVgg(Z-< z&__Tac*NWPivXw@`Ul{KG)Gv$8b}h@C*BHH0(OQ*-4+Vqg&|##ADx0W2KAw{!F*JP zvXJ?S#V7-G06IYxiSV%iO~s-9#L<8hfa(ZjSOZyI98^IDMMDrFBw7}92ZjZ7gWVvC zfu2A@Ldb{%%YX-{AdP8C~+7=9KkRd zFpxVAFox}kf&&BRj)J0s1l5Iu{|gli^g!(W-{=5|Fbp(@#kZIs?C2SQ0lGoHj@@F3 zLPJA5puE_jQ#izsjL4>-DzOK62aIG--ZN`IKcQB$mKe90xSO!!Bz~2Lz z(0-r|o{&_)>fi~w4Wb>mhs4@-@75_Y@hI`*?!Y;b>Hrz&WXN22^KAtX%GeQiBDrx$ z+)-3T%m)3S2E@7$pRHY2Y6dh9D)x5?vU(=g}AA3P{BP7ASV%TXcs6WsLhAG z0tG*uI6xs#>o5ohNFv~hpi^QN7#rdUG9c6dS_H@TI8m@Eai34j8g#l#1 zZ7mLb5q1Ik2Uj#RBKe`rAPedVMY@1D1$BvzjbH~V0p>wl!){Pl5C_l}K(!|dj6h@( zVxT)H0i}2i*zVeWPreY!vNs`9LV=!lYhB6KmpW8m5~uFh~`99_F{XYfO4=8CSzK=*6!L__$h3GQAQr>ufKwBUjqOAd z!G4ilfzHsoAnS2og?7Wuj4=ZAwxc<`A~Qk*0K15T3&~+15E!1q9rr+rTj&wS3kdro z+}Yi6C_w}9y}y;kAaQU3Ag=g|0j?3;9%8y;xw*U4@1=IxDL~IZcfL=&HPykGi6o7T$kiRwX!T}-%6Q~H1JLC-?h~MZ2jDhe6n7I?R20qw#il00e^kLgx7`EqIOsS-UVe_9>t##W7TZe0I__AMBxv4GoC*+JKrzHC009IZ=Mt!k z?2eSfd)N+apa?<()C9c&y`XdmRqXH%I6H~sp$TdXI)n*`4y_2sVIm=HF90!$-kt7% zpHO!oF=8I*2zAI0hevGzp6-XY(ZNpQ7C-_+pF*s|$H1T<5KQ8D+eX8^3&WexK!3n3 z1z7@p1BwXpkPMJqL@XeBMDf85avx9xY>zMkSs)=>!r_iQ0z)HoVIKN{V%$*w(I~Ll zSvaL2H^WHyw#UHLw}(~(z=F36ha9>0*1z-APk2gey9G80YK};uIH$u@0YvkfR`l0ohK0d=v|z zzv3*1gTcBm!4;!8c3C_MS`JEsy5I5W;v;dW4hh^jf(lUBZTZ0?7}6b;Mj^%B!82H` zf`uorqB!gra132>3_%7X_@T6IQ_w3e0m^m9ut89=9Y@eo1&)E!8GtJeMffL3fvUT< z(cHTe^#m;de<+;-tI}h4T`{$-+jje*a8v@`7;^>x*&4Hy~MEN4vNu;V6O}aaTvDPz5;;Zs6YTX zb-4e-UATaOQ670c@-wIvZgwCcn8F|_{+)njkhT7W9MDK|n}^WkX#E0*1X>U^L&N^N z8$=GMFob5?IY>T$Dp8<&AU8;0*@0oK7eLlE7;+xL{-FOt&%!_v5EBjuk~`Aw0g{v` zJTfN{G}I2&$Dr_ty$~U^1>D9J7;;+TXFpxgbQ}Cz(%7EvA-|ys(-=Uf~M~oom z#D)kE1O^Tr0tB}mv@HoZFGJx1g>Ir^pu>I8MUVvmnZPp{AU15eZDe=&jChB*1;Yjk zR`{s6csoEl+zOBqh&(U?!w#Wo-9a%_9QJ}64?Lof0J(_4@qsa6&&22L$P3O6U~9Yz zQOxa-2RVScAx<~=e2PYZARr)sMPQu9cJ&h-1lqwJNr-TUwcWvX_c!5isUQc4_CP%V zEKXtcr*NM@8iRfiQSfLM1^XeYgqs6w^$>xf`QS1aETf6cLf#8Tg$Tnf^+V7P41*T{ zI^w?t!9(?_vvA9Z5ZLjE4-SEo0j%LpfrlvY!xQKf+-!r0;|<0j`j9uQgewiI0AlXP z_Wd&l;NyeVpQA7ma3qPC0jl8`qCUYra#LU|aiZ(H-(r1%Z~W2FD1ZP1!Q_s%2+#s} zm;qp-d{hLBaZG__Kn~6&j!E=m5GCcC!#!Ply5loKrXNWR1b|S z4ub!0cI}5prLZ|3?BEF|7+)Ob5FrIH*gpboP+NckPzz@+90}SM(i5CZU|>TCo{2n% z5ePc59N3s6F9#NVfFQ(0giT?<2K|B?7;m5ya9V@v!=F3U2^}(E(6ewd7~ymUvYi4A zpeT3))D^l1xsSvliExRbb#Z7>e+XjO49J2E4~YZe1y|4`(i+Om>WZM<;W?cM1P zZ3hwxFc`%P91SoENCB z0|ySKy}LLvQxt}C3D5u+57a;&jKl!i5gh?n9)XS1WSc<%D9B47G3)?wN(iD42O?e( z!q7~R-48&AM43;)tr2JesR4R8h2VhVz+OOtod1q$BZmVu0D%x97#auHbUT}vg#!mo z1%ocD?|aeJjf4m$99V2>VqdHC^TA_0z9ERAOb_c0HL8NiI&GXgA4@V!I=QC z-o_MMA-)(y2WbLFM+pqP?Jc;iQ6vR<@PwGy6GV-e0Ky1JFe{+l=OZFVNiake3`0vV zKqd^R3ldNb{lG2c6Nxws5Fj{y0LlQcG3+x6M>684ek%v124?@)fe0R#P*epifWSC< z*bX@6-p>$tV&p==AZsHM0+3J@qRfB<7{(wX-zK9}B#uHe> zlyDOfheq)ac>sh35)xd&NhBH!>JOX1xJi_!r0@@d%Y<<@=}vQR3+)`aVL6+Omo|a} zaZ^qu_vu|C!kcw9TFq9{yZdNH)+^oxrJww?&oTT}cW_qUtFEr@=H8E@Q(?v2*9X!? zUB7ueyMNgAZ%jvHte0|clPE-rtY1#HyQz%jg6Mk})QTOk;4P{55K0L%YUT>u{uw#yiV}x?+grWt>4s>-sI5o$u(?nzb1XCcHw%VUFT`D z-my*#)-O3?!Dfk1Mk~L#6misDzW7P+NBxQ9)+8^xq&EMY@)n&NVgu?2c`F0E9~!mO zMULCtxi_qBCvZ(W{E=FEQrs8a??I$-QzyG=IqX%yV}r&?^U#G)8k%jHZ7a#s%x>rZS~BuoSJN8sT)VwW#|Q*%x7g?!P5 zL!*RlnY>#QWuLb&t8g)cfz&a9Ps%NIqrHq5I@cDrFn#7vCix<4tj^U^LOFepL)ys0 z&BESNE`<6>()XFzmWx)r6cnac8ueeDpPyV?Y+tALR~`@#uIa+#+W!Svd=>|jXCXGB zYZhuIwz6_Zd0o+SL+4y+w7K14ne<9%#AUZ7kIl!Iqh_s57nsr~OhxMB`PVqoFAs?F zOw_c9HgJ!T7gY22s&_uAjS`>fi=?eyff zu+Ce)vn(y*w zltOFcujwD!OK13wU)S&Br_3oK+w8R1V5%plb+QjFZwZbYwj9gXY7bK}DY-y6>i!?3 z`}YHS8w?y7Uvz;IFuTIl3~}~^vk+JefEW&nzB!}Z|jhulgrUv=-aWP+P>`V z4r+ycK>@;n{IliVwkvFD$(GVeIlshoy*rb+FFrdYnb*rL(qZ5-oo`_0sO{Brw^dnb zAhY9uSzP?ND~`W9v?A#r{FJ-vusRZ`qu4WR)YWPlD4{gaMwv?0*>c8;C4=I5^vi%Z zHJ7FiJj`cEvlQ%UeW+G6x;gaYMrtrC=dsr4l%n~#l%fms`O|_K#Xke*H8ox5>008t za|PJF*i8l59k`wyp1UgmktjN78l-RSdtf2@D}Rbv#V%P#C+{T*}1|)hfl(HU_^0 z=z#=Rage+_PNAhYaUp^87cEGCuOh|?Cj$5Myu2LNH6U?Vwc5(jZEOz>mGTGS&3Wgv zHD%H`S`qr;*CD<|-1-LyFLXUcnK~VUo0aVjjkykpx&?XJG!;EH>T|9+b7w;S0qio};L-oTSw;LHCgtzAYIRD^vV+-lV_sj1~ zeeMb=jQXl=4$>=Vm+IGLw1;Hu4`!_sVs!pd+EARfW>nE~(7;z`Fz#B|@zvl5J*?hB z(I1!SduC1YhpRa6uDI1bSv1m1Wz@d$Uf6zPzB|;!UvMM7L(WH^y1bBZvd+`oL%}Bf zWoYiA$jGIBy^^ZbLg{4_&E-Pc5RRi2+|LtKAZCz^a7}s~?J~CKWw;+Bfv5NIeIE31 zY{x%qXLL9~T$+OtcjB8SFyp&}dT={{GaSEydz);s(gS9t*}MWjhXxy|g-t^n&$u^k zOij=a<&<}uW>rpwa(H~v5?c7tNB!lO^0MNEK7%j$ab6Kw#xIL#$E1X1Pd*%r?5(aE z((Q}9k?7#7V#C*}GxogIwx+P}rPyY?;H%h=PZge?nCsU`sji6T8!M==^ZILrMkhP3 z-&OnDD$iuAY-eQHKq}4V98XtjQ`>u;aqk+&Xq~BAZ@tUT_BF@*y#AEZIJ%Tx7iJcS{`CO#Qbo(;GH|m(AMC{dN2&^A<|-E{*DLgs}Af zHIY8oRuq26`MzncQ1Ahpgf&~ocnvKcB|+N=X*n^bGp41Q>*kypO|&T$*t(@Xgon7JI3 zs6Dw+kv7ejXj@x7r!)AL%fo4?wkq&btM=IM5D&IPyH>?v2q&(0|Fw)pAJD-W8vKkk zM;m;t?MQ!l#fg&pD8&_5ouV^ze5MAU})#_JBq>sb9^d-Wl$rM7k1ZON)K?Dtnq6__Hn4Q`5qtB%G3?w zvXJgQZPYTPv-m!P%g4?_Puj9>t&ce?t$#gwr{vm*%7o&^+)?YAA}o`zr&(v$yB({+IOWInL0h)!nk+M%(oq%=ib~GH}Y#lFQwz@O;Le* zLhs_i7ut4m^I0pI2VDjaJZ=lB*QB4f(wt0FefdZupkr@`y8i%wk{)$};d1QDK*4X~ zP;zk)`QhhpkARzj+wBA$l6Z=VI}zBjgN->#Fq}aj10D&kKzTT!Ky%|~LI4YJkDT?; z$$|ot%f>wECNFO*QVX;DHJ-_Rw9;0bUurj9_hsl_?)^mqdvQ=p=}47ZJXfcy^yktH z-K%!K<)N3uWP%RtTbQk^Y`$(2d93KA!*S{^p2_hc2@MZvO?~#PL573OgXYWW@mrWu z`4(27bA4rDrm5QPm~MeJk8$?<#d|&K3#l!K5(AvBo$qH>G>sMdb4GG$XmUU1|Tu$j``Q*9yhr_GmgM}uU*)ps0J)b)l>)v?`t8VJ0 z$tn+bm7B|PI2;jXalG=Yk#a($o2KJ#euRdD)60c6spXF!na_l3&9ZBnf1#QUsgRxI zeo!#t#G^`(Ws8ZUthq|1mv-b(nVksV%-j*?gu%N;vhRc$>fdHM{X6hnHWV zPBqlNCF9bRmU8;X$!C2%ALYH8_z0P-ja=j-se8;Ox{m%ZAFNYT^%|>;6;$NcQ&cUn zaEy)g)K`lB8(_W-JUZxrMt~793egicOBBcfX6j!mU)i9}va(WaB18uH6lnMj(CrN_ zxldzmIHk~Kt2&W65#TnT&OA6O%F5(2&rx04X3`cAdCC9ASG|27D&MnWl4Lh34~LO3 z?d502s_)UBbU0~b*fL*i^@^KmXt$F8>iKZ4a-KH1sDc|@ye@fD(biuGCN>pRQei(+ z%dYkMYFT7gbPTa8r>I>&!IM~zbGhJ8GT{P$-`xqwzca^PS%4~hGDA>tH) zHtJyUf|e?9-N?U%SueQ`f2tg%nalZ+#bJKF6Szj-gaV%q6bHU=~K4@sL~o>lBap$pwfCOrXIr z@=*4_9tH&7xN=xlF)YG^xh$*w`1N9^^7@I&z@8_9hq@~w0@X7WYQnSzwy+E>!_;hB zxu&xJgkY{(LhiHk3u~ZDbU$t3%v6eO?*B+_j zqTD5{ek^F-p!HMao!2hXo!5K(E9g)EETyIwnxIvtyyAN$RqnYQ$9|bu1NMNVYdVb! zclZ@mQ{G7C4K3*$dwoS;kNd7qLs)?-Puj4rv*UPVuWy8}JcUn8JOzz)@o=F>Ma;k# z!@=AQy`rf%TUhq{90t?#Y7u?GuIxc)<87{v>kXNg`GCo5pNpG3fh^ap;N8VfoOX%{ zA)01Plo8bp;lc}!Ynn?>a+F(Mn%@rUOP&dH8GFU|Ml+wWC&FvR?vFN;+Y>>(-hm%H zyY^)=Iu-UdOtTj?<;q({$eF2d`4sVF&=yya3pqqvDQ#glEOXVxRI|naN>o^ zPou*|*r|Qiyqux=!>v;TU&?c~u%(+U9|_9zAwzU(1IrPqW7x`6TnNUqi4qVPY){b#)_ach6}ALu1T2nJb}uEh#Q6DAO2|F& zk9=~@X^=WuF?*3+Cdk}}nc-MpnM=d8ieSA=SWhTRQ+tvK)4}(rt{eqD;z{aQm~SXs zLg(@rvxRAgcG@{gQ~vC@ z5r3w+Mf4mvj50Y7x&neB@kzx0d0z{Uy~V-22AmY1+wdWO{|gV9w88Zd&wEz%tF52k@`!AdD*#E&DOru{AI;_-vtH`#=)$M`G|#KF=JtJ={$U>lszi^dbEG*GpHa2YYJKluI@Oe_NNQI4 zIdS-YYgB>Opm|*emMZbDDJg^!W&Phuk%KmfM>y zGDG^;y?M?rYZey-@#&^iJv%VwF}_^hXZg|0hjX5il#^2b4>wM&vLCe(+#=eZT>NAD z(h{@&xBV2nG*2qtjJvB>uDN7j_g7C$V0>Y9eOFcFyz$7a2{`-Ns2F@%C+xypHDck` zLe?Yk>xbfjpCRd?j4>Lo9OEVC3`5Os<^qI%zbtmgz^f^QKk)-1ao`Z}hp(WTdIc}p z)}^wEj>g!xNL9HUc;30!ShVil%G4sE!;Q*yN(U@TBcKaQn zHvK!9(@a77SIFzCpn#yBo{Sff4c#BSe1+m|#4_ZMPqAq7Zzk9Vm?lhRE%hfP7fQQ$ zu~J1|ztW(TrUsVOnR<5%^Te~;^?XD|8(TA4$OsKAD+lmQzo1`2Fm6jb3`P zmhXDmIn&fPQ%P=8pvY~Wv~akF;;86`ikMNnuUY$w%SvC}y-tge~KA z?}k@n+Y{zVugxac9&j?0+`gvy)~=*Ek@~3S)b*}};?*ACm~k%q)Z*-+n(j`v#RQLj z6WenOSM8q_hjwIYa>l=1yzg>5n_SmXIAAq1J6Vi3TTh`tH~M=~NwLANG+D0-d%F({ za-U4rmO7+oR{}m=Z_l7-?O(7EuE^qfy&@byR&=B0`eH!1t)ym*3eA$lrR2Xn<{rKd z`l)AeL9aIAuPA$dy+Xh58ri~{VKTks6sMP+%gV_@*M(OvOr}bHtM9&GyY8T?-*?cF zX(=h~L-e~A=c&-FuDtt6$;FJcLQ|iTlE*VOcb#YPU5T38XS_b*Y6YVZP}v*C0nc)mDhSZRBs!*ukf?Gg6C9j@rNdj&py z3Vgu^VjNtG;MeUk{0i2@D3qWY+wD$aM22JT_j(hTY(hEf=!*9^nyp1!51pNVDn{qq z?b+F`+a`C;Fe$8?&dWR{UQn1}@0H62x_x5FVJmgRy)niKISxWynzFVE-F7ce7SAlJ z52((J&+D1mC*7xxjV~B!P$@djsVTWH@P{PB>3gKuC8w0(lLxiJhUVG*ZY9LjD+KNG zQYcdVOF*lHXX;B;2}kmY8<{`W|9qp$pgMl#p}WE^<~@&C=_Ie${c85R`Jrfkb{UeIMSU-<2c%Y3abp57}x0p(6{b_RSm57Q=&|Qv_AagslpagujRL z-tTvMC~6|WcYvi4aW{eH56swH-7bE}c3Ak%NlQWD+n1C@UpU`P9z9%@kipYpY(ZEV zD)GuTRFtu}^_R_=5Kncg52L+Cgz?21IR|U@F5@g02jxCL*0OwdKP`FRszNQZHo9(e z2EB5}>SLwLpW3FF5(T@Om8*Le-$=*C$4h@vrgOVO&pYn%!!R-MVs6%_w8hE9g&!@Z zT%YcXvMUl)hdv1(?pmv!5|ykllJm>_@M^BYK|4FI;{C;Q9Zp4KLQ-Ms4y@5iYGp@; zCB-%ZHp#C7tJkIyiu>7UaKxgU?md-$ z$Ba*k`o5mRYE;tG%$DVM-``&wVUBTSshmDUJH6Itr$JWXT-f2OaI2!scYk2mkDsm^6v;Q1JkMEL<%hFe#iNxOT zBR4aORQ4%sw+2`EQMn5FiQL8fxtWRT5*OjXKCws-{<@smhnusZ8nfqXd8VG&C)#yd z%un=B`OcpnqD?vKwZt2zQlqLvek~nz0YU}%935Z(z@Fhk4+Ce}I}FjsZ|D|Z_rcQb z1h>wZIE*3aSQexB8-stg1`<#c{NN@>lp~$;jQ6<8(tgEv(M}bhsomWTC-iW$dCSzcCCYsChW!Ao5%g!Ck8+&UQIIL!kyGDvc z51I6ovXXO*=Z!^DQr9{C#r8%&GrU%P-8mqMd3O2B%!8yRZEntYJ#kyug~GHl$>P70 z6=p4U%S2TIPneVqrrs>C-*;-3-j+CErFwKHI_j&RoA6~VIfgx}_{`Slk1OJ9~_^;2tFh555|bnUy|7fae2ShRGD zlxZ-SN+#FPs>>9pbw(2r- z4af8$G25eVS0pa`4GslvVdVSOmeMc#_5aje+QVAsua^ysRCPUc$uFQleL26(rZgex ztitfpyGVwQdh{19lQ-RFm=@A`>_vkfN^A0^Ch5`d&t6D9?%H@%hRUVvCe!zy>2hV) zC1vINf0Pl(vNn%b$8XjSwslp$Hs<`n%dNY4f@$HYeeUgvkpAyQu{_?Fa@9mz{6nfr zV|yGH{@`hJctXBYxLLHRs-?x3U0Q4Cotc=nea|_so3{3}t^tHhV~$jNKhI&Mn>LzL z@~;Iel6cwz(hFk-%}$uxPR{se)8)?}JF}Wv?jkK`#G_;^nmnaB#=UU0dP<`@=s;R* zi|fpSaFLm#hxHa%!l$eCV|$qqVOtysKN7 zpJS*j{Gby4fdV+hf_6q{S742A|9cBgoV&3(;SA?z6(9A@ELHRFZK}?P4lsQ32xFnq z(!QrASv{Yw7HBpq=;s_M=0%7OFX{-aJN^22&09d6{cp&`jdtET?*4sG6xc|SiMwOi z85b6fqw`K`r>6=#A~Qs3a=)z;&Nv@ix%;ZPyh!ID^|Obj+&~;K+Y!GU0XN$b0&P2B z@IXh`sNi4!z%$j~n-lnTBR)-n%3v3R!BvQ4lT6V*D-$x6al!c8T9R^RwiE01dQ~k! zp$v0|UIWZ6LySQMS4G3A3Vs>iq|0E~aq>jg5+$t`CznKj!K?a?49?B)Bg$t+8$2GX z-5Lzcd;RJ3?GU3IfA24FpRw)X7WT5N$bOpMZT`0S>+8lC0`E=zSF6VxMTHH-#y4+G zPt%yKyXJ6>D3>|OOQ{{4KWNKiVOaL5Er{2ER>#KfK!%Q=iSS=pYVi_*q*X)3bT6#m)#cm9v zm)+@SNx>2lKXXC>Wf@4?<~&R zWNsSVoXu6A`J-NJ%FJoaFiqNkYQN#rG) ztBhZZW0+zpzgbkw>$9SvwH&<>U4FB4u*Te6l|4C?rpCAPnJA0;td)g^`ntm4i#JlS z8=qa+3`mo7+j5^ADs%GDrBCP(b7N@YNudkLu6+SUpyVH|^7EI`sLoDOd6*_7W`0cV zd>vi&+}TWL3h(BT9M@+&Nu_1I`3%{DE_$y#juS>w<5sNXe)1|>m$1;Zt$P%koJe7r zP5a^MdRJlM8UH$KoVVhvz`~qP3w2#|YP6nhUqYzlkY!0vLPT9|%AqhH%BsyD9fdM! z9MZ;b^uMK3{UMusu0;Lzy9K#1NhQH=(5nAz*umfZ!CePf$`cpt_zQF6zS{(+XG%3) zcV-=y)+b-2F7sPBzFAZ|T$D4u_*N?ANJ@@%$v#PL&3zn4s)J2F4~TWR$@`wWPPlnl7s-g%2G3WSD7{AXN@R7oH8P+dM*7wzOV zX0O7X(&5lzHD-$36UC|s%|DJ3ssA!8I-Fvjl>sE0n~$G;sz)WpL9TtNJ5)v#-4 zUH6)=ZkO0}a6p^b{)JZa9?Gxe{7Wt?Vw=XD={$;SS?xtK=3}pDrH8wEgE@>Z-`p$e z$ZFEoT zPWV~Xisb4L^Xk62laZm*>q6P*XO@Jx_D3IPnGZcgx**RYdKjJo91eb3hTi=oUSOJKEY*n=W1Ub}41GsIt{@<1&pOn^#><2_j5Xv(hcXF(_-aHw+qPD*ot8Tl+Jm)=n>E*vCzw{Asvdwy(9lY$Da`o9*RJ7bJf+ zX!<5yvP!9WxyEn#SLYVSvzTXOyyQ0`5zF1;qr)4rqz_K@L=-9i9wbN)J(Rhwr5_uU z7V)z4+EC&v|GQ`QVxrM+FJ4QoIXzUTBXebgKSjnUQK$J{Kt zgNRgc%U)9Hd3ERIUMQyR&W)$zK?=?Ik>Bad<8$qUm}OM^Byh9 zeF#j-H7QJ0x5`$Y>H2#MtBU@zAV%eF*FmZIsfM9+id~A}6u4y7BY4hPv1l}jas4fo zmF*mh8h>A4FH>GuE8&|Ix)=7xcnr^uD*6`L&#^l?PX9 zovf#ex<8v8tz22+`W3Gsr{&12EHO;+z}{{(Sa%B}IBC50ol;{yrtiaAE>9I~%{lEe zmJ1eZG|OqOs&%PJh4$Z{BsSFaIX#wA`ax*Dav<`Gyq31(c#rn& zbH(1Y{p1)p@>9a{=cw$B^$!{*DX&}Vl^SA?Y{Xk|slID@VPUF4ePxtQ&)EByf`23z zPwP|T7vqFW4`v^`Zi>B+W;snqJ{FWGL+nqVnsy zcm6NC2J3HISf~c=L(OF#zw-CdiOuB;Jej4!W_gjl$??(0W<`%WNOygiHtVW2yvoC0 zw|T(k$=XeUp-ytv42^{@%JZ>t)Kz7%MWx4DwGG%!{58`|#dt`a7;I}2WwdI2{d4_} zdL(9p^}cr1dZPYp^6O=m*0i=>O=dY#j(k0}aGq$pcS#8$n;JEtOZL(Hraskm8aGeM zj!qd0+n#(LnLUvls(CZh(5>Ufa%jkk7S#b`1CjXNP%R62X4#yT7PsCDrUSaiH_rAY z530_qJiDTD3$29QQS^ZE94_L;(GULZggaQ6VHoKsgZxtHlI&7_Ug+ez6RvN**T)Oh z46JqXPtntBP}S39=7v@EZHaC)XK2YTsW`U0a-c8YL`S_$=5p zN3(5Y%HIpTDHxX02PM|>v=|&u^+|~y;_WF1BSlZ zC^v95hQ~*;Z+3NAF)yW5JP9h~I5<8PjF^?K>R~TU`lwF_OBK2O4xw<$&v3m z^gMo!X9_0|Dq1X%lFxG;(5;A=4vToHxOrP^TBXuErqxNy-fT{xCwup!7b^qW5B)S1 zJ|?w0+KZ&6-K&!IzMHQsoU}Z$g%vVpFnvD_M2>Qp?$_-@E6+KTm|1%g$8nJDxDcCtG2h34!m|hwrRHU z&x4$rel2FY89ajCVczjpS?%RA#>0vIO2&awCLs%tU45sNrPW{7@O)h<=ef19+x0t- z9Lp9)6*IlrJe8N9P#CEjJ|B08PA$2$i;Y^%@xygZx!SU<`yV@oBo^f~s}oWZhVB?j z=5W3#vp-`sWs>iobnQL^nNc+l`-38NCiTeN3*My>$)VPnF`9h6MQ-F{v=UjZB6qW` zOf;%iCQq`>hM9Jo-qHCUL$%6OZvOmZN6QN%LJx(ecS?PC&(FsS!`QEU!gPw+kH&iIkbQvy3TV^yq zZt=2n5;}2dJ=s=AzfbD*5GiNQ#N_u<<3Xn*ada{BIg4e%vr_A=!&G4=EAkVn4u5)1 z%_^MlQnm5M_7h&oVRv+^8_3F-}6ge(_i0?vOME!b@HHBnHh9Nz(cTCP4+@nwIz4KplAc#VQ+VE&d9_YW zT10y|>BZe)8WBhO*RFkxiA;e7p3I(03+g49{e_!OM zRVW?GSxKxgG>jtIBM-`g_XwB`8MdB&9QEIJcjEL>ul2vJ|q zWKMstoWek7>D4&ndXwfxc_>rN-`<5|yY4gam8=`u^bf0f@mF1uiyNmnBGMA>Mp~S` zG_vvJUAM81Ezer3Ql5!)&)~gm`iu_so4g`kc>*to#i}%t(^Tu@InD&-eD6pw?&1F= z-7)6f>gV}-&iST_^taF5?B(*w>$g+(i%PevvL}`HzFSt?eW^E1|8C&9=f1@zv{ZeX z^BF2DGV|1fDU~xyG0Q9kHUggrA!(ht26Um_X#}zmTC3+(HOYAyXE(fbzHE>xWYzMU z(m0g`h{I!05*U5}v)rRFZ?KO&3?H|54G#@;uQME4_Yh&aV-@OF61sEFdro6fSFdV< zJv1q^$jwpi%EDx3_=(tsjWb^pCZx(EKWVepI$BWd6*VXEi5JyzF5kW4`{c*&m6t}D z*i?}F=Y$c-U3UDHVdODZqYK`934@Y5N5#0^mH9x$qDFV+<%2tmtGxHkN3vXC$~0_H zcz8ZHruIH5hf>i&??Y3zg6h#DZ%(fsXq>F@YZtanRhHAx8t~&TF{!(88_12nVg)y7 z%LcCKdtl%^QQ#gXovtm+VQNhyasL}>Ve7~h``euHZZ4b0Bo&`~fG<-F>Y->+lWxit>wC`Fc&=u`WSXPGv@+F{P^~1=%74U44Z2-r%g6)VQzjjGIR0Hif%m;6DbB6 z2EL_T`D4$x!4z$y^4_rUiz}zUfe%Nx)TUMsch9(K?|a(_V|f)fMst5N4Kc$TL!HO- zT_RmXc&g4;Ra8C>7bve3Rq&E=c}AO58t$lCICMyX61`9b!3TVu1?N=p%bdRmMov!m z^SQ^^!4j8ke>=#d*v7t>iDW*P?@O~qxTeW(cNXqBB@kI$cHZwvPuY|A}a*u~3P z6dZE>dJLJL;aj6IAFo-NH?&1lF<)Lqb}bukaAq!RQ?gKb_BQY`6`ZE0X|LkiCnxvq z8Ev$$Xp*ws0;y|weUh>EwbNhnhRTYac$HOMYcGrBTXio*^hapg-XsL}XjNDE4Kz&- zaqxYsviEFTh?lz7x4=XB>J^yY+y>n`jk8%k{v<3(g=d_fvbIQ36{EAJBzRdrm-VFL zGHX*ENsw6^E(_+mm_g;a+(dukhshPq#WKqXzQoxlZ9nzN(pns)o7KIAbaxyh4=$&8 z8qF6&%fU=N_=ZNEE#IJLvcx+ic?%Ofs+G4=@l#+z)TTx@ZE<>NOe9#dFH-aNjfEvMXP>l`OBSae`idXsb%`yU>$( zTGc%B-N~*3KRe2mK5jD-eqBL4n}Y99L4rq`;CyPQid{>C@pMnNb&ZCwt)y_7a&i8F z6SJ;+=oac!Y-+M|)f&B6X^q?7dCArO&^&Rg+>%K*(f{6e-Q{x*G%7tRIypL*j9fTU zi`M%D?p%C$Kat{fV}kaKQlwzq#eF5J(>dn(ZiI8|u@QT^lJ8W_*%lWx7QQRuZ%9g{ zoC}f@KANN&m|>ZvUO@e+Mollm_=QXsC9A$fr*q!@F7wwc8v&_ynI1NFjT$W$DV6b8 zOP_Gxyl=rC@a9d;v}xDb?))yJ@~X$_p4!53q-7$PYdiCtfB@Gl^1{lsU;B&QL}qW&NyRxcM%QA)h&6iC0NGSEZ)B&sc-Mq3f-mD(7t9 zAxS0Hxb*IYaIPSsccDxzuhSdo)$VSR&8DJ7}P-vaBe>Xfu}mzS<=&u>GqmA^ce*|MaGC zm+S2hnHI~;C4W}!zsVyNV#v2Fdb3W6*3y=%>e)(h%hgSVaNZF0fON0%4N^rfF*Y@0 zUV*#r_=+143m(D{gDqOP!oLv(i91z(o}NL+{Nlb}IXX93*R{weD9$|}v6s#CP_Xn# zIO5#q$w7CTw^c*hV9(~eUzl1Dyes-`AAvUk{gEYS?U(o)EwVypan zAfAR=qJP)&r$6^D%np*+aGsL9xhIV8%n|I9x!2mCH)bZvQj`3dUT?fF%zGLxzY(+W zRMhe9n*7kzAFTC><&|UWf9lZ$vGbfAhygL;D6O1#{&May%ay-Z8|jWboBG9MQ{t;9 z6*^~>QmL;&Q)1;A7{=!)p!3%>S-3`KdnP!znfH?WwVOd4Z5DTl6W3BNfTMV@XNlI?`>cJkE7-K~>z& zG3s>}Crc6mw;09Y{|phhJ^Y^-9b_wB+f4QezBh0~CAU3iIl*t% zwOKTr*Zlj0lR=goqm|h8l}~}TW3Dpu#f%+l*1Z{F5y|7$V}Cx+ZLUaWqV&GRQ#({! zbV5GK#~_rRwad{-%aRak<5|ld-_$lB2R;)dXQIpenrZiNVbWXm1VwhCey&hUL1P=e z9Ak>>5{1(GpT8UkTEmqQdBf>h;cJFH@k;%=B|nP{#XOwacx5|B3VRLQsP*H^-u^sh z9EIVa;-{{B4HnZhU88OqHcQu6H9r?A@j0`;;wn5-V9lRP{Z-IDFSb5ws7JxE^pJ?y z;_K0clVdjV(SsG~O9u;{C$~wN*C@w*ziN~>NGGuT?7Fmd?RplkAvhXyJ1DJ?>o4=M zJkwQ_rO5~Wc^SJ&{-+_mxuMbK&lUNNUe`uiBfnQRT@Z*MwHvWEUDQ_Gr}oOkaq=lu zA*IB`xf# z>2%OhClwZS=Y5>zsLIy#jBYuT`-YKMNVD~;zSc#R79G>MhBU#Or;-eUf)X~VmfHlZ%X#uT>h+U5fi8LhMedrWx76n*!lQ26@sPpeCF7;sJrs@%>? z^m{U6;Yc%bP;YX;PIg$*vpmIj+G@43lx%q+@cN_fHy1gsI5z7Hyw5eB<{(r1`Q~Qr zU+H#KJ>4xc?|AO!`^pW*R=o4uq%xhI_9(sT!b|pLU-?7t{_oLe!gLE12ZM`7irgWc z4-;QW!+UhhxBXch@SMh8Thn~|uH>$z{Q>Q5V3w^D+b6Niw>L1lcTx7oWEXX&SzGrAbcF9Tj+OOID(9HO>&>Btqy z)~>DgYAPe$zlC`^AK{Dh?+^ZZ;icc9yZIXOK`VY*JQYJNoGcroJT(b9iM8FSw!_lb zXG#Yo#%Lacm4%M5fU{@Qa>~=!ZhufmB(`1>)=1?n45Ae)RM#-?qmJrY;2v9jSe?&C zr_w8Mw{1lzgv&XYZ|eNyG+EWhfnROx9xh`u&vOd+7o}Sb`I-kKdG5Y3w=ua@OwPN* zST#NI^^>I(2F24M8G)f)CnI(wxQz^DOGGS-9CTtzG>|D$R&z*Im;AMs%#%r|G5lE( z!h0e2W73Mlt)ORlV(-1K=tPe`ta?UntJ5QTxH4Xub#*rr{~JZ!s}fz~d z#&2x~5tC{*&#MXNGQVg^`7P$m2-qwns%~MoU4xR-yOLNHMJyg#h8dc*XXuwCE!k94q=?7G8?2r7pP2 z2Eu3N+#*NHm_eONuEE{uYKy^leP<^oFZrjOjyJ41%bDPgK88jwJmFs#fCRtKgwqjx zRd6V9tY9i%Lrd_|!p+P4E!l)W4+^uj)Wr<5Yh-!os*_>Gr5WSzo;Jlj^$DC?8i z-rVb(LaJ>_P8giY!=`$dx+50IhG3j>f9wBSi7T$ImwJEc|4czCc83DKA|fcGvKp zcp)R)hKBI-b<<6gDX4$n`-ktSBO#=OCloUtb&8by{cRy-IhmbWLB+KfCmTx<(nZVa z&bTxOm(XE=cj)ikeot(V5j71Wc*LAY158+@sTW&OoF`8X+C%(X0R|DWPM6}Z9wC#z zECk&>DauoF?}e=@AGL5?haYl}-%XW6?jp;Jm4a|?da%)+pA5c%>jBDalYTE2%%8QaJA~H&N6-j#PLC(*NksW6W*(~56#2!q~adIQgmQC$;zJj=4!*EHbSVZ!va<&x{l zW6=l0-1Jgp@?C$8)6Vq&Y=i!HPn-ftzo^NlgaW_^GOMaO)6o^FSKZj8qno7RJa*U7 z%}`;^6VtR)uvg#g*Sv6&BN8NYwAiCnl$`R<>8t$zMWHyNS$K<_l`v%T~n7f#Z zLe4x_S`)sKc9u~m1d{#9k(=q9nV`QdrbQ@7RHfufFVL?%#TkI(OdN&aKTK>fOJPk5 zUBR+8%p7fw#WmOW6=BH7Y+gd|$!kZ`c~b1S2RuhYXsrDxuDrW@mFQ@cf_419=eedy zCDeAe+()auh+J^~;n#N2ep?l~%PsBogtB+zv9?7fa$@mYx@|p28nJcJn#8s(lzJPR z@?+w#$FxRaf*7#hYBQMDL(Y4#!@p7(gjiCBFG5WR;O2A9kSx-G`KSiGbp7Lqt($%KCp-!Y%z_ZLC znVB@{ivI2`)8O|R?f7c@kHX6Vjh+#ChP9)(VpSUS7tvhiJG15YuIsM&7r}@S#S?J zMujasJsR*k(TR<;$o_#bxZDo12M>dfQp3M3OuTH#5sLfheO z`*lA<7)Og&5P$jQE`ubEP=~e$i&DgSIHcMnv@rR3!Azp6e(~Y7^f=^P-iYOPtbLUB zasAvjX>EhOMFHO6m%BP%o|>g-BmoZi3hUfi2djq|M}`f46LNMnyoT+KhcA~W z#>$lG}$GyR8o-6RT!*6w`ERlrqa+E7VivnBC$$4$JP(|H7u3y<*t(ckHV>i{Y zUXmo-?1>e5^~<*HcMb8!Yc`-}@cv(g4ipflo7JCxn3xN^jhHRD2;yb|8R&i~K6iGK z<3DnSjQ*sgK(s5%&>S~})sFsYOJv&IBxpU|Iym8cmstyGs~k!$Hfz3TpEnl%K<9H7Y{vCE~@kRUU!6SRsvUde$k1G^p}%- zej|_XN9eP}mO`)2ko&#VS^lsQdgn zPA<4kIJnkhQW)_zO-nM6^iHDxPFLp;0rMYT8pp{MEH7JL>qTDkMBdIYRC`Xk(`RBZ z3>(q^$pAv)s;3MCT6_~GrDc>7IR7M|r&23>8f3YjdHfWHLljOeqLsj}9Pt_I4aWFn%v4HLo#M95a zZ)Xjeh*@@0wMphr_o36?R({hjUFewSqxZNSv|GKzK%stPbfg0rdYzDEPrm*9*XsH$ z7W&mDJ59fT!J)r<3cTh#b=lA1$)-gdtb?N3-7N!-;IBCtmEMZ3RThi=fH1+ zywPgnYr8RS_{Af}PpV^uPqsXdw+XgNC=I}AQluyHj-5w(%;GiFvm${kR_=r*<+d#i z?K5Ql>wxK=b4lOAJjK}CuFajaQ-YA#HKqS`(r3+~E85%Jf|RIzyvAy}#{ZB?x1%?z zSyuN=S&Y|Ak|!u~&e|_mYmR%SDRxE^P`+5%mH|)ya}>q4X^$2mV+792`Qi~*YAyc#Pyh@qFmihe>IXR}Io!Yp!>(8w!F;h_?yLVgVhuA%fT4%hve9 zc7J_7>UX88WA(?>jEh05qp&^?`X5%fwC1%Hs7|=X-M2!@jJk=HMliJqKjnSLqNA{D z?#03EfdTvT#KvV z(?zakE`BmV4*>Cj=3t*pf8>cYa*!|D;Vw(vlC@rj@@fg!4J6|@ciDe_fBahY05yso zH0Sqq!f&m1r)XGP1dm`*x_}6jsO;Ud!YU_r-qn*Z@RFTJ4#GOZo z4{NIg<~YT8@+9l>MiM++Yr?A$f7#$C?b zesHPJi)@%QY?>I!S3wv*dh1CPeGqm{rL-mt_k+In6&s{dN4ux#8fPj>RH#@z;4oH` zq%gz0e;{p7%);~kAb$}Z{^9E!q6wv(;gJ_8YtWdk3^>zQ5=&_b`SOd{oi}{tvZ^HeF8D@qK>D& z&a)tGp>x!2nVk*7wMakR?xF$gbNJ96*8{Ogbz^_rG5TRg* ze`kS$6v#elC=heqygoWN5J+vFx~9K{?}nS&X9PKoH)ds*Oqv}-Bk|82*=yN&G;g4s zK_R0bsUK-~auW~D+m2dW{A}B5TU@ikdh!z;lHZ}w?>id`lZRmsZck{9*IEqVRXssd(-+<-4#bexPdSo?uIM6i*)7oO@ft0XylaMQ>8cNRa z(1*UqiAd-G#i2q}o7kt1%^noVovpkXS30%W*v>)8z&pB@Nkp;Iz@y98-h^G=%yN-W zCh{0pyf$mT<6pCzBfV$L`>~=2o1;r|G2`OjRjF}rIQHre#yX^RPnjq@8+P8fE9be0 zUTwq9#!?c4XvTXTy0J)p7hNX@%fzKw()evFM;&zbWf1mT^=L{Eo4W>drVykMRKIDi z!#d~vZ9F)*aTnm3<@K$LI`;@x$*4u+a&*k=cKB-{DPd^?Y~e7C^~2CkWaGZv;T`b> z7NG{^^F;U)DSSlbCciWIk0JmW+fVwtb}PHS6G5kR>Vs(&JVePHqUCH)OP1 z&J+5G3!s@!katlHg@$Uuo1Uhl$cmk9*iccKsk(|=ZdG-1*wFgTHc|8M*RMj>t`W3Z z!d@TVq3ZArN+8-AC$Trf_C(E6E$D{^3L@q!>wV^i{&pU@ArWgYqdWaRQH)Yu@SPK z{x1cZ??joIYBP_sR2yJXyjR1oR(KG}#m6y+9u(ayhkFE`wX8Te=5^SX%4~Pj_l0*p zrZYp+_^(xtH~U1={aSbhja+v+Yb5L;x0pAv13!p)nTUlENNjxl$)6T<>NkCCsJPsksl!154#u z(fiR<1r9I?qQzh;Xh=oeN%{ca>{hJ>C=FW(6(lPOc@q`@pImP-^?=)yUurx4VSYUWcyII zADoRz?^$UarB;UoUbzU1cWtmN7_FDM7)*PwV=Nk5vt|$^qfy$)i#6iD=qmZWa94a3 z{3R*7k#O_8HzPBtKUC3DmX;I+J=8``$HD*td~&F-@t;i%4~I1EEh%LL8j+bC1TL!Yeb2sf za56WDx6sep0V*1uE9g%x!QQDAi$jHsouhm7Gc3V%e*LoAV{_fh1?g($IkojRkQx)0 zH3PKHSQ+M8j{f(mV2~*N{_^@^o9y^pb#?Q?`8|zDIe&-OKuz;E&~o@~+wy+_;h$e) z_JE`SICJLx$mq<}IJ{cI)d$PU*s*A`4PI5X7YgZm^?$$r>j7hy^Sk=~ikm3{u@TAJ z`lh1RLQKOeI8G)x$E;B^2dx=P%vSMX{-M@?U;*yazc^i@S2=>GnawRGU~Vswb)WfJoZKkWuyu6Ww(VO%Y+;!4YGD!q=1G2(i^lfxTCB)v2sw(s`nx7@@UFX|&B6|iSEnf3z{@5O5x9_Or z+FG<6FtUX(PA2Z8rid?=AYc)X&XB(+g$E%W(ko=llieky_8OcI_& zVi8@*gshBA4-JMsi;sS%m`frqLlW4%4#883fl$w-lSjuvO@4HkP&lauC7|Im09y0S zyT=~VQC_# zsi}xYLTBBxtjV?=6sIR8#MuSXSH^wZkGKO;a0?FBA;F=2t8P-EGAn1%2Fk&c$8OMqDt#D{wo+#w5clg zSbSjbIKI#e)8H>%28qYs90wWJDK!)715(E!FMOn13c^eRUy$K+8!`Tc3rq(-_RJH3R zfbqb21+_Po|7g7(8y)$e&|OKqxyqAU$lsx?QeB9p;oz=2DJ>3;@{^NNQY#F-Y~Gh8 zdtBZ+8HARmtMz3c_kwFz!WJeQ9NfLg>zeCnw#5eDzQMKlRA#m1C^SI zNOdtL3!VSK8TP>yf|pbG>;juTfb>;RE2#g0RTq0?%&wvWbfTe~VAurIR0MRWn<^r6Q40aY$f87O-PisCwq+@(Bq(0T56;*%f$u3+VzUv*QTV$fI&K6)9v zPG!%kXj{hjD9fvwR5R#nfbEU{6#f|cc5vG>B;&1N*vk%g*Ikq74eFL|-_7*Zy#q7T zW<#lVu;Hev5YVLD_u8O-!gzg&8!gAn4(}wJv+|nnr74hRYB5I~6n=rJhHNu5TaIB9 zw%-t)*4Gz$k4`e{W?@}DRYCo4BL-}BT)T2w+%ck?O^!^lHZ|!G5azlDXdp zgVE%~oGdp>mF!BCj&`8XpKaJ+qtcHyoC=FxWYn%xpaCY9&Jz%&NaW;9=e^_rfj)RM zw|9jHB&6=X!9H+K^F@=9VabesP^cQ)N+l(7s@kE)FU=SgRMlcIe6OdX8y1INk2l+b zD&*vFrb`;HuHq!8^;`Ufo|5=%`lv7JVWaWjEd+Z`4#!b3{ZQ20{8*sWcMfvd=GgjS zpP9aGt86pv7RJf%QZ4VRU$z~;FAdni-UJQ9*jku)w4pnK#rSn+$X*2gBlOuxVUtNY ze*CZfe{W(6J2$S)20#B%6w43IBh_p8tj%|KUthIxbeD^F?r~HDiJGn<%=aiaNTN$8 zuvgQTp*y(DxgsQ0;)MA#>gGCD&ZU4;u&MoqdEr&1XYCwn#sSaCscfN)JbFKtgd#5{ zrp%0pF=+qZZl_AGVAd@!g?2F zXTmSqaQ@N^?cMO;-D43wdeVKTC^N@Ab7aYpkuUE$&&5zXZL{1$p`9AIIY2JodFboj zY;4AdDV)5XX#UTh6S6h9JYuTZhzQQDN=@nDS?{47=dKei`x=Im8wUxYw;Xf}it{-@ z_1%a+;JMKiR)_f+)4{Np{W;U9nS^a}q)M&lhEe+^Ay`N67ju=$k%|DrVa1G|yo~c( z-5xYB)*yI< zEqTCy-UcDY>De4;IN!f;QB%xaH)_%rX<5D@^la|L(Z!fP8PQY1SVUr<TT(0DyWCMl1h*oC!ef$e=^9sqZJyG(5dtfUw|i@iV97sdoRDdgN1H{DYT@c zBfdltjwxAG)RpP2w}5b7kw`_!FaKolxO=Bm7!vH*4>|f|9lpzS2kSflJhTe?=vt9W z%=^jEZq**XXf!p;?&ABq_Zkry=Sz6kg)TZ4rJjD63=G0BgQGSV zB4oa%q&UpRQ``GpnCM~1l|zX@-DTgllGpO_IbrFW043?AKxHJ+b#+>->iXgBW}iTm zt|gC_U5C!T^8BOB^Fn)>=RX}uV&C~i;5HQ#T-FKBdm`SB z7u7tHBWCyG3ih0idaU0Z76hZ*9S<9ldijd2JMZi~g;nc`TB^WSpArFhe}TditG^n{1VEE?M?sNE1VrD?&7JId7Ijw{F9eh$s-3+<7f^}~% z^&Xz;JhIM5ofzSqxZ+&QP;!1|;okn|Tq;%)v_A#x4rkjn=jLgI?5UX;y*v94>zQZ@ z#>UqFFGyxrQrDDILdv{Xd?I9pxY(Na`ekZ`Q*^KR*MrA!uQ@CC!3LM??DCxM&~Ehg z%#b2>aEBl%(G6YQPp(-a2l~6pWy^ZqgLF5A!$UavzMQ0}bSPGhuYfvN&@;5DfzR&8 zlL5^FmO2gjX3Cnj*BX4*jji4G{2hjMjU1G|G}`{_iH@S==8DGLP<(HnPQfRu1~Mx+ zH$V1pe>;fa5*AU?Yf&>BCElBSSl9|w<$YawakGs*4VyRkqC2kd4N>j=eN8-J;BOr{ z#-qo)=eJc0_{|;I@-qqlH~-P_*Y&8~EN z9$96Z{WdHtyL6O(oo<+^v_TjK)SKY-h z{|#5PnVQ;By;DJfR}RO-#@tOf!l5h62OitvcR1FNbfi=Z@uS%uq-J=TdwV(B9CXRw2wWd&T{Lm@Sx?;z#blvyIr|v65MQ-O zxXJa3OMA)gfLEIP7~8ub&E7KG`F|12`fouBmv!t*{asO{CO0$t=;K~FUQJ5{&&DKou#S+NZlP;|RqbuSiUL;Fn=wMiMKh_Mts}Bjl zKJ6sJQN9Z!<+8&U13Q&IWo%>)s7rDuRK_gLQxk>jz@wyvK@fU@D*6CRjal7@rz87A zK`9;zV1#aD8thjFU3<b|NpY!JZWB-%loV%pjl_q9H zZyXPI_vOs6FYj7&S}kzAtj<0bkBgh6QE`3*CQ=rJYo9ET=d#b==qVnbhUKZBamE9? zwx6vA-Z-XFEIW(N6}alCUdSN2vNDtJ!LtXyvt;@rDZ} z(Qu3S6Luf?NS~QgeQ-xjMNWmIQ_wk{vA=)|0%r0318j|FnfI01A!Au}5ROa$^JpKw z7`4!|!2iIv8Iur%$bV_dBD`Y9{cr@XzpWenpg(sya$%%m8J=whTfVE+JU-@jcNf`G zrE}R5&NCWevj#b^Tw_&lyx0qBaG3m#x7SDXT{Zp}*l8Zyw(ZMbd2sd9jp4cz zrP`3jan`~T2^OVjx@zrv6;UO9Iu6y!26G!Gn%Bc(4n&?Eyk zg}qfPc93M1xP)BOBc*4eWIsT+XU{ncU*UU>Q46~24zVdZVlh_VyN-3hUV8Ow9sZ(v z1I60uvr|RHX}=FZ?*L~D;KcdY(}8I=2PmKNpA3@U9$RnL?7ha-+X`6JGO>8j#M@be z6Jrbn2QNs!_6@UQ3$v1dXPp^2{_LwHdDeLG>@%L&ZiW@uI->OAAmbQplp^0<-4|#K`0S?5;z3@*gA*}-E2X4cE zGPFb;oaLRD{K;^l4?DT1^eH>e{c*ELsLD9GxDkJ)73#T|&wOjbwaN!{(NXm~@YWds z6&z|QzMf22n(tQ2;j6lkZd1>4$CctnM$YqlxO>9pinb%#mODcA+&28h zUL$-x1Ke_l_c=2!)jz7A{`>&_(8N4*cW7u$0A^VW&tz6tgAi?s=W1;;GK-xF}* zZgA*BWE0fH*@nxzJs?jO0XH)|scekahD&r%vs=1a4WK|Y_ZQ=-lKV8@wN9f}lSuvr znKV2~t+6v0B@|*cXB!mSGGk-N>bcgAY%YH@BoYJrGcnD&d~mQznIpYgFT!{%!LS*J zhbF|iH{Ln=hImwPm{!{tVTA5-u=U+~;5c8^Srsm69FjS-R5=y9m)4<+>*C$BJ`6{p z(p}}wotyq^m=Lc=5$}w~_1BB;q^3IeM*Ysom+^_vAN3wc#)Cp3 zQ5z3g8oj7~Lykug-^lx4ja9ymR-@S+=7J=6dXI%Aj?91LCeygdzboA^R#|BHv495F zcX-zA^O#EmbaPNpgW>GiuVcJ*hdJ7H>Lq7Ai;q6QA59c)BStOdm=xHL-iZGXNF~uJCC-*H? z5Xgtfm2qJw9868y=ZTbhkH*Vx?&S!;@B=_XPW|vtNs4>odtbSd0P!%V%gc*twMZw1$rGR9Rw z?S~8roVVxq_+B{H?S;jWtBTtSt-laI)E&3!#bIJ~W62<~w2I;Fi$0R%g19SjM*w>o zJBj3F7uU3SEVTALMWUo!A%C|e4~pwb$iut7V-c|weXCos%2T0&Dp^=NZZ6P}J!IC! zmFT1yG7cCXfLkzdC;O49tHvaFNkr+2R}95jE5ta~r{ij8HJE*rhL<61tRz1*Yf)@k zZhlEGx)T)KJA!Hr4tJQyn=poqx*f8(bpv+yh=v`eVXf42^kh>Oaf;3Se#Ks5<023w zfLiV}R$OOb&BKF>KnKjbG@mNPtp1C>>O2qN|=C7$7*a|i-h@+bcS$}14foHa*;e}zvlo# zEx!4g3T`Oe;;*uGJ9vO){-AnI)3j;M1n_x(Voj6c;ImdAfEkaPR>G7XPZ+fs zsfIqvE@4z8<1RIH^+8es;)<3Qmc~;#Q=bPt4iC~_?4kS{v!rtG@eH0)G~3;EadlDC zdyZ%9UcKJ>*_OBUi%W}ouiqN9NO5W20#?a!*qdZ&lg6V$yD#gyT1H>uxj}@$6l?f@ zjWTFIsiT^CsAG?j5B(bX>4F==agywr|z zdG+D62V0`MhY;P(B))ltDF7;mG#Kk0rL>YYOUlMekI4FV#*!k!(7CeG(h8UE!+>Irj_J!h6uPh-F2Y-He%J7F( zD9;VSrvqyj(!Ga+wIXFI+F)sO+z%)1a?7f(iY6Of9tI_kPgN}b@Ud~5Sg;Yi(US8Y zg;r?b&dOWccNFz$*s%|>Q0wj*j3PDLVENBcNm@;sLNNytKfK8r2>*m>EV?3KN_d0W zhM`l+?OOs3)1egjjdm0!I?Uu2v+^cP5ZnK+h(JMk1&|gzf6o6#FM_L1L+H;p9O{7` zi+;Ie#);0_y^G){2HP`w;#BDeaHNP3xPVqEg8mVEyn6E2Ar5L^+Z!{OaSL8FF0G`5 zIcRmbY2 z^5q4scJ(4S&*TL=WyWih89RVMA%7P94MYR+qK8taVxQzruW{>MIp}EY!7P=TIQ$rz zt2WhzwW#=1Ar?Ab>!=^+LK6D=hj*?*}Vf zl4P57Xpu*D(*y6_5rCy7g}KR7L7Q12({ar|G~~@&_`zHI&gfzrbPTaovThdzB@5KL z>_fZ|#WHmrT#BNw4xx2mE2%v2+Y7U93#+a~08^uWp3PKqwfuc0Hj7R&yqWC*9F*aiG_ssFMgZR0&6`owkGipp59I+9YEnC+ePzT;sgGAPCBmh&G~=;i2)MX0w7)dw7E!?>_Rrnb_xoUp(P6KY)Au z!taC=2m=iN8xRac0S-nlil9+%a)tA^{#euF2_H}0ZtClJYyZW~7LSStmtW>Khs0H5 zvNne_)XDrQE%C#4oCSgXB5ACTd>vK;VYOBJ`-?_$qJoXkzT#_W-3&-3hIW9b<9)!uuO#q_%DPA zkd1|=-gUq2?4tj>QQF2kk->VzT`I2^6gzo&(+>q{(D{2?X_r3`5?sGqcfjQlP;%n9 zglL;MilHd$MNnaQ`BX}atAbh<*r=v%Ksk4g z%pM#K>HY2Ryuq=JlC35^JCRGGTY0N{ZDvV_KmA%3*iQ@lsLA9zaRm44DyXoJe(QUO zkZ8EyG%+VTRWXyK5z)egK$p+y%*DcwP{TVv8TNfa^HEohU~Qk~@To}SRAx$1tQ$gi zCQ(Lnc-0cmQtBq1Vdm3nfp1`@&+DkSHTYk{He$^MR^7sDS*pTHzGmkyzkYIeGsIsZ z_*}PxifN$Hnx%kz(DdxdO0p4#ufzN8*PJ7+nsFLdPS9^$&LufDIXPvKhN7bUpyrCH zCC`mS*mDhQ@eO5(O)oq&K6GE)xEQMHIe&fOUC=wnLQDfW&}7`uX3mI=UM#$z3N14; z4!4)h4o0Re;F~6B;PdwzubGwcKmW++Q#U-aamHM+6EFN*&i!pCMiZHdfjAX&Ab%qfCp zlG$2Grt$z9q-=Vt6XFFxzCgMRk76WD%F1UM7*;l@n5>Cfk?=VK=}&K+m&V`P7 zeKP{Qb^^NnS2NivcI77Juy3!LG>ywBiGiv_ZFfEEw%)k<(Elpnk_}-(xJ-Bh9+o<) zy^>!0N8rbIEtZfD{T9OU{bk+Mkgr2LidS_EgH$k2?@s*v&0fi1~rN3S#NcT7P%kK3#*J~?qW`;cD1Tv5doOd9q*S*o-@PG9iIP@)Zh$5K4BP3~>sMi?}Ts+Id6arO``8FW~ie^iEquagkQ zweblJ)#M4(1?SbAty)mEsru{+ud3`qSs&^BcG%mE_%}Ioz;vsen@0Fy-J}%b&+gLy zZMdWpY}TpL1dw2H%vy~gf4Q*68;kF+E_^=ilAT7L|BW2^U4;NK=ywhR*M$cZxX#0X$UkeGeow zM9fkjcFuGVFP`187|U$d5tkc7eb}~~yH!co#;lzCf&`%A3--o?O)kN2QE{0kY~snQ zE6LljyrAGN-mAS+2>sIMe`e2JXwuOXF`vwhC7*rMHsFR_&<=dwzv5LbsoH&%WPR-$ zgS1yoMR#Wl2foVx$+Ju#gF7cPX7QmVnDM^Zw*9=BV43$&Y1`lR6i*&I9N*s_c1zZ? zrL7ff`QQkPBs~JD;-5zYU%tV$S(wd)9_fgN>)ZYCUj2tQ^6>94`_0Nqc|b@!wG@3~ zkf}`@=U{@afD|%(YI3mm=LY#yp>}B2*`_8%RVYhZu+IG zBwqvbp*y|TZ162pbS1^XnL6)>54;}kbv^SyTHLlv5U22xuKdYfpep(7{qPD^ub&L% z@dQDOtye(K_nTz>f#FPb=!?&ABMukij*sP82fJ!Zm#8R3AvxMx-Bhk=U<4=+;#(Bw zgL+1$mp&yow>~!7)elGA{Ccld`W5u#zt?RNgk^>%54(@1QsloCbFEn{g8rSDw2A91*qrS7^4v-26%xIXZSKfXihhY4xJl08gTfSHlOcL{!I9E3E6e#TM;T}N z*Qa$_YMbWxJzlZV*;CuW{lihQPqo}xI;=H=u7>bpyqW21N_2_Ay_8i?t;(?1{{W%npJm7OV8t1BG2zt~;-jiC zv{ir%N4nqF`K1J;@FGnXl%jvg@sY)cd!=hzrTLRwp=928e2iq#e$8z5C;5bp&2{Z1 zB{wXoyuacP=Z0i^Q#G&Qn8Co?#R#q-8RN2(4+ibMSG01JZb5WD4zOx<#DvvRLUqBC zcQFbNza8Do=GvbQhlDrm?^34^n2*#r!35HTQg8zs#YE_2FSJbLPt4JM(YoOi%M@t(~WxCza^vZk;_6%)k^U)&LFu1ZVfrt8DG z$#EFv2kiz?U&D{?%UQSa#;0;B>h8n-q~aQkfkwX&8hL*+5>pxXpxq)pXk|>_+zX%r zU=G#e(%}LMPU{!F`%=PVt^{aADTE;oLluK79~|q!4;Wd5*Rgmd-H-Jr4Nne2AY*Db zqB`~sKDA0YFC0JC-M^v{B_66y&iJ?*C|S}et!GOheP+x!d>u(R3T;pX6j^+p^oP{< zEfoPc9f^Z}?V9lIW*ao=iKXQVku71{Jk_Gsl9(zev8c?u&?A&vrRpgls)HR1&U3;d ztERn7QtOY)>%2U|yKuMD)AVH8ucs`ISl^obNPF1ysbZH<8j1%|LXbTRCj*?O+qN?W zOF|!4J@*5!E5XwaKj=AudF3QvE;#POw|dlI6QiVHiB=eXG+z3*^s7c-)#n=_VUtRSd2|lJ=I1`CabYJJ`z7nxibZRq+GO%4v}8@ z%nexgov~v$`^OXh*(n|u3H!z$J=tl&P2_RIajYHe>-hsxW9)(8IO!I9&G z@r_dOh(e^o0tfQ#8v}VU$qKs(aP=i z^$DNy%g)LNZ+lj|zN9aI3EXb+Rt>55+2owv72c`nE|BcV<5TYxb!c>T8ov`|(?hD$ zyx}IcbgrpPkdJO}W7K9d7)d5`&?5b9uwLjE_uenCZ+#W)zNRIA6(m^>zFeK})zV?z zKCB^)(NN_`p6$bFT*+g;K6Oj|K^064M?Ys(R(s5G*HuZ(o!fJWl?R`3O-g27_U!lT z+l4w?*dRvlVP^%egd0*?cJf()Fz%hC<55xVBsCBj5BpPcRD8if!br#2JNtRj!)60l zC;~jMufG7qs=G>Ilvb2{v+zv3rG=j=j;rOvVQ3~e?c-N_#>-Co>Zq+Ex%;)PV8P+V zW{z^xV9r0L_=1M^^Xf-@X)Fqm+V6FT`wzYb%E7B&{8v?Z6>U)4ZiG`oN64RpjMK-B z05EwTAW-kkO58o^z3l@oo^w$RphZVVH^!(XFJ2d4dX7)D6IF_eU&)JYiB7z7 zPd@HQx=}C&9tW&{{lVP$=vE^!0pj;{76*R;uT1>}9(e>ROmM9c6#8wv$Wu`1fdiD2 z#eG=p>1jgui~97-es|mwIE9yfDLk&StR8522k*qm2$pXPPb6)P4&Pp( znVG+!rQg0L+n#UL)5-t$!lR;reDt|g@&f)lfkWZ0-%NDIyUE+wo0ts4ZU2xw32Jj_ zX&+dHP;qq@>IRGJHcY)FSR5)AH)7d(&umw<;%*A0QWE}wI5{^-3l2^6%(al!pvpPV zA%>#Uty}JI=$h1``saJTe8nPo&vIB?GKj0lGd-G+>bo&F_#(>oGC^LWh*s77EKIOz zRbJg)3h#!StTB{nG`XXyh)5ZYg+ODKFLX4FhhmX#!7bSF18JM~#wH@1Pp}kml?mUb zkuE$Cb9(7tTGr4gQkE#2pU$+-e95(@E^pHg$a!{-h;$pOW&VMsqPge{5y#2XP$^K3 zEf2Q=MW`t3uNk}eKm|flmBydxl1C6RW0H9$l2_=n*%xic<1JNE_`7SWa~rxf1T|=J zyjKv-{sAr|OkQK(-ic|MrV_R|5n^&7%zUk8fp5HvI3wrceozE5aoTsR^z-4?rkSKS zX{^Lvd1vSu9=+>D;i{2dt}pCfvzpWCCl9n$L91^hAd%6+lh*yy>f z?ZU-l5Z%rU=w1j_OzutBwWSQ3mXvS3{5%~U{hMhQr~r%-@f+X#I!w#-)juxbcvKGp zv_}|z7@Kb&FTP+`q_TRPU}x^~)-wh2*7E!Z0usa$)i1-!Qyaap>j;TGWJ%+$5Q(~# z-vPfl_!tK({C`B9cT`hZ*!3A53o1CGAV?bvAieiC7C@v)mm29M^j^bQ0O?XgN2yXH zB?JgX!O%jH5@HAtAhZCXh7v-U?>h6o@0Ydm2Q1cI$>QE~&htEb|Mn&y3pMTJoE^&o zdFO#<%WrIz;Nf%T2Ck3ceYUUkWFbE?SNm)qU944OFy)_WOFRc{d405hXa0{W&)+*S zT_=BZvlN+pCV?0dlwx|_bHnsR=&ePH6aR++n`W4sqcSMsh?(-?VA6tt>iOZFrkyCvjmURzn z%I_lRzt5JLK{gt2;%`t~ZGWCp4W57kM486ZAs5^EOq^&U%!OoV&fE7}yXHskp z!iEB|o}#^*!O?HJpz+&NLaYJ{JYiid2ifnM1zZp1hVd^q_I^-X$3Y`Qxl^89m#O#C zJwBzr``gFy6p(|=DwTO=YJ9GmY8R+UJsZB>wXMOKtXmv-@0Bx+w4~;r8(H~8%-38iRYQp_P(tQYliDgDy|p|`4!=5XH+{=A zW<8jU<{#AcgvId_&EFnZjNCXex@V^d0=)ITpJh)4&pr_^+Gu+Oc;TmJ>xo3OS~Ogr z`ni;-t1!IYKBT<+(y25sj2k~qQ$rKX9USbt6+%P4H0Lg}J}A3g+B7*uw0DZEgHeVN zeQdBDEgBO6agD=(gbxOojONGp>2G_l3x;yYkdNtoN!GhnR7IHFfgi-SMNP4?TGPwvrUxtXlU^Jm58+KSFi3 zPard0JD8|9_q(t2y;P3kJ%qcF-$emf@jdmsh<_ONns({^DVJ4>48P&KsFq#dZpgkJ zKr?lDWB3xxIckbf_Wt_!= zaUtsA&MqX_ew=H5b%$&r}{UKhuyXMYkpyM}3aVQpSBz zmD|L5=jFBm^9#zUS(!&WpO`y;#r@z!2=x3cy;lZII>0wZcHeksVxQr_b455!OK_i* zKjK7TxJ+cr1)ESS`mYJJb%^1PyxJ3QeQ5-7+jl_)Xk$AkOi2-O)%D#+No`>U!)8TW zVgT~rYS!X-OESuDZTB6tzJMvbc+{ciNYtuixv(Z^#zy7Zu2`wfWPuV3n$7q5@3l}) z+uvOOO8@)1&*SxOpA+LN3+SsdyH|#kGnSgB$|7FYYIW2fT_nPn`tDymGEFEBhDVun4QUL>Xq|!G~Ok}qt8-DVDjs7 zJ2$qeUYeHYMwn@nk{ivr&8?s>r%_gYD3P5NH}Wm(dO$E6fv-YSw)N=;I}+1V79LhMRpWhb^&Uq4t_^pq;bc2pHzlQMcc~zLCaYZ} zE8z@)%<|)#nV_DQc6JyYdfBbzgHAqYt&a?s|N2dv+os3k>x^K)3bFg7{l08n;mSqH z91_UQabQr^BAF2F7ZzDceYWqi*5`qMtx6TQJM2W+sX63vj*E?yW0W1d9)sQtxOR5) zlXe3lUl0!K@Z^w9D?b?9%<|rXX@ru{sef2#{DJ-*eTy($f+Gn1tt#?{_uH(5!a@K_ zGf7yW^xP+(hRX|QnWE`2;zqr-ur9eKf5`TE)|LAQjb{9SDYBXK?LQ3l+h8g;Jf}rF zA~clwhW8&Hw66erZmAv3vlE&qxO@huV2SXjnPB zQ)7anYF@@H=|sg{lLn`wM}}qyGkH`Q@!8loV3sCg*9fa^$QzkeX)Ez$SsU()4B0<~ zb<-33tn4kk4Ft59J75uYMg?GAze-Ys`NaxeZWY5Ue_%(8zb85#-ca{#OLm&OcYCxp zN_Y5qdtLwrqh<(K7ni~`>9EauZlpE`9gyC>ydFz}Iid^kyv6>YPw7k52zZcz2U#(72*1m%D1p&*ALzWE>(;^E ze*_HU7=9Zcz{y2a4i4oDi8X-2{OiOF<|vc-DbfP<;m^qsXS@-%NWY$bUa$u>L@8?Y z@-Alkak?OXa=@d#i$*ksh2!uxaDx6l#Jvj4Rj)`r*@3m=8*&DT^&qS8WTa$$cu?Gi z=bi;en>x~30j0j^`2D0@ILGZY385@4)s=qQ#>FLu8~_ihjTahnpq()}**!%9OQx-! zNYASa)mB{QZ*-m#+DilDGRf>HyDhWg(rHV16irvRuJeJJy6>P$^+@$ulkP!2_CS_A z4&-_w>DLDnX^)2tP_dbw-ZOKbXRwN~@`1jh?``53*1hnBVz{?n&QHeMuos8|^QrFR z&c4^IntR)*2Ci;(i(A%o0>2r^!BpV1?dGw4y8?HC(!CEm?F8S>4d<{Y-?Q{n-pYMh z)<-2jUtVknavbHPlLxoKyW+4*YJJXpNtytF=8_epctd#x&eVuAhu z^SSC^+z~4`D`u2uAl&@>8;mw(Ml5OiBjVq$Hxzh5ts<5XRASEtPH$2W`U zpY2Eb)PysL=ECizWqbOCw!243oku}!<~ex+%NU3I9|x6<#V4&h2yC+WbRpB!%yA?L z`lAR{*V%n>D=Z}PqT)sT>i3L48GaMBA*cd|ECFAeq?F5;=-)fFtYyu_B5j_2cYGh> zdr;Ema8z9kM#-Q(dZe-cW9|g%Cj8^5?JvK#;NFSFRvQ1phYVEC6$=yUGR#exiH$da+Z)V!84z+Pj zYVt~Iizn#u7QVi&tyRrqNUvZ$Xm|i={aH!n6k`I_Yk=3|h5E#H9>13PWhVT$?gZuu z|El8u?FRm;Ay+q+TeN{dws;+U2U30T1}9{ zFx2n^aT#LJeCC|6Vj(DWV7Z$v)UJOwxngzODI>Owv;hnFHX|ifmDHi=SC?E?_aWZ; z_Ba6Y$?|5iZ0~$-SUA*R&Oe*Sk7N2|e=c@8|E%ao zaWAW-gpXT!;)4r-ue7BvS-d?&)HwT2cnx#?UM>3ZF;IVU%?wKHiVk+#$V^;PME{I? zuLqXPf3oiq6nud;LQX9L|HB{&H;K4!xo|6xvay?}mP(#^2w+Y(=uQa)=LWgE*czpA z$A+HO1YmgS{!NuT{$tBUMJT+<6YK0`ob=RstL?c&3OpO;q3Kl7G0j_wv_kA2 ziWcVIN!y5@BEeAdsAgYSX!{CCcGYhcGSJ@Pmm~q~JwDEMiz|jg!0j+O?DkNPKQ1oY z5}G>#7Im40q&x;m3)mIutn6$?r~`rykeCB<+$T!HvP*2Fe%IX-?LOGkU#MmyRlKIk zXFZ`yC|e9yjp9wWNzEOxwP})ZjPsltVKo1q^{g?e58x;*f6V{*?DgAhopq_Gzs&3g zuJWq?S<{0|iHa%Jm6)r6!jEQ>N`m@E%Jl+N`P=7@wb{iD9`+Or5zT{IOHaCJ>;PW z^~|uhTHNWmEh?hB2I00EXNx=+MzQbUzc0N;KhJJp8*yCTp5!pdeHtjrR<;ncHS(_h z;kmzl`_*=0*QrQX6w`3F5M)k-HG)`LU1}|@FiiOCAzQh-^$U#V%{}yy=h}lo>fc}) zy3;3`rtuoJC+uFcdWtU(kmg3^1E2f8?e0qHE~I57Xn`~%8^1mAs&y+SMX1Kq(hy+w zm`zFrqkRw?_V*_0hoG+Q=8=4rkM`a;< zWm$eu5J3_B>$Ge}5o=>hRBBoBQf%(N<3Bb~ad-!>gzKt`aBVsM($fRGb2S>Qc}hr- z6^|IPY9rlX=X^L2Bbf8FA-wL!w?-hFBEQUiX_;5XW87C8ZRO~^dt?33H#{0E5Zi7z zA$Ov*2*{6^H6o;^7;%yZ(zgBE?#Qr{!w2F;^6*$} zl(qfDdSaTpPQ2UhE4-bq{lF4Bqe`JRq4h}!pv9D*2fe>vk^5Jr% zsR4$!mH7mU$3j>g*T&>UYKea6Ie&QKzK4HesI1X~bz~7E*ovDe@E|}QvhWpO?Gpn9 zvF=QOxMk?IiubvUoU*X-Jr(#si$LR9KaiQ30~W_FW~iyxq4v_;9F%KI;k|iYy0-lr z)>bV*?Tjzp;23n#1*2+7D=F~_FskF; z_j$b&j_>m3+3$P5nIz0lNK5V8{=?8|FZ}XgB+B2)(P}p|Jh|yfpY2F>k@}QPykm6X zxR29JG2IemEGRLoBOmPGl{TXciPJla3nDI0CSA0c_L5;#p3c6@Bi?1~(i;CEw<|}@N^|4}jYdM>i z$LHz*8G`#PtX(CPC_@3KK<}$q3N${RUQzCeH*uS{5_##X1iC$trlw|uHTayL0=?7| z^R6l_C2jr^I=OI1%{t;#fhDzU@ptV~#ygL$F7&J&F)V!i3%|Sac+NNvzczbpWi8kP##Ox$(Fu6_Rq1GLzX#c~&=((SGlmr6e zq8U>-f@~CXeZQk?D>m~;j4^Kq+-l9^Qd1XJa1%M^jRzl|v|cLt@vbfV%Rsfo2(Sfd zq?ysT1)`@&->E8d` zXilCHYjf4+uBPl&?T3BSAnSuC2Ec(;T4;ms_N@$4z?2)k2cGl5SOgVG-z&d~6~oS_ zJuSYwZT|@8C2Q&1uueP}V105h_aIEtEoBqSTG95fnls}UB=tXFDo|+s3KRieKwlhK zGkz6hzaYbt*Vt~0_D!mG_rhZ)QfyVdd+KJLMAeEZ$(3Nj3x9!`NNmFbmbtb#|KLpp z?5g;Ju*@Of*N%$1+cB8JvDXivgQPLnrpbY2p>}Tcm!O%v3ct=lkGv@xJ=w!GOL3tg z6_G9jk%XxGzJ2LCNoj_3r&G*sNJo#=_ZTremP3c<&d>RS6yrnao0oGNo(GFUzQwCK znGcD?NB;heZ|)s_XH9H?>B-F`bv1WtZ0JmBj3M>axgHS3;df4G(rEma3dxhkZ>DR$ zucRbs>tEEvdYIL;$QomT%ag*L_m%*m|YZbGCl-GxBULwUJ%;{J) zRii!Qc`NIKr|rq4>vl$ey7Zh^G63q-eH%IJEHf}LbiYKN$A5N*#Y@II!6Svqg&(}bS z%c+-ZAy>{QUC1F6IAp}^J$mYAuzOWQdzA||Da?{IP(h?{a_rfWMHX2Si_31;IKuTp zZ#nw77{Pj)3ql6P4WeYE5+zksD||;L_F5+D)r@r(PN*CzpHi-EBWPe{-YcxD1CLP6 zpKil0&Ew*yqCdqhdJ%rmL9<{I*t0+BmLqP#y5d%~9|Y$kyAsx*_0mxp)qv|Tz#Yjn z_03d=K)V$Ep4i`Q8hXy~muyOn&Dh@O7*vufpS_tAe1W%zw-mp)KD4~n@%HU%RIAt6 z={}QiCU|oFsVYWjO|SVtAOXV9q%-V z4@>jO>4nR+v=8UH$YiB$i|YkP#`v|}2n`SFVt&e2Cp_r8qjaZL(rX(`o}PY6`)T+vn4)!z7&Y>5P- zdB({j79G`39~=}42g+|^n*3U2Q4Q3iPPZL`G%sLYyJ!n;A-ioj<9SJV=Y96m5Dp-`CQ+_t^CK6PhtWk$+L49ZY-OSLXbi1XrYI&;5y_Y&}c|_uJgaNvVT{Ia@iNm}s5F1F)7{d$FEd zX7Ro#hpB!puhn{6*a)f9%OF8q)rLvw)Nb-T~*CGNmZqEJIlb#osz*Cb@Vnkm_ zO>v$cS97z?C42o?6WbsksTGYp0jVk7A<($h*`hXsBY0xe%;a>H)|%zCMnt7uY^MpN ztg9PM$Rsv}{B9RpkM`p|=# z+7R?vuk(w?R*eXow7(OTcfGoY;6jTf?&b9+rPaaG+E`N)CaP!p5}(L=$^}X9`uYT^ zz@XN4>gjxf{^_1>sBC^`r^0*pxM+HjoX#SlZq`vv1qB`jAf2vDD0UoooFV7#X1TR# zs_zN<30Dt5I^7IMw|ddaxkneHCE|}Bb&-{1j$5rt#yWJ9e3+iB2e>d3(<91ExFY)u zUaJc1?gG%MA=aYSo5z$_&<;-EVOFU@*D9(-83tZPr~CnR&Z!MtGBakGhNO8hVshRQ zSRdk8DKaCV7UNQ#sWZ#jDaahYT#xfjVCf7E^2UeE`ZWBR5(wD4`eI&O}=Zk41aa#DdJB$ zZJDW%OP}o_Z01#xvJ@dq813G{SwCAAJ@uyPx}SyPD;~t@js||G=6$R z@yVj;v8%qR?_dX8C(nZvDDUU%DIYTmxy&Wrfo;XJ^-cE{zTRUB0S~{+^@Z4-qp2_~ zB`p56Y5t9N`Yd(puw$<};#+P+Z9I04BH>vF7Mci6Kl}vkgNKwCTPd)7_Ef1Q~7S&2gwIU`hwKjXF5dAFa%qELFc+d=3DA} z$B0VzHmBRW0dRDc3P;QsRwUIc35MT^%qh~`j#&P4pVJ5dqoKEsP9Z|bUYL>ex zlo>4Tib5fTh1_ex?rk;?_!(N`B>G$-!3UiFGYInPb;yicA+PuH%iU(GLH_5r$Ey>g zi*jyb(aB!OIyF-JinD|H-mcme-&a$)=0>EH-<>eF1v(>6Q`oqic)jYy%(Q(dgVfnn z@9C(Y$1yo72Wkab>v)!-ZceJWfc{#|{=XQ}|MkrQ59F`T%Wn*>z#91rOTchGO!TjD zMSPvJ+C_4DtN{H<@Mxi&v^74Rgk95oK5{o0+#c>;H-irsU~Zn-J>tztbyyXyiQj-u znO%lfAh#4{URgPMFs{^@M{ znubs>Ka*jVu(BO(cOtD!#TMSm%6YdsN0RmmwMju zbY|7jK}qNx=eSW!+ZuhdeIq)e(2O7J?QE_TBAjudpIzl^(stbV`bv+fTRyjpY{fhGljJRDzp(Bd~8eXejYC zwk$wnKwL3|Z`*N7TH*hIy%Swtt(?ga&7i07W~!oUgzQr1+s3*=$=LDZh?f)3O2bsH z)_69mC>fgD>iJHPuGL@vkO^{qlG_>aIBif_7y%<3RY?fxnZs=McM@;kKFF5$*r~H7 z_$>GFn#@HxPA6lR$oM-mO6v;8o? zda&YApwQ}Vp~JmX<&C3>Oiut9BxpFEesa=jbx^Iph3ALhGexY|`BDNxdv)|8D~}8A zc*G^Axb-e?;5kZ{S1ghzmMJ+>_ync#x0Y!GP_9q(Pp;5WgKP`ck8FSUWO2PTvn}M6 zF|)*THjY_Y;e)T7-gmrnVyxIcYJfj26yE29xErix4ayYS%?EF>@dJ{i$z}MRC{e{g zAhQR2`ydWrfM;DK?y=1xh68OPrJ8$B*>YhutK>Rom-+3r&U^6V<;A0DCQbTnYUT!| zSXRfy!&zeO)yuLt-F9oXF2k%bOoHyp2l8Nx<$|hBu*qaq+jVUe>{kD~O)MMmkgu;C zG(Tp?&x-qePmXgoKl8Ko1HHHB8|_SZ;^v_`QSaR(95mDdFsxWDNsf_e35#ZS(Hnn6 zTYud(4?SEQMV#A&p`9>dIpz^I5s^vz0_P{H)vsV`Ls^GID?h#NgKWbc%J-z*D#ap= zpPaAPqY!k(%H0QRY+f{on_zR#uK|Ce!G7?3V>8KA11!vcVq|-$&oV{FY%q&5heRSQ zhXQZCjrP{$xfbOdCf>>0x&>TeeM1e;UMR4vz4VsT|_ph{_-)VWR=yI%=w9NN3T z;-c~MzX}wnL?zGGw?aH`39XNZHpAYCXPA?vi?+Dd#z3@+6ZsHp#aVBfWskc50J_uM z5icwfF;iH*reb?$v#r?F;an&y$o@XiOpC`EHsi7r+&oK=W`k^I zDb{@PB2MilJaG*g^~JP1$WdbS!i2$e@Xv5{ucONZTfm7|p&YFQEX*DqjpbwiI$-~| zvG#9~{L4wlprUp(1iRjZ@Q6c~OZMerr>7Dq?|Sx=Ct~X5rWeoX33U*vuOiJeCy;_= z6?JCo>7MhuRhy)f5rUp`EENn5r0I87OZ!KN7Cq%{;H6~Y72Ze72IfFr+Jp>PkhxP5 z`6eCHspVn$@2M8@Wymbjmzvq0bD;d*zIxSBR}kdoc6?`%;u92y^YPYXM;Kym6o>@s zyH;N=AWnq_Zoo({wguLsyHtmXC(hvts&(54^f9nAK z<$!HokzM318fVSV0WG(x4JkkyH9VIkFg^m}Nk~<) zin;rBHc?Kqx0cLR0bLKHr|#Jfd5GJTia8Mfb_pZ?g@{cFC13YM%mje9os|pLJ15Ml zuk)Y@@5)N=d0=0=ZLN5nqN>yiYy5o<3?jNr6RS}PON(tqQ^UDFVD6a?LUxc9X{}Eh znG!VQxwK;C+&+=6v#vQDV5Z7d&`E5jFR!+jA9&}a31JG-Ri_k{c9uVW2XOiARwF?i8sLL{eo;wWf(sAyEllI#pz)6 zM?AgrZsCz0!9iJTC64PxR2f{aoS~R6Fb6K2Ui3p-1$fpX zJ$t`?ufEA6CS%BYr5K-XXq^R%OS&nxKF`X)^7}`|a||Co6C29zMi*NfaNLM4-r^#c zTvF#)Tru6RUz#j4$umq(VVwk2#QaM4JWi<_20jyQ!MZ+@rG;!`p#FCbN32B&Pg{JJ zJltVb=_1#?vf$HHu=)>=$7#!Ls4HdS572kfjSniZs4sCIuxE!jFK*hKN$cvbb)jrs z7kZv;P*NX4Nsnt>fnMC@fBSL&_2T|5fB`AkZwkNK`@jDH79H*g8bqI2qm8t;p5OVo z`C>5z?-fJqATC+d)XTY!%<>94;^hubZb%CQc>uNz6XGN*eM@Ue#FadVjlpC08p*l! zt_rrcu0^%-j`y=QY*~E#r@D#Wg= zL^`}49JHD$28@xNK$l@PKL{RZ)%zutaC?YWdw9wv=uk7q3tRW^a3{)hv4imIy!(;g zI=v)xBpK3pTc?rYftC z;Qd@uWB{Yv4vpzdg<_9PxPvoJy=tzYE$tM;A-DVx2dyU^dCWXaJ5k7`g-e^(>-$Sv z9;xB}VG!JgMfN6G@vq7<^}gS$qWZx@1r}W}5ziZ$d5*s&N4YzlcJME-6HK7oVZ3Ia z@raj3JZIbZpEesmTR!Cd!{94)H;8p3U(9f!ZcZys* zRyAIw6o{G+$&#(*czB#bTZ*K(rrp7VmcAM**WkgxJWu>4KlCiiw0?p%o#cwd$9|`- zIXO5LChG?Wr}%-yylO(grw_;Qvsub2b(Co{Oj|GyraT*&1e*MUeUo9^s6!IAAVq&_ zrpX;efjxsd#a?1|k3_EwHg6qXz7swrMxs)EaFVO1HQp(F6(hYZT5CXNWQWswm-tTC z86Gl9V40-KHB%-@PgRrd{>WRjsQ-hwdzHVM#7K$M>gi`$iIrU>JYZk$?2s-&C_C6E zYc3Qe*)Hd1LMttJK8vYJoFtpy0|MleQ!Z?W%lid~j5jWrX{WBcI2ppsh>Wre7X%^i z?9S%4Y$A)&)k&c_JoQ0kU@AyyTY#N(qMM3pbT5KZZ_chPh2h_HZP6mr_WaX8AKQJF z;Ra~)gGThg0m2_jWtTN;U*;#QiE}ADdCng}zq<7i!W9 zhNiEp2Z3N_%Z`DoW2I4#T!~YhfrnlyQDw`H!D_ovR2AZU~CO~vX zByY;>xxw&2!|pQ=a}9?kg+fb#t0=WgE3zvRMQqyBk)$>c4&MSc#YI4&rF_TWC48kl zC&BqMB3QnF+;)V$$qf%u?a@_vB^X}WO_zH>QU%}soEFbCOa>7Wcyu;B zEk9$P((tEElce<*d+(SY{1=OBx@N z4g`J4Mc{K6oa%Mf^tk36SM1d|&J37D)&_57o?LQmzXTR=xLLGU)c-g&X1bg&N~8nngzFf19%OJ^QO#&Q1$d}k`{KAcPAqVZ$62!Vpw@tK z8slCnu4j|y2foI|Dy80#lCWJk zD6SILar#>L)TIh0MOiQgA4a*NZP+sO7U~*lMZsI(3RQxb|BEPQE?x~oP?&@>=@6V znMn^`*j5c!i+}V2}qiEB;`3u=I&hdoSLT&JdHk#WXTRM&G~ZZR0o@747qE zM~yf@%j2Ib8s`~(TS6YcyD;mV)ak&=w{IMFLnPY|>y_&}f6oMNaS7~|^=hlh zy67uGBVj~pKHylT5);y-cshHd(YnWH2MoH@e{xF)_3yy=f7Ex3AAwW&e_jcOJ4h@y zSrmB{Q3>5gTs4K3E$Btqb{-n-zjwq~qb)$9Yzb-e$pJ{&*vGgBc@QoO)1Q!yv@@F{ z@h(vl)$KQOZXnI8cy60Egi2(}CC;nG=1ZDFp?|>gOov%><3}gC;@d> z#PWbX5)9CCktTNErz|SiVs+rO*!xby-E%_68*fZqOc#_ui~?J7;xF@ijQeEcoI+di zU2efb!{nJVTO9I{lf&fkglfnjlON_r9bHm&A)!z7HQu&?9Gc9ITo(64N2mqQl^YP| zL05*IIj?f^C+23h9BQsF!HsJx`8(fKwZb2SSe;$>MQ)7z+?(`#Lqc3UlbHtcB7^=4 ztevHehY-XHl3ABKnK#7VmZ{4?zdxhMU1P$t<%!NV7CJj^TWAWGS3x(iyi=DdPu}bB z5|G=?a;gqFIk&^CCD)HPqn`eS$Dwx264=2l&(p!j31YL7i!MBz@AlV zm*sHd#I`I)mK<)qR}OE`dG-W_=oVxbSjm?wwy)2UeXSCmf^=Bk=@P1lFy5U=I7!6c zFLfA;wH|R%tLgEA+7gn)l}b*SA`AE$t03m?d_K)Kxjc!Pd~0Oo3JPQycLaG+exSDv zFu$Yolw7I}(mARr`HrO-#RmzN6)QO{s}I?WDqBF=WN=OV)XRya>e;#!O3v|AZ+}sr zi}+k1b+?wMLp@O@G9|&KIAYNzlmz>d{e)=x>494D7^DB=k<6+vDLzSQ?jrZ8BJuG1 z7yZ{ys4#tp%jgv|SQxw1aZuN#(XaWJS4)WBU!m8fmaZX9M&q5FZK{IxpYgXxq_ts9 z!eM5!3rSmr`G-?}=}F>u`i?8Z#CP_jC(^@CTJz{dnxncBbo{vJV22B02_TiQ;&#w9 zreVm!*_scPH>;NS=@;$^4>Z0}j%E)5h!p@5_$D&pr znx2}?u6B@WiB{oB-^LlstQHeRex=L!=%rTyxn&9U55s*Xk(W9>^Prn21D5QCQEpq= zd2E*gtxCFQ$OS?j3g5(A1vazZ+(vWF2}epmrV^65+d#j6BHo5g{WU*eqqr9Zzcn}- z_=$k`m)hB@9PVi|aUu@r=h;)-iks_6S71XhkMHyyUs(W*Kh?6gCU*$?`;TR=tR`s={_pLODYj?uq< z0U&RH*T(=vRhidlfl1(cp=y1+t=F|{4&yfE3fyI;$*OY8BW5_5+V!&Ehgv&KdkPI4 zUD~Fa9X=CSiKngZm%(}f+)q6duEtV))0Qf#cM@c_&4TM@R`t{vrl8c>s1F=X$LAVE z`tV)_pjC@9Q*lDx*e#qwVtIW|U?VMPZg^+_zg0hCk^Q1Gr<2)i^j&esaIR%gV2+1v ztM}PBK8t|2^5QknspjF6N zB?cnZ@S8p42say^{6vIEUdLfa)FT~zCyD;SI5MMq#|G1@VB3S&gB`sss}8Wa6vh&; zMhE*gH~X2KnVOBdTwLIjN))w%he_J28GWsn=S!eJ4Ix5}Wo_~$i(2iKFwplbS3|1doF9(MNt)5~=Y0TVbxTi8T>7+>Hl)hEYVTp#IXZE?KWo zgd@mHW5T};r#NDO>U43=e7|<$!fWdGvNl{L&l&G*MtM?q_M`1W`d?1QON@S+mcEn1 zVlRg`Y6@Za?{)_4$BHH4x{1OPOBQ-}qa!Y5_1>62&NBXCr}oEjsIHy&C3L@I1xn5T z>}%0&G@SbOI~6wiw+bk5nz6by#S8Yh@tRzrLCjWbFC5W;c=-+^u#S5zGzae~F_!&odV_@21y71T^#PXA371hOuqZ79q`xaP;e3 z5kl>(h0(;4MXt!Psp@0DmGW{NOKr`8q>3=EgkyDAf%hW(&(Q8x>m; znE_jW;Jbqq9^}4I!?u6Y0)5| z7$+K;5-;r{nHi{+I1FJAzJIP}Bt)!G{Eao?%RP&Va+}!KGZZH;B*v)GTW{>Ok(Xc> z%H=xiyk12w*DzkCf+8t;k|8DiyKJb3HHI>e7Al%yeivOx9_fc;Kp)Gv7_u!X2tMUT09(8UwedPXw-R&Aqf_99qGAJviQ_BPz=ya~BAsIuF=XDWj4n&^ zrbNU`OWL2@G$j}jvAn7(LtW;*!wqYvH7%O@l^w#Jyz#B- zjN<14roj{Q-}*~!{dXY=Wc^(nvgn+mRb=JQw9{T`@QS%OE8ofRAYROrBnxeM{x{HL zWzf8E9@fIY zWUa5e;X-*5hoA!3mJ{hRU}0q|r=`{+07Jf}B(o7z4ZTy2+rgt1ZmWo9pjT9pm9 zYj@q=ft8v`uj4l^Zv zwQCg!axw{ypUyE0STe6O@V2E^n{yb#(su+QMKTi8l44m{{Z*u+ju#3A)O;_Oh1ouo z)iL#t>OpL>5BI>&SB9$Z!-Mr_etOJE1na(Ebs#yV*6GVqj>~R3Pb)d0Vak9~_EYvI za{Kci)Ajy$Gn3jh*NHBJZDOsZQ*DBE&ZX$;#cgpDsrn+#)i28v_Oy|r6%AU`98=8V zMoiER_(@*Edo=y2Gv^KGc3zz^)7y(nV-+F0bg%DT`^m}W&dcdY7u8X|fJa)(^F(o^ z-i-xzRCv4t3ThK+>*U|t7#_-DY>2`yU;$l`+n(*XzohBvO|Q-bh#o%csUIw%?tRp( zD}?b(G5lq1gac2tUVDXFGIt-3IdNd}#QRMoi#1P@Z_4<)=b{{R52$02?P#31()dXW zdvo8g$h7BP&kv1zx6!$X{!AzTs!+~IjCkkbjB1*-VY;=xC9S-k+nKYXk*W?847sYg@?F)MzF2_*3+=MAbjV|NmdQ60)fX2 z&QjB@BX4Bg?>tHA+R8q(2MWz3cT#~*3R^qec~(Id7JNH>8y}>p{N#`PCTpfm1)~LM zQ|ca+*Y4i>-yLBe#Ep;s6c{2FpTw|;shszZ%-&cm$m`Ps0(C7qH zLVNle-rtorD zsNHz>BaHtIS!{H4Yj|Bjyh>pVIy&0`<(oWdlciU(0O{U&)5laufwO0e$>*9oC0=~K zpx~w51hjD;qt)`xy)ohTnWqb>6?|g9$F-VIWSX2VVkgavs6F+q+P5VvAc#+V^8$O177f5ID_av-8nY_``XOgK(7o62vV z>LJ*`IxrjytLB`1BYBqZCicd%om+GWdNR3T6^kvIToxfs%S4N$pf0hWE$FhAPBlHh z2M#|YYdc@bb1|I|(qTPjx~v1@qJg^kIetz-QwK#mF5X$EMJQVlRzbQ_vy^K4H30P^ zj4G*KHfc)8&o8Cdb~-W#jawp$t?^5L_0rx>ieY{(PMJA+bfxu`=m@~ZzOltGxsPld zz`{n>dvV~iq9j%0PHQhP<>`Qpre&WHbRs#7`E`Qi1q&m-2nF?K!yMJ4b19* zpMF30xW$~6T|-ki!@lVmbLVQbZP09bta>XzJB^yN{EfQ7NVrq`4d?;3dmierLDtk` zH|1sU#Ug#QEE=&WrYgbbdsOZKG~=crmdxpq`(!(OxIhtUvQe=wls9EU0wuQvq;t0Q z-k+?1)DE;AZhHlvT^wzn@LwzTZOgltRj0HeRG#eOJS6=f!zeN2L7L9YV;k;mJ3ON1 zc5D%7sG?F|jp@0#$ppuf4jZ4k7VL%6R~h!uH#&a_KjKfkb{=DWE!X|qmdMKk9dAFF zkf8f4n~(RJsE)m%1UAnM=Y^#!-yKF@k1V%f-T0=7lFNsbxR{=KNLngMG2^Rd>dvNp z$Is08f*jGKwi>NB$CU8z#+^)Vh>N!%!@UWIcnSY|Q(dg`TH9FJK_7@~cIx{e9{h~5 zdGOY8UOU8PsIM+yMZ@iC3FE_F5UN7P(leuRrp8^Wp4{92rXk_uM#J^=#H_^f7e9>g z9^{e5A@TCG+moa0`TZb**G-JfL&*s4GwWDin&IK ziA*LcX!?fry2+f%fQ{`1M2voR#@RpgP^3;rSmdO;+xo_KCvm6eI66g=nzj7No})~) zPD=dqU17qNkT>s?c%TPS{mMenxg>7!xzaJI=F!SpRm&}S>o)I)zQUFeraqJ7=Y;QP z$y!ems&C|k^P*3Aul!0)Jo_?*W7~D6Ji(aNl`dBwvN^x^{nEzpyWqXzOXDBR?mCFw zWmh`(y?8xm{+a+tB45DxhXCW`>9@Dlu}$ns*%FGs+Z}P7vzsoC2w}8ulIk2AEo7s; zO}|~CQK=r#0%-&}`Lp{`PudSGyVxNa0M%$Tr{YhBfZ(JQk2y)Fq@t&uwRKGNwJwFO zbC`B)qg~xm~*pC^^3djYM#}Z7zEb=##%_(nN0y7thRd$ zUZUqOe{WRVrgejv!`^rKlbTL%pQ^&i4XN6iA=-Jk*j#Yf$;W>f1fCdoLbwz@)A>8; zosuWk-T|wcNueoK;qprk?5KyeEc0oK21P@qCntPk#f2$lotDS$7n_jdR~qH7br4$+ z;ZmZlgTZZSz_R-PsCw_HrqVX-mv_b)J1B~P3XC94ga}A)?@*;n4KGhAxWQ}#>``lW+r_Y?BfItB%y}>K^L8|LsVm`qN7bPH({W!r{!IAes8Hh% z^Q_0wr^SNud@yFwZO0#bpAjGsUaiOf&_1XmK3u*hrQdI4)k#FiPeeCLb{l6J>%A9g z zPZAvJ7zEf41{x?|}o|{RMR2?p z7C+6t*{uFC-}x~0aVoON#%E~=*M0K!nwI**1J=XY`Ru{AfzJL9O0#xVHBPx~uaD$b zf`|g@EWFY($UFQ})sugf;%9(ac;23Bq2b@t8MPisc#p@2(S*Aw0_5zV1_?}~DX^h! zg4AdAgHGnN&CSM^vo+0;`Q2tU#MJz69E1D}B zchW>{$);Wal*GE4ZuRCVHHBz_OMOpc{z&K_(O#j4Pz^d2zLw%2nuLnU&9oDgj#Du6 zq?+z?yAzFJpEfD1=$^O9p>A)Enp$poMGJ88mM=z+#hMpyMAKsh&Q*Du)^G2@96~@{ z#8a07?ru_b_qC=??x8lHXcb9Bw7qJ0Or;xJZZWtaX2Okb~q2Bf^LKU_za zvZgy-%i1L?Wg-8<~j9xiG?Y(Qr>d&!bVmQ`Hm~E-r`9*+#`(72vbszwp$erCQbX z7?pvP3lMO0SbVUBJ>iFj)(0yD<~LQom}9)~G{mY(I`j>Atkflc_>k5xe!nlY$UdDS+|=QO+l_jhx|izwe9Z;f=`5H z=BTGDy(aaRDJ9Eg0rp6z1IL+JDX{6)=wo&FU_7CIyPb*}}A4i+QVV^^K8CIT9EG9ElI-W!rpkP+2u z8HF9ERST=BbM|AK7rZKqRf6PnZcStQpJrR^^sLTs`(kFUy)s6AzO!45CeP?U3ZX*s zRhINEnPeTHy<_iy$$<$1n5VTIY61Q$6=P@F}s=cLM_EA@sn{h{+7^Geo^Kx-^XLh zX?z;^2I{lKrjmngnXS66R!O-lduuf}!1orK-anjGCSD&+=IV*rqYb*PU#9vR8&wb% zW`P-!s+rag-+L~}1qSA(FXTvuZ_wt8(w-qXBf(UoIF^3#aqqY{9PJpRK-)wZpN*nP zzT$;7v?ag&^KMni5+>?t7ev$}6}!?!01Y39ARIvBZkdHxSb4i9b0yrnPd;vZuUD&lfYJh!Z|>n*5=p zeP`~tcP`NDvDItgn)xR8Cv!B76qHx@lBH)dHe^=&Uyd^B+s}KSI*(^iR!^?-z36g4 z#BQWAC(%s>?W*4-%pMnNn149mx%|VkgeO}~N*OnE)Cwc)ndDdVJI6?S!nXWPp(t5uDq0W+uI;>s) z$-#^a?+$@G-lLF_GR^j+{E8yJoDX)hm6a)+`BA9#;=5uV!|6w;o}|$za?!6SLl8&oGJzg;q=La z`(Dx+(x2w(#w+EoNbz*#6Om{D0#!fSgVD z-$|N(6tKYOrqYN;p`&;-Nk+;UM`mF4wSuX^)Mau{!tB@h0Pi zC;i(sKRG(NLQ~-S&0+DTA#Y(RtY!k@36^L5h`TFX`pK!&JTH5x6r*4d@!j+KTqHdR zWmAgA9w^JR&6sMRmcKG?h)%3jWvj25Mm`+!2s~o80^T$|gD0hsRb@` zA^q@sg?-z91HD|WauQ-ihb$`&jqqjjqVFEGDZ#sXaGy8Bk5K@@Vtk~kYk#+=wP-`P z!I2vYMlJiBt2IVk9(bJPC-~aUN3_@~+OzS9Kb+CSgVv8D&U?B&*_)nsb)iX$?$mpb z9Uy}$d@f!oxP$odSJv5E#F_a?23Oi|dGH;*-@cjn2#z`+v%bd@<-qA-AXmSn=;`Dzdf2=k(G^R)X&6y1pWt>VYQHW-<_n0QFaQ= zxz@Ea*|&9MrK3ijJ9zPKOKwh>Rj*?!qoYuN3|+R1T67|LNE-H<_Bvo0isNIiW8|HM zRdoiMqUuF_KmQ1bf97q_{nqaL#o;!&6uJMt{7d27*H4e$>gK+DZ!i*0p&a@g}tAcQ^z(Xx1O{hhHdUI-LP5hH`sKLG$nudpbe zh7Hb0nY1=2mG&Px?XZ6!M*ElgZXAepa zKc39>-*WI*L8Z@aqnoB|qkePv!3Qnpqto$Eq|018;mJtW0<E7&&Znf=i z2}=jzkXfgBH|SbtM~*j z^ES)zI!kNBzl^(6{Png2Em$a3qteipkJ)~1gJp%ytLs2ZI7>^*}sig)UeQLAXYXY z$8Ka-om@SWs5RmX5qh^@0z9u^-MBTQSR2qv*uvVOsdN1 z3$cQ0K9;OnE^(i+AQt2kJg;mAmlT)tCP7BdDsF{7hkCAdb5GCZz26)&+A(6_gf?mw zVQ6L_dHJw$OP|xZ*4FefBSEyTL&K>1G3~)Y;-NcJB@F7+9OcxIew>_{ZzNuMd#~6l zm!UW?T_MDbxF=#WylE>+5r}=E`K?c~gP8Kx+;@N9An3qyLiPteWZ527^SC={G|mLi z=b8f#pug#h($b4ij1~JzGt2c&LXzypMie%e$>d^);njh8{gjY^?r_vKq#9N)P9WD- z0c@=*mH5gJsHqrFA`4*SeuJ3@i2l$-{X2X7GS%u9NGaobJ){M9$@|Q3RG=evvC|Nj z;%9;7LZzpT?N5cSw8oK7;b5(VTRuRjqS)b(Y2{=!HyC;C+Bh()Ecf_-B^*lO2iNF= zeOuAZxj)U9o%mw7VXLHk|h*%IXSQxQQ>SryWv$2PYf@Pm0F~2T^gMXu_ zNOX7N%9e%wuTEKAl^J{3;?z^47R%KIvU*<`2I8-pFNq2NIO8(l4x|nLZ%-O{ zRgFNfaH3Wq0JGP4l1J)A)~WxIgY>AqbS#ypuffKgem|MoSnz^cw^QE^5`o(8lDB!Y``}S5Z^VDla7Fb{Ec2AB#}?GDOZ9d<|M?vk)!*^8!ldKQfQH zEzeH6p@q1wCEN$(Kf`D5N<^QW%Q4ZZ)&GrZs$Vhh-uy1VXv{6IBf_gR$+1E1GOiYZ zlsC9-;1n$y@THXunXE9VEH0Z+f)3@v1apQ`lo|N3n#`vacMihXi!(mVMH9Bn!L>2h z`hw(zt1pF%&lK}jb31VxXhyZ%*41?t`cnb`=L3rVGrw|er0&O)9ly5uM(#kbA5~=h zMKq#Lst_OXPg;t*A#)0ku?iu(0O$*GE{ErGMY(?t7GHHtWq!RZ=euwq* z8j*Ddg9;sKx1yH592=eqi|OJrce$uiSq*oTj$tDEZ;kp8yO>U-fD6x& z6P@=(RGZ0Lh-k5#q(ioGcAkfg^_;=WEt#4n*6q7Fh7h3=4U5SeUS)QB+J-F)Pa9a2 z6eH-nlCoj}KPo5Xx{hGt((~c#Z3>+riE2)TGP#veRlwd5S=Un8EPscuaR^narwJ9B zg4%zdg+8FroQ(N1o1QQdP4i5GF3*oy0@F8ui)D&}vP881Q*&qcoU&MEVz<6_>Fz!J z=3%E+lc+_IYM$H}qE)_<$VHePQV4L4R>bwfD}wr~6t;XDe9z?Q<2xx^VG7RH z6-^gTEW zysOXt`!D(mH~~5Sp{`xIekp{Aux*`VvOZJWbHF>FtF}@;&kCXcHhCD}s&kLI7<)pA z14+HtUO6cdu!6c2X5@JU82m0cl=2Fd5hGklcvqGhT}ywuZfsP%*p$VL3&D7Vu6xvV^`JdK&*WA;Yx^D;6#;2 zDrxBc>z^EH1nq!>${@CTBuI6BOGpn4hG&xOU9|L z)C)SB@pKNBKkZI(>axfl-1?!-!4X7BNikU~P|(La%MhF_oh^6VM80gmM|#$s?tL!K zt(BdXA%ICoss6#U72n^cy)^$mF?)bZ6#}I2toL)xz*}nD_-y8Zy;hh{Z0%eTf4FQ5 z=NP>AX$U9 zD*38BUF9>{vWZ9X&y6$%q=Qv6Q&Sw(*!9o!H6aSu4N5C$zFOXiGuNg}%dm1vAPeUB zw1DD9kwCZjp({QvO22xMyGM(6N#bL=p*QMG@;&bKWT%h2hQ749^j~)v%k=GI+n!q8=N0aU$_EQ3X0?|~`n7jt+a-Lldn5++ z<@#?jzV`D*PZ6n~(zcRB%k)pbQc{wvHy5^^ScJY2Q|BrS4F>Z%g+oMhpJ-|Bb5ZkAK zZDA*fH*PAf!t3EUC0r?S(gwKo(&FtEs$2Ra6MQ9PLcLl<1l|LtmLGUb?C);vHkn=Q zjG41NcBPYVi3v27(KOzJNfS*G(ksSCIq8R$)kocsU}@8!t3%i1Wd!mtgrX)GLG#lI z)2k#$$+$!%QhZIACEPOH6QjUvt|qiZyq#|}qPKpsqzA7cZIWy=rWZm~lmOoie-YEH z`3SghFSEL%Xv)TXaRD3KBe-pCIBs5a^Sw2v9Bqy^ONIkSfpQ4 zv{rsQW)0W5KonXTjp$~oo3!{l8?F?-l%rfDPhOLGWc)?y{)>i)itv_&s>-~hdvYnj z?DQx=7R3dNm@PFQdmXBrPInbFmDbX8JdcYLGwhz$6>ZoZgGZ1CgdG8q<>W?HcIwWa zaa-RWS4<1WIqd_e4b#2q1&ScTaq!f5v?oR2xY? zr6vTR1!(fO(Sr2Bo1;p^vW^CrIwYIg?D2zfE66`EeNm{5in$dX>%0fx*#L|REZO0f zF9uR8uHd#ul%_8<0~~kUA;uN459Tp}V)ZYy2Mjj7p{q5b}%lc(qrgMG03>1pc39#`W^g{>7W>+v+Nxc|Z4av4&XF-D`LMgsN@ z1e5L6o(;|)@JVlmM;@H(2Tm>^-(|kM9J?bVji|R3QOWjNTHu_{Xx<>KFJSufDO}wp ztuj)R4SHyE-1Y}llD?x80Q}P+ryRxZFh0#?8>d4%9O&;+R$WcM*$bFl;3??W72>OK zD%q9yLZSzvV`U=n+pz=X@ldN~kBwRXG38o|?(Uk8_|Q&~OH*Eyp)w(7gwAJ+xBr?e z01wl--({y-7`CL>OWo<&v%aTJk-OrDMJw_Ef34{I0s`3x){6tGgkq@vz9yEJe|_d! zUT{8*f*oK=@+@=xhZu&24cdo#c{XhwU#h(vi8@pCMk2aZ-B2GY9mIBdPzmniv;$Y_ za&`t%zf`4hS$U7wN8JA8=4!oG2XRW@fpx~D+4W%?PZ0iCsmnbR1qH8aXUlt}LOv36 zLY!LC_HV-t!~(S5ueT5;%0I|!_@BaFQ^H07v@i!}_6}wiPJoy(GI&&j{j!3S_G`G7 zo+QkfuKMEOlXX~D#!(}-x7=^8=j< zDQev1pe_3)8-J#P3gb8VFh%#1`(30eErXV3Yd0(OgL6D8zjE$G6;~YG&I+ncycgIizOEUFxWH zq*_q~>=q)Ba8J>>R~eX;l=DCGev{bCS2$nLd~o*^V&Z{Yo7n0|I`t>DFDUdNE{4YY zM~Tr{3W?R);o4f^ie|Eqj>B6#K9Cm3fmb+_aQc%2`HT;z>wuH-%k)EPRh80aE`UKp zyA)>y?TVRV=ODnB9l!;r+T`7L8|j!G$D?FIjR=_2^QG>TJ#8%D)lN+z3x(1UnhHIp zH0VO!)^a8{TO8ssSQX}*KTnbP8f2UV%52}@%qjn>Efe1JiTYcAuhUft)T)pC^q+^{Za^sg?mE z!ida{5h9nP98Qt)GTdn4F`q8ExQ~0aP5k^5+AOpx! ze(Sy1eD&1!zHH~prPGf^{6hgJ{_1XMMPE31sNQ(GE@%;=_xM|Pt)pJK8R3t; zq@$SGhN0x-Y$ej``7=B7M=wQb9;D1kxEpnsL$-?e7@k#<8MWUpTul z5feiB+R$&0o$1#M>qdU}_bW{1UGGtIsHqwsY`eP1^r*7Q2tSiG^)8^^I8{@cN2>(u zv$ozNo$G4xGOCO&rLA3{lx3>~&&$hJ#dG^5R_i+uOY#S7 zH}bkPw2HeF5gJFfbd7n|$6~W#!~N^3)tO;sSJlK$RC8k_hUcL??Slxa zu;d4~(f+j%!E^s)7ynNF{99_d#7p^vUSfq6ZPu5gCTvMVt2@ciT=TVu^VsB1R10Q* zJ&01(8*y%L2>wuRxomUbTSEgXS4sJxBh-Z^Ao!ExQvu`E=e1G)5q*%F49?kZ%eb^r zp*RzDw>ho8Cd0??;eEimM^)fzPm)tjE04j3y~J10Ow{>XJAsq6Y9b9mZhErVZ~6*k zs~;uq*>#x>D(nFFW+6k^x4!PFi0DEkFHb`fDdG9SP4#5mX{bdX z-@CBLh`g7rTgORDa&E!u?btP}o}PhI>n+(+Y41!+QD zKRJ@fEa@P_BIKS+_9hI7#S{XAsLoG+V}$uo%49E02pr!+%BTkG8<}}L|4!Gyfk|VZ zr)MfDZ0Y(X2*oLAl@*uVY@j+$_hExfmN5VBpWihYZ;ReCt5Bsi1=3J@v{_u!%FgVB z2+@4@G9!RC-Bb^Rb1xhE`}YDhLmnL1Bsc_5Gw)=h<99a?yiK$RKVjx<>=W*K6a(X* zCYqF#!>#ReTVP+{vNAGVGy`6%TP+X;{W@N7eVXjZ;Ow{E+(%W_I#jiPj*zryZp#LO zhkq13mn|s*46nJT@YlOVnPYyq?Li*eQaG4_j*XOTy|8D$kR#7X9i1P0y7<=H24+P-}qfna#V6~X|aOp zAG|o%9nopHnw5yUSj-x7>T6=E`WNkzZcOO+&QdlkGHmq}7$!}XwUXM#878~d4G!{L zTHagv#>;iR%_b#YfpDZ}7jBN(*yPx}u=?SZA5uFOwg8|M9(AZX$!L1@Iwz-)gye8| zGPGwbJaPVVvB2hPCfK;inD)bWv+?jJM>+$kA8QL3NPr2R$1Yg=R};!u=bs!;2ZBlX zL!8_6{!N0vBGxf?l_yyan<#U#yrL&0GGaQR-s&!eiHp3p)LOGD-UzvOaRpFOt(^3%b|A}O6L_FtsM)Mm{(gZsP!=e{t1jNg;<#b_#+ z$wPHUs?Y+A;?3Y2W<2~?qotptMkDj6*B%@f2?Z1gGk8^OpZpW_a$Eu)i{CiTzMvP7 zQ)-{!eamuOeG_Ad4eZzbWhQI+ItE1pXu+I9rYIh+!k=U7K4&@}hO{?inJrWY*>z~f z^vuybnyWP+9vM{VwTC+9GQ53tqMN$`Z-l+~_Wxts{8nx*IZW8VwP`OpabA`S@HF<) zitMqT)%#R-LMx&to|2*PrH`J@oI<~|n2GHnz+23p$r2MTyLq!8fYOJshGTb<2r+04 z$ib^GWrB^V8`f=8|ErVys(-vS0ToeAo#~3toLe^YHO{2cGY($OIHV?DnoOQp?LgNo zb%lp+ANnQb12^-6@Nlzyk+EWRrQ&={SV`lICm6K@RnmFkEVcX)e@*r{Pj%*2O0nMO z!@9ZU-Y!hWQI!BWRI^9`hn0BD9vmt3kMDHfhYwLOMGK?-wJ?*1ANWQ#$JW z(_b&ZcW~uhDtWP*x(-|UaVXGtljvK~UhfNNfi0nQy~vAS8!*Q`YxLYvM3c~V^#cHi zy`;Ap_$ph1_+I084r@lx=Rwh1B0czOe!@VqN7mQnr&oaM@qWDcwZc<#OGR5x`*X6=n<&4d?0Y$wUvW z@izms`d<4=CpjzbLEh&5Rh$fU@_{$fW}N5KpNKgO$SnPh_vzkv2v&L+Fu`)Fo7I2l z_CiG?%0(s!;voON(koLTz)9Uf;!v28KGR<@s4wY{)J2FKXD_qkbEhGojZU+%C?qUHJ>RI_0C7Kc40hBf_VKOO7U2X0;9<#Dq{urK;)KN@)zWu6c3& zcFe$QO|HOxXW7J^WR-6%4^2M6e$c|bXXtQG)8Rh4oYn?Emc4+QPCSu?Jes7|+)4FE z2?aIUH!QdkiExb?sYBaRn_w!NAXAOHe59j2(V7LK=kHfby3ybO`Uw;)Hf*yJW%rYV zt+eqQsD+X`d!lilJWfZ1$M=70t%?$lse4@U2|r-|Rpx&8g$|SbSl9-90T;svmUsWj zk%87%SFc2Zs#ce%v*4kavRC^Gx^b1%)4EQX+7{Ed;gw(qb6ey4nqkzXf6SiGkZNsW zOpdc0ic{uv2&@Y%x!#!t^ghA4Sb=tHO{$$xbGq>S%YIEkf%2CH|Clgmo((eug#cdP3h3e#KGPE}pY4iVfJ zJr)q=cQ5#6&Fk<$2}yQ2l@z9| z$9vUx7kjrQtPMFFzf1YHM#e?JG8M#Q4M~l4meC@^t?liIJ>ufq!?>3kUiAhiDkl-y zk^yhp!dHF>+Y5!ssJ?p>v4!u3_-}*E)_4P?u>!YZu5LfqM77r_=*Frwa~}0CIA?gg z0k*9o>c%bvX7VF6M2T5+S*y>3dIR5W%@|4X&+oV1xIZ!r!+Es8p0UTsh=^m=aWPRh5FuXS6AT>{R6JvH?? z+5GO=t+58bIFBV<46x))AZ1W>AJpz{{)f^{pSh%YOx*oDSvbyK;;mU3`m^+_Ef*(E zDkx&&${MwOS&VP~@9;8f+@ykFOcXo8;6)LhF9gpxtUk`mJeKNcrc>i4_JMK^0k0pB zWihL?EAso4o&wvlyFSc^{=^VHFr+OFJ#xal#orCAxNvwXwopVQ{%B2~JRJQ3!< z8P`8qCsk=qH@D|$n7Q%M)4?sK1&q821_=eL42?R&X81bRl4nYIDZD*7)^A8fcb2hk z-JLykg;n($0s^8I=%X#%6dZBl6Fp{Gd1a`W}Gg&OBqjbHJ-$Xo%cQ5-`yryd9Q0ci>{vi zC36w1Jf|&H+6yJ>#8Q&ToF*Fu2(M|j0DNk2eA5kiKRN6ths-)wL63nvD+i4eWK0IL z&|kW{<{UDn@8`EFO(O3dAn4;s$XB+S)cWsb&Fd-D6qVKzo48b_R#>o-A|S;l6?Bmg zq!Ti5;R~GpMcai`F|3oMGM_`yxfEUnZH%fSlEmq8RLlqrd--amHD~{)!guwF zZ7E@a*qfn-zDTQ1#x0+>>qknBfdO5#hd6eeyZMf@VAHM3Jk+({g`711ewi2m<+hwl zoNJJ!`TI@TW487ou?~F%gvUjth>nT(67AxOW)Jr^0aSS32u{r@9Aysvye&QIc4a&- zuDI60SWOM8Y$G(<@5f3$)~PJp9%QY33u;kd!lJx{NNQCHyZ_v^xS@9vYSRf|jBLs@2q2xi_Ozpzt00-Ov&tLH+RNR)wu!SEPN~{}M+H0gWj{m24xiD^Z{o6uQt?&zgDC0Ph8q7rG%SYiEzUk zi6;B5t;gWz+Uo9+Rz@fMp~}2zY}>GwqoPlHGakX-F;xq5Q#q|z$M$q{=5EdYnBV#~ zQTB@nA&szed2u;e_2F^Ks-;w!~M+#)pkt#LDN)FY;w;->aT{3H@(2l;Zlp9s#T0>?dwCeNj=i(3f@eVHZm={ z@|749X6X;_zty@C=~x(+lOsP$QZZBeJ2{5w@i$eESG>X>O_{ zD@ao8x#8W$y2nph9LI^j)>=R1$*gM~TiQ zg$T<*K(r6#{upsQk%xvub6eqrLpRB779KE?ZP>T~ z-gCT#$*LI)SimMvoSKE+GU`J-oV_J7Khk*9QTlBMwp{S@gnss|bJk%y4`k(ik5sJN zmNYDrhtnyW6kijQZkKX3gkr9ex_ZRjtQ($Bi0*!Q`C5hq^TcD#Pw~FFem30`&cs*e zI5r6kX}9pM=;QRf=g%Fs4yT^0mDkHU;al!7RUd@E4gj}Ts>x~fW}+s`0}bP@zDTQ= zPte4`bp2$SF8x97ODkb5;to>UWBTcGtD&=@<3}E1P5BZ|yPGDAA=!BBzM%Smma;Us@^BzxUB#4*kUfV}_2Aq=| zfKmx4S&0BY{jXX2zhC|Hmw#6NKRNBMCe6sKNrACL{0YygXOeahVP$6SlY zJtkH@;GlHQiz4yh=YL`Ge5V;#1tD#rg4RX9aWH z6HK~p8rL;(yxK-+##7}z*qDRg&bFpn=ngquL~<)zg$76$&IY@C4hB!j-T&HEOXf;H z7V&f1C4WhByll#9R{axPMgX!}z;riO+*VA6VPwyL6&{?mGb`YfoO@hZ>`^MOS#vs` zM_9}Iz$~1Ov0bhiFFc*!tFfT&r(tT=c2wL=gXG_*M7$zbd@|#jv2iNGck75=ufN2M zRl6ZxCk@(?Yl_YA3WqkWTaE4C7fc|-&GZy-vmfX3TibATyYYW6vuuu2h<^s!^EJ?T z7BalsaZO(Js8RUpNg@4m#?2^qPnoI|m9CO$_N@3VaOOO1pOcF|z8FwS91tx=6LMjz zfC2Bl- z*A)c%a}5BD;sP$38=lsYajvV=UzR&sTHq`8NoxR9j*ZM#PV7K~WXOKh+t%EOSI0g$Gt8nQFr6J2qF>#zh!k2uC~Kxhm{vz0n5nBf zqO6Y7WgSsa{X#~cstC@ulPP2tUj3}<4YA+L=QI>`4#<2J6gTpkH?*b{gEj4rd`Zci zs=jCQ@DXC2*v{t3iHf-Tk-X!yD1bRw`3C1jwv|S${NxCrxW#MOGI&l)3#6Fx-8Y6; zeD^PprnU%XlU9`A@NoU2+$}VU{%Z2tfgE;rf=@+okYMe_A8!eGXYQGIP=h}MI}=bC z0-G3UDo4yeq>Zy0WYUvwsL$o?AbIxFB+(HG<40*EW(@YQCG3MFYxWCeT)^?~y*{5O zr!{U)EZ3H{R9+^YXHMHMKQx-OTKMtr4e10tf>-VqW{;9{0XkUWbaw&uRT(tpWz6g| zb19LHuU}gq?psf01<5jB2?-N(JTe?u+{P1d7%|y{O9LEEY3{AuJsJZqyCQk2gEx

tbC#5;2;zMo&fcni`p zaT%tpHKgujYvoPmwL*wmGCkslRzoofQUQ24F)}o4Gt8<%ohBDX!BrI{&G1}wwwj;nCFYF`2gXq&2kd$#)_*CEEe z;`s4*k$aWVFNlZ^Yj+R^DCoF2D~@iVMv@&VRy4{#dl6!CMg7@1s`=Z#%_H}B)6%Nq}HdVQ~RG$AV`^w_}4@(6`wT?1Nn@bR5$pJpQ3~)X%{C*o+eD26#HYWx0i3vKHM7S)LCie?&ncG1$ zdRwa_SIl-~=GFz8PV_`^Pbiw#e$eZ;zSl59sooum4-u)KupTw6jI&`)ysF-rc}SCo z-%D#eiAmuxgC_f^jLQU>(DNkX(`r~MR;7`!ViBd+Iv3Ci?kRA!yv%ZjW``XX*=6q4snSbzCze!PQqm}V!S(@NIU#(a5LLlU9l8zfqv19PnV%br1bk&-j z>h{!5&h6La?R!nm&PK%XB%eT){c*ogRFv|h!`_>f3FlYDCroz9Go!j57+1>K+O#sXe? zRC%r9W9-hBO6s=;$}iBjtaeHFhy}LmTumVKzYW!BLI)vlTk_KOR@qOE#W6r%laos{ zsdZdUsDIT+Ptp|P3E22^TW*=*ACikvnW9%_L2q?Kk+XXmRnAmi;h^UkFXwyjGgl6b zOri(5dQYkWbCu}tICx{2ir!#_aW3-OT~=XLsNd>IeQ(2z8)pIGC&$UX!^oao)B!LS zY@&8kOQIVw&WOBLf78mmtb*GIt-%Rl1k&I+Q8G&NQ!=fsDq$@6GOfWvvS%RLDr4r* z@NL9e1Ffn_-AuE|R@N>m!rlC=Q~+IS+VgkQa2%_}!FWx?rq+F1Dwp`!jd5#hrS-N8 zOhg$oFIwKXi02prO5d-J%$E9}JLvaFxv@{hy`E`p%}%o+zcMgx89s-zhdMW~ssd-v zcbtCEOWJ?;I+!YB@)GM+Rxn%7bQIH5OEu7a56Ao|HkskDd2Hq-kMwg86D%8JcSCX= zK*~(y)CL@ab~UhL9;VkG+WRd-EoH4}ixfOzM^t1fxz@RR#B8pg66-ei=jjEoIaegc zOyEv8V%9eA)(gjbJWGdLk(B5VgAP_pdw^~%TdWi7Wa zPI!iZK5XszmB^Zxj7tpU{sp*K{bLi%ap@wF@CyRF)3A{jEppVSo(r*72IZJ|7J)Gx zuPR{=`ZsWLpIdB*yk|8Xtt0Hz^9?)woNBgSzW?go=P1S9W87z8fQIkT5%P~2;pOgb zWo3(-vC6D@PH%E6@`Y#KEQJnqqiKE9kg&j_6REJ8IO~Q7X62qq9RxfrUQv;emdi}& zDaD&vS(*kyd@{GP>W0!Lf&+2R2^v6ke~)&*E-?DTTE-B1J&#hjcX*RI>s_yWlD(WF zEm33*e(vrvmZXd4AuNJ2i!kqVWWkfZ^y+Dk#80g;cdjY8wto0A(DETMXkHE7MBNcC ztZus+9IcUiah_6_Qj0{~G?BDui#q}(9Wr!<(l{JrOY&YJQ?A|bX!KHs(R9Dg{SNk`-Mo3Ko5i8Bv6 z(LA{f#NU62g|$OGmR|zF5wNa3{--DjwBr8@)Fpw>|8D{1^Z&d%Py^w_8)bc_V>j^K zoJAWxBd0IjKkh<=1gaaxpV_i#yv8;C1kn@xQD?PlU#>gORwGA?6ro6tZd5*5InB{4 zR$#Sd?Rl|cW9$lAx7Xu-R|w8**4()2POZ-)OQ(iS&mWX-@T&JM4cKjw%c&{F(>x19 zC4QcUD5Vls3csV6v-0stel)?x$m#BT#mO*z%!Je16%wfH)zEC4ab~i=r|6_JE=Ot> z@1J2uz!>Xk?XD-D+yDu=UA;dMF@TR`Ma0b7PKmLcuzkmehK5>MAeg%`8lC2-^Svl? zzr&5fHp^}-9<^^-p<@eL;kwiK9Z~c9BB-g?kS@Y8KZ2f*U)$7MD zw2k$NRQBib3!{1Qw@$i#Yn83L9SUJzY}@Q&#f6@hH4J5mlEYJZ+dJ(!x%DaH59+^d zm~Xo_SJXo{b9_tQmbnfc6`+49>9*ZFzWa@`Tgsj)E9Y~JpXITg!eo<>Y-3>}FOR|* z6h^!}$xj+`;IGV;&2?!d9?v6E58xYL~RwUs)Z3IoR2>TmQUO8v>&>rIg!q+Hy`ALcsFzFO1wWe&sXifYn&H-Moa{B zF=8WjYyx9%PdVv4%@=ZN{pem(X&hpJTc_<(Uan8!X1gnH*t8Q5$wl(J2Ob=E9 zB798bYGnI)I{VRj3dxR^bI&hs*=iG1|E3nA;X)r;%T+k`NVP9e8LSBo$yk)sfR6SJ zfsLA6Ti$Qi9qx#^$XAb*fxrNbU((n0mLFO0pkO$a)M!tU)|zmXiIR4F5%+F*Zh0>u z;{Xloh?y{0g^~5Y0SDM1ji_Z~4~o#*cPJ-_R@p8Fq(%jJ*z&i9-5 z_w#vwUT*}-S1$-6J-TQyzZ>>XBoIjrw2E>A`-ngmds$^mov-Zbrlgl>wA@s>Q_PvR zjoZ4te%PhBl(N@*kMQMj#f%F{M0qP%!6yUvX}vz8(_|1U$9DAwpEYeVDl-G)7VxdZ0L9!dX$;z%Y*IQi*sXJxG zTf0NjLyp@a*;nld2guu{rn{lBjhH#hKBF`r;|nKxFDA);q%ZHvRNr(lquppZrw?lB zARps1);it~EzATc`1YNXOzimFpu!+P;HxGiss_uh(c=@)5Py|{{f4DL8t6N7zX2M^ z3HV{}te#XzvDI9-zX z#?8&VRWO?EH;{7Z_`^}ZrCD;!>DtSaLYecT@jQ-lDQ@6MnnQ`K#9ZCHy$$rZ`1XQ? zsKtX|_DvNw(NsibI%L>knquy0oNn9WTs$4iO)!f$zEZ)Tad0zvx=iu)1lw;(2Q~Bz zg=-Clx6@&#?~+=;fnMw=((=sO9Vxafn2O1t6Ta6 zY)(=99<`j$LZr%trfPlDThU?x1M^uCPkr|;_CvIVoXV?*rH(fbN7e$4gO)L|7G`cV z7hH0Eb-}k0uli75=S}M-si@7tE&I##jr5ifHFP8y>xztESYkVuRuzU&68$Ss_ei9e z0VqN;Cd%cmh#9xGR^jHnN=#At)?iGkq4TpjMTBU!^c766O-ok@YQae*ah9kS!5)IV zm)v5@?b#I+lj?yzh(Uu;1t({1B2nqJt4X$vT^EhLX1FCT9&zK)sjl9KeEcG{aP8d{ zBOzZoL!07qpI$cg)WzM^=r+sMN9$!>t;Hk#W5jiN02Y{nr{e4-#FRXjE%f03r0eQu zW^B#t`MZT1>7|%2@vmJ%s23INd)?0zKix~qwE={)E7qRvXU9W4%$Ezwqot7g${*wP z#`skp*U0Upoa#Q4X`g^wE!`nu#mVc}XIck= z)aLYDvrLyblEF*5>OJ@gn4gcdswPf%cB(Z{v6J}oO}6jB(?9StQ>@nU-!09vQRz1M z>)jPbBkk{954gnF{ZrUZupL=QHF<0z^!45r?5mnUZfEh>!pTc=@q~z)isV>K@VE8z zm#t>*4|ZmjnmGsA@!^$MuvSiMVB2XkXvoF;^G3AE>}Af?YM2qEs1wqI)%JPT3s*{r zk=BMsUsXD2-sJ+(v+$Wv3NSzoSvrZfBd$mvDVIC0Md0G~iRTXCGan!m1;1D8d?g^n z=|EEDp2XnaH{c3?!ZfgrC_GplC4I*tmNYy4V|{PBP!ZbnXTiVr2JkAR#K*k@kEo53BWnVuBz?u zbYe;z>zOtC+YeTgWqH3-w}tAdo>L>#F|&QECm=Y!f68-b2hw@8P#bpQn@v5+D_gHL z^0ngEazz=rM>^=Wikt0mYHO+0ciedeS1S=>P=}w~)M$OTml7R+PN`OKC=whptPN2D z+@*LBtX+K&QB*D}_U1*9iJe(t5X}%?_ zc(eW%>%EzRa_}G1<`84HXqh_}Fe%#6N^~gOkC^=GZe^w1>d>=AZGMX-r^i2XTB&hG z9oP=AZmB|OxD}BicDpzJ^@*){?I@i{Pnle&m01W=ZKdkZNml3~U!|{n%m}mpfvw$+ zD{~ubw}Zp*LhVsuU*fFydTH5|!o)Tniag3KYhl&tF6AB8#4YQlmFMBe!vmAPF;zIt zvoJ&Mq-ftC-l^45G+7dI!}dp&fRhQ;YVR0p9w%A7ftV(DmgSsW?(Wzs~Q=u26){2F8bTOMJ?*sK`7ij|U>?88(! zLKr<#(PVONqnfh9zXhkalM~Qzwp5rN!mp*2FU$Sgm$2k9zp-RK(Mll)YC23W+lFDx zXI@HqxLR82zIGfczdoW>=-Bfr&5O76lhgR?FFw9vc#T?ZD7) z;v09(O$_P--k6zTcwkt5wDnx>>AAys?$mcV2{ATl34?0CORB8WKe&_rWl{QKiW0JU zRdv`TNVyt|NG^Y34mrcECTq&AJ61W+I=bbVgT8RTP37y2q|n@p-L>*!pRHY)CL9RK zqQXw1kI6k1kfxl*C)$vkob%4U8DEduB?{tPkNFFG9_db}VWYj!1G=1FRa*~4nKjsU zzh8Pet9U=~1aQ-%K(;a`=LT&dDiaW?AgrmEz{7!Q{JM5NF`u)_*Pj2&n|9x^;OIbt zYCx^bY34Jyh@rFQi3@c^)d=^(Ib`q1xF&0Gkm5I(>f%9J5QEjoRqZ`be9gW& zP80`L$<8*<_HYNrKn{5BuWsWOLw3e%iD7PcmoRpwmYzR=?iI^l?7+ikixw}csWp+k zQ0A4WiOBi0M0+n>zWy+FG~Fs8%aky9nk>M3NIfI#V`|RFKS*?UXg*IraqCL+7~Mqc zZ({i)-*FUrz}SQZk0k_e4*6kR+w~$`#aGC0$=IDr<|PXmD%eNb{P>x)6bguzU|44 zt$1#Wb`&y3UL{RZ)1*du(tV%jSOu$Tt(s-{tDxA+c8`C;S7OHuD zgKNASOX2dVD;BrSUVq`pPOQ)j{qV?BsDF^@288DWH8yHsK#3SwUmhfFQ z@s+q8RAyLl=7j1i_cG#CX=ue(Yq4E6it+TTeUa*vc-Tbsden0wwpb-tL|}3YH@-41 zUsqh<-Iem(5HfFkzVU(A0B&#B4pX@pS8mMNGghBP;zeW4x9^73t;MAkfTU5=`u0U( zj6-G1WsQOLl@HXNmkB^7pxUbM@y%?zK5!gF)p%WVP|5X`$z{PQ&<7~HsWkC$RO(-r z(=hGtJX@BpY`HWUwOXcXtQPsbcz(%NcarJaGSi3Dq^?;E9DI_AG%MNRD#z=O99)iT z3Ch0ya=W7Pt`(^w6tTLx^x1@^Y)03WphEQ;aIhxogoTSd2e3~@Tod- z$YmGt1fNEp=*-p%=miMlcSWVWdfQrcC(HPPf^8To?elzz+W?gx`Y%fb5OaJ2?|E+~ z(z5NBffuqI%BbF));AHn4Ki*yicWU>{{Yef~Z!`+@ zr`EmWz<`*7`-z9gPi*(Ob_OhA^^f|L@1ePBfd~*&Vj;(CvYoQ&-4guj)!y|hc(BF2 zujda+4#G5v1*7|`YsPBswMzxX_Zk`P5~eB*Uxpg2{}?#M`aL=|uR776Tifqyr?7=x z{w^nm;EOV04d~fCj2|3aHFcgS&aR|mX*dmBbhT?u;X|nvjPCtXXdHpR$S?d8lUID) zywWz3WzgcE>8`R#tnNCEs~5DWvQS4k)jkeux*duL4LN>|=yS|)ewymwYZnt>`NdNK zkNA2H+3{XSVDF4ZUy4QoZBO;Bz?t!1pJF1}t2(~e9dhmGMXu{#BvSAkH zx#QwO8IQUY%t!OO9MnqHJgOX%ZZ6w-lql6Z+Fq10@nS<_jn`;K;$bRCJb=p{RiqrQN7O$v24?BX?p~W;-52+E!TphyXT5Tq zp$X~i0v4#n_zM5udH8YFJAL&7h%We?^&p&RB6H=al)xGsj6b;(YhE{}ivSv0i{y?{ zLzk&0iOUH~QemrmhY*Or+>sLGgR)mo&j-vyrkO<{0}?f;YRqOWHIZ3IpDW!L=gS+c z7$I+Tk}ZO;J?@H0P?A|gWDYjturOJF?{8b+5SoEbWZB)Dr4c4C0w#Ad_;CX;$?A! z6p*1Yu*b|=7Y1^rlaM*mjmoI{eVr33v-dOAV5n}giUN1U7et$Te+eA-uUnn=mf&w1VL8l4l?>TG70@A<7d}fYyC={H3W+jM@;K(|rAIXl` zi3m$y=Oo^O*)MOCS8?h>ml+E!6V0DyS1CcFvUd4xpJ#=YNXrjM;9(#7qB}Qr~bsL#x))eL2T%d$co>4a9L( z;p8MadT@K0?wPzB7&3YQSy6uowM;t^0a=SW*HsNx6*iNf-_z=IutJQC)`R_eQbi(R z(#~ZDMZ?e4we(C6IFhFMCHBqb{fr%S`5d_>O#4D9rfO9H6KyuTJ!b$Yb|l9e?i6)s zK7koHhkyIx=N=i6nH;i8%8WeLf?v(zxh|Fz^ORoZNB@98}-+?+Gl)itHI^<3sF>qYOg|JRZG|7l;~%`YU`e^qgv|Cv_{H19)Z zCw`x|ek1O+Ey^@pWVe?y9JJhIj^iq4E=_2(;d7UeE;FNm38dDdacobqfYaB1S=4+8TuTxN))mnA z>fnl{n2|KgX=+4-x{$NhVwr4f*rlJt5s$l0`_&1%@S9c;$91g(lXoEwL|vZJAkTg2 z!3?cIYrZWPbN?D3YuIg;ko<#&v2|ghAC}P>%jr%?or-*^GOz@#R;{X?BdWqbWo%U^ z-UVNPdsBTpJWx{09I}lMAWYFJmoZl`%!;C zpa~$=Q#xV2V_-C_P6kGL`Uor_@K5qITn~9I66MX4JHjC*UvGH`AKma9NBjPyUS{1L zo)A$L(0WvB2H@Z;CWEQ+tII72qtyx1+wJ5Zzyk%2JLj)L?S#c{Z@YbxgpJLj5C2yN z-k_fttii5+qk4DOhVmE{!>2>849tw0gVicFMwY^_Hw5+7PYm+aMQ#1bgu-wC%kq7+ z;Hs+mxY}HuhFR}x=n7rAcTQexjOIq&td$igba0_+YoKvx+OG4V)7C;lO{x`dpT^m3 zZUvht1zT_TVXae8!eR(UJ>wpMPPBF``*jN}8u>eN0c$=<%kCLgrUhqn7ludz|jJFqOP zMAfN~h7#Sn4;A)l3MH};9*3=&-pOXqAv|SprLddGvD&-daE@ulm@cW0&rkMrhPbY{ zrz|5~LeSlt9X+h&^`%}PS*-G?UO}c{?}%+r{!b8*g}$1a(_1 z9g37^p&ZjpKSC(1<6{fh{GHS*Kd|-1CdKbDU|7v*g^^CcN3DY0GZIbnh7t9{foDE!d9ANz{!)| zaz}n(RMuSZ*%o_Pby?_)7|_D=?Qw^9+h{g2TRqgF5=5@$orYMs-08k>5<@^yoEq5V z9y4X$2j4Y8`g+>s&7i%xzNE>7gq;{mF==Z3Av0ruRn2TK>_TwX`P2I+#5RlP*@w{J zuUCaT%t=OtTOk+qE!tYr=~4<|#T;_TBAyUTPMAU9|7cAScF*2+k|x9CJS(F7m(=tI z%Pdiu%BQ3Xu){B@X?`wH`DtHEU4MA&VwYu#&{iK%EHC5iDl#)&Y957NpPhY0n)o3) zdl<{QC59AwO-b*U@(R^-YB92}67+lKkFr7#>!^`-uq~H3*&sy|p&V1ZiVkv3?tmG< z2v?Y2*^XK$?n$;QVYoi4$v#gS=luLEVRq!A*JJ0^y+3SM_j)E!nqK$e^$y ze?3Flvclf5ONXrEtT~|6)ukiz>3S>5s9F;&0FR5f#OR75=t92{>kCo-HKTOVk!;k) zt%m8$J4?;HJop&#X;m0oZ&o0j#zLk57>MbNX3d5ChJSxximOhnU zl+a6mv8!UnbdEd;Yh70*+GnMaS5Bo@7G0lugTO0lhixjiKHi(?w=MBWQP%BYgVq(O z)!q_Wofg&1`Wd~E2QK_<6Yy%}wpLO~g5GpCD{EA+9gS=+8a5A=__mSigAJWH%}xx3 z)b<9l);`r?A6)i*ux8jMwxj$ni|j;~J+UYJCbX)F8*RlSv{;{%0tkl8hW~SP|xx8%^2f*ar*+=`jX6v8gCKbdCI}+mRMG?1y6c_*W6&D ztF#7L%}VG}3g)GvRn7MP--;{6NQpn}48qMvR1^L76EGdo;dFz81yU)diD**XIonoq zBGHZwHm~Je>Cc|0nZ=1KWD=9rAKmmdM%p-k47gWk@`LV}L7H&xGc|52{~YRHq2!^e zYLA}trZg?~z|)!kU=9^x+L)F-Ser%}r&?3G;?`V$ zKW`dMEeUOdh6bdx+M_|AcFityap-7@G%`~neRG6f{aLFHKDh?94Vp|T(-IwRk^-qw zY8%R?)>T^DE-hL&S#;dJ%3NaQBf?tnsvp?bX6pS{f#Knfc4k2GYo_y!F$T{{RI@3y zSrTnsq~e;MyqF0p)SA6+0K>by4kPeNCwjn@{FQNE&cgf3WC>XUG-$9oz6FE#lWB;c=KIqtt~B*698 zeSlR3ygmzvUf?Bge^~-DSCW|){d5c!8sn^RG?ZO(jdD~P%}ZewIQ;S*NI||f9LY2e z=Y3A3c^^^=T=j~!ZOrqgYv%KY>ux4-yP)YmJgS+kTBF*b++`?aVog@Km=3MhR*&lUu|)py>U-OwV-BSV!Yh( zS?-S9{?zuueh$97IlyWA0Y&WZmI3{1EJ9+Tn)X5OJ|TrlgWVog2vqoaL?*)IYPRU9 zizAuXw8e85S0v0ut3+_>n)7yaoq*j?xdDo+1kfqwlK0a&j-EVaJSZ#J=o5Bp?~jb& z1V|V3@>SZKi8-ta7u^q;1&LaO)1`D9Q+R1uiR6=dEKRS zJbbM|$NFN9Td9uSWpg|K8=JhmC&lKnm4Ev!-E#UHj~?UgzgQbe0Q5BV~Wd)luP z@qMxkC-2(otZd|m_E5XQ?fwZ2Q?Boy?JEKVgjUNi(DqmIl2-+TxC}9C!HDp-Ob+*f z)nNB3c>Ip8R#>{$di}iHRH8}=ZO)ex1XPUn1Nk00N9_g2sa;J&--t+dGa?AT4xe*a z?%g|vSf)IjNk$qq4TmVIf$ueWWLi8EIrFz5htUtGE5Z#=JnFXgKC-@3dEy~1%aVRh zK4m0BV^wecKKb_UykhpBbDO^FF{a!^w24>S?(cQEr?(r-{>)B~ z;7ISvmjYc}=ck9NXzNmdJBemUeVeI*9!}$20A`*u=$AAi{*N*$I~&#trybkQnV>bm zW}7pUX_ud--wH7=tR3>6yWWg@fob7r>E&%XcteeHBDal;;=oc_wPxP2N|>IT`WL(~ z@utsenyFadka5m_fPf_GXO6j}NLd!Yt%+q@^+0mQcUVZv1JKiR=C_Q&=F&QdE1l3) z-n&xa`|Z_c&;2x#rqjULMW=?5A&0vy-HO^#Z-EtZP8GjrN3HD~BhqFGevdue_v7{NO0+oQ<*URFT6xeAInOoHrOj0D(84xPz0?DXTr5(8@pX7#AtC zcmB}vj&&a9loq@hElH#8wWDv=n1Qdx;&JtZr*=1sk7AltAniGcZN$dGO4rVaiG0tU zA+dHPasDW1x(53F>wefwK|=v8Sw%oCMbIg-DAcSQc{2nK66m}>jl9)T__OSK#O<%m z<_dquC}g}y0zaC*mIdHA{-k8b-zoSWsY&6_6PI;#$WF;!$O*~6duiy~M-i6fNzG)c zj>=~v)_}8fjFI1c+3$wlIY$7Th+DsJUQl_WQFsA*KT#O@S61Y-meth@*BW;wjBLrh zF1xoC+G>u2@HkxCqaii;R#Hn-y)Z(yRFJ^PzUKo_hFmh9D2~ryVs%ByGg}~N7L8Wi z6=xVp4>A>Ig8pIfAnKCi@D`U-nTeqz%+S|C9v?;fC8MI|8Xepy=FX$HRU0zXl}7uI z_Lj}x&NQy%YzsYXIDYYDSM3_eC?n*_bu9B^w+M?i8ix4zxqo5rt*O`MnKi+C%?Xlo z&7W`g6=|0lM2LCrQW)1~8;mTgwX2xa`JY z;`Ef2)vKbmqDMou>KE^t4yCTItWdNxNL=2@HZ0VvB6s{vgQ`IZnN!!pKu}%mI44Hb zesX90gtVEfuQqsGE^ZjIYwvVtT3jM8R{lp_oCtaJ#UZTb8hu~Yy1P9<2y5^P_F8&j{1?K2h2*d(1Tpten8>4;{*@ldgz}bkP*ME9ycBo-ft4^ zn6lV$>?TB@pZgk#T99LV`OMs4AxmjM|GkSOx|5}?WP6_}f2gZj6a+S?s6pv_PTN|A zsB-Msd|kA)A{`3zTfu}}lQoa5(drkf)VqU7R*o`dB9l9mO_}eCE3OWI?e3{`TDvJw z%LF7kh`7k8-EPauoE?*Hwa0@tE@g5WF*en@CBOyHdua#1_wup?;0u2Xc-PBF5R0DkCuEH;|ztgV`JjS~8gRXhLbYAfW$tZ{5Q z*O5U#{GU{YU4eb}Mbcc8o~tuZ*;Tnj`q^(~$*2HPk2?`Awjp9J6*|tsC#xT|Rl}g} z;liy%H})y6S(+WO@zz(Qzk?@}BQx}9cMU|VOIZfH|=WM0-n_T&vpq9NOd?t_W9s!2kv(o^S_SZeT0GN0GmxKI&NeR$Df0e<0 zCA<8=a!zL1PzF~nU-NWB#nF-Rs8jpVM9ADR5$dFvWh(W&?CLaMXz| zw`+Fbrnj6!zQLGdq;q1DB%e*2!yabYThI6T+?w}TK|z6p#8=1QaI1DwB*TVmr1PPC z-fyoxVl4O^Vl&fcFx28+?wnz{qvGb~qlR+vNNOKYu|1UMtLFuWXDZ%o>yT|vqds9e z(a`)G0Ml1AbP#WZ>Td0bl_T{Q*E~8+z{N$4*T$%Z$NEbF@Q2OdC$$rF^Y$9ZdQ;cd zpZ~JFbLt+QBIbaNEX$%vx*Vc1zmFf$e8+@RHC&JDBsUMI66?q65|cP9Vx_hy-6gau zb-QzMer{(s8pDrjN5p!_q9!HLW{DqVC%n67P7TdSbep{BMFa}1fhHiC0R%^nyAH%1;7AJ4y59X<}^jR!q9tNWKFB`Ad? zl3t07ZM~VQKjcEzJTkF&(;ut2QzpmTe{nlGRuzoaNXA{C5r`wpZZvG*FiqfM`N!md zWWeaVv~Gr_V_$Cc5^lU|usI+CARW{03MYzdVpQ8}@WzU8A99eD$R8*JD^PncFzP~$ zmZ8&TPT?bAMIKG^VTSeD%CD&*o)5#qlhOaOa7?=ZnHv59r_K49hu@F!)VqRi-O&Xu z6N*r%?wXunJYOav#EH5T0qw37iMG2xnFR^j61m)ZTN+4VAx0zTgP#nC)P8Kan?m?u zxie6GvGMSC=$~)z-e+f-Wxsdr=)=#uSA*uhUh!s4T?{qK?6U=P>WQ54=|F8lYLERR z(vL~H|FQ_EU9<-m9KOejxi6&yw0`Z+D-X)l9swF_R#~V#dbRDEj<|jj zmi`=`sx0!L*UdY9IVpV*FdF(=)dU|pMYRbkDH&PNT&kYRI)C=ia!u&GcHoLTd}3*= z`kRO@YUoPQhrd#le9!5%+}ZvK)lu-c*p)YZtoR4??~F{)-_o%GXA4+BERP4SfO8lZ zP+}rqFLkTBCi-`b?yI=A^ITTbI6&!o`me|YXHMO=xt zB;|X>WozzSeXimzxjz=nG!j}Exl!XOJBMSRnQQ+MQ+8Ck)FEg-`e;x_D7bo+o{0rw6HhT|{tXQPKh%hw%&rcm8TRX)@Q)hb@mOgzTciZ-(6e=(NqVGCyL5O zGSu%h+KpgGPVHp%slE4e7jq9ql~y)=#n%w;dxE|@&5)lLEK1QLiV!z&{E3UpX2Y2{ z`%}Jl_(-DqgFU*zV!Mi}_u`V-61G)5NXSJEU-*8UQWh*l#;u^VHXxFDrxq?V$9me$ z?sk~max#|MZxO1Ir)Vl~Lg}%ndZZ(I0J^$!Tf_rm!Qm!p`E08;Qu&h%on*ovN@rGC#IvGX=8UH+! zctK&M`ERX*cRqK{A$2Zl=D&qz=Oz`ucyA*yztnRnI?`eHo=;#MPgc^(Ef$d}D~qyyP@cQ(SPZla(6kAy*@W&Ks{x}6ZSEum{n)8+HO?6E6n%3%s#(?&+k zV{2<4Kz_a9!qS5TIo+n~TQ1Ew7kwG=UJxg(^k#9uYQ3Cr-#ZKbM?Nvet!mtmz0@We z)?E?x^B_dap&-_Lhr_isnZLvgy>Jm!{*FllE5TiS`U*P{B@89|xd>9=^=L?MWf~>^ z`;g~iC%2t2dlzFxdZ0RsALg?EK%ARAs~fkhyK;IJ$DRdq&E^75YO}4J4ctGy;2!gj zug*Wp4$l+oG-(d?OL6g54!*P&l(#)#b5cI(p(xur$8dKVS@@rI0{gM}4LD-__AV$p zq(W>2>#%h^{>r61bPi^Mw3+7i)i4g$;fziIzK@c{kHG=)qAJjz8Ir*fL8AiAyh69f z98TA=fo$cG0y{#-GHJxo+F94&`}Dqu1U!T0UOe$ITVF6(dS$)EQ;7Nj2~wIlJc~b< z%CuyR-uZqMUL1LeR9WduUD}_sJoVeDQ4(x0P0qeGEG*;TfhC)yEVQ=AfyEUzP1V1s zR}nusSeWYXDC4|R=rpE3wT-DU`x7N^n|ck9li#0Wbq)1KaU=5a3(}({ zstU=s`P0XWvv#orHeLyXm@vQA0iP;e_3(q`o|x79fV%lLlJuXdxpwA%8L7YU2){(l z#b3$?fRO#V0#BMhSIVgz+f_5GOauz@J~rA7>yhB&U^%3t|Q^w1`!cAP6v z+B5SxV?K(U>x)J~$^nRS{HkWN?HH!H&Amtgr7=HHJ=5Hgt!Ra? zdo{|jJ?YN|UuPY&Emwe4-m4_FP8~0}I+()wOb9yj7QkS;a!d(fxbUiNr>RTfnFNWn zsp1g!>7M-hneXG~!DUl+TK%PDPX8I_nM<=%m|>?@f?dEWogU4fMIOC77kkD6P&WF7+ENFQIz>K~IYsWFH)GX#)sqy17AzoPJZ`ixMZY@M6i zRk)${N-LKm-`8B_{dFmE7SbU!d+DKcsO@AREZwbPyq9TI_uF4P$`Oc`5xHXTZGj8o zq7gCk%;%h?c9hTdGmBVStLQj9yB=BV&pII;Xo9Vkn|K~{ENP*qVP^EL1pJ|C(m==a zb-*o~9qAS&Lv)@U#62}c=J{>?YGy)};g%?uS?l)vuLfVxl|0@_cBhtt3&YQDvp8L0 zG5kmP{O(6VA>ohQEQf!g#)NXTA4ql~)Yi3>!w^{u{#=7dr;oR$(@mcv?D%%Kb6Y}{ zYC|_#u_qi_``s$$WWVmQN({NXoEG|QY@W6idU-gyKmK)#+`*WWW3E2_E5&&)h=rgZ7KR>&X`*1e&aN>-miRw+--kL1 z=#|X#|8B|9`ft$!FeBaCLMCsotBx1e6rrM<6^teFKS%EZeLc7I&5?wH zR!k)Tc0?d`FoN!qWHQ$7stOm}Z1fvn6+Lx#HxvL4D@P{l_&6p78F4^r53csCv!Cwh zrF{7{BKK>z_`d~dmL26p)i^uv%6Wg(16es*q^ z=LR@ts|BFd_mi55oXiqpM8bnawZj44sWdt30ja+9SivFlo_XDkwRnx8sUZG0;xOcA>?0{7f%>l zZ*Ifi3AWw-tX&I+pD*MV(BfC}I(L1j;ax;`@yz_&8RvI*=eF*>21c3xiM+krZj6a! z|KjSdQ4vO4n2|r2QNVm%_QJO*LxxUY6NGmU0L@!za~oIo-}C3%2z7HmgWbMun+FU9 zJQNsT*yENp=cIzvaXFAyH)n&mB6>E4%thh2Md_zk6te1^mUbbi5gR8R2nbB zK16$-E#ughaFWTfuX>@u*Z&}K`)M{5Y-?tLB)m-A)@7W946Vj!;jZ*j+sis=MmLQY zY`)=Ni6zxpa|Z{Y+D!uK6@Xu-v8fWq2$2Ulwpg6Zi0UEO^9-VYkl{B&&Fh+=Ko0U#!l5c;VlEAwPg|)?Z=cEWbPb znnwQBP6CP;EVBRo0W;&q%zc8sj#@6O08QC^V(5rsn^o5Fh7NrMkFi(OjTjOR5?B2) zPqFWzIfUeK9oK?x-rsbfQx!jn^TExM;|tt7%khuOV2G6`6t~ZDYo8YLerCU!8oZIT zSka-|xI8=8GCP9pW$1W9mhLFNQ9VK0s`ibJDAY`R!OD-OsF+5;>H@^!XSJVl_I_X2 zC2VY5mru^LUN7n`#qY*?Gl2>(bS{K2{mJbbmG&Tj<3@_sVrl?A54w`LtiC3`!LKla z1OmDEaV4*AqjUxQrFUTJyoaLG!`scRo=Z$;-Kt{yuGbnxT+WxPy~GM}yro5%Dh3(- z&suzS#}?ZVfBgvHJ5hgTl(b&+a(NTlm6Jl)aW)(+0yNq0y| zmU}*YHDk!*jJ)4?kI;O(*OXVzbSE7Oo4pV0*p6Yw!S79!yJ%lQvz8juki-huX|DU0 zBL!s642z69>D*mAnkw-svpaq|)L-@~WtoYxkE($kY+Y^<#w=b`Z~QnQa^|-JHV5g@ zzil9ICI4Z$H*qa8S??SaHT^b?<)^R^3xOqB_Xii^0mAb9YjqAIj!&Avu&l*b&dN$d z)}Y8k#7R}P%3)G&IEUuZb_S(l@u=SC z-tqhx5h4XW3Nba_kJBLzw47KE%dw)rdrM1t6t;IJt&|m8-pI|!o9sABIVh)$$&XGL zJkuG@NlY9RYFTQxT}ZD#=~d`5hXSWoa;rZU5}1jAqT$je-<=E?OW3ba|E@4CRNQnduFfx%Wt2h(<>vYL)KZ-@EUqsyRkizE@aK8c zi+w^8m<}n^9`~98-!ZarpCu1tYU1xVr=_pvcbJ(u`F3itb+#_Yc;ya>)T`3{9o%Ze z26CuJ-=26|80amb7QrUW2;+!{6P5q6#KQR1_Ihu^Tb=cJ(*#9vW!D0G$=jE#^ozLo%AgCyz z@SmfhX=8I(8^C7&NA%iY7HNB~5~9&8&@+3oUelo4`h`Ef71*yNe@)^8Ti7PcTj1xX zp!Ce`z2guCf*iRltXbd?YFxM4MifA9H$Tk|F%S?~97L6Yn@J;$mBshEq#!f8?7s;! zbBrR+&CqI<*L&)zA4+hn6=^%mh29x=h4ZWS@0PA9gQP?9ooMIN6&Y$ImR=M9NuVcs7dpGCOgD_m zRUfGz1qsWNy6VAc6&0RUUiF-jHfA@QU$i$PBdg(WXmVSrc5#n-?7A0l!J-2Gqf+t; z^Nvt+r{N~4!O{?3DdW*l>1E2O%(%sXs?zA4TU1x9rU-gas4S~l6;bi`D5k2Phi2>5 zQ6Q*zQMr6uEMND)_Q%>L0`rV2!|fdibL#hG(=;%h%!wZ;IIk0Ih?%;Y0)5!I&OhEv z{ghgsfvUJ{z$TX*{xpv9l9JhWxZf}mI7$}nUcuV$hMkY!zgoX`r1nuI z@~+802Yu4ln42?u?3V3`;!>rddbUe^1{M{~lSR`=KY zc%In<^yu=)Eld*lalQ6R)ll#cbn)De`l|v6HWKok;?pJL;=95ET2;~+ig%5}vo*OT z=Zbm!WG_7Kb>D22J8i0T__R_)8Rb4l8_w*^Mq}6wAOFiz=>DVXP$zhM32qG0t=t${ z?2_AfA?suoXP4#MPc)NtUst=}%Lp}1J)D~fiXK(=Yrk^q9k)7Xq56pCGR^dn&D6GQ zur|bO9FNTi8ge}JKKddyb;HMN$BmOv@%+Q|c`LjMIEQ^0oJjA(({w*2%QMi~y)hKf zQzkGLbseZrTy#ap?Em#^t^6g6D>gYKbDRTxG{8EK9^4In2O9-CQt&T0t7=TU+KFk? z!WQ3`fMQ6YT(v_TCX4UI)fZ2rf;|58D+KI7UVuH|8jU~QFMDp;i~xItZOwmlulD)o z@_uD<&n!?qDcb|NwI5@Fz-?ZZdHhS33(Vh`JE0NK1(blO{ULM%fbHK7RfWs%E3}OO z=M4Tw40YNUrrzPj1ffd&?F3&pL$zBL#p9{n`vSVOJ*_*0DbVZXRdqHJEPj+i#EhENH2YRKe{}93gM<1 zY~{@y=#kkB(tSglsc%1-=n}$SRi**Q^p{|Jg(eC8AoT~e@{@l7(TW8?~t0&#sHhk8WVBz8(|+B=G2c|C+F8s{C0(W`CnprPe}U zq82Za{$yNsjtw@Nsjc3cHQ&yeSZ&T8wXN)#sCO2Oan)1(m&Ge0XuX~xWglN*-KAh+ z+~}(`BhZ41(*08Hec5Q(et6`O%?vdjsX%*Pu3y%Hf0ekUp4-xFCnE9UqY9#_({kV6 zeW8GIktSxQQdRCD7eI-8Gr`Auf3XUXZNKE)+y7ABz<&5Y0`AvV{OkJl+h5nO%~^JR zDGD2AD7V%tNx2nV^cttC5Hd^PLrghj#heKo6)N|STd3CbnVBW;1Xw^hjyi)=({;)f zjC!j)gzDVWjmZ2`odh`x;%YHM*FgBl>N-67O_1+}sd*rb>}IaiPM)pjc5L)3@710% z?<+sUUs#P+X@_xoMG zb5Eko|JKcyMBbj;e9O=eJm5OTILJh=a#(I`EO z7S!SeOD+JmG(Qe}ublEe`In_6$~+~e^~=a>`yxy+uDtx%y|>*oI6u{iZ0NEW)cg*# z6l}NlN6FsN3^#xTyV{DDZkc*t(NexQ;&yaOu5NU`9rKy)pHN1O#JalTo;#6Ltzs6( z<)Ro<4VD z_Vm*?fz)86oJnxLopx3k%^rk%iYbzUffLsbk7b{Sasyns3Xx*FZl&D_U|d=3=fOpV z-!9yGcmL628Iiwl{dIxuS2co4y43Ui&=CKdG*R@edQhA=_^%L!PZbACzdLY9zkQi} zA!+X{Ap!P#*^73gYilzU47*U=o=aRKqib#XI(h8&HyZJ*@kO)sZS|B>-T%YXdj~bS z?cu`gquZ`1ib_9-fOG-rZ7T{QO{7VU^pXGq5<1y7kQ%9>Bhp(UgoF;8P6$N^5JCto zbV#V76YuLj-<|JfCYfZ0KbVy_>wSLfY4GQPEq?pOL)*rH#pa_gEm)n5p=xuT`!Zw_ z;Kts0Od7xx1~(cowfeFBMUt~->@4TXf!wT!rfzg)3Gw8lmjG(-_^pdSO20=B8Nl97 z^De~PheFp^JQ})4^^aE$RlmIGK28XFJ~~g71FGf1vP#FTOe8*z-Hb}I+|qZCJ0phM z5SJQ+BGJDQ$&#dcEK7fYpRrgXf^0^a;vXVzfNudBMz* z1nhAS=`i~A$BHpYU1k|SQbA2G&2OwtM;}JGv{v%sP;*D9QSk#ci4LS&K*=M^mNY4z zS&Kc+K{tm`pc`OnXEvqeP*Sc~aSd4!kU8kPe__7QKpMWnyaqq>Q8gfS!1vVlY;AMM zAv$%X_7}??bUcTR@j>mj&<)AaS2~4}4yh2BZk5Ic2Hf9f8JwtQxPYGtpb_6$Qmd7!zyeE??yV4^RE69f24kEo_~)^?3QP zFxCy#Stot-TDGlosKq?6yG?MK1k@PcZqte)fS_pV|ID{9T_nhu$5Wxz~PCx zZZx4RNuPe67mco=mKkTU7bt7Kg-}LC?KovInF4JJk`hvGWYTvv{-xP?<63)1BQe)1 zc`Xraqj?kOUU}PV1-IEZ{;b~nwjPYGaeUr6*=++>vlwZOM|fvatSLMW7R|pKA!!OB zWn3M!+Otj@%3IndkDXWXla}1)a!K|X)C+I{UxmB2!UO%)DSkoSOPZp|$aJmFZo~Yk zxhXIYcaC?F+ewfbS;hE|COf6pQ|J9Dgrr>z9{hT$7;@J;qw2=@zLW_iBkN1YgI6p%|NuZ-c=Fres{B5A($fd zXStGBTX)|$>b5>k6XCHC;Yi`XD>P_j-jM1@$&0Mt2;@+_`+B=4<*J6f4+xE->>u*L zy%qvO&_GN=MsM&&btvgL>F5;pPGyjDV&Q?s#V6j!KNE(E=q}Kj$T;}bP-yv_U6_uN zndrtFNJ9!F$=kaes4J5W3U!jeR{oCLmIqbK#MTTq9SdtG%tl%*<{uRl)o~)0Ua6>X z!|=x_#yf}5Uo6!ia>juAV6;PhU$4Wq5RorUqg?Gf5VsmavF|sGZH7$yfWS0Ymb$Uh%d0;lwMCoEWdmFcB18p9et>5YpTRx;JHWbOvY^3{MU%8pvnVbZQ!ji6J~>6AGCvY)LR z!;1RP6*wAgD|8y+9wgZuso_}5dAzn@0p#Gd(AJ|Ba7)v=EHUT4y_6n&Sz|%V%Byu8 z+pTP004BSj!B3HTs=SoexO1JKqw3nK=T89!>l5c5V=(V!MA=$^VX@6%)v}!1=X!v0EEe4)a&+*f0=3{O|ZG zs8jm_YF`~^*a{3wZ?7TdT-!aS&jq=*qgOtyw{=YhhwWEn*lL<8^pC1bJI(oCny5{2 ziP!V|-s^~o{h9+Ve>`Gr!EGRDv!<}tEyIUFwh-N&GokB13UgrGLa#5pMip@m)au=x1nYM(Rvs<(XARAaMTCXFJ^hg>2Y8sk z{y<@?AKy_=0m-#bRS(jqF>*mX207hMH1`Z~Bvy&NveO;lUGni5rDyO$&yJO@=I5;^ zSazPeHe0IeX%1OmmO)J_WEP_`v{Fr^NH33nu~?*d$c(2LPLXfzd;DU-zN+ZW49Spa z)_H66$(Xt!CKB2H@M-Dq)ddz+@ekr8*$QfGJ!(ui5og7dKOQWh0O^eZMMb^fyRxcP zhW-d6}|Hzb+`mdPJ z%XBOFYF|g1W;97OU}~Cr-QLVvL0clVpPza?Ng==9kg#c_SsqRF;oWu>f_uHQsqt4t zrxmrSIjdStb(K7sp4y8Zen$eh5B|`>i0X~Q45(lNHpb$Cm~cN%a1=iQaV5zkohJ4M zOlwwRQ#LfH4>$8;2BK$@`&E0MXh@X{zPoT;moE0Qyy1~%v51>XSOyf%pe=+PwDdT* zsYV7id&3F2Wjz!sXm~cbyB6kECe1K(SynDZmQrnuULr3)?|iEx5RVw3xd9_)=qI6X z0S(=Ody79OeP#a(2o5#paz(Q0(agn}wZ3&4J& z<_=6Sz0aJZthO72EIhiBlSm+1XTip%NNAwj`bizOMQ@>5O_8puH%0oVtL5Hh@9qlI zP?;dz*KZ9YTc^7)F6gYD8BZcZQw)2lx4dM#N+#kUC8vDUO+%U#wdsGg-J{aYS?F0j zVc!~~4T=sKjfVJngB}n~Q`9d>d#x+Ho^1$yI9^spXcQOk9huFzNcQk}JKX)x488Ht znaevcvd`b0xkg))S6e(05#A=Oe)sCC>Dk!wKkZ!_-DUM$JU#W+SY-g?PNPm9STZrT z;*N}2#Nc)G%B@sQB8{|?s3^RjSGu2vXSxy=y0(Fny&a6@N%5gi&&~Z~!*t#3Be**2 zvS@^ySJZ6lpMsR!mAvDN;am!=PSzE)b+1VSsb$DM6Bn{Ir_M)};-DvW9R_#X46yt$ zztMzyk0w+MuQj0-hIqZE=VR4D^63KdbP}K}#(G}`H~E__)%Bl{two5lg;$GD)m>s2 zgR%Y1wGlo{mWwhe@zD0S?~_7Me?<)%VY9x$1+<=VM|55m9D>740qbK zv&wqw;96DnNU*-4f~&U>kA<<6z@2FBkyyiL|2%tM)`azdE;$n-EkmI=N#4tyX_(av z1AzuXx&ET-O(kf0Xx{3W`Z*{vGuhF*3Uq;dS+PQ`B)h>Nl{n0sXA=UZZhBU1IFV~A zs!nUX3qaBFylJgFwm_R!>il;5Ol-=WN z`MU9vaa)PvAzV%zFIWp|h9_p?Z;RO?TkJ+XdAn^e121cY4A$;v=wlDu-MveAl~OiD z!$LxUu1WVrGBzmUas^`Be}dw#v+b`*ZwY>MIPqP%-S<7Z2u>ZXiE}6?s1xN$GwNkZ^fi@x;v|?l?IwE!BC_rokKcgG+{JUIC)-s@ZmN$p!mc|AFS^< z$2w=WN1{&ojh?lz=BMiBP8uJoNa{_;n3y*;U5%5It>Vv#)tlmncU`K_N|?P{VcXmh zl-^FRA?Dhk)lk(_)Oe7AGtFf%5f;4Q^FiSrr&%VRmr>tw@l{| zMn(&7rmSmwqGsi?Scc!F*7&OA)T)O)&Dqtj*O?CxgKUO3+d3Q4e0ZFqj=iJ%~ zI)c~GaLjQ?vGGyp*uBZ%r(tmFRdxsMypV~32CzZcoRJ9fo9}3Q;{5%HmWebz;gHsx ztV$5*%(>-$r@9W(Y(NTdAWmKVS0;466TOV|J^eI2f;qD@w)1sk5Led}=9HMyyyss# z+If#pe*bE7u+Vhe&uxkMUHRF5#N}V;P#-@9n64K*_9|9gXPT?l7H(jkwW3POD(}nrqB&dlhz`tU8+5W0mnTHsW92XhR=!2L#rR;$#Hn9H9PM z5?RTfThaE3$hb7O0r?feFfqGG{^)3Sr~(N19w({f2-iq&R!K3FYbX_Um*B?(ni)ik z!Od06V|!&y9zU1zs*0R96VRYA+jFY!KF51IRiIAcjxo%l)r}jh&%B>$sc15DRwd-! z3RIe9D{63LkW`VTgUPP__`sF&t8$Y8u&*07WG;jB?RuG2P)GIG?17@1>BcZM8}mO) zw9Rbvdn28~j6LHjmK@CuqO)VSq|8u~2l+Kvx$E8E>&0y3MlZBU?>%!BY{OsLSaYe{ zu+s1V%uhljCRkpqtNu`UH;!Wb8c*qM?Z9_r^XePb-0su6aBiTfj!q;O3Q`)*b4I}yn+KD89(sXfeaND)Kfi`JIeDMl z*6a*OlX#B4Dz6{h#2u3Qk8P^l zt4}#t?rRk;c{yS0pORnxD@6HSZt=UE2Eexe2(_tPK{?E*v zXR%;Mb3rxb>Z3F2afuHH+QC<}M^Z!jYanG^!Ex$2nvnE;iD8bUg)Mm}w}qXNE2!7F zq}8XqdJ0}C&842S0FPZ^#`hGLk-kfKSypQCmxf@6qiKcd>)Ly|*x7ZO+z* z=+52Y%WT1Ws3^e7v zVBq{r!cBBN(=yCmZylW3zS5|2_)2hQUORdQqFJQl=UAK2f3fI#X5?AX=yM=Oqh*?*S$RdPr#G>39Cg{O+8XrX9;Dj?4s%`ioSjTRDZCoTda8p=J%zM~ zG9ol!^=?FlMFx#GYC*R!INQG|;Ne zR!hmI9IbiGTj99*G`rR@nPB?zCGy56^9P*(OMT6nZ0Vtt7UJyKeur&6vWZ_$P)Xsq z%;>_GFrz6Mr?gtfh5j}1XkmvijawNi^=s6-p;i%va^R>CUh7&P8HG$Aj~~_Q?=HR+ z_<8;4`915e@ULdJ;g9ZU{3yr&xo{*#xEiP80v>XRLoCANiPZk$(+GjY5Hc@3D|kta zzLC0HCMh@R&qEx2W?a8gY-;I~xzKvQQRhpqy-<**;XbOABd`k1>YCWyW-S;LWH9KT zpisTl@)N=F!b5Yp2Q?g$udF$bo~QPvlpE?mB>NbWC&6O>ObOAt5{*zI zr^^cXvVM18CDjWxmivJl-g~bb1Hmg^9}CtLyi8;3X8a{NK~gfhTna_>Uc*@58Q)rB zI+>Nv{m2m7j4Ru@J!cPYm%3N#L{Cak&RDdsYDG0L&dJLKj3-ge5|HUs>sOmbmfRef zc&GPAt<`OmJU-tjrt_mwUqmKkVK0M8L3bVeKC}am`ybFZa~QA(Dc{nm{{WbDF6=21 zp8C8os*hFMAu(~A<^ zqxt#yr(ePAf>O13JmSCQb#@MhIsYwh&sK0v$*=6UX3W<%7h`ii8fw|rR6|o#z>##u zW-vOaov`3!SkxwafB=k$`)VUJ3AN0t<{MYw=SLcgSGfBENJ|gYQ~Y+Bb~Q)2edP6I zpfRHA3h(i_PqlGo<>HCBCD0P;wqK#J0a!GT%_f+LmS?*l%c``l9`#qzqdYGl6;wHd z`bheK5TDcNzdD|ONuV7+IUAGGDp9|Fxd$z`dQv0lF>*EDP<2m1bjzh{C+1~F)-J3L zw!&u+w6XHVrp#>ZOZL8IVye3Lj$Cf1FQ$WcGjT+v^`BY3o6xuN67BMxSKI|%c%}5& zj&#A)3BvI0!f2~%c|*6oSSvM{`d)c|`Vh|N!)a2`wL9nJ7&RknTLH~HE)5wC!Dd;< zmoOKZptl|A4d*2!A6BWpg4LfG#I*aZ+chF0b$g90)GjZ*3!Q3GTz-0uhv-h;x8{25 zyBwz_f!86*p@tjN1a$Uc`BqrmXmVwVr;6o4QQY*-DyZT3IpCqP^k~l+{z7|6@_ksI zB#;sIbRkSIQ+T*m+oV71<`gf!%oSH(9D57pPik%K~wQFA$=a*VHlW-`jUp7hKS6p2nzgZ#?&RY4uklpw-%}1^E(cEgteVR7-OwFy5v z?m-2ce0r_>pmDHn0Nr4pEdADg_EiTgwHG{7D%oA;HHf>&b+bkV<~1&#E($9}oK$yhn2|i_Txbwe!=w+;wc_LiN7<4;PTS9jw}rh6QJb@$9VItu=Jla@5U;(AQVCsF)*idwbBC?C~@B3Z&FFx1ne=GThZH}#M`m<>1FiYi=i-!+4n-2j2 z+&#ik%O`22&eeBzOFhr9_rn8M#4YKU(-*ca@&=B0rtyc&ISsGPl+ft_zAPGKVtDLj zM1Ry?dt_I|iHnrOAYuRAL<={d4Rs@ys)_3^LPPF1Mw(81ik>*cbt{+V zch);yabsg-CrZMU559y`b9P?FQ2ABz?3a}QmG(u zU1GR)uWJY481~H65u}lF_*h4Qu!kV?{6IFx5Qi_1QW9f}W~#=2v5bvOqnxvj(XV=c zW7q%3fcov<&%X)&>nyC-fpO{gIWEBZ1J6I=f<7mzhV@f~&u>#^3M?ms4ffoG}Us3Zt z%nX8N3m`;9kZ^VEWWkBf*>m6~1V5H7~4pf1P=l+(H2+%6flICKq;aXoHW0~xfmfikO_ z_oDxyX|5z8UW5sz&34@R>&;)b;+JnT&T%u4%IB1Vxy)1=M57uZRnmm1A)S;$a))17 zQO9@11B?-CgY~Uf`vM;`_e08@7RSnkjee+K1)JcVil-FL$h)|u2!Kwj2Cd5{2;5mnIay4~sa0R28_ zHVfGVp^tKxOHOnoCM8khYu|aVL>al6iB8s26?B^2kpEAeI+|HA)oQdmT%`UVh?<}5v!u*4W=4r@2(H3 z$@VQzN_BdjjEhKx7%l%|k)m}?^T7{v2ahST&udz%SlxH7VX0YK=h*A~)x=7bTYy`J z4&1Y+Af%{T3Yi$#;qTVJ1nrshh4j{~iL``kp!v?iflAvHpwhOf=uR|e36+(X9C9FK zH<;#ShHPA9@Tc&1O@#eKFW@lvm5chIMnT)$>+s!!gvVRLM`!(XwqS2q->`3owO)9Q z@3_g@+*15Si_g7#mkr71jah5O)_DtBl+l%V`HYmzTL*1uf~fbdRoF7}_;IuXq{-qb zxuPX5g?YQFTQhR7Ijv7uGr7HRyrUgVv&vSdezrAbeckU3j|$V}?0 z^YHwMVwKkB^8I8Y(bW`ES0Y$+Zdb1fs%!4TJDT&8PUk6#2$R?>ef6=bt{CZ7b0;@l z0ncjgMNnOFg2o9#VL6``qJ6erRaATLnX~PtZ`|bJ&GGXPgVH8XSUde>4#RNI{IqG$ z?wO@v^E@YE^L(onUB=Z8NCl(Ax%y1i(>l`vdUw5obHLXB)NR^;oh0FH>Y)^uVv1iC!P%(fd(xPu4X#Pxz@N}ENa<`(3@M!GA&jYEtS>L#J$KIp7gNhLQK zi(ywO)onIxm=o#MZOa{{cv^#V(373EX4@qv=$`uiq*XR0O~EEu^&1MwP4lIG54&OM ztxz3o2+9H?7f&I(r|%BmL9Enyyb&V2ucKLi)gguOAl9 zOE@rO|2){O3GiHAq7!%V;^a^ZO4+bd5;39CiSGkw+#U4Z0>}qBX|HNAaY~|16+t-E z#P|(w8|GM!X;{ihwKYPakAj)N-pC=uyMcA?)V(9xV0@Y;5te5;QNc4iO2m(b4bze! zK$;fKtC!}{=C*Ruq>hab^-gp5t)P|yI@jAD>kS8=2I;9g$gGZAvWG~?^!Sb-%%D8V3{2ZD7jfoxbS3kYS zo438ew}@%J%k+p0eb^i=l0&JIWZRd^#RsiKEK!J_*|(uu645%wN{=`9HdQ7=o8Lu- z5Hq^6tc1`jOjeDBM+X895+Y@l&#W7J(i{t{A_y-Eu2J;2lM}!cWCT}+hAjD*-MiF< zdTQZTNCzn4yHcg>y=>#MK*N>2OPhJi*@9MiGhDdNNWm@Q-DYWUTyg6sySRNRw|4I; zmAGke>r^qQw>Lse01(}cH1Aqi#GtJDRI|{v&fBm;FJPxIoZ=s-(^!~j@hnki1qqlt zmxMFqffC;vR?L5>AOyVIihVEaa}M*lxmeHLIqaTXdrWr>a8 zD>?S*qg}~hV|sGuC0c=M4^BUg8KsS_+Z~9MFAUup56>A#x%ZOy0X$tvqlmq3AEC>Soz4}N}~EZN|EQg42bk>87UjfkBH%bcf} z?rjWby;kzai>f>oEKER9pg7;5u$xf+ywIF;`YZaT_HR5J{JdV zZ*6|}QCtUxivR1+{mse$ew_UY#JmBiZ-8s}-x6QIp?e-NN!HK6;*vj_BuQ3@n)F)R zmw$W}*WCXyzO1abAHNZr=P~Im7@1L{g|B*Z&}PjRGecBFpAY8*2*NfY+riMfQ8PjY zFJJOtNw4h-!zUPHxQ48PVZ?uI%mk(qUjfm)aE?W#&>LUEd79D$(k*LnHxRP8C5RPM zh!Lq;?%{BOtek*Q*q%y1y}uy8yc-nHVcxU))Ja{>_)Vy7z8C5jOKT~}T_QV{uDa{| zMSf!csM9Yfr_QL|TedH$wY5rUk>cJ7YytCdxOqX#& z+kE*kyvZR%?~){j?!5e-W~DHD0T6wZPYE=ZphLk^7M_DEF7I4+dta~JE;^tGX_VZR z!j&tj!b65o)eFFRXpD!+gF$axw%!RiqLq~g=bn~{gotLzni0h2YENo~MHJR!0?<~8*2C6=egrPXJ-BU77mvF<1-YU) z$TlIaTlW3HpiXWvsV*}Rzf`ra5=394okW7jfkxfm7YYsz`Pg5Du^H`%xJJ@7S7Y;p z{6QJDj*VIEq6UEqhcz=zUs9LR{TM}DHxB(1oY@7^qfYn6yIjjYr`3B=ubEn6VY<3E zb-${n-11&+f0Ozbtm{t6QmF&5i`R&`gx94!C5)bGcP-?J)*Zj-<@5HC`GQb0Yz<)R zc6AKR$~x3PzD)_xsntEkYr@F>r%j_Z)attC*|I_X+&b2LRb3W_Sir#wSqyOjl@Vm? zMK27@>EmuRlj}yoC=cn!3S9CTgGF9bL~MV6Mj&<3Hv7jaNoZQ89OI4MlHE9SnT5Mpb@i1cWVdc8@6LufsZcHi{A>o3JZBBlS@a5ZW zLVrpqCPV_^$QRlZ>Xu)iuk}M)@~d@kjbe~;_t{(~{*H57u=bd6rcz-M6lS}i{u zDxcNTegUY`)MU0ELQsidy-tTKORx=|jLUCulhReIxE#$mRk8G>0wsxiNrCCin<;_l z%j3Zrmj?QZ+)=zI6x@(g;9bn)wTmQ?(7i)ng%XG23Cxx_8tXAe3jGt&cYL zF+qv6Oc`?<@ECvPsS?S%ly<87>my{6~TLSoo$yz}Q|jYCEex7L))L-keB6n4JN@kZ1E)V0H@vsUJfw3= zuv;V~na`l9Ah+-nORJ%lB8tCpH9(M#Vs_Yrk;+CP>Y=v02k9AVEvLvqt{v_@603i6 z4Yyu14u|W&Dj2)N^}aLo<=s~EiH=X}Cz<;4-X87)4gfLn!0obd*~sn<)@nRD#)A)IYH?hLR+?%wpJ%1_-P|7H~#+5`X zSH%)INemcxPTpxKQ2FwkD-v~`Ij!m9oQX9PJ5QEh|Zl<;*sBs6cOoL4y|2zz((s-Wm6eYUN%EE$Mv34UE5-{cl$`!PU(k8ZmzL*jMwXyl*tF?kG+ut z#vgY+A#OdfcfRXs)<0!aUB7oM*zEC=Dh^h3H0Prr=A;K|Q#$?o)UM{#V?+A|ZCV6` zleVNllH-`!JUJTD4r3E9$%{kJ@1OVaSUHiKw%Lk;ZQYB(9NC3$jV5^pRN~1#mHG9_ zqXwL?Tb9pq&EhAj1Z{B&UKj07dKcCYJtCosHHZ9FO_JnnVOJ_yu7FgPq7Uz6H1pYk zl@wZ%7CKiYQ1|)~&erIvk#*a*Wpl7^U!Daw5%UF(rK1*4*dA?81X#fXJP!7l=9d~2 zR2O$&AK+2i$HVi{L=!v@_Nh3OcWY|RP(u(>o3IqC-5CsOQhz?EZ*(fIDFX~$XRm`BI$(et3-(aO?BQ!QG18a>$7FZeNqYCEF&)+QL*lwECb^SYC1EXBWL)l?E`PU+ zhP-pSxgg~WvWSpWf30ArPkrNP&U>jGPc1?hV^Sz;+3Pjr>A+F+V-#Lh#Bs16Z<5!M zx}M&Rm)XB{(qk?T!U~SA3cuA56@8XFGzWD#Tf^xe9G>FU)c#Z%y_Yq>YT7^aGbxYG zu|77f@#yJZvPEcniz$br^&SN~pR*tW>(2zy2HRgLYYN4k)wXb+`sy(H5vY{44r59> zW_V@9%w_5^);&?NKgRz|{To=M|FFCPUe75_o<^cKru(ZTV_L36?T!N2YfFXz8Et9b z=SdeXa^}#`lA+XVzeOzs)~ch1AuuSddr`65k`Wwio^R6j0#u1kc`*pJDswZEMG_V8j0LRJ};*6H_CdRRXw13OYG{tHqOGBXv zI=)i6nh{112YVek;r25eq61GsUA)b-2yqXh6tAvf@;jivP(I{iP8n@I{Y3hN(^_KM z*Tf1CSKcwyw|rE9K}^1yMcw<+0s>l_LBeTTIzULj1+vcP+t>m7Zod z*0SlY_+BqdXN6ZM3jtz1H_s#wa-+{ikg1-qh+u!yBb{K<&%LI2eMa?>%Ykm<#!Ysr zrOPhtPS-I-C4B4aQfS;GA*RiZAT2TS;y{t`q!c#n`e7VXZK2P-Mb&AI6bi*tSPZ|A zy5?`)S@=d$nK^Rd4>MyV#jryCYs z43d{-hN)85ifhhA#Tgo|^%P;UDfvt9 z_f5!8X4M&%u?lO8PR}O50`G1VN+>ZP?hBU5>9E3S++wfpKYe3(DtYd@c3)D1$x4X@ zKU(d+(aECK){3<{I*rbgy9$#G^#%M&?{UHRe`l$(@Iq(FWc69e@ z5e@RcV>?*Noe?&Or44~udF8+ZI^Hlt8ILNDNkS(PwG!cd3&NtKB@-Q!kYqVj)cNR~ zSY41`wUaIVyHA%-P%jYSnJeo65*l5e_fgBUS&|-_8gEbmPYCbiR**PdD{><0u4ZOf zL(SG66-cve1jWCPh0OLG}4o(%_&bSmxDpPo< zt7NTPryX(A{m8!ml2`g}iY_)N<4T|=U!8RJE%J)RC|EpD!z)H#B3+2<9U%I5K1fTg zd-vo-H#bz4$|dHmbNZlFjpO0Ye)ib7|Ce7ZEuEj%LC|Gn^qqR|p~M6syYTe}_Rykf zpfA&$mq}l3{DzUMv=cm;GxejJAFj1T&08gt|^~~wY ztWK)lb9JeZ1qqh*rk}*GPL02Q+uo7K*33R+4US^6=Y|`la7+e6R~r54JX}!y*H~JB zPY=}Uj^Y}m{@seLs!q8xdxqVz(aKsgOg5O}qi`OHOx0a5rmw_hUW|H@LXp8iX(%sp ziOCF=2)EosiYRP^kf7Xnv{Ks*-^?U__VNPZ@46GDGk?%wrkihP{kQ~SBQoFvr>~g9 z>mkkC$6e2fGTAX7D~}&PsSl0_PVArd`Bwg_CdT#ET%HMtGUNC~4>)25E)D>qd;#=% z@2>*}l>eDffD4A@=HLIiQ+{t0|9%7@4NJ&?_a{LmwOhuCM~d%@u`w^Ue^hNxj7?5v zcqHZUm>WkKu0A9;eHp2$PyA|L26y5FNNjv`m`WWg5n@dhwK z1d)Pn|HXpmgKICAXKQ<+iboeGY%1AigZzS3KMI*jssqU{eKhrf_7(zQj`wTEINKV? zRdy#*hd2of8?H+}8);i7L8_odgV5%A_wqS`7J3R_weF(F>%=WTZBUtJ*26g$ww-4c$g&AsJf2L+hat2#=(6@~Ot4WMtz;>E;EWXWp0aBwA4BafD~o;>LR` z1OAdetehoG)Bb2J7Ui?oe-zr|sJW({gTU_^LfHj7SB6TnYQtfgqqF?; zX`RFU+CLwXd1@L%2D+>I{m{2F_@!VxtZp>!93ZrpkH++s;-Y7U_y{vMHr)~OjKn2L z@vRg@ReSiWktFXi2F(_MYmrd_lTHzx)R3TLA#X9$9f+jf7)fE>`^r( zw;|3|L$q-oqg{=Fub7?hd2v$4)9;@X@?GZH|W`f-f?7v zNi$TUWx+Zn)BNMM3)iF?ax!o*2#8sz_-9{qV`ZYxN7rnq*DuqDps#ejQJB^1N9x}? z;PzBqy0q7OQ9RmKaP08_d+*!mgTh3&&<7a52}+)#9d@FFk!-SRe)D|7N!(dpLxirE zNo1S-V1!=thlkZR>c&44al7AfZK$%cEO#CqBxaP)eIByK7#!JZNk6PDcZUO+hD=h# znGDN}zyK}_b(iNGhTPvt$vA(BBMtQ70myP5|5D$`?6GAO=DUeu}&p%9(k5oF#xZm;|!wC0UEP4W(yZlWev)CdNC!VUKe zSCJJKCL^H zjBxVcihp&5|GuHmyt=47@d>Q`dUe&$(#~bVnOyXYIJeTPlsGdocz?k2FMr~rb9^MC zmy3k|o)GiN+Hb*v}|Mn$hjDRrn2@ARMH7^qT{19wwd-iL+ z`X86(p5TXvFNE}JSlm~4OlN8Io1MF;c>3ni(O>odIahn!W}cnCeW4B9dHCK6^!&g2 zNcH=y$fDgp*#e?LExcHWak4b! zt4CO=XMl`}zx>qXoj+^ski{C1+!;3z{2`@qm9#&7x-t>igyJs0vJ9#NXc4l5G4q`y zoX0w_VDU}VfJN@tJ+FW_8{|KOV^fIw49U#p7cWF_!lyz)IN}(oDg7R7BemoyNyFmW zz}`+}2t_xSwpYQRdUqovxIC1&!bz3(qnxX~ zbuSj!znhwFJ;zS|AD+$wn(h7n|L*PmcB@ufHHuq`mZJ7*bWf zwXc;FL2K4b35gkPjUYyiAYzo*1hIGjAHDbY|D40YX?x;x($DMle!iZs$J12ibj%11 zy&7Y8_G61PcW&bPlR*y>P|$v!iqC%j9r)tgum4eLPkwsBoje(Z0jlPS2@3ceH1;Lw zrggj*$>1>*6n|WNifuRrV&7zLW|9eX&z^aVh4}hjH%mI${8i2&+lJ2Rg=; zXOn2IRG@?y?-;S4xUK_xihhkD5$GESma1u2(T}~&0Nk!>8s?WosF_Oup{vo>h_}qd z>5d5SVEk===$tc7UF2r$h$L)aXg8EaSkCBb{61HxnL0;PhFzy#FP_SKpEi5-sD8rA zZnI8J^nmD*S@|YP7#}ge+3KvGAOmJlSDN zOF}KA!&Se8_SKN(4Du7TR28mMdSh%DV;br8s!+-<2zW3gzJy%n}QQUl0nv`Ms;kkW7 z7d@-*z?Zl#B9M+RCQy>*T45Z+u1h3OPuk&4l9-~Rk7A#i8Ch@>Tb?SV-eg17cx6TP zJrB9fbS0T?y|fGV8{~W zcSl6_;%|8|zQZTCEFfJ(BVos~ORDoXOnQbnX$>ihqC=JNORU$`FIHO1fYTOhuV`RM z=XgV2C3qh&*k;OY+#o`SVyj}BcaFX_E4Cp@@w}YYQVOL;YeR7;MJs{EGpRMxn)JTc zyEaWQpetESYjvV7AH_$y8>d?y!6;HmQyz6aAM@ruYSjGorX#VVLA5Ce#2^gi*Xs2c z*qi3FZ$ud|sGU2OyDS_yXOAFVI%GqUupxinFd+=1>rFy=!Ie{#*%6zv0)f+tO|OtP zFYHWN6$V|ID3}f@y`@Z{S-A#uF%~rt^s>uv%G4O$xoL34pf)eW>Tb3Lc*=qoQMl&_ z=AWGX!6vgz78K-(@LepMplS-ZSp79)oK?=!iaKn{cQ2DXo|wD8G2TKX+YcR0P$XRy zz6Z7kS9?|U7*^Du{VVitVBl{yDchMd{S}_?*jX8Zij@G0)mooi<;jXRI~qQsd56*s zEMSx9yVQ0U?-@EmF><bQXIcOYGe9GSyvKAc|4)nT$BT2V(bVOjblv-KW$qXn}0h zuq-1Of(tX)2mDtn$Wa8}gYEb`tsu=<4uS{Vd7yv;gJZfLq2$Jez(hir77L{{k{?!G z1~ynj0~u6Vaj3sTFsJ60*E#Feq4DEy-;k#2{Y1<_-kuBRbqP*+DW)vBfo!bR4c2D_}P7@ zaQSfojUwcgtY#8ErEYz@X5Z;j94@#%^cfnQzOa1|UmWh;_KH=?>L2aaU$vz}$E+ox ztfH~~2M=P4r3I{&`c+5Pk}Xu~l+z`~`8KEf#`2nOs^y7r|Jh-Xs7Y+fiqkuHTwG(!N;AOEyzi1HY#v{JT2yq4 z`>_usvT-G?xXiXHF5yTJv;#s{Z2(<%`&-@V>TT~WP|#^5N#EP3;K&z_x%fd#@?XKr z)t=oSGn-H===O4zBA|bep@c0ZA zTtGK?zU_5c+g_n0+ZP+B0C?IVN%QJB;hsBEQy+XWYRSqFD>%NeeHjf}AO^#?JcICF zT@eEM9*C9vx+=ZFA@;x*ruP)xm3kA5J+j;A#=EUvF3()Yqd#o3UAQ)y2>r`ds&fTY zaG~xXk*DTL-Yo2Y8Z$e55rqX(Pb5LDeE*T{bo`fJz6Zpue^RV@ zSS$pr@L3tU7D{tDZxD|fd}YQ;+v$_q?^dXTR>$Zd9(>P;6(>Hvx*+N1@ZRd5Twh80 z2vEnNZd!#iMlZQtM-myI+XLy;|LY*>>bsS;BfSYmjZtOMOs*KDE!j+}(CbL+8z`Gq z0l2^f3L4kzxB3Ma*N^qWRKyC$P|4oyjT}M#u$%4Ih0ffznfhZoR^MGyhDy?wG{35B zQI)!k&-SN4exEu7Hap%{8aV3}y6m8?<>U-h#3}86g-K_9N|sh<6TU_}7y8)c$@Ej! zw~vShQylrTy*iBD;v4qEI-pSA>Z`n+Jn#OZ=J%vL*0-}cGo80Kq&Oml-eW+}sa)C2 zmQnN#@5$luf71Oa2TPAElMKXlRgtttrbo0v!7d~?Gjucj*Wdp$s{oZ5xK1dJd$rYc z<0TAtx0Lkg;NLGo%Qysh-bo%lH&>*RDa9uHdRX+_y&YoE9@ zQeMH;M-E6QOOv^;d=l_Yd{u>-M_0}%&Uy*ETvo;R#?%XTt+GC|9gD@ zPiL}>XSnNzN-`m1VBK(WyA0TrbZ}i^#ndmWw|^z)JzOqkTFsoL-7&+h<$Dap3zYfq zC4n*{)LZa&+BzlA{mYCnEz*?~-fZ6OG3Tw|E9gj(FF``PwvRXB&IH_phPd@7m)O}I<$Q7nRe33{G@81e*Yh)1Y?>c z+RCuY7jE$s_9t1!I0@V>lzNd%sD&i;VHXSNi6iuWX_s0**qJU#B#-lSuWVnvt^VO^ zN!M$!8y@seP^5=H+}R4ot?*UWwe79h)qCRn#nh^2&`C&~6Q4tZ(E|2f`4%J}$YFh) zo=_xOjp;t)&I}CPekMK+sns{LL?&U=S?&q4Rq}(M$OnR+wU^`Oh~3wmCTC&XI^K(K zK2Wl?gShaP2Z8mvvVm^2AleVM%yDV-45qBAr7X?$n0GJKUa|mXp~g=fvy5vZO>Q6U z8t`3^R$X$Vgu#bLvM4c#TJt+}SZb9o4kvi&pd>NYe@I2&;Fb2{gv(#4<5`SqH{x_v zc2|4FZiwwnwmrpH*Oo`a|5hSYHvlVfz_6uzPnVElP!1agCLAxzG7vO$DfDrz4e?@J zARtCP1>V&Exv+j^V*>yxz{R^kJ)S*853y%QE+o9Gs?+x_4BKh#;e(}Atb_<&SYfVb zHszSE(dM)x)afjXf#Z>w$OgdPXIy zvT0Q`1*uR;GX45AY~13s(hQTq8AwZ2UboOG7CYV0l~L;Zj63EI@jh0 zt%F>$es4eb{S*+y3+)rRIw&KhZ9gW`Hz(RKquzzQc;zegYSs;|dfj(!jQ2_VYzHx+ zFU>KaX9IVg-5NEvYph56JH3ygA_nn<=05qFfIV*@!&_l5IxH?;aK~WH|HvlORIt!w z#{MCEl5$#{$X$b|%uGZ_HyL51d5Y{V26?FYpID0tS*s%ZIyUH=W)t!2(+qou?8zLB zX4yWudp=fMP9NTestf0QuUP%~J`(;h4)?GkFRwcE!q+f4A4(W1``p3Sz?`onowXLY z+IfJqFas1m(dLz-#*MMY6hn({nZ18@$dRpcFAi$+vu=jncsIFn5F#|evF2>)iWoXI z+qaW~fdXKaf2_okYv*<;JQ?X2*BQjDNL+Le-^Q~1<#6;;H-{e{I{ zobYdiTN^D)Q}vNo^@Z<-N?mB*`v)~@L6v<5%FLwHO*4bGX6>qmIO?wWNft@9qdBFmI z0_86V{NBMF5Wf#dN=Yj+gJ8ka7u=qGZc1=E1v57l8&FA9OIG-9pNluOBvwkt zjx~he-ttC4b_zZ$g%W7}g2Y|Qx3vP*9Mt~DLA9UAN0P{fBj`xkiR-z%T7u|uTF*tcYih{jZ*pP;9fM`;f5 zWBL586xR=pz92Kg@;a3V7@zV4?3#90heRB>&wBDVRWa`mNsWmfGoLY(Ho292?v|-i zxVoqXaEYYFaB+PF8>LIIYv@wB)Ud#}Om?0f?#+lR^xe=pjzzzf>#a|_+f%kwzUbVK zX*`7(;w)eBuiTm+?p-nNmb`Rioil35--uZ5bV319i}ME6sU^XoT_3Kuk(|4m z6fXMQ6FyYEZ3-Q(3*Mt?PWlza(L$C=<*WKQ!#E}6!i%Y0up$$?L%!Dj6qH|}-dC`! zpyn&-3;H!a){FVQ4#Dn$DycMf#P!1hEC22G1f@5jHGwShs{8&#R;?qA$Y=;wJXGg| z?Bv@LT6Grf4$xfc6C{U8z=b^@9;RyceA&au+Df&?OL=_3xh2eV-REdTNJt25u<`CeR#pdR<~hVUYz!Q3p3l19Zmf*2C?U>hdWPShf3@vexC(@{&{dsSOHLA_aNSO= zrhQc(fLHx3_=T?#tS}MPF|B=nT1WR~*I5;aMpbg9c8K}8x1Q>h<)cPt`dvbYouhxb zA%T`V;<<*iRkq4}TH?2C9~%=(J<-N(D~y*NE| z$*jn?X|F2xGErU*-!H^7YQmSs<+6QVBWHuz)euWuh}D#A0IsGUukO7*S(u9B++z(S zJ6wgH6}4%rT;G54sODwlz4}gsgA~S(ajBq1svtkl(W}a?Sgw0BtJ=FNaHYOl%33-f zW+Z=i<$C7opLOfAi$|d6D^8v()rPLpmpbZ4vp4s<($?>S@+kDrWBP0JwaPxSkJIql zC<;0FpuK}69skc^^{wPdYt>uHHVNASj<5!p!(a>9Gh5atGb=b$`bmyuO4}Mve+5@{ z;9qlNX;Uj~yj7}j$o~7@R{$Cm%(F$8!N@r_@-)|OSUp2uyTOqweo&_Edkz)AOMB>* z3{8wSjNUF2Rf(|B7ksbgpNKfxRC-Bxi7#+N0i z3=te?z_Kt;yUFK&d$E%x8h}2bak7ke(x;tVCvNSZK5n2H{%@qU$a`LlZLL};%^EZ69qq64tSM!}XHjrWn{*Qh^UHvDs@8-cXJ~x^wi0r6K0e2A z@Hp`Xtc64QI=aOjHK<4n&%oA2|24||+TIowOJFSAd0t=gnsqD9nQL*CNxv;4yV~8> zPE_-5>$Sv$0AU|%Ek02@SCad!Oy)0ltnZA?Igfbf!R=mE6|6hY+0PW5ZIy_WIp3Ad zjmh2H4X|;vG4H+E(@6_1T}z6Mho0N$f9S~V53#w8`7Ker8#n-^HB5M*mCQP7+0CQF zVkG0a%Q;Jn-vyzeibG1cy%p2S!-XZi!^wd5PzY zDZ3U;DZM)%^FCZvac5i%wj?n}Y0{p|G3U?G3!P*3l@@`m6}{Ylkvcac9^>K1y?BoU z#WB?X?iJD{R!COvX>TlilGdl7YE|2Q5#0lIPyhOP?}0lHTqi4qC&-Mas(X9GyUJ#c z_S%|hLUW3T2{Jqu$tQ584Vb?t5S7DBsJ0q`w{L1~|5dFCem2yPYrVg(MfA$iuf{Rz z%hOA!>6e!V`JH9W0aUn)kfWK<0fAPhf zucF_{NGpC8>L|gRGt{`^AW&iRAg-&Tx$3atQ~Hk2kt31UJJ9PW?)X8`@x-z0;f}w% zFrIT?!+g-0v-6Yd(`e8|v!+z@zlM*bhf^o8Oi(S;A46HdkR)?r+hU-0EZPRxpE}uA ziBtn^T*&MdD0cHaRpZ|F#;-sHF$}&`W~c>;MzjjhhgG1#^(SkS4rS}EjJC1J;9y9e zvTP?yIwI>Io%;fUxwjcd?^g=%tJv^AOp;mwJ*d~|{4`=Y)N5TK58v7=MVP}I1Xr3~ zcFee~V-W%uHqMCkxEZRM?M=2XUiZqWsSA^Rrq6R}S?#J7Z*p*An2!7^|4ltlVee)2 z{_Z5SKF!Q1EcnWNn9b`BvvsuC7%2lFKH_23j4Az+qRKtly+qeS(#;3Dhe7GkTMbNV zM*5OLT-ckcfq0F5KOjt5=i79t%jVj7N<%b<5W%LNIFu8}AxJ1`bB)uB(>@R`QbE-D zlNr6=8)cZ?^SA?>%+TT9cs}BI@ z=F-6IXHrinO;D>>NgQPjTqNZ0Z?zn=K5~0^7?^x~)2y!Sy}@I>iMghE=AO`&=(W@U z)?``u5_{6N!;wC`-(tj;f{(Yjno?_e5 zx)C9u1FN!X)KMO1=?;nky{VYesB51XYkrWZl{(A3B$=TtCBkib?ab19iI8H$7sFEE9P?`p-Xanuw3KS+axXMWfaz zjbi3#5O_-=ZO0`z%LR8R0~TaqLS728W+ql77aMZi@OS5z4=TUxwx=UvSw%_CZXWb& zWex>$I~QZ>qh)Kh;mnGd}f|ThsJjEcVH|zB%|s0RKDl zXU!z*=K-V)!S6}ay$#JXdXka*)|YNb`O~k`K9vA`YJ^G4663?X%|f=7Df2l@JnR%C z-|A|w&t-?1V1{1|4we#-DDRiUBA zZJ2qq=RDo#Sm)H<4jn?VtFyz>0V>%Whv68BQjk#2wW?rgp^KHw_>BP%`Z8(TV@9<& zY}>EknlGqlKA!xgbw_0{Oz^|(^WDraX*JpJ81~Q)8oIZPfJc+~lKoni^r_KfldMEw z3Jf&i%QGQw{DHsJ)O5gMe#v)c@hGP9*U1leLJNuqUJhq>N8bV|%mYZO;D6pB-wrG3 zhEiNwDcLU1DlQaCpJ~qz>KaJ1a@r7ZaaGyZnKZtbB$Gh|L+hNQS!#i(TLKI%ZnZYWyKU7aiBT*0f7jdaW7latfg)JL~7Pv|w*Q?_Kij%a4 znv7!Nr7P)&{)ZgPvO}T8fafuksNcQTx^Z-;35`1=Zqj(xq82k4o3_E^A1$506oPdp zr)UfC{rmw3UCf)>N;$GBsm#Aonn@WLIl6WmT=F!6d=)|7-WKWJBCqZ8Vh<8z-DWx*UFV$LwCtSPZJQYtcwboQm>wi2w5QdhZ%HQA~hIg-r9V)Q z@ZC#$kRvlL@R}Cd#Duj@=>A|colx%a`-x6HvC*E8Q-A6+Y%V><-kD&3cb=sc2NOs6 zi7Lc1*#)e5G{P~FTxzBNMtOaSk}9s#oG@INk}{bQ3^p@x(eL5&Sq0u(SGs9qwEoQ| z6`2b8M!zcX)1AoU7WprsPiPFXX@OL*Dwnt8v`XS6f+9X$>U|0of*qtf_ybeSe;69_ zbES#T?z>?hIqAtcvWC|r=-Y^UcO-@5b%7wd=DFIH`IDL0A15N*&r#X`r@Wq&#h-(o z6E4=@Kfk-i#@!RP-O6=9ukoiD2Kfz1$aq4uzj|A&VPr*QByC2Hdh5S02huD%&>p>> zCUR|2OGN&b%>}oxYl5lT9zb+sYDR&W%XpoV)U`U2gjfF%@2UVSLN|QMn?|*sOE(^u za%@TGZ+#%5=e776*}nd*j3>vlrKf*_UNlc`#yMq&EsgFo`)Svw?w72j`Zf{OfS@_| zaiUtL2ji^L&H4iBUA@w4E~qNBT~=RnD2N8P{=w$a-PB=lBd|a;=$ifDzVs`6JcoRJ zWTdIsy{I5yJ<46rq_Jg|8M{fXHR77#II1*TxGRGWUVgW-eRqOjd3Wzy_XlbhrA%Uz zC47ryb`YagpK-XBmBq|7;~zYVtQ@v~Yh3+WD)sGA>##J22jidw0IO^)J652!T?nnSw{z!3TRr6c2hy|p;JCKNgGMyp?|ngN zxrs3x{|`3141e;`EO!k1mFD##7w1xbT ze0PI;W>3x{ohydylg4;2 z`yYxvXF4^|4^A^0oX8$@6Q543i0vLOU*=7q3K*VG6b^_%V#G#kwtBU*VCzT}lRt9V zx&c+y(c=DYt&eWltK$>ge&i%3ja{ZG5*%8_LbC_~>Qd_Jp%~+Gce8%S2ggWNd6JUuiAQp@gRsqG`se0;?T1wZ4W$HcqNO5b7Rsnw@vO>hkIWq$&t+yw``~0R zJek(gZXAz4I+J|ZL~d?j03B&C*kPj2RXvOP%KA*tqYhmVzX&yPT>}u)f+hVf6M)6d zz~97+EEKErI{meh#SHi_zm~>u8gZrp)6|YUu2}U44`CMTR+vhxkWq2`-}h@@u4REV zjZppC3)8zVcevk|S*Lx#_1%hc_-DJ7D%s?h!!K!vGl}nzneq#K;7qTOxD=*@aLuLF z2w%UJjRuC(`}Z`3Ij+e4lK>E^+1OeS4$e)(xA0Mee%j9)D6i%B_7AIck!nqr7z0UP zc2I(Vr$NxPG>4j#j%(JZ<|gUz{j-nK3SU@CXjCA(C%}0gR7;gGp^2H1hIsP4QZ*vJ zDiA%#7Z6@C`VH%J{SN74yGt5N>r-T8OjDo6tZXjuIDD#_onVR9~{dxN(~}_ERl|77I-O<9>dGi zrJz7`_=hp_n>^)KiI~jj@m(Hqis=rmJWQg>ma)F)|FJP>`DZl-DMkhM-Fg%JAw?`H zGAio}w;Jy@@k8fM^pVVoDyB7B+9%AK9~$gh4BGR~w>IyA1x3c+aE0hvHbclud5}zC*1pbHZ zQIq-~R(9awE8SaLUqUE@SHipL%f%dm5^KTvjoa|oz zeadhjM>s5BGH3On4hFg=%WomL9SB95D@0HJuKgi@35gJS{%Qc*9ZUc zD6Phs{JA8Ks-#?106uMYp_zEbW~YHK0u%!G8;~ouzK^Yz;N0HBB4J#v>d$D*(5$g3 z*0uV>iP^5$Gr1>{!pX>i4Y+<V z7iHVYO8sMwYh3@V!4og@&QaiAHN7*TnAX0j*&e;?0h*|AUyAiEpMf^Q%OqgAgA>ZA zN0HE)U8R?~6DOmolV0Pe4gz!-Y*YWY!w$UUzg14UCZNCp<164){g)LLv1mjLpH+*FYPRvdb^{*mDh<(|kNCkCj0 z)uumyj_zOA4iJ;4MyX*PggFw$4UW1Zo46WpC^0s96#6^7=#$=(jrOL3SfzV(A8Eot z{)~HtFE)#dJ`0!7^+WYlz9^MoKzoE}i{tsGOq(xvLmu5$e+ct_B4j=4QuQJd%|~(A z*Q3XKjSsqi8oF_2DtO&DF%PbA?F1RWn1x`b@9_&zJ<*F zybsuY=TTRu71etL3p;!1qyUV-m9R$&#HIDJo8Z97Bc|MyEiI?{H~ki6cXzP?-Cpm3 zT+xM=kVYtjAOfB29;wlsSWdtfgvDzwGY?>O`jIT6MOeD-ikc}@K%;!UFy+SM`ALNw zRR!8r8q?3mxkApI(v80nFMJ!~AvGCTtMoZbBzrZUQuSRHur(-8+pqgRL#cFWUhzqq zPNX(|EG@lE4o%>v6?p7>);WHhNEPg0Y{WQx_1S;G&VIN!lpEk3l(~U}>1^RenV=%R zfMQpH%THqzF?|@qi@ARL5npNH&I$JhcS(YE=TrF=a=511N9&d4^f#`gh>`wn<&FC* zn;r{Tld`%duVSSjl3#m$a2BPo@wxzSMjThxd1Pt`u-e1uZm>ocp2X?&xhB*vHO=^b zOUWn+^wxmz=b_=y9#DU1Q*#tmWvge`h6Voi7Y*MkI}?HuX1Y|7IQpK&*e2UnLSAX{ zY^Y#Kf3Jn~yB}@?8sXpwIGrOWYxu=Myx_`08X1|@%7@^e@O0DNK8}6l?`hD7SlJ43 z0dyqq@7259Ydz)&@#%@BTW+LDOKuIy-DUSgzI=TT5M|FWk6heg0L5&|l{0U?pcAjs z4@bk`)A|6Wd~DJ~O<#W^6)yU4fso(7jXSdv>HbIi=U}~+Z{q*fk1mk+y+wEh@mkj?6A{9)X4((6fkz@$!${JPA z0M<9)5vA-a^C2nP7a$i5==50ZkH)fAprbCuvYhsWZ_tA@_Hr^oEuR@+TTSs**(c8DRU zm_SS7UfoS8D~&+fryP;nY0d@hIo~wq@TFJR3=P?S)%OOdWdnBC0j%k5)RHXKdTXqF zbR8@bDY{mOaIDdHC6Cr~sH|A6N+&inwztl%*OQ(ve|66q^cJ-$hTx!;b8cM{a z9sS0+Oub}v;o-OTIwco0axa$Si{%~x%zlf!nLY{v``Vk-wNmE-BW6J{k9w;nij*tN zl~eB`WM7vyx&&jvK|@(QM`7)qJL0oKVFIxMF0f~F%M9}Y48N{VbHZP~Ln5CYf+;GS z2LVqBUA66A#J%LD14x?cIGrTn$+LA_)>F00BE|rf-PG5uGQaikme{sj?dHX>-bh#4 z^#fTS@S&aJ$3Y?-Zf$zXcWFz1;^5wIn7&R+k+h6dtxumj-dvQUVu@xi%wx|Sjvf?| zUu`U6IImZ323l_*gwrF5UzXVGcxwf)wY5eG%{!r~GzMU0Lt%ILQwi#Vy-^VICN|Q? zjS;+ixu}H`B@|biDvoxqg>N!9d#;Mg430Y2O*i@&I%oC|mzPy##f7Cir8I9$&Z^^p zUc|aX;7l2XtXv?V&Je<3%FvTvl~GtnF17hJbLRhVCZ0D zYX<3!j6=Wdy(@KV&a&*^hMjg|z*Od0LV-UyTH^!(ouE2rUj%xM?+GBnxxOG?2?bXM?M7({FXlL1iI2 zJQuNy5&Tr|B}PR#&E5gnwIKeifzn3t;r?{X*5u5Da*)mD-Zk3Scgw?!5TRnLgP#RW4W~?R)e;hZk;Px?CA4e;*EH9oYfv zo?~jV6~*$V1}$TtCQqh=-w(EkJ9I{6P-c9(O1cN4dgy*}S{~W(M#Q59yURNw{;zjZ;>Qr~eslQe6Ay=koc3Y<{2E_Vm|;cs&ppdI5On#{`DMJ2l6~W=`L<|& zQkZ#E*O+xCNz7$}sN?D~CMzQ|M_ylr*a%Jk5zw5gV%Zu|=hG5?6Q30LtSoO$g zV&_v#y1(1V+kgc#LqpH>Z+Ll4LH0$iuPwh?ZE2ZSp9V7Pzmk2hcHB#M<+Cx-h!{A! zi~`o$miByPH++Ze-7ng*Aq$I6I&O-uJ@&AT()^<`V+t5AKVj2oVp6i7J~GC=LyVOWxiY(4KOzjK|$+5XB6-nPFbQ z{f=$uup(Jj$tGCL`d0N9@=iA82V2lSU-@1dcx1^#J$l;Ky(Y*NWa$Ais!+J&l@vAf z=YyVa2D6^wR-laG^wSF!HY=%|1mCvkl;nb36SGKmfL6fP>Y60g!&^wrJ$pM?3A9{c?{U zymDcCd}h^UL0@r2oG~oZ_7>NgJpO| zCti5v%Jex*7$?c<=?Ep}~EIXq6s9SN~VXBh2L&2FFN2u@}=(RbSr3n6ZSAXShgf>dm@5~ucVL+=7%NL6(=Y%}SEI*+(i9t)jkh$(cf@W_GS zX@5gckL_dgAZh=ZBl?tj8ZHo?7t#vu$-6UWOge`g0XVD&7@}DM>>y`f#!F=*dt#en zJUvO*ODY>ywmg$NwU-|V8j;j)scVR6sH%5t$j~0#ujY2-nX-~A-SXMFa=9@ncCc>% zTbjJ*b4%SI4<>MnC-jcX?i<~j;yiVsYpHOT0<)GE_E9rYZar$!l@9hFs+}I`-% zKj#&N%$5DEQv|=Ex%LoMo=i}WSxfo>(<9TVFhml_Z}GIo^faM#4s-){+`VaW$CFQ% zo77MZJJwXyTC}?KA@2lu3MnR>v=Tt;Q+7I$(o=)jbviomIQwd+SY=|3ZxJZE*uzWF&`i_#zVu3tC;)cS3RR;)ufUH zRx`@y+!U)N-O0yp>TzK<1wH)$#A#^1$ub4kog`4I;un~yxwb(->9jOA-IKcn=Dd9H zx;OD1#+O5UG-Ro^(2r&bxDc|p2x@YD8~o{(ZWDLL6fG-$?;~8-w&vib6l`UtN4Lc$ z>_dwq+hePaq`}`73b*B9QIxhHY(uKS2cl!Ny@ZRuvO}nP}>Gl$kn2{*x&IdHtJ5L0tETzJXXem%c zGU&jD#(INrg0OHQoJoPPX@Y6g3V#$c2i|zPTra$z6j`y`EuWW z^_dQ5Ub+7cUv8=;3C(jXsRU4%o?~13wvDOi8>o>29Q;8!LXW{b@3Z2PvWq9Nb;I8;}HF&#@7lquzHD$G2~e z9hnW52j+vNp`+|1up>&YUJ~0?{Q?r}dloLva(SD0Q74W1Zc+iat;q!vnr-uFzG&iW)6lgkaR|;9dl{kh<+fjOka0p zW(*oels_MI8LED#OG^w!ZY?*Y)1oa7F0yC^$kpK0^@2m`P0nJYeczXApLS}#96lR$ z(@{rURCMbbcJLah$m<44bR|1%ph9lAW*3Tqh!6_?VWp)arNi(`W^i$GqJ~3K}U{c-c&q( z8T*1(CpU89q4i1Q|e-ke5}r<+BBp~^koiN zX0Gm0mKK}7PHjyuJ8$CN_f9XfVzdI%ce(>csEn%{=Jj!|U+-ML7)Bz9)NhaL%bV6* z$E80yn%@l-)eB%@3A+!n@C0cfX!^|D#SRM}Lkn$}G$gyPngV_y7zbcvtv`LM^AKsy z;xxaR(xZ935_FHf_0V!MluM_$IQXk~uzZJ}tp#1aCFd$n#N6_#*{2Fu+*L%F@GUQM z$O>e)KDC?PLVh0D<8|2R(t|E5Uvopa)w`vo0EF<7=_(mR_7nNAwXJHU1q}bRMNcZ`{rU|wA?X*l)L#RXI z`pAvr8hoB!BgEd^!T(El`<;gp<~jx%m_Dbs&ds{rw{U7h)Y3+evQE7;h=fv%^-`>V z<6u|D17~7D2)!ZNaiMG9>~KU$O?EW8q;{cZ%niGoK0Ccj@LPBi*AtNQ*q*8Rts z@KNi+*Y%HZ;VKC*_IBTGNax%}Xl*(J2cNr|9xB(5H(%v%sBuRt+YbzY*%KINHZzq{ zm~{#U&*!%uc=#)lwHMdL;(TXI=j-B(flBcLKQ|qE@6BFOLG*Y1bNCf`^_wbJr}Q$G zL8QZU6Jrwz$30C6BSjJjr~H-zb6gyhlkGWh`owq?XF8i)Vr3Us-4b>qbuqM&CZz73 zwHY+-S=)J7OUsUm(w5|$PFN5U63#)T+-3v1_!F7B={NADKb4UjyNydZ9 zC!vs{Xeh~zP8t1F*3ym_m{%S%#ySalGj^QQ=$sqrRb5V;>m?DfY5nZZs8d!tAEPY5 ze+jy{`7pq$eH?8d*YtHPzN3%SfK+`&xrgnEhiv z1}3ECn6|s9`;f&EMpT3->2;Jm7yCA0QnCJO)`L~q#k|5^%A3F4tfUFL{!I67q2G|j zvo~h1v~YPsx#+&M#cHhd- zI!5o@UNAy(pPTt<%D0rA?-5R*<~fVgRxXR0s?k(k`tW%gfFK2L2aTIa(VcKkNh-W}Ngm7% zu=jbLmXN^RL^Xv+hBko_CNw@s_e7zdtBGJ&`r0IgsrrClJwyn^htPwXz4Z_t0tsJT zJ?^*cKHpA`KUlUO^_x=;Bp+PdR|>6jpzP@X=5x1yF>azJzl@CK({sK(;=G}|GS~~U z*9Djs}Zd# z@fLsol&0BEneP{rD^Hj%8LM9i!yvONYZN`b;8WvriyPo~%BgQ@g2WvSg%s+B+I?2V zVmFwp_Jy@pGddo|A!KuT&!SZDev-9v!PJ=%blQvi=RIGugikS_j|_FjT%ODCl7A8T zW@g#le9+dhH|b1nQ5w3@;Oh0IcTKMG1Y2MBqsWAtSQlLU7(pDMYi|`tHZ26hA1`sm zo5`3yJy|0DIaUFV{PI88LZ(WjpL#5y^g_`0Ja)QC?5Udura1AMVoEvRw^>S@M5JtS zrm7!g6lem>E%+dxKJ?P)hW!T9kB{83?eNJ}y@hk&+q82p__OufW`RFN?IgjrMwI;E zzBOS+c!lQS2rS8vnv`rquCqtNoI8j~^WO0!t1FNmO+AxN;K*hR;Q5%6xEp#Ryux>X zuw4U;#FWaNwn_0vNxTIae9kzWnQ>vnhOwajVwL?AUlWf z!LJXESzl}Ert`)K)zcv6#Y8s^#}KeK9OY?N?v`wGn*`4=zY2@;jBKTaNjPl4yl3@~ z@SX-*1iKgSubn*|yJb)%Qnj+)naykCq;#jMLlJrB82_P#{;p{xghQ=6%L56FN*_=x zr~C8k*>^F8c^P-LG|f{L`rkqlxn5kkzdV%C6yK-Obd9>~4x-e#CmMaBR75mhbO2fh zR6^9qR7mB{8gQH`>fD~NP>Xu|amLKl0v1`88XD11o#GvEY)SPj$oaixtLeNp($xbi z$b6`sb8+qX60(P)_=8O^GX;v@u20=hluWN`mJoa+QjcY-?2DU=mY20hxi|v7ccWU)t9Sf~D||uh z?I(swBd0bfxSG$RS^zXt1EZA7L_<0!LguQ8Xhue=it!J&-q-V}f!L83C8}6Y=l+69 ze>;M`Yg^CdMbo(Ye44|QAp6ql1@$G1D(2bLVD8HNphH?FKgV z#k`|AV|d7OfpnWMg^n-@#3V;JKRXz+Bxqb25JD|jh%kCt_5Cli-bh_nsIxb68@}o7 zzqoJHL2o0q@8!`Ix&ILTZyb8!8h;xwbmwz)33&32Wk8qJ^`S#!H z_$@VaEjGXDeY~Fd3F1$P+S$XER=-#4^t<|@h{Ip<&_A-ZTuXalt z=Jy?xmrWk8uTx)*rn{c=X1c{5-Fd|F+65wFe8*w?IxPvO=KUJSj(A#FD&mCs_QEH~8=5dS-+-sh+3&&f z!n!Iatt8&q{FcLADtx_NI7xQfzeK}UP~+Z7t^MUPJKHYu4|F5_2jEW-b+AYk^7)Mbs0G|vdM1qP)_EqskRMZiXAgzO_fMh`=wxfWG zh>8Tsiin7SNY0KDL?kFlauP;DBSEs@-TPEE`hM%)Z{2l&+_P49Rh_WU-p_u{KBq!= z-Mt*%GQ;eEyu9DOi1yj#)@8wIx81fK=*sW>&DWkkY1FT~FgkNwT=<`%;&~y<`QX~S z%V+aTaLwrVEH}=w5`k>^l~&!h`mHzv%z^lib5&(SOp`B}N5FR*F_fW=g-lf5J&GR($eIN!6a`?S}t66z(bc+qN}7 z_cLd_qFUM^K)UXk0ZiZbPdo9x&b_m8%jf8XoV_;iGKlpyNmYWIY@M#`tndvFQu1}V zqf{MZ*6BWdU?|t2E$-Xc#?i8%Wc9Ye^HX{DCdEZTV zKzgV1juA;mzYvYw#@M8h3Wcc6;p-yanOBO|ycrL9pmg?YdU}qcb-L-oZTIgV{XL^4 z_*qOxu4YSByV+PXpC;cP_)C+Anb*&qOH&VplX-ZUgMEk|{N#nD00<`Lt^)4D9g>&0 zrPd2B*Ia27WT~BJVPX8Sl6^|S&DgcpJE_Da!1Tbq#PR5^CA<%UUli=y)L~}cRXuQO z{OAGaibZNy$~0roa3kR^3V?!hKz0m%NGso_{UB5dJP+Uh(i;%U7!$n^ohp z-PM|3!fQFZYlCmwgcQB8Z`iNot^V45S;~&BWytlU7J2w(!>7#eR7fi;QE#AeSh_E? zuL;0exFyAA(}4k_^ywnz{*|dCuZ-{Av8yrO;cBWlZj_;&**sd6lC-KQ$|8OC<^|H_ zBT93{=Zfpg)+Fr-6Mxuy$-DmF36fo(jbC?c*_~#c9<_dJ;TD7Cl=mN{!V6PsMz5YK zSml~3>EfX4F4FPtrf5@?NO->A-&@s-ltMk1+A8Yp_v_R7TS;QDU1dqjt>N~Ai(5u6 zO0QXV%^@{upZSR8>G1yAQW5=}zfRTW$7<%l(-LZ@?X;@P&Fo$7?{Kh?Fc3(~HZn2t z8WL7>`gY;gmn~luyN2{%h3HPg8|wJ-BoE&>e52`Z)7>4h^S1Lm9G>9|lyiG2(E=$l?d)U~$uf|mB zz_!05O8zLW@$y|!@jB8#mhX9POS89~{k3DKdYhY6cwb!@x!QchVQ_5D%;hnU+*BSd zOw{QYyPC73`CyuH{yYEJMLjJo#kIn}w6tx=6y}T8IiqbH_~O`olg%aFpG}_MI`ij| zqPru1jyx@!I2+bvF0W{O(IzEOUC_V2T3fc54f+~x3u_74IxFf+aJ9XS&RaN5k*hUU ze=W`zY*l9)ysY2zv2&P5QLAmqJ)2X$N8jfr>Zb3Ck`akAI9_M==ILUoy)|C^2U+`G z?T>wDx7Z6_8}nd?O6@=Jj|An7DOcwz>|F5g*LswVVbmR4*MGZeR@bL0lqo70@88GL z_|R+S@A=_Eimq1edAselbCfq}8`Lu@pPILMiGJF$mM*Kaz z>P$kDypM;KxbZoTSgWP~E$OX+f^uil^atZ-j`GIZ3=7sKoe#eKu|BnvpTxu#Sjl<&Ud=S`)SA|t3;z@G zx8mssed|Z9M0OllE-tiCeSz|E<>Jw88 z-8v*_$oUb_Sf-(P`7_F zgX}=ew!KIWJo)SbCRc zcWu)sTru1G1^R{y!OB6mua!Cfbv{*QJm2}e-$+PF#EO!D^DT+mVJzzwAk|p=7O4r! zI0;564`2_k(851Zf`N*!n`Rth1fv&%JAAMLUL3~~p34=>lg91@c|N|^lhXGlwxD>` z@A~l~BKQ2NPdL6(IL(BfWJzar24^k*_iIZig8^lU455+R5Mb`M^tScXLH?~hGcCCL(jQcQ3m4@y47x`C47Ye#cg^12mX0K zw#&4(&*t04=Y0>}?~&}d>gXnL=Ipa9<8obbeKz*Kk4R~By=vuq8{I5fmPDF^8e2KB`N_UP#hrYznN5F}zcRO;aY{Djw%^qw zb6$5H*=_vt_m0!2cV~s}`24W&ET6{m4F#Vc`b582YaSkbixW_z7_@PtPRykDg{tMJ zC3Vy^mNvwXrLKxmm;2x;-lI3TxWDr|6(#%C(l4vFpl+$1^OG|t&O2XL^y}zuGRpaC zTfC>e@?CrmAFg+liSOZSYK{jV5K4sV2jxOw>mHEa!0VW;>N9vU3$H9fhu#zv$ELNCjx`&axmj1xj_Gm-5UmuKGif+@)?d=ei zh`rv=y2B@t{kv^J%b3S1%>@P-%lfSL3VN=oc=~>!@fnUs$%VL0cG|IhI*N-~s>u=8 z?Tgo}T#+)AeeQg5J!jSY;9pEcUo^`3w|!gxRabAm^GSo@Tii4}1{`IT zqL;@M?lm;-ZZXcby|io$ephDlRAgbZ*OtATIimK`*T3$Ykl|dj82!UH?oG&-&Cl#N zU;Aoz+%d9t#6(uY_z3HUTI9rIIkSwhq{MM2f19ZCo5Flga@Xfts$F(TUA(wvsfhAT zIu?M-DeBfn)jU<1toe!ucp~NC(VOt3hJokJldn3Af6p%S>K#^^9+C429(O4DuP=&K zt%?^seZM`p+^DAAD|<&=Y+Gk%@o%h;+9H}O-JJyFRd@Xp7MxMAmL;ojEF)v*!o;FW zJS9gRLsYfYN^_%=|F-CrtkJT5?KIRNn)|W$v$3iQ{74}7`pZu`ZwJJ~>`fyd-^iH^ zX%*l93ZLGk7p8dm_-pC>otK}V(&$wDP9;9w=&InIzu9~JhI6038p)1WNSXw0>GC=7 zo!W5nJM}v3J9TBr?0fN-UN0S#OM2dCe@sil#puoHBM+N(|D4=#Zsg%uyJa|sue+vp zsjT#QzR4oq*s&+o#%dXhDzc)QT6XUx1-wLyYj;0dSlnS1AE}Lv7d*W+g2_7 zrDMo>?J5uT4JMaltn95!UcY)%_##bqeatachbMg<+MoApt0u@e@>a)naKzm4{^lf?%*Qc-Me9nBGHLUw3^H}3i z$&JpLo28`Q-|8>ge^VJR@4+QKI0=Afs6iW;BVewW&Kk>pp)P(V%KWKp$uF#TWg&&W zLtD;zIB3-lDegC}Z&ir?2LJN5@s^!%_;17a;)`Fn-oGmnzf8NYA&Onl{#DXqRXE?1 zRMlQptMiWvXAW%#YE6VDDMYgF@Q!|a6n=Jrgzkmwg`Z3YLP|%!QyJrKjn%EJhtG1V zEZ78|rrBO~jC$$W4>SZzU(miv?o0?WOhShPluV$yUlfb^WB#GxvA%mvwxQd3u}LN&xy~=UmDjI zxkG!?h_#v*zq;2yZR2xw-h6i2$?uSoy~yvL`P?@TcivGp;CIZM4*!h5F+g}@+wif~ z!$q2VjwBVBANtGE`IJ@?Z;S5L>WVoi=D5K>UAibe!*hE>ve{scTvD~~CLP{Xt!3fM!uab?`(*geDl%J< z%Ch`0p;|5HU^7`(6SvgPNl$*IUbUE(hgykd5-<6`Nq7jKnb*mG0wIC$#{oF=&z1R? zg?Zr7hokW4X1jCLt>!sfcAs#QOwEh%H2$Vxu05W*LdP-t#KekpnS1doi@xS>7R%7} z(-NFhw6)l#12R)63kc+%#uJUI&H@&t+a{~0=$C}Qp`!#Od-S<=55Z`xd zu-569l7(mgF4R1Ek=IkbaDD6!)iqy->a%A?z4|yPF(FkFpuTQRT39Pz*V^-c)R-u= z`3KjV9aBxRS(fiw-hHJY&eF!Q-udch!=WPUFBQqLSN08ezKM!SYkHu2R47$NbkET0 zUn+&;E!X_l&_2B81_WIY)0WiSBjc6(Hu_-XgyCpJ)7$e^%Wv*WdZsNY_D8)!P(Z?Q z&W(#FlY8q@^Q(o6EGM#R#`2F_q^{Z#d%Qb0Ix=?p{cTq5x0e4gywpE(t3z6?)2pYa z2KpP=FGK&n@?d1%;$NP&>@#ok5bs?3U+1FYPAC>FcQ<(GzDG;Ybm&%dS~dT_Ha|N$ zDf%hJa6Ahx$gOm7^cq`fCzF)jX`&Ef@OMqzF~v<=CsMOB*R_|Z<+o}c{d0AutZ|Iw zg3;&i;QUn?NDUwSbB2W{nZLMP%cff2y~Y<&oVoq$*qh+xPRr;-%%bi?!dl=j^RB>bt%0vAk26j#J}` zw(=&6v!-vNE5KN=+`?_vr-#{dNYsyoyD4O@4D*gk%4>i^X%oM zj%aB;dzYhd^skU}2Nf?J@0v`tyLha~B{@HAwt!%~?(&JFCK77T7ge38@y>c;Gr{WZ zGfh^vGLW{>yZzSVn9Y_>fuo(A^ZpxK@A$WR?03+8X1DCol1=GJHcvQmvli5)!n+j; zM-nPohDnw8?RIhlC8bRNJ?M-fs);@&1x4X{2CQ?)a|uEaQ1Ot&RG#)>f!fuD`o*t| zHLax%P5z$fS+o0g-ndzcP44riXLX$$3td>7$DX~A3^bV4GUnymY!ESU>eIv{G2gu(GFc?)?32-G$_~SY&kK)-#z6ZkS)u-d8o!q=XD&kw^+)jfyPf}COp{``>B`sU+x8~s14 zu7_3aylYzB;5W1?CT(S2v(E9szPzVu2b0I`E(xpO$zFWzQi$C!YsEyGx>irE>y`N2+4B4_)8w>_)Y!ass0*Dp}-PtCV^mg7+x-J){F0?%yPx?=5j^~h){y&Bvg@4q)n{cNU1vBj=+>FQ?%o!d`Tj_J=UGvoxh8~$}`7VlBRk$ak&BllxXl7jnNzO-{{2enzg z_Oa*dCCAepqE_~h&=d*QdX!Ak$TKH0yS`98m1?Hy_K&!cQ@{qo5Cr{Ql?R0Nx~e|wZl!Aeq@8%JQm z#&bQqG^5PDHwVOrl>|K*;C`opx~2L=g6KWxOMUb1-55(-R$J%ZQ?)=qTZ+GBo9re2 z)vpbdB9_#~R+(eGUwJ>b>tkRTck+Jh5K)=KNjW*|o-| ziSWz*Av;#I!9S-tT3c1Wy{*wRz9BPidod#HdUowl*OvuR`F8$ZDmUK`j5^Jf*YHy> z>|MLj_L)WFjkV`&Q;l=)h3wNFHGJpy@V#cFORZeq!4T19#l9oq0^UOb{F?hK2~u2BL%m%>r6{)))k(!=UvPZD zsf_+pcGuS3d(T#HK$u`zNv>)tBV06WR4J^64=#cW=Dh0*4A&<@nv z(@zuoY(I>9j<7DLM)+>~Qn_k@SzOs!?F&9>bwjK3P2;M3J!G}}jGLX>Q&ub3cbR_Z z&$u}OWUP7B zh1>LU5vF)@O|GgTM$^|*r7Yv*qyFChqbEZ=-)Wzl__S}(w)~>zlQ`#+kCAcqVmDbG zf;ENGBIP}qs^cSX5!^iHskK1qA z6Lem&+0EwSnrzuW#w8N>?~`N-JP9C&^SxYo$$j$RM|EHGs$%Z`rQ zEOXi#7r()yyvX9JPxfKSEx&eE8yz&3@mUhkF{{WV#x_;@@G^Mag`KLnMZDh8v2;7Q zT5Y1=eltCNdw6~JWy`F|XNCHCN?Ur?eG?IXlkmJ>;frgKya?>QJqV4g%hcvh%i(GHaz;?z1ewzq01I2$cKog034yPn+sv|~fDrrLwSjcU#> z@7PwGxo^9a9yW2%)!ERdh}YrmkaoXFZ2i?s_F6h-8GBVs95`h%0nM!^4wiXNvdlE* zE1AkphRsa%HLa`_Yt(lwDtjDRb*$cf|GMSN!*wqh@0yt!uzIf_I%?vX-7) zUi`wCBT+F2r4D;l9o+q$vRG0dm40P=;Hcbf&IwLOMgN*_<+T&L9mk&UO{i^oV=r!2 zH-2&D=0%09?NW^a=cBYOwMBCBQtt|=`c7?EdY(IM)^}#IR`ca# zYGxulZxK>GarI!AgF46BflsfaCcxFSDWe!I&BIs5IFK{Jv`R_Oetkz-eJzRk<87F9JQhPuH{w*@3cIZX#JrU`&i+CVbuGq z-TCX9fBmCYly&TNRJv7mdCYG^(TC5q?K^u}(j<~*&VARYlpiAB%&%t!o$Yr zEpO>cwmq5kv^GP2TtSgjog8kni#4avt;V<{$;c<8*E*#tV&$x~F5@9<@orIpis;2J z!{XFW+0>h9?teD9Lt0NE`KX=PDlzjxNt+W*h|HD~F+Ayz5ue9jI)?1}L z9T79%6wM2z{~H9>6jM)PFeJpYe`=w7+~=$XaNkL2G*5Ngkk=9ylYOAo{;WJs;N!MU zGOyGFqEqfi!LJb$Z0z;i$7G#H4QuCiy*EpLRxr_4^PL(~5S(qcJPsTfu}Y>`%`3oi zgQ>q+dKM>n?^26H-%qV0G2MF}y_;Q(gvOwP$eGE%WG zp2Yg@k2Wj3JM44q$dF%m&iJdK5*PrlT@;pO1+2GIINCI*yWi{IueH-T!Ex{1_u70} z5%vp*(^31U>b{RPcTH^jrSb~$&wRD)9M2X^+3s(Y|F*XE^8U>+9a^HYZQoLjAKp;A zzwCa(DbKUgDF#x71py|tVvcPOpSRYUg!gh12LiK_bQVQ;2kUjXrPSV6IVSwHplY`6 z1G}nbGnT8~%rmm>D;@MSYm0RqPZTHIl<4yftbpf(D-N%Fs}t9=ZDeghLeI$fq{^W0 zX{9YD`}`jqG5MEh%{wdIVuLHrQYM0%q&l|@n+i@3$@=}k#@rL>0zLua(2f1(GMq_tv_E zS+`c?3>}CZ$a5>#^hq} z@SWOxesZb!gBOaqigPEwoXz=8`3#v&vRL8YsdM8?M+ED;N1k%(A2>Hx!E-_hA#tp~ zl+vpE9HZTgbmLlwJmcXwH9NOpeu#-D?{23DLs7lE`^6PwY+LnT_f+wX-xXi#swC&2 z8+F+L45FstCHgtAgQxN^oYhNlz5ml?BFu$^6DWPwPf`8^ij(pX!Ep}dVIYD)a6lP@ zI!v*&znEg2Pnn5^Ez;jSyI8by-NupE|9tu|`bz1Dz!A@6)?ZFKf|E;~9;x38G0mRw zEoS>SK4+JO8x-rEejU|y9yv62yWfjb)s(%)dos}Jk&1yL?}(zex$Z;XQrFYHo>xA* z+{W93FoN;6Ax>-fLA@cNewT4QhBM6~X8a?kFjxrzZ^)7WREL_xG8lv8z9U|ZBF+cn zNwvA55H)BlxGm>C!HZUqiR2@O1Cm5AUeLnokdMXmEfJ8!oO#oAp$(K_&j1`U8_&++ zga6@&_bh1W*dPUjAlZ2+b2J8#MU5fhoek6&wZ;EsX6Om#Vl5$19Biept)cK4WW^4` zu>(+R<}`^_FR^pFHpnIYu($Gr5}S!Wq+`%>8}yCX{umRe6UE%wVWT4OcjsUe6I_?h z;i26?_<$yp%fUv_hE@r7NTRR)!7jQ0--X78eF8rguMI`ms9CsyLokOTr~+zmNNy=9 z6Gh;NrY;E(T)v`AHhfUe%X|b70tzVg1H7qyrf&*+LfVs&v5LS$j)`9x8%Z4^9%116 z0^uN@gPiGvkr+8>7eN+5hn5cdPw71!16y(Hj6F3BLI;f_M0 zDanQ}q4~-nnV8Hy_kfl(5vcX>U*2>@LNJuFe$nhj3?$$fa{YpRXw6CDTW}Uqc19^R z3-;zdnL+*ui&@Mq72mpv9sEVwD9O#t|!vH+W_P{x4^@e6Ko|nJ75z)R$c=E&0t`~d4+RGKv&+$!P#4&Py)h^r z0E1Bi0I+nkjXgp7DXtQi;SvHBvq0;6$uOM>)yPzd^&!zB7y|%sI3}u* zsS?pdIG`S_3yyh;gmn@WQ7qU`-m-Rw41@W9E(X66w6)G(TG6{(j7tr2@w;*p@29jV-94`na;);0kdfZ zxt$=eWOf1(+$V1+YSl{yTOlA0)8Z!xx&0v-F#d8qwL}>a0}Yj7!&X3R*suqvDidd5 zE*A}`25QkfIX4q`Ne=jOodoRhwBRwqiP}y1j(gt59VA5VO^Udj+YNNf?T(-}hYWPG zi(34^m>xq2B0YY9g1`wRID-z_Nu=R8Crtqh)CmtDA))D6l7g9;htPy}9#<$EJ%duf zbVw+<6dMtFpb~lMEFRL4onVZk0i8zZ1C2-@*bf8IZpb6qkCZn3hllrpFlNgngzueLC8KH;0X6gkHJML+fb-NFH||?G45+CSh|ndU%=y8i3Oi<5v=#$Z!)riS=n$ z(9v$n{mesb00?$X7zsv`u?gR>=pkdCCY&(l%t3$|XAr~|UxxleVK5N~L5&%>lE%IDoM|aT7CKC^}+JSqR-y(}bW80cGx?5D2B^!!Y0p zMS%?Y0W_p2hBh2R&=*kwGhzN?gD}iyoI{*IG=<8C;0gjwDxy3y9-&|k9cu|-aEJ+) zAB(`{$T()pjhxUwXwIVyvlgCwu|ol>P>M~O(G;gbRiF+yGgO(v4+1~UmR*X#6U3(C zDK>?2#OfD#m;js5(y$4d0mz|c=qNZqMPUQdFpE3dc$5R7861=5xMkDj`N0L$C)96MCE^l10aVA(BB9rl(_ajQyiZC=v-suf=FO5Zq8yD0qVA z(+{@Niq9o$Z!##+Qh=A>1ifQGI>`*FISj-o1uH?~!PjH|01*1+c>)fb6$)iE2=paS zXo3Bs-)3U{*+L}b$fKkm4udjoq~Z2J?16T8)r6=)tVBU17{CsjqNYyigs2q^w-5Cztbp69n8)PzF8(LDHDG zj~h3T$;er9wuOp7=Lj8QO@}*ZbB(kk*ny3u#E*0q`KH}VXD~}9JU|9h)EY9h7tuOH zDY1+cVP{x{yDosrL`#StPw+|@i8H7=^F%UOK+^@;xDGH7Cd4K55~3(RdWu1_5e-BE z?>WGjLU%$I(w~f6r~tCCFMq44G3i=7-6726irQLM8}t?n-I7Y#UVLnD1$mg13p~hqWe)%hFTE6a39pAuCwW= zK>>)fpbF6+?ZXO?2*gWX5?>MiLLSqY-~g;+d;$f;c5KDHGRKudVE_T5VHqYOUYSdJ zq8&ksDkZ{q(_A7XBB0O&W!i(L)8SGskxd5PG^!k$fzf^3_zxX%D-$;3A8Dcc@*f)v8gfNGM;GB;@3~ZkoTf`KoPJ_&t0wyxbObso<4yZ?ucHkg$ zEgH|?xd9B!hZOpss;_gyJfJU{h~-!ecWMYyxCTSV;@Q`fCrG3Lha$^R&&vecP>M(4 zCCv^HA`Bn69LENWur=raT7citLdb`Jgpd)}Qn)t512S6?X>=nrT?m?k=`^2V0}03c zIp`OcS*S5MhIWDw6o!%tL?i5slp!Y9fVtdb;v8)AgK$KXA4;xLwpRvwXfJVXM9IXP zw6{=Y1}~yIsQ(grpC&|1gUW1FhU;*GKq$%cr|3%`({rd<1SGiV!e~O|VZT5$#1~B^ zW0YAe;PB%nhBGuqB%AgGV)vtr(UQgqn=)_GBA8N(@M-8!%!YY~&FBbbD#I=~3+GoX zM3(_|dESAIuy6(4V5XuL(_E+LLs%>*(+&n7kdX%?8`Y;)1y4`a*^$I;=M29?wxs1e7| z{+@xk2qHb!rY2uydG#<)` zUYUhrCMMb{Y)mi9Ad3#Xc$X5g=miD12R%kqV?cw7co%?Z%s>EHMBvY4fl$MM#VsY< zDeOrP++M(7LL-6?6Pnq?T}UxRfezm*E-NEBc<7Lc@qwC7JB{`$R7EBr*5G|)5Nb~n zIZI@mPmezI4xZ3rM-=0UDKF3s=eGPXlz~JDq@nj1?*5V9vy{ z*$EhEF=2mzA7V(945flgVcdas5H=ZXu^vGjZ9u{xMqECzaWyZ5-UUgpScSSIY{8c6 zS=gBd)6POd0g_LMLX(9%2Vndpx=n`~bU?#t3lg5*GDCtK6F3=dK@E(T5Sx%B^ejL| z9m?X`Pkca{!pRe4BMc11fO=e~VV^`L;0jVw`9Hu#@Bvz4HjNw(N`fxfTS_!0q$Tb^ zyqF0A`-MD;%y~FWp&9ugk$}z=JxpklpwHRp3rJ`f0}6Jye#tdz9$>`7MspV4R%FAb zor2rHOdHx1=#1-6v=rLWR*~S1SWYEWi5Lp{FaZv2gi*>>7XX18U^AFQF@Vq%KpcoE zp-_QV6*fotvF*O{Vc=J!!Z;@<1xvDg(F%@tR*8KRb$X$YM>=cYDg%7 z5|R)s5CR{%? zcMY;aX_O&`0$Mzml_^}3!%`S~#t)7B62B=3@#3=#n}X`}MIv(f0IbGgO87@_i4z%e zks!`2&uJ4;89H%C8f`HJmi?%KBWbE%x(KW!93~7yG|WNFPhf$4s5)T;!xGRG6N-h8 zGDXgDAp_*lz%pQhNW>oR$W7zA9sb!NlxQ&(7HDbM3pm4QIyAw$^l1BWwg%aujO(DB zj_|NU04gPVQ3PHblRqUwMaGes4dB4ih%o>?j&5axXt+`b!&Lcaw7{c88f>` (rhs.lastDownloadedAt ?? .distantPast) + } + + XCTAssertEqual(queuedRedownload.sortPriority, 1) + XCTAssertEqual(completedDownload.sortPriority, 7) + XCTAssertEqual(sortedDownloads.map(\.gid), [queuedRedownload.gid, completedDownload.gid]) + } + + func testInProgressDownloadPrefersTemporaryCoverURL() throws { + let gid = "811" + let download = sampleDownload( + gid: gid, + title: "Temporary Cover Archive", + status: .downloading, + completedPageCount: 3 + ) + + guard let rootURL = FileUtil.downloadsDirectoryURL else { + throw XCTSkip("Downloads directory is unavailable in the test environment.") + } + + let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) + try? FileManager.default.removeItem(at: temporaryFolderURL) + defer { try? FileManager.default.removeItem(at: temporaryFolderURL) } + + try FileManager.default.createDirectory( + at: temporaryFolderURL, + withIntermediateDirectories: true + ) + let temporaryCoverURL = temporaryFolderURL.appendingPathComponent("cover.jpg") + try Data([0xFF, 0xD8, 0xFF]).write(to: temporaryCoverURL, options: .atomic) + + XCTAssertEqual(download.resolvedCoverURL(rootURL: rootURL), temporaryCoverURL) + } + + func testQueuedDownloadPreservesTemporaryWorkingSet() { + let queuedDownload = sampleDownload( + gid: "809", + title: "Queued Archive", + status: .queued, + completedPageCount: 3 + ) + + XCTAssertTrue(queuedDownload.shouldPreserveTemporaryWorkingSet) + } + + func testActiveDownloadDoesNotNormalizeWhileTaskIsStillRunning() { + let activeDownload = sampleDownload( + gid: "810", + title: "Running Archive", + status: .downloading, + completedPageCount: 3 + ) + + XCTAssertFalse( + activeDownload.needsInterruptedDownloadNormalization( + activeGalleryID: activeDownload.gid, + hasActiveTask: true + ) + ) + XCTAssertTrue( + activeDownload.needsInterruptedDownloadNormalization( + activeGalleryID: nil, + hasActiveTask: false + ) + ) + XCTAssertTrue( + activeDownload.needsInterruptedDownloadNormalization( + activeGalleryID: "another-gid", + hasActiveTask: true + ) + ) + } + + func testAppLaunchAutomationResolveParsesGalleryURLAndCookies() { + let automation = AppLaunchAutomation.resolve(environment: [ + "EHPANDA_AUTOMATION_TAB": "downloads", + "EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID": "1394965", + "EHPANDA_AUTOMATION_GALLERY_URL": "https://e-hentai.org/g/1394965/56c35114b6/", + "EHPANDA_AUTOMATION_IPB_MEMBER_ID": "4172984", + "EHPANDA_AUTOMATION_IPB_PASS_HASH": "pass-hash", + "EHPANDA_AUTOMATION_IGNEOUS": "igneous-value" + ]) + + XCTAssertEqual(automation?.initialTab, .downloads) + XCTAssertEqual(automation?.autoDownloadGID, "1394965") + XCTAssertEqual( + automation?.galleryURL, + URL(string: "https://e-hentai.org/g/1394965/56c35114b6/") + ) + XCTAssertEqual(automation?.loginCookies?.memberID, "4172984") + XCTAssertEqual(automation?.loginCookies?.passHash, "pass-hash") + XCTAssertEqual(automation?.loginCookies?.igneous, "igneous-value") + } + + func testImportAutomationCookiesClearsStaleIgneousAndUsesSessionCookies() { + let cookieClient = CookieClient.live + cookieClient.clearAll() + defer { cookieClient.clearAll() } + + cookieClient.setOrEditCookie( + for: Defaults.URL.exhentai, + key: Defaults.Cookie.igneous, + value: "stale-igneous" + ) + + cookieClient.importAutomationCookies( + memberID: "4172984", + passHash: "pass-hash", + igneous: nil + ) + + let exCookies = HTTPCookieStorage.shared.cookies(for: Defaults.URL.exhentai) ?? [] + let memberCookie = exCookies.first { $0.name == Defaults.Cookie.ipbMemberId } + let passHashCookie = exCookies.first { $0.name == Defaults.Cookie.ipbPassHash } + let igneousCookie = exCookies.first { $0.name == Defaults.Cookie.igneous } + + XCTAssertEqual(memberCookie?.value, "4172984") + XCTAssertEqual(passHashCookie?.value, "pass-hash") + XCTAssertTrue(memberCookie?.isSessionOnly == true) + XCTAssertTrue(passHashCookie?.isSessionOnly == true) + XCTAssertNil(igneousCookie) + XCTAssertTrue(cookieClient.didLogin) + XCTAssertTrue(cookieClient.shouldFetchIgneous) + } + + @MainActor + func testRunLaunchAutomationFallsBackToInitialTabWhenGalleryURLIsUnhandleable() async { + setenv("EHPANDA_AUTOMATION_TAB", "downloads", 1) + setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://example.com/not-a-gallery", 1) + defer { + unsetenv("EHPANDA_AUTOMATION_TAB") + unsetenv("EHPANDA_AUTOMATION_GALLERY_URL") + } + + let store = TestStore(initialState: AppReducer.State()) { + AppReducer() + } withDependencies: { + $0.cookieClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.urlClient = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + } + + await store.send(.runLaunchAutomation) { + $0.didRunLaunchAutomation = true + } + await store.receive(\.tabBar.setTabBarItemType, .downloads) { + $0.tabBarState.tabBarItemType = .downloads + } + } + + @MainActor + func testDatabasePreparationImportsAutomationCookiesBeforeLoadingSettings() async { + let cookieClient = CookieClient.live + cookieClient.clearAll() + setenv("EHPANDA_AUTOMATION_IPB_MEMBER_ID", "4172984", 1) + setenv("EHPANDA_AUTOMATION_IPB_PASS_HASH", "pass-hash", 1) + defer { + cookieClient.clearAll() + unsetenv("EHPANDA_AUTOMATION_IPB_MEMBER_ID") + unsetenv("EHPANDA_AUTOMATION_IPB_PASS_HASH") + } + + let store = TestStore(initialState: AppReducer.State()) { + AppReducer() + } withDependencies: { + $0.cookieClient = cookieClient + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.uiApplicationClient = .noop + $0.userDefaultsClient = .noop + $0.appDelegateClient = .noop + $0.libraryClient = .noop + $0.loggerClient = .noop + $0.fileClient = .noop + $0.dfClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + + await store.send(.appDelegate(.migration(.onDatabasePreparationSuccess))) + await store.receive(\.appDelegate.removeExpiredImageURLs) + XCTAssertTrue(cookieClient.didLogin) + await store.receive(\.setting.loadUserSettings) + } + + @MainActor + func testLoadUserSettingsDefersExLaunchAutomationUntilIgneousArrives() async { + let cookieClient = CookieClient.live + cookieClient.clearAll() + cookieClient.importAutomationCookies( + memberID: "4172984", + passHash: "pass-hash", + igneous: nil + ) + setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://exhentai.org/g/1394965/56c35114b6/", 1) + defer { + cookieClient.clearAll() + unsetenv("EHPANDA_AUTOMATION_GALLERY_URL") + } + + let store = TestStore(initialState: AppReducer.State()) { + AppReducer() + } withDependencies: { + $0.cookieClient = cookieClient + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.uiApplicationClient = .noop + $0.userDefaultsClient = .noop + $0.appDelegateClient = .noop + $0.libraryClient = .noop + $0.loggerClient = .noop + $0.fileClient = .noop + $0.dfClient = .noop + $0.urlClient = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + } + store.exhaustivity = .off + + await store.send(.setting(.loadUserSettingsDone)) + XCTAssertFalse(store.state.didRunLaunchAutomation) + XCTAssertTrue(store.state.isWaitingForIgneousBeforeLaunchAutomation) + + let response: HTTPURLResponse = HTTPURLResponse( + url: Defaults.URL.exhentai, + statusCode: 200, + httpVersion: nil, + headerFields: [ + "Set-Cookie": "\(Defaults.Cookie.igneous)=test-igneous" + ] + )! + await store.send(.setting(.fetchIgneousDone(.success(response)))) + await store.receive(\.runLaunchAutomation) { + $0.didRunLaunchAutomation = true + $0.isWaitingForIgneousBeforeLaunchAutomation = false + } + } + + @MainActor + func testLoadUserSettingsKeepsExLaunchAutomationDeferredWhenIgneousFetchFails() async { + let cookieClient = CookieClient.live + cookieClient.clearAll() + cookieClient.importAutomationCookies( + memberID: "4172984", + passHash: "pass-hash", + igneous: nil + ) + setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://exhentai.org/g/1394965/56c35114b6/", 1) + defer { + cookieClient.clearAll() + unsetenv("EHPANDA_AUTOMATION_GALLERY_URL") + } + + let store = TestStore(initialState: AppReducer.State()) { + AppReducer() + } withDependencies: { + $0.cookieClient = cookieClient + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.uiApplicationClient = .noop + $0.userDefaultsClient = .noop + $0.appDelegateClient = .noop + $0.libraryClient = .noop + $0.loggerClient = .noop + $0.fileClient = .noop + $0.dfClient = .noop + $0.urlClient = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + } + store.exhaustivity = .off + + await store.send(.setting(.loadUserSettingsDone)) + XCTAssertFalse(store.state.didRunLaunchAutomation) + XCTAssertTrue(store.state.isWaitingForIgneousBeforeLaunchAutomation) + + await store.send(.setting(.fetchIgneousDone(.failure(.networkingFailed)))) + await store.receive(\.setting.account.loadCookies) + XCTAssertFalse(store.state.didRunLaunchAutomation) + XCTAssertTrue(store.state.isWaitingForIgneousBeforeLaunchAutomation) + } + + @MainActor + func testDownloadsReducerKeepsIdleStateForEmptyLibrary() async { + let store = TestStore(initialState: DownloadsReducer.State()) { + DownloadsReducer() + } + + await store.send(.fetchDownloadsDone([])) { + $0.loadingState = .idle + } + + XCTAssertEqual(store.state.downloads, []) + } + + @MainActor + func testDownloadsReducerSeedsOnlineDetailStateFromDownload() async { + let download = sampleDownload( + gid: "123456", + title: "Completed Gallery", + status: .completed + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } + store.exhaustivity = .off + + await store.send(.setNavigation(.detail(download.gid))) + + XCTAssertEqual(store.state.route, .detail(download.gid)) + XCTAssertEqual(store.state.detailState.wrappedValue?.gid, download.gid) + XCTAssertEqual(store.state.detailState.wrappedValue?.gallery.id, download.gid) + XCTAssertEqual(store.state.detailState.wrappedValue?.downloadBadge, .downloaded) + XCTAssertTrue(store.state.detailState.wrappedValue?.shouldCheckForRemoteUpdates == true) + } + + @MainActor + func testDownloadsReducerUpdateActionUsesDownloadClientRetry() async { + let retried = UncheckedBox<[String]>([]) + let download = sampleDownload( + gid: "123456", + title: "Completed Gallery", + status: .updateAvailable, + latestRemoteVersionSignature: "hash:v2" + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { gid, mode in + if mode == .update { + retried.value.append(gid) + } + return .success(()) + }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + store.exhaustivity = .off + + await store.send(.updateDownload(download.gid)) + await store.receive(\.updateDownloadDone) + + XCTAssertEqual(retried.value, [download.gid]) + } + + @MainActor + func testDownloadsReducerDeleteActionUsesDownloadClientDelete() async { + let deleted = UncheckedBox<[String]>([]) + let download = sampleDownload( + gid: "654321", + title: "Completed Gallery", + status: .completed + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { gid in + deleted.value.append(gid) + return .success(()) + }, + loadManifest: { _ in .failure(.notFound) } + ) + } + store.exhaustivity = .off + + await store.send(.deleteDownload(download.gid)) + await store.receive(\.deleteDownloadDone) + + XCTAssertEqual(deleted.value, [download.gid]) + } + + @MainActor + func testDownloadsReducerTogglePauseActionUsesDownloadClientPause() async { + let toggled = UncheckedBox<[String]>([]) + let download = sampleDownload( + gid: "987654", + title: "Downloading Gallery", + status: .downloading, + completedPageCount: 9 + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { gid in + toggled.value.append(gid) + return .success(()) + }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + store.exhaustivity = .off + + await store.send(.toggleDownloadPause(download.gid)) + await store.receive(\.toggleDownloadPauseDone) + + XCTAssertEqual(toggled.value, [download.gid]) + } + + @MainActor + func testDownloadInspectorReducerLoadsInspection() async { + let download = sampleDownload( + gid: "246810", + title: "Inspector Gallery", + status: .failed, + completedPageCount: 1 + ) + let inspection = sampleInspection(download: download) + + let store = TestStore(initialState: .init(gid: download.gid)) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in .success(inspection) } + ) + } + store.exhaustivity = .off + + await store.send(.loadInspection) + await store.receive(\.loadInspectionDone) { + $0.inspection = inspection + $0.stableInspection = inspection + $0.loadingState = .idle + } + } + + @MainActor + func testDownloadInspectorReducerRetryPageUsesDownloadClientRetryPages() async { + let retried = UncheckedBox<[Int]>([]) + let retryExpectation = XCTestExpectation(description: "Retry page") + let download = sampleDownload( + gid: "112233", + title: "Retry Page Gallery", + status: .failed, + completedPageCount: 1 + ) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = sampleInspection(download: download) + initialState.loadingState = .idle + + let store = TestStore(initialState: initialState) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, pageIndices in + retried.value = pageIndices + retryExpectation.fulfill() + return .success(()) + }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in .success(initialState.inspection!) } + ) + } + store.exhaustivity = .off + + await store.send(.retryPage(2)) + await fulfillment(of: [retryExpectation], timeout: 1) + XCTAssertEqual(retried.value, [2]) + } + + @MainActor + func testDownloadInspectorReducerRetryFailedPagesMarksFailedPagesPending() async { + let retried = UncheckedBox<[Int]>([]) + let download = sampleDownload( + gid: "112235", + title: "Retry Failed Pages Gallery", + status: .partial, + completedPageCount: 1 + ) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = sampleInspection(download: download) + initialState.loadingState = .idle + + let store = TestStore(initialState: initialState) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, pageIndices in + retried.value = pageIndices + return .success(()) + }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in .success(initialState.inspection!) } + ) + } + store.exhaustivity = .off + + await store.send(.retryFailedPages) { + guard let inspection = $0.inspection else { return } + $0.inspection = .init( + download: inspection.download, + coverURL: inspection.coverURL, + pages: [ + .init( + index: 1, + status: .downloaded, + relativePath: "pages/0001.jpg", + fileURL: URL(fileURLWithPath: "/tmp/0001.jpg"), + failure: nil + ), + .init( + index: 2, + status: .pending, + relativePath: "pages/0002.jpg", + fileURL: nil, + failure: nil + ) + ] + ) + } + + XCTAssertEqual(retried.value, [2]) + } + + @MainActor + func testDownloadInspectorKeepsRetriedPagesPendingWhileRetryWorkRemainsActive() async { + let download = sampleDownload( + gid: "112236", + title: "Retry Pending Gallery", + status: .partial, + completedPageCount: 1 + ) + let refreshedInspection = sampleInspection(download: download) + + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = sampleInspection(download: download) + initialState.stableInspection = sampleInspection(download: download) + initialState.retryingPageIndices = [2] + initialState.loadingState = .idle + + let store = TestStore(initialState: initialState) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in .success(refreshedInspection) } + ) + } + store.exhaustivity = .off + + await store.send(.loadInspection) + let requestID = store.state.inspectionRequestID + await store.send(.loadInspectionDone(requestID, .success(refreshedInspection))) { + $0.inspection = .init( + download: download, + coverURL: refreshedInspection.coverURL, + pages: [ + refreshedInspection.pages[0], + .init( + index: 2, + status: .pending, + relativePath: "pages/0002.jpg", + fileURL: nil, + failure: nil + ) + ] + ) + $0.loadingState = .idle + $0.retryingPageIndices = [2] + } + } + + @MainActor + func testDownloadInspectorClearsRetryingPagesAfterRetrySettlesWithFailure() async { + let initialDownload = sampleDownload( + gid: "112237", + title: "Retry Failure Gallery", + status: .partial, + completedPageCount: 1 + ) + let settledDownload = sampleDownload( + gid: "112237", + title: "Retry Failure Gallery", + status: .partial, + completedPageCount: 1, + lastError: .init(code: .networkingFailed, message: "Network Error") + ) + let settledInspection = sampleInspection(download: settledDownload) + + var initialState = DownloadInspectorReducer.State(gid: initialDownload.gid) + initialState.inspection = sampleInspection(download: initialDownload) + initialState.stableInspection = sampleInspection(download: initialDownload) + initialState.retryingPageIndices = [2] + initialState.loadingState = .idle + + let store = TestStore(initialState: initialState) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in .success(settledInspection) } + ) + } + store.exhaustivity = .off + + await store.send(.loadInspection) + let requestID = store.state.inspectionRequestID + await store.send(.loadInspectionDone(requestID, .success(settledInspection))) { + $0.inspection = settledInspection + $0.stableInspection = settledInspection + $0.loadingState = .idle + $0.retryingPageIndices = [] + } + } + + @MainActor + func testDownloadInspectorRestoresStableInspectionWhenRetryReloadFails() async { + let download = sampleDownload( + gid: "112238", + title: "Retry Reload Failure Gallery", + status: .partial, + completedPageCount: 1 + ) + let stableInspection = sampleInspection(download: download) + + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = .init( + download: download, + coverURL: stableInspection.coverURL, + pages: [ + stableInspection.pages[0], + .init( + index: 2, + status: .pending, + relativePath: "pages/0002.jpg", + fileURL: nil, + failure: nil + ) + ] + ) + initialState.stableInspection = stableInspection + initialState.retryingPageIndices = [2] + initialState.loadingState = .idle + + let store = TestStore(initialState: initialState) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in .failure(.networkingFailed) } + ) + } + store.exhaustivity = .off + + let requestID = store.state.inspectionRequestID + await store.send(.loadInspectionDone(requestID, .failure(.networkingFailed))) { + $0.inspection = stableInspection + $0.loadingState = .failed(.networkingFailed) + $0.retryingPageIndices = [] + } + } + + @MainActor + func testDownloadInspectorSkipsReloadWhenObservedDownloadDidNotChange() async { + let download = sampleDownload( + gid: "112244", + title: "Stable Inspector Gallery", + status: .partial, + completedPageCount: 1 + ) + let inspection = sampleInspection(download: download) + let loadInspectionCount = UncheckedBox(0) + + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = inspection + initialState.loadingState = .idle + + let store = TestStore(initialState: initialState) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in + loadInspectionCount.value += 1 + return .success(inspection) + } + ) + } + store.exhaustivity = .off + + await store.send(.observeDownloadsDone([download])) + XCTAssertEqual(loadInspectionCount.value, 0) + } + + @MainActor + func testDownloadInspectorIgnoresStaleInspectionResponses() async { + let originalDownload = sampleDownload( + gid: "112245", + title: "Stale Inspector Gallery", + status: .partial, + completedPageCount: 1 + ) + let refreshedDownload = sampleDownload( + gid: "112245", + title: "Stale Inspector Gallery", + status: .partial, + completedPageCount: 2 + ) + let staleInspection = sampleInspection(download: originalDownload) + let refreshedInspection = sampleInspection(download: refreshedDownload) + + let firstRequestID = UUID() + let secondRequestID = UUID() + var initialState = DownloadInspectorReducer.State(gid: originalDownload.gid) + initialState.loadingState = .loading + initialState.inspectionRequestID = secondRequestID + + let store = TestStore(initialState: initialState) { + DownloadInspectorReducer() + } + store.exhaustivity = .off + + await store.send(.loadInspectionDone(firstRequestID, .success(staleInspection))) + XCTAssertNil(store.state.inspection) + + await store.send(.loadInspectionDone(secondRequestID, .success(refreshedInspection))) { + $0.inspection = refreshedInspection + $0.stableInspection = refreshedInspection + $0.loadingState = .idle + } + } + + func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000)) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let manager = DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: .shared + ) + + try await insertPersistedDownload( + gid: gid, + status: .failed, + completedPageCount: 1, + pageCount: 2 + ) + + let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try Data([0x01]).write( + to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try JSONEncoder().encode( + DownloadFailedPagesSnapshot( + pages: [ + .init( + index: 2, + relativePath: "pages/0002.jpg", + failure: .init(code: .networkingFailed, message: "Network Error") + ) + ] + ) + ) + .write( + to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadFailedPages), + options: .atomic + ) + + let result = await manager.loadInspection(gid: gid) + let inspection = try result.get() + + XCTAssertEqual(inspection.pages[0].status, .downloaded) + XCTAssertEqual(inspection.pages[1].status, .failed) + XCTAssertEqual(inspection.pages[1].failure?.code, .networkingFailed) + } + + func testDownloadManagerLoadLocalPageURLsPrefersCompletedFolderForCompletedDownload() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 11) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + + try await insertPersistedDownload( + gid: gid, + status: .completed, + completedPageCount: 2, + pageCount: 2 + ) + + let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let manifest = sampleManifest(gid: gid, title: "Pause Race") + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + let completedPageURL = completedFolderURL.appendingPathComponent("pages/0001.jpg") + try Data([0x01]).write(to: completedPageURL, options: .atomic) + try Data([0x02]).write( + to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let temporaryPageURL = temporaryFolderURL.appendingPathComponent("pages/0001.jpg") + try Data([0x02]).write(to: temporaryPageURL, options: .atomic) + + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + + XCTAssertEqual(pageURLs[1], completedPageURL) + XCTAssertNotEqual(pageURLs[1], temporaryPageURL) + XCTAssertNil(pageURLs[3]) + } + + func testDownloadManagerLoadLocalPageURLsMergesReadableCompletedPagesWithTemporaryPages() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 12) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + + try await insertPersistedDownload( + gid: gid, + status: .downloading, + completedPageCount: 2, + pageCount: 2 + ) + + let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let manifest = sampleManifest(gid: gid, title: "Pause Race") + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([0x01]).write( + to: completedFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try Data([0x09]).write( + to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let temporaryPageURL = temporaryFolderURL.appendingPathComponent("pages/0002.jpg") + try Data([0x02]).write(to: temporaryPageURL, options: .atomic) + + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + + XCTAssertEqual(pageURLs[1], completedFolderURL.appendingPathComponent("pages/0001.jpg")) + XCTAssertEqual(pageURLs[2], temporaryPageURL) + } + + func testRepairSeedRejectsOldCompletedVersionWhenGalleryUpdatedButPageCountMatches() async throws { + let gid = "repair-seed-\(UUID().uuidString)" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + + try storage.ensureRootDirectory() + let existingDownload = sampleDownload( + gid: gid, + title: "Mixed Version", + status: .missingFiles, + pageCount: 2, + completedPageCount: 2, + remoteVersionSignature: "hash:v1", + latestRemoteVersionSignature: "hash:v2" + ) + let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Mixed Version", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let oldManifest = sampleManifest( + gid: gid, + title: "Mixed Version", + pageCount: 2, + versionSignature: "hash:v1" + ) + try JSONEncoder().encode(oldManifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([0x01]).write( + to: completedFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) + + let payload = DownloadRequestPayload( + gallery: Gallery( + gid: gid, + token: "token", + title: "Mixed Version", + rating: 4, + tags: [], + category: .doujinshi, + uploader: "Uploader", + pageCount: 2, + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token") + ), + galleryDetail: GalleryDetail( + gid: gid, + title: "Mixed Version", + jpnTitle: nil, + isFavorited: false, + visibility: .yes, + rating: 4, + userRating: 0, + ratingCount: 1, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + favoritedCount: 0, + pageCount: 2, + sizeCount: 1, + sizeType: "MB", + torrentCount: 0 + ), + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + options: .init(), + mode: .repair + ) + + let workingSeed = try await manager.testingPrepareWorkingSeed( + payload: payload, + existingDownload: existingDownload, + versionSignature: "hash:v2" + ) + + XCTAssertNil(workingSeed.manifest) + XCTAssertTrue(workingSeed.existingPages.isEmpty) + XCTAssertNil(workingSeed.coverRelativePath) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: workingSeed.folderURL.appendingPathComponent("pages/0001.jpg").path + ) + ) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: workingSeed.folderURL.appendingPathComponent("pages/0002.jpg").path + ) + ) + } + + func testDownloadManagerLoadLocalPageURLsMarksCompletedDownloadMissingFilesWhenZeroBytePageIsFound() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 13) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + + try await insertPersistedDownload( + gid: gid, + status: .completed, + completedPageCount: 2, + pageCount: 2 + ) + + let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let manifest = sampleManifest(gid: gid, title: "Pause Race") + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + let emptyPageURL = completedFolderURL.appendingPathComponent("pages/0001.jpg") + try Data().write(to: emptyPageURL, options: .atomic) + let goodPageURL = completedFolderURL.appendingPathComponent("pages/0002.jpg") + try Data([0x02]).write(to: goodPageURL, options: .atomic) + + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + let stored = await manager.testingFetchDownload(gid: gid) + + XCTAssertNil(pageURLs[1]) + XCTAssertEqual(pageURLs[2], goodPageURL) + XCTAssertFalse(FileManager.default.fileExists(atPath: emptyPageURL.path)) + XCTAssertEqual(stored?.status, .missingFiles) + XCTAssertEqual(stored?.completedPageCount, 1) + } + + @MainActor + func testImageClientFetchImageUsesStableAliasCacheKey() async throws { + let url = try XCTUnwrap( + URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") + ) + let stableCacheKey = try XCTUnwrap(url.stableImageCacheKey) + let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemRed.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try XCTUnwrap(image.pngData()) + + KingfisherManager.shared.cache.store(image, original: imageData, forKey: stableCacheKey) + defer { + KingfisherManager.shared.cache.removeImage(forKey: stableCacheKey) + KingfisherManager.shared.cache.removeImage(forKey: url.absoluteString) + } + + let result = await ImageClient.live.fetchImage(url: url) + let fetchedImage = try result.get() + + XCTAssertEqual(fetchedImage.size, image.size) + } + + func testRetryPagesQueuesWorkWhenAnotherDownloadIsActive() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + + try await insertPersistedDownload( + gid: gid, + status: .partial, + completedPageCount: 1, + pageCount: 2 + ) + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL, + withIntermediateDirectories: true + ) + try storage.writeFailedPages( + .init( + pages: [ + .init( + index: 2, + relativePath: "pages/0002.jpg", + failure: .init(code: .networkingFailed, message: "Network Error") + ) + ] + ), + folderURL: temporaryFolderURL + ) + + let blockingTask = Task { + _ = try? await Task.sleep(for: .seconds(60)) + } + defer { blockingTask.cancel() } + await manager.testingInstallActiveTask(gid: "other-active-download", task: blockingTask) + + let result = await manager.retryPages(gid: gid, pageIndices: [2]) + + guard case .success = result else { + return XCTFail("Retry pages should succeed, got \(result)") + } + + let stored = await manager.testingFetchDownload(gid: gid) + XCTAssertEqual(stored?.status, .queued) + XCTAssertEqual(stored?.badge, .queued) + XCTAssertNil(stored?.pendingOperation) + XCTAssertNil(stored?.lastError) + + let resumeState = try storage.readResumeState(folderURL: temporaryFolderURL) + XCTAssertEqual(resumeState.pageSelection, [2]) + XCTAssertFalse(FileManager.default.fileExists( + atPath: temporaryFolderURL + .appendingPathComponent(Defaults.FilePath.downloadFailedPages) + .path + )) + } + + func testCancelQueuedRepairRestoresReadableCountAndClearsPendingOperation() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = "cancel-repair-\(UUID().uuidString)" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + + try await insertPersistedDownload( + gid: gid, + status: .missingFiles, + completedPageCount: 0, + pageCount: 2, + remoteVersionSignature: "hash:v1", + latestRemoteVersionSignature: "hash:v1", + pendingOperation: .repair + ) + + let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let manifest = sampleManifest(gid: gid, title: "Pause Race") + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([0x01]).write( + to: completedFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + + let result = await manager.togglePause(gid: gid) + guard case .success = result else { + return XCTFail("Cancelling queued repair should succeed, got \(result)") + } + + let stored = await manager.testingFetchDownload(gid: gid) + XCTAssertEqual(stored?.status, .missingFiles) + XCTAssertEqual(stored?.completedPageCount, 1) + XCTAssertNil(stored?.pendingOperation) + } + + func testRetryPagesUsesMinimalSourceResolutionAndSkipsWhenNoPendingPages() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 200) + let pageIndex = 42 + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + let recorder = RequestRecorder() + let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") + let mpvHTML = try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html") + let metadataResponse = try JSONSerialization.data(withJSONObject: [ + "gmetadata": [[ + "gid": Int(gid)!, + "token": "token", + "current_gid": Int(gid)!, + "current_key": "updated-key", + "parent_gid": Int(gid)!, + "parent_key": "token", + "first_gid": Int(gid)!, + "first_key": "token" + ]] + ]) + + SharedSessionStubURLProtocol.requestHandler = { request in + guard let url = request.url else { + throw URLError(.badURL) + } + + if url.host == "api.e-hentai.org" { + recorder.recordMetadata() + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + metadataResponse + ) + } + + if url.path.contains("/g/\(gid)/token") { + let pageNumber = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "p" })? + .value + .flatMap(Int.init) + if let pageNumber { + recorder.recordPreview(pageNumber) + } else { + recorder.recordDetail() + } + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )!, + detailHTML + ) + } + + if url.path.contains("/mpv/\(gid)/token") { + recorder.recordMPV() + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )!, + mpvHTML + ) + } + + if url.path == "/api.php" { + let method = requestBodyData(from: request) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + if method?["method"] as? String == "gdata" { + recorder.recordMetadata() + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + metadataResponse + ) + } + + recorder.recordImageDispatch() + let responseData = try JSONSerialization.data(withJSONObject: [ + "i": "https://example.com/image-\(pageIndex).jpg" + ]) + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + responseData + ) + } + + if url.host == "example.com" { + recorder.recordImageDownload() + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/jpeg"] + )!, + Data([0xFF, 0xD8, 0xFF, 0xD9]) + ) + } + + throw URLError(.unsupportedURL) + } + URLProtocol.registerClass(SharedSessionStubURLProtocol.self) + defer { + SharedSessionStubURLProtocol.requestHandler = nil + URLProtocol.unregisterClass(SharedSessionStubURLProtocol.self) + } + + let scaffoldDownload = sampleDownload( + gid: gid, + title: "Pause Race", + status: .partial, + pageCount: 156, + completedPageCount: 155 + ) + let (payload, versionSignature) = try await manager.testingFetchLatestPayload( + for: scaffoldDownload, + mode: .redownload, + pageSelection: [pageIndex] + ) + recorder.reset() + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let pageCount = payload.galleryDetail.pageCount + let manifest = sampleManifest( + gid: gid, + title: "Pause Race", + pageCount: pageCount, + versionSignature: versionSignature + ) + func writeTemporaryWorkingSet(missing pageToOmit: Int?) throws { + try? FileManager.default.removeItem(at: temporaryFolderURL) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try JSONEncoder().encode(manifest).write( + to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: temporaryFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + for index in 1...pageCount where index != pageToOmit { + try Data([UInt8(index % 255)]).write( + to: temporaryFolderURL.appendingPathComponent( + "pages/\(String(format: "%04d", index)).jpg" + ), + options: .atomic + ) + } + try storage.writeResumeState( + .init( + mode: .redownload, + versionSignature: versionSignature, + pageCount: pageCount, + downloadOptions: .init(), + pageSelection: [pageIndex] + ), + folderURL: temporaryFolderURL + ) + } + + try await insertPersistedDownload( + gid: gid, + status: .partial, + completedPageCount: pageCount - 1, + pageCount: pageCount, + remoteVersionSignature: versionSignature, + latestRemoteVersionSignature: versionSignature + ) + + try writeTemporaryWorkingSet(missing: pageIndex) + await manager.testingProcessDownload(gid: gid) + + let firstRunSnapshot = recorder.snapshot() + XCTAssertEqual(firstRunSnapshot.previewPageNumbers, [1]) + + recorder.reset() + try await clearPersistedDownloads() + try await insertPersistedDownload( + gid: gid, + status: .partial, + completedPageCount: pageCount, + pageCount: pageCount, + remoteVersionSignature: versionSignature, + latestRemoteVersionSignature: versionSignature + ) + + try writeTemporaryWorkingSet(missing: nil) + await manager.testingProcessDownload(gid: gid) + + let secondRunSnapshot = recorder.snapshot() + XCTAssertTrue(secondRunSnapshot.previewPageNumbers.isEmpty) + XCTAssertEqual(secondRunSnapshot.mpvRequests, 0) + XCTAssertEqual(secondRunSnapshot.imageDispatchRequests, 0) + } + + func testRetryPagesFallsBackToFullUpdateWhenGalleryHasUpdate() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) + let pageIndex = 42 + let oldVersionSignature = try XCTUnwrap( + DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") + ) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let queueingManager = DownloadManager( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + let immediateManager = DownloadManager( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") + let mpvHTML = try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html") + let metadataResponse = try JSONSerialization.data(withJSONObject: [ + "gmetadata": [[ + "gid": Int(gid)!, + "token": "token", + "current_gid": Int(gid)!, + "current_key": "updated-key", + "parent_gid": Int(gid)!, + "parent_key": "token", + "first_gid": Int(gid)!, + "first_key": "token" + ]] + ]) + + SharedSessionStubURLProtocol.requestHandler = { request in + guard let url = request.url else { + throw URLError(.badURL) + } + + if url.path.contains("/g/\(gid)/token") { + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )!, + detailHTML + ) + } + + if url.path.contains("/mpv/") { + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )!, + mpvHTML + ) + } + + if url.path == "/api.php" { + let body = requestBodyData(from: request) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + let method = body?["method"] as? String + if method == "gdata" { + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + metadataResponse + ) + } + + let responseData = try JSONSerialization.data(withJSONObject: [ + "i": "https://example.com/image-\(pageIndex).jpg" + ]) + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + responseData + ) + } + + if url.host == "example.com" { + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/jpeg"] + )!, + Data([0xFF, 0xD8, 0xFF, 0xD9]) + ) + } + + throw URLError(.unsupportedURL) + } + URLProtocol.registerClass(SharedSessionStubURLProtocol.self) + defer { + SharedSessionStubURLProtocol.requestHandler = nil + URLProtocol.unregisterClass(SharedSessionStubURLProtocol.self) + } + + let scaffoldDownload = sampleDownload( + gid: gid, + title: "Pause Race", + status: .partial, + pageCount: 156, + completedPageCount: 155, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: "" + ) + let (payload, updatedVersionSignature) = try await queueingManager.testingFetchLatestPayload( + for: scaffoldDownload, + mode: .update + ) + + let pageCount = payload.galleryDetail.pageCount + XCTAssertGreaterThan(pageCount, pageIndex) + XCTAssertGreaterThan(pageCount, 5) + let oldCount = pageCount - 5 + XCTAssertNotEqual(oldCount, pageCount) + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + + // Queued update path: retryPages should queue a full update and keep no page-selection state. + try await insertPersistedDownload( + gid: gid, + status: .partial, + completedPageCount: oldCount - 1, + pageCount: oldCount, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: updatedVersionSignature + ) + + let queuedCandidate = await queueingManager.testingFetchDownload(gid: gid) + XCTAssertTrue(queuedCandidate?.hasUpdate == true) + + let blockerTask = Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + } + await queueingManager.testingInstallActiveTask(gid: "blocker", task: blockerTask) + defer { blockerTask.cancel() } + + let retryResult = await queueingManager.retryPages(gid: gid, pageIndices: [pageIndex]) + guard case .success = retryResult else { + return XCTFail("retryPages should succeed, got \(retryResult)") + } + + let queued = await queueingManager.testingFetchDownload(gid: gid) + XCTAssertEqual(queued?.status, .partial) + XCTAssertEqual(queued?.pendingOperation, .update) + XCTAssertNil(queued?.lastError) + if FileManager.default.fileExists(atPath: temporaryFolderURL.path) { + let queuedResumeState = try storage.readResumeState(folderURL: temporaryFolderURL) + XCTAssertEqual(queuedResumeState.mode, .update) + XCTAssertNil(queuedResumeState.pageSelection) + XCTAssertNotEqual(queuedResumeState.pageSelection, [pageIndex]) + } + + try await clearPersistedDownloads() + try? storage.removeTemporaryFolder(gid: gid) + + // Immediate update path: retryPages should normalize the working set to full-update semantics. + let manifest = sampleManifest( + gid: gid, + title: "Pause Race", + pageCount: pageCount, + versionSignature: updatedVersionSignature + ) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try JSONEncoder().encode(manifest).write( + to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: temporaryFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + for index in 1...pageCount where index != pageIndex { + try Data([UInt8(index % 255)]).write( + to: temporaryFolderURL.appendingPathComponent( + "pages/\(String(format: "%04d", index)).jpg" + ), + options: .atomic + ) + } + try storage.writeResumeState( + .init( + mode: .update, + versionSignature: updatedVersionSignature, + pageCount: pageCount, + downloadOptions: .init(), + pageSelection: [pageIndex] + ), + folderURL: temporaryFolderURL + ) + try await insertPersistedDownload( + gid: gid, + status: .partial, + completedPageCount: oldCount - 1, + pageCount: oldCount, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: updatedVersionSignature + ) + + let immediateBlockerTask = Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + } + await immediateManager.testingInstallActiveTask(gid: gid, task: immediateBlockerTask) + defer { immediateBlockerTask.cancel() } + + let immediateRetryResult = await immediateManager.retryPages(gid: gid, pageIndices: [pageIndex]) + guard case .success = immediateRetryResult else { + return XCTFail("Immediate retryPages should succeed, got \(immediateRetryResult)") + } + + let resumedState = try storage.readResumeState(folderURL: temporaryFolderURL) + XCTAssertEqual(resumedState.mode, .update) + XCTAssertEqual(resumedState.versionSignature, updatedVersionSignature) + XCTAssertEqual(resumedState.pageCount, pageCount) + XCTAssertNil(resumedState.pageSelection) + XCTAssertNotEqual(resumedState.pageSelection, [pageIndex]) + let resumedDownload = await immediateManager.testingFetchDownload(gid: gid) + XCTAssertEqual(resumedDownload?.status, .downloading) + XCTAssertNil(resumedDownload?.pendingOperation) + XCTAssertNil(resumedDownload?.lastError) + } + + func testProcessDownloadClearsStalePageSelectionWhenLatestPayloadRevealsUpdate() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 401) + let pageIndex = 42 + let oldVersionSignature = try XCTUnwrap( + DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") + ) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") + let mpvHTML = try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html") + var allowedImageURLs = Set() + let metadataResponse = try JSONSerialization.data(withJSONObject: [ + "gmetadata": [[ + "gid": Int(gid)!, + "token": "token", + "current_gid": Int(gid)!, + "current_key": "updated-key", + "parent_gid": Int(gid)!, + "parent_key": "token", + "first_gid": Int(gid)!, + "first_key": "token" + ]] + ]) + + SharedSessionStubURLProtocol.requestHandler = { request in + guard let url = request.url else { + throw URLError(.badURL) + } + + if url.path.contains("/g/\(gid)/token") { + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )!, + detailHTML + ) + } + + if url.path.contains("/mpv/") { + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )!, + mpvHTML + ) + } + + if url.path == "/api.php" { + let body = requestBodyData(from: request) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + let method = body?["method"] as? String + if method == "gdata" { + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + metadataResponse + ) + } + + let responseData = try JSONSerialization.data(withJSONObject: [ + "i": "https://example.com/image-\(pageIndex).jpg" + ]) + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + responseData + ) + } + + if url.host == "example.com" || allowedImageURLs.contains(url.absoluteString) { + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/jpeg"] + )!, + Data([0xFF, 0xD8, 0xFF, 0xD9]) + ) + } + + throw URLError(.unsupportedURL) + } + URLProtocol.registerClass(SharedSessionStubURLProtocol.self) + defer { + SharedSessionStubURLProtocol.requestHandler = nil + URLProtocol.unregisterClass(SharedSessionStubURLProtocol.self) + } + + let scaffoldDownload = sampleDownload( + gid: gid, + title: "Pause Race", + status: .partial, + pageCount: 156, + completedPageCount: 155, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: oldVersionSignature + ) + let (latestPayload, updatedVersionSignature) = try await manager.testingFetchLatestPayload( + for: scaffoldDownload, + mode: .redownload, + pageSelection: [pageIndex] + ) + if let coverURL = latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL { + allowedImageURLs.insert(coverURL.absoluteString) + } + + let updatedPageCount = latestPayload.galleryDetail.pageCount + XCTAssertGreaterThan(updatedPageCount, pageIndex) + XCTAssertGreaterThan(updatedPageCount, 5) + let oldPageCount = updatedPageCount - 5 + XCTAssertNotEqual(oldPageCount, updatedPageCount) + + try await insertPersistedDownload( + gid: gid, + status: .partial, + completedPageCount: oldPageCount - 1, + pageCount: oldPageCount, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: oldVersionSignature + ) + + let beforeProcess = await manager.testingFetchDownload(gid: gid) + XCTAssertFalse(beforeProcess?.hasUpdate ?? true) + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let staleManifest = sampleManifest( + gid: gid, + title: "Pause Race", + pageCount: oldPageCount, + versionSignature: oldVersionSignature + ) + try JSONEncoder().encode(staleManifest).write( + to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: temporaryFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([UInt8(pageIndex % 255)]).write( + to: temporaryFolderURL.appendingPathComponent( + "pages/\(String(format: "%04d", pageIndex)).jpg" + ), + options: .atomic + ) + try storage.writeResumeState( + .init( + mode: .redownload, + versionSignature: oldVersionSignature, + pageCount: oldPageCount, + downloadOptions: .init(), + pageSelection: [pageIndex] + ), + folderURL: temporaryFolderURL + ) + + await manager.testingProcessDownload(gid: gid) + + let completedDownload = await manager.testingFetchDownload(gid: gid) + let unwrappedCompletedDownload = try XCTUnwrap(completedDownload) + XCTAssertEqual(unwrappedCompletedDownload.status, .completed) + XCTAssertEqual(unwrappedCompletedDownload.pageCount, updatedPageCount) + XCTAssertEqual(unwrappedCompletedDownload.completedPageCount, updatedPageCount) + XCTAssertEqual(unwrappedCompletedDownload.remoteVersionSignature, updatedVersionSignature) + XCTAssertEqual(unwrappedCompletedDownload.latestRemoteVersionSignature, updatedVersionSignature) + + let completedFolderURL = storage.folderURL(relativePath: unwrappedCompletedDownload.folderRelativePath) + let completedManifest = try storage.readManifest(folderURL: completedFolderURL) + XCTAssertEqual(completedManifest.versionSignature, updatedVersionSignature) + XCTAssertEqual(completedManifest.pageCount, updatedPageCount) + XCTAssertEqual(completedManifest.pages.count, updatedPageCount) + XCTAssertTrue(FileManager.default.fileExists( + atPath: completedFolderURL.appendingPathComponent("pages/0001.jpg").path + )) + + let completedResumeState = try storage.readResumeState(folderURL: completedFolderURL) + XCTAssertEqual(completedResumeState.mode, .redownload) + XCTAssertEqual(completedResumeState.versionSignature, updatedVersionSignature) + XCTAssertEqual(completedResumeState.pageCount, updatedPageCount) + XCTAssertNil(completedResumeState.pageSelection) + XCTAssertFalse(FileManager.default.fileExists(atPath: temporaryFolderURL.path)) + } + + @MainActor + func testProcessDownloadClearsRemoteAssetCacheAfterSuccessfulDownload() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 402) + let pageIndex = 42 + let oldVersionSignature = try XCTUnwrap( + DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") + ) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") + let mpvHTML = try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html") + let currentPageImageURL = try XCTUnwrap( + URL(string: "https://example.com/image-\(pageIndex).jpg") + ) + let staleStoredPageURL = try XCTUnwrap( + URL(string: "https://example.com/stale-image-\(gid)-1.jpg") + ) + let plainPreviewURL = try XCTUnwrap( + URL(string: "https://ehgt.org/preview/\(gid)/1.webp") + ) + let combinedPreviewURL = URLUtil.combinedPreviewURL( + plainURL: plainPreviewURL, + width: "200", + height: "300", + offset: "40" + ) + var allowedImageURLs = Set() + let metadataResponse = try JSONSerialization.data(withJSONObject: [ + "gmetadata": [[ + "gid": Int(gid)!, + "token": "token", + "current_gid": Int(gid)!, + "current_key": "updated-key", + "parent_gid": Int(gid)!, + "parent_key": "token", + "first_gid": Int(gid)!, + "first_key": "token" + ]] + ]) + + SharedSessionStubURLProtocol.requestHandler = { request in + guard let url = request.url else { + throw URLError(.badURL) + } + + if url.path.contains("/g/\(gid)/token") { + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )!, + detailHTML + ) + } + + if url.path.contains("/mpv/") { + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )!, + mpvHTML + ) + } + + if url.path == "/api.php" { + let body = requestBodyData(from: request) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + let method = body?["method"] as? String + if method == "gdata" { + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + metadataResponse + ) + } + + let responseData = try JSONSerialization.data(withJSONObject: [ + "i": currentPageImageURL.absoluteString + ]) + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + responseData + ) + } + + if url.host == "example.com" || allowedImageURLs.contains(url.absoluteString) { + return ( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/jpeg"] + )!, + Data([0xFF, 0xD8, 0xFF, 0xD9]) + ) + } + + throw URLError(.unsupportedURL) + } + URLProtocol.registerClass(SharedSessionStubURLProtocol.self) + defer { + SharedSessionStubURLProtocol.requestHandler = nil + URLProtocol.unregisterClass(SharedSessionStubURLProtocol.self) + } + + let scaffoldDownload = sampleDownload( + gid: gid, + title: "Pause Race", + status: .partial, + pageCount: 156, + completedPageCount: 155, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: oldVersionSignature + ) + let (latestPayload, _) = try await manager.testingFetchLatestPayload( + for: scaffoldDownload, + mode: .redownload, + pageSelection: [pageIndex] + ) + let coverURL = try XCTUnwrap(latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL) + allowedImageURLs.insert(coverURL.absoluteString) + + let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemTeal.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let cachedImageData = try XCTUnwrap(cachedImage.jpegData(compressionQuality: 1)) + + let cachedURLs = combinedPreviewURL.previewCacheCleanupURLs() + + [currentPageImageURL, staleStoredPageURL, coverURL] + let cachedKeys = Set(cachedURLs.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) + for cacheKey in cachedKeys { + KingfisherManager.shared.cache.storeToDisk(cachedImageData, forKey: cacheKey) + } + defer { + cachedKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } + } + + let seededCacheClock = ContinuousClock() + let seededCacheDeadline = seededCacheClock.now.advanced(by: .seconds(1)) + while !cachedKeys.allSatisfy({ KingfisherManager.shared.cache.isCached(forKey: $0) }), + seededCacheClock.now < seededCacheDeadline + { + try? await Task.sleep(for: .milliseconds(10)) + } + XCTAssertTrue(cachedKeys.allSatisfy { KingfisherManager.shared.cache.isCached(forKey: $0) }) + + let updatedPageCount = latestPayload.galleryDetail.pageCount + let oldPageCount = updatedPageCount - 5 + XCTAssertGreaterThan(updatedPageCount, pageIndex) + XCTAssertGreaterThan(oldPageCount, 0) + + try await insertPersistedDownload( + gid: gid, + status: .partial, + completedPageCount: oldPageCount - 1, + pageCount: oldPageCount, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: oldVersionSignature + ) + try await insertPersistedGalleryState( + gid: gid, + previewURLs: [1: combinedPreviewURL], + imageURLs: [1: staleStoredPageURL] + ) + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let staleManifest = sampleManifest( + gid: gid, + title: "Pause Race", + pageCount: oldPageCount, + versionSignature: oldVersionSignature + ) + try JSONEncoder().encode(staleManifest).write( + to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: temporaryFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([UInt8(pageIndex % 255)]).write( + to: temporaryFolderURL.appendingPathComponent( + "pages/\(String(format: "%04d", pageIndex)).jpg" + ), + options: .atomic + ) + try storage.writeResumeState( + .init( + mode: .redownload, + versionSignature: oldVersionSignature, + pageCount: oldPageCount, + downloadOptions: .init(), + pageSelection: [pageIndex] + ), + folderURL: temporaryFolderURL + ) + + await manager.testingProcessDownload(gid: gid) + + let completedDownload = await manager.testingFetchDownload(gid: gid) + XCTAssertEqual(completedDownload?.status, .completed) + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while cachedKeys.contains(where: { KingfisherManager.shared.cache.isCached(forKey: $0) }), + clock.now < deadline + { + try? await Task.sleep(for: .milliseconds(10)) + } + + for cacheKey in cachedKeys { + XCTAssertFalse( + KingfisherManager.shared.cache.isCached(forKey: cacheKey), + "Expected cache key to be removed after successful download: \(cacheKey)" + ) + } + } + + @MainActor + func testDownloadsReducerRefreshesWithoutResumingQueueAfterPauseFailure() async { + let download = sampleDownload( + gid: "987655", + title: "Queued Gallery", + status: .queued, + completedPageCount: 3 + ) + let reconcileCount = UncheckedBox(0) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [download] }, + fetchDownload: { _ in nil }, + reconcileDownloads: { + reconcileCount.value += 1 + }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .failure(.networkingFailed) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + + await store.send(.toggleDownloadPause(download.gid)) + await store.receive(\.toggleDownloadPauseDone) + await store.finish() + + XCTAssertEqual(reconcileCount.value, 1) + } + + @MainActor + func testDownloadsReducerRefreshDownloadsUsesClientRefresh() async { + let refreshCount = UncheckedBox(0) + let reconcileCount = UncheckedBox(0) + + let store = TestStore(initialState: DownloadsReducer.State()) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + reconcileDownloads: { + reconcileCount.value += 1 + }, + refreshDownloads: { + refreshCount.value += 1 + }, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + + await store.send(.refreshDownloads) + await store.receive(\.refreshDownloadsDone) + + XCTAssertEqual(refreshCount.value, 1) + XCTAssertEqual(reconcileCount.value, 0) + } + + @MainActor + func testDownloadsReducerBootstrapUsesClientRefresh() async { + let refreshCount = UncheckedBox(0) + let reconcileCount = UncheckedBox(0) + + let store = TestStore(initialState: DownloadsReducer.State()) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + reconcileDownloads: { + reconcileCount.value += 1 + }, + refreshDownloads: { + refreshCount.value += 1 + }, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + + await store.send(.bootstrapDownloads) + await store.receive(\.refreshDownloadsDone) + + XCTAssertEqual(refreshCount.value, 1) + XCTAssertEqual(reconcileCount.value, 0) + } + + @MainActor + func testDetailReducerStartDownloadEnqueuesGalleryWithSnapshotOptions() async { + let capturedPayload = UncheckedBox(nil) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let options = DownloadOptionsSnapshot( + threadMode: .quadruple, + allowCellular: false, + autoRetryFailedPages: false + ) + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + initialState.galleryDetail = detail + initialState.galleryPreviewURLs = [ + 1: URL(string: "https://example.com/1.jpg")! + ] + initialState.previewConfig = .large(rows: 2) + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, .queued) }) + }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { payload in + capturedPayload.value = payload + return .success(()) + }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.startDownload(options)) + await store.skipReceivedActions(strict: false) + + XCTAssertEqual(capturedPayload.value?.gallery.gid, gallery.gid) + XCTAssertEqual(capturedPayload.value?.galleryDetail, detail) + XCTAssertEqual(capturedPayload.value?.previewConfig, .large(rows: 2)) + XCTAssertEqual(capturedPayload.value?.options, options) + XCTAssertEqual(capturedPayload.value?.mode, .initial) + XCTAssertEqual(store.state.downloadBadge, .queued) + } + + @MainActor + func testDetailReducerStartDownloadUnlocksActionsAfterQueueing() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let options = DownloadOptionsSnapshot() + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + initialState.galleryDetail = detail + initialState.galleryPreviewURLs = [ + 1: URL(string: "https://example.com/1.jpg")! + ] + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, .queued) }) + }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.startDownload(options)) { + $0.isPreparingDownload = true + $0.didRunLaunchAutomation = true + } + await store.receive(\.startDownloadDone) { + $0.isPreparingDownload = false + $0.downloadBadge = .queued + $0.hasLoadedDownloadBadge = true + } + await store.receive(\.fetchDownloadBadge) + await store.receive(\.fetchDownloadBadgeDone, .queued) { + $0.downloadBadge = .queued + $0.hasLoadedDownloadBadge = true + } + } + + @MainActor + func testDetailReducerLaunchAutomationWaitsForResolvedDownloadBadge() async { + let capturedPayload = UncheckedBox(nil) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let options = DownloadOptionsSnapshot() + var initialState = DetailReducer.State() + initialState.gallery = gallery + initialState.galleryDetail = detail + initialState.galleryPreviewURLs = [ + 1: URL(string: "https://example.com/1.jpg")! + ] + + setenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID", gallery.gid, 1) + defer { unsetenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID") } + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, .queued) }) + }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { payload in + capturedPayload.value = payload + return .success(()) + }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.runLaunchAutomationIfNeeded(options)) + XCTAssertNil(capturedPayload.value) + XCTAssertFalse(store.state.didRunLaunchAutomation) + + await store.send(.fetchDownloadBadgeDone(.none)) { + $0.hasLoadedDownloadBadge = true + } + await store.send(.runLaunchAutomationIfNeeded(options)) { + $0.didRunLaunchAutomation = true + } + await store.receive(\.startDownload, options) + await store.skipReceivedActions(strict: false) + + XCTAssertEqual(capturedPayload.value?.gallery.gid, gallery.gid) + } + + @MainActor + func testDetailReducerLaunchAutomationDoesNotRedownloadWhenBadgeIsResolved() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let options = DownloadOptionsSnapshot() + var initialState = DetailReducer.State() + initialState.gallery = gallery + initialState.galleryDetail = detail + + setenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID", gallery.gid, 1) + defer { unsetenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID") } + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .noop + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.fetchDownloadBadgeDone(.downloaded)) { + $0.downloadBadge = .downloaded + $0.hasLoadedDownloadBadge = true + } + await store.send(.runLaunchAutomationIfNeeded(options)) { + $0.didRunLaunchAutomation = true + } + } + + @MainActor + func testDetailReducerIgnoresStartDownloadWhilePreparing() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let enqueueCount = UncheckedBox(0) + let options = DownloadOptionsSnapshot() + + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + initialState.galleryDetail = detail + initialState.isPreparingDownload = true + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in + enqueueCount.value += 1 + return .success(()) + }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + + await store.send(.startDownload(options)) + + XCTAssertEqual(enqueueCount.value, 0) + XCTAssertTrue(store.state.isPreparingDownload) + XCTAssertEqual(store.state.downloadBadge, .none) + } + + @MainActor + func testDetailReducerTogglesPauseForActiveDownload() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let togglePauseCount = UncheckedBox(0) + + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + initialState.galleryDetail = detail + initialState.downloadBadge = .downloading(7, 26) + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, .paused(7, 26)) }) + }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in + togglePauseCount.value += 1 + return .success(()) + }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.toggleDownloadPause) { + $0.isPreparingDownload = true + } + await store.receive(\.toggleDownloadPauseDone) { + $0.isPreparingDownload = false + $0.downloadBadge = .paused(7, 26) + $0.hasLoadedDownloadBadge = true + } + await store.receive(\.fetchDownloadBadge) + await store.receive(\.fetchDownloadBadgeDone, .paused(7, 26)) { + $0.downloadBadge = .paused(7, 26) + $0.hasLoadedDownloadBadge = true + } + + XCTAssertEqual(togglePauseCount.value, 1) + XCTAssertEqual(store.state.downloadBadge, .paused(7, 26)) + XCTAssertFalse(store.state.isPreparingDownload) + } + + @MainActor + func testDetailReducerObservesDownloadBadgeTransitions() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let continuationBox = UncheckedBox.Continuation?>(nil) + let stream = AsyncStream<[DownloadedGallery]> { continuation in + continuationBox.value = continuation + } + + var initialState = DetailReducer.State() + initialState.gallery = gallery + initialState.galleryDetail = detail + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { stream }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) + }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.onAppear(gallery.gid, false)) { + $0.gid = gallery.gid + $0.showsNewDawnGreeting = false + $0.hasLoadedDownloadBadge = false + $0.didRunLaunchAutomation = false + } + await store.skipReceivedActions(strict: false) + + continuationBox.value?.yield([ + sampleDownload(gid: gallery.gid, title: gallery.title, status: .queued) + ]) + await store.receive(\.observeDownloadDone) { + $0.downloadBadge = .queued + $0.hasLoadedDownloadBadge = true + } + + continuationBox.value?.yield([ + sampleDownload( + gid: gallery.gid, + title: gallery.title, + status: .downloading, + pageCount: 26, + completedPageCount: 7 + ) + ]) + await store.receive(\.observeDownloadDone) { + $0.downloadBadge = .downloading(7, 26) + $0.hasLoadedDownloadBadge = true + } + + continuationBox.value?.yield([ + sampleDownload( + gid: gallery.gid, + title: gallery.title, + status: .completed, + pageCount: 26, + completedPageCount: 26 + ) + ]) + await store.receive(\.observeDownloadDone) { + $0.downloadBadge = .downloaded + $0.hasLoadedDownloadBadge = true + } + + continuationBox.value?.finish() + } + + @MainActor + func testDetailReducerOpenReadingUsesLocalManifestWhenAvailable() async { + let download = sampleDownload( + gid: "888", + title: "Offline Archive", + status: .completed, + pageCount: 2 + ) + let manifest = sampleManifest(gid: download.gid, title: download.title) + var initialState = DetailReducer.State(download: download) + initialState.galleryDetail = sampleGalleryDetail(gid: download.gid, title: download.title) + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [download] }, + fetchDownload: { gid in gid == download.gid ? download : nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, .downloaded) }) + }, + updateRemoteSignature: { _, _ in .downloaded }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { gid in + gid == download.gid + ? .success((download, manifest)) + : .failure(.notFound) + } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.openReading) + await store.skipReceivedActions(strict: false) + + XCTAssertEqual(store.state.readingState.contentSource, .local(download, manifest)) + if case .reading = store.state.route { + } else { + XCTFail("Expected reading route to be active.") + } + } + + @MainActor + func testDetailReducerOpenReadingFallsBackToRemoteWhenManifestUnavailable() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + initialState.galleryDetail = detail + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.openReading) + await store.skipReceivedActions(strict: false) + + XCTAssertEqual(store.state.readingState.contentSource, .remote) + if case .reading = store.state.route { + } else { + XCTFail("Expected reading route to be active.") + } + } + + @MainActor + func testPreviewsReducerOpenReadingUsesLocalManifestWhenAvailable() async { + let download = sampleDownload( + gid: "991", + title: "Preview Download", + status: .completed, + pageCount: 2, + completedPageCount: 2 + ) + let manifest = sampleManifest(gid: download.gid, title: download.title) + var initialState = PreviewsReducer.State() + initialState.gallery = download.gallery + + let store = TestStore(initialState: initialState) { + PreviewsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [download] }, + fetchDownload: { gid in gid == download.gid ? download : nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { gid in + gid == download.gid + ? .success((download, manifest)) + : .failure(.notFound) + } + ) + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + store.exhaustivity = .off + + await store.send(.openReading(1)) + await store.skipReceivedActions(strict: false) + + if case .local(let actualDownload, let actualManifest) = store.state.readingState.contentSource { + XCTAssertEqual(actualDownload, download) + XCTAssertEqual(actualManifest, manifest) + } else { + XCTFail("Expected previews to open local reading content.") + } + if case .reading = store.state.route { + } else { + XCTFail("Expected reading route to be active.") + } + } + + @MainActor + func testPreviewsReducerClearsLocalPreviewURLsWhenObservedDownloadDisappears() async { + let gallery = sampleGallery() + let localURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") + var initialState = PreviewsReducer.State() + initialState.gallery = gallery + initialState.localPreviewURLs = [1: localURL] + + let store = TestStore(initialState: initialState) { + PreviewsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in .success([:]) } + ) + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + store.exhaustivity = .off + + await store.send(.observeDownloadsDone([])) + await store.receive(\.loadLocalPreviewURLs) + let requestID = store.state.localPreviewRequestID + await store.receive(\.loadLocalPreviewURLsDone) { + $0.localPreviewURLs = [:] + } + XCTAssertEqual(store.state.localPreviewRequestID, requestID) + } + + @MainActor + func testPreviewsReducerRemoteFallbackKeepsExistingLocalPreviewPages() async { + let gallery = sampleGallery() + let localURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") + var initialState = PreviewsReducer.State() + initialState.gallery = gallery + initialState.localPreviewURLs = [1: localURL] + + let store = TestStore(initialState: initialState) { + PreviewsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + store.exhaustivity = .off + + await store.send(.openReading(1)) + await store.receive(\.openReadingDone) + guard case .reading = store.state.route else { + XCTFail("Expected previews route to enter reading") + return + } + XCTAssertEqual(store.state.readingState.contentSource, .remote) + XCTAssertEqual(store.state.readingState.localPageURLs, [1: localURL]) + } + + @MainActor + func testDetailReducerDownloadedContextStoresVersionMetadataResult() async { + let download = sampleDownload( + gid: "889", + title: "Offline Archive", + status: .completed, + pageCount: 2 + ) + let detail = sampleGalleryDetail(gid: download.gid, title: download.title) + var initialState = DetailReducer.State(download: download) + initialState.galleryDetail = detail + let metadata = DownloadVersionMetadata( + gid: detail.gid, + token: download.token, + currentGID: "990", + currentKey: "chain-key", + parentGID: download.gid, + parentKey: download.token, + firstGID: download.gid, + firstKey: download.token + ) + + let store = TestStore(initialState: initialState) { + DetailReducer() + } + + await store.send( + .fetchVersionMetadataDone(.success(metadata)) + ) { + $0.galleryVersionMetadata = metadata + } + } + + @MainActor + func testReadingReducerRemoteSourceLoadsLocalPagesAndSkipsRemoteFetchForDownloadedPage() async throws { + let gallery = sampleGallery() + let localPageURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") + let remotePageURL = URL(string: "https://example.com/pages/0001.jpg")! + var initialState = ReadingReducer.State(contentSource: .remote) + initialState.gallery = gallery + initialState.imageURLs = [1: remotePageURL] + + let store = TestStore( + initialState: initialState + ) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.yield([]) + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { gid in + gid == gallery.gid ? .success([1: localPageURL]) : .failure(.notFound) + } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + + await store.send(.loadLocalPageURLs(gallery.gid)) + let requestID = store.state.localPageRequestID + await store.receive(\.loadLocalPageURLsDone) { + $0.localPageURLs = [1: localPageURL] + } + XCTAssertEqual(store.state.localPageRequestID, requestID) + + XCTAssertEqual(store.state.localPageURLs[1], localPageURL) + + await store.send(.fetchImageURLs(1)) { + $0.imageURLLoadingStates[1] = .idle + } + } + + @MainActor + func testReadingReducerOnWebImageSucceededCapturesCachedPageIntoDownloadProgress() async { + let capturedCalls = UncheckedBox([(String, Int, URL?)]()) + let gallery = sampleGallery() + let remotePageURL = URL(string: "https://example.com/pages/0001.jpg")! + var initialState = ReadingReducer.State(contentSource: .remote) + initialState.gallery = gallery + initialState.imageURLs = [1: remotePageURL] + + let store = TestStore(initialState: initialState) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + captureCachedPage: { gid, index, imageURL in + capturedCalls.value.append((gid, index, imageURL)) + } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + + await store.send(.onWebImageSucceeded(1)) { + $0.imageURLLoadingStates[1] = .idle + $0.webImageLoadSuccessIndices.insert(1) + } + await store.receive(\.captureCachedPage) + + XCTAssertEqual(capturedCalls.value.count, 1) + XCTAssertEqual(capturedCalls.value.first?.0, gallery.gid) + XCTAssertEqual(capturedCalls.value.first?.1, 1) + XCTAssertEqual(capturedCalls.value.first?.2, remotePageURL) + } + + @MainActor + func testReadingReducerOnWebImageSucceededDoesNotCaptureAlreadyLocalPage() async { + let capturedCalls = UncheckedBox([(String, Int, URL?)]()) + let gallery = sampleGallery() + let localPageURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("0001.jpg") + var initialState = ReadingReducer.State(contentSource: .remote) + initialState.gallery = gallery + initialState.localPageURLs = [1: localPageURL] + + let store = TestStore(initialState: initialState) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + captureCachedPage: { gid, index, imageURL in + capturedCalls.value.append((gid, index, imageURL)) + } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + + await store.send(.onWebImageSucceeded(1)) { + $0.imageURLLoadingStates[1] = .idle + $0.webImageLoadSuccessIndices.insert(1) + } + await store.finish() + + XCTAssertTrue(capturedCalls.value.isEmpty) + } + + @MainActor + func testReadingReducerLocalSourceLoadsOfflineImagesWithoutNetwork() async throws { + let download = sampleDownload( + gid: "777", + title: "Offline Archive", + status: .completed, + pageCount: 2 + ) + let manifest = sampleManifest(gid: download.gid, title: download.title) + let folderURL = try prepareLocalDownloadFiles(download: download, manifest: manifest) + defer { try? FileManager.default.removeItem(at: folderURL) } + + let store = TestStore( + initialState: ReadingReducer.State(contentSource: .local(download, manifest)) + ) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + + await store.send(.fetchDatabaseInfos(download.gid)) + XCTAssertEqual(store.state.gallery.id, download.gid) + XCTAssertEqual(store.state.imageURLs[1], folderURL.appendingPathComponent("pages/0001.jpg")) + XCTAssertEqual(store.state.imageURLs[2], folderURL.appendingPathComponent("pages/0002.jpg")) + + await store.send(.fetchImageURLs(1)) { + $0.imageURLLoadingStates[1] = .idle + } + await store.send(.reloadAllWebImages) + + XCTAssertEqual(store.state.imageURLs[1], folderURL.appendingPathComponent("pages/0001.jpg")) + XCTAssertEqual(store.state.imageURLs[2], folderURL.appendingPathComponent("pages/0002.jpg")) + } + + @MainActor + func testDownloadManagerCaptureCachedPageRestoresTemporaryPageAndUpdatesCompletedCount() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 27) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + try await insertPersistedDownload( + gid: gid, + status: .downloading, + completedPageCount: 0, + pageCount: 2 + ) + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + + let imageURL = try XCTUnwrap(URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg")) + let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemBlue.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try XCTUnwrap(image.jpegData(compressionQuality: 1)) + let cacheKey = try XCTUnwrap(imageURL.stableImageCacheKey) + KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) + defer { + KingfisherManager.shared.cache.removeImage(forKey: cacheKey) + KingfisherManager.shared.cache.removeImage(forKey: imageURL.absoluteString) + } + + await manager.captureCachedPage( + gid: gid, + index: 1, + imageURL: imageURL + ) + + let stored = await manager.testingFetchDownload(gid: gid) + XCTAssertEqual(stored?.completedPageCount, 1) + + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + XCTAssertEqual( + pageURLs[1], + temporaryFolderURL.appendingPathComponent("pages/0001.jpg") + ) + } + + @MainActor + func testDownloadManagerCaptureCachedPageRepairsCompletedDownloadWithLatestRemoteImage() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 28) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + try await insertPersistedDownload( + gid: gid, + status: .missingFiles, + completedPageCount: 1, + pageCount: 2, + lastError: .init(code: .fileOperationFailed, message: "Page 1 is missing.") + ) + + let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let manifest = sampleManifest(gid: gid, title: "Pause Race") + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) + + let imageURL = try XCTUnwrap(URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg")) + let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemOrange.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try XCTUnwrap(image.jpegData(compressionQuality: 1)) + let cacheKey = try XCTUnwrap(imageURL.stableImageCacheKey) + KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) + defer { + KingfisherManager.shared.cache.removeImage(forKey: cacheKey) + KingfisherManager.shared.cache.removeImage(forKey: imageURL.absoluteString) + } + + await manager.captureCachedPage( + gid: gid, + index: 1, + imageURL: imageURL + ) + + let stored = await manager.testingFetchDownload(gid: gid) + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + + XCTAssertEqual(stored?.status, .completed) + XCTAssertEqual(stored?.completedPageCount, 2) + XCTAssertNil(stored?.lastError) + XCTAssertEqual( + pageURLs[1], + completedFolderURL.appendingPathComponent("pages/0001.jpg") + ) + } + + @MainActor + func testDownloadManagerReconcileNormalizesFailedDownloadBeforeTempCleanup() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 31) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + try await insertPersistedDownload( + gid: gid, + status: .failed, + completedPageCount: 0, + pageCount: 2, + lastError: .init(code: .networkingFailed, message: "Network Error") + ) + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try Data([0x01]).write( + to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + + await manager.reconcileDownloads() + + let stored = await manager.testingFetchDownload(gid: gid) + let localPages = try await manager.loadLocalPageURLs(gid: gid).get() + + XCTAssertEqual(stored?.status, .partial) + XCTAssertEqual(stored?.completedPageCount, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: temporaryFolderURL.path)) + XCTAssertEqual( + localPages[1], + temporaryFolderURL.appendingPathComponent("pages/0001.jpg") + ) + } + + @MainActor + func testUpdateRemoteSignatureDoesNotMarkUpdateAvailableWhenStoredChainAndLatestHashAreDifferentKinds() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 101) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let manager = DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: .shared + ) + try await insertPersistedDownload( + gid: gid, + status: .completed, + completedPageCount: 26, + token: "token", + remoteVersionSignature: "chain:\(gid):token" + ) + + let badge = await manager.updateRemoteSignature(gid: gid, latestSignature: "hash:new") + let stored = await manager.testingFetchDownload(gid: gid) + + XCTAssertEqual(badge, .downloaded) + XCTAssertEqual(stored?.status, .completed) + XCTAssertEqual(stored?.remoteVersionSignature, "chain:\(gid):token") + XCTAssertEqual(stored?.latestRemoteVersionSignature, "hash:new") + } + + @MainActor + func testUpdateRemoteSignatureDoesNotMarkUpdateAvailableWhenStoredHashAndLatestNonOriginalChainAreDifferentKinds() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 102) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let manager = DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: .shared + ) + try await insertPersistedDownload( + gid: gid, + status: .completed, + completedPageCount: 26, + token: "token", + remoteVersionSignature: "hash:old" + ) + + let badge = await manager.updateRemoteSignature( + gid: gid, + latestSignature: "chain:othergid:othertoken" + ) + let stored = await manager.testingFetchDownload(gid: gid) + + XCTAssertEqual(badge, .downloaded) + XCTAssertEqual(stored?.status, .completed) + XCTAssertEqual(stored?.remoteVersionSignature, "hash:old") + XCTAssertEqual(stored?.latestRemoteVersionSignature, "chain:othergid:othertoken") + } + + @MainActor + func testUpdateRemoteSignatureCanonicalizesStoredHashToOriginalChainWithoutMarkingUpdate() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 103) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let manager = DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: .shared + ) + try await insertPersistedDownload( + gid: gid, + status: .completed, + completedPageCount: 26, + token: "token", + remoteVersionSignature: "hash:old" + ) + + let badge = await manager.updateRemoteSignature( + gid: gid, + latestSignature: "chain:\(gid):token" + ) + let stored = await manager.testingFetchDownload(gid: gid) + + XCTAssertEqual(badge, .downloaded) + XCTAssertEqual(stored?.status, .completed) + XCTAssertEqual(stored?.remoteVersionSignature, "chain:\(gid):token") + XCTAssertEqual(stored?.latestRemoteVersionSignature, "chain:\(gid):token") + } + + @MainActor + func testDetailReducerDoesNotRequestVersionMetadataForUndownloadedGallery() async { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + var galleryState = GalleryState(gid: gallery.gid) + galleryState.previewURLs = [1: URL(string: "https://example.com/1t.jpg")!] + galleryState.previewConfig = .normal(rows: 4) + + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) + }, + updateRemoteSignature: { _, _ in + updateCheckCount.value += 1 + return .none + }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send( + .fetchGalleryDetailDone( + .success((detail, galleryState, "", nil)) + ) + ) + await store.skipReceivedActions(strict: false) + + XCTAssertEqual(updateCheckCount.value, 0) + XCTAssertNil(store.state.galleryVersionMetadata) + XCTAssertFalse(store.state.shouldCheckForRemoteUpdates) + } + + @MainActor + func testDetailReducerRequestsVersionMetadataWhenBadgeArrivesAfterDetail() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let galleryState = sampleGalleryState(gid: gallery.gid) + try installGalleryVersionMetadataStub(for: gallery) + defer { uninstallSharedSessionStub() } + + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) + }, + updateRemoteSignature: { _, _ in + updateCheckCount.value += 1 + return .downloaded + }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in .success([:]) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.fetchGalleryDetailDone(.success((detail, galleryState, "", nil)))) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(updateCheckCount.value, 0) + + await store.send(.fetchDownloadBadgeDone(.downloaded)) + await drainDetailMetadataEffects( + store, + condition: { + updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil + } + ) + + XCTAssertEqual(updateCheckCount.value, 1) + XCTAssertTrue(store.state.shouldCheckForRemoteUpdates) + XCTAssertTrue(store.state.didRequestVersionMetadata) + XCTAssertNotNil(store.state.galleryVersionMetadata) + } + + @MainActor + func testDetailReducerRequestsVersionMetadataWhenBadgeArrivesBeforeDetail() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let galleryState = sampleGalleryState(gid: gallery.gid) + try installGalleryVersionMetadataStub(for: gallery) + defer { uninstallSharedSessionStub() } + + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, .downloaded) }) + }, + updateRemoteSignature: { _, _ in + updateCheckCount.value += 1 + return .downloaded + }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in .success([:]) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.fetchDownloadBadgeDone(.downloaded)) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(updateCheckCount.value, 0) + + await store.send(.fetchGalleryDetailDone(.success((detail, galleryState, "", nil)))) + await drainDetailMetadataEffects( + store, + condition: { + updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil + } + ) + + XCTAssertEqual(updateCheckCount.value, 1) + XCTAssertTrue(store.state.shouldCheckForRemoteUpdates) + XCTAssertTrue(store.state.didRequestVersionMetadata) + XCTAssertNotNil(store.state.galleryVersionMetadata) + } + + @MainActor + func testDetailReducerObserveDownloadDoneAlsoTriggersMetadataCheckWithoutDuplicateRequests() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + try installGalleryVersionMetadataStub(for: gallery) + defer { uninstallSharedSessionStub() } + + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + initialState.galleryDetail = detail + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in + updateCheckCount.value += 1 + return .downloaded + }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in .success([:]) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.observeDownloadDone(.downloaded)) + await drainDetailMetadataEffects( + store, + condition: { updateCheckCount.value == 1 } + ) + XCTAssertEqual(updateCheckCount.value, 1) + + await store.send(.observeDownloadDone(.downloaded)) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(updateCheckCount.value, 1) + } + + @MainActor + func testDetailReducerRemoteUpdateFlagDoesNotStayStickyWhenBadgeReturnsToNone() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + try installGalleryVersionMetadataStub(for: gallery) + defer { uninstallSharedSessionStub() } + + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + initialState.galleryDetail = detail + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in + updateCheckCount.value += 1 + return .downloaded + }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in .success([:]) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.fetchDownloadBadgeDone(.downloaded)) + await drainDetailMetadataEffects( + store, + condition: { + updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil + } + ) + XCTAssertEqual(updateCheckCount.value, 1) + XCTAssertTrue(store.state.shouldCheckForRemoteUpdates) + XCTAssertTrue(store.state.didRequestVersionMetadata) + + await store.send(.fetchDownloadBadgeDone(.none)) { + $0.downloadBadge = .none + $0.hasLoadedDownloadBadge = true + $0.shouldCheckForRemoteUpdates = false + $0.didRequestVersionMetadata = false + $0.galleryVersionMetadata = nil + } + await store.skipReceivedActions(strict: false) + + XCTAssertFalse(store.state.shouldCheckForRemoteUpdates) + XCTAssertFalse(store.state.didRequestVersionMetadata) + XCTAssertNil(store.state.galleryVersionMetadata) + } + + @MainActor + func testDetailReducerDeleteDownloadResetsDownloadContext() async { + let download = sampleDownload( + gid: "7733", + title: "Reset Context", + status: .completed + ) + var initialState = DetailReducer.State(download: download) + initialState.galleryVersionMetadata = sampleVersionMetadata( + gid: download.gid, + token: download.token + ) + initialState.didRequestVersionMetadata = true + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [download] }, + fetchDownload: { gid in gid == download.gid ? download : nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) + }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in .success([:]) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.deleteDownloadDone(.success(()))) { + $0.galleryVersionMetadata = nil + $0.didRequestVersionMetadata = false + $0.isDownloadContext = false + $0.shouldCheckForRemoteUpdates = false + } + await store.skipReceivedActions(strict: false) + + XCTAssertFalse(store.state.isDownloadContext) + XCTAssertFalse(store.state.shouldCheckForRemoteUpdates) + XCTAssertFalse(store.state.didRequestVersionMetadata) + XCTAssertNil(store.state.galleryVersionMetadata) + } + + func testFileBasedQuotaImageMapsToQuotaExceeded() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadManager() + let response = makeResponse( + url: URL(string: "https://ehgt.org/g/509.gif")!, + contentType: "image/gif", + contentLength: 28658 + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://ehgt.org/g/509.gif") + ) + + XCTAssertEqual(error, .quotaExceeded) + } + + func testFileBasedQuotaImageRequiresKnown509Signature() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadManager() + var data = try Data(contentsOf: fileURL) + data[0] = 0 + try data.write(to: fileURL, options: .atomic) + let response = makeResponse( + url: URL(string: "https://ehgt.org/g/509.gif")!, + contentType: "image/gif", + contentLength: data.count + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://ehgt.org/g/509.gif") + ) + + XCTAssertNil(error) + } + + func testFileBasedBinaryKokomadeImageMapsToAuthenticationRequired() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("gif") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let imageData = try XCTUnwrap(Data(base64Encoded: "R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")) + try imageData.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let response = makeResponse( + url: URL(string: "https://exhentai.org/img/kokomade.jpg")!, + contentType: "image/gif", + contentLength: imageData.count + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1") + ) + + XCTAssertEqual(error, .authenticationRequired) + } + + func testFileBasedQuotaImageFingerprintMapsToQuotaExceededEvenWhenURLLooksNormal() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadManager() + let normalImageURL = try XCTUnwrap(URL(string: "https://ehgt.org/h/normal-image-cache-key/1")) + let response = makeResponse( + url: normalImageURL, + contentType: "image/gif", + contentLength: 28658 + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: normalImageURL + ) + + XCTAssertEqual(error, .quotaExceeded) + } + + func testFileBasedKokomadeImageFingerprintMapsToAuthenticationRequiredEvenWhenURLLooksNormal() async throws { + let fileURL = try writeFixtureToTemporaryFile(resource: "Kokomade", pathExtension: "jpg") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadManager() + let normalImageURL = try XCTUnwrap(URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1&key=normal-cache-key")) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: makeResponse( + url: normalImageURL, + contentType: "image/jpeg", + contentLength: 144844 + ), + requestURL: normalImageURL + ) + + XCTAssertEqual(error, .authenticationRequired) + } + + func testFileBasedTextImageLimitMapsToQuotaExceeded() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + try """ + You have exceeded your image viewing limits + """.data(using: .utf8)!.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let response = makeResponse( + url: URL(string: "https://e-hentai.org/s/1/1-1")!, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://e-hentai.org/s/1/1-1") + ) + + XCTAssertEqual(error, .quotaExceeded) + } + + @MainActor + func testCachedQuotaPlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 32) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + let normalImageURL = try XCTUnwrap( + URL(string: "https://ehgt.org/h/quota-placeholder-cache-\(gid)/1") + ) + try await insertPersistedGalleryState(gid: gid, imageURLs: [1: normalImageURL]) + + let placeholderURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) + defer { try? FileManager.default.removeItem(at: placeholderURL) } + let placeholderData = try Data(contentsOf: placeholderURL) + let cacheKeys = normalImageURL.imageCacheKeys(includeStableAlias: true) + for cacheKey in cacheKeys { + KingfisherManager.shared.cache.storeToDisk(placeholderData, forKey: cacheKey) + } + defer { + cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } + } + + let payload = DownloadRequestPayload( + gallery: Gallery( + gid: gid, + token: "token", + title: "Quota Placeholder", + rating: 4, + tags: [], + category: .doujinshi, + uploader: "Uploader", + pageCount: 1, + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token")! + ), + galleryDetail: GalleryDetail( + gid: gid, + title: "Quota Placeholder", + jpnTitle: nil, + isFavorited: false, + visibility: .yes, + rating: 4, + userRating: 0, + ratingCount: 0, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + favoritedCount: 0, + pageCount: 1, + sizeCount: 12, + sizeType: "MB", + torrentCount: 0 + ), + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + options: DownloadOptionsSnapshot(), + mode: .initial + ) + + let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) + let restoredPageURL = storage.temporaryFolderURL(gid: gid) + .appendingPathComponent("pages/0001.gif") + + XCTAssertEqual(restoredCount, 0) + XCTAssertFalse(FileManager.default.fileExists(atPath: restoredPageURL.path)) + } + + @MainActor + func testCachedKokomadePlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 33) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + let normalImageURL = try XCTUnwrap(URL(string: "https://exhentai.org/fullimg.php?gid=\(gid)&page=1&key=normal-cache-key")) + try await insertPersistedGalleryState(gid: gid, imageURLs: [1: normalImageURL]) + + let imageData = try fixtureData(resource: "Kokomade", pathExtension: "jpg") + let cacheKeys = normalImageURL.imageCacheKeys(includeStableAlias: true) + for cacheKey in cacheKeys { + KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) + } + defer { + cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } + } + + let payload = DownloadRequestPayload( + gallery: Gallery( + gid: gid, + token: "token", + title: "Auth Placeholder", + rating: 4, + tags: [], + category: .doujinshi, + uploader: "Uploader", + pageCount: 1, + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + galleryURL: URL(string: "https://exhentai.org/g/\(gid)/token")! + ), + galleryDetail: GalleryDetail( + gid: gid, + title: "Auth Placeholder", + jpnTitle: nil, + isFavorited: false, + visibility: .yes, + rating: 4, + userRating: 0, + ratingCount: 0, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + favoritedCount: 0, + pageCount: 1, + sizeCount: 12, + sizeType: "MB", + torrentCount: 0 + ), + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .exhentai, + options: DownloadOptionsSnapshot(), + mode: .initial + ) + + let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) + let restoredPageURL = storage.temporaryFolderURL(gid: gid) + .appendingPathComponent("pages/0001.jpg") + + XCTAssertEqual(restoredCount, 0) + XCTAssertFalse(FileManager.default.fileExists(atPath: restoredPageURL.path)) + } + + func testFileBasedEmptyExResponseMapsToAuthenticationRequired() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .exLoginRequired) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let cookieClient = CookieClient.live + cookieClient.clearAll() + defer { cookieClient.clearAll() } + cookieClient.setOrEditCookie( + for: Defaults.URL.exhentai, + key: Defaults.Cookie.yay, + value: "louder" + ) + + let manager = makeTestingDownloadManager() + let response = makeResponse( + url: Defaults.URL.exhentai, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://exhentai.org/g/1/1/") + ) + + XCTAssertEqual(error, .authenticationRequired) + } + + func testFileBasedAuthHTMLMarkersMapToAuthenticationRequired() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + try """ + + + Login + +

Access to ExHentai.org is restricted.

+ + + """.data(using: .utf8)!.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let response = makeResponse( + url: Defaults.URL.exhentai, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://exhentai.org/g/1/1/") + ) + + XCTAssertEqual(error, .authenticationRequired) + } + + func testFileBasedInvalidPageMapsToNotFound() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + try """ +

Invalid page

Gallery not found

+ """.data(using: .utf8)!.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let response = makeResponse( + url: URL(string: "https://e-hentai.org/g/1/1/")!, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://e-hentai.org/g/1/1/") + ) + + XCTAssertEqual(error, .notFound) + } + + func testFileBasedKeepTryingMapsToNotFound() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + try "

Keep trying

" + .data(using: .utf8)! + .write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let response = makeResponse( + url: URL(string: "https://e-hentai.org/s/1/1-1")!, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://e-hentai.org/s/1/1-1") + ) + + XCTAssertEqual(error, .notFound) + } + + func testFileBasedHTTP404MapsToNotFound() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("txt") + defer { try? FileManager.default.removeItem(at: fileURL) } + + try Data("Not here".utf8).write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let response = makeResponse( + url: URL(string: "https://e-hentai.org/g/1/1/")!, + statusCode: 404, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://e-hentai.org/g/1/1/") + ) + + XCTAssertEqual(error, .notFound) + } + + func testFileBased404GalleryNotAvailableFallsBackToNotFound() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + try """ + + Gallery Not Available +

Gallery Not Available

+ + """.data(using: .utf8)!.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let response = makeResponse( + url: URL(string: "https://e-hentai.org/g/1/1/")!, + statusCode: 404, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://e-hentai.org/g/1/1/") + ) + + XCTAssertEqual(error, .notFound) + } + + func testFileBasedHTMLBanPageStillParsesThroughParserInsteadOfParseFailed() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .ipBanned) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadManager() + let response = makeResponse( + url: URL(string: "https://example.com/banned")!, + contentType: "text/html; charset=utf-8" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://example.com/banned") + ) + + XCTAssertNotEqual(error, .parseFailed) + guard case .ipBanned = error else { + return XCTFail("Expected ipBanned, got \(String(describing: error))") + } + } + + func testIpBannedDoesNotRetryImmediately() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + let manager = DownloadManager( + storage: DownloadFileStorage( + rootURL: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true), + fileManager: .default + ), + urlSession: URLSession(configuration: configuration) + ) + let recorder = RequestRecorder() + let ipBannedHTML = try fixtureData(resource: HTMLFilename.ipBanned.rawValue, pathExtension: "html") + SharedSessionStubURLProtocol.requestHandler = { request in + recorder.recordDetail() + return ( + HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com/banned")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )!, + ipBannedHTML + ) + } + defer { + SharedSessionStubURLProtocol.requestHandler = nil + } + + let download = sampleDownload( + gid: "123456", + title: "Banned Gallery", + status: .partial + ) + + do { + _ = try await manager.testingFetchLatestPayload( + for: download, + mode: .redownload + ) + XCTFail("Expected ipBanned error") + } catch let error as AppError { + guard case .ipBanned = error else { + return XCTFail("Expected ipBanned, got \(error)") + } + } + + XCTAssertEqual(recorder.snapshot().detailRequests, 1) + } + + @MainActor + func testReadingReducerLocalSourceWithoutGalleryStateDoesNotStayLoading() async { + let download = sampleDownload( + gid: "700001", + title: "Offline Gallery", + status: .completed, + pageCount: 2, + completedPageCount: 2 + ) + let manifest = sampleManifest(gid: download.gid, title: download.title) + let store = TestStore( + initialState: ReadingReducer.State(contentSource: .local(download, manifest)) + ) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .noop + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + let folderURL = download.folderURL ?? FileManager.default.temporaryDirectory + .appendingPathComponent(download.folderRelativePath, isDirectory: true) + + await store.send(.fetchDatabaseInfos(download.gid)) { + $0.gallery = download.gallery + $0.galleryDetail = GalleryDetail( + gid: download.gid, + title: download.title, + jpnTitle: download.jpnTitle, + isFavorited: false, + visibility: .yes, + rating: download.rating, + userRating: 0, + ratingCount: 0, + category: download.category, + language: manifest.language, + uploader: download.uploader ?? "", + postedDate: download.postedDate, + coverURL: download.coverURL, + favoritedCount: 0, + pageCount: download.pageCount, + sizeCount: 0, + sizeType: "", + torrentCount: 0 + ) + $0.localPageURLs = [ + 1: folderURL.appendingPathComponent("pages/0001.jpg"), + 2: folderURL.appendingPathComponent("pages/0002.jpg") + ] + $0.previewConfig = .normal(rows: 4) + $0.previewURLs = $0.localPageURLs + $0.thumbnailURLs = $0.localPageURLs + $0.imageURLs = $0.localPageURLs + $0.originalImageURLs = $0.localPageURLs + $0.databaseLoadingState = .idle + } + await store.finish() + + XCTAssertEqual(store.state.databaseLoadingState, .idle) + XCTAssertEqual(store.state.readingProgress, 0) + } + + @MainActor + func testReadingReducerDoesNotReloadLocalPagesWhenOnlyOtherGalleryChanges() async { + let gallery = sampleGallery() + let relevantDownload = sampleDownload( + gid: gallery.gid, + title: gallery.title, + status: .completed + ) + let otherDownload = sampleDownload( + gid: "900001", + title: "Other Gallery", + status: .queued + ) + let updatedOtherDownload = sampleDownload( + gid: otherDownload.gid, + title: otherDownload.title, + status: .downloading, + pageCount: 12, + completedPageCount: 4 + ) + let continuationBox = UncheckedBox.Continuation?>(nil) + let stream = AsyncStream<[DownloadedGallery]> { continuation in + continuationBox.value = continuation + } + let loadCount = UncheckedBox(0) + + var initialState = ReadingReducer.State(contentSource: .remote) + initialState.gallery = gallery + + let store = TestStore(initialState: initialState) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { stream }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { gid in + XCTAssertEqual(gid, gallery.gid) + loadCount.value += 1 + return .success([:]) + } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + + await store.send(.observeDownloads(gallery.gid)) + + continuationBox.value?.yield([relevantDownload, otherDownload]) + await store.receive(\.observeDownloadsDone, [relevantDownload]) + await store.receive(\.loadLocalPageURLs, gallery.gid) + await store.receive(\.loadLocalPageURLsDone) + XCTAssertEqual(loadCount.value, 1) + + continuationBox.value?.yield([relevantDownload, updatedOtherDownload]) + try? await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(loadCount.value, 1) + + continuationBox.value?.finish() + await store.finish() + } + + @MainActor + func testPreviewsReducerDoesNotReloadLocalPreviewsWhenOnlyOtherGalleryChanges() async { + let gallery = sampleGallery() + let relevantDownload = sampleDownload( + gid: gallery.gid, + title: gallery.title, + status: .completed + ) + let otherDownload = sampleDownload( + gid: "900002", + title: "Other Preview Gallery", + status: .queued + ) + let updatedOtherDownload = sampleDownload( + gid: otherDownload.gid, + title: otherDownload.title, + status: .paused, + pageCount: 12, + completedPageCount: 2 + ) + let continuationBox = UncheckedBox.Continuation?>(nil) + let stream = AsyncStream<[DownloadedGallery]> { continuation in + continuationBox.value = continuation + } + let loadCount = UncheckedBox(0) + + var initialState = PreviewsReducer.State() + initialState.gallery = gallery + + let store = TestStore(initialState: initialState) { + PreviewsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { stream }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { gid in + XCTAssertEqual(gid, gallery.gid) + loadCount.value += 1 + return .success([:]) + } + ) + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + store.exhaustivity = .off + + await store.send(.observeDownloads(gallery.gid)) + + continuationBox.value?.yield([relevantDownload, otherDownload]) + await store.receive(\.observeDownloadsDone, [relevantDownload]) + await store.receive(\.loadLocalPreviewURLs, gallery.gid) + await store.receive(\.loadLocalPreviewURLsDone) + XCTAssertEqual(loadCount.value, 1) + + continuationBox.value?.yield([relevantDownload, updatedOtherDownload]) + try? await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(loadCount.value, 1) + + continuationBox.value?.finish() + await store.finish() + } + + @MainActor + func testReadingAndPreviewsStillEmitOneFinalRefreshWhenRelevantDownloadDisappears() async { + let gallery = sampleGallery() + let relevantDownload = sampleDownload( + gid: gallery.gid, + title: gallery.title, + status: .completed + ) + + let readingContinuationBox = UncheckedBox.Continuation?>(nil) + let readingStream = AsyncStream<[DownloadedGallery]> { continuation in + readingContinuationBox.value = continuation + } + let readingLoadCount = UncheckedBox(0) + var readingState = ReadingReducer.State(contentSource: .remote) + readingState.gallery = gallery + + let readingStore = TestStore(initialState: readingState) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { readingStream }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in + readingLoadCount.value += 1 + return .success([:]) + } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + readingStore.exhaustivity = .off + + await readingStore.send(.observeDownloads(gallery.gid)) + readingContinuationBox.value?.yield([relevantDownload]) + await readingStore.receive(\.observeDownloadsDone, [relevantDownload]) + await readingStore.receive(\.loadLocalPageURLs, gallery.gid) + await readingStore.receive(\.loadLocalPageURLsDone) + + readingContinuationBox.value?.yield([]) + await readingStore.receive(\.observeDownloadsDone, []) + await readingStore.receive(\.loadLocalPageURLs, gallery.gid) + await readingStore.receive(\.loadLocalPageURLsDone) + + XCTAssertEqual(readingLoadCount.value, 2) + readingContinuationBox.value?.finish() + await readingStore.finish() + + let previewsContinuationBox = UncheckedBox.Continuation?>(nil) + let previewsStream = AsyncStream<[DownloadedGallery]> { continuation in + previewsContinuationBox.value = continuation + } + let previewsLoadCount = UncheckedBox(0) + var previewsState = PreviewsReducer.State() + previewsState.gallery = gallery + + let previewsStore = TestStore(initialState: previewsState) { + PreviewsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { previewsStream }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in + previewsLoadCount.value += 1 + return .success([:]) + } + ) + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + previewsStore.exhaustivity = .off + + await previewsStore.send(.observeDownloads(gallery.gid)) + previewsContinuationBox.value?.yield([relevantDownload]) + await previewsStore.receive(\.observeDownloadsDone, [relevantDownload]) + await previewsStore.receive(\.loadLocalPreviewURLs, gallery.gid) + await previewsStore.receive(\.loadLocalPreviewURLsDone) + + previewsContinuationBox.value?.yield([]) + await previewsStore.receive(\.observeDownloadsDone, []) + await previewsStore.receive(\.loadLocalPreviewURLs, gallery.gid) + await previewsStore.receive(\.loadLocalPreviewURLsDone) + + XCTAssertEqual(previewsLoadCount.value, 2) + previewsContinuationBox.value?.finish() + await previewsStore.finish() + } + + @MainActor + func testDownloadInspectorClearsInspectionWhenObservedDownloadDisappears() async { + let download = sampleDownload( + gid: "9988", + title: "Observed Archive", + status: .completed + ) + let inspection = sampleInspection(download: download) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = inspection + initialState.stableInspection = inspection + initialState.retryingPageIndices = [2] + initialState.loadingState = .idle + + let store = TestStore(initialState: initialState) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.yield([download]) + continuation.yield([]) + continuation.finish() + } + }, + fetchDownloads: { [download] }, + fetchDownload: { gid in gid == download.gid ? download : nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in .success(inspection) } + ) + } + store.exhaustivity = .off + + await store.send(.observeDownloads) + await store.receive(\.observeDownloadsDone, [download]) + await store.receive(\.observeDownloadsDone, []) { + $0.inspection = nil + $0.stableInspection = nil + $0.loadingState = .idle + $0.retryingPageIndices = [] + } + } + + @MainActor + func testDownloadManagerBatchesObserverUpdatesDuringCachedPageRestore() async throws { + try await preparePersistenceStore() + try await clearPersistedDownloads() + defer { + Task { + try? await self.clearPersistedDownloads() + } + } + + let pageCount = 20 + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 104) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + try await insertPersistedDownload( + gid: gid, + status: .downloading, + completedPageCount: 0, + pageCount: pageCount + ) + + let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemTeal.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try XCTUnwrap(cachedImage.jpegData(compressionQuality: 1)) + let imageURLs = Dictionary(uniqueKeysWithValues: (1...pageCount).map { index in + (index, URL(string: "https://example.com/pages/\(gid)-\(index).jpg")!) + }) + try await insertPersistedGalleryState(gid: gid, imageURLs: imageURLs) + let cacheKeys = Set(imageURLs.values.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) + for cacheKey in cacheKeys { + KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) + } + defer { + cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } + } + + let observationStream = await manager.observeDownloads() + let emissionTask = Task { + var emissionCount = 0 + for await downloads in observationStream { + guard let relevantDownload = downloads.first(where: { $0.gid == gid }) else { continue } + emissionCount += 1 + if relevantDownload.completedPageCount == pageCount { + break + } + } + return emissionCount + } + + let payload = DownloadRequestPayload( + gallery: Gallery( + gid: gid, + token: "token", + title: "Cached Restore Gallery", + rating: 4, + tags: [], + category: .doujinshi, + uploader: "Uploader", + pageCount: pageCount, + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token")! + ), + galleryDetail: GalleryDetail( + gid: gid, + title: "Cached Restore Gallery", + jpnTitle: nil, + isFavorited: false, + visibility: .yes, + rating: 4, + userRating: 0, + ratingCount: 0, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + favoritedCount: 0, + pageCount: pageCount, + sizeCount: 12, + sizeType: "MB", + torrentCount: 0 + ), + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + options: DownloadOptionsSnapshot(), + mode: .initial + ) + + let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) + let emissionCount = await emissionTask.value + let stored = await manager.testingFetchDownload(gid: gid) + + XCTAssertEqual(restoredCount, pageCount) + XCTAssertEqual(stored?.completedPageCount, pageCount) + XCTAssertLessThan(emissionCount, pageCount) + XCTAssertLessThanOrEqual(emissionCount, 1 + Int(ceil(Double(pageCount) / 8.0))) + } +} + +private extension DownloadFeatureReducerTests { + @MainActor + func drainDetailMetadataEffects( + _ store: TestStoreOf, + timeout: Duration = .seconds(1), + condition: @escaping @MainActor () -> Bool + ) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !condition() && clock.now < deadline { + await store.skipReceivedActions(strict: false) + try? await Task.sleep(for: .milliseconds(10)) + } + await store.skipReceivedActions(strict: false) + } + + func sampleGalleryState(gid: String) -> GalleryState { + var galleryState = GalleryState(gid: gid) + galleryState.previewURLs = [1: URL(string: "https://example.com/1t.jpg")!] + galleryState.previewConfig = .normal(rows: 4) + return galleryState + } + + func sampleVersionMetadata(gid: String, token: String) -> DownloadVersionMetadata { + DownloadVersionMetadata( + gid: gid, + token: token, + currentGID: gid, + currentKey: "updated-key", + parentGID: gid, + parentKey: token, + firstGID: gid, + firstKey: token + ) + } + + func makeTestingDownloadManager() -> DownloadManager { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: .shared + ) + } + + func makeResponse( + url: URL, + statusCode: Int = 200, + contentType: String, + contentLength: Int? = nil, + headers: [String: String] = [:] + ) -> HTTPURLResponse { + var headerFields = headers + headerFields["Content-Type"] = contentType + if let contentLength { + headerFields["Content-Length"] = "\(contentLength)" + } + return HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: headerFields + )! + } + + func writeFixtureToTemporaryFile(filename: HTMLFilename) throws -> URL { + try writeFixtureToTemporaryFile(resource: filename.rawValue, pathExtension: "html") + } + + func writeFixtureToTemporaryFile(resource: String, pathExtension: String) throws -> URL { + let temporaryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(pathExtension) + try fixtureData(resource: resource, pathExtension: pathExtension) + .write(to: temporaryURL, options: .atomic) + return temporaryURL + } + + func fixtureData(resource: String, pathExtension: String) throws -> Data { + let fixtureURL = try XCTUnwrap( + Bundle(for: Self.self).url(forResource: resource, withExtension: pathExtension) + ) + return try Data(contentsOf: fixtureURL) + } + + func installGalleryVersionMetadataStub(for gallery: Gallery) throws { + let gid = try XCTUnwrap(Int(gallery.gid)) + let payload: [String: Any] = [ + "gmetadata": [[ + "gid": gid, + "token": gallery.token, + "current_gid": gid, + "current_key": "updated-key", + "parent_gid": gid, + "parent_key": gallery.token, + "first_gid": gid, + "first_key": gallery.token + ]] + ] + let responseData = try JSONSerialization.data(withJSONObject: payload, options: []) + SharedSessionStubURLProtocol.requestHandler = { request in + let response = HTTPURLResponse( + url: request.url ?? Defaults.URL.api, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, responseData) + } + URLProtocol.registerClass(SharedSessionStubURLProtocol.self) + } + + func uninstallSharedSessionStub() { + SharedSessionStubURLProtocol.requestHandler = nil + URLProtocol.unregisterClass(SharedSessionStubURLProtocol.self) + } + + func sampleGallery() -> Gallery { + Gallery( + gid: "123456", + token: "token", + title: "Sample Gallery", + rating: 4, + tags: [], + category: .doujinshi, + uploader: "Uploader", + pageCount: 12, + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + galleryURL: URL(string: "https://e-hentai.org/g/123456/token") + ) + } + + func sampleGalleryDetail(gid: String, title: String) -> GalleryDetail { + GalleryDetail( + gid: gid, + title: title, + jpnTitle: nil, + isFavorited: false, + visibility: .yes, + rating: 4, + userRating: 0, + ratingCount: 10, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + favoritedCount: 2, + pageCount: 12, + sizeCount: 120, + sizeType: "MB", + torrentCount: 0 + ) + } + + func sampleManifest( + gid: String, + title: String, + pageCount: Int = 2, + versionSignature: String = "hash:v1" + ) -> DownloadManifest { + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: .now, + pageCount: pageCount, + coverRelativePath: "cover.jpg", + galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token")!, + rating: 4, + downloadOptions: DownloadOptionsSnapshot(), + versionSignature: versionSignature, + downloadedAt: .now, + pages: (1...pageCount).map { + .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") + } + ) + } + + func sampleInspection(download: DownloadedGallery) -> DownloadInspection { + .init( + download: download, + coverURL: download.coverURL, + pages: [ + .init( + index: 1, + status: .downloaded, + relativePath: "pages/0001.jpg", + fileURL: URL(fileURLWithPath: "/tmp/0001.jpg"), + failure: nil + ), + .init( + index: 2, + status: .failed, + relativePath: "pages/0002.jpg", + fileURL: nil, + failure: .init(code: .networkingFailed, message: "Network Error") + ) + ] + ) + } + + func sampleDownload( + gid: String, + title: String, + status: DownloadStatus, + category: EhPanda.Category = .doujinshi, + pageCount: Int = 12, + completedPageCount: Int? = nil, + lastDownloadedAt: Date? = .now, + remoteVersionSignature: String = "hash:v1", + latestRemoteVersionSignature: String = "hash:v1", + lastError: DownloadFailure? = nil, + pendingOperation: DownloadStartMode? = nil + ) -> DownloadedGallery { + DownloadedGallery( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + uploader: "Uploader", + category: category, + tags: [], + pageCount: pageCount, + postedDate: .now, + rating: 4, + onlineCoverURL: URL(string: "https://example.com/cover.jpg"), + folderRelativePath: "\(gid) - \(title)", + coverRelativePath: "cover.jpg", + status: status, + completedPageCount: completedPageCount ?? (status == .completed ? pageCount : 0), + lastDownloadedAt: lastDownloadedAt, + lastError: lastError, + downloadOptionsSnapshot: DownloadOptionsSnapshot(), + remoteVersionSignature: remoteVersionSignature, + latestRemoteVersionSignature: latestRemoteVersionSignature, + pendingOperation: pendingOperation + ) + } + + func prepareLocalDownloadFiles( + download: DownloadedGallery, + manifest: DownloadManifest + ) throws -> URL { + guard let folderURL = download.folderURL else { + throw XCTSkip("Downloads directory is unavailable in the test environment.") + } + try? FileManager.default.removeItem(at: folderURL) + try FileManager.default.createDirectory( + at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try JSONEncoder().encode(manifest).write( + to: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: folderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) + return folderURL + } + + func preparePersistenceStore() async throws { + if !PersistenceController.shared.container.persistentStoreCoordinator.persistentStores.isEmpty { + return + } + + let result: Result = await withCheckedContinuation { continuation in + PersistenceController.shared.prepare { result in + continuation.resume(returning: result) + } + } + try result.get() + } + + @MainActor + func clearPersistedDownloads() throws { + let context = PersistenceController.shared.container.viewContext + let downloadRequest = NSFetchRequest(entityName: "DownloadedGalleryMO") + let downloads = try context.fetch(downloadRequest) + for object in downloads { + context.delete(object) + } + let stateRequest = NSFetchRequest(entityName: "GalleryStateMO") + let states = try context.fetch(stateRequest) + for object in states { + context.delete(object) + } + guard context.hasChanges else { return } + try context.save() + } + + @MainActor + func insertPersistedDownload( + gid: String, + status: DownloadStatus, + completedPageCount: Int, + pageCount: Int = 26, + token: String = "token", + remoteVersionSignature: String = "", + latestRemoteVersionSignature: String = "", + lastError: DownloadFailure? = nil, + pendingOperation: DownloadStartMode? = nil + ) throws { + let context = PersistenceController.shared.container.viewContext + let object = DownloadedGalleryMO(context: context) + object.gid = gid + object.host = GalleryHost.ehentai.rawValue + object.token = token + object.title = "Pause Race" + object.jpnTitle = nil + object.uploader = "Uploader" + object.category = Category.doujinshi.rawValue + object.tags = [GalleryTag]().toData() + object.pageCount = Int64(pageCount) + object.postedDate = .now + object.rating = 4 + object.onlineCoverURL = URL(string: "https://example.com/cover.jpg") + object.folderRelativePath = "\(gid) - Pause Race" + object.coverRelativePath = nil + object.status = status.rawValue + object.completedPageCount = Int64(completedPageCount) + object.lastDownloadedAt = .now + object.lastError = lastError?.toData() + object.downloadOptionsSnapshot = DownloadOptionsSnapshot().toData() + object.remoteVersionSignature = remoteVersionSignature + object.latestRemoteVersionSignature = latestRemoteVersionSignature + object.pendingOperation = pendingOperation?.rawValue + try context.save() + } + + @MainActor + func insertPersistedGalleryState( + gid: String, + previewURLs: [Int: URL] = [:], + imageURLs: [Int: URL], + originalImageURLs: [Int: URL] = [:] + ) throws { + let context = PersistenceController.shared.container.viewContext + let object = GalleryStateMO(context: context) + object.gid = gid + object.previewURLs = previewURLs.toData() + object.imageURLs = imageURLs.toData() + object.originalImageURLs = originalImageURLs.toData() + try context.save() + } +} + +private final class UncheckedBox: @unchecked Sendable { + var value: Value + + init(_ value: Value) { + self.value = value + } +} + +private struct RequestRecorderSnapshot: Equatable { + var detailRequests = 0 + var metadataRequests = 0 + var mpvRequests = 0 + var imageDispatchRequests = 0 + var imageDownloads = 0 + var previewPageNumbers = [Int]() +} + +private final class RequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var state = RequestRecorderSnapshot() + + func recordDetail() { + mutate { $0.detailRequests += 1 } + } + + func recordMetadata() { + mutate { $0.metadataRequests += 1 } + } + + func recordPreview(_ pageNumber: Int) { + mutate { $0.previewPageNumbers.append(pageNumber) } + } + + func recordMPV() { + mutate { $0.mpvRequests += 1 } + } + + func recordImageDispatch() { + mutate { $0.imageDispatchRequests += 1 } + } + + func recordImageDownload() { + mutate { $0.imageDownloads += 1 } + } + + func reset() { + mutate { $0 = .init() } + } + + func snapshot() -> RequestRecorderSnapshot { + lock.lock() + defer { lock.unlock() } + return state + } + + private func mutate(_ update: (inout RequestRecorderSnapshot) -> Void) { + lock.lock() + defer { lock.unlock() } + update(&state) + } +} + +private func requestBodyData(from request: URLRequest) -> Data? { + if let httpBody = request.httpBody { + return httpBody + } + + guard let stream = request.httpBodyStream else { + return nil + } + + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let readCount = stream.read(buffer, maxLength: bufferSize) + guard readCount >= 0 else { + return nil + } + guard readCount > 0 else { + break + } + data.append(buffer, count: readCount) + } + + return data +} + +private final class FailFastURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + client?.urlProtocol(self, didFailWithError: URLError(.cancelled)) + } + + override func stopLoading() {} +} + +private final class SharedSessionStubURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + requestHandler != nil + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.requestHandler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift new file mode 100644 index 000000000..74b29a323 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -0,0 +1,463 @@ +// +// DownloadFileStorageTests.swift +// EhPandaTests +// + +import Foundation +import XCTest +@testable import EhPanda + +final class DownloadFileStorageTests: XCTestCase { + func testWriteReadAndValidateManifest() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let download = sampleDownload(folderRelativePath: "123 - Sample") + let folderURL = storage.folderURL(relativePath: download.folderRelativePath) + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + + let manifest = sampleManifest(pageCount: 2) + try storage.writeManifest(manifest, folderURL: folderURL) + try Data([0xFF, 0xD8, 0xFF]).write( + to: folderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: folderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) + + let loadedManifest = try storage.readManifest(folderURL: folderURL) + + XCTAssertEqual(loadedManifest, manifest) + XCTAssertEqual(storage.validate(download: download), .valid) + } + + func testEnsureRootDirectoryMarksDownloadsFolderExcludedFromBackup() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + + let resourceValues = try rootURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) + XCTAssertEqual(resourceValues.isExcludedFromBackup, true) + } + + func testValidateReportsMissingPageFiles() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let download = sampleDownload(folderRelativePath: "123 - Sample") + let folderURL = storage.folderURL(relativePath: download.folderRelativePath) + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try storage.writeManifest(sampleManifest(pageCount: 2), folderURL: folderURL) + try Data([0xFF, 0xD8, 0xFF]).write( + to: folderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + + XCTAssertEqual( + storage.validate(download: download), + .missingFiles("Page 2 is missing.") + ) + } + + func testValidateRemovesZeroBytePageFilesAndRequiresRepair() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let download = sampleDownload(folderRelativePath: "123 - Sample") + let folderURL = storage.folderURL(relativePath: download.folderRelativePath) + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try storage.writeManifest(sampleManifest(pageCount: 2), folderURL: folderURL) + try Data([0xFF, 0xD8, 0xFF]).write( + to: folderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data().write( + to: folderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: folderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) + + XCTAssertEqual( + storage.validate(download: download), + .missingFiles("Page 1 is missing.") + ) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: folderURL.appendingPathComponent("pages/0001.jpg").path + ) + ) + } + + func testCleanupTemporaryFoldersRemovesOnlyTemporaryArtifacts() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let temporaryURL = storage.temporaryFolderURL(gid: "123") + let regularURL = storage.folderURL(relativePath: "123 - Sample") + try FileManager.default.createDirectory(at: temporaryURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: regularURL, withIntermediateDirectories: true) + + try storage.cleanupTemporaryFolders() + + XCTAssertFalse(FileManager.default.fileExists(atPath: temporaryURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: regularURL.path)) + } + + func testCleanupTemporaryFoldersPreservesSpecifiedGalleryFolders() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let preservedURL = storage.temporaryFolderURL(gid: "123") + let removedURL = storage.temporaryFolderURL(gid: "456") + try FileManager.default.createDirectory(at: preservedURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: removedURL, withIntermediateDirectories: true) + + try storage.cleanupTemporaryFolders(preservingGIDs: ["123"]) + + XCTAssertTrue(FileManager.default.fileExists(atPath: preservedURL.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: removedURL.path)) + } + + func testExistingPageRelativePathsDetectsCompletedPages() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.temporaryFolderURL(gid: "123") + let pagesURL = folderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, + isDirectory: true + ) + try FileManager.default.createDirectory(at: pagesURL, withIntermediateDirectories: true) + try Data([0x01]).write(to: pagesURL.appendingPathComponent("0001.jpg"), options: .atomic) + try Data([0x02]).write(to: pagesURL.appendingPathComponent("0002.png"), options: .atomic) + try Data([0x03]).write(to: pagesURL.appendingPathComponent("0027.jpg"), options: .atomic) + try Data([0x04]).write(to: pagesURL.appendingPathComponent("invalid.jpg"), options: .atomic) + + XCTAssertEqual( + storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2), + [ + 1: "pages/0001.jpg", + 2: "pages/0002.png" + ] + ) + } + + func testExistingPageRelativePathsRemovesZeroByteFiles() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.temporaryFolderURL(gid: "123") + let pagesURL = folderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, + isDirectory: true + ) + try FileManager.default.createDirectory(at: pagesURL, withIntermediateDirectories: true) + let emptyPageURL = pagesURL.appendingPathComponent("0001.jpg") + try Data().write(to: emptyPageURL, options: .atomic) + try Data([0x02]).write(to: pagesURL.appendingPathComponent("0002.png"), options: .atomic) + + XCTAssertEqual( + storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2), + [ + 2: "pages/0002.png" + ] + ) + XCTAssertFalse(FileManager.default.fileExists(atPath: emptyPageURL.path)) + } + + func testIsReadableAssetFileDoesNotDeleteFileWhenAttributesLookupFails() throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let fileManager = ThrowingAttributesFileManager(failingPath: rootURL.path) + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: fileManager) + + try storage.ensureRootDirectory() + let fileURL = rootURL.appendingPathComponent("cover.jpg") + try Data([0xFF, 0xD8, 0xFF]).write(to: fileURL, options: .atomic) + fileManager.failingPath = fileURL.path + + XCTAssertTrue(storage.isReadableAssetFile(at: fileURL)) + XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) + } + + func testMakeFolderRelativePathSanitizesSeparatorsWhitespaceAndLength() { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + let unsafeTitle = " /Alpha\\\\Beta:\n\tGamma Delta \(String(repeating: "X", count: 200)). " + let relativePath = storage.makeFolderRelativePath(gid: "123", title: unsafeTitle) + + XCTAssertTrue(relativePath.hasPrefix("123 - ")) + XCTAssertFalse(relativePath.contains("/")) + XCTAssertFalse(relativePath.contains("\\")) + XCTAssertFalse(relativePath.contains(":")) + XCTAssertFalse(relativePath.contains("\n")) + XCTAssertFalse(relativePath.hasSuffix(" ")) + XCTAssertFalse(relativePath.hasSuffix(".")) + XCTAssertLessThanOrEqual(relativePath.count, "123 - ".count + 96) + } + + func testWriteAndReadResumeState() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.temporaryFolderURL(gid: "123") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + + let resumeState = DownloadResumeState( + mode: .update, + versionSignature: "hash:v2", + pageCount: 27, + downloadOptions: .init( + threadMode: .quadruple, + allowCellular: false, + autoRetryFailedPages: false + ) + ) + try storage.writeResumeState(resumeState, folderURL: folderURL) + + XCTAssertEqual( + try storage.readResumeState(folderURL: folderURL), + resumeState + ) + } + + func testWriteReadAndRemoveFailedPagesSnapshot() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.temporaryFolderURL(gid: "123") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + + let snapshot = DownloadFailedPagesSnapshot( + pages: [ + .init( + index: 3, + relativePath: "pages/0003.jpg", + failure: .init(code: .networkingFailed, message: "Network Error") + ) + ] + ) + + try storage.writeFailedPages(snapshot, folderURL: folderURL) + XCTAssertEqual(try storage.readFailedPages(folderURL: folderURL), snapshot) + + try storage.removeFailedPages(folderURL: folderURL) + XCTAssertThrowsError(try storage.readFailedPages(folderURL: folderURL)) + } + + func testMaterializeRepairSeedCopiesOnlyManifestCoverAndExistingPageFiles() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let sourceFolderURL = storage.folderURL(relativePath: "123 - Source") + let tempFolderURL = storage.temporaryFolderURL(gid: "123") + try FileManager.default.createDirectory( + at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let manifest = sampleManifest(pageCount: 3) + try storage.writeManifest(manifest, folderURL: sourceFolderURL) + try Data([0xFF, 0xD8, 0xFF]).write( + to: sourceFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([0x01]).write( + to: sourceFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try Data([0x03]).write( + to: sourceFolderURL.appendingPathComponent("pages/0003.jpg"), + options: .atomic + ) + try FileManager.default.createDirectory( + at: sourceFolderURL.appendingPathComponent("nested", isDirectory: true), + withIntermediateDirectories: true + ) + try Data([0x09]).write( + to: sourceFolderURL.appendingPathComponent("nested/ignored.bin"), + options: .atomic + ) + + try storage.materializeRepairSeed( + from: sourceFolderURL, + manifest: manifest, + to: tempFolderURL + ) + + XCTAssertTrue( + FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest).path + ) + ) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent("cover.jpg").path + ) + ) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent("pages/0001.jpg").path + ) + ) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent("pages/0002.jpg").path + ) + ) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent("pages/0003.jpg").path + ) + ) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent("nested/ignored.bin").path + ) + ) + } + + func testLinkOrCopyReadableAssetFallsBackToCopyWhenHardLinkFails() throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let fileManager = LinkFailingFileManager() + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: fileManager) + try storage.ensureRootDirectory() + + let sourceURL = rootURL.appendingPathComponent("source.bin") + let destinationURL = rootURL.appendingPathComponent("nested/destination.bin") + try Data([0x01, 0x02, 0x03]).write(to: sourceURL, options: .atomic) + + try storage.linkOrCopyReadableAsset(at: sourceURL, to: destinationURL) + + XCTAssertTrue(FileManager.default.fileExists(atPath: destinationURL.path)) + XCTAssertEqual(try Data(contentsOf: destinationURL), Data([0x01, 0x02, 0x03])) + } +} + +private final class ThrowingAttributesFileManager: FileManager { + var failingPath: String + + init(failingPath: String) { + self.failingPath = failingPath + super.init() + } + + override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { + if path == failingPath { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadUnknownError) + } + return try super.attributesOfItem(atPath: path) + } +} + +private final class LinkFailingFileManager: FileManager { + override func linkItem(at srcURL: URL, to dstURL: URL) throws { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteUnknownError) + } +} + +private extension DownloadFileStorageTests { + func makeStorage() -> (DownloadFileStorage, URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return ( + DownloadFileStorage(rootURL: rootURL, fileManager: .default), + rootURL + ) + } + + func sampleDownload( + status: DownloadStatus = .completed, + folderRelativePath: String + ) -> DownloadedGallery { + DownloadedGallery( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: nil, + uploader: "Uploader", + category: .doujinshi, + tags: [], + pageCount: 2, + postedDate: .now, + rating: 4, + onlineCoverURL: URL(string: "https://example.com/cover.jpg"), + folderRelativePath: folderRelativePath, + coverRelativePath: "cover.jpg", + status: status, + completedPageCount: status == .completed ? 2 : 0, + lastDownloadedAt: .now, + lastError: nil, + downloadOptionsSnapshot: DownloadOptionsSnapshot(), + remoteVersionSignature: "hash:v1", + latestRemoteVersionSignature: "hash:v1" + ) + } + + func sampleManifest(pageCount: Int) -> DownloadManifest { + DownloadManifest( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: .now, + pageCount: pageCount, + coverRelativePath: "cover.jpg", + galleryURL: URL(string: "https://e-hentai.org/g/123/token")!, + rating: 4, + downloadOptions: DownloadOptionsSnapshot(), + versionSignature: "hash:v1", + downloadedAt: .now, + pages: (1...pageCount).map { + .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") + } + ) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift new file mode 100644 index 000000000..c2c42f6d5 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift @@ -0,0 +1,308 @@ +// +// DownloadSignatureBuilderTests.swift +// EhPandaTests +// + +import XCTest +@testable import EhPanda + +final class DownloadSignatureBuilderTests: XCTestCase { + func testVersionIdentifierPrefersGalleryChainMetadata() { + let signature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")! + ], + versionMetadata: .init( + gid: "1394965", + token: "56c35114b6", + currentGID: "2000000", + currentKey: "new-chain-key", + parentGID: "1394965", + parentKey: "56c35114b6", + firstGID: "1394965", + firstKey: "56c35114b6" + ) + ) + + XCTAssertEqual(signature, "chain:2000000:new-chain-key") + } + + func testVersionIdentifierFallsBackToOriginalGalleryIdentityWhenCurrentChainFieldsAreMissing() { + let signature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [:], + versionMetadata: .init( + gid: sampleGallery.gid, + token: sampleGallery.token, + currentGID: nil, + currentKey: nil, + parentGID: nil, + parentKey: nil, + firstGID: nil, + firstKey: nil + ) + ) + + XCTAssertEqual(signature, "chain:\(sampleGallery.gid):\(sampleGallery.token)") + } + + func testMakeReturnsHashPrefixedFallbackSignature() { + let signature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [:] + ) + + XCTAssertTrue(signature.hasPrefix("hash:")) + } + + func testHashAndChainSignaturesAreIncomparableForUpdateCheck() { + XCTAssertEqual( + DownloadSignatureBuilder.hasUpdateComparison( + remoteVersionSignature: "hash:abc", + latestRemoteVersionSignature: "chain:newgid:newtoken", + gid: sampleGallery.gid, + token: sampleGallery.token + ), + .incomparable + ) + XCTAssertNil( + DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( + remoteVersionSignature: "hash:abc", + latestRemoteVersionSignature: "chain:newgid:newtoken", + gid: sampleGallery.gid, + token: sampleGallery.token + ) + ) + } + + func testCanonicalizeHashToOriginalChainOnlyWhenLatestMatchesOriginalGalleryIdentity() { + let latestSignature = "chain:\(sampleGallery.gid):\(sampleGallery.token)" + + XCTAssertEqual( + DownloadSignatureBuilder.hasUpdateComparison( + remoteVersionSignature: "hash:abc", + latestRemoteVersionSignature: latestSignature, + gid: sampleGallery.gid, + token: sampleGallery.token + ), + .same + ) + XCTAssertEqual( + DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( + remoteVersionSignature: "hash:abc", + latestRemoteVersionSignature: latestSignature, + gid: sampleGallery.gid, + token: sampleGallery.token + ), + latestSignature + ) + } + + func testDoNotCanonicalizeHashWhenLatestChainPointsToDifferentCurrentGallery() { + XCTAssertEqual( + DownloadSignatureBuilder.hasUpdateComparison( + remoteVersionSignature: "hash:abc", + latestRemoteVersionSignature: "chain:othergid:othertoken", + gid: sampleGallery.gid, + token: sampleGallery.token + ), + .incomparable + ) + XCTAssertNil( + DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( + remoteVersionSignature: "hash:abc", + latestRemoteVersionSignature: "chain:othergid:othertoken", + gid: sampleGallery.gid, + token: sampleGallery.token + ) + ) + } + + func testSignatureIgnoresPreviewHostRotationAndLayoutChanges() { + let firstSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")!, + 2: URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200")! + ] + ) + + let secondSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: URL(string: "https://beta.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0")!, + 2: URL(string: "https://beta.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=250")! + ] + ) + + XCTAssertEqual(firstSignature, secondSignature) + } + + func testSignatureChangesWhenCombinedPreviewAtlasChanges() { + let firstSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")! + ] + ) + + let secondSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: URL(string: "https://alpha.hath.network/c2/token-a/1394965-1.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")! + ] + ) + + XCTAssertNotEqual(firstSignature, secondSignature) + } + + func testSignatureIgnoresCombinedPreviewTokenRotation() { + let firstSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")! + ] + ) + + let secondSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: URL(string: "https://beta.hath.network/c2/token-b/1394965-0.webp?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0")! + ] + ) + + XCTAssertEqual(firstSignature, secondSignature) + } + + func testSignatureIgnoresHostRotationForStandalonePreviewURLs() { + let firstSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")!, + 2: URL(string: "https://alpha.ehgt.org/t/56/78/preview-2.webp")! + ] + ) + + let secondSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: URL(string: "https://beta.ehgt.org/t/12/34/preview-1.webp")!, + 2: URL(string: "https://beta.ehgt.org/t/56/78/preview-2.webp")! + ] + ) + + XCTAssertEqual(firstSignature, secondSignature) + } + + func testSignatureIgnoresCoverHostAndQueryChanges() { + let firstSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetailWithCoverURL("https://ehgt.org/w/00/686/86308-b7cs0xve.webp?dl=1"), + host: .ehentai, + previewURLs: [:] + ) + + let secondSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetailWithCoverURL("https://mirror.ehgt.org/w/00/686/86308-b7cs0xve.webp?source=thumb"), + host: .ehentai, + previewURLs: [:] + ) + + XCTAssertEqual(firstSignature, secondSignature) + } + + func testSignatureIgnoresGalleryHostTransitions() { + let ehSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")! + ] + ) + + let exSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .exhentai, + previewURLs: [ + 1: URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")! + ] + ) + + XCTAssertEqual(ehSignature, exSignature) + } +} + +private extension DownloadSignatureBuilderTests { + var sampleGallery: Gallery { + Gallery( + gid: "1394965", + token: "56c35114b6", + title: "(C95) [Hoshimame (Hoshimame Mana)] Mugyutto Mugyu Gurumi (Summer Pockets)[Chinese] [红茶汉化组]", + rating: 4.5, + tags: [], + category: .nonH, + uploader: "多路卡", + pageCount: 26, + postedDate: samplePostedDate, + coverURL: URL(string: "https://ehgt.org/cover.webp"), + galleryURL: URL(string: "https://e-hentai.org/g/1394965/56c35114b6/") + ) + } + + var sampleDetail: GalleryDetail { + sampleDetailWithCoverURL("https://ehgt.org/cover.webp") + } + + func sampleDetailWithCoverURL(_ coverURL: String) -> GalleryDetail { + GalleryDetail( + gid: "1394965", + title: sampleGallery.title, + jpnTitle: "(C95) [ほしまめ (星豆まな)] むぎゅっとむぎゅぐるみ (Summer Pockets)[中国翻訳]", + isFavorited: false, + visibility: .yes, + rating: 4.5, + userRating: 0, + ratingCount: 0, + category: .nonH, + language: .chinese, + uploader: "多路卡", + postedDate: samplePostedDate, + coverURL: URL(string: coverURL), + favoritedCount: 0, + pageCount: 26, + sizeCount: 114, + sizeType: "MB", + torrentCount: 0 + ) + } + + var samplePostedDate: Date { + Date(timeIntervalSince1970: 576_346_020) + } +} diff --git a/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift b/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift new file mode 100644 index 000000000..9f424853e --- /dev/null +++ b/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift @@ -0,0 +1,66 @@ +// +// DownloadPageErrorParserTests.swift +// EhPandaTests +// + +import Kanna +import XCTest +@testable import EhPanda + +final class DownloadPageErrorParserTests: XCTestCase, TestHelper { + func testIPBannedPageMapsToIPBanned() throws { + let document = try htmlDocument(filename: .ipBanned) + + XCTAssertEqual( + Parser.parseDownloadPageError(doc: document), + .ipBanned(.minutes(59, seconds: 48)) + ) + } + + func testNormalGalleryDetailPageDoesNotMapToDownloadError() throws { + let document = try htmlDocument(filename: .galleryDetail) + + XCTAssertNil(Parser.parseDownloadPageError(doc: document)) + } + + func testAuthenticationRequiredMarkersMapToAuthenticationRequired() throws { + let document = try XCTUnwrap( + Kanna.HTML( + html: """ + + + + +

Access to ExHentai.org is restricted.

+ + + """, + encoding: .utf8 + ) + ) + + XCTAssertEqual( + Parser.parseDownloadPageError(doc: document), + .authenticationRequired + ) + } + + func testNotFoundMarkersMapToNotFound() throws { + let document = try XCTUnwrap( + Kanna.HTML( + html: """ +

Invalid page

Gallery not found.

Key missing.

Keep trying.

+ """, + encoding: .utf8 + ) + ) + + XCTAssertEqual(Parser.parseDownloadPageError(doc: document), .notFound) + XCTAssertEqual(Parser.parseDownloadPageError(content: "Gallery not found"), .notFound) + XCTAssertEqual(Parser.parseDownloadPageError(content: "Keep trying"), .notFound) + } + + func testGalleryNotAvailableIsNotHardMappedToDownloadError() { + XCTAssertNil(Parser.parseDownloadPageError(content: "Gallery Not Available")) + } +} diff --git a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift new file mode 100644 index 000000000..40c165511 --- /dev/null +++ b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -0,0 +1,113 @@ +// +// SettingDownloadTests.swift +// EhPandaTests +// + +import SwiftUI +import XCTest +@testable import EhPanda + +final class SettingDownloadTests: XCTestCase { + func testLegacySettingDecodesDownloadDefaults() throws { + let data = """ + { + "galleryHost": "E-Hentai", + "showsNewDawnGreeting": true + } + """.data(using: .utf8)! + + let setting = try JSONDecoder().decode(Setting.self, from: data) + + XCTAssertEqual(setting.downloadThreadMode, .single) + XCTAssertTrue(setting.downloadAllowCellular) + XCTAssertTrue(setting.downloadAutoRetryFailedPages) + } + + func testDownloadOptionsSnapshotMatchesSettingValues() { + var setting = Setting() + setting.downloadThreadMode = .quadruple + setting.downloadAllowCellular = false + setting.downloadAutoRetryFailedPages = false + + XCTAssertEqual( + setting.downloadOptionsSnapshot, + DownloadOptionsSnapshot( + threadMode: .quadruple, + allowCellular: false, + autoRetryFailedPages: false + ) + ) + } + + func testLegacyDownloadOptionsSnapshotDecodesWithoutOriginalImageField() throws { + let data = """ + { + "threadMode": "triple", + "useOriginalImages": true, + "allowCellular": false, + "autoRetryFailedPages": false + } + """.data(using: .utf8)! + + let snapshot = try JSONDecoder().decode(DownloadOptionsSnapshot.self, from: data) + + XCTAssertEqual( + snapshot, + DownloadOptionsSnapshot( + threadMode: .triple, + allowCellular: false, + autoRetryFailedPages: false + ) + ) + } + + func testImageCacheKeysPreferStablePathAlias() { + let url = URL(string: "https://alpha.hath.network/h/123/456/image.webp?download=1")! + + XCTAssertEqual( + url.imageCacheKeys(includeStableAlias: true), + [ + "download::h/123/456/image.webp", + "https://alpha.hath.network/h/123/456/image.webp?download=1" + ] + ) + } + + func testStableImageCacheKeyIgnoresHostRotationAndQuery() { + let firstURL = URL(string: "https://alpha.hath.network/h/123/456/image.webp?download=1")! + let secondURL = URL(string: "https://beta.hath.network/h/123/456/image.webp?source=viewer")! + + XCTAssertEqual(firstURL.stableImageCacheKey, secondURL.stableImageCacheKey) + } + + func testStableImageCacheKeyKeepsIdentityQueryForFullImageScript() { + let firstURL = URL(string: "https://e-hentai.org/fullimg.php?gid=42&page=7&key=alpha")! + let secondURL = URL(string: "https://exhentai.org/fullimg.php?page=7&gid=42&key=beta")! + + XCTAssertEqual( + firstURL.stableImageCacheKey, + "download::fullimg.php?gid=42&key=alpha&page=7" + ) + XCTAssertEqual( + secondURL.stableImageCacheKey, + "download::fullimg.php?gid=42&key=beta&page=7" + ) + XCTAssertNotEqual(firstURL.stableImageCacheKey, secondURL.stableImageCacheKey) + } + + func testCombinedPreviewURLCleanupIncludesPlainPreviewURL() { + let plainURL = URL(string: "https://ehgt.org/ab/cd/preview.webp")! + let combinedURL = URLUtil.combinedPreviewURL( + plainURL: plainURL, + width: "200", + height: "300", + offset: "40" + ) + + XCTAssertEqual( + combinedURL.previewCacheCleanupURLs(), + [combinedURL, plainURL] + ) + XCTAssertEqual(plainURL.previewCacheCleanupURLs(), [plainURL]) + } +} diff --git a/ShareExtension/Info.plist b/ShareExtension/Info.plist index 4c8c1c00c..ea3b19ad9 100644 --- a/ShareExtension/Info.plist +++ b/ShareExtension/Info.plist @@ -2,8 +2,6 @@ - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) NSExtension NSExtensionAttributes From 887f63fd2c3c8003bb75f8b42f3830afe2ec4e5a Mon Sep 17 00:00:00 2001 From: vvbbnn00 Date: Wed, 25 Mar 2026 12:37:22 +0800 Subject: [PATCH 002/614] refactor: simplify cache readiness checks with a dedicated async function --- .../DownloadFeatureReducerTests.swift | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift index b94bec4eb..e8e6677cd 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift @@ -2794,14 +2794,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { cachedKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } - let seededCacheClock = ContinuousClock() - let seededCacheDeadline = seededCacheClock.now.advanced(by: .seconds(1)) - while !cachedKeys.allSatisfy({ KingfisherManager.shared.cache.isCached(forKey: $0) }), - seededCacheClock.now < seededCacheDeadline - { - try? await Task.sleep(for: .milliseconds(10)) - } - XCTAssertTrue(cachedKeys.allSatisfy { KingfisherManager.shared.cache.isCached(forKey: $0) }) + await waitUntilCacheReady(for: cachedKeys) let updatedPageCount = latestPayload.galleryDetail.pageCount let oldPageCount = updatedPageCount - 5 @@ -4741,6 +4734,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } + await waitUntilCacheReady(for: cacheKeys) let payload = DownloadRequestPayload( gallery: Gallery( @@ -4819,6 +4813,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } + await waitUntilCacheReady(for: cacheKeys) let payload = DownloadRequestPayload( gallery: Gallery( @@ -5522,6 +5517,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } + await waitUntilCacheReady(for: cacheKeys) let observationStream = await manager.observeDownloads() let emissionTask = Task { @@ -5589,6 +5585,27 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } private extension DownloadFeatureReducerTests { + func waitUntilCacheReady( + for keys: Keys, + timeout: Duration = .seconds(1) + ) async where Keys.Element == String { + let cacheKeys = Array(keys) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while !cacheKeys.allSatisfy({ KingfisherManager.shared.cache.isCached(forKey: $0) }), + clock.now < deadline + { + try? await clock.sleep(until: clock.now.advanced(by: .milliseconds(10)), tolerance: .zero) + } + + let missingKeys = cacheKeys.filter { !KingfisherManager.shared.cache.isCached(forKey: $0) } + XCTAssertTrue( + missingKeys.isEmpty, + "Timed out waiting for Kingfisher cache visibility for keys: \(missingKeys)" + ) + } + @MainActor func drainDetailMetadataEffects( _ store: TestStoreOf, From e9f564647776cbee54f28dabcd1204151066d226 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 27 Mar 2026 20:14:27 +0800 Subject: [PATCH 003/614] Resolve build issues --- .../xcshareddata/swiftpm/Package.resolved | 4 +- EhPanda/App/Generated/Strings.swift | 332 +++++++++--------- EhPanda/View/Detail/DetailView.swift | 24 +- 3 files changed, 181 insertions(+), 179 deletions(-) diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a94821f17..6ef3a677d 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "a9c3fecb5d31fc8aad5d8ba5d830924966d7fb15", - "version" : "1.23.0" + "revision" : "df934d9c5a274a6f6a7bdcec73fbcb330149ff8b", + "version" : "1.23.2" } }, { diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index a7b3500f6..b5f4f8ae4 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -249,6 +249,45 @@ internal enum L10n { internal static let showsNewDawnGreeting = L10n.tr("Localizable", "account_setting_view.title.shows_new_dawn_greeting", fallback: "Shows new dawn greeting") } } + internal enum AppError { + internal enum Alert { + /// Login required to access this download. + internal static let authenticationRequired = L10n.tr("Localizable", "app_error.alert.authentication_required", fallback: "Login required to access this download.") + /// Local file operation failed. + internal static let localFileOperationFailed = L10n.tr("Localizable", "app_error.alert.local_file_operation_failed", fallback: "Local file operation failed.") + /// Image quota exceeded. + /// Please wait and try again later. + internal static let quotaExceeded = L10n.tr("Localizable", "app_error.alert.quota_exceeded", fallback: "Image quota exceeded.\nPlease wait and try again later.") + } + internal enum LocalizedDescription { + /// Authentication Required + internal static let authenticationRequired = L10n.tr("Localizable", "app_error.localized_description.authentication_required", fallback: "Authentication Required") + /// Copyright Claim + internal static let copyrightClaim = L10n.tr("Localizable", "app_error.localized_description.copyright_claim", fallback: "Copyright Claim") + /// Database Corrupted + internal static let databaseCorrupted = L10n.tr("Localizable", "app_error.localized_description.database_corrupted", fallback: "Database Corrupted") + /// File Operation Failed + internal static let fileOperationFailed = L10n.tr("Localizable", "app_error.localized_description.file_operation_failed", fallback: "File Operation Failed") + /// Gallery Expunged + internal static let galleryExpunged = L10n.tr("Localizable", "app_error.localized_description.gallery_expunged", fallback: "Gallery Expunged") + /// IP Banned + internal static let ipBanned = L10n.tr("Localizable", "app_error.localized_description.ip_banned", fallback: "IP Banned") + /// Network Error + internal static let networkError = L10n.tr("Localizable", "app_error.localized_description.network_error", fallback: "Network Error") + /// No updates available + internal static let noUpdatesAvailable = L10n.tr("Localizable", "app_error.localized_description.no_updates_available", fallback: "No updates available") + /// Not found + internal static let notFound = L10n.tr("Localizable", "app_error.localized_description.not_found", fallback: "Not found") + /// Parse Error + internal static let parseError = L10n.tr("Localizable", "app_error.localized_description.parse_error", fallback: "Parse Error") + /// Quota Exceeded + internal static let quotaExceeded = L10n.tr("Localizable", "app_error.localized_description.quota_exceeded", fallback: "Quota Exceeded") + /// Unknown Error + internal static let unknownError = L10n.tr("Localizable", "app_error.localized_description.unknown_error", fallback: "Unknown Error") + /// Web image loading error + internal static let webImageLoadingError = L10n.tr("Localizable", "app_error.localized_description.web_image_loading_error", fallback: "Web image loading error") + } + } internal enum AppIconView { internal enum Title { /// App icon @@ -301,45 +340,6 @@ internal enum L10n { internal static let archives = L10n.tr("Localizable", "archives_view.title.archives", fallback: "Archives") } } - internal enum AppError { - internal enum Alert { - /// Login required to access this download. - internal static let authenticationRequired = L10n.tr("Localizable", "app_error.alert.authentication_required", fallback: "Login required to access this download.") - /// Local file operation failed. - internal static let localFileOperationFailed = L10n.tr("Localizable", "app_error.alert.local_file_operation_failed", fallback: "Local file operation failed.") - /// Image quota exceeded. - /// Please wait and try again later. - internal static let quotaExceeded = L10n.tr("Localizable", "app_error.alert.quota_exceeded", fallback: "Image quota exceeded.\nPlease wait and try again later.") - } - internal enum LocalizedDescription { - /// Authentication Required - internal static let authenticationRequired = L10n.tr("Localizable", "app_error.localized_description.authentication_required", fallback: "Authentication Required") - /// Copyright Claim - internal static let copyrightClaim = L10n.tr("Localizable", "app_error.localized_description.copyright_claim", fallback: "Copyright Claim") - /// Database Corrupted - internal static let databaseCorrupted = L10n.tr("Localizable", "app_error.localized_description.database_corrupted", fallback: "Database Corrupted") - /// File Operation Failed - internal static let fileOperationFailed = L10n.tr("Localizable", "app_error.localized_description.file_operation_failed", fallback: "File Operation Failed") - /// Gallery Expunged - internal static let galleryExpunged = L10n.tr("Localizable", "app_error.localized_description.gallery_expunged", fallback: "Gallery Expunged") - /// IP Banned - internal static let ipBanned = L10n.tr("Localizable", "app_error.localized_description.ip_banned", fallback: "IP Banned") - /// Network Error - internal static let networkError = L10n.tr("Localizable", "app_error.localized_description.network_error", fallback: "Network Error") - /// No updates available - internal static let noUpdatesAvailable = L10n.tr("Localizable", "app_error.localized_description.no_updates_available", fallback: "No updates available") - /// Not found - internal static let notFound = L10n.tr("Localizable", "app_error.localized_description.not_found", fallback: "Not found") - /// Parse Error - internal static let parseError = L10n.tr("Localizable", "app_error.localized_description.parse_error", fallback: "Parse Error") - /// Quota Exceeded - internal static let quotaExceeded = L10n.tr("Localizable", "app_error.localized_description.quota_exceeded", fallback: "Quota Exceeded") - /// Unknown Error - internal static let unknownError = L10n.tr("Localizable", "app_error.localized_description.unknown_error", fallback: "Unknown Error") - /// Web image loading error - internal static let webImageLoadingError = L10n.tr("Localizable", "app_error.localized_description.web_image_loading_error", fallback: "Web image loading error") - } - } internal enum CommentsView { internal enum Title { /// Comments @@ -434,6 +434,40 @@ internal enum L10n { } } internal enum DetailView { + internal enum Accessibility { + internal enum DownloadButton { + /// Download + internal static let download = L10n.tr("Localizable", "detail_view.accessibility.download_button.download", fallback: "Download") + /// Delete downloaded gallery + internal static let downloaded = L10n.tr("Localizable", "detail_view.accessibility.download_button.downloaded", fallback: "Delete downloaded gallery") + /// Downloading %d of %d + internal static func downloading(_ p1: Int, _ p2: Int) -> String { + return L10n.tr("Localizable", "detail_view.accessibility.download_button.downloading", p1, p2, fallback: "Downloading %d of %d") + } + /// Log in to download + internal static let login = L10n.tr("Localizable", "detail_view.accessibility.download_button.login", fallback: "Log in to download") + /// Retry download. %d of %d pages are already available. + internal static func partial(_ p1: Int, _ p2: Int) -> String { + return L10n.tr("Localizable", "detail_view.accessibility.download_button.partial", p1, p2, fallback: "Retry download. %d of %d pages are already available.") + } + /// Pause download + internal static let pauseAction = L10n.tr("Localizable", "detail_view.accessibility.download_button.pause_action", fallback: "Pause download") + /// Resume download. Paused at %d of %d + internal static func paused(_ p1: Int, _ p2: Int) -> String { + return L10n.tr("Localizable", "detail_view.accessibility.download_button.paused", p1, p2, fallback: "Resume download. Paused at %d of %d") + } + /// Preparing download + internal static let preparing = L10n.tr("Localizable", "detail_view.accessibility.download_button.preparing", fallback: "Preparing download") + /// Queued + internal static let queued = L10n.tr("Localizable", "detail_view.accessibility.download_button.queued", fallback: "Queued") + /// Repair download + internal static let repair = L10n.tr("Localizable", "detail_view.accessibility.download_button.repair", fallback: "Repair download") + /// Retry download + internal static let retry = L10n.tr("Localizable", "detail_view.accessibility.download_button.retry", fallback: "Retry download") + /// Update download + internal static let update = L10n.tr("Localizable", "detail_view.accessibility.download_button.update", fallback: "Update download") + } + } internal enum ActionSection { internal enum Button { /// Give a Rating @@ -462,74 +496,6 @@ internal enum L10n { /// Read internal static let read = L10n.tr("Localizable", "detail_view.button.read", fallback: "Read") } - internal enum Accessibility { - /// Download - internal static let downloadButtonDownload = L10n.tr("Localizable", "detail_view.accessibility.download_button.download", fallback: "Download") - /// Delete downloaded gallery - internal static let downloadButtonDownloaded = L10n.tr("Localizable", "detail_view.accessibility.download_button.downloaded", fallback: "Delete downloaded gallery") - /// Downloading %d of %d - internal static func downloadButtonDownloading(_ p1: Int, _ p2: Int) -> String { - L10n.tr("Localizable", "detail_view.accessibility.download_button.downloading", p1, p2, fallback: "Downloading %d of %d") - } - /// Log in to download - internal static let downloadButtonLogin = L10n.tr("Localizable", "detail_view.accessibility.download_button.login", fallback: "Log in to download") - /// Preparing download - internal static let downloadButtonPreparing = L10n.tr("Localizable", "detail_view.accessibility.download_button.preparing", fallback: "Preparing download") - /// Queued - internal static let downloadButtonQueued = L10n.tr("Localizable", "detail_view.accessibility.download_button.queued", fallback: "Queued") - /// Retry download. %d of %d pages are already available. - internal static func downloadButtonPartial(_ p1: Int, _ p2: Int) -> String { - L10n.tr("Localizable", "detail_view.accessibility.download_button.partial", p1, p2, fallback: "Retry download. %d of %d pages are already available.") - } - /// Pause download - internal static let downloadButtonPauseAction = L10n.tr("Localizable", "detail_view.accessibility.download_button.pause_action", fallback: "Pause download") - /// Resume download. Paused at %d of %d - internal static func downloadButtonPaused(_ p1: Int, _ p2: Int) -> String { - L10n.tr("Localizable", "detail_view.accessibility.download_button.paused", p1, p2, fallback: "Resume download. Paused at %d of %d") - } - /// Repair download - internal static let downloadButtonRepair = L10n.tr("Localizable", "detail_view.accessibility.download_button.repair", fallback: "Repair download") - /// Retry download - internal static let downloadButtonRetry = L10n.tr("Localizable", "detail_view.accessibility.download_button.retry", fallback: "Retry download") - /// Update download - internal static let downloadButtonUpdate = L10n.tr("Localizable", "detail_view.accessibility.download_button.update", fallback: "Update download") - } - internal enum Dialog { - internal enum Button { - /// Redownload - internal static let redownload = L10n.tr("Localizable", "detail_view.dialog.button.redownload", fallback: "Redownload") - /// Repair - internal static let repair = L10n.tr("Localizable", "detail_view.dialog.button.repair", fallback: "Repair") - /// Update - internal static let update = L10n.tr("Localizable", "detail_view.dialog.button.update", fallback: "Update") - } - internal enum Message { - /// This will stop the current download and remove the gallery from this device. - internal static let deleteActiveDownload = L10n.tr("Localizable", "detail_view.dialog.message.delete_active_download", fallback: "This will stop the current download and remove the gallery from this device.") - /// This will remove the downloaded gallery from this device. - internal static let deleteDownloadedGallery = L10n.tr("Localizable", "detail_view.dialog.message.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") - /// Repair the offline files for this gallery now? - internal static let repairDownload = L10n.tr("Localizable", "detail_view.dialog.message.repair_download", fallback: "Repair the offline files for this gallery now?") - /// Start a fresh download for this gallery now? - internal static let redownloadGallery = L10n.tr("Localizable", "detail_view.dialog.message.redownload_gallery", fallback: "Start a fresh download for this gallery now?") - /// Update this gallery to the newest online version now? - internal static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.message.update_download", fallback: "Update this gallery to the newest online version now?") - } - internal enum Title { - /// Delete Download? - internal static let deleteDownload = L10n.tr("Localizable", "detail_view.dialog.title.delete_download", fallback: "Delete Download?") - /// Repair Download? - internal static let repairDownload = L10n.tr("Localizable", "detail_view.dialog.title.repair_download", fallback: "Repair Download?") - /// Redownload Gallery? - internal static let redownloadGallery = L10n.tr("Localizable", "detail_view.dialog.title.redownload_gallery", fallback: "Redownload Gallery?") - /// Update Download? - internal static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.title.update_download", fallback: "Update Download?") - } - } - internal enum OfflineNotice { - /// Couldn't refresh online details. Showing saved details instead. - internal static let savedDetails = L10n.tr("Localizable", "detail_view.offline_notice.saved_details", fallback: "Couldn't refresh online details. Showing saved details instead.") - } internal enum ContextMenu { internal enum Button { /// Detail @@ -564,6 +530,42 @@ internal enum L10n { } } } + internal enum Dialog { + internal enum Button { + /// Redownload + internal static let redownload = L10n.tr("Localizable", "detail_view.dialog.button.redownload", fallback: "Redownload") + /// Repair + internal static let repair = L10n.tr("Localizable", "detail_view.dialog.button.repair", fallback: "Repair") + /// Update + internal static let update = L10n.tr("Localizable", "detail_view.dialog.button.update", fallback: "Update") + } + internal enum Message { + /// This will stop the current download and remove the gallery from this device. + internal static let deleteActiveDownload = L10n.tr("Localizable", "detail_view.dialog.message.delete_active_download", fallback: "This will stop the current download and remove the gallery from this device.") + /// This will remove the downloaded gallery from this device. + internal static let deleteDownloadedGallery = L10n.tr("Localizable", "detail_view.dialog.message.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") + /// Start a fresh download for this gallery now? + internal static let redownloadGallery = L10n.tr("Localizable", "detail_view.dialog.message.redownload_gallery", fallback: "Start a fresh download for this gallery now?") + /// Repair the offline files for this gallery now? + internal static let repairDownload = L10n.tr("Localizable", "detail_view.dialog.message.repair_download", fallback: "Repair the offline files for this gallery now?") + /// Update this gallery to the newest online version now? + internal static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.message.update_download", fallback: "Update this gallery to the newest online version now?") + } + internal enum Title { + /// Delete Download? + internal static let deleteDownload = L10n.tr("Localizable", "detail_view.dialog.title.delete_download", fallback: "Delete Download?") + /// Redownload Gallery? + internal static let redownloadGallery = L10n.tr("Localizable", "detail_view.dialog.title.redownload_gallery", fallback: "Redownload Gallery?") + /// Repair Download? + internal static let repairDownload = L10n.tr("Localizable", "detail_view.dialog.title.repair_download", fallback: "Repair Download?") + /// Update Download? + internal static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.title.update_download", fallback: "Update Download?") + } + } + internal enum OfflineNotice { + /// Couldn't refresh online details. Showing saved details instead. + internal static let savedDetails = L10n.tr("Localizable", "detail_view.offline_notice.saved_details", fallback: "Couldn't refresh online details. Showing saved details instead.") + } internal enum Section { internal enum Title { /// Comments @@ -605,7 +607,7 @@ internal enum L10n { internal static let manifestMissing = L10n.tr("Localizable", "download_file_storage.validation.manifest_missing", fallback: "Manifest file is missing.") /// Page %d is missing. internal static func pageMissing(_ p1: Int) -> String { - L10n.tr("Localizable", "download_file_storage.validation.page_missing", p1, fallback: "Page %d is missing.") + return L10n.tr("Localizable", "download_file_storage.validation.page_missing", p1, fallback: "Page %d is missing.") } } } @@ -658,7 +660,7 @@ internal enum L10n { internal enum Button { /// Retry Failed Pages (%d) internal static func retryFailedPages(_ p1: Int) -> String { - L10n.tr("Localizable", "downloads_view.inspector.button.retry_failed_pages", p1, fallback: "Retry Failed Pages (%d)") + return L10n.tr("Localizable", "downloads_view.inspector.button.retry_failed_pages", p1, fallback: "Retry Failed Pages (%d)") } /// Update Download internal static let updateDownload = L10n.tr("Localizable", "downloads_view.inspector.button.update_download", fallback: "Update Download") @@ -670,7 +672,7 @@ internal enum L10n { internal static let tapToRetry = L10n.tr("Localizable", "downloads_view.inspector.page.tap_to_retry", fallback: "Tap to retry this page") /// Page %d internal static func title(_ p1: Int) -> String { - L10n.tr("Localizable", "downloads_view.inspector.page.title", p1, fallback: "Page %d") + return L10n.tr("Localizable", "downloads_view.inspector.page.title", p1, fallback: "Page %d") } } internal enum Section { @@ -942,34 +944,6 @@ internal enum L10n { internal static let off = L10n.tr("Localizable", "enum.auto_play_policy.value.off", fallback: "Off") } } - internal enum DownloadListFilter { - internal enum Title { - /// Active - internal static let active = L10n.tr("Localizable", "enum.download_list_filter.title.active", fallback: "Active") - /// All - internal static let all = L10n.tr("Localizable", "enum.download_list_filter.title.all", fallback: "All") - /// Downloaded - internal static let completed = L10n.tr("Localizable", "enum.download_list_filter.title.completed", fallback: "Downloaded") - /// Needs Attention - internal static let failed = L10n.tr("Localizable", "enum.download_list_filter.title.failed", fallback: "Needs Attention") - /// Update Available - internal static let update = L10n.tr("Localizable", "enum.download_list_filter.title.update", fallback: "Update Available") - } - } - internal enum DownloadThreadMode { - internal enum Value { - /// 2 images at a time - internal static let double = L10n.tr("Localizable", "enum.download_thread_mode.value.double", fallback: "2 images at a time") - /// 4 images at a time - internal static let quadruple = L10n.tr("Localizable", "enum.download_thread_mode.value.quadruple", fallback: "4 images at a time") - /// 5 images at a time - internal static let quintuple = L10n.tr("Localizable", "enum.download_thread_mode.value.quintuple", fallback: "5 images at a time") - /// 1 image at a time - internal static let single = L10n.tr("Localizable", "enum.download_thread_mode.value.single", fallback: "1 image at a time") - /// 3 images at a time - internal static let triple = L10n.tr("Localizable", "enum.download_thread_mode.value.triple", fallback: "3 images at a time") - } - } internal enum BanInterval { internal enum Description { /// Localizable.strings @@ -1511,6 +1485,34 @@ internal enum L10n { internal static let western = L10n.tr("Localizable", "enum.category.value.western", fallback: "Western") } } + internal enum DownloadListFilter { + internal enum Title { + /// Active + internal static let active = L10n.tr("Localizable", "enum.download_list_filter.title.active", fallback: "Active") + /// All + internal static let all = L10n.tr("Localizable", "enum.download_list_filter.title.all", fallback: "All") + /// Downloaded + internal static let completed = L10n.tr("Localizable", "enum.download_list_filter.title.completed", fallback: "Downloaded") + /// Needs Attention + internal static let failed = L10n.tr("Localizable", "enum.download_list_filter.title.failed", fallback: "Needs Attention") + /// Update Available + internal static let update = L10n.tr("Localizable", "enum.download_list_filter.title.update", fallback: "Update Available") + } + } + internal enum DownloadThreadMode { + internal enum Value { + /// 2 images at a time + internal static let double = L10n.tr("Localizable", "enum.download_thread_mode.value.double", fallback: "2 images at a time") + /// 4 images at a time + internal static let quadruple = L10n.tr("Localizable", "enum.download_thread_mode.value.quadruple", fallback: "4 images at a time") + /// 5 images at a time + internal static let quintuple = L10n.tr("Localizable", "enum.download_thread_mode.value.quintuple", fallback: "5 images at a time") + /// 1 image at a time + internal static let single = L10n.tr("Localizable", "enum.download_thread_mode.value.single", fallback: "1 image at a time") + /// 3 images at a time + internal static let triple = L10n.tr("Localizable", "enum.download_thread_mode.value.triple", fallback: "3 images at a time") + } + } internal enum EhSetting { internal enum ArchiverBehavior { internal enum Value { @@ -1877,10 +1879,10 @@ internal enum L10n { internal static let account = L10n.tr("Localizable", "enum.setting_state_route.value.account", fallback: "Account") /// Appearance internal static let appearance = L10n.tr("Localizable", "enum.setting_state_route.value.appearance", fallback: "Appearance") - /// General - internal static let general = L10n.tr("Localizable", "enum.setting_state_route.value.general", fallback: "General") /// Downloads internal static let downloads = L10n.tr("Localizable", "enum.setting_state_route.value.downloads", fallback: "Downloads") + /// General + internal static let general = L10n.tr("Localizable", "enum.setting_state_route.value.general", fallback: "General") /// Laboratory internal static let laboratory = L10n.tr("Localizable", "enum.setting_state_route.value.laboratory", fallback: "Laboratory") /// Reading @@ -2378,32 +2380,12 @@ internal enum L10n { internal static let `none` = L10n.tr("Localizable", "struct.cookie_value.localized_string.none", fallback: "None") } } - internal enum Greeting { - internal enum Mark { - /// and - internal static let and = L10n.tr("Localizable", "struct.greeting.mark.and", fallback: " and ") - /// ! - internal static let end = L10n.tr("Localizable", "struct.greeting.mark.end", fallback: "!") - /// , - internal static let separator = L10n.tr("Localizable", "struct.greeting.mark.separator", fallback: ", ") - /// You gain - internal static let start = L10n.tr("Localizable", "struct.greeting.mark.start", fallback: "You gain ") - } - } - internal enum HathArchive { - internal enum Price { - /// Free - internal static let free = L10n.tr("Localizable", "struct.hath_archive.price.free", fallback: "Free") - /// N/A - internal static let notAvailable = L10n.tr("Localizable", "struct.hath_archive.price.not_available", fallback: "N/A") - } - } internal enum DownloadBadge { internal enum Compact { - /// DL - internal static let downloading = L10n.tr("Localizable", "struct.download_badge.compact.downloading", fallback: "DL") /// Done internal static let done = L10n.tr("Localizable", "struct.download_badge.compact.done", fallback: "Done") + /// DL + internal static let downloading = L10n.tr("Localizable", "struct.download_badge.compact.downloading", fallback: "DL") /// Needs Attention internal static let needsAttention = L10n.tr("Localizable", "struct.download_badge.compact.needs_attention", fallback: "Needs Attention") /// Pause @@ -2414,19 +2396,19 @@ internal enum L10n { internal static let downloaded = L10n.tr("Localizable", "struct.download_badge.text.downloaded", fallback: "Downloaded") /// Downloading %d/%d internal static func downloading(_ p1: Int, _ p2: Int) -> String { - L10n.tr("Localizable", "struct.download_badge.text.downloading", p1, p2, fallback: "Downloading %d/%d") + return L10n.tr("Localizable", "struct.download_badge.text.downloading", p1, p2, fallback: "Downloading %d/%d") } /// Needs Attention internal static let needsAttention = L10n.tr("Localizable", "struct.download_badge.text.needs_attention", fallback: "Needs Attention") /// Needs Attention %d/%d internal static func needsAttentionProgress(_ p1: Int, _ p2: Int) -> String { - L10n.tr("Localizable", "struct.download_badge.text.needs_attention_progress", p1, p2, fallback: "Needs Attention %d/%d") + return L10n.tr("Localizable", "struct.download_badge.text.needs_attention_progress", p1, p2, fallback: "Needs Attention %d/%d") } /// Needs Repair internal static let needsRepair = L10n.tr("Localizable", "struct.download_badge.text.needs_repair", fallback: "Needs Repair") /// Paused %d/%d internal static func paused(_ p1: Int, _ p2: Int) -> String { - L10n.tr("Localizable", "struct.download_badge.text.paused", p1, p2, fallback: "Paused %d/%d") + return L10n.tr("Localizable", "struct.download_badge.text.paused", p1, p2, fallback: "Paused %d/%d") } /// Queued internal static let queued = L10n.tr("Localizable", "struct.download_badge.text.queued", fallback: "Queued") @@ -2434,6 +2416,26 @@ internal enum L10n { internal static let updateAvailable = L10n.tr("Localizable", "struct.download_badge.text.update_available", fallback: "Update Available") } } + internal enum Greeting { + internal enum Mark { + /// and + internal static let and = L10n.tr("Localizable", "struct.greeting.mark.and", fallback: " and ") + /// ! + internal static let end = L10n.tr("Localizable", "struct.greeting.mark.end", fallback: "!") + /// , + internal static let separator = L10n.tr("Localizable", "struct.greeting.mark.separator", fallback: ", ") + /// You gain + internal static let start = L10n.tr("Localizable", "struct.greeting.mark.start", fallback: "You gain ") + } + } + internal enum HathArchive { + internal enum Price { + /// Free + internal static let free = L10n.tr("Localizable", "struct.hath_archive.price.free", fallback: "Free") + /// N/A + internal static let notAvailable = L10n.tr("Localizable", "struct.hath_archive.price.not_available", fallback: "N/A") + } + } internal enum User { internal enum FavoriteCategory { /// All diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index f295f6e9c..0dfce9040 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -504,40 +504,40 @@ private struct HeaderSection: View { } } private var downloadButtonAccessibilityLabel: String { - guard canDownload else { return L10n.Localizable.DetailView.Accessibility.downloadButtonLogin } + guard canDownload else { return L10n.Localizable.DetailView.Accessibility.DownloadButton.login } guard !showsMetadataPreparation else { - return L10n.Localizable.DetailView.Accessibility.downloadButtonPreparing + return L10n.Localizable.DetailView.Accessibility.DownloadButton.preparing } switch downloadBadge { case .none: - return L10n.Localizable.DetailView.Accessibility.downloadButtonDownload + return L10n.Localizable.DetailView.Accessibility.DownloadButton.download case .queued: - return L10n.Localizable.DetailView.Accessibility.downloadButtonQueued + return L10n.Localizable.DetailView.Accessibility.DownloadButton.queued case .downloading(let completed, let total): - let progress = L10n.Localizable.DetailView.Accessibility.downloadButtonDownloading( + let progress = L10n.Localizable.DetailView.Accessibility.DownloadButton.downloading( completed, max(total, 1) ) - return [progress, L10n.Localizable.DetailView.Accessibility.downloadButtonPauseAction] + return [progress, L10n.Localizable.DetailView.Accessibility.DownloadButton.pauseAction] .joined(separator: ". ") case .paused(let completed, let total): - return L10n.Localizable.DetailView.Accessibility.downloadButtonPaused( + return L10n.Localizable.DetailView.Accessibility.DownloadButton.paused( completed, max(total, 1) ) case .downloaded: - return L10n.Localizable.DetailView.Accessibility.downloadButtonDownloaded + return L10n.Localizable.DetailView.Accessibility.DownloadButton.downloaded case .updateAvailable: - return L10n.Localizable.DetailView.Accessibility.downloadButtonUpdate + return L10n.Localizable.DetailView.Accessibility.DownloadButton.update case .partial(let completed, let total): - return L10n.Localizable.DetailView.Accessibility.downloadButtonPartial( + return L10n.Localizable.DetailView.Accessibility.DownloadButton.partial( completed, max(total, 1) ) case .failed: - return L10n.Localizable.DetailView.Accessibility.downloadButtonRetry + return L10n.Localizable.DetailView.Accessibility.DownloadButton.retry case .missingFiles: - return L10n.Localizable.DetailView.Accessibility.downloadButtonRepair + return L10n.Localizable.DetailView.Accessibility.DownloadButton.repair } } private var showsMetadataPreparation: Bool { From 7be9140fc5a95adaa6a0c73d3500bb3ee7059547 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 27 Mar 2026 22:21:14 +0800 Subject: [PATCH 004/614] Resolve code review issues --- EhPanda/App/Tools/Extensions/Extensions.swift | 2 +- .../Tools/Utilities/DownloadFileStorage.swift | 47 +- EhPanda/View/Detail/DetailReducer.swift | 2 +- EhPanda/View/Detail/DetailView.swift | 4 +- EhPanda/View/Downloads/DownloadsView.swift | 2 + EhPanda/View/Home/HomeReducer.swift | 7 +- .../DownloadFeatureReducerTests.swift | 412 +++++++----------- 7 files changed, 207 insertions(+), 269 deletions(-) diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift index 2db14f637..bafce7dcb 100644 --- a/EhPanda/App/Tools/Extensions/Extensions.swift +++ b/EhPanda/App/Tools/Extensions/Extensions.swift @@ -30,7 +30,7 @@ extension Data { var data = self data.append(0) - let str = Array(self).withUnsafeBufferPointer { ptr -> String? in + let str = Array(data).withUnsafeBufferPointer { ptr -> String? in guard let address = ptr.baseAddress else { return nil } return String(cString: address) } diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 8f543bf46..c1a7163da 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -76,6 +76,18 @@ struct DownloadFileStorage { rootURL.appendingPathComponent(relativePath, isDirectory: true) } + private func validatedChildURL( + root: URL, relativePath: String + ) -> URL? { + let resolved = root + .appendingPathComponent(relativePath) + .standardizedFileURL + guard resolved.path.hasPrefix(root.standardizedFileURL.path + "/") else { + return nil + } + return resolved + } + func manifestURL(relativePath: String) -> URL { folderURL(relativePath: relativePath) .appendingPathComponent(Defaults.FilePath.downloadManifest) @@ -283,24 +295,21 @@ struct DownloadFileStorage { ) if let coverRelativePath = manifest.coverRelativePath, - coverRelativePath.notEmpty + coverRelativePath.notEmpty, + let sourceCoverURL = validatedChildURL(root: sourceFolderURL, relativePath: coverRelativePath), + let destCoverURL = validatedChildURL(root: temporaryFolderURL, relativePath: coverRelativePath) { - let sourceCoverURL = sourceFolderURL.appendingPathComponent(coverRelativePath) if sanitizeAssetFileIfNeeded(at: sourceCoverURL) { - try linkOrCopyReadableAsset( - at: sourceCoverURL, - to: temporaryFolderURL.appendingPathComponent(coverRelativePath) - ) + try linkOrCopyReadableAsset(at: sourceCoverURL, to: destCoverURL) } } for page in manifest.pages { - let sourcePageURL = sourceFolderURL.appendingPathComponent(page.relativePath) + guard let sourcePageURL = validatedChildURL(root: sourceFolderURL, relativePath: page.relativePath), + let destPageURL = validatedChildURL(root: temporaryFolderURL, relativePath: page.relativePath) + else { continue } guard sanitizeAssetFileIfNeeded(at: sourcePageURL) else { continue } - try linkOrCopyReadableAsset( - at: sourcePageURL, - to: temporaryFolderURL.appendingPathComponent(page.relativePath) - ) + try linkOrCopyReadableAsset(at: sourcePageURL, to: destPageURL) } } @@ -346,14 +355,16 @@ struct DownloadFileStorage { if let coverRelativePath = manifest.coverRelativePath, !coverRelativePath.isEmpty { - let coverURL = folderURL.appendingPathComponent(coverRelativePath) - guard sanitizeAssetFileIfNeeded(at: coverURL) else { + guard let coverURL = validatedChildURL(root: folderURL, relativePath: coverRelativePath), + sanitizeAssetFileIfNeeded(at: coverURL) + else { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.coverImageMissing) } } for page in manifest.pages { - let pageURL = folderURL.appendingPathComponent(page.relativePath) - guard sanitizeAssetFileIfNeeded(at: pageURL) else { + guard let pageURL = validatedChildURL(root: folderURL, relativePath: page.relativePath), + sanitizeAssetFileIfNeeded(at: pageURL) + else { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.pageMissing(page.index)) } } @@ -362,7 +373,7 @@ struct DownloadFileStorage { func validPageCount(folderURL: URL, manifest: DownloadManifest) -> Int { manifest.pages.reduce(into: 0) { count, page in - let pageURL = folderURL.appendingPathComponent(page.relativePath) + guard let pageURL = validatedChildURL(root: folderURL, relativePath: page.relativePath) else { return } if sanitizeAssetFileIfNeeded(at: pageURL) { count += 1 } @@ -381,7 +392,7 @@ struct DownloadFileStorage { do { attributes = try fileManager.attributesOfItem(atPath: url.path) } catch { - return true + return false } let isRegularFile = (attributes[.type] as? FileAttributeType).map { $0 == .typeRegular } ?? true @@ -389,7 +400,7 @@ struct DownloadFileStorage { try? fileManager.removeItem(at: url) return false } - guard let fileSize = (attributes[.size] as? NSNumber)?.intValue else { return true } + guard let fileSize = (attributes[.size] as? NSNumber)?.intValue else { return false } guard fileSize > 0 else { try? fileManager.removeItem(at: url) return false diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 3d9f487dc..13c62e163 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -96,7 +96,7 @@ struct DetailReducer { userRating: 0, ratingCount: 0, category: download.category, - language: .japanese, + language: .other, uploader: download.uploader ?? "", postedDate: download.postedDate, coverURL: download.coverURL, diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 0dfce9040..07eebba2e 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -321,9 +321,7 @@ struct DetailView: View { switch store.downloadBadge { case .none: store.send(.startDownload(options)) - case .queued: - break - case .downloading, .paused: + case .queued, .downloading, .paused: store.send(.toggleDownloadPause) case .downloaded: downloadDialog = .delete(isActiveDownload: false) diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift index 01c3f1587..9fc599f46 100644 --- a/EhPanda/View/Downloads/DownloadsView.swift +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -435,6 +435,8 @@ private struct DownloadListRow: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture(perform: openAction) + .accessibilityAddTraits(.isButton) + .accessibilityLabel(download.title) } } diff --git a/EhPanda/View/Home/HomeReducer.swift b/EhPanda/View/Home/HomeReducer.swift index 4f2551597..ba94cc2d6 100644 --- a/EhPanda/View/Home/HomeReducer.swift +++ b/EhPanda/View/Home/HomeReducer.swift @@ -293,12 +293,17 @@ struct HomeReducer { case .observeDownloadsDone(let downloads): let visibleGIDs = state.visibleGalleryIDs - state.downloadBadges = Dictionary( + let downloadedGIDs = Set(downloads.map(\.gid)) + let newBadges = [String: DownloadBadge]( uniqueKeysWithValues: downloads.compactMap { download in guard visibleGIDs.contains(download.gid) else { return nil } return (download.gid, download.badge) } ) + state.downloadBadges.merge(newBadges, uniquingKeysWith: { _, new in new }) + for gid in state.downloadBadges.keys where !downloadedGIDs.contains(gid) { + state.downloadBadges.removeValue(forKey: gid) + } return .none case .frontpage: diff --git a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift index e8e6677cd..53c3f0b9f 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift @@ -18,13 +18,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testPauseKeepsActiveDownloadPausedWhenDeferredSchedulingRuns() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000)) let rootURL = FileManager.default.temporaryDirectory @@ -38,7 +32,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { urlSession: URLSession(configuration: configuration) ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .downloading, completedPageCount: 7 @@ -69,13 +64,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testPauseUsesTemporaryWorkingSetProgressWhenCancelling() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 1) let rootURL = FileManager.default.temporaryDirectory @@ -90,7 +79,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { urlSession: URLSession(configuration: configuration) ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .downloading, completedPageCount: 1, @@ -133,13 +123,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testReconcileDownloadsNormalizesLegacyFailedStatusToNeedsAttention() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) let rootURL = FileManager.default.temporaryDirectory @@ -153,7 +137,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { urlSession: URLSession(configuration: configuration) ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .failed, completedPageCount: 0, @@ -168,13 +153,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testReconcileDownloadsClearsCancellationLikeGalleryError() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 3) let rootURL = FileManager.default.temporaryDirectory @@ -188,7 +167,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { urlSession: URLSession(configuration: configuration) ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .partial, completedPageCount: 4, @@ -207,13 +187,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testLoadInspectionFiltersCancellationFailuresIntoPendingPages() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 4) let rootURL = FileManager.default.temporaryDirectory @@ -228,7 +202,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { urlSession: URLSession(configuration: configuration) ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .partial, completedPageCount: 1, @@ -1412,13 +1387,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000)) let rootURL = FileManager.default.temporaryDirectory @@ -1430,7 +1399,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { urlSession: .shared ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .failed, completedPageCount: 1, @@ -1471,13 +1441,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testDownloadManagerLoadLocalPageURLsPrefersCompletedFolderForCompletedDownload() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 11) let rootURL = FileManager.default.temporaryDirectory @@ -1490,7 +1454,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { urlSession: .shared ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .completed, completedPageCount: 2, @@ -1534,13 +1499,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testDownloadManagerLoadLocalPageURLsMergesReadableCompletedPagesWithTemporaryPages() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 12) let rootURL = FileManager.default.temporaryDirectory @@ -1553,7 +1512,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { urlSession: .shared ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .downloading, completedPageCount: 2, @@ -1710,13 +1670,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testDownloadManagerLoadLocalPageURLsMarksCompletedDownloadMissingFilesWhenZeroBytePageIsFound() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 13) let rootURL = FileManager.default.temporaryDirectory @@ -1729,7 +1683,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { urlSession: .shared ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .completed, completedPageCount: 2, @@ -1790,13 +1745,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testRetryPagesQueuesWorkWhenAnotherDownloadIsActive() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) let rootURL = FileManager.default.temporaryDirectory @@ -1809,7 +1758,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { urlSession: .shared ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .partial, completedPageCount: 1, @@ -1862,13 +1812,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testCancelQueuedRepairRestoresReadableCountAndClearsPendingOperation() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = "cancel-repair-\(UUID().uuidString)" let rootURL = FileManager.default.temporaryDirectory @@ -1881,7 +1825,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { urlSession: .shared ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .missingFiles, completedPageCount: 0, @@ -1922,13 +1867,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testRetryPagesUsesMinimalSourceResolutionAndSkipsWhenNoPendingPages() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() + let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 200) let pageIndex = 42 @@ -1938,6 +1878,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, @@ -1959,7 +1900,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ]] ]) - SharedSessionStubURLProtocol.requestHandler = { request in + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in guard let url = request.url else { throw URLError(.badURL) } @@ -2060,8 +2001,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } URLProtocol.registerClass(SharedSessionStubURLProtocol.self) defer { - SharedSessionStubURLProtocol.requestHandler = nil - URLProtocol.unregisterClass(SharedSessionStubURLProtocol.self) + SharedSessionStubURLProtocol.removeHandler(for: sessionID) } let scaffoldDownload = sampleDownload( @@ -2120,7 +2060,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) } - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .partial, completedPageCount: pageCount - 1, @@ -2136,8 +2077,9 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { XCTAssertEqual(firstRunSnapshot.previewPageNumbers, [1]) recorder.reset() - try await clearPersistedDownloads() - try await insertPersistedDownload( + try clearPersistedDownloads(in: container) + try insertPersistedDownload( + in: container, gid: gid, status: .partial, completedPageCount: pageCount, @@ -2156,13 +2098,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testRetryPagesFallsBackToFullUpdateWhenGalleryHasUpdate() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() + let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) let pageIndex = 42 @@ -2175,6 +2112,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let queueingManager = DownloadManager( storage: storage, @@ -2199,7 +2137,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ]] ]) - SharedSessionStubURLProtocol.requestHandler = { request in + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in guard let url = request.url else { throw URLError(.badURL) } @@ -2274,8 +2212,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } URLProtocol.registerClass(SharedSessionStubURLProtocol.self) defer { - SharedSessionStubURLProtocol.requestHandler = nil - URLProtocol.unregisterClass(SharedSessionStubURLProtocol.self) + SharedSessionStubURLProtocol.removeHandler(for: sessionID) } let scaffoldDownload = sampleDownload( @@ -2300,7 +2237,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) // Queued update path: retryPages should queue a full update and keep no page-selection state. - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .partial, completedPageCount: oldCount - 1, @@ -2334,7 +2272,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { XCTAssertNotEqual(queuedResumeState.pageSelection, [pageIndex]) } - try await clearPersistedDownloads() + try clearPersistedDownloads(in: container) try? storage.removeTemporaryFolder(gid: gid) // Immediate update path: retryPages should normalize the working set to full-update semantics. @@ -2374,7 +2312,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ), folderURL: temporaryFolderURL ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .partial, completedPageCount: oldCount - 1, @@ -2407,13 +2346,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testProcessDownloadClearsStalePageSelectionWhenLatestPayloadRevealsUpdate() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() + let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 401) let pageIndex = 42 @@ -2426,6 +2360,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, @@ -2447,7 +2382,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ]] ]) - SharedSessionStubURLProtocol.requestHandler = { request in + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in guard let url = request.url else { throw URLError(.badURL) } @@ -2522,8 +2457,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } URLProtocol.registerClass(SharedSessionStubURLProtocol.self) defer { - SharedSessionStubURLProtocol.requestHandler = nil - URLProtocol.unregisterClass(SharedSessionStubURLProtocol.self) + SharedSessionStubURLProtocol.removeHandler(for: sessionID) } let scaffoldDownload = sampleDownload( @@ -2550,7 +2484,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let oldPageCount = updatedPageCount - 5 XCTAssertNotEqual(oldPageCount, updatedPageCount) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .partial, completedPageCount: oldPageCount - 1, @@ -2627,13 +2562,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { @MainActor func testProcessDownloadClearsRemoteAssetCacheAfterSuccessfulDownload() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() + let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 402) let pageIndex = 42 @@ -2646,6 +2576,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, @@ -2682,7 +2613,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ]] ]) - SharedSessionStubURLProtocol.requestHandler = { request in + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in guard let url = request.url else { throw URLError(.badURL) } @@ -2757,8 +2688,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } URLProtocol.registerClass(SharedSessionStubURLProtocol.self) defer { - SharedSessionStubURLProtocol.requestHandler = nil - URLProtocol.unregisterClass(SharedSessionStubURLProtocol.self) + SharedSessionStubURLProtocol.removeHandler(for: sessionID) } let scaffoldDownload = sampleDownload( @@ -2801,7 +2731,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { XCTAssertGreaterThan(updatedPageCount, pageIndex) XCTAssertGreaterThan(oldPageCount, 0) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .partial, completedPageCount: oldPageCount - 1, @@ -2809,7 +2740,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { remoteVersionSignature: oldVersionSignature, latestRemoteVersionSignature: oldVersionSignature ) - try await insertPersistedGalleryState( + try insertPersistedGalleryState( + in: container, gid: gid, previewURLs: [1: combinedPreviewURL], imageURLs: [1: staleStoredPageURL] @@ -3905,13 +3837,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { @MainActor func testDownloadManagerCaptureCachedPageRestoresTemporaryPageAndUpdatesCompletedCount() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 27) let rootURL = FileManager.default.temporaryDirectory @@ -3923,7 +3849,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { storage: storage, urlSession: .shared ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .downloading, completedPageCount: 0, @@ -3967,13 +3894,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { @MainActor func testDownloadManagerCaptureCachedPageRepairsCompletedDownloadWithLatestRemoteImage() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 28) let rootURL = FileManager.default.temporaryDirectory @@ -3985,7 +3906,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { storage: storage, urlSession: .shared ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .missingFiles, completedPageCount: 1, @@ -4045,13 +3967,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { @MainActor func testDownloadManagerReconcileNormalizesFailedDownloadBeforeTempCleanup() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 31) let rootURL = FileManager.default.temporaryDirectory @@ -4060,7 +3976,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .failed, completedPageCount: 0, @@ -4094,13 +4011,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { @MainActor func testUpdateRemoteSignatureDoesNotMarkUpdateAvailableWhenStoredChainAndLatestHashAreDifferentKinds() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 101) let rootURL = FileManager.default.temporaryDirectory @@ -4111,7 +4022,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), urlSession: .shared ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .completed, completedPageCount: 26, @@ -4130,13 +4042,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { @MainActor func testUpdateRemoteSignatureDoesNotMarkUpdateAvailableWhenStoredHashAndLatestNonOriginalChainAreDifferentKinds() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 102) let rootURL = FileManager.default.temporaryDirectory @@ -4147,7 +4053,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), urlSession: .shared ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .completed, completedPageCount: 26, @@ -4169,13 +4076,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { @MainActor func testUpdateRemoteSignatureCanonicalizesStoredHashToOriginalChainWithoutMarkingUpdate() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 103) let rootURL = FileManager.default.temporaryDirectory @@ -4186,7 +4087,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), urlSession: .shared ) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .completed, completedPageCount: 26, @@ -4269,8 +4171,9 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let galleryState = sampleGalleryState(gid: gallery.gid) - try installGalleryVersionMetadataStub(for: gallery) - defer { uninstallSharedSessionStub() } + let sessionID = UUID().uuidString + try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) + defer { uninstallSharedSessionStub(sessionID: sessionID) } var initialState = DetailReducer.State() initialState.gid = gallery.gid @@ -4333,8 +4236,9 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let galleryState = sampleGalleryState(gid: gallery.gid) - try installGalleryVersionMetadataStub(for: gallery) - defer { uninstallSharedSessionStub() } + let sessionID = UUID().uuidString + try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) + defer { uninstallSharedSessionStub(sessionID: sessionID) } var initialState = DetailReducer.State() initialState.gid = gallery.gid @@ -4396,8 +4300,9 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - try installGalleryVersionMetadataStub(for: gallery) - defer { uninstallSharedSessionStub() } + let sessionID = UUID().uuidString + try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) + defer { uninstallSharedSessionStub(sessionID: sessionID) } var initialState = DetailReducer.State() initialState.gid = gallery.gid @@ -4452,8 +4357,9 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - try installGalleryVersionMetadataStub(for: gallery) - defer { uninstallSharedSessionStub() } + let sessionID = UUID().uuidString + try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) + defer { uninstallSharedSessionStub(sessionID: sessionID) } var initialState = DetailReducer.State() initialState.gid = gallery.gid @@ -4704,13 +4610,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { @MainActor func testCachedQuotaPlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 32) let rootURL = FileManager.default.temporaryDirectory @@ -4722,7 +4622,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let normalImageURL = try XCTUnwrap( URL(string: "https://ehgt.org/h/quota-placeholder-cache-\(gid)/1") ) - try await insertPersistedGalleryState(gid: gid, imageURLs: [1: normalImageURL]) + try insertPersistedGalleryState(in: container, gid: gid, imageURLs: [1: normalImageURL]) let placeholderURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) defer { try? FileManager.default.removeItem(at: placeholderURL) } @@ -4787,13 +4687,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { @MainActor func testCachedKokomadePlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 33) let rootURL = FileManager.default.temporaryDirectory @@ -4803,7 +4697,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) let normalImageURL = try XCTUnwrap(URL(string: "https://exhentai.org/fullimg.php?gid=\(gid)&page=1&key=normal-cache-key")) - try await insertPersistedGalleryState(gid: gid, imageURLs: [1: normalImageURL]) + try insertPersistedGalleryState(in: container, gid: gid, imageURLs: [1: normalImageURL]) let imageData = try fixtureData(resource: "Kokomade", pathExtension: "jpg") let cacheKeys = normalImageURL.imageCacheKeys(includeStableAlias: true) @@ -5042,8 +4936,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } func testIpBannedDoesNotRetryImmediately() async throws { + let sessionID = UUID().uuidString let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] let manager = DownloadManager( storage: DownloadFileStorage( rootURL: FileManager.default.temporaryDirectory @@ -5054,7 +4950,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) let recorder = RequestRecorder() let ipBannedHTML = try fixtureData(resource: HTMLFilename.ipBanned.rawValue, pathExtension: "html") - SharedSessionStubURLProtocol.requestHandler = { request in + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in recorder.recordDetail() return ( HTTPURLResponse( @@ -5067,7 +4963,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) } defer { - SharedSessionStubURLProtocol.requestHandler = nil + SharedSessionStubURLProtocol.removeHandler(for: sessionID) } let download = sampleDownload( @@ -5478,13 +5374,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { @MainActor func testDownloadManagerBatchesObserverUpdatesDuringCachedPageRestore() async throws { - try await preparePersistenceStore() - try await clearPersistedDownloads() - defer { - Task { - try? await self.clearPersistedDownloads() - } - } + let container = makeInMemoryContainer() let pageCount = 20 let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 104) @@ -5494,7 +5384,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) - try await insertPersistedDownload( + try insertPersistedDownload( + in: container, gid: gid, status: .downloading, completedPageCount: 0, @@ -5509,7 +5400,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let imageURLs = Dictionary(uniqueKeysWithValues: (1...pageCount).map { index in (index, URL(string: "https://example.com/pages/\(gid)-\(index).jpg")!) }) - try await insertPersistedGalleryState(gid: gid, imageURLs: imageURLs) + try insertPersistedGalleryState(in: container, gid: gid, imageURLs: imageURLs) let cacheKeys = Set(imageURLs.values.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) for cacheKey in cacheKeys { KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) @@ -5690,7 +5581,7 @@ private extension DownloadFeatureReducerTests { return try Data(contentsOf: fixtureURL) } - func installGalleryVersionMetadataStub(for gallery: Gallery) throws { + func installGalleryVersionMetadataStub(for gallery: Gallery, sessionID: String) throws { let gid = try XCTUnwrap(Int(gallery.gid)) let payload: [String: Any] = [ "gmetadata": [[ @@ -5705,7 +5596,7 @@ private extension DownloadFeatureReducerTests { ]] ] let responseData = try JSONSerialization.data(withJSONObject: payload, options: []) - SharedSessionStubURLProtocol.requestHandler = { request in + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in let response = HTTPURLResponse( url: request.url ?? Defaults.URL.api, statusCode: 200, @@ -5717,9 +5608,8 @@ private extension DownloadFeatureReducerTests { URLProtocol.registerClass(SharedSessionStubURLProtocol.self) } - func uninstallSharedSessionStub() { - SharedSessionStubURLProtocol.requestHandler = nil - URLProtocol.unregisterClass(SharedSessionStubURLProtocol.self) + func uninstallSharedSessionStub(sessionID: String) { + SharedSessionStubURLProtocol.removeHandler(for: sessionID) } func sampleGallery() -> Gallery { @@ -5880,22 +5770,25 @@ private extension DownloadFeatureReducerTests { return folderURL } - func preparePersistenceStore() async throws { - if !PersistenceController.shared.container.persistentStoreCoordinator.persistentStores.isEmpty { - return - } - - let result: Result = await withCheckedContinuation { continuation in - PersistenceController.shared.prepare { result in - continuation.resume(returning: result) - } + func makeInMemoryContainer() -> NSPersistentContainer { + let modelURL = Bundle(for: Self.self).url(forResource: "Model", withExtension: "momd") + ?? Bundle.main.url(forResource: "Model", withExtension: "momd")! + let model = NSManagedObjectModel(contentsOf: modelURL)! + let container = NSPersistentContainer(name: UUID().uuidString, managedObjectModel: model) + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + container.persistentStoreDescriptions = [description] + let expectation = XCTestExpectation(description: "Load in-memory store") + container.loadPersistentStores { _, error in + XCTAssertNil(error) + expectation.fulfill() } - try result.get() + _ = XCTWaiter.wait(for: [expectation], timeout: 5) + return container } - @MainActor - func clearPersistedDownloads() throws { - let context = PersistenceController.shared.container.viewContext + func clearPersistedDownloads(in container: NSPersistentContainer) throws { + let context = container.viewContext let downloadRequest = NSFetchRequest(entityName: "DownloadedGalleryMO") let downloads = try context.fetch(downloadRequest) for object in downloads { @@ -5910,8 +5803,8 @@ private extension DownloadFeatureReducerTests { try context.save() } - @MainActor func insertPersistedDownload( + in container: NSPersistentContainer, gid: String, status: DownloadStatus, completedPageCount: Int, @@ -5922,7 +5815,7 @@ private extension DownloadFeatureReducerTests { lastError: DownloadFailure? = nil, pendingOperation: DownloadStartMode? = nil ) throws { - let context = PersistenceController.shared.container.viewContext + let context = container.viewContext let object = DownloadedGalleryMO(context: context) object.gid = gid object.host = GalleryHost.ehentai.rawValue @@ -5949,14 +5842,14 @@ private extension DownloadFeatureReducerTests { try context.save() } - @MainActor func insertPersistedGalleryState( + in container: NSPersistentContainer, gid: String, previewURLs: [Int: URL] = [:], imageURLs: [Int: URL], originalImageURLs: [Int: URL] = [:] ) throws { - let context = PersistenceController.shared.container.viewContext + let context = container.viewContext let object = GalleryStateMO(context: context) object.gid = gid object.previewURLs = previewURLs.toData() @@ -6076,10 +5969,39 @@ private final class FailFastURLProtocol: URLProtocol { } private final class SharedSessionStubURLProtocol: URLProtocol { - static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static let headerKey = "X-TestSession-ID" + + private static let lock = NSLock() + private static var handlers: [String: (URLRequest) throws -> (HTTPURLResponse, Data)] = [:] + + static func setHandler( + for sessionID: String, + handler: @escaping (URLRequest) throws -> (HTTPURLResponse, Data) + ) { + lock.lock() + defer { lock.unlock() } + handlers[sessionID] = handler + } + + static func removeHandler(for sessionID: String) { + lock.lock() + defer { lock.unlock() } + handlers[sessionID] = nil + } + + private static func handler( + for request: URLRequest + ) -> ((URLRequest) throws -> (HTTPURLResponse, Data))? { + guard let sessionID = request.value(forHTTPHeaderField: headerKey) else { + return nil + } + lock.lock() + defer { lock.unlock() } + return handlers[sessionID] + } override class func canInit(with request: URLRequest) -> Bool { - requestHandler != nil + handler(for: request) != nil } override class func canonicalRequest(for request: URLRequest) -> URLRequest { @@ -6087,7 +6009,7 @@ private final class SharedSessionStubURLProtocol: URLProtocol { } override func startLoading() { - guard let handler = Self.requestHandler else { + guard let handler = Self.handler(for: request) else { client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) return } From 812c037a5d4b259dd525613931ae43ae0be1c211 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 27 Mar 2026 23:22:03 +0800 Subject: [PATCH 005/614] Resolve an issue that causes test last forever --- .../App/Tools/Clients/DownloadClient.swift | 22 +++++++---- .../DownloadFeatureReducerTests.swift | 37 ++++++++++++++++++- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index e8eb63528..cc526b753 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -226,15 +226,21 @@ actor DownloadManager { private let storage: DownloadFileStorage private let urlSession: URLSession + private let persistenceContainer: NSPersistentContainer private var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() private var lastObservedDownloads = [DownloadedGallery]() private var activeGalleryID: String? private var activeTask: Task? private var schedulingBlockedGalleryIDs = Set() - init(storage: DownloadFileStorage, urlSession: URLSession) { + init( + storage: DownloadFileStorage, + urlSession: URLSession, + persistenceContainer: NSPersistentContainer = PersistenceController.shared.container + ) { self.storage = storage self.urlSession = urlSession + self.persistenceContainer = persistenceContainer } func observeDownloads() -> AsyncStream<[DownloadedGallery]> { @@ -3444,7 +3450,7 @@ actor DownloadManager { guard !validEntries.isEmpty else { return } await MainActor.run { - let context = PersistenceController.shared.container.viewContext + let context = persistenceContainer.viewContext let request = NSFetchRequest(entityName: "GalleryStateMO") request.fetchLimit = 1 request.predicate = NSPredicate(format: "gid == %@", gid) @@ -3483,7 +3489,7 @@ actor DownloadManager { private func fetchCachedGalleryImageState(gid: String) async -> CachedGalleryImageState? { await MainActor.run { guard gid.isValidGID else { return nil } - let context = PersistenceController.shared.container.viewContext + let context = persistenceContainer.viewContext let request = NSFetchRequest(entityName: "GalleryStateMO") request.fetchLimit = 1 request.predicate = NSPredicate(format: "gid == %@", gid) @@ -3666,7 +3672,7 @@ actor DownloadManager { fileprivate func fetchDownload(gid: String) async -> DownloadedGallery? { await MainActor.run { - let context = PersistenceController.shared.container.viewContext + let context = persistenceContainer.viewContext let request = NSFetchRequest( entityName: "DownloadedGalleryMO" ) @@ -3678,7 +3684,7 @@ actor DownloadManager { private func fetchDownloadsFromStore() async -> [DownloadedGallery] { await MainActor.run { - let context = PersistenceController.shared.container.viewContext + let context = persistenceContainer.viewContext let request = NSFetchRequest( entityName: "DownloadedGalleryMO" ) @@ -3695,7 +3701,7 @@ actor DownloadManager { private func fetchDownloadsFromStore(gids: [String]) async -> [DownloadedGallery] { await MainActor.run { - let context = PersistenceController.shared.container.viewContext + let context = persistenceContainer.viewContext let request = NSFetchRequest( entityName: "DownloadedGalleryMO" ) @@ -3717,7 +3723,7 @@ actor DownloadManager { update: @escaping (DownloadedGalleryMO) -> Void ) async throws { try await MainActor.run { - let context = PersistenceController.shared.container.viewContext + let context = persistenceContainer.viewContext let request = NSFetchRequest( entityName: "DownloadedGalleryMO" ) @@ -3757,7 +3763,7 @@ actor DownloadManager { private func deleteDownloadRecord(gid: String) async throws { try await MainActor.run { - let context = PersistenceController.shared.container.viewContext + let context = persistenceContainer.viewContext let request = NSFetchRequest( entityName: "DownloadedGalleryMO" ) diff --git a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift index 53c3f0b9f..b854b9d0a 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift @@ -5383,7 +5383,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + persistenceContainer: container + ) try insertPersistedDownload( in: container, gid: gid, @@ -5465,7 +5469,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) - let emissionCount = await emissionTask.value + let emissionCount = try await waitForTaskValue( + emissionTask, + timeout: .seconds(2), + description: "observer updates for cached page restore" + ) let stored = await manager.testingFetchDownload(gid: gid) XCTAssertEqual(restoredCount, pageCount) @@ -5497,6 +5505,31 @@ private extension DownloadFeatureReducerTests { ) } + func waitForTaskValue( + _ task: Task, + timeout: Duration = .seconds(1), + description: String + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + await task.value + } + group.addTask { + try await Task.sleep(for: timeout) + task.cancel() + throw NSError( + domain: "DownloadFeatureReducerTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Timed out waiting for \(description)"] + ) + } + + let value = try await group.next() + group.cancelAll() + return try XCTUnwrap(value) + } + } + @MainActor func drainDetailMetadataEffects( _ store: TestStoreOf, From fd26b0f3446182398247db48327bed9c297aeb09 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 28 Mar 2026 00:26:02 +0800 Subject: [PATCH 006/614] Rewrite all tests using Swift Testing --- .gitignore | 1 + EhPanda.xcodeproj/project.pbxproj | 2 - .../Resources/Utility/Extensions.swift | 12 - .../Resources/Utility/TestHelper.swift | 11 +- .../DownloadFeatureReducerTests.swift | 900 ++++++++++-------- .../Download/DownloadFileStorageTests.swift | 111 ++- .../DownloadSignatureBuilderTests.swift | 63 +- .../Gallery/GalleryDetailParserTests.swift | 51 +- .../Gallery/GalleryImageURLParserTests.swift | 13 +- .../Gallery/GalleryMPVKeysParserTests.swift | 9 +- .../Tests/Parser/List/ListParserTests.swift | 11 +- .../Parser/Other/BanIntervalParserTests.swift | 7 +- .../Other/DownloadPageErrorParserTests.swift | 65 +- .../Parser/Other/EhSettingParserTests.swift | 109 +-- .../Parser/Other/GreetingParserTests.swift | 19 +- .../Parser/Other/SettingDownloadTests.swift | 53 +- 16 files changed, 784 insertions(+), 653 deletions(-) delete mode 100644 EhPandaTests/Resources/Utility/Extensions.swift diff --git a/.gitignore b/.gitignore index 962609f19..eafee22ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.xcode-home EhPanda.xcodeproj/xcuserdata EhPanda.xcodeproj/project.xcworkspace/xcuserdata Config/LocalSigning.xcconfig diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 0e012ea48..2ceca52aa 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -183,7 +183,6 @@ ABA732DF25A852D800B3D9AB /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA732DE25A852D800B3D9AB /* Filter.swift */; }; ABA9A6BC28EC786100EE28DE /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = ABA9A6BB28EC786100EE28DE /* swiftgen.yml */; }; ABA9A6C228EC7BD000EE28DE /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA9A6C128EC7BD000EE28DE /* Strings.swift */; }; - ABAB5B9527EF023300198597 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABAB5B9427EF023300198597 /* Extensions.swift */; }; ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */ = {isa = PBXBuildFile; productRef = ABAC82FD26BC4A96009F5026 /* OpenCC */; }; ABBB2631278E6EF3007B6149 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2630278E6EF3007B6149 /* SearchView.swift */; }; ABBB2636278FB888007B6149 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = ABBB2635278FB888007B6149 /* SwiftUINavigation */; }; @@ -2120,7 +2119,6 @@ AB31CD3B27B66E0300F40E0A /* ListParserTestType.swift in Sources */, ABD9770E27B65A7300983DE7 /* ListParserTests.swift in Sources */, EAB100012F1E000100000002 /* SettingDownloadTests.swift in Sources */, - ABAB5B9527EF023300198597 /* Extensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/EhPandaTests/Resources/Utility/Extensions.swift b/EhPandaTests/Resources/Utility/Extensions.swift deleted file mode 100644 index 1b21cf70e..000000000 --- a/EhPandaTests/Resources/Utility/Extensions.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Extensions.swift -// EhPandaTests -// - -import XCTest - -extension XCTWaiter { - static func wait(timeout: TimeInterval) { - _ = Self.wait(for: [.init()], timeout: timeout) - } -} diff --git a/EhPandaTests/Resources/Utility/TestHelper.swift b/EhPandaTests/Resources/Utility/TestHelper.swift index a26a72de7..bd853b6a9 100644 --- a/EhPandaTests/Resources/Utility/TestHelper.swift +++ b/EhPandaTests/Resources/Utility/TestHelper.swift @@ -4,13 +4,18 @@ // import Kanna -import XCTest +import Testing +import Foundation protocol TestHelper {} -extension TestHelper where Self: XCTestCase { +final class TestBundleLocator {} + +extension TestHelper { func htmlDocument(filename: HTMLFilename) throws -> HTMLDocument { - guard let url = Bundle(for: Self.self).url(forResource: filename.rawValue, withExtension: "html") else { + guard let url = Bundle(for: TestBundleLocator.self) + .url(forResource: filename.rawValue, withExtension: "html") + else { throw TestError.htmlDocumentNotFound(filename) } return try Kanna.HTML(url: url, encoding: .utf8) diff --git a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift index b854b9d0a..492497b58 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift @@ -7,16 +7,18 @@ import CoreData import ComposableArchitecture import Kingfisher import UIKit -import XCTest +import Testing @testable import EhPanda -final class DownloadFeatureReducerTests: XCTestCase, TestHelper { +struct DownloadFeatureReducerTests: TestHelper { + @Test func testQuickSearchWordUsesNameWhenContentIsEmpty() { let word = QuickSearchWord(name: "artist:hossy", content: "") - XCTAssertEqual(word.effectiveSearchText, "artist:hossy") + #expect(word.effectiveSearchText == "artist:hossy") } + @Test func testPauseKeepsActiveDownloadPausedWhenDeferredSchedulingRuns() async throws { let container = makeInMemoryContainer() @@ -51,18 +53,20 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let result = await manager.togglePause(gid: gid) guard case .success = result else { - return XCTFail("Pause should succeed, got \(result)") + Issue.record("Pause should succeed, got \(result)") + return } try await Task.sleep(for: .milliseconds(100)) let stored = await manager.testingFetchDownload(gid: gid) let activeGalleryID = await manager.testingActiveGalleryID() - XCTAssertEqual(stored?.status, .paused) - XCTAssertEqual(stored?.badge, .paused(7, 26)) - XCTAssertNil(activeGalleryID) + #expect(stored?.status == .paused) + #expect(stored?.badge == .paused(7, 26)) + #expect(activeGalleryID == nil) } + @Test func testPauseUsesTemporaryWorkingSetProgressWhenCancelling() async throws { let container = makeInMemoryContainer() @@ -113,15 +117,17 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let result = await manager.togglePause(gid: gid) guard case .success = result else { - return XCTFail("Pause should succeed, got \(result)") + Issue.record("Pause should succeed, got \(result)") + return } let stored = await manager.testingFetchDownload(gid: gid) - XCTAssertEqual(stored?.status, .paused) - XCTAssertEqual(stored?.completedPageCount, 2) - XCTAssertEqual(stored?.badge, .paused(2, 2)) + #expect(stored?.status == .paused) + #expect(stored?.completedPageCount == 2) + #expect(stored?.badge == .paused(2, 2)) } + @Test func testReconcileDownloadsNormalizesLegacyFailedStatusToNeedsAttention() async throws { let container = makeInMemoryContainer() @@ -148,10 +154,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await manager.reconcileDownloads() let stored = await manager.testingFetchDownload(gid: gid) - XCTAssertEqual(stored?.status, .partial) - XCTAssertEqual(stored?.badge, .partial(0, 18)) + #expect(stored?.status == .partial) + #expect(stored?.badge == .partial(0, 18)) } + @Test func testReconcileDownloadsClearsCancellationLikeGalleryError() async throws { let container = makeInMemoryContainer() @@ -182,10 +189,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await manager.reconcileDownloads() let stored = await manager.testingFetchDownload(gid: gid) - XCTAssertNil(stored?.lastError) - XCTAssertEqual(stored?.status, .partial) + #expect(stored?.lastError == nil) + #expect(stored?.status == .partial) } + @Test func testLoadInspectionFiltersCancellationFailuresIntoPendingPages() async throws { let container = makeInMemoryContainer() @@ -237,14 +245,16 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let result = await manager.loadInspection(gid: gid) guard case .success(let inspection) = result else { - return XCTFail("Expected inspection to load successfully, got \(result)") + Issue.record("Expected inspection to load successfully, got \(result)") + return } - XCTAssertEqual(inspection.pages[0].status, .downloaded) - XCTAssertEqual(inspection.pages[1].status, .pending) - XCTAssertTrue((try? storage.readFailedPages(folderURL: temporaryFolderURL).pages.isEmpty) ?? true) + #expect(inspection.pages[0].status == .downloaded) + #expect(inspection.pages[1].status == .pending) + #expect((try? storage.readFailedPages(folderURL: temporaryFolderURL).pages.isEmpty) ?? true) } + @Test func testDownloadsFilterMatchesKeywordAndStatus() { let activeDownload = sampleDownload( gid: "101", @@ -263,9 +273,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { state.filter = .active state.keyword = "alpha" - XCTAssertEqual(state.filteredDownloads, [activeDownload]) + #expect(state.filteredDownloads == [activeDownload]) } + @Test func testQueuedRetryWorkAppearsAsActiveDownloadBadge() { let queuedRedownload = sampleDownload( gid: "303", @@ -275,11 +286,12 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { pendingOperation: .redownload ) - XCTAssertEqual(queuedRedownload.pendingOperation, .redownload) - XCTAssertEqual(queuedRedownload.badge, .queued) - XCTAssertTrue(queuedRedownload.matches(filter: .active)) + #expect(queuedRedownload.pendingOperation == .redownload) + #expect(queuedRedownload.badge == .queued) + #expect(queuedRedownload.matches(filter: .active)) } + @Test func testQueuedRepairWorkAppearsAsActiveDownloadBadge() { let queuedRepair = sampleDownload( gid: "404", @@ -289,11 +301,12 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { pendingOperation: .repair ) - XCTAssertEqual(queuedRepair.pendingOperation, .repair) - XCTAssertEqual(queuedRepair.badge, .queued) - XCTAssertTrue(queuedRepair.matches(filter: .active)) + #expect(queuedRepair.pendingOperation == .repair) + #expect(queuedRepair.badge == .queued) + #expect(queuedRepair.matches(filter: .active)) } + @Test func testQueuedUpdateWorkAppearsAsActiveDownloadBadge() { let queuedUpdate = sampleDownload( gid: "414", @@ -304,12 +317,13 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { pendingOperation: .update ) - XCTAssertEqual(queuedUpdate.pendingOperation, .update) - XCTAssertEqual(queuedUpdate.badge, .queued) - XCTAssertTrue(queuedUpdate.matches(filter: .active)) - XCTAssertFalse(queuedUpdate.matches(filter: .update)) + #expect(queuedUpdate.pendingOperation == .update) + #expect(queuedUpdate.badge == .queued) + #expect(queuedUpdate.matches(filter: .active)) + #expect(queuedUpdate.matches(filter: .update) == false) } + @Test func testQueuedResumedUpdateDoesNotPretendToBeInitialWork() { let resumedUpdate = sampleDownload( gid: "415", @@ -320,12 +334,13 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { latestRemoteVersionSignature: "hash:v2" ) - XCTAssertNil(resumedUpdate.pendingOperation) - XCTAssertTrue(resumedUpdate.isQueuedWorkItem) - XCTAssertEqual(resumedUpdate.badge, .queued) - XCTAssertTrue(resumedUpdate.matches(filter: .active)) + #expect(resumedUpdate.pendingOperation == nil) + #expect(resumedUpdate.isQueuedWorkItem) + #expect(resumedUpdate.badge == .queued) + #expect(resumedUpdate.matches(filter: .active)) } + @Test func testPausedDownloadAppearsAsActiveBadge() { let pausedDownload = sampleDownload( gid: "455", @@ -335,10 +350,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { completedPageCount: 4 ) - XCTAssertEqual(pausedDownload.badge, .paused(4, 12)) - XCTAssertTrue(pausedDownload.matches(filter: .active)) + #expect(pausedDownload.badge == .paused(4, 12)) + #expect(pausedDownload.matches(filter: .active)) } + @Test func testActiveDownloadsDoNotExposeUpdateActions() { let downloadingUpdate = sampleDownload( gid: "456", @@ -361,11 +377,12 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { latestRemoteVersionSignature: "hash:v2" ) - XCTAssertFalse(downloadingUpdate.canTriggerUpdate) - XCTAssertFalse(pausedUpdate.canTriggerUpdate) - XCTAssertTrue(completedUpdate.canTriggerUpdate) + #expect(downloadingUpdate.canTriggerUpdate == false) + #expect(pausedUpdate.canTriggerUpdate == false) + #expect(completedUpdate.canTriggerUpdate) } + @Test func testDownloadsFilterMatchesGalleryFilterCriteria() { let qualifyingDownload = sampleDownload( gid: "466", @@ -391,9 +408,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { state.galleryFilter.pageLowerBound = "20" state.galleryFilter.pageUpperBound = "40" - XCTAssertEqual(state.filteredDownloads, [qualifyingDownload]) + #expect(state.filteredDownloads == [qualifyingDownload]) } + @Test func testDownloadsFilterExcludesSelectedCategoriesLikeSearchFilter() { let nonHDownload = sampleDownload( gid: "478", @@ -412,9 +430,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { state.downloads = [nonHDownload, mangaDownload] state.galleryFilter.excludedCategories = [.nonH] - XCTAssertEqual(state.filteredDownloads, [mangaDownload]) + #expect(state.filteredDownloads == [mangaDownload]) } + @Test func testPartialDownloadBadgeUsesNeedsAttentionCopy() { let partialDownload = sampleDownload( gid: "480", @@ -424,10 +443,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { completedPageCount: 5 ) - XCTAssertEqual(partialDownload.badge.text, "Needs Attention 5/12") - XCTAssertEqual(DownloadListFilter.failed.title, "Needs Attention") + #expect(partialDownload.badge.text == "Needs Attention 5/12") + #expect(DownloadListFilter.failed.title == "Needs Attention") } + @Test func testQueuedRedownloadDoesNotLeakIntoCompletedFilter() { let queuedRedownload = sampleDownload( gid: "505", @@ -437,10 +457,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { pendingOperation: .redownload ) - XCTAssertFalse(queuedRedownload.matches(filter: .completed)) - XCTAssertFalse(queuedRedownload.matches(filter: .update)) + #expect(queuedRedownload.matches(filter: .completed) == false) + #expect(queuedRedownload.matches(filter: .update) == false) } + @Test func testQueuedRepairDoesNotLeakIntoFailedFilter() { let queuedRepair = sampleDownload( gid: "606", @@ -457,12 +478,13 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { completedPageCount: 0 ) - XCTAssertFalse(queuedRepair.matches(filter: .failed)) - XCTAssertFalse(queuedRepair.matches(filter: .update)) - XCTAssertEqual(missingFilesWithoutQueuedWork.badge, .missingFiles) - XCTAssertTrue(missingFilesWithoutQueuedWork.matches(filter: .failed)) + #expect(queuedRepair.matches(filter: .failed) == false) + #expect(queuedRepair.matches(filter: .update) == false) + #expect(missingFilesWithoutQueuedWork.badge == .missingFiles) + #expect(missingFilesWithoutQueuedWork.matches(filter: .failed)) } + @Test func testQueuedRedownloadKeepsQueuedSortPriority() { let completedDownload = sampleDownload( gid: "707", @@ -487,11 +509,12 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { return (lhs.lastDownloadedAt ?? .distantPast) > (rhs.lastDownloadedAt ?? .distantPast) } - XCTAssertEqual(queuedRedownload.sortPriority, 1) - XCTAssertEqual(completedDownload.sortPriority, 7) - XCTAssertEqual(sortedDownloads.map(\.gid), [queuedRedownload.gid, completedDownload.gid]) + #expect(queuedRedownload.sortPriority == 1) + #expect(completedDownload.sortPriority == 7) + #expect(sortedDownloads.map(\.gid) == [queuedRedownload.gid, completedDownload.gid]) } + @Test func testInProgressDownloadPrefersTemporaryCoverURL() throws { let gid = "811" let download = sampleDownload( @@ -501,9 +524,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { completedPageCount: 3 ) - guard let rootURL = FileUtil.downloadsDirectoryURL else { - throw XCTSkip("Downloads directory is unavailable in the test environment.") - } + let rootURL = try #require( + FileUtil.downloadsDirectoryURL, + "Downloads directory is unavailable in the test environment." + ) let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) try? FileManager.default.removeItem(at: temporaryFolderURL) @@ -516,9 +540,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let temporaryCoverURL = temporaryFolderURL.appendingPathComponent("cover.jpg") try Data([0xFF, 0xD8, 0xFF]).write(to: temporaryCoverURL, options: .atomic) - XCTAssertEqual(download.resolvedCoverURL(rootURL: rootURL), temporaryCoverURL) + #expect(download.resolvedCoverURL(rootURL: rootURL) == temporaryCoverURL) } + @Test func testQueuedDownloadPreservesTemporaryWorkingSet() { let queuedDownload = sampleDownload( gid: "809", @@ -527,9 +552,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { completedPageCount: 3 ) - XCTAssertTrue(queuedDownload.shouldPreserveTemporaryWorkingSet) + #expect(queuedDownload.shouldPreserveTemporaryWorkingSet) } + @Test func testActiveDownloadDoesNotNormalizeWhileTaskIsStillRunning() { let activeDownload = sampleDownload( gid: "810", @@ -538,19 +564,19 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { completedPageCount: 3 ) - XCTAssertFalse( + #expect( activeDownload.needsInterruptedDownloadNormalization( activeGalleryID: activeDownload.gid, hasActiveTask: true - ) + ) == false ) - XCTAssertTrue( + #expect( activeDownload.needsInterruptedDownloadNormalization( activeGalleryID: nil, hasActiveTask: false ) ) - XCTAssertTrue( + #expect( activeDownload.needsInterruptedDownloadNormalization( activeGalleryID: "another-gid", hasActiveTask: true @@ -558,6 +584,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) } + @Test func testAppLaunchAutomationResolveParsesGalleryURLAndCookies() { let automation = AppLaunchAutomation.resolve(environment: [ "EHPANDA_AUTOMATION_TAB": "downloads", @@ -568,17 +595,17 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { "EHPANDA_AUTOMATION_IGNEOUS": "igneous-value" ]) - XCTAssertEqual(automation?.initialTab, .downloads) - XCTAssertEqual(automation?.autoDownloadGID, "1394965") - XCTAssertEqual( - automation?.galleryURL, - URL(string: "https://e-hentai.org/g/1394965/56c35114b6/") + #expect(automation?.initialTab == .downloads) + #expect(automation?.autoDownloadGID == "1394965") + #expect( + automation?.galleryURL == URL(string: "https://e-hentai.org/g/1394965/56c35114b6/") ) - XCTAssertEqual(automation?.loginCookies?.memberID, "4172984") - XCTAssertEqual(automation?.loginCookies?.passHash, "pass-hash") - XCTAssertEqual(automation?.loginCookies?.igneous, "igneous-value") + #expect(automation?.loginCookies?.memberID == "4172984") + #expect(automation?.loginCookies?.passHash == "pass-hash") + #expect(automation?.loginCookies?.igneous == "igneous-value") } + @Test func testImportAutomationCookiesClearsStaleIgneousAndUsesSessionCookies() { let cookieClient = CookieClient.live cookieClient.clearAll() @@ -601,16 +628,17 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let passHashCookie = exCookies.first { $0.name == Defaults.Cookie.ipbPassHash } let igneousCookie = exCookies.first { $0.name == Defaults.Cookie.igneous } - XCTAssertEqual(memberCookie?.value, "4172984") - XCTAssertEqual(passHashCookie?.value, "pass-hash") - XCTAssertTrue(memberCookie?.isSessionOnly == true) - XCTAssertTrue(passHashCookie?.isSessionOnly == true) - XCTAssertNil(igneousCookie) - XCTAssertTrue(cookieClient.didLogin) - XCTAssertTrue(cookieClient.shouldFetchIgneous) + #expect(memberCookie?.value == "4172984") + #expect(passHashCookie?.value == "pass-hash") + #expect(memberCookie?.isSessionOnly == true) + #expect(passHashCookie?.isSessionOnly == true) + #expect(igneousCookie == nil) + #expect(cookieClient.didLogin) + #expect(cookieClient.shouldFetchIgneous) } @MainActor + @Test func testRunLaunchAutomationFallsBackToInitialTabWhenGalleryURLIsUnhandleable() async { setenv("EHPANDA_AUTOMATION_TAB", "downloads", 1) setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://example.com/not-a-gallery", 1) @@ -641,6 +669,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } @MainActor + @Test func testDatabasePreparationImportsAutomationCookiesBeforeLoadingSettings() async { let cookieClient = CookieClient.live cookieClient.clearAll() @@ -672,11 +701,12 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.appDelegate(.migration(.onDatabasePreparationSuccess))) await store.receive(\.appDelegate.removeExpiredImageURLs) - XCTAssertTrue(cookieClient.didLogin) + #expect(cookieClient.didLogin) await store.receive(\.setting.loadUserSettings) } @MainActor + @Test func testLoadUserSettingsDefersExLaunchAutomationUntilIgneousArrives() async { let cookieClient = CookieClient.live cookieClient.clearAll() @@ -714,8 +744,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { store.exhaustivity = .off await store.send(.setting(.loadUserSettingsDone)) - XCTAssertFalse(store.state.didRunLaunchAutomation) - XCTAssertTrue(store.state.isWaitingForIgneousBeforeLaunchAutomation) + #expect(store.state.didRunLaunchAutomation == false) + #expect(store.state.isWaitingForIgneousBeforeLaunchAutomation) let response: HTTPURLResponse = HTTPURLResponse( url: Defaults.URL.exhentai, @@ -733,6 +763,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } @MainActor + @Test func testLoadUserSettingsKeepsExLaunchAutomationDeferredWhenIgneousFetchFails() async { let cookieClient = CookieClient.live cookieClient.clearAll() @@ -770,16 +801,17 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { store.exhaustivity = .off await store.send(.setting(.loadUserSettingsDone)) - XCTAssertFalse(store.state.didRunLaunchAutomation) - XCTAssertTrue(store.state.isWaitingForIgneousBeforeLaunchAutomation) + #expect(store.state.didRunLaunchAutomation == false) + #expect(store.state.isWaitingForIgneousBeforeLaunchAutomation) await store.send(.setting(.fetchIgneousDone(.failure(.networkingFailed)))) await store.receive(\.setting.account.loadCookies) - XCTAssertFalse(store.state.didRunLaunchAutomation) - XCTAssertTrue(store.state.isWaitingForIgneousBeforeLaunchAutomation) + #expect(store.state.didRunLaunchAutomation == false) + #expect(store.state.isWaitingForIgneousBeforeLaunchAutomation) } @MainActor + @Test func testDownloadsReducerKeepsIdleStateForEmptyLibrary() async { let store = TestStore(initialState: DownloadsReducer.State()) { DownloadsReducer() @@ -789,10 +821,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { $0.loadingState = .idle } - XCTAssertEqual(store.state.downloads, []) + #expect(store.state.downloads == []) } @MainActor + @Test func testDownloadsReducerSeedsOnlineDetailStateFromDownload() async { let download = sampleDownload( gid: "123456", @@ -809,14 +842,15 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.setNavigation(.detail(download.gid))) - XCTAssertEqual(store.state.route, .detail(download.gid)) - XCTAssertEqual(store.state.detailState.wrappedValue?.gid, download.gid) - XCTAssertEqual(store.state.detailState.wrappedValue?.gallery.id, download.gid) - XCTAssertEqual(store.state.detailState.wrappedValue?.downloadBadge, .downloaded) - XCTAssertTrue(store.state.detailState.wrappedValue?.shouldCheckForRemoteUpdates == true) + #expect(store.state.route == .detail(download.gid)) + #expect(store.state.detailState.wrappedValue?.gid == download.gid) + #expect(store.state.detailState.wrappedValue?.gallery.id == download.gid) + #expect(store.state.detailState.wrappedValue?.downloadBadge == .downloaded) + #expect(store.state.detailState.wrappedValue?.shouldCheckForRemoteUpdates == true) } @MainActor + @Test func testDownloadsReducerUpdateActionUsesDownloadClientRetry() async { let retried = UncheckedBox<[String]>([]) let download = sampleDownload( @@ -860,10 +894,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.updateDownload(download.gid)) await store.receive(\.updateDownloadDone) - XCTAssertEqual(retried.value, [download.gid]) + #expect(retried.value == [download.gid]) } @MainActor + @Test func testDownloadsReducerDeleteActionUsesDownloadClientDelete() async { let deleted = UncheckedBox<[String]>([]) let download = sampleDownload( @@ -904,10 +939,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.deleteDownload(download.gid)) await store.receive(\.deleteDownloadDone) - XCTAssertEqual(deleted.value, [download.gid]) + #expect(deleted.value == [download.gid]) } @MainActor + @Test func testDownloadsReducerTogglePauseActionUsesDownloadClientPause() async { let toggled = UncheckedBox<[String]>([]) let download = sampleDownload( @@ -949,10 +985,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.toggleDownloadPause(download.gid)) await store.receive(\.toggleDownloadPauseDone) - XCTAssertEqual(toggled.value, [download.gid]) + #expect(toggled.value == [download.gid]) } @MainActor + @Test func testDownloadInspectorReducerLoadsInspection() async { let download = sampleDownload( gid: "246810", @@ -997,55 +1034,57 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } @MainActor + @Test func testDownloadInspectorReducerRetryPageUsesDownloadClientRetryPages() async { - let retried = UncheckedBox<[Int]>([]) - let retryExpectation = XCTestExpectation(description: "Retry page") - let download = sampleDownload( - gid: "112233", - title: "Retry Page Gallery", - status: .failed, - completedPageCount: 1 - ) - var initialState = DownloadInspectorReducer.State(gid: download.gid) - initialState.inspection = sampleInspection(download: download) - initialState.loadingState = .idle - - let store = TestStore(initialState: initialState) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, pageIndices in - retried.value = pageIndices - retryExpectation.fulfill() - return .success(()) - }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in .success(initialState.inspection!) } + await confirmation(expectedCount: 1) { confirm in + let retried = UncheckedBox<[Int]>([]) + let download = sampleDownload( + gid: "112233", + title: "Retry Page Gallery", + status: .failed, + completedPageCount: 1 ) - } - store.exhaustivity = .off + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = sampleInspection(download: download) + initialState.loadingState = .idle + + let store = TestStore(initialState: initialState) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, pageIndices in + retried.value = pageIndices + confirm() + return .success(()) + }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in .success(initialState.inspection!) } + ) + } + store.exhaustivity = .off - await store.send(.retryPage(2)) - await fulfillment(of: [retryExpectation], timeout: 1) - XCTAssertEqual(retried.value, [2]) + await store.send(.retryPage(2)) + #expect(retried.value == [2]) + } } @MainActor + @Test func testDownloadInspectorReducerRetryFailedPagesMarksFailedPagesPending() async { let retried = UncheckedBox<[Int]>([]) let download = sampleDownload( @@ -1111,10 +1150,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) } - XCTAssertEqual(retried.value, [2]) + #expect(retried.value == [2]) } @MainActor + @Test func testDownloadInspectorKeepsRetriedPagesPendingWhileRetryWorkRemainsActive() async { let download = sampleDownload( gid: "112236", @@ -1179,6 +1219,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } @MainActor + @Test func testDownloadInspectorClearsRetryingPagesAfterRetrySettlesWithFailure() async { let initialDownload = sampleDownload( gid: "112237", @@ -1238,6 +1279,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } @MainActor + @Test func testDownloadInspectorRestoresStableInspectionWhenRetryReloadFails() async { let download = sampleDownload( gid: "112238", @@ -1301,6 +1343,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } @MainActor + @Test func testDownloadInspectorSkipsReloadWhenObservedDownloadDidNotChange() async { let download = sampleDownload( gid: "112244", @@ -1345,10 +1388,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { store.exhaustivity = .off await store.send(.observeDownloadsDone([download])) - XCTAssertEqual(loadInspectionCount.value, 0) + #expect(loadInspectionCount.value == 0) } @MainActor + @Test func testDownloadInspectorIgnoresStaleInspectionResponses() async { let originalDownload = sampleDownload( gid: "112245", @@ -1377,7 +1421,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { store.exhaustivity = .off await store.send(.loadInspectionDone(firstRequestID, .success(staleInspection))) - XCTAssertNil(store.state.inspection) + #expect(store.state.inspection == nil) await store.send(.loadInspectionDone(secondRequestID, .success(refreshedInspection))) { $0.inspection = refreshedInspection @@ -1386,6 +1430,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } } + @Test func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { let container = makeInMemoryContainer() @@ -1435,11 +1480,12 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let result = await manager.loadInspection(gid: gid) let inspection = try result.get() - XCTAssertEqual(inspection.pages[0].status, .downloaded) - XCTAssertEqual(inspection.pages[1].status, .failed) - XCTAssertEqual(inspection.pages[1].failure?.code, .networkingFailed) + #expect(inspection.pages[0].status == .downloaded) + #expect(inspection.pages[1].status == .failed) + #expect(inspection.pages[1].failure?.code == .networkingFailed) } + @Test func testDownloadManagerLoadLocalPageURLsPrefersCompletedFolderForCompletedDownload() async throws { let container = makeInMemoryContainer() @@ -1493,11 +1539,12 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - XCTAssertEqual(pageURLs[1], completedPageURL) - XCTAssertNotEqual(pageURLs[1], temporaryPageURL) - XCTAssertNil(pageURLs[3]) + #expect(pageURLs[1] == completedPageURL) + #expect(pageURLs[1] != temporaryPageURL) + #expect(pageURLs[3] == nil) } + @Test func testDownloadManagerLoadLocalPageURLsMergesReadableCompletedPagesWithTemporaryPages() async throws { let container = makeInMemoryContainer() @@ -1553,10 +1600,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - XCTAssertEqual(pageURLs[1], completedFolderURL.appendingPathComponent("pages/0001.jpg")) - XCTAssertEqual(pageURLs[2], temporaryPageURL) + #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("pages/0001.jpg")) + #expect(pageURLs[2] == temporaryPageURL) } + @Test func testRepairSeedRejectsOldCompletedVersionWhenGalleryUpdatedButPageCountMatches() async throws { let gid = "repair-seed-\(UUID().uuidString)" let rootURL = FileManager.default.temporaryDirectory @@ -1654,21 +1702,22 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { versionSignature: "hash:v2" ) - XCTAssertNil(workingSeed.manifest) - XCTAssertTrue(workingSeed.existingPages.isEmpty) - XCTAssertNil(workingSeed.coverRelativePath) - XCTAssertFalse( + #expect(workingSeed.manifest == nil) + #expect(workingSeed.existingPages.isEmpty) + #expect(workingSeed.coverRelativePath == nil) + #expect( FileManager.default.fileExists( atPath: workingSeed.folderURL.appendingPathComponent("pages/0001.jpg").path - ) + ) == false ) - XCTAssertFalse( + #expect( FileManager.default.fileExists( atPath: workingSeed.folderURL.appendingPathComponent("pages/0002.jpg").path - ) + ) == false ) } + @Test func testDownloadManagerLoadLocalPageURLsMarksCompletedDownloadMissingFilesWhenZeroBytePageIsFound() async throws { let container = makeInMemoryContainer() @@ -1713,24 +1762,25 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() let stored = await manager.testingFetchDownload(gid: gid) - XCTAssertNil(pageURLs[1]) - XCTAssertEqual(pageURLs[2], goodPageURL) - XCTAssertFalse(FileManager.default.fileExists(atPath: emptyPageURL.path)) - XCTAssertEqual(stored?.status, .missingFiles) - XCTAssertEqual(stored?.completedPageCount, 1) + #expect(pageURLs[1] == nil) + #expect(pageURLs[2] == goodPageURL) + #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) + #expect(stored?.status == .missingFiles) + #expect(stored?.completedPageCount == 1) } @MainActor + @Test func testImageClientFetchImageUsesStableAliasCacheKey() async throws { - let url = try XCTUnwrap( + let url = try #require( URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") ) - let stableCacheKey = try XCTUnwrap(url.stableImageCacheKey) + let stableCacheKey = try #require(url.stableImageCacheKey) let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in UIColor.systemRed.setFill() context.fill(.init(x: 0, y: 0, width: 1, height: 1)) } - let imageData = try XCTUnwrap(image.pngData()) + let imageData = try #require(image.pngData()) KingfisherManager.shared.cache.store(image, original: imageData, forKey: stableCacheKey) defer { @@ -1741,9 +1791,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let result = await ImageClient.live.fetchImage(url: url) let fetchedImage = try result.get() - XCTAssertEqual(fetchedImage.size, image.size) + #expect(fetchedImage.size == image.size) } + @Test func testRetryPagesQueuesWorkWhenAnotherDownloadIsActive() async throws { let container = makeInMemoryContainer() @@ -1793,24 +1844,26 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let result = await manager.retryPages(gid: gid, pageIndices: [2]) guard case .success = result else { - return XCTFail("Retry pages should succeed, got \(result)") + Issue.record("Retry pages should succeed, got \(result)") + return } let stored = await manager.testingFetchDownload(gid: gid) - XCTAssertEqual(stored?.status, .queued) - XCTAssertEqual(stored?.badge, .queued) - XCTAssertNil(stored?.pendingOperation) - XCTAssertNil(stored?.lastError) + #expect(stored?.status == .queued) + #expect(stored?.badge == .queued) + #expect(stored?.pendingOperation == nil) + #expect(stored?.lastError == nil) let resumeState = try storage.readResumeState(folderURL: temporaryFolderURL) - XCTAssertEqual(resumeState.pageSelection, [2]) - XCTAssertFalse(FileManager.default.fileExists( + #expect(resumeState.pageSelection == [2]) + #expect(FileManager.default.fileExists( atPath: temporaryFolderURL .appendingPathComponent(Defaults.FilePath.downloadFailedPages) .path - )) + ) == false) } + @Test func testCancelQueuedRepairRestoresReadableCountAndClearsPendingOperation() async throws { let container = makeInMemoryContainer() @@ -1857,15 +1910,17 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let result = await manager.togglePause(gid: gid) guard case .success = result else { - return XCTFail("Cancelling queued repair should succeed, got \(result)") + Issue.record("Cancelling queued repair should succeed, got \(result)") + return } let stored = await manager.testingFetchDownload(gid: gid) - XCTAssertEqual(stored?.status, .missingFiles) - XCTAssertEqual(stored?.completedPageCount, 1) - XCTAssertNil(stored?.pendingOperation) + #expect(stored?.status == .missingFiles) + #expect(stored?.completedPageCount == 1) + #expect(stored?.pendingOperation == nil) } + @Test func testRetryPagesUsesMinimalSourceResolutionAndSkipsWhenNoPendingPages() async throws { let container = makeInMemoryContainer() let sessionID = UUID().uuidString @@ -2074,7 +2129,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await manager.testingProcessDownload(gid: gid) let firstRunSnapshot = recorder.snapshot() - XCTAssertEqual(firstRunSnapshot.previewPageNumbers, [1]) + #expect(firstRunSnapshot.previewPageNumbers == [1]) recorder.reset() try clearPersistedDownloads(in: container) @@ -2092,18 +2147,19 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await manager.testingProcessDownload(gid: gid) let secondRunSnapshot = recorder.snapshot() - XCTAssertTrue(secondRunSnapshot.previewPageNumbers.isEmpty) - XCTAssertEqual(secondRunSnapshot.mpvRequests, 0) - XCTAssertEqual(secondRunSnapshot.imageDispatchRequests, 0) + #expect(secondRunSnapshot.previewPageNumbers.isEmpty) + #expect(secondRunSnapshot.mpvRequests == 0) + #expect(secondRunSnapshot.imageDispatchRequests == 0) } + @Test func testRetryPagesFallsBackToFullUpdateWhenGalleryHasUpdate() async throws { let container = makeInMemoryContainer() let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) let pageIndex = 42 - let oldVersionSignature = try XCTUnwrap( + let oldVersionSignature = try #require( DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") ) let rootURL = FileManager.default.temporaryDirectory @@ -2230,10 +2286,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) let pageCount = payload.galleryDetail.pageCount - XCTAssertGreaterThan(pageCount, pageIndex) - XCTAssertGreaterThan(pageCount, 5) + #expect(pageCount > pageIndex) + #expect(pageCount > 5) let oldCount = pageCount - 5 - XCTAssertNotEqual(oldCount, pageCount) + #expect(oldCount != pageCount) let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) // Queued update path: retryPages should queue a full update and keep no page-selection state. @@ -2248,7 +2304,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) let queuedCandidate = await queueingManager.testingFetchDownload(gid: gid) - XCTAssertTrue(queuedCandidate?.hasUpdate == true) + #expect(queuedCandidate?.hasUpdate == true) let blockerTask = Task { try? await Task.sleep(nanoseconds: 5_000_000_000) @@ -2258,18 +2314,19 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let retryResult = await queueingManager.retryPages(gid: gid, pageIndices: [pageIndex]) guard case .success = retryResult else { - return XCTFail("retryPages should succeed, got \(retryResult)") + Issue.record("retryPages should succeed, got \(retryResult)") + return } let queued = await queueingManager.testingFetchDownload(gid: gid) - XCTAssertEqual(queued?.status, .partial) - XCTAssertEqual(queued?.pendingOperation, .update) - XCTAssertNil(queued?.lastError) + #expect(queued?.status == .partial) + #expect(queued?.pendingOperation == .update) + #expect(queued?.lastError == nil) if FileManager.default.fileExists(atPath: temporaryFolderURL.path) { let queuedResumeState = try storage.readResumeState(folderURL: temporaryFolderURL) - XCTAssertEqual(queuedResumeState.mode, .update) - XCTAssertNil(queuedResumeState.pageSelection) - XCTAssertNotEqual(queuedResumeState.pageSelection, [pageIndex]) + #expect(queuedResumeState.mode == .update) + #expect(queuedResumeState.pageSelection == nil) + #expect(queuedResumeState.pageSelection != [pageIndex]) } try clearPersistedDownloads(in: container) @@ -2330,28 +2387,30 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let immediateRetryResult = await immediateManager.retryPages(gid: gid, pageIndices: [pageIndex]) guard case .success = immediateRetryResult else { - return XCTFail("Immediate retryPages should succeed, got \(immediateRetryResult)") + Issue.record("Immediate retryPages should succeed, got \(immediateRetryResult)") + return } let resumedState = try storage.readResumeState(folderURL: temporaryFolderURL) - XCTAssertEqual(resumedState.mode, .update) - XCTAssertEqual(resumedState.versionSignature, updatedVersionSignature) - XCTAssertEqual(resumedState.pageCount, pageCount) - XCTAssertNil(resumedState.pageSelection) - XCTAssertNotEqual(resumedState.pageSelection, [pageIndex]) + #expect(resumedState.mode == .update) + #expect(resumedState.versionSignature == updatedVersionSignature) + #expect(resumedState.pageCount == pageCount) + #expect(resumedState.pageSelection == nil) + #expect(resumedState.pageSelection != [pageIndex]) let resumedDownload = await immediateManager.testingFetchDownload(gid: gid) - XCTAssertEqual(resumedDownload?.status, .downloading) - XCTAssertNil(resumedDownload?.pendingOperation) - XCTAssertNil(resumedDownload?.lastError) + #expect(resumedDownload?.status == .downloading) + #expect(resumedDownload?.pendingOperation == nil) + #expect(resumedDownload?.lastError == nil) } + @Test func testProcessDownloadClearsStalePageSelectionWhenLatestPayloadRevealsUpdate() async throws { let container = makeInMemoryContainer() let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 401) let pageIndex = 42 - let oldVersionSignature = try XCTUnwrap( + let oldVersionSignature = try #require( DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") ) let rootURL = FileManager.default.temporaryDirectory @@ -2479,10 +2538,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } let updatedPageCount = latestPayload.galleryDetail.pageCount - XCTAssertGreaterThan(updatedPageCount, pageIndex) - XCTAssertGreaterThan(updatedPageCount, 5) + #expect(updatedPageCount > pageIndex) + #expect(updatedPageCount > 5) let oldPageCount = updatedPageCount - 5 - XCTAssertNotEqual(oldPageCount, updatedPageCount) + #expect(oldPageCount != updatedPageCount) try insertPersistedDownload( in: container, @@ -2495,7 +2554,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) let beforeProcess = await manager.testingFetchDownload(gid: gid) - XCTAssertFalse(beforeProcess?.hasUpdate ?? true) + #expect(beforeProcess?.hasUpdate ?? true == false) let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) try FileManager.default.createDirectory( @@ -2536,38 +2595,41 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await manager.testingProcessDownload(gid: gid) let completedDownload = await manager.testingFetchDownload(gid: gid) - let unwrappedCompletedDownload = try XCTUnwrap(completedDownload) - XCTAssertEqual(unwrappedCompletedDownload.status, .completed) - XCTAssertEqual(unwrappedCompletedDownload.pageCount, updatedPageCount) - XCTAssertEqual(unwrappedCompletedDownload.completedPageCount, updatedPageCount) - XCTAssertEqual(unwrappedCompletedDownload.remoteVersionSignature, updatedVersionSignature) - XCTAssertEqual(unwrappedCompletedDownload.latestRemoteVersionSignature, updatedVersionSignature) + let unwrappedCompletedDownload = try #require(completedDownload) + #expect(unwrappedCompletedDownload.status == .completed) + #expect(unwrappedCompletedDownload.pageCount == updatedPageCount) + #expect(unwrappedCompletedDownload.completedPageCount == updatedPageCount) + #expect(unwrappedCompletedDownload.remoteVersionSignature == updatedVersionSignature) + #expect(unwrappedCompletedDownload.latestRemoteVersionSignature == updatedVersionSignature) let completedFolderURL = storage.folderURL(relativePath: unwrappedCompletedDownload.folderRelativePath) let completedManifest = try storage.readManifest(folderURL: completedFolderURL) - XCTAssertEqual(completedManifest.versionSignature, updatedVersionSignature) - XCTAssertEqual(completedManifest.pageCount, updatedPageCount) - XCTAssertEqual(completedManifest.pages.count, updatedPageCount) - XCTAssertTrue(FileManager.default.fileExists( - atPath: completedFolderURL.appendingPathComponent("pages/0001.jpg").path - )) + #expect(completedManifest.versionSignature == updatedVersionSignature) + #expect(completedManifest.pageCount == updatedPageCount) + #expect(completedManifest.pages.count == updatedPageCount) + #expect( + FileManager.default.fileExists( + atPath: completedFolderURL.appendingPathComponent("pages/0001.jpg").path + ) + ) let completedResumeState = try storage.readResumeState(folderURL: completedFolderURL) - XCTAssertEqual(completedResumeState.mode, .redownload) - XCTAssertEqual(completedResumeState.versionSignature, updatedVersionSignature) - XCTAssertEqual(completedResumeState.pageCount, updatedPageCount) - XCTAssertNil(completedResumeState.pageSelection) - XCTAssertFalse(FileManager.default.fileExists(atPath: temporaryFolderURL.path)) + #expect(completedResumeState.mode == .redownload) + #expect(completedResumeState.versionSignature == updatedVersionSignature) + #expect(completedResumeState.pageCount == updatedPageCount) + #expect(completedResumeState.pageSelection == nil) + #expect(FileManager.default.fileExists(atPath: temporaryFolderURL.path) == false) } @MainActor + @Test func testProcessDownloadClearsRemoteAssetCacheAfterSuccessfulDownload() async throws { let container = makeInMemoryContainer() let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 402) let pageIndex = 42 - let oldVersionSignature = try XCTUnwrap( + let oldVersionSignature = try #require( DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") ) let rootURL = FileManager.default.temporaryDirectory @@ -2584,13 +2646,13 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") let mpvHTML = try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html") - let currentPageImageURL = try XCTUnwrap( + let currentPageImageURL = try #require( URL(string: "https://example.com/image-\(pageIndex).jpg") ) - let staleStoredPageURL = try XCTUnwrap( + let staleStoredPageURL = try #require( URL(string: "https://example.com/stale-image-\(gid)-1.jpg") ) - let plainPreviewURL = try XCTUnwrap( + let plainPreviewURL = try #require( URL(string: "https://ehgt.org/preview/\(gid)/1.webp") ) let combinedPreviewURL = URLUtil.combinedPreviewURL( @@ -2705,14 +2767,14 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { mode: .redownload, pageSelection: [pageIndex] ) - let coverURL = try XCTUnwrap(latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL) + let coverURL = try #require(latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL) allowedImageURLs.insert(coverURL.absoluteString) let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in UIColor.systemTeal.setFill() context.fill(.init(x: 0, y: 0, width: 1, height: 1)) } - let cachedImageData = try XCTUnwrap(cachedImage.jpegData(compressionQuality: 1)) + let cachedImageData = try #require(cachedImage.jpegData(compressionQuality: 1)) let cachedURLs = combinedPreviewURL.previewCacheCleanupURLs() + [currentPageImageURL, staleStoredPageURL, coverURL] @@ -2728,8 +2790,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let updatedPageCount = latestPayload.galleryDetail.pageCount let oldPageCount = updatedPageCount - 5 - XCTAssertGreaterThan(updatedPageCount, pageIndex) - XCTAssertGreaterThan(oldPageCount, 0) + #expect(updatedPageCount > pageIndex) + #expect(oldPageCount > 0) try insertPersistedDownload( in: container, @@ -2786,7 +2848,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await manager.testingProcessDownload(gid: gid) let completedDownload = await manager.testingFetchDownload(gid: gid) - XCTAssertEqual(completedDownload?.status, .completed) + #expect(completedDownload?.status == .completed) let clock = ContinuousClock() let deadline = clock.now.advanced(by: .seconds(1)) @@ -2797,14 +2859,15 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } for cacheKey in cachedKeys { - XCTAssertFalse( - KingfisherManager.shared.cache.isCached(forKey: cacheKey), + #expect( + KingfisherManager.shared.cache.isCached(forKey: cacheKey) == false, "Expected cache key to be removed after successful download: \(cacheKey)" ) } } @MainActor + @Test func testDownloadsReducerRefreshesWithoutResumingQueueAfterPauseFailure() async { let download = sampleDownload( gid: "987655", @@ -2846,10 +2909,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.receive(\.toggleDownloadPauseDone) await store.finish() - XCTAssertEqual(reconcileCount.value, 1) + #expect(reconcileCount.value == 1) } @MainActor + @Test func testDownloadsReducerRefreshDownloadsUsesClientRefresh() async { let refreshCount = UncheckedBox(0) let reconcileCount = UncheckedBox(0) @@ -2885,11 +2949,12 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.refreshDownloads) await store.receive(\.refreshDownloadsDone) - XCTAssertEqual(refreshCount.value, 1) - XCTAssertEqual(reconcileCount.value, 0) + #expect(refreshCount.value == 1) + #expect(reconcileCount.value == 0) } @MainActor + @Test func testDownloadsReducerBootstrapUsesClientRefresh() async { let refreshCount = UncheckedBox(0) let reconcileCount = UncheckedBox(0) @@ -2925,11 +2990,12 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.bootstrapDownloads) await store.receive(\.refreshDownloadsDone) - XCTAssertEqual(refreshCount.value, 1) - XCTAssertEqual(reconcileCount.value, 0) + #expect(refreshCount.value == 1) + #expect(reconcileCount.value == 0) } @MainActor + @Test func testDetailReducerStartDownloadEnqueuesGalleryWithSnapshotOptions() async { let capturedPayload = UncheckedBox(nil) let gallery = sampleGallery() @@ -2983,15 +3049,16 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.startDownload(options)) await store.skipReceivedActions(strict: false) - XCTAssertEqual(capturedPayload.value?.gallery.gid, gallery.gid) - XCTAssertEqual(capturedPayload.value?.galleryDetail, detail) - XCTAssertEqual(capturedPayload.value?.previewConfig, .large(rows: 2)) - XCTAssertEqual(capturedPayload.value?.options, options) - XCTAssertEqual(capturedPayload.value?.mode, .initial) - XCTAssertEqual(store.state.downloadBadge, .queued) + #expect(capturedPayload.value?.gallery.gid == gallery.gid) + #expect(capturedPayload.value?.galleryDetail == detail) + #expect(capturedPayload.value?.previewConfig == .large(rows: 2)) + #expect(capturedPayload.value?.options == options) + #expect(capturedPayload.value?.mode == .initial) + #expect(store.state.downloadBadge == .queued) } @MainActor + @Test func testDetailReducerStartDownloadUnlocksActionsAfterQueueing() async { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) @@ -3050,6 +3117,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } @MainActor + @Test func testDetailReducerLaunchAutomationWaitsForResolvedDownloadBadge() async { let capturedPayload = UncheckedBox(nil) let gallery = sampleGallery() @@ -3098,8 +3166,8 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { store.exhaustivity = .off await store.send(.runLaunchAutomationIfNeeded(options)) - XCTAssertNil(capturedPayload.value) - XCTAssertFalse(store.state.didRunLaunchAutomation) + #expect(capturedPayload.value == nil) + #expect(store.state.didRunLaunchAutomation == false) await store.send(.fetchDownloadBadgeDone(.none)) { $0.hasLoadedDownloadBadge = true @@ -3110,10 +3178,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.receive(\.startDownload, options) await store.skipReceivedActions(strict: false) - XCTAssertEqual(capturedPayload.value?.gallery.gid, gallery.gid) + #expect(capturedPayload.value?.gallery.gid == gallery.gid) } @MainActor + @Test func testDetailReducerLaunchAutomationDoesNotRedownloadWhenBadgeIsResolved() async { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) @@ -3145,6 +3214,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } @MainActor + @Test func testDetailReducerIgnoresStartDownloadWhilePreparing() async { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) @@ -3188,12 +3258,13 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.startDownload(options)) - XCTAssertEqual(enqueueCount.value, 0) - XCTAssertTrue(store.state.isPreparingDownload) - XCTAssertEqual(store.state.downloadBadge, .none) + #expect(enqueueCount.value == 0) + #expect(store.state.isPreparingDownload) + #expect(store.state.downloadBadge == .none) } @MainActor + @Test func testDetailReducerTogglesPauseForActiveDownload() async { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) @@ -3251,12 +3322,13 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { $0.hasLoadedDownloadBadge = true } - XCTAssertEqual(togglePauseCount.value, 1) - XCTAssertEqual(store.state.downloadBadge, .paused(7, 26)) - XCTAssertFalse(store.state.isPreparingDownload) + #expect(togglePauseCount.value == 1) + #expect(store.state.downloadBadge == .paused(7, 26)) + #expect(store.state.isPreparingDownload == false) } @MainActor + @Test func testDetailReducerObservesDownloadBadgeTransitions() async { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) @@ -3342,6 +3414,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } @MainActor + @Test func testDetailReducerOpenReadingUsesLocalManifestWhenAvailable() async { let download = sampleDownload( gid: "888", @@ -3389,14 +3462,15 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.openReading) await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.readingState.contentSource, .local(download, manifest)) + #expect(store.state.readingState.contentSource == .local(download, manifest)) if case .reading = store.state.route { } else { - XCTFail("Expected reading route to be active.") + Issue.record("Expected reading route to be active.") } } @MainActor + @Test func testDetailReducerOpenReadingFallsBackToRemoteWhenManifestUnavailable() async { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) @@ -3435,14 +3509,15 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.openReading) await store.skipReceivedActions(strict: false) - XCTAssertEqual(store.state.readingState.contentSource, .remote) + #expect(store.state.readingState.contentSource == .remote) if case .reading = store.state.route { } else { - XCTFail("Expected reading route to be active.") + Issue.record("Expected reading route to be active.") } } @MainActor + @Test func testPreviewsReducerOpenReadingUsesLocalManifestWhenAvailable() async { let download = sampleDownload( gid: "991", @@ -3489,18 +3564,19 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.skipReceivedActions(strict: false) if case .local(let actualDownload, let actualManifest) = store.state.readingState.contentSource { - XCTAssertEqual(actualDownload, download) - XCTAssertEqual(actualManifest, manifest) + #expect(actualDownload == download) + #expect(actualManifest == manifest) } else { - XCTFail("Expected previews to open local reading content.") + Issue.record("Expected previews to open local reading content.") } if case .reading = store.state.route { } else { - XCTFail("Expected reading route to be active.") + Issue.record("Expected reading route to be active.") } } @MainActor + @Test func testPreviewsReducerClearsLocalPreviewURLsWhenObservedDownloadDisappears() async { let gallery = sampleGallery() let localURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") @@ -3541,10 +3617,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.receive(\.loadLocalPreviewURLsDone) { $0.localPreviewURLs = [:] } - XCTAssertEqual(store.state.localPreviewRequestID, requestID) + #expect(store.state.localPreviewRequestID == requestID) } @MainActor + @Test func testPreviewsReducerRemoteFallbackKeepsExistingLocalPreviewPages() async { let gallery = sampleGallery() let localURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") @@ -3581,14 +3658,15 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.openReading(1)) await store.receive(\.openReadingDone) guard case .reading = store.state.route else { - XCTFail("Expected previews route to enter reading") + Issue.record("Expected previews route to enter reading") return } - XCTAssertEqual(store.state.readingState.contentSource, .remote) - XCTAssertEqual(store.state.readingState.localPageURLs, [1: localURL]) + #expect(store.state.readingState.contentSource == .remote) + #expect(store.state.readingState.localPageURLs == [1: localURL]) } @MainActor + @Test func testDetailReducerDownloadedContextStoresVersionMetadataResult() async { let download = sampleDownload( gid: "889", @@ -3622,6 +3700,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } @MainActor + @Test func testReadingReducerRemoteSourceLoadsLocalPagesAndSkipsRemoteFetchForDownloadedPage() async throws { let gallery = sampleGallery() let localPageURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") @@ -3673,9 +3752,9 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.receive(\.loadLocalPageURLsDone) { $0.localPageURLs = [1: localPageURL] } - XCTAssertEqual(store.state.localPageRequestID, requestID) + #expect(store.state.localPageRequestID == requestID) - XCTAssertEqual(store.state.localPageURLs[1], localPageURL) + #expect(store.state.localPageURLs[1] == localPageURL) await store.send(.fetchImageURLs(1)) { $0.imageURLLoadingStates[1] = .idle @@ -3683,6 +3762,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } @MainActor + @Test func testReadingReducerOnWebImageSucceededCapturesCachedPageIntoDownloadProgress() async { let capturedCalls = UncheckedBox([(String, Int, URL?)]()) let gallery = sampleGallery() @@ -3732,13 +3812,14 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } await store.receive(\.captureCachedPage) - XCTAssertEqual(capturedCalls.value.count, 1) - XCTAssertEqual(capturedCalls.value.first?.0, gallery.gid) - XCTAssertEqual(capturedCalls.value.first?.1, 1) - XCTAssertEqual(capturedCalls.value.first?.2, remotePageURL) + #expect(capturedCalls.value.count == 1) + #expect(capturedCalls.value.first?.0 == gallery.gid) + #expect(capturedCalls.value.first?.1 == 1) + #expect(capturedCalls.value.first?.2 == remotePageURL) } @MainActor + @Test func testReadingReducerOnWebImageSucceededDoesNotCaptureAlreadyLocalPage() async { let capturedCalls = UncheckedBox([(String, Int, URL?)]()) let gallery = sampleGallery() @@ -3790,10 +3871,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } await store.finish() - XCTAssertTrue(capturedCalls.value.isEmpty) + #expect(capturedCalls.value.isEmpty) } @MainActor + @Test func testReadingReducerLocalSourceLoadsOfflineImagesWithoutNetwork() async throws { let download = sampleDownload( gid: "777", @@ -3822,20 +3904,21 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { store.exhaustivity = .off await store.send(.fetchDatabaseInfos(download.gid)) - XCTAssertEqual(store.state.gallery.id, download.gid) - XCTAssertEqual(store.state.imageURLs[1], folderURL.appendingPathComponent("pages/0001.jpg")) - XCTAssertEqual(store.state.imageURLs[2], folderURL.appendingPathComponent("pages/0002.jpg")) + #expect(store.state.gallery.id == download.gid) + #expect(store.state.imageURLs[1] == folderURL.appendingPathComponent("pages/0001.jpg")) + #expect(store.state.imageURLs[2] == folderURL.appendingPathComponent("pages/0002.jpg")) await store.send(.fetchImageURLs(1)) { $0.imageURLLoadingStates[1] = .idle } await store.send(.reloadAllWebImages) - XCTAssertEqual(store.state.imageURLs[1], folderURL.appendingPathComponent("pages/0001.jpg")) - XCTAssertEqual(store.state.imageURLs[2], folderURL.appendingPathComponent("pages/0002.jpg")) + #expect(store.state.imageURLs[1] == folderURL.appendingPathComponent("pages/0001.jpg")) + #expect(store.state.imageURLs[2] == folderURL.appendingPathComponent("pages/0002.jpg")) } @MainActor + @Test func testDownloadManagerCaptureCachedPageRestoresTemporaryPageAndUpdatesCompletedCount() async throws { let container = makeInMemoryContainer() @@ -3863,13 +3946,13 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { withIntermediateDirectories: true ) - let imageURL = try XCTUnwrap(URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg")) + let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg")) let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in UIColor.systemBlue.setFill() context.fill(.init(x: 0, y: 0, width: 1, height: 1)) } - let imageData = try XCTUnwrap(image.jpegData(compressionQuality: 1)) - let cacheKey = try XCTUnwrap(imageURL.stableImageCacheKey) + let imageData = try #require(image.jpegData(compressionQuality: 1)) + let cacheKey = try #require(imageURL.stableImageCacheKey) KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) defer { KingfisherManager.shared.cache.removeImage(forKey: cacheKey) @@ -3883,16 +3966,14 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) let stored = await manager.testingFetchDownload(gid: gid) - XCTAssertEqual(stored?.completedPageCount, 1) + #expect(stored?.completedPageCount == 1) let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - XCTAssertEqual( - pageURLs[1], - temporaryFolderURL.appendingPathComponent("pages/0001.jpg") - ) + #expect(pageURLs[1] == temporaryFolderURL.appendingPathComponent("pages/0001.jpg")) } @MainActor + @Test func testDownloadManagerCaptureCachedPageRepairsCompletedDownloadWithLatestRemoteImage() async throws { let container = makeInMemoryContainer() @@ -3934,13 +4015,13 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { options: .atomic ) - let imageURL = try XCTUnwrap(URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg")) + let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg")) let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in UIColor.systemOrange.setFill() context.fill(.init(x: 0, y: 0, width: 1, height: 1)) } - let imageData = try XCTUnwrap(image.jpegData(compressionQuality: 1)) - let cacheKey = try XCTUnwrap(imageURL.stableImageCacheKey) + let imageData = try #require(image.jpegData(compressionQuality: 1)) + let cacheKey = try #require(imageURL.stableImageCacheKey) KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) defer { KingfisherManager.shared.cache.removeImage(forKey: cacheKey) @@ -3956,16 +4037,14 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let stored = await manager.testingFetchDownload(gid: gid) let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - XCTAssertEqual(stored?.status, .completed) - XCTAssertEqual(stored?.completedPageCount, 2) - XCTAssertNil(stored?.lastError) - XCTAssertEqual( - pageURLs[1], - completedFolderURL.appendingPathComponent("pages/0001.jpg") - ) + #expect(stored?.status == .completed) + #expect(stored?.completedPageCount == 2) + #expect(stored?.lastError == nil) + #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("pages/0001.jpg")) } @MainActor + @Test func testDownloadManagerReconcileNormalizesFailedDownloadBeforeTempCleanup() async throws { let container = makeInMemoryContainer() @@ -4000,16 +4079,14 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let stored = await manager.testingFetchDownload(gid: gid) let localPages = try await manager.loadLocalPageURLs(gid: gid).get() - XCTAssertEqual(stored?.status, .partial) - XCTAssertEqual(stored?.completedPageCount, 1) - XCTAssertTrue(FileManager.default.fileExists(atPath: temporaryFolderURL.path)) - XCTAssertEqual( - localPages[1], - temporaryFolderURL.appendingPathComponent("pages/0001.jpg") - ) + #expect(stored?.status == .partial) + #expect(stored?.completedPageCount == 1) + #expect(FileManager.default.fileExists(atPath: temporaryFolderURL.path)) + #expect(localPages[1] == temporaryFolderURL.appendingPathComponent("pages/0001.jpg")) } @MainActor + @Test func testUpdateRemoteSignatureDoesNotMarkUpdateAvailableWhenStoredChainAndLatestHashAreDifferentKinds() async throws { let container = makeInMemoryContainer() @@ -4034,13 +4111,14 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let badge = await manager.updateRemoteSignature(gid: gid, latestSignature: "hash:new") let stored = await manager.testingFetchDownload(gid: gid) - XCTAssertEqual(badge, .downloaded) - XCTAssertEqual(stored?.status, .completed) - XCTAssertEqual(stored?.remoteVersionSignature, "chain:\(gid):token") - XCTAssertEqual(stored?.latestRemoteVersionSignature, "hash:new") + #expect(badge == .downloaded) + #expect(stored?.status == .completed) + #expect(stored?.remoteVersionSignature == "chain:\(gid):token") + #expect(stored?.latestRemoteVersionSignature == "hash:new") } @MainActor + @Test func testUpdateRemoteSignatureDoesNotMarkUpdateAvailableWhenStoredHashAndLatestNonOriginalChainAreDifferentKinds() async throws { let container = makeInMemoryContainer() @@ -4068,13 +4146,14 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) let stored = await manager.testingFetchDownload(gid: gid) - XCTAssertEqual(badge, .downloaded) - XCTAssertEqual(stored?.status, .completed) - XCTAssertEqual(stored?.remoteVersionSignature, "hash:old") - XCTAssertEqual(stored?.latestRemoteVersionSignature, "chain:othergid:othertoken") + #expect(badge == .downloaded) + #expect(stored?.status == .completed) + #expect(stored?.remoteVersionSignature == "hash:old") + #expect(stored?.latestRemoteVersionSignature == "chain:othergid:othertoken") } @MainActor + @Test func testUpdateRemoteSignatureCanonicalizesStoredHashToOriginalChainWithoutMarkingUpdate() async throws { let container = makeInMemoryContainer() @@ -4102,13 +4181,14 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) let stored = await manager.testingFetchDownload(gid: gid) - XCTAssertEqual(badge, .downloaded) - XCTAssertEqual(stored?.status, .completed) - XCTAssertEqual(stored?.remoteVersionSignature, "chain:\(gid):token") - XCTAssertEqual(stored?.latestRemoteVersionSignature, "chain:\(gid):token") + #expect(badge == .downloaded) + #expect(stored?.status == .completed) + #expect(stored?.remoteVersionSignature == "chain:\(gid):token") + #expect(stored?.latestRemoteVersionSignature == "chain:\(gid):token") } @MainActor + @Test func testDetailReducerDoesNotRequestVersionMetadataForUndownloadedGallery() async { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() @@ -4160,12 +4240,13 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) await store.skipReceivedActions(strict: false) - XCTAssertEqual(updateCheckCount.value, 0) - XCTAssertNil(store.state.galleryVersionMetadata) - XCTAssertFalse(store.state.shouldCheckForRemoteUpdates) + #expect(updateCheckCount.value == 0) + #expect(store.state.galleryVersionMetadata == nil) + #expect(store.state.shouldCheckForRemoteUpdates == false) } @MainActor + @Test func testDetailReducerRequestsVersionMetadataWhenBadgeArrivesAfterDetail() async throws { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() @@ -4214,7 +4295,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.fetchGalleryDetailDone(.success((detail, galleryState, "", nil)))) await store.skipReceivedActions(strict: false) - XCTAssertEqual(updateCheckCount.value, 0) + #expect(updateCheckCount.value == 0) await store.send(.fetchDownloadBadgeDone(.downloaded)) await drainDetailMetadataEffects( @@ -4224,13 +4305,14 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } ) - XCTAssertEqual(updateCheckCount.value, 1) - XCTAssertTrue(store.state.shouldCheckForRemoteUpdates) - XCTAssertTrue(store.state.didRequestVersionMetadata) - XCTAssertNotNil(store.state.galleryVersionMetadata) + #expect(updateCheckCount.value == 1) + #expect(store.state.shouldCheckForRemoteUpdates) + #expect(store.state.didRequestVersionMetadata) + #expect(store.state.galleryVersionMetadata != nil) } @MainActor + @Test func testDetailReducerRequestsVersionMetadataWhenBadgeArrivesBeforeDetail() async throws { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() @@ -4279,7 +4361,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.send(.fetchDownloadBadgeDone(.downloaded)) await store.skipReceivedActions(strict: false) - XCTAssertEqual(updateCheckCount.value, 0) + #expect(updateCheckCount.value == 0) await store.send(.fetchGalleryDetailDone(.success((detail, galleryState, "", nil)))) await drainDetailMetadataEffects( @@ -4289,13 +4371,14 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } ) - XCTAssertEqual(updateCheckCount.value, 1) - XCTAssertTrue(store.state.shouldCheckForRemoteUpdates) - XCTAssertTrue(store.state.didRequestVersionMetadata) - XCTAssertNotNil(store.state.galleryVersionMetadata) + #expect(updateCheckCount.value == 1) + #expect(store.state.shouldCheckForRemoteUpdates) + #expect(store.state.didRequestVersionMetadata) + #expect(store.state.galleryVersionMetadata != nil) } @MainActor + @Test func testDetailReducerObserveDownloadDoneAlsoTriggersMetadataCheckWithoutDuplicateRequests() async throws { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() @@ -4345,14 +4428,15 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { store, condition: { updateCheckCount.value == 1 } ) - XCTAssertEqual(updateCheckCount.value, 1) + #expect(updateCheckCount.value == 1) await store.send(.observeDownloadDone(.downloaded)) await store.skipReceivedActions(strict: false) - XCTAssertEqual(updateCheckCount.value, 1) + #expect(updateCheckCount.value == 1) } @MainActor + @Test func testDetailReducerRemoteUpdateFlagDoesNotStayStickyWhenBadgeReturnsToNone() async throws { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() @@ -4404,9 +4488,9 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil } ) - XCTAssertEqual(updateCheckCount.value, 1) - XCTAssertTrue(store.state.shouldCheckForRemoteUpdates) - XCTAssertTrue(store.state.didRequestVersionMetadata) + #expect(updateCheckCount.value == 1) + #expect(store.state.shouldCheckForRemoteUpdates) + #expect(store.state.didRequestVersionMetadata) await store.send(.fetchDownloadBadgeDone(.none)) { $0.downloadBadge = .none @@ -4417,12 +4501,13 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } await store.skipReceivedActions(strict: false) - XCTAssertFalse(store.state.shouldCheckForRemoteUpdates) - XCTAssertFalse(store.state.didRequestVersionMetadata) - XCTAssertNil(store.state.galleryVersionMetadata) + #expect(store.state.shouldCheckForRemoteUpdates == false) + #expect(store.state.didRequestVersionMetadata == false) + #expect(store.state.galleryVersionMetadata == nil) } @MainActor + @Test func testDetailReducerDeleteDownloadResetsDownloadContext() async { let download = sampleDownload( gid: "7733", @@ -4474,12 +4559,13 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } await store.skipReceivedActions(strict: false) - XCTAssertFalse(store.state.isDownloadContext) - XCTAssertFalse(store.state.shouldCheckForRemoteUpdates) - XCTAssertFalse(store.state.didRequestVersionMetadata) - XCTAssertNil(store.state.galleryVersionMetadata) + #expect(store.state.isDownloadContext == false) + #expect(store.state.shouldCheckForRemoteUpdates == false) + #expect(store.state.didRequestVersionMetadata == false) + #expect(store.state.galleryVersionMetadata == nil) } + @Test func testFileBasedQuotaImageMapsToQuotaExceeded() async throws { let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) defer { try? FileManager.default.removeItem(at: fileURL) } @@ -4496,9 +4582,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: URL(string: "https://ehgt.org/g/509.gif") ) - XCTAssertEqual(error, .quotaExceeded) + #expect(error == .quotaExceeded) } + @Test func testFileBasedQuotaImageRequiresKnown509Signature() async throws { let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) defer { try? FileManager.default.removeItem(at: fileURL) } @@ -4518,16 +4605,17 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: URL(string: "https://ehgt.org/g/509.gif") ) - XCTAssertNil(error) + #expect(error == nil) } + @Test func testFileBasedBinaryKokomadeImageMapsToAuthenticationRequired() async throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) .appendingPathExtension("gif") defer { try? FileManager.default.removeItem(at: fileURL) } - let imageData = try XCTUnwrap(Data(base64Encoded: "R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")) + let imageData = try #require(Data(base64Encoded: "R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")) try imageData.write(to: fileURL, options: .atomic) let manager = makeTestingDownloadManager() @@ -4542,15 +4630,16 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1") ) - XCTAssertEqual(error, .authenticationRequired) + #expect(error == .authenticationRequired) } + @Test func testFileBasedQuotaImageFingerprintMapsToQuotaExceededEvenWhenURLLooksNormal() async throws { let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) defer { try? FileManager.default.removeItem(at: fileURL) } let manager = makeTestingDownloadManager() - let normalImageURL = try XCTUnwrap(URL(string: "https://ehgt.org/h/normal-image-cache-key/1")) + let normalImageURL = try #require(URL(string: "https://ehgt.org/h/normal-image-cache-key/1")) let response = makeResponse( url: normalImageURL, contentType: "image/gif", @@ -4562,15 +4651,16 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: normalImageURL ) - XCTAssertEqual(error, .quotaExceeded) + #expect(error == .quotaExceeded) } + @Test func testFileBasedKokomadeImageFingerprintMapsToAuthenticationRequiredEvenWhenURLLooksNormal() async throws { let fileURL = try writeFixtureToTemporaryFile(resource: "Kokomade", pathExtension: "jpg") defer { try? FileManager.default.removeItem(at: fileURL) } let manager = makeTestingDownloadManager() - let normalImageURL = try XCTUnwrap(URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1&key=normal-cache-key")) + let normalImageURL = try #require(URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1&key=normal-cache-key")) let error = await manager.testingDetectResponseError( fileURL: fileURL, response: makeResponse( @@ -4581,9 +4671,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: normalImageURL ) - XCTAssertEqual(error, .authenticationRequired) + #expect(error == .authenticationRequired) } + @Test func testFileBasedTextImageLimitMapsToQuotaExceeded() async throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) @@ -4605,10 +4696,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: URL(string: "https://e-hentai.org/s/1/1-1") ) - XCTAssertEqual(error, .quotaExceeded) + #expect(error == .quotaExceeded) } @MainActor + @Test func testCachedQuotaPlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { let container = makeInMemoryContainer() @@ -4619,7 +4711,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) - let normalImageURL = try XCTUnwrap( + let normalImageURL = try #require( URL(string: "https://ehgt.org/h/quota-placeholder-cache-\(gid)/1") ) try insertPersistedGalleryState(in: container, gid: gid, imageURLs: [1: normalImageURL]) @@ -4681,11 +4773,12 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let restoredPageURL = storage.temporaryFolderURL(gid: gid) .appendingPathComponent("pages/0001.gif") - XCTAssertEqual(restoredCount, 0) - XCTAssertFalse(FileManager.default.fileExists(atPath: restoredPageURL.path)) + #expect(restoredCount == 0) + #expect(FileManager.default.fileExists(atPath: restoredPageURL.path) == false) } @MainActor + @Test func testCachedKokomadePlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { let container = makeInMemoryContainer() @@ -4696,7 +4789,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) - let normalImageURL = try XCTUnwrap(URL(string: "https://exhentai.org/fullimg.php?gid=\(gid)&page=1&key=normal-cache-key")) + let normalImageURL = try #require(URL(string: "https://exhentai.org/fullimg.php?gid=\(gid)&page=1&key=normal-cache-key")) try insertPersistedGalleryState(in: container, gid: gid, imageURLs: [1: normalImageURL]) let imageData = try fixtureData(resource: "Kokomade", pathExtension: "jpg") @@ -4754,10 +4847,11 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { let restoredPageURL = storage.temporaryFolderURL(gid: gid) .appendingPathComponent("pages/0001.jpg") - XCTAssertEqual(restoredCount, 0) - XCTAssertFalse(FileManager.default.fileExists(atPath: restoredPageURL.path)) + #expect(restoredCount == 0) + #expect(FileManager.default.fileExists(atPath: restoredPageURL.path) == false) } + @Test func testFileBasedEmptyExResponseMapsToAuthenticationRequired() async throws { let fileURL = try writeFixtureToTemporaryFile(filename: .exLoginRequired) defer { try? FileManager.default.removeItem(at: fileURL) } @@ -4782,9 +4876,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: URL(string: "https://exhentai.org/g/1/1/") ) - XCTAssertEqual(error, .authenticationRequired) + #expect(error == .authenticationRequired) } + @Test func testFileBasedAuthHTMLMarkersMapToAuthenticationRequired() async throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) @@ -4812,9 +4907,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: URL(string: "https://exhentai.org/g/1/1/") ) - XCTAssertEqual(error, .authenticationRequired) + #expect(error == .authenticationRequired) } + @Test func testFileBasedInvalidPageMapsToNotFound() async throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) @@ -4836,9 +4932,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: URL(string: "https://e-hentai.org/g/1/1/") ) - XCTAssertEqual(error, .notFound) + #expect(error == .notFound) } + @Test func testFileBasedKeepTryingMapsToNotFound() async throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) @@ -4860,9 +4957,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: URL(string: "https://e-hentai.org/s/1/1-1") ) - XCTAssertEqual(error, .notFound) + #expect(error == .notFound) } + @Test func testFileBasedHTTP404MapsToNotFound() async throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) @@ -4883,9 +4981,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: URL(string: "https://e-hentai.org/g/1/1/") ) - XCTAssertEqual(error, .notFound) + #expect(error == .notFound) } + @Test func testFileBased404GalleryNotAvailableFallsBackToNotFound() async throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) @@ -4911,9 +5010,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: URL(string: "https://e-hentai.org/g/1/1/") ) - XCTAssertEqual(error, .notFound) + #expect(error == .notFound) } + @Test func testFileBasedHTMLBanPageStillParsesThroughParserInsteadOfParseFailed() async throws { let fileURL = try writeFixtureToTemporaryFile(filename: .ipBanned) defer { try? FileManager.default.removeItem(at: fileURL) } @@ -4929,12 +5029,14 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { requestURL: URL(string: "https://example.com/banned") ) - XCTAssertNotEqual(error, .parseFailed) + #expect(error != .parseFailed) guard case .ipBanned = error else { - return XCTFail("Expected ipBanned, got \(String(describing: error))") + Issue.record("Expected ipBanned, got \(String(describing: error))") + return } } + @Test func testIpBannedDoesNotRetryImmediately() async throws { let sessionID = UUID().uuidString let configuration = URLSessionConfiguration.ephemeral @@ -4977,17 +5079,19 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { for: download, mode: .redownload ) - XCTFail("Expected ipBanned error") + Issue.record("Expected ipBanned error") } catch let error as AppError { guard case .ipBanned = error else { - return XCTFail("Expected ipBanned, got \(error)") + Issue.record("Expected ipBanned, got \(error)") + return } } - XCTAssertEqual(recorder.snapshot().detailRequests, 1) + #expect(recorder.snapshot().detailRequests == 1) } @MainActor + @Test func testReadingReducerLocalSourceWithoutGalleryStateDoesNotStayLoading() async { let download = sampleDownload( gid: "700001", @@ -5051,11 +5155,12 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } await store.finish() - XCTAssertEqual(store.state.databaseLoadingState, .idle) - XCTAssertEqual(store.state.readingProgress, 0) + #expect(store.state.databaseLoadingState == .idle) + #expect(store.state.readingProgress == 0) } @MainActor + @Test func testReadingReducerDoesNotReloadLocalPagesWhenOnlyOtherGalleryChanges() async { let gallery = sampleGallery() let relevantDownload = sampleDownload( @@ -5106,7 +5211,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { delete: { _ in .success(()) }, loadManifest: { _ in .failure(.notFound) }, loadLocalPageURLs: { gid in - XCTAssertEqual(gid, gallery.gid) + #expect(gid == gallery.gid) loadCount.value += 1 return .success([:]) } @@ -5123,18 +5228,19 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.receive(\.observeDownloadsDone, [relevantDownload]) await store.receive(\.loadLocalPageURLs, gallery.gid) await store.receive(\.loadLocalPageURLsDone) - XCTAssertEqual(loadCount.value, 1) + #expect(loadCount.value == 1) continuationBox.value?.yield([relevantDownload, updatedOtherDownload]) try? await Task.sleep(for: .milliseconds(50)) - XCTAssertEqual(loadCount.value, 1) + #expect(loadCount.value == 1) continuationBox.value?.finish() await store.finish() } @MainActor + @Test func testPreviewsReducerDoesNotReloadLocalPreviewsWhenOnlyOtherGalleryChanges() async { let gallery = sampleGallery() let relevantDownload = sampleDownload( @@ -5180,7 +5286,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { delete: { _ in .success(()) }, loadManifest: { _ in .failure(.notFound) }, loadLocalPageURLs: { gid in - XCTAssertEqual(gid, gallery.gid) + #expect(gid == gallery.gid) loadCount.value += 1 return .success([:]) } @@ -5196,18 +5302,19 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await store.receive(\.observeDownloadsDone, [relevantDownload]) await store.receive(\.loadLocalPreviewURLs, gallery.gid) await store.receive(\.loadLocalPreviewURLsDone) - XCTAssertEqual(loadCount.value, 1) + #expect(loadCount.value == 1) continuationBox.value?.yield([relevantDownload, updatedOtherDownload]) try? await Task.sleep(for: .milliseconds(50)) - XCTAssertEqual(loadCount.value, 1) + #expect(loadCount.value == 1) continuationBox.value?.finish() await store.finish() } @MainActor + @Test func testReadingAndPreviewsStillEmitOneFinalRefreshWhenRelevantDownloadDisappears() async { let gallery = sampleGallery() let relevantDownload = sampleDownload( @@ -5267,7 +5374,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await readingStore.receive(\.loadLocalPageURLs, gallery.gid) await readingStore.receive(\.loadLocalPageURLsDone) - XCTAssertEqual(readingLoadCount.value, 2) + #expect(readingLoadCount.value == 2) readingContinuationBox.value?.finish() await readingStore.finish() @@ -5316,12 +5423,13 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { await previewsStore.receive(\.loadLocalPreviewURLs, gallery.gid) await previewsStore.receive(\.loadLocalPreviewURLsDone) - XCTAssertEqual(previewsLoadCount.value, 2) + #expect(previewsLoadCount.value == 2) previewsContinuationBox.value?.finish() await previewsStore.finish() } @MainActor + @Test func testDownloadInspectorClearsInspectionWhenObservedDownloadDisappears() async { let download = sampleDownload( gid: "9988", @@ -5373,6 +5481,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { } @MainActor + @Test func testDownloadManagerBatchesObserverUpdatesDuringCachedPageRestore() async throws { let container = makeInMemoryContainer() @@ -5400,7 +5509,7 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { UIColor.systemTeal.setFill() context.fill(.init(x: 0, y: 0, width: 1, height: 1)) } - let imageData = try XCTUnwrap(cachedImage.jpegData(compressionQuality: 1)) + let imageData = try #require(cachedImage.jpegData(compressionQuality: 1)) let imageURLs = Dictionary(uniqueKeysWithValues: (1...pageCount).map { index in (index, URL(string: "https://example.com/pages/\(gid)-\(index).jpg")!) }) @@ -5476,10 +5585,10 @@ final class DownloadFeatureReducerTests: XCTestCase, TestHelper { ) let stored = await manager.testingFetchDownload(gid: gid) - XCTAssertEqual(restoredCount, pageCount) - XCTAssertEqual(stored?.completedPageCount, pageCount) - XCTAssertLessThan(emissionCount, pageCount) - XCTAssertLessThanOrEqual(emissionCount, 1 + Int(ceil(Double(pageCount) / 8.0))) + #expect(restoredCount == pageCount) + #expect(stored?.completedPageCount == pageCount) + #expect(emissionCount < pageCount) + #expect(emissionCount <= 1 + Int(ceil(Double(pageCount) / 8.0))) } } @@ -5499,7 +5608,7 @@ private extension DownloadFeatureReducerTests { } let missingKeys = cacheKeys.filter { !KingfisherManager.shared.cache.isCached(forKey: $0) } - XCTAssertTrue( + #expect( missingKeys.isEmpty, "Timed out waiting for Kingfisher cache visibility for keys: \(missingKeys)" ) @@ -5526,7 +5635,7 @@ private extension DownloadFeatureReducerTests { let value = try await group.next() group.cancelAll() - return try XCTUnwrap(value) + return try #require(value, "Expected one task group result for \(description).") } } @@ -5608,14 +5717,14 @@ private extension DownloadFeatureReducerTests { } func fixtureData(resource: String, pathExtension: String) throws -> Data { - let fixtureURL = try XCTUnwrap( - Bundle(for: Self.self).url(forResource: resource, withExtension: pathExtension) + let fixtureURL = try #require( + Bundle(for: TestBundleLocator.self).url(forResource: resource, withExtension: pathExtension) ) return try Data(contentsOf: fixtureURL) } func installGalleryVersionMetadataStub(for gallery: Gallery, sessionID: String) throws { - let gid = try XCTUnwrap(Int(gallery.gid)) + let gid = try #require(Int(gallery.gid)) let payload: [String: Any] = [ "gmetadata": [[ "gid": gid, @@ -5781,7 +5890,11 @@ private extension DownloadFeatureReducerTests { manifest: DownloadManifest ) throws -> URL { guard let folderURL = download.folderURL else { - throw XCTSkip("Downloads directory is unavailable in the test environment.") + throw NSError( + domain: "DownloadFeatureReducerTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Downloads directory is unavailable in the test environment."] + ) } try? FileManager.default.removeItem(at: folderURL) try FileManager.default.createDirectory( @@ -5804,19 +5917,26 @@ private extension DownloadFeatureReducerTests { } func makeInMemoryContainer() -> NSPersistentContainer { - let modelURL = Bundle(for: Self.self).url(forResource: "Model", withExtension: "momd") + let modelURL = Bundle(for: TestBundleLocator.self).url(forResource: "Model", withExtension: "momd") ?? Bundle.main.url(forResource: "Model", withExtension: "momd")! let model = NSManagedObjectModel(contentsOf: modelURL)! let container = NSPersistentContainer(name: UUID().uuidString, managedObjectModel: model) let description = NSPersistentStoreDescription() description.type = NSInMemoryStoreType container.persistentStoreDescriptions = [description] - let expectation = XCTestExpectation(description: "Load in-memory store") + let semaphore = DispatchSemaphore(value: 0) + var loadError: Error? container.loadPersistentStores { _, error in - XCTAssertNil(error) - expectation.fulfill() + loadError = error + semaphore.signal() + } + let waitResult = semaphore.wait(timeout: .now() + 5) + if waitResult == .timedOut { + Issue.record("Timed out loading in-memory persistent store.") + } + if let loadError { + Issue.record("Failed to load in-memory persistent store: \(loadError)") } - _ = XCTWaiter.wait(for: [expectation], timeout: 5) return container } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 74b29a323..816857991 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -4,10 +4,11 @@ // import Foundation -import XCTest +import Testing @testable import EhPanda -final class DownloadFileStorageTests: XCTestCase { +struct DownloadFileStorageTests { + @Test func testWriteReadAndValidateManifest() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -38,10 +39,11 @@ final class DownloadFileStorageTests: XCTestCase { let loadedManifest = try storage.readManifest(folderURL: folderURL) - XCTAssertEqual(loadedManifest, manifest) - XCTAssertEqual(storage.validate(download: download), .valid) + #expect(loadedManifest == manifest) + #expect(storage.validate(download: download) == .valid) } + @Test func testEnsureRootDirectoryMarksDownloadsFolderExcludedFromBackup() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -49,9 +51,10 @@ final class DownloadFileStorageTests: XCTestCase { try storage.ensureRootDirectory() let resourceValues = try rootURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) - XCTAssertEqual(resourceValues.isExcludedFromBackup, true) + #expect(resourceValues.isExcludedFromBackup == true) } + @Test func testValidateReportsMissingPageFiles() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -74,12 +77,12 @@ final class DownloadFileStorageTests: XCTestCase { options: .atomic ) - XCTAssertEqual( - storage.validate(download: download), - .missingFiles("Page 2 is missing.") + #expect( + storage.validate(download: download) == .missingFiles("Page 2 is missing.") ) } + @Test func testValidateRemovesZeroBytePageFilesAndRequiresRepair() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -106,17 +109,17 @@ final class DownloadFileStorageTests: XCTestCase { options: .atomic ) - XCTAssertEqual( - storage.validate(download: download), - .missingFiles("Page 1 is missing.") + #expect( + storage.validate(download: download) == .missingFiles("Page 1 is missing.") ) - XCTAssertFalse( + #expect( FileManager.default.fileExists( atPath: folderURL.appendingPathComponent("pages/0001.jpg").path - ) + ) == false ) } + @Test func testCleanupTemporaryFoldersRemovesOnlyTemporaryArtifacts() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -129,10 +132,11 @@ final class DownloadFileStorageTests: XCTestCase { try storage.cleanupTemporaryFolders() - XCTAssertFalse(FileManager.default.fileExists(atPath: temporaryURL.path)) - XCTAssertTrue(FileManager.default.fileExists(atPath: regularURL.path)) + #expect(FileManager.default.fileExists(atPath: temporaryURL.path) == false) + #expect(FileManager.default.fileExists(atPath: regularURL.path)) } + @Test func testCleanupTemporaryFoldersPreservesSpecifiedGalleryFolders() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -145,10 +149,11 @@ final class DownloadFileStorageTests: XCTestCase { try storage.cleanupTemporaryFolders(preservingGIDs: ["123"]) - XCTAssertTrue(FileManager.default.fileExists(atPath: preservedURL.path)) - XCTAssertFalse(FileManager.default.fileExists(atPath: removedURL.path)) + #expect(FileManager.default.fileExists(atPath: preservedURL.path)) + #expect(FileManager.default.fileExists(atPath: removedURL.path) == false) } + @Test func testExistingPageRelativePathsDetectsCompletedPages() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -165,15 +170,15 @@ final class DownloadFileStorageTests: XCTestCase { try Data([0x03]).write(to: pagesURL.appendingPathComponent("0027.jpg"), options: .atomic) try Data([0x04]).write(to: pagesURL.appendingPathComponent("invalid.jpg"), options: .atomic) - XCTAssertEqual( - storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2), - [ + #expect( + storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2) == [ 1: "pages/0001.jpg", 2: "pages/0002.png" ] ) } + @Test func testExistingPageRelativePathsRemovesZeroByteFiles() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -189,15 +194,15 @@ final class DownloadFileStorageTests: XCTestCase { try Data().write(to: emptyPageURL, options: .atomic) try Data([0x02]).write(to: pagesURL.appendingPathComponent("0002.png"), options: .atomic) - XCTAssertEqual( - storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2), - [ + #expect( + storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2) == [ 2: "pages/0002.png" ] ) - XCTAssertFalse(FileManager.default.fileExists(atPath: emptyPageURL.path)) + #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) } + @Test func testIsReadableAssetFileDoesNotDeleteFileWhenAttributesLookupFails() throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -211,10 +216,11 @@ final class DownloadFileStorageTests: XCTestCase { try Data([0xFF, 0xD8, 0xFF]).write(to: fileURL, options: .atomic) fileManager.failingPath = fileURL.path - XCTAssertTrue(storage.isReadableAssetFile(at: fileURL)) - XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) + #expect(storage.isReadableAssetFile(at: fileURL)) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) } + @Test func testMakeFolderRelativePathSanitizesSeparatorsWhitespaceAndLength() { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -222,16 +228,17 @@ final class DownloadFileStorageTests: XCTestCase { let unsafeTitle = " /Alpha\\\\Beta:\n\tGamma Delta \(String(repeating: "X", count: 200)). " let relativePath = storage.makeFolderRelativePath(gid: "123", title: unsafeTitle) - XCTAssertTrue(relativePath.hasPrefix("123 - ")) - XCTAssertFalse(relativePath.contains("/")) - XCTAssertFalse(relativePath.contains("\\")) - XCTAssertFalse(relativePath.contains(":")) - XCTAssertFalse(relativePath.contains("\n")) - XCTAssertFalse(relativePath.hasSuffix(" ")) - XCTAssertFalse(relativePath.hasSuffix(".")) - XCTAssertLessThanOrEqual(relativePath.count, "123 - ".count + 96) + #expect(relativePath.hasPrefix("123 - ")) + #expect(relativePath.contains("/") == false) + #expect(relativePath.contains("\\") == false) + #expect(relativePath.contains(":") == false) + #expect(relativePath.contains("\n") == false) + #expect(relativePath.hasSuffix(" ") == false) + #expect(relativePath.hasSuffix(".") == false) + #expect(relativePath.count <= "123 - ".count + 96) } + @Test func testWriteAndReadResumeState() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -252,12 +259,10 @@ final class DownloadFileStorageTests: XCTestCase { ) try storage.writeResumeState(resumeState, folderURL: folderURL) - XCTAssertEqual( - try storage.readResumeState(folderURL: folderURL), - resumeState - ) + #expect(try storage.readResumeState(folderURL: folderURL) == resumeState) } + @Test func testWriteReadAndRemoveFailedPagesSnapshot() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -277,12 +282,17 @@ final class DownloadFileStorageTests: XCTestCase { ) try storage.writeFailedPages(snapshot, folderURL: folderURL) - XCTAssertEqual(try storage.readFailedPages(folderURL: folderURL), snapshot) + #expect(try storage.readFailedPages(folderURL: folderURL) == snapshot) try storage.removeFailedPages(folderURL: folderURL) - XCTAssertThrowsError(try storage.readFailedPages(folderURL: folderURL)) + do { + _ = try storage.readFailedPages(folderURL: folderURL) + Issue.record("Expected readFailedPages to throw after removing the snapshot.") + } catch { + } } + @Test func testMaterializeRepairSeedCopiesOnlyManifestCoverAndExistingPageFiles() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -323,38 +333,39 @@ final class DownloadFileStorageTests: XCTestCase { to: tempFolderURL ) - XCTAssertTrue( + #expect( FileManager.default.fileExists( atPath: tempFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest).path ) ) - XCTAssertTrue( + #expect( FileManager.default.fileExists( atPath: tempFolderURL.appendingPathComponent("cover.jpg").path ) ) - XCTAssertTrue( + #expect( FileManager.default.fileExists( atPath: tempFolderURL.appendingPathComponent("pages/0001.jpg").path ) ) - XCTAssertFalse( + #expect( FileManager.default.fileExists( atPath: tempFolderURL.appendingPathComponent("pages/0002.jpg").path - ) + ) == false ) - XCTAssertTrue( + #expect( FileManager.default.fileExists( atPath: tempFolderURL.appendingPathComponent("pages/0003.jpg").path ) ) - XCTAssertFalse( + #expect( FileManager.default.fileExists( atPath: tempFolderURL.appendingPathComponent("nested/ignored.bin").path - ) + ) == false ) } + @Test func testLinkOrCopyReadableAssetFallsBackToCopyWhenHardLinkFails() throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -370,8 +381,8 @@ final class DownloadFileStorageTests: XCTestCase { try storage.linkOrCopyReadableAsset(at: sourceURL, to: destinationURL) - XCTAssertTrue(FileManager.default.fileExists(atPath: destinationURL.path)) - XCTAssertEqual(try Data(contentsOf: destinationURL), Data([0x01, 0x02, 0x03])) + #expect(FileManager.default.fileExists(atPath: destinationURL.path)) + #expect(try Data(contentsOf: destinationURL) == Data([0x01, 0x02, 0x03])) } } diff --git a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift index c2c42f6d5..3ff83a109 100644 --- a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift @@ -3,10 +3,12 @@ // EhPandaTests // -import XCTest +import Testing +import Foundation @testable import EhPanda -final class DownloadSignatureBuilderTests: XCTestCase { +struct DownloadSignatureBuilderTests { + @Test func testVersionIdentifierPrefersGalleryChainMetadata() { let signature = DownloadSignatureBuilder.make( gallery: sampleGallery, @@ -27,9 +29,10 @@ final class DownloadSignatureBuilderTests: XCTestCase { ) ) - XCTAssertEqual(signature, "chain:2000000:new-chain-key") + #expect(signature == "chain:2000000:new-chain-key") } + @Test func testVersionIdentifierFallsBackToOriginalGalleryIdentityWhenCurrentChainFieldsAreMissing() { let signature = DownloadSignatureBuilder.make( gallery: sampleGallery, @@ -48,9 +51,10 @@ final class DownloadSignatureBuilderTests: XCTestCase { ) ) - XCTAssertEqual(signature, "chain:\(sampleGallery.gid):\(sampleGallery.token)") + #expect(signature == "chain:\(sampleGallery.gid):\(sampleGallery.token)") } + @Test func testMakeReturnsHashPrefixedFallbackSignature() { let signature = DownloadSignatureBuilder.make( gallery: sampleGallery, @@ -59,72 +63,72 @@ final class DownloadSignatureBuilderTests: XCTestCase { previewURLs: [:] ) - XCTAssertTrue(signature.hasPrefix("hash:")) + #expect(signature.hasPrefix("hash:")) } + @Test func testHashAndChainSignaturesAreIncomparableForUpdateCheck() { - XCTAssertEqual( + #expect( DownloadSignatureBuilder.hasUpdateComparison( remoteVersionSignature: "hash:abc", latestRemoteVersionSignature: "chain:newgid:newtoken", gid: sampleGallery.gid, token: sampleGallery.token - ), - .incomparable + ) == .incomparable ) - XCTAssertNil( + #expect( DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( remoteVersionSignature: "hash:abc", latestRemoteVersionSignature: "chain:newgid:newtoken", gid: sampleGallery.gid, token: sampleGallery.token - ) + ) == nil ) } + @Test func testCanonicalizeHashToOriginalChainOnlyWhenLatestMatchesOriginalGalleryIdentity() { let latestSignature = "chain:\(sampleGallery.gid):\(sampleGallery.token)" - XCTAssertEqual( + #expect( DownloadSignatureBuilder.hasUpdateComparison( remoteVersionSignature: "hash:abc", latestRemoteVersionSignature: latestSignature, gid: sampleGallery.gid, token: sampleGallery.token - ), - .same + ) == .same ) - XCTAssertEqual( + #expect( DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( remoteVersionSignature: "hash:abc", latestRemoteVersionSignature: latestSignature, gid: sampleGallery.gid, token: sampleGallery.token - ), - latestSignature + ) == latestSignature ) } + @Test func testDoNotCanonicalizeHashWhenLatestChainPointsToDifferentCurrentGallery() { - XCTAssertEqual( + #expect( DownloadSignatureBuilder.hasUpdateComparison( remoteVersionSignature: "hash:abc", latestRemoteVersionSignature: "chain:othergid:othertoken", gid: sampleGallery.gid, token: sampleGallery.token - ), - .incomparable + ) == .incomparable ) - XCTAssertNil( + #expect( DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( remoteVersionSignature: "hash:abc", latestRemoteVersionSignature: "chain:othergid:othertoken", gid: sampleGallery.gid, token: sampleGallery.token - ) + ) == nil ) } + @Test func testSignatureIgnoresPreviewHostRotationAndLayoutChanges() { let firstSignature = DownloadSignatureBuilder.make( gallery: sampleGallery, @@ -146,9 +150,10 @@ final class DownloadSignatureBuilderTests: XCTestCase { ] ) - XCTAssertEqual(firstSignature, secondSignature) + #expect(firstSignature == secondSignature) } + @Test func testSignatureChangesWhenCombinedPreviewAtlasChanges() { let firstSignature = DownloadSignatureBuilder.make( gallery: sampleGallery, @@ -168,9 +173,10 @@ final class DownloadSignatureBuilderTests: XCTestCase { ] ) - XCTAssertNotEqual(firstSignature, secondSignature) + #expect(firstSignature != secondSignature) } + @Test func testSignatureIgnoresCombinedPreviewTokenRotation() { let firstSignature = DownloadSignatureBuilder.make( gallery: sampleGallery, @@ -190,9 +196,10 @@ final class DownloadSignatureBuilderTests: XCTestCase { ] ) - XCTAssertEqual(firstSignature, secondSignature) + #expect(firstSignature == secondSignature) } + @Test func testSignatureIgnoresHostRotationForStandalonePreviewURLs() { let firstSignature = DownloadSignatureBuilder.make( gallery: sampleGallery, @@ -214,9 +221,10 @@ final class DownloadSignatureBuilderTests: XCTestCase { ] ) - XCTAssertEqual(firstSignature, secondSignature) + #expect(firstSignature == secondSignature) } + @Test func testSignatureIgnoresCoverHostAndQueryChanges() { let firstSignature = DownloadSignatureBuilder.make( gallery: sampleGallery, @@ -232,9 +240,10 @@ final class DownloadSignatureBuilderTests: XCTestCase { previewURLs: [:] ) - XCTAssertEqual(firstSignature, secondSignature) + #expect(firstSignature == secondSignature) } + @Test func testSignatureIgnoresGalleryHostTransitions() { let ehSignature = DownloadSignatureBuilder.make( gallery: sampleGallery, @@ -254,7 +263,7 @@ final class DownloadSignatureBuilderTests: XCTestCase { ] ) - XCTAssertEqual(ehSignature, exSignature) + #expect(ehSignature == exSignature) } } diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift index 50d83112b..4c6caca54 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift @@ -4,35 +4,36 @@ // import Kanna -import XCTest +import Testing @testable import EhPanda -class GalleryDetailParserTests: XCTestCase, TestHelper { +struct GalleryDetailParserTests: TestHelper { + @Test func testExample() throws { let document = try htmlDocument(filename: .galleryDetail) let (detail, state) = try Parser.parseGalleryDetail(doc: document, gid: "2725078") - XCTAssertEqual(detail.gid, "2725078") - XCTAssertEqual(detail.title, "[Artist] mks") - XCTAssertEqual(detail.jpnTitle, "[アーティスト] mks") - XCTAssertFalse(detail.isFavorited) - XCTAssertEqual(detail.visibility, .yes) - XCTAssertEqual(detail.rating, 4.5) - XCTAssertEqual(detail.userRating, 0) - XCTAssertEqual(detail.ratingCount, 110) - XCTAssertEqual(detail.category, .nonH) - XCTAssertEqual(detail.language, .japanese) - XCTAssertEqual(detail.uploader, "Pokom") - XCTAssertEqual(detail.coverURL?.absoluteString, "https://ehgt.org/03/08/0308268821e99628b05a19fa54e2fc0fa9ad8f4b-1705560-1012-1470-png_250.jpg") - XCTAssertEqual(detail.archiveURL?.absoluteString, "https://e-hentai.org/archiver.php?gid=3103480&token=0000000000") - XCTAssertEqual(detail.parentURL?.absoluteString, "https://e-hentai.org/g/2930572/daf4b9880d/") - XCTAssertEqual(detail.favoritedCount, 591) - XCTAssertEqual(detail.pageCount, 156) - XCTAssertEqual(detail.sizeCount, 314.3) - XCTAssertEqual(detail.sizeType, "MiB") - XCTAssertEqual(detail.torrentCount, 1) - XCTAssertEqual(state.tags.count, 1) - XCTAssertEqual(state.previewURLs.count, 40) - XCTAssertEqual(state.previewConfig, .normal(rows: 4)) - XCTAssertEqual(state.comments.count, 10) + #expect(detail.gid == "2725078") + #expect(detail.title == "[Artist] mks") + #expect(detail.jpnTitle == "[アーティスト] mks") + #expect(detail.isFavorited == false) + #expect(detail.visibility == .yes) + #expect(detail.rating == 4.5) + #expect(detail.userRating == 0) + #expect(detail.ratingCount == 110) + #expect(detail.category == .nonH) + #expect(detail.language == .japanese) + #expect(detail.uploader == "Pokom") + #expect(detail.coverURL?.absoluteString == "https://ehgt.org/03/08/0308268821e99628b05a19fa54e2fc0fa9ad8f4b-1705560-1012-1470-png_250.jpg") + #expect(detail.archiveURL?.absoluteString == "https://e-hentai.org/archiver.php?gid=3103480&token=0000000000") + #expect(detail.parentURL?.absoluteString == "https://e-hentai.org/g/2930572/daf4b9880d/") + #expect(detail.favoritedCount == 591) + #expect(detail.pageCount == 156) + #expect(detail.sizeCount == 314.3) + #expect(detail.sizeType == "MiB") + #expect(detail.torrentCount == 1) + #expect(state.tags.count == 1) + #expect(state.previewURLs.count == 40) + #expect(state.previewConfig == .normal(rows: 4)) + #expect(state.comments.count == 10) } } diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift index 6f4d2a128..d8efd1b91 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift @@ -4,10 +4,11 @@ // import Kanna -import XCTest +import Testing @testable import EhPanda -class GalleryImageURLParserTests: XCTestCase, TestHelper { +struct GalleryImageURLParserTests: TestHelper { + @Test func testExample() throws { let document = try htmlDocument(filename: .galleryNormalImageURL) try testGalleryNormalImageURLParser(doc: document) @@ -17,13 +18,13 @@ class GalleryImageURLParserTests: XCTestCase, TestHelper { func testGalleryNormalImageURLParser(doc: HTMLDocument) throws { let inputIndex = 1 let (index, imageURL, originalImageURL) = try Parser.parseGalleryNormalImageURL(doc: doc, index: inputIndex) - XCTAssertEqual(index, inputIndex) - XCTAssertEqual(imageURL.absoluteString, "https://akrtazd.spuqplybaxmf.hath.network:65000/h/ea42b28bceeae68f1f6adb414da61d186b3d126b-311480-1280-1920-jpg/keystamp=1694132700-fd778f8260;fileindex=132044713;xres=1280/87052610_5090394_0.jpg") - XCTAssertEqual(originalImageURL?.absoluteString, "https://e-hentai.org/fullimg.php?gid=0000000&page=1&key=000000000") + #expect(index == inputIndex) + #expect(imageURL.absoluteString == "https://akrtazd.spuqplybaxmf.hath.network:65000/h/ea42b28bceeae68f1f6adb414da61d186b3d126b-311480-1280-1920-jpg/keystamp=1694132700-fd778f8260;fileindex=132044713;xres=1280/87052610_5090394_0.jpg") + #expect(originalImageURL?.absoluteString == "https://e-hentai.org/fullimg.php?gid=0000000&page=1&key=000000000") } func testSkipServerIdentifierParser(doc: HTMLDocument) throws { let identifier = try Parser.parseSkipServerIdentifier(doc: doc) - XCTAssertEqual(identifier, "00000-000000") + #expect(identifier == "00000-000000") } } diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift index 215f1295c..8ef61f90b 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift @@ -4,14 +4,15 @@ // import Kanna -import XCTest +import Testing @testable import EhPanda -class GalleryMPVKeysParserTests: XCTestCase, TestHelper { +struct GalleryMPVKeysParserTests: TestHelper { + @Test func testExample() throws { let document = try htmlDocument(filename: .galleryMPVKeys) let (mpvKey, mpvImageKeys) = try Parser.parseMPVKeys(doc: document) - XCTAssertEqual(mpvKey, "00000000000") - XCTAssertEqual(mpvImageKeys.count, 194) + #expect(mpvKey == "00000000000") + #expect(mpvImageKeys.count == 194) } } diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index c03e8a2cc..3b0abc130 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -4,22 +4,23 @@ // import Kanna -import XCTest +import Testing @testable import EhPanda -class ListParserTests: XCTestCase, TestHelper { +struct ListParserTests: TestHelper { + @Test func testExample() throws { let tuples: [(ListParserTestType, HTMLDocument)] = try ListParserTestType.allCases.compactMap { type in (type, try htmlDocument(filename: type.filename)) } - XCTAssertEqual(tuples.count, ListParserTestType.allCases.count) + #expect(tuples.count == ListParserTestType.allCases.count) try tuples.forEach { type, document in let galleries = try Parser.parseGalleries(doc: document) let uploaders = galleries.compactMap(\.uploader).filter(\.notEmpty) - XCTAssertEqual(galleries.count, type.assertCount, .init(describing: type)) + #expect(galleries.count == type.assertCount, "\(type)") if type.hasUploader { - XCTAssertEqual(uploaders.count, type.assertCount, .init(describing: type)) + #expect(uploaders.count == type.assertCount, "\(type)") } } } diff --git a/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift b/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift index 5a4672ba9..f71298701 100644 --- a/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift @@ -4,13 +4,14 @@ // import Kanna -import XCTest +import Testing @testable import EhPanda -class BanIntervalParserTests: XCTestCase, TestHelper { +struct BanIntervalParserTests: TestHelper { + @Test func testExample() throws { let document = try htmlDocument(filename: .ipBanned) let banInterval = Parser.parseBanInterval(doc: document) - XCTAssertEqual(banInterval, .minutes(59, seconds: 48)) + #expect(banInterval == .minutes(59, seconds: 48)) } } diff --git a/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift b/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift index 9f424853e..895cd6554 100644 --- a/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift @@ -4,63 +4,60 @@ // import Kanna -import XCTest +import Testing @testable import EhPanda -final class DownloadPageErrorParserTests: XCTestCase, TestHelper { +struct DownloadPageErrorParserTests: TestHelper { + @Test func testIPBannedPageMapsToIPBanned() throws { let document = try htmlDocument(filename: .ipBanned) - XCTAssertEqual( - Parser.parseDownloadPageError(doc: document), - .ipBanned(.minutes(59, seconds: 48)) + #expect( + Parser.parseDownloadPageError(doc: document) == .ipBanned(.minutes(59, seconds: 48)) ) } + @Test func testNormalGalleryDetailPageDoesNotMapToDownloadError() throws { let document = try htmlDocument(filename: .galleryDetail) - XCTAssertNil(Parser.parseDownloadPageError(doc: document)) + #expect(Parser.parseDownloadPageError(doc: document) == nil) } + @Test func testAuthenticationRequiredMarkersMapToAuthenticationRequired() throws { - let document = try XCTUnwrap( - Kanna.HTML( - html: """ - - - - -

Access to ExHentai.org is restricted.

- - - """, - encoding: .utf8 - ) + let document = try Kanna.HTML( + html: """ + + + + +

Access to ExHentai.org is restricted.

+ + + """, + encoding: .utf8 ) - XCTAssertEqual( - Parser.parseDownloadPageError(doc: document), - .authenticationRequired - ) + #expect(Parser.parseDownloadPageError(doc: document) == .authenticationRequired) } + @Test func testNotFoundMarkersMapToNotFound() throws { - let document = try XCTUnwrap( - Kanna.HTML( - html: """ -

Invalid page

Gallery not found.

Key missing.

Keep trying.

- """, - encoding: .utf8 - ) + let document = try Kanna.HTML( + html: """ +

Invalid page

Gallery not found.

Key missing.

Keep trying.

+ """, + encoding: .utf8 ) - XCTAssertEqual(Parser.parseDownloadPageError(doc: document), .notFound) - XCTAssertEqual(Parser.parseDownloadPageError(content: "Gallery not found"), .notFound) - XCTAssertEqual(Parser.parseDownloadPageError(content: "Keep trying"), .notFound) + #expect(Parser.parseDownloadPageError(doc: document) == .notFound) + #expect(Parser.parseDownloadPageError(content: "Gallery not found") == .notFound) + #expect(Parser.parseDownloadPageError(content: "Keep trying") == .notFound) } + @Test func testGalleryNotAvailableIsNotHardMappedToDownloadError() { - XCTAssertNil(Parser.parseDownloadPageError(content: "Gallery Not Available")) + #expect(Parser.parseDownloadPageError(content: "Gallery Not Available") == nil) } } diff --git a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift b/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift index aa9c67759..b81e81ba2 100644 --- a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift @@ -4,10 +4,11 @@ // import Kanna -import XCTest +import Testing @testable import EhPanda -class EhSettingParserTests: XCTestCase, TestHelper { +struct EhSettingParserTests: TestHelper { + @Test func testExample() throws { let document = try htmlDocument(filename: .ehSetting) let ehSetting = try Parser.parseEhSetting(doc: document) @@ -17,74 +18,74 @@ class EhSettingParserTests: XCTestCase, TestHelper { } func testEhProfiles(_ profiles: [EhProfile]) { - XCTAssertEqual(profiles.count, 3) + #expect(profiles.count == 3) let ehProfile1 = profiles[0] - XCTAssertEqual(ehProfile1.value, 1) - XCTAssertEqual(ehProfile1.name, "Default Profile") - XCTAssertEqual(ehProfile1.isSelected, true) - XCTAssertEqual(ehProfile1.isDefault, true) + #expect(ehProfile1.value == 1) + #expect(ehProfile1.name == "Default Profile") + #expect(ehProfile1.isSelected == true) + #expect(ehProfile1.isDefault == true) let ehProfile2 = profiles[1] - XCTAssertEqual(ehProfile2.value, 2) - XCTAssertEqual(ehProfile2.name, "EhPanda") - XCTAssertEqual(ehProfile2.isSelected, false) - XCTAssertEqual(ehProfile2.isDefault, false) - XCTAssertTrue(EhSetting.verifyEhPandaProfileName(with: ehProfile2.name)) + #expect(ehProfile2.value == 2) + #expect(ehProfile2.name == "EhPanda") + #expect(ehProfile2.isSelected == false) + #expect(ehProfile2.isDefault == false) + #expect(EhSetting.verifyEhPandaProfileName(with: ehProfile2.name)) } func testCapability(ehSetting: EhSetting) { - XCTAssertEqual(ehSetting.capableLoadThroughHathSetting, .legacyNo) - XCTAssertEqual(ehSetting.capableLoadThroughHathSettings, EhSetting.LoadThroughHathSetting.allCases) + #expect(ehSetting.capableLoadThroughHathSetting == .legacyNo) + #expect(ehSetting.capableLoadThroughHathSettings == EhSetting.LoadThroughHathSetting.allCases) - XCTAssertEqual(ehSetting.capableImageResolution, .x2400) - XCTAssertEqual(ehSetting.capableImageResolutions, EhSetting.ImageResolution.allCases) + #expect(ehSetting.capableImageResolution == .x2400) + #expect(ehSetting.capableImageResolutions == EhSetting.ImageResolution.allCases) - XCTAssertEqual(ehSetting.capableSearchResultCount, .oneHundred) - XCTAssertEqual(ehSetting.capableSearchResultCounts, [.twentyFive, .fifty, .oneHundred]) + #expect(ehSetting.capableSearchResultCount == .oneHundred) + #expect(ehSetting.capableSearchResultCounts == [.twentyFive, .fifty, .oneHundred]) - XCTAssertEqual(ehSetting.capableThumbnailConfigSizes, [.auto, .small, .normal]) + #expect(ehSetting.capableThumbnailConfigSizes == [.auto, .small, .normal]) - XCTAssertEqual(ehSetting.capableThumbnailConfigRowCount, .forty) - XCTAssertEqual(ehSetting.capableThumbnailConfigRowCounts, EhSetting.ThumbnailRowCount.allCases) + #expect(ehSetting.capableThumbnailConfigRowCount == .forty) + #expect(ehSetting.capableThumbnailConfigRowCounts == EhSetting.ThumbnailRowCount.allCases) } func testRemainingStuff(ehSetting: EhSetting) { - XCTAssertEqual(ehSetting.loadThroughHathSetting, .anyClient) - XCTAssertEqual(ehSetting.browsingCountry, .autoDetect) - XCTAssertEqual(ehSetting.literalBrowsingCountry, "Japan") - XCTAssertEqual(ehSetting.imageResolution, .auto) - XCTAssertEqual(ehSetting.imageSizeWidth, 0) - XCTAssertEqual(ehSetting.imageSizeHeight, 0) - XCTAssertEqual(ehSetting.galleryName, .japanese) - XCTAssertEqual(ehSetting.archiverBehavior, .manualSelectManualStart) - XCTAssertEqual(ehSetting.displayMode, .compact) - XCTAssertEqual(ehSetting.showSearchRangeIndicator, true) - XCTAssertEqual(ehSetting.disabledCategories, .init(repeating: false, count: 10)) - XCTAssertEqual(ehSetting.favoriteCategories, [ + #expect(ehSetting.loadThroughHathSetting == .anyClient) + #expect(ehSetting.browsingCountry == .autoDetect) + #expect(ehSetting.literalBrowsingCountry == "Japan") + #expect(ehSetting.imageResolution == .auto) + #expect(ehSetting.imageSizeWidth == 0) + #expect(ehSetting.imageSizeHeight == 0) + #expect(ehSetting.galleryName == .japanese) + #expect(ehSetting.archiverBehavior == .manualSelectManualStart) + #expect(ehSetting.displayMode == .compact) + #expect(ehSetting.showSearchRangeIndicator == true) + #expect(ehSetting.disabledCategories == .init(repeating: false, count: 10)) + #expect(ehSetting.favoriteCategories == [ "Favorites 0", "Favorites 1", "Favorites 2", "Favorites 3", "Favorites 4", "Favorites 5", "Favorites 6", "Favorites 7", "Favorites 8", "Favorites 9" ]) - XCTAssertEqual(ehSetting.favoritesSortOrder, .favoritedTime) - XCTAssertEqual(ehSetting.ratingsColor, "") - XCTAssertEqual(ehSetting.tagFilteringThreshold, 0) - XCTAssertEqual(ehSetting.tagWatchingThreshold, 0) - XCTAssertEqual(ehSetting.showFilteredRemovalCount, true) - XCTAssertEqual(ehSetting.excludedLanguages, .init(repeating: false, count: 50)) - XCTAssertEqual(ehSetting.excludedUploaders, "") - XCTAssertEqual(ehSetting.searchResultCount, .oneHundred) - XCTAssertEqual(ehSetting.thumbnailLoadTiming, .onMouseOver) - XCTAssertEqual(ehSetting.thumbnailConfigSize, .auto) - XCTAssertEqual(ehSetting.thumbnailConfigRows, .four) - XCTAssertEqual(ehSetting.coverScaleFactor, 100) - XCTAssertEqual(ehSetting.viewportVirtualWidth, 0) - XCTAssertEqual(ehSetting.commentsSortOrder, .oldest) - XCTAssertEqual(ehSetting.commentVotesShowTiming, .onHoverOrClick) - XCTAssertEqual(ehSetting.tagsSortOrder, .alphabetical) - XCTAssertEqual(ehSetting.galleryPageNumbering, .none) - XCTAssertEqual(ehSetting.useOriginalImages, false) - XCTAssertEqual(ehSetting.useMultiplePageViewer, true) - XCTAssertEqual(ehSetting.multiplePageViewerStyle, .alignLeftScaleIfOverWidth) - XCTAssertEqual(ehSetting.multiplePageViewerShowThumbnailPane, true) + #expect(ehSetting.favoritesSortOrder == .favoritedTime) + #expect(ehSetting.ratingsColor == "") + #expect(ehSetting.tagFilteringThreshold == 0) + #expect(ehSetting.tagWatchingThreshold == 0) + #expect(ehSetting.showFilteredRemovalCount == true) + #expect(ehSetting.excludedLanguages == .init(repeating: false, count: 50)) + #expect(ehSetting.excludedUploaders == "") + #expect(ehSetting.searchResultCount == .oneHundred) + #expect(ehSetting.thumbnailLoadTiming == .onMouseOver) + #expect(ehSetting.thumbnailConfigSize == .auto) + #expect(ehSetting.thumbnailConfigRows == .four) + #expect(ehSetting.coverScaleFactor == 100) + #expect(ehSetting.viewportVirtualWidth == 0) + #expect(ehSetting.commentsSortOrder == .oldest) + #expect(ehSetting.commentVotesShowTiming == .onHoverOrClick) + #expect(ehSetting.tagsSortOrder == .alphabetical) + #expect(ehSetting.galleryPageNumbering == .none) + #expect(ehSetting.useOriginalImages == false) + #expect(ehSetting.useMultiplePageViewer == true) + #expect(ehSetting.multiplePageViewerStyle == .alignLeftScaleIfOverWidth) + #expect(ehSetting.multiplePageViewerShowThumbnailPane == true) } } diff --git a/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift b/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift index 6da760198..b00f8fb6a 100644 --- a/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift @@ -4,19 +4,20 @@ // import Kanna -import XCTest +import Testing @testable import EhPanda -class GreetingParserTests: XCTestCase, TestHelper { +struct GreetingParserTests: TestHelper { + @Test func testExample() throws { let document = try htmlDocument(filename: .galleryDetailWithGreeting) let greeting = try Parser.parseGreeting(doc: document) - XCTAssertEqual(greeting.gainedEXP, 30) - XCTAssertEqual(greeting.gainedCredits, 329) - XCTAssertNil(greeting.gainedGP) - XCTAssertNil(greeting.gainedHath) - XCTAssertNotNil(greeting.updateTime) - XCTAssertFalse(greeting.gainedNothing) - XCTAssertNotNil(greeting.gainContent) + #expect(greeting.gainedEXP == 30) + #expect(greeting.gainedCredits == 329) + #expect(greeting.gainedGP == nil) + #expect(greeting.gainedHath == nil) + #expect(greeting.updateTime != nil) + #expect(greeting.gainedNothing == false) + #expect(greeting.gainContent != nil) } } diff --git a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift index 40c165511..d72a43e4a 100644 --- a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -4,10 +4,11 @@ // import SwiftUI -import XCTest +import Testing @testable import EhPanda -final class SettingDownloadTests: XCTestCase { +struct SettingDownloadTests { + @Test func testLegacySettingDecodesDownloadDefaults() throws { let data = """ { @@ -18,20 +19,20 @@ final class SettingDownloadTests: XCTestCase { let setting = try JSONDecoder().decode(Setting.self, from: data) - XCTAssertEqual(setting.downloadThreadMode, .single) - XCTAssertTrue(setting.downloadAllowCellular) - XCTAssertTrue(setting.downloadAutoRetryFailedPages) + #expect(setting.downloadThreadMode == .single) + #expect(setting.downloadAllowCellular) + #expect(setting.downloadAutoRetryFailedPages) } + @Test func testDownloadOptionsSnapshotMatchesSettingValues() { var setting = Setting() setting.downloadThreadMode = .quadruple setting.downloadAllowCellular = false setting.downloadAutoRetryFailedPages = false - XCTAssertEqual( - setting.downloadOptionsSnapshot, - DownloadOptionsSnapshot( + #expect( + setting.downloadOptionsSnapshot == DownloadOptionsSnapshot( threadMode: .quadruple, allowCellular: false, autoRetryFailedPages: false @@ -39,6 +40,7 @@ final class SettingDownloadTests: XCTestCase { ) } + @Test func testLegacyDownloadOptionsSnapshotDecodesWithoutOriginalImageField() throws { let data = """ { @@ -51,9 +53,8 @@ final class SettingDownloadTests: XCTestCase { let snapshot = try JSONDecoder().decode(DownloadOptionsSnapshot.self, from: data) - XCTAssertEqual( - snapshot, - DownloadOptionsSnapshot( + #expect( + snapshot == DownloadOptionsSnapshot( threadMode: .triple, allowCellular: false, autoRetryFailedPages: false @@ -61,40 +62,37 @@ final class SettingDownloadTests: XCTestCase { ) } + @Test func testImageCacheKeysPreferStablePathAlias() { let url = URL(string: "https://alpha.hath.network/h/123/456/image.webp?download=1")! - XCTAssertEqual( - url.imageCacheKeys(includeStableAlias: true), - [ + #expect( + url.imageCacheKeys(includeStableAlias: true) == [ "download::h/123/456/image.webp", "https://alpha.hath.network/h/123/456/image.webp?download=1" ] ) } + @Test func testStableImageCacheKeyIgnoresHostRotationAndQuery() { let firstURL = URL(string: "https://alpha.hath.network/h/123/456/image.webp?download=1")! let secondURL = URL(string: "https://beta.hath.network/h/123/456/image.webp?source=viewer")! - XCTAssertEqual(firstURL.stableImageCacheKey, secondURL.stableImageCacheKey) + #expect(firstURL.stableImageCacheKey == secondURL.stableImageCacheKey) } + @Test func testStableImageCacheKeyKeepsIdentityQueryForFullImageScript() { let firstURL = URL(string: "https://e-hentai.org/fullimg.php?gid=42&page=7&key=alpha")! let secondURL = URL(string: "https://exhentai.org/fullimg.php?page=7&gid=42&key=beta")! - XCTAssertEqual( - firstURL.stableImageCacheKey, - "download::fullimg.php?gid=42&key=alpha&page=7" - ) - XCTAssertEqual( - secondURL.stableImageCacheKey, - "download::fullimg.php?gid=42&key=beta&page=7" - ) - XCTAssertNotEqual(firstURL.stableImageCacheKey, secondURL.stableImageCacheKey) + #expect(firstURL.stableImageCacheKey == "download::fullimg.php?gid=42&key=alpha&page=7") + #expect(secondURL.stableImageCacheKey == "download::fullimg.php?gid=42&key=beta&page=7") + #expect(firstURL.stableImageCacheKey != secondURL.stableImageCacheKey) } + @Test func testCombinedPreviewURLCleanupIncludesPlainPreviewURL() { let plainURL = URL(string: "https://ehgt.org/ab/cd/preview.webp")! let combinedURL = URLUtil.combinedPreviewURL( @@ -104,10 +102,7 @@ final class SettingDownloadTests: XCTestCase { offset: "40" ) - XCTAssertEqual( - combinedURL.previewCacheCleanupURLs(), - [combinedURL, plainURL] - ) - XCTAssertEqual(plainURL.previewCacheCleanupURLs(), [plainURL]) + #expect(combinedURL.previewCacheCleanupURLs() == [combinedURL, plainURL]) + #expect(plainURL.previewCacheCleanupURLs() == [plainURL]) } } From 9b52635275c7ad8baa9cd2dbdb8afaac652ec283 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 28 Mar 2026 12:58:21 +0800 Subject: [PATCH 007/614] Update xctest-dynamic-overlay & use #require macro --- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../DownloadFeatureReducerTests.swift | 382 ++++++++++-------- .../Download/DownloadFileStorageTests.swift | 10 +- .../DownloadSignatureBuilderTests.swift | 42 +- .../Parser/Other/SettingDownloadTests.swift | 28 +- 5 files changed, 249 insertions(+), 217 deletions(-) diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6ef3a677d..f840f4719 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -249,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", - "version" : "1.7.0" + "revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad", + "version" : "1.9.0" } } ], diff --git a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift index 492497b58..938c746a4 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift @@ -10,6 +10,7 @@ import UIKit import Testing @testable import EhPanda +@Suite(.serialized) struct DownloadFeatureReducerTests: TestHelper { @Test func testQuickSearchWordUsesNameWhenContentIsEmpty() { @@ -20,7 +21,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testPauseKeepsActiveDownloadPausedWhenDeferredSchedulingRuns() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000)) let rootURL = FileManager.default.temporaryDirectory @@ -68,7 +69,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testPauseUsesTemporaryWorkingSetProgressWhenCancelling() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 1) let rootURL = FileManager.default.temporaryDirectory @@ -129,7 +130,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testReconcileDownloadsNormalizesLegacyFailedStatusToNeedsAttention() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) let rootURL = FileManager.default.temporaryDirectory @@ -160,7 +161,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testReconcileDownloadsClearsCancellationLikeGalleryError() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 3) let rootURL = FileManager.default.temporaryDirectory @@ -195,7 +196,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testLoadInspectionFiltersCancellationFailuresIntoPendingPages() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 4) let rootURL = FileManager.default.temporaryDirectory @@ -747,14 +748,14 @@ struct DownloadFeatureReducerTests: TestHelper { #expect(store.state.didRunLaunchAutomation == false) #expect(store.state.isWaitingForIgneousBeforeLaunchAutomation) - let response: HTTPURLResponse = HTTPURLResponse( + let response = try #require(HTTPURLResponse( url: Defaults.URL.exhentai, statusCode: 200, httpVersion: nil, headerFields: [ "Set-Cookie": "\(Defaults.Cookie.igneous)=test-igneous" ] - )! + )) await store.send(.setting(.fetchIgneousDone(.success(response)))) await store.receive(\.runLaunchAutomation) { $0.didRunLaunchAutomation = true @@ -1073,7 +1074,12 @@ struct DownloadFeatureReducerTests: TestHelper { }, delete: { _ in .success(()) }, loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in .success(initialState.inspection!) } + loadInspection: { _ in + guard let inspection = initialState.inspection else { + return .failure(.notFound) + } + return .success(inspection) + } ) } store.exhaustivity = .off @@ -1121,7 +1127,12 @@ struct DownloadFeatureReducerTests: TestHelper { }, delete: { _ in .success(()) }, loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in .success(initialState.inspection!) } + loadInspection: { _ in + guard let inspection = initialState.inspection else { + return .failure(.notFound) + } + return .success(inspection) + } ) } store.exhaustivity = .off @@ -1432,7 +1443,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000)) let rootURL = FileManager.default.temporaryDirectory @@ -1487,7 +1498,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testDownloadManagerLoadLocalPageURLsPrefersCompletedFolderForCompletedDownload() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 11) let rootURL = FileManager.default.temporaryDirectory @@ -1513,7 +1524,7 @@ struct DownloadFeatureReducerTests: TestHelper { at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - let manifest = sampleManifest(gid: gid, title: "Pause Race") + let manifest = try sampleManifest(gid: gid, title: "Pause Race") try JSONEncoder().encode(manifest).write( to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), options: .atomic @@ -1546,7 +1557,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testDownloadManagerLoadLocalPageURLsMergesReadableCompletedPagesWithTemporaryPages() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 12) let rootURL = FileManager.default.temporaryDirectory @@ -1572,7 +1583,7 @@ struct DownloadFeatureReducerTests: TestHelper { at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - let manifest = sampleManifest(gid: gid, title: "Pause Race") + let manifest = try sampleManifest(gid: gid, title: "Pause Race") try JSONEncoder().encode(manifest).write( to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), options: .atomic @@ -1632,7 +1643,7 @@ struct DownloadFeatureReducerTests: TestHelper { at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - let oldManifest = sampleManifest( + let oldManifest = try sampleManifest( gid: gid, title: "Mixed Version", pageCount: 2, @@ -1719,7 +1730,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testDownloadManagerLoadLocalPageURLsMarksCompletedDownloadMissingFilesWhenZeroBytePageIsFound() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 13) let rootURL = FileManager.default.temporaryDirectory @@ -1745,7 +1756,7 @@ struct DownloadFeatureReducerTests: TestHelper { at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - let manifest = sampleManifest(gid: gid, title: "Pause Race") + let manifest = try sampleManifest(gid: gid, title: "Pause Race") try JSONEncoder().encode(manifest).write( to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), options: .atomic @@ -1796,7 +1807,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testRetryPagesQueuesWorkWhenAnotherDownloadIsActive() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) let rootURL = FileManager.default.temporaryDirectory @@ -1865,7 +1876,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testCancelQueuedRepairRestoresReadableCountAndClearsPendingOperation() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = "cancel-repair-\(UUID().uuidString)" let rootURL = FileManager.default.temporaryDirectory @@ -1894,7 +1905,7 @@ struct DownloadFeatureReducerTests: TestHelper { at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - let manifest = sampleManifest(gid: gid, title: "Pause Race") + let manifest = try sampleManifest(gid: gid, title: "Pause Race") try JSONEncoder().encode(manifest).write( to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), options: .atomic @@ -1922,7 +1933,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testRetryPagesUsesMinimalSourceResolutionAndSkipsWhenNoPendingPages() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 200) @@ -1942,15 +1953,16 @@ struct DownloadFeatureReducerTests: TestHelper { let recorder = RequestRecorder() let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") let mpvHTML = try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html") + let gidInt = try #require(Int(gid)) let metadataResponse = try JSONSerialization.data(withJSONObject: [ "gmetadata": [[ - "gid": Int(gid)!, + "gid": gidInt, "token": "token", - "current_gid": Int(gid)!, + "current_gid": gidInt, "current_key": "updated-key", - "parent_gid": Int(gid)!, + "parent_gid": gidInt, "parent_key": "token", - "first_gid": Int(gid)!, + "first_gid": gidInt, "first_key": "token" ]] ]) @@ -1963,12 +1975,12 @@ struct DownloadFeatureReducerTests: TestHelper { if url.host == "api.e-hentai.org" { recorder.recordMetadata() return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] - )!, + )), metadataResponse ) } @@ -1985,12 +1997,12 @@ struct DownloadFeatureReducerTests: TestHelper { recorder.recordDetail() } return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "text/html; charset=utf-8"] - )!, + )), detailHTML ) } @@ -1998,12 +2010,12 @@ struct DownloadFeatureReducerTests: TestHelper { if url.path.contains("/mpv/\(gid)/token") { recorder.recordMPV() return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "text/html; charset=utf-8"] - )!, + )), mpvHTML ) } @@ -2014,12 +2026,12 @@ struct DownloadFeatureReducerTests: TestHelper { if method?["method"] as? String == "gdata" { recorder.recordMetadata() return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] - )!, + )), metadataResponse ) } @@ -2029,12 +2041,12 @@ struct DownloadFeatureReducerTests: TestHelper { "i": "https://example.com/image-\(pageIndex).jpg" ]) return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] - )!, + )), responseData ) } @@ -2042,12 +2054,12 @@ struct DownloadFeatureReducerTests: TestHelper { if url.host == "example.com" { recorder.recordImageDownload() return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "image/jpeg"] - )!, + )), Data([0xFF, 0xD8, 0xFF, 0xD9]) ) } @@ -2075,7 +2087,7 @@ struct DownloadFeatureReducerTests: TestHelper { let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) let pageCount = payload.galleryDetail.pageCount - let manifest = sampleManifest( + let manifest = try sampleManifest( gid: gid, title: "Pause Race", pageCount: pageCount, @@ -2154,7 +2166,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testRetryPagesFallsBackToFullUpdateWhenGalleryHasUpdate() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) @@ -2180,15 +2192,16 @@ struct DownloadFeatureReducerTests: TestHelper { ) let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") let mpvHTML = try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html") + let gidInt = try #require(Int(gid)) let metadataResponse = try JSONSerialization.data(withJSONObject: [ "gmetadata": [[ - "gid": Int(gid)!, + "gid": gidInt, "token": "token", - "current_gid": Int(gid)!, + "current_gid": gidInt, "current_key": "updated-key", - "parent_gid": Int(gid)!, + "parent_gid": gidInt, "parent_key": "token", - "first_gid": Int(gid)!, + "first_gid": gidInt, "first_key": "token" ]] ]) @@ -2200,24 +2213,24 @@ struct DownloadFeatureReducerTests: TestHelper { if url.path.contains("/g/\(gid)/token") { return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "text/html; charset=utf-8"] - )!, + )), detailHTML ) } if url.path.contains("/mpv/") { return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "text/html; charset=utf-8"] - )!, + )), mpvHTML ) } @@ -2228,12 +2241,12 @@ struct DownloadFeatureReducerTests: TestHelper { let method = body?["method"] as? String if method == "gdata" { return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] - )!, + )), metadataResponse ) } @@ -2242,24 +2255,24 @@ struct DownloadFeatureReducerTests: TestHelper { "i": "https://example.com/image-\(pageIndex).jpg" ]) return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] - )!, + )), responseData ) } if url.host == "example.com" { return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "image/jpeg"] - )!, + )), Data([0xFF, 0xD8, 0xFF, 0xD9]) ) } @@ -2333,7 +2346,7 @@ struct DownloadFeatureReducerTests: TestHelper { try? storage.removeTemporaryFolder(gid: gid) // Immediate update path: retryPages should normalize the working set to full-update semantics. - let manifest = sampleManifest( + let manifest = try sampleManifest( gid: gid, title: "Pause Race", pageCount: pageCount, @@ -2405,7 +2418,7 @@ struct DownloadFeatureReducerTests: TestHelper { @Test func testProcessDownloadClearsStalePageSelectionWhenLatestPayloadRevealsUpdate() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 401) @@ -2428,15 +2441,16 @@ struct DownloadFeatureReducerTests: TestHelper { let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") let mpvHTML = try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html") var allowedImageURLs = Set() + let gidInt = try #require(Int(gid)) let metadataResponse = try JSONSerialization.data(withJSONObject: [ "gmetadata": [[ - "gid": Int(gid)!, + "gid": gidInt, "token": "token", - "current_gid": Int(gid)!, + "current_gid": gidInt, "current_key": "updated-key", - "parent_gid": Int(gid)!, + "parent_gid": gidInt, "parent_key": "token", - "first_gid": Int(gid)!, + "first_gid": gidInt, "first_key": "token" ]] ]) @@ -2448,24 +2462,24 @@ struct DownloadFeatureReducerTests: TestHelper { if url.path.contains("/g/\(gid)/token") { return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "text/html; charset=utf-8"] - )!, + )), detailHTML ) } if url.path.contains("/mpv/") { return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "text/html; charset=utf-8"] - )!, + )), mpvHTML ) } @@ -2476,12 +2490,12 @@ struct DownloadFeatureReducerTests: TestHelper { let method = body?["method"] as? String if method == "gdata" { return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] - )!, + )), metadataResponse ) } @@ -2490,24 +2504,24 @@ struct DownloadFeatureReducerTests: TestHelper { "i": "https://example.com/image-\(pageIndex).jpg" ]) return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] - )!, + )), responseData ) } if url.host == "example.com" || allowedImageURLs.contains(url.absoluteString) { return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "image/jpeg"] - )!, + )), Data([0xFF, 0xD8, 0xFF, 0xD9]) ) } @@ -2561,7 +2575,7 @@ struct DownloadFeatureReducerTests: TestHelper { at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - let staleManifest = sampleManifest( + let staleManifest = try sampleManifest( gid: gid, title: "Pause Race", pageCount: oldPageCount, @@ -2624,7 +2638,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test func testProcessDownloadClearsRemoteAssetCacheAfterSuccessfulDownload() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 402) @@ -2662,15 +2676,16 @@ struct DownloadFeatureReducerTests: TestHelper { offset: "40" ) var allowedImageURLs = Set() + let gidInt = try #require(Int(gid)) let metadataResponse = try JSONSerialization.data(withJSONObject: [ "gmetadata": [[ - "gid": Int(gid)!, + "gid": gidInt, "token": "token", - "current_gid": Int(gid)!, + "current_gid": gidInt, "current_key": "updated-key", - "parent_gid": Int(gid)!, + "parent_gid": gidInt, "parent_key": "token", - "first_gid": Int(gid)!, + "first_gid": gidInt, "first_key": "token" ]] ]) @@ -2682,24 +2697,24 @@ struct DownloadFeatureReducerTests: TestHelper { if url.path.contains("/g/\(gid)/token") { return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "text/html; charset=utf-8"] - )!, + )), detailHTML ) } if url.path.contains("/mpv/") { return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "text/html; charset=utf-8"] - )!, + )), mpvHTML ) } @@ -2710,12 +2725,12 @@ struct DownloadFeatureReducerTests: TestHelper { let method = body?["method"] as? String if method == "gdata" { return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] - )!, + )), metadataResponse ) } @@ -2724,24 +2739,24 @@ struct DownloadFeatureReducerTests: TestHelper { "i": currentPageImageURL.absoluteString ]) return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] - )!, + )), responseData ) } if url.host == "example.com" || allowedImageURLs.contains(url.absoluteString) { return ( - HTTPURLResponse( + try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "image/jpeg"] - )!, + )), Data([0xFF, 0xD8, 0xFF, 0xD9]) ) } @@ -2814,7 +2829,7 @@ struct DownloadFeatureReducerTests: TestHelper { at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - let staleManifest = sampleManifest( + let staleManifest = try sampleManifest( gid: gid, title: "Pause Race", pageCount: oldPageCount, @@ -2996,7 +3011,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test - func testDetailReducerStartDownloadEnqueuesGalleryWithSnapshotOptions() async { + func testDetailReducerStartDownloadEnqueuesGalleryWithSnapshotOptions() async throws { let capturedPayload = UncheckedBox(nil) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) @@ -3010,7 +3025,7 @@ struct DownloadFeatureReducerTests: TestHelper { initialState.gallery = gallery initialState.galleryDetail = detail initialState.galleryPreviewURLs = [ - 1: URL(string: "https://example.com/1.jpg")! + 1: try #require(URL(string: "https://example.com/1.jpg")) ] initialState.previewConfig = .large(rows: 2) @@ -3059,7 +3074,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test - func testDetailReducerStartDownloadUnlocksActionsAfterQueueing() async { + func testDetailReducerStartDownloadUnlocksActionsAfterQueueing() async throws { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let options = DownloadOptionsSnapshot() @@ -3068,7 +3083,7 @@ struct DownloadFeatureReducerTests: TestHelper { initialState.gallery = gallery initialState.galleryDetail = detail initialState.galleryPreviewURLs = [ - 1: URL(string: "https://example.com/1.jpg")! + 1: try #require(URL(string: "https://example.com/1.jpg")) ] let store = TestStore(initialState: initialState) { @@ -3118,7 +3133,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test - func testDetailReducerLaunchAutomationWaitsForResolvedDownloadBadge() async { + func testDetailReducerLaunchAutomationWaitsForResolvedDownloadBadge() async throws { let capturedPayload = UncheckedBox(nil) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) @@ -3127,7 +3142,7 @@ struct DownloadFeatureReducerTests: TestHelper { initialState.gallery = gallery initialState.galleryDetail = detail initialState.galleryPreviewURLs = [ - 1: URL(string: "https://example.com/1.jpg")! + 1: try #require(URL(string: "https://example.com/1.jpg")) ] setenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID", gallery.gid, 1) @@ -3422,7 +3437,7 @@ struct DownloadFeatureReducerTests: TestHelper { status: .completed, pageCount: 2 ) - let manifest = sampleManifest(gid: download.gid, title: download.title) + let manifest = try sampleManifest(gid: download.gid, title: download.title) var initialState = DetailReducer.State(download: download) initialState.galleryDetail = sampleGalleryDetail(gid: download.gid, title: download.title) @@ -3526,7 +3541,7 @@ struct DownloadFeatureReducerTests: TestHelper { pageCount: 2, completedPageCount: 2 ) - let manifest = sampleManifest(gid: download.gid, title: download.title) + let manifest = try sampleManifest(gid: download.gid, title: download.title) var initialState = PreviewsReducer.State() initialState.gallery = download.gallery @@ -3704,7 +3719,7 @@ struct DownloadFeatureReducerTests: TestHelper { func testReadingReducerRemoteSourceLoadsLocalPagesAndSkipsRemoteFetchForDownloadedPage() async throws { let gallery = sampleGallery() let localPageURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") - let remotePageURL = URL(string: "https://example.com/pages/0001.jpg")! + let remotePageURL = try #require(URL(string: "https://example.com/pages/0001.jpg")) var initialState = ReadingReducer.State(contentSource: .remote) initialState.gallery = gallery initialState.imageURLs = [1: remotePageURL] @@ -3763,10 +3778,10 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test - func testReadingReducerOnWebImageSucceededCapturesCachedPageIntoDownloadProgress() async { + func testReadingReducerOnWebImageSucceededCapturesCachedPageIntoDownloadProgress() async throws { let capturedCalls = UncheckedBox([(String, Int, URL?)]()) let gallery = sampleGallery() - let remotePageURL = URL(string: "https://example.com/pages/0001.jpg")! + let remotePageURL = try #require(URL(string: "https://example.com/pages/0001.jpg")) var initialState = ReadingReducer.State(contentSource: .remote) initialState.gallery = gallery initialState.imageURLs = [1: remotePageURL] @@ -3883,7 +3898,7 @@ struct DownloadFeatureReducerTests: TestHelper { status: .completed, pageCount: 2 ) - let manifest = sampleManifest(gid: download.gid, title: download.title) + let manifest = try sampleManifest(gid: download.gid, title: download.title) let folderURL = try prepareLocalDownloadFiles(download: download, manifest: manifest) defer { try? FileManager.default.removeItem(at: folderURL) } @@ -3920,7 +3935,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test func testDownloadManagerCaptureCachedPageRestoresTemporaryPageAndUpdatesCompletedCount() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 27) let rootURL = FileManager.default.temporaryDirectory @@ -3975,7 +3990,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test func testDownloadManagerCaptureCachedPageRepairsCompletedDownloadWithLatestRemoteImage() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 28) let rootURL = FileManager.default.temporaryDirectory @@ -4001,7 +4016,7 @@ struct DownloadFeatureReducerTests: TestHelper { at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - let manifest = sampleManifest(gid: gid, title: "Pause Race") + let manifest = try sampleManifest(gid: gid, title: "Pause Race") try JSONEncoder().encode(manifest).write( to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), options: .atomic @@ -4046,7 +4061,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test func testDownloadManagerReconcileNormalizesFailedDownloadBeforeTempCleanup() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 31) let rootURL = FileManager.default.temporaryDirectory @@ -4088,7 +4103,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test func testUpdateRemoteSignatureDoesNotMarkUpdateAvailableWhenStoredChainAndLatestHashAreDifferentKinds() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 101) let rootURL = FileManager.default.temporaryDirectory @@ -4120,7 +4135,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test func testUpdateRemoteSignatureDoesNotMarkUpdateAvailableWhenStoredHashAndLatestNonOriginalChainAreDifferentKinds() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 102) let rootURL = FileManager.default.temporaryDirectory @@ -4155,7 +4170,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test func testUpdateRemoteSignatureCanonicalizesStoredHashToOriginalChainWithoutMarkingUpdate() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 103) let rootURL = FileManager.default.temporaryDirectory @@ -4189,12 +4204,12 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test - func testDetailReducerDoesNotRequestVersionMetadataForUndownloadedGallery() async { + func testDetailReducerDoesNotRequestVersionMetadataForUndownloadedGallery() async throws { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) var galleryState = GalleryState(gid: gallery.gid) - galleryState.previewURLs = [1: URL(string: "https://example.com/1t.jpg")!] + galleryState.previewURLs = [1: try #require(URL(string: "https://example.com/1t.jpg"))] galleryState.previewConfig = .normal(rows: 4) var initialState = DetailReducer.State() @@ -4251,7 +4266,7 @@ struct DownloadFeatureReducerTests: TestHelper { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let galleryState = sampleGalleryState(gid: gallery.gid) + let galleryState = try sampleGalleryState(gid: gallery.gid) let sessionID = UUID().uuidString try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) defer { uninstallSharedSessionStub(sessionID: sessionID) } @@ -4317,7 +4332,7 @@ struct DownloadFeatureReducerTests: TestHelper { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let galleryState = sampleGalleryState(gid: gallery.gid) + let galleryState = try sampleGalleryState(gid: gallery.gid) let sessionID = UUID().uuidString try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) defer { uninstallSharedSessionStub(sessionID: sessionID) } @@ -4571,15 +4586,16 @@ struct DownloadFeatureReducerTests: TestHelper { defer { try? FileManager.default.removeItem(at: fileURL) } let manager = makeTestingDownloadManager() - let response = makeResponse( - url: URL(string: "https://ehgt.org/g/509.gif")!, + let quotaImageURL = try #require(URL(string: "https://ehgt.org/g/509.gif")) + let response = try makeResponse( + url: quotaImageURL, contentType: "image/gif", contentLength: 28658 ) let error = await manager.testingDetectResponseError( fileURL: fileURL, response: response, - requestURL: URL(string: "https://ehgt.org/g/509.gif") + requestURL: quotaImageURL ) #expect(error == .quotaExceeded) @@ -4594,15 +4610,16 @@ struct DownloadFeatureReducerTests: TestHelper { var data = try Data(contentsOf: fileURL) data[0] = 0 try data.write(to: fileURL, options: .atomic) - let response = makeResponse( - url: URL(string: "https://ehgt.org/g/509.gif")!, + let quotaImageURL = try #require(URL(string: "https://ehgt.org/g/509.gif")) + let response = try makeResponse( + url: quotaImageURL, contentType: "image/gif", contentLength: data.count ) let error = await manager.testingDetectResponseError( fileURL: fileURL, response: response, - requestURL: URL(string: "https://ehgt.org/g/509.gif") + requestURL: quotaImageURL ) #expect(error == nil) @@ -4619,8 +4636,9 @@ struct DownloadFeatureReducerTests: TestHelper { try imageData.write(to: fileURL, options: .atomic) let manager = makeTestingDownloadManager() - let response = makeResponse( - url: URL(string: "https://exhentai.org/img/kokomade.jpg")!, + let kokomadeURL = try #require(URL(string: "https://exhentai.org/img/kokomade.jpg")) + let response = try makeResponse( + url: kokomadeURL, contentType: "image/gif", contentLength: imageData.count ) @@ -4640,7 +4658,7 @@ struct DownloadFeatureReducerTests: TestHelper { let manager = makeTestingDownloadManager() let normalImageURL = try #require(URL(string: "https://ehgt.org/h/normal-image-cache-key/1")) - let response = makeResponse( + let response = try makeResponse( url: normalImageURL, contentType: "image/gif", contentLength: 28658 @@ -4663,7 +4681,7 @@ struct DownloadFeatureReducerTests: TestHelper { let normalImageURL = try #require(URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1&key=normal-cache-key")) let error = await manager.testingDetectResponseError( fileURL: fileURL, - response: makeResponse( + response: try makeResponse( url: normalImageURL, contentType: "image/jpeg", contentLength: 144844 @@ -4681,19 +4699,21 @@ struct DownloadFeatureReducerTests: TestHelper { .appendingPathExtension("html") defer { try? FileManager.default.removeItem(at: fileURL) } - try """ + let htmlData = try #require(""" You have exceeded your image viewing limits - """.data(using: .utf8)!.write(to: fileURL, options: .atomic) + """.data(using: .utf8)) + try htmlData.write(to: fileURL, options: .atomic) let manager = makeTestingDownloadManager() - let response = makeResponse( - url: URL(string: "https://e-hentai.org/s/1/1-1")!, + let quotaURL = try #require(URL(string: "https://e-hentai.org/s/1/1-1")) + let response = try makeResponse( + url: quotaURL, contentType: "text/html" ) let error = await manager.testingDetectResponseError( fileURL: fileURL, response: response, - requestURL: URL(string: "https://e-hentai.org/s/1/1-1") + requestURL: quotaURL ) #expect(error == .quotaExceeded) @@ -4702,7 +4722,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test func testCachedQuotaPlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 32) let rootURL = FileManager.default.temporaryDirectory @@ -4740,7 +4760,7 @@ struct DownloadFeatureReducerTests: TestHelper { pageCount: 1, postedDate: .now, coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token")! + galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")) ), galleryDetail: GalleryDetail( gid: gid, @@ -4780,7 +4800,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test func testCachedKokomadePlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 33) let rootURL = FileManager.default.temporaryDirectory @@ -4814,7 +4834,7 @@ struct DownloadFeatureReducerTests: TestHelper { pageCount: 1, postedDate: .now, coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: URL(string: "https://exhentai.org/g/\(gid)/token")! + galleryURL: try #require(URL(string: "https://exhentai.org/g/\(gid)/token")) ), galleryDetail: GalleryDetail( gid: gid, @@ -4866,7 +4886,7 @@ struct DownloadFeatureReducerTests: TestHelper { ) let manager = makeTestingDownloadManager() - let response = makeResponse( + let response = try makeResponse( url: Defaults.URL.exhentai, contentType: "text/html" ) @@ -4886,7 +4906,7 @@ struct DownloadFeatureReducerTests: TestHelper { .appendingPathExtension("html") defer { try? FileManager.default.removeItem(at: fileURL) } - try """ + let authHTMLData = try #require(""" Login @@ -4894,10 +4914,11 @@ struct DownloadFeatureReducerTests: TestHelper {

Access to ExHentai.org is restricted.

- """.data(using: .utf8)!.write(to: fileURL, options: .atomic) + """.data(using: .utf8)) + try authHTMLData.write(to: fileURL, options: .atomic) let manager = makeTestingDownloadManager() - let response = makeResponse( + let response = try makeResponse( url: Defaults.URL.exhentai, contentType: "text/html" ) @@ -4917,19 +4938,21 @@ struct DownloadFeatureReducerTests: TestHelper { .appendingPathExtension("html") defer { try? FileManager.default.removeItem(at: fileURL) } - try """ + let invalidPageData = try #require("""

Invalid page

Gallery not found

- """.data(using: .utf8)!.write(to: fileURL, options: .atomic) + """.data(using: .utf8)) + try invalidPageData.write(to: fileURL, options: .atomic) let manager = makeTestingDownloadManager() - let response = makeResponse( - url: URL(string: "https://e-hentai.org/g/1/1/")!, + let galleryURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) + let response = try makeResponse( + url: galleryURL, contentType: "text/html" ) let error = await manager.testingDetectResponseError( fileURL: fileURL, response: response, - requestURL: URL(string: "https://e-hentai.org/g/1/1/") + requestURL: galleryURL ) #expect(error == .notFound) @@ -4942,19 +4965,21 @@ struct DownloadFeatureReducerTests: TestHelper { .appendingPathExtension("html") defer { try? FileManager.default.removeItem(at: fileURL) } - try "

Keep trying

" - .data(using: .utf8)! - .write(to: fileURL, options: .atomic) + let keepTryingData = try #require( + "

Keep trying

".data(using: .utf8) + ) + try keepTryingData.write(to: fileURL, options: .atomic) let manager = makeTestingDownloadManager() - let response = makeResponse( - url: URL(string: "https://e-hentai.org/s/1/1-1")!, + let pageURL = try #require(URL(string: "https://e-hentai.org/s/1/1-1")) + let response = try makeResponse( + url: pageURL, contentType: "text/html" ) let error = await manager.testingDetectResponseError( fileURL: fileURL, response: response, - requestURL: URL(string: "https://e-hentai.org/s/1/1-1") + requestURL: pageURL ) #expect(error == .notFound) @@ -4970,15 +4995,16 @@ struct DownloadFeatureReducerTests: TestHelper { try Data("Not here".utf8).write(to: fileURL, options: .atomic) let manager = makeTestingDownloadManager() - let response = makeResponse( - url: URL(string: "https://e-hentai.org/g/1/1/")!, + let notFoundURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) + let response = try makeResponse( + url: notFoundURL, statusCode: 404, contentType: "text/html" ) let error = await manager.testingDetectResponseError( fileURL: fileURL, response: response, - requestURL: URL(string: "https://e-hentai.org/g/1/1/") + requestURL: notFoundURL ) #expect(error == .notFound) @@ -4991,23 +5017,25 @@ struct DownloadFeatureReducerTests: TestHelper { .appendingPathExtension("html") defer { try? FileManager.default.removeItem(at: fileURL) } - try """ + let galleryNotAvailableData = try #require(""" Gallery Not Available

Gallery Not Available

- """.data(using: .utf8)!.write(to: fileURL, options: .atomic) + """.data(using: .utf8)) + try galleryNotAvailableData.write(to: fileURL, options: .atomic) let manager = makeTestingDownloadManager() - let response = makeResponse( - url: URL(string: "https://e-hentai.org/g/1/1/")!, + let galleryURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) + let response = try makeResponse( + url: galleryURL, statusCode: 404, contentType: "text/html" ) let error = await manager.testingDetectResponseError( fileURL: fileURL, response: response, - requestURL: URL(string: "https://e-hentai.org/g/1/1/") + requestURL: galleryURL ) #expect(error == .notFound) @@ -5019,14 +5047,15 @@ struct DownloadFeatureReducerTests: TestHelper { defer { try? FileManager.default.removeItem(at: fileURL) } let manager = makeTestingDownloadManager() - let response = makeResponse( - url: URL(string: "https://example.com/banned")!, + let bannedURL = try #require(URL(string: "https://example.com/banned")) + let response = try makeResponse( + url: bannedURL, contentType: "text/html; charset=utf-8" ) let error = await manager.testingDetectResponseError( fileURL: fileURL, response: response, - requestURL: URL(string: "https://example.com/banned") + requestURL: bannedURL ) #expect(error != .parseFailed) @@ -5052,15 +5081,16 @@ struct DownloadFeatureReducerTests: TestHelper { ) let recorder = RequestRecorder() let ipBannedHTML = try fixtureData(resource: HTMLFilename.ipBanned.rawValue, pathExtension: "html") + let fallbackBannedURL = try #require(URL(string: "https://example.com/banned")) SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in recorder.recordDetail() return ( - HTTPURLResponse( - url: request.url ?? URL(string: "https://example.com/banned")!, + try #require(HTTPURLResponse( + url: request.url ?? fallbackBannedURL, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "text/html; charset=utf-8"] - )!, + )), ipBannedHTML ) } @@ -5100,7 +5130,7 @@ struct DownloadFeatureReducerTests: TestHelper { pageCount: 2, completedPageCount: 2 ) - let manifest = sampleManifest(gid: download.gid, title: download.title) + let manifest = try sampleManifest(gid: download.gid, title: download.title) let store = TestStore( initialState: ReadingReducer.State(contentSource: .local(download, manifest)) ) { @@ -5483,7 +5513,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test func testDownloadManagerBatchesObserverUpdatesDuringCachedPageRestore() async throws { - let container = makeInMemoryContainer() + let container = try makeInMemoryContainer() let pageCount = 20 let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 104) @@ -5510,8 +5540,8 @@ struct DownloadFeatureReducerTests: TestHelper { context.fill(.init(x: 0, y: 0, width: 1, height: 1)) } let imageData = try #require(cachedImage.jpegData(compressionQuality: 1)) - let imageURLs = Dictionary(uniqueKeysWithValues: (1...pageCount).map { index in - (index, URL(string: "https://example.com/pages/\(gid)-\(index).jpg")!) + let imageURLs = try Dictionary(uniqueKeysWithValues: (1...pageCount).map { index in + (index, try #require(URL(string: "https://example.com/pages/\(gid)-\(index).jpg"))) }) try insertPersistedGalleryState(in: container, gid: gid, imageURLs: imageURLs) let cacheKeys = Set(imageURLs.values.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) @@ -5548,7 +5578,7 @@ struct DownloadFeatureReducerTests: TestHelper { pageCount: pageCount, postedDate: .now, coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token")! + galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")) ), galleryDetail: GalleryDetail( gid: gid, @@ -5654,9 +5684,9 @@ private extension DownloadFeatureReducerTests { await store.skipReceivedActions(strict: false) } - func sampleGalleryState(gid: String) -> GalleryState { + func sampleGalleryState(gid: String) throws -> GalleryState { var galleryState = GalleryState(gid: gid) - galleryState.previewURLs = [1: URL(string: "https://example.com/1t.jpg")!] + galleryState.previewURLs = [1: try #require(URL(string: "https://example.com/1t.jpg"))] galleryState.previewConfig = .normal(rows: 4) return galleryState } @@ -5689,18 +5719,18 @@ private extension DownloadFeatureReducerTests { contentType: String, contentLength: Int? = nil, headers: [String: String] = [:] - ) -> HTTPURLResponse { + ) throws -> HTTPURLResponse { var headerFields = headers headerFields["Content-Type"] = contentType if let contentLength { headerFields["Content-Length"] = "\(contentLength)" } - return HTTPURLResponse( + return try #require(HTTPURLResponse( url: url, statusCode: statusCode, httpVersion: nil, headerFields: headerFields - )! + )) } func writeFixtureToTemporaryFile(filename: HTMLFilename) throws -> URL { @@ -5739,12 +5769,12 @@ private extension DownloadFeatureReducerTests { ] let responseData = try JSONSerialization.data(withJSONObject: payload, options: []) SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in - let response = HTTPURLResponse( + let response = try #require(HTTPURLResponse( url: request.url ?? Defaults.URL.api, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] - )! + )) return (response, responseData) } URLProtocol.registerClass(SharedSessionStubURLProtocol.self) @@ -5798,7 +5828,7 @@ private extension DownloadFeatureReducerTests { title: String, pageCount: Int = 2, versionSignature: String = "hash:v1" - ) -> DownloadManifest { + ) throws -> DownloadManifest { DownloadManifest( gid: gid, host: .ehentai, @@ -5812,7 +5842,7 @@ private extension DownloadFeatureReducerTests { postedDate: .now, pageCount: pageCount, coverRelativePath: "cover.jpg", - galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token")!, + galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), versionSignature: versionSignature, @@ -5916,10 +5946,12 @@ private extension DownloadFeatureReducerTests { return folderURL } - func makeInMemoryContainer() -> NSPersistentContainer { - let modelURL = Bundle(for: TestBundleLocator.self).url(forResource: "Model", withExtension: "momd") - ?? Bundle.main.url(forResource: "Model", withExtension: "momd")! - let model = NSManagedObjectModel(contentsOf: modelURL)! + func makeInMemoryContainer() throws -> NSPersistentContainer { + let modelURL = try #require( + Bundle(for: TestBundleLocator.self).url(forResource: "Model", withExtension: "momd") + ?? Bundle.main.url(forResource: "Model", withExtension: "momd") + ) + let model = try #require(NSManagedObjectModel(contentsOf: modelURL)) let container = NSPersistentContainer(name: UUID().uuidString, managedObjectModel: model) let description = NSPersistentStoreDescription() description.type = NSInMemoryStoreType diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 816857991..c9e941094 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -22,7 +22,7 @@ struct DownloadFileStorageTests { withIntermediateDirectories: true ) - let manifest = sampleManifest(pageCount: 2) + let manifest = try sampleManifest(pageCount: 2) try storage.writeManifest(manifest, folderURL: folderURL) try Data([0xFF, 0xD8, 0xFF]).write( to: folderURL.appendingPathComponent("cover.jpg"), @@ -304,7 +304,7 @@ struct DownloadFileStorageTests { at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - let manifest = sampleManifest(pageCount: 3) + let manifest = try sampleManifest(pageCount: 3) try storage.writeManifest(manifest, folderURL: sourceFolderURL) try Data([0xFF, 0xD8, 0xFF]).write( to: sourceFolderURL.appendingPathComponent("cover.jpg"), @@ -447,8 +447,8 @@ private extension DownloadFileStorageTests { ) } - func sampleManifest(pageCount: Int) -> DownloadManifest { - DownloadManifest( + func sampleManifest(pageCount: Int) throws -> DownloadManifest { + try DownloadManifest( gid: "123", host: .ehentai, token: "token", @@ -461,7 +461,7 @@ private extension DownloadFileStorageTests { postedDate: .now, pageCount: pageCount, coverRelativePath: "cover.jpg", - galleryURL: URL(string: "https://e-hentai.org/g/123/token")!, + galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), versionSignature: "hash:v1", diff --git a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift index 3ff83a109..c4d08b00e 100644 --- a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift @@ -9,13 +9,13 @@ import Foundation struct DownloadSignatureBuilderTests { @Test - func testVersionIdentifierPrefersGalleryChainMetadata() { + func testVersionIdentifierPrefersGalleryChainMetadata() throws { let signature = DownloadSignatureBuilder.make( gallery: sampleGallery, detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")! + 1: try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")) ], versionMetadata: .init( gid: "1394965", @@ -129,14 +129,14 @@ struct DownloadSignatureBuilderTests { } @Test - func testSignatureIgnoresPreviewHostRotationAndLayoutChanges() { + func testSignatureIgnoresPreviewHostRotationAndLayoutChanges() throws { let firstSignature = DownloadSignatureBuilder.make( gallery: sampleGallery, detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")!, - 2: URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200")! + 1: try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")), + 2: try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200")) ] ) @@ -145,8 +145,8 @@ struct DownloadSignatureBuilderTests { detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: URL(string: "https://beta.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0")!, - 2: URL(string: "https://beta.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=250")! + 1: try #require(URL(string: "https://beta.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0")), + 2: try #require(URL(string: "https://beta.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=250")) ] ) @@ -154,13 +154,13 @@ struct DownloadSignatureBuilderTests { } @Test - func testSignatureChangesWhenCombinedPreviewAtlasChanges() { + func testSignatureChangesWhenCombinedPreviewAtlasChanges() throws { let firstSignature = DownloadSignatureBuilder.make( gallery: sampleGallery, detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")! + 1: try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")) ] ) @@ -169,7 +169,7 @@ struct DownloadSignatureBuilderTests { detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: URL(string: "https://alpha.hath.network/c2/token-a/1394965-1.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")! + 1: try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-1.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")) ] ) @@ -177,13 +177,13 @@ struct DownloadSignatureBuilderTests { } @Test - func testSignatureIgnoresCombinedPreviewTokenRotation() { + func testSignatureIgnoresCombinedPreviewTokenRotation() throws { let firstSignature = DownloadSignatureBuilder.make( gallery: sampleGallery, detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")! + 1: try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")) ] ) @@ -192,7 +192,7 @@ struct DownloadSignatureBuilderTests { detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: URL(string: "https://beta.hath.network/c2/token-b/1394965-0.webp?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0")! + 1: try #require(URL(string: "https://beta.hath.network/c2/token-b/1394965-0.webp?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0")) ] ) @@ -200,14 +200,14 @@ struct DownloadSignatureBuilderTests { } @Test - func testSignatureIgnoresHostRotationForStandalonePreviewURLs() { + func testSignatureIgnoresHostRotationForStandalonePreviewURLs() throws { let firstSignature = DownloadSignatureBuilder.make( gallery: sampleGallery, detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")!, - 2: URL(string: "https://alpha.ehgt.org/t/56/78/preview-2.webp")! + 1: try #require(URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")), + 2: try #require(URL(string: "https://alpha.ehgt.org/t/56/78/preview-2.webp")) ] ) @@ -216,8 +216,8 @@ struct DownloadSignatureBuilderTests { detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: URL(string: "https://beta.ehgt.org/t/12/34/preview-1.webp")!, - 2: URL(string: "https://beta.ehgt.org/t/56/78/preview-2.webp")! + 1: try #require(URL(string: "https://beta.ehgt.org/t/12/34/preview-1.webp")), + 2: try #require(URL(string: "https://beta.ehgt.org/t/56/78/preview-2.webp")) ] ) @@ -244,13 +244,13 @@ struct DownloadSignatureBuilderTests { } @Test - func testSignatureIgnoresGalleryHostTransitions() { + func testSignatureIgnoresGalleryHostTransitions() throws { let ehSignature = DownloadSignatureBuilder.make( gallery: sampleGallery, detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")! + 1: try #require(URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")) ] ) @@ -259,7 +259,7 @@ struct DownloadSignatureBuilderTests { detail: sampleDetail, host: .exhentai, previewURLs: [ - 1: URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")! + 1: try #require(URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")) ] ) diff --git a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift index d72a43e4a..e27caa978 100644 --- a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -10,12 +10,12 @@ import Testing struct SettingDownloadTests { @Test func testLegacySettingDecodesDownloadDefaults() throws { - let data = """ + let data = try #require(""" { "galleryHost": "E-Hentai", "showsNewDawnGreeting": true } - """.data(using: .utf8)! + """.data(using: .utf8)) let setting = try JSONDecoder().decode(Setting.self, from: data) @@ -42,14 +42,14 @@ struct SettingDownloadTests { @Test func testLegacyDownloadOptionsSnapshotDecodesWithoutOriginalImageField() throws { - let data = """ + let data = try #require(""" { "threadMode": "triple", "useOriginalImages": true, "allowCellular": false, "autoRetryFailedPages": false } - """.data(using: .utf8)! + """.data(using: .utf8)) let snapshot = try JSONDecoder().decode(DownloadOptionsSnapshot.self, from: data) @@ -63,8 +63,8 @@ struct SettingDownloadTests { } @Test - func testImageCacheKeysPreferStablePathAlias() { - let url = URL(string: "https://alpha.hath.network/h/123/456/image.webp?download=1")! + func testImageCacheKeysPreferStablePathAlias() throws { + let url = try #require(URL(string: "https://alpha.hath.network/h/123/456/image.webp?download=1")) #expect( url.imageCacheKeys(includeStableAlias: true) == [ @@ -75,17 +75,17 @@ struct SettingDownloadTests { } @Test - func testStableImageCacheKeyIgnoresHostRotationAndQuery() { - let firstURL = URL(string: "https://alpha.hath.network/h/123/456/image.webp?download=1")! - let secondURL = URL(string: "https://beta.hath.network/h/123/456/image.webp?source=viewer")! + func testStableImageCacheKeyIgnoresHostRotationAndQuery() throws { + let firstURL = try #require(URL(string: "https://alpha.hath.network/h/123/456/image.webp?download=1")) + let secondURL = try #require(URL(string: "https://beta.hath.network/h/123/456/image.webp?source=viewer")) #expect(firstURL.stableImageCacheKey == secondURL.stableImageCacheKey) } @Test - func testStableImageCacheKeyKeepsIdentityQueryForFullImageScript() { - let firstURL = URL(string: "https://e-hentai.org/fullimg.php?gid=42&page=7&key=alpha")! - let secondURL = URL(string: "https://exhentai.org/fullimg.php?page=7&gid=42&key=beta")! + func testStableImageCacheKeyKeepsIdentityQueryForFullImageScript() throws { + let firstURL = try #require(URL(string: "https://e-hentai.org/fullimg.php?gid=42&page=7&key=alpha")) + let secondURL = try #require(URL(string: "https://exhentai.org/fullimg.php?page=7&gid=42&key=beta")) #expect(firstURL.stableImageCacheKey == "download::fullimg.php?gid=42&key=alpha&page=7") #expect(secondURL.stableImageCacheKey == "download::fullimg.php?gid=42&key=beta&page=7") @@ -93,8 +93,8 @@ struct SettingDownloadTests { } @Test - func testCombinedPreviewURLCleanupIncludesPlainPreviewURL() { - let plainURL = URL(string: "https://ehgt.org/ab/cd/preview.webp")! + func testCombinedPreviewURLCleanupIncludesPlainPreviewURL() throws { + let plainURL = try #require(URL(string: "https://ehgt.org/ab/cd/preview.webp")) let combinedURL = URLUtil.combinedPreviewURL( plainURL: plainURL, width: "200", From 9686832b1e0adb5e3be10ffe6e62d29f2390d99a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 28 Mar 2026 13:49:20 +0800 Subject: [PATCH 008/614] Fill test coverage gaps --- .../DownloadFeatureReducerTests.swift | 8 +- .../Download/DownloadFileStorageTests.swift | 83 +++++++++++++++++++ .../DownloadSignatureBuilderTests.swift | 21 +++++ .../Parser/Other/SettingDownloadTests.swift | 7 ++ 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift index 938c746a4..2290b858b 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift @@ -708,7 +708,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test - func testLoadUserSettingsDefersExLaunchAutomationUntilIgneousArrives() async { + func testLoadUserSettingsDefersExLaunchAutomationUntilIgneousArrives() async throws { let cookieClient = CookieClient.live cookieClient.clearAll() cookieClient.importAutomationCookies( @@ -3430,7 +3430,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test - func testDetailReducerOpenReadingUsesLocalManifestWhenAvailable() async { + func testDetailReducerOpenReadingUsesLocalManifestWhenAvailable() async throws { let download = sampleDownload( gid: "888", title: "Offline Archive", @@ -3533,7 +3533,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test - func testPreviewsReducerOpenReadingUsesLocalManifestWhenAvailable() async { + func testPreviewsReducerOpenReadingUsesLocalManifestWhenAvailable() async throws { let download = sampleDownload( gid: "991", title: "Preview Download", @@ -5122,7 +5122,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test - func testReadingReducerLocalSourceWithoutGalleryStateDoesNotStayLoading() async { + func testReadingReducerLocalSourceWithoutGalleryStateDoesNotStayLoading() async throws { let download = sampleDownload( gid: "700001", title: "Offline Gallery", diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index c9e941094..8c9bda98a 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -365,6 +365,89 @@ struct DownloadFileStorageTests { ) } + @Test + func testMaterializeRepairSeedRejectsTraversalPathsInManifestPages() throws { + let sourceRootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let destRootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { + try? FileManager.default.removeItem(at: sourceRootURL) + try? FileManager.default.removeItem(at: destRootURL) + } + + let sourceStorage = DownloadFileStorage(rootURL: sourceRootURL, fileManager: .default) + let destStorage = DownloadFileStorage(rootURL: destRootURL, fileManager: .default) + try sourceStorage.ensureRootDirectory() + try destStorage.ensureRootDirectory() + + let sourceFolderURL = sourceStorage.folderURL(relativePath: "123 - Source") + let tempFolderURL = destStorage.temporaryFolderURL(gid: "123") + try FileManager.default.createDirectory( + at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + + let manifest = try DownloadManifest( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: .now, + pageCount: 2, + coverRelativePath: "cover.jpg", + galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), + rating: 4, + downloadOptions: DownloadOptionsSnapshot(), + versionSignature: "hash:v1", + downloadedAt: .now, + pages: [ + .init(index: 1, relativePath: "pages/0001.jpg"), + .init(index: 2, relativePath: "../escape.jpg") + ] + ) + try sourceStorage.writeManifest(manifest, folderURL: sourceFolderURL) + try Data([0xFF, 0xD8, 0xFF]).write( + to: sourceFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([0x01]).write( + to: sourceFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + let escapeURL = sourceFolderURL.deletingLastPathComponent() + .appendingPathComponent("escape.jpg") + try Data([0x99]).write(to: escapeURL, options: .atomic) + + try destStorage.materializeRepairSeed( + from: sourceFolderURL, + manifest: manifest, + to: tempFolderURL + ) + + #expect( + FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent("pages/0001.jpg").path + ) + ) + #expect( + FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent("../escape.jpg") + .standardizedFileURL.path + ) == false + ) + #expect( + FileManager.default.fileExists( + atPath: destRootURL.appendingPathComponent("escape.jpg").path + ) == false + ) + } + @Test func testLinkOrCopyReadableAssetFallsBackToCopyWhenHardLinkFails() throws { let rootURL = FileManager.default.temporaryDirectory diff --git a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift index c4d08b00e..a48442409 100644 --- a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift @@ -265,6 +265,27 @@ struct DownloadSignatureBuilderTests { #expect(ehSignature == exSignature) } + @Test + func testSignatureIsOrderIndependentForSamePreviewURLSet() throws { + let urlA = try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")) + let urlB = try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200")) + + let ascendingSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [1: urlA, 2: urlB] + ) + + let descendingSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [2: urlB, 1: urlA] + ) + + #expect(ascendingSignature == descendingSignature) + } } private extension DownloadSignatureBuilderTests { diff --git a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift index e27caa978..61eb529a2 100644 --- a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -92,6 +92,13 @@ struct SettingDownloadTests { #expect(firstURL.stableImageCacheKey != secondURL.stableImageCacheKey) } + @Test + func testStableImageCacheKeyFallbackRetainsNonIgnoredNonPreferredQueries() throws { + let url = try #require(URL(string: "https://example.com/h/123/image.webp?custom=abc&dl=1")) + + #expect(url.stableImageCacheKey == "download::h/123/image.webp?custom=abc") + } + @Test func testCombinedPreviewURLCleanupIncludesPlainPreviewURL() throws { let plainURL = try #require(URL(string: "https://ehgt.org/ab/cd/preview.webp")) From 8631202528bf71f62d6181f2d153432de1b74c89 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 28 Mar 2026 14:09:04 +0800 Subject: [PATCH 009/614] Update packages --- EhPanda.xcodeproj/project.pbxproj | 128 ++++++++---------- .../xcshareddata/swiftpm/Package.resolved | 40 +++--- 2 files changed, 75 insertions(+), 93 deletions(-) diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 2ceca52aa..fe1244f02 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 100; objects = { /* Begin PBXBuildFile section */ @@ -324,14 +324,12 @@ /* Begin PBXCopyFilesBuildPhase section */ AB5BE68126B95FDD007D4A55 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; dstPath = ""; - dstSubfolderSpec = 13; + dstSubfolder = PlugIns; files = ( AB5BE68026B95FDD007D4A55 /* ShareExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; - runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ @@ -653,14 +651,11 @@ /* Begin PBXFrameworksBuildPhase section */ AB5BE67326B95FDD007D4A55 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; files = ( ); - runOnlyForDeploymentPostprocessing = 0; }; ABC3C7512593696C00E0C11B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; files = ( AB2EB99F280251D600011A8A /* TTProgressHUD in Frameworks */, AB2EB9A52802521700011A8A /* DeprecatedAPI in Frameworks */, @@ -679,14 +674,11 @@ AB1FA94927C62BC80063EF55 /* CommonMark in Frameworks */, AB17573D27675B1E00FD64E2 /* Colorful in Frameworks */, ); - runOnlyForDeploymentPostprocessing = 0; }; ABF294C926D20F82004DD03A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; files = ( ); - runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ @@ -1625,8 +1617,6 @@ ); buildRules = ( ); - dependencies = ( - ); name = ShareExtension; productName = ShareExtension; productReference = AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */; @@ -1712,7 +1702,6 @@ }; }; buildConfigurationList = ABC3C74F2593696C00E0C11B /* Build configuration list for PBXProject "EhPanda" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -1745,6 +1734,7 @@ AB2EB9A32802521700011A8A /* XCRemoteSwiftPackageReference "DeprecatedAPI" */, EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */, ); + preferredProjectObjectVersion = 56; productRefGroup = ABC3C7552593696C00E0C11B /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1759,14 +1749,11 @@ /* Begin PBXResourcesBuildPhase section */ AB5BE67426B95FDD007D4A55 /* Resources */ = { isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; files = ( ); - runOnlyForDeploymentPostprocessing = 0; }; ABC3C7522593696C00E0C11B /* Resources */ = { isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; files = ( AB0CFB8C27BBD2D7004BD372 /* AppIcon_Developer@3x.png in Resources */, AB0CFB9627BBD323004BD372 /* AppIcon_Ukiyoe@2x.png in Resources */, @@ -1808,11 +1795,9 @@ ABE9012627F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad@2x.png in Resources */, AB0CFB9427BBD323004BD372 /* AppIcon_Ukiyoe_iPad_Pro@2x.png in Resources */, ); - runOnlyForDeploymentPostprocessing = 0; }; ABF294CA26D20F82004DD03A /* Resources */ = { isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; files = ( AB41DB3F27B760D700DD3604 /* FrontPageExtendedList.html in Resources */, AB41DB4C27B760D700DD3604 /* FavoritesCompactList.html in Resources */, @@ -1845,7 +1830,6 @@ AB41DB4A27B760D700DD3604 /* WatchedThumbnailList.html in Resources */, AB31CD3F27B670FD00F40E0A /* GalleryNormalImageURL.html in Resources */, ); - runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ @@ -1853,56 +1837,57 @@ AB2E936227A24E0A00EA99F1 /* SwiftGen */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); name = SwiftGen; - outputFileListPaths = ( - ); outputPaths = ( $SRCROOT/EhPanda/App/Generated/Strings.swift, ); - runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if test -d \"/opt/homebrew/bin/\"; then\n PATH=\"/opt/homebrew/bin/:${PATH}\"\nfi\n\nexport PATH\n\nif which swiftgen >/dev/null; then\n swiftgen\nelse\n echo \"warning: SwiftGen not installed, download from https://github.com/SwiftGen/SwiftGen\"\nfi\n"; + shellScript = ( + "if test -d \"/opt/homebrew/bin/\"; then", + " PATH=\"/opt/homebrew/bin/:${PATH}\"", + "fi", + "", + "export PATH", + "", + "if which swiftgen >/dev/null; then", + " swiftgen", + "else", + " echo \"warning: SwiftGen not installed, download from https://github.com/SwiftGen/SwiftGen\"", + "fi", + "", + ); }; AB69FE41263C328400716FBD /* SwiftLint */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); name = SwiftLint; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if test -d \"/opt/homebrew/bin/\"; then\n PATH=\"/opt/homebrew/bin/:${PATH}\"\nfi\n\nexport PATH\n\nif which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = ( + "if test -d \"/opt/homebrew/bin/\"; then", + " PATH=\"/opt/homebrew/bin/:${PATH}\"", + "fi", + "", + "export PATH", + "", + "if which swiftlint >/dev/null; then", + " swiftlint", + "else", + " echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"", + "fi", + "", + ); }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ AB5BE67226B95FDD007D4A55 /* Sources */ = { isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; files = ( AB5BE67926B95FDD007D4A55 /* ShareViewController.swift in Sources */, ); - runOnlyForDeploymentPostprocessing = 0; }; ABC3C7502593696C00E0C11B /* Sources */ = { isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; files = ( ABA732D925A8018A00B3D9AB /* Extensions.swift in Sources */, AB0929D02781E1CC00F107CA /* UIApplicationClient.swift in Sources */, @@ -2097,11 +2082,9 @@ ABBB266A2797C61F007B6149 /* TorrentsReducer.swift in Sources */, ABF75F3F25A19CD200544D29 /* User.swift in Sources */, ); - runOnlyForDeploymentPostprocessing = 0; }; ABF294C826D20F82004DD03A /* Sources */ = { isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; files = ( EAB100012F1E000100000003 /* DownloadFileStorageTests.swift in Sources */, EAB100012F1E000100000004 /* DownloadFeatureReducerTests.swift in Sources */, @@ -2120,7 +2103,6 @@ ABD9770E27B65A7300983DE7 /* ListParserTests.swift in Sources */, EAB100012F1E000100000002 /* SettingDownloadTests.swift in Sources */, ); - runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ @@ -2179,7 +2161,7 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ - AB5BE68226B95FDD007D4A55 /* Debug */ = { + AB5BE68226B95FDD007D4A55 /* Debug configuration for PBXNativeTarget "ShareExtension" */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -2207,7 +2189,7 @@ }; name = Debug; }; - AB5BE68326B95FDD007D4A55 /* Release */ = { + AB5BE68326B95FDD007D4A55 /* Release configuration for PBXNativeTarget "ShareExtension" */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -2235,7 +2217,7 @@ }; name = Release; }; - ABC3C7612593696E00E0C11B /* Debug */ = { + ABC3C7612593696E00E0C11B /* Debug configuration for PBXProject "EhPanda" */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -2298,7 +2280,7 @@ }; name = Debug; }; - ABC3C7622593696E00E0C11B /* Release */ = { + ABC3C7622593696E00E0C11B /* Release configuration for PBXProject "EhPanda" */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -2355,7 +2337,7 @@ }; name = Release; }; - ABC3C7642593696E00E0C11B /* Debug */ = { + ABC3C7642593696E00E0C11B /* Debug configuration for PBXNativeTarget "EhPanda" */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; @@ -2384,7 +2366,7 @@ }; name = Debug; }; - ABC3C7652593696E00E0C11B /* Release */ = { + ABC3C7652593696E00E0C11B /* Release configuration for PBXNativeTarget "EhPanda" */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; @@ -2413,7 +2395,7 @@ }; name = Release; }; - ABF294D226D20F82004DD03A /* Debug */ = { + ABF294D226D20F82004DD03A /* Debug configuration for PBXNativeTarget "EhPandaTests" */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; @@ -2440,7 +2422,7 @@ }; name = Debug; }; - ABF294D326D20F82004DD03A /* Release */ = { + ABF294D326D20F82004DD03A /* Release configuration for PBXNativeTarget "EhPandaTests" */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; @@ -2473,37 +2455,33 @@ AB5BE68426B95FDD007D4A55 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( - AB5BE68226B95FDD007D4A55 /* Debug */, - AB5BE68326B95FDD007D4A55 /* Release */, + AB5BE68226B95FDD007D4A55 /* Debug configuration for PBXNativeTarget "ShareExtension" */, + AB5BE68326B95FDD007D4A55 /* Release configuration for PBXNativeTarget "ShareExtension" */, ); - defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; ABC3C74F2593696C00E0C11B /* Build configuration list for PBXProject "EhPanda" */ = { isa = XCConfigurationList; buildConfigurations = ( - ABC3C7612593696E00E0C11B /* Debug */, - ABC3C7622593696E00E0C11B /* Release */, + ABC3C7612593696E00E0C11B /* Debug configuration for PBXProject "EhPanda" */, + ABC3C7622593696E00E0C11B /* Release configuration for PBXProject "EhPanda" */, ); - defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; ABC3C7632593696E00E0C11B /* Build configuration list for PBXNativeTarget "EhPanda" */ = { isa = XCConfigurationList; buildConfigurations = ( - ABC3C7642593696E00E0C11B /* Debug */, - ABC3C7652593696E00E0C11B /* Release */, + ABC3C7642593696E00E0C11B /* Debug configuration for PBXNativeTarget "EhPanda" */, + ABC3C7652593696E00E0C11B /* Release configuration for PBXNativeTarget "EhPanda" */, ); - defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; ABF294D426D20F82004DD03A /* Build configuration list for PBXNativeTarget "EhPandaTests" */ = { isa = XCConfigurationList; buildConfigurations = ( - ABF294D226D20F82004DD03A /* Debug */, - ABF294D326D20F82004DD03A /* Release */, + ABF294D226D20F82004DD03A /* Debug configuration for PBXNativeTarget "EhPandaTests" */, + ABF294D326D20F82004DD03A /* Release configuration for PBXNativeTarget "EhPandaTests" */, ); - defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ @@ -2586,8 +2564,12 @@ repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.17.0; + minimumVersion = 1.25.0; }; + traits = ( + ComposableArchitecture2DeprecationOverloads, + ComposableArchitecture2Deprecations, + ); }; ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */ = { isa = XCRemoteSwiftPackageReference; @@ -2610,7 +2592,7 @@ repositoryURL = "https://github.com/onevcat/Kingfisher"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.0.0; + minimumVersion = 8.0.0; }; }; ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { @@ -2618,7 +2600,7 @@ repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.0.0; + minimumVersion = 7.0.0; }; }; ABD7005726B1C31500DC59C9 /* XCRemoteSwiftPackageReference "Kanna" */ = { @@ -2626,7 +2608,7 @@ repositoryURL = "https://github.com/tid-kijyun/Kanna"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 5.0.0; + minimumVersion = 6.0.0; }; }; EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */ = { diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f840f4719..b65d07608 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "5928286acce13def418ec36d05a001a9641086f2", - "version" : "1.0.3" + "revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6", + "version" : "1.1.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tid-kijyun/Kanna", "state" : { - "revision" : "41c3d28ea0eac07e4551b28def9de1ede702e739", - "version" : "5.3.0" + "revision" : "3c73af6d3859d9240db60aef233941a715387744", + "version" : "6.1.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "2ef543ee21d63734e1c004ad6c870255e8716c50", - "version" : "7.12.0" + "revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008", + "version" : "8.8.0" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", "state" : { - "revision" : "7cca2d60925876b5953a2cf7341cd80fbeac983c", - "version" : "4.1.1" + "revision" : "e01b3d4f861412f8dcee8d93c417d2c2b0cdfd77", + "version" : "7.0.0" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "df934d9c5a274a6f6a7bdcec73fbcb330149ff8b", - "version" : "1.23.2" + "revision" : "d5d2e0258fa2e80df761c2b73353422d42f4b98e", + "version" : "1.25.3" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", - "version" : "1.3.3" + "revision" : "06c57924455064182d6b217f06ebc05d00cb2990", + "version" : "1.5.0" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", - "version" : "1.10.0" + "revision" : "706feb7858a7f6c242879d137b8ee30926aa5b26", + "version" : "1.12.0" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "bf498690e1f6b4af790260f542e8428a4ba10d78", - "version" : "2.6.0" + "revision" : "e7441dc4dfec6a4ae929e614e3c1e67c6639d164", + "version" : "2.7.0" } }, { @@ -168,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818", - "version" : "2.7.4" + "revision" : "bc27f8322bc30f6ce7d864d137dc77a6de8b57eb", + "version" : "2.8.0" } }, { From ecb8ef1e0f83a95fd0472c4ba8c7792246dcb108 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 28 Mar 2026 14:25:22 +0800 Subject: [PATCH 010/614] Resolve compiler warnings --- .../Tools/Extensions/Reducer_Extension.swift | 6 +- EhPanda/DataFlow/AppReducer.swift | 30 ++- EhPanda/DataFlow/AppRouteReducer.swift | 12 +- EhPanda/Models/Support/AppError.swift | 2 +- .../Detail/Comments/CommentsReducer.swift | 4 +- .../View/Detail/Components/LinkedText.swift | 14 +- EhPanda/View/Detail/DetailReducer.swift | 12 +- .../DetailSearch/DetailSearchReducer.swift | 14 +- EhPanda/View/Detail/DetailView.swift | 4 +- .../Detail/Previews/PreviewsReducer.swift | 4 +- .../View/Detail/Torrents/TorrentsView.swift | 4 +- EhPanda/View/Downloads/DownloadsReducer.swift | 14 +- EhPanda/View/Favorites/FavoritesReducer.swift | 4 +- .../Home/Frontpage/FrontpageReducer.swift | 4 +- .../View/Home/History/HistoryReducer.swift | 4 +- EhPanda/View/Home/HomeReducer.swift | 20 +- EhPanda/View/Home/HomeView.swift | 2 +- .../View/Home/Popular/PopularReducer.swift | 4 +- .../View/Home/Toplists/ToplistsReducer.swift | 14 +- .../View/Home/Watched/WatchedReducer.swift | 4 +- EhPanda/View/Reading/ReadingReducer.swift | 2 +- EhPanda/View/Reading/ReadingView.swift | 2 +- .../View/Reading/Support/ControlPanel.swift | 2 +- EhPanda/View/Search/SearchReducer.swift | 14 +- EhPanda/View/Search/SearchRootReducer.swift | 16 +- .../Search/Support/QuickSearchReducer.swift | 4 +- .../AccountSettingReducer.swift | 12 +- .../Setting/EhSetting/EhSettingView.swift | 198 ++++++++++++------ .../GeneralSettingReducer.swift | 4 +- EhPanda/View/Setting/SettingReducer.swift | 136 +++++------- .../View/Support/Components/AlertView.swift | 2 +- .../Components/DownloadBadgeStore.swift | 2 +- .../Components/TagSuggestionView.swift | 2 +- EhPanda/View/Support/FiltersReducer.swift | 24 +-- 34 files changed, 309 insertions(+), 287 deletions(-) diff --git a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift index c1ea651a2..b33594da7 100644 --- a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift +++ b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift @@ -25,7 +25,7 @@ extension Reducer { ) -> some Reducer { Reduce { state, action in let previousCase = Binding.constant(`enum`(state)).case(caseKeyPath).wrappedValue - let effects = reduce(into: &state, action: action) + let effects = _reduce(into: &state, action: action) let currentCase = Binding.constant(`enum`(state)).case(caseKeyPath).wrappedValue return previousCase == nil && currentCase != nil @@ -47,7 +47,7 @@ where State == Base.State, Action == Base.Action { public var body: some Reducer { var `self`: Reduce! self = Reduce { state, action in - base(self).reduce(into: &state, action: action) + base(self)._reduce(into: &state, action: action) } return self } @@ -66,7 +66,7 @@ where State == Base.State, Action == Base.Action { var body: some Reducer { Reduce { state, action in Logger.info(action) - return base.reduce(into: &state, action: action) + return base._reduce(into: &state, action: action) } } } diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index ff6a57497..aa9dbe121 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -49,11 +49,11 @@ struct AppReducer { var body: some Reducer { LoggingReducer { BindingReducer() - .onChange(of: \.appRouteState.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.appRoute(.clearSubStates)) : .none }) + .onChange(of: \.appRouteState.route) { _, state in + state.appRouteState.route == nil ? .send(.appRoute(.clearSubStates)) : .none } .onChange(of: \.settingState.setting) { _, _ in - Reduce({ _, _ in .send(.setting(.syncSetting)) }) + .send(.setting(.syncSetting)) } Reduce { state, action in @@ -94,19 +94,17 @@ struct AppReducer { } case .appDelegate(.migration(.onDatabasePreparationSuccess)): - return .concatenate( - .run { _ in - if let loginCookies = AppLaunchAutomation.current?.loginCookies { - cookieClient.importAutomationCookies( - memberID: loginCookies.memberID, - passHash: loginCookies.passHash, - igneous: loginCookies.igneous - ) - } - }, - .send(.appDelegate(.removeExpiredImageURLs)), - .send(.setting(.loadUserSettings)) - ) + return .run { send in + if let loginCookies = AppLaunchAutomation.current?.loginCookies { + cookieClient.importAutomationCookies( + memberID: loginCookies.memberID, + passHash: loginCookies.passHash, + igneous: loginCookies.igneous + ) + } + await send(.appDelegate(.removeExpiredImageURLs)) + await send(.setting(.loadUserSettings)) + } case .appDelegate: return .none diff --git a/EhPanda/DataFlow/AppRouteReducer.swift b/EhPanda/DataFlow/AppRouteReducer.swift index 8d6b9ce92..61c908956 100644 --- a/EhPanda/DataFlow/AppRouteReducer.swift +++ b/EhPanda/DataFlow/AppRouteReducer.swift @@ -56,8 +56,8 @@ struct AppRouteReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } Reduce { state, action in @@ -157,10 +157,10 @@ struct AppRouteReducer { state.route = nil switch result { case .success(let gallery): - return .concatenate( - .run(operation: { _ in await databaseClient.cacheGalleries([gallery]) }), - .send(.handleGalleryLink(url)) - ) + return .run { send in + await databaseClient.cacheGalleries([gallery]) + await send(.handleGalleryLink(url)) + } case .failure: return .run { send in try await Task.sleep(for: .milliseconds(500)) diff --git a/EhPanda/Models/Support/AppError.swift b/EhPanda/Models/Support/AppError.swift index 94b738cd3..fa5298f48 100644 --- a/EhPanda/Models/Support/AppError.swift +++ b/EhPanda/Models/Support/AppError.swift @@ -78,7 +78,7 @@ extension AppError { case .parseFailed: return .rectangleAndTextMagnifyingglass case .quotaExceeded: - return .speedometer + return .gaugeWithDotsNeedle67percent case .authenticationRequired: return .lockCircleFill case .fileOperationFailed: diff --git a/EhPanda/View/Detail/Comments/CommentsReducer.swift b/EhPanda/View/Detail/Comments/CommentsReducer.swift index 432d412c0..f7f4ea27d 100644 --- a/EhPanda/View/Detail/Comments/CommentsReducer.swift +++ b/EhPanda/View/Detail/Comments/CommentsReducer.swift @@ -73,8 +73,8 @@ struct CommentsReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } Reduce { state, action in diff --git a/EhPanda/View/Detail/Components/LinkedText.swift b/EhPanda/View/Detail/Components/LinkedText.swift index 9e92ae5d9..5250e0734 100644 --- a/EhPanda/View/Detail/Components/LinkedText.swift +++ b/EhPanda/View/Detail/Components/LinkedText.swift @@ -42,15 +42,19 @@ private struct LinkColoredText: View { self.components = components } - var body: some View { - components.map { component in + var body: Text { + var result = AttributedString() + for component in components { switch component { case .text(let text): - return Text(verbatim: text) + result.append(AttributedString(text)) case .link(let text, _): - return Text(verbatim: text).foregroundColor(.accentColor) + var link = AttributedString(text) + link.foregroundColor = .accentColor + result.append(link) } - }.reduce(Text(""), +) + } + return Text(result) } } diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 13c62e163..faccbc090 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -725,10 +725,10 @@ struct DetailReducer { case .comments(.detail(let recursiveAction)): guard state.commentsState.wrappedValue != nil else { return .none } - return self.reduce( + let effect = self._reduce( into: &state.commentsState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction ) - .map({ Action.comments(.detail($0)) }) + return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) case .comments: return .none @@ -738,10 +738,10 @@ struct DetailReducer { case .detailSearch(.detail(let recursiveAction)): guard state.detailSearchState.wrappedValue != nil else { return .none } - return self.reduce( + let effect = self._reduce( into: &state.detailSearchState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction ) - .map({ Action.detailSearch(.detail($0)) }) + return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) case .detailSearch: return .none @@ -804,8 +804,8 @@ struct DetailReducer { var body: some Reducer { RecurseReducer { (self) in BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } coreReducer(self: self) diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift index 70e330c20..5080f9052 100644 --- a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -67,16 +67,14 @@ struct DetailSearchReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } - .onChange(of: \.keyword) { _, newValue in - Reduce { state, _ in - if !newValue.isEmpty { - state.lastKeyword = newValue - } - return .none + .onChange(of: \.keyword) { _, state in + if !state.keyword.isEmpty { + state.lastKeyword = state.keyword } + return .none } Reduce { state, action in diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 07eebba2e..71080b588 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -409,7 +409,7 @@ private extension DetailView { store.send(.setNavigation(.archives(galleryURL, archiveURL))) } } label: { - Label(L10n.Localizable.DetailView.ToolbarItem.Button.archives, systemSymbol: .docZipper) + Label(L10n.Localizable.DetailView.ToolbarItem.Button.archives, systemSymbol: .zipperPage) } .disabled(store.galleryDetail?.archiveURL == nil || !CookieUtil.didLogin) Button { @@ -1092,7 +1092,7 @@ private extension TagsSection { links: translation.links )) } label: { - Image(systemSymbol: .docRichtext) + Image(systemSymbol: .richtextPage) Text(L10n.Localizable.DetailView.ContextMenu.Button.detail) } } diff --git a/EhPanda/View/Detail/Previews/PreviewsReducer.swift b/EhPanda/View/Detail/Previews/PreviewsReducer.swift index e0ebd788e..085cd1a67 100644 --- a/EhPanda/View/Detail/Previews/PreviewsReducer.swift +++ b/EhPanda/View/Detail/Previews/PreviewsReducer.swift @@ -71,8 +71,8 @@ struct PreviewsReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } Reduce { state, action in diff --git a/EhPanda/View/Detail/Torrents/TorrentsView.swift b/EhPanda/View/Detail/Torrents/TorrentsView.swift index 8d1c9df20..531e4e916 100644 --- a/EhPanda/View/Detail/Torrents/TorrentsView.swift +++ b/EhPanda/View/Detail/Torrents/TorrentsView.swift @@ -30,7 +30,7 @@ struct TorrentsView: View { Button { store.send(.fetchTorrent(torrent.hash, torrent.torrentURL)) } label: { - Image(systemSymbol: .arrowDownDocFill) + Image(systemSymbol: .arrowDownDocumentFill) } } } @@ -89,7 +89,7 @@ private extension TorrentsView { } Spacer() HStack(spacing: 3) { - Image(systemSymbol: .docCircle) + Image(systemSymbol: .documentCircle) Text(torrent.fileSize) } } diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index 5173b8b38..48c094324 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -80,16 +80,12 @@ struct DownloadsReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce { _, _ in - newValue == nil ? .send(.clearSubStates) : .none - } + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } - .onChange(of: \.galleryFilter) { _, _ in - Reduce { state, _ in - state.galleryFilter.fixInvalidData() - return .none - } + .onChange(of: \.galleryFilter) { _, state in + state.galleryFilter.fixInvalidData() + return .none } Reduce { state, action in diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift index c9301be9b..5409d36b1 100644 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -89,8 +89,8 @@ struct FavoritesReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } Reduce { state, action in diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift index f27c5aee5..aaf28198a 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift @@ -67,8 +67,8 @@ struct FrontpageReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } Reduce { state, action in diff --git a/EhPanda/View/Home/History/HistoryReducer.swift b/EhPanda/View/Home/History/HistoryReducer.swift index 5c8c89ba4..532c6c6e2 100644 --- a/EhPanda/View/Home/History/HistoryReducer.swift +++ b/EhPanda/View/Home/History/HistoryReducer.swift @@ -62,8 +62,8 @@ struct HistoryReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } Reduce { state, action in diff --git a/EhPanda/View/Home/HomeReducer.swift b/EhPanda/View/Home/HomeReducer.swift index ba94cc2d6..178ed2e3c 100644 --- a/EhPanda/View/Home/HomeReducer.swift +++ b/EhPanda/View/Home/HomeReducer.swift @@ -116,18 +116,16 @@ struct HomeReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } - .onChange(of: \.cardPageIndex) { _, newValue in - Reduce { state, _ in - guard newValue < state.popularGalleries.count else { return .none } - state.currentCardID = state.popularGalleries[state.cardPageIndex].gid - state.allowsCardHitTesting = false - return .run { send in - try await Task.sleep(for: .milliseconds(300)) - await send(.setAllowsCardHitTesting(true)) - } + .onChange(of: \.cardPageIndex) { _, state in + guard state.cardPageIndex < state.popularGalleries.count else { return .none } + state.currentCardID = state.popularGalleries[state.cardPageIndex].gid + state.allowsCardHitTesting = false + return .run { send in + try await Task.sleep(for: .milliseconds(300)) + await send(.setAllowsCardHitTesting(true)) } } diff --git a/EhPanda/View/Home/HomeView.swift b/EhPanda/View/Home/HomeView.swift index e7406305f..d34596a61 100644 --- a/EhPanda/View/Home/HomeView.swift +++ b/EhPanda/View/Home/HomeView.swift @@ -559,7 +559,7 @@ extension HomeMiscGridType { case .watched: return .tagCircle case .history: - return .clockArrowCirclepath + return .clockArrowTriangleheadCounterclockwiseRotate90 } } } diff --git a/EhPanda/View/Home/Popular/PopularReducer.swift b/EhPanda/View/Home/Popular/PopularReducer.swift index 1b8df2a4d..4095641b9 100644 --- a/EhPanda/View/Home/Popular/PopularReducer.swift +++ b/EhPanda/View/Home/Popular/PopularReducer.swift @@ -55,8 +55,8 @@ struct PopularReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } Reduce { state, action in diff --git a/EhPanda/View/Home/Toplists/ToplistsReducer.swift b/EhPanda/View/Home/Toplists/ToplistsReducer.swift index b36ca8fe7..94463ba17 100644 --- a/EhPanda/View/Home/Toplists/ToplistsReducer.swift +++ b/EhPanda/View/Home/Toplists/ToplistsReducer.swift @@ -88,16 +88,14 @@ struct ToplistsReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } - .onChange(of: \.jumpPageAlertPresented) { _, newValue in - Reduce { state, _ in - if !newValue { - state.jumpPageAlertFocused = false - } - return .none + .onChange(of: \.jumpPageAlertPresented) { _, state in + if !state.jumpPageAlertPresented { + state.jumpPageAlertFocused = false } + return .none } Reduce { state, action in diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift index d84a476c1..4765ce6a1 100644 --- a/EhPanda/View/Home/Watched/WatchedReducer.swift +++ b/EhPanda/View/Home/Watched/WatchedReducer.swift @@ -74,8 +74,8 @@ struct WatchedReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } Reduce { state, action in diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index 5987bd90d..2699a7d19 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -203,7 +203,7 @@ struct ReadingReducer { var body: some Reducer { BindingReducer() .onChange(of: \.showsSliderPreview) { _, _ in - Reduce({ _, _ in .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) }) + .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) } Reduce { state, action in diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 1c42f69eb..723750342 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -621,7 +621,7 @@ private struct ImageContainer: View { .foregroundColor(.gray).padding(.bottom, 30) ZStack { Button(action: reloadImage) { - Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + Image(systemSymbol: .exclamationmarkArrowTrianglehead2ClockwiseRotate90) } .font(.system(size: 30, weight: .medium)).foregroundColor(.gray) .opacity(loadingState == .loading ? 0 : 1) diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift index 8945b2191..da6569007 100644 --- a/EhPanda/View/Reading/Support/ControlPanel.swift +++ b/EhPanda/View/Reading/Support/ControlPanel.swift @@ -192,7 +192,7 @@ private struct UpperPanel: View { ToolbarFeaturesMenu { Button(action: retryAllFailedImagesAction) { - Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + Image(systemSymbol: .exclamationmarkArrowTrianglehead2ClockwiseRotate90) Text(L10n.Localizable.ReadingView.ToolbarItem.Button.retryAllFailedImages) } Button(action: reloadAllImagesAction) { diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index 4e8dffb6b..2b8311ee0 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -74,16 +74,14 @@ struct SearchReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } - .onChange(of: \.keyword) { _, newValue in - Reduce { state, _ in - if !newValue.isEmpty { - state.lastKeyword = newValue - } - return .none + .onChange(of: \.keyword) { _, state in + if !state.keyword.isEmpty { + state.lastKeyword = state.keyword } + return .none } Reduce { state, action in diff --git a/EhPanda/View/Search/SearchRootReducer.swift b/EhPanda/View/Search/SearchRootReducer.swift index c55a95fdf..d949d51d5 100644 --- a/EhPanda/View/Search/SearchRootReducer.swift +++ b/EhPanda/View/Search/SearchRootReducer.swift @@ -89,15 +89,13 @@ struct SearchRootReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce { _, _ in - newValue == nil - ? .merge( - .send(.clearSubStates), - .send(.fetchDatabaseInfos) - ) - : .none - } + .onChange(of: \.route) { _, state in + state.route == nil + ? .merge( + .send(.clearSubStates), + .send(.fetchDatabaseInfos) + ) + : .none } Reduce { state, action in diff --git a/EhPanda/View/Search/Support/QuickSearchReducer.swift b/EhPanda/View/Search/Support/QuickSearchReducer.swift index 1c48d3712..ad6377de4 100644 --- a/EhPanda/View/Search/Support/QuickSearchReducer.swift +++ b/EhPanda/View/Search/Support/QuickSearchReducer.swift @@ -64,8 +64,8 @@ struct QuickSearchReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } Reduce { state, action in diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift index e36a84fc7..e41dfc723 100644 --- a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift +++ b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift @@ -46,14 +46,14 @@ struct AccountSettingReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } - .onChange(of: \.ehCookiesState) { _, newValue in - Reduce({ _, _ in .run(operation: { _ in cookieClient.setCookies(state: newValue) }) }) + .onChange(of: \.ehCookiesState) { _, state in + .run(operation: { [value = state.ehCookiesState] _ in cookieClient.setCookies(state: value) }) } - .onChange(of: \.exCookiesState) { _, newValue in - Reduce({ _, _ in .run(operation: { _ in cookieClient.setCookies(state: newValue) }) }) + .onChange(of: \.exCookiesState) { _, state in + .run(operation: { [value = state.exCookiesState] _ in cookieClient.setCookies(state: value) }) } Reduce { state, action in diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView.swift b/EhPanda/View/Setting/EhSetting/EhSettingView.swift index e64686f29..b2530fd74 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView.swift @@ -164,7 +164,7 @@ private struct EhProfileSection: View { } var body: some View { - Section(L10n.Localizable.EhSettingView.Section.Title.profileSettings) { + Section { Picker(L10n.Localizable.EhSettingView.Title.selectedProfile, selection: $ehProfile) { ForEach(ehSetting.ehProfiles) { ehProfile in Text(ehProfile.name) @@ -194,6 +194,9 @@ private struct EhProfileSection: View { ) } } + } header: { + Text(L10n.Localizable.EhSettingView.Section.Title.profileSettings) + .regularHeaderStyled() } .onChange(of: ehProfile) { _, newValue in performEhProfileAction(nil, nil, newValue.value) @@ -239,17 +242,12 @@ private struct ImageLoadSettingsSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.imageLoadSettings) + Text.boldHeader(L10n.Localizable.EhSettingView.Section.Title.imageLoadSettings) } footer: { Text(ehSetting.loadThroughHathSetting.description) } - Section( - L10n.Localizable.EhSettingView.Description.browsingCountry( - ehSetting.localizedLiteralBrowsingCountry ?? ehSetting.literalBrowsingCountry - ) - .localizedKey - ) { + Section { Picker(L10n.Localizable.EhSettingView.Title.browsingCountry, selection: $ehSetting.browsingCountry) { ForEach(EhSetting.BrowsingCountry.allCases) { country in Text(country.name) @@ -257,6 +255,14 @@ private struct ImageLoadSettingsSection: View { .foregroundColor(country == ehSetting.browsingCountry ? .accentColor : .primary) } } + } header: { + Text( + L10n.Localizable.EhSettingView.Description.browsingCountry( + ehSetting.localizedLiteralBrowsingCountry ?? ehSetting.literalBrowsingCountry + ) + .localizedKey + ) + .regularHeaderStyled() } } } @@ -279,21 +285,25 @@ private struct ImageSizeSettingsSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.imageSizeSettings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.imageResolution) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.imageSizeSettings, + description: L10n.Localizable.EhSettingView.Description.imageResolution + ) } if let useOriginalImagesBinding = Binding($ehSetting.useOriginalImages) { - Section(L10n.Localizable.EhSettingView.Section.Title.originalImages) { + Section { Toggle( L10n.Localizable.EhSettingView.Title.useOriginalImages, isOn: useOriginalImagesBinding ) + } header: { + Text(L10n.Localizable.EhSettingView.Section.Title.originalImages) + .regularHeaderStyled() } } - Section(L10n.Localizable.EhSettingView.Description.imageSize) { + Section { Text(L10n.Localizable.EhSettingView.Title.imageSize) ValuePicker( @@ -305,6 +315,9 @@ private struct ImageSizeSettingsSection: View { title: L10n.Localizable.EhSettingView.Title.vertical, value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px" ) + } header: { + Text(L10n.Localizable.EhSettingView.Description.imageSize) + .regularHeaderStyled() } } } @@ -327,9 +340,10 @@ private struct GalleryNameDisplaySection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.galleryNameDisplay) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.galleryName) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.galleryNameDisplay, + description: L10n.Localizable.EhSettingView.Description.galleryName + ) } } } @@ -352,9 +366,10 @@ private struct ArchiverSettingsSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.archiverSettings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.archiverBehavior) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.archiverSettings, + description: L10n.Localizable.EhSettingView.Description.archiverBehavior + ) } } } @@ -375,12 +390,13 @@ private struct FrontPageSettingsSection: View { Section { CategoryView(bindings: categoryBindings) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.frontPageSettings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.galleryCategory) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.frontPageSettings, + description: L10n.Localizable.EhSettingView.Description.galleryCategory + ) } - Section(L10n.Localizable.EhSettingView.Description.displayMode) { + Section { Picker(L10n.Localizable.EhSettingView.Title.displayMode, selection: $ehSetting.displayMode) { ForEach(EhSetting.DisplayMode.allCases) { mode in Text(mode.value) @@ -388,13 +404,19 @@ private struct FrontPageSettingsSection: View { } } .pickerStyle(.menu) + } header: { + Text(L10n.Localizable.EhSettingView.Description.displayMode) + .regularHeaderStyled() } - Section(L10n.Localizable.EhSettingView.Section.Title.showSearchRangeIndicator) { + Section { Toggle( L10n.Localizable.EhSettingView.Title.showSearchRangeIndicator, isOn: $ehSetting.showSearchRangeIndicator ) + } header: { + Text(L10n.Localizable.EhSettingView.Section.Title.showSearchRangeIndicator) + .regularHeaderStyled() } } } @@ -414,9 +436,10 @@ private struct OptionalUIElementsSection: View { isOn: $ehSetting.enableGalleryThumbnailSelector ) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.optionalUIElements) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.optionalUIElements) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.optionalUIElements, + description: L10n.Localizable.EhSettingView.Description.optionalUIElements + ) } } } @@ -450,12 +473,13 @@ private struct FavoritesSection: View { .padding(.leading) } } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.favorites) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.favoriteCategories) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.favorites, + description: L10n.Localizable.EhSettingView.Description.favoriteCategories + ) } - Section(L10n.Localizable.EhSettingView.Description.favoritesSortOrder) { + Section { Picker( L10n.Localizable.EhSettingView.Title.favoritesSortOrder, selection: $ehSetting.favoritesSortOrder @@ -466,6 +490,9 @@ private struct FavoritesSection: View { } } .pickerStyle(.menu) + } header: { + Text(L10n.Localizable.EhSettingView.Description.favoritesSortOrder) + .regularHeaderStyled() } } } @@ -490,9 +517,10 @@ private struct RatingsSection: View { .focused($isFocused) } } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.ratings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.ratingsColor) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.ratings, + description: L10n.Localizable.EhSettingView.Description.ratingsColor + ) } } } @@ -512,9 +540,10 @@ private struct TagFilteringThresholdSection: View { value: $ehSetting.tagFilteringThreshold, range: -9999...0 ) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.tagFilteringThreshold) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.tagFilteringThreshold) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.tagFilteringThreshold, + description: L10n.Localizable.EhSettingView.Description.tagFilteringThreshold + ) } } } @@ -534,9 +563,10 @@ private struct TagWatchingThresholdSection: View { value: $ehSetting.tagWatchingThreshold, range: 0...9999 ) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.tagWatchingThreshold) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.tagWatchingThreshold) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.tagWatchingThreshold, + description: L10n.Localizable.EhSettingView.Description.tagWatchingThreshold + ) } } } @@ -556,8 +586,10 @@ private struct FilteredRemovalCountSection: View { isOn: $ehSetting.showFilteredRemovalCount ) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.filteredRemovalCount).newlineBold() - + Text(L10n.Localizable.EhSettingView.Description.filteredRemovalCount) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.filteredRemovalCount, + description: L10n.Localizable.EhSettingView.Description.filteredRemovalCount + ) } } } @@ -610,9 +642,10 @@ private struct ExcludedLanguagesSection: View { ) } } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.excludedLanguages) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.excludedLanguages) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.excludedLanguages, + description: L10n.Localizable.EhSettingView.Description.excludedLanguages + ) } } } @@ -683,9 +716,10 @@ private struct ExcludedUploadersSection: View { .disableAutocorrection(true) .focused($isFocused) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.excludedUploaders) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.excludedUploaders) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.excludedUploaders, + description: L10n.Localizable.EhSettingView.Description.excludedUploaders + ) } footer: { Text( L10n.Localizable.EhSettingView.Description.excludedUploadersCount( @@ -715,9 +749,10 @@ private struct SearchResultCountSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.searchResultCount) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.resultCount) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.searchResultCount, + description: L10n.Localizable.EhSettingView.Description.resultCount + ) } } } @@ -743,14 +778,15 @@ private struct ThumbnailSettingsSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.thumbnailSettings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.thumbnailLoadTiming) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.thumbnailSettings, + description: L10n.Localizable.EhSettingView.Description.thumbnailLoadTiming + ) } footer: { Text(ehSetting.thumbnailLoadTiming.description) } - Section(L10n.Localizable.EhSettingView.Description.thumbnailConfiguration) { + Section { LabeledContent(L10n.Localizable.EhSettingView.Title.thumbnailSize) { Picker(selection: $ehSetting.thumbnailConfigSize) { ForEach(ehSetting.capableThumbnailConfigSizes) { size in @@ -776,6 +812,9 @@ private struct ThumbnailSettingsSection: View { .pickerStyle(.segmented) .frame(width: 200) } + } header: { + Text(L10n.Localizable.EhSettingView.Description.thumbnailConfiguration) + .regularHeaderStyled() } } } @@ -797,9 +836,10 @@ private struct CoverScalingSection: View { unit: "%" ) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.coverScaling) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.coverScaleFactor) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.coverScaling, + description: L10n.Localizable.EhSettingView.Description.coverScaleFactor + ) } } } @@ -821,9 +861,10 @@ private struct ViewportOverrideSection: View { unit: "px" ) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.viewportOverride) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.virtualWidth) + Text.boldHeader( + L10n.Localizable.EhSettingView.Section.Title.viewportOverride, + description: L10n.Localizable.EhSettingView.Description.virtualWidth + ) } } } @@ -874,7 +915,7 @@ private struct GalleryCommentsSection: View { } var body: some View { - Section(L10n.Localizable.EhSettingView.Section.Title.galleryComments) { + Section { Picker( L10n.Localizable.EhSettingView.Title.commentsSortOrder, selection: $ehSetting.commentsSortOrder @@ -896,6 +937,9 @@ private struct GalleryCommentsSection: View { } } .pickerStyle(.menu) + } header: { + Text(L10n.Localizable.EhSettingView.Section.Title.galleryComments) + .regularHeaderStyled() } } } @@ -909,7 +953,7 @@ private struct GalleryTagsSection: View { } var body: some View { - Section(L10n.Localizable.EhSettingView.Section.Title.galleryTags) { + Section { Picker(L10n.Localizable.EhSettingView.Title.tagsSortOrder, selection: $ehSetting.tagsSortOrder) { ForEach(EhSetting.TagsSortOrder.allCases) { order in Text(order.value) @@ -917,6 +961,9 @@ private struct GalleryTagsSection: View { } } .pickerStyle(.menu) + } header: { + Text(L10n.Localizable.EhSettingView.Section.Title.galleryTags) + .regularHeaderStyled() } } } @@ -930,7 +977,7 @@ private struct GalleryPageThumbnailLabelingSection: View { } var body: some View { - Section(L10n.Localizable.EhSettingView.Section.Title.galleryPageThumbnailLabeling) { + Section { Picker( L10n.Localizable.EhSettingView.Title.showLabelBelowGalleryThumbnails, selection: $ehSetting.galleryPageNumbering @@ -941,6 +988,9 @@ private struct GalleryPageThumbnailLabelingSection: View { } } .pickerStyle(.menu) + } header: { + Text(L10n.Localizable.EhSettingView.Section.Title.galleryPageThumbnailLabeling) + .regularHeaderStyled() } } } @@ -958,7 +1008,7 @@ private struct MultiplePageViewerSection: View { let multiplePageViewerStyleBinding = Binding($ehSetting.multiplePageViewerStyle), let multiplePageViewerShowPaneBinding = Binding($ehSetting.multiplePageViewerShowThumbnailPane) { - Section(L10n.Localizable.EhSettingView.Section.Title.multiPageViewer) { + Section { Toggle( L10n.Localizable.EhSettingView.Title.useMultiPageViewer, isOn: useMultiplePageViewerBinding @@ -979,6 +1029,9 @@ private struct MultiplePageViewerSection: View { L10n.Localizable.EhSettingView.Title.showThumbnailPane, isOn: multiplePageViewerShowPaneBinding ) + } header: { + Text(L10n.Localizable.EhSettingView.Section.Title.multiPageViewer) + .regularHeaderStyled() } } } @@ -997,12 +1050,19 @@ private extension String { } private extension Text { - func newlineBold() -> Text { - bold() + Text("\n") + static func boldHeader(_ title: String, description: String? = nil) -> Self { + var result = AttributedString(title) + result.font = .body.weight(.bold) + if let description{ + var descriptionString = AttributedString("\n\(description)") + descriptionString.font = .subheadline.weight(.regular) + result.append(descriptionString) + } + return Text(result) } - func appending(_ string: some StringProtocol) -> Text { - self + Text(string) + func regularHeaderStyled() -> Self { + font(.subheadline.weight(.regular)) } } diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift index d63026bd8..3a0f4eb05 100644 --- a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift +++ b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift @@ -50,8 +50,8 @@ struct GeneralSettingReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none } Reduce { state, action in diff --git a/EhPanda/View/Setting/SettingReducer.swift b/EhPanda/View/Setting/SettingReducer.swift index d9eb84e1d..3b6b5f6f6 100644 --- a/EhPanda/View/Setting/SettingReducer.swift +++ b/EhPanda/View/Setting/SettingReducer.swift @@ -114,97 +114,77 @@ struct SettingReducer { var body: some Reducer { BindingReducer() .onChange(of: \.setting) { _, _ in - Reduce({ _, _ in .send(.syncSetting) }) + .send(.syncSetting) } - .onChange(of: \.setting.galleryHost) { _, newValue in - Reduce { _, _ in - .merge( - .send(.syncSetting), - .run(operation: { _ in userDefaultsClient.setValue(newValue.rawValue, .galleryHost) }) - ) - } + .onChange(of: \.setting.galleryHost) { _, state in + .merge( + .send(.syncSetting), + .run(operation: { [value = state.setting.galleryHost.rawValue] _ in userDefaultsClient.setValue(value, .galleryHost) }) + ) } - .onChange(of: \.setting.enablesTagsExtension) { _, newValue in - Reduce { _, _ in - var effects: [Effect] = [ - .send(.syncSetting) - ] - if newValue { - effects.append(.send(.fetchTagTranslator)) - } - return .merge(effects) + .onChange(of: \.setting.enablesTagsExtension) { _, state in + var effects: [Effect] = [ + .send(.syncSetting) + ] + if state.setting.enablesTagsExtension { + effects.append(.send(.fetchTagTranslator)) } + return .merge(effects) } .onChange(of: \.setting.preferredColorScheme) { _, _ in - Reduce { _, _ in - .merge( - .send(.syncSetting), - .send(.syncUserInterfaceStyle) - ) - } - } - .onChange(of: \.setting.appIconType) { _, newValue in - Reduce { _, _ in - .merge( - .send(.syncSetting), - .run { send in - _ = await uiApplicationClient.setAlternateIconName(newValue.filename) - await send(.syncAppIconType) - } - ) - } + .merge( + .send(.syncSetting), + .send(.syncUserInterfaceStyle) + ) } - .onChange(of: \.setting.autoLockPolicy) { _, newValue in - Reduce { state, _ in - if newValue != .never && state.setting.backgroundBlurRadius == 0 { - state.setting.backgroundBlurRadius = 10 + .onChange(of: \.setting.appIconType) { _, state in + .merge( + .send(.syncSetting), + .run { [value = state.setting.appIconType.filename] send in + _ = await uiApplicationClient.setAlternateIconName(value) + await send(.syncAppIconType) } - return .send(.syncSetting) - } + ) } - .onChange(of: \.setting.backgroundBlurRadius) { _, newValue in - Reduce { state, _ in - if state.setting.autoLockPolicy != .never && newValue == 0 { - state.setting.autoLockPolicy = .never - } - return .send(.syncSetting) + .onChange(of: \.setting.autoLockPolicy) { _, state in + if state.setting.autoLockPolicy != .never && state.setting.backgroundBlurRadius == 0 { + state.setting.backgroundBlurRadius = 10 } + return .send(.syncSetting) } - .onChange(of: \.setting.enablesLandscape) { _, newValue in - Reduce { _, _ in - var effects: [Effect] = [ - .send(.syncSetting) - ] - if !newValue && !deviceClient.isPad() { - effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) - } - return .merge(effects) + .onChange(of: \.setting.backgroundBlurRadius) { _, state in + if state.setting.autoLockPolicy != .never && state.setting.backgroundBlurRadius == 0 { + state.setting.autoLockPolicy = .never } + return .send(.syncSetting) } - .onChange(of: \.setting.maximumScaleFactor) { _, newValue in - Reduce { state, _ in - if state.setting.doubleTapScaleFactor > newValue { - state.setting.doubleTapScaleFactor = newValue - } - return .send(.syncSetting) + .onChange(of: \.setting.enablesLandscape) { _, state in + var effects: [Effect] = [ + .send(.syncSetting) + ] + if !state.setting.enablesLandscape && !deviceClient.isPad() { + effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) } + return .merge(effects) } - .onChange(of: \.setting.doubleTapScaleFactor) { _, newValue in - Reduce { state, _ in - if state.setting.maximumScaleFactor < newValue { - state.setting.maximumScaleFactor = newValue - } - return .send(.syncSetting) + .onChange(of: \.setting.maximumScaleFactor) { _, state in + if state.setting.doubleTapScaleFactor > state.setting.maximumScaleFactor { + state.setting.doubleTapScaleFactor = state.setting.maximumScaleFactor } + return .send(.syncSetting) } - .onChange(of: \.setting.bypassesSNIFiltering) { _, newValue in - Reduce { _, _ in - .merge( - .send(.syncSetting), - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), - .run(operation: { _ in dfClient.setActive(newValue) }) - ) + .onChange(of: \.setting.doubleTapScaleFactor) { _, state in + if state.setting.maximumScaleFactor < state.setting.doubleTapScaleFactor { + state.setting.maximumScaleFactor = state.setting.doubleTapScaleFactor } + return .send(.syncSetting) + } + .onChange(of: \.setting.bypassesSNIFiltering) { _, state in + .merge( + .send(.syncSetting), + .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), + .run(operation: { [value = state.setting.bypassesSNIFiltering] _ in dfClient.setActive(value) }) + ) } Reduce { state, action in @@ -308,10 +288,10 @@ struct SettingReducer { case .fetchIgneousDone(let result): if case .success(let response) = result { - return .concatenate( - .run(operation: { _ in cookieClient.setCredentials(response: response) }), - .send(.account(.loadCookies)) - ) + return .run { send in + cookieClient.setCredentials(response: response) + await send(.account(.loadCookies)) + } } return .send(.account(.loadCookies)) diff --git a/EhPanda/View/Support/Components/AlertView.swift b/EhPanda/View/Support/Components/AlertView.swift index 06481449b..d4fe17700 100644 --- a/EhPanda/View/Support/Components/AlertView.swift +++ b/EhPanda/View/Support/Components/AlertView.swift @@ -35,7 +35,7 @@ struct FetchMoreFooter: View { Button { retryAction?() } label: { - Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + Image(systemSymbol: .exclamationmarkArrowTrianglehead2ClockwiseRotate90) .foregroundStyle(.red).imageScale(.large) } .opacity(![.idle, .loading].contains(loadingState) ? 1 : 0) diff --git a/EhPanda/View/Support/Components/DownloadBadgeStore.swift b/EhPanda/View/Support/Components/DownloadBadgeStore.swift index 4a7ff6cf6..df7819c3e 100644 --- a/EhPanda/View/Support/Components/DownloadBadgeStore.swift +++ b/EhPanda/View/Support/Components/DownloadBadgeStore.swift @@ -21,7 +21,7 @@ final class DownloadBadgeStore: ObservableObject { guard let self else { return } await self.apply(downloads: client.fetchDownloads()) for await downloads in client.observeDownloads() { - await self.apply(downloads: downloads) + self.apply(downloads: downloads) } } } diff --git a/EhPanda/View/Support/Components/TagSuggestionView.swift b/EhPanda/View/Support/Components/TagSuggestionView.swift index 1d1b1184f..42be34ca0 100644 --- a/EhPanda/View/Support/Components/TagSuggestionView.swift +++ b/EhPanda/View/Support/Components/TagSuggestionView.swift @@ -95,7 +95,7 @@ private struct SuggestionCell: View { .contentShape(Rectangle()) .onTapGesture(perform: action) } else { - (Text(displayValue.localizedKey) + Text("\n") + Text(suggestion.displayKey.localizedKey)) + Text("\(Text(displayValue.localizedKey))\n\(Text(suggestion.displayKey.localizedKey))") .searchCompletion(suggestion.tag.searchKeyword) } } diff --git a/EhPanda/View/Support/FiltersReducer.swift b/EhPanda/View/Support/FiltersReducer.swift index 12a960f91..243c6dc93 100644 --- a/EhPanda/View/Support/FiltersReducer.swift +++ b/EhPanda/View/Support/FiltersReducer.swift @@ -43,23 +43,17 @@ struct FiltersReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.searchFilter) { _, _ in - Reduce { state, _ in - state.searchFilter.fixInvalidData() - return .send(.syncFilter(.search)) - } + .onChange(of: \.searchFilter) { _, state in + state.searchFilter.fixInvalidData() + return .send(.syncFilter(.search)) } - .onChange(of: \.globalFilter) { _, _ in - Reduce { state, _ in - state.globalFilter.fixInvalidData() - return .send(.syncFilter(.global)) - } + .onChange(of: \.globalFilter) { _, state in + state.globalFilter.fixInvalidData() + return .send(.syncFilter(.global)) } - .onChange(of: \.watchedFilter) { _, _ in - Reduce { state, _ in - state.watchedFilter.fixInvalidData() - return .send(.syncFilter(.watched)) - } + .onChange(of: \.watchedFilter) { _, state in + state.watchedFilter.fixInvalidData() + return .send(.syncFilter(.watched)) } Reduce { state, action in From 2fc9b21466eb85a5514f9d6bf6133ee5b883ede0 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 28 Mar 2026 17:15:35 +0800 Subject: [PATCH 011/614] Introduce SwiftLint plugin & resolve lint issues --- .swiftlint.yml | 8 - EhPanda.xcodeproj/project.pbxproj | 62 +++-- .../xcshareddata/swiftpm/Package.resolved | 11 +- .../App/Tools/Clients/DownloadClient.swift | 220 ++++++++++-------- EhPanda/App/Tools/Clients/ImageClient.swift | 7 +- EhPanda/App/Tools/Parser.swift | 4 +- EhPanda/DataFlow/AppReducer.swift | 10 +- EhPanda/Models/Gallery/GalleryDetail.swift | 1 + EhPanda/Network/Request.swift | 68 +++--- .../View/Reading/Support/GestureHandler.swift | 22 +- EhPanda/View/Setting/SettingReducer.swift | 4 +- EhPandaTests/Models/ListParserTestType.swift | 12 +- .../DownloadFeatureReducerTests.swift | 68 +++--- .../Download/DownloadFileStorageTests.swift | 4 +- .../DownloadSignatureBuilderTests.swift | 55 ++++- .../Gallery/GalleryDetailParserTests.swift | 5 +- .../Gallery/GalleryImageURLParserTests.swift | 8 +- .../Other/DownloadPageErrorParserTests.swift | 3 +- .../Parser/Other/EhSettingParserTests.swift | 2 +- .../Parser/Other/SettingDownloadTests.swift | 8 +- 20 files changed, 339 insertions(+), 243 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 65bd698ee..7b9b494b3 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -6,13 +6,5 @@ disabled_rules: - function_body_length - cyclomatic_complexity -identifier_name: - excluded: - - x - - y - - id - - no - excluded: - - EhPandaTests - EhPanda/App/Generated diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index fe1244f02..ea93c52b1 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -1617,6 +1617,9 @@ ); buildRules = ( ); + dependencies = ( + A66A766F2F77C89100FC07B8 /* PBXTargetDependency */, + ); name = ShareExtension; productName = ShareExtension; productReference = AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */; @@ -1627,7 +1630,6 @@ buildConfigurationList = ABC3C7632593696E00E0C11B /* Build configuration list for PBXNativeTarget "EhPanda" */; buildPhases = ( AB2E936227A24E0A00EA99F1 /* SwiftGen */, - AB69FE41263C328400716FBD /* SwiftLint */, ABC3C7502593696C00E0C11B /* Sources */, ABC3C7512593696C00E0C11B /* Frameworks */, ABC3C7522593696C00E0C11B /* Resources */, @@ -1636,6 +1638,7 @@ buildRules = ( ); dependencies = ( + A66A766D2F77C88A00FC07B8 /* PBXTargetDependency */, AB5BE67F26B95FDD007D4A55 /* PBXTargetDependency */, ); name = EhPanda; @@ -1672,6 +1675,7 @@ buildRules = ( ); dependencies = ( + A66A76712F77C89600FC07B8 /* PBXTargetDependency */, ABF294D126D20F82004DD03A /* PBXTargetDependency */, ); name = EhPandaTests; @@ -1733,6 +1737,7 @@ AB2EB9A0280251F600011A8A /* XCRemoteSwiftPackageReference "AlertKit" */, AB2EB9A32802521700011A8A /* XCRemoteSwiftPackageReference "DeprecatedAPI" */, EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */, + A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */, ); preferredProjectObjectVersion = 56; productRefGroup = ABC3C7552593696C00E0C11B /* Products */; @@ -1857,26 +1862,6 @@ "", ); }; - AB69FE41263C328400716FBD /* SwiftLint */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - name = SwiftLint; - shellPath = /bin/sh; - shellScript = ( - "if test -d \"/opt/homebrew/bin/\"; then", - " PATH=\"/opt/homebrew/bin/:${PATH}\"", - "fi", - "", - "export PATH", - "", - "if which swiftlint >/dev/null; then", - " swiftlint", - "else", - " echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"", - "fi", - "", - ); - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -2107,6 +2092,18 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + A66A766D2F77C88A00FC07B8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = A66A766C2F77C88A00FC07B8 /* SwiftLintBuildToolPlugin */; + }; + A66A766F2F77C89100FC07B8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = A66A766E2F77C89100FC07B8 /* SwiftLintBuildToolPlugin */; + }; + A66A76712F77C89600FC07B8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = A66A76702F77C89600FC07B8 /* SwiftLintBuildToolPlugin */; + }; AB5BE67F26B95FDD007D4A55 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = AB5BE67526B95FDD007D4A55 /* ShareExtension */; @@ -2487,6 +2484,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SimplyDanny/SwiftLintPlugins"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.0.0; + }; + }; AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Co2333/Colorful"; @@ -2622,6 +2627,21 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + A66A766C2F77C88A00FC07B8 /* SwiftLintBuildToolPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; + productName = "plugin:SwiftLintBuildToolPlugin"; + }; + A66A766E2F77C89100FC07B8 /* SwiftLintBuildToolPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; + productName = "plugin:SwiftLintBuildToolPlugin"; + }; + A66A76702F77C89600FC07B8 /* SwiftLintBuildToolPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; + productName = "plugin:SwiftLintBuildToolPlugin"; + }; AB17573C27675B1E00FD64E2 /* Colorful */ = { isa = XCSwiftPackageProductDependency; package = AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */; diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b65d07608..92a9d090b 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "d2c86e73cf55b52b5883b87c93943d803ad451433deeaa7ca1c32e53b38a2565", + "originHash" : "31490f6b507ee1988e23d0475c877e68d523ae16c08079502b8aab82e26a1e02", "pins" : [ { "identity" : "alertkit", @@ -190,6 +190,15 @@ "version" : "1.0.0" } }, + { + "identity" : "swiftlintplugins", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", + "state" : { + "revision" : "8a4640d14777685ba8f14e832373160498fbab92", + "version" : "0.63.2" + } + }, { "identity" : "swiftuipager", "kind" : "remoteSourceControl", diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index cc526b753..debe2d927 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -224,6 +224,27 @@ actor DownloadManager { let failedPages: [DownloadFailedPagesSnapshot.Page] } + private struct FailureContext: Sendable { + let gid: String + let originalDownload: DownloadedGallery + let mode: DownloadStartMode + let hadReadableFiles: Bool + let latestSignature: String? + } + + private struct PageDownloadContext: Sendable { + let payload: DownloadRequestPayload + let source: ResolvedSource? + let temporaryFolderURL: URL + let storedGalleryImageState: CachedGalleryImageState? + } + + private struct CacheRestoreSource: Sendable { + let cacheURLs: [URL?] + let referenceURL: URL? + let imageURL: URL? + } + private let storage: DownloadFileStorage private let urlSession: URLSession private let persistenceContainer: NSPersistentContainer @@ -748,13 +769,16 @@ actor DownloadManager { ) do { let cacheURLs = pageImageCacheURLs(imageURL: imageURL) + let cacheSource = CacheRestoreSource( + cacheURLs: cacheURLs, + referenceURL: preferredPageReferenceURL(imageURL: imageURL), + imageURL: imageURL + ) guard let pageResult = try await restorePageFromCache( index: index, - cacheURLs: cacheURLs, + source: cacheSource, folderURL: captureTarget.folderURL, preferredRelativePath: captureTarget.preferredRelativePath ?? existingPages[index], - referenceURL: preferredPageReferenceURL(imageURL: imageURL), - imageURL: imageURL, overwriteExistingFile: true ) else { return @@ -994,14 +1018,14 @@ actor DownloadManager { "error": error.localizedDescription ] ) - await persistFailure( + let failureContext = FailureContext( gid: gid, - error: error, originalDownload: download, mode: mode, hadReadableFiles: hadReadableFiles, latestSignature: fetchedVersionSignature ) + await persistFailure(error: error, context: failureContext) await notifyObservers() } catch let error as PartialDownloadError { let pageError = error.failedPages.first?.failure.appError ?? .unknown @@ -1015,28 +1039,28 @@ actor DownloadManager { "failedPages": error.failedPages.map(\.index) ] ) - await persistFailure( + let failureContext = FailureContext( gid: gid, - error: pageError, originalDownload: download, mode: mode, hadReadableFiles: hadReadableFiles, latestSignature: fetchedVersionSignature ) + await persistFailure(error: pageError, context: failureContext) await notifyObservers() } catch { let appError = AppError.fileOperationFailed(error.localizedDescription) guard !isCancellationLikeAppError(appError) else { return } guard !shouldSuppressFailurePersistence(for: gid) else { return } Logger.error(error) - await persistFailure( + let failureContext = FailureContext( gid: gid, - error: appError, originalDownload: download, mode: mode, hadReadableFiles: hadReadableFiles, latestSignature: fetchedVersionSignature ) + await persistFailure(error: appError, context: failureContext) await notifyObservers() } } @@ -1348,56 +1372,52 @@ actor DownloadManager { } private func persistFailure( - gid: String, error: AppError, - originalDownload: DownloadedGallery, - mode: DownloadStartMode, - hadReadableFiles: Bool, - latestSignature: String? + context: FailureContext ) async { let workingCompletedPageCount = temporaryCompletedPageCount( - gid: gid, - expectedPageCount: originalDownload.pageCount + gid: context.gid, + expectedPageCount: context.originalDownload.pageCount ) - let hasTemporaryWorkingSet = storage.temporaryFolderExists(gid: gid) + let hasTemporaryWorkingSet = storage.temporaryFolderExists(gid: context.gid) let recoveredCompletedPageCount = hasTemporaryWorkingSet ? workingCompletedPageCount - : max(originalDownload.completedPageCount, workingCompletedPageCount) + : max(context.originalDownload.completedPageCount, workingCompletedPageCount) do { - try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in + try await updateDownloadRecord(gid: context.gid, createIfMissing: false) { record in record.lastError = DownloadFailure(error: error).toData() record.pendingOperation = nil - if mode == .repair { + if context.mode == .repair { record.status = DownloadStatus.missingFiles.rawValue - record.completedPageCount = Int64(originalDownload.completedPageCount) - record.folderRelativePath = originalDownload.folderRelativePath - record.coverRelativePath = originalDownload.coverRelativePath - record.remoteVersionSignature = originalDownload.remoteVersionSignature - record.latestRemoteVersionSignature = latestSignature - ?? originalDownload.latestRemoteVersionSignature - } else if hadReadableFiles, [.update, .redownload].contains(mode) { + record.completedPageCount = Int64(context.originalDownload.completedPageCount) + record.folderRelativePath = context.originalDownload.folderRelativePath + record.coverRelativePath = context.originalDownload.coverRelativePath + record.remoteVersionSignature = context.originalDownload.remoteVersionSignature + record.latestRemoteVersionSignature = context.latestSignature + ?? context.originalDownload.latestRemoteVersionSignature + } else if context.hadReadableFiles, [.update, .redownload].contains(context.mode) { record.status = self.fallbackStatus( - for: originalDownload, - mode: mode, - latestSignature: latestSignature + for: context.originalDownload, + mode: context.mode, + latestSignature: context.latestSignature ) .rawValue - record.completedPageCount = Int64(originalDownload.pageCount) - record.folderRelativePath = originalDownload.folderRelativePath - record.coverRelativePath = originalDownload.coverRelativePath - record.remoteVersionSignature = originalDownload.remoteVersionSignature - record.latestRemoteVersionSignature = latestSignature - ?? originalDownload.latestRemoteVersionSignature + record.completedPageCount = Int64(context.originalDownload.pageCount) + record.folderRelativePath = context.originalDownload.folderRelativePath + record.coverRelativePath = context.originalDownload.coverRelativePath + record.remoteVersionSignature = context.originalDownload.remoteVersionSignature + record.latestRemoteVersionSignature = context.latestSignature + ?? context.originalDownload.latestRemoteVersionSignature } else if workingCompletedPageCount > 0 { record.status = DownloadStatus.partial.rawValue record.completedPageCount = Int64(workingCompletedPageCount) - record.latestRemoteVersionSignature = latestSignature - ?? originalDownload.latestRemoteVersionSignature + record.latestRemoteVersionSignature = context.latestSignature + ?? context.originalDownload.latestRemoteVersionSignature } else { record.status = DownloadStatus.partial.rawValue record.completedPageCount = Int64(recoveredCompletedPageCount) - record.latestRemoteVersionSignature = latestSignature - ?? originalDownload.latestRemoteVersionSignature + record.latestRemoteVersionSignature = context.latestSignature + ?? context.originalDownload.latestRemoteVersionSignature } } } catch { @@ -1566,15 +1586,18 @@ actor DownloadManager { requiredPageIndices: pendingPageIndices ) } - let batchResult = try await downloadPages( + let downloadContext = PageDownloadContext( payload: payload, - pendingPageIndices: pendingPageIndices, source: source, temporaryFolderURL: temporaryFolderURL, - existingManifest: workingSeed.manifest, - existingPageRelativePaths: workingSeed.existingPages, storedGalleryImageState: storedGalleryImageState ) + let batchResult = try await downloadPages( + context: downloadContext, + pendingPageIndices: pendingPageIndices, + existingManifest: workingSeed.manifest, + existingPageRelativePaths: workingSeed.existingPages + ) if payload.pageSelection != nil { try? storage.writeResumeState( .init( @@ -1729,13 +1752,10 @@ actor DownloadManager { } private func downloadPages( - payload: DownloadRequestPayload, + context: PageDownloadContext, pendingPageIndices: [Int], - source: ResolvedSource?, - temporaryFolderURL: URL, existingManifest: DownloadManifest?, - existingPageRelativePaths: [Int: String], - storedGalleryImageState: CachedGalleryImageState? + existingPageRelativePaths: [Int: String] ) async throws -> DownloadBatchResult { let manifestPages = Dictionary( uniqueKeysWithValues: (existingManifest?.pages ?? []).map { ($0.index, $0.relativePath) } @@ -1744,6 +1764,8 @@ actor DownloadManager { existingPageRelativePaths, uniquingKeysWith: { manifestPath, _ in manifestPath } ) + let payload = context.payload + let temporaryFolderURL = context.temporaryFolderURL var failedPages = (try? storage.readFailedPages(folderURL: temporaryFolderURL).map) ?? [:] let pageIndices = Array(1...payload.galleryDetail.pageCount) var results = [PageResult]() @@ -1756,7 +1778,7 @@ actor DownloadManager { .init( index: index, relativePath: relativePath, - imageURL: storedGalleryImageState?.imageURLs[index] + imageURL: context.storedGalleryImageState?.imageURLs[index] ) ) } @@ -1775,7 +1797,7 @@ actor DownloadManager { indices: pendingPageIndices, temporaryFolderURL: temporaryFolderURL, existingPages: existingPages, - storedGalleryImageState: storedGalleryImageState + storedGalleryImageState: context.storedGalleryImageState ) if !restoredCachedPages.isEmpty { restoredCachedPages.forEach { @@ -1805,11 +1827,8 @@ actor DownloadManager { return .success( try await self.downloadPage( index: index, - payload: payload, - source: source, - temporaryFolderURL: temporaryFolderURL, - preferredRelativePath: existingPages[index], - storedGalleryImageState: storedGalleryImageState + context: context, + preferredRelativePath: existingPages[index] ) ) } catch is CancellationError { @@ -1883,11 +1902,8 @@ actor DownloadManager { return .success( try await self.downloadPage( index: nextIndex, - payload: payload, - source: source, - temporaryFolderURL: temporaryFolderURL, - preferredRelativePath: existingPages[nextIndex], - storedGalleryImageState: storedGalleryImageState + context: context, + preferredRelativePath: existingPages[nextIndex] ) ) } catch is CancellationError { @@ -1948,12 +1964,12 @@ actor DownloadManager { private func downloadPage( index: Int, - payload: DownloadRequestPayload, - source: ResolvedSource?, - temporaryFolderURL: URL, - preferredRelativePath: String?, - storedGalleryImageState: CachedGalleryImageState? + context: PageDownloadContext, + preferredRelativePath: String? ) async throws -> PageResult { + let payload = context.payload + let temporaryFolderURL = context.temporaryFolderURL + let storedGalleryImageState = context.storedGalleryImageState let attempts = payload.options.autoRetryFailedPages ? 2 : 1 var capturedError: AppError = .unknown @@ -1964,17 +1980,20 @@ actor DownloadManager { index: index, storedGalleryImageState: storedGalleryImageState ) - if let pageResult = try await restorePageFromCache( - index: index, + let storedSource = CacheRestoreSource( cacheURLs: storedCacheURLs, - folderURL: temporaryFolderURL, - preferredRelativePath: preferredRelativePath, referenceURL: storedCacheURLs.compactMap(\.self).first, imageURL: storedGalleryImageState?.imageURLs[index] + ) + if let pageResult = try await restorePageFromCache( + index: index, + source: storedSource, + folderURL: temporaryFolderURL, + preferredRelativePath: preferredRelativePath ) { return pageResult } - guard let source else { throw AppError.notFound } + guard let source = context.source else { throw AppError.notFound } let resolvedImageSource = try await resolvedImageSource( index: index, payload: payload, @@ -1986,13 +2005,16 @@ actor DownloadManager { index: index, storedGalleryImageState: storedGalleryImageState ) - if let pageResult = try await restorePageFromCache( - index: index, + let resolvedSource = CacheRestoreSource( cacheURLs: resolvedCacheURLs, - folderURL: temporaryFolderURL, - preferredRelativePath: preferredRelativePath, referenceURL: preferredPageReferenceURL(resolvedImageSource: resolvedImageSource), imageURL: resolvedImageSource.imageURL + ) + if let pageResult = try await restorePageFromCache( + index: index, + source: resolvedSource, + folderURL: temporaryFolderURL, + preferredRelativePath: preferredRelativePath ) { return pageResult } @@ -2128,12 +2150,10 @@ actor DownloadManager { case .mpv(let mpvKey, let imageKeys): guard let imageKey = imageKeys[index] else { throw AppError.notFound } let imageURL = try await fetchMPVImageURL( - host: payload.host, - gid: payload.gallery.gid, + payload: payload, index: index, mpvKey: mpvKey, imageKey: imageKey, - allowsCellular: payload.options.allowCellular, retriesRequest: retriesRequest ) return .init(imageURL: imageURL) @@ -2204,15 +2224,13 @@ actor DownloadManager { } private func fetchMPVImageURL( - host: GalleryHost, - gid: String, + payload: DownloadRequestPayload, index: Int, mpvKey: String, imageKey: String, - allowsCellular: Bool, retriesRequest: Bool = true ) async throws -> URL { - guard let gidInteger = Int(gid) else { throw AppError.notFound } + guard let gidInteger = Int(payload.gallery.gid) else { throw AppError.notFound } let params: [String: Any] = [ "method": "imagedispatch", "gid": gidInteger, @@ -2221,10 +2239,10 @@ actor DownloadManager { "mpvkey": mpvKey ] - var request = URLRequest(url: host.url.appendingPathComponent("api.php")) + var request = URLRequest(url: payload.host.url.appendingPathComponent("api.php")) request.httpMethod = "POST" request.httpBody = try JSONSerialization.data(withJSONObject: params) - request.allowsCellularAccess = allowsCellular + request.allowsCellularAccess = payload.options.allowCellular let (data, response) = try await dataResponse(for: request, retriesRequest: retriesRequest) if let error = detectResponseError( @@ -3120,13 +3138,16 @@ actor DownloadManager { index: index, storedGalleryImageState: storedGalleryImageState ) - guard let pageResult = try await restorePageFromCache( - index: index, + let cacheSource = CacheRestoreSource( cacheURLs: cacheURLs, - folderURL: temporaryFolderURL, - preferredRelativePath: existingPages[index], referenceURL: cacheURLs.compactMap(\.self).first, imageURL: storedGalleryImageState?.imageURLs[index] + ) + guard let pageResult = try await restorePageFromCache( + index: index, + source: cacheSource, + folderURL: temporaryFolderURL, + preferredRelativePath: existingPages[index] ) else { continue } @@ -3159,15 +3180,13 @@ actor DownloadManager { private func restorePageFromCache( index: Int, - cacheURLs: [URL?], + source: CacheRestoreSource, folderURL: URL, preferredRelativePath: String?, - referenceURL: URL?, - imageURL: URL?, overwriteExistingFile: Bool = false ) async throws -> PageResult? { // Cache-assisted restores must reject known placeholder images before promoting them to offline files. - guard let cachedData = await validatedCachedAssetData(for: cacheURLs) + guard let cachedData = await validatedCachedAssetData(for: source.cacheURLs) else { return nil } @@ -3176,7 +3195,7 @@ actor DownloadManager { if let preferredRelativePath { relativePath = preferredRelativePath } else { - let fallbackURL = referenceURL ?? URL(string: "https://example.com/\(index).jpg")! + let fallbackURL = source.referenceURL ?? URL(string: "https://example.com/\(index).jpg")! let fileExtension = fileExtension( for: fallbackURL, response: nil, @@ -3196,7 +3215,7 @@ actor DownloadManager { return .init( index: index, relativePath: relativePath, - imageURL: imageURL + imageURL: source.imageURL ) } @@ -3810,18 +3829,21 @@ actor DownloadManager { ) ) - let batchResult = try await downloadPages( + let downloadContext = PageDownloadContext( payload: payload, + source: nil, + temporaryFolderURL: temporaryFolderURL, + storedGalleryImageState: await fetchCachedGalleryImageState(gid: payload.gallery.gid) + ) + let batchResult = try await downloadPages( + context: downloadContext, pendingPageIndices: pendingPageIndices( payload: payload, folderURL: temporaryFolderURL, existingPageRelativePaths: [:] ), - source: nil, - temporaryFolderURL: temporaryFolderURL, existingManifest: nil, - existingPageRelativePaths: [:], - storedGalleryImageState: await fetchCachedGalleryImageState(gid: payload.gallery.gid) + existingPageRelativePaths: [:] ) return batchResult.pages.count } diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index b206efe80..b95e03011 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -76,10 +76,9 @@ extension ImageClient { } return .failure(AppError.notFound) } - for key in url.imageCacheKeys(includeStableAlias: true) { - if KingfisherManager.shared.cache.isCached(forKey: key) { - return await retrieveImage(key) - } + for key in url.imageCacheKeys(includeStableAlias: true) + where KingfisherManager.shared.cache.isCached(forKey: key) { + return await retrieveImage(key) } return await downloadImage(url) } diff --git a/EhPanda/App/Tools/Parser.swift b/EhPanda/App/Tools/Parser.swift index 2afd68065..2129a2411 100644 --- a/EhPanda/App/Tools/Parser.swift +++ b/EhPanda/App/Tools/Parser.swift @@ -1858,7 +1858,9 @@ extension Parser { // JDownloader matches these image-limit texts to distinguish quota exhaustion from generic HTML failures. // Reference: https://github.com/mirror/jdownloader/blob/master/src/jd/plugins/hoster/EHentaiOrg.java if normalizedContent.contains("you have exceeded your image viewing limits") - || normalizedContent.contains("you have reached the image limit, and do not have sufficient gp to buy a download quota") + || normalizedContent.contains( + "you have reached the image limit, and do not have sufficient gp to buy a download quota" + ) { return .quotaExceeded } diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index aa9dbe121..d50d150ed 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -20,7 +20,7 @@ struct AppReducer { var downloadsState = DownloadsReducer.State() var settingState = SettingReducer.State() var didRunLaunchAutomation = false - var isWaitingForIgneousBeforeLaunchAutomation = false + var isAwaitingIgneousForLaunchAutomation = false } enum Action: BindableAction { @@ -222,19 +222,19 @@ struct AppReducer { if state.settingState.setting.detectsLinksFromClipboard { effects.append(.send(.appRoute(.detectClipboardURL))) } - state.isWaitingForIgneousBeforeLaunchAutomation = shouldDelayLaunchAutomationUntilIgneous( + state.isAwaitingIgneousForLaunchAutomation = shouldDelayLaunchAutomationUntilIgneous( state: state ) - if !state.isWaitingForIgneousBeforeLaunchAutomation { + if !state.isAwaitingIgneousForLaunchAutomation { effects.append(.send(.runLaunchAutomation)) } return effects.isEmpty ? .none : .merge(effects) case .setting(.account(.loadCookies)): - guard state.isWaitingForIgneousBeforeLaunchAutomation, + guard state.isAwaitingIgneousForLaunchAutomation, !shouldDelayLaunchAutomationUntilIgneous(state: state) else { return .none } - state.isWaitingForIgneousBeforeLaunchAutomation = false + state.isAwaitingIgneousForLaunchAutomation = false return .send(.runLaunchAutomation) case .setting(.fetchGreetingDone(let result)): diff --git a/EhPanda/Models/Gallery/GalleryDetail.swift b/EhPanda/Models/Gallery/GalleryDetail.swift index a6cf99284..eccd93aff 100644 --- a/EhPanda/Models/Gallery/GalleryDetail.swift +++ b/EhPanda/Models/Gallery/GalleryDetail.swift @@ -80,6 +80,7 @@ extension GalleryDetail: DateFormattable { enum GalleryVisibility: Codable, Equatable { case yes + // swiftlint:disable:next identifier_name case no(reason: String) } diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index f19fbf192..b59199ea4 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -391,43 +391,43 @@ struct GalleryDetailRequest: Request { } } -private struct GalleryVersionMetadataAPIResponse: Decodable { - struct GalleryMetadata: Decodable { - let gid: Int - let token: String - let currentGID: Int? - let currentKey: String? - let parentGID: Int? - let parentKey: String? - let firstGID: Int? - let firstKey: String? - - enum CodingKeys: String, CodingKey { - case gid - case token - case currentGID = "current_gid" - case currentKey = "current_key" - case parentGID = "parent_gid" - case parentKey = "parent_key" - case firstGID = "first_gid" - case firstKey = "first_key" - } +private struct GalleryVersionMetadata: Decodable { + let gid: Int + let token: String + let currentGID: Int? + let currentKey: String? + let parentGID: Int? + let parentKey: String? + let firstGID: Int? + let firstKey: String? + + enum CodingKeys: String, CodingKey { + case gid + case token + case currentGID = "current_gid" + case currentKey = "current_key" + case parentGID = "parent_gid" + case parentKey = "parent_key" + case firstGID = "first_gid" + case firstKey = "first_key" + } - var versionMetadata: DownloadVersionMetadata { - DownloadVersionMetadata( - gid: String(gid), - token: token, - currentGID: currentGID.map(String.init), - currentKey: currentKey, - parentGID: parentGID.map(String.init), - parentKey: parentKey, - firstGID: firstGID.map(String.init), - firstKey: firstKey - ) - } + var versionMetadata: DownloadVersionMetadata { + DownloadVersionMetadata( + gid: String(gid), + token: token, + currentGID: currentGID.map(String.init), + currentKey: currentKey, + parentGID: parentGID.map(String.init), + parentKey: parentKey, + firstGID: firstGID.map(String.init), + firstKey: firstKey + ) } +} - let gmetadata: [GalleryMetadata] +private struct GalleryVersionMetadataAPIResponse: Decodable { + let gmetadata: [GalleryVersionMetadata] } struct GalleryVersionMetadataRequest: Request { diff --git a/EhPanda/View/Reading/Support/GestureHandler.swift b/EhPanda/View/Reading/Support/GestureHandler.swift index c3b88c58e..9f87d2624 100644 --- a/EhPanda/View/Reading/Support/GestureHandler.swift +++ b/EhPanda/View/Reading/Support/GestureHandler.swift @@ -12,26 +12,26 @@ final class GestureHandler: ObservableObject { @Published private var baseScale: Double = 1 @Published private var newOffset: CGSize = .zero - private func edgeWidth(x: Double) -> Double { + private func edgeWidth(xAxis: Double) -> Double { let marginW = DeviceUtil.absWindowW * (scale - 1) / 2 let leadingMargin = scaleAnchor.x / 0.5 * marginW let trailingMargin = (1 - scaleAnchor.x) / 0.5 * marginW - return min(max(x, -trailingMargin), leadingMargin) + return min(max(xAxis, -trailingMargin), leadingMargin) } - private func edgeHeight(y: Double) -> Double { + private func edgeHeight(yAxis: Double) -> Double { let marginH = DeviceUtil.absWindowH * (scale - 1) / 2 let topMargin = scaleAnchor.y / 0.5 * marginH let bottomMargin = (1 - scaleAnchor.y) / 0.5 * marginH - return min(max(y, -bottomMargin), topMargin) + return min(max(yAxis, -bottomMargin), topMargin) } private func correctOffset() { - offset.width = edgeWidth(x: offset.width) - offset.height = edgeHeight(y: offset.height) + offset.width = edgeWidth(xAxis: offset.width) + offset.height = edgeHeight(yAxis: offset.height) } private func correctScaleAnchor(point: CGPoint) { - let x = min(1, max(0, point.x / DeviceUtil.absWindowW)) - let y = min(1, max(0, point.y / DeviceUtil.absWindowH)) - scaleAnchor = .init(x: x, y: y) + let xAxis = min(1, max(0, point.x / DeviceUtil.absWindowW)) + let yAxis = min(1, max(0, point.y / DeviceUtil.absWindowH)) + scaleAnchor = .init(x: xAxis, y: yAxis) } private func setOffset(_ offset: CGSize) { self.offset = offset @@ -106,8 +106,8 @@ final class GestureHandler: ObservableObject { guard scale > 1 else { return } let newX = value.translation.width + newOffset.width let newY = value.translation.height + newOffset.height - let newOffsetW = edgeWidth(x: newX) - let newOffsetH = edgeHeight(y: newY) + let newOffsetW = edgeWidth(xAxis: newX) + let newOffsetH = edgeHeight(yAxis: newY) setOffset(.init(width: newOffsetW, height: newOffsetH)) } diff --git a/EhPanda/View/Setting/SettingReducer.swift b/EhPanda/View/Setting/SettingReducer.swift index 3b6b5f6f6..9ef13645b 100644 --- a/EhPanda/View/Setting/SettingReducer.swift +++ b/EhPanda/View/Setting/SettingReducer.swift @@ -119,7 +119,9 @@ struct SettingReducer { .onChange(of: \.setting.galleryHost) { _, state in .merge( .send(.syncSetting), - .run(operation: { [value = state.setting.galleryHost.rawValue] _ in userDefaultsClient.setValue(value, .galleryHost) }) + .run(operation: { [value = state.setting.galleryHost.rawValue] _ in + userDefaultsClient.setValue(value, .galleryHost) + }) ) } .onChange(of: \.setting.enablesTagsExtension) { _, state in diff --git a/EhPandaTests/Models/ListParserTestType.swift b/EhPandaTests/Models/ListParserTestType.swift index 657e079a8..1f326dce4 100644 --- a/EhPandaTests/Models/ListParserTestType.swift +++ b/EhPandaTests/Models/ListParserTestType.swift @@ -64,13 +64,17 @@ extension ListParserTestType { } var assertCount: Int { switch self { - case .frontPageMinimalList, .frontPageMinimalPlusList, .frontPageCompactList, .frontPageExtendedList, .frontPageThumbnailList: + case .frontPageMinimalList, .frontPageMinimalPlusList, .frontPageCompactList, + .frontPageExtendedList, .frontPageThumbnailList: return 100 - case .watchedMinimalList, .watchedMinimalPlusList, .watchedCompactList, .watchedExtendedList, .watchedThumbnailList: + case .watchedMinimalList, .watchedMinimalPlusList, .watchedCompactList, + .watchedExtendedList, .watchedThumbnailList: return 100 - case .popularMinimalList, .popularMinimalPlusList, .popularCompactList, .popularExtendedList, .popularThumbnailList: + case .popularMinimalList, .popularMinimalPlusList, .popularCompactList, + .popularExtendedList, .popularThumbnailList: return 100 - case .favoritesMinimalList, .favoritesMinimalPlusList, .favoritesCompactList, .favoritesExtendedList, .favoritesThumbnailList: + case .favoritesMinimalList, .favoritesMinimalPlusList, .favoritesCompactList, + .favoritesExtendedList, .favoritesThumbnailList: return 100 case .toplistsCompactList: return 50 diff --git a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift index 2290b858b..2d081ec7b 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift @@ -746,7 +746,7 @@ struct DownloadFeatureReducerTests: TestHelper { await store.send(.setting(.loadUserSettingsDone)) #expect(store.state.didRunLaunchAutomation == false) - #expect(store.state.isWaitingForIgneousBeforeLaunchAutomation) + #expect(store.state.isAwaitingIgneousForLaunchAutomation) let response = try #require(HTTPURLResponse( url: Defaults.URL.exhentai, @@ -759,7 +759,7 @@ struct DownloadFeatureReducerTests: TestHelper { await store.send(.setting(.fetchIgneousDone(.success(response)))) await store.receive(\.runLaunchAutomation) { $0.didRunLaunchAutomation = true - $0.isWaitingForIgneousBeforeLaunchAutomation = false + $0.isAwaitingIgneousForLaunchAutomation = false } } @@ -803,12 +803,12 @@ struct DownloadFeatureReducerTests: TestHelper { await store.send(.setting(.loadUserSettingsDone)) #expect(store.state.didRunLaunchAutomation == false) - #expect(store.state.isWaitingForIgneousBeforeLaunchAutomation) + #expect(store.state.isAwaitingIgneousForLaunchAutomation) await store.send(.setting(.fetchIgneousDone(.failure(.networkingFailed)))) await store.receive(\.setting.account.loadCookies) #expect(store.state.didRunLaunchAutomation == false) - #expect(store.state.isWaitingForIgneousBeforeLaunchAutomation) + #expect(store.state.isAwaitingIgneousForLaunchAutomation) } @MainActor @@ -1793,7 +1793,7 @@ struct DownloadFeatureReducerTests: TestHelper { } let imageData = try #require(image.pngData()) - KingfisherManager.shared.cache.store(image, original: imageData, forKey: stableCacheKey) + try await KingfisherManager.shared.cache.store(image, original: imageData, forKey: stableCacheKey) defer { KingfisherManager.shared.cache.removeImage(forKey: stableCacheKey) KingfisherManager.shared.cache.removeImage(forKey: url.absoluteString) @@ -2795,7 +2795,7 @@ struct DownloadFeatureReducerTests: TestHelper { + [currentPageImageURL, staleStoredPageURL, coverURL] let cachedKeys = Set(cachedURLs.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) for cacheKey in cachedKeys { - KingfisherManager.shared.cache.storeToDisk(cachedImageData, forKey: cacheKey) + try await KingfisherManager.shared.cache.storeToDisk(cachedImageData, forKey: cacheKey) } defer { cachedKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } @@ -3968,7 +3968,7 @@ struct DownloadFeatureReducerTests: TestHelper { } let imageData = try #require(image.jpegData(compressionQuality: 1)) let cacheKey = try #require(imageURL.stableImageCacheKey) - KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) + try await KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) defer { KingfisherManager.shared.cache.removeImage(forKey: cacheKey) KingfisherManager.shared.cache.removeImage(forKey: imageURL.absoluteString) @@ -4037,7 +4037,7 @@ struct DownloadFeatureReducerTests: TestHelper { } let imageData = try #require(image.jpegData(compressionQuality: 1)) let cacheKey = try #require(imageURL.stableImageCacheKey) - KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) + try await KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) defer { KingfisherManager.shared.cache.removeImage(forKey: cacheKey) KingfisherManager.shared.cache.removeImage(forKey: imageURL.absoluteString) @@ -4102,7 +4102,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test - func testUpdateRemoteSignatureDoesNotMarkUpdateAvailableWhenStoredChainAndLatestHashAreDifferentKinds() async throws { + func testUpdateRemoteSignatureSkipsUpdateWhenStoredChainAndLatestHashDiffer() async throws { let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 101) @@ -4134,7 +4134,7 @@ struct DownloadFeatureReducerTests: TestHelper { @MainActor @Test - func testUpdateRemoteSignatureDoesNotMarkUpdateAvailableWhenStoredHashAndLatestNonOriginalChainAreDifferentKinds() async throws { + func testUpdateRemoteSignatureSkipsUpdateWhenStoredHashAndLatestNonOriginalChainDiffer() async throws { let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 102) @@ -4678,7 +4678,9 @@ struct DownloadFeatureReducerTests: TestHelper { defer { try? FileManager.default.removeItem(at: fileURL) } let manager = makeTestingDownloadManager() - let normalImageURL = try #require(URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1&key=normal-cache-key")) + let normalImageURL = try #require( + URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1&key=normal-cache-key") + ) let error = await manager.testingDetectResponseError( fileURL: fileURL, response: try makeResponse( @@ -4699,9 +4701,9 @@ struct DownloadFeatureReducerTests: TestHelper { .appendingPathExtension("html") defer { try? FileManager.default.removeItem(at: fileURL) } - let htmlData = try #require(""" + let htmlData = Data(""" You have exceeded your image viewing limits - """.data(using: .utf8)) + """.utf8) try htmlData.write(to: fileURL, options: .atomic) let manager = makeTestingDownloadManager() @@ -4741,7 +4743,7 @@ struct DownloadFeatureReducerTests: TestHelper { let placeholderData = try Data(contentsOf: placeholderURL) let cacheKeys = normalImageURL.imageCacheKeys(includeStableAlias: true) for cacheKey in cacheKeys { - KingfisherManager.shared.cache.storeToDisk(placeholderData, forKey: cacheKey) + try await KingfisherManager.shared.cache.storeToDisk(placeholderData, forKey: cacheKey) } defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } @@ -4760,7 +4762,7 @@ struct DownloadFeatureReducerTests: TestHelper { pageCount: 1, postedDate: .now, coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")) + galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token") as URL?) ), galleryDetail: GalleryDetail( gid: gid, @@ -4809,13 +4811,15 @@ struct DownloadFeatureReducerTests: TestHelper { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) - let normalImageURL = try #require(URL(string: "https://exhentai.org/fullimg.php?gid=\(gid)&page=1&key=normal-cache-key")) + let normalImageURL = try #require( + URL(string: "https://exhentai.org/fullimg.php?gid=\(gid)&page=1&key=normal-cache-key") + ) try insertPersistedGalleryState(in: container, gid: gid, imageURLs: [1: normalImageURL]) let imageData = try fixtureData(resource: "Kokomade", pathExtension: "jpg") let cacheKeys = normalImageURL.imageCacheKeys(includeStableAlias: true) for cacheKey in cacheKeys { - KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) + try await KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) } defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } @@ -4834,7 +4838,7 @@ struct DownloadFeatureReducerTests: TestHelper { pageCount: 1, postedDate: .now, coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: try #require(URL(string: "https://exhentai.org/g/\(gid)/token")) + galleryURL: try #require(URL(string: "https://exhentai.org/g/\(gid)/token") as URL?) ), galleryDetail: GalleryDetail( gid: gid, @@ -4906,7 +4910,7 @@ struct DownloadFeatureReducerTests: TestHelper { .appendingPathExtension("html") defer { try? FileManager.default.removeItem(at: fileURL) } - let authHTMLData = try #require(""" + let authHTMLData = Data(""" Login @@ -4914,7 +4918,7 @@ struct DownloadFeatureReducerTests: TestHelper {

Access to ExHentai.org is restricted.

- """.data(using: .utf8)) + """.utf8) try authHTMLData.write(to: fileURL, options: .atomic) let manager = makeTestingDownloadManager() @@ -4938,9 +4942,9 @@ struct DownloadFeatureReducerTests: TestHelper { .appendingPathExtension("html") defer { try? FileManager.default.removeItem(at: fileURL) } - let invalidPageData = try #require(""" + let invalidPageData = Data("""

Invalid page

Gallery not found

- """.data(using: .utf8)) + """.utf8) try invalidPageData.write(to: fileURL, options: .atomic) let manager = makeTestingDownloadManager() @@ -4965,8 +4969,8 @@ struct DownloadFeatureReducerTests: TestHelper { .appendingPathExtension("html") defer { try? FileManager.default.removeItem(at: fileURL) } - let keepTryingData = try #require( - "

Keep trying

".data(using: .utf8) + let keepTryingData = Data( + "

Keep trying

".utf8 ) try keepTryingData.write(to: fileURL, options: .atomic) @@ -5017,12 +5021,12 @@ struct DownloadFeatureReducerTests: TestHelper { .appendingPathExtension("html") defer { try? FileManager.default.removeItem(at: fileURL) } - let galleryNotAvailableData = try #require(""" + let galleryNotAvailableData = Data(""" Gallery Not Available

Gallery Not Available

- """.data(using: .utf8)) + """.utf8) try galleryNotAvailableData.write(to: fileURL, options: .atomic) let manager = makeTestingDownloadManager() @@ -5546,7 +5550,7 @@ struct DownloadFeatureReducerTests: TestHelper { try insertPersistedGalleryState(in: container, gid: gid, imageURLs: imageURLs) let cacheKeys = Set(imageURLs.values.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) for cacheKey in cacheKeys { - KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) + try await KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) } defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } @@ -5578,7 +5582,7 @@ struct DownloadFeatureReducerTests: TestHelper { pageCount: pageCount, postedDate: .now, coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")) + galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token") as URL?) ), galleryDetail: GalleryDetail( gid: gid, @@ -6138,11 +6142,11 @@ private func requestBodyData(from request: URLRequest) -> Data? { } private final class FailFastURLProtocol: URLProtocol { - override class func canInit(with request: URLRequest) -> Bool { + override static func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { + override static func canonicalRequest(for request: URLRequest) -> URLRequest { request } @@ -6185,11 +6189,11 @@ private final class SharedSessionStubURLProtocol: URLProtocol { return handlers[sessionID] } - override class func canInit(with request: URLRequest) -> Bool { + override static func canInit(with request: URLRequest) -> Bool { handler(for: request) != nil } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { + override static func canonicalRequest(for request: URLRequest) -> URLRequest { request } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 8c9bda98a..2d08e9bb7 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -388,7 +388,7 @@ struct DownloadFileStorageTests { withIntermediateDirectories: true ) - let manifest = try DownloadManifest( + let manifest = DownloadManifest( gid: "123", host: .ehentai, token: "token", @@ -531,7 +531,7 @@ private extension DownloadFileStorageTests { } func sampleManifest(pageCount: Int) throws -> DownloadManifest { - try DownloadManifest( + DownloadManifest( gid: "123", host: .ehentai, token: "token", diff --git a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift index a48442409..89f1d9fb5 100644 --- a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift @@ -15,7 +15,10 @@ struct DownloadSignatureBuilderTests { detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")) + 1: try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" + )) ], versionMetadata: .init( gid: "1394965", @@ -135,8 +138,14 @@ struct DownloadSignatureBuilderTests { detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")), - 2: try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200")) + 1: try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" + )), + 2: try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200" + )) ] ) @@ -145,8 +154,14 @@ struct DownloadSignatureBuilderTests { detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: try #require(URL(string: "https://beta.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0")), - 2: try #require(URL(string: "https://beta.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=250")) + 1: try #require(URL( + string: "https://beta.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0" + )), + 2: try #require(URL( + string: "https://beta.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=250" + )) ] ) @@ -160,7 +175,10 @@ struct DownloadSignatureBuilderTests { detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")) + 1: try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" + )) ] ) @@ -169,7 +187,10 @@ struct DownloadSignatureBuilderTests { detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-1.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")) + 1: try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-1.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" + )) ] ) @@ -183,7 +204,10 @@ struct DownloadSignatureBuilderTests { detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")) + 1: try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" + )) ] ) @@ -192,7 +216,10 @@ struct DownloadSignatureBuilderTests { detail: sampleDetail, host: .ehentai, previewURLs: [ - 1: try #require(URL(string: "https://beta.hath.network/c2/token-b/1394965-0.webp?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0")) + 1: try #require(URL( + string: "https://beta.hath.network/c2/token-b/1394965-0.webp" + + "?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0" + )) ] ) @@ -267,8 +294,14 @@ struct DownloadSignatureBuilderTests { } @Test func testSignatureIsOrderIndependentForSamePreviewURLSet() throws { - let urlA = try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0")) - let urlB = try #require(URL(string: "https://alpha.hath.network/c2/token-a/1394965-0.webp?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200")) + let urlA = try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" + )) + let urlB = try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200" + )) let ascendingSignature = DownloadSignatureBuilder.make( gallery: sampleGallery, diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift index 4c6caca54..27450a53e 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift @@ -23,7 +23,10 @@ struct GalleryDetailParserTests: TestHelper { #expect(detail.category == .nonH) #expect(detail.language == .japanese) #expect(detail.uploader == "Pokom") - #expect(detail.coverURL?.absoluteString == "https://ehgt.org/03/08/0308268821e99628b05a19fa54e2fc0fa9ad8f4b-1705560-1012-1470-png_250.jpg") + #expect( + detail.coverURL?.absoluteString + == "https://ehgt.org/03/08/0308268821e99628b05a19fa54e2fc0fa9ad8f4b-1705560-1012-1470-png_250.jpg" + ) #expect(detail.archiveURL?.absoluteString == "https://e-hentai.org/archiver.php?gid=3103480&token=0000000000") #expect(detail.parentURL?.absoluteString == "https://e-hentai.org/g/2930572/daf4b9880d/") #expect(detail.favoritedCount == 591) diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift index d8efd1b91..ce6426150 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift @@ -19,7 +19,12 @@ struct GalleryImageURLParserTests: TestHelper { let inputIndex = 1 let (index, imageURL, originalImageURL) = try Parser.parseGalleryNormalImageURL(doc: doc, index: inputIndex) #expect(index == inputIndex) - #expect(imageURL.absoluteString == "https://akrtazd.spuqplybaxmf.hath.network:65000/h/ea42b28bceeae68f1f6adb414da61d186b3d126b-311480-1280-1920-jpg/keystamp=1694132700-fd778f8260;fileindex=132044713;xres=1280/87052610_5090394_0.jpg") + let expectedImageURL = + "https://akrtazd.spuqplybaxmf.hath.network:65000/h/" + + "ea42b28bceeae68f1f6adb414da61d186b3d126b-311480-1280-1920-jpg/" + + "keystamp=1694132700-fd778f8260;fileindex=132044713;xres=1280/" + + "87052610_5090394_0.jpg" + #expect(imageURL.absoluteString == expectedImageURL) #expect(originalImageURL?.absoluteString == "https://e-hentai.org/fullimg.php?gid=0000000&page=1&key=000000000") } func testSkipServerIdentifierParser(doc: HTMLDocument) throws { @@ -27,4 +32,3 @@ struct GalleryImageURLParserTests: TestHelper { #expect(identifier == "00000-000000") } } - diff --git a/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift b/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift index 895cd6554..219d7fa42 100644 --- a/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift @@ -46,7 +46,8 @@ struct DownloadPageErrorParserTests: TestHelper { func testNotFoundMarkersMapToNotFound() throws { let document = try Kanna.HTML( html: """ -

Invalid page

Gallery not found.

Key missing.

Keep trying.

+

Invalid page

Gallery not found.

+

Key missing.

Keep trying.

""", encoding: .utf8 ) diff --git a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift b/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift index b81e81ba2..0f37df6d4 100644 --- a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift @@ -19,7 +19,7 @@ struct EhSettingParserTests: TestHelper { func testEhProfiles(_ profiles: [EhProfile]) { #expect(profiles.count == 3) - + let ehProfile1 = profiles[0] #expect(ehProfile1.value == 1) #expect(ehProfile1.name == "Default Profile") diff --git a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift index 61eb529a2..fadf4e6b4 100644 --- a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -10,12 +10,12 @@ import Testing struct SettingDownloadTests { @Test func testLegacySettingDecodesDownloadDefaults() throws { - let data = try #require(""" + let data = Data(""" { "galleryHost": "E-Hentai", "showsNewDawnGreeting": true } - """.data(using: .utf8)) + """.utf8) let setting = try JSONDecoder().decode(Setting.self, from: data) @@ -42,14 +42,14 @@ struct SettingDownloadTests { @Test func testLegacyDownloadOptionsSnapshotDecodesWithoutOriginalImageField() throws { - let data = try #require(""" + let data = Data(""" { "threadMode": "triple", "useOriginalImages": true, "allowCellular": false, "autoRetryFailedPages": false } - """.data(using: .utf8)) + """.utf8) let snapshot = try JSONDecoder().decode(DownloadOptionsSnapshot.self, from: data) From 67c045db4950e7d91deb9887639c5298e2da23db Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 28 Mar 2026 21:16:50 +0800 Subject: [PATCH 012/614] Update project settings --- EhPanda.xcodeproj/project.pbxproj | 1817 +---------------- .../xcshareddata/xcschemes/EhPanda.xcscheme | 2 +- 2 files changed, 56 insertions(+), 1763 deletions(-) diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index ea93c52b1..bcb95896c 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -7,263 +7,22 @@ objects = { /* Begin PBXBuildFile section */ - AB0929B6277F043D00F107CA /* AccountSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */; }; - AB0929BE2780032400F107CA /* EhSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BD2780032400F107CA /* EhSettingReducer.swift */; }; - AB0929C027805A8200F107CA /* LoginReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BF27805A8200F107CA /* LoginReducer.swift */; }; - AB0929C6278160AE00F107CA /* LibraryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C5278160AE00F107CA /* LibraryClient.swift */; }; - AB0929C82781938A00F107CA /* DFClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C72781938A00F107CA /* DFClient.swift */; }; - AB0929CA278196ED00F107CA /* CookieClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C9278196ED00F107CA /* CookieClient.swift */; }; - AB0929CC2781A0B000F107CA /* HapticsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929CB2781A0B000F107CA /* HapticsClient.swift */; }; - AB0929CE2781AADA00F107CA /* DatabaseClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929CD2781AADA00F107CA /* DatabaseClient.swift */; }; - AB0929D02781E1CC00F107CA /* UIApplicationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929CF2781E1CC00F107CA /* UIApplicationClient.swift */; }; - AB0929D22781E7D500F107CA /* LoggerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D12781E7D500F107CA /* LoggerClient.swift */; }; - AB0929D42781EDDC00F107CA /* UserDefaultsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D32781EDDC00F107CA /* UserDefaultsClient.swift */; }; - AB0929D62782A65F00F107CA /* GeneralSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D52782A65F00F107CA /* GeneralSettingReducer.swift */; }; - AB0929D82782A83A00F107CA /* AuthorizationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D72782A83A00F107CA /* AuthorizationClient.swift */; }; - AB0ABCB526C5406400AD970F /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0ABCB426C5406400AD970F /* LoginView.swift */; }; - AB0ABCB726C541A400AD970F /* WaveForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0ABCB626C541A400AD970F /* WaveForm.swift */; }; - AB0CFB7427BAB9D0004BD372 /* AppIcon_Default@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB6527BAB9CF004BD372 /* AppIcon_Default@3x.png */; }; - AB0CFB7527BAB9D0004BD372 /* AppIcon_Default_iPad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB6627BAB9CF004BD372 /* AppIcon_Default_iPad@2x.png */; }; - AB0CFB7827BAB9D0004BD372 /* AppIcon_Default_iPad_Pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB6927BAB9CF004BD372 /* AppIcon_Default_iPad_Pro@2x.png */; }; - AB0CFB7A27BAB9D0004BD372 /* AppIcon_Default@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB6B27BAB9CF004BD372 /* AppIcon_Default@2x.png */; }; - AB0CFB7B27BAB9D0004BD372 /* AppIcon_Default_iPad.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB6C27BAB9CF004BD372 /* AppIcon_Default_iPad.png */; }; - AB0CFB8027BBBFA0004BD372 /* EhSetting.html in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB7F27BBBFA0004BD372 /* EhSetting.html */; }; - AB0CFB8227BBBFCE004BD372 /* EhSettingParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0CFB8127BBBFCE004BD372 /* EhSettingParserTests.swift */; }; - AB0CFB8827BBD2D7004BD372 /* AppIcon_Developer_iPad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8327BBD2D7004BD372 /* AppIcon_Developer_iPad@2x.png */; }; - AB0CFB8927BBD2D7004BD372 /* AppIcon_Developer@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8427BBD2D7004BD372 /* AppIcon_Developer@2x.png */; }; - AB0CFB8A27BBD2D7004BD372 /* AppIcon_Developer_iPad.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8527BBD2D7004BD372 /* AppIcon_Developer_iPad.png */; }; - AB0CFB8B27BBD2D7004BD372 /* AppIcon_Developer_iPad_Pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8627BBD2D7004BD372 /* AppIcon_Developer_iPad_Pro@2x.png */; }; - AB0CFB8C27BBD2D7004BD372 /* AppIcon_Developer@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8727BBD2D7004BD372 /* AppIcon_Developer@3x.png */; }; - AB0CFB9227BBD323004BD372 /* AppIcon_Ukiyoe_iPad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8D27BBD323004BD372 /* AppIcon_Ukiyoe_iPad@2x.png */; }; - AB0CFB9327BBD323004BD372 /* AppIcon_Ukiyoe@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8E27BBD323004BD372 /* AppIcon_Ukiyoe@3x.png */; }; - AB0CFB9427BBD323004BD372 /* AppIcon_Ukiyoe_iPad_Pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8F27BBD323004BD372 /* AppIcon_Ukiyoe_iPad_Pro@2x.png */; }; - AB0CFB9527BBD323004BD372 /* AppIcon_Ukiyoe_iPad.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB9027BBD323004BD372 /* AppIcon_Ukiyoe_iPad.png */; }; - AB0CFB9627BBD323004BD372 /* AppIcon_Ukiyoe@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB9127BBD323004BD372 /* AppIcon_Ukiyoe@2x.png */; }; - AB0CFBC927C07F95004BD372 /* TagSuggestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0CFBC827C07F95004BD372 /* TagSuggestionView.swift */; }; - AB0CFBCB27C0B07F004BD372 /* TagSuggestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0CFBCA27C0B07F004BD372 /* TagSuggestion.swift */; }; - AB0CFBCD27C1CC67004BD372 /* EhTagTranslationDatabaseModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0CFBCC27C1CC67004BD372 /* EhTagTranslationDatabaseModel.swift */; }; - AB0CFBD527C24B3B004BD372 /* MarkdownUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0CFBD427C24B3B004BD372 /* MarkdownUtil.swift */; }; - AB0CFBD727C3B2D0004BD372 /* TagDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0CFBD627C3B2D0004BD372 /* TagDetailView.swift */; }; - AB10117E26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10117D26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift */; }; - AB10118026986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10117F26986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift */; }; AB17573D27675B1E00FD64E2 /* Colorful in Frameworks */ = {isa = PBXBuildFile; productRef = AB17573C27675B1E00FD64E2 /* Colorful */; }; AB17574027678B3400FD64E2 /* UIImageColors in Frameworks */ = {isa = PBXBuildFile; productRef = AB17573F27678B3400FD64E2 /* UIImageColors */; }; - AB1EF25427AFA19200F507D6 /* Heap.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1EF25327AFA19200F507D6 /* Heap.swift */; }; - AB1FA8FC27C5E0E50063EF55 /* TagDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1FA8FB27C5E0E50063EF55 /* TagDetail.swift */; }; AB1FA94927C62BC80063EF55 /* CommonMark in Frameworks */ = {isa = PBXBuildFile; productRef = AB1FA94827C62BC80063EF55 /* CommonMark */; }; - AB1FA94D27CA1F140063EF55 /* TagTranslation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1FA94C27CA1F140063EF55 /* TagTranslation.swift */; }; - AB24C55A27674EDF0085C33A /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB24C55927674EDF0085C33A /* FavoritesView.swift */; }; - AB24C55C2767565A0085C33A /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB24C55B2767565A0085C33A /* HomeView.swift */; }; - AB24C566276758E30085C33A /* GalleryCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB24C565276758E30085C33A /* GalleryCardCell.swift */; }; - AB26F59027ABF21000AB3468 /* Model5toModel6.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = AB26F58F27ABF21000AB3468 /* Model5toModel6.xcmappingmodel */; }; - AB26F59427ACC6CD00AB3468 /* TagTranslator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB26F59327ACC6CD00AB3468 /* TagTranslator.swift */; }; - AB26F59627ACCA1800AB3468 /* AppEnv.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB26F59527ACCA1800AB3468 /* AppEnv.swift */; }; AB26F59927ACDB4200AB3468 /* FilePicker in Frameworks */ = {isa = PBXBuildFile; productRef = AB26F59827ACDB4200AB3468 /* FilePicker */; }; - AB2CED64268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2CED63268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift */; }; AB2EB99F280251D600011A8A /* TTProgressHUD in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB99E280251D600011A8A /* TTProgressHUD */; }; AB2EB9A2280251F600011A8A /* AlertKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB9A1280251F600011A8A /* AlertKit */; }; AB2EB9A52802521700011A8A /* DeprecatedAPI in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB9A42802521700011A8A /* DeprecatedAPI */; }; - AB3072D2276D734800EFF242 /* SubSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3072D1276D734800EFF242 /* SubSection.swift */; }; - AB3072D4276E19AA00EFF242 /* FrontpageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3072D3276E19AA00EFF242 /* FrontpageView.swift */; }; - AB31CD3027B666E200F40E0A /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD2F27B666E200F40E0A /* TestError.swift */; }; - AB31CD3227B6671400F40E0A /* BanIntervalParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD3127B6671400F40E0A /* BanIntervalParserTests.swift */; }; - AB31CD3727B6695800F40E0A /* HTMLFilename.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD3627B6695800F40E0A /* HTMLFilename.swift */; }; - AB31CD3B27B66E0300F40E0A /* ListParserTestType.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD3A27B66E0300F40E0A /* ListParserTestType.swift */; }; - AB31CD3D27B66F7D00F40E0A /* GalleryImageURLParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD3C27B66F7D00F40E0A /* GalleryImageURLParserTests.swift */; }; - AB31CD3F27B670FD00F40E0A /* GalleryNormalImageURL.html in Resources */ = {isa = PBXBuildFile; fileRef = AB31CD3E27B670FD00F40E0A /* GalleryNormalImageURL.html */; }; - AB31CD4127B6769F00F40E0A /* GalleryMPVKeys.html in Resources */ = {isa = PBXBuildFile; fileRef = AB31CD4027B6769F00F40E0A /* GalleryMPVKeys.html */; }; - AB31CD4327B676C300F40E0A /* GalleryMPVKeysParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD4227B676C300F40E0A /* GalleryMPVKeysParserTests.swift */; }; - AB358311269D7B63009466A5 /* DFURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB358310269D7B63009466A5 /* DFURLProtocol.swift */; }; - AB358313269D7E89009466A5 /* DFRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB358312269D7E89009466A5 /* DFRequest.swift */; }; - AB358315269D821D009466A5 /* DFExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB358314269D821D009466A5 /* DFExtensions.swift */; }; - AB358317269D826B009466A5 /* DFStreamHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB358316269D826B009466A5 /* DFStreamHandler.swift */; }; - AB358319269D9996009466A5 /* DomainResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB358318269D9996009466A5 /* DomainResolver.swift */; }; - AB38A0CB25CA993D00764D64 /* ColorCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB38A0CA25CA993D00764D64 /* ColorCodable.swift */; }; - AB3E9E6E26D210B1008FE518 /* GalleryDetail.html in Resources */ = {isa = PBXBuildFile; fileRef = AB3E9E6526D210B1008FE518 /* GalleryDetail.html */; }; - AB3E9E7426D210B1008FE518 /* TestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3E9E6D26D210B1008FE518 /* TestHelper.swift */; }; - AB41DB3D27B760D700DD3604 /* WatchedCompactList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2827B760D600DD3604 /* WatchedCompactList.html */; }; - AB41DB3E27B760D700DD3604 /* PopularThumbnailList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2927B760D600DD3604 /* PopularThumbnailList.html */; }; - AB41DB3F27B760D700DD3604 /* FrontPageExtendedList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2A27B760D600DD3604 /* FrontPageExtendedList.html */; }; - AB41DB4027B760D700DD3604 /* FrontPageMinimalPlusList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2B27B760D600DD3604 /* FrontPageMinimalPlusList.html */; }; - AB41DB4127B760D700DD3604 /* PopularExtendedList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2C27B760D600DD3604 /* PopularExtendedList.html */; }; - AB41DB4227B760D700DD3604 /* FavoritesThumbnailList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2D27B760D700DD3604 /* FavoritesThumbnailList.html */; }; - AB41DB4327B760D700DD3604 /* ToplistsCompactList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2E27B760D700DD3604 /* ToplistsCompactList.html */; }; - AB41DB4427B760D700DD3604 /* FavoritesExtendedList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2F27B760D700DD3604 /* FavoritesExtendedList.html */; }; - AB41DB4527B760D700DD3604 /* FrontPageMinimalList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3027B760D700DD3604 /* FrontPageMinimalList.html */; }; - AB41DB4627B760D700DD3604 /* FrontPageCompactList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3127B760D700DD3604 /* FrontPageCompactList.html */; }; - AB41DB4727B760D700DD3604 /* PopularMinimalPlusList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3227B760D700DD3604 /* PopularMinimalPlusList.html */; }; - AB41DB4827B760D700DD3604 /* WatchedExtendedList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3327B760D700DD3604 /* WatchedExtendedList.html */; }; - AB41DB4927B760D700DD3604 /* FavoritesMinimalPlusList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3427B760D700DD3604 /* FavoritesMinimalPlusList.html */; }; - AB41DB4A27B760D700DD3604 /* WatchedThumbnailList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3527B760D700DD3604 /* WatchedThumbnailList.html */; }; - AB41DB4B27B760D700DD3604 /* FrontPageThumbnailList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3627B760D700DD3604 /* FrontPageThumbnailList.html */; }; - AB41DB4C27B760D700DD3604 /* FavoritesCompactList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3727B760D700DD3604 /* FavoritesCompactList.html */; }; - AB41DB4D27B760D700DD3604 /* FavoritesMinimalList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3827B760D700DD3604 /* FavoritesMinimalList.html */; }; - AB41DB4E27B760D700DD3604 /* WatchedMinimalPlusList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3927B760D700DD3604 /* WatchedMinimalPlusList.html */; }; - AB41DB4F27B760D700DD3604 /* WatchedMinimalList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3A27B760D700DD3604 /* WatchedMinimalList.html */; }; - AB41DB5027B760D700DD3604 /* PopularCompactList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3B27B760D700DD3604 /* PopularCompactList.html */; }; - AB41DB5127B760D700DD3604 /* PopularMinimalList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3C27B760D700DD3604 /* PopularMinimalList.html */; }; - AB4FD2C1268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4FD2C0268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift */; }; - AB58A5AC2776B2BC00C0D285 /* AppDelegateReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB58A5AB2776B2BC00C0D285 /* AppDelegateReducer.swift */; }; - AB58A5B22776B99000C0D285 /* AppReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB58A5B12776B99000C0D285 /* AppReducer.swift */; }; - AB5BE67926B95FDD007D4A55 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB5BE67826B95FDD007D4A55 /* ShareViewController.swift */; }; AB5BE68026B95FDD007D4A55 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AB60D0E9274C7ECE00F899AB /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = AB60D0E8274C7ECE00F899AB /* WaterfallGrid */; }; - AB63EADB2699AC8200090535 /* AppEnvMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB63EADA2699AC8200090535 /* AppEnvMO+CoreDataProperties.swift */; }; - AB63EADD2699AC9100090535 /* AppEnvMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB63EADC2699AC9100090535 /* AppEnvMO+CoreDataClass.swift */; }; AB6505A026B0027800F91E9D /* SwiftUIPager in Frameworks */ = {isa = PBXBuildFile; productRef = AB65059F26B0027800F91E9D /* SwiftUIPager */; }; - AB69CB8026B3DABC00699359 /* AdvancedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB69CB7F26B3DABC00699359 /* AdvancedList.swift */; }; - AB69CB8226B3DAF400699359 /* ControlPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB69CB8126B3DAF400699359 /* ControlPanel.swift */; }; - AB6DE897268822390087C579 /* LogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6DE896268822390087C579 /* LogsView.swift */; }; - AB706F7927890A6C0025A48A /* AppRouteReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7827890A6C0025A48A /* AppRouteReducer.swift */; }; - AB706F7B278937500025A48A /* FrontpageReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7A278937500025A48A /* FrontpageReducer.swift */; }; - AB706F80278981370025A48A /* AlertKit_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7F278981370025A48A /* AlertKit_Extension.swift */; }; - AB706F82278986120025A48A /* ToolbarItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F81278986120025A48A /* ToolbarItems.swift */; }; - AB706F842789AD2D0025A48A /* ToplistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F832789AD2D0025A48A /* ToplistsView.swift */; }; - AB706F862789AD490025A48A /* ToplistsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F852789AD490025A48A /* ToplistsReducer.swift */; }; - AB706F88278A4C8A0025A48A /* PopularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F87278A4C8A0025A48A /* PopularView.swift */; }; - AB706F8A278A4CC50025A48A /* PopularReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F89278A4CC50025A48A /* PopularReducer.swift */; }; - AB706F8C278A4F6C0025A48A /* WatchedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F8B278A4F6C0025A48A /* WatchedView.swift */; }; - AB706F8E278A5DCF0025A48A /* DeviceClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F8D278A5DCF0025A48A /* DeviceClient.swift */; }; - AB706F90278A5F680025A48A /* AppDelegateClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F8F278A5F680025A48A /* AppDelegateClient.swift */; }; - AB706F92278A6E8C0025A48A /* WatchedReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F91278A6E8C0025A48A /* WatchedReducer.swift */; }; - AB706F95278A75D30025A48A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F94278A75D30025A48A /* HistoryView.swift */; }; - AB706F97278A77E20025A48A /* HistoryReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F96278A77E20025A48A /* HistoryReducer.swift */; }; - AB706F99278A820C0025A48A /* FiltersReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F98278A820C0025A48A /* FiltersReducer.swift */; }; - AB706F9B278AC5A30025A48A /* SearchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9A278AC5A30025A48A /* SearchRootView.swift */; }; - AB706F9D278ACCA20025A48A /* SearchRootReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9C278ACCA20025A48A /* SearchRootReducer.swift */; }; - AB706F9F278AD4800025A48A /* GalleryHistoryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9E278AD4800025A48A /* GalleryHistoryCell.swift */; }; - AB706FA1278BCEC60025A48A /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706FA0278BCEC60025A48A /* DetailView.swift */; }; - AB706FA3278BCF2F0025A48A /* DetailReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706FA2278BCF2F0025A48A /* DetailReducer.swift */; }; - AB706FA5278C3DDE0025A48A /* PreviewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706FA4278C3DDE0025A48A /* PreviewsView.swift */; }; - AB7B29F226AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7B29F126AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift */; }; - AB7B29F626AC741600EE1F14 /* GenericList.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7B29F526AC741600EE1F14 /* GenericList.swift */; }; - AB7BF2A927A63C89001865A3 /* Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2A827A63C89001865A3 /* Language.swift */; }; - AB7BF2AB27A642FB001865A3 /* BrowsingCountry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2AA27A642FB001865A3 /* BrowsingCountry.swift */; }; - AB7BF2B727A9652F001865A3 /* Greeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2B627A9652F001865A3 /* Greeting.swift */; }; - AB7BF2BA27A96562001865A3 /* Gallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2B927A96562001865A3 /* Gallery.swift */; }; - AB7BF2BC27A965DA001865A3 /* Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2BB27A965DA001865A3 /* Category.swift */; }; - AB7BF2C027A9669A001865A3 /* TagNamespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2BF27A9669A001865A3 /* TagNamespace.swift */; }; - AB7BF2C227A96760001865A3 /* GalleryDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2C127A96760001865A3 /* GalleryDetail.swift */; }; - AB7BF2C427A9683F001865A3 /* GalleryArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2C327A9683F001865A3 /* GalleryArchive.swift */; }; - AB7BF2C627A968AB001865A3 /* TranslatableLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2C527A968AB001865A3 /* TranslatableLanguage.swift */; }; - AB7BF2C827A968F7001865A3 /* GalleryComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2C727A968F7001865A3 /* GalleryComment.swift */; }; - AB7BF2CA27A969F4001865A3 /* GalleryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2C927A969F4001865A3 /* GalleryState.swift */; }; - AB7BF2CC27A96A3C001865A3 /* GalleryTorrent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2CB27A96A3C001865A3 /* GalleryTorrent.swift */; }; - AB7BF2CE27AA3E58001865A3 /* AppUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2CD27AA3E58001865A3 /* AppUtil.swift */; }; - AB7BF2D027AA3E75001865A3 /* DeviceUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2CF27AA3E75001865A3 /* DeviceUtil.swift */; }; - AB7BF2D227AA3EDC001865A3 /* HapticsUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D127AA3EDC001865A3 /* HapticsUtil.swift */; }; - AB7BF2D427AA3F12001865A3 /* CookieUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D327AA3F12001865A3 /* CookieUtil.swift */; }; - AB7BF2D627AA3F4C001865A3 /* FileUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D527AA3F4C001865A3 /* FileUtil.swift */; }; - AB7BF2D827AA3F61001865A3 /* UserDefaultsUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D727AA3F61001865A3 /* UserDefaultsUtil.swift */; }; - AB7BF2DA27AA78CF001865A3 /* Reducer_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D927AA78CF001865A3 /* Reducer_Extension.swift */; }; - AB7BF2FB27ABCA3A001865A3 /* MigrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FA27ABCA3A001865A3 /* MigrationView.swift */; }; - AB7BF2FD27ABCAD4001865A3 /* MigrationReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FC27ABCAD4001865A3 /* MigrationReducer.swift */; }; - AB7BF30727ABDFF1001865A3 /* CoreDataMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FE27ABDFF1001865A3 /* CoreDataMigrator.swift */; }; - AB7BF30A27ABDFF1001865A3 /* CoreDataMigrationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF30327ABDFF1001865A3 /* CoreDataMigrationStep.swift */; }; - AB7BF30D27ABDFF1001865A3 /* CoreDataMigrationVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF30627ABDFF1001865A3 /* CoreDataMigrationVersion.swift */; }; - AB7BF31B27ABE028001865A3 /* NSManagedObjectModel+Resource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF31327ABE028001865A3 /* NSManagedObjectModel+Resource.swift */; }; - AB7BF31C27ABE028001865A3 /* NSManagedObjectModel+Compatible.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF31427ABE028001865A3 /* NSManagedObjectModel+Compatible.swift */; }; - AB7BF31D27ABE028001865A3 /* FileManager+ApplicationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF31627ABE028001865A3 /* FileManager+ApplicationSupport.swift */; }; - AB7BF31E27ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF31827ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift */; }; - AB7E6B3025D24FE00035CC68 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AB7E6B3225D24FE00035CC68 /* InfoPlist.strings */; }; - AB86ABF52782DAB300E61E6A /* LogsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF42782DAB300E61E6A /* LogsReducer.swift */; }; - AB86ABF72782DDE600E61E6A /* FileClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF62782DDE600E61E6A /* FileClient.swift */; }; - AB86ABF92782EC0D00E61E6A /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF82782EC0D00E61E6A /* AboutView.swift */; }; - AB86AC0A2782FAFA00E61E6A /* AppearanceSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC092782FAFA00E61E6A /* AppearanceSettingReducer.swift */; }; AB86AC1027831AD100E61E6A /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = AB86AC0F27831AD100E61E6A /* ComposableArchitecture */; }; - AB86AC1327856F2700E61E6A /* AppLockReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC1227856F2700E61E6A /* AppLockReducer.swift */; }; - AB86AC1A2785C2B300E61E6A /* HomeReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC192785C2B300E61E6A /* HomeReducer.swift */; }; - AB8C821926BF801700E8C5E6 /* EhSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8C821826BF801700E8C5E6 /* EhSetting.swift */; }; - AB90276B291F548700697256 /* AppIcon_NotMyPresident@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB902766291F548600697256 /* AppIcon_NotMyPresident@3x.png */; }; - AB90276C291F548700697256 /* AppIcon_NotMyPresident_iPad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB902767291F548600697256 /* AppIcon_NotMyPresident_iPad@2x.png */; }; - AB90276D291F548700697256 /* AppIcon_NotMyPresident_iPad_Pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB902768291F548700697256 /* AppIcon_NotMyPresident_iPad_Pro@2x.png */; }; - AB90276E291F548700697256 /* AppIcon_NotMyPresident_iPad.png in Resources */ = {isa = PBXBuildFile; fileRef = AB902769291F548700697256 /* AppIcon_NotMyPresident_iPad.png */; }; - AB90276F291F548700697256 /* AppIcon_NotMyPresident@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB90276A291F548700697256 /* AppIcon_NotMyPresident@2x.png */; }; - ABA732D925A8018A00B3D9AB /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA732D825A8018A00B3D9AB /* Extensions.swift */; }; - ABA732DF25A852D800B3D9AB /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA732DE25A852D800B3D9AB /* Filter.swift */; }; - ABA9A6BC28EC786100EE28DE /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = ABA9A6BB28EC786100EE28DE /* swiftgen.yml */; }; - ABA9A6C228EC7BD000EE28DE /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA9A6C128EC7BD000EE28DE /* Strings.swift */; }; ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */ = {isa = PBXBuildFile; productRef = ABAC82FD26BC4A96009F5026 /* OpenCC */; }; - ABBB2631278E6EF3007B6149 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2630278E6EF3007B6149 /* SearchView.swift */; }; ABBB2636278FB888007B6149 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = ABBB2635278FB888007B6149 /* SwiftUINavigation */; }; - ABBB2638278FBD2F007B6149 /* SwiftUINavigation_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2637278FBD2F007B6149 /* SwiftUINavigation_Extension.swift */; }; - ABBB263A2792588F007B6149 /* TTProgressHUD_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26392792588F007B6149 /* TTProgressHUD_Extension.swift */; }; - ABBB263E2793C648007B6149 /* PreviewsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB263D2793C648007B6149 /* PreviewsReducer.swift */; }; - ABBB2640279417EC007B6149 /* CommentsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB263F279417EC007B6149 /* CommentsReducer.swift */; }; - ABBB264227942B74007B6149 /* URLClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB264127942B74007B6149 /* URLClient.swift */; }; - ABBB266627977C2A007B6149 /* ArchivesReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266527977C2A007B6149 /* ArchivesReducer.swift */; }; - ABBB26682797BFAA007B6149 /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26672797BFAA007B6149 /* ActivityView.swift */; }; - ABBB266A2797C61F007B6149 /* TorrentsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26692797C61F007B6149 /* TorrentsReducer.swift */; }; - ABBB266C2797E882007B6149 /* ClipboardClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266B2797E882007B6149 /* ClipboardClient.swift */; }; - ABBB266E27998479007B6149 /* QuickSearchReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266D27998479007B6149 /* QuickSearchReducer.swift */; }; - ABBB2671279AFA61007B6149 /* EnvironmentKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2670279AFA61007B6149 /* EnvironmentKeys.swift */; }; - ABBB2673279B9332007B6149 /* ReadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2672279B9332007B6149 /* ReadingView.swift */; }; - ABBB2675279B933D007B6149 /* ReadingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2674279B933D007B6149 /* ReadingReducer.swift */; }; - ABBB2677279CDBB0007B6149 /* ImageClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2676279CDBB0007B6149 /* ImageClient.swift */; }; - ABBB2679279D454C007B6149 /* GalleryInfosReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2678279D454C007B6149 /* GalleryInfosReducer.swift */; }; - ABBC332826BE31AE0084A331 /* EhSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBC332726BE31AE0084A331 /* EhSettingView.swift */; }; - ABBC332A26BE7C940084A331 /* SettingTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBC332926BE7C940084A331 /* SettingTextField.swift */; }; - ABBCCC9026C95F6E007D8A36 /* GalleryInfosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBCCC8F26C95F6E007D8A36 /* GalleryInfosView.swift */; }; - ABBD2B602768D7AD0072AED2 /* GalleryRankingCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBD2B5F2768D7AD0072AED2 /* GalleryRankingCell.swift */; }; - ABC0A8D126F7037F008EC24C /* IPBanned.html in Resources */ = {isa = PBXBuildFile; fileRef = ABC0A8D026F7037F008EC24C /* IPBanned.html */; }; - ABC1FAB82642C37D00A9F352 /* NewDawnView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC1FAB72642C37D00A9F352 /* NewDawnView.swift */; }; - ABC3C7852593699B00E0C11B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ABC3C7692593699A00E0C11B /* Assets.xcassets */; }; - ABC3C7872593699B00E0C11B /* EhPandaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC3C76B2593699A00E0C11B /* EhPandaApp.swift */; }; - ABC3C7892593699B00E0C11B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC3C76D2593699A00E0C11B /* Defaults.swift */; }; - ABC3C78F2593699B00E0C11B /* ViewModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC3C7762593699A00E0C11B /* ViewModifiers.swift */; }; ABC4A0792751B40E00968A4F /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = ABC4A0782751B40E00968A4F /* Kingfisher */; }; - ABC681F326898D46007BBD69 /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = ABC681F126898D46007BBD69 /* Model.xcdatamodeld */; }; - ABC732C527B9024500D47DA9 /* LiveText.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC732C427B9024500D47DA9 /* LiveText.swift */; }; - ABC732C727B90F0900D47DA9 /* LiveTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC732C627B90F0900D47DA9 /* LiveTextView.swift */; }; - ABC8355D27B118330091DCDB /* DetailSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355C27B118330091DCDB /* DetailSearchView.swift */; }; - ABC8355F27B118370091DCDB /* DetailSearchReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */; }; - ABCA93BE26918DE100A98BC6 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93BD26918DE100A98BC6 /* Persistence.swift */; }; - ABCA93C02691925900A98BC6 /* GalleryMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93BF2691925900A98BC6 /* GalleryMO+CoreDataClass.swift */; }; - ABCA93C22691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93C12691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift */; }; - ABCD2F0A259763FC008E5A20 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD2F09259763FC008E5A20 /* Request.swift */; }; - ABCD2F0E25976B95008E5A20 /* Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD2F0D25976B95008E5A20 /* Parser.swift */; }; - ABD4032626B78E5A00001B8C /* GalleryThumbnailCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD4032526B78E5A00001B8C /* GalleryThumbnailCell.swift */; }; - ABD4032826B7967F00001B8C /* CategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD4032726B7967F00001B8C /* CategoryView.swift */; }; - ABD49D5A277C5356003D1A07 /* FavoritesReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D59277C5356003D1A07 /* FavoritesReducer.swift */; }; ABD49D5D277C6C9D003D1A07 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */; }; - ABD49D60277C7722003D1A07 /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D5F277C7722003D1A07 /* TabBarView.swift */; }; - ABD49D64277C7AD5003D1A07 /* TabBarReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D63277C7AD5003D1A07 /* TabBarReducer.swift */; }; - ABD49D67277EAC90003D1A07 /* URLUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D66277EAC90003D1A07 /* URLUtil.swift */; }; - ABD5FDD4263D05110021A4C6 /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = ABD5FDD3263D05110021A4C6 /* .swiftlint.yml */; }; ABD7005926B1C31500DC59C9 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = ABD7005826B1C31500DC59C9 /* Kanna */; }; - ABD9770E27B65A7300983DE7 /* ListParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD9770D27B65A7300983DE7 /* ListParserTests.swift */; }; - ABD9771027B65E3400983DE7 /* GalleryDetailParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD9770F27B65E3400983DE7 /* GalleryDetailParserTests.swift */; }; - ABD9771327B6612400983DE7 /* GreetingParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD9771227B6612400983DE7 /* GreetingParserTests.swift */; }; - ABE1867826A1733000689FDC /* LaboratorySettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE1867726A1733000689FDC /* LaboratorySettingView.swift */; }; - ABE9012227F722D100F3651D /* AppIcon_StandWithUkraine2022@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = ABE9011D27F722D000F3651D /* AppIcon_StandWithUkraine2022@2x.png */; }; - ABE9012327F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad.png in Resources */ = {isa = PBXBuildFile; fileRef = ABE9011E27F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad.png */; }; - ABE9012427F722D100F3651D /* AppIcon_StandWithUkraine2022@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = ABE9011F27F722D100F3651D /* AppIcon_StandWithUkraine2022@3x.png */; }; - ABE9012527F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad_Pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = ABE9012027F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad_Pro@2x.png */; }; - ABE9012627F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = ABE9012127F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad@2x.png */; }; - ABE9401526FF158D0085E158 /* QuickSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE9401426FF158D0085E158 /* QuickSearchView.swift */; }; - ABEA1FE625A9B40B002966B9 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABEA1FE525A9B40B002966B9 /* Setting.swift */; }; - ABEE0AFA2595C6F800C997AE /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = ABEE0AFC2595C6F800C997AE /* Localizable.strings */; }; - ABF313A525B1AB6600D47A2F /* Misc.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF313A425B1AB6600D47A2F /* Misc.swift */; }; - ABF45ABB25F3312F00ECB568 /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AB625F3312F00ECB568 /* AppError.swift */; }; - ABF45ADF25F3313D00ECB568 /* FiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC125F3313D00ECB568 /* FiltersView.swift */; }; - ABF45AE425F3313D00ECB568 /* TagCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC725F3313D00ECB568 /* TagCloudView.swift */; }; - ABF45AE525F3313D00ECB568 /* PostCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC825F3313D00ECB568 /* PostCommentView.swift */; }; - ABF45AE725F3313D00ECB568 /* RatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ACA25F3313D00ECB568 /* RatingView.swift */; }; - ABF45AE825F3313D00ECB568 /* LinkedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ACB25F3313D00ECB568 /* LinkedText.swift */; }; - ABF45AE925F3313D00ECB568 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ACC25F3313D00ECB568 /* AlertView.swift */; }; - ABF45AEA25F3313D00ECB568 /* Placeholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ACD25F3313D00ECB568 /* Placeholder.swift */; }; - ABF45AEB25F3313D00ECB568 /* GalleryDetailCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ACE25F3313D00ECB568 /* GalleryDetailCell.swift */; }; - ABF45AEE25F3313D00ECB568 /* ArchivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD325F3313D00ECB568 /* ArchivesView.swift */; }; - ABF45AEF25F3313D00ECB568 /* TorrentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD425F3313D00ECB568 /* TorrentsView.swift */; }; - ABF45AF025F3313D00ECB568 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD525F3313D00ECB568 /* CommentsView.swift */; }; - ABF45AF225F3313D00ECB568 /* GeneralSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD825F3313D00ECB568 /* GeneralSettingView.swift */; }; - ABF45AF325F3313D00ECB568 /* AccountSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD925F3313D00ECB568 /* AccountSettingView.swift */; }; - ABF45AF425F3313D00ECB568 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ADA25F3313D00ECB568 /* WebView.swift */; }; - ABF45AF525F3313D00ECB568 /* ReadingSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ADB25F3313D00ECB568 /* ReadingSettingView.swift */; }; - ABF45AF625F3313D00ECB568 /* AppearanceSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ADC25F3313D00ECB568 /* AppearanceSettingView.swift */; }; - ABF45AF725F3313D00ECB568 /* SettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ADD25F3313D00ECB568 /* SettingView.swift */; }; - ABF75F3F25A19CD200544D29 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF75F3E25A19CD200544D29 /* User.swift */; }; - ABF9720A26DE6E1300118887 /* GalleryDetailWithGreeting.html in Resources */ = {isa = PBXBuildFile; fileRef = ABF9720926DE6E1300118887 /* GalleryDetailWithGreeting.html */; }; EA0BBD472E37CCB700DC8143 /* CODEOWNERS in Resources */ = {isa = PBXBuildFile; fileRef = EA0BBD462E37CCB700DC8143 /* CODEOWNERS */; }; EA0C92452C3EB42300D211F6 /* ISSUE_TEMPLATE in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92432C3EB42300D211F6 /* ISSUE_TEMPLATE */; }; EA0C92462C3EB42300D211F6 /* workflows in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92442C3EB42300D211F6 /* workflows */; }; @@ -273,34 +32,6 @@ EA0C925C2C3EB49500D211F6 /* README.de.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92562C3EB49500D211F6 /* README.de.md */; }; EA0C925D2C3EB49500D211F6 /* README.chs.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92572C3EB49500D211F6 /* README.chs.md */; }; EA0C925E2C3EB49500D211F6 /* README.jpn.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92582C3EB49500D211F6 /* README.jpn.md */; }; - EA2E2E7F2A1F7E500038A261 /* SettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E2E7E2A1F7E500038A261 /* SettingReducer.swift */; }; - EA2E2E822A1FA1060038A261 /* SearchReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E2E812A1FA1050038A261 /* SearchReducer.swift */; }; - EA5AA4A72EA9149E00BC2B5C /* PageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A62EA9149E00BC2B5C /* PageHandler.swift */; }; - EA5AA4A82EA9149E00BC2B5C /* LiveTextHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A52EA9149E00BC2B5C /* LiveTextHandler.swift */; }; - EA5AA4A92EA9149E00BC2B5C /* GestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A42EA9149E00BC2B5C /* GestureHandler.swift */; }; - EA5AA4AA2EA9149E00BC2B5C /* AutoPlayHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A32EA9149E00BC2B5C /* AutoPlayHandler.swift */; }; - EA698C032CCDD2FB0058BC19 /* EquatableVoid.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA698C022CCDD2FB0058BC19 /* EquatableVoid.swift */; }; - EA698C092CCDE7090058BC19 /* IdentifiableBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA698C082CCDE7050058BC19 /* IdentifiableBox.swift */; }; - EA8C4D262F0E100100000001 /* DownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8C4D262F0E100100000011 /* DownloadClient.swift */; }; - EA8C4D262F0E100100000002 /* DownloadFileStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8C4D262F0E100100000012 /* DownloadFileStorage.swift */; }; - EA8C4D262F0E100100000003 /* DownloadedGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8C4D262F0E100100000013 /* DownloadedGallery.swift */; }; - EA8C4D262F0E100100000004 /* DownloadedGalleryMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8C4D262F0E100100000014 /* DownloadedGalleryMO+CoreDataClass.swift */; }; - EA8C4D262F0E100100000005 /* DownloadedGalleryMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8C4D262F0E100100000015 /* DownloadedGalleryMO+CoreDataProperties.swift */; }; - EAA100012F1D000100000001 /* DownloadsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D000100000011 /* DownloadsReducer.swift */; }; - EAA100012F1D000100000002 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D000100000012 /* DownloadsView.swift */; }; - EAA100012F1D000100000005 /* DownloadSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D000100000015 /* DownloadSettingView.swift */; }; - EAA100012F1D000100000006 /* DownloadBadgeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D000100000016 /* DownloadBadgeLabel.swift */; }; - EAA100012F1D000100000007 /* DownloadFiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D000100000017 /* DownloadFiltersView.swift */; }; - EAA100012F1D000100000008 /* DownloadBadgeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D000100000018 /* DownloadBadgeStore.swift */; }; - EAA100012F1D000100000019 /* PreviewImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA100012F1D00010000001A /* PreviewImageView.swift */; }; - EAB100012F1E000100000001 /* DownloadPageErrorParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000011 /* DownloadPageErrorParserTests.swift */; }; - EAB100012F1E000100000002 /* SettingDownloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000012 /* SettingDownloadTests.swift */; }; - EAB100012F1E000100000003 /* DownloadFileStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000013 /* DownloadFileStorageTests.swift */; }; - EAB100012F1E000100000004 /* DownloadFeatureReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000014 /* DownloadFeatureReducerTests.swift */; }; - EAB100012F1E000100000005 /* BandwidthExceeded.html in Resources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000015 /* BandwidthExceeded.html */; }; - EAB100012F1E000100000006 /* ExLoginRequired.html in Resources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000016 /* ExLoginRequired.html */; }; - EAB100012F1E000100000007 /* DownloadSignatureBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000017 /* DownloadSignatureBuilderTests.swift */; }; - EAB100012F1E000100000018 /* Kokomade.jpg in Resources */ = {isa = PBXBuildFile; fileRef = EAB100012F1E000100000019 /* Kokomade.jpg */; }; EAE63E2129E2A6330048C601 /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = EAE63E2029E2A6330048C601 /* SwiftyBeaver */; }; /* End PBXBuildFile section */ @@ -334,275 +65,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingReducer.swift; sourceTree = ""; }; - AB0929BD2780032400F107CA /* EhSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingReducer.swift; sourceTree = ""; }; - AB0929BF27805A8200F107CA /* LoginReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginReducer.swift; sourceTree = ""; }; - AB0929C5278160AE00F107CA /* LibraryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryClient.swift; sourceTree = ""; }; - AB0929C72781938A00F107CA /* DFClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFClient.swift; sourceTree = ""; }; - AB0929C9278196ED00F107CA /* CookieClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieClient.swift; sourceTree = ""; }; - AB0929CB2781A0B000F107CA /* HapticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticsClient.swift; sourceTree = ""; }; - AB0929CD2781AADA00F107CA /* DatabaseClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseClient.swift; sourceTree = ""; }; - AB0929CF2781E1CC00F107CA /* UIApplicationClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationClient.swift; sourceTree = ""; }; - AB0929D12781E7D500F107CA /* LoggerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerClient.swift; sourceTree = ""; }; - AB0929D32781EDDC00F107CA /* UserDefaultsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsClient.swift; sourceTree = ""; }; - AB0929D52782A65F00F107CA /* GeneralSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingReducer.swift; sourceTree = ""; }; - AB0929D72782A83A00F107CA /* AuthorizationClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationClient.swift; sourceTree = ""; }; - AB0ABCB426C5406400AD970F /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; - AB0ABCB626C541A400AD970F /* WaveForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveForm.swift; sourceTree = ""; }; - AB0CFB6527BAB9CF004BD372 /* AppIcon_Default@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Default@3x.png"; sourceTree = ""; }; - AB0CFB6627BAB9CF004BD372 /* AppIcon_Default_iPad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Default_iPad@2x.png"; sourceTree = ""; }; - AB0CFB6927BAB9CF004BD372 /* AppIcon_Default_iPad_Pro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Default_iPad_Pro@2x.png"; sourceTree = ""; }; - AB0CFB6B27BAB9CF004BD372 /* AppIcon_Default@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Default@2x.png"; sourceTree = ""; }; - AB0CFB6C27BAB9CF004BD372 /* AppIcon_Default_iPad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon_Default_iPad.png; sourceTree = ""; }; - AB0CFB7F27BBBFA0004BD372 /* EhSetting.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = EhSetting.html; sourceTree = ""; }; - AB0CFB8127BBBFCE004BD372 /* EhSettingParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingParserTests.swift; sourceTree = ""; }; - AB0CFB8327BBD2D7004BD372 /* AppIcon_Developer_iPad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Developer_iPad@2x.png"; sourceTree = ""; }; - AB0CFB8427BBD2D7004BD372 /* AppIcon_Developer@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Developer@2x.png"; sourceTree = ""; }; - AB0CFB8527BBD2D7004BD372 /* AppIcon_Developer_iPad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon_Developer_iPad.png; sourceTree = ""; }; - AB0CFB8627BBD2D7004BD372 /* AppIcon_Developer_iPad_Pro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Developer_iPad_Pro@2x.png"; sourceTree = ""; }; - AB0CFB8727BBD2D7004BD372 /* AppIcon_Developer@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Developer@3x.png"; sourceTree = ""; }; - AB0CFB8D27BBD323004BD372 /* AppIcon_Ukiyoe_iPad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Ukiyoe_iPad@2x.png"; sourceTree = ""; }; - AB0CFB8E27BBD323004BD372 /* AppIcon_Ukiyoe@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Ukiyoe@3x.png"; sourceTree = ""; }; - AB0CFB8F27BBD323004BD372 /* AppIcon_Ukiyoe_iPad_Pro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Ukiyoe_iPad_Pro@2x.png"; sourceTree = ""; }; - AB0CFB9027BBD323004BD372 /* AppIcon_Ukiyoe_iPad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon_Ukiyoe_iPad.png; sourceTree = ""; }; - AB0CFB9127BBD323004BD372 /* AppIcon_Ukiyoe@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Ukiyoe@2x.png"; sourceTree = ""; }; - AB0CFBC827C07F95004BD372 /* TagSuggestionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagSuggestionView.swift; sourceTree = ""; }; - AB0CFBCA27C0B07F004BD372 /* TagSuggestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSuggestion.swift; sourceTree = ""; }; - AB0CFBCC27C1CC67004BD372 /* EhTagTranslationDatabaseModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhTagTranslationDatabaseModel.swift; sourceTree = ""; }; - AB0CFBD427C24B3B004BD372 /* MarkdownUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownUtil.swift; sourceTree = ""; }; - AB0CFBD627C3B2D0004BD372 /* TagDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailView.swift; sourceTree = ""; }; - AB10117D26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryStateMO+CoreDataProperties.swift"; sourceTree = ""; }; - AB10117F26986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryStateMO+CoreDataClass.swift"; sourceTree = ""; }; - AB1EF25327AFA19200F507D6 /* Heap.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Heap.swift; sourceTree = ""; }; - AB1FA8FB27C5E0E50063EF55 /* TagDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetail.swift; sourceTree = ""; }; - AB1FA94C27CA1F140063EF55 /* TagTranslation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagTranslation.swift; sourceTree = ""; }; - AB24C55927674EDF0085C33A /* FavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = ""; }; - AB24C55B2767565A0085C33A /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; - AB24C565276758E30085C33A /* GalleryCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryCardCell.swift; sourceTree = ""; }; - AB253B4726AB08B500F95275 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; - AB253B4826AB08B500F95275 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; - AB26F58F27ABF21000AB3468 /* Model5toModel6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Model5toModel6.xcmappingmodel; sourceTree = ""; }; - AB26F59327ACC6CD00AB3468 /* TagTranslator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagTranslator.swift; sourceTree = ""; }; - AB26F59527ACCA1800AB3468 /* AppEnv.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnv.swift; sourceTree = ""; }; - AB2CED63268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryMO+CoreDataProperties.swift"; sourceTree = ""; }; - AB3072D1276D734800EFF242 /* SubSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubSection.swift; sourceTree = ""; }; - AB3072D3276E19AA00EFF242 /* FrontpageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontpageView.swift; sourceTree = ""; }; - AB31CD2F27B666E200F40E0A /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; - AB31CD3127B6671400F40E0A /* BanIntervalParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanIntervalParserTests.swift; sourceTree = ""; }; - AB31CD3627B6695800F40E0A /* HTMLFilename.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLFilename.swift; sourceTree = ""; }; - AB31CD3A27B66E0300F40E0A /* ListParserTestType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListParserTestType.swift; sourceTree = ""; }; - AB31CD3C27B66F7D00F40E0A /* GalleryImageURLParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryImageURLParserTests.swift; sourceTree = ""; }; - AB31CD3E27B670FD00F40E0A /* GalleryNormalImageURL.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = GalleryNormalImageURL.html; sourceTree = ""; }; - AB31CD4027B6769F00F40E0A /* GalleryMPVKeys.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = GalleryMPVKeys.html; sourceTree = ""; }; - AB31CD4227B676C300F40E0A /* GalleryMPVKeysParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryMPVKeysParserTests.swift; sourceTree = ""; }; - AB358310269D7B63009466A5 /* DFURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFURLProtocol.swift; sourceTree = ""; }; - AB358312269D7E89009466A5 /* DFRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFRequest.swift; sourceTree = ""; }; - AB358314269D821D009466A5 /* DFExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFExtensions.swift; sourceTree = ""; }; - AB358316269D826B009466A5 /* DFStreamHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFStreamHandler.swift; sourceTree = ""; }; - AB358318269D9996009466A5 /* DomainResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainResolver.swift; sourceTree = ""; }; - AB38A0CA25CA993D00764D64 /* ColorCodable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorCodable.swift; sourceTree = ""; }; - AB3E9E6526D210B1008FE518 /* GalleryDetail.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = GalleryDetail.html; sourceTree = ""; }; - AB3E9E6D26D210B1008FE518 /* TestHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestHelper.swift; sourceTree = ""; }; - AB41DB2827B760D600DD3604 /* WatchedCompactList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = WatchedCompactList.html; sourceTree = ""; }; - AB41DB2927B760D600DD3604 /* PopularThumbnailList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = PopularThumbnailList.html; sourceTree = ""; }; - AB41DB2A27B760D600DD3604 /* FrontPageExtendedList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FrontPageExtendedList.html; sourceTree = ""; }; - AB41DB2B27B760D600DD3604 /* FrontPageMinimalPlusList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FrontPageMinimalPlusList.html; sourceTree = ""; }; - AB41DB2C27B760D600DD3604 /* PopularExtendedList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = PopularExtendedList.html; sourceTree = ""; }; - AB41DB2D27B760D700DD3604 /* FavoritesThumbnailList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FavoritesThumbnailList.html; sourceTree = ""; }; - AB41DB2E27B760D700DD3604 /* ToplistsCompactList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = ToplistsCompactList.html; sourceTree = ""; }; - AB41DB2F27B760D700DD3604 /* FavoritesExtendedList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FavoritesExtendedList.html; sourceTree = ""; }; - AB41DB3027B760D700DD3604 /* FrontPageMinimalList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FrontPageMinimalList.html; sourceTree = ""; }; - AB41DB3127B760D700DD3604 /* FrontPageCompactList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FrontPageCompactList.html; sourceTree = ""; }; - AB41DB3227B760D700DD3604 /* PopularMinimalPlusList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = PopularMinimalPlusList.html; sourceTree = ""; }; - AB41DB3327B760D700DD3604 /* WatchedExtendedList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = WatchedExtendedList.html; sourceTree = ""; }; - AB41DB3427B760D700DD3604 /* FavoritesMinimalPlusList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FavoritesMinimalPlusList.html; sourceTree = ""; }; - AB41DB3527B760D700DD3604 /* WatchedThumbnailList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = WatchedThumbnailList.html; sourceTree = ""; }; - AB41DB3627B760D700DD3604 /* FrontPageThumbnailList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FrontPageThumbnailList.html; sourceTree = ""; }; - AB41DB3727B760D700DD3604 /* FavoritesCompactList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FavoritesCompactList.html; sourceTree = ""; }; - AB41DB3827B760D700DD3604 /* FavoritesMinimalList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FavoritesMinimalList.html; sourceTree = ""; }; - AB41DB3927B760D700DD3604 /* WatchedMinimalPlusList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = WatchedMinimalPlusList.html; sourceTree = ""; }; - AB41DB3A27B760D700DD3604 /* WatchedMinimalList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = WatchedMinimalList.html; sourceTree = ""; }; - AB41DB3B27B760D700DD3604 /* PopularCompactList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = PopularCompactList.html; sourceTree = ""; }; - AB41DB3C27B760D700DD3604 /* PopularMinimalList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = PopularMinimalList.html; sourceTree = ""; }; - AB41DB5227B7EC5500DD3604 /* Model 7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 7.xcdatamodel"; sourceTree = ""; }; - AB48BCF626D2539B0021A06C /* Model 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 2.xcdatamodel"; sourceTree = ""; }; - AB4FD2C0268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryDetailMO+CoreDataProperties.swift"; sourceTree = ""; }; - AB543FF126DB7FD9009344C0 /* Model 3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 3.xcdatamodel"; sourceTree = ""; }; - AB58A5AB2776B2BC00C0D285 /* AppDelegateReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateReducer.swift; sourceTree = ""; }; - AB58A5B12776B99000C0D285 /* AppReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReducer.swift; sourceTree = ""; }; AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - AB5BE67826B95FDD007D4A55 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; - AB5BE67D26B95FDD007D4A55 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - AB63EADA2699AC8200090535 /* AppEnvMO+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppEnvMO+CoreDataProperties.swift"; sourceTree = ""; }; - AB63EADC2699AC9100090535 /* AppEnvMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppEnvMO+CoreDataClass.swift"; sourceTree = ""; }; - AB69CB7F26B3DABC00699359 /* AdvancedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedList.swift; sourceTree = ""; }; - AB69CB8126B3DAF400699359 /* ControlPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlPanel.swift; sourceTree = ""; }; - AB6DE896268822390087C579 /* LogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsView.swift; sourceTree = ""; }; - AB706F7827890A6C0025A48A /* AppRouteReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteReducer.swift; sourceTree = ""; }; - AB706F7A278937500025A48A /* FrontpageReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontpageReducer.swift; sourceTree = ""; }; - AB706F7F278981370025A48A /* AlertKit_Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertKit_Extension.swift; sourceTree = ""; }; - AB706F81278986120025A48A /* ToolbarItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarItems.swift; sourceTree = ""; }; - AB706F832789AD2D0025A48A /* ToplistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToplistsView.swift; sourceTree = ""; }; - AB706F852789AD490025A48A /* ToplistsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToplistsReducer.swift; sourceTree = ""; }; - AB706F87278A4C8A0025A48A /* PopularView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularView.swift; sourceTree = ""; }; - AB706F89278A4CC50025A48A /* PopularReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularReducer.swift; sourceTree = ""; }; - AB706F8B278A4F6C0025A48A /* WatchedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedView.swift; sourceTree = ""; }; - AB706F8D278A5DCF0025A48A /* DeviceClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceClient.swift; sourceTree = ""; }; - AB706F8F278A5F680025A48A /* AppDelegateClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateClient.swift; sourceTree = ""; }; - AB706F91278A6E8C0025A48A /* WatchedReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedReducer.swift; sourceTree = ""; }; - AB706F93278A6F2B0025A48A /* Model 6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 6.xcdatamodel"; sourceTree = ""; }; - AB706F94278A75D30025A48A /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; - AB706F96278A77E20025A48A /* HistoryReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryReducer.swift; sourceTree = ""; }; - AB706F98278A820C0025A48A /* FiltersReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersReducer.swift; sourceTree = ""; }; - AB706F9A278AC5A30025A48A /* SearchRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRootView.swift; sourceTree = ""; }; - AB706F9C278ACCA20025A48A /* SearchRootReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRootReducer.swift; sourceTree = ""; }; - AB706F9E278AD4800025A48A /* GalleryHistoryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryHistoryCell.swift; sourceTree = ""; }; - AB706FA0278BCEC60025A48A /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = ""; }; - AB706FA2278BCF2F0025A48A /* DetailReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailReducer.swift; sourceTree = ""; }; - AB706FA4278C3DDE0025A48A /* PreviewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewsView.swift; sourceTree = ""; }; - AB7B29F126AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Model5toModel6MigrationPolicy.swift; sourceTree = ""; }; - AB7B29F526AC741600EE1F14 /* GenericList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericList.swift; sourceTree = ""; }; - AB7BF2A827A63C89001865A3 /* Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Language.swift; sourceTree = ""; }; - AB7BF2AA27A642FB001865A3 /* BrowsingCountry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowsingCountry.swift; sourceTree = ""; }; - AB7BF2B627A9652F001865A3 /* Greeting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Greeting.swift; sourceTree = ""; }; - AB7BF2B927A96562001865A3 /* Gallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gallery.swift; sourceTree = ""; }; - AB7BF2BB27A965DA001865A3 /* Category.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Category.swift; sourceTree = ""; }; - AB7BF2BF27A9669A001865A3 /* TagNamespace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagNamespace.swift; sourceTree = ""; }; - AB7BF2C127A96760001865A3 /* GalleryDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryDetail.swift; sourceTree = ""; }; - AB7BF2C327A9683F001865A3 /* GalleryArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryArchive.swift; sourceTree = ""; }; - AB7BF2C527A968AB001865A3 /* TranslatableLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslatableLanguage.swift; sourceTree = ""; }; - AB7BF2C727A968F7001865A3 /* GalleryComment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryComment.swift; sourceTree = ""; }; - AB7BF2C927A969F4001865A3 /* GalleryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryState.swift; sourceTree = ""; }; - AB7BF2CB27A96A3C001865A3 /* GalleryTorrent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryTorrent.swift; sourceTree = ""; }; - AB7BF2CD27AA3E58001865A3 /* AppUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUtil.swift; sourceTree = ""; }; - AB7BF2CF27AA3E75001865A3 /* DeviceUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceUtil.swift; sourceTree = ""; }; - AB7BF2D127AA3EDC001865A3 /* HapticsUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticsUtil.swift; sourceTree = ""; }; - AB7BF2D327AA3F12001865A3 /* CookieUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieUtil.swift; sourceTree = ""; }; - AB7BF2D527AA3F4C001865A3 /* FileUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtil.swift; sourceTree = ""; }; - AB7BF2D727AA3F61001865A3 /* UserDefaultsUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsUtil.swift; sourceTree = ""; }; - AB7BF2D927AA78CF001865A3 /* Reducer_Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reducer_Extension.swift; sourceTree = ""; }; - AB7BF2FA27ABCA3A001865A3 /* MigrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationView.swift; sourceTree = ""; }; - AB7BF2FC27ABCAD4001865A3 /* MigrationReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationReducer.swift; sourceTree = ""; }; - AB7BF2FE27ABDFF1001865A3 /* CoreDataMigrator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataMigrator.swift; sourceTree = ""; }; - AB7BF30327ABDFF1001865A3 /* CoreDataMigrationStep.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataMigrationStep.swift; sourceTree = ""; }; - AB7BF30627ABDFF1001865A3 /* CoreDataMigrationVersion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataMigrationVersion.swift; sourceTree = ""; }; - AB7BF31327ABE028001865A3 /* NSManagedObjectModel+Resource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectModel+Resource.swift"; sourceTree = ""; }; - AB7BF31427ABE028001865A3 /* NSManagedObjectModel+Compatible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectModel+Compatible.swift"; sourceTree = ""; }; - AB7BF31627ABE028001865A3 /* FileManager+ApplicationSupport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "FileManager+ApplicationSupport.swift"; sourceTree = ""; }; - AB7BF31827ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSPersistentStoreCoordinator+SQLite.swift"; sourceTree = ""; }; - AB7E6B3125D24FE00035CC68 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; - AB7E6B3425D24FE40035CC68 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; - AB7E6B3525D24FE50035CC68 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; - AB86ABF42782DAB300E61E6A /* LogsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsReducer.swift; sourceTree = ""; }; - AB86ABF62782DDE600E61E6A /* FileClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileClient.swift; sourceTree = ""; }; - AB86ABF82782EC0D00E61E6A /* AboutView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; - AB86AC092782FAFA00E61E6A /* AppearanceSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingReducer.swift; sourceTree = ""; }; - AB86AC1227856F2700E61E6A /* AppLockReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockReducer.swift; sourceTree = ""; }; - AB86AC192785C2B300E61E6A /* HomeReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeReducer.swift; sourceTree = ""; }; - AB8C821826BF801700E8C5E6 /* EhSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSetting.swift; sourceTree = ""; }; - AB902766291F548600697256 /* AppIcon_NotMyPresident@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_NotMyPresident@3x.png"; sourceTree = ""; }; - AB902767291F548600697256 /* AppIcon_NotMyPresident_iPad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_NotMyPresident_iPad@2x.png"; sourceTree = ""; }; - AB902768291F548700697256 /* AppIcon_NotMyPresident_iPad_Pro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_NotMyPresident_iPad_Pro@2x.png"; sourceTree = ""; }; - AB902769291F548700697256 /* AppIcon_NotMyPresident_iPad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon_NotMyPresident_iPad.png; sourceTree = ""; }; - AB90276A291F548700697256 /* AppIcon_NotMyPresident@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_NotMyPresident@2x.png"; sourceTree = ""; }; - AB994DBB25986F7A00E9A367 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; - ABA732D825A8018A00B3D9AB /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; - ABA732DE25A852D800B3D9AB /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = ""; }; - ABA9A6BB28EC786100EE28DE /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = SOURCE_ROOT; }; - ABA9A6BE28EC7BA200EE28DE /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Constant.strings; sourceTree = ""; }; - ABA9A6C128EC7BD000EE28DE /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; - ABAB5B9427EF023300198597 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; - ABB5013026A41EBA00B542D9 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; - ABB5013126A41EBA00B542D9 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = ""; }; - ABBB2630278E6EF3007B6149 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; - ABBB2637278FBD2F007B6149 /* SwiftUINavigation_Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUINavigation_Extension.swift; sourceTree = ""; }; - ABBB26392792588F007B6149 /* TTProgressHUD_Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTProgressHUD_Extension.swift; sourceTree = ""; }; - ABBB263D2793C648007B6149 /* PreviewsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewsReducer.swift; sourceTree = ""; }; - ABBB263F279417EC007B6149 /* CommentsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsReducer.swift; sourceTree = ""; }; - ABBB264127942B74007B6149 /* URLClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLClient.swift; sourceTree = ""; }; - ABBB266527977C2A007B6149 /* ArchivesReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchivesReducer.swift; sourceTree = ""; }; - ABBB26672797BFAA007B6149 /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; - ABBB26692797C61F007B6149 /* TorrentsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentsReducer.swift; sourceTree = ""; }; - ABBB266B2797E882007B6149 /* ClipboardClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardClient.swift; sourceTree = ""; }; - ABBB266D27998479007B6149 /* QuickSearchReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSearchReducer.swift; sourceTree = ""; }; - ABBB2670279AFA61007B6149 /* EnvironmentKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentKeys.swift; sourceTree = ""; }; - ABBB2672279B9332007B6149 /* ReadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingView.swift; sourceTree = ""; }; - ABBB2674279B933D007B6149 /* ReadingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingReducer.swift; sourceTree = ""; }; - ABBB2676279CDBB0007B6149 /* ImageClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageClient.swift; sourceTree = ""; }; - ABBB2678279D454C007B6149 /* GalleryInfosReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryInfosReducer.swift; sourceTree = ""; }; - ABBC332726BE31AE0084A331 /* EhSettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingView.swift; sourceTree = ""; }; - ABBC332926BE7C940084A331 /* SettingTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingTextField.swift; sourceTree = ""; }; - ABBCCC8F26C95F6E007D8A36 /* GalleryInfosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryInfosView.swift; sourceTree = ""; }; - ABBD2B5F2768D7AD0072AED2 /* GalleryRankingCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRankingCell.swift; sourceTree = ""; }; - ABC0A8D026F7037F008EC24C /* IPBanned.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = IPBanned.html; sourceTree = ""; }; - ABC1FAB72642C37D00A9F352 /* NewDawnView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewDawnView.swift; sourceTree = ""; }; ABC3C7542593696C00E0C11B /* EhPanda.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EhPanda.app; sourceTree = BUILT_PRODUCTS_DIR; }; - ABC3C7692593699A00E0C11B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - ABC3C76B2593699A00E0C11B /* EhPandaApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EhPandaApp.swift; sourceTree = ""; }; - ABC3C76D2593699A00E0C11B /* Defaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = ""; }; - ABC3C76E2593699A00E0C11B /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - ABC3C7762593699A00E0C11B /* ViewModifiers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewModifiers.swift; sourceTree = ""; }; - ABC4A07A2753084100968A4F /* Model 5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 5.xcdatamodel"; sourceTree = ""; }; - ABC681F226898D46007BBD69 /* Model.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model.xcdatamodel; sourceTree = ""; }; - ABC732C427B9024500D47DA9 /* LiveText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveText.swift; sourceTree = ""; }; - ABC732C627B90F0900D47DA9 /* LiveTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTextView.swift; sourceTree = ""; }; - ABC8355C27B118330091DCDB /* DetailSearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchView.swift; sourceTree = ""; }; - ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchReducer.swift; sourceTree = ""; }; - ABCA93BD26918DE100A98BC6 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; - ABCA93BF2691925900A98BC6 /* GalleryMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryMO+CoreDataClass.swift"; sourceTree = ""; }; - ABCA93C12691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryDetailMO+CoreDataClass.swift"; sourceTree = ""; }; - ABCD2F09259763FC008E5A20 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; - ABCD2F0D25976B95008E5A20 /* Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; - ABD4032526B78E5A00001B8C /* GalleryThumbnailCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryThumbnailCell.swift; sourceTree = ""; }; - ABD4032726B7967F00001B8C /* CategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryView.swift; sourceTree = ""; }; - ABD49D59277C5356003D1A07 /* FavoritesReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesReducer.swift; sourceTree = ""; }; - ABD49D5F277C7722003D1A07 /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; - ABD49D63277C7AD5003D1A07 /* TabBarReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarReducer.swift; sourceTree = ""; }; - ABD49D66277EAC90003D1A07 /* URLUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLUtil.swift; sourceTree = ""; }; - ABD5FDD3263D05110021A4C6 /* .swiftlint.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = SOURCE_ROOT; }; - ABD9770D27B65A7300983DE7 /* ListParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListParserTests.swift; sourceTree = ""; }; - ABD9770F27B65E3400983DE7 /* GalleryDetailParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryDetailParserTests.swift; sourceTree = ""; }; - ABD9771227B6612400983DE7 /* GreetingParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GreetingParserTests.swift; sourceTree = ""; }; - ABDD3E872930E73E009B3C2D /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/InfoPlist.strings"; sourceTree = ""; }; - ABDD3E882930E73E009B3C2D /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/Localizable.strings"; sourceTree = ""; }; - ABDD3E8B2930E797009B3C2D /* zh-Hant-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-HK"; path = "zh-Hant-HK.lproj/InfoPlist.strings"; sourceTree = ""; }; - ABDD3E8C2930E797009B3C2D /* zh-Hant-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-HK"; path = "zh-Hant-HK.lproj/Localizable.strings"; sourceTree = ""; }; - ABDD3E8D2930E879009B3C2D /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; - ABDD3E8E2930E879009B3C2D /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; - ABE1867726A1733000689FDC /* LaboratorySettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaboratorySettingView.swift; sourceTree = ""; }; - ABE9011D27F722D000F3651D /* AppIcon_StandWithUkraine2022@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_StandWithUkraine2022@2x.png"; sourceTree = ""; }; - ABE9011E27F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon_StandWithUkraine2022_iPad.png; sourceTree = ""; }; - ABE9011F27F722D100F3651D /* AppIcon_StandWithUkraine2022@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_StandWithUkraine2022@3x.png"; sourceTree = ""; }; - ABE9012027F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad_Pro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_StandWithUkraine2022_iPad_Pro@2x.png"; sourceTree = ""; }; - ABE9012127F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_StandWithUkraine2022_iPad@2x.png"; sourceTree = ""; }; - ABE9401426FF158D0085E158 /* QuickSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSearchView.swift; sourceTree = ""; }; - ABE9401626FF2E610085E158 /* Model 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 4.xcdatamodel"; sourceTree = ""; }; - ABEA1FE525A9B40B002966B9 /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; - ABEE0AFB2595C6F800C997AE /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - ABEE0AFE2595C73D00C997AE /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; ABF294CC26D20F82004DD03A /* EhPandaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EhPandaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - ABF313A425B1AB6600D47A2F /* Misc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Misc.swift; sourceTree = ""; }; - ABF45AB625F3312F00ECB568 /* AppError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; - ABF45AC125F3313D00ECB568 /* FiltersView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FiltersView.swift; sourceTree = ""; }; - ABF45AC725F3313D00ECB568 /* TagCloudView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagCloudView.swift; sourceTree = ""; }; - ABF45AC825F3313D00ECB568 /* PostCommentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostCommentView.swift; sourceTree = ""; }; - ABF45ACA25F3313D00ECB568 /* RatingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RatingView.swift; sourceTree = ""; }; - ABF45ACB25F3313D00ECB568 /* LinkedText.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkedText.swift; sourceTree = ""; }; - ABF45ACC25F3313D00ECB568 /* AlertView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = ""; }; - ABF45ACD25F3313D00ECB568 /* Placeholder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Placeholder.swift; sourceTree = ""; }; - ABF45ACE25F3313D00ECB568 /* GalleryDetailCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryDetailCell.swift; sourceTree = ""; }; - ABF45AD325F3313D00ECB568 /* ArchivesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArchivesView.swift; sourceTree = ""; }; - ABF45AD425F3313D00ECB568 /* TorrentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TorrentsView.swift; sourceTree = ""; }; - ABF45AD525F3313D00ECB568 /* CommentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = ""; }; - ABF45AD825F3313D00ECB568 /* GeneralSettingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneralSettingView.swift; sourceTree = ""; }; - ABF45AD925F3313D00ECB568 /* AccountSettingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountSettingView.swift; sourceTree = ""; }; - ABF45ADA25F3313D00ECB568 /* WebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; - ABF45ADB25F3313D00ECB568 /* ReadingSettingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReadingSettingView.swift; sourceTree = ""; }; - ABF45ADC25F3313D00ECB568 /* AppearanceSettingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppearanceSettingView.swift; sourceTree = ""; }; - ABF45ADD25F3313D00ECB568 /* SettingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingView.swift; sourceTree = ""; }; - ABF53F4725A306D200AB5918 /* EhPanda.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = EhPanda.entitlements; sourceTree = ""; }; - ABF75F3E25A19CD200544D29 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; - ABF9720926DE6E1300118887 /* GalleryDetailWithGreeting.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = GalleryDetailWithGreeting.html; sourceTree = ""; }; EA0BBD462E37CCB700DC8143 /* CODEOWNERS */ = {isa = PBXFileReference; lastKnownFileType = text; name = CODEOWNERS; path = .github/CODEOWNERS; sourceTree = ""; }; EA0C92432C3EB42300D211F6 /* ISSUE_TEMPLATE */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ISSUE_TEMPLATE; path = .github/ISSUE_TEMPLATE; sourceTree = ""; }; EA0C92442C3EB42300D211F6 /* workflows */ = {isa = PBXFileReference; lastKnownFileType = folder; name = workflows; path = .github/workflows; sourceTree = ""; }; @@ -617,37 +82,50 @@ EA0C92562C3EB49500D211F6 /* README.de.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = README.de.md; path = READMEs/README.de.md; sourceTree = ""; }; EA0C92572C3EB49500D211F6 /* README.chs.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = README.chs.md; path = READMEs/README.chs.md; sourceTree = ""; }; EA0C92582C3EB49500D211F6 /* README.jpn.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = README.jpn.md; path = READMEs/README.jpn.md; sourceTree = ""; }; - EA2E2E7E2A1F7E500038A261 /* SettingReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingReducer.swift; sourceTree = ""; }; - EA2E2E812A1FA1050038A261 /* SearchReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchReducer.swift; sourceTree = ""; }; - EA5AA4A32EA9149E00BC2B5C /* AutoPlayHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPlayHandler.swift; sourceTree = ""; }; - EA5AA4A42EA9149E00BC2B5C /* GestureHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureHandler.swift; sourceTree = ""; }; - EA5AA4A52EA9149E00BC2B5C /* LiveTextHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTextHandler.swift; sourceTree = ""; }; - EA5AA4A62EA9149E00BC2B5C /* PageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHandler.swift; sourceTree = ""; }; - EA698C022CCDD2FB0058BC19 /* EquatableVoid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EquatableVoid.swift; sourceTree = ""; }; - EA698C082CCDE7050058BC19 /* IdentifiableBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiableBox.swift; sourceTree = ""; }; - EA8C4D262F0E100100000011 /* DownloadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadClient.swift; sourceTree = ""; }; - EA8C4D262F0E100100000012 /* DownloadFileStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileStorage.swift; sourceTree = ""; }; - EA8C4D262F0E100100000013 /* DownloadedGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedGallery.swift; sourceTree = ""; }; - EA8C4D262F0E100100000014 /* DownloadedGalleryMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DownloadedGalleryMO+CoreDataClass.swift"; sourceTree = ""; }; - EA8C4D262F0E100100000015 /* DownloadedGalleryMO+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DownloadedGalleryMO+CoreDataProperties.swift"; sourceTree = ""; }; - EA8C4D262F0E100100000016 /* Model 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 8.xcdatamodel"; sourceTree = ""; }; - EAA100012F1D000100000011 /* DownloadsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsReducer.swift; sourceTree = ""; }; - EAA100012F1D000100000012 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; - EAA100012F1D000100000015 /* DownloadSettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadSettingView.swift; sourceTree = ""; }; - EAA100012F1D000100000016 /* DownloadBadgeLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadBadgeLabel.swift; sourceTree = ""; }; - EAA100012F1D000100000017 /* DownloadFiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFiltersView.swift; sourceTree = ""; }; - EAA100012F1D000100000018 /* DownloadBadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadBadgeStore.swift; sourceTree = ""; }; - EAA100012F1D00010000001A /* PreviewImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewImageView.swift; sourceTree = ""; }; - EAB100012F1E000100000011 /* DownloadPageErrorParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadPageErrorParserTests.swift; sourceTree = ""; }; - EAB100012F1E000100000012 /* SettingDownloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingDownloadTests.swift; sourceTree = ""; }; - EAB100012F1E000100000013 /* DownloadFileStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileStorageTests.swift; sourceTree = ""; }; - EAB100012F1E000100000014 /* DownloadFeatureReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFeatureReducerTests.swift; sourceTree = ""; }; - EAB100012F1E000100000015 /* BandwidthExceeded.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = BandwidthExceeded.html; sourceTree = ""; }; - EAB100012F1E000100000016 /* ExLoginRequired.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = ExLoginRequired.html; sourceTree = ""; }; - EAB100012F1E000100000017 /* DownloadSignatureBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadSignatureBuilderTests.swift; sourceTree = ""; }; - EAB100012F1E000100000019 /* Kokomade.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = Kokomade.jpg; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + A6844C242F780C8700BBF6E5 /* Exceptions for "EhPanda" folder in "EhPanda" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "/Localized: App/Constant.strings", + App/Info.plist, + ); + target = ABC3C7532593696C00E0C11B /* EhPanda */; + }; + A6844C292F780C8D00BBF6E5 /* Exceptions for "ShareExtension" folder in "ShareExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + ShareViewController.swift, + ); + target = AB5BE67526B95FDD007D4A55 /* ShareExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A6844B3D2F780C8600BBF6E5 /* EhPanda */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A6844C242F780C8700BBF6E5 /* Exceptions for "EhPanda" folder in "EhPanda" target */, + ); + path = EhPanda; + sourceTree = ""; + }; + A6844C272F780C8B00BBF6E5 /* ShareExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A6844C292F780C8D00BBF6E5 /* Exceptions for "ShareExtension" folder in "ShareExtension" target */, + ); + path = ShareExtension; + sourceTree = ""; + }; + A6844C652F780C9C00BBF6E5 /* EhPandaTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = EhPandaTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ AB5BE67326B95FDD007D4A55 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -683,436 +161,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - AB0929C12781589000F107CA /* Clients */ = { - isa = PBXGroup; - children = ( - AB0929C72781938A00F107CA /* DFClient.swift */, - AB86ABF62782DDE600E61E6A /* FileClient.swift */, - ABBB264127942B74007B6149 /* URLClient.swift */, - ABBB2676279CDBB0007B6149 /* ImageClient.swift */, - AB706F8D278A5DCF0025A48A /* DeviceClient.swift */, - AB0929D12781E7D500F107CA /* LoggerClient.swift */, - AB0929CB2781A0B000F107CA /* HapticsClient.swift */, - AB0929C5278160AE00F107CA /* LibraryClient.swift */, - AB0929C9278196ED00F107CA /* CookieClient.swift */, - AB0929CD2781AADA00F107CA /* DatabaseClient.swift */, - EA8C4D262F0E100100000011 /* DownloadClient.swift */, - ABBB266B2797E882007B6149 /* ClipboardClient.swift */, - AB706F8F278A5F680025A48A /* AppDelegateClient.swift */, - AB0929D32781EDDC00F107CA /* UserDefaultsClient.swift */, - AB0929CF2781E1CC00F107CA /* UIApplicationClient.swift */, - AB0929D72782A83A00F107CA /* AuthorizationClient.swift */, - ); - path = Clients; - sourceTree = ""; - }; - AB1FA8FA27C5DE800063EF55 /* Tags */ = { - isa = PBXGroup; - children = ( - AB1FA8FB27C5E0E50063EF55 /* TagDetail.swift */, - AB26F59327ACC6CD00AB3468 /* TagTranslator.swift */, - AB1FA94C27CA1F140063EF55 /* TagTranslation.swift */, - AB7BF2BF27A9669A001865A3 /* TagNamespace.swift */, - AB0CFBCA27C0B07F004BD372 /* TagSuggestion.swift */, - AB7BF2C527A968AB001865A3 /* TranslatableLanguage.swift */, - AB0CFBCC27C1CC67004BD372 /* EhTagTranslationDatabaseModel.swift */, - ); - path = Tags; - sourceTree = ""; - }; - AB24C55D276756A40085C33A /* Support */ = { - isa = PBXGroup; - children = ( - ABF45AC125F3313D00ECB568 /* FiltersView.swift */, - AB706F98278A820C0025A48A /* FiltersReducer.swift */, - ABC1FAB72642C37D00A9F352 /* NewDawnView.swift */, - ABF45AC625F3313D00ECB568 /* Components */, - ); - path = Support; - sourceTree = ""; - }; - AB24C55F276757240085C33A /* Favorites */ = { - isa = PBXGroup; - children = ( - AB24C55927674EDF0085C33A /* FavoritesView.swift */, - ABD49D59277C5356003D1A07 /* FavoritesReducer.swift */, - ); - path = Favorites; - sourceTree = ""; - }; - AB24C561276757A30085C33A /* Support */ = { - isa = PBXGroup; - children = ( - EA5AA4A32EA9149E00BC2B5C /* AutoPlayHandler.swift */, - EA5AA4A42EA9149E00BC2B5C /* GestureHandler.swift */, - EA5AA4A52EA9149E00BC2B5C /* LiveTextHandler.swift */, - EA5AA4A62EA9149E00BC2B5C /* PageHandler.swift */, - ABC732C627B90F0900D47DA9 /* LiveTextView.swift */, - AB69CB8126B3DAF400699359 /* ControlPanel.swift */, - AB69CB7F26B3DABC00699359 /* AdvancedList.swift */, - ); - path = Support; - sourceTree = ""; - }; - AB24C562276757B00085C33A /* Components */ = { - isa = PBXGroup; - children = ( - ABF45ACB25F3313D00ECB568 /* LinkedText.swift */, - ABF45ACA25F3313D00ECB568 /* RatingView.swift */, - AB0CFBD627C3B2D0004BD372 /* TagDetailView.swift */, - ABF45AC825F3313D00ECB568 /* PostCommentView.swift */, - ); - path = Components; - sourceTree = ""; - }; - AB24C563276757C30085C33A /* Support */ = { - isa = PBXGroup; - children = ( - ABE9401426FF158D0085E158 /* QuickSearchView.swift */, - ABBB266D27998479007B6149 /* QuickSearchReducer.swift */, - ); - path = Support; - sourceTree = ""; - }; - AB24C564276758D00085C33A /* Cells */ = { - isa = PBXGroup; - children = ( - ABF45ACE25F3313D00ECB568 /* GalleryDetailCell.swift */, - ABD4032526B78E5A00001B8C /* GalleryThumbnailCell.swift */, - AB24C565276758E30085C33A /* GalleryCardCell.swift */, - ABBD2B5F2768D7AD0072AED2 /* GalleryRankingCell.swift */, - AB706F9E278AD4800025A48A /* GalleryHistoryCell.swift */, - ); - path = Cells; - sourceTree = ""; - }; - AB31CD2E27B666D500F40E0A /* Models */ = { - isa = PBXGroup; - children = ( - AB31CD2F27B666E200F40E0A /* TestError.swift */, - AB31CD3627B6695800F40E0A /* HTMLFilename.swift */, - AB31CD3A27B66E0300F40E0A /* ListParserTestType.swift */, - ); - path = Models; - sourceTree = ""; - }; - AB31CD3327B6674B00F40E0A /* List */ = { - isa = PBXGroup; - children = ( - ABD9770D27B65A7300983DE7 /* ListParserTests.swift */, - ); - path = List; - sourceTree = ""; - }; - AB31CD3427B6675100F40E0A /* Gallery */ = { - isa = PBXGroup; - children = ( - ABD9770F27B65E3400983DE7 /* GalleryDetailParserTests.swift */, - AB31CD4227B676C300F40E0A /* GalleryMPVKeysParserTests.swift */, - AB31CD3C27B66F7D00F40E0A /* GalleryImageURLParserTests.swift */, - ); - path = Gallery; - sourceTree = ""; - }; - AB31CD3527B6675D00F40E0A /* Other */ = { - isa = PBXGroup; - children = ( - ABD9771227B6612400983DE7 /* GreetingParserTests.swift */, - AB0CFB8127BBBFCE004BD372 /* EhSettingParserTests.swift */, - AB31CD3127B6671400F40E0A /* BanIntervalParserTests.swift */, - EAB100012F1E000100000011 /* DownloadPageErrorParserTests.swift */, - EAB100012F1E000100000012 /* SettingDownloadTests.swift */, - ); - path = Other; - sourceTree = ""; - }; - AB3E9E6126D210B1008FE518 /* EhPandaTests */ = { - isa = PBXGroup; - children = ( - ABA12F2E27D49AD10021922D /* Tests */, - AB31CD2E27B666D500F40E0A /* Models */, - AB3E9E6226D210B1008FE518 /* Resources */, - ); - path = EhPandaTests; - sourceTree = ""; - }; - AB3E9E6226D210B1008FE518 /* Resources */ = { - isa = PBXGroup; - children = ( - AB3E9E6C26D210B1008FE518 /* Utility */, - AB3E9E6326D210B1008FE518 /* Parser */, - ); - path = Resources; - sourceTree = ""; - }; - AB3E9E6326D210B1008FE518 /* Parser */ = { - isa = PBXGroup; - children = ( - AB3E9E6626D210B1008FE518 /* List */, - AB3E9E6426D210B1008FE518 /* Gallery */, - ABD9771127B65F9D00983DE7 /* Other */, - ); - path = Parser; - sourceTree = ""; - }; - AB3E9E6426D210B1008FE518 /* Gallery */ = { - isa = PBXGroup; - children = ( - AB3E9E6526D210B1008FE518 /* GalleryDetail.html */, - AB31CD4027B6769F00F40E0A /* GalleryMPVKeys.html */, - AB31CD3E27B670FD00F40E0A /* GalleryNormalImageURL.html */, - ); - path = Gallery; - sourceTree = ""; - }; - AB3E9E6626D210B1008FE518 /* List */ = { - isa = PBXGroup; - children = ( - AB41DB3727B760D700DD3604 /* FavoritesCompactList.html */, - AB41DB2F27B760D700DD3604 /* FavoritesExtendedList.html */, - AB41DB3827B760D700DD3604 /* FavoritesMinimalList.html */, - AB41DB3427B760D700DD3604 /* FavoritesMinimalPlusList.html */, - AB41DB2D27B760D700DD3604 /* FavoritesThumbnailList.html */, - AB41DB3127B760D700DD3604 /* FrontPageCompactList.html */, - AB41DB2A27B760D600DD3604 /* FrontPageExtendedList.html */, - AB41DB3027B760D700DD3604 /* FrontPageMinimalList.html */, - AB41DB2B27B760D600DD3604 /* FrontPageMinimalPlusList.html */, - AB41DB3627B760D700DD3604 /* FrontPageThumbnailList.html */, - AB41DB3B27B760D700DD3604 /* PopularCompactList.html */, - AB41DB2C27B760D600DD3604 /* PopularExtendedList.html */, - AB41DB3C27B760D700DD3604 /* PopularMinimalList.html */, - AB41DB3227B760D700DD3604 /* PopularMinimalPlusList.html */, - AB41DB2927B760D600DD3604 /* PopularThumbnailList.html */, - AB41DB2E27B760D700DD3604 /* ToplistsCompactList.html */, - AB41DB2827B760D600DD3604 /* WatchedCompactList.html */, - AB41DB3327B760D700DD3604 /* WatchedExtendedList.html */, - AB41DB3A27B760D700DD3604 /* WatchedMinimalList.html */, - AB41DB3927B760D700DD3604 /* WatchedMinimalPlusList.html */, - AB41DB3527B760D700DD3604 /* WatchedThumbnailList.html */, - ); - path = List; - sourceTree = ""; - }; - AB3E9E6C26D210B1008FE518 /* Utility */ = { - isa = PBXGroup; - children = ( - AB3E9E6D26D210B1008FE518 /* TestHelper.swift */, - ABAB5B9427EF023300198597 /* Extensions.swift */, - ); - path = Utility; - sourceTree = ""; - }; - AB40CFE52598423E00D1DC9A /* Tools */ = { - isa = PBXGroup; - children = ( - AB706F7E278981210025A48A /* Extensions */, - AB0929C12781589000F107CA /* Clients */, - ABD49D65277EAC7E003D1A07 /* Utilities */, - ABCD2F0D25976B95008E5A20 /* Parser.swift */, - ABC3C76D2593699A00E0C11B /* Defaults.swift */, - AB38A0CA25CA993D00764D64 /* ColorCodable.swift */, - EA698C022CCDD2FB0058BC19 /* EquatableVoid.swift */, - EA698C082CCDE7050058BC19 /* IdentifiableBox.swift */, - ABBB2670279AFA61007B6149 /* EnvironmentKeys.swift */, - ); - path = Tools; - sourceTree = ""; - }; - AB47FDA625BC823F0007765D /* Icons */ = { - isa = PBXGroup; - children = ( - AB0CFB6927BAB9CF004BD372 /* AppIcon_Default_iPad_Pro@2x.png */, - AB0CFB6C27BAB9CF004BD372 /* AppIcon_Default_iPad.png */, - AB0CFB6627BAB9CF004BD372 /* AppIcon_Default_iPad@2x.png */, - AB0CFB6B27BAB9CF004BD372 /* AppIcon_Default@2x.png */, - AB0CFB6527BAB9CF004BD372 /* AppIcon_Default@3x.png */, - AB0CFB8627BBD2D7004BD372 /* AppIcon_Developer_iPad_Pro@2x.png */, - AB0CFB8527BBD2D7004BD372 /* AppIcon_Developer_iPad.png */, - AB0CFB8327BBD2D7004BD372 /* AppIcon_Developer_iPad@2x.png */, - AB0CFB8427BBD2D7004BD372 /* AppIcon_Developer@2x.png */, - AB902768291F548700697256 /* AppIcon_NotMyPresident_iPad_Pro@2x.png */, - AB902769291F548700697256 /* AppIcon_NotMyPresident_iPad.png */, - AB902767291F548600697256 /* AppIcon_NotMyPresident_iPad@2x.png */, - AB90276A291F548700697256 /* AppIcon_NotMyPresident@2x.png */, - AB902766291F548600697256 /* AppIcon_NotMyPresident@3x.png */, - ABE9012027F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad_Pro@2x.png */, - ABE9011E27F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad.png */, - ABE9012127F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad@2x.png */, - ABE9011D27F722D000F3651D /* AppIcon_StandWithUkraine2022@2x.png */, - ABE9011F27F722D100F3651D /* AppIcon_StandWithUkraine2022@3x.png */, - AB0CFB8727BBD2D7004BD372 /* AppIcon_Developer@3x.png */, - AB0CFB8F27BBD323004BD372 /* AppIcon_Ukiyoe_iPad_Pro@2x.png */, - AB0CFB9027BBD323004BD372 /* AppIcon_Ukiyoe_iPad.png */, - AB0CFB8D27BBD323004BD372 /* AppIcon_Ukiyoe_iPad@2x.png */, - AB0CFB9127BBD323004BD372 /* AppIcon_Ukiyoe@2x.png */, - AB0CFB8E27BBD323004BD372 /* AppIcon_Ukiyoe@3x.png */, - ); - path = Icons; - sourceTree = ""; - }; - AB5BE67726B95FDD007D4A55 /* ShareExtension */ = { - isa = PBXGroup; - children = ( - AB5BE67826B95FDD007D4A55 /* ShareViewController.swift */, - AB5BE67D26B95FDD007D4A55 /* Info.plist */, - ); - path = ShareExtension; - sourceTree = ""; - }; - AB706F7E278981210025A48A /* Extensions */ = { - isa = PBXGroup; - children = ( - ABA732D825A8018A00B3D9AB /* Extensions.swift */, - ABC3C7762593699A00E0C11B /* ViewModifiers.swift */, - AB706F7F278981370025A48A /* AlertKit_Extension.swift */, - AB7BF2D927AA78CF001865A3 /* Reducer_Extension.swift */, - ABBB26392792588F007B6149 /* TTProgressHUD_Extension.swift */, - ABBB2637278FBD2F007B6149 /* SwiftUINavigation_Extension.swift */, - ); - path = Extensions; - sourceTree = ""; - }; - AB7B29F326AC472B00EE1F14 /* MODefinition */ = { - isa = PBXGroup; - children = ( - AB63EADC2699AC9100090535 /* AppEnvMO+CoreDataClass.swift */, - AB63EADA2699AC8200090535 /* AppEnvMO+CoreDataProperties.swift */, - ABCA93BF2691925900A98BC6 /* GalleryMO+CoreDataClass.swift */, - AB2CED63268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift */, - ABCA93C12691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift */, - AB4FD2C0268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift */, - AB10117F26986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift */, - AB10117D26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift */, - EA8C4D262F0E100100000014 /* DownloadedGalleryMO+CoreDataClass.swift */, - EA8C4D262F0E100100000015 /* DownloadedGalleryMO+CoreDataProperties.swift */, - ); - path = MODefinition; - sourceTree = ""; - }; - AB7B29F426AC475300EE1F14 /* Migration */ = { - isa = PBXGroup; - children = ( - AB7BF30327ABDFF1001865A3 /* CoreDataMigrationStep.swift */, - AB7BF30627ABDFF1001865A3 /* CoreDataMigrationVersion.swift */, - AB7BF2FE27ABDFF1001865A3 /* CoreDataMigrator.swift */, - AB7BF2FF27ABDFF1001865A3 /* Mappings */, - AB7BF30127ABDFF1001865A3 /* Policies */, - ); - path = Migration; - sourceTree = ""; - }; - AB7BF2B827A96559001865A3 /* Gallery */ = { - isa = PBXGroup; - children = ( - AB7BF2B927A96562001865A3 /* Gallery.swift */, - AB7BF2C127A96760001865A3 /* GalleryDetail.swift */, - AB7BF2C927A969F4001865A3 /* GalleryState.swift */, - AB7BF2C327A9683F001865A3 /* GalleryArchive.swift */, - AB7BF2CB27A96A3C001865A3 /* GalleryTorrent.swift */, - AB7BF2C727A968F7001865A3 /* GalleryComment.swift */, - AB7BF2BB27A965DA001865A3 /* Category.swift */, - AB7BF2A827A63C89001865A3 /* Language.swift */, - ); - path = Gallery; - sourceTree = ""; - }; - AB7BF2BD27A9663E001865A3 /* Persistent */ = { - isa = PBXGroup; - children = ( - ABF75F3E25A19CD200544D29 /* User.swift */, - ABA732DE25A852D800B3D9AB /* Filter.swift */, - ABEA1FE525A9B40B002966B9 /* Setting.swift */, - AB26F59527ACCA1800AB3468 /* AppEnv.swift */, - EA8C4D262F0E100100000013 /* DownloadedGallery.swift */, - AB7BF2B627A9652F001865A3 /* Greeting.swift */, - ); - path = Persistent; - sourceTree = ""; - }; - AB7BF2BE27A96674001865A3 /* Support */ = { - isa = PBXGroup; - children = ( - ABF313A425B1AB6600D47A2F /* Misc.swift */, - ABC732C427B9024500D47DA9 /* LiveText.swift */, - ABF45AB625F3312F00ECB568 /* AppError.swift */, - AB8C821826BF801700E8C5E6 /* EhSetting.swift */, - AB7BF2AA27A642FB001865A3 /* BrowsingCountry.swift */, - ); - path = Support; - sourceTree = ""; - }; - AB7BF2F927ABCA20001865A3 /* Migration */ = { - isa = PBXGroup; - children = ( - AB7BF2FA27ABCA3A001865A3 /* MigrationView.swift */, - AB7BF2FC27ABCAD4001865A3 /* MigrationReducer.swift */, - ); - path = Migration; - sourceTree = ""; - }; - AB7BF2FF27ABDFF1001865A3 /* Mappings */ = { - isa = PBXGroup; - children = ( - AB26F58F27ABF21000AB3468 /* Model5toModel6.xcmappingmodel */, - ); - path = Mappings; - sourceTree = ""; - }; - AB7BF30127ABDFF1001865A3 /* Policies */ = { - isa = PBXGroup; - children = ( - AB7B29F126AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift */, - ); - path = Policies; - sourceTree = ""; - }; - AB7BF30E27ABE028001865A3 /* Extensions */ = { - isa = PBXGroup; - children = ( - AB7BF31227ABE028001865A3 /* NSManagedObjectModel */, - AB7BF31527ABE028001865A3 /* FileManager */, - AB7BF31727ABE028001865A3 /* NSPersistentStoreCoordinator */, - ); - path = Extensions; - sourceTree = ""; - }; - AB7BF31227ABE028001865A3 /* NSManagedObjectModel */ = { - isa = PBXGroup; - children = ( - AB7BF31327ABE028001865A3 /* NSManagedObjectModel+Resource.swift */, - AB7BF31427ABE028001865A3 /* NSManagedObjectModel+Compatible.swift */, - ); - path = NSManagedObjectModel; - sourceTree = ""; - }; - AB7BF31527ABE028001865A3 /* FileManager */ = { - isa = PBXGroup; - children = ( - AB7BF31627ABE028001865A3 /* FileManager+ApplicationSupport.swift */, - ); - path = FileManager; - sourceTree = ""; - }; - AB7BF31727ABE028001865A3 /* NSPersistentStoreCoordinator */ = { - isa = PBXGroup; - children = ( - AB7BF31827ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift */, - ); - path = NSPersistentStoreCoordinator; - sourceTree = ""; - }; - AB821BEF268A09AC009B2381 /* Database */ = { - isa = PBXGroup; - children = ( - ABC681F126898D46007BBD69 /* Model.xcdatamodeld */, - AB7B29F426AC475300EE1F14 /* Migration */, - AB7BF30E27ABE028001865A3 /* Extensions */, - AB7B29F326AC472B00EE1F14 /* MODefinition */, - ABCA93BD26918DE100A98BC6 /* Persistence.swift */, - ); - path = Database; - sourceTree = ""; - }; AB82819926B1C39B00A80CFA /* Frameworks */ = { isa = PBXGroup; children = ( @@ -1120,41 +168,12 @@ name = Frameworks; sourceTree = ""; }; - AB86AC112783226100E61E6A /* Search */ = { - isa = PBXGroup; - children = ( - AB706F9A278AC5A30025A48A /* SearchRootView.swift */, - AB706F9C278ACCA20025A48A /* SearchRootReducer.swift */, - ABBB2630278E6EF3007B6149 /* SearchView.swift */, - EA2E2E812A1FA1050038A261 /* SearchReducer.swift */, - AB24C563276757C30085C33A /* Support */, - ); - path = Search; - sourceTree = ""; - }; - ABA12F2E27D49AD10021922D /* Tests */ = { - isa = PBXGroup; - children = ( - ABD9770C27B65A5300983DE7 /* Parser */, - EAB100012F1E000100000021 /* Download */, - ); - path = Tests; - sourceTree = ""; - }; - ABA9A6C028EC7BD000EE28DE /* Generated */ = { - isa = PBXGroup; - children = ( - ABA9A6C128EC7BD000EE28DE /* Strings.swift */, - ); - path = Generated; - sourceTree = ""; - }; ABC3C74B2593696C00E0C11B = { isa = PBXGroup; children = ( - ABC3C7562593696C00E0C11B /* EhPanda */, - AB5BE67726B95FDD007D4A55 /* ShareExtension */, - AB3E9E6126D210B1008FE518 /* EhPandaTests */, + A6844B3D2F780C8600BBF6E5 /* EhPanda */, + A6844C272F780C8B00BBF6E5 /* ShareExtension */, + A6844C652F780C9C00BBF6E5 /* EhPandaTests */, EA0C92472C3EB44300D211F6 /* Config */, EA0C92422C3EB40100D211F6 /* GitHub */, EA0C92522C3EB47F00D211F6 /* READMEs */, @@ -1173,217 +192,6 @@ name = Products; sourceTree = ""; }; - ABC3C7562593696C00E0C11B /* EhPanda */ = { - isa = PBXGroup; - children = ( - ABC3C7682593699A00E0C11B /* App */, - ABF45AB325F3312F00ECB568 /* DataFlow */, - ABC3C77B2593699A00E0C11B /* Network */, - ABC3C77F2593699A00E0C11B /* Models */, - AB821BEF268A09AC009B2381 /* Database */, - ABF45ABF25F3313D00ECB568 /* View */, - ABD5FDD3263D05110021A4C6 /* .swiftlint.yml */, - ABA9A6BB28EC786100EE28DE /* swiftgen.yml */, - ABF53F4725A306D200AB5918 /* EhPanda.entitlements */, - ); - path = EhPanda; - sourceTree = ""; - }; - ABC3C7682593699A00E0C11B /* App */ = { - isa = PBXGroup; - children = ( - ABC3C76B2593699A00E0C11B /* EhPandaApp.swift */, - AB40CFE52598423E00D1DC9A /* Tools */, - AB47FDA625BC823F0007765D /* Icons */, - ABC3C7692593699A00E0C11B /* Assets.xcassets */, - ABC3C76E2593699A00E0C11B /* Info.plist */, - AB7E6B3225D24FE00035CC68 /* InfoPlist.strings */, - ABA9A6BD28EC7BA200EE28DE /* Constant.strings */, - ABEE0AFC2595C6F800C997AE /* Localizable.strings */, - ABA9A6C028EC7BD000EE28DE /* Generated */, - ); - path = App; - sourceTree = ""; - }; - ABC3C77B2593699A00E0C11B /* Network */ = { - isa = PBXGroup; - children = ( - ABCD2F09259763FC008E5A20 /* Request.swift */, - AB358318269D9996009466A5 /* DomainResolver.swift */, - AB358312269D7E89009466A5 /* DFRequest.swift */, - AB358316269D826B009466A5 /* DFStreamHandler.swift */, - AB358314269D821D009466A5 /* DFExtensions.swift */, - AB358310269D7B63009466A5 /* DFURLProtocol.swift */, - ); - path = Network; - sourceTree = ""; - }; - ABC3C77F2593699A00E0C11B /* Models */ = { - isa = PBXGroup; - children = ( - AB7BF2B827A96559001865A3 /* Gallery */, - AB7BF2BD27A9663E001865A3 /* Persistent */, - AB1FA8FA27C5DE800063EF55 /* Tags */, - AB7BF2BE27A96674001865A3 /* Support */, - ); - path = Models; - sourceTree = ""; - }; - ABD49D5E277C7715003D1A07 /* TabBar */ = { - isa = PBXGroup; - children = ( - ABD49D5F277C7722003D1A07 /* TabBarView.swift */, - ABD49D63277C7AD5003D1A07 /* TabBarReducer.swift */, - ); - path = TabBar; - sourceTree = ""; - }; - ABD49D65277EAC7E003D1A07 /* Utilities */ = { - isa = PBXGroup; - children = ( - ABD49D66277EAC90003D1A07 /* URLUtil.swift */, - AB7BF2CD27AA3E58001865A3 /* AppUtil.swift */, - AB7BF2D527AA3F4C001865A3 /* FileUtil.swift */, - EA8C4D262F0E100100000012 /* DownloadFileStorage.swift */, - AB7BF2CF27AA3E75001865A3 /* DeviceUtil.swift */, - AB7BF2D127AA3EDC001865A3 /* HapticsUtil.swift */, - AB7BF2D327AA3F12001865A3 /* CookieUtil.swift */, - AB0CFBD427C24B3B004BD372 /* MarkdownUtil.swift */, - AB7BF2D727AA3F61001865A3 /* UserDefaultsUtil.swift */, - ); - path = Utilities; - sourceTree = ""; - }; - ABD9770C27B65A5300983DE7 /* Parser */ = { - isa = PBXGroup; - children = ( - AB31CD3327B6674B00F40E0A /* List */, - AB31CD3427B6675100F40E0A /* Gallery */, - AB31CD3527B6675D00F40E0A /* Other */, - ); - path = Parser; - sourceTree = ""; - }; - ABD9771127B65F9D00983DE7 /* Other */ = { - isa = PBXGroup; - children = ( - AB0CFB7F27BBBFA0004BD372 /* EhSetting.html */, - ABC0A8D026F7037F008EC24C /* IPBanned.html */, - ABF9720926DE6E1300118887 /* GalleryDetailWithGreeting.html */, - EAB100012F1E000100000015 /* BandwidthExceeded.html */, - EAB100012F1E000100000016 /* ExLoginRequired.html */, - EAB100012F1E000100000019 /* Kokomade.jpg */, - ); - path = Other; - sourceTree = ""; - }; - ABF45AB325F3312F00ECB568 /* DataFlow */ = { - isa = PBXGroup; - children = ( - AB1EF25327AFA19200F507D6 /* Heap.swift */, - AB58A5B12776B99000C0D285 /* AppReducer.swift */, - AB86AC1227856F2700E61E6A /* AppLockReducer.swift */, - AB706F7827890A6C0025A48A /* AppRouteReducer.swift */, - AB58A5AB2776B2BC00C0D285 /* AppDelegateReducer.swift */, - ); - path = DataFlow; - sourceTree = ""; - }; - ABF45ABF25F3313D00ECB568 /* View */ = { - isa = PBXGroup; - children = ( - AB7BF2F927ABCA20001865A3 /* Migration */, - ABD49D5E277C7715003D1A07 /* TabBar */, - ABF45AC025F3313D00ECB568 /* Home */, - AB24C55F276757240085C33A /* Favorites */, - AB86AC112783226100E61E6A /* Search */, - EAA100002F1D000100000001 /* Downloads */, - ABF45AD125F3313D00ECB568 /* Detail */, - ABF45ACF25F3313D00ECB568 /* Reading */, - ABF45AD725F3313D00ECB568 /* Setting */, - AB24C55D276756A40085C33A /* Support */, - ); - path = View; - sourceTree = ""; - }; - ABF45AC025F3313D00ECB568 /* Home */ = { - isa = PBXGroup; - children = ( - EA7E47EC2A210C4300971697 /* History */, - EA7E47EA2A2103FE00971697 /* Popular */, - EA7E47E92A2102BA00971697 /* Toplists */, - EA7E47EB2A2107CF00971697 /* Watched */, - EA7E47E82A21015400971697 /* Frontpage */, - AB24C55B2767565A0085C33A /* HomeView.swift */, - AB86AC192785C2B300E61E6A /* HomeReducer.swift */, - ); - path = Home; - sourceTree = ""; - }; - ABF45AC625F3313D00ECB568 /* Components */ = { - isa = PBXGroup; - children = ( - AB24C564276758D00085C33A /* Cells */, - EAA100012F1D000100000016 /* DownloadBadgeLabel.swift */, - EAA100012F1D000100000018 /* DownloadBadgeStore.swift */, - EAA100012F1D00010000001A /* PreviewImageView.swift */, - AB7B29F526AC741600EE1F14 /* GenericList.swift */, - ABD4032726B7967F00001B8C /* CategoryView.swift */, - ABF45ACD25F3313D00ECB568 /* Placeholder.swift */, - ABF45AC725F3313D00ECB568 /* TagCloudView.swift */, - ABBC332926BE7C940084A331 /* SettingTextField.swift */, - ABF45ACC25F3313D00ECB568 /* AlertView.swift */, - AB0ABCB626C541A400AD970F /* WaveForm.swift */, - AB3072D1276D734800EFF242 /* SubSection.swift */, - AB706F81278986120025A48A /* ToolbarItems.swift */, - ABBB26672797BFAA007B6149 /* ActivityView.swift */, - AB0CFBC827C07F95004BD372 /* TagSuggestionView.swift */, - ); - path = Components; - sourceTree = ""; - }; - ABF45ACF25F3313D00ECB568 /* Reading */ = { - isa = PBXGroup; - children = ( - ABBB2672279B9332007B6149 /* ReadingView.swift */, - ABBB2674279B933D007B6149 /* ReadingReducer.swift */, - AB24C561276757A30085C33A /* Support */, - ); - path = Reading; - sourceTree = ""; - }; - ABF45AD125F3313D00ECB568 /* Detail */ = { - isa = PBXGroup; - children = ( - EA2E2E852A20E40B0038A261 /* Torrents */, - EA2E2E842A20E1840038A261 /* Archives */, - EA2E2E862A20E52C0038A261 /* Previews */, - EA2E2E872A20E6C90038A261 /* Comments */, - EA2E2E882A20E9C50038A261 /* GalleryInfos */, - EA2E2E892A20EA460038A261 /* DetailSearch */, - AB24C562276757B00085C33A /* Components */, - AB706FA0278BCEC60025A48A /* DetailView.swift */, - AB706FA2278BCF2F0025A48A /* DetailReducer.swift */, - ); - path = Detail; - sourceTree = ""; - }; - ABF45AD725F3313D00ECB568 /* Setting */ = { - isa = PBXGroup; - children = ( - EA2E2E792A1F78980038A261 /* Logs */, - EA2B9B062A0A8A7C00E7BA07 /* Login */, - EAEC870B2A1F74D500E1A97A /* EhSetting */, - EA2E2E7B2A1F7AEF0038A261 /* GeneralSetting */, - EA2B9B042A0A89C900E7BA07 /* AccountSetting */, - EA2E2E7D2A1F7D390038A261 /* AppearanceSetting */, - EA2E2E802A1F7F2A0038A261 /* Components */, - ABF45ADD25F3313D00ECB568 /* SettingView.swift */, - EA2E2E7E2A1F7E500038A261 /* SettingReducer.swift */, - ); - path = Setting; - sourceTree = ""; - }; EA0C92422C3EB40100D211F6 /* GitHub */ = { isa = PBXGroup; children = ( @@ -1419,191 +227,6 @@ name = READMEs; sourceTree = ""; }; - EA2B9B042A0A89C900E7BA07 /* AccountSetting */ = { - isa = PBXGroup; - children = ( - ABF45AD925F3313D00ECB568 /* AccountSettingView.swift */, - AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */, - ); - path = AccountSetting; - sourceTree = ""; - }; - EA2B9B062A0A8A7C00E7BA07 /* Login */ = { - isa = PBXGroup; - children = ( - AB0ABCB426C5406400AD970F /* LoginView.swift */, - AB0929BF27805A8200F107CA /* LoginReducer.swift */, - ); - path = Login; - sourceTree = ""; - }; - EA2E2E792A1F78980038A261 /* Logs */ = { - isa = PBXGroup; - children = ( - AB6DE896268822390087C579 /* LogsView.swift */, - AB86ABF42782DAB300E61E6A /* LogsReducer.swift */, - ); - path = Logs; - sourceTree = ""; - }; - EA2E2E7B2A1F7AEF0038A261 /* GeneralSetting */ = { - isa = PBXGroup; - children = ( - ABF45AD825F3313D00ECB568 /* GeneralSettingView.swift */, - AB0929D52782A65F00F107CA /* GeneralSettingReducer.swift */, - ); - path = GeneralSetting; - sourceTree = ""; - }; - EA2E2E7D2A1F7D390038A261 /* AppearanceSetting */ = { - isa = PBXGroup; - children = ( - ABF45ADC25F3313D00ECB568 /* AppearanceSettingView.swift */, - AB86AC092782FAFA00E61E6A /* AppearanceSettingReducer.swift */, - ); - path = AppearanceSetting; - sourceTree = ""; - }; - EA2E2E802A1F7F2A0038A261 /* Components */ = { - isa = PBXGroup; - children = ( - EAA100012F1D000100000015 /* DownloadSettingView.swift */, - ABF45ADB25F3313D00ECB568 /* ReadingSettingView.swift */, - ABE1867726A1733000689FDC /* LaboratorySettingView.swift */, - AB86ABF82782EC0D00E61E6A /* AboutView.swift */, - ABF45ADA25F3313D00ECB568 /* WebView.swift */, - ); - path = Components; - sourceTree = ""; - }; - EA2E2E842A20E1840038A261 /* Archives */ = { - isa = PBXGroup; - children = ( - ABF45AD325F3313D00ECB568 /* ArchivesView.swift */, - ABBB266527977C2A007B6149 /* ArchivesReducer.swift */, - ); - path = Archives; - sourceTree = ""; - }; - EA2E2E852A20E40B0038A261 /* Torrents */ = { - isa = PBXGroup; - children = ( - ABF45AD425F3313D00ECB568 /* TorrentsView.swift */, - ABBB26692797C61F007B6149 /* TorrentsReducer.swift */, - ); - path = Torrents; - sourceTree = ""; - }; - EA2E2E862A20E52C0038A261 /* Previews */ = { - isa = PBXGroup; - children = ( - AB706FA4278C3DDE0025A48A /* PreviewsView.swift */, - ABBB263D2793C648007B6149 /* PreviewsReducer.swift */, - ); - path = Previews; - sourceTree = ""; - }; - EA2E2E872A20E6C90038A261 /* Comments */ = { - isa = PBXGroup; - children = ( - ABF45AD525F3313D00ECB568 /* CommentsView.swift */, - ABBB263F279417EC007B6149 /* CommentsReducer.swift */, - ); - path = Comments; - sourceTree = ""; - }; - EA2E2E882A20E9C50038A261 /* GalleryInfos */ = { - isa = PBXGroup; - children = ( - ABBCCC8F26C95F6E007D8A36 /* GalleryInfosView.swift */, - ABBB2678279D454C007B6149 /* GalleryInfosReducer.swift */, - ); - path = GalleryInfos; - sourceTree = ""; - }; - EA2E2E892A20EA460038A261 /* DetailSearch */ = { - isa = PBXGroup; - children = ( - ABC8355C27B118330091DCDB /* DetailSearchView.swift */, - ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */, - ); - path = DetailSearch; - sourceTree = ""; - }; - EA7E47E82A21015400971697 /* Frontpage */ = { - isa = PBXGroup; - children = ( - AB3072D3276E19AA00EFF242 /* FrontpageView.swift */, - AB706F7A278937500025A48A /* FrontpageReducer.swift */, - ); - path = Frontpage; - sourceTree = ""; - }; - EA7E47E92A2102BA00971697 /* Toplists */ = { - isa = PBXGroup; - children = ( - AB706F832789AD2D0025A48A /* ToplistsView.swift */, - AB706F852789AD490025A48A /* ToplistsReducer.swift */, - ); - path = Toplists; - sourceTree = ""; - }; - EA7E47EA2A2103FE00971697 /* Popular */ = { - isa = PBXGroup; - children = ( - AB706F87278A4C8A0025A48A /* PopularView.swift */, - AB706F89278A4CC50025A48A /* PopularReducer.swift */, - ); - path = Popular; - sourceTree = ""; - }; - EA7E47EB2A2107CF00971697 /* Watched */ = { - isa = PBXGroup; - children = ( - AB706F8B278A4F6C0025A48A /* WatchedView.swift */, - AB706F91278A6E8C0025A48A /* WatchedReducer.swift */, - ); - path = Watched; - sourceTree = ""; - }; - EA7E47EC2A210C4300971697 /* History */ = { - isa = PBXGroup; - children = ( - AB706F94278A75D30025A48A /* HistoryView.swift */, - AB706F96278A77E20025A48A /* HistoryReducer.swift */, - ); - path = History; - sourceTree = ""; - }; - EAA100002F1D000100000001 /* Downloads */ = { - isa = PBXGroup; - children = ( - EAA100012F1D000100000012 /* DownloadsView.swift */, - EAA100012F1D000100000011 /* DownloadsReducer.swift */, - EAA100012F1D000100000017 /* DownloadFiltersView.swift */, - ); - path = Downloads; - sourceTree = ""; - }; - EAB100012F1E000100000021 /* Download */ = { - isa = PBXGroup; - children = ( - EAB100012F1E000100000013 /* DownloadFileStorageTests.swift */, - EAB100012F1E000100000014 /* DownloadFeatureReducerTests.swift */, - EAB100012F1E000100000017 /* DownloadSignatureBuilderTests.swift */, - ); - path = Download; - sourceTree = ""; - }; - EAEC870B2A1F74D500E1A97A /* EhSetting */ = { - isa = PBXGroup; - children = ( - ABBC332726BE31AE0084A331 /* EhSettingView.swift */, - AB0929BD2780032400F107CA /* EhSettingReducer.swift */, - ); - path = EhSetting; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1641,6 +264,9 @@ A66A766D2F77C88A00FC07B8 /* PBXTargetDependency */, AB5BE67F26B95FDD007D4A55 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + A6844B3D2F780C8600BBF6E5 /* EhPanda */, + ); name = EhPanda; packageProductDependencies = ( AB65059F26B0027800F91E9D /* SwiftUIPager */, @@ -1678,6 +304,9 @@ A66A76712F77C89600FC07B8 /* PBXTargetDependency */, ABF294D126D20F82004DD03A /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + A6844C652F780C9C00BBF6E5 /* EhPandaTests */, + ); name = EhPandaTests; productName = EhPandaTests; productReference = ABF294CC26D20F82004DD03A /* EhPandaTests.xctest */; @@ -1691,7 +320,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1300; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 2640; TargetAttributes = { AB5BE67526B95FDD007D4A55 = { CreatedOnToolsVersion = 13.0; @@ -1739,7 +368,7 @@ EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */, A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */, ); - preferredProjectObjectVersion = 56; + preferredProjectObjectVersion = 100; productRefGroup = ABC3C7552593696C00E0C11B /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1760,80 +389,20 @@ ABC3C7522593696C00E0C11B /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( - AB0CFB8C27BBD2D7004BD372 /* AppIcon_Developer@3x.png in Resources */, - AB0CFB9627BBD323004BD372 /* AppIcon_Ukiyoe@2x.png in Resources */, - AB90276E291F548700697256 /* AppIcon_NotMyPresident_iPad.png in Resources */, - AB90276D291F548700697256 /* AppIcon_NotMyPresident_iPad_Pro@2x.png in Resources */, - AB0CFB7A27BAB9D0004BD372 /* AppIcon_Default@2x.png in Resources */, EA0C92592C3EB49500D211F6 /* README.cht.md in Resources */, - AB0CFB7527BAB9D0004BD372 /* AppIcon_Default_iPad@2x.png in Resources */, - AB0CFB9227BBD323004BD372 /* AppIcon_Ukiyoe_iPad@2x.png in Resources */, - ABC3C7852593699B00E0C11B /* Assets.xcassets in Resources */, - AB0CFB7B27BAB9D0004BD372 /* AppIcon_Default_iPad.png in Resources */, - AB90276C291F548700697256 /* AppIcon_NotMyPresident_iPad@2x.png in Resources */, EA0C925D2C3EB49500D211F6 /* README.chs.md in Resources */, - AB0CFB9527BBD323004BD372 /* AppIcon_Ukiyoe_iPad.png in Resources */, EA0C925C2C3EB49500D211F6 /* README.de.md in Resources */, - ABE9012427F722D100F3651D /* AppIcon_StandWithUkraine2022@3x.png in Resources */, - AB90276B291F548700697256 /* AppIcon_NotMyPresident@3x.png in Resources */, EA0C92452C3EB42300D211F6 /* ISSUE_TEMPLATE in Resources */, - AB0CFB8827BBD2D7004BD372 /* AppIcon_Developer_iPad@2x.png in Resources */, - ABE9012227F722D100F3651D /* AppIcon_StandWithUkraine2022@2x.png in Resources */, EA0C925B2C3EB49500D211F6 /* README.md in Resources */, - AB0CFB7427BAB9D0004BD372 /* AppIcon_Default@3x.png in Resources */, - AB7E6B3025D24FE00035CC68 /* InfoPlist.strings in Resources */, - ABA9A6BC28EC786100EE28DE /* swiftgen.yml in Resources */, - AB0CFB9327BBD323004BD372 /* AppIcon_Ukiyoe@3x.png in Resources */, - AB0CFB8927BBD2D7004BD372 /* AppIcon_Developer@2x.png in Resources */, EA0BBD472E37CCB700DC8143 /* CODEOWNERS in Resources */, - ABEE0AFA2595C6F800C997AE /* Localizable.strings in Resources */, - AB90276F291F548700697256 /* AppIcon_NotMyPresident@2x.png in Resources */, EA0C92462C3EB42300D211F6 /* workflows in Resources */, EA0C925A2C3EB49500D211F6 /* README.ko.md in Resources */, - ABD5FDD4263D05110021A4C6 /* .swiftlint.yml in Resources */, - ABE9012527F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad_Pro@2x.png in Resources */, - ABE9012327F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad.png in Resources */, - AB0CFB7827BAB9D0004BD372 /* AppIcon_Default_iPad_Pro@2x.png in Resources */, - AB0CFB8B27BBD2D7004BD372 /* AppIcon_Developer_iPad_Pro@2x.png in Resources */, EA0C925E2C3EB49500D211F6 /* README.jpn.md in Resources */, - AB0CFB8A27BBD2D7004BD372 /* AppIcon_Developer_iPad.png in Resources */, - ABE9012627F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad@2x.png in Resources */, - AB0CFB9427BBD323004BD372 /* AppIcon_Ukiyoe_iPad_Pro@2x.png in Resources */, ); }; ABF294CA26D20F82004DD03A /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( - AB41DB3F27B760D700DD3604 /* FrontPageExtendedList.html in Resources */, - AB41DB4C27B760D700DD3604 /* FavoritesCompactList.html in Resources */, - AB31CD4127B6769F00F40E0A /* GalleryMPVKeys.html in Resources */, - ABC0A8D126F7037F008EC24C /* IPBanned.html in Resources */, - ABF9720A26DE6E1300118887 /* GalleryDetailWithGreeting.html in Resources */, - AB41DB4927B760D700DD3604 /* FavoritesMinimalPlusList.html in Resources */, - AB41DB4E27B760D700DD3604 /* WatchedMinimalPlusList.html in Resources */, - AB41DB3D27B760D700DD3604 /* WatchedCompactList.html in Resources */, - AB41DB4427B760D700DD3604 /* FavoritesExtendedList.html in Resources */, - AB3E9E6E26D210B1008FE518 /* GalleryDetail.html in Resources */, - AB41DB3E27B760D700DD3604 /* PopularThumbnailList.html in Resources */, - AB41DB4727B760D700DD3604 /* PopularMinimalPlusList.html in Resources */, - AB41DB4827B760D700DD3604 /* WatchedExtendedList.html in Resources */, - AB41DB4F27B760D700DD3604 /* WatchedMinimalList.html in Resources */, - AB41DB4227B760D700DD3604 /* FavoritesThumbnailList.html in Resources */, - AB41DB5027B760D700DD3604 /* PopularCompactList.html in Resources */, - AB41DB4B27B760D700DD3604 /* FrontPageThumbnailList.html in Resources */, - AB41DB4327B760D700DD3604 /* ToplistsCompactList.html in Resources */, - AB0CFB8027BBBFA0004BD372 /* EhSetting.html in Resources */, - EAB100012F1E000100000005 /* BandwidthExceeded.html in Resources */, - EAB100012F1E000100000006 /* ExLoginRequired.html in Resources */, - EAB100012F1E000100000018 /* Kokomade.jpg in Resources */, - AB41DB5127B760D700DD3604 /* PopularMinimalList.html in Resources */, - AB41DB4627B760D700DD3604 /* FrontPageCompactList.html in Resources */, - AB41DB4127B760D700DD3604 /* PopularExtendedList.html in Resources */, - AB41DB4527B760D700DD3604 /* FrontPageMinimalList.html in Resources */, - AB41DB4D27B760D700DD3604 /* FavoritesMinimalList.html in Resources */, - AB41DB4027B760D700DD3604 /* FrontPageMinimalPlusList.html in Resources */, - AB41DB4A27B760D700DD3604 /* WatchedThumbnailList.html in Resources */, - AB31CD3F27B670FD00F40E0A /* GalleryNormalImageURL.html in Resources */, ); }; /* End PBXResourcesBuildPhase section */ @@ -1868,225 +437,16 @@ AB5BE67226B95FDD007D4A55 /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( - AB5BE67926B95FDD007D4A55 /* ShareViewController.swift in Sources */, ); }; ABC3C7502593696C00E0C11B /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( - ABA732D925A8018A00B3D9AB /* Extensions.swift in Sources */, - AB0929D02781E1CC00F107CA /* UIApplicationClient.swift in Sources */, - ABF45AF325F3313D00ECB568 /* AccountSettingView.swift in Sources */, - AB706F842789AD2D0025A48A /* ToplistsView.swift in Sources */, - ABCD2F0A259763FC008E5A20 /* Request.swift in Sources */, - ABF45AE925F3313D00ECB568 /* AlertView.swift in Sources */, - ABF45ABB25F3312F00ECB568 /* AppError.swift in Sources */, - AB10118026986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift in Sources */, - AB63EADB2699AC8200090535 /* AppEnvMO+CoreDataProperties.swift in Sources */, - AB7BF2FD27ABCAD4001865A3 /* MigrationReducer.swift in Sources */, - AB7BF2D827AA3F61001865A3 /* UserDefaultsUtil.swift in Sources */, - EA2E2E822A1FA1060038A261 /* SearchReducer.swift in Sources */, - AB0929CA278196ED00F107CA /* CookieClient.swift in Sources */, - AB7BF2FB27ABCA3A001865A3 /* MigrationView.swift in Sources */, - AB7BF31C27ABE028001865A3 /* NSManagedObjectModel+Compatible.swift in Sources */, - EA2E2E7F2A1F7E500038A261 /* SettingReducer.swift in Sources */, - AB7BF31B27ABE028001865A3 /* NSManagedObjectModel+Resource.swift in Sources */, - ABBC332826BE31AE0084A331 /* EhSettingView.swift in Sources */, - AB7BF2C827A968F7001865A3 /* GalleryComment.swift in Sources */, - AB706F97278A77E20025A48A /* HistoryReducer.swift in Sources */, - AB0929CC2781A0B000F107CA /* HapticsClient.swift in Sources */, - ABD4032626B78E5A00001B8C /* GalleryThumbnailCell.swift in Sources */, - AB0CFBCD27C1CC67004BD372 /* EhTagTranslationDatabaseModel.swift in Sources */, - AB358319269D9996009466A5 /* DomainResolver.swift in Sources */, - AB63EADD2699AC9100090535 /* AppEnvMO+CoreDataClass.swift in Sources */, - AB7BF2C027A9669A001865A3 /* TagNamespace.swift in Sources */, - ABCA93BE26918DE100A98BC6 /* Persistence.swift in Sources */, - AB86ABF92782EC0D00E61E6A /* AboutView.swift in Sources */, - AB7BF2BA27A96562001865A3 /* Gallery.swift in Sources */, - AB0929BE2780032400F107CA /* EhSettingReducer.swift in Sources */, - AB0929D42781EDDC00F107CA /* UserDefaultsClient.swift in Sources */, - AB0929D82782A83A00F107CA /* AuthorizationClient.swift in Sources */, - ABF45AEF25F3313D00ECB568 /* TorrentsView.swift in Sources */, - AB706F99278A820C0025A48A /* FiltersReducer.swift in Sources */, - AB3072D4276E19AA00EFF242 /* FrontpageView.swift in Sources */, - AB3072D2276D734800EFF242 /* SubSection.swift in Sources */, - ABBB26682797BFAA007B6149 /* ActivityView.swift in Sources */, - AB1FA94D27CA1F140063EF55 /* TagTranslation.swift in Sources */, - ABC8355D27B118330091DCDB /* DetailSearchView.swift in Sources */, - ABBB264227942B74007B6149 /* URLClient.swift in Sources */, - AB0CFBD527C24B3B004BD372 /* MarkdownUtil.swift in Sources */, - ABF45AF625F3313D00ECB568 /* AppearanceSettingView.swift in Sources */, - AB7BF2CE27AA3E58001865A3 /* AppUtil.swift in Sources */, - AB86AC1A2785C2B300E61E6A /* HomeReducer.swift in Sources */, - EA698C032CCDD2FB0058BC19 /* EquatableVoid.swift in Sources */, - AB7BF2D427AA3F12001865A3 /* CookieUtil.swift in Sources */, - AB7BF30A27ABDFF1001865A3 /* CoreDataMigrationStep.swift in Sources */, - AB69CB8226B3DAF400699359 /* ControlPanel.swift in Sources */, - AB7BF2D227AA3EDC001865A3 /* HapticsUtil.swift in Sources */, - ABD49D5A277C5356003D1A07 /* FavoritesReducer.swift in Sources */, - AB1EF25427AFA19200F507D6 /* Heap.swift in Sources */, - AB7BF2C227A96760001865A3 /* GalleryDetail.swift in Sources */, - ABE1867826A1733000689FDC /* LaboratorySettingView.swift in Sources */, - ABF45AEB25F3313D00ECB568 /* GalleryDetailCell.swift in Sources */, - AB69CB8026B3DABC00699359 /* AdvancedList.swift in Sources */, - ABC3C7892593699B00E0C11B /* Defaults.swift in Sources */, - AB8C821926BF801700E8C5E6 /* EhSetting.swift in Sources */, - AB86AC1327856F2700E61E6A /* AppLockReducer.swift in Sources */, - AB58A5AC2776B2BC00C0D285 /* AppDelegateReducer.swift in Sources */, - ABBB263E2793C648007B6149 /* PreviewsReducer.swift in Sources */, - ABBC332A26BE7C940084A331 /* SettingTextField.swift in Sources */, - AB358317269D826B009466A5 /* DFStreamHandler.swift in Sources */, - AB7BF2CA27A969F4001865A3 /* GalleryState.swift in Sources */, - AB0929C6278160AE00F107CA /* LibraryClient.swift in Sources */, - ABF45ADF25F3313D00ECB568 /* FiltersView.swift in Sources */, - AB706F9F278AD4800025A48A /* GalleryHistoryCell.swift in Sources */, - AB706F8E278A5DCF0025A48A /* DeviceClient.swift in Sources */, - AB0CFBCB27C0B07F004BD372 /* TagSuggestion.swift in Sources */, - AB7BF30727ABDFF1001865A3 /* CoreDataMigrator.swift in Sources */, - ABC3C78F2593699B00E0C11B /* ViewModifiers.swift in Sources */, - AB86ABF52782DAB300E61E6A /* LogsReducer.swift in Sources */, - AB7BF2AB27A642FB001865A3 /* BrowsingCountry.swift in Sources */, - ABD49D60277C7722003D1A07 /* TabBarView.swift in Sources */, - AB26F59027ABF21000AB3468 /* Model5toModel6.xcmappingmodel in Sources */, - AB706FA5278C3DDE0025A48A /* PreviewsView.swift in Sources */, - ABF45AE725F3313D00ECB568 /* RatingView.swift in Sources */, - AB2CED64268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift in Sources */, - ABCD2F0E25976B95008E5A20 /* Parser.swift in Sources */, - ABF45AF725F3313D00ECB568 /* SettingView.swift in Sources */, - AB1FA8FC27C5E0E50063EF55 /* TagDetail.swift in Sources */, - AB706F862789AD490025A48A /* ToplistsReducer.swift in Sources */, - AB7BF2CC27A96A3C001865A3 /* GalleryTorrent.swift in Sources */, - ABF45AEA25F3313D00ECB568 /* Placeholder.swift in Sources */, - ABD4032826B7967F00001B8C /* CategoryView.swift in Sources */, - ABC681F326898D46007BBD69 /* Model.xcdatamodeld in Sources */, - ABBB266627977C2A007B6149 /* ArchivesReducer.swift in Sources */, - ABBB2640279417EC007B6149 /* CommentsReducer.swift in Sources */, - AB0929C027805A8200F107CA /* LoginReducer.swift in Sources */, - ABBB2631278E6EF3007B6149 /* SearchView.swift in Sources */, - AB706F92278A6E8C0025A48A /* WatchedReducer.swift in Sources */, - AB706F80278981370025A48A /* AlertKit_Extension.swift in Sources */, - ABA9A6C228EC7BD000EE28DE /* Strings.swift in Sources */, - AB58A5B22776B99000C0D285 /* AppReducer.swift in Sources */, - AB24C566276758E30085C33A /* GalleryCardCell.swift in Sources */, - ABBB2679279D454C007B6149 /* GalleryInfosReducer.swift in Sources */, - AB7BF2B727A9652F001865A3 /* Greeting.swift in Sources */, - ABC8355F27B118370091DCDB /* DetailSearchReducer.swift in Sources */, - AB4FD2C1268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift in Sources */, - AB26F59427ACC6CD00AB3468 /* TagTranslator.swift in Sources */, - AB0929CE2781AADA00F107CA /* DatabaseClient.swift in Sources */, - EA8C4D262F0E100100000001 /* DownloadClient.swift in Sources */, - EAA100012F1D000100000001 /* DownloadsReducer.swift in Sources */, - EAA100012F1D000100000002 /* DownloadsView.swift in Sources */, - EAA100012F1D000100000005 /* DownloadSettingView.swift in Sources */, - EAA100012F1D000100000006 /* DownloadBadgeLabel.swift in Sources */, - EAA100012F1D000100000007 /* DownloadFiltersView.swift in Sources */, - EAA100012F1D000100000008 /* DownloadBadgeStore.swift in Sources */, - EAA100012F1D000100000019 /* PreviewImageView.swift in Sources */, - AB6DE897268822390087C579 /* LogsView.swift in Sources */, - AB7BF31D27ABE028001865A3 /* FileManager+ApplicationSupport.swift in Sources */, - AB706F7B278937500025A48A /* FrontpageReducer.swift in Sources */, - AB86ABF72782DDE600E61E6A /* FileClient.swift in Sources */, - AB7B29F226AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift in Sources */, - AB706F7927890A6C0025A48A /* AppRouteReducer.swift in Sources */, - AB706F88278A4C8A0025A48A /* PopularView.swift in Sources */, - EA8C4D262F0E100100000005 /* DownloadedGalleryMO+CoreDataProperties.swift in Sources */, - EA8C4D262F0E100100000003 /* DownloadedGallery.swift in Sources */, - AB706FA1278BCEC60025A48A /* DetailView.swift in Sources */, - ABE9401526FF158D0085E158 /* QuickSearchView.swift in Sources */, - AB706F9B278AC5A30025A48A /* SearchRootView.swift in Sources */, - AB706F8A278A4CC50025A48A /* PopularReducer.swift in Sources */, - ABD49D64277C7AD5003D1A07 /* TabBarReducer.swift in Sources */, - ABF45AF025F3313D00ECB568 /* CommentsView.swift in Sources */, - ABBB2671279AFA61007B6149 /* EnvironmentKeys.swift in Sources */, - AB7BF2DA27AA78CF001865A3 /* Reducer_Extension.swift in Sources */, - ABBD2B602768D7AD0072AED2 /* GalleryRankingCell.swift in Sources */, - ABBB263A2792588F007B6149 /* TTProgressHUD_Extension.swift in Sources */, - AB7BF2D627AA3F4C001865A3 /* FileUtil.swift in Sources */, - EA8C4D262F0E100100000004 /* DownloadedGalleryMO+CoreDataClass.swift in Sources */, - EA8C4D262F0E100100000002 /* DownloadFileStorage.swift in Sources */, - AB0ABCB726C541A400AD970F /* WaveForm.swift in Sources */, - AB0929D62782A65F00F107CA /* GeneralSettingReducer.swift in Sources */, - AB706FA3278BCF2F0025A48A /* DetailReducer.swift in Sources */, - ABBCCC9026C95F6E007D8A36 /* GalleryInfosView.swift in Sources */, - AB7BF2A927A63C89001865A3 /* Language.swift in Sources */, - AB86AC0A2782FAFA00E61E6A /* AppearanceSettingReducer.swift in Sources */, - AB7BF2C427A9683F001865A3 /* GalleryArchive.swift in Sources */, - ABF45AF525F3313D00ECB568 /* ReadingSettingView.swift in Sources */, - EA698C092CCDE7090058BC19 /* IdentifiableBox.swift in Sources */, - AB0CFBD727C3B2D0004BD372 /* TagDetailView.swift in Sources */, - AB38A0CB25CA993D00764D64 /* ColorCodable.swift in Sources */, - ABBB2675279B933D007B6149 /* ReadingReducer.swift in Sources */, - ABF45AF425F3313D00ECB568 /* WebView.swift in Sources */, - AB7B29F626AC741600EE1F14 /* GenericList.swift in Sources */, - AB0CFBC927C07F95004BD372 /* TagSuggestionView.swift in Sources */, - AB706F82278986120025A48A /* ToolbarItems.swift in Sources */, - ABBB266E27998479007B6149 /* QuickSearchReducer.swift in Sources */, - AB0ABCB526C5406400AD970F /* LoginView.swift in Sources */, - AB24C55C2767565A0085C33A /* HomeView.swift in Sources */, - ABF45AF225F3313D00ECB568 /* GeneralSettingView.swift in Sources */, - AB358311269D7B63009466A5 /* DFURLProtocol.swift in Sources */, - ABBB266C2797E882007B6149 /* ClipboardClient.swift in Sources */, - AB24C55A27674EDF0085C33A /* FavoritesView.swift in Sources */, - AB7BF2BC27A965DA001865A3 /* Category.swift in Sources */, - ABBB2673279B9332007B6149 /* ReadingView.swift in Sources */, - AB7BF2D027AA3E75001865A3 /* DeviceUtil.swift in Sources */, - ABF45AE425F3313D00ECB568 /* TagCloudView.swift in Sources */, - ABCA93C22691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift in Sources */, - AB7BF2C627A968AB001865A3 /* TranslatableLanguage.swift in Sources */, - ABF45AEE25F3313D00ECB568 /* ArchivesView.swift in Sources */, - ABBB2677279CDBB0007B6149 /* ImageClient.swift in Sources */, - AB706F95278A75D30025A48A /* HistoryView.swift in Sources */, - ABEA1FE625A9B40B002966B9 /* Setting.swift in Sources */, - ABCA93C02691925900A98BC6 /* GalleryMO+CoreDataClass.swift in Sources */, - AB7BF30D27ABDFF1001865A3 /* CoreDataMigrationVersion.swift in Sources */, - AB706F8C278A4F6C0025A48A /* WatchedView.swift in Sources */, - AB0929D22781E7D500F107CA /* LoggerClient.swift in Sources */, - AB358315269D821D009466A5 /* DFExtensions.swift in Sources */, - ABC3C7872593699B00E0C11B /* EhPandaApp.swift in Sources */, - AB0929C82781938A00F107CA /* DFClient.swift in Sources */, - AB706F9D278ACCA20025A48A /* SearchRootReducer.swift in Sources */, - ABF313A525B1AB6600D47A2F /* Misc.swift in Sources */, - ABA732DF25A852D800B3D9AB /* Filter.swift in Sources */, - AB7BF31E27ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift in Sources */, - ABC1FAB82642C37D00A9F352 /* NewDawnView.swift in Sources */, - ABC732C527B9024500D47DA9 /* LiveText.swift in Sources */, - ABF45AE825F3313D00ECB568 /* LinkedText.swift in Sources */, - ABC732C727B90F0900D47DA9 /* LiveTextView.swift in Sources */, - AB0929B6277F043D00F107CA /* AccountSettingReducer.swift in Sources */, - ABD49D67277EAC90003D1A07 /* URLUtil.swift in Sources */, - ABBB2638278FBD2F007B6149 /* SwiftUINavigation_Extension.swift in Sources */, - AB10117E26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift in Sources */, - AB26F59627ACCA1800AB3468 /* AppEnv.swift in Sources */, - EA5AA4A72EA9149E00BC2B5C /* PageHandler.swift in Sources */, - EA5AA4A82EA9149E00BC2B5C /* LiveTextHandler.swift in Sources */, - EA5AA4A92EA9149E00BC2B5C /* GestureHandler.swift in Sources */, - EA5AA4AA2EA9149E00BC2B5C /* AutoPlayHandler.swift in Sources */, - ABF45AE525F3313D00ECB568 /* PostCommentView.swift in Sources */, - AB358313269D7E89009466A5 /* DFRequest.swift in Sources */, - AB706F90278A5F680025A48A /* AppDelegateClient.swift in Sources */, - ABBB266A2797C61F007B6149 /* TorrentsReducer.swift in Sources */, - ABF75F3F25A19CD200544D29 /* User.swift in Sources */, ); }; ABF294C826D20F82004DD03A /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( - EAB100012F1E000100000003 /* DownloadFileStorageTests.swift in Sources */, - EAB100012F1E000100000004 /* DownloadFeatureReducerTests.swift in Sources */, - EAB100012F1E000100000007 /* DownloadSignatureBuilderTests.swift in Sources */, - AB31CD3D27B66F7D00F40E0A /* GalleryImageURLParserTests.swift in Sources */, - AB0CFB8227BBBFCE004BD372 /* EhSettingParserTests.swift in Sources */, - AB31CD4327B676C300F40E0A /* GalleryMPVKeysParserTests.swift in Sources */, - AB31CD3027B666E200F40E0A /* TestError.swift in Sources */, - ABD9771027B65E3400983DE7 /* GalleryDetailParserTests.swift in Sources */, - AB31CD3227B6671400F40E0A /* BanIntervalParserTests.swift in Sources */, - ABD9771327B6612400983DE7 /* GreetingParserTests.swift in Sources */, - EAB100012F1E000100000001 /* DownloadPageErrorParserTests.swift in Sources */, - AB31CD3727B6695800F40E0A /* HTMLFilename.swift in Sources */, - AB3E9E7426D210B1008FE518 /* TestHelper.swift in Sources */, - AB31CD3B27B66E0300F40E0A /* ListParserTestType.swift in Sources */, - ABD9770E27B65A7300983DE7 /* ListParserTests.swift in Sources */, - EAB100012F1E000100000002 /* SettingDownloadTests.swift in Sources */, ); }; /* End PBXSourcesBuildPhase section */ @@ -2116,47 +476,6 @@ }; /* End PBXTargetDependency section */ -/* Begin PBXVariantGroup section */ - AB7E6B3225D24FE00035CC68 /* InfoPlist.strings */ = { - isa = PBXVariantGroup; - children = ( - AB7E6B3125D24FE00035CC68 /* ja */, - AB7E6B3425D24FE40035CC68 /* zh-Hans */, - AB7E6B3525D24FE50035CC68 /* en */, - ABB5013126A41EBA00B542D9 /* ko */, - AB253B4826AB08B500F95275 /* de */, - ABDD3E872930E73E009B3C2D /* zh-Hant-TW */, - ABDD3E8B2930E797009B3C2D /* zh-Hant-HK */, - ABDD3E8D2930E879009B3C2D /* zh-Hant */, - ); - name = InfoPlist.strings; - sourceTree = ""; - }; - ABA9A6BD28EC7BA200EE28DE /* Constant.strings */ = { - isa = PBXVariantGroup; - children = ( - ABA9A6BE28EC7BA200EE28DE /* en */, - ); - name = Constant.strings; - sourceTree = ""; - }; - ABEE0AFC2595C6F800C997AE /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - ABEE0AFB2595C6F800C997AE /* en */, - ABEE0AFE2595C73D00C997AE /* zh-Hans */, - AB994DBB25986F7A00E9A367 /* ja */, - ABB5013026A41EBA00B542D9 /* ko */, - AB253B4726AB08B500F95275 /* de */, - ABDD3E882930E73E009B3C2D /* zh-Hant-TW */, - ABDD3E8C2930E797009B3C2D /* zh-Hant-HK */, - ABDD3E8E2930E879009B3C2D /* zh-Hant */, - ); - name = Localizable.strings; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ AB5BE68226B95FDD007D4A55 /* Debug configuration for PBXNativeTarget "ShareExtension" */ = { isa = XCBuildConfiguration; @@ -2165,7 +484,6 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 2U4DN3V26P; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2193,7 +511,6 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 2U4DN3V26P; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2272,6 +589,7 @@ ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = ""; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -2328,6 +646,7 @@ MTL_FAST_MATH = YES; OTHER_LDFLAGS = ""; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; @@ -2337,7 +656,6 @@ ABC3C7642593696E00E0C11B /* Debug configuration for PBXNativeTarget "EhPanda" */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; @@ -2345,7 +663,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 157; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 2U4DN3V26P; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -2366,7 +683,6 @@ ABC3C7652593696E00E0C11B /* Release configuration for PBXNativeTarget "EhPanda" */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; @@ -2374,7 +690,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 157; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 2U4DN3V26P; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -2400,7 +715,6 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 2U4DN3V26P; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2427,7 +741,6 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 2U4DN3V26P; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2723,26 +1036,6 @@ productName = SwiftyBeaver; }; /* End XCSwiftPackageProductDependency section */ - -/* Begin XCVersionGroup section */ - ABC681F126898D46007BBD69 /* Model.xcdatamodeld */ = { - isa = XCVersionGroup; - children = ( - EA8C4D262F0E100100000016 /* Model 8.xcdatamodel */, - AB41DB5227B7EC5500DD3604 /* Model 7.xcdatamodel */, - AB706F93278A6F2B0025A48A /* Model 6.xcdatamodel */, - ABC4A07A2753084100968A4F /* Model 5.xcdatamodel */, - ABE9401626FF2E610085E158 /* Model 4.xcdatamodel */, - AB543FF126DB7FD9009344C0 /* Model 3.xcdatamodel */, - AB48BCF626D2539B0021A06C /* Model 2.xcdatamodel */, - ABC681F226898D46007BBD69 /* Model.xcdatamodel */, - ); - currentVersion = EA8C4D262F0E100100000016 /* Model 8.xcdatamodel */; - path = Model.xcdatamodeld; - sourceTree = ""; - versionGroupType = wrapper.xcdatamodel; - }; -/* End XCVersionGroup section */ }; rootObject = ABC3C74C2593696C00E0C11B /* Project object */; } diff --git a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme index e791012ec..fc7c6b173 100644 --- a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme +++ b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme @@ -1,6 +1,6 @@ Date: Sat, 28 Mar 2026 22:07:15 +0800 Subject: [PATCH 013/614] Adopt to Observation framework --- EhPanda/View/Detail/DetailView.swift | 2 +- EhPanda/View/Home/HomeView.swift | 2 +- EhPanda/View/Reading/ReadingView.swift | 23 ++++++++++++------- .../Reading/Support/AutoPlayHandler.swift | 10 +++++--- .../View/Reading/Support/GestureHandler.swift | 17 +++++++++----- .../Reading/Support/LiveTextHandler.swift | 14 +++++++---- .../View/Reading/Support/PageHandler.swift | 7 ++++-- .../Components/Cells/GalleryCardCell.swift | 2 +- .../Components/Cells/GalleryDetailCell.swift | 2 +- .../Components/Cells/GalleryHistoryCell.swift | 2 +- .../Components/Cells/GalleryRankingCell.swift | 2 +- .../Cells/GalleryThumbnailCell.swift | 2 +- .../Components/DownloadBadgeStore.swift | 10 +++++--- .../Components/TagSuggestionView.swift | 9 +++++--- 14 files changed, 67 insertions(+), 37 deletions(-) diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 71080b588..00eedbb91 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -436,7 +436,7 @@ private extension DetailView { // MARK: HeaderSection private struct HeaderSection: View { - @ObservedObject private var downloadStore = DownloadBadgeStore.shared + private let downloadStore = DownloadBadgeStore.shared private let gallery: Gallery private let galleryDetail: GalleryDetail diff --git a/EhPanda/View/Home/HomeView.swift b/EhPanda/View/Home/HomeView.swift index d34596a61..25f548798 100644 --- a/EhPanda/View/Home/HomeView.swift +++ b/EhPanda/View/Home/HomeView.swift @@ -313,7 +313,7 @@ private struct CoverWallSection: View { } private struct VerticalCoverStack: View { - @ObservedObject private var downloadStore = DownloadBadgeStore.shared + private let downloadStore = DownloadBadgeStore.shared private let galleries: [Gallery] private let downloadBadges: [String: DownloadBadge] diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 723750342..214b1919c 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -7,6 +7,7 @@ import SwiftUI import Kingfisher import SwiftUIPager import ComposableArchitecture +import Observation struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme @@ -16,10 +17,10 @@ struct ReadingView: View { @Binding private var setting: Setting private let blurRadius: Double - @StateObject private var liveTextHandler = LiveTextHandler() - @StateObject private var autoPlayHandler = AutoPlayHandler() - @StateObject private var gestureHandler = GestureHandler() - @StateObject private var pageHandler = PageHandler() + @State private var liveTextHandler = LiveTextHandler() + @State private var autoPlayHandler = AutoPlayHandler() + @State private var gestureHandler = GestureHandler() + @State private var pageHandler = PageHandler() @StateObject private var page: Page = .first() init( @@ -52,7 +53,10 @@ struct ReadingView: View { } var body: some View { - changeTriggers(content: { content }) + @Bindable var bindableLiveTextHandler = liveTextHandler + @Bindable var bindablePageHandler = pageHandler + + return changeTriggers(content: { content }) .sheet(item: $store.route.sending(\.setNavigation).readingSetting) { _ in NavigationView { ReadingSettingView( @@ -105,7 +109,10 @@ struct ReadingView: View { } var content: some View { - ZStack { + @Bindable var bindableLiveTextHandler = liveTextHandler + @Bindable var bindablePageHandler = pageHandler + + return ZStack { backgroundColor.ignoresSafeArea() ZStack { @@ -146,8 +153,8 @@ struct ReadingView: View { ControlPanel( showsPanel: $store.showsPanel, showsSliderPreview: $store.showsSliderPreview, - sliderValue: $pageHandler.sliderValue, setting: $setting, - enablesLiveText: $liveTextHandler.enablesLiveText, + sliderValue: $bindablePageHandler.sliderValue, setting: $setting, + enablesLiveText: $bindableLiveTextHandler.enablesLiveText, autoPlayPolicy: .init(get: { autoPlayHandler.policy }, set: { setAutoPlayPolocy($0) }), range: 1...Float(store.gallery.pageCount), previewURLs: displayPreviewURLs, diff --git a/EhPanda/View/Reading/Support/AutoPlayHandler.swift b/EhPanda/View/Reading/Support/AutoPlayHandler.swift index 660f96d9f..889b0af55 100644 --- a/EhPanda/View/Reading/Support/AutoPlayHandler.swift +++ b/EhPanda/View/Reading/Support/AutoPlayHandler.swift @@ -4,12 +4,16 @@ // import SwiftUI +import Observation -final class AutoPlayHandler: ObservableObject { - @Published var policy: AutoPlayPolicy = .off +@Observable +@MainActor +final class AutoPlayHandler { + var policy: AutoPlayPolicy = .off + @ObservationIgnored private var timer: Timer? - deinit { + isolated deinit { invalidate() } diff --git a/EhPanda/View/Reading/Support/GestureHandler.swift b/EhPanda/View/Reading/Support/GestureHandler.swift index 9f87d2624..68e028c16 100644 --- a/EhPanda/View/Reading/Support/GestureHandler.swift +++ b/EhPanda/View/Reading/Support/GestureHandler.swift @@ -4,13 +4,18 @@ // import SwiftUI +import Observation -final class GestureHandler: ObservableObject { - @Published var scaleAnchor: UnitPoint = .center - @Published var scale: Double = 1 - @Published var offset: CGSize = .zero - @Published private var baseScale: Double = 1 - @Published private var newOffset: CGSize = .zero +@Observable +@MainActor +final class GestureHandler { + var scaleAnchor: UnitPoint = .center + var scale: Double = 1 + var offset: CGSize = .zero + @ObservationIgnored + private var baseScale: Double = 1 + @ObservationIgnored + private var newOffset: CGSize = .zero private func edgeWidth(xAxis: Double) -> Double { let marginW = DeviceUtil.absWindowW * (scale - 1) / 2 diff --git a/EhPanda/View/Reading/Support/LiveTextHandler.swift b/EhPanda/View/Reading/Support/LiveTextHandler.swift index 28266c851..b146e3e50 100644 --- a/EhPanda/View/Reading/Support/LiveTextHandler.swift +++ b/EhPanda/View/Reading/Support/LiveTextHandler.swift @@ -14,15 +14,19 @@ import Vision import SwiftUI import Foundation +import Observation -final class LiveTextHandler: ObservableObject { - @Published var enablesLiveText = false - @Published var liveTextGroups = [Int: [LiveTextGroup]]() - @Published private(set) var focusedLiveTextGroup: LiveTextGroup? +@Observable +@MainActor +final class LiveTextHandler { + var enablesLiveText = false + var liveTextGroups = [Int: [LiveTextGroup]]() + private(set) var focusedLiveTextGroup: LiveTextGroup? + @ObservationIgnored private var processingRequests = [VNRequest]() - deinit { + isolated deinit { cancelRequests() } diff --git a/EhPanda/View/Reading/Support/PageHandler.swift b/EhPanda/View/Reading/Support/PageHandler.swift index 2a06f4895..f4f066eea 100644 --- a/EhPanda/View/Reading/Support/PageHandler.swift +++ b/EhPanda/View/Reading/Support/PageHandler.swift @@ -4,9 +4,12 @@ // import SwiftUI +import Observation -final class PageHandler: ObservableObject { - @Published var sliderValue: Float = 1 { +@Observable +@MainActor +final class PageHandler { + var sliderValue: Float = 1 { didSet { Logger.info("sliderValue.didSet", context: ["sliderValue": sliderValue]) } diff --git a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift index 020d323a0..035106ead 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift @@ -10,7 +10,7 @@ import UIImageColors struct GalleryCardCell: View { @Environment(\.colorScheme) private var colorScheme - @ObservedObject private var downloadStore = DownloadBadgeStore.shared + private let downloadStore = DownloadBadgeStore.shared private let currentID: String private let colors: [Color] diff --git a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift index a563ea39c..4dbd23c97 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift @@ -8,7 +8,7 @@ import Kingfisher struct GalleryDetailCell: View { @Environment(\.colorScheme) private var colorScheme - @ObservedObject private var downloadStore = DownloadBadgeStore.shared + private let downloadStore = DownloadBadgeStore.shared private let gallery: Gallery private let coverURLOverride: URL? diff --git a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift index d055ac82e..86f4f793c 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift @@ -7,7 +7,7 @@ import SwiftUI import Kingfisher struct GalleryHistoryCell: View { - @ObservedObject private var downloadStore = DownloadBadgeStore.shared + private let downloadStore = DownloadBadgeStore.shared private let gallery: Gallery diff --git a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift index 4589c3285..87e66031a 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift @@ -7,7 +7,7 @@ import SwiftUI import Kingfisher struct GalleryRankingCell: View { - @ObservedObject private var downloadStore = DownloadBadgeStore.shared + private let downloadStore = DownloadBadgeStore.shared private let gallery: Gallery private let ranking: Int diff --git a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift index 19032e382..e6f7c9cd6 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -8,7 +8,7 @@ import Kingfisher struct GalleryThumbnailCell: View { @Environment(\.colorScheme) private var colorScheme - @ObservedObject private var downloadStore = DownloadBadgeStore.shared + private let downloadStore = DownloadBadgeStore.shared private let gallery: Gallery private let setting: Setting diff --git a/EhPanda/View/Support/Components/DownloadBadgeStore.swift b/EhPanda/View/Support/Components/DownloadBadgeStore.swift index df7819c3e..7210777bb 100644 --- a/EhPanda/View/Support/Components/DownloadBadgeStore.swift +++ b/EhPanda/View/Support/Components/DownloadBadgeStore.swift @@ -4,15 +4,19 @@ // import Foundation +import Observation +@Observable @MainActor -final class DownloadBadgeStore: ObservableObject { +final class DownloadBadgeStore { static let shared = DownloadBadgeStore(client: DownloadClientKey.liveValue) - @Published private(set) var badges = [String: DownloadBadge]() - @Published private(set) var downloads = [String: DownloadedGallery]() + private(set) var badges = [String: DownloadBadge]() + private(set) var downloads = [String: DownloadedGallery]() + @ObservationIgnored private let client: DownloadClient + @ObservationIgnored private var observeTask: Task? init(client: DownloadClient) { diff --git a/EhPanda/View/Support/Components/TagSuggestionView.swift b/EhPanda/View/Support/Components/TagSuggestionView.swift index 42be34ca0..de8173210 100644 --- a/EhPanda/View/Support/Components/TagSuggestionView.swift +++ b/EhPanda/View/Support/Components/TagSuggestionView.swift @@ -5,6 +5,7 @@ import SwiftUI import Kingfisher +import Observation struct TagSuggestionView: View { @Binding private var keyword: String @@ -12,7 +13,7 @@ struct TagSuggestionView: View { private let showsImages: Bool private let isEnabled: Bool - @StateObject private var translationHandler = TagTranslationHandler() + @State private var translationHandler = TagTranslationHandler() init(keyword: Binding, translations: [String: TagTranslation], showsImages: Bool, isEnabled: Bool) { _keyword = keyword @@ -102,8 +103,10 @@ private struct SuggestionCell: View { } // MARK: TagTranslationHandler -final class TagTranslationHandler: ObservableObject { - @Published var suggestions = [TagSuggestion]() +@Observable +@MainActor +final class TagTranslationHandler { + var suggestions = [TagSuggestion]() func analyze(text: inout String, translations: [String: TagTranslation]) { let keyword = text.replacingOccurrences(of: " +", with: " ", options: .regularExpression) From b5211682249a39de591cfdd43d5bfdddf169c61f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 29 Mar 2026 01:22:07 +0800 Subject: [PATCH 014/614] Implement WebP support --- EhPanda.xcodeproj/project.pbxproj | 17 ++++++++ .../xcshareddata/swiftpm/Package.resolved | 20 ++++++++- .../App/Tools/Clients/ClipboardClient.swift | 2 + EhPanda/App/Tools/Clients/LibraryClient.swift | 5 +++ EhPanda/App/Tools/Extensions/Extensions.swift | 9 +++- EhPanda/View/Reading/ReadingReducer.swift | 6 +-- EhPanda/View/Reading/ReadingView.swift | 42 +++++++------------ 7 files changed, 69 insertions(+), 32 deletions(-) diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index bcb95896c..572548d93 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 82F69C912F12815E00A7F9E4 /* KingfisherWebP in Frameworks */ = {isa = PBXBuildFile; productRef = 82F69C902F12815E00A7F9E4 /* KingfisherWebP */; }; AB17573D27675B1E00FD64E2 /* Colorful in Frameworks */ = {isa = PBXBuildFile; productRef = AB17573C27675B1E00FD64E2 /* Colorful */; }; AB17574027678B3400FD64E2 /* UIImageColors in Frameworks */ = {isa = PBXBuildFile; productRef = AB17573F27678B3400FD64E2 /* UIImageColors */; }; AB1FA94927C62BC80063EF55 /* CommonMark in Frameworks */ = {isa = PBXBuildFile; productRef = AB1FA94827C62BC80063EF55 /* CommonMark */; }; @@ -148,6 +149,7 @@ ABD49D5D277C6C9D003D1A07 /* SFSafeSymbols in Frameworks */, ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */, AB86AC1027831AD100E61E6A /* ComposableArchitecture in Frameworks */, + 82F69C912F12815E00A7F9E4 /* KingfisherWebP in Frameworks */, ABBB2636278FB888007B6149 /* SwiftUINavigation in Frameworks */, AB1FA94927C62BC80063EF55 /* CommonMark in Frameworks */, AB17573D27675B1E00FD64E2 /* Colorful in Frameworks */, @@ -285,6 +287,7 @@ AB2EB9A1280251F600011A8A /* AlertKit */, AB2EB9A42802521700011A8A /* DeprecatedAPI */, EAE63E2029E2A6330048C601 /* SwiftyBeaver */, + 82F69C902F12815E00A7F9E4 /* KingfisherWebP */, ); productName = EhPanda; productReference = ABC3C7542593696C00E0C11B /* EhPanda.app */; @@ -355,6 +358,7 @@ ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */, AB60D0E7274C7ECE00F899AB /* XCRemoteSwiftPackageReference "WaterfallGrid" */, ABC4A0772751B40E00968A4F /* XCRemoteSwiftPackageReference "Kingfisher" */, + 8268CA182F127FF900AE0557 /* XCRemoteSwiftPackageReference "KingfisherWebP" */, AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */, AB17573E27678B3400FD64E2 /* XCRemoteSwiftPackageReference "UIImageColors" */, ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, @@ -797,6 +801,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 8268CA182F127FF900AE0557 /* XCRemoteSwiftPackageReference "KingfisherWebP" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/yeatse/KingfisherWebP"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.7.2; + }; + }; A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SimplyDanny/SwiftLintPlugins"; @@ -940,6 +952,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 82F69C902F12815E00A7F9E4 /* KingfisherWebP */ = { + isa = XCSwiftPackageProductDependency; + package = 8268CA182F127FF900AE0557 /* XCRemoteSwiftPackageReference "KingfisherWebP" */; + productName = KingfisherWebP; + }; A66A766C2F77C88A00FC07B8 /* SwiftLintBuildToolPlugin */ = { isa = XCSwiftPackageProductDependency; package = A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 92a9d090b..e9121d46e 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "31490f6b507ee1988e23d0475c877e68d523ae16c08079502b8aab82e26a1e02", + "originHash" : "1db400ad57793dfef830afbe50dda3941f6d39d89c823ef409aba9435f872876", "pins" : [ { "identity" : "alertkit", @@ -64,6 +64,24 @@ "version" : "8.8.0" } }, + { + "identity" : "kingfisherwebp", + "kind" : "remoteSourceControl", + "location" : "https://github.com/yeatse/KingfisherWebP", + "state" : { + "revision" : "6939874df4417cc37a05893ac2f27332e6668460", + "version" : "1.7.3" + } + }, + { + "identity" : "libwebp-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", + "state" : { + "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", + "version" : "1.5.0" + } + }, { "identity" : "sfsafesymbols", "kind" : "remoteSourceControl", diff --git a/EhPanda/App/Tools/Clients/ClipboardClient.swift b/EhPanda/App/Tools/Clients/ClipboardClient.swift index fabfbce42..4bed6f1e2 100644 --- a/EhPanda/App/Tools/Clients/ClipboardClient.swift +++ b/EhPanda/App/Tools/Clients/ClipboardClient.swift @@ -34,6 +34,8 @@ extension ClipboardClient { DispatchQueue.global(qos: .utility).async { if let data = image.kf.data(format: .GIF) { UIPasteboard.general.setData(data, forPasteboardType: UTType.gif.identifier) + } else { + UIPasteboard.general.image = image } } } else { diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index 85ccc1364..7f207c2d4 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -9,6 +9,7 @@ import Foundation import Kingfisher import SwiftyBeaver import UIImageColors +import KingfisherWebP import ComposableArchitecture struct LibraryClient { @@ -54,6 +55,10 @@ extension LibraryClient { let config = KingfisherManager.shared.downloader.sessionConfiguration config.httpCookieStorage = HTTPCookieStorage.shared KingfisherManager.shared.downloader.sessionConfiguration = config + KingfisherManager.shared.defaultOptions += [ + .processor(WebPProcessor.default), + .cacheSerializer(WebPSerializer.default), + ] }, clearWebImageDiskCache: { KingfisherManager.shared.cache.clearDiskCache() diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift index bafce7dcb..47322bb0e 100644 --- a/EhPanda/App/Tools/Extensions/Extensions.swift +++ b/EhPanda/App/Tools/Extensions/Extensions.swift @@ -67,8 +67,13 @@ extension URL { "gid", "page", "imgkey", "fileindex", "xres", "p", "key" ] - var isGIF: Bool { - pathExtension == "gif" + var isAnimatedImage: Bool { + switch pathExtension.lowercased() { + case "gif", "webp": + true + default: + false + } } var stableImageCacheKey: String? { diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index 2699a7d19..b1a0644de 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -300,17 +300,17 @@ struct ReadingReducer { return .none case .copyImage(let imageURL): - return .send(.fetchImage(.copy(imageURL.isGIF), imageURL)) + return .send(.fetchImage(.copy(imageURL.isAnimatedImage), imageURL)) case .saveImage(let imageURL): - return .send(.fetchImage(.save(imageURL.isGIF), imageURL)) + return .send(.fetchImage(.save(imageURL.isAnimatedImage), imageURL)) case .saveImageDone(let isSucceeded): state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error return .send(.setNavigation(.hud)) case .shareImage(let imageURL): - return .send(.fetchImage(.share(imageURL.isGIF), imageURL)) + return .send(.fetchImage(.share(imageURL.isAnimatedImage), imageURL)) case .fetchImage(let action, let imageURL): return .run { send in diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 214b1919c..387cbf5d0 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -5,9 +5,9 @@ import SwiftUI import Kingfisher +import Observation import SwiftUIPager import ComposableArchitecture -import Observation struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme @@ -579,34 +579,24 @@ private struct ImageContainer: View { .frame(width: width, height: height) } @ViewBuilder private func image(url: URL?) -> some View { - if let url, url.isFileURL { - if url.isGIF { - KFAnimatedImage(url) - .cacheMemoryOnly() - .placeholder(placeholder).fade(duration: 0.25) - .onSuccess(onSuccess).onFailure(onFailure) - } else { - KFImage.url( - url, - cacheKey: localFileCacheKey(url) - ) - .cacheMemoryOnly() + let isFileURL = url?.isFileURL ?? false + if url?.isAnimatedImage == true { + KFAnimatedImage(url) .placeholder(placeholder) - .defaultModifier(withRoundedCorners: false) - .onSuccess(onSuccess).onFailure(onFailure) - } - } else if url?.isGIF != true { - KFImage.url( - url, - cacheKey: url?.stableImageCacheKey ?? url?.absoluteString - ) + .fade(duration: 0.25) + .onSuccess(onSuccess) + .onFailure(onFailure) + .cacheMemoryOnly(isFileURL) + } else { + let cacheKey = isFileURL + ? url.map(localFileCacheKey) + : url?.stableImageCacheKey ?? url?.absoluteString + KFImage.url(url, cacheKey: cacheKey) .placeholder(placeholder) .defaultModifier(withRoundedCorners: false) - .onSuccess(onSuccess).onFailure(onFailure) - } else { - KFAnimatedImage(url) - .placeholder(placeholder).fade(duration: 0.25) - .onSuccess(onSuccess).onFailure(onFailure) + .onSuccess(onSuccess) + .onFailure(onFailure) + .cacheMemoryOnly(isFileURL) } } From 4d05cd03d1928abf824a36ff2c78526e319f22f5 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 29 Mar 2026 10:32:58 +0800 Subject: [PATCH 015/614] Resolve lint issues --- .swiftlint.yml | 8 - EhPanda/App/Tools/Clients/CookieClient.swift | 6 +- .../Clients/DatabaseClient+Updates.swift | 139 + .../App/Tools/Clients/DatabaseClient.swift | 169 +- .../Tools/Clients/DownloadClient+Cache.swift | 324 + .../Clients/DownloadClient+Execution.swift | 262 + .../DownloadClient+ExecutionFetch.swift | 183 + .../DownloadClient+ExecutionPerform.swift | 258 + .../DownloadClient+ExecutionSupport.swift | 333 + .../Clients/DownloadClient+Manager.swift | 157 + .../Clients/DownloadClient+Networking.swift | 396 ++ .../Clients/DownloadClient+PageDownload.swift | 356 + .../DownloadClient+PageDownloadHelpers.swift | 182 + .../Clients/DownloadClient+Persistence.swift | 388 + .../DownloadClient+PersistenceHelpers.swift | 302 + .../DownloadClient+PersistenceNormalize.swift | 224 + .../Clients/DownloadClient+PublicAPI.swift | 359 + .../DownloadClient+PublicAPIHelpers.swift | 233 + .../DownloadClient+ResponseValidation.swift | 288 + ...loadClient+ResponseValidationHelpers.swift | 384 + .../Clients/DownloadClient+RetryHelpers.swift | 195 + .../Clients/DownloadClient+Scheduling.swift | 277 + .../DownloadClient+SchedulingHelpers.swift | 212 + .../Clients/DownloadClient+Testing.swift | 119 + .../App/Tools/Clients/DownloadClient.swift | 3851 +--------- EhPanda/App/Tools/Clients/FileClient.swift | 14 +- EhPanda/App/Tools/Clients/LibraryClient.swift | 2 +- .../Tools/Clients/UIApplicationClient.swift | 3 +- EhPanda/App/Tools/Clients/URLClient.swift | 14 +- EhPanda/App/Tools/Defaults.swift | 2 +- EhPanda/App/Tools/Extensions/Extensions.swift | 11 +- .../Tools/Extensions/Reducer_Extension.swift | 4 +- .../App/Tools/Extensions/ViewModifiers.swift | 4 +- EhPanda/App/Tools/Parser.swift | 1943 ------ EhPanda/App/Tools/Parser/Parser+Archive.swift | 106 + EhPanda/App/Tools/Parser/Parser+Comment.swift | 256 + EhPanda/App/Tools/Parser/Parser+Detail.swift | 283 + .../App/Tools/Parser/Parser+Download.swift | 113 + .../App/Tools/Parser/Parser+Favorite.swift | 37 + .../App/Tools/Parser/Parser+Greeting.swift | 81 + EhPanda/App/Tools/Parser/Parser+Image.swift | 84 + EhPanda/App/Tools/Parser/Parser+List.swift | 302 + EhPanda/App/Tools/Parser/Parser+Misc.swift | 73 + EhPanda/App/Tools/Parser/Parser+Preview.swift | 97 + EhPanda/App/Tools/Parser/Parser+Profile.swift | 292 + EhPanda/App/Tools/Parser/Parser+Shared.swift | 125 + EhPanda/App/Tools/Parser/Parser+Torrent.swift | 86 + EhPanda/App/Tools/Parser/Parser+Types.swift | 42 + EhPanda/App/Tools/Parser/Parser+User.swift | 54 + EhPanda/App/Tools/Parser/Parser.swift | 1 + EhPanda/App/Tools/Utilities/CookieUtil.swift | 2 +- .../DownloadFileStorage+Operations.swift | 149 + .../Tools/Utilities/DownloadFileStorage.swift | 159 +- .../App/Tools/Utilities/MarkdownUtil.swift | 36 +- EhPanda/App/Tools/Utilities/URLUtil.swift | 79 +- EhPanda/DataFlow/AppDelegateReducer.swift | 2 +- EhPanda/DataFlow/AppLockReducer.swift | 3 +- EhPanda/DataFlow/AppReducer.swift | 3 +- EhPanda/DataFlow/AppRouteReducer.swift | 8 +- .../FileManager+ApplicationSupport.swift | 2 +- .../NSPersistentStoreCoordinator+SQLite.swift | 2 +- .../Database/Migration/CoreDataMigrator.swift | 2 +- EhPanda/Database/Persistence.swift | 2 +- EhPanda/Models/Gallery/Gallery.swift | 4 +- EhPanda/Models/Gallery/GalleryDetail.swift | 4 +- EhPanda/Models/Gallery/Language.swift | 4 +- .../DownloadedGallery+Extensions.swift | 183 + .../DownloadedGallery+SignatureBuilder.swift | 159 + .../DownloadedGallery+SupportTypes.swift | 263 + .../Models/Persistent/DownloadedGallery.swift | 578 -- EhPanda/Models/Persistent/Setting.swift | 2 +- EhPanda/Models/Support/AppError.swift | 12 +- .../Support/BrowsingCountry+EnglishName.swift | 263 + EhPanda/Models/Support/BrowsingCountry.swift | 256 - EhPanda/Models/Support/EhSetting+Enums.swift | 110 + .../Models/Support/EhSetting+Extensions.swift | 87 + EhPanda/Models/Support/EhSetting.swift | 189 - EhPanda/Models/Tags/TagTranslation.swift | 2 +- .../Models/Tags/TranslatableLanguage.swift | 2 +- EhPanda/Network/DFRequest.swift | 2 +- EhPanda/Network/DFStreamHandler.swift | 3 +- EhPanda/Network/Request+Account.swift | 399 ++ EhPanda/Network/Request+Detail.swift | 269 + EhPanda/Network/Request+Gallery.swift | 196 + EhPanda/Network/Request+Image.swift | 247 + EhPanda/Network/Request.swift | 1046 +-- .../Detail/Archives/ArchivesReducer.swift | 10 +- .../View/Detail/Archives/ArchivesView.swift | 2 +- .../Detail/Comments/CommentsReducer.swift | 8 +- .../View/Detail/Comments/CommentsView.swift | 12 +- .../Detail/Components/TagDetailView.swift | 4 +- .../View/Detail/DetailReducer+Actions.swift | 213 + .../View/Detail/DetailReducer+Download.swift | 302 + EhPanda/View/Detail/DetailReducer+Fetch.swift | 258 + EhPanda/View/Detail/DetailReducer.swift | 704 +- .../DetailSearch/DetailSearchView.swift | 86 +- .../View/Detail/DetailView+CommentCells.swift | 65 + .../Detail/DetailView+HeaderSection.swift | 269 + .../View/Detail/DetailView+Navigation.swift | 80 + EhPanda/View/Detail/DetailView+Subviews.swift | 344 + EhPanda/View/Detail/DetailView.swift | 1357 +--- .../GalleryInfos/GalleryInfosView.swift | 2 +- .../View/Detail/Previews/PreviewsView.swift | 3 +- .../View/Downloads/DownloadFiltersView.swift | 2 +- .../Downloads/DownloadInspectorReducer.swift | 263 + EhPanda/View/Downloads/DownloadsReducer.swift | 270 +- .../Downloads/DownloadsView+Subviews.swift | 214 + EhPanda/View/Downloads/DownloadsView.swift | 210 +- EhPanda/View/Favorites/FavoritesReducer.swift | 16 +- EhPanda/View/Favorites/FavoritesView.swift | 92 +- .../View/Home/Frontpage/FrontpageView.swift | 50 +- EhPanda/View/Home/History/HistoryView.swift | 46 +- EhPanda/View/Home/HomeReducer+Body.swift | 230 + EhPanda/View/Home/HomeReducer.swift | 226 +- EhPanda/View/Home/HomeView+Sections.swift | 347 + EhPanda/View/Home/HomeView.swift | 453 +- EhPanda/View/Home/Popular/PopularView.swift | 46 +- EhPanda/View/Home/Toplists/ToplistsView.swift | 62 +- EhPanda/View/Home/Watched/WatchedView.swift | 100 +- .../View/Reading/ReadingReducer+Body.swift | 315 + .../Reading/ReadingReducer+Database.swift | 151 + .../Reading/ReadingReducer+ImageFetch.swift | 379 + EhPanda/View/Reading/ReadingReducer.swift | 692 +- .../View/Reading/ReadingView+Gestures.swift | 57 + EhPanda/View/Reading/ReadingView.swift | 472 +- .../View/Reading/ReadingViewComponents.swift | 319 + EhPanda/View/Search/SearchRootReducer.swift | 20 +- .../View/Search/SearchRootView+Keywords.swift | 144 + EhPanda/View/Search/SearchRootView.swift | 227 +- EhPanda/View/Search/SearchView.swift | 90 +- .../View/Search/Support/QuickSearchView.swift | 10 +- EhPanda/View/Setting/Components/WebView.swift | 4 +- .../EhSetting/EhSettingView+Sections1.swift | 312 + .../EhSetting/EhSettingView+Sections2.swift | 178 + .../EhSetting/EhSettingView+Sections3.swift | 350 + .../Setting/EhSetting/EhSettingView.swift | 935 +-- .../GeneralSetting/GeneralSettingView.swift | 4 +- EhPanda/View/Setting/Login/LoginReducer.swift | 2 +- EhPanda/View/Setting/Logs/LogsView.swift | 2 +- .../View/Setting/SettingReducer+Body.swift | 288 + .../View/Setting/SettingReducer+Helpers.swift | 134 + EhPanda/View/Setting/SettingReducer.swift | 413 +- .../Cells/GalleryThumbnailCell.swift | 2 +- .../View/Support/Components/GenericList.swift | 2 +- EhPandaTests/Models/ListParserTestType.swift | 8 +- .../Resources/Utility/TestHelper.swift | 2 +- .../Download/DetailReducerDownloadTests.swift | 164 + .../Download/DetailReducerMetadataTests.swift | 189 + .../DetailReducerMetadataUpdateTests.swift | 174 + .../Download/DetailReducerObserveTests.swift | 200 + .../DetailReducerPauseAndGuardTests.swift | 164 + .../Download/DownloadAutomationTests.swift | 239 + .../Download/DownloadBadgeSortTests.swift | 161 + .../DownloadFeatureReducerTests.swift | 6216 +---------------- .../DownloadFeatureTestFactories.swift | 375 + .../Download/DownloadFeatureTestHelpers.swift | 286 + .../DownloadFeatureTestSupportTypes.swift | 195 + .../DownloadFeatureTestTemporaryStorage.swift | 56 + .../DownloadFileStorageRepairTests.swift | 221 + .../DownloadFileStorageStateTests.swift | 75 + .../Download/DownloadFileStorageTests.swift | 236 - .../DownloadFilterAndBadgeTests.swift | 204 + .../Download/DownloadImageErrorTests.swift | 147 + .../DownloadImageParsingCacheTests.swift | 130 + .../Download/DownloadImageParsingTests.swift | 214 + .../Download/DownloadInspectorLoadTests.swift | 153 + .../DownloadInspectorRetryTests.swift | 154 + .../Download/DownloadInspectorSkipTests.swift | 100 + .../Tests/Download/DownloadIpBanTests.swift | 68 + .../DownloadManagerCaptureTests.swift | 142 + .../DownloadManagerRepairSeedTests.swift | 197 + .../DownloadManagerStorageTests.swift | 189 + .../Download/DownloadObserverBatchTests.swift | 160 + .../DownloadObserverReadingTests.swift | 225 + .../DownloadObserverRefreshTests.swift | 156 + .../DownloadPauseAndReconcileTests.swift | 259 + .../Download/DownloadProcessCacheTests.swift | 299 + .../Tests/Download/DownloadProcessTests.swift | 163 + .../DownloadRetryMinimalSourceTests.swift | 110 + .../Download/DownloadRetryPagesTests.swift | 137 + .../DownloadRetryUpdateFallbackTests.swift | 198 + .../DownloadSignatureBuilderTests.swift | 188 - .../DownloadSignaturePreviewTests.swift | 249 + .../DownloadVersionSignatureTests.swift | 156 + .../DownloadsReducerActionTests.swift | 190 + .../DownloadsReducerRefreshTests.swift | 141 + .../PreviewsReducerDownloadTests.swift | 151 + .../ReadingReducerDownloadTests.swift | 171 + .../Download/ReadingReducerLocalTests.swift | 110 + .../Gallery/GalleryImageURLParserTests.swift | 5 +- ShareExtension/ShareViewController.swift | 3 +- 191 files changed, 23258 insertions(+), 20683 deletions(-) create mode 100644 EhPanda/App/Tools/Clients/DatabaseClient+Updates.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+Cache.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+Execution.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+Manager.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+Networking.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+Testing.swift delete mode 100644 EhPanda/App/Tools/Parser.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Archive.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Comment.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Detail.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Download.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Favorite.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Greeting.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Image.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+List.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Misc.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Preview.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Profile.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Shared.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Torrent.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+Types.swift create mode 100644 EhPanda/App/Tools/Parser/Parser+User.swift create mode 100644 EhPanda/App/Tools/Parser/Parser.swift create mode 100644 EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift create mode 100644 EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift create mode 100644 EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift create mode 100644 EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift create mode 100644 EhPanda/Models/Support/BrowsingCountry+EnglishName.swift create mode 100644 EhPanda/Models/Support/EhSetting+Enums.swift create mode 100644 EhPanda/Models/Support/EhSetting+Extensions.swift create mode 100644 EhPanda/Network/Request+Account.swift create mode 100644 EhPanda/Network/Request+Detail.swift create mode 100644 EhPanda/Network/Request+Gallery.swift create mode 100644 EhPanda/Network/Request+Image.swift create mode 100644 EhPanda/View/Detail/DetailReducer+Actions.swift create mode 100644 EhPanda/View/Detail/DetailReducer+Download.swift create mode 100644 EhPanda/View/Detail/DetailReducer+Fetch.swift create mode 100644 EhPanda/View/Detail/DetailView+CommentCells.swift create mode 100644 EhPanda/View/Detail/DetailView+HeaderSection.swift create mode 100644 EhPanda/View/Detail/DetailView+Navigation.swift create mode 100644 EhPanda/View/Detail/DetailView+Subviews.swift create mode 100644 EhPanda/View/Downloads/DownloadInspectorReducer.swift create mode 100644 EhPanda/View/Downloads/DownloadsView+Subviews.swift create mode 100644 EhPanda/View/Home/HomeReducer+Body.swift create mode 100644 EhPanda/View/Home/HomeView+Sections.swift create mode 100644 EhPanda/View/Reading/ReadingReducer+Body.swift create mode 100644 EhPanda/View/Reading/ReadingReducer+Database.swift create mode 100644 EhPanda/View/Reading/ReadingReducer+ImageFetch.swift create mode 100644 EhPanda/View/Reading/ReadingView+Gestures.swift create mode 100644 EhPanda/View/Reading/ReadingViewComponents.swift create mode 100644 EhPanda/View/Search/SearchRootView+Keywords.swift create mode 100644 EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift create mode 100644 EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift create mode 100644 EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift create mode 100644 EhPanda/View/Setting/SettingReducer+Body.swift create mode 100644 EhPanda/View/Setting/SettingReducer+Helpers.swift create mode 100644 EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift create mode 100644 EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift create mode 100644 EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift create mode 100644 EhPandaTests/Tests/Download/DetailReducerObserveTests.swift create mode 100644 EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadAutomationTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift create mode 100644 EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift create mode 100644 EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift create mode 100644 EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift create mode 100644 EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadImageErrorTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadImageParsingTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadIpBanTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadProcessTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadSignaturePreviewTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift create mode 100644 EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift create mode 100644 EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift create mode 100644 EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 7b9b494b3..7e49f18b8 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,10 +1,2 @@ -disabled_rules: - - large_tuple - - file_length - - opening_brace - - type_body_length - - function_body_length - - cyclomatic_complexity - excluded: - EhPanda/App/Generated diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift index cc6a8d3fa..eaaf4922d 100644 --- a/EhPanda/App/Tools/Clients/CookieClient.swift +++ b/EhPanda/App/Tools/Clients/CookieClient.swift @@ -178,8 +178,8 @@ extension CookieClient { var shouldFetchIgneous: Bool { let url = Defaults.URL.exhentai return !getCookie(url, Defaults.Cookie.ipbMemberId).rawValue.isEmpty - && !getCookie(url, Defaults.Cookie.ipbPassHash).rawValue.isEmpty - && getCookie(url, Defaults.Cookie.igneous).rawValue.isEmpty + && !getCookie(url, Defaults.Cookie.ipbPassHash).rawValue.isEmpty + && getCookie(url, Defaults.Cookie.igneous).rawValue.isEmpty } func removeYay() { removeCookie(Defaults.URL.exhentai, Defaults.Cookie.yay) @@ -256,7 +256,7 @@ extension CookieClient { for: cookie, key: subState.key, value: trimsSpaces - ? subState.editingText .trimmingCharacters(in: .whitespaces) : subState.editingText + ? subState.editingText .trimmingCharacters(in: .whitespaces) : subState.editingText ) } } diff --git a/EhPanda/App/Tools/Clients/DatabaseClient+Updates.swift b/EhPanda/App/Tools/Clients/DatabaseClient+Updates.swift new file mode 100644 index 000000000..8e9e8c944 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DatabaseClient+Updates.swift @@ -0,0 +1,139 @@ +// +// DatabaseClient+Updates.swift +// EhPanda +// + +import SwiftUI +import CoreData + +// MARK: UpdateGalleryState +extension DatabaseClient { + @MainActor func updateGalleryState(gid: String, commitChanges: @escaping (GalleryStateMO) -> Void) { + guard gid.isValidGID else { return } + update( + entityType: GalleryStateMO.self, gid: gid, createIfNil: true, + commitChanges: commitChanges + ) + } + @MainActor func updateGalleryState(gid: String, key: String, value: Any?) { + guard gid.isValidGID else { return } + updateGalleryState(gid: gid) { stateMO in + stateMO.setValue(value, forKeyPath: key) + } + } + @MainActor func updateGalleryTags(gid: String, tags: [GalleryTag]) { + guard gid.isValidGID else { return } + updateGalleryState(gid: gid, key: "tags", value: tags.toData()) + } + @MainActor func updatePreviewConfig(gid: String, config: PreviewConfig) { + guard gid.isValidGID else { return } + updateGalleryState(gid: gid, key: "previewConfig", value: config.toData()) + } + @MainActor func updateReadingProgress(gid: String, progress: Int) { + guard gid.isValidGID else { return } + updateGalleryState(gid: gid, key: "readingProgress", value: Int64(progress)) + } + @MainActor func updateComments(gid: String, comments: [GalleryComment]) { + guard gid.isValidGID else { return } + updateGalleryState(gid: gid, key: "comments", value: comments.toData()) + } + + @MainActor func removeImageURLs(gid: String) { + guard gid.isValidGID else { return } + updateGalleryState(gid: gid) { galleryStateMO in + galleryStateMO.imageURLs = nil + galleryStateMO.previewURLs = nil + galleryStateMO.thumbnailURLs = nil + galleryStateMO.originalImageURLs = nil + } + } + @MainActor func removeImageURLs() { + batchUpdate(entityType: GalleryStateMO.self) { galleryStateMOs in + galleryStateMOs.forEach { galleryStateMO in + galleryStateMO.imageURLs = nil + galleryStateMO.previewURLs = nil + galleryStateMO.thumbnailURLs = nil + galleryStateMO.originalImageURLs = nil + } + } + } + @MainActor func removeExpiredImageURLs() { + fetchHistoryGalleries() + .filter { Date().timeIntervalSince($0.lastOpenDate ?? .distantPast) > .oneWeek } + .forEach { removeImageURLs(gid: $0.id) } + } + @MainActor func updateThumbnailURLs(gid: String, thumbnailURLs: [Int: URL]) { + guard gid.isValidGID else { return } + updateGalleryState(gid: gid) { galleryStateMO in + update(gid: gid, storedData: &galleryStateMO.thumbnailURLs, new: thumbnailURLs) + } + } + @MainActor func updateImageURLs(gid: String, imageURLs: [Int: URL], originalImageURLs: [Int: URL]) { + guard gid.isValidGID else { return } + updateGalleryState(gid: gid) { galleryStateMO in + update(gid: gid, storedData: &galleryStateMO.imageURLs, new: imageURLs) + update(gid: gid, storedData: &galleryStateMO.originalImageURLs, new: originalImageURLs) + } + } + @MainActor func updatePreviewURLs(gid: String, previewURLs: [Int: URL]) { + guard gid.isValidGID else { return } + updateGalleryState(gid: gid) { galleryStateMO in + update(gid: gid, storedData: &galleryStateMO.previewURLs, new: previewURLs) + } + } +} + +// MARK: UpdateAppEnv +extension DatabaseClient { + @MainActor func updateAppEnv(key: String, value: Any?) { + update( + entityType: AppEnvMO.self, createIfNil: true, + commitChanges: { $0.setValue(value, forKeyPath: key) } + ) + } + @MainActor func updateSetting(_ setting: Setting) { + updateAppEnv(key: "setting", value: setting.toData()) + } + @MainActor func updateFilter(_ filter: Filter, range: FilterRange) { + let key: String + switch range { + case .search: + key = "searchFilter" + case .global: + key = "globalFilter" + case .watched: + key = "watchedFilter" + } + updateAppEnv(key: key, value: filter.toData()) + } + @MainActor func updateTagTranslator(_ tagTranslator: TagTranslator) { + updateAppEnv(key: "tagTranslator", value: tagTranslator.toData()) + } + @MainActor func updateUser(_ user: User) { + updateAppEnv(key: "user", value: user.toData()) + } + @MainActor func updateHistoryKeywords(_ keywords: [String]) { + updateAppEnv(key: "historyKeywords", value: keywords.toData()) + } + @MainActor func updateQuickSearchWords(_ words: [QuickSearchWord]) { + updateAppEnv(key: "quickSearchWords", value: words.toData()) + } + + // Update User + @MainActor func updateUserProperty(_ commitChanges: @escaping (inout User) -> Void) { + var user = fetchAppEnv().user + commitChanges(&user) + updateUser(user) + } + @MainActor func updateGreeting(_ greeting: Greeting) { + updateUserProperty { user in + user.greeting = greeting + } + } + @MainActor func updateGalleryFunds(galleryPoints: String, credits: String) { + updateUserProperty { user in + user.credits = credits + user.galleryPoints = galleryPoints + } + } +} diff --git a/EhPanda/App/Tools/Clients/DatabaseClient.swift b/EhPanda/App/Tools/Clients/DatabaseClient.swift index bd0d55bab..92387296b 100644 --- a/EhPanda/App/Tools/Clients/DatabaseClient.swift +++ b/EhPanda/App/Tools/Clients/DatabaseClient.swift @@ -58,7 +58,7 @@ extension DatabaseClient { // MARK: Foundation extension DatabaseClient { - private func batchFetch( + func batchFetch( entityType: MO.Type, fetchLimit: Int = 0, predicate: NSPredicate? = nil, findBeforeFetch: Bool = true, sortDescriptors: [NSSortDescriptor]? = nil ) -> [MO] { @@ -82,7 +82,7 @@ extension DatabaseClient { return results } - private func fetch( + func fetch( entityType: MO.Type, predicate: NSPredicate? = nil, findBeforeFetch: Bool = true, commitChanges: ((MO?) -> Void)? = nil ) -> MO? { @@ -94,7 +94,7 @@ extension DatabaseClient { return managedObject } - private func fetchOrCreate( + func fetchOrCreate( entityType: MO.Type, predicate: NSPredicate? = nil, commitChanges: ((MO?) -> Void)? = nil ) -> MO { @@ -110,7 +110,7 @@ extension DatabaseClient { } } - private func batchUpdate( + func batchUpdate( entityType: MO.Type, predicate: NSPredicate? = nil, commitChanges: ([MO]) -> Void ) { commitChanges(batchFetch( @@ -120,7 +120,7 @@ extension DatabaseClient { )) saveContext() } - private func update( + func update( entityType: MO.Type, predicate: NSPredicate? = nil, createIfNil: Bool = false, commitChanges: (MO) -> Void ) { @@ -141,7 +141,7 @@ extension DatabaseClient { // MARK: GalleryIdentifiable extension DatabaseClient { - private func fetch( + func fetch( entityType: MO.Type, gid: String, findBeforeFetch: Bool = true, commitChanges: ((MO?) -> Void)? = nil @@ -151,14 +151,14 @@ extension DatabaseClient { findBeforeFetch: findBeforeFetch, commitChanges: commitChanges ) } - private func fetchOrCreate(entityType: MO.Type, gid: String) -> MO { + func fetchOrCreate(entityType: MO.Type, gid: String) -> MO { fetchOrCreate( entityType: entityType, predicate: NSPredicate(format: "gid == %@", gid), commitChanges: { $0?.gid = gid } ) } - private func update( + func update( entityType: MO.Type, gid: String, createIfNil: Bool = false, commitChanges: @escaping ((MO) -> Void) @@ -178,6 +178,13 @@ extension DatabaseClient { } } +// MARK: GalleryState Helpers +extension DatabaseClient { + func update(gid: String, storedData: inout Data?, new: T) { + storedData = new.toData() + } +} + // MARK: Fetch extension DatabaseClient { func fetchGallery(gid: String) -> Gallery? { @@ -325,151 +332,7 @@ extension DatabaseClient { } } -// MARK: UpdateGalleryState -extension DatabaseClient { - @MainActor func updateGalleryState(gid: String, commitChanges: @escaping (GalleryStateMO) -> Void) { - guard gid.isValidGID else { return } - update( - entityType: GalleryStateMO.self, gid: gid, createIfNil: true, - commitChanges: commitChanges - ) - } - @MainActor func updateGalleryState(gid: String, key: String, value: Any?) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { stateMO in - stateMO.setValue(value, forKeyPath: key) - } - } - @MainActor func updateGalleryTags(gid: String, tags: [GalleryTag]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid, key: "tags", value: tags.toData()) - } - @MainActor func updatePreviewConfig(gid: String, config: PreviewConfig) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid, key: "previewConfig", value: config.toData()) - } - @MainActor func updateReadingProgress(gid: String, progress: Int) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid, key: "readingProgress", value: Int64(progress)) - } - @MainActor func updateComments(gid: String, comments: [GalleryComment]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid, key: "comments", value: comments.toData()) - } - - @MainActor func removeImageURLs(gid: String) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { galleryStateMO in - galleryStateMO.imageURLs = nil - galleryStateMO.previewURLs = nil - galleryStateMO.thumbnailURLs = nil - galleryStateMO.originalImageURLs = nil - } - } - @MainActor func removeImageURLs() { - batchUpdate(entityType: GalleryStateMO.self) { galleryStateMOs in - galleryStateMOs.forEach { galleryStateMO in - galleryStateMO.imageURLs = nil - galleryStateMO.previewURLs = nil - galleryStateMO.thumbnailURLs = nil - galleryStateMO.originalImageURLs = nil - } - } - } - @MainActor func removeExpiredImageURLs() { - fetchHistoryGalleries() - .filter { Date().timeIntervalSince($0.lastOpenDate ?? .distantPast) > .oneWeek } - .forEach { removeImageURLs(gid: $0.id) } - } - @MainActor func updateThumbnailURLs(gid: String, thumbnailURLs: [Int: URL]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { galleryStateMO in - update(gid: gid, storedData: &galleryStateMO.thumbnailURLs, new: thumbnailURLs) - } - } - @MainActor func updateImageURLs(gid: String, imageURLs: [Int: URL], originalImageURLs: [Int: URL]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { galleryStateMO in - update(gid: gid, storedData: &galleryStateMO.imageURLs, new: imageURLs) - update(gid: gid, storedData: &galleryStateMO.originalImageURLs, new: originalImageURLs) - } - } - @MainActor func updatePreviewURLs(gid: String, previewURLs: [Int: URL]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { galleryStateMO in - update(gid: gid, storedData: &galleryStateMO.previewURLs, new: previewURLs) - } - } - - private func update( - gid: String, storedData: inout Data?, new: [Int: T] - ) { - guard !new.isEmpty, gid.isValidGID else { return } - - if let storedDictionary = storedData?.toObject() as [Int: T]? { - storedData = storedDictionary.merging( - new, uniquingKeysWith: { _, new in new } - ).toData() - } else { - storedData = new.toData() - } - } -} - -// MARK: UpdateAppEnv -extension DatabaseClient { - @MainActor func updateAppEnv(key: String, value: Any?) { - update( - entityType: AppEnvMO.self, createIfNil: true, - commitChanges: { $0.setValue(value, forKeyPath: key) } - ) - } - @MainActor func updateSetting(_ setting: Setting) { - updateAppEnv(key: "setting", value: setting.toData()) - } - @MainActor func updateFilter(_ filter: Filter, range: FilterRange) { - let key: String - switch range { - case .search: - key = "searchFilter" - case .global: - key = "globalFilter" - case .watched: - key = "watchedFilter" - } - updateAppEnv(key: key, value: filter.toData()) - } - @MainActor func updateTagTranslator(_ tagTranslator: TagTranslator) { - updateAppEnv(key: "tagTranslator", value: tagTranslator.toData()) - } - @MainActor func updateUser(_ user: User) { - updateAppEnv(key: "user", value: user.toData()) - } - @MainActor func updateHistoryKeywords(_ keywords: [String]) { - updateAppEnv(key: "historyKeywords", value: keywords.toData()) - } - @MainActor func updateQuickSearchWords(_ words: [QuickSearchWord]) { - updateAppEnv(key: "quickSearchWords", value: words.toData()) - } - - // Update User - @MainActor func updateUserProperty(_ commitChanges: @escaping (inout User) -> Void) { - var user = fetchAppEnv().user - commitChanges(&user) - updateUser(user) - } - @MainActor func updateGreeting(_ greeting: Greeting) { - updateUserProperty { user in - user.greeting = greeting - } - } - @MainActor func updateGalleryFunds(galleryPoints: String, credits: String) { - updateUserProperty { user in - user.credits = credits - user.galleryPoints = galleryPoints - } - } -} +// UpdateGalleryState and UpdateAppEnv are in DatabaseClient+Updates.swift // MARK: API enum DatabaseClientKey: DependencyKey { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift new file mode 100644 index 000000000..9b2d2ff9f --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -0,0 +1,324 @@ +// +// DownloadClient+Cache.swift +// EhPanda +// + +import Foundation +import Kingfisher + +// MARK: - Cache Operations +extension DownloadManager { + func cacheKeys( + for url: URL, + includeStableAlias: Bool + ) -> [String] { + url.imageCacheKeys(includeStableAlias: includeStableAlias) + } + + func removeCachedImages( + for urls: [URL?], + includeStableAlias: Bool + ) { + let keys = urls + .compactMap(\.self) + .flatMap { + cacheKeys(for: $0, includeStableAlias: includeStableAlias) + } + + for key in Set(keys) { + KingfisherManager.shared.cache + .removeImage(forKey: key) + } + } + + func pageImageCacheURLs( + resolvedImageSource: ResolvedImageSource?, + index: Int, + storedGalleryImageState: CachedGalleryImageState? + ) -> [URL?] { + [ + resolvedImageSource?.imageURL, + storedGalleryImageState?.imageURLs[index] + ] + } + + func pageImageCacheURLs( + imageURL: URL? + ) -> [URL?] { + [imageURL] + } + + func canSatisfyPendingPageDownloadsFromCache( + pendingPageIndices: [Int], + temporaryFolderURL: URL, + existingPageRelativePaths: [Int: String], + storedGalleryImageState: CachedGalleryImageState? + ) async -> Bool { + guard !pendingPageIndices.isEmpty else { return true } + for index in pendingPageIndices { + if let relativePath = + existingPageRelativePaths[index] { + let fileURL = temporaryFolderURL + .appendingPathComponent(relativePath) + if fileManager() + .fileExists(atPath: fileURL.path) { + continue + } + } + guard await validatedCachedAssetData( + for: pageImageCacheURLs( + resolvedImageSource: nil, + index: index, + storedGalleryImageState: + storedGalleryImageState + ) + ) != nil else { + return false + } + } + return true + } + + func restorePendingPagesFromStoredCache( + indices: [Int], + temporaryFolderURL: URL, + existingPages: [Int: String], + storedGalleryImageState: CachedGalleryImageState? + ) async throws -> [PageResult] { + var restoredPages = [PageResult]() + for index in indices { + let cacheURLs = pageImageCacheURLs( + resolvedImageSource: nil, + index: index, + storedGalleryImageState: + storedGalleryImageState + ) + let cacheSource = CacheRestoreSource( + cacheURLs: cacheURLs, + referenceURL: cacheURLs + .compactMap(\.self).first, + imageURL: storedGalleryImageState? + .imageURLs[index] + ) + guard let pageResult = + try await restorePageFromCache( + index: index, + source: cacheSource, + folderURL: temporaryFolderURL, + preferredRelativePath: + existingPages[index] + ) else { + continue + } + restoredPages.append(pageResult) + } + return restoredPages + } + + func restorePageFromCache( + index: Int, + source: CacheRestoreSource, + folderURL: URL, + preferredRelativePath: String?, + overwriteExistingFile: Bool = false + ) async throws -> PageResult? { + guard let cachedData = await validatedCachedAssetData( + for: source.cacheURLs + ) + else { + return nil + } + + let relativePath: String + if let preferredRelativePath { + relativePath = preferredRelativePath + } else { + let fallbackURL = source.referenceURL + ?? URL( + string: "https://example.com/\(index).jpg" + )! + let ext = fileExtension( + for: fallbackURL, + response: nil, + prefixData: cachedData + ) + relativePath = storage.makePageRelativePath( + index: index, + fileExtension: ext + ) + } + + let fileURL = folderURL + .appendingPathComponent(relativePath) + if overwriteExistingFile + || !fileManager() + .fileExists(atPath: fileURL.path) { + try write(data: cachedData, to: fileURL) + } + + return .init( + index: index, + relativePath: relativePath, + imageURL: source.imageURL + ) + } + + func preferredPageReferenceURL( + resolvedImageSource: ResolvedImageSource + ) -> URL? { + resolvedImageSource.imageURL + } + + func preferredPageReferenceURL( + imageURL: URL? + ) -> URL? { + imageURL + } + + func clearFailedPage( + index: Int, + folderURL: URL + ) throws { + guard let failedSnapshot = try? storage + .readFailedPages(folderURL: folderURL) else { + return + } + let remainingPages = failedSnapshot.pages + .filter { $0.index != index } + if remainingPages.count == failedSnapshot.pages.count { + return + } + if remainingPages.isEmpty { + try? storage.removeFailedPages(folderURL: folderURL) + } else { + try storage.writeFailedPages( + .init(pages: remainingPages), + folderURL: folderURL + ) + } + } + + func shouldExposeTemporaryWorkingSet( + for download: DownloadedGallery + ) -> Bool { + download.shouldPreserveTemporaryWorkingSet + || download.status == .failed + } + + func cachedImageData(for url: URL) async -> Data? { + await cachedImageData( + for: [url], + includeStableAlias: false + ) + } + + func cachedImageData( + for urls: [URL?], + includeStableAlias: Bool + ) async -> Data? { + let allKeys = urls + .compactMap { $0 } + .flatMap { + cacheKeys( + for: $0, + includeStableAlias: includeStableAlias + ) + } + let keys = allKeys + .reduce(into: [String]()) { partialResult, key in + guard !partialResult.contains(key) else { + return + } + partialResult.append(key) + } + + for key in keys { + if let data = await cachedImageData(forKey: key) { + return data + } + } + return nil + } + + func cachedImageData(forKey key: String) async -> Data? { + if let image = KingfisherManager.shared.cache + .retrieveImageInMemoryCache(forKey: key), + let data = image.kf.data(format: .unknown) { + return data + } + + if let data = try? KingfisherManager.shared.cache + .diskStorage.value(forKey: key) { + return data + } + + return await withCheckedContinuation { continuation in + KingfisherManager.shared.cache + .retrieveImage(forKey: key) { result in + switch result { + case .success(let value): + guard let image = value.image, + let data = image.kf + .data(format: .unknown) + else { + continuation.resume(returning: nil) + return + } + continuation.resume(returning: data) + + case .failure: + continuation.resume(returning: nil) + } + } + } + } + + func validatedCachedAssetData( + for urls: [URL?] + ) async -> Data? { + guard let cachedData = await cachedImageData( + for: urls, + includeStableAlias: true + ) else { + return nil + } + guard detectCachedAssetError( + data: cachedData, + referenceURLs: urls + ) == nil else { + removeCachedImages( + for: urls, + includeStableAlias: true + ) + return nil + } + return cachedData + } + + func detectCachedAssetError( + data: Data, + referenceURLs _: [URL?] + ) -> AppError? { + guard !data.isEmpty else { return .parseFailed } + if isAuthenticationRequiredPlaceholderImageData(data) { + return .authenticationRequired + } + if isQuotaExceededAssetData(data) { + return .quotaExceeded + } + + let looksLikeHTML = prefixLooksLikeHTML( + Data( + data.prefix(Self.responseInspectionPrefixLength) + ) + ) + if let error = detectTextualDownloadError( + data: data, + looksLikeHTML: looksLikeHTML + ) { + return error + } + + return isDecodableImageData(data) ? nil : .parseFailed + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift new file mode 100644 index 000000000..e55516aaa --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -0,0 +1,262 @@ +// +// DownloadClient+Execution.swift +// EhPanda +// + +import Kanna +import Foundation + +// MARK: - Process Download +extension DownloadManager { + func processDownload(gid: String) async { + defer { + activeTask = nil + activeGalleryID = nil + Task { + await self.scheduleNextIfNeeded() + } + } + + guard let download = await fetchDownload(gid: gid) else { + return + } + let mode = queuedMode(for: download) + let hadReadableFiles = + storage.validate(download: download) == .valid + var fetchedVersionSignature: String? + + do { + try await markDownloadAsDownloading( + gid: gid, + completedPageCount: download.completedPageCount + ) + await notifyObservers() + let result = try await fetchNormalizeAndDownload( + gid: gid, + download: download, + mode: mode + ) + fetchedVersionSignature = result.versionSignature + guard !Task.isCancelled else { return } + try await completeDownload( + gid: gid, + download: download, + result: result + ) + } catch is CancellationError { + return + } catch { + let context = FailureContext( + gid: gid, + originalDownload: download, + mode: mode, + hadReadableFiles: hadReadableFiles, + latestSignature: fetchedVersionSignature + ) + handleProcessDownloadError(error: error, context: context) + } + } + + private func completeDownload( + gid: String, + download: DownloadedGallery, + result: ProcessDownloadResult + ) async throws { + try await persistCompletedDownload( + gid: gid, + payload: result.payload, + folderRelativePath: result.folderRelativePath, + coverRelativePath: result.coverRelativePath, + versionSignature: result.versionSignature + ) + if download.folderRelativePath != result.folderRelativePath { + try? storage.removeFolder( + relativePath: download.folderRelativePath + ) + } + await notifyObservers() + } + + private func handleProcessDownloadError( + error: Error, + context: FailureContext + ) { + if let appError = error as? AppError { + handleProcessDownloadAppError(error: appError, context: context) + } else if let partialError = error as? PartialDownloadError { + handleProcessDownloadPartialError(error: partialError, context: context) + } else { + handleProcessDownloadGenericError(error: error, context: context) + } + } + + private struct ProcessDownloadResult { + let payload: DownloadRequestPayload + let folderRelativePath: String + let coverRelativePath: String? + let versionSignature: String + } + + private func markDownloadAsDownloading( + gid: String, + completedPageCount: Int + ) async throws { + try await updateDownloadRecord( + gid: gid, + createIfMissing: false + ) { record in + record.status = DownloadStatus.downloading.rawValue + record.completedPageCount = Int64(completedPageCount) + record.lastError = nil + record.pendingOperation = nil + } + } + + private func fetchNormalizeAndDownload( + gid: String, + download: DownloadedGallery, + mode: DownloadStartMode + ) async throws -> ProcessDownloadResult { + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let existingResumeState = try? storage + .readResumeState(folderURL: temporaryFolderURL) + let rawPageSelection = existingResumeState?.pageSelection + let fetchResult = try await fetchLatestPayload( + for: download, + mode: mode, + pageSelection: rawPageSelection + ) + let payload = normalizeFetchedPayload( + fetchResult.payload, + mode: mode, + versionSignature: fetchResult.versionSignature, + existingResumeState: existingResumeState, + rawPageSelection: rawPageSelection + ) + let folderRelativePath = storage.makeFolderRelativePath( + gid: payload.gallery.gid, + title: payload.galleryDetail.trimmedTitle.isEmpty + ? payload.gallery.title + : payload.galleryDetail.trimmedTitle + ) + let downloadResult = try await performDownload( + payload: payload, + versionSignature: fetchResult.versionSignature, + folderRelativePath: folderRelativePath, + existingDownload: download + ) + return ProcessDownloadResult( + payload: payload, + folderRelativePath: folderRelativePath, + coverRelativePath: downloadResult.coverRelativePath, + versionSignature: fetchResult.versionSignature + ) + } + + private func handleProcessDownloadAppError( + error: AppError, + context: FailureContext + ) { + guard !isCancellationLikeAppError(error) else { return } + guard !shouldSuppressFailurePersistence(for: context.gid) else { + return + } + Logger.error( + "Download failed.", + context: [ + "gid": context.gid, + "mode": context.mode.rawValue, + "error": error.localizedDescription + ] + ) + Task { + await persistFailure(error: error, context: context) + await notifyObservers() + } + } + + private func handleProcessDownloadPartialError( + error: PartialDownloadError, + context: FailureContext + ) { + let pageError = + error.failedPages.first?.failure.appError ?? .unknown + guard !isCancellationLikeAppError(pageError) else { return } + guard !shouldSuppressFailurePersistence(for: context.gid) else { + return + } + Logger.error( + "Download partially failed.", + context: [ + "gid": context.gid, + "mode": context.mode.rawValue, + "failedPages": error.failedPages.map(\.index) + ] + ) + Task { + await persistFailure(error: pageError, context: context) + await notifyObservers() + } + } + + private func handleProcessDownloadGenericError( + error: Error, + context: FailureContext + ) { + let appError = AppError.fileOperationFailed( + error.localizedDescription + ) + guard !isCancellationLikeAppError(appError) else { return } + guard !shouldSuppressFailurePersistence(for: context.gid) else { + return + } + Logger.error(error) + Task { + await persistFailure( + error: appError, + context: context + ) + await notifyObservers() + } + } + + func persistCompletedDownload( + gid: String, + payload: DownloadRequestPayload, + folderRelativePath: String, + coverRelativePath: String?, + versionSignature: String + ) async throws { + try await updateDownloadRecord( + gid: gid, + createIfMissing: false + ) { record in + record.host = payload.host.rawValue + record.token = payload.gallery.token + record.title = payload.gallery.title + record.jpnTitle = payload.galleryDetail.jpnTitle + record.uploader = payload.galleryDetail.uploader + record.category = payload.gallery.category.rawValue + record.tags = payload.gallery.tags.toData() + record.pageCount = + Int64(payload.galleryDetail.pageCount) + record.postedDate = payload.galleryDetail.postedDate + record.rating = payload.galleryDetail.rating + record.onlineCoverURL = + payload.galleryDetail.coverURL + ?? payload.gallery.coverURL + record.folderRelativePath = folderRelativePath + record.coverRelativePath = coverRelativePath + record.downloadOptionsSnapshot = + payload.options.toData() + record.completedPageCount = + Int64(payload.galleryDetail.pageCount) + record.lastDownloadedAt = .now + record.lastError = nil + record.remoteVersionSignature = versionSignature + record.latestRemoteVersionSignature = versionSignature + record.pendingOperation = nil + record.status = DownloadStatus.completed.rawValue + } + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift new file mode 100644 index 000000000..e3caf1ac2 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -0,0 +1,183 @@ +// +// DownloadClient+ExecutionFetch.swift +// EhPanda +// + +import Kanna +import Foundation + +// MARK: - Fetch & Normalize Payload +extension DownloadManager { + struct FetchLatestPayloadResult: Sendable { + let payload: DownloadRequestPayload + let versionSignature: String + } + + func fetchLatestPayload( + for download: DownloadedGallery, + mode: DownloadStartMode, + pageSelection: [Int]? + ) async throws -> FetchLatestPayloadResult { + let galleryURL = download.gallery.galleryURL + guard let galleryURL else { throw AppError.notFound } + let (detail, galleryState) = try await withRetry( + operation: "fetchLatestPayload", + context: [ + "gid": download.gid, + "mode": mode.rawValue, + "galleryURL": galleryURL.absoluteString + ] + ) { + let doc = try await htmlDocument( + url: URLUtil.galleryDetail(url: galleryURL), + allowsCellular: + download.downloadOptionsSnapshot.allowCellular, + retriesRequest: false + ) + return try Parser.parseGalleryDetail( + doc: doc, + gid: download.gid + ) + } + let components = buildGalleryComponents( + download: download, + detail: detail, + galleryState: galleryState, + galleryURL: galleryURL + ) + let versionMetadata = await fetchVersionMetadata( + gid: download.gid, + token: download.token + ) + let fetchedData = FetchedGalleryData( + download: download, + detail: detail, + versionMetadata: versionMetadata + ) + return buildFetchResult( + fetchedData: fetchedData, + components: components, + mode: mode, + pageSelection: pageSelection + ) + } + + private struct FetchedGalleryData { + let download: DownloadedGallery + let detail: GalleryDetail + let versionMetadata: DownloadVersionMetadata? + } + + private func buildFetchResult( + fetchedData: FetchedGalleryData, + components: GalleryComponents, + mode: DownloadStartMode, + pageSelection: [Int]? + ) -> FetchLatestPayloadResult { + let download = fetchedData.download + let detail = fetchedData.detail + let versionMetadata = fetchedData.versionMetadata + let versionSignature = DownloadSignatureBuilder.make( + gallery: components.gallery, + detail: detail, + host: download.host, + previewURLs: components.previewURLs, + versionMetadata: versionMetadata + ) + return FetchLatestPayloadResult( + payload: .init( + gallery: components.gallery, + galleryDetail: detail, + previewURLs: components.previewURLs, + previewConfig: components.previewConfig, + host: download.host, + versionMetadata: versionMetadata, + options: download.downloadOptionsSnapshot, + mode: mode, + pageSelection: pageSelection.map(Set.init) + ), + versionSignature: versionSignature + ) + } + + private struct GalleryComponents { + let gallery: Gallery + let previewURLs: [Int: URL] + let previewConfig: PreviewConfig + } + + private func buildGalleryComponents( + download: DownloadedGallery, + detail: GalleryDetail, + galleryState: GalleryState, + galleryURL: URL + ) -> GalleryComponents { + let gallery = Gallery( + gid: download.gid, + token: download.token, + title: detail.title, + rating: detail.rating, + tags: galleryState.tags, + category: detail.category, + uploader: detail.uploader, + pageCount: detail.pageCount, + postedDate: detail.postedDate, + coverURL: detail.coverURL ?? download.onlineCoverURL, + galleryURL: galleryURL + ) + return GalleryComponents( + gallery: gallery, + previewURLs: galleryState.previewURLs, + previewConfig: galleryState.previewConfig ?? .normal(rows: 4) + ) + } + + private func fetchVersionMetadata( + gid: String, + token: String + ) async -> DownloadVersionMetadata? { + switch await GalleryVersionMetadataRequest( + gid: gid, + token: token + ).response() { + case .success(let metadata): + return metadata + case .failure: + return nil + } + } + + func normalizeFetchedPayload( + _ payload: DownloadRequestPayload, + mode: DownloadStartMode, + versionSignature: String, + existingResumeState: DownloadResumeState?, + rawPageSelection: [Int]? + ) -> DownloadRequestPayload { + let shouldPreservePageSelection = + rawPageSelection?.isEmpty == false + && existingResumeState?.matches( + mode: mode, + versionSignature: versionSignature, + pageCount: payload.galleryDetail.pageCount, + downloadOptions: payload.options + ) == true + && mode != .update + + guard !shouldPreservePageSelection else { + return payload + } + + return .init( + gallery: payload.gallery, + galleryDetail: payload.galleryDetail, + previewURLs: payload.previewURLs, + previewConfig: payload.previewConfig, + host: payload.host, + versionMetadata: payload.versionMetadata, + options: payload.options, + mode: payload.mode, + pageSelection: nil + ) + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift new file mode 100644 index 000000000..280cc5a48 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -0,0 +1,258 @@ +// +// DownloadClient+ExecutionPerform.swift +// EhPanda +// + +import Foundation + +// MARK: - Perform Download +extension DownloadManager { + struct PerformDownloadResult { + let coverRelativePath: String? + let pages: [PageResult] + } + + func performDownload( + payload: DownloadRequestPayload, + versionSignature: String, + folderRelativePath: String, + existingDownload: DownloadedGallery + ) async throws -> PerformDownloadResult { + try storage.ensureRootDirectory() + + let temporaryFolderURL = storage + .temporaryFolderURL(gid: payload.gallery.gid) + let workingSeed = try prepareWorkingSeed( + payload: payload, + existingDownload: existingDownload, + temporaryFolderURL: temporaryFolderURL, + versionSignature: versionSignature + ) + let pendingIndices = pendingPageIndices( + payload: payload, + folderURL: temporaryFolderURL, + existingPageRelativePaths: workingSeed.existingPages + ) + try storage.writeResumeState( + .init( + mode: payload.mode, + versionSignature: versionSignature, + pageCount: payload.galleryDetail.pageCount, + downloadOptions: payload.options, + pageSelection: payload.pageSelection?.sorted() + ), + folderURL: temporaryFolderURL + ) + + let executionContext = DownloadExecutionContext( + existingDownload: existingDownload, + versionSignature: versionSignature, + folderRelativePath: folderRelativePath + ) + do { + let batchAndCover = try await executePageDownloads( + payload: payload, + workingSeed: workingSeed, + pendingIndices: pendingIndices, + temporaryFolderURL: temporaryFolderURL, + executionContext: executionContext + ) + return batchAndCover + } catch is CancellationError { + throw CancellationError() + } catch { + throw error + } + } + + private func executePageDownloads( + payload: DownloadRequestPayload, + workingSeed: WorkingSeed, + pendingIndices: [Int], + temporaryFolderURL: URL, + executionContext: DownloadExecutionContext + ) async throws -> PerformDownloadResult { + let existingDownload = executionContext.existingDownload + let versionSignature = executionContext.versionSignature + let folderRelativePath = executionContext.folderRelativePath + let storedGalleryImageState = + await fetchCachedGalleryImageState( + gid: payload.gallery.gid + ) + let coverRelativePath = try await downloadAndPersistCoverIfNeeded( + payload: payload, + temporaryFolderURL: temporaryFolderURL, + existingCoverRelativePath: workingSeed.coverRelativePath, + existingDownload: existingDownload + ) + let source = try await resolveSourceIfNeeded( + payload: payload, + pendingIndices: pendingIndices, + temporaryFolderURL: temporaryFolderURL, + existingPages: workingSeed.existingPages, + storedGalleryImageState: storedGalleryImageState + ) + let downloadContext = PageDownloadContext( + payload: payload, + source: source, + temporaryFolderURL: temporaryFolderURL, + storedGalleryImageState: storedGalleryImageState + ) + let batchResult = try await downloadPages( + context: downloadContext, + pendingPageIndices: pendingIndices, + existingManifest: workingSeed.manifest, + existingPageRelativePaths: workingSeed.existingPages + ) + let finalizeCtx = FinalizeContext( + versionSignature: versionSignature, + coverRelativePath: coverRelativePath, + batchResult: batchResult, + storedGalleryImageState: storedGalleryImageState, + existingDownload: existingDownload + ) + try await finalizeBatchResult( + context: finalizeCtx, + payload: payload, + temporaryFolderURL: temporaryFolderURL, + folderRelativePath: folderRelativePath + ) + return PerformDownloadResult( + coverRelativePath: coverRelativePath, + pages: batchResult.pages + ) + } + + private func downloadAndPersistCoverIfNeeded( + payload: DownloadRequestPayload, + temporaryFolderURL: URL, + existingCoverRelativePath: String?, + existingDownload: DownloadedGallery + ) async throws -> String? { + let coverRelativePath = try await downloadCoverImage( + payload: payload, + temporaryFolderURL: temporaryFolderURL, + existingCoverRelativePath: existingCoverRelativePath + ) + if coverRelativePath != existingDownload.coverRelativePath { + try? await updateDownloadRecord( + gid: payload.gallery.gid, + createIfMissing: false + ) { record in + record.coverRelativePath = coverRelativePath + } + } + return coverRelativePath + } + + private func finalizeBatchResult( + context: FinalizeContext, + payload: DownloadRequestPayload, + temporaryFolderURL: URL, + folderRelativePath: String + ) async throws { + if payload.pageSelection != nil { + try? storage.writeResumeState( + .init( + mode: payload.mode, + versionSignature: context.versionSignature, + pageCount: payload.galleryDetail.pageCount, + downloadOptions: payload.options + ), + folderURL: temporaryFolderURL + ) + } + if !context.batchResult.failedPages.isEmpty { + throw PartialDownloadError( + failedPages: context.batchResult.failedPages + ) + } + try finalizeDownload( + payload: payload, + temporaryFolderURL: temporaryFolderURL, + folderRelativePath: folderRelativePath, + finalizeContext: context + ) + } + + private func resolveSourceIfNeeded( + payload: DownloadRequestPayload, + pendingIndices: [Int], + temporaryFolderURL: URL, + existingPages: [Int: String], + storedGalleryImageState: CachedGalleryImageState? + ) async throws -> ResolvedSource? { + let canSatisfyFromCache = + await canSatisfyPendingPageDownloadsFromCache( + pendingPageIndices: pendingIndices, + temporaryFolderURL: temporaryFolderURL, + existingPageRelativePaths: existingPages, + storedGalleryImageState: storedGalleryImageState + ) + if pendingIndices.isEmpty || canSatisfyFromCache { + return nil + } + return try await resolveSource( + payload: payload, + requiredPageIndices: pendingIndices + ) + } + + private func finalizeDownload( + payload: DownloadRequestPayload, + temporaryFolderURL: URL, + folderRelativePath: String, + finalizeContext: FinalizeContext + ) throws { + let versionSignature = finalizeContext.versionSignature + let coverRelativePath = finalizeContext.coverRelativePath + let batchResult = finalizeContext.batchResult + let storedGalleryImageState = finalizeContext.storedGalleryImageState + let existingDownload = finalizeContext.existingDownload + let manifest = DownloadManifest( + gid: payload.gallery.gid, + host: payload.host, + token: payload.gallery.token, + title: payload.gallery.title, + jpnTitle: payload.galleryDetail.jpnTitle, + category: payload.gallery.category, + language: payload.galleryDetail.language, + uploader: payload.galleryDetail.uploader, + tags: payload.gallery.tags, + postedDate: payload.galleryDetail.postedDate, + pageCount: payload.galleryDetail.pageCount, + coverRelativePath: coverRelativePath, + galleryURL: + payload.gallery.galleryURL.forceUnwrapped, + rating: payload.galleryDetail.rating, + downloadOptions: payload.options, + versionSignature: versionSignature, + downloadedAt: .now, + pages: batchResult.pages + .sorted(by: { $0.index < $1.index }) + .map { + .init( + index: $0.index, + relativePath: $0.relativePath + ) + } + ) + try storage.writeManifest( + manifest, + folderURL: temporaryFolderURL + ) + try? storage.removeFailedPages( + folderURL: temporaryFolderURL + ) + try storage.replaceFolder( + relativePath: folderRelativePath, + with: temporaryFolderURL + ) + cleanupCachedRemoteAssetsAfterSuccessfulDownload( + payload: payload, + storedGalleryImageState: storedGalleryImageState, + pages: batchResult.pages, + existingDownload: existingDownload + ) + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift new file mode 100644 index 000000000..169250313 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -0,0 +1,333 @@ +// +// DownloadClient+ExecutionSupport.swift +// EhPanda +// + +import Foundation + +// MARK: - Execution Support +extension DownloadManager { + func downloadCoverImage( + payload: DownloadRequestPayload, + temporaryFolderURL: URL, + existingCoverRelativePath: String? + ) async throws -> String? { + if let coverRelativePath = existingCoverRelativePath, + !coverRelativePath.isEmpty { + let localCoverURL = temporaryFolderURL + .appendingPathComponent(coverRelativePath) + if fileManager() + .fileExists(atPath: localCoverURL.path) { + return coverRelativePath + } + } + guard let coverURL = + payload.galleryDetail.coverURL + ?? payload.gallery.coverURL + else { + return nil + } + if let cachedData = await validatedCachedAssetData( + for: [coverURL] + ) { + return try saveCoverFromCache( + cachedData: cachedData, + coverURL: coverURL, + temporaryFolderURL: temporaryFolderURL + ) + } + return try await downloadCoverFromNetwork( + coverURL: coverURL, + temporaryFolderURL: temporaryFolderURL, + allowsCellular: payload.options.allowCellular + ) + } + + private func saveCoverFromCache( + cachedData: Data, + coverURL: URL, + temporaryFolderURL: URL + ) throws -> String { + let ext = fileExtension( + for: coverURL, + response: nil, + prefixData: cachedData + ) + let relativePath = storage + .makeCoverRelativePath(fileExtension: ext) + let fileURL = temporaryFolderURL + .appendingPathComponent(relativePath) + try write(data: cachedData, to: fileURL) + return relativePath + } + + private func downloadCoverFromNetwork( + coverURL: URL, + temporaryFolderURL: URL, + allowsCellular: Bool + ) async throws -> String { + let (downloadedFileURL, response) = + try await downloadResponse( + url: coverURL, + allowsCellular: allowsCellular + ) + let prefixData = try readResponsePrefixData( + at: downloadedFileURL + ) + let ext = fileExtension( + for: coverURL, + response: response, + prefixData: prefixData + ) + let relativePath = storage + .makeCoverRelativePath(fileExtension: ext) + let fileURL = temporaryFolderURL + .appendingPathComponent(relativePath) + try moveDownloadedFile( + from: downloadedFileURL, + to: fileURL + ) + return relativePath + } + + func cleanupCachedRemoteAssetsAfterSuccessfulDownload( + payload: DownloadRequestPayload, + storedGalleryImageState: CachedGalleryImageState?, + pages: [PageResult], + existingDownload: DownloadedGallery + ) { + let previewURLs = ( + Array(payload.previewURLs.values) + + (storedGalleryImageState.map { + Array($0.previewURLs.values) + } ?? []) + ) + .flatMap { $0.previewCacheCleanupURLs() } + let pageURLs = pages.compactMap(\.imageURL) + + (storedGalleryImageState.map { + Array($0.imageURLs.values) + } ?? []) + let coverURLs = [ + payload.galleryDetail.coverURL, + payload.gallery.coverURL, + existingDownload.onlineCoverURL + ] + .compactMap(\.self) + + let urls = Array(Set(previewURLs + pageURLs + coverURLs)) + .map(Optional.some) + removeCachedImages(for: urls, includeStableAlias: true) + } + + func resolveSource( + payload: DownloadRequestPayload, + requiredPageIndices: [Int] + ) async throws -> ResolvedSource { + let requiredPageNumbers = Array( + Set(requiredPageIndices.map { + payload.previewConfig.pageNumber(index: $0) + }) + ) + .sorted() + var thumbnailURLs = [Int: URL]() + for pageNumber in requiredPageNumbers { + let pageURLs = try await fetchThumbnailURLs( + galleryURL: + payload.gallery.galleryURL.forceUnwrapped, + pageNum: pageNumber, + allowsCellular: payload.options.allowCellular + ) + thumbnailURLs + .merge(pageURLs, uniquingKeysWith: { _, new in new }) + } + guard let firstURL = requiredPageIndices.lazy + .compactMap({ thumbnailURLs[$0] }).first + ?? thumbnailURLs.values.first + else { + throw AppError.notFound + } + if firstURL.pathComponents.count > 1, + firstURL.pathComponents[1] == "mpv" { + let mpvResult = try await fetchMPVKeys( + mpvURL: firstURL, + allowsCellular: payload.options.allowCellular + ) + return .mpv(mpvResult.mpvKey, mpvResult.imageKeys) + } else { + return .normal(thumbnailURLs) + } + } + + func prepareWorkingSeed( + payload: DownloadRequestPayload, + existingDownload: DownloadedGallery, + temporaryFolderURL: URL, + versionSignature: String + ) throws -> WorkingSeed { + let localFileManager = fileManager() + let resumeState = try? storage + .readResumeState(folderURL: temporaryFolderURL) + let shouldReuseTemporaryFolder = resumeState?.matches( + mode: payload.mode, + versionSignature: versionSignature, + pageCount: payload.galleryDetail.pageCount, + downloadOptions: payload.options + ) == true + && localFileManager.fileExists(atPath: temporaryFolderURL.path) + + let seedContext = RepairSeedContext( + existingDownload: existingDownload, + payload: payload, + versionSignature: versionSignature + ) + try setupTemporaryFolder( + temporaryFolderURL: temporaryFolderURL, + shouldReuse: shouldReuseTemporaryFolder, + seedContext: seedContext, + localFileManager: localFileManager + ) + + let manifest = validatedManifest( + at: temporaryFolderURL, + gid: payload.gallery.gid, + pageCount: payload.galleryDetail.pageCount, + versionSignature: versionSignature, + downloadOptions: payload.options + ) + let existingPages = storage.existingPageRelativePaths( + folderURL: temporaryFolderURL, + expectedPageCount: payload.galleryDetail.pageCount + ) + let coverRelativePath = manifest?.coverRelativePath + ?? storage.existingCoverRelativePath( + folderURL: temporaryFolderURL + ) + return .init( + folderURL: temporaryFolderURL, + manifest: manifest, + existingPages: existingPages, + coverRelativePath: coverRelativePath + ) + } + + private struct RepairSeedContext { + let existingDownload: DownloadedGallery + let payload: DownloadRequestPayload + let versionSignature: String + } + + private func setupTemporaryFolder( + temporaryFolderURL: URL, + shouldReuse: Bool, + seedContext: RepairSeedContext, + localFileManager: FileManager + ) throws { + if !shouldReuse { + try? localFileManager.removeItem(at: temporaryFolderURL) + } + if !localFileManager.fileExists(atPath: temporaryFolderURL.path) { + if let seed = repairSeed( + for: seedContext.existingDownload, + payload: seedContext.payload, + versionSignature: seedContext.versionSignature + ) { + try storage.materializeRepairSeed( + from: seed.folderURL, + manifest: seed.manifest, + to: temporaryFolderURL + ) + } else { + try createDirectory(at: temporaryFolderURL) + } + } + let pagesFolderURL = temporaryFolderURL + .appendingPathComponent( + Defaults.FilePath.downloadPages, + isDirectory: true + ) + try createDirectory(at: pagesFolderURL) + } + + func resolvedImageSource( + index: Int, + payload: DownloadRequestPayload, + source: ResolvedSource, + retriesRequest: Bool + ) async throws -> ResolvedImageSource { + switch source { + case .normal(let thumbnailURLs): + guard let thumbnailURL = thumbnailURLs[index] else { + throw AppError.notFound + } + let doc = try await htmlDocument( + url: thumbnailURL, + allowsCellular: payload.options.allowCellular, + retriesRequest: retriesRequest + ) + let imageInfo = + try Parser.parseGalleryNormalImageURL( + doc: doc, + index: index + ) + return .init(imageURL: imageInfo.imageURL) + + case .mpv(let mpvKey, let imageKeys): + guard let imageKey = imageKeys[index] else { + throw AppError.notFound + } + let imageURL = try await fetchMPVImageURL( + payload: payload, + index: index, + mpvKey: mpvKey, + imageKey: imageKey, + retriesRequest: retriesRequest + ) + return .init(imageURL: imageURL) + } + } + + func repairSeed( + for download: DownloadedGallery, + payload: DownloadRequestPayload, + versionSignature: String + ) -> RepairSeed? { + guard payload.mode == .repair, + let folderURL = download + .resolvedFolderURL(rootURL: storage.rootURL), + fileManager() + .fileExists(atPath: folderURL.path), + let manifest = try? storage + .readManifest(folderURL: folderURL), + manifest.gid == download.gid, + manifest.pageCount == + payload.galleryDetail.pageCount, + manifest.pages.count == manifest.pageCount, + manifest.versionSignature == versionSignature + else { + return nil + } + return .init(folderURL: folderURL, manifest: manifest) + } + + func pendingPageIndices( + payload: DownloadRequestPayload, + folderURL: URL, + existingPageRelativePaths: [Int: String] + ) -> [Int] { + let selectedIndices = payload.pageSelection.map(Set.init) + return (1...payload.galleryDetail.pageCount).filter { index in + if let selectedIndices, + !selectedIndices.contains(index) { + return false + } + guard let relativePath = + existingPageRelativePaths[index] else { + return true + } + let fileURL = folderURL + .appendingPathComponent(relativePath) + return !fileManager() + .fileExists(atPath: fileURL.path) + } + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift new file mode 100644 index 000000000..c838c0242 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -0,0 +1,157 @@ +// +// DownloadClient+Manager.swift +// EhPanda +// + +import CoreData +import Foundation + +actor DownloadManager { + static let retryLimit = 3 + static let progressFlushPageInterval = 8 + static let progressFlushMinimumInterval: TimeInterval = 0.4 + static let responseInspectionPrefixLength = 4096 + static let kokomadeImageByteCount = 144844 + static let kokomadeImageSHA1 = "e48ed350e902a51581246d2a764fa7827e8e6988" + static let kokomadeImageURLSuffixes = [ + "exhentai.org/img/kokomade.jpg" + ] + static let quotaExceededImageByteCount = 28658 + static let quotaExceededImageSHA1 = "f54b887b017694dc25eb1a1404f71981885f8ed9" + static let quotaExceededImageURLSuffixes = [ + "exhentai.org/img/509.gif", + "ehgt.org/g/509.gif" + ] + + struct PageResult: Sendable { + let index: Int + let relativePath: String + let imageURL: URL? + } + + struct PageFailure: Error, Sendable { + let index: Int + let relativePath: String? + let error: AppError + } + + struct DownloadBatchResult: Sendable { + let pages: [PageResult] + let failedPages: [DownloadFailedPagesSnapshot.Page] + } + + enum PageTaskOutcome: Sendable { + case success(PageResult) + case failure(PageFailure) + case cancelled + } + + struct RepairSeed: Sendable { + let folderURL: URL + let manifest: DownloadManifest + } + + struct WorkingSeed: Sendable { + let folderURL: URL + let manifest: DownloadManifest? + let existingPages: [Int: String] + let coverRelativePath: String? + } + + enum ResolvedSource: Sendable { + case normal([Int: URL]) + case mpv(String, [Int: String]) + } + + struct ResolvedImageSource: Sendable { + let imageURL: URL + } + + struct CachedGalleryImageState: Sendable { + let previewURLs: [Int: URL] + let imageURLs: [Int: URL] + } + + struct PartialDownloadError: Error, Sendable { + let failedPages: [DownloadFailedPagesSnapshot.Page] + } + + struct FailureContext: Sendable { + let gid: String + let originalDownload: DownloadedGallery + let mode: DownloadStartMode + let hadReadableFiles: Bool + let latestSignature: String? + } + + struct PageDownloadContext: Sendable { + let payload: DownloadRequestPayload + let source: ResolvedSource? + let temporaryFolderURL: URL + let storedGalleryImageState: CachedGalleryImageState? + } + + struct CacheRestoreSource: Sendable { + let cacheURLs: [URL?] + let referenceURL: URL? + let imageURL: URL? + } + + struct CaptureTargetResult: Sendable { + let folderURL: URL + let preferredRelativePath: String? + let isTemporary: Bool + } + + struct PrepareWorkingSeedResult: Sendable { + let folderURL: URL + let manifest: DownloadManifest? + let existingPages: [Int: String] + let coverRelativePath: String? + } + + struct HTMLResponseContext { + let prefixData: Data + let fullData: Data? + let response: URLResponse + let requestURL: URL? + let mimeType: String? + } + + struct DownloadExecutionContext: Sendable { + let existingDownload: DownloadedGallery + let versionSignature: String + let folderRelativePath: String + } + + struct FinalizeContext: Sendable { + let versionSignature: String + let coverRelativePath: String? + let batchResult: DownloadBatchResult + let storedGalleryImageState: CachedGalleryImageState? + let existingDownload: DownloadedGallery + } + + let storage: DownloadFileStorage + let urlSession: URLSession + let persistenceContainer: NSPersistentContainer + var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() + var lastObservedDownloads = [DownloadedGallery]() + var activeGalleryID: String? + var activeTask: Task? + var schedulingBlockedGalleryIDs = Set() + + init( + storage: DownloadFileStorage, + urlSession: URLSession, + persistenceContainer: NSPersistentContainer = PersistenceController.shared.container + ) { + self.storage = storage + self.urlSession = urlSession + self.persistenceContainer = persistenceContainer + } + + func fileManager() -> FileManager { + storage.fileManager + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift new file mode 100644 index 000000000..acbb2a411 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift @@ -0,0 +1,396 @@ +// +// DownloadClient+Networking.swift +// EhPanda +// + +import Kanna +import Foundation + +// MARK: - HTML & Network +extension DownloadManager { + func htmlDocument( + url: URL, + allowsCellular: Bool, + retriesRequest: Bool = true + ) async throws -> HTMLDocument { + var request = URLRequest(url: url) + request.allowsCellularAccess = allowsCellular + let (data, response) = try await dataResponse( + for: request, + retriesRequest: retriesRequest + ) + if let error = detectResponseError( + data: data, + response: response, + requestURL: request.url, + expectsHTML: true + ) { + throw error + } + if let document = try? Kanna.HTML( + html: data, + encoding: .utf8 + ) { + return document + } + if let document = try? Kanna.HTML( + html: data.utf8InvalidCharactersRipped, + encoding: .utf8 + ) { + return document + } + throw AppError.parseFailed + } + + func downloadResponse( + url: URL, + allowsCellular: Bool, + retriesRequest: Bool = true + ) async throws -> (URL, URLResponse) { + var request = URLRequest(url: url) + request.allowsCellularAccess = allowsCellular + return try await downloadResponse( + for: request, + retriesRequest: retriesRequest + ) + } + + func downloadResponse( + for request: URLRequest, + retriesRequest: Bool = true + ) async throws -> (URL, URLResponse) { + let performRequest = { + try await self.rawDownloadResponse(for: request) + } + + let response: (URL, URLResponse) + if retriesRequest { + response = try await withRetry( + operation: "downloadResponse", + context: [ + "url": request.url?.absoluteString ?? "" + ] + ) { + try await performRequest() + } + } else { + response = try await performRequest() + } + + if let error = detectResponseError( + fileURL: response.0, + response: response.1, + requestURL: request.url + ) { + try? fileManager().removeItem(at: response.0) + throw error + } + + return response + } + + func dataResponse( + for request: URLRequest, + retriesRequest: Bool = true + ) async throws -> (Data, URLResponse) { + if retriesRequest { + return try await withRetry( + operation: "dataResponse", + context: [ + "url": request.url?.absoluteString ?? "" + ] + ) { + try await rawDataResponse(for: request) + } + } + return try await rawDataResponse(for: request) + } + + func rawDataResponse( + for request: URLRequest + ) async throws -> (Data, URLResponse) { + do { + return try await urlSession.data(for: request) + } catch let error as AppError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError + where error.code == .cancelled { + throw CancellationError() + } catch { + if Self.isCancellationLikeError(error) { + throw CancellationError() + } + if error is URLError { + throw AppError.networkingFailed + } + throw AppError.unknown + } + } + + func rawDownloadResponse( + for request: URLRequest + ) async throws -> (URL, URLResponse) { + do { + return try await urlSession.download(for: request) + } catch let error as AppError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError + where error.code == .cancelled { + throw CancellationError() + } catch { + if Self.isCancellationLikeError(error) { + throw CancellationError() + } + if error is URLError { + throw AppError.networkingFailed + } + throw AppError.unknown + } + } + + func withRetry( + operation: String, + context: [String: Any], + maxAttempts: Int = retryLimit, + body: () async throws -> T + ) async throws -> T { + var attempt = 1 + while true { + do { + return try await body() + } catch is CancellationError { + throw CancellationError() + } catch let error as AppError { + guard error.isRetryable, + attempt < maxAttempts else { + throw error + } + Logger.error( + "Download operation will retry.", + context: context.merging([ + "operation": operation, + "attempt": attempt, + "error": error.localizedDescription + ], uniquingKeysWith: { _, new in new }) + ) + attempt += 1 + } catch { + guard attempt < maxAttempts else { + throw error + } + Logger.error( + "Download operation will retry" + + " after unexpected error.", + context: context.merging([ + "operation": operation, + "attempt": attempt, + "error": error.localizedDescription + ], uniquingKeysWith: { _, new in new }) + ) + attempt += 1 + } + } + } + + func fetchThumbnailURLs( + galleryURL: URL, + pageNum: Int, + allowsCellular: Bool + ) async throws -> [Int: URL] { + let detailPageURL = URLUtil.detailPage( + url: galleryURL, + pageNum: pageNum + ) + let urls = try await withRetry( + operation: "fetchThumbnailURLs", + context: [ + "galleryURL": galleryURL.absoluteString, + "detailPageURL": detailPageURL.absoluteString, + "pageNum": pageNum + ] + ) { + let doc = try await htmlDocument( + url: detailPageURL, + allowsCellular: allowsCellular, + retriesRequest: false + ) + return try Parser.parseThumbnailURLs(doc: doc) + } + guard !urls.isEmpty else { throw AppError.notFound } + return urls + } + + struct MPVKeysResult: Sendable { + let mpvKey: String + let imageKeys: [Int: String] + } + + func fetchMPVKeys( + mpvURL: URL, + allowsCellular: Bool + ) async throws -> MPVKeysResult { + let (mpvKey, imageKeys) = try await withRetry( + operation: "fetchMPVKeys", + context: [ + "mpvURL": mpvURL.absoluteString + ] + ) { + let doc = try await htmlDocument( + url: mpvURL, + allowsCellular: allowsCellular, + retriesRequest: false + ) + return try Parser.parseMPVKeys(doc: doc) + } + return MPVKeysResult( + mpvKey: mpvKey, + imageKeys: imageKeys + ) + } + + func fetchMPVImageURL( + payload: DownloadRequestPayload, + index: Int, + mpvKey: String, + imageKey: String, + retriesRequest: Bool = true + ) async throws -> URL { + guard let gidInteger = Int(payload.gallery.gid) else { + throw AppError.notFound + } + let params: [String: Any] = [ + "method": "imagedispatch", + "gid": gidInteger, + "page": index, + "imgkey": imageKey, + "mpvkey": mpvKey + ] + + var request = URLRequest( + url: payload.host.url + .appendingPathComponent("api.php") + ) + request.httpMethod = "POST" + request.httpBody = try JSONSerialization + .data(withJSONObject: params) + request.allowsCellularAccess = + payload.options.allowCellular + + let (data, response) = try await dataResponse( + for: request, + retriesRequest: retriesRequest + ) + if let error = detectResponseError( + data: data, + response: response, + requestURL: request.url + ) { + throw error + } + guard let dictionary = try JSONSerialization + .jsonObject(with: data) as? [String: Any], + let imageURLString = dictionary["i"] as? String, + let imageURL = URL(string: imageURLString) + else { + throw AppError.parseFailed + } + return imageURL + } +} + +// MARK: - File Operations +extension DownloadManager { + func fileExtension( + for url: URL, + response: URLResponse?, + prefixData: Data + ) -> String { + if url.pathExtension.notEmpty { + return url.pathExtension.lowercased() + } + if let ext = extensionFromMimeType(response) { + return ext + } + return extensionFromMagicBytes(prefixData) ?? "jpg" + } + + private func extensionFromMimeType( + _ response: URLResponse? + ) -> String? { + guard let mimeType = response?.mimeType?.lowercased() + else { + return nil + } + switch mimeType { + case "image/jpeg": + return "jpg" + case "image/png": + return "png" + case "image/gif": + return "gif" + case "image/webp": + return "webp" + default: + return nil + } + } + + private func extensionFromMagicBytes( + _ prefixData: Data + ) -> String? { + if prefixData.starts(with: [0x47, 0x49, 0x46]) { + return "gif" + } + if prefixData.starts(with: [0x89, 0x50, 0x4E, 0x47]) { + return "png" + } + if prefixData.starts(with: [0x52, 0x49, 0x46, 0x46]), + prefixData.count >= 12, + String( + bytes: prefixData[8..<12], + encoding: .utf8 + ) == "WEBP" { + return "webp" + } + return nil + } + + func createDirectory(at url: URL) throws { + try fileManager().createDirectory( + at: url, + withIntermediateDirectories: true + ) + } + + func write(data: Data, to url: URL) throws { + try createDirectory(at: url.deletingLastPathComponent()) + try data.write(to: url, options: .atomic) + } + + func moveDownloadedFile( + from sourceURL: URL, + to destinationURL: URL + ) throws { + try createDirectory( + at: destinationURL.deletingLastPathComponent() + ) + if fileManager() + .fileExists(atPath: destinationURL.path) { + try fileManager().removeItem(at: destinationURL) + } + try fileManager() + .moveItem(at: sourceURL, to: destinationURL) + } + + func readResponsePrefixData(at fileURL: URL) throws -> Data { + let handle = try FileHandle(forReadingFrom: fileURL) + defer { try? handle.close() } + return try handle.read( + upToCount: Self.responseInspectionPrefixLength + ) ?? Data() + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift new file mode 100644 index 000000000..e45df0c77 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -0,0 +1,356 @@ +// +// DownloadClient+PageDownload.swift +// EhPanda +// + +import Foundation + +// MARK: - Download Pages +extension DownloadManager { + private struct PageDownloadProgress { + var results: [PageResult] = [] + var failedPages: [Int: DownloadFailedPagesSnapshot.Page?] = [:] + var completedCount: Int = 0 + var pendingResolvedPages: [PageResult] = [] + var lastFlushDate: Date = Date() + } + + func downloadPages( + context: PageDownloadContext, + pendingPageIndices: [Int], + existingManifest: DownloadManifest?, + existingPageRelativePaths: [Int: String] + ) async throws -> DownloadBatchResult { + let existingPages = buildExistingPages( + existingManifest: existingManifest, + existingPageRelativePaths: existingPageRelativePaths + ) + var progress = PageDownloadProgress() + progress.failedPages = (try? storage + .readFailedPages( + folderURL: context.temporaryFolderURL + ).map) ?? [:] + + try await initializePageDownloadState( + context: context, + existingPages: existingPages, + progress: &progress + ) + + try await restoreAndFlushCachedPages( + context: context, + pendingPageIndices: pendingPageIndices, + existingPages: existingPages, + progress: &progress + ) + + let restoredIndices = Set( + progress.results + .prefix(progress.completedCount) + .map(\.index) + ) + let remainingPageIndices = pendingPageIndices + .filter { !restoredIndices.contains($0) } + var wasCancelled = false + await processRemainingPages( + context: context, + remainingPageIndices: remainingPageIndices, + existingPages: existingPages, + progress: &progress, + wasCancelled: &wasCancelled + ) + + if wasCancelled || Task.isCancelled { + throw CancellationError() + } + try await flushDownloadProgress( + gid: context.payload.gallery.gid, + pendingResolvedPages: &progress.pendingResolvedPages, + completedCount: progress.completedCount, + lastFlushDate: &progress.lastFlushDate, + force: true + ) + return try buildBatchResult( + results: progress.results, + failedPages: progress.failedPages, + temporaryFolderURL: context.temporaryFolderURL + ) + } + + private func initializePageDownloadState( + context: PageDownloadContext, + existingPages: [Int: String], + progress: inout PageDownloadProgress + ) async throws { + let payload = context.payload + let pageIndices = Array(1...payload.galleryDetail.pageCount) + collectExistingPages( + pageIndices: pageIndices, + existingPages: existingPages, + context: context, + results: &progress.results, + failedPages: &progress.failedPages + ) + progress.completedCount = progress.results.count + guard progress.completedCount > 0 else { return } + let completedCount = progress.completedCount + try await updateDownloadRecord( + gid: payload.gallery.gid, + createIfMissing: false + ) { record in + record.completedPageCount = Int64(completedCount) + } + await notifyObservers() + } + + private func restoreAndFlushCachedPages( + context: PageDownloadContext, + pendingPageIndices: [Int], + existingPages: [Int: String], + progress: inout PageDownloadProgress + ) async throws { + let payload = context.payload + let restoredCachedPages = + try await restorePendingPagesFromStoredCache( + indices: pendingPageIndices, + temporaryFolderURL: context.temporaryFolderURL, + existingPages: existingPages, + storedGalleryImageState: + context.storedGalleryImageState + ) + guard !restoredCachedPages.isEmpty else { return } + restoredCachedPages.forEach { + progress.failedPages[$0.index] = nil + progress.results.append($0) + } + progress.completedCount += restoredCachedPages.count + progress.pendingResolvedPages + .append(contentsOf: restoredCachedPages) + try await flushDownloadProgress( + gid: payload.gallery.gid, + pendingResolvedPages: &progress.pendingResolvedPages, + completedCount: progress.completedCount, + lastFlushDate: &progress.lastFlushDate, + force: true + ) + } + + private func buildBatchResult( + results: [PageResult], + failedPages: [Int: DownloadFailedPagesSnapshot.Page?], + temporaryFolderURL: URL + ) throws -> DownloadBatchResult { + let failedSnapshot = DownloadFailedPagesSnapshot( + pages: failedPages.values + .compactMap { $0 } + .filter { + !isCancellationLikeAppError($0.failure.appError) + } + .sorted(by: { $0.index < $1.index }) + ) + if failedSnapshot.pages.isEmpty { + try? storage.removeFailedPages( + folderURL: temporaryFolderURL + ) + } else { + try storage.writeFailedPages( + failedSnapshot, + folderURL: temporaryFolderURL + ) + } + return .init( + pages: results, + failedPages: failedSnapshot.pages + ) + } + + private func buildExistingPages( + existingManifest: DownloadManifest?, + existingPageRelativePaths: [Int: String] + ) -> [Int: String] { + let manifestPages = Dictionary( + uniqueKeysWithValues: + (existingManifest?.pages ?? []) + .map { ($0.index, $0.relativePath) } + ) + return manifestPages.merging( + existingPageRelativePaths, + uniquingKeysWith: { manifestPath, _ in manifestPath } + ) + } + + private func collectExistingPages( + pageIndices: [Int], + existingPages: [Int: String], + context: PageDownloadContext, + results: inout [PageResult], + failedPages: inout [Int: DownloadFailedPagesSnapshot.Page?] + ) { + for index in pageIndices { + guard let relativePath = existingPages[index] else { + continue + } + let fileURL = context.temporaryFolderURL + .appendingPathComponent(relativePath) + guard fileManager() + .fileExists(atPath: fileURL.path) else { + continue + } + failedPages[index] = nil + results.append( + .init( + index: index, + relativePath: relativePath, + imageURL: context.storedGalleryImageState? + .imageURLs[index] + ) + ) + } + } + + private func processRemainingPages( + context: PageDownloadContext, + remainingPageIndices: [Int], + existingPages: [Int: String], + progress: inout PageDownloadProgress, + wasCancelled: inout Bool + ) async { + let payload = context.payload + await withTaskGroup(of: PageTaskOutcome.self) { group in + var pendingIterator = + remainingPageIndices.makeIterator() + seedInitialPageTasks( + to: &group, + iterator: &pendingIterator, + context: context, + pageCount: remainingPageIndices.count, + existingPages: existingPages + ) + while let outcome = await group.next() { + if wasCancelled || Task.isCancelled + || schedulingBlockedGalleryIDs + .contains(payload.gallery.gid) { + wasCancelled = true + group.cancelAll() + continue + } + applyPageTaskOutcome( + outcome, + progress: &progress, + wasCancelled: &wasCancelled, + group: &group + ) + guard !wasCancelled else { continue } + try? await flushDownloadProgress( + gid: payload.gallery.gid, + pendingResolvedPages: + &progress.pendingResolvedPages, + completedCount: progress.completedCount, + lastFlushDate: &progress.lastFlushDate, + force: false + ) + if let nextIndex = pendingIterator.next() { + addPageDownloadTask( + to: &group, + index: nextIndex, + context: context, + existingPages: existingPages + ) + } + } + } + } + + private func seedInitialPageTasks( + to group: inout TaskGroup, + iterator: inout IndexingIterator<[Int]>, + context: PageDownloadContext, + pageCount: Int, + existingPages: [Int: String] + ) { + let workerCount = context.payload.options.workerCount + for _ in 0.. + ) { + switch outcome { + case .success(let pageResult): + progress.completedCount += 1 + progress.failedPages[pageResult.index] = nil + progress.results.append(pageResult) + progress.pendingResolvedPages.append(pageResult) + + case .failure(let failure): + if isCancellationLikeAppError(failure.error) { + wasCancelled = true + group.cancelAll() + return + } + progress.failedPages[failure.index] = .init( + index: failure.index, + relativePath: failure.relativePath, + failure: .init(error: failure.error) + ) + + case .cancelled: + wasCancelled = true + group.cancelAll() + } + } + + private func addPageDownloadTask( + to group: inout TaskGroup, + index: Int, + context: PageDownloadContext, + existingPages: [Int: String] + ) { + group.addTask { + do { + return .success( + try await self.downloadPage( + index: index, + context: context, + preferredRelativePath: + existingPages[index] + ) + ) + } catch is CancellationError { + return .cancelled + } catch let error as AppError { + return .failure( + .init( + index: index, + relativePath: existingPages[index], + error: error + ) + ) + } catch { + if Self.isCancellationLikeError(error) { + return .cancelled + } + return .failure( + .init( + index: index, + relativePath: existingPages[index], + error: .fileOperationFailed( + error.localizedDescription + ) + ) + ) + } + } + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift new file mode 100644 index 000000000..5ab926ba0 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -0,0 +1,182 @@ +// +// DownloadClient+PageDownloadHelpers.swift +// EhPanda +// + +import Foundation + +// MARK: - Download Single Page +extension DownloadManager { + func downloadPage( + index: Int, + context: PageDownloadContext, + preferredRelativePath: String? + ) async throws -> PageResult { + let payload = context.payload + let attempts = payload.options.autoRetryFailedPages ? 2 : 1 + var capturedError: AppError = .unknown + + for _ in 0.. PageResult { + let payload = context.payload + let temporaryFolderURL = context.temporaryFolderURL + let storedGalleryImageState = context.storedGalleryImageState + + if let result = try await attemptCacheRestore( + index: index, + storedGalleryImageState: storedGalleryImageState, + temporaryFolderURL: temporaryFolderURL, + preferredRelativePath: preferredRelativePath + ) { + return result + } + guard let source = context.source else { + throw AppError.notFound + } + let resolved = try await resolvedImageSource( + index: index, + payload: payload, + source: source, + retriesRequest: false + ) + if let result = try await attemptResolvedCacheRestore( + index: index, + resolvedImageSource: resolved, + storedGalleryImageState: storedGalleryImageState, + temporaryFolderURL: temporaryFolderURL, + preferredRelativePath: preferredRelativePath + ) { + return result + } + return try await downloadAndSavePage( + index: index, + resolvedImageSource: resolved, + payload: payload, + temporaryFolderURL: temporaryFolderURL, + preferredRelativePath: preferredRelativePath + ) + } + + private func attemptCacheRestore( + index: Int, + storedGalleryImageState: CachedGalleryImageState?, + temporaryFolderURL: URL, + preferredRelativePath: String? + ) async throws -> PageResult? { + let storedCacheURLs = pageImageCacheURLs( + resolvedImageSource: nil, + index: index, + storedGalleryImageState: storedGalleryImageState + ) + let storedSource = CacheRestoreSource( + cacheURLs: storedCacheURLs, + referenceURL: storedCacheURLs + .compactMap(\.self).first, + imageURL: storedGalleryImageState? + .imageURLs[index] + ) + return try await restorePageFromCache( + index: index, + source: storedSource, + folderURL: temporaryFolderURL, + preferredRelativePath: preferredRelativePath + ) + } + + private func attemptResolvedCacheRestore( + index: Int, + resolvedImageSource: ResolvedImageSource, + storedGalleryImageState: CachedGalleryImageState?, + temporaryFolderURL: URL, + preferredRelativePath: String? + ) async throws -> PageResult? { + let resolvedCacheURLs = pageImageCacheURLs( + resolvedImageSource: resolvedImageSource, + index: index, + storedGalleryImageState: storedGalleryImageState + ) + let resolvedSource = CacheRestoreSource( + cacheURLs: resolvedCacheURLs, + referenceURL: preferredPageReferenceURL( + resolvedImageSource: resolvedImageSource + ), + imageURL: resolvedImageSource.imageURL + ) + return try await restorePageFromCache( + index: index, + source: resolvedSource, + folderURL: temporaryFolderURL, + preferredRelativePath: preferredRelativePath + ) + } + + private func downloadAndSavePage( + index: Int, + resolvedImageSource: ResolvedImageSource, + payload: DownloadRequestPayload, + temporaryFolderURL: URL, + preferredRelativePath: String? + ) async throws -> PageResult { + let targetURL = resolvedImageSource.imageURL + let (downloadedFileURL, response) = + try await downloadResponse( + url: targetURL, + allowsCellular: payload.options.allowCellular, + retriesRequest: false + ) + let relativePath: String + if let preferredRelativePath { + relativePath = preferredRelativePath + } else { + let prefixData = try readResponsePrefixData( + at: downloadedFileURL + ) + let ext = fileExtension( + for: targetURL, + response: response, + prefixData: prefixData + ) + relativePath = storage.makePageRelativePath( + index: index, + fileExtension: ext + ) + } + let fileURL = temporaryFolderURL + .appendingPathComponent(relativePath) + try moveDownloadedFile( + from: downloadedFileURL, + to: fileURL + ) + return .init( + index: index, + relativePath: relativePath, + imageURL: resolvedImageSource.imageURL + ) + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift new file mode 100644 index 000000000..16ebe5926 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -0,0 +1,388 @@ +// +// DownloadClient+Persistence.swift +// EhPanda +// + +import CoreData +import Foundation + +// MARK: - Core Data Operations +extension DownloadManager { + func fetchDownload( + gid: String + ) async -> DownloadedGallery? { + await MainActor.run { + let context = persistenceContainer.viewContext + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate( + format: "gid == %@", + gid + ) + return try? context.fetch(request).first?.toEntity() + } + } + + func fetchDownloadsFromStore() async -> [DownloadedGallery] { + await MainActor.run { + let context = persistenceContainer.viewContext + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.sortDescriptors = [ + NSSortDescriptor( + keyPath: \DownloadedGalleryMO + .lastDownloadedAt, + ascending: false + ) + ] + let objects = (try? context.fetch(request)) ?? [] + return objects.map { $0.toEntity() } + } + } + + func fetchDownloadsFromStore( + gids: [String] + ) async -> [DownloadedGallery] { + await MainActor.run { + let context = persistenceContainer.viewContext + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.predicate = NSPredicate( + format: "gid IN %@", + gids + ) + request.sortDescriptors = [ + NSSortDescriptor( + keyPath: \DownloadedGalleryMO + .lastDownloadedAt, + ascending: false + ) + ] + let objects = (try? context.fetch(request)) ?? [] + return objects.map { $0.toEntity() } + } + } + + func updateDownloadRecord( + gid: String, + createIfMissing: Bool = true, + update: @escaping (DownloadedGalleryMO) -> Void + ) async throws { + try await MainActor.run { + let context = persistenceContainer.viewContext + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate( + format: "gid == %@", + gid + ) + + let object: DownloadedGalleryMO + if let storedObject = + try context.fetch(request).first { + object = storedObject + } else if !createIfMissing { + return + } else { + object = DownloadedGalleryMO(context: context) + object.gid = gid + object.host = GalleryHost.ehentai.rawValue + object.token = "" + object.title = "" + object.category = + Category.private.rawValue + object.pageCount = 0 + object.postedDate = .now + object.rating = 0 + object.folderRelativePath = gid + object.status = + DownloadStatus.queued.rawValue + object.remoteVersionSignature = "" + object.completedPageCount = 0 + } + + update(object) + guard context.hasChanges else { return } + do { + try context.save() + } catch { + throw AppError.databaseCorrupted( + error.localizedDescription + ) + } + } + } + + func deleteDownloadRecord(gid: String) async throws { + try await MainActor.run { + let context = persistenceContainer.viewContext + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate( + format: "gid == %@", + gid + ) + guard let object = + try context.fetch(request).first else { + return + } + context.delete(object) + guard context.hasChanges else { return } + do { + try context.save() + } catch { + throw AppError.databaseCorrupted( + error.localizedDescription + ) + } + } + } +} + +// MARK: - Persist Failure & Progress +extension DownloadManager { + func persistFailure( + error: AppError, + context: FailureContext + ) async { + let workingCompletedPageCount = + temporaryCompletedPageCount( + gid: context.gid, + expectedPageCount: + context.originalDownload.pageCount + ) + let hasTemporaryWorkingSet = storage + .temporaryFolderExists(gid: context.gid) + let recoveredCompletedPageCount = + hasTemporaryWorkingSet + ? workingCompletedPageCount + : max( + context.originalDownload + .completedPageCount, + workingCompletedPageCount + ) + do { + try await updateDownloadRecord( + gid: context.gid, + createIfMissing: false + ) { record in + record.lastError = + DownloadFailure(error: error).toData() + record.pendingOperation = nil + self.applyFailureStatus( + to: record, + context: context, + workingCompletedPageCount: + workingCompletedPageCount, + recoveredCompletedPageCount: + recoveredCompletedPageCount + ) + } + } catch { + Logger.error(error) + } + } + + private func applyFailureStatus( + to record: DownloadedGalleryMO, + context: FailureContext, + workingCompletedPageCount: Int, + recoveredCompletedPageCount: Int + ) { + if context.mode == .repair { + applyRepairFailureStatus(to: record, context: context) + } else if context.hadReadableFiles, + [.update, .redownload].contains(context.mode) { + applyFallbackFailureStatus(to: record, context: context) + } else if workingCompletedPageCount > 0 { + record.status = DownloadStatus.partial.rawValue + record.completedPageCount = Int64(workingCompletedPageCount) + record.latestRemoteVersionSignature = + context.latestSignature + ?? context.originalDownload.latestRemoteVersionSignature + } else { + record.status = DownloadStatus.partial.rawValue + record.completedPageCount = Int64(recoveredCompletedPageCount) + record.latestRemoteVersionSignature = + context.latestSignature + ?? context.originalDownload.latestRemoteVersionSignature + } + } + + private func applyRepairFailureStatus( + to record: DownloadedGalleryMO, + context: FailureContext + ) { + record.status = DownloadStatus.missingFiles.rawValue + record.completedPageCount = Int64( + context.originalDownload.completedPageCount + ) + record.folderRelativePath = + context.originalDownload.folderRelativePath + record.coverRelativePath = + context.originalDownload.coverRelativePath + record.remoteVersionSignature = + context.originalDownload.remoteVersionSignature + record.latestRemoteVersionSignature = + context.latestSignature + ?? context.originalDownload.latestRemoteVersionSignature + } + + private func applyFallbackFailureStatus( + to record: DownloadedGalleryMO, + context: FailureContext + ) { + record.status = self.fallbackStatus( + for: context.originalDownload, + mode: context.mode, + latestSignature: context.latestSignature + ).rawValue + record.completedPageCount = Int64( + context.originalDownload.pageCount + ) + record.folderRelativePath = + context.originalDownload.folderRelativePath + record.coverRelativePath = + context.originalDownload.coverRelativePath + record.remoteVersionSignature = + context.originalDownload.remoteVersionSignature + record.latestRemoteVersionSignature = + context.latestSignature + ?? context.originalDownload.latestRemoteVersionSignature + } + + func flushDownloadProgress( + gid: String, + pendingResolvedPages: inout [PageResult], + completedCount: Int, + lastFlushDate: inout Date, + force: Bool + ) async throws { + let shouldFlush = force + || pendingResolvedPages.count + >= Self.progressFlushPageInterval + || Date().timeIntervalSince(lastFlushDate) + >= Self.progressFlushMinimumInterval + guard shouldFlush else { return } + + let resolvedPages = pendingResolvedPages + pendingResolvedPages + .removeAll(keepingCapacity: true) + await persistResolvedImageURLs( + gid: gid, + entries: resolvedPages + ) + try await updateDownloadRecord( + gid: gid, + createIfMissing: false + ) { record in + record.completedPageCount = + Int64(completedCount) + } + lastFlushDate = Date() + await notifyObservers() + } + + func persistResolvedImageURLs( + gid: String, + index: Int, + imageURL: URL? + ) async { + await persistResolvedImageURLs( + gid: gid, + entries: [ + .init( + index: index, + relativePath: "", + imageURL: imageURL + ) + ] + ) + } + + func persistResolvedImageURLs( + gid: String, + entries: [PageResult] + ) async { + guard gid.isValidGID else { return } + let validEntries = entries + .filter { $0.imageURL != nil } + guard !validEntries.isEmpty else { return } + + await MainActor.run { + let context = persistenceContainer.viewContext + let request = NSFetchRequest( + entityName: "GalleryStateMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate( + format: "gid == %@", + gid + ) + + let object: GalleryStateMO + if let stored = + try? context.fetch(request).first { + object = stored + } else { + object = GalleryStateMO(context: context) + object.gid = gid + } + + var imageURLs = (object.imageURLs?.toObject() + as [Int: URL]?) ?? [:] + var hasChanges = false + + for entry in validEntries { + if let imageURL = entry.imageURL, + imageURLs[entry.index] != imageURL { + imageURLs[entry.index] = imageURL + hasChanges = true + } + } + + guard hasChanges else { + return + } + + object.imageURLs = imageURLs.toData() + + guard context.hasChanges else { return } + try? context.save() + } + } + + func fetchCachedGalleryImageState( + gid: String + ) async -> CachedGalleryImageState? { + await MainActor.run { + guard gid.isValidGID else { return nil } + let context = persistenceContainer.viewContext + let request = NSFetchRequest( + entityName: "GalleryStateMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate( + format: "gid == %@", + gid + ) + guard let object = + try? context.fetch(request).first else { + return nil + } + let state = object.toEntity() + return .init( + previewURLs: state.previewURLs, + imageURLs: state.imageURLs + ) + } + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift new file mode 100644 index 000000000..322518ffc --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -0,0 +1,302 @@ +// +// DownloadClient+PersistenceHelpers.swift +// EhPanda +// + +import CoreData +import Foundation + +// MARK: - Validation & Sanitization +extension DownloadManager { + func temporaryCompletedPageCount( + gid: String, + expectedPageCount: Int + ) -> Int { + let folderURL = storage.temporaryFolderURL(gid: gid) + guard fileManager() + .fileExists(atPath: folderURL.path) else { + return 0 + } + return storage.existingPageRelativePaths( + folderURL: folderURL, + expectedPageCount: expectedPageCount + ) + .count + } + + func validatedCompletedPageCount( + _ download: DownloadedGallery + ) -> Int { + guard let folderURL = download + .resolvedFolderURL(rootURL: storage.rootURL), + fileManager() + .fileExists(atPath: folderURL.path) + else { + return 0 + } + + guard let manifest = try? storage + .readManifest(folderURL: folderURL) else { + return storage.existingPageRelativePaths( + folderURL: folderURL, + expectedPageCount: download.pageCount + ) + .count + } + + return storage.validPageCount( + folderURL: folderURL, + manifest: manifest + ) + } + + @discardableResult + func sanitizeLocalFilesIfNeeded( + gid: String, + clearingLastError: Bool = false + ) async -> DownloadedGallery? { + guard let download = await fetchDownload(gid: gid) + else { return nil } + + let (hasTemporaryFolder, temporaryCompletedCount) = + scanTemporaryFolder(gid: gid, download: download) + scanCompletedFolder(download: download) + + let updateResult = computeSanitizeUpdate( + download: download, + hasTemporaryFolder: hasTemporaryFolder, + temporaryCompletedCount: temporaryCompletedCount, + clearingLastError: clearingLastError + ) + + guard updateResult.needsUpdate else { return download } + + do { + try await updateDownloadRecord( + gid: gid, + createIfMissing: false + ) { record in + record.status = updateResult.status.rawValue + record.completedPageCount = + Int64(updateResult.completedPageCount) + record.lastError = + updateResult.lastError?.toData() + } + await notifyObservers() + } catch { + Logger.error(error) + } + + return await fetchDownload(gid: gid) + } + + private func scanTemporaryFolder( + gid: String, + download: DownloadedGallery + ) -> (hasTemporaryFolder: Bool, temporaryCompletedCount: Int) { + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let hasTemporaryFolder = fileManager() + .fileExists(atPath: temporaryFolderURL.path) + let temporaryCompletedCount = hasTemporaryFolder + ? storage.existingPageRelativePaths( + folderURL: temporaryFolderURL, + expectedPageCount: download.pageCount + ).count + : 0 + if hasTemporaryFolder { + _ = storage.existingCoverRelativePath( + folderURL: temporaryFolderURL + ) + } + return (hasTemporaryFolder, temporaryCompletedCount) + } + + private func scanCompletedFolder(download: DownloadedGallery) { + guard let completedFolderURL = download + .resolvedFolderURL(rootURL: storage.rootURL), + fileManager().fileExists(atPath: completedFolderURL.path) + else { return } + _ = storage.existingPageRelativePaths( + folderURL: completedFolderURL, + expectedPageCount: download.pageCount + ) + _ = storage.existingCoverRelativePath( + folderURL: completedFolderURL + ) + } + + private struct SanitizeUpdateResult { + let needsUpdate: Bool + let status: DownloadStatus + let completedPageCount: Int + let lastError: DownloadFailure? + } + + private struct MutableSanitizeState { + var status: DownloadStatus + var completedPageCount: Int + var lastError: DownloadFailure? + var needsUpdate: Bool + } + + private func computeSanitizeUpdate( + download: DownloadedGallery, + hasTemporaryFolder: Bool, + temporaryCompletedCount: Int, + clearingLastError: Bool + ) -> SanitizeUpdateResult { + var state = MutableSanitizeState( + status: download.status, + completedPageCount: download.completedPageCount, + lastError: download.lastError, + needsUpdate: false + ) + applyTemporaryFolderUpdate( + download: download, + hasTemporaryFolder: hasTemporaryFolder, + temporaryCompletedCount: temporaryCompletedCount, + state: &state + ) + applyCompletedStatusUpdate( + download: download, + clearingLastError: clearingLastError, + state: &state + ) + return SanitizeUpdateResult( + needsUpdate: state.needsUpdate, + status: state.status, + completedPageCount: state.completedPageCount, + lastError: state.lastError + ) + } + + private func applyTemporaryFolderUpdate( + download: DownloadedGallery, + hasTemporaryFolder: Bool, + temporaryCompletedCount: Int, + state: inout MutableSanitizeState + ) { + guard hasTemporaryFolder, + shouldExposeTemporaryWorkingSet(for: download) + else { return } + + if state.completedPageCount != temporaryCompletedCount { + state.completedPageCount = temporaryCompletedCount + state.needsUpdate = true + } + if download.status == .failed { + state.status = .partial + state.needsUpdate = true + } + } + + private func applyCompletedStatusUpdate( + download: DownloadedGallery, + clearingLastError: Bool, + state: inout MutableSanitizeState + ) { + if [.completed, .updateAvailable, .missingFiles] + .contains(download.status) { + let validation = storage + .validate(download: download) + let completedPageCount = + validatedCompletedPageCount(download) + switch validation { + case .valid: + let expectedStatus: DownloadStatus = + download.hasUpdate + ? .updateAvailable : .completed + if state.status != expectedStatus { + state.status = expectedStatus + state.needsUpdate = true + } + if state.completedPageCount != completedPageCount { + state.completedPageCount = completedPageCount + state.needsUpdate = true + } + if clearingLastError || state.lastError != nil { + state.lastError = nil + state.needsUpdate = true + } + + case .missingFiles(let message): + if state.status != .missingFiles { + state.status = .missingFiles + state.needsUpdate = true + } + if state.completedPageCount != completedPageCount { + state.completedPageCount = completedPageCount + state.needsUpdate = true + } + let failure = DownloadFailure( + code: .fileOperationFailed, + message: message + ) + if state.lastError != failure { + state.lastError = failure + state.needsUpdate = true + } + } + } else if clearingLastError, state.lastError != nil { + state.lastError = nil + state.needsUpdate = true + } + } + + func captureTarget( + for download: DownloadedGallery, + index: Int + ) -> CaptureTargetResult? { + let temporaryFolderURL = storage + .temporaryFolderURL(gid: download.gid) + if shouldExposeTemporaryWorkingSet(for: download), + fileManager() + .fileExists(atPath: temporaryFolderURL.path) { + let temporaryPages = + storage.existingPageRelativePaths( + folderURL: temporaryFolderURL, + expectedPageCount: download.pageCount + ) + let manifestRelativePath = (try? storage + .readManifest( + folderURL: temporaryFolderURL + ))? + .pages + .first(where: { $0.index == index })? + .relativePath + let preferredRelativePath = temporaryPages[index] + ?? manifestRelativePath + return CaptureTargetResult( + folderURL: temporaryFolderURL, + preferredRelativePath: preferredRelativePath, + isTemporary: true + ) + } + + guard let completedFolderURL = download + .resolvedFolderURL(rootURL: storage.rootURL), + fileManager() + .fileExists(atPath: completedFolderURL.path) + else { + return nil + } + + let completedPages = + storage.existingPageRelativePaths( + folderURL: completedFolderURL, + expectedPageCount: download.pageCount + ) + let manifestRelativePath = (try? storage + .readManifest(folderURL: completedFolderURL))? + .pages + .first(where: { $0.index == index })? + .relativePath + let preferredRelativePath = completedPages[index] + ?? manifestRelativePath + return CaptureTargetResult( + folderURL: completedFolderURL, + preferredRelativePath: preferredRelativePath, + isTemporary: false + ) + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift new file mode 100644 index 000000000..2bf3eec3c --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -0,0 +1,224 @@ +// +// DownloadClient+PersistenceNormalize.swift +// EhPanda +// + +import Foundation + +// MARK: - Manifest, Folder & Normalize +extension DownloadManager { + func validatedManifest( + at folderURL: URL, + gid: String, + pageCount: Int, + versionSignature: String, + downloadOptions: DownloadOptionsSnapshot + ) -> DownloadManifest? { + guard let manifest = try? storage + .readManifest(folderURL: folderURL), + manifest.gid == gid, + manifest.pageCount == pageCount, + manifest.pages.count == pageCount, + manifest.versionSignature == versionSignature, + manifest.downloadOptions == downloadOptions + else { + return nil + } + return manifest + } + + func activeInspectionFolderURL( + for download: DownloadedGallery + ) -> URL? { + let temporaryFolderURL = storage + .temporaryFolderURL(gid: download.gid) + let completedFolderURL = download + .resolvedFolderURL(rootURL: storage.rootURL) + let temporaryFolderExists = fileManager() + .fileExists(atPath: temporaryFolderURL.path) + let completedFolderExists = completedFolderURL + .map { + fileManager().fileExists(atPath: $0.path) + } ?? false + + if shouldExposeTemporaryWorkingSet(for: download) { + return temporaryFolderExists + ? temporaryFolderURL + : completedFolderURL + } + if completedFolderExists { + return completedFolderURL + } + if temporaryFolderExists { + return temporaryFolderURL + } + return nil + } + + func sanitizedFailedPages( + folderURL: URL + ) -> [Int: DownloadFailedPagesSnapshot.Page] { + guard var snapshot = try? storage + .readFailedPages(folderURL: folderURL) else { + return [:] + } + let filteredPages = snapshot.pages.filter { + !isCancellationLikeAppError($0.failure.appError) + } + guard filteredPages.count != snapshot.pages.count + else { + return snapshot.map + } + + snapshot.pages = filteredPages + if filteredPages.isEmpty { + try? storage.removeFailedPages( + folderURL: folderURL + ) + } else { + try? storage.writeFailedPages( + snapshot, + folderURL: folderURL + ) + } + return snapshot.map + } + + func normalizeNeedsAttentionDownloads( + _ downloads: [DownloadedGallery] + ) async { + for download in downloads { + let shouldClearCancellationError = + download.lastError.map { + isCancellationLikeAppError($0.appError) + } ?? false + guard download.status == .failed + || shouldClearCancellationError else { + continue + } + + let normalizedCompletedPageCount = max( + download.completedPageCount, + temporaryCompletedPageCount( + gid: download.gid, + expectedPageCount: + max(download.pageCount, 1) + ) + ) + do { + try await updateDownloadRecord( + gid: download.gid, + createIfMissing: false + ) { record in + if download.status == .failed { + record.status = + DownloadStatus.partial.rawValue + record.completedPageCount = Int64( + normalizedCompletedPageCount + ) + } + if shouldClearCancellationError { + record.lastError = nil + } + } + } catch { + Logger.error(error) + } + } + } + + func normalizeInterruptedDownloads( + _ downloads: [DownloadedGallery] + ) async { + let hasActiveTask = activeTask != nil + let activeGalleryID = activeGalleryID + for download in downloads where + download.needsInterruptedDownloadNormalization( + activeGalleryID: activeGalleryID, + hasActiveTask: hasActiveTask + ) { + do { + try await updateDownloadRecord( + gid: download.gid, + createIfMissing: false + ) { record in + record.status = + DownloadStatus.paused.rawValue + } + } catch { + Logger.error(error) + } + } + } + + func reconcileActiveDownloadState() async { + guard activeTask != nil, + let activeGalleryID, + let activeDownload = await fetchDownload( + gid: activeGalleryID + ), + activeDownload.status != .downloading + else { return } + + do { + try await updateDownloadRecord( + gid: activeGalleryID, + createIfMissing: false + ) { record in + record.status = + DownloadStatus.downloading.rawValue + record.lastError = nil + } + } catch { + Logger.error(error) + } + } + + func validateDownloads() async { + let downloads = await fetchDownloadsFromStore() + for download in downloads + where [.completed, .updateAvailable, .missingFiles] + .contains(download.status) { + let validation = storage + .validate(download: download) + switch validation { + case .valid: + let expectedStatus: DownloadStatus = + download.hasUpdate + ? .updateAvailable : .completed + guard download.status != expectedStatus + else { continue } + do { + try await updateDownloadRecord( + gid: download.gid, + createIfMissing: false + ) { record in + record.status = + expectedStatus.rawValue + } + } catch { + Logger.error(error) + } + + case .missingFiles(let message): + do { + try await updateDownloadRecord( + gid: download.gid, + createIfMissing: false + ) { record in + record.status = + DownloadStatus.missingFiles + .rawValue + record.lastError = DownloadFailure( + code: .fileOperationFailed, + message: message + ) + .toData() + } + } catch { + Logger.error(error) + } + } + } + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift new file mode 100644 index 000000000..033791546 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -0,0 +1,359 @@ +// +// DownloadClient+PublicAPI.swift +// EhPanda +// + +import CoreData +import Foundation + +// MARK: - Public API +extension DownloadManager { + func observeDownloads() -> AsyncStream<[DownloadedGallery]> { + let identifier = UUID() + return AsyncStream { continuation in + continuation.onTermination = { [weak self] _ in + guard let self else { return } + Task { + await self.removeObserver(id: identifier) + } + } + Task { + await self.addObserver(id: identifier, continuation: continuation) + } + } + } + + func fetchDownloads() async -> [DownloadedGallery] { + sortDownloads(await fetchDownloadsFromStore()) + } + + func reconcileDownloads() async { + await syncDownloadsState(scheduleNext: false) + } + + func refreshDownloads() async { + await syncDownloadsState(scheduleNext: true) + } + + func resumeQueue() async { + await scheduleNextIfNeeded() + } + + func badges(for gids: [String]) async -> [String: DownloadBadge] { + guard !gids.isEmpty else { return [:] } + let downloads = await fetchDownloadsFromStore(gids: gids) + return Dictionary(uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) }) + } + + private struct SignatureUpdateInfo { + let download: DownloadedGallery + let latestSignature: String? + let comparison: DownloadSignatureBuilder.Comparison + let canonicalizedSignature: String? + } + + func updateRemoteSignature( + gid: String, + latestSignature: String? + ) async -> DownloadBadge { + guard let download = await fetchDownload(gid: gid) else { + return .none + } + let info = SignatureUpdateInfo( + download: download, + latestSignature: latestSignature, + comparison: DownloadSignatureBuilder.hasUpdateComparison( + remoteVersionSignature: download.remoteVersionSignature, + latestRemoteVersionSignature: latestSignature, + gid: download.gid, + token: download.token + ), + canonicalizedSignature: + DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( + remoteVersionSignature: download.remoteVersionSignature, + latestRemoteVersionSignature: latestSignature, + gid: download.gid, + token: download.token + ) + ) + var didChange = false + do { + try await updateDownloadRecord( + gid: gid, createIfMissing: false + ) { record in + self.applySignatureUpdate(to: record, info: info, didChange: &didChange) + } + } catch { + Logger.error(error) + } + if didChange { await notifyObservers() } + return (await fetchDownload(gid: gid))?.badge ?? .none + } + + private func applySignatureUpdate( + to record: DownloadedGalleryMO, + info: SignatureUpdateInfo, + didChange: inout Bool + ) { + let download = info.download + let latestSignature = info.latestSignature + if download.latestRemoteVersionSignature != latestSignature { + record.latestRemoteVersionSignature = latestSignature + didChange = true + } + if let canonicalized = info.canonicalizedSignature, + canonicalized != download.remoteVersionSignature { + record.remoteVersionSignature = canonicalized + didChange = true + } + guard latestSignature?.notEmpty == true, + [.completed, .updateAvailable].contains(download.status) + else { return } + let desiredStatus: DownloadStatus? + switch info.comparison { + case .different: desiredStatus = .updateAvailable + case .same: desiredStatus = .completed + case .incomparable: desiredStatus = nil + } + if let desiredStatus, desiredStatus != download.status { + record.status = desiredStatus.rawValue + didChange = true + } + } + + func enqueue( + payload: DownloadRequestPayload + ) async -> Result { + do { + try storage.ensureRootDirectory() + let versionSignature = DownloadSignatureBuilder.make( + gallery: payload.gallery, + detail: payload.galleryDetail, + host: payload.host, + previewURLs: payload.previewURLs, + versionMetadata: payload.versionMetadata + ) + let folderRelativePath = storage.makeFolderRelativePath( + gid: payload.gallery.gid, + title: payload.galleryDetail.trimmedTitle.isEmpty + ? payload.gallery.title + : payload.galleryDetail.trimmedTitle + ) + try await updateDownloadRecord(gid: payload.gallery.gid) { record in + record.gid = payload.gallery.gid + record.host = payload.host.rawValue + record.token = payload.gallery.token + record.title = payload.gallery.title + record.jpnTitle = payload.galleryDetail.jpnTitle + record.uploader = payload.galleryDetail.uploader + record.category = payload.gallery.category.rawValue + record.tags = payload.gallery.tags.toData() + record.pageCount = Int64(payload.galleryDetail.pageCount) + record.postedDate = payload.galleryDetail.postedDate + record.rating = payload.galleryDetail.rating + record.onlineCoverURL = + payload.galleryDetail.coverURL ?? payload.gallery.coverURL + record.folderRelativePath = folderRelativePath + record.downloadOptionsSnapshot = payload.options.toData() + record.completedPageCount = 0 + record.lastDownloadedAt = .now + record.lastError = nil + record.latestRemoteVersionSignature = versionSignature + record.pendingOperation = nil + record.status = DownloadStatus.queued.rawValue + } + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.unknown) + } + } + + func togglePause(gid: String) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + + if let pendingMode = download.pendingOperation { + return await cancelQueuedWorkItem(download, mode: pendingMode) + } + + switch download.status { + case .queued, .downloading: + return await pause(gid: gid) + case .paused: + return await resume(gid: gid) + case .partial, .completed, .failed, .updateAvailable, .missingFiles: + return .failure(.unknown) + } + } + + func delete(gid: String) async -> Result { + let taskToCancel: Task? + schedulingBlockedGalleryIDs.insert(gid) + defer { + schedulingBlockedGalleryIDs.remove(gid) + } + if activeGalleryID == gid { + taskToCancel = activeTask + activeTask?.cancel() + activeTask = nil + activeGalleryID = nil + } else { + taskToCancel = nil + } + await taskToCancel?.value + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + do { + try? storage.removeTemporaryFolder(gid: gid) + try storage.removeFolder(relativePath: download.folderRelativePath) + try await deleteDownloadRecord(gid: gid) + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.fileOperationFailed(error.localizedDescription)) + } + } + + func loadManifest( + gid: String + ) async -> Result<(DownloadedGallery, DownloadManifest), AppError> { + let sanitizedDownload = await sanitizeLocalFilesIfNeeded(gid: gid) + let resolvedDownload: DownloadedGallery? + if let sanitizedDownload { + resolvedDownload = sanitizedDownload + } else { + resolvedDownload = await fetchDownload(gid: gid) + } + guard let download = resolvedDownload, + let folderURL = download.resolvedFolderURL(rootURL: storage.rootURL) + else { + return .failure(.notFound) + } + switch storage.validate(download: download) { + case .valid: + break + case .missingFiles(let message): + return .failure(.fileOperationFailed(message)) + } + do { + let manifest = try storage.readManifest(folderURL: folderURL) + return .success((download, manifest)) + } catch { + return .failure(.fileOperationFailed(error.localizedDescription)) + } + } + + func captureCachedPage( + gid: String, + index: Int, + imageURL: URL? + ) async { + guard let download = await fetchDownload(gid: gid), + index >= 1, + index <= max(download.pageCount, 1) + else { return } + + guard let captureTarget = captureTarget( + for: download, index: index + ) else { return } + + await performCacheCapture( + gid: gid, + index: index, + imageURL: imageURL, + captureTarget: captureTarget, + download: download + ) + } + + private func performCacheCapture( + gid: String, + index: Int, + imageURL: URL?, + captureTarget: CaptureTargetResult, + download: DownloadedGallery + ) async { + let existingPages = storage.existingPageRelativePaths( + folderURL: captureTarget.folderURL, + expectedPageCount: download.pageCount + ) + do { + let cacheURLs = pageImageCacheURLs(imageURL: imageURL) + let cacheSource = CacheRestoreSource( + cacheURLs: cacheURLs, + referenceURL: preferredPageReferenceURL(imageURL: imageURL), + imageURL: imageURL + ) + guard let pageResult = try await restorePageFromCache( + index: index, + source: cacheSource, + folderURL: captureTarget.folderURL, + preferredRelativePath: + captureTarget.preferredRelativePath ?? existingPages[index], + overwriteExistingFile: true + ) else { return } + await persistResolvedImageURLs( + gid: gid, index: index, imageURL: pageResult.imageURL + ) + if captureTarget.isTemporary { + try clearFailedPage( + index: index, folderURL: captureTarget.folderURL + ) + } + _ = await sanitizeLocalFilesIfNeeded(gid: gid, clearingLastError: true) + } catch { + Logger.error(error) + } + } + + func loadInspection( + gid: String + ) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + + let activeFolderURL = activeInspectionFolderURL(for: download) + + let existingRelativePaths = activeFolderURL.map { + storage.existingPageRelativePaths( + folderURL: $0, + expectedPageCount: download.pageCount + ) + } ?? [:] + let failedPages = activeFolderURL + .map(sanitizedFailedPages(folderURL:)) ?? [:] + + let pages = buildInspectionPages( + download: download, + activeFolderURL: activeFolderURL, + existingRelativePaths: existingRelativePaths, + failedPages: failedPages + ) + + let coverURL = activeFolderURL.flatMap { folderURL in + storage.existingCoverRelativePath(folderURL: folderURL).map { + folderURL.appendingPathComponent($0) + } + } ?? download.coverURL + + return .success( + .init( + download: download, + coverURL: coverURL, + pages: pages + ) + ) + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift new file mode 100644 index 000000000..b27f4467a --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -0,0 +1,233 @@ +// +// DownloadClient+PublicAPIHelpers.swift +// EhPanda +// + +import CoreData +import Foundation + +// MARK: - Private helpers for public API +extension DownloadManager { + func buildInspectionPages( + download: DownloadedGallery, + activeFolderURL: URL?, + existingRelativePaths: [Int: String], + failedPages: [Int: DownloadFailedPagesSnapshot.Page] + ) -> [DownloadPageInspection] { + (1...download.pageCount).map { index -> DownloadPageInspection in + if let relativePath = existingRelativePaths[index], + let folderURL = activeFolderURL { + let fileURL = folderURL + .appendingPathComponent(relativePath) + if fileManager().fileExists(atPath: fileURL.path) { + return .init( + index: index, + status: .downloaded, + relativePath: relativePath, + fileURL: fileURL, + failure: nil + ) + } + } + + if let failedPage = failedPages[index] { + return .init( + index: index, + status: .failed, + relativePath: failedPage.relativePath, + fileURL: nil, + failure: failedPage.failure + ) + } + + return .init( + index: index, + status: .pending, + relativePath: nil, + fileURL: nil, + failure: nil + ) + } + } + + func buildCompletedPageURLs( + completedFolderURL: URL?, + download: DownloadedGallery + ) -> [Int: URL] { + let completedPageRelativePaths = completedFolderURL.map { + storage.existingPageRelativePaths( + folderURL: $0, + expectedPageCount: download.pageCount + ) + } ?? [:] + return completedPageRelativePaths + .reduce(into: [Int: URL]()) { result, entry in + guard let folderURL = completedFolderURL else { return } + result[entry.key] = folderURL + .appendingPathComponent(entry.value) + } + } + + func buildTemporaryPageURLs( + hasTemporaryFolder: Bool, + temporaryFolderURL: URL, + download: DownloadedGallery + ) -> [Int: URL] { + let temporaryPageRelativePaths = hasTemporaryFolder + ? storage.existingPageRelativePaths( + folderURL: temporaryFolderURL, + expectedPageCount: download.pageCount + ) + : [:] + return temporaryPageRelativePaths + .reduce(into: [Int: URL]()) { result, entry in + result[entry.key] = temporaryFolderURL + .appendingPathComponent(entry.value) + } + } + + func resolveLocalPageURLs( + completedValidation: DownloadValidationState, + completedFolderURL: URL?, + completedPageURLs: [Int: URL], + temporaryPageURLs: [Int: URL], + shouldExposeTemp: Bool + ) -> Result<[Int: URL], AppError> { + if completedValidation == .valid, + let completedFolderURL, + fileManager().fileExists(atPath: completedFolderURL.path), + let manifest = try? storage.readManifest( + folderURL: completedFolderURL + ) { + let completedManifestPageURLs = manifest + .imageURLs(folderURL: completedFolderURL) + guard shouldExposeTemp else { + return .success(completedManifestPageURLs) + } + return .success( + completedManifestPageURLs.merging( + temporaryPageURLs, + uniquingKeysWith: { _, temporary in temporary } + ) + ) + } + + guard shouldExposeTemp else { + return .success(completedPageURLs) + } + + if !completedPageURLs.isEmpty, !temporaryPageURLs.isEmpty { + return .success( + completedPageURLs.merging( + temporaryPageURLs, + uniquingKeysWith: { _, temporary in temporary } + ) + ) + } + + if !temporaryPageURLs.isEmpty { + return .success(temporaryPageURLs) + } + + return .success(completedPageURLs) + } + + struct RetryParams { + let shouldResumeExistingWork: Bool + let resumedStatus: DownloadStatus + let completedPageCount: Int + let pendingOperation: DownloadStartMode? + } + + func computeRetryParams( + download: DownloadedGallery, + resolvedMode: DownloadStartMode, + existingResumeState: DownloadResumeState?, + gid: String + ) -> RetryParams { + let shouldResumeExisting = shouldResumeExistingWorkingSet( + for: download, + mode: resolvedMode, + resumeState: existingResumeState + ) + let shouldStartImmediately = + activeTask == nil || activeGalleryID == gid + let resumedStatus: DownloadStatus + let completedPageCount: Int + let pendingOperation: DownloadStartMode? + + if shouldResumeExisting { + resumedStatus = shouldStartImmediately + ? .downloading : .queued + completedPageCount = download.completedPageCount + pendingOperation = nil + } else if shouldStartImmediately { + resumedStatus = .downloading + completedPageCount = validatedCompletedPageCount(download) + pendingOperation = nil + } else { + resumedStatus = download.status + completedPageCount = validatedCompletedPageCount(download) + pendingOperation = resolvedMode + } + + return RetryParams( + shouldResumeExistingWork: shouldResumeExisting, + resumedStatus: resumedStatus, + completedPageCount: completedPageCount, + pendingOperation: pendingOperation + ) + } + + func writeRetryResumeState( + download: DownloadedGallery, + resolvedMode: DownloadStartMode, + existingResumeState: DownloadResumeState?, + temporaryFolderURL: URL + ) { + let downloadOptions = download.downloadOptionsSnapshot + let versionSignature = preferredVersionSignature( + for: download, + mode: resolvedMode, + resumeState: existingResumeState + ) + let pageCount = preferredWorkingPageCount( + for: download, + mode: resolvedMode, + versionSignature: versionSignature, + resumeState: existingResumeState + ) + try? storage.writeResumeState( + .init( + mode: resolvedMode, + versionSignature: versionSignature, + pageCount: pageCount, + downloadOptions: downloadOptions + ), + folderURL: temporaryFolderURL + ) + } + + func clearSelectedFailedPages( + selectedPageIndices: [Int], + temporaryFolderURL: URL + ) { + if let failedSnapshot = try? storage.readFailedPages( + folderURL: temporaryFolderURL + ) { + let remainingPages = failedSnapshot.pages.filter { + !selectedPageIndices.contains($0.index) + } + if remainingPages.isEmpty { + try? storage.removeFailedPages( + folderURL: temporaryFolderURL + ) + } else { + try? storage.writeFailedPages( + .init(pages: remainingPages), + folderURL: temporaryFolderURL + ) + } + } + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift new file mode 100644 index 000000000..13fe29400 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift @@ -0,0 +1,288 @@ +// +// DownloadClient+ResponseValidation.swift +// EhPanda +// + +import Kanna +import CryptoKit +import Foundation +import ImageIO + +// MARK: - Response Error Detection +extension DownloadManager { + func detectResponseError( + data: Data, + response: URLResponse, + requestURL: URL?, + expectsHTML: Bool = false + ) -> AppError? { + detectResponseError( + prefixData: Data( + data.prefix(Self.responseInspectionPrefixLength) + ), + fullData: data, + response: response, + requestURL: requestURL, + expectsHTML: expectsHTML + ) + } + + func detectResponseError( + fileURL: URL, + response: URLResponse, + requestURL: URL? + ) -> AppError? { + let prefixData = (try? readResponsePrefixData( + at: fileURL + )) ?? Data() + if let error = detectPlaceholderFileErrors( + response: response, + fileURL: fileURL, + requestURL: requestURL + ) { + return error + } + let mimeType = normalizedMimeType(response) + let shouldInspect = shouldInspectTextResponse( + mimeType: mimeType, + prefixData: prefixData + ) + guard shouldInspect else { + if statusCode(for: response) == 404 { + return .notFound + } + return nil + } + return detectResponseError( + prefixData: prefixData, + fullData: resolveFileData( + fileURL: fileURL, + mimeType: mimeType, + prefixData: prefixData, + response: response + ), + response: response, + requestURL: requestURL, + expectsHTML: false + ) + } + + private func detectPlaceholderFileErrors( + response: URLResponse, + fileURL: URL, + requestURL: URL? + ) -> AppError? { + let placeholderData = loadPlaceholderDataIfNeeded( + response: response, + fileURL: fileURL + ) + if let placeholderData { + if isAuthenticationRequiredPlaceholderImageData( + placeholderData + ) { + return .authenticationRequired + } + if isQuotaExceededAssetData(placeholderData) { + return .quotaExceeded + } + } + if isAuthenticationRequiredPlaceholderResponse( + response: response, + requestURL: requestURL + ) { + return .authenticationRequired + } + if isQuotaExceededResponse( + fullData: nil, + fileURL: fileURL, + response: response, + requestURL: requestURL + ) { + return .quotaExceeded + } + return nil + } + + private func resolveFileData( + fileURL: URL, + mimeType: String?, + prefixData: Data, + response: URLResponse + ) -> Data? { + let looksLikeHTML = responseLooksLikeHTML( + mimeType: mimeType, + prefixData: prefixData, + expectsHTML: false + ) + let placeholderData = loadPlaceholderDataIfNeeded( + response: response, + fileURL: fileURL + ) + if looksLikeHTML { + return placeholderData ?? (try? Data( + contentsOf: fileURL, + options: .mappedIfSafe + )) + } + return placeholderData + } + + func detectResponseError( + prefixData: Data, + fullData: Data?, + response: URLResponse, + requestURL: URL?, + expectsHTML: Bool + ) -> AppError? { + if let error = detectDataErrors( + fullData: fullData, + response: response, + requestURL: requestURL + ) { + return error + } + + let mimeType = normalizedMimeType(response) + let shouldInspect = expectsHTML + || shouldInspectTextResponse( + mimeType: mimeType, + prefixData: prefixData + ) + if shouldInspect { + let inspectedData = fullData ?? prefixData + if let error = detectTextualDownloadError( + data: inspectedData, + looksLikeHTML: responseLooksLikeHTML( + mimeType: mimeType, + prefixData: prefixData, + expectsHTML: expectsHTML + ) + ) { + return error + } + } + if isAuthenticationRequiredResponse( + prefixData: prefixData, + fullData: fullData, + response: response, + requestURL: requestURL + ) { + return .authenticationRequired + } + guard shouldInspect else { return nil } + + let htmlContext = HTMLResponseContext( + prefixData: prefixData, fullData: fullData, + response: response, requestURL: requestURL, + mimeType: mimeType + ) + return detectHTMLResponseError( + context: htmlContext, expectsHTML: expectsHTML + ) + } + + private func detectDataErrors( + fullData: Data?, + response: URLResponse, + requestURL: URL? + ) -> AppError? { + if let fullData { + if isAuthenticationRequiredPlaceholderImageData( + fullData + ) { + return .authenticationRequired + } + if isQuotaExceededAssetData(fullData) { + return .quotaExceeded + } + } + if isAuthenticationRequiredPlaceholderResponse( + response: response, + requestURL: requestURL + ) { + return .authenticationRequired + } + if isQuotaExceededResponse( + fullData: fullData, + fileURL: nil, + response: response, + requestURL: requestURL + ) { + return .quotaExceeded + } + return nil + } + + private func detectHTMLResponseError( + context: HTMLResponseContext, + expectsHTML: Bool + ) -> AppError? { + let prefixData = context.prefixData + let fullData = context.fullData + let response = context.response + let requestURL = context.requestURL + let mimeType = context.mimeType + let textPrefix = String( + bytes: prefixData, + encoding: .utf8 + ) ?? "" + + let looksLikeHTML = responseLooksLikeHTML( + mimeType: mimeType, + prefixData: prefixData, + expectsHTML: expectsHTML + ) + guard looksLikeHTML else { + if statusCode(for: response) == 404 { + return .notFound + } + return nil + } + + if let fullData, + let document = try? Kanna.HTML( + html: fullData.utf8InvalidCharactersRipped, + encoding: .utf8 + ), + let error = Parser.parseDownloadPageError( + doc: document + ) { + return error + } + if expectsHTML { + if statusCode(for: response) == 404 { + return .notFound + } + return nil + } + Logger.error( + "Download received unexpected HTML response.", + context: [ + "url": requestURL?.absoluteString ?? "", + "snippet": String(textPrefix.prefix(240)) + ] + ) + if statusCode(for: response) == 404 { + return .notFound + } + return .parseFailed + } + + private func loadPlaceholderDataIfNeeded( + response: URLResponse, + fileURL: URL + ) -> Data? { + let byteCount = responseContentLength(response) + ?? fileSize(at: fileURL) + guard let byteCount, + byteCount == Self.kokomadeImageByteCount + || byteCount == Self.quotaExceededImageByteCount + else { + return nil + } + return try? Data( + contentsOf: fileURL, + options: .mappedIfSafe + ) + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift new file mode 100644 index 000000000..6391c62ee --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -0,0 +1,384 @@ +// +// DownloadClient+ResponseValidationHelpers.swift +// EhPanda +// + +import Kanna +import CryptoKit +import Foundation +import ImageIO + +// MARK: - Response Inspection Helpers +extension DownloadManager { + func normalizedMimeType( + _ response: URLResponse + ) -> String? { + if let mimeType = response.mimeType?.lowercased(), + mimeType.notEmpty { + return mimeType + } + if let httpResponse = response as? HTTPURLResponse, + let contentType = httpResponse.value( + forHTTPHeaderField: "Content-Type" + )?.lowercased(), + let mimeType = contentType + .split(separator: ";").first, + !mimeType.isEmpty { + return String(mimeType) + } + return nil + } + + func shouldInspectTextResponse( + mimeType: String?, + prefixData: Data + ) -> Bool { + if let mimeType { + if mimeType.hasPrefix("image/") { + return prefixLooksLikeHTML(prefixData) + } + if mimeType == "text/html" + || mimeType == "text/plain" { + return true + } + return prefixLooksLikeHTML(prefixData) + } + + guard !prefixIsKnownBinaryImage(prefixData) else { + return false + } + return true + } + + func prefixLooksLikeHTML(_ prefixData: Data) -> Bool { + let prefix = String( + bytes: prefixData, + encoding: .utf8 + )? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + guard prefix.notEmpty else { return false } + + let htmlMarkers = [ + " Bool { + expectsHTML + || mimeType == "text/html" + || prefixLooksLikeHTML(prefixData) + } + + func detectTextualDownloadError( + data: Data, + looksLikeHTML: Bool + ) -> AppError? { + let normalizedData = data.utf8InvalidCharactersRipped + let rawContent = String( + data: normalizedData, + encoding: .utf8 + ) ?? "" + if !looksLikeHTML { + return Parser.parseDownloadPageError( + content: rawContent + ) + } + + if let document = try? Kanna.HTML( + html: normalizedData, + encoding: .utf8 + ), + let error = Parser.parseDownloadPageError( + doc: document + ) { + return error + } + + guard rawContent.count <= 1024 else { + return nil + } + return Parser.parseDownloadPageError( + content: rawContent + ) + } + + func prefixIsKnownBinaryImage( + _ prefixData: Data + ) -> Bool { + prefixData.starts(with: [0xFF, 0xD8, 0xFF]) + || prefixData.starts( + with: [0x89, 0x50, 0x4E, 0x47] + ) + || prefixData.starts(with: [0x47, 0x49, 0x46]) + || ( + prefixData.starts( + with: [0x52, 0x49, 0x46, 0x46] + ) + && prefixData.count >= 12 + && String( + bytes: prefixData[8..<12], + encoding: .utf8 + ) == "WEBP" + ) + } + + func isQuotaExceededResponse( + fullData: Data?, + fileURL: URL?, + response: URLResponse, + requestURL: URL? + ) -> Bool { + let urls = [requestURL, response.url] + .compactMap(\.self) + let lowercasedURLs = urls + .map { $0.absoluteString.lowercased() } + guard lowercasedURLs.contains(where: { url in + Self.quotaExceededImageURLSuffixes + .contains(where: url.hasSuffix) + }) else { + return false + } + + let byteCount = fullData?.count + ?? responseContentLength(response) + ?? fileSize(at: fileURL) + guard byteCount == Self.quotaExceededImageByteCount + else { + return false + } + + let data: Data? + if let fullData { + data = fullData + } else if let fileURL { + data = try? Data( + contentsOf: fileURL, + options: .mappedIfSafe + ) + } else { + data = nil + } + guard let data else { return false } + return isQuotaExceededAssetData(data) + } + + func isAuthenticationRequiredPlaceholderResponse( + response: URLResponse, + requestURL: URL? + ) -> Bool { + [requestURL, response.url].contains { + isAuthenticationRequiredPlaceholderURL($0) + } + } + + func isAuthenticationRequiredPlaceholderURL( + _ url: URL? + ) -> Bool { + guard let url else { return false } + let normalizedURL = url.absoluteString.lowercased() + if normalizedURL.contains("bounce_login.php") { + return true + } + return isKokomadePlaceholderURL(url) + } + + func isKokomadePlaceholderURL(_ url: URL?) -> Bool { + guard let url else { return false } + let normalizedURL = url.absoluteString.lowercased() + return isExHentaiURL(url) + && Self.kokomadeImageURLSuffixes + .contains(where: normalizedURL.hasSuffix) + } + + func isAuthenticationRequiredResponse( + prefixData: Data, + fullData: Data?, + response: URLResponse, + requestURL: URL? + ) -> Bool { + guard isExHentaiURL(requestURL) + || isExHentaiURL(response.url) else { + return false + } + guard normalizedMimeType(response) == "text/html" + else { + return false + } + guard fullData?.isEmpty ?? prefixData.isEmpty else { + return false + } + + let cookies = responseCookies( + response: response, + requestURL: requestURL + ) + let hasYay = cookies.contains { + $0.name == Defaults.Cookie.yay + && $0.value.notEmpty + } + let hasValidIgneous = cookies.contains { + $0.name == Defaults.Cookie.igneous + && $0.value.notEmpty + && $0.value != Defaults.Cookie.mystery + } + return hasYay && !hasValidIgneous + } + + func responseCookies( + response: URLResponse, + requestURL: URL? + ) -> [HTTPCookie] { + let urls = [ + response.url, + requestURL, + Defaults.URL.exhentai, + Defaults.URL.sexhentai + ] + .compactMap(\.self) + var uniqueURLs = [URL]() + for url in urls where !uniqueURLs.contains(url) { + uniqueURLs.append(url) + } + + var cookies = [HTTPCookie]() + if let httpResponse = response as? HTTPURLResponse, + let responseURL = httpResponse.url { + let headerFields = httpResponse.allHeaderFields + .reduce(into: [String: String]()) { partial, item in + guard let key = item.key as? String, + let value = item.value as? String + else { return } + partial[key] = value + } + cookies += HTTPCookie.cookies( + withResponseHeaderFields: headerFields, + for: responseURL + ) + } + + for url in uniqueURLs { + cookies += HTTPCookieStorage.shared + .cookies(for: url) ?? [] + } + return cookies + } + + func isExHentaiURL(_ url: URL?) -> Bool { + guard let host = url?.host?.lowercased() else { + return false + } + return host == "exhentai.org" + || host.hasSuffix(".exhentai.org") + } + + func statusCode(for response: URLResponse) -> Int? { + (response as? HTTPURLResponse)?.statusCode + } + + func responseContentLength( + _ response: URLResponse + ) -> Int? { + if response.expectedContentLength > 0 { + return Int(response.expectedContentLength) + } + if let httpResponse = response as? HTTPURLResponse, + let header = httpResponse.value( + forHTTPHeaderField: "Content-Length" + ), + let contentLength = Int(header) { + return contentLength + } + return nil + } + + func fileSize(at fileURL: URL?) -> Int? { + guard let fileURL else { return nil } + let values = try? fileURL.resourceValues( + forKeys: [.fileSizeKey] + ) + return values?.fileSize + } + + func isAuthenticationRequiredPlaceholderImageData( + _ data: Data + ) -> Bool { + guard data.count == Self.kokomadeImageByteCount else { + return false + } + return sha1Hex(for: data) == Self.kokomadeImageSHA1 + } + + func isQuotaExceededAssetData(_ data: Data) -> Bool { + guard data.count == Self.quotaExceededImageByteCount + else { + return false + } + return sha1Hex(for: data) + == Self.quotaExceededImageSHA1 + } + + func sha1Hex(for data: Data) -> String { + let digest = Insecure.SHA1.hash(data: data) + return digest + .map { String(format: "%02x", $0) } + .joined() + } + + func isDecodableImageData(_ data: Data) -> Bool { + guard let source = CGImageSourceCreateWithData( + data as CFData, + nil + ) else { + return false + } + return CGImageSourceGetCount(source) > 0 + } + + func shouldSuppressFailurePersistence( + for gid: String + ) -> Bool { + schedulingBlockedGalleryIDs.contains(gid) + || Task.isCancelled + } + + nonisolated static func isCancellationLikeError( + _ error: Error + ) -> Bool { + if error is CancellationError { + return true + } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, + nsError.code == URLError.cancelled.rawValue { + return true + } + + let message = nsError.localizedDescription + .lowercased() + return message.contains("cancellation") + || message.contains("cancelled") + || message.contains("canceled") + } + + func isCancellationLikeAppError( + _ error: AppError + ) -> Bool { + guard case .fileOperationFailed(let reason) = error + else { return false } + return Self.isCancellationLikeError(NSError( + domain: NSCocoaErrorDomain, + code: NSUserCancelledError, + userInfo: [NSLocalizedDescriptionKey: reason] + )) + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift new file mode 100644 index 000000000..b0aa67fbd --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -0,0 +1,195 @@ +// +// DownloadClient+RetryHelpers.swift +// EhPanda +// + +import CoreData +import Foundation + +// MARK: - Retry & RetryPages +extension DownloadManager { + func retry( + gid: String, + mode: DownloadStartMode + ) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + do { + try await performRetry(gid: gid, download: download, mode: mode) + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.unknown) + } + } + + private func performRetry( + gid: String, + download: DownloadedGallery, + mode: DownloadStartMode + ) async throws { + let resolvedMode = effectiveRetryMode( + for: download, requestedMode: mode + ) + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let existingResumeState = fileManager() + .fileExists(atPath: temporaryFolderURL.path) + ? (try? storage.readResumeState(folderURL: temporaryFolderURL)) + : nil + let retryParams = computeRetryParams( + download: download, + resolvedMode: resolvedMode, + existingResumeState: existingResumeState, + gid: gid + ) + if !retryParams.shouldResumeExistingWork { + try? storage.removeTemporaryFolder(gid: gid) + } + try await updateDownloadRecord( + gid: gid, createIfMissing: false + ) { record in + record.status = retryParams.resumedStatus.rawValue + record.completedPageCount = Int64(retryParams.completedPageCount) + record.lastDownloadedAt = .now + record.lastError = nil + record.pendingOperation = retryParams.pendingOperation?.rawValue + } + if fileManager().fileExists(atPath: temporaryFolderURL.path) { + writeRetryResumeState( + download: download, + resolvedMode: resolvedMode, + existingResumeState: existingResumeState, + temporaryFolderURL: temporaryFolderURL + ) + } + await notifyObservers() + await scheduleNextIfNeeded() + } + + func retryPages( + gid: String, + pageIndices: [Int] + ) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + let mode = resumeMode(for: download) + if mode == .update { return await retry(gid: gid, mode: .update) } + + let selectedPageIndices = Array(Set(pageIndices)).sorted() + guard !selectedPageIndices.isEmpty else { return .success(()) } + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + guard fileManager().fileExists(atPath: temporaryFolderURL.path) else { + return .failure(.notFound) + } + do { + try await performRetryPages( + gid: gid, + download: download, + mode: mode, + selectedPageIndices: selectedPageIndices, + temporaryFolderURL: temporaryFolderURL + ) + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.unknown) + } + } + + private func performRetryPages( + gid: String, + download: DownloadedGallery, + mode: DownloadStartMode, + selectedPageIndices: [Int], + temporaryFolderURL: URL + ) async throws { + let existingResumeState = try? storage.readResumeState( + folderURL: temporaryFolderURL + ) + let versionSignature = preferredVersionSignature( + for: download, mode: mode, resumeState: existingResumeState + ) + let pageCount = preferredWorkingPageCount( + for: download, mode: mode, + versionSignature: versionSignature, + resumeState: existingResumeState + ) + let resumedStatus: DownloadStatus = + activeTask == nil || activeGalleryID == gid + ? .downloading : .queued + + clearSelectedFailedPages( + selectedPageIndices: selectedPageIndices, + temporaryFolderURL: temporaryFolderURL + ) + try storage.writeResumeState( + .init( + mode: mode, + versionSignature: versionSignature, + pageCount: pageCount, + downloadOptions: download.downloadOptionsSnapshot, + pageSelection: selectedPageIndices + ), + folderURL: temporaryFolderURL + ) + try await updateDownloadRecord( + gid: gid, createIfMissing: false + ) { record in + record.status = resumedStatus.rawValue + record.lastDownloadedAt = .now + record.lastError = nil + record.pendingOperation = nil + } + await notifyObservers() + await scheduleNextIfNeeded() + } + + func loadLocalPageURLs( + gid: String + ) async -> Result<[Int: URL], AppError> { + let sanitizedDownload = await sanitizeLocalFilesIfNeeded(gid: gid) + let resolvedDownload: DownloadedGallery? + if let sanitizedDownload { + resolvedDownload = sanitizedDownload + } else { + resolvedDownload = await fetchDownload(gid: gid) + } + guard let download = resolvedDownload else { + return .failure(.notFound) + } + + let completedFolderURL = download + .resolvedFolderURL(rootURL: storage.rootURL) + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let hasTemporaryFolder = fileManager() + .fileExists(atPath: temporaryFolderURL.path) + let shouldExposeTemp = hasTemporaryFolder + && self.shouldExposeTemporaryWorkingSet(for: download) + let completedValidation = storage.validate(download: download) + + let completedPageURLs = buildCompletedPageURLs( + completedFolderURL: completedFolderURL, + download: download + ) + let temporaryPageURLs = buildTemporaryPageURLs( + hasTemporaryFolder: hasTemporaryFolder, + temporaryFolderURL: temporaryFolderURL, + download: download + ) + + return resolveLocalPageURLs( + completedValidation: completedValidation, + completedFolderURL: completedFolderURL, + completedPageURLs: completedPageURLs, + temporaryPageURLs: temporaryPageURLs, + shouldExposeTemp: shouldExposeTemp + ) + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift new file mode 100644 index 000000000..fd482c42a --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -0,0 +1,277 @@ +// +// DownloadClient+Scheduling.swift +// EhPanda +// + +import Foundation + +// MARK: - Observer Management & Scheduling +extension DownloadManager { + func addObserver( + id: UUID, + continuation: AsyncStream<[DownloadedGallery]>.Continuation + ) async { + observers[id] = continuation + let downloads = await fetchDownloads() + lastObservedDownloads = downloads + continuation.yield(downloads) + } + + func removeObserver(id: UUID) { + observers[id] = nil + } + + func notifyObservers() async { + let downloads = await fetchDownloads() + guard downloads != lastObservedDownloads else { return } + lastObservedDownloads = downloads + observers.values.forEach { $0.yield(downloads) } + } + + func scheduleNextIfNeeded() async { + guard activeTask == nil else { + await reconcileActiveDownloadState() + return + } + let downloads = await fetchDownloadsFromStore() + let nextDownload = downloads + .filter { + !schedulingBlockedGalleryIDs.contains($0.gid) + && shouldSchedule(download: $0) + } + .sorted { lhs, rhs in + let lhsIsDownloading = lhs.status == .downloading + let rhsIsDownloading = rhs.status == .downloading + if lhsIsDownloading != rhsIsDownloading { + return lhsIsDownloading + } + return (lhs.lastDownloadedAt ?? .distantPast) + < (rhs.lastDownloadedAt ?? .distantPast) + } + .first + guard let nextDownload else { return } + + activeGalleryID = nextDownload.gid + activeTask = Task { [weak self] in + guard let self else { return } + await self.processDownload(gid: nextDownload.gid) + } + } + + func shouldSchedule(download: DownloadedGallery) -> Bool { + if download.status == .downloading || download.isQueuedWorkItem { + return true + } + + guard download.status == .partial else { + return false + } + + let temporaryFolderURL = storage + .temporaryFolderURL(gid: download.gid) + guard let resumeState = try? storage + .readResumeState(folderURL: temporaryFolderURL), + let pageSelection = resumeState.pageSelection + else { + return false + } + return !pageSelection.isEmpty + } + + func syncDownloadsState(scheduleNext: Bool) async { + let downloads = await fetchDownloadsFromStore() + await normalizeNeedsAttentionDownloads(downloads) + await normalizeInterruptedDownloads(downloads) + + let normalizedDownloads = await fetchDownloadsFromStore() + do { + try storage.ensureRootDirectory() + try storage.cleanupTemporaryFolders( + preservingGIDs: Set( + normalizedDownloads.compactMap { download in + download.shouldPreserveTemporaryWorkingSet + ? download.gid + : nil + } + ) + ) + } catch { + Logger.error(error) + } + await reconcileActiveDownloadState() + await validateDownloads() + await notifyObservers() + guard scheduleNext else { return } + await scheduleNextIfNeeded() + } +} + +// MARK: - Pause & Resume +extension DownloadManager { + func pause(gid: String) async -> Result { + do { + schedulingBlockedGalleryIDs.insert(gid) + defer { + schedulingBlockedGalleryIDs.remove(gid) + } + guard let currentDownload = await fetchDownload(gid: gid) + else { + return .failure(.notFound) + } + guard [.queued, .downloading] + .contains(currentDownload.status) + else { + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } + let taskToCancel = try await writeInitialPauseRecord( + gid: gid, + download: currentDownload + ) + await taskToCancel?.value + try await writeSettledPauseRecord( + gid: gid, + download: currentDownload + ) + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.unknown) + } + } + + private func writeInitialPauseRecord( + gid: String, + download: DownloadedGallery + ) async throws -> Task? { + let initialCount = max( + download.completedPageCount, + temporaryCompletedPageCount( + gid: gid, + expectedPageCount: max(download.pageCount, 1) + ) + ) + try await updateDownloadRecord( + gid: gid, + createIfMissing: false + ) { record in + record.status = DownloadStatus.paused.rawValue + record.completedPageCount = Int64(initialCount) + record.lastError = nil + record.lastDownloadedAt = .now + } + await notifyObservers() + if activeGalleryID == gid { + let task = activeTask + activeTask?.cancel() + activeTask = nil + activeGalleryID = nil + return task + } + return nil + } + + private func writeSettledPauseRecord( + gid: String, + download: DownloadedGallery + ) async throws { + let settledCount = max( + download.completedPageCount, + temporaryCompletedPageCount( + gid: gid, + expectedPageCount: max(download.pageCount, 1) + ) + ) + try await updateDownloadRecord( + gid: gid, + createIfMissing: false + ) { record in + record.status = DownloadStatus.paused.rawValue + record.completedPageCount = Int64(settledCount) + record.lastError = nil + record.lastDownloadedAt = .now + } + } + + func cancelQueuedWorkItem( + _ download: DownloadedGallery, + mode: DownloadStartMode + ) async -> Result { + switch mode { + case .initial: + return await pause(gid: download.gid) + case .redownload, .update, .repair: + break + } + + let restoredStatus = download.status + let restoredCompletedPageCount = + validatedCompletedPageCount(download) + do { + try await updateDownloadRecord( + gid: download.gid, + createIfMissing: false + ) { record in + record.status = restoredStatus.rawValue + record.completedPageCount = + Int64(restoredCompletedPageCount) + record.lastDownloadedAt = .now + record.pendingOperation = nil + } + await notifyObservers() + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.unknown) + } + } + + func resume(gid: String) async -> Result { + guard await fetchDownload(gid: gid) != nil else { + return .failure(.notFound) + } + + do { + let resumedStatus: DownloadStatus = + activeTask == nil ? .downloading : .queued + try await updateDownloadRecord( + gid: gid, + createIfMissing: false + ) { record in + record.status = resumedStatus.rawValue + record.lastError = nil + record.lastDownloadedAt = .now + record.pendingOperation = nil + } + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.unknown) + } + } + + func sortDownloads( + _ downloads: [DownloadedGallery] + ) -> [DownloadedGallery] { + downloads.sorted { lhs, rhs in + let lhsPriority = lhs.sortPriority + let rhsPriority = rhs.sortPriority + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + return (lhs.lastDownloadedAt ?? .distantPast) + > (rhs.lastDownloadedAt ?? .distantPast) + } + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift new file mode 100644 index 000000000..c7d26ddfc --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -0,0 +1,212 @@ +// +// DownloadClient+SchedulingHelpers.swift +// EhPanda +// + +import Foundation + +// MARK: - Mode Resolution +extension DownloadManager { + func queuedMode( + for download: DownloadedGallery + ) -> DownloadStartMode { + if let pendingOperation = download.pendingOperation { + return pendingOperation + } + switch download.status { + case .missingFiles: + return effectiveRetryMode( + for: download, + requestedMode: .repair + ) + case .updateAvailable: + return .update + case .partial: + return resumeMode(for: download) + case .completed: + return effectiveRetryMode( + for: download, + requestedMode: .redownload + ) + case .failed: + return effectiveRetryMode( + for: download, + requestedMode: download.remoteVersionSignature.isEmpty + ? .initial : .redownload + ) + case .paused: + return resumeMode(for: download) + case .queued, .downloading: + return readResumeMode(gid: download.gid) + ?? effectiveRetryMode( + for: download, + requestedMode: download.remoteVersionSignature.isEmpty + ? .initial : .redownload + ) + } + } + + func resumeMode( + for download: DownloadedGallery + ) -> DownloadStartMode { + if download.remoteVersionSignature.isEmpty { + return .initial + } + if download.hasUpdate { + return .update + } + if let mode = readResumeMode(gid: download.gid) { + return effectiveRetryMode( + for: download, + requestedMode: mode + ) + } + if download.status == .partial { + return effectiveRetryMode( + for: download, + requestedMode: download.remoteVersionSignature.isEmpty + ? .initial : .redownload + ) + } + if case .missingFiles = storage.validate(download: download) { + return .repair + } + return .redownload + } + + func effectiveRetryMode( + for download: DownloadedGallery, + requestedMode: DownloadStartMode + ) -> DownloadStartMode { + guard requestedMode != .initial, download.hasUpdate else { + return requestedMode + } + return .update + } + + func preferredVersionSignature( + for download: DownloadedGallery, + mode: DownloadStartMode, + resumeState: DownloadResumeState? + ) -> String { + switch mode { + case .update: + if let latestSignature = + download.latestRemoteVersionSignature, + latestSignature.notEmpty { + return latestSignature + } + case .initial, .redownload, .repair: + break + } + + if let resumeState, + resumeState.versionSignature.notEmpty { + return resumeState.versionSignature + } + + if download.remoteVersionSignature.notEmpty { + return download.remoteVersionSignature + } + + return download.latestRemoteVersionSignature ?? "" + } + + func preferredWorkingPageCount( + for download: DownloadedGallery, + mode: DownloadStartMode, + versionSignature: String, + resumeState: DownloadResumeState? + ) -> Int { + guard mode == .update else { + return download.pageCount + } + + let temporaryFolderURL = storage + .temporaryFolderURL(gid: download.gid) + guard fileManager() + .fileExists(atPath: temporaryFolderURL.path) else { + return download.pageCount + } + + if let manifest = try? storage + .readManifest(folderURL: temporaryFolderURL), + manifest.gid == download.gid, + manifest.versionSignature == versionSignature { + return manifest.pageCount + } + + if let resumeState, + resumeState.versionSignature == versionSignature { + return resumeState.pageCount + } + + return download.pageCount + } + + func shouldResumeExistingWorkingSet( + for download: DownloadedGallery, + mode: DownloadStartMode, + resumeState: DownloadResumeState? + ) -> Bool { + guard download.status == .failed + || storage.temporaryFolderExists(gid: download.gid), + let resumeState + else { + return false + } + + let versionSignature = preferredVersionSignature( + for: download, + mode: mode, + resumeState: resumeState + ) + let pageCount = preferredWorkingPageCount( + for: download, + mode: mode, + versionSignature: versionSignature, + resumeState: resumeState + ) + + guard resumeState.mode == mode, + resumeState.versionSignature == versionSignature, + resumeState.downloadOptions == + download.downloadOptionsSnapshot + else { + return false + } + + if mode == .update, + let manifest = try? storage.readManifest( + folderURL: storage.temporaryFolderURL(gid: download.gid) + ), + manifest.gid == download.gid, + manifest.versionSignature == versionSignature { + return manifest.pageCount == pageCount + } + + return resumeState.pageCount == pageCount + } + + func readResumeMode(gid: String) -> DownloadStartMode? { + let folderURL = storage.temporaryFolderURL(gid: gid) + return try? storage.readResumeState(folderURL: folderURL).mode + } + + func fallbackStatus( + for download: DownloadedGallery, + mode: DownloadStartMode, + latestSignature: String? + ) -> DownloadStatus { + let comparison = DownloadSignatureBuilder.hasUpdateComparison( + remoteVersionSignature: download.remoteVersionSignature, + latestRemoteVersionSignature: latestSignature, + gid: download.gid, + token: download.token + ) + let shouldKeepUpdateBadge = mode == .update + || download.status == .updateAvailable + || comparison == .different + return shouldKeepUpdateBadge ? .updateAvailable : .completed + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift new file mode 100644 index 000000000..ed7953fb2 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -0,0 +1,119 @@ +// +// DownloadClient+Testing.swift +// EhPanda +// + +import Foundation + +#if DEBUG +extension DownloadManager { + func testingInstallActiveTask( + gid: String, + task: Task + ) { + activeGalleryID = gid + activeTask = task + } + + func testingScheduleNextIfNeeded() async { + await scheduleNextIfNeeded() + } + + func testingFetchDownload( + gid: String + ) async -> DownloadedGallery? { + await fetchDownload(gid: gid) + } + + func testingActiveGalleryID() -> String? { + activeGalleryID + } + + func testingRestoreCachedPages( + payload: DownloadRequestPayload + ) async throws -> Int { + try storage.ensureRootDirectory() + let temporaryFolderURL = storage + .temporaryFolderURL(gid: payload.gallery.gid) + try? fileManager().removeItem(at: temporaryFolderURL) + try createDirectory(at: temporaryFolderURL) + try createDirectory( + at: temporaryFolderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, + isDirectory: true + ) + ) + + let downloadContext = PageDownloadContext( + payload: payload, + source: nil, + temporaryFolderURL: temporaryFolderURL, + storedGalleryImageState: + await fetchCachedGalleryImageState( + gid: payload.gallery.gid + ) + ) + let batchResult = try await downloadPages( + context: downloadContext, + pendingPageIndices: pendingPageIndices( + payload: payload, + folderURL: temporaryFolderURL, + existingPageRelativePaths: [:] + ), + existingManifest: nil, + existingPageRelativePaths: [:] + ) + return batchResult.pages.count + } + + func testingFetchLatestPayload( + for download: DownloadedGallery, + mode: DownloadStartMode, + pageSelection: [Int]? = nil + ) async throws -> FetchLatestPayloadResult { + try await fetchLatestPayload( + for: download, + mode: mode, + pageSelection: pageSelection + ) + } + + func testingPrepareWorkingSeed( + payload: DownloadRequestPayload, + existingDownload: DownloadedGallery, + versionSignature: String + ) throws -> PrepareWorkingSeedResult { + let temporaryFolderURL = storage + .temporaryFolderURL(gid: payload.gallery.gid) + try? fileManager().removeItem(at: temporaryFolderURL) + let workingSeed = try prepareWorkingSeed( + payload: payload, + existingDownload: existingDownload, + temporaryFolderURL: temporaryFolderURL, + versionSignature: versionSignature + ) + return PrepareWorkingSeedResult( + folderURL: workingSeed.folderURL, + manifest: workingSeed.manifest, + existingPages: workingSeed.existingPages, + coverRelativePath: workingSeed.coverRelativePath + ) + } + + func testingProcessDownload(gid: String) async { + await processDownload(gid: gid) + } + + func testingDetectResponseError( + fileURL: URL, + response: URLResponse, + requestURL: URL? + ) -> AppError? { + detectResponseError( + fileURL: fileURL, + response: response, + requestURL: requestURL + ) + } +} +#endif diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index debe2d927..98627b453 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -3,12 +3,7 @@ // EhPanda // -import Kanna -import CryptoKit -import CoreData import Foundation -import ImageIO -import Kingfisher import ComposableArchitecture struct DownloadClient { @@ -83,3825 +78,55 @@ extension DownloadClient { await manager.reconcileDownloads() await manager.resumeQueue() } - return .init( - observeDownloads: { - AsyncStream { continuation in - let task = Task { - let stream = await manager.observeDownloads() - for await downloads in stream { - continuation.yield(downloads) - } - continuation.finish() - } - continuation.onTermination = { _ in - task.cancel() - } + return makeDownloadClient(manager: manager) + } + + private static func makeObserveDownloadsStream( + manager: DownloadManager + ) -> AsyncStream<[DownloadedGallery]> { + AsyncStream { continuation in + let task = Task { + let stream = await manager.observeDownloads() + for await downloads in stream { + continuation.yield(downloads) } - }, - fetchDownloads: { - await manager.fetchDownloads() - }, - fetchDownload: { gid in - await manager.fetchDownload(gid: gid) - }, - reconcileDownloads: { - await manager.reconcileDownloads() - }, - refreshDownloads: { - await manager.refreshDownloads() - }, - resumeQueue: { - await manager.resumeQueue() - }, - badges: { gids in - await manager.badges(for: gids) - }, + continuation.finish() + } + continuation.onTermination = { _ in + task.cancel() + } + } + } + + private static func makeDownloadClient( + manager: DownloadManager + ) -> Self { + .init( + observeDownloads: { makeObserveDownloadsStream(manager: manager) }, + fetchDownloads: { await manager.fetchDownloads() }, + fetchDownload: { gid in await manager.fetchDownload(gid: gid) }, + reconcileDownloads: { await manager.reconcileDownloads() }, + refreshDownloads: { await manager.refreshDownloads() }, + resumeQueue: { await manager.resumeQueue() }, + badges: { gids in await manager.badges(for: gids) }, updateRemoteSignature: { gid, signature in await manager.updateRemoteSignature(gid: gid, latestSignature: signature) }, - enqueue: { payload in - await manager.enqueue(payload: payload) - }, - togglePause: { gid in - await manager.togglePause(gid: gid) - }, - retry: { gid, mode in - await manager.retry(gid: gid, mode: mode) - }, + enqueue: { payload in await manager.enqueue(payload: payload) }, + togglePause: { gid in await manager.togglePause(gid: gid) }, + retry: { gid, mode in await manager.retry(gid: gid, mode: mode) }, retryPages: { gid, pageIndices in await manager.retryPages(gid: gid, pageIndices: pageIndices) }, - delete: { gid in - await manager.delete(gid: gid) - }, - loadManifest: { gid in - await manager.loadManifest(gid: gid) - }, - loadLocalPageURLs: { gid in - await manager.loadLocalPageURLs(gid: gid) - }, + delete: { gid in await manager.delete(gid: gid) }, + loadManifest: { gid in await manager.loadManifest(gid: gid) }, + loadLocalPageURLs: { gid in await manager.loadLocalPageURLs(gid: gid) }, captureCachedPage: { gid, index, imageURL in - await manager.captureCachedPage( - gid: gid, - index: index, - imageURL: imageURL - ) + await manager.captureCachedPage(gid: gid, index: index, imageURL: imageURL) }, - loadInspection: { gid in - await manager.loadInspection(gid: gid) - } - ) - } -} - -actor DownloadManager { - private static let retryLimit = 3 - private static let progressFlushPageInterval = 8 - private static let progressFlushMinimumInterval: TimeInterval = 0.4 - private static let responseInspectionPrefixLength = 4096 - private static let kokomadeImageByteCount = 144844 - private static let kokomadeImageSHA1 = "e48ed350e902a51581246d2a764fa7827e8e6988" - private static let kokomadeImageURLSuffixes = [ - "exhentai.org/img/kokomade.jpg" - ] - private static let quotaExceededImageByteCount = 28658 - private static let quotaExceededImageSHA1 = "f54b887b017694dc25eb1a1404f71981885f8ed9" - private static let quotaExceededImageURLSuffixes = [ - "exhentai.org/img/509.gif", - "ehgt.org/g/509.gif" - ] - - private struct PageResult: Sendable { - let index: Int - let relativePath: String - let imageURL: URL? - } - - private struct PageFailure: Error, Sendable { - let index: Int - let relativePath: String? - let error: AppError - } - - private struct DownloadBatchResult: Sendable { - let pages: [PageResult] - let failedPages: [DownloadFailedPagesSnapshot.Page] - } - - private enum PageTaskOutcome: Sendable { - case success(PageResult) - case failure(PageFailure) - case cancelled - } - - private struct RepairSeed: Sendable { - let folderURL: URL - let manifest: DownloadManifest - } - - private struct WorkingSeed: Sendable { - let folderURL: URL - let manifest: DownloadManifest? - let existingPages: [Int: String] - let coverRelativePath: String? - } - - private enum ResolvedSource: Sendable { - case normal([Int: URL]) - case mpv(String, [Int: String]) - } - - private struct ResolvedImageSource: Sendable { - let imageURL: URL - } - - private struct CachedGalleryImageState: Sendable { - let previewURLs: [Int: URL] - let imageURLs: [Int: URL] - } - - private struct PartialDownloadError: Error, Sendable { - let failedPages: [DownloadFailedPagesSnapshot.Page] - } - - private struct FailureContext: Sendable { - let gid: String - let originalDownload: DownloadedGallery - let mode: DownloadStartMode - let hadReadableFiles: Bool - let latestSignature: String? - } - - private struct PageDownloadContext: Sendable { - let payload: DownloadRequestPayload - let source: ResolvedSource? - let temporaryFolderURL: URL - let storedGalleryImageState: CachedGalleryImageState? - } - - private struct CacheRestoreSource: Sendable { - let cacheURLs: [URL?] - let referenceURL: URL? - let imageURL: URL? - } - - private let storage: DownloadFileStorage - private let urlSession: URLSession - private let persistenceContainer: NSPersistentContainer - private var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() - private var lastObservedDownloads = [DownloadedGallery]() - private var activeGalleryID: String? - private var activeTask: Task? - private var schedulingBlockedGalleryIDs = Set() - - init( - storage: DownloadFileStorage, - urlSession: URLSession, - persistenceContainer: NSPersistentContainer = PersistenceController.shared.container - ) { - self.storage = storage - self.urlSession = urlSession - self.persistenceContainer = persistenceContainer - } - - func observeDownloads() -> AsyncStream<[DownloadedGallery]> { - let identifier = UUID() - return AsyncStream { continuation in - continuation.onTermination = { [weak self] _ in - guard let self else { return } - Task { - await self.removeObserver(id: identifier) - } - } - Task { - await self.addObserver(id: identifier, continuation: continuation) - } - } - } - - func fetchDownloads() async -> [DownloadedGallery] { - sortDownloads(await fetchDownloadsFromStore()) - } - - func reconcileDownloads() async { - await syncDownloadsState(scheduleNext: false) - } - - func refreshDownloads() async { - await syncDownloadsState(scheduleNext: true) - } - - private func syncDownloadsState(scheduleNext: Bool) async { - let downloads = await fetchDownloadsFromStore() - // Normalize legacy failures before temp cleanup so recoverable working sets are not - // deleted just because older records still say `.failed`. - await normalizeNeedsAttentionDownloads(downloads) - await normalizeInterruptedDownloads(downloads) - - let normalizedDownloads = await fetchDownloadsFromStore() - do { - try storage.ensureRootDirectory() - try storage.cleanupTemporaryFolders( - preservingGIDs: Set( - normalizedDownloads.compactMap { download in - download.shouldPreserveTemporaryWorkingSet - ? download.gid - : nil - } - ) - ) - } catch { - Logger.error(error) - } - await reconcileActiveDownloadState() - await validateDownloads() - await notifyObservers() - guard scheduleNext else { return } - await scheduleNextIfNeeded() - } - - func resumeQueue() async { - await scheduleNextIfNeeded() - } - - func badges(for gids: [String]) async -> [String: DownloadBadge] { - guard !gids.isEmpty else { return [:] } - let downloads = await fetchDownloadsFromStore(gids: gids) - return Dictionary(uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) }) - } - - func updateRemoteSignature(gid: String, latestSignature: String?) async -> DownloadBadge { - guard let download = await fetchDownload(gid: gid) else { return .none } - let comparison = DownloadSignatureBuilder.hasUpdateComparison( - remoteVersionSignature: download.remoteVersionSignature, - latestRemoteVersionSignature: latestSignature, - gid: download.gid, - token: download.token - ) - let canonicalizedSignature = DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( - remoteVersionSignature: download.remoteVersionSignature, - latestRemoteVersionSignature: latestSignature, - gid: download.gid, - token: download.token - ) - var didChange = false - - do { - try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in - if download.latestRemoteVersionSignature != latestSignature { - record.latestRemoteVersionSignature = latestSignature - didChange = true - } - - if let canonicalizedSignature, - canonicalizedSignature != download.remoteVersionSignature - { - record.remoteVersionSignature = canonicalizedSignature - didChange = true - } - - guard latestSignature?.notEmpty == true, - [.completed, .updateAvailable].contains(download.status) - else { return } - - let desiredStatus: DownloadStatus? - switch comparison { - case .different: - desiredStatus = .updateAvailable - case .same: - desiredStatus = .completed - case .incomparable: - desiredStatus = nil - } - - if let desiredStatus, - desiredStatus != download.status - { - record.status = desiredStatus.rawValue - didChange = true - } - } - } catch { - Logger.error(error) - } - - if didChange { - await notifyObservers() - } - return (await fetchDownload(gid: gid))?.badge ?? .none - } - - func enqueue(payload: DownloadRequestPayload) async -> Result { - do { - try storage.ensureRootDirectory() - let versionSignature = DownloadSignatureBuilder.make( - gallery: payload.gallery, - detail: payload.galleryDetail, - host: payload.host, - previewURLs: payload.previewURLs, - versionMetadata: payload.versionMetadata - ) - let folderRelativePath = storage.makeFolderRelativePath( - gid: payload.gallery.gid, - title: payload.galleryDetail.trimmedTitle.isEmpty - ? payload.gallery.title - : payload.galleryDetail.trimmedTitle - ) - try await updateDownloadRecord(gid: payload.gallery.gid) { record in - record.gid = payload.gallery.gid - record.host = payload.host.rawValue - record.token = payload.gallery.token - record.title = payload.gallery.title - record.jpnTitle = payload.galleryDetail.jpnTitle - record.uploader = payload.galleryDetail.uploader - record.category = payload.gallery.category.rawValue - record.tags = payload.gallery.tags.toData() - record.pageCount = Int64(payload.galleryDetail.pageCount) - record.postedDate = payload.galleryDetail.postedDate - record.rating = payload.galleryDetail.rating - record.onlineCoverURL = payload.galleryDetail.coverURL ?? payload.gallery.coverURL - record.folderRelativePath = folderRelativePath - record.downloadOptionsSnapshot = payload.options.toData() - record.completedPageCount = 0 - record.lastDownloadedAt = .now - record.lastError = nil - record.latestRemoteVersionSignature = versionSignature - record.pendingOperation = nil - record.status = DownloadStatus.queued.rawValue - } - await notifyObservers() - await scheduleNextIfNeeded() - return .success(()) - } catch let error as AppError { - return .failure(error) - } catch { - Logger.error(error) - return .failure(.unknown) - } - } - - func retry(gid: String, mode: DownloadStartMode) async -> Result { - guard let download = await fetchDownload(gid: gid) else { - return .failure(.notFound) - } - do { - let resolvedMode = effectiveRetryMode(for: download, requestedMode: mode) - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let existingResumeState = fileManager().fileExists(atPath: temporaryFolderURL.path) - ? (try? storage.readResumeState(folderURL: temporaryFolderURL)) - : nil - let shouldResumeExistingWork = shouldResumeExistingWorkingSet( - for: download, - mode: resolvedMode, - resumeState: existingResumeState - ) - let shouldStartImmediately = activeTask == nil || activeGalleryID == gid - let resumedStatus: DownloadStatus - let completedPageCount: Int - let pendingOperation: DownloadStartMode? - - if shouldResumeExistingWork { - resumedStatus = shouldStartImmediately ? .downloading : .queued - completedPageCount = download.completedPageCount - pendingOperation = nil - } else if shouldStartImmediately { - resumedStatus = .downloading - completedPageCount = validatedCompletedPageCount(download) - pendingOperation = nil - } else { - resumedStatus = download.status - completedPageCount = validatedCompletedPageCount(download) - pendingOperation = resolvedMode - } - - if !shouldResumeExistingWork { - try? storage.removeTemporaryFolder(gid: gid) - } - try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in - record.status = resumedStatus.rawValue - record.completedPageCount = Int64(completedPageCount) - record.lastDownloadedAt = .now - record.lastError = nil - record.pendingOperation = pendingOperation?.rawValue - } - if fileManager().fileExists(atPath: temporaryFolderURL.path) { - let downloadOptions = download.downloadOptionsSnapshot - let versionSignature = preferredVersionSignature( - for: download, - mode: resolvedMode, - resumeState: existingResumeState - ) - let pageCount = preferredWorkingPageCount( - for: download, - mode: resolvedMode, - versionSignature: versionSignature, - resumeState: existingResumeState - ) - try? storage.writeResumeState( - .init( - mode: resolvedMode, - versionSignature: versionSignature, - pageCount: pageCount, - downloadOptions: downloadOptions - ), - folderURL: temporaryFolderURL - ) - } - await notifyObservers() - await scheduleNextIfNeeded() - return .success(()) - } catch let error as AppError { - return .failure(error) - } catch { - Logger.error(error) - return .failure(.unknown) - } - } - - func retryPages(gid: String, pageIndices: [Int]) async -> Result { - guard let download = await fetchDownload(gid: gid) else { - return .failure(.notFound) - } - - let mode = resumeMode(for: download) - if mode == .update { - return await retry(gid: gid, mode: .update) - } - - let selectedPageIndices = Array(Set(pageIndices)).sorted() - guard !selectedPageIndices.isEmpty else { return .success(()) } - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - guard fileManager().fileExists(atPath: temporaryFolderURL.path) else { - return .failure(.notFound) - } - - let existingResumeState = try? storage.readResumeState(folderURL: temporaryFolderURL) - let versionSignature = preferredVersionSignature( - for: download, - mode: mode, - resumeState: existingResumeState - ) - let pageCount = preferredWorkingPageCount( - for: download, - mode: mode, - versionSignature: versionSignature, - resumeState: existingResumeState - ) - let resumedStatus: DownloadStatus = activeTask == nil || activeGalleryID == gid - ? .downloading - : .queued - - do { - if let failedSnapshot = try? storage.readFailedPages(folderURL: temporaryFolderURL) { - let remainingPages = failedSnapshot.pages.filter { !selectedPageIndices.contains($0.index) } - if remainingPages.isEmpty { - try? storage.removeFailedPages(folderURL: temporaryFolderURL) - } else { - try storage.writeFailedPages(.init(pages: remainingPages), folderURL: temporaryFolderURL) - } - } - try storage.writeResumeState( - .init( - mode: mode, - versionSignature: versionSignature, - pageCount: pageCount, - downloadOptions: download.downloadOptionsSnapshot, - pageSelection: selectedPageIndices - ), - folderURL: temporaryFolderURL - ) - try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in - record.status = resumedStatus.rawValue - record.lastDownloadedAt = .now - record.lastError = nil - record.pendingOperation = nil - } - await notifyObservers() - await scheduleNextIfNeeded() - return .success(()) - } catch let error as AppError { - return .failure(error) - } catch { - Logger.error(error) - return .failure(.unknown) - } - } - - func togglePause(gid: String) async -> Result { - guard let download = await fetchDownload(gid: gid) else { - return .failure(.notFound) - } - - if let pendingMode = download.pendingOperation { - return await cancelQueuedWorkItem(download, mode: pendingMode) - } - - switch download.status { - case .queued, .downloading: - return await pause(gid: gid) - case .paused: - return await resume(gid: gid) - case .partial, .completed, .failed, .updateAvailable, .missingFiles: - return .failure(.unknown) - } - } - - func delete(gid: String) async -> Result { - let taskToCancel: Task? - schedulingBlockedGalleryIDs.insert(gid) - defer { - schedulingBlockedGalleryIDs.remove(gid) - } - if activeGalleryID == gid { - taskToCancel = activeTask - activeTask?.cancel() - activeTask = nil - activeGalleryID = nil - } else { - taskToCancel = nil - } - await taskToCancel?.value - guard let download = await fetchDownload(gid: gid) else { - return .failure(.notFound) - } - do { - try? storage.removeTemporaryFolder(gid: gid) - try storage.removeFolder(relativePath: download.folderRelativePath) - try await deleteDownloadRecord(gid: gid) - await notifyObservers() - await scheduleNextIfNeeded() - return .success(()) - } catch let error as AppError { - return .failure(error) - } catch { - Logger.error(error) - return .failure(.fileOperationFailed(error.localizedDescription)) - } - } - - func loadManifest(gid: String) async -> Result<(DownloadedGallery, DownloadManifest), AppError> { - let sanitizedDownload = await sanitizeLocalFilesIfNeeded(gid: gid) - let resolvedDownload: DownloadedGallery? - if let sanitizedDownload { - resolvedDownload = sanitizedDownload - } else { - resolvedDownload = await fetchDownload(gid: gid) - } - guard let download = resolvedDownload, - let folderURL = download.resolvedFolderURL(rootURL: storage.rootURL) - else { - return .failure(.notFound) - } - switch storage.validate(download: download) { - case .valid: - break - case .missingFiles(let message): - return .failure(.fileOperationFailed(message)) - } - do { - let manifest = try storage.readManifest(folderURL: folderURL) - return .success((download, manifest)) - } catch { - return .failure(.fileOperationFailed(error.localizedDescription)) - } - } - - func loadLocalPageURLs(gid: String) async -> Result<[Int: URL], AppError> { - let sanitizedDownload = await sanitizeLocalFilesIfNeeded(gid: gid) - let resolvedDownload: DownloadedGallery? - if let sanitizedDownload { - resolvedDownload = sanitizedDownload - } else { - resolvedDownload = await fetchDownload(gid: gid) - } - guard let download = resolvedDownload else { - return .failure(.notFound) - } - - let completedFolderURL = download.resolvedFolderURL(rootURL: storage.rootURL) - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let hasTemporaryFolder = fileManager().fileExists(atPath: temporaryFolderURL.path) - let shouldExposeTemporaryWorkingSet = hasTemporaryFolder - && self.shouldExposeTemporaryWorkingSet(for: download) - let completedValidation = storage.validate(download: download) - - let completedPageRelativePaths = completedFolderURL.map { - storage.existingPageRelativePaths( - folderURL: $0, - expectedPageCount: download.pageCount - ) - } ?? [:] - let temporaryPageRelativePaths = hasTemporaryFolder - ? storage.existingPageRelativePaths( - folderURL: temporaryFolderURL, - expectedPageCount: download.pageCount - ) - : [:] - - let completedPageURLs = completedPageRelativePaths.reduce(into: [Int: URL]()) { result, entry in - guard let folderURL = completedFolderURL else { return } - result[entry.key] = folderURL.appendingPathComponent(entry.value) - } - let temporaryPageURLs = temporaryPageRelativePaths.reduce(into: [Int: URL]()) { result, entry in - result[entry.key] = temporaryFolderURL.appendingPathComponent(entry.value) - } - - if completedValidation == .valid, - let completedFolderURL, - fileManager().fileExists(atPath: completedFolderURL.path), - let manifest = try? storage.readManifest(folderURL: completedFolderURL) - { - let completedManifestPageURLs = manifest.imageURLs(folderURL: completedFolderURL) - guard shouldExposeTemporaryWorkingSet else { - return .success(completedManifestPageURLs) - } - return .success( - completedManifestPageURLs.merging( - temporaryPageURLs, - uniquingKeysWith: { _, temporary in temporary } - ) - ) - } - - guard shouldExposeTemporaryWorkingSet else { - return .success(completedPageURLs) - } - - if !completedPageURLs.isEmpty, !temporaryPageURLs.isEmpty { - return .success( - completedPageURLs.merging( - temporaryPageURLs, - uniquingKeysWith: { _, temporary in temporary } - ) - ) - } - - if !temporaryPageURLs.isEmpty { - return .success(temporaryPageURLs) - } - - return .success(completedPageURLs) - } - - func captureCachedPage( - gid: String, - index: Int, - imageURL: URL? - ) async { - guard let download = await fetchDownload(gid: gid), - index >= 1, - index <= max(download.pageCount, 1) - else { - return - } - - guard let captureTarget = captureTarget( - for: download, - index: index - ) else { - return - } - - let existingPages = storage.existingPageRelativePaths( - folderURL: captureTarget.folderURL, - expectedPageCount: download.pageCount - ) - do { - let cacheURLs = pageImageCacheURLs(imageURL: imageURL) - let cacheSource = CacheRestoreSource( - cacheURLs: cacheURLs, - referenceURL: preferredPageReferenceURL(imageURL: imageURL), - imageURL: imageURL - ) - guard let pageResult = try await restorePageFromCache( - index: index, - source: cacheSource, - folderURL: captureTarget.folderURL, - preferredRelativePath: captureTarget.preferredRelativePath ?? existingPages[index], - overwriteExistingFile: true - ) else { - return - } - - await persistResolvedImageURLs( - gid: gid, - index: index, - imageURL: pageResult.imageURL - ) - if captureTarget.isTemporary { - try clearFailedPage(index: index, folderURL: captureTarget.folderURL) - } - _ = await sanitizeLocalFilesIfNeeded(gid: gid, clearingLastError: true) - } catch { - Logger.error(error) - } - } - - func loadInspection(gid: String) async -> Result { - guard let download = await fetchDownload(gid: gid) else { - return .failure(.notFound) - } - - let activeFolderURL = activeInspectionFolderURL(for: download) - - let existingRelativePaths = activeFolderURL.map { - storage.existingPageRelativePaths(folderURL: $0, expectedPageCount: download.pageCount) - } ?? [:] - let failedPages = activeFolderURL.map(sanitizedFailedPages(folderURL:)) ?? [:] - - let pages = (1...download.pageCount).map { index -> DownloadPageInspection in - if let relativePath = existingRelativePaths[index], let folderURL = activeFolderURL { - let fileURL = folderURL.appendingPathComponent(relativePath) - if fileManager().fileExists(atPath: fileURL.path) { - return .init( - index: index, - status: .downloaded, - relativePath: relativePath, - fileURL: fileURL, - failure: nil - ) - } - } - - if let failedPage = failedPages[index] { - return .init( - index: index, - status: .failed, - relativePath: failedPage.relativePath, - fileURL: nil, - failure: failedPage.failure - ) - } - - return .init( - index: index, - status: .pending, - relativePath: nil, - fileURL: nil, - failure: nil - ) - } - - let coverURL = activeFolderURL.flatMap { folderURL in - storage.existingCoverRelativePath(folderURL: folderURL).map { - folderURL.appendingPathComponent($0) - } - } ?? download.coverURL - - return .success( - .init( - download: download, - coverURL: coverURL, - pages: pages - ) - ) - } - - private func addObserver(id: UUID, continuation: AsyncStream<[DownloadedGallery]>.Continuation) async { - observers[id] = continuation - let downloads = await fetchDownloads() - lastObservedDownloads = downloads - continuation.yield(downloads) - } - - private func removeObserver(id: UUID) { - observers[id] = nil - } - - private func notifyObservers() async { - let downloads = await fetchDownloads() - guard downloads != lastObservedDownloads else { return } - lastObservedDownloads = downloads - observers.values.forEach { $0.yield(downloads) } - } - - private func scheduleNextIfNeeded() async { - guard activeTask == nil else { - await reconcileActiveDownloadState() - return - } - let downloads = await fetchDownloadsFromStore() - let nextDownload = downloads - .filter { - !schedulingBlockedGalleryIDs.contains($0.gid) - && shouldSchedule(download: $0) - } - .sorted { lhs, rhs in - let lhsIsDownloading = lhs.status == .downloading - let rhsIsDownloading = rhs.status == .downloading - if lhsIsDownloading != rhsIsDownloading { - return lhsIsDownloading - } - return (lhs.lastDownloadedAt ?? .distantPast) < (rhs.lastDownloadedAt ?? .distantPast) - } - .first - guard let nextDownload else { return } - - activeGalleryID = nextDownload.gid - activeTask = Task { [weak self] in - guard let self else { return } - await self.processDownload(gid: nextDownload.gid) - } - } - - private func shouldSchedule(download: DownloadedGallery) -> Bool { - if download.status == .downloading || download.isQueuedWorkItem { - return true - } - - guard download.status == .partial else { - return false - } - - let temporaryFolderURL = storage.temporaryFolderURL(gid: download.gid) - guard let resumeState = try? storage.readResumeState(folderURL: temporaryFolderURL), - let pageSelection = resumeState.pageSelection - else { - return false - } - return !pageSelection.isEmpty - } - - private func processDownload(gid: String) async { - defer { - activeTask = nil - activeGalleryID = nil - Task { - await self.scheduleNextIfNeeded() - } - } - - guard let download = await fetchDownload(gid: gid) else { return } - let mode = queuedMode(for: download) - let previousFolderRelativePath = download.folderRelativePath - let hadReadableFiles = storage.validate(download: download) == .valid - var fetchedVersionSignature: String? - - do { - try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in - record.status = DownloadStatus.downloading.rawValue - record.completedPageCount = Int64(download.completedPageCount) - record.lastError = nil - record.pendingOperation = nil - } - await notifyObservers() - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let existingResumeState = try? storage.readResumeState(folderURL: temporaryFolderURL) - let rawPageSelection = existingResumeState?.pageSelection - let (fetchedPayload, versionSignature) = try await fetchLatestPayload( - for: download, - mode: mode, - pageSelection: rawPageSelection - ) - fetchedVersionSignature = versionSignature - let payload = normalizeFetchedPayload( - fetchedPayload, - mode: mode, - versionSignature: versionSignature, - existingResumeState: existingResumeState, - rawPageSelection: rawPageSelection - ) - let folderRelativePath = storage.makeFolderRelativePath( - gid: payload.gallery.gid, - title: payload.galleryDetail.trimmedTitle.isEmpty - ? payload.gallery.title - : payload.galleryDetail.trimmedTitle - ) - let downloadResult = try await performDownload( - payload: payload, - versionSignature: versionSignature, - folderRelativePath: folderRelativePath, - existingDownload: download - ) - - guard !Task.isCancelled else { return } - - try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in - record.host = payload.host.rawValue - record.token = payload.gallery.token - record.title = payload.gallery.title - record.jpnTitle = payload.galleryDetail.jpnTitle - record.uploader = payload.galleryDetail.uploader - record.category = payload.gallery.category.rawValue - record.tags = payload.gallery.tags.toData() - record.pageCount = Int64(payload.galleryDetail.pageCount) - record.postedDate = payload.galleryDetail.postedDate - record.rating = payload.galleryDetail.rating - record.onlineCoverURL = payload.galleryDetail.coverURL ?? payload.gallery.coverURL - record.folderRelativePath = folderRelativePath - record.coverRelativePath = downloadResult.coverRelativePath - record.downloadOptionsSnapshot = payload.options.toData() - record.completedPageCount = Int64(payload.galleryDetail.pageCount) - record.lastDownloadedAt = .now - record.lastError = nil - record.remoteVersionSignature = versionSignature - record.latestRemoteVersionSignature = versionSignature - record.pendingOperation = nil - record.status = DownloadStatus.completed.rawValue - } - if previousFolderRelativePath != folderRelativePath { - try? storage.removeFolder(relativePath: previousFolderRelativePath) - } - await notifyObservers() - } catch is CancellationError { - return - } catch let error as AppError { - guard !isCancellationLikeAppError(error) else { return } - guard !shouldSuppressFailurePersistence(for: gid) else { return } - Logger.error( - "Download failed.", - context: [ - "gid": gid, - "mode": mode.rawValue, - "error": error.localizedDescription - ] - ) - let failureContext = FailureContext( - gid: gid, - originalDownload: download, - mode: mode, - hadReadableFiles: hadReadableFiles, - latestSignature: fetchedVersionSignature - ) - await persistFailure(error: error, context: failureContext) - await notifyObservers() - } catch let error as PartialDownloadError { - let pageError = error.failedPages.first?.failure.appError ?? .unknown - guard !isCancellationLikeAppError(pageError) else { return } - guard !shouldSuppressFailurePersistence(for: gid) else { return } - Logger.error( - "Download partially failed.", - context: [ - "gid": gid, - "mode": mode.rawValue, - "failedPages": error.failedPages.map(\.index) - ] - ) - let failureContext = FailureContext( - gid: gid, - originalDownload: download, - mode: mode, - hadReadableFiles: hadReadableFiles, - latestSignature: fetchedVersionSignature - ) - await persistFailure(error: pageError, context: failureContext) - await notifyObservers() - } catch { - let appError = AppError.fileOperationFailed(error.localizedDescription) - guard !isCancellationLikeAppError(appError) else { return } - guard !shouldSuppressFailurePersistence(for: gid) else { return } - Logger.error(error) - let failureContext = FailureContext( - gid: gid, - originalDownload: download, - mode: mode, - hadReadableFiles: hadReadableFiles, - latestSignature: fetchedVersionSignature - ) - await persistFailure(error: appError, context: failureContext) - await notifyObservers() - } - } - - private func queuedMode(for download: DownloadedGallery) -> DownloadStartMode { - if let pendingOperation = download.pendingOperation { - return pendingOperation - } - switch download.status { - case .missingFiles: - return effectiveRetryMode(for: download, requestedMode: .repair) - case .updateAvailable: - return .update - case .partial: - return resumeMode(for: download) - case .completed: - return effectiveRetryMode(for: download, requestedMode: .redownload) - case .failed: - return effectiveRetryMode( - for: download, - requestedMode: download.remoteVersionSignature.isEmpty ? .initial : .redownload - ) - case .paused: - return resumeMode(for: download) - case .queued, .downloading: - return readResumeMode(gid: download.gid) - ?? effectiveRetryMode( - for: download, - requestedMode: download.remoteVersionSignature.isEmpty ? .initial : .redownload - ) - } - } - - private func pause(gid: String) async -> Result { - let taskToCancel: Task? - do { - schedulingBlockedGalleryIDs.insert(gid) - defer { - schedulingBlockedGalleryIDs.remove(gid) - } - guard let currentDownload = await fetchDownload(gid: gid) else { - return .failure(.notFound) - } - guard [.queued, .downloading].contains(currentDownload.status) else { - await notifyObservers() - await scheduleNextIfNeeded() - return .success(()) - } - - let initialCompletedPageCount = max( - currentDownload.completedPageCount, - temporaryCompletedPageCount( - gid: gid, - expectedPageCount: max(currentDownload.pageCount, 1) - ) - ) - try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in - record.status = DownloadStatus.paused.rawValue - record.completedPageCount = Int64(initialCompletedPageCount) - record.lastError = nil - record.lastDownloadedAt = .now - } - await notifyObservers() - - if activeGalleryID == gid { - taskToCancel = activeTask - activeTask?.cancel() - activeTask = nil - activeGalleryID = nil - } else { - taskToCancel = nil - } - await taskToCancel?.value - let settledCompletedPageCount = max( - currentDownload.completedPageCount, - temporaryCompletedPageCount( - gid: gid, - expectedPageCount: max(currentDownload.pageCount, 1) - ) - ) - try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in - record.status = DownloadStatus.paused.rawValue - record.completedPageCount = Int64(settledCompletedPageCount) - record.lastError = nil - record.lastDownloadedAt = .now - } - await notifyObservers() - await scheduleNextIfNeeded() - return .success(()) - } catch let error as AppError { - return .failure(error) - } catch { - Logger.error(error) - return .failure(.unknown) - } - } - - private func cancelQueuedWorkItem( - _ download: DownloadedGallery, - mode: DownloadStartMode - ) async -> Result { - switch mode { - case .initial: - return await pause(gid: download.gid) - case .redownload, .update, .repair: - break - } - - let restoredStatus = download.status - let restoredCompletedPageCount = validatedCompletedPageCount(download) - do { - try await updateDownloadRecord(gid: download.gid, createIfMissing: false) { record in - record.status = restoredStatus.rawValue - record.completedPageCount = Int64(restoredCompletedPageCount) - record.lastDownloadedAt = .now - record.pendingOperation = nil - } - await notifyObservers() - return .success(()) - } catch let error as AppError { - return .failure(error) - } catch { - Logger.error(error) - return .failure(.unknown) - } - } - - private func resume(gid: String) async -> Result { - guard await fetchDownload(gid: gid) != nil else { - return .failure(.notFound) - } - - do { - // If another gallery is already active, keep this task in the queue and let the - // temporary resume state decide whether it resumes an update/redownload/repair later. - let resumedStatus: DownloadStatus = activeTask == nil ? .downloading : .queued - try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in - record.status = resumedStatus.rawValue - record.lastError = nil - record.lastDownloadedAt = .now - record.pendingOperation = nil - } - await notifyObservers() - await scheduleNextIfNeeded() - return .success(()) - } catch let error as AppError { - return .failure(error) - } catch { - Logger.error(error) - return .failure(.unknown) - } - } - - private func resumeMode(for download: DownloadedGallery) -> DownloadStartMode { - if download.remoteVersionSignature.isEmpty { - return .initial - } - if download.hasUpdate { - return .update - } - if let mode = readResumeMode(gid: download.gid) { - return effectiveRetryMode(for: download, requestedMode: mode) - } - if download.status == .partial { - return effectiveRetryMode( - for: download, - requestedMode: download.remoteVersionSignature.isEmpty ? .initial : .redownload - ) - } - if case .missingFiles = storage.validate(download: download) { - return .repair - } - return .redownload - } - - private func effectiveRetryMode( - for download: DownloadedGallery, - requestedMode: DownloadStartMode - ) -> DownloadStartMode { - guard requestedMode != .initial, download.hasUpdate else { - return requestedMode - } - return .update - } - - private func preferredVersionSignature( - for download: DownloadedGallery, - mode: DownloadStartMode, - resumeState: DownloadResumeState? - ) -> String { - switch mode { - case .update: - if let latestSignature = download.latestRemoteVersionSignature, - latestSignature.notEmpty - { - return latestSignature - } - case .initial, .redownload, .repair: - break - } - - if let resumeState, - resumeState.versionSignature.notEmpty - { - return resumeState.versionSignature - } - - if download.remoteVersionSignature.notEmpty { - return download.remoteVersionSignature - } - - return download.latestRemoteVersionSignature ?? "" - } - - private func preferredWorkingPageCount( - for download: DownloadedGallery, - mode: DownloadStartMode, - versionSignature: String, - resumeState: DownloadResumeState? - ) -> Int { - guard mode == .update else { - return download.pageCount - } - - let temporaryFolderURL = storage.temporaryFolderURL(gid: download.gid) - guard fileManager().fileExists(atPath: temporaryFolderURL.path) else { - return download.pageCount - } - - if let manifest = try? storage.readManifest(folderURL: temporaryFolderURL), - manifest.gid == download.gid, - manifest.versionSignature == versionSignature - { - return manifest.pageCount - } - - if let resumeState, - resumeState.versionSignature == versionSignature - { - return resumeState.pageCount - } - - return download.pageCount - } - - private func shouldResumeExistingWorkingSet( - for download: DownloadedGallery, - mode: DownloadStartMode, - resumeState: DownloadResumeState? - ) -> Bool { - guard download.status == .failed || storage.temporaryFolderExists(gid: download.gid), - let resumeState - else { - return false - } - - let versionSignature = preferredVersionSignature( - for: download, - mode: mode, - resumeState: resumeState - ) - let pageCount = preferredWorkingPageCount( - for: download, - mode: mode, - versionSignature: versionSignature, - resumeState: resumeState - ) - - guard resumeState.mode == mode, - resumeState.versionSignature == versionSignature, - resumeState.downloadOptions == download.downloadOptionsSnapshot - else { - return false - } - - if mode == .update, - let manifest = try? storage.readManifest( - folderURL: storage.temporaryFolderURL(gid: download.gid) - ), - manifest.gid == download.gid, - manifest.versionSignature == versionSignature - { - return manifest.pageCount == pageCount - } - - return resumeState.pageCount == pageCount - } - - private func readResumeMode(gid: String) -> DownloadStartMode? { - let folderURL = storage.temporaryFolderURL(gid: gid) - return try? storage.readResumeState(folderURL: folderURL).mode - } - - private func fallbackStatus( - for download: DownloadedGallery, - mode: DownloadStartMode, - latestSignature: String? - ) -> DownloadStatus { - let comparison = DownloadSignatureBuilder.hasUpdateComparison( - remoteVersionSignature: download.remoteVersionSignature, - latestRemoteVersionSignature: latestSignature, - gid: download.gid, - token: download.token - ) - let shouldKeepUpdateBadge = mode == .update - || download.status == .updateAvailable - || comparison == .different - return shouldKeepUpdateBadge ? .updateAvailable : .completed - } - - private func persistFailure( - error: AppError, - context: FailureContext - ) async { - let workingCompletedPageCount = temporaryCompletedPageCount( - gid: context.gid, - expectedPageCount: context.originalDownload.pageCount - ) - let hasTemporaryWorkingSet = storage.temporaryFolderExists(gid: context.gid) - let recoveredCompletedPageCount = hasTemporaryWorkingSet - ? workingCompletedPageCount - : max(context.originalDownload.completedPageCount, workingCompletedPageCount) - do { - try await updateDownloadRecord(gid: context.gid, createIfMissing: false) { record in - record.lastError = DownloadFailure(error: error).toData() - record.pendingOperation = nil - if context.mode == .repair { - record.status = DownloadStatus.missingFiles.rawValue - record.completedPageCount = Int64(context.originalDownload.completedPageCount) - record.folderRelativePath = context.originalDownload.folderRelativePath - record.coverRelativePath = context.originalDownload.coverRelativePath - record.remoteVersionSignature = context.originalDownload.remoteVersionSignature - record.latestRemoteVersionSignature = context.latestSignature - ?? context.originalDownload.latestRemoteVersionSignature - } else if context.hadReadableFiles, [.update, .redownload].contains(context.mode) { - record.status = self.fallbackStatus( - for: context.originalDownload, - mode: context.mode, - latestSignature: context.latestSignature - ) - .rawValue - record.completedPageCount = Int64(context.originalDownload.pageCount) - record.folderRelativePath = context.originalDownload.folderRelativePath - record.coverRelativePath = context.originalDownload.coverRelativePath - record.remoteVersionSignature = context.originalDownload.remoteVersionSignature - record.latestRemoteVersionSignature = context.latestSignature - ?? context.originalDownload.latestRemoteVersionSignature - } else if workingCompletedPageCount > 0 { - record.status = DownloadStatus.partial.rawValue - record.completedPageCount = Int64(workingCompletedPageCount) - record.latestRemoteVersionSignature = context.latestSignature - ?? context.originalDownload.latestRemoteVersionSignature - } else { - record.status = DownloadStatus.partial.rawValue - record.completedPageCount = Int64(recoveredCompletedPageCount) - record.latestRemoteVersionSignature = context.latestSignature - ?? context.originalDownload.latestRemoteVersionSignature - } - } - } catch { - Logger.error(error) - } - } - - private func fetchLatestPayload( - for download: DownloadedGallery, - mode: DownloadStartMode, - pageSelection: [Int]? - ) async throws -> (DownloadRequestPayload, String) { - let galleryURL = download.gallery.galleryURL - guard let galleryURL else { throw AppError.notFound } - let (detail, galleryState) = try await withRetry( - operation: "fetchLatestPayload", - context: [ - "gid": download.gid, - "mode": mode.rawValue, - "galleryURL": galleryURL.absoluteString - ] - ) { - let doc = try await htmlDocument( - url: URLUtil.galleryDetail(url: galleryURL), - allowsCellular: download.downloadOptionsSnapshot.allowCellular, - retriesRequest: false - ) - return try Parser.parseGalleryDetail(doc: doc, gid: download.gid) - } - let gallery = Gallery( - gid: download.gid, - token: download.token, - title: detail.title, - rating: detail.rating, - tags: galleryState.tags, - category: detail.category, - uploader: detail.uploader, - pageCount: detail.pageCount, - postedDate: detail.postedDate, - coverURL: detail.coverURL ?? download.onlineCoverURL, - galleryURL: galleryURL - ) - let previewConfig = galleryState.previewConfig ?? .normal(rows: 4) - let previewURLs = galleryState.previewURLs - let versionMetadata: DownloadVersionMetadata? - switch await GalleryVersionMetadataRequest(gid: download.gid, token: download.token).response() { - case .success(let metadata): - versionMetadata = metadata - case .failure: - versionMetadata = nil - } - let versionSignature = DownloadSignatureBuilder.make( - gallery: gallery, - detail: detail, - host: download.host, - previewURLs: previewURLs, - versionMetadata: versionMetadata - ) - return ( - .init( - gallery: gallery, - galleryDetail: detail, - previewURLs: previewURLs, - previewConfig: previewConfig, - host: download.host, - versionMetadata: versionMetadata, - options: download.downloadOptionsSnapshot, - mode: mode, - pageSelection: pageSelection.map(Set.init) - ), - versionSignature - ) - } - - private func normalizeFetchedPayload( - _ payload: DownloadRequestPayload, - mode: DownloadStartMode, - versionSignature: String, - existingResumeState: DownloadResumeState?, - rawPageSelection: [Int]? - ) -> DownloadRequestPayload { - let shouldPreservePageSelection = rawPageSelection?.isEmpty == false - && existingResumeState?.matches( - mode: mode, - versionSignature: versionSignature, - pageCount: payload.galleryDetail.pageCount, - downloadOptions: payload.options - ) == true - && mode != .update - - guard !shouldPreservePageSelection else { - return payload - } - - return .init( - gallery: payload.gallery, - galleryDetail: payload.galleryDetail, - previewURLs: payload.previewURLs, - previewConfig: payload.previewConfig, - host: payload.host, - versionMetadata: payload.versionMetadata, - options: payload.options, - mode: payload.mode, - pageSelection: nil - ) - } - - private func performDownload( - payload: DownloadRequestPayload, - versionSignature: String, - folderRelativePath: String, - existingDownload: DownloadedGallery - ) async throws -> (coverRelativePath: String?, pages: [PageResult]) { - try storage.ensureRootDirectory() - - let temporaryFolderURL = storage.temporaryFolderURL(gid: payload.gallery.gid) - let workingSeed = try prepareWorkingSeed( - payload: payload, - existingDownload: existingDownload, - temporaryFolderURL: temporaryFolderURL, - versionSignature: versionSignature - ) - let pendingPageIndices = pendingPageIndices( - payload: payload, - folderURL: temporaryFolderURL, - existingPageRelativePaths: workingSeed.existingPages - ) - try storage.writeResumeState( - .init( - mode: payload.mode, - versionSignature: versionSignature, - pageCount: payload.galleryDetail.pageCount, - downloadOptions: payload.options, - pageSelection: payload.pageSelection?.sorted() - ), - folderURL: temporaryFolderURL - ) - - do { - let storedGalleryImageState = await fetchCachedGalleryImageState(gid: payload.gallery.gid) - let coverRelativePath = try await downloadCoverImage( - payload: payload, - temporaryFolderURL: temporaryFolderURL, - existingCoverRelativePath: workingSeed.coverRelativePath - ) - if coverRelativePath != existingDownload.coverRelativePath { - try? await updateDownloadRecord( - gid: payload.gallery.gid, - createIfMissing: false - ) { record in - record.coverRelativePath = coverRelativePath - } - } - let canSatisfyPendingPagesFromCache = await canSatisfyPendingPageDownloadsFromCache( - pendingPageIndices: pendingPageIndices, - temporaryFolderURL: temporaryFolderURL, - existingPageRelativePaths: workingSeed.existingPages, - storedGalleryImageState: storedGalleryImageState - ) - let source: ResolvedSource? - if pendingPageIndices.isEmpty || canSatisfyPendingPagesFromCache { - source = nil - } else { - source = try await resolveSource( - payload: payload, - requiredPageIndices: pendingPageIndices - ) - } - let downloadContext = PageDownloadContext( - payload: payload, - source: source, - temporaryFolderURL: temporaryFolderURL, - storedGalleryImageState: storedGalleryImageState - ) - let batchResult = try await downloadPages( - context: downloadContext, - pendingPageIndices: pendingPageIndices, - existingManifest: workingSeed.manifest, - existingPageRelativePaths: workingSeed.existingPages - ) - if payload.pageSelection != nil { - try? storage.writeResumeState( - .init( - mode: payload.mode, - versionSignature: versionSignature, - pageCount: payload.galleryDetail.pageCount, - downloadOptions: payload.options - ), - folderURL: temporaryFolderURL - ) - } - if !batchResult.failedPages.isEmpty { - throw PartialDownloadError(failedPages: batchResult.failedPages) - } - - let manifest = DownloadManifest( - gid: payload.gallery.gid, - host: payload.host, - token: payload.gallery.token, - title: payload.gallery.title, - jpnTitle: payload.galleryDetail.jpnTitle, - category: payload.gallery.category, - language: payload.galleryDetail.language, - uploader: payload.galleryDetail.uploader, - tags: payload.gallery.tags, - postedDate: payload.galleryDetail.postedDate, - pageCount: payload.galleryDetail.pageCount, - coverRelativePath: coverRelativePath, - galleryURL: payload.gallery.galleryURL.forceUnwrapped, - rating: payload.galleryDetail.rating, - downloadOptions: payload.options, - versionSignature: versionSignature, - downloadedAt: .now, - pages: batchResult.pages - .sorted(by: { $0.index < $1.index }) - .map { .init(index: $0.index, relativePath: $0.relativePath) } - ) - try storage.writeManifest(manifest, folderURL: temporaryFolderURL) - try? storage.removeFailedPages(folderURL: temporaryFolderURL) - try storage.replaceFolder( - relativePath: folderRelativePath, - with: temporaryFolderURL - ) - cleanupCachedRemoteAssetsAfterSuccessfulDownload( - payload: payload, - storedGalleryImageState: storedGalleryImageState, - pages: batchResult.pages, - existingDownload: existingDownload - ) - return (coverRelativePath, batchResult.pages) - } catch is CancellationError { - throw CancellationError() - } catch { - throw error - } - } - - private func downloadCoverImage( - payload: DownloadRequestPayload, - temporaryFolderURL: URL, - existingCoverRelativePath: String? - ) async throws -> String? { - if let coverRelativePath = existingCoverRelativePath, - !coverRelativePath.isEmpty - { - let localCoverURL = temporaryFolderURL.appendingPathComponent(coverRelativePath) - if fileManager().fileExists(atPath: localCoverURL.path) { - return coverRelativePath - } - } - guard let coverURL = payload.galleryDetail.coverURL ?? payload.gallery.coverURL else { - return nil - } - if let cachedData = await validatedCachedAssetData(for: [coverURL]) { - let fileExtension = fileExtension(for: coverURL, response: nil, prefixData: cachedData) - let relativePath = storage.makeCoverRelativePath(fileExtension: fileExtension) - let fileURL = temporaryFolderURL.appendingPathComponent(relativePath) - try write(data: cachedData, to: fileURL) - return relativePath - } - let (downloadedFileURL, response) = try await downloadResponse( - url: coverURL, - allowsCellular: payload.options.allowCellular - ) - let prefixData = try readResponsePrefixData(at: downloadedFileURL) - let fileExtension = fileExtension( - for: coverURL, - response: response, - prefixData: prefixData - ) - let relativePath = storage.makeCoverRelativePath(fileExtension: fileExtension) - let fileURL = temporaryFolderURL.appendingPathComponent(relativePath) - try moveDownloadedFile(from: downloadedFileURL, to: fileURL) - return relativePath - } - - private func cleanupCachedRemoteAssetsAfterSuccessfulDownload( - payload: DownloadRequestPayload, - storedGalleryImageState: CachedGalleryImageState?, - pages: [PageResult], - existingDownload: DownloadedGallery - ) { - let previewURLs = ( - Array(payload.previewURLs.values) - + (storedGalleryImageState.map { Array($0.previewURLs.values) } ?? []) - ) - .flatMap { $0.previewCacheCleanupURLs() } - let pageURLs = pages.compactMap(\.imageURL) - + (storedGalleryImageState.map { Array($0.imageURLs.values) } ?? []) - let coverURLs = [ - payload.galleryDetail.coverURL, - payload.gallery.coverURL, - existingDownload.onlineCoverURL - ] - .compactMap(\.self) - - let urls = Array(Set(previewURLs + pageURLs + coverURLs)).map(Optional.some) - removeCachedImages(for: urls, includeStableAlias: true) - } - - private func resolveSource( - payload: DownloadRequestPayload, - requiredPageIndices: [Int] - ) async throws -> ResolvedSource { - let requiredPageNumbers = Array( - Set(requiredPageIndices.map { payload.previewConfig.pageNumber(index: $0) }) - ) - .sorted() - var thumbnailURLs = [Int: URL]() - for pageNumber in requiredPageNumbers { - let pageURLs = try await fetchThumbnailURLs( - galleryURL: payload.gallery.galleryURL.forceUnwrapped, - pageNum: pageNumber, - allowsCellular: payload.options.allowCellular - ) - thumbnailURLs.merge(pageURLs, uniquingKeysWith: { _, new in new }) - } - guard let firstURL = requiredPageIndices.lazy.compactMap({ thumbnailURLs[$0] }).first - ?? thumbnailURLs.values.first - else { - throw AppError.notFound - } - if firstURL.pathComponents.count > 1, firstURL.pathComponents[1] == "mpv" { - let (mpvKey, imageKeys) = try await fetchMPVKeys( - mpvURL: firstURL, - allowsCellular: payload.options.allowCellular - ) - return .mpv(mpvKey, imageKeys) - } else { - return .normal(thumbnailURLs) - } - } - - private func downloadPages( - context: PageDownloadContext, - pendingPageIndices: [Int], - existingManifest: DownloadManifest?, - existingPageRelativePaths: [Int: String] - ) async throws -> DownloadBatchResult { - let manifestPages = Dictionary( - uniqueKeysWithValues: (existingManifest?.pages ?? []).map { ($0.index, $0.relativePath) } - ) - let existingPages = manifestPages.merging( - existingPageRelativePaths, - uniquingKeysWith: { manifestPath, _ in manifestPath } - ) - let payload = context.payload - let temporaryFolderURL = context.temporaryFolderURL - var failedPages = (try? storage.readFailedPages(folderURL: temporaryFolderURL).map) ?? [:] - let pageIndices = Array(1...payload.galleryDetail.pageCount) - var results = [PageResult]() - for index in pageIndices { - guard let relativePath = existingPages[index] else { continue } - let fileURL = temporaryFolderURL.appendingPathComponent(relativePath) - guard fileManager().fileExists(atPath: fileURL.path) else { continue } - failedPages[index] = nil - results.append( - .init( - index: index, - relativePath: relativePath, - imageURL: context.storedGalleryImageState?.imageURLs[index] - ) - ) - } - var completedCount = results.count - var pendingResolvedPages = [PageResult]() - var lastFlushDate = Date() - - if completedCount > 0 { - try await updateDownloadRecord(gid: payload.gallery.gid, createIfMissing: false) { record in - record.completedPageCount = Int64(completedCount) - } - await notifyObservers() - } - - let restoredCachedPages = try await restorePendingPagesFromStoredCache( - indices: pendingPageIndices, - temporaryFolderURL: temporaryFolderURL, - existingPages: existingPages, - storedGalleryImageState: context.storedGalleryImageState - ) - if !restoredCachedPages.isEmpty { - restoredCachedPages.forEach { - failedPages[$0.index] = nil - results.append($0) - } - completedCount += restoredCachedPages.count - pendingResolvedPages.append(contentsOf: restoredCachedPages) - try await flushDownloadProgress( - gid: payload.gallery.gid, - pendingResolvedPages: &pendingResolvedPages, - completedCount: completedCount, - lastFlushDate: &lastFlushDate, - force: true - ) - } - - let restoredIndices = Set(restoredCachedPages.map(\.index)) - let remainingPageIndices = pendingPageIndices.filter { !restoredIndices.contains($0) } - var wasCancelled = false - await withTaskGroup(of: PageTaskOutcome.self) { group in - var pendingIterator = remainingPageIndices.makeIterator() - for _ in 0.. PageResult { - let payload = context.payload - let temporaryFolderURL = context.temporaryFolderURL - let storedGalleryImageState = context.storedGalleryImageState - let attempts = payload.options.autoRetryFailedPages ? 2 : 1 - var capturedError: AppError = .unknown - - for _ in 0.. WorkingSeed { - let fileManager = fileManager() - let resumeState = try? storage.readResumeState(folderURL: temporaryFolderURL) - let shouldReuseTemporaryFolder = resumeState?.matches( - mode: payload.mode, - versionSignature: versionSignature, - pageCount: payload.galleryDetail.pageCount, - downloadOptions: payload.options - ) == true - && fileManager.fileExists(atPath: temporaryFolderURL.path) - - if !shouldReuseTemporaryFolder { - try? fileManager.removeItem(at: temporaryFolderURL) - } - - if !fileManager.fileExists(atPath: temporaryFolderURL.path) { - if let repairSeed = repairSeed( - for: existingDownload, - payload: payload, - versionSignature: versionSignature - ) { - try storage.materializeRepairSeed( - from: repairSeed.folderURL, - manifest: repairSeed.manifest, - to: temporaryFolderURL - ) - } else { - try createDirectory(at: temporaryFolderURL) - } - } - - let pagesFolderURL = temporaryFolderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, - isDirectory: true - ) - try createDirectory(at: pagesFolderURL) - - let manifest = validatedManifest( - at: temporaryFolderURL, - gid: payload.gallery.gid, - pageCount: payload.galleryDetail.pageCount, - versionSignature: versionSignature, - downloadOptions: payload.options - ) - let existingPages = storage.existingPageRelativePaths( - folderURL: temporaryFolderURL, - expectedPageCount: payload.galleryDetail.pageCount - ) - let coverRelativePath = manifest?.coverRelativePath - ?? storage.existingCoverRelativePath(folderURL: temporaryFolderURL) - - return .init( - folderURL: temporaryFolderURL, - manifest: manifest, - existingPages: existingPages, - coverRelativePath: coverRelativePath - ) - } - - private func resolvedImageSource( - index: Int, - payload: DownloadRequestPayload, - source: ResolvedSource, - retriesRequest: Bool - ) async throws -> ResolvedImageSource { - switch source { - case .normal(let thumbnailURLs): - guard let thumbnailURL = thumbnailURLs[index] else { throw AppError.notFound } - let doc = try await htmlDocument( - url: thumbnailURL, - allowsCellular: payload.options.allowCellular, - retriesRequest: retriesRequest - ) - let (_, imageURL, _) = try Parser.parseGalleryNormalImageURL( - doc: doc, - index: index - ) - return .init(imageURL: imageURL) - - case .mpv(let mpvKey, let imageKeys): - guard let imageKey = imageKeys[index] else { throw AppError.notFound } - let imageURL = try await fetchMPVImageURL( - payload: payload, - index: index, - mpvKey: mpvKey, - imageKey: imageKey, - retriesRequest: retriesRequest - ) - return .init(imageURL: imageURL) - } - } - - private func repairSeed( - for download: DownloadedGallery, - payload: DownloadRequestPayload, - versionSignature: String - ) -> RepairSeed? { - guard payload.mode == .repair, - let folderURL = download.resolvedFolderURL(rootURL: storage.rootURL), - fileManager().fileExists(atPath: folderURL.path), - let manifest = try? storage.readManifest(folderURL: folderURL), - manifest.gid == download.gid, - manifest.pageCount == payload.galleryDetail.pageCount, - manifest.pages.count == manifest.pageCount, - manifest.versionSignature == versionSignature - else { - return nil - } - return .init(folderURL: folderURL, manifest: manifest) - } - - private func fetchThumbnailURLs( - galleryURL: URL, - pageNum: Int, - allowsCellular: Bool - ) async throws -> [Int: URL] { - let detailPageURL = URLUtil.detailPage(url: galleryURL, pageNum: pageNum) - let urls = try await withRetry( - operation: "fetchThumbnailURLs", - context: [ - "galleryURL": galleryURL.absoluteString, - "detailPageURL": detailPageURL.absoluteString, - "pageNum": pageNum - ] - ) { - let doc = try await htmlDocument( - url: detailPageURL, - allowsCellular: allowsCellular, - retriesRequest: false - ) - return try Parser.parseThumbnailURLs(doc: doc) - } - guard !urls.isEmpty else { throw AppError.notFound } - return urls - } - - private func fetchMPVKeys( - mpvURL: URL, - allowsCellular: Bool - ) async throws -> (String, [Int: String]) { - try await withRetry( - operation: "fetchMPVKeys", - context: [ - "mpvURL": mpvURL.absoluteString - ] - ) { - let doc = try await htmlDocument( - url: mpvURL, - allowsCellular: allowsCellular, - retriesRequest: false - ) - return try Parser.parseMPVKeys(doc: doc) - } - } - - private func fetchMPVImageURL( - payload: DownloadRequestPayload, - index: Int, - mpvKey: String, - imageKey: String, - retriesRequest: Bool = true - ) async throws -> URL { - guard let gidInteger = Int(payload.gallery.gid) else { throw AppError.notFound } - let params: [String: Any] = [ - "method": "imagedispatch", - "gid": gidInteger, - "page": index, - "imgkey": imageKey, - "mpvkey": mpvKey - ] - - var request = URLRequest(url: payload.host.url.appendingPathComponent("api.php")) - request.httpMethod = "POST" - request.httpBody = try JSONSerialization.data(withJSONObject: params) - request.allowsCellularAccess = payload.options.allowCellular - - let (data, response) = try await dataResponse(for: request, retriesRequest: retriesRequest) - if let error = detectResponseError( - data: data, - response: response, - requestURL: request.url - ) { - throw error - } - guard let dictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let imageURLString = dictionary["i"] as? String, - let imageURL = URL(string: imageURLString) - else { - throw AppError.parseFailed - } - return imageURL - } - - private func htmlDocument( - url: URL, - allowsCellular: Bool, - retriesRequest: Bool = true - ) async throws -> HTMLDocument { - var request = URLRequest(url: url) - request.allowsCellularAccess = allowsCellular - let (data, response) = try await dataResponse(for: request, retriesRequest: retriesRequest) - if let error = detectResponseError( - data: data, - response: response, - requestURL: request.url, - expectsHTML: true - ) { - throw error - } - if let document = try? Kanna.HTML(html: data, encoding: .utf8) { - return document - } - if let document = try? Kanna.HTML( - html: data.utf8InvalidCharactersRipped, - encoding: .utf8 - ) { - return document - } - throw AppError.parseFailed - } - - private func downloadResponse( - url: URL, - allowsCellular: Bool, - retriesRequest: Bool = true - ) async throws -> (URL, URLResponse) { - var request = URLRequest(url: url) - request.allowsCellularAccess = allowsCellular - return try await downloadResponse(for: request, retriesRequest: retriesRequest) - } - - private func downloadResponse( - for request: URLRequest, - retriesRequest: Bool = true - ) async throws -> (URL, URLResponse) { - let performRequest = { - try await self.rawDownloadResponse(for: request) - } - - let response: (URL, URLResponse) - if retriesRequest { - response = try await withRetry( - operation: "downloadResponse", - context: [ - "url": request.url?.absoluteString ?? "" - ] - ) { - try await performRequest() - } - } else { - response = try await performRequest() - } - - if let error = detectResponseError( - fileURL: response.0, - response: response.1, - requestURL: request.url - ) { - try? fileManager().removeItem(at: response.0) - throw error - } - - return response - } - - private func dataResponse( - for request: URLRequest, - retriesRequest: Bool = true - ) async throws -> (Data, URLResponse) { - if retriesRequest { - return try await withRetry( - operation: "dataResponse", - context: [ - "url": request.url?.absoluteString ?? "" - ] - ) { - try await rawDataResponse(for: request) - } - } - return try await rawDataResponse(for: request) - } - - private func rawDataResponse(for request: URLRequest) async throws -> (Data, URLResponse) { - do { - return try await urlSession.data(for: request) - } catch let error as AppError { - throw error - } catch is CancellationError { - throw CancellationError() - } catch let error as URLError where error.code == .cancelled { - throw CancellationError() - } catch { - if Self.isCancellationLikeError(error) { - throw CancellationError() - } - if error is URLError { - throw AppError.networkingFailed - } - throw AppError.unknown - } - } - - private func rawDownloadResponse(for request: URLRequest) async throws -> (URL, URLResponse) { - do { - return try await urlSession.download(for: request) - } catch let error as AppError { - throw error - } catch is CancellationError { - throw CancellationError() - } catch let error as URLError where error.code == .cancelled { - throw CancellationError() - } catch { - if Self.isCancellationLikeError(error) { - throw CancellationError() - } - if error is URLError { - throw AppError.networkingFailed - } - throw AppError.unknown - } - } - - private func detectResponseError( - data: Data, - response: URLResponse, - requestURL: URL?, - expectsHTML: Bool = false - ) -> AppError? { - detectResponseError( - prefixData: Data(data.prefix(Self.responseInspectionPrefixLength)), - fullData: data, - response: response, - requestURL: requestURL, - expectsHTML: expectsHTML - ) - } - - private func detectResponseError( - fileURL: URL, - response: URLResponse, - requestURL: URL? - ) -> AppError? { - let prefixData = (try? readResponsePrefixData(at: fileURL)) ?? Data() - let placeholderData: Data? - if let byteCount = responseContentLength(response) ?? fileSize(at: fileURL), - byteCount == Self.kokomadeImageByteCount - || byteCount == Self.quotaExceededImageByteCount - { - placeholderData = try? Data(contentsOf: fileURL, options: .mappedIfSafe) - } else { - placeholderData = nil - } - if let placeholderData { - if isAuthenticationRequiredPlaceholderImageData(placeholderData) { - return .authenticationRequired - } - if isQuotaExceededAssetData(placeholderData) { - return .quotaExceeded - } - } - if isAuthenticationRequiredPlaceholderResponse( - response: response, - requestURL: requestURL - ) { - return .authenticationRequired - } - if isQuotaExceededResponse( - fullData: nil, - fileURL: fileURL, - response: response, - requestURL: requestURL - ) { - return .quotaExceeded - } - let mimeType = normalizedMimeType(response) - let shouldInspect = shouldInspectTextResponse( - mimeType: mimeType, - prefixData: prefixData - ) - guard shouldInspect else { - if statusCode(for: response) == 404 { - return .notFound - } - return nil - } - - let looksLikeHTML = responseLooksLikeHTML( - mimeType: mimeType, - prefixData: prefixData, - expectsHTML: false - ) - let fullData = looksLikeHTML - ? placeholderData ?? (try? Data(contentsOf: fileURL, options: .mappedIfSafe)) - : placeholderData - - return detectResponseError( - prefixData: prefixData, - fullData: fullData, - response: response, - requestURL: requestURL, - expectsHTML: false - ) - } - - private func detectResponseError( - prefixData: Data, - fullData: Data?, - response: URLResponse, - requestURL: URL?, - expectsHTML: Bool - ) -> AppError? { - if let fullData { - if isAuthenticationRequiredPlaceholderImageData(fullData) { - return .authenticationRequired - } - if isQuotaExceededAssetData(fullData) { - return .quotaExceeded - } - } - if isAuthenticationRequiredPlaceholderResponse( - response: response, - requestURL: requestURL - ) { - return .authenticationRequired - } - if isQuotaExceededResponse( - fullData: fullData, - fileURL: nil, - response: response, - requestURL: requestURL - ) { - return .quotaExceeded - } - - let mimeType = normalizedMimeType(response) - let shouldInspect = expectsHTML || shouldInspectTextResponse( - mimeType: mimeType, - prefixData: prefixData - ) - if shouldInspect { - let inspectedData = fullData ?? prefixData - if let error = detectTextualDownloadError( - data: inspectedData, - looksLikeHTML: responseLooksLikeHTML( - mimeType: mimeType, - prefixData: prefixData, - expectsHTML: expectsHTML - ) - ) { - return error - } - } - if isAuthenticationRequiredResponse( - prefixData: prefixData, - fullData: fullData, - response: response, - requestURL: requestURL - ) { - return .authenticationRequired - } - guard shouldInspect else { return nil } - - let textPrefix = String(bytes: prefixData, encoding: .utf8) ?? "" - - let looksLikeHTML = responseLooksLikeHTML( - mimeType: mimeType, - prefixData: prefixData, - expectsHTML: expectsHTML - ) - guard looksLikeHTML else { - if statusCode(for: response) == 404 { - return .notFound - } - return nil - } - - if let fullData, - let document = try? Kanna.HTML( - html: fullData.utf8InvalidCharactersRipped, - encoding: .utf8 - ), - let error = Parser.parseDownloadPageError(doc: document) - { - return error - } - if expectsHTML { - if statusCode(for: response) == 404 { - return .notFound - } - return nil - } - Logger.error( - "Download received unexpected HTML response.", - context: [ - "url": requestURL?.absoluteString ?? "", - "snippet": String(textPrefix.prefix(240)) - ] - ) - if statusCode(for: response) == 404 { - return .notFound - } - return .parseFailed - } - - private func withRetry( - operation: String, - context: [String: Any], - maxAttempts: Int = retryLimit, - body: () async throws -> T - ) async throws -> T { - var attempt = 1 - while true { - do { - return try await body() - } catch is CancellationError { - throw CancellationError() - } catch let error as AppError { - guard error.isRetryable, attempt < maxAttempts else { - throw error - } - Logger.error( - "Download operation will retry.", - context: context.merging([ - "operation": operation, - "attempt": attempt, - "error": error.localizedDescription - ], uniquingKeysWith: { _, new in new }) - ) - attempt += 1 - } catch { - guard attempt < maxAttempts else { - throw error - } - Logger.error( - "Download operation will retry after unexpected error.", - context: context.merging([ - "operation": operation, - "attempt": attempt, - "error": error.localizedDescription - ], uniquingKeysWith: { _, new in new }) - ) - attempt += 1 - } - } - } - - private func fileExtension( - for url: URL, - response: URLResponse?, - prefixData: Data - ) -> String { - if url.pathExtension.notEmpty { - return url.pathExtension.lowercased() - } - if let mimeType = response?.mimeType?.lowercased() { - switch mimeType { - case "image/jpeg": - return "jpg" - case "image/png": - return "png" - case "image/gif": - return "gif" - case "image/webp": - return "webp" - default: - break - } - } - if prefixData.starts(with: [0x47, 0x49, 0x46]) { - return "gif" - } - if prefixData.starts(with: [0x89, 0x50, 0x4E, 0x47]) { - return "png" - } - if prefixData.starts(with: [0x52, 0x49, 0x46, 0x46]), - prefixData.count >= 12, - String(bytes: prefixData[8..<12], encoding: .utf8) == "WEBP" - { - return "webp" - } - return "jpg" - } - - private func createDirectory(at url: URL) throws { - try fileManager().createDirectory(at: url, withIntermediateDirectories: true) - } - - private func write(data: Data, to url: URL) throws { - try createDirectory(at: url.deletingLastPathComponent()) - try data.write(to: url, options: .atomic) - } - - private func moveDownloadedFile(from sourceURL: URL, to destinationURL: URL) throws { - try createDirectory(at: destinationURL.deletingLastPathComponent()) - if fileManager().fileExists(atPath: destinationURL.path) { - try fileManager().removeItem(at: destinationURL) - } - try fileManager().moveItem(at: sourceURL, to: destinationURL) - } - - private func readResponsePrefixData(at fileURL: URL) throws -> Data { - let handle = try FileHandle(forReadingFrom: fileURL) - defer { try? handle.close() } - return try handle.read(upToCount: Self.responseInspectionPrefixLength) ?? Data() - } - - private func normalizedMimeType(_ response: URLResponse) -> String? { - if let mimeType = response.mimeType?.lowercased(), mimeType.notEmpty { - return mimeType - } - if let httpResponse = response as? HTTPURLResponse, - let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type")?.lowercased(), - let mimeType = contentType.split(separator: ";").first, - !mimeType.isEmpty - { - return String(mimeType) - } - return nil - } - - private func shouldInspectTextResponse( - mimeType: String?, - prefixData: Data - ) -> Bool { - if let mimeType { - if mimeType.hasPrefix("image/") { - return prefixLooksLikeHTML(prefixData) - } - if mimeType == "text/html" || mimeType == "text/plain" { - return true - } - return prefixLooksLikeHTML(prefixData) - } - - guard !prefixIsKnownBinaryImage(prefixData) else { - return false - } - return true - } - - private func prefixLooksLikeHTML(_ prefixData: Data) -> Bool { - let prefix = String(bytes: prefixData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() ?? "" - guard prefix.notEmpty else { return false } - - let htmlMarkers = [ - " Bool { - expectsHTML - || mimeType == "text/html" - || prefixLooksLikeHTML(prefixData) - } - - private func detectTextualDownloadError( - data: Data, - looksLikeHTML: Bool - ) -> AppError? { - let normalizedData = data.utf8InvalidCharactersRipped - let rawContent = String(data: normalizedData, encoding: .utf8) ?? "" - if !looksLikeHTML { - return Parser.parseDownloadPageError(content: rawContent) - } - - if let document = try? Kanna.HTML( - html: normalizedData, - encoding: .utf8 - ), - let error = Parser.parseDownloadPageError(doc: document) - { - return error - } - - guard rawContent.count <= 1024 else { - return nil - } - return Parser.parseDownloadPageError(content: rawContent) - } - - private func prefixIsKnownBinaryImage(_ prefixData: Data) -> Bool { - prefixData.starts(with: [0xFF, 0xD8, 0xFF]) - || prefixData.starts(with: [0x89, 0x50, 0x4E, 0x47]) - || prefixData.starts(with: [0x47, 0x49, 0x46]) - || ( - prefixData.starts(with: [0x52, 0x49, 0x46, 0x46]) - && prefixData.count >= 12 - && String(bytes: prefixData[8..<12], encoding: .utf8) == "WEBP" - ) - } - - private func isQuotaExceededResponse( - fullData: Data?, - fileURL: URL?, - response: URLResponse, - requestURL: URL? - ) -> Bool { - let urls = [requestURL, response.url].compactMap(\.self) - let lowercasedURLs = urls.map { $0.absoluteString.lowercased() } - guard lowercasedURLs.contains(where: { url in - Self.quotaExceededImageURLSuffixes.contains(where: url.hasSuffix) - }) else { - return false - } - - let byteCount = fullData?.count ?? responseContentLength(response) ?? fileSize(at: fileURL) - guard byteCount == Self.quotaExceededImageByteCount else { - return false - } - - let data: Data? - if let fullData { - data = fullData - } else if let fileURL { - data = try? Data(contentsOf: fileURL, options: .mappedIfSafe) - } else { - data = nil - } - guard let data else { return false } - return isQuotaExceededAssetData(data) - } - - private func isAuthenticationRequiredPlaceholderResponse( - response: URLResponse, - requestURL: URL? - ) -> Bool { - [requestURL, response.url].contains { isAuthenticationRequiredPlaceholderURL($0) } - } - - private func isAuthenticationRequiredPlaceholderURL(_ url: URL?) -> Bool { - guard let url else { return false } - let normalizedURL = url.absoluteString.lowercased() - // JDownloader treats `bounce_login.php` as an account / re-login required signal for EH/EX. - // Reference: https://github.com/mirror/jdownloader/blob/master/src/jd/plugins/hoster/EHentaiOrg.java - if normalizedURL.contains("bounce_login.php") { - return true - } - return isKokomadePlaceholderURL(url) - } - - private func isKokomadePlaceholderURL(_ url: URL?) -> Bool { - guard let url else { return false } - let normalizedURL = url.absoluteString.lowercased() - // Ex login failures commonly surface as a kokomade placeholder wall when `igneous` is missing. - // Reference: https://github.com/OpportunityLiu/E-Viewer/issues/124 - return isExHentaiURL(url) - && Self.kokomadeImageURLSuffixes.contains(where: normalizedURL.hasSuffix) - } - - private func isAuthenticationRequiredResponse( - prefixData: Data, - fullData: Data?, - response: URLResponse, - requestURL: URL? - ) -> Bool { - guard isExHentaiURL(requestURL) || isExHentaiURL(response.url) else { - return false - } - guard normalizedMimeType(response) == "text/html" else { - return false - } - guard fullData?.isEmpty ?? prefixData.isEmpty else { - return false - } - - let cookies = responseCookies(response: response, requestURL: requestURL) - let hasYay = cookies.contains { - $0.name == Defaults.Cookie.yay && $0.value.notEmpty - } - let hasValidIgneous = cookies.contains { - $0.name == Defaults.Cookie.igneous - && $0.value.notEmpty - && $0.value != Defaults.Cookie.mystery - } - return hasYay && !hasValidIgneous - } - - private func responseCookies( - response: URLResponse, - requestURL: URL? - ) -> [HTTPCookie] { - let urls = [response.url, requestURL, Defaults.URL.exhentai, Defaults.URL.sexhentai] - .compactMap(\.self) - var uniqueURLs = [URL]() - for url in urls where !uniqueURLs.contains(url) { - uniqueURLs.append(url) - } - - var cookies = [HTTPCookie]() - if let httpResponse = response as? HTTPURLResponse, - let responseURL = httpResponse.url - { - let headerFields = httpResponse.allHeaderFields.reduce(into: [String: String]()) { partial, item in - guard let key = item.key as? String, - let value = item.value as? String - else { return } - partial[key] = value - } - cookies += HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: responseURL) - } - - for url in uniqueURLs { - cookies += HTTPCookieStorage.shared.cookies(for: url) ?? [] - } - return cookies - } - - private func isExHentaiURL(_ url: URL?) -> Bool { - guard let host = url?.host?.lowercased() else { - return false - } - return host == "exhentai.org" || host.hasSuffix(".exhentai.org") - } - - private func statusCode(for response: URLResponse) -> Int? { - (response as? HTTPURLResponse)?.statusCode - } - - private func responseContentLength(_ response: URLResponse) -> Int? { - if response.expectedContentLength > 0 { - return Int(response.expectedContentLength) - } - if let httpResponse = response as? HTTPURLResponse, - let header = httpResponse.value(forHTTPHeaderField: "Content-Length"), - let contentLength = Int(header) - { - return contentLength - } - return nil - } - - private func fileSize(at fileURL: URL?) -> Int? { - guard let fileURL else { return nil } - let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]) - return values?.fileSize - } - - private func fileManager() -> FileManager { - storage.fileManager - } - - private func cachedImageData(for url: URL) async -> Data? { - await cachedImageData(for: [url], includeStableAlias: false) - } - - private func cachedImageData( - for urls: [URL?], - includeStableAlias: Bool - ) async -> Data? { - let allKeys = urls - .compactMap { $0 } - .flatMap { cacheKeys(for: $0, includeStableAlias: includeStableAlias) } - let keys = allKeys.reduce(into: [String]()) { partialResult, key in - guard !partialResult.contains(key) else { return } - partialResult.append(key) - } - - for key in keys { - if let data = await cachedImageData(forKey: key) { - return data - } - } - return nil - } - - private func cachedImageData(forKey key: String) async -> Data? { - if let image = KingfisherManager.shared.cache.retrieveImageInMemoryCache(forKey: key), - let data = image.kf.data(format: .unknown) - { - return data - } - - if let data = try? KingfisherManager.shared.cache.diskStorage.value(forKey: key) { - return data - } - - return await withCheckedContinuation { continuation in - KingfisherManager.shared.cache.retrieveImage(forKey: key) { result in - switch result { - case .success(let value): - guard let image = value.image, - let data = image.kf.data(format: .unknown) - else { - continuation.resume(returning: nil) - return - } - continuation.resume(returning: data) - - case .failure: - continuation.resume(returning: nil) - } - } - } - } - - private func validatedCachedAssetData(for urls: [URL?]) async -> Data? { - guard let cachedData = await cachedImageData(for: urls, includeStableAlias: true) else { - return nil - } - guard detectCachedAssetError(data: cachedData, referenceURLs: urls) == nil else { - removeCachedImages(for: urls, includeStableAlias: true) - return nil - } - return cachedData - } - - private func detectCachedAssetError( - data: Data, - referenceURLs _: [URL?] - ) -> AppError? { - guard !data.isEmpty else { return .parseFailed } - if isAuthenticationRequiredPlaceholderImageData(data) { - return .authenticationRequired - } - if isQuotaExceededAssetData(data) { - return .quotaExceeded - } - - let looksLikeHTML = prefixLooksLikeHTML(Data(data.prefix(Self.responseInspectionPrefixLength))) - if let error = detectTextualDownloadError(data: data, looksLikeHTML: looksLikeHTML) { - return error - } - - return isDecodableImageData(data) ? nil : .parseFailed - } - - // Cached assets may be keyed by the original image URL even when the response was redirected - // to a placeholder image, so placeholder detection must rely on content fingerprints instead - // of the cache key alone. - // Observed in our own tests by fetching the live kokomade placeholder asset: - // https://exhentai.org/img/kokomade.jpg - private func isAuthenticationRequiredPlaceholderImageData(_ data: Data) -> Bool { - guard data.count == Self.kokomadeImageByteCount else { - return false - } - return sha1Hex(for: data) == Self.kokomadeImageSHA1 - } - - // Verified from the live 509 placeholder asset captured in our own tests. - private func isQuotaExceededAssetData(_ data: Data) -> Bool { - guard data.count == Self.quotaExceededImageByteCount else { - return false - } - return sha1Hex(for: data) == Self.quotaExceededImageSHA1 - } - - private func sha1Hex(for data: Data) -> String { - let digest = Insecure.SHA1.hash(data: data) - return digest.map { String(format: "%02x", $0) }.joined() - } - - private func isDecodableImageData(_ data: Data) -> Bool { - guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { - return false - } - return CGImageSourceGetCount(source) > 0 - } - - private func shouldSuppressFailurePersistence(for gid: String) -> Bool { - schedulingBlockedGalleryIDs.contains(gid) || Task.isCancelled - } - - nonisolated private static func isCancellationLikeError(_ error: Error) -> Bool { - if error is CancellationError { - return true - } - - let nsError = error as NSError - if nsError.domain == NSURLErrorDomain, - nsError.code == URLError.cancelled.rawValue - { - return true - } - - let message = nsError.localizedDescription.lowercased() - return message.contains("cancellation") - || message.contains("cancelled") - || message.contains("canceled") - } - - private func isCancellationLikeAppError(_ error: AppError) -> Bool { - guard case .fileOperationFailed(let reason) = error else { return false } - return Self.isCancellationLikeError(NSError( - domain: NSCocoaErrorDomain, - code: NSUserCancelledError, - userInfo: [NSLocalizedDescriptionKey: reason] - )) - } - - private func cacheKeys(for url: URL, includeStableAlias: Bool) -> [String] { - url.imageCacheKeys(includeStableAlias: includeStableAlias) - } - - private func removeCachedImages( - for urls: [URL?], - includeStableAlias: Bool - ) { - let keys = urls - .compactMap(\.self) - .flatMap { cacheKeys(for: $0, includeStableAlias: includeStableAlias) } - - for key in Set(keys) { - KingfisherManager.shared.cache.removeImage(forKey: key) - } - } - - private func pageImageCacheURLs( - resolvedImageSource: ResolvedImageSource?, - index: Int, - storedGalleryImageState: CachedGalleryImageState? - ) -> [URL?] { - [resolvedImageSource?.imageURL, storedGalleryImageState?.imageURLs[index]] - } - - private func pageImageCacheURLs( - imageURL: URL? - ) -> [URL?] { - [imageURL] - } - - private func canSatisfyPendingPageDownloadsFromCache( - pendingPageIndices: [Int], - temporaryFolderURL: URL, - existingPageRelativePaths: [Int: String], - storedGalleryImageState: CachedGalleryImageState? - ) async -> Bool { - guard !pendingPageIndices.isEmpty else { return true } - for index in pendingPageIndices { - if let relativePath = existingPageRelativePaths[index] { - let fileURL = temporaryFolderURL.appendingPathComponent(relativePath) - if fileManager().fileExists(atPath: fileURL.path) { - continue - } - } - guard await validatedCachedAssetData( - for: pageImageCacheURLs( - resolvedImageSource: nil, - index: index, - storedGalleryImageState: storedGalleryImageState - ) - ) != nil else { - return false - } - } - return true - } - - private func restorePendingPagesFromStoredCache( - indices: [Int], - temporaryFolderURL: URL, - existingPages: [Int: String], - storedGalleryImageState: CachedGalleryImageState? - ) async throws -> [PageResult] { - var restoredPages = [PageResult]() - for index in indices { - let cacheURLs = pageImageCacheURLs( - resolvedImageSource: nil, - index: index, - storedGalleryImageState: storedGalleryImageState - ) - let cacheSource = CacheRestoreSource( - cacheURLs: cacheURLs, - referenceURL: cacheURLs.compactMap(\.self).first, - imageURL: storedGalleryImageState?.imageURLs[index] - ) - guard let pageResult = try await restorePageFromCache( - index: index, - source: cacheSource, - folderURL: temporaryFolderURL, - preferredRelativePath: existingPages[index] - ) else { - continue - } - restoredPages.append(pageResult) - } - return restoredPages - } - - private func pendingPageIndices( - payload: DownloadRequestPayload, - folderURL: URL, - existingPageRelativePaths: [Int: String] - ) -> [Int] { - let selectedIndices = payload.pageSelection.map(Set.init) - return (1...payload.galleryDetail.pageCount).filter { index in - if let selectedIndices, !selectedIndices.contains(index) { - return false - } - guard let relativePath = existingPageRelativePaths[index] else { - return true - } - let fileURL = folderURL.appendingPathComponent(relativePath) - return !fileManager().fileExists(atPath: fileURL.path) - } - } - - private func shouldExposeTemporaryWorkingSet(for download: DownloadedGallery) -> Bool { - download.shouldPreserveTemporaryWorkingSet || download.status == .failed - } - - private func restorePageFromCache( - index: Int, - source: CacheRestoreSource, - folderURL: URL, - preferredRelativePath: String?, - overwriteExistingFile: Bool = false - ) async throws -> PageResult? { - // Cache-assisted restores must reject known placeholder images before promoting them to offline files. - guard let cachedData = await validatedCachedAssetData(for: source.cacheURLs) - else { - return nil - } - - let relativePath: String - if let preferredRelativePath { - relativePath = preferredRelativePath - } else { - let fallbackURL = source.referenceURL ?? URL(string: "https://example.com/\(index).jpg")! - let fileExtension = fileExtension( - for: fallbackURL, - response: nil, - prefixData: cachedData - ) - relativePath = storage.makePageRelativePath( - index: index, - fileExtension: fileExtension - ) - } - - let fileURL = folderURL.appendingPathComponent(relativePath) - if overwriteExistingFile || !fileManager().fileExists(atPath: fileURL.path) { - try write(data: cachedData, to: fileURL) - } - - return .init( - index: index, - relativePath: relativePath, - imageURL: source.imageURL - ) - } - - private func preferredPageReferenceURL( - resolvedImageSource: ResolvedImageSource - ) -> URL? { - resolvedImageSource.imageURL - } - - private func preferredPageReferenceURL( - imageURL: URL? - ) -> URL? { - imageURL - } - - private func clearFailedPage(index: Int, folderURL: URL) throws { - guard let failedSnapshot = try? storage.readFailedPages(folderURL: folderURL) else { return } - let remainingPages = failedSnapshot.pages.filter { $0.index != index } - if remainingPages.count == failedSnapshot.pages.count { - return - } - if remainingPages.isEmpty { - try? storage.removeFailedPages(folderURL: folderURL) - } else { - try storage.writeFailedPages(.init(pages: remainingPages), folderURL: folderURL) - } - } - - private func temporaryCompletedPageCount( - gid: String, - expectedPageCount: Int - ) -> Int { - let folderURL = storage.temporaryFolderURL(gid: gid) - guard fileManager().fileExists(atPath: folderURL.path) else { return 0 } - return storage.existingPageRelativePaths( - folderURL: folderURL, - expectedPageCount: expectedPageCount - ) - .count - } - - private func validatedCompletedPageCount(_ download: DownloadedGallery) -> Int { - guard let folderURL = download.resolvedFolderURL(rootURL: storage.rootURL), - fileManager().fileExists(atPath: folderURL.path) - else { - return 0 - } - - guard let manifest = try? storage.readManifest(folderURL: folderURL) else { - return storage.existingPageRelativePaths( - folderURL: folderURL, - expectedPageCount: download.pageCount - ) - .count - } - - return storage.validPageCount(folderURL: folderURL, manifest: manifest) - } - - @discardableResult - private func sanitizeLocalFilesIfNeeded( - gid: String, - clearingLastError: Bool = false - ) async -> DownloadedGallery? { - guard let download = await fetchDownload(gid: gid) else { return nil } - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let hasTemporaryFolder = fileManager().fileExists(atPath: temporaryFolderURL.path) - let temporaryCompletedCount = hasTemporaryFolder - ? storage.existingPageRelativePaths( - folderURL: temporaryFolderURL, - expectedPageCount: download.pageCount - ) - .count - : 0 - if hasTemporaryFolder { - _ = storage.existingCoverRelativePath(folderURL: temporaryFolderURL) - } - - if let completedFolderURL = download.resolvedFolderURL(rootURL: storage.rootURL), - fileManager().fileExists(atPath: completedFolderURL.path) - { - _ = storage.existingPageRelativePaths( - folderURL: completedFolderURL, - expectedPageCount: download.pageCount - ) - _ = storage.existingCoverRelativePath(folderURL: completedFolderURL) - } - - var needsUpdate = false - var updatedStatus = download.status - var updatedCompletedPageCount = download.completedPageCount - var updatedLastError = download.lastError - - if hasTemporaryFolder, - shouldExposeTemporaryWorkingSet(for: download) - { - if updatedCompletedPageCount != temporaryCompletedCount { - updatedCompletedPageCount = temporaryCompletedCount - needsUpdate = true - } - if download.status == .failed { - updatedStatus = .partial - needsUpdate = true - } - } - - if [.completed, .updateAvailable, .missingFiles].contains(download.status) { - let validation = storage.validate(download: download) - let completedPageCount = validatedCompletedPageCount(download) - switch validation { - case .valid: - let expectedStatus: DownloadStatus = download.hasUpdate ? .updateAvailable : .completed - if updatedStatus != expectedStatus { - updatedStatus = expectedStatus - needsUpdate = true - } - if updatedCompletedPageCount != completedPageCount { - updatedCompletedPageCount = completedPageCount - needsUpdate = true - } - if clearingLastError || updatedLastError != nil { - updatedLastError = nil - needsUpdate = true - } - - case .missingFiles(let message): - if updatedStatus != .missingFiles { - updatedStatus = .missingFiles - needsUpdate = true - } - if updatedCompletedPageCount != completedPageCount { - updatedCompletedPageCount = completedPageCount - needsUpdate = true - } - let failure = DownloadFailure( - code: .fileOperationFailed, - message: message - ) - if updatedLastError != failure { - updatedLastError = failure - needsUpdate = true - } - } - } else if clearingLastError, updatedLastError != nil { - updatedLastError = nil - needsUpdate = true - } - - guard needsUpdate else { return download } - - do { - try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in - record.status = updatedStatus.rawValue - record.completedPageCount = Int64(updatedCompletedPageCount) - record.lastError = updatedLastError?.toData() - } - await notifyObservers() - } catch { - Logger.error(error) - } - - return await fetchDownload(gid: gid) - } - - private func captureTarget( - for download: DownloadedGallery, - index: Int - ) -> (folderURL: URL, preferredRelativePath: String?, isTemporary: Bool)? { - let temporaryFolderURL = storage.temporaryFolderURL(gid: download.gid) - if shouldExposeTemporaryWorkingSet(for: download), - fileManager().fileExists(atPath: temporaryFolderURL.path) - { - let temporaryPages = storage.existingPageRelativePaths( - folderURL: temporaryFolderURL, - expectedPageCount: download.pageCount - ) - let manifestRelativePath = (try? storage.readManifest(folderURL: temporaryFolderURL))? - .pages - .first(where: { $0.index == index })? - .relativePath - let preferredRelativePath = temporaryPages[index] - ?? manifestRelativePath - return (temporaryFolderURL, preferredRelativePath, true) - } - - guard let completedFolderURL = download.resolvedFolderURL(rootURL: storage.rootURL), - fileManager().fileExists(atPath: completedFolderURL.path) - else { - return nil - } - - let completedPages = storage.existingPageRelativePaths( - folderURL: completedFolderURL, - expectedPageCount: download.pageCount - ) - let manifestRelativePath = (try? storage.readManifest(folderURL: completedFolderURL))? - .pages - .first(where: { $0.index == index })? - .relativePath - let preferredRelativePath = completedPages[index] - ?? manifestRelativePath - return (completedFolderURL, preferredRelativePath, false) - } - - private func flushDownloadProgress( - gid: String, - pendingResolvedPages: inout [PageResult], - completedCount: Int, - lastFlushDate: inout Date, - force: Bool - ) async throws { - let shouldFlush = force - || pendingResolvedPages.count >= Self.progressFlushPageInterval - || Date().timeIntervalSince(lastFlushDate) >= Self.progressFlushMinimumInterval - guard shouldFlush else { return } - - let resolvedPages = pendingResolvedPages - pendingResolvedPages.removeAll(keepingCapacity: true) - await persistResolvedImageURLs(gid: gid, entries: resolvedPages) - try await updateDownloadRecord(gid: gid, createIfMissing: false) { record in - record.completedPageCount = Int64(completedCount) - } - lastFlushDate = Date() - await notifyObservers() - } - - private func persistResolvedImageURLs( - gid: String, - index: Int, - imageURL: URL? - ) async { - await persistResolvedImageURLs( - gid: gid, - entries: [ - .init( - index: index, - relativePath: "", - imageURL: imageURL - ) - ] - ) - } - - private func persistResolvedImageURLs( - gid: String, - entries: [PageResult] - ) async { - guard gid.isValidGID else { return } - let validEntries = entries.filter { $0.imageURL != nil } - guard !validEntries.isEmpty else { return } - - await MainActor.run { - let context = persistenceContainer.viewContext - let request = NSFetchRequest(entityName: "GalleryStateMO") - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", gid) - - let object: GalleryStateMO - if let stored = try? context.fetch(request).first { - object = stored - } else { - object = GalleryStateMO(context: context) - object.gid = gid - } - - var imageURLs = (object.imageURLs?.toObject() as [Int: URL]?) ?? [:] - var hasChanges = false - - for entry in validEntries { - if let imageURL = entry.imageURL, - imageURLs[entry.index] != imageURL - { - imageURLs[entry.index] = imageURL - hasChanges = true - } - } - - guard hasChanges else { - return - } - - object.imageURLs = imageURLs.toData() - - guard context.hasChanges else { return } - try? context.save() - } - } - - private func fetchCachedGalleryImageState(gid: String) async -> CachedGalleryImageState? { - await MainActor.run { - guard gid.isValidGID else { return nil } - let context = persistenceContainer.viewContext - let request = NSFetchRequest(entityName: "GalleryStateMO") - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", gid) - guard let object = try? context.fetch(request).first else { return nil } - let state = object.toEntity() - return .init( - previewURLs: state.previewURLs, - imageURLs: state.imageURLs - ) - } - } - - private func validatedManifest( - at folderURL: URL, - gid: String, - pageCount: Int, - versionSignature: String, - downloadOptions: DownloadOptionsSnapshot - ) -> DownloadManifest? { - guard let manifest = try? storage.readManifest(folderURL: folderURL), - manifest.gid == gid, - manifest.pageCount == pageCount, - manifest.pages.count == pageCount, - manifest.versionSignature == versionSignature, - manifest.downloadOptions == downloadOptions - else { - return nil - } - return manifest - } - - private func activeInspectionFolderURL(for download: DownloadedGallery) -> URL? { - let temporaryFolderURL = storage.temporaryFolderURL(gid: download.gid) - let completedFolderURL = download.resolvedFolderURL(rootURL: storage.rootURL) - let temporaryFolderExists = fileManager().fileExists(atPath: temporaryFolderURL.path) - let completedFolderExists = completedFolderURL.map { fileManager().fileExists(atPath: $0.path) } ?? false - - if shouldExposeTemporaryWorkingSet(for: download) { - return temporaryFolderExists - ? temporaryFolderURL - : completedFolderURL - } - if completedFolderExists { - return completedFolderURL - } - if temporaryFolderExists { - return temporaryFolderURL - } - return nil - } - - private func sanitizedFailedPages(folderURL: URL) -> [Int: DownloadFailedPagesSnapshot.Page] { - guard var snapshot = try? storage.readFailedPages(folderURL: folderURL) else { - return [:] - } - let filteredPages = snapshot.pages.filter { !isCancellationLikeAppError($0.failure.appError) } - guard filteredPages.count != snapshot.pages.count else { - return snapshot.map - } - - snapshot.pages = filteredPages - if filteredPages.isEmpty { - try? storage.removeFailedPages(folderURL: folderURL) - } else { - try? storage.writeFailedPages(snapshot, folderURL: folderURL) - } - return snapshot.map - } - - private func normalizeNeedsAttentionDownloads(_ downloads: [DownloadedGallery]) async { - for download in downloads { - let shouldClearCancellationError = download.lastError.map { - isCancellationLikeAppError($0.appError) - } ?? false - guard download.status == .failed || shouldClearCancellationError else { continue } - - let normalizedCompletedPageCount = max( - download.completedPageCount, - temporaryCompletedPageCount( - gid: download.gid, - expectedPageCount: max(download.pageCount, 1) - ) - ) - do { - try await updateDownloadRecord(gid: download.gid, createIfMissing: false) { record in - if download.status == .failed { - record.status = DownloadStatus.partial.rawValue - record.completedPageCount = Int64(normalizedCompletedPageCount) - } - if shouldClearCancellationError { - record.lastError = nil - } - } - } catch { - Logger.error(error) - } - } - } - - private func normalizeInterruptedDownloads(_ downloads: [DownloadedGallery]) async { - let hasActiveTask = activeTask != nil - let activeGalleryID = activeGalleryID - for download in downloads where - download.needsInterruptedDownloadNormalization( - activeGalleryID: activeGalleryID, - hasActiveTask: hasActiveTask - ) - { - do { - try await updateDownloadRecord(gid: download.gid, createIfMissing: false) { record in - record.status = DownloadStatus.paused.rawValue - } - } catch { - Logger.error(error) - } - } - } - - private func reconcileActiveDownloadState() async { - guard activeTask != nil, - let activeGalleryID, - let activeDownload = await fetchDownload(gid: activeGalleryID), - activeDownload.status != .downloading - else { return } - - do { - try await updateDownloadRecord(gid: activeGalleryID, createIfMissing: false) { record in - record.status = DownloadStatus.downloading.rawValue - record.lastError = nil - } - } catch { - Logger.error(error) - } - } - - private func validateDownloads() async { - let downloads = await fetchDownloadsFromStore() - for download in downloads - where [.completed, .updateAvailable, .missingFiles].contains(download.status) { - let validation = storage.validate(download: download) - switch validation { - case .valid: - let expectedStatus: DownloadStatus = download.hasUpdate ? .updateAvailable : .completed - guard download.status != expectedStatus else { continue } - do { - try await updateDownloadRecord(gid: download.gid, createIfMissing: false) { record in - record.status = expectedStatus.rawValue - } - } catch { - Logger.error(error) - } - - case .missingFiles(let message): - do { - try await updateDownloadRecord(gid: download.gid, createIfMissing: false) { record in - record.status = DownloadStatus.missingFiles.rawValue - record.lastError = DownloadFailure( - code: .fileOperationFailed, - message: message - ) - .toData() - } - } catch { - Logger.error(error) - } - } - } - } - - private func sortDownloads(_ downloads: [DownloadedGallery]) -> [DownloadedGallery] { - downloads.sorted { lhs, rhs in - let lhsPriority = lhs.sortPriority - let rhsPriority = rhs.sortPriority - if lhsPriority != rhsPriority { - return lhsPriority < rhsPriority - } - return (lhs.lastDownloadedAt ?? .distantPast) > (rhs.lastDownloadedAt ?? .distantPast) - } - } - - fileprivate func fetchDownload(gid: String) async -> DownloadedGallery? { - await MainActor.run { - let context = persistenceContainer.viewContext - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", gid) - return try? context.fetch(request).first?.toEntity() - } - } - - private func fetchDownloadsFromStore() async -> [DownloadedGallery] { - await MainActor.run { - let context = persistenceContainer.viewContext - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.sortDescriptors = [ - NSSortDescriptor( - keyPath: \DownloadedGalleryMO.lastDownloadedAt, - ascending: false - ) - ] - let objects = (try? context.fetch(request)) ?? [] - return objects.map { $0.toEntity() } - } - } - - private func fetchDownloadsFromStore(gids: [String]) async -> [DownloadedGallery] { - await MainActor.run { - let context = persistenceContainer.viewContext - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.predicate = NSPredicate(format: "gid IN %@", gids) - request.sortDescriptors = [ - NSSortDescriptor( - keyPath: \DownloadedGalleryMO.lastDownloadedAt, - ascending: false - ) - ] - let objects = (try? context.fetch(request)) ?? [] - return objects.map { $0.toEntity() } - } - } - - private func updateDownloadRecord( - gid: String, - createIfMissing: Bool = true, - update: @escaping (DownloadedGalleryMO) -> Void - ) async throws { - try await MainActor.run { - let context = persistenceContainer.viewContext - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", gid) - - let object: DownloadedGalleryMO - if let storedObject = try context.fetch(request).first { - object = storedObject - } else if !createIfMissing { - return - } else { - object = DownloadedGalleryMO(context: context) - object.gid = gid - object.host = GalleryHost.ehentai.rawValue - object.token = "" - object.title = "" - object.category = Category.private.rawValue - object.pageCount = 0 - object.postedDate = .now - object.rating = 0 - object.folderRelativePath = gid - object.status = DownloadStatus.queued.rawValue - object.remoteVersionSignature = "" - object.completedPageCount = 0 - } - - update(object) - guard context.hasChanges else { return } - do { - try context.save() - } catch { - throw AppError.databaseCorrupted(error.localizedDescription) - } - } - } - - private func deleteDownloadRecord(gid: String) async throws { - try await MainActor.run { - let context = persistenceContainer.viewContext - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", gid) - guard let object = try context.fetch(request).first else { return } - context.delete(object) - guard context.hasChanges else { return } - do { - try context.save() - } catch { - throw AppError.databaseCorrupted(error.localizedDescription) - } - } - } - -#if DEBUG - func testingInstallActiveTask(gid: String, task: Task) { - activeGalleryID = gid - activeTask = task - } - - func testingScheduleNextIfNeeded() async { - await scheduleNextIfNeeded() - } - - func testingFetchDownload(gid: String) async -> DownloadedGallery? { - await fetchDownload(gid: gid) - } - - func testingActiveGalleryID() -> String? { - activeGalleryID - } - - func testingRestoreCachedPages(payload: DownloadRequestPayload) async throws -> Int { - try storage.ensureRootDirectory() - let temporaryFolderURL = storage.temporaryFolderURL(gid: payload.gallery.gid) - try? fileManager().removeItem(at: temporaryFolderURL) - try createDirectory(at: temporaryFolderURL) - try createDirectory( - at: temporaryFolderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, - isDirectory: true - ) - ) - - let downloadContext = PageDownloadContext( - payload: payload, - source: nil, - temporaryFolderURL: temporaryFolderURL, - storedGalleryImageState: await fetchCachedGalleryImageState(gid: payload.gallery.gid) - ) - let batchResult = try await downloadPages( - context: downloadContext, - pendingPageIndices: pendingPageIndices( - payload: payload, - folderURL: temporaryFolderURL, - existingPageRelativePaths: [:] - ), - existingManifest: nil, - existingPageRelativePaths: [:] - ) - return batchResult.pages.count - } - - func testingFetchLatestPayload( - for download: DownloadedGallery, - mode: DownloadStartMode, - pageSelection: [Int]? = nil - ) async throws -> (DownloadRequestPayload, String) { - try await fetchLatestPayload( - for: download, - mode: mode, - pageSelection: pageSelection - ) - } - - func testingPrepareWorkingSeed( - payload: DownloadRequestPayload, - existingDownload: DownloadedGallery, - versionSignature: String - ) throws -> ( - folderURL: URL, - manifest: DownloadManifest?, - existingPages: [Int: String], - coverRelativePath: String? - ) { - let temporaryFolderURL = storage.temporaryFolderURL(gid: payload.gallery.gid) - try? fileManager().removeItem(at: temporaryFolderURL) - let workingSeed = try prepareWorkingSeed( - payload: payload, - existingDownload: existingDownload, - temporaryFolderURL: temporaryFolderURL, - versionSignature: versionSignature - ) - return ( - folderURL: workingSeed.folderURL, - manifest: workingSeed.manifest, - existingPages: workingSeed.existingPages, - coverRelativePath: workingSeed.coverRelativePath - ) - } - - func testingProcessDownload(gid: String) async { - await processDownload(gid: gid) - } - - func testingDetectResponseError( - fileURL: URL, - response: URLResponse, - requestURL: URL? - ) -> AppError? { - detectResponseError( - fileURL: fileURL, - response: response, - requestURL: requestURL + loadInspection: { gid in await manager.loadInspection(gid: gid) } ) } -#endif } // MARK: API diff --git a/EhPanda/App/Tools/Clients/FileClient.swift b/EhPanda/App/Tools/Clients/FileClient.swift index f82d9b315..cd9b71e85 100644 --- a/EhPanda/App/Tools/Clients/FileClient.swift +++ b/EhPanda/App/Tools/Clients/FileClient.swift @@ -49,7 +49,7 @@ extension FileClient { await withCheckedContinuation { continuation in guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(fileName) else { - continuation.resume(returning: .failure(.notFound)) + continuation.resume(returning: .failure(.notFound)) return } @@ -68,13 +68,13 @@ extension FileClient { EhTagTranslationDatabaseResponse.self, from: data ).tagTranslations else { - continuation.resume(returning: .failure(.parseFailed)) - return - } + continuation.resume(returning: .failure(.parseFailed)) + return + } guard !translations.isEmpty else { - continuation.resume(returning: .failure(.parseFailed)) - return - } + continuation.resume(returning: .failure(.parseFailed)) + return + } continuation.resume(returning: .success(.init(hasCustomTranslations: true, translations: translations))) } } diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index 7f207c2d4..5dd303bf5 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -57,7 +57,7 @@ extension LibraryClient { KingfisherManager.shared.downloader.sessionConfiguration = config KingfisherManager.shared.defaultOptions += [ .processor(WebPProcessor.default), - .cacheSerializer(WebPSerializer.default), + .cacheSerializer(WebPSerializer.default) ] }, clearWebImageDiskCache: { diff --git a/EhPanda/App/Tools/Clients/UIApplicationClient.swift b/EhPanda/App/Tools/Clients/UIApplicationClient.swift index f7b163bfe..468cb04c8 100644 --- a/EhPanda/App/Tools/Clients/UIApplicationClient.swift +++ b/EhPanda/App/Tools/Clients/UIApplicationClient.swift @@ -52,8 +52,7 @@ extension UIApplicationClient { @MainActor func openFileApp() { if let dirPath = FileUtil.logsDirectoryURL?.path, - let dirURL = URL(string: "shareddocuments://" + dirPath) - { + let dirURL = URL(string: "shareddocuments://" + dirPath) { return openURL(dirURL) } } diff --git a/EhPanda/App/Tools/Clients/URLClient.swift b/EhPanda/App/Tools/Clients/URLClient.swift index 5a9136a2d..456ec2702 100644 --- a/EhPanda/App/Tools/Clients/URLClient.swift +++ b/EhPanda/App/Tools/Clients/URLClient.swift @@ -6,6 +6,12 @@ import SwiftUI import Dependencies +struct URLAnalysisResult { + let isGalleryImageURL: Bool + let pageIndex: Int? + let commentID: String? +} + struct URLClient { let checkIfHandleable: (URL) -> Bool let checkIfMPVURL: (URL?) -> Bool @@ -16,7 +22,7 @@ extension URLClient { static let live: Self = .init( checkIfHandleable: { url in (url.absoluteString.contains(Defaults.URL.ehentai.absoluteString) - || url.absoluteString.contains(Defaults.URL.exhentai.absoluteString)) + || url.absoluteString.contains(Defaults.URL.exhentai.absoluteString)) && url.pathComponents.count >= 4 && ["g", "s"].contains(url.pathComponents[1]) && !url.pathComponents[2].isEmpty && !url.pathComponents[3].isEmpty }, @@ -40,9 +46,9 @@ extension URLClient { else { return url } return newURL } - func analyzeURL(_ url: URL) -> (Bool, Int?, String?) { + func analyzeURL(_ url: URL) -> URLAnalysisResult { guard checkIfHandleable(url) else { - return (false, nil, nil) + return URLAnalysisResult(isGalleryImageURL: false, pageIndex: nil, commentID: nil) } var isGalleryImageURL = false var commentID: String? @@ -62,7 +68,7 @@ extension URLClient { } } - return (isGalleryImageURL, pageIndex, commentID) + return URLAnalysisResult(isGalleryImageURL: isGalleryImageURL, pageIndex: pageIndex, commentID: commentID) } } diff --git a/EhPanda/App/Tools/Defaults.swift b/EhPanda/App/Tools/Defaults.swift index 6823c3637..9fb458271 100644 --- a/EhPanda/App/Tools/Defaults.swift +++ b/EhPanda/App/Tools/Defaults.swift @@ -9,7 +9,7 @@ import Foundation struct Defaults { struct FrameSize { static let archiveGridWidth: CGFloat = - DeviceUtil.isPadWidth ? 175 : DeviceUtil.isSEWidth ? 125 : 150 + DeviceUtil.isPadWidth ? 175 : DeviceUtil.isSEWidth ? 125 : 150 static var cardCellWidth: CGFloat { DeviceUtil.windowW * 0.8 } static let cardCellHeight: CGFloat = Defaults.ImageSize.headerH + 20 * 2 static var cardCellSize: CGSize { diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift index 47322bb0e..a33e1ff03 100644 --- a/EhPanda/App/Tools/Extensions/Extensions.swift +++ b/EhPanda/App/Tools/Extensions/Extensions.swift @@ -103,13 +103,13 @@ extension URL { } func previewCacheCleanupURLs() -> [URL] { - guard let (plainURL, _, _) = Parser.parsePreviewConfigs(url: self), - plainURL != self + guard let info = Parser.parsePreviewConfigs(url: self), + info.plainURL != self else { return [self] } - return [self, plainURL] + return [self, info.plainURL] } func appending(queryItems: [URLQueryItem]) -> URL { @@ -215,7 +215,7 @@ extension String { types: NSTextCheckingResult.CheckingType.link.rawValue ) { if let match = detector.firstMatch(in: self, options: [], - range: NSRange(location: 0, length: utf16.count) + range: NSRange(location: 0, length: utf16.count) ) { return match.range.length == utf16.count } else { return false } @@ -238,8 +238,7 @@ extension String { while let rangeA = result.range(of: subString1), let rangeB = result.range(of: subString2), - rangeA.lowerBound < rangeB.upperBound - { + rangeA.lowerBound < rangeB.upperBound { let unwanted = result[rangeA.lowerBound.. (URL?, ImageModifier) { guard let url = originalURL, - let (plainURL, size, offset) = Parser.parsePreviewConfigs(url: url) + let info = Parser.parsePreviewConfigs(url: url) else { return (originalURL, RoundedOffsetModifier(size: nil, offset: nil)) } - return (plainURL, RoundedOffsetModifier(size: size, offset: offset)) + return (info.plainURL, RoundedOffsetModifier(size: info.size, offset: info.offset)) } } diff --git a/EhPanda/App/Tools/Parser.swift b/EhPanda/App/Tools/Parser.swift deleted file mode 100644 index 2129a2411..000000000 --- a/EhPanda/App/Tools/Parser.swift +++ /dev/null @@ -1,1943 +0,0 @@ -// -// Parser.swift -// EhPanda -// - -import Kanna -import OpenCC -import SwiftUI - -struct Parser { - // MARK: List - static func parseGalleries(doc: HTMLDocument) throws -> [Gallery] { - func parseDisplayMode(doc: HTMLDocument) throws -> String { - guard let containerNode = doc.at_xpath("//div [@id='dms']") ?? doc.at_xpath("//div [@class='searchnav']") - else { throw AppError.parseFailed } - - var dmsNode: XMLElement? - for select in containerNode.xpath("//select") where select["onchange"]?.contains("inline_set=dm_") == true { - dmsNode = select - break - } - guard let dmsNode else { throw AppError.parseFailed } - - for option in dmsNode.xpath("//option") where option["selected"] == "selected" { - if let displayMode = option.text { - return displayMode - } - } - throw AppError.parseFailed - } - func parseThumbnailPanel(node: XMLElement) throws -> (URL, Category, Float, Date, Int, String?) { - var tmpCoverURL: URL? - var tmpCategory: Category? - var tmpPublishedDate: Date? - var tmpPageCount: Int? - var uploader: String? - - for div in node.xpath("//div") { - if let imgNode = div.at_css("img"), - let urlString = imgNode["data-src"] ?? imgNode["src"], let url = URL(string: urlString), - [Defaults.URL.torrentDownload, Defaults.URL.torrentDownloadInvalid].map(\.absoluteString) - .contains(where: { $0 == urlString }) == false, imgNode["alt"] != "T" - { - tmpCoverURL = url - } - if let rawValue = div.text, let category = Category(rawValue: rawValue) { - tmpCategory = category - } - if let onClick = div["onclick"], !onClick.isEmpty, let dateString = div.text, - let date = try? parseDate(time: dateString, format: Defaults.DateFormat.publish) - { - tmpPublishedDate = date - } - if let components = div.text?.split(separator: " "), components.count == 2, - ["page", "pages"].contains(components[1]), let pageCount = Int(components[0]) - { - tmpPageCount = pageCount - } - // Extended display mode uses this - if let aLink = div.at_xpath("//a"), aLink["href"]?.contains("uploader") == true { - uploader = aLink.text - } else if div.text == "(Disowned)" { - uploader = div.text - } - } - - guard let coverURL = tmpCoverURL, - let category = tmpCategory, - let (rating, _, _) = try? parseRating(node: node), - let publishedDate = tmpPublishedDate, - let pageCount = tmpPageCount - else { throw AppError.parseFailed } - return (coverURL, category, rating, publishedDate, pageCount, uploader) - } - func parseGalleryTitle(node: XMLElement) throws -> (String, URL) { - func findTitle(glink: XMLElement) throws -> (String, URL) { - guard let glinkParentNode = glink.parent, - let glinkGrandParentNode = glinkParentNode.parent, - let title = glink.text, - let urlString = glinkParentNode["href"] ?? glinkGrandParentNode["href"], - let url = URL(string: urlString), - url.pathComponents.count >= 4 - else { throw AppError.parseFailed } - return (title, url) - } - - for glink in node.xpath("//div") where glink.className?.contains("glink") == true { - if let result = try? findTitle(glink: glink) { - return result - } - } - for glink in node.xpath("//span") where glink.className?.contains("glink") == true { - if let result = try? findTitle(glink: glink) { - return result - } - } - throw AppError.parseFailed - } - func parseGalleryTags(node: XMLElement?) throws -> [GalleryTag] { - guard let node = node else { throw AppError.parseFailed } - var tags = [GalleryTag]() - for tagLink in node.xpath("//div") - where ["gt", "gtl"].contains(tagLink.className) && tagLink["title"]?.isEmpty == false { - guard let titleComponents = tagLink["title"]?.split(separator: ":"), - titleComponents.count == 2 - else { continue } - var contentTextColor: Color? - var contentBackgroundColor: Color? - let namespace = String(titleComponents[0]) - let contentText = String(titleComponents[1]) - if let style = tagLink["style"], let rangeB = style.range(of: ",#"), - let rangeA = style.range(of: "background:radial-gradient(#") - { - let hex = String(style[rangeA.upperBound.. 151 { - contentTextColor = .secondary - } else { - contentTextColor = .white - } - } - } - if let index = tags.firstIndex(where: { $0.rawNamespace == namespace }) { - let contents = tags[index].contents - let galleryTagContent = GalleryTag.Content( - rawNamespace: namespace, text: contentText, - isVotedUp: false, isVotedDown: false, - textColor: contentTextColor, - backgroundColor: contentBackgroundColor - ) - let newContents = contents + [galleryTagContent] - tags[index] = .init(rawNamespace: namespace, contents: newContents) - } else { - let galleryTagContent = GalleryTag.Content( - rawNamespace: namespace, text: contentText, - isVotedUp: false, isVotedDown: false, - textColor: contentTextColor, - backgroundColor: contentBackgroundColor - ) - tags.append(.init(rawNamespace: namespace, contents: [galleryTagContent])) - } - } - return tags - } - func parseUploader(node: XMLElement) throws -> String { - var tmpUploader: String? - for link in node.xpath("//td") where link.className?.contains("glhide") == true { - for divLink in link.xpath("//div") - where ["page", "pages"].contains(where: { divLink.text?.contains($0) != false }) == false { - if let aLink = divLink.at_xpath("//a"), - aLink["href"]?.contains("uploader") == true, - let aText = aLink.text - { - tmpUploader = aText - } else if divLink.text == "(Disowned)" { - tmpUploader = divLink.text - } - } - } - guard let uploader = tmpUploader else { throw AppError.parseFailed } - return uploader - } - - // MARK: Galleries (Minimal) - func parseMinimalModeGalleries(doc: HTMLDocument, parsesTags: Bool) throws -> [Gallery] { - var galleries = [Gallery]() - for link in doc.xpath("//tr") { - let gltmNode = link.at_xpath("//div [@class='gltm']") - let tags = (try? parseGalleryTags(node: gltmNode)) ?? [] - guard let gl2mNode = link.at_xpath("//td [@class='gl2m']"), - let gl3mNode = link.at_xpath("//td [@class='gl3m glname']"), - let (coverURL, category, rating, publishedDate, pageCount, _) = - try? parseThumbnailPanel(node: gl2mNode), - let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3mNode) - else { continue } - galleries.append( - .init( - gid: galleryURL.pathComponents[2], - token: galleryURL.pathComponents[3], - title: galleryTitle, - rating: rating, - tags: parsesTags ? tags : [], - category: category, - uploader: try? parseUploader(node: link), - pageCount: pageCount, - postedDate: publishedDate, - coverURL: coverURL, - galleryURL: galleryURL - ) - ) - } - return galleries - } - // MARK: Galleries (Compact) - func parseCompactModeGalleries(doc: HTMLDocument) throws -> [Gallery] { - var galleries = [Gallery]() - for link in doc.xpath("//tr") { - guard let gl2cNode = link.at_xpath("//td [@class='gl2c']"), - let gl3cNode = link.at_xpath("//td [@class='gl3c glname']"), - let (coverURL, category, rating, publishedDate, pageCount, _) = - try? parseThumbnailPanel(node: gl2cNode), - let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3cNode) - else { continue } - galleries.append( - .init( - gid: galleryURL.pathComponents[2], - token: galleryURL.pathComponents[3], - title: galleryTitle, - rating: rating, - tags: (try? parseGalleryTags(node: gl3cNode)) ?? [], - category: category, - uploader: try? parseUploader(node: link), - pageCount: pageCount, - postedDate: publishedDate, - coverURL: coverURL, - galleryURL: galleryURL - ) - ) - } - - return galleries - } - // MARK: Galleries (Extended) - func parseExtendedModeGalleries(doc: HTMLDocument) throws -> [Gallery] { - var galleries = [Gallery]() - for link in doc.xpath("//tr") { - guard let gl3eSiblingNode = link.at_xpath("//div [@class='gl3e']")?.nextSibling, - let (coverURL, category, rating, publishedDate, pageCount, uploader) = - try? parseThumbnailPanel(node: link), - let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3eSiblingNode) - else { continue } - galleries.append( - .init( - gid: galleryURL.pathComponents[2], - token: galleryURL.pathComponents[3], - title: galleryTitle, - rating: rating, - tags: (try? parseGalleryTags(node: gl3eSiblingNode)) ?? [], - category: category, - uploader: uploader, - pageCount: pageCount, - postedDate: publishedDate, - coverURL: coverURL, - galleryURL: galleryURL - ) - ) - } - return galleries - } - // MARK: Galleries (Thumbnail) - func parseThumbnailModeGalleries(doc: HTMLDocument) throws -> [Gallery] { - var galleries = [Gallery]() - for link in doc.xpath("//div [@class='gl1t']") { - let gl6tNode = link.at_xpath("//div [@class='gl6t']") - guard let (coverURL, category, rating, publishedDate, pageCount, _) = - try? parseThumbnailPanel(node: link), - let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: link) - else { continue } - galleries.append( - .init( - gid: galleryURL.pathComponents[2], - token: galleryURL.pathComponents[3], - title: galleryTitle, - rating: rating, - tags: (try? parseGalleryTags(node: gl6tNode)) ?? [], - category: category, - pageCount: pageCount, - postedDate: publishedDate, - coverURL: coverURL, - galleryURL: galleryURL - ) - ) - } - return galleries - } - - let galleries: [Gallery] - switch try? parseDisplayMode(doc: doc) { - case "Minimal": - galleries = (try? parseMinimalModeGalleries(doc: doc, parsesTags: false)) ?? [] - case "Minimal+": - galleries = (try? parseMinimalModeGalleries(doc: doc, parsesTags: true)) ?? [] - case "Compact": - galleries = (try? parseCompactModeGalleries(doc: doc)) ?? [] - case "Extended": - galleries = (try? parseExtendedModeGalleries(doc: doc)) ?? [] - case "Thumbnail": - galleries = (try? parseThumbnailModeGalleries(doc: doc)) ?? [] - default: - // Toplists doesn't have a display mode selector and it's compact mode - galleries = (try? parseCompactModeGalleries(doc: doc)) ?? [] - } - - if galleries.isEmpty, let banInterval = parseBanInterval(doc: doc) { - throw AppError.ipBanned(banInterval) - } - return galleries - } - - // MARK: Detail - static func parseGalleryURL(doc: HTMLDocument) throws -> URL { - guard let galleryURLString = doc.at_xpath("//div [@class='sb']")?.at_xpath("//a")?["href"], - let galleryURL = URL(string: galleryURLString) else { throw AppError.parseFailed } - return galleryURL - } - static func parseGalleryDetail(doc: HTMLDocument, gid: String) throws -> (GalleryDetail, GalleryState) { - func parsePreviewConfig(doc: HTMLDocument) throws -> PreviewConfig { - guard let previewMode = try? parsePreviewMode(doc: doc), - let gpcText = doc.at_xpath("//p [@class='gpc']")?.text, - let rangeA = gpcText.range(of: "Showing 1 - "), - let rangeB = gpcText.range(of: " of "), - let singlePageCount = Int(gpcText[rangeA.upperBound.. URL { - guard let coverHTML = node?.at_xpath("//div [@id='gd1']")?.innerHTML, - let rangeA = coverHTML.range(of: "url("), let rangeB = coverHTML.range(of: ")"), - let url = URL(string: .init(coverHTML[rangeA.upperBound.. [GalleryTag] { - var tags = [GalleryTag]() - for link in node.xpath("//tr") { - guard let tcText = link.at_xpath("//td [@class='tc']")?.text else { continue } - let namespace = String(tcText.dropLast()) - var contents = [GalleryTag.Content]() - for divLink in link.xpath("//div") { - guard var text = divLink.text, let aClass = divLink.at_xpath("//a")?.className else { continue } - if let range = text.range(of: " | ") { - text = .init(text[.. (URL?, Int) { - guard let node = node else { throw AppError.parseFailed } - - var archiveURL: URL? - for g2gspLink in node.xpath("//p [@class='g2 gsp']") { - if archiveURL == nil { - archiveURL = try? parseArchiveURL(node: g2gspLink) - } else { - break - } - } - - var tmpTorrentCount: Int? - for g2Link in node.xpath("//p [@class='g2']") { - if let aText = g2Link.at_xpath("//a")?.text, - let rangeA = aText.range(of: "Torrent Download ("), - let rangeB = aText.range(of: ")") - { - tmpTorrentCount = Int(aText[rangeA.upperBound.. [String] { - guard let object = node?.xpath("//tr") - else { throw AppError.parseFailed } - - var infoPanel = Array( - repeating: "", - count: 8 - ) - for gddLink in object { - guard let gdt1Text = gddLink.at_xpath("//td [@class='gdt1']")?.text, - let gdt2Text = gddLink.at_xpath("//td [@class='gdt2']")?.text - else { continue } - let aHref = gddLink.at_xpath("//td [@class='gdt2']")?.at_xpath("//a")?["href"] - - if gdt1Text.contains("Posted") { - infoPanel[0] = gdt2Text - } - if gdt1Text.contains("Parent") { - infoPanel[1] = aHref ?? "None" - } - if gdt1Text.contains("Visible") { - infoPanel[2] = gdt2Text - } - if gdt1Text.contains("Language") { - let words = gdt2Text.split(separator: " ") - if !words.isEmpty { - infoPanel[3] = words[0] - .trimmingCharacters(in: .whitespaces) - } - } - if gdt1Text.contains("File Size") { - infoPanel[4] = gdt2Text - .replacingOccurrences(of: " KiB", with: "") - .replacingOccurrences(of: " MiB", with: "") - .replacingOccurrences(of: " GiB", with: "") - - if gdt2Text.contains("KiB") { infoPanel[5] = "KiB" } - if gdt2Text.contains("MiB") { infoPanel[5] = "MiB" } - if gdt2Text.contains("GiB") { infoPanel[5] = "GiB" } - } - if gdt1Text.contains("Length") { - infoPanel[6] = gdt2Text.replacingOccurrences(of: " pages", with: "") - } - if gdt1Text.contains("Favorited") { - infoPanel[7] = gdt2Text - .replacingOccurrences(of: " times", with: "") - .replacingOccurrences(of: "Never", with: "0") - .replacingOccurrences(of: "Once", with: "1") - } - } - - guard infoPanel.filter({ !$0.isEmpty }).count == 8 - else { throw AppError.parseFailed } - - return infoPanel - } - - func parseVisibility(value: String) throws -> GalleryVisibility { - guard value != "Yes" else { return .yes } - guard let rangeA = value.range(of: "("), - let rangeB = value.range(of: ")") - else { throw AppError.parseFailed } - - let reason = String(value[rangeA.upperBound.. String { - guard let gdnNode = node?.at_xpath("//div [@id='gdn']") else { - throw AppError.parseFailed - } - - if let aText = gdnNode.at_xpath("//a")?.text { - return aText - } else if let gdnText = gdnNode.text { - return gdnText - } else { - throw AppError.parseFailed - } - } - - var tmpGalleryDetail: GalleryDetail? - var tmpGalleryState: GalleryState? - for link in doc.xpath("//div [@class='gm']") { - guard tmpGalleryDetail == nil, tmpGalleryState == nil, - let gd3Node = link.at_xpath("//div [@id='gd3']"), - let gd4Node = link.at_xpath("//div [@id='gd4']"), - let gd5Node = link.at_xpath("//div [@id='gd5']"), - let gddNode = gd3Node.at_xpath("//div [@id='gdd']"), - let gdrNode = gd3Node.at_xpath("//div [@id='gdr']"), - let gdfNode = gd3Node.at_xpath("//div [@id='gdf']"), - let coverURL = try? parseCoverURL(node: link), - let tags = try? parseGalleryTags(node: gd4Node), - let previewURLs = try? parsePreviewURLs(doc: doc), - let arcAndTor = try? parseArcAndTor(node: gd5Node), - let infoPanel = try? parseInfoPanel(node: gddNode), - let visibility = try? parseVisibility(value: infoPanel[2]), - let sizeCount = Float(infoPanel[4]), - let pageCount = Int(infoPanel[6]), - let favoritedCount = Int(infoPanel[7]), - let language = Language(rawValue: infoPanel[3]), - let engTitle = link.at_xpath("//h1 [@id='gn']")?.text, - let uploader = try? parseUploader(node: gd3Node), - let (imgRating, textRating, containsUserRating) = try? parseRating(node: gdrNode), - let ratingCount = Int(gdrNode.at_xpath("//span [@id='rating_count']")?.text ?? ""), - let category = Category(rawValue: gd3Node.at_xpath("//div [@id='gdc']")?.text ?? ""), - let postedDate = try? parseDate(time: infoPanel[0], format: Defaults.DateFormat.publish) - else { continue } - - let isFavorited = gdfNode - .at_xpath("//a [@id='favoritelink']")? - .text?.contains("Add to Favorites") == false - let gjText = link.at_xpath("//h1 [@id='gj']")?.text - let jpnTitle = gjText?.isEmpty != false ? nil : gjText - let parentURLString = infoPanel[1].isValidURL ? infoPanel[1] : "" - - tmpGalleryDetail = GalleryDetail( - gid: gid, - title: engTitle, - jpnTitle: jpnTitle, - isFavorited: isFavorited, - visibility: visibility, - rating: containsUserRating ? textRating ?? 0.0 : imgRating, - userRating: containsUserRating ? imgRating : 0.0, - ratingCount: ratingCount, - category: category, - language: language, - uploader: uploader, - postedDate: postedDate, - coverURL: coverURL, - archiveURL: arcAndTor.0, - parentURL: URL(string: parentURLString), - favoritedCount: favoritedCount, - pageCount: pageCount, - sizeCount: sizeCount, - sizeType: infoPanel[5], - torrentCount: arcAndTor.1 - ) - tmpGalleryState = GalleryState( - gid: gid, - tags: tags, - previewURLs: previewURLs, - previewConfig: try? parsePreviewConfig(doc: doc), - comments: parseComments(doc: doc) - ) - break - } - - guard let galleryDetail = tmpGalleryDetail, - let galleryState = tmpGalleryState - else { - if let reason = doc.at_xpath("//div [@class='d']")?.at_xpath("//p")?.text { - if let rangeA = reason.range(of: "copyright claim by "), - let rangeB = reason.range(of: ".Sorry about that.") - { - let owner = String(reason[rangeA.upperBound.. [Int: URL] { - func parseCombinedPreviewURLs(node: XMLElement) -> [Int: URL] { - var previewURLs = [Int: URL]() - - for link in node.xpath("//a") { - if let divNode = link.at_xpath(".//div[@title and @style]"), - let style = divNode["style"], - let rangeA = style.range(of: "width:"), - let rangeB = style.range(of: "px;height:"), - let rangeC = style.range(of: "px;background"), - let rangeD = style.range(of: "url("), - let rangeE = style.range(of: ") -"), - let rangeF = style[rangeE.upperBound...].range(of: "px "), - let urlString = style[rangeD.upperBound.. [Int: URL] { - var previewURLs = [Int: URL]() - - for link in node.xpath("//a") { - if let divNode = link.at_xpath(".//div[@title and @style]"), - let style = divNode["style"], - let rangeA = style.range(of: "url("), - let rangeB = style.range(of: ")"), - let urlString = style[rangeA.upperBound.. [GalleryComment] { - var comments = [GalleryComment]() - for link in doc.xpath("//div [@id='cdiv']") { - for c1Link in link.xpath("//div [@class='c1']") { - guard let c3Node = c1Link.at_xpath("//div [@class='c3']")?.text, - let c6Node = c1Link.at_xpath("//div [@class='c6']"), - let commentID = c6Node["id"]? - .replacingOccurrences(of: "comment_", with: ""), - let rangeA = c3Node.range(of: "Posted on "), - let rangeB = c3Node.range(of: " by:   ") - else { continue } - - var score: String? - if let c5Node = c1Link.at_xpath("//div [@class='c5 nosel']") { - score = c5Node.at_xpath("//span")?.text - } - let author = String(c3Node[rangeB.upperBound...]) - let commentTime = String(c3Node[rangeA.upperBound.. Int? { - // The probable format of page title is "Page [Number]: filename" - ( - title - .components(separatedBy: ":") - .first? - .replacingOccurrences(of: "Page ", with: "") - .trimmingCharacters(in: .whitespaces) - ) - .flatMap(Int.init) - } - - // MARK: ImageURL - static func parseThumbnailURLs(doc: HTMLDocument) throws -> [Int: URL] { - var thumbnailURLs = [Int: URL]() - - guard let gdtNode = doc.at_xpath("//div [@id='gdt']") - else { throw AppError.parseFailed } - - for aLink in gdtNode.xpath("a") { - guard let href = aLink["href"], - let thumbnailURL = URL(string: href), - let divNode = aLink.at_xpath(".//div[@title and @style]"), - let title = divNode["title"], - let index = parseGTX00IndexFromTitle(from: title) - else { continue } - - thumbnailURLs[index] = thumbnailURL - } - - return thumbnailURLs - } - - static func parseSkipServerIdentifier(doc: HTMLDocument) throws -> String { - guard let text = doc.at_xpath("//div [@id='i6']")?.at_xpath("//a [@id='loadfail']")?["onclick"], - let rangeA = text.range(of: "nl('"), let rangeB = text.range(of: "')") - else { throw AppError.parseFailed } - return .init(text[rangeA.upperBound.. (Int, URL, URL?) { - guard let i3Node = doc.at_xpath("//div [@id='i3']"), - let imageURLString = i3Node.at_css("img")?["src"], - let imageURL = URL(string: imageURLString) - else { throw AppError.parseFailed } - - guard let i7Node = doc.at_xpath("//div [@id='i7']"), - let originalImageURLString = i7Node.at_xpath("//a")?["href"], - let originalImageURL = URL(string: originalImageURLString) - else { return (index, imageURL, nil) } - - return (index, imageURL, originalImageURL) - } - - static func parsePreviewMode(doc: HTMLDocument) throws -> String { - if doc.at_xpath("//div [@class='gt100']") != nil { - return "gt100" - } else if doc.at_xpath("//div [@class='gt200']") != nil { - return "gt200" - } else { - throw AppError.parseFailed - } - } - - static func parseMPVKeys(doc: HTMLDocument) throws -> (String, [Int: String]) { - var tmpMPVKey: String? - var imgKeys = [Int: String]() - - for link in doc.xpath("//script [@type='text/javascript']") { - guard let text = link.text, - let rangeA = text.range(of: "mpvkey = \""), - let rangeB = text.range(of: "\";\nvar imagelist = "), - let rangeC = text.range(of: "\"}]") - else { continue } - - tmpMPVKey = String(text[rangeA.upperBound.. User { - var displayName: String? - var avatarURL: URL? - - for ipbLink in doc.xpath("//table [@class='ipbtable']") { - guard let profileName = ipbLink.at_xpath("//div [@id='profilename']")?.text - else { continue } - - displayName = profileName - - for imgLink in ipbLink.xpath("//img") { - guard let imgURLString = imgLink["src"], - imgURLString.contains("forums.e-hentai.org/uploads"), - let imgURL = URL(string: imgURLString) - else { continue } - - avatarURL = imgURL - } - } - if displayName != nil { - return User(displayName: displayName, avatarURL: avatarURL) - } else { - throw AppError.parseFailed - } - } - - // MARK: Archive - static func parseGalleryArchive(doc: HTMLDocument) throws -> GalleryArchive { - guard let node = doc.at_xpath("//table") - else { throw AppError.parseFailed } - - var hathArchives = [GalleryArchive.HathArchive]() - for link in node.xpath("//td") { - var tmpResolution: ArchiveResolution? - var tmpFileSize: String? - var tmpGPPrice: String? - - for pLink in link.xpath("//p") { - if let pText = pLink.text { - if let res = ArchiveResolution(rawValue: pText) { - tmpResolution = res - } - if pText.contains("N/A") { - tmpFileSize = "N/A" - tmpGPPrice = "N/A" - - if tmpResolution != nil { - break - } - } else { - if pText.contains("KiB") - || pText.contains("MiB") - || pText.contains("GiB") - { - tmpFileSize = pText - } else { - tmpGPPrice = pText - } - } - } - } - - guard let resolution = tmpResolution, - let fileSize = tmpFileSize, - let gpPrice = tmpGPPrice - else { continue } - - hathArchives.append( - GalleryArchive.HathArchive( - resolution: resolution, - fileSize: fileSize, - gpPrice: gpPrice - ) - ) - } - - return GalleryArchive(hathArchives: hathArchives) - } - - // MARK: Torrent - static func parseGalleryTorrents(doc: HTMLDocument) -> [GalleryTorrent] { - var torrents = [GalleryTorrent]() - - for link in doc.xpath("//form") { - var tmpPostedTime: String? - var tmpFileSize: String? - var tmpSeedCount: Int? - var tmpPeerCount: Int? - var tmpDownloadCount: Int? - var tmpUploader: String? - var tmpFileName: String? - var tmpHash: String? - var tmpTorrentURL: URL? - - for trLink in link.xpath("//tr") { - for tdLink in trLink.xpath("//td") { - if let tdText = tdLink.text { - if tdText.contains("Posted: ") { - tmpPostedTime = tdText.replacingOccurrences(of: "Posted: ", with: "") - } - if tdText.contains("Size: ") { - tmpFileSize = tdText.replacingOccurrences(of: "Size: ", with: "") - } - if tdText.contains("Seeds: ") { - tmpSeedCount = Int(tdText.replacingOccurrences(of: "Seeds: ", with: "")) - } - if tdText.contains("Peers: ") { - tmpPeerCount = Int(tdText.replacingOccurrences(of: "Peers: ", with: "")) - } - if tdText.contains("Downloads: ") { - tmpDownloadCount = Int(tdText.replacingOccurrences(of: "Downloads: ", with: "")) - } - if tdText.contains("Uploader: ") { - tmpUploader = tdText.replacingOccurrences(of: "Uploader: ", with: "") - } - } - if let aLink = tdLink.at_xpath("//a"), - let aHref = aLink["href"], - let aText = aLink.text, - let aURL = URL(string: aHref), - let range = aURL.lastPathComponent.range(of: ".torrent") - { - tmpHash = String(aURL.lastPathComponent[.. Greeting { - func trim(string: String) -> String? { - if string.contains("EXP") { - return "EXP" - } else if string.contains("Credits") { - return "Credits" - } else if string.contains("GP") { - return "GP" - } else if string.contains("Hath") { - return "Hath" - } else { - return nil - } - } - - func trim(int: String) -> Int? { - Int(int.replacingOccurrences(of: ",", with: "") - .replacingOccurrences(of: " ", with: "")) - } - - guard let node = doc.at_xpath("//div [@id='eventpane']") - else { throw AppError.parseFailed } - - var greeting = Greeting() - for link in node.xpath("//p") { - guard var text = link.text, - text.contains("You gain") == true - else { continue } - - var gainedValues = [String]() - for strongLink in link.xpath("//strong") { - if let strongText = strongLink.text { - gainedValues.append(strongText) - } - } - - var gainedTypes = [String]() - for value in gainedValues { - guard let range = text.range(of: value) else { break } - let removeText = String(text[.. EhSetting { - func parseInt(node: XMLElement, name: String) -> Int? { - var value: Int? - for link in node.xpath("//input [@name='\(name)']") - where link["checked"] == "checked" { - value = Int(link["value"] ?? "") - } - return value - } - func parseEnum(node: XMLElement, name: String) -> T? - where T.RawValue == Int - { - guard let rawValue = parseInt( - node: node, name: name - ) else { return nil } - return T(rawValue: rawValue) - } - func parseString(node: XMLElement, name: String) -> String? { - node.at_xpath("//input [@name='\(name)']")?["value"] - } - func parseTextEditorString(node: XMLElement, name: String) -> String? { - node.at_xpath("//textarea [@name='\(name)']")?.text - } - func parseBool(node: XMLElement, name: String) -> Bool? { - switch parseString(node: node, name: name) { - case "0": return false - case "1": return true - default: return nil - } - } - func parseCheckBoxBool(node: XMLElement, name: String) -> Bool? { - node.at_xpath("//input [@name='\(name)']")?["checked"] == "checked" - } - func parseCapability(node: XMLElement, name: String) -> T? - where T.RawValue == Int - { - var maxValue: Int? - for link in node.xpath("//input [@name='\(name)']") - where link["disabled"] != "disabled" - { - let value = Int(link["value"] ?? "") ?? 0 - if maxValue == nil { - maxValue = value - } else if maxValue ?? 0 < value { - maxValue = value - } - } - return T(rawValue: maxValue ?? 0) - } - func parseSelections(node: XMLElement, name: String) -> [(String, String, Bool)] { - guard let select = node.at_xpath("//select [@name='\(name)']") - else { return [] } - - var selections = [(String, String, Bool)]() - for link in select.xpath("//option") { - guard let name = link.text, - let value = link["value"] - else { continue } - - selections.append((name, value, link["selected"] == "selected")) - } - - return selections - } - - var tmpForm: XMLElement? - for link in doc.xpath("//form [@method='post']") - where link["id"] == nil { - tmpForm = link - } - guard let profileOuter = doc.at_xpath("//div [@id='profile_outer']"), - let form = tmpForm else { throw AppError.parseFailed } - - // swiftlint:disable line_length - var ehProfiles = [EhProfile](); var isCapableOfCreatingNewProfile: Bool?; var capableLoadThroughHathSetting: EhSetting.LoadThroughHathSetting?; var capableImageResolution: EhSetting.ImageResolution?; var capableSearchResultCount: EhSetting.SearchResultCount?; var capableThumbnailConfigSizes = [EhSetting.ThumbnailSize](); var capableThumbnailConfigRowCount: EhSetting.ThumbnailRowCount?; var loadThroughHathSetting: EhSetting.LoadThroughHathSetting?; var browsingCountry: EhSetting.BrowsingCountry?; var imageResolution: EhSetting.ImageResolution?; var imageSizeWidth: Float?; var imageSizeHeight: Float?; var galleryName: EhSetting.GalleryName?; var literalBrowsingCountry: String?; var archiverBehavior: EhSetting.ArchiverBehavior?; var displayMode: EhSetting.DisplayMode?; var showSearchRangeIndicator: Bool?; var enableGalleryThumbnailSelector: Bool?; var disabledCategories = [Bool](); var favoriteCategories = [String](); var favoritesSortOrder: EhSetting.FavoritesSortOrder?; var ratingsColor: String?; var tagFilteringThreshold: Float?; var tagWatchingThreshold: Float?; var showFilteredRemovalCount: Bool?; var excludedLanguages = [Bool](); var excludedUploaders: String?; var searchResultCount: EhSetting.SearchResultCount?; var thumbnailLoadTiming: EhSetting.ThumbnailLoadTiming?; var thumbnailConfigSize: EhSetting.ThumbnailSize?; var thumbnailConfigRows: EhSetting.ThumbnailRowCount?; var coverScaleFactor: Float?; var viewportVirtualWidth: Float?; var commentsSortOrder: EhSetting.CommentsSortOrder?; var commentVotesShowTiming: EhSetting.CommentVotesShowTiming?; var tagsSortOrder: EhSetting.TagsSortOrder?; var galleryPageNumbers: EhSetting.GalleryPageNumbering?; var useOriginalImages: Bool?; var useMultiplePageViewer: Bool?; var multiplePageViewerStyle: EhSetting.MultiplePageViewerStyle?; var multiplePageViewerShowThumbnailPane: Bool? - // swiftlint:enable line_length - - ehProfiles = parseSelections(node: profileOuter, name: "profile_set") - .compactMap { (name, value, isSelected) in - guard let value = Int(value) else { return nil } - return EhProfile(value: value, name: name, isSelected: isSelected) - } - - for button in profileOuter.xpath("//input [@type='button']") { - if button["value"] == "Create New" { - isCapableOfCreatingNewProfile = true - break - } else { - isCapableOfCreatingNewProfile = false - } - } - - for optouter in form.xpath("//div [@class='optouter']") { - if optouter.at_xpath("//input [@name='uh']") != nil { - loadThroughHathSetting = parseEnum(node: optouter, name: "uh") - capableLoadThroughHathSetting = parseCapability(node: optouter, name: "uh") - } - if optouter.at_xpath("//select [@name='co']") != nil { - var value = parseSelections(node: optouter, name: "co").filter(\.2).first?.1 - - if value == "" { value = "-" } - browsingCountry = EhSetting.BrowsingCountry(rawValue: value ?? "") - - if let pText = optouter.at_xpath("//p")?.text, - let rangeA = pText.range(of: "You appear to be browsing the site from "), - let rangeB = pText.range(of: " or use a VPN or proxy in this country") - { - literalBrowsingCountry = String(pText[rangeA.upperBound.. EhSetting.ThumbnailSize? = { - switch $0 { - case 0: .auto - case 1: .normal - case 2: .small - default: nil - } - } - for option in options where option.isEnabled { - if let size = thumbnailSize(option.value) { - capableThumbnailConfigSizes.append(size) - } - } - if let selectedSize = (options.first(where: \.isSelected)?.value).flatMap(thumbnailSize) { - thumbnailConfigSize = selectedSize - } - } - if optouter.at_xpath("//input [@name='tr']") != nil { - thumbnailConfigRows = parseEnum(node: optouter, name: "tr") - capableThumbnailConfigRowCount = parseCapability(node: optouter, name: "tr") - } - if optouter.at_xpath("//input [@name='tp']") != nil { - coverScaleFactor = Float(parseString(node: optouter, name: "tp") ?? "100") - if coverScaleFactor == nil { coverScaleFactor = 100 } - } - if optouter.at_xpath("//input [@name='vp']") != nil { - viewportVirtualWidth = Float(parseString(node: optouter, name: "vp") ?? "0") - if viewportVirtualWidth == nil { viewportVirtualWidth = 0 } - } - if optouter.at_xpath("//input [@name='cs']") != nil { - commentsSortOrder = parseEnum(node: optouter, name: "cs") - } - if optouter.at_xpath("//input [@name='sc']") != nil { - commentVotesShowTiming = parseEnum(node: optouter, name: "sc") - } - if optouter.at_xpath("//input [@name='tb']") != nil { - tagsSortOrder = parseEnum(node: optouter, name: "tb") - } - if optouter.at_xpath("//input [@name='pn']") != nil { - galleryPageNumbers = parseEnum(node: optouter, name: "pn") - } - if optouter.at_xpath("//input [@name='oi']") != nil { - useOriginalImages = parseInt(node: optouter, name: "oi") == 1 - } - if optouter.at_xpath("//input [@name='qb']") != nil { - useMultiplePageViewer = parseInt(node: optouter, name: "qb") == 1 - } - if optouter.at_xpath("//input [@name='ms']") != nil { - multiplePageViewerStyle = parseEnum(node: optouter, name: "ms") - } - if optouter.at_xpath("//input [@name='mt']") != nil { - multiplePageViewerShowThumbnailPane = parseInt(node: optouter, name: "mt") == 0 - } - } - - // swiftlint:disable line_length - guard !ehProfiles.filter(\.isSelected).isEmpty, let isCapableOfCreatingNewProfile, let capableLoadThroughHathSetting, let capableImageResolution, let capableSearchResultCount, !capableThumbnailConfigSizes.isEmpty, let capableThumbnailConfigRowCount, let loadThroughHathSetting, let browsingCountry, let literalBrowsingCountry, let imageResolution, let imageSizeWidth, let imageSizeHeight, let galleryName, let archiverBehavior, let displayMode, let showSearchRangeIndicator, let enableGalleryThumbnailSelector, disabledCategories.count == 10, favoriteCategories.count == 10, let favoritesSortOrder, let ratingsColor, let tagFilteringThreshold, let tagWatchingThreshold, let showFilteredRemovalCount, excludedLanguages.count == 50, let excludedUploaders, let searchResultCount, let thumbnailLoadTiming, let thumbnailConfigSize, let thumbnailConfigRows, let coverScaleFactor, let viewportVirtualWidth, let commentsSortOrder, let commentVotesShowTiming, let tagsSortOrder, let galleryPageNumbers - else { throw AppError.parseFailed } - - return EhSetting(ehProfiles: ehProfiles.sorted(), isCapableOfCreatingNewProfile: isCapableOfCreatingNewProfile, capableLoadThroughHathSetting: capableLoadThroughHathSetting, capableImageResolution: capableImageResolution, capableSearchResultCount: capableSearchResultCount, capableThumbnailConfigRowCount: capableThumbnailConfigRowCount, capableThumbnailConfigSizes: capableThumbnailConfigSizes, loadThroughHathSetting: loadThroughHathSetting, browsingCountry: browsingCountry, literalBrowsingCountry: literalBrowsingCountry, imageResolution: imageResolution, imageSizeWidth: imageSizeWidth, imageSizeHeight: imageSizeHeight, galleryName: galleryName, archiverBehavior: archiverBehavior, displayMode: displayMode, showSearchRangeIndicator: showSearchRangeIndicator, enableGalleryThumbnailSelector: enableGalleryThumbnailSelector, disabledCategories: disabledCategories, favoriteCategories: favoriteCategories, favoritesSortOrder: favoritesSortOrder, ratingsColor: ratingsColor, tagFilteringThreshold: tagFilteringThreshold, tagWatchingThreshold: tagWatchingThreshold, showFilteredRemovalCount: showFilteredRemovalCount, excludedLanguages: excludedLanguages, excludedUploaders: excludedUploaders, searchResultCount: searchResultCount, thumbnailLoadTiming: thumbnailLoadTiming, thumbnailConfigSize: thumbnailConfigSize, thumbnailConfigRows: thumbnailConfigRows, coverScaleFactor: coverScaleFactor, viewportVirtualWidth: viewportVirtualWidth, commentsSortOrder: commentsSortOrder, commentVotesShowTiming: commentVotesShowTiming, tagsSortOrder: tagsSortOrder, galleryPageNumbering: galleryPageNumbers, useOriginalImages: useOriginalImages, useMultiplePageViewer: useMultiplePageViewer, multiplePageViewerStyle: multiplePageViewerStyle, multiplePageViewerShowThumbnailPane: multiplePageViewerShowThumbnailPane - ) - // swiftlint:enable line_length - } - - // MARK: APIKey - static func parseAPIKey(doc: HTMLDocument) throws -> String { - var tmpKey: String? - - for link in doc.xpath("//script [@type='text/javascript']") { - guard let script = link.text, script.contains("apikey"), - let rangeA = script.range(of: ";\nvar apikey = \""), - let rangeB = script.range(of: "\";\nvar average_rating") - else { continue } - - tmpKey = String(script[rangeA.upperBound.. Date { - let formatter = DateFormatter() - formatter.dateFormat = format - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.locale = Locale(identifier: "en_US_POSIX") - - guard let date = formatter.date(from: time) - else { throw AppError.parseFailed } - - return date - } - - // MARK: Rating - /// Returns ratings parsed from stars image / text and if the return contains a userRating . - static func parseRating(node: XMLElement) throws -> (Float, Float?, Bool) { - func parseTextRating(node: XMLElement) throws -> Float { - guard let ratingString = node - .at_xpath("//td [@id='rating_label']")?.text? - .replacingOccurrences(of: "Average: ", with: "") - .replacingOccurrences(of: "Not Yet Rated", with: "0"), - let rating = Float(ratingString) - else { throw AppError.parseFailed } - - return rating - } - - var tmpRatingString: String? - var containsUserRating = false - - for link in node.xpath("//div") where - link.className?.contains("ir") == true - && link["style"]?.isEmpty == false - { - if tmpRatingString != nil { break } - tmpRatingString = link["style"] - containsUserRating = link.className != "ir" - } - - guard let ratingString = tmpRatingString - else { throw AppError.parseFailed } - - var tmpRating: Float? - if ratingString.contains("0px") { tmpRating = 5.0 } - if ratingString.contains("-16px") { tmpRating = 4.0 } - if ratingString.contains("-32px") { tmpRating = 3.0 } - if ratingString.contains("-48px") { tmpRating = 2.0 } - if ratingString.contains("-64px") { tmpRating = 1.0 } - if ratingString.contains("-80px") { tmpRating = 0.0 } - - guard var rating = tmpRating - else { throw AppError.parseFailed } - - if ratingString.contains("-21px") { rating -= 0.5 } - return (rating, try? parseTextRating(node: node), containsUserRating) - } - - // MARK: PageNumber - static func parsePageNum(doc: HTMLDocument) -> PageNumber { - var current = 0 - var maximum = 0 - - guard let link = doc.at_xpath("//table [@class='ptt']"), - let currentStr = link.at_xpath("//td [@class='ptds']")?.text - else { - if let link = doc.at_xpath("//div [@class='searchnav']") { - var timestamp: String? - var isEnabled = false - - for aLink in link.xpath("//a") where aLink.text?.contains("Next") == true { - timestamp = aLink["href"] - .map(URLComponents.init)?? - .queryItems? - .first(where: { $0.name == "next" })? - .value? - .split(separator: "-") - .last - .map(String.init) - - isEnabled = true - break - } - - return PageNumber(lastItemTimestamp: timestamp, isNextButtonEnabled: isEnabled) - } else { - return PageNumber(isNextButtonEnabled: false) - } - } - - if let range = currentStr.range(of: "-") { - current = (Int(currentStr[range.upperBound...]) ?? 1) - 1 - } else { - current = (Int(currentStr) ?? 1) - 1 - } - for aLink in link.xpath("//a") { - if let num = Int(aLink.text ?? "") { - maximum = num - 1 - } - } - return PageNumber(current: current, maximum: maximum) - } - - // MARK: SortOrder - static func parseFavoritesSortOrder(doc: HTMLDocument) -> FavoritesSortOrder? { - guard let idoNode = doc.at_xpath("//div [@class='ido']") else { return nil } - for link in idoNode.xpath("//div") where link.className == nil { - guard let aText = link.at_xpath("//div")?.at_xpath("//a")?.text else { continue } - if aText == "Use Posted" { - return .favoritedTime - } else if aText == "Use Favorited" { - return .lastUpdateTime - } - } - return nil - } - - // MARK: Balance - static func parseCurrentFunds(doc: HTMLDocument) throws -> (String, String) { - var tmpGP: String? - var tmpCredits: String? - - for element in doc.xpath("//p") { - if let text = element.text, - let rangeA = text.range(of: "GP"), - let rangeB = text.range(of: "[?]"), - let rangeC = text.range(of: "Credits") - { - tmpGP = String(text[.. String { - guard let dbNode = doc.at_xpath("//div [@id='db']") - else { throw AppError.parseFailed } - - var response = [String]() - for pLink in dbNode.xpath("//p") { - if let pText = pLink.text { - response.append(pText) - } - } - - var respString = response.joined(separator: " ") - - if let rangeA = respString.range(of: "A ") ?? respString.range(of: "An "), - let rangeB = respString.range(of: "resolution"), - let rangeC = respString.range(of: "client"), - let rangeD = respString.range(of: "Downloads") - { - let resp = String(respString[rangeA.upperBound.. " + clientName - } else { - respString = resp - } - } - } - - return respString - } - - // MARK: ArchiveURL - static func parseArchiveURL(node: XMLElement) throws -> URL { - var archiveURL: URL? - if let aLink = node.at_xpath("//a"), - aLink.text?.contains("Archive Download") == true, let onClick = aLink["onclick"], - let rangeA = onClick.range(of: "popUp('"), let rangeB = onClick.range(of: "',") - { - archiveURL = URL(string: .init(onClick[rangeA.upperBound.. [Int: String] { - var favoriteCategories = [Int: String]() - - for link in doc.xpath("//div [@id='favsel']") { - for inputLink in link.xpath("//input") { - guard let name = inputLink["name"], - let value = inputLink["value"], - let type = FavoritesType(rawValue: name) - else { continue } - - favoriteCategories[type.index] = value - } - } - - if !favoriteCategories.isEmpty { - return favoriteCategories - } else { - throw AppError.parseFailed - } - } - - // MARK: Profile - static func parseProfileIndex(doc: HTMLDocument) throws -> VerifyEhProfileResponse { - var profileNotFound = true - var profileValue: Int? - - let selector = doc.at_xpath("//select [@name='profile_set']") - let options = selector?.xpath("//option") - - guard let options = options, options.count >= 1 - else { throw AppError.parseFailed } - - for link in options where EhSetting.verifyEhPandaProfileName(with: link.text) { - profileNotFound = false - profileValue = Int(link["value"] ?? "") - } - - return .init(profileValue: profileValue, isProfileNotFound: profileNotFound) - } - - // MARK: CommentContent - static func parseCommentContent(node: XMLElement) -> [CommentContent] { - var contents = [CommentContent]() - - for div in node.xpath("//div") { - node.removeChild(div) - } - for span in node.xpath("span") { - node.removeChild(span) - } - - guard var rawContent = node.innerHTML? - .replacingOccurrences(of: "
", with: "\n") - .replacingOccurrences(of: "", with: "") - else { return [] } - - while (node.xpath("//a").count - + node.xpath("//img").count) > 0 - { - var tmpLink: XMLElement? - - let links = [ - node.at_xpath("//a"), - node.at_xpath("//img") - ] - .compactMap({ $0 }) - - links.forEach { newLink in - if tmpLink == nil { - tmpLink = newLink - } else { - if let tmpHTML = tmpLink?.toHTML, - let newHTML = newLink.toHTML, - let tmpBound = rawContent.range(of: tmpHTML)?.lowerBound, - let newBound = rawContent.range(of: newHTML)?.lowerBound, - newBound < tmpBound - { - tmpLink = newLink - } - } - } - - guard let link = tmpLink, - let html = link.toHTML? - .replacingOccurrences(of: "
", with: "\n") - .replacingOccurrences(of: "", with: ""), - let range = rawContent.range(of: html) - else { continue } - - let text = String(rawContent[.. (URL, CGSize, CGSize)? { - guard var components = URLComponents( - url: url, resolvingAgainstBaseURL: false - ), - let queryItems = components.queryItems - else { return nil } - - let keys = [ - Defaults.URL.Component.Key.ehpandaWidth, - Defaults.URL.Component.Key.ehpandaHeight, - Defaults.URL.Component.Key.ehpandaOffset - ] - let configs = keys.map(\.rawValue).compactMap { key in - queryItems.filter({ $0.name == key }).first?.value - } - .compactMap(Int.init) - - components.queryItems = nil - guard configs.count == keys.count, - let plainURL = components.url - else { return nil } - - let size = CGSize(width: configs[0], height: configs[1]) - return (plainURL, size, CGSize(width: configs[2], height: 0)) - } - - // MARK: parseBanInterval - static func parseBanInterval(doc: HTMLDocument) -> BanInterval? { - guard let text = doc.body?.text, let range = text.range(of: "The ban expires in ") - else { return nil } - - let expireDescription = String(text[range.upperBound...]) - - if let daysRange = expireDescription.range(of: "days"), - let days = Int(expireDescription[.. AppError? { - if let banInterval = parseBanInterval(doc: doc) { - return .ipBanned(banInterval) - } - // Ex login failures commonly surface as a kokomade placeholder wall when `igneous` is missing. - // Reference: https://github.com/OpportunityLiu/E-Viewer/issues/124 - if doc.at_xpath("//img[contains(@src, 'kokomade.jpg')]") != nil { - return .authenticationRequired - } - - for candidate in downloadErrorCandidates(doc: doc) { - if let error = parseDownloadPageError(content: candidate) { - return error - } - } - return nil - } - - static func parseDownloadPageError(content: String) -> AppError? { - let normalizedContent = content.lowercased() - guard normalizedContent.notEmpty else { return nil } - - // Ex login failures commonly surface as a kokomade placeholder wall when `igneous` is missing. - // Reference: https://github.com/OpportunityLiu/E-Viewer/issues/124 - if normalizedContent.contains("kokomade.jpg") - || normalizedContent.contains("access to exhentai.org is restricted") - { - return .authenticationRequired - } - // JDownloader matches these image-limit texts to distinguish quota exhaustion from generic HTML failures. - // Reference: https://github.com/mirror/jdownloader/blob/master/src/jd/plugins/hoster/EHentaiOrg.java - if normalizedContent.contains("you have exceeded your image viewing limits") - || normalizedContent.contains( - "you have reached the image limit, and do not have sufficient gp to buy a download quota" - ) - { - return .quotaExceeded - } - // `Gallery Not Available` is intentionally not mapped to `.expunged` in the download parser. - // gallery-dl treats `404 + Gallery Not Available` as an authorization-like unavailable state: - // https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py - if normalizedContent.contains("gallery not available") - || normalizedContent.contains(L10n.Constant.Website.Response.galleryUnavailable.lowercased()) - { - return nil - } - // JDownloader treats `bounce_login.php` as an account / re-login required signal for EH/EX. - // Reference: https://github.com/mirror/jdownloader/blob/master/src/jd/plugins/hoster/EHentaiOrg.java - if normalizedContent.contains("bounce_login.php"), - !looksLikeGalleryDetailMarkup(normalizedContent) - { - return .authenticationRequired - } - // gallery-dl treats `Key missing` and `Gallery not found` as gallery-level not-found conditions. - // Reference: https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py - if normalizedContent.contains("gallery not found") - || normalizedContent.contains("key missing") - { - return .notFound - } - // gallery-dl treats `Invalid page` and `Keep trying` as image-page not-found conditions. - // Reference: https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py - if normalizedContent.contains("invalid page") - || normalizedContent.contains("keep trying") - { - return .notFound - } - return nil - } - - private static func downloadErrorCandidates(doc: HTMLDocument) -> [String] { - var candidates = [String]() - - let directCandidates = [ - doc.at_xpath("//title")?.text, - doc.at_xpath("//h1")?.text, - doc.at_xpath("//div[@class='d']//p")?.text - ] - for candidate in directCandidates.compactMap(\.self) { - let trimmedCandidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmedCandidate.notEmpty, !candidates.contains(trimmedCandidate) else { continue } - candidates.append(trimmedCandidate) - } - - if let bodyText = doc.body?.text?.trimmingCharacters(in: .whitespacesAndNewlines), - bodyText.notEmpty, - bodyText.count <= 1024, - !candidates.contains(bodyText) - { - candidates.append(bodyText) - } - - if let bodyContent = doc.body?.innerHTML?.trimmingCharacters(in: .whitespacesAndNewlines), - bodyContent.notEmpty, - bodyContent.count <= 2048, - !candidates.contains(bodyContent) - { - candidates.append(bodyContent) - } - - return candidates - } - - private static func looksLikeGalleryDetailMarkup(_ normalizedContent: String) -> Bool { - normalizedContent.contains(#"id="gd1""#) - || normalizedContent.contains(#"id='gd1'"#) - || normalizedContent.contains(#"id="gdt""#) - || normalizedContent.contains(#"id='gdt'"#) - || normalizedContent.contains(#"id="taglist""#) - || normalizedContent.contains(#"id='taglist'"#) - || normalizedContent.contains("gallerypopups.php") - || normalizedContent.contains("api.e-hentai.org/api.php") - || normalizedContent.contains("api.exhentai.org/api.php") - } -} diff --git a/EhPanda/App/Tools/Parser/Parser+Archive.swift b/EhPanda/App/Tools/Parser/Parser+Archive.swift new file mode 100644 index 000000000..7c34ecd6e --- /dev/null +++ b/EhPanda/App/Tools/Parser/Parser+Archive.swift @@ -0,0 +1,106 @@ +import Kanna +import Foundation + +extension Parser { + static func parseGalleryArchive(doc: HTMLDocument) throws -> GalleryArchive { + guard let node = doc.at_xpath("//table") + else { throw AppError.parseFailed } + + var hathArchives = [GalleryArchive.HathArchive]() + for link in node.xpath("//td") { + var tmpResolution: ArchiveResolution? + var tmpFileSize: String? + var tmpGPPrice: String? + + for pLink in link.xpath("//p") { + if let pText = pLink.text { + if let res = ArchiveResolution(rawValue: pText) { + tmpResolution = res + } + if pText.contains("N/A") { + tmpFileSize = "N/A" + tmpGPPrice = "N/A" + + if tmpResolution != nil { + break + } + } else { + if pText.contains("KiB") + || pText.contains("MiB") + || pText.contains("GiB") { + tmpFileSize = pText + } else { + tmpGPPrice = pText + } + } + } + } + + guard let resolution = tmpResolution, + let fileSize = tmpFileSize, + let gpPrice = tmpGPPrice + else { continue } + + hathArchives.append( + GalleryArchive.HathArchive( + resolution: resolution, + fileSize: fileSize, + gpPrice: gpPrice + ) + ) + } + + return GalleryArchive(hathArchives: hathArchives) + } + + static func parseDownloadCommandResponse(doc: HTMLDocument) throws -> String { + guard let dbNode = doc.at_xpath("//div [@id='db']") + else { throw AppError.parseFailed } + + var response = [String]() + for pLink in dbNode.xpath("//p") { + if let pText = pLink.text { + response.append(pText) + } + } + + var respString = response.joined(separator: " ") + + if let rangeA = respString.range(of: "A ") ?? respString.range(of: "An "), + let rangeB = respString.range(of: "resolution"), + let rangeC = respString.range(of: "client"), + let rangeD = respString.range(of: "Downloads") { + let resp = String(respString[rangeA.upperBound.. " + clientName + } else { + respString = resp + } + } + } + + return respString + } + + static func parseArchiveURL(node: XMLElement) throws -> URL { + var archiveURL: URL? + if let aLink = node.at_xpath("//a"), + aLink.text?.contains("Archive Download") == true, let onClick = aLink["onclick"], + let rangeA = onClick.range(of: "popUp('"), let rangeB = onClick.range(of: "',") { + archiveURL = URL(string: .init(onClick[rangeA.upperBound.. [GalleryComment] { + var comments = [GalleryComment]() + for link in doc.xpath("//div [@id='cdiv']") { + for c1Link in link.xpath("//div [@class='c1']") { + guard let c3Node = c1Link.at_xpath("//div [@class='c3']")?.text, + let c6Node = c1Link.at_xpath("//div [@class='c6']"), + let commentID = c6Node["id"]? + .replacingOccurrences(of: "comment_", with: ""), + let rangeA = c3Node.range(of: "Posted on "), + let rangeB = c3Node.range(of: " by:   ") + else { continue } + + var score: String? + if let c5Node = c1Link.at_xpath("//div [@class='c5 nosel']") { + score = c5Node.at_xpath("//span")?.text + } + let author = String(c3Node[rangeB.upperBound...]) + let commentTime = String(c3Node[rangeA.upperBound.. [CommentContent] { + var contents = [CommentContent]() + + for div in node.xpath("//div") { + node.removeChild(div) + } + for span in node.xpath("span") { + node.removeChild(span) + } + + guard var rawContent = node.innerHTML? + .replacingOccurrences(of: "
", with: "\n") + .replacingOccurrences(of: "", with: "") + else { return [] } + + while (node.xpath("//a").count + + node.xpath("//img").count) > 0 { + var tmpLink: XMLElement? + + let links = [ + node.at_xpath("//a"), + node.at_xpath("//img") + ] + .compactMap({ $0 }) + + links.forEach { newLink in + if tmpLink == nil { + tmpLink = newLink + } else { + if let tmpHTML = tmpLink?.toHTML, + let newHTML = newLink.toHTML, + let tmpBound = rawContent.range(of: tmpHTML)?.lowerBound, + let newBound = rawContent.range(of: newHTML)?.lowerBound, + newBound < tmpBound { + tmpLink = newLink + } + } + } + + guard let link = tmpLink, + let html = link.toHTML? + .replacingOccurrences(of: "
", with: "\n") + .replacingOccurrences(of: "", with: ""), + let range = rawContent.range(of: html) + else { continue } + + let text = String(rawContent[.. URL { + guard let galleryURLString = doc.at_xpath("//div [@class='sb']")?.at_xpath("//a")?["href"], + let galleryURL = URL(string: galleryURLString) else { throw AppError.parseFailed } + return galleryURL + } + + // swiftlint:disable:next function_body_length + static func parseGalleryDetail(doc: HTMLDocument, gid: String) throws -> (GalleryDetail, GalleryState) { + var tmpGalleryDetail: GalleryDetail? + var tmpGalleryState: GalleryState? + for link in doc.xpath("//div [@class='gm']") { + guard tmpGalleryDetail == nil, tmpGalleryState == nil, + let gd3Node = link.at_xpath("//div [@id='gd3']"), + let gd4Node = link.at_xpath("//div [@id='gd4']"), + let gd5Node = link.at_xpath("//div [@id='gd5']"), + let gddNode = gd3Node.at_xpath("//div [@id='gdd']"), + let gdrNode = gd3Node.at_xpath("//div [@id='gdr']"), + let gdfNode = gd3Node.at_xpath("//div [@id='gdf']"), + let coverURL = try? parseCoverURL(node: link), + let tags = try? parseGalleryTags(node: gd4Node), + let previewURLs = try? parsePreviewURLs(doc: doc), + let arcAndTor = try? parseArcAndTor(node: gd5Node), + let infoPanel = try? parseInfoPanel(node: gddNode), + let visibility = try? parseVisibility(value: infoPanel[2]), + let sizeCount = Float(infoPanel[4]), + let pageCount = Int(infoPanel[6]), + let favoritedCount = Int(infoPanel[7]), + let language = Language(rawValue: infoPanel[3]), + let engTitle = link.at_xpath("//h1 [@id='gn']")?.text, + let uploader = try? parseUploader(node: gd3Node), + let ratingResult = try? parseRating(node: gdrNode), + let ratingCount = Int(gdrNode.at_xpath("//span [@id='rating_count']")?.text ?? ""), + let category = Category(rawValue: gd3Node.at_xpath("//div [@id='gdc']")?.text ?? ""), + let postedDate = try? parseDate(time: infoPanel[0], format: Defaults.DateFormat.publish) + else { continue } + + let isFavorited = gdfNode + .at_xpath("//a [@id='favoritelink']")? + .text?.contains("Add to Favorites") == false + let gjText = link.at_xpath("//h1 [@id='gj']")?.text + let jpnTitle = gjText?.isEmpty != false ? nil : gjText + let parentURLString = infoPanel[1].isValidURL ? infoPanel[1] : "" + + tmpGalleryDetail = GalleryDetail( + gid: gid, + title: engTitle, + jpnTitle: jpnTitle, + isFavorited: isFavorited, + visibility: visibility, + rating: ratingResult.containsUserRating ? ratingResult.textRating ?? 0.0 : ratingResult.imgRating, + userRating: ratingResult.containsUserRating ? ratingResult.imgRating : 0.0, + ratingCount: ratingCount, + category: category, + language: language, + uploader: uploader, + postedDate: postedDate, + coverURL: coverURL, + archiveURL: arcAndTor.0, + parentURL: URL(string: parentURLString), + favoritedCount: favoritedCount, + pageCount: pageCount, + sizeCount: sizeCount, + sizeType: infoPanel[5], + torrentCount: arcAndTor.1 + ) + tmpGalleryState = GalleryState( + gid: gid, + tags: tags, + previewURLs: previewURLs, + previewConfig: try? parsePreviewConfig(doc: doc), + comments: parseComments(doc: doc) + ) + break + } + + guard let galleryDetail = tmpGalleryDetail, + let galleryState = tmpGalleryState + else { + if let reason = doc.at_xpath("//div [@class='d']")?.at_xpath("//p")?.text { + if let rangeA = reason.range(of: "copyright claim by "), + let rangeB = reason.range(of: ".Sorry about that.") { + let owner = String(reason[rangeA.upperBound.. String { + if doc.at_xpath("//div [@class='gt100']") != nil { + return "gt100" + } else if doc.at_xpath("//div [@class='gt200']") != nil { + return "gt200" + } else { + throw AppError.parseFailed + } + } + + static func parsePreviewConfig(doc: HTMLDocument) throws -> PreviewConfig { + guard let previewMode = try? parsePreviewMode(doc: doc), + let gpcText = doc.at_xpath("//p [@class='gpc']")?.text, + let rangeA = gpcText.range(of: "Showing 1 - "), + let rangeB = gpcText.range(of: " of "), + let singlePageCount = Int(gpcText[rangeA.upperBound.. URL { + guard let coverHTML = node?.at_xpath("//div [@id='gd1']")?.innerHTML, + let rangeA = coverHTML.range(of: "url("), let rangeB = coverHTML.range(of: ")"), + let url = URL(string: .init(coverHTML[rangeA.upperBound.. [GalleryTag] { + var tags = [GalleryTag]() + for link in node.xpath("//tr") { + guard let tcText = link.at_xpath("//td [@class='tc']")?.text else { continue } + let namespace = String(tcText.dropLast()) + var contents = [GalleryTag.Content]() + for divLink in link.xpath("//div") { + guard var text = divLink.text, let aClass = divLink.at_xpath("//a")?.className else { continue } + if let range = text.range(of: " | ") { + text = .init(text[.. (URL?, Int) { + guard let node = node else { throw AppError.parseFailed } + + var archiveURL: URL? + for g2gspLink in node.xpath("//p [@class='g2 gsp']") { + if archiveURL == nil { + archiveURL = try? parseArchiveURL(node: g2gspLink) + } else { + break + } + } + + var tmpTorrentCount: Int? + for g2Link in node.xpath("//p [@class='g2']") { + if let aText = g2Link.at_xpath("//a")?.text, + let rangeA = aText.range(of: "Torrent Download ("), + let rangeB = aText.range(of: ")") { + tmpTorrentCount = Int(aText[rangeA.upperBound.. [String] { + guard let object = node?.xpath("//tr") + else { throw AppError.parseFailed } + + var infoPanel = Array( + repeating: "", + count: 8 + ) + for gddLink in object { + guard let gdt1Text = gddLink.at_xpath("//td [@class='gdt1']")?.text, + let gdt2Text = gddLink.at_xpath("//td [@class='gdt2']")?.text + else { continue } + let aHref = gddLink.at_xpath("//td [@class='gdt2']")?.at_xpath("//a")?["href"] + + if gdt1Text.contains("Posted") { + infoPanel[0] = gdt2Text + } + if gdt1Text.contains("Parent") { + infoPanel[1] = aHref ?? "None" + } + if gdt1Text.contains("Visible") { + infoPanel[2] = gdt2Text + } + if gdt1Text.contains("Language") { + let words = gdt2Text.split(separator: " ") + if !words.isEmpty { + infoPanel[3] = words[0] + .trimmingCharacters(in: .whitespaces) + } + } + if gdt1Text.contains("File Size") { + infoPanel[4] = gdt2Text + .replacingOccurrences(of: " KiB", with: "") + .replacingOccurrences(of: " MiB", with: "") + .replacingOccurrences(of: " GiB", with: "") + + if gdt2Text.contains("KiB") { infoPanel[5] = "KiB" } + if gdt2Text.contains("MiB") { infoPanel[5] = "MiB" } + if gdt2Text.contains("GiB") { infoPanel[5] = "GiB" } + } + if gdt1Text.contains("Length") { + infoPanel[6] = gdt2Text.replacingOccurrences(of: " pages", with: "") + } + if gdt1Text.contains("Favorited") { + infoPanel[7] = gdt2Text + .replacingOccurrences(of: " times", with: "") + .replacingOccurrences(of: "Never", with: "0") + .replacingOccurrences(of: "Once", with: "1") + } + } + + guard infoPanel.filter({ !$0.isEmpty }).count == 8 + else { throw AppError.parseFailed } + + return infoPanel + } + + static func parseVisibility(value: String) throws -> GalleryVisibility { + guard value != "Yes" else { return .yes } + guard let rangeA = value.range(of: "("), + let rangeB = value.range(of: ")") + else { throw AppError.parseFailed } + + let reason = String(value[rangeA.upperBound.. String { + guard let gdnNode = node?.at_xpath("//div [@id='gdn']") else { + throw AppError.parseFailed + } + + if let aText = gdnNode.at_xpath("//a")?.text { + return aText + } else if let gdnText = gdnNode.text { + return gdnText + } else { + throw AppError.parseFailed + } + } +} diff --git a/EhPanda/App/Tools/Parser/Parser+Download.swift b/EhPanda/App/Tools/Parser/Parser+Download.swift new file mode 100644 index 000000000..d449f8aa9 --- /dev/null +++ b/EhPanda/App/Tools/Parser/Parser+Download.swift @@ -0,0 +1,113 @@ +import Kanna + +extension Parser { + static func parseDownloadPageError(doc: HTMLDocument) -> AppError? { + if let banInterval = parseBanInterval(doc: doc) { + return .ipBanned(banInterval) + } + // Ex login failures commonly surface as a kokomade placeholder wall when `igneous` is missing. + // Reference: https://github.com/OpportunityLiu/E-Viewer/issues/124 + if doc.at_xpath("//img[contains(@src, 'kokomade.jpg')]") != nil { + return .authenticationRequired + } + + for candidate in downloadErrorCandidates(doc: doc) { + if let error = parseDownloadPageError(content: candidate) { + return error + } + } + return nil + } + + static func parseDownloadPageError(content: String) -> AppError? { + let normalizedContent = content.lowercased() + guard normalizedContent.notEmpty else { return nil } + + // Ex login failures commonly surface as a kokomade placeholder wall when `igneous` is missing. + // Reference: https://github.com/OpportunityLiu/E-Viewer/issues/124 + if normalizedContent.contains("kokomade.jpg") + || normalizedContent.contains("access to exhentai.org is restricted") { + return .authenticationRequired + } + // JDownloader matches these image-limit texts to distinguish quota exhaustion from generic HTML failures. + // Reference: https://github.com/mirror/jdownloader/blob/master/src/jd/plugins/hoster/EHentaiOrg.java + if normalizedContent.contains("you have exceeded your image viewing limits") + || normalizedContent.contains( + "you have reached the image limit, and do not have sufficient gp to buy a download quota" + ) { + return .quotaExceeded + } + // `Gallery Not Available` is intentionally not mapped to `.expunged` in the download parser. + // gallery-dl treats `404 + Gallery Not Available` as an authorization-like unavailable state: + // https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py + if normalizedContent.contains("gallery not available") + || normalizedContent.contains(L10n.Constant.Website.Response.galleryUnavailable.lowercased()) { + return nil + } + // JDownloader treats `bounce_login.php` as an account / re-login required signal for EH/EX. + // Reference: https://github.com/mirror/jdownloader/blob/master/src/jd/plugins/hoster/EHentaiOrg.java + if normalizedContent.contains("bounce_login.php"), + !looksLikeGalleryDetailMarkup(normalizedContent) { + return .authenticationRequired + } + // gallery-dl treats `Key missing` and `Gallery not found` as gallery-level not-found conditions. + // Reference: https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py + if normalizedContent.contains("gallery not found") + || normalizedContent.contains("key missing") { + return .notFound + } + // gallery-dl treats `Invalid page` and `Keep trying` as image-page not-found conditions. + // Reference: https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py + if normalizedContent.contains("invalid page") + || normalizedContent.contains("keep trying") { + return .notFound + } + return nil + } +} + +// MARK: Helpers +private extension Parser { + static func downloadErrorCandidates(doc: HTMLDocument) -> [String] { + var candidates = [String]() + + let directCandidates = [ + doc.at_xpath("//title")?.text, + doc.at_xpath("//h1")?.text, + doc.at_xpath("//div[@class='d']//p")?.text + ] + for candidate in directCandidates.compactMap(\.self) { + let trimmedCandidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedCandidate.notEmpty, !candidates.contains(trimmedCandidate) else { continue } + candidates.append(trimmedCandidate) + } + + if let bodyText = doc.body?.text?.trimmingCharacters(in: .whitespacesAndNewlines), + bodyText.notEmpty, + bodyText.count <= 1024, + !candidates.contains(bodyText) { + candidates.append(bodyText) + } + + if let bodyContent = doc.body?.innerHTML?.trimmingCharacters(in: .whitespacesAndNewlines), + bodyContent.notEmpty, + bodyContent.count <= 2048, + !candidates.contains(bodyContent) { + candidates.append(bodyContent) + } + + return candidates + } + + static func looksLikeGalleryDetailMarkup(_ normalizedContent: String) -> Bool { + normalizedContent.contains(#"id="gd1""#) + || normalizedContent.contains(#"id='gd1'"#) + || normalizedContent.contains(#"id="gdt""#) + || normalizedContent.contains(#"id='gdt'"#) + || normalizedContent.contains(#"id="taglist""#) + || normalizedContent.contains(#"id='taglist'"#) + || normalizedContent.contains("gallerypopups.php") + || normalizedContent.contains("api.e-hentai.org/api.php") + || normalizedContent.contains("api.exhentai.org/api.php") + } +} diff --git a/EhPanda/App/Tools/Parser/Parser+Favorite.swift b/EhPanda/App/Tools/Parser/Parser+Favorite.swift new file mode 100644 index 000000000..516107c00 --- /dev/null +++ b/EhPanda/App/Tools/Parser/Parser+Favorite.swift @@ -0,0 +1,37 @@ +import Kanna + +extension Parser { + static func parseFavoritesSortOrder(doc: HTMLDocument) -> FavoritesSortOrder? { + guard let idoNode = doc.at_xpath("//div [@class='ido']") else { return nil } + for link in idoNode.xpath("//div") where link.className == nil { + guard let aText = link.at_xpath("//div")?.at_xpath("//a")?.text else { continue } + if aText == "Use Posted" { + return .favoritedTime + } else if aText == "Use Favorited" { + return .lastUpdateTime + } + } + return nil + } + + static func parseFavoriteCategories(doc: HTMLDocument) throws -> [Int: String] { + var favoriteCategories = [Int: String]() + + for link in doc.xpath("//div [@id='favsel']") { + for inputLink in link.xpath("//input") { + guard let name = inputLink["name"], + let value = inputLink["value"], + let type = FavoritesType(rawValue: name) + else { continue } + + favoriteCategories[type.index] = value + } + } + + if !favoriteCategories.isEmpty { + return favoriteCategories + } else { + throw AppError.parseFailed + } + } +} diff --git a/EhPanda/App/Tools/Parser/Parser+Greeting.swift b/EhPanda/App/Tools/Parser/Parser+Greeting.swift new file mode 100644 index 000000000..e810f42c4 --- /dev/null +++ b/EhPanda/App/Tools/Parser/Parser+Greeting.swift @@ -0,0 +1,81 @@ +import Kanna +import Foundation + +extension Parser { + // swiftlint:disable:next cyclomatic_complexity + static func parseGreeting(doc: HTMLDocument) throws -> Greeting { + guard let node = doc.at_xpath("//div [@id='eventpane']") + else { throw AppError.parseFailed } + + var greeting = Greeting() + for link in node.xpath("//p") { + guard var text = link.text, + text.contains("You gain") == true + else { continue } + var gainedTypes = [String]() + var gainedValues = [String]() + for strongLink in link.xpath("//strong") { + if let strongText = strongLink.text { + gainedValues.append(strongText) + } + } + for value in gainedValues { + guard let range = text.range(of: value) else { break } + let removeText = String(text[.. String? { + if string.contains("EXP") { + return "EXP" + } else if string.contains("Credits") { + return "Credits" + } else if string.contains("GP") { + return "GP" + } else if string.contains("Hath") { + return "Hath" + } else { + return nil + } + } + + static func trim(int: String) -> Int? { + Int(int.replacingOccurrences(of: ",", with: "") + .replacingOccurrences(of: " ", with: "")) + } +} diff --git a/EhPanda/App/Tools/Parser/Parser+Image.swift b/EhPanda/App/Tools/Parser/Parser+Image.swift new file mode 100644 index 000000000..1944d15ed --- /dev/null +++ b/EhPanda/App/Tools/Parser/Parser+Image.swift @@ -0,0 +1,84 @@ +import Kanna +import Foundation + +extension Parser { + // MARK: ImageURL + static func parseThumbnailURLs(doc: HTMLDocument) throws -> [Int: URL] { + var thumbnailURLs = [Int: URL]() + + guard let gdtNode = doc.at_xpath("//div [@id='gdt']") + else { throw AppError.parseFailed } + + for aLink in gdtNode.xpath("a") { + guard let href = aLink["href"], + let thumbnailURL = URL(string: href), + let divNode = aLink.at_xpath(".//div[@title and @style]"), + let title = divNode["title"], + let index = parseGTX00IndexFromTitle(from: title) + else { continue } + + thumbnailURLs[index] = thumbnailURL + } + + return thumbnailURLs + } + + static func parseGalleryNormalImageURL(doc: HTMLDocument, index: Int) throws -> GalleryNormalImageInfo { + guard let i3Node = doc.at_xpath("//div [@id='i3']"), + let imageURLString = i3Node.at_css("img")?["src"], + let imageURL = URL(string: imageURLString) + else { throw AppError.parseFailed } + + guard let i7Node = doc.at_xpath("//div [@id='i7']"), + let originalImageURLString = i7Node.at_xpath("//a")?["href"], + let originalImageURL = URL(string: originalImageURLString) + else { + return GalleryNormalImageInfo( + index: index, + imageURL: imageURL, + originalImageURL: nil + ) + } + + return GalleryNormalImageInfo( + index: index, + imageURL: imageURL, + originalImageURL: originalImageURL + ) + } + + static func parseMPVKeys(doc: HTMLDocument) throws -> (String, [Int: String]) { + var tmpMPVKey: String? + var imgKeys = [Int: String]() + + for link in doc.xpath("//script [@type='text/javascript']") { + guard let text = link.text, + let rangeA = text.range(of: "mpvkey = \""), + let rangeB = text.range(of: "\";\nvar imagelist = "), + let rangeC = text.range(of: "\"}]") + else { continue } + + tmpMPVKey = String(text[rangeA.upperBound.. [Gallery] { + let galleries: [Gallery] + switch try? parseDisplayMode(doc: doc) { + case "Minimal": + galleries = (try? parseMinimalModeGalleries(doc: doc, parsesTags: false)) ?? [] + case "Minimal+": + galleries = (try? parseMinimalModeGalleries(doc: doc, parsesTags: true)) ?? [] + case "Compact": + galleries = (try? parseCompactModeGalleries(doc: doc)) ?? [] + case "Extended": + galleries = (try? parseExtendedModeGalleries(doc: doc)) ?? [] + case "Thumbnail": + galleries = (try? parseThumbnailModeGalleries(doc: doc)) ?? [] + default: + // Toplists doesn't have a display mode selector and it's compact mode + galleries = (try? parseCompactModeGalleries(doc: doc)) ?? [] + } + + if galleries.isEmpty, let banInterval = parseBanInterval(doc: doc) { + throw AppError.ipBanned(banInterval) + } + return galleries + } +} + +// MARK: DisplayMode +private extension Parser { + static func parseDisplayMode(doc: HTMLDocument) throws -> String { + guard let containerNode = doc.at_xpath("//div [@id='dms']") ?? doc.at_xpath("//div [@class='searchnav']") + else { throw AppError.parseFailed } + + var dmsNode: XMLElement? + for select in containerNode.xpath("//select") where select["onchange"]?.contains("inline_set=dm_") == true { + dmsNode = select + break + } + guard let dmsNode else { throw AppError.parseFailed } + + for option in dmsNode.xpath("//option") where option["selected"] == "selected" { + if let displayMode = option.text { + return displayMode + } + } + throw AppError.parseFailed + } + + static func parseMinimalModeGalleries(doc: HTMLDocument, parsesTags: Bool) throws -> [Gallery] { + var galleries = [Gallery]() + for link in doc.xpath("//tr") { + let gltmNode = link.at_xpath("//div [@class='gltm']") + let tags = (try? parseGalleryTags(node: gltmNode)) ?? [] + guard let gl2mNode = link.at_xpath("//td [@class='gl2m']"), + let gl3mNode = link.at_xpath("//td [@class='gl3m glname']"), + let panelInfo = try? parseThumbnailPanel(node: gl2mNode), + let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3mNode) + else { continue } + galleries.append( + .init( + gid: galleryURL.pathComponents[2], + token: galleryURL.pathComponents[3], + title: galleryTitle, + rating: panelInfo.rating, + tags: parsesTags ? tags : [], + category: panelInfo.category, + uploader: try? parseUploader(node: link), + pageCount: panelInfo.pageCount, + postedDate: panelInfo.publishedDate, + coverURL: panelInfo.coverURL, + galleryURL: galleryURL + ) + ) + } + return galleries + } + + static func parseCompactModeGalleries(doc: HTMLDocument) throws -> [Gallery] { + var galleries = [Gallery]() + for link in doc.xpath("//tr") { + guard let gl2cNode = link.at_xpath("//td [@class='gl2c']"), + let gl3cNode = link.at_xpath("//td [@class='gl3c glname']"), + let panelInfo = try? parseThumbnailPanel(node: gl2cNode), + let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3cNode) + else { continue } + galleries.append( + .init( + gid: galleryURL.pathComponents[2], + token: galleryURL.pathComponents[3], + title: galleryTitle, + rating: panelInfo.rating, + tags: (try? parseGalleryTags(node: gl3cNode)) ?? [], + category: panelInfo.category, + uploader: try? parseUploader(node: link), + pageCount: panelInfo.pageCount, + postedDate: panelInfo.publishedDate, + coverURL: panelInfo.coverURL, + galleryURL: galleryURL + ) + ) + } + + return galleries + } + + static func parseExtendedModeGalleries(doc: HTMLDocument) throws -> [Gallery] { + var galleries = [Gallery]() + for link in doc.xpath("//tr") { + guard let gl3eSiblingNode = link.at_xpath("//div [@class='gl3e']")?.nextSibling, + let panelInfo = try? parseThumbnailPanel(node: link), + let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3eSiblingNode) + else { continue } + galleries.append( + .init( + gid: galleryURL.pathComponents[2], + token: galleryURL.pathComponents[3], + title: galleryTitle, + rating: panelInfo.rating, + tags: (try? parseGalleryTags(node: gl3eSiblingNode)) ?? [], + category: panelInfo.category, + uploader: panelInfo.uploader, + pageCount: panelInfo.pageCount, + postedDate: panelInfo.publishedDate, + coverURL: panelInfo.coverURL, + galleryURL: galleryURL + ) + ) + } + return galleries + } + + static func parseThumbnailModeGalleries(doc: HTMLDocument) throws -> [Gallery] { + var galleries = [Gallery]() + for link in doc.xpath("//div [@class='gl1t']") { + let gl6tNode = link.at_xpath("//div [@class='gl6t']") + guard let panelInfo = try? parseThumbnailPanel(node: link), + let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: link) + else { continue } + galleries.append( + .init( + gid: galleryURL.pathComponents[2], + token: galleryURL.pathComponents[3], + title: galleryTitle, + rating: panelInfo.rating, + tags: (try? parseGalleryTags(node: gl6tNode)) ?? [], + category: panelInfo.category, + pageCount: panelInfo.pageCount, + postedDate: panelInfo.publishedDate, + coverURL: panelInfo.coverURL, + galleryURL: galleryURL + ) + ) + } + return galleries + } +} + +// MARK: Helpers +private extension Parser { + static func parseThumbnailPanel(node: XMLElement) throws -> ThumbnailPanelInfo { + var tmpCoverURL: URL? + var tmpCategory: Category? + var tmpPublishedDate: Date? + var tmpPageCount: Int? + var uploader: String? + + for div in node.xpath("//div") { + if let imgNode = div.at_css("img"), + let urlString = imgNode["data-src"] ?? imgNode["src"], let url = URL(string: urlString), + [Defaults.URL.torrentDownload, Defaults.URL.torrentDownloadInvalid].map(\.absoluteString) + .contains(where: { $0 == urlString }) == false, imgNode["alt"] != "T" { + tmpCoverURL = url + } + if let rawValue = div.text, let category = Category(rawValue: rawValue) { + tmpCategory = category + } + if let onClick = div["onclick"], !onClick.isEmpty, let dateString = div.text, + let date = try? parseDate(time: dateString, format: Defaults.DateFormat.publish) { + tmpPublishedDate = date + } + if let components = div.text?.split(separator: " "), components.count == 2, + ["page", "pages"].contains(components[1]), let pageCount = Int(components[0]) { + tmpPageCount = pageCount + } + // Extended display mode uses this + if let aLink = div.at_xpath("//a"), aLink["href"]?.contains("uploader") == true { + uploader = aLink.text + } else if div.text == "(Disowned)" { + uploader = div.text + } + } + + guard let coverURL = tmpCoverURL, + let category = tmpCategory, + let ratingResult = try? parseRating(node: node), + let publishedDate = tmpPublishedDate, + let pageCount = tmpPageCount + else { throw AppError.parseFailed } + return ThumbnailPanelInfo( + coverURL: coverURL, + category: category, + rating: ratingResult.imgRating, + publishedDate: publishedDate, + pageCount: pageCount, + uploader: uploader + ) + } + + static func parseGalleryTitle(node: XMLElement) throws -> (String, URL) { + func findTitle(glink: XMLElement) throws -> (String, URL) { + guard let glinkParentNode = glink.parent, + let glinkGrandParentNode = glinkParentNode.parent, + let title = glink.text, + let urlString = glinkParentNode["href"] ?? glinkGrandParentNode["href"], + let url = URL(string: urlString), + url.pathComponents.count >= 4 + else { throw AppError.parseFailed } + return (title, url) + } + + for glink in node.xpath("//div") where glink.className?.contains("glink") == true { + if let result = try? findTitle(glink: glink) { + return result + } + } + for glink in node.xpath("//span") where glink.className?.contains("glink") == true { + if let result = try? findTitle(glink: glink) { + return result + } + } + throw AppError.parseFailed + } + + static func parseGalleryTags(node: XMLElement?) throws -> [GalleryTag] { + guard let node = node else { throw AppError.parseFailed } + var tags = [GalleryTag]() + for tagLink in node.xpath("//div") + where ["gt", "gtl"].contains(tagLink.className) && tagLink["title"]?.isEmpty == false { + guard let titleComponents = tagLink["title"]?.split(separator: ":"), + titleComponents.count == 2 + else { continue } + var contentTextColor: Color? + var contentBackgroundColor: Color? + let namespace = String(titleComponents[0]) + let contentText = String(titleComponents[1]) + if let style = tagLink["style"], let rangeB = style.range(of: ",#"), + let rangeA = style.range(of: "background:radial-gradient(#") { + let hex = String(style[rangeA.upperBound.. 151 { + contentTextColor = .secondary + } else { + contentTextColor = .white + } + } + } + if let index = tags.firstIndex(where: { $0.rawNamespace == namespace }) { + let contents = tags[index].contents + let galleryTagContent = GalleryTag.Content( + rawNamespace: namespace, text: contentText, + isVotedUp: false, isVotedDown: false, + textColor: contentTextColor, + backgroundColor: contentBackgroundColor + ) + let newContents = contents + [galleryTagContent] + tags[index] = .init(rawNamespace: namespace, contents: newContents) + } else { + let galleryTagContent = GalleryTag.Content( + rawNamespace: namespace, text: contentText, + isVotedUp: false, isVotedDown: false, + textColor: contentTextColor, + backgroundColor: contentBackgroundColor + ) + tags.append(.init(rawNamespace: namespace, contents: [galleryTagContent])) + } + } + return tags + } + + static func parseUploader(node: XMLElement) throws -> String { + var tmpUploader: String? + for link in node.xpath("//td") where link.className?.contains("glhide") == true { + for divLink in link.xpath("//div") + where ["page", "pages"].contains(where: { divLink.text?.contains($0) != false }) == false { + if let aLink = divLink.at_xpath("//a"), + aLink["href"]?.contains("uploader") == true, + let aText = aLink.text { + tmpUploader = aText + } else if divLink.text == "(Disowned)" { + tmpUploader = divLink.text + } + } + } + guard let uploader = tmpUploader else { throw AppError.parseFailed } + return uploader + } +} diff --git a/EhPanda/App/Tools/Parser/Parser+Misc.swift b/EhPanda/App/Tools/Parser/Parser+Misc.swift new file mode 100644 index 000000000..d0fef0cec --- /dev/null +++ b/EhPanda/App/Tools/Parser/Parser+Misc.swift @@ -0,0 +1,73 @@ +import Kanna +import Foundation + +extension Parser { + static func parseSkipServerIdentifier(doc: HTMLDocument) throws -> String { + guard let text = doc.at_xpath("//div [@id='i6']")?.at_xpath("//a [@id='loadfail']")?["onclick"], + let rangeA = text.range(of: "nl('"), let rangeB = text.range(of: "')") + else { throw AppError.parseFailed } + return .init(text[rangeA.upperBound.. String { + var tmpKey: String? + + for link in doc.xpath("//script [@type='text/javascript']") { + guard let script = link.text, script.contains("apikey"), + let rangeA = script.range(of: ";\nvar apikey = \""), + let rangeB = script.range(of: "\";\nvar average_rating") + else { continue } + + tmpKey = String(script[rangeA.upperBound.. PageNumber { + var current = 0 + var maximum = 0 + + guard let link = doc.at_xpath("//table [@class='ptt']"), + let currentStr = link.at_xpath("//td [@class='ptds']")?.text + else { + if let link = doc.at_xpath("//div [@class='searchnav']") { + var timestamp: String? + var isEnabled = false + + for aLink in link.xpath("//a") where aLink.text?.contains("Next") == true { + timestamp = aLink["href"] + .map(URLComponents.init)?? + .queryItems? + .first(where: { $0.name == "next" })? + .value? + .split(separator: "-") + .last + .map(String.init) + + isEnabled = true + break + } + + return PageNumber(lastItemTimestamp: timestamp, isNextButtonEnabled: isEnabled) + } else { + return PageNumber(isNextButtonEnabled: false) + } + } + + if let range = currentStr.range(of: "-") { + current = (Int(currentStr[range.upperBound...]) ?? 1) - 1 + } else { + current = (Int(currentStr) ?? 1) - 1 + } + for aLink in link.xpath("//a") { + if let num = Int(aLink.text ?? "") { + maximum = num - 1 + } + } + return PageNumber(current: current, maximum: maximum) + } +} diff --git a/EhPanda/App/Tools/Parser/Parser+Preview.swift b/EhPanda/App/Tools/Parser/Parser+Preview.swift new file mode 100644 index 000000000..8691a2b66 --- /dev/null +++ b/EhPanda/App/Tools/Parser/Parser+Preview.swift @@ -0,0 +1,97 @@ +import Kanna +import Foundation + +extension Parser { + static func parsePreviewURLs(doc: HTMLDocument) throws -> [Int: URL] { + guard let gdtNode = doc.at_xpath("//div [@id='gdt']") + else { throw AppError.parseFailed } + + let combinedURLs = parseCombinedPreviewURLs(node: gdtNode) + return combinedURLs.isEmpty ? parseStandalonePreviewURLs(node: gdtNode) : combinedURLs + } + + static func parsePreviewConfigs(url: URL) -> PreviewConfigInfo? { + guard var components = URLComponents( + url: url, resolvingAgainstBaseURL: false + ), + let queryItems = components.queryItems + else { return nil } + + let keys = [ + Defaults.URL.Component.Key.ehpandaWidth, + Defaults.URL.Component.Key.ehpandaHeight, + Defaults.URL.Component.Key.ehpandaOffset + ] + let configs = keys.map(\.rawValue).compactMap { key in + queryItems.filter({ $0.name == key }).first?.value + } + .compactMap(Int.init) + + components.queryItems = nil + guard configs.count == keys.count, + let plainURL = components.url + else { return nil } + + let size = CGSize(width: configs[0], height: configs[1]) + return PreviewConfigInfo( + plainURL: plainURL, + size: size, + offset: CGSize(width: configs[2], height: 0) + ) + } +} + +private extension Parser { + static func parseCombinedPreviewURLs(node: XMLElement) -> [Int: URL] { + var previewURLs = [Int: URL]() + + for link in node.xpath("//a") { + if let divNode = link.at_xpath(".//div[@title and @style]"), + let style = divNode["style"], + let rangeA = style.range(of: "width:"), + let rangeB = style.range(of: "px;height:"), + let rangeC = style.range(of: "px;background"), + let rangeD = style.range(of: "url("), + let rangeE = style.range(of: ") -"), + let rangeF = style[rangeE.upperBound...].range(of: "px "), + let urlString = style[rangeD.upperBound.. [Int: URL] { + var previewURLs = [Int: URL]() + + for link in node.xpath("//a") { + if let divNode = link.at_xpath(".//div[@title and @style]"), + let style = divNode["style"], + let rangeA = style.range(of: "url("), + let rangeB = style.range(of: ")"), + let urlString = style[rangeA.upperBound.. VerifyEhProfileResponse { + var profileNotFound = true + var profileValue: Int? + + let selector = doc.at_xpath("//select [@name='profile_set']") + let options = selector?.xpath("//option") + + guard let options = options, options.count >= 1 + else { throw AppError.parseFailed } + + for link in options where EhSetting.verifyEhPandaProfileName(with: link.text) { + profileNotFound = false + profileValue = Int(link["value"] ?? "") + } + + return .init(profileValue: profileValue, isProfileNotFound: profileNotFound) + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + static func parseEhSetting(doc: HTMLDocument) throws -> EhSetting { + var tmpForm: XMLElement? + for link in doc.xpath("//form [@method='post']") + where link["id"] == nil { + tmpForm = link + } + guard let profileOuter = doc.at_xpath("//div [@id='profile_outer']"), + let form = tmpForm else { throw AppError.parseFailed } + + // swiftlint:disable line_length + var ehProfiles = [EhProfile](); var isCapableOfCreatingNewProfile: Bool?; var capableLoadThroughHathSetting: EhSetting.LoadThroughHathSetting?; var capableImageResolution: EhSetting.ImageResolution?; var capableSearchResultCount: EhSetting.SearchResultCount?; var capableThumbnailConfigSizes = [EhSetting.ThumbnailSize](); var capableThumbnailConfigRowCount: EhSetting.ThumbnailRowCount?; var loadThroughHathSetting: EhSetting.LoadThroughHathSetting?; var browsingCountry: EhSetting.BrowsingCountry?; var imageResolution: EhSetting.ImageResolution?; var imageSizeWidth: Float?; var imageSizeHeight: Float?; var galleryName: EhSetting.GalleryName?; var literalBrowsingCountry: String?; var archiverBehavior: EhSetting.ArchiverBehavior?; var displayMode: EhSetting.DisplayMode?; var showSearchRangeIndicator: Bool?; var enableGalleryThumbnailSelector: Bool?; var disabledCategories = [Bool](); var favoriteCategories = [String](); var favoritesSortOrder: EhSetting.FavoritesSortOrder?; var ratingsColor: String?; var tagFilteringThreshold: Float?; var tagWatchingThreshold: Float?; var showFilteredRemovalCount: Bool?; var excludedLanguages = [Bool](); var excludedUploaders: String?; var searchResultCount: EhSetting.SearchResultCount?; var thumbnailLoadTiming: EhSetting.ThumbnailLoadTiming?; var thumbnailConfigSize: EhSetting.ThumbnailSize?; var thumbnailConfigRows: EhSetting.ThumbnailRowCount?; var coverScaleFactor: Float?; var viewportVirtualWidth: Float?; var commentsSortOrder: EhSetting.CommentsSortOrder?; var commentVotesShowTiming: EhSetting.CommentVotesShowTiming?; var tagsSortOrder: EhSetting.TagsSortOrder?; var galleryPageNumbers: EhSetting.GalleryPageNumbering?; var useOriginalImages: Bool?; var useMultiplePageViewer: Bool?; var multiplePageViewerStyle: EhSetting.MultiplePageViewerStyle?; var multiplePageViewerShowThumbnailPane: Bool? + // swiftlint:enable line_length + + ehProfiles = parseSelections(node: profileOuter, name: "profile_set") + .compactMap { option in + guard let value = Int(option.value) else { return nil } + return EhProfile(value: value, name: option.name, isSelected: option.isSelected) + } + + for button in profileOuter.xpath("//input [@type='button']") { + if button["value"] == "Create New" { + isCapableOfCreatingNewProfile = true + break + } else { + isCapableOfCreatingNewProfile = false + } + } + + for optouter in form.xpath("//div [@class='optouter']") { + if optouter.at_xpath("//input [@name='uh']") != nil { + loadThroughHathSetting = parseEnum(node: optouter, name: "uh") + capableLoadThroughHathSetting = parseCapability(node: optouter, name: "uh") + } + if optouter.at_xpath("//select [@name='co']") != nil { + var value = parseSelections(node: optouter, name: "co") + .filter(\.isSelected) + .first? + .value + + if value == "" { value = "-" } + browsingCountry = EhSetting.BrowsingCountry(rawValue: value ?? "") + + if let pText = optouter.at_xpath("//p")?.text, + let rangeA = pText.range(of: "You appear to be browsing the site from "), + let rangeB = pText.range(of: " or use a VPN or proxy in this country") { + literalBrowsingCountry = String(pText[rangeA.upperBound.. EhSetting.ThumbnailSize? = { + switch $0 { + case 0: .auto + case 1: .normal + case 2: .small + default: nil + } + } + for option in options where option.isEnabled { + if let size = thumbnailSize(option.value) { + capableThumbnailConfigSizes.append(size) + } + } + if let selectedSize = (options.first(where: \.isSelected)?.value).flatMap(thumbnailSize) { + thumbnailConfigSize = selectedSize + } + } + if optouter.at_xpath("//input [@name='tr']") != nil { + thumbnailConfigRows = parseEnum(node: optouter, name: "tr") + capableThumbnailConfigRowCount = parseCapability(node: optouter, name: "tr") + } + if optouter.at_xpath("//input [@name='tp']") != nil { + coverScaleFactor = Float(parseString(node: optouter, name: "tp") ?? "100") + if coverScaleFactor == nil { coverScaleFactor = 100 } + } + if optouter.at_xpath("//input [@name='vp']") != nil { + viewportVirtualWidth = Float(parseString(node: optouter, name: "vp") ?? "0") + if viewportVirtualWidth == nil { viewportVirtualWidth = 0 } + } + if optouter.at_xpath("//input [@name='cs']") != nil { + commentsSortOrder = parseEnum(node: optouter, name: "cs") + } + if optouter.at_xpath("//input [@name='sc']") != nil { + commentVotesShowTiming = parseEnum(node: optouter, name: "sc") + } + if optouter.at_xpath("//input [@name='tb']") != nil { + tagsSortOrder = parseEnum(node: optouter, name: "tb") + } + if optouter.at_xpath("//input [@name='pn']") != nil { + galleryPageNumbers = parseEnum(node: optouter, name: "pn") + } + if optouter.at_xpath("//input [@name='oi']") != nil { + useOriginalImages = parseInt(node: optouter, name: "oi") == 1 + } + if optouter.at_xpath("//input [@name='qb']") != nil { + useMultiplePageViewer = parseInt(node: optouter, name: "qb") == 1 + } + if optouter.at_xpath("//input [@name='ms']") != nil { + multiplePageViewerStyle = parseEnum(node: optouter, name: "ms") + } + if optouter.at_xpath("//input [@name='mt']") != nil { + multiplePageViewerShowThumbnailPane = parseInt(node: optouter, name: "mt") == 0 + } + } + + // swiftlint:disable line_length + guard !ehProfiles.filter(\.isSelected).isEmpty, let isCapableOfCreatingNewProfile, let capableLoadThroughHathSetting, let capableImageResolution, let capableSearchResultCount, !capableThumbnailConfigSizes.isEmpty, let capableThumbnailConfigRowCount, let loadThroughHathSetting, let browsingCountry, let literalBrowsingCountry, let imageResolution, let imageSizeWidth, let imageSizeHeight, let galleryName, let archiverBehavior, let displayMode, let showSearchRangeIndicator, let enableGalleryThumbnailSelector, disabledCategories.count == 10, favoriteCategories.count == 10, let favoritesSortOrder, let ratingsColor, let tagFilteringThreshold, let tagWatchingThreshold, let showFilteredRemovalCount, excludedLanguages.count == 50, let excludedUploaders, let searchResultCount, let thumbnailLoadTiming, let thumbnailConfigSize, let thumbnailConfigRows, let coverScaleFactor, let viewportVirtualWidth, let commentsSortOrder, let commentVotesShowTiming, let tagsSortOrder, let galleryPageNumbers + else { throw AppError.parseFailed } + + return EhSetting(ehProfiles: ehProfiles.sorted(), isCapableOfCreatingNewProfile: isCapableOfCreatingNewProfile, capableLoadThroughHathSetting: capableLoadThroughHathSetting, capableImageResolution: capableImageResolution, capableSearchResultCount: capableSearchResultCount, capableThumbnailConfigRowCount: capableThumbnailConfigRowCount, capableThumbnailConfigSizes: capableThumbnailConfigSizes, loadThroughHathSetting: loadThroughHathSetting, browsingCountry: browsingCountry, literalBrowsingCountry: literalBrowsingCountry, imageResolution: imageResolution, imageSizeWidth: imageSizeWidth, imageSizeHeight: imageSizeHeight, galleryName: galleryName, archiverBehavior: archiverBehavior, displayMode: displayMode, showSearchRangeIndicator: showSearchRangeIndicator, enableGalleryThumbnailSelector: enableGalleryThumbnailSelector, disabledCategories: disabledCategories, favoriteCategories: favoriteCategories, favoritesSortOrder: favoritesSortOrder, ratingsColor: ratingsColor, tagFilteringThreshold: tagFilteringThreshold, tagWatchingThreshold: tagWatchingThreshold, showFilteredRemovalCount: showFilteredRemovalCount, excludedLanguages: excludedLanguages, excludedUploaders: excludedUploaders, searchResultCount: searchResultCount, thumbnailLoadTiming: thumbnailLoadTiming, thumbnailConfigSize: thumbnailConfigSize, thumbnailConfigRows: thumbnailConfigRows, coverScaleFactor: coverScaleFactor, viewportVirtualWidth: viewportVirtualWidth, commentsSortOrder: commentsSortOrder, commentVotesShowTiming: commentVotesShowTiming, tagsSortOrder: tagsSortOrder, galleryPageNumbering: galleryPageNumbers, useOriginalImages: useOriginalImages, useMultiplePageViewer: useMultiplePageViewer, multiplePageViewerStyle: multiplePageViewerStyle, multiplePageViewerShowThumbnailPane: multiplePageViewerShowThumbnailPane + ) + // swiftlint:enable line_length + } +} + +// MARK: Helpers +private extension Parser { + + static func parseInt(node: XMLElement, name: String) -> Int? { + var value: Int? + for link in node.xpath("//input [@name='\(name)']") + where link["checked"] == "checked" { + value = Int(link["value"] ?? "") + } + return value + } + + static func parseEnum(node: XMLElement, name: String) -> T? where T.RawValue == Int { + guard let rawValue = parseInt( + node: node, name: name + ) else { return nil } + return T(rawValue: rawValue) + } + + static func parseString(node: XMLElement, name: String) -> String? { + node.at_xpath("//input [@name='\(name)']")?["value"] + } + + static func parseTextEditorString(node: XMLElement, name: String) -> String? { + node.at_xpath("//textarea [@name='\(name)']")?.text + } + + static func parseBool(node: XMLElement, name: String) -> Bool? { + switch parseString(node: node, name: name) { + case "0": return false + case "1": return true + default: return nil + } + } + + static func parseCheckBoxBool(node: XMLElement, name: String) -> Bool? { + node.at_xpath("//input [@name='\(name)']")?["checked"] == "checked" + } + + static func parseCapability(node: XMLElement, name: String) -> T? where T.RawValue == Int { + var maxValue: Int? + for link in node.xpath("//input [@name='\(name)']") where link["disabled"] != "disabled" { + let value = Int(link["value"] ?? "") ?? 0 + if maxValue == nil { + maxValue = value + } else if maxValue ?? 0 < value { + maxValue = value + } + } + return T(rawValue: maxValue ?? 0) + } + + static func parseSelections(node: XMLElement, name: String) -> [SelectionOption] { + guard let select = node.at_xpath("//select [@name='\(name)']") + else { return [] } + + var selections = [SelectionOption]() + for link in select.xpath("//option") { + guard let name = link.text, + let value = link["value"] + else { continue } + + selections.append( + SelectionOption( + name: name, + value: value, + isSelected: link["selected"] == "selected" + ) + ) + } + + return selections + } +} diff --git a/EhPanda/App/Tools/Parser/Parser+Shared.swift b/EhPanda/App/Tools/Parser/Parser+Shared.swift new file mode 100644 index 000000000..3ca68c388 --- /dev/null +++ b/EhPanda/App/Tools/Parser/Parser+Shared.swift @@ -0,0 +1,125 @@ +import Kanna +import Foundation + +extension Parser { + static func parseGTX00IndexFromTitle(from title: String) -> Int? { + // The probable format of page title is "Page [Number]: filename" + ( + title + .components(separatedBy: ":") + .first? + .replacingOccurrences(of: "Page ", with: "") + .trimmingCharacters(in: .whitespaces) + ) + .flatMap(Int.init) + } + + static func parseDate(time: String, format: String) throws -> Date { + let formatter = DateFormatter() + formatter.dateFormat = format + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "en_US_POSIX") + + guard let date = formatter.date(from: time) + else { throw AppError.parseFailed } + + return date + } + + // swiftlint:disable cyclomatic_complexity + /// Returns ratings parsed from stars image / text and if the return contains a userRating . + static func parseRating(node: XMLElement) throws -> RatingResult { + func parseTextRating(node: XMLElement) throws -> Float { + guard let ratingString = node + .at_xpath("//td [@id='rating_label']")?.text? + .replacingOccurrences(of: "Average: ", with: "") + .replacingOccurrences(of: "Not Yet Rated", with: "0"), + let rating = Float(ratingString) + else { throw AppError.parseFailed } + + return rating + } + + var tmpRatingString: String? + var containsUserRating = false + + for link in node.xpath("//div") where + link.className?.contains("ir") == true + && link["style"]?.isEmpty == false { + if tmpRatingString != nil { break } + tmpRatingString = link["style"] + containsUserRating = link.className != "ir" + } + + guard let ratingString = tmpRatingString + else { throw AppError.parseFailed } + + var tmpRating: Float? + if ratingString.contains("0px") { tmpRating = 5.0 } + if ratingString.contains("-16px") { tmpRating = 4.0 } + if ratingString.contains("-32px") { tmpRating = 3.0 } + if ratingString.contains("-48px") { tmpRating = 2.0 } + if ratingString.contains("-64px") { tmpRating = 1.0 } + if ratingString.contains("-80px") { tmpRating = 0.0 } + + guard var rating = tmpRating + else { throw AppError.parseFailed } + + if ratingString.contains("-21px") { rating -= 0.5 } + return RatingResult( + imgRating: rating, + textRating: try? parseTextRating(node: node), + containsUserRating: containsUserRating + ) + } + // swiftlint:enable cyclomatic_complexity + + static func parseBanInterval(doc: HTMLDocument) -> BanInterval? { + guard let text = doc.body?.text, let range = text.range(of: "The ban expires in ") + else { return nil } + + let expireDescription = String(text[range.upperBound...]) + + if let daysRange = expireDescription.range(of: "days"), + let days = Int(expireDescription[.. [GalleryTorrent] { + var torrents = [GalleryTorrent]() + + for link in doc.xpath("//form") { + var tmpPostedTime: String? + var tmpFileSize: String? + var tmpSeedCount: Int? + var tmpPeerCount: Int? + var tmpDownloadCount: Int? + var tmpUploader: String? + var tmpFileName: String? + var tmpHash: String? + var tmpTorrentURL: URL? + + for trLink in link.xpath("//tr") { + for tdLink in trLink.xpath("//td") { + if let tdText = tdLink.text { + if tdText.contains("Posted: ") { + tmpPostedTime = tdText.replacingOccurrences(of: "Posted: ", with: "") + } + if tdText.contains("Size: ") { + tmpFileSize = tdText.replacingOccurrences(of: "Size: ", with: "") + } + if tdText.contains("Seeds: ") { + tmpSeedCount = Int(tdText.replacingOccurrences(of: "Seeds: ", with: "")) + } + if tdText.contains("Peers: ") { + tmpPeerCount = Int(tdText.replacingOccurrences(of: "Peers: ", with: "")) + } + if tdText.contains("Downloads: ") { + tmpDownloadCount = Int(tdText.replacingOccurrences(of: "Downloads: ", with: "")) + } + if tdText.contains("Uploader: ") { + tmpUploader = tdText.replacingOccurrences(of: "Uploader: ", with: "") + } + } + if let aLink = tdLink.at_xpath("//a"), + let aHref = aLink["href"], + let aText = aLink.text, + let aURL = URL(string: aHref), + let range = aURL.lastPathComponent.range(of: ".torrent") { + tmpHash = String(aURL.lastPathComponent[.. User { + var displayName: String? + var avatarURL: URL? + + for ipbLink in doc.xpath("//table [@class='ipbtable']") { + guard let profileName = ipbLink.at_xpath("//div [@id='profilename']")?.text + else { continue } + + displayName = profileName + + for imgLink in ipbLink.xpath("//img") { + guard let imgURLString = imgLink["src"], + imgURLString.contains("forums.e-hentai.org/uploads"), + let imgURL = URL(string: imgURLString) + else { continue } + + avatarURL = imgURL + } + } + if displayName != nil { + return User(displayName: displayName, avatarURL: avatarURL) + } else { + throw AppError.parseFailed + } + } + + static func parseCurrentFunds(doc: HTMLDocument) throws -> (String, String) { + var tmpGP: String? + var tmpCredits: String? + + for element in doc.xpath("//p") { + if let text = element.text, + let rangeA = text.range(of: "GP"), + let rangeB = text.range(of: "[?]"), + let rangeC = text.range(of: "Credits") { + tmpGP = String(text[.. Bool { diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift new file mode 100644 index 000000000..f0bb8b54b --- /dev/null +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -0,0 +1,149 @@ +// +// DownloadFileStorage+Operations.swift +// EhPanda +// + +import Foundation + +extension DownloadFileStorage { + func replaceFolder(relativePath: String, with temporaryFolderURL: URL) throws { + let targetURL = folderURL(relativePath: relativePath) + if fileManager.fileExists(atPath: targetURL.path) { + _ = try fileManager.replaceItemAt( + targetURL, + withItemAt: temporaryFolderURL + ) + } else { + try fileManager.moveItem(at: temporaryFolderURL, to: targetURL) + } + } + + func linkOrCopyReadableAsset(at sourceURL: URL, to destinationURL: URL) throws { + guard sanitizeAssetFileIfNeeded(at: sourceURL) else { + throw AppError.fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Error.assetUnreadable(sourceURL.lastPathComponent) + ) + } + + try fileManager.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + + do { + try fileManager.linkItem(at: sourceURL, to: destinationURL) + } catch { + try fileManager.copyItem(at: sourceURL, to: destinationURL) + } + } + + func materializeRepairSeed( + from sourceFolderURL: URL, + manifest: DownloadManifest, + to temporaryFolderURL: URL + ) throws { + try fileManager.createDirectory(at: temporaryFolderURL, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: temporaryFolderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, + isDirectory: true + ), + withIntermediateDirectories: true + ) + + try linkOrCopyReadableAsset( + at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) + ) + + if let coverRelativePath = manifest.coverRelativePath, + coverRelativePath.notEmpty, + let sourceCoverURL = validatedChildURL(root: sourceFolderURL, relativePath: coverRelativePath), + let destCoverURL = validatedChildURL(root: temporaryFolderURL, relativePath: coverRelativePath) { + if sanitizeAssetFileIfNeeded(at: sourceCoverURL) { + try linkOrCopyReadableAsset(at: sourceCoverURL, to: destCoverURL) + } + } + + for page in manifest.pages { + guard let sourcePageURL = validatedChildURL(root: sourceFolderURL, relativePath: page.relativePath), + let destPageURL = validatedChildURL(root: temporaryFolderURL, relativePath: page.relativePath) + else { continue } + guard sanitizeAssetFileIfNeeded(at: sourcePageURL) else { continue } + try linkOrCopyReadableAsset(at: sourcePageURL, to: destPageURL) + } + } + + func removeFolder(relativePath: String) throws { + let targetURL = folderURL(relativePath: relativePath) + guard fileManager.fileExists(atPath: targetURL.path) else { return } + try fileManager.removeItem(at: targetURL) + } + + func cleanupTemporaryFolders(preservingGIDs: Set = []) throws { + guard fileManager.fileExists(atPath: rootURL.path) else { return } + let urls = try fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: nil + ) + for url in urls where url.lastPathComponent.hasPrefix(".tmp-") { + let gid = String(url.lastPathComponent.dropFirst(".tmp-".count)) + if preservingGIDs.contains(gid) { + continue + } + try? fileManager.removeItem(at: url) + } + } + + func validate(download: DownloadedGallery) -> DownloadValidationState { + guard let folderURL = download.resolvedFolderURL(rootURL: rootURL) else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadFolderUnresolved) + } + guard fileManager.fileExists(atPath: folderURL.path) else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadFolderMissing) + } + guard let manifestURL = download.resolvedManifestURL(rootURL: rootURL), + fileManager.fileExists(atPath: manifestURL.path) + else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestMissing) + } + guard let manifest = try? readManifest(folderURL: folderURL) else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestCorrupted) + } + guard manifest.pageCount == manifest.pages.count else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadedPagesIncomplete) + } + if let coverRelativePath = manifest.coverRelativePath, + !coverRelativePath.isEmpty { + guard let coverURL = validatedChildURL(root: folderURL, relativePath: coverRelativePath), + sanitizeAssetFileIfNeeded(at: coverURL) + else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.coverImageMissing) + } + } + for page in manifest.pages { + guard let pageURL = validatedChildURL(root: folderURL, relativePath: page.relativePath), + sanitizeAssetFileIfNeeded(at: pageURL) + else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.pageMissing(page.index)) + } + } + return .valid + } + + func validPageCount(folderURL: URL, manifest: DownloadManifest) -> Int { + manifest.pages.reduce(into: 0) { count, page in + guard let pageURL = validatedChildURL(root: folderURL, relativePath: page.relativePath) else { return } + if sanitizeAssetFileIfNeeded(at: pageURL) { + count += 1 + } + } + } + + func isReadableAssetFile(at url: URL) -> Bool { + sanitizeAssetFileIfNeeded(at: url) + } +} diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index c1a7163da..e1c6c3fa3 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -47,18 +47,18 @@ struct DownloadResumeState: Codable, Equatable { struct DownloadFileStorage { let rootURL: URL let fileManager: FileManager - private let encoder: JSONEncoder - private let decoder: JSONDecoder + let encoder: JSONEncoder + let decoder: JSONDecoder init( rootURL: URL? = FileUtil.downloadsDirectoryURL, fileManager: FileManager = .default ) { self.rootURL = rootURL - ?? FileUtil.temporaryDirectory.appendingPathComponent( - Defaults.FilePath.downloads, - isDirectory: true - ) + ?? FileUtil.temporaryDirectory.appendingPathComponent( + Defaults.FilePath.downloads, + isDirectory: true + ) self.fileManager = fileManager encoder = JSONEncoder() decoder = JSONDecoder() @@ -76,7 +76,7 @@ struct DownloadFileStorage { rootURL.appendingPathComponent(relativePath, isDirectory: true) } - private func validatedChildURL( + func validatedChildURL( root: URL, relativePath: String ) -> URL? { let resolved = root @@ -241,151 +241,8 @@ struct DownloadFileStorage { return try decoder.decode(DownloadManifest.self, from: data) } - func replaceFolder(relativePath: String, with temporaryFolderURL: URL) throws { - let targetURL = folderURL(relativePath: relativePath) - if fileManager.fileExists(atPath: targetURL.path) { - _ = try fileManager.replaceItemAt( - targetURL, - withItemAt: temporaryFolderURL - ) - } else { - try fileManager.moveItem(at: temporaryFolderURL, to: targetURL) - } - } - - func linkOrCopyReadableAsset(at sourceURL: URL, to destinationURL: URL) throws { - guard sanitizeAssetFileIfNeeded(at: sourceURL) else { - throw AppError.fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Error.assetUnreadable(sourceURL.lastPathComponent) - ) - } - - try fileManager.createDirectory( - at: destinationURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - if fileManager.fileExists(atPath: destinationURL.path) { - try fileManager.removeItem(at: destinationURL) - } - - do { - try fileManager.linkItem(at: sourceURL, to: destinationURL) - } catch { - try fileManager.copyItem(at: sourceURL, to: destinationURL) - } - } - - func materializeRepairSeed( - from sourceFolderURL: URL, - manifest: DownloadManifest, - to temporaryFolderURL: URL - ) throws { - try fileManager.createDirectory(at: temporaryFolderURL, withIntermediateDirectories: true) - try fileManager.createDirectory( - at: temporaryFolderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, - isDirectory: true - ), - withIntermediateDirectories: true - ) - - try linkOrCopyReadableAsset( - at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) - ) - - if let coverRelativePath = manifest.coverRelativePath, - coverRelativePath.notEmpty, - let sourceCoverURL = validatedChildURL(root: sourceFolderURL, relativePath: coverRelativePath), - let destCoverURL = validatedChildURL(root: temporaryFolderURL, relativePath: coverRelativePath) - { - if sanitizeAssetFileIfNeeded(at: sourceCoverURL) { - try linkOrCopyReadableAsset(at: sourceCoverURL, to: destCoverURL) - } - } - - for page in manifest.pages { - guard let sourcePageURL = validatedChildURL(root: sourceFolderURL, relativePath: page.relativePath), - let destPageURL = validatedChildURL(root: temporaryFolderURL, relativePath: page.relativePath) - else { continue } - guard sanitizeAssetFileIfNeeded(at: sourcePageURL) else { continue } - try linkOrCopyReadableAsset(at: sourcePageURL, to: destPageURL) - } - } - - func removeFolder(relativePath: String) throws { - let targetURL = folderURL(relativePath: relativePath) - guard fileManager.fileExists(atPath: targetURL.path) else { return } - try fileManager.removeItem(at: targetURL) - } - - func cleanupTemporaryFolders(preservingGIDs: Set = []) throws { - guard fileManager.fileExists(atPath: rootURL.path) else { return } - let urls = try fileManager.contentsOfDirectory( - at: rootURL, - includingPropertiesForKeys: nil - ) - for url in urls where url.lastPathComponent.hasPrefix(".tmp-") { - let gid = String(url.lastPathComponent.dropFirst(".tmp-".count)) - if preservingGIDs.contains(gid) { - continue - } - try? fileManager.removeItem(at: url) - } - } - - func validate(download: DownloadedGallery) -> DownloadValidationState { - guard let folderURL = download.resolvedFolderURL(rootURL: rootURL) else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadFolderUnresolved) - } - guard fileManager.fileExists(atPath: folderURL.path) else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadFolderMissing) - } - guard let manifestURL = download.resolvedManifestURL(rootURL: rootURL), - fileManager.fileExists(atPath: manifestURL.path) - else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestMissing) - } - guard let manifest = try? readManifest(folderURL: folderURL) else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestCorrupted) - } - guard manifest.pageCount == manifest.pages.count else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadedPagesIncomplete) - } - if let coverRelativePath = manifest.coverRelativePath, - !coverRelativePath.isEmpty - { - guard let coverURL = validatedChildURL(root: folderURL, relativePath: coverRelativePath), - sanitizeAssetFileIfNeeded(at: coverURL) - else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.coverImageMissing) - } - } - for page in manifest.pages { - guard let pageURL = validatedChildURL(root: folderURL, relativePath: page.relativePath), - sanitizeAssetFileIfNeeded(at: pageURL) - else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.pageMissing(page.index)) - } - } - return .valid - } - - func validPageCount(folderURL: URL, manifest: DownloadManifest) -> Int { - manifest.pages.reduce(into: 0) { count, page in - guard let pageURL = validatedChildURL(root: folderURL, relativePath: page.relativePath) else { return } - if sanitizeAssetFileIfNeeded(at: pageURL) { - count += 1 - } - } - } - - func isReadableAssetFile(at url: URL) -> Bool { - sanitizeAssetFileIfNeeded(at: url) - } - @discardableResult - private func sanitizeAssetFileIfNeeded(at url: URL) -> Bool { + func sanitizeAssetFileIfNeeded(at url: URL) -> Bool { guard fileManager.fileExists(atPath: url.path) else { return false } let attributes: [FileAttributeKey: Any] diff --git a/EhPanda/App/Tools/Utilities/MarkdownUtil.swift b/EhPanda/App/Tools/Utilities/MarkdownUtil.swift index 03c5e4ddf..e735a3420 100644 --- a/EhPanda/App/Tools/Utilities/MarkdownUtil.swift +++ b/EhPanda/App/Tools/Utilities/MarkdownUtil.swift @@ -13,7 +13,7 @@ struct MarkdownUtil { .compactMap({ $0[case: \.paragraph] }) .flatMap(\.text) .compactMap({ $0[case: \.text] }) - ?? [] + ?? [] } static func parseLinks(markdown: String) -> [URL] { (try? Document(markdown: markdown))?.blocks @@ -21,7 +21,7 @@ struct MarkdownUtil { .flatMap(\.text) .compactMap({ $0[case: \.link] }) .compactMap(\.url) - ?? [] + ?? [] } static func parseImages(markdown: String) -> [URL] { (try? Document(markdown: markdown))?.blocks @@ -36,7 +36,7 @@ struct MarkdownUtil { } return nil } - ?? [] + ?? [] } } @@ -93,13 +93,13 @@ extension Block.AllCasePaths: Sequence { public func makeIterator() -> some IteratorProtocol> { [ \.blockQuote, - \.bulletList, - \.orderedList, - \.code, - \.html, - \.paragraph, - \.heading, - \.thematicBreak + \.bulletList, + \.orderedList, + \.code, + \.html, + \.paragraph, + \.heading, + \.thematicBreak ] .makeIterator() } @@ -161,14 +161,14 @@ extension Inline.AllCasePaths: Sequence { public func makeIterator() -> some IteratorProtocol> { [ \.text, - \.softBreak, - \.lineBreak, - \.code, - \.html, - \.emphasis, - \.strong, - \.link, - \.image + \.softBreak, + \.lineBreak, + \.code, + \.html, + \.emphasis, + \.strong, + \.link, + \.image ] .makeIterator() } diff --git a/EhPanda/App/Tools/Utilities/URLUtil.swift b/EhPanda/App/Tools/Utilities/URLUtil.swift index 0fae2053f..bcc153e61 100644 --- a/EhPanda/App/Tools/Utilities/URLUtil.swift +++ b/EhPanda/App/Tools/Utilities/URLUtil.swift @@ -61,7 +61,7 @@ struct URLUtil { if let sortOrder = sortOrder { url.append(queryItems: [ .inlineSet: sortOrder == .favoritedTime - ? .sortOrderByFavoritedTime : .sortOrderByUpdateTime + ? .sortOrderByFavoritedTime : .sortOrderByUpdateTime ]) } return url @@ -142,6 +142,23 @@ private extension URL { var queryItems1 = [Defaults.URL.Component.Key: String]() var queryItems2 = [Defaults.URL.Component.Key: Defaults.URL.Component.Value]() + applyingCategoryFilter(filter, queryItems1: &queryItems1) + + if !filter.advanced { return appending(queryItems: queryItems1).appending(queryItems: queryItems2) } + queryItems2[.advSearch] = .one + + applyingBasicAdvancedFilter(filter, queryItems2: &queryItems2) + applyingMinRatingFilter(filter, queryItems1: &queryItems1, queryItems2: &queryItems2) + applyingPageRangeFilter(filter, queryItems1: &queryItems1, queryItems2: &queryItems2) + applyingDisableFilter(filter, queryItems2: &queryItems2) + + return appending(queryItems: queryItems1).appending(queryItems: queryItems2) + } + + func applyingCategoryFilter( + _ filter: Filter, + queryItems1: inout [Defaults.URL.Component.Key: String] + ) { var categoryValue = 0 categoryValue += filter.doujinshi ? Category.doujinshi.filterValue : 0 categoryValue += filter.manga ? Category.manga.filterValue : 0 @@ -153,14 +170,15 @@ private extension URL { categoryValue += filter.cosplay ? Category.cosplay.filterValue : 0 categoryValue += filter.asianPorn ? Category.asianPorn.filterValue : 0 categoryValue += filter.misc ? Category.misc.filterValue : 0 - if ![0, 1023].contains(categoryValue) { queryItems1[.fCats] = String(categoryValue) } + } - if !filter.advanced { return appending(queryItems: queryItems1).appending(queryItems: queryItems2) } - queryItems2[.advSearch] = .one - + func applyingBasicAdvancedFilter( + _ filter: Filter, + queryItems2: inout [Defaults.URL.Component.Key: Defaults.URL.Component.Value] + ) { if filter.galleryName { queryItems2[.fSname] = .filterOn } if filter.galleryTags { queryItems2[.fStags] = .filterOn } if filter.galleryDesc { queryItems2[.fSdesc] = .filterOn } @@ -169,41 +187,42 @@ private extension URL { if filter.lowPowerTags { queryItems2[.fSdt1] = .filterOn } if filter.downvotedTags { queryItems2[.fSdt2] = .filterOn } if filter.expungedGalleries { queryItems2[.fSh] = .filterOn } + } + func applyingMinRatingFilter( + _ filter: Filter, + queryItems1: inout [Defaults.URL.Component.Key: String], + queryItems2: inout [Defaults.URL.Component.Key: Defaults.URL.Component.Value] + ) { if filter.minRatingActivated, [2, 3, 4, 5].contains(filter.minRating) { queryItems2[.fSr] = .filterOn queryItems1[.fSrdd] = String(filter.minRating) } + } - if filter.pageRangeActivated { - queryItems2[.fSp] = .filterOn - - switch (Int(filter.pageLowerBound), Int(filter.pageUpperBound)) { - case let (.some(minPages), .some(maxPages)): - if minPages > 0 && maxPages > 0 && minPages <= maxPages { - queryItems1[.fSpf] = String(minPages) - queryItems1[.fSpt] = String(maxPages) - } - - case let (.some(minPages), _): - if minPages > 0 { - queryItems1[.fSpf] = String(minPages) - } - - case let (_, .some(maxPages)): - if maxPages > 0 { - queryItems1[.fSpt] = String(maxPages) - } - - case (.none, .none): - break - } + func applyingPageRangeFilter( + _ filter: Filter, + queryItems1: inout [Defaults.URL.Component.Key: String], + queryItems2: inout [Defaults.URL.Component.Key: Defaults.URL.Component.Value] + ) { + guard filter.pageRangeActivated else { return } + queryItems2[.fSp] = .filterOn + let minPages = Int(filter.pageLowerBound) + let maxPages = Int(filter.pageUpperBound) + if let minPages, minPages > 0 { + queryItems1[.fSpf] = String(minPages) } + if let maxPages, maxPages > 0 { + queryItems1[.fSpt] = String(maxPages) + } + } + func applyingDisableFilter( + _ filter: Filter, + queryItems2: inout [Defaults.URL.Component.Key: Defaults.URL.Component.Value] + ) { if filter.disableLanguage { queryItems2[.fSfl] = .filterOn } if filter.disableUploader { queryItems2[.fSfu] = .filterOn } if filter.disableTags { queryItems2[.fSft] = .filterOn } - - return appending(queryItems: queryItems1).appending(queryItems: queryItems2) } } diff --git a/EhPanda/DataFlow/AppDelegateReducer.swift b/EhPanda/DataFlow/AppDelegateReducer.swift index 4b413e104..7dca95ccd 100644 --- a/EhPanda/DataFlow/AppDelegateReducer.swift +++ b/EhPanda/DataFlow/AppDelegateReducer.swift @@ -65,7 +65,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions - launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { if !AppUtil.isTesting { store.send(.appDelegate(.onLaunchFinish)) diff --git a/EhPanda/DataFlow/AppLockReducer.swift b/EhPanda/DataFlow/AppLockReducer.swift index 14f67490b..2bfdce399 100644 --- a/EhPanda/DataFlow/AppLockReducer.swift +++ b/EhPanda/DataFlow/AppLockReducer.swift @@ -36,8 +36,7 @@ struct AppLockReducer { switch action { case .onBecomeActive(let threshold, let blurRadius): if let date = state.becameInactiveDate, threshold >= 0, - Date.now.timeIntervalSince(date) >= Double(threshold) - { + Date.now.timeIntervalSince(date) >= Double(threshold) { return .merge( .send(.authorize), .send(.lockApp(blurRadius)) diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index d50d150ed..7994c5a47 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -266,8 +266,7 @@ private extension AppReducer { else { return false } if let galleryURL = automation.galleryURL, - galleryURL.host?.contains("exhentai.org") == true - { + galleryURL.host?.contains("exhentai.org") == true { return true } diff --git a/EhPanda/DataFlow/AppRouteReducer.swift b/EhPanda/DataFlow/AppRouteReducer.swift index 61c908956..dc43e793d 100644 --- a/EhPanda/DataFlow/AppRouteReducer.swift +++ b/EhPanda/DataFlow/AppRouteReducer.swift @@ -98,7 +98,7 @@ struct AppRouteReducer { state.route = nil state.detailState.wrappedValue = .init() } - let (isGalleryImageURL, _, _) = urlClient.analyzeURL(url) + let analysis = urlClient.analyzeURL(url) let gid = urlClient.parseGalleryID(url) guard databaseClient.fetchGallery(gid: gid) == nil else { return .run { [delay] send in @@ -108,11 +108,13 @@ struct AppRouteReducer { } return .run { [delay] send in try await Task.sleep(for: .milliseconds(delay)) - await send(.fetchGallery(url, isGalleryImageURL)) + await send(.fetchGallery(url, analysis.isGalleryImageURL)) } case .handleGalleryLink(let url): - let (_, pageIndex, commentID) = urlClient.analyzeURL(url) + let analysis = urlClient.analyzeURL(url) + let pageIndex = analysis.pageIndex + let commentID = analysis.commentID let gid = urlClient.parseGalleryID(url) var effects = [Effect]() state.detailState.wrappedValue = .init() diff --git a/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift b/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift index 13afa9a0e..749ac489f 100755 --- a/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift +++ b/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift @@ -8,7 +8,7 @@ import Foundation extension FileManager { static func clearApplicationSupportDirectoryContents() { guard let applicationSupportURL = FileManager.default.urls( - for: .applicationSupportDirectory, in: .userDomainMask).first, + for: .applicationSupportDirectory, in: .userDomainMask).first, let applicationSupportDirectoryContents = try? FileManager .default.contentsOfDirectory(atPath: applicationSupportURL.path) else { return } diff --git a/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift b/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift index 10820f454..5fac1e09e 100755 --- a/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift +++ b/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift @@ -29,7 +29,7 @@ extension NSPersistentStoreCoordinator { } } - static func metadata(at storeURL: URL) -> [String: Any]? { + static func metadata(at storeURL: URL) -> [String: Any]? { try? NSPersistentStoreCoordinator.metadataForPersistentStore( ofType: NSSQLiteStoreType, at: storeURL, options: nil ) diff --git a/EhPanda/Database/Migration/CoreDataMigrator.swift b/EhPanda/Database/Migration/CoreDataMigrator.swift index 5694e6bee..cbf6ad65f 100755 --- a/EhPanda/Database/Migration/CoreDataMigrator.swift +++ b/EhPanda/Database/Migration/CoreDataMigrator.swift @@ -36,7 +36,7 @@ class CoreDataMigrator: CoreDataMigratorProtocol { ) } catch { let message = "Failed attempting to migrate from \(migrationStep.sourceModel) " - + "to \(migrationStep.destinationModel), error: \(error)." + + "to \(migrationStep.destinationModel), error: \(error)." throw AppError.databaseCorrupted(message) } diff --git a/EhPanda/Database/Persistence.swift b/EhPanda/Database/Persistence.swift index 9f8748ae5..9773d2a49 100644 --- a/EhPanda/Database/Persistence.swift +++ b/EhPanda/Database/Persistence.swift @@ -22,7 +22,7 @@ struct PersistenceController { extension PersistenceController { func prepare(completion: @escaping (Result) -> Void) { do { - try loadPersistentStore(completion: completion) + try loadPersistentStore(completion: completion) } catch { completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) } diff --git a/EhPanda/Models/Gallery/Gallery.swift b/EhPanda/Models/Gallery/Gallery.swift index 8d0aacb6d..072433620 100644 --- a/EhPanda/Models/Gallery/Gallery.swift +++ b/EhPanda/Models/Gallery/Gallery.swift @@ -43,8 +43,8 @@ struct Gallery: Identifiable, Codable, Equatable, Hashable { postedDate: .now, coverURL: URL( string: "https://github.com/" - + "EhPanda-Team/Imageset/blob/" - + "main/JPGs/2.jpg?raw=true" + + "EhPanda-Team/Imageset/blob/" + + "main/JPGs/2.jpg?raw=true" ), galleryURL: nil ) diff --git a/EhPanda/Models/Gallery/GalleryDetail.swift b/EhPanda/Models/Gallery/GalleryDetail.swift index eccd93aff..fd699e8d7 100644 --- a/EhPanda/Models/Gallery/GalleryDetail.swift +++ b/EhPanda/Models/Gallery/GalleryDetail.swift @@ -31,8 +31,8 @@ struct GalleryDetail: Codable, Equatable { postedDate: .distantPast, coverURL: URL( string: "https://github.com/" - + "EhPanda-Team/Imageset/blob/" - + "main/JPGs/2.jpg?raw=true" + + "EhPanda-Team/Imageset/blob/" + + "main/JPGs/2.jpg?raw=true" ), favoritedCount: 514, pageCount: 114, diff --git a/EhPanda/Models/Gallery/Language.swift b/EhPanda/Models/Gallery/Language.swift index dada937d0..9fbe0ec24 100644 --- a/EhPanda/Models/Gallery/Language.swift +++ b/EhPanda/Models/Gallery/Language.swift @@ -28,9 +28,9 @@ extension Language { } var abbreviation: String { switch self { - // swiftlint:disable switch_case_alignment line_length + // swiftlint:disable switch_case_alignment line_length case .invalid, .other: return "N/A"; case .afrikaans: return "AF"; case .albanian: return "SQ"; case .arabic: return "AR"; case .bengali: return "BN"; case .bosnian: return "BS"; case .bulgarian: return "BG"; case .burmese: return "MY"; case .catalan: return "CA"; case .cebuano: return "CEB"; case .chinese: return "ZH"; case .croatian: return "HR"; case .czech: return "CS"; case .danish: return "DA"; case .dutch: return "NL"; case .english: return "EN"; case .esperanto: return "EO"; case .estonian: return "ET"; case .finnish: return "FI"; case .french: return "FR"; case .georgian: return "KA"; case .german: return "DE"; case .greek: return "EL"; case .hebrew: return "HE"; case .hindi: return "HI"; case .hmong: return "HMN"; case .hungarian: return "HU"; case .indonesian: return "ID"; case .italian: return "IT"; case .japanese: return "JA"; case .kazakh: return "KK"; case .khmer: return "KM"; case .korean: return "KO"; case .kurdish: return "KU"; case .lao: return "LO"; case .latin: return "LA"; case .mongolian: return "MN"; case .ndebele: return "ND"; case .nepali: return "NE"; case .norwegian: return "NO"; case .oromo: return "OM"; case .pashto: return "PS"; case .persian: return "FA"; case .polish: return "PL"; case .portuguese: return "PT"; case .punjabi: return "PA"; case .romanian: return "RO"; case .russian: return "RU"; case .sango: return "SG"; case .serbian: return "SR"; case .shona: return "SN"; case .slovak: return "SK"; case .slovenian: return "SL"; case .somali: return "SO"; case .spanish: return "ES"; case .swahili: return "SW"; case .swedish: return "SV"; case .tagalog: return "TL"; case .thai: return "TH"; case .tigrinya: return "TI"; case .turkish: return "TR"; case .ukrainian: return "UK"; case .urdu: return "UR"; case .vietnamese: return "VI"; case .zulu: return "ZU" - // swiftlint:enable switch_case_alignment line_length + // swiftlint:enable switch_case_alignment line_length } } var value: String { diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift new file mode 100644 index 000000000..b12ddb9ff --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -0,0 +1,183 @@ +// +// DownloadedGallery+Extensions.swift +// EhPanda +// + +import SwiftUI + +// MARK: - DownloadBadge +extension DownloadBadge { + var text: String { + switch self { + case .none: + return "" + case .queued: + return L10n.Localizable.Struct.DownloadBadge.Text.queued + case .downloading(let completed, let total): + return L10n.Localizable.Struct.DownloadBadge.Text.downloading(completed, max(total, 1)) + case .paused(let completed, let total): + return L10n.Localizable.Struct.DownloadBadge.Text.paused(completed, max(total, 1)) + case .partial(let completed, let total): + return L10n.Localizable.Struct.DownloadBadge.Text.needsAttentionProgress( + completed, + max(total, 1) + ) + case .downloaded: + return L10n.Localizable.Struct.DownloadBadge.Text.downloaded + case .failed: + return L10n.Localizable.Struct.DownloadBadge.Text.needsAttention + case .updateAvailable: + return L10n.Localizable.Struct.DownloadBadge.Text.updateAvailable + case .missingFiles: + return L10n.Localizable.Struct.DownloadBadge.Text.needsRepair + } + } + + var color: Color { + switch self { + case .none: + return .clear + case .queued: + return .orange + case .downloading: + return .blue + case .paused: + return .indigo + case .partial: + return .orange + case .downloaded: + return .green + case .failed: + return .orange + case .updateAvailable: + return .yellow + case .missingFiles: + return .pink + } + } +} + +// MARK: - DownloadListFilter +enum DownloadListFilter: String, CaseIterable, Identifiable { + case all + case active + case completed + case failed + case update + + var id: String { rawValue } + + var title: String { + switch self { + case .all: + return L10n.Localizable.Enum.DownloadListFilter.Title.all + case .active: + return L10n.Localizable.Enum.DownloadListFilter.Title.active + case .completed: + return L10n.Localizable.Enum.DownloadListFilter.Title.completed + case .failed: + return L10n.Localizable.Enum.DownloadListFilter.Title.failed + case .update: + return L10n.Localizable.Enum.DownloadListFilter.Title.update + } + } +} + +// MARK: - DownloadGalleryFilter +struct DownloadGalleryFilter: Equatable { + var excludedCategories = Set() + var minimumRatingActivated = false + var minimumRating = 2 + var pageRangeActivated = false + var pageLowerBound = "" + var pageUpperBound = "" + + mutating func fixInvalidData() { + if !pageLowerBound.isEmpty && Int(pageLowerBound) == nil { + pageLowerBound = "" + } + if !pageUpperBound.isEmpty && Int(pageUpperBound) == nil { + pageUpperBound = "" + } + } + + mutating func reset() { + self = .init() + } + + var hasActiveValues: Bool { + !excludedCategories.isEmpty + || minimumRatingActivated + || pageRangeActivated + || pageLowerBound.notEmpty + || pageUpperBound.notEmpty + } +} + +// MARK: - DownloadRequestPayload +struct DownloadRequestPayload: Equatable, @unchecked Sendable { + let gallery: Gallery + let galleryDetail: GalleryDetail + let previewURLs: [Int: URL] + let previewConfig: PreviewConfig + let host: GalleryHost + let versionMetadata: DownloadVersionMetadata? + let options: DownloadOptionsSnapshot + let mode: DownloadStartMode + let pageSelection: Set? + + init( + gallery: Gallery, + galleryDetail: GalleryDetail, + previewURLs: [Int: URL], + previewConfig: PreviewConfig, + host: GalleryHost, + versionMetadata: DownloadVersionMetadata? = nil, + options: DownloadOptionsSnapshot, + mode: DownloadStartMode, + pageSelection: Set? = nil + ) { + self.gallery = gallery + self.galleryDetail = galleryDetail + self.previewURLs = previewURLs + self.previewConfig = previewConfig + self.host = host + self.versionMetadata = versionMetadata + self.options = options + self.mode = mode + self.pageSelection = pageSelection + } +} + +// MARK: - ReadingContentSource +enum ReadingContentSource: Equatable { + case remote + case local(DownloadedGallery, DownloadManifest) +} + +// MARK: - DownloadVersionMetadata +struct DownloadVersionMetadata: Equatable, Codable, Sendable { + let gid: String + let token: String + let currentGID: String? + let currentKey: String? + let parentGID: String? + let parentKey: String? + let firstGID: String? + let firstKey: String? + + var versionIdentifier: String? { + DownloadSignatureBuilder.chainVersionIdentifier( + gid: resolvedCurrentGID, + token: resolvedCurrentKey + ) + } + + private var resolvedCurrentGID: String { + currentGID?.notEmpty == true ? currentGID.forceUnwrapped : gid + } + + private var resolvedCurrentKey: String { + currentKey?.notEmpty == true ? currentKey.forceUnwrapped : token + } +} diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift b/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift new file mode 100644 index 000000000..c22d605ea --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift @@ -0,0 +1,159 @@ +// +// DownloadedGallery+SignatureBuilder.swift +// EhPanda +// + +import Foundation +import CryptoKit + +enum DownloadSignatureBuilder { + enum SignatureKind: Equatable { + case chain(gid: String, token: String) + case hash(String) + } + + enum Comparison: Equatable { + case same + case different + case incomparable + } + + static func make( + gallery: Gallery, + detail: GalleryDetail, + host _: GalleryHost, + previewURLs: [Int: URL], + versionMetadata: DownloadVersionMetadata? = nil + ) -> String { + if let versionIdentifier = versionMetadata?.versionIdentifier { + return versionIdentifier + } + + let previewHash = SHA256.hash( + data: previewURLs + .sorted(by: { $0.key < $1.key }) + .map { "\($0.key)=\(normalizedPreviewSignatureValue(url: $0.value))" } + .joined(separator: "|") + .data(using: .utf8) ?? Data() + ) + + let payload = [ + gallery.gid, + gallery.token, + gallery.title, + detail.jpnTitle ?? "", + String(detail.pageCount), + normalizedCoverSignatureValue(url: detail.coverURL ?? gallery.coverURL), + detail.formattedDateString, + previewHash.compactMap { String(format: "%02x", $0) }.joined() + ] + .joined(separator: "::") + + let digest = SHA256.hash( + data: payload.data(using: String.Encoding.utf8) ?? Data() + ) + let hash = digest.compactMap { String(format: "%02x", $0) }.joined() + return "hash:\(hash)" + } + + static func chainVersionIdentifier(gid: String, token: String) -> String? { + guard gid.notEmpty, token.notEmpty else { return nil } + return "chain:\(gid):\(token)" + } + + static func parse(_ value: String?) -> SignatureKind? { + guard let value, value.notEmpty else { return nil } + + if value.hasPrefix("chain:") { + let components = value.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) + guard components.count == 3, + !components[1].isEmpty, + !components[2].isEmpty + else { + return nil + } + return .chain(gid: String(components[1]), token: String(components[2])) + } + + if value.hasPrefix("hash:") { + let hash = String(value.dropFirst("hash:".count)) + guard hash.notEmpty else { return nil } + return .hash(hash) + } + + return nil + } + + static func compare( + remoteVersionSignature: String, + latestRemoteVersionSignature: String?, + gid: String, + token: String + ) -> Comparison { + guard let storedSignature = parse(remoteVersionSignature), + let latestSignature = parse(latestRemoteVersionSignature) + else { + return .incomparable + } + + switch (storedSignature, latestSignature) { + case let (.chain(storedGID, storedToken), .chain(latestGID, latestToken)): + return storedGID == latestGID && storedToken == latestToken ? .same : .different + + case let (.hash(storedHash), .hash(latestHash)): + return storedHash == latestHash ? .same : .different + + case (.hash, .chain): + return latestRemoteVersionSignature == chainVersionIdentifier(gid: gid, token: token) + ? .same + : .incomparable + + case (.chain, .hash): + return .incomparable + } + } + + static func canonicalizeStoredSignatureIfSafe( + remoteVersionSignature: String, + latestRemoteVersionSignature: String?, + gid: String, + token: String + ) -> String? { + guard case .hash = parse(remoteVersionSignature), + case .chain = parse(latestRemoteVersionSignature), + latestRemoteVersionSignature == chainVersionIdentifier(gid: gid, token: token) + else { + return nil + } + return latestRemoteVersionSignature + } + + static func hasUpdateComparison( + remoteVersionSignature: String, + latestRemoteVersionSignature: String?, + gid: String, + token: String + ) -> Comparison { + compare( + remoteVersionSignature: remoteVersionSignature, + latestRemoteVersionSignature: latestRemoteVersionSignature, + gid: gid, + token: token + ) + } + + private static func normalizedPreviewSignatureValue(url: URL) -> String { + let lastPathComponent = url.lastPathComponent + guard lastPathComponent.notEmpty else { + return normalizedCoverSignatureValue(url: url) + } + return lastPathComponent + } + + private static func normalizedCoverSignatureValue(url: URL?) -> String { + guard let url else { return "" } + let stablePathComponents = url.pathComponents + .filter { $0 != "/" && $0.notEmpty } + return stablePathComponents.joined(separator: "/") + } +} diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift new file mode 100644 index 000000000..4e40169f0 --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -0,0 +1,263 @@ +// +// DownloadedGallery+SupportTypes.swift +// EhPanda +// + +import SwiftUI + +// MARK: DownloadedGallery Computed Properties +extension DownloadedGallery { + var displayTitle: String { + jpnTitle?.notEmpty == true ? jpnTitle.forceUnwrapped : title + } + + var searchableText: String { + [ + title, + jpnTitle ?? "", + uploader ?? "", + category.value, + tags.flatMap(\.contents).map(\.text).joined(separator: " ") + ] + .joined(separator: " ") + } + + func resolvedFolderURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { + rootURL?.appendingPathComponent(folderRelativePath, isDirectory: true) + } + + func resolvedManifestURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { + resolvedFolderURL(rootURL: rootURL)? + .appendingPathComponent(Defaults.FilePath.downloadManifest) + } + + func resolvedLocalCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { + guard let folderURL = resolvedFolderURL(rootURL: rootURL), + let coverRelativePath, + coverRelativePath.notEmpty + else { return nil } + let coverURL = folderURL.appendingPathComponent(coverRelativePath) + guard isReadableLocalAssetFile(coverURL) else { + return nil + } + return coverURL + } + + func resolvedTemporaryCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { + guard shouldPreserveTemporaryWorkingSet, + let rootURL + else { + return nil + } + + let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) + guard FileManager.default.fileExists(atPath: temporaryFolderURL.path) else { + return nil + } + + if let coverRelativePath, + coverRelativePath.notEmpty { + let coverURL = temporaryFolderURL.appendingPathComponent(coverRelativePath) + if isReadableLocalAssetFile(coverURL) { + return coverURL + } + } + + guard let fileURLs = try? FileManager.default.contentsOfDirectory( + at: temporaryFolderURL, + includingPropertiesForKeys: nil + ) else { + return nil + } + + return fileURLs.first(where: { + $0.lastPathComponent.hasPrefix("cover.") && isReadableLocalAssetFile($0) + }) + } + + func resolvedCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { + resolvedLocalCoverURL(rootURL: rootURL) + ?? resolvedTemporaryCoverURL(rootURL: rootURL) + ?? onlineCoverURL + } + + var folderURL: URL? { + resolvedFolderURL() + } + + var manifestURL: URL? { + resolvedManifestURL() + } + + var localCoverURL: URL? { + resolvedLocalCoverURL() + } + + var coverURL: URL? { + resolvedCoverURL() + } + + var badge: DownloadBadge { + if isQueuedWorkItem { + return .queued + } + switch status { + case .queued: + return .queued + case .downloading: + return .downloading(completedPageCount, pageCount) + case .paused: + return .paused(completedPageCount, pageCount) + case .partial: + return .partial(completedPageCount, pageCount) + case .completed: + return .downloaded + case .failed: + return .failed + case .updateAvailable: + return .updateAvailable + case .missingFiles: + return .missingFiles + } + } + + var sortPriority: Int { + if isQueuedWorkItem { + return 1 + } + + switch status { + case .downloading: + return 0 + case .paused: + return 1 + case .queued: + return 2 + case .partial: + return 3 + case .updateAvailable: + return 4 + case .missingFiles: + return 5 + case .failed: + return 6 + case .completed: + return 7 + } + } + + var gallery: Gallery { + Gallery( + gid: gid, + token: token, + title: displayTitle, + rating: rating, + tags: tags, + category: category, + uploader: uploader, + pageCount: pageCount, + postedDate: postedDate, + coverURL: coverURL, + galleryURL: host.url + .appendingPathComponent("g") + .appendingPathComponent(gid) + .appendingPathComponent(token) + ) + } + + var canRetry: Bool { + [.partial, .failed, .missingFiles].contains(status) + } + + var canPauseOrResume: Bool { + [.downloading, .paused].contains(status) + } + + var shouldPreserveTemporaryWorkingSet: Bool { + pendingOperation != nil + || [.queued, .downloading, .paused, .partial].contains(status) + } + + var isPendingQueue: Bool { + badge == .queued + } + + var canCancelFromDetailAction: Bool { + isPendingQueue || canPauseOrResume || [.partial, .completed].contains(status) + } + + var canTriggerUpdate: Bool { + guard !isQueuedWorkItem, !canPauseOrResume else { return false } + return status == .updateAvailable || ([.completed, .missingFiles].contains(status) && hasUpdate) + } + + var isQueuedWorkItem: Bool { + status == .queued || pendingOperation != nil + } + + var hasUpdate: Bool { + DownloadSignatureBuilder.hasUpdateComparison( + remoteVersionSignature: remoteVersionSignature, + latestRemoteVersionSignature: latestRemoteVersionSignature, + gid: gid, + token: token + ) == .different + } + + func needsInterruptedDownloadNormalization( + activeGalleryID: String?, + hasActiveTask: Bool + ) -> Bool { + status == .downloading && !(hasActiveTask && activeGalleryID == gid) + } + + func matches(filter: DownloadListFilter) -> Bool { + if isQueuedWorkItem { + return filter == .all || filter == .active + } + + switch filter { + case .all: + return true + case .active: + return [.downloading, .paused].contains(status) + case .completed: + return status == .completed + case .failed: + return [.partial, .failed, .missingFiles].contains(status) + case .update: + return status == .updateAvailable || hasUpdate + } + } + + func matches(queryFilter: DownloadGalleryFilter) -> Bool { + if queryFilter.excludedCategories.contains(category) { + return false + } + + if queryFilter.minimumRatingActivated && rating < Float(queryFilter.minimumRating) { + return false + } + + guard queryFilter.pageRangeActivated else { return true } + + if let lowerBound = Int(queryFilter.pageLowerBound), pageCount < lowerBound { + return false + } + if let upperBound = Int(queryFilter.pageUpperBound), pageCount > upperBound { + return false + } + + return true + } +} + +extension DownloadedGallery { + func isReadableLocalAssetFile(_ url: URL) -> Bool { + guard FileManager.default.fileExists(atPath: url.path) else { return false } + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + let isRegularFile = values?.isRegularFile ?? true + let fileSize = values?.fileSize ?? 0 + return isRegularFile && fileSize > 0 + } +} diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 3e8e1daba..a6a838987 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -4,7 +4,6 @@ // import SwiftUI -import CryptoKit enum DownloadThreadMode: Codable, CaseIterable, Identifiable, Sendable { case single @@ -290,146 +289,6 @@ enum DownloadBadge: Equatable { case missingFiles } -extension DownloadBadge { - var text: String { - switch self { - case .none: - return "" - case .queued: - return L10n.Localizable.Struct.DownloadBadge.Text.queued - case .downloading(let completed, let total): - return L10n.Localizable.Struct.DownloadBadge.Text.downloading(completed, max(total, 1)) - case .paused(let completed, let total): - return L10n.Localizable.Struct.DownloadBadge.Text.paused(completed, max(total, 1)) - case .partial(let completed, let total): - return L10n.Localizable.Struct.DownloadBadge.Text.needsAttentionProgress( - completed, - max(total, 1) - ) - case .downloaded: - return L10n.Localizable.Struct.DownloadBadge.Text.downloaded - case .failed: - return L10n.Localizable.Struct.DownloadBadge.Text.needsAttention - case .updateAvailable: - return L10n.Localizable.Struct.DownloadBadge.Text.updateAvailable - case .missingFiles: - return L10n.Localizable.Struct.DownloadBadge.Text.needsRepair - } - } - - var color: Color { - switch self { - case .none: - return .clear - case .queued: - return .orange - case .downloading: - return .blue - case .paused: - return .indigo - case .partial: - return .orange - case .downloaded: - return .green - case .failed: - return .orange - case .updateAvailable: - return .yellow - case .missingFiles: - return .pink - } - } -} - -enum DownloadListFilter: String, CaseIterable, Identifiable { - case all - case active - case completed - case failed - case update - - var id: String { rawValue } - - var title: String { - switch self { - case .all: - return L10n.Localizable.Enum.DownloadListFilter.Title.all - case .active: - return L10n.Localizable.Enum.DownloadListFilter.Title.active - case .completed: - return L10n.Localizable.Enum.DownloadListFilter.Title.completed - case .failed: - return L10n.Localizable.Enum.DownloadListFilter.Title.failed - case .update: - return L10n.Localizable.Enum.DownloadListFilter.Title.update - } - } -} - -struct DownloadGalleryFilter: Equatable { - var excludedCategories = Set() - var minimumRatingActivated = false - var minimumRating = 2 - var pageRangeActivated = false - var pageLowerBound = "" - var pageUpperBound = "" - - mutating func fixInvalidData() { - if !pageLowerBound.isEmpty && Int(pageLowerBound) == nil { - pageLowerBound = "" - } - if !pageUpperBound.isEmpty && Int(pageUpperBound) == nil { - pageUpperBound = "" - } - } - - mutating func reset() { - self = .init() - } - - var hasActiveValues: Bool { - !excludedCategories.isEmpty - || minimumRatingActivated - || pageRangeActivated - || pageLowerBound.notEmpty - || pageUpperBound.notEmpty - } -} - -struct DownloadRequestPayload: Equatable, @unchecked Sendable { - let gallery: Gallery - let galleryDetail: GalleryDetail - let previewURLs: [Int: URL] - let previewConfig: PreviewConfig - let host: GalleryHost - let versionMetadata: DownloadVersionMetadata? - let options: DownloadOptionsSnapshot - let mode: DownloadStartMode - let pageSelection: Set? - - init( - gallery: Gallery, - galleryDetail: GalleryDetail, - previewURLs: [Int: URL], - previewConfig: PreviewConfig, - host: GalleryHost, - versionMetadata: DownloadVersionMetadata? = nil, - options: DownloadOptionsSnapshot, - mode: DownloadStartMode, - pageSelection: Set? = nil - ) { - self.gallery = gallery - self.galleryDetail = galleryDetail - self.previewURLs = previewURLs - self.previewConfig = previewConfig - self.host = host - self.versionMetadata = versionMetadata - self.options = options - self.mode = mode - self.pageSelection = pageSelection - } -} - struct DownloadedGallery: Identifiable, Equatable { var id: String { gid } @@ -504,441 +363,4 @@ struct DownloadedGallery: Identifiable, Equatable { self.pendingOperation = pendingOperation } - var displayTitle: String { - jpnTitle?.notEmpty == true ? jpnTitle.forceUnwrapped : title - } - - var searchableText: String { - [ - title, - jpnTitle ?? "", - uploader ?? "", - category.value, - tags.flatMap(\.contents).map(\.text).joined(separator: " ") - ] - .joined(separator: " ") - } - - func resolvedFolderURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { - rootURL?.appendingPathComponent(folderRelativePath, isDirectory: true) - } - - func resolvedManifestURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { - resolvedFolderURL(rootURL: rootURL)? - .appendingPathComponent(Defaults.FilePath.downloadManifest) - } - - func resolvedLocalCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { - guard let folderURL = resolvedFolderURL(rootURL: rootURL), - let coverRelativePath, - coverRelativePath.notEmpty - else { return nil } - let coverURL = folderURL.appendingPathComponent(coverRelativePath) - guard isReadableLocalAssetFile(coverURL) else { - return nil - } - return coverURL - } - - func resolvedTemporaryCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { - guard shouldPreserveTemporaryWorkingSet, - let rootURL - else { - return nil - } - - let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) - guard FileManager.default.fileExists(atPath: temporaryFolderURL.path) else { - return nil - } - - if let coverRelativePath, - coverRelativePath.notEmpty - { - let coverURL = temporaryFolderURL.appendingPathComponent(coverRelativePath) - if isReadableLocalAssetFile(coverURL) { - return coverURL - } - } - - guard let fileURLs = try? FileManager.default.contentsOfDirectory( - at: temporaryFolderURL, - includingPropertiesForKeys: nil - ) else { - return nil - } - - return fileURLs.first(where: { - $0.lastPathComponent.hasPrefix("cover.") && isReadableLocalAssetFile($0) - }) - } - - func resolvedCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { - resolvedLocalCoverURL(rootURL: rootURL) - ?? resolvedTemporaryCoverURL(rootURL: rootURL) - ?? onlineCoverURL - } - - var folderURL: URL? { - resolvedFolderURL() - } - - var manifestURL: URL? { - resolvedManifestURL() - } - - var localCoverURL: URL? { - resolvedLocalCoverURL() - } - - var coverURL: URL? { - resolvedCoverURL() - } - - var badge: DownloadBadge { - if isQueuedWorkItem { - return .queued - } - switch status { - case .queued: - return .queued - case .downloading: - return .downloading(completedPageCount, pageCount) - case .paused: - return .paused(completedPageCount, pageCount) - case .partial: - return .partial(completedPageCount, pageCount) - case .completed: - return .downloaded - case .failed: - return .failed - case .updateAvailable: - return .updateAvailable - case .missingFiles: - return .missingFiles - } - } - - var sortPriority: Int { - if isQueuedWorkItem { - return 1 - } - - switch status { - case .downloading: - return 0 - case .paused: - return 1 - case .queued: - return 2 - case .partial: - return 3 - case .updateAvailable: - return 4 - case .missingFiles: - return 5 - case .failed: - return 6 - case .completed: - return 7 - } - } - - var gallery: Gallery { - Gallery( - gid: gid, - token: token, - title: displayTitle, - rating: rating, - tags: tags, - category: category, - uploader: uploader, - pageCount: pageCount, - postedDate: postedDate, - coverURL: coverURL, - galleryURL: host.url - .appendingPathComponent("g") - .appendingPathComponent(gid) - .appendingPathComponent(token) - ) - } - - var canRetry: Bool { - [.partial, .failed, .missingFiles].contains(status) - } - - var canPauseOrResume: Bool { - [.downloading, .paused].contains(status) - } - - var shouldPreserveTemporaryWorkingSet: Bool { - pendingOperation != nil - || [.queued, .downloading, .paused, .partial].contains(status) - } - - var isPendingQueue: Bool { - badge == .queued - } - - var canCancelFromDetailAction: Bool { - isPendingQueue || canPauseOrResume || [.partial, .completed].contains(status) - } - - var canTriggerUpdate: Bool { - guard !isQueuedWorkItem, !canPauseOrResume else { return false } - return status == .updateAvailable || ([.completed, .missingFiles].contains(status) && hasUpdate) - } - - var isQueuedWorkItem: Bool { - status == .queued || pendingOperation != nil - } - - var hasUpdate: Bool { - DownloadSignatureBuilder.hasUpdateComparison( - remoteVersionSignature: remoteVersionSignature, - latestRemoteVersionSignature: latestRemoteVersionSignature, - gid: gid, - token: token - ) == .different - } - - func needsInterruptedDownloadNormalization( - activeGalleryID: String?, - hasActiveTask: Bool - ) -> Bool { - status == .downloading && !(hasActiveTask && activeGalleryID == gid) - } - - func matches(filter: DownloadListFilter) -> Bool { - if isQueuedWorkItem { - return filter == .all || filter == .active - } - - switch filter { - case .all: - return true - case .active: - return [.downloading, .paused].contains(status) - case .completed: - return status == .completed - case .failed: - return [.partial, .failed, .missingFiles].contains(status) - case .update: - return status == .updateAvailable || hasUpdate - } - } - - func matches(queryFilter: DownloadGalleryFilter) -> Bool { - if queryFilter.excludedCategories.contains(category) { - return false - } - - if queryFilter.minimumRatingActivated && rating < Float(queryFilter.minimumRating) { - return false - } - - guard queryFilter.pageRangeActivated else { return true } - - if let lowerBound = Int(queryFilter.pageLowerBound), pageCount < lowerBound { - return false - } - if let upperBound = Int(queryFilter.pageUpperBound), pageCount > upperBound { - return false - } - - return true - } -} - -private extension DownloadedGallery { - func isReadableLocalAssetFile(_ url: URL) -> Bool { - guard FileManager.default.fileExists(atPath: url.path) else { return false } - let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) - let isRegularFile = values?.isRegularFile ?? true - let fileSize = values?.fileSize ?? 0 - return isRegularFile && fileSize > 0 - } -} - -enum ReadingContentSource: Equatable { - case remote - case local(DownloadedGallery, DownloadManifest) -} - -struct DownloadVersionMetadata: Equatable, Codable, Sendable { - let gid: String - let token: String - let currentGID: String? - let currentKey: String? - let parentGID: String? - let parentKey: String? - let firstGID: String? - let firstKey: String? - - var versionIdentifier: String? { - DownloadSignatureBuilder.chainVersionIdentifier( - gid: resolvedCurrentGID, - token: resolvedCurrentKey - ) - } - - private var resolvedCurrentGID: String { - currentGID?.notEmpty == true ? currentGID.forceUnwrapped : gid - } - - private var resolvedCurrentKey: String { - currentKey?.notEmpty == true ? currentKey.forceUnwrapped : token - } -} - -enum DownloadSignatureBuilder { - enum SignatureKind: Equatable { - case chain(gid: String, token: String) - case hash(String) - } - - enum Comparison: Equatable { - case same - case different - case incomparable - } - - static func make( - gallery: Gallery, - detail: GalleryDetail, - host _: GalleryHost, - previewURLs: [Int: URL], - versionMetadata: DownloadVersionMetadata? = nil - ) -> String { - if let versionIdentifier = versionMetadata?.versionIdentifier { - return versionIdentifier - } - - let previewHash = SHA256.hash( - data: previewURLs - .sorted(by: { $0.key < $1.key }) - .map { "\($0.key)=\(normalizedPreviewSignatureValue(url: $0.value))" } - .joined(separator: "|") - .data(using: .utf8) ?? Data() - ) - - let payload = [ - gallery.gid, - gallery.token, - gallery.title, - detail.jpnTitle ?? "", - String(detail.pageCount), - normalizedCoverSignatureValue(url: detail.coverURL ?? gallery.coverURL), - detail.formattedDateString, - previewHash.compactMap { String(format: "%02x", $0) }.joined() - ] - .joined(separator: "::") - - let digest = SHA256.hash( - data: payload.data(using: String.Encoding.utf8) ?? Data() - ) - let hash = digest.compactMap { String(format: "%02x", $0) }.joined() - return "hash:\(hash)" - } - - static func chainVersionIdentifier(gid: String, token: String) -> String? { - guard gid.notEmpty, token.notEmpty else { return nil } - return "chain:\(gid):\(token)" - } - - static func parse(_ value: String?) -> SignatureKind? { - guard let value, value.notEmpty else { return nil } - - if value.hasPrefix("chain:") { - let components = value.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) - guard components.count == 3, - !components[1].isEmpty, - !components[2].isEmpty - else { - return nil - } - return .chain(gid: String(components[1]), token: String(components[2])) - } - - if value.hasPrefix("hash:") { - let hash = String(value.dropFirst("hash:".count)) - guard hash.notEmpty else { return nil } - return .hash(hash) - } - - return nil - } - - static func compare( - remoteVersionSignature: String, - latestRemoteVersionSignature: String?, - gid: String, - token: String - ) -> Comparison { - guard let storedSignature = parse(remoteVersionSignature), - let latestSignature = parse(latestRemoteVersionSignature) - else { - return .incomparable - } - - switch (storedSignature, latestSignature) { - case let (.chain(storedGID, storedToken), .chain(latestGID, latestToken)): - return storedGID == latestGID && storedToken == latestToken ? .same : .different - - case let (.hash(storedHash), .hash(latestHash)): - return storedHash == latestHash ? .same : .different - - case (.hash, .chain): - return latestRemoteVersionSignature == chainVersionIdentifier(gid: gid, token: token) - ? .same - : .incomparable - - case (.chain, .hash): - return .incomparable - } - } - - static func canonicalizeStoredSignatureIfSafe( - remoteVersionSignature: String, - latestRemoteVersionSignature: String?, - gid: String, - token: String - ) -> String? { - guard case .hash = parse(remoteVersionSignature), - case .chain = parse(latestRemoteVersionSignature), - latestRemoteVersionSignature == chainVersionIdentifier(gid: gid, token: token) - else { - return nil - } - return latestRemoteVersionSignature - } - - static func hasUpdateComparison( - remoteVersionSignature: String, - latestRemoteVersionSignature: String?, - gid: String, - token: String - ) -> Comparison { - compare( - remoteVersionSignature: remoteVersionSignature, - latestRemoteVersionSignature: latestRemoteVersionSignature, - gid: gid, - token: token - ) - } - - private static func normalizedPreviewSignatureValue(url: URL) -> String { - let lastPathComponent = url.lastPathComponent - guard lastPathComponent.notEmpty else { - return normalizedCoverSignatureValue(url: url) - } - return lastPathComponent - } - - private static func normalizedCoverSignatureValue(url: URL?) -> String { - guard let url else { return "" } - let stablePathComponents = url.pathComponents - .filter { $0 != "/" && $0.notEmpty } - return stablePathComponents.joined(separator: "/") - } } diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift index 580691db6..db00d739b 100644 --- a/EhPanda/Models/Persistent/Setting.swift +++ b/EhPanda/Models/Persistent/Setting.swift @@ -232,7 +232,7 @@ extension Setting { doubleTapScaleFactor = (try? container?.decodeIfPresent(Double.self, forKey: .doubleTapScaleFactor)) ?? 2 // Downloads downloadThreadMode = (try? container?.decodeIfPresent(DownloadThreadMode.self, forKey: .downloadThreadMode)) - ?? .single + ?? .single downloadAllowCellular = (try? container?.decodeIfPresent(Bool.self, forKey: .downloadAllowCellular)) ?? true downloadAutoRetryFailedPages = ( try? container?.decodeIfPresent(Bool.self, forKey: .downloadAutoRetryFailedPages) diff --git a/EhPanda/Models/Support/AppError.swift b/EhPanda/Models/Support/AppError.swift index fa5298f48..a5d983c6e 100644 --- a/EhPanda/Models/Support/AppError.swift +++ b/EhPanda/Models/Support/AppError.swift @@ -28,10 +28,10 @@ extension AppError { var isRetryable: Bool { switch self { case .databaseCorrupted, .networkingFailed, .parseFailed, - .fileOperationFailed, .noUpdates, .unknown, .webImageFailed: + .fileOperationFailed, .noUpdates, .unknown, .webImageFailed: return true case .copyrightClaim, .expunged, .quotaExceeded, .authenticationRequired, .notFound, - .ipBanned: + .ipBanned: return false } } @@ -165,18 +165,18 @@ extension BanInterval { private func daysWithUnit(_ days: Int) -> String { days > 1 ? L10n.Localizable.Common.Value.days("\(days)") - : L10n.Localizable.Common.Value.day("\(days)") + : L10n.Localizable.Common.Value.day("\(days)") } private func hoursWithUnit(_ hours: Int) -> String { hours > 1 ? L10n.Localizable.Common.Value.hours("\(hours)") - : L10n.Localizable.Common.Value.hour("\(hours)") + : L10n.Localizable.Common.Value.hour("\(hours)") } private func minutesWithUnit(_ minutes: Int) -> String { minutes > 1 ? L10n.Localizable.Common.Value.minutes("\(minutes)") - : L10n.Localizable.Common.Value.minute("\(minutes)") + : L10n.Localizable.Common.Value.minute("\(minutes)") } private func secondsWithUnit(_ seconds: Int) -> String { seconds > 1 ? L10n.Localizable.Common.Value.seconds("\(seconds)") - : L10n.Localizable.Common.Value.second("\(seconds)") + : L10n.Localizable.Common.Value.second("\(seconds)") } } diff --git a/EhPanda/Models/Support/BrowsingCountry+EnglishName.swift b/EhPanda/Models/Support/BrowsingCountry+EnglishName.swift new file mode 100644 index 000000000..9c61cd733 --- /dev/null +++ b/EhPanda/Models/Support/BrowsingCountry+EnglishName.swift @@ -0,0 +1,263 @@ +// +// BrowsingCountry+EnglishName.swift +// EhPanda +// + +extension EhSetting.BrowsingCountry { + var englishName: String { + switch self { + case .autoDetect: return "Auto-Detect" + case .afghanistan: return "Afghanistan" + case .alandIslands: return "Aland Islands" + case .albania: return "Albania" + case .algeria: return "Algeria" + case .americanSamoa: return "American Samoa" + case .andorra: return "Andorra" + case .angola: return "Angola" + case .anguilla: return "Anguilla" + case .antarctica: return "Antarctica" + case .antiguaAndBarbuda: return "Antigua and Barbuda" + case .argentina: return "Argentina" + case .armenia: return "Armenia" + case .aruba: return "Aruba" + case .asiaPacificRegion: return "Asia-Pacific Region" + case .australia: return "Australia" + case .austria: return "Austria" + case .azerbaijan: return "Azerbaijan" + case .bahamas: return "Bahamas" + case .bahrain: return "Bahrain" + case .bangladesh: return "Bangladesh" + case .barbados: return "Barbados" + case .belarus: return "Belarus" + case .belgium: return "Belgium" + case .belize: return "Belize" + case .benin: return "Benin" + case .bermuda: return "Bermuda" + case .bhutan: return "Bhutan" + case .bolivia: return "Bolivia" + case .bonaireSaintEustatiusAndSaba: return "Bonaire Saint Eustatius and Saba" + case .bosniaAndHerzegovina: return "Bosnia and Herzegovina" + case .botswana: return "Botswana" + case .bouvetIsland: return "Bouvet Island" + case .brazil: return "Brazil" + case .britishIndianOceanTerritory: return "British Indian Ocean Territory" + case .bruneiDarussalam: return "Brunei Darussalam" + case .bulgaria: return "Bulgaria" + case .burkinaFaso: return "Burkina Faso" + case .burundi: return "Burundi" + case .cambodia: return "Cambodia" + case .cameroon: return "Cameroon" + case .canada: return "Canada" + case .capeVerde: return "Cape Verde" + case .caymanIslands: return "Cayman Islands" + case .centralAfricanRepublic: return "Central African Republic" + case .chad: return "Chad" + case .chile: return "Chile" + case .china: return "China" + case .christmasIsland: return "Christmas Island" + case .cocosIslands: return "Cocos Islands" + case .colombia: return "Colombia" + case .comoros: return "Comoros" + case .congo: return "Congo" + case .theDemocraticRepublicOfTheCongo: return "The Democratic Republic of the Congo" + case .cookIslands: return "Cook Islands" + case .costaRica: return "Costa Rica" + case .coteDIvoire: return "Cote D'Ivoire" + case .croatia: return "Croatia" + case .cuba: return "Cuba" + case .curacao: return "Curacao" + case .cyprus: return "Cyprus" + case .czechRepublic: return "Czech Republic" + case .denmark: return "Denmark" + case .djibouti: return "Djibouti" + case .dominica: return "Dominica" + case .dominicanRepublic: return "Dominican Republic" + case .ecuador: return "Ecuador" + case .egypt: return "Egypt" + case .elSalvador: return "El Salvador" + case .equatorialGuinea: return "Equatorial Guinea" + case .eritrea: return "Eritrea" + case .estonia: return "Estonia" + case .ethiopia: return "Ethiopia" + case .europe: return "Europe" + case .falklandIslands: return "Falkland Islands" + case .faroeIslands: return "Faroe Islands" + case .fiji: return "Fiji" + case .finland: return "Finland" + case .france: return "France" + case .frenchGuiana: return "French Guiana" + case .frenchPolynesia: return "French Polynesia" + case .frenchSouthernTerritories: return "French Southern Territories" + case .gabon: return "Gabon" + case .gambia: return "Gambia" + case .georgia: return "Georgia" + case .germany: return "Germany" + case .ghana: return "Ghana" + case .gibraltar: return "Gibraltar" + case .greece: return "Greece" + case .greenland: return "Greenland" + case .grenada: return "Grenada" + case .guadeloupe: return "Guadeloupe" + case .guam: return "Guam" + case .guatemala: return "Guatemala" + case .guernsey: return "Guernsey" + case .guinea: return "Guinea" + case .guineaBissau: return "Guinea-Bissau" + case .guyana: return "Guyana" + case .haiti: return "Haiti" + case .heardIslandAndMcDonaldIslands: return "Heard Island and McDonald Islands" + case .vaticanCityState: return "Vatican City State" + case .honduras: return "Honduras" + case .hongKong: return "Hong Kong" + case .hungary: return "Hungary" + case .iceland: return "Iceland" + case .india: return "India" + case .indonesia: return "Indonesia" + case .iran: return "Iran" + case .iraq: return "Iraq" + case .ireland: return "Ireland" + case .isleOfMan: return "Isle of Man" + case .israel: return "Israel" + case .italy: return "Italy" + case .jamaica: return "Jamaica" + case .japan: return "Japan" + case .jersey: return "Jersey" + case .jordan: return "Jordan" + case .kazakhstan: return "Kazakhstan" + case .kenya: return "Kenya" + case .kiribati: return "Kiribati" + case .kuwait: return "Kuwait" + case .kyrgyzstan: return "Kyrgyzstan" + case .laoPeoplesDemocraticRepublic: return "Lao People's Democratic Republic" + case .latvia: return "Latvia" + case .lebanon: return "Lebanon" + case .lesotho: return "Lesotho" + case .liberia: return "Liberia" + case .libya: return "Libya" + case .liechtenstein: return "Liechtenstein" + case .lithuania: return "Lithuania" + case .luxembourg: return "Luxembourg" + case .macau: return "Macau" + case .macedonia: return "Macedonia" + case .madagascar: return "Madagascar" + case .malawi: return "Malawi" + case .malaysia: return "Malaysia" + case .maldives: return "Maldives" + case .mali: return "Mali" + case .malta: return "Malta" + case .marshallIslands: return "Marshall Islands" + case .martinique: return "Martinique" + case .mauritania: return "Mauritania" + case .mauritius: return "Mauritius" + case .mayotte: return "Mayotte" + case .mexico: return "Mexico" + case .micronesia: return "Micronesia" + case .moldova: return "Moldova" + case .monaco: return "Monaco" + case .mongolia: return "Mongolia" + case .montenegro: return "Montenegro" + case .montserrat: return "Montserrat" + case .morocco: return "Morocco" + case .mozambique: return "Mozambique" + case .myanmar: return "Myanmar" + case .namibia: return "Namibia" + case .nauru: return "Nauru" + case .nepal: return "Nepal" + case .netherlands: return "Netherlands" + case .newCaledonia: return "New Caledonia" + case .newZealand: return "New Zealand" + case .nicaragua: return "Nicaragua" + case .niger: return "Niger" + case .nigeria: return "Nigeria" + case .niue: return "Niue" + case .norfolkIsland: return "Norfolk Island" + case .northKorea: return "North Korea" + case .northernMarianaIslands: return "Northern Mariana Islands" + case .norway: return "Norway" + case .oman: return "Oman" + case .pakistan: return "Pakistan" + case .palau: return "Palau" + case .palestinianTerritory: return "Palestinian Territory" + case .panama: return "Panama" + case .papuaNewGuinea: return "Papua New Guinea" + case .paraguay: return "Paraguay" + case .peru: return "Peru" + case .philippines: return "Philippines" + case .pitcairnIslands: return "Pitcairn Islands" + case .poland: return "Poland" + case .portugal: return "Portugal" + case .puertoRico: return "Puerto Rico" + case .qatar: return "Qatar" + case .reunion: return "Reunion" + case .romania: return "Romania" + case .russianFederation: return "Russian Federation" + case .rwanda: return "Rwanda" + case .saintBarthelemy: return "Saint Barthelemy" + case .saintHelena: return "Saint Helena" + case .saintKittsAndNevis: return "Saint Kitts and Nevis" + case .saintLucia: return "Saint Lucia" + case .saintMartin: return "Saint Martin" + case .saintPierreAndMiquelon: return "Saint Pierre and Miquelon" + case .saintVincentAndTheGrenadines: return "Saint Vincent and the Grenadines" + case .samoa: return "Samoa" + case .sanMarino: return "San Marino" + case .saoTomeAndPrincipe: return "Sao Tome and Principe" + case .saudiArabia: return "Saudi Arabia" + case .senegal: return "Senegal" + case .serbia: return "Serbia" + case .seychelles: return "Seychelles" + case .sierraLeone: return "Sierra Leone" + case .singapore: return "Singapore" + case .sintMaarten: return "Sint Maarten" + case .slovakia: return "Slovakia" + case .slovenia: return "Slovenia" + case .solomonIslands: return "Solomon Islands" + case .somalia: return "Somalia" + case .southAfrica: return "South Africa" + case .southGeorgiaAndTheSouthSandwichIslands: return "South Georgia and the South Sandwich Islands" + case .southKorea: return "South Korea" + case .southSudan: return "South Sudan" + case .spain: return "Spain" + case .sriLanka: return "Sri Lanka" + case .sudan: return "Sudan" + case .suriname: return "Suriname" + case .svalbardAndJanMayen: return "Svalbard and Jan Mayen" + case .swaziland: return "Swaziland" + case .sweden: return "Sweden" + case .switzerland: return "Switzerland" + case .syrianArabRepublic: return "Syrian Arab Republic" + case .taiwan: return "Taiwan" + case .tajikistan: return "Tajikistan" + case .tanzania: return "Tanzania" + case .thailand: return "Thailand" + case .timorLeste: return "Timor-Leste" + case .togo: return "Togo" + case .tokelau: return "Tokelau" + case .tonga: return "Tonga" + case .trinidadAndTobago: return "Trinidad and Tobago" + case .tunisia: return "Tunisia" + case .turkey: return "Turkey" + case .turkmenistan: return "Turkmenistan" + case .turksAndCaicosIslands: return "Turks and Caicos Islands" + case .tuvalu: return "Tuvalu" + case .uganda: return "Uganda" + case .ukraine: return "Ukraine" + case .unitedArabEmirates: return "United Arab Emirates" + case .unitedKingdom: return "United Kingdom" + case .unitedStates: return "United States" + case .unitedStatesMinorOutlyingIslands: return "United States Minor Outlying Islands" + case .uruguay: return "Uruguay" + case .uzbekistan: return "Uzbekistan" + case .vanuatu: return "Vanuatu" + case .venezuela: return "Venezuela" + case .vietnam: return "Vietnam" + case .virginIslandsBritish: return "British Virgin Islands" + case .virginIslandsUS: return "U.S. Virgin Islands" + case .wallisAndFutuna: return "Wallis and Futuna" + case .westernSahara: return "Western Sahara" + case .yemen: return "Yemen" + case .zambia: return "Zambia" + case .zimbabwe: return "Zimbabwe" + } + } +} diff --git a/EhPanda/Models/Support/BrowsingCountry.swift b/EhPanda/Models/Support/BrowsingCountry.swift index 317307624..6e3b751c1 100644 --- a/EhPanda/Models/Support/BrowsingCountry.swift +++ b/EhPanda/Models/Support/BrowsingCountry.swift @@ -269,261 +269,5 @@ extension EhSetting.BrowsingCountry { case .zimbabwe: return L10n.Localizable.Enum.BrowsingCountry.Name.zimbabwe } } - var englishName: String { - switch self { - case .autoDetect: return "Auto-Detect" - case .afghanistan: return "Afghanistan" - case .alandIslands: return "Aland Islands" - case .albania: return "Albania" - case .algeria: return "Algeria" - case .americanSamoa: return "American Samoa" - case .andorra: return "Andorra" - case .angola: return "Angola" - case .anguilla: return "Anguilla" - case .antarctica: return "Antarctica" - case .antiguaAndBarbuda: return "Antigua and Barbuda" - case .argentina: return "Argentina" - case .armenia: return "Armenia" - case .aruba: return "Aruba" - case .asiaPacificRegion: return "Asia-Pacific Region" - case .australia: return "Australia" - case .austria: return "Austria" - case .azerbaijan: return "Azerbaijan" - case .bahamas: return "Bahamas" - case .bahrain: return "Bahrain" - case .bangladesh: return "Bangladesh" - case .barbados: return "Barbados" - case .belarus: return "Belarus" - case .belgium: return "Belgium" - case .belize: return "Belize" - case .benin: return "Benin" - case .bermuda: return "Bermuda" - case .bhutan: return "Bhutan" - case .bolivia: return "Bolivia" - case .bonaireSaintEustatiusAndSaba: return "Bonaire Saint Eustatius and Saba" - case .bosniaAndHerzegovina: return "Bosnia and Herzegovina" - case .botswana: return "Botswana" - case .bouvetIsland: return "Bouvet Island" - case .brazil: return "Brazil" - case .britishIndianOceanTerritory: return "British Indian Ocean Territory" - case .bruneiDarussalam: return "Brunei Darussalam" - case .bulgaria: return "Bulgaria" - case .burkinaFaso: return "Burkina Faso" - case .burundi: return "Burundi" - case .cambodia: return "Cambodia" - case .cameroon: return "Cameroon" - case .canada: return "Canada" - case .capeVerde: return "Cape Verde" - case .caymanIslands: return "Cayman Islands" - case .centralAfricanRepublic: return "Central African Republic" - case .chad: return "Chad" - case .chile: return "Chile" - case .china: return "China" - case .christmasIsland: return "Christmas Island" - case .cocosIslands: return "Cocos Islands" - case .colombia: return "Colombia" - case .comoros: return "Comoros" - case .congo: return "Congo" - case .theDemocraticRepublicOfTheCongo: return "The Democratic Republic of the Congo" - case .cookIslands: return "Cook Islands" - case .costaRica: return "Costa Rica" - case .coteDIvoire: return "Cote D'Ivoire" - case .croatia: return "Croatia" - case .cuba: return "Cuba" - case .curacao: return "Curacao" - case .cyprus: return "Cyprus" - case .czechRepublic: return "Czech Republic" - case .denmark: return "Denmark" - case .djibouti: return "Djibouti" - case .dominica: return "Dominica" - case .dominicanRepublic: return "Dominican Republic" - case .ecuador: return "Ecuador" - case .egypt: return "Egypt" - case .elSalvador: return "El Salvador" - case .equatorialGuinea: return "Equatorial Guinea" - case .eritrea: return "Eritrea" - case .estonia: return "Estonia" - case .ethiopia: return "Ethiopia" - case .europe: return "Europe" - case .falklandIslands: return "Falkland Islands" - case .faroeIslands: return "Faroe Islands" - case .fiji: return "Fiji" - case .finland: return "Finland" - case .france: return "France" - case .frenchGuiana: return "French Guiana" - case .frenchPolynesia: return "French Polynesia" - case .frenchSouthernTerritories: return "French Southern Territories" - case .gabon: return "Gabon" - case .gambia: return "Gambia" - case .georgia: return "Georgia" - case .germany: return "Germany" - case .ghana: return "Ghana" - case .gibraltar: return "Gibraltar" - case .greece: return "Greece" - case .greenland: return "Greenland" - case .grenada: return "Grenada" - case .guadeloupe: return "Guadeloupe" - case .guam: return "Guam" - case .guatemala: return "Guatemala" - case .guernsey: return "Guernsey" - case .guinea: return "Guinea" - case .guineaBissau: return "Guinea-Bissau" - case .guyana: return "Guyana" - case .haiti: return "Haiti" - case .heardIslandAndMcDonaldIslands: return "Heard Island and McDonald Islands" - case .vaticanCityState: return "Vatican City State" - case .honduras: return "Honduras" - case .hongKong: return "Hong Kong" - case .hungary: return "Hungary" - case .iceland: return "Iceland" - case .india: return "India" - case .indonesia: return "Indonesia" - case .iran: return "Iran" - case .iraq: return "Iraq" - case .ireland: return "Ireland" - case .isleOfMan: return "Isle of Man" - case .israel: return "Israel" - case .italy: return "Italy" - case .jamaica: return "Jamaica" - case .japan: return "Japan" - case .jersey: return "Jersey" - case .jordan: return "Jordan" - case .kazakhstan: return "Kazakhstan" - case .kenya: return "Kenya" - case .kiribati: return "Kiribati" - case .kuwait: return "Kuwait" - case .kyrgyzstan: return "Kyrgyzstan" - case .laoPeoplesDemocraticRepublic: return "Lao People's Democratic Republic" - case .latvia: return "Latvia" - case .lebanon: return "Lebanon" - case .lesotho: return "Lesotho" - case .liberia: return "Liberia" - case .libya: return "Libya" - case .liechtenstein: return "Liechtenstein" - case .lithuania: return "Lithuania" - case .luxembourg: return "Luxembourg" - case .macau: return "Macau" - case .macedonia: return "Macedonia" - case .madagascar: return "Madagascar" - case .malawi: return "Malawi" - case .malaysia: return "Malaysia" - case .maldives: return "Maldives" - case .mali: return "Mali" - case .malta: return "Malta" - case .marshallIslands: return "Marshall Islands" - case .martinique: return "Martinique" - case .mauritania: return "Mauritania" - case .mauritius: return "Mauritius" - case .mayotte: return "Mayotte" - case .mexico: return "Mexico" - case .micronesia: return "Micronesia" - case .moldova: return "Moldova" - case .monaco: return "Monaco" - case .mongolia: return "Mongolia" - case .montenegro: return "Montenegro" - case .montserrat: return "Montserrat" - case .morocco: return "Morocco" - case .mozambique: return "Mozambique" - case .myanmar: return "Myanmar" - case .namibia: return "Namibia" - case .nauru: return "Nauru" - case .nepal: return "Nepal" - case .netherlands: return "Netherlands" - case .newCaledonia: return "New Caledonia" - case .newZealand: return "New Zealand" - case .nicaragua: return "Nicaragua" - case .niger: return "Niger" - case .nigeria: return "Nigeria" - case .niue: return "Niue" - case .norfolkIsland: return "Norfolk Island" - case .northKorea: return "North Korea" - case .northernMarianaIslands: return "Northern Mariana Islands" - case .norway: return "Norway" - case .oman: return "Oman" - case .pakistan: return "Pakistan" - case .palau: return "Palau" - case .palestinianTerritory: return "Palestinian Territory" - case .panama: return "Panama" - case .papuaNewGuinea: return "Papua New Guinea" - case .paraguay: return "Paraguay" - case .peru: return "Peru" - case .philippines: return "Philippines" - case .pitcairnIslands: return "Pitcairn Islands" - case .poland: return "Poland" - case .portugal: return "Portugal" - case .puertoRico: return "Puerto Rico" - case .qatar: return "Qatar" - case .reunion: return "Reunion" - case .romania: return "Romania" - case .russianFederation: return "Russian Federation" - case .rwanda: return "Rwanda" - case .saintBarthelemy: return "Saint Barthelemy" - case .saintHelena: return "Saint Helena" - case .saintKittsAndNevis: return "Saint Kitts and Nevis" - case .saintLucia: return "Saint Lucia" - case .saintMartin: return "Saint Martin" - case .saintPierreAndMiquelon: return "Saint Pierre and Miquelon" - case .saintVincentAndTheGrenadines: return "Saint Vincent and the Grenadines" - case .samoa: return "Samoa" - case .sanMarino: return "San Marino" - case .saoTomeAndPrincipe: return "Sao Tome and Principe" - case .saudiArabia: return "Saudi Arabia" - case .senegal: return "Senegal" - case .serbia: return "Serbia" - case .seychelles: return "Seychelles" - case .sierraLeone: return "Sierra Leone" - case .singapore: return "Singapore" - case .sintMaarten: return "Sint Maarten" - case .slovakia: return "Slovakia" - case .slovenia: return "Slovenia" - case .solomonIslands: return "Solomon Islands" - case .somalia: return "Somalia" - case .southAfrica: return "South Africa" - case .southGeorgiaAndTheSouthSandwichIslands: return "South Georgia and the South Sandwich Islands" - case .southKorea: return "South Korea" - case .southSudan: return "South Sudan" - case .spain: return "Spain" - case .sriLanka: return "Sri Lanka" - case .sudan: return "Sudan" - case .suriname: return "Suriname" - case .svalbardAndJanMayen: return "Svalbard and Jan Mayen" - case .swaziland: return "Swaziland" - case .sweden: return "Sweden" - case .switzerland: return "Switzerland" - case .syrianArabRepublic: return "Syrian Arab Republic" - case .taiwan: return "Taiwan" - case .tajikistan: return "Tajikistan" - case .tanzania: return "Tanzania" - case .thailand: return "Thailand" - case .timorLeste: return "Timor-Leste" - case .togo: return "Togo" - case .tokelau: return "Tokelau" - case .tonga: return "Tonga" - case .trinidadAndTobago: return "Trinidad and Tobago" - case .tunisia: return "Tunisia" - case .turkey: return "Turkey" - case .turkmenistan: return "Turkmenistan" - case .turksAndCaicosIslands: return "Turks and Caicos Islands" - case .tuvalu: return "Tuvalu" - case .uganda: return "Uganda" - case .ukraine: return "Ukraine" - case .unitedArabEmirates: return "United Arab Emirates" - case .unitedKingdom: return "United Kingdom" - case .unitedStates: return "United States" - case .unitedStatesMinorOutlyingIslands: return "United States Minor Outlying Islands" - case .uruguay: return "Uruguay" - case .uzbekistan: return "Uzbekistan" - case .vanuatu: return "Vanuatu" - case .venezuela: return "Venezuela" - case .vietnam: return "Vietnam" - case .virginIslandsBritish: return "British Virgin Islands" - case .virginIslandsUS: return "U.S. Virgin Islands" - case .wallisAndFutuna: return "Wallis and Futuna" - case .westernSahara: return "Western Sahara" - case .yemen: return "Yemen" - case .zambia: return "Zambia" - case .zimbabwe: return "Zimbabwe" - } - } } // swiftlint:enable line_length diff --git a/EhPanda/Models/Support/EhSetting+Enums.swift b/EhPanda/Models/Support/EhSetting+Enums.swift new file mode 100644 index 000000000..eeb717f54 --- /dev/null +++ b/EhPanda/Models/Support/EhSetting+Enums.swift @@ -0,0 +1,110 @@ +// +// EhSetting+Enums.swift +// EhPanda +// + +// MARK: CommentsSortOrder +extension EhSetting { + enum CommentsSortOrder: Int, CaseIterable, Identifiable { + case oldest + case recent + case highestScore + } +} +extension EhSetting.CommentsSortOrder { + var id: Int { rawValue } + + var value: String { + switch self { + case .oldest: + return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.oldest + case .recent: + return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.recent + case .highestScore: + return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.highestScore + } + } +} + +// MARK: CommentVotesShowTiming +extension EhSetting { + enum CommentVotesShowTiming: Int, CaseIterable, Identifiable { + case onHoverOrClick + case always + } +} +extension EhSetting.CommentVotesShowTiming { + var id: Int { rawValue } + + var value: String { + switch self { + case .onHoverOrClick: + return L10n.Localizable.Enum.EhSetting.CommentsVotesShowTiming.Value.onHoverOrClick + case .always: + return L10n.Localizable.Enum.EhSetting.CommentsVotesShowTiming.Value.always + } + } +} + +// MARK: TagsSortOrder +extension EhSetting { + enum TagsSortOrder: Int, CaseIterable, Identifiable { + case alphabetical + case tagPower + } +} +extension EhSetting.TagsSortOrder { + var id: Int { rawValue } + + var value: String { + switch self { + case .alphabetical: + return L10n.Localizable.Enum.EhSetting.TagsSortOrder.Value.alphabetical + case .tagPower: + return L10n.Localizable.Enum.EhSetting.TagsSortOrder.Value.tagPower + } + } +} + +// MARK: MultiplePageViewerStyle +extension EhSetting { + enum MultiplePageViewerStyle: Int, CaseIterable, Identifiable { + case alignLeftScaleIfOverWidth + case alignCenterScaleIfOverWidth + case alignCenterAlwaysScale + } +} +extension EhSetting.MultiplePageViewerStyle { + var id: Int { rawValue } + + var value: String { + switch self { + case .alignLeftScaleIfOverWidth: + return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignLeftScaleIfOverWidth + case .alignCenterScaleIfOverWidth: + return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignCenterScaleIfOverWidth + case .alignCenterAlwaysScale: + return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignCenterAlwaysScale + } + } +} + +// MARK: GalleryPageNumbering +extension EhSetting { + enum GalleryPageNumbering: Int, CaseIterable, Identifiable { + case none + case pageNumberOnly + case pageNumberAndName + } +} +extension EhSetting.GalleryPageNumbering { + var id: Int { rawValue } + + var value: String { + switch self { + case .none: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.none + case .pageNumberOnly: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.pageNumberOnly + case .pageNumberAndName: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.pageNumberAndName + } + } +} diff --git a/EhPanda/Models/Support/EhSetting+Extensions.swift b/EhPanda/Models/Support/EhSetting+Extensions.swift new file mode 100644 index 000000000..121b76389 --- /dev/null +++ b/EhPanda/Models/Support/EhSetting+Extensions.swift @@ -0,0 +1,87 @@ +// +// EhSetting+Extensions.swift +// EhPanda +// + +// MARK: ThumbnailLoadTiming +extension EhSetting { + enum ThumbnailLoadTiming: Int, CaseIterable, Identifiable { + case onMouseOver + case onPageLoad + } +} +extension EhSetting.ThumbnailLoadTiming { + var id: Int { rawValue } + + var value: String { + switch self { + case .onMouseOver: + return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Value.onMouseOver + case .onPageLoad: + return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Value.onPageLoad + } + } + var description: String { + switch self { + case .onMouseOver: + return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Description.onMouseOver + case .onPageLoad: + return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Description.onPageLoad + } + } +} + +// MARK: ThumbnailSize +extension EhSetting { + enum ThumbnailSize: Int, CaseIterable, Identifiable, Comparable { + case auto + case small + case normal + /// Deprecated + case large + } +} +extension EhSetting.ThumbnailSize { + var id: Int { rawValue } + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var value: String { + switch self { + case .normal: + return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.normal + case .large: + return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.large + case .small: + return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.small + case .auto: + return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.auto + } + } +} + +// MARK: ThumbnailRowCount +extension EhSetting { + enum ThumbnailRowCount: Int, CaseIterable, Identifiable, Comparable { + case four + case ten + case twenty + case forty + } +} +extension EhSetting.ThumbnailRowCount { + var id: Int { rawValue } + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var value: String { + switch self { + case .four: "4" + case .ten: "8" + case .twenty: "20" + case .forty: "40" + } + } +} diff --git a/EhPanda/Models/Support/EhSetting.swift b/EhPanda/Models/Support/EhSetting.swift index d7fc9aad8..cbb6a2df4 100644 --- a/EhPanda/Models/Support/EhSetting.swift +++ b/EhPanda/Models/Support/EhSetting.swift @@ -352,192 +352,3 @@ extension EhSetting.SearchResultCount { } } } - -// MARK: ThumbnailLoadTiming -extension EhSetting { - enum ThumbnailLoadTiming: Int, CaseIterable, Identifiable { - case onMouseOver - case onPageLoad - } -} -extension EhSetting.ThumbnailLoadTiming { - var id: Int { rawValue } - - var value: String { - switch self { - case .onMouseOver: - return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Value.onMouseOver - case .onPageLoad: - return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Value.onPageLoad - } - } - var description: String { - switch self { - case .onMouseOver: - return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Description.onMouseOver - case .onPageLoad: - return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Description.onPageLoad - } - } -} - -// MARK: ThumbnailSize -extension EhSetting { - enum ThumbnailSize: Int, CaseIterable, Identifiable, Comparable { - case auto - case small - case normal - /// Deprecated - case large - } -} -extension EhSetting.ThumbnailSize { - var id: Int { rawValue } - static func < (lhs: Self, rhs: Self) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .normal: - return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.normal - case .large: - return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.large - case .small: - return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.small - case .auto: - return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.auto - } - } -} - -// MARK: ThumbnailRowCount -extension EhSetting { - enum ThumbnailRowCount: Int, CaseIterable, Identifiable, Comparable { - case four - case ten - case twenty - case forty - } -} -extension EhSetting.ThumbnailRowCount { - var id: Int { rawValue } - static func < (lhs: Self, rhs: Self) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .four: "4" - case .ten: "8" - case .twenty: "20" - case .forty: "40" - } - } -} - -// MARK: CommentsSortOrder -extension EhSetting { - enum CommentsSortOrder: Int, CaseIterable, Identifiable { - case oldest - case recent - case highestScore - } -} -extension EhSetting.CommentsSortOrder { - var id: Int { rawValue } - - var value: String { - switch self { - case .oldest: - return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.oldest - case .recent: - return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.recent - case .highestScore: - return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.highestScore - } - } -} - -// MARK: CommentVotesShowTiming -extension EhSetting { - enum CommentVotesShowTiming: Int, CaseIterable, Identifiable { - case onHoverOrClick - case always - } -} -extension EhSetting.CommentVotesShowTiming { - var id: Int { rawValue } - - var value: String { - switch self { - case .onHoverOrClick: - return L10n.Localizable.Enum.EhSetting.CommentsVotesShowTiming.Value.onHoverOrClick - case .always: - return L10n.Localizable.Enum.EhSetting.CommentsVotesShowTiming.Value.always - } - } -} - -// MARK: TagsSortOrder -extension EhSetting { - enum TagsSortOrder: Int, CaseIterable, Identifiable { - case alphabetical - case tagPower - } -} -extension EhSetting.TagsSortOrder { - var id: Int { rawValue } - - var value: String { - switch self { - case .alphabetical: - return L10n.Localizable.Enum.EhSetting.TagsSortOrder.Value.alphabetical - case .tagPower: - return L10n.Localizable.Enum.EhSetting.TagsSortOrder.Value.tagPower - } - } -} - -// MARK: MultiplePageViewerStyle -extension EhSetting { - enum MultiplePageViewerStyle: Int, CaseIterable, Identifiable { - case alignLeftScaleIfOverWidth - case alignCenterScaleIfOverWidth - case alignCenterAlwaysScale - } -} -extension EhSetting.MultiplePageViewerStyle { - var id: Int { rawValue } - - var value: String { - switch self { - case .alignLeftScaleIfOverWidth: - return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignLeftScaleIfOverWidth - case .alignCenterScaleIfOverWidth: - return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignCenterScaleIfOverWidth - case .alignCenterAlwaysScale: - return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignCenterAlwaysScale - } - } -} - -// MARK: GalleryPageNumbering -extension EhSetting { - enum GalleryPageNumbering: Int, CaseIterable, Identifiable { - case none - case pageNumberOnly - case pageNumberAndName - } -} -extension EhSetting.GalleryPageNumbering { - var id: Int { rawValue } - - var value: String { - switch self { - case .none: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.none - case .pageNumberOnly: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.pageNumberOnly - case .pageNumberAndName: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.pageNumberAndName - } - } -} diff --git a/EhPanda/Models/Tags/TagTranslation.swift b/EhPanda/Models/Tags/TagTranslation.swift index b50e01a98..00d8dbecd 100644 --- a/EhPanda/Models/Tags/TagTranslation.swift +++ b/EhPanda/Models/Tags/TagTranslation.swift @@ -50,7 +50,7 @@ struct TagTranslation: Codable, Equatable, Hashable { func getSuggestion(keyword: String, originalKeyword: String, matchesNamespace: Bool) -> TagSuggestion { func getWeight(value: String, range: Range) -> Float { namespace.weight * .init(keyword.count + 1) / .init(value.count) - * (range.lowerBound == value.startIndex ? 2.0 : 1.0) + * (range.lowerBound == value.startIndex ? 2.0 : 1.0) } var weight: Float = .zero diff --git a/EhPanda/Models/Tags/TranslatableLanguage.swift b/EhPanda/Models/Tags/TranslatableLanguage.swift index bfae94a83..ec46b8a96 100644 --- a/EhPanda/Models/Tags/TranslatableLanguage.swift +++ b/EhPanda/Models/Tags/TranslatableLanguage.swift @@ -16,7 +16,7 @@ extension TranslatableLanguage { static var current: TranslatableLanguage? { guard let preferredLanguage = Locale.preferredLanguages.first, let translatableLanguage = TranslatableLanguage.allCases.compactMap({ lang in - preferredLanguage.contains(lang.languageCode) ? lang : nil + preferredLanguage.contains(lang.languageCode) ? lang : nil }).first else { return nil } return translatableLanguage } diff --git a/EhPanda/Network/DFRequest.swift b/EhPanda/Network/DFRequest.swift index cdfc8579f..1a1c2a9a6 100644 --- a/EhPanda/Network/DFRequest.swift +++ b/EhPanda/Network/DFRequest.swift @@ -20,7 +20,7 @@ struct DFRequest { request = req.domainIPReplaced() if let url = req.url, - let cookies = HTTPCookieStorage + let cookies = HTTPCookieStorage .shared.cookies(for: url) { request.allHTTPHeaderFields = HTTPCookie .requestHeaderFields(with: cookies) diff --git a/EhPanda/Network/DFStreamHandler.swift b/EhPanda/Network/DFStreamHandler.swift index cd7404ab7..237df1701 100644 --- a/EhPanda/Network/DFStreamHandler.swift +++ b/EhPanda/Network/DFStreamHandler.swift @@ -160,8 +160,7 @@ private extension DFStreamEventHandler { if ["/", "/popular", "/watched"].contains(url.absoluteString) || ["/?f_search"].contains(where: url.absoluteString.contains), let domain = request.request.domainWithScheme, - let originalURL = URL(string: domain) - { + let originalURL = URL(string: domain) { url = originalURL.appendingPathComponent(url.absoluteString) } diff --git a/EhPanda/Network/Request+Account.swift b/EhPanda/Network/Request+Account.swift new file mode 100644 index 000000000..fac56baf2 --- /dev/null +++ b/EhPanda/Network/Request+Account.swift @@ -0,0 +1,399 @@ +// +// Request+Account.swift +// EhPanda +// + +import Kanna +import Combine +import Foundation + +// MARK: Account Ops +struct LoginRequest: Request { + let username: String + let password: String + + var publisher: AnyPublisher { + let params: [String: String] = [ + "b": "d", + "bt": "1-1", + "CookieDate": "1", + "UserName": username, + "PassWord": password, + "ipb_login_submit": "Login!" + ] + + var request = URLRequest(url: Defaults.URL.login) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { $0.response as? HTTPURLResponse } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct IgneousRequest: Request { + var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: Defaults.URL.exhentai) + .genericRetry() + .compactMap { $0.response as? HTTPURLResponse } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct VerifyEhProfileResponse: Equatable { + let profileValue: Int? + let isProfileNotFound: Bool +} +struct VerifyEhProfileRequest: Request { + var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap(Parser.parseProfileIndex) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct EhProfileRequest: Request { + var action: EhProfileAction? + var name: String? + var set: Int? + + var publisher: AnyPublisher { + var params = [String: String]() + + if let action = action { + params["profile_action"] = action.rawValue + } + if let name = name { + params["profile_name"] = name + } + if let set = set { + params["profile_set"] = "\(set)" + } + + var request = URLRequest(url: Defaults.URL.uConfig) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap(Parser.parseEhSetting) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct EhSettingRequest: Request { + var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap(Parser.parseEhSetting) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct SubmitEhSettingChangesRequest: Request { + let ehSetting: EhSetting + + var publisher: AnyPublisher { + let url = Defaults.URL.uConfig + var params: [String: String] = [ + "uh": String(ehSetting.loadThroughHathSetting.rawValue), + "co": ehSetting.browsingCountry.rawValue, + "xr": String(ehSetting.imageResolution.rawValue), + "rx": String(Int(ehSetting.imageSizeWidth)), + "ry": String(Int(ehSetting.imageSizeHeight)), + "tl": String(ehSetting.galleryName.rawValue), + "ar": String(ehSetting.archiverBehavior.rawValue), + "dm": String(ehSetting.displayMode.rawValue), + "pp": ehSetting.showSearchRangeIndicator ? "0" : "1", + "fs": String(ehSetting.favoritesSortOrder.rawValue), + "ru": ehSetting.ratingsColor, + "ft": String(Int(ehSetting.tagFilteringThreshold)), + "wt": String(Int(ehSetting.tagWatchingThreshold)), + "tf": ehSetting.showFilteredRemovalCount ? "0" : "1", + "xu": ehSetting.excludedUploaders, + "rc": String(ehSetting.searchResultCount.rawValue), + "lt": String(ehSetting.thumbnailLoadTiming.rawValue), + "tr": String(ehSetting.thumbnailConfigRows.rawValue), + "tp": String(Int(ehSetting.coverScaleFactor)), + "vp": String(Int(ehSetting.viewportVirtualWidth)), + "cs": String(ehSetting.commentsSortOrder.rawValue), + "sc": String(ehSetting.commentVotesShowTiming.rawValue), + "tb": String(ehSetting.tagsSortOrder.rawValue), + "pn": String(ehSetting.galleryPageNumbering.rawValue), + "apply": "Apply" + ] + + if ehSetting.enableGalleryThumbnailSelector { + params["xn_0"] = "on" + } + + switch ehSetting.thumbnailConfigSize { + case .auto: params["ts"] = "0" + case .normal: params["ts"] = "1" + case .small: params["ts"] = "2" + default: break + } + + EhSetting.categoryNames.enumerated().forEach { index, name in + params["ct_\(name)"] = ehSetting.disabledCategories[index] ? "1" : "0" + } + Array(0...9).forEach { index in + params["favorite_\(index)"] = ehSetting.favoriteCategories[index] + } + ehSetting.excludedLanguages.enumerated().forEach { index, value in + if value { + params["xl_\(EhSetting.languageValues[index])"] = "on" + } + } + + if let useOriginalImages = ehSetting.useOriginalImages { + params["oi"] = useOriginalImages ? "1" : "0" + } + if let useMultiplePageViewer = ehSetting.useMultiplePageViewer { + params["qb"] = useMultiplePageViewer ? "1" : "0" + } + if let multiplePageViewerStyle = ehSetting.multiplePageViewerStyle { + params["ms"] = String(multiplePageViewerStyle.rawValue) + } + if let multiplePageViewerShowThumbnailPane = ehSetting.multiplePageViewerShowThumbnailPane { + params["mt"] = multiplePageViewerShowThumbnailPane ? "0" : "1" + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap(Parser.parseEhSetting) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct FavorGalleryRequest: Request { + let gid: String + let token: String + let favIndex: Int + + var publisher: AnyPublisher { + let url = URLUtil.addFavorite(gid: gid, token: token) + let params: [String: String] = [ + "favcat": "\(favIndex)", + "favnote": "", + "apply": "Add to Favorites", + "update": "1" + ] + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { $0 } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct UnfavorGalleryRequest: Request { + let gid: String + + var publisher: AnyPublisher { + let params: [String: String] = [ + "ddact": "delete", + "modifygids[]": gid, + "apply": "Apply" + ] + + var request = URLRequest(url: Defaults.URL.favorites) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { $0 } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct SendDownloadCommandRequest: Request { + let archiveURL: URL + let resolution: String + + var publisher: AnyPublisher { + let params: [String: String] = [ + "hathdl_xres": resolution + ] + + var request = URLRequest(url: archiveURL) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap(Parser.parseDownloadCommandResponse) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct RateGalleryRequest: Request { + let apiuid: Int + let apikey: String + let gid: Int + let token: String + let rating: Int + + var publisher: AnyPublisher { + let params: [String: Any] = [ + "method": "rategallery", + "apiuid": apiuid, + "apikey": apikey, + "gid": gid, + "token": token, + "rating": rating + ] + + var request = URLRequest(url: Defaults.URL.api) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { $0 } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct CommentGalleryRequest: Request { + let content: String + let galleryURL: URL + + var publisher: AnyPublisher { + let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") + let params: [String: String] = [ + "commenttext_new": fixedContent + ] + + var request = URLRequest(url: galleryURL) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { $0 } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct EditGalleryCommentRequest: Request { + let commentID: String + let content: String + let galleryURL: URL + + var publisher: AnyPublisher { + let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") + let params: [String: String] = [ + "edit_comment": commentID, + "commenttext_edit": fixedContent + ] + + var request = URLRequest(url: galleryURL) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { $0 } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct VoteGalleryCommentRequest: Request { + let apiuid: Int + let apikey: String + let gid: Int + let token: String + let commentID: Int + let commentVote: Int + + var publisher: AnyPublisher { + let params: [String: Any] = [ + "method": "votecomment", + "apiuid": apiuid, + "apikey": apikey, + "gid": gid, + "token": token, + "comment_id": commentID, + "comment_vote": commentVote + ] + + var request = URLRequest(url: Defaults.URL.api) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { $0 } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct VoteGalleryTagRequest: Request { + let apiuid: Int + let apikey: String + let gid: Int + let token: String + let tag: String + let vote: Int + + var publisher: AnyPublisher { + let params: [String: Any] = [ + "method": "taggallery", + "apiuid": apiuid, + "apikey": apikey, + "gid": gid, + "token": token, + "tags": tag, + "vote": vote + ] + + var request = URLRequest(url: Defaults.URL.api) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { $0 } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/EhPanda/Network/Request+Detail.swift b/EhPanda/Network/Request+Detail.swift new file mode 100644 index 000000000..ffc2af5bc --- /dev/null +++ b/EhPanda/Network/Request+Detail.swift @@ -0,0 +1,269 @@ +// +// Request+Detail.swift +// EhPanda +// + +import Kanna +import Combine +import Foundation + +// MARK: Response Types +struct GalleryDetailResponse { + let galleryDetail: GalleryDetail + let galleryState: GalleryState + let apiKey: String + let greeting: Greeting? +} + +// MARK: Fetch others +struct GalleryDetailRequest: Request { + let gid: String + let galleryURL: URL + + var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: URLUtil.galleryDetail(url: galleryURL)) + .genericRetry() + .tryMap { resp -> HTMLDocument in + do { + return try Kanna.HTML(html: resp.data, encoding: .utf8) + } catch { + guard let parseError = error as? ParseError, parseError == .EncodingMismatch + else { throw error } + + guard let htmlDocument = try? Kanna.HTML( + html: resp.data.utf8InvalidCharactersRipped, + encoding: .utf8 + ) else { + throw error + } + return htmlDocument + } + } + .tryMap { doc in + let (detail, state) = try Parser.parseGalleryDetail(doc: doc, gid: gid) + return (doc, detail, state, try Parser.parseAPIKey(doc: doc)) + } + .mapError(mapAppError) + .map { doc, detail, state, apiKey in + GalleryDetailResponse( + galleryDetail: detail, + galleryState: state, + apiKey: apiKey, + greeting: try? Parser.parseGreeting(doc: doc) + ) + } + .eraseToAnyPublisher() + } +} + +private struct GalleryVersionMetadata: Decodable { + let gid: Int + let token: String + let currentGID: Int? + let currentKey: String? + let parentGID: Int? + let parentKey: String? + let firstGID: Int? + let firstKey: String? + + enum CodingKeys: String, CodingKey { + case gid + case token + case currentGID = "current_gid" + case currentKey = "current_key" + case parentGID = "parent_gid" + case parentKey = "parent_key" + case firstGID = "first_gid" + case firstKey = "first_key" + } + + var versionMetadata: DownloadVersionMetadata { + DownloadVersionMetadata( + gid: String(gid), + token: token, + currentGID: currentGID.map(String.init), + currentKey: currentKey, + parentGID: parentGID.map(String.init), + parentKey: parentKey, + firstGID: firstGID.map(String.init), + firstKey: firstKey + ) + } +} + +private struct GalleryVersionMetadataAPIResponse: Decodable { + let gmetadata: [GalleryVersionMetadata] +} + +struct GalleryVersionMetadataRequest: Request { + let gid: String + let token: String + + var publisher: AnyPublisher { + guard let gid = Int(gid) else { + return Fail(error: AppError.notFound) + .eraseToAnyPublisher() + } + + let params: [String: Any] = [ + "method": "gdata", + "gidlist": [[gid, token]], + "namespace": 1 + ] + + var request = URLRequest(url: Defaults.URL.api) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map(\.data) + .tryMap { data in + let response = try JSONDecoder().decode(GalleryVersionMetadataAPIResponse.self, from: data) + guard let metadata = response.gmetadata.first?.versionMetadata else { + throw AppError.notFound + } + return metadata + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct GalleryReverseRequest: Request { + let url: URL + let isGalleryImageURL: Bool + + func getGallery(from detail: GalleryDetail?, and url: URL) -> Gallery? { + if let detail = detail { + return Gallery( + gid: url.pathComponents[2], + token: url.pathComponents[3], + title: detail.title, + rating: detail.rating, + tags: [], + category: detail.category, + uploader: detail.uploader, + pageCount: detail.pageCount, + postedDate: detail.postedDate, + coverURL: detail.coverURL, + galleryURL: url + ) + } else { + return nil + } + } + + var publisher: AnyPublisher { + galleryURL(url: url) + .genericRetry() + .flatMap(gallery) + .eraseToAnyPublisher() + } + + func galleryURL(url: URL) -> AnyPublisher { + switch isGalleryImageURL { + case true: + return URLSession.shared.dataTaskPublisher(for: url) + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap(Parser.parseGalleryURL) + .mapError(mapAppError) + .eraseToAnyPublisher() + + case false: + return Just(url) + .setFailureType(to: AppError.self) + .eraseToAnyPublisher() + } + } + + func gallery(url: URL) -> AnyPublisher { + URLSession.shared.dataTaskPublisher(for: url) + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .compactMap { + guard let (detail, _) = try? Parser.parseGalleryDetail(doc: $0, gid: url.pathComponents[2]) + else { return nil } + + return getGallery(from: detail, and: url) + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct GalleryArchiveRequest: Request { + let archiveURL: URL + + var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: archiveURL) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { (html: HTMLDocument) -> (HTMLDocument, GalleryArchive) in + let archive = try Parser.parseGalleryArchive(doc: html) + return (html, archive) + } + .map { html, archive in + guard let (currentGP, currentCredits) = try? Parser.parseCurrentFunds(doc: html) + else { return GalleryArchiveResponse(archive: archive, galleryPoints: nil, credits: nil) } + return GalleryArchiveResponse(archive: archive, galleryPoints: currentGP, credits: currentCredits) + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct GalleryArchiveFundsRequest: Request { + let gid: String + let galleryURL: URL + + var publisher: AnyPublisher<(String, String), AppError> { + archiveURL(url: galleryURL) + .genericRetry() + .flatMap(funds) + .eraseToAnyPublisher() + } + + func archiveURL(url: URL) -> AnyPublisher { + URLSession.shared.dataTaskPublisher(for: url) + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .compactMap { try? Parser.parseGalleryDetail(doc: $0, gid: gid).0.archiveURL } + .mapError(mapAppError) + .eraseToAnyPublisher() + } + + func funds(url: URL) -> AnyPublisher<(String, String), AppError> { + URLSession.shared.dataTaskPublisher(for: url) + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap(Parser.parseCurrentFunds) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct GalleryTorrentsRequest: Request { + let gid: String + let token: String + + var publisher: AnyPublisher<[GalleryTorrent], AppError> { + URLSession.shared.dataTaskPublisher(for: URLUtil.galleryTorrents(gid: gid, token: token)) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .map(Parser.parseGalleryTorrents) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct GalleryPreviewURLsRequest: Request { + let galleryURL: URL + let pageNum: Int + + var publisher: AnyPublisher<[Int: URL], AppError> { + URLSession.shared.dataTaskPublisher(for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum)) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap(Parser.parsePreviewURLs) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/EhPanda/Network/Request+Gallery.swift b/EhPanda/Network/Request+Gallery.swift new file mode 100644 index 000000000..5e96f2585 --- /dev/null +++ b/EhPanda/Network/Request+Gallery.swift @@ -0,0 +1,196 @@ +// +// Request+Gallery.swift +// EhPanda +// + +import Kanna +import Combine +import Foundation + +// MARK: Fetch ListItems +struct SearchGalleriesRequest: Request { + let keyword: String + let filter: Filter + + var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + URLSession.shared.dataTaskPublisher( + for: URLUtil.searchList(keyword: keyword, filter: filter) + ) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct MoreSearchGalleriesRequest: Request { + let keyword: String + let filter: Filter + let lastID: String + + var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + URLSession.shared.dataTaskPublisher( + for: URLUtil.moreSearchList(keyword: keyword, filter: filter, lastID: lastID) + ) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct FrontpageGalleriesRequest: Request { + let filter: Filter + + var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + URLSession.shared.dataTaskPublisher(for: URLUtil.frontpageList(filter: filter)) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct MoreFrontpageGalleriesRequest: Request { + let filter: Filter + let lastID: String + + var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + URLSession.shared.dataTaskPublisher(for: URLUtil.moreFrontpageList(filter: filter, lastID: lastID)) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct PopularGalleriesRequest: Request { + let filter: Filter + + var publisher: AnyPublisher<[Gallery], AppError> { + URLSession.shared.dataTaskPublisher(for: URLUtil.popularList(filter: filter)) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap(Parser.parseGalleries) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct WatchedGalleriesRequest: Request { + let filter: Filter + let keyword: String + + var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + URLSession.shared.dataTaskPublisher(for: URLUtil.watchedList(filter: filter, keyword: keyword)) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct MoreWatchedGalleriesRequest: Request { + let filter: Filter + let lastID: String + let keyword: String + + var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + URLSession.shared.dataTaskPublisher( + for: URLUtil.moreWatchedList(filter: filter, lastID: lastID, keyword: keyword) + ) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct FavoritesGalleriesRequest: Request { + let favIndex: Int + let keyword: String + var sortOrder: FavoritesSortOrder? + + var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher( + for: URLUtil.favoritesList(favIndex: favIndex, keyword: keyword, sortOrder: sortOrder) + ) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { + FavoritesGalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + sortOrder: Parser.parseFavoritesSortOrder(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct MoreFavoritesGalleriesRequest: Request { + let favIndex: Int + let lastID: String + var lastTimestamp: String + let keyword: String + + var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher( + for: URLUtil.moreFavoritesList( + favIndex: favIndex, lastID: lastID, lastTimestamp: lastTimestamp, keyword: keyword + ) + ) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { + FavoritesGalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + sortOrder: Parser.parseFavoritesSortOrder(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct ToplistsGalleriesRequest: Request { + let catIndex: Int + var pageNum: Int? + + var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + URLSession.shared.dataTaskPublisher( + for: URLUtil.toplistsList(catIndex: catIndex, pageNum: pageNum) + ) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct MoreToplistsGalleriesRequest: Request { + let catIndex: Int + let pageNum: Int + + var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + URLSession.shared.dataTaskPublisher( + for: URLUtil.moreToplistsList( + catIndex: catIndex, pageNum: pageNum + ) + ) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/EhPanda/Network/Request+Image.swift b/EhPanda/Network/Request+Image.swift new file mode 100644 index 000000000..82b178c1c --- /dev/null +++ b/EhPanda/Network/Request+Image.swift @@ -0,0 +1,247 @@ +// +// Request+Image.swift +// EhPanda +// + +import Kanna +import Combine +import Foundation + +// MARK: Response Types +struct GalleryMPVImageURLResponse { + let imageURL: URL + let originalImageURL: URL? + let skipServerIdentifier: String +} + +// MARK: Image Requests +struct MPVKeysRequest: Request { + let mpvURL: URL + + var publisher: AnyPublisher<(String, [Int: String]), AppError> { + URLSession.shared.dataTaskPublisher(for: mpvURL) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap(Parser.parseMPVKeys) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct ThumbnailURLsRequest: Request { + let galleryURL: URL + let pageNum: Int + + var publisher: AnyPublisher<[Int: URL], AppError> { + URLSession.shared.dataTaskPublisher( + for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum) + ) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap(Parser.parseThumbnailURLs) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct GalleryNormalImageURLsRequest: Request { + let thumbnailURLs: [Int: URL] + + var publisher: AnyPublisher<([Int: URL], [Int: URL]), AppError> { + thumbnailURLs.publisher + .flatMap { index, url in + URLSession.shared.dataTaskPublisher(for: url) + .genericRetry() + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { + try Parser.parseGalleryNormalImageURL(doc: $0, index: index) + } + } + .collect() + .map { infos in + var imageURLs = [Int: URL]() + var originalImageURLs = [Int: URL]() + for info in infos { + imageURLs[info.index] = info.imageURL + originalImageURLs[info.index] = info.originalImageURL + } + return (imageURLs, originalImageURLs) + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct ImageURLRefetchResult { + let imageURL: URL + let anotherImageURL: URL + let response: HTTPURLResponse? +} + +struct GalleryNormalImageURLRefetchRequest: Request { + let index: Int + let pageNum: Int + let galleryURL: URL + let thumbnailURL: URL? + let storedImageURL: URL + + var publisher: AnyPublisher<([Int: URL], HTTPURLResponse?), AppError> { + storedThumbnailURL() + .flatMap(renewThumbnailURL) + .flatMap(imageURL) + .genericRetry() + .map { result in + ( + [index: result.imageURL != storedImageURL + ? result.imageURL : result.anotherImageURL], + result.response + ) + } + .eraseToAnyPublisher() + } + + func storedThumbnailURL() -> AnyPublisher { + if let thumbnailURL = thumbnailURL { + return Just(thumbnailURL) + .setFailureType(to: AppError.self) + .eraseToAnyPublisher() + } else { + return URLSession.shared.dataTaskPublisher( + for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum) + ) + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap(Parser.parseThumbnailURLs) + .compactMap({ thumbnailURLs in thumbnailURLs[index] }) + .mapError(mapAppError) + .eraseToAnyPublisher() + } + } + + func renewThumbnailURL(stored: URL) + -> AnyPublisher<(URL, URL), AppError> { + URLSession.shared.dataTaskPublisher(for: stored) + .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { + let identifier = try Parser.parseSkipServerIdentifier(doc: $0) + let imageURL = try Parser.parseGalleryNormalImageURL( + doc: $0, index: index + ).imageURL + return ( + stored.appending( + queryItems: [.skipServerIdentifier: identifier] + ), + imageURL + ) + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } + + func imageURL(thumbnailURL: URL, anotherImageURL: URL) + -> AnyPublisher { + URLSession.shared.dataTaskPublisher(for: thumbnailURL) + .tryMap { + ( + try Kanna.HTML(html: $0.data, encoding: .utf8), + $0.response as? HTTPURLResponse + ) + } + .tryMap { html, response in + ( + try Parser.parseGalleryNormalImageURL( + doc: html, index: index + ), + response + ) + } + .map { info, response in + ImageURLRefetchResult( + imageURL: anotherImageURL, + anotherImageURL: info.imageURL, + response: response + ) + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +struct GalleryMPVImageURLRequest: Request { + let gid: Int + let index: Int + let mpvKey: String + let mpvImageKey: String + let skipServerIdentifier: String? + + var publisher: AnyPublisher { + var params: [String: Any] = [ + "method": "imagedispatch", + "gid": gid, + "page": index, + "imgkey": mpvImageKey, + "mpvkey": mpvKey + ] + if let skipServerIdentifier = skipServerIdentifier { + params["nl"] = skipServerIdentifier + } + + var request = URLRequest(url: Defaults.URL.api) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data( + withJSONObject: params, options: [] + ) + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map(\.data) + .tryMap { data in + guard let dict = try JSONSerialization + .jsonObject(with: data) as? [String: Any], + let imageURLString = dict["i"] as? String, + let imageURL = URL(string: imageURLString) + else { throw AppError.parseFailed } + + var skipServerIdentifier: String? + + if let integerIdentifier = dict["s"] as? Int { + skipServerIdentifier = integerIdentifier.description + } else if let stringIdentifier = dict["s"] as? String { + skipServerIdentifier = stringIdentifier + } + + guard let skipServerIdentifier + else { throw AppError.parseFailed } + + if let originalSlice = dict["lf"] as? String { + let originalImageURL = Defaults.URL.host + .appendingPathComponent(originalSlice) + return GalleryMPVImageURLResponse( + imageURL: imageURL, + originalImageURL: originalImageURL, + skipServerIdentifier: skipServerIdentifier + ) + } else { + return GalleryMPVImageURLResponse( + imageURL: imageURL, + originalImageURL: nil, + skipServerIdentifier: skipServerIdentifier + ) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +// MARK: Tool +struct DataRequest: Request { + let url: URL + + var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: url) + .genericRetry() + .map(\.data) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index b59199ea4..e4a15a8c7 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -1,7 +1,6 @@ // -// PopularItemsRequest.swift +// Request.swift // EhPanda -// import Kanna import Combine @@ -35,7 +34,7 @@ extension Request { } } -private extension Publisher { +extension Publisher { func genericRetry() -> Publishers.Retry { retry(3) } @@ -62,12 +61,12 @@ private extension Publisher { } } } -private extension URLRequest { +extension URLRequest { mutating func setURLEncodedContentType() { setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") } } -private extension Dictionary where Key == String, Value == String { +extension Dictionary where Key == String, Value == String { func dictString() -> String { var array = [String]() keys.forEach { key in @@ -85,6 +84,20 @@ private extension URL { } } +// MARK: - Response Types + +struct FavoritesGalleriesResult { + let pageNumber: PageNumber + let sortOrder: FavoritesSortOrder? + let galleries: [Gallery] +} + +struct GalleryArchiveResponse { + let archive: GalleryArchive + let galleryPoints: String? + let credits: String? +} + // MARK: Routine struct GreetingRequest: Request { var publisher: AnyPublisher { @@ -148,1031 +161,20 @@ struct TagTranslatorRequest: Request { .flatMap { date in URLSession.shared.dataTaskPublisher(for: language.downloadURL) .tryMap { data, _ in - let response = try JSONDecoder().decode(EhTagTranslationDatabaseResponse.self, from: data) + let response = try JSONDecoder().decode( + EhTagTranslationDatabaseResponse.self, from: data + ) var translations = response.tagTranslations guard !translations.isEmpty else { throw AppError.parseFailed } if language == .traditionalChinese { translations = translations.chtConverted } - return TagTranslator(language: language, updatedDate: date, translations: translations) + return TagTranslator( + language: language, updatedDate: date, translations: translations + ) } } .mapError(mapAppError) .eraseToAnyPublisher() } } - -// MARK: Fetch ListItems -struct SearchGalleriesRequest: Request { - let keyword: String - let filter: Filter - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.searchList(keyword: keyword, filter: filter) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct MoreSearchGalleriesRequest: Request { - let keyword: String - let filter: Filter - let lastID: String - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.moreSearchList(keyword: keyword, filter: filter, lastID: lastID) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct FrontpageGalleriesRequest: Request { - let filter: Filter - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.frontpageList(filter: filter)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct MoreFrontpageGalleriesRequest: Request { - let filter: Filter - let lastID: String - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.moreFrontpageList(filter: filter, lastID: lastID)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct PopularGalleriesRequest: Request { - let filter: Filter - - var publisher: AnyPublisher<[Gallery], AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.popularList(filter: filter)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseGalleries) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct WatchedGalleriesRequest: Request { - let filter: Filter - let keyword: String - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.watchedList(filter: filter, keyword: keyword)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct MoreWatchedGalleriesRequest: Request { - let filter: Filter - let lastID: String - let keyword: String - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.moreWatchedList(filter: filter, lastID: lastID, keyword: keyword) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct FavoritesGalleriesRequest: Request { - let favIndex: Int - let keyword: String - var sortOrder: FavoritesSortOrder? - - var publisher: AnyPublisher<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.favoritesList(favIndex: favIndex, keyword: keyword, sortOrder: sortOrder) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { - ( - Parser.parsePageNum(doc: $0), - Parser.parseFavoritesSortOrder(doc: $0), - try Parser.parseGalleries(doc: $0) - ) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct MoreFavoritesGalleriesRequest: Request { - let favIndex: Int - let lastID: String - var lastTimestamp: String - let keyword: String - - var publisher: AnyPublisher<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.moreFavoritesList( - favIndex: favIndex, lastID: lastID, lastTimestamp: lastTimestamp, keyword: keyword - ) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { - ( - Parser.parsePageNum(doc: $0), - Parser.parseFavoritesSortOrder(doc: $0), - try Parser.parseGalleries(doc: $0) - ) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct ToplistsGalleriesRequest: Request { - let catIndex: Int - var pageNum: Int? - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.toplistsList(catIndex: catIndex, pageNum: pageNum) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct MoreToplistsGalleriesRequest: Request { - let catIndex: Int - let pageNum: Int - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.moreToplistsList( - catIndex: catIndex, pageNum: pageNum - ) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -// MARK: Fetch others -struct GalleryDetailRequest: Request { - let gid: String - let galleryURL: URL - - var publisher: AnyPublisher<(GalleryDetail, GalleryState, String, Greeting?), AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.galleryDetail(url: galleryURL)) - .genericRetry() - .tryMap { resp -> HTMLDocument in - do { - return try Kanna.HTML(html: resp.data, encoding: .utf8) - } catch { - guard let parseError = error as? ParseError, parseError == .EncodingMismatch - else { throw error } - - guard let htmlDocument = try? Kanna.HTML( - html: resp.data.utf8InvalidCharactersRipped, - encoding: .utf8 - ) else { - throw error - } - return htmlDocument - } - } - .tryMap { doc in - let (detail, state) = try Parser.parseGalleryDetail(doc: doc, gid: gid) - return (doc, detail, state, try Parser.parseAPIKey(doc: doc)) - } - .mapError(mapAppError) - .map { doc, detail, state, apiKey in - ( - detail, - state, - apiKey, - try? Parser.parseGreeting(doc: doc) - ) - } - .eraseToAnyPublisher() - } -} - -private struct GalleryVersionMetadata: Decodable { - let gid: Int - let token: String - let currentGID: Int? - let currentKey: String? - let parentGID: Int? - let parentKey: String? - let firstGID: Int? - let firstKey: String? - - enum CodingKeys: String, CodingKey { - case gid - case token - case currentGID = "current_gid" - case currentKey = "current_key" - case parentGID = "parent_gid" - case parentKey = "parent_key" - case firstGID = "first_gid" - case firstKey = "first_key" - } - - var versionMetadata: DownloadVersionMetadata { - DownloadVersionMetadata( - gid: String(gid), - token: token, - currentGID: currentGID.map(String.init), - currentKey: currentKey, - parentGID: parentGID.map(String.init), - parentKey: parentKey, - firstGID: firstGID.map(String.init), - firstKey: firstKey - ) - } -} - -private struct GalleryVersionMetadataAPIResponse: Decodable { - let gmetadata: [GalleryVersionMetadata] -} - -struct GalleryVersionMetadataRequest: Request { - let gid: String - let token: String - - var publisher: AnyPublisher { - guard let gid = Int(gid) else { - return Fail(error: AppError.notFound) - .eraseToAnyPublisher() - } - - let params: [String: Any] = [ - "method": "gdata", - "gidlist": [[gid, token]], - "namespace": 1 - ] - - var request = URLRequest(url: Defaults.URL.api) - request.httpMethod = "POST" - request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map(\.data) - .tryMap { data in - let response = try JSONDecoder().decode(GalleryVersionMetadataAPIResponse.self, from: data) - guard let metadata = response.gmetadata.first?.versionMetadata else { - throw AppError.notFound - } - return metadata - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryReverseRequest: Request { - let url: URL - let isGalleryImageURL: Bool - - func getGallery(from detail: GalleryDetail?, and url: URL) -> Gallery? { - if let detail = detail { - return Gallery( - gid: url.pathComponents[2], - token: url.pathComponents[3], - title: detail.title, - rating: detail.rating, - tags: [], - category: detail.category, - uploader: detail.uploader, - pageCount: detail.pageCount, - postedDate: detail.postedDate, - coverURL: detail.coverURL, - galleryURL: url - ) - } else { - return nil - } - } - - var publisher: AnyPublisher { - galleryURL(url: url) - .genericRetry() - .flatMap(gallery) - .eraseToAnyPublisher() - } - - func galleryURL(url: URL) -> AnyPublisher { - switch isGalleryImageURL { - case true: - return URLSession.shared.dataTaskPublisher(for: url) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseGalleryURL) - .mapError(mapAppError) - .eraseToAnyPublisher() - - case false: - return Just(url) - .setFailureType(to: AppError.self) - .eraseToAnyPublisher() - } - } - - func gallery(url: URL) -> AnyPublisher { - URLSession.shared.dataTaskPublisher(for: url) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .compactMap { - guard let (detail, _) = try? Parser.parseGalleryDetail(doc: $0, gid: url.pathComponents[2]) - else { return nil } - - return getGallery(from: detail, and: url) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryArchiveRequest: Request { - let archiveURL: URL - - var publisher: AnyPublisher<(GalleryArchive, String?, String?), AppError> { - URLSession.shared.dataTaskPublisher(for: archiveURL) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (html: HTMLDocument) -> (HTMLDocument, GalleryArchive) in - let archive = try Parser.parseGalleryArchive(doc: html) - return (html, archive) - } - .map { html, archive in - guard let (currentGP, currentCredits) = try? Parser.parseCurrentFunds(doc: html) - else { return (archive, nil, nil) } - return (archive, currentGP, currentCredits) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryArchiveFundsRequest: Request { - let gid: String - let galleryURL: URL - - var publisher: AnyPublisher<(String, String), AppError> { - archiveURL(url: galleryURL) - .genericRetry() - .flatMap(funds) - .eraseToAnyPublisher() - } - - func archiveURL(url: URL) -> AnyPublisher { - URLSession.shared.dataTaskPublisher(for: url) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .compactMap { try? Parser.parseGalleryDetail(doc: $0, gid: gid).0.archiveURL } - .mapError(mapAppError) - .eraseToAnyPublisher() - } - - func funds(url: URL) -> AnyPublisher<(String, String), AppError> { - URLSession.shared.dataTaskPublisher(for: url) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseCurrentFunds) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryTorrentsRequest: Request { - let gid: String - let token: String - - var publisher: AnyPublisher<[GalleryTorrent], AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.galleryTorrents(gid: gid, token: token)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .map(Parser.parseGalleryTorrents) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryPreviewURLsRequest: Request { - let galleryURL: URL - let pageNum: Int - - var publisher: AnyPublisher<[Int: URL], AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parsePreviewURLs) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct MPVKeysRequest: Request { - let mpvURL: URL - - var publisher: AnyPublisher<(String, [Int: String]), AppError> { - URLSession.shared.dataTaskPublisher(for: mpvURL) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseMPVKeys) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct ThumbnailURLsRequest: Request { - let galleryURL: URL - let pageNum: Int - - var publisher: AnyPublisher<[Int: URL], AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseThumbnailURLs) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryNormalImageURLsRequest: Request { - let thumbnailURLs: [Int: URL] - - var publisher: AnyPublisher<([Int: URL], [Int: URL]), AppError> { - thumbnailURLs.publisher - .flatMap { index, url in - URLSession.shared.dataTaskPublisher(for: url) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { try Parser.parseGalleryNormalImageURL(doc: $0, index: index) } - } - .collect() - .map { tuples in - var imageURLs = [Int: URL]() - var originalImageURLs = [Int: URL]() - for (index, imageURL, originalImageURL) in tuples { - imageURLs[index] = imageURL - originalImageURLs[index] = originalImageURL - } - return (imageURLs, originalImageURLs) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryNormalImageURLRefetchRequest: Request { - let index: Int - let pageNum: Int - let galleryURL: URL - let thumbnailURL: URL? - let storedImageURL: URL - - var publisher: AnyPublisher<([Int: URL], HTTPURLResponse?), AppError> { - storedThumbnailURL() - .flatMap(renewThumbnailURL) - .flatMap(imageURL) - .genericRetry() - .map { imageURL1, imageURL2, response in - ([index: imageURL1 != storedImageURL ? imageURL1 : imageURL2], response) - } - .eraseToAnyPublisher() - } - - func storedThumbnailURL() -> AnyPublisher { - if let thumbnailURL = thumbnailURL { - return Just(thumbnailURL) - .setFailureType(to: AppError.self) - .eraseToAnyPublisher() - } else { - return URLSession.shared.dataTaskPublisher(for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum)) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseThumbnailURLs) - .compactMap({ thumbnailURLs in thumbnailURLs[index] }) - .mapError(mapAppError) - .eraseToAnyPublisher() - } - } - - func renewThumbnailURL(stored: URL) -> AnyPublisher<(URL, URL), AppError> { - URLSession.shared.dataTaskPublisher(for: stored) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { - let identifier = try Parser.parseSkipServerIdentifier(doc: $0) - let imageURL = try Parser.parseGalleryNormalImageURL(doc: $0, index: index).1 - return (stored.appending(queryItems: [.skipServerIdentifier: identifier]), imageURL) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } - - func imageURL(thumbnailURL: URL, anotherImageURL: URL) - -> AnyPublisher<(URL, URL, HTTPURLResponse?), AppError> { - URLSession.shared.dataTaskPublisher(for: thumbnailURL) - .tryMap { - (try Kanna.HTML(html: $0.data, encoding: .utf8), $0.response as? HTTPURLResponse) - } - .tryMap { html, response in - (try Parser.parseGalleryNormalImageURL(doc: html, index: index), response) - } - .map { imageURL, response in - (anotherImageURL, imageURL.1, response) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryMPVImageURLRequest: Request { - let gid: Int - let index: Int - let mpvKey: String - let mpvImageKey: String - let skipServerIdentifier: String? - - var publisher: AnyPublisher<(URL, URL?, String), AppError> { - var params: [String: Any] = [ - "method": "imagedispatch", - "gid": gid, - "page": index, - "imgkey": mpvImageKey, - "mpvkey": mpvKey - ] - if let skipServerIdentifier = skipServerIdentifier { - params["nl"] = skipServerIdentifier - } - - var request = URLRequest(url: Defaults.URL.api) - request.httpMethod = "POST" - request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map(\.data) - .tryMap { data in - guard let dict = try JSONSerialization - .jsonObject(with: data) as? [String: Any], - let imageURLString = dict["i"] as? String, - let imageURL = URL(string: imageURLString) - else { throw AppError.parseFailed } - - var skipServerIdentifier: String? - - if let integerIdentifier = dict["s"] as? Int { - skipServerIdentifier = integerIdentifier.description - } else if let stringIdentifier = dict["s"] as? String { - skipServerIdentifier = stringIdentifier - } - - guard let skipServerIdentifier else { throw AppError.parseFailed } - - if let originalImageURLStringSlice = dict["lf"] as? String { - let originalImageURL = Defaults.URL.host.appendingPathComponent(originalImageURLStringSlice) - return (imageURL, originalImageURL, skipServerIdentifier) - } else { - return (imageURL, nil, skipServerIdentifier) - } - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -// MARK: Tool -struct DataRequest: Request { - let url: URL - - var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: url) - .genericRetry() - .map(\.data) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -// MARK: Account Ops -struct LoginRequest: Request { - let username: String - let password: String - - var publisher: AnyPublisher { - let params: [String: String] = [ - "b": "d", - "bt": "1-1", - "CookieDate": "1", - "UserName": username, - "PassWord": password, - "ipb_login_submit": "Login!" - ] - - var request = URLRequest(url: Defaults.URL.login) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0.response as? HTTPURLResponse } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct IgneousRequest: Request { - var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.exhentai) - .genericRetry() - .compactMap { $0.response as? HTTPURLResponse } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct VerifyEhProfileResponse: Equatable { - let profileValue: Int? - let isProfileNotFound: Bool -} -struct VerifyEhProfileRequest: Request { - var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseProfileIndex) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct EhProfileRequest: Request { - var action: EhProfileAction? - var name: String? - var set: Int? - - var publisher: AnyPublisher { - var params = [String: String]() - - if let action = action { - params["profile_action"] = action.rawValue - } - if let name = name { - params["profile_name"] = name - } - if let set = set { - params["profile_set"] = "\(set)" - } - - var request = URLRequest(url: Defaults.URL.uConfig) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseEhSetting) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct EhSettingRequest: Request { - var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseEhSetting) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct SubmitEhSettingChangesRequest: Request { - let ehSetting: EhSetting - - var publisher: AnyPublisher { - let url = Defaults.URL.uConfig - var params: [String: String] = [ - "uh": String(ehSetting.loadThroughHathSetting.rawValue), - "co": ehSetting.browsingCountry.rawValue, - "xr": String(ehSetting.imageResolution.rawValue), - "rx": String(Int(ehSetting.imageSizeWidth)), - "ry": String(Int(ehSetting.imageSizeHeight)), - "tl": String(ehSetting.galleryName.rawValue), - "ar": String(ehSetting.archiverBehavior.rawValue), - "dm": String(ehSetting.displayMode.rawValue), - "pp": ehSetting.showSearchRangeIndicator ? "0" : "1", - "fs": String(ehSetting.favoritesSortOrder.rawValue), - "ru": ehSetting.ratingsColor, - "ft": String(Int(ehSetting.tagFilteringThreshold)), - "wt": String(Int(ehSetting.tagWatchingThreshold)), - "tf": ehSetting.showFilteredRemovalCount ? "0" : "1", - "xu": ehSetting.excludedUploaders, - "rc": String(ehSetting.searchResultCount.rawValue), - "lt": String(ehSetting.thumbnailLoadTiming.rawValue), - "tr": String(ehSetting.thumbnailConfigRows.rawValue), - "tp": String(Int(ehSetting.coverScaleFactor)), - "vp": String(Int(ehSetting.viewportVirtualWidth)), - "cs": String(ehSetting.commentsSortOrder.rawValue), - "sc": String(ehSetting.commentVotesShowTiming.rawValue), - "tb": String(ehSetting.tagsSortOrder.rawValue), - "pn": String(ehSetting.galleryPageNumbering.rawValue), - "apply": "Apply" - ] - - if ehSetting.enableGalleryThumbnailSelector { - params["xn_0"] = "on" - } - - switch ehSetting.thumbnailConfigSize { - case .auto: params["ts"] = "0" - case .normal: params["ts"] = "1" - case .small: params["ts"] = "2" - default: break - } - - EhSetting.categoryNames.enumerated().forEach { index, name in - params["ct_\(name)"] = ehSetting.disabledCategories[index] ? "1" : "0" - } - Array(0...9).forEach { index in - params["favorite_\(index)"] = ehSetting.favoriteCategories[index] - } - ehSetting.excludedLanguages.enumerated().forEach { index, value in - if value { - params["xl_\(EhSetting.languageValues[index])"] = "on" - } - } - - if let useOriginalImages = ehSetting.useOriginalImages { - params["oi"] = useOriginalImages ? "1" : "0" - } - if let useMultiplePageViewer = ehSetting.useMultiplePageViewer { - params["qb"] = useMultiplePageViewer ? "1" : "0" - } - if let multiplePageViewerStyle = ehSetting.multiplePageViewerStyle { - params["ms"] = String(multiplePageViewerStyle.rawValue) - } - if let multiplePageViewerShowThumbnailPane = ehSetting.multiplePageViewerShowThumbnailPane { - params["mt"] = multiplePageViewerShowThumbnailPane ? "0" : "1" - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseEhSetting) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct FavorGalleryRequest: Request { - let gid: String - let token: String - let favIndex: Int - - var publisher: AnyPublisher { - let url = URLUtil.addFavorite(gid: gid, token: token) - let params: [String: String] = [ - "favcat": "\(favIndex)", - "favnote": "", - "apply": "Add to Favorites", - "update": "1" - ] - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct UnfavorGalleryRequest: Request { - let gid: String - - var publisher: AnyPublisher { - let params: [String: String] = [ - "ddact": "delete", - "modifygids[]": gid, - "apply": "Apply" - ] - - var request = URLRequest(url: Defaults.URL.favorites) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct SendDownloadCommandRequest: Request { - let archiveURL: URL - let resolution: String - - var publisher: AnyPublisher { - let params: [String: String] = [ - "hathdl_xres": resolution - ] - - var request = URLRequest(url: archiveURL) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseDownloadCommandResponse) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct RateGalleryRequest: Request { - let apiuid: Int - let apikey: String - let gid: Int - let token: String - let rating: Int - - var publisher: AnyPublisher { - let params: [String: Any] = [ - "method": "rategallery", - "apiuid": apiuid, - "apikey": apikey, - "gid": gid, - "token": token, - "rating": rating - ] - - var request = URLRequest(url: Defaults.URL.api) - request.httpMethod = "POST" - request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct CommentGalleryRequest: Request { - let content: String - let galleryURL: URL - - var publisher: AnyPublisher { - let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") - let params: [String: String] = [ - "commenttext_new": fixedContent - ] - - var request = URLRequest(url: galleryURL) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct EditGalleryCommentRequest: Request { - let commentID: String - let content: String - let galleryURL: URL - - var publisher: AnyPublisher { - let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") - let params: [String: String] = [ - "edit_comment": commentID, - "commenttext_edit": fixedContent - ] - - var request = URLRequest(url: galleryURL) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct VoteGalleryCommentRequest: Request { - let apiuid: Int - let apikey: String - let gid: Int - let token: String - let commentID: Int - let commentVote: Int - - var publisher: AnyPublisher { - let params: [String: Any] = [ - "method": "votecomment", - "apiuid": apiuid, - "apikey": apikey, - "gid": gid, - "token": token, - "comment_id": commentID, - "comment_vote": commentVote - ] - - var request = URLRequest(url: Defaults.URL.api) - request.httpMethod = "POST" - request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct VoteGalleryTagRequest: Request { - let apiuid: Int - let apikey: String - let gid: Int - let token: String - let tag: String - let vote: Int - - var publisher: AnyPublisher { - let params: [String: Any] = [ - "method": "taggallery", - "apiuid": apiuid, - "apikey": apikey, - "gid": gid, - "token": token, - "tags": tag, - "vote": vote - ] - - var request = URLRequest(url: Defaults.URL.api) - request.httpMethod = "POST" - request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} diff --git a/EhPanda/View/Detail/Archives/ArchivesReducer.swift b/EhPanda/View/Detail/Archives/ArchivesReducer.swift index b98ed509d..e20955955 100644 --- a/EhPanda/View/Detail/Archives/ArchivesReducer.swift +++ b/EhPanda/View/Detail/Archives/ArchivesReducer.swift @@ -39,7 +39,7 @@ struct ArchivesReducer { case teardown case fetchArchive(String, URL, URL) - case fetchArchiveDone(String, URL, Result<(GalleryArchive, String?, String?), AppError>) + case fetchArchiveDone(String, URL, Result) case fetchArchiveFunds(String, URL) case fetchArchiveFundsDone(Result<(String, String), AppError>) case fetchDownloadResponse(URL) @@ -82,13 +82,13 @@ struct ArchivesReducer { case .fetchArchiveDone(let gid, let galleryURL, let result): state.loadingState = .idle switch result { - case .success(let (archive, galleryPoints, credits)): - guard !archive.hathArchives.isEmpty else { + case .success(let response): + guard !response.archive.hathArchives.isEmpty else { state.loadingState = .failed(.notFound) return .none } - state.hathArchives = archive.hathArchives - if let galleryPoints = galleryPoints, let credits = credits { + state.hathArchives = response.archive.hathArchives + if let galleryPoints = response.galleryPoints, let credits = response.credits { return .send(.syncGalleryFunds(galleryPoints, credits)) } else if cookieClient.isSameAccount { return .send(.fetchArchiveFunds(gid, galleryURL)) diff --git a/EhPanda/View/Detail/Archives/ArchivesView.swift b/EhPanda/View/Detail/Archives/ArchivesView.swift index 828e681bf..e53a95f2b 100644 --- a/EhPanda/View/Detail/Archives/ArchivesView.swift +++ b/EhPanda/View/Detail/Archives/ArchivesView.swift @@ -47,7 +47,7 @@ struct ArchivesView: View { LoadingView() .opacity( store.loadingState == .loading - && store.hathArchives.isEmpty ? 1 : 0 + && store.hathArchives.isEmpty ? 1 : 0 ) let error = store.loadingState.failed diff --git a/EhPanda/View/Detail/Comments/CommentsReducer.swift b/EhPanda/View/Detail/Comments/CommentsReducer.swift index f7f4ea27d..825c3cb4a 100644 --- a/EhPanda/View/Detail/Comments/CommentsReducer.swift +++ b/EhPanda/View/Detail/Comments/CommentsReducer.swift @@ -132,15 +132,17 @@ struct CommentsReducer { guard urlClient.checkIfHandleable(url) else { return .run(operation: { _ in await uiApplicationClient.openURL(url) }) } - let (isGalleryImageURL, _, _) = urlClient.analyzeURL(url) + let analysis = urlClient.analyzeURL(url) let gid = urlClient.parseGalleryID(url) guard databaseClient.fetchGallery(gid: gid) == nil else { return .send(.handleGalleryLink(url)) } - return .send(.fetchGallery(url, isGalleryImageURL)) + return .send(.fetchGallery(url, analysis.isGalleryImageURL)) case .handleGalleryLink(let url): - let (_, pageIndex, commentID) = urlClient.analyzeURL(url) + let analysis = urlClient.analyzeURL(url) + let pageIndex = analysis.pageIndex + let commentID = analysis.commentID let gid = urlClient.parseGalleryID(url) var effects = [Effect]() if let pageIndex = pageIndex { diff --git a/EhPanda/View/Detail/Comments/CommentsView.swift b/EhPanda/View/Detail/Comments/CommentsView.swift index 9a809df95..a6bc6bd2a 100644 --- a/EhPanda/View/Detail/Comments/CommentsView.swift +++ b/EhPanda/View/Detail/Comments/CommentsView.swift @@ -41,13 +41,13 @@ struct CommentsView: View { var body: some View { ScrollViewReader { proxy in List(comments) { comment in - CommentCell( + CommentsCommentCell( gid: gid, comment: comment, linkAction: { store.send(.handleCommentLink($0)) } ) .opacity( comment.commentID == store.scrollCommentID - ? store.scrollRowOpacity : 1 + ? store.scrollRowOpacity : 1 ) .swipeActions(edge: .leading) { if comment.votable { @@ -92,8 +92,8 @@ struct CommentsView: View { let hasCommentID = !route.wrappedValue.isEmpty PostCommentView( title: hasCommentID - ? L10n.Localizable.PostCommentView.Title.editComment - : L10n.Localizable.PostCommentView.Title.postComment, + ? L10n.Localizable.PostCommentView.Title.editComment + : L10n.Localizable.PostCommentView.Title.postComment, content: $store.commentContent, isFocused: $store.postCommentFocused, postAction: { @@ -149,8 +149,8 @@ private extension CommentsView { } } -// MARK: CommentCell -private struct CommentCell: View { +// MARK: CommentsCommentCell +private struct CommentsCommentCell: View { private let gid: String private var comment: GalleryComment private let linkAction: (URL) -> Void diff --git a/EhPanda/View/Detail/Components/TagDetailView.swift b/EhPanda/View/Detail/Components/TagDetailView.swift index 6b61d45db..d6b0cfd6b 100644 --- a/EhPanda/View/Detail/Components/TagDetailView.swift +++ b/EhPanda/View/Detail/Components/TagDetailView.swift @@ -17,7 +17,7 @@ struct TagDetailView: View { NavigationView { ScrollView(showsIndicators: false) { VStack { - DescriptionSection(description: detail.description) + TagDescriptionSection(description: detail.description) ImagesSection(imageURLs: detail.imageURLs).padding(.vertical) LinksSection(links: detail.links).padding(.vertical) } @@ -27,7 +27,7 @@ struct TagDetailView: View { } } -private struct DescriptionSection: View { +private struct TagDescriptionSection: View { private let description: String init(description: String) { diff --git a/EhPanda/View/Detail/DetailReducer+Actions.swift b/EhPanda/View/Detail/DetailReducer+Actions.swift new file mode 100644 index 000000000..ac0e4a311 --- /dev/null +++ b/EhPanda/View/Detail/DetailReducer+Actions.swift @@ -0,0 +1,213 @@ +// +// DetailReducer+Actions.swift +// EhPanda +// + +import Foundation +import ComposableArchitecture + +// MARK: - Navigation & UI Action Handlers +extension DetailReducer { + func handleNavigationActions( + state: inout State, + action: Action + ) -> Effect? { + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .send(.clearSubStates) : .none + + case .clearSubStates: + state.readingState = .init() + state.archivesState = .init() + state.torrentsState = .init() + state.previewsState = .init() + state.commentsState.wrappedValue = .init() + state.commentContent = .init() + state.postCommentFocused = false + state.galleryInfosState = .init() + state.detailSearchState.wrappedValue = .init() + return .merge( + .send(.reading(.teardown)), + .send(.archives(.teardown)), + .send(.torrents(.teardown)), + .send(.previews(.teardown)), + .send(.comments(.teardown)), + .send(.detailSearch(.teardown)) + ) + + case .onPostCommentAppear: + return .run { send in + try await Task.sleep(for: .milliseconds(750)) + await send(.setPostCommentFocused(true)) + } + + case .onAppear(let gid, let showsNewDawnGreeting): + return handleOnAppear(gid: gid, showsNewDawnGreeting: showsNewDawnGreeting, state: &state) + + default: + return nil + } + } + + private func handleOnAppear( + gid: String, + showsNewDawnGreeting: Bool, + state: inout State + ) -> Effect { + state.gid = gid + state.showsNewDawnGreeting = showsNewDawnGreeting + state.isPreparingDownload = false + state.hasLoadedDownloadBadge = false + state.didRunLaunchAutomation = false + state.localPreviewURLs = .init() + if state.detailSearchState.wrappedValue == nil { + state.detailSearchState.wrappedValue = .init() + } + if state.commentsState.wrappedValue == nil { + state.commentsState.wrappedValue = .init() + } + return .merge( + .send(.fetchDatabaseInfos(gid)), + .send(.fetchDownloadBadge), + .send(.observeDownload), + .send(.loadLocalPreviewURLs) + ) + } + + func handleUIActions( + state: inout State, + action: Action + ) -> Effect? { + switch action { + case .toggleShowFullTitle: + state.showsFullTitle.toggle() + return .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) + + case .toggleShowUserRating: + state.showsUserRating.toggle() + return .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) + + case .setCommentContent(let content): + state.commentContent = content + return .none + + case .setPostCommentFocused(let isFocused): + state.postCommentFocused = isFocused + return .none + + case .updateRating(let value): + state.updateRating(value: value) + return .none + + case .confirmRating(let value): + state.updateRating(value: value) + return .merge( + .send(.rateGallery), + .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), + .run { send in + try await Task.sleep(for: .seconds(1)) + await send(.confirmRatingDone) + } + ) + + case .confirmRatingDone: + state.showsUserRating = false + return .none + + default: + return nil + } + } + + func handleSyncActions( + state: inout State, + action: Action + ) -> Effect? { + switch action { + case .syncGalleryTags: + return .run { [state] _ in + await databaseClient.updateGalleryTags(gid: state.gallery.id, tags: state.galleryTags) + } + + case .syncGalleryDetail: + guard let detail = state.galleryDetail else { return .none } + return .run(operation: { _ in await databaseClient.cacheGalleryDetail(detail) }) + + case .syncGalleryPreviewURLs: + return .run { [state] _ in + await databaseClient + .updatePreviewURLs(gid: state.gallery.id, previewURLs: state.galleryPreviewURLs) + } + + case .syncGalleryComments: + return .run { [state] _ in + await databaseClient.updateComments(gid: state.gallery.id, comments: state.galleryComments) + } + + case .syncGreeting(let greeting): + return .run(operation: { _ in await databaseClient.updateGreeting(greeting) }) + + case .syncPreviewConfig(let config): + return .run { [state] _ in + await databaseClient.updatePreviewConfig(gid: state.gallery.id, config: config) + } + + case .saveGalleryHistory: + return .run { [state] _ in + await databaseClient.updateLastOpenDate(gid: state.gallery.id) + } + + case .updateReadingProgress(let progress): + return .run { [state] _ in + await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) + } + + default: + return nil + } + } + + func handleChildActions( + state: inout State, + action: Action, + self reducer: Reduce + ) -> Effect? { + switch action { + case .reading(.onPerformDismiss): + return .send(.setNavigation(nil)) + + case .reading, .archives, .torrents, .previews, .galleryInfos: + return .none + + case .comments(.performCommentActionDone(let result)): + return .send(.anyGalleryOpsDone(result)) + + case .comments(.detail(let recursiveAction)): + guard state.commentsState.wrappedValue != nil else { return .none } + let effect = reducer._reduce( + into: &state.commentsState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction + ) + return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) + + case .comments: + return .none + + case .detailSearch(.detail(let recursiveAction)): + guard state.detailSearchState.wrappedValue != nil else { return .none } + let effect = reducer._reduce( + into: &state.detailSearchState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction + ) + return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) + + case .detailSearch: + return .none + + default: + return nil + } + } +} diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift new file mode 100644 index 000000000..e9100df8b --- /dev/null +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -0,0 +1,302 @@ +// +// DetailReducer+Download.swift +// EhPanda +// + +import Foundation +import ComposableArchitecture + +// MARK: - Download Action Handlers +extension DetailReducer { + func handleDownloadActions( + state: inout State, + action: Action + ) -> Effect? { + handleDownloadBadgeActions(state: &state, action: action) + ?? handleDownloadLifecycleActions(state: &state, action: action) + } + + private func handleDownloadBadgeActions( + state: inout State, + action: Action + ) -> Effect? { + switch action { + case .fetchDownloadBadge: + return handleFetchDownloadBadge(state: &state) + case .fetchDownloadBadgeDone(let badge): + return handleFetchDownloadBadgeDone(badge: badge, state: &state) + case .observeDownload: + return handleObserveDownload(state: &state) + case .observeDownloadDone(let badge): + return handleObserveDownloadDone(badge: badge, state: &state) + case .loadLocalPreviewURLs: + return handleLoadLocalPreviewURLs(state: &state) + case .loadLocalPreviewURLsDone(let requestID, let urls): + return handleLoadLocalPreviewURLsDone(requestID: requestID, urls: urls, state: &state) + case .openReading: + return handleOpenReading(state: &state) + case .openReadingDone(let result): + return handleOpenReadingDone(result: result, state: &state) + default: + return nil + } + } + + private func handleDownloadLifecycleActions( + state: inout State, + action: Action + ) -> Effect? { + switch action { + case .runLaunchAutomationIfNeeded(let options): + return handleRunLaunchAutomation(options: options, state: &state) + case .startDownload(let options): + return handleStartDownload(options: options, state: &state) + case .startDownloadDone(let result): + return handleStartDownloadDone(result: result, state: &state) + case .toggleDownloadPause: + return handleToggleDownloadPause(state: &state) + case .toggleDownloadPauseDone(let result): + return handleToggleDownloadPauseDone(result: result, state: &state) + case .retryDownload(let mode): + return handleRetryDownload(mode: mode, state: &state) + case .retryDownloadDone(let result): + return handleRetryDownloadDone(result: result, state: &state) + case .deleteDownload: + return handleDeleteDownload(state: state) + case .deleteDownloadDone(let result): + return handleDeleteDownloadDone(result: result, state: &state) + default: + return nil + } + } + + private func handleFetchDownloadBadge(state: inout State) -> Effect { + guard state.gid.isValidGID else { return .none } + return .run { [galleryID = state.gid] send in + let badge = await downloadClient.badges([galleryID])[galleryID] ?? .none + await send(.fetchDownloadBadgeDone(badge)) + } + .cancellable(id: CancelID.fetchDownloadBadge, cancelInFlight: true) + } + + private func handleFetchDownloadBadgeDone(badge: DownloadBadge, state: inout State) -> Effect { + _ = applyDownloadBadge(badge, state: &state) + var effects: [Effect] = [.send(.loadLocalPreviewURLs)] + if shouldRequestVersionMetadata(state: state) { + effects.append(.send(.fetchVersionMetadataIfNeeded)) + } + return .merge(effects) + } + + private func handleObserveDownload(state: inout State) -> Effect { + guard state.gid.isValidGID else { return .none } + return .run { [galleryID = state.gid] send in + for await downloads in downloadClient.observeDownloads() { + let badge = downloads.first(where: { $0.gid == galleryID })?.badge ?? .none + await send(.observeDownloadDone(badge)) + } + } + .cancellable(id: CancelID.observeDownload, cancelInFlight: true) + } + + private func handleObserveDownloadDone(badge: DownloadBadge, state: inout State) -> Effect { + let didChangeBadge = applyDownloadBadge(badge, state: &state) + guard didChangeBadge else { return .none } + var effects: [Effect] = [.send(.loadLocalPreviewURLs)] + if shouldRequestVersionMetadata(state: state) { + effects.append(.send(.fetchVersionMetadataIfNeeded)) + } + return .merge(effects) + } + + private func handleLoadLocalPreviewURLs(state: inout State) -> Effect { + guard state.gid.isValidGID else { + state.localPreviewRequestID = UUID() + state.localPreviewURLs = .init() + return .none + } + let requestID = UUID() + state.localPreviewRequestID = requestID + return .run { [galleryID = state.gid] send in + let localPreviewURLs: [Int: URL] + switch await downloadClient.loadLocalPageURLs(galleryID) { + case .success(let pageURLs): + localPreviewURLs = pageURLs + case .failure: + localPreviewURLs = [:] + } + await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) + } + .cancellable(id: CancelID.loadLocalPreviewURLs, cancelInFlight: true) + } + + private func handleLoadLocalPreviewURLsDone( + requestID: UUID, + urls localPreviewURLs: [Int: URL], + state: inout State + ) -> Effect { + guard state.localPreviewRequestID == requestID else { return .none } + guard state.localPreviewURLs != localPreviewURLs else { return .none } + state.localPreviewURLs = localPreviewURLs + return .none + } + + private func handleOpenReading(state: inout State) -> Effect { + state.readingState = .init(contentSource: .remote) + return .run { [galleryID = state.gallery.id] send in + guard galleryID.isValidGID else { + await send(.openReadingDone(.failure(.notFound))) + return + } + await send(.openReadingDone(await downloadClient.loadManifest(galleryID))) + } + } + + private func handleOpenReadingDone( + result: Result<(DownloadedGallery, DownloadManifest), AppError>, + state: inout State + ) -> Effect { + if case .success(let (download, manifest)) = result { + state.readingState = .init(contentSource: .local(download, manifest)) + } else { + state.readingState.contentSource = .remote + state.readingState.localPageURLs = state.localPreviewURLs + } + state.route = .reading() + return .none + } + + private func handleRunLaunchAutomation( + options: DownloadOptionsSnapshot, + state: inout State + ) -> Effect { + guard !state.didRunLaunchAutomation, + AppLaunchAutomation.current?.autoDownloadGID == state.gallery.id, + state.galleryDetail != nil, + state.hasLoadedDownloadBadge + else { return .none } + state.didRunLaunchAutomation = true + guard state.downloadBadge == .none else { return .none } + return .send(.startDownload(options)) + } + + private func handleStartDownload( + options: DownloadOptionsSnapshot, + state: inout State + ) -> Effect { + guard !state.isPreparingDownload else { return .none } + state.didRunLaunchAutomation = true + guard let detail = state.galleryDetail else { return .none } + state.isPreparingDownload = true + let payload = DownloadRequestPayload( + gallery: state.gallery, + galleryDetail: detail, + previewURLs: state.galleryPreviewURLs, + previewConfig: state.previewConfig, + host: AppUtil.galleryHost, + versionMetadata: state.galleryVersionMetadata, + options: options, + mode: .initial + ) + return .run { send in + await send(.startDownloadDone(await downloadClient.enqueue(payload))) + } + } + + private func handleStartDownloadDone( + result: Result, + state: inout State + ) -> Effect { + state.isPreparingDownload = false + if case .success = result { + state.downloadBadge = .queued + state.hasLoadedDownloadBadge = true + return .merge( + .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + } + + private func handleToggleDownloadPause(state: inout State) -> Effect { + guard !state.isPreparingDownload else { return .none } + state.isPreparingDownload = true + return .run { [galleryID = state.gallery.id] send in + await send(.toggleDownloadPauseDone(await downloadClient.togglePause(galleryID))) + } + } + + private func handleToggleDownloadPauseDone( + result: Result, + state: inout State + ) -> Effect { + state.isPreparingDownload = false + if case .success = result { + switch state.downloadBadge { + case .downloading(let completed, let total): + state.downloadBadge = .paused(completed, total) + case .paused: + state.downloadBadge = .queued + default: + break + } + state.hasLoadedDownloadBadge = state.downloadBadge != .none + return .merge( + .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + } + + private func handleRetryDownload( + mode: DownloadStartMode, + state: inout State + ) -> Effect { + guard !state.isPreparingDownload else { return .none } + state.isPreparingDownload = true + return .run { [galleryID = state.gallery.id] send in + await send(.retryDownloadDone(await downloadClient.retry(galleryID, mode))) + } + } + + private func handleRetryDownloadDone( + result: Result, + state: inout State + ) -> Effect { + state.isPreparingDownload = false + if case .success = result { + state.downloadBadge = .queued + state.hasLoadedDownloadBadge = true + return .merge( + .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + } + + private func handleDeleteDownload(state: State) -> Effect { + .run { [galleryID = state.gallery.id] send in + await send(.deleteDownloadDone(await downloadClient.delete(galleryID))) + } + } + + private func handleDeleteDownloadDone( + result: Result, + state: inout State + ) -> Effect { + if case .success = result { + state.galleryVersionMetadata = nil + state.didRequestVersionMetadata = false + state.isDownloadContext = false + state.shouldCheckForRemoteUpdates = false + return .merge( + .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + } +} diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/EhPanda/View/Detail/DetailReducer+Fetch.swift new file mode 100644 index 000000000..e818f188e --- /dev/null +++ b/EhPanda/View/Detail/DetailReducer+Fetch.swift @@ -0,0 +1,258 @@ +// +// DetailReducer+Fetch.swift +// EhPanda +// + +import Foundation +import ComposableArchitecture + +// MARK: - Fetch & Gallery Ops Action Handlers +extension DetailReducer { + func handleFetchActions( + state: inout State, + action: Action, + self reducer: Reduce + ) -> Effect? { + switch action { + case .teardown: + return .merge(CancelID.allCases.map(Effect.cancel(id:))) + + case .fetchDatabaseInfos(let gid): + return handleFetchDatabaseInfos(gid: gid, state: &state) + + case .fetchDatabaseInfosDone(let galleryState): + return handleFetchDatabaseInfosDone(galleryState: galleryState, state: &state) + + case .fetchGalleryDetail: + return handleFetchGalleryDetail(state: &state) + + case .fetchGalleryDetailDone(let result): + return handleFetchGalleryDetailDone(result: result, state: &state) + + case .fetchVersionMetadataIfNeeded: + return handleFetchVersionMetadataIfNeeded(state: &state) + + case .fetchVersionMetadataDone(let result): + if case .success(let metadata) = result { + state.galleryVersionMetadata = metadata + } + return .none + + default: + return nil + } + } + + private func handleFetchDatabaseInfos(gid: String, state: inout State) -> Effect { + if let gallery = databaseClient.fetchGallery(gid: gid) { + state.gallery = gallery + } else if state.gallery.id != gid { + return .none + } + if let detail = databaseClient.fetchGalleryDetail(gid: gid) { + state.galleryDetail = detail + } + return .merge( + .send(.fetchDownloadBadge), + .send(.saveGalleryHistory), + .run { [galleryID = state.gallery.id] send in + guard let dbState = await databaseClient.fetchGalleryState(gid: galleryID) else { return } + await send(.fetchDatabaseInfosDone(dbState)) + } + .cancellable(id: CancelID.fetchDatabaseInfos) + ) + } + + private func handleFetchDatabaseInfosDone(galleryState: GalleryState, state: inout State) -> Effect { + state.galleryTags = galleryState.tags + state.galleryPreviewURLs = galleryState.previewURLs + state.galleryComments = galleryState.comments + if let previewConfig = galleryState.previewConfig { + state.previewConfig = previewConfig + } + return .send(.fetchGalleryDetail) + } + + private func handleFetchGalleryDetail(state: inout State) -> Effect { + guard state.loadingState != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.loadingState = .loading + state.didRequestVersionMetadata = false + state.galleryVersionMetadata = nil + return .run { [galleryID = state.gallery.id] send in + let response = await GalleryDetailRequest(gid: galleryID, galleryURL: galleryURL).response() + await send(.fetchGalleryDetailDone(response)) + } + .cancellable(id: CancelID.fetchGalleryDetail) + } + + private func handleFetchGalleryDetailDone( + result: Result, + state: inout State + ) -> Effect { + state.loadingState = .idle + switch result { + case .success(let response): + return applyGalleryDetailResponse(response, state: &state) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + } + + private func applyGalleryDetailResponse( + _ response: GalleryDetailResponse, + state: inout State + ) -> Effect { + var effects: [Effect] = [ + .send(.syncGalleryTags), + .send(.syncGalleryDetail), + .send(.syncGalleryPreviewURLs), + .send(.syncGalleryComments), + .send(.fetchDownloadBadge) + ] + state.apiKey = response.apiKey + state.galleryDetail = response.galleryDetail + state.galleryTags = response.galleryState.tags + state.galleryPreviewURLs = response.galleryState.previewURLs + state.galleryComments = response.galleryState.comments + if let config = response.galleryState.previewConfig { + state.previewConfig = config + } + state.userRating = Int(response.galleryDetail.userRating) * 2 + if shouldRequestVersionMetadata(state: state) { + effects.append(.send(.fetchVersionMetadataIfNeeded)) + } + if let greeting = response.greeting { + effects.append(.send(.syncGreeting(greeting))) + if !greeting.gainedNothing && state.showsNewDawnGreeting { + effects.append(.send(.setNavigation(.newDawn(greeting)))) + } + } + if let config = response.galleryState.previewConfig { + effects.append(.send(.syncPreviewConfig(config))) + } + return .merge(effects) + } + + private func handleFetchVersionMetadataIfNeeded(state: inout State) -> Effect { + guard state.shouldCheckForRemoteUpdates, + !state.didRequestVersionMetadata, + let detail = state.galleryDetail + else { + return .none + } + state.didRequestVersionMetadata = true + return .run { [gallery = state.gallery, previewURLs = state.galleryPreviewURLs, detail] send in + let metadata: DownloadVersionMetadata? + switch await GalleryVersionMetadataRequest(gid: gallery.gid, token: gallery.token).response() { + case .success(let fetchedMetadata): + metadata = fetchedMetadata + case .failure: + metadata = nil + } + await send(.fetchVersionMetadataDone(.success(metadata))) + guard let metadata else { return } + let latestSignature = DownloadSignatureBuilder.make( + gallery: gallery, + detail: detail, + host: AppUtil.galleryHost, + previewURLs: previewURLs, + versionMetadata: metadata + ) + let badge = await downloadClient.updateRemoteSignature( + gallery.gid, + latestSignature + ) + await send(.fetchDownloadBadgeDone(badge)) + } + .cancellable(id: CancelID.fetchVersionMetadata, cancelInFlight: true) + } + + func handleGalleryOpsActions( + state: inout State, + action: Action + ) -> Effect? { + switch action { + case .rateGallery: + return handleRateGallery(state: state) + case .favorGallery(let favIndex): + return handleFavorGallery(favIndex: favIndex, state: state) + case .unfavorGallery: + return handleUnfavorGallery(state: state) + case .postComment(let galleryURL): + return handlePostComment(galleryURL: galleryURL, state: state) + case .voteTag(let tag, let vote): + return handleVoteTag(tag: tag, vote: vote, state: state) + case .anyGalleryOpsDone(let result): + return handleAnyGalleryOpsDone(result: result) + default: + return nil + } + } + + private func handleRateGallery(state: State) -> Effect { + guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) + else { return .none } + return .run { [state] send in + let response = await RateGalleryRequest( + apiuid: apiuid, apikey: state.apiKey, + gid: gid, token: state.gallery.token, rating: state.userRating + ).response() + await send(.anyGalleryOpsDone(response)) + }.cancellable(id: CancelID.rateGallery) + } + + private func handleFavorGallery(favIndex: Int, state: State) -> Effect { + .run { [state] send in + let response = await FavorGalleryRequest( + gid: state.gallery.id, token: state.gallery.token, favIndex: favIndex + ).response() + await send(.anyGalleryOpsDone(response)) + } + .cancellable(id: CancelID.favorGallery) + } + + private func handleUnfavorGallery(state: State) -> Effect { + .run { [galleryID = state.gallery.id] send in + let response = await UnfavorGalleryRequest(gid: galleryID).response() + await send(.anyGalleryOpsDone(response)) + } + .cancellable(id: CancelID.unfavorGallery) + } + + private func handlePostComment(galleryURL: URL, state: State) -> Effect { + guard !state.commentContent.isEmpty else { return .none } + return .run { [commentContent = state.commentContent] send in + let response = await CommentGalleryRequest( + content: commentContent, galleryURL: galleryURL + ).response() + await send(.anyGalleryOpsDone(response)) + } + .cancellable(id: CancelID.postComment) + } + + private func handleVoteTag(tag: String, vote: Int, state: State) -> Effect { + guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) + else { return .none } + return .run { [state] send in + let response = await VoteGalleryTagRequest( + apiuid: apiuid, apikey: state.apiKey, + gid: gid, token: state.gallery.token, tag: tag, vote: vote + ).response() + await send(.anyGalleryOpsDone(response)) + } + .cancellable(id: CancelID.voteTag) + } + + private func handleAnyGalleryOpsDone(result: Result) -> Effect { + if case .success = result { + return .merge( + .send(.fetchGalleryDetail), + .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }) + ) + } + return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + } +} diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index faccbc090..935869b48 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -24,7 +24,7 @@ struct DetailReducer { case galleryInfos(Gallery, GalleryDetail) } - private enum CancelID: CaseIterable { + enum CancelID: CaseIterable { case fetchDatabaseInfos case fetchGalleryDetail case fetchVersionMetadata @@ -43,12 +43,10 @@ struct DetailReducer { var route: Route? var commentContent = "" var postCommentFocused = false - var showsNewDawnGreeting = false var showsUserRating = false var showsFullTitle = false var userRating = 0 - var apiKey = "" var gid = "" var loadingState: LoadingState = .idle @@ -68,7 +66,6 @@ struct DetailReducer { var shouldCheckForRemoteUpdates = false var didRequestVersionMetadata = false var localPreviewRequestID = UUID() - var readingState = ReadingReducer.State() var archivesState = ArchivesReducer.State() var torrentsState = TorrentsReducer.State() @@ -87,24 +84,13 @@ struct DetailReducer { gid = download.gid gallery = download.gallery galleryDetail = GalleryDetail( - gid: download.gid, - title: download.title, - jpnTitle: download.jpnTitle, - isFavorited: false, - visibility: .yes, - rating: download.rating, - userRating: 0, - ratingCount: 0, - category: download.category, - language: .other, - uploader: download.uploader ?? "", - postedDate: download.postedDate, - coverURL: download.coverURL, - favoritedCount: 0, - pageCount: download.pageCount, - sizeCount: 0, - sizeType: "", - torrentCount: 0 + gid: download.gid, title: download.title, jpnTitle: download.jpnTitle, + isFavorited: false, visibility: .yes, rating: download.rating, + userRating: 0, ratingCount: 0, category: download.category, + language: .other, uploader: download.uploader ?? "", + postedDate: download.postedDate, coverURL: download.coverURL, + favoritedCount: 0, pageCount: download.pageCount, + sizeCount: 0, sizeType: "", torrentCount: 0 ) downloadBadge = download.badge hasLoadedDownloadBadge = download.badge != .none @@ -125,7 +111,6 @@ struct DetailReducer { case clearSubStates case onPostCommentAppear case onAppear(String, Bool) - case toggleShowFullTitle case toggleShowUserRating case setCommentContent(String) @@ -133,7 +118,6 @@ struct DetailReducer { case updateRating(DragGesture.Value) case confirmRating(DragGesture.Value) case confirmRatingDone - case syncGalleryTags case syncGalleryDetail case syncGalleryPreviewURLs @@ -159,24 +143,19 @@ struct DetailReducer { case retryDownloadDone(Result) case deleteDownload case deleteDownloadDone(Result) - case teardown case fetchDatabaseInfos(String) case fetchDatabaseInfosDone(GalleryState) case fetchGalleryDetail - case fetchGalleryDetailDone( - Result<(GalleryDetail, GalleryState, String, Greeting?), AppError> - ) + case fetchGalleryDetailDone(Result) case fetchVersionMetadataIfNeeded case fetchVersionMetadataDone(Result) - case rateGallery case favorGallery(Int) case unfavorGallery case postComment(URL) case voteTag(String, Int) case anyGalleryOpsDone(Result) - case reading(ReadingReducer.Action) case archives(ArchivesReducer.Action) case torrents(TorrentsReducer.Action) @@ -186,630 +165,38 @@ struct DetailReducer { case detailSearch(DetailSearchReducer.Action) } - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.downloadClient) private var downloadClient - @Dependency(\.hapticsClient) private var hapticsClient - @Dependency(\.cookieClient) private var cookieClient + @Dependency(\.databaseClient) var databaseClient + @Dependency(\.downloadClient) var downloadClient + @Dependency(\.hapticsClient) var hapticsClient + @Dependency(\.cookieClient) var cookieClient + + var body: some Reducer { detailBody } +} +// MARK: - Reducer Body +extension DetailReducer { func coreReducer(self: Reduce) -> some Reducer { Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.readingState = .init() - state.archivesState = .init() - state.torrentsState = .init() - state.previewsState = .init() - state.commentsState.wrappedValue = .init() - state.commentContent = .init() - state.postCommentFocused = false - state.galleryInfosState = .init() - state.detailSearchState.wrappedValue = .init() - return .merge( - .send(.reading(.teardown)), - .send(.archives(.teardown)), - .send(.torrents(.teardown)), - .send(.previews(.teardown)), - .send(.comments(.teardown)), - .send(.detailSearch(.teardown)) - ) - - case .onPostCommentAppear: - return .run { send in - try await Task.sleep(for: .milliseconds(750)) - await send(.setPostCommentFocused(true)) - } - - case .onAppear(let gid, let showsNewDawnGreeting): - state.gid = gid - state.showsNewDawnGreeting = showsNewDawnGreeting - state.isPreparingDownload = false - state.hasLoadedDownloadBadge = false - state.didRunLaunchAutomation = false - state.localPreviewURLs = .init() - if state.detailSearchState.wrappedValue == nil { - state.detailSearchState.wrappedValue = .init() - } - if state.commentsState.wrappedValue == nil { - state.commentsState.wrappedValue = .init() - } - return .merge( - .send(.fetchDatabaseInfos(gid)), - .send(.fetchDownloadBadge), - .send(.observeDownload), - .send(.loadLocalPreviewURLs) - ) - - case .toggleShowFullTitle: - state.showsFullTitle.toggle() - return .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) - - case .toggleShowUserRating: - state.showsUserRating.toggle() - return .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) - - case .setCommentContent(let content): - state.commentContent = content - return .none - - case .setPostCommentFocused(let isFocused): - state.postCommentFocused = isFocused - return .none - - case .updateRating(let value): - state.updateRating(value: value) - return .none - - case .confirmRating(let value): - state.updateRating(value: value) - return .merge( - .send(.rateGallery), - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), - .run { send in - try await Task.sleep(for: .seconds(1)) - await send(.confirmRatingDone) - } - ) - - case .confirmRatingDone: - state.showsUserRating = false - return .none - - case .syncGalleryTags: - return .run { [state] _ in - await databaseClient.updateGalleryTags(gid: state.gallery.id, tags: state.galleryTags) - } - - case .syncGalleryDetail: - guard let detail = state.galleryDetail else { return .none } - return .run(operation: { _ in await databaseClient.cacheGalleryDetail(detail) }) - - case .syncGalleryPreviewURLs: - return .run { [state] _ in - await databaseClient - .updatePreviewURLs(gid: state.gallery.id, previewURLs: state.galleryPreviewURLs) - } - - case .syncGalleryComments: - return .run { [state] _ in - await databaseClient.updateComments(gid: state.gallery.id, comments: state.galleryComments) - } - - case .syncGreeting(let greeting): - return .run(operation: { _ in await databaseClient.updateGreeting(greeting) }) - - case .syncPreviewConfig(let config): - return .run { [state] _ in - await databaseClient.updatePreviewConfig(gid: state.gallery.id, config: config) - } - - case .saveGalleryHistory: - return .run { [state] _ in - await databaseClient.updateLastOpenDate(gid: state.gallery.id) - } - - case .updateReadingProgress(let progress): - return .run { [state] _ in - await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) - } - - case .fetchDownloadBadge: - guard state.gid.isValidGID else { return .none } - return .run { [galleryID = state.gid] send in - let badge = await downloadClient.badges([galleryID])[galleryID] ?? .none - await send(.fetchDownloadBadgeDone(badge)) - } - .cancellable(id: CancelID.fetchDownloadBadge, cancelInFlight: true) - - case .fetchDownloadBadgeDone(let badge): - _ = applyDownloadBadge(badge, state: &state) - - var effects: [Effect] = [ - .send(.loadLocalPreviewURLs) - ] - if shouldRequestVersionMetadata(state: state) { - effects.append(.send(.fetchVersionMetadataIfNeeded)) - } - return .merge(effects) - - case .observeDownload: - guard state.gid.isValidGID else { return .none } - return .run { [galleryID = state.gid] send in - for await downloads in downloadClient.observeDownloads() { - let badge = downloads.first(where: { $0.gid == galleryID })?.badge ?? .none - await send(.observeDownloadDone(badge)) - } - } - .cancellable(id: CancelID.observeDownload, cancelInFlight: true) - - case .observeDownloadDone(let badge): - let didChangeBadge = applyDownloadBadge(badge, state: &state) - guard didChangeBadge else { return .none } - - var effects: [Effect] = [ - .send(.loadLocalPreviewURLs) - ] - if shouldRequestVersionMetadata(state: state) { - effects.append(.send(.fetchVersionMetadataIfNeeded)) - } - return .merge(effects) - - case .loadLocalPreviewURLs: - guard state.gid.isValidGID else { - state.localPreviewRequestID = UUID() - state.localPreviewURLs = .init() - return .none - } - let requestID = UUID() - state.localPreviewRequestID = requestID - return .run { [galleryID = state.gid] send in - let localPreviewURLs: [Int: URL] - switch await downloadClient.loadLocalPageURLs(galleryID) { - case .success(let pageURLs): - localPreviewURLs = pageURLs - case .failure: - localPreviewURLs = [:] - } - await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) - } - .cancellable(id: CancelID.loadLocalPreviewURLs, cancelInFlight: true) - - case .loadLocalPreviewURLsDone(let requestID, let localPreviewURLs): - guard state.localPreviewRequestID == requestID else { return .none } - guard state.localPreviewURLs != localPreviewURLs else { return .none } - state.localPreviewURLs = localPreviewURLs - return .none - - case .openReading: - state.readingState = .init(contentSource: .remote) - return .run { [galleryID = state.gallery.id] send in - guard galleryID.isValidGID else { - await send(.openReadingDone(.failure(.notFound))) - return - } - await send(.openReadingDone(await downloadClient.loadManifest(galleryID))) - } - - case .openReadingDone(let result): - if case .success(let (download, manifest)) = result { - state.readingState = .init(contentSource: .local(download, manifest)) - } else { - state.readingState.contentSource = .remote - state.readingState.localPageURLs = state.localPreviewURLs - } - state.route = .reading() - return .none - - case .runLaunchAutomationIfNeeded(let options): - guard !state.didRunLaunchAutomation, - AppLaunchAutomation.current?.autoDownloadGID == state.gallery.id, - state.galleryDetail != nil, - state.hasLoadedDownloadBadge - else { return .none } - - state.didRunLaunchAutomation = true - guard state.downloadBadge == .none else { return .none } - return .send(.startDownload(options)) - - case .startDownload(let options): - guard !state.isPreparingDownload else { return .none } - state.didRunLaunchAutomation = true - guard let detail = state.galleryDetail else { return .none } - state.isPreparingDownload = true - let payload = DownloadRequestPayload( - gallery: state.gallery, - galleryDetail: detail, - previewURLs: state.galleryPreviewURLs, - previewConfig: state.previewConfig, - host: AppUtil.galleryHost, - versionMetadata: state.galleryVersionMetadata, - options: options, - mode: .initial - ) - return .run { send in - await send(.startDownloadDone(await downloadClient.enqueue(payload))) - } - - case .startDownloadDone(let result): - state.isPreparingDownload = false - if case .success = result { - state.downloadBadge = .queued - state.hasLoadedDownloadBadge = true - return .merge( - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), - .send(.fetchDownloadBadge) - ) - } - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) - - case .toggleDownloadPause: - guard !state.isPreparingDownload else { return .none } - state.isPreparingDownload = true - return .run { [galleryID = state.gallery.id] send in - await send(.toggleDownloadPauseDone(await downloadClient.togglePause(galleryID))) - } - - case .toggleDownloadPauseDone(let result): - state.isPreparingDownload = false - if case .success = result { - switch state.downloadBadge { - case .downloading(let completed, let total): - state.downloadBadge = .paused(completed, total) - case .paused: - state.downloadBadge = .queued - default: - break - } - state.hasLoadedDownloadBadge = state.downloadBadge != .none - return .merge( - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), - .send(.fetchDownloadBadge) - ) - } - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) - - case .retryDownload(let mode): - guard !state.isPreparingDownload else { return .none } - state.isPreparingDownload = true - return .run { [galleryID = state.gallery.id] send in - await send(.retryDownloadDone(await downloadClient.retry(galleryID, mode))) - } - - case .retryDownloadDone(let result): - state.isPreparingDownload = false - if case .success = result { - state.downloadBadge = .queued - state.hasLoadedDownloadBadge = true - return .merge( - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), - .send(.fetchDownloadBadge) - ) - } - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) - - case .deleteDownload: - return .run { [galleryID = state.gallery.id] send in - await send(.deleteDownloadDone(await downloadClient.delete(galleryID))) - } - - case .deleteDownloadDone(let result): - if case .success = result { - state.galleryVersionMetadata = nil - state.didRequestVersionMetadata = false - state.isDownloadContext = false - state.shouldCheckForRemoteUpdates = false - return .merge( - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), - .send(.fetchDownloadBadge) - ) - } - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) - - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - - case .fetchDatabaseInfos(let gid): - if let gallery = databaseClient.fetchGallery(gid: gid) { - state.gallery = gallery - } else if state.gallery.id != gid { - return .none - } - if let detail = databaseClient.fetchGalleryDetail(gid: gid) { - state.galleryDetail = detail - } - return .merge( - .send(.fetchDownloadBadge), - .send(.saveGalleryHistory), - .run { [galleryID = state.gallery.id] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: galleryID) else { return } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: CancelID.fetchDatabaseInfos) - ) - - case .fetchDatabaseInfosDone(let galleryState): - state.galleryTags = galleryState.tags - state.galleryPreviewURLs = galleryState.previewURLs - state.galleryComments = galleryState.comments - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - return .send(.fetchGalleryDetail) - - case .fetchGalleryDetail: - guard state.loadingState != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.loadingState = .loading - state.didRequestVersionMetadata = false - state.galleryVersionMetadata = nil - return .run { [galleryID = state.gallery.id] send in - let response = await GalleryDetailRequest(gid: galleryID, galleryURL: galleryURL).response() - await send(.fetchGalleryDetailDone(response)) - } - .cancellable(id: CancelID.fetchGalleryDetail) - - case .fetchGalleryDetailDone(let result): - state.loadingState = .idle - switch result { - case .success(let (galleryDetail, galleryState, apiKey, greeting)): - var effects: [Effect] = [ - .send(.syncGalleryTags), - .send(.syncGalleryDetail), - .send(.syncGalleryPreviewURLs), - .send(.syncGalleryComments), - .send(.fetchDownloadBadge) - ] - state.apiKey = apiKey - state.galleryDetail = galleryDetail - state.galleryTags = galleryState.tags - state.galleryPreviewURLs = galleryState.previewURLs - state.galleryComments = galleryState.comments - if let config = galleryState.previewConfig { - state.previewConfig = config - } - state.userRating = Int(galleryDetail.userRating) * 2 - if shouldRequestVersionMetadata(state: state) { - effects.append(.send(.fetchVersionMetadataIfNeeded)) - } - if let greeting = greeting { - effects.append(.send(.syncGreeting(greeting))) - if !greeting.gainedNothing && state.showsNewDawnGreeting { - effects.append(.send(.setNavigation(.newDawn(greeting)))) - } - } - if let config = galleryState.previewConfig { - effects.append(.send(.syncPreviewConfig(config))) - } - return .merge(effects) - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .fetchVersionMetadataIfNeeded: - guard state.shouldCheckForRemoteUpdates, - !state.didRequestVersionMetadata, - let detail = state.galleryDetail - else { - return .none - } - state.didRequestVersionMetadata = true - return .run { [gallery = state.gallery, previewURLs = state.galleryPreviewURLs, detail] send in - let metadata: DownloadVersionMetadata? - switch await GalleryVersionMetadataRequest(gid: gallery.gid, token: gallery.token).response() { - case .success(let fetchedMetadata): - metadata = fetchedMetadata - case .failure: - metadata = nil - } - - await send(.fetchVersionMetadataDone(.success(metadata))) - - guard let metadata else { return } - let latestSignature = DownloadSignatureBuilder.make( - gallery: gallery, - detail: detail, - host: AppUtil.galleryHost, - previewURLs: previewURLs, - versionMetadata: metadata - ) - let badge = await downloadClient.updateRemoteSignature( - gallery.gid, - latestSignature - ) - await send(.fetchDownloadBadgeDone(badge)) - } - .cancellable(id: CancelID.fetchVersionMetadata, cancelInFlight: true) - - case .fetchVersionMetadataDone(let result): - if case .success(let metadata) = result { - state.galleryVersionMetadata = metadata - } - return .none - - case .rateGallery: - guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) - else { return .none } - return .run { [state] send in - let response = await RateGalleryRequest( - apiuid: apiuid, - apikey: state.apiKey, - gid: gid, - token: state.gallery.token, - rating: state.userRating - ) - .response() - await send(.anyGalleryOpsDone(response)) - }.cancellable(id: CancelID.rateGallery) - - case .favorGallery(let favIndex): - return .run { [state] send in - let response = await FavorGalleryRequest( - gid: state.gallery.id, - token: state.gallery.token, - favIndex: favIndex - ) - .response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.favorGallery) - - case .unfavorGallery: - return .run { [galleryID = state.gallery.id] send in - let response = await UnfavorGalleryRequest(gid: galleryID).response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.unfavorGallery) - - case .postComment(let galleryURL): - guard !state.commentContent.isEmpty else { return .none } - return .run { [commentContent = state.commentContent] send in - let response = await CommentGalleryRequest( - content: commentContent, galleryURL: galleryURL - ) - .response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.postComment) - - case .voteTag(let tag, let vote): - guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) - else { return .none } - return .run { [state] send in - let response = await VoteGalleryTagRequest( - apiuid: apiuid, - apikey: state.apiKey, - gid: gid, - token: state.gallery.token, - tag: tag, - vote: vote - ) - .response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.voteTag) - - case .anyGalleryOpsDone(let result): - if case .success = result { - return .merge( - .send(.fetchGalleryDetail), - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }) - ) - } - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) - - case .reading(.onPerformDismiss): - return .send(.setNavigation(nil)) - - case .reading: - return .none - - case .archives: - return .none - - case .torrents: - return .none - - case .previews: - return .none - - case .comments(.performCommentActionDone(let result)): - return .send(.anyGalleryOpsDone(result)) - - case .comments(.detail(let recursiveAction)): - guard state.commentsState.wrappedValue != nil else { return .none } - let effect = self._reduce( - into: &state.commentsState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction - ) - return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) - - case .comments: - return .none - - case .galleryInfos: - return .none - - case .detailSearch(.detail(let recursiveAction)): - guard state.detailSearchState.wrappedValue != nil else { return .none } - let effect = self._reduce( - into: &state.detailSearchState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction - ) - return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) - - case .detailSearch: - return .none - } + if let effect = handleNavigationActions(state: &state, action: action) { return effect } + if let effect = handleUIActions(state: &state, action: action) { return effect } + if let effect = handleSyncActions(state: &state, action: action) { return effect } + if let effect = handleDownloadActions(state: &state, action: action) { return effect } + if let effect = handleFetchActions(state: &state, action: action, self: self) { return effect } + if let effect = handleGalleryOpsActions(state: &state, action: action) { return effect } + if let effect = handleChildActions(state: &state, action: action, self: self) { return effect } + return .none } - .ifLet( - \.commentsState.wrappedValue, - action: \.comments, - then: CommentsReducer.init - ) - .ifLet( - \.detailSearchState.wrappedValue, - action: \.detailSearch, - then: DetailSearchReducer.init - ) + .ifLet(\.commentsState.wrappedValue, action: \.comments, then: CommentsReducer.init) + .ifLet(\.detailSearchState.wrappedValue, action: \.detailSearch, then: DetailSearchReducer.init) } - func hapticsReducer( - @ReducerBuilder reducer: () -> some Reducer - ) -> some Reducer { - reducer() - .haptics( - unwrapping: \.route, - case: \.detailSearch, - hapticsClient: hapticsClient, - style: .soft - ) - .haptics( - unwrapping: \.route, - case: \.postComment, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.tagDetail, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.torrents, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.archives, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.reading, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.share, - hapticsClient: hapticsClient - ) - } - - var body: some Reducer { + var detailBody: some Reducer { RecurseReducer { (self) in BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none } - coreReducer(self: self) - Scope(state: \.readingState, action: \.reading, child: ReadingReducer.init) Scope(state: \.archivesState, action: \.archives, child: ArchivesReducer.init) Scope(state: \.torrentsState, action: \.torrents, child: TorrentsReducer.init) @@ -817,29 +204,40 @@ struct DetailReducer { Scope(state: \.galleryInfosState, action: \.galleryInfos, child: GalleryInfosReducer.init) } } +} - private func applyDownloadBadge( - _ badge: DownloadBadge, - state: inout State - ) -> Bool { - let didChangeBadge = badge != state.downloadBadge || !state.hasLoadedDownloadBadge +// MARK: - Haptics +extension DetailReducer { + func hapticsReducer( + @ReducerBuilder reducer: () -> some Reducer + ) -> some Reducer { + reducer() + .haptics(unwrapping: \.route, case: \.detailSearch, hapticsClient: hapticsClient, style: .soft) + .haptics(unwrapping: \.route, case: \.postComment, hapticsClient: hapticsClient) + .haptics(unwrapping: \.route, case: \.tagDetail, hapticsClient: hapticsClient) + .haptics(unwrapping: \.route, case: \.torrents, hapticsClient: hapticsClient) + .haptics(unwrapping: \.route, case: \.archives, hapticsClient: hapticsClient) + .haptics(unwrapping: \.route, case: \.reading, hapticsClient: hapticsClient) + .haptics(unwrapping: \.route, case: \.share, hapticsClient: hapticsClient) + } +} +// MARK: - Helpers +extension DetailReducer { + func applyDownloadBadge(_ badge: DownloadBadge, state: inout State) -> Bool { + let didChangeBadge = badge != state.downloadBadge || !state.hasLoadedDownloadBadge state.downloadBadge = badge - if badge != .none { - state.isPreparingDownload = false - } + if badge != .none { state.isPreparingDownload = false } state.hasLoadedDownloadBadge = true state.shouldCheckForRemoteUpdates = state.isDownloadContext || badge != .none - if badge == .none && !state.isDownloadContext { state.galleryVersionMetadata = nil state.didRequestVersionMetadata = false } - return didChangeBadge } - private func shouldRequestVersionMetadata(state: State) -> Bool { + func shouldRequestVersionMetadata(state: State) -> Bool { state.galleryDetail != nil && state.shouldCheckForRemoteUpdates && !state.didRequestVersionMetadata diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift index da4cccf59..29d7f8420 100644 --- a/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift +++ b/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift @@ -28,53 +28,53 @@ struct DetailSearchView: View { var body: some View { let content = - GenericList( - galleries: store.galleries, - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState, - footerLoadingState: store.footerLoadingState, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickDetailSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.fetchGalleries(keyword)) - } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) - .accentColor(setting.accentColor).autoBlur(radius: blurRadius) - } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + GenericList( + galleries: store.galleries, + setting: setting, + pageNumber: store.pageNumber, + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + } ) - } - .onSubmit(of: .search) { - store.send(.fetchGalleries()) - } - .onAppear { - if store.galleries.isEmpty { - DispatchQueue.main.async { + .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in + QuickSearchView( + store: store.scope(state: \.quickDetailSearchState, action: \.quickSearch) + ) { keyword in + store.send(.setNavigation(nil)) store.send(.fetchGalleries(keyword)) } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(store.lastKeyword) + .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) + .accentColor(setting.accentColor).autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: tagTranslator.translations, + showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + ) + } + .onSubmit(of: .search) { + store.send(.fetchGalleries()) + } + .onAppear { + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries(keyword)) + } + } + } + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(store.lastKeyword) if DeviceUtil.isPad { content diff --git a/EhPanda/View/Detail/DetailView+CommentCells.swift b/EhPanda/View/Detail/DetailView+CommentCells.swift new file mode 100644 index 000000000..7a76f2abd --- /dev/null +++ b/EhPanda/View/Detail/DetailView+CommentCells.swift @@ -0,0 +1,65 @@ +// +// DetailView+CommentCells.swift +// EhPanda +// + +import SwiftUI + +struct CommentCell: View { + let comment: GalleryComment + let backgroundColor: Color + + private var content: String { + comment.contents + .filter({ [.plainText, .linkedText].contains($0.type) }) + .compactMap(\.text).joined() + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(comment.author).font(.subheadline.bold()) + Spacer() + Group { + ZStack { + Image(systemSymbol: .handThumbsupFill) + .opacity(comment.votedUp ? 1 : 0) + Image(systemSymbol: .handThumbsdownFill) + .opacity(comment.votedDown ? 1 : 0) + } + Text(comment.score ?? "") + Text(comment.formattedDateString).lineLimit(1) + } + .font(.footnote).foregroundStyle(.secondary) + } + .minimumScaleFactor(0.75).lineLimit(1) + Text(content).padding(.top, 1) + Spacer() + } + .padding().background(backgroundColor) + .frame(width: 300, height: 120) + .cornerRadius(15) + } +} + +struct CommentButton: View { + let backgroundColor: Color + let action: () -> Void + + var body: some View { + let shape = RoundedRectangle(cornerRadius: 15) + + Button(action: action) { + HStack { + Image(systemSymbol: .squareAndPencil) + Text(L10n.Localizable.DetailView.Button.postComment) + .bold() + } + .padding() + .frame(maxWidth: .infinity) + .background(backgroundColor) + .clipShape(shape) + } + .glassEffect(.clear.interactive(), in: shape) + } +} diff --git a/EhPanda/View/Detail/DetailView+HeaderSection.swift b/EhPanda/View/Detail/DetailView+HeaderSection.swift new file mode 100644 index 000000000..e06c9fe8e --- /dev/null +++ b/EhPanda/View/Detail/DetailView+HeaderSection.swift @@ -0,0 +1,269 @@ +// +// DetailView+HeaderSection.swift +// EhPanda +// + +import SwiftUI +import Kingfisher + +// MARK: HeaderSection +struct HeaderSection: View { + private let downloadStore = DownloadBadgeStore.shared + + let gallery: Gallery + let galleryDetail: GalleryDetail + let user: User + let downloadBadge: DownloadBadge + let isPreparingDownload: Bool + let canDownload: Bool + let displaysJapaneseTitle: Bool + let showFullTitle: Bool + let showFullTitleAction: () -> Void + let downloadAction: () -> Void + let favorAction: (Int) -> Void + let unfavorAction: () -> Void + let navigateReadingAction: () -> Void + let navigateUploaderAction: () -> Void + + private let actionIconButtonSize: CGFloat = 32 + private let actionIconFont: Font = .system(size: 16, weight: .semibold) + + private var title: String { + let normalTitle = galleryDetail.title + return displaysJapaneseTitle ? galleryDetail.jpnTitle ?? normalTitle : normalTitle + } + private var showsMetadataPreparation: Bool { isPreparingDownload && downloadBadge == .none } + private var isDownloadActionDisabled: Bool { + guard canDownload else { return true } + return isPreparingDownload + } + private var downloadButtonTint: Color { + switch downloadBadge { + case .updateAvailable: return .orange + case .downloaded: return .red + case .partial: return .orange + case .failed, .missingFiles: return .red + default: return .accentColor + } + } + private var categoryLabel: some View { + CategoryLabel( + text: gallery.category.value, color: gallery.color, font: .headline, + insets: .init(top: 2, leading: 4, bottom: 2, trailing: 4), cornerRadius: 3 + ) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + private var downloadButton: some View { + Group { + if let progress = activeDownloadProgress { + Button(action: downloadAction) { + progressIndicator(progress: progress, isDeterminate: true, + centerSystemName: activeDownloadIconSystemName) + } + .buttonStyle(.plain) + } else if let progress = queuedDownloadProgress { + Button(action: downloadAction) { + progressIndicator(progress: progress, isDeterminate: false, + centerSystemName: activeDownloadIconSystemName) + } + .buttonStyle(.plain) + } else { + Button(action: downloadAction) { + Image(systemName: downloadIconSystemName) + .font(actionIconFont) + .foregroundStyle(canDownload ? downloadButtonTint : .secondary) + .rotationEffect(.degrees(showsMetadataPreparation ? 360 : 0)) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + .contentShape(Circle()) + } + .buttonStyle(.glass(.regular.interactive())) + .buttonBorderShape(.circle) + .animation( + showsMetadataPreparation + ? .linear(duration: 0.9).repeatForever(autoreverses: false) : .default, + value: showsMetadataPreparation + ) + } + } + .disabled(isDownloadActionDisabled) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + .accessibilityLabel(downloadButtonAccessibilityLabel) + } + private var favoriteButton: some View { + ZStack { + Button(action: unfavorAction) { + Image(systemSymbol: .heartFill) + .font(actionIconFont) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + .opacity(galleryDetail.isFavorited ? 1 : 0) + Menu { + ForEach(0..<10) { index in + Button(user.getFavoriteCategory(index: index)) { favorAction(index) } + } + } label: { + Image(systemSymbol: .heart) + .font(actionIconFont) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + .opacity(galleryDetail.isFavorited ? 0 : 1) + } + .foregroundStyle(.tint) + .buttonStyle(.glass(.regular.interactive())) + .buttonBorderShape(.circle) + .disabled(!CookieUtil.didLogin) + } + private var readButton: some View { + Button(action: navigateReadingAction) { + Image(systemSymbol: .bookFill) + .font(actionIconFont) + .foregroundStyle(.white) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + .buttonStyle(.glassProminent) + .buttonBorderShape(.circle) + .accessibilityLabel(L10n.Localizable.DetailView.Button.read) + } + private func progressIndicator( + progress: Double, isDeterminate: Bool, centerSystemName: String + ) -> some View { + ZStack { + Circle() + .fill(.ultraThinMaterial) + .overlay(Circle().strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.75)) + if isDeterminate { + Circle().stroke(downloadButtonTint.opacity(0.18), lineWidth: 2.5).padding(3) + Circle() + .trim(from: 0, to: progress) + .stroke(downloadButtonTint, style: .init(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .padding(3) + } else { + ProgressView() + .progressViewStyle(.circular) + .tint(downloadButtonTint) + .controlSize(.small) + } + Image(systemName: centerSystemName) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(downloadButtonTint) + } + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + private var actionButtons: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 6) { downloadButton; favoriteButton; readButton } + .fixedSize(horizontal: true, vertical: false) + VStack(alignment: .trailing, spacing: 6) { + HStack(spacing: 6) { downloadButton; favoriteButton } + readButton + } + .fixedSize(horizontal: true, vertical: false) + VStack(alignment: .trailing, spacing: 6) { downloadButton; favoriteButton; readButton } + .fixedSize(horizontal: true, vertical: false) + } + .layoutPriority(1) + } + private var bottomActionRow: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { categoryLabel; Spacer(minLength: 8); actionButtons } + VStack(alignment: .leading, spacing: 8) { categoryLabel; actionButtons } + } + } + private var queuedDownloadProgress: Double? { + if case .queued = downloadBadge { return 0 } + return nil + } + private var activeDownloadProgress: Double? { + if case .downloading(let completed, let total) = downloadBadge { + return Double(completed) / Double(max(total, 1)) + } + if case .paused(let completed, let total) = downloadBadge { + return Double(completed) / Double(max(total, 1)) + } + return nil + } + private var activeDownloadIconSystemName: String { + switch downloadBadge { + case .paused: return "play.fill" + case .downloading: return "pause.fill" + default: return downloadIconSystemName + } + } + private var downloadIconSystemName: String { + switch downloadBadge { + case .downloaded: return "trash" + case .updateAvailable: return "arrow.triangle.2.circlepath" + case .partial: return "exclamationmark.circle" + case .failed: return "exclamationmark.circle" + case .missingFiles: return "wrench.and.screwdriver" + case .paused: return "play.fill" + default: return "icloud.and.arrow.down" + } + } + private var resolvedCoverURL: URL? { downloadStore.resolvedCoverURL(for: gallery) } + + var body: some View { + HStack { + KFImage(resolvedCoverURL) + .placeholder({ Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }) + .defaultModifier() + .scaledToFit() + .frame(width: Defaults.ImageSize.headerW, height: Defaults.ImageSize.headerH) + VStack(alignment: .leading) { + Button(action: showFullTitleAction) { + Text(title) + .font(.title3.bold()) + .multilineTextAlignment(.leading) + .tint(.primary) + .lineLimit(showFullTitle ? nil : 3) + .fixedSize(horizontal: false, vertical: true) + } + Button(gallery.uploader ?? "", action: navigateUploaderAction) + .lineLimit(1).font(.callout).foregroundStyle(.secondary) + Spacer() + bottomActionRow + } + .padding(.horizontal, 10) + .frame(minHeight: Defaults.ImageSize.headerH) + } + } +} + +// MARK: HeaderSection Accessibility +extension HeaderSection { + var downloadButtonAccessibilityLabel: String { + guard canDownload else { return L10n.Localizable.DetailView.Accessibility.DownloadButton.login } + guard !showsMetadataPreparation else { + return L10n.Localizable.DetailView.Accessibility.DownloadButton.preparing + } + return downloadBadgeAccessibilityLabel + } + var downloadBadgeAccessibilityLabel: String { + switch downloadBadge { + case .none: + return L10n.Localizable.DetailView.Accessibility.DownloadButton.download + case .queued: + return L10n.Localizable.DetailView.Accessibility.DownloadButton.queued + case .downloading(let completed, let total): + let progress = L10n.Localizable.DetailView.Accessibility.DownloadButton.downloading( + completed, max(total, 1) + ) + return [progress, L10n.Localizable.DetailView.Accessibility.DownloadButton.pauseAction] + .joined(separator: ". ") + case .paused(let completed, let total): + return L10n.Localizable.DetailView.Accessibility.DownloadButton.paused(completed, max(total, 1)) + case .downloaded: + return L10n.Localizable.DetailView.Accessibility.DownloadButton.downloaded + case .updateAvailable: + return L10n.Localizable.DetailView.Accessibility.DownloadButton.update + case .partial(let completed, let total): + return L10n.Localizable.DetailView.Accessibility.DownloadButton.partial(completed, max(total, 1)) + case .failed: + return L10n.Localizable.DetailView.Accessibility.DownloadButton.retry + case .missingFiles: + return L10n.Localizable.DetailView.Accessibility.DownloadButton.repair + } + } +} diff --git a/EhPanda/View/Detail/DetailView+Navigation.swift b/EhPanda/View/Detail/DetailView+Navigation.swift new file mode 100644 index 000000000..5eac25698 --- /dev/null +++ b/EhPanda/View/Detail/DetailView+Navigation.swift @@ -0,0 +1,80 @@ +// +// DetailView+Navigation.swift +// EhPanda +// + +import SwiftUI +import ComposableArchitecture + +// MARK: NavigationLinks +extension DetailView { + @ViewBuilder var navigationLinks: some View { + NavigationLink(unwrapping: $store.route, case: \.previews) { _ in + PreviewsView( + store: store.scope(state: \.previewsState, action: \.previews), + gid: gid, setting: $setting, blurRadius: blurRadius + ) + } + NavigationLink(unwrapping: $store.route, case: \.comments) { route in + if let commentStore = store.scope(state: \.commentsState.wrappedValue, action: \.comments) { + CommentsView( + store: commentStore, gid: gid, token: store.gallery.token, apiKey: store.apiKey, + galleryURL: route.wrappedValue, comments: store.galleryComments, user: user, + setting: $setting, blurRadius: blurRadius, + tagTranslator: tagTranslator + ) + } + } + NavigationLink(unwrapping: $store.route, case: \.detailSearch) { route in + if let detailSearchStore = store.scope(state: \.detailSearchState.wrappedValue, action: \.detailSearch) { + DetailSearchView( + store: detailSearchStore, keyword: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } + NavigationLink(unwrapping: $store.route, case: \.galleryInfos) { route in + let (gallery, galleryDetail) = route.wrappedValue + GalleryInfosView( + store: store.scope(state: \.galleryInfosState, action: \.galleryInfos), + gallery: gallery, galleryDetail: galleryDetail + ) + } + } +} + +// MARK: ToolBar +extension DetailView { + func toolbar() -> some ToolbarContent { + CustomToolbarItem { + ToolbarFeaturesMenu { + Button { + if let galleryURL = store.gallery.galleryURL, + let archiveURL = store.galleryDetail?.archiveURL { + store.send(.setNavigation(.archives(galleryURL, archiveURL))) + } + } label: { + Label(L10n.Localizable.DetailView.ToolbarItem.Button.archives, systemSymbol: .zipperPage) + } + .disabled(store.galleryDetail?.archiveURL == nil || !CookieUtil.didLogin) + Button { + store.send(.setNavigation(.torrents())) + } label: { + let base = L10n.Localizable.DetailView.ToolbarItem.Button.torrents + let torrentCount = store.galleryDetail?.torrentCount ?? 0 + let baseWithCount = [base, "(\(torrentCount))"].joined(separator: " ") + Label(torrentCount > 0 ? baseWithCount : base, systemSymbol: .leaf) + } + .disabled((store.galleryDetail?.torrentCount ?? 0 > 0) != true) + Button { + if let galleryURL = store.gallery.galleryURL { + store.send(.setNavigation(.share(galleryURL))) + } + } label: { + Label(L10n.Localizable.DetailView.ToolbarItem.Button.share, systemSymbol: .squareAndArrowUp) + } + } + .disabled(store.galleryDetail == nil || store.loadingState == .loading) + } + } +} diff --git a/EhPanda/View/Detail/DetailView+Subviews.swift b/EhPanda/View/Detail/DetailView+Subviews.swift new file mode 100644 index 000000000..7333252c8 --- /dev/null +++ b/EhPanda/View/Detail/DetailView+Subviews.swift @@ -0,0 +1,344 @@ +// +// DetailView+Subviews.swift +// EhPanda +// + +import SwiftUI +import Kingfisher + +// MARK: DescriptionSection +struct DescriptionSection: View { + let gallery: Gallery + let galleryDetail: GalleryDetail + let navigateGalleryInfosAction: () -> Void + + private var infos: [DescScrollInfo] {[ + DescScrollInfo( + title: L10n.Localizable.DetailView.DescriptionSection.Title.favorited, + description: L10n.Localizable.DetailView.DescriptionSection.Description.favorited, + value: .init(galleryDetail.favoritedCount) + ), + DescScrollInfo( + title: L10n.Localizable.DetailView.DescriptionSection.Title.language, + description: galleryDetail.language.value, + value: galleryDetail.language.abbreviation + ), + DescScrollInfo( + title: L10n.Localizable.DetailView.DescriptionSection.Title.ratings("\(galleryDetail.ratingCount)"), + description: .init(), value: .init(), rating: galleryDetail.rating, isRating: true + ), + DescScrollInfo( + title: L10n.Localizable.DetailView.DescriptionSection.Title.pageCount, + description: L10n.Localizable.DetailView.DescriptionSection.Description.pageCount, + value: .init(galleryDetail.pageCount) + ), + DescScrollInfo( + title: L10n.Localizable.DetailView.DescriptionSection.Title.fileSize, + description: galleryDetail.sizeType, value: .init(galleryDetail.sizeCount) + ) + ]} + private var itemWidth: Double { + max(DeviceUtil.absWindowW / 5, 80) + } + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(infos) { info in + Group { + if info.isRating { + DescScrollRatingItem(title: info.title, rating: info.rating) + } else { + DescScrollItem(title: info.title, value: info.value, description: info.description) + } + } + .frame(width: itemWidth).drawingGroup() + Divider() + if info == infos.last { + Button(action: navigateGalleryInfosAction) { + Image(systemSymbol: .ellipsis) + .font(.system(size: 20, weight: .bold)) + } + .frame(width: itemWidth) + } + } + .withHorizontalSpacing() + } + } + .frame(height: 60) + } +} + +extension DescriptionSection { + struct DescScrollInfo: Identifiable, Equatable { + var id: String { title } + let title: String + let description: String + let value: String + var rating: Float = 0 + var isRating = false + } + struct DescScrollItem: View { + let title: String + let value: String + let description: String + + var body: some View { + VStack(spacing: 3) { + Text(title).textCase(.uppercase).font(.caption) + Text(value).fontWeight(.medium).font(.title3).lineLimit(1) + Text(description).font(.caption) + } + } + } + struct DescScrollRatingItem: View { + let title: String + let rating: Float + + var body: some View { + VStack(spacing: 3) { + Text(title).textCase(.uppercase).font(.caption).lineLimit(1) + Text(String(format: "%.2f", rating)).fontWeight(.medium).font(.title3) + RatingView(rating: rating).font(.system(size: 12)).foregroundStyle(.primary) + } + } + } +} + +// MARK: ActionSection +struct ActionSection: View { + let galleryDetail: GalleryDetail + let userRating: Int + let showUserRating: Bool + let showUserRatingAction: () -> Void + let updateRatingAction: (DragGesture.Value) -> Void + let confirmRatingAction: (DragGesture.Value) -> Void + let navigateSimilarGalleryAction: () -> Void + + var body: some View { + VStack { + HStack { + Group { + Button(action: showUserRatingAction) { + Spacer() + Image(systemSymbol: .squareAndPencil) + Text(L10n.Localizable.DetailView.ActionSection.Button.giveARating).bold() + Spacer() + } + .disabled(!CookieUtil.didLogin) + Button(action: navigateSimilarGalleryAction) { + Spacer() + Image(systemSymbol: .photoOnRectangleAngled) + Text(L10n.Localizable.DetailView.ActionSection.Button.similarGallery).bold() + Spacer() + } + } + .font(.callout).foregroundStyle(.primary) + } + if showUserRating { + HStack { + RatingView(rating: Float(userRating) / 2) + .font(.system(size: 24)) + .foregroundStyle(.yellow) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged(updateRatingAction) + .onEnded(confirmRatingAction) + ) + } + .padding(.top, 10) + } + } + .padding(.horizontal) + } +} + +// MARK: TagsSection +struct TagsSection: View { + let tags: [GalleryTag] + let showsImages: Bool + let voteTagAction: (String, Int) -> Void + let navigateSearchAction: (String) -> Void + let navigateTagDetailAction: (TagDetail) -> Void + let translateAction: (String) -> (String, TagTranslation?) + + var body: some View { + VStack(alignment: .leading) { + ForEach(tags) { tag in + TagRow( + tag: tag, showsImages: showsImages, + voteTagAction: voteTagAction, + navigateSearchAction: navigateSearchAction, + navigateTagDetailAction: navigateTagDetailAction, + translateAction: translateAction + ) + } + } + .padding(.horizontal) + } +} + +extension TagsSection { + struct TagRow: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.inSheet) private var inSheet + + let tag: GalleryTag + let showsImages: Bool + let voteTagAction: (String, Int) -> Void + let navigateSearchAction: (String) -> Void + let navigateTagDetailAction: (TagDetail) -> Void + let translateAction: (String) -> (String, TagTranslation?) + + private var reversedPrimary: Color { colorScheme == .light ? .white : .black } + private var backgroundColor: Color { + inSheet && colorScheme == .dark ? Color(.systemGray4) : Color(.systemGray5) + } + private var padding: EdgeInsets { .init(top: 5, leading: 14, bottom: 5, trailing: 14) } + + var body: some View { + HStack(alignment: .top) { + Text(tag.namespace?.value ?? tag.rawNamespace).font(.subheadline.bold()) + .foregroundColor(reversedPrimary).padding(padding) + .background(Color(.systemGray)).cornerRadius(5) + TagCloudView(data: tag.contents) { content in + tagContentView(content: content) + } + } + } + + @ViewBuilder + private func tagContentView(content: GalleryTag.Content) -> some View { + let (_, translation) = translateAction(content.rawNamespace + content.text) + Button { + navigateSearchAction(content.serachKeyword(tag: tag)) + } label: { + TagCloudCell( + text: translation?.displayValue ?? content.text, + imageURL: translation?.valueImageURL, + showsImages: showsImages, + font: .subheadline, padding: padding, textColor: .primary, + backgroundColor: backgroundColor + ) + } + .contextMenu { + tagContextMenu(content: content, translation: translation) + } + } + + @ViewBuilder + private func tagContextMenu( + content: GalleryTag.Content, + translation: TagTranslation? + ) -> some View { + if let translation = translation, + let description = translation.descriptionPlainText, + !description.isEmpty { + Button { + navigateTagDetailAction(.init( + title: translation.displayValue, description: description, + imageURLs: translation.descriptionImageURLs, + links: translation.links + )) + } label: { + Image(systemSymbol: .richtextPage) + Text(L10n.Localizable.DetailView.ContextMenu.Button.detail) + } + } + if CookieUtil.didLogin { + tagVoteButtons(content: content) + } + } + + @ViewBuilder + private func tagVoteButtons(content: GalleryTag.Content) -> some View { + if content.isVotedUp || content.isVotedDown { + Button { + voteTagAction(content.voteKeyword(tag: tag), content.isVotedUp ? -1 : 1) + } label: { + Image(systemSymbol: content.isVotedUp ? .handThumbsup : .handThumbsdown) + .symbolVariant(.fill) + Text(L10n.Localizable.DetailView.ContextMenu.Button.withdrawVote) + } + } else { + Button { + voteTagAction(content.voteKeyword(tag: tag), 1) + } label: { + Image(systemSymbol: .handThumbsup) + Text(L10n.Localizable.DetailView.ContextMenu.Button.voteUp) + } + Button { + voteTagAction(content.voteKeyword(tag: tag), -1) + } label: { + Image(systemSymbol: .handThumbsdown) + Text(L10n.Localizable.DetailView.ContextMenu.Button.voteDown) + } + } + } + } +} + +// MARK: PreviewsSection +struct PreviewsSection: View { + let pageCount: Int + let previewURLs: [Int: URL] + let navigatePreviewsAction: () -> Void + let navigateReadingAction: (Int) -> Void + + private var width: CGFloat { Defaults.ImageSize.previewAvgW } + private var height: CGFloat { width / Defaults.ImageSize.previewAspect } + + var body: some View { + SubSection( + title: L10n.Localizable.DetailView.Section.Title.previews, + showAll: pageCount > 20, showAllAction: navigatePreviewsAction + ) { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + ForEach(previewURLs.tuples.sorted(by: { $0.0 < $1.0 }), id: \.0) { index, previewURL in + Button { + navigateReadingAction(index) + } label: { + PreviewImageView(originalURL: previewURL) + .frame(width: width, height: height) + } + } + .withHorizontalSpacing(height: height) + } + } + } + } +} + +// MARK: CommentsSection +struct CommentsSection: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.inSheet) private var inSheet + + let comments: [GalleryComment] + let navigateCommentAction: () -> Void + let navigatePostCommentAction: () -> Void + + private var backgroundColor: Color { + inSheet && colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6) + } + + var body: some View { + SubSection( + title: L10n.Localizable.DetailView.Section.Title.comments, + showAll: !comments.isEmpty, showAllAction: navigateCommentAction + ) { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(comments.prefix(min(comments.count, 6))) { comment in + CommentCell(comment: comment, backgroundColor: backgroundColor) + } + .withHorizontalSpacing() + } + .drawingGroup() + } + CommentButton(backgroundColor: backgroundColor, action: navigatePostCommentAction) + .padding(.horizontal).disabled(!CookieUtil.didLogin) + } + } +} diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 00eedbb91..daf1dae4a 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -8,78 +8,78 @@ import Kingfisher import ComposableArchitecture import CommonMark -struct DetailView: View { - private enum DownloadDialog: Equatable { - case delete(isActiveDownload: Bool) - case retry(DownloadStartMode) +private enum DownloadDialog: Equatable { + case delete(isActiveDownload: Bool) + case retry(DownloadStartMode) - var title: String { - switch self { - case .delete: - return L10n.Localizable.DetailView.Dialog.Title.deleteDownload - case .retry(let mode): - switch mode { - case .repair: - return L10n.Localizable.DetailView.Dialog.Title.repairDownload - case .update: - return L10n.Localizable.DetailView.Dialog.Title.updateDownload - case .initial, .redownload: - return L10n.Localizable.DetailView.Dialog.Title.redownloadGallery - } + var title: String { + switch self { + case .delete: + return L10n.Localizable.DetailView.Dialog.Title.deleteDownload + case .retry(let mode): + switch mode { + case .repair: + return L10n.Localizable.DetailView.Dialog.Title.repairDownload + case .update: + return L10n.Localizable.DetailView.Dialog.Title.updateDownload + case .initial, .redownload: + return L10n.Localizable.DetailView.Dialog.Title.redownloadGallery } } + } - var message: String { - switch self { - case .delete(let isActiveDownload): - return isActiveDownload - ? L10n.Localizable.DetailView.Dialog.Message.deleteActiveDownload - : L10n.Localizable.DetailView.Dialog.Message.deleteDownloadedGallery - case .retry(let mode): - switch mode { - case .repair: - return L10n.Localizable.DetailView.Dialog.Message.repairDownload - case .update: - return L10n.Localizable.DetailView.Dialog.Message.updateDownload - case .initial, .redownload: - return L10n.Localizable.DetailView.Dialog.Message.redownloadGallery - } + var message: String { + switch self { + case .delete(let isActiveDownload): + return isActiveDownload + ? L10n.Localizable.DetailView.Dialog.Message.deleteActiveDownload + : L10n.Localizable.DetailView.Dialog.Message.deleteDownloadedGallery + case .retry(let mode): + switch mode { + case .repair: + return L10n.Localizable.DetailView.Dialog.Message.repairDownload + case .update: + return L10n.Localizable.DetailView.Dialog.Message.updateDownload + case .initial, .redownload: + return L10n.Localizable.DetailView.Dialog.Message.redownloadGallery } } + } - var confirmTitle: String { - switch self { - case .delete: - return L10n.Localizable.ConfirmationDialog.Button.delete - case .retry(let mode): - switch mode { - case .repair: - return L10n.Localizable.DetailView.Dialog.Button.repair - case .update: - return L10n.Localizable.DetailView.Dialog.Button.update - case .initial, .redownload: - return L10n.Localizable.DetailView.Dialog.Button.redownload - } + var confirmTitle: String { + switch self { + case .delete: + return L10n.Localizable.ConfirmationDialog.Button.delete + case .retry(let mode): + switch mode { + case .repair: + return L10n.Localizable.DetailView.Dialog.Button.repair + case .update: + return L10n.Localizable.DetailView.Dialog.Button.update + case .initial, .redownload: + return L10n.Localizable.DetailView.Dialog.Button.redownload } } + } - var confirmRole: ButtonRole? { - switch self { - case .delete: - return .destructive - case .retry: - return nil - } + var confirmRole: ButtonRole? { + switch self { + case .delete: + return .destructive + case .retry: + return nil } } +} - @Bindable private var store: StoreOf +struct DetailView: View { + @Bindable var store: StoreOf @State private var downloadDialog: DownloadDialog? - private let gid: String - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator + let gid: String + let user: User + @Binding var setting: Setting + let blurRadius: Double + let tagTranslator: TagTranslator init( store: StoreOf, gid: String, @@ -93,97 +93,146 @@ struct DetailView: View { self.tagTranslator = tagTranslator } + var body: some View { + modalModifiers(content: { content }) + .animation(.default, value: store.showsUserRating) + .animation(.default, value: store.showsFullTitle) + .animation(.default, value: store.galleryDetail) + .onAppear { + DispatchQueue.main.async { + store.send(.onAppear(gid, setting.showsNewDawnGreeting)) + } + } + .onChange(of: store.galleryDetail) { _, _ in + runLaunchAutomationIfNeeded() + } + .onChange(of: store.hasLoadedDownloadBadge) { _, _ in + runLaunchAutomationIfNeeded() + } + .alert( + downloadDialog?.title ?? "", + isPresented: Binding( + get: { downloadDialog != nil }, + set: { if !$0 { downloadDialog = nil } } + ), + presenting: downloadDialog + ) { dialog in + Button(dialog.confirmTitle, role: dialog.confirmRole) { + switch dialog { + case .delete: + store.send(.deleteDownload) + case .retry(let mode): + store.send(.retryDownload(mode)) + } + downloadDialog = nil + } + Button(L10n.Localizable.Common.Button.cancel, role: .cancel) { + downloadDialog = nil + } + } message: { dialog in + Text(dialog.message) + } + .background(navigationLinks) + .toolbar(content: toolbar) + } + +} + +// MARK: Content +private extension DetailView { var content: some View { ZStack { ScrollView(showsIndicators: false) { let content = - VStack(spacing: 30) { - if let error = store.loadingState.failed, - store.galleryDetail != nil { - offlineFallbackNotice(error: error) - .padding(.horizontal) - } - HeaderSection( - gallery: store.gallery, - galleryDetail: store.galleryDetail ?? .empty, - user: user, - downloadBadge: store.downloadBadge, - isPreparingDownload: store.isPreparingDownload, - canDownload: !store.gallery.id.isEmpty - && (AppUtil.galleryHost == .ehentai || CookieUtil.didLogin), - displaysJapaneseTitle: setting.displaysJapaneseTitle, - showFullTitle: store.showsFullTitle, - showFullTitleAction: { store.send(.toggleShowFullTitle) }, - downloadAction: { handleDownloadAction() }, - favorAction: { store.send(.favorGallery($0)) }, - unfavorAction: { store.send(.unfavorGallery) }, - navigateReadingAction: { store.send(.openReading) }, - navigateUploaderAction: { - if let uploader = store.galleryDetail?.uploader { - let keyword = "uploader:" + "\"\(uploader)\"" - store.send(.setNavigation(.detailSearch(keyword))) - } + VStack(spacing: 30) { + if let error = store.loadingState.failed, + store.galleryDetail != nil { + offlineFallbackNotice(error: error) + .padding(.horizontal) } - ) - .padding(.horizontal) - DescriptionSection( - gallery: store.gallery, - galleryDetail: store.galleryDetail ?? .empty, - navigateGalleryInfosAction: { - if let galleryDetail = store.galleryDetail { - store.send(.setNavigation(.galleryInfos(store.gallery, galleryDetail))) - } - } - ) - ActionSection( - galleryDetail: store.galleryDetail ?? .empty, - userRating: store.userRating, - showUserRating: store.showsUserRating, - showUserRatingAction: { store.send(.toggleShowUserRating) }, - updateRatingAction: { store.send(.updateRating($0)) }, - confirmRatingAction: { store.send(.confirmRating($0)) }, - navigateSimilarGalleryAction: { - if let trimmedTitle = store.galleryDetail?.trimmedTitle { - store.send(.setNavigation(.detailSearch(trimmedTitle))) + HeaderSection( + gallery: store.gallery, + galleryDetail: store.galleryDetail ?? .empty, + user: user, + downloadBadge: store.downloadBadge, + isPreparingDownload: store.isPreparingDownload, + canDownload: !store.gallery.id.isEmpty + && (AppUtil.galleryHost == .ehentai || CookieUtil.didLogin), + displaysJapaneseTitle: setting.displaysJapaneseTitle, + showFullTitle: store.showsFullTitle, + showFullTitleAction: { store.send(.toggleShowFullTitle) }, + downloadAction: { handleDownloadAction() }, + favorAction: { store.send(.favorGallery($0)) }, + unfavorAction: { store.send(.unfavorGallery) }, + navigateReadingAction: { store.send(.openReading) }, + navigateUploaderAction: { + if let uploader = store.galleryDetail?.uploader { + let keyword = "uploader:" + "\"\(uploader)\"" + store.send(.setNavigation(.detailSearch(keyword))) + } } - } - ) - if !store.galleryTags.isEmpty { - TagsSection( - tags: store.galleryTags, showsImages: setting.showsImagesInTags, - voteTagAction: { store.send(.voteTag($0, $1)) }, - navigateSearchAction: { store.send(.setNavigation(.detailSearch($0))) }, - navigateTagDetailAction: { store.send(.setNavigation(.tagDetail($0))) }, - translateAction: { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) .padding(.horizontal) - } - let displayPreviewURLs = store.localPreviewURLs.merging( - store.galleryPreviewURLs, - uniquingKeysWith: { local, _ in local } - ) - if !displayPreviewURLs.isEmpty { - PreviewsSection( - pageCount: store.galleryDetail?.pageCount ?? 0, - previewURLs: displayPreviewURLs, - navigatePreviewsAction: { store.send(.setNavigation(.previews)) }, - navigateReadingAction: { - store.send(.updateReadingProgress($0)) - store.send(.openReading) + DescriptionSection( + gallery: store.gallery, + galleryDetail: store.galleryDetail ?? .empty, + navigateGalleryInfosAction: { + if let galleryDetail = store.galleryDetail { + store.send(.setNavigation(.galleryInfos(store.gallery, galleryDetail))) + } } ) - } - CommentsSection( - comments: store.galleryComments, - navigateCommentAction: { - if let galleryURL = store.gallery.galleryURL { - store.send(.setNavigation(.comments(galleryURL))) + ActionSection( + galleryDetail: store.galleryDetail ?? .empty, + userRating: store.userRating, + showUserRating: store.showsUserRating, + showUserRatingAction: { store.send(.toggleShowUserRating) }, + updateRatingAction: { store.send(.updateRating($0)) }, + confirmRatingAction: { store.send(.confirmRating($0)) }, + navigateSimilarGalleryAction: { + if let trimmedTitle = store.galleryDetail?.trimmedTitle { + store.send(.setNavigation(.detailSearch(trimmedTitle))) + } } - }, - navigatePostCommentAction: { store.send(.setNavigation(.postComment())) } - ) - } - .padding(.bottom, 20) + ) + if !store.galleryTags.isEmpty { + TagsSection( + tags: store.galleryTags, showsImages: setting.showsImagesInTags, + voteTagAction: { store.send(.voteTag($0, $1)) }, + navigateSearchAction: { store.send(.setNavigation(.detailSearch($0))) }, + navigateTagDetailAction: { store.send(.setNavigation(.tagDetail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + } + ) + .padding(.horizontal) + } + let displayPreviewURLs = store.localPreviewURLs.merging( + store.galleryPreviewURLs, + uniquingKeysWith: { local, _ in local } + ) + if !displayPreviewURLs.isEmpty { + PreviewsSection( + pageCount: store.galleryDetail?.pageCount ?? 0, + previewURLs: displayPreviewURLs, + navigatePreviewsAction: { store.send(.setNavigation(.previews)) }, + navigateReadingAction: { + store.send(.updateReadingProgress($0)) + store.send(.openReading) + } + ) + } + CommentsSection( + comments: store.galleryComments, + navigateCommentAction: { + if let galleryURL = store.gallery.galleryURL { + store.send(.setNavigation(.comments(galleryURL))) + } + }, + navigatePostCommentAction: { store.send(.setNavigation(.postComment())) } + ) + } + .padding(.bottom, 20) if #available(iOS 18.0, *) { content @@ -198,7 +247,7 @@ struct DetailView: View { LoadingView() .opacity( store.galleryDetail == nil - && store.loadingState == .loading ? 1 : 0 + && store.loadingState == .loading ? 1 : 0 ) let error = store.loadingState.failed @@ -209,6 +258,35 @@ struct DetailView: View { } func modalModifiers(@ViewBuilder content: () -> Content) -> some View { + primaryModalModifiers(content: content) + .sheet(item: $store.route.sending(\.setNavigation).postComment) { _ in + PostCommentView( + title: L10n.Localizable.PostCommentView.Title.postComment, + content: $store.commentContent, + isFocused: $store.postCommentFocused, + postAction: { + if let galleryURL = store.gallery.galleryURL { + store.send(.postComment(galleryURL)) + } + store.send(.setNavigation(nil)) + }, + cancelAction: { store.send(.setNavigation(nil)) }, + onAppearAction: { store.send(.onPostCommentAppear) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(item: $store.route.sending(\.setNavigation).newDawn) { greeting in + NewDawnView(greeting: greeting) + .autoBlur(radius: blurRadius) + } + .sheet(item: $store.route.sending(\.setNavigation).tagDetail, id: \.title) { detail in + TagDetailView(detail: detail) + .autoBlur(radius: blurRadius) + } + } + + private func primaryModalModifiers(@ViewBuilder content: () -> Content) -> some View { content() .fullScreenCover(item: $store.route.sending(\.setNavigation).reading) { _ in ReadingView( @@ -246,76 +324,12 @@ struct DetailView: View { ActivityView(activityItems: [url]) .autoBlur(radius: blurRadius) } - .sheet(item: $store.route.sending(\.setNavigation).postComment) { _ in - PostCommentView( - title: L10n.Localizable.PostCommentView.Title.postComment, - content: $store.commentContent, - isFocused: $store.postCommentFocused, - postAction: { - if let galleryURL = store.gallery.galleryURL { - store.send(.postComment(galleryURL)) - } - store.send(.setNavigation(nil)) - }, - cancelAction: { store.send(.setNavigation(nil)) }, - onAppearAction: { store.send(.onPostCommentAppear) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).newDawn) { greeting in - NewDawnView(greeting: greeting) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).tagDetail, id: \.title) { detail in - TagDetailView(detail: detail) - .autoBlur(radius: blurRadius) - } } - var body: some View { - modalModifiers(content: { content }) - .animation(.default, value: store.showsUserRating) - .animation(.default, value: store.showsFullTitle) - .animation(.default, value: store.galleryDetail) - .onAppear { - DispatchQueue.main.async { - store.send(.onAppear(gid, setting.showsNewDawnGreeting)) - } - } - .onChange(of: store.galleryDetail) { _, _ in - runLaunchAutomationIfNeeded() - } - .onChange(of: store.hasLoadedDownloadBadge) { _, _ in - runLaunchAutomationIfNeeded() - } - .alert( - downloadDialog?.title ?? "", - isPresented: Binding( - get: { downloadDialog != nil }, - set: { if !$0 { downloadDialog = nil } } - ), - presenting: downloadDialog - ) { dialog in - Button(dialog.confirmTitle, role: dialog.confirmRole) { - switch dialog { - case .delete: - store.send(.deleteDownload) - case .retry(let mode): - store.send(.retryDownload(mode)) - } - downloadDialog = nil - } - Button(L10n.Localizable.Common.Button.cancel, role: .cancel) { - downloadDialog = nil - } - } message: { dialog in - Text(dialog.message) - } - .background(navigationLinks) - .toolbar(content: toolbar) - } +} +// MARK: Actions +private extension DetailView { private func handleDownloadAction() { let options = setting.downloadOptionsSnapshot switch store.downloadBadge { @@ -344,8 +358,8 @@ struct DetailView: View { L10n.Localizable.DetailView.OfflineNotice.savedDetails, systemImage: "wifi.exclamationmark" ) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.orange) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.orange) if error.isRetryable != false { Button(L10n.Localizable.ErrorView.Button.retry) { store.send(.fetchGalleryDetail) @@ -360,933 +374,6 @@ struct DetailView: View { } } -// MARK: NavigationLinks -private extension DetailView { - @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: $store.route, case: \.previews) { _ in - PreviewsView( - store: store.scope(state: \.previewsState, action: \.previews), - gid: gid, setting: $setting, blurRadius: blurRadius - ) - } - NavigationLink(unwrapping: $store.route, case: \.comments) { route in - if let commentStore = store.scope(state: \.commentsState.wrappedValue, action: \.comments) { - CommentsView( - store: commentStore, gid: gid, token: store.gallery.token, apiKey: store.apiKey, - galleryURL: route.wrappedValue, comments: store.galleryComments, user: user, - setting: $setting, blurRadius: blurRadius, - tagTranslator: tagTranslator - ) - } - } - NavigationLink(unwrapping: $store.route, case: \.detailSearch) { route in - if let detailSearchStore = store.scope(state: \.detailSearchState.wrappedValue, action: \.detailSearch) { - DetailSearchView( - store: detailSearchStore, keyword: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - NavigationLink(unwrapping: $store.route, case: \.galleryInfos) { route in - let (gallery, galleryDetail) = route.wrappedValue - GalleryInfosView( - store: store.scope(state: \.galleryInfosState, action: \.galleryInfos), - gallery: gallery, galleryDetail: galleryDetail - ) - } - } -} - -// MARK: ToolBar -private extension DetailView { - func toolbar() -> some ToolbarContent { - CustomToolbarItem { - ToolbarFeaturesMenu { - Button { - if let galleryURL = store.gallery.galleryURL, - let archiveURL = store.galleryDetail?.archiveURL - { - store.send(.setNavigation(.archives(galleryURL, archiveURL))) - } - } label: { - Label(L10n.Localizable.DetailView.ToolbarItem.Button.archives, systemSymbol: .zipperPage) - } - .disabled(store.galleryDetail?.archiveURL == nil || !CookieUtil.didLogin) - Button { - store.send(.setNavigation(.torrents())) - } label: { - let base = L10n.Localizable.DetailView.ToolbarItem.Button.torrents - let torrentCount = store.galleryDetail?.torrentCount ?? 0 - let baseWithCount = [base, "(\(torrentCount))"].joined(separator: " ") - Label(torrentCount > 0 ? baseWithCount : base, systemSymbol: .leaf) - } - .disabled((store.galleryDetail?.torrentCount ?? 0 > 0) != true) - Button { - if let galleryURL = store.gallery.galleryURL { - store.send(.setNavigation(.share(galleryURL))) - } - } label: { - Label(L10n.Localizable.DetailView.ToolbarItem.Button.share, systemSymbol: .squareAndArrowUp) - } - } - .disabled(store.galleryDetail == nil || store.loadingState == .loading) - } - } -} - -// MARK: HeaderSection -private struct HeaderSection: View { - private let downloadStore = DownloadBadgeStore.shared - - private let gallery: Gallery - private let galleryDetail: GalleryDetail - private let user: User - private let downloadBadge: DownloadBadge - private let isPreparingDownload: Bool - private let canDownload: Bool - private let displaysJapaneseTitle: Bool - private let showFullTitle: Bool - private let showFullTitleAction: () -> Void - private let downloadAction: () -> Void - private let favorAction: (Int) -> Void - private let unfavorAction: () -> Void - private let navigateReadingAction: () -> Void - private let navigateUploaderAction: () -> Void - - private let actionIconButtonSize: CGFloat = 32 - private let actionIconFont: Font = .system(size: 16, weight: .semibold) - - init( - gallery: Gallery, galleryDetail: GalleryDetail, - user: User, downloadBadge: DownloadBadge, isPreparingDownload: Bool, canDownload: Bool, - displaysJapaneseTitle: Bool, showFullTitle: Bool, - showFullTitleAction: @escaping () -> Void, - downloadAction: @escaping () -> Void, - favorAction: @escaping (Int) -> Void, - unfavorAction: @escaping () -> Void, - navigateReadingAction: @escaping () -> Void, - navigateUploaderAction: @escaping () -> Void - ) { - self.gallery = gallery - self.galleryDetail = galleryDetail - self.user = user - self.downloadBadge = downloadBadge - self.isPreparingDownload = isPreparingDownload - self.canDownload = canDownload - self.displaysJapaneseTitle = displaysJapaneseTitle - self.showFullTitle = showFullTitle - self.showFullTitleAction = showFullTitleAction - self.downloadAction = downloadAction - self.favorAction = favorAction - self.unfavorAction = unfavorAction - self.navigateReadingAction = navigateReadingAction - self.navigateUploaderAction = navigateUploaderAction - } - - private var title: String { - let normalTitle = galleryDetail.title - return displaysJapaneseTitle ? galleryDetail.jpnTitle ?? normalTitle : normalTitle - } - private var downloadButtonTint: Color { - switch downloadBadge { - case .updateAvailable: - return .orange - case .downloaded: - return .red - case .partial: - return .orange - case .failed, .missingFiles: - return .red - default: - return .accentColor - } - } - private var downloadButtonAccessibilityLabel: String { - guard canDownload else { return L10n.Localizable.DetailView.Accessibility.DownloadButton.login } - guard !showsMetadataPreparation else { - return L10n.Localizable.DetailView.Accessibility.DownloadButton.preparing - } - switch downloadBadge { - case .none: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.download - case .queued: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.queued - case .downloading(let completed, let total): - let progress = L10n.Localizable.DetailView.Accessibility.DownloadButton.downloading( - completed, - max(total, 1) - ) - return [progress, L10n.Localizable.DetailView.Accessibility.DownloadButton.pauseAction] - .joined(separator: ". ") - case .paused(let completed, let total): - return L10n.Localizable.DetailView.Accessibility.DownloadButton.paused( - completed, - max(total, 1) - ) - case .downloaded: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.downloaded - case .updateAvailable: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.update - case .partial(let completed, let total): - return L10n.Localizable.DetailView.Accessibility.DownloadButton.partial( - completed, - max(total, 1) - ) - case .failed: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.retry - case .missingFiles: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.repair - } - } - private var showsMetadataPreparation: Bool { - isPreparingDownload && downloadBadge == .none - } - private var queuedDownloadProgress: Double? { - if case .queued = downloadBadge { - return 0 - } - return nil - } - private var activeDownloadProgress: Double? { - if case .downloading(let completed, let total) = downloadBadge { - return Double(completed) / Double(max(total, 1)) - } - if case .paused(let completed, let total) = downloadBadge { - return Double(completed) / Double(max(total, 1)) - } - return nil - } - private var activeDownloadIconSystemName: String { - switch downloadBadge { - case .paused: - return "play.fill" - case .downloading: - return "pause.fill" - default: - return downloadIconSystemName - } - } - private var downloadIconSystemName: String { - switch downloadBadge { - case .downloaded: - return "trash" - case .updateAvailable: - return "arrow.triangle.2.circlepath" - case .partial: - return "exclamationmark.circle" - case .failed: - return "exclamationmark.circle" - case .missingFiles: - return "wrench.and.screwdriver" - case .paused: - return "play.fill" - default: - return "icloud.and.arrow.down" - } - } - private var isDownloadActionDisabled: Bool { - guard canDownload else { return true } - return isPreparingDownload - } - private var categoryLabel: some View { - CategoryLabel( - text: gallery.category.value, - color: gallery.color, - font: .headline, - insets: .init(top: 2, leading: 4, bottom: 2, trailing: 4), - cornerRadius: 3 - ) - .lineLimit(1) - .minimumScaleFactor(0.72) - } - private var downloadButton: some View { - Group { - if let progress = activeDownloadProgress { - Button(action: downloadAction) { - progressIndicator( - progress: progress, - isDeterminate: true, - centerSystemName: activeDownloadIconSystemName - ) - } - .buttonStyle(.plain) - } else if let progress = queuedDownloadProgress { - Button(action: downloadAction) { - progressIndicator( - progress: progress, - isDeterminate: false, - centerSystemName: activeDownloadIconSystemName - ) - } - .buttonStyle(.plain) - } else { - Button(action: downloadAction) { - Image(systemName: downloadIconSystemName) - .font(actionIconFont) - .foregroundStyle(canDownload ? downloadButtonTint : .secondary) - .rotationEffect(.degrees(showsMetadataPreparation ? 360 : 0)) - .frame(width: actionIconButtonSize, height: actionIconButtonSize) - .contentShape(Circle()) - } - .buttonStyle(.glass(.regular.interactive())) - .buttonBorderShape(.circle) - .animation( - showsMetadataPreparation - ? .linear(duration: 0.9).repeatForever(autoreverses: false) - : .default, - value: showsMetadataPreparation - ) - } - } - .disabled(isDownloadActionDisabled) - .frame(width: actionIconButtonSize, height: actionIconButtonSize) - .accessibilityLabel(downloadButtonAccessibilityLabel) - } - private var favoriteButton: some View { - ZStack { - Button(action: unfavorAction) { - Image(systemSymbol: .heartFill) - .font(actionIconFont) - .frame(width: actionIconButtonSize, height: actionIconButtonSize) - } - .opacity(galleryDetail.isFavorited ? 1 : 0) - - Menu { - ForEach(0..<10) { index in - Button(user.getFavoriteCategory(index: index)) { - favorAction(index) - } - } - } label: { - Image(systemSymbol: .heart) - .font(actionIconFont) - .frame(width: actionIconButtonSize, height: actionIconButtonSize) - } - .opacity(galleryDetail.isFavorited ? 0 : 1) - } - .foregroundStyle(.tint) - .buttonStyle(.glass(.regular.interactive())) - .buttonBorderShape(.circle) - .disabled(!CookieUtil.didLogin) - } - private var readButton: some View { - Button(action: navigateReadingAction) { - Image(systemSymbol: .bookFill) - .font(actionIconFont) - .foregroundStyle(.white) - .frame(width: actionIconButtonSize, height: actionIconButtonSize) - } - .buttonStyle(.glassProminent) - .buttonBorderShape(.circle) - .accessibilityLabel(L10n.Localizable.DetailView.Button.read) - } - private func progressIndicator( - progress: Double, - isDeterminate: Bool, - centerSystemName: String - ) -> some View { - ZStack { - Circle() - .fill(.ultraThinMaterial) - .overlay( - Circle() - .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.75) - ) - - if isDeterminate { - Circle() - .stroke(downloadButtonTint.opacity(0.18), lineWidth: 2.5) - .padding(3) - Circle() - .trim(from: 0, to: progress) - .stroke( - downloadButtonTint, - style: .init(lineWidth: 2.5, lineCap: .round) - ) - .rotationEffect(.degrees(-90)) - .padding(3) - } else { - ProgressView() - .progressViewStyle(.circular) - .tint(downloadButtonTint) - .controlSize(.small) - } - - Image(systemName: centerSystemName) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(downloadButtonTint) - } - .frame(width: actionIconButtonSize, height: actionIconButtonSize) - } - private var actionButtons: some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 6) { - downloadButton - favoriteButton - readButton - } - .fixedSize(horizontal: true, vertical: false) - - VStack(alignment: .trailing, spacing: 6) { - HStack(spacing: 6) { - downloadButton - favoriteButton - } - readButton - } - .fixedSize(horizontal: true, vertical: false) - - VStack(alignment: .trailing, spacing: 6) { - downloadButton - favoriteButton - readButton - } - .fixedSize(horizontal: true, vertical: false) - } - .layoutPriority(1) - } - private var bottomActionRow: some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 8) { - categoryLabel - Spacer(minLength: 8) - actionButtons - } - - VStack(alignment: .leading, spacing: 8) { - categoryLabel - actionButtons - } - } - } - - private var resolvedCoverURL: URL? { - downloadStore.resolvedCoverURL(for: gallery) - } - - var body: some View { - HStack { - KFImage(resolvedCoverURL) - .placeholder({ Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }) - .defaultModifier() - .scaledToFit() - .frame( - width: Defaults.ImageSize.headerW, - height: Defaults.ImageSize.headerH - ) - - VStack(alignment: .leading) { - Button(action: showFullTitleAction) { - Text(title) - .font(.title3.bold()) - .multilineTextAlignment(.leading) - .tint(.primary) - .lineLimit(showFullTitle ? nil : 3) - .fixedSize(horizontal: false, vertical: true) - } - - Button(gallery.uploader ?? "", action: navigateUploaderAction) - .lineLimit(1) - .font(.callout) - .foregroundStyle(.secondary) - - Spacer() - - bottomActionRow - } - .padding(.horizontal, 10) - .frame(minHeight: Defaults.ImageSize.headerH) - } - } -} - -// MARK: DescriptionSection -private struct DescriptionSection: View { - private let gallery: Gallery - private let galleryDetail: GalleryDetail - private let navigateGalleryInfosAction: () -> Void - - init( - gallery: Gallery, galleryDetail: GalleryDetail, - navigateGalleryInfosAction: @escaping () -> Void - ) { - self.gallery = gallery - self.galleryDetail = galleryDetail - self.navigateGalleryInfosAction = navigateGalleryInfosAction - } - - private var infos: [DescScrollInfo] {[ - DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.favorited, - description: L10n.Localizable.DetailView.DescriptionSection.Description.favorited, - value: .init(galleryDetail.favoritedCount) - ), - DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.language, - description: galleryDetail.language.value, - value: galleryDetail.language.abbreviation - ), - DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.ratings("\(galleryDetail.ratingCount)"), - description: .init(), value: .init(), rating: galleryDetail.rating, isRating: true - ), - DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.pageCount, - description: L10n.Localizable.DetailView.DescriptionSection.Description.pageCount, - value: .init(galleryDetail.pageCount) - ), - DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.fileSize, - description: galleryDetail.sizeType, value: .init(galleryDetail.sizeCount) - ) - ]} - private var itemWidth: Double { - max(DeviceUtil.absWindowW / 5, 80) - } - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(infos) { info in - Group { - if info.isRating { - DescScrollRatingItem(title: info.title, rating: info.rating) - } else { - DescScrollItem(title: info.title, value: info.value, description: info.description) - } - } - .frame(width: itemWidth).drawingGroup() - Divider() - if info == infos.last { - Button(action: navigateGalleryInfosAction) { - Image(systemSymbol: .ellipsis) - .font(.system(size: 20, weight: .bold)) - } - .frame(width: itemWidth) - } - } - .withHorizontalSpacing() - } - } - .frame(height: 60) - } -} - -private extension DescriptionSection { - struct DescScrollInfo: Identifiable, Equatable { - var id: String { title } - - let title: String - let description: String - let value: String - var rating: Float = 0 - var isRating = false - } - struct DescScrollItem: View { - private let title: String - private let value: String - private let description: String - - init(title: String, value: String, description: String) { - self.title = title - self.value = value - self.description = description - } - - var body: some View { - VStack(spacing: 3) { - Text(title).textCase(.uppercase).font(.caption) - Text(value).fontWeight(.medium).font(.title3).lineLimit(1) - Text(description).font(.caption) - } - } - } - struct DescScrollRatingItem: View { - private let title: String - private let rating: Float - - init(title: String, rating: Float) { - self.title = title - self.rating = rating - } - - var body: some View { - VStack(spacing: 3) { - Text(title).textCase(.uppercase).font(.caption).lineLimit(1) - Text(String(format: "%.2f", rating)).fontWeight(.medium).font(.title3) - RatingView(rating: rating).font(.system(size: 12)).foregroundStyle(.primary) - } - } - } -} - -// MARK: ActionSection -private struct ActionSection: View { - private let galleryDetail: GalleryDetail - private let userRating: Int - private let showUserRating: Bool - private let showUserRatingAction: () -> Void - private let updateRatingAction: (DragGesture.Value) -> Void - private let confirmRatingAction: (DragGesture.Value) -> Void - private let navigateSimilarGalleryAction: () -> Void - - init( - galleryDetail: GalleryDetail, - userRating: Int, showUserRating: Bool, - showUserRatingAction: @escaping () -> Void, - updateRatingAction: @escaping (DragGesture.Value) -> Void, - confirmRatingAction: @escaping (DragGesture.Value) -> Void, - navigateSimilarGalleryAction: @escaping () -> Void - ) { - self.galleryDetail = galleryDetail - self.userRating = userRating - self.showUserRating = showUserRating - self.showUserRatingAction = showUserRatingAction - self.updateRatingAction = updateRatingAction - self.confirmRatingAction = confirmRatingAction - self.navigateSimilarGalleryAction = navigateSimilarGalleryAction - } - - var body: some View { - VStack { - HStack { - Group { - Button(action: showUserRatingAction) { - Spacer() - Image(systemSymbol: .squareAndPencil) - Text(L10n.Localizable.DetailView.ActionSection.Button.giveARating).bold() - Spacer() - } - .disabled(!CookieUtil.didLogin) - Button(action: navigateSimilarGalleryAction) { - Spacer() - Image(systemSymbol: .photoOnRectangleAngled) - Text(L10n.Localizable.DetailView.ActionSection.Button.similarGallery).bold() - Spacer() - } - } - .font(.callout).foregroundStyle(.primary) - } - if showUserRating { - HStack { - RatingView(rating: Float(userRating) / 2) - .font(.system(size: 24)) - .foregroundStyle(.yellow) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged(updateRatingAction) - .onEnded(confirmRatingAction) - ) - } - .padding(.top, 10) - } - } - .padding(.horizontal) - } -} - -// MARK: TagsSection -private struct TagsSection: View { - private let tags: [GalleryTag] - private let showsImages: Bool - private let voteTagAction: (String, Int) -> Void - private let navigateSearchAction: (String) -> Void - private let navigateTagDetailAction: (TagDetail) -> Void - private let translateAction: (String) -> (String, TagTranslation?) - - init( - tags: [GalleryTag], showsImages: Bool, - voteTagAction: @escaping (String, Int) -> Void, - navigateSearchAction: @escaping (String) -> Void, - navigateTagDetailAction: @escaping (TagDetail) -> Void, - translateAction: @escaping (String) -> (String, TagTranslation?) - ) { - self.tags = tags - self.showsImages = showsImages - self.voteTagAction = voteTagAction - self.navigateSearchAction = navigateSearchAction - self.navigateTagDetailAction = navigateTagDetailAction - self.translateAction = translateAction - } - - var body: some View { - VStack(alignment: .leading) { - ForEach(tags) { tag in - TagRow( - tag: tag, showsImages: showsImages, - voteTagAction: voteTagAction, - navigateSearchAction: navigateSearchAction, - navigateTagDetailAction: navigateTagDetailAction, - translateAction: translateAction - ) - } - } - .padding(.horizontal) - } -} - -private extension TagsSection { - struct TagRow: View { - @Environment(\.colorScheme) private var colorScheme - @Environment(\.inSheet) private var inSheet - - private let tag: GalleryTag - private let showsImages: Bool - private let voteTagAction: (String, Int) -> Void - private let navigateSearchAction: (String) -> Void - private let navigateTagDetailAction: (TagDetail) -> Void - private let translateAction: (String) -> (String, TagTranslation?) - - init( - tag: GalleryTag, showsImages: Bool, - voteTagAction: @escaping (String, Int) -> Void, - navigateSearchAction: @escaping (String) -> Void, - navigateTagDetailAction: @escaping (TagDetail) -> Void, - translateAction: @escaping (String) -> (String, TagTranslation?) - ) { - self.tag = tag - self.showsImages = showsImages - self.voteTagAction = voteTagAction - self.navigateSearchAction = navigateSearchAction - self.navigateTagDetailAction = navigateTagDetailAction - self.translateAction = translateAction - } - - private var reversedPrimary: Color { - colorScheme == .light ? .white : .black - } - private var backgroundColor: Color { - inSheet && colorScheme == .dark ? Color(.systemGray4) : Color(.systemGray5) - } - private var padding: EdgeInsets { - .init(top: 5, leading: 14, bottom: 5, trailing: 14) - } - - var body: some View { - HStack(alignment: .top) { - Text(tag.namespace?.value ?? tag.rawNamespace).font(.subheadline.bold()) - .foregroundColor(reversedPrimary).padding(padding) - .background(Color(.systemGray)).cornerRadius(5) - TagCloudView(data: tag.contents) { content in - let (_, translation) = translateAction(content.rawNamespace + content.text) - Button { - navigateSearchAction(content.serachKeyword(tag: tag)) - } label: { - TagCloudCell( - text: translation?.displayValue ?? content.text, - imageURL: translation?.valueImageURL, - showsImages: showsImages, - font: .subheadline, padding: padding, textColor: .primary, - backgroundColor: backgroundColor - ) - } - .contextMenu { - if let translation = translation, - let description = translation.descriptionPlainText, - !description.isEmpty - { - Button { - navigateTagDetailAction(.init( - title: translation.displayValue, description: description, - imageURLs: translation.descriptionImageURLs, - links: translation.links - )) - } label: { - Image(systemSymbol: .richtextPage) - Text(L10n.Localizable.DetailView.ContextMenu.Button.detail) - } - } - if CookieUtil.didLogin { - if content.isVotedUp || content.isVotedDown { - Button { - voteTagAction(content.voteKeyword(tag: tag), content.isVotedUp ? -1 : 1) - } label: { - Image(systemSymbol: content.isVotedUp ? .handThumbsup : .handThumbsdown) - .symbolVariant(.fill) - Text(L10n.Localizable.DetailView.ContextMenu.Button.withdrawVote) - } - } else { - Button { - voteTagAction(content.voteKeyword(tag: tag), 1) - } label: { - Image(systemSymbol: .handThumbsup) - Text(L10n.Localizable.DetailView.ContextMenu.Button.voteUp) - } - Button { - voteTagAction(content.voteKeyword(tag: tag), -1) - } label: { - Image(systemSymbol: .handThumbsdown) - Text(L10n.Localizable.DetailView.ContextMenu.Button.voteDown) - } - } - } - } - } - } - } - } -} - -// MARK: PreviewSection -private struct PreviewsSection: View { - private let pageCount: Int - private let previewURLs: [Int: URL] - private let navigatePreviewsAction: () -> Void - private let navigateReadingAction: (Int) -> Void - - init( - pageCount: Int, previewURLs: [Int: URL], - navigatePreviewsAction: @escaping () -> Void, - navigateReadingAction: @escaping (Int) -> Void - ) { - self.pageCount = pageCount - self.previewURLs = previewURLs - self.navigatePreviewsAction = navigatePreviewsAction - self.navigateReadingAction = navigateReadingAction - } - - private var width: CGFloat { - Defaults.ImageSize.previewAvgW - } - private var height: CGFloat { - width / Defaults.ImageSize.previewAspect - } - - var body: some View { - SubSection( - title: L10n.Localizable.DetailView.Section.Title.previews, - showAll: pageCount > 20, showAllAction: navigatePreviewsAction - ) { - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - ForEach(previewURLs.tuples.sorted(by: { $0.0 < $1.0 }), id: \.0) { index, previewURL in - Button { - navigateReadingAction(index) - } label: { - PreviewImageView(originalURL: previewURL) - .frame(width: width, height: height) - } - } - .withHorizontalSpacing(height: height) - } - } - } - } -} - -// MARK: CommentsSection -private struct CommentsSection: View { - @Environment(\.colorScheme) private var colorScheme - @Environment(\.inSheet) private var inSheet - - private let comments: [GalleryComment] - private let navigateCommentAction: () -> Void - private let navigatePostCommentAction: () -> Void - - init( - comments: [GalleryComment], - navigateCommentAction: @escaping () -> Void, - navigatePostCommentAction: @escaping () -> Void - ) { - self.comments = comments - self.navigateCommentAction = navigateCommentAction - self.navigatePostCommentAction = navigatePostCommentAction - } - - private var backgroundColor: Color { - inSheet && colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6) - } - - var body: some View { - SubSection( - title: L10n.Localizable.DetailView.Section.Title.comments, - showAll: !comments.isEmpty, showAllAction: navigateCommentAction - ) { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(comments.prefix(min(comments.count, 6))) { comment in - CommentCell(comment: comment, backgroundColor: backgroundColor) - } - .withHorizontalSpacing() - } - .drawingGroup() - } - CommentButton(backgroundColor: backgroundColor, action: navigatePostCommentAction) - .padding(.horizontal).disabled(!CookieUtil.didLogin) - } - } -} - -private struct CommentCell: View { - private let comment: GalleryComment - private let backgroundColor: Color - - init(comment: GalleryComment, backgroundColor: Color) { - self.comment = comment - self.backgroundColor = backgroundColor - } - - private var content: String { - comment.contents - .filter({ [.plainText, .linkedText].contains($0.type) }) - .compactMap(\.text).joined() - } - - var body: some View { - VStack(alignment: .leading) { - HStack { - Text(comment.author).font(.subheadline.bold()) - Spacer() - Group { - ZStack { - Image(systemSymbol: .handThumbsupFill) - .opacity(comment.votedUp ? 1 : 0) - Image(systemSymbol: .handThumbsdownFill) - .opacity(comment.votedDown ? 1 : 0) - } - Text(comment.score ?? "") - Text(comment.formattedDateString).lineLimit(1) - } - .font(.footnote).foregroundStyle(.secondary) - } - .minimumScaleFactor(0.75).lineLimit(1) - Text(content).padding(.top, 1) - Spacer() - } - .padding().background(backgroundColor) - .frame(width: 300, height: 120) - .cornerRadius(15) - } -} - -private struct CommentButton: View { - private let backgroundColor: Color - private let action: () -> Void - - init(backgroundColor: Color, action: @escaping () -> Void) { - self.backgroundColor = backgroundColor - self.action = action - } - - var body: some View { - let shape = RoundedRectangle(cornerRadius: 15) - - Button(action: action) { - HStack { - Image(systemSymbol: .squareAndPencil) - - Text(L10n.Localizable.DetailView.Button.postComment) - .bold() - } - .padding() - .frame(maxWidth: .infinity) - .background(backgroundColor) - .clipShape(shape) - } - .glassEffect(.clear.interactive(), in: shape) - } -} - struct DetailView_Previews: PreviewProvider { static var previews: some View { NavigationView { diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift index d28c870f4..edf9f11b6 100644 --- a/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift +++ b/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift @@ -69,7 +69,7 @@ struct GalleryInfosView: View { Info( title: L10n.Localizable.GalleryInfosView.Title.favorited, value: galleryDetail.isFavorited ? L10n.Localizable.GalleryInfosView.Value.yes - : L10n.Localizable.GalleryInfosView.Value.no + : L10n.Localizable.GalleryInfosView.Value.no ), Info( title: L10n.Localizable.GalleryInfosView.Title.ratingCount, diff --git a/EhPanda/View/Detail/Previews/PreviewsView.swift b/EhPanda/View/Detail/Previews/PreviewsView.swift index 16fc45177..cefc17a47 100644 --- a/EhPanda/View/Detail/Previews/PreviewsView.swift +++ b/EhPanda/View/Detail/Previews/PreviewsView.swift @@ -53,8 +53,7 @@ struct PreviewsView: View { } .onAppear { if store.databaseLoadingState != .loading - && displayPreviewURLs[index] == nil && (index - 1) % 10 == 0 - { + && displayPreviewURLs[index] == nil && (index - 1) % 10 == 0 { store.send(.fetchPreviewURLs(index)) } } diff --git a/EhPanda/View/Downloads/DownloadFiltersView.swift b/EhPanda/View/Downloads/DownloadFiltersView.swift index 043e60393..dfaebf1e4 100644 --- a/EhPanda/View/Downloads/DownloadFiltersView.swift +++ b/EhPanda/View/Downloads/DownloadFiltersView.swift @@ -57,7 +57,7 @@ struct DownloadFiltersView: View { L10n.Localizable.FiltersView.Title.setPagesRange, isOn: $filter.pageRangeActivated ) - .disabled(focusedBound != nil) + .disabled(focusedBound != nil) DownloadPagesRangeSetter( lowerBound: $filter.pageLowerBound, upperBound: $filter.pageUpperBound, diff --git a/EhPanda/View/Downloads/DownloadInspectorReducer.swift b/EhPanda/View/Downloads/DownloadInspectorReducer.swift new file mode 100644 index 000000000..f1a4074f1 --- /dev/null +++ b/EhPanda/View/Downloads/DownloadInspectorReducer.swift @@ -0,0 +1,263 @@ +// +// DownloadInspectorReducer.swift +// EhPanda +// + +import Foundation +import ComposableArchitecture + +@Reducer +struct DownloadInspectorReducer { + private enum CancelID { + case observeDownloads + case loadInspection + } + + @ObservableState + struct State: Equatable { + var gid = "" + var inspection: DownloadInspection? + var stableInspection: DownloadInspection? + var loadingState: LoadingState = .loading + var inspectionRequestID = UUID() + var retryingPageIndices = Set() + + init(gid: String = "") { + self.gid = gid + loadingState = gid.isEmpty ? .idle : .loading + } + } + + enum Action { + case onAppear + case teardown + case loadInspection + case loadInspectionDone(UUID, Result) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) + case retryPage(Int) + case retryPageDone(Result) + case retryFailedPages + case retryFailedPagesDone(Result) + case updateDownload + case updateDownloadDone(Result) + } + + @Dependency(\.downloadClient) private var downloadClient + + var body: some Reducer { + Reduce { state, action in + switch action { + case .onAppear: + guard state.gid.notEmpty else { return .none } + return .merge( + .send(.loadInspection), + .send(.observeDownloads) + ) + + case .teardown: + return .merge( + .cancel(id: CancelID.observeDownloads), + .cancel(id: CancelID.loadInspection) + ) + + case .loadInspection: + guard state.gid.notEmpty else { return .none } + if state.inspection == nil { + state.loadingState = .loading + } + let requestID = UUID() + state.inspectionRequestID = requestID + return .run { [gid = state.gid] send in + await send(.loadInspectionDone(requestID, await downloadClient.loadInspection(gid))) + } + .cancellable(id: CancelID.loadInspection, cancelInFlight: true) + + case .loadInspectionDone(let requestID, let result): + guard state.inspectionRequestID == requestID else { return .none } + switch result { + case .success(let inspection): + state.stableInspection = inspection + let inspection = state.overlayRetryingPages(in: inspection) + state.inspection = inspection + state.loadingState = .idle + state.retryingPageIndices = state.reconciledRetryingPageIndices( + for: inspection + ) + case .failure(let error): + state.retryingPageIndices = .init() + if let stableInspection = state.stableInspection { + state.inspection = stableInspection + } + state.loadingState = .failed(error) + } + return .none + + case .observeDownloads: + guard state.gid.notEmpty else { return .none } + return .run { [gid = state.gid] send in + var hadRelevantDownloads = false + for await downloads in downloadClient.observeDownloads() { + let relevantDownloads = downloads.filter { $0.gid == gid } + let hasRelevantDownloads = !relevantDownloads.isEmpty + guard hasRelevantDownloads || hadRelevantDownloads else { continue } + hadRelevantDownloads = hasRelevantDownloads + await send(.observeDownloadsDone(relevantDownloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone(let downloads): + guard !downloads.isEmpty else { + state.inspection = nil + state.stableInspection = nil + state.retryingPageIndices = .init() + state.loadingState = .idle + return .none + } + guard let latestDownload = downloads.first else { return .none } + let previousDownload = state.inspection?.download + if let inspection = state.inspection, + state.retryingPageIndices.isEmpty || state.shouldKeepRetryPending(for: latestDownload) { + state.inspection = state.overlayRetryingPages(in: .init( + download: latestDownload, + coverURL: inspection.coverURL, + pages: inspection.pages + )) + } + guard previousDownload != latestDownload else { return .none } + return .send(.loadInspection) + + case .retryPage(let index): + guard state.gid.notEmpty else { return .none } + state.inspectionRequestID = UUID() + state.retryingPageIndices.insert(index) + state.stableInspection = state.inspection ?? state.stableInspection + if let inspection = state.inspection { + state.inspection = .init( + download: inspection.download, + coverURL: inspection.coverURL, + pages: inspection.pages.map { page in + guard page.index == index else { return page } + return .init( + index: index, + status: .pending, + relativePath: page.relativePath, + fileURL: nil, + failure: nil + ) + } + ) + } + return .merge( + .cancel(id: CancelID.loadInspection), + .run { [gid = state.gid] send in + await send(.retryPageDone(await downloadClient.retryPages(gid, [index]))) + } + ) + + case .retryPageDone(let result): + if case .failure = result { + state.retryingPageIndices = .init() + return .send(.loadInspection) + } + return .none + + case .retryFailedPages: + guard let failedPageIndices = state.inspection?.failedPageIndices, + let gid = state.inspection?.download.gid, + !failedPageIndices.isEmpty + else { + return .none + } + state.inspectionRequestID = UUID() + state.retryingPageIndices.formUnion(failedPageIndices) + state.stableInspection = state.inspection ?? state.stableInspection + if let inspection = state.inspection { + state.inspection = .init( + download: inspection.download, + coverURL: inspection.coverURL, + pages: inspection.pages.map { page in + guard failedPageIndices.contains(page.index) else { return page } + return .init( + index: page.index, + status: .pending, + relativePath: page.relativePath, + fileURL: nil, + failure: nil + ) + } + ) + } + return .merge( + .cancel(id: CancelID.loadInspection), + .run { send in + await send(.retryFailedPagesDone(await downloadClient.retryPages(gid, failedPageIndices))) + } + ) + + case .retryFailedPagesDone(let result): + if case .failure = result { + state.retryingPageIndices = .init() + return .send(.loadInspection) + } + return .none + + case .updateDownload: + guard let gid = state.inspection?.download.gid else { return .none } + return .run { send in + await send(.updateDownloadDone(await downloadClient.retry(gid, .update))) + } + + case .updateDownloadDone(let result): + if case .failure = result { + return .send(.loadInspection) + } + return .none + } + } + } +} + +extension DownloadInspectorReducer.State { + func shouldKeepRetryPending(for download: DownloadedGallery) -> Bool { + download.canPauseOrResume + || download.isPendingQueue + || (download.status == .partial && download.lastError == nil) + } + + func overlayRetryingPages(in inspection: DownloadInspection) -> DownloadInspection { + guard !retryingPageIndices.isEmpty else { return inspection } + + guard shouldKeepRetryPending(for: inspection.download) else { return inspection } + + return .init( + download: inspection.download, + coverURL: inspection.coverURL, + pages: inspection.pages.map { page in + guard retryingPageIndices.contains(page.index), + page.status != .downloaded + else { + return page + } + return .init( + index: page.index, + status: .pending, + relativePath: page.relativePath, + fileURL: page.fileURL, + failure: nil + ) + } + ) + } + + func reconciledRetryingPageIndices(for inspection: DownloadInspection) -> Set { + guard !retryingPageIndices.isEmpty else { return .init() } + + guard shouldKeepRetryPending(for: inspection.download) else { return .init() } + + return retryingPageIndices.filter { index in + inspection.pages.first(where: { $0.index == index })?.status != .downloaded + } + } +} diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index 48c094324..9aa7f0887 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -41,11 +41,11 @@ struct DownloadsReducer { var filteredDownloads: [DownloadedGallery] { downloads.filter { $0.matches(filter: filter) - && $0.matches(queryFilter: galleryFilter) - && ( - keyword.isEmpty - || $0.searchableText.caseInsensitiveContains(keyword) - ) + && $0.matches(queryFilter: galleryFilter) + && ( + keyword.isEmpty + || $0.searchableText.caseInsensitiveContains(keyword) + ) } } } @@ -96,8 +96,7 @@ struct DownloadsReducer { case .setNavigation(let route): state.route = route if case .detail(let gid) = route, - let download = state.downloads.first(where: { $0.gid == gid }) - { + let download = state.downloads.first(where: { $0.gid == gid }) { state.detailState.wrappedValue = .init(download: download) } else if case .inspector(let gid) = route { state.inspectorState = .init(gid: gid) @@ -212,260 +211,3 @@ struct DownloadsReducer { Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) } } - -@Reducer -struct DownloadInspectorReducer { - private enum CancelID { - case observeDownloads - case loadInspection - } - - @ObservableState - struct State: Equatable { - var gid = "" - var inspection: DownloadInspection? - var stableInspection: DownloadInspection? - var loadingState: LoadingState = .loading - var inspectionRequestID = UUID() - var retryingPageIndices = Set() - - init(gid: String = "") { - self.gid = gid - loadingState = gid.isEmpty ? .idle : .loading - } - } - - enum Action { - case onAppear - case teardown - case loadInspection - case loadInspectionDone(UUID, Result) - case observeDownloads - case observeDownloadsDone([DownloadedGallery]) - case retryPage(Int) - case retryPageDone(Result) - case retryFailedPages - case retryFailedPagesDone(Result) - case updateDownload - case updateDownloadDone(Result) - } - - @Dependency(\.downloadClient) private var downloadClient - - var body: some Reducer { - Reduce { state, action in - switch action { - case .onAppear: - guard state.gid.notEmpty else { return .none } - return .merge( - .send(.loadInspection), - .send(.observeDownloads) - ) - - case .teardown: - return .merge( - .cancel(id: CancelID.observeDownloads), - .cancel(id: CancelID.loadInspection) - ) - - case .loadInspection: - guard state.gid.notEmpty else { return .none } - if state.inspection == nil { - state.loadingState = .loading - } - let requestID = UUID() - state.inspectionRequestID = requestID - return .run { [gid = state.gid] send in - await send(.loadInspectionDone(requestID, await downloadClient.loadInspection(gid))) - } - .cancellable(id: CancelID.loadInspection, cancelInFlight: true) - - case .loadInspectionDone(let requestID, let result): - guard state.inspectionRequestID == requestID else { return .none } - switch result { - case .success(let inspection): - state.stableInspection = inspection - let inspection = state.overlayRetryingPages(in: inspection) - state.inspection = inspection - state.loadingState = .idle - state.retryingPageIndices = state.reconciledRetryingPageIndices( - for: inspection - ) - case .failure(let error): - state.retryingPageIndices = .init() - if let stableInspection = state.stableInspection { - state.inspection = stableInspection - } - state.loadingState = .failed(error) - } - return .none - - case .observeDownloads: - guard state.gid.notEmpty else { return .none } - return .run { [gid = state.gid] send in - var hadRelevantDownloads = false - for await downloads in downloadClient.observeDownloads() { - let relevantDownloads = downloads.filter { $0.gid == gid } - let hasRelevantDownloads = !relevantDownloads.isEmpty - guard hasRelevantDownloads || hadRelevantDownloads else { continue } - hadRelevantDownloads = hasRelevantDownloads - await send(.observeDownloadsDone(relevantDownloads)) - } - } - .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) - - case .observeDownloadsDone(let downloads): - guard !downloads.isEmpty else { - state.inspection = nil - state.stableInspection = nil - state.retryingPageIndices = .init() - state.loadingState = .idle - return .none - } - guard let latestDownload = downloads.first else { return .none } - let previousDownload = state.inspection?.download - if let inspection = state.inspection, - state.retryingPageIndices.isEmpty || state.shouldKeepRetryPending(for: latestDownload) - { - state.inspection = state.overlayRetryingPages(in: .init( - download: latestDownload, - coverURL: inspection.coverURL, - pages: inspection.pages - )) - } - guard previousDownload != latestDownload else { return .none } - return .send(.loadInspection) - - case .retryPage(let index): - guard state.gid.notEmpty else { return .none } - state.inspectionRequestID = UUID() - state.retryingPageIndices.insert(index) - state.stableInspection = state.inspection ?? state.stableInspection - if let inspection = state.inspection { - state.inspection = .init( - download: inspection.download, - coverURL: inspection.coverURL, - pages: inspection.pages.map { page in - guard page.index == index else { return page } - return .init( - index: index, - status: .pending, - relativePath: page.relativePath, - fileURL: nil, - failure: nil - ) - } - ) - } - return .merge( - .cancel(id: CancelID.loadInspection), - .run { [gid = state.gid] send in - await send(.retryPageDone(await downloadClient.retryPages(gid, [index]))) - } - ) - - case .retryPageDone(let result): - if case .failure = result { - state.retryingPageIndices = .init() - return .send(.loadInspection) - } - return .none - - case .retryFailedPages: - guard let failedPageIndices = state.inspection?.failedPageIndices, - let gid = state.inspection?.download.gid, - !failedPageIndices.isEmpty - else { - return .none - } - state.inspectionRequestID = UUID() - state.retryingPageIndices.formUnion(failedPageIndices) - state.stableInspection = state.inspection ?? state.stableInspection - if let inspection = state.inspection { - state.inspection = .init( - download: inspection.download, - coverURL: inspection.coverURL, - pages: inspection.pages.map { page in - guard failedPageIndices.contains(page.index) else { return page } - return .init( - index: page.index, - status: .pending, - relativePath: page.relativePath, - fileURL: nil, - failure: nil - ) - } - ) - } - return .merge( - .cancel(id: CancelID.loadInspection), - .run { send in - await send(.retryFailedPagesDone(await downloadClient.retryPages(gid, failedPageIndices))) - } - ) - - case .retryFailedPagesDone(let result): - if case .failure = result { - state.retryingPageIndices = .init() - return .send(.loadInspection) - } - return .none - - case .updateDownload: - guard let gid = state.inspection?.download.gid else { return .none } - return .run { send in - await send(.updateDownloadDone(await downloadClient.retry(gid, .update))) - } - - case .updateDownloadDone(let result): - if case .failure = result { - return .send(.loadInspection) - } - return .none - } - } - } -} - -private extension DownloadInspectorReducer.State { - func shouldKeepRetryPending(for download: DownloadedGallery) -> Bool { - download.canPauseOrResume - || download.isPendingQueue - || (download.status == .partial && download.lastError == nil) - } - - func overlayRetryingPages(in inspection: DownloadInspection) -> DownloadInspection { - guard !retryingPageIndices.isEmpty else { return inspection } - - guard shouldKeepRetryPending(for: inspection.download) else { return inspection } - - return .init( - download: inspection.download, - coverURL: inspection.coverURL, - pages: inspection.pages.map { page in - guard retryingPageIndices.contains(page.index), - page.status != .downloaded - else { - return page - } - return .init( - index: page.index, - status: .pending, - relativePath: page.relativePath, - fileURL: page.fileURL, - failure: nil - ) - } - ) - } - - func reconciledRetryingPageIndices(for inspection: DownloadInspection) -> Set { - guard !retryingPageIndices.isEmpty else { return .init() } - - guard shouldKeepRetryPending(for: inspection.download) else { return .init() } - - return retryingPageIndices.filter { index in - inspection.pages.first(where: { $0.index == index })?.status != .downloaded - } - } -} diff --git a/EhPanda/View/Downloads/DownloadsView+Subviews.swift b/EhPanda/View/Downloads/DownloadsView+Subviews.swift new file mode 100644 index 000000000..b8d5179cc --- /dev/null +++ b/EhPanda/View/Downloads/DownloadsView+Subviews.swift @@ -0,0 +1,214 @@ +// +// DownloadsView+Subviews.swift +// EhPanda +// + +import SwiftUI +import SFSafeSymbols +import ComposableArchitecture + +struct DownloadInspectorView: View { + @Environment(\.dismiss) private var dismiss + + @Bindable private var store: StoreOf + private let setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: StoreOf, + setting: Setting, + blurRadius: Double, + tagTranslator: TagTranslator + ) { + self.store = store + self.setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + var body: some View { + Group { + switch store.loadingState { + case .loading where store.inspection == nil: + LoadingView() + + case .failed(let error) where store.inspection == nil: + ErrorView(error: error, action: { store.send(.loadInspection) }) + + default: + List { + if let inspection = store.inspection { + Section { + StaticGalleryDetailCell( + gallery: inspection.download.gallery, + resolvedCoverURL: inspection.coverURL, + setting: setting, + translateAction: { + tagTranslator.lookup( + word: $0, + returnOriginal: !setting.translatesTags + ) + }, + downloadBadge: inspection.download.badge + ) + .listRowInsets(.init(top: 10, leading: 10, bottom: 10, trailing: 10)) + .listRowBackground(Color.clear) + } + + if !inspection.failedPageIndices.isEmpty || inspection.download.canTriggerUpdate { + Section(L10n.Localizable.DownloadsView.Inspector.Section.actions) { + if !inspection.failedPageIndices.isEmpty { + Button { + store.send(.retryFailedPages) + } label: { + Label( + L10n.Localizable.DownloadsView.Inspector.Button.retryFailedPages( + inspection.failedPageIndices.count + ), + systemImage: "arrow.clockwise.circle" + ) + } + } + + if inspection.download.canTriggerUpdate { + Button { + store.send(.updateDownload) + } label: { + Label( + L10n.Localizable.DownloadsView.Inspector.Button.updateDownload, + systemImage: "arrow.triangle.2.circlepath" + ) + } + } + } + } + + Section(L10n.Localizable.DownloadsView.Inspector.Section.pages) { + ForEach(inspection.pages) { page in + DownloadInspectorPageRow(page: page) { + store.send(.retryPage(page.index)) + } + } + } + } + } + .listStyle(.insetGrouped) + } + } + .autoBlur(radius: blurRadius) + .navigationTitle(L10n.Localizable.DownloadsView.Inspector.Title.downloadStatus) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + CustomToolbarItem(placement: .cancellationAction) { + Button(L10n.Localizable.EhSettingView.ToolbarItem.Button.done) { + dismiss() + } + } + } + .onAppear { + store.send(.onAppear) + } + } +} + +struct DownloadListRow: View { + let download: DownloadedGallery + let setting: Setting + let tagTranslator: TagTranslator + let openAction: () -> Void + + var body: some View { + HStack(spacing: 0) { + StaticGalleryDetailCell( + gallery: download.gallery, + resolvedCoverURL: download.coverURL, + setting: setting, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + }, + downloadBadge: download.badge + ) + .allowsHitTesting(false) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture(perform: openAction) + .accessibilityAddTraits(.isButton) + .accessibilityLabel(download.title) + } +} + +struct DownloadInspectorPageRow: View { + let page: DownloadPageInspection + let retryAction: () -> Void + + private var symbolName: String { + switch page.status { + case .pending: + return "clock" + case .downloaded: + return "checkmark.circle.fill" + case .failed: + return "exclamationmark.circle.fill" + } + } + + private var tint: Color { + switch page.status { + case .pending: + return .secondary + case .downloaded: + return .green + case .failed: + return .red + } + } + + private var subtitle: String { + switch page.status { + case .pending: + return L10n.Localizable.DownloadsView.Inspector.Page.pending + case .downloaded: + return page.relativePath ?? L10n.Localizable.Struct.DownloadBadge.Text.downloaded + case .failed: + return page.failure?.message ?? L10n.Localizable.DownloadsView.Inspector.Page.tapToRetry + } + } + + var body: some View { + Group { + if page.status == .failed { + Button(action: retryAction) { + rowContent + } + .buttonStyle(.plain) + } else { + rowContent + } + } + } + + private var rowContent: some View { + HStack(spacing: 12) { + Image(systemName: symbolName) + .foregroundStyle(tint) + .font(.title3) + VStack(alignment: .leading, spacing: 4) { + Text(L10n.Localizable.DownloadsView.Inspector.Page.title(page.index)) + .font(.body.weight(.medium)) + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + if page.status == .failed { + Image(systemSymbol: .arrowClockwise) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +} diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift index 9fc599f46..6e9e59687 100644 --- a/EhPanda/View/Downloads/DownloadsView.swift +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -157,6 +157,10 @@ struct DownloadsView: View { .toolbar(content: toolbar) } +} + +// MARK: Subviews +private extension DownloadsView { @ViewBuilder private var downloadsList: some View { switch store.loadingState { case .loading where store.downloads.isEmpty: @@ -307,212 +311,6 @@ struct DownloadsView: View { } } -private struct DownloadInspectorView: View { - @Environment(\.dismiss) private var dismiss - - @Bindable private var store: StoreOf - private let setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator - - init( - store: StoreOf, - setting: Setting, - blurRadius: Double, - tagTranslator: TagTranslator - ) { - self.store = store - self.setting = setting - self.blurRadius = blurRadius - self.tagTranslator = tagTranslator - } - - var body: some View { - Group { - switch store.loadingState { - case .loading where store.inspection == nil: - LoadingView() - - case .failed(let error) where store.inspection == nil: - ErrorView(error: error, action: { store.send(.loadInspection) }) - - default: - List { - if let inspection = store.inspection { - Section { - StaticGalleryDetailCell( - gallery: inspection.download.gallery, - resolvedCoverURL: inspection.coverURL, - setting: setting, - translateAction: { - tagTranslator.lookup( - word: $0, - returnOriginal: !setting.translatesTags - ) - }, - downloadBadge: inspection.download.badge - ) - .listRowInsets(.init(top: 10, leading: 10, bottom: 10, trailing: 10)) - .listRowBackground(Color.clear) - } - - if !inspection.failedPageIndices.isEmpty || inspection.download.canTriggerUpdate { - Section(L10n.Localizable.DownloadsView.Inspector.Section.actions) { - if !inspection.failedPageIndices.isEmpty { - Button { - store.send(.retryFailedPages) - } label: { - Label( - L10n.Localizable.DownloadsView.Inspector.Button.retryFailedPages( - inspection.failedPageIndices.count - ), - systemImage: "arrow.clockwise.circle" - ) - } - } - - if inspection.download.canTriggerUpdate { - Button { - store.send(.updateDownload) - } label: { - Label( - L10n.Localizable.DownloadsView.Inspector.Button.updateDownload, - systemImage: "arrow.triangle.2.circlepath" - ) - } - } - } - } - - Section(L10n.Localizable.DownloadsView.Inspector.Section.pages) { - ForEach(inspection.pages) { page in - DownloadInspectorPageRow(page: page) { - store.send(.retryPage(page.index)) - } - } - } - } - } - .listStyle(.insetGrouped) - } - } - .autoBlur(radius: blurRadius) - .navigationTitle(L10n.Localizable.DownloadsView.Inspector.Title.downloadStatus) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - CustomToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.EhSettingView.ToolbarItem.Button.done) { - dismiss() - } - } - } - .onAppear { - store.send(.onAppear) - } - } -} - -private struct DownloadListRow: View { - let download: DownloadedGallery - let setting: Setting - let tagTranslator: TagTranslator - let openAction: () -> Void - - var body: some View { - HStack(spacing: 0) { - StaticGalleryDetailCell( - gallery: download.gallery, - resolvedCoverURL: download.coverURL, - setting: setting, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - }, - downloadBadge: download.badge - ) - .allowsHitTesting(false) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture(perform: openAction) - .accessibilityAddTraits(.isButton) - .accessibilityLabel(download.title) - } -} - -private struct DownloadInspectorPageRow: View { - let page: DownloadPageInspection - let retryAction: () -> Void - - private var symbolName: String { - switch page.status { - case .pending: - return "clock" - case .downloaded: - return "checkmark.circle.fill" - case .failed: - return "exclamationmark.circle.fill" - } - } - - private var tint: Color { - switch page.status { - case .pending: - return .secondary - case .downloaded: - return .green - case .failed: - return .red - } - } - - private var subtitle: String { - switch page.status { - case .pending: - return L10n.Localizable.DownloadsView.Inspector.Page.pending - case .downloaded: - return page.relativePath ?? L10n.Localizable.Struct.DownloadBadge.Text.downloaded - case .failed: - return page.failure?.message ?? L10n.Localizable.DownloadsView.Inspector.Page.tapToRetry - } - } - - var body: some View { - Group { - if page.status == .failed { - Button(action: retryAction) { - rowContent - } - .buttonStyle(.plain) - } else { - rowContent - } - } - } - - private var rowContent: some View { - HStack(spacing: 12) { - Image(systemName: symbolName) - .foregroundStyle(tint) - .font(.title3) - VStack(alignment: .leading, spacing: 4) { - Text(L10n.Localizable.DownloadsView.Inspector.Page.title(page.index)) - .font(.body.weight(.medium)) - Text(subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(2) - } - Spacer() - if page.status == .failed { - Image(systemSymbol: .arrowClockwise) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } -} - struct DownloadsView_Previews: PreviewProvider { static var previews: some View { DownloadsView( diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift index 5409d36b1..a64ec8d58 100644 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -71,9 +71,9 @@ struct FavoritesReducer { case onNotLoginViewButtonTapped case fetchGalleries(String? = nil, FavoritesSortOrder? = nil) - case fetchGalleriesDone(Int, Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) + case fetchGalleriesDone(Int, Result) case fetchMoreGalleries - case fetchMoreGalleriesDone(Int, Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) + case fetchMoreGalleriesDone(Int, Result) case fetchDownloadBadges([String]) case fetchDownloadBadgesDone([String: DownloadBadge]) case observeDownloads @@ -139,7 +139,9 @@ struct FavoritesReducer { case .fetchGalleriesDone(let targetFavIndex, let result): state.rawLoadingState[targetFavIndex] = .idle switch result { - case .success(let (pageNumber, sortOrder, galleries)): + case .success(let fetchResult): + let pageNumber = fetchResult.pageNumber + let galleries = fetchResult.galleries guard !galleries.isEmpty else { state.rawLoadingState[targetFavIndex] = .failed(.notFound) guard pageNumber.hasNextPage() else { return .none } @@ -147,7 +149,7 @@ struct FavoritesReducer { } state.rawPageNumber[targetFavIndex] = pageNumber state.rawGalleries[targetFavIndex] = galleries - state.sortOrder = sortOrder + state.sortOrder = fetchResult.sortOrder return .merge( .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), .send(.fetchDownloadBadges(galleries.map(\.gid))) @@ -179,10 +181,12 @@ struct FavoritesReducer { case .fetchMoreGalleriesDone(let targetFavIndex, let result): state.rawFooterLoadingState[targetFavIndex] = .idle switch result { - case .success(let (pageNumber, sortOrder, galleries)): + case .success(let fetchResult): + let pageNumber = fetchResult.pageNumber + let galleries = fetchResult.galleries state.rawPageNumber[targetFavIndex] = pageNumber state.insertGalleries(index: targetFavIndex, galleries: galleries) - state.sortOrder = sortOrder + state.sortOrder = fetchResult.sortOrder var effects: [Effect] = [ .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index e72f4c050..6554db7bb 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -33,57 +33,57 @@ struct FavoritesView: View { var body: some View { NavigationView { let content = - ZStack { - if CookieUtil.didLogin { - GenericList( - galleries: store.galleries ?? [], - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState ?? .idle, - footerLoadingState: store.footerLoadingState ?? .idle, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - }, - downloadBadges: store.downloadBadges + ZStack { + if CookieUtil.didLogin { + GenericList( + galleries: store.galleries ?? [], + setting: setting, + pageNumber: store.pageNumber, + loadingState: store.loadingState ?? .idle, + footerLoadingState: store.footerLoadingState ?? .idle, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + }, + downloadBadges: store.downloadBadges + ) + } else { + NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) + } + } + .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in + QuickSearchView( + store: store.scope(state: \.quickSearchState, action: \.quickSearch) + ) { keyword in + store.send(.setNavigation(nil)) + store.send(.fetchGalleries(keyword)) + } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: tagTranslator.translations, + showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion ) - } else { - NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) } - } - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.fetchGalleries(keyword)) + .onSubmit(of: .search) { + store.send(.fetchGalleries()) } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion - ) - } - .onSubmit(of: .search) { - store.send(.fetchGalleries()) - } - .onAppear { - store.send(.onAppear) - if store.galleries?.isEmpty != false && CookieUtil.didLogin { - DispatchQueue.main.async { - store.send(.fetchGalleries()) + .onAppear { + store.send(.onAppear) + if store.galleries?.isEmpty != false && CookieUtil.didLogin { + DispatchQueue.main.async { + store.send(.fetchGalleries()) + } } } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(navigationTitle) + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(navigationTitle) if DeviceUtil.isPad { content diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index 571b15c76..b49134fb6 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -27,34 +27,34 @@ struct FrontpageView: View { var body: some View { let content = - GenericList( - galleries: store.filteredGalleries, - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState, - footerLoadingState: store.footerLoadingState, - fetchAction: { store.send(.fetchGalleries) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + GenericList( + galleries: store.filteredGalleries, + setting: setting, + pageNumber: store.pageNumber, + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + fetchAction: { store.send(.fetchGalleries) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + } + ) + .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - ) - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) - .autoBlur(radius: blurRadius).environment(\.inSheet, true) - } - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .onAppear { - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries) + .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) + .onAppear { + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries) + } } } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.FrontpageView.Title.frontpage) + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(L10n.Localizable.FrontpageView.Title.frontpage) if DeviceUtil.isPad { content diff --git a/EhPanda/View/Home/History/HistoryView.swift b/EhPanda/View/Home/History/HistoryView.swift index f106ada10..26bdb04c7 100644 --- a/EhPanda/View/Home/History/HistoryView.swift +++ b/EhPanda/View/Home/History/HistoryView.swift @@ -26,31 +26,31 @@ struct HistoryView: View { var body: some View { let content = - GenericList( - galleries: store.filteredGalleries, - setting: setting, - pageNumber: nil, - loadingState: store.loadingState, - footerLoadingState: .idle, - fetchAction: { store.send(.fetchGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - }, - downloadBadges: store.downloadBadges - ) - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .onAppear { - store.send(.onAppear) - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries) + GenericList( + galleries: store.filteredGalleries, + setting: setting, + pageNumber: nil, + loadingState: store.loadingState, + footerLoadingState: .idle, + fetchAction: { store.send(.fetchGalleries) }, + navigateAction: { store.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + }, + downloadBadges: store.downloadBadges + ) + .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) + .onAppear { + store.send(.onAppear) + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries) + } } } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.HistoryView.Title.history) + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(L10n.Localizable.HistoryView.Title.history) if DeviceUtil.isPad { content diff --git a/EhPanda/View/Home/HomeReducer+Body.swift b/EhPanda/View/Home/HomeReducer+Body.swift new file mode 100644 index 000000000..20ec9af75 --- /dev/null +++ b/EhPanda/View/Home/HomeReducer+Body.swift @@ -0,0 +1,230 @@ +// +// HomeReducer+Body.swift +// EhPanda +// + +import SwiftUI +import Kingfisher +import UIImageColors +import ComposableArchitecture + +extension HomeReducer { + @ReducerBuilder + var reducerBody: some Reducer { + BindingReducer() + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none + } + .onChange(of: \.cardPageIndex) { _, state in + guard state.cardPageIndex < state.popularGalleries.count else { return .none } + state.currentCardID = state.popularGalleries[state.cardPageIndex].gid + state.allowsCardHitTesting = false + return .run { send in + try await Task.sleep(for: .milliseconds(300)) + await send(.setAllowsCardHitTesting(true)) + } + } + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .onAppear: + return .send(.observeDownloads) + + case .setNavigation(let route): + state.route = route + return route == nil ? .send(.clearSubStates) : .none + + case .clearSubStates: + state.frontpageState = .init() + state.toplistsState = .init() + state.popularState = .init() + state.watchedState = .init() + state.historyState = .init() + state.detailState.wrappedValue = .init() + return .merge( + .send(.frontpage(.teardown)), + .send(.toplists(.teardown)), + .send(.popular(.teardown)), + .send(.watched(.teardown)), + .send(.detail(.teardown)) + ) + + case .setAllowsCardHitTesting(let isAllowed): + state.allowsCardHitTesting = isAllowed + return .none + + case .fetchAllGalleries: + return .merge( + .send(.fetchPopularGalleries), + .send(.fetchFrontpageGalleries), + .send(.fetchAllToplistsGalleries) + ) + + case .fetchAllToplistsGalleries: + return .merge( + ToplistsType.allCases + .map { Action.fetchToplistsGalleries($0.categoryIndex) } + .map(Effect.send) + ) + + case .fetchPopularGalleries: + guard state.popularLoadingState != .loading else { return .none } + state.popularLoadingState = .loading + state.rawCardColors = [String: [Color]]() + let filter = databaseClient.fetchFilterSynchronously(range: .global) + return .run { send in + let response = await PopularGalleriesRequest(filter: filter).response() + await send(.fetchPopularGalleriesDone(response)) + } + + case .fetchPopularGalleriesDone(let result): + state.popularLoadingState = .idle + switch result { + case .success(let galleries): + guard !galleries.isEmpty else { + state.popularLoadingState = .failed(.notFound) + return .none + } + state.setPopularGalleries(galleries) + return .merge( + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), + .send(.fetchDownloadBadges(galleries.map(\.gid))) + ) + case .failure(let error): + state.popularLoadingState = .failed(error) + } + return .none + + case .fetchFrontpageGalleries: + guard state.frontpageLoadingState != .loading else { return .none } + state.frontpageLoadingState = .loading + let filter = databaseClient.fetchFilterSynchronously(range: .global) + return .run { send in + let response = await FrontpageGalleriesRequest(filter: filter).response() + await send(.fetchFrontpageGalleriesDone(response)) + } + + case .fetchFrontpageGalleriesDone(let result): + state.frontpageLoadingState = .idle + switch result { + case .success(let (_, galleries)): + guard !galleries.isEmpty else { + state.frontpageLoadingState = .failed(.notFound) + return .none + } + state.setFrontpageGalleries(galleries) + return .merge( + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), + .send(.fetchDownloadBadges(galleries.map(\.gid))) + ) + case .failure(let error): + state.frontpageLoadingState = .failed(error) + } + return .none + + case .fetchToplistsGalleries(let index, let pageNum): + guard state.toplistsLoadingState[index] != .loading else { return .none } + state.toplistsLoadingState[index] = .loading + return .run { send in + let response = await ToplistsGalleriesRequest(catIndex: index, pageNum: pageNum).response() + await send(.fetchToplistsGalleriesDone(index, response)) + } + + case .fetchToplistsGalleriesDone(let index, let result): + state.toplistsLoadingState[index] = .idle + switch result { + case .success(let (_, galleries)): + guard !galleries.isEmpty else { + state.toplistsLoadingState[index] = .failed(.notFound) + return .none + } + state.toplistsGalleries[index] = galleries + return .merge( + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), + .send(.fetchDownloadBadges(galleries.map(\.gid))) + ) + case .failure(let error): + state.toplistsLoadingState[index] = .failed(error) + } + return .none + + case .analyzeImageColors(let gid, let result): + guard !state.rawCardColors.keys.contains(gid) else { return .none } + return .run { send in + let colors = await libraryClient.analyzeImageColors(result.image) + await send(.analyzeImageColorsDone(gid, colors)) + } + + case .analyzeImageColorsDone(let gid, let colors): + if let colors = colors { + state.rawCardColors[gid] = [ + colors.primary, colors.secondary, + colors.detail, colors.background + ] + .map(Color.init) + } + return .none + + case .fetchDownloadBadges(let gids): + return .run { send in + await send(.fetchDownloadBadgesDone(await downloadClient.badges(gids))) + } + + case .fetchDownloadBadgesDone(let badges): + state.downloadBadges.merge(badges, uniquingKeysWith: { _, new in new }) + return .none + + case .observeDownloads: + return .run { send in + for await downloads in downloadClient.observeDownloads() { + await send(.observeDownloadsDone(downloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone(let downloads): + let visibleGIDs = state.visibleGalleryIDs + let downloadedGIDs = Set(downloads.map(\.gid)) + let newBadges = [String: DownloadBadge]( + uniqueKeysWithValues: downloads.compactMap { download in + guard visibleGIDs.contains(download.gid) else { return nil } + return (download.gid, download.badge) + } + ) + state.downloadBadges.merge(newBadges, uniquingKeysWith: { _, new in new }) + for gid in state.downloadBadges.keys where !downloadedGIDs.contains(gid) { + state.downloadBadges.removeValue(forKey: gid) + } + return .none + + case .frontpage: + return .none + + case .toplists: + return .none + + case .popular: + return .none + + case .watched: + return .none + + case .history: + return .none + + case .detail: + return .none + } + } + + Scope(state: \.frontpageState, action: \.frontpage, child: FrontpageReducer.init) + Scope(state: \.toplistsState, action: \.toplists, child: ToplistsReducer.init) + Scope(state: \.popularState, action: \.popular, child: PopularReducer.init) + Scope(state: \.watchedState, action: \.watched, child: WatchedReducer.init) + Scope(state: \.historyState, action: \.history, child: HistoryReducer.init) + Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) + } +} diff --git a/EhPanda/View/Home/HomeReducer.swift b/EhPanda/View/Home/HomeReducer.swift index 178ed2e3c..a65d83ef2 100644 --- a/EhPanda/View/Home/HomeReducer.swift +++ b/EhPanda/View/Home/HomeReducer.swift @@ -10,7 +10,7 @@ import ComposableArchitecture @Reducer struct HomeReducer { - private enum CancelID { + enum CancelID { case observeDownloads } @@ -110,225 +110,9 @@ struct HomeReducer { case detail(DetailReducer.Action) } - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.downloadClient) private var downloadClient - @Dependency(\.libraryClient) private var libraryClient + @Dependency(\.databaseClient) var databaseClient + @Dependency(\.downloadClient) var downloadClient + @Dependency(\.libraryClient) var libraryClient - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } - .onChange(of: \.cardPageIndex) { _, state in - guard state.cardPageIndex < state.popularGalleries.count else { return .none } - state.currentCardID = state.popularGalleries[state.cardPageIndex].gid - state.allowsCardHitTesting = false - return .run { send in - try await Task.sleep(for: .milliseconds(300)) - await send(.setAllowsCardHitTesting(true)) - } - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .onAppear: - return .send(.observeDownloads) - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.frontpageState = .init() - state.toplistsState = .init() - state.popularState = .init() - state.watchedState = .init() - state.historyState = .init() - state.detailState.wrappedValue = .init() - return .merge( - .send(.frontpage(.teardown)), - .send(.toplists(.teardown)), - .send(.popular(.teardown)), - .send(.watched(.teardown)), - .send(.detail(.teardown)) - ) - - case .setAllowsCardHitTesting(let isAllowed): - state.allowsCardHitTesting = isAllowed - return .none - - case .fetchAllGalleries: - return .merge( - .send(.fetchPopularGalleries), - .send(.fetchFrontpageGalleries), - .send(.fetchAllToplistsGalleries) - ) - - case .fetchAllToplistsGalleries: - return .merge( - ToplistsType.allCases - .map { Action.fetchToplistsGalleries($0.categoryIndex) } - .map(Effect.send) - ) - - case .fetchPopularGalleries: - guard state.popularLoadingState != .loading else { return .none } - state.popularLoadingState = .loading - state.rawCardColors = [String: [Color]]() - let filter = databaseClient.fetchFilterSynchronously(range: .global) - return .run { send in - let response = await PopularGalleriesRequest(filter: filter).response() - await send(.fetchPopularGalleriesDone(response)) - } - - case .fetchPopularGalleriesDone(let result): - state.popularLoadingState = .idle - switch result { - case .success(let galleries): - guard !galleries.isEmpty else { - state.popularLoadingState = .failed(.notFound) - return .none - } - state.setPopularGalleries(galleries) - return .merge( - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), - .send(.fetchDownloadBadges(galleries.map(\.gid))) - ) - case .failure(let error): - state.popularLoadingState = .failed(error) - } - return .none - - case .fetchFrontpageGalleries: - guard state.frontpageLoadingState != .loading else { return .none } - state.frontpageLoadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .global) - return .run { send in - let response = await FrontpageGalleriesRequest(filter: filter).response() - await send(.fetchFrontpageGalleriesDone(response)) - } - - case .fetchFrontpageGalleriesDone(let result): - state.frontpageLoadingState = .idle - switch result { - case .success(let (_, galleries)): - guard !galleries.isEmpty else { - state.frontpageLoadingState = .failed(.notFound) - return .none - } - state.setFrontpageGalleries(galleries) - return .merge( - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), - .send(.fetchDownloadBadges(galleries.map(\.gid))) - ) - case .failure(let error): - state.frontpageLoadingState = .failed(error) - } - return .none - - case .fetchToplistsGalleries(let index, let pageNum): - guard state.toplistsLoadingState[index] != .loading else { return .none } - state.toplistsLoadingState[index] = .loading - return .run { send in - let response = await ToplistsGalleriesRequest(catIndex: index, pageNum: pageNum).response() - await send(.fetchToplistsGalleriesDone(index, response)) - } - - case .fetchToplistsGalleriesDone(let index, let result): - state.toplistsLoadingState[index] = .idle - switch result { - case .success(let (_, galleries)): - guard !galleries.isEmpty else { - state.toplistsLoadingState[index] = .failed(.notFound) - return .none - } - state.toplistsGalleries[index] = galleries - return .merge( - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), - .send(.fetchDownloadBadges(galleries.map(\.gid))) - ) - case .failure(let error): - state.toplistsLoadingState[index] = .failed(error) - } - return .none - - case .analyzeImageColors(let gid, let result): - guard !state.rawCardColors.keys.contains(gid) else { return .none } - return .run { send in - let colors = await libraryClient.analyzeImageColors(result.image) - await send(.analyzeImageColorsDone(gid, colors)) - } - - case .analyzeImageColorsDone(let gid, let colors): - if let colors = colors { - state.rawCardColors[gid] = [ - colors.primary, colors.secondary, - colors.detail, colors.background - ] - .map(Color.init) - } - return .none - - case .fetchDownloadBadges(let gids): - return .run { send in - await send(.fetchDownloadBadgesDone(await downloadClient.badges(gids))) - } - - case .fetchDownloadBadgesDone(let badges): - state.downloadBadges.merge(badges, uniquingKeysWith: { _, new in new }) - return .none - - case .observeDownloads: - return .run { send in - for await downloads in downloadClient.observeDownloads() { - await send(.observeDownloadsDone(downloads)) - } - } - .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) - - case .observeDownloadsDone(let downloads): - let visibleGIDs = state.visibleGalleryIDs - let downloadedGIDs = Set(downloads.map(\.gid)) - let newBadges = [String: DownloadBadge]( - uniqueKeysWithValues: downloads.compactMap { download in - guard visibleGIDs.contains(download.gid) else { return nil } - return (download.gid, download.badge) - } - ) - state.downloadBadges.merge(newBadges, uniquingKeysWith: { _, new in new }) - for gid in state.downloadBadges.keys where !downloadedGIDs.contains(gid) { - state.downloadBadges.removeValue(forKey: gid) - } - return .none - - case .frontpage: - return .none - - case .toplists: - return .none - - case .popular: - return .none - - case .watched: - return .none - - case .history: - return .none - - case .detail: - return .none - } - } - - Scope(state: \.frontpageState, action: \.frontpage, child: FrontpageReducer.init) - Scope(state: \.toplistsState, action: \.toplists, child: ToplistsReducer.init) - Scope(state: \.popularState, action: \.popular, child: PopularReducer.init) - Scope(state: \.watchedState, action: \.watched, child: WatchedReducer.init) - Scope(state: \.historyState, action: \.history, child: HistoryReducer.init) - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) - } + var body: some Reducer { reducerBody } } diff --git a/EhPanda/View/Home/HomeView+Sections.swift b/EhPanda/View/Home/HomeView+Sections.swift new file mode 100644 index 000000000..7eb9a7119 --- /dev/null +++ b/EhPanda/View/Home/HomeView+Sections.swift @@ -0,0 +1,347 @@ +// +// HomeView+Sections.swift +// EhPanda +// + +import SwiftUI +import Kingfisher +import SwiftUIPager +import SFSafeSymbols + +// MARK: CardSlideSection +struct CardSlideSection: View, Equatable { + @StateObject private var page: Page = .withIndex(1) + @Binding private var pageIndex: Int + + private let galleries: [Gallery] + private let currentID: String + private let colors: [Color] + private let downloadBadges: [String: DownloadBadge] + private let navigateAction: (String) -> Void + private let webImageSuccessAction: (String, RetrieveImageResult) -> Void + + init( + galleries: [Gallery], pageIndex: Binding, currentID: String, + colors: [Color], downloadBadges: [String: DownloadBadge], + navigateAction: @escaping (String) -> Void, + webImageSuccessAction: @escaping (String, RetrieveImageResult) -> Void + ) { + self.galleries = galleries + _pageIndex = pageIndex + self.currentID = currentID + self.colors = colors + self.downloadBadges = downloadBadges + self.navigateAction = navigateAction + self.webImageSuccessAction = webImageSuccessAction + } + + static func == (lhs: CardSlideSection, rhs: CardSlideSection) -> Bool { + lhs.galleries == rhs.galleries + && lhs.currentID == rhs.currentID + && lhs.colors == rhs.colors + && lhs.downloadBadges == rhs.downloadBadges + } + + var body: some View { + Pager(page: page, data: galleries) { gallery in + Button { + navigateAction(gallery.id) + } label: { + GalleryCardCell( + gallery: gallery, + currentID: currentID, + colors: colors, + webImageSuccessAction: { + webImageSuccessAction(gallery.gid, $0) + }, + downloadBadge: downloadBadges[gallery.gid] ?? .none + ) + .tint(.primary) + .multilineTextAlignment(.leading) + } + } + .preferredItemSize(Defaults.FrameSize.cardCellSize) + .interactive(opacity: 0.2).itemSpacing(20) + .loopPages().pagingPriority(.high) + .synchronize($pageIndex, $page.index) + .frame(height: Defaults.FrameSize.cardCellHeight) + } +} + +// MARK: CoverWallSection +struct CoverWallSection: View { + private let galleries: [Gallery] + private let isLoading: Bool + private let downloadBadges: [String: DownloadBadge] + private let navigateAction: (String) -> Void + private let showAllAction: () -> Void + private let reloadAction: () -> Void + + init( + galleries: [Gallery], isLoading: Bool, downloadBadges: [String: DownloadBadge], + navigateAction: @escaping (String) -> Void, + showAllAction: @escaping () -> Void, + reloadAction: @escaping () -> Void + ) { + self.galleries = galleries + self.isLoading = isLoading + self.downloadBadges = downloadBadges + self.navigateAction = navigateAction + self.showAllAction = showAllAction + self.reloadAction = reloadAction + } + + private var dataSource: [[Gallery]] { + var galleries = galleries + if galleries.isEmpty { + galleries = Gallery.mockGalleries(count: 25) + } + if galleries.count % 2 != 0 { galleries = galleries.dropLast() } + return stride(from: 0, to: galleries.count, by: 2).map { index in + [galleries[index], galleries[index + 1]] + } + } + + var body: some View { + SubSection( + title: L10n.Localizable.HomeView.Section.Title.frontpage, + tint: .secondary, isLoading: isLoading, + reloadAction: reloadAction, + showAllAction: showAllAction + ) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + ForEach(dataSource, id: \.first) { + VerticalCoverStack( + galleries: $0, + downloadBadges: downloadBadges, + navigateAction: navigateAction + ) + } + .withHorizontalSpacing(width: 0) + } + } + .frame(height: Defaults.ImageSize.rowH * 2 + 30) + } + } +} + +struct VerticalCoverStack: View { + private let downloadStore = DownloadBadgeStore.shared + + private let galleries: [Gallery] + private let downloadBadges: [String: DownloadBadge] + private let navigateAction: (String) -> Void + + init( + galleries: [Gallery], + downloadBadges: [String: DownloadBadge], + navigateAction: @escaping (String) -> Void + ) { + self.galleries = galleries + self.downloadBadges = downloadBadges + self.navigateAction = navigateAction + } + + private func placeholder() -> some View { + Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) + } + private func imageContainer(gallery: Gallery) -> some View { + Button { + navigateAction(gallery.id) + } label: { + KFImage(downloadStore.resolvedCoverURL(for: gallery)) + .placeholder(placeholder) + .defaultModifier() + .scaledToFill() + .frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH).cornerRadius(2) + .overlay(alignment: .topTrailing) { + DownloadBadgeLabel( + badge: downloadBadges[gallery.gid] ?? .none, + compact: true + ) + .padding(6) + } + } + } + + var body: some View { + VStack(spacing: 20) { + ForEach(galleries, content: imageContainer) + } + } +} + +// MARK: ToplistsSection +struct ToplistsSection: View { + private let galleries: [Int: [Gallery]] + private let isLoading: Bool + private let downloadBadges: [String: DownloadBadge] + private let navigateAction: (String) -> Void + private let showAllAction: () -> Void + private let reloadAction: () -> Void + + init( + galleries: [Int: [Gallery]], isLoading: Bool, downloadBadges: [String: DownloadBadge], + navigateAction: @escaping (String) -> Void, + showAllAction: @escaping () -> Void, + reloadAction: @escaping () -> Void + ) { + self.galleries = galleries + self.isLoading = isLoading + self.downloadBadges = downloadBadges + self.navigateAction = navigateAction + self.showAllAction = showAllAction + self.reloadAction = reloadAction + } + + private var dataSource: [Int: [Gallery]] { + guard !galleries.isEmpty else { + var dictionary = [Int: [Gallery]]() + var gallery: Gallery = .empty + gallery.title = "......" + gallery.uploader = "......" + let galleries = Array(repeating: gallery, count: 6) + + ToplistsType.allCases.forEach { type in + dictionary[type.categoryIndex] = galleries + } + return dictionary + } + return galleries + } + private func galleries(type: ToplistsType, range: ClosedRange) -> [Gallery] { + let galleries = dataSource[type.categoryIndex] ?? [] + guard galleries.count > range.upperBound else { return [] } + return Array(galleries[range]) + } + + var body: some View { + SubSection( + title: L10n.Localizable.HomeView.Section.Title.toplists, + tint: .secondary, isLoading: isLoading, + reloadAction: reloadAction, + showAllAction: showAllAction + ) { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(ToplistsType.allCases, content: verticalStacks) + } + } + } + } + private func verticalStacks(type: ToplistsType) -> some View { + VStack(alignment: .leading) { + Text(type.value).font(.subheadline.bold()) + HStack { + VerticalToplistsStack( + galleries: galleries(type: type, range: 0...2), startRanking: 1, + downloadBadges: downloadBadges, + navigateAction: navigateAction + ) + if DeviceUtil.isPad { + VerticalToplistsStack( + galleries: galleries(type: type, range: 3...5), startRanking: 4, + downloadBadges: downloadBadges, + navigateAction: navigateAction + ) + } + } + } + .padding(.horizontal, 20).padding(.vertical, 5) + } +} + +struct VerticalToplistsStack: View { + private let galleries: [Gallery] + private let startRanking: Int + private let downloadBadges: [String: DownloadBadge] + private let navigateAction: (String) -> Void + + init( + galleries: [Gallery], + startRanking: Int, + downloadBadges: [String: DownloadBadge], + navigateAction: @escaping (String) -> Void + ) { + self.galleries = galleries + self.startRanking = startRanking + self.downloadBadges = downloadBadges + self.navigateAction = navigateAction + } + + var body: some View { + VStack(spacing: 10) { + ForEach(0.. Void + + init(navigateAction: @escaping (HomeMiscGridType) -> Void) { + self.navigateAction = navigateAction + } + + var body: some View { + SubSection(title: L10n.Localizable.HomeView.Section.Title.other, showAll: false) { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + let types = HomeMiscGridType.allCases + ForEach(types) { type in + Button { + navigateAction(type) + } label: { + MiscGridItem(title: type.title, symbol: type.symbol).tint(.primary) + } + .padding(.trailing, type == types.last ? 0 : 10) + } + .withHorizontalSpacing() + } + } + } + } +} + +struct MiscGridItem: View { + private let title: String + private let subTitle: String? + private let symbol: SFSymbol + + init(title: String, subTitle: String? = nil, symbol: SFSymbol) { + self.title = title + self.subTitle = subTitle + self.symbol = symbol + } + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text(title).font(.title2.bold()).lineLimit(1).frame(minWidth: 100) + if let subTitle = subTitle { + Text(subTitle).font(.subheadline).foregroundColor(.secondary).lineLimit(2) + } + } + Image(systemSymbol: symbol).font(.system(size: 50, weight: .light, design: .default)) + .foregroundColor(.secondary).imageScale(.large).offset(x: 20, y: 20) + } + .padding(30).cornerRadius(15).background(Color(.systemGray6).cornerRadius(15)) + } +} diff --git a/EhPanda/View/Home/HomeView.swift b/EhPanda/View/Home/HomeView.swift index 25f548798..25fefcef4 100644 --- a/EhPanda/View/Home/HomeView.swift +++ b/EhPanda/View/Home/HomeView.swift @@ -5,7 +5,6 @@ import SwiftUI import Kingfisher -import SwiftUIPager import SFSafeSymbols import ComposableArchitecture @@ -31,74 +30,74 @@ struct HomeView: View { var body: some View { NavigationView { let content = - ZStack { - ScrollView(showsIndicators: false) { - VStack { - if !store.popularGalleries.isEmpty { - CardSlideSection( - galleries: store.popularGalleries, - pageIndex: $store.cardPageIndex, - currentID: store.currentCardID, - colors: store.cardColors, - downloadBadges: store.downloadBadges, - navigateAction: navigateTo(gid:), - webImageSuccessAction: { gid, result in - store.send(.analyzeImageColors(gid, result)) + ZStack { + ScrollView(showsIndicators: false) { + VStack { + if !store.popularGalleries.isEmpty { + CardSlideSection( + galleries: store.popularGalleries, + pageIndex: $store.cardPageIndex, + currentID: store.currentCardID, + colors: store.cardColors, + downloadBadges: store.downloadBadges, + navigateAction: navigateTo(gid:), + webImageSuccessAction: { gid, result in + store.send(.analyzeImageColors(gid, result)) + } + ) + .equatable().allowsHitTesting(store.allowsCardHitTesting) + } + Group { + if store.frontpageGalleries.count > 1 { + CoverWallSection( + galleries: store.frontpageGalleries, + isLoading: store.frontpageLoadingState == .loading, + downloadBadges: store.downloadBadges, + navigateAction: navigateTo(gid:), + showAllAction: { store.send(.setNavigation(.section(.frontpage))) }, + reloadAction: { store.send(.fetchFrontpageGalleries) } + ) } - ) - .equatable().allowsHitTesting(store.allowsCardHitTesting) - } - Group { - if store.frontpageGalleries.count > 1 { - CoverWallSection( - galleries: store.frontpageGalleries, - isLoading: store.frontpageLoadingState == .loading, + ToplistsSection( + galleries: store.toplistsGalleries, + isLoading: !store.toplistsLoadingState + .values.allSatisfy({ $0 != .loading }), downloadBadges: store.downloadBadges, navigateAction: navigateTo(gid:), - showAllAction: { store.send(.setNavigation(.section(.frontpage))) }, - reloadAction: { store.send(.fetchFrontpageGalleries) } + showAllAction: { store.send(.setNavigation(.section(.toplists))) }, + reloadAction: { store.send(.fetchAllToplistsGalleries) } ) + MiscGridSection(navigateAction: navigateTo(type:)) } - ToplistsSection( - galleries: store.toplistsGalleries, - isLoading: !store.toplistsLoadingState - .values.allSatisfy({ $0 != .loading }), - downloadBadges: store.downloadBadges, - navigateAction: navigateTo(gid:), - showAllAction: { store.send(.setNavigation(.section(.toplists))) }, - reloadAction: { store.send(.fetchAllToplistsGalleries) } - ) - MiscGridSection(navigateAction: navigateTo(type:)) + .padding(.vertical) } - .padding(.vertical) } - } - .opacity(store.popularGalleries.isEmpty ? 0 : 1).zIndex(2) + .opacity(store.popularGalleries.isEmpty ? 0 : 1).zIndex(2) - LoadingView() - .opacity( - store.popularLoadingState == .loading - && store.popularGalleries.isEmpty ? 1 : 0 - ) - .zIndex(0) + LoadingView() + .opacity( + store.popularLoadingState == .loading + && store.popularGalleries.isEmpty ? 1 : 0 + ) + .zIndex(0) - let error = store.popularLoadingState.failed - ErrorView(error: error ?? .unknown) { - store.send(.fetchAllGalleries) + let error = store.popularLoadingState.failed + ErrorView(error: error ?? .unknown) { + store.send(.fetchAllGalleries) + } + .opacity(store.popularGalleries.isEmpty && error != nil ? 1 : 0) + .zIndex(1) } - .opacity(store.popularGalleries.isEmpty && error != nil ? 1 : 0) - .zIndex(1) - } - .animation(.default, value: store.popularLoadingState) - .onAppear { - store.send(.onAppear) - if store.popularGalleries.isEmpty { - store.send(.fetchAllGalleries) + .animation(.default, value: store.popularLoadingState) + .onAppear { + store.send(.onAppear) + if store.popularGalleries.isEmpty { + store.send(.fetchAllGalleries) + } } - } - .background(navigationLinks) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.HomeView.Title.home) + .background(navigationLinks) + .toolbar(content: toolbar) + .navigationTitle(L10n.Localizable.HomeView.Title.home) if DeviceUtil.isPad { content @@ -194,344 +193,6 @@ private extension HomeView { } } -// MARK: CardSlideSection -private struct CardSlideSection: View, Equatable { - @StateObject private var page: Page = .withIndex(1) - @Binding private var pageIndex: Int - - private let galleries: [Gallery] - private let currentID: String - private let colors: [Color] - private let downloadBadges: [String: DownloadBadge] - private let navigateAction: (String) -> Void - private let webImageSuccessAction: (String, RetrieveImageResult) -> Void - - init( - galleries: [Gallery], pageIndex: Binding, currentID: String, - colors: [Color], downloadBadges: [String: DownloadBadge], - navigateAction: @escaping (String) -> Void, - webImageSuccessAction: @escaping (String, RetrieveImageResult) -> Void - ) { - self.galleries = galleries - _pageIndex = pageIndex - self.currentID = currentID - self.colors = colors - self.downloadBadges = downloadBadges - self.navigateAction = navigateAction - self.webImageSuccessAction = webImageSuccessAction - } - - static func == (lhs: CardSlideSection, rhs: CardSlideSection) -> Bool { - lhs.galleries == rhs.galleries - && lhs.currentID == rhs.currentID - && lhs.colors == rhs.colors - && lhs.downloadBadges == rhs.downloadBadges - } - - var body: some View { - Pager(page: page, data: galleries) { gallery in - Button { - navigateAction(gallery.id) - } label: { - GalleryCardCell( - gallery: gallery, - currentID: currentID, - colors: colors, - webImageSuccessAction: { - webImageSuccessAction(gallery.gid, $0) - }, - downloadBadge: downloadBadges[gallery.gid] ?? .none - ) - .tint(.primary) - .multilineTextAlignment(.leading) - } - } - .preferredItemSize(Defaults.FrameSize.cardCellSize) - .interactive(opacity: 0.2).itemSpacing(20) - .loopPages().pagingPriority(.high) - .synchronize($pageIndex, $page.index) - .frame(height: Defaults.FrameSize.cardCellHeight) - } -} - -// MARK: CoverWallSection -private struct CoverWallSection: View { - private let galleries: [Gallery] - private let isLoading: Bool - private let downloadBadges: [String: DownloadBadge] - private let navigateAction: (String) -> Void - private let showAllAction: () -> Void - private let reloadAction: () -> Void - - init( - galleries: [Gallery], isLoading: Bool, downloadBadges: [String: DownloadBadge], - navigateAction: @escaping (String) -> Void, - showAllAction: @escaping () -> Void, - reloadAction: @escaping () -> Void - ) { - self.galleries = galleries - self.isLoading = isLoading - self.downloadBadges = downloadBadges - self.navigateAction = navigateAction - self.showAllAction = showAllAction - self.reloadAction = reloadAction - } - - private var dataSource: [[Gallery]] { - var galleries = galleries - if galleries.isEmpty { - galleries = Gallery.mockGalleries(count: 25) - } - if galleries.count % 2 != 0 { galleries = galleries.dropLast() } - return stride(from: 0, to: galleries.count, by: 2).map { index in - [galleries[index], galleries[index + 1]] - } - } - - var body: some View { - SubSection( - title: L10n.Localizable.HomeView.Section.Title.frontpage, - tint: .secondary, isLoading: isLoading, - reloadAction: reloadAction, - showAllAction: showAllAction - ) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 20) { - ForEach(dataSource, id: \.first) { - VerticalCoverStack( - galleries: $0, - downloadBadges: downloadBadges, - navigateAction: navigateAction - ) - } - .withHorizontalSpacing(width: 0) - } - } - .frame(height: Defaults.ImageSize.rowH * 2 + 30) - } - } -} - -private struct VerticalCoverStack: View { - private let downloadStore = DownloadBadgeStore.shared - - private let galleries: [Gallery] - private let downloadBadges: [String: DownloadBadge] - private let navigateAction: (String) -> Void - - init( - galleries: [Gallery], - downloadBadges: [String: DownloadBadge], - navigateAction: @escaping (String) -> Void - ) { - self.galleries = galleries - self.downloadBadges = downloadBadges - self.navigateAction = navigateAction - } - - private func placeholder() -> some View { - Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) - } - private func imageContainer(gallery: Gallery) -> some View { - Button { - navigateAction(gallery.id) - } label: { - KFImage(downloadStore.resolvedCoverURL(for: gallery)) - .placeholder(placeholder) - .defaultModifier() - .scaledToFill() - .frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH).cornerRadius(2) - .overlay(alignment: .topTrailing) { - DownloadBadgeLabel( - badge: downloadBadges[gallery.gid] ?? .none, - compact: true - ) - .padding(6) - } - } - } - - var body: some View { - VStack(spacing: 20) { - ForEach(galleries, content: imageContainer) - } - } -} - -// MARK: ToplistsSection -private struct ToplistsSection: View { - private let galleries: [Int: [Gallery]] - private let isLoading: Bool - private let downloadBadges: [String: DownloadBadge] - private let navigateAction: (String) -> Void - private let showAllAction: () -> Void - private let reloadAction: () -> Void - - init( - galleries: [Int: [Gallery]], isLoading: Bool, downloadBadges: [String: DownloadBadge], - navigateAction: @escaping (String) -> Void, - showAllAction: @escaping () -> Void, - reloadAction: @escaping () -> Void - ) { - self.galleries = galleries - self.isLoading = isLoading - self.downloadBadges = downloadBadges - self.navigateAction = navigateAction - self.showAllAction = showAllAction - self.reloadAction = reloadAction - } - - private var dataSource: [Int: [Gallery]] { - guard !galleries.isEmpty else { - var dictionary = [Int: [Gallery]]() - var gallery: Gallery = .empty - gallery.title = "......" - gallery.uploader = "......" - let galleries = Array(repeating: gallery, count: 6) - - ToplistsType.allCases.forEach { type in - dictionary[type.categoryIndex] = galleries - } - return dictionary - } - return galleries - } - private func galleries(type: ToplistsType, range: ClosedRange) -> [Gallery] { - let galleries = dataSource[type.categoryIndex] ?? [] - guard galleries.count > range.upperBound else { return [] } - return Array(galleries[range]) - } - - var body: some View { - SubSection( - title: L10n.Localizable.HomeView.Section.Title.toplists, - tint: .secondary, isLoading: isLoading, - reloadAction: reloadAction, - showAllAction: showAllAction - ) { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(ToplistsType.allCases, content: verticalStacks) - } - } - } - } - private func verticalStacks(type: ToplistsType) -> some View { - VStack(alignment: .leading) { - Text(type.value).font(.subheadline.bold()) - HStack { - VerticalToplistsStack( - galleries: galleries(type: type, range: 0...2), startRanking: 1, - downloadBadges: downloadBadges, - navigateAction: navigateAction - ) - if DeviceUtil.isPad { - VerticalToplistsStack( - galleries: galleries(type: type, range: 3...5), startRanking: 4, - downloadBadges: downloadBadges, - navigateAction: navigateAction - ) - } - } - } - .padding(.horizontal, 20).padding(.vertical, 5) - } -} - -private struct VerticalToplistsStack: View { - private let galleries: [Gallery] - private let startRanking: Int - private let downloadBadges: [String: DownloadBadge] - private let navigateAction: (String) -> Void - - init( - galleries: [Gallery], - startRanking: Int, - downloadBadges: [String: DownloadBadge], - navigateAction: @escaping (String) -> Void - ) { - self.galleries = galleries - self.startRanking = startRanking - self.downloadBadges = downloadBadges - self.navigateAction = navigateAction - } - - var body: some View { - VStack(spacing: 10) { - ForEach(0.. Void - - init(navigateAction: @escaping (HomeMiscGridType) -> Void) { - self.navigateAction = navigateAction - } - - var body: some View { - SubSection(title: L10n.Localizable.HomeView.Section.Title.other, showAll: false) { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - let types = HomeMiscGridType.allCases - ForEach(types) { type in - Button { - navigateAction(type) - } label: { - MiscGridItem(title: type.title, symbol: type.symbol).tint(.primary) - } - .padding(.trailing, type == types.last ? 0 : 10) - } - .withHorizontalSpacing() - } - } - } - } -} - -private struct MiscGridItem: View { - private let title: String - private let subTitle: String? - private let symbol: SFSymbol - - init(title: String, subTitle: String? = nil, symbol: SFSymbol) { - self.title = title - self.subTitle = subTitle - self.symbol = symbol - } - - var body: some View { - HStack { - VStack(alignment: .leading) { - Text(title).font(.title2.bold()).lineLimit(1).frame(minWidth: 100) - if let subTitle = subTitle { - Text(subTitle).font(.subheadline).foregroundColor(.secondary).lineLimit(2) - } - } - Image(systemSymbol: symbol).font(.system(size: 50, weight: .light, design: .default)) - .foregroundColor(.secondary).imageScale(.large).offset(x: 20, y: 20) - } - .padding(30).cornerRadius(15).background(Color(.systemGray6).cornerRadius(15)) - } -} - // MARK: Definition enum HomeMiscGridType: CaseIterable, Identifiable { var id: String { title } diff --git a/EhPanda/View/Home/Popular/PopularView.swift b/EhPanda/View/Home/Popular/PopularView.swift index 3c7e3c56d..e98b7c75d 100644 --- a/EhPanda/View/Home/Popular/PopularView.swift +++ b/EhPanda/View/Home/Popular/PopularView.swift @@ -26,32 +26,32 @@ struct PopularView: View { var body: some View { let content = - GenericList( - galleries: store.filteredGalleries, - setting: setting, pageNumber: nil, - loadingState: store.loadingState, - footerLoadingState: .idle, - fetchAction: { store.send(.fetchGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + GenericList( + galleries: store.filteredGalleries, + setting: setting, pageNumber: nil, + loadingState: store.loadingState, + footerLoadingState: .idle, + fetchAction: { store.send(.fetchGalleries) }, + navigateAction: { store.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + } + ) + .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - ) - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) - .autoBlur(radius: blurRadius).environment(\.inSheet, true) - } - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .onAppear { - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries) + .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) + .onAppear { + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries) + } } } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.PopularView.Title.popular) + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(L10n.Localizable.PopularView.Title.popular) if DeviceUtil.isPad { content diff --git a/EhPanda/View/Home/Toplists/ToplistsView.swift b/EhPanda/View/Home/Toplists/ToplistsView.swift index 0f53cb03e..26a3881b7 100644 --- a/EhPanda/View/Home/Toplists/ToplistsView.swift +++ b/EhPanda/View/Home/Toplists/ToplistsView.swift @@ -30,39 +30,39 @@ struct ToplistsView: View { var body: some View { let content = - GenericList( - galleries: store.filteredGalleries ?? [], - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState ?? .idle, - footerLoadingState: store.footerLoadingState ?? .idle, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - .jumpPageAlert( - index: $store.jumpPageIndex, - isPresented: $store.jumpPageAlertPresented, - isFocused: $store.jumpPageAlertFocused, - pageNumber: store.pageNumber ?? .init(), - jumpAction: { store.send(.performJumpPage) } - ) - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .navigationBarBackButtonHidden(store.jumpPageAlertPresented) - .animation(.default, value: store.jumpPageAlertPresented) - .onAppear { - if store.galleries?.isEmpty != false { - DispatchQueue.main.async { - store.send(.fetchGalleries()) + GenericList( + galleries: store.filteredGalleries ?? [], + setting: setting, + pageNumber: store.pageNumber, + loadingState: store.loadingState ?? .idle, + footerLoadingState: store.footerLoadingState ?? .idle, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + } + ) + .jumpPageAlert( + index: $store.jumpPageIndex, + isPresented: $store.jumpPageAlertPresented, + isFocused: $store.jumpPageAlertFocused, + pageNumber: store.pageNumber ?? .init(), + jumpAction: { store.send(.performJumpPage) } + ) + .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) + .navigationBarBackButtonHidden(store.jumpPageAlertPresented) + .animation(.default, value: store.jumpPageAlertPresented) + .onAppear { + if store.galleries?.isEmpty != false { + DispatchQueue.main.async { + store.send(.fetchGalleries()) + } } } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(navigationTitle) + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(navigationTitle) if DeviceUtil.isPad { content diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift index ef3b1cedb..b2824d7cc 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -26,61 +26,61 @@ struct WatchedView: View { var body: some View { let content = - ZStack { - if CookieUtil.didLogin { - GenericList( - galleries: store.galleries, - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState, - footerLoadingState: store.footerLoadingState, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - }, - downloadBadges: store.downloadBadges + ZStack { + if CookieUtil.didLogin { + GenericList( + galleries: store.galleries, + setting: setting, + pageNumber: store.pageNumber, + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + }, + downloadBadges: store.downloadBadges + ) + } else { + NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) + } + } + .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in + QuickSearchView( + store: store.scope(state: \.quickSearchState, action: \.quickSearch) + ) { keyword in + store.send(.setNavigation(nil)) + store.send(.fetchGalleries(keyword)) + } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: tagTranslator.translations, + showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion ) - } else { - NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) } - } - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.fetchGalleries(keyword)) + .onSubmit(of: .search) { + store.send(.fetchGalleries()) } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) - .autoBlur(radius: blurRadius).environment(\.inSheet, true) - } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion - ) - } - .onSubmit(of: .search) { - store.send(.fetchGalleries()) - } - .onAppear { - store.send(.onAppear) - if store.galleries.isEmpty && CookieUtil.didLogin { - DispatchQueue.main.async { - store.send(.fetchGalleries()) + .onAppear { + store.send(.onAppear) + if store.galleries.isEmpty && CookieUtil.didLogin { + DispatchQueue.main.async { + store.send(.fetchGalleries()) + } } } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.WatchedView.Title.watched) + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(L10n.Localizable.WatchedView.Title.watched) if DeviceUtil.isPad { content diff --git a/EhPanda/View/Reading/ReadingReducer+Body.swift b/EhPanda/View/Reading/ReadingReducer+Body.swift new file mode 100644 index 000000000..59d9f76da --- /dev/null +++ b/EhPanda/View/Reading/ReadingReducer+Body.swift @@ -0,0 +1,315 @@ +// +// ReadingReducer+Body.swift +// EhPanda + +import SwiftUI +import Kingfisher +import TTProgressHUD +import ComposableArchitecture + +// MARK: - CancelID +enum ReadingCancelID: CaseIterable { + case fetchImage + case fetchDatabaseInfos + case observeDownloads + case loadLocalPageURLs + case fetchPreviewURLs + case fetchThumbnailURLs + case fetchNormalImageURLs + case refetchNormalImageURLs + case fetchMPVKeys + case fetchMPVImageURL +} + +// MARK: - Reducer Body +extension ReadingReducer { + @ReducerBuilder + func makeBody() -> some Reducer { + BindingReducer() + .onChange(of: \.showsSliderPreview) { _, _ in + .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) + } + mainReducer + } + + var mainReducer: some Reducer { + Reduce { state, action in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .toggleShowsPanel: + state.showsPanel.toggle() + return .none + + case .setOrientationPortrait(let isPortrait): + return reduceOrientation(isPortrait: isPortrait) + + case .onPerformDismiss: + return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) + + case .onAppear(let gid, let enablesLandscape): + return reduceOnAppear(gid: gid, enablesLandscape: enablesLandscape) + + case .onWebImageRetry(let index): + state.imageURLLoadingStates[index] = .idle + return .none + + case .onWebImageSucceeded(let index): + return reduceWebImageSucceeded(state: &state, index: index) + + case .onWebImageFailed(let index): + state.imageURLLoadingStates[index] = .failed(.webImageFailed) + return .none + + case .reloadAllWebImages: + return reduceReloadAllWebImages(state: &state) + + case .retryAllFailedWebImages: + return reduceRetryAllFailedWebImages(state: &state) + + case .copyImage(let imageURL): + return .send(.fetchImage(.copy(imageURL.isAnimatedImage), imageURL)) + + case .saveImage(let imageURL): + return .send(.fetchImage(.save(imageURL.isAnimatedImage), imageURL)) + + case .saveImageDone(let isSucceeded): + state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error + return .send(.setNavigation(.hud)) + + case .shareImage(let imageURL): + return .send(.fetchImage(.share(imageURL.isAnimatedImage), imageURL)) + + case .fetchImage(let action, let imageURL): + return .run { send in + let result = await imageClient.fetchImage(url: imageURL) + await send(.fetchImageDone(action, result)) + } + .cancellable(id: ReadingCancelID.fetchImage) + + case .fetchImageDone(let action, let result): + return reduceFetchImageDone(state: &state, action: action, result: result) + + case .syncReadingProgress(let progress): + return .run { [state] _ in + await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) + } + + case .syncPreviewURLs(let previewURLs): + guard state.contentSource == .remote else { return .none } + return .run { [state] _ in + await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) + } + + case .syncThumbnailURLs(let thumbnailURLs): + guard state.contentSource == .remote else { return .none } + return .run { [state] _ in + await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) + } + + case .syncImageURLs(let imageURLs, let originalImageURLs): + guard state.contentSource == .remote else { return .none } + return .run { [state] _ in + await databaseClient.updateImageURLs( + gid: state.gallery.id, + imageURLs: imageURLs, + originalImageURLs: originalImageURLs + ) + } + + case .teardown: + return reduceTeardown() + + case .fetchDatabaseInfos(let gid): + return reduceFetchDatabaseInfos(state: &state, gid: gid) + + case .fetchDatabaseInfosDone(let galleryState): + return reduceFetchDatabaseInfosDone(state: &state, galleryState: galleryState) + + case .observeDownloads(let gid): + return reduceObserveDownloads(gid: gid) + + case .observeDownloadsDone: + guard state.gallery.id.isValidGID else { return .none } + return .send(.loadLocalPageURLs(state.gallery.id)) + + case .loadLocalPageURLs(let gid): + return reduceLoadLocalPageURLs(state: &state, gid: gid) + + case .loadLocalPageURLsDone(let requestID, let localPageURLs): + return reduceLoadLocalPageURLsDone( + state: &state, requestID: requestID, localPageURLs: localPageURLs + ) + + case .fetchPreviewURLs(let index): + return reduceFetchPreviewURLs(state: &state, index: index) + + case .fetchPreviewURLsDone(let index, let result): + return reduceFetchPreviewURLsDone(state: &state, index: index, result: result) + + case .fetchImageURLs(let index): + return reduceFetchImageURLs(state: &state, index: index) + + case .refetchImageURLs(let index): + return reduceRefetchImageURLs(state: &state, index: index) + + case .prefetchImages(let index, let prefetchLimit): + return reducePrefetchImages(state: &state, index: index, prefetchLimit: prefetchLimit) + + case .fetchThumbnailURLs(let index): + return reduceFetchThumbnailURLs(state: &state, index: index) + + case .fetchThumbnailURLsDone(let index, let result): + return reduceFetchThumbnailURLsDone(state: &state, index: index, result: result) + + case .fetchNormalImageURLs(let index, let thumbnailURLs): + return reduceFetchNormalImageURLs( + state: &state, index: index, thumbnailURLs: thumbnailURLs + ) + + case .fetchNormalImageURLsDone(let index, let result): + return reduceFetchNormalImageURLsDone(state: &state, index: index, result: result) + + case .refetchNormalImageURLs(let index): + return reduceRefetchNormalImageURLs(state: &state, index: index) + + case .refetchNormalImageURLsDone(let index, let result): + return reduceRefetchNormalImageURLsDone(state: &state, index: index, result: result) + + case .fetchMPVKeys(let index, let mpvURL): + return reduceFetchMPVKeys(state: &state, index: index, mpvURL: mpvURL) + + case .fetchMPVKeysDone(let index, let result): + return reduceFetchMPVKeysDone(state: &state, index: index, result: result) + + case .fetchMPVImageURL(let index, let isRefresh): + return reduceFetchMPVImageURL(state: &state, index: index, isRefresh: isRefresh) + + case .fetchMPVImageURLDone(let index, let result): + return reduceFetchMPVImageURLDone(state: &state, index: index, result: result) + + case .captureCachedPage(let index): + return reduceCaptureCachedPage(state: &state, index: index) + } + } + .haptics( + unwrapping: \.route, + case: \.readingSetting, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: \.share, + hapticsClient: hapticsClient + ) + } +} + +// MARK: - UI Actions +extension ReadingReducer { + func reduceOrientation(isPortrait: Bool) -> Effect { + var effects = [Effect]() + if isPortrait { + effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) + effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientation() })) + } else { + effects.append(.run(operation: { _ in appDelegateClient.setAllOrientationMask() })) + } + return .merge(effects) + } + + func reduceOnAppear(gid: String, enablesLandscape: Bool) -> Effect { + var effects: [Effect] = [ + .send(.fetchDatabaseInfos(gid)), + .send(.observeDownloads(gid)), + .send(.loadLocalPageURLs(gid)) + ] + if enablesLandscape { + effects.append(.send(.setOrientationPortrait(false))) + } + return .merge(effects) + } + + func reduceWebImageSucceeded(state: inout State, index: Int) -> Effect { + state.imageURLLoadingStates[index] = .idle + state.webImageLoadSuccessIndices.insert(index) + guard state.contentSource == .remote, + state.gallery.id.isValidGID, + state.localPageURLs[index] == nil + else { + return .none + } + return .send(.captureCachedPage(index)) + } + + func reduceReloadAllWebImages(state: inout State) -> Effect { + guard state.contentSource == .remote else { + if case .local(let download, let manifest) = state.contentSource { + applyLocalSource(state: &state, download: download, manifest: manifest) + } + return .none + } + state.previewURLs = .init() + state.thumbnailURLs = .init() + state.imageURLs = .init() + state.originalImageURLs = .init() + state.mpvKey = nil + state.mpvImageKeys = .init() + state.mpvSkipServerIdentifiers = .init() + state.forceRefreshID = .init() + return .run { [state] _ in + await databaseClient.removeImageURLs(gid: state.gallery.id) + } + } + + func reduceRetryAllFailedWebImages(state: inout State) -> Effect { + guard state.contentSource == .remote else { return .none } + state.imageURLLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.imageURLLoadingStates[index] = .idle + } + } + state.previewLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.previewLoadingStates[index] = .idle + } + } + return .none + } + + func reduceFetchImageDone( + state: inout State, + action: ImageAction, + result: Result + ) -> Effect { + if case .success(let image) = result { + switch action { + case .copy(let isAnimated): + state.hudConfig = .copiedToClipboardSucceeded + return .merge( + .send(.setNavigation(.hud)), + .run(operation: { _ in clipboardClient.saveImage(image, isAnimated) }) + ) + case .save(let isAnimated): + return .run { send in + let success = await imageClient.saveImageToPhotoLibrary(image, isAnimated) + await send(.saveImageDone(success)) + } + case .share(let isAnimated): + if isAnimated, let data = image.kf.data(format: .GIF) { + return .send(.setNavigation(.share(.init(value: .data(data))))) + } else { + return .send(.setNavigation(.share(.init(value: .image(image))))) + } + } + } else { + state.hudConfig = .error + return .send(.setNavigation(.hud)) + } + } +} diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift new file mode 100644 index 000000000..74a380db3 --- /dev/null +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -0,0 +1,151 @@ +// +// ReadingReducer+Database.swift +// EhPanda + +import SwiftUI +import ComposableArchitecture + +// MARK: - Database & Download Actions +extension ReadingReducer { + func reduceTeardown() -> Effect { + var effects: [Effect] = [ + .merge(ReadingCancelID.allCases.map(Effect.cancel(id:))) + ] + if !deviceClient.isPad() { + effects.append(.send(.setOrientationPortrait(true))) + } + return .merge(effects) + } + + func reduceFetchDatabaseInfos(state: inout State, gid: String) -> Effect { + if case .local(let download, let manifest) = state.contentSource { + applyLocalSource(state: &state, download: download, manifest: manifest) + } else { + guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } + state.gallery = gallery + state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) + } + return .run { [state] send in + guard let dbState = await databaseClient.fetchGalleryState(gid: state.gallery.id) else { return } + await send(.fetchDatabaseInfosDone(dbState)) + } + .cancellable(id: ReadingCancelID.fetchDatabaseInfos) + } + + func reduceFetchDatabaseInfosDone(state: inout State, galleryState: GalleryState) -> Effect { + if state.contentSource == .remote { + if let previewConfig = galleryState.previewConfig { + state.previewConfig = previewConfig + } + state.previewURLs = galleryState.previewURLs + state.imageURLs = galleryState.imageURLs + state.thumbnailURLs = galleryState.thumbnailURLs + state.originalImageURLs = galleryState.originalImageURLs + } + state.readingProgress = galleryState.readingProgress + state.databaseLoadingState = .idle + return .none + } + + func reduceObserveDownloads(gid: String) -> Effect { + guard gid.isValidGID else { return .none } + return .run { send in + var previousRelevantDownloads = [DownloadedGallery]() + var hadRelevantDownloads = false + for await downloads in downloadClient.observeDownloads() { + let relevantDownloads = downloads.filter { $0.gid == gid } + let hasRelevantDownloads = !relevantDownloads.isEmpty + guard hasRelevantDownloads || hadRelevantDownloads else { continue } + if relevantDownloads == previousRelevantDownloads { + hadRelevantDownloads = hasRelevantDownloads + continue + } + previousRelevantDownloads = relevantDownloads + hadRelevantDownloads = hasRelevantDownloads + await send(.observeDownloadsDone(relevantDownloads)) + } + } + .cancellable(id: ReadingCancelID.observeDownloads, cancelInFlight: true) + } + + func reduceLoadLocalPageURLs(state: inout State, gid: String) -> Effect { + guard gid.isValidGID else { + state.localPageRequestID = UUID() + state.localPageURLs = .init() + return .none + } + let requestID = UUID() + state.localPageRequestID = requestID + return .run { send in + let localPageURLs: [Int: URL] + switch await downloadClient.loadLocalPageURLs(gid) { + case .success(let pageURLs): + localPageURLs = pageURLs + case .failure: + localPageURLs = [:] + } + await send(.loadLocalPageURLsDone(requestID, localPageURLs)) + } + .cancellable(id: ReadingCancelID.loadLocalPageURLs, cancelInFlight: true) + } + + func reduceLoadLocalPageURLsDone( + state: inout State, requestID: UUID, localPageURLs: [Int: URL] + ) -> Effect { + guard state.localPageRequestID == requestID else { return .none } + if case .local = state.contentSource, + localPageURLs.isEmpty { + state.contentSource = .remote + state.previewURLs = .init() + state.thumbnailURLs = .init() + state.imageURLs = .init() + state.originalImageURLs = .init() + state.forceRefreshID = .init() + } + state.localPageURLs = localPageURLs + return .none + } + + func applyLocalSource( + state: inout State, + download: DownloadedGallery, + manifest: DownloadManifest + ) { + guard let folderURL = download.folderURL else { return } + + state.gallery = download.gallery + state.galleryDetail = GalleryDetail( + gid: download.gid, + title: download.title, + jpnTitle: download.jpnTitle, + isFavorited: false, + visibility: .yes, + rating: download.rating, + userRating: 0, + ratingCount: 0, + category: download.category, + language: manifest.language, + uploader: download.uploader ?? "", + postedDate: download.postedDate, + coverURL: download.coverURL, + favoritedCount: 0, + pageCount: download.pageCount, + sizeCount: 0, + sizeType: "", + torrentCount: 0 + ) + let imageURLs = manifest.imageURLs(folderURL: folderURL) + state.localPageURLs = imageURLs + state.previewConfig = .normal(rows: 4) + state.previewURLs = imageURLs + state.thumbnailURLs = imageURLs + state.imageURLs = imageURLs + state.originalImageURLs = imageURLs + state.mpvKey = nil + state.mpvImageKeys = .init() + state.mpvSkipServerIdentifiers = .init() + state.imageURLLoadingStates = .init() + state.previewLoadingStates = .init() + state.databaseLoadingState = .idle + } +} diff --git a/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift b/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift new file mode 100644 index 000000000..9d93d9445 --- /dev/null +++ b/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift @@ -0,0 +1,379 @@ +// +// ReadingReducer+ImageFetch.swift +// EhPanda + +import Foundation +import ComposableArchitecture + +// MARK: - Image URL Fetch Actions +extension ReadingReducer { + func reduceFetchPreviewURLs(state: inout State, index: Int) -> Effect { + guard state.contentSource == .remote else { + state.previewLoadingStates[index] = .idle + return .none + } + guard state.previewLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.previewLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + return .run { send in + let response = await GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() + await send(.fetchPreviewURLsDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchPreviewURLs) + } + + func reduceFetchPreviewURLsDone( + state: inout State, index: Int, result: Result<[Int: URL], AppError> + ) -> Effect { + switch result { + case .success(let previewURLs): + guard !previewURLs.isEmpty else { + state.previewLoadingStates[index] = .failed(.notFound) + return .none + } + state.previewLoadingStates[index] = .idle + state.updatePreviewURLs(previewURLs) + return .send(.syncPreviewURLs(previewURLs)) + case .failure(let error): + state.previewLoadingStates[index] = .failed(error) + } + return .none + } + + func reduceFetchImageURLs(state: inout State, index: Int) -> Effect { + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.localPageURLs[index] == nil else { + state.imageURLLoadingStates[index] = .idle + return .none + } + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, false)) + } else { + return .send(.fetchThumbnailURLs(index)) + } + } + + func reduceRefetchImageURLs(state: inout State, index: Int) -> Effect { + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.localPageURLs[index] == nil else { + state.imageURLLoadingStates[index] = .idle + return .none + } + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, true)) + } else { + return .send(.refetchNormalImageURLs(index)) + } + } + + func reducePrefetchImages( + state: inout State, index: Int, prefetchLimit: Int + ) -> Effect { + guard state.contentSource == .remote else { return .none } + func getPrefetchImageURLs(range: ClosedRange) -> [URL] { + (range.lowerBound...range.upperBound).compactMap { index in + if let url = state.localPageURLs[index], !url.isFileURL { + return url + } + if let url = state.imageURLs[index] { + return url + } + return nil + } + } + func getFetchImageURLIndices(range: ClosedRange) -> [Int] { + (range.lowerBound...range.upperBound).compactMap { index in + if state.localPageURLs[index] != nil { + return nil + } + if state.imageURLs[index] == nil, + state.imageURLLoadingStates[index] != .loading { + return index + } + return nil + } + } + var prefetchImageURLs = [URL]() + var fetchImageURLIndices = [Int]() + var effects = [Effect]() + let previousUpperBound = max(index - 2, 1) + let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) + if previousUpperBound - previousLowerBound > 0 { + prefetchImageURLs += getPrefetchImageURLs(range: previousLowerBound...previousUpperBound) + fetchImageURLIndices += getFetchImageURLIndices(range: previousLowerBound...previousUpperBound) + } + let nextLowerBound = min(index + 2, state.gallery.pageCount) + let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) + if nextUpperBound - nextLowerBound > 0 { + prefetchImageURLs += getPrefetchImageURLs(range: nextLowerBound...nextUpperBound) + fetchImageURLIndices += getFetchImageURLIndices(range: nextLowerBound...nextUpperBound) + } + fetchImageURLIndices.forEach { + effects.append(.send(.fetchImageURLs($0))) + } + effects.append( + .run { [prefetchImageURLs] _ in + imageClient.prefetchImages(prefetchImageURLs) + } + ) + return .merge(effects) + } + + func reduceFetchThumbnailURLs(state: inout State, index: Int) -> Effect { + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.previewConfig.batchRange(index: index).forEach { + state.imageURLLoadingStates[$0] = .loading + } + let pageNum = state.previewConfig.pageNumber(index: index) + return .run { send in + let response = await ThumbnailURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() + await send(.fetchThumbnailURLsDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchThumbnailURLs) + } + + func reduceFetchThumbnailURLsDone( + state: inout State, index: Int, result: Result<[Int: URL], AppError> + ) -> Effect { + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let thumbnailURLs): + guard !thumbnailURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { + return .send(.fetchMPVKeys(index, url)) + } else { + state.updateThumbnailURLs(thumbnailURLs) + return .merge( + .send(.syncThumbnailURLs(thumbnailURLs)), + .send(.fetchNormalImageURLs(index, thumbnailURLs)) + ) + } + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none + } + + func reduceFetchNormalImageURLs( + state: inout State, index: Int, thumbnailURLs: [Int: URL] + ) -> Effect { + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } + return .run { send in + let response = await GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs).response() + await send(.fetchNormalImageURLsDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchNormalImageURLs) + } + + func reduceFetchNormalImageURLsDone( + state: inout State, index: Int, + result: Result<([Int: URL], [Int: URL]), AppError> + ) -> Effect { + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let (imageURLs, originalImageURLs)): + guard !imageURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.updateImageURLs(imageURLs, originalImageURLs) + return .send(.syncImageURLs(imageURLs, originalImageURLs)) + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none + } + + func reduceRefetchNormalImageURLs(state: inout State, index: Int) -> Effect { + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL, + let imageURL = state.imageURLs[index] + else { return .none } + state.imageURLLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + return .run { [thumbnailURL = state.thumbnailURLs[index]] send in + let response = await GalleryNormalImageURLRefetchRequest( + index: index, + pageNum: pageNum, + galleryURL: galleryURL, + thumbnailURL: thumbnailURL, + storedImageURL: imageURL + ) + .response() + await send(.refetchNormalImageURLsDone(index, response)) + } + .cancellable(id: ReadingCancelID.refetchNormalImageURLs) + } + + func reduceRefetchNormalImageURLsDone( + state: inout State, index: Int, + result: Result<([Int: URL], HTTPURLResponse?), AppError> + ) -> Effect { + switch result { + case .success(let (imageURLs, response)): + var effects = [Effect]() + if let response = response { + effects.append(.run(operation: { _ in cookieClient.setSkipServer(response: response) })) + } + guard !imageURLs.isEmpty else { + state.imageURLLoadingStates[index] = .failed(.notFound) + return effects.isEmpty ? .none : .merge(effects) + } + state.imageURLLoadingStates[index] = .idle + state.updateImageURLs(imageURLs, [:]) + effects.append(.send(.syncImageURLs(imageURLs, [:]))) + return .merge(effects) + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + } + return .none + } +} + +// MARK: - MPV Actions +extension ReadingReducer { + func reduceFetchMPVKeys( + state: inout State, index: Int, mpvURL: URL + ) -> Effect { + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } + return .run { send in + let response = await MPVKeysRequest(mpvURL: mpvURL).response() + await send(.fetchMPVKeysDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchMPVKeys) + } + + func reduceFetchMPVKeysDone( + state: inout State, index: Int, + result: Result<(String, [Int: String]), AppError> + ) -> Effect { + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let (mpvKey, mpvImageKeys)): + let pageCount = state.gallery.pageCount + guard mpvImageKeys.count == pageCount else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.mpvKey = mpvKey + state.mpvImageKeys = mpvImageKeys + return .merge( + Array(1...min(3, max(1, pageCount))).map { + .send(.fetchMPVImageURL($0, false)) + } + ) + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none + } + + func reduceFetchMPVImageURL( + state: inout State, index: Int, isRefresh: Bool + ) -> Effect { + guard state.contentSource == .remote else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, + let mpvImageKey = state.mpvImageKeys[index], + state.imageURLLoadingStates[index] != .loading + else { return .none } + state.imageURLLoadingStates[index] = .loading + let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil + return .run { send in + let response = await GalleryMPVImageURLRequest( + gid: gidInteger, + index: index, + mpvKey: mpvKey, + mpvImageKey: mpvImageKey, + skipServerIdentifier: skipServerIdentifier + ) + .response() + await send(.fetchMPVImageURLDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchMPVImageURL) + } + + func reduceFetchMPVImageURLDone( + state: inout State, index: Int, result: Result + ) -> Effect { + switch result { + case .success(let mpvResult): + let imageURLs: [Int: URL] = [index: mpvResult.imageURL] + var originalImageURLs = [Int: URL]() + if let originalImageURL = mpvResult.originalImageURL { + originalImageURLs[index] = originalImageURL + } + state.imageURLLoadingStates[index] = .idle + state.mpvSkipServerIdentifiers[index] = mpvResult.skipServerIdentifier + state.updateImageURLs(imageURLs, originalImageURLs) + return .send(.syncImageURLs(imageURLs, originalImageURLs)) + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + } + return .none + } + + func reduceCaptureCachedPage(state: inout State, index: Int) -> Effect { + guard state.contentSource == .remote, + state.gallery.id.isValidGID + else { + return .none + } + let gid = state.gallery.id + let imageURL = state.imageURLs[index] + return .run { _ in + await downloadClient.captureCachedPage( + gid, + index, + imageURL + ) + } + } +} diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index b1a0644de..bb9fbc71f 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -35,19 +35,6 @@ struct ReadingReducer { case share(Bool) } - private enum CancelID: CaseIterable { - case fetchImage - case fetchDatabaseInfos - case observeDownloads - case loadLocalPageURLs - case fetchPreviewURLs - case fetchThumbnailURLs - case fetchNormalImageURLs - case refetchNormalImageURLs - case fetchMPVKeys - case fetchMPVImageURL - } - @ObservableState struct State: Equatable { var route: Route? @@ -186,674 +173,19 @@ struct ReadingReducer { case fetchMPVKeys(Int, URL) case fetchMPVKeysDone(Int, Result<(String, [Int: String]), AppError>) case fetchMPVImageURL(Int, Bool) - case fetchMPVImageURLDone(Int, Result<(URL, URL?, String), AppError>) + case fetchMPVImageURLDone(Int, Result) case captureCachedPage(Int) } - @Dependency(\.appDelegateClient) private var appDelegateClient - @Dependency(\.clipboardClient) private var clipboardClient - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.downloadClient) private var downloadClient - @Dependency(\.hapticsClient) private var hapticsClient - @Dependency(\.cookieClient) private var cookieClient - @Dependency(\.deviceClient) private var deviceClient - @Dependency(\.imageClient) private var imageClient - @Dependency(\.urlClient) private var urlClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.showsSliderPreview) { _, _ in - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .toggleShowsPanel: - state.showsPanel.toggle() - return .none - - case .setOrientationPortrait(let isPortrait): - var effects = [Effect]() - if isPortrait { - effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) - effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientation() })) - } else { - effects.append(.run(operation: { _ in appDelegateClient.setAllOrientationMask() })) - } - return .merge(effects) - - case .onPerformDismiss: - return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) - - case .onAppear(let gid, let enablesLandscape): - var effects: [Effect] = [ - .send(.fetchDatabaseInfos(gid)), - .send(.observeDownloads(gid)), - .send(.loadLocalPageURLs(gid)) - ] - if enablesLandscape { - effects.append(.send(.setOrientationPortrait(false))) - } - return .merge(effects) - - case .onWebImageRetry(let index): - state.imageURLLoadingStates[index] = .idle - return .none - - case .onWebImageSucceeded(let index): - state.imageURLLoadingStates[index] = .idle - state.webImageLoadSuccessIndices.insert(index) - guard state.contentSource == .remote, - state.gallery.id.isValidGID, - state.localPageURLs[index] == nil - else { - return .none - } - return .send(.captureCachedPage(index)) - - case .onWebImageFailed(let index): - state.imageURLLoadingStates[index] = .failed(.webImageFailed) - return .none - - case .reloadAllWebImages: - guard state.contentSource == .remote else { - if case .local(let download, let manifest) = state.contentSource { - applyLocalSource( - state: &state, - download: download, - manifest: manifest - ) - } - return .none - } - state.previewURLs = .init() - state.thumbnailURLs = .init() - state.imageURLs = .init() - state.originalImageURLs = .init() - state.mpvKey = nil - state.mpvImageKeys = .init() - state.mpvSkipServerIdentifiers = .init() - state.forceRefreshID = .init() - return .run { [state] _ in - await databaseClient.removeImageURLs(gid: state.gallery.id) - } - - case .retryAllFailedWebImages: - guard state.contentSource == .remote else { return .none } - state.imageURLLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.imageURLLoadingStates[index] = .idle - } - } - state.previewLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.previewLoadingStates[index] = .idle - } - } - return .none - - case .copyImage(let imageURL): - return .send(.fetchImage(.copy(imageURL.isAnimatedImage), imageURL)) - - case .saveImage(let imageURL): - return .send(.fetchImage(.save(imageURL.isAnimatedImage), imageURL)) - - case .saveImageDone(let isSucceeded): - state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error - return .send(.setNavigation(.hud)) - - case .shareImage(let imageURL): - return .send(.fetchImage(.share(imageURL.isAnimatedImage), imageURL)) - - case .fetchImage(let action, let imageURL): - return .run { send in - let result = await imageClient.fetchImage(url: imageURL) - await send(.fetchImageDone(action, result)) - } - .cancellable(id: CancelID.fetchImage) - - case .fetchImageDone(let action, let result): - if case .success(let image) = result { - switch action { - case .copy(let isAnimated): - state.hudConfig = .copiedToClipboardSucceeded - return .merge( - .send(.setNavigation(.hud)), - .run(operation: { _ in clipboardClient.saveImage(image, isAnimated) }) - ) - case .save(let isAnimated): - return .run { send in - let success = await imageClient.saveImageToPhotoLibrary(image, isAnimated) - await send(.saveImageDone(success)) - } - case .share(let isAnimated): - if isAnimated, let data = image.kf.data(format: .GIF) { - return .send(.setNavigation(.share(.init(value: .data(data))))) - } else { - return .send(.setNavigation(.share(.init(value: .image(image))))) - } - } - } else { - state.hudConfig = .error - return .send(.setNavigation(.hud)) - } - - case .syncReadingProgress(let progress): - return .run { [state] _ in - await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) - } - - case .syncPreviewURLs(let previewURLs): - guard state.contentSource == .remote else { return .none } - return .run { [state] _ in - await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) - } - - case .syncThumbnailURLs(let thumbnailURLs): - guard state.contentSource == .remote else { return .none } - return .run { [state] _ in - await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) - } - - case .syncImageURLs(let imageURLs, let originalImageURLs): - guard state.contentSource == .remote else { return .none } - return .run { [state] _ in - await databaseClient.updateImageURLs( - gid: state.gallery.id, - imageURLs: imageURLs, - originalImageURLs: originalImageURLs - ) - } - - case .teardown: - var effects: [Effect] = [ - .merge(CancelID.allCases.map(Effect.cancel(id:))) - ] - if !deviceClient.isPad() { - effects.append(.send(.setOrientationPortrait(true))) - } - return .merge(effects) - - case .fetchDatabaseInfos(let gid): - if case .local(let download, let manifest) = state.contentSource { - applyLocalSource( - state: &state, - download: download, - manifest: manifest - ) - } else { - guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery - state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) - } - return .run { [state] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: state.gallery.id) else { return } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: CancelID.fetchDatabaseInfos) - - case .fetchDatabaseInfosDone(let galleryState): - if state.contentSource == .remote { - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - state.previewURLs = galleryState.previewURLs - state.imageURLs = galleryState.imageURLs - state.thumbnailURLs = galleryState.thumbnailURLs - state.originalImageURLs = galleryState.originalImageURLs - } - state.readingProgress = galleryState.readingProgress - state.databaseLoadingState = .idle - return .none - - case .observeDownloads(let gid): - guard gid.isValidGID else { return .none } - return .run { send in - var previousRelevantDownloads = [DownloadedGallery]() - var hadRelevantDownloads = false - for await downloads in downloadClient.observeDownloads() { - let relevantDownloads = downloads.filter { $0.gid == gid } - let hasRelevantDownloads = !relevantDownloads.isEmpty - guard hasRelevantDownloads || hadRelevantDownloads else { continue } - if relevantDownloads == previousRelevantDownloads { - hadRelevantDownloads = hasRelevantDownloads - continue - } - previousRelevantDownloads = relevantDownloads - hadRelevantDownloads = hasRelevantDownloads - await send(.observeDownloadsDone(relevantDownloads)) - } - } - .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) - - case .observeDownloadsDone: - guard state.gallery.id.isValidGID else { return .none } - return .send(.loadLocalPageURLs(state.gallery.id)) - - case .loadLocalPageURLs(let gid): - guard gid.isValidGID else { - state.localPageRequestID = UUID() - state.localPageURLs = .init() - return .none - } - let requestID = UUID() - state.localPageRequestID = requestID - return .run { send in - let localPageURLs: [Int: URL] - switch await downloadClient.loadLocalPageURLs(gid) { - case .success(let pageURLs): - localPageURLs = pageURLs - case .failure: - localPageURLs = [:] - } - await send(.loadLocalPageURLsDone(requestID, localPageURLs)) - } - .cancellable(id: CancelID.loadLocalPageURLs, cancelInFlight: true) - - case .loadLocalPageURLsDone(let requestID, let localPageURLs): - guard state.localPageRequestID == requestID else { return .none } - if case .local = state.contentSource, - localPageURLs.isEmpty - { - state.contentSource = .remote - state.previewURLs = .init() - state.thumbnailURLs = .init() - state.imageURLs = .init() - state.originalImageURLs = .init() - state.forceRefreshID = .init() - } - state.localPageURLs = localPageURLs - return .none - - case .fetchPreviewURLs(let index): - guard state.contentSource == .remote else { - state.previewLoadingStates[index] = .idle - return .none - } - guard state.previewLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.previewLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { send in - let response = await GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() - await send(.fetchPreviewURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchPreviewURLs) - - case .fetchPreviewURLsDone(let index, let result): - switch result { - case .success(let previewURLs): - guard !previewURLs.isEmpty else { - state.previewLoadingStates[index] = .failed(.notFound) - return .none - } - state.previewLoadingStates[index] = .idle - state.updatePreviewURLs(previewURLs) - return .send(.syncPreviewURLs(previewURLs)) - case .failure(let error): - state.previewLoadingStates[index] = .failed(error) - } - return .none - - case .fetchImageURLs(let index): - guard state.contentSource == .remote else { - state.imageURLLoadingStates[index] = .idle - return .none - } - guard state.localPageURLs[index] == nil else { - state.imageURLLoadingStates[index] = .idle - return .none - } - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, false)) - } else { - return .send(.fetchThumbnailURLs(index)) - } - - case .refetchImageURLs(let index): - guard state.contentSource == .remote else { - state.imageURLLoadingStates[index] = .idle - return .none - } - guard state.localPageURLs[index] == nil else { - state.imageURLLoadingStates[index] = .idle - return .none - } - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, true)) - } else { - return .send(.refetchNormalImageURLs(index)) - } - - case .prefetchImages(let index, let prefetchLimit): - guard state.contentSource == .remote else { return .none } - func getPrefetchImageURLs(range: ClosedRange) -> [URL] { - (range.lowerBound...range.upperBound).compactMap { index in - if let url = state.localPageURLs[index], !url.isFileURL { - return url - } - if let url = state.imageURLs[index] { - return url - } - return nil - } - } - func getFetchImageURLIndices(range: ClosedRange) -> [Int] { - (range.lowerBound...range.upperBound).compactMap { index in - if state.localPageURLs[index] != nil { - return nil - } - if state.imageURLs[index] == nil, state.imageURLLoadingStates[index] != .loading { - return index - } - return nil - } - } - var prefetchImageURLs = [URL]() - var fetchImageURLIndices = [Int]() - var effects = [Effect]() - let previousUpperBound = max(index - 2, 1) - let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) - if previousUpperBound - previousLowerBound > 0 { - prefetchImageURLs += getPrefetchImageURLs(range: previousLowerBound...previousUpperBound) - fetchImageURLIndices += getFetchImageURLIndices(range: previousLowerBound...previousUpperBound) - } - let nextLowerBound = min(index + 2, state.gallery.pageCount) - let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) - if nextUpperBound - nextLowerBound > 0 { - prefetchImageURLs += getPrefetchImageURLs(range: nextLowerBound...nextUpperBound) - fetchImageURLIndices += getFetchImageURLIndices(range: nextLowerBound...nextUpperBound) - } - fetchImageURLIndices.forEach { - effects.append(.send(.fetchImageURLs($0))) - } - effects.append( - .run { [prefetchImageURLs] _ in - imageClient.prefetchImages(prefetchImageURLs) - } - ) - return .merge(effects) - - case .fetchThumbnailURLs(let index): - guard state.contentSource == .remote else { - state.imageURLLoadingStates[index] = .idle - return .none - } - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.previewConfig.batchRange(index: index).forEach { - state.imageURLLoadingStates[$0] = .loading - } - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { send in - let response = await ThumbnailURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() - await send(.fetchThumbnailURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchThumbnailURLs) - - case .fetchThumbnailURLsDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let thumbnailURLs): - guard !thumbnailURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { - return .send(.fetchMPVKeys(index, url)) - } else { - state.updateThumbnailURLs(thumbnailURLs) - return .merge( - .send(.syncThumbnailURLs(thumbnailURLs)), - .send(.fetchNormalImageURLs(index, thumbnailURLs)) - ) - } - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - - case .fetchNormalImageURLs(let index, let thumbnailURLs): - guard state.contentSource == .remote else { - state.imageURLLoadingStates[index] = .idle - return .none - } - return .run { send in - let response = await GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs).response() - await send(.fetchNormalImageURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchNormalImageURLs) - - case .fetchNormalImageURLsDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let (imageURLs, originalImageURLs)): - guard !imageURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - - case .refetchNormalImageURLs(let index): - guard state.contentSource == .remote else { - state.imageURLLoadingStates[index] = .idle - return .none - } - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL, - let imageURL = state.imageURLs[index] - else { return .none } - state.imageURLLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { [thumbnailURL = state.thumbnailURLs[index]] send in - let response = await GalleryNormalImageURLRefetchRequest( - index: index, - pageNum: pageNum, - galleryURL: galleryURL, - thumbnailURL: thumbnailURL, - storedImageURL: imageURL - ) - .response() - await send(.refetchNormalImageURLsDone(index, response)) - } - .cancellable(id: CancelID.refetchNormalImageURLs) - - case .refetchNormalImageURLsDone(let index, let result): - switch result { - case .success(let (imageURLs, response)): - var effects = [Effect]() - if let response = response { - effects.append(.run(operation: { _ in cookieClient.setSkipServer(response: response) })) - } - guard !imageURLs.isEmpty else { - state.imageURLLoadingStates[index] = .failed(.notFound) - return effects.isEmpty ? .none : .merge(effects) - } - state.imageURLLoadingStates[index] = .idle - state.updateImageURLs(imageURLs, [:]) - effects.append(.send(.syncImageURLs(imageURLs, [:]))) - return .merge(effects) - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) - } - return .none - - case .fetchMPVKeys(let index, let mpvURL): - guard state.contentSource == .remote else { - state.imageURLLoadingStates[index] = .idle - return .none - } - return .run { send in - let response = await MPVKeysRequest(mpvURL: mpvURL).response() - await send(.fetchMPVKeysDone(index, response)) - } - .cancellable(id: CancelID.fetchMPVKeys) - - case .fetchMPVKeysDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let (mpvKey, mpvImageKeys)): - let pageCount = state.gallery.pageCount - guard mpvImageKeys.count == pageCount else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.mpvKey = mpvKey - state.mpvImageKeys = mpvImageKeys - return .merge( - Array(1...min(3, max(1, pageCount))).map { - .send(.fetchMPVImageURL($0, false)) - } - ) - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - - case .fetchMPVImageURL(let index, let isRefresh): - guard state.contentSource == .remote else { - state.imageURLLoadingStates[index] = .idle - return .none - } - guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, - let mpvImageKey = state.mpvImageKeys[index], - state.imageURLLoadingStates[index] != .loading - else { return .none } - state.imageURLLoadingStates[index] = .loading - let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil - return .run { send in - let response = await GalleryMPVImageURLRequest( - gid: gidInteger, - index: index, - mpvKey: mpvKey, - mpvImageKey: mpvImageKey, - skipServerIdentifier: skipServerIdentifier - ) - .response() - await send(.fetchMPVImageURLDone(index, response)) - } - .cancellable(id: CancelID.fetchMPVImageURL) - - case .fetchMPVImageURLDone(let index, let result): - switch result { - case .success(let (imageURL, originalImageURL, skipServerIdentifier)): - let imageURLs: [Int: URL] = [index: imageURL] - var originalImageURLs = [Int: URL]() - if let originalImageURL = originalImageURL { - originalImageURLs[index] = originalImageURL - } - state.imageURLLoadingStates[index] = .idle - state.mpvSkipServerIdentifiers[index] = skipServerIdentifier - state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) - } - return .none - - case .captureCachedPage(let index): - guard state.contentSource == .remote, - state.gallery.id.isValidGID - else { - return .none - } - let gid = state.gallery.id - let imageURL = state.imageURLs[index] - return .run { _ in - await downloadClient.captureCachedPage( - gid, - index, - imageURL - ) - } - } - } - .haptics( - unwrapping: \.route, - case: \.readingSetting, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.share, - hapticsClient: hapticsClient - ) - } -} - -private extension ReadingReducer { - func applyLocalSource( - state: inout State, - download: DownloadedGallery, - manifest: DownloadManifest - ) { - guard let folderURL = download.folderURL else { return } - - state.gallery = download.gallery - state.galleryDetail = GalleryDetail( - gid: download.gid, - title: download.title, - jpnTitle: download.jpnTitle, - isFavorited: false, - visibility: .yes, - rating: download.rating, - userRating: 0, - ratingCount: 0, - category: download.category, - language: manifest.language, - uploader: download.uploader ?? "", - postedDate: download.postedDate, - coverURL: download.coverURL, - favoritedCount: 0, - pageCount: download.pageCount, - sizeCount: 0, - sizeType: "", - torrentCount: 0 - ) - let imageURLs = manifest.imageURLs(folderURL: folderURL) - state.localPageURLs = imageURLs - state.previewConfig = .normal(rows: 4) - state.previewURLs = imageURLs - state.thumbnailURLs = imageURLs - state.imageURLs = imageURLs - state.originalImageURLs = imageURLs - state.mpvKey = nil - state.mpvImageKeys = .init() - state.mpvSkipServerIdentifiers = .init() - state.imageURLLoadingStates = .init() - state.previewLoadingStates = .init() - state.databaseLoadingState = .idle - } + @Dependency(\.appDelegateClient) var appDelegateClient + @Dependency(\.clipboardClient) var clipboardClient + @Dependency(\.databaseClient) var databaseClient + @Dependency(\.downloadClient) var downloadClient + @Dependency(\.hapticsClient) var hapticsClient + @Dependency(\.cookieClient) var cookieClient + @Dependency(\.deviceClient) var deviceClient + @Dependency(\.imageClient) var imageClient + @Dependency(\.urlClient) var urlClient + + var body: some Reducer { makeBody() } } diff --git a/EhPanda/View/Reading/ReadingView+Gestures.swift b/EhPanda/View/Reading/ReadingView+Gestures.swift new file mode 100644 index 000000000..5776ea8a2 --- /dev/null +++ b/EhPanda/View/Reading/ReadingView+Gestures.swift @@ -0,0 +1,57 @@ +// +// ReadingView+Gestures.swift +// EhPanda +// + +import SwiftUI + +// MARK: Gesture +extension ReadingView { + var tapGesture: some Gesture { + let singleTap = TapGesture(count: 1) + .onEnded { + gestureHandler.onSingleTapGestureEnded( + readingDirection: setting.readingDirection, + setPageIndexOffsetAction: { + let newValue = page.index + $0 + page.update(.new(index: newValue)) + Logger.info("Pager.update", context: ["update": newValue]) + }, + toggleShowsPanelAction: { store.send(.toggleShowsPanel) } + ) + } + let doubleTap = TapGesture(count: 2) + .onEnded { + gestureHandler.onDoubleTapGestureEnded( + scaleMaximum: setting.maximumScaleFactor, + doubleTapScale: setting.doubleTapScaleFactor + ) + } + return ExclusiveGesture(doubleTap, singleTap) + } + var magnificationGesture: some Gesture { + MagnificationGesture() + .onChanged { + gestureHandler.onMagnificationGestureChanged( + value: $0, scaleMaximum: setting.maximumScaleFactor + ) + } + .onEnded { + gestureHandler.onMagnificationGestureEnded( + value: $0, scaleMaximum: setting.maximumScaleFactor + ) + } + } + var dragGesture: some Gesture { + DragGesture(minimumDistance: .zero, coordinateSpace: .local) + .onChanged(gestureHandler.onDragGestureChanged) + .onEnded(gestureHandler.onDragGestureEnded) + } + var controlPanelDismissGesture: some Gesture { + DragGesture().onEnded { + gestureHandler.onControlPanelDismissGestureEnded( + value: $0, dismissAction: { store.send(.onPerformDismiss) } + ) + } + } +} diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 387cbf5d0..898405dce 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -13,15 +13,15 @@ struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme @Bindable var store: StoreOf - private let gid: String - @Binding private var setting: Setting - private let blurRadius: Double + let gid: String + @Binding var setting: Setting + let blurRadius: Double @State private var liveTextHandler = LiveTextHandler() @State private var autoPlayHandler = AutoPlayHandler() - @State private var gestureHandler = GestureHandler() + @State var gestureHandler = GestureHandler() @State private var pageHandler = PageHandler() - @StateObject private var page: Page = .first() + @StateObject var page: Page = .first() init( store: StoreOf, @@ -170,8 +170,31 @@ struct ReadingView: View { @ViewBuilder private func changeTriggers(@ViewBuilder content: () -> Content) -> some View { + pageAndAutoPlayTriggers(content: content) + // LiveText + .onChange(of: liveTextHandler.enablesLiveText) { _, newValue in + Logger.info("liveTextHandler.enablesLiveText changed", context: ["isEnabled": newValue]) + if newValue { store.webImageLoadSuccessIndices.forEach(analyzeImageForLiveText) } + } + .onChange(of: store.webImageLoadSuccessIndices) { _, newValue in + Logger.info("store.webImageLoadSuccessIndices changed", context: [ + "count": store.webImageLoadSuccessIndices.count + ]) + if liveTextHandler.enablesLiveText { + newValue.forEach(analyzeImageForLiveText) + } + } + // Orientation + .onChange(of: setting.enablesLandscape) { _, newValue in + Logger.info("setting.enablesLandscape changed", context: ["newValue": newValue]) + store.send(.setOrientationPortrait(!newValue)) + } + } + + @ViewBuilder + private func pageAndAutoPlayTriggers(@ViewBuilder content: () -> Content) -> some View { content() - // Page + // Page .onChange(of: page.index) { _, newValue in Logger.info("page.index changed", context: ["pageIndex": newValue]) let newValue = pageHandler.mapFromPager( @@ -197,7 +220,6 @@ struct ReadingView: View { Logger.info("store.readingProgress changed", context: ["readingProgress": newValue]) pageHandler.sliderValue = .init(newValue) } - // AutoPlay .onChange(of: store.route) { _, newValue in Logger.info("store.route changed", context: ["route": newValue]) @@ -205,26 +227,6 @@ struct ReadingView: View { setAutoPlayPolocy(.off) } } - - // LiveText - .onChange(of: liveTextHandler.enablesLiveText) { _, newValue in - Logger.info("liveTextHandler.enablesLiveText changed", context: ["isEnabled": newValue]) - if newValue { store.webImageLoadSuccessIndices.forEach(analyzeImageForLiveText) } - } - .onChange(of: store.webImageLoadSuccessIndices) { _, newValue in - Logger.info("store.webImageLoadSuccessIndices changed", context: [ - "count": store.webImageLoadSuccessIndices.count - ]) - if liveTextHandler.enablesLiveText { - newValue.forEach(analyzeImageForLiveText) - } - } - - // Orientation - .onChange(of: setting.enablesLandscape) { _, newValue in - Logger.info("setting.enablesLandscape changed", context: ["newValue": newValue]) - store.send(.setOrientationPortrait(!newValue)) - } } @ViewBuilder private func imageStack(index: Int) -> some View { @@ -284,407 +286,49 @@ extension ReadingView { return } if imageURL.isFileURL { - if let image = UIImage(contentsOfFile: imageURL.path) - ?? ((try? Data(contentsOf: imageURL)).flatMap(UIImage.init(data:))), - let cgImage = image.cgImage - { - liveTextHandler.analyzeImage( - cgImage, size: image.size, index: index, recognitionLanguages: - store.galleryDetail?.language.codes - ) - } else { - Logger.info("analyzeImageForLiveText local image not found", context: ["index": index]) - } + analyzeLocalImage(at: imageURL, index: index) return } let cacheKeys = imageURL.imageCacheKeys(includeStableAlias: true) - - func retrieveImage(cacheKeys: ArraySlice) { - guard let cacheKey = cacheKeys.first else { - Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) - return - } - KingfisherManager.shared.cache.retrieveImage(forKey: cacheKey) { result in - switch result { - case .success(let result): - if let image = result.image, let cgImage = image.cgImage { - liveTextHandler.analyzeImage( - cgImage, size: image.size, index: index, recognitionLanguages: - store.galleryDetail?.language.codes - ) - } else { - retrieveImage(cacheKeys: cacheKeys.dropFirst()) - } - case .failure(let error): - if cacheKeys.count > 1 { - retrieveImage(cacheKeys: cacheKeys.dropFirst()) - } else { - Logger.info( - "analyzeImageForLiveText failed", - context: [ - "index": index, - "error": error - ] - as [String: Any] - ) - } - } - } - } - - retrieveImage(cacheKeys: ArraySlice(cacheKeys)) + retrieveCachedImage(cacheKeys: ArraySlice(cacheKeys), index: index) } -} -// MARK: Gesture -extension ReadingView { - var tapGesture: some Gesture { - let singleTap = TapGesture(count: 1) - .onEnded { - gestureHandler.onSingleTapGestureEnded( - readingDirection: setting.readingDirection, - setPageIndexOffsetAction: { - let newValue = page.index + $0 - page.update(.new(index: newValue)) - Logger.info("Pager.update", context: ["update": newValue]) - }, - toggleShowsPanelAction: { store.send(.toggleShowsPanel) } - ) - } - let doubleTap = TapGesture(count: 2) - .onEnded { - gestureHandler.onDoubleTapGestureEnded( - scaleMaximum: setting.maximumScaleFactor, - doubleTapScale: setting.doubleTapScaleFactor - ) - } - return ExclusiveGesture(doubleTap, singleTap) - } - var magnificationGesture: some Gesture { - MagnificationGesture() - .onChanged { - gestureHandler.onMagnificationGestureChanged( - value: $0, scaleMaximum: setting.maximumScaleFactor - ) - } - .onEnded { - gestureHandler.onMagnificationGestureEnded( - value: $0, scaleMaximum: setting.maximumScaleFactor - ) - } - } - var dragGesture: some Gesture { - DragGesture(minimumDistance: .zero, coordinateSpace: .local) - .onChanged(gestureHandler.onDragGestureChanged) - .onEnded(gestureHandler.onDragGestureEnded) - } - var controlPanelDismissGesture: some Gesture { - DragGesture().onEnded { - gestureHandler.onControlPanelDismissGestureEnded( - value: $0, dismissAction: { store.send(.onPerformDismiss) } + private func analyzeLocalImage(at imageURL: URL, index: Int) { + if let image = UIImage(contentsOfFile: imageURL.path) + ?? ((try? Data(contentsOf: imageURL)).flatMap(UIImage.init(data:))), + let cgImage = image.cgImage { + liveTextHandler.analyzeImage( + cgImage, size: image.size, index: index, recognitionLanguages: + store.galleryDetail?.language.codes ) - } - } -} - -// MARK: HorizontalImageStack -private struct HorizontalImageStack: View { - private let index: Int - private let isDualPage: Bool - private let isDatabaseLoading: Bool - private let backgroundColor: Color - private let config: ImageStackConfig - private let imageURLs: [Int: URL] - private let originalImageURLs: [Int: URL] - private let loadingStates: [Int: LoadingState] - private let enablesLiveText: Bool - private let liveTextGroups: [Int: [LiveTextGroup]] - private let focusedLiveTextGroup: LiveTextGroup? - private let liveTextTapAction: (LiveTextGroup) -> Void - private let fetchAction: (Int) -> Void - private let refetchAction: (Int) -> Void - private let prefetchAction: (Int) -> Void - private let loadRetryAction: (Int) -> Void - private let loadSucceededAction: (Int) -> Void - private let loadFailedAction: (Int) -> Void - private let copyImageAction: (URL) -> Void - private let saveImageAction: (URL) -> Void - private let shareImageAction: (URL) -> Void - - init( - index: Int, isDualPage: Bool, isDatabaseLoading: Bool, backgroundColor: Color, - config: ImageStackConfig, imageURLs: [Int: URL], originalImageURLs: [Int: URL], - loadingStates: [Int: LoadingState], enablesLiveText: Bool, - liveTextGroups: [Int: [LiveTextGroup]], focusedLiveTextGroup: LiveTextGroup?, - liveTextTapAction: @escaping (LiveTextGroup) -> Void, - fetchAction: @escaping (Int) -> Void, - refetchAction: @escaping (Int) -> Void, prefetchAction: @escaping (Int) -> Void, - loadRetryAction: @escaping (Int) -> Void, loadSucceededAction: @escaping (Int) -> Void, - loadFailedAction: @escaping (Int) -> Void, copyImageAction: @escaping (URL) -> Void, - saveImageAction: @escaping (URL) -> Void, shareImageAction: @escaping (URL) -> Void - ) { - self.index = index - self.isDualPage = isDualPage - self.isDatabaseLoading = isDatabaseLoading - self.backgroundColor = backgroundColor - self.config = config - self.imageURLs = imageURLs - self.originalImageURLs = originalImageURLs - self.loadingStates = loadingStates - self.enablesLiveText = enablesLiveText - self.liveTextGroups = liveTextGroups - self.focusedLiveTextGroup = focusedLiveTextGroup - self.liveTextTapAction = liveTextTapAction - self.fetchAction = fetchAction - self.refetchAction = refetchAction - self.prefetchAction = prefetchAction - self.loadRetryAction = loadRetryAction - self.loadSucceededAction = loadSucceededAction - self.loadFailedAction = loadFailedAction - self.copyImageAction = copyImageAction - self.saveImageAction = saveImageAction - self.shareImageAction = shareImageAction - } - - var body: some View { - HStack(spacing: 0) { - if config.isFirstAvailable { - imageContainer(index: config.firstIndex) - } - if config.isSecondAvailable { - imageContainer(index: config.secondIndex) - } + } else { + Logger.info("analyzeImageForLiveText local image not found", context: ["index": index]) } } - func imageContainer(index: Int) -> some View { - ImageContainer( - index: index, - imageURL: imageURLs[index], - loadingState: loadingStates[index] ?? .idle, - isDualPage: isDualPage, - backgroundColor: backgroundColor, - enablesLiveText: enablesLiveText, - liveTextGroups: liveTextGroups[index] ?? [], - focusedLiveTextGroup: focusedLiveTextGroup, - liveTextTapAction: liveTextTapAction, - refetchAction: refetchAction, - loadRetryAction: loadRetryAction, - loadSucceededAction: loadSucceededAction, - loadFailedAction: loadFailedAction - ) - .onAppear { - if !isDatabaseLoading { - if imageURLs[index] == nil { - fetchAction(index) - } - prefetchAction(index) - } - } - .contextMenu { contextMenuItems(index: index) } - } - @ViewBuilder private func contextMenuItems(index: Int) -> some View { - Button { - refetchAction(index) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.reload, systemSymbol: .arrowCounterclockwise) + private func retrieveCachedImage(cacheKeys: ArraySlice, index: Int) { + guard let cacheKey = cacheKeys.first else { + Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) + return } - if let imageURL = imageURLs[index] { - Button { - copyImageAction(imageURL) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.copy, systemSymbol: .plusSquareOnSquare) - } - Button { - saveImageAction(imageURL) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.save, systemSymbol: .squareAndArrowDown) - } - if let originalImageURL = originalImageURLs[index] { - Button { - saveImageAction(originalImageURL) - } label: { - Label( - L10n.Localizable.ReadingView.ContextMenu.Button.saveOriginal, - systemSymbol: .squareAndArrowDownOnSquare + KingfisherManager.shared.cache.retrieveImage(forKey: cacheKey) { result in + switch result { + case .success(let result): + if let image = result.image, let cgImage = image.cgImage { + liveTextHandler.analyzeImage( + cgImage, size: image.size, index: index, recognitionLanguages: + store.galleryDetail?.language.codes ) + } else { + retrieveCachedImage(cacheKeys: cacheKeys.dropFirst(), index: index) } - } - Button { - shareImageAction(imageURL) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.share, systemSymbol: .squareAndArrowUp) - } - } - } -} - -// MARK: ImageContainer -private struct ImageContainer: View { - private var width: CGFloat { - DeviceUtil.windowW / (isDualPage ? 2 : 1) - } - private var height: CGFloat { - width / Defaults.ImageSize.contentAspect - } - - private let index: Int - private let imageURL: URL? - private let loadingState: LoadingState - private let isDualPage: Bool - private let backgroundColor: Color - private let enablesLiveText: Bool - private let liveTextGroups: [LiveTextGroup] - private let focusedLiveTextGroup: LiveTextGroup? - private let liveTextTapAction: (LiveTextGroup) -> Void - private let refetchAction: (Int) -> Void - private let loadRetryAction: (Int) -> Void - private let loadSucceededAction: (Int) -> Void - private let loadFailedAction: (Int) -> Void - - init( - index: Int, imageURL: URL?, - loadingState: LoadingState, - isDualPage: Bool, - backgroundColor: Color, - enablesLiveText: Bool, - liveTextGroups: [LiveTextGroup], - focusedLiveTextGroup: LiveTextGroup?, - liveTextTapAction: @escaping (LiveTextGroup) -> Void, - refetchAction: @escaping (Int) -> Void, - loadRetryAction: @escaping (Int) -> Void, - loadSucceededAction: @escaping (Int) -> Void, - loadFailedAction: @escaping (Int) -> Void - ) { - self.index = index - self.imageURL = imageURL - self.loadingState = loadingState - self.isDualPage = isDualPage - self.backgroundColor = backgroundColor - self.enablesLiveText = enablesLiveText - self.liveTextGroups = liveTextGroups - self.focusedLiveTextGroup = focusedLiveTextGroup - self.liveTextTapAction = liveTextTapAction - self.refetchAction = refetchAction - self.loadRetryAction = loadRetryAction - self.loadSucceededAction = loadSucceededAction - self.loadFailedAction = loadFailedAction - } - - private func placeholder(_ progress: Progress) -> some View { - Placeholder(style: .progress( - pageNumber: index, progress: progress, - isDualPage: isDualPage, backgroundColor: backgroundColor - )) - .frame(width: width, height: height) - } - @ViewBuilder private func image(url: URL?) -> some View { - let isFileURL = url?.isFileURL ?? false - if url?.isAnimatedImage == true { - KFAnimatedImage(url) - .placeholder(placeholder) - .fade(duration: 0.25) - .onSuccess(onSuccess) - .onFailure(onFailure) - .cacheMemoryOnly(isFileURL) - } else { - let cacheKey = isFileURL - ? url.map(localFileCacheKey) - : url?.stableImageCacheKey ?? url?.absoluteString - KFImage.url(url, cacheKey: cacheKey) - .placeholder(placeholder) - .defaultModifier(withRoundedCorners: false) - .onSuccess(onSuccess) - .onFailure(onFailure) - .cacheMemoryOnly(isFileURL) - } - } - - var body: some View { - if loadingState == .idle { - image(url: imageURL).scaledToFit().overlay( - LiveTextView( - liveTextGroups: liveTextGroups, - focusedLiveTextGroup: focusedLiveTextGroup, - tapAction: liveTextTapAction - ) - .opacity(enablesLiveText ? 1 : 0) - ) - } else { - ZStack { - backgroundColor - VStack { - Text(String(index)).font(.largeTitle.bold()) - .foregroundColor(.gray).padding(.bottom, 30) - ZStack { - Button(action: reloadImage) { - Image(systemSymbol: .exclamationmarkArrowTrianglehead2ClockwiseRotate90) - } - .font(.system(size: 30, weight: .medium)).foregroundColor(.gray) - .opacity(loadingState == .loading ? 0 : 1) - ProgressView().opacity(loadingState == .loading ? 1 : 0) - } + case .failure: + if cacheKeys.count > 1 { + retrieveCachedImage(cacheKeys: cacheKeys.dropFirst(), index: index) + } else { + Logger.info("analyzeImageForLiveText failed", context: ["index": index]) } } - .frame(width: width, height: height) - } - } - private func reloadImage() { - if let error = loadingState.failed { - if case .webImageFailed = error { - loadRetryAction(index) - } else { - refetchAction(index) - } - } - } - private func onSuccess(_: RetrieveImageResult) { - loadSucceededAction(index) - } - private func onFailure(_: KingfisherError) { - if imageURL != nil { - loadFailedAction(index) - } - } - - private func localFileCacheKey(_ url: URL) -> String { - let resourceValues = try? url.resourceValues(forKeys: [ - .contentModificationDateKey, - .fileSizeKey - ]) - let modificationStamp = resourceValues?.contentModificationDate? - .timeIntervalSinceReferenceDate ?? .zero - let fileSize = resourceValues?.fileSize ?? 0 - return "local::\(url.path)#\(fileSize)#\(modificationStamp)" - } -} - -// MARK: Definition -struct ImageStackConfig { - let firstIndex: Int - let secondIndex: Int - let isFirstAvailable: Bool - let isSecondAvailable: Bool -} - -enum AutoPlayPolicy: Int, CaseIterable, Identifiable { - var id: Int { rawValue } - - case off = -1 - case sec1 = 1 - case sec2 = 2 - case sec3 = 3 - case sec4 = 4 - case sec5 = 5 -} - -extension AutoPlayPolicy { - var value: String { - switch self { - case .off: - return L10n.Localizable.Enum.AutoPlayPolicy.Value.off - default: - return L10n.Localizable.Common.Value.seconds("\(rawValue)") } } } diff --git a/EhPanda/View/Reading/ReadingViewComponents.swift b/EhPanda/View/Reading/ReadingViewComponents.swift new file mode 100644 index 000000000..968004578 --- /dev/null +++ b/EhPanda/View/Reading/ReadingViewComponents.swift @@ -0,0 +1,319 @@ +// +// ReadingViewComponents.swift +// EhPanda + +import SwiftUI +import Kingfisher + +// MARK: ImageStackConfig +struct ImageStackConfig { + let firstIndex: Int + let secondIndex: Int + let isFirstAvailable: Bool + let isSecondAvailable: Bool +} + +// MARK: AutoPlayPolicy +enum AutoPlayPolicy: Int, CaseIterable, Identifiable { + var id: Int { rawValue } + + case off = -1 + case sec1 = 1 + case sec2 = 2 + case sec3 = 3 + case sec4 = 4 + case sec5 = 5 +} + +extension AutoPlayPolicy { + var value: String { + switch self { + case .off: + return L10n.Localizable.Enum.AutoPlayPolicy.Value.off + default: + return L10n.Localizable.Common.Value.seconds("\(rawValue)") + } + } +} + +// MARK: HorizontalImageStack +struct HorizontalImageStack: View { + private let index: Int + private let isDualPage: Bool + private let isDatabaseLoading: Bool + private let backgroundColor: Color + private let config: ImageStackConfig + private let imageURLs: [Int: URL] + private let originalImageURLs: [Int: URL] + private let loadingStates: [Int: LoadingState] + private let enablesLiveText: Bool + private let liveTextGroups: [Int: [LiveTextGroup]] + private let focusedLiveTextGroup: LiveTextGroup? + private let liveTextTapAction: (LiveTextGroup) -> Void + private let fetchAction: (Int) -> Void + private let refetchAction: (Int) -> Void + private let prefetchAction: (Int) -> Void + private let loadRetryAction: (Int) -> Void + private let loadSucceededAction: (Int) -> Void + private let loadFailedAction: (Int) -> Void + private let copyImageAction: (URL) -> Void + private let saveImageAction: (URL) -> Void + private let shareImageAction: (URL) -> Void + + init( + index: Int, isDualPage: Bool, isDatabaseLoading: Bool, backgroundColor: Color, + config: ImageStackConfig, imageURLs: [Int: URL], originalImageURLs: [Int: URL], + loadingStates: [Int: LoadingState], enablesLiveText: Bool, + liveTextGroups: [Int: [LiveTextGroup]], focusedLiveTextGroup: LiveTextGroup?, + liveTextTapAction: @escaping (LiveTextGroup) -> Void, + fetchAction: @escaping (Int) -> Void, + refetchAction: @escaping (Int) -> Void, prefetchAction: @escaping (Int) -> Void, + loadRetryAction: @escaping (Int) -> Void, loadSucceededAction: @escaping (Int) -> Void, + loadFailedAction: @escaping (Int) -> Void, copyImageAction: @escaping (URL) -> Void, + saveImageAction: @escaping (URL) -> Void, shareImageAction: @escaping (URL) -> Void + ) { + self.index = index + self.isDualPage = isDualPage + self.isDatabaseLoading = isDatabaseLoading + self.backgroundColor = backgroundColor + self.config = config + self.imageURLs = imageURLs + self.originalImageURLs = originalImageURLs + self.loadingStates = loadingStates + self.enablesLiveText = enablesLiveText + self.liveTextGroups = liveTextGroups + self.focusedLiveTextGroup = focusedLiveTextGroup + self.liveTextTapAction = liveTextTapAction + self.fetchAction = fetchAction + self.refetchAction = refetchAction + self.prefetchAction = prefetchAction + self.loadRetryAction = loadRetryAction + self.loadSucceededAction = loadSucceededAction + self.loadFailedAction = loadFailedAction + self.copyImageAction = copyImageAction + self.saveImageAction = saveImageAction + self.shareImageAction = shareImageAction + } + + var body: some View { + HStack(spacing: 0) { + if config.isFirstAvailable { + imageContainer(index: config.firstIndex) + } + if config.isSecondAvailable { + imageContainer(index: config.secondIndex) + } + } + } + + func imageContainer(index: Int) -> some View { + ImageContainer( + index: index, + imageURL: imageURLs[index], + loadingState: loadingStates[index] ?? .idle, + isDualPage: isDualPage, + backgroundColor: backgroundColor, + enablesLiveText: enablesLiveText, + liveTextGroups: liveTextGroups[index] ?? [], + focusedLiveTextGroup: focusedLiveTextGroup, + liveTextTapAction: liveTextTapAction, + refetchAction: refetchAction, + loadRetryAction: loadRetryAction, + loadSucceededAction: loadSucceededAction, + loadFailedAction: loadFailedAction + ) + .onAppear { + if !isDatabaseLoading { + if imageURLs[index] == nil { + fetchAction(index) + } + prefetchAction(index) + } + } + .contextMenu { contextMenuItems(index: index) } + } + @ViewBuilder private func contextMenuItems(index: Int) -> some View { + Button { + refetchAction(index) + } label: { + Label(L10n.Localizable.ReadingView.ContextMenu.Button.reload, systemSymbol: .arrowCounterclockwise) + } + if let imageURL = imageURLs[index] { + Button { + copyImageAction(imageURL) + } label: { + Label(L10n.Localizable.ReadingView.ContextMenu.Button.copy, systemSymbol: .plusSquareOnSquare) + } + Button { + saveImageAction(imageURL) + } label: { + Label(L10n.Localizable.ReadingView.ContextMenu.Button.save, systemSymbol: .squareAndArrowDown) + } + if let originalImageURL = originalImageURLs[index] { + Button { + saveImageAction(originalImageURL) + } label: { + Label( + L10n.Localizable.ReadingView.ContextMenu.Button.saveOriginal, + systemSymbol: .squareAndArrowDownOnSquare + ) + } + } + Button { + shareImageAction(imageURL) + } label: { + Label(L10n.Localizable.ReadingView.ContextMenu.Button.share, systemSymbol: .squareAndArrowUp) + } + } + } +} + +// MARK: ImageContainer +struct ImageContainer: View { + private var width: CGFloat { + DeviceUtil.windowW / (isDualPage ? 2 : 1) + } + private var height: CGFloat { + width / Defaults.ImageSize.contentAspect + } + + private let index: Int + private let imageURL: URL? + private let loadingState: LoadingState + private let isDualPage: Bool + private let backgroundColor: Color + private let enablesLiveText: Bool + private let liveTextGroups: [LiveTextGroup] + private let focusedLiveTextGroup: LiveTextGroup? + private let liveTextTapAction: (LiveTextGroup) -> Void + private let refetchAction: (Int) -> Void + private let loadRetryAction: (Int) -> Void + private let loadSucceededAction: (Int) -> Void + private let loadFailedAction: (Int) -> Void + + init( + index: Int, imageURL: URL?, + loadingState: LoadingState, + isDualPage: Bool, + backgroundColor: Color, + enablesLiveText: Bool, + liveTextGroups: [LiveTextGroup], + focusedLiveTextGroup: LiveTextGroup?, + liveTextTapAction: @escaping (LiveTextGroup) -> Void, + refetchAction: @escaping (Int) -> Void, + loadRetryAction: @escaping (Int) -> Void, + loadSucceededAction: @escaping (Int) -> Void, + loadFailedAction: @escaping (Int) -> Void + ) { + self.index = index + self.imageURL = imageURL + self.loadingState = loadingState + self.isDualPage = isDualPage + self.backgroundColor = backgroundColor + self.enablesLiveText = enablesLiveText + self.liveTextGroups = liveTextGroups + self.focusedLiveTextGroup = focusedLiveTextGroup + self.liveTextTapAction = liveTextTapAction + self.refetchAction = refetchAction + self.loadRetryAction = loadRetryAction + self.loadSucceededAction = loadSucceededAction + self.loadFailedAction = loadFailedAction + } + + private func placeholder(_ progress: Progress) -> some View { + Placeholder(style: .progress( + pageNumber: index, progress: progress, + isDualPage: isDualPage, backgroundColor: backgroundColor + )) + .frame(width: width, height: height) + } + @ViewBuilder private func image(url: URL?) -> some View { + if let url, url.isFileURL { + if url.isAnimatedImage { + KFAnimatedImage(url) + .cacheMemoryOnly() + .placeholder(placeholder).fade(duration: 0.25) + .onSuccess(onSuccess).onFailure(onFailure) + } else { + KFImage.url( + url, + cacheKey: localFileCacheKey(url) + ) + .cacheMemoryOnly() + .placeholder(placeholder) + .defaultModifier(withRoundedCorners: false) + .onSuccess(onSuccess).onFailure(onFailure) + } + } else if url?.isAnimatedImage != true { + KFImage.url( + url, + cacheKey: url?.stableImageCacheKey ?? url?.absoluteString + ) + .placeholder(placeholder) + .defaultModifier(withRoundedCorners: false) + .onSuccess(onSuccess).onFailure(onFailure) + } else { + KFAnimatedImage(url) + .placeholder(placeholder).fade(duration: 0.25) + .onSuccess(onSuccess).onFailure(onFailure) + } + } + + var body: some View { + if loadingState == .idle { + image(url: imageURL).scaledToFit().overlay( + LiveTextView( + liveTextGroups: liveTextGroups, + focusedLiveTextGroup: focusedLiveTextGroup, + tapAction: liveTextTapAction + ) + .opacity(enablesLiveText ? 1 : 0) + ) + } else { + ZStack { + backgroundColor + VStack { + Text(String(index)).font(.largeTitle.bold()) + .foregroundColor(.gray).padding(.bottom, 30) + ZStack { + Button(action: reloadImage) { + Image(systemSymbol: .exclamationmarkArrowTrianglehead2ClockwiseRotate90) + } + .font(.system(size: 30, weight: .medium)).foregroundColor(.gray) + .opacity(loadingState == .loading ? 0 : 1) + ProgressView().opacity(loadingState == .loading ? 1 : 0) + } + } + } + .frame(width: width, height: height) + } + } + private func reloadImage() { + if let error = loadingState.failed { + if case .webImageFailed = error { + loadRetryAction(index) + } else { + refetchAction(index) + } + } + } + private func onSuccess(_: RetrieveImageResult) { + loadSucceededAction(index) + } + private func onFailure(_: KingfisherError) { + if imageURL != nil { + loadFailedAction(index) + } + } + + private func localFileCacheKey(_ url: URL) -> String { + let resourceValues = try? url.resourceValues(forKeys: [ + .contentModificationDateKey, + .fileSizeKey + ]) + let modificationStamp = resourceValues?.contentModificationDate? + .timeIntervalSinceReferenceDate ?? .zero + let fileSize = resourceValues?.fileSize ?? 0 + return "local::\(url.path)#\(fileSize)#\(modificationStamp)" + } +} diff --git a/EhPanda/View/Search/SearchRootReducer.swift b/EhPanda/View/Search/SearchRootReducer.swift index d949d51d5..3afa2ecb8 100644 --- a/EhPanda/View/Search/SearchRootReducer.swift +++ b/EhPanda/View/Search/SearchRootReducer.swift @@ -91,11 +91,11 @@ struct SearchRootReducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil - ? .merge( - .send(.clearSubStates), - .send(.fetchDatabaseInfos) - ) - : .none + ? .merge( + .send(.clearSubStates), + .send(.fetchDatabaseInfos) + ) + : .none } Reduce { state, action in @@ -106,11 +106,11 @@ struct SearchRootReducer { case .setNavigation(let route): state.route = route return route == nil - ? .merge( - .send(.clearSubStates), - .send(.fetchDatabaseInfos) - ) - : .none + ? .merge( + .send(.clearSubStates), + .send(.fetchDatabaseInfos) + ) + : .none case .setKeyword(let keyword): state.keyword = keyword diff --git a/EhPanda/View/Search/SearchRootView+Keywords.swift b/EhPanda/View/Search/SearchRootView+Keywords.swift new file mode 100644 index 000000000..a352b1c3e --- /dev/null +++ b/EhPanda/View/Search/SearchRootView+Keywords.swift @@ -0,0 +1,144 @@ +// +// SearchRootView+Keywords.swift +// EhPanda +// + +import SwiftUI + +// MARK: DoubleVerticalKeywordsStack +struct DoubleVerticalKeywordsStack: View { + private let keywords: [WrappedKeyword] + private let searchAction: (String) -> Void + private let removeAction: ((String) -> Void)? + + init( + keywords: [WrappedKeyword], + searchAction: @escaping (String) -> Void, + removeAction: ((String) -> Void)? = nil + ) { + self.keywords = keywords + self.searchAction = searchAction + self.removeAction = removeAction + } + + var singleKeywords: [WrappedKeyword] { + .init(keywords.prefix(min(keywords.count, 10))) + } + var doubleKeywords: ([WrappedKeyword], [WrappedKeyword]) { + var leadingKeywords = [WrappedKeyword]() + var trailingKeywords = [WrappedKeyword]() + keywords.enumerated().forEach { (index, keyword) in + guard index < 20 else { return } + if index % 2 == 0 { + leadingKeywords.append(keyword) + } else { + trailingKeywords.append(keyword) + } + } + return (leadingKeywords, trailingKeywords) + } + + var body: some View { + HStack(alignment: .top, spacing: 30) { + if !DeviceUtil.isPad { + VerticalKeywordsStack( + keywords: singleKeywords, + searchAction: searchAction, + removeAction: removeAction + ) + } else { + let (leadingKeywords, trailingKeywords) = doubleKeywords + VerticalKeywordsStack( + keywords: leadingKeywords, + searchAction: searchAction, + removeAction: removeAction + ) + VerticalKeywordsStack( + keywords: trailingKeywords, + searchAction: searchAction, + removeAction: removeAction + ) + } + } + .padding() + } +} + +struct VerticalKeywordsStack: View { + private let keywords: [WrappedKeyword] + private let searchAction: (String) -> Void + private let removeAction: ((String) -> Void)? + + init(keywords: [WrappedKeyword], searchAction: @escaping (String) -> Void, removeAction: ((String) -> Void)?) { + self.keywords = keywords + self.searchAction = searchAction + self.removeAction = removeAction + } + + var body: some View { + VStack(spacing: 10) { + ForEach(keywords, id: \.self) { keyword in + VStack(alignment: .leading, spacing: 10) { + KeywordCell(wrappedKeyword: keyword, searchAction: searchAction, removeAction: removeAction) + Divider().opacity(keyword == keywords.last ? 0 : 1) + } + } + } + } +} + +struct KeywordCell: View { + private let wrappedKeyword: WrappedKeyword + private let searchAction: (String) -> Void + private let removeAction: ((String) -> Void)? + + init(wrappedKeyword: WrappedKeyword, searchAction: @escaping (String) -> Void, removeAction: ((String) -> Void)?) { + self.wrappedKeyword = wrappedKeyword + self.searchAction = searchAction + self.removeAction = removeAction + } + + var title: String { + wrappedKeyword.displayText.isEmpty ? wrappedKeyword.keyword : wrappedKeyword.displayText + } + + var body: some View { + HStack(spacing: 20) { + Button { + searchAction(wrappedKeyword.keyword) + } label: { + Image(systemSymbol: .magnifyingglass) + + Text(title) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } + .tint(.primary) + + if removeAction != nil { + Button { + removeAction?(wrappedKeyword.keyword) + } label: { + Image(systemSymbol: .xmark) + .imageScale(.small) + .foregroundColor(.secondary) + } + } + } + } +} + +// MARK: Definition +struct WrappedKeyword: Hashable { + let keyword: String + let displayText: String + + init(keyword: String, displayText: String) { + self.keyword = keyword + self.displayText = displayText + } + + init(keyword: String) { + self.init(keyword: keyword, displayText: .init()) + } +} diff --git a/EhPanda/View/Search/SearchRootView.swift b/EhPanda/View/Search/SearchRootView.swift index 23622e3df..6379808a1 100644 --- a/EhPanda/View/Search/SearchRootView.swift +++ b/EhPanda/View/Search/SearchRootView.swift @@ -27,54 +27,54 @@ struct SearchRootView: View { var body: some View { NavigationView { let content = - ScrollView(showsIndicators: false) { - SuggestionsPanel( - historyKeywords: store.historyKeywords.reversed(), - historyGalleries: store.historyGalleries, - quickSearchWords: store.quickSearchWords, - navigateGalleryAction: { store.send(.setNavigation(.detail($0))) }, - navigateQuickSearchAction: { store.send(.setNavigation(.quickSearch())) }, - searchKeywordAction: { keyword in + ScrollView(showsIndicators: false) { + SuggestionsPanel( + historyKeywords: store.historyKeywords.reversed(), + historyGalleries: store.historyGalleries, + quickSearchWords: store.quickSearchWords, + navigateGalleryAction: { store.send(.setNavigation(.detail($0))) }, + navigateQuickSearchAction: { store.send(.setNavigation(.quickSearch())) }, + searchKeywordAction: { keyword in + store.send(.setKeyword(keyword)) + store.send(.setNavigation(.search)) + }, + removeKeywordAction: { store.send(.removeHistoryKeyword($0)) } + ) + } + .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in + QuickSearchView( + store: store.scope(state: \.quickSearchState, action: \.quickSearch) + ) { keyword in + store.send(.setNavigation(nil)) store.send(.setKeyword(keyword)) - store.send(.setNavigation(.search)) - }, - removeKeywordAction: { store.send(.removeHistoryKeyword($0)) } - ) - } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) - .autoBlur(radius: blurRadius).environment(\.inSheet, true) - } - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.setKeyword(keyword)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - store.send(.setNavigation(.search)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + store.send(.setNavigation(.search)) + } } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion - ) - } - .onSubmit(of: .search) { - store.send(.setNavigation(.search)) - } - .onAppear { - store.send(.fetchHistoryGalleries) - store.send(.fetchDatabaseInfos) - } - .background(navigationLinks) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.SearchView.Title.search) + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: tagTranslator.translations, + showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + ) + } + .onSubmit(of: .search) { + store.send(.setNavigation(.search)) + } + .onAppear { + store.send(.fetchHistoryGalleries) + store.send(.fetchDatabaseInfos) + } + .background(navigationLinks) + .toolbar(content: toolbar) + .navigationTitle(L10n.Localizable.SearchView.Title.search) if DeviceUtil.isPad { content @@ -263,128 +263,6 @@ private struct HistoryKeywordsSection: View { } } -private struct DoubleVerticalKeywordsStack: View { - private let keywords: [WrappedKeyword] - private let searchAction: (String) -> Void - private let removeAction: ((String) -> Void)? - - init( - keywords: [WrappedKeyword], - searchAction: @escaping (String) -> Void, - removeAction: ((String) -> Void)? = nil - ) { - self.keywords = keywords - self.searchAction = searchAction - self.removeAction = removeAction - } - - var singleKeywords: [WrappedKeyword] { - .init(keywords.prefix(min(keywords.count, 10))) - } - var doubleKeywords: ([WrappedKeyword], [WrappedKeyword]) { - var leadingKeywords = [WrappedKeyword]() - var trailingKeywords = [WrappedKeyword]() - keywords.enumerated().forEach { (index, keyword) in - guard index < 20 else { return } - if index % 2 == 0 { - leadingKeywords.append(keyword) - } else { - trailingKeywords.append(keyword) - } - } - return (leadingKeywords, trailingKeywords) - } - - var body: some View { - HStack(alignment: .top, spacing: 30) { - if !DeviceUtil.isPad { - VerticalKeywordsStack( - keywords: singleKeywords, - searchAction: searchAction, - removeAction: removeAction - ) - } else { - let (leadingKeywords, trailingKeywords) = doubleKeywords - VerticalKeywordsStack( - keywords: leadingKeywords, - searchAction: searchAction, - removeAction: removeAction - ) - VerticalKeywordsStack( - keywords: trailingKeywords, - searchAction: searchAction, - removeAction: removeAction - ) - } - } - .padding() - } -} - -private struct VerticalKeywordsStack: View { - private let keywords: [WrappedKeyword] - private let searchAction: (String) -> Void - private let removeAction: ((String) -> Void)? - - init(keywords: [WrappedKeyword], searchAction: @escaping (String) -> Void, removeAction: ((String) -> Void)?) { - self.keywords = keywords - self.searchAction = searchAction - self.removeAction = removeAction - } - - var body: some View { - VStack(spacing: 10) { - ForEach(keywords, id: \.self) { keyword in - VStack(alignment: .leading, spacing: 10) { - KeywordCell(wrappedKeyword: keyword, searchAction: searchAction, removeAction: removeAction) - Divider().opacity(keyword == keywords.last ? 0 : 1) - } - } - } - } -} - -private struct KeywordCell: View { - private let wrappedKeyword: WrappedKeyword - private let searchAction: (String) -> Void - private let removeAction: ((String) -> Void)? - - init(wrappedKeyword: WrappedKeyword, searchAction: @escaping (String) -> Void, removeAction: ((String) -> Void)?) { - self.wrappedKeyword = wrappedKeyword - self.searchAction = searchAction - self.removeAction = removeAction - } - - var title: String { - wrappedKeyword.displayText.isEmpty ? wrappedKeyword.keyword : wrappedKeyword.displayText - } - - var body: some View { - HStack(spacing: 20) { - Button { - searchAction(wrappedKeyword.keyword) - } label: { - Image(systemSymbol: .magnifyingglass) - - Text(title) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - } - .tint(.primary) - - if removeAction != nil { - Button { - removeAction?(wrappedKeyword.keyword) - } label: { - Image(systemSymbol: .xmark) - .imageScale(.small) - .foregroundColor(.secondary) - } - } - } - } -} - // MARK: HistoryGalleriesSection private struct HistoryGalleriesSection: View { private let galleries: [Gallery] @@ -414,21 +292,6 @@ private struct HistoryGalleriesSection: View { } } -// MARK: Definition -private struct WrappedKeyword: Hashable { - let keyword: String - let displayText: String - - init(keyword: String, displayText: String) { - self.keyword = keyword - self.displayText = displayText - } - - init(keyword: String) { - self.init(keyword: keyword, displayText: .init()) - } -} - struct SearchRootView_Previews: PreviewProvider { static var previews: some View { SearchRootView( diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index cb78abdcf..2de69ee5a 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -28,55 +28,55 @@ struct SearchView: View { var body: some View { let content = - GenericList( - galleries: store.galleries, - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState, - footerLoadingState: store.footerLoadingState, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - }, - downloadBadges: store.downloadBadges - ) - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.fetchGalleries(keyword)) - } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) - .accentColor(setting.accentColor).autoBlur(radius: blurRadius) - } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + GenericList( + galleries: store.galleries, + setting: setting, + pageNumber: store.pageNumber, + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + }, + downloadBadges: store.downloadBadges ) - } - .onSubmit(of: .search) { - store.send(.fetchGalleries()) - } - .onAppear { - store.send(.onAppear) - if store.galleries.isEmpty { - DispatchQueue.main.async { + .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in + QuickSearchView( + store: store.scope(state: \.quickSearchState, action: \.quickSearch) + ) { keyword in + store.send(.setNavigation(nil)) store.send(.fetchGalleries(keyword)) } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(store.lastKeyword) + .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) + .accentColor(setting.accentColor).autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: tagTranslator.translations, + showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + ) + } + .onSubmit(of: .search) { + store.send(.fetchGalleries()) + } + .onAppear { + store.send(.onAppear) + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries(keyword)) + } + } + } + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(store.lastKeyword) if DeviceUtil.isPad { content diff --git a/EhPanda/View/Search/Support/QuickSearchView.swift b/EhPanda/View/Search/Support/QuickSearchView.swift index c4acd8ce6..d9e2e2c0b 100644 --- a/EhPanda/View/Search/Support/QuickSearchView.swift +++ b/EhPanda/View/Search/Support/QuickSearchView.swift @@ -73,13 +73,13 @@ struct QuickSearchView: View { } LoadingView().opacity( store.loadingState == .loading - && store.quickSearchWords.isEmpty ? 1 : 0 + && store.quickSearchWords.isEmpty ? 1 : 0 ) ErrorView(error: .notFound) - .opacity( - store.loadingState != .loading - && store.quickSearchWords.isEmpty ? 1 : 0 - ) + .opacity( + store.loadingState != .loading + && store.quickSearchWords.isEmpty ? 1 : 0 + ) } .synchronize($store.focusedField, $focusedField) .environment(\.editMode, $store.listEditMode) diff --git a/EhPanda/View/Setting/Components/WebView.swift b/EhPanda/View/Setting/Components/WebView.swift index 885152caa..f771497a9 100644 --- a/EhPanda/View/Setting/Components/WebView.swift +++ b/EhPanda/View/Setting/Components/WebView.swift @@ -26,8 +26,8 @@ struct WebView: UIViewControllerRepresentable { guard parent.url.absoluteString == Defaults.URL.webLogin.absoluteString, let webViewURL = webView.url, let queryItems = URLComponents(url: webViewURL, resolvingAgainstBaseURL: false)?.queryItems, queryItems.contains(where: { queryItem in - queryItem.name == Defaults.URL.Component.Key.code.rawValue - && queryItem.value == Defaults.URL.Component.Value.zeroOne.rawValue + queryItem.name == Defaults.URL.Component.Key.code.rawValue + && queryItem.value == Defaults.URL.Component.Value.zeroOne.rawValue }) else { return } diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift new file mode 100644 index 000000000..146c2782a --- /dev/null +++ b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift @@ -0,0 +1,312 @@ +// +// EhSettingView+Sections1.swift +// EhPanda +// + +import SwiftUI +import ComposableArchitecture + +// MARK: EhProfileSection +struct EhProfileSection: View { + @Binding var route: EhSettingReducer.Route? + @Binding var ehSetting: EhSetting + @Binding var ehProfile: EhProfile + @Binding var editingProfileName: String + let deleteAction: () -> Void + let deleteDialogAction: () -> Void + let performEhProfileAction: (EhProfileAction?, String?, Int) -> Void + + @FocusState private var isFocused + + var body: some View { + Section { + Picker(L10n.Localizable.EhSettingView.Title.selectedProfile, selection: $ehProfile) { + ForEach(ehSetting.ehProfiles) { ehProfile in + Text(ehProfile.name) + .tag(ehProfile) + } + } + .pickerStyle(.menu) + + if !ehProfile.isDefault { + Button(L10n.Localizable.EhSettingView.Button.setAsDefault) { + performEhProfileAction(.default, nil, ehProfile.value) + } + + Button( + L10n.Localizable.EhSettingView.Button.deleteProfile, + role: .destructive, + action: deleteDialogAction + ) + .confirmationDialog( + message: L10n.Localizable.ConfirmationDialog.Title.delete, + unwrapping: $route, + case: \.deleteProfile + ) { + Button( + L10n.Localizable.ConfirmationDialog.Button.delete, + role: .destructive, action: deleteAction + ) + } + } + } header: { + Text(L10n.Localizable.EhSettingView.Section.Title.profileSettings) + .ehSettingRegularHeaderStyled() + } + .onChange(of: ehProfile) { _, newValue in + performEhProfileAction(nil, nil, newValue.value) + } + + Section { + SettingTextField(text: $editingProfileName, width: nil, alignment: .leading, background: .clear) + .focused($isFocused) + + Button(L10n.Localizable.EhSettingView.Button.rename) { + performEhProfileAction(.rename, editingProfileName, ehProfile.value) + } + .disabled(isFocused) + + if ehSetting.isCapableOfCreatingNewProfile { + Button(L10n.Localizable.EhSettingView.Button.createNew) { + performEhProfileAction(.create, editingProfileName, ehProfile.value) + } + .disabled(isFocused) + } + } + } +} + +// MARK: ImageLoadSettingsSection +struct ImageLoadSettingsSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Picker( + L10n.Localizable.EhSettingView.Title.loadImagesThroughTheHathNetwork, + selection: $ehSetting.loadThroughHathSetting + ) { + ForEach(ehSetting.capableLoadThroughHathSettings) { setting in + Text(setting.value) + .tag(setting) + } + } + .pickerStyle(.menu) + } header: { + Text.ehSettingBoldHeader(L10n.Localizable.EhSettingView.Section.Title.imageLoadSettings) + } footer: { + Text(ehSetting.loadThroughHathSetting.description) + } + + Section { + Picker(L10n.Localizable.EhSettingView.Title.browsingCountry, selection: $ehSetting.browsingCountry) { + ForEach(EhSetting.BrowsingCountry.allCases) { country in + Text(country.name) + .tag(country) + .foregroundColor(country == ehSetting.browsingCountry ? .accentColor : .primary) + } + } + } header: { + Text( + L10n.Localizable.EhSettingView.Description.browsingCountry( + ehSetting.localizedLiteralBrowsingCountry ?? ehSetting.literalBrowsingCountry + ) + .localizedKey + ) + .ehSettingRegularHeaderStyled() + } + } +} + +// MARK: ImageSizeSettingsSection +struct ImageSizeSettingsSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Picker(L10n.Localizable.EhSettingView.Title.imageResolution, selection: $ehSetting.imageResolution) { + ForEach(ehSetting.capableImageResolutions) { setting in + Text(setting.value) + .tag(setting) + } + } + .pickerStyle(.menu) + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.imageSizeSettings, + description: L10n.Localizable.EhSettingView.Description.imageResolution + ) + } + + if let useOriginalImagesBinding = Binding($ehSetting.useOriginalImages) { + Section { + Toggle( + L10n.Localizable.EhSettingView.Title.useOriginalImages, + isOn: useOriginalImagesBinding + ) + } header: { + Text(L10n.Localizable.EhSettingView.Section.Title.originalImages) + .ehSettingRegularHeaderStyled() + } + } + + Section { + Text(L10n.Localizable.EhSettingView.Title.imageSize) + + EhSettingValuePicker( + title: L10n.Localizable.EhSettingView.Title.horizontal, + value: $ehSetting.imageSizeWidth, range: 0...65535, unit: "px" + ) + + EhSettingValuePicker( + title: L10n.Localizable.EhSettingView.Title.vertical, + value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px" + ) + } header: { + Text(L10n.Localizable.EhSettingView.Description.imageSize) + .ehSettingRegularHeaderStyled() + } + } +} + +// MARK: GalleryNameDisplaySection +struct GalleryNameDisplaySection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Picker(L10n.Localizable.EhSettingView.Title.galleryName, selection: $ehSetting.galleryName) { + ForEach(EhSetting.GalleryName.allCases) { name in + Text(name.value) + .tag(name) + } + } + .pickerStyle(.menu) + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.galleryNameDisplay, + description: L10n.Localizable.EhSettingView.Description.galleryName + ) + } + } +} + +// MARK: ArchiverSettingsSection +struct ArchiverSettingsSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Picker(L10n.Localizable.EhSettingView.Title.archiverBehavior, selection: $ehSetting.archiverBehavior) { + ForEach(EhSetting.ArchiverBehavior.allCases) { behavior in + Text(behavior.value) + .tag(behavior) + } + } + .pickerStyle(.menu) + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.archiverSettings, + description: L10n.Localizable.EhSettingView.Description.archiverBehavior + ) + } + } +} + +// MARK: FrontPageSettingsSection +struct FrontPageSettingsSection: View { + @Binding var ehSetting: EhSetting + + private var categoryBindings: [Binding] { + $ehSetting.disabledCategories.map({ $0 }) + } + + var body: some View { + Section { + CategoryView(bindings: categoryBindings) + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.frontPageSettings, + description: L10n.Localizable.EhSettingView.Description.galleryCategory + ) + } + + Section { + Picker(L10n.Localizable.EhSettingView.Title.displayMode, selection: $ehSetting.displayMode) { + ForEach(EhSetting.DisplayMode.allCases) { mode in + Text(mode.value) + .tag(mode) + } + } + .pickerStyle(.menu) + } header: { + Text(L10n.Localizable.EhSettingView.Description.displayMode) + .ehSettingRegularHeaderStyled() + } + + Section { + Toggle( + L10n.Localizable.EhSettingView.Title.showSearchRangeIndicator, + isOn: $ehSetting.showSearchRangeIndicator + ) + } header: { + Text(L10n.Localizable.EhSettingView.Section.Title.showSearchRangeIndicator) + .ehSettingRegularHeaderStyled() + } + } +} + +// MARK: Shared Helpers +struct EhSettingValuePicker: View { + private let title: String + @Binding var value: Float + private let range: ClosedRange + private let unit: String + + init(title: String, value: Binding, range: ClosedRange, unit: String = "") { + self.title = title + _value = value + self.range = range + self.unit = unit + } + + var body: some View { + LabeledContent(title) { + Text(String(Int(value)) + unit) + .foregroundStyle(.tint) + } + + Slider( + value: $value, + in: range, + label: EmptyView.init, + minimumValueLabel: { + Text(String(Int(range.lowerBound)) + unit) + .fontWeight(.medium) + .font(.callout) + }, + maximumValueLabel: { + Text(String(Int(range.upperBound)) + unit) + .fontWeight(.medium) + .font(.callout) + } + ) + } +} + +extension Text { + static func ehSettingBoldHeader(_ title: String, description: String? = nil) -> Self { + var result = AttributedString(title) + result.font = .body.weight(.bold) + if let description { + var descriptionString = AttributedString("\n\(description)") + descriptionString.font = .subheadline.weight(.regular) + result.append(descriptionString) + } + return Text(result) + } + + func ehSettingRegularHeaderStyled() -> Self { + font(.subheadline.weight(.regular)) + } +} diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift new file mode 100644 index 000000000..6eb62a70a --- /dev/null +++ b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift @@ -0,0 +1,178 @@ +// +// EhSettingView+Sections2.swift +// EhPanda +// + +import SwiftUI + +// MARK: OptionalUIElementsSection +struct OptionalUIElementsSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Toggle( + L10n.Localizable.EhSettingView.Title.enableGalleryThumbnailSelector, + isOn: $ehSetting.enableGalleryThumbnailSelector + ) + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.optionalUIElements, + description: L10n.Localizable.EhSettingView.Description.optionalUIElements + ) + } + } +} + +// MARK: FavoritesSection +struct EhSettingFavoritesSection: View { + @Binding var ehSetting: EhSetting + @FocusState private var isFocused + + private var tuples: [(Category, Binding)] { + Category.allFavoritesCases.enumerated().map { index, category in + (category, $ehSetting.favoriteCategories[index]) + } + } + + var body: some View { + Section { + ForEach(tuples, id: \.0) { category, nameBinding in + HStack(spacing: 30) { + Circle() + .foregroundColor(category.color) + .frame(width: 10) + + SettingTextField(text: nameBinding, width: nil, alignment: .leading, background: .clear) + .focused($isFocused) + } + .padding(.leading) + } + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.favorites, + description: L10n.Localizable.EhSettingView.Description.favoriteCategories + ) + } + + Section { + Picker( + L10n.Localizable.EhSettingView.Title.favoritesSortOrder, + selection: $ehSetting.favoritesSortOrder + ) { + ForEach(EhSetting.FavoritesSortOrder.allCases) { order in + Text(order.value) + .tag(order) + } + } + .pickerStyle(.menu) + } header: { + Text(L10n.Localizable.EhSettingView.Description.favoritesSortOrder) + .ehSettingRegularHeaderStyled() + } + } +} + +// MARK: RatingsSection +struct RatingsSection: View { + @Binding var ehSetting: EhSetting + @FocusState var isFocused + + var body: some View { + Section { + LabeledContent(L10n.Localizable.EhSettingView.Title.ratingsColor) { + SettingTextField( + text: $ehSetting.ratingsColor, + promptText: L10n.Localizable.EhSettingView.Promt.ratingsColor, + width: 80 + ) + .focused($isFocused) + } + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.ratings, + description: L10n.Localizable.EhSettingView.Description.ratingsColor + ) + } + } +} + +// MARK: SearchResultCountSection +struct SearchResultCountSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Picker(L10n.Localizable.EhSettingView.Title.resultCount, selection: $ehSetting.searchResultCount) { + ForEach(ehSetting.capableSearchResultCounts) { count in + Text(String(count.value)) + .tag(count) + } + } + .pickerStyle(.menu) + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.searchResultCount, + description: L10n.Localizable.EhSettingView.Description.resultCount + ) + } + } +} + +// MARK: ThumbnailSettingsSection +struct ThumbnailSettingsSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Picker( + L10n.Localizable.EhSettingView.Title.thumbnailLoadTiming, + selection: $ehSetting.thumbnailLoadTiming + ) { + ForEach(EhSetting.ThumbnailLoadTiming.allCases) { timing in + Text(timing.value) + .tag(timing) + } + } + .pickerStyle(.menu) + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.thumbnailSettings, + description: L10n.Localizable.EhSettingView.Description.thumbnailLoadTiming + ) + } footer: { + Text(ehSetting.thumbnailLoadTiming.description) + } + + Section { + LabeledContent(L10n.Localizable.EhSettingView.Title.thumbnailSize) { + Picker(selection: $ehSetting.thumbnailConfigSize) { + ForEach(ehSetting.capableThumbnailConfigSizes) { size in + Text(size.value) + .tag(size) + } + } label: { + Text(ehSetting.thumbnailConfigSize.value) + } + .pickerStyle(.segmented) + .frame(width: 200) + } + + LabeledContent(L10n.Localizable.EhSettingView.Title.thumbnailRowCount) { + Picker(selection: $ehSetting.thumbnailConfigRows) { + ForEach(ehSetting.capableThumbnailConfigRowCounts) { row in + Text(row.value) + .tag(row) + } + } label: { + Text(ehSetting.capableThumbnailConfigRowCount.value) + } + .pickerStyle(.segmented) + .frame(width: 200) + } + } header: { + Text(L10n.Localizable.EhSettingView.Description.thumbnailConfiguration) + .ehSettingRegularHeaderStyled() + } + } +} diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift new file mode 100644 index 000000000..3c309bcc8 --- /dev/null +++ b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift @@ -0,0 +1,350 @@ +// +// EhSettingView+Sections3.swift +// EhPanda +// + +import SwiftUI + +// MARK: CoverScalingSection +struct CoverScalingSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + EhSettingValuePicker( + title: L10n.Localizable.EhSettingView.Title.scaleFactor, + value: $ehSetting.coverScaleFactor, + range: 75...150, + unit: "%" + ) + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.coverScaling, + description: L10n.Localizable.EhSettingView.Description.coverScaleFactor + ) + } + } +} + +// MARK: TagFilteringThresholdSection +struct TagFilteringThresholdSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + EhSettingValuePicker( + title: L10n.Localizable.EhSettingView.Title.tagFilteringThreshold, + value: $ehSetting.tagFilteringThreshold, range: -9999...0 + ) + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.tagFilteringThreshold, + description: L10n.Localizable.EhSettingView.Description.tagFilteringThreshold + ) + } + } +} + +// MARK: TagWatchingThresholdSection +struct TagWatchingThresholdSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + EhSettingValuePicker( + title: L10n.Localizable.EhSettingView.Title.tagWatchingThreshold, + value: $ehSetting.tagWatchingThreshold, range: 0...9999 + ) + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.tagWatchingThreshold, + description: L10n.Localizable.EhSettingView.Description.tagWatchingThreshold + ) + } + } +} + +// MARK: FilteredRemovalCountSection +struct FilteredRemovalCountSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Toggle( + L10n.Localizable.EhSettingView.Title.showFilteredRemovalCount, + isOn: $ehSetting.showFilteredRemovalCount + ) + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.filteredRemovalCount, + description: L10n.Localizable.EhSettingView.Description.filteredRemovalCount + ) + } + } +} + +// MARK: ExcludedLanguagesSection +struct ExcludedLanguagesSection: View { + @Binding var ehSetting: EhSetting + + private let languages = Language.allExcludedCases.map(\.value) + private var languageBindings: [Binding] { + $ehSetting.excludedLanguages.map({ $0 }) + } + private func rowBindings(index: Int) -> [Binding] { + [-1, 0, 1].map { num in + let index = index * 3 + num + if index != -1 { + return languageBindings[index] + } else { + return .constant(false) + } + } + } + + var body: some View { + Section { + HStack { + Text("") + .frame(width: DeviceUtil.windowW * 0.25) + + ForEach(EhSetting.ExcludedLanguagesCategory.allCases) { category in + Color.clear + .overlay { + Text(category.value) + .lineLimit(1) + .font(.subheadline) + .fixedSize() + } + } + } + + ForEach(0..<(languageBindings.count / 3) + 1, id: \.self) { index in + ExcludeRow( + title: languages[index], + bindings: rowBindings(index: index), + isFirstRow: index == 0 + ) + } + } header: { + Text.ehSettingBoldHeader( + L10n.Localizable.EhSettingView.Section.Title.excludedLanguages, + description: L10n.Localizable.EhSettingView.Description.excludedLanguages + ) + } + } +} + +struct ExcludeRow: View { + let title: String + let bindings: [Binding] + let isFirstRow: Bool + + var body: some View { + HStack { + Text(title) + .lineLimit(1) + .font(.subheadline) + .fixedSize() + .frame(maxWidth: .infinity, alignment: .leading) + .frame(width: DeviceUtil.windowW * 0.25) + + ForEach(0.. Void - private let deleteDialogAction: () -> Void - private let performEhProfileAction: (EhProfileAction?, String?, Int) -> Void - - @FocusState private var isFocused - - init( - route: Binding, ehSetting: Binding, - ehProfile: Binding, editingProfileName: Binding, - deleteAction: @escaping () -> Void, deleteDialogAction: @escaping () -> Void, - performEhProfileAction: @escaping (EhProfileAction?, String?, Int) -> Void - ) { - _route = route - _ehSetting = ehSetting - _ehProfile = ehProfile - _editingProfileName = editingProfileName - self.deleteAction = deleteAction - self.deleteDialogAction = deleteDialogAction - self.performEhProfileAction = performEhProfileAction - } - - var body: some View { - Section { - Picker(L10n.Localizable.EhSettingView.Title.selectedProfile, selection: $ehProfile) { - ForEach(ehSetting.ehProfiles) { ehProfile in - Text(ehProfile.name) - .tag(ehProfile) - } - } - .pickerStyle(.menu) - - if !ehProfile.isDefault { - Button(L10n.Localizable.EhSettingView.Button.setAsDefault) { - performEhProfileAction(.default, nil, ehProfile.value) - } - - Button( - L10n.Localizable.EhSettingView.Button.deleteProfile, - role: .destructive, - action: deleteDialogAction - ) - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.delete, - unwrapping: $route, - case: \.deleteProfile - ) { - Button( - L10n.Localizable.ConfirmationDialog.Button.delete, - role: .destructive, action: deleteAction - ) - } - } - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.profileSettings) - .regularHeaderStyled() - } - .onChange(of: ehProfile) { _, newValue in - performEhProfileAction(nil, nil, newValue.value) - } - - Section { - SettingTextField(text: $editingProfileName, width: nil, alignment: .leading, background: .clear) - .focused($isFocused) - - Button(L10n.Localizable.EhSettingView.Button.rename) { - performEhProfileAction(.rename, editingProfileName, ehProfile.value) - } - .disabled(isFocused) - - if ehSetting.isCapableOfCreatingNewProfile { - Button(L10n.Localizable.EhSettingView.Button.createNew) { - performEhProfileAction(.create, editingProfileName, ehProfile.value) - } - .disabled(isFocused) - } - } - } -} - -// MARK: ImageLoadSettingsSection -private struct ImageLoadSettingsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Picker( - L10n.Localizable.EhSettingView.Title.loadImagesThroughTheHathNetwork, - selection: $ehSetting.loadThroughHathSetting - ) { - ForEach(ehSetting.capableLoadThroughHathSettings) { setting in - Text(setting.value) - .tag(setting) - } - } - .pickerStyle(.menu) - } header: { - Text.boldHeader(L10n.Localizable.EhSettingView.Section.Title.imageLoadSettings) - } footer: { - Text(ehSetting.loadThroughHathSetting.description) - } - - Section { - Picker(L10n.Localizable.EhSettingView.Title.browsingCountry, selection: $ehSetting.browsingCountry) { - ForEach(EhSetting.BrowsingCountry.allCases) { country in - Text(country.name) - .tag(country) - .foregroundColor(country == ehSetting.browsingCountry ? .accentColor : .primary) - } - } - } header: { - Text( - L10n.Localizable.EhSettingView.Description.browsingCountry( - ehSetting.localizedLiteralBrowsingCountry ?? ehSetting.literalBrowsingCountry - ) - .localizedKey - ) - .regularHeaderStyled() - } - } -} - -// MARK: ImageSizeSettingsSection -private struct ImageSizeSettingsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Picker(L10n.Localizable.EhSettingView.Title.imageResolution, selection: $ehSetting.imageResolution) { - ForEach(ehSetting.capableImageResolutions) { setting in - Text(setting.value) - .tag(setting) - } - } - .pickerStyle(.menu) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.imageSizeSettings, - description: L10n.Localizable.EhSettingView.Description.imageResolution - ) - } - - if let useOriginalImagesBinding = Binding($ehSetting.useOriginalImages) { - Section { - Toggle( - L10n.Localizable.EhSettingView.Title.useOriginalImages, - isOn: useOriginalImagesBinding - ) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.originalImages) - .regularHeaderStyled() - } - } - - Section { - Text(L10n.Localizable.EhSettingView.Title.imageSize) - - ValuePicker( - title: L10n.Localizable.EhSettingView.Title.horizontal, - value: $ehSetting.imageSizeWidth, range: 0...65535, unit: "px" - ) - - ValuePicker( - title: L10n.Localizable.EhSettingView.Title.vertical, - value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px" - ) - } header: { - Text(L10n.Localizable.EhSettingView.Description.imageSize) - .regularHeaderStyled() - } - } -} - -// MARK: GalleryNameDisplaySection -private struct GalleryNameDisplaySection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Picker(L10n.Localizable.EhSettingView.Title.galleryName, selection: $ehSetting.galleryName) { - ForEach(EhSetting.GalleryName.allCases) { name in - Text(name.value) - .tag(name) - } - } - .pickerStyle(.menu) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.galleryNameDisplay, - description: L10n.Localizable.EhSettingView.Description.galleryName - ) - } - } -} - -// MARK: ArchiverSettingsSection -private struct ArchiverSettingsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Picker(L10n.Localizable.EhSettingView.Title.archiverBehavior, selection: $ehSetting.archiverBehavior) { - ForEach(EhSetting.ArchiverBehavior.allCases) { behavior in - Text(behavior.value) - .tag(behavior) - } - } - .pickerStyle(.menu) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.archiverSettings, - description: L10n.Localizable.EhSettingView.Description.archiverBehavior - ) - } - } -} - -// MARK: FrontPageSettingsSection -private struct FrontPageSettingsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - private var categoryBindings: [Binding] { - $ehSetting.disabledCategories.map({ $0 }) - } - - var body: some View { - Section { - CategoryView(bindings: categoryBindings) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.frontPageSettings, - description: L10n.Localizable.EhSettingView.Description.galleryCategory - ) - } - - Section { - Picker(L10n.Localizable.EhSettingView.Title.displayMode, selection: $ehSetting.displayMode) { - ForEach(EhSetting.DisplayMode.allCases) { mode in - Text(mode.value) - .tag(mode) - } - } - .pickerStyle(.menu) - } header: { - Text(L10n.Localizable.EhSettingView.Description.displayMode) - .regularHeaderStyled() - } - - Section { - Toggle( - L10n.Localizable.EhSettingView.Title.showSearchRangeIndicator, - isOn: $ehSetting.showSearchRangeIndicator - ) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.showSearchRangeIndicator) - .regularHeaderStyled() - } - } -} - -// MARK: OptionalUIElementsSection -private struct OptionalUIElementsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - self._ehSetting = ehSetting - } - - var body: some View { - Section { - Toggle( - L10n.Localizable.EhSettingView.Title.enableGalleryThumbnailSelector, - isOn: $ehSetting.enableGalleryThumbnailSelector - ) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.optionalUIElements, - description: L10n.Localizable.EhSettingView.Description.optionalUIElements - ) - } - } -} - -// MARK: FavoritesSection -private struct FavoritesSection: View { - @Binding private var ehSetting: EhSetting - @FocusState private var isFocused - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - private var tuples: [(Category, Binding)] { - Category.allFavoritesCases.enumerated().map { index, category in - (category, $ehSetting.favoriteCategories[index]) - } - } - - var body: some View { - Section { - ForEach(tuples, id: \.0) { category, nameBinding in - HStack(spacing: 30) { - Circle() - .foregroundColor(category.color) - .frame(width: 10) - - SettingTextField(text: nameBinding, width: nil, alignment: .leading, background: .clear) - .focused($isFocused) - } - .padding(.leading) - } - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.favorites, - description: L10n.Localizable.EhSettingView.Description.favoriteCategories - ) - } - - Section { - Picker( - L10n.Localizable.EhSettingView.Title.favoritesSortOrder, - selection: $ehSetting.favoritesSortOrder - ) { - ForEach(EhSetting.FavoritesSortOrder.allCases) { order in - Text(order.value) - .tag(order) - } - } - .pickerStyle(.menu) - } header: { - Text(L10n.Localizable.EhSettingView.Description.favoritesSortOrder) - .regularHeaderStyled() - } - } -} - -// MARK: RatingsSection -private struct RatingsSection: View { - @Binding private var ehSetting: EhSetting - @FocusState var isFocused - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - LabeledContent(L10n.Localizable.EhSettingView.Title.ratingsColor) { - SettingTextField( - text: $ehSetting.ratingsColor, - promptText: L10n.Localizable.EhSettingView.Promt.ratingsColor, - width: 80 - ) - .focused($isFocused) - } - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.ratings, - description: L10n.Localizable.EhSettingView.Description.ratingsColor - ) - } - } -} - -// MARK: TagFilteringThresholdSection -private struct TagFilteringThresholdSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - ValuePicker( - title: L10n.Localizable.EhSettingView.Title.tagFilteringThreshold, - value: $ehSetting.tagFilteringThreshold, range: -9999...0 - ) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.tagFilteringThreshold, - description: L10n.Localizable.EhSettingView.Description.tagFilteringThreshold - ) - } - } -} - -// MARK: TagWatchingThresholdSection -private struct TagWatchingThresholdSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - ValuePicker( - title: L10n.Localizable.EhSettingView.Title.tagWatchingThreshold, - value: $ehSetting.tagWatchingThreshold, range: 0...9999 - ) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.tagWatchingThreshold, - description: L10n.Localizable.EhSettingView.Description.tagWatchingThreshold - ) - } - } -} - -// MARK: FilteredRemovalCountSection -private struct FilteredRemovalCountSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Toggle( - L10n.Localizable.EhSettingView.Title.showFilteredRemovalCount, - isOn: $ehSetting.showFilteredRemovalCount - ) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.filteredRemovalCount, - description: L10n.Localizable.EhSettingView.Description.filteredRemovalCount - ) - } - } -} - -// MARK: ExcludedLanguagesSection -private struct ExcludedLanguagesSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - private let languages = Language.allExcludedCases.map(\.value) - private var languageBindings: [Binding] { - $ehSetting.excludedLanguages.map({ $0 }) - } - private func rowBindings(index: Int) -> [Binding] { - [-1, 0, 1].map { num in - let index = index * 3 + num - if index != -1 { - return languageBindings[index] - } else { - return .constant(false) - } - } - } - - var body: some View { - Section { - HStack { - Text("") - .frame(width: DeviceUtil.windowW * 0.25) - - ForEach(EhSetting.ExcludedLanguagesCategory.allCases) { category in - Color.clear - .overlay { - Text(category.value) - .lineLimit(1) - .font(.subheadline) - .fixedSize() - } - } - } - - ForEach(0..<(languageBindings.count / 3) + 1, id: \.self) { index in - ExcludeRow( - title: languages[index], - bindings: rowBindings(index: index), - isFirstRow: index == 0 - ) - } - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.excludedLanguages, - description: L10n.Localizable.EhSettingView.Description.excludedLanguages - ) - } - } -} - -private struct ExcludeRow: View { - private let title: String - private let bindings: [Binding] - private let isFirstRow: Bool - - init(title: String, bindings: [Binding], isFirstRow: Bool) { - self.title = title - self.bindings = bindings - self.isFirstRow = isFirstRow - } - - var body: some View { - HStack { - Text(title) - .lineLimit(1) - .font(.subheadline) - .fixedSize() - .frame(maxWidth: .infinity, alignment: .leading) - .frame(width: DeviceUtil.windowW * 0.25) - - ForEach(0..) { - _isOn = isOn - } - - var body: some View { - Color.clear - .overlay { - Image(systemSymbol: isOn ? .nosign : .circle) - .foregroundColor(isOn ? .red : .primary) - .font(.title) - } - .onTapGesture { - withAnimation { isOn.toggle() } - HapticsUtil.generateFeedback(style: .soft) - } - } -} - -// MARK: ExcludedUploadersSection -private struct ExcludedUploadersSection: View { - @Binding private var ehSetting: EhSetting - @FocusState var isFocused - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - TextEditor(text: $ehSetting.excludedUploaders) - .textInputAutocapitalization(.none) - .frame(maxHeight: DeviceUtil.windowH * 0.3) - .disableAutocorrection(true) - .focused($isFocused) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.excludedUploaders, - description: L10n.Localizable.EhSettingView.Description.excludedUploaders - ) - } footer: { - Text( - L10n.Localizable.EhSettingView.Description.excludedUploadersCount( - "\(ehSetting.excludedUploaders.lineCount)", "\(1000)" - ) - .localizedKey - ) - } - } -} - -// MARK: SearchResultCountSection -private struct SearchResultCountSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Picker(L10n.Localizable.EhSettingView.Title.resultCount, selection: $ehSetting.searchResultCount) { - ForEach(ehSetting.capableSearchResultCounts) { count in - Text(String(count.value)) - .tag(count) - } - } - .pickerStyle(.menu) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.searchResultCount, - description: L10n.Localizable.EhSettingView.Description.resultCount - ) - } - } -} - -// MARK: ThumbnailSettingsSection -private struct ThumbnailSettingsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - self._ehSetting = ehSetting - } - - var body: some View { - Section { - Picker( - L10n.Localizable.EhSettingView.Title.thumbnailLoadTiming, - selection: $ehSetting.thumbnailLoadTiming - ) { - ForEach(EhSetting.ThumbnailLoadTiming.allCases) { timing in - Text(timing.value) - .tag(timing) - } - } - .pickerStyle(.menu) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.thumbnailSettings, - description: L10n.Localizable.EhSettingView.Description.thumbnailLoadTiming - ) - } footer: { - Text(ehSetting.thumbnailLoadTiming.description) - } - - Section { - LabeledContent(L10n.Localizable.EhSettingView.Title.thumbnailSize) { - Picker(selection: $ehSetting.thumbnailConfigSize) { - ForEach(ehSetting.capableThumbnailConfigSizes) { size in - Text(size.value) - .tag(size) - } - } label: { - Text(ehSetting.thumbnailConfigSize.value) - } - .pickerStyle(.segmented) - .frame(width: 200) - } - - LabeledContent(L10n.Localizable.EhSettingView.Title.thumbnailRowCount) { - Picker(selection: $ehSetting.thumbnailConfigRows) { - ForEach(ehSetting.capableThumbnailConfigRowCounts) { row in - Text(row.value) - .tag(row) - } - } label: { - Text(ehSetting.capableThumbnailConfigRowCount.value) - } - .pickerStyle(.segmented) - .frame(width: 200) - } - } header: { - Text(L10n.Localizable.EhSettingView.Description.thumbnailConfiguration) - .regularHeaderStyled() - } - } -} - -// MARK: CoverScalingSection -private struct CoverScalingSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - self._ehSetting = ehSetting - } - - var body: some View { - Section { - ValuePicker( - title: L10n.Localizable.EhSettingView.Title.scaleFactor, - value: $ehSetting.coverScaleFactor, - range: 75...150, - unit: "%" - ) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.coverScaling, - description: L10n.Localizable.EhSettingView.Description.coverScaleFactor - ) - } - } -} - -// MARK: ViewportOverrideSection -private struct ViewportOverrideSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - ValuePicker( - title: L10n.Localizable.EhSettingView.Title.virtualWidth, - value: $ehSetting.viewportVirtualWidth, - range: 0...9999, - unit: "px" - ) - } header: { - Text.boldHeader( - L10n.Localizable.EhSettingView.Section.Title.viewportOverride, - description: L10n.Localizable.EhSettingView.Description.virtualWidth - ) - } - } -} - -private struct ValuePicker: View { - private let title: String - @Binding private var value: Float - private let range: ClosedRange - private let unit: String - - init(title: String, value: Binding, range: ClosedRange, unit: String = "") { - self.title = title - _value = value - self.range = range - self.unit = unit - } - - var body: some View { - LabeledContent(title) { - Text(String(Int(value)) + unit) - .foregroundStyle(.tint) - } - - Slider( - value: $value, - in: range, - label: EmptyView.init, - minimumValueLabel: { - Text(String(Int(range.lowerBound)) + unit) - .fontWeight(.medium) - .font(.callout) - }, - maximumValueLabel: { - Text(String(Int(range.upperBound)) + unit) - .fontWeight(.medium) - .font(.callout) - } - ) - } -} - -// MARK: GalleryCommentsSection -private struct GalleryCommentsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Picker( - L10n.Localizable.EhSettingView.Title.commentsSortOrder, - selection: $ehSetting.commentsSortOrder - ) { - ForEach(EhSetting.CommentsSortOrder.allCases) { order in - Text(order.value) - .tag(order) - } - } - .pickerStyle(.menu) - - Picker( - L10n.Localizable.EhSettingView.Title.commentsVotesShowTiming, - selection: $ehSetting.commentVotesShowTiming - ) { - ForEach(EhSetting.CommentVotesShowTiming.allCases) { timing in - Text(timing.value) - .tag(timing) - } - } - .pickerStyle(.menu) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.galleryComments) - .regularHeaderStyled() - } - } -} - -// MARK: GalleryTagsSection -private struct GalleryTagsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Picker(L10n.Localizable.EhSettingView.Title.tagsSortOrder, selection: $ehSetting.tagsSortOrder) { - ForEach(EhSetting.TagsSortOrder.allCases) { order in - Text(order.value) - .tag(order) - } - } - .pickerStyle(.menu) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.galleryTags) - .regularHeaderStyled() - } - } -} - -// MARK: GalleryPageThumbnailLabelingSection -private struct GalleryPageThumbnailLabelingSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - self._ehSetting = ehSetting - } - - var body: some View { - Section { - Picker( - L10n.Localizable.EhSettingView.Title.showLabelBelowGalleryThumbnails, - selection: $ehSetting.galleryPageNumbering - ) { - ForEach(EhSetting.GalleryPageNumbering.allCases) { behavior in - Text(behavior.value) - .tag(behavior) - } - } - .pickerStyle(.menu) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.galleryPageThumbnailLabeling) - .regularHeaderStyled() - } - } -} - -// MARK: MultiplePageViewerSection -private struct MultiplePageViewerSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - if let useMultiplePageViewerBinding = Binding($ehSetting.useMultiplePageViewer), - let multiplePageViewerStyleBinding = Binding($ehSetting.multiplePageViewerStyle), - let multiplePageViewerShowPaneBinding = Binding($ehSetting.multiplePageViewerShowThumbnailPane) - { - Section { - Toggle( - L10n.Localizable.EhSettingView.Title.useMultiPageViewer, - isOn: useMultiplePageViewerBinding - ) - - Picker( - L10n.Localizable.EhSettingView.Title.displayStyle, - selection: multiplePageViewerStyleBinding - ) { - ForEach(EhSetting.MultiplePageViewerStyle.allCases) { style in - Text(style.value) - .tag(style) - } - } - .pickerStyle(.menu) - - Toggle( - L10n.Localizable.EhSettingView.Title.showThumbnailPane, - isOn: multiplePageViewerShowPaneBinding - ) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.multiPageViewer) - .regularHeaderStyled() - } - } - } -} - -private extension String { - var lineCount: Int { - var count = 0 - enumerateLines { line, _ in - if !line.isEmpty { - count += 1 - } - } - return count - } -} - -private extension Text { - static func boldHeader(_ title: String, description: String? = nil) -> Self { - var result = AttributedString(title) - result.font = .body.weight(.bold) - if let description{ - var descriptionString = AttributedString("\n\(description)") - descriptionString.font = .subheadline.weight(.regular) - result.append(descriptionString) - } - return Text(result) - } - - func regularHeaderStyled() -> Self { - font(.subheadline.weight(.regular)) - } -} - struct EhSettingView_Previews: PreviewProvider { static var previews: some View { NavigationView { diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift index 017f2520c..a67f35654 100644 --- a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift +++ b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift @@ -46,7 +46,7 @@ struct GeneralSettingView: View { private var language: String { Locale.current.language.languageCode.map(\.identifier).flatMap(Locale.current.localizedString(forLanguageCode:)) - ?? L10n.Localizable.GeneralSettingView.Value.defaultLanguageDescription + ?? L10n.Localizable.GeneralSettingView.Value.defaultLanguageDescription } var body: some View { @@ -75,7 +75,7 @@ struct GeneralSettingView: View { .foregroundStyle(.yellow) .opacity( translatesTags && tagTranslatorEmpty - && tagTranslatorLoadingState != .loading ? 1 : 0 + && tagTranslatorLoadingState != .loading ? 1 : 0 ) ProgressView() .tint(nil) diff --git a/EhPanda/View/Setting/Login/LoginReducer.swift b/EhPanda/View/Setting/Login/LoginReducer.swift index 358cd9a26..e641615d0 100644 --- a/EhPanda/View/Setting/Login/LoginReducer.swift +++ b/EhPanda/View/Setting/Login/LoginReducer.swift @@ -35,7 +35,7 @@ struct LoginReducer { } var loginButtonColor: Color { loginState == .loading ? .clear : loginButtonDisabled - ? .primary.opacity(0.25) : .primary.opacity(0.75) + ? .primary.opacity(0.25) : .primary.opacity(0.75) } } diff --git a/EhPanda/View/Setting/Logs/LogsView.swift b/EhPanda/View/Setting/Logs/LogsView.swift index 30afa5c1c..9c300e692 100644 --- a/EhPanda/View/Setting/Logs/LogsView.swift +++ b/EhPanda/View/Setting/Logs/LogsView.swift @@ -76,7 +76,7 @@ private struct LogCell: View { private var dateRangeString: String { parseDate(string: log.contents.first) - + " - " + parseDate(string: log.contents.last) + + " - " + parseDate(string: log.contents.last) } init(log: Log, isLatest: Bool) { diff --git a/EhPanda/View/Setting/SettingReducer+Body.swift b/EhPanda/View/Setting/SettingReducer+Body.swift new file mode 100644 index 000000000..0b5ac4a30 --- /dev/null +++ b/EhPanda/View/Setting/SettingReducer+Body.swift @@ -0,0 +1,288 @@ +// +// SettingReducer+Body.swift +// EhPanda +// + +import Foundation +import ComposableArchitecture + +extension SettingReducer { + @ReducerBuilder + var reducerBody: some Reducer { + BindingReducer() + .onChange(of: \.setting) { _, _ in + .send(.syncSetting) + } + .onChange(of: \.setting.galleryHost) { _, state in + .merge( + .send(.syncSetting), + .run(operation: { [value = state.setting.galleryHost.rawValue] _ in + userDefaultsClient.setValue(value, .galleryHost) + }) + ) + } + .onChange(of: \.setting.enablesTagsExtension) { _, state in + var effects: [Effect] = [ + .send(.syncSetting) + ] + if state.setting.enablesTagsExtension { + effects.append(.send(.fetchTagTranslator)) + } + return .merge(effects) + } + .onChange(of: \.setting.preferredColorScheme) { _, _ in + .merge( + .send(.syncSetting), + .send(.syncUserInterfaceStyle) + ) + } + .onChange(of: \.setting.appIconType) { _, state in + .merge( + .send(.syncSetting), + .run { [value = state.setting.appIconType.filename] send in + _ = await uiApplicationClient.setAlternateIconName(value) + await send(.syncAppIconType) + } + ) + } + .onChange(of: \.setting.autoLockPolicy) { _, state in + if state.setting.autoLockPolicy != .never && state.setting.backgroundBlurRadius == 0 { + state.setting.backgroundBlurRadius = 10 + } + return .send(.syncSetting) + } + .onChange(of: \.setting.backgroundBlurRadius) { _, state in + if state.setting.autoLockPolicy != .never && state.setting.backgroundBlurRadius == 0 { + state.setting.autoLockPolicy = .never + } + return .send(.syncSetting) + } + .onChange(of: \.setting.enablesLandscape) { _, state in + var effects: [Effect] = [ + .send(.syncSetting) + ] + if !state.setting.enablesLandscape && !deviceClient.isPad() { + effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) + } + return .merge(effects) + } + .onChange(of: \.setting.maximumScaleFactor) { _, state in + if state.setting.doubleTapScaleFactor > state.setting.maximumScaleFactor { + state.setting.doubleTapScaleFactor = state.setting.maximumScaleFactor + } + return .send(.syncSetting) + } + .onChange(of: \.setting.doubleTapScaleFactor) { _, state in + if state.setting.maximumScaleFactor < state.setting.doubleTapScaleFactor { + state.setting.maximumScaleFactor = state.setting.doubleTapScaleFactor + } + return .send(.syncSetting) + } + .onChange(of: \.setting.bypassesSNIFiltering) { _, state in + .merge( + .send(.syncSetting), + .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), + .run(operation: { [value = state.setting.bypassesSNIFiltering] _ in dfClient.setActive(value) }) + ) + } + + Reduce { state, action in + switch action { + case .binding: + return .merge( + .send(.syncUser), + .send(.syncSetting), + .send(.syncTagTranslator) + ) + + case .setNavigation(let route): + state.route = route + return .none + + case .clearSubStates: + state.accountSettingState = .init() + state.generalSettingState = .init() + state.appearanceSettingState = .init() + return .none + + case .syncAppIconType: + if let iconName = uiApplicationClient.alternateIconName() { + state.setting.appIconType = AppIconType.allCases.filter({ + iconName.contains($0.filename) + }).first ?? .default + } + return .none + + case .syncUserInterfaceStyle: + let style = state.setting.preferredColorScheme.userInterfaceStyle + return .run(operation: { _ in await uiApplicationClient.setUserInterfaceStyle(style) }) + + case .syncSetting: + return .run { [state] _ in + await databaseClient.updateSetting(state.setting) + } + case .syncTagTranslator: + return .run { [state] _ in + await databaseClient.updateTagTranslator(state.tagTranslator) + } + case .syncUser: + return .run { [state] _ in + await databaseClient.updateUser(state.user) + } + + case .loadUserSettings: + return .run { send in + let appEnv = await databaseClient.fetchAppEnv() + await send(.onLoadUserSettings(appEnv)) + } + + case .onLoadUserSettings(let appEnv): + return handleLoadUserSettings(&state, appEnv: appEnv) + + case .loadUserSettingsDone: + state.hasLoadedInitialSetting = true + return .none + + case .createDefaultEhProfile: + return .run { _ in + _ = await EhProfileRequest(action: .create, name: "EhPanda").response() + } + + case .fetchIgneous: + guard cookieClient.didLogin else { return .none } + return .run { send in + let response = await IgneousRequest().response() + await send(.fetchIgneousDone(response)) + } + + case .fetchIgneousDone(let result): + if case .success(let response) = result { + return .run { send in + cookieClient.setCredentials(response: response) + await send(.account(.loadCookies)) + } + } + return .send(.account(.loadCookies)) + + case .fetchUserInfo: + guard cookieClient.didLogin else { return .none } + let uid = cookieClient + .getCookie(Defaults.URL.host, Defaults.Cookie.ipbMemberId).rawValue + if !uid.isEmpty { + return .run { send in + let response = await UserInfoRequest(uid: uid).response() + await send(.fetchUserInfoDone(response)) + } + } + return .none + + case .fetchUserInfoDone(let result): + if case .success(let user) = result { + state.updateUser(user) + return .send(.syncUser) + } + return .none + + case .fetchGreeting: + return handleFetchGreeting(&state) + + case .fetchGreetingDone(let result): + switch result { + case .success(let greeting): + state.setGreeting(greeting) + return .send(.syncUser) + case .failure(let error): + if case .parseFailed = error { + var greeting = Greeting() + greeting.updateTime = Date() + state.setGreeting(greeting) + return .send(.syncUser) + } + } + return .none + + case .fetchTagTranslator: + return handleFetchTagTranslator(&state) + + case .fetchTagTranslatorDone(let result): + state.tagTranslatorLoadingState = .idle + switch result { + case .success(let tagTranslator): + state.tagTranslator = tagTranslator + return .send(.syncTagTranslator) + case .failure(let error): + state.tagTranslatorLoadingState = .failed(error) + } + return .none + + case .fetchEhProfileIndex: + guard cookieClient.didLogin else { return .none } + return .run { send in + let response = await VerifyEhProfileRequest().response() + await send(.fetchEhProfileIndexDone(response)) + } + + case .fetchEhProfileIndexDone(let result): + return handleFetchEhProfileIndexDone(result) + + case .fetchFavoriteCategories: + guard cookieClient.didLogin else { return .none } + return .run { send in + let response = await FavoriteCategoriesRequest().response() + await send(.fetchFavoriteCategoriesDone(response)) + } + + case .fetchFavoriteCategoriesDone(let result): + if case .success(let categories) = result { + state.user.favoriteCategories = categories + } + return .none + + case .account(.login(.loginDone)): + return .merge( + .run(operation: { _ in cookieClient.removeYay() }), + .run(operation: { _ in cookieClient.syncExCookies() }), + .run(operation: { _ in cookieClient.fulfillAnotherHostField() }), + .send(.fetchIgneous), + .send(.fetchUserInfo), + .send(.fetchFavoriteCategories), + .send(.fetchEhProfileIndex) + ) + + case .account(.onLogoutConfirmButtonTapped): + state.user = User() + return .merge( + .send(.syncUser), + .run(operation: { _ in cookieClient.clearAll() }), + .run(operation: { _ in await databaseClient.removeImageURLs() }), + .run(operation: { _ in libraryClient.clearWebImageDiskCache() }) + ) + + case .account: + return .none + + case .general(.onTranslationsFilePicked(let url)): + return .run { send in + let result = await fileClient.importTagTranslator(url) + await send(.fetchTagTranslatorDone(result)) + } + + case .general(.onRemoveCustomTranslations): + state.tagTranslator.hasCustomTranslations = false + state.tagTranslator.translations = .init() + return .send(.syncTagTranslator) + + case .general: + return .none + + case .appearance: + return .none + } + } + + Scope(state: \.accountSettingState, action: \.account, child: AccountSettingReducer.init) + Scope(state: \.generalSettingState, action: \.general, child: GeneralSettingReducer.init) + Scope(state: \.appearanceSettingState, action: \.appearance, child: AppearanceSettingReducer.init) + } + +} diff --git a/EhPanda/View/Setting/SettingReducer+Helpers.swift b/EhPanda/View/Setting/SettingReducer+Helpers.swift new file mode 100644 index 000000000..e6e398cb6 --- /dev/null +++ b/EhPanda/View/Setting/SettingReducer+Helpers.swift @@ -0,0 +1,134 @@ +// +// SettingReducer+Helpers.swift +// EhPanda +// + +import Foundation +import ComposableArchitecture + +extension SettingReducer { + func handleLoadUserSettings( + _ state: inout State, appEnv: AppEnv + ) -> Effect { + state.setting = appEnv.setting + state.tagTranslator = appEnv.tagTranslator + state.user = appEnv.user + var effects: [Effect] = [ + .send(.syncAppIconType), + .send(.loadUserSettingsDone), + .send(.syncUserInterfaceStyle), + .run { [state] _ in + dfClient.setActive(state.setting.bypassesSNIFiltering) + } + ] + if let value: String = userDefaultsClient.getValue(.galleryHost), + let galleryHost = GalleryHost(rawValue: value) { + state.setting.galleryHost = galleryHost + } + if cookieClient.shouldFetchIgneous { + effects.append(.send(.fetchIgneous)) + } + if cookieClient.didLogin { + effects.append(contentsOf: [ + .send(.fetchUserInfo), + .send(.fetchGreeting), + .send(.fetchFavoriteCategories), + .send(.fetchEhProfileIndex) + ]) + } + if state.setting.enablesTagsExtension { + effects.append(.send(.fetchTagTranslator)) + } + return .merge(effects) + } + + func handleFetchGreeting(_ state: inout State) -> Effect { + func verifyDate(with updateTime: Date?) -> Bool { + guard let updateTime = updateTime else { return true } + + let currentTime = Date() + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = Defaults.DateFormat.greeting + + let currentTimeString = formatter.string(from: currentTime) + if let currentDay = formatter.date(from: currentTimeString) { + return currentTime > currentDay && updateTime < currentDay + } + + return false + } + + guard cookieClient.didLogin, + state.setting.showsNewDawnGreeting + else { return .none } + let requestEffect = Effect.run { send in + let response = await GreetingRequest().response() + await send(Action.fetchGreetingDone(response)) + } + if let greeting = state.user.greeting { + if verifyDate(with: greeting.updateTime) { + return requestEffect + } + } else { + return requestEffect + } + return .none + } + + func handleFetchTagTranslator(_ state: inout State) -> Effect { + guard state.tagTranslatorLoadingState != .loading, + !state.tagTranslator.hasCustomTranslations, + let language = TranslatableLanguage.current + else { return .none } + state.tagTranslatorLoadingState = .loading + + var databaseEffect: Effect? + if state.tagTranslator.language != language { + state.tagTranslator = TagTranslator(language: language) + databaseEffect = .send(.syncTagTranslator) + } + let updatedDate = state.tagTranslator.updatedDate + let requestEffect = Effect.run { send in + let response = await TagTranslatorRequest(language: language, updatedDate: updatedDate).response() + await send(Action.fetchTagTranslatorDone(response)) + } + if let databaseEffect = databaseEffect { + return .merge(databaseEffect, requestEffect) + } else { + return requestEffect + } + } + + func handleFetchEhProfileIndexDone( + _ result: Result + ) -> Effect { + var effects = [Effect]() + + if case .success(let response) = result { + if let profileValue = response.profileValue { + let hostURL = Defaults.URL.host + let profileValueString = String(profileValue) + let selectedProfileKey = Defaults.Cookie.selectedProfile + + let cookieValue = cookieClient.getCookie(hostURL, selectedProfileKey) + if cookieValue.rawValue != profileValueString { + effects.append( + .run { _ in + cookieClient.setOrEditCookie( + for: hostURL, key: selectedProfileKey, value: profileValueString + ) + } + ) + } + } else if response.isProfileNotFound { + effects.append(.send(.createDefaultEhProfile)) + } else { + let message = "Found profile but failed in parsing value." + effects.append(.run(operation: { _ in loggerClient.error(message, nil) })) + } + } + return effects.isEmpty ? .none : .merge(effects) + } +} diff --git a/EhPanda/View/Setting/SettingReducer.swift b/EhPanda/View/Setting/SettingReducer.swift index 9ef13645b..58a5a17c6 100644 --- a/EhPanda/View/Setting/SettingReducer.swift +++ b/EhPanda/View/Setting/SettingReducer.swift @@ -42,8 +42,7 @@ struct SettingReducer { if let prevGreeting = user.greeting, let prevDate = prevGreeting.updateTime, - prevDate < currDate - { + prevDate < currDate { user.greeting = greeting } else if user.greeting == nil { user.greeting = greeting @@ -58,8 +57,7 @@ struct SettingReducer { self.user.avatarURL = avatarURL } if let galleryPoints = user.galleryPoints, - let credits = user.credits - { + let credits = user.credits { self.user.galleryPoints = galleryPoints self.user.credits = credits } @@ -99,398 +97,17 @@ struct SettingReducer { case appearance(AppearanceSettingReducer.Action) } - @Dependency(\.uiApplicationClient) private var uiApplicationClient - @Dependency(\.userDefaultsClient) private var userDefaultsClient - @Dependency(\.appDelegateClient) private var appDelegateClient - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.libraryClient) private var libraryClient - @Dependency(\.hapticsClient) private var hapticsClient - @Dependency(\.loggerClient) private var loggerClient - @Dependency(\.cookieClient) private var cookieClient - @Dependency(\.deviceClient) private var deviceClient - @Dependency(\.fileClient) private var fileClient - @Dependency(\.dfClient) private var dfClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.setting) { _, _ in - .send(.syncSetting) - } - .onChange(of: \.setting.galleryHost) { _, state in - .merge( - .send(.syncSetting), - .run(operation: { [value = state.setting.galleryHost.rawValue] _ in - userDefaultsClient.setValue(value, .galleryHost) - }) - ) - } - .onChange(of: \.setting.enablesTagsExtension) { _, state in - var effects: [Effect] = [ - .send(.syncSetting) - ] - if state.setting.enablesTagsExtension { - effects.append(.send(.fetchTagTranslator)) - } - return .merge(effects) - } - .onChange(of: \.setting.preferredColorScheme) { _, _ in - .merge( - .send(.syncSetting), - .send(.syncUserInterfaceStyle) - ) - } - .onChange(of: \.setting.appIconType) { _, state in - .merge( - .send(.syncSetting), - .run { [value = state.setting.appIconType.filename] send in - _ = await uiApplicationClient.setAlternateIconName(value) - await send(.syncAppIconType) - } - ) - } - .onChange(of: \.setting.autoLockPolicy) { _, state in - if state.setting.autoLockPolicy != .never && state.setting.backgroundBlurRadius == 0 { - state.setting.backgroundBlurRadius = 10 - } - return .send(.syncSetting) - } - .onChange(of: \.setting.backgroundBlurRadius) { _, state in - if state.setting.autoLockPolicy != .never && state.setting.backgroundBlurRadius == 0 { - state.setting.autoLockPolicy = .never - } - return .send(.syncSetting) - } - .onChange(of: \.setting.enablesLandscape) { _, state in - var effects: [Effect] = [ - .send(.syncSetting) - ] - if !state.setting.enablesLandscape && !deviceClient.isPad() { - effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) - } - return .merge(effects) - } - .onChange(of: \.setting.maximumScaleFactor) { _, state in - if state.setting.doubleTapScaleFactor > state.setting.maximumScaleFactor { - state.setting.doubleTapScaleFactor = state.setting.maximumScaleFactor - } - return .send(.syncSetting) - } - .onChange(of: \.setting.doubleTapScaleFactor) { _, state in - if state.setting.maximumScaleFactor < state.setting.doubleTapScaleFactor { - state.setting.maximumScaleFactor = state.setting.doubleTapScaleFactor - } - return .send(.syncSetting) - } - .onChange(of: \.setting.bypassesSNIFiltering) { _, state in - .merge( - .send(.syncSetting), - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), - .run(operation: { [value = state.setting.bypassesSNIFiltering] _ in dfClient.setActive(value) }) - ) - } - - Reduce { state, action in - switch action { - case .binding: - return .merge( - .send(.syncUser), - .send(.syncSetting), - .send(.syncTagTranslator) - ) - - case .setNavigation(let route): - state.route = route - return .none - - case .clearSubStates: - state.accountSettingState = .init() - state.generalSettingState = .init() - state.appearanceSettingState = .init() - return .none - - case .syncAppIconType: - if let iconName = uiApplicationClient.alternateIconName() { - state.setting.appIconType = AppIconType.allCases.filter({ - iconName.contains($0.filename) - }).first ?? .default - } - return .none - - case .syncUserInterfaceStyle: - let style = state.setting.preferredColorScheme.userInterfaceStyle - return .run(operation: { _ in await uiApplicationClient.setUserInterfaceStyle(style) }) - - case .syncSetting: - return .run { [state] _ in - await databaseClient.updateSetting(state.setting) - } - case .syncTagTranslator: - return .run { [state] _ in - await databaseClient.updateTagTranslator(state.tagTranslator) - } - case .syncUser: - return .run { [state] _ in - await databaseClient.updateUser(state.user) - } - - case .loadUserSettings: - return .run { send in - let appEnv = await databaseClient.fetchAppEnv() - await send(.onLoadUserSettings(appEnv)) - } - - case .onLoadUserSettings(let appEnv): - state.setting = appEnv.setting - state.tagTranslator = appEnv.tagTranslator - state.user = appEnv.user - var effects: [Effect] = [ - .send(.syncAppIconType), - .send(.loadUserSettingsDone), - .send(.syncUserInterfaceStyle), - .run { [state] _ in - dfClient.setActive(state.setting.bypassesSNIFiltering) - } - ] - if let value: String = userDefaultsClient.getValue(.galleryHost), - let galleryHost = GalleryHost(rawValue: value) - { - state.setting.galleryHost = galleryHost - } - if cookieClient.shouldFetchIgneous { - effects.append(.send(.fetchIgneous)) - } - if cookieClient.didLogin { - effects.append(contentsOf: [ - .send(.fetchUserInfo), - .send(.fetchGreeting), - .send(.fetchFavoriteCategories), - .send(.fetchEhProfileIndex) - ]) - } - if state.setting.enablesTagsExtension { - effects.append(.send(.fetchTagTranslator)) - } - return .merge(effects) - - case .loadUserSettingsDone: - state.hasLoadedInitialSetting = true - return .none - - case .createDefaultEhProfile: - return .run { _ in - _ = await EhProfileRequest(action: .create, name: "EhPanda").response() - } - - case .fetchIgneous: - guard cookieClient.didLogin else { return .none } - return .run { send in - let response = await IgneousRequest().response() - await send(.fetchIgneousDone(response)) - } - - case .fetchIgneousDone(let result): - if case .success(let response) = result { - return .run { send in - cookieClient.setCredentials(response: response) - await send(.account(.loadCookies)) - } - } - return .send(.account(.loadCookies)) - - case .fetchUserInfo: - guard cookieClient.didLogin else { return .none } - let uid = cookieClient - .getCookie(Defaults.URL.host, Defaults.Cookie.ipbMemberId).rawValue - if !uid.isEmpty { - return .run { send in - let response = await UserInfoRequest(uid: uid).response() - await send(.fetchUserInfoDone(response)) - } - } - return .none - - case .fetchUserInfoDone(let result): - if case .success(let user) = result { - state.updateUser(user) - return .send(.syncUser) - } - return .none - - case .fetchGreeting: - func verifyDate(with updateTime: Date?) -> Bool { - guard let updateTime = updateTime else { return true } - - let currentTime = Date() - let formatter = DateFormatter() - formatter.locale = Locale.current - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = Defaults.DateFormat.greeting - - let currentTimeString = formatter.string(from: currentTime) - if let currentDay = formatter.date(from: currentTimeString) { - return currentTime > currentDay && updateTime < currentDay - } - - return false - } - - guard cookieClient.didLogin, - state.setting.showsNewDawnGreeting - else { return .none } - let requestEffect = Effect.run { send in - let response = await GreetingRequest().response() - await send(Action.fetchGreetingDone(response)) - } - if let greeting = state.user.greeting { - if verifyDate(with: greeting.updateTime) { - return requestEffect - } - } else { - return requestEffect - } - return .none - - case .fetchGreetingDone(let result): - switch result { - case .success(let greeting): - state.setGreeting(greeting) - return .send(.syncUser) - case .failure(let error): - if case .parseFailed = error { - var greeting = Greeting() - greeting.updateTime = Date() - state.setGreeting(greeting) - return .send(.syncUser) - } - } - return .none - - case .fetchTagTranslator: - guard state.tagTranslatorLoadingState != .loading, - !state.tagTranslator.hasCustomTranslations, - let language = TranslatableLanguage.current - else { return .none } - state.tagTranslatorLoadingState = .loading - - var databaseEffect: Effect? - if state.tagTranslator.language != language { - state.tagTranslator = TagTranslator(language: language) - databaseEffect = .send(.syncTagTranslator) - } - let updatedDate = state.tagTranslator.updatedDate - let requestEffect = Effect.run { send in - let response = await TagTranslatorRequest(language: language, updatedDate: updatedDate).response() - await send(Action.fetchTagTranslatorDone(response)) - } - if let databaseEffect = databaseEffect { - return .merge(databaseEffect, requestEffect) - } else { - return requestEffect - } - - case .fetchTagTranslatorDone(let result): - state.tagTranslatorLoadingState = .idle - switch result { - case .success(let tagTranslator): - state.tagTranslator = tagTranslator - return .send(.syncTagTranslator) - case .failure(let error): - state.tagTranslatorLoadingState = .failed(error) - } - return .none - - case .fetchEhProfileIndex: - guard cookieClient.didLogin else { return .none } - return .run { send in - let response = await VerifyEhProfileRequest().response() - await send(.fetchEhProfileIndexDone(response)) - } - - case .fetchEhProfileIndexDone(let result): - var effects = [Effect]() - - if case .success(let response) = result { - if let profileValue = response.profileValue { - let hostURL = Defaults.URL.host - let profileValueString = String(profileValue) - let selectedProfileKey = Defaults.Cookie.selectedProfile - - let cookieValue = cookieClient.getCookie(hostURL, selectedProfileKey) - if cookieValue.rawValue != profileValueString { - effects.append( - .run { _ in - cookieClient.setOrEditCookie( - for: hostURL, key: selectedProfileKey, value: profileValueString - ) - } - ) - } - } else if response.isProfileNotFound { - effects.append(.send(.createDefaultEhProfile)) - } else { - let message = "Found profile but failed in parsing value." - effects.append(.run(operation: { _ in loggerClient.error(message, nil) })) - } - } - return effects.isEmpty ? .none : .merge(effects) - - case .fetchFavoriteCategories: - guard cookieClient.didLogin else { return .none } - return .run { send in - let response = await FavoriteCategoriesRequest().response() - await send(.fetchFavoriteCategoriesDone(response)) - } - - case .fetchFavoriteCategoriesDone(let result): - if case .success(let categories) = result { - state.user.favoriteCategories = categories - } - return .none - - case .account(.login(.loginDone)): - return .merge( - .run(operation: { _ in cookieClient.removeYay() }), - .run(operation: { _ in cookieClient.syncExCookies() }), - .run(operation: { _ in cookieClient.fulfillAnotherHostField() }), - .send(.fetchIgneous), - .send(.fetchUserInfo), - .send(.fetchFavoriteCategories), - .send(.fetchEhProfileIndex) - ) - - case .account(.onLogoutConfirmButtonTapped): - state.user = User() - return .merge( - .send(.syncUser), - .run(operation: { _ in cookieClient.clearAll() }), - .run(operation: { _ in await databaseClient.removeImageURLs() }), - .run(operation: { _ in libraryClient.clearWebImageDiskCache() }) - ) - - case .account: - return .none - - case .general(.onTranslationsFilePicked(let url)): - return .run { send in - let result = await fileClient.importTagTranslator(url) - await send(.fetchTagTranslatorDone(result)) - } - - case .general(.onRemoveCustomTranslations): - state.tagTranslator.hasCustomTranslations = false - state.tagTranslator.translations = .init() - return .send(.syncTagTranslator) - - case .general: - return .none - - case .appearance: - return .none - } - } - - Scope(state: \.accountSettingState, action: \.account, child: AccountSettingReducer.init) - Scope(state: \.generalSettingState, action: \.general, child: GeneralSettingReducer.init) - Scope(state: \.appearanceSettingState, action: \.appearance, child: AppearanceSettingReducer.init) - } + @Dependency(\.uiApplicationClient) var uiApplicationClient + @Dependency(\.userDefaultsClient) var userDefaultsClient + @Dependency(\.appDelegateClient) var appDelegateClient + @Dependency(\.databaseClient) var databaseClient + @Dependency(\.libraryClient) var libraryClient + @Dependency(\.hapticsClient) var hapticsClient + @Dependency(\.loggerClient) var loggerClient + @Dependency(\.cookieClient) var cookieClient + @Dependency(\.deviceClient) var deviceClient + @Dependency(\.fileClient) var fileClient + @Dependency(\.dfClient) var dfClient + + var body: some Reducer { reducerBody } } diff --git a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift index e6f7c9cd6..7b64f8bb2 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -59,7 +59,7 @@ struct GalleryThumbnailCell: View { } Spacer() } - } + } VStack(alignment: .leading, spacing: 5) { Text(gallery.title) .font(.callout.bold()) diff --git a/EhPanda/View/Support/Components/GenericList.swift b/EhPanda/View/Support/Components/GenericList.swift index 3857bda0c..69e70a6d9 100644 --- a/EhPanda/View/Support/Components/GenericList.swift +++ b/EhPanda/View/Support/Components/GenericList.swift @@ -196,7 +196,7 @@ private struct WaterfallList: View { translateAction: translateAction, downloadBadge: downloadBadges[gallery.gid] ?? .none ) - .tint(.primary).multilineTextAlignment(.leading) + .tint(.primary).multilineTextAlignment(.leading) } .buttonStyle(.borderless) } diff --git a/EhPandaTests/Models/ListParserTestType.swift b/EhPandaTests/Models/ListParserTestType.swift index 1f326dce4..c1d9dcdb1 100644 --- a/EhPandaTests/Models/ListParserTestType.swift +++ b/EhPandaTests/Models/ListParserTestType.swift @@ -83,12 +83,12 @@ extension ListParserTestType { var hasUploader: Bool { switch self { case .frontPageMinimalList, .frontPageMinimalPlusList, .frontPageCompactList, .frontPageExtendedList, - .watchedMinimalList, .watchedMinimalPlusList, .watchedCompactList, .watchedExtendedList, - .popularMinimalList, .popularMinimalPlusList, .popularCompactList, .popularExtendedList, - .toplistsCompactList: + .watchedMinimalList, .watchedMinimalPlusList, .watchedCompactList, .watchedExtendedList, + .popularMinimalList, .popularMinimalPlusList, .popularCompactList, .popularExtendedList, + .toplistsCompactList: return true case .frontPageThumbnailList, .watchedThumbnailList, .popularThumbnailList, .favoritesThumbnailList, - .favoritesMinimalList, .favoritesMinimalPlusList, .favoritesCompactList, .favoritesExtendedList: + .favoritesMinimalList, .favoritesMinimalPlusList, .favoritesCompactList, .favoritesExtendedList: return false } } diff --git a/EhPandaTests/Resources/Utility/TestHelper.swift b/EhPandaTests/Resources/Utility/TestHelper.swift index bd853b6a9..f64de674e 100644 --- a/EhPandaTests/Resources/Utility/TestHelper.swift +++ b/EhPandaTests/Resources/Utility/TestHelper.swift @@ -14,7 +14,7 @@ final class TestBundleLocator {} extension TestHelper { func htmlDocument(filename: HTMLFilename) throws -> HTMLDocument { guard let url = Bundle(for: TestBundleLocator.self) - .url(forResource: filename.rawValue, withExtension: "html") + .url(forResource: filename.rawValue, withExtension: "html") else { throw TestError.htmlDocumentNotFound(filename) } diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift new file mode 100644 index 000000000..01b0b342a --- /dev/null +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -0,0 +1,164 @@ +// +// DetailReducerDownloadTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DetailReducerDownloadTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDetailReducerStartDownloadEnqueuesGalleryWithSnapshotOptions() async throws { + let capturedPayload = UncheckedBox(nil) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let options = DownloadOptionsSnapshot( + threadMode: .quadruple, + allowCellular: false, + autoRetryFailedPages: false + ) + let previewURL = try #require(URL(string: "https://example.com/1.jpg")) + let store = makeDownloadTestStore( + gallery: gallery, detail: detail, + badgeValue: .queued, + configure: { state in + state.galleryPreviewURLs = [1: previewURL] + state.previewConfig = .large(rows: 2) + }, + enqueue: { payload in + capturedPayload.value = payload + return .success(()) + } + ) + store.exhaustivity = .off + + await store.send(.startDownload(options)) + await store.skipReceivedActions(strict: false) + + #expect(capturedPayload.value?.gallery.gid == gallery.gid) + #expect(capturedPayload.value?.galleryDetail == detail) + #expect(capturedPayload.value?.previewConfig == .large(rows: 2)) + #expect(capturedPayload.value?.options == options) + #expect(capturedPayload.value?.mode == .initial) + #expect(store.state.downloadBadge == .queued) + } + + @MainActor + @Test + func testDetailReducerStartDownloadUnlocksActionsAfterQueueing() async throws { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let options = DownloadOptionsSnapshot() + let previewURL = try #require(URL(string: "https://example.com/1.jpg")) + let store = makeDownloadTestStore( + gallery: gallery, detail: detail, + badgeValue: .queued, + configure: { state in state.galleryPreviewURLs = [1: previewURL] }, + enqueue: { _ in .success(()) } + ) + store.exhaustivity = .off + + await store.send(.startDownload(options)) { + $0.isPreparingDownload = true + $0.didRunLaunchAutomation = true + } + await store.receive(\.startDownloadDone) { + $0.isPreparingDownload = false + $0.downloadBadge = .queued + $0.hasLoadedDownloadBadge = true + } + await store.receive(\.fetchDownloadBadge) + await store.receive(\.fetchDownloadBadgeDone, .queued) { + $0.downloadBadge = .queued + $0.hasLoadedDownloadBadge = true + } + } + + @MainActor + @Test + func testDetailReducerLaunchAutomationWaitsForResolvedDownloadBadge() async throws { + let capturedPayload = UncheckedBox(nil) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let options = DownloadOptionsSnapshot() + let previewURL = try #require(URL(string: "https://example.com/1.jpg")) + + setenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID", gallery.gid, 1) + defer { unsetenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID") } + + let store = makeDownloadTestStore( + gallery: gallery, detail: detail, + badgeValue: .queued, + configure: { state in + state.gid = "" + state.galleryPreviewURLs = [1: previewURL] + }, + enqueue: { payload in + capturedPayload.value = payload + return .success(()) + } + ) + store.exhaustivity = .off + + await store.send(.runLaunchAutomationIfNeeded(options)) + #expect(capturedPayload.value == nil) + #expect(store.state.didRunLaunchAutomation == false) + + await store.send(.fetchDownloadBadgeDone(.none)) { + $0.hasLoadedDownloadBadge = true + } + await store.send(.runLaunchAutomationIfNeeded(options)) { + $0.didRunLaunchAutomation = true + } + await store.receive(\.startDownload, options) + await store.skipReceivedActions(strict: false) + + #expect(capturedPayload.value?.gallery.gid == gallery.gid) + } +} + +// MARK: - Store Factory Helpers + +private extension DetailReducerDownloadTests { + func makeDownloadTestStore( + gallery: Gallery, detail: GalleryDetail, + badgeValue: DownloadBadge, + configure: (inout DetailReducer.State) -> Void = { _ in }, + enqueue: @escaping @Sendable (DownloadRequestPayload) async -> Result + ) -> TestStoreOf { + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + initialState.galleryDetail = detail + configure(&initialState) + return TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, badgeValue) }) + }, + updateRemoteSignature: { _, _ in .none }, + enqueue: enqueue, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + } +} diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift new file mode 100644 index 000000000..35a047f93 --- /dev/null +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -0,0 +1,189 @@ +// +// DetailReducerMetadataTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DetailReducerMetadataTests: DownloadFeatureTestCase { + @Test + func testDetailReducerDoesNotRequestVersionMetadataForUndownloadedGallery() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let galleryState = GalleryState(gid: gallery.gid) + + let store = makeMetadataTestStore( + gid: gallery.gid, gallery: gallery, + badgeValue: .none, updateCheckCount: updateCheckCount + ) + store.exhaustivity = .off + + await store.send( + .fetchGalleryDetailDone( + .success(GalleryDetailResponse( + galleryDetail: detail, galleryState: galleryState, apiKey: "", greeting: nil + )) + ) + ) + await store.skipReceivedActions(strict: false) + + #expect(updateCheckCount.value == 0) + #expect(store.state.galleryVersionMetadata == nil) + #expect(store.state.shouldCheckForRemoteUpdates == false) + _ = galleryState + } + + @MainActor + @Test + func testDetailReducerRequestsVersionMetadataWhenBadgeArrivesAfterDetail() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let galleryState = try sampleGalleryState(gid: gallery.gid) + let sessionID = UUID().uuidString + try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) + defer { uninstallSharedSessionStub(sessionID: sessionID) } + + let store = makeDownloadedMetadataTestStore( + gid: gallery.gid, gallery: gallery, + badgeValue: .none, updateCheckCount: updateCheckCount + ) + store.exhaustivity = .off + + await store.send(.fetchGalleryDetailDone(.success(GalleryDetailResponse( + galleryDetail: detail, galleryState: galleryState, apiKey: "", greeting: nil + )))) + await store.skipReceivedActions(strict: false) + #expect(updateCheckCount.value == 0) + + await store.send(.fetchDownloadBadgeDone(.downloaded)) + await drainDetailMetadataEffects( + store, + condition: { + updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil + } + ) + + #expect(updateCheckCount.value == 1) + #expect(store.state.shouldCheckForRemoteUpdates) + #expect(store.state.didRequestVersionMetadata) + #expect(store.state.galleryVersionMetadata != nil) + } + + @MainActor + @Test + func testDetailReducerRequestsVersionMetadataWhenBadgeArrivesBeforeDetail() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let galleryState = try sampleGalleryState(gid: gallery.gid) + let sessionID = UUID().uuidString + try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) + defer { uninstallSharedSessionStub(sessionID: sessionID) } + + let store = makeDownloadedMetadataTestStore( + gid: gallery.gid, gallery: gallery, + badgeValue: .downloaded, updateCheckCount: updateCheckCount + ) + store.exhaustivity = .off + + await store.send(.fetchDownloadBadgeDone(.downloaded)) + await store.skipReceivedActions(strict: false) + #expect(updateCheckCount.value == 0) + + await store.send(.fetchGalleryDetailDone(.success(GalleryDetailResponse( + galleryDetail: detail, galleryState: galleryState, apiKey: "", greeting: nil + )))) + await drainDetailMetadataEffects( + store, + condition: { + updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil + } + ) + + #expect(updateCheckCount.value == 1) + #expect(store.state.shouldCheckForRemoteUpdates) + #expect(store.state.didRequestVersionMetadata) + #expect(store.state.galleryVersionMetadata != nil) + } +} + +// MARK: - Store Factory Helpers + +private extension DetailReducerMetadataTests { + func makeMetadataTestStore( + gid: String, gallery: Gallery, + badgeValue: DownloadBadge, updateCheckCount: UncheckedBox + ) -> TestStoreOf { + var initialState = DetailReducer.State() + initialState.gid = gid + initialState.gallery = gallery + return TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, badgeValue) }) }, + updateRemoteSignature: { _, _ in + updateCheckCount.value += 1 + return .none + }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + } + + func makeDownloadedMetadataTestStore( + gid: String, gallery: Gallery, + badgeValue: DownloadBadge, updateCheckCount: UncheckedBox + ) -> TestStoreOf { + var initialState = DetailReducer.State() + initialState.gid = gid + initialState.gallery = gallery + return TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, badgeValue) }) }, + updateRemoteSignature: { _, _ in + updateCheckCount.value += 1 + return .downloaded + }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in .success([:]) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + } +} diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift new file mode 100644 index 000000000..b191831d8 --- /dev/null +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -0,0 +1,174 @@ +// +// DetailReducerMetadataUpdateTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDetailReducerObserveDownloadDoneAlsoTriggersMetadataCheckWithoutDuplicateRequests() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let sessionID = UUID().uuidString + try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) + defer { uninstallSharedSessionStub(sessionID: sessionID) } + + let store = makeUpdateTestStore( + gid: gallery.gid, gallery: gallery, detail: detail, + updateCheckCount: updateCheckCount + ) + store.exhaustivity = .off + + await store.send(.observeDownloadDone(.downloaded)) + await drainDetailMetadataEffects(store, condition: { updateCheckCount.value == 1 }) + #expect(updateCheckCount.value == 1) + + await store.send(.observeDownloadDone(.downloaded)) + await store.skipReceivedActions(strict: false) + #expect(updateCheckCount.value == 1) + } + + @MainActor + @Test + func testDetailReducerRemoteUpdateFlagDoesNotStayStickyWhenBadgeReturnsToNone() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let sessionID = UUID().uuidString + try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) + defer { uninstallSharedSessionStub(sessionID: sessionID) } + + let store = makeUpdateTestStore( + gid: gallery.gid, gallery: gallery, detail: detail, + updateCheckCount: updateCheckCount + ) + store.exhaustivity = .off + + await store.send(.fetchDownloadBadgeDone(.downloaded)) + await drainDetailMetadataEffects( + store, + condition: { + updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil + } + ) + #expect(updateCheckCount.value == 1) + #expect(store.state.shouldCheckForRemoteUpdates) + #expect(store.state.didRequestVersionMetadata) + + await store.send(.fetchDownloadBadgeDone(.none)) { + $0.downloadBadge = .none + $0.hasLoadedDownloadBadge = true + $0.shouldCheckForRemoteUpdates = false + $0.didRequestVersionMetadata = false + $0.galleryVersionMetadata = nil + } + await store.skipReceivedActions(strict: false) + + #expect(store.state.shouldCheckForRemoteUpdates == false) + #expect(store.state.didRequestVersionMetadata == false) + #expect(store.state.galleryVersionMetadata == nil) + } + + @MainActor + @Test + func testDetailReducerDeleteDownloadResetsDownloadContext() async { + let download = sampleDownload(gid: "7733", title: "Reset Context", status: .completed) + var initialState = DetailReducer.State(download: download) + initialState.galleryVersionMetadata = sampleVersionMetadata( + gid: download.gid, token: download.token + ) + initialState.didRequestVersionMetadata = true + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = makeDeleteTestClient(download: download) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.deleteDownloadDone(.success(()))) { + $0.galleryVersionMetadata = nil + $0.didRequestVersionMetadata = false + $0.isDownloadContext = false + $0.shouldCheckForRemoteUpdates = false + } + await store.skipReceivedActions(strict: false) + + #expect(store.state.isDownloadContext == false) + #expect(store.state.shouldCheckForRemoteUpdates == false) + #expect(store.state.didRequestVersionMetadata == false) + #expect(store.state.galleryVersionMetadata == nil) + } + +} + +// MARK: - Store Factory Helpers + +private extension DetailReducerMetadataUpdateTests { + func makeUpdateTestStore( + gid: String, gallery: Gallery, detail: GalleryDetail, + updateCheckCount: UncheckedBox + ) -> TestStoreOf { + var initialState = DetailReducer.State() + initialState.gid = gid + initialState.gallery = gallery + initialState.galleryDetail = detail + return TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in + updateCheckCount.value += 1 + return .downloaded + }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in .success([:]) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + } + + func makeDeleteTestClient(download: DownloadedGallery) -> DownloadClient { + .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [download] }, + fetchDownload: { gid in gid == download.gid ? download : nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in .success([:]) } + ) + } +} diff --git a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift new file mode 100644 index 000000000..726757b48 --- /dev/null +++ b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift @@ -0,0 +1,200 @@ +// +// DetailReducerObserveTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DetailReducerObserveTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDetailReducerObservesDownloadBadgeTransitions() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let continuationBox = UncheckedBox.Continuation?>(nil) + let stream = AsyncStream<[DownloadedGallery]> { continuation in + continuationBox.value = continuation + } + let store = makeObserveTestStore(gallery: gallery, detail: detail, stream: stream) + store.exhaustivity = .off + + await store.send(.onAppear(gallery.gid, false)) { + $0.gid = gallery.gid + $0.showsNewDawnGreeting = false + $0.hasLoadedDownloadBadge = false + $0.didRunLaunchAutomation = false + } + await store.skipReceivedActions(strict: false) + + continuationBox.value?.yield([ + sampleDownload(gid: gallery.gid, title: gallery.title, status: .queued) + ]) + await store.receive(\.observeDownloadDone) { + $0.downloadBadge = .queued + $0.hasLoadedDownloadBadge = true + } + + continuationBox.value?.yield([ + sampleDownload( + gid: gallery.gid, title: gallery.title, status: .downloading, + pageCount: 26, completedPageCount: 7 + ) + ]) + await store.receive(\.observeDownloadDone) { + $0.downloadBadge = .downloading(7, 26) + $0.hasLoadedDownloadBadge = true + } + + continuationBox.value?.yield([ + sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed, + pageCount: 26, completedPageCount: 26 + ) + ]) + await store.receive(\.observeDownloadDone) { + $0.downloadBadge = .downloaded + $0.hasLoadedDownloadBadge = true + } + + continuationBox.value?.finish() + } + + @MainActor + @Test + func testDetailReducerOpenReadingUsesLocalManifestWhenAvailable() async throws { + let download = sampleDownload(gid: "888", title: "Offline Archive", status: .completed, pageCount: 2) + let manifest = try sampleManifest(gid: download.gid, title: download.title) + var initialState = DetailReducer.State(download: download) + initialState.galleryDetail = sampleGalleryDetail(gid: download.gid, title: download.title) + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = makeLocalManifestClient(download: download, manifest: manifest) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.openReading) + await store.skipReceivedActions(strict: false) + + #expect(store.state.readingState.contentSource == .local(download, manifest)) + if case .reading = store.state.route { + } else { + Issue.record("Expected reading route to be active.") + } + } + + @MainActor + @Test + func testDetailReducerOpenReadingFallsBackToRemoteWhenManifestUnavailable() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + initialState.galleryDetail = detail + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = makeNoManifestClient() + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.openReading) + await store.skipReceivedActions(strict: false) + + #expect(store.state.readingState.contentSource == .remote) + if case .reading = store.state.route { + } else { + Issue.record("Expected reading route to be active.") + } + } + +} + +// MARK: - Store Factory Helpers + +private extension DetailReducerObserveTests { + func makeObserveTestStore( + gallery: Gallery, detail: GalleryDetail, + stream: AsyncStream<[DownloadedGallery]> + ) -> TestStoreOf { + var initialState = DetailReducer.State() + initialState.gallery = gallery + initialState.galleryDetail = detail + return TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { stream }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + } + + func makeLocalManifestClient( + download: DownloadedGallery, manifest: DownloadManifest + ) -> DownloadClient { + .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [download] }, + fetchDownload: { gid in gid == download.gid ? download : nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, .downloaded) }) }, + updateRemoteSignature: { _, _ in .downloaded }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { gid in + gid == download.gid ? .success((download, manifest)) : .failure(.notFound) + } + ) + } + + func makeNoManifestClient() -> DownloadClient { + .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } +} diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift new file mode 100644 index 000000000..a587713e3 --- /dev/null +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -0,0 +1,164 @@ +// +// DetailReducerPauseAndGuardTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { + @Test + func testDetailReducerLaunchAutomationDoesNotRedownloadWhenBadgeIsResolved() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let options = DownloadOptionsSnapshot() + var initialState = DetailReducer.State() + initialState.gallery = gallery + initialState.galleryDetail = detail + + setenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID", gallery.gid, 1) + defer { unsetenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID") } + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .noop + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + + await store.send(.fetchDownloadBadgeDone(.downloaded)) { + $0.downloadBadge = .downloaded + $0.hasLoadedDownloadBadge = true + } + await store.send(.runLaunchAutomationIfNeeded(options)) { + $0.didRunLaunchAutomation = true + } + } + + @MainActor + @Test + func testDetailReducerIgnoresStartDownloadWhilePreparing() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let enqueueCount = UncheckedBox(0) + let options = DownloadOptionsSnapshot() + + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + initialState.galleryDetail = detail + initialState.isPreparingDownload = true + + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in + enqueueCount.value += 1 + return .success(()) + }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + + await store.send(.startDownload(options)) + + #expect(enqueueCount.value == 0) + #expect(store.state.isPreparingDownload) + #expect(store.state.downloadBadge == .none) + } + + @MainActor + @Test + func testDetailReducerTogglesPauseForActiveDownload() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let togglePauseCount = UncheckedBox(0) + + var initialState = DetailReducer.State() + initialState.gid = gallery.gid + initialState.gallery = gallery + initialState.galleryDetail = detail + initialState.downloadBadge = .downloading(7, 26) + + let store = makeTogglePauseStore(initialState: initialState, togglePauseCount: togglePauseCount) + + await store.send(.toggleDownloadPause) { $0.isPreparingDownload = true } + await store.receive(\.toggleDownloadPauseDone) { + $0.isPreparingDownload = false + $0.downloadBadge = .paused(7, 26) + $0.hasLoadedDownloadBadge = true + } + await store.receive(\.fetchDownloadBadge) + await store.receive(\.fetchDownloadBadgeDone, .paused(7, 26)) { + $0.downloadBadge = .paused(7, 26) + $0.hasLoadedDownloadBadge = true + } + + #expect(togglePauseCount.value == 1) + #expect(store.state.downloadBadge == .paused(7, 26)) + #expect(store.state.isPreparingDownload == false) + } + +} + +// MARK: - Store Factory Helpers + +private extension DetailReducerPauseAndGuardTests { + func makeTogglePauseStore( + initialState: DetailReducer.State, + togglePauseCount: UncheckedBox + ) -> TestStoreOf { + let store = TestStore(initialState: initialState) { + DetailReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { AsyncStream { continuation in continuation.finish() } }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, .paused(7, 26)) }) + }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in + togglePauseCount.value += 1 + return .success(()) + }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + store.exhaustivity = .off + return store + } +} diff --git a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift new file mode 100644 index 000000000..ce3f676ef --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift @@ -0,0 +1,239 @@ +// +// DownloadAutomationTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadAutomationTests: DownloadFeatureTestCase { + @Test + func testAppLaunchAutomationResolveParsesGalleryURLAndCookies() { + let automation = AppLaunchAutomation.resolve(environment: [ + "EHPANDA_AUTOMATION_TAB": "downloads", + "EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID": "1394965", + "EHPANDA_AUTOMATION_GALLERY_URL": "https://e-hentai.org/g/1394965/56c35114b6/", + "EHPANDA_AUTOMATION_IPB_MEMBER_ID": "4172984", + "EHPANDA_AUTOMATION_IPB_PASS_HASH": "pass-hash", + "EHPANDA_AUTOMATION_IGNEOUS": "igneous-value" + ]) + + #expect(automation?.initialTab == .downloads) + #expect(automation?.autoDownloadGID == "1394965") + #expect( + automation?.galleryURL == URL(string: "https://e-hentai.org/g/1394965/56c35114b6/") + ) + #expect(automation?.loginCookies?.memberID == "4172984") + #expect(automation?.loginCookies?.passHash == "pass-hash") + #expect(automation?.loginCookies?.igneous == "igneous-value") + } + + @Test + func testImportAutomationCookiesClearsStaleIgneousAndUsesSessionCookies() { + let cookieClient = CookieClient.live + cookieClient.clearAll() + defer { cookieClient.clearAll() } + + cookieClient.setOrEditCookie( + for: Defaults.URL.exhentai, + key: Defaults.Cookie.igneous, + value: "stale-igneous" + ) + + cookieClient.importAutomationCookies( + memberID: "4172984", + passHash: "pass-hash", + igneous: nil + ) + + let exCookies = HTTPCookieStorage.shared.cookies(for: Defaults.URL.exhentai) ?? [] + let memberCookie = exCookies.first { $0.name == Defaults.Cookie.ipbMemberId } + let passHashCookie = exCookies.first { $0.name == Defaults.Cookie.ipbPassHash } + let igneousCookie = exCookies.first { $0.name == Defaults.Cookie.igneous } + + #expect(memberCookie?.value == "4172984") + #expect(passHashCookie?.value == "pass-hash") + #expect(memberCookie?.isSessionOnly == true) + #expect(passHashCookie?.isSessionOnly == true) + #expect(igneousCookie == nil) + #expect(cookieClient.didLogin) + #expect(cookieClient.shouldFetchIgneous) + } + + @MainActor + @Test + func testRunLaunchAutomationFallsBackToInitialTabWhenGalleryURLIsUnhandleable() async { + setenv("EHPANDA_AUTOMATION_TAB", "downloads", 1) + setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://example.com/not-a-gallery", 1) + defer { + unsetenv("EHPANDA_AUTOMATION_TAB") + unsetenv("EHPANDA_AUTOMATION_GALLERY_URL") + } + + let store = TestStore(initialState: AppReducer.State()) { + AppReducer() + } withDependencies: { + $0.cookieClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.urlClient = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + } + + await store.send(.runLaunchAutomation) { + $0.didRunLaunchAutomation = true + } + await store.receive(\.tabBar.setTabBarItemType, .downloads) { + $0.tabBarState.tabBarItemType = .downloads + } + } + + @MainActor + @Test + func testDatabasePreparationImportsAutomationCookiesBeforeLoadingSettings() async { + let cookieClient = CookieClient.live + cookieClient.clearAll() + setenv("EHPANDA_AUTOMATION_IPB_MEMBER_ID", "4172984", 1) + setenv("EHPANDA_AUTOMATION_IPB_PASS_HASH", "pass-hash", 1) + defer { + cookieClient.clearAll() + unsetenv("EHPANDA_AUTOMATION_IPB_MEMBER_ID") + unsetenv("EHPANDA_AUTOMATION_IPB_PASS_HASH") + } + + let store = TestStore(initialState: AppReducer.State()) { + AppReducer() + } withDependencies: { + $0.cookieClient = cookieClient + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.uiApplicationClient = .noop + $0.userDefaultsClient = .noop + $0.appDelegateClient = .noop + $0.libraryClient = .noop + $0.loggerClient = .noop + $0.fileClient = .noop + $0.dfClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + + await store.send(.appDelegate(.migration(.onDatabasePreparationSuccess))) + await store.receive(\.appDelegate.removeExpiredImageURLs) + #expect(cookieClient.didLogin) + await store.receive(\.setting.loadUserSettings) + } + + @MainActor + @Test + func testLoadUserSettingsDefersExLaunchAutomationUntilIgneousArrives() async throws { + let cookieClient = CookieClient.live + cookieClient.clearAll() + cookieClient.importAutomationCookies( + memberID: "4172984", + passHash: "pass-hash", + igneous: nil + ) + setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://exhentai.org/g/1394965/56c35114b6/", 1) + defer { + cookieClient.clearAll() + unsetenv("EHPANDA_AUTOMATION_GALLERY_URL") + } + + let store = TestStore(initialState: AppReducer.State()) { + AppReducer() + } withDependencies: { + $0.cookieClient = cookieClient + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.uiApplicationClient = .noop + $0.userDefaultsClient = .noop + $0.appDelegateClient = .noop + $0.libraryClient = .noop + $0.loggerClient = .noop + $0.fileClient = .noop + $0.dfClient = .noop + $0.urlClient = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + } + store.exhaustivity = .off + + await store.send(.setting(.loadUserSettingsDone)) + #expect(store.state.didRunLaunchAutomation == false) + #expect(store.state.isAwaitingIgneousForLaunchAutomation) + + let response = try #require(HTTPURLResponse( + url: Defaults.URL.exhentai, + statusCode: 200, + httpVersion: nil, + headerFields: [ + "Set-Cookie": "\(Defaults.Cookie.igneous)=test-igneous" + ] + )) + await store.send(.setting(.fetchIgneousDone(.success(response)))) + await store.receive(\.runLaunchAutomation) { + $0.didRunLaunchAutomation = true + $0.isAwaitingIgneousForLaunchAutomation = false + } + } + + @MainActor + @Test + func testLoadUserSettingsKeepsExLaunchAutomationDeferredWhenIgneousFetchFails() async { + let cookieClient = CookieClient.live + cookieClient.clearAll() + cookieClient.importAutomationCookies( + memberID: "4172984", + passHash: "pass-hash", + igneous: nil + ) + setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://exhentai.org/g/1394965/56c35114b6/", 1) + defer { + cookieClient.clearAll() + unsetenv("EHPANDA_AUTOMATION_GALLERY_URL") + } + + let store = TestStore(initialState: AppReducer.State()) { + AppReducer() + } withDependencies: { + $0.cookieClient = cookieClient + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.uiApplicationClient = .noop + $0.userDefaultsClient = .noop + $0.appDelegateClient = .noop + $0.libraryClient = .noop + $0.loggerClient = .noop + $0.fileClient = .noop + $0.dfClient = .noop + $0.urlClient = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + } + store.exhaustivity = .off + + await store.send(.setting(.loadUserSettingsDone)) + #expect(store.state.didRunLaunchAutomation == false) + #expect(store.state.isAwaitingIgneousForLaunchAutomation) + + await store.send(.setting(.fetchIgneousDone(.failure(.networkingFailed)))) + await store.receive(\.setting.account.loadCookies) + #expect(store.state.didRunLaunchAutomation == false) + #expect(store.state.isAwaitingIgneousForLaunchAutomation) + } + +} diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift new file mode 100644 index 000000000..8583fe1bd --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -0,0 +1,161 @@ +// +// DownloadBadgeSortTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +struct DownloadBadgeSortTests: DownloadFeatureTestCase { + @Test + func testPartialDownloadBadgeUsesNeedsAttentionCopy() { + let partialDownload = sampleDownload( + gid: "480", + title: "Incomplete Archive", + status: .partial, + pageCount: 12, + completedPageCount: 5 + ) + #expect(partialDownload.badge.text == "Needs Attention 5/12") + #expect(DownloadListFilter.failed.title == "Needs Attention") + } + + @Test + func testQueuedRedownloadDoesNotLeakIntoCompletedFilter() { + let queuedRedownload = sampleDownload( + gid: "505", + title: "Delta Archive", + status: .completed, + completedPageCount: 12, + pendingOperation: .redownload + ) + + #expect(queuedRedownload.matches(filter: .completed) == false) + #expect(queuedRedownload.matches(filter: .update) == false) + } + + @Test + func testQueuedRepairDoesNotLeakIntoFailedFilter() { + let queuedRepair = sampleDownload( + gid: "606", + title: "Repair Archive", + status: .missingFiles, + completedPageCount: 3, + pendingOperation: .repair + ) + let missingFilesWithoutQueuedWork = sampleDownload( + gid: "607", + title: "Actually Missing", + status: .missingFiles, + pageCount: 4, + completedPageCount: 0 + ) + + #expect(queuedRepair.matches(filter: .failed) == false) + #expect(queuedRepair.matches(filter: .update) == false) + #expect(missingFilesWithoutQueuedWork.badge == .missingFiles) + #expect(missingFilesWithoutQueuedWork.matches(filter: .failed)) + } + + @Test + func testQueuedRedownloadKeepsQueuedSortPriority() { + let completedDownload = sampleDownload( + gid: "707", + title: "Completed Archive", + status: .completed, + lastDownloadedAt: .distantFuture + ) + + let queuedRedownload = sampleDownload( + gid: "808", + title: "Queued Archive", + status: .completed, + completedPageCount: 12, + lastDownloadedAt: .distantPast, + pendingOperation: .redownload + ) + + let sortedDownloads = [completedDownload, queuedRedownload].sorted { lhs, rhs in + if lhs.sortPriority != rhs.sortPriority { + return lhs.sortPriority < rhs.sortPriority + } + return (lhs.lastDownloadedAt ?? .distantPast) > (rhs.lastDownloadedAt ?? .distantPast) + } + + #expect(queuedRedownload.sortPriority == 1) + #expect(completedDownload.sortPriority == 7) + #expect(sortedDownloads.map(\.gid) == [queuedRedownload.gid, completedDownload.gid]) + } + + @Test + func testInProgressDownloadPrefersTemporaryCoverURL() throws { + let gid = "811" + let download = sampleDownload( + gid: gid, + title: "Temporary Cover Archive", + status: .downloading, + completedPageCount: 3 + ) + + let rootURL = try #require( + FileUtil.downloadsDirectoryURL, + "Downloads directory is unavailable in the test environment." + ) + + let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) + try? FileManager.default.removeItem(at: temporaryFolderURL) + defer { try? FileManager.default.removeItem(at: temporaryFolderURL) } + + try FileManager.default.createDirectory( + at: temporaryFolderURL, + withIntermediateDirectories: true + ) + let temporaryCoverURL = temporaryFolderURL.appendingPathComponent("cover.jpg") + try Data([0xFF, 0xD8, 0xFF]).write(to: temporaryCoverURL, options: .atomic) + + #expect(download.resolvedCoverURL(rootURL: rootURL) == temporaryCoverURL) + } + + @Test + func testQueuedDownloadPreservesTemporaryWorkingSet() { + let queuedDownload = sampleDownload( + gid: "809", + title: "Queued Archive", + status: .queued, + completedPageCount: 3 + ) + + #expect(queuedDownload.shouldPreserveTemporaryWorkingSet) + } + + @Test + func testActiveDownloadDoesNotNormalizeWhileTaskIsStillRunning() { + let activeDownload = sampleDownload( + gid: "810", + title: "Running Archive", + status: .downloading, + completedPageCount: 3 + ) + + #expect( + activeDownload.needsInterruptedDownloadNormalization( + activeGalleryID: activeDownload.gid, + hasActiveTask: true + ) == false + ) + #expect( + activeDownload.needsInterruptedDownloadNormalization( + activeGalleryID: nil, + hasActiveTask: false + ) + ) + #expect( + activeDownload.needsInterruptedDownloadNormalization( + activeGalleryID: "another-gid", + hasActiveTask: true + ) + ) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift index 2d081ec7b..571ecb465 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift @@ -2,6216 +2,6 @@ // DownloadFeatureReducerTests.swift // EhPandaTests // - -import CoreData -import ComposableArchitecture -import Kingfisher -import UIKit -import Testing -@testable import EhPanda - -@Suite(.serialized) -struct DownloadFeatureReducerTests: TestHelper { - @Test - func testQuickSearchWordUsesNameWhenContentIsEmpty() { - let word = QuickSearchWord(name: "artist:hossy", content: "") - - #expect(word.effectiveSearchText == "artist:hossy") - } - - @Test - func testPauseKeepsActiveDownloadPausedWhenDeferredSchedulingRuns() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000)) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [FailFastURLProtocol.self] - let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: URLSession(configuration: configuration) - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .downloading, - completedPageCount: 7 - ) - - let activeTask = Task { [manager] in - do { - try await Task.sleep(for: .seconds(60)) - } catch is CancellationError { - await manager.testingScheduleNextIfNeeded() - } catch {} - } - await manager.testingInstallActiveTask(gid: gid, task: activeTask) - - let result = await manager.togglePause(gid: gid) - - guard case .success = result else { - Issue.record("Pause should succeed, got \(result)") - return - } - - try await Task.sleep(for: .milliseconds(100)) - - let stored = await manager.testingFetchDownload(gid: gid) - let activeGalleryID = await manager.testingActiveGalleryID() - #expect(stored?.status == .paused) - #expect(stored?.badge == .paused(7, 26)) - #expect(activeGalleryID == nil) - } - - @Test - func testPauseUsesTemporaryWorkingSetProgressWhenCancelling() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 1) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [FailFastURLProtocol.self] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: URLSession(configuration: configuration) - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .downloading, - completedPageCount: 1, - pageCount: 2 - ) - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - try Data([0x01]).write( - to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), - options: .atomic - ) - try Data([0x02]).write( - to: temporaryFolderURL.appendingPathComponent("pages/0002.jpg"), - options: .atomic - ) - - let activeTask = Task { [manager] in - do { - try await Task.sleep(for: .seconds(60)) - } catch is CancellationError { - await manager.testingScheduleNextIfNeeded() - } catch {} - } - await manager.testingInstallActiveTask(gid: gid, task: activeTask) - - let result = await manager.togglePause(gid: gid) - - guard case .success = result else { - Issue.record("Pause should succeed, got \(result)") - return - } - - let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.status == .paused) - #expect(stored?.completedPageCount == 2) - #expect(stored?.badge == .paused(2, 2)) - } - - @Test - func testReconcileDownloadsNormalizesLegacyFailedStatusToNeedsAttention() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [FailFastURLProtocol.self] - let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: URLSession(configuration: configuration) - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .failed, - completedPageCount: 0, - pageCount: 18 - ) - - await manager.reconcileDownloads() - - let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.status == .partial) - #expect(stored?.badge == .partial(0, 18)) - } - - @Test - func testReconcileDownloadsClearsCancellationLikeGalleryError() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 3) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [FailFastURLProtocol.self] - let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: URLSession(configuration: configuration) - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .partial, - completedPageCount: 4, - pageCount: 18, - lastError: .init( - code: .fileOperationFailed, - message: "The operation could not be completed. (Swift.CancellationError error 1.)" - ) - ) - - await manager.reconcileDownloads() - - let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.lastError == nil) - #expect(stored?.status == .partial) - } - - @Test - func testLoadInspectionFiltersCancellationFailuresIntoPendingPages() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 4) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [FailFastURLProtocol.self] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: URLSession(configuration: configuration) - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .partial, - completedPageCount: 1, - pageCount: 2 - ) - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - try Data([0x01]).write( - to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), - options: .atomic - ) - try storage.writeFailedPages( - .init( - pages: [ - .init( - index: 2, - relativePath: "pages/0002.jpg", - failure: .init( - code: .fileOperationFailed, - message: "The operation could not be completed. (Swift.CancellationError error 1.)" - ) - ) - ] - ), - folderURL: temporaryFolderURL - ) - - let result = await manager.loadInspection(gid: gid) - guard case .success(let inspection) = result else { - Issue.record("Expected inspection to load successfully, got \(result)") - return - } - - #expect(inspection.pages[0].status == .downloaded) - #expect(inspection.pages[1].status == .pending) - #expect((try? storage.readFailedPages(folderURL: temporaryFolderURL).pages.isEmpty) ?? true) - } - - @Test - func testDownloadsFilterMatchesKeywordAndStatus() { - let activeDownload = sampleDownload( - gid: "101", - title: "Alpha Archive", - status: .downloading, - completedPageCount: 2 - ) - let completedDownload = sampleDownload( - gid: "202", - title: "Beta Collection", - status: .completed - ) - - var state = DownloadsReducer.State() - state.downloads = [activeDownload, completedDownload] - state.filter = .active - state.keyword = "alpha" - - #expect(state.filteredDownloads == [activeDownload]) - } - - @Test - func testQueuedRetryWorkAppearsAsActiveDownloadBadge() { - let queuedRedownload = sampleDownload( - gid: "303", - title: "Gamma Archive", - status: .completed, - completedPageCount: 12, - pendingOperation: .redownload - ) - - #expect(queuedRedownload.pendingOperation == .redownload) - #expect(queuedRedownload.badge == .queued) - #expect(queuedRedownload.matches(filter: .active)) - } - - @Test - func testQueuedRepairWorkAppearsAsActiveDownloadBadge() { - let queuedRepair = sampleDownload( - gid: "404", - title: "Broken Archive", - status: .missingFiles, - completedPageCount: 3, - pendingOperation: .repair - ) - - #expect(queuedRepair.pendingOperation == .repair) - #expect(queuedRepair.badge == .queued) - #expect(queuedRepair.matches(filter: .active)) - } - - @Test - func testQueuedUpdateWorkAppearsAsActiveDownloadBadge() { - let queuedUpdate = sampleDownload( - gid: "414", - title: "Updated Archive", - status: .updateAvailable, - completedPageCount: 12, - latestRemoteVersionSignature: "hash:v2", - pendingOperation: .update - ) - - #expect(queuedUpdate.pendingOperation == .update) - #expect(queuedUpdate.badge == .queued) - #expect(queuedUpdate.matches(filter: .active)) - #expect(queuedUpdate.matches(filter: .update) == false) - } - - @Test - func testQueuedResumedUpdateDoesNotPretendToBeInitialWork() { - let resumedUpdate = sampleDownload( - gid: "415", - title: "Resumed Update", - status: .queued, - pageCount: 26, - completedPageCount: 7, - latestRemoteVersionSignature: "hash:v2" - ) - - #expect(resumedUpdate.pendingOperation == nil) - #expect(resumedUpdate.isQueuedWorkItem) - #expect(resumedUpdate.badge == .queued) - #expect(resumedUpdate.matches(filter: .active)) - } - - @Test - func testPausedDownloadAppearsAsActiveBadge() { - let pausedDownload = sampleDownload( - gid: "455", - title: "Paused Archive", - status: .paused, - pageCount: 12, - completedPageCount: 4 - ) - - #expect(pausedDownload.badge == .paused(4, 12)) - #expect(pausedDownload.matches(filter: .active)) - } - - @Test - func testActiveDownloadsDoNotExposeUpdateActions() { - let downloadingUpdate = sampleDownload( - gid: "456", - title: "Downloading Update", - status: .downloading, - completedPageCount: 5, - latestRemoteVersionSignature: "hash:v2" - ) - let pausedUpdate = sampleDownload( - gid: "457", - title: "Paused Update", - status: .paused, - completedPageCount: 5, - latestRemoteVersionSignature: "hash:v2" - ) - let completedUpdate = sampleDownload( - gid: "458", - title: "Completed Update", - status: .completed, - latestRemoteVersionSignature: "hash:v2" - ) - - #expect(downloadingUpdate.canTriggerUpdate == false) - #expect(pausedUpdate.canTriggerUpdate == false) - #expect(completedUpdate.canTriggerUpdate) - } - - @Test - func testDownloadsFilterMatchesGalleryFilterCriteria() { - let qualifyingDownload = sampleDownload( - gid: "466", - title: "Chinese Archive", - status: .completed, - pageCount: 28 - ) - let filteredOutDownload = sampleDownload( - gid: "477", - title: "Low Rated Archive", - status: .completed, - pageCount: 8 - ) - - var state = DownloadsReducer.State() - state.downloads = [ - qualifyingDownload, - filteredOutDownload - ] - state.galleryFilter.minimumRatingActivated = true - state.galleryFilter.minimumRating = 4 - state.galleryFilter.pageRangeActivated = true - state.galleryFilter.pageLowerBound = "20" - state.galleryFilter.pageUpperBound = "40" - - #expect(state.filteredDownloads == [qualifyingDownload]) - } - - @Test - func testDownloadsFilterExcludesSelectedCategoriesLikeSearchFilter() { - let nonHDownload = sampleDownload( - gid: "478", - title: "Healthy Archive", - status: .completed, - category: .nonH - ) - let mangaDownload = sampleDownload( - gid: "479", - title: "Comic Archive", - status: .completed, - category: .manga - ) - - var state = DownloadsReducer.State() - state.downloads = [nonHDownload, mangaDownload] - state.galleryFilter.excludedCategories = [.nonH] - - #expect(state.filteredDownloads == [mangaDownload]) - } - - @Test - func testPartialDownloadBadgeUsesNeedsAttentionCopy() { - let partialDownload = sampleDownload( - gid: "480", - title: "Incomplete Archive", - status: .partial, - pageCount: 12, - completedPageCount: 5 - ) - - #expect(partialDownload.badge.text == "Needs Attention 5/12") - #expect(DownloadListFilter.failed.title == "Needs Attention") - } - - @Test - func testQueuedRedownloadDoesNotLeakIntoCompletedFilter() { - let queuedRedownload = sampleDownload( - gid: "505", - title: "Delta Archive", - status: .completed, - completedPageCount: 12, - pendingOperation: .redownload - ) - - #expect(queuedRedownload.matches(filter: .completed) == false) - #expect(queuedRedownload.matches(filter: .update) == false) - } - - @Test - func testQueuedRepairDoesNotLeakIntoFailedFilter() { - let queuedRepair = sampleDownload( - gid: "606", - title: "Repair Archive", - status: .missingFiles, - completedPageCount: 3, - pendingOperation: .repair - ) - let missingFilesWithoutQueuedWork = sampleDownload( - gid: "607", - title: "Actually Missing", - status: .missingFiles, - pageCount: 4, - completedPageCount: 0 - ) - - #expect(queuedRepair.matches(filter: .failed) == false) - #expect(queuedRepair.matches(filter: .update) == false) - #expect(missingFilesWithoutQueuedWork.badge == .missingFiles) - #expect(missingFilesWithoutQueuedWork.matches(filter: .failed)) - } - - @Test - func testQueuedRedownloadKeepsQueuedSortPriority() { - let completedDownload = sampleDownload( - gid: "707", - title: "Completed Archive", - status: .completed, - lastDownloadedAt: .distantFuture - ) - - let queuedRedownload = sampleDownload( - gid: "808", - title: "Queued Archive", - status: .completed, - completedPageCount: 12, - lastDownloadedAt: .distantPast, - pendingOperation: .redownload - ) - - let sortedDownloads = [completedDownload, queuedRedownload].sorted { lhs, rhs in - if lhs.sortPriority != rhs.sortPriority { - return lhs.sortPriority < rhs.sortPriority - } - return (lhs.lastDownloadedAt ?? .distantPast) > (rhs.lastDownloadedAt ?? .distantPast) - } - - #expect(queuedRedownload.sortPriority == 1) - #expect(completedDownload.sortPriority == 7) - #expect(sortedDownloads.map(\.gid) == [queuedRedownload.gid, completedDownload.gid]) - } - - @Test - func testInProgressDownloadPrefersTemporaryCoverURL() throws { - let gid = "811" - let download = sampleDownload( - gid: gid, - title: "Temporary Cover Archive", - status: .downloading, - completedPageCount: 3 - ) - - let rootURL = try #require( - FileUtil.downloadsDirectoryURL, - "Downloads directory is unavailable in the test environment." - ) - - let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) - try? FileManager.default.removeItem(at: temporaryFolderURL) - defer { try? FileManager.default.removeItem(at: temporaryFolderURL) } - - try FileManager.default.createDirectory( - at: temporaryFolderURL, - withIntermediateDirectories: true - ) - let temporaryCoverURL = temporaryFolderURL.appendingPathComponent("cover.jpg") - try Data([0xFF, 0xD8, 0xFF]).write(to: temporaryCoverURL, options: .atomic) - - #expect(download.resolvedCoverURL(rootURL: rootURL) == temporaryCoverURL) - } - - @Test - func testQueuedDownloadPreservesTemporaryWorkingSet() { - let queuedDownload = sampleDownload( - gid: "809", - title: "Queued Archive", - status: .queued, - completedPageCount: 3 - ) - - #expect(queuedDownload.shouldPreserveTemporaryWorkingSet) - } - - @Test - func testActiveDownloadDoesNotNormalizeWhileTaskIsStillRunning() { - let activeDownload = sampleDownload( - gid: "810", - title: "Running Archive", - status: .downloading, - completedPageCount: 3 - ) - - #expect( - activeDownload.needsInterruptedDownloadNormalization( - activeGalleryID: activeDownload.gid, - hasActiveTask: true - ) == false - ) - #expect( - activeDownload.needsInterruptedDownloadNormalization( - activeGalleryID: nil, - hasActiveTask: false - ) - ) - #expect( - activeDownload.needsInterruptedDownloadNormalization( - activeGalleryID: "another-gid", - hasActiveTask: true - ) - ) - } - - @Test - func testAppLaunchAutomationResolveParsesGalleryURLAndCookies() { - let automation = AppLaunchAutomation.resolve(environment: [ - "EHPANDA_AUTOMATION_TAB": "downloads", - "EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID": "1394965", - "EHPANDA_AUTOMATION_GALLERY_URL": "https://e-hentai.org/g/1394965/56c35114b6/", - "EHPANDA_AUTOMATION_IPB_MEMBER_ID": "4172984", - "EHPANDA_AUTOMATION_IPB_PASS_HASH": "pass-hash", - "EHPANDA_AUTOMATION_IGNEOUS": "igneous-value" - ]) - - #expect(automation?.initialTab == .downloads) - #expect(automation?.autoDownloadGID == "1394965") - #expect( - automation?.galleryURL == URL(string: "https://e-hentai.org/g/1394965/56c35114b6/") - ) - #expect(automation?.loginCookies?.memberID == "4172984") - #expect(automation?.loginCookies?.passHash == "pass-hash") - #expect(automation?.loginCookies?.igneous == "igneous-value") - } - - @Test - func testImportAutomationCookiesClearsStaleIgneousAndUsesSessionCookies() { - let cookieClient = CookieClient.live - cookieClient.clearAll() - defer { cookieClient.clearAll() } - - cookieClient.setOrEditCookie( - for: Defaults.URL.exhentai, - key: Defaults.Cookie.igneous, - value: "stale-igneous" - ) - - cookieClient.importAutomationCookies( - memberID: "4172984", - passHash: "pass-hash", - igneous: nil - ) - - let exCookies = HTTPCookieStorage.shared.cookies(for: Defaults.URL.exhentai) ?? [] - let memberCookie = exCookies.first { $0.name == Defaults.Cookie.ipbMemberId } - let passHashCookie = exCookies.first { $0.name == Defaults.Cookie.ipbPassHash } - let igneousCookie = exCookies.first { $0.name == Defaults.Cookie.igneous } - - #expect(memberCookie?.value == "4172984") - #expect(passHashCookie?.value == "pass-hash") - #expect(memberCookie?.isSessionOnly == true) - #expect(passHashCookie?.isSessionOnly == true) - #expect(igneousCookie == nil) - #expect(cookieClient.didLogin) - #expect(cookieClient.shouldFetchIgneous) - } - - @MainActor - @Test - func testRunLaunchAutomationFallsBackToInitialTabWhenGalleryURLIsUnhandleable() async { - setenv("EHPANDA_AUTOMATION_TAB", "downloads", 1) - setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://example.com/not-a-gallery", 1) - defer { - unsetenv("EHPANDA_AUTOMATION_TAB") - unsetenv("EHPANDA_AUTOMATION_GALLERY_URL") - } - - let store = TestStore(initialState: AppReducer.State()) { - AppReducer() - } withDependencies: { - $0.cookieClient = .noop - $0.deviceClient = .noop - $0.hapticsClient = .noop - $0.urlClient = .init( - checkIfHandleable: { _ in false }, - checkIfMPVURL: { _ in false }, - parseGalleryID: { _ in .init() } - ) - } - - await store.send(.runLaunchAutomation) { - $0.didRunLaunchAutomation = true - } - await store.receive(\.tabBar.setTabBarItemType, .downloads) { - $0.tabBarState.tabBarItemType = .downloads - } - } - - @MainActor - @Test - func testDatabasePreparationImportsAutomationCookiesBeforeLoadingSettings() async { - let cookieClient = CookieClient.live - cookieClient.clearAll() - setenv("EHPANDA_AUTOMATION_IPB_MEMBER_ID", "4172984", 1) - setenv("EHPANDA_AUTOMATION_IPB_PASS_HASH", "pass-hash", 1) - defer { - cookieClient.clearAll() - unsetenv("EHPANDA_AUTOMATION_IPB_MEMBER_ID") - unsetenv("EHPANDA_AUTOMATION_IPB_PASS_HASH") - } - - let store = TestStore(initialState: AppReducer.State()) { - AppReducer() - } withDependencies: { - $0.cookieClient = cookieClient - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.hapticsClient = .noop - $0.uiApplicationClient = .noop - $0.userDefaultsClient = .noop - $0.appDelegateClient = .noop - $0.libraryClient = .noop - $0.loggerClient = .noop - $0.fileClient = .noop - $0.dfClient = .noop - $0.urlClient = .noop - } - store.exhaustivity = .off - - await store.send(.appDelegate(.migration(.onDatabasePreparationSuccess))) - await store.receive(\.appDelegate.removeExpiredImageURLs) - #expect(cookieClient.didLogin) - await store.receive(\.setting.loadUserSettings) - } - - @MainActor - @Test - func testLoadUserSettingsDefersExLaunchAutomationUntilIgneousArrives() async throws { - let cookieClient = CookieClient.live - cookieClient.clearAll() - cookieClient.importAutomationCookies( - memberID: "4172984", - passHash: "pass-hash", - igneous: nil - ) - setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://exhentai.org/g/1394965/56c35114b6/", 1) - defer { - cookieClient.clearAll() - unsetenv("EHPANDA_AUTOMATION_GALLERY_URL") - } - - let store = TestStore(initialState: AppReducer.State()) { - AppReducer() - } withDependencies: { - $0.cookieClient = cookieClient - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.hapticsClient = .noop - $0.uiApplicationClient = .noop - $0.userDefaultsClient = .noop - $0.appDelegateClient = .noop - $0.libraryClient = .noop - $0.loggerClient = .noop - $0.fileClient = .noop - $0.dfClient = .noop - $0.urlClient = .init( - checkIfHandleable: { _ in false }, - checkIfMPVURL: { _ in false }, - parseGalleryID: { _ in .init() } - ) - } - store.exhaustivity = .off - - await store.send(.setting(.loadUserSettingsDone)) - #expect(store.state.didRunLaunchAutomation == false) - #expect(store.state.isAwaitingIgneousForLaunchAutomation) - - let response = try #require(HTTPURLResponse( - url: Defaults.URL.exhentai, - statusCode: 200, - httpVersion: nil, - headerFields: [ - "Set-Cookie": "\(Defaults.Cookie.igneous)=test-igneous" - ] - )) - await store.send(.setting(.fetchIgneousDone(.success(response)))) - await store.receive(\.runLaunchAutomation) { - $0.didRunLaunchAutomation = true - $0.isAwaitingIgneousForLaunchAutomation = false - } - } - - @MainActor - @Test - func testLoadUserSettingsKeepsExLaunchAutomationDeferredWhenIgneousFetchFails() async { - let cookieClient = CookieClient.live - cookieClient.clearAll() - cookieClient.importAutomationCookies( - memberID: "4172984", - passHash: "pass-hash", - igneous: nil - ) - setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://exhentai.org/g/1394965/56c35114b6/", 1) - defer { - cookieClient.clearAll() - unsetenv("EHPANDA_AUTOMATION_GALLERY_URL") - } - - let store = TestStore(initialState: AppReducer.State()) { - AppReducer() - } withDependencies: { - $0.cookieClient = cookieClient - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.hapticsClient = .noop - $0.uiApplicationClient = .noop - $0.userDefaultsClient = .noop - $0.appDelegateClient = .noop - $0.libraryClient = .noop - $0.loggerClient = .noop - $0.fileClient = .noop - $0.dfClient = .noop - $0.urlClient = .init( - checkIfHandleable: { _ in false }, - checkIfMPVURL: { _ in false }, - parseGalleryID: { _ in .init() } - ) - } - store.exhaustivity = .off - - await store.send(.setting(.loadUserSettingsDone)) - #expect(store.state.didRunLaunchAutomation == false) - #expect(store.state.isAwaitingIgneousForLaunchAutomation) - - await store.send(.setting(.fetchIgneousDone(.failure(.networkingFailed)))) - await store.receive(\.setting.account.loadCookies) - #expect(store.state.didRunLaunchAutomation == false) - #expect(store.state.isAwaitingIgneousForLaunchAutomation) - } - - @MainActor - @Test - func testDownloadsReducerKeepsIdleStateForEmptyLibrary() async { - let store = TestStore(initialState: DownloadsReducer.State()) { - DownloadsReducer() - } - - await store.send(.fetchDownloadsDone([])) { - $0.loadingState = .idle - } - - #expect(store.state.downloads == []) - } - - @MainActor - @Test - func testDownloadsReducerSeedsOnlineDetailStateFromDownload() async { - let download = sampleDownload( - gid: "123456", - title: "Completed Gallery", - status: .completed - ) - var initialState = DownloadsReducer.State() - initialState.downloads = [download] - - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } - store.exhaustivity = .off - - await store.send(.setNavigation(.detail(download.gid))) - - #expect(store.state.route == .detail(download.gid)) - #expect(store.state.detailState.wrappedValue?.gid == download.gid) - #expect(store.state.detailState.wrappedValue?.gallery.id == download.gid) - #expect(store.state.detailState.wrappedValue?.downloadBadge == .downloaded) - #expect(store.state.detailState.wrappedValue?.shouldCheckForRemoteUpdates == true) - } - - @MainActor - @Test - func testDownloadsReducerUpdateActionUsesDownloadClientRetry() async { - let retried = UncheckedBox<[String]>([]) - let download = sampleDownload( - gid: "123456", - title: "Completed Gallery", - status: .updateAvailable, - latestRemoteVersionSignature: "hash:v2" - ) - var initialState = DownloadsReducer.State() - initialState.downloads = [download] - - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { gid, mode in - if mode == .update { - retried.value.append(gid) - } - return .success(()) - }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - } - store.exhaustivity = .off - - await store.send(.updateDownload(download.gid)) - await store.receive(\.updateDownloadDone) - - #expect(retried.value == [download.gid]) - } - - @MainActor - @Test - func testDownloadsReducerDeleteActionUsesDownloadClientDelete() async { - let deleted = UncheckedBox<[String]>([]) - let download = sampleDownload( - gid: "654321", - title: "Completed Gallery", - status: .completed - ) - var initialState = DownloadsReducer.State() - initialState.downloads = [download] - - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { gid in - deleted.value.append(gid) - return .success(()) - }, - loadManifest: { _ in .failure(.notFound) } - ) - } - store.exhaustivity = .off - - await store.send(.deleteDownload(download.gid)) - await store.receive(\.deleteDownloadDone) - - #expect(deleted.value == [download.gid]) - } - - @MainActor - @Test - func testDownloadsReducerTogglePauseActionUsesDownloadClientPause() async { - let toggled = UncheckedBox<[String]>([]) - let download = sampleDownload( - gid: "987654", - title: "Downloading Gallery", - status: .downloading, - completedPageCount: 9 - ) - var initialState = DownloadsReducer.State() - initialState.downloads = [download] - - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { gid in - toggled.value.append(gid) - return .success(()) - }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - } - store.exhaustivity = .off - - await store.send(.toggleDownloadPause(download.gid)) - await store.receive(\.toggleDownloadPauseDone) - - #expect(toggled.value == [download.gid]) - } - - @MainActor - @Test - func testDownloadInspectorReducerLoadsInspection() async { - let download = sampleDownload( - gid: "246810", - title: "Inspector Gallery", - status: .failed, - completedPageCount: 1 - ) - let inspection = sampleInspection(download: download) - - let store = TestStore(initialState: .init(gid: download.gid)) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in .success(inspection) } - ) - } - store.exhaustivity = .off - - await store.send(.loadInspection) - await store.receive(\.loadInspectionDone) { - $0.inspection = inspection - $0.stableInspection = inspection - $0.loadingState = .idle - } - } - - @MainActor - @Test - func testDownloadInspectorReducerRetryPageUsesDownloadClientRetryPages() async { - await confirmation(expectedCount: 1) { confirm in - let retried = UncheckedBox<[Int]>([]) - let download = sampleDownload( - gid: "112233", - title: "Retry Page Gallery", - status: .failed, - completedPageCount: 1 - ) - var initialState = DownloadInspectorReducer.State(gid: download.gid) - initialState.inspection = sampleInspection(download: download) - initialState.loadingState = .idle - - let store = TestStore(initialState: initialState) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, pageIndices in - retried.value = pageIndices - confirm() - return .success(()) - }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in - guard let inspection = initialState.inspection else { - return .failure(.notFound) - } - return .success(inspection) - } - ) - } - store.exhaustivity = .off - - await store.send(.retryPage(2)) - #expect(retried.value == [2]) - } - } - - @MainActor - @Test - func testDownloadInspectorReducerRetryFailedPagesMarksFailedPagesPending() async { - let retried = UncheckedBox<[Int]>([]) - let download = sampleDownload( - gid: "112235", - title: "Retry Failed Pages Gallery", - status: .partial, - completedPageCount: 1 - ) - var initialState = DownloadInspectorReducer.State(gid: download.gid) - initialState.inspection = sampleInspection(download: download) - initialState.loadingState = .idle - - let store = TestStore(initialState: initialState) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, pageIndices in - retried.value = pageIndices - return .success(()) - }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in - guard let inspection = initialState.inspection else { - return .failure(.notFound) - } - return .success(inspection) - } - ) - } - store.exhaustivity = .off - - await store.send(.retryFailedPages) { - guard let inspection = $0.inspection else { return } - $0.inspection = .init( - download: inspection.download, - coverURL: inspection.coverURL, - pages: [ - .init( - index: 1, - status: .downloaded, - relativePath: "pages/0001.jpg", - fileURL: URL(fileURLWithPath: "/tmp/0001.jpg"), - failure: nil - ), - .init( - index: 2, - status: .pending, - relativePath: "pages/0002.jpg", - fileURL: nil, - failure: nil - ) - ] - ) - } - - #expect(retried.value == [2]) - } - - @MainActor - @Test - func testDownloadInspectorKeepsRetriedPagesPendingWhileRetryWorkRemainsActive() async { - let download = sampleDownload( - gid: "112236", - title: "Retry Pending Gallery", - status: .partial, - completedPageCount: 1 - ) - let refreshedInspection = sampleInspection(download: download) - - var initialState = DownloadInspectorReducer.State(gid: download.gid) - initialState.inspection = sampleInspection(download: download) - initialState.stableInspection = sampleInspection(download: download) - initialState.retryingPageIndices = [2] - initialState.loadingState = .idle - - let store = TestStore(initialState: initialState) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in .success(refreshedInspection) } - ) - } - store.exhaustivity = .off - - await store.send(.loadInspection) - let requestID = store.state.inspectionRequestID - await store.send(.loadInspectionDone(requestID, .success(refreshedInspection))) { - $0.inspection = .init( - download: download, - coverURL: refreshedInspection.coverURL, - pages: [ - refreshedInspection.pages[0], - .init( - index: 2, - status: .pending, - relativePath: "pages/0002.jpg", - fileURL: nil, - failure: nil - ) - ] - ) - $0.loadingState = .idle - $0.retryingPageIndices = [2] - } - } - - @MainActor - @Test - func testDownloadInspectorClearsRetryingPagesAfterRetrySettlesWithFailure() async { - let initialDownload = sampleDownload( - gid: "112237", - title: "Retry Failure Gallery", - status: .partial, - completedPageCount: 1 - ) - let settledDownload = sampleDownload( - gid: "112237", - title: "Retry Failure Gallery", - status: .partial, - completedPageCount: 1, - lastError: .init(code: .networkingFailed, message: "Network Error") - ) - let settledInspection = sampleInspection(download: settledDownload) - - var initialState = DownloadInspectorReducer.State(gid: initialDownload.gid) - initialState.inspection = sampleInspection(download: initialDownload) - initialState.stableInspection = sampleInspection(download: initialDownload) - initialState.retryingPageIndices = [2] - initialState.loadingState = .idle - - let store = TestStore(initialState: initialState) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in .success(settledInspection) } - ) - } - store.exhaustivity = .off - - await store.send(.loadInspection) - let requestID = store.state.inspectionRequestID - await store.send(.loadInspectionDone(requestID, .success(settledInspection))) { - $0.inspection = settledInspection - $0.stableInspection = settledInspection - $0.loadingState = .idle - $0.retryingPageIndices = [] - } - } - - @MainActor - @Test - func testDownloadInspectorRestoresStableInspectionWhenRetryReloadFails() async { - let download = sampleDownload( - gid: "112238", - title: "Retry Reload Failure Gallery", - status: .partial, - completedPageCount: 1 - ) - let stableInspection = sampleInspection(download: download) - - var initialState = DownloadInspectorReducer.State(gid: download.gid) - initialState.inspection = .init( - download: download, - coverURL: stableInspection.coverURL, - pages: [ - stableInspection.pages[0], - .init( - index: 2, - status: .pending, - relativePath: "pages/0002.jpg", - fileURL: nil, - failure: nil - ) - ] - ) - initialState.stableInspection = stableInspection - initialState.retryingPageIndices = [2] - initialState.loadingState = .idle - - let store = TestStore(initialState: initialState) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in .failure(.networkingFailed) } - ) - } - store.exhaustivity = .off - - let requestID = store.state.inspectionRequestID - await store.send(.loadInspectionDone(requestID, .failure(.networkingFailed))) { - $0.inspection = stableInspection - $0.loadingState = .failed(.networkingFailed) - $0.retryingPageIndices = [] - } - } - - @MainActor - @Test - func testDownloadInspectorSkipsReloadWhenObservedDownloadDidNotChange() async { - let download = sampleDownload( - gid: "112244", - title: "Stable Inspector Gallery", - status: .partial, - completedPageCount: 1 - ) - let inspection = sampleInspection(download: download) - let loadInspectionCount = UncheckedBox(0) - - var initialState = DownloadInspectorReducer.State(gid: download.gid) - initialState.inspection = inspection - initialState.loadingState = .idle - - let store = TestStore(initialState: initialState) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in - loadInspectionCount.value += 1 - return .success(inspection) - } - ) - } - store.exhaustivity = .off - - await store.send(.observeDownloadsDone([download])) - #expect(loadInspectionCount.value == 0) - } - - @MainActor - @Test - func testDownloadInspectorIgnoresStaleInspectionResponses() async { - let originalDownload = sampleDownload( - gid: "112245", - title: "Stale Inspector Gallery", - status: .partial, - completedPageCount: 1 - ) - let refreshedDownload = sampleDownload( - gid: "112245", - title: "Stale Inspector Gallery", - status: .partial, - completedPageCount: 2 - ) - let staleInspection = sampleInspection(download: originalDownload) - let refreshedInspection = sampleInspection(download: refreshedDownload) - - let firstRequestID = UUID() - let secondRequestID = UUID() - var initialState = DownloadInspectorReducer.State(gid: originalDownload.gid) - initialState.loadingState = .loading - initialState.inspectionRequestID = secondRequestID - - let store = TestStore(initialState: initialState) { - DownloadInspectorReducer() - } - store.exhaustivity = .off - - await store.send(.loadInspectionDone(firstRequestID, .success(staleInspection))) - #expect(store.state.inspection == nil) - - await store.send(.loadInspectionDone(secondRequestID, .success(refreshedInspection))) { - $0.inspection = refreshedInspection - $0.stableInspection = refreshedInspection - $0.loadingState = .idle - } - } - - @Test - func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000)) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .failed, - completedPageCount: 1, - pageCount: 2 - ) - - let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - try Data([0x01]).write( - to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), - options: .atomic - ) - try JSONEncoder().encode( - DownloadFailedPagesSnapshot( - pages: [ - .init( - index: 2, - relativePath: "pages/0002.jpg", - failure: .init(code: .networkingFailed, message: "Network Error") - ) - ] - ) - ) - .write( - to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadFailedPages), - options: .atomic - ) - - let result = await manager.loadInspection(gid: gid) - let inspection = try result.get() - - #expect(inspection.pages[0].status == .downloaded) - #expect(inspection.pages[1].status == .failed) - #expect(inspection.pages[1].failure?.code == .networkingFailed) - } - - @Test - func testDownloadManagerLoadLocalPageURLsPrefersCompletedFolderForCompletedDownload() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 11) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: .shared - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .completed, - completedPageCount: 2, - pageCount: 2 - ) - - let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) - try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let manifest = try sampleManifest(gid: gid, title: "Pause Race") - try JSONEncoder().encode(manifest).write( - to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) - try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - let completedPageURL = completedFolderURL.appendingPathComponent("pages/0001.jpg") - try Data([0x01]).write(to: completedPageURL, options: .atomic) - try Data([0x02]).write( - to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), - options: .atomic - ) - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let temporaryPageURL = temporaryFolderURL.appendingPathComponent("pages/0001.jpg") - try Data([0x02]).write(to: temporaryPageURL, options: .atomic) - - let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - - #expect(pageURLs[1] == completedPageURL) - #expect(pageURLs[1] != temporaryPageURL) - #expect(pageURLs[3] == nil) - } - - @Test - func testDownloadManagerLoadLocalPageURLsMergesReadableCompletedPagesWithTemporaryPages() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 12) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: .shared - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .downloading, - completedPageCount: 2, - pageCount: 2 - ) - - let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) - try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let manifest = try sampleManifest(gid: gid, title: "Pause Race") - try JSONEncoder().encode(manifest).write( - to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) - try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - try Data([0x01]).write( - to: completedFolderURL.appendingPathComponent("pages/0001.jpg"), - options: .atomic - ) - try Data([0x09]).write( - to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), - options: .atomic - ) - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let temporaryPageURL = temporaryFolderURL.appendingPathComponent("pages/0002.jpg") - try Data([0x02]).write(to: temporaryPageURL, options: .atomic) - - let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - - #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("pages/0001.jpg")) - #expect(pageURLs[2] == temporaryPageURL) - } - - @Test - func testRepairSeedRejectsOldCompletedVersionWhenGalleryUpdatedButPageCountMatches() async throws { - let gid = "repair-seed-\(UUID().uuidString)" - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: .shared - ) - - try storage.ensureRootDirectory() - let existingDownload = sampleDownload( - gid: gid, - title: "Mixed Version", - status: .missingFiles, - pageCount: 2, - completedPageCount: 2, - remoteVersionSignature: "hash:v1", - latestRemoteVersionSignature: "hash:v2" - ) - let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Mixed Version", isDirectory: true) - try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let oldManifest = try sampleManifest( - gid: gid, - title: "Mixed Version", - pageCount: 2, - versionSignature: "hash:v1" - ) - try JSONEncoder().encode(oldManifest).write( - to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) - try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - try Data([0x01]).write( - to: completedFolderURL.appendingPathComponent("pages/0001.jpg"), - options: .atomic - ) - try Data([0x02]).write( - to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), - options: .atomic - ) - - let payload = DownloadRequestPayload( - gallery: Gallery( - gid: gid, - token: "token", - title: "Mixed Version", - rating: 4, - tags: [], - category: .doujinshi, - uploader: "Uploader", - pageCount: 2, - postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token") - ), - galleryDetail: GalleryDetail( - gid: gid, - title: "Mixed Version", - jpnTitle: nil, - isFavorited: false, - visibility: .yes, - rating: 4, - userRating: 0, - ratingCount: 1, - category: .doujinshi, - language: .japanese, - uploader: "Uploader", - postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - favoritedCount: 0, - pageCount: 2, - sizeCount: 1, - sizeType: "MB", - torrentCount: 0 - ), - previewURLs: [:], - previewConfig: .normal(rows: 4), - host: .ehentai, - options: .init(), - mode: .repair - ) - - let workingSeed = try await manager.testingPrepareWorkingSeed( - payload: payload, - existingDownload: existingDownload, - versionSignature: "hash:v2" - ) - - #expect(workingSeed.manifest == nil) - #expect(workingSeed.existingPages.isEmpty) - #expect(workingSeed.coverRelativePath == nil) - #expect( - FileManager.default.fileExists( - atPath: workingSeed.folderURL.appendingPathComponent("pages/0001.jpg").path - ) == false - ) - #expect( - FileManager.default.fileExists( - atPath: workingSeed.folderURL.appendingPathComponent("pages/0002.jpg").path - ) == false - ) - } - - @Test - func testDownloadManagerLoadLocalPageURLsMarksCompletedDownloadMissingFilesWhenZeroBytePageIsFound() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 13) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: .shared - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .completed, - completedPageCount: 2, - pageCount: 2 - ) - - let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) - try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let manifest = try sampleManifest(gid: gid, title: "Pause Race") - try JSONEncoder().encode(manifest).write( - to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) - try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - let emptyPageURL = completedFolderURL.appendingPathComponent("pages/0001.jpg") - try Data().write(to: emptyPageURL, options: .atomic) - let goodPageURL = completedFolderURL.appendingPathComponent("pages/0002.jpg") - try Data([0x02]).write(to: goodPageURL, options: .atomic) - - let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - let stored = await manager.testingFetchDownload(gid: gid) - - #expect(pageURLs[1] == nil) - #expect(pageURLs[2] == goodPageURL) - #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) - #expect(stored?.status == .missingFiles) - #expect(stored?.completedPageCount == 1) - } - - @MainActor - @Test - func testImageClientFetchImageUsesStableAliasCacheKey() async throws { - let url = try #require( - URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") - ) - let stableCacheKey = try #require(url.stableImageCacheKey) - let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in - UIColor.systemRed.setFill() - context.fill(.init(x: 0, y: 0, width: 1, height: 1)) - } - let imageData = try #require(image.pngData()) - - try await KingfisherManager.shared.cache.store(image, original: imageData, forKey: stableCacheKey) - defer { - KingfisherManager.shared.cache.removeImage(forKey: stableCacheKey) - KingfisherManager.shared.cache.removeImage(forKey: url.absoluteString) - } - - let result = await ImageClient.live.fetchImage(url: url) - let fetchedImage = try result.get() - - #expect(fetchedImage.size == image.size) - } - - @Test - func testRetryPagesQueuesWorkWhenAnotherDownloadIsActive() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: .shared - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .partial, - completedPageCount: 1, - pageCount: 2 - ) - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL, - withIntermediateDirectories: true - ) - try storage.writeFailedPages( - .init( - pages: [ - .init( - index: 2, - relativePath: "pages/0002.jpg", - failure: .init(code: .networkingFailed, message: "Network Error") - ) - ] - ), - folderURL: temporaryFolderURL - ) - - let blockingTask = Task { - _ = try? await Task.sleep(for: .seconds(60)) - } - defer { blockingTask.cancel() } - await manager.testingInstallActiveTask(gid: "other-active-download", task: blockingTask) - - let result = await manager.retryPages(gid: gid, pageIndices: [2]) - - guard case .success = result else { - Issue.record("Retry pages should succeed, got \(result)") - return - } - - let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.status == .queued) - #expect(stored?.badge == .queued) - #expect(stored?.pendingOperation == nil) - #expect(stored?.lastError == nil) - - let resumeState = try storage.readResumeState(folderURL: temporaryFolderURL) - #expect(resumeState.pageSelection == [2]) - #expect(FileManager.default.fileExists( - atPath: temporaryFolderURL - .appendingPathComponent(Defaults.FilePath.downloadFailedPages) - .path - ) == false) - } - - @Test - func testCancelQueuedRepairRestoresReadableCountAndClearsPendingOperation() async throws { - let container = try makeInMemoryContainer() - - let gid = "cancel-repair-\(UUID().uuidString)" - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: .shared - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .missingFiles, - completedPageCount: 0, - pageCount: 2, - remoteVersionSignature: "hash:v1", - latestRemoteVersionSignature: "hash:v1", - pendingOperation: .repair - ) - - let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) - try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let manifest = try sampleManifest(gid: gid, title: "Pause Race") - try JSONEncoder().encode(manifest).write( - to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) - try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - try Data([0x01]).write( - to: completedFolderURL.appendingPathComponent("pages/0001.jpg"), - options: .atomic - ) - - let result = await manager.togglePause(gid: gid) - guard case .success = result else { - Issue.record("Cancelling queued repair should succeed, got \(result)") - return - } - - let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.status == .missingFiles) - #expect(stored?.completedPageCount == 1) - #expect(stored?.pendingOperation == nil) - } - - @Test - func testRetryPagesUsesMinimalSourceResolutionAndSkipsWhenNoPendingPages() async throws { - let container = try makeInMemoryContainer() - let sessionID = UUID().uuidString - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 200) - let pageIndex = 42 - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [SharedSessionStubURLProtocol.self] - configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: URLSession(configuration: configuration) - ) - let recorder = RequestRecorder() - let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") - let mpvHTML = try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html") - let gidInt = try #require(Int(gid)) - let metadataResponse = try JSONSerialization.data(withJSONObject: [ - "gmetadata": [[ - "gid": gidInt, - "token": "token", - "current_gid": gidInt, - "current_key": "updated-key", - "parent_gid": gidInt, - "parent_key": "token", - "first_gid": gidInt, - "first_key": "token" - ]] - ]) - - SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in - guard let url = request.url else { - throw URLError(.badURL) - } - - if url.host == "api.e-hentai.org" { - recorder.recordMetadata() - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )), - metadataResponse - ) - } - - if url.path.contains("/g/\(gid)/token") { - let pageNumber = URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "p" })? - .value - .flatMap(Int.init) - if let pageNumber { - recorder.recordPreview(pageNumber) - } else { - recorder.recordDetail() - } - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "text/html; charset=utf-8"] - )), - detailHTML - ) - } - - if url.path.contains("/mpv/\(gid)/token") { - recorder.recordMPV() - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "text/html; charset=utf-8"] - )), - mpvHTML - ) - } - - if url.path == "/api.php" { - let method = requestBodyData(from: request) - .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } - if method?["method"] as? String == "gdata" { - recorder.recordMetadata() - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )), - metadataResponse - ) - } - - recorder.recordImageDispatch() - let responseData = try JSONSerialization.data(withJSONObject: [ - "i": "https://example.com/image-\(pageIndex).jpg" - ]) - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )), - responseData - ) - } - - if url.host == "example.com" { - recorder.recordImageDownload() - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "image/jpeg"] - )), - Data([0xFF, 0xD8, 0xFF, 0xD9]) - ) - } - - throw URLError(.unsupportedURL) - } - URLProtocol.registerClass(SharedSessionStubURLProtocol.self) - defer { - SharedSessionStubURLProtocol.removeHandler(for: sessionID) - } - - let scaffoldDownload = sampleDownload( - gid: gid, - title: "Pause Race", - status: .partial, - pageCount: 156, - completedPageCount: 155 - ) - let (payload, versionSignature) = try await manager.testingFetchLatestPayload( - for: scaffoldDownload, - mode: .redownload, - pageSelection: [pageIndex] - ) - recorder.reset() - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let pageCount = payload.galleryDetail.pageCount - let manifest = try sampleManifest( - gid: gid, - title: "Pause Race", - pageCount: pageCount, - versionSignature: versionSignature - ) - func writeTemporaryWorkingSet(missing pageToOmit: Int?) throws { - try? FileManager.default.removeItem(at: temporaryFolderURL) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - try JSONEncoder().encode(manifest).write( - to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) - try Data([0x00]).write( - to: temporaryFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - for index in 1...pageCount where index != pageToOmit { - try Data([UInt8(index % 255)]).write( - to: temporaryFolderURL.appendingPathComponent( - "pages/\(String(format: "%04d", index)).jpg" - ), - options: .atomic - ) - } - try storage.writeResumeState( - .init( - mode: .redownload, - versionSignature: versionSignature, - pageCount: pageCount, - downloadOptions: .init(), - pageSelection: [pageIndex] - ), - folderURL: temporaryFolderURL - ) - } - - try insertPersistedDownload( - in: container, - gid: gid, - status: .partial, - completedPageCount: pageCount - 1, - pageCount: pageCount, - remoteVersionSignature: versionSignature, - latestRemoteVersionSignature: versionSignature - ) - - try writeTemporaryWorkingSet(missing: pageIndex) - await manager.testingProcessDownload(gid: gid) - - let firstRunSnapshot = recorder.snapshot() - #expect(firstRunSnapshot.previewPageNumbers == [1]) - - recorder.reset() - try clearPersistedDownloads(in: container) - try insertPersistedDownload( - in: container, - gid: gid, - status: .partial, - completedPageCount: pageCount, - pageCount: pageCount, - remoteVersionSignature: versionSignature, - latestRemoteVersionSignature: versionSignature - ) - - try writeTemporaryWorkingSet(missing: nil) - await manager.testingProcessDownload(gid: gid) - - let secondRunSnapshot = recorder.snapshot() - #expect(secondRunSnapshot.previewPageNumbers.isEmpty) - #expect(secondRunSnapshot.mpvRequests == 0) - #expect(secondRunSnapshot.imageDispatchRequests == 0) - } - - @Test - func testRetryPagesFallsBackToFullUpdateWhenGalleryHasUpdate() async throws { - let container = try makeInMemoryContainer() - let sessionID = UUID().uuidString - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) - let pageIndex = 42 - let oldVersionSignature = try #require( - DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") - ) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [SharedSessionStubURLProtocol.self] - configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let queueingManager = DownloadManager( - storage: storage, - urlSession: URLSession(configuration: configuration) - ) - let immediateManager = DownloadManager( - storage: storage, - urlSession: URLSession(configuration: configuration) - ) - let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") - let mpvHTML = try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html") - let gidInt = try #require(Int(gid)) - let metadataResponse = try JSONSerialization.data(withJSONObject: [ - "gmetadata": [[ - "gid": gidInt, - "token": "token", - "current_gid": gidInt, - "current_key": "updated-key", - "parent_gid": gidInt, - "parent_key": "token", - "first_gid": gidInt, - "first_key": "token" - ]] - ]) - - SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in - guard let url = request.url else { - throw URLError(.badURL) - } - - if url.path.contains("/g/\(gid)/token") { - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "text/html; charset=utf-8"] - )), - detailHTML - ) - } - - if url.path.contains("/mpv/") { - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "text/html; charset=utf-8"] - )), - mpvHTML - ) - } - - if url.path == "/api.php" { - let body = requestBodyData(from: request) - .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } - let method = body?["method"] as? String - if method == "gdata" { - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )), - metadataResponse - ) - } - - let responseData = try JSONSerialization.data(withJSONObject: [ - "i": "https://example.com/image-\(pageIndex).jpg" - ]) - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )), - responseData - ) - } - - if url.host == "example.com" { - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "image/jpeg"] - )), - Data([0xFF, 0xD8, 0xFF, 0xD9]) - ) - } - - throw URLError(.unsupportedURL) - } - URLProtocol.registerClass(SharedSessionStubURLProtocol.self) - defer { - SharedSessionStubURLProtocol.removeHandler(for: sessionID) - } - - let scaffoldDownload = sampleDownload( - gid: gid, - title: "Pause Race", - status: .partial, - pageCount: 156, - completedPageCount: 155, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: "" - ) - let (payload, updatedVersionSignature) = try await queueingManager.testingFetchLatestPayload( - for: scaffoldDownload, - mode: .update - ) - - let pageCount = payload.galleryDetail.pageCount - #expect(pageCount > pageIndex) - #expect(pageCount > 5) - let oldCount = pageCount - 5 - #expect(oldCount != pageCount) - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - - // Queued update path: retryPages should queue a full update and keep no page-selection state. - try insertPersistedDownload( - in: container, - gid: gid, - status: .partial, - completedPageCount: oldCount - 1, - pageCount: oldCount, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: updatedVersionSignature - ) - - let queuedCandidate = await queueingManager.testingFetchDownload(gid: gid) - #expect(queuedCandidate?.hasUpdate == true) - - let blockerTask = Task { - try? await Task.sleep(nanoseconds: 5_000_000_000) - } - await queueingManager.testingInstallActiveTask(gid: "blocker", task: blockerTask) - defer { blockerTask.cancel() } - - let retryResult = await queueingManager.retryPages(gid: gid, pageIndices: [pageIndex]) - guard case .success = retryResult else { - Issue.record("retryPages should succeed, got \(retryResult)") - return - } - - let queued = await queueingManager.testingFetchDownload(gid: gid) - #expect(queued?.status == .partial) - #expect(queued?.pendingOperation == .update) - #expect(queued?.lastError == nil) - if FileManager.default.fileExists(atPath: temporaryFolderURL.path) { - let queuedResumeState = try storage.readResumeState(folderURL: temporaryFolderURL) - #expect(queuedResumeState.mode == .update) - #expect(queuedResumeState.pageSelection == nil) - #expect(queuedResumeState.pageSelection != [pageIndex]) - } - - try clearPersistedDownloads(in: container) - try? storage.removeTemporaryFolder(gid: gid) - - // Immediate update path: retryPages should normalize the working set to full-update semantics. - let manifest = try sampleManifest( - gid: gid, - title: "Pause Race", - pageCount: pageCount, - versionSignature: updatedVersionSignature - ) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - try JSONEncoder().encode(manifest).write( - to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) - try Data([0x00]).write( - to: temporaryFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - for index in 1...pageCount where index != pageIndex { - try Data([UInt8(index % 255)]).write( - to: temporaryFolderURL.appendingPathComponent( - "pages/\(String(format: "%04d", index)).jpg" - ), - options: .atomic - ) - } - try storage.writeResumeState( - .init( - mode: .update, - versionSignature: updatedVersionSignature, - pageCount: pageCount, - downloadOptions: .init(), - pageSelection: [pageIndex] - ), - folderURL: temporaryFolderURL - ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .partial, - completedPageCount: oldCount - 1, - pageCount: oldCount, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: updatedVersionSignature - ) - - let immediateBlockerTask = Task { - try? await Task.sleep(nanoseconds: 5_000_000_000) - } - await immediateManager.testingInstallActiveTask(gid: gid, task: immediateBlockerTask) - defer { immediateBlockerTask.cancel() } - - let immediateRetryResult = await immediateManager.retryPages(gid: gid, pageIndices: [pageIndex]) - guard case .success = immediateRetryResult else { - Issue.record("Immediate retryPages should succeed, got \(immediateRetryResult)") - return - } - - let resumedState = try storage.readResumeState(folderURL: temporaryFolderURL) - #expect(resumedState.mode == .update) - #expect(resumedState.versionSignature == updatedVersionSignature) - #expect(resumedState.pageCount == pageCount) - #expect(resumedState.pageSelection == nil) - #expect(resumedState.pageSelection != [pageIndex]) - let resumedDownload = await immediateManager.testingFetchDownload(gid: gid) - #expect(resumedDownload?.status == .downloading) - #expect(resumedDownload?.pendingOperation == nil) - #expect(resumedDownload?.lastError == nil) - } - - @Test - func testProcessDownloadClearsStalePageSelectionWhenLatestPayloadRevealsUpdate() async throws { - let container = try makeInMemoryContainer() - let sessionID = UUID().uuidString - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 401) - let pageIndex = 42 - let oldVersionSignature = try #require( - DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") - ) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [SharedSessionStubURLProtocol.self] - configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: URLSession(configuration: configuration) - ) - let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") - let mpvHTML = try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html") - var allowedImageURLs = Set() - let gidInt = try #require(Int(gid)) - let metadataResponse = try JSONSerialization.data(withJSONObject: [ - "gmetadata": [[ - "gid": gidInt, - "token": "token", - "current_gid": gidInt, - "current_key": "updated-key", - "parent_gid": gidInt, - "parent_key": "token", - "first_gid": gidInt, - "first_key": "token" - ]] - ]) - - SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in - guard let url = request.url else { - throw URLError(.badURL) - } - - if url.path.contains("/g/\(gid)/token") { - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "text/html; charset=utf-8"] - )), - detailHTML - ) - } - - if url.path.contains("/mpv/") { - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "text/html; charset=utf-8"] - )), - mpvHTML - ) - } - - if url.path == "/api.php" { - let body = requestBodyData(from: request) - .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } - let method = body?["method"] as? String - if method == "gdata" { - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )), - metadataResponse - ) - } - - let responseData = try JSONSerialization.data(withJSONObject: [ - "i": "https://example.com/image-\(pageIndex).jpg" - ]) - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )), - responseData - ) - } - - if url.host == "example.com" || allowedImageURLs.contains(url.absoluteString) { - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "image/jpeg"] - )), - Data([0xFF, 0xD8, 0xFF, 0xD9]) - ) - } - - throw URLError(.unsupportedURL) - } - URLProtocol.registerClass(SharedSessionStubURLProtocol.self) - defer { - SharedSessionStubURLProtocol.removeHandler(for: sessionID) - } - - let scaffoldDownload = sampleDownload( - gid: gid, - title: "Pause Race", - status: .partial, - pageCount: 156, - completedPageCount: 155, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: oldVersionSignature - ) - let (latestPayload, updatedVersionSignature) = try await manager.testingFetchLatestPayload( - for: scaffoldDownload, - mode: .redownload, - pageSelection: [pageIndex] - ) - if let coverURL = latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL { - allowedImageURLs.insert(coverURL.absoluteString) - } - - let updatedPageCount = latestPayload.galleryDetail.pageCount - #expect(updatedPageCount > pageIndex) - #expect(updatedPageCount > 5) - let oldPageCount = updatedPageCount - 5 - #expect(oldPageCount != updatedPageCount) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .partial, - completedPageCount: oldPageCount - 1, - pageCount: oldPageCount, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: oldVersionSignature - ) - - let beforeProcess = await manager.testingFetchDownload(gid: gid) - #expect(beforeProcess?.hasUpdate ?? true == false) - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let staleManifest = try sampleManifest( - gid: gid, - title: "Pause Race", - pageCount: oldPageCount, - versionSignature: oldVersionSignature - ) - try JSONEncoder().encode(staleManifest).write( - to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) - try Data([0x00]).write( - to: temporaryFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - try Data([UInt8(pageIndex % 255)]).write( - to: temporaryFolderURL.appendingPathComponent( - "pages/\(String(format: "%04d", pageIndex)).jpg" - ), - options: .atomic - ) - try storage.writeResumeState( - .init( - mode: .redownload, - versionSignature: oldVersionSignature, - pageCount: oldPageCount, - downloadOptions: .init(), - pageSelection: [pageIndex] - ), - folderURL: temporaryFolderURL - ) - - await manager.testingProcessDownload(gid: gid) - - let completedDownload = await manager.testingFetchDownload(gid: gid) - let unwrappedCompletedDownload = try #require(completedDownload) - #expect(unwrappedCompletedDownload.status == .completed) - #expect(unwrappedCompletedDownload.pageCount == updatedPageCount) - #expect(unwrappedCompletedDownload.completedPageCount == updatedPageCount) - #expect(unwrappedCompletedDownload.remoteVersionSignature == updatedVersionSignature) - #expect(unwrappedCompletedDownload.latestRemoteVersionSignature == updatedVersionSignature) - - let completedFolderURL = storage.folderURL(relativePath: unwrappedCompletedDownload.folderRelativePath) - let completedManifest = try storage.readManifest(folderURL: completedFolderURL) - #expect(completedManifest.versionSignature == updatedVersionSignature) - #expect(completedManifest.pageCount == updatedPageCount) - #expect(completedManifest.pages.count == updatedPageCount) - #expect( - FileManager.default.fileExists( - atPath: completedFolderURL.appendingPathComponent("pages/0001.jpg").path - ) - ) - - let completedResumeState = try storage.readResumeState(folderURL: completedFolderURL) - #expect(completedResumeState.mode == .redownload) - #expect(completedResumeState.versionSignature == updatedVersionSignature) - #expect(completedResumeState.pageCount == updatedPageCount) - #expect(completedResumeState.pageSelection == nil) - #expect(FileManager.default.fileExists(atPath: temporaryFolderURL.path) == false) - } - - @MainActor - @Test - func testProcessDownloadClearsRemoteAssetCacheAfterSuccessfulDownload() async throws { - let container = try makeInMemoryContainer() - let sessionID = UUID().uuidString - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 402) - let pageIndex = 42 - let oldVersionSignature = try #require( - DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") - ) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [SharedSessionStubURLProtocol.self] - configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: URLSession(configuration: configuration) - ) - let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") - let mpvHTML = try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html") - let currentPageImageURL = try #require( - URL(string: "https://example.com/image-\(pageIndex).jpg") - ) - let staleStoredPageURL = try #require( - URL(string: "https://example.com/stale-image-\(gid)-1.jpg") - ) - let plainPreviewURL = try #require( - URL(string: "https://ehgt.org/preview/\(gid)/1.webp") - ) - let combinedPreviewURL = URLUtil.combinedPreviewURL( - plainURL: plainPreviewURL, - width: "200", - height: "300", - offset: "40" - ) - var allowedImageURLs = Set() - let gidInt = try #require(Int(gid)) - let metadataResponse = try JSONSerialization.data(withJSONObject: [ - "gmetadata": [[ - "gid": gidInt, - "token": "token", - "current_gid": gidInt, - "current_key": "updated-key", - "parent_gid": gidInt, - "parent_key": "token", - "first_gid": gidInt, - "first_key": "token" - ]] - ]) - - SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in - guard let url = request.url else { - throw URLError(.badURL) - } - - if url.path.contains("/g/\(gid)/token") { - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "text/html; charset=utf-8"] - )), - detailHTML - ) - } - - if url.path.contains("/mpv/") { - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "text/html; charset=utf-8"] - )), - mpvHTML - ) - } - - if url.path == "/api.php" { - let body = requestBodyData(from: request) - .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } - let method = body?["method"] as? String - if method == "gdata" { - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )), - metadataResponse - ) - } - - let responseData = try JSONSerialization.data(withJSONObject: [ - "i": currentPageImageURL.absoluteString - ]) - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )), - responseData - ) - } - - if url.host == "example.com" || allowedImageURLs.contains(url.absoluteString) { - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "image/jpeg"] - )), - Data([0xFF, 0xD8, 0xFF, 0xD9]) - ) - } - - throw URLError(.unsupportedURL) - } - URLProtocol.registerClass(SharedSessionStubURLProtocol.self) - defer { - SharedSessionStubURLProtocol.removeHandler(for: sessionID) - } - - let scaffoldDownload = sampleDownload( - gid: gid, - title: "Pause Race", - status: .partial, - pageCount: 156, - completedPageCount: 155, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: oldVersionSignature - ) - let (latestPayload, _) = try await manager.testingFetchLatestPayload( - for: scaffoldDownload, - mode: .redownload, - pageSelection: [pageIndex] - ) - let coverURL = try #require(latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL) - allowedImageURLs.insert(coverURL.absoluteString) - - let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in - UIColor.systemTeal.setFill() - context.fill(.init(x: 0, y: 0, width: 1, height: 1)) - } - let cachedImageData = try #require(cachedImage.jpegData(compressionQuality: 1)) - - let cachedURLs = combinedPreviewURL.previewCacheCleanupURLs() - + [currentPageImageURL, staleStoredPageURL, coverURL] - let cachedKeys = Set(cachedURLs.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) - for cacheKey in cachedKeys { - try await KingfisherManager.shared.cache.storeToDisk(cachedImageData, forKey: cacheKey) - } - defer { - cachedKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } - } - - await waitUntilCacheReady(for: cachedKeys) - - let updatedPageCount = latestPayload.galleryDetail.pageCount - let oldPageCount = updatedPageCount - 5 - #expect(updatedPageCount > pageIndex) - #expect(oldPageCount > 0) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .partial, - completedPageCount: oldPageCount - 1, - pageCount: oldPageCount, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: oldVersionSignature - ) - try insertPersistedGalleryState( - in: container, - gid: gid, - previewURLs: [1: combinedPreviewURL], - imageURLs: [1: staleStoredPageURL] - ) - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let staleManifest = try sampleManifest( - gid: gid, - title: "Pause Race", - pageCount: oldPageCount, - versionSignature: oldVersionSignature - ) - try JSONEncoder().encode(staleManifest).write( - to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) - try Data([0x00]).write( - to: temporaryFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - try Data([UInt8(pageIndex % 255)]).write( - to: temporaryFolderURL.appendingPathComponent( - "pages/\(String(format: "%04d", pageIndex)).jpg" - ), - options: .atomic - ) - try storage.writeResumeState( - .init( - mode: .redownload, - versionSignature: oldVersionSignature, - pageCount: oldPageCount, - downloadOptions: .init(), - pageSelection: [pageIndex] - ), - folderURL: temporaryFolderURL - ) - - await manager.testingProcessDownload(gid: gid) - - let completedDownload = await manager.testingFetchDownload(gid: gid) - #expect(completedDownload?.status == .completed) - - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: .seconds(1)) - while cachedKeys.contains(where: { KingfisherManager.shared.cache.isCached(forKey: $0) }), - clock.now < deadline - { - try? await Task.sleep(for: .milliseconds(10)) - } - - for cacheKey in cachedKeys { - #expect( - KingfisherManager.shared.cache.isCached(forKey: cacheKey) == false, - "Expected cache key to be removed after successful download: \(cacheKey)" - ) - } - } - - @MainActor - @Test - func testDownloadsReducerRefreshesWithoutResumingQueueAfterPauseFailure() async { - let download = sampleDownload( - gid: "987655", - title: "Queued Gallery", - status: .queued, - completedPageCount: 3 - ) - let reconcileCount = UncheckedBox(0) - var initialState = DownloadsReducer.State() - initialState.downloads = [download] - - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [download] }, - fetchDownload: { _ in nil }, - reconcileDownloads: { - reconcileCount.value += 1 - }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .failure(.networkingFailed) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - } - - await store.send(.toggleDownloadPause(download.gid)) - await store.receive(\.toggleDownloadPauseDone) - await store.finish() - - #expect(reconcileCount.value == 1) - } - - @MainActor - @Test - func testDownloadsReducerRefreshDownloadsUsesClientRefresh() async { - let refreshCount = UncheckedBox(0) - let reconcileCount = UncheckedBox(0) - - let store = TestStore(initialState: DownloadsReducer.State()) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - reconcileDownloads: { - reconcileCount.value += 1 - }, - refreshDownloads: { - refreshCount.value += 1 - }, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - } - - await store.send(.refreshDownloads) - await store.receive(\.refreshDownloadsDone) - - #expect(refreshCount.value == 1) - #expect(reconcileCount.value == 0) - } - - @MainActor - @Test - func testDownloadsReducerBootstrapUsesClientRefresh() async { - let refreshCount = UncheckedBox(0) - let reconcileCount = UncheckedBox(0) - - let store = TestStore(initialState: DownloadsReducer.State()) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - reconcileDownloads: { - reconcileCount.value += 1 - }, - refreshDownloads: { - refreshCount.value += 1 - }, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - } - - await store.send(.bootstrapDownloads) - await store.receive(\.refreshDownloadsDone) - - #expect(refreshCount.value == 1) - #expect(reconcileCount.value == 0) - } - - @MainActor - @Test - func testDetailReducerStartDownloadEnqueuesGalleryWithSnapshotOptions() async throws { - let capturedPayload = UncheckedBox(nil) - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let options = DownloadOptionsSnapshot( - threadMode: .quadruple, - allowCellular: false, - autoRetryFailedPages: false - ) - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery - initialState.galleryDetail = detail - initialState.galleryPreviewURLs = [ - 1: try #require(URL(string: "https://example.com/1.jpg")) - ] - initialState.previewConfig = .large(rows: 2) - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, .queued) }) - }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { payload in - capturedPayload.value = payload - return .success(()) - }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.startDownload(options)) - await store.skipReceivedActions(strict: false) - - #expect(capturedPayload.value?.gallery.gid == gallery.gid) - #expect(capturedPayload.value?.galleryDetail == detail) - #expect(capturedPayload.value?.previewConfig == .large(rows: 2)) - #expect(capturedPayload.value?.options == options) - #expect(capturedPayload.value?.mode == .initial) - #expect(store.state.downloadBadge == .queued) - } - - @MainActor - @Test - func testDetailReducerStartDownloadUnlocksActionsAfterQueueing() async throws { - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let options = DownloadOptionsSnapshot() - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery - initialState.galleryDetail = detail - initialState.galleryPreviewURLs = [ - 1: try #require(URL(string: "https://example.com/1.jpg")) - ] - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, .queued) }) - }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.startDownload(options)) { - $0.isPreparingDownload = true - $0.didRunLaunchAutomation = true - } - await store.receive(\.startDownloadDone) { - $0.isPreparingDownload = false - $0.downloadBadge = .queued - $0.hasLoadedDownloadBadge = true - } - await store.receive(\.fetchDownloadBadge) - await store.receive(\.fetchDownloadBadgeDone, .queued) { - $0.downloadBadge = .queued - $0.hasLoadedDownloadBadge = true - } - } - - @MainActor - @Test - func testDetailReducerLaunchAutomationWaitsForResolvedDownloadBadge() async throws { - let capturedPayload = UncheckedBox(nil) - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let options = DownloadOptionsSnapshot() - var initialState = DetailReducer.State() - initialState.gallery = gallery - initialState.galleryDetail = detail - initialState.galleryPreviewURLs = [ - 1: try #require(URL(string: "https://example.com/1.jpg")) - ] - - setenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID", gallery.gid, 1) - defer { unsetenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID") } - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, .queued) }) - }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { payload in - capturedPayload.value = payload - return .success(()) - }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.runLaunchAutomationIfNeeded(options)) - #expect(capturedPayload.value == nil) - #expect(store.state.didRunLaunchAutomation == false) - - await store.send(.fetchDownloadBadgeDone(.none)) { - $0.hasLoadedDownloadBadge = true - } - await store.send(.runLaunchAutomationIfNeeded(options)) { - $0.didRunLaunchAutomation = true - } - await store.receive(\.startDownload, options) - await store.skipReceivedActions(strict: false) - - #expect(capturedPayload.value?.gallery.gid == gallery.gid) - } - - @MainActor - @Test - func testDetailReducerLaunchAutomationDoesNotRedownloadWhenBadgeIsResolved() async { - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let options = DownloadOptionsSnapshot() - var initialState = DetailReducer.State() - initialState.gallery = gallery - initialState.galleryDetail = detail - - setenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID", gallery.gid, 1) - defer { unsetenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID") } - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .noop - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.fetchDownloadBadgeDone(.downloaded)) { - $0.downloadBadge = .downloaded - $0.hasLoadedDownloadBadge = true - } - await store.send(.runLaunchAutomationIfNeeded(options)) { - $0.didRunLaunchAutomation = true - } - } - - @MainActor - @Test - func testDetailReducerIgnoresStartDownloadWhilePreparing() async { - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let enqueueCount = UncheckedBox(0) - let options = DownloadOptionsSnapshot() - - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery - initialState.galleryDetail = detail - initialState.isPreparingDownload = true - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in - enqueueCount.value += 1 - return .success(()) - }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - - await store.send(.startDownload(options)) - - #expect(enqueueCount.value == 0) - #expect(store.state.isPreparingDownload) - #expect(store.state.downloadBadge == .none) - } - - @MainActor - @Test - func testDetailReducerTogglesPauseForActiveDownload() async { - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let togglePauseCount = UncheckedBox(0) - - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery - initialState.galleryDetail = detail - initialState.downloadBadge = .downloading(7, 26) - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, .paused(7, 26)) }) - }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in - togglePauseCount.value += 1 - return .success(()) - }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.toggleDownloadPause) { - $0.isPreparingDownload = true - } - await store.receive(\.toggleDownloadPauseDone) { - $0.isPreparingDownload = false - $0.downloadBadge = .paused(7, 26) - $0.hasLoadedDownloadBadge = true - } - await store.receive(\.fetchDownloadBadge) - await store.receive(\.fetchDownloadBadgeDone, .paused(7, 26)) { - $0.downloadBadge = .paused(7, 26) - $0.hasLoadedDownloadBadge = true - } - - #expect(togglePauseCount.value == 1) - #expect(store.state.downloadBadge == .paused(7, 26)) - #expect(store.state.isPreparingDownload == false) - } - - @MainActor - @Test - func testDetailReducerObservesDownloadBadgeTransitions() async { - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let continuationBox = UncheckedBox.Continuation?>(nil) - let stream = AsyncStream<[DownloadedGallery]> { continuation in - continuationBox.value = continuation - } - - var initialState = DetailReducer.State() - initialState.gallery = gallery - initialState.galleryDetail = detail - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { stream }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) - }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.onAppear(gallery.gid, false)) { - $0.gid = gallery.gid - $0.showsNewDawnGreeting = false - $0.hasLoadedDownloadBadge = false - $0.didRunLaunchAutomation = false - } - await store.skipReceivedActions(strict: false) - - continuationBox.value?.yield([ - sampleDownload(gid: gallery.gid, title: gallery.title, status: .queued) - ]) - await store.receive(\.observeDownloadDone) { - $0.downloadBadge = .queued - $0.hasLoadedDownloadBadge = true - } - - continuationBox.value?.yield([ - sampleDownload( - gid: gallery.gid, - title: gallery.title, - status: .downloading, - pageCount: 26, - completedPageCount: 7 - ) - ]) - await store.receive(\.observeDownloadDone) { - $0.downloadBadge = .downloading(7, 26) - $0.hasLoadedDownloadBadge = true - } - - continuationBox.value?.yield([ - sampleDownload( - gid: gallery.gid, - title: gallery.title, - status: .completed, - pageCount: 26, - completedPageCount: 26 - ) - ]) - await store.receive(\.observeDownloadDone) { - $0.downloadBadge = .downloaded - $0.hasLoadedDownloadBadge = true - } - - continuationBox.value?.finish() - } - - @MainActor - @Test - func testDetailReducerOpenReadingUsesLocalManifestWhenAvailable() async throws { - let download = sampleDownload( - gid: "888", - title: "Offline Archive", - status: .completed, - pageCount: 2 - ) - let manifest = try sampleManifest(gid: download.gid, title: download.title) - var initialState = DetailReducer.State(download: download) - initialState.galleryDetail = sampleGalleryDetail(gid: download.gid, title: download.title) - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [download] }, - fetchDownload: { gid in gid == download.gid ? download : nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, .downloaded) }) - }, - updateRemoteSignature: { _, _ in .downloaded }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { gid in - gid == download.gid - ? .success((download, manifest)) - : .failure(.notFound) - } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.openReading) - await store.skipReceivedActions(strict: false) - - #expect(store.state.readingState.contentSource == .local(download, manifest)) - if case .reading = store.state.route { - } else { - Issue.record("Expected reading route to be active.") - } - } - - @MainActor - @Test - func testDetailReducerOpenReadingFallsBackToRemoteWhenManifestUnavailable() async { - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery - initialState.galleryDetail = detail - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.openReading) - await store.skipReceivedActions(strict: false) - - #expect(store.state.readingState.contentSource == .remote) - if case .reading = store.state.route { - } else { - Issue.record("Expected reading route to be active.") - } - } - - @MainActor - @Test - func testPreviewsReducerOpenReadingUsesLocalManifestWhenAvailable() async throws { - let download = sampleDownload( - gid: "991", - title: "Preview Download", - status: .completed, - pageCount: 2, - completedPageCount: 2 - ) - let manifest = try sampleManifest(gid: download.gid, title: download.title) - var initialState = PreviewsReducer.State() - initialState.gallery = download.gallery - - let store = TestStore(initialState: initialState) { - PreviewsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [download] }, - fetchDownload: { gid in gid == download.gid ? download : nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { gid in - gid == download.gid - ? .success((download, manifest)) - : .failure(.notFound) - } - ) - $0.databaseClient = .noop - $0.hapticsClient = .noop - } - store.exhaustivity = .off - - await store.send(.openReading(1)) - await store.skipReceivedActions(strict: false) - - if case .local(let actualDownload, let actualManifest) = store.state.readingState.contentSource { - #expect(actualDownload == download) - #expect(actualManifest == manifest) - } else { - Issue.record("Expected previews to open local reading content.") - } - if case .reading = store.state.route { - } else { - Issue.record("Expected reading route to be active.") - } - } - - @MainActor - @Test - func testPreviewsReducerClearsLocalPreviewURLsWhenObservedDownloadDisappears() async { - let gallery = sampleGallery() - let localURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") - var initialState = PreviewsReducer.State() - initialState.gallery = gallery - initialState.localPreviewURLs = [1: localURL] - - let store = TestStore(initialState: initialState) { - PreviewsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in .success([:]) } - ) - $0.databaseClient = .noop - $0.hapticsClient = .noop - } - store.exhaustivity = .off - - await store.send(.observeDownloadsDone([])) - await store.receive(\.loadLocalPreviewURLs) - let requestID = store.state.localPreviewRequestID - await store.receive(\.loadLocalPreviewURLsDone) { - $0.localPreviewURLs = [:] - } - #expect(store.state.localPreviewRequestID == requestID) - } - - @MainActor - @Test - func testPreviewsReducerRemoteFallbackKeepsExistingLocalPreviewPages() async { - let gallery = sampleGallery() - let localURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") - var initialState = PreviewsReducer.State() - initialState.gallery = gallery - initialState.localPreviewURLs = [1: localURL] - - let store = TestStore(initialState: initialState) { - PreviewsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.databaseClient = .noop - $0.hapticsClient = .noop - } - store.exhaustivity = .off - - await store.send(.openReading(1)) - await store.receive(\.openReadingDone) - guard case .reading = store.state.route else { - Issue.record("Expected previews route to enter reading") - return - } - #expect(store.state.readingState.contentSource == .remote) - #expect(store.state.readingState.localPageURLs == [1: localURL]) - } - - @MainActor - @Test - func testDetailReducerDownloadedContextStoresVersionMetadataResult() async { - let download = sampleDownload( - gid: "889", - title: "Offline Archive", - status: .completed, - pageCount: 2 - ) - let detail = sampleGalleryDetail(gid: download.gid, title: download.title) - var initialState = DetailReducer.State(download: download) - initialState.galleryDetail = detail - let metadata = DownloadVersionMetadata( - gid: detail.gid, - token: download.token, - currentGID: "990", - currentKey: "chain-key", - parentGID: download.gid, - parentKey: download.token, - firstGID: download.gid, - firstKey: download.token - ) - - let store = TestStore(initialState: initialState) { - DetailReducer() - } - - await store.send( - .fetchVersionMetadataDone(.success(metadata)) - ) { - $0.galleryVersionMetadata = metadata - } - } - - @MainActor - @Test - func testReadingReducerRemoteSourceLoadsLocalPagesAndSkipsRemoteFetchForDownloadedPage() async throws { - let gallery = sampleGallery() - let localPageURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") - let remotePageURL = try #require(URL(string: "https://example.com/pages/0001.jpg")) - var initialState = ReadingReducer.State(contentSource: .remote) - initialState.gallery = gallery - initialState.imageURLs = [1: remotePageURL] - - let store = TestStore( - initialState: initialState - ) { - ReadingReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.clipboardClient = .noop - $0.cookieClient = .noop - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.yield([]) - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { gid in - gid == gallery.gid ? .success([1: localPageURL]) : .failure(.notFound) - } - ) - $0.hapticsClient = .noop - $0.imageClient = .noop - $0.urlClient = .noop - } - store.exhaustivity = .off - - await store.send(.loadLocalPageURLs(gallery.gid)) - let requestID = store.state.localPageRequestID - await store.receive(\.loadLocalPageURLsDone) { - $0.localPageURLs = [1: localPageURL] - } - #expect(store.state.localPageRequestID == requestID) - - #expect(store.state.localPageURLs[1] == localPageURL) - - await store.send(.fetchImageURLs(1)) { - $0.imageURLLoadingStates[1] = .idle - } - } - - @MainActor - @Test - func testReadingReducerOnWebImageSucceededCapturesCachedPageIntoDownloadProgress() async throws { - let capturedCalls = UncheckedBox([(String, Int, URL?)]()) - let gallery = sampleGallery() - let remotePageURL = try #require(URL(string: "https://example.com/pages/0001.jpg")) - var initialState = ReadingReducer.State(contentSource: .remote) - initialState.gallery = gallery - initialState.imageURLs = [1: remotePageURL] - - let store = TestStore(initialState: initialState) { - ReadingReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.clipboardClient = .noop - $0.cookieClient = .noop - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - captureCachedPage: { gid, index, imageURL in - capturedCalls.value.append((gid, index, imageURL)) - } - ) - $0.hapticsClient = .noop - $0.imageClient = .noop - $0.urlClient = .noop - } - store.exhaustivity = .off - - await store.send(.onWebImageSucceeded(1)) { - $0.imageURLLoadingStates[1] = .idle - $0.webImageLoadSuccessIndices.insert(1) - } - await store.receive(\.captureCachedPage) - - #expect(capturedCalls.value.count == 1) - #expect(capturedCalls.value.first?.0 == gallery.gid) - #expect(capturedCalls.value.first?.1 == 1) - #expect(capturedCalls.value.first?.2 == remotePageURL) - } - - @MainActor - @Test - func testReadingReducerOnWebImageSucceededDoesNotCaptureAlreadyLocalPage() async { - let capturedCalls = UncheckedBox([(String, Int, URL?)]()) - let gallery = sampleGallery() - let localPageURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathComponent("0001.jpg") - var initialState = ReadingReducer.State(contentSource: .remote) - initialState.gallery = gallery - initialState.localPageURLs = [1: localPageURL] - - let store = TestStore(initialState: initialState) { - ReadingReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.clipboardClient = .noop - $0.cookieClient = .noop - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - captureCachedPage: { gid, index, imageURL in - capturedCalls.value.append((gid, index, imageURL)) - } - ) - $0.hapticsClient = .noop - $0.imageClient = .noop - $0.urlClient = .noop - } - store.exhaustivity = .off - - await store.send(.onWebImageSucceeded(1)) { - $0.imageURLLoadingStates[1] = .idle - $0.webImageLoadSuccessIndices.insert(1) - } - await store.finish() - - #expect(capturedCalls.value.isEmpty) - } - - @MainActor - @Test - func testReadingReducerLocalSourceLoadsOfflineImagesWithoutNetwork() async throws { - let download = sampleDownload( - gid: "777", - title: "Offline Archive", - status: .completed, - pageCount: 2 - ) - let manifest = try sampleManifest(gid: download.gid, title: download.title) - let folderURL = try prepareLocalDownloadFiles(download: download, manifest: manifest) - defer { try? FileManager.default.removeItem(at: folderURL) } - - let store = TestStore( - initialState: ReadingReducer.State(contentSource: .local(download, manifest)) - ) { - ReadingReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.clipboardClient = .noop - $0.cookieClient = .noop - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.hapticsClient = .noop - $0.imageClient = .noop - $0.urlClient = .noop - } - store.exhaustivity = .off - - await store.send(.fetchDatabaseInfos(download.gid)) - #expect(store.state.gallery.id == download.gid) - #expect(store.state.imageURLs[1] == folderURL.appendingPathComponent("pages/0001.jpg")) - #expect(store.state.imageURLs[2] == folderURL.appendingPathComponent("pages/0002.jpg")) - - await store.send(.fetchImageURLs(1)) { - $0.imageURLLoadingStates[1] = .idle - } - await store.send(.reloadAllWebImages) - - #expect(store.state.imageURLs[1] == folderURL.appendingPathComponent("pages/0001.jpg")) - #expect(store.state.imageURLs[2] == folderURL.appendingPathComponent("pages/0002.jpg")) - } - - @MainActor - @Test - func testDownloadManagerCaptureCachedPageRestoresTemporaryPageAndUpdatesCompletedCount() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 27) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: .shared - ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .downloading, - completedPageCount: 0, - pageCount: 2 - ) - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - - let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg")) - let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in - UIColor.systemBlue.setFill() - context.fill(.init(x: 0, y: 0, width: 1, height: 1)) - } - let imageData = try #require(image.jpegData(compressionQuality: 1)) - let cacheKey = try #require(imageURL.stableImageCacheKey) - try await KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) - defer { - KingfisherManager.shared.cache.removeImage(forKey: cacheKey) - KingfisherManager.shared.cache.removeImage(forKey: imageURL.absoluteString) - } - - await manager.captureCachedPage( - gid: gid, - index: 1, - imageURL: imageURL - ) - - let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.completedPageCount == 1) - - let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - #expect(pageURLs[1] == temporaryFolderURL.appendingPathComponent("pages/0001.jpg")) - } - - @MainActor - @Test - func testDownloadManagerCaptureCachedPageRepairsCompletedDownloadWithLatestRemoteImage() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 28) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: .shared - ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .missingFiles, - completedPageCount: 1, - pageCount: 2, - lastError: .init(code: .fileOperationFailed, message: "Page 1 is missing.") - ) - - let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) - try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let manifest = try sampleManifest(gid: gid, title: "Pause Race") - try JSONEncoder().encode(manifest).write( - to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) - try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - try Data([0x02]).write( - to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), - options: .atomic - ) - - let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg")) - let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in - UIColor.systemOrange.setFill() - context.fill(.init(x: 0, y: 0, width: 1, height: 1)) - } - let imageData = try #require(image.jpegData(compressionQuality: 1)) - let cacheKey = try #require(imageURL.stableImageCacheKey) - try await KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) - defer { - KingfisherManager.shared.cache.removeImage(forKey: cacheKey) - KingfisherManager.shared.cache.removeImage(forKey: imageURL.absoluteString) - } - - await manager.captureCachedPage( - gid: gid, - index: 1, - imageURL: imageURL - ) - - let stored = await manager.testingFetchDownload(gid: gid) - let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - - #expect(stored?.status == .completed) - #expect(stored?.completedPageCount == 2) - #expect(stored?.lastError == nil) - #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("pages/0001.jpg")) - } - - @MainActor - @Test - func testDownloadManagerReconcileNormalizesFailedDownloadBeforeTempCleanup() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 31) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) - try insertPersistedDownload( - in: container, - gid: gid, - status: .failed, - completedPageCount: 0, - pageCount: 2, - lastError: .init(code: .networkingFailed, message: "Network Error") - ) - - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - try Data([0x01]).write( - to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), - options: .atomic - ) - - await manager.reconcileDownloads() - - let stored = await manager.testingFetchDownload(gid: gid) - let localPages = try await manager.loadLocalPageURLs(gid: gid).get() - - #expect(stored?.status == .partial) - #expect(stored?.completedPageCount == 1) - #expect(FileManager.default.fileExists(atPath: temporaryFolderURL.path)) - #expect(localPages[1] == temporaryFolderURL.appendingPathComponent("pages/0001.jpg")) - } - - @MainActor - @Test - func testUpdateRemoteSignatureSkipsUpdateWhenStoredChainAndLatestHashDiffer() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 101) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared - ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .completed, - completedPageCount: 26, - token: "token", - remoteVersionSignature: "chain:\(gid):token" - ) - - let badge = await manager.updateRemoteSignature(gid: gid, latestSignature: "hash:new") - let stored = await manager.testingFetchDownload(gid: gid) - - #expect(badge == .downloaded) - #expect(stored?.status == .completed) - #expect(stored?.remoteVersionSignature == "chain:\(gid):token") - #expect(stored?.latestRemoteVersionSignature == "hash:new") - } - - @MainActor - @Test - func testUpdateRemoteSignatureSkipsUpdateWhenStoredHashAndLatestNonOriginalChainDiffer() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 102) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared - ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .completed, - completedPageCount: 26, - token: "token", - remoteVersionSignature: "hash:old" - ) - - let badge = await manager.updateRemoteSignature( - gid: gid, - latestSignature: "chain:othergid:othertoken" - ) - let stored = await manager.testingFetchDownload(gid: gid) - - #expect(badge == .downloaded) - #expect(stored?.status == .completed) - #expect(stored?.remoteVersionSignature == "hash:old") - #expect(stored?.latestRemoteVersionSignature == "chain:othergid:othertoken") - } - - @MainActor - @Test - func testUpdateRemoteSignatureCanonicalizesStoredHashToOriginalChainWithoutMarkingUpdate() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 103) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared - ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .completed, - completedPageCount: 26, - token: "token", - remoteVersionSignature: "hash:old" - ) - - let badge = await manager.updateRemoteSignature( - gid: gid, - latestSignature: "chain:\(gid):token" - ) - let stored = await manager.testingFetchDownload(gid: gid) - - #expect(badge == .downloaded) - #expect(stored?.status == .completed) - #expect(stored?.remoteVersionSignature == "chain:\(gid):token") - #expect(stored?.latestRemoteVersionSignature == "chain:\(gid):token") - } - - @MainActor - @Test - func testDetailReducerDoesNotRequestVersionMetadataForUndownloadedGallery() async throws { - let updateCheckCount = UncheckedBox(0) - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - var galleryState = GalleryState(gid: gallery.gid) - galleryState.previewURLs = [1: try #require(URL(string: "https://example.com/1t.jpg"))] - galleryState.previewConfig = .normal(rows: 4) - - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) - }, - updateRemoteSignature: { _, _ in - updateCheckCount.value += 1 - return .none - }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send( - .fetchGalleryDetailDone( - .success((detail, galleryState, "", nil)) - ) - ) - await store.skipReceivedActions(strict: false) - - #expect(updateCheckCount.value == 0) - #expect(store.state.galleryVersionMetadata == nil) - #expect(store.state.shouldCheckForRemoteUpdates == false) - } - - @MainActor - @Test - func testDetailReducerRequestsVersionMetadataWhenBadgeArrivesAfterDetail() async throws { - let updateCheckCount = UncheckedBox(0) - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let galleryState = try sampleGalleryState(gid: gallery.gid) - let sessionID = UUID().uuidString - try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) - defer { uninstallSharedSessionStub(sessionID: sessionID) } - - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) - }, - updateRemoteSignature: { _, _ in - updateCheckCount.value += 1 - return .downloaded - }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in .success([:]) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.fetchGalleryDetailDone(.success((detail, galleryState, "", nil)))) - await store.skipReceivedActions(strict: false) - #expect(updateCheckCount.value == 0) - - await store.send(.fetchDownloadBadgeDone(.downloaded)) - await drainDetailMetadataEffects( - store, - condition: { - updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil - } - ) - - #expect(updateCheckCount.value == 1) - #expect(store.state.shouldCheckForRemoteUpdates) - #expect(store.state.didRequestVersionMetadata) - #expect(store.state.galleryVersionMetadata != nil) - } - - @MainActor - @Test - func testDetailReducerRequestsVersionMetadataWhenBadgeArrivesBeforeDetail() async throws { - let updateCheckCount = UncheckedBox(0) - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let galleryState = try sampleGalleryState(gid: gallery.gid) - let sessionID = UUID().uuidString - try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) - defer { uninstallSharedSessionStub(sessionID: sessionID) } - - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, .downloaded) }) - }, - updateRemoteSignature: { _, _ in - updateCheckCount.value += 1 - return .downloaded - }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in .success([:]) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.fetchDownloadBadgeDone(.downloaded)) - await store.skipReceivedActions(strict: false) - #expect(updateCheckCount.value == 0) - - await store.send(.fetchGalleryDetailDone(.success((detail, galleryState, "", nil)))) - await drainDetailMetadataEffects( - store, - condition: { - updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil - } - ) - - #expect(updateCheckCount.value == 1) - #expect(store.state.shouldCheckForRemoteUpdates) - #expect(store.state.didRequestVersionMetadata) - #expect(store.state.galleryVersionMetadata != nil) - } - - @MainActor - @Test - func testDetailReducerObserveDownloadDoneAlsoTriggersMetadataCheckWithoutDuplicateRequests() async throws { - let updateCheckCount = UncheckedBox(0) - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let sessionID = UUID().uuidString - try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) - defer { uninstallSharedSessionStub(sessionID: sessionID) } - - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery - initialState.galleryDetail = detail - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in - updateCheckCount.value += 1 - return .downloaded - }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in .success([:]) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.observeDownloadDone(.downloaded)) - await drainDetailMetadataEffects( - store, - condition: { updateCheckCount.value == 1 } - ) - #expect(updateCheckCount.value == 1) - - await store.send(.observeDownloadDone(.downloaded)) - await store.skipReceivedActions(strict: false) - #expect(updateCheckCount.value == 1) - } - - @MainActor - @Test - func testDetailReducerRemoteUpdateFlagDoesNotStayStickyWhenBadgeReturnsToNone() async throws { - let updateCheckCount = UncheckedBox(0) - let gallery = sampleGallery() - let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let sessionID = UUID().uuidString - try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) - defer { uninstallSharedSessionStub(sessionID: sessionID) } - - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery - initialState.galleryDetail = detail - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in - updateCheckCount.value += 1 - return .downloaded - }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in .success([:]) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.fetchDownloadBadgeDone(.downloaded)) - await drainDetailMetadataEffects( - store, - condition: { - updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil - } - ) - #expect(updateCheckCount.value == 1) - #expect(store.state.shouldCheckForRemoteUpdates) - #expect(store.state.didRequestVersionMetadata) - - await store.send(.fetchDownloadBadgeDone(.none)) { - $0.downloadBadge = .none - $0.hasLoadedDownloadBadge = true - $0.shouldCheckForRemoteUpdates = false - $0.didRequestVersionMetadata = false - $0.galleryVersionMetadata = nil - } - await store.skipReceivedActions(strict: false) - - #expect(store.state.shouldCheckForRemoteUpdates == false) - #expect(store.state.didRequestVersionMetadata == false) - #expect(store.state.galleryVersionMetadata == nil) - } - - @MainActor - @Test - func testDetailReducerDeleteDownloadResetsDownloadContext() async { - let download = sampleDownload( - gid: "7733", - title: "Reset Context", - status: .completed - ) - var initialState = DetailReducer.State(download: download) - initialState.galleryVersionMetadata = sampleVersionMetadata( - gid: download.gid, - token: download.token - ) - initialState.didRequestVersionMetadata = true - - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [download] }, - fetchDownload: { gid in gid == download.gid ? download : nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) - }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in .success([:]) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } - store.exhaustivity = .off - - await store.send(.deleteDownloadDone(.success(()))) { - $0.galleryVersionMetadata = nil - $0.didRequestVersionMetadata = false - $0.isDownloadContext = false - $0.shouldCheckForRemoteUpdates = false - } - await store.skipReceivedActions(strict: false) - - #expect(store.state.isDownloadContext == false) - #expect(store.state.shouldCheckForRemoteUpdates == false) - #expect(store.state.didRequestVersionMetadata == false) - #expect(store.state.galleryVersionMetadata == nil) - } - - @Test - func testFileBasedQuotaImageMapsToQuotaExceeded() async throws { - let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) - defer { try? FileManager.default.removeItem(at: fileURL) } - - let manager = makeTestingDownloadManager() - let quotaImageURL = try #require(URL(string: "https://ehgt.org/g/509.gif")) - let response = try makeResponse( - url: quotaImageURL, - contentType: "image/gif", - contentLength: 28658 - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: response, - requestURL: quotaImageURL - ) - - #expect(error == .quotaExceeded) - } - - @Test - func testFileBasedQuotaImageRequiresKnown509Signature() async throws { - let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) - defer { try? FileManager.default.removeItem(at: fileURL) } - - let manager = makeTestingDownloadManager() - var data = try Data(contentsOf: fileURL) - data[0] = 0 - try data.write(to: fileURL, options: .atomic) - let quotaImageURL = try #require(URL(string: "https://ehgt.org/g/509.gif")) - let response = try makeResponse( - url: quotaImageURL, - contentType: "image/gif", - contentLength: data.count - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: response, - requestURL: quotaImageURL - ) - - #expect(error == nil) - } - - @Test - func testFileBasedBinaryKokomadeImageMapsToAuthenticationRequired() async throws { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("gif") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let imageData = try #require(Data(base64Encoded: "R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")) - try imageData.write(to: fileURL, options: .atomic) - - let manager = makeTestingDownloadManager() - let kokomadeURL = try #require(URL(string: "https://exhentai.org/img/kokomade.jpg")) - let response = try makeResponse( - url: kokomadeURL, - contentType: "image/gif", - contentLength: imageData.count - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: response, - requestURL: URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1") - ) - - #expect(error == .authenticationRequired) - } - - @Test - func testFileBasedQuotaImageFingerprintMapsToQuotaExceededEvenWhenURLLooksNormal() async throws { - let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) - defer { try? FileManager.default.removeItem(at: fileURL) } - - let manager = makeTestingDownloadManager() - let normalImageURL = try #require(URL(string: "https://ehgt.org/h/normal-image-cache-key/1")) - let response = try makeResponse( - url: normalImageURL, - contentType: "image/gif", - contentLength: 28658 - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: response, - requestURL: normalImageURL - ) - - #expect(error == .quotaExceeded) - } - - @Test - func testFileBasedKokomadeImageFingerprintMapsToAuthenticationRequiredEvenWhenURLLooksNormal() async throws { - let fileURL = try writeFixtureToTemporaryFile(resource: "Kokomade", pathExtension: "jpg") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let manager = makeTestingDownloadManager() - let normalImageURL = try #require( - URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1&key=normal-cache-key") - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: try makeResponse( - url: normalImageURL, - contentType: "image/jpeg", - contentLength: 144844 - ), - requestURL: normalImageURL - ) - - #expect(error == .authenticationRequired) - } - - @Test - func testFileBasedTextImageLimitMapsToQuotaExceeded() async throws { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("html") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let htmlData = Data(""" - You have exceeded your image viewing limits - """.utf8) - try htmlData.write(to: fileURL, options: .atomic) - - let manager = makeTestingDownloadManager() - let quotaURL = try #require(URL(string: "https://e-hentai.org/s/1/1-1")) - let response = try makeResponse( - url: quotaURL, - contentType: "text/html" - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: response, - requestURL: quotaURL - ) - - #expect(error == .quotaExceeded) - } - - @MainActor - @Test - func testCachedQuotaPlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 32) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) - let normalImageURL = try #require( - URL(string: "https://ehgt.org/h/quota-placeholder-cache-\(gid)/1") - ) - try insertPersistedGalleryState(in: container, gid: gid, imageURLs: [1: normalImageURL]) - - let placeholderURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) - defer { try? FileManager.default.removeItem(at: placeholderURL) } - let placeholderData = try Data(contentsOf: placeholderURL) - let cacheKeys = normalImageURL.imageCacheKeys(includeStableAlias: true) - for cacheKey in cacheKeys { - try await KingfisherManager.shared.cache.storeToDisk(placeholderData, forKey: cacheKey) - } - defer { - cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } - } - await waitUntilCacheReady(for: cacheKeys) - - let payload = DownloadRequestPayload( - gallery: Gallery( - gid: gid, - token: "token", - title: "Quota Placeholder", - rating: 4, - tags: [], - category: .doujinshi, - uploader: "Uploader", - pageCount: 1, - postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token") as URL?) - ), - galleryDetail: GalleryDetail( - gid: gid, - title: "Quota Placeholder", - jpnTitle: nil, - isFavorited: false, - visibility: .yes, - rating: 4, - userRating: 0, - ratingCount: 0, - category: .doujinshi, - language: .japanese, - uploader: "Uploader", - postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - favoritedCount: 0, - pageCount: 1, - sizeCount: 12, - sizeType: "MB", - torrentCount: 0 - ), - previewURLs: [:], - previewConfig: .normal(rows: 4), - host: .ehentai, - options: DownloadOptionsSnapshot(), - mode: .initial - ) - - let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) - let restoredPageURL = storage.temporaryFolderURL(gid: gid) - .appendingPathComponent("pages/0001.gif") - - #expect(restoredCount == 0) - #expect(FileManager.default.fileExists(atPath: restoredPageURL.path) == false) - } - - @MainActor - @Test - func testCachedKokomadePlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 33) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) - let normalImageURL = try #require( - URL(string: "https://exhentai.org/fullimg.php?gid=\(gid)&page=1&key=normal-cache-key") - ) - try insertPersistedGalleryState(in: container, gid: gid, imageURLs: [1: normalImageURL]) - - let imageData = try fixtureData(resource: "Kokomade", pathExtension: "jpg") - let cacheKeys = normalImageURL.imageCacheKeys(includeStableAlias: true) - for cacheKey in cacheKeys { - try await KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) - } - defer { - cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } - } - await waitUntilCacheReady(for: cacheKeys) - - let payload = DownloadRequestPayload( - gallery: Gallery( - gid: gid, - token: "token", - title: "Auth Placeholder", - rating: 4, - tags: [], - category: .doujinshi, - uploader: "Uploader", - pageCount: 1, - postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: try #require(URL(string: "https://exhentai.org/g/\(gid)/token") as URL?) - ), - galleryDetail: GalleryDetail( - gid: gid, - title: "Auth Placeholder", - jpnTitle: nil, - isFavorited: false, - visibility: .yes, - rating: 4, - userRating: 0, - ratingCount: 0, - category: .doujinshi, - language: .japanese, - uploader: "Uploader", - postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - favoritedCount: 0, - pageCount: 1, - sizeCount: 12, - sizeType: "MB", - torrentCount: 0 - ), - previewURLs: [:], - previewConfig: .normal(rows: 4), - host: .exhentai, - options: DownloadOptionsSnapshot(), - mode: .initial - ) - - let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) - let restoredPageURL = storage.temporaryFolderURL(gid: gid) - .appendingPathComponent("pages/0001.jpg") - - #expect(restoredCount == 0) - #expect(FileManager.default.fileExists(atPath: restoredPageURL.path) == false) - } - - @Test - func testFileBasedEmptyExResponseMapsToAuthenticationRequired() async throws { - let fileURL = try writeFixtureToTemporaryFile(filename: .exLoginRequired) - defer { try? FileManager.default.removeItem(at: fileURL) } - - let cookieClient = CookieClient.live - cookieClient.clearAll() - defer { cookieClient.clearAll() } - cookieClient.setOrEditCookie( - for: Defaults.URL.exhentai, - key: Defaults.Cookie.yay, - value: "louder" - ) - - let manager = makeTestingDownloadManager() - let response = try makeResponse( - url: Defaults.URL.exhentai, - contentType: "text/html" - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: response, - requestURL: URL(string: "https://exhentai.org/g/1/1/") - ) - - #expect(error == .authenticationRequired) - } - - @Test - func testFileBasedAuthHTMLMarkersMapToAuthenticationRequired() async throws { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("html") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let authHTMLData = Data(""" - - - Login - -

Access to ExHentai.org is restricted.

- - - """.utf8) - try authHTMLData.write(to: fileURL, options: .atomic) - - let manager = makeTestingDownloadManager() - let response = try makeResponse( - url: Defaults.URL.exhentai, - contentType: "text/html" - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: response, - requestURL: URL(string: "https://exhentai.org/g/1/1/") - ) - - #expect(error == .authenticationRequired) - } - - @Test - func testFileBasedInvalidPageMapsToNotFound() async throws { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("html") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let invalidPageData = Data(""" -

Invalid page

Gallery not found

- """.utf8) - try invalidPageData.write(to: fileURL, options: .atomic) - - let manager = makeTestingDownloadManager() - let galleryURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) - let response = try makeResponse( - url: galleryURL, - contentType: "text/html" - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: response, - requestURL: galleryURL - ) - - #expect(error == .notFound) - } - - @Test - func testFileBasedKeepTryingMapsToNotFound() async throws { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("html") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let keepTryingData = Data( - "

Keep trying

".utf8 - ) - try keepTryingData.write(to: fileURL, options: .atomic) - - let manager = makeTestingDownloadManager() - let pageURL = try #require(URL(string: "https://e-hentai.org/s/1/1-1")) - let response = try makeResponse( - url: pageURL, - contentType: "text/html" - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: response, - requestURL: pageURL - ) - - #expect(error == .notFound) - } - - @Test - func testFileBasedHTTP404MapsToNotFound() async throws { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("txt") - defer { try? FileManager.default.removeItem(at: fileURL) } - - try Data("Not here".utf8).write(to: fileURL, options: .atomic) - - let manager = makeTestingDownloadManager() - let notFoundURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) - let response = try makeResponse( - url: notFoundURL, - statusCode: 404, - contentType: "text/html" - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: response, - requestURL: notFoundURL - ) - - #expect(error == .notFound) - } - - @Test - func testFileBased404GalleryNotAvailableFallsBackToNotFound() async throws { - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("html") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let galleryNotAvailableData = Data(""" - - Gallery Not Available -

Gallery Not Available

- - """.utf8) - try galleryNotAvailableData.write(to: fileURL, options: .atomic) - - let manager = makeTestingDownloadManager() - let galleryURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) - let response = try makeResponse( - url: galleryURL, - statusCode: 404, - contentType: "text/html" - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: response, - requestURL: galleryURL - ) - - #expect(error == .notFound) - } - - @Test - func testFileBasedHTMLBanPageStillParsesThroughParserInsteadOfParseFailed() async throws { - let fileURL = try writeFixtureToTemporaryFile(filename: .ipBanned) - defer { try? FileManager.default.removeItem(at: fileURL) } - - let manager = makeTestingDownloadManager() - let bannedURL = try #require(URL(string: "https://example.com/banned")) - let response = try makeResponse( - url: bannedURL, - contentType: "text/html; charset=utf-8" - ) - let error = await manager.testingDetectResponseError( - fileURL: fileURL, - response: response, - requestURL: bannedURL - ) - - #expect(error != .parseFailed) - guard case .ipBanned = error else { - Issue.record("Expected ipBanned, got \(String(describing: error))") - return - } - } - - @Test - func testIpBannedDoesNotRetryImmediately() async throws { - let sessionID = UUID().uuidString - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [SharedSessionStubURLProtocol.self] - configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] - let manager = DownloadManager( - storage: DownloadFileStorage( - rootURL: FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true), - fileManager: .default - ), - urlSession: URLSession(configuration: configuration) - ) - let recorder = RequestRecorder() - let ipBannedHTML = try fixtureData(resource: HTMLFilename.ipBanned.rawValue, pathExtension: "html") - let fallbackBannedURL = try #require(URL(string: "https://example.com/banned")) - SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in - recorder.recordDetail() - return ( - try #require(HTTPURLResponse( - url: request.url ?? fallbackBannedURL, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "text/html; charset=utf-8"] - )), - ipBannedHTML - ) - } - defer { - SharedSessionStubURLProtocol.removeHandler(for: sessionID) - } - - let download = sampleDownload( - gid: "123456", - title: "Banned Gallery", - status: .partial - ) - - do { - _ = try await manager.testingFetchLatestPayload( - for: download, - mode: .redownload - ) - Issue.record("Expected ipBanned error") - } catch let error as AppError { - guard case .ipBanned = error else { - Issue.record("Expected ipBanned, got \(error)") - return - } - } - - #expect(recorder.snapshot().detailRequests == 1) - } - - @MainActor - @Test - func testReadingReducerLocalSourceWithoutGalleryStateDoesNotStayLoading() async throws { - let download = sampleDownload( - gid: "700001", - title: "Offline Gallery", - status: .completed, - pageCount: 2, - completedPageCount: 2 - ) - let manifest = try sampleManifest(gid: download.gid, title: download.title) - let store = TestStore( - initialState: ReadingReducer.State(contentSource: .local(download, manifest)) - ) { - ReadingReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.clipboardClient = .noop - $0.cookieClient = .noop - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.downloadClient = .noop - $0.hapticsClient = .noop - $0.imageClient = .noop - $0.urlClient = .noop - } - store.exhaustivity = .off - let folderURL = download.folderURL ?? FileManager.default.temporaryDirectory - .appendingPathComponent(download.folderRelativePath, isDirectory: true) - - await store.send(.fetchDatabaseInfos(download.gid)) { - $0.gallery = download.gallery - $0.galleryDetail = GalleryDetail( - gid: download.gid, - title: download.title, - jpnTitle: download.jpnTitle, - isFavorited: false, - visibility: .yes, - rating: download.rating, - userRating: 0, - ratingCount: 0, - category: download.category, - language: manifest.language, - uploader: download.uploader ?? "", - postedDate: download.postedDate, - coverURL: download.coverURL, - favoritedCount: 0, - pageCount: download.pageCount, - sizeCount: 0, - sizeType: "", - torrentCount: 0 - ) - $0.localPageURLs = [ - 1: folderURL.appendingPathComponent("pages/0001.jpg"), - 2: folderURL.appendingPathComponent("pages/0002.jpg") - ] - $0.previewConfig = .normal(rows: 4) - $0.previewURLs = $0.localPageURLs - $0.thumbnailURLs = $0.localPageURLs - $0.imageURLs = $0.localPageURLs - $0.originalImageURLs = $0.localPageURLs - $0.databaseLoadingState = .idle - } - await store.finish() - - #expect(store.state.databaseLoadingState == .idle) - #expect(store.state.readingProgress == 0) - } - - @MainActor - @Test - func testReadingReducerDoesNotReloadLocalPagesWhenOnlyOtherGalleryChanges() async { - let gallery = sampleGallery() - let relevantDownload = sampleDownload( - gid: gallery.gid, - title: gallery.title, - status: .completed - ) - let otherDownload = sampleDownload( - gid: "900001", - title: "Other Gallery", - status: .queued - ) - let updatedOtherDownload = sampleDownload( - gid: otherDownload.gid, - title: otherDownload.title, - status: .downloading, - pageCount: 12, - completedPageCount: 4 - ) - let continuationBox = UncheckedBox.Continuation?>(nil) - let stream = AsyncStream<[DownloadedGallery]> { continuation in - continuationBox.value = continuation - } - let loadCount = UncheckedBox(0) - - var initialState = ReadingReducer.State(contentSource: .remote) - initialState.gallery = gallery - - let store = TestStore(initialState: initialState) { - ReadingReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.clipboardClient = .noop - $0.cookieClient = .noop - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { stream }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { gid in - #expect(gid == gallery.gid) - loadCount.value += 1 - return .success([:]) - } - ) - $0.hapticsClient = .noop - $0.imageClient = .noop - $0.urlClient = .noop - } - store.exhaustivity = .off - - await store.send(.observeDownloads(gallery.gid)) - - continuationBox.value?.yield([relevantDownload, otherDownload]) - await store.receive(\.observeDownloadsDone, [relevantDownload]) - await store.receive(\.loadLocalPageURLs, gallery.gid) - await store.receive(\.loadLocalPageURLsDone) - #expect(loadCount.value == 1) - - continuationBox.value?.yield([relevantDownload, updatedOtherDownload]) - try? await Task.sleep(for: .milliseconds(50)) - - #expect(loadCount.value == 1) - - continuationBox.value?.finish() - await store.finish() - } - - @MainActor - @Test - func testPreviewsReducerDoesNotReloadLocalPreviewsWhenOnlyOtherGalleryChanges() async { - let gallery = sampleGallery() - let relevantDownload = sampleDownload( - gid: gallery.gid, - title: gallery.title, - status: .completed - ) - let otherDownload = sampleDownload( - gid: "900002", - title: "Other Preview Gallery", - status: .queued - ) - let updatedOtherDownload = sampleDownload( - gid: otherDownload.gid, - title: otherDownload.title, - status: .paused, - pageCount: 12, - completedPageCount: 2 - ) - let continuationBox = UncheckedBox.Continuation?>(nil) - let stream = AsyncStream<[DownloadedGallery]> { continuation in - continuationBox.value = continuation - } - let loadCount = UncheckedBox(0) - - var initialState = PreviewsReducer.State() - initialState.gallery = gallery - - let store = TestStore(initialState: initialState) { - PreviewsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { stream }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { gid in - #expect(gid == gallery.gid) - loadCount.value += 1 - return .success([:]) - } - ) - $0.databaseClient = .noop - $0.hapticsClient = .noop - } - store.exhaustivity = .off - - await store.send(.observeDownloads(gallery.gid)) - - continuationBox.value?.yield([relevantDownload, otherDownload]) - await store.receive(\.observeDownloadsDone, [relevantDownload]) - await store.receive(\.loadLocalPreviewURLs, gallery.gid) - await store.receive(\.loadLocalPreviewURLsDone) - #expect(loadCount.value == 1) - - continuationBox.value?.yield([relevantDownload, updatedOtherDownload]) - try? await Task.sleep(for: .milliseconds(50)) - - #expect(loadCount.value == 1) - - continuationBox.value?.finish() - await store.finish() - } - - @MainActor - @Test - func testReadingAndPreviewsStillEmitOneFinalRefreshWhenRelevantDownloadDisappears() async { - let gallery = sampleGallery() - let relevantDownload = sampleDownload( - gid: gallery.gid, - title: gallery.title, - status: .completed - ) - - let readingContinuationBox = UncheckedBox.Continuation?>(nil) - let readingStream = AsyncStream<[DownloadedGallery]> { continuation in - readingContinuationBox.value = continuation - } - let readingLoadCount = UncheckedBox(0) - var readingState = ReadingReducer.State(contentSource: .remote) - readingState.gallery = gallery - - let readingStore = TestStore(initialState: readingState) { - ReadingReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.clipboardClient = .noop - $0.cookieClient = .noop - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { readingStream }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in - readingLoadCount.value += 1 - return .success([:]) - } - ) - $0.hapticsClient = .noop - $0.imageClient = .noop - $0.urlClient = .noop - } - readingStore.exhaustivity = .off - - await readingStore.send(.observeDownloads(gallery.gid)) - readingContinuationBox.value?.yield([relevantDownload]) - await readingStore.receive(\.observeDownloadsDone, [relevantDownload]) - await readingStore.receive(\.loadLocalPageURLs, gallery.gid) - await readingStore.receive(\.loadLocalPageURLsDone) - - readingContinuationBox.value?.yield([]) - await readingStore.receive(\.observeDownloadsDone, []) - await readingStore.receive(\.loadLocalPageURLs, gallery.gid) - await readingStore.receive(\.loadLocalPageURLsDone) - - #expect(readingLoadCount.value == 2) - readingContinuationBox.value?.finish() - await readingStore.finish() - - let previewsContinuationBox = UncheckedBox.Continuation?>(nil) - let previewsStream = AsyncStream<[DownloadedGallery]> { continuation in - previewsContinuationBox.value = continuation - } - let previewsLoadCount = UncheckedBox(0) - var previewsState = PreviewsReducer.State() - previewsState.gallery = gallery - - let previewsStore = TestStore(initialState: previewsState) { - PreviewsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { previewsStream }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in - previewsLoadCount.value += 1 - return .success([:]) - } - ) - $0.databaseClient = .noop - $0.hapticsClient = .noop - } - previewsStore.exhaustivity = .off - - await previewsStore.send(.observeDownloads(gallery.gid)) - previewsContinuationBox.value?.yield([relevantDownload]) - await previewsStore.receive(\.observeDownloadsDone, [relevantDownload]) - await previewsStore.receive(\.loadLocalPreviewURLs, gallery.gid) - await previewsStore.receive(\.loadLocalPreviewURLsDone) - - previewsContinuationBox.value?.yield([]) - await previewsStore.receive(\.observeDownloadsDone, []) - await previewsStore.receive(\.loadLocalPreviewURLs, gallery.gid) - await previewsStore.receive(\.loadLocalPreviewURLsDone) - - #expect(previewsLoadCount.value == 2) - previewsContinuationBox.value?.finish() - await previewsStore.finish() - } - - @MainActor - @Test - func testDownloadInspectorClearsInspectionWhenObservedDownloadDisappears() async { - let download = sampleDownload( - gid: "9988", - title: "Observed Archive", - status: .completed - ) - let inspection = sampleInspection(download: download) - var initialState = DownloadInspectorReducer.State(gid: download.gid) - initialState.inspection = inspection - initialState.stableInspection = inspection - initialState.retryingPageIndices = [2] - initialState.loadingState = .idle - - let store = TestStore(initialState: initialState) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.yield([download]) - continuation.yield([]) - continuation.finish() - } - }, - fetchDownloads: { [download] }, - fetchDownload: { gid in gid == download.gid ? download : nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in .success(inspection) } - ) - } - store.exhaustivity = .off - - await store.send(.observeDownloads) - await store.receive(\.observeDownloadsDone, [download]) - await store.receive(\.observeDownloadsDone, []) { - $0.inspection = nil - $0.stableInspection = nil - $0.loadingState = .idle - $0.retryingPageIndices = [] - } - } - - @MainActor - @Test - func testDownloadManagerBatchesObserverUpdatesDuringCachedPageRestore() async throws { - let container = try makeInMemoryContainer() - - let pageCount = 20 - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 104) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( - storage: storage, - urlSession: .shared, - persistenceContainer: container - ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .downloading, - completedPageCount: 0, - pageCount: pageCount - ) - - let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in - UIColor.systemTeal.setFill() - context.fill(.init(x: 0, y: 0, width: 1, height: 1)) - } - let imageData = try #require(cachedImage.jpegData(compressionQuality: 1)) - let imageURLs = try Dictionary(uniqueKeysWithValues: (1...pageCount).map { index in - (index, try #require(URL(string: "https://example.com/pages/\(gid)-\(index).jpg"))) - }) - try insertPersistedGalleryState(in: container, gid: gid, imageURLs: imageURLs) - let cacheKeys = Set(imageURLs.values.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) - for cacheKey in cacheKeys { - try await KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) - } - defer { - cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } - } - await waitUntilCacheReady(for: cacheKeys) - - let observationStream = await manager.observeDownloads() - let emissionTask = Task { - var emissionCount = 0 - for await downloads in observationStream { - guard let relevantDownload = downloads.first(where: { $0.gid == gid }) else { continue } - emissionCount += 1 - if relevantDownload.completedPageCount == pageCount { - break - } - } - return emissionCount - } - - let payload = DownloadRequestPayload( - gallery: Gallery( - gid: gid, - token: "token", - title: "Cached Restore Gallery", - rating: 4, - tags: [], - category: .doujinshi, - uploader: "Uploader", - pageCount: pageCount, - postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token") as URL?) - ), - galleryDetail: GalleryDetail( - gid: gid, - title: "Cached Restore Gallery", - jpnTitle: nil, - isFavorited: false, - visibility: .yes, - rating: 4, - userRating: 0, - ratingCount: 0, - category: .doujinshi, - language: .japanese, - uploader: "Uploader", - postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - favoritedCount: 0, - pageCount: pageCount, - sizeCount: 12, - sizeType: "MB", - torrentCount: 0 - ), - previewURLs: [:], - previewConfig: .normal(rows: 4), - host: .ehentai, - options: DownloadOptionsSnapshot(), - mode: .initial - ) - - let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) - let emissionCount = try await waitForTaskValue( - emissionTask, - timeout: .seconds(2), - description: "observer updates for cached page restore" - ) - let stored = await manager.testingFetchDownload(gid: gid) - - #expect(restoredCount == pageCount) - #expect(stored?.completedPageCount == pageCount) - #expect(emissionCount < pageCount) - #expect(emissionCount <= 1 + Int(ceil(Double(pageCount) / 8.0))) - } -} - -private extension DownloadFeatureReducerTests { - func waitUntilCacheReady( - for keys: Keys, - timeout: Duration = .seconds(1) - ) async where Keys.Element == String { - let cacheKeys = Array(keys) - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - - while !cacheKeys.allSatisfy({ KingfisherManager.shared.cache.isCached(forKey: $0) }), - clock.now < deadline - { - try? await clock.sleep(until: clock.now.advanced(by: .milliseconds(10)), tolerance: .zero) - } - - let missingKeys = cacheKeys.filter { !KingfisherManager.shared.cache.isCached(forKey: $0) } - #expect( - missingKeys.isEmpty, - "Timed out waiting for Kingfisher cache visibility for keys: \(missingKeys)" - ) - } - - func waitForTaskValue( - _ task: Task, - timeout: Duration = .seconds(1), - description: String - ) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - await task.value - } - group.addTask { - try await Task.sleep(for: timeout) - task.cancel() - throw NSError( - domain: "DownloadFeatureReducerTests", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Timed out waiting for \(description)"] - ) - } - - let value = try await group.next() - group.cancelAll() - return try #require(value, "Expected one task group result for \(description).") - } - } - - @MainActor - func drainDetailMetadataEffects( - _ store: TestStoreOf, - timeout: Duration = .seconds(1), - condition: @escaping @MainActor () -> Bool - ) async { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - while !condition() && clock.now < deadline { - await store.skipReceivedActions(strict: false) - try? await Task.sleep(for: .milliseconds(10)) - } - await store.skipReceivedActions(strict: false) - } - - func sampleGalleryState(gid: String) throws -> GalleryState { - var galleryState = GalleryState(gid: gid) - galleryState.previewURLs = [1: try #require(URL(string: "https://example.com/1t.jpg"))] - galleryState.previewConfig = .normal(rows: 4) - return galleryState - } - - func sampleVersionMetadata(gid: String, token: String) -> DownloadVersionMetadata { - DownloadVersionMetadata( - gid: gid, - token: token, - currentGID: gid, - currentKey: "updated-key", - parentGID: gid, - parentKey: token, - firstGID: gid, - firstKey: token - ) - } - - func makeTestingDownloadManager() -> DownloadManager { - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - return DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared - ) - } - - func makeResponse( - url: URL, - statusCode: Int = 200, - contentType: String, - contentLength: Int? = nil, - headers: [String: String] = [:] - ) throws -> HTTPURLResponse { - var headerFields = headers - headerFields["Content-Type"] = contentType - if let contentLength { - headerFields["Content-Length"] = "\(contentLength)" - } - return try #require(HTTPURLResponse( - url: url, - statusCode: statusCode, - httpVersion: nil, - headerFields: headerFields - )) - } - - func writeFixtureToTemporaryFile(filename: HTMLFilename) throws -> URL { - try writeFixtureToTemporaryFile(resource: filename.rawValue, pathExtension: "html") - } - - func writeFixtureToTemporaryFile(resource: String, pathExtension: String) throws -> URL { - let temporaryURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension(pathExtension) - try fixtureData(resource: resource, pathExtension: pathExtension) - .write(to: temporaryURL, options: .atomic) - return temporaryURL - } - - func fixtureData(resource: String, pathExtension: String) throws -> Data { - let fixtureURL = try #require( - Bundle(for: TestBundleLocator.self).url(forResource: resource, withExtension: pathExtension) - ) - return try Data(contentsOf: fixtureURL) - } - - func installGalleryVersionMetadataStub(for gallery: Gallery, sessionID: String) throws { - let gid = try #require(Int(gallery.gid)) - let payload: [String: Any] = [ - "gmetadata": [[ - "gid": gid, - "token": gallery.token, - "current_gid": gid, - "current_key": "updated-key", - "parent_gid": gid, - "parent_key": gallery.token, - "first_gid": gid, - "first_key": gallery.token - ]] - ] - let responseData = try JSONSerialization.data(withJSONObject: payload, options: []) - SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in - let response = try #require(HTTPURLResponse( - url: request.url ?? Defaults.URL.api, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )) - return (response, responseData) - } - URLProtocol.registerClass(SharedSessionStubURLProtocol.self) - } - - func uninstallSharedSessionStub(sessionID: String) { - SharedSessionStubURLProtocol.removeHandler(for: sessionID) - } - - func sampleGallery() -> Gallery { - Gallery( - gid: "123456", - token: "token", - title: "Sample Gallery", - rating: 4, - tags: [], - category: .doujinshi, - uploader: "Uploader", - pageCount: 12, - postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: URL(string: "https://e-hentai.org/g/123456/token") - ) - } - - func sampleGalleryDetail(gid: String, title: String) -> GalleryDetail { - GalleryDetail( - gid: gid, - title: title, - jpnTitle: nil, - isFavorited: false, - visibility: .yes, - rating: 4, - userRating: 0, - ratingCount: 10, - category: .doujinshi, - language: .japanese, - uploader: "Uploader", - postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - favoritedCount: 2, - pageCount: 12, - sizeCount: 120, - sizeType: "MB", - torrentCount: 0 - ) - } - - func sampleManifest( - gid: String, - title: String, - pageCount: Int = 2, - versionSignature: String = "hash:v1" - ) throws -> DownloadManifest { - DownloadManifest( - gid: gid, - host: .ehentai, - token: "token", - title: title, - jpnTitle: nil, - category: .doujinshi, - language: .japanese, - uploader: "Uploader", - tags: [], - postedDate: .now, - pageCount: pageCount, - coverRelativePath: "cover.jpg", - galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), - rating: 4, - downloadOptions: DownloadOptionsSnapshot(), - versionSignature: versionSignature, - downloadedAt: .now, - pages: (1...pageCount).map { - .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") - } - ) - } - - func sampleInspection(download: DownloadedGallery) -> DownloadInspection { - .init( - download: download, - coverURL: download.coverURL, - pages: [ - .init( - index: 1, - status: .downloaded, - relativePath: "pages/0001.jpg", - fileURL: URL(fileURLWithPath: "/tmp/0001.jpg"), - failure: nil - ), - .init( - index: 2, - status: .failed, - relativePath: "pages/0002.jpg", - fileURL: nil, - failure: .init(code: .networkingFailed, message: "Network Error") - ) - ] - ) - } - - func sampleDownload( - gid: String, - title: String, - status: DownloadStatus, - category: EhPanda.Category = .doujinshi, - pageCount: Int = 12, - completedPageCount: Int? = nil, - lastDownloadedAt: Date? = .now, - remoteVersionSignature: String = "hash:v1", - latestRemoteVersionSignature: String = "hash:v1", - lastError: DownloadFailure? = nil, - pendingOperation: DownloadStartMode? = nil - ) -> DownloadedGallery { - DownloadedGallery( - gid: gid, - host: .ehentai, - token: "token", - title: title, - jpnTitle: nil, - uploader: "Uploader", - category: category, - tags: [], - pageCount: pageCount, - postedDate: .now, - rating: 4, - onlineCoverURL: URL(string: "https://example.com/cover.jpg"), - folderRelativePath: "\(gid) - \(title)", - coverRelativePath: "cover.jpg", - status: status, - completedPageCount: completedPageCount ?? (status == .completed ? pageCount : 0), - lastDownloadedAt: lastDownloadedAt, - lastError: lastError, - downloadOptionsSnapshot: DownloadOptionsSnapshot(), - remoteVersionSignature: remoteVersionSignature, - latestRemoteVersionSignature: latestRemoteVersionSignature, - pendingOperation: pendingOperation - ) - } - - func prepareLocalDownloadFiles( - download: DownloadedGallery, - manifest: DownloadManifest - ) throws -> URL { - guard let folderURL = download.folderURL else { - throw NSError( - domain: "DownloadFeatureReducerTests", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Downloads directory is unavailable in the test environment."] - ) - } - try? FileManager.default.removeItem(at: folderURL) - try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - try JSONEncoder().encode(manifest).write( - to: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) - try Data([0x01]).write( - to: folderURL.appendingPathComponent("pages/0001.jpg"), - options: .atomic - ) - try Data([0x02]).write( - to: folderURL.appendingPathComponent("pages/0002.jpg"), - options: .atomic - ) - return folderURL - } - - func makeInMemoryContainer() throws -> NSPersistentContainer { - let modelURL = try #require( - Bundle(for: TestBundleLocator.self).url(forResource: "Model", withExtension: "momd") - ?? Bundle.main.url(forResource: "Model", withExtension: "momd") - ) - let model = try #require(NSManagedObjectModel(contentsOf: modelURL)) - let container = NSPersistentContainer(name: UUID().uuidString, managedObjectModel: model) - let description = NSPersistentStoreDescription() - description.type = NSInMemoryStoreType - container.persistentStoreDescriptions = [description] - let semaphore = DispatchSemaphore(value: 0) - var loadError: Error? - container.loadPersistentStores { _, error in - loadError = error - semaphore.signal() - } - let waitResult = semaphore.wait(timeout: .now() + 5) - if waitResult == .timedOut { - Issue.record("Timed out loading in-memory persistent store.") - } - if let loadError { - Issue.record("Failed to load in-memory persistent store: \(loadError)") - } - return container - } - - func clearPersistedDownloads(in container: NSPersistentContainer) throws { - let context = container.viewContext - let downloadRequest = NSFetchRequest(entityName: "DownloadedGalleryMO") - let downloads = try context.fetch(downloadRequest) - for object in downloads { - context.delete(object) - } - let stateRequest = NSFetchRequest(entityName: "GalleryStateMO") - let states = try context.fetch(stateRequest) - for object in states { - context.delete(object) - } - guard context.hasChanges else { return } - try context.save() - } - - func insertPersistedDownload( - in container: NSPersistentContainer, - gid: String, - status: DownloadStatus, - completedPageCount: Int, - pageCount: Int = 26, - token: String = "token", - remoteVersionSignature: String = "", - latestRemoteVersionSignature: String = "", - lastError: DownloadFailure? = nil, - pendingOperation: DownloadStartMode? = nil - ) throws { - let context = container.viewContext - let object = DownloadedGalleryMO(context: context) - object.gid = gid - object.host = GalleryHost.ehentai.rawValue - object.token = token - object.title = "Pause Race" - object.jpnTitle = nil - object.uploader = "Uploader" - object.category = Category.doujinshi.rawValue - object.tags = [GalleryTag]().toData() - object.pageCount = Int64(pageCount) - object.postedDate = .now - object.rating = 4 - object.onlineCoverURL = URL(string: "https://example.com/cover.jpg") - object.folderRelativePath = "\(gid) - Pause Race" - object.coverRelativePath = nil - object.status = status.rawValue - object.completedPageCount = Int64(completedPageCount) - object.lastDownloadedAt = .now - object.lastError = lastError?.toData() - object.downloadOptionsSnapshot = DownloadOptionsSnapshot().toData() - object.remoteVersionSignature = remoteVersionSignature - object.latestRemoteVersionSignature = latestRemoteVersionSignature - object.pendingOperation = pendingOperation?.rawValue - try context.save() - } - - func insertPersistedGalleryState( - in container: NSPersistentContainer, - gid: String, - previewURLs: [Int: URL] = [:], - imageURLs: [Int: URL], - originalImageURLs: [Int: URL] = [:] - ) throws { - let context = container.viewContext - let object = GalleryStateMO(context: context) - object.gid = gid - object.previewURLs = previewURLs.toData() - object.imageURLs = imageURLs.toData() - object.originalImageURLs = originalImageURLs.toData() - try context.save() - } -} - -private final class UncheckedBox: @unchecked Sendable { - var value: Value - - init(_ value: Value) { - self.value = value - } -} - -private struct RequestRecorderSnapshot: Equatable { - var detailRequests = 0 - var metadataRequests = 0 - var mpvRequests = 0 - var imageDispatchRequests = 0 - var imageDownloads = 0 - var previewPageNumbers = [Int]() -} - -private final class RequestRecorder: @unchecked Sendable { - private let lock = NSLock() - private var state = RequestRecorderSnapshot() - - func recordDetail() { - mutate { $0.detailRequests += 1 } - } - - func recordMetadata() { - mutate { $0.metadataRequests += 1 } - } - - func recordPreview(_ pageNumber: Int) { - mutate { $0.previewPageNumbers.append(pageNumber) } - } - - func recordMPV() { - mutate { $0.mpvRequests += 1 } - } - - func recordImageDispatch() { - mutate { $0.imageDispatchRequests += 1 } - } - - func recordImageDownload() { - mutate { $0.imageDownloads += 1 } - } - - func reset() { - mutate { $0 = .init() } - } - - func snapshot() -> RequestRecorderSnapshot { - lock.lock() - defer { lock.unlock() } - return state - } - - private func mutate(_ update: (inout RequestRecorderSnapshot) -> Void) { - lock.lock() - defer { lock.unlock() } - update(&state) - } -} - -private func requestBodyData(from request: URLRequest) -> Data? { - if let httpBody = request.httpBody { - return httpBody - } - - guard let stream = request.httpBodyStream else { - return nil - } - - stream.open() - defer { stream.close() } - - var data = Data() - let bufferSize = 1024 - let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) - defer { buffer.deallocate() } - - while stream.hasBytesAvailable { - let readCount = stream.read(buffer, maxLength: bufferSize) - guard readCount >= 0 else { - return nil - } - guard readCount > 0 else { - break - } - data.append(buffer, count: readCount) - } - - return data -} - -private final class FailFastURLProtocol: URLProtocol { - override static func canInit(with request: URLRequest) -> Bool { - true - } - - override static func canonicalRequest(for request: URLRequest) -> URLRequest { - request - } - - override func startLoading() { - client?.urlProtocol(self, didFailWithError: URLError(.cancelled)) - } - - override func stopLoading() {} -} - -private final class SharedSessionStubURLProtocol: URLProtocol { - static let headerKey = "X-TestSession-ID" - - private static let lock = NSLock() - private static var handlers: [String: (URLRequest) throws -> (HTTPURLResponse, Data)] = [:] - - static func setHandler( - for sessionID: String, - handler: @escaping (URLRequest) throws -> (HTTPURLResponse, Data) - ) { - lock.lock() - defer { lock.unlock() } - handlers[sessionID] = handler - } - - static func removeHandler(for sessionID: String) { - lock.lock() - defer { lock.unlock() } - handlers[sessionID] = nil - } - - private static func handler( - for request: URLRequest - ) -> ((URLRequest) throws -> (HTTPURLResponse, Data))? { - guard let sessionID = request.value(forHTTPHeaderField: headerKey) else { - return nil - } - lock.lock() - defer { lock.unlock() } - return handlers[sessionID] - } - - override static func canInit(with request: URLRequest) -> Bool { - handler(for: request) != nil - } - - override static func canonicalRequest(for request: URLRequest) -> URLRequest { - request - } - - override func startLoading() { - guard let handler = Self.handler(for: request) else { - client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) - return - } - - do { - let (response, data) = try handler(request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) - } - } - - override func stopLoading() {} -} +// Tests have been split into separate files by feature area. +// See the other files in this directory for the individual test suites. +// diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift new file mode 100644 index 000000000..34e4c315e --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -0,0 +1,375 @@ +// +// DownloadFeatureTestFactories.swift +// EhPandaTests +// + +import CoreData +import Foundation +import Testing +@testable import EhPanda + +// MARK: - Sample Data Factories & CoreData Helpers + +extension DownloadFeatureTestCase { + func sampleManifest( + gid: String, + title: String, + pageCount: Int = 2, + versionSignature: String = "hash:v1" + ) throws -> DownloadManifest { + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: .now, + pageCount: pageCount, + coverRelativePath: "cover.jpg", + galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), + rating: 4, + downloadOptions: DownloadOptionsSnapshot(), + versionSignature: versionSignature, + downloadedAt: .now, + pages: (1...pageCount).map { + .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") + } + ) + } + + func sampleInspection( + download: DownloadedGallery + ) -> DownloadInspection { + .init( + download: download, + coverURL: download.coverURL, + pages: [ + .init( + index: 1, + status: .downloaded, + relativePath: "pages/0001.jpg", + fileURL: URL(fileURLWithPath: "/tmp/0001.jpg"), + failure: nil + ), + .init( + index: 2, + status: .failed, + relativePath: "pages/0002.jpg", + fileURL: nil, + failure: .init(code: .networkingFailed, message: "Network Error") + ) + ] + ) + } + + func sampleDownload( + gid: String, + title: String, + status: DownloadStatus, + category: EhPanda.Category = .doujinshi, + pageCount: Int = 12, + completedPageCount: Int? = nil, + lastDownloadedAt: Date? = .now, + remoteVersionSignature: String = "hash:v1", + latestRemoteVersionSignature: String = "hash:v1", + lastError: DownloadFailure? = nil, + pendingOperation: DownloadStartMode? = nil + ) -> DownloadedGallery { + DownloadedGallery( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + uploader: "Uploader", + category: category, + tags: [], + pageCount: pageCount, + postedDate: .now, + rating: 4, + onlineCoverURL: URL(string: "https://example.com/cover.jpg"), + folderRelativePath: "\(gid) - \(title)", + coverRelativePath: "cover.jpg", + status: status, + completedPageCount: completedPageCount ?? (status == .completed ? pageCount : 0), + lastDownloadedAt: lastDownloadedAt, + lastError: lastError, + downloadOptionsSnapshot: DownloadOptionsSnapshot(), + remoteVersionSignature: remoteVersionSignature, + latestRemoteVersionSignature: latestRemoteVersionSignature, + pendingOperation: pendingOperation + ) + } + + func prepareLocalDownloadFiles( + download: DownloadedGallery, + manifest: DownloadManifest + ) throws -> URL { + guard let folderURL = download.folderURL else { + throw NSError( + domain: "DownloadFeatureReducerTests", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "Downloads directory is unavailable in the test environment." + ] + ) + } + try? FileManager.default.removeItem(at: folderURL) + try FileManager.default.createDirectory( + at: folderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, isDirectory: true + ), + withIntermediateDirectories: true + ) + try JSONEncoder().encode(manifest).write( + to: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: folderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) + return folderURL + } + + func makeInMemoryContainer() throws -> NSPersistentContainer { + let modelURL = try #require( + Bundle(for: TestBundleLocator.self).url(forResource: "Model", withExtension: "momd") + ?? Bundle.main.url(forResource: "Model", withExtension: "momd") + ) + let model = try #require(NSManagedObjectModel(contentsOf: modelURL)) + let container = NSPersistentContainer( + name: UUID().uuidString, managedObjectModel: model + ) + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + container.persistentStoreDescriptions = [description] + let semaphore = DispatchSemaphore(value: 0) + var loadError: Error? + container.loadPersistentStores { _, error in + loadError = error + semaphore.signal() + } + let waitResult = semaphore.wait(timeout: .now() + 5) + if waitResult == .timedOut { + Issue.record("Timed out loading in-memory persistent store.") + } + if let loadError { + Issue.record( + "Failed to load in-memory persistent store: \(loadError)" + ) + } + return container + } + + func clearPersistedDownloads( + in container: NSPersistentContainer + ) throws { + let context = container.viewContext + let downloadRequest = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + let downloads = try context.fetch(downloadRequest) + for object in downloads { + context.delete(object) + } + let stateRequest = NSFetchRequest( + entityName: "GalleryStateMO" + ) + let states = try context.fetch(stateRequest) + for object in states { + context.delete(object) + } + guard context.hasChanges else { return } + try context.save() + } + + func insertPersistedDownload( + in container: NSPersistentContainer, + gid: String, + status: DownloadStatus, + completedPageCount: Int, + pageCount: Int = 26, + token: String = "token", + remoteVersionSignature: String = "", + latestRemoteVersionSignature: String = "", + lastError: DownloadFailure? = nil, + pendingOperation: DownloadStartMode? = nil + ) throws { + let context = container.viewContext + let object = DownloadedGalleryMO(context: context) + object.gid = gid + object.host = GalleryHost.ehentai.rawValue + object.token = token + object.title = "Pause Race" + object.jpnTitle = nil + object.uploader = "Uploader" + object.category = Category.doujinshi.rawValue + object.tags = [GalleryTag]().toData() + object.pageCount = Int64(pageCount) + object.postedDate = .now + object.rating = 4 + object.onlineCoverURL = URL(string: "https://example.com/cover.jpg") + object.folderRelativePath = "\(gid) - Pause Race" + object.coverRelativePath = nil + object.status = status.rawValue + object.completedPageCount = Int64(completedPageCount) + object.lastDownloadedAt = .now + object.lastError = lastError?.toData() + object.downloadOptionsSnapshot = DownloadOptionsSnapshot().toData() + object.remoteVersionSignature = remoteVersionSignature + object.latestRemoteVersionSignature = latestRemoteVersionSignature + object.pendingOperation = pendingOperation?.rawValue + try context.save() + } + + func insertPersistedGalleryState( + in container: NSPersistentContainer, + gid: String, + previewURLs: [Int: URL] = [:], + imageURLs: [Int: URL], + originalImageURLs: [Int: URL] = [:] + ) throws { + let context = container.viewContext + let object = GalleryStateMO(context: context) + object.gid = gid + object.previewURLs = previewURLs.toData() + object.imageURLs = imageURLs.toData() + object.originalImageURLs = originalImageURLs.toData() + try context.save() + } +} + +// MARK: - Stub Handler Content + +struct StubHandlerContent { + let detailHTML: Data + let mpvHTML: Data + let metadataResponse: Data +} + +// MARK: - Stub Route Context + +struct StubRouteContext { + let gid: String + let pageIndex: Int + let content: StubHandlerContent + let recorder: RequestRecorder? +} + +// MARK: - Stub Manager & Handler Helpers + +extension DownloadFeatureTestCase { + func makeStubbedDownloadManager( + rootURL: URL, + sessionID: String + ) -> (DownloadFileStorage, DownloadManager) { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [ + SharedSessionStubURLProtocol.headerKey: sessionID + ] + let storage = DownloadFileStorage( + rootURL: rootURL, fileManager: .default + ) + let manager = DownloadManager( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + return (storage, manager) + } + + func makeMetadataResponseData( + gid: String, + token: String = "token" + ) throws -> Data { + let gidInt = try #require(Int(gid)) + return try JSONSerialization.data(withJSONObject: [ + "gmetadata": [[ + "gid": gidInt, "token": token, + "current_gid": gidInt, "current_key": "updated-key", + "parent_gid": gidInt, "parent_key": token, + "first_gid": gidInt, "first_key": token + ]] + ]) + } + + func installDownloadStubHandler( + sessionID: String, gid: String, pageIndex: Int, + content: StubHandlerContent, + recorder: RequestRecorder? = nil, + allowedImageURLs: Set = [] + ) { + let context = StubRouteContext( + gid: gid, pageIndex: pageIndex, content: content, recorder: recorder + ) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.host == "example.com" || allowedImageURLs.contains(url.absoluteString) { + recorder?.recordImageDownload() + return (try Self.stubResponse(url: url, contentType: "image/jpeg"), Data([0xFF, 0xD8, 0xFF, 0xD9])) + } + return try Self.routeStubRequest(url: url, request: request, context: context) + } + URLProtocol.registerClass(SharedSessionStubURLProtocol.self) + } + + private static func routeStubRequest( + url: URL, request: URLRequest, + context: StubRouteContext + ) throws -> (HTTPURLResponse, Data) { + let gid = context.gid + let pageIndex = context.pageIndex + let detailHTML = context.content.detailHTML + let mpvHTML = context.content.mpvHTML + let metadataResponse = context.content.metadataResponse + let recorder = context.recorder + if url.host == "api.e-hentai.org" { + recorder?.recordMetadata() + return (try stubResponse(url: url, contentType: "application/json"), metadataResponse) + } + if url.path.contains("/g/\(gid)/token") { + let pageNum = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first { $0.name == "p" }?.value.flatMap(Int.init) + if let pageNum { recorder?.recordPreview(pageNum) } else { recorder?.recordDetail() } + return (try stubResponse(url: url, contentType: "text/html; charset=utf-8"), detailHTML) + } + if url.path.contains("/mpv/") { + recorder?.recordMPV() + return (try stubResponse(url: url, contentType: "text/html; charset=utf-8"), mpvHTML) + } + if url.path == "/api.php" { + let body = requestBodyData(from: request) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + if body?["method"] as? String == "gdata" { + recorder?.recordMetadata() + return (try stubResponse(url: url, contentType: "application/json"), metadataResponse) + } + recorder?.recordImageDispatch() + let data = try JSONSerialization.data(withJSONObject: [ + "i": "https://example.com/image-\(pageIndex).jpg" + ]) + return (try stubResponse(url: url, contentType: "application/json"), data) + } + throw URLError(.unsupportedURL) + } + + private static func stubResponse( + url: URL, contentType: String + ) throws -> HTTPURLResponse { + try #require(HTTPURLResponse( + url: url, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": contentType] + )) + } + +} diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift new file mode 100644 index 000000000..503ccedc0 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -0,0 +1,286 @@ +// +// DownloadFeatureTestHelpers.swift +// EhPandaTests +// + +import Foundation +import CoreData +import ComposableArchitecture +import Kingfisher +import UIKit +import Testing +@testable import EhPanda + +// MARK: - Shared Test Helper Protocol + +protocol DownloadFeatureTestCase: TestHelper { + func waitUntilCacheReady( + for keys: Keys, + timeout: Duration + ) async where Keys.Element == String + + func waitForTaskValue( + _ task: Task, + timeout: Duration, + description: String + ) async throws -> T + + func sampleGalleryState(gid: String) throws -> GalleryState + func sampleVersionMetadata(gid: String, token: String) -> DownloadVersionMetadata + func makeTestingDownloadManager() -> DownloadManager + func makeResponse( + url: URL, + statusCode: Int, + contentType: String, + contentLength: Int?, + headers: [String: String] + ) throws -> HTTPURLResponse + func writeFixtureToTemporaryFile(filename: HTMLFilename) throws -> URL + func writeFixtureToTemporaryFile(resource: String, pathExtension: String) throws -> URL + func fixtureData(resource: String, pathExtension: String) throws -> Data + func installGalleryVersionMetadataStub(for gallery: Gallery, sessionID: String) throws + func uninstallSharedSessionStub(sessionID: String) + func sampleGallery() -> Gallery + func sampleGalleryDetail(gid: String, title: String) -> GalleryDetail + func sampleManifest( + gid: String, + title: String, + pageCount: Int, + versionSignature: String + ) throws -> DownloadManifest + func sampleInspection(download: DownloadedGallery) -> DownloadInspection + func prepareLocalDownloadFiles( + download: DownloadedGallery, + manifest: DownloadManifest + ) throws -> URL + func makeInMemoryContainer() throws -> NSPersistentContainer + func clearPersistedDownloads(in container: NSPersistentContainer) throws + func insertPersistedGalleryState( + in container: NSPersistentContainer, + gid: String, + previewURLs: [Int: URL], + imageURLs: [Int: URL], + originalImageURLs: [Int: URL] + ) throws +} + +// MARK: - Default Implementations + +extension DownloadFeatureTestCase { + func waitUntilCacheReady( + for keys: Keys, + timeout: Duration = .seconds(1) + ) async where Keys.Element == String { + let cacheKeys = Array(keys) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while !cacheKeys.allSatisfy({ KingfisherManager.shared.cache.isCached(forKey: $0) }), + clock.now < deadline { + try? await clock.sleep(until: clock.now.advanced(by: .milliseconds(10)), tolerance: .zero) + } + + let missingKeys = cacheKeys.filter { !KingfisherManager.shared.cache.isCached(forKey: $0) } + #expect( + missingKeys.isEmpty, + "Timed out waiting for Kingfisher cache visibility for keys: \(missingKeys)" + ) + } + + func waitForTaskValue( + _ task: Task, + timeout: Duration = .seconds(1), + description: String + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + await task.value + } + group.addTask { + try await Task.sleep(for: timeout) + task.cancel() + throw NSError( + domain: "DownloadFeatureReducerTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Timed out waiting for \(description)"] + ) + } + + let value = try await group.next() + group.cancelAll() + return try #require(value, "Expected one task group result for \(description).") + } + } + + @MainActor + func drainDetailMetadataEffects( + _ store: TestStoreOf, + timeout: Duration = .seconds(1), + condition: @escaping @MainActor () -> Bool + ) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !condition() && clock.now < deadline { + await store.skipReceivedActions(strict: false) + try? await Task.sleep(for: .milliseconds(10)) + } + await store.skipReceivedActions(strict: false) + } + + func sampleGalleryState(gid: String) throws -> GalleryState { + var galleryState = GalleryState(gid: gid) + galleryState.previewURLs = [1: try #require(URL(string: "https://example.com/1t.jpg"))] + galleryState.previewConfig = .normal(rows: 4) + return galleryState + } + + func sampleVersionMetadata( + gid: String, + token: String + ) -> DownloadVersionMetadata { + DownloadVersionMetadata( + gid: gid, + token: token, + currentGID: gid, + currentKey: "updated-key", + parentGID: gid, + parentKey: token, + firstGID: gid, + firstKey: token + ) + } + + func makeTestingDownloadManager() -> DownloadManager { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: .shared + ) + } + + func makeResponse( + url: URL, + statusCode: Int = 200, + contentType: String, + contentLength: Int? = nil, + headers: [String: String] = [:] + ) throws -> HTTPURLResponse { + var headerFields = headers + headerFields["Content-Type"] = contentType + if let contentLength { + headerFields["Content-Length"] = "\(contentLength)" + } + return try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: headerFields + )) + } + + func writeFixtureToTemporaryFile( + filename: HTMLFilename + ) throws -> URL { + try writeFixtureToTemporaryFile(resource: filename.rawValue, pathExtension: "html") + } + + func writeFixtureToTemporaryFile( + resource: String, + pathExtension: String + ) throws -> URL { + let temporaryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(pathExtension) + try fixtureData(resource: resource, pathExtension: pathExtension) + .write(to: temporaryURL, options: .atomic) + return temporaryURL + } + + func fixtureData( + resource: String, + pathExtension: String + ) throws -> Data { + let fixtureURL = try #require( + Bundle(for: TestBundleLocator.self).url(forResource: resource, withExtension: pathExtension) + ) + return try Data(contentsOf: fixtureURL) + } + + func installGalleryVersionMetadataStub( + for gallery: Gallery, + sessionID: String + ) throws { + let gid = try #require(Int(gallery.gid)) + let payload: [String: Any] = [ + "gmetadata": [[ + "gid": gid, + "token": gallery.token, + "current_gid": gid, + "current_key": "updated-key", + "parent_gid": gid, + "parent_key": gallery.token, + "first_gid": gid, + "first_key": gallery.token + ]] + ] + let responseData = try JSONSerialization.data(withJSONObject: payload, options: []) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + let response = try #require(HTTPURLResponse( + url: request.url ?? Defaults.URL.api, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )) + return (response, responseData) + } + URLProtocol.registerClass(SharedSessionStubURLProtocol.self) + } + + func uninstallSharedSessionStub(sessionID: String) { + SharedSessionStubURLProtocol.removeHandler(for: sessionID) + } + + func sampleGallery() -> Gallery { + Gallery( + gid: "123456", + token: "token", + title: "Sample Gallery", + rating: 4, + tags: [], + category: .doujinshi, + uploader: "Uploader", + pageCount: 12, + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + galleryURL: URL(string: "https://e-hentai.org/g/123456/token") + ) + } + + func sampleGalleryDetail( + gid: String, + title: String + ) -> GalleryDetail { + GalleryDetail( + gid: gid, + title: title, + jpnTitle: nil, + isFavorited: false, + visibility: .yes, + rating: 4, + userRating: 0, + ratingCount: 10, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + favoritedCount: 2, + pageCount: 12, + sizeCount: 120, + sizeType: "MB", + torrentCount: 0 + ) + } + +} diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift new file mode 100644 index 000000000..4d18448c5 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift @@ -0,0 +1,195 @@ +// +// DownloadFeatureTestSupportTypes.swift +// EhPandaTests +// + +import Foundation +@testable import EhPanda + +// MARK: - Supporting Types + +final class UncheckedBox: @unchecked Sendable { + var value: Value + + init(_ value: Value) { + self.value = value + } +} + +struct RequestRecorderSnapshot: Equatable { + var detailRequests = 0 + var metadataRequests = 0 + var mpvRequests = 0 + var imageDispatchRequests = 0 + var imageDownloads = 0 + var previewPageNumbers = [Int]() +} + +final class RequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var state = RequestRecorderSnapshot() + + func recordDetail() { + mutate { $0.detailRequests += 1 } + } + + func recordMetadata() { + mutate { $0.metadataRequests += 1 } + } + + func recordPreview(_ pageNumber: Int) { + mutate { $0.previewPageNumbers.append(pageNumber) } + } + + func recordMPV() { + mutate { $0.mpvRequests += 1 } + } + + func recordImageDispatch() { + mutate { $0.imageDispatchRequests += 1 } + } + + func recordImageDownload() { + mutate { $0.imageDownloads += 1 } + } + + func reset() { + mutate { $0 = .init() } + } + + func snapshot() -> RequestRecorderSnapshot { + lock.lock() + defer { lock.unlock() } + return state + } + + private func mutate( + _ update: (inout RequestRecorderSnapshot) -> Void + ) { + lock.lock() + defer { lock.unlock() } + update(&state) + } +} + +func requestBodyData(from request: URLRequest) -> Data? { + if let httpBody = request.httpBody { + return httpBody + } + + guard let stream = request.httpBodyStream else { + return nil + } + + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let readCount = stream.read(buffer, maxLength: bufferSize) + guard readCount >= 0 else { + return nil + } + guard readCount > 0 else { + break + } + data.append(buffer, count: readCount) + } + + return data +} + +final class FailFastURLProtocol: URLProtocol { + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest( + for request: URLRequest + ) -> URLRequest { + request + } + + override func startLoading() { + client?.urlProtocol( + self, didFailWithError: URLError(.cancelled) + ) + } + + override func stopLoading() {} +} + +final class SharedSessionStubURLProtocol: URLProtocol { + static let headerKey = "X-TestSession-ID" + + private static let lock = NSLock() + private static var handlers: + [String: (URLRequest) throws -> (HTTPURLResponse, Data)] = [:] + + static func setHandler( + for sessionID: String, + handler: @escaping (URLRequest) throws -> (HTTPURLResponse, Data) + ) { + lock.lock() + defer { lock.unlock() } + handlers[sessionID] = handler + } + + static func removeHandler(for sessionID: String) { + lock.lock() + defer { lock.unlock() } + handlers[sessionID] = nil + } + + private static func handler( + for request: URLRequest + ) -> ((URLRequest) throws -> (HTTPURLResponse, Data))? { + guard let sessionID = request.value( + forHTTPHeaderField: headerKey + ) else { + return nil + } + lock.lock() + defer { lock.unlock() } + return handlers[sessionID] + } + + override static func canInit(with request: URLRequest) -> Bool { + handler(for: request) != nil + } + + override static func canonicalRequest( + for request: URLRequest + ) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler(for: request) else { + client?.urlProtocol( + self, + didFailWithError: URLError(.badServerResponse) + ) + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol( + self, + didReceive: response, + cacheStoragePolicy: .notAllowed + ) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift new file mode 100644 index 000000000..7d369c1bf --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift @@ -0,0 +1,56 @@ +// +// DownloadFeatureTestTemporaryStorage.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +// MARK: - Temporary Storage Helpers + +extension DownloadFeatureTestCase { + func writeTemporaryManifestAndPages( + storage: DownloadFileStorage, gid: String, + manifest: DownloadManifest, pageCount: Int, + omittingPage pageToOmit: Int? = nil, + versionSignature: String, + mode: DownloadStartMode = .redownload, + pageSelection: [Int]? = nil + ) throws { + let folderURL = storage.temporaryFolderURL(gid: gid) + try? FileManager.default.removeItem(at: folderURL) + try FileManager.default.createDirectory( + at: folderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, isDirectory: true + ), + withIntermediateDirectories: true + ) + try JSONEncoder().encode(manifest).write( + to: folderURL.appendingPathComponent( + Defaults.FilePath.downloadManifest + ), + options: .atomic + ) + try Data([0x00]).write( + to: folderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + for index in 1...max(1, pageCount) where index != pageToOmit && pageCount > 0 { + try Data([UInt8(index % 255)]).write( + to: folderURL.appendingPathComponent( + "pages/\(String(format: "%04d", index)).jpg" + ), + options: .atomic + ) + } + try storage.writeResumeState( + .init( + mode: mode, versionSignature: versionSignature, + pageCount: pageCount, downloadOptions: .init(), + pageSelection: pageSelection + ), + folderURL: folderURL + ) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift new file mode 100644 index 000000000..5092cdae7 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift @@ -0,0 +1,221 @@ +// +// DownloadFileStorageRepairTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +struct DownloadFileStorageRepairTests { + @Test + func testMaterializeRepairSeedCopiesOnlyManifestCoverAndExistingPageFiles() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let sourceFolderURL = storage.folderURL(relativePath: "123 - Source") + let tempFolderURL = storage.temporaryFolderURL(gid: "123") + let manifest = try sampleManifest(pageCount: 3) + try setupRepairSourceFiles( + sourceFolderURL: sourceFolderURL, storage: storage, manifest: manifest + ) + + try storage.materializeRepairSeed( + from: sourceFolderURL, manifest: manifest, to: tempFolderURL + ) + + verifyRepairSeedResult(tempFolderURL: tempFolderURL) + } + + @Test + func testMaterializeRepairSeedRejectsTraversalPathsInManifestPages() throws { + let sourceRootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let destRootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { + try? FileManager.default.removeItem(at: sourceRootURL) + try? FileManager.default.removeItem(at: destRootURL) + } + + let env = try setupTraversalTestEnvironment( + sourceRootURL: sourceRootURL, destRootURL: destRootURL + ) + + try env.destStorage.materializeRepairSeed( + from: env.sourceFolderURL, manifest: env.manifest, to: env.tempFolderURL + ) + + #expect(FileManager.default.fileExists( + atPath: env.tempFolderURL.appendingPathComponent("pages/0001.jpg").path + )) + #expect(FileManager.default.fileExists( + atPath: env.tempFolderURL.appendingPathComponent("../escape.jpg").standardizedFileURL.path + ) == false) + #expect(FileManager.default.fileExists( + atPath: destRootURL.appendingPathComponent("escape.jpg").path + ) == false) + } + + @Test + func testLinkOrCopyReadableAssetFallsBackToCopyWhenHardLinkFails() throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let fileManager = LinkFailingFileManager() + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: fileManager) + try storage.ensureRootDirectory() + + let sourceURL = rootURL.appendingPathComponent("source.bin") + let destinationURL = rootURL.appendingPathComponent("nested/destination.bin") + try Data([0x01, 0x02, 0x03]).write(to: sourceURL, options: .atomic) + + try storage.linkOrCopyReadableAsset(at: sourceURL, to: destinationURL) + + #expect(FileManager.default.fileExists(atPath: destinationURL.path)) + #expect(try Data(contentsOf: destinationURL) == Data([0x01, 0x02, 0x03])) + } +} + +private final class LinkFailingFileManager: FileManager { + override func linkItem(at srcURL: URL, to dstURL: URL) throws { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteUnknownError) + } +} + +private struct TraversalTestEnvironment { + let sourceStorage: DownloadFileStorage + let destStorage: DownloadFileStorage + let sourceFolderURL: URL + let tempFolderURL: URL + let manifest: DownloadManifest +} + +private extension DownloadFileStorageRepairTests { + func setupTraversalTestEnvironment( + sourceRootURL: URL, destRootURL: URL + ) throws -> TraversalTestEnvironment { + let sourceStorage = DownloadFileStorage(rootURL: sourceRootURL, fileManager: .default) + let destStorage = DownloadFileStorage(rootURL: destRootURL, fileManager: .default) + try sourceStorage.ensureRootDirectory() + try destStorage.ensureRootDirectory() + let sourceFolderURL = sourceStorage.folderURL(relativePath: "123 - Source") + let tempFolderURL = destStorage.temporaryFolderURL(gid: "123") + try FileManager.default.createDirectory( + at: sourceFolderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, isDirectory: true + ), + withIntermediateDirectories: true + ) + let manifest = DownloadManifest( + gid: "123", host: .ehentai, token: "token", title: "Sample", jpnTitle: nil, + category: .doujinshi, language: .japanese, uploader: "Uploader", tags: [], + postedDate: .now, pageCount: 2, coverRelativePath: "cover.jpg", + galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), + rating: 4, downloadOptions: DownloadOptionsSnapshot(), versionSignature: "hash:v1", + downloadedAt: .now, + pages: [ + .init(index: 1, relativePath: "pages/0001.jpg"), + .init(index: 2, relativePath: "../escape.jpg") + ] + ) + try sourceStorage.writeManifest(manifest, folderURL: sourceFolderURL) + try Data([0xFF, 0xD8, 0xFF]).write( + to: sourceFolderURL.appendingPathComponent("cover.jpg"), options: .atomic + ) + try Data([0x01]).write( + to: sourceFolderURL.appendingPathComponent("pages/0001.jpg"), options: .atomic + ) + let escapeURL = sourceFolderURL.deletingLastPathComponent().appendingPathComponent("escape.jpg") + try Data([0x99]).write(to: escapeURL, options: .atomic) + return TraversalTestEnvironment( + sourceStorage: sourceStorage, destStorage: destStorage, + sourceFolderURL: sourceFolderURL, tempFolderURL: tempFolderURL, manifest: manifest + ) + } + + func setupRepairSourceFiles( + sourceFolderURL: URL, + storage: DownloadFileStorage, + manifest: DownloadManifest + ) throws { + try FileManager.default.createDirectory( + at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try storage.writeManifest(manifest, folderURL: sourceFolderURL) + try Data([0xFF, 0xD8, 0xFF]).write( + to: sourceFolderURL.appendingPathComponent("cover.jpg"), options: .atomic + ) + try Data([0x01]).write( + to: sourceFolderURL.appendingPathComponent("pages/0001.jpg"), options: .atomic + ) + try Data([0x03]).write( + to: sourceFolderURL.appendingPathComponent("pages/0003.jpg"), options: .atomic + ) + try FileManager.default.createDirectory( + at: sourceFolderURL.appendingPathComponent("nested", isDirectory: true), + withIntermediateDirectories: true + ) + try Data([0x09]).write( + to: sourceFolderURL.appendingPathComponent("nested/ignored.bin"), options: .atomic + ) + } + + func verifyRepairSeedResult(tempFolderURL: URL) { + #expect(FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest).path + )) + #expect(FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent("cover.jpg").path + )) + #expect(FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent("pages/0001.jpg").path + )) + #expect(FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent("pages/0002.jpg").path + ) == false) + #expect(FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent("pages/0003.jpg").path + )) + #expect(FileManager.default.fileExists( + atPath: tempFolderURL.appendingPathComponent("nested/ignored.bin").path + ) == false) + } + + func makeStorage() -> (DownloadFileStorage, URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return ( + DownloadFileStorage(rootURL: rootURL, fileManager: .default), + rootURL + ) + } + + func sampleManifest(pageCount: Int) throws -> DownloadManifest { + DownloadManifest( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: .now, + pageCount: pageCount, + coverRelativePath: "cover.jpg", + galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), + rating: 4, + downloadOptions: DownloadOptionsSnapshot(), + versionSignature: "hash:v1", + downloadedAt: .now, + pages: (1...pageCount).map { + .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") + } + ) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift new file mode 100644 index 000000000..c0b8fa0df --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift @@ -0,0 +1,75 @@ +// +// DownloadFileStorageStateTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +struct DownloadFileStorageStateTests { + @Test + func testWriteAndReadResumeState() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.temporaryFolderURL(gid: "123") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + + let resumeState = DownloadResumeState( + mode: .update, + versionSignature: "hash:v2", + pageCount: 27, + downloadOptions: .init( + threadMode: .quadruple, + allowCellular: false, + autoRetryFailedPages: false + ) + ) + try storage.writeResumeState(resumeState, folderURL: folderURL) + + #expect(try storage.readResumeState(folderURL: folderURL) == resumeState) + } + + @Test + func testWriteReadAndRemoveFailedPagesSnapshot() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.temporaryFolderURL(gid: "123") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + + let snapshot = DownloadFailedPagesSnapshot( + pages: [ + .init( + index: 3, + relativePath: "pages/0003.jpg", + failure: .init(code: .networkingFailed, message: "Network Error") + ) + ] + ) + + try storage.writeFailedPages(snapshot, folderURL: folderURL) + #expect(try storage.readFailedPages(folderURL: folderURL) == snapshot) + + try storage.removeFailedPages(folderURL: folderURL) + do { + _ = try storage.readFailedPages(folderURL: folderURL) + Issue.record("Expected readFailedPages to throw after removing the snapshot.") + } catch { + } + } +} + +private extension DownloadFileStorageStateTests { + func makeStorage() -> (DownloadFileStorage, URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return ( + DownloadFileStorage(rootURL: rootURL, fileManager: .default), + rootURL + ) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 2d08e9bb7..f002338d0 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -237,236 +237,6 @@ struct DownloadFileStorageTests { #expect(relativePath.hasSuffix(".") == false) #expect(relativePath.count <= "123 - ".count + 96) } - - @Test - func testWriteAndReadResumeState() throws { - let (storage, rootURL) = makeStorage() - defer { try? FileManager.default.removeItem(at: rootURL) } - - try storage.ensureRootDirectory() - let folderURL = storage.temporaryFolderURL(gid: "123") - try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - - let resumeState = DownloadResumeState( - mode: .update, - versionSignature: "hash:v2", - pageCount: 27, - downloadOptions: .init( - threadMode: .quadruple, - allowCellular: false, - autoRetryFailedPages: false - ) - ) - try storage.writeResumeState(resumeState, folderURL: folderURL) - - #expect(try storage.readResumeState(folderURL: folderURL) == resumeState) - } - - @Test - func testWriteReadAndRemoveFailedPagesSnapshot() throws { - let (storage, rootURL) = makeStorage() - defer { try? FileManager.default.removeItem(at: rootURL) } - - try storage.ensureRootDirectory() - let folderURL = storage.temporaryFolderURL(gid: "123") - try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - - let snapshot = DownloadFailedPagesSnapshot( - pages: [ - .init( - index: 3, - relativePath: "pages/0003.jpg", - failure: .init(code: .networkingFailed, message: "Network Error") - ) - ] - ) - - try storage.writeFailedPages(snapshot, folderURL: folderURL) - #expect(try storage.readFailedPages(folderURL: folderURL) == snapshot) - - try storage.removeFailedPages(folderURL: folderURL) - do { - _ = try storage.readFailedPages(folderURL: folderURL) - Issue.record("Expected readFailedPages to throw after removing the snapshot.") - } catch { - } - } - - @Test - func testMaterializeRepairSeedCopiesOnlyManifestCoverAndExistingPageFiles() throws { - let (storage, rootURL) = makeStorage() - defer { try? FileManager.default.removeItem(at: rootURL) } - - try storage.ensureRootDirectory() - let sourceFolderURL = storage.folderURL(relativePath: "123 - Source") - let tempFolderURL = storage.temporaryFolderURL(gid: "123") - try FileManager.default.createDirectory( - at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let manifest = try sampleManifest(pageCount: 3) - try storage.writeManifest(manifest, folderURL: sourceFolderURL) - try Data([0xFF, 0xD8, 0xFF]).write( - to: sourceFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - try Data([0x01]).write( - to: sourceFolderURL.appendingPathComponent("pages/0001.jpg"), - options: .atomic - ) - try Data([0x03]).write( - to: sourceFolderURL.appendingPathComponent("pages/0003.jpg"), - options: .atomic - ) - try FileManager.default.createDirectory( - at: sourceFolderURL.appendingPathComponent("nested", isDirectory: true), - withIntermediateDirectories: true - ) - try Data([0x09]).write( - to: sourceFolderURL.appendingPathComponent("nested/ignored.bin"), - options: .atomic - ) - - try storage.materializeRepairSeed( - from: sourceFolderURL, - manifest: manifest, - to: tempFolderURL - ) - - #expect( - FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest).path - ) - ) - #expect( - FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent("cover.jpg").path - ) - ) - #expect( - FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent("pages/0001.jpg").path - ) - ) - #expect( - FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent("pages/0002.jpg").path - ) == false - ) - #expect( - FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent("pages/0003.jpg").path - ) - ) - #expect( - FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent("nested/ignored.bin").path - ) == false - ) - } - - @Test - func testMaterializeRepairSeedRejectsTraversalPathsInManifestPages() throws { - let sourceRootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - let destRootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { - try? FileManager.default.removeItem(at: sourceRootURL) - try? FileManager.default.removeItem(at: destRootURL) - } - - let sourceStorage = DownloadFileStorage(rootURL: sourceRootURL, fileManager: .default) - let destStorage = DownloadFileStorage(rootURL: destRootURL, fileManager: .default) - try sourceStorage.ensureRootDirectory() - try destStorage.ensureRootDirectory() - - let sourceFolderURL = sourceStorage.folderURL(relativePath: "123 - Source") - let tempFolderURL = destStorage.temporaryFolderURL(gid: "123") - try FileManager.default.createDirectory( - at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - - let manifest = DownloadManifest( - gid: "123", - host: .ehentai, - token: "token", - title: "Sample", - jpnTitle: nil, - category: .doujinshi, - language: .japanese, - uploader: "Uploader", - tags: [], - postedDate: .now, - pageCount: 2, - coverRelativePath: "cover.jpg", - galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), - rating: 4, - downloadOptions: DownloadOptionsSnapshot(), - versionSignature: "hash:v1", - downloadedAt: .now, - pages: [ - .init(index: 1, relativePath: "pages/0001.jpg"), - .init(index: 2, relativePath: "../escape.jpg") - ] - ) - try sourceStorage.writeManifest(manifest, folderURL: sourceFolderURL) - try Data([0xFF, 0xD8, 0xFF]).write( - to: sourceFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - try Data([0x01]).write( - to: sourceFolderURL.appendingPathComponent("pages/0001.jpg"), - options: .atomic - ) - let escapeURL = sourceFolderURL.deletingLastPathComponent() - .appendingPathComponent("escape.jpg") - try Data([0x99]).write(to: escapeURL, options: .atomic) - - try destStorage.materializeRepairSeed( - from: sourceFolderURL, - manifest: manifest, - to: tempFolderURL - ) - - #expect( - FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent("pages/0001.jpg").path - ) - ) - #expect( - FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent("../escape.jpg") - .standardizedFileURL.path - ) == false - ) - #expect( - FileManager.default.fileExists( - atPath: destRootURL.appendingPathComponent("escape.jpg").path - ) == false - ) - } - - @Test - func testLinkOrCopyReadableAssetFallsBackToCopyWhenHardLinkFails() throws { - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let fileManager = LinkFailingFileManager() - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: fileManager) - try storage.ensureRootDirectory() - - let sourceURL = rootURL.appendingPathComponent("source.bin") - let destinationURL = rootURL.appendingPathComponent("nested/destination.bin") - try Data([0x01, 0x02, 0x03]).write(to: sourceURL, options: .atomic) - - try storage.linkOrCopyReadableAsset(at: sourceURL, to: destinationURL) - - #expect(FileManager.default.fileExists(atPath: destinationURL.path)) - #expect(try Data(contentsOf: destinationURL) == Data([0x01, 0x02, 0x03])) - } } private final class ThrowingAttributesFileManager: FileManager { @@ -485,12 +255,6 @@ private final class ThrowingAttributesFileManager: FileManager { } } -private final class LinkFailingFileManager: FileManager { - override func linkItem(at srcURL: URL, to dstURL: URL) throws { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteUnknownError) - } -} - private extension DownloadFileStorageTests { func makeStorage() -> (DownloadFileStorage, URL) { let rootURL = FileManager.default.temporaryDirectory diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift new file mode 100644 index 000000000..3ebffb375 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -0,0 +1,204 @@ +// +// DownloadFilterAndBadgeTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { + @Test + func testDownloadsFilterMatchesKeywordAndStatus() { + let activeDownload = sampleDownload( + gid: "101", + title: "Alpha Archive", + status: .downloading, + completedPageCount: 2 + ) + let completedDownload = sampleDownload( + gid: "202", + title: "Beta Collection", + status: .completed + ) + + var state = DownloadsReducer.State() + state.downloads = [activeDownload, completedDownload] + state.filter = .active + state.keyword = "alpha" + + #expect(state.filteredDownloads == [activeDownload]) + } + + @Test + func testQueuedRetryWorkAppearsAsActiveDownloadBadge() { + let queuedRedownload = sampleDownload( + gid: "303", + title: "Gamma Archive", + status: .completed, + completedPageCount: 12, + pendingOperation: .redownload + ) + + #expect(queuedRedownload.pendingOperation == .redownload) + #expect(queuedRedownload.badge == .queued) + #expect(queuedRedownload.matches(filter: .active)) + } + + @Test + func testQueuedRepairWorkAppearsAsActiveDownloadBadge() { + let queuedRepair = sampleDownload( + gid: "404", + title: "Broken Archive", + status: .missingFiles, + completedPageCount: 3, + pendingOperation: .repair + ) + + #expect(queuedRepair.pendingOperation == .repair) + #expect(queuedRepair.badge == .queued) + #expect(queuedRepair.matches(filter: .active)) + } + + @Test + func testQueuedUpdateWorkAppearsAsActiveDownloadBadge() { + let queuedUpdate = sampleDownload( + gid: "414", + title: "Updated Archive", + status: .updateAvailable, + completedPageCount: 12, + latestRemoteVersionSignature: "hash:v2", + pendingOperation: .update + ) + + #expect(queuedUpdate.pendingOperation == .update) + #expect(queuedUpdate.badge == .queued) + #expect(queuedUpdate.matches(filter: .active)) + #expect(queuedUpdate.matches(filter: .update) == false) + } + + @Test + func testQueuedResumedUpdateDoesNotPretendToBeInitialWork() { + let resumedUpdate = sampleDownload( + gid: "415", + title: "Resumed Update", + status: .queued, + pageCount: 26, + completedPageCount: 7, + latestRemoteVersionSignature: "hash:v2" + ) + + #expect(resumedUpdate.pendingOperation == nil) + #expect(resumedUpdate.isQueuedWorkItem) + #expect(resumedUpdate.badge == .queued) + #expect(resumedUpdate.matches(filter: .active)) + } + + @Test + func testPausedDownloadAppearsAsActiveBadge() { + let pausedDownload = sampleDownload( + gid: "455", + title: "Paused Archive", + status: .paused, + pageCount: 12, + completedPageCount: 4 + ) + + #expect(pausedDownload.badge == .paused(4, 12)) + #expect(pausedDownload.matches(filter: .active)) + } + + @Test + func testActiveDownloadsDoNotExposeUpdateActions() { + let downloadingUpdate = sampleDownload( + gid: "456", + title: "Downloading Update", + status: .downloading, + completedPageCount: 5, + latestRemoteVersionSignature: "hash:v2" + ) + let pausedUpdate = sampleDownload( + gid: "457", + title: "Paused Update", + status: .paused, + completedPageCount: 5, + latestRemoteVersionSignature: "hash:v2" + ) + let completedUpdate = sampleDownload( + gid: "458", + title: "Completed Update", + status: .completed, + latestRemoteVersionSignature: "hash:v2" + ) + + #expect(downloadingUpdate.canTriggerUpdate == false) + #expect(pausedUpdate.canTriggerUpdate == false) + #expect(completedUpdate.canTriggerUpdate) + } + + @Test + func testDownloadsFilterMatchesGalleryFilterCriteria() { + let qualifyingDownload = sampleDownload( + gid: "466", + title: "Chinese Archive", + status: .completed, + pageCount: 28 + ) + let filteredOutDownload = sampleDownload( + gid: "477", + title: "Low Rated Archive", + status: .completed, + pageCount: 8 + ) + + var state = DownloadsReducer.State() + state.downloads = [ + qualifyingDownload, + filteredOutDownload + ] + state.galleryFilter.minimumRatingActivated = true + state.galleryFilter.minimumRating = 4 + state.galleryFilter.pageRangeActivated = true + state.galleryFilter.pageLowerBound = "20" + state.galleryFilter.pageUpperBound = "40" + + #expect(state.filteredDownloads == [qualifyingDownload]) + } + + @Test + func testDownloadsFilterExcludesSelectedCategoriesLikeSearchFilter() { + let nonHDownload = sampleDownload( + gid: "478", + title: "Healthy Archive", + status: .completed, + category: .nonH + ) + let mangaDownload = sampleDownload( + gid: "479", + title: "Comic Archive", + status: .completed, + category: .manga + ) + + var state = DownloadsReducer.State() + state.downloads = [nonHDownload, mangaDownload] + state.galleryFilter.excludedCategories = [.nonH] + + #expect(state.filteredDownloads == [mangaDownload]) + } + + @Test + func testPartialDownloadBadgeUsesNeedsAttentionCopy() { + let partialDownload = sampleDownload( + gid: "480", + title: "Incomplete Archive", + status: .partial, + pageCount: 12, + completedPageCount: 5 + ) + + #expect(partialDownload.badge.text == "Needs Attention 5/12") + #expect(DownloadListFilter.failed.title == "Needs Attention") + } +} diff --git a/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift b/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift new file mode 100644 index 000000000..37ff2a1cc --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift @@ -0,0 +1,147 @@ +// +// DownloadImageErrorTests.swift +// EhPandaTests +// + +import CoreData +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadImageErrorTests: DownloadFeatureTestCase { + @Test + func testFileBasedInvalidPageMapsToNotFound() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let invalidPageData = Data(""" +

Invalid page

Gallery not found

+ """.utf8) + try invalidPageData.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let galleryURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) + let response = try makeResponse( + url: galleryURL, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: galleryURL + ) + + #expect(error == .notFound) + } + + @Test + func testFileBasedKeepTryingMapsToNotFound() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let keepTryingData = Data( + "

Keep trying

".utf8 + ) + try keepTryingData.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let pageURL = try #require(URL(string: "https://e-hentai.org/s/1/1-1")) + let response = try makeResponse( + url: pageURL, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: pageURL + ) + + #expect(error == .notFound) + } + + @Test + func testFileBasedHTTP404MapsToNotFound() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("txt") + defer { try? FileManager.default.removeItem(at: fileURL) } + + try Data("Not here".utf8).write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let notFoundURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) + let response = try makeResponse( + url: notFoundURL, + statusCode: 404, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: notFoundURL + ) + + #expect(error == .notFound) + } + + @Test + func testFileBased404GalleryNotAvailableFallsBackToNotFound() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let galleryNotAvailableData = Data(""" + + Gallery Not Available +

Gallery Not Available

+ + """.utf8) + try galleryNotAvailableData.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let galleryURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) + let response = try makeResponse( + url: galleryURL, + statusCode: 404, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: galleryURL + ) + + #expect(error == .notFound) + } + + @Test + func testFileBasedHTMLBanPageStillParsesThroughParserInsteadOfParseFailed() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .ipBanned) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadManager() + let bannedURL = try #require(URL(string: "https://example.com/banned")) + let response = try makeResponse( + url: bannedURL, + contentType: "text/html; charset=utf-8" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: bannedURL + ) + + #expect(error != .parseFailed) + guard case .ipBanned = error else { + Issue.record("Expected ipBanned, got \(String(describing: error))") + return + } + } + +} diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift new file mode 100644 index 000000000..1cf936701 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -0,0 +1,130 @@ +// +// DownloadImageParsingCacheTests.swift +// EhPandaTests +// + +import CoreData +import Kingfisher +import UIKit +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { + func testCachedKokomadePlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { + let container = try makeInMemoryContainer() + let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 33) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + let normalImageURL = try #require( + URL(string: "https://exhentai.org/fullimg.php?gid=\(gid)&page=1&key=normal-cache-key") + ) + try insertPersistedGalleryState(in: container, gid: gid, imageURLs: [1: normalImageURL]) + + let imageData = try fixtureData(resource: "Kokomade", pathExtension: "jpg") + let cacheKeys = normalImageURL.imageCacheKeys(includeStableAlias: true) + for cacheKey in cacheKeys { + try await KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) + } + defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } + await waitUntilCacheReady(for: cacheKeys) + + let payload = try makeExhentaiPayload(gid: gid) + let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) + let restoredPageURL = storage.temporaryFolderURL(gid: gid) + .appendingPathComponent("pages/0001.jpg") + + #expect(restoredCount == 0) + #expect(FileManager.default.fileExists(atPath: restoredPageURL.path) == false) + } + + @Test + func testFileBasedEmptyExResponseMapsToAuthenticationRequired() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .exLoginRequired) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let cookieClient = CookieClient.live + cookieClient.clearAll() + defer { cookieClient.clearAll() } + cookieClient.setOrEditCookie( + for: Defaults.URL.exhentai, + key: Defaults.Cookie.yay, + value: "louder" + ) + + let manager = makeTestingDownloadManager() + let response = try makeResponse( + url: Defaults.URL.exhentai, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://exhentai.org/g/1/1/") + ) + + #expect(error == .authenticationRequired) + } + + @Test + func testFileBasedAuthHTMLMarkersMapToAuthenticationRequired() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let authHTMLData = Data(""" + + + Login + +

Access to ExHentai.org is restricted.

+ + + """.utf8) + try authHTMLData.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let response = try makeResponse( + url: Defaults.URL.exhentai, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://exhentai.org/g/1/1/") + ) + + #expect(error == .authenticationRequired) + } + +} + +// MARK: - Payload Factory + +private extension DownloadImageParsingCacheTests { + func makeExhentaiPayload(gid: String) throws -> DownloadRequestPayload { + DownloadRequestPayload( + gallery: Gallery( + gid: gid, token: "token", title: "Auth Placeholder", rating: 4, + tags: [], category: .doujinshi, uploader: "Uploader", pageCount: 1, postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + galleryURL: try #require(URL(string: "https://exhentai.org/g/\(gid)/token") as URL?) + ), + galleryDetail: GalleryDetail( + gid: gid, title: "Auth Placeholder", jpnTitle: nil, + isFavorited: false, visibility: .yes, rating: 4, userRating: 0, ratingCount: 0, + category: .doujinshi, language: .japanese, uploader: "Uploader", postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + favoritedCount: 0, pageCount: 1, sizeCount: 12, sizeType: "MB", torrentCount: 0 + ), + previewURLs: [:], previewConfig: .normal(rows: 4), + host: .exhentai, options: DownloadOptionsSnapshot(), mode: .initial + ) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift new file mode 100644 index 000000000..567f76f0a --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift @@ -0,0 +1,214 @@ +// +// DownloadImageParsingTests.swift +// EhPandaTests +// + +import CoreData +import Kingfisher +import UIKit +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadImageParsingTests: DownloadFeatureTestCase { + func testFileBasedQuotaImageMapsToQuotaExceeded() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadManager() + let quotaImageURL = try #require(URL(string: "https://ehgt.org/g/509.gif")) + let response = try makeResponse( + url: quotaImageURL, + contentType: "image/gif", + contentLength: 28658 + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: quotaImageURL + ) + + #expect(error == .quotaExceeded) + } + + @Test + func testFileBasedQuotaImageRequiresKnown509Signature() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadManager() + var data = try Data(contentsOf: fileURL) + data[0] = 0 + try data.write(to: fileURL, options: .atomic) + let quotaImageURL = try #require(URL(string: "https://ehgt.org/g/509.gif")) + let response = try makeResponse( + url: quotaImageURL, + contentType: "image/gif", + contentLength: data.count + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: quotaImageURL + ) + + #expect(error == nil) + } + + @Test + func testFileBasedBinaryKokomadeImageMapsToAuthenticationRequired() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("gif") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let imageData = try #require(Data(base64Encoded: "R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")) + try imageData.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let kokomadeURL = try #require(URL(string: "https://exhentai.org/img/kokomade.jpg")) + let response = try makeResponse( + url: kokomadeURL, + contentType: "image/gif", + contentLength: imageData.count + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1") + ) + + #expect(error == .authenticationRequired) + } + + @Test + func testFileBasedQuotaImageFingerprintMapsToQuotaExceededEvenWhenURLLooksNormal() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadManager() + let normalImageURL = try #require(URL(string: "https://ehgt.org/h/normal-image-cache-key/1")) + let response = try makeResponse( + url: normalImageURL, + contentType: "image/gif", + contentLength: 28658 + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: normalImageURL + ) + + #expect(error == .quotaExceeded) + } + + @Test + func testFileBasedKokomadeImageFingerprintMapsToAuthenticationRequiredEvenWhenURLLooksNormal() async throws { + let fileURL = try writeFixtureToTemporaryFile(resource: "Kokomade", pathExtension: "jpg") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadManager() + let normalImageURL = try #require( + URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1&key=normal-cache-key") + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: try makeResponse( + url: normalImageURL, + contentType: "image/jpeg", + contentLength: 144844 + ), + requestURL: normalImageURL + ) + + #expect(error == .authenticationRequired) + } + + @Test + func testFileBasedTextImageLimitMapsToQuotaExceeded() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let htmlData = Data(""" + You have exceeded your image viewing limits + """.utf8) + try htmlData.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadManager() + let quotaURL = try #require(URL(string: "https://e-hentai.org/s/1/1-1")) + let response = try makeResponse( + url: quotaURL, + contentType: "text/html" + ) + let error = await manager.testingDetectResponseError( + fileURL: fileURL, + response: response, + requestURL: quotaURL + ) + + #expect(error == .quotaExceeded) + } + + @MainActor + @Test + func testCachedQuotaPlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { + let container = try makeInMemoryContainer() + let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 32) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + let normalImageURL = try #require( + URL(string: "https://ehgt.org/h/quota-placeholder-cache-\(gid)/1") + ) + try insertPersistedGalleryState(in: container, gid: gid, imageURLs: [1: normalImageURL]) + + let placeholderURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) + defer { try? FileManager.default.removeItem(at: placeholderURL) } + let placeholderData = try Data(contentsOf: placeholderURL) + let cacheKeys = normalImageURL.imageCacheKeys(includeStableAlias: true) + for cacheKey in cacheKeys { + try await KingfisherManager.shared.cache.storeToDisk(placeholderData, forKey: cacheKey) + } + defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } + await waitUntilCacheReady(for: cacheKeys) + + let payload = makeEhentaiPayload(gid: gid) + let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) + let restoredPageURL = storage.temporaryFolderURL(gid: gid) + .appendingPathComponent("pages/0001.gif") + + #expect(restoredCount == 0) + #expect(FileManager.default.fileExists(atPath: restoredPageURL.path) == false) + } + +} + +// MARK: - Payload Factory + +private extension DownloadImageParsingTests { + func makeEhentaiPayload(gid: String) -> DownloadRequestPayload { + DownloadRequestPayload( + gallery: Gallery( + gid: gid, token: "token", title: "Quota Placeholder", rating: 4, + tags: [], category: .doujinshi, uploader: "Uploader", pageCount: 1, postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token") + ), + galleryDetail: GalleryDetail( + gid: gid, title: "Quota Placeholder", jpnTitle: nil, + isFavorited: false, visibility: .yes, rating: 4, userRating: 0, ratingCount: 0, + category: .doujinshi, language: .japanese, uploader: "Uploader", postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + favoritedCount: 0, pageCount: 1, sizeCount: 12, sizeType: "MB", torrentCount: 0 + ), + previewURLs: [:], previewConfig: .normal(rows: 4), + host: .ehentai, options: DownloadOptionsSnapshot(), mode: .initial + ) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift new file mode 100644 index 000000000..51987698c --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -0,0 +1,153 @@ +// +// DownloadInspectorLoadTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadInspectorLoadTests: DownloadFeatureTestCase { + @Test + func testDownloadInspectorReducerLoadsInspection() async { + let download = sampleDownload( + gid: "246810", title: "Inspector Gallery", + status: .failed, completedPageCount: 1 + ) + let inspection = sampleInspection(download: download) + let store = makeInspectorStore( + gid: download.gid, + loadInspection: { _ in .success(inspection) } + ) + store.exhaustivity = .off + + await store.send(.loadInspection) + await store.receive(\.loadInspectionDone) { + $0.inspection = inspection + $0.stableInspection = inspection + $0.loadingState = .idle + } + } + + @MainActor + @Test + func testDownloadInspectorReducerRetryPageUsesDownloadClientRetryPages() async { + await confirmation(expectedCount: 1) { confirm in + let retried = UncheckedBox<[Int]>([]) + let download = sampleDownload( + gid: "112233", title: "Retry Page Gallery", + status: .failed, completedPageCount: 1 + ) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = sampleInspection(download: download) + initialState.loadingState = .idle + let store = makeInspectorStore( + gid: download.gid, + initialInspection: initialState.inspection, + retryPages: { _, pageIndices in + retried.value = pageIndices + confirm() + return .success(()) + }, + loadInspection: { [initialState] _ in + guard let inspection = initialState.inspection else { + return .failure(.notFound) + } + return .success(inspection) + } + ) + store.exhaustivity = .off + + await store.send(.retryPage(2)) + #expect(retried.value == [2]) + } + } + + @MainActor + @Test + func testDownloadInspectorReducerRetryFailedPagesMarksFailedPagesPending() async { + let retried = UncheckedBox<[Int]>([]) + let download = sampleDownload( + gid: "112235", title: "Retry Failed Pages Gallery", + status: .partial, completedPageCount: 1 + ) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = sampleInspection(download: download) + initialState.loadingState = .idle + let store = makeInspectorStore( + gid: download.gid, + initialInspection: initialState.inspection, + retryPages: { _, pageIndices in + retried.value = pageIndices + return .success(()) + }, + loadInspection: { [initialState] _ in + guard let inspection = initialState.inspection else { + return .failure(.notFound) + } + return .success(inspection) + } + ) + store.exhaustivity = .off + + await store.send(.retryFailedPages) { + guard let inspection = $0.inspection else { return } + $0.inspection = .init( + download: inspection.download, + coverURL: inspection.coverURL, + pages: [ + .init( + index: 1, status: .downloaded, relativePath: "pages/0001.jpg", + fileURL: URL(fileURLWithPath: "/tmp/0001.jpg"), failure: nil + ), + .init( + index: 2, status: .pending, relativePath: "pages/0002.jpg", + fileURL: nil, failure: nil + ) + ] + ) + } + + #expect(retried.value == [2]) + } + +} + +// MARK: - Store Factory Helpers + +private extension DownloadInspectorLoadTests { + func makeInspectorStore( + gid: String, + initialInspection: DownloadInspection? = nil, + retryPages: (@Sendable (String, [Int]) async -> Result)? = nil, + loadInspection: @escaping @Sendable (String) async -> Result + ) -> TestStoreOf { + var initialState = DownloadInspectorReducer.State(gid: gid) + initialState.inspection = initialInspection + if initialInspection != nil { initialState.loadingState = .idle } + return TestStore(initialState: initialState) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: retryPages ?? { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: loadInspection + ) + } + } +} diff --git a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift new file mode 100644 index 000000000..a14f105e4 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -0,0 +1,154 @@ +// +// DownloadInspectorRetryTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadInspectorRetryTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDownloadInspectorKeepsRetriedPagesPendingWhileRetryWorkRemainsActive() async { + let download = sampleDownload( + gid: "112236", title: "Retry Pending Gallery", + status: .partial, completedPageCount: 1 + ) + let refreshedInspection = sampleInspection(download: download) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = sampleInspection(download: download) + initialState.stableInspection = sampleInspection(download: download) + initialState.retryingPageIndices = [2] + initialState.loadingState = .idle + + let store = makeRetryTestStore( + initialState: initialState, + loadInspection: { _ in .success(refreshedInspection) } + ) + store.exhaustivity = .off + + await store.send(.loadInspection) + let requestID = store.state.inspectionRequestID + await store.send(.loadInspectionDone(requestID, .success(refreshedInspection))) { + $0.inspection = .init( + download: download, coverURL: refreshedInspection.coverURL, + pages: [ + refreshedInspection.pages[0], + .init( + index: 2, status: .pending, relativePath: "pages/0002.jpg", + fileURL: nil, failure: nil + ) + ] + ) + $0.loadingState = .idle + $0.retryingPageIndices = [2] + } + } + + @MainActor + @Test + func testDownloadInspectorClearsRetryingPagesAfterRetrySettlesWithFailure() async { + let initialDownload = sampleDownload( + gid: "112237", title: "Retry Failure Gallery", + status: .partial, completedPageCount: 1 + ) + let settledDownload = sampleDownload( + gid: "112237", title: "Retry Failure Gallery", status: .partial, + completedPageCount: 1, lastError: .init(code: .networkingFailed, message: "Network Error") + ) + let settledInspection = sampleInspection(download: settledDownload) + var initialState = DownloadInspectorReducer.State(gid: initialDownload.gid) + initialState.inspection = sampleInspection(download: initialDownload) + initialState.stableInspection = sampleInspection(download: initialDownload) + initialState.retryingPageIndices = [2] + initialState.loadingState = .idle + + let store = makeRetryTestStore( + initialState: initialState, + loadInspection: { _ in .success(settledInspection) } + ) + store.exhaustivity = .off + + await store.send(.loadInspection) + let requestID = store.state.inspectionRequestID + await store.send(.loadInspectionDone(requestID, .success(settledInspection))) { + $0.inspection = settledInspection + $0.stableInspection = settledInspection + $0.loadingState = .idle + $0.retryingPageIndices = [] + } + } + + @MainActor + @Test + func testDownloadInspectorRestoresStableInspectionWhenRetryReloadFails() async { + let download = sampleDownload( + gid: "112238", title: "Retry Reload Failure Gallery", + status: .partial, completedPageCount: 1 + ) + let stableInspection = sampleInspection(download: download) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = .init( + download: download, coverURL: stableInspection.coverURL, + pages: [ + stableInspection.pages[0], + .init( + index: 2, status: .pending, relativePath: "pages/0002.jpg", + fileURL: nil, failure: nil + ) + ] + ) + initialState.stableInspection = stableInspection + initialState.retryingPageIndices = [2] + initialState.loadingState = .idle + + let store = makeRetryTestStore( + initialState: initialState, + loadInspection: { _ in .failure(.networkingFailed) } + ) + store.exhaustivity = .off + + let requestID = store.state.inspectionRequestID + await store.send(.loadInspectionDone(requestID, .failure(.networkingFailed))) { + $0.inspection = stableInspection + $0.loadingState = .failed(.networkingFailed) + $0.retryingPageIndices = [] + } + } + +} + +// MARK: - Store Factory Helpers + +private extension DownloadInspectorRetryTests { + func makeRetryTestStore( + initialState: DownloadInspectorReducer.State, + loadInspection: @escaping @Sendable (String) async -> Result + ) -> TestStoreOf { + TestStore(initialState: initialState) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: loadInspection + ) + } + } +} diff --git a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift new file mode 100644 index 000000000..69dfa9603 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift @@ -0,0 +1,100 @@ +// +// DownloadInspectorSkipTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadInspectorSkipTests: DownloadFeatureTestCase { + @Test + func testDownloadInspectorSkipsReloadWhenObservedDownloadDidNotChange() async { + let download = sampleDownload( + gid: "112244", + title: "Stable Inspector Gallery", + status: .partial, + completedPageCount: 1 + ) + let inspection = sampleInspection(download: download) + let loadInspectionCount = UncheckedBox(0) + + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = inspection + initialState.loadingState = .idle + + let store = TestStore(initialState: initialState) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in + loadInspectionCount.value += 1 + return .success(inspection) + } + ) + } + store.exhaustivity = .off + + await store.send(.observeDownloadsDone([download])) + #expect(loadInspectionCount.value == 0) + } + + @MainActor + @Test + func testDownloadInspectorIgnoresStaleInspectionResponses() async { + let originalDownload = sampleDownload( + gid: "112245", + title: "Stale Inspector Gallery", + status: .partial, + completedPageCount: 1 + ) + let refreshedDownload = sampleDownload( + gid: "112245", + title: "Stale Inspector Gallery", + status: .partial, + completedPageCount: 2 + ) + let staleInspection = sampleInspection(download: originalDownload) + let refreshedInspection = sampleInspection(download: refreshedDownload) + + let firstRequestID = UUID() + let secondRequestID = UUID() + var initialState = DownloadInspectorReducer.State(gid: originalDownload.gid) + initialState.loadingState = .loading + initialState.inspectionRequestID = secondRequestID + + let store = TestStore(initialState: initialState) { + DownloadInspectorReducer() + } + store.exhaustivity = .off + + await store.send(.loadInspectionDone(firstRequestID, .success(staleInspection))) + #expect(store.state.inspection == nil) + + await store.send(.loadInspectionDone(secondRequestID, .success(refreshedInspection))) { + $0.inspection = refreshedInspection + $0.stableInspection = refreshedInspection + $0.loadingState = .idle + } + } + +} diff --git a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift new file mode 100644 index 000000000..c1fbc302b --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift @@ -0,0 +1,68 @@ +// +// DownloadIpBanTests.swift +// EhPandaTests +// + +import CoreData +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadIpBanTests: DownloadFeatureTestCase { + @Test + func testIpBannedDoesNotRetryImmediately() async throws { + let sessionID = UUID().uuidString + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] + let manager = DownloadManager( + storage: DownloadFileStorage( + rootURL: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true), + fileManager: .default + ), + urlSession: URLSession(configuration: configuration) + ) + let recorder = RequestRecorder() + let ipBannedHTML = try fixtureData(resource: HTMLFilename.ipBanned.rawValue, pathExtension: "html") + let fallbackBannedURL = try #require(URL(string: "https://example.com/banned")) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + recorder.recordDetail() + return ( + try #require(HTTPURLResponse( + url: request.url ?? fallbackBannedURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )), + ipBannedHTML + ) + } + defer { + SharedSessionStubURLProtocol.removeHandler(for: sessionID) + } + + let download = sampleDownload( + gid: "123456", + title: "Banned Gallery", + status: .partial + ) + + do { + _ = try await manager.testingFetchLatestPayload( + for: download, + mode: .redownload + ) + Issue.record("Expected ipBanned error") + } catch let error as AppError { + guard case .ipBanned = error else { + Issue.record("Expected ipBanned, got \(error)") + return + } + } + + #expect(recorder.snapshot().detailRequests == 1) + } + +} diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift new file mode 100644 index 000000000..ca58e96b3 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -0,0 +1,142 @@ +// +// DownloadManagerCaptureTests.swift +// EhPandaTests +// + +import CoreData +import Kingfisher +import UIKit +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadManagerCaptureTests: DownloadFeatureTestCase { + @Test + func testDownloadManagerCaptureCachedPageRestoresTemporaryPageAndUpdatesCompletedCount() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 27) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + try insertPersistedDownload( + in: container, + gid: gid, + status: .downloading, + completedPageCount: 0, + pageCount: 2 + ) + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + + let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg")) + let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemBlue.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try #require(image.jpegData(compressionQuality: 1)) + let cacheKey = try #require(imageURL.stableImageCacheKey) + try await KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) + defer { + KingfisherManager.shared.cache.removeImage(forKey: cacheKey) + KingfisherManager.shared.cache.removeImage(forKey: imageURL.absoluteString) + } + + await manager.captureCachedPage( + gid: gid, + index: 1, + imageURL: imageURL + ) + + let stored = await manager.testingFetchDownload(gid: gid) + #expect(stored?.completedPageCount == 1) + + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + #expect(pageURLs[1] == temporaryFolderURL.appendingPathComponent("pages/0001.jpg")) + } + + @MainActor + @Test + func testDownloadManagerCaptureCachedPageRepairsCompletedDownloadWithLatestRemoteImage() async throws { + let container = try makeInMemoryContainer() + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 28) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + try insertPersistedDownload( + in: container, gid: gid, status: .missingFiles, completedPageCount: 1, pageCount: 2, + lastError: .init(code: .fileOperationFailed, message: "Page 1 is missing.") + ) + + let completedFolderURL = try setupCaptureMissingFilesFolder( + rootURL: rootURL, gid: gid + ) + let (imageURL, cacheKey) = try await setupCaptureCachedImage() + defer { + KingfisherManager.shared.cache.removeImage(forKey: cacheKey) + KingfisherManager.shared.cache.removeImage(forKey: imageURL.absoluteString) + } + + await manager.captureCachedPage(gid: gid, index: 1, imageURL: imageURL) + + let stored = await manager.testingFetchDownload(gid: gid) + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + + #expect(stored?.status == .completed) + #expect(stored?.completedPageCount == 2) + #expect(stored?.lastError == nil) + #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("pages/0001.jpg")) + } + +} + +// MARK: - Setup Helpers + +private extension DownloadManagerCaptureTests { + func setupCaptureMissingFilesFolder(rootURL: URL, gid: String) throws -> URL { + let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let manifest = try sampleManifest(gid: gid, title: "Pause Race") + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("cover.jpg"), options: .atomic + ) + try Data([0x02]).write( + to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), options: .atomic + ) + return completedFolderURL + } + + @MainActor + func setupCaptureCachedImage() async throws -> (URL, String) { + let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg")) + let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemOrange.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try #require(image.jpegData(compressionQuality: 1)) + let cacheKey = try #require(imageURL.stableImageCacheKey) + try await KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) + return (imageURL, cacheKey) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift new file mode 100644 index 000000000..2b7dd61e2 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -0,0 +1,197 @@ +// +// DownloadManagerRepairSeedTests.swift +// EhPandaTests +// + +import CoreData +import Kingfisher +import UIKit +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { + @Test + func testRepairSeedRejectsOldCompletedVersionWhenGalleryUpdatedButPageCountMatches() async throws { + let gid = "repair-seed-\(UUID().uuidString)" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + try storage.ensureRootDirectory() + + let existingDownload = sampleDownload( + gid: gid, title: "Mixed Version", status: .missingFiles, + pageCount: 2, completedPageCount: 2, + remoteVersionSignature: "hash:v1", + latestRemoteVersionSignature: "hash:v2" + ) + try setupRepairSeedFiles(storage: storage, rootURL: rootURL, gid: gid) + + let payload = makeRepairSeedPayload(gid: gid) + let workingSeed = try await manager.testingPrepareWorkingSeed( + payload: payload, existingDownload: existingDownload, + versionSignature: "hash:v2" + ) + + #expect(workingSeed.manifest == nil) + #expect(workingSeed.existingPages.isEmpty) + #expect(workingSeed.coverRelativePath == nil) + #expect( + FileManager.default.fileExists( + atPath: workingSeed.folderURL.appendingPathComponent("pages/0001.jpg").path + ) == false + ) + #expect( + FileManager.default.fileExists( + atPath: workingSeed.folderURL.appendingPathComponent("pages/0002.jpg").path + ) == false + ) + } + + @Test + func testDownloadManagerLoadLocalPageURLsMarksCompletedDownloadMissingFilesWhenZeroBytePageIsFound() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 13) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + + try insertPersistedDownload( + in: container, gid: gid, status: .completed, + completedPageCount: 2, pageCount: 2 + ) + + let (emptyPageURL, goodPageURL) = try setupZeroBytePageFiles( + rootURL: rootURL, gid: gid, storage: storage + ) + + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + let stored = await manager.testingFetchDownload(gid: gid) + + #expect(pageURLs[1] == nil) + #expect(pageURLs[2] == goodPageURL) + #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) + #expect(stored?.status == .missingFiles) + #expect(stored?.completedPageCount == 1) + } + + @MainActor + @Test + func testImageClientFetchImageUsesStableAliasCacheKey() async throws { + let url = try #require( + URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") + ) + let stableCacheKey = try #require(url.stableImageCacheKey) + let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemRed.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try #require(image.pngData()) + + try await KingfisherManager.shared.cache.store(image, original: imageData, forKey: stableCacheKey) + defer { + KingfisherManager.shared.cache.removeImage(forKey: stableCacheKey) + KingfisherManager.shared.cache.removeImage(forKey: url.absoluteString) + } + + let result = await ImageClient.live.fetchImage(url: url) + let fetchedImage = try result.get() + + #expect(fetchedImage.size == image.size) + } + +} + +// MARK: - Repair Seed Helpers + +private extension DownloadManagerRepairSeedTests { + func setupRepairSeedFiles( + storage: DownloadFileStorage, rootURL: URL, gid: String + ) throws { + let completedFolderURL = rootURL.appendingPathComponent( + "\(gid) - Mixed Version", isDirectory: true + ) + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, isDirectory: true + ), + withIntermediateDirectories: true + ) + let oldManifest = try sampleManifest( + gid: gid, title: "Mixed Version", + pageCount: 2, versionSignature: "hash:v1" + ) + try JSONEncoder().encode(oldManifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("cover.jpg"), options: .atomic + ) + try Data([0x01]).write( + to: completedFolderURL.appendingPathComponent("pages/0001.jpg"), options: .atomic + ) + try Data([0x02]).write( + to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), options: .atomic + ) + } + + func makeRepairSeedPayload(gid: String) -> DownloadRequestPayload { + DownloadRequestPayload( + gallery: Gallery( + gid: gid, token: "token", title: "Mixed Version", + rating: 4, tags: [], category: .doujinshi, + uploader: "Uploader", pageCount: 2, postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token") + ), + galleryDetail: GalleryDetail( + gid: gid, title: "Mixed Version", jpnTitle: nil, + isFavorited: false, visibility: .yes, + rating: 4, userRating: 0, ratingCount: 1, + category: .doujinshi, language: .japanese, + uploader: "Uploader", postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + favoritedCount: 0, pageCount: 2, + sizeCount: 1, sizeType: "MB", torrentCount: 0 + ), + previewURLs: [:], previewConfig: .normal(rows: 4), + host: .ehentai, options: .init(), mode: .repair + ) + } + + func setupZeroBytePageFiles( + rootURL: URL, gid: String, storage: DownloadFileStorage + ) throws -> (URL, URL) { + let completedFolderURL = rootURL.appendingPathComponent( + "\(gid) - Pause Race", isDirectory: true + ) + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, isDirectory: true + ), + withIntermediateDirectories: true + ) + let manifest = try sampleManifest(gid: gid, title: "Pause Race") + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("cover.jpg"), options: .atomic + ) + let emptyPageURL = completedFolderURL.appendingPathComponent("pages/0001.jpg") + try Data().write(to: emptyPageURL, options: .atomic) + let goodPageURL = completedFolderURL.appendingPathComponent("pages/0002.jpg") + try Data([0x02]).write(to: goodPageURL, options: .atomic) + return (emptyPageURL, goodPageURL) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift new file mode 100644 index 000000000..59926cd2e --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -0,0 +1,189 @@ +// +// DownloadManagerStorageTests.swift +// EhPandaTests +// + +import CoreData +import Kingfisher +import UIKit +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadManagerStorageTests: DownloadFeatureTestCase { + @Test + func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000)) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let manager = DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: .shared + ) + + try insertPersistedDownload( + in: container, + gid: gid, + status: .failed, + completedPageCount: 1, + pageCount: 2 + ) + + let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try Data([0x01]).write( + to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try JSONEncoder().encode( + DownloadFailedPagesSnapshot( + pages: [ + .init( + index: 2, + relativePath: "pages/0002.jpg", + failure: .init(code: .networkingFailed, message: "Network Error") + ) + ] + ) + ) + .write( + to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadFailedPages), + options: .atomic + ) + + let result = await manager.loadInspection(gid: gid) + let inspection = try result.get() + + #expect(inspection.pages[0].status == .downloaded) + #expect(inspection.pages[1].status == .failed) + #expect(inspection.pages[1].failure?.code == .networkingFailed) + } + + @Test + func testDownloadManagerLoadLocalPageURLsPrefersCompletedFolderForCompletedDownload() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 11) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + + try insertPersistedDownload( + in: container, + gid: gid, + status: .completed, + completedPageCount: 2, + pageCount: 2 + ) + + let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let manifest = try sampleManifest(gid: gid, title: "Pause Race") + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + let completedPageURL = completedFolderURL.appendingPathComponent("pages/0001.jpg") + try Data([0x01]).write(to: completedPageURL, options: .atomic) + try Data([0x02]).write( + to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let temporaryPageURL = temporaryFolderURL.appendingPathComponent("pages/0001.jpg") + try Data([0x02]).write(to: temporaryPageURL, options: .atomic) + + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + + #expect(pageURLs[1] == completedPageURL) + #expect(pageURLs[1] != temporaryPageURL) + #expect(pageURLs[3] == nil) + } + + @Test + func testDownloadManagerLoadLocalPageURLsMergesReadableCompletedPagesWithTemporaryPages() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 12) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + + try insertPersistedDownload( + in: container, + gid: gid, + status: .downloading, + completedPageCount: 2, + pageCount: 2 + ) + + let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let manifest = try sampleManifest(gid: gid, title: "Pause Race") + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([0x01]).write( + to: completedFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try Data([0x09]).write( + to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let temporaryPageURL = temporaryFolderURL.appendingPathComponent("pages/0002.jpg") + try Data([0x02]).write(to: temporaryPageURL, options: .atomic) + + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + + #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("pages/0001.jpg")) + #expect(pageURLs[2] == temporaryPageURL) + } + +} diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift new file mode 100644 index 000000000..0363636f7 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -0,0 +1,160 @@ +// +// DownloadObserverBatchTests.swift +// EhPandaTests +// + +import Foundation +import CoreData +import ComposableArchitecture +import Kingfisher +import UIKit +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadObserverBatchTests: DownloadFeatureTestCase { + @Test + func testDownloadInspectorClearsInspectionWhenObservedDownloadDisappears() async { + let download = sampleDownload( + gid: "9988", + title: "Observed Archive", + status: .completed + ) + let inspection = sampleInspection(download: download) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = inspection + initialState.stableInspection = inspection + initialState.retryingPageIndices = [2] + initialState.loadingState = .idle + + let store = TestStore(initialState: initialState) { + DownloadInspectorReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.yield([download]) + continuation.yield([]) + continuation.finish() + } + }, + fetchDownloads: { [download] }, + fetchDownload: { gid in gid == download.gid ? download : nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in .success(inspection) } + ) + } + store.exhaustivity = .off + + await store.send(.observeDownloads) + await store.receive(\.observeDownloadsDone, [download]) + await store.receive(\.observeDownloadsDone, []) { + $0.inspection = nil + $0.stableInspection = nil + $0.loadingState = .idle + $0.retryingPageIndices = [] + } + } + + @MainActor + @Test + func testDownloadManagerBatchesObserverUpdatesDuringCachedPageRestore() async throws { + let container = try makeInMemoryContainer() + let pageCount = 20 + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 104) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) + try insertPersistedDownload( + in: container, gid: gid, status: .downloading, completedPageCount: 0, pageCount: pageCount + ) + + let cacheKeys = try await setupBatchRestoreCachedImages( + container: container, gid: gid, pageCount: pageCount + ) + defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } + await waitUntilCacheReady(for: cacheKeys) + + let observationStream = await manager.observeDownloads() + let emissionTask = Task { + var emissionCount = 0 + for await downloads in observationStream { + guard let relevantDownload = downloads.first(where: { $0.gid == gid }) else { continue } + emissionCount += 1 + if relevantDownload.completedPageCount == pageCount { break } + } + return emissionCount + } + + let payload = try makeBatchRestorePayload(gid: gid, pageCount: pageCount) + let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) + let emissionCount = try await waitForTaskValue( + emissionTask, + timeout: .seconds(2), + description: "observer updates for cached page restore" + ) + let stored = await manager.testingFetchDownload(gid: gid) + + #expect(restoredCount == pageCount) + #expect(stored?.completedPageCount == pageCount) + #expect(emissionCount < pageCount) + #expect(emissionCount <= 1 + Int(ceil(Double(pageCount) / 8.0))) + } +} + +// MARK: - Setup Helpers + +private extension DownloadObserverBatchTests { + @MainActor + func setupBatchRestoreCachedImages( + container: NSPersistentContainer, + gid: String, + pageCount: Int + ) async throws -> Set { + let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemTeal.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try #require(cachedImage.jpegData(compressionQuality: 1)) + let imageURLs = try Dictionary(uniqueKeysWithValues: (1...pageCount).map { index in + (index, try #require(URL(string: "https://example.com/pages/\(gid)-\(index).jpg"))) + }) + try insertPersistedGalleryState(in: container, gid: gid, imageURLs: imageURLs) + let cacheKeys = Set(imageURLs.values.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) + for cacheKey in cacheKeys { + try await KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) + } + return cacheKeys + } + + func makeBatchRestorePayload(gid: String, pageCount: Int) throws -> DownloadRequestPayload { + DownloadRequestPayload( + gallery: Gallery( + gid: gid, token: "token", title: "Cached Restore Gallery", rating: 4, + tags: [], category: .doujinshi, uploader: "Uploader", pageCount: pageCount, + postedDate: .now, coverURL: URL(string: "https://example.com/cover.jpg"), + galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token") as URL?) + ), + galleryDetail: GalleryDetail( + gid: gid, title: "Cached Restore Gallery", jpnTitle: nil, + isFavorited: false, visibility: .yes, rating: 4, userRating: 0, ratingCount: 0, + category: .doujinshi, language: .japanese, uploader: "Uploader", postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + favoritedCount: 0, pageCount: pageCount, sizeCount: 12, sizeType: "MB", torrentCount: 0 + ), + previewURLs: [:], previewConfig: .normal(rows: 4), + host: .ehentai, options: DownloadOptionsSnapshot(), mode: .initial + ) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift new file mode 100644 index 000000000..6ea828202 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -0,0 +1,225 @@ +// +// DownloadObserverReadingTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadObserverReadingTests: DownloadFeatureTestCase { + @Test + func testReadingReducerLocalSourceWithoutGalleryStateDoesNotStayLoading() async throws { + let download = sampleDownload( + gid: "700001", title: "Offline Gallery", status: .completed, pageCount: 2, completedPageCount: 2 + ) + let manifest = try sampleManifest(gid: download.gid, title: download.title) + let store = TestStore( + initialState: ReadingReducer.State(contentSource: .local(download, manifest)) + ) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .noop + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + let folderURL = download.folderURL ?? FileManager.default.temporaryDirectory + .appendingPathComponent(download.folderRelativePath, isDirectory: true) + + await store.send(.fetchDatabaseInfos(download.gid)) { + $0.gallery = download.gallery + $0.galleryDetail = GalleryDetail( + gid: download.gid, title: download.title, jpnTitle: download.jpnTitle, + isFavorited: false, visibility: .yes, rating: download.rating, + userRating: 0, ratingCount: 0, category: download.category, + language: manifest.language, uploader: download.uploader ?? "", + postedDate: download.postedDate, coverURL: download.coverURL, + favoritedCount: 0, pageCount: download.pageCount, sizeCount: 0, sizeType: "", + torrentCount: 0 + ) + $0.localPageURLs = [ + 1: folderURL.appendingPathComponent("pages/0001.jpg"), + 2: folderURL.appendingPathComponent("pages/0002.jpg") + ] + $0.previewConfig = .normal(rows: 4) + $0.previewURLs = $0.localPageURLs + $0.thumbnailURLs = $0.localPageURLs + $0.imageURLs = $0.localPageURLs + $0.originalImageURLs = $0.localPageURLs + $0.databaseLoadingState = .idle + } + await store.finish() + + #expect(store.state.databaseLoadingState == .idle) + #expect(store.state.readingProgress == 0) + } + + @MainActor + @Test + func testReadingReducerDoesNotReloadLocalPagesWhenOnlyOtherGalleryChanges() async { + let gallery = sampleGallery() + let relevantDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .completed) + let otherDownload = sampleDownload(gid: "900001", title: "Other Gallery", status: .queued) + let updatedOtherDownload = sampleDownload( + gid: otherDownload.gid, title: otherDownload.title, + status: .downloading, pageCount: 12, completedPageCount: 4 + ) + let (stream, continuation) = makeObserverStream() + let loadCount = UncheckedBox(0) + + var initialState = ReadingReducer.State(contentSource: .remote) + initialState.gallery = gallery + + let store = makeReadingStoreWithLoadCount( + initialState: initialState, stream: stream, + expectedGID: gallery.gid, loadCount: loadCount + ) + + await store.send(.observeDownloads(gallery.gid)) + continuation.yield([relevantDownload, otherDownload]) + await store.receive(\.observeDownloadsDone, [relevantDownload]) + await store.receive(\.loadLocalPageURLs, gallery.gid) + await store.receive(\.loadLocalPageURLsDone) + #expect(loadCount.value == 1) + + continuation.yield([relevantDownload, updatedOtherDownload]) + try? await Task.sleep(for: .milliseconds(50)) + #expect(loadCount.value == 1) + + continuation.finish() + await store.finish() + } + + @MainActor + @Test + func testPreviewsReducerDoesNotReloadLocalPreviewsWhenOnlyOtherGalleryChanges() async { + let gallery = sampleGallery() + let relevantDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .completed) + let otherDownload = sampleDownload(gid: "900002", title: "Other Preview Gallery", status: .queued) + let updatedOtherDownload = sampleDownload( + gid: otherDownload.gid, title: otherDownload.title, + status: .paused, pageCount: 12, completedPageCount: 2 + ) + let (stream, continuation) = makeObserverStream() + let loadCount = UncheckedBox(0) + + var initialState = PreviewsReducer.State() + initialState.gallery = gallery + + let store = makePreviewsStoreWithLoadCount( + initialState: initialState, stream: stream, + expectedGID: gallery.gid, loadCount: loadCount + ) + + await store.send(.observeDownloads(gallery.gid)) + continuation.yield([relevantDownload, otherDownload]) + await store.receive(\.observeDownloadsDone, [relevantDownload]) + await store.receive(\.loadLocalPreviewURLs, gallery.gid) + await store.receive(\.loadLocalPreviewURLsDone) + #expect(loadCount.value == 1) + + continuation.yield([relevantDownload, updatedOtherDownload]) + try? await Task.sleep(for: .milliseconds(50)) + #expect(loadCount.value == 1) + + continuation.finish() + await store.finish() + } + +} + +// MARK: - Store Factory Helpers + +private extension DownloadObserverReadingTests { + func makeObserverStream() + -> (AsyncStream<[DownloadedGallery]>, AsyncStream<[DownloadedGallery]>.Continuation) { + var continuation: AsyncStream<[DownloadedGallery]>.Continuation! + let stream = AsyncStream<[DownloadedGallery]> { continuation = $0 } + return (stream, continuation) + } + + func makeReadingStoreWithLoadCount( + initialState: ReadingReducer.State, + stream: AsyncStream<[DownloadedGallery]>, + expectedGID: String, + loadCount: UncheckedBox + ) -> TestStoreOf { + let store = TestStore(initialState: initialState) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { stream }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { gid in + #expect(gid == expectedGID) + loadCount.value += 1 + return .success([:]) + } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + return store + } + + func makePreviewsStoreWithLoadCount( + initialState: PreviewsReducer.State, + stream: AsyncStream<[DownloadedGallery]>, + expectedGID: String, + loadCount: UncheckedBox + ) -> TestStoreOf { + let store = TestStore(initialState: initialState) { + PreviewsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { stream }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { gid in + #expect(gid == expectedGID) + loadCount.value += 1 + return .success([:]) + } + ) + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + store.exhaustivity = .off + return store + } +} diff --git a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift new file mode 100644 index 000000000..9acec5c64 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -0,0 +1,156 @@ +// +// DownloadObserverRefreshTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadObserverRefreshTests: DownloadFeatureTestCase { + @Test + func testReadingReducerEmitsOneFinalRefreshWhenRelevantDownloadDisappears() async { + let gallery = sampleGallery() + let relevantDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .completed) + let (stream, continuation) = makeObserverStream() + let loadCount = UncheckedBox(0) + + var initialState = ReadingReducer.State(contentSource: .remote) + initialState.gallery = gallery + + let store = makeReadingObserverStore( + initialState: initialState, + stream: stream, + loadLocalPageURLs: { _ in + loadCount.value += 1 + return .success([:]) + } + ) + + await store.send(.observeDownloads(gallery.gid)) + continuation.yield([relevantDownload]) + await store.receive(\.observeDownloadsDone, [relevantDownload]) + await store.receive(\.loadLocalPageURLs, gallery.gid) + await store.receive(\.loadLocalPageURLsDone) + + continuation.yield([]) + await store.receive(\.observeDownloadsDone, []) + await store.receive(\.loadLocalPageURLs, gallery.gid) + await store.receive(\.loadLocalPageURLsDone) + + #expect(loadCount.value == 2) + continuation.finish() + await store.finish() + } + + @Test + func testPreviewsReducerEmitsOneFinalRefreshWhenRelevantDownloadDisappears() async { + let gallery = sampleGallery() + let relevantDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .completed) + let (stream, continuation) = makeObserverStream() + let loadCount = UncheckedBox(0) + + var initialState = PreviewsReducer.State() + initialState.gallery = gallery + + let store = makePreviewsObserverStore( + initialState: initialState, + stream: stream, + loadLocalPageURLs: { _ in + loadCount.value += 1 + return .success([:]) + } + ) + + await store.send(.observeDownloads(gallery.gid)) + continuation.yield([relevantDownload]) + await store.receive(\.observeDownloadsDone, [relevantDownload]) + await store.receive(\.loadLocalPreviewURLs, gallery.gid) + await store.receive(\.loadLocalPreviewURLsDone) + + continuation.yield([]) + await store.receive(\.observeDownloadsDone, []) + await store.receive(\.loadLocalPreviewURLs, gallery.gid) + await store.receive(\.loadLocalPreviewURLsDone) + + #expect(loadCount.value == 2) + continuation.finish() + await store.finish() + } + +} + +// MARK: - Store Factory Helpers + +private extension DownloadObserverRefreshTests { + func makeObserverStream() -> (AsyncStream<[DownloadedGallery]>, AsyncStream<[DownloadedGallery]>.Continuation) { + var continuation: AsyncStream<[DownloadedGallery]>.Continuation! + let stream = AsyncStream<[DownloadedGallery]> { continuation = $0 } + return (stream, continuation) + } + + func makeReadingObserverStore( + initialState: ReadingReducer.State, + stream: AsyncStream<[DownloadedGallery]>, + loadLocalPageURLs: @escaping @Sendable (String) async -> Result<[Int: URL], AppError> + ) -> TestStoreOf { + let store = TestStore(initialState: initialState) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = makeObserveDownloadClient( + stream: stream, loadLocalPageURLs: loadLocalPageURLs + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + return store + } + + func makePreviewsObserverStore( + initialState: PreviewsReducer.State, + stream: AsyncStream<[DownloadedGallery]>, + loadLocalPageURLs: @escaping @Sendable (String) async -> Result<[Int: URL], AppError> + ) -> TestStoreOf { + let store = TestStore(initialState: initialState) { + PreviewsReducer() + } withDependencies: { + $0.downloadClient = makeObserveDownloadClient( + stream: stream, loadLocalPageURLs: loadLocalPageURLs + ) + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + store.exhaustivity = .off + return store + } + + func makeObserveDownloadClient( + stream: AsyncStream<[DownloadedGallery]>, + loadLocalPageURLs: @escaping @Sendable (String) async -> Result<[Int: URL], AppError> + ) -> DownloadClient { + .init( + observeDownloads: { stream }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: loadLocalPageURLs + ) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift new file mode 100644 index 000000000..b77f80756 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -0,0 +1,259 @@ +// +// DownloadPauseAndReconcileTests.swift +// EhPandaTests +// + +import Foundation +import CoreData +import ComposableArchitecture +import Kingfisher +import UIKit +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { + @Test + func testQuickSearchWordUsesNameWhenContentIsEmpty() { + let word = QuickSearchWord(name: "artist:hossy", content: "") + + #expect(word.effectiveSearchText == "artist:hossy") + } + + @Test + func testPauseKeepsActiveDownloadPausedWhenDeferredSchedulingRuns() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000)) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FailFastURLProtocol.self] + let manager = DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: URLSession(configuration: configuration) + ) + + try insertPersistedDownload( + in: container, + gid: gid, + status: .downloading, + completedPageCount: 7 + ) + + let activeTask = Task { [manager] in + do { + try await Task.sleep(for: .seconds(60)) + } catch is CancellationError { + await manager.testingScheduleNextIfNeeded() + } catch {} + } + await manager.testingInstallActiveTask(gid: gid, task: activeTask) + + let result = await manager.togglePause(gid: gid) + + guard case .success = result else { + Issue.record("Pause should succeed, got \(result)") + return + } + + try await Task.sleep(for: .milliseconds(100)) + + let stored = await manager.testingFetchDownload(gid: gid) + let activeGalleryID = await manager.testingActiveGalleryID() + #expect(stored?.status == .paused) + #expect(stored?.badge == .paused(7, 26)) + #expect(activeGalleryID == nil) + } + + @Test + func testPauseUsesTemporaryWorkingSetProgressWhenCancelling() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 1) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FailFastURLProtocol.self] + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + + try insertPersistedDownload( + in: container, + gid: gid, + status: .downloading, + completedPageCount: 1, + pageCount: 2 + ) + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try Data([0x01]).write( + to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: temporaryFolderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) + + let activeTask = Task { [manager] in + do { + try await Task.sleep(for: .seconds(60)) + } catch is CancellationError { + await manager.testingScheduleNextIfNeeded() + } catch {} + } + await manager.testingInstallActiveTask(gid: gid, task: activeTask) + + let result = await manager.togglePause(gid: gid) + + guard case .success = result else { + Issue.record("Pause should succeed, got \(result)") + return + } + + let stored = await manager.testingFetchDownload(gid: gid) + #expect(stored?.status == .paused) + #expect(stored?.completedPageCount == 2) + #expect(stored?.badge == .paused(2, 2)) + } + + @Test + func testReconcileDownloadsNormalizesLegacyFailedStatusToNeedsAttention() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FailFastURLProtocol.self] + let manager = DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: URLSession(configuration: configuration) + ) + + try insertPersistedDownload( + in: container, + gid: gid, + status: .failed, + completedPageCount: 0, + pageCount: 18 + ) + + await manager.reconcileDownloads() + + let stored = await manager.testingFetchDownload(gid: gid) + #expect(stored?.status == .partial) + #expect(stored?.badge == .partial(0, 18)) + } + + @Test + func testReconcileDownloadsClearsCancellationLikeGalleryError() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 3) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FailFastURLProtocol.self] + let manager = DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: URLSession(configuration: configuration) + ) + + try insertPersistedDownload( + in: container, + gid: gid, + status: .partial, + completedPageCount: 4, + pageCount: 18, + lastError: .init( + code: .fileOperationFailed, + message: "The operation could not be completed. (Swift.CancellationError error 1.)" + ) + ) + + await manager.reconcileDownloads() + + let stored = await manager.testingFetchDownload(gid: gid) + #expect(stored?.lastError == nil) + #expect(stored?.status == .partial) + } + + @Test + func testLoadInspectionFiltersCancellationFailuresIntoPendingPages() async throws { + let container = try makeInMemoryContainer() + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 4) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FailFastURLProtocol.self] + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: URLSession(configuration: configuration)) + try insertPersistedDownload( + in: container, gid: gid, status: .partial, completedPageCount: 1, pageCount: 2 + ) + let temporaryFolderURL = try setupCancellationFilterTestFolder(storage: storage, gid: gid) + + let result = await manager.loadInspection(gid: gid) + guard case .success(let inspection) = result else { + Issue.record("Expected inspection to load successfully, got \(result)") + return + } + + #expect(inspection.pages[0].status == .downloaded) + #expect(inspection.pages[1].status == .pending) + #expect((try? storage.readFailedPages(folderURL: temporaryFolderURL).pages.isEmpty) ?? true) + } +} + +// MARK: - Setup Helpers + +private extension DownloadPauseAndReconcileTests { + @discardableResult + func setupCancellationFilterTestFolder( + storage: DownloadFileStorage, + gid: String + ) throws -> URL { + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try Data([0x01]).write( + to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try storage.writeFailedPages( + .init(pages: [ + .init( + index: 2, + relativePath: "pages/0002.jpg", + failure: .init( + code: .fileOperationFailed, + message: "The operation could not be completed. (Swift.CancellationError error 1.)" + ) + ) + ]), + folderURL: temporaryFolderURL + ) + return temporaryFolderURL + } +} diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift new file mode 100644 index 000000000..8af1422f2 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -0,0 +1,299 @@ +// +// DownloadProcessCacheTests.swift +// EhPandaTests +// + +import CoreData +import Kingfisher +import UIKit +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadProcessCacheTests: DownloadFeatureTestCase { + @MainActor + @Test + func testProcessDownloadClearsRemoteAssetCacheAfterSuccessfulDownload() async throws { + let container = try makeInMemoryContainer() + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 402) + let pageIndex = 42 + let oldVersionSignature = try #require( + DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") + ) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let cacheTestManager = try makeCacheTestManager( + rootURL: rootURL, sessionID: sessionID, gid: gid, pageIndex: pageIndex + ) + let storage = cacheTestManager.storage + let manager = cacheTestManager.manager + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let (cachedKeys, coverURL) = try await prepareCacheTestAssets( + manager: manager, gid: gid, + pageIndex: pageIndex, oldVersionSignature: oldVersionSignature + ) + defer { cachedKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } + + await waitUntilCacheReady(for: cachedKeys) + + let updatedPageCount = try await setupCacheTestDownload( + container: container, storage: storage, gid: gid, + pageIndex: pageIndex, oldVersionSignature: oldVersionSignature + ) + + await manager.testingProcessDownload(gid: gid) + + let completedDownload = await manager.testingFetchDownload(gid: gid) + #expect(completedDownload?.status == .completed) + + try await waitUntilCacheCleared(cachedKeys: cachedKeys) + + for cacheKey in cachedKeys { + #expect( + KingfisherManager.shared.cache.isCached(forKey: cacheKey) == false, + "Expected cache key to be removed after successful download: \(cacheKey)" + ) + } + _ = updatedPageCount + } + +} + +// MARK: - Cache Test Manager Result + +struct CacheTestManagerResult { + let storage: DownloadFileStorage + let manager: DownloadManager + let metadataResponse: Data +} + +// MARK: - Cache Test Helpers + +private extension DownloadProcessCacheTests { + func makeCacheTestManager( + rootURL: URL, sessionID: String, gid: String, pageIndex: Int + ) throws -> CacheTestManagerResult { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: URLSession(configuration: configuration)) + let content = StubHandlerContent( + detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), + mpvHTML: try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html"), + metadataResponse: try makeMetadataResponseData(gid: gid) + ) + installCacheTestStubHandler( + sessionID: sessionID, gid: gid, pageIndex: pageIndex, + content: content, allowedImageURLs: [] + ) + URLProtocol.registerClass(SharedSessionStubURLProtocol.self) + return CacheTestManagerResult(storage: storage, manager: manager, metadataResponse: content.metadataResponse) + } + + func installCacheTestStubHandler( + sessionID: String, gid: String, pageIndex: Int, + content: StubHandlerContent, + allowedImageURLs: Set + ) { + let detailHTML = content.detailHTML + let mpvHTML = content.mpvHTML + let metadataResponse = content.metadataResponse + let currentPageImageURL = URL(string: "https://example.com/image-\(pageIndex).jpg") + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path.contains("/g/\(gid)/token") { + return (try Self.makeCacheHTMLResponse(url: url), detailHTML) + } + if url.path.contains("/mpv/") { + return (try Self.makeCacheHTMLResponse(url: url), mpvHTML) + } + if url.path == "/api.php" { + return try Self.makeCacheAPIResponse( + url: url, request: request, + metadataResponse: metadataResponse, + imageURLString: currentPageImageURL?.absoluteString ?? "" + ) + } + if url.host == "example.com" || allowedImageURLs.contains(url.absoluteString) { + return (try Self.makeCacheImageResponse(url: url), Data([0xFF, 0xD8, 0xFF, 0xD9])) + } + throw URLError(.unsupportedURL) + } + } + + static func makeCacheHTMLResponse(url: URL) throws -> HTTPURLResponse { + try #require(HTTPURLResponse( + url: url, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )) + } + + static func makeCacheImageResponse(url: URL) throws -> HTTPURLResponse { + try #require(HTTPURLResponse( + url: url, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": "image/jpeg"] + )) + } + + static func makeCacheJSONResponse(url: URL) throws -> HTTPURLResponse { + try #require(HTTPURLResponse( + url: url, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )) + } + + static func makeCacheAPIResponse( + url: URL, request: URLRequest, + metadataResponse: Data, imageURLString: String + ) throws -> (HTTPURLResponse, Data) { + let body = requestBodyData(from: request) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + if body?["method"] as? String == "gdata" { + return (try makeCacheJSONResponse(url: url), metadataResponse) + } + let responseData = try JSONSerialization.data(withJSONObject: ["i": imageURLString]) + return (try makeCacheJSONResponse(url: url), responseData) + } + + @MainActor + func prepareCacheTestAssets( + manager: DownloadManager, gid: String, + pageIndex: Int, oldVersionSignature: String + ) async throws -> (Set, URL) { + let currentPageImageURL = try #require( + URL(string: "https://example.com/image-\(pageIndex).jpg") + ) + let staleStoredPageURL = try #require( + URL(string: "https://example.com/stale-image-\(gid)-1.jpg") + ) + let plainPreviewURL = try #require( + URL(string: "https://ehgt.org/preview/\(gid)/1.webp") + ) + let combinedPreviewURL = URLUtil.combinedPreviewURL( + plainURL: plainPreviewURL, width: "200", height: "300", offset: "40" + ) + + let scaffoldDownload = sampleDownload( + gid: gid, title: "Pause Race", status: .partial, + pageCount: 156, completedPageCount: 155, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: oldVersionSignature + ) + let latestPayload = try await manager.testingFetchLatestPayload( + for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] + ).payload + let coverURL = try #require( + latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL + ) + + let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { ctx in + UIColor.systemTeal.setFill() + ctx.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let cachedImageData = try #require(cachedImage.jpegData(compressionQuality: 1)) + let cachedURLs = combinedPreviewURL.previewCacheCleanupURLs() + + [currentPageImageURL, staleStoredPageURL, coverURL] + let cachedKeys = Set(cachedURLs.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) + for cacheKey in cachedKeys { + try await KingfisherManager.shared.cache.storeToDisk(cachedImageData, forKey: cacheKey) + } + return (cachedKeys, coverURL) + } + + func setupCacheTestDownload( + container: NSPersistentContainer, storage: DownloadFileStorage, + gid: String, pageIndex: Int, oldVersionSignature: String + ) async throws -> Int { + let staleStoredPageURL = try #require( + URL(string: "https://example.com/stale-image-\(gid)-1.jpg") + ) + let plainPreviewURL = try #require(URL(string: "https://ehgt.org/preview/\(gid)/1.webp")) + let combinedPreviewURL = URLUtil.combinedPreviewURL( + plainURL: plainPreviewURL, width: "200", height: "300", offset: "40" + ) + let scaffoldDownload = sampleDownload( + gid: gid, title: "Pause Race", status: .partial, + pageCount: 156, completedPageCount: 155, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: oldVersionSignature + ) + let latestPayload = try await DownloadManager( + storage: storage, urlSession: .shared + ).testingFetchLatestPayload( + for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] + ).payload + let updatedPageCount = latestPayload.galleryDetail.pageCount + let oldPageCount = updatedPageCount - 5 + #expect(updatedPageCount > pageIndex) + #expect(oldPageCount > 0) + + try insertPersistedDownload( + in: container, gid: gid, status: .partial, + completedPageCount: oldPageCount - 1, pageCount: oldPageCount, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: oldVersionSignature + ) + try insertPersistedGalleryState( + in: container, gid: gid, + previewURLs: [1: combinedPreviewURL], imageURLs: [1: staleStoredPageURL] + ) + try setupCacheTestTemporaryFolder( + storage: storage, gid: gid, + pageIndex: pageIndex, oldPageCount: oldPageCount, + oldVersionSignature: oldVersionSignature + ) + return updatedPageCount + } + + func setupCacheTestTemporaryFolder( + storage: DownloadFileStorage, gid: String, + pageIndex: Int, oldPageCount: Int, oldVersionSignature: String + ) throws { + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, isDirectory: true + ), + withIntermediateDirectories: true + ) + let staleManifest = try sampleManifest( + gid: gid, title: "Pause Race", + pageCount: oldPageCount, versionSignature: oldVersionSignature + ) + try JSONEncoder().encode(staleManifest).write( + to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: temporaryFolderURL.appendingPathComponent("cover.jpg"), options: .atomic + ) + try Data([UInt8(pageIndex % 255)]).write( + to: temporaryFolderURL.appendingPathComponent( + "pages/\(String(format: "%04d", pageIndex)).jpg" + ), + options: .atomic + ) + try storage.writeResumeState( + .init( + mode: .redownload, versionSignature: oldVersionSignature, + pageCount: oldPageCount, downloadOptions: .init(), pageSelection: [pageIndex] + ), + folderURL: temporaryFolderURL + ) + } + + func waitUntilCacheCleared(cachedKeys: Set) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while cachedKeys.contains(where: { KingfisherManager.shared.cache.isCached(forKey: $0) }), + clock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + } +} diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift new file mode 100644 index 000000000..21f2c71a3 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -0,0 +1,163 @@ +// +// DownloadProcessTests.swift +// EhPandaTests +// + +import CoreData +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadProcessTests: DownloadFeatureTestCase { + @Test + func testProcessDownloadClearsStalePageSelectionWhenLatestPayloadRevealsUpdate() async throws { + let container = try makeInMemoryContainer() + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 401) + let pageIndex = 42 + let oldVersionSignature = try #require( + DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") + ) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, manager) = makeStubbedDownloadManager(rootURL: rootURL, sessionID: sessionID) + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let (updatedPageCount, updatedVersionSignature) = try await fetchAndInstallStub( + manager: manager, sessionID: sessionID, gid: gid, + pageIndex: pageIndex, oldVersionSignature: oldVersionSignature + ) + let oldPageCount = updatedPageCount - 5 + + try insertPersistedDownload( + in: container, gid: gid, status: .partial, + completedPageCount: oldPageCount - 1, pageCount: oldPageCount, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: oldVersionSignature + ) + let beforeProcess = await manager.testingFetchDownload(gid: gid) + #expect(beforeProcess?.hasUpdate ?? true == false) + + let temporaryFolderURL = try prepareStaleTemporaryFolder( + storage: storage, gid: gid, pageIndex: pageIndex, + oldPageCount: oldPageCount, oldVersionSignature: oldVersionSignature + ) + + await manager.testingProcessDownload(gid: gid) + + try await verifyCompletedProcess( + manager: manager, storage: storage, + context: ProcessVerificationContext( + gid: gid, + updatedPageCount: updatedPageCount, + updatedVersionSignature: updatedVersionSignature, + temporaryFolderURL: temporaryFolderURL + ) + ) + } +} + +private struct ProcessVerificationContext { + let gid: String + let updatedPageCount: Int + let updatedVersionSignature: String + let temporaryFolderURL: URL +} + +private extension DownloadProcessTests { + func fetchAndInstallStub( + manager: DownloadManager, sessionID: String, gid: String, + pageIndex: Int, oldVersionSignature: String + ) async throws -> (Int, String) { + let stubContent = StubHandlerContent( + detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), + mpvHTML: try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html"), + metadataResponse: try makeMetadataResponseData(gid: gid) + ) + var allowedImageURLs = Set() + installDownloadStubHandler( + sessionID: sessionID, gid: gid, pageIndex: pageIndex, + content: stubContent, allowedImageURLs: allowedImageURLs + ) + let scaffoldDownload = sampleDownload( + gid: gid, title: "Pause Race", status: .partial, + pageCount: 156, completedPageCount: 155, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: oldVersionSignature + ) + let fetchResult = try await manager.testingFetchLatestPayload( + for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] + ) + let latestPayload = fetchResult.payload + let updatedVersionSignature = fetchResult.versionSignature + if let coverURL = latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL { + allowedImageURLs.insert(coverURL.absoluteString) + installDownloadStubHandler( + sessionID: sessionID, gid: gid, pageIndex: pageIndex, + content: stubContent, allowedImageURLs: allowedImageURLs + ) + } + let updatedPageCount = latestPayload.galleryDetail.pageCount + #expect(updatedPageCount > pageIndex) + #expect(updatedPageCount > 5) + return (updatedPageCount, updatedVersionSignature) + } + + func prepareStaleTemporaryFolder( + storage: DownloadFileStorage, gid: String, pageIndex: Int, + oldPageCount: Int, oldVersionSignature: String + ) throws -> URL { + let staleManifest = try sampleManifest( + gid: gid, title: "Pause Race", + pageCount: oldPageCount, versionSignature: oldVersionSignature + ) + try writeTemporaryManifestAndPages( + storage: storage, gid: gid, manifest: staleManifest, + pageCount: 0, versionSignature: oldVersionSignature, + pageSelection: [pageIndex] + ) + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try Data([UInt8(pageIndex % 255)]).write( + to: temporaryFolderURL.appendingPathComponent( + "pages/\(String(format: "%04d", pageIndex)).jpg" + ), + options: .atomic + ) + return temporaryFolderURL + } + + func verifyCompletedProcess( + manager: DownloadManager, + storage: DownloadFileStorage, + context: ProcessVerificationContext + ) async throws { + let completedDownload = await manager.testingFetchDownload(gid: context.gid) + let unwrapped = try #require(completedDownload) + #expect(unwrapped.status == .completed) + #expect(unwrapped.pageCount == context.updatedPageCount) + #expect(unwrapped.completedPageCount == context.updatedPageCount) + #expect(unwrapped.remoteVersionSignature == context.updatedVersionSignature) + #expect(unwrapped.latestRemoteVersionSignature == context.updatedVersionSignature) + + let completedFolderURL = storage.folderURL(relativePath: unwrapped.folderRelativePath) + let manifest = try storage.readManifest(folderURL: completedFolderURL) + #expect(manifest.versionSignature == context.updatedVersionSignature) + #expect(manifest.pageCount == context.updatedPageCount) + #expect(manifest.pages.count == context.updatedPageCount) + #expect( + FileManager.default.fileExists( + atPath: completedFolderURL.appendingPathComponent("pages/0001.jpg").path + ) + ) + + let resumeState = try storage.readResumeState(folderURL: completedFolderURL) + #expect(resumeState.mode == .redownload) + #expect(resumeState.versionSignature == context.updatedVersionSignature) + #expect(resumeState.pageCount == context.updatedPageCount) + #expect(resumeState.pageSelection == nil) + #expect(FileManager.default.fileExists(atPath: context.temporaryFolderURL.path) == false) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift new file mode 100644 index 000000000..0bd1ca4d0 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -0,0 +1,110 @@ +// +// DownloadRetryMinimalSourceTests.swift +// EhPandaTests +// + +import CoreData +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { + @Test + func testRetryPagesUsesMinimalSourceResolutionAndSkipsWhenNoPendingPages() async throws { + let container = try makeInMemoryContainer() + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 200) + let pageIndex = 42 + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, manager) = makeStubbedDownloadManager(rootURL: rootURL, sessionID: sessionID) + let setup = try await setupMinimalSourceTest( + manager: manager, sessionID: sessionID, gid: gid, pageIndex: pageIndex + ) + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let manifest = try sampleManifest( + gid: gid, title: "Pause Race", + pageCount: setup.pageCount, versionSignature: setup.versionSignature + ) + try insertPersistedDownload( + in: container, gid: gid, status: .partial, + completedPageCount: setup.pageCount - 1, pageCount: setup.pageCount, + remoteVersionSignature: setup.versionSignature, + latestRemoteVersionSignature: setup.versionSignature + ) + try writeTemporaryManifestAndPages( + storage: storage, gid: gid, manifest: manifest, + pageCount: setup.pageCount, omittingPage: pageIndex, + versionSignature: setup.versionSignature, + pageSelection: [pageIndex] + ) + await manager.testingProcessDownload(gid: gid) + + let firstRunSnapshot = setup.recorder.snapshot() + #expect(firstRunSnapshot.previewPageNumbers == [1]) + + setup.recorder.reset() + try clearPersistedDownloads(in: container) + try insertPersistedDownload( + in: container, gid: gid, status: .partial, + completedPageCount: setup.pageCount, pageCount: setup.pageCount, + remoteVersionSignature: setup.versionSignature, + latestRemoteVersionSignature: setup.versionSignature + ) + try writeTemporaryManifestAndPages( + storage: storage, gid: gid, manifest: manifest, + pageCount: setup.pageCount, versionSignature: setup.versionSignature, + pageSelection: [pageIndex] + ) + await manager.testingProcessDownload(gid: gid) + + let secondRunSnapshot = setup.recorder.snapshot() + #expect(secondRunSnapshot.previewPageNumbers.isEmpty) + #expect(secondRunSnapshot.mpvRequests == 0) + #expect(secondRunSnapshot.imageDispatchRequests == 0) + } +} + +// MARK: - Minimal Source Test Result + +private struct MinimalSourceTestResult { + let recorder: RequestRecorder + let versionSignature: String + let pageCount: Int +} + +// MARK: - Setup Helpers + +private extension DownloadRetryMinimalSourceTests { + func setupMinimalSourceTest( + manager: DownloadManager, sessionID: String, gid: String, pageIndex: Int + ) async throws -> MinimalSourceTestResult { + let recorder = RequestRecorder() + let stubContent = StubHandlerContent( + detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), + mpvHTML: try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html"), + metadataResponse: try makeMetadataResponseData(gid: gid) + ) + installDownloadStubHandler( + sessionID: sessionID, gid: gid, pageIndex: pageIndex, + content: stubContent, recorder: recorder + ) + let scaffoldDownload = sampleDownload( + gid: gid, title: "Pause Race", status: .partial, + pageCount: 156, completedPageCount: 155 + ) + let fetchResult = try await manager.testingFetchLatestPayload( + for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] + ) + recorder.reset() + return MinimalSourceTestResult( + recorder: recorder, + versionSignature: fetchResult.versionSignature, + pageCount: fetchResult.payload.galleryDetail.pageCount + ) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift new file mode 100644 index 000000000..e8fea8678 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -0,0 +1,137 @@ +// +// DownloadRetryPagesTests.swift +// EhPandaTests +// + +import CoreData +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadRetryPagesTests: DownloadFeatureTestCase { + @Test + func testRetryPagesQueuesWorkWhenAnotherDownloadIsActive() async throws { + let container = try makeInMemoryContainer() + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + try insertPersistedDownload( + in: container, gid: gid, status: .partial, completedPageCount: 1, pageCount: 2 + ) + let temporaryFolderURL = try setupRetryPagesPartialFolder(storage: storage, gid: gid) + + let blockingTask = Task { _ = try? await Task.sleep(for: .seconds(60)) } + defer { blockingTask.cancel() } + await manager.testingInstallActiveTask(gid: "other-active-download", task: blockingTask) + + let result = await manager.retryPages(gid: gid, pageIndices: [2]) + guard case .success = result else { + Issue.record("Retry pages should succeed, got \(result)") + return + } + + let stored = await manager.testingFetchDownload(gid: gid) + #expect(stored?.status == .queued) + #expect(stored?.badge == .queued) + #expect(stored?.pendingOperation == nil) + #expect(stored?.lastError == nil) + + let resumeState = try storage.readResumeState(folderURL: temporaryFolderURL) + #expect(resumeState.pageSelection == [2]) + #expect(FileManager.default.fileExists( + atPath: temporaryFolderURL + .appendingPathComponent(Defaults.FilePath.downloadFailedPages) + .path + ) == false) + } + + @Test + func testCancelQueuedRepairRestoresReadableCountAndClearsPendingOperation() async throws { + let container = try makeInMemoryContainer() + + let gid = "cancel-repair-\(UUID().uuidString)" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + + try insertPersistedDownload( + in: container, + gid: gid, + status: .missingFiles, + completedPageCount: 0, + pageCount: 2, + remoteVersionSignature: "hash:v1", + latestRemoteVersionSignature: "hash:v1", + pendingOperation: .repair + ) + + let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + let manifest = try sampleManifest(gid: gid, title: "Pause Race") + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([0x01]).write( + to: completedFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + + let result = await manager.togglePause(gid: gid) + guard case .success = result else { + Issue.record("Cancelling queued repair should succeed, got \(result)") + return + } + + let stored = await manager.testingFetchDownload(gid: gid) + #expect(stored?.status == .missingFiles) + #expect(stored?.completedPageCount == 1) + #expect(stored?.pendingOperation == nil) + } + +} + +// MARK: - Setup Helpers + +private extension DownloadRetryPagesTests { + @discardableResult + func setupRetryPagesPartialFolder( + storage: DownloadFileStorage, + gid: String + ) throws -> URL { + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL, + withIntermediateDirectories: true + ) + try storage.writeFailedPages( + .init(pages: [ + .init( + index: 2, + relativePath: "pages/0002.jpg", + failure: .init(code: .networkingFailed, message: "Network Error") + ) + ]), + folderURL: temporaryFolderURL + ) + return temporaryFolderURL + } +} diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift new file mode 100644 index 000000000..a3954204d --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -0,0 +1,198 @@ +// +// DownloadRetryUpdateFallbackTests.swift +// EhPandaTests +// + +import CoreData +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { + @Test + func testRetryPagesQueuesFullUpdateWhenGalleryHasUpdate() async throws { + let container = try makeInMemoryContainer() + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) + let pageIndex = 42 + let oldVersionSignature = try #require( + DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") + ) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, queueingManager) = makeStubbedDownloadManager(rootURL: rootURL, sessionID: sessionID) + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let fallbackResult = try await fetchUpdateFallbackPayload( + manager: queueingManager, sessionID: sessionID, gid: gid, + pageIndex: pageIndex, oldVersionSignature: oldVersionSignature + ) + let updatedVersionSignature = fallbackResult.versionSignature + let pageCount = fallbackResult.pageCount + let oldCount = pageCount - 5 + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + + try insertPersistedDownload( + in: container, gid: gid, status: .partial, + completedPageCount: oldCount - 1, pageCount: oldCount, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: updatedVersionSignature + ) + let queuedCandidate = await queueingManager.testingFetchDownload(gid: gid) + #expect(queuedCandidate?.hasUpdate == true) + + let blockerTask = Task { try? await Task.sleep(nanoseconds: 5_000_000_000) } + await queueingManager.testingInstallActiveTask(gid: "blocker", task: blockerTask) + defer { blockerTask.cancel() } + + let retryResult = await queueingManager.retryPages(gid: gid, pageIndices: [pageIndex]) + guard case .success = retryResult else { + Issue.record("retryPages should succeed, got \(retryResult)") + return + } + + let queued = await queueingManager.testingFetchDownload(gid: gid) + #expect(queued?.status == .partial) + #expect(queued?.pendingOperation == .update) + #expect(queued?.lastError == nil) + if FileManager.default.fileExists(atPath: temporaryFolderURL.path) { + let queuedResumeState = try storage.readResumeState(folderURL: temporaryFolderURL) + #expect(queuedResumeState.mode == .update) + #expect(queuedResumeState.pageSelection == nil) + } + } + + @Test + func testRetryPagesNormalizesImmediateUpdateWhenGalleryHasUpdate() async throws { + let container = try makeInMemoryContainer() + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) + let pageIndex = 42 + let oldVersionSignature = try #require( + DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") + ) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, immediateManager) = makeStubbedDownloadManager(rootURL: rootURL, sessionID: sessionID) + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let updateResult = try await fetchUpdateFallbackPayload( + manager: immediateManager, sessionID: sessionID, gid: gid, + pageIndex: pageIndex, oldVersionSignature: oldVersionSignature + ) + let updatedVersionSignature = updateResult.versionSignature + let pageCount = updateResult.pageCount + + try setupImmediateUpdateTestState( + container: container, storage: storage, + context: DownloadPageContext(gid: gid, pageIndex: pageIndex, pageCount: pageCount), + signatures: VersionSignaturePair(old: oldVersionSignature, updated: updatedVersionSignature) + ) + + let immediateBlockerTask = Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + } + await immediateManager.testingInstallActiveTask(gid: gid, task: immediateBlockerTask) + defer { immediateBlockerTask.cancel() } + + let result = await immediateManager.retryPages(gid: gid, pageIndices: [pageIndex]) + guard case .success = result else { + Issue.record("Immediate retryPages should succeed, got \(result)") + return + } + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let resumedState = try storage.readResumeState(folderURL: temporaryFolderURL) + #expect(resumedState.mode == .update) + #expect(resumedState.versionSignature == updatedVersionSignature) + #expect(resumedState.pageCount == pageCount) + #expect(resumedState.pageSelection == nil) + let resumedDownload = await immediateManager.testingFetchDownload(gid: gid) + #expect(resumedDownload?.status == .downloading) + #expect(resumedDownload?.pendingOperation == nil) + #expect(resumedDownload?.lastError == nil) + } +} + +// MARK: - Update Fallback Payload Result + +private struct UpdateFallbackPayloadResult { + let versionSignature: String + let pageCount: Int +} + +// MARK: - Version Signature Pair + +private struct VersionSignaturePair { + let old: String + let updated: String +} + +// MARK: - Download Page Context + +private struct DownloadPageContext { + let gid: String + let pageIndex: Int + let pageCount: Int +} + +// MARK: - Setup Helpers + +private extension DownloadRetryUpdateFallbackTests { + func fetchUpdateFallbackPayload( + manager: DownloadManager, sessionID: String, gid: String, + pageIndex: Int, oldVersionSignature: String + ) async throws -> UpdateFallbackPayloadResult { + let stubContent = StubHandlerContent( + detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), + mpvHTML: try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html"), + metadataResponse: try makeMetadataResponseData(gid: gid) + ) + installDownloadStubHandler( + sessionID: sessionID, gid: gid, pageIndex: pageIndex, content: stubContent + ) + let scaffoldDownload = sampleDownload( + gid: gid, title: "Pause Race", status: .partial, + pageCount: 156, completedPageCount: 155, + remoteVersionSignature: oldVersionSignature, + latestRemoteVersionSignature: "" + ) + let fetchResult = try await manager.testingFetchLatestPayload( + for: scaffoldDownload, mode: .update + ) + let pageCount = fetchResult.payload.galleryDetail.pageCount + #expect(pageCount > pageIndex) + #expect(pageCount > 5) + return UpdateFallbackPayloadResult( + versionSignature: fetchResult.versionSignature, pageCount: pageCount + ) + } + + func setupImmediateUpdateTestState( + container: NSPersistentContainer, storage: DownloadFileStorage, + context: DownloadPageContext, signatures: VersionSignaturePair + ) throws { + let oldCount = context.pageCount - 5 + let manifest = try sampleManifest( + gid: context.gid, title: "Pause Race", + pageCount: context.pageCount, versionSignature: signatures.updated + ) + try writeTemporaryManifestAndPages( + storage: storage, gid: context.gid, manifest: manifest, + pageCount: context.pageCount, omittingPage: context.pageIndex, + versionSignature: signatures.updated, + mode: .update, pageSelection: [context.pageIndex] + ) + try insertPersistedDownload( + in: container, gid: context.gid, status: .partial, + completedPageCount: oldCount - 1, pageCount: oldCount, + remoteVersionSignature: signatures.old, + latestRemoteVersionSignature: signatures.updated + ) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift index 89f1d9fb5..6fa2071f7 100644 --- a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift @@ -131,194 +131,6 @@ struct DownloadSignatureBuilderTests { ) } - @Test - func testSignatureIgnoresPreviewHostRotationAndLayoutChanges() throws { - let firstSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" - )), - 2: try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200" - )) - ] - ) - - let secondSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://beta.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0" - )), - 2: try #require(URL( - string: "https://beta.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=250" - )) - ] - ) - - #expect(firstSignature == secondSignature) - } - - @Test - func testSignatureChangesWhenCombinedPreviewAtlasChanges() throws { - let firstSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" - )) - ] - ) - - let secondSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-1.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" - )) - ] - ) - - #expect(firstSignature != secondSignature) - } - - @Test - func testSignatureIgnoresCombinedPreviewTokenRotation() throws { - let firstSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" - )) - ] - ) - - let secondSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://beta.hath.network/c2/token-b/1394965-0.webp" - + "?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0" - )) - ] - ) - - #expect(firstSignature == secondSignature) - } - - @Test - func testSignatureIgnoresHostRotationForStandalonePreviewURLs() throws { - let firstSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")), - 2: try #require(URL(string: "https://alpha.ehgt.org/t/56/78/preview-2.webp")) - ] - ) - - let secondSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL(string: "https://beta.ehgt.org/t/12/34/preview-1.webp")), - 2: try #require(URL(string: "https://beta.ehgt.org/t/56/78/preview-2.webp")) - ] - ) - - #expect(firstSignature == secondSignature) - } - - @Test - func testSignatureIgnoresCoverHostAndQueryChanges() { - let firstSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetailWithCoverURL("https://ehgt.org/w/00/686/86308-b7cs0xve.webp?dl=1"), - host: .ehentai, - previewURLs: [:] - ) - - let secondSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetailWithCoverURL("https://mirror.ehgt.org/w/00/686/86308-b7cs0xve.webp?source=thumb"), - host: .ehentai, - previewURLs: [:] - ) - - #expect(firstSignature == secondSignature) - } - - @Test - func testSignatureIgnoresGalleryHostTransitions() throws { - let ehSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")) - ] - ) - - let exSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .exhentai, - previewURLs: [ - 1: try #require(URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")) - ] - ) - - #expect(ehSignature == exSignature) - } - @Test - func testSignatureIsOrderIndependentForSamePreviewURLSet() throws { - let urlA = try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" - )) - let urlB = try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200" - )) - - let ascendingSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [1: urlA, 2: urlB] - ) - - let descendingSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [2: urlB, 1: urlA] - ) - - #expect(ascendingSignature == descendingSignature) - } } private extension DownloadSignatureBuilderTests { diff --git a/EhPandaTests/Tests/Download/DownloadSignaturePreviewTests.swift b/EhPandaTests/Tests/Download/DownloadSignaturePreviewTests.swift new file mode 100644 index 000000000..d6f392975 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadSignaturePreviewTests.swift @@ -0,0 +1,249 @@ +// +// DownloadSignaturePreviewTests.swift +// EhPandaTests +// + +import Testing +import Foundation +@testable import EhPanda + +struct DownloadSignaturePreviewTests { + @Test + func testSignatureIgnoresPreviewHostRotationAndLayoutChanges() throws { + let firstSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" + )), + 2: try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200" + )) + ] + ) + + let secondSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: try #require(URL( + string: "https://beta.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0" + )), + 2: try #require(URL( + string: "https://beta.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=250" + )) + ] + ) + + #expect(firstSignature == secondSignature) + } + + @Test + func testSignatureChangesWhenCombinedPreviewAtlasChanges() throws { + let firstSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" + )) + ] + ) + + let secondSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-1.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" + )) + ] + ) + + #expect(firstSignature != secondSignature) + } + + @Test + func testSignatureIgnoresCombinedPreviewTokenRotation() throws { + let firstSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" + )) + ] + ) + + let secondSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: try #require(URL( + string: "https://beta.hath.network/c2/token-b/1394965-0.webp" + + "?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0" + )) + ] + ) + + #expect(firstSignature == secondSignature) + } + + @Test + func testSignatureIgnoresHostRotationForStandalonePreviewURLs() throws { + let firstSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: try #require(URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")), + 2: try #require(URL(string: "https://alpha.ehgt.org/t/56/78/preview-2.webp")) + ] + ) + + let secondSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: try #require(URL(string: "https://beta.ehgt.org/t/12/34/preview-1.webp")), + 2: try #require(URL(string: "https://beta.ehgt.org/t/56/78/preview-2.webp")) + ] + ) + + #expect(firstSignature == secondSignature) + } + + @Test + func testSignatureIgnoresCoverHostAndQueryChanges() { + let firstSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetailWithCoverURL("https://ehgt.org/w/00/686/86308-b7cs0xve.webp?dl=1"), + host: .ehentai, + previewURLs: [:] + ) + + let secondSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetailWithCoverURL("https://mirror.ehgt.org/w/00/686/86308-b7cs0xve.webp?source=thumb"), + host: .ehentai, + previewURLs: [:] + ) + + #expect(firstSignature == secondSignature) + } + + @Test + func testSignatureIgnoresGalleryHostTransitions() throws { + let ehSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [ + 1: try #require(URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")) + ] + ) + + let exSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .exhentai, + previewURLs: [ + 1: try #require(URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")) + ] + ) + + #expect(ehSignature == exSignature) + } + + @Test + func testSignatureIsOrderIndependentForSamePreviewURLSet() throws { + let urlA = try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" + )) + let urlB = try #require(URL( + string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" + + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200" + )) + + let ascendingSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [1: urlA, 2: urlB] + ) + + let descendingSignature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [2: urlB, 1: urlA] + ) + + #expect(ascendingSignature == descendingSignature) + } +} + +private extension DownloadSignaturePreviewTests { + var sampleGallery: Gallery { + Gallery( + gid: "1394965", + token: "56c35114b6", + title: "(C95) [Hoshimame (Hoshimame Mana)] Mugyutto Mugyu Gurumi (Summer Pockets)[Chinese] [红茶汉化组]", + rating: 4.5, + tags: [], + category: .nonH, + uploader: "多路卡", + pageCount: 26, + postedDate: samplePostedDate, + coverURL: URL(string: "https://ehgt.org/cover.webp"), + galleryURL: URL(string: "https://e-hentai.org/g/1394965/56c35114b6/") + ) + } + + var sampleDetail: GalleryDetail { + sampleDetailWithCoverURL("https://ehgt.org/cover.webp") + } + + func sampleDetailWithCoverURL(_ coverURL: String) -> GalleryDetail { + GalleryDetail( + gid: "1394965", + title: sampleGallery.title, + jpnTitle: "(C95) [ほしまめ (星豆まな)] むぎゅっとむぎゅぐるみ (Summer Pockets)[中国翻訳]", + isFavorited: false, + visibility: .yes, + rating: 4.5, + userRating: 0, + ratingCount: 0, + category: .nonH, + language: .chinese, + uploader: "多路卡", + postedDate: samplePostedDate, + coverURL: URL(string: coverURL), + favoritedCount: 0, + pageCount: 26, + sizeCount: 114, + sizeType: "MB", + torrentCount: 0 + ) + } + + var samplePostedDate: Date { + Date(timeIntervalSince1970: 576_346_020) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift new file mode 100644 index 000000000..46c045f9c --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -0,0 +1,156 @@ +// +// DownloadVersionSignatureTests.swift +// EhPandaTests +// + +import CoreData +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadVersionSignatureTests: DownloadFeatureTestCase { + @Test + func testDownloadManagerReconcileNormalizesFailedDownloadBeforeTempCleanup() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 31) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + try insertPersistedDownload( + in: container, + gid: gid, + status: .failed, + completedPageCount: 0, + pageCount: 2, + lastError: .init(code: .networkingFailed, message: "Network Error") + ) + + let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + try FileManager.default.createDirectory( + at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try Data([0x01]).write( + to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + + await manager.reconcileDownloads() + + let stored = await manager.testingFetchDownload(gid: gid) + let localPages = try await manager.loadLocalPageURLs(gid: gid).get() + + #expect(stored?.status == .partial) + #expect(stored?.completedPageCount == 1) + #expect(FileManager.default.fileExists(atPath: temporaryFolderURL.path)) + #expect(localPages[1] == temporaryFolderURL.appendingPathComponent("pages/0001.jpg")) + } + + @MainActor + @Test + func testUpdateRemoteSignatureSkipsUpdateWhenStoredChainAndLatestHashDiffer() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 101) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let manager = DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: .shared + ) + try insertPersistedDownload( + in: container, + gid: gid, + status: .completed, + completedPageCount: 26, + token: "token", + remoteVersionSignature: "chain:\(gid):token" + ) + + let badge = await manager.updateRemoteSignature(gid: gid, latestSignature: "hash:new") + let stored = await manager.testingFetchDownload(gid: gid) + + #expect(badge == .downloaded) + #expect(stored?.status == .completed) + #expect(stored?.remoteVersionSignature == "chain:\(gid):token") + #expect(stored?.latestRemoteVersionSignature == "hash:new") + } + + @MainActor + @Test + func testUpdateRemoteSignatureSkipsUpdateWhenStoredHashAndLatestNonOriginalChainDiffer() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 102) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let manager = DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: .shared + ) + try insertPersistedDownload( + in: container, + gid: gid, + status: .completed, + completedPageCount: 26, + token: "token", + remoteVersionSignature: "hash:old" + ) + + let badge = await manager.updateRemoteSignature( + gid: gid, + latestSignature: "chain:othergid:othertoken" + ) + let stored = await manager.testingFetchDownload(gid: gid) + + #expect(badge == .downloaded) + #expect(stored?.status == .completed) + #expect(stored?.remoteVersionSignature == "hash:old") + #expect(stored?.latestRemoteVersionSignature == "chain:othergid:othertoken") + } + + @MainActor + @Test + func testUpdateRemoteSignatureCanonicalizesStoredHashToOriginalChainWithoutMarkingUpdate() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 103) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let manager = DownloadManager( + storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + urlSession: .shared + ) + try insertPersistedDownload( + in: container, + gid: gid, + status: .completed, + completedPageCount: 26, + token: "token", + remoteVersionSignature: "hash:old" + ) + + let badge = await manager.updateRemoteSignature( + gid: gid, + latestSignature: "chain:\(gid):token" + ) + let stored = await manager.testingFetchDownload(gid: gid) + + #expect(badge == .downloaded) + #expect(stored?.status == .completed) + #expect(stored?.remoteVersionSignature == "chain:\(gid):token") + #expect(stored?.latestRemoteVersionSignature == "chain:\(gid):token") + } + +} diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift new file mode 100644 index 000000000..99d148e02 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -0,0 +1,190 @@ +// +// DownloadsReducerActionTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadsReducerActionTests: DownloadFeatureTestCase { + @Test + func testDownloadsReducerKeepsIdleStateForEmptyLibrary() async { + let store = TestStore(initialState: DownloadsReducer.State()) { + DownloadsReducer() + } + + await store.send(.fetchDownloadsDone([])) { + $0.loadingState = .idle + } + + #expect(store.state.downloads == []) + } + + @MainActor + @Test + func testDownloadsReducerSeedsOnlineDetailStateFromDownload() async { + let download = sampleDownload( + gid: "123456", + title: "Completed Gallery", + status: .completed + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } + store.exhaustivity = .off + + await store.send(.setNavigation(.detail(download.gid))) + + #expect(store.state.route == .detail(download.gid)) + #expect(store.state.detailState.wrappedValue?.gid == download.gid) + #expect(store.state.detailState.wrappedValue?.gallery.id == download.gid) + #expect(store.state.detailState.wrappedValue?.downloadBadge == .downloaded) + #expect(store.state.detailState.wrappedValue?.shouldCheckForRemoteUpdates == true) + } + + @MainActor + @Test + func testDownloadsReducerUpdateActionUsesDownloadClientRetry() async { + let retried = UncheckedBox<[String]>([]) + let download = sampleDownload( + gid: "123456", + title: "Completed Gallery", + status: .updateAvailable, + latestRemoteVersionSignature: "hash:v2" + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { gid, mode in + if mode == .update { + retried.value.append(gid) + } + return .success(()) + }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + store.exhaustivity = .off + + await store.send(.updateDownload(download.gid)) + await store.receive(\.updateDownloadDone) + + #expect(retried.value == [download.gid]) + } + + @MainActor + @Test + func testDownloadsReducerDeleteActionUsesDownloadClientDelete() async { + let deleted = UncheckedBox<[String]>([]) + let download = sampleDownload( + gid: "654321", + title: "Completed Gallery", + status: .completed + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { gid in + deleted.value.append(gid) + return .success(()) + }, + loadManifest: { _ in .failure(.notFound) } + ) + } + store.exhaustivity = .off + + await store.send(.deleteDownload(download.gid)) + await store.receive(\.deleteDownloadDone) + + #expect(deleted.value == [download.gid]) + } + + @MainActor + @Test + func testDownloadsReducerTogglePauseActionUsesDownloadClientPause() async { + let toggled = UncheckedBox<[String]>([]) + let download = sampleDownload( + gid: "987654", + title: "Downloading Gallery", + status: .downloading, + completedPageCount: 9 + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { gid in + toggled.value.append(gid) + return .success(()) + }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + store.exhaustivity = .off + + await store.send(.toggleDownloadPause(download.gid)) + await store.receive(\.toggleDownloadPauseDone) + + #expect(toggled.value == [download.gid]) + } + +} diff --git a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift new file mode 100644 index 000000000..2be66a278 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift @@ -0,0 +1,141 @@ +// +// DownloadsReducerRefreshTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDownloadsReducerRefreshesWithoutResumingQueueAfterPauseFailure() async { + let download = sampleDownload( + gid: "987655", + title: "Queued Gallery", + status: .queued, + completedPageCount: 3 + ) + let reconcileCount = UncheckedBox(0) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [download] }, + fetchDownload: { _ in nil }, + reconcileDownloads: { + reconcileCount.value += 1 + }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .failure(.networkingFailed) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + + await store.send(.toggleDownloadPause(download.gid)) + await store.receive(\.toggleDownloadPauseDone) + await store.finish() + + #expect(reconcileCount.value == 1) + } + + @MainActor + @Test + func testDownloadsReducerRefreshDownloadsUsesClientRefresh() async { + let refreshCount = UncheckedBox(0) + let reconcileCount = UncheckedBox(0) + + let store = TestStore(initialState: DownloadsReducer.State()) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + reconcileDownloads: { + reconcileCount.value += 1 + }, + refreshDownloads: { + refreshCount.value += 1 + }, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + + await store.send(.refreshDownloads) + await store.receive(\.refreshDownloadsDone) + + #expect(refreshCount.value == 1) + #expect(reconcileCount.value == 0) + } + + @MainActor + @Test + func testDownloadsReducerBootstrapUsesClientRefresh() async { + let refreshCount = UncheckedBox(0) + let reconcileCount = UncheckedBox(0) + + let store = TestStore(initialState: DownloadsReducer.State()) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + reconcileDownloads: { + reconcileCount.value += 1 + }, + refreshDownloads: { + refreshCount.value += 1 + }, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + + await store.send(.bootstrapDownloads) + await store.receive(\.refreshDownloadsDone) + + #expect(refreshCount.value == 1) + #expect(reconcileCount.value == 0) + } + +} diff --git a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift new file mode 100644 index 000000000..5a23aefba --- /dev/null +++ b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -0,0 +1,151 @@ +// +// PreviewsReducerDownloadTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct PreviewsReducerDownloadTests: DownloadFeatureTestCase { + @MainActor + @Test + func testPreviewsReducerOpenReadingUsesLocalManifestWhenAvailable() async throws { + let download = sampleDownload( + gid: "991", title: "Preview Download", status: .completed, pageCount: 2, completedPageCount: 2 + ) + let manifest = try sampleManifest(gid: download.gid, title: download.title) + var initialState = PreviewsReducer.State() + initialState.gallery = download.gallery + + let store = makePreviewsManifestStore(download: download, manifest: manifest) + + await store.send(.openReading(1)) + await store.skipReceivedActions(strict: false) + + if case .local(let actualDownload, let actualManifest) = store.state.readingState.contentSource { + #expect(actualDownload == download) + #expect(actualManifest == manifest) + } else { + Issue.record("Expected previews to open local reading content.") + } + if case .reading = store.state.route { + } else { + Issue.record("Expected reading route to be active.") + } + } + + @MainActor + @Test + func testPreviewsReducerClearsLocalPreviewURLsWhenObservedDownloadDisappears() async { + let gallery = sampleGallery() + let localURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") + var initialState = PreviewsReducer.State() + initialState.gallery = gallery + initialState.localPreviewURLs = [1: localURL] + + let store = makePreviewsNoManifestStore(initialState: initialState, withLoadLocalPageURLs: true) + + await store.send(.observeDownloadsDone([])) + await store.receive(\.loadLocalPreviewURLs) + let requestID = store.state.localPreviewRequestID + await store.receive(\.loadLocalPreviewURLsDone) { + $0.localPreviewURLs = [:] + } + #expect(store.state.localPreviewRequestID == requestID) + } + + @MainActor + @Test + func testPreviewsReducerRemoteFallbackKeepsExistingLocalPreviewPages() async { + let gallery = sampleGallery() + let localURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") + var initialState = PreviewsReducer.State() + initialState.gallery = gallery + initialState.localPreviewURLs = [1: localURL] + + let store = makePreviewsNoManifestStore(initialState: initialState, withLoadLocalPageURLs: false) + + await store.send(.openReading(1)) + await store.receive(\.openReadingDone) + guard case .reading = store.state.route else { + Issue.record("Expected previews route to enter reading") + return + } + #expect(store.state.readingState.contentSource == .remote) + #expect(store.state.readingState.localPageURLs == [1: localURL]) + } + +} + +// MARK: - Store Factory Helpers + +private extension PreviewsReducerDownloadTests { + func makePreviewsManifestStore( + download: DownloadedGallery, + manifest: DownloadManifest + ) -> TestStoreOf { + var initialState = PreviewsReducer.State() + initialState.gallery = download.gallery + let store = TestStore(initialState: initialState) { + PreviewsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { AsyncStream { continuation in continuation.finish() } }, + fetchDownloads: { [download] }, + fetchDownload: { gid in gid == download.gid ? download : nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { gid in + gid == download.gid ? .success((download, manifest)) : .failure(.notFound) + } + ) + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + store.exhaustivity = .off + return store + } + + func makePreviewsNoManifestClient(loadLocalPageURLs: Bool) -> DownloadClient { + .init( + observeDownloads: { AsyncStream { continuation in continuation.finish() } }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: loadLocalPageURLs ? { _ in .success([:]) } : { _ in .failure(.notFound) } + ) + } + + func makePreviewsNoManifestStore( + initialState: PreviewsReducer.State, + withLoadLocalPageURLs: Bool + ) -> TestStoreOf { + let downloadClient = makePreviewsNoManifestClient(loadLocalPageURLs: withLoadLocalPageURLs) + let store = TestStore(initialState: initialState) { + PreviewsReducer() + } withDependencies: { + $0.downloadClient = downloadClient + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + store.exhaustivity = .off + return store + } +} diff --git a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift new file mode 100644 index 000000000..6d675a3dc --- /dev/null +++ b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -0,0 +1,171 @@ +// +// ReadingReducerDownloadTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct ReadingReducerDownloadTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDetailReducerDownloadedContextStoresVersionMetadataResult() async { + let download = sampleDownload( + gid: "889", title: "Offline Archive", status: .completed, pageCount: 2 + ) + let detail = sampleGalleryDetail(gid: download.gid, title: download.title) + var initialState = DetailReducer.State(download: download) + initialState.galleryDetail = detail + let metadata = DownloadVersionMetadata( + gid: detail.gid, token: download.token, + currentGID: "990", currentKey: "chain-key", + parentGID: download.gid, parentKey: download.token, + firstGID: download.gid, firstKey: download.token + ) + + let store = TestStore(initialState: initialState) { DetailReducer() } + await store.send(.fetchVersionMetadataDone(.success(metadata))) { + $0.galleryVersionMetadata = metadata + } + } + + @MainActor + @Test + func testReadingReducerRemoteSourceLoadsLocalPagesAndSkipsRemoteFetchForDownloadedPage() async throws { + let gallery = sampleGallery() + let localPageURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") + let remotePageURL = try #require(URL(string: "https://example.com/pages/0001.jpg")) + var initialState = ReadingReducer.State(contentSource: .remote) + initialState.gallery = gallery + initialState.imageURLs = [1: remotePageURL] + + let store = makeLocalPageLoadStore( + initialState: initialState, gallery: gallery, localPageURL: localPageURL + ) + + await store.send(.loadLocalPageURLs(gallery.gid)) + let requestID = store.state.localPageRequestID + await store.receive(\.loadLocalPageURLsDone) { $0.localPageURLs = [1: localPageURL] } + #expect(store.state.localPageRequestID == requestID) + #expect(store.state.localPageURLs[1] == localPageURL) + + await store.send(.fetchImageURLs(1)) { $0.imageURLLoadingStates[1] = .idle } + } + + @MainActor + @Test + func testReadingReducerOnWebImageSucceededCapturesCachedPageIntoDownloadProgress() async throws { + let capturedCalls = UncheckedBox([CapturedPageCall]()) + let gallery = sampleGallery() + let remotePageURL = try #require(URL(string: "https://example.com/pages/0001.jpg")) + var initialState = ReadingReducer.State(contentSource: .remote) + initialState.gallery = gallery + initialState.imageURLs = [1: remotePageURL] + + let store = makeCapturePageStore( + initialState: initialState, capturedCalls: capturedCalls + ) + + await store.send(.onWebImageSucceeded(1)) { + $0.imageURLLoadingStates[1] = .idle + $0.webImageLoadSuccessIndices.insert(1) + } + await store.receive(\.captureCachedPage) + + #expect(capturedCalls.value.count == 1) + #expect(capturedCalls.value.first?.gid == gallery.gid) + #expect(capturedCalls.value.first?.index == 1) + #expect(capturedCalls.value.first?.imageURL == remotePageURL) + } + +} + +// MARK: - Captured Page Call + +private struct CapturedPageCall { + let gid: String + let index: Int + let imageURL: URL? +} + +// MARK: - Store Factory Helpers + +private extension ReadingReducerDownloadTests { + func makeLocalPageLoadStore( + initialState: ReadingReducer.State, + gallery: Gallery, + localPageURL: URL + ) -> TestStoreOf { + let store = TestStore(initialState: initialState) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { AsyncStream { $0.yield([]); $0.finish() } }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { gid in + gid == gallery.gid ? .success([1: localPageURL]) : .failure(.notFound) + } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + return store + } + + func makeCapturePageStore( + initialState: ReadingReducer.State, + capturedCalls: UncheckedBox<[CapturedPageCall]> + ) -> TestStoreOf { + let store = TestStore(initialState: initialState) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { AsyncStream { $0.finish() } }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + captureCachedPage: { gid, index, imageURL in + capturedCalls.value.append(CapturedPageCall(gid: gid, index: index, imageURL: imageURL)) + } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + return store + } +} diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift new file mode 100644 index 000000000..dedd114be --- /dev/null +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -0,0 +1,110 @@ +// +// ReadingReducerLocalTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct ReadingReducerLocalTests: DownloadFeatureTestCase { + func testReadingReducerOnWebImageSucceededDoesNotCaptureAlreadyLocalPage() async { + let capturedCalls = UncheckedBox([(String, Int, URL?)]()) + let gallery = sampleGallery() + let localPageURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("0001.jpg") + var initialState = ReadingReducer.State(contentSource: .remote) + initialState.gallery = gallery + initialState.localPageURLs = [1: localPageURL] + + let store = TestStore(initialState: initialState) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + captureCachedPage: { gid, index, imageURL in + capturedCalls.value.append((gid, index, imageURL)) + } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + + await store.send(.onWebImageSucceeded(1)) { + $0.imageURLLoadingStates[1] = .idle + $0.webImageLoadSuccessIndices.insert(1) + } + await store.finish() + + #expect(capturedCalls.value.isEmpty) + } + + @MainActor + @Test + func testReadingReducerLocalSourceLoadsOfflineImagesWithoutNetwork() async throws { + let download = sampleDownload( + gid: "777", + title: "Offline Archive", + status: .completed, + pageCount: 2 + ) + let manifest = try sampleManifest(gid: download.gid, title: download.title) + let folderURL = try prepareLocalDownloadFiles(download: download, manifest: manifest) + defer { try? FileManager.default.removeItem(at: folderURL) } + + let store = TestStore( + initialState: ReadingReducer.State(contentSource: .local(download, manifest)) + ) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + + await store.send(.fetchDatabaseInfos(download.gid)) + #expect(store.state.gallery.id == download.gid) + #expect(store.state.imageURLs[1] == folderURL.appendingPathComponent("pages/0001.jpg")) + #expect(store.state.imageURLs[2] == folderURL.appendingPathComponent("pages/0002.jpg")) + + await store.send(.fetchImageURLs(1)) { + $0.imageURLLoadingStates[1] = .idle + } + await store.send(.reloadAllWebImages) + + #expect(store.state.imageURLs[1] == folderURL.appendingPathComponent("pages/0001.jpg")) + #expect(store.state.imageURLs[2] == folderURL.appendingPathComponent("pages/0002.jpg")) + } + +} diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift index ce6426150..2c54e43ad 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift @@ -17,7 +17,10 @@ struct GalleryImageURLParserTests: TestHelper { func testGalleryNormalImageURLParser(doc: HTMLDocument) throws { let inputIndex = 1 - let (index, imageURL, originalImageURL) = try Parser.parseGalleryNormalImageURL(doc: doc, index: inputIndex) + let result = try Parser.parseGalleryNormalImageURL(doc: doc, index: inputIndex) + let index = result.index + let imageURL = result.imageURL + let originalImageURL = result.originalImageURL #expect(index == inputIndex) let expectedImageURL = "https://akrtazd.spuqplybaxmf.hath.network:65000/h/" diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index d9df2d995..b27b4294d 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -24,8 +24,7 @@ class ShareViewController: UIViewController { itemProvider.loadItem(forTypeIdentifier: "public.url") { (item, _) in if let shareURL = item as? URL, let scheme = shareURL.scheme, let replacedURL = URL(string: shareURL.absoluteString - .replacingOccurrences(of: scheme, with: "ehpanda")) - { + .replacingOccurrences(of: scheme, with: "ehpanda")) { self.openMainApp(url: replacedURL) } } From db25e02fd082435f3c67d4d2469e3ccdbbe72ca6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Apr 2026 22:46:05 +0800 Subject: [PATCH 016/614] Update dependencies --- .../xcshareddata/swiftpm/Package.resolved | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e9121d46e..0068e292e 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008", - "version" : "8.8.0" + "revision" : "c152c1915f60c51e4afa0752656993ee5b3c63db", + "version" : "8.8.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", - "version" : "1.7.2" + "revision" : "206cbce3882b4de9aee19ce62ac5b7306cadd45b", + "version" : "1.7.3" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "d5d2e0258fa2e80df761c2b73353422d42f4b98e", - "version" : "1.25.3" + "revision" : "1eaa6fa2ee57ac42843283b9fd3457af408c858d", + "version" : "1.25.5" } }, { @@ -168,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "e7441dc4dfec6a4ae929e614e3c1e67c6639d164", - "version" : "2.7.0" + "revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248", + "version" : "2.8.0" } }, { @@ -177,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4", - "version" : "2.0.9" + "revision" : "25ac73741c3436605d61eceb5207e896973918e7", + "version" : "2.0.10" } }, { @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "4799286537280063c85a32f09884cfbca301b1a1", - "version" : "602.0.0" + "revision" : "2b59c0c741e9184ab057fd22950b491076d42e91", + "version" : "603.0.0" } }, { From 4c32a55ddae702b59b6255ce3230cf72006ea6e6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Apr 2026 22:51:42 +0800 Subject: [PATCH 017/614] Resolve test crashing issues --- EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift | 1 + EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift | 1 + EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift | 2 ++ 3 files changed, 4 insertions(+) diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift index 35a047f93..31e2a6ba9 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -10,6 +10,7 @@ import Testing @Suite(.serialized) struct DetailReducerMetadataTests: DownloadFeatureTestCase { + @MainActor @Test func testDetailReducerDoesNotRequestVersionMetadataForUndownloadedGallery() async throws { let updateCheckCount = UncheckedBox(0) diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift index 51987698c..d5fc399ac 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -10,6 +10,7 @@ import Testing @Suite(.serialized) struct DownloadInspectorLoadTests: DownloadFeatureTestCase { + @MainActor @Test func testDownloadInspectorReducerLoadsInspection() async { let download = sampleDownload( diff --git a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift index 9acec5c64..5ed2e6489 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -10,6 +10,7 @@ import Testing @Suite(.serialized) struct DownloadObserverRefreshTests: DownloadFeatureTestCase { + @MainActor @Test func testReadingReducerEmitsOneFinalRefreshWhenRelevantDownloadDisappears() async { let gallery = sampleGallery() @@ -45,6 +46,7 @@ struct DownloadObserverRefreshTests: DownloadFeatureTestCase { await store.finish() } + @MainActor @Test func testPreviewsReducerEmitsOneFinalRefreshWhenRelevantDownloadDisappears() async { let gallery = sampleGallery() From 3f1ff5d97611fb3f5c97c507f9604af1530a9c3b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Apr 2026 22:57:43 +0800 Subject: [PATCH 018/614] Fix download tests --- .../Tools/Clients/DownloadClient+Cache.swift | 6 +- .../DownloadClient+ExecutionFetch.swift | 19 +++- .../DownloadClient+ExecutionPerform.swift | 6 +- .../DownloadClient+ExecutionSupport.swift | 4 +- .../App/Tools/Clients/DownloadClient.swift | 9 ++ .../Tools/Utilities/DownloadFileStorage.swift | 12 +- EhPanda/App/Tools/Utilities/URLUtil.swift | 6 +- EhPanda/Network/Request+Detail.swift | 9 +- EhPanda/View/Detail/DetailReducer+Fetch.swift | 2 +- .../Download/DetailReducerMetadataTests.swift | 12 +- .../DetailReducerMetadataUpdateTests.swift | 9 +- .../DetailReducerPauseAndGuardTests.swift | 1 + .../DownloadFeatureTestFactories.swift | 15 ++- .../Download/DownloadFeatureTestHelpers.swift | 3 +- .../DownloadImageParsingCacheTests.swift | 2 +- .../Download/DownloadImageParsingTests.swift | 2 +- .../Download/DownloadInspectorSkipTests.swift | 1 + .../Tests/Download/DownloadIpBanTests.swift | 3 +- .../DownloadManagerCaptureTests.swift | 5 +- .../DownloadManagerRepairSeedTests.swift | 2 +- .../DownloadManagerStorageTests.swift | 9 +- .../Download/DownloadObserverBatchTests.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../DownloadPauseAndReconcileTests.swift | 16 ++- .../Download/DownloadProcessCacheTests.swift | 106 ++++++++++++------ .../Tests/Download/DownloadProcessTests.swift | 4 +- .../DownloadRetryMinimalSourceTests.swift | 5 +- .../Download/DownloadRetryPagesTests.swift | 5 +- .../DownloadRetryUpdateFallbackTests.swift | 8 +- .../DownloadVersionSignatureTests.swift | 11 +- .../DownloadsReducerActionTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + 32 files changed, 199 insertions(+), 97 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 9b2d2ff9f..9422f5098 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -18,7 +18,7 @@ extension DownloadManager { func removeCachedImages( for urls: [URL?], includeStableAlias: Bool - ) { + ) async { let keys = urls .compactMap(\.self) .flatMap { @@ -26,7 +26,7 @@ extension DownloadManager { } for key in Set(keys) { - KingfisherManager.shared.cache + try? await KingfisherManager.shared.cache .removeImage(forKey: key) } } @@ -286,7 +286,7 @@ extension DownloadManager { data: cachedData, referenceURLs: urls ) == nil else { - removeCachedImages( + await removeCachedImages( for: urls, includeStableAlias: true ) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index e3caf1ac2..1275b568d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -45,7 +45,7 @@ extension DownloadManager { galleryState: galleryState, galleryURL: galleryURL ) - let versionMetadata = await fetchVersionMetadata( + let versionMetadata = await fetchOptionalVersionMetadata( gid: download.gid, token: download.token ) @@ -132,14 +132,25 @@ extension DownloadManager { ) } - private func fetchVersionMetadata( + func fetchVersionMetadata( + gid: String, + token: String + ) async -> Result { + await GalleryVersionMetadataRequest( + gid: gid, + token: token, + urlSession: urlSession + ).response() + } + + private func fetchOptionalVersionMetadata( gid: String, token: String ) async -> DownloadVersionMetadata? { - switch await GalleryVersionMetadataRequest( + switch await fetchVersionMetadata( gid: gid, token: token - ).response() { + ) { case .success(let metadata): return metadata case .failure: diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 280cc5a48..7fde40aa5 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -167,7 +167,7 @@ extension DownloadManager { failedPages: context.batchResult.failedPages ) } - try finalizeDownload( + try await finalizeDownload( payload: payload, temporaryFolderURL: temporaryFolderURL, folderRelativePath: folderRelativePath, @@ -203,7 +203,7 @@ extension DownloadManager { temporaryFolderURL: URL, folderRelativePath: String, finalizeContext: FinalizeContext - ) throws { + ) async throws { let versionSignature = finalizeContext.versionSignature let coverRelativePath = finalizeContext.coverRelativePath let batchResult = finalizeContext.batchResult @@ -248,7 +248,7 @@ extension DownloadManager { relativePath: folderRelativePath, with: temporaryFolderURL ) - cleanupCachedRemoteAssetsAfterSuccessfulDownload( + await cleanupCachedRemoteAssetsAfterSuccessfulDownload( payload: payload, storedGalleryImageState: storedGalleryImageState, pages: batchResult.pages, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 169250313..f0de7ec03 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -95,7 +95,7 @@ extension DownloadManager { storedGalleryImageState: CachedGalleryImageState?, pages: [PageResult], existingDownload: DownloadedGallery - ) { + ) async { let previewURLs = ( Array(payload.previewURLs.values) + (storedGalleryImageState.map { @@ -116,7 +116,7 @@ extension DownloadManager { let urls = Array(Set(previewURLs + pageURLs + coverURLs)) .map(Optional.some) - removeCachedImages(for: urls, includeStableAlias: true) + await removeCachedImages(for: urls, includeStableAlias: true) } func resolveSource( diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 98627b453..e901cd467 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -14,6 +14,7 @@ struct DownloadClient { let refreshDownloads: () async -> Void let resumeQueue: () async -> Void let badges: ([String]) async -> [String: DownloadBadge] + let fetchVersionMetadata: (String, String) async -> Result let updateRemoteSignature: (String, String?) async -> DownloadBadge let enqueue: (DownloadRequestPayload) async -> Result let togglePause: (String) async -> Result @@ -33,6 +34,8 @@ struct DownloadClient { refreshDownloads: @escaping () async -> Void, resumeQueue: @escaping () async -> Void, badges: @escaping ([String]) async -> [String: DownloadBadge], + fetchVersionMetadata: @escaping (String, String) async -> Result + = { _, _ in .failure(.notFound) }, updateRemoteSignature: @escaping (String, String?) async -> DownloadBadge, enqueue: @escaping (DownloadRequestPayload) async -> Result, togglePause: @escaping (String) async -> Result, @@ -51,6 +54,7 @@ struct DownloadClient { self.refreshDownloads = refreshDownloads self.resumeQueue = resumeQueue self.badges = badges + self.fetchVersionMetadata = fetchVersionMetadata self.updateRemoteSignature = updateRemoteSignature self.enqueue = enqueue self.togglePause = togglePause @@ -109,6 +113,9 @@ extension DownloadClient { refreshDownloads: { await manager.refreshDownloads() }, resumeQueue: { await manager.resumeQueue() }, badges: { gids in await manager.badges(for: gids) }, + fetchVersionMetadata: { gid, token in + await manager.fetchVersionMetadata(gid: gid, token: token) + }, updateRemoteSignature: { gid, signature in await manager.updateRemoteSignature(gid: gid, latestSignature: signature) }, @@ -158,6 +165,7 @@ extension DownloadClient { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, + fetchVersionMetadata: { _, _ in .failure(.notFound) }, updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, @@ -180,6 +188,7 @@ extension DownloadClient { refreshDownloads: IssueReporting.unimplemented(placeholder: placeholder()), resumeQueue: IssueReporting.unimplemented(placeholder: placeholder()), badges: IssueReporting.unimplemented(placeholder: placeholder()), + fetchVersionMetadata: IssueReporting.unimplemented(placeholder: placeholder()), updateRemoteSignature: IssueReporting.unimplemented(placeholder: placeholder()), enqueue: IssueReporting.unimplemented(placeholder: placeholder()), togglePause: IssueReporting.unimplemented(placeholder: placeholder()), diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index e1c6c3fa3..2a9238fb6 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -249,7 +249,7 @@ struct DownloadFileStorage { do { attributes = try fileManager.attributesOfItem(atPath: url.path) } catch { - return false + return canReadNonEmptyFile(at: url) } let isRegularFile = (attributes[.type] as? FileAttributeType).map { $0 == .typeRegular } ?? true @@ -265,4 +265,14 @@ struct DownloadFileStorage { return true } + + private func canReadNonEmptyFile(at url: URL) -> Bool { + do { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + return try handle.read(upToCount: 1)?.isEmpty == false + } catch { + return false + } + } } diff --git a/EhPanda/App/Tools/Utilities/URLUtil.swift b/EhPanda/App/Tools/Utilities/URLUtil.swift index bcc153e61..f751dda9c 100644 --- a/EhPanda/App/Tools/Utilities/URLUtil.swift +++ b/EhPanda/App/Tools/Utilities/URLUtil.swift @@ -123,7 +123,11 @@ struct URLUtil { } static func combinedPreviewURL(plainURL: URL, width: String, height: String, offset: String) -> URL { - plainURL.appending(queryItems: [.ehpandaWidth: width, .ehpandaHeight: height, .ehpandaOffset: offset]) + plainURL.appending(queryItems: [ + URLQueryItem(name: Defaults.URL.Component.Key.ehpandaWidth.rawValue, value: width), + URLQueryItem(name: Defaults.URL.Component.Key.ehpandaHeight.rawValue, value: height), + URLQueryItem(name: Defaults.URL.Component.Key.ehpandaOffset.rawValue, value: offset) + ]) } // GitHub diff --git a/EhPanda/Network/Request+Detail.swift b/EhPanda/Network/Request+Detail.swift index ffc2af5bc..cee5a0003 100644 --- a/EhPanda/Network/Request+Detail.swift +++ b/EhPanda/Network/Request+Detail.swift @@ -98,6 +98,13 @@ private struct GalleryVersionMetadataAPIResponse: Decodable { struct GalleryVersionMetadataRequest: Request { let gid: String let token: String + let urlSession: URLSession + + init(gid: String, token: String, urlSession: URLSession = .shared) { + self.gid = gid + self.token = token + self.urlSession = urlSession + } var publisher: AnyPublisher { guard let gid = Int(gid) else { @@ -115,7 +122,7 @@ struct GalleryVersionMetadataRequest: Request { request.httpMethod = "POST" request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) - return URLSession.shared.dataTaskPublisher(for: request) + return urlSession.dataTaskPublisher(for: request) .genericRetry() .map(\.data) .tryMap { data in diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/EhPanda/View/Detail/DetailReducer+Fetch.swift index e818f188e..96835d4c0 100644 --- a/EhPanda/View/Detail/DetailReducer+Fetch.swift +++ b/EhPanda/View/Detail/DetailReducer+Fetch.swift @@ -146,7 +146,7 @@ extension DetailReducer { state.didRequestVersionMetadata = true return .run { [gallery = state.gallery, previewURLs = state.galleryPreviewURLs, detail] send in let metadata: DownloadVersionMetadata? - switch await GalleryVersionMetadataRequest(gid: gallery.gid, token: gallery.token).response() { + switch await downloadClient.fetchVersionMetadata(gallery.gid, gallery.token) { case .success(let fetchedMetadata): metadata = fetchedMetadata case .failure: diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift index 31e2a6ba9..1b59892e1 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -46,9 +46,6 @@ struct DetailReducerMetadataTests: DownloadFeatureTestCase { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let galleryState = try sampleGalleryState(gid: gallery.gid) - let sessionID = UUID().uuidString - try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) - defer { uninstallSharedSessionStub(sessionID: sessionID) } let store = makeDownloadedMetadataTestStore( gid: gallery.gid, gallery: gallery, @@ -83,9 +80,6 @@ struct DetailReducerMetadataTests: DownloadFeatureTestCase { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let galleryState = try sampleGalleryState(gid: gallery.gid) - let sessionID = UUID().uuidString - try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) - defer { uninstallSharedSessionStub(sessionID: sessionID) } let store = makeDownloadedMetadataTestStore( gid: gallery.gid, gallery: gallery, @@ -136,6 +130,9 @@ private extension DetailReducerMetadataTests { refreshDownloads: {}, resumeQueue: {}, badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, badgeValue) }) }, + fetchVersionMetadata: { _, _ in + .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) + }, updateRemoteSignature: { _, _ in updateCheckCount.value += 1 return .none @@ -171,6 +168,9 @@ private extension DetailReducerMetadataTests { refreshDownloads: {}, resumeQueue: {}, badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, badgeValue) }) }, + fetchVersionMetadata: { _, _ in + .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) + }, updateRemoteSignature: { _, _ in updateCheckCount.value += 1 return .downloaded diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index b191831d8..c7d5b376a 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -16,9 +16,6 @@ struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let sessionID = UUID().uuidString - try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) - defer { uninstallSharedSessionStub(sessionID: sessionID) } let store = makeUpdateTestStore( gid: gallery.gid, gallery: gallery, detail: detail, @@ -41,9 +38,6 @@ struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let sessionID = UUID().uuidString - try installGalleryVersionMetadataStub(for: gallery, sessionID: sessionID) - defer { uninstallSharedSessionStub(sessionID: sessionID) } let store = makeUpdateTestStore( gid: gallery.gid, gallery: gallery, detail: detail, @@ -135,6 +129,9 @@ private extension DetailReducerMetadataUpdateTests { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, + fetchVersionMetadata: { _, _ in + .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) + }, updateRemoteSignature: { _, _ in updateCheckCount.value += 1 return .downloaded diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index a587713e3..3ca2f77f1 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -10,6 +10,7 @@ import Testing @Suite(.serialized) struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { + @MainActor @Test func testDetailReducerLaunchAutomationDoesNotRedownloadWhenBadgeIsResolved() async { let gallery = sampleGallery() diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 34e4c315e..4aae1c192 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -142,13 +142,9 @@ extension DownloadFeatureTestCase { } func makeInMemoryContainer() throws -> NSPersistentContainer { - let modelURL = try #require( - Bundle(for: TestBundleLocator.self).url(forResource: "Model", withExtension: "momd") - ?? Bundle.main.url(forResource: "Model", withExtension: "momd") - ) - let model = try #require(NSManagedObjectModel(contentsOf: modelURL)) let container = NSPersistentContainer( - name: UUID().uuidString, managedObjectModel: model + name: UUID().uuidString, + managedObjectModel: PersistenceController.shared.container.managedObjectModel ) let description = NSPersistentStoreDescription() description.type = NSInMemoryStoreType @@ -271,7 +267,8 @@ struct StubRouteContext { extension DownloadFeatureTestCase { func makeStubbedDownloadManager( rootURL: URL, - sessionID: String + sessionID: String, + persistenceContainer: NSPersistentContainer? = nil ) -> (DownloadFileStorage, DownloadManager) { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] @@ -281,9 +278,11 @@ extension DownloadFeatureTestCase { let storage = DownloadFileStorage( rootURL: rootURL, fileManager: .default ) + let container = persistenceContainer ?? PersistenceController.shared.container let manager = DownloadManager( storage: storage, - urlSession: URLSession(configuration: configuration) + urlSession: URLSession(configuration: configuration), + persistenceContainer: container ) return (storage, manager) } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift index 503ccedc0..abdc6f0a6 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -155,7 +155,8 @@ extension DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) return DownloadManager( storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared + urlSession: .shared, + persistenceContainer: PersistenceController.shared.container ) } diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift index 1cf936701..26962776f 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -20,7 +20,7 @@ struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) let normalImageURL = try #require( URL(string: "https://exhentai.org/fullimg.php?gid=\(gid)&page=1&key=normal-cache-key") ) diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift index 567f76f0a..fb02af33e 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift @@ -162,7 +162,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) let normalImageURL = try #require( URL(string: "https://ehgt.org/h/quota-placeholder-cache-\(gid)/1") ) diff --git a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift index 69dfa9603..252df6b0f 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift @@ -10,6 +10,7 @@ import Testing @Suite(.serialized) struct DownloadInspectorSkipTests: DownloadFeatureTestCase { + @MainActor @Test func testDownloadInspectorSkipsReloadWhenObservedDownloadDidNotChange() async { let download = sampleDownload( diff --git a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift index c1fbc302b..d80a84ae6 100644 --- a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift +++ b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift @@ -22,7 +22,8 @@ struct DownloadIpBanTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true), fileManager: .default ), - urlSession: URLSession(configuration: configuration) + urlSession: URLSession(configuration: configuration), + persistenceContainer: PersistenceController.shared.container ) let recorder = RequestRecorder() let ipBannedHTML = try fixtureData(resource: HTMLFilename.ipBanned.rawValue, pathExtension: "html") diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index ca58e96b3..e96fa9d97 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -24,7 +24,8 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared + urlSession: .shared, + persistenceContainer: container ) try insertPersistedDownload( in: container, @@ -76,7 +77,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) try insertPersistedDownload( in: container, gid: gid, status: .missingFiles, completedPageCount: 1, pageCount: 2, lastError: .init(code: .fileOperationFailed, message: "Page 1 is missing.") diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index 2b7dd61e2..f7f54772d 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -62,7 +62,7 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) try insertPersistedDownload( in: container, gid: gid, status: .completed, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 59926cd2e..0514a4f95 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -23,7 +23,8 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let manager = DownloadManager( storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared + urlSession: .shared, + persistenceContainer: container ) try insertPersistedDownload( @@ -79,7 +80,8 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared + urlSession: .shared, + persistenceContainer: container ) try insertPersistedDownload( @@ -138,7 +140,8 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared + urlSession: .shared, + persistenceContainer: container ) try insertPersistedDownload( diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 0363636f7..67a38d348 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -13,6 +13,7 @@ import Testing @Suite(.serialized) struct DownloadObserverBatchTests: DownloadFeatureTestCase { + @MainActor @Test func testDownloadInspectorClearsInspectionWhenObservedDownloadDisappears() async { let download = sampleDownload( diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index 6ea828202..866eeabb3 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -10,6 +10,7 @@ import Testing @Suite(.serialized) struct DownloadObserverReadingTests: DownloadFeatureTestCase { + @MainActor @Test func testReadingReducerLocalSourceWithoutGalleryStateDoesNotStayLoading() async throws { let download = sampleDownload( diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index b77f80756..c391069a7 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -33,7 +33,8 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { configuration.protocolClasses = [FailFastURLProtocol.self] let manager = DownloadManager( storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: URLSession(configuration: configuration) + urlSession: URLSession(configuration: configuration), + persistenceContainer: container ) try insertPersistedDownload( @@ -82,7 +83,8 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: URLSession(configuration: configuration) + urlSession: URLSession(configuration: configuration), + persistenceContainer: container ) try insertPersistedDownload( @@ -142,7 +144,8 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { configuration.protocolClasses = [FailFastURLProtocol.self] let manager = DownloadManager( storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: URLSession(configuration: configuration) + urlSession: URLSession(configuration: configuration), + persistenceContainer: container ) try insertPersistedDownload( @@ -173,7 +176,8 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { configuration.protocolClasses = [FailFastURLProtocol.self] let manager = DownloadManager( storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: URLSession(configuration: configuration) + urlSession: URLSession(configuration: configuration), + persistenceContainer: container ) try insertPersistedDownload( @@ -206,7 +210,9 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: URLSession(configuration: configuration)) + let manager = DownloadManager( + storage: storage, urlSession: URLSession(configuration: configuration), persistenceContainer: container + ) try insertPersistedDownload( in: container, gid: gid, status: .partial, completedPageCount: 1, pageCount: 2 ) diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 8af1422f2..7a736f03d 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -27,7 +27,8 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let cacheTestManager = try makeCacheTestManager( - rootURL: rootURL, sessionID: sessionID, gid: gid, pageIndex: pageIndex + rootURL: rootURL, sessionID: sessionID, gid: gid, pageIndex: pageIndex, + persistenceContainer: container ) let storage = cacheTestManager.storage let manager = cacheTestManager.manager @@ -42,8 +43,14 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { await waitUntilCacheReady(for: cachedKeys) let updatedPageCount = try await setupCacheTestDownload( - container: container, storage: storage, gid: gid, - pageIndex: pageIndex, oldVersionSignature: oldVersionSignature + .init( + container: container, + storage: storage, + manager: manager, + gid: gid, + pageIndex: pageIndex, + oldVersionSignature: oldVersionSignature + ) ) await manager.testingProcessDownload(gid: gid) @@ -72,19 +79,33 @@ struct CacheTestManagerResult { let metadataResponse: Data } +private struct CacheTestDownloadSetup { + let container: NSPersistentContainer + let storage: DownloadFileStorage + let manager: DownloadManager + let gid: String + let pageIndex: Int + let oldVersionSignature: String +} + // MARK: - Cache Test Helpers private extension DownloadProcessCacheTests { func makeCacheTestManager( - rootURL: URL, sessionID: String, gid: String, pageIndex: Int + rootURL: URL, sessionID: String, gid: String, pageIndex: Int, + persistenceContainer: NSPersistentContainer = PersistenceController.shared.container ) throws -> CacheTestManagerResult { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: URLSession(configuration: configuration)) + let manager = DownloadManager( + storage: storage, + urlSession: URLSession(configuration: configuration), + persistenceContainer: persistenceContainer + ) let content = StubHandlerContent( - detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), + detailHTML: try makeUniqueDetailHTML(gid: gid), mpvHTML: try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html"), metadataResponse: try makeMetadataResponseData(gid: gid) ) @@ -104,7 +125,7 @@ private extension DownloadProcessCacheTests { let detailHTML = content.detailHTML let mpvHTML = content.mpvHTML let metadataResponse = content.metadataResponse - let currentPageImageURL = URL(string: "https://example.com/image-\(pageIndex).jpg") + let currentPageImageURL = Self.currentPageImageURL(gid: gid, pageIndex: pageIndex) SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in guard let url = request.url else { throw URLError(.badURL) } if url.path.contains("/g/\(gid)/token") { @@ -127,6 +148,20 @@ private extension DownloadProcessCacheTests { } } + func makeUniqueDetailHTML(gid: String) throws -> Data { + let fixtureCoverURL = + "https://ehgt.org/03/08/0308268821e99628b05a19fa54e2fc0fa9ad8f4b-1705560-1012-1470-png_250.jpg" + let uniqueCoverURL = "https://example.com/download-cache/\(gid)/cover.jpg" + let fixtureHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") + let detailHTML = try #require(String(bytes: fixtureHTML, encoding: .utf8)) + .replacingOccurrences(of: fixtureCoverURL, with: uniqueCoverURL) + return Data(detailHTML.utf8) + } + + static func currentPageImageURL(gid: String, pageIndex: Int) -> URL? { + URL(string: "https://example.com/download-cache/\(gid)/image-\(pageIndex).jpg") + } + static func makeCacheHTMLResponse(url: URL) throws -> HTTPURLResponse { try #require(HTTPURLResponse( url: url, statusCode: 200, httpVersion: nil, @@ -167,7 +202,7 @@ private extension DownloadProcessCacheTests { pageIndex: Int, oldVersionSignature: String ) async throws -> (Set, URL) { let currentPageImageURL = try #require( - URL(string: "https://example.com/image-\(pageIndex).jpg") + Self.currentPageImageURL(gid: gid, pageIndex: pageIndex) ) let staleStoredPageURL = try #require( URL(string: "https://example.com/stale-image-\(gid)-1.jpg") @@ -206,47 +241,48 @@ private extension DownloadProcessCacheTests { return (cachedKeys, coverURL) } - func setupCacheTestDownload( - container: NSPersistentContainer, storage: DownloadFileStorage, - gid: String, pageIndex: Int, oldVersionSignature: String - ) async throws -> Int { + func setupCacheTestDownload(_ setup: CacheTestDownloadSetup) async throws -> Int { let staleStoredPageURL = try #require( - URL(string: "https://example.com/stale-image-\(gid)-1.jpg") + URL(string: "https://example.com/stale-image-\(setup.gid)-1.jpg") + ) + let plainPreviewURL = try #require( + URL(string: "https://ehgt.org/preview/\(setup.gid)/1.webp") ) - let plainPreviewURL = try #require(URL(string: "https://ehgt.org/preview/\(gid)/1.webp")) let combinedPreviewURL = URLUtil.combinedPreviewURL( plainURL: plainPreviewURL, width: "200", height: "300", offset: "40" ) let scaffoldDownload = sampleDownload( - gid: gid, title: "Pause Race", status: .partial, + gid: setup.gid, title: "Pause Race", status: .partial, pageCount: 156, completedPageCount: 155, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: oldVersionSignature + remoteVersionSignature: setup.oldVersionSignature, + latestRemoteVersionSignature: setup.oldVersionSignature ) - let latestPayload = try await DownloadManager( - storage: storage, urlSession: .shared - ).testingFetchLatestPayload( - for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] + let latestPayload = try await setup.manager.testingFetchLatestPayload( + for: scaffoldDownload, mode: .redownload, + pageSelection: [setup.pageIndex] ).payload let updatedPageCount = latestPayload.galleryDetail.pageCount let oldPageCount = updatedPageCount - 5 - #expect(updatedPageCount > pageIndex) + #expect(updatedPageCount > setup.pageIndex) #expect(oldPageCount > 0) - try insertPersistedDownload( - in: container, gid: gid, status: .partial, - completedPageCount: oldPageCount - 1, pageCount: oldPageCount, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: oldVersionSignature - ) - try insertPersistedGalleryState( - in: container, gid: gid, - previewURLs: [1: combinedPreviewURL], imageURLs: [1: staleStoredPageURL] - ) + try await MainActor.run { + try insertPersistedDownload( + in: setup.container, gid: setup.gid, status: .partial, + completedPageCount: oldPageCount - 1, pageCount: oldPageCount, + remoteVersionSignature: setup.oldVersionSignature, + latestRemoteVersionSignature: setup.oldVersionSignature + ) + try insertPersistedGalleryState( + in: setup.container, gid: setup.gid, + previewURLs: [1: combinedPreviewURL], + imageURLs: [1: staleStoredPageURL] + ) + } try setupCacheTestTemporaryFolder( - storage: storage, gid: gid, - pageIndex: pageIndex, oldPageCount: oldPageCount, - oldVersionSignature: oldVersionSignature + storage: setup.storage, gid: setup.gid, + pageIndex: setup.pageIndex, oldPageCount: oldPageCount, + oldVersionSignature: setup.oldVersionSignature ) return updatedPageCount } diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 21f2c71a3..1930e6f05 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -23,7 +23,9 @@ struct DownloadProcessTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let (storage, manager) = makeStubbedDownloadManager(rootURL: rootURL, sessionID: sessionID) + let (storage, manager) = makeStubbedDownloadManager( + rootURL: rootURL, sessionID: sessionID, persistenceContainer: container + ) defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } let (updatedPageCount, updatedVersionSignature) = try await fetchAndInstallStub( diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 0bd1ca4d0..1974b8694 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -20,7 +20,9 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let (storage, manager) = makeStubbedDownloadManager(rootURL: rootURL, sessionID: sessionID) + let (storage, manager) = makeStubbedDownloadManager( + rootURL: rootURL, sessionID: sessionID, persistenceContainer: container + ) let setup = try await setupMinimalSourceTest( manager: manager, sessionID: sessionID, gid: gid, pageIndex: pageIndex ) @@ -61,7 +63,6 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { pageSelection: [pageIndex] ) await manager.testingProcessDownload(gid: gid) - let secondRunSnapshot = setup.recorder.snapshot() #expect(secondRunSnapshot.previewPageNumbers.isEmpty) #expect(secondRunSnapshot.mpvRequests == 0) diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index e8fea8678..9682fb14d 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -19,7 +19,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) try insertPersistedDownload( in: container, gid: gid, status: .partial, completedPageCount: 1, pageCount: 2 ) @@ -62,7 +62,8 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared + urlSession: .shared, + persistenceContainer: container ) try insertPersistedDownload( diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index a3954204d..51ee20e7b 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -23,7 +23,9 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let (storage, queueingManager) = makeStubbedDownloadManager(rootURL: rootURL, sessionID: sessionID) + let (storage, queueingManager) = makeStubbedDownloadManager( + rootURL: rootURL, sessionID: sessionID, persistenceContainer: container + ) defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } let fallbackResult = try await fetchUpdateFallbackPayload( @@ -78,7 +80,9 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let (storage, immediateManager) = makeStubbedDownloadManager(rootURL: rootURL, sessionID: sessionID) + let (storage, immediateManager) = makeStubbedDownloadManager( + rootURL: rootURL, sessionID: sessionID, persistenceContainer: container + ) defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } let updateResult = try await fetchUpdateFallbackPayload( diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 46c045f9c..4a9b7ba31 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -20,7 +20,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) try insertPersistedDownload( in: container, gid: gid, @@ -63,7 +63,8 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { let manager = DownloadManager( storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared + urlSession: .shared, + persistenceContainer: container ) try insertPersistedDownload( in: container, @@ -95,7 +96,8 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { let manager = DownloadManager( storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared + urlSession: .shared, + persistenceContainer: container ) try insertPersistedDownload( in: container, @@ -130,7 +132,8 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { let manager = DownloadManager( storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared + urlSession: .shared, + persistenceContainer: container ) try insertPersistedDownload( in: container, diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift index 99d148e02..29a731b7f 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -10,6 +10,7 @@ import Testing @Suite(.serialized) struct DownloadsReducerActionTests: DownloadFeatureTestCase { + @MainActor @Test func testDownloadsReducerKeepsIdleStateForEmptyLibrary() async { let store = TestStore(initialState: DownloadsReducer.State()) { diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift index dedd114be..1c67d98aa 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -10,6 +10,7 @@ import Testing @Suite(.serialized) struct ReadingReducerLocalTests: DownloadFeatureTestCase { + @MainActor func testReadingReducerOnWebImageSucceededDoesNotCaptureAlreadyLocalPage() async { let capturedCalls = UncheckedBox([(String, Int, URL?)]()) let gallery = sampleGallery() From d038541c2259eb7a6ca9c624009e3ca3136697c4 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 12 May 2026 18:37:10 +0800 Subject: [PATCH 019/614] Resolve concurrency warnings --- EhPanda/Models/Support/LiveText.swift | 6 +- EhPanda/View/Reading/ReadingView.swift | 39 ++--- .../Reading/Support/LiveTextHandler.swift | 140 +++++++++--------- 3 files changed, 90 insertions(+), 95 deletions(-) diff --git a/EhPanda/Models/Support/LiveText.swift b/EhPanda/Models/Support/LiveText.swift index db6271fb2..6eccdeedc 100644 --- a/EhPanda/Models/Support/LiveText.swift +++ b/EhPanda/Models/Support/LiveText.swift @@ -7,7 +7,7 @@ import SwiftUI import Foundation // MARK: LiveTextBounds -struct LiveTextBounds: Equatable { +struct LiveTextBounds: Equatable, Sendable { let topLeft: CGPoint let topRight: CGPoint let bottomLeft: CGPoint @@ -88,7 +88,7 @@ struct LiveTextBounds: Equatable { } // MARK: LiveTextGroup -struct LiveTextGroup: Equatable, Identifiable { +struct LiveTextGroup: Equatable, Identifiable, Sendable { var id: UUID = .init() let blocks: [LiveTextBlock] let text: String @@ -130,7 +130,7 @@ struct LiveTextGroup: Equatable, Identifiable { } // MARK: LiveTextBlock -struct LiveTextBlock: Equatable, Identifiable { +struct LiveTextBlock: Equatable, Identifiable, Sendable { var id: UUID = .init() let text: String diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 898405dce..100c81ee9 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -290,7 +290,9 @@ extension ReadingView { return } let cacheKeys = imageURL.imageCacheKeys(includeStableAlias: true) - retrieveCachedImage(cacheKeys: ArraySlice(cacheKeys), index: index) + Task { + await retrieveCachedImage(cacheKeys: ArraySlice(cacheKeys), index: index) + } } private func analyzeLocalImage(at imageURL: URL, index: Int) { @@ -306,28 +308,27 @@ extension ReadingView { } } - private func retrieveCachedImage(cacheKeys: ArraySlice, index: Int) { + private func retrieveCachedImage(cacheKeys: ArraySlice, index: Int) async { guard let cacheKey = cacheKeys.first else { Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) return } - KingfisherManager.shared.cache.retrieveImage(forKey: cacheKey) { result in - switch result { - case .success(let result): - if let image = result.image, let cgImage = image.cgImage { - liveTextHandler.analyzeImage( - cgImage, size: image.size, index: index, recognitionLanguages: - store.galleryDetail?.language.codes - ) - } else { - retrieveCachedImage(cacheKeys: cacheKeys.dropFirst(), index: index) - } - case .failure: - if cacheKeys.count > 1 { - retrieveCachedImage(cacheKeys: cacheKeys.dropFirst(), index: index) - } else { - Logger.info("analyzeImageForLiveText failed", context: ["index": index]) - } + + do { + let result = try await KingfisherManager.shared.cache.retrieveImage(forKey: cacheKey) + if let image = result.image, let cgImage = image.cgImage { + liveTextHandler.analyzeImage( + cgImage, size: image.size, index: index, recognitionLanguages: + store.galleryDetail?.language.codes + ) + } else { + await retrieveCachedImage(cacheKeys: cacheKeys.dropFirst(), index: index) + } + } catch { + if cacheKeys.count > 1 { + await retrieveCachedImage(cacheKeys: cacheKeys.dropFirst(), index: index) + } else { + Logger.info("analyzeImageForLiveText failed", context: ["index": index]) } } } diff --git a/EhPanda/View/Reading/Support/LiveTextHandler.swift b/EhPanda/View/Reading/Support/LiveTextHandler.swift index b146e3e50..5c6d3ebae 100644 --- a/EhPanda/View/Reading/Support/LiveTextHandler.swift +++ b/EhPanda/View/Reading/Support/LiveTextHandler.swift @@ -24,7 +24,7 @@ final class LiveTextHandler { private(set) var focusedLiveTextGroup: LiveTextGroup? @ObservationIgnored - private var processingRequests = [VNRequest]() + private var analysisTasks = [Int: Task]() isolated deinit { cancelRequests() @@ -32,11 +32,12 @@ final class LiveTextHandler { func cancelRequests() { Logger.info("cancelRequests", context: [ - "processingRequestsCount": processingRequests.count + "processingRequestsCount": analysisTasks.count ]) - processingRequests.forEach { request in - request.cancel() + analysisTasks.values.forEach { task in + task.cancel() } + analysisTasks.removeAll() } func setFocusedLiveTextGroup(_ group: LiveTextGroup) { @@ -49,89 +50,82 @@ final class LiveTextHandler { "index": index, "recognitionLanguages": recognitionLanguages as Any ]) - let requestHandler = VNImageRequestHandler(cgImage: cgImage) - let textRecognitionRequest = VNRecognizeTextRequest { [weak self] in - self?.textRecognitionHandler(request: $0, error: $1, size: size, index: index) - } - textRecognitionRequest.usesLanguageCorrection = true - textRecognitionRequest.preferBackgroundProcessing = true - if let languages = recognitionLanguages { - textRecognitionRequest.recognitionLanguages = languages - } - - processingRequests.append(textRecognitionRequest) - DispatchQueue.global(qos: .utility).async { [weak self] in - guard let self = self else { return } + analysisTasks[index]?.cancel() + analysisTasks[index] = Task { [weak self] in do { - try requestHandler.perform([textRecognitionRequest]) + let groups = try await Self.recognizeTextGroups( + in: cgImage, + size: size, + recognitionLanguages: recognitionLanguages + ) + guard !Task.isCancelled else { return } + self?.liveTextGroups[index] = groups + } catch is CancellationError { } catch { - self.removeRequest(textRecognitionRequest) - Logger.info("Unable to perform the requests.", context: ["error": error]) + Logger.info("Unable to perform the requests.", context: [ + "error": error, "index": index + ]) } + self?.analysisTasks[index] = nil } } - private func removeRequest(_ request: VNRequest) { - if let index = processingRequests.firstIndex(of: request) { - processingRequests.remove(at: index) + @concurrent + private static func recognizeTextGroups( + in cgImage: CGImage, + size: CGSize, + recognitionLanguages: [String]? + ) async throws -> [LiveTextGroup] { + var request = RecognizeTextRequest() + request.usesLanguageCorrection = true + if let recognitionLanguages { + request.recognitionLanguages = recognitionLanguages.map { + Locale.Language(identifier: $0) + } } - } - private func textRecognitionHandler(request: VNRequest, error: Error?, size: CGSize, index: Int) { - Logger.info("textRecognitionHandler", context: [ - "request": request, "error": error as Any, "index": index - ]) - removeRequest(request) - - guard let observations = request.results as? [VNRecognizedTextObservation] else { return } - - DispatchQueue.global(qos: .userInteractive).async { [weak self] in - guard let self = self else { return } - let blocks: [LiveTextBlock] = observations.compactMap { observation in - guard let recognizedText = observation.topCandidates(1).first?.string else { return nil } - return .init( - text: recognizedText, - bounds: .init( - topLeft: observation.topLeft.verticalReversed, - topRight: observation.topRight.verticalReversed, - bottomLeft: observation.bottomLeft.verticalReversed, - bottomRight: observation.bottomRight.verticalReversed - ) + let observations = try await request.perform(on: cgImage) + let blocks: [LiveTextBlock] = observations.compactMap { observation in + guard let recognizedText = observation.topCandidates(1).first?.string else { return nil } + return .init( + text: recognizedText, + bounds: .init( + topLeft: observation.topLeft.cgPoint.verticalReversed, + topRight: observation.topRight.cgPoint.verticalReversed, + bottomLeft: observation.bottomLeft.cgPoint.verticalReversed, + bottomRight: observation.bottomRight.cgPoint.verticalReversed ) - } - - var groupData = [[LiveTextBlock]]() - blocks.forEach { newItem in - if let groupIndex = groupData.firstIndex(where: { items in - items.first { item in - let angle = abs(item.bounds.getAngle(size) - newItem.bounds.getAngle(size)) - .truncatingRemainder(dividingBy: 360.0) - let isAngleValid = angle < 5 || angle > (360 - 5) - let aHeight = item.bounds.getHeight(size) - let bHeight = newItem.bounds.getHeight(size) - let isHeightValid = abs(aHeight - bHeight) < (min(aHeight, bHeight) / 2) - - guard isAngleValid && isHeightValid else { return false } - return self.polygonsIntersecting( - lhs: item.bounds.expandingHalfHeight(size).edges, - rhs: newItem.bounds.expandingHalfHeight(size).edges - ) - } != nil - }) { - groupData[groupIndex].append(newItem) - } else { - groupData.append([newItem]) - } - } + ) + } - let groups = groupData.compactMap(LiveTextGroup.init) - DispatchQueue.main.async { - self.liveTextGroups[index] = groups + var groupData = [[LiveTextBlock]]() + blocks.forEach { newItem in + if let groupIndex = groupData.firstIndex(where: { items in + items.first { item in + let angle = abs(item.bounds.getAngle(size) - newItem.bounds.getAngle(size)) + .truncatingRemainder(dividingBy: 360.0) + let isAngleValid = angle < 5 || angle > (360 - 5) + let aHeight = item.bounds.getHeight(size) + let bHeight = newItem.bounds.getHeight(size) + let isHeightValid = abs(aHeight - bHeight) < (min(aHeight, bHeight) / 2) + + guard isAngleValid && isHeightValid else { return false } + return polygonsIntersecting( + lhs: item.bounds.expandingHalfHeight(size).edges, + rhs: newItem.bounds.expandingHalfHeight(size).edges + ) + } != nil + }) { + groupData[groupIndex].append(newItem) + } else { + groupData.append([newItem]) } } + + return groupData.compactMap(LiveTextGroup.init) } - private func polygonsIntersecting(lhs: [CGPoint], rhs: [CGPoint]) -> Bool { + nonisolated private static func polygonsIntersecting(lhs: [CGPoint], rhs: [CGPoint]) -> Bool { guard !lhs.isEmpty, !rhs.isEmpty, lhs.count == rhs.count else { return false } for points in [lhs, rhs] { for index1 in 0.. Date: Tue, 12 May 2026 19:35:03 +0800 Subject: [PATCH 020/614] Adopt to Swift Concurrency --- EhPanda.xcodeproj/project.pbxproj | 24 +++-- .../App/Tools/Clients/AppDelegateClient.swift | 8 +- .../Tools/Clients/AuthorizationClient.swift | 6 +- .../App/Tools/Clients/ClipboardClient.swift | 10 +- EhPanda/App/Tools/Clients/CookieClient.swift | 12 +-- EhPanda/App/Tools/Clients/DFClient.swift | 4 +- .../App/Tools/Clients/DatabaseClient.swift | 13 +-- EhPanda/App/Tools/Clients/DeviceClient.swift | 14 +-- .../DownloadClient+ExecutionSupport.swift | 2 +- .../Clients/DownloadClient+Manager.swift | 2 +- .../Clients/DownloadClient+Persistence.swift | 8 +- .../Clients/DownloadClient+PublicAPI.swift | 36 +++++-- .../DownloadClient+SchedulingHelpers.swift | 2 +- .../App/Tools/Clients/DownloadClient.swift | 82 ++++++++------- EhPanda/App/Tools/Clients/FileClient.swift | 10 +- EhPanda/App/Tools/Clients/HapticsClient.swift | 6 +- EhPanda/App/Tools/Clients/ImageClient.swift | 12 +-- EhPanda/App/Tools/Clients/LibraryClient.swift | 27 +++-- EhPanda/App/Tools/Clients/LoggerClient.swift | 6 +- .../Tools/Clients/UIApplicationClient.swift | 10 +- EhPanda/App/Tools/Clients/URLClient.swift | 8 +- .../Tools/Clients/UserDefaultsClient.swift | 4 +- EhPanda/App/Tools/Defaults.swift | 11 ++- .../Tools/Extensions/Reducer_Extension.swift | 6 +- .../SwiftUINavigation_Extension.swift | 27 ++--- .../Extensions/TTProgressHUD_Extension.swift | 42 +++++++- EhPanda/App/Tools/Utilities/DeviceUtil.swift | 9 +- .../Tools/Utilities/DownloadFileStorage.swift | 97 +++++++++++++++--- EhPanda/App/Tools/Utilities/HapticsUtil.swift | 1 + EhPanda/DataFlow/AppReducer.swift | 31 ++++-- EhPanda/DataFlow/AppRouteReducer.swift | 7 +- .../Database/Migration/CoreDataMigrator.swift | 2 +- EhPanda/Database/Persistence.swift | 14 ++- EhPanda/Models/Persistent/Setting.swift | 4 +- EhPanda/Models/Support/AppError.swift | 2 +- EhPanda/Network/Request+Account.swift | 28 +++--- EhPanda/Network/Request.swift | 21 ++-- .../Detail/Archives/ArchivesReducer.swift | 9 +- .../Detail/Comments/CommentsReducer.swift | 9 +- .../View/Detail/DetailReducer+Actions.swift | 30 +++--- .../View/Detail/DetailReducer+Download.swift | 16 +-- EhPanda/View/Detail/DetailReducer+Fetch.swift | 22 ++--- EhPanda/View/Detail/DetailReducer.swift | 2 +- .../GalleryInfos/GalleryInfosReducer.swift | 5 +- .../Detail/Torrents/TorrentsReducer.swift | 5 +- EhPanda/View/Favorites/FavoritesReducer.swift | 14 +-- EhPanda/View/Home/HomeReducer+Body.swift | 9 +- EhPanda/View/Home/HomeReducer.swift | 3 +- .../View/Home/Toplists/ToplistsReducer.swift | 4 +- .../View/Reading/ReadingReducer+Body.swift | 12 +-- .../Reading/ReadingReducer+Database.swift | 9 +- EhPanda/View/Reading/ReadingReducer.swift | 7 +- EhPanda/View/Reading/ReadingView.swift | 16 ++- .../Reading/Support/AutoPlayHandler.swift | 8 +- .../View/Reading/Support/LiveTextView.swift | 1 + EhPanda/View/Search/SearchRootReducer.swift | 4 +- .../AccountSettingReducer.swift | 5 +- EhPanda/View/Setting/Login/LoginReducer.swift | 6 +- .../View/Setting/SettingReducer+Body.swift | 18 +++- EhPanda/View/Setting/SettingReducer.swift | 1 + .../Support/Components/PreviewImageView.swift | 11 ++- .../Support/Components/TagCloudView.swift | 99 +++++++++++-------- EhPanda/View/TabBar/TabBarReducer.swift | 6 +- .../Download/DetailReducerDownloadTests.swift | 1 + .../Download/DetailReducerMetadataTests.swift | 1 + .../DetailReducerMetadataUpdateTests.swift | 1 + .../Download/DetailReducerObserveTests.swift | 1 + .../DetailReducerPauseAndGuardTests.swift | 1 + .../DownloadFeatureTestFactories.swift | 25 +++-- .../DownloadFeatureTestSupportTypes.swift | 44 ++++++--- .../Download/DownloadFileStorageTests.swift | 12 +-- .../Download/DownloadInspectorLoadTests.swift | 1 + .../DownloadInspectorRetryTests.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../Download/DownloadProcessCacheTests.swift | 2 +- .../DownloadRetryMinimalSourceTests.swift | 61 +++++++++--- .../PreviewsReducerDownloadTests.swift | 11 ++- .../ReadingReducerDownloadTests.swift | 1 + ShareExtension/ShareViewController.swift | 12 ++- 80 files changed, 709 insertions(+), 407 deletions(-) diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 572548d93..d289e8b0e 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -503,7 +503,9 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -530,7 +532,9 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -678,8 +682,10 @@ PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -705,8 +711,10 @@ PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -731,7 +739,9 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EhPanda.app/EhPanda"; }; @@ -757,7 +767,9 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EhPanda.app/EhPanda"; }; diff --git a/EhPanda/App/Tools/Clients/AppDelegateClient.swift b/EhPanda/App/Tools/Clients/AppDelegateClient.swift index 877b4bcee..b484f7d14 100644 --- a/EhPanda/App/Tools/Clients/AppDelegateClient.swift +++ b/EhPanda/App/Tools/Clients/AppDelegateClient.swift @@ -6,9 +6,9 @@ import SwiftUI import ComposableArchitecture -struct AppDelegateClient { - let setOrientation: @MainActor (UIInterfaceOrientationMask) -> Void - let setOrientationMask: (UIInterfaceOrientationMask) -> Void +struct AppDelegateClient: Sendable { + let setOrientation: @MainActor @Sendable (UIInterfaceOrientationMask) -> Void + let setOrientationMask: @MainActor @Sendable (UIInterfaceOrientationMask) -> Void } extension AppDelegateClient { @@ -25,9 +25,11 @@ extension AppDelegateClient { func setPortraitOrientation() { setOrientation(.portrait) } + @MainActor func setAllOrientationMask() { setOrientationMask([.all]) } + @MainActor func setPortraitOrientationMask() { setOrientationMask([.portrait, .portraitUpsideDown]) } diff --git a/EhPanda/App/Tools/Clients/AuthorizationClient.swift b/EhPanda/App/Tools/Clients/AuthorizationClient.swift index 18a929406..1f3a791bc 100644 --- a/EhPanda/App/Tools/Clients/AuthorizationClient.swift +++ b/EhPanda/App/Tools/Clients/AuthorizationClient.swift @@ -7,9 +7,9 @@ import Combine import LocalAuthentication import ComposableArchitecture -struct AuthorizationClient { - let passcodeNotSet: () -> Bool - let localAuthroize: (String) async -> Bool +struct AuthorizationClient: Sendable { + let passcodeNotSet: @Sendable () -> Bool + let localAuthroize: @Sendable (String) async -> Bool } extension AuthorizationClient { diff --git a/EhPanda/App/Tools/Clients/ClipboardClient.swift b/EhPanda/App/Tools/Clients/ClipboardClient.swift index 4bed6f1e2..1cb4a97a7 100644 --- a/EhPanda/App/Tools/Clients/ClipboardClient.swift +++ b/EhPanda/App/Tools/Clients/ClipboardClient.swift @@ -7,11 +7,11 @@ import SwiftUI import ComposableArchitecture import UniformTypeIdentifiers -struct ClipboardClient { - let url: () -> URL? - let changeCount: () -> Int - let saveText: (String) -> Void - let saveImage: (UIImage, Bool) -> Void +struct ClipboardClient: Sendable { + let url: @Sendable () -> URL? + let changeCount: @Sendable () -> Int + let saveText: @Sendable (String) -> Void + let saveImage: @Sendable (UIImage, Bool) -> Void } extension ClipboardClient { diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift index eaaf4922d..eaed795bb 100644 --- a/EhPanda/App/Tools/Clients/CookieClient.swift +++ b/EhPanda/App/Tools/Clients/CookieClient.swift @@ -6,12 +6,12 @@ import Foundation import ComposableArchitecture -struct CookieClient { - let clearAll: () -> Void - let getCookie: (URL, String) -> CookieValue - private let removeCookie: (URL, String) -> Void - private let checkExistence: (URL, String) -> Bool - private let initializeCookie: (HTTPCookie, String) -> HTTPCookie +struct CookieClient: Sendable { + let clearAll: @Sendable () -> Void + let getCookie: @Sendable (URL, String) -> CookieValue + private let removeCookie: @Sendable (URL, String) -> Void + private let checkExistence: @Sendable (URL, String) -> Bool + private let initializeCookie: @Sendable (HTTPCookie, String) -> HTTPCookie } extension CookieClient { diff --git a/EhPanda/App/Tools/Clients/DFClient.swift b/EhPanda/App/Tools/Clients/DFClient.swift index c74e2c33d..389095876 100644 --- a/EhPanda/App/Tools/Clients/DFClient.swift +++ b/EhPanda/App/Tools/Clients/DFClient.swift @@ -7,8 +7,8 @@ import Foundation import Kingfisher import ComposableArchitecture -struct DFClient { - let setActive: (Bool) -> Void +struct DFClient: Sendable { + let setActive: @Sendable (Bool) -> Void } extension DFClient { diff --git a/EhPanda/App/Tools/Clients/DatabaseClient.swift b/EhPanda/App/Tools/Clients/DatabaseClient.swift index 92387296b..910cc7fc5 100644 --- a/EhPanda/App/Tools/Clients/DatabaseClient.swift +++ b/EhPanda/App/Tools/Clients/DatabaseClient.swift @@ -8,11 +8,12 @@ import Combine import CoreData import ComposableArchitecture -struct DatabaseClient { - let prepareDatabase: () async -> Result - let dropDatabase: () async -> Result - private let saveContext: () -> Void - private let materializedObjects: (NSManagedObjectContext, NSPredicate) -> [NSManagedObject] +struct DatabaseClient: Sendable { + let prepareDatabase: @Sendable () async -> Result + let dropDatabase: @Sendable () async -> Result + private let saveContext: @Sendable () -> Void + private let materializedObjects: + @Sendable (NSManagedObjectContext, NSPredicate) -> [NSManagedObject] } extension DatabaseClient { @@ -161,7 +162,7 @@ extension DatabaseClient { func update( entityType: MO.Type, gid: String, createIfNil: Bool = false, - commitChanges: @escaping ((MO) -> Void) + commitChanges: @escaping @Sendable ((MO) -> Void) ) { AppUtil.dispatchMainSync { let storedMO: MO? diff --git a/EhPanda/App/Tools/Clients/DeviceClient.swift b/EhPanda/App/Tools/Clients/DeviceClient.swift index 27bc12657..9abbc8c4b 100644 --- a/EhPanda/App/Tools/Clients/DeviceClient.swift +++ b/EhPanda/App/Tools/Clients/DeviceClient.swift @@ -6,17 +6,19 @@ import SwiftUI import Dependencies -struct DeviceClient { - let isPad: () -> Bool - let absWindowW: () -> Double - let absWindowH: () -> Double - let touchPoint: () -> CGPoint? +struct DeviceClient: Sendable { + let isPad: @Sendable () async -> Bool + let absWindowW: @MainActor @Sendable () -> Double + let absWindowH: @MainActor @Sendable () -> Double + let touchPoint: @MainActor @Sendable () -> CGPoint? } extension DeviceClient { static let live: Self = .init( isPad: { - DeviceUtil.isPad + await MainActor.run { + DeviceUtil.isPad + } }, absWindowW: { DeviceUtil.absWindowW diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index f0de7ec03..c4eeb3824 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -220,7 +220,7 @@ extension DownloadManager { temporaryFolderURL: URL, shouldReuse: Bool, seedContext: RepairSeedContext, - localFileManager: FileManager + localFileManager: DownloadFileManager ) throws { if !shouldReuse { try? localFileManager.removeItem(at: temporaryFolderURL) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index c838c0242..406662811 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -151,7 +151,7 @@ actor DownloadManager { self.persistenceContainer = persistenceContainer } - func fileManager() -> FileManager { + func fileManager() -> DownloadFileManager { storage.fileManager } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 16ebe5926..07bd4115b 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -70,7 +70,7 @@ extension DownloadManager { func updateDownloadRecord( gid: String, createIfMissing: Bool = true, - update: @escaping (DownloadedGalleryMO) -> Void + update: @MainActor @Sendable @escaping (DownloadedGalleryMO) -> Void ) async throws { try await MainActor.run { let context = persistenceContainer.viewContext @@ -191,7 +191,7 @@ extension DownloadManager { } } - private func applyFailureStatus( + nonisolated private func applyFailureStatus( to record: DownloadedGalleryMO, context: FailureContext, workingCompletedPageCount: Int, @@ -217,7 +217,7 @@ extension DownloadManager { } } - private func applyRepairFailureStatus( + nonisolated private func applyRepairFailureStatus( to record: DownloadedGalleryMO, context: FailureContext ) { @@ -236,7 +236,7 @@ extension DownloadManager { ?? context.originalDownload.latestRemoteVersionSignature } - private func applyFallbackFailureStatus( + nonisolated private func applyFallbackFailureStatus( to record: DownloadedGalleryMO, context: FailureContext ) { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 033791546..9dba59b33 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -76,12 +76,12 @@ extension DownloadManager { token: download.token ) ) - var didChange = false + let didChange = signatureUpdateWouldChange(info: info) do { try await updateDownloadRecord( gid: gid, createIfMissing: false ) { record in - self.applySignatureUpdate(to: record, info: info, didChange: &didChange) + self.applySignatureUpdate(to: record, info: info) } } catch { Logger.error(error) @@ -90,21 +90,18 @@ extension DownloadManager { return (await fetchDownload(gid: gid))?.badge ?? .none } - private func applySignatureUpdate( + nonisolated private func applySignatureUpdate( to record: DownloadedGalleryMO, - info: SignatureUpdateInfo, - didChange: inout Bool + info: SignatureUpdateInfo ) { let download = info.download let latestSignature = info.latestSignature if download.latestRemoteVersionSignature != latestSignature { record.latestRemoteVersionSignature = latestSignature - didChange = true } if let canonicalized = info.canonicalizedSignature, canonicalized != download.remoteVersionSignature { record.remoteVersionSignature = canonicalized - didChange = true } guard latestSignature?.notEmpty == true, [.completed, .updateAvailable].contains(download.status) @@ -117,10 +114,33 @@ extension DownloadManager { } if let desiredStatus, desiredStatus != download.status { record.status = desiredStatus.rawValue - didChange = true } } + nonisolated private func signatureUpdateWouldChange( + info: SignatureUpdateInfo + ) -> Bool { + let download = info.download + let latestSignature = info.latestSignature + if download.latestRemoteVersionSignature != latestSignature { + return true + } + if let canonicalized = info.canonicalizedSignature, + canonicalized != download.remoteVersionSignature { + return true + } + guard latestSignature?.notEmpty == true, + [.completed, .updateAvailable].contains(download.status) + else { return false } + let desiredStatus: DownloadStatus? + switch info.comparison { + case .different: desiredStatus = .updateAvailable + case .same: desiredStatus = .completed + case .incomparable: desiredStatus = nil + } + return desiredStatus != nil && desiredStatus != download.status + } + func enqueue( payload: DownloadRequestPayload ) async -> Result { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index c7d26ddfc..f9631eec8 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -193,7 +193,7 @@ extension DownloadManager { return try? storage.readResumeState(folderURL: folderURL).mode } - func fallbackStatus( + nonisolated func fallbackStatus( for download: DownloadedGallery, mode: DownloadStartMode, latestSignature: String? diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index e901cd467..fc9f9f16b 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -6,46 +6,52 @@ import Foundation import ComposableArchitecture -struct DownloadClient { - let observeDownloads: () -> AsyncStream<[DownloadedGallery]> - let fetchDownloads: () async -> [DownloadedGallery] - let fetchDownload: (String) async -> DownloadedGallery? - let reconcileDownloads: () async -> Void - let refreshDownloads: () async -> Void - let resumeQueue: () async -> Void - let badges: ([String]) async -> [String: DownloadBadge] - let fetchVersionMetadata: (String, String) async -> Result - let updateRemoteSignature: (String, String?) async -> DownloadBadge - let enqueue: (DownloadRequestPayload) async -> Result - let togglePause: (String) async -> Result - let retry: (String, DownloadStartMode) async -> Result - let retryPages: (String, [Int]) async -> Result - let delete: (String) async -> Result - let loadManifest: (String) async -> Result<(DownloadedGallery, DownloadManifest), AppError> - let loadLocalPageURLs: (String) async -> Result<[Int: URL], AppError> - let captureCachedPage: (String, Int, URL?) async -> Void - let loadInspection: (String) async -> Result +struct DownloadClient: Sendable { + let observeDownloads: @Sendable () -> AsyncStream<[DownloadedGallery]> + let fetchDownloads: @Sendable () async -> [DownloadedGallery] + let fetchDownload: @Sendable (String) async -> DownloadedGallery? + let reconcileDownloads: @Sendable () async -> Void + let refreshDownloads: @Sendable () async -> Void + let resumeQueue: @Sendable () async -> Void + let badges: @Sendable ([String]) async -> [String: DownloadBadge] + let fetchVersionMetadata: @Sendable (String, String) async -> Result + let updateRemoteSignature: @Sendable (String, String?) async -> DownloadBadge + let enqueue: @Sendable (DownloadRequestPayload) async -> Result + let togglePause: @Sendable (String) async -> Result + let retry: @Sendable (String, DownloadStartMode) async -> Result + let retryPages: @Sendable (String, [Int]) async -> Result + let delete: @Sendable (String) async -> Result + let loadManifest: @Sendable (String) async -> Result<(DownloadedGallery, DownloadManifest), AppError> + let loadLocalPageURLs: @Sendable (String) async -> Result<[Int: URL], AppError> + let captureCachedPage: @Sendable (String, Int, URL?) async -> Void + let loadInspection: @Sendable (String) async -> Result init( - observeDownloads: @escaping () -> AsyncStream<[DownloadedGallery]>, - fetchDownloads: @escaping () async -> [DownloadedGallery], - fetchDownload: @escaping (String) async -> DownloadedGallery?, - reconcileDownloads: @escaping () async -> Void = {}, - refreshDownloads: @escaping () async -> Void, - resumeQueue: @escaping () async -> Void, - badges: @escaping ([String]) async -> [String: DownloadBadge], - fetchVersionMetadata: @escaping (String, String) async -> Result + observeDownloads: @escaping @Sendable () -> AsyncStream<[DownloadedGallery]>, + fetchDownloads: @escaping @Sendable () async -> [DownloadedGallery], + fetchDownload: @escaping @Sendable (String) async -> DownloadedGallery?, + reconcileDownloads: @escaping @Sendable () async -> Void = {}, + refreshDownloads: @escaping @Sendable () async -> Void, + resumeQueue: @escaping @Sendable () async -> Void, + badges: @escaping @Sendable ([String]) async -> [String: DownloadBadge], + fetchVersionMetadata: @escaping @Sendable (String, String) async -> Result = { _, _ in .failure(.notFound) }, - updateRemoteSignature: @escaping (String, String?) async -> DownloadBadge, - enqueue: @escaping (DownloadRequestPayload) async -> Result, - togglePause: @escaping (String) async -> Result, - retry: @escaping (String, DownloadStartMode) async -> Result, - retryPages: @escaping (String, [Int]) async -> Result = { _, _ in .success(()) }, - delete: @escaping (String) async -> Result, - loadManifest: @escaping (String) async -> Result<(DownloadedGallery, DownloadManifest), AppError>, - loadLocalPageURLs: @escaping (String) async -> Result<[Int: URL], AppError> = { _ in .failure(.notFound) }, - captureCachedPage: @escaping (String, Int, URL?) async -> Void = { _, _, _ in }, - loadInspection: @escaping (String) async -> Result = { _ in .failure(.notFound) } + updateRemoteSignature: @escaping @Sendable (String, String?) async -> DownloadBadge, + enqueue: @escaping @Sendable (DownloadRequestPayload) async -> Result, + togglePause: @escaping @Sendable (String) async -> Result, + retry: @escaping @Sendable (String, DownloadStartMode) async -> Result, + retryPages: @escaping @Sendable (String, [Int]) async -> Result = { _, _ in .success(()) }, + delete: @escaping @Sendable (String) async -> Result, + loadManifest: @escaping @Sendable (String) async -> Result< + (DownloadedGallery, DownloadManifest), AppError + >, + loadLocalPageURLs: @escaping @Sendable (String) async -> Result< + [Int: URL], AppError + > = { _ in .failure(.notFound) }, + captureCachedPage: @escaping @Sendable (String, Int, URL?) async -> Void = { _, _, _ in }, + loadInspection: @escaping @Sendable (String) async -> Result< + DownloadInspection, AppError + > = { _ in .failure(.notFound) } ) { self.observeDownloads = observeDownloads self.fetchDownloads = fetchDownloads @@ -72,7 +78,7 @@ extension DownloadClient { static func live( rootURL: URL? = FileUtil.downloadsDirectoryURL, urlSession: URLSession = .shared, - fileManager: FileManager = .default + fileManager: sending FileManager = .default ) -> Self { let manager = DownloadManager( storage: .init(rootURL: rootURL, fileManager: fileManager), diff --git a/EhPanda/App/Tools/Clients/FileClient.swift b/EhPanda/App/Tools/Clients/FileClient.swift index cd9b71e85..a608ed0aa 100644 --- a/EhPanda/App/Tools/Clients/FileClient.swift +++ b/EhPanda/App/Tools/Clients/FileClient.swift @@ -7,11 +7,11 @@ import Combine import Foundation import ComposableArchitecture -struct FileClient { - let createFile: (String, Data?) -> Bool - let fetchLogs: () async -> Result<[Log], AppError> - let deleteLog: (String) async -> Result - let importTagTranslator: (URL) async -> Result +struct FileClient: Sendable { + let createFile: @Sendable (String, Data?) -> Bool + let fetchLogs: @Sendable () async -> Result<[Log], AppError> + let deleteLog: @Sendable (String) async -> Result + let importTagTranslator: @Sendable (URL) async -> Result } extension FileClient { diff --git a/EhPanda/App/Tools/Clients/HapticsClient.swift b/EhPanda/App/Tools/Clients/HapticsClient.swift index 25e4d7d59..58eef165c 100644 --- a/EhPanda/App/Tools/Clients/HapticsClient.swift +++ b/EhPanda/App/Tools/Clients/HapticsClient.swift @@ -6,9 +6,9 @@ import SwiftUI import ComposableArchitecture -struct HapticsClient { - let generateFeedback: (UIImpactFeedbackGenerator.FeedbackStyle) -> Void - let generateNotificationFeedback: (UINotificationFeedbackGenerator.FeedbackType) -> Void +struct HapticsClient: Sendable { + let generateFeedback: @MainActor @Sendable (UIImpactFeedbackGenerator.FeedbackStyle) -> Void + let generateNotificationFeedback: @MainActor @Sendable (UINotificationFeedbackGenerator.FeedbackType) -> Void } extension HapticsClient { diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index b95e03011..7b361f75f 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -9,11 +9,11 @@ import Combine import Kingfisher import ComposableArchitecture -struct ImageClient { - let prefetchImages: ([URL]) -> Void - let saveImageToPhotoLibrary: (UIImage, Bool) async -> Bool - let downloadImage: (URL) async -> Result - let retrieveImage: (String) async -> Result +struct ImageClient: Sendable { + let prefetchImages: @Sendable ([URL]) -> Void + let saveImageToPhotoLibrary: @Sendable (UIImage, Bool) async -> Bool + let downloadImage: @Sendable (URL) async -> Result + let retrieveImage: @Sendable (String) async -> Result } extension ImageClient { @@ -87,7 +87,7 @@ extension ImageClient { private final class ImageSaver: NSObject { private let completion: (Bool) -> Void - init(completion: @escaping (Bool) -> Void) { + init(completion: @escaping @Sendable (Bool) -> Void) { self.completion = completion } diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index 5dd303bf5..574e47f8a 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -12,12 +12,12 @@ import UIImageColors import KingfisherWebP import ComposableArchitecture -struct LibraryClient { - let initializeLogger: () -> Void - let initializeWebImage: () -> Void - let clearWebImageDiskCache: () -> Void - let analyzeImageColors: (UIImage) async -> UIImageColors? - let calculateWebImageDiskCacheSize: () async -> UInt? +struct LibraryClient: Sendable { + let initializeLogger: @Sendable () -> Void + let initializeWebImage: @Sendable () -> Void + let clearWebImageDiskCache: @Sendable () -> Void + let analyzeImageColors: @Sendable (UIImage) async -> [Color]? + let calculateWebImageDiskCacheSize: @Sendable () async -> UInt? } extension LibraryClient { @@ -66,7 +66,15 @@ extension LibraryClient { analyzeImageColors: { image in await withCheckedContinuation { continuation in image.getColors(quality: .lowest) { colors in - continuation.resume(returning: colors) + continuation.resume( + returning: colors.map { + [ + $0.primary, $0.secondary, + $0.detail, $0.background + ] + .map(Color.init) + } + ) } } }, @@ -110,7 +118,10 @@ extension LibraryClient { initializeLogger: IssueReporting.unimplemented(placeholder: placeholder()), initializeWebImage: IssueReporting.unimplemented(placeholder: placeholder()), clearWebImageDiskCache: IssueReporting.unimplemented(placeholder: placeholder()), - analyzeImageColors: IssueReporting.unimplemented(placeholder: placeholder()), + analyzeImageColors: { _ in + reportIssue("Unimplemented: LibraryClient.analyzeImageColors") + return .none + }, calculateWebImageDiskCacheSize: IssueReporting.unimplemented(placeholder: placeholder()) ) diff --git a/EhPanda/App/Tools/Clients/LoggerClient.swift b/EhPanda/App/Tools/Clients/LoggerClient.swift index 7b6c87115..e24df02ad 100644 --- a/EhPanda/App/Tools/Clients/LoggerClient.swift +++ b/EhPanda/App/Tools/Clients/LoggerClient.swift @@ -5,9 +5,9 @@ import ComposableArchitecture -struct LoggerClient { - let info: (Any, Any?) -> Void - let error: (Any, Any?) -> Void +struct LoggerClient: Sendable { + let info: @Sendable (Any, Any?) -> Void + let error: @Sendable (Any, Any?) -> Void } extension LoggerClient { diff --git a/EhPanda/App/Tools/Clients/UIApplicationClient.swift b/EhPanda/App/Tools/Clients/UIApplicationClient.swift index 468cb04c8..054603173 100644 --- a/EhPanda/App/Tools/Clients/UIApplicationClient.swift +++ b/EhPanda/App/Tools/Clients/UIApplicationClient.swift @@ -7,12 +7,12 @@ import SwiftUI import Combine import ComposableArchitecture -struct UIApplicationClient { - let openURL: @MainActor (URL) -> Void +struct UIApplicationClient: Sendable { + let openURL: @MainActor @Sendable (URL) -> Void let hideKeyboard: @Sendable () async -> Void - let alternateIconName: () -> String? - let setAlternateIconName: @MainActor (String?) async -> Bool - let setUserInterfaceStyle: @MainActor (UIUserInterfaceStyle) -> Void + let alternateIconName: @MainActor @Sendable () -> String? + let setAlternateIconName: @MainActor @Sendable (String?) async -> Bool + let setUserInterfaceStyle: @MainActor @Sendable (UIUserInterfaceStyle) -> Void } extension UIApplicationClient { diff --git a/EhPanda/App/Tools/Clients/URLClient.swift b/EhPanda/App/Tools/Clients/URLClient.swift index 456ec2702..7876ae44d 100644 --- a/EhPanda/App/Tools/Clients/URLClient.swift +++ b/EhPanda/App/Tools/Clients/URLClient.swift @@ -12,10 +12,10 @@ struct URLAnalysisResult { let commentID: String? } -struct URLClient { - let checkIfHandleable: (URL) -> Bool - let checkIfMPVURL: (URL?) -> Bool - let parseGalleryID: (URL) -> String +struct URLClient: Sendable { + let checkIfHandleable: @Sendable (URL) -> Bool + let checkIfMPVURL: @Sendable (URL?) -> Bool + let parseGalleryID: @Sendable (URL) -> String } extension URLClient { diff --git a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift index f886a8b23..8e747a7ec 100644 --- a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift +++ b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift @@ -6,8 +6,8 @@ import Foundation import ComposableArchitecture -struct UserDefaultsClient { - let setValue: (Any, AppUserDefaults) -> Void +struct UserDefaultsClient: Sendable { + let setValue: @Sendable (Any, AppUserDefaults) -> Void } extension UserDefaultsClient { diff --git a/EhPanda/App/Tools/Defaults.swift b/EhPanda/App/Tools/Defaults.swift index 9fb458271..99746f93c 100644 --- a/EhPanda/App/Tools/Defaults.swift +++ b/EhPanda/App/Tools/Defaults.swift @@ -7,9 +7,11 @@ import UIKit import Foundation struct Defaults { + @MainActor struct FrameSize { - static let archiveGridWidth: CGFloat = + static var archiveGridWidth: CGFloat { DeviceUtil.isPadWidth ? 175 : DeviceUtil.isSEWidth ? 125 : 150 + } static var cardCellWidth: CGFloat { DeviceUtil.windowW * 0.8 } static let cardCellHeight: CGFloat = Defaults.ImageSize.headerH + 20 * 2 static var cardCellSize: CGSize { @@ -22,6 +24,7 @@ struct Defaults { DeviceUtil.isPadWidth ? 0.5 : 1.0 } } + @MainActor struct ImageSize { static let rowAspect: CGFloat = 8/11 static let headerAspect: CGFloat = 8/11 @@ -34,9 +37,9 @@ struct Defaults { static let rowH: CGFloat = 120 static let headerW: CGFloat = headerH * headerAspect static let headerH: CGFloat = 150 - static let previewMinW: CGFloat = DeviceUtil.isPadWidth ? 180 : 100 - static let previewMaxW: CGFloat = DeviceUtil.isPadWidth ? 220 : 120 - static let previewAvgW: CGFloat = (previewMinW + previewMaxW) / 2 + static var previewMinW: CGFloat { DeviceUtil.isPadWidth ? 180 : 100 } + static var previewMaxW: CGFloat { DeviceUtil.isPadWidth ? 220 : 120 } + static var previewAvgW: CGFloat { (previewMinW + previewMaxW) / 2 } } struct Cookie { static let yay = "yay" diff --git a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift index 25160313b..5c73c9778 100644 --- a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift +++ b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift @@ -7,18 +7,18 @@ import SwiftUI import ComposableArchitecture extension Reducer { - func haptics( + func haptics( unwrapping enum: @escaping (State) -> Enum?, case caseKeyPath: CaseKeyPath, hapticsClient: HapticsClient, style: UIImpactFeedbackGenerator.FeedbackStyle = .light ) -> some Reducer { onBecomeNonNil(unwrapping: `enum`, case: caseKeyPath) { _, _ in - .run(operation: { _ in hapticsClient.generateFeedback(style) }) + .run(operation: { _ in await hapticsClient.generateFeedback(style) }) } } - private func onBecomeNonNil( + private func onBecomeNonNil( unwrapping enum: @escaping (State) -> Enum?, case caseKeyPath: CaseKeyPath, perform additionalEffects: @escaping (inout State, Action) -> Effect diff --git a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift b/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift index d9eb463c0..219ae1d49 100644 --- a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift +++ b/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift @@ -19,7 +19,7 @@ extension NavigationLink { isActive: .init(value) ) } - init( + init( unwrapping enum: Binding, case caseKeyPath: CaseKeyPath, @ViewBuilder destination: @escaping (Binding) -> WrappedDestination @@ -32,7 +32,7 @@ extension NavigationLink { } extension View { - func confirmationDialog( + func confirmationDialog( message: String, unwrapping enum: Binding, case caseKeyPath: CaseKeyPath, @@ -46,7 +46,7 @@ extension View { message: { _ in Text(message) } ) } - func confirmationDialog( + func confirmationDialog( message: String, unwrapping enum: Binding, case caseKeyPath: CaseKeyPath, @@ -66,7 +66,7 @@ extension View { ) } - func sheet( + func sheet( unwrapping enum: Binding, case caseKeyPath: CaseKeyPath, @ViewBuilder content: @escaping (Case) -> Content @@ -77,8 +77,8 @@ extension View { ) } - func progressHUD( - config: TTProgressHUDConfig, + func progressHUD( + config: ProgressHUDConfigState, unwrapping enum: Binding, case caseKeyPath: CaseKeyPath ) -> some View { @@ -86,23 +86,26 @@ extension View { self TTProgressHUD( `enum`.case(caseKeyPath).isRemovedDuplicatesPresent(), - config: config + config: config.progressHUDConfig ) } } } extension Binding { - func `case`(_ caseKeyPath: CaseKeyPath) -> Binding where Value == Enum? { - .init( - get: { self.wrappedValue.flatMap(AnyCasePath(caseKeyPath).extract(from:)) }, + func `case`( + _ caseKeyPath: CaseKeyPath + ) -> Binding where Value == Enum? { + let casePath = AnyCasePath(caseKeyPath) + return .init( + get: { self.wrappedValue.flatMap(casePath.extract(from:)) }, set: { newValue, transaction in - self.transaction(transaction).wrappedValue = newValue.map(AnyCasePath(caseKeyPath).embed) + self.transaction(transaction).wrappedValue = newValue.map(casePath.embed) } ) } - func isRemovedDuplicatesPresent() -> Binding where Value == Wrapped? { + func isRemovedDuplicatesPresent() -> Binding where Value == Wrapped? { .init( get: { wrappedValue != nil }, set: { isPresent, transaction in diff --git a/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift b/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift index 80c0910f7..9aa23bd96 100644 --- a/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift +++ b/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift @@ -5,12 +5,44 @@ import TTProgressHUD +enum ProgressHUDConfigState: Equatable, Sendable { + case loading(title: String? = nil) + case communicating + case error(caption: String? = nil) + case success(caption: String? = nil) + case savedToPhotoLibrary + case copiedToClipboardSucceeded + + @MainActor + var progressHUDConfig: TTProgressHUDConfig { + switch self { + case .loading(let title): + return .loading(title: title) + case .communicating: + return .loading(title: L10n.Localizable.Hud.Title.communicating) + case .error(let caption): + return .error(caption: caption) + case .success(let caption): + return .success(caption: caption) + case .savedToPhotoLibrary: + return .success(caption: L10n.Localizable.Hud.Caption.savedToPhotoLibrary) + case .copiedToClipboardSucceeded: + return .success(caption: L10n.Localizable.Hud.Caption.copiedToClipboard) + } + } +} + extension TTProgressHUDConfig { - static let error: Self = error(caption: nil) - static let loading: Self = loading(title: L10n.Localizable.Hud.Title.loading) - static let communicating: Self = loading(title: L10n.Localizable.Hud.Title.communicating) - static let savedToPhotoLibrary: Self = success(caption: L10n.Localizable.Hud.Caption.savedToPhotoLibrary) - static let copiedToClipboardSucceeded: Self = success(caption: L10n.Localizable.Hud.Caption.copiedToClipboard) + @MainActor + static var error: Self { error(caption: nil) } + @MainActor + static var loading: Self { loading(title: L10n.Localizable.Hud.Title.loading) } + @MainActor + static var communicating: Self { loading(title: L10n.Localizable.Hud.Title.communicating) } + @MainActor + static var savedToPhotoLibrary: Self { success(caption: L10n.Localizable.Hud.Caption.savedToPhotoLibrary) } + @MainActor + static var copiedToClipboardSucceeded: Self { success(caption: L10n.Localizable.Hud.Caption.copiedToClipboard) } static func loading(title: String? = nil) -> Self { .init(type: .loading, title: title) diff --git a/EhPanda/App/Tools/Utilities/DeviceUtil.swift b/EhPanda/App/Tools/Utilities/DeviceUtil.swift index 3a64e266f..23018c871 100644 --- a/EhPanda/App/Tools/Utilities/DeviceUtil.swift +++ b/EhPanda/App/Tools/Utilities/DeviceUtil.swift @@ -6,6 +6,7 @@ import SwiftUI import Foundation +@MainActor struct DeviceUtil { static var isPad: Bool { UIDevice.current.userInterfaceIdiom == .pad @@ -34,6 +35,10 @@ struct DeviceUtil { .windows.last } + private static var currentScreen: UIScreen? { + keyWindow?.windowScene?.screen ?? anyWindow?.windowScene?.screen + } + static var isLandscape: Bool { [.landscapeLeft, .landscapeRight] .contains(keyWindow?.windowScene?.effectiveGeometry.interfaceOrientation) @@ -69,10 +74,10 @@ struct DeviceUtil { } static var absScreenW: CGFloat { - UIScreen.main.bounds.size.width + currentScreen?.bounds.size.width ?? 0 } static var absScreenH: CGFloat { - UIScreen.main.bounds.size.height + currentScreen?.bounds.size.height ?? 0 } } diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 2a9238fb6..b8be0557a 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -4,6 +4,7 @@ // import Foundation +import Synchronization enum DownloadValidationState: Equatable { case valid @@ -44,24 +45,20 @@ struct DownloadResumeState: Codable, Equatable { } } -struct DownloadFileStorage { +struct DownloadFileStorage: Sendable { let rootURL: URL - let fileManager: FileManager - let encoder: JSONEncoder - let decoder: JSONDecoder + let fileManager: DownloadFileManager init( rootURL: URL? = FileUtil.downloadsDirectoryURL, - fileManager: FileManager = .default + fileManager: sending FileManager = .default ) { self.rootURL = rootURL ?? FileUtil.temporaryDirectory.appendingPathComponent( Defaults.FilePath.downloads, isDirectory: true ) - self.fileManager = fileManager - encoder = JSONEncoder() - decoder = JSONDecoder() + self.fileManager = DownloadFileManager(fileManager) } func ensureRootDirectory() throws { @@ -116,23 +113,23 @@ struct DownloadFileStorage { } func writeResumeState(_ state: DownloadResumeState, folderURL: URL) throws { - let data = try encoder.encode(state) + let data = try JSONEncoder().encode(state) try data.write(to: resumeStateURL(folderURL: folderURL), options: .atomic) } func readResumeState(folderURL: URL) throws -> DownloadResumeState { let data = try Data(contentsOf: resumeStateURL(folderURL: folderURL)) - return try decoder.decode(DownloadResumeState.self, from: data) + return try JSONDecoder().decode(DownloadResumeState.self, from: data) } func writeFailedPages(_ snapshot: DownloadFailedPagesSnapshot, folderURL: URL) throws { - let data = try encoder.encode(snapshot) + let data = try JSONEncoder().encode(snapshot) try data.write(to: failedPagesURL(folderURL: folderURL), options: .atomic) } func readFailedPages(folderURL: URL) throws -> DownloadFailedPagesSnapshot { let data = try Data(contentsOf: failedPagesURL(folderURL: folderURL)) - return try decoder.decode(DownloadFailedPagesSnapshot.self, from: data) + return try JSONDecoder().decode(DownloadFailedPagesSnapshot.self, from: data) } func removeFailedPages(folderURL: URL) throws { @@ -230,7 +227,7 @@ struct DownloadFileStorage { } func writeManifest(_ manifest: DownloadManifest, folderURL: URL) throws { - let data = try encoder.encode(manifest) + let data = try JSONEncoder().encode(manifest) let fileURL = folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) try data.write(to: fileURL, options: .atomic) } @@ -238,7 +235,7 @@ struct DownloadFileStorage { func readManifest(folderURL: URL) throws -> DownloadManifest { let manifestURL = folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) let data = try Data(contentsOf: manifestURL) - return try decoder.decode(DownloadManifest.self, from: data) + return try JSONDecoder().decode(DownloadManifest.self, from: data) } @discardableResult @@ -276,3 +273,75 @@ struct DownloadFileStorage { } } } + +final class DownloadFileManager: Sendable { + private let fileManager: Mutex + + init(_ fileManager: sending FileManager) { + self.fileManager = Mutex(fileManager) + } + + func createDirectory( + at url: URL, + withIntermediateDirectories createIntermediates: Bool + ) throws { + try fileManager.withLock { + try $0.createDirectory( + at: url, + withIntermediateDirectories: createIntermediates + ) + } + } + + func fileExists(atPath path: String) -> Bool { + fileManager.withLock { $0.fileExists(atPath: path) } + } + + func removeItem(at url: URL) throws { + try fileManager.withLock { + try $0.removeItem(at: url) + } + } + + func contentsOfDirectory( + at url: URL, + includingPropertiesForKeys keys: [URLResourceKey]? + ) throws -> [URL] { + try fileManager.withLock { + try $0.contentsOfDirectory( + at: url, + includingPropertiesForKeys: keys + ) + } + } + + func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { + try fileManager.withLock { + try $0.attributesOfItem(atPath: path) + } + } + + func replaceItemAt(_ originalItemURL: URL, withItemAt newItemURL: URL) throws -> URL? { + try fileManager.withLock { + try $0.replaceItemAt(originalItemURL, withItemAt: newItemURL) + } + } + + func moveItem(at sourceURL: URL, to destinationURL: URL) throws { + try fileManager.withLock { + try $0.moveItem(at: sourceURL, to: destinationURL) + } + } + + func linkItem(at sourceURL: URL, to destinationURL: URL) throws { + try fileManager.withLock { + try $0.linkItem(at: sourceURL, to: destinationURL) + } + } + + func copyItem(at sourceURL: URL, to destinationURL: URL) throws { + try fileManager.withLock { + try $0.copyItem(at: sourceURL, to: destinationURL) + } + } +} diff --git a/EhPanda/App/Tools/Utilities/HapticsUtil.swift b/EhPanda/App/Tools/Utilities/HapticsUtil.swift index b0a3f3dfd..27ae1d054 100644 --- a/EhPanda/App/Tools/Utilities/HapticsUtil.swift +++ b/EhPanda/App/Tools/Utilities/HapticsUtil.swift @@ -6,6 +6,7 @@ import SwiftUI import AudioToolbox +@MainActor struct HapticsUtil { static func generateFeedback(style: UIImpactFeedbackGenerator.FeedbackStyle) { guard !isLegacyTapticEngine else { diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index 7994c5a47..9fe843c9d 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -27,6 +27,7 @@ struct AppReducer { case binding(BindingAction) case onScenePhaseChange(ScenePhase) case runLaunchAutomation + case clearPadSettingSubstates case appDelegate(AppDelegateReducer.Action) case appRoute(AppRouteReducer.Action) @@ -110,12 +111,14 @@ struct AppReducer { return .none case .appRoute(.clearSubStates): - var effects = [Effect]() - if deviceClient.isPad() { - state.settingState.route = nil - effects.append(.send(.setting(.clearSubStates))) + return .run { send in + guard await deviceClient.isPad() else { return } + await send(.clearPadSettingSubstates) } - return effects.isEmpty ? .none : .merge(effects) + + case .clearPadSettingSubstates: + state.settingState.route = nil + return .send(.setting(.clearSubStates)) case .appRoute: return .none @@ -134,7 +137,9 @@ struct AppReducer { case .tabBar(.setTabBarItemType(let type)): var effects = [Effect]() - let hapticEffect: Effect = .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) + let hapticEffect: Effect = .run { _ in + await hapticsClient.generateFeedback(.soft) + } if type == state.tabBarState.tabBarItemType { switch type { case .home: @@ -174,8 +179,13 @@ struct AppReducer { effects.append(hapticEffect) } } - if type == .setting && deviceClient.isPad() { - effects.append(.send(.appRoute(.setNavigation(.setting())))) + if type == .setting { + effects.append( + .run { send in + guard await deviceClient.isPad() else { return } + await send(.appRoute(.setNavigation(.setting()))) + } + ) } return effects.isEmpty ? .none : .merge(effects) @@ -184,14 +194,15 @@ struct AppReducer { case .home(.watched(.onNotLoginViewButtonTapped)), .favorites(.onNotLoginViewButtonTapped): var effects: [Effect] = [ - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), + .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), .send(.tabBar(.setTabBarItemType(.setting))) ] effects.append(.send(.setting(.setNavigation(.account)))) if !cookieClient.didLogin { effects.append( .run { send in - let delay = UInt64(deviceClient.isPad() ? 1200 : 200) + let isPad = await deviceClient.isPad() + let delay = UInt64(isPad ? 1200 : 200) try await Task.sleep(for: .milliseconds(delay)) await send(.setting(.account(.setNavigation(.login)))) } diff --git a/EhPanda/DataFlow/AppRouteReducer.swift b/EhPanda/DataFlow/AppRouteReducer.swift index dc43e793d..85570679b 100644 --- a/EhPanda/DataFlow/AppRouteReducer.swift +++ b/EhPanda/DataFlow/AppRouteReducer.swift @@ -4,7 +4,6 @@ // import SwiftUI -import TTProgressHUD import ComposableArchitecture @Reducer @@ -20,7 +19,7 @@ struct AppRouteReducer { @ObservableState struct State: Equatable { var route: Route? - var hudConfig: TTProgressHUDConfig = .loading + var hudConfig: ProgressHUDConfigState = .loading() var detailState: Heap @@ -32,7 +31,7 @@ struct AppRouteReducer { enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) - case setHUDConfig(TTProgressHUDConfig) + case setHUDConfig(ProgressHUDConfigState) case clearSubStates case detectClipboardURL @@ -166,7 +165,7 @@ struct AppRouteReducer { case .failure: return .run { send in try await Task.sleep(for: .milliseconds(500)) - await send(.setHUDConfig(.error)) + await send(.setHUDConfig(.error())) } } diff --git a/EhPanda/Database/Migration/CoreDataMigrator.swift b/EhPanda/Database/Migration/CoreDataMigrator.swift index cbf6ad65f..dbef28a6b 100755 --- a/EhPanda/Database/Migration/CoreDataMigrator.swift +++ b/EhPanda/Database/Migration/CoreDataMigrator.swift @@ -10,7 +10,7 @@ protocol CoreDataMigratorProtocol { func migrateStore(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws } -class CoreDataMigrator: CoreDataMigratorProtocol { +final class CoreDataMigrator: CoreDataMigratorProtocol, Sendable { func requiresMigration(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws -> Bool { guard let metadata = NSPersistentStoreCoordinator.metadata(at: storeURL) else { return false } return (try CoreDataMigrationVersion.compatibleVersionForStoreMetadata(metadata) != version) diff --git a/EhPanda/Database/Persistence.swift b/EhPanda/Database/Persistence.swift index 9773d2a49..8dd59cc9f 100644 --- a/EhPanda/Database/Persistence.swift +++ b/EhPanda/Database/Persistence.swift @@ -5,7 +5,7 @@ import CoreData -struct PersistenceController { +struct PersistenceController: Sendable { static let shared = PersistenceController() let migrator = CoreDataMigrator() @@ -20,14 +20,14 @@ struct PersistenceController { // MARK: Preparation extension PersistenceController { - func prepare(completion: @escaping (Result) -> Void) { + func prepare(completion: @escaping @Sendable (Result) -> Void) { do { try loadPersistentStore(completion: completion) } catch { completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) } } - func rebuild(completion: @escaping (Result) -> Void) { + func rebuild(completion: @escaping @Sendable (Result) -> Void) { guard let storeURL = container.persistentStoreDescriptions.first?.url else { completion(.failure(.databaseCorrupted("PersistentContainer was not set up properly."))) return @@ -49,7 +49,9 @@ extension PersistenceController { } } } - private func loadPersistentStore(completion: @escaping (Result) -> Void) throws { + private func loadPersistentStore( + completion: @escaping @Sendable (Result) -> Void + ) throws { try migrateStoreIfNeeded { result in switch result { case .success: @@ -66,7 +68,9 @@ extension PersistenceController { } } } - private func migrateStoreIfNeeded(completion: @escaping (Result) -> Void) throws { + private func migrateStoreIfNeeded( + completion: @escaping @Sendable (Result) -> Void + ) throws { guard let storeURL = container.persistentStoreDescriptions.first?.url else { throw AppError.databaseCorrupted("PersistentContainer was not set up properly.") } diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift index db00d739b..04849eaed 100644 --- a/EhPanda/Models/Persistent/Setting.swift +++ b/EhPanda/Models/Persistent/Setting.swift @@ -31,7 +31,7 @@ struct Setting: Codable, Equatable { var autoLockPolicy: AutoLockPolicy = .never // Appearance - var listDisplayMode: ListDisplayMode = DeviceUtil.isPadWidth ? .thumbnail : .detail + var listDisplayMode: ListDisplayMode = .detail var preferredColorScheme = PreferredColorScheme.automatic var accentColor: Color = .blue var appIconType: AppIconType = .default @@ -214,7 +214,7 @@ extension Setting { backgroundBlurRadius = (try? container?.decodeIfPresent(Double.self, forKey: .backgroundBlurRadius)) ?? 10 autoLockPolicy = (try? container?.decodeIfPresent(AutoLockPolicy.self, forKey: .autoLockPolicy)) ?? .never // Appearance - listDisplayMode = (try? container?.decodeIfPresent(ListDisplayMode.self, forKey: .listDisplayMode)) ?? (DeviceUtil.isPadWidth ? .thumbnail : .detail) + listDisplayMode = (try? container?.decodeIfPresent(ListDisplayMode.self, forKey: .listDisplayMode)) ?? .detail preferredColorScheme = (try? container?.decodeIfPresent(PreferredColorScheme.self, forKey: .preferredColorScheme)) ?? .automatic accentColor = (try? container?.decodeIfPresent(Color.self, forKey: .accentColor)) ?? .blue appIconType = (try? container?.decodeIfPresent(AppIconType.self, forKey: .appIconType)) ?? .default diff --git a/EhPanda/Models/Support/AppError.swift b/EhPanda/Models/Support/AppError.swift index a5d983c6e..f739840e3 100644 --- a/EhPanda/Models/Support/AppError.swift +++ b/EhPanda/Models/Support/AppError.swift @@ -6,7 +6,7 @@ import Foundation import SFSafeSymbols -enum AppError: Error, Identifiable, Equatable, Hashable { +enum AppError: Error, Identifiable, Equatable, Hashable, Sendable { var id: String { localizedDescription } case databaseCorrupted(String?) diff --git a/EhPanda/Network/Request+Account.swift b/EhPanda/Network/Request+Account.swift index fac56baf2..b566c9b8f 100644 --- a/EhPanda/Network/Request+Account.swift +++ b/EhPanda/Network/Request+Account.swift @@ -191,7 +191,7 @@ struct FavorGalleryRequest: Request { let token: String let favIndex: Int - var publisher: AnyPublisher { + var publisher: AnyPublisher { let url = URLUtil.addFavorite(gid: gid, token: token) let params: [String: String] = [ "favcat": "\(favIndex)", @@ -207,7 +207,7 @@ struct FavorGalleryRequest: Request { return URLSession.shared.dataTaskPublisher(for: request) .genericRetry() - .map { $0 } + .map { _ in () } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -216,7 +216,7 @@ struct FavorGalleryRequest: Request { struct UnfavorGalleryRequest: Request { let gid: String - var publisher: AnyPublisher { + var publisher: AnyPublisher { let params: [String: String] = [ "ddact": "delete", "modifygids[]": gid, @@ -230,7 +230,7 @@ struct UnfavorGalleryRequest: Request { return URLSession.shared.dataTaskPublisher(for: request) .genericRetry() - .map { $0 } + .map { _ in () } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -266,7 +266,7 @@ struct RateGalleryRequest: Request { let token: String let rating: Int - var publisher: AnyPublisher { + var publisher: AnyPublisher { let params: [String: Any] = [ "method": "rategallery", "apiuid": apiuid, @@ -282,7 +282,7 @@ struct RateGalleryRequest: Request { return URLSession.shared.dataTaskPublisher(for: request) .genericRetry() - .map { $0 } + .map { _ in () } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -292,7 +292,7 @@ struct CommentGalleryRequest: Request { let content: String let galleryURL: URL - var publisher: AnyPublisher { + var publisher: AnyPublisher { let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") let params: [String: String] = [ "commenttext_new": fixedContent @@ -305,7 +305,7 @@ struct CommentGalleryRequest: Request { return URLSession.shared.dataTaskPublisher(for: request) .genericRetry() - .map { $0 } + .map { _ in () } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -316,7 +316,7 @@ struct EditGalleryCommentRequest: Request { let content: String let galleryURL: URL - var publisher: AnyPublisher { + var publisher: AnyPublisher { let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") let params: [String: String] = [ "edit_comment": commentID, @@ -330,7 +330,7 @@ struct EditGalleryCommentRequest: Request { return URLSession.shared.dataTaskPublisher(for: request) .genericRetry() - .map { $0 } + .map { _ in () } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -344,7 +344,7 @@ struct VoteGalleryCommentRequest: Request { let commentID: Int let commentVote: Int - var publisher: AnyPublisher { + var publisher: AnyPublisher { let params: [String: Any] = [ "method": "votecomment", "apiuid": apiuid, @@ -361,7 +361,7 @@ struct VoteGalleryCommentRequest: Request { return URLSession.shared.dataTaskPublisher(for: request) .genericRetry() - .map { $0 } + .map { _ in () } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -375,7 +375,7 @@ struct VoteGalleryTagRequest: Request { let tag: String let vote: Int - var publisher: AnyPublisher { + var publisher: AnyPublisher { let params: [String: Any] = [ "method": "taggallery", "apiuid": apiuid, @@ -392,7 +392,7 @@ struct VoteGalleryTagRequest: Request { return URLSession.shared.dataTaskPublisher(for: request) .genericRetry() - .map { $0 } + .map { _ in () } .mapError(mapAppError) .eraseToAnyPublisher() } diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index e4a15a8c7..ab004c94b 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -8,7 +8,7 @@ import Foundation import ComposableArchitecture protocol Request { - associatedtype Response + associatedtype Response: Sendable var publisher: AnyPublisher { get } } @@ -39,8 +39,17 @@ extension Publisher { retry(3) } - func async() async -> Result where Failure == AppError { - await withCheckedContinuation { continuation in + func async() async -> Result where Output: Sendable, Failure == AppError { + do { + let output = try await asyncOutput() + return .success(output) + } catch { + return .failure(error as? AppError ?? .unknown) + } + } + + private func asyncOutput() async throws -> Output where Output: Sendable, Failure == AppError { + try await withCheckedThrowingContinuation { continuation in var cancellable: AnyCancellable? var finishedWithoutValue = true cancellable = first() @@ -48,15 +57,15 @@ extension Publisher { switch result { case .finished: if finishedWithoutValue { - continuation.resume(returning: .failure(.unknown)) + continuation.resume(throwing: AppError.unknown) } case let .failure(error): - continuation.resume(returning: .failure(error)) + continuation.resume(throwing: error) } cancellable?.cancel() } receiveValue: { value in finishedWithoutValue = false - continuation.resume(returning: .success(value)) + continuation.resume(returning: value) } } } diff --git a/EhPanda/View/Detail/Archives/ArchivesReducer.swift b/EhPanda/View/Detail/Archives/ArchivesReducer.swift index e20955955..05e7c3b86 100644 --- a/EhPanda/View/Detail/Archives/ArchivesReducer.swift +++ b/EhPanda/View/Detail/Archives/ArchivesReducer.swift @@ -4,7 +4,6 @@ // import Foundation -import TTProgressHUD import ComposableArchitecture @Reducer @@ -27,8 +26,8 @@ struct ArchivesReducer { var loadingState: LoadingState = .idle var hathArchives = [GalleryArchive.HathArchive]() - var messageHUDConfig = TTProgressHUDConfig() - var communicatingHUDConfig: TTProgressHUDConfig = .communicating + var messageHUDConfig: ProgressHUDConfigState = .loading() + var communicatingHUDConfig: ProgressHUDConfigState = .communicating } enum Action: BindableAction { @@ -149,11 +148,11 @@ struct ArchivesReducer { isSuccess = true } case .failure: - state.messageHUDConfig = .error + state.messageHUDConfig = .error() isSuccess = false } return .run { _ in - hapticsClient.generateNotificationFeedback(isSuccess ? .success : .error) + await hapticsClient.generateNotificationFeedback(isSuccess ? .success : .error) } } } diff --git a/EhPanda/View/Detail/Comments/CommentsReducer.swift b/EhPanda/View/Detail/Comments/CommentsReducer.swift index 825c3cb4a..7bc4c6d8b 100644 --- a/EhPanda/View/Detail/Comments/CommentsReducer.swift +++ b/EhPanda/View/Detail/Comments/CommentsReducer.swift @@ -4,7 +4,6 @@ // import Foundation -import TTProgressHUD import ComposableArchitecture @Reducer @@ -26,7 +25,7 @@ struct CommentsReducer { var commentContent = "" var postCommentFocused = false - var hudConfig: TTProgressHUDConfig = .loading + var hudConfig: ProgressHUDConfigState = .loading() var scrollCommentID: String? var scrollRowOpacity: Double = 1 @@ -43,7 +42,7 @@ struct CommentsReducer { case clearSubStates case clearScrollCommentID - case setHUDConfig(TTProgressHUDConfig) + case setHUDConfig(ProgressHUDConfigState) case setPostCommentFocused(Bool) case setScrollRowOpacity(Double) case setCommentContent(String) @@ -58,7 +57,7 @@ struct CommentsReducer { case teardown case postComment(URL, String? = nil) case voteComment(String, String, String, String, Int) - case performCommentActionDone(Result) + case performCommentActionDone(Result) case fetchGallery(URL, Bool) case fetchGalleryDone(URL, Result) @@ -253,7 +252,7 @@ struct CommentsReducer { case .failure: return .run { send in try await Task.sleep(for: .milliseconds(500)) - await send(.setHUDConfig(.error)) + await send(.setHUDConfig(.error())) } } diff --git a/EhPanda/View/Detail/DetailReducer+Actions.swift b/EhPanda/View/Detail/DetailReducer+Actions.swift index ac0e4a311..1041e70a3 100644 --- a/EhPanda/View/Detail/DetailReducer+Actions.swift +++ b/EhPanda/View/Detail/DetailReducer+Actions.swift @@ -85,11 +85,11 @@ extension DetailReducer { switch action { case .toggleShowFullTitle: state.showsFullTitle.toggle() - return .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) + return .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }) case .toggleShowUserRating: state.showsUserRating.toggle() - return .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) + return .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }) case .setCommentContent(let content): state.commentContent = content @@ -107,7 +107,7 @@ extension DetailReducer { state.updateRating(value: value) return .merge( .send(.rateGallery), - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), + .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), .run { send in try await Task.sleep(for: .seconds(1)) await send(.confirmRatingDone) @@ -129,8 +129,8 @@ extension DetailReducer { ) -> Effect? { switch action { case .syncGalleryTags: - return .run { [state] _ in - await databaseClient.updateGalleryTags(gid: state.gallery.id, tags: state.galleryTags) + return .run { [gid = state.gallery.id, tags = state.galleryTags] _ in + await databaseClient.updateGalleryTags(gid: gid, tags: tags) } case .syncGalleryDetail: @@ -138,32 +138,32 @@ extension DetailReducer { return .run(operation: { _ in await databaseClient.cacheGalleryDetail(detail) }) case .syncGalleryPreviewURLs: - return .run { [state] _ in + return .run { [gid = state.gallery.id, previewURLs = state.galleryPreviewURLs] _ in await databaseClient - .updatePreviewURLs(gid: state.gallery.id, previewURLs: state.galleryPreviewURLs) + .updatePreviewURLs(gid: gid, previewURLs: previewURLs) } case .syncGalleryComments: - return .run { [state] _ in - await databaseClient.updateComments(gid: state.gallery.id, comments: state.galleryComments) + return .run { [gid = state.gallery.id, comments = state.galleryComments] _ in + await databaseClient.updateComments(gid: gid, comments: comments) } case .syncGreeting(let greeting): return .run(operation: { _ in await databaseClient.updateGreeting(greeting) }) case .syncPreviewConfig(let config): - return .run { [state] _ in - await databaseClient.updatePreviewConfig(gid: state.gallery.id, config: config) + return .run { [gid = state.gallery.id] _ in + await databaseClient.updatePreviewConfig(gid: gid, config: config) } case .saveGalleryHistory: - return .run { [state] _ in - await databaseClient.updateLastOpenDate(gid: state.gallery.id) + return .run { [gid = state.gallery.id] _ in + await databaseClient.updateLastOpenDate(gid: gid) } case .updateReadingProgress(let progress): - return .run { [state] _ in - await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) + return .run { [gid = state.gallery.id] _ in + await databaseClient.updateReadingProgress(gid: gid, progress: progress) } default: diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index e9100df8b..808a7da25 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -212,11 +212,11 @@ extension DetailReducer { state.downloadBadge = .queued state.hasLoadedDownloadBadge = true return .merge( - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), .send(.fetchDownloadBadge) ) } - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } private func handleToggleDownloadPause(state: inout State) -> Effect { @@ -243,11 +243,11 @@ extension DetailReducer { } state.hasLoadedDownloadBadge = state.downloadBadge != .none return .merge( - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), .send(.fetchDownloadBadge) ) } - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } private func handleRetryDownload( @@ -270,11 +270,11 @@ extension DetailReducer { state.downloadBadge = .queued state.hasLoadedDownloadBadge = true return .merge( - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), .send(.fetchDownloadBadge) ) } - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } private func handleDeleteDownload(state: State) -> Effect { @@ -293,10 +293,10 @@ extension DetailReducer { state.isDownloadContext = false state.shouldCheckForRemoteUpdates = false return .merge( - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }), + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), .send(.fetchDownloadBadge) ) } - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } } diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/EhPanda/View/Detail/DetailReducer+Fetch.swift index 96835d4c0..e3b7212ad 100644 --- a/EhPanda/View/Detail/DetailReducer+Fetch.swift +++ b/EhPanda/View/Detail/DetailReducer+Fetch.swift @@ -195,19 +195,19 @@ extension DetailReducer { private func handleRateGallery(state: State) -> Effect { guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) else { return .none } - return .run { [state] send in + return .run { [apiKey = state.apiKey, token = state.gallery.token, rating = state.userRating] send in let response = await RateGalleryRequest( - apiuid: apiuid, apikey: state.apiKey, - gid: gid, token: state.gallery.token, rating: state.userRating + apiuid: apiuid, apikey: apiKey, + gid: gid, token: token, rating: rating ).response() await send(.anyGalleryOpsDone(response)) }.cancellable(id: CancelID.rateGallery) } private func handleFavorGallery(favIndex: Int, state: State) -> Effect { - .run { [state] send in + .run { [gid = state.gallery.id, token = state.gallery.token] send in let response = await FavorGalleryRequest( - gid: state.gallery.id, token: state.gallery.token, favIndex: favIndex + gid: gid, token: token, favIndex: favIndex ).response() await send(.anyGalleryOpsDone(response)) } @@ -236,23 +236,23 @@ extension DetailReducer { private func handleVoteTag(tag: String, vote: Int, state: State) -> Effect { guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) else { return .none } - return .run { [state] send in + return .run { [apiKey = state.apiKey, token = state.gallery.token] send in let response = await VoteGalleryTagRequest( - apiuid: apiuid, apikey: state.apiKey, - gid: gid, token: state.gallery.token, tag: tag, vote: vote + apiuid: apiuid, apikey: apiKey, + gid: gid, token: token, tag: tag, vote: vote ).response() await send(.anyGalleryOpsDone(response)) } .cancellable(id: CancelID.voteTag) } - private func handleAnyGalleryOpsDone(result: Result) -> Effect { + private func handleAnyGalleryOpsDone(result: Result) -> Effect { if case .success = result { return .merge( .send(.fetchGalleryDetail), - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }) + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) ) } - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } } diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 935869b48..84535eb7a 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -155,7 +155,7 @@ struct DetailReducer { case unfavorGallery case postComment(URL) case voteTag(String, Int) - case anyGalleryOpsDone(Result) + case anyGalleryOpsDone(Result) case reading(ReadingReducer.Action) case archives(ArchivesReducer.Action) case torrents(TorrentsReducer.Action) diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift index 4f0d4f05f..68f2b54d9 100644 --- a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift +++ b/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift @@ -3,7 +3,6 @@ // EhPanda // -import TTProgressHUD import ComposableArchitecture @Reducer @@ -16,7 +15,7 @@ struct GalleryInfosReducer { @ObservableState struct State: Equatable { var route: Route? - var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded + var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded } enum Action: BindableAction, Equatable { @@ -39,7 +38,7 @@ struct GalleryInfosReducer { state.route = .hud return .merge( .run(operation: { _ in clipboardClient.saveText(text) }), - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }) + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) ) } } diff --git a/EhPanda/View/Detail/Torrents/TorrentsReducer.swift b/EhPanda/View/Detail/Torrents/TorrentsReducer.swift index 8423ee3d4..bfaff6678 100644 --- a/EhPanda/View/Detail/Torrents/TorrentsReducer.swift +++ b/EhPanda/View/Detail/Torrents/TorrentsReducer.swift @@ -4,7 +4,6 @@ // import Foundation -import TTProgressHUD import ComposableArchitecture @Reducer @@ -24,7 +23,7 @@ struct TorrentsReducer { var route: Route? var torrents = [GalleryTorrent]() var loadingState: LoadingState = .idle - var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded + var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded } enum Action: BindableAction, Equatable { @@ -61,7 +60,7 @@ struct TorrentsReducer { state.route = .hud return .merge( .run(operation: { _ in clipboardClient.saveText(magnetURL) }), - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }) + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) ) case .presentTorrentActivity(let hash, let data): diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift index a64ec8d58..4b89e8e0a 100644 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -128,12 +128,12 @@ struct FavoritesReducer { } else { state.rawPageNumber[state.index]?.resetPages() } - return .run { [state] send in + return .run { [index = state.index, keyword = state.keyword] send in let response = await FavoritesGalleriesRequest( - favIndex: state.index, keyword: state.keyword, sortOrder: sortOrder + favIndex: index, keyword: keyword, sortOrder: sortOrder ) .response() - await send(.fetchGalleriesDone(state.index, response)) + await send(.fetchGalleriesDone(index, response)) } case .fetchGalleriesDone(let targetFavIndex, let result): @@ -167,15 +167,15 @@ struct FavoritesReducer { let lastItemTimestamp = pageNumber.lastItemTimestamp else { return .none } state.rawFooterLoadingState[state.index] = .loading - return .run { [state] send in + return .run { [index = state.index, keyword = state.keyword] send in let response = await MoreFavoritesGalleriesRequest( - favIndex: state.index, + favIndex: index, lastID: lastID, lastTimestamp: lastItemTimestamp, - keyword: state.keyword + keyword: keyword ) .response() - await send(.fetchMoreGalleriesDone(state.index, response)) + await send(.fetchMoreGalleriesDone(index, response)) } case .fetchMoreGalleriesDone(let targetFavIndex, let result): diff --git a/EhPanda/View/Home/HomeReducer+Body.swift b/EhPanda/View/Home/HomeReducer+Body.swift index 20ec9af75..79e35f738 100644 --- a/EhPanda/View/Home/HomeReducer+Body.swift +++ b/EhPanda/View/Home/HomeReducer+Body.swift @@ -5,7 +5,6 @@ import SwiftUI import Kingfisher -import UIImageColors import ComposableArchitecture extension HomeReducer { @@ -159,13 +158,7 @@ extension HomeReducer { } case .analyzeImageColorsDone(let gid, let colors): - if let colors = colors { - state.rawCardColors[gid] = [ - colors.primary, colors.secondary, - colors.detail, colors.background - ] - .map(Color.init) - } + state.rawCardColors[gid] = colors return .none case .fetchDownloadBadges(let gids): diff --git a/EhPanda/View/Home/HomeReducer.swift b/EhPanda/View/Home/HomeReducer.swift index a65d83ef2..8a8001b45 100644 --- a/EhPanda/View/Home/HomeReducer.swift +++ b/EhPanda/View/Home/HomeReducer.swift @@ -5,7 +5,6 @@ import SwiftUI import Kingfisher -import UIImageColors import ComposableArchitecture @Reducer @@ -87,7 +86,7 @@ struct HomeReducer { case clearSubStates case setAllowsCardHitTesting(Bool) case analyzeImageColors(String, RetrieveImageResult) - case analyzeImageColorsDone(String, UIImageColors?) + case analyzeImageColorsDone(String, [Color]?) case fetchAllGalleries case fetchAllToplistsGalleries diff --git a/EhPanda/View/Home/Toplists/ToplistsReducer.swift b/EhPanda/View/Home/Toplists/ToplistsReducer.swift index 94463ba17..0226766de 100644 --- a/EhPanda/View/Home/Toplists/ToplistsReducer.swift +++ b/EhPanda/View/Home/Toplists/ToplistsReducer.swift @@ -120,13 +120,13 @@ struct ToplistsReducer { guard let index = Int(state.jumpPageIndex), let pageNumber = state.pageNumber, index > 0, index <= pageNumber.maximum + 1 else { - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } return .send(.fetchGalleries(index - 1)) case .presentJumpPageAlert: state.jumpPageAlertPresented = true - return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) + return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) case .setJumpPageAlertFocused(let isFocused): state.jumpPageAlertFocused = isFocused diff --git a/EhPanda/View/Reading/ReadingReducer+Body.swift b/EhPanda/View/Reading/ReadingReducer+Body.swift index 59d9f76da..61e8df61f 100644 --- a/EhPanda/View/Reading/ReadingReducer+Body.swift +++ b/EhPanda/View/Reading/ReadingReducer+Body.swift @@ -27,7 +27,7 @@ extension ReadingReducer { func makeBody() -> some Reducer { BindingReducer() .onChange(of: \.showsSliderPreview) { _, _ in - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) + .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }) } mainReducer } @@ -50,7 +50,7 @@ extension ReadingReducer { return reduceOrientation(isPortrait: isPortrait) case .onPerformDismiss: - return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) + return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) case .onAppear(let gid, let enablesLandscape): return reduceOnAppear(gid: gid, enablesLandscape: enablesLandscape) @@ -79,7 +79,7 @@ extension ReadingReducer { return .send(.fetchImage(.save(imageURL.isAnimatedImage), imageURL)) case .saveImageDone(let isSucceeded): - state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error + state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error() return .send(.setNavigation(.hud)) case .shareImage(let imageURL): @@ -215,10 +215,10 @@ extension ReadingReducer { func reduceOrientation(isPortrait: Bool) -> Effect { var effects = [Effect]() if isPortrait { - effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) + effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientationMask() })) effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientation() })) } else { - effects.append(.run(operation: { _ in appDelegateClient.setAllOrientationMask() })) + effects.append(.run(operation: { _ in await appDelegateClient.setAllOrientationMask() })) } return .merge(effects) } @@ -308,7 +308,7 @@ extension ReadingReducer { } } } else { - state.hudConfig = .error + state.hudConfig = .error() return .send(.setNavigation(.hud)) } } diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift index 74a380db3..cd246088a 100644 --- a/EhPanda/View/Reading/ReadingReducer+Database.swift +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -11,9 +11,12 @@ extension ReadingReducer { var effects: [Effect] = [ .merge(ReadingCancelID.allCases.map(Effect.cancel(id:))) ] - if !deviceClient.isPad() { - effects.append(.send(.setOrientationPortrait(true))) - } + effects.append( + .run { send in + guard await !deviceClient.isPad() else { return } + await send(.setOrientationPortrait(true)) + } + ) return .merge(effects) } diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index bb9fbc71f..6154dbd4e 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -4,7 +4,6 @@ // import SwiftUI -import TTProgressHUD import ComposableArchitecture @Reducer @@ -44,7 +43,7 @@ struct ReadingReducer { var readingProgress: Int = .zero var forceRefreshID: UUID = .init() - var hudConfig: TTProgressHUDConfig = .loading + var hudConfig: ProgressHUDConfigState = .loading() var webImageLoadSuccessIndices = Set() var imageURLLoadingStates = [Int: LoadingState]() @@ -88,7 +87,7 @@ struct ReadingReducer { } // Image - func containerDataSource(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> [Int] { + func containerDataSource(setting: Setting, isLandscape: Bool) -> [Int] { let defaultData = Array(1...gallery.pageCount) guard isLandscape && setting.enablesDualPageMode && setting.readingDirection != .vertical @@ -101,7 +100,7 @@ struct ReadingReducer { return data } func imageContainerConfigs( - index: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape + index: Int, setting: Setting, isLandscape: Bool ) -> ImageStackConfig { let direction = setting.readingDirection let isReversed = direction == .rightToLeft diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 100c81ee9..775eab624 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -119,7 +119,10 @@ struct ReadingView: View { if setting.readingDirection == .vertical { AdvancedList( page: page, - data: store.state.containerDataSource(setting: setting), + data: store.state.containerDataSource( + setting: setting, + isLandscape: DeviceUtil.isLandscape + ), id: \.self, spacing: setting.contentDividerHeight, gesture: SimultaneousGesture(magnificationGesture, tapGesture), @@ -129,7 +132,10 @@ struct ReadingView: View { } else { Pager( page: page, - data: store.state.containerDataSource(setting: setting), + data: store.state.containerDataSource( + setting: setting, + isLandscape: DeviceUtil.isLandscape + ), id: \.self, content: imageStack ) @@ -230,7 +236,11 @@ struct ReadingView: View { } @ViewBuilder private func imageStack(index: Int) -> some View { - let imageStackConfig = store.state.imageContainerConfigs(index: index, setting: setting) + let imageStackConfig = store.state.imageContainerConfigs( + index: index, + setting: setting, + isLandscape: DeviceUtil.isLandscape + ) let isDualPage = setting.enablesDualPageMode && setting.readingDirection != .vertical && DeviceUtil.isLandscape HorizontalImageStack( index: index, diff --git a/EhPanda/View/Reading/Support/AutoPlayHandler.swift b/EhPanda/View/Reading/Support/AutoPlayHandler.swift index 889b0af55..ccec77319 100644 --- a/EhPanda/View/Reading/Support/AutoPlayHandler.swift +++ b/EhPanda/View/Reading/Support/AutoPlayHandler.swift @@ -22,7 +22,7 @@ final class AutoPlayHandler { timer?.invalidate() } - func setPolicy(_ policy: AutoPlayPolicy, updatePageAction: @escaping () -> Void) { + func setPolicy(_ policy: AutoPlayPolicy, updatePageAction: @MainActor @escaping () -> Void) { Logger.info("setPolicy", context: ["policy": policy]) self.policy = policy timer?.invalidate() @@ -30,7 +30,11 @@ final class AutoPlayHandler { if timeInterval > 0 { timer = .scheduledTimer( withTimeInterval: timeInterval, repeats: true, - block: { _ in updatePageAction() } + block: { _ in + Task { @MainActor in + updatePageAction() + } + } ) } } diff --git a/EhPanda/View/Reading/Support/LiveTextView.swift b/EhPanda/View/Reading/Support/LiveTextView.swift index 7179b2c19..27f76c0d8 100644 --- a/EhPanda/View/Reading/Support/LiveTextView.swift +++ b/EhPanda/View/Reading/Support/LiveTextView.swift @@ -112,6 +112,7 @@ private struct HighlightView: UIViewRepresentable { self.highLightView = highLightView } + @MainActor @objc func onTap(sender: UIView) { Logger.info("onTap", context: ["tappedText": textView?.text]) guard let textView = textView else { return } diff --git a/EhPanda/View/Search/SearchRootReducer.swift b/EhPanda/View/Search/SearchRootReducer.swift index 3afa2ecb8..bc681d373 100644 --- a/EhPanda/View/Search/SearchRootReducer.swift +++ b/EhPanda/View/Search/SearchRootReducer.swift @@ -128,8 +128,8 @@ struct SearchRootReducer { ) case .syncHistoryKeywords: - return .run { [state] _ in - await databaseClient.updateHistoryKeywords(state.historyKeywords) + return .run { [historyKeywords = state.historyKeywords] _ in + await databaseClient.updateHistoryKeywords(historyKeywords) } case .fetchDatabaseInfos: diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift index e41dfc723..2a03bf6f1 100644 --- a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift +++ b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift @@ -4,7 +4,6 @@ // import Foundation -import TTProgressHUD import ComposableArchitecture @Reducer @@ -23,7 +22,7 @@ struct AccountSettingReducer { var route: Route? var ehCookiesState: CookiesState = .empty(.ehentai) var exCookiesState: CookiesState = .empty(.exhentai) - var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded + var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded var loginState = LoginReducer.State() var ehSettingState = EhSettingReducer.State() @@ -86,7 +85,7 @@ struct AccountSettingReducer { return .merge( .send(.setNavigation(.hud)), .run(operation: { _ in clipboardClient.saveText(cookiesDescription) }), - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }) + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) ) case .login(.loginDone): diff --git a/EhPanda/View/Setting/Login/LoginReducer.swift b/EhPanda/View/Setting/Login/LoginReducer.swift index e641615d0..3c60ec6d9 100644 --- a/EhPanda/View/Setting/Login/LoginReducer.swift +++ b/EhPanda/View/Setting/Login/LoginReducer.swift @@ -71,7 +71,7 @@ struct LoginReducer { state.focusedField = nil state.loginState = .loading return .merge( - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), + .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), .run { [state] send in let response = await LoginRequest(username: state.username, password: state.password).response() await send(.loginDone(response)) @@ -84,10 +84,10 @@ struct LoginReducer { var effects = [Effect]() if cookieClient.didLogin { state.loginState = .idle - effects.append(.run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) })) + effects.append(.run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) })) } else { state.loginState = .failed(.unknown) - effects.append(.run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) })) + effects.append(.run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) })) } if case .success(let response) = result, let response = response { effects.append(.run(operation: { _ in cookieClient.setCredentials(response: response) })) diff --git a/EhPanda/View/Setting/SettingReducer+Body.swift b/EhPanda/View/Setting/SettingReducer+Body.swift index 0b5ac4a30..0cdb8f178 100644 --- a/EhPanda/View/Setting/SettingReducer+Body.swift +++ b/EhPanda/View/Setting/SettingReducer+Body.swift @@ -61,8 +61,13 @@ extension SettingReducer { var effects: [Effect] = [ .send(.syncSetting) ] - if !state.setting.enablesLandscape && !deviceClient.isPad() { - effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) + if !state.setting.enablesLandscape { + effects.append( + .run { _ in + guard await !deviceClient.isPad() else { return } + await appDelegateClient.setPortraitOrientationMask() + } + ) } return .merge(effects) } @@ -81,7 +86,7 @@ extension SettingReducer { .onChange(of: \.setting.bypassesSNIFiltering) { _, state in .merge( .send(.syncSetting), - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), + .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), .run(operation: { [value = state.setting.bypassesSNIFiltering] _ in dfClient.setActive(value) }) ) } @@ -106,7 +111,12 @@ extension SettingReducer { return .none case .syncAppIconType: - if let iconName = uiApplicationClient.alternateIconName() { + return .run { send in + await send(.syncAppIconTypeDone(await uiApplicationClient.alternateIconName())) + } + + case .syncAppIconTypeDone(let iconName): + if let iconName { state.setting.appIconType = AppIconType.allCases.filter({ iconName.contains($0.filename) }).first ?? .default diff --git a/EhPanda/View/Setting/SettingReducer.swift b/EhPanda/View/Setting/SettingReducer.swift index 58a5a17c6..73331c3eb 100644 --- a/EhPanda/View/Setting/SettingReducer.swift +++ b/EhPanda/View/Setting/SettingReducer.swift @@ -70,6 +70,7 @@ struct SettingReducer { case clearSubStates case syncAppIconType + case syncAppIconTypeDone(String?) case syncUserInterfaceStyle case syncSetting case syncTagTranslator diff --git a/EhPanda/View/Support/Components/PreviewImageView.swift b/EhPanda/View/Support/Components/PreviewImageView.swift index 1e0a548d9..7ba74a178 100644 --- a/EhPanda/View/Support/Components/PreviewImageView.swift +++ b/EhPanda/View/Support/Components/PreviewImageView.swift @@ -103,7 +103,10 @@ private struct LocalPreviewImageView: View { let fileURL = fileURL let maxPixelSize = maxPixelSize let generatedThumbnail = await Task.detached(priority: .utility) { - Self.makeThumbnail(fileURL: fileURL, maxPixelSize: maxPixelSize) + LocalPreviewThumbnailGenerator.make( + fileURL: fileURL, + maxPixelSize: maxPixelSize + ) } .value @@ -113,7 +116,10 @@ private struct LocalPreviewImageView: View { thumbnail = generatedThumbnail } - nonisolated private static func makeThumbnail(fileURL: URL, maxPixelSize: CGFloat) -> UIImage? { +} + +private enum LocalPreviewThumbnailGenerator { + static func make(fileURL: URL, maxPixelSize: CGFloat) -> UIImage? { guard let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil) else { return nil } @@ -137,6 +143,7 @@ private struct LocalPreviewImageView: View { } } +@MainActor private final class LocalPreviewThumbnailCache { static let shared = LocalPreviewThumbnailCache() diff --git a/EhPanda/View/Support/Components/TagCloudView.swift b/EhPanda/View/Support/Components/TagCloudView.swift index ba0158ef7..f043c8a6a 100644 --- a/EhPanda/View/Support/Components/TagCloudView.swift +++ b/EhPanda/View/Support/Components/TagCloudView.swift @@ -15,8 +15,6 @@ where TagCell: View, Element: Equatable & Identifiable, ID == Element.ID { private let spacing: Double private let content: (Element) -> TagCell - @State private var totalHeight = CGFloat.zero - init( data: Data, id: KeyPath = \Element.id, spacing: Double = 4, @ViewBuilder content: @escaping (Element) -> TagCell @@ -28,56 +26,73 @@ where TagCell: View, Element: Equatable & Identifiable, ID == Element.ID { } var body: some View { - VStack { - GeometryReader { geometry in - generateContent(in: geometry) + FlowLayout(spacing: spacing) { + ForEach(data, id: id) { element in + content(element) } } - .frame(height: totalHeight) } } -private extension TagCloudView { - func generateContent(in proxy: GeometryProxy) -> some View { - ZStack(alignment: .topLeading) { - var width = CGFloat.zero - var height = CGFloat.zero - ForEach(data, id: id) { content in - self.content(content) - .padding([.trailing, .bottom], spacing) - .alignmentGuide(.leading, computeValue: { [proxyWidth = proxy.size.width] dimensions in - if abs(width - dimensions.width) > proxyWidth { - width = 0 - height -= dimensions.height - } - let result = width - if content == data.last { - width = 0 // last item - } else { - width -= dimensions.width - } - return result - }) - .alignmentGuide(.top, computeValue: { _ in - let result = height - if content == data.last { - height = 0 // last item - } - return result - }) - } +private struct FlowLayout: Layout { + let spacing: Double + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let frames = frames( + for: subviews, + maxWidth: proposal.width ?? .infinity + ) + let size = frames.reduce(CGSize.zero) { size, frame in + CGSize( + width: max(size.width, frame.maxX), + height: max(size.height, frame.maxY) + ) } - .background(viewHeightReader(binding: $totalHeight)) + return CGSize(width: proposal.width ?? size.width, height: size.height) } - func viewHeightReader(binding: Binding) -> some View { - GeometryReader { geometry -> Color in - let rect = geometry.frame(in: .local) - DispatchQueue.main.async { - binding.wrappedValue = rect.size.height + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let frames = frames(for: subviews, maxWidth: bounds.width) + for (index, subview) in subviews.enumerated() { + subview.place( + at: CGPoint( + x: bounds.minX + frames[index].minX, + y: bounds.minY + frames[index].minY + ), + proposal: ProposedViewSize(frames[index].size) + ) + } + } + + private func frames(for subviews: Subviews, maxWidth: CGFloat) -> [CGRect] { + var frames = [CGRect]() + var origin = CGPoint.zero + var rowHeight = CGFloat.zero + let maxWidth = maxWidth.isFinite ? maxWidth : .greatestFiniteMagnitude + let spacing = CGFloat(spacing) + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if origin.x > 0, origin.x + size.width > maxWidth { + origin.x = 0 + origin.y += rowHeight + spacing + rowHeight = 0 } - return .clear + + frames.append(CGRect(origin: origin, size: size)) + origin.x += size.width + spacing + rowHeight = max(rowHeight, size.height) } + return frames } } diff --git a/EhPanda/View/TabBar/TabBarReducer.swift b/EhPanda/View/TabBar/TabBarReducer.swift index 2fc33e651..d3c78ed2d 100644 --- a/EhPanda/View/TabBar/TabBarReducer.swift +++ b/EhPanda/View/TabBar/TabBarReducer.swift @@ -16,15 +16,11 @@ struct TabBarReducer { case setTabBarItemType(TabBarItemType) } - @Dependency(\.deviceClient) private var deviceClient - var body: some Reducer { Reduce { state, action in switch action { case .setTabBarItemType(let type): - if !deviceClient.isPad() || type != .setting { - state.tabBarItemType = type - } + state.tabBarItemType = type return .none } } diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index 01b0b342a..84e61297e 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -9,6 +9,7 @@ import Testing @testable import EhPanda @Suite(.serialized) +@MainActor struct DetailReducerDownloadTests: DownloadFeatureTestCase { @MainActor @Test diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift index 1b59892e1..60cffdcb3 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -9,6 +9,7 @@ import Testing @testable import EhPanda @Suite(.serialized) +@MainActor struct DetailReducerMetadataTests: DownloadFeatureTestCase { @MainActor @Test diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index c7d5b376a..82d811c03 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -9,6 +9,7 @@ import Testing @testable import EhPanda @Suite(.serialized) +@MainActor struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { @MainActor @Test diff --git a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift index 726757b48..83ac6d49b 100644 --- a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift @@ -9,6 +9,7 @@ import Testing @testable import EhPanda @Suite(.serialized) +@MainActor struct DetailReducerObserveTests: DownloadFeatureTestCase { @MainActor @Test diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index 3ca2f77f1..18e8452d6 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -9,6 +9,7 @@ import Testing @testable import EhPanda @Suite(.serialized) +@MainActor struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { @MainActor @Test diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 4aae1c192..597e9729b 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -247,7 +247,7 @@ extension DownloadFeatureTestCase { // MARK: - Stub Handler Content -struct StubHandlerContent { +struct StubHandlerContent: Sendable { let detailHTML: Data let mpvHTML: Data let metadataResponse: Data @@ -255,7 +255,7 @@ struct StubHandlerContent { // MARK: - Stub Route Context -struct StubRouteContext { +struct StubRouteContext: Sendable { let gid: String let pageIndex: Int let content: StubHandlerContent @@ -315,14 +315,26 @@ extension DownloadFeatureTestCase { guard let url = request.url else { throw URLError(.badURL) } if url.host == "example.com" || allowedImageURLs.contains(url.absoluteString) { recorder?.recordImageDownload() - return (try Self.stubResponse(url: url, contentType: "image/jpeg"), Data([0xFF, 0xD8, 0xFF, 0xD9])) + return ( + try DownloadFeatureTestStubRouter.stubResponse( + url: url, + contentType: "image/jpeg" + ), + Data([0xFF, 0xD8, 0xFF, 0xD9]) + ) } - return try Self.routeStubRequest(url: url, request: request, context: context) + return try DownloadFeatureTestStubRouter.routeStubRequest( + url: url, + request: request, + context: context + ) } URLProtocol.registerClass(SharedSessionStubURLProtocol.self) } +} - private static func routeStubRequest( +private enum DownloadFeatureTestStubRouter { + static func routeStubRequest( url: URL, request: URLRequest, context: StubRouteContext ) throws -> (HTTPURLResponse, Data) { @@ -362,7 +374,7 @@ extension DownloadFeatureTestCase { throw URLError(.unsupportedURL) } - private static func stubResponse( + static func stubResponse( url: URL, contentType: String ) throws -> HTTPURLResponse { try #require(HTTPURLResponse( @@ -370,5 +382,4 @@ extension DownloadFeatureTestCase { headerFields: ["Content-Type": contentType] )) } - } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift index 4d18448c5..eb7a06471 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift @@ -4,6 +4,7 @@ // import Foundation +import Synchronization @testable import EhPanda // MARK: - Supporting Types @@ -126,36 +127,28 @@ final class FailFastURLProtocol: URLProtocol { final class SharedSessionStubURLProtocol: URLProtocol { static let headerKey = "X-TestSession-ID" - private static let lock = NSLock() - private static var handlers: - [String: (URLRequest) throws -> (HTTPURLResponse, Data)] = [:] + private static let handlers = SharedSessionStubHandlers() static func setHandler( for sessionID: String, - handler: @escaping (URLRequest) throws -> (HTTPURLResponse, Data) + handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) ) { - lock.lock() - defer { lock.unlock() } - handlers[sessionID] = handler + handlers.setHandler(for: sessionID, handler: handler) } static func removeHandler(for sessionID: String) { - lock.lock() - defer { lock.unlock() } - handlers[sessionID] = nil + handlers.removeHandler(for: sessionID) } private static func handler( for request: URLRequest - ) -> ((URLRequest) throws -> (HTTPURLResponse, Data))? { + ) -> (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? { guard let sessionID = request.value( forHTTPHeaderField: headerKey ) else { return nil } - lock.lock() - defer { lock.unlock() } - return handlers[sessionID] + return handlers.handler(for: sessionID) } override static func canInit(with request: URLRequest) -> Bool { @@ -193,3 +186,26 @@ final class SharedSessionStubURLProtocol: URLProtocol { override func stopLoading() {} } + +private final class SharedSessionStubHandlers: Sendable { + private let handlers = Mutex< + [String: @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)] + >([:]) + + func setHandler( + for sessionID: String, + handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) + ) { + handlers.withLock { $0[sessionID] = handler } + } + + func removeHandler(for sessionID: String) { + handlers.withLock { $0[sessionID] = nil } + } + + func handler( + for sessionID: String + ) -> (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? { + handlers.withLock { $0[sessionID] } + } +} diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index f002338d0..8168ba300 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -208,13 +208,13 @@ struct DownloadFileStorageTests { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let fileManager = ThrowingAttributesFileManager(failingPath: rootURL.path) - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: fileManager) - - try storage.ensureRootDirectory() + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) let fileURL = rootURL.appendingPathComponent("cover.jpg") try Data([0xFF, 0xD8, 0xFF]).write(to: fileURL, options: .atomic) - fileManager.failingPath = fileURL.path + let storage = DownloadFileStorage( + rootURL: rootURL, + fileManager: ThrowingAttributesFileManager(failingPath: fileURL.path) + ) #expect(storage.isReadableAssetFile(at: fileURL)) #expect(FileManager.default.fileExists(atPath: fileURL.path)) @@ -240,7 +240,7 @@ struct DownloadFileStorageTests { } private final class ThrowingAttributesFileManager: FileManager { - var failingPath: String + let failingPath: String init(failingPath: String) { self.failingPath = failingPath diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift index d5fc399ac..4793ca8b5 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -9,6 +9,7 @@ import Testing @testable import EhPanda @Suite(.serialized) +@MainActor struct DownloadInspectorLoadTests: DownloadFeatureTestCase { @MainActor @Test diff --git a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift index a14f105e4..c7a43ba38 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -9,6 +9,7 @@ import Testing @testable import EhPanda @Suite(.serialized) +@MainActor struct DownloadInspectorRetryTests: DownloadFeatureTestCase { @MainActor @Test diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index 866eeabb3..726a7df88 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -9,6 +9,7 @@ import Testing @testable import EhPanda @Suite(.serialized) +@MainActor struct DownloadObserverReadingTests: DownloadFeatureTestCase { @MainActor @Test diff --git a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift index 5ed2e6489..8771342cc 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -9,6 +9,7 @@ import Testing @testable import EhPanda @Suite(.serialized) +@MainActor struct DownloadObserverRefreshTests: DownloadFeatureTestCase { @MainActor @Test diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 7a736f03d..79701a016 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -34,7 +34,7 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { let manager = cacheTestManager.manager defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } - let (cachedKeys, coverURL) = try await prepareCacheTestAssets( + let (cachedKeys, _) = try await prepareCacheTestAssets( manager: manager, gid: gid, pageIndex: pageIndex, oldVersionSignature: oldVersionSignature ) diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 1974b8694..28547724b 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -50,23 +50,17 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { #expect(firstRunSnapshot.previewPageNumbers == [1]) setup.recorder.reset() - try clearPersistedDownloads(in: container) - try insertPersistedDownload( - in: container, gid: gid, status: .partial, - completedPageCount: setup.pageCount, pageCount: setup.pageCount, - remoteVersionSignature: setup.versionSignature, - latestRemoteVersionSignature: setup.versionSignature + try await assertRetrySkipsCompletedSelection( + .init( + container: container, + storage: storage, + manager: manager, + gid: gid, + pageIndex: pageIndex, + setup: setup, + manifest: manifest + ) ) - try writeTemporaryManifestAndPages( - storage: storage, gid: gid, manifest: manifest, - pageCount: setup.pageCount, versionSignature: setup.versionSignature, - pageSelection: [pageIndex] - ) - await manager.testingProcessDownload(gid: gid) - let secondRunSnapshot = setup.recorder.snapshot() - #expect(secondRunSnapshot.previewPageNumbers.isEmpty) - #expect(secondRunSnapshot.mpvRequests == 0) - #expect(secondRunSnapshot.imageDispatchRequests == 0) } } @@ -78,9 +72,44 @@ private struct MinimalSourceTestResult { let pageCount: Int } +private struct MinimalSourceRetrySkipContext { + let container: NSPersistentContainer + let storage: DownloadFileStorage + let manager: DownloadManager + let gid: String + let pageIndex: Int + let setup: MinimalSourceTestResult + let manifest: DownloadManifest +} + // MARK: - Setup Helpers private extension DownloadRetryMinimalSourceTests { + func assertRetrySkipsCompletedSelection( + _ context: MinimalSourceRetrySkipContext + ) async throws { + try clearPersistedDownloads(in: context.container) + try insertPersistedDownload( + in: context.container, gid: context.gid, status: .partial, + completedPageCount: context.setup.pageCount, + pageCount: context.setup.pageCount, + remoteVersionSignature: context.setup.versionSignature, + latestRemoteVersionSignature: context.setup.versionSignature + ) + try writeTemporaryManifestAndPages( + storage: context.storage, gid: context.gid, + manifest: context.manifest, + pageCount: context.setup.pageCount, + versionSignature: context.setup.versionSignature, + pageSelection: [context.pageIndex] + ) + await context.manager.testingProcessDownload(gid: context.gid) + let snapshot = context.setup.recorder.snapshot() + #expect(snapshot.previewPageNumbers.isEmpty) + #expect(snapshot.mpvRequests == 0) + #expect(snapshot.imageDispatchRequests == 0) + } + func setupMinimalSourceTest( manager: DownloadManager, sessionID: String, gid: String, pageIndex: Int ) async throws -> MinimalSourceTestResult { diff --git a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift index 5a23aefba..fda6ed382 100644 --- a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -9,6 +9,7 @@ import Testing @testable import EhPanda @Suite(.serialized) +@MainActor struct PreviewsReducerDownloadTests: DownloadFeatureTestCase { @MainActor @Test @@ -116,7 +117,13 @@ private extension PreviewsReducerDownloadTests { } func makePreviewsNoManifestClient(loadLocalPageURLs: Bool) -> DownloadClient { - .init( + let loadLocalPageURLsResult: @Sendable (String) async -> Result<[Int: URL], AppError> + if loadLocalPageURLs { + loadLocalPageURLsResult = { _ in .success([:]) } + } else { + loadLocalPageURLsResult = { _ in .failure(.notFound) } + } + return .init( observeDownloads: { AsyncStream { continuation in continuation.finish() } }, fetchDownloads: { [] }, fetchDownload: { _ in nil }, @@ -129,7 +136,7 @@ private extension PreviewsReducerDownloadTests { retry: { _, _ in .success(()) }, delete: { _ in .success(()) }, loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: loadLocalPageURLs ? { _ in .success([:]) } : { _ in .failure(.notFound) } + loadLocalPageURLs: loadLocalPageURLsResult ) } diff --git a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift index 6d675a3dc..4573a3daa 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -9,6 +9,7 @@ import Testing @testable import EhPanda @Suite(.serialized) +@MainActor struct ReadingReducerDownloadTests: DownloadFeatureTestCase { @MainActor @Test diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index b27b4294d..515b55cab 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -3,6 +3,7 @@ // ShareExtension // +import AppIntents import UIKit class ShareViewController: UIViewController { @@ -21,20 +22,25 @@ class ShareViewController: UIViewController { return } - itemProvider.loadItem(forTypeIdentifier: "public.url") { (item, _) in + itemProvider.loadItem(forTypeIdentifier: "public.url") { [weak self] (item, _) in if let shareURL = item as? URL, let scheme = shareURL.scheme, let replacedURL = URL(string: shareURL.absoluteString .replacingOccurrences(of: scheme, with: "ehpanda")) { - self.openMainApp(url: replacedURL) + Task { @MainActor in + self?.openMainApp(url: replacedURL) + } } } } + @MainActor private func openMainApp(url: URL) { extensionContext?.completeRequest( returningItems: nil, completionHandler: { [weak self] _ in - self?.openURL(url) + Task { @MainActor in + self?.openURL(url) + } } ) } From 30998898438ca28b8c99d6482671de7704f64c74 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 20 May 2026 22:09:57 +0800 Subject: [PATCH 021/614] Improve WebP support --- .gitignore | 1 + .../App/Tools/Clients/ClipboardClient.swift | 6 +-- EhPanda/App/Tools/Clients/ImageClient.swift | 5 ++- .../Tools/Extensions/AnimatedImageData.swift | 44 +++++++++++++++++++ EhPanda/App/Tools/Extensions/Extensions.swift | 6 +-- .../View/Reading/ReadingReducer+Body.swift | 17 ++++--- EhPanda/View/Reading/ReadingReducer.swift | 4 +- 7 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 EhPanda/App/Tools/Extensions/AnimatedImageData.swift diff --git a/.gitignore b/.gitignore index eafee22ff..4ebf63436 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.build .DS_Store .xcode-home EhPanda.xcodeproj/xcuserdata diff --git a/EhPanda/App/Tools/Clients/ClipboardClient.swift b/EhPanda/App/Tools/Clients/ClipboardClient.swift index 1cb4a97a7..c7f9fc338 100644 --- a/EhPanda/App/Tools/Clients/ClipboardClient.swift +++ b/EhPanda/App/Tools/Clients/ClipboardClient.swift @@ -5,7 +5,6 @@ import SwiftUI import ComposableArchitecture -import UniformTypeIdentifiers struct ClipboardClient: Sendable { let url: @Sendable () -> URL? @@ -32,8 +31,9 @@ extension ClipboardClient { saveImage: { (image, isAnimated) in if isAnimated { DispatchQueue.global(qos: .utility).async { - if let data = image.kf.data(format: .GIF) { - UIPasteboard.general.setData(data, forPasteboardType: UTType.gif.identifier) + if let data = image.animatedSourceData, + let pasteboardType = data.animatedImagePasteboardType { + UIPasteboard.general.setData(data, forPasteboardType: pasteboardType) } else { UIPasteboard.general.image = image } diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 7b361f75f..ef1866475 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -23,7 +23,10 @@ extension ImageClient { }, saveImageToPhotoLibrary: { (image, isAnimated) in await withCheckedContinuation { continuation in - if let data = image.kf.data(format: isAnimated ? .GIF : .unknown) { + let data = isAnimated + ? image.animatedSourceData + : image.kf.data(format: .unknown) + if let data { PHPhotoLibrary.shared().performChanges { let request = PHAssetCreationRequest.forAsset() request.addResource(with: .photo, data: data, options: nil) diff --git a/EhPanda/App/Tools/Extensions/AnimatedImageData.swift b/EhPanda/App/Tools/Extensions/AnimatedImageData.swift new file mode 100644 index 000000000..039e60dfa --- /dev/null +++ b/EhPanda/App/Tools/Extensions/AnimatedImageData.swift @@ -0,0 +1,44 @@ +// +// AnimatedImageData.swift +// EhPanda +// + +import UIKit +import Kingfisher +import KingfisherWebP +import UniformTypeIdentifiers + +extension Data { + var animatedImagePasteboardType: String? { + if isWebPFormat { + return UTType.webP.identifier + } + if isGIFFormat { + return UTType.gif.identifier + } + return nil + } + + private var isGIFFormat: Bool { + starts(with: [0x47, 0x49, 0x46]) + } +} + +extension UIImage { + var hasAnimatedFrames: Bool { + (kf.imageFrameCount ?? images?.count ?? 1) > 1 + } + + var animatedSourceData: Data? { + if let data = kf.frameSource?.data { + return data + } + guard hasAnimatedFrames else { + return nil + } + if let data = kf.data(format: .GIF) { + return data + } + return kf.webpRepresentation() + } +} diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift index a33e1ff03..08585fe7b 100644 --- a/EhPanda/App/Tools/Extensions/Extensions.swift +++ b/EhPanda/App/Tools/Extensions/Extensions.swift @@ -69,10 +69,8 @@ extension URL { var isAnimatedImage: Bool { switch pathExtension.lowercased() { - case "gif", "webp": - true - default: - false + case "gif", "webp": true + default: false } } diff --git a/EhPanda/View/Reading/ReadingReducer+Body.swift b/EhPanda/View/Reading/ReadingReducer+Body.swift index 61e8df61f..9c35d59e8 100644 --- a/EhPanda/View/Reading/ReadingReducer+Body.swift +++ b/EhPanda/View/Reading/ReadingReducer+Body.swift @@ -73,17 +73,17 @@ extension ReadingReducer { return reduceRetryAllFailedWebImages(state: &state) case .copyImage(let imageURL): - return .send(.fetchImage(.copy(imageURL.isAnimatedImage), imageURL)) + return .send(.fetchImage(.copy, imageURL)) case .saveImage(let imageURL): - return .send(.fetchImage(.save(imageURL.isAnimatedImage), imageURL)) + return .send(.fetchImage(.save, imageURL)) case .saveImageDone(let isSucceeded): state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error() return .send(.setNavigation(.hud)) case .shareImage(let imageURL): - return .send(.fetchImage(.share(imageURL.isAnimatedImage), imageURL)) + return .send(.fetchImage(.share, imageURL)) case .fetchImage(let action, let imageURL): return .run { send in @@ -289,19 +289,22 @@ extension ReadingReducer { ) -> Effect { if case .success(let image) = result { switch action { - case .copy(let isAnimated): + case .copy: + let isAnimated = image.hasAnimatedFrames state.hudConfig = .copiedToClipboardSucceeded return .merge( .send(.setNavigation(.hud)), .run(operation: { _ in clipboardClient.saveImage(image, isAnimated) }) ) - case .save(let isAnimated): + case .save: + let isAnimated = image.hasAnimatedFrames return .run { send in let success = await imageClient.saveImageToPhotoLibrary(image, isAnimated) await send(.saveImageDone(success)) } - case .share(let isAnimated): - if isAnimated, let data = image.kf.data(format: .GIF) { + case .share: + let isAnimated = image.hasAnimatedFrames + if isAnimated, let data = image.animatedSourceData { return .send(.setNavigation(.share(.init(value: .data(data))))) } else { return .send(.setNavigation(.share(.init(value: .image(image))))) diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index 6154dbd4e..9a32deb4b 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -29,9 +29,7 @@ struct ReadingReducer { } enum ImageAction { - case copy(Bool) - case save(Bool) - case share(Bool) + case copy, save, share } @ObservableState From b86ae1559e2831b4a6b366eb6cb699cf652e73eb Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 20 May 2026 22:10:33 +0800 Subject: [PATCH 022/614] Refactor versioning --- .github/workflows/deploy.yml | 22 +++++++++------------- EhPanda.xcodeproj/project.pbxproj | 4 ++++ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 14950b7ad..75d7c1f5e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,7 +6,6 @@ on: types: [closed] env: DEVELOPER_DIR: /Applications/Xcode_26.5.app - APP_VERSION: '2.8.0' SCHEME_NAME: 'EhPanda' ALTSTORE_JSON_PATH: './AltStore.json' BUILDS_PATH: '/tmp/action-builds' @@ -45,11 +44,6 @@ jobs: -skipMacroValidation -scheme ${{ env.SCHEME_NAME }} -destination 'platform=iOS Simulator,name=iPhone Air' - - name: Bump version - id: bump-version - uses: yanamura/ios-bump-version@v1 - with: - version: ${{ env.APP_VERSION }} - name: Xcode archive run: xcodebuild archive -skipMacroValidation @@ -98,6 +92,7 @@ jobs: [[ ! -z "$min_os_version" ]] || exit 1 [[ ! -z "$size" ]] || exit 1 [[ ! -z "$sha256" ]] || exit 1 + [[ ! -z "$notes" ]] || exit 1 [[ ! -z "$privacy" ]] || exit 1 { @@ -147,11 +142,6 @@ jobs: body: ${{ github.event.pull_request.body }} name: ${{ github.event.pull_request.title }} tag_name: 'v${{ steps.retrieve-data.outputs.version }}' - - name: Commit bump version - run: | - git add . - git commit -m "Bump version" - git push origin HEAD - name: Update AltStore.json env: RELEASE_SIZE: ${{ steps.retrieve-data.outputs.size }} @@ -202,10 +192,16 @@ jobs: git commit -m "Update AltStore.json" git push origin HEAD - name: Post release notes + env: + RELEASE_VERSION: ${{ steps.retrieve-data.outputs.version }} + RELEASE_BODY: ${{ github.event.pull_request.body }} + RELEASE_NOTES: ${{ steps.retrieve-data.outputs.notes }} run: | + message="$(printf '*v%s Release Notes:*\n%s' "$RELEASE_VERSION" "$RELEASE_BODY")" curl https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \ -d parse_mode=markdown -d chat_id=${{ secrets.TELEGRAM_CHANNEL_ID }} \ - -d text='*v${{ steps.retrieve-data.outputs.version }} Release Notes:*%0A${{ github.event.pull_request.body }}' + --data-urlencode "text=$message" + payload="$(jq -n --arg content "**v$RELEASE_VERSION Release Notes:**\n$RELEASE_NOTES" '{content: $content}')" curl ${{ secrets.DISCORD_WEBHOOK }} \ - -F 'payload_json={"content": "**v${{ steps.retrieve-data.outputs.version }} Release Notes:**\n${{ steps.retrieve-data.outputs.notes }}"}' + -F "payload_json=$payload" diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index d289e8b0e..6518dfa3b 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -498,6 +498,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); + MARKETING_VERSION = 2.8.0; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -527,6 +528,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); + MARKETING_VERSION = 2.8.0; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -678,6 +680,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 2.8.0; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -707,6 +710,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 2.8.0; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; From ed5ff8f6e97bf44564666ebee36ef70be1673c60 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 21 May 2026 09:14:09 +0800 Subject: [PATCH 023/614] Update Xcode settings --- EhPanda.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 6518dfa3b..482c0f6ee 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -503,8 +503,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -533,8 +533,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -742,8 +742,8 @@ PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.tests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -770,8 +770,8 @@ PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.tests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; From 7584b158216ebcb8c81c78964b32dcd47eb137ec Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 21 May 2026 09:14:22 +0800 Subject: [PATCH 024/614] Improve WebP support --- EhPanda.xcodeproj/project.pbxproj | 37 ++++-- .../xcshareddata/swiftpm/Package.resolved | 36 ++++-- .../Clients/DownloadClient+Networking.swift | 22 +--- ...loadClient+ResponseValidationHelpers.swift | 22 +--- EhPanda/App/Tools/Clients/ImageClient.swift | 41 ++++++- EhPanda/App/Tools/Clients/LibraryClient.swift | 25 ++++- .../Tools/Extensions/AnimatedImageData.swift | 44 -------- .../Extensions/AnimatedImage_Extension.swift | 106 ++++++++++++++++++ EhPanda/App/Tools/Extensions/Extensions.swift | 4 +- EhPanda/View/Reading/ReadingView.swift | 3 + .../View/Reading/ReadingViewComponents.swift | 82 ++++++++------ .../View/Support/Components/Placeholder.swift | 23 +++- 12 files changed, 292 insertions(+), 153 deletions(-) delete mode 100644 EhPanda/App/Tools/Extensions/AnimatedImageData.swift create mode 100644 EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 482c0f6ee..baa390712 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -7,7 +7,8 @@ objects = { /* Begin PBXBuildFile section */ - 82F69C912F12815E00A7F9E4 /* KingfisherWebP in Frameworks */ = {isa = PBXBuildFile; productRef = 82F69C902F12815E00A7F9E4 /* KingfisherWebP */; }; + 82E6B0052F185A0000D1F93A /* SDWebImageWebPCoder in Frameworks */ = {isa = PBXBuildFile; productRef = 82E6B0042F185A0000D1F93A /* SDWebImageWebPCoder */; }; + 82E6B0082F185A0000D1F93A /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 82E6B0072F185A0000D1F93A /* SDWebImageSwiftUI */; }; AB17573D27675B1E00FD64E2 /* Colorful in Frameworks */ = {isa = PBXBuildFile; productRef = AB17573C27675B1E00FD64E2 /* Colorful */; }; AB17574027678B3400FD64E2 /* UIImageColors in Frameworks */ = {isa = PBXBuildFile; productRef = AB17573F27678B3400FD64E2 /* UIImageColors */; }; AB1FA94927C62BC80063EF55 /* CommonMark in Frameworks */ = {isa = PBXBuildFile; productRef = AB1FA94827C62BC80063EF55 /* CommonMark */; }; @@ -149,7 +150,8 @@ ABD49D5D277C6C9D003D1A07 /* SFSafeSymbols in Frameworks */, ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */, AB86AC1027831AD100E61E6A /* ComposableArchitecture in Frameworks */, - 82F69C912F12815E00A7F9E4 /* KingfisherWebP in Frameworks */, + 82E6B0052F185A0000D1F93A /* SDWebImageWebPCoder in Frameworks */, + 82E6B0082F185A0000D1F93A /* SDWebImageSwiftUI in Frameworks */, ABBB2636278FB888007B6149 /* SwiftUINavigation in Frameworks */, AB1FA94927C62BC80063EF55 /* CommonMark in Frameworks */, AB17573D27675B1E00FD64E2 /* Colorful in Frameworks */, @@ -287,7 +289,8 @@ AB2EB9A1280251F600011A8A /* AlertKit */, AB2EB9A42802521700011A8A /* DeprecatedAPI */, EAE63E2029E2A6330048C601 /* SwiftyBeaver */, - 82F69C902F12815E00A7F9E4 /* KingfisherWebP */, + 82E6B0042F185A0000D1F93A /* SDWebImageWebPCoder */, + 82E6B0072F185A0000D1F93A /* SDWebImageSwiftUI */, ); productName = EhPanda; productReference = ABC3C7542593696C00E0C11B /* EhPanda.app */; @@ -358,7 +361,6 @@ ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */, AB60D0E7274C7ECE00F899AB /* XCRemoteSwiftPackageReference "WaterfallGrid" */, ABC4A0772751B40E00968A4F /* XCRemoteSwiftPackageReference "Kingfisher" */, - 8268CA182F127FF900AE0557 /* XCRemoteSwiftPackageReference "KingfisherWebP" */, AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */, AB17573E27678B3400FD64E2 /* XCRemoteSwiftPackageReference "UIImageColors" */, ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, @@ -370,6 +372,8 @@ AB2EB9A0280251F600011A8A /* XCRemoteSwiftPackageReference "AlertKit" */, AB2EB9A32802521700011A8A /* XCRemoteSwiftPackageReference "DeprecatedAPI" */, EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */, + 82E6B0032F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */, + 82E6B0062F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */, ); preferredProjectObjectVersion = 100; @@ -817,12 +821,20 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 8268CA182F127FF900AE0557 /* XCRemoteSwiftPackageReference "KingfisherWebP" */ = { + 82E6B0032F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/yeatse/KingfisherWebP"; + repositoryURL = "https://github.com/SDWebImage/SDWebImageWebPCoder"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.7.2; + minimumVersion = 0.14.6; + }; + }; + 82E6B0062F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageSwiftUI"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; }; }; A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */ = { @@ -968,10 +980,15 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 82F69C902F12815E00A7F9E4 /* KingfisherWebP */ = { + 82E6B0042F185A0000D1F93A /* SDWebImageWebPCoder */ = { + isa = XCSwiftPackageProductDependency; + package = 82E6B0032F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */; + productName = SDWebImageWebPCoder; + }; + 82E6B0072F185A0000D1F93A /* SDWebImageSwiftUI */ = { isa = XCSwiftPackageProductDependency; - package = 8268CA182F127FF900AE0557 /* XCRemoteSwiftPackageReference "KingfisherWebP" */; - productName = KingfisherWebP; + package = 82E6B0062F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; + productName = SDWebImageSwiftUI; }; A66A766C2F77C88A00FC07B8 /* SwiftLintBuildToolPlugin */ = { isa = XCSwiftPackageProductDependency; diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0068e292e..9021f5014 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1db400ad57793dfef830afbe50dda3941f6d39d89c823ef409aba9435f872876", + "originHash" : "e701a6d79a25f06dac1d0b3156dba1f0ad620d066aae2bdd39ab0ef6f21b8391", "pins" : [ { "identity" : "alertkit", @@ -65,21 +65,39 @@ } }, { - "identity" : "kingfisherwebp", + "identity" : "libwebp-xcode", "kind" : "remoteSourceControl", - "location" : "https://github.com/yeatse/KingfisherWebP", + "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", "state" : { - "revision" : "6939874df4417cc37a05893ac2f27332e6668460", - "version" : "1.7.3" + "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", + "version" : "1.5.0" } }, { - "identity" : "libwebp-xcode", + "identity" : "sdwebimage", "kind" : "remoteSourceControl", - "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", + "location" : "https://github.com/SDWebImage/SDWebImage", "state" : { - "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", - "version" : "1.5.0" + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "sdwebimageswiftui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageSwiftUI", + "state" : { + "revision" : "0e331457ca9af2f0b08bcaa138a91ffb907b004f", + "version" : "3.1.4" + } + }, + { + "identity" : "sdwebimagewebpcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder", + "state" : { + "revision" : "12d83edbcc795fb7b5c0c3cb74d739108d3357d2", + "version" : "0.15.0" } }, { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift index acbb2a411..da15cb8ed 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift @@ -315,7 +315,7 @@ extension DownloadManager { if let ext = extensionFromMimeType(response) { return ext } - return extensionFromMagicBytes(prefixData) ?? "jpg" + return prefixData.knownBinaryImageFileExtension ?? "jpg" } private func extensionFromMimeType( @@ -339,26 +339,6 @@ extension DownloadManager { } } - private func extensionFromMagicBytes( - _ prefixData: Data - ) -> String? { - if prefixData.starts(with: [0x47, 0x49, 0x46]) { - return "gif" - } - if prefixData.starts(with: [0x89, 0x50, 0x4E, 0x47]) { - return "png" - } - if prefixData.starts(with: [0x52, 0x49, 0x46, 0x46]), - prefixData.count >= 12, - String( - bytes: prefixData[8..<12], - encoding: .utf8 - ) == "WEBP" { - return "webp" - } - return nil - } - func createDirectory(at url: URL) throws { try fileManager().createDirectory( at: url, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index 6391c62ee..2bf5626ec 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -44,7 +44,7 @@ extension DownloadManager { return prefixLooksLikeHTML(prefixData) } - guard !prefixIsKnownBinaryImage(prefixData) else { + guard !prefixData.isKnownBinaryImageFormat else { return false } return true @@ -111,26 +111,6 @@ extension DownloadManager { ) } - func prefixIsKnownBinaryImage( - _ prefixData: Data - ) -> Bool { - prefixData.starts(with: [0xFF, 0xD8, 0xFF]) - || prefixData.starts( - with: [0x89, 0x50, 0x4E, 0x47] - ) - || prefixData.starts(with: [0x47, 0x49, 0x46]) - || ( - prefixData.starts( - with: [0x52, 0x49, 0x46, 0x46] - ) - && prefixData.count >= 12 - && String( - bytes: prefixData[8..<12], - encoding: .utf8 - ) == "WEBP" - ) - } - func isQuotaExceededResponse( fullData: Data?, fileURL: URL?, diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index ef1866475..fe23feaf4 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -7,6 +7,7 @@ import Photos import SwiftUI import Combine import Kingfisher +import SDWebImage import ComposableArchitecture struct ImageClient: Sendable { @@ -19,7 +20,25 @@ struct ImageClient: Sendable { extension ImageClient { static let live: Self = .init( prefetchImages: { urls in - ImagePrefetcher(urls: urls).start() + let (sdWebImageURLs, kingfisherURLs) = urls.reduce(into: ([URL](), [URL]())) { result, url in + if url.isPotentiallyAnimatedImage { + result.0.append(url) + } else { + result.1.append(url) + } + } + if !kingfisherURLs.isEmpty { + ImagePrefetcher(urls: kingfisherURLs).start() + } + if !sdWebImageURLs.isEmpty { + SDWebImagePrefetcher.shared.prefetchURLs( + sdWebImageURLs, + options: [.lowPriority, .continueInBackground, .handleCookies], + context: [.animatedImageClass: SDAnimatedImage.self], + progress: nil, + completed: nil + ) + } }, saveImageToPhotoLibrary: { (image, isAnimated) in await withCheckedContinuation { continuation in @@ -39,7 +58,24 @@ extension ImageClient { } }, downloadImage: { url in - await withCheckedContinuation { continuation in + if url.isPotentiallyAnimatedImage { + let result: Result = await withCheckedContinuation { continuation in + SDWebImageManager.shared.loadImage( + with: url, + options: [.retryFailed, .continueInBackground, .handleCookies], + context: [.callbackQueue: SDCallbackQueue.main], + progress: nil + ) { image, _, error, _, _, _ in + if let image { + continuation.resume(returning: .success(image)) + } else { + continuation.resume(returning: .failure(error ?? AppError.notFound)) + } + } + } + return result + } + let result: Result = await withCheckedContinuation { continuation in KingfisherManager.shared.downloader.downloadImage(with: url, options: nil) { result in switch result { case .success(let result): @@ -49,6 +85,7 @@ extension ImageClient { } } } + return result }, retrieveImage: { key in await withCheckedContinuation { continuation in diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index 574e47f8a..afd1abc89 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -7,9 +7,10 @@ import SwiftUI import Combine import Foundation import Kingfisher +import SDWebImage +import SDWebImageWebPCoder import SwiftyBeaver import UIImageColors -import KingfisherWebP import ComposableArchitecture struct LibraryClient: Sendable { @@ -55,13 +56,19 @@ extension LibraryClient { let config = KingfisherManager.shared.downloader.sessionConfiguration config.httpCookieStorage = HTTPCookieStorage.shared KingfisherManager.shared.downloader.sessionConfiguration = config - KingfisherManager.shared.defaultOptions += [ - .processor(WebPProcessor.default), - .cacheSerializer(WebPSerializer.default) - ] + + let sdConfig = URLSessionConfiguration.default + sdConfig.httpCookieStorage = HTTPCookieStorage.shared + SDWebImageDownloaderConfig.default.sessionConfiguration = sdConfig + SDWebImageDownloader.shared.setValue( + "image/webp,image/apng,image/png,image/gif,image/*,*/*;q=0.8", + forHTTPHeaderField: "Accept" + ) + SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) }, clearWebImageDiskCache: { KingfisherManager.shared.cache.clearDiskCache() + SDImageCache.shared.clearDisk(onCompletion: nil) }, analyzeImageColors: { image in await withCheckedContinuation { continuation in @@ -79,11 +86,17 @@ extension LibraryClient { } }, calculateWebImageDiskCacheSize: { - await withCheckedContinuation { continuation in + async let kingfisherSize: UInt? = withCheckedContinuation { continuation in KingfisherManager.shared.cache.calculateDiskStorageSize { continuation.resume(returning: try? $0.get()) } } + async let sdWebImageSize: UInt? = withCheckedContinuation { continuation in + SDImageCache.shared.calculateSize { _, totalSize in + continuation.resume(returning: UInt(totalSize)) + } + } + return await (kingfisherSize ?? 0) + (sdWebImageSize ?? 0) } ) } diff --git a/EhPanda/App/Tools/Extensions/AnimatedImageData.swift b/EhPanda/App/Tools/Extensions/AnimatedImageData.swift deleted file mode 100644 index 039e60dfa..000000000 --- a/EhPanda/App/Tools/Extensions/AnimatedImageData.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// AnimatedImageData.swift -// EhPanda -// - -import UIKit -import Kingfisher -import KingfisherWebP -import UniformTypeIdentifiers - -extension Data { - var animatedImagePasteboardType: String? { - if isWebPFormat { - return UTType.webP.identifier - } - if isGIFFormat { - return UTType.gif.identifier - } - return nil - } - - private var isGIFFormat: Bool { - starts(with: [0x47, 0x49, 0x46]) - } -} - -extension UIImage { - var hasAnimatedFrames: Bool { - (kf.imageFrameCount ?? images?.count ?? 1) > 1 - } - - var animatedSourceData: Data? { - if let data = kf.frameSource?.data { - return data - } - guard hasAnimatedFrames else { - return nil - } - if let data = kf.data(format: .GIF) { - return data - } - return kf.webpRepresentation() - } -} diff --git a/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift new file mode 100644 index 000000000..ab496183d --- /dev/null +++ b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift @@ -0,0 +1,106 @@ +// +// AnimatedImage_Extension.swift +// EhPanda +// + +import UIKit +import SDWebImage +import UniformTypeIdentifiers + +private enum ImageDataSignature { + static let jpeg: [UInt8] = [0xFF, 0xD8, 0xFF] + static let png: [UInt8] = [0x89, 0x50, 0x4E, 0x47] + static let gif = Array("GIF".utf8) + static let riff = Array("RIFF".utf8) + static let webp = Array("WEBP".utf8) + static let apngAnimationControl = Array("acTL".utf8) +} + +extension Data { + var knownBinaryImageFileExtension: String? { + if isJPEGFormat { + return "jpg" + } + if isPNGFormat { + return "png" + } + if isGIFFormat { + return "gif" + } + if isWebPFormat { + return "webp" + } + return nil + } + + var isKnownBinaryImageFormat: Bool { + knownBinaryImageFileExtension != nil + } + + var isJPEGFormat: Bool { + starts(with: ImageDataSignature.jpeg) + } + + var isPNGFormat: Bool { + starts(with: ImageDataSignature.png) + } + + var isAPNGFormat: Bool { + isPNGFormat && range(of: Data(ImageDataSignature.apngAnimationControl)) != nil + } + + var isGIFFormat: Bool { + starts(with: ImageDataSignature.gif) + } + + var isWebPFormat: Bool { + starts(with: ImageDataSignature.riff) + && hasBytes(ImageDataSignature.webp, at: 8) + } + + var animatedImagePasteboardType: String? { + if isWebPFormat { + return UTType.webP.identifier + } + if isAPNGFormat { + return UTType.png.identifier + } + if isGIFFormat { + return UTType.gif.identifier + } + return nil + } + + private func hasBytes(_ bytes: [UInt8], at offset: Int) -> Bool { + guard count >= offset + bytes.count else { return false } + let start = index(startIndex, offsetBy: offset) + let end = index(start, offsetBy: bytes.count) + return self[start.. Void init( - index: Int, isDualPage: Bool, isDatabaseLoading: Bool, backgroundColor: Color, + index: Int, isDualPage: Bool, isActive: Bool, isDatabaseLoading: Bool, backgroundColor: Color, config: ImageStackConfig, imageURLs: [Int: URL], originalImageURLs: [Int: URL], loadingStates: [Int: LoadingState], enablesLiveText: Bool, liveTextGroups: [Int: [LiveTextGroup]], focusedLiveTextGroup: LiveTextGroup?, @@ -74,6 +77,7 @@ struct HorizontalImageStack: View { ) { self.index = index self.isDualPage = isDualPage + self.isActive = isActive self.isDatabaseLoading = isDatabaseLoading self.backgroundColor = backgroundColor self.config = config @@ -112,6 +116,7 @@ struct HorizontalImageStack: View { imageURL: imageURLs[index], loadingState: loadingStates[index] ?? .idle, isDualPage: isDualPage, + isActive: isActive, backgroundColor: backgroundColor, enablesLiveText: enablesLiveText, liveTextGroups: liveTextGroups[index] ?? [], @@ -181,6 +186,7 @@ struct ImageContainer: View { private let imageURL: URL? private let loadingState: LoadingState private let isDualPage: Bool + private let isActive: Bool private let backgroundColor: Color private let enablesLiveText: Bool private let liveTextGroups: [LiveTextGroup] @@ -195,6 +201,7 @@ struct ImageContainer: View { index: Int, imageURL: URL?, loadingState: LoadingState, isDualPage: Bool, + isActive: Bool, backgroundColor: Color, enablesLiveText: Bool, liveTextGroups: [LiveTextGroup], @@ -209,6 +216,7 @@ struct ImageContainer: View { self.imageURL = imageURL self.loadingState = loadingState self.isDualPage = isDualPage + self.isActive = isActive self.backgroundColor = backgroundColor self.enablesLiveText = enablesLiveText self.liveTextGroups = liveTextGroups @@ -220,42 +228,48 @@ struct ImageContainer: View { self.loadFailedAction = loadFailedAction } - private func placeholder(_ progress: Progress) -> some View { - Placeholder(style: .progress( - pageNumber: index, progress: progress, - isDualPage: isDualPage, backgroundColor: backgroundColor - )) + private func placeholder(_ progress: Progress?) -> some View { + Placeholder( + style: .progress( + pageNumber: index, + progress: progress, + isDualPage: isDualPage, + backgroundColor: backgroundColor + ) + ) .frame(width: width, height: height) } @ViewBuilder private func image(url: URL?) -> some View { - if let url, url.isFileURL { - if url.isAnimatedImage { - KFAnimatedImage(url) - .cacheMemoryOnly() - .placeholder(placeholder).fade(duration: 0.25) - .onSuccess(onSuccess).onFailure(onFailure) - } else { - KFImage.url( - url, - cacheKey: localFileCacheKey(url) - ) - .cacheMemoryOnly() - .placeholder(placeholder) - .defaultModifier(withRoundedCorners: false) - .onSuccess(onSuccess).onFailure(onFailure) - } - } else if url?.isAnimatedImage != true { - KFImage.url( - url, - cacheKey: url?.stableImageCacheKey ?? url?.absoluteString + if let url, url.isPotentiallyAnimatedImage { + AnimatedImage( + url: url, + options: [.retryFailed, .continueInBackground, .handleCookies], + context: [.callbackQueue: SDCallbackQueue.main], + isAnimating: .constant(isActive), + placeholder: { placeholder(nil) } ) - .placeholder(placeholder) - .defaultModifier(withRoundedCorners: false) - .onSuccess(onSuccess).onFailure(onFailure) + .resizable() + .onViewUpdate { imageView, _ in + if !isActive { + imageView.stopAnimating() + } + } + .onSuccess(perform: { _, _, _ in loadSucceededAction(index) }) + .onFailure(perform: { _ in loadFailedAction(index) }) + .clipped() } else { - KFAnimatedImage(url) - .placeholder(placeholder).fade(duration: 0.25) - .onSuccess(onSuccess).onFailure(onFailure) + let isFileURL = url?.isFileURL ?? false + let cacheKey = url.map { url in + isFileURL + ? localFileCacheKey(url) + : url.stableImageCacheKey ?? url.absoluteString + } + KFImage.url(url, cacheKey: cacheKey) + .cacheMemoryOnly(isFileURL) + .placeholder(placeholder) + .defaultModifier(withRoundedCorners: false) + .onSuccess(onSuccess) + .onFailure(onFailure) } } @@ -306,6 +320,10 @@ struct ImageContainer: View { } } + private var emptyProgress: Progress { + Progress(totalUnitCount: 1) + } + private func localFileCacheKey(_ url: URL) -> String { let resourceValues = try? url.resourceValues(forKeys: [ .contentModificationDateKey, diff --git a/EhPanda/View/Support/Components/Placeholder.swift b/EhPanda/View/Support/Components/Placeholder.swift index 89fea30e8..db9c3b7fe 100644 --- a/EhPanda/View/Support/Components/Placeholder.swift +++ b/EhPanda/View/Support/Components/Placeholder.swift @@ -18,17 +18,28 @@ struct Placeholder: View { case .activity(let ratio, let cornerRadius): ZStack { Color(inSheet ? .systemGray4 : .systemGray5) + ProgressView() } - .aspectRatio(ratio, contentMode: .fill).cornerRadius(cornerRadius) + .aspectRatio(ratio, contentMode: .fill) + .cornerRadius(cornerRadius) + case .progress(let pageNumber, let progress, let isDualPage, let backgroundColor): ZStack { backgroundColor VStack { - Text(String(pageNumber)).font(.largeTitle.bold()) - .foregroundColor(.gray).padding(.bottom, 30) - ProgressView(progress).progressViewStyle(.plainLinear) - .frame(width: DeviceUtil.absWindowW * (isDualPage ? 0.25 : 0.5)) + Text(String(pageNumber)) + .font(.largeTitle.bold()) + .foregroundColor(.gray) + .padding(.bottom, 30) + + if let progress { + ProgressView(progress) + .progressViewStyle(.plainLinear) + .frame(width: DeviceUtil.absWindowW * (isDualPage ? 0.25 : 0.5)) + } else { + ProgressView() + } } } } @@ -37,5 +48,5 @@ struct Placeholder: View { enum PlaceholderStyle { case activity(ratio: CGFloat, cornerRadius: CGFloat = 5) - case progress(pageNumber: Int, progress: Progress, isDualPage: Bool = false, backgroundColor: Color) + case progress(pageNumber: Int, progress: Progress?, isDualPage: Bool = false, backgroundColor: Color) } From 214d784c8d350026c334ff2901f3955c5644bf08 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 21 May 2026 17:57:38 +0800 Subject: [PATCH 025/614] Fix download parsing issue --- .../DownloadClient+ResponseValidation.swift | 4 +++ ...loadClient+ResponseValidationHelpers.swift | 11 ++++++ .../Clients/DownloadClient+Testing.swift | 12 +++++++ .../Download/DownloadImageErrorTests.swift | 34 +++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift index 13fe29400..60af90102 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift @@ -227,6 +227,10 @@ extension DownloadManager { encoding: .utf8 ) ?? "" + guard !prefixLooksLikeJSON(prefixData) else { + return nil + } + let looksLikeHTML = responseLooksLikeHTML( mimeType: mimeType, prefixData: prefixData, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index 2bf5626ec..9fd04e256 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -68,6 +68,17 @@ extension DownloadManager { return htmlMarkers.contains(where: prefix.contains) } + func prefixLooksLikeJSON(_ prefixData: Data) -> Bool { + let prefix = String( + bytes: prefixData, + encoding: .utf8 + )? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard let firstCharacter = prefix.first else { return false } + + return firstCharacter == "{" || firstCharacter == "[" + } + func responseLooksLikeHTML( mimeType: String?, prefixData: Data, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index ed7953fb2..dc1e2115b 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -115,5 +115,17 @@ extension DownloadManager { requestURL: requestURL ) } + + func testingDetectResponseError( + data: Data, + response: URLResponse, + requestURL: URL? + ) -> AppError? { + detectResponseError( + data: data, + response: response, + requestURL: requestURL + ) + } } #endif diff --git a/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift b/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift index 37ff2a1cc..f26fbe646 100644 --- a/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift @@ -144,4 +144,38 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { } } + @Test + func testTextHTMLJSONAPIResponseDoesNotMapToParseFailed() async throws { + let manager = makeTestingDownloadManager() + let apiURL = try #require(URL(string: "https://e-hentai.org/api.php")) + let response = try makeResponse( + url: apiURL, + contentType: "text/html; charset=UTF-8" + ) + let responsePayload: [String: String] = [ + "d": "1184 x 1728 :: 14.78 MiB", + "o": "org", + "lf": #"fullimg/3861928/1/99j92okaldl/Karin_1.webp"#, + "ls": "?f_shash=6aa741ba4e302352139ae2fc7377c846e68d9093", + "ll": "6aa741ba4e302352139ae2fc7377c846e68d9093" + + "-15497378-1184-1728-wbp/forumtoken/3861928-1/Karin_1.webp", + "lo": "s/6aa741ba4e/3861928-1", + "xres": "1184", + "yres": "1728", + "i": "https://mrfmlfe.vzpqazmbjydh.hath.network:60000/h/" + + "6aa741ba4e302352139ae2fc7377c846e68d9093-15497378-1184-1728-wbp/" + + "keystamp=1779356100-f4c09dd971;fileindex=232157952;xres=org/Karin_1.webp", + "s": "48803" + ] + let data = try JSONSerialization.data(withJSONObject: responsePayload) + + let error = await manager.testingDetectResponseError( + data: data, + response: response, + requestURL: apiURL + ) + + #expect(error == nil) + } + } From f2f0866464e007e1364ee2e893eb9fdecd90ba9d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 25 May 2026 17:10:32 +0800 Subject: [PATCH 026/614] Update download layout & logics --- EhPanda/App/Generated/Strings.swift | 18 ++ .../DownloadClient+ExecutionPerform.swift | 58 +++-- .../DownloadClient+PersistenceNormalize.swift | 32 +++ .../Clients/DownloadClient+PublicAPI.swift | 5 + .../App/Tools/Clients/DownloadClient.swift | 6 + .../DownloadFileStorage+Operations.swift | 203 ++++++++++++++++-- .../Tools/Utilities/DownloadFileStorage.swift | 17 ++ EhPanda/App/de.lproj/Localizable.strings | 7 + EhPanda/App/en.lproj/Localizable.strings | 7 + EhPanda/App/ja.lproj/Localizable.strings | 7 + EhPanda/App/ko.lproj/Localizable.strings | 35 +-- EhPanda/App/zh-Hans.lproj/Localizable.strings | 7 + .../App/zh-Hant-HK.lproj/Localizable.strings | 7 + .../App/zh-Hant-TW.lproj/Localizable.strings | 7 + EhPanda/App/zh-Hant.lproj/Localizable.strings | 7 + .../DownloadedGallery+Extensions.swift | 53 +++++ .../DownloadedGallery+Manifest.swift | 94 ++++++++ .../Models/Persistent/DownloadedGallery.swift | 36 +--- .../Detail/DetailView+HeaderSection.swift | 33 ++- EhPanda/View/Downloads/DownloadsReducer.swift | 79 +++++++ .../Downloads/DownloadsView+Subviews.swift | 107 ++++++++- EhPanda/View/Downloads/DownloadsView.swift | 76 ++++++- .../Reading/ReadingReducer+Database.swift | 4 + .../Components/Cells/GalleryDetailCell.swift | 13 +- .../Components/DownloadBadgeLabel.swift | 44 +++- .../DownloadFileStorageHashTests.swift | 142 ++++++++++++ .../DownloadsReducerActionTests.swift | 88 ++++++++ .../DownloadsReducerReadingDismissTests.swift | 32 +++ .../ReadingReducerDownloadTests.swift | 24 +++ 29 files changed, 1135 insertions(+), 113 deletions(-) create mode 100644 EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift create mode 100644 EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift create mode 100644 EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index b5f4f8ae4..0b5a8091c 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -593,6 +593,8 @@ internal enum L10n { } } internal enum Validation { + /// Cover image data is corrupted. + internal static let coverImageCorrupted = L10n.tr("Localizable", "download_file_storage.validation.cover_image_corrupted", fallback: "Cover image data is corrupted.") /// Cover image is missing. internal static let coverImageMissing = L10n.tr("Localizable", "download_file_storage.validation.cover_image_missing", fallback: "Cover image is missing.") /// Download folder is missing. @@ -605,6 +607,10 @@ internal enum L10n { internal static let manifestCorrupted = L10n.tr("Localizable", "download_file_storage.validation.manifest_corrupted", fallback: "Manifest file is corrupted.") /// Manifest file is missing. internal static let manifestMissing = L10n.tr("Localizable", "download_file_storage.validation.manifest_missing", fallback: "Manifest file is missing.") + /// Page %d image data is corrupted. + internal static func pageImageCorrupted(_ p1: Int) -> String { + return L10n.tr("Localizable", "download_file_storage.validation.page_image_corrupted", p1, fallback: "Page %d image data is corrupted.") + } /// Page %d is missing. internal static func pageMissing(_ p1: Int) -> String { return L10n.tr("Localizable", "download_file_storage.validation.page_missing", p1, fallback: "Page %d is missing.") @@ -637,6 +643,8 @@ internal enum L10n { internal enum Button { /// Clear Filters internal static let clearFilters = L10n.tr("Localizable", "downloads_view.button.clear_filters", fallback: "Clear Filters") + /// Validate Image Data + internal static let validateImageData = L10n.tr("Localizable", "downloads_view.button.validate_image_data", fallback: "Validate Image Data") } internal enum Dialog { internal enum Message { @@ -666,6 +674,8 @@ internal enum L10n { internal static let updateDownload = L10n.tr("Localizable", "downloads_view.inspector.button.update_download", fallback: "Update Download") } internal enum Page { + /// No pages + internal static let `none` = L10n.tr("Localizable", "downloads_view.inspector.page.none", fallback: "No pages") /// Pending internal static let pending = L10n.tr("Localizable", "downloads_view.inspector.page.pending", fallback: "Pending") /// Tap to retry this page @@ -681,6 +691,14 @@ internal enum L10n { /// Pages internal static let pages = L10n.tr("Localizable", "downloads_view.inspector.section.pages", fallback: "Pages") } + internal enum Status { + /// Downloaded + internal static let downloaded = L10n.tr("Localizable", "downloads_view.inspector.status.downloaded", fallback: "Downloaded") + /// Failed + internal static let failed = L10n.tr("Localizable", "downloads_view.inspector.status.failed", fallback: "Failed") + /// Pending + internal static let pending = L10n.tr("Localizable", "downloads_view.inspector.status.pending", fallback: "Pending") + } internal enum Title { /// Download Status internal static let downloadStatus = L10n.tr("Localizable", "downloads_view.inspector.title.download_status", fallback: "Download Status") diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 7fde40aa5..c174449b3 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -205,11 +205,45 @@ extension DownloadManager { finalizeContext: FinalizeContext ) async throws { let versionSignature = finalizeContext.versionSignature - let coverRelativePath = finalizeContext.coverRelativePath let batchResult = finalizeContext.batchResult let storedGalleryImageState = finalizeContext.storedGalleryImageState let existingDownload = finalizeContext.existingDownload - let manifest = DownloadManifest( + let manifest = makeManifest( + payload: payload, + coverRelativePath: finalizeContext.coverRelativePath, + batchResult: batchResult, + versionSignature: versionSignature + ) + let hashedManifest = try storage.addingCurrentFileHashes( + to: manifest, + folderURL: temporaryFolderURL + ) + try storage.writeManifest( + hashedManifest, + folderURL: temporaryFolderURL + ) + try? storage.removeFailedPages( + folderURL: temporaryFolderURL + ) + try storage.replaceFolder( + relativePath: folderRelativePath, + with: temporaryFolderURL + ) + await cleanupCachedRemoteAssetsAfterSuccessfulDownload( + payload: payload, + storedGalleryImageState: storedGalleryImageState, + pages: batchResult.pages, + existingDownload: existingDownload + ) + } + + private func makeManifest( + payload: DownloadRequestPayload, + coverRelativePath: String?, + batchResult: DownloadBatchResult, + versionSignature: String + ) -> DownloadManifest { + DownloadManifest( gid: payload.gallery.gid, host: payload.host, token: payload.gallery.token, @@ -222,8 +256,7 @@ extension DownloadManager { postedDate: payload.galleryDetail.postedDate, pageCount: payload.galleryDetail.pageCount, coverRelativePath: coverRelativePath, - galleryURL: - payload.gallery.galleryURL.forceUnwrapped, + galleryURL: payload.gallery.galleryURL.forceUnwrapped, rating: payload.galleryDetail.rating, downloadOptions: payload.options, versionSignature: versionSignature, @@ -237,22 +270,5 @@ extension DownloadManager { ) } ) - try storage.writeManifest( - manifest, - folderURL: temporaryFolderURL - ) - try? storage.removeFailedPages( - folderURL: temporaryFolderURL - ) - try storage.replaceFolder( - relativePath: folderRelativePath, - with: temporaryFolderURL - ) - await cleanupCachedRemoteAssetsAfterSuccessfulDownload( - payload: payload, - storedGalleryImageState: storedGalleryImageState, - pages: batchResult.pages, - existingDownload: existingDownload - ) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 2bf3eec3c..75bb7f53d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -183,6 +183,7 @@ extension DownloadManager { .validate(download: download) switch validation { case .valid: + refreshMissingManifestHashesIfNeeded(download: download) let expectedStatus: DownloadStatus = download.hasUpdate ? .updateAvailable : .completed @@ -221,4 +222,35 @@ extension DownloadManager { } } } + + func validateImageData() async { + await validateDownloads() + await notifyObservers() + } + + private func refreshMissingManifestHashesIfNeeded( + download: DownloadedGallery + ) { + guard let folderURL = download + .resolvedFolderURL(rootURL: storage.rootURL), + let manifest = try? storage.readManifest(folderURL: folderURL), + manifest.needsFileHashRefresh + else { + return + } + + do { + try storage.refreshManifestFileHashes(folderURL: folderURL) + } catch { + Logger.error(error) + } + } +} + +private extension DownloadManifest { + var needsFileHashRefresh: Bool { + let needsCoverHash = coverRelativePath?.notEmpty == true + && coverFileHash == nil + return needsCoverHash || pages.contains { $0.fileHash == nil } + } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 9dba59b33..448a85b2d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -331,6 +331,11 @@ extension DownloadManager { index: index, folderURL: captureTarget.folderURL ) } + _ = try? storage.refreshManifestPageFileHash( + folderURL: captureTarget.folderURL, + pageIndex: index, + relativePath: pageResult.relativePath + ) _ = await sanitizeLocalFilesIfNeeded(gid: gid, clearingLastError: true) } catch { Logger.error(error) diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index fc9f9f16b..4defe4234 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -12,6 +12,7 @@ struct DownloadClient: Sendable { let fetchDownload: @Sendable (String) async -> DownloadedGallery? let reconcileDownloads: @Sendable () async -> Void let refreshDownloads: @Sendable () async -> Void + let validateImageData: @Sendable () async -> Void let resumeQueue: @Sendable () async -> Void let badges: @Sendable ([String]) async -> [String: DownloadBadge] let fetchVersionMetadata: @Sendable (String, String) async -> Result @@ -32,6 +33,7 @@ struct DownloadClient: Sendable { fetchDownload: @escaping @Sendable (String) async -> DownloadedGallery?, reconcileDownloads: @escaping @Sendable () async -> Void = {}, refreshDownloads: @escaping @Sendable () async -> Void, + validateImageData: @escaping @Sendable () async -> Void = {}, resumeQueue: @escaping @Sendable () async -> Void, badges: @escaping @Sendable ([String]) async -> [String: DownloadBadge], fetchVersionMetadata: @escaping @Sendable (String, String) async -> Result @@ -58,6 +60,7 @@ struct DownloadClient: Sendable { self.fetchDownload = fetchDownload self.reconcileDownloads = reconcileDownloads self.refreshDownloads = refreshDownloads + self.validateImageData = validateImageData self.resumeQueue = resumeQueue self.badges = badges self.fetchVersionMetadata = fetchVersionMetadata @@ -117,6 +120,7 @@ extension DownloadClient { fetchDownload: { gid in await manager.fetchDownload(gid: gid) }, reconcileDownloads: { await manager.reconcileDownloads() }, refreshDownloads: { await manager.refreshDownloads() }, + validateImageData: { await manager.validateImageData() }, resumeQueue: { await manager.resumeQueue() }, badges: { gids in await manager.badges(for: gids) }, fetchVersionMetadata: { gid, token in @@ -169,6 +173,7 @@ extension DownloadClient { fetchDownload: { _ in nil }, reconcileDownloads: {}, refreshDownloads: {}, + validateImageData: {}, resumeQueue: {}, badges: { _ in [:] }, fetchVersionMetadata: { _, _ in .failure(.notFound) }, @@ -192,6 +197,7 @@ extension DownloadClient { fetchDownload: IssueReporting.unimplemented(placeholder: placeholder()), reconcileDownloads: IssueReporting.unimplemented(placeholder: placeholder()), refreshDownloads: IssueReporting.unimplemented(placeholder: placeholder()), + validateImageData: IssueReporting.unimplemented(placeholder: placeholder()), resumeQueue: IssueReporting.unimplemented(placeholder: placeholder()), badges: IssueReporting.unimplemented(placeholder: placeholder()), fetchVersionMetadata: IssueReporting.unimplemented(placeholder: placeholder()), diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index f0bb8b54b..793ff3cad 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -77,6 +77,88 @@ extension DownloadFileStorage { } } + func addingCurrentFileHashes( + to manifest: DownloadManifest, + folderURL: URL + ) throws -> DownloadManifest { + let coverFileHash: String? + if let coverRelativePath = manifest.coverRelativePath, + coverRelativePath.notEmpty { + coverFileHash = try hashReadableAsset( + folderURL: folderURL, + relativePath: coverRelativePath, + missingMessage: L10n.Localizable.DownloadFileStorage.Validation.coverImageMissing + ) + } else { + coverFileHash = nil + } + + let pages = try manifest.pages.map { page in + DownloadManifest.Page( + index: page.index, + relativePath: page.relativePath, + fileHash: try hashReadableAsset( + folderURL: folderURL, + relativePath: page.relativePath, + missingMessage: L10n.Localizable.DownloadFileStorage.Validation.pageMissing(page.index) + ) + ) + } + + return manifest.replacing( + coverFileHash: coverFileHash, + pages: pages + ) + } + + @discardableResult + func refreshManifestFileHashes(folderURL: URL) throws -> DownloadManifest { + let manifest = try readManifest(folderURL: folderURL) + let hashedManifest = try addingCurrentFileHashes( + to: manifest, + folderURL: folderURL + ) + if hashedManifest != manifest { + try writeManifest(hashedManifest, folderURL: folderURL) + } + return hashedManifest + } + + @discardableResult + func refreshManifestPageFileHash( + folderURL: URL, + pageIndex: Int, + relativePath: String? = nil + ) throws -> DownloadManifest { + let manifest = try readManifest(folderURL: folderURL) + var didUpdate = false + let pages = try manifest.pages.map { page in + guard page.index == pageIndex else { return page } + didUpdate = true + let refreshedRelativePath = relativePath ?? page.relativePath + return DownloadManifest.Page( + index: page.index, + relativePath: refreshedRelativePath, + fileHash: try hashReadableAsset( + folderURL: folderURL, + relativePath: refreshedRelativePath, + missingMessage: L10n.Localizable.DownloadFileStorage.Validation.pageMissing(page.index) + ) + ) + } + + guard didUpdate else { return manifest } + + let refreshedManifest = manifest.replacing( + coverFileHash: manifest.coverFileHash, + pages: pages + ) + if refreshedManifest != manifest { + try writeManifest(refreshedManifest, folderURL: folderURL) + } + return refreshedManifest + } + func removeFolder(relativePath: String) throws { let targetURL = folderURL(relativePath: relativePath) guard fileManager.fileExists(atPath: targetURL.path) else { return } @@ -116,20 +198,17 @@ extension DownloadFileStorage { guard manifest.pageCount == manifest.pages.count else { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadedPagesIncomplete) } - if let coverRelativePath = manifest.coverRelativePath, - !coverRelativePath.isEmpty { - guard let coverURL = validatedChildURL(root: folderURL, relativePath: coverRelativePath), - sanitizeAssetFileIfNeeded(at: coverURL) - else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.coverImageMissing) - } + if let coverValidationFailure = validateCover( + folderURL: folderURL, + manifest: manifest + ) { + return coverValidationFailure } - for page in manifest.pages { - guard let pageURL = validatedChildURL(root: folderURL, relativePath: page.relativePath), - sanitizeAssetFileIfNeeded(at: pageURL) - else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.pageMissing(page.index)) - } + if let pageValidationFailure = validatePages( + folderURL: folderURL, + pages: manifest.pages + ) { + return pageValidationFailure } return .valid } @@ -146,4 +225,102 @@ extension DownloadFileStorage { func isReadableAssetFile(at url: URL) -> Bool { sanitizeAssetFileIfNeeded(at: url) } + + private func hashReadableAsset( + folderURL: URL, + relativePath: String, + missingMessage: String + ) throws -> String { + guard let fileURL = validatedChildURL(root: folderURL, relativePath: relativePath), + sanitizeAssetFileIfNeeded(at: fileURL) + else { + throw AppError.fileOperationFailed(missingMessage) + } + return try fileHash(at: fileURL) + } + + private func validateCover( + folderURL: URL, + manifest: DownloadManifest + ) -> DownloadValidationState? { + guard let coverRelativePath = manifest.coverRelativePath, + coverRelativePath.notEmpty + else { return nil } + + guard let coverURL = validatedChildURL(root: folderURL, relativePath: coverRelativePath), + sanitizeAssetFileIfNeeded(at: coverURL) + else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.coverImageMissing) + } + + if let expectedHash = manifest.coverFileHash, + (try? fileHash(at: coverURL)) != expectedHash { + return .missingFiles( + L10n.Localizable.DownloadFileStorage.Validation.coverImageCorrupted + ) + } + + return nil + } + + private func validatePages( + folderURL: URL, + pages: [DownloadManifest.Page] + ) -> DownloadValidationState? { + for page in pages { + if let validationFailure = validatePage(folderURL: folderURL, page: page) { + return validationFailure + } + } + return nil + } + + private func validatePage( + folderURL: URL, + page: DownloadManifest.Page + ) -> DownloadValidationState? { + guard let pageURL = validatedChildURL(root: folderURL, relativePath: page.relativePath), + sanitizeAssetFileIfNeeded(at: pageURL) + else { + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.pageMissing(page.index)) + } + + if let expectedHash = page.fileHash, + (try? fileHash(at: pageURL)) != expectedHash { + return .missingFiles( + L10n.Localizable.DownloadFileStorage.Validation.pageImageCorrupted(page.index) + ) + } + + return nil + } +} + +private extension DownloadManifest { + func replacing( + coverFileHash: String?, + pages: [Page] + ) -> DownloadManifest { + DownloadManifest( + gid: gid, + host: host, + token: token, + title: title, + jpnTitle: jpnTitle, + category: category, + language: language, + uploader: uploader, + tags: tags, + postedDate: postedDate, + pageCount: pageCount, + coverRelativePath: coverRelativePath, + coverFileHash: coverFileHash, + galleryURL: galleryURL, + rating: rating, + downloadOptions: downloadOptions, + versionSignature: versionSignature, + downloadedAt: downloadedAt, + pages: pages + ) + } } diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index b8be0557a..2be70e9e4 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -4,6 +4,7 @@ // import Foundation +import CryptoKit import Synchronization enum DownloadValidationState: Equatable { @@ -238,6 +239,22 @@ struct DownloadFileStorage: Sendable { return try JSONDecoder().decode(DownloadManifest.self, from: data) } + func fileHash(at url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + + var hasher = SHA256() + while true { + let data = try handle.read(upToCount: 1024 * 1024) + guard let data, !data.isEmpty else { break } + hasher.update(data: data) + } + + let digest = hasher.finalize() + let hex = digest.map { String(format: "%02x", $0) }.joined() + return "sha256:\(hex)" + } + @discardableResult func sanitizeAssetFileIfNeeded(at url: URL) -> Bool { guard fileManager.fileExists(atPath: url.path) else { return false } diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 0956f74fb..91edb55f1 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -969,6 +969,7 @@ "downloads_view.empty_state.downloads" = "Heruntergeladene Galerien werden hier angezeigt."; "downloads_view.empty_state.no_matching_filters" = "Keine Downloads entsprechen den aktuellen Filtern."; "downloads_view.button.clear_filters" = "Filter löschen"; +"downloads_view.button.validate_image_data" = "Bilddaten validieren"; "downloads_view.inspector.section.actions" = "Aktionen"; "downloads_view.inspector.section.pages" = "Seiten"; "downloads_view.inspector.button.retry_failed_pages" = "Fehlgeschlagene Seiten erneut versuchen (%d)"; @@ -977,6 +978,10 @@ "downloads_view.inspector.page.pending" = "Ausstehend"; "downloads_view.inspector.page.tap_to_retry" = "Tippen, um diese Seite erneut zu versuchen"; "downloads_view.inspector.page.title" = "Seite %d"; +"downloads_view.inspector.page.none" = "Keine Seiten"; +"downloads_view.inspector.status.pending" = "Ausstehend"; +"downloads_view.inspector.status.downloaded" = "Heruntergeladen"; +"downloads_view.inspector.status.failed" = "Fehlgeschlagen"; "download_setting_view.section.title.download_queue" = "Download-Warteschlange"; "download_setting_view.section.title.network" = "Netzwerk"; "download_setting_view.title.concurrent_image_downloads" = "Gleichzeitige Bilddownloads"; @@ -1013,3 +1018,5 @@ "download_file_storage.validation.downloaded_pages_incomplete" = "Heruntergeladene Seiten sind unvollständig."; "download_file_storage.validation.cover_image_missing" = "Coverbild fehlt."; "download_file_storage.validation.page_missing" = "Seite %d fehlt."; +"download_file_storage.validation.cover_image_corrupted" = "Coverbilddaten sind beschädigt."; +"download_file_storage.validation.page_image_corrupted" = "Bilddaten von Seite %d sind beschädigt."; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 73a039f0f..7652578f7 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -399,6 +399,7 @@ "downloads_view.empty_state.downloads" = "Downloaded galleries will appear here."; "downloads_view.empty_state.no_matching_filters" = "No downloads match the current filters."; "downloads_view.button.clear_filters" = "Clear Filters"; +"downloads_view.button.validate_image_data" = "Validate Image Data"; "downloads_view.inspector.section.actions" = "Actions"; "downloads_view.inspector.section.pages" = "Pages"; "downloads_view.inspector.button.retry_failed_pages" = "Retry Failed Pages (%d)"; @@ -407,6 +408,10 @@ "downloads_view.inspector.page.pending" = "Pending"; "downloads_view.inspector.page.tap_to_retry" = "Tap to retry this page"; "downloads_view.inspector.page.title" = "Page %d"; +"downloads_view.inspector.page.none" = "No pages"; +"downloads_view.inspector.status.pending" = "Pending"; +"downloads_view.inspector.status.downloaded" = "Downloaded"; +"downloads_view.inspector.status.failed" = "Failed"; // MARK: DownloadSettingView "download_setting_view.section.title.download_queue" = "Download Queue"; @@ -478,6 +483,8 @@ "download_file_storage.validation.downloaded_pages_incomplete" = "Downloaded pages are incomplete."; "download_file_storage.validation.cover_image_missing" = "Cover image is missing."; "download_file_storage.validation.page_missing" = "Page %d is missing."; +"download_file_storage.validation.cover_image_corrupted" = "Cover image data is corrupted."; +"download_file_storage.validation.page_image_corrupted" = "Page %d image data is corrupted."; // MARK: FiltersView "filters_view.title.filters" = "Filters"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index f1ad08ffe..0bf43ab07 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -969,6 +969,7 @@ "downloads_view.empty_state.downloads" = "ダウンロードしたギャラリーはここに表示されます。"; "downloads_view.empty_state.no_matching_filters" = "現在のフィルターに一致するダウンロードはありません。"; "downloads_view.button.clear_filters" = "フィルターをクリア"; +"downloads_view.button.validate_image_data" = "画像データを検証"; "downloads_view.inspector.section.actions" = "操作"; "downloads_view.inspector.section.pages" = "ページ"; "downloads_view.inspector.button.retry_failed_pages" = "失敗したページを再試行 (%d)"; @@ -977,6 +978,10 @@ "downloads_view.inspector.page.pending" = "待機中"; "downloads_view.inspector.page.tap_to_retry" = "タップしてこのページを再試行"; "downloads_view.inspector.page.title" = "ページ %d"; +"downloads_view.inspector.page.none" = "ページなし"; +"downloads_view.inspector.status.pending" = "待機中"; +"downloads_view.inspector.status.downloaded" = "ダウンロード済み"; +"downloads_view.inspector.status.failed" = "失敗"; "download_setting_view.section.title.download_queue" = "ダウンロードキュー"; "download_setting_view.section.title.network" = "ネットワーク"; "download_setting_view.title.concurrent_image_downloads" = "同時画像ダウンロード数"; @@ -1013,3 +1018,5 @@ "download_file_storage.validation.downloaded_pages_incomplete" = "ダウンロード済みページが不完全です。"; "download_file_storage.validation.cover_image_missing" = "表紙画像が見つかりません。"; "download_file_storage.validation.page_missing" = "ページ %d が見つかりません。"; +"download_file_storage.validation.cover_image_corrupted" = "表紙画像データが破損しています。"; +"download_file_storage.validation.page_image_corrupted" = "ページ %d の画像データが破損しています。"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 503cb0f73..c0fad452c 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -966,17 +966,22 @@ "downloads_view.swipe.button.update" = "업데이트"; "downloads_view.swipe.button.resume" = "재개"; "downloads_view.swipe.button.pause" = "일시 정지"; -"downloads_view.empty_state.downloads" = "다운로드한 갤러리가 여기에 표시됩니다."; -"downloads_view.empty_state.no_matching_filters" = "현재 필터와 일치하는 다운로드가 없습니다."; -"downloads_view.button.clear_filters" = "필터 지우기"; -"downloads_view.inspector.section.actions" = "동작"; -"downloads_view.inspector.section.pages" = "페이지"; -"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도 (%d)"; -"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; -"downloads_view.inspector.title.download_status" = "다운로드 상태"; -"downloads_view.inspector.page.pending" = "대기 중"; -"downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; -"downloads_view.inspector.page.title" = "페이지 %d"; +"downloads_view.empty_state.downloads" = "다운로드한 갤러리가 여기에 표시됩니다."; +"downloads_view.empty_state.no_matching_filters" = "현재 필터와 일치하는 다운로드가 없습니다."; +"downloads_view.button.clear_filters" = "필터 지우기"; +"downloads_view.button.validate_image_data" = "이미지 데이터 검증"; +"downloads_view.inspector.section.actions" = "동작"; +"downloads_view.inspector.section.pages" = "페이지"; +"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도 (%d)"; +"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; +"downloads_view.inspector.title.download_status" = "다운로드 상태"; +"downloads_view.inspector.page.pending" = "대기 중"; +"downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; +"downloads_view.inspector.page.title" = "페이지 %d"; +"downloads_view.inspector.page.none" = "페이지 없음"; +"downloads_view.inspector.status.pending" = "대기 중"; +"downloads_view.inspector.status.downloaded" = "다운로드됨"; +"downloads_view.inspector.status.failed" = "실패"; "download_setting_view.section.title.download_queue" = "다운로드 대기열"; "download_setting_view.section.title.network" = "네트워크"; "download_setting_view.title.concurrent_image_downloads" = "동시 이미지 다운로드 수"; @@ -1010,6 +1015,8 @@ "download_file_storage.validation.download_folder_missing" = "다운로드 폴더가 없습니다."; "download_file_storage.validation.manifest_missing" = "매니페스트 파일이 없습니다."; "download_file_storage.validation.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; -"download_file_storage.validation.downloaded_pages_incomplete" = "다운로드한 페이지가 불완전합니다."; -"download_file_storage.validation.cover_image_missing" = "표지 이미지가 없습니다."; -"download_file_storage.validation.page_missing" = "페이지 %d가 없습니다."; +"download_file_storage.validation.downloaded_pages_incomplete" = "다운로드한 페이지가 불완전합니다."; +"download_file_storage.validation.cover_image_missing" = "표지 이미지가 없습니다."; +"download_file_storage.validation.page_missing" = "페이지 %d가 없습니다."; +"download_file_storage.validation.cover_image_corrupted" = "표지 이미지 데이터가 손상되었습니다."; +"download_file_storage.validation.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 0592c6d52..babb73797 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -399,6 +399,7 @@ "downloads_view.empty_state.downloads" = "已下载的画廊会显示在这里。"; "downloads_view.empty_state.no_matching_filters" = "没有下载项符合当前筛选条件。"; "downloads_view.button.clear_filters" = "清除筛选"; +"downloads_view.button.validate_image_data" = "验证图片数据"; "downloads_view.inspector.section.actions" = "操作"; "downloads_view.inspector.section.pages" = "页面"; "downloads_view.inspector.button.retry_failed_pages" = "重试失败页面(%d)"; @@ -407,6 +408,10 @@ "downloads_view.inspector.page.pending" = "等待中"; "downloads_view.inspector.page.tap_to_retry" = "点按以重试此页"; "downloads_view.inspector.page.title" = "第 %d 页"; +"downloads_view.inspector.page.none" = "无页面"; +"downloads_view.inspector.status.pending" = "等待中"; +"downloads_view.inspector.status.downloaded" = "已下载"; +"downloads_view.inspector.status.failed" = "失败"; // MARK: DownloadSettingView "download_setting_view.section.title.download_queue" = "下载队列"; @@ -478,6 +483,8 @@ "download_file_storage.validation.downloaded_pages_incomplete" = "下载页面不完整。"; "download_file_storage.validation.cover_image_missing" = "封面图片缺失。"; "download_file_storage.validation.page_missing" = "第 %d 页缺失。"; +"download_file_storage.validation.cover_image_corrupted" = "封面图片数据已损坏。"; +"download_file_storage.validation.page_image_corrupted" = "第 %d 页图片数据已损坏。"; // MARK: FiltersView "filters_view.title.filters" = "筛选"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index e50bc7173..211377f77 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -966,6 +966,7 @@ "downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; "downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; "downloads_view.button.clear_filters" = "清除篩選"; +"downloads_view.button.validate_image_data" = "驗證圖片資料"; "downloads_view.inspector.section.actions" = "操作"; "downloads_view.inspector.section.pages" = "頁面"; "downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面(%d)"; @@ -974,6 +975,10 @@ "downloads_view.inspector.page.pending" = "等待中"; "downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; "downloads_view.inspector.page.title" = "第 %d 頁"; +"downloads_view.inspector.page.none" = "沒有頁面"; +"downloads_view.inspector.status.pending" = "等待中"; +"downloads_view.inspector.status.downloaded" = "已下載"; +"downloads_view.inspector.status.failed" = "失敗"; "download_setting_view.section.title.download_queue" = "下載佇列"; "download_setting_view.section.title.network" = "網絡"; "download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; @@ -1010,3 +1015,5 @@ "download_file_storage.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; "download_file_storage.validation.cover_image_missing" = "封面圖片缺失。"; "download_file_storage.validation.page_missing" = "第 %d 頁缺失。"; +"download_file_storage.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; +"download_file_storage.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index d5fa84ce0..4f5d3cc69 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -967,6 +967,7 @@ "downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; "downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; "downloads_view.button.clear_filters" = "清除篩選"; +"downloads_view.button.validate_image_data" = "驗證圖片資料"; "downloads_view.inspector.section.actions" = "操作"; "downloads_view.inspector.section.pages" = "頁面"; "downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面(%d)"; @@ -975,6 +976,10 @@ "downloads_view.inspector.page.pending" = "等待中"; "downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; "downloads_view.inspector.page.title" = "第 %d 頁"; +"downloads_view.inspector.page.none" = "沒有頁面"; +"downloads_view.inspector.status.pending" = "等待中"; +"downloads_view.inspector.status.downloaded" = "已下載"; +"downloads_view.inspector.status.failed" = "失敗"; "download_setting_view.section.title.download_queue" = "下載佇列"; "download_setting_view.section.title.network" = "網路"; "download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; @@ -1011,3 +1016,5 @@ "download_file_storage.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; "download_file_storage.validation.cover_image_missing" = "封面圖片缺失。"; "download_file_storage.validation.page_missing" = "第 %d 頁缺失。"; +"download_file_storage.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; +"download_file_storage.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index c75a43a29..9207cb6b5 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -967,6 +967,7 @@ "downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; "downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; "downloads_view.button.clear_filters" = "清除篩選"; +"downloads_view.button.validate_image_data" = "驗證圖片資料"; "downloads_view.inspector.section.actions" = "操作"; "downloads_view.inspector.section.pages" = "頁面"; "downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面(%d)"; @@ -975,6 +976,10 @@ "downloads_view.inspector.page.pending" = "等待中"; "downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; "downloads_view.inspector.page.title" = "第 %d 頁"; +"downloads_view.inspector.page.none" = "沒有頁面"; +"downloads_view.inspector.status.pending" = "等待中"; +"downloads_view.inspector.status.downloaded" = "已下載"; +"downloads_view.inspector.status.failed" = "失敗"; "download_setting_view.section.title.download_queue" = "下載佇列"; "download_setting_view.section.title.network" = "網絡"; "download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; @@ -1011,3 +1016,5 @@ "download_file_storage.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; "download_file_storage.validation.cover_image_missing" = "封面圖片缺失。"; "download_file_storage.validation.page_missing" = "第 %d 頁缺失。"; +"download_file_storage.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; +"download_file_storage.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift index b12ddb9ff..167581ddf 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -33,6 +33,41 @@ extension DownloadBadge { } } + var labelContent: DownloadBadgeLabelContent { + switch self { + case .none: + return .text("") + case .queued: + return .text(L10n.Localizable.Struct.DownloadBadge.Text.queued) + case .downloading(let completed, let total): + return .progress( + L10n.Localizable.Struct.DownloadBadge.Compact.downloading, + completed: completed, + total: total + ) + case .paused(let completed, let total): + return .progress( + L10n.Localizable.Struct.DownloadBadge.Compact.paused, + completed: completed, + total: total + ) + case .partial(let completed, let total): + return .progress( + L10n.Localizable.Struct.DownloadBadge.Text.needsAttention, + completed: completed, + total: total + ) + case .downloaded: + return .text(L10n.Localizable.Struct.DownloadBadge.Text.downloaded) + case .failed: + return .text(L10n.Localizable.Struct.DownloadBadge.Text.needsAttention) + case .updateAvailable: + return .text(L10n.Localizable.Struct.DownloadBadge.Text.updateAvailable) + case .missingFiles: + return .text(L10n.Localizable.Struct.DownloadBadge.Text.needsRepair) + } + } + var color: Color { switch self { case .none: @@ -57,6 +92,24 @@ extension DownloadBadge { } } +struct DownloadBadgeLabelContent: Equatable { + let text: String + let numbers: String? + + static func text(_ text: String) -> Self { + .init(text: text, numbers: nil) + } + + static func progress(_ text: String, completed: Int, total: Int) -> Self { + .init( + text: text, + numbers: [max(completed, 0), max(total, 1)] + .map({ $0.formatted(.number) }) + .joined(separator: "/") + ) + } +} + // MARK: - DownloadListFilter enum DownloadListFilter: String, CaseIterable, Identifiable { case all diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift new file mode 100644 index 000000000..006934b72 --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -0,0 +1,94 @@ +// +// DownloadedGallery+Manifest.swift +// EhPanda +// + +import Foundation + +struct DownloadManifest: Codable, Equatable { + struct Page: Codable, Equatable, Identifiable { + var id: Int { index } + + let index: Int + let relativePath: String + let fileHash: String? + + init( + index: Int, + relativePath: String, + fileHash: String? = nil + ) { + self.index = index + self.relativePath = relativePath + self.fileHash = fileHash + } + } + + let gid: String + let host: GalleryHost + let token: String + let title: String + let jpnTitle: String? + let category: Category + let language: Language + let uploader: String? + let tags: [GalleryTag] + let postedDate: Date + let pageCount: Int + let coverRelativePath: String? + let coverFileHash: String? + let galleryURL: URL + let rating: Float + let downloadOptions: DownloadOptionsSnapshot + let versionSignature: String + let downloadedAt: Date + let pages: [Page] + + init( + gid: String, + host: GalleryHost, + token: String, + title: String, + jpnTitle: String?, + category: Category, + language: Language, + uploader: String?, + tags: [GalleryTag], + postedDate: Date, + pageCount: Int, + coverRelativePath: String?, + coverFileHash: String? = nil, + galleryURL: URL, + rating: Float, + downloadOptions: DownloadOptionsSnapshot, + versionSignature: String, + downloadedAt: Date, + pages: [Page] + ) { + self.gid = gid + self.host = host + self.token = token + self.title = title + self.jpnTitle = jpnTitle + self.category = category + self.language = language + self.uploader = uploader + self.tags = tags + self.postedDate = postedDate + self.pageCount = pageCount + self.coverRelativePath = coverRelativePath + self.coverFileHash = coverFileHash + self.galleryURL = galleryURL + self.rating = rating + self.downloadOptions = downloadOptions + self.versionSignature = versionSignature + self.downloadedAt = downloadedAt + self.pages = pages + } + + func imageURLs(folderURL: URL) -> [Int: URL] { + Dictionary(uniqueKeysWithValues: pages.map { + ($0.index, folderURL.appendingPathComponent($0.relativePath)) + }) + } +} diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index a6a838987..dbbe1ae17 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -201,40 +201,6 @@ enum DownloadStartMode: String, Codable, Equatable, Sendable { case repair } -struct DownloadManifest: Codable, Equatable { - struct Page: Codable, Equatable, Identifiable { - var id: Int { index } - - let index: Int - let relativePath: String - } - - let gid: String - let host: GalleryHost - let token: String - let title: String - let jpnTitle: String? - let category: Category - let language: Language - let uploader: String? - let tags: [GalleryTag] - let postedDate: Date - let pageCount: Int - let coverRelativePath: String? - let galleryURL: URL - let rating: Float - let downloadOptions: DownloadOptionsSnapshot - let versionSignature: String - let downloadedAt: Date - let pages: [Page] - - func imageURLs(folderURL: URL) -> [Int: URL] { - Dictionary(uniqueKeysWithValues: pages.map { - ($0.index, folderURL.appendingPathComponent($0.relativePath)) - }) - } -} - struct DownloadFailedPagesSnapshot: Codable, Equatable, Sendable { struct Page: Codable, Equatable, Identifiable, Sendable { var id: Int { index } @@ -251,7 +217,7 @@ struct DownloadFailedPagesSnapshot: Codable, Equatable, Sendable { } } -enum DownloadPageStatus: String, Equatable, Sendable { +enum DownloadPageStatus: String, Equatable, CaseIterable, Sendable { case pending case downloaded case failed diff --git a/EhPanda/View/Detail/DetailView+HeaderSection.swift b/EhPanda/View/Detail/DetailView+HeaderSection.swift index e06c9fe8e..c043d840e 100644 --- a/EhPanda/View/Detail/DetailView+HeaderSection.swift +++ b/EhPanda/View/Detail/DetailView+HeaderSection.swift @@ -58,16 +58,24 @@ struct HeaderSection: View { Group { if let progress = activeDownloadProgress { Button(action: downloadAction) { - progressIndicator(progress: progress, isDeterminate: true, - centerSystemName: activeDownloadIconSystemName) + progressIndicator( + progress: progress, + isDeterminate: true, + centerSystemName: activeDownloadIconSystemName + ) } - .buttonStyle(.plain) + .buttonStyle(.glass(.regular.interactive())) + .buttonBorderShape(.circle) } else if let progress = queuedDownloadProgress { Button(action: downloadAction) { - progressIndicator(progress: progress, isDeterminate: false, - centerSystemName: activeDownloadIconSystemName) + progressIndicator( + progress: progress, + isDeterminate: false, + centerSystemName: activeDownloadIconSystemName + ) } - .buttonStyle(.plain) + .buttonStyle(.glass(.regular.interactive())) + .buttonBorderShape(.circle) } else { Button(action: downloadAction) { Image(systemName: downloadIconSystemName) @@ -129,9 +137,6 @@ struct HeaderSection: View { progress: Double, isDeterminate: Bool, centerSystemName: String ) -> some View { ZStack { - Circle() - .fill(.ultraThinMaterial) - .overlay(Circle().strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.75)) if isDeterminate { Circle().stroke(downloadButtonTint.opacity(0.18), lineWidth: 2.5).padding(3) Circle() @@ -155,11 +160,13 @@ struct HeaderSection: View { ViewThatFits(in: .horizontal) { HStack(spacing: 6) { downloadButton; favoriteButton; readButton } .fixedSize(horizontal: true, vertical: false) + VStack(alignment: .trailing, spacing: 6) { HStack(spacing: 6) { downloadButton; favoriteButton } readButton } .fixedSize(horizontal: true, vertical: false) + VStack(alignment: .trailing, spacing: 6) { downloadButton; favoriteButton; readButton } .fixedSize(horizontal: true, vertical: false) } @@ -168,7 +175,13 @@ struct HeaderSection: View { private var bottomActionRow: some View { ViewThatFits(in: .horizontal) { HStack(spacing: 8) { categoryLabel; Spacer(minLength: 8); actionButtons } - VStack(alignment: .leading, spacing: 8) { categoryLabel; actionButtons } + + VStack(alignment: .leading, spacing: 8) { + categoryLabel + + actionButtons + .frame(maxWidth: .infinity, alignment: .trailing) + } } } private var queuedDownloadProgress: Double? { diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index 9aa7f0887..1cb136de0 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -14,6 +14,7 @@ struct DownloadsReducer { case filters(EquatableVoid = .init()) case inspector(String) case detail(String) + case reading(String) } private enum CancelID { @@ -31,8 +32,10 @@ struct DownloadsReducer { var hasLoadedInitialDownloads = false var detailState: Heap + var readingState = ReadingReducer.State() var inspectorState = DownloadInspectorReducer.State() var quickSearchState = QuickSearchReducer.State() + var readingRequestID = UUID() init() { detailState = .init(.init()) @@ -64,6 +67,10 @@ struct DownloadsReducer { case observeDownloadsDone([DownloadedGallery]) case refreshDownloads case refreshDownloadsDone + case validateImageData + case validateImageDataDone + case openReading(String) + case openReadingDone(UUID, String, Result<(DownloadedGallery, DownloadManifest), AppError>) case toggleDownloadPause(String) case toggleDownloadPauseDone(Result) case updateDownload(String) @@ -72,6 +79,7 @@ struct DownloadsReducer { case deleteDownloadDone(Result) case detail(DetailReducer.Action) + case reading(ReadingReducer.Action) case inspector(DownloadInspectorReducer.Action) case quickSearch(QuickSearchReducer.Action) } @@ -105,10 +113,12 @@ struct DownloadsReducer { case .clearSubStates: state.detailState.wrappedValue = .init() + state.readingState = .init() state.inspectorState = .init() state.quickSearchState = .init() return .merge( .send(.detail(.teardown)), + .send(.reading(.teardown)), .send(.inspector(.teardown)), .send(.quickSearch(.teardown)) ) @@ -162,6 +172,40 @@ struct DownloadsReducer { case .refreshDownloadsDone: return .none + case .validateImageData: + return .run { send in + await downloadClient.validateImageData() + await send(.validateImageDataDone) + } + + case .validateImageDataDone: + return .none + + case .openReading(let gid): + let requestID = UUID() + state.readingRequestID = requestID + state.readingState = .init(contentSource: .remote) + if let download = state.downloads.first(where: { $0.gid == gid }) { + state.readingState.applyDownloadFallback(download) + } + return .run { send in + await send( + .openReadingDone( + requestID, + gid, + await downloadClient.loadManifest(gid) + ) + ) + } + + case .openReadingDone(let requestID, let gid, let result): + guard state.readingRequestID == requestID else { return .none } + if case .success(let (download, manifest)) = result { + state.readingState = .init(contentSource: .local(download, manifest)) + } + state.route = .reading(gid) + return .none + case .toggleDownloadPause(let gid): return .run { send in await send(.toggleDownloadPauseDone(await downloadClient.togglePause(gid))) @@ -194,6 +238,12 @@ struct DownloadsReducer { case .detail: return .none + case .reading(.onPerformDismiss): + return .send(.setNavigation(nil)) + + case .reading: + return .none + case .inspector: return .none @@ -205,9 +255,38 @@ struct DownloadsReducer { Scope(state: \.detailState.wrappedValue!, action: \.detail) { DetailReducer() } + Scope(state: \.readingState, action: \.reading) { + ReadingReducer() + } Scope(state: \.inspectorState, action: \.inspector) { DownloadInspectorReducer() } Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) } } + +private extension ReadingReducer.State { + mutating func applyDownloadFallback(_ download: DownloadedGallery) { + gallery = download.gallery + galleryDetail = GalleryDetail( + gid: download.gid, + title: download.title, + jpnTitle: download.jpnTitle, + isFavorited: false, + visibility: .yes, + rating: download.rating, + userRating: 0, + ratingCount: 0, + category: download.category, + language: .other, + uploader: download.uploader ?? "", + postedDate: download.postedDate, + coverURL: download.coverURL, + favoritedCount: 0, + pageCount: download.pageCount, + sizeCount: 0, + sizeType: "", + torrentCount: 0 + ) + } +} diff --git a/EhPanda/View/Downloads/DownloadsView+Subviews.swift b/EhPanda/View/Downloads/DownloadsView+Subviews.swift index b8d5179cc..06e01bcb4 100644 --- a/EhPanda/View/Downloads/DownloadsView+Subviews.swift +++ b/EhPanda/View/Downloads/DownloadsView+Subviews.swift @@ -84,11 +84,13 @@ struct DownloadInspectorView: View { } } - Section(L10n.Localizable.DownloadsView.Inspector.Section.pages) { - ForEach(inspection.pages) { page in - DownloadInspectorPageRow(page: page) { - store.send(.retryPage(page.index)) - } + ForEach(DownloadPageStatus.allCases, id: \.self) { status in + let pages = inspection.pages.filter { $0.status == status } + Section(status.sectionTitle(count: pages.count)) { + DownloadInspectorPageGroupRow( + status: status, + pages: pages + ) } } } @@ -112,6 +114,101 @@ struct DownloadInspectorView: View { } } +struct DownloadInspectorPageGroupRow: View { + let status: DownloadPageStatus + let pages: [DownloadPageInspection] + + private var pageNumbersText: String { + let indices = pages.map(\.index).sorted() + guard !indices.isEmpty else { + return L10n.Localizable.DownloadsView.Inspector.Page.none + } + return Self.formattedPageRanges(indices) + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: status.symbolName) + .foregroundStyle(status.tint) + .font(.title3) + .frame(width: 24) + + Text(pageNumbersText) + .font(.callout) + .foregroundStyle(pages.isEmpty ? .secondary : .primary) + .lineLimit(nil) + .textSelection(.enabled) + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + } + + private static func formattedPageRanges(_ indices: [Int]) -> String { + var ranges = [String]() + var rangeStart: Int? + var previous: Int? + + func appendCurrentRange() { + guard let start = rangeStart, + let end = previous + else { return } + ranges.append(start == end ? "\(start)" : "\(start)-\(end)") + } + + for index in indices { + if let last = previous, index == last + 1 { + previous = index + continue + } + appendCurrentRange() + rangeStart = index + previous = index + } + appendCurrentRange() + + return ranges.joined(separator: ", ") + } +} + +private extension DownloadPageStatus { + var title: String { + switch self { + case .pending: + return L10n.Localizable.DownloadsView.Inspector.Status.pending + case .downloaded: + return L10n.Localizable.DownloadsView.Inspector.Status.downloaded + case .failed: + return L10n.Localizable.DownloadsView.Inspector.Status.failed + } + } + + func sectionTitle(count: Int) -> String { + "\(title) (\(count))" + } + + var symbolName: String { + switch self { + case .pending: + return "clock" + case .downloaded: + return "checkmark.circle.fill" + case .failed: + return "exclamationmark.circle.fill" + } + } + + var tint: Color { + switch self { + case .pending: + return .secondary + case .downloaded: + return .green + case .failed: + return .red + } + } +} + struct DownloadListRow: View { let download: DownloadedGallery let setting: Setting diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift index 6e9e59687..a3dc20209 100644 --- a/EhPanda/View/Downloads/DownloadsView.swift +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -120,6 +120,16 @@ struct DownloadsView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } + .fullScreenCover(item: $store.route.sending(\.setNavigation).reading, id: \.self) { route in + ReadingView( + store: store.scope(state: \.readingState, action: \.reading), + gid: route.wrappedValue, + setting: $setting, + blurRadius: blurRadius + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } .onAppear { store.send(.onAppear) } @@ -177,7 +187,10 @@ private extension DownloadsView { setting: setting, tagTranslator: tagTranslator ) { - store.send(.setNavigation(.detail(download.gid))) + store.send(.openReading(download.gid)) + } + .contextMenu { + downloadContextMenu(download) } .swipeActions(edge: .leading, allowsFullSwipe: false) { Button { @@ -227,11 +240,62 @@ private extension DownloadsView { } } } - .listStyle(.plain) .refreshable { store.send(.refreshDownloads) } } } + @ViewBuilder private func downloadContextMenu(_ download: DownloadedGallery) -> some View { + Button { + store.send(.setNavigation(.detail(download.gid))) + } label: { + Label( + L10n.Localizable.DetailView.ContextMenu.Button.detail, + systemImage: "info.circle" + ) + } + + Button { + store.send(.setNavigation(.inspector(download.gid))) + } label: { + Label( + L10n.Localizable.DownloadsView.Swipe.Button.pages, + systemImage: "list.bullet.rectangle.portrait" + ) + } + + if download.canTriggerUpdate { + Button { + store.send(.updateDownload(download.gid)) + } label: { + Label( + L10n.Localizable.DownloadsView.Swipe.Button.update, + systemImage: "arrow.triangle.2.circlepath" + ) + } + } + + if download.canPauseOrResume || download.isPendingQueue { + Button { + store.send(.toggleDownloadPause(download.gid)) + } label: { + Label( + download.status == .paused + ? L10n.Localizable.DownloadsView.Swipe.Button.resume + : L10n.Localizable.DownloadsView.Swipe.Button.pause, + systemImage: download.status == .paused + ? "play.fill" + : "pause.fill" + ) + } + } + + Button(role: .destructive) { + rowDialog = .delete(download) + } label: { + Label(L10n.Localizable.ConfirmationDialog.Button.delete, systemSymbol: .trash) + } + } + @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { NavigationLink(unwrapping: $store.route, case: \.detail) { route in @@ -294,6 +358,14 @@ private extension DownloadsView { QuickSearchButton { store.send(.setNavigation(.quickSearch())) } + Button { + store.send(.validateImageData) + } label: { + Label( + L10n.Localizable.DownloadsView.Button.validateImageData, + systemImage: "checkmark.shield" + ) + } if store.filter != .all || store.keyword.notEmpty || store.galleryFilter.hasActiveValues { Button { store.filter = .all diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift index cd246088a..c9dd1a3b2 100644 --- a/EhPanda/View/Reading/ReadingReducer+Database.swift +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -106,6 +106,10 @@ extension ReadingReducer { state.forceRefreshID = .init() } state.localPageURLs = localPageURLs + localPageURLs.keys.forEach { + state.imageURLLoadingStates[$0] = .idle + state.previewLoadingStates[$0] = .idle + } return .none } diff --git a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift index 4dbd23c97..fb58ea7f4 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift @@ -120,8 +120,17 @@ private struct GalleryDetailCellContent: View { .font(.headline) .foregroundStyle(.primary) .fixedSize(horizontal: false, vertical: true) - DownloadBadgeLabel(badge: downloadBadge) - Text(gallery.uploader ?? "").lineLimit(1).font(.subheadline).foregroundStyle(.secondary) + + HStack { + Text(gallery.uploader ?? "") + .lineLimit(1) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + DownloadBadgeLabel(badge: downloadBadge) + } + let tagContents = gallery.tagContents(maximum: setting.listTagsNumberMaximum) if setting.showsTagsInList, !tagContents.isEmpty { TagCloudView(data: tagContents) { content in diff --git a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift index 06914e2fa..8bbb36303 100644 --- a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift +++ b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift @@ -7,23 +7,45 @@ import SwiftUI struct DownloadBadgeLabel: View { private let badge: DownloadBadge - private let compact: Bool + private let isCompactStyle: Bool + + init?(badge: DownloadBadge, compact: Bool = false) { + guard badge != .none else { return nil } - init(badge: DownloadBadge, compact: Bool = false) { self.badge = badge - self.compact = compact + self.isCompactStyle = compact } var body: some View { - if badge != .none { - Text(compact ? compactText : badge.text) - .font(compact ? .caption2.bold() : .caption.bold()) - .foregroundStyle(foregroundColor) - .padding(.horizontal, compact ? 6 : 8) - .padding(.vertical, compact ? 3 : 4) - .background(backgroundColor) - .clipShape(Capsule()) + labelText + .foregroundStyle(foregroundColor) + .padding(.horizontal, isCompactStyle ? 6 : 8) + .padding(.vertical, isCompactStyle ? 3 : 4) + .background(backgroundColor) + .clipShape(.capsule) + } + + private var labelText: Text { + if isCompactStyle { + Text(compactText) + .font(.caption2.bold()) + } else { + Text(attributedText) + } + } + + private var attributedText: AttributedString { + let baseFont = Font.caption.bold() + let label = badge.labelContent + let separator = " " + var text = AttributedString(label.text) + text.font = baseFont + if let numbers = label.numbers { + var numberText = AttributedString([separator, numbers].joined()) + numberText.font = baseFont.monospacedDigit() + text += numberText } + return text } private var compactText: String { diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift new file mode 100644 index 000000000..df11fbd93 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -0,0 +1,142 @@ +// +// DownloadFileStorageHashTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +struct DownloadFileStorageHashTests { + @Test + func testValidateReportsCorruptedPageImageData() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (download, folderURL) = try makePreparedDownload(storage: storage) + let pageTwoURL = folderURL.appendingPathComponent("pages/0002.jpg") + + let manifest = try storage.addingCurrentFileHashes( + to: sampleManifest(pageCount: 2), + folderURL: folderURL + ) + try storage.writeManifest(manifest, folderURL: folderURL) + try Data([0x03]).write(to: pageTwoURL, options: .atomic) + + #expect( + storage.validate(download: download) + == .missingFiles("Page 2 image data is corrupted.") + ) + } + + @Test + func testRefreshManifestPageFileHashUpdatesSinglePageHash() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (download, folderURL) = try makePreparedDownload(storage: storage) + let pageTwoURL = folderURL.appendingPathComponent("pages/0002.jpg") + + let manifest = try storage.addingCurrentFileHashes( + to: sampleManifest(pageCount: 2), + folderURL: folderURL + ) + try storage.writeManifest(manifest, folderURL: folderURL) + try Data([0x03]).write(to: pageTwoURL, options: .atomic) + + let refreshedManifest = try storage.refreshManifestPageFileHash( + folderURL: folderURL, + pageIndex: 2 + ) + + #expect(refreshedManifest.pages[0].fileHash == manifest.pages[0].fileHash) + #expect(refreshedManifest.pages[1].fileHash != manifest.pages[1].fileHash) + #expect(storage.validate(download: download) == .valid) + } + + private func makePreparedDownload( + storage: DownloadFileStorage + ) throws -> (DownloadedGallery, URL) { + try storage.ensureRootDirectory() + let download = sampleDownload(folderRelativePath: "123 - Sample") + let folderURL = storage.folderURL(relativePath: download.folderRelativePath) + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try Data([0xFF, 0xD8, 0xFF]).write( + to: folderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: folderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) + return (download, folderURL) + } + + private func makeStorage() -> (DownloadFileStorage, URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return ( + DownloadFileStorage(rootURL: rootURL, fileManager: .default), + rootURL + ) + } + + private func sampleDownload(folderRelativePath: String) -> DownloadedGallery { + DownloadedGallery( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: nil, + uploader: "Uploader", + category: .doujinshi, + tags: [], + pageCount: 2, + postedDate: .now, + rating: 4, + onlineCoverURL: URL(string: "https://example.com/cover.jpg"), + folderRelativePath: folderRelativePath, + coverRelativePath: "cover.jpg", + status: .completed, + completedPageCount: 2, + lastDownloadedAt: .now, + lastError: nil, + downloadOptionsSnapshot: DownloadOptionsSnapshot(), + remoteVersionSignature: "hash:v1", + latestRemoteVersionSignature: "hash:v1" + ) + } + + private func sampleManifest(pageCount: Int) throws -> DownloadManifest { + DownloadManifest( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: .now, + pageCount: pageCount, + coverRelativePath: "cover.jpg", + galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), + rating: 4, + downloadOptions: DownloadOptionsSnapshot(), + versionSignature: "hash:v1", + downloadedAt: .now, + pages: (1...pageCount).map { + .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") + } + ) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift index 29a731b7f..e112536d9 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -142,6 +142,94 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { #expect(deleted.value == [download.gid]) } + @MainActor + @Test + func testDownloadsReducerOpenReadingLoadsManifestAndRoutesToReader() async throws { + let download = sampleDownload( + gid: "135790", + title: "Readable Gallery", + status: .completed, + pageCount: 2 + ) + let manifest = try sampleManifest( + gid: download.gid, + title: download.title, + pageCount: 2, + versionSignature: "hash:v1" + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { gid in + gid == download.gid ? .success((download, manifest)) : .failure(.notFound) + } + ) + } + store.exhaustivity = .off + + await store.send(.openReading(download.gid)) + await store.receive(\.openReadingDone) + + #expect(store.state.route == .reading(download.gid)) + #expect(store.state.readingState.contentSource == .local(download, manifest)) + } + + @MainActor + @Test + func testDownloadsReducerValidateImageDataUsesDownloadClient() async { + let validated = UncheckedBox(false) + let store = TestStore(initialState: DownloadsReducer.State()) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + validateImageData: { + validated.value = true + }, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + store.exhaustivity = .off + + await store.send(.validateImageData) + await store.receive(\.validateImageDataDone) + + #expect(validated.value) + } + @MainActor @Test func testDownloadsReducerTogglePauseActionUsesDownloadClientPause() async { diff --git a/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift new file mode 100644 index 000000000..4b090d6d7 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift @@ -0,0 +1,32 @@ +// +// DownloadsReducerReadingDismissTests.swift +// EhPandaTests +// + +import ComposableArchitecture +import Testing +@testable import EhPanda + +struct DownloadsReducerReadingDismissTests { + @MainActor + @Test + func readingDismissClearsRoute() async { + let gid = "135790" + var initialState = DownloadsReducer.State() + initialState.route = .reading(gid) + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + } + store.exhaustivity = .off + + await store.send(.reading(.onPerformDismiss)) + await store.receive(\.setNavigation) + + #expect(store.state.route == nil) + } +} diff --git a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift index 4573a3daa..95709dad9 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -56,6 +56,30 @@ struct ReadingReducerDownloadTests: DownloadFeatureTestCase { await store.send(.fetchImageURLs(1)) { $0.imageURLLoadingStates[1] = .idle } } + @MainActor + @Test + func testReadingReducerLocalPageLoadClearsStaleRemoteImageFailure() async throws { + let gallery = sampleGallery() + let localPageURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") + var initialState = ReadingReducer.State(contentSource: .remote) + initialState.gallery = gallery + initialState.imageURLLoadingStates[1] = .failed(.webImageFailed) + initialState.previewLoadingStates[1] = .failed(.webImageFailed) + + let store = makeLocalPageLoadStore( + initialState: initialState, gallery: gallery, localPageURL: localPageURL + ) + + await store.send(.loadLocalPageURLs(gallery.gid)) + let requestID = store.state.localPageRequestID + await store.receive(\.loadLocalPageURLsDone) { + $0.localPageURLs = [1: localPageURL] + $0.imageURLLoadingStates[1] = .idle + $0.previewLoadingStates[1] = .idle + } + #expect(store.state.localPageRequestID == requestID) + } + @MainActor @Test func testReadingReducerOnWebImageSucceededCapturesCachedPageIntoDownloadProgress() async throws { From 0c8550e8d2769c2521c644969b69445fcdc5685a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 26 May 2026 12:59:24 +0800 Subject: [PATCH 027/614] Update folder structure & resolve conflicts --- .github/workflows/deploy.yml | 1 - EhPanda.xcodeproj/project.pbxproj | 18 ++++++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 75d7c1f5e..fe335349c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -92,7 +92,6 @@ jobs: [[ ! -z "$min_os_version" ]] || exit 1 [[ ! -z "$size" ]] || exit 1 [[ ! -z "$sha256" ]] || exit 1 - [[ ! -z "$notes" ]] || exit 1 [[ ! -z "$privacy" ]] || exit 1 { diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index baa390712..e91caf9ea 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -25,9 +25,6 @@ ABC4A0792751B40E00968A4F /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = ABC4A0782751B40E00968A4F /* Kingfisher */; }; ABD49D5D277C6C9D003D1A07 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */; }; ABD7005926B1C31500DC59C9 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = ABD7005826B1C31500DC59C9 /* Kanna */; }; - EA0BBD472E37CCB700DC8143 /* CODEOWNERS in Resources */ = {isa = PBXBuildFile; fileRef = EA0BBD462E37CCB700DC8143 /* CODEOWNERS */; }; - EA0C92452C3EB42300D211F6 /* ISSUE_TEMPLATE in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92432C3EB42300D211F6 /* ISSUE_TEMPLATE */; }; - EA0C92462C3EB42300D211F6 /* workflows in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92442C3EB42300D211F6 /* workflows */; }; EA0C92592C3EB49500D211F6 /* README.cht.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92532C3EB49500D211F6 /* README.cht.md */; }; EA0C925A2C3EB49500D211F6 /* README.ko.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92542C3EB49500D211F6 /* README.ko.md */; }; EA0C925B2C3EB49500D211F6 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92552C3EB49500D211F6 /* README.md */; }; @@ -70,9 +67,6 @@ AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; ABC3C7542593696C00E0C11B /* EhPanda.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EhPanda.app; sourceTree = BUILT_PRODUCTS_DIR; }; ABF294CC26D20F82004DD03A /* EhPandaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EhPandaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - EA0BBD462E37CCB700DC8143 /* CODEOWNERS */ = {isa = PBXFileReference; lastKnownFileType = text; name = CODEOWNERS; path = .github/CODEOWNERS; sourceTree = ""; }; - EA0C92432C3EB42300D211F6 /* ISSUE_TEMPLATE */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ISSUE_TEMPLATE; path = .github/ISSUE_TEMPLATE; sourceTree = ""; }; - EA0C92442C3EB42300D211F6 /* workflows */ = {isa = PBXFileReference; lastKnownFileType = folder; name = workflows; path = .github/workflows; sourceTree = ""; }; EA0C92482C3EB45E00D211F6 /* AltStore.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = AltStore.json; sourceTree = ""; }; EA0C92492C3EB45E00D211F6 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; EA0C924A2C3EB45E00D211F6 /* .gitattributes */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = .gitattributes; sourceTree = ""; }; @@ -105,6 +99,11 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + A62261E62FC559F00055B5C0 /* .github */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = .github; + sourceTree = ""; + }; A6844B3D2F780C8600BBF6E5 /* EhPanda */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -199,9 +198,7 @@ EA0C92422C3EB40100D211F6 /* GitHub */ = { isa = PBXGroup; children = ( - EA0BBD462E37CCB700DC8143 /* CODEOWNERS */, - EA0C92432C3EB42300D211F6 /* ISSUE_TEMPLATE */, - EA0C92442C3EB42300D211F6 /* workflows */, + A62261E62FC559F00055B5C0 /* .github */, ); name = GitHub; sourceTree = ""; @@ -400,10 +397,7 @@ EA0C92592C3EB49500D211F6 /* README.cht.md in Resources */, EA0C925D2C3EB49500D211F6 /* README.chs.md in Resources */, EA0C925C2C3EB49500D211F6 /* README.de.md in Resources */, - EA0C92452C3EB42300D211F6 /* ISSUE_TEMPLATE in Resources */, EA0C925B2C3EB49500D211F6 /* README.md in Resources */, - EA0BBD472E37CCB700DC8143 /* CODEOWNERS in Resources */, - EA0C92462C3EB42300D211F6 /* workflows in Resources */, EA0C925A2C3EB49500D211F6 /* README.ko.md in Resources */, EA0C925E2C3EB49500D211F6 /* README.jpn.md in Resources */, ); From ca31320b6ba0b6c781104588bf5eacb94b51334a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 26 May 2026 13:13:16 +0800 Subject: [PATCH 028/614] Update setting pages --- EhPanda/App/Generated/Strings.swift | 10 ++-- EhPanda/App/de.lproj/Localizable.strings | 4 +- EhPanda/App/en.lproj/Localizable.strings | 5 +- EhPanda/App/ja.lproj/Localizable.strings | 4 +- EhPanda/App/ko.lproj/Localizable.strings | 46 +++++++++---------- EhPanda/App/zh-Hans.lproj/Localizable.strings | 4 +- .../App/zh-Hant-HK.lproj/Localizable.strings | 4 +- .../App/zh-Hant-TW.lproj/Localizable.strings | 4 +- EhPanda/App/zh-Hant.lproj/Localizable.strings | 4 +- .../Components/DownloadSettingView.swift | 2 +- EhPanda/View/Setting/SettingReducer.swift | 2 +- EhPanda/View/Setting/SettingView.swift | 12 ++--- 12 files changed, 52 insertions(+), 49 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 0b5a8091c..5402eb649 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -618,6 +618,8 @@ internal enum L10n { } } internal enum DownloadSettingView { + /// Download + internal static let title = L10n.tr("Localizable", "download_setting_view.title", fallback: "Download") internal enum Footer { /// Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder. internal static let network = L10n.tr("Localizable", "download_setting_view.footer.network", fallback: "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder.") @@ -1891,14 +1893,14 @@ internal enum L10n { } internal enum SettingStateRoute { internal enum Value { - /// About EhPanda - internal static let about = L10n.tr("Localizable", "enum.setting_state_route.value.about", fallback: "About EhPanda") + /// About + internal static let about = L10n.tr("Localizable", "enum.setting_state_route.value.about", fallback: "About") /// Account internal static let account = L10n.tr("Localizable", "enum.setting_state_route.value.account", fallback: "Account") /// Appearance internal static let appearance = L10n.tr("Localizable", "enum.setting_state_route.value.appearance", fallback: "Appearance") - /// Downloads - internal static let downloads = L10n.tr("Localizable", "enum.setting_state_route.value.downloads", fallback: "Downloads") + /// Download + internal static let download = L10n.tr("Localizable", "enum.setting_state_route.value.download", fallback: "Download") /// General internal static let general = L10n.tr("Localizable", "enum.setting_state_route.value.general", fallback: "General") /// Laboratory diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 91edb55f1..b613c41fa 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -155,9 +155,9 @@ "enum.setting_state_route.value.general" = "Allgemein"; "enum.setting_state_route.value.appearance" = "Oberfläche"; "enum.setting_state_route.value.reading" = "Am Lesen"; -"enum.setting_state_route.value.downloads" = "Downloads"; +"enum.setting_state_route.value.download" = "Download"; "enum.setting_state_route.value.laboratory" = "Experimentelles"; -"enum.setting_state_route.value.about" = "Über EhPanda"; +"enum.setting_state_route.value.about" = "About"; // MARK: AccountSettingView "account_setting_view.title.account" = "Konto"; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 7652578f7..5a74e380e 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -177,9 +177,9 @@ "enum.setting_state_route.value.general" = "General"; "enum.setting_state_route.value.appearance" = "Appearance"; "enum.setting_state_route.value.reading" = "Reading"; -"enum.setting_state_route.value.downloads" = "Downloads"; +"enum.setting_state_route.value.download" = "Download"; "enum.setting_state_route.value.laboratory" = "Laboratory"; -"enum.setting_state_route.value.about" = "About EhPanda"; +"enum.setting_state_route.value.about" = "About"; // MARK: AccountSettingView "account_setting_view.title.account" = "Account"; @@ -414,6 +414,7 @@ "downloads_view.inspector.status.failed" = "Failed"; // MARK: DownloadSettingView +"download_setting_view.title" = "Download"; "download_setting_view.section.title.download_queue" = "Download Queue"; "download_setting_view.section.title.network" = "Network"; "download_setting_view.title.concurrent_image_downloads" = "Concurrent image downloads"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 0bf43ab07..28d9480ba 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -155,9 +155,9 @@ "enum.setting_state_route.value.general" = "一般"; "enum.setting_state_route.value.appearance" = "外観"; "enum.setting_state_route.value.reading" = "閲覧"; -"enum.setting_state_route.value.downloads" = "ダウンロード"; +"enum.setting_state_route.value.download" = "ダウンロード"; "enum.setting_state_route.value.laboratory" = "ラボ"; -"enum.setting_state_route.value.about" = "EhPanda について"; +"enum.setting_state_route.value.about" = "アプリについて"; // MARK: AccountSettingView "account_setting_view.title.account" = "アカウント"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index c0fad452c..bdd3d5a30 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -155,9 +155,9 @@ "enum.setting_state_route.value.general" = "일반"; "enum.setting_state_route.value.appearance" = "외관"; "enum.setting_state_route.value.reading" = "읽기"; -"enum.setting_state_route.value.downloads" = "다운로드"; +"enum.setting_state_route.value.download" = "다운로드"; "enum.setting_state_route.value.laboratory" = "실험실"; -"enum.setting_state_route.value.about" = "EhPanda 정보"; +"enum.setting_state_route.value.about" = "About"; // MARK: AccountSettingView "account_setting_view.title.account" = "계정"; @@ -966,22 +966,22 @@ "downloads_view.swipe.button.update" = "업데이트"; "downloads_view.swipe.button.resume" = "재개"; "downloads_view.swipe.button.pause" = "일시 정지"; -"downloads_view.empty_state.downloads" = "다운로드한 갤러리가 여기에 표시됩니다."; -"downloads_view.empty_state.no_matching_filters" = "현재 필터와 일치하는 다운로드가 없습니다."; -"downloads_view.button.clear_filters" = "필터 지우기"; -"downloads_view.button.validate_image_data" = "이미지 데이터 검증"; -"downloads_view.inspector.section.actions" = "동작"; -"downloads_view.inspector.section.pages" = "페이지"; -"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도 (%d)"; -"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; -"downloads_view.inspector.title.download_status" = "다운로드 상태"; -"downloads_view.inspector.page.pending" = "대기 중"; -"downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; -"downloads_view.inspector.page.title" = "페이지 %d"; -"downloads_view.inspector.page.none" = "페이지 없음"; -"downloads_view.inspector.status.pending" = "대기 중"; -"downloads_view.inspector.status.downloaded" = "다운로드됨"; -"downloads_view.inspector.status.failed" = "실패"; +"downloads_view.empty_state.downloads" = "다운로드한 갤러리가 여기에 표시됩니다."; +"downloads_view.empty_state.no_matching_filters" = "현재 필터와 일치하는 다운로드가 없습니다."; +"downloads_view.button.clear_filters" = "필터 지우기"; +"downloads_view.button.validate_image_data" = "이미지 데이터 검증"; +"downloads_view.inspector.section.actions" = "동작"; +"downloads_view.inspector.section.pages" = "페이지"; +"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도 (%d)"; +"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; +"downloads_view.inspector.title.download_status" = "다운로드 상태"; +"downloads_view.inspector.page.pending" = "대기 중"; +"downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; +"downloads_view.inspector.page.title" = "페이지 %d"; +"downloads_view.inspector.page.none" = "페이지 없음"; +"downloads_view.inspector.status.pending" = "대기 중"; +"downloads_view.inspector.status.downloaded" = "다운로드됨"; +"downloads_view.inspector.status.failed" = "실패"; "download_setting_view.section.title.download_queue" = "다운로드 대기열"; "download_setting_view.section.title.network" = "네트워크"; "download_setting_view.title.concurrent_image_downloads" = "동시 이미지 다운로드 수"; @@ -1015,8 +1015,8 @@ "download_file_storage.validation.download_folder_missing" = "다운로드 폴더가 없습니다."; "download_file_storage.validation.manifest_missing" = "매니페스트 파일이 없습니다."; "download_file_storage.validation.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; -"download_file_storage.validation.downloaded_pages_incomplete" = "다운로드한 페이지가 불완전합니다."; -"download_file_storage.validation.cover_image_missing" = "표지 이미지가 없습니다."; -"download_file_storage.validation.page_missing" = "페이지 %d가 없습니다."; -"download_file_storage.validation.cover_image_corrupted" = "표지 이미지 데이터가 손상되었습니다."; -"download_file_storage.validation.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; +"download_file_storage.validation.downloaded_pages_incomplete" = "다운로드한 페이지가 불완전합니다."; +"download_file_storage.validation.cover_image_missing" = "표지 이미지가 없습니다."; +"download_file_storage.validation.page_missing" = "페이지 %d가 없습니다."; +"download_file_storage.validation.cover_image_corrupted" = "표지 이미지 데이터가 손상되었습니다."; +"download_file_storage.validation.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index babb73797..15e899552 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -177,9 +177,9 @@ "enum.setting_state_route.value.general" = "一般"; "enum.setting_state_route.value.appearance" = "外观"; "enum.setting_state_route.value.reading" = "阅读"; -"enum.setting_state_route.value.downloads" = "下载"; +"enum.setting_state_route.value.download" = "下载"; "enum.setting_state_route.value.laboratory" = "实验室"; -"enum.setting_state_route.value.about" = "关于 EhPanda"; +"enum.setting_state_route.value.about" = "关于"; // MARK: AccountSettingView "account_setting_view.title.account" = "账户"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 211377f77..7a82956d3 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -155,9 +155,9 @@ "enum.setting_state_route.value.general" = "一般"; "enum.setting_state_route.value.appearance" = "外觀"; "enum.setting_state_route.value.reading" = "閱讀"; -"enum.setting_state_route.value.downloads" = "下載"; +"enum.setting_state_route.value.download" = "下載"; "enum.setting_state_route.value.laboratory" = "實驗性功能"; -"enum.setting_state_route.value.about" = "關於 EhPanda"; +"enum.setting_state_route.value.about" = "關於"; // MARK: AccountSettingView "account_setting_view.title.account" = "帳號設定"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 4f5d3cc69..117fedffb 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -155,9 +155,9 @@ "enum.setting_state_route.value.general" = "一般"; "enum.setting_state_route.value.appearance" = "外觀"; "enum.setting_state_route.value.reading" = "閱讀"; -"enum.setting_state_route.value.downloads" = "下載"; +"enum.setting_state_route.value.download" = "下載"; "enum.setting_state_route.value.laboratory" = "實驗性功能"; -"enum.setting_state_route.value.about" = "關於 EhPanda"; +"enum.setting_state_route.value.about" = "關於"; // MARK: AccountSettingView "account_setting_view.title.account" = "帳號設定"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 9207cb6b5..cc35ad1ce 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -155,9 +155,9 @@ "enum.setting_state_route.value.general" = "一般"; "enum.setting_state_route.value.appearance" = "外觀"; "enum.setting_state_route.value.reading" = "閱讀"; -"enum.setting_state_route.value.downloads" = "下載"; +"enum.setting_state_route.value.download" = "下載"; "enum.setting_state_route.value.laboratory" = "實驗性功能"; -"enum.setting_state_route.value.about" = "關於 EhPanda"; +"enum.setting_state_route.value.about" = "關於"; // MARK: AccountSettingView "account_setting_view.title.account" = "帳號設定"; diff --git a/EhPanda/View/Setting/Components/DownloadSettingView.swift b/EhPanda/View/Setting/Components/DownloadSettingView.swift index 7e3560da3..ec201ee27 100644 --- a/EhPanda/View/Setting/Components/DownloadSettingView.swift +++ b/EhPanda/View/Setting/Components/DownloadSettingView.swift @@ -49,7 +49,7 @@ struct DownloadSettingView: View { Text(L10n.Localizable.DownloadSettingView.Footer.network) } } - .navigationTitle(L10n.Localizable.DownloadsView.Title.downloads) + .navigationTitle(L10n.Localizable.DownloadSettingView.title) } } diff --git a/EhPanda/View/Setting/SettingReducer.swift b/EhPanda/View/Setting/SettingReducer.swift index 73331c3eb..320b381eb 100644 --- a/EhPanda/View/Setting/SettingReducer.swift +++ b/EhPanda/View/Setting/SettingReducer.swift @@ -16,7 +16,7 @@ struct SettingReducer { case general case appearance case reading - case downloads + case download case laboratory case about } diff --git a/EhPanda/View/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift index 035583bb9..2fe18fd8e 100644 --- a/EhPanda/View/Setting/SettingView.swift +++ b/EhPanda/View/Setting/SettingView.swift @@ -89,7 +89,7 @@ private extension SettingView { ) .tint(store.setting.accentColor) } - NavigationLink(unwrapping: $store.route, case: \.downloads) { _ in + NavigationLink(unwrapping: $store.route, case: \.download) { _ in DownloadSettingView( downloadThreadMode: $store.setting.downloadThreadMode, downloadAllowCellular: $store.setting.downloadAllowCellular, @@ -160,8 +160,8 @@ extension SettingReducer.Route { return L10n.Localizable.Enum.SettingStateRoute.Value.appearance case .reading: return L10n.Localizable.Enum.SettingStateRoute.Value.reading - case .downloads: - return L10n.Localizable.Enum.SettingStateRoute.Value.downloads + case .download: + return L10n.Localizable.Enum.SettingStateRoute.Value.download case .laboratory: return L10n.Localizable.Enum.SettingStateRoute.Value.laboratory case .about: @@ -178,12 +178,12 @@ extension SettingReducer.Route { return .circleRighthalfFilled case .reading: return .newspaperFill - case .downloads: - return .arrowDownCircle + case .download: + return .squareAndArrowDownOnSquareFill case .laboratory: return .testtube2 case .about: - return .pCircleFill + return .iCircleFill } } } From 14f82bc05398b3667842867cf86ef03af6557904 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 26 May 2026 14:49:36 +0800 Subject: [PATCH 029/614] Bump version to 3.0.0 (158) & Update CI --- .github/workflows/deploy-pre-release.yml | 45 +++--- .github/workflows/deploy.yml | 8 +- EhPanda.xcodeproj/project.pbxproj | 16 +- EhPanda/App/Info.plist | 4 +- Scripts/bump-version.sh | 180 +++++++++++++++++++++++ 5 files changed, 221 insertions(+), 32 deletions(-) create mode 100755 Scripts/bump-version.sh diff --git a/.github/workflows/deploy-pre-release.yml b/.github/workflows/deploy-pre-release.yml index 9c54a15ff..2bd7cfe8d 100644 --- a/.github/workflows/deploy-pre-release.yml +++ b/.github/workflows/deploy-pre-release.yml @@ -2,17 +2,13 @@ name: Deploy Pre-release on: workflow_dispatch: inputs: - versionTag: - description: 'Version tag' - required: true - type: string releaseTitle: description: 'Release title' required: true type: string releaseDescription: description: 'Release description' - required: true + required: false type: string env: DEVELOPER_DIR: /Applications/Xcode_26.5.app @@ -47,16 +43,13 @@ jobs: - name: Run tests run: xcodebuild clean test -skipMacroValidation + -skipPackagePluginValidation -scheme ${{ env.SCHEME_NAME }} -destination 'platform=iOS Simulator,name=iPhone Air' - - name: Bump version - id: bump-version - uses: yanamura/ios-bump-version@v1 - with: - version: ${{ inputs.versionTag }} - name: Xcode archive run: xcodebuild archive -skipMacroValidation + -skipPackagePluginValidation -scheme ${{ env.SCHEME_NAME }} -destination 'generic/platform=iOS' -archivePath ${{ env.ARCHIVE_PATH }} @@ -77,15 +70,29 @@ jobs: - name: Retrieve data id: retrieve-data run: | - echo "size=$(stat -f%z $IPA_OUTPUT_PATH)" >> $GITHUB_OUTPUT - echo "version_date=$(date -u +"%Y-%m-%dT%T")" >> $GITHUB_OUTPUT + app_info_plist="$PAYLOAD_PATH/$SCHEME_NAME.app/Info.plist" + version="$(plutil -extract CFBundleShortVersionString raw -o - "$app_info_plist")" + size="$(stat -f%z "$IPA_OUTPUT_PATH")" + + [[ ! -z "$version" ]] || exit 1 + [[ ! -z "$size" ]] || exit 1 + + { + echo "size=$size" + echo "version=$version" + echo "version_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + } >> "$GITHUB_OUTPUT" - name: Validate data + env: + RELEASE_TITLE: ${{ inputs.releaseTitle }} + RELEASE_SIZE: ${{ steps.retrieve-data.outputs.size }} + RELEASE_VERSION: ${{ steps.retrieve-data.outputs.version }} + RELEASE_DATE: ${{ steps.retrieve-data.outputs.version_date }} run: | - [[ ! -z "${{ inputs.releaseTitle }}" ]] || exit 1 - [[ ! -z "${{ inputs.releaseDescription }}" ]] || exit 1 - [[ ! -z "${{ steps.retrieve-data.outputs.size }}" ]] || exit 1 - [[ ! -z "${{ steps.bump-version.outputs.version }}" ]] || exit 1 - [[ ! -z "${{ steps.retrieve-data.outputs.version_date }}" ]] || exit 1 + [[ ! -z "$RELEASE_TITLE" ]] || exit 1 + [[ ! -z "$RELEASE_SIZE" ]] || exit 1 + [[ ! -z "$RELEASE_VERSION" ]] || exit 1 + [[ ! -z "$RELEASE_DATE" ]] || exit 1 - name: Release to GitHub uses: softprops/action-gh-release@v3 with: @@ -94,5 +101,5 @@ jobs: files: ${{ env.IPA_OUTPUT_PATH }} token: ${{ secrets.GITHUB_TOKEN }} name: ${{ inputs.releaseTitle }} - body: ${{ inputs.releaseDescription }} - tag_name: 'v${{ steps.bump-version.outputs.version }}' + body: ${{ inputs.releaseDescription || '' }} + tag_name: ${{ steps.retrieve-data.outputs.version }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fe335349c..c506e0e5e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -42,11 +42,13 @@ jobs: - name: Run tests run: xcodebuild clean test -skipMacroValidation + -skipPackagePluginValidation -scheme ${{ env.SCHEME_NAME }} -destination 'platform=iOS Simulator,name=iPhone Air' - name: Xcode archive run: xcodebuild archive -skipMacroValidation + -skipPackagePluginValidation -scheme ${{ env.SCHEME_NAME }} -destination 'generic/platform=iOS' -archivePath ${{ env.ARCHIVE_PATH }} @@ -140,7 +142,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} body: ${{ github.event.pull_request.body }} name: ${{ github.event.pull_request.title }} - tag_name: 'v${{ steps.retrieve-data.outputs.version }}' + tag_name: ${{ steps.retrieve-data.outputs.version }} - name: Update AltStore.json env: RELEASE_SIZE: ${{ steps.retrieve-data.outputs.size }} @@ -196,11 +198,11 @@ jobs: RELEASE_BODY: ${{ github.event.pull_request.body }} RELEASE_NOTES: ${{ steps.retrieve-data.outputs.notes }} run: | - message="$(printf '*v%s Release Notes:*\n%s' "$RELEASE_VERSION" "$RELEASE_BODY")" + message="$(printf '*%s Release Notes:*\n%s' "$RELEASE_VERSION" "$RELEASE_BODY")" curl https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \ -d parse_mode=markdown -d chat_id=${{ secrets.TELEGRAM_CHANNEL_ID }} \ --data-urlencode "text=$message" - payload="$(jq -n --arg content "**v$RELEASE_VERSION Release Notes:**\n$RELEASE_NOTES" '{content: $content}')" + payload="$(jq -n --arg content "**$RELEASE_VERSION Release Notes:**\n$RELEASE_NOTES" '{content: $content}')" curl ${{ secrets.DISCORD_WEBHOOK }} \ -F "payload_json=$payload" diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index e91caf9ea..f78215a13 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -485,7 +485,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 157; + CURRENT_PROJECT_VERSION = 158; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -496,7 +496,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.8.0; + MARKETING_VERSION = 3.0.0; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -515,7 +515,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 157; + CURRENT_PROJECT_VERSION = 158; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -526,7 +526,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.8.0; + MARKETING_VERSION = 3.0.0; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -669,7 +669,7 @@ CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 157; + CURRENT_PROJECT_VERSION = 158; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; @@ -678,7 +678,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8.0; + MARKETING_VERSION = 3.0.0; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -699,7 +699,7 @@ CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 157; + CURRENT_PROJECT_VERSION = 158; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; @@ -708,7 +708,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8.0; + MARKETING_VERSION = 3.0.0; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/EhPanda/App/Info.plist b/EhPanda/App/Info.plist index e852058be..2a009ffc0 100644 --- a/EhPanda/App/Info.plist +++ b/EhPanda/App/Info.plist @@ -118,7 +118,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.8.0 + $(MARKETING_VERSION) CFBundleURLTypes @@ -133,7 +133,7 @@ CFBundleVersion - 157 + $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/Scripts/bump-version.sh b/Scripts/bump-version.sh new file mode 100755 index 000000000..54fe19e72 --- /dev/null +++ b/Scripts/bump-version.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# +# Bumps MARKETING_VERSION and CURRENT_PROJECT_VERSION for every non-test +# target in EhPanda.xcodeproj at once. +# +# Usage: ./Scripts/bump-version.sh -v [-b ] +# If -b is omitted, the current build number is auto-incremented by 1. +# Example: ./Scripts/bump-version.sh -v 2.8.1 -b 158 +# ./Scripts/bump-version.sh -v 2.8.1 + +set -euo pipefail + +PROG="$(basename "$0")" + +usage() { + echo "Usage: $PROG -v [-b ]" >&2 + exit 1 +} + +help() { + cat < [-b ] + $PROG -h | --help + +Options: + -v Marketing version in semantic format (required). + -b Build number (non-negative integer). If omitted, the + current build number is detected from the project and + incremented by 1. + -h, --help Show this help and exit. + +Examples: + $PROG -v 2.8.1 -b 158 # set version 2.8.1, build 158 + $PROG -v 2.8.1 # set version 2.8.1, auto-increment build +EOF + exit 0 +} + +# Translate long --help before getopts (getopts is short-opt only). +for arg in "$@"; do + case "$arg" in + --help) help ;; + esac +done + +VERSION="" +BUILD="" + +while getopts ":v:b:h" opt; do + case "$opt" in + v) VERSION="$OPTARG" ;; + b) BUILD="$OPTARG" ;; + h) help ;; + \?) echo "Error: unknown option -$OPTARG" >&2; usage ;; + :) echo "Error: option -$OPTARG requires an argument" >&2; usage ;; + esac +done + +if [ -z "$VERSION" ]; then + echo "Error: -v is required" >&2 + usage +fi + +if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be in semantic x.y.z format (got '$VERSION')" >&2 + exit 1 +fi + +if [ -n "$BUILD" ] && ! [[ "$BUILD" =~ ^[0-9]+$ ]]; then + echo "Error: build number must be a non-negative integer (got '$BUILD')" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PBXPROJ="$SCRIPT_DIR/../EhPanda.xcodeproj/project.pbxproj" + +if [ ! -f "$PBXPROJ" ]; then + echo "Error: project.pbxproj not found at $PBXPROJ" >&2 + exit 1 +fi + +TEST_BUNDLE_ID="app.ehpanda.tests" + +if [ -z "$BUILD" ]; then + # Pick the highest CURRENT_PROJECT_VERSION across non-test build configs and add 1. + CURRENT_BUILD="$(awk -v test_id="$TEST_BUNDLE_ID" ' + function flush( i, is_test, m) { + is_test = 0 + for (i = 1; i <= n; i++) { + if (block[i] ~ ("PRODUCT_BUNDLE_IDENTIFIER = " test_id ";")) { is_test = 1; break } + } + if (!is_test) { + for (i = 1; i <= n; i++) { + if (match(block[i], /CURRENT_PROJECT_VERSION = [0-9]+;/)) { + m = substr(block[i], RSTART + 26, RLENGTH - 27) + if (m + 0 > max + 0) max = m + 0 + } + } + } + n = 0; in_block = 0 + } + { + if (in_block) { + block[++n] = $0 + if ($0 ~ /^\t\t};[[:space:]]*$/) flush() + next + } + if ($0 ~ /isa = XCBuildConfiguration;/) { in_block = 1; n = 1; block[n] = $0; next } + } + END { if (in_block) flush(); print max + 0 } + ' "$PBXPROJ")" + + if [ -z "$CURRENT_BUILD" ] || [ "$CURRENT_BUILD" = "0" ]; then + echo "Error: could not detect current build number from $PBXPROJ" >&2 + exit 1 + fi + BUILD=$((CURRENT_BUILD + 1)) + echo "Auto-detected current build $CURRENT_BUILD, bumping to $BUILD." +fi + +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT + +awk -v v="$VERSION" -v b="$BUILD" -v test_id="$TEST_BUNDLE_ID" ' +function flush_block( i, is_test, line) { + is_test = 0 + for (i = 1; i <= n; i++) { + if (block[i] ~ ("PRODUCT_BUNDLE_IDENTIFIER = " test_id ";")) { + is_test = 1 + break + } + } + for (i = 1; i <= n; i++) { + line = block[i] + if (!is_test) { + if (line ~ /MARKETING_VERSION = /) { + sub(/MARKETING_VERSION = [^;]+;/, "MARKETING_VERSION = " v ";", line) + marketing_hits++ + } else if (line ~ /CURRENT_PROJECT_VERSION = /) { + sub(/CURRENT_PROJECT_VERSION = [^;]+;/, "CURRENT_PROJECT_VERSION = " b ";", line) + build_hits++ + } + } + print line + } + n = 0 + in_block = 0 +} + +{ + if (in_block) { + block[++n] = $0 + # XCBuildConfiguration block ends at 2-tab indented "};" + if ($0 ~ /^\t\t};[[:space:]]*$/) { + flush_block() + } + next + } + if ($0 ~ /isa = XCBuildConfiguration;/) { + in_block = 1 + n = 1 + block[n] = $0 + next + } + print +} +END { + if (in_block) flush_block() + printf("Updated %d MARKETING_VERSION and %d CURRENT_PROJECT_VERSION entries.\n", marketing_hits, build_hits) > "/dev/stderr" +} +' "$PBXPROJ" > "$TMP" + +mv "$TMP" "$PBXPROJ" +trap - EXIT + +echo "Bumped non-test targets to version $VERSION (build $BUILD)." From 81d1e9442c6321a35ebacf7a5cfa6d087d58dba2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 30 May 2026 21:27:00 +0800 Subject: [PATCH 030/614] Update SwiftLint rules --- .swiftlint.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.swiftlint.yml b/.swiftlint.yml index 7e49f18b8..dc1965641 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,2 +1,21 @@ +disabled_rules: + - file_length + - opening_brace + - type_body_length + - function_body_length + - cyclomatic_complexity + - blanket_disable_command + - multiple_closures_with_trailing_closure + +opt_in_rules: + - force_try + +force_try: + severity: error + +line_length: + warning: 120 + error: 120 + excluded: - EhPanda/App/Generated From 4a133d0d95c62ab494d63713b14d67a0776328ee Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 30 May 2026 21:27:12 +0800 Subject: [PATCH 031/614] Update setting page symbol --- EhPanda/View/Setting/SettingView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EhPanda/View/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift index 2fe18fd8e..7ecb489fd 100644 --- a/EhPanda/View/Setting/SettingView.swift +++ b/EhPanda/View/Setting/SettingView.swift @@ -183,7 +183,7 @@ extension SettingReducer.Route { case .laboratory: return .testtube2 case .about: - return .iCircleFill + return .infoCircleFill } } } From 314cb1201c2324663ab646d7260aeb68cf7df59c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 30 May 2026 21:27:32 +0800 Subject: [PATCH 032/614] Improve downloads UX --- EhPanda/App/Generated/Strings.swift | 14 +- .../DownloadClient+PersistenceNormalize.swift | 93 ++++---- .../App/Tools/Clients/DownloadClient.swift | 8 +- .../Tools/Utilities/DownloadFileStorage.swift | 2 +- EhPanda/App/de.lproj/Localizable.strings | 5 +- EhPanda/App/en.lproj/Localizable.strings | 5 +- EhPanda/App/ja.lproj/Localizable.strings | 5 +- EhPanda/App/ko.lproj/Localizable.strings | 11 +- EhPanda/App/zh-Hans.lproj/Localizable.strings | 5 +- .../App/zh-Hant-HK.lproj/Localizable.strings | 5 +- .../App/zh-Hant-TW.lproj/Localizable.strings | 5 +- EhPanda/App/zh-Hant.lproj/Localizable.strings | 5 +- .../DownloadedGallery+SupportTypes.swift | 22 ++ .../Downloads/DownloadInspectorReducer.swift | 67 +++++- EhPanda/View/Downloads/DownloadsReducer.swift | 11 - .../Downloads/DownloadsView+Subviews.swift | 216 +++++++++++++----- EhPanda/View/Downloads/DownloadsView.swift | 37 +-- .../Components/DownloadSettingView.swift | 2 +- .../Download/DownloadInspectorLoadTests.swift | 212 ++++++++++++++++- .../DownloadsReducerActionTests.swift | 37 --- 20 files changed, 549 insertions(+), 218 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 5402eb649..40608baac 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -668,12 +668,18 @@ internal enum L10n { } internal enum Inspector { internal enum Button { - /// Retry Failed Pages (%d) - internal static func retryFailedPages(_ p1: Int) -> String { - return L10n.tr("Localizable", "downloads_view.inspector.button.retry_failed_pages", p1, fallback: "Retry Failed Pages (%d)") - } + /// Retry Failed Pages + internal static let retryFailedPages = L10n.tr("Localizable", "downloads_view.inspector.button.retry_failed_pages", fallback: "Retry Failed Pages") /// Update Download internal static let updateDownload = L10n.tr("Localizable", "downloads_view.inspector.button.update_download", fallback: "Update Download") + /// Validating Image Data... + internal static let validatingImageData = L10n.tr("Localizable", "downloads_view.inspector.button.validating_image_data", fallback: "Validating Image Data...") + } + internal enum Hud { + /// Image data could not be validated. + internal static let imageDataUnavailable = L10n.tr("Localizable", "downloads_view.inspector.hud.image_data_unavailable", fallback: "Image data could not be validated.") + /// Image data is valid + internal static let imageDataValid = L10n.tr("Localizable", "downloads_view.inspector.hud.image_data_valid", fallback: "Image data is valid") } internal enum Page { /// No pages diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 75bb7f53d..602c213d2 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -176,56 +176,59 @@ extension DownloadManager { func validateDownloads() async { let downloads = await fetchDownloadsFromStore() - for download in downloads - where [.completed, .updateAvailable, .missingFiles] - .contains(download.status) { - let validation = storage - .validate(download: download) - switch validation { - case .valid: - refreshMissingManifestHashesIfNeeded(download: download) - let expectedStatus: DownloadStatus = - download.hasUpdate - ? .updateAvailable : .completed - guard download.status != expectedStatus - else { continue } - do { - try await updateDownloadRecord( - gid: download.gid, - createIfMissing: false - ) { record in - record.status = - expectedStatus.rawValue - } - } catch { - Logger.error(error) + for download in downloads where download.canValidateImageData { + _ = await validateDownload(download) + } + } + + func validateImageData(gid: String) async -> DownloadValidationState? { + guard let download = await fetchDownload(gid: gid), + download.canValidateImageData + else { return nil } + let validation = await validateDownload(download) + await notifyObservers() + return validation + } + + private func validateDownload(_ download: DownloadedGallery) async -> DownloadValidationState { + let validation = storage.validate(download: download) + switch validation { + case .valid: + refreshMissingManifestHashesIfNeeded(download: download) + let expectedStatus: DownloadStatus = + download.hasUpdate + ? .updateAvailable : .completed + guard download.status != expectedStatus + else { return validation } + do { + try await updateDownloadRecord( + gid: download.gid, + createIfMissing: false + ) { record in + record.status = expectedStatus.rawValue } + } catch { + Logger.error(error) + } - case .missingFiles(let message): - do { - try await updateDownloadRecord( - gid: download.gid, - createIfMissing: false - ) { record in - record.status = - DownloadStatus.missingFiles - .rawValue - record.lastError = DownloadFailure( - code: .fileOperationFailed, - message: message - ) - .toData() - } - } catch { - Logger.error(error) + case .missingFiles(let message): + do { + try await updateDownloadRecord( + gid: download.gid, + createIfMissing: false + ) { record in + record.status = DownloadStatus.missingFiles.rawValue + record.lastError = DownloadFailure( + code: .fileOperationFailed, + message: message + ) + .toData() } + } catch { + Logger.error(error) } } - } - - func validateImageData() async { - await validateDownloads() - await notifyObservers() + return validation } private func refreshMissingManifestHashesIfNeeded( diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 4defe4234..6fbb873f5 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -12,7 +12,7 @@ struct DownloadClient: Sendable { let fetchDownload: @Sendable (String) async -> DownloadedGallery? let reconcileDownloads: @Sendable () async -> Void let refreshDownloads: @Sendable () async -> Void - let validateImageData: @Sendable () async -> Void + let validateImageData: @Sendable (String) async -> DownloadValidationState? let resumeQueue: @Sendable () async -> Void let badges: @Sendable ([String]) async -> [String: DownloadBadge] let fetchVersionMetadata: @Sendable (String, String) async -> Result @@ -33,7 +33,7 @@ struct DownloadClient: Sendable { fetchDownload: @escaping @Sendable (String) async -> DownloadedGallery?, reconcileDownloads: @escaping @Sendable () async -> Void = {}, refreshDownloads: @escaping @Sendable () async -> Void, - validateImageData: @escaping @Sendable () async -> Void = {}, + validateImageData: @escaping @Sendable (String) async -> DownloadValidationState? = { _ in nil }, resumeQueue: @escaping @Sendable () async -> Void, badges: @escaping @Sendable ([String]) async -> [String: DownloadBadge], fetchVersionMetadata: @escaping @Sendable (String, String) async -> Result @@ -120,7 +120,7 @@ extension DownloadClient { fetchDownload: { gid in await manager.fetchDownload(gid: gid) }, reconcileDownloads: { await manager.reconcileDownloads() }, refreshDownloads: { await manager.refreshDownloads() }, - validateImageData: { await manager.validateImageData() }, + validateImageData: { gid in await manager.validateImageData(gid: gid) }, resumeQueue: { await manager.resumeQueue() }, badges: { gids in await manager.badges(for: gids) }, fetchVersionMetadata: { gid, token in @@ -173,7 +173,7 @@ extension DownloadClient { fetchDownload: { _ in nil }, reconcileDownloads: {}, refreshDownloads: {}, - validateImageData: {}, + validateImageData: { _ in nil }, resumeQueue: {}, badges: { _ in [:] }, fetchVersionMetadata: { _, _ in .failure(.notFound) }, diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 2be70e9e4..b7ae7a813 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -7,7 +7,7 @@ import Foundation import CryptoKit import Synchronization -enum DownloadValidationState: Equatable { +enum DownloadValidationState: Equatable, Sendable { case valid case missingFiles(String) } diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index b613c41fa..f3db0ae38 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -972,8 +972,11 @@ "downloads_view.button.validate_image_data" = "Bilddaten validieren"; "downloads_view.inspector.section.actions" = "Aktionen"; "downloads_view.inspector.section.pages" = "Seiten"; -"downloads_view.inspector.button.retry_failed_pages" = "Fehlgeschlagene Seiten erneut versuchen (%d)"; +"downloads_view.inspector.button.retry_failed_pages" = "Fehlgeschlagene Seiten erneut versuchen"; +"downloads_view.inspector.button.validating_image_data" = "Bilddaten werden geprüft..."; "downloads_view.inspector.button.update_download" = "Download aktualisieren"; +"downloads_view.inspector.hud.image_data_valid" = "Bilddaten sind gültig"; +"downloads_view.inspector.hud.image_data_unavailable" = "Bilddaten konnten nicht geprüft werden."; "downloads_view.inspector.title.download_status" = "Downloadstatus"; "downloads_view.inspector.page.pending" = "Ausstehend"; "downloads_view.inspector.page.tap_to_retry" = "Tippen, um diese Seite erneut zu versuchen"; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 5a74e380e..a73a6c42d 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -402,8 +402,11 @@ "downloads_view.button.validate_image_data" = "Validate Image Data"; "downloads_view.inspector.section.actions" = "Actions"; "downloads_view.inspector.section.pages" = "Pages"; -"downloads_view.inspector.button.retry_failed_pages" = "Retry Failed Pages (%d)"; +"downloads_view.inspector.button.retry_failed_pages" = "Retry Failed Pages"; +"downloads_view.inspector.button.validating_image_data" = "Validating Image Data..."; "downloads_view.inspector.button.update_download" = "Update Download"; +"downloads_view.inspector.hud.image_data_valid" = "Image data is valid"; +"downloads_view.inspector.hud.image_data_unavailable" = "Image data could not be validated."; "downloads_view.inspector.title.download_status" = "Download Status"; "downloads_view.inspector.page.pending" = "Pending"; "downloads_view.inspector.page.tap_to_retry" = "Tap to retry this page"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 28d9480ba..9a608f3b4 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -972,8 +972,11 @@ "downloads_view.button.validate_image_data" = "画像データを検証"; "downloads_view.inspector.section.actions" = "操作"; "downloads_view.inspector.section.pages" = "ページ"; -"downloads_view.inspector.button.retry_failed_pages" = "失敗したページを再試行 (%d)"; +"downloads_view.inspector.button.retry_failed_pages" = "失敗したページを再試行"; +"downloads_view.inspector.button.validating_image_data" = "画像データを検証中..."; "downloads_view.inspector.button.update_download" = "ダウンロードを更新"; +"downloads_view.inspector.hud.image_data_valid" = "画像データは有効です"; +"downloads_view.inspector.hud.image_data_unavailable" = "画像データを検証できませんでした。"; "downloads_view.inspector.title.download_status" = "ダウンロード状況"; "downloads_view.inspector.page.pending" = "待機中"; "downloads_view.inspector.page.tap_to_retry" = "タップしてこのページを再試行"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index bdd3d5a30..bfd6c7e57 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -971,10 +971,13 @@ "downloads_view.button.clear_filters" = "필터 지우기"; "downloads_view.button.validate_image_data" = "이미지 데이터 검증"; "downloads_view.inspector.section.actions" = "동작"; -"downloads_view.inspector.section.pages" = "페이지"; -"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도 (%d)"; -"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; -"downloads_view.inspector.title.download_status" = "다운로드 상태"; +"downloads_view.inspector.section.pages" = "페이지"; +"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도"; +"downloads_view.inspector.button.validating_image_data" = "이미지 데이터 검증 중..."; +"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; +"downloads_view.inspector.hud.image_data_valid" = "이미지 데이터가 유효합니다"; +"downloads_view.inspector.hud.image_data_unavailable" = "이미지 데이터를 검증할 수 없습니다."; +"downloads_view.inspector.title.download_status" = "다운로드 상태"; "downloads_view.inspector.page.pending" = "대기 중"; "downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; "downloads_view.inspector.page.title" = "페이지 %d"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 15e899552..1ffd80df9 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -402,8 +402,11 @@ "downloads_view.button.validate_image_data" = "验证图片数据"; "downloads_view.inspector.section.actions" = "操作"; "downloads_view.inspector.section.pages" = "页面"; -"downloads_view.inspector.button.retry_failed_pages" = "重试失败页面(%d)"; +"downloads_view.inspector.button.retry_failed_pages" = "重试失败页面"; +"downloads_view.inspector.button.validating_image_data" = "正在验证图像数据..."; "downloads_view.inspector.button.update_download" = "更新下载"; +"downloads_view.inspector.hud.image_data_valid" = "图像数据有效"; +"downloads_view.inspector.hud.image_data_unavailable" = "无法验证图像数据。"; "downloads_view.inspector.title.download_status" = "下载状态"; "downloads_view.inspector.page.pending" = "等待中"; "downloads_view.inspector.page.tap_to_retry" = "点按以重试此页"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 7a82956d3..1f0634099 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -969,8 +969,11 @@ "downloads_view.button.validate_image_data" = "驗證圖片資料"; "downloads_view.inspector.section.actions" = "操作"; "downloads_view.inspector.section.pages" = "頁面"; -"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面(%d)"; +"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; +"downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; "downloads_view.inspector.button.update_download" = "更新下載"; +"downloads_view.inspector.hud.image_data_valid" = "圖片資料有效"; +"downloads_view.inspector.hud.image_data_unavailable" = "無法驗證圖片資料。"; "downloads_view.inspector.title.download_status" = "下載狀態"; "downloads_view.inspector.page.pending" = "等待中"; "downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 117fedffb..01d0caed4 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -970,8 +970,11 @@ "downloads_view.button.validate_image_data" = "驗證圖片資料"; "downloads_view.inspector.section.actions" = "操作"; "downloads_view.inspector.section.pages" = "頁面"; -"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面(%d)"; +"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; +"downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; "downloads_view.inspector.button.update_download" = "更新下載"; +"downloads_view.inspector.hud.image_data_valid" = "圖片資料有效"; +"downloads_view.inspector.hud.image_data_unavailable" = "無法驗證圖片資料。"; "downloads_view.inspector.title.download_status" = "下載狀態"; "downloads_view.inspector.page.pending" = "等待中"; "downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index cc35ad1ce..2afee3f5e 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -970,8 +970,11 @@ "downloads_view.button.validate_image_data" = "驗證圖片資料"; "downloads_view.inspector.section.actions" = "操作"; "downloads_view.inspector.section.pages" = "頁面"; -"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面(%d)"; +"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; +"downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; "downloads_view.inspector.button.update_download" = "更新下載"; +"downloads_view.inspector.hud.image_data_valid" = "圖片資料有效"; +"downloads_view.inspector.hud.image_data_unavailable" = "無法驗證圖片資料。"; "downloads_view.inspector.title.download_status" = "下載狀態"; "downloads_view.inspector.page.pending" = "等待中"; "downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index 4e40169f0..5583bc794 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -169,10 +169,18 @@ extension DownloadedGallery { [.partial, .failed, .missingFiles].contains(status) } + var canValidateImageData: Bool { + [.completed, .updateAvailable, .missingFiles].contains(status) + } + var canPauseOrResume: Bool { [.downloading, .paused].contains(status) } + var canTogglePause: Bool { + canPauseOrResume || isPendingQueue + } + var shouldPreserveTemporaryWorkingSet: Bool { pendingOperation != nil || [.queued, .downloading, .paused, .partial].contains(status) @@ -261,3 +269,17 @@ extension DownloadedGallery { return isRegularFile && fileSize > 0 } } + +extension DownloadInspection { + var hasDownloadedPages: Bool { + pages.contains { $0.status == .downloaded } + } + + var canRetryFailedPages: Bool { + !failedPageIndices.isEmpty + } + + var canValidateImageData: Bool { + hasDownloadedPages && download.canValidateImageData + } +} diff --git a/EhPanda/View/Downloads/DownloadInspectorReducer.swift b/EhPanda/View/Downloads/DownloadInspectorReducer.swift index f1a4074f1..a35ac1f39 100644 --- a/EhPanda/View/Downloads/DownloadInspectorReducer.swift +++ b/EhPanda/View/Downloads/DownloadInspectorReducer.swift @@ -8,6 +8,11 @@ import ComposableArchitecture @Reducer struct DownloadInspectorReducer { + @CasePathable + enum Route: Equatable { + case hud + } + private enum CancelID { case observeDownloads case loadInspection @@ -15,12 +20,15 @@ struct DownloadInspectorReducer { @ObservableState struct State: Equatable { + var route: Route? var gid = "" var inspection: DownloadInspection? var stableInspection: DownloadInspection? var loadingState: LoadingState = .loading + var hudConfig: ProgressHUDConfigState = .loading() var inspectionRequestID = UUID() var retryingPageIndices = Set() + var isValidatingImageData = false init(gid: String = "") { self.gid = gid @@ -28,7 +36,8 @@ struct DownloadInspectorReducer { } } - enum Action { + enum Action: BindableAction { + case binding(BindingAction) case onAppear case teardown case loadInspection @@ -39,15 +48,22 @@ struct DownloadInspectorReducer { case retryPageDone(Result) case retryFailedPages case retryFailedPagesDone(Result) - case updateDownload - case updateDownloadDone(Result) + case toggleDownloadPause + case toggleDownloadPauseDone(Result) + case validateImageData + case validateImageDataDone(DownloadValidationState?) } @Dependency(\.downloadClient) private var downloadClient var body: some Reducer { + BindingReducer() + Reduce { state, action in switch action { + case .binding: + return .none + case .onAppear: guard state.gid.notEmpty else { return .none } return .merge( @@ -203,22 +219,59 @@ struct DownloadInspectorReducer { } return .none - case .updateDownload: - guard let gid = state.inspection?.download.gid else { return .none } + case .toggleDownloadPause: + guard let download = state.inspection?.download, + download.canTogglePause + else { return .none } return .run { send in - await send(.updateDownloadDone(await downloadClient.retry(gid, .update))) + await send(.toggleDownloadPauseDone(await downloadClient.togglePause(download.gid))) } - case .updateDownloadDone(let result): + case .toggleDownloadPauseDone(let result): if case .failure = result { return .send(.loadInspection) } return .none + + case .validateImageData: + guard state.gid.notEmpty, + state.inspection?.canValidateImageData == true, + !state.isValidatingImageData + else { return .none } + state.isValidatingImageData = true + return .run { [gid = state.gid] send in + await send(.validateImageDataDone(await downloadClient.validateImageData(gid))) + } + + case .validateImageDataDone(let validation): + state.isValidatingImageData = false + state.hudConfig = validation.hudConfig + state.route = .hud + return .send(.loadInspection) } } } } +private extension Optional where Wrapped == DownloadValidationState { + var hudConfig: ProgressHUDConfigState { + switch self { + case .some(.valid): + return .success( + caption: L10n.Localizable.DownloadsView.Inspector.Hud.imageDataValid + ) + + case .some(.missingFiles(let message)): + return .error(caption: message) + + case nil: + return .error( + caption: L10n.Localizable.DownloadsView.Inspector.Hud.imageDataUnavailable + ) + } + } +} + extension DownloadInspectorReducer.State { func shouldKeepRetryPending(for download: DownloadedGallery) -> Bool { download.canPauseOrResume diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index 1cb136de0..89774fdf9 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -67,8 +67,6 @@ struct DownloadsReducer { case observeDownloadsDone([DownloadedGallery]) case refreshDownloads case refreshDownloadsDone - case validateImageData - case validateImageDataDone case openReading(String) case openReadingDone(UUID, String, Result<(DownloadedGallery, DownloadManifest), AppError>) case toggleDownloadPause(String) @@ -172,15 +170,6 @@ struct DownloadsReducer { case .refreshDownloadsDone: return .none - case .validateImageData: - return .run { send in - await downloadClient.validateImageData() - await send(.validateImageDataDone) - } - - case .validateImageDataDone: - return .none - case .openReading(let gid): let requestID = UUID() state.readingRequestID = requestID diff --git a/EhPanda/View/Downloads/DownloadsView+Subviews.swift b/EhPanda/View/Downloads/DownloadsView+Subviews.swift index 06e01bcb4..0711263de 100644 --- a/EhPanda/View/Downloads/DownloadsView+Subviews.swift +++ b/EhPanda/View/Downloads/DownloadsView+Subviews.swift @@ -9,6 +9,7 @@ import ComposableArchitecture struct DownloadInspectorView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion @Bindable private var store: StoreOf private let setting: Setting @@ -56,56 +57,70 @@ struct DownloadInspectorView: View { .listRowBackground(Color.clear) } - if !inspection.failedPageIndices.isEmpty || inspection.download.canTriggerUpdate { - Section(L10n.Localizable.DownloadsView.Inspector.Section.actions) { - if !inspection.failedPageIndices.isEmpty { - Button { - store.send(.retryFailedPages) - } label: { - Label( - L10n.Localizable.DownloadsView.Inspector.Button.retryFailedPages( - inspection.failedPageIndices.count - ), - systemImage: "arrow.clockwise.circle" - ) - } - } - - if inspection.download.canTriggerUpdate { - Button { - store.send(.updateDownload) - } label: { - Label( - L10n.Localizable.DownloadsView.Inspector.Button.updateDownload, - systemImage: "arrow.triangle.2.circlepath" - ) - } - } - } - } - - ForEach(DownloadPageStatus.allCases, id: \.self) { status in - let pages = inspection.pages.filter { $0.status == status } - Section(status.sectionTitle(count: pages.count)) { + Section { + ForEach(DownloadPageStatus.inspectorSummaryOrder, id: \.self) { status in + let pages = inspection.pages.filter { $0.status == status } DownloadInspectorPageGroupRow( status: status, pages: pages ) } } + + let isPauseResumeDisabled = !inspection.download.canTogglePause + let isRetryFailedPagesDisabled = !inspection.canRetryFailedPages + let isValidateImageDataDisabled = + !inspection.canValidateImageData || store.isValidatingImageData + Section(L10n.Localizable.DownloadsView.Inspector.Section.actions) { + Button { + store.send(.toggleDownloadPause) + } label: { + Label( + inspection.download.inspectorPauseResumeTitle, + systemSymbol: inspection.download.inspectorPauseResumeSymbol + ) + .disabledActionForegroundStyle(isPauseResumeDisabled) + } + .disabled(isPauseResumeDisabled) + + Button { + store.send(.retryFailedPages) + } label: { + Label( + L10n.Localizable.DownloadsView.Inspector.Button.retryFailedPages, + systemSymbol: .arrowClockwise + ) + .disabledActionForegroundStyle(isRetryFailedPagesDisabled) + } + .disabled(isRetryFailedPagesDisabled) + + Button { + store.send(.validateImageData) + } label: { + DownloadInspectorValidationActionLabel( + isValidating: store.isValidatingImageData, + isDisabled: isValidateImageDataDisabled, + reduceMotion: reduceMotion + ) + } + .disabled(isValidateImageDataDisabled) + } } } .listStyle(.insetGrouped) } } .autoBlur(radius: blurRadius) + .progressHUD( + config: store.hudConfig, + unwrapping: $store.route, + case: \.hud + ) .navigationTitle(L10n.Localizable.DownloadsView.Inspector.Title.downloadStatus) .navigationBarTitleDisplayMode(.inline) .toolbar { - CustomToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.EhSettingView.ToolbarItem.Button.done) { - dismiss() - } + ToolbarItem(placement: .cancellationAction) { + Button(role: .close, action: dismiss.callAsFunction) } } .onAppear { @@ -114,10 +129,51 @@ struct DownloadInspectorView: View { } } +private struct DownloadInspectorValidationActionLabel: View { + let isValidating: Bool + let isDisabled: Bool + let reduceMotion: Bool + + private var title: String { + isValidating + ? L10n.Localizable.DownloadsView.Inspector.Button.validatingImageData + : L10n.Localizable.DownloadsView.Button.validateImageData + } + + private var progressAnimation: Animation? { + reduceMotion ? nil : .easeInOut(duration: 0.2) + } + + var body: some View { + HStack { + Label(title, systemSymbol: .checkmarkShield) + Spacer(minLength: 12) + ZStack { + if isValidating { + ProgressView() + .controlSize(.small) + .transition( + .opacity.combined(with: .scale(scale: 0.85)) + ) + } + } + .frame(width: 20, height: 20) + } + .disabledActionForegroundStyle(isDisabled) + .animation(progressAnimation, value: isValidating) + } +} + struct DownloadInspectorPageGroupRow: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let status: DownloadPageStatus let pages: [DownloadPageInspection] + private var countAnimation: Animation? { + reduceMotion ? nil : .easeInOut(duration: 0.2) + } + private var pageNumbersText: String { let indices = pages.map(\.index).sorted() guard !indices.isEmpty else { @@ -128,16 +184,27 @@ struct DownloadInspectorPageGroupRow: View { var body: some View { HStack(alignment: .top, spacing: 12) { - Image(systemName: status.symbolName) - .foregroundStyle(status.tint) + Image(systemSymbol: status.symbol) + .foregroundStyle(status.tintColor) .font(.title3) - .frame(width: 24) + .labelReservedIconWidth(24) - Text(pageNumbersText) - .font(.callout) - .foregroundStyle(pages.isEmpty ? .secondary : .primary) - .lineLimit(nil) - .textSelection(.enabled) + VStack(alignment: .leading, spacing: 3) { + Text(status.summaryTitle(count: pages.count)) + .font(.body.weight(.medium)) + .monospacedDigit() + .contentTransition(.numericText()) + .animation(countAnimation, value: pages.count) + + Text(pageNumbersText) + .font(.callout) + .monospacedDigit() + .contentTransition(.numericText()) + .foregroundStyle(pages.isEmpty ? .secondary : .primary) + .lineLimit(nil) + .textSelection(.enabled) + .animation(countAnimation, value: pageNumbersText) + } } .padding(.vertical, 4) .accessibilityElement(children: .combine) @@ -171,6 +238,12 @@ struct DownloadInspectorPageGroupRow: View { } private extension DownloadPageStatus { + static let inspectorSummaryOrder: [Self] = [ + .downloaded, + .pending, + .failed + ] + var title: String { switch self { case .pending: @@ -182,29 +255,46 @@ private extension DownloadPageStatus { } } - func sectionTitle(count: Int) -> String { + func summaryTitle(count: Int) -> String { "\(title) (\(count))" } - var symbolName: String { + var symbol: SFSymbol { switch self { - case .pending: - return "clock" - case .downloaded: - return "checkmark.circle.fill" - case .failed: - return "exclamationmark.circle.fill" + case .pending: .clock + case .downloaded: .checkmarkCircle + case .failed: .exclamationmarkCircle } } - var tint: Color { + var tintColor: Color { switch self { - case .pending: - return .secondary - case .downloaded: - return .green - case .failed: - return .red + case .pending: .primary + case .downloaded: .green + case .failed: .red + } + } +} + +private extension DownloadedGallery { + var inspectorPauseResumeTitle: String { + status == .paused + ? L10n.Localizable.DownloadsView.Swipe.Button.resume + : L10n.Localizable.DownloadsView.Swipe.Button.pause + } + + var inspectorPauseResumeSymbol: SFSymbol { + status == .paused ? .playFill : .pauseFill + } +} + +private extension View { + @ViewBuilder + func disabledActionForegroundStyle(_ isDisabled: Bool) -> some View { + if isDisabled { + foregroundStyle(.secondary) + } else { + self } } } @@ -241,14 +331,14 @@ struct DownloadInspectorPageRow: View { let page: DownloadPageInspection let retryAction: () -> Void - private var symbolName: String { + private var symbol: SFSymbol { switch page.status { case .pending: - return "clock" + return .clock case .downloaded: - return "checkmark.circle.fill" + return .checkmarkCircle case .failed: - return "exclamationmark.circle.fill" + return .exclamationmarkCircle } } @@ -289,7 +379,7 @@ struct DownloadInspectorPageRow: View { private var rowContent: some View { HStack(spacing: 12) { - Image(systemName: symbolName) + Image(systemSymbol: symbol) .foregroundStyle(tint) .font(.title3) VStack(alignment: .leading, spacing: 4) { diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift index a3dc20209..e943150b6 100644 --- a/EhPanda/View/Downloads/DownloadsView.swift +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -155,7 +155,7 @@ struct DownloadsView: View { switch dialog { case .delete(let download): Text( - download.canPauseOrResume || download.isPendingQueue + download.canTogglePause ? L10n.Localizable.DownloadsView.Dialog.Message.deleteActiveDownload : L10n.Localizable.DownloadsView.Dialog.Message.deleteDownloadedGallery ) @@ -216,7 +216,7 @@ private extension DownloadsView { .tint(.orange) } - if download.canPauseOrResume || download.isPendingQueue { + if download.canTogglePause { Button { store.send(.toggleDownloadPause(download.gid)) } label: { @@ -274,7 +274,7 @@ private extension DownloadsView { } } - if download.canPauseOrResume || download.isPendingQueue { + if download.canTogglePause { Button { store.send(.toggleDownloadPause(download.gid)) } label: { @@ -347,38 +347,9 @@ private extension DownloadsView { } } } label: { - Image(systemSymbol: .line3HorizontalDecreaseCircle) + Image(systemSymbol: .dialLow) .symbolRenderingMode(.hierarchical) } - - ToolbarFeaturesMenu { - FiltersButton { - store.send(.setNavigation(.filters())) - } - QuickSearchButton { - store.send(.setNavigation(.quickSearch())) - } - Button { - store.send(.validateImageData) - } label: { - Label( - L10n.Localizable.DownloadsView.Button.validateImageData, - systemImage: "checkmark.shield" - ) - } - if store.filter != .all || store.keyword.notEmpty || store.galleryFilter.hasActiveValues { - Button { - store.filter = .all - store.keyword = "" - store.galleryFilter.reset() - } label: { - Label( - L10n.Localizable.DownloadsView.Button.clearFilters, - systemSymbol: .arrowCounterclockwise - ) - } - } - } } } } diff --git a/EhPanda/View/Setting/Components/DownloadSettingView.swift b/EhPanda/View/Setting/Components/DownloadSettingView.swift index ec201ee27..7a00a257e 100644 --- a/EhPanda/View/Setting/Components/DownloadSettingView.swift +++ b/EhPanda/View/Setting/Components/DownloadSettingView.swift @@ -22,7 +22,7 @@ struct DownloadSettingView: View { var body: some View { Form { - Section(L10n.Localizable.DownloadSettingView.Section.Title.downloadQueue) { + Section { Picker( L10n.Localizable.DownloadSettingView.Title.concurrentImageDownloads, selection: $downloadThreadMode diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift index 4793ca8b5..3220de698 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -115,6 +115,213 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { #expect(retried.value == [2]) } + @MainActor + @Test + func testDownloadInspectorReducerValidateImageDataUsesCurrentGallery() async { + let validatedGID = UncheckedBox(nil) + let download = sampleDownload( + gid: "112236", title: "Validate Gallery", + status: .completed, pageCount: 2 + ) + let inspection = sampleInspection(download: download) + let refreshedInspection = DownloadInspection( + download: download, + coverURL: inspection.coverURL, + pages: inspection.pages.map { + .init( + index: $0.index, + status: .downloaded, + relativePath: $0.relativePath, + fileURL: $0.fileURL, + failure: nil + ) + } + ) + let store = makeInspectorStore( + gid: download.gid, + initialInspection: inspection, + validateImageData: { gid in + validatedGID.value = gid + return .valid + }, + loadInspection: { gid in + gid == download.gid ? .success(refreshedInspection) : .failure(.notFound) + } + ) + store.exhaustivity = .off + + await store.send(.validateImageData) { + $0.isValidatingImageData = true + } + await store.receive(\.validateImageDataDone) { + $0.isValidatingImageData = false + $0.hudConfig = .success( + caption: L10n.Localizable.DownloadsView.Inspector.Hud.imageDataValid + ) + $0.route = .hud + } + await store.receive(\.loadInspection) + await store.receive(\.loadInspectionDone) { + $0.inspection = refreshedInspection + $0.stableInspection = refreshedInspection + $0.loadingState = .idle + } + + #expect(validatedGID.value == download.gid) + } + + @MainActor + @Test + func testDownloadInspectorReducerTogglePauseUsesCurrentGallery() async { + let toggledGID = UncheckedBox(nil) + let download = sampleDownload( + gid: "112238", title: "Toggle Pause Gallery", + status: .downloading, completedPageCount: 1 + ) + let inspection = sampleInspection(download: download) + let store = makeInspectorStore( + gid: download.gid, + initialInspection: inspection, + togglePause: { gid in + toggledGID.value = gid + return .success(()) + }, + loadInspection: { _ in .success(inspection) } + ) + store.exhaustivity = .off + + await store.send(.toggleDownloadPause) + await store.receive(\.toggleDownloadPauseDone) + + #expect(toggledGID.value == download.gid) + } + + @MainActor + @Test + func testDownloadInspectorReducerTogglePauseUsesQueuedGallery() async { + let toggledGID = UncheckedBox(nil) + let download = sampleDownload( + gid: "112240", title: "Queued Gallery", + status: .queued, completedPageCount: 0 + ) + let inspection = sampleInspection(download: download) + let store = makeInspectorStore( + gid: download.gid, + initialInspection: inspection, + togglePause: { gid in + toggledGID.value = gid + return .success(()) + }, + loadInspection: { _ in .success(inspection) } + ) + store.exhaustivity = .off + + await store.send(.toggleDownloadPause) + await store.receive(\.toggleDownloadPauseDone) + + #expect(toggledGID.value == download.gid) + } + + @MainActor + @Test + func testDownloadInspectorReducerTogglePauseIgnoredForNonPauseableStatus() async { + let didToggle = UncheckedBox(false) + let download = sampleDownload( + gid: "112239", title: "Completed Gallery", + status: .completed, pageCount: 2 + ) + let inspection = sampleInspection(download: download) + let store = makeInspectorStore( + gid: download.gid, + initialInspection: inspection, + togglePause: { _ in + didToggle.value = true + return .success(()) + }, + loadInspection: { _ in .success(inspection) } + ) + store.exhaustivity = .off + + await store.send(.toggleDownloadPause) + + #expect(!didToggle.value) + } + +} + +extension DownloadInspectorLoadTests { + @MainActor + @Test + func testDownloadInspectorReducerValidateImageDataIgnoredWithoutDownloadedPages() async { + let didValidate = UncheckedBox(false) + let download = sampleDownload( + gid: "112237", title: "Validate Empty Gallery", + status: .completed, pageCount: 2 + ) + let inspection = DownloadInspection( + download: download, + coverURL: download.coverURL, + pages: [ + .init( + index: 1, status: .pending, relativePath: nil, + fileURL: nil, failure: nil + ), + .init( + index: 2, status: .failed, relativePath: "pages/0002.jpg", + fileURL: nil, + failure: .init(code: .networkingFailed, message: "Network Error") + ) + ] + ) + let store = makeInspectorStore( + gid: download.gid, + initialInspection: inspection, + validateImageData: { _ in + didValidate.value = true + return .valid + }, + loadInspection: { _ in .success(inspection) } + ) + store.exhaustivity = .off + + await store.send(.validateImageData) + + #expect(!didValidate.value) + } + + @MainActor + @Test + func testDownloadInspectorReducerValidateImageDataShowsMissingFilesHUD() async { + let download = sampleDownload( + gid: "112241", title: "Missing Image Data Gallery", + status: .completed, pageCount: 2 + ) + let inspection = sampleInspection(download: download) + let store = makeInspectorStore( + gid: download.gid, + initialInspection: inspection, + validateImageData: { _ in + .missingFiles("Page 2 image data is corrupted.") + }, + loadInspection: { _ in .success(inspection) } + ) + store.exhaustivity = .off + + await store.send(.validateImageData) { + $0.isValidatingImageData = true + } + await store.receive(\.validateImageDataDone) { + $0.isValidatingImageData = false + $0.hudConfig = .error(caption: "Page 2 image data is corrupted.") + $0.route = .hud + } + await store.receive(\.loadInspection) + await store.receive(\.loadInspectionDone) { + $0.inspection = inspection + $0.stableInspection = inspection + $0.loadingState = .idle + } + } } // MARK: - Store Factory Helpers @@ -124,6 +331,8 @@ private extension DownloadInspectorLoadTests { gid: String, initialInspection: DownloadInspection? = nil, retryPages: (@Sendable (String, [Int]) async -> Result)? = nil, + validateImageData: (@Sendable (String) async -> DownloadValidationState?)? = nil, + togglePause: (@Sendable (String) async -> Result)? = nil, loadInspection: @escaping @Sendable (String) async -> Result ) -> TestStoreOf { var initialState = DownloadInspectorReducer.State(gid: gid) @@ -139,11 +348,12 @@ private extension DownloadInspectorLoadTests { fetchDownloads: { [] }, fetchDownload: { _ in nil }, refreshDownloads: {}, + validateImageData: validateImageData ?? { _ in nil }, resumeQueue: {}, badges: { _ in [:] }, updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, + togglePause: togglePause ?? { _ in .success(()) }, retry: { _, _ in .success(()) }, retryPages: retryPages ?? { _, _ in .success(()) }, delete: { _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift index e112536d9..520c936f8 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -193,43 +193,6 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { #expect(store.state.readingState.contentSource == .local(download, manifest)) } - @MainActor - @Test - func testDownloadsReducerValidateImageDataUsesDownloadClient() async { - let validated = UncheckedBox(false) - let store = TestStore(initialState: DownloadsReducer.State()) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - validateImageData: { - validated.value = true - }, - resumeQueue: {}, - badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - } - store.exhaustivity = .off - - await store.send(.validateImageData) - await store.receive(\.validateImageDataDone) - - #expect(validated.value) - } - @MainActor @Test func testDownloadsReducerTogglePauseActionUsesDownloadClientPause() async { From 786086612c3627015e6da26b614379d18de35247 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 30 May 2026 21:39:43 +0800 Subject: [PATCH 033/614] Resolve double setting pages issue on iPad --- EhPanda/DataFlow/AppReducer.swift | 8 -------- EhPanda/View/TabBar/TabBarView.swift | 8 +++++++- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index 9fe843c9d..b74865971 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -179,14 +179,6 @@ struct AppReducer { effects.append(hapticEffect) } } - if type == .setting { - effects.append( - .run { send in - guard await deviceClient.isPad() else { return } - await send(.appRoute(.setNavigation(.setting()))) - } - ) - } return effects.isEmpty ? .none : .merge(effects) case .tabBar: diff --git a/EhPanda/View/TabBar/TabBarView.swift b/EhPanda/View/TabBar/TabBarView.swift index ae294b555..f70aff1c1 100644 --- a/EhPanda/View/TabBar/TabBarView.swift +++ b/EhPanda/View/TabBar/TabBarView.swift @@ -20,7 +20,13 @@ struct TabBarView: View { TabView( selection: .init( get: { store.tabBarState.tabBarItemType }, - set: { store.send(.tabBar(.setTabBarItemType($0))) } + set: { tab in + if tab == .setting, DeviceUtil.isPad { + store.send(.appRoute(.setNavigation(.setting()))) + } else { + store.send(.tabBar(.setTabBarItemType(tab))) + } + } ) ) { ForEach(TabBarItemType.allCases) { type in From 7f809bb50c27154738af943033dda6c900c78aa8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 30 May 2026 22:51:24 +0800 Subject: [PATCH 034/614] Enforce force_unwrapping rule --- .swiftlint.yml | 4 ++++ EhPanda/App/Tools/Clients/CookieClient.swift | 3 ++- .../Tools/Clients/DownloadClient+Cache.swift | 8 +++---- .../View/Detail/Components/LinkedText.swift | 22 +++++++++++++------ .../View/Detail/DetailReducer+Actions.swift | 2 ++ 5 files changed, 26 insertions(+), 13 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index dc1965641..195d1a263 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -9,10 +9,14 @@ disabled_rules: opt_in_rules: - force_try + - force_unwrapping force_try: severity: error +force_unwrapping: + severity: error + line_length: warning: 120 error: 120 diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift index eaed795bb..fd1d5fa27 100644 --- a/EhPanda/App/Tools/Clients/CookieClient.swift +++ b/EhPanda/App/Tools/Clients/CookieClient.swift @@ -31,7 +31,8 @@ extension CookieClient { cookies.forEach { cookie in guard cookie.name == key && !cookie.value.isEmpty else { return } - guard cookie.expiresDate == nil || cookie.expiresDate! > .now else { + if let expiresDate = cookie.expiresDate, + expiresDate <= .now { value = CookieValue( rawValue: "", localizedString: L10n.Localizable.Struct.CookieValue.LocalizedString.expired ) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 9422f5098..3ddbb4be0 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -132,11 +132,7 @@ extension DownloadManager { let relativePath: String if let preferredRelativePath { relativePath = preferredRelativePath - } else { - let fallbackURL = source.referenceURL - ?? URL( - string: "https://example.com/\(index).jpg" - )! + } else if let fallbackURL = source.referenceURL { let ext = fileExtension( for: fallbackURL, response: nil, @@ -146,6 +142,8 @@ extension DownloadManager { index: index, fileExtension: ext ) + } else { + return nil } let fileURL = folderURL diff --git a/EhPanda/View/Detail/Components/LinkedText.swift b/EhPanda/View/Detail/Components/LinkedText.swift index 5250e0734..189cff6c4 100644 --- a/EhPanda/View/Detail/Components/LinkedText.swift +++ b/EhPanda/View/Detail/Components/LinkedText.swift @@ -31,7 +31,12 @@ private struct LinkColoredText: View { ) components.append(.text(trimmedText)) } - components.append(.link(nsText.substring(with: result.range), result.url!)) + let linkText = nsText.substring(with: result.range) + if let url = result.url { + components.append(.link(linkText, url)) + } else { + components.append(.text(linkText)) + } index = result.range.location + result.range.length } @@ -110,8 +115,9 @@ private struct LinkTapOverlay: UIViewRepresentable { let attributedString = NSAttributedString( string: text, attributes: [.font: UIFont.preferredFont(forTextStyle: .body)] ) - context.coordinator.textStorage = NSTextStorage(attributedString: attributedString) - context.coordinator.textStorage!.addLayoutManager(context.coordinator.layoutManager) + let textStorage = NSTextStorage(attributedString: attributedString) + textStorage.addLayoutManager(context.coordinator.layoutManager) + context.coordinator.textStorage = textStorage } func makeCoordinator() -> Coordinator { @@ -135,13 +141,15 @@ private struct LinkTapOverlay: UIViewRepresentable { } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - let location = touch.location(in: gestureRecognizer.view!) + guard let view = gestureRecognizer.view else { return false } + let location = touch.location(in: view) let result = link(at: location) return result != nil } @objc func didTapLabel(_ gesture: UITapGestureRecognizer) { - let location = gesture.location(in: gesture.view!) + guard let view = gesture.view else { return } + let location = gesture.location(in: view) guard let result = link(at: location) else { return } @@ -170,13 +178,13 @@ private struct LinkTapOverlay: UIViewRepresentable { } private final class LinkTapOverlayView: UIView { - var textContainer: NSTextContainer! + var textContainer: NSTextContainer? override func layoutSubviews() { super.layoutSubviews() var newSize = bounds.size newSize.height += 20 // need some extra space here to actually get the last line - textContainer.size = newSize + textContainer?.size = newSize } } diff --git a/EhPanda/View/Detail/DetailReducer+Actions.swift b/EhPanda/View/Detail/DetailReducer+Actions.swift index 1041e70a3..7521d3ded 100644 --- a/EhPanda/View/Detail/DetailReducer+Actions.swift +++ b/EhPanda/View/Detail/DetailReducer+Actions.swift @@ -189,6 +189,7 @@ extension DetailReducer { case .comments(.detail(let recursiveAction)): guard state.commentsState.wrappedValue != nil else { return .none } let effect = reducer._reduce( + // swiftlint:disable:next force_unwrapping into: &state.commentsState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction ) return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) @@ -199,6 +200,7 @@ extension DetailReducer { case .detailSearch(.detail(let recursiveAction)): guard state.detailSearchState.wrappedValue != nil else { return .none } let effect = reducer._reduce( + // swiftlint:disable:next force_unwrapping into: &state.detailSearchState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction ) return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) From f916376875d40ffda3712fe348fd0779f44df9a2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 30 May 2026 22:59:20 +0800 Subject: [PATCH 035/614] Fix test workflow --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b97ba67bb..1bd40ae33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,5 +22,6 @@ jobs: - name: Run tests run: xcodebuild clean test -skipMacroValidation + -skipPackagePluginValidation -scheme ${{ env.SCHEME_NAME }} -destination 'platform=iOS Simulator,name=iPhone Air' From 05f638da01fb4475e375f359bf378cceb124c130 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:22:53 +0800 Subject: [PATCH 036/614] Guard page range --- EhPanda/App/Tools/Utilities/URLUtil.swift | 9 ++- .../DownloadFilterAndBadgeTests.swift | 61 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/EhPanda/App/Tools/Utilities/URLUtil.swift b/EhPanda/App/Tools/Utilities/URLUtil.swift index f751dda9c..81e8548c8 100644 --- a/EhPanda/App/Tools/Utilities/URLUtil.swift +++ b/EhPanda/App/Tools/Utilities/URLUtil.swift @@ -213,10 +213,13 @@ private extension URL { queryItems2[.fSp] = .filterOn let minPages = Int(filter.pageLowerBound) let maxPages = Int(filter.pageUpperBound) - if let minPages, minPages > 0 { + if let minPages, let maxPages { + guard minPages > 0, maxPages > 0, minPages <= maxPages else { return } queryItems1[.fSpf] = String(minPages) - } - if let maxPages, maxPages > 0 { + queryItems1[.fSpt] = String(maxPages) + } else if let minPages, minPages > 0 { + queryItems1[.fSpf] = String(minPages) + } else if let maxPages, maxPages > 0 { queryItems1[.fSpt] = String(maxPages) } } diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 3ebffb375..afb1f57b7 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -166,6 +166,59 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { #expect(state.filteredDownloads == [qualifyingDownload]) } + @Test + func testSearchPageRangeFilterOmitsInvertedBounds() { + var filter = Filter() + filter.advanced = true + filter.pageRangeActivated = true + filter.pageLowerBound = "50" + filter.pageUpperBound = "10" + + let queryItems = queryItems(for: URLUtil.frontpageList(filter: filter)) + + #expect(queryItems["f_sp"] == "on") + #expect(queryItems["f_spf"] == nil) + #expect(queryItems["f_spt"] == nil) + } + + @Test + func testSearchPageRangeFilterKeepsValidBounds() { + var filter = Filter() + filter.advanced = true + filter.pageRangeActivated = true + filter.pageLowerBound = "10" + filter.pageUpperBound = "50" + + let queryItems = queryItems(for: URLUtil.frontpageList(filter: filter)) + + #expect(queryItems["f_sp"] == "on") + #expect(queryItems["f_spf"] == "10") + #expect(queryItems["f_spt"] == "50") + } + + @Test + func testSearchPageRangeFilterKeepsSingleBounds() { + var lowerOnlyFilter = Filter() + lowerOnlyFilter.advanced = true + lowerOnlyFilter.pageRangeActivated = true + lowerOnlyFilter.pageLowerBound = "10" + + var upperOnlyFilter = Filter() + upperOnlyFilter.advanced = true + upperOnlyFilter.pageRangeActivated = true + upperOnlyFilter.pageUpperBound = "50" + + let lowerOnlyQueryItems = queryItems(for: URLUtil.frontpageList(filter: lowerOnlyFilter)) + let upperOnlyQueryItems = queryItems(for: URLUtil.frontpageList(filter: upperOnlyFilter)) + + #expect(lowerOnlyQueryItems["f_sp"] == "on") + #expect(lowerOnlyQueryItems["f_spf"] == "10") + #expect(lowerOnlyQueryItems["f_spt"] == nil) + #expect(upperOnlyQueryItems["f_sp"] == "on") + #expect(upperOnlyQueryItems["f_spf"] == nil) + #expect(upperOnlyQueryItems["f_spt"] == "50") + } + @Test func testDownloadsFilterExcludesSelectedCategoriesLikeSearchFilter() { let nonHDownload = sampleDownload( @@ -201,4 +254,12 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { #expect(partialDownload.badge.text == "Needs Attention 5/12") #expect(DownloadListFilter.failed.title == "Needs Attention") } + + private func queryItems(for url: URL) -> [String: String] { + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .reduce(into: [String: String]()) { result, item in + result[item.name] = item.value + } ?? [:] + } } From 428c412fd13803f4a60ec3e2e318f01940158480 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:24:21 +0800 Subject: [PATCH 037/614] Check LiveText cancel --- EhPanda/View/Reading/Support/LiveTextHandler.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/EhPanda/View/Reading/Support/LiveTextHandler.swift b/EhPanda/View/Reading/Support/LiveTextHandler.swift index 5c6d3ebae..4e4d29721 100644 --- a/EhPanda/View/Reading/Support/LiveTextHandler.swift +++ b/EhPanda/View/Reading/Support/LiveTextHandler.swift @@ -85,6 +85,8 @@ final class LiveTextHandler { } let observations = try await request.perform(on: cgImage) + try Task.checkCancellation() + let blocks: [LiveTextBlock] = observations.compactMap { observation in guard let recognizedText = observation.topCandidates(1).first?.string else { return nil } return .init( @@ -99,7 +101,9 @@ final class LiveTextHandler { } var groupData = [[LiveTextBlock]]() - blocks.forEach { newItem in + for newItem in blocks { + try Task.checkCancellation() + if let groupIndex = groupData.firstIndex(where: { items in items.first { item in let angle = abs(item.bounds.getAngle(size) - newItem.bounds.getAngle(size)) From bd0edd59426b5c2b7b41a998aa18c5cdc4fd25bc Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:25:16 +0800 Subject: [PATCH 038/614] Keep LiveText handle --- EhPanda/View/Reading/Support/LiveTextHandler.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/EhPanda/View/Reading/Support/LiveTextHandler.swift b/EhPanda/View/Reading/Support/LiveTextHandler.swift index 4e4d29721..a7e4e46d9 100644 --- a/EhPanda/View/Reading/Support/LiveTextHandler.swift +++ b/EhPanda/View/Reading/Support/LiveTextHandler.swift @@ -66,7 +66,6 @@ final class LiveTextHandler { "error": error, "index": index ]) } - self?.analysisTasks[index] = nil } } From 5492bbfc1ac59184dcba77aa965cdc56b034600b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:29:51 +0800 Subject: [PATCH 039/614] Merge URL batches --- .../App/Tools/Clients/DatabaseClient.swift | 69 +++++++++++++----- .../Download/DatabaseClientUpdateTests.swift | 71 +++++++++++++++++++ 2 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 EhPandaTests/Tests/Download/DatabaseClientUpdateTests.swift diff --git a/EhPanda/App/Tools/Clients/DatabaseClient.swift b/EhPanda/App/Tools/Clients/DatabaseClient.swift index 910cc7fc5..47eb72f07 100644 --- a/EhPanda/App/Tools/Clients/DatabaseClient.swift +++ b/EhPanda/App/Tools/Clients/DatabaseClient.swift @@ -11,6 +11,7 @@ import ComposableArchitecture struct DatabaseClient: Sendable { let prepareDatabase: @Sendable () async -> Result let dropDatabase: @Sendable () async -> Result + private let viewContext: @Sendable () -> NSManagedObjectContext private let saveContext: @Sendable () -> Void private let materializedObjects: @Sendable (NSManagedObjectContext, NSPredicate) -> [NSManagedObject] @@ -32,6 +33,9 @@ extension DatabaseClient { } } }, + viewContext: { + PersistenceController.shared.container.viewContext + }, saveContext: { let context = PersistenceController.shared.container.viewContext AppUtil.dispatchMainSync { @@ -44,17 +48,43 @@ extension DatabaseClient { } } }, - materializedObjects: { context, predicate in - var objects = [NSManagedObject]() - for object in context.registeredObjects where !object.isFault { - guard object.entity.attributesByName.keys.contains("gid"), - predicate.evaluate(with: object) - else { continue } - objects.append(object) - } - return objects - } + materializedObjects: materializedObjectsFromContext ) + + static func live(persistenceContainer container: NSPersistentContainer) -> Self { + .init( + prepareDatabase: { .success(()) }, + dropDatabase: { .success(()) }, + viewContext: { + container.viewContext + }, + saveContext: { + let context = container.viewContext + AppUtil.dispatchMainSync { + guard context.hasChanges else { return } + do { + try context.save() + } catch { + Logger.error(error) + fatalError("Unresolved error \(error)") + } + } + }, + materializedObjects: materializedObjectsFromContext + ) + } + + private static let materializedObjectsFromContext: + @Sendable (NSManagedObjectContext, NSPredicate) -> [NSManagedObject] = { context, predicate in + var objects = [NSManagedObject]() + for object in context.registeredObjects where !object.isFault { + guard object.entity.attributesByName.keys.contains("gid"), + predicate.evaluate(with: object) + else { continue } + objects.append(object) + } + return objects + } } // MARK: Foundation @@ -64,7 +94,7 @@ extension DatabaseClient { findBeforeFetch: Bool = true, sortDescriptors: [NSSortDescriptor]? = nil ) -> [MO] { var results = [MO]() - let context = PersistenceController.shared.container.viewContext + let context = viewContext() AppUtil.dispatchMainSync { if findBeforeFetch, let predicate = predicate { if let objects = materializedObjects(context, predicate) as? [MO], !objects.isEmpty { @@ -104,7 +134,7 @@ extension DatabaseClient { ) { return storedMO } else { - let newMO = MO(context: PersistenceController.shared.container.viewContext) + let newMO = MO(context: viewContext()) commitChanges?(newMO) saveContext() return newMO @@ -181,8 +211,11 @@ extension DatabaseClient { // MARK: GalleryState Helpers extension DatabaseClient { - func update(gid: String, storedData: inout Data?, new: T) { - storedData = new.toData() + func update(gid: String, storedData: inout Data?, new: [Int: T]) { + guard !new.isEmpty, gid.isValidGID else { return } + storedData = ((storedData?.toObject() as [Int: T]?) ?? [:]) + .merging(new, uniquingKeysWith: { _, new in new }) + .toData() } } @@ -292,7 +325,7 @@ extension DatabaseClient { } } if storedMO == nil { - gallery.toManagedObject(in: PersistenceController.shared.container.viewContext) + gallery.toManagedObject(in: viewContext()) } } saveContext() @@ -327,7 +360,7 @@ extension DatabaseClient { managedObject?.uploader = detail.uploader } if storedMO == nil { - detail.toManagedObject(in: PersistenceController.shared.container.viewContext) + detail.toManagedObject(in: viewContext()) } saveContext() } @@ -354,6 +387,9 @@ extension DatabaseClient { static let noop: Self = .init( prepareDatabase: { .success(()) }, dropDatabase: { .success(()) }, + viewContext: { + PersistenceController.shared.container.viewContext + }, saveContext: {}, materializedObjects: { _, _ in .init() } ) @@ -363,6 +399,7 @@ extension DatabaseClient { static let unimplemented: Self = .init( prepareDatabase: IssueReporting.unimplemented(placeholder: placeholder()), dropDatabase: IssueReporting.unimplemented(placeholder: placeholder()), + viewContext: IssueReporting.unimplemented(placeholder: placeholder()), saveContext: IssueReporting.unimplemented(placeholder: placeholder()), materializedObjects: IssueReporting.unimplemented(placeholder: placeholder()) ) diff --git a/EhPandaTests/Tests/Download/DatabaseClientUpdateTests.swift b/EhPandaTests/Tests/Download/DatabaseClientUpdateTests.swift new file mode 100644 index 000000000..6df10ad11 --- /dev/null +++ b/EhPandaTests/Tests/Download/DatabaseClientUpdateTests.swift @@ -0,0 +1,71 @@ +// +// DatabaseClientUpdateTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +struct DatabaseClientUpdateTests: DownloadFeatureTestCase { + @MainActor + @Test + func testURLUpdatesMergePageBatches() async throws { + let container = try makeInMemoryContainer() + let databaseClient = DatabaseClient.live(persistenceContainer: container) + let gid = "123456" + + let imageURLs = try urlBatches(path: "image") + let originalImageURLs = try urlBatches(path: "original") + let thumbnailURLs = try urlBatches(path: "thumbnail") + let previewURLs = try urlBatches(path: "preview") + + databaseClient.updateImageURLs( + gid: gid, + imageURLs: imageURLs.first, + originalImageURLs: originalImageURLs.first + ) + databaseClient.updateThumbnailURLs(gid: gid, thumbnailURLs: thumbnailURLs.first) + databaseClient.updatePreviewURLs(gid: gid, previewURLs: previewURLs.first) + + databaseClient.updateImageURLs( + gid: gid, + imageURLs: imageURLs.second, + originalImageURLs: originalImageURLs.second + ) + databaseClient.updateThumbnailURLs(gid: gid, thumbnailURLs: thumbnailURLs.second) + databaseClient.updatePreviewURLs(gid: gid, previewURLs: previewURLs.second) + + let galleryState = try #require(await databaseClient.fetchGalleryState(gid: gid)) + + #expect(galleryState.imageURLs == imageURLs.merged) + #expect(galleryState.originalImageURLs == originalImageURLs.merged) + #expect(galleryState.thumbnailURLs == thumbnailURLs.merged) + #expect(galleryState.previewURLs == previewURLs.merged) + } + + private func urlBatches( + path: String + ) throws -> URLBatches { + let first = [ + 1: try #require(URL(string: "https://example.com/\(path)-1.jpg")), + 2: try #require(URL(string: "https://example.com/\(path)-2.jpg")) + ] + let second = [ + 3: try #require(URL(string: "https://example.com/\(path)-3.jpg")), + 4: try #require(URL(string: "https://example.com/\(path)-4.jpg")) + ] + + return .init( + first: first, + second: second, + merged: first.merging(second, uniquingKeysWith: { _, new in new }) + ) + } +} + +private struct URLBatches { + let first: [Int: URL] + let second: [Int: URL] + let merged: [Int: URL] +} From 1b5fcfb1c07ef8b1d65d3dbf9998f4c05de8e277 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:37:44 +0800 Subject: [PATCH 040/614] Seal scheduler race --- .../Clients/DownloadClient+Manager.swift | 4 + .../Clients/DownloadClient+Persistence.swift | 7 +- .../Clients/DownloadClient+Scheduling.swift | 5 +- .../Clients/DownloadClient+Testing.swift | 14 +++ .../DownloadFeatureTestSupportTypes.swift | 21 ++++ .../Download/DownloadSchedulingTests.swift | 95 +++++++++++++++++++ 6 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 EhPandaTests/Tests/Download/DownloadSchedulingTests.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 406662811..9556658ad 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -140,6 +140,10 @@ actor DownloadManager { var activeGalleryID: String? var activeTask: Task? var schedulingBlockedGalleryIDs = Set() +#if DEBUG + var testingFetchDownloadsFromStoreHook: (@Sendable () async -> Void)? + var testingScheduledGalleryIDHistory = [String]() +#endif init( storage: DownloadFileStorage, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 07bd4115b..a54926164 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -26,7 +26,12 @@ extension DownloadManager { } func fetchDownloadsFromStore() async -> [DownloadedGallery] { - await MainActor.run { +#if DEBUG + if let testingFetchDownloadsFromStoreHook { + await testingFetchDownloadsFromStoreHook() + } +#endif + return await MainActor.run { let context = persistenceContainer.viewContext let request = NSFetchRequest( entityName: "DownloadedGalleryMO" diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index fd482c42a..6dd62eb43 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -29,11 +29,11 @@ extension DownloadManager { } func scheduleNextIfNeeded() async { + let downloads = await fetchDownloadsFromStore() guard activeTask == nil else { await reconcileActiveDownloadState() return } - let downloads = await fetchDownloadsFromStore() let nextDownload = downloads .filter { !schedulingBlockedGalleryIDs.contains($0.gid) @@ -51,6 +51,9 @@ extension DownloadManager { .first guard let nextDownload else { return } +#if DEBUG + testingScheduledGalleryIDHistory.append(nextDownload.gid) +#endif activeGalleryID = nextDownload.gid activeTask = Task { [weak self] in guard let self else { return } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index dc1e2115b..afbb6107a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -19,6 +19,20 @@ extension DownloadManager { await scheduleNextIfNeeded() } + func testingSetFetchDownloadsFromStoreHook( + _ hook: (@Sendable () async -> Void)? + ) { + testingFetchDownloadsFromStoreHook = hook + } + + func testingScheduledGalleryIDs() -> [String] { + testingScheduledGalleryIDHistory + } + + func testingHasActiveTask() -> Bool { + activeTask != nil + } + func testingFetchDownload( gid: String ) async -> DownloadedGallery? { diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift index eb7a06471..b411e7a5b 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift @@ -124,6 +124,27 @@ final class FailFastURLProtocol: URLProtocol { override func stopLoading() {} } +final class HangingURLProtocol: URLProtocol { + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest( + for request: URLRequest + ) -> URLRequest { + request + } + + override func startLoading() {} + + override func stopLoading() { + client?.urlProtocol( + self, + didFailWithError: URLError(.cancelled) + ) + } +} + final class SharedSessionStubURLProtocol: URLProtocol { static let headerKey = "X-TestSession-ID" diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift new file mode 100644 index 000000000..7670cd89b --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -0,0 +1,95 @@ +// +// DownloadSchedulingTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +@Suite +struct DownloadSchedulingTests: DownloadFeatureTestCase { + @Test + func testConcurrentSchedulingCreatesOnlyOneActiveTask() async throws { + let container = try makeInMemoryContainer() + let gid = "100001" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [HangingURLProtocol.self] + let manager = DownloadManager( + storage: DownloadFileStorage( + rootURL: rootURL, + fileManager: .default + ), + urlSession: URLSession(configuration: configuration), + persistenceContainer: container + ) + + try insertPersistedDownload( + in: container, + gid: gid, + status: .queued, + completedPageCount: 0 + ) + + let gate = ScheduleFetchGate() + await manager.testingSetFetchDownloadsFromStoreHook { + await gate.waitAtGate() + } + + async let firstSchedule: Void = + manager.testingScheduleNextIfNeeded() + async let secondSchedule: Void = + manager.testingScheduleNextIfNeeded() + + await gate.waitForBothArrivals() + await gate.releaseAll() + _ = await (firstSchedule, secondSchedule) + await manager.testingSetFetchDownloadsFromStoreHook(nil) + + let scheduledGalleryIDs = await manager + .testingScheduledGalleryIDs() + let hasActiveTask = await manager.testingHasActiveTask() + let activeGalleryID = await manager.testingActiveGalleryID() + #expect(scheduledGalleryIDs.count == 1) + #expect(hasActiveTask) + #expect(scheduledGalleryIDs.first == activeGalleryID) + + guard case .success = await manager.pause(gid: gid) else { + Issue.record("Pause should succeed for the active test download.") + return + } + } +} + +private actor ScheduleFetchGate { + private var arrivalCount = 0 + private var bothArrivedContinuation: CheckedContinuation? + private var releaseContinuations = [CheckedContinuation]() + + func waitAtGate() async { + arrivalCount += 1 + if arrivalCount == 2 { + bothArrivedContinuation?.resume() + bothArrivedContinuation = nil + } + await withCheckedContinuation { continuation in + releaseContinuations.append(continuation) + } + } + + func waitForBothArrivals() async { + guard arrivalCount < 2 else { return } + await withCheckedContinuation { continuation in + bothArrivedContinuation = continuation + } + } + + func releaseAll() { + releaseContinuations.forEach { $0.resume() } + releaseContinuations.removeAll() + } +} From fa31183d99c4f938d6af9109146c5b75d01f2218 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:41:25 +0800 Subject: [PATCH 041/614] Order failure settle --- .../Clients/DownloadClient+Execution.swift | 49 ++++----- .../Clients/DownloadClient+Manager.swift | 1 + .../Clients/DownloadClient+Persistence.swift | 5 + .../Clients/DownloadClient+Testing.swift | 6 ++ .../Tests/Download/DownloadProcessTests.swift | 99 +++++++++++++++++++ 5 files changed, 137 insertions(+), 23 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index e55516aaa..f1e9a62dc 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -53,7 +53,7 @@ extension DownloadManager { hadReadableFiles: hadReadableFiles, latestSignature: fetchedVersionSignature ) - handleProcessDownloadError(error: error, context: context) + await handleProcessDownloadError(error: error, context: context) } } @@ -80,13 +80,22 @@ extension DownloadManager { private func handleProcessDownloadError( error: Error, context: FailureContext - ) { + ) async { if let appError = error as? AppError { - handleProcessDownloadAppError(error: appError, context: context) + await handleProcessDownloadAppError( + error: appError, + context: context + ) } else if let partialError = error as? PartialDownloadError { - handleProcessDownloadPartialError(error: partialError, context: context) + await handleProcessDownloadPartialError( + error: partialError, + context: context + ) } else { - handleProcessDownloadGenericError(error: error, context: context) + await handleProcessDownloadGenericError( + error: error, + context: context + ) } } @@ -156,7 +165,7 @@ extension DownloadManager { private func handleProcessDownloadAppError( error: AppError, context: FailureContext - ) { + ) async { guard !isCancellationLikeAppError(error) else { return } guard !shouldSuppressFailurePersistence(for: context.gid) else { return @@ -169,16 +178,14 @@ extension DownloadManager { "error": error.localizedDescription ] ) - Task { - await persistFailure(error: error, context: context) - await notifyObservers() - } + await persistFailure(error: error, context: context) + await notifyObservers() } private func handleProcessDownloadPartialError( error: PartialDownloadError, context: FailureContext - ) { + ) async { let pageError = error.failedPages.first?.failure.appError ?? .unknown guard !isCancellationLikeAppError(pageError) else { return } @@ -193,16 +200,14 @@ extension DownloadManager { "failedPages": error.failedPages.map(\.index) ] ) - Task { - await persistFailure(error: pageError, context: context) - await notifyObservers() - } + await persistFailure(error: pageError, context: context) + await notifyObservers() } private func handleProcessDownloadGenericError( error: Error, context: FailureContext - ) { + ) async { let appError = AppError.fileOperationFailed( error.localizedDescription ) @@ -211,13 +216,11 @@ extension DownloadManager { return } Logger.error(error) - Task { - await persistFailure( - error: appError, - context: context - ) - await notifyObservers() - } + await persistFailure( + error: appError, + context: context + ) + await notifyObservers() } func persistCompletedDownload( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 9556658ad..b7250f640 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -142,6 +142,7 @@ actor DownloadManager { var schedulingBlockedGalleryIDs = Set() #if DEBUG var testingFetchDownloadsFromStoreHook: (@Sendable () async -> Void)? + var testingPersistFailureHook: (@Sendable () async -> Void)? var testingScheduledGalleryIDHistory = [String]() #endif diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index a54926164..7e68f18ab 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -158,6 +158,11 @@ extension DownloadManager { error: AppError, context: FailureContext ) async { +#if DEBUG + if let testingPersistFailureHook { + await testingPersistFailureHook() + } +#endif let workingCompletedPageCount = temporaryCompletedPageCount( gid: context.gid, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index afbb6107a..f4f494001 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -25,6 +25,12 @@ extension DownloadManager { testingFetchDownloadsFromStoreHook = hook } + func testingSetPersistFailureHook( + _ hook: (@Sendable () async -> Void)? + ) { + testingPersistFailureHook = hook + } + func testingScheduledGalleryIDs() -> [String] { testingScheduledGalleryIDHistory } diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 1930e6f05..76e83520c 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -10,6 +10,66 @@ import Testing @Suite(.serialized) struct DownloadProcessTests: DownloadFeatureTestCase { + @Test + func testFailurePersistenceCompletesBeforeRescheduling() async throws { + let container = try makeInMemoryContainer() + let sessionID = UUID().uuidString + let gid = "100010" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (_, manager) = makeStubbedDownloadManager( + rootURL: rootURL, + sessionID: sessionID, + persistenceContainer: container + ) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + throw URLError(.notConnectedToInternet) + } + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + try insertPersistedDownload( + in: container, + gid: gid, + status: .queued, + completedPageCount: 0, + pageCount: 2 + ) + + let persistenceGate = FailurePersistenceGate() + await manager.testingSetPersistFailureHook { + await persistenceGate.waitAtGate() + } + + let completionProbe = ProcessCompletionProbe() + let processTask = Task { + await manager.testingProcessDownload(gid: gid) + await completionProbe.finish() + } + + await persistenceGate.waitForArrival() + let completedBeforePersistence = await completionProbe + .isFinished() + let scheduledBeforePersistence = await manager + .testingScheduledGalleryIDs() + #expect(completedBeforePersistence == false) + #expect(scheduledBeforePersistence.isEmpty) + + await persistenceGate.release() + await processTask.value + await manager.testingSetPersistFailureHook(nil) + + let stored = await manager.testingFetchDownload(gid: gid) + #expect(stored?.status == .partial) + #expect(stored?.lastError?.code == .networkingFailed) + + await manager.testingScheduleNextIfNeeded() + let scheduledAfterFailure = await manager + .testingScheduledGalleryIDs() + #expect(scheduledAfterFailure.isEmpty) + } + @Test func testProcessDownloadClearsStalePageSelectionWhenLatestPayloadRevealsUpdate() async throws { let container = try makeInMemoryContainer() @@ -62,6 +122,45 @@ struct DownloadProcessTests: DownloadFeatureTestCase { } } +private actor FailurePersistenceGate { + private var didArrive = false + private var arrivalContinuation: CheckedContinuation? + private var releaseContinuation: CheckedContinuation? + + func waitAtGate() async { + didArrive = true + arrivalContinuation?.resume() + arrivalContinuation = nil + await withCheckedContinuation { continuation in + releaseContinuation = continuation + } + } + + func waitForArrival() async { + guard !didArrive else { return } + await withCheckedContinuation { continuation in + arrivalContinuation = continuation + } + } + + func release() { + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +private actor ProcessCompletionProbe { + private var finished = false + + func finish() { + finished = true + } + + func isFinished() -> Bool { + finished + } +} + private struct ProcessVerificationContext { let gid: String let updatedPageCount: Int From 66627fb1c92a0d1769a1d89df46421e01bba4c8c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:43:32 +0800 Subject: [PATCH 042/614] Move cache key helpers --- EhPanda/App/Tools/Extensions/Extensions.swift | 55 --------------- .../Tools/Extensions/URL+ImageCacheKey.swift | 70 +++++++++++++++++++ 2 files changed, 70 insertions(+), 55 deletions(-) create mode 100644 EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift index f3aa35a19..1297daca0 100644 --- a/EhPanda/App/Tools/Extensions/Extensions.swift +++ b/EhPanda/App/Tools/Extensions/Extensions.swift @@ -60,12 +60,6 @@ extension Float { // MARK: URL extension URL { static let mock = Defaults.URL.ehentai - private static let ignoredStableCacheQueryNames: Set = [ - "dl", "download", "source", "from", "view" - ] - private static let preferredStableCacheQueryNames: Set = [ - "gid", "page", "imgkey", "fileindex", "xres", "p", "key" - ] var isPotentiallyAnimatedImage: Bool { switch pathExtension.lowercased() { @@ -74,32 +68,6 @@ extension URL { } } - var stableImageCacheKey: String? { - let normalizedPath = pathComponents - .filter { $0 != "/" && $0.notEmpty } - .joined(separator: "/") - guard normalizedPath.notEmpty else { return nil } - - let queryItems = normalizedStableCacheQueryItems - guard !queryItems.isEmpty else { - return "download::\(normalizedPath)" - } - - let normalizedQuery = queryItems - .map { "\($0.name)=\($0.value ?? "")" } - .joined(separator: "&") - return "download::\(normalizedPath)?\(normalizedQuery)" - } - - func imageCacheKeys(includeStableAlias: Bool) -> [String] { - var keys = [String]() - if includeStableAlias, let stableImageCacheKey { - keys.append(stableImageCacheKey) - } - keys.append(absoluteString) - return keys - } - func previewCacheCleanupURLs() -> [URL] { guard let info = Parser.parsePreviewConfigs(url: self), info.plainURL != self @@ -143,29 +111,6 @@ extension URL { mutating func append(queryItems: [Defaults.URL.Component.Key: String]) { self = appending(queryItems: queryItems) } - - private var normalizedStableCacheQueryItems: [URLQueryItem] { - guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems? - .filter({ ($0.value ?? "").notEmpty }) - else { - return [] - } - - let preferredQueryItems = queryItems.filter { - Self.preferredStableCacheQueryNames.contains($0.name.lowercased()) - } - let filteredQueryItems = preferredQueryItems.isEmpty - ? queryItems.filter { !Self.ignoredStableCacheQueryNames.contains($0.name.lowercased()) } - : preferredQueryItems - - return filteredQueryItems.sorted { lhs, rhs in - if lhs.name == rhs.name { - return (lhs.value ?? "") < (rhs.value ?? "") - } - return lhs.name < rhs.name - } - } } // MARK: String diff --git a/EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift b/EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift new file mode 100644 index 000000000..844af193d --- /dev/null +++ b/EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift @@ -0,0 +1,70 @@ +// +// URL+ImageCacheKey.swift +// EhPanda +// + +import Foundation + +extension URL { + private static let ignoredStableCacheQueryNames: Set = [ + "dl", "download", "source", "from", "view" + ] + private static let preferredStableCacheQueryNames: Set = [ + "gid", "page", "imgkey", "fileindex", "xres", "p", "key" + ] + + var stableImageCacheKey: String? { + let normalizedPath = pathComponents + .filter { $0 != "/" && $0.notEmpty } + .joined(separator: "/") + guard normalizedPath.notEmpty else { return nil } + + let queryItems = normalizedStableCacheQueryItems + guard !queryItems.isEmpty else { + return "download::\(normalizedPath)" + } + + let normalizedQuery = queryItems + .map { "\($0.name)=\($0.value ?? "")" } + .joined(separator: "&") + return "download::\(normalizedPath)?\(normalizedQuery)" + } + + func imageCacheKeys(includeStableAlias: Bool) -> [String] { + var keys = [String]() + if includeStableAlias, let stableImageCacheKey { + keys.append(stableImageCacheKey) + } + keys.append(absoluteString) + return keys + } + + private var normalizedStableCacheQueryItems: [URLQueryItem] { + guard let components = URLComponents( + url: self, + resolvingAgainstBaseURL: false + ), + let queryItems = components.queryItems? + .filter({ ($0.value ?? "").notEmpty }) + else { + return [] + } + + let preferredQueryItems = queryItems.filter { + Self.preferredStableCacheQueryNames.contains($0.name.lowercased()) + } + let filteredQueryItems = preferredQueryItems.isEmpty + ? queryItems.filter { + !Self.ignoredStableCacheQueryNames + .contains($0.name.lowercased()) + } + : preferredQueryItems + + return filteredQueryItems.sorted { lhs, rhs in + if lhs.name == rhs.name { + return (lhs.value ?? "") < (rhs.value ?? "") + } + return lhs.name < rhs.name + } + } +} From b1e87422c1f4d314163f361ace8ab069087b9ce5 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:45:25 +0800 Subject: [PATCH 043/614] Move launch automation --- .../Tools/Utilities/AppLaunchAutomation.swift | 114 ++++++++++++++++++ EhPanda/App/Tools/Utilities/AppUtil.swift | 80 ------------ 2 files changed, 114 insertions(+), 80 deletions(-) create mode 100644 EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift diff --git a/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift b/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift new file mode 100644 index 000000000..82491c087 --- /dev/null +++ b/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift @@ -0,0 +1,114 @@ +// +// AppLaunchAutomation.swift +// EhPanda +// + +import Foundation + +struct AppLaunchAutomation { + struct LoginCookies { + let memberID: String + let passHash: String + let igneous: String? + } + + let initialTab: TabBarItemType? + let autoDownloadGID: String? + let loginCookies: LoginCookies? + let galleryURL: URL? + + static var current: Self? { + #if DEBUG + resolve(environment: ProcessInfo.processInfo.environment) + #else + nil + #endif + } + + static func resolve(environment: [String: String]) -> Self? { + #if DEBUG + let initialTab = environment["EHPANDA_AUTOMATION_TAB"] + .flatMap(parseTab(rawValue:)) + let autoDownloadGID = trimmedValue( + environment: environment, + key: "EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID" + ) + let galleryURL = trimmedValue( + environment: environment, + key: "EHPANDA_AUTOMATION_GALLERY_URL" + ) + .flatMap(URL.init(string:)) + let memberID = trimmedValue( + environment: environment, + key: "EHPANDA_AUTOMATION_IPB_MEMBER_ID" + ) + let passHash = trimmedValue( + environment: environment, + key: "EHPANDA_AUTOMATION_IPB_PASS_HASH" + ) + let igneous = trimmedValue( + environment: environment, + key: "EHPANDA_AUTOMATION_IGNEOUS" + ) + let loginCookies: LoginCookies? = if let memberID, let passHash { + LoginCookies( + memberID: memberID, + passHash: passHash, + igneous: igneous + ) + } else { + nil + } + + guard initialTab != nil + || autoDownloadGID != nil + || loginCookies != nil + || galleryURL != nil + else { + return nil + } + return .init( + initialTab: initialTab, + autoDownloadGID: autoDownloadGID, + loginCookies: loginCookies, + galleryURL: galleryURL + ) + #else + nil + #endif + } + + private static func parseTab(rawValue: String) -> TabBarItemType? { + switch rawValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() { + case "home": + return .home + case "favorites": + return .favorites + case "search": + return .search + case "downloads": + return .downloads + case "setting", "settings": + return .setting + default: + return nil + } + } + + private static func trimmedValue( + environment: [String: String], + key: String + ) -> String? { + environment[key] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .flatMap(\.nilIfEmpty) + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/EhPanda/App/Tools/Utilities/AppUtil.swift b/EhPanda/App/Tools/Utilities/AppUtil.swift index 02c9a234b..a73c66018 100644 --- a/EhPanda/App/Tools/Utilities/AppUtil.swift +++ b/EhPanda/App/Tools/Utilities/AppUtil.swift @@ -35,83 +35,3 @@ struct AppUtil { } } } - -struct AppLaunchAutomation { - struct LoginCookies { - let memberID: String - let passHash: String - let igneous: String? - } - - let initialTab: TabBarItemType? - let autoDownloadGID: String? - let loginCookies: LoginCookies? - let galleryURL: URL? - - static var current: Self? { - #if DEBUG - resolve(environment: ProcessInfo.processInfo.environment) - #else - nil - #endif - } - - static func resolve(environment: [String: String]) -> Self? { - #if DEBUG - let initialTab = environment["EHPANDA_AUTOMATION_TAB"] - .flatMap(parseTab(rawValue:)) - let autoDownloadGID = trimmedValue(environment: environment, key: "EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID") - let galleryURL = trimmedValue(environment: environment, key: "EHPANDA_AUTOMATION_GALLERY_URL") - .flatMap(URL.init(string:)) - let memberID = trimmedValue(environment: environment, key: "EHPANDA_AUTOMATION_IPB_MEMBER_ID") - let passHash = trimmedValue(environment: environment, key: "EHPANDA_AUTOMATION_IPB_PASS_HASH") - let igneous = trimmedValue(environment: environment, key: "EHPANDA_AUTOMATION_IGNEOUS") - let loginCookies: LoginCookies? = if let memberID, let passHash { - LoginCookies(memberID: memberID, passHash: passHash, igneous: igneous) - } else { - nil - } - - guard initialTab != nil || autoDownloadGID != nil || loginCookies != nil || galleryURL != nil else { - return nil - } - return .init( - initialTab: initialTab, - autoDownloadGID: autoDownloadGID, - loginCookies: loginCookies, - galleryURL: galleryURL - ) - #else - nil - #endif - } - - private static func parseTab(rawValue: String) -> TabBarItemType? { - switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "home": - return .home - case "favorites": - return .favorites - case "search": - return .search - case "downloads": - return .downloads - case "setting", "settings": - return .setting - default: - return nil - } - } - - private static func trimmedValue(environment: [String: String], key: String) -> String? { - environment[key] - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .flatMap(\.nilIfEmpty) - } -} - -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} From f1ba54d83896f9e1299aa48c496d5d2dcdc13da3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:47:09 +0800 Subject: [PATCH 044/614] Simplify badge text --- .../DownloadedGallery+Extensions.swift | 53 ------------------- .../Components/DownloadBadgeLabel.swift | 17 +----- 2 files changed, 2 insertions(+), 68 deletions(-) diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift index 167581ddf..b12ddb9ff 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -33,41 +33,6 @@ extension DownloadBadge { } } - var labelContent: DownloadBadgeLabelContent { - switch self { - case .none: - return .text("") - case .queued: - return .text(L10n.Localizable.Struct.DownloadBadge.Text.queued) - case .downloading(let completed, let total): - return .progress( - L10n.Localizable.Struct.DownloadBadge.Compact.downloading, - completed: completed, - total: total - ) - case .paused(let completed, let total): - return .progress( - L10n.Localizable.Struct.DownloadBadge.Compact.paused, - completed: completed, - total: total - ) - case .partial(let completed, let total): - return .progress( - L10n.Localizable.Struct.DownloadBadge.Text.needsAttention, - completed: completed, - total: total - ) - case .downloaded: - return .text(L10n.Localizable.Struct.DownloadBadge.Text.downloaded) - case .failed: - return .text(L10n.Localizable.Struct.DownloadBadge.Text.needsAttention) - case .updateAvailable: - return .text(L10n.Localizable.Struct.DownloadBadge.Text.updateAvailable) - case .missingFiles: - return .text(L10n.Localizable.Struct.DownloadBadge.Text.needsRepair) - } - } - var color: Color { switch self { case .none: @@ -92,24 +57,6 @@ extension DownloadBadge { } } -struct DownloadBadgeLabelContent: Equatable { - let text: String - let numbers: String? - - static func text(_ text: String) -> Self { - .init(text: text, numbers: nil) - } - - static func progress(_ text: String, completed: Int, total: Int) -> Self { - .init( - text: text, - numbers: [max(completed, 0), max(total, 1)] - .map({ $0.formatted(.number) }) - .joined(separator: "/") - ) - } -} - // MARK: - DownloadListFilter enum DownloadListFilter: String, CaseIterable, Identifiable { case all diff --git a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift index 8bbb36303..c12718510 100644 --- a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift +++ b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift @@ -30,24 +30,11 @@ struct DownloadBadgeLabel: View { Text(compactText) .font(.caption2.bold()) } else { - Text(attributedText) + Text(badge.text) + .font(.caption.bold().monospacedDigit()) } } - private var attributedText: AttributedString { - let baseFont = Font.caption.bold() - let label = badge.labelContent - let separator = " " - var text = AttributedString(label.text) - text.font = baseFont - if let numbers = label.numbers { - var numberText = AttributedString([separator, numbers].joined()) - numberText.font = baseFont.monospacedDigit() - text += numberText - } - return text - } - private var compactText: String { switch badge { case .downloading: From cb45e29e66715f14d534be04b6be4e1305de738a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:51:07 +0800 Subject: [PATCH 045/614] Nest comment cells --- .../View/Detail/Comments/CommentsView.swift | 169 +++++++++--------- .../View/Detail/DetailView+CommentCells.swift | 58 +++--- EhPanda/View/Detail/DetailView+Subviews.swift | 2 +- 3 files changed, 116 insertions(+), 113 deletions(-) diff --git a/EhPanda/View/Detail/Comments/CommentsView.swift b/EhPanda/View/Detail/Comments/CommentsView.swift index a6bc6bd2a..839dbcbdd 100644 --- a/EhPanda/View/Detail/Comments/CommentsView.swift +++ b/EhPanda/View/Detail/Comments/CommentsView.swift @@ -41,7 +41,7 @@ struct CommentsView: View { var body: some View { ScrollViewReader { proxy in List(comments) { comment in - CommentsCommentCell( + CommentCell( gid: gid, comment: comment, linkAction: { store.send(.handleCommentLink($0)) } ) @@ -149,108 +149,109 @@ private extension CommentsView { } } -// MARK: CommentsCommentCell -private struct CommentsCommentCell: View { - private let gid: String - private var comment: GalleryComment - private let linkAction: (URL) -> Void +extension CommentsView { + struct CommentCell: View { + private let gid: String + private var comment: GalleryComment + private let linkAction: (URL) -> Void - init(gid: String, comment: GalleryComment, linkAction: @escaping (URL) -> Void) { - self.gid = gid - self.comment = comment - self.linkAction = linkAction - } + init(gid: String, comment: GalleryComment, linkAction: @escaping (URL) -> Void) { + self.gid = gid + self.comment = comment + self.linkAction = linkAction + } - var body: some View { - VStack(alignment: .leading) { - HStack { - Text(comment.author).font(.subheadline.bold()) - Spacer() - Group { - ZStack { - Image(systemSymbol: .handThumbsupFill) - .opacity(comment.votedUp ? 1 : 0) - Image(systemSymbol: .handThumbsdownFill) - .opacity(comment.votedDown ? 1 : 0) + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(comment.author).font(.subheadline.bold()) + Spacer() + Group { + ZStack { + Image(systemSymbol: .handThumbsupFill) + .opacity(comment.votedUp ? 1 : 0) + Image(systemSymbol: .handThumbsdownFill) + .opacity(comment.votedDown ? 1 : 0) + } + Text(comment.score ?? "") + Text(comment.formattedDateString) } - Text(comment.score ?? "") - Text(comment.formattedDateString) + .font(.footnote).foregroundStyle(.secondary) } - .font(.footnote).foregroundStyle(.secondary) - } - .minimumScaleFactor(0.75).lineLimit(1) - ForEach(comment.contents) { content in - switch content.type { - case .plainText: - if let text = content.text { - LinkedText(text: text, action: linkAction) - } - case .linkedText: - if let text = content.text, let link = content.link { - Text(text).foregroundStyle(.tint) - .onTapGesture { linkAction(link) } - } - case .singleLink: - if let link = content.link { - Text(link.absoluteString).foregroundStyle(.tint) - .onTapGesture { linkAction(link) } + .minimumScaleFactor(0.75).lineLimit(1) + ForEach(comment.contents) { content in + switch content.type { + case .plainText: + if let text = content.text { + LinkedText(text: text, action: linkAction) + } + case .linkedText: + if let text = content.text, let link = content.link { + Text(text).foregroundStyle(.tint) + .onTapGesture { linkAction(link) } + } + case .singleLink: + if let link = content.link { + Text(link.absoluteString).foregroundStyle(.tint) + .onTapGesture { linkAction(link) } + } + case .singleImg, .doubleImg, .linkedImg, .doubleLinkedImg: + generateWebImages( + imgURL: content.imgURL, secondImgURL: content.secondImgURL, + link: content.link, secondLink: content.secondLink + ) } - case .singleImg, .doubleImg, .linkedImg, .doubleLinkedImg: - generateWebImages( - imgURL: content.imgURL, secondImgURL: content.secondImgURL, - link: content.link, secondLink: content.secondLink - ) } + .fixedSize(horizontal: false, vertical: true) } - .fixedSize(horizontal: false, vertical: true) + .padding() } - .padding() - } - @ViewBuilder private func generateWebImages( - imgURL: URL?, secondImgURL: URL?, - link: URL?, secondLink: URL? - ) -> some View { - // Double - if let imgURL = imgURL, let secondImgURL = secondImgURL { - HStack(spacing: 0) { - if let link = link, let secondLink = secondLink { - imageContainer(url: imgURL, widthFactor: 4) { - linkAction(link) + @ViewBuilder private func generateWebImages( + imgURL: URL?, secondImgURL: URL?, + link: URL?, secondLink: URL? + ) -> some View { + // Double + if let imgURL = imgURL, let secondImgURL = secondImgURL { + HStack(spacing: 0) { + if let link = link, let secondLink = secondLink { + imageContainer(url: imgURL, widthFactor: 4) { + linkAction(link) + } + imageContainer(url: secondImgURL, widthFactor: 4) { + linkAction(secondLink) + } + } else { + imageContainer(url: imgURL, widthFactor: 4) + imageContainer(url: secondImgURL, widthFactor: 4) } - imageContainer(url: secondImgURL, widthFactor: 4) { - linkAction(secondLink) + } + } + // Single + else if let imgURL = imgURL { + if let link = link { + imageContainer(url: imgURL, widthFactor: 2) { + linkAction(link) } } else { - imageContainer(url: imgURL, widthFactor: 4) - imageContainer(url: secondImgURL, widthFactor: 4) + imageContainer(url: imgURL, widthFactor: 2) } } } - // Single - else if let imgURL = imgURL { - if let link = link { - imageContainer(url: imgURL, widthFactor: 2) { - linkAction(link) + @ViewBuilder func imageContainer( + url: URL, widthFactor: Double, action: (() -> Void)? = nil + ) -> some View { + let image = KFImage(url) + .commentDefaultModifier().scaledToFit() + .frame(width: DeviceUtil.windowW / widthFactor) + if let action = action { + Button(action: action) { + image } + .buttonStyle(.plain) } else { - imageContainer(url: imgURL, widthFactor: 2) - } - } - } - @ViewBuilder func imageContainer( - url: URL, widthFactor: Double, action: (() -> Void)? = nil - ) -> some View { - let image = KFImage(url) - .commentDefaultModifier().scaledToFit() - .frame(width: DeviceUtil.windowW / widthFactor) - if let action = action { - Button(action: action) { image } - .buttonStyle(.plain) - } else { - image } } } diff --git a/EhPanda/View/Detail/DetailView+CommentCells.swift b/EhPanda/View/Detail/DetailView+CommentCells.swift index 7a76f2abd..8a1567729 100644 --- a/EhPanda/View/Detail/DetailView+CommentCells.swift +++ b/EhPanda/View/Detail/DetailView+CommentCells.swift @@ -5,40 +5,42 @@ import SwiftUI -struct CommentCell: View { - let comment: GalleryComment - let backgroundColor: Color +extension DetailView { + struct CommentCell: View { + let comment: GalleryComment + let backgroundColor: Color - private var content: String { - comment.contents - .filter({ [.plainText, .linkedText].contains($0.type) }) - .compactMap(\.text).joined() - } + private var content: String { + comment.contents + .filter({ [.plainText, .linkedText].contains($0.type) }) + .compactMap(\.text).joined() + } - var body: some View { - VStack(alignment: .leading) { - HStack { - Text(comment.author).font(.subheadline.bold()) - Spacer() - Group { - ZStack { - Image(systemSymbol: .handThumbsupFill) - .opacity(comment.votedUp ? 1 : 0) - Image(systemSymbol: .handThumbsdownFill) - .opacity(comment.votedDown ? 1 : 0) + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(comment.author).font(.subheadline.bold()) + Spacer() + Group { + ZStack { + Image(systemSymbol: .handThumbsupFill) + .opacity(comment.votedUp ? 1 : 0) + Image(systemSymbol: .handThumbsdownFill) + .opacity(comment.votedDown ? 1 : 0) + } + Text(comment.score ?? "") + Text(comment.formattedDateString).lineLimit(1) } - Text(comment.score ?? "") - Text(comment.formattedDateString).lineLimit(1) + .font(.footnote).foregroundStyle(.secondary) } - .font(.footnote).foregroundStyle(.secondary) + .minimumScaleFactor(0.75).lineLimit(1) + Text(content).padding(.top, 1) + Spacer() } - .minimumScaleFactor(0.75).lineLimit(1) - Text(content).padding(.top, 1) - Spacer() + .padding().background(backgroundColor) + .frame(width: 300, height: 120) + .cornerRadius(15) } - .padding().background(backgroundColor) - .frame(width: 300, height: 120) - .cornerRadius(15) } } diff --git a/EhPanda/View/Detail/DetailView+Subviews.swift b/EhPanda/View/Detail/DetailView+Subviews.swift index 7333252c8..339e32af2 100644 --- a/EhPanda/View/Detail/DetailView+Subviews.swift +++ b/EhPanda/View/Detail/DetailView+Subviews.swift @@ -331,7 +331,7 @@ struct CommentsSection: View { ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(comments.prefix(min(comments.count, 6))) { comment in - CommentCell(comment: comment, backgroundColor: backgroundColor) + DetailView.CommentCell(comment: comment, backgroundColor: backgroundColor) } .withHorizontalSpacing() } From f16a3d5e51949f1e608b3e797d111d41daa94819 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:53:18 +0800 Subject: [PATCH 046/614] Add nonEmpty helper --- .../Clients/DownloadClient+PersistenceNormalize.swift | 2 +- EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift | 4 ++-- EhPanda/App/Tools/Extensions/Extensions.swift | 3 +++ EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift | 8 +------- .../Models/Persistent/DownloadedGallery+Extensions.swift | 4 ++-- .../Persistent/DownloadedGallery+SupportTypes.swift | 2 +- 6 files changed, 10 insertions(+), 13 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 602c213d2..6e81f6203 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -252,7 +252,7 @@ extension DownloadManager { private extension DownloadManifest { var needsFileHashRefresh: Bool { - let needsCoverHash = coverRelativePath?.notEmpty == true + let needsCoverHash = coverRelativePath?.nonEmpty != nil && coverFileHash == nil return needsCoverHash || pages.contains { $0.fileHash == nil } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 448a85b2d..7dfbe3015 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -103,7 +103,7 @@ extension DownloadManager { canonicalized != download.remoteVersionSignature { record.remoteVersionSignature = canonicalized } - guard latestSignature?.notEmpty == true, + guard latestSignature?.nonEmpty != nil, [.completed, .updateAvailable].contains(download.status) else { return } let desiredStatus: DownloadStatus? @@ -129,7 +129,7 @@ extension DownloadManager { canonicalized != download.remoteVersionSignature { return true } - guard latestSignature?.notEmpty == true, + guard latestSignature?.nonEmpty != nil, [.completed, .updateAvailable].contains(download.status) else { return false } let desiredStatus: DownloadStatus? diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift index 1297daca0..33e982a41 100644 --- a/EhPanda/App/Tools/Extensions/Extensions.swift +++ b/EhPanda/App/Tools/Extensions/Extensions.swift @@ -118,6 +118,9 @@ extension String { var notEmpty: Bool { !isEmpty } + var nonEmpty: String? { + isEmpty ? nil : self + } var isInteger: Bool { Int(self) != nil } diff --git a/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift b/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift index 82491c087..0fe291f27 100644 --- a/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift +++ b/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift @@ -103,12 +103,6 @@ struct AppLaunchAutomation { ) -> String? { environment[key] .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .flatMap(\.nilIfEmpty) - } -} - -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self + .flatMap(\.nonEmpty) } } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift index b12ddb9ff..5c5073ef9 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -174,10 +174,10 @@ struct DownloadVersionMetadata: Equatable, Codable, Sendable { } private var resolvedCurrentGID: String { - currentGID?.notEmpty == true ? currentGID.forceUnwrapped : gid + currentGID?.nonEmpty ?? gid } private var resolvedCurrentKey: String { - currentKey?.notEmpty == true ? currentKey.forceUnwrapped : token + currentKey?.nonEmpty ?? token } } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index 5583bc794..357649543 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -8,7 +8,7 @@ import SwiftUI // MARK: DownloadedGallery Computed Properties extension DownloadedGallery { var displayTitle: String { - jpnTitle?.notEmpty == true ? jpnTitle.forceUnwrapped : title + jpnTitle?.nonEmpty ?? title } var searchableText: String { From 41fa424a9464483e53a1a058b3c959603f73bb17 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:56:43 +0800 Subject: [PATCH 047/614] Remove notEmpty --- EhPanda/App/Tools/Clients/CookieClient.swift | 2 +- .../App/Tools/Clients/DownloadClient+Networking.swift | 2 +- .../DownloadClient+ResponseValidationHelpers.swift | 8 ++++---- .../Clients/DownloadClient+SchedulingHelpers.swift | 6 +++--- EhPanda/App/Tools/Extensions/Extensions.swift | 7 ++----- EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift | 6 +++--- EhPanda/App/Tools/Parser/Parser+Download.swift | 8 ++++---- .../Utilities/DownloadFileStorage+Operations.swift | 6 +++--- .../Persistent/DownloadedGallery+Extensions.swift | 4 ++-- .../DownloadedGallery+SignatureBuilder.swift | 10 +++++----- .../Persistent/DownloadedGallery+SupportTypes.swift | 4 ++-- EhPanda/Models/Support/AppError.swift | 4 ++-- EhPanda/Models/Support/Misc.swift | 2 +- EhPanda/Network/Request.swift | 2 +- EhPanda/View/Downloads/DownloadInspectorReducer.swift | 10 +++++----- EhPanda/View/Search/SearchRootView.swift | 2 +- EhPanda/View/Search/Support/QuickSearchView.swift | 2 +- EhPandaTests/Tests/Parser/List/ListParserTests.swift | 2 +- 18 files changed, 42 insertions(+), 45 deletions(-) diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift index fd1d5fa27..673b80fbc 100644 --- a/EhPanda/App/Tools/Clients/CookieClient.swift +++ b/EhPanda/App/Tools/Clients/CookieClient.swift @@ -108,7 +108,7 @@ extension CookieClient { ) } - if let igneous, igneous.notEmpty { + if let igneous, !igneous.isEmpty { [Defaults.URL.exhentai, Defaults.URL.sexhentai].forEach { url in setCookie( for: url, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift index da15cb8ed..4e9d660d7 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift @@ -309,7 +309,7 @@ extension DownloadManager { response: URLResponse?, prefixData: Data ) -> String { - if url.pathExtension.notEmpty { + if !url.pathExtension.isEmpty { return url.pathExtension.lowercased() } if let ext = extensionFromMimeType(response) { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index 9fd04e256..1b211f84d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -14,7 +14,7 @@ extension DownloadManager { _ response: URLResponse ) -> String? { if let mimeType = response.mimeType?.lowercased(), - mimeType.notEmpty { + !mimeType.isEmpty { return mimeType } if let httpResponse = response as? HTTPURLResponse, @@ -57,7 +57,7 @@ extension DownloadManager { )? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() ?? "" - guard prefix.notEmpty else { return false } + guard !prefix.isEmpty else { return false } let htmlMarkers = [ " AppError? { let normalizedContent = content.lowercased() - guard normalizedContent.notEmpty else { return nil } + guard !normalizedContent.isEmpty else { return nil } // Ex login failures commonly surface as a kokomade placeholder wall when `igneous` is missing. // Reference: https://github.com/OpportunityLiu/E-Viewer/issues/124 @@ -78,19 +78,19 @@ private extension Parser { ] for candidate in directCandidates.compactMap(\.self) { let trimmedCandidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmedCandidate.notEmpty, !candidates.contains(trimmedCandidate) else { continue } + guard !trimmedCandidate.isEmpty, !candidates.contains(trimmedCandidate) else { continue } candidates.append(trimmedCandidate) } if let bodyText = doc.body?.text?.trimmingCharacters(in: .whitespacesAndNewlines), - bodyText.notEmpty, + !bodyText.isEmpty, bodyText.count <= 1024, !candidates.contains(bodyText) { candidates.append(bodyText) } if let bodyContent = doc.body?.innerHTML?.trimmingCharacters(in: .whitespacesAndNewlines), - bodyContent.notEmpty, + !bodyContent.isEmpty, bodyContent.count <= 2048, !candidates.contains(bodyContent) { candidates.append(bodyContent) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index 793ff3cad..76020f8c5 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -60,7 +60,7 @@ extension DownloadFileStorage { ) if let coverRelativePath = manifest.coverRelativePath, - coverRelativePath.notEmpty, + !coverRelativePath.isEmpty, let sourceCoverURL = validatedChildURL(root: sourceFolderURL, relativePath: coverRelativePath), let destCoverURL = validatedChildURL(root: temporaryFolderURL, relativePath: coverRelativePath) { if sanitizeAssetFileIfNeeded(at: sourceCoverURL) { @@ -83,7 +83,7 @@ extension DownloadFileStorage { ) throws -> DownloadManifest { let coverFileHash: String? if let coverRelativePath = manifest.coverRelativePath, - coverRelativePath.notEmpty { + !coverRelativePath.isEmpty { coverFileHash = try hashReadableAsset( folderURL: folderURL, relativePath: coverRelativePath, @@ -244,7 +244,7 @@ extension DownloadFileStorage { manifest: DownloadManifest ) -> DownloadValidationState? { guard let coverRelativePath = manifest.coverRelativePath, - coverRelativePath.notEmpty + !coverRelativePath.isEmpty else { return nil } guard let coverURL = validatedChildURL(root: folderURL, relativePath: coverRelativePath), diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift index 5c5073ef9..ae48ac00a 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -109,8 +109,8 @@ struct DownloadGalleryFilter: Equatable { !excludedCategories.isEmpty || minimumRatingActivated || pageRangeActivated - || pageLowerBound.notEmpty - || pageUpperBound.notEmpty + || !pageLowerBound.isEmpty + || !pageUpperBound.isEmpty } } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift b/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift index c22d605ea..c114e7e0e 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift @@ -57,12 +57,12 @@ enum DownloadSignatureBuilder { } static func chainVersionIdentifier(gid: String, token: String) -> String? { - guard gid.notEmpty, token.notEmpty else { return nil } + guard !gid.isEmpty, !token.isEmpty else { return nil } return "chain:\(gid):\(token)" } static func parse(_ value: String?) -> SignatureKind? { - guard let value, value.notEmpty else { return nil } + guard let value, !value.isEmpty else { return nil } if value.hasPrefix("chain:") { let components = value.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) @@ -77,7 +77,7 @@ enum DownloadSignatureBuilder { if value.hasPrefix("hash:") { let hash = String(value.dropFirst("hash:".count)) - guard hash.notEmpty else { return nil } + guard !hash.isEmpty else { return nil } return .hash(hash) } @@ -144,7 +144,7 @@ enum DownloadSignatureBuilder { private static func normalizedPreviewSignatureValue(url: URL) -> String { let lastPathComponent = url.lastPathComponent - guard lastPathComponent.notEmpty else { + guard !lastPathComponent.isEmpty else { return normalizedCoverSignatureValue(url: url) } return lastPathComponent @@ -153,7 +153,7 @@ enum DownloadSignatureBuilder { private static func normalizedCoverSignatureValue(url: URL?) -> String { guard let url else { return "" } let stablePathComponents = url.pathComponents - .filter { $0 != "/" && $0.notEmpty } + .filter { $0 != "/" && !$0.isEmpty } return stablePathComponents.joined(separator: "/") } } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index 357649543..2302aed00 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -34,7 +34,7 @@ extension DownloadedGallery { func resolvedLocalCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { guard let folderURL = resolvedFolderURL(rootURL: rootURL), let coverRelativePath, - coverRelativePath.notEmpty + !coverRelativePath.isEmpty else { return nil } let coverURL = folderURL.appendingPathComponent(coverRelativePath) guard isReadableLocalAssetFile(coverURL) else { @@ -56,7 +56,7 @@ extension DownloadedGallery { } if let coverRelativePath, - coverRelativePath.notEmpty { + !coverRelativePath.isEmpty { let coverURL = temporaryFolderURL.appendingPathComponent(coverRelativePath) if isReadableLocalAssetFile(coverURL) { return coverURL diff --git a/EhPanda/Models/Support/AppError.swift b/EhPanda/Models/Support/AppError.swift index f739840e3..b7b8d1a92 100644 --- a/EhPanda/Models/Support/AppError.swift +++ b/EhPanda/Models/Support/AppError.swift @@ -117,7 +117,7 @@ extension AppError { return L10n.Localizable.AppError.Alert.authenticationRequired case .fileOperationFailed(let reason): return [L10n.Localizable.AppError.Alert.localFileOperationFailed, reason] - .filter(\.notEmpty) + .filter { !$0.isEmpty } .joined(separator: "\n") case .noUpdates, .webImageFailed: return "" @@ -160,7 +160,7 @@ extension BanInterval { case .unrecognized(let content): params = [content] } - return params.filter(\.notEmpty).joined(separator: " ") + return params.filter { !$0.isEmpty }.joined(separator: " ") } private func daysWithUnit(_ days: Int) -> String { diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift index 92586bb66..6f210aadc 100644 --- a/EhPanda/Models/Support/Misc.swift +++ b/EhPanda/Models/Support/Misc.swift @@ -49,7 +49,7 @@ struct QuickSearchWord: Codable, Equatable, Identifiable { var content: String var effectiveSearchText: String { - content.notEmpty ? content : name + !content.isEmpty ? content : name } } diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index ab004c94b..8d96f8c39 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -87,7 +87,7 @@ extension Dictionary where Key == String, Value == String { private extension URL { var galleryToken: String? { - let filteredComponents = pathComponents.filter { $0 != "/" && $0.notEmpty } + let filteredComponents = pathComponents.filter { $0 != "/" && !$0.isEmpty } guard filteredComponents.count >= 3 else { return nil } return filteredComponents[2] } diff --git a/EhPanda/View/Downloads/DownloadInspectorReducer.swift b/EhPanda/View/Downloads/DownloadInspectorReducer.swift index a35ac1f39..5b7f07169 100644 --- a/EhPanda/View/Downloads/DownloadInspectorReducer.swift +++ b/EhPanda/View/Downloads/DownloadInspectorReducer.swift @@ -65,7 +65,7 @@ struct DownloadInspectorReducer { return .none case .onAppear: - guard state.gid.notEmpty else { return .none } + guard !state.gid.isEmpty else { return .none } return .merge( .send(.loadInspection), .send(.observeDownloads) @@ -78,7 +78,7 @@ struct DownloadInspectorReducer { ) case .loadInspection: - guard state.gid.notEmpty else { return .none } + guard !state.gid.isEmpty else { return .none } if state.inspection == nil { state.loadingState = .loading } @@ -110,7 +110,7 @@ struct DownloadInspectorReducer { return .none case .observeDownloads: - guard state.gid.notEmpty else { return .none } + guard !state.gid.isEmpty else { return .none } return .run { [gid = state.gid] send in var hadRelevantDownloads = false for await downloads in downloadClient.observeDownloads() { @@ -145,7 +145,7 @@ struct DownloadInspectorReducer { return .send(.loadInspection) case .retryPage(let index): - guard state.gid.notEmpty else { return .none } + guard !state.gid.isEmpty else { return .none } state.inspectionRequestID = UUID() state.retryingPageIndices.insert(index) state.stableInspection = state.inspection ?? state.stableInspection @@ -234,7 +234,7 @@ struct DownloadInspectorReducer { return .none case .validateImageData: - guard state.gid.notEmpty, + guard !state.gid.isEmpty, state.inspection?.canValidateImageData == true, !state.isValidatingImageData else { return .none } diff --git a/EhPanda/View/Search/SearchRootView.swift b/EhPanda/View/Search/SearchRootView.swift index 6379808a1..d3e0b4032 100644 --- a/EhPanda/View/Search/SearchRootView.swift +++ b/EhPanda/View/Search/SearchRootView.swift @@ -224,7 +224,7 @@ private struct QuickSearchWordsSection: View { .map { .init( keyword: $0.effectiveSearchText, - displayText: $0.content.notEmpty ? $0.name : "" + displayText: !$0.content.isEmpty ? $0.name : "" ) } .removeDuplicates() diff --git a/EhPanda/View/Search/Support/QuickSearchView.swift b/EhPanda/View/Search/Support/QuickSearchView.swift index d9e2e2c0b..0d23787d3 100644 --- a/EhPanda/View/Search/Support/QuickSearchView.swift +++ b/EhPanda/View/Search/Support/QuickSearchView.swift @@ -26,7 +26,7 @@ struct QuickSearchView: View { searchAction(word.effectiveSearchText) } label: { VStack(alignment: .leading, spacing: 5) { - if !word.name.isEmpty, word.content.notEmpty { + if !word.name.isEmpty, !word.content.isEmpty { Text(word.name).font(.subheadline).foregroundColor(.secondary).lineLimit(1) } Text(word.effectiveSearchText) diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index 3b0abc130..72e2c9478 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -17,7 +17,7 @@ struct ListParserTests: TestHelper { try tuples.forEach { type, document in let galleries = try Parser.parseGalleries(doc: document) - let uploaders = galleries.compactMap(\.uploader).filter(\.notEmpty) + let uploaders = galleries.compactMap(\.uploader).filter { !$0.isEmpty } #expect(galleries.count == type.assertCount, "\(type)") if type.hasUploader { #expect(uploaders.count == type.assertCount, "\(type)") From 13d6e16fd36ecc2daa173975ee3999f19d5b9924 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:57:44 +0800 Subject: [PATCH 048/614] Use unimplemented --- EhPanda/App/Tools/Clients/LibraryClient.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index afd1abc89..5b3659f00 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -131,10 +131,7 @@ extension LibraryClient { initializeLogger: IssueReporting.unimplemented(placeholder: placeholder()), initializeWebImage: IssueReporting.unimplemented(placeholder: placeholder()), clearWebImageDiskCache: IssueReporting.unimplemented(placeholder: placeholder()), - analyzeImageColors: { _ in - reportIssue("Unimplemented: LibraryClient.analyzeImageColors") - return .none - }, + analyzeImageColors: IssueReporting.unimplemented(placeholder: placeholder()), calculateWebImageDiskCacheSize: IssueReporting.unimplemented(placeholder: placeholder()) ) From 9f902075f5dd94434c15ac99ca5d39399d11e7a4 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 21:59:54 +0800 Subject: [PATCH 049/614] Use query sugar --- EhPanda/App/Tools/Utilities/URLUtil.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/EhPanda/App/Tools/Utilities/URLUtil.swift b/EhPanda/App/Tools/Utilities/URLUtil.swift index 81e8548c8..e16fd1150 100644 --- a/EhPanda/App/Tools/Utilities/URLUtil.swift +++ b/EhPanda/App/Tools/Utilities/URLUtil.swift @@ -123,11 +123,10 @@ struct URLUtil { } static func combinedPreviewURL(plainURL: URL, width: String, height: String, offset: String) -> URL { - plainURL.appending(queryItems: [ - URLQueryItem(name: Defaults.URL.Component.Key.ehpandaWidth.rawValue, value: width), - URLQueryItem(name: Defaults.URL.Component.Key.ehpandaHeight.rawValue, value: height), - URLQueryItem(name: Defaults.URL.Component.Key.ehpandaOffset.rawValue, value: offset) - ]) + plainURL + .appending(queryItems: [.ehpandaWidth: width]) + .appending(queryItems: [.ehpandaHeight: height]) + .appending(queryItems: [.ehpandaOffset: offset]) } // GitHub From 9b611d14a1b4feee2e64224c1507224bf66ae1f3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:00:57 +0800 Subject: [PATCH 050/614] Inline sdData --- EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift index ab496183d..44ca3720d 100644 --- a/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift +++ b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift @@ -91,8 +91,7 @@ extension UIImage { } // `sd_imageData()` can preserve animated formats that SDWebImage knows how to export. - let sdData = sd_imageData() - if let data = sdData, data.animatedImagePasteboardType != nil { + if let data = sd_imageData(), data.animatedImagePasteboardType != nil { return data } From bab1b2c897a3972d4b94c8dc15b67d100bbf8b57 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:02:26 +0800 Subject: [PATCH 051/614] Delete test shell --- .../Tests/Download/DownloadFeatureReducerTests.swift | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift diff --git a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift deleted file mode 100644 index 571ecb465..000000000 --- a/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// DownloadFeatureReducerTests.swift -// EhPandaTests -// -// Tests have been split into separate files by feature area. -// See the other files in this directory for the individual test suites. -// From 36ab87f59087d6be80e9a3925ff925ba3e887a6f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:04:23 +0800 Subject: [PATCH 052/614] Make fileManager property --- EhPanda/App/Tools/Clients/DownloadClient+Cache.swift | 4 ++-- .../Clients/DownloadClient+ExecutionSupport.swift | 8 ++++---- .../App/Tools/Clients/DownloadClient+Manager.swift | 2 +- .../Tools/Clients/DownloadClient+Networking.swift | 10 +++++----- .../Tools/Clients/DownloadClient+PageDownload.swift | 2 +- .../Clients/DownloadClient+PersistenceHelpers.swift | 12 ++++++------ .../DownloadClient+PersistenceNormalize.swift | 4 ++-- .../Clients/DownloadClient+PublicAPIHelpers.swift | 4 ++-- .../Tools/Clients/DownloadClient+RetryHelpers.swift | 8 ++++---- .../Clients/DownloadClient+SchedulingHelpers.swift | 2 +- .../App/Tools/Clients/DownloadClient+Testing.swift | 4 ++-- 11 files changed, 30 insertions(+), 30 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 3ddbb4be0..7294ff2ae 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -60,7 +60,7 @@ extension DownloadManager { existingPageRelativePaths[index] { let fileURL = temporaryFolderURL .appendingPathComponent(relativePath) - if fileManager() + if fileManager .fileExists(atPath: fileURL.path) { continue } @@ -149,7 +149,7 @@ extension DownloadManager { let fileURL = folderURL .appendingPathComponent(relativePath) if overwriteExistingFile - || !fileManager() + || !fileManager .fileExists(atPath: fileURL.path) { try write(data: cachedData, to: fileURL) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index c4eeb3824..cd1513cd4 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -16,7 +16,7 @@ extension DownloadManager { !coverRelativePath.isEmpty { let localCoverURL = temporaryFolderURL .appendingPathComponent(coverRelativePath) - if fileManager() + if fileManager .fileExists(atPath: localCoverURL.path) { return coverRelativePath } @@ -164,7 +164,7 @@ extension DownloadManager { temporaryFolderURL: URL, versionSignature: String ) throws -> WorkingSeed { - let localFileManager = fileManager() + let localFileManager = fileManager let resumeState = try? storage .readResumeState(folderURL: temporaryFolderURL) let shouldReuseTemporaryFolder = resumeState?.matches( @@ -294,7 +294,7 @@ extension DownloadManager { guard payload.mode == .repair, let folderURL = download .resolvedFolderURL(rootURL: storage.rootURL), - fileManager() + fileManager .fileExists(atPath: folderURL.path), let manifest = try? storage .readManifest(folderURL: folderURL), @@ -326,7 +326,7 @@ extension DownloadManager { } let fileURL = folderURL .appendingPathComponent(relativePath) - return !fileManager() + return !fileManager .fileExists(atPath: fileURL.path) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index b7250f640..61c9cd9aa 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -156,7 +156,7 @@ actor DownloadManager { self.persistenceContainer = persistenceContainer } - func fileManager() -> DownloadFileManager { + var fileManager: DownloadFileManager { storage.fileManager } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift index 4e9d660d7..28a248b0a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift @@ -82,7 +82,7 @@ extension DownloadManager { response: response.1, requestURL: request.url ) { - try? fileManager().removeItem(at: response.0) + try? fileManager.removeItem(at: response.0) throw error } @@ -340,7 +340,7 @@ extension DownloadManager { } func createDirectory(at url: URL) throws { - try fileManager().createDirectory( + try fileManager.createDirectory( at: url, withIntermediateDirectories: true ) @@ -358,11 +358,11 @@ extension DownloadManager { try createDirectory( at: destinationURL.deletingLastPathComponent() ) - if fileManager() + if fileManager .fileExists(atPath: destinationURL.path) { - try fileManager().removeItem(at: destinationURL) + try fileManager.removeItem(at: destinationURL) } - try fileManager() + try fileManager .moveItem(at: sourceURL, to: destinationURL) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index e45df0c77..93b792550 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -192,7 +192,7 @@ extension DownloadManager { } let fileURL = context.temporaryFolderURL .appendingPathComponent(relativePath) - guard fileManager() + guard fileManager .fileExists(atPath: fileURL.path) else { continue } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index 322518ffc..304079624 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -13,7 +13,7 @@ extension DownloadManager { expectedPageCount: Int ) -> Int { let folderURL = storage.temporaryFolderURL(gid: gid) - guard fileManager() + guard fileManager .fileExists(atPath: folderURL.path) else { return 0 } @@ -29,7 +29,7 @@ extension DownloadManager { ) -> Int { guard let folderURL = download .resolvedFolderURL(rootURL: storage.rootURL), - fileManager() + fileManager .fileExists(atPath: folderURL.path) else { return 0 @@ -95,7 +95,7 @@ extension DownloadManager { download: DownloadedGallery ) -> (hasTemporaryFolder: Bool, temporaryCompletedCount: Int) { let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let hasTemporaryFolder = fileManager() + let hasTemporaryFolder = fileManager .fileExists(atPath: temporaryFolderURL.path) let temporaryCompletedCount = hasTemporaryFolder ? storage.existingPageRelativePaths( @@ -114,7 +114,7 @@ extension DownloadManager { private func scanCompletedFolder(download: DownloadedGallery) { guard let completedFolderURL = download .resolvedFolderURL(rootURL: storage.rootURL), - fileManager().fileExists(atPath: completedFolderURL.path) + fileManager.fileExists(atPath: completedFolderURL.path) else { return } _ = storage.existingPageRelativePaths( folderURL: completedFolderURL, @@ -250,7 +250,7 @@ extension DownloadManager { let temporaryFolderURL = storage .temporaryFolderURL(gid: download.gid) if shouldExposeTemporaryWorkingSet(for: download), - fileManager() + fileManager .fileExists(atPath: temporaryFolderURL.path) { let temporaryPages = storage.existingPageRelativePaths( @@ -275,7 +275,7 @@ extension DownloadManager { guard let completedFolderURL = download .resolvedFolderURL(rootURL: storage.rootURL), - fileManager() + fileManager .fileExists(atPath: completedFolderURL.path) else { return nil diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 6e81f6203..20a9c5def 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -34,11 +34,11 @@ extension DownloadManager { .temporaryFolderURL(gid: download.gid) let completedFolderURL = download .resolvedFolderURL(rootURL: storage.rootURL) - let temporaryFolderExists = fileManager() + let temporaryFolderExists = fileManager .fileExists(atPath: temporaryFolderURL.path) let completedFolderExists = completedFolderURL .map { - fileManager().fileExists(atPath: $0.path) + fileManager.fileExists(atPath: $0.path) } ?? false if shouldExposeTemporaryWorkingSet(for: download) { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index b27f4467a..a2f1ae89c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -19,7 +19,7 @@ extension DownloadManager { let folderURL = activeFolderURL { let fileURL = folderURL .appendingPathComponent(relativePath) - if fileManager().fileExists(atPath: fileURL.path) { + if fileManager.fileExists(atPath: fileURL.path) { return .init( index: index, status: .downloaded, @@ -95,7 +95,7 @@ extension DownloadManager { ) -> Result<[Int: URL], AppError> { if completedValidation == .valid, let completedFolderURL, - fileManager().fileExists(atPath: completedFolderURL.path), + fileManager.fileExists(atPath: completedFolderURL.path), let manifest = try? storage.readManifest( folderURL: completedFolderURL ) { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index b0aa67fbd..2c609d13c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -35,7 +35,7 @@ extension DownloadManager { for: download, requestedMode: mode ) let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let existingResumeState = fileManager() + let existingResumeState = fileManager .fileExists(atPath: temporaryFolderURL.path) ? (try? storage.readResumeState(folderURL: temporaryFolderURL)) : nil @@ -57,7 +57,7 @@ extension DownloadManager { record.lastError = nil record.pendingOperation = retryParams.pendingOperation?.rawValue } - if fileManager().fileExists(atPath: temporaryFolderURL.path) { + if fileManager.fileExists(atPath: temporaryFolderURL.path) { writeRetryResumeState( download: download, resolvedMode: resolvedMode, @@ -83,7 +83,7 @@ extension DownloadManager { guard !selectedPageIndices.isEmpty else { return .success(()) } let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - guard fileManager().fileExists(atPath: temporaryFolderURL.path) else { + guard fileManager.fileExists(atPath: temporaryFolderURL.path) else { return .failure(.notFound) } do { @@ -168,7 +168,7 @@ extension DownloadManager { let completedFolderURL = download .resolvedFolderURL(rootURL: storage.rootURL) let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let hasTemporaryFolder = fileManager() + let hasTemporaryFolder = fileManager .fileExists(atPath: temporaryFolderURL.path) let shouldExposeTemp = hasTemporaryFolder && self.shouldExposeTemporaryWorkingSet(for: download) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index f40819ea1..2f8050b25 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -124,7 +124,7 @@ extension DownloadManager { let temporaryFolderURL = storage .temporaryFolderURL(gid: download.gid) - guard fileManager() + guard fileManager .fileExists(atPath: temporaryFolderURL.path) else { return download.pageCount } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index f4f494001..4729d403a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -55,7 +55,7 @@ extension DownloadManager { try storage.ensureRootDirectory() let temporaryFolderURL = storage .temporaryFolderURL(gid: payload.gallery.gid) - try? fileManager().removeItem(at: temporaryFolderURL) + try? fileManager.removeItem(at: temporaryFolderURL) try createDirectory(at: temporaryFolderURL) try createDirectory( at: temporaryFolderURL.appendingPathComponent( @@ -105,7 +105,7 @@ extension DownloadManager { ) throws -> PrepareWorkingSeedResult { let temporaryFolderURL = storage .temporaryFolderURL(gid: payload.gallery.gid) - try? fileManager().removeItem(at: temporaryFolderURL) + try? fileManager.removeItem(at: temporaryFolderURL) let workingSeed = try prepareWorkingSeed( payload: payload, existingDownload: existingDownload, From 495dee1e92abfd48909ba2d57098b1eaedf42cbd Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:06:12 +0800 Subject: [PATCH 053/614] Clean searchableText --- .../DownloadedGallery+SupportTypes.swift | 6 ++-- .../DownloadFilterAndBadgeTests.swift | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index 2302aed00..fbdb2a12a 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -14,11 +14,13 @@ extension DownloadedGallery { var searchableText: String { [ title, - jpnTitle ?? "", - uploader ?? "", + jpnTitle, + uploader, category.value, tags.flatMap(\.contents).map(\.text).joined(separator: " ") ] + .compactMap { $0 } + .filter { !$0.isEmpty } .joined(separator: " ") } diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index afb1f57b7..7307dd6e0 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -31,6 +31,37 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { #expect(state.filteredDownloads == [activeDownload]) } + @Test + func testSearchableTextDropsNilAndBlankFields() { + let download = DownloadedGallery( + gid: "111", + host: .ehentai, + token: "token", + title: "Solo Title", + jpnTitle: nil, + uploader: nil, + category: .doujinshi, + tags: [], + pageCount: 1, + postedDate: .now, + rating: 4, + onlineCoverURL: nil, + folderRelativePath: "111 - Solo Title", + coverRelativePath: nil, + status: .completed, + completedPageCount: 1, + lastDownloadedAt: .now, + lastError: nil, + downloadOptionsSnapshot: DownloadOptionsSnapshot(), + remoteVersionSignature: "hash:v1", + latestRemoteVersionSignature: "hash:v1" + ) + + #expect(download.searchableText == ["Solo Title", Category.doujinshi.value].joined(separator: " ")) + #expect(!download.searchableText.contains(" ")) + #expect(download.searchableText == download.searchableText.trimmingCharacters(in: .whitespaces)) + } + @Test func testQueuedRetryWorkAppearsAsActiveDownloadBadge() { let queuedRedownload = sampleDownload( From 7c7ad320ceac4c52fa05b87fcaf78f620f0da56f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:08:46 +0800 Subject: [PATCH 054/614] Delete ImageSaver --- EhPanda/App/Tools/Clients/ImageClient.swift | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index fe23feaf4..0837168f6 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -124,23 +124,6 @@ extension ImageClient { } } -private final class ImageSaver: NSObject { - private let completion: (Bool) -> Void - - init(completion: @escaping @Sendable (Bool) -> Void) { - self.completion = completion - } - - func saveImage(_ image: UIImage) { - UIImageWriteToSavedPhotosAlbum(image, self, #selector(didFinishSavingImage), nil) - } - @objc func didFinishSavingImage( - _ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer - ) { - completion(error == nil) - } -} - // MARK: API enum ImageClientKey: DependencyKey { static let liveValue = ImageClient.live From e15f076c0faed6fb87a361b0af3d6d3ea3de5f20 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:11:16 +0800 Subject: [PATCH 055/614] Nest setting sections --- .../Setting/EhSetting/EhSettingView+Sections1.swift | 10 +++++++--- .../Setting/EhSetting/EhSettingView+Sections2.swift | 6 +++++- .../Setting/EhSetting/EhSettingView+Sections3.swift | 12 ++++++++---- EhPanda/View/Setting/EhSetting/EhSettingView.swift | 2 +- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift index 146c2782a..a861d7f68 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift @@ -6,6 +6,8 @@ import SwiftUI import ComposableArchitecture +extension EhSettingView { + // MARK: EhProfileSection struct EhProfileSection: View { @Binding var route: EhSettingReducer.Route? @@ -153,12 +155,12 @@ struct ImageSizeSettingsSection: View { Section { Text(L10n.Localizable.EhSettingView.Title.imageSize) - EhSettingValuePicker( + ValuePicker( title: L10n.Localizable.EhSettingView.Title.horizontal, value: $ehSetting.imageSizeWidth, range: 0...65535, unit: "px" ) - EhSettingValuePicker( + ValuePicker( title: L10n.Localizable.EhSettingView.Title.vertical, value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px" ) @@ -257,7 +259,7 @@ struct FrontPageSettingsSection: View { } // MARK: Shared Helpers -struct EhSettingValuePicker: View { +struct ValuePicker: View { private let title: String @Binding var value: Float private let range: ClosedRange @@ -294,6 +296,8 @@ struct EhSettingValuePicker: View { } } +} + extension Text { static func ehSettingBoldHeader(_ title: String, description: String? = nil) -> Self { var result = AttributedString(title) diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift index 6eb62a70a..6dd6e8195 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift @@ -5,6 +5,8 @@ import SwiftUI +extension EhSettingView { + // MARK: OptionalUIElementsSection struct OptionalUIElementsSection: View { @Binding var ehSetting: EhSetting @@ -25,7 +27,7 @@ struct OptionalUIElementsSection: View { } // MARK: FavoritesSection -struct EhSettingFavoritesSection: View { +struct FavoritesSection: View { @Binding var ehSetting: EhSetting @FocusState private var isFocused @@ -97,6 +99,8 @@ struct RatingsSection: View { } } +} + // MARK: SearchResultCountSection struct SearchResultCountSection: View { @Binding var ehSetting: EhSetting diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift index 3c309bcc8..94c587223 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift @@ -5,13 +5,15 @@ import SwiftUI +extension EhSettingView { + // MARK: CoverScalingSection struct CoverScalingSection: View { @Binding var ehSetting: EhSetting var body: some View { Section { - EhSettingValuePicker( + ValuePicker( title: L10n.Localizable.EhSettingView.Title.scaleFactor, value: $ehSetting.coverScaleFactor, range: 75...150, @@ -32,7 +34,7 @@ struct TagFilteringThresholdSection: View { var body: some View { Section { - EhSettingValuePicker( + ValuePicker( title: L10n.Localizable.EhSettingView.Title.tagFilteringThreshold, value: $ehSetting.tagFilteringThreshold, range: -9999...0 ) @@ -51,7 +53,7 @@ struct TagWatchingThresholdSection: View { var body: some View { Section { - EhSettingValuePicker( + ValuePicker( title: L10n.Localizable.EhSettingView.Title.tagWatchingThreshold, value: $ehSetting.tagWatchingThreshold, range: 0...9999 ) @@ -208,7 +210,7 @@ struct ViewportOverrideSection: View { var body: some View { Section { - EhSettingValuePicker( + ValuePicker( title: L10n.Localizable.EhSettingView.Title.virtualWidth, value: $ehSetting.viewportVirtualWidth, range: 0...9999, @@ -337,6 +339,8 @@ struct MultiplePageViewerSection: View { } } +} + extension String { var ehSettingLineCount: Int { var count = 0 diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView.swift b/EhPanda/View/Setting/EhSetting/EhSettingView.swift index 75ceef867..ef48541a8 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView.swift @@ -82,7 +82,7 @@ struct EhSettingView: View { ArchiverSettingsSection(ehSetting: ehSetting) FrontPageSettingsSection(ehSetting: ehSetting) OptionalUIElementsSection(ehSetting: ehSetting) - EhSettingFavoritesSection(ehSetting: ehSetting) + FavoritesSection(ehSetting: ehSetting) SearchResultCountSection(ehSetting: ehSetting) ThumbnailSettingsSection(ehSetting: ehSetting) } From 929e3097ae848a2f8fcb1acded5a93317e80e15a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:12:35 +0800 Subject: [PATCH 056/614] Collapse gallery cell --- .../Downloads/DownloadsView+Subviews.swift | 8 +-- .../Components/Cells/GalleryDetailCell.swift | 53 +++++-------------- 2 files changed, 18 insertions(+), 43 deletions(-) diff --git a/EhPanda/View/Downloads/DownloadsView+Subviews.swift b/EhPanda/View/Downloads/DownloadsView+Subviews.swift index 0711263de..51609f2ca 100644 --- a/EhPanda/View/Downloads/DownloadsView+Subviews.swift +++ b/EhPanda/View/Downloads/DownloadsView+Subviews.swift @@ -41,9 +41,9 @@ struct DownloadInspectorView: View { List { if let inspection = store.inspection { Section { - StaticGalleryDetailCell( + GalleryDetailCell( gallery: inspection.download.gallery, - resolvedCoverURL: inspection.coverURL, + coverSource: .static(inspection.coverURL), setting: setting, translateAction: { tagTranslator.lookup( @@ -307,9 +307,9 @@ struct DownloadListRow: View { var body: some View { HStack(spacing: 0) { - StaticGalleryDetailCell( + GalleryDetailCell( gallery: download.gallery, - resolvedCoverURL: download.coverURL, + coverSource: .static(download.coverURL), setting: setting, translateAction: { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) diff --git a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift index fb58ea7f4..7aa085b91 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift @@ -7,66 +7,41 @@ import SwiftUI import Kingfisher struct GalleryDetailCell: View { + enum CoverSource { + case dynamic + case `static`(URL?) + } + @Environment(\.colorScheme) private var colorScheme private let downloadStore = DownloadBadgeStore.shared private let gallery: Gallery - private let coverURLOverride: URL? + private let coverSource: CoverSource private let setting: Setting private let translateAction: ((String) -> (String, TagTranslation?))? private let downloadBadge: DownloadBadge init( gallery: Gallery, - coverURLOverride: URL? = nil, + coverSource: CoverSource = .dynamic, setting: Setting, translateAction: ((String) -> (String, TagTranslation?))? = nil, downloadBadge: DownloadBadge = .none ) { self.gallery = gallery - self.coverURLOverride = coverURLOverride + self.coverSource = coverSource self.setting = setting self.translateAction = translateAction self.downloadBadge = downloadBadge } private var resolvedCoverURL: URL? { - coverURLOverride ?? downloadStore.resolvedCoverURL(for: gallery) - } - - var body: some View { - GalleryDetailCellContent( - gallery: gallery, - resolvedCoverURL: resolvedCoverURL, - setting: setting, - colorScheme: colorScheme, - translateAction: translateAction, - downloadBadge: downloadBadge - ) - } -} - -struct StaticGalleryDetailCell: View { - @Environment(\.colorScheme) private var colorScheme - - private let gallery: Gallery - private let resolvedCoverURL: URL? - private let setting: Setting - private let translateAction: ((String) -> (String, TagTranslation?))? - private let downloadBadge: DownloadBadge - - init( - gallery: Gallery, - resolvedCoverURL: URL?, - setting: Setting, - translateAction: ((String) -> (String, TagTranslation?))? = nil, - downloadBadge: DownloadBadge = .none - ) { - self.gallery = gallery - self.resolvedCoverURL = resolvedCoverURL - self.setting = setting - self.translateAction = translateAction - self.downloadBadge = downloadBadge + switch coverSource { + case .dynamic: + downloadStore.resolvedCoverURL(for: gallery) + case .static(let url): + url + } } var body: some View { From 08044ae882e5b419eed2346051caecef9ff6d52c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:14:55 +0800 Subject: [PATCH 057/614] Drop dead filters --- .../DownloadedGallery+Extensions.swift | 31 ---- .../DownloadedGallery+SupportTypes.swift | 20 --- .../View/Downloads/DownloadFiltersView.swift | 133 ------------------ EhPanda/View/Downloads/DownloadsReducer.swift | 18 +-- EhPanda/View/Downloads/DownloadsView.swift | 21 --- .../DownloadFilterAndBadgeTests.swift | 51 ------- 6 files changed, 1 insertion(+), 273 deletions(-) delete mode 100644 EhPanda/View/Downloads/DownloadFiltersView.swift diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift index ae48ac00a..952aa2f9a 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -83,37 +83,6 @@ enum DownloadListFilter: String, CaseIterable, Identifiable { } } -// MARK: - DownloadGalleryFilter -struct DownloadGalleryFilter: Equatable { - var excludedCategories = Set() - var minimumRatingActivated = false - var minimumRating = 2 - var pageRangeActivated = false - var pageLowerBound = "" - var pageUpperBound = "" - - mutating func fixInvalidData() { - if !pageLowerBound.isEmpty && Int(pageLowerBound) == nil { - pageLowerBound = "" - } - if !pageUpperBound.isEmpty && Int(pageUpperBound) == nil { - pageUpperBound = "" - } - } - - mutating func reset() { - self = .init() - } - - var hasActiveValues: Bool { - !excludedCategories.isEmpty - || minimumRatingActivated - || pageRangeActivated - || !pageLowerBound.isEmpty - || !pageUpperBound.isEmpty - } -} - // MARK: - DownloadRequestPayload struct DownloadRequestPayload: Equatable, @unchecked Sendable { let gallery: Gallery diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index fbdb2a12a..cc6485c95 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -240,26 +240,6 @@ extension DownloadedGallery { } } - func matches(queryFilter: DownloadGalleryFilter) -> Bool { - if queryFilter.excludedCategories.contains(category) { - return false - } - - if queryFilter.minimumRatingActivated && rating < Float(queryFilter.minimumRating) { - return false - } - - guard queryFilter.pageRangeActivated else { return true } - - if let lowerBound = Int(queryFilter.pageLowerBound), pageCount < lowerBound { - return false - } - if let upperBound = Int(queryFilter.pageUpperBound), pageCount > upperBound { - return false - } - - return true - } } extension DownloadedGallery { diff --git a/EhPanda/View/Downloads/DownloadFiltersView.swift b/EhPanda/View/Downloads/DownloadFiltersView.swift deleted file mode 100644 index dfaebf1e4..000000000 --- a/EhPanda/View/Downloads/DownloadFiltersView.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// DownloadFiltersView.swift -// EhPanda -// - -import SwiftUI - -private enum DownloadFilterFocusedBound: Hashable { - case lower - case upper -} - -struct DownloadFiltersView: View { - @Binding private var filter: DownloadGalleryFilter - @FocusState private var focusedBound: DownloadFilterFocusedBound? - private let resetAction: () -> Void - - init(filter: Binding, resetAction: @escaping () -> Void) { - _filter = filter - self.resetAction = resetAction - } - - private var categoryBindings: [Binding] { - Category.allFiltersCases.map(categoryBinding) - } - - private func categoryBinding(_ category: Category) -> Binding { - .init( - get: { - filter.excludedCategories.contains(category) - }, - set: { isExcluded in - if isExcluded { - filter.excludedCategories.insert(category) - } else { - filter.excludedCategories.remove(category) - } - } - ) - } - - var body: some View { - NavigationView { - Form { - Section { - CategoryView(bindings: categoryBindings) - } - - Section(L10n.Localizable.FiltersView.Section.Title.advanced) { - Toggle( - L10n.Localizable.FiltersView.Title.setMinimumRating, - isOn: $filter.minimumRatingActivated - ) - DownloadMinimumRatingSetter(minimum: $filter.minimumRating) - .disabled(!filter.minimumRatingActivated) - Toggle( - L10n.Localizable.FiltersView.Title.setPagesRange, - isOn: $filter.pageRangeActivated - ) - .disabled(focusedBound != nil) - DownloadPagesRangeSetter( - lowerBound: $filter.pageLowerBound, - upperBound: $filter.pageUpperBound, - focusedBound: $focusedBound - ) - .disabled(!filter.pageRangeActivated) - } - - Section { - Button(role: .destructive, action: resetAction) { - Text(L10n.Localizable.FiltersView.Button.resetFilters) - } - } - } - .navigationTitle(L10n.Localizable.FiltersView.Title.filters) - } - } -} - -private struct DownloadMinimumRatingSetter: View { - @Binding private var minimum: Int - - init(minimum: Binding) { - _minimum = minimum - } - - var body: some View { - Picker(L10n.Localizable.FiltersView.Title.minimumRating, selection: $minimum) { - ForEach(Array(2...5), id: \.self) { number in - Text(L10n.Localizable.Common.Value.stars("\(number)")).tag(number) - } - } - .pickerStyle(.menu) - } -} - -private struct DownloadPagesRangeSetter: View { - @Binding private var lowerBound: String - @Binding private var upperBound: String - private let focusedBound: FocusState.Binding - - init( - lowerBound: Binding, - upperBound: Binding, - focusedBound: FocusState.Binding - ) { - _lowerBound = lowerBound - _upperBound = upperBound - self.focusedBound = focusedBound - } - - var body: some View { - HStack { - Text(L10n.Localizable.FiltersView.Title.pagesRange) - Spacer() - SettingTextField(text: $lowerBound) - .focused(focusedBound, equals: .lower) - .submitLabel(.next) - Text("-") - SettingTextField(text: $upperBound) - .focused(focusedBound, equals: .upper) - .submitLabel(.done) - } - .onSubmit { - switch focusedBound.wrappedValue { - case .lower: - focusedBound.wrappedValue = .upper - default: - focusedBound.wrappedValue = nil - } - } - } -} diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index 89774fdf9..be44951eb 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -10,8 +10,6 @@ import ComposableArchitecture struct DownloadsReducer { @CasePathable enum Route: Equatable { - case quickSearch(EquatableVoid = .init()) - case filters(EquatableVoid = .init()) case inspector(String) case detail(String) case reading(String) @@ -26,7 +24,6 @@ struct DownloadsReducer { var route: Route? var keyword = "" var filter: DownloadListFilter = .all - var galleryFilter = DownloadGalleryFilter() var downloads = [DownloadedGallery]() var loadingState: LoadingState = .loading var hasLoadedInitialDownloads = false @@ -34,7 +31,6 @@ struct DownloadsReducer { var detailState: Heap var readingState = ReadingReducer.State() var inspectorState = DownloadInspectorReducer.State() - var quickSearchState = QuickSearchReducer.State() var readingRequestID = UUID() init() { @@ -44,7 +40,6 @@ struct DownloadsReducer { var filteredDownloads: [DownloadedGallery] { downloads.filter { $0.matches(filter: filter) - && $0.matches(queryFilter: galleryFilter) && ( keyword.isEmpty || $0.searchableText.caseInsensitiveContains(keyword) @@ -79,7 +74,6 @@ struct DownloadsReducer { case detail(DetailReducer.Action) case reading(ReadingReducer.Action) case inspector(DownloadInspectorReducer.Action) - case quickSearch(QuickSearchReducer.Action) } @Dependency(\.downloadClient) private var downloadClient @@ -89,10 +83,6 @@ struct DownloadsReducer { .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none } - .onChange(of: \.galleryFilter) { _, state in - state.galleryFilter.fixInvalidData() - return .none - } Reduce { state, action in switch action { @@ -113,12 +103,10 @@ struct DownloadsReducer { state.detailState.wrappedValue = .init() state.readingState = .init() state.inspectorState = .init() - state.quickSearchState = .init() return .merge( .send(.detail(.teardown)), .send(.reading(.teardown)), - .send(.inspector(.teardown)), - .send(.quickSearch(.teardown)) + .send(.inspector(.teardown)) ) case .onAppear: @@ -235,9 +223,6 @@ struct DownloadsReducer { case .inspector: return .none - - case .quickSearch: - return .none } } @@ -250,7 +235,6 @@ struct DownloadsReducer { Scope(state: \.inspectorState, action: \.inspector) { DownloadInspectorReducer() } - Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) } } diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift index e943150b6..d2f366826 100644 --- a/EhPanda/View/Downloads/DownloadsView.swift +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -88,16 +88,6 @@ struct DownloadsView: View { placement: .navigationBarDrawer(displayMode: .automatic), prompt: L10n.Localizable.DownloadsView.Search.Prompt.downloads ) - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.keyword = keyword - store.send(.setNavigation(nil)) - } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } .sheet(item: $store.route.sending(\.setNavigation).inspector, id: \.self) { _ in NavigationView { DownloadInspectorView( @@ -110,16 +100,6 @@ struct DownloadsView: View { .autoBlur(radius: blurRadius) .navigationViewStyle(.stack) } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - DownloadFiltersView( - filter: $store.galleryFilter, - resetAction: { - store.galleryFilter.reset() - } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } .fullScreenCover(item: $store.route.sending(\.setNavigation).reading, id: \.self) { route in ReadingView( store: store.scope(state: \.readingState, action: \.reading), @@ -327,7 +307,6 @@ private extension DownloadsView { AlertViewButton(title: L10n.Localizable.DownloadsView.Button.clearFilters) { store.keyword = "" store.filter = .all - store.galleryFilter.reset() } } } diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 7307dd6e0..732192401 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -168,35 +168,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { #expect(completedUpdate.canTriggerUpdate) } - @Test - func testDownloadsFilterMatchesGalleryFilterCriteria() { - let qualifyingDownload = sampleDownload( - gid: "466", - title: "Chinese Archive", - status: .completed, - pageCount: 28 - ) - let filteredOutDownload = sampleDownload( - gid: "477", - title: "Low Rated Archive", - status: .completed, - pageCount: 8 - ) - - var state = DownloadsReducer.State() - state.downloads = [ - qualifyingDownload, - filteredOutDownload - ] - state.galleryFilter.minimumRatingActivated = true - state.galleryFilter.minimumRating = 4 - state.galleryFilter.pageRangeActivated = true - state.galleryFilter.pageLowerBound = "20" - state.galleryFilter.pageUpperBound = "40" - - #expect(state.filteredDownloads == [qualifyingDownload]) - } - @Test func testSearchPageRangeFilterOmitsInvertedBounds() { var filter = Filter() @@ -250,28 +221,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { #expect(upperOnlyQueryItems["f_spt"] == "50") } - @Test - func testDownloadsFilterExcludesSelectedCategoriesLikeSearchFilter() { - let nonHDownload = sampleDownload( - gid: "478", - title: "Healthy Archive", - status: .completed, - category: .nonH - ) - let mangaDownload = sampleDownload( - gid: "479", - title: "Comic Archive", - status: .completed, - category: .manga - ) - - var state = DownloadsReducer.State() - state.downloads = [nonHDownload, mangaDownload] - state.galleryFilter.excludedCategories = [.nonH] - - #expect(state.filteredDownloads == [mangaDownload]) - } - @Test func testPartialDownloadBadgeUsesNeedsAttentionCopy() { let partialDownload = sampleDownload( From bb9329bcd504117d2d281cd059f5b76f3029f690 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:17:08 +0800 Subject: [PATCH 058/614] Use gallery cover URL --- EhPanda/View/Detail/DetailView+HeaderSection.swift | 4 +--- EhPanda/View/Home/HomeView+Sections.swift | 4 +--- EhPanda/View/Support/Components/Cells/GalleryCardCell.swift | 3 +-- EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift | 3 +-- .../View/Support/Components/Cells/GalleryHistoryCell.swift | 4 +--- .../View/Support/Components/Cells/GalleryRankingCell.swift | 4 +--- .../View/Support/Components/Cells/GalleryThumbnailCell.swift | 3 +-- EhPanda/View/Support/Components/DownloadBadgeStore.swift | 4 ---- 8 files changed, 7 insertions(+), 22 deletions(-) diff --git a/EhPanda/View/Detail/DetailView+HeaderSection.swift b/EhPanda/View/Detail/DetailView+HeaderSection.swift index c043d840e..6c6eeb4e4 100644 --- a/EhPanda/View/Detail/DetailView+HeaderSection.swift +++ b/EhPanda/View/Detail/DetailView+HeaderSection.swift @@ -8,8 +8,6 @@ import Kingfisher // MARK: HeaderSection struct HeaderSection: View { - private let downloadStore = DownloadBadgeStore.shared - let gallery: Gallery let galleryDetail: GalleryDetail let user: User @@ -215,7 +213,7 @@ struct HeaderSection: View { default: return "icloud.and.arrow.down" } } - private var resolvedCoverURL: URL? { downloadStore.resolvedCoverURL(for: gallery) } + private var resolvedCoverURL: URL? { gallery.coverURL } var body: some View { HStack { diff --git a/EhPanda/View/Home/HomeView+Sections.swift b/EhPanda/View/Home/HomeView+Sections.swift index 7eb9a7119..d9ab4eeef 100644 --- a/EhPanda/View/Home/HomeView+Sections.swift +++ b/EhPanda/View/Home/HomeView+Sections.swift @@ -127,8 +127,6 @@ struct CoverWallSection: View { } struct VerticalCoverStack: View { - private let downloadStore = DownloadBadgeStore.shared - private let galleries: [Gallery] private let downloadBadges: [String: DownloadBadge] private let navigateAction: (String) -> Void @@ -150,7 +148,7 @@ struct VerticalCoverStack: View { Button { navigateAction(gallery.id) } label: { - KFImage(downloadStore.resolvedCoverURL(for: gallery)) + KFImage(gallery.coverURL) .placeholder(placeholder) .defaultModifier() .scaledToFill() diff --git a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift index 035106ead..4b80da2f1 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift @@ -10,7 +10,6 @@ import UIImageColors struct GalleryCardCell: View { @Environment(\.colorScheme) private var colorScheme - private let downloadStore = DownloadBadgeStore.shared private let currentID: String private let colors: [Color] @@ -47,7 +46,7 @@ struct GalleryCardCell: View { } private var resolvedCoverURL: URL? { - downloadStore.resolvedCoverURL(for: gallery) + gallery.coverURL } var body: some View { diff --git a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift index 7aa085b91..76c267fca 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift @@ -13,7 +13,6 @@ struct GalleryDetailCell: View { } @Environment(\.colorScheme) private var colorScheme - private let downloadStore = DownloadBadgeStore.shared private let gallery: Gallery private let coverSource: CoverSource @@ -38,7 +37,7 @@ struct GalleryDetailCell: View { private var resolvedCoverURL: URL? { switch coverSource { case .dynamic: - downloadStore.resolvedCoverURL(for: gallery) + gallery.coverURL case .static(let url): url } diff --git a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift index 86f4f793c..9d2477993 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift @@ -7,8 +7,6 @@ import SwiftUI import Kingfisher struct GalleryHistoryCell: View { - private let downloadStore = DownloadBadgeStore.shared - private let gallery: Gallery init(gallery: Gallery) { @@ -16,7 +14,7 @@ struct GalleryHistoryCell: View { } private var resolvedCoverURL: URL? { - downloadStore.resolvedCoverURL(for: gallery) + gallery.coverURL } var body: some View { diff --git a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift index 87e66031a..075490009 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift @@ -7,8 +7,6 @@ import SwiftUI import Kingfisher struct GalleryRankingCell: View { - private let downloadStore = DownloadBadgeStore.shared - private let gallery: Gallery private let ranking: Int private let downloadBadge: DownloadBadge @@ -20,7 +18,7 @@ struct GalleryRankingCell: View { } private var resolvedCoverURL: URL? { - downloadStore.resolvedCoverURL(for: gallery) + gallery.coverURL } var body: some View { diff --git a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift index 7b64f8bb2..8c746251f 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -8,7 +8,6 @@ import Kingfisher struct GalleryThumbnailCell: View { @Environment(\.colorScheme) private var colorScheme - private let downloadStore = DownloadBadgeStore.shared private let gallery: Gallery private let setting: Setting @@ -35,7 +34,7 @@ struct GalleryThumbnailCell: View { } private var resolvedCoverURL: URL? { - downloadStore.resolvedCoverURL(for: gallery) + gallery.coverURL } var body: some View { diff --git a/EhPanda/View/Support/Components/DownloadBadgeStore.swift b/EhPanda/View/Support/Components/DownloadBadgeStore.swift index 7210777bb..bf24c63fc 100644 --- a/EhPanda/View/Support/Components/DownloadBadgeStore.swift +++ b/EhPanda/View/Support/Components/DownloadBadgeStore.swift @@ -30,10 +30,6 @@ final class DownloadBadgeStore { } } - func resolvedCoverURL(for gallery: Gallery) -> URL? { - downloads[gallery.gid]?.coverURL ?? gallery.coverURL - } - private func apply(downloads: [DownloadedGallery]) { let resolvedDownloads = Dictionary(uniqueKeysWithValues: downloads.map { ($0.gid, $0) }) let resolvedBadges = Dictionary(uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) }) From 2b4d41660fe3e45ce658459b466829757831d795 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:19:30 +0800 Subject: [PATCH 059/614] Remove offline detail init --- .../View/Detail/DetailReducer+Download.swift | 1 - EhPanda/View/Detail/DetailReducer.swift | 25 ++----------------- EhPanda/View/Downloads/DownloadsReducer.swift | 4 ++- .../DetailReducerMetadataUpdateTests.swift | 8 +++--- .../Download/DetailReducerObserveTests.swift | 3 ++- .../ReadingReducerDownloadTests.swift | 3 ++- 6 files changed, 13 insertions(+), 31 deletions(-) diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index 808a7da25..874c1be62 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -290,7 +290,6 @@ extension DetailReducer { if case .success = result { state.galleryVersionMetadata = nil state.didRequestVersionMetadata = false - state.isDownloadContext = false state.shouldCheckForRemoteUpdates = false return .merge( .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 84535eb7a..3853368a9 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -62,7 +62,6 @@ struct DetailReducer { var isPreparingDownload = false var hasLoadedDownloadBadge = false var didRunLaunchAutomation = false - var isDownloadContext = false var shouldCheckForRemoteUpdates = false var didRequestVersionMetadata = false var localPreviewRequestID = UUID() @@ -79,26 +78,6 @@ struct DetailReducer { detailSearchState = .init(nil) } - init(download: DownloadedGallery) { - self.init() - gid = download.gid - gallery = download.gallery - galleryDetail = GalleryDetail( - gid: download.gid, title: download.title, jpnTitle: download.jpnTitle, - isFavorited: false, visibility: .yes, rating: download.rating, - userRating: 0, ratingCount: 0, category: download.category, - language: .other, uploader: download.uploader ?? "", - postedDate: download.postedDate, coverURL: download.coverURL, - favoritedCount: 0, pageCount: download.pageCount, - sizeCount: 0, sizeType: "", torrentCount: 0 - ) - downloadBadge = download.badge - hasLoadedDownloadBadge = download.badge != .none - isDownloadContext = true - shouldCheckForRemoteUpdates = true - didRequestVersionMetadata = false - } - mutating func updateRating(value: DragGesture.Value) { let rating = Int(value.location.x / 31 * 2) + 1 userRating = min(max(rating, 1), 10) @@ -229,8 +208,8 @@ extension DetailReducer { state.downloadBadge = badge if badge != .none { state.isPreparingDownload = false } state.hasLoadedDownloadBadge = true - state.shouldCheckForRemoteUpdates = state.isDownloadContext || badge != .none - if badge == .none && !state.isDownloadContext { + state.shouldCheckForRemoteUpdates = badge != .none + if badge == .none { state.galleryVersionMetadata = nil state.didRequestVersionMetadata = false } diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index be44951eb..c523709c6 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -93,7 +93,9 @@ struct DownloadsReducer { state.route = route if case .detail(let gid) = route, let download = state.downloads.first(where: { $0.gid == gid }) { - state.detailState.wrappedValue = .init(download: download) + var detailState = DetailReducer.State() + detailState.gallery = download.gallery + state.detailState.wrappedValue = detailState } else if case .inspector(let gid) = route { state.inspectorState = .init(gid: gid) } diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index 82d811c03..953d141e6 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -73,13 +73,15 @@ struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { @MainActor @Test - func testDetailReducerDeleteDownloadResetsDownloadContext() async { + func testDetailReducerDeleteDownloadResetsMetadataState() async { let download = sampleDownload(gid: "7733", title: "Reset Context", status: .completed) - var initialState = DetailReducer.State(download: download) + var initialState = DetailReducer.State() + initialState.gallery = download.gallery initialState.galleryVersionMetadata = sampleVersionMetadata( gid: download.gid, token: download.token ) initialState.didRequestVersionMetadata = true + initialState.shouldCheckForRemoteUpdates = true let store = TestStore(initialState: initialState) { DetailReducer() @@ -94,12 +96,10 @@ struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { await store.send(.deleteDownloadDone(.success(()))) { $0.galleryVersionMetadata = nil $0.didRequestVersionMetadata = false - $0.isDownloadContext = false $0.shouldCheckForRemoteUpdates = false } await store.skipReceivedActions(strict: false) - #expect(store.state.isDownloadContext == false) #expect(store.state.shouldCheckForRemoteUpdates == false) #expect(store.state.didRequestVersionMetadata == false) #expect(store.state.galleryVersionMetadata == nil) diff --git a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift index 83ac6d49b..5c1087b6c 100644 --- a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift @@ -69,7 +69,8 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { func testDetailReducerOpenReadingUsesLocalManifestWhenAvailable() async throws { let download = sampleDownload(gid: "888", title: "Offline Archive", status: .completed, pageCount: 2) let manifest = try sampleManifest(gid: download.gid, title: download.title) - var initialState = DetailReducer.State(download: download) + var initialState = DetailReducer.State() + initialState.gallery = download.gallery initialState.galleryDetail = sampleGalleryDetail(gid: download.gid, title: download.title) let store = TestStore(initialState: initialState) { diff --git a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift index 95709dad9..44a85dc33 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -18,7 +18,8 @@ struct ReadingReducerDownloadTests: DownloadFeatureTestCase { gid: "889", title: "Offline Archive", status: .completed, pageCount: 2 ) let detail = sampleGalleryDetail(gid: download.gid, title: download.title) - var initialState = DetailReducer.State(download: download) + var initialState = DetailReducer.State() + initialState.gallery = download.gallery initialState.galleryDetail = detail let metadata = DownloadVersionMetadata( gid: detail.gid, token: download.token, From 7edf64ba1a5b53d91fe627efab8ce3c7b7e991d5 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:23:14 +0800 Subject: [PATCH 060/614] Store reading language --- EhPanda/View/Downloads/DownloadsReducer.swift | 21 +---------------- .../Reading/ReadingReducer+Database.swift | 23 ++----------------- EhPanda/View/Reading/ReadingReducer.swift | 2 +- EhPanda/View/Reading/ReadingView.swift | 4 ++-- .../DownloadObserverReadingTests.swift | 10 +------- 5 files changed, 7 insertions(+), 53 deletions(-) diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index c523709c6..78135f3ac 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -243,25 +243,6 @@ struct DownloadsReducer { private extension ReadingReducer.State { mutating func applyDownloadFallback(_ download: DownloadedGallery) { gallery = download.gallery - galleryDetail = GalleryDetail( - gid: download.gid, - title: download.title, - jpnTitle: download.jpnTitle, - isFavorited: false, - visibility: .yes, - rating: download.rating, - userRating: 0, - ratingCount: 0, - category: download.category, - language: .other, - uploader: download.uploader ?? "", - postedDate: download.postedDate, - coverURL: download.coverURL, - favoritedCount: 0, - pageCount: download.pageCount, - sizeCount: 0, - sizeType: "", - torrentCount: 0 - ) + language = .other } } diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift index c9dd1a3b2..5c3c112ea 100644 --- a/EhPanda/View/Reading/ReadingReducer+Database.swift +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -26,7 +26,7 @@ extension ReadingReducer { } else { guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } state.gallery = gallery - state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) + state.language = databaseClient.fetchGalleryDetail(gid: state.gallery.id)?.language } return .run { [state] send in guard let dbState = await databaseClient.fetchGalleryState(gid: state.gallery.id) else { return } @@ -121,26 +121,7 @@ extension ReadingReducer { guard let folderURL = download.folderURL else { return } state.gallery = download.gallery - state.galleryDetail = GalleryDetail( - gid: download.gid, - title: download.title, - jpnTitle: download.jpnTitle, - isFavorited: false, - visibility: .yes, - rating: download.rating, - userRating: 0, - ratingCount: 0, - category: download.category, - language: manifest.language, - uploader: download.uploader ?? "", - postedDate: download.postedDate, - coverURL: download.coverURL, - favoritedCount: 0, - pageCount: download.pageCount, - sizeCount: 0, - sizeType: "", - torrentCount: 0 - ) + state.language = manifest.language let imageURLs = manifest.imageURLs(folderURL: folderURL) state.localPageURLs = imageURLs state.previewConfig = .normal(rows: 4) diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index 9a32deb4b..659c51be3 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -37,7 +37,7 @@ struct ReadingReducer { var route: Route? var contentSource: ReadingContentSource = .remote var gallery: Gallery = .empty - var galleryDetail: GalleryDetail? + var language: Language? var readingProgress: Int = .zero var forceRefreshID: UUID = .init() diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index dce207b8a..16d3da9ef 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -314,7 +314,7 @@ extension ReadingView { let cgImage = image.cgImage { liveTextHandler.analyzeImage( cgImage, size: image.size, index: index, recognitionLanguages: - store.galleryDetail?.language.codes + store.language?.codes ) } else { Logger.info("analyzeImageForLiveText local image not found", context: ["index": index]) @@ -332,7 +332,7 @@ extension ReadingView { if let image = result.image, let cgImage = image.cgImage { liveTextHandler.analyzeImage( cgImage, size: image.size, index: index, recognitionLanguages: - store.galleryDetail?.language.codes + store.language?.codes ) } else { await retrieveCachedImage(cacheKeys: cacheKeys.dropFirst(), index: index) diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index 726a7df88..997c9244a 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -39,15 +39,7 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { await store.send(.fetchDatabaseInfos(download.gid)) { $0.gallery = download.gallery - $0.galleryDetail = GalleryDetail( - gid: download.gid, title: download.title, jpnTitle: download.jpnTitle, - isFavorited: false, visibility: .yes, rating: download.rating, - userRating: 0, ratingCount: 0, category: download.category, - language: manifest.language, uploader: download.uploader ?? "", - postedDate: download.postedDate, coverURL: download.coverURL, - favoritedCount: 0, pageCount: download.pageCount, sizeCount: 0, sizeType: "", - torrentCount: 0 - ) + $0.language = manifest.language $0.localPageURLs = [ 1: folderURL.appendingPathComponent("pages/0001.jpg"), 2: folderURL.appendingPathComponent("pages/0002.jpg") From b7b8ba58e8ff7c6e5fb13e868a024818ba2a022e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:28:37 +0800 Subject: [PATCH 061/614] Use download thread limit --- EhPanda/App/Generated/Strings.swift | 14 --- EhPanda/App/en.lproj/Localizable.strings | 7 -- EhPanda/App/zh-Hans.lproj/Localizable.strings | 7 -- .../Models/Persistent/DownloadedGallery.swift | 89 ++----------------- EhPanda/Models/Persistent/Setting.swift | 7 +- .../Components/DownloadSettingView.swift | 27 +++--- EhPanda/View/Setting/SettingView.swift | 2 +- .../Download/DetailReducerDownloadTests.swift | 2 +- .../DownloadFileStorageStateTests.swift | 2 +- .../Parser/Other/SettingDownloadTests.swift | 10 +-- 10 files changed, 34 insertions(+), 133 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 40608baac..2b2cebe89 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -1525,20 +1525,6 @@ internal enum L10n { internal static let update = L10n.tr("Localizable", "enum.download_list_filter.title.update", fallback: "Update Available") } } - internal enum DownloadThreadMode { - internal enum Value { - /// 2 images at a time - internal static let double = L10n.tr("Localizable", "enum.download_thread_mode.value.double", fallback: "2 images at a time") - /// 4 images at a time - internal static let quadruple = L10n.tr("Localizable", "enum.download_thread_mode.value.quadruple", fallback: "4 images at a time") - /// 5 images at a time - internal static let quintuple = L10n.tr("Localizable", "enum.download_thread_mode.value.quintuple", fallback: "5 images at a time") - /// 1 image at a time - internal static let single = L10n.tr("Localizable", "enum.download_thread_mode.value.single", fallback: "1 image at a time") - /// 3 images at a time - internal static let triple = L10n.tr("Localizable", "enum.download_thread_mode.value.triple", fallback: "3 images at a time") - } - } internal enum EhSetting { internal enum ArchiverBehavior { internal enum Value { diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index a73a6c42d..b68e23d4d 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -450,13 +450,6 @@ // AutoPlayPolicy "enum.auto_play_policy.value.off" = "Off"; -// MARK: DownloadThreadMode -"enum.download_thread_mode.value.single" = "1 image at a time"; -"enum.download_thread_mode.value.double" = "2 images at a time"; -"enum.download_thread_mode.value.triple" = "3 images at a time"; -"enum.download_thread_mode.value.quadruple" = "4 images at a time"; -"enum.download_thread_mode.value.quintuple" = "5 images at a time"; - // MARK: DownloadListFilter "enum.download_list_filter.title.all" = "All"; "enum.download_list_filter.title.active" = "Active"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 1ffd80df9..2418e7f6b 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -449,13 +449,6 @@ // AutoPlayPolicy "enum.auto_play_policy.value.off" = "不启用"; -// MARK: DownloadThreadMode -"enum.download_thread_mode.value.single" = "同时下载 1 张图片"; -"enum.download_thread_mode.value.double" = "同时下载 2 张图片"; -"enum.download_thread_mode.value.triple" = "同时下载 3 张图片"; -"enum.download_thread_mode.value.quadruple" = "同时下载 4 张图片"; -"enum.download_thread_mode.value.quintuple" = "同时下载 5 张图片"; - // MARK: DownloadListFilter "enum.download_list_filter.title.all" = "全部"; "enum.download_list_filter.title.active" = "进行中"; diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index dbbe1ae17..b1611f782 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -5,116 +5,41 @@ import SwiftUI -enum DownloadThreadMode: Codable, CaseIterable, Identifiable, Sendable { - case single - case double - case triple - case quadruple - case quintuple - - var id: Int { workerCount } - - var value: String { - switch self { - case .single: - return L10n.Localizable.Enum.DownloadThreadMode.Value.single - case .double: - return L10n.Localizable.Enum.DownloadThreadMode.Value.double - case .triple: - return L10n.Localizable.Enum.DownloadThreadMode.Value.triple - case .quadruple: - return L10n.Localizable.Enum.DownloadThreadMode.Value.quadruple - case .quintuple: - return L10n.Localizable.Enum.DownloadThreadMode.Value.quintuple - } - } - - var workerCount: Int { - switch self { - case .single: - return 1 - case .double: - return 2 - case .triple: - return 3 - case .quadruple: - return 4 - case .quintuple: - return 5 - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let storedValue = (try? container.decode(String.self)) ?? "" - switch storedValue { - case "single": - self = .single - case "double": - self = .double - case "triple": - self = .triple - case "quadruple": - self = .quadruple - case "quintuple": - self = .quintuple - default: - self = .single - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .single: - try container.encode("single") - case .double: - try container.encode("double") - case .triple: - try container.encode("triple") - case .quadruple: - try container.encode("quadruple") - case .quintuple: - try container.encode("quintuple") - } - } -} - struct DownloadOptionsSnapshot: Codable, Equatable, Sendable { - var threadMode: DownloadThreadMode = .single + var threadLimit = 1 var allowCellular = true var autoRetryFailedPages = true var workerCount: Int { - threadMode.workerCount + threadLimit } private enum CodingKeys: String, CodingKey { - case threadMode + case threadLimit case allowCellular case autoRetryFailedPages } init( - threadMode: DownloadThreadMode = .single, + threadLimit: Int = 1, allowCellular: Bool = true, autoRetryFailedPages: Bool = true ) { - self.threadMode = threadMode + self.threadLimit = threadLimit self.allowCellular = allowCellular self.autoRetryFailedPages = autoRetryFailedPages } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - threadMode = try container.decodeIfPresent(DownloadThreadMode.self, forKey: .threadMode) ?? .single + threadLimit = try container.decodeIfPresent(Int.self, forKey: .threadLimit) ?? 1 allowCellular = try container.decodeIfPresent(Bool.self, forKey: .allowCellular) ?? true autoRetryFailedPages = try container.decodeIfPresent(Bool.self, forKey: .autoRetryFailedPages) ?? true } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(threadMode, forKey: .threadMode) + try container.encode(threadLimit, forKey: .threadLimit) try container.encode(allowCellular, forKey: .allowCellular) try container.encode(autoRetryFailedPages, forKey: .autoRetryFailedPages) } diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift index 04849eaed..d4b6c6b98 100644 --- a/EhPanda/Models/Persistent/Setting.swift +++ b/EhPanda/Models/Persistent/Setting.swift @@ -50,7 +50,7 @@ struct Setting: Codable, Equatable { var doubleTapScaleFactor: Double = 2 // Downloads - var downloadThreadMode: DownloadThreadMode = .single + var downloadThreadLimit = 1 var downloadAllowCellular = true var downloadAutoRetryFailedPages = true @@ -61,7 +61,7 @@ struct Setting: Codable, Equatable { extension Setting { var downloadOptionsSnapshot: DownloadOptionsSnapshot { .init( - threadMode: downloadThreadMode, + threadLimit: downloadThreadLimit, allowCellular: downloadAllowCellular, autoRetryFailedPages: downloadAutoRetryFailedPages ) @@ -231,8 +231,7 @@ extension Setting { maximumScaleFactor = (try? container?.decodeIfPresent(Double.self, forKey: .maximumScaleFactor)) ?? 3 doubleTapScaleFactor = (try? container?.decodeIfPresent(Double.self, forKey: .doubleTapScaleFactor)) ?? 2 // Downloads - downloadThreadMode = (try? container?.decodeIfPresent(DownloadThreadMode.self, forKey: .downloadThreadMode)) - ?? .single + downloadThreadLimit = (try? container?.decodeIfPresent(Int.self, forKey: .downloadThreadLimit)) ?? 1 downloadAllowCellular = (try? container?.decodeIfPresent(Bool.self, forKey: .downloadAllowCellular)) ?? true downloadAutoRetryFailedPages = ( try? container?.decodeIfPresent(Bool.self, forKey: .downloadAutoRetryFailedPages) diff --git a/EhPanda/View/Setting/Components/DownloadSettingView.swift b/EhPanda/View/Setting/Components/DownloadSettingView.swift index 7a00a257e..986e96703 100644 --- a/EhPanda/View/Setting/Components/DownloadSettingView.swift +++ b/EhPanda/View/Setting/Components/DownloadSettingView.swift @@ -6,16 +6,16 @@ import SwiftUI struct DownloadSettingView: View { - @Binding private var downloadThreadMode: DownloadThreadMode + @Binding private var downloadThreadLimit: Int @Binding private var downloadAllowCellular: Bool @Binding private var downloadAutoRetryFailedPages: Bool init( - downloadThreadMode: Binding, + downloadThreadLimit: Binding, downloadAllowCellular: Binding, downloadAutoRetryFailedPages: Binding ) { - _downloadThreadMode = downloadThreadMode + _downloadThreadLimit = downloadThreadLimit _downloadAllowCellular = downloadAllowCellular _downloadAutoRetryFailedPages = downloadAutoRetryFailedPages } @@ -23,15 +23,13 @@ struct DownloadSettingView: View { var body: some View { Form { Section { - Picker( - L10n.Localizable.DownloadSettingView.Title.concurrentImageDownloads, - selection: $downloadThreadMode - ) { - ForEach(DownloadThreadMode.allCases) { - Text($0.value).tag($0) + VStack(alignment: .leading) { + LabeledContent(L10n.Localizable.DownloadSettingView.Title.concurrentImageDownloads) { + Text(downloadThreadLimit, format: .number) + .monospacedDigit() } + Slider(value: downloadThreadLimitValue, in: 1...5, step: 1) } - .pickerStyle(.menu) Toggle( L10n.Localizable.DownloadSettingView.Title.retryFailedPagesAutomatically, isOn: $downloadAutoRetryFailedPages @@ -51,13 +49,20 @@ struct DownloadSettingView: View { } .navigationTitle(L10n.Localizable.DownloadSettingView.title) } + + private var downloadThreadLimitValue: Binding { + .init( + get: { Double(downloadThreadLimit) }, + set: { downloadThreadLimit = Int($0.rounded()) } + ) + } } struct DownloadSettingView_Previews: PreviewProvider { static var previews: some View { NavigationView { DownloadSettingView( - downloadThreadMode: .constant(.single), + downloadThreadLimit: .constant(1), downloadAllowCellular: .constant(true), downloadAutoRetryFailedPages: .constant(true) ) diff --git a/EhPanda/View/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift index 7ecb489fd..e3e759097 100644 --- a/EhPanda/View/Setting/SettingView.swift +++ b/EhPanda/View/Setting/SettingView.swift @@ -91,7 +91,7 @@ private extension SettingView { } NavigationLink(unwrapping: $store.route, case: \.download) { _ in DownloadSettingView( - downloadThreadMode: $store.setting.downloadThreadMode, + downloadThreadLimit: $store.setting.downloadThreadLimit, downloadAllowCellular: $store.setting.downloadAllowCellular, downloadAutoRetryFailedPages: $store.setting.downloadAutoRetryFailedPages ) diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index 84e61297e..095ec1a7d 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -18,7 +18,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let options = DownloadOptionsSnapshot( - threadMode: .quadruple, + threadLimit: 4, allowCellular: false, autoRetryFailedPages: false ) diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift index c0b8fa0df..7666d9fae 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift @@ -22,7 +22,7 @@ struct DownloadFileStorageStateTests { versionSignature: "hash:v2", pageCount: 27, downloadOptions: .init( - threadMode: .quadruple, + threadLimit: 4, allowCellular: false, autoRetryFailedPages: false ) diff --git a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift index fadf4e6b4..c0a413d38 100644 --- a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -19,7 +19,7 @@ struct SettingDownloadTests { let setting = try JSONDecoder().decode(Setting.self, from: data) - #expect(setting.downloadThreadMode == .single) + #expect(setting.downloadThreadLimit == 1) #expect(setting.downloadAllowCellular) #expect(setting.downloadAutoRetryFailedPages) } @@ -27,13 +27,13 @@ struct SettingDownloadTests { @Test func testDownloadOptionsSnapshotMatchesSettingValues() { var setting = Setting() - setting.downloadThreadMode = .quadruple + setting.downloadThreadLimit = 4 setting.downloadAllowCellular = false setting.downloadAutoRetryFailedPages = false #expect( setting.downloadOptionsSnapshot == DownloadOptionsSnapshot( - threadMode: .quadruple, + threadLimit: 4, allowCellular: false, autoRetryFailedPages: false ) @@ -44,7 +44,7 @@ struct SettingDownloadTests { func testLegacyDownloadOptionsSnapshotDecodesWithoutOriginalImageField() throws { let data = Data(""" { - "threadMode": "triple", + "threadLimit": 3, "useOriginalImages": true, "allowCellular": false, "autoRetryFailedPages": false @@ -55,7 +55,7 @@ struct SettingDownloadTests { #expect( snapshot == DownloadOptionsSnapshot( - threadMode: .triple, + threadLimit: 3, allowCellular: false, autoRetryFailedPages: false ) From 2be4234ec0951922da10a25bd51c0e498636843d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:37:08 +0800 Subject: [PATCH 062/614] Seed detail badge --- EhPanda/View/Downloads/DownloadsReducer.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index 78135f3ac..456125ffe 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -94,7 +94,9 @@ struct DownloadsReducer { if case .detail(let gid) = route, let download = state.downloads.first(where: { $0.gid == gid }) { var detailState = DetailReducer.State() + detailState.gid = download.gid detailState.gallery = download.gallery + _ = DetailReducer().applyDownloadBadge(download.badge, state: &detailState) state.detailState.wrappedValue = detailState } else if case .inspector(let gid) = route { state.inspectorState = .init(gid: gid) From 2881c0ce15e3fbec2c4856587365d23bb1eb0f32 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:38:51 +0800 Subject: [PATCH 063/614] Ban unchecked sendable --- .swiftlint.yml | 12 ++++++ EhPanda/Models/Gallery/Category.swift | 2 +- EhPanda/Models/Gallery/Gallery.swift | 2 +- EhPanda/Models/Gallery/GalleryDetail.swift | 4 +- EhPanda/Models/Gallery/GalleryState.swift | 6 +-- EhPanda/Models/Gallery/Language.swift | 2 +- .../DownloadedGallery+Extensions.swift | 2 +- EhPanda/Models/Persistent/Setting.swift | 2 +- EhPanda/Models/Tags/TagNamespace.swift | 2 +- .../DownloadFeatureTestSupportTypes.swift | 42 ++++++++----------- 10 files changed, 41 insertions(+), 35 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 195d1a263..292b7e6cd 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -21,5 +21,17 @@ line_length: warning: 120 error: 120 +custom_rules: + no_unchecked_sendable: + name: "No @unchecked Sendable" + regex: "@unchecked\\s+Sendable" + message: "@unchecked Sendable is banned - use a real Sendable value type, an actor, or Mutex." + severity: error + no_nslock: + name: "No NSLock" + regex: "\\bNSLock\\b" + message: "NSLock is banned - use Mutex (Synchronization) instead." + severity: error + excluded: - EhPanda/App/Generated diff --git a/EhPanda/Models/Gallery/Category.swift b/EhPanda/Models/Gallery/Category.swift index 7c1504161..64cf724b6 100644 --- a/EhPanda/Models/Gallery/Category.swift +++ b/EhPanda/Models/Gallery/Category.swift @@ -5,7 +5,7 @@ import SwiftUI -enum Category: String, Codable, CaseIterable, Identifiable { +enum Category: String, Codable, CaseIterable, Identifiable, Sendable { var id: String { rawValue } static let allFavoritesCases: [Self] = [.misc] + allCases.dropLast(2) diff --git a/EhPanda/Models/Gallery/Gallery.swift b/EhPanda/Models/Gallery/Gallery.swift index 072433620..451bc548e 100644 --- a/EhPanda/Models/Gallery/Gallery.swift +++ b/EhPanda/Models/Gallery/Gallery.swift @@ -5,7 +5,7 @@ import SwiftUI -struct Gallery: Identifiable, Codable, Equatable, Hashable { +struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { static func == (lhs: Gallery, rhs: Gallery) -> Bool { lhs.gid == rhs.gid } diff --git a/EhPanda/Models/Gallery/GalleryDetail.swift b/EhPanda/Models/Gallery/GalleryDetail.swift index fd699e8d7..7cbc4a23a 100644 --- a/EhPanda/Models/Gallery/GalleryDetail.swift +++ b/EhPanda/Models/Gallery/GalleryDetail.swift @@ -5,7 +5,7 @@ import Foundation -struct GalleryDetail: Codable, Equatable { +struct GalleryDetail: Codable, Equatable, Sendable { static let empty: Self = .init( gid: "", title: "", isFavorited: false, visibility: .yes, rating: 0, userRating: 0, @@ -78,7 +78,7 @@ extension GalleryDetail: DateFormattable { } } -enum GalleryVisibility: Codable, Equatable { +enum GalleryVisibility: Codable, Equatable, Sendable { case yes // swiftlint:disable:next identifier_name case no(reason: String) diff --git a/EhPanda/Models/Gallery/GalleryState.swift b/EhPanda/Models/Gallery/GalleryState.swift index f649f16a7..204a8f1a1 100644 --- a/EhPanda/Models/Gallery/GalleryState.swift +++ b/EhPanda/Models/Gallery/GalleryState.swift @@ -40,8 +40,8 @@ extension GalleryState: CustomStringConvertible { } } -struct GalleryTag: Codable, Equatable, Hashable, Identifiable { - struct Content: Codable, Equatable, Hashable, Identifiable { +struct GalleryTag: Codable, Equatable, Hashable, Identifiable, Sendable { + struct Content: Codable, Equatable, Hashable, Identifiable, Sendable { var id: String { rawNamespace + text } var firstLetterCapitalizedText: String { text.firstLetterCapitalized @@ -73,7 +73,7 @@ struct GalleryTag: Codable, Equatable, Hashable, Identifiable { let contents: [Content] } -enum PreviewConfig: Codable, Equatable { +enum PreviewConfig: Codable, Equatable, Sendable { case normal(rows: Int) case large(rows: Int) } diff --git a/EhPanda/Models/Gallery/Language.swift b/EhPanda/Models/Gallery/Language.swift index 9fbe0ec24..044ef6752 100644 --- a/EhPanda/Models/Gallery/Language.swift +++ b/EhPanda/Models/Gallery/Language.swift @@ -3,7 +3,7 @@ // EhPanda // -enum Language: String, Codable { +enum Language: String, Codable, Sendable { static let allExcludedCases: [Self] = [ .japanese, .english, .chinese, .dutch, .french, .german, .hungarian, .italian, .korean, .polish, .portuguese, .russian, .spanish, .thai, .vietnamese, .invalid, .other diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift index 952aa2f9a..ca10cfb63 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -84,7 +84,7 @@ enum DownloadListFilter: String, CaseIterable, Identifiable { } // MARK: - DownloadRequestPayload -struct DownloadRequestPayload: Equatable, @unchecked Sendable { +struct DownloadRequestPayload: Equatable, Sendable { let gallery: Gallery let galleryDetail: GalleryDetail let previewURLs: [Int: URL] diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift index d4b6c6b98..3f6b94c86 100644 --- a/EhPanda/Models/Persistent/Setting.swift +++ b/EhPanda/Models/Persistent/Setting.swift @@ -68,7 +68,7 @@ extension Setting { } } -enum GalleryHost: String, Codable, Equatable, CaseIterable, Identifiable { +enum GalleryHost: String, Codable, Equatable, CaseIterable, Identifiable, Sendable { case ehentai = "E-Hentai" case exhentai = "ExHentai" diff --git a/EhPanda/Models/Tags/TagNamespace.swift b/EhPanda/Models/Tags/TagNamespace.swift index b423b1d88..83a124a85 100644 --- a/EhPanda/Models/Tags/TagNamespace.swift +++ b/EhPanda/Models/Tags/TagNamespace.swift @@ -3,7 +3,7 @@ // EhPanda // -enum TagNamespace: String, Codable, CaseIterable { +enum TagNamespace: String, Codable, CaseIterable, Sendable { case reclass case language case parody diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift index b411e7a5b..b853a50a6 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift @@ -9,11 +9,16 @@ import Synchronization // MARK: - Supporting Types -final class UncheckedBox: @unchecked Sendable { - var value: Value +final class UncheckedBox: Sendable { + private let storage: Mutex init(_ value: Value) { - self.value = value + storage = Mutex(value) + } + + var value: Value { + get { storage.withLock { $0 } } + set { storage.withLock { $0 = newValue } } } } @@ -26,50 +31,39 @@ struct RequestRecorderSnapshot: Equatable { var previewPageNumbers = [Int]() } -final class RequestRecorder: @unchecked Sendable { - private let lock = NSLock() - private var state = RequestRecorderSnapshot() +final class RequestRecorder: Sendable { + private let state = Mutex(RequestRecorderSnapshot()) func recordDetail() { - mutate { $0.detailRequests += 1 } + state.withLock { $0.detailRequests += 1 } } func recordMetadata() { - mutate { $0.metadataRequests += 1 } + state.withLock { $0.metadataRequests += 1 } } func recordPreview(_ pageNumber: Int) { - mutate { $0.previewPageNumbers.append(pageNumber) } + state.withLock { $0.previewPageNumbers.append(pageNumber) } } func recordMPV() { - mutate { $0.mpvRequests += 1 } + state.withLock { $0.mpvRequests += 1 } } func recordImageDispatch() { - mutate { $0.imageDispatchRequests += 1 } + state.withLock { $0.imageDispatchRequests += 1 } } func recordImageDownload() { - mutate { $0.imageDownloads += 1 } + state.withLock { $0.imageDownloads += 1 } } func reset() { - mutate { $0 = .init() } + state.withLock { $0 = .init() } } func snapshot() -> RequestRecorderSnapshot { - lock.lock() - defer { lock.unlock() } - return state - } - - private func mutate( - _ update: (inout RequestRecorderSnapshot) -> Void - ) { - lock.lock() - defer { lock.unlock() } - update(&state) + state.withLock { $0 } } } From 61046b40887ac3a7114b26f4c9d18ad8525e2f1c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:40:33 +0800 Subject: [PATCH 064/614] Add reducer lint rules --- .swiftlint.yml | 10 ++++++++++ EhPanda/View/Downloads/DownloadsReducer.swift | 12 +++--------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 292b7e6cd..de2281578 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -32,6 +32,16 @@ custom_rules: regex: "\\bNSLock\\b" message: "NSLock is banned - use Mutex (Synchronization) instead." severity: error + scope_reducer_child_shorthand: + name: "Scope child shorthand" + regex: 'Scope\([^)]*\)\s*\{\s*[A-Z]\w*\(\)\s*\}' + message: "Use Scope(state:action:child: Reducer.init) instead of expanding a closure for a single bare Reducer()." + severity: error + foreach_reducer_element_shorthand: + name: "forEach element shorthand" + regex: 'forEach\([^)]*\)\s*\{\s*[A-Z]\w*\(\)\s*\}' + message: "Use .forEach(_:action:element: Reducer.init) instead of expanding a closure for a single bare Reducer()." + severity: error excluded: - EhPanda/App/Generated diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index 456125ffe..24398370b 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -230,15 +230,9 @@ struct DownloadsReducer { } } - Scope(state: \.detailState.wrappedValue!, action: \.detail) { - DetailReducer() - } - Scope(state: \.readingState, action: \.reading) { - ReadingReducer() - } - Scope(state: \.inspectorState, action: \.inspector) { - DownloadInspectorReducer() - } + Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) + Scope(state: \.readingState, action: \.reading, child: ReadingReducer.init) + Scope(state: \.inspectorState, action: \.inspector, child: DownloadInspectorReducer.init) } } From dc0c927f90e6c51f670baf323719aded10bf2970 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:42:20 +0800 Subject: [PATCH 065/614] Extract JSON storage --- .../DownloadFileStorage+JSONCoding.swift | 16 +++++++++++++++ .../Tools/Utilities/DownloadFileStorage.swift | 20 ++++++------------- 2 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 EhPanda/App/Tools/Utilities/DownloadFileStorage+JSONCoding.swift diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+JSONCoding.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+JSONCoding.swift new file mode 100644 index 000000000..77d030089 --- /dev/null +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+JSONCoding.swift @@ -0,0 +1,16 @@ +// +// DownloadFileStorage+JSONCoding.swift +// EhPanda +// + +import Foundation + +extension DownloadFileStorage { + func writeJSON(_ value: T, to url: URL) throws { + try JSONEncoder().encode(value).write(to: url, options: .atomic) + } + + func readJSON(_ type: T.Type, from url: URL) throws -> T { + try JSONDecoder().decode(type, from: Data(contentsOf: url)) + } +} diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index b7ae7a813..1eeca528e 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -114,23 +114,19 @@ struct DownloadFileStorage: Sendable { } func writeResumeState(_ state: DownloadResumeState, folderURL: URL) throws { - let data = try JSONEncoder().encode(state) - try data.write(to: resumeStateURL(folderURL: folderURL), options: .atomic) + try writeJSON(state, to: resumeStateURL(folderURL: folderURL)) } func readResumeState(folderURL: URL) throws -> DownloadResumeState { - let data = try Data(contentsOf: resumeStateURL(folderURL: folderURL)) - return try JSONDecoder().decode(DownloadResumeState.self, from: data) + try readJSON(DownloadResumeState.self, from: resumeStateURL(folderURL: folderURL)) } func writeFailedPages(_ snapshot: DownloadFailedPagesSnapshot, folderURL: URL) throws { - let data = try JSONEncoder().encode(snapshot) - try data.write(to: failedPagesURL(folderURL: folderURL), options: .atomic) + try writeJSON(snapshot, to: failedPagesURL(folderURL: folderURL)) } func readFailedPages(folderURL: URL) throws -> DownloadFailedPagesSnapshot { - let data = try Data(contentsOf: failedPagesURL(folderURL: folderURL)) - return try JSONDecoder().decode(DownloadFailedPagesSnapshot.self, from: data) + try readJSON(DownloadFailedPagesSnapshot.self, from: failedPagesURL(folderURL: folderURL)) } func removeFailedPages(folderURL: URL) throws { @@ -228,15 +224,11 @@ struct DownloadFileStorage: Sendable { } func writeManifest(_ manifest: DownloadManifest, folderURL: URL) throws { - let data = try JSONEncoder().encode(manifest) - let fileURL = folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) - try data.write(to: fileURL, options: .atomic) + try writeJSON(manifest, to: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest)) } func readManifest(folderURL: URL) throws -> DownloadManifest { - let manifestURL = folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) - let data = try Data(contentsOf: manifestURL) - return try JSONDecoder().decode(DownloadManifest.self, from: data) + try readJSON(DownloadManifest.self, from: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest)) } func fileHash(at url: URL) throws -> String { From a49b2b125f21657d779599909274bcbbe4c5aaeb Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 22:54:19 +0800 Subject: [PATCH 066/614] Use concrete dirs --- .../DownloadClient+ExecutionSupport.swift | 4 +-- .../DownloadClient+PersistenceHelpers.swift | 19 +++++++------- .../DownloadClient+PersistenceNormalize.swift | 12 ++++----- .../Clients/DownloadClient+PublicAPI.swift | 5 ++-- .../App/Tools/Clients/DownloadClient.swift | 2 +- EhPanda/App/Tools/Clients/FileClient.swift | 21 +++++---------- EhPanda/App/Tools/Clients/LibraryClient.swift | 2 +- .../Tools/Clients/UIApplicationClient.swift | 4 +-- .../DownloadFileStorage+Operations.swift | 9 +++---- .../Tools/Utilities/DownloadFileStorage.swift | 6 +---- EhPanda/App/Tools/Utilities/FileUtil.swift | 20 ++++++-------- .../DownloadedGallery+SupportTypes.swift | 26 +++++++++---------- .../Reading/ReadingReducer+Database.swift | 2 +- .../Download/DownloadBadgeSortTests.swift | 5 +--- .../DownloadFeatureTestFactories.swift | 11 +------- .../DownloadObserverReadingTests.swift | 3 +-- 16 files changed, 56 insertions(+), 95 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index cd1513cd4..b413cccdb 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -291,9 +291,9 @@ extension DownloadManager { payload: DownloadRequestPayload, versionSignature: String ) -> RepairSeed? { + let folderURL = download + .resolvedFolderURL(rootURL: storage.rootURL) guard payload.mode == .repair, - let folderURL = download - .resolvedFolderURL(rootURL: storage.rootURL), fileManager .fileExists(atPath: folderURL.path), let manifest = try? storage diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index 304079624..b78ac0594 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -27,9 +27,9 @@ extension DownloadManager { func validatedCompletedPageCount( _ download: DownloadedGallery ) -> Int { - guard let folderURL = download - .resolvedFolderURL(rootURL: storage.rootURL), - fileManager + let folderURL = download + .resolvedFolderURL(rootURL: storage.rootURL) + guard fileManager .fileExists(atPath: folderURL.path) else { return 0 @@ -112,10 +112,9 @@ extension DownloadManager { } private func scanCompletedFolder(download: DownloadedGallery) { - guard let completedFolderURL = download - .resolvedFolderURL(rootURL: storage.rootURL), - fileManager.fileExists(atPath: completedFolderURL.path) - else { return } + let completedFolderURL = download + .resolvedFolderURL(rootURL: storage.rootURL) + guard fileManager.fileExists(atPath: completedFolderURL.path) else { return } _ = storage.existingPageRelativePaths( folderURL: completedFolderURL, expectedPageCount: download.pageCount @@ -273,9 +272,9 @@ extension DownloadManager { ) } - guard let completedFolderURL = download - .resolvedFolderURL(rootURL: storage.rootURL), - fileManager + let completedFolderURL = download + .resolvedFolderURL(rootURL: storage.rootURL) + guard fileManager .fileExists(atPath: completedFolderURL.path) else { return nil diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 20a9c5def..bf71aedcf 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -36,10 +36,8 @@ extension DownloadManager { .resolvedFolderURL(rootURL: storage.rootURL) let temporaryFolderExists = fileManager .fileExists(atPath: temporaryFolderURL.path) - let completedFolderExists = completedFolderURL - .map { - fileManager.fileExists(atPath: $0.path) - } ?? false + let completedFolderExists = fileManager + .fileExists(atPath: completedFolderURL.path) if shouldExposeTemporaryWorkingSet(for: download) { return temporaryFolderExists @@ -234,9 +232,9 @@ extension DownloadManager { private func refreshMissingManifestHashesIfNeeded( download: DownloadedGallery ) { - guard let folderURL = download - .resolvedFolderURL(rootURL: storage.rootURL), - let manifest = try? storage.readManifest(folderURL: folderURL), + let folderURL = download + .resolvedFolderURL(rootURL: storage.rootURL) + guard let manifest = try? storage.readManifest(folderURL: folderURL), manifest.needsFileHashRefresh else { return diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 7dfbe3015..4dcf1f1f6 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -255,11 +255,10 @@ extension DownloadManager { } else { resolvedDownload = await fetchDownload(gid: gid) } - guard let download = resolvedDownload, - let folderURL = download.resolvedFolderURL(rootURL: storage.rootURL) - else { + guard let download = resolvedDownload else { return .failure(.notFound) } + let folderURL = download.resolvedFolderURL(rootURL: storage.rootURL) switch storage.validate(download: download) { case .valid: break diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 6fbb873f5..9352db055 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -79,7 +79,7 @@ struct DownloadClient: Sendable { extension DownloadClient { static func live( - rootURL: URL? = FileUtil.downloadsDirectoryURL, + rootURL: URL = FileUtil.downloadsDirectoryURL, urlSession: URLSession = .shared, fileManager: sending FileManager = .default ) -> Self { diff --git a/EhPanda/App/Tools/Clients/FileClient.swift b/EhPanda/App/Tools/Clients/FileClient.swift index a608ed0aa..42954b1de 100644 --- a/EhPanda/App/Tools/Clients/FileClient.swift +++ b/EhPanda/App/Tools/Clients/FileClient.swift @@ -21,8 +21,7 @@ extension FileClient { }, fetchLogs: { await withCheckedContinuation { continuation in - guard let path = FileUtil.logsDirectoryURL?.path, - let enumerator = FileManager.default.enumerator(atPath: path), + guard let enumerator = FileManager.default.enumerator(atPath: FileUtil.logsDirectoryURL.path), let fileNames = (enumerator.allObjects as? [String])? .filter({ $0.contains(Defaults.FilePath.ehpandaLog) }) else { @@ -31,8 +30,8 @@ extension FileClient { } let logs: [Log] = fileNames.compactMap { name in - guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(name), - let content = try? String(contentsOf: fileURL, encoding: .utf8) + let fileURL = FileUtil.logsDirectoryURL.appendingPathComponent(name) + guard let content = try? String(contentsOf: fileURL, encoding: .utf8) else { return nil } return Log( @@ -47,11 +46,7 @@ extension FileClient { }, deleteLog: { fileName in await withCheckedContinuation { continuation in - guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(fileName) - else { - continuation.resume(returning: .failure(.notFound)) - return - } + let fileURL = FileUtil.logsDirectoryURL.appendingPathComponent(fileName) try? FileManager.default.removeItem(at: fileURL) @@ -81,12 +76,8 @@ extension FileClient { ) func saveTorrent(hash: String, data: Data) -> URL? { - if let cachesDirectory = FileUtil.cachesDirectory { - let torrentDirectory = cachesDirectory.appendingPathComponent("\(hash).torrent") - return createFile(torrentDirectory.path, data) ? torrentDirectory : nil - } else { - return nil - } + let torrentDirectory = FileUtil.cachesDirectory.appendingPathComponent("\(hash).torrent") + return createFile(torrentDirectory.path, data) ? torrentDirectory : nil } } diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index 5b3659f00..c74af5ffa 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -35,7 +35,7 @@ extension LibraryClient { file.format = format file.logFileAmount = 10 file.calendar = Calendar(identifier: .gregorian) - file.logFileURL = FileUtil.logsDirectoryURL? + file.logFileURL = FileUtil.logsDirectoryURL .appendingPathComponent(Defaults.FilePath.ehpandaLog) console.format = format diff --git a/EhPanda/App/Tools/Clients/UIApplicationClient.swift b/EhPanda/App/Tools/Clients/UIApplicationClient.swift index 054603173..3bc539b6b 100644 --- a/EhPanda/App/Tools/Clients/UIApplicationClient.swift +++ b/EhPanda/App/Tools/Clients/UIApplicationClient.swift @@ -51,8 +51,8 @@ extension UIApplicationClient { } @MainActor func openFileApp() { - if let dirPath = FileUtil.logsDirectoryURL?.path, - let dirURL = URL(string: "shareddocuments://" + dirPath) { + let dirPath = FileUtil.logsDirectoryURL.path + if let dirURL = URL(string: "shareddocuments://" + dirPath) { return openURL(dirURL) } } diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index 76020f8c5..a2f6b5e8c 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -181,15 +181,12 @@ extension DownloadFileStorage { } func validate(download: DownloadedGallery) -> DownloadValidationState { - guard let folderURL = download.resolvedFolderURL(rootURL: rootURL) else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadFolderUnresolved) - } + let folderURL = download.resolvedFolderURL(rootURL: rootURL) guard fileManager.fileExists(atPath: folderURL.path) else { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadFolderMissing) } - guard let manifestURL = download.resolvedManifestURL(rootURL: rootURL), - fileManager.fileExists(atPath: manifestURL.path) - else { + let manifestURL = download.resolvedManifestURL(rootURL: rootURL) + guard fileManager.fileExists(atPath: manifestURL.path) else { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestMissing) } guard let manifest = try? readManifest(folderURL: folderURL) else { diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 1eeca528e..d2afbb3f8 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -51,14 +51,10 @@ struct DownloadFileStorage: Sendable { let fileManager: DownloadFileManager init( - rootURL: URL? = FileUtil.downloadsDirectoryURL, + rootURL: URL = FileUtil.downloadsDirectoryURL, fileManager: sending FileManager = .default ) { self.rootURL = rootURL - ?? FileUtil.temporaryDirectory.appendingPathComponent( - Defaults.FilePath.downloads, - isDirectory: true - ) self.fileManager = DownloadFileManager(fileManager) } diff --git a/EhPanda/App/Tools/Utilities/FileUtil.swift b/EhPanda/App/Tools/Utilities/FileUtil.swift index 48f33a333..d52be7720 100644 --- a/EhPanda/App/Tools/Utilities/FileUtil.swift +++ b/EhPanda/App/Tools/Utilities/FileUtil.swift @@ -6,17 +6,17 @@ import Foundation struct FileUtil { - static var documentDirectory: URL? { - url(for: .documentDirectory) + static var documentDirectory: URL { + .documentsDirectory } - static var cachesDirectory: URL? { - url(for: .cachesDirectory) + static var cachesDirectory: URL { + .cachesDirectory } - static var logsDirectoryURL: URL? { - documentDirectory?.appendingPathComponent(Defaults.FilePath.logs) + static var logsDirectoryURL: URL { + documentDirectory.appendingPathComponent(Defaults.FilePath.logs) } - static var downloadsDirectoryURL: URL? { - documentDirectory?.appendingPathComponent( + static var downloadsDirectoryURL: URL { + documentDirectory.appendingPathComponent( Defaults.FilePath.downloads, isDirectory: true ) @@ -24,8 +24,4 @@ struct FileUtil { static var temporaryDirectory: URL { .init(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) } - - static func url(for searchPathDirectory: FileManager.SearchPathDirectory) -> URL? { - try? FileManager.default.url(for: searchPathDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - } } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index cc6485c95..991767127 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -24,20 +24,20 @@ extension DownloadedGallery { .joined(separator: " ") } - func resolvedFolderURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { - rootURL?.appendingPathComponent(folderRelativePath, isDirectory: true) + func resolvedFolderURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL { + rootURL.appendingPathComponent(folderRelativePath, isDirectory: true) } - func resolvedManifestURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { - resolvedFolderURL(rootURL: rootURL)? + func resolvedManifestURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL { + resolvedFolderURL(rootURL: rootURL) .appendingPathComponent(Defaults.FilePath.downloadManifest) } - func resolvedLocalCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { - guard let folderURL = resolvedFolderURL(rootURL: rootURL), - let coverRelativePath, + func resolvedLocalCoverURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL? { + guard let coverRelativePath, !coverRelativePath.isEmpty else { return nil } + let folderURL = resolvedFolderURL(rootURL: rootURL) let coverURL = folderURL.appendingPathComponent(coverRelativePath) guard isReadableLocalAssetFile(coverURL) else { return nil @@ -45,10 +45,8 @@ extension DownloadedGallery { return coverURL } - func resolvedTemporaryCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { - guard shouldPreserveTemporaryWorkingSet, - let rootURL - else { + func resolvedTemporaryCoverURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL? { + guard shouldPreserveTemporaryWorkingSet else { return nil } @@ -77,17 +75,17 @@ extension DownloadedGallery { }) } - func resolvedCoverURL(rootURL: URL? = FileUtil.downloadsDirectoryURL) -> URL? { + func resolvedCoverURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL? { resolvedLocalCoverURL(rootURL: rootURL) ?? resolvedTemporaryCoverURL(rootURL: rootURL) ?? onlineCoverURL } - var folderURL: URL? { + var folderURL: URL { resolvedFolderURL() } - var manifestURL: URL? { + var manifestURL: URL { resolvedManifestURL() } diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift index 5c3c112ea..bbded780c 100644 --- a/EhPanda/View/Reading/ReadingReducer+Database.swift +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -118,7 +118,7 @@ extension ReadingReducer { download: DownloadedGallery, manifest: DownloadManifest ) { - guard let folderURL = download.folderURL else { return } + let folderURL = download.folderURL state.gallery = download.gallery state.language = manifest.language diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index 8583fe1bd..ccd76650d 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -99,10 +99,7 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { completedPageCount: 3 ) - let rootURL = try #require( - FileUtil.downloadsDirectoryURL, - "Downloads directory is unavailable in the test environment." - ) + let rootURL = FileUtil.downloadsDirectoryURL let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) try? FileManager.default.removeItem(at: temporaryFolderURL) diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 597e9729b..e51a191ad 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -109,16 +109,7 @@ extension DownloadFeatureTestCase { download: DownloadedGallery, manifest: DownloadManifest ) throws -> URL { - guard let folderURL = download.folderURL else { - throw NSError( - domain: "DownloadFeatureReducerTests", - code: 1, - userInfo: [ - NSLocalizedDescriptionKey: - "Downloads directory is unavailable in the test environment." - ] - ) - } + let folderURL = download.folderURL try? FileManager.default.removeItem(at: folderURL) try FileManager.default.createDirectory( at: folderURL.appendingPathComponent( diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index 997c9244a..a8e5088a6 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -34,8 +34,7 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { $0.urlClient = .noop } store.exhaustivity = .off - let folderURL = download.folderURL ?? FileManager.default.temporaryDirectory - .appendingPathComponent(download.folderRelativePath, isDirectory: true) + let folderURL = download.folderURL await store.send(.fetchDatabaseInfos(download.gid)) { $0.gallery = download.gallery From 8bb4e9fa4a40b1d3a68f45edf78cd1f45c9a322b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 23:06:13 +0800 Subject: [PATCH 067/614] Collapse file manager --- .../Tools/Clients/DownloadClient+Cache.swift | 6 +- .../DownloadClient+ExecutionSupport.swift | 30 ++--- .../Clients/DownloadClient+Networking.swift | 26 ++-- .../Clients/DownloadClient+PageDownload.swift | 3 +- .../DownloadClient+PersistenceHelpers.swift | 25 ++-- .../DownloadClient+PersistenceNormalize.swift | 10 +- .../DownloadClient+PublicAPIHelpers.swift | 4 +- .../Clients/DownloadClient+RetryHelpers.swift | 14 ++- .../DownloadClient+SchedulingHelpers.swift | 5 +- .../Clients/DownloadClient+Testing.swift | 8 +- .../Tools/Utilities/DownloadFileManager.swift | 21 ++++ .../DownloadFileStorage+Operations.swift | 82 +++++++----- .../Tools/Utilities/DownloadFileStorage.swift | 119 +++++------------- 13 files changed, 172 insertions(+), 181 deletions(-) create mode 100644 EhPanda/App/Tools/Utilities/DownloadFileManager.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 7294ff2ae..dc9ebc377 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -60,8 +60,7 @@ extension DownloadManager { existingPageRelativePaths[index] { let fileURL = temporaryFolderURL .appendingPathComponent(relativePath) - if fileManager - .fileExists(atPath: fileURL.path) { + if fileManager.operate({ $0.fileExists(atPath: fileURL.path) }) { continue } } @@ -149,8 +148,7 @@ extension DownloadManager { let fileURL = folderURL .appendingPathComponent(relativePath) if overwriteExistingFile - || !fileManager - .fileExists(atPath: fileURL.path) { + || !fileManager.operate({ $0.fileExists(atPath: fileURL.path) }) { try write(data: cachedData, to: fileURL) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index b413cccdb..5a06560ed 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -16,8 +16,7 @@ extension DownloadManager { !coverRelativePath.isEmpty { let localCoverURL = temporaryFolderURL .appendingPathComponent(coverRelativePath) - if fileManager - .fileExists(atPath: localCoverURL.path) { + if fileManager.operate({ $0.fileExists(atPath: localCoverURL.path) }) { return coverRelativePath } } @@ -164,7 +163,6 @@ extension DownloadManager { temporaryFolderURL: URL, versionSignature: String ) throws -> WorkingSeed { - let localFileManager = fileManager let resumeState = try? storage .readResumeState(folderURL: temporaryFolderURL) let shouldReuseTemporaryFolder = resumeState?.matches( @@ -173,7 +171,9 @@ extension DownloadManager { pageCount: payload.galleryDetail.pageCount, downloadOptions: payload.options ) == true - && localFileManager.fileExists(atPath: temporaryFolderURL.path) + && fileManager.operate { + $0.fileExists(atPath: temporaryFolderURL.path) + } let seedContext = RepairSeedContext( existingDownload: existingDownload, @@ -183,8 +183,7 @@ extension DownloadManager { try setupTemporaryFolder( temporaryFolderURL: temporaryFolderURL, shouldReuse: shouldReuseTemporaryFolder, - seedContext: seedContext, - localFileManager: localFileManager + seedContext: seedContext ) let manifest = validatedManifest( @@ -219,13 +218,14 @@ extension DownloadManager { private func setupTemporaryFolder( temporaryFolderURL: URL, shouldReuse: Bool, - seedContext: RepairSeedContext, - localFileManager: DownloadFileManager + seedContext: RepairSeedContext ) throws { if !shouldReuse { - try? localFileManager.removeItem(at: temporaryFolderURL) + try? fileManager.operate { + try $0.removeItem(at: temporaryFolderURL) + } } - if !localFileManager.fileExists(atPath: temporaryFolderURL.path) { + if !fileManager.operate({ $0.fileExists(atPath: temporaryFolderURL.path) }) { if let seed = repairSeed( for: seedContext.existingDownload, payload: seedContext.payload, @@ -294,8 +294,9 @@ extension DownloadManager { let folderURL = download .resolvedFolderURL(rootURL: storage.rootURL) guard payload.mode == .repair, - fileManager - .fileExists(atPath: folderURL.path), + fileManager.operate({ + $0.fileExists(atPath: folderURL.path) + }), let manifest = try? storage .readManifest(folderURL: folderURL), manifest.gid == download.gid, @@ -326,8 +327,9 @@ extension DownloadManager { } let fileURL = folderURL .appendingPathComponent(relativePath) - return !fileManager - .fileExists(atPath: fileURL.path) + return !fileManager.operate { + $0.fileExists(atPath: fileURL.path) + } } } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift index 28a248b0a..c02c3ad47 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift @@ -82,7 +82,9 @@ extension DownloadManager { response: response.1, requestURL: request.url ) { - try? fileManager.removeItem(at: response.0) + try? fileManager.operate { + try $0.removeItem(at: response.0) + } throw error } @@ -340,10 +342,12 @@ extension DownloadManager { } func createDirectory(at url: URL) throws { - try fileManager.createDirectory( - at: url, - withIntermediateDirectories: true - ) + try fileManager.operate { + try $0.createDirectory( + at: url, + withIntermediateDirectories: true + ) + } } func write(data: Data, to url: URL) throws { @@ -358,12 +362,14 @@ extension DownloadManager { try createDirectory( at: destinationURL.deletingLastPathComponent() ) - if fileManager - .fileExists(atPath: destinationURL.path) { - try fileManager.removeItem(at: destinationURL) + if fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) { + try fileManager.operate { + try $0.removeItem(at: destinationURL) + } + } + try fileManager.operate { + try $0.moveItem(at: sourceURL, to: destinationURL) } - try fileManager - .moveItem(at: sourceURL, to: destinationURL) } func readResponsePrefixData(at fileURL: URL) throws -> Data { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index 93b792550..e7b372047 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -192,8 +192,7 @@ extension DownloadManager { } let fileURL = context.temporaryFolderURL .appendingPathComponent(relativePath) - guard fileManager - .fileExists(atPath: fileURL.path) else { + guard fileManager.operate({ $0.fileExists(atPath: fileURL.path) }) else { continue } failedPages[index] = nil diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index b78ac0594..72d73b365 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -13,8 +13,7 @@ extension DownloadManager { expectedPageCount: Int ) -> Int { let folderURL = storage.temporaryFolderURL(gid: gid) - guard fileManager - .fileExists(atPath: folderURL.path) else { + guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return 0 } return storage.existingPageRelativePaths( @@ -29,8 +28,7 @@ extension DownloadManager { ) -> Int { let folderURL = download .resolvedFolderURL(rootURL: storage.rootURL) - guard fileManager - .fileExists(atPath: folderURL.path) + guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return 0 } @@ -95,8 +93,9 @@ extension DownloadManager { download: DownloadedGallery ) -> (hasTemporaryFolder: Bool, temporaryCompletedCount: Int) { let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let hasTemporaryFolder = fileManager - .fileExists(atPath: temporaryFolderURL.path) + let hasTemporaryFolder = fileManager.operate { + $0.fileExists(atPath: temporaryFolderURL.path) + } let temporaryCompletedCount = hasTemporaryFolder ? storage.existingPageRelativePaths( folderURL: temporaryFolderURL, @@ -114,7 +113,9 @@ extension DownloadManager { private func scanCompletedFolder(download: DownloadedGallery) { let completedFolderURL = download .resolvedFolderURL(rootURL: storage.rootURL) - guard fileManager.fileExists(atPath: completedFolderURL.path) else { return } + guard fileManager.operate({ + $0.fileExists(atPath: completedFolderURL.path) + }) else { return } _ = storage.existingPageRelativePaths( folderURL: completedFolderURL, expectedPageCount: download.pageCount @@ -249,8 +250,9 @@ extension DownloadManager { let temporaryFolderURL = storage .temporaryFolderURL(gid: download.gid) if shouldExposeTemporaryWorkingSet(for: download), - fileManager - .fileExists(atPath: temporaryFolderURL.path) { + fileManager.operate({ + $0.fileExists(atPath: temporaryFolderURL.path) + }) { let temporaryPages = storage.existingPageRelativePaths( folderURL: temporaryFolderURL, @@ -274,8 +276,9 @@ extension DownloadManager { let completedFolderURL = download .resolvedFolderURL(rootURL: storage.rootURL) - guard fileManager - .fileExists(atPath: completedFolderURL.path) + guard fileManager.operate({ + $0.fileExists(atPath: completedFolderURL.path) + }) else { return nil } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index bf71aedcf..0d6ee9494 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -34,10 +34,12 @@ extension DownloadManager { .temporaryFolderURL(gid: download.gid) let completedFolderURL = download .resolvedFolderURL(rootURL: storage.rootURL) - let temporaryFolderExists = fileManager - .fileExists(atPath: temporaryFolderURL.path) - let completedFolderExists = fileManager - .fileExists(atPath: completedFolderURL.path) + let temporaryFolderExists = fileManager.operate { + $0.fileExists(atPath: temporaryFolderURL.path) + } + let completedFolderExists = fileManager.operate { + $0.fileExists(atPath: completedFolderURL.path) + } if shouldExposeTemporaryWorkingSet(for: download) { return temporaryFolderExists diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index a2f1ae89c..251923ac9 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -19,7 +19,7 @@ extension DownloadManager { let folderURL = activeFolderURL { let fileURL = folderURL .appendingPathComponent(relativePath) - if fileManager.fileExists(atPath: fileURL.path) { + if fileManager.operate({ $0.fileExists(atPath: fileURL.path) }) { return .init( index: index, status: .downloaded, @@ -95,7 +95,7 @@ extension DownloadManager { ) -> Result<[Int: URL], AppError> { if completedValidation == .valid, let completedFolderURL, - fileManager.fileExists(atPath: completedFolderURL.path), + fileManager.operate({ $0.fileExists(atPath: completedFolderURL.path) }), let manifest = try? storage.readManifest( folderURL: completedFolderURL ) { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index 2c609d13c..8cfe37482 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -35,8 +35,9 @@ extension DownloadManager { for: download, requestedMode: mode ) let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let existingResumeState = fileManager - .fileExists(atPath: temporaryFolderURL.path) + let existingResumeState = fileManager.operate { + $0.fileExists(atPath: temporaryFolderURL.path) + } ? (try? storage.readResumeState(folderURL: temporaryFolderURL)) : nil let retryParams = computeRetryParams( @@ -57,7 +58,7 @@ extension DownloadManager { record.lastError = nil record.pendingOperation = retryParams.pendingOperation?.rawValue } - if fileManager.fileExists(atPath: temporaryFolderURL.path) { + if fileManager.operate({ $0.fileExists(atPath: temporaryFolderURL.path) }) { writeRetryResumeState( download: download, resolvedMode: resolvedMode, @@ -83,7 +84,7 @@ extension DownloadManager { guard !selectedPageIndices.isEmpty else { return .success(()) } let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - guard fileManager.fileExists(atPath: temporaryFolderURL.path) else { + guard fileManager.operate({ $0.fileExists(atPath: temporaryFolderURL.path) }) else { return .failure(.notFound) } do { @@ -168,8 +169,9 @@ extension DownloadManager { let completedFolderURL = download .resolvedFolderURL(rootURL: storage.rootURL) let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let hasTemporaryFolder = fileManager - .fileExists(atPath: temporaryFolderURL.path) + let hasTemporaryFolder = fileManager.operate { + $0.fileExists(atPath: temporaryFolderURL.path) + } let shouldExposeTemp = hasTemporaryFolder && self.shouldExposeTemporaryWorkingSet(for: download) let completedValidation = storage.validate(download: download) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index 2f8050b25..9ec30eda6 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -124,8 +124,9 @@ extension DownloadManager { let temporaryFolderURL = storage .temporaryFolderURL(gid: download.gid) - guard fileManager - .fileExists(atPath: temporaryFolderURL.path) else { + guard fileManager.operate({ + $0.fileExists(atPath: temporaryFolderURL.path) + }) else { return download.pageCount } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 4729d403a..3af992f58 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -55,7 +55,9 @@ extension DownloadManager { try storage.ensureRootDirectory() let temporaryFolderURL = storage .temporaryFolderURL(gid: payload.gallery.gid) - try? fileManager.removeItem(at: temporaryFolderURL) + try? fileManager.operate { + try $0.removeItem(at: temporaryFolderURL) + } try createDirectory(at: temporaryFolderURL) try createDirectory( at: temporaryFolderURL.appendingPathComponent( @@ -105,7 +107,9 @@ extension DownloadManager { ) throws -> PrepareWorkingSeedResult { let temporaryFolderURL = storage .temporaryFolderURL(gid: payload.gallery.gid) - try? fileManager.removeItem(at: temporaryFolderURL) + try? fileManager.operate { + try $0.removeItem(at: temporaryFolderURL) + } let workingSeed = try prepareWorkingSeed( payload: payload, existingDownload: existingDownload, diff --git a/EhPanda/App/Tools/Utilities/DownloadFileManager.swift b/EhPanda/App/Tools/Utilities/DownloadFileManager.swift new file mode 100644 index 000000000..dc6aeec4b --- /dev/null +++ b/EhPanda/App/Tools/Utilities/DownloadFileManager.swift @@ -0,0 +1,21 @@ +// +// DownloadFileManager.swift +// EhPanda +// + +import Foundation +import Synchronization + +final class DownloadFileManager: Sendable { + private let fileManager: Mutex + + init(_ fileManager: sending FileManager) { + self.fileManager = Mutex(fileManager) + } + + func operate( + _ body: (inout sending FileManager) throws -> sending T + ) rethrows -> sending T { + try fileManager.withLock(body) + } +} diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index a2f6b5e8c..c30d287dd 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -8,13 +8,15 @@ import Foundation extension DownloadFileStorage { func replaceFolder(relativePath: String, with temporaryFolderURL: URL) throws { let targetURL = folderURL(relativePath: relativePath) - if fileManager.fileExists(atPath: targetURL.path) { - _ = try fileManager.replaceItemAt( - targetURL, - withItemAt: temporaryFolderURL - ) - } else { - try fileManager.moveItem(at: temporaryFolderURL, to: targetURL) + try fileManager.operate { + if $0.fileExists(atPath: targetURL.path) { + _ = try $0.replaceItemAt( + targetURL, + withItemAt: temporaryFolderURL + ) + } else { + try $0.moveItem(at: temporaryFolderURL, to: targetURL) + } } } @@ -25,18 +27,24 @@ extension DownloadFileStorage { ) } - try fileManager.createDirectory( - at: destinationURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - if fileManager.fileExists(atPath: destinationURL.path) { - try fileManager.removeItem(at: destinationURL) + try fileManager.operate { + try $0.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if $0.fileExists(atPath: destinationURL.path) { + try $0.removeItem(at: destinationURL) + } } do { - try fileManager.linkItem(at: sourceURL, to: destinationURL) + try fileManager.operate { + try $0.linkItem(at: sourceURL, to: destinationURL) + } } catch { - try fileManager.copyItem(at: sourceURL, to: destinationURL) + try fileManager.operate { + try $0.copyItem(at: sourceURL, to: destinationURL) + } } } @@ -45,14 +53,16 @@ extension DownloadFileStorage { manifest: DownloadManifest, to temporaryFolderURL: URL ) throws { - try fileManager.createDirectory(at: temporaryFolderURL, withIntermediateDirectories: true) - try fileManager.createDirectory( - at: temporaryFolderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, - isDirectory: true - ), - withIntermediateDirectories: true - ) + try fileManager.operate { + try $0.createDirectory(at: temporaryFolderURL, withIntermediateDirectories: true) + try $0.createDirectory( + at: temporaryFolderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, + isDirectory: true + ), + withIntermediateDirectories: true + ) + } try linkOrCopyReadableAsset( at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), @@ -161,32 +171,38 @@ extension DownloadFileStorage { func removeFolder(relativePath: String) throws { let targetURL = folderURL(relativePath: relativePath) - guard fileManager.fileExists(atPath: targetURL.path) else { return } - try fileManager.removeItem(at: targetURL) + try fileManager.operate { + guard $0.fileExists(atPath: targetURL.path) else { return } + try $0.removeItem(at: targetURL) + } } func cleanupTemporaryFolders(preservingGIDs: Set = []) throws { - guard fileManager.fileExists(atPath: rootURL.path) else { return } - let urls = try fileManager.contentsOfDirectory( - at: rootURL, - includingPropertiesForKeys: nil - ) + let urls = try fileManager.operate { + guard $0.fileExists(atPath: rootURL.path) else { return [URL]() } + return try $0.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: nil + ) + } for url in urls where url.lastPathComponent.hasPrefix(".tmp-") { let gid = String(url.lastPathComponent.dropFirst(".tmp-".count)) if preservingGIDs.contains(gid) { continue } - try? fileManager.removeItem(at: url) + try? fileManager.operate { + try $0.removeItem(at: url) + } } } func validate(download: DownloadedGallery) -> DownloadValidationState { let folderURL = download.resolvedFolderURL(rootURL: rootURL) - guard fileManager.fileExists(atPath: folderURL.path) else { + guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadFolderMissing) } let manifestURL = download.resolvedManifestURL(rootURL: rootURL) - guard fileManager.fileExists(atPath: manifestURL.path) else { + guard fileManager.operate({ $0.fileExists(atPath: manifestURL.path) }) else { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestMissing) } guard let manifest = try? readManifest(folderURL: folderURL) else { diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index d2afbb3f8..a96e93f20 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -5,7 +5,6 @@ import Foundation import CryptoKit -import Synchronization enum DownloadValidationState: Equatable, Sendable { case valid @@ -59,7 +58,9 @@ struct DownloadFileStorage: Sendable { } func ensureRootDirectory() throws { - try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) + try fileManager.operate { + try $0.createDirectory(at: rootURL, withIntermediateDirectories: true) + } var resourceValues = URLResourceValues() resourceValues.isExcludedFromBackup = true var mutableRootURL = rootURL @@ -92,13 +93,15 @@ struct DownloadFileStorage: Sendable { } func temporaryFolderExists(gid: String) -> Bool { - fileManager.fileExists(atPath: temporaryFolderURL(gid: gid).path) + fileManager.operate { $0.fileExists(atPath: temporaryFolderURL(gid: gid).path) } } func removeTemporaryFolder(gid: String) throws { let targetURL = temporaryFolderURL(gid: gid) - guard fileManager.fileExists(atPath: targetURL.path) else { return } - try fileManager.removeItem(at: targetURL) + try fileManager.operate { + guard $0.fileExists(atPath: targetURL.path) else { return } + try $0.removeItem(at: targetURL) + } } func resumeStateURL(folderURL: URL) -> URL { @@ -127,8 +130,10 @@ struct DownloadFileStorage: Sendable { func removeFailedPages(folderURL: URL) throws { let url = failedPagesURL(folderURL: folderURL) - guard fileManager.fileExists(atPath: url.path) else { return } - try fileManager.removeItem(at: url) + try fileManager.operate { + guard $0.fileExists(atPath: url.path) else { return } + try $0.removeItem(at: url) + } } func existingPageRelativePaths( @@ -139,10 +144,12 @@ struct DownloadFileStorage: Sendable { Defaults.FilePath.downloadPages, isDirectory: true ) - guard let pageURLs = try? fileManager.contentsOfDirectory( - at: pagesFolderURL, - includingPropertiesForKeys: nil - ) else { + guard let pageURLs = try? fileManager.operate({ + try $0.contentsOfDirectory( + at: pagesFolderURL, + includingPropertiesForKeys: nil + ) + }) else { return [:] } @@ -164,10 +171,12 @@ struct DownloadFileStorage: Sendable { } func existingCoverRelativePath(folderURL: URL) -> String? { - guard let fileURLs = try? fileManager.contentsOfDirectory( - at: folderURL, - includingPropertiesForKeys: nil - ) else { + guard let fileURLs = try? fileManager.operate({ + try $0.contentsOfDirectory( + at: folderURL, + includingPropertiesForKeys: nil + ) + }) else { return nil } @@ -245,23 +254,23 @@ struct DownloadFileStorage: Sendable { @discardableResult func sanitizeAssetFileIfNeeded(at url: URL) -> Bool { - guard fileManager.fileExists(atPath: url.path) else { return false } + guard fileManager.operate({ $0.fileExists(atPath: url.path) }) else { return false } let attributes: [FileAttributeKey: Any] do { - attributes = try fileManager.attributesOfItem(atPath: url.path) + attributes = try fileManager.operate { try $0.attributesOfItem(atPath: url.path) } } catch { return canReadNonEmptyFile(at: url) } let isRegularFile = (attributes[.type] as? FileAttributeType).map { $0 == .typeRegular } ?? true guard isRegularFile else { - try? fileManager.removeItem(at: url) + try? fileManager.operate { try $0.removeItem(at: url) } return false } guard let fileSize = (attributes[.size] as? NSNumber)?.intValue else { return false } guard fileSize > 0 else { - try? fileManager.removeItem(at: url) + try? fileManager.operate { try $0.removeItem(at: url) } return false } @@ -278,75 +287,3 @@ struct DownloadFileStorage: Sendable { } } } - -final class DownloadFileManager: Sendable { - private let fileManager: Mutex - - init(_ fileManager: sending FileManager) { - self.fileManager = Mutex(fileManager) - } - - func createDirectory( - at url: URL, - withIntermediateDirectories createIntermediates: Bool - ) throws { - try fileManager.withLock { - try $0.createDirectory( - at: url, - withIntermediateDirectories: createIntermediates - ) - } - } - - func fileExists(atPath path: String) -> Bool { - fileManager.withLock { $0.fileExists(atPath: path) } - } - - func removeItem(at url: URL) throws { - try fileManager.withLock { - try $0.removeItem(at: url) - } - } - - func contentsOfDirectory( - at url: URL, - includingPropertiesForKeys keys: [URLResourceKey]? - ) throws -> [URL] { - try fileManager.withLock { - try $0.contentsOfDirectory( - at: url, - includingPropertiesForKeys: keys - ) - } - } - - func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { - try fileManager.withLock { - try $0.attributesOfItem(atPath: path) - } - } - - func replaceItemAt(_ originalItemURL: URL, withItemAt newItemURL: URL) throws -> URL? { - try fileManager.withLock { - try $0.replaceItemAt(originalItemURL, withItemAt: newItemURL) - } - } - - func moveItem(at sourceURL: URL, to destinationURL: URL) throws { - try fileManager.withLock { - try $0.moveItem(at: sourceURL, to: destinationURL) - } - } - - func linkItem(at sourceURL: URL, to destinationURL: URL) throws { - try fileManager.withLock { - try $0.linkItem(at: sourceURL, to: destinationURL) - } - } - - func copyItem(at sourceURL: URL, to destinationURL: URL) throws { - try fileManager.withLock { - try $0.copyItem(at: sourceURL, to: destinationURL) - } - } -} From a4265fdc0192ecb343a1b585238cdb994dfcff86 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 23:18:13 +0800 Subject: [PATCH 068/614] Parse response errors --- .../DownloadClient+ResponseValidation.swift | 2 +- ...loadClient+ResponseValidationHelpers.swift | 6 +- EhPanda/App/Tools/Parser/Parser+Detail.swift | 4 +- EhPanda/App/Tools/Parser/Parser+List.swift | 4 +- ...nload.swift => Parser+ResponseError.swift} | 10 +- EhPanda/Network/Request+Account.swift | 22 ++-- EhPanda/Network/Request+Detail.swift | 78 +++++++------- EhPanda/Network/Request+Gallery.swift | 100 ++++++++++++------ EhPanda/Network/Request+Image.swift | 66 +++++++----- EhPanda/Network/Request.swift | 92 ++++++++++++++-- .../Other/DownloadPageErrorParserTests.swift | 63 +++++++++-- 11 files changed, 315 insertions(+), 132 deletions(-) rename EhPanda/App/Tools/Parser/{Parser+Download.swift => Parser+ResponseError.swift} (93%) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift index 60af90102..aa551f47e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift @@ -248,7 +248,7 @@ extension DownloadManager { html: fullData.utf8InvalidCharactersRipped, encoding: .utf8 ), - let error = Parser.parseDownloadPageError( + let error = Parser.parseResponseError( doc: document ) { return error diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index 1b211f84d..9f0bf31ca 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -99,7 +99,7 @@ extension DownloadManager { encoding: .utf8 ) ?? "" if !looksLikeHTML { - return Parser.parseDownloadPageError( + return Parser.parseResponseError( content: rawContent ) } @@ -108,7 +108,7 @@ extension DownloadManager { html: normalizedData, encoding: .utf8 ), - let error = Parser.parseDownloadPageError( + let error = Parser.parseResponseError( doc: document ) { return error @@ -117,7 +117,7 @@ extension DownloadManager { guard rawContent.count <= 1024 else { return nil } - return Parser.parseDownloadPageError( + return Parser.parseResponseError( content: rawContent ) } diff --git a/EhPanda/App/Tools/Parser/Parser+Detail.swift b/EhPanda/App/Tools/Parser/Parser+Detail.swift index 7e25e5b45..8ca94f86d 100644 --- a/EhPanda/App/Tools/Parser/Parser+Detail.swift +++ b/EhPanda/App/Tools/Parser/Parser+Detail.swift @@ -88,8 +88,8 @@ extension Parser { } else { throw AppError.expunged(reason) } - } else if let banInterval = parseBanInterval(doc: doc) { - throw AppError.ipBanned(banInterval) + } else if let error = parseResponseError(doc: doc) { + throw error } else { throw AppError.parseFailed } diff --git a/EhPanda/App/Tools/Parser/Parser+List.swift b/EhPanda/App/Tools/Parser/Parser+List.swift index 554b28222..83728aa37 100644 --- a/EhPanda/App/Tools/Parser/Parser+List.swift +++ b/EhPanda/App/Tools/Parser/Parser+List.swift @@ -20,8 +20,8 @@ extension Parser { galleries = (try? parseCompactModeGalleries(doc: doc)) ?? [] } - if galleries.isEmpty, let banInterval = parseBanInterval(doc: doc) { - throw AppError.ipBanned(banInterval) + if galleries.isEmpty, let error = parseResponseError(doc: doc) { + throw error } return galleries } diff --git a/EhPanda/App/Tools/Parser/Parser+Download.swift b/EhPanda/App/Tools/Parser/Parser+ResponseError.swift similarity index 93% rename from EhPanda/App/Tools/Parser/Parser+Download.swift rename to EhPanda/App/Tools/Parser/Parser+ResponseError.swift index a3651e1f4..9f78145a6 100644 --- a/EhPanda/App/Tools/Parser/Parser+Download.swift +++ b/EhPanda/App/Tools/Parser/Parser+ResponseError.swift @@ -1,7 +1,7 @@ import Kanna extension Parser { - static func parseDownloadPageError(doc: HTMLDocument) -> AppError? { + static func parseResponseError(doc: HTMLDocument) -> AppError? { if let banInterval = parseBanInterval(doc: doc) { return .ipBanned(banInterval) } @@ -11,15 +11,15 @@ extension Parser { return .authenticationRequired } - for candidate in downloadErrorCandidates(doc: doc) { - if let error = parseDownloadPageError(content: candidate) { + for candidate in responseErrorCandidates(doc: doc) { + if let error = parseResponseError(content: candidate) { return error } } return nil } - static func parseDownloadPageError(content: String) -> AppError? { + static func parseResponseError(content: String) -> AppError? { let normalizedContent = content.lowercased() guard !normalizedContent.isEmpty else { return nil } @@ -68,7 +68,7 @@ extension Parser { // MARK: Helpers private extension Parser { - static func downloadErrorCandidates(doc: HTMLDocument) -> [String] { + static func responseErrorCandidates(doc: HTMLDocument) -> [String] { var candidates = [String]() let directCandidates = [ diff --git a/EhPanda/Network/Request+Account.swift b/EhPanda/Network/Request+Account.swift index b566c9b8f..777d11558 100644 --- a/EhPanda/Network/Request+Account.swift +++ b/EhPanda/Network/Request+Account.swift @@ -53,8 +53,8 @@ struct VerifyEhProfileRequest: Request { var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseProfileIndex) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseProfileIndex) } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -85,8 +85,8 @@ struct EhProfileRequest: Request { return URLSession.shared.dataTaskPublisher(for: request) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseEhSetting) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseEhSetting) } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -96,8 +96,8 @@ struct EhSettingRequest: Request { var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseEhSetting) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseEhSetting) } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -179,8 +179,8 @@ struct SubmitEhSettingChangesRequest: Request { return URLSession.shared.dataTaskPublisher(for: request) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseEhSetting) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseEhSetting) } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -252,8 +252,10 @@ struct SendDownloadCommandRequest: Request { return URLSession.shared.dataTaskPublisher(for: request) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseDownloadCommandResponse) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0, Parser.parseDownloadCommandResponse) + } .mapError(mapAppError) .eraseToAnyPublisher() } diff --git a/EhPanda/Network/Request+Detail.swift b/EhPanda/Network/Request+Detail.swift index cee5a0003..0ae34522b 100644 --- a/EhPanda/Network/Request+Detail.swift +++ b/EhPanda/Network/Request+Detail.swift @@ -23,25 +23,15 @@ struct GalleryDetailRequest: Request { var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: URLUtil.galleryDetail(url: galleryURL)) .genericRetry() - .tryMap { resp -> HTMLDocument in - do { - return try Kanna.HTML(html: resp.data, encoding: .utf8) - } catch { - guard let parseError = error as? ParseError, parseError == .EncodingMismatch - else { throw error } - - guard let htmlDocument = try? Kanna.HTML( - html: resp.data.utf8InvalidCharactersRipped, - encoding: .utf8 - ) else { - throw error - } - return htmlDocument - } - } + .tryMap { try htmlDocumentWithUTF8Fallback(data: $0.data) } .tryMap { doc in - let (detail, state) = try Parser.parseGalleryDetail(doc: doc, gid: gid) - return (doc, detail, state, try Parser.parseAPIKey(doc: doc)) + try parseResponse(doc: doc) { + let (detail, state) = try Parser.parseGalleryDetail( + doc: $0, + gid: gid + ) + return (doc, detail, state, try Parser.parseAPIKey(doc: $0)) + } } .mapError(mapAppError) .map { doc, detail, state, apiKey in @@ -172,8 +162,8 @@ struct GalleryReverseRequest: Request { switch isGalleryImageURL { case true: return URLSession.shared.dataTaskPublisher(for: url) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseGalleryURL) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseGalleryURL) } .mapError(mapAppError) .eraseToAnyPublisher() @@ -186,12 +176,17 @@ struct GalleryReverseRequest: Request { func gallery(url: URL) -> AnyPublisher { URLSession.shared.dataTaskPublisher(for: url) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .compactMap { - guard let (detail, _) = try? Parser.parseGalleryDetail(doc: $0, gid: url.pathComponents[2]) - else { return nil } - - return getGallery(from: detail, and: url) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + let (detail, _) = try Parser.parseGalleryDetail( + doc: $0, + gid: url.pathComponents[2] + ) + guard let gallery = getGallery(from: detail, and: url) + else { throw AppError.parseFailed } + return gallery + } } .mapError(mapAppError) .eraseToAnyPublisher() @@ -204,10 +199,12 @@ struct GalleryArchiveRequest: Request { var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: archiveURL) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { try htmlDocument(data: $0.data) } .tryMap { (html: HTMLDocument) -> (HTMLDocument, GalleryArchive) in - let archive = try Parser.parseGalleryArchive(doc: html) - return (html, archive) + try parseResponse(doc: html) { + let archive = try Parser.parseGalleryArchive(doc: $0) + return (html, archive) + } } .map { html, archive in guard let (currentGP, currentCredits) = try? Parser.parseCurrentFunds(doc: html) @@ -232,16 +229,25 @@ struct GalleryArchiveFundsRequest: Request { func archiveURL(url: URL) -> AnyPublisher { URLSession.shared.dataTaskPublisher(for: url) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .compactMap { try? Parser.parseGalleryDetail(doc: $0, gid: gid).0.archiveURL } + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + guard let archiveURL = try Parser + .parseGalleryDetail(doc: $0, gid: gid) + .0 + .archiveURL + else { throw AppError.parseFailed } + return archiveURL + } + } .mapError(mapAppError) .eraseToAnyPublisher() } func funds(url: URL) -> AnyPublisher<(String, String), AppError> { URLSession.shared.dataTaskPublisher(for: url) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseCurrentFunds) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseCurrentFunds) } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -254,7 +260,7 @@ struct GalleryTorrentsRequest: Request { var publisher: AnyPublisher<[GalleryTorrent], AppError> { URLSession.shared.dataTaskPublisher(for: URLUtil.galleryTorrents(gid: gid, token: token)) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { try htmlDocument(data: $0.data) } .map(Parser.parseGalleryTorrents) .mapError(mapAppError) .eraseToAnyPublisher() @@ -268,8 +274,8 @@ struct GalleryPreviewURLsRequest: Request { var publisher: AnyPublisher<[Int: URL], AppError> { URLSession.shared.dataTaskPublisher(for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum)) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parsePreviewURLs) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parsePreviewURLs) } .mapError(mapAppError) .eraseToAnyPublisher() } diff --git a/EhPanda/Network/Request+Gallery.swift b/EhPanda/Network/Request+Gallery.swift index 5e96f2585..750994199 100644 --- a/EhPanda/Network/Request+Gallery.swift +++ b/EhPanda/Network/Request+Gallery.swift @@ -17,8 +17,12 @@ struct SearchGalleriesRequest: Request { for: URLUtil.searchList(keyword: keyword, filter: filter) ) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + } + } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -34,8 +38,12 @@ struct MoreSearchGalleriesRequest: Request { for: URLUtil.moreSearchList(keyword: keyword, filter: filter, lastID: lastID) ) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + } + } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -47,8 +55,12 @@ struct FrontpageGalleriesRequest: Request { var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { URLSession.shared.dataTaskPublisher(for: URLUtil.frontpageList(filter: filter)) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + } + } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -61,8 +73,12 @@ struct MoreFrontpageGalleriesRequest: Request { var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { URLSession.shared.dataTaskPublisher(for: URLUtil.moreFrontpageList(filter: filter, lastID: lastID)) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + } + } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -74,8 +90,8 @@ struct PopularGalleriesRequest: Request { var publisher: AnyPublisher<[Gallery], AppError> { URLSession.shared.dataTaskPublisher(for: URLUtil.popularList(filter: filter)) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseGalleries) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseGalleries) } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -88,8 +104,12 @@ struct WatchedGalleriesRequest: Request { var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { URLSession.shared.dataTaskPublisher(for: URLUtil.watchedList(filter: filter, keyword: keyword)) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + } + } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -105,8 +125,12 @@ struct MoreWatchedGalleriesRequest: Request { for: URLUtil.moreWatchedList(filter: filter, lastID: lastID, keyword: keyword) ) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + } + } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -122,13 +146,15 @@ struct FavoritesGalleriesRequest: Request { for: URLUtil.favoritesList(favIndex: favIndex, keyword: keyword, sortOrder: sortOrder) ) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { - FavoritesGalleriesResult( - pageNumber: Parser.parsePageNum(doc: $0), - sortOrder: Parser.parseFavoritesSortOrder(doc: $0), - galleries: try Parser.parseGalleries(doc: $0) - ) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + FavoritesGalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + sortOrder: Parser.parseFavoritesSortOrder(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } } .mapError(mapAppError) .eraseToAnyPublisher() @@ -148,13 +174,15 @@ struct MoreFavoritesGalleriesRequest: Request { ) ) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { - FavoritesGalleriesResult( - pageNumber: Parser.parsePageNum(doc: $0), - sortOrder: Parser.parseFavoritesSortOrder(doc: $0), - galleries: try Parser.parseGalleries(doc: $0) - ) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + FavoritesGalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + sortOrder: Parser.parseFavoritesSortOrder(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } } .mapError(mapAppError) .eraseToAnyPublisher() @@ -170,8 +198,12 @@ struct ToplistsGalleriesRequest: Request { for: URLUtil.toplistsList(catIndex: catIndex, pageNum: pageNum) ) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + } + } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -188,8 +220,12 @@ struct MoreToplistsGalleriesRequest: Request { ) ) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + } + } .mapError(mapAppError) .eraseToAnyPublisher() } diff --git a/EhPanda/Network/Request+Image.swift b/EhPanda/Network/Request+Image.swift index 82b178c1c..8005fc474 100644 --- a/EhPanda/Network/Request+Image.swift +++ b/EhPanda/Network/Request+Image.swift @@ -21,8 +21,8 @@ struct MPVKeysRequest: Request { var publisher: AnyPublisher<(String, [Int: String]), AppError> { URLSession.shared.dataTaskPublisher(for: mpvURL) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseMPVKeys) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseMPVKeys) } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -37,8 +37,8 @@ struct ThumbnailURLsRequest: Request { for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum) ) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseThumbnailURLs) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseThumbnailURLs) } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -52,9 +52,14 @@ struct GalleryNormalImageURLsRequest: Request { .flatMap { index, url in URLSession.shared.dataTaskPublisher(for: url) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { - try Parser.parseGalleryNormalImageURL(doc: $0, index: index) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + try Parser.parseGalleryNormalImageURL( + doc: $0, + index: index + ) + } } } .collect() @@ -109,8 +114,8 @@ struct GalleryNormalImageURLRefetchRequest: Request { return URLSession.shared.dataTaskPublisher( for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum) ) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseThumbnailURLs) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseThumbnailURLs) } .compactMap({ thumbnailURLs in thumbnailURLs[index] }) .mapError(mapAppError) .eraseToAnyPublisher() @@ -120,18 +125,20 @@ struct GalleryNormalImageURLRefetchRequest: Request { func renewThumbnailURL(stored: URL) -> AnyPublisher<(URL, URL), AppError> { URLSession.shared.dataTaskPublisher(for: stored) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { - let identifier = try Parser.parseSkipServerIdentifier(doc: $0) - let imageURL = try Parser.parseGalleryNormalImageURL( - doc: $0, index: index - ).imageURL - return ( - stored.appending( - queryItems: [.skipServerIdentifier: identifier] - ), - imageURL - ) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + let identifier = try Parser.parseSkipServerIdentifier(doc: $0) + let imageURL = try Parser.parseGalleryNormalImageURL( + doc: $0, index: index + ).imageURL + return ( + stored.appending( + queryItems: [.skipServerIdentifier: identifier] + ), + imageURL + ) + } } .mapError(mapAppError) .eraseToAnyPublisher() @@ -142,17 +149,20 @@ struct GalleryNormalImageURLRefetchRequest: Request { URLSession.shared.dataTaskPublisher(for: thumbnailURL) .tryMap { ( - try Kanna.HTML(html: $0.data, encoding: .utf8), + try htmlDocument(data: $0.data), $0.response as? HTTPURLResponse ) } .tryMap { html, response in - ( - try Parser.parseGalleryNormalImageURL( - doc: html, index: index - ), - response - ) + try parseResponse(doc: html) { + ( + try Parser.parseGalleryNormalImageURL( + doc: $0, + index: index + ), + response + ) + } } .map { info, response in ImageURLRefetchResult( diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index 8d96f8c39..802aed7a0 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -12,12 +12,86 @@ protocol Request { var publisher: AnyPublisher { get } } + +private struct ResponseParsingError: Error { + let underlyingError: Error + let responseError: AppError? +} + extension Request { func response() async -> Result { await publisher.receive(on: DispatchQueue.main).async() } + func htmlDocument(data: Data) throws -> HTMLDocument { + do { + return try Kanna.HTML(html: data, encoding: .utf8) + } catch { + let content = String( + data: data.utf8InvalidCharactersRipped, + encoding: .utf8 + ) + throw ResponseParsingError( + underlyingError: error, + responseError: content.flatMap( + Parser.parseResponseError(content:) + ) + ) + } + } + + func htmlDocumentWithUTF8Fallback(data: Data) throws -> HTMLDocument { + do { + return try Kanna.HTML(html: data, encoding: .utf8) + } catch { + guard let parseError = error as? ParseError, + parseError == .EncodingMismatch, + let htmlDocument = try? Kanna.HTML( + html: data.utf8InvalidCharactersRipped, + encoding: .utf8 + ) + else { + let content = String( + data: data.utf8InvalidCharactersRipped, + encoding: .utf8 + ) + throw ResponseParsingError( + underlyingError: error, + responseError: content.flatMap( + Parser.parseResponseError(content:) + ) + ) + } + return htmlDocument + } + } + + func parseResponse( + doc: HTMLDocument, + _ parser: (HTMLDocument) throws -> T + ) throws -> T { + do { + return try parser(doc) + } catch { + throw ResponseParsingError( + underlyingError: error, + responseError: Parser.parseResponseError(doc: doc) + ) + } + } + func mapAppError(error: Error) -> AppError { + if let responseParsingError = error as? ResponseParsingError { + if let responseError = parsedResponseError( + from: responseParsingError + ) { + return responseError + } + return mapAppError( + error: responseParsingError.underlyingError + ) + } + switch error { case is ParseError: return .parseFailed @@ -32,6 +106,12 @@ extension Request { return error as? AppError ?? .unknown } } + + private func parsedResponseError( + from error: ResponseParsingError + ) -> AppError? { + error.responseError + } } extension Publisher { @@ -112,8 +192,8 @@ struct GreetingRequest: Request { var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: Defaults.URL.news) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseGreeting) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseGreeting) } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -125,8 +205,8 @@ struct UserInfoRequest: Request { var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: URLUtil.userInfo(uid: uid)) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseUserInfo) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseUserInfo) } .mapError(mapAppError) .eraseToAnyPublisher() } @@ -136,8 +216,8 @@ struct FavoriteCategoriesRequest: Request { var publisher: AnyPublisher<[Int: String], AppError> { URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseFavoriteCategories) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseFavoriteCategories) } .mapError(mapAppError) .eraseToAnyPublisher() } diff --git a/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift b/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift index 219d7fa42..1ef455f4e 100644 --- a/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift @@ -4,6 +4,7 @@ // import Kanna +import Combine import Testing @testable import EhPanda @@ -13,7 +14,7 @@ struct DownloadPageErrorParserTests: TestHelper { let document = try htmlDocument(filename: .ipBanned) #expect( - Parser.parseDownloadPageError(doc: document) == .ipBanned(.minutes(59, seconds: 48)) + Parser.parseResponseError(doc: document) == .ipBanned(.minutes(59, seconds: 48)) ) } @@ -21,7 +22,26 @@ struct DownloadPageErrorParserTests: TestHelper { func testNormalGalleryDetailPageDoesNotMapToDownloadError() throws { let document = try htmlDocument(filename: .galleryDetail) - #expect(Parser.parseDownloadPageError(doc: document) == nil) + #expect(Parser.parseResponseError(doc: document) == nil) + } + + @Test + func testNormalParserFixturesDoNotMapToResponseError() throws { + for type in ListParserTestType.allCases { + let document = try htmlDocument(filename: type.filename) + #expect(Parser.parseResponseError(doc: document) == nil) + } + + for filename in [ + HTMLFilename.galleryDetail, + .galleryDetailWithGreeting, + .galleryMPVKeys, + .galleryNormalImageURL, + .ehSetting + ] { + let document = try htmlDocument(filename: filename) + #expect(Parser.parseResponseError(doc: document) == nil) + } } @Test @@ -39,7 +59,7 @@ struct DownloadPageErrorParserTests: TestHelper { encoding: .utf8 ) - #expect(Parser.parseDownloadPageError(doc: document) == .authenticationRequired) + #expect(Parser.parseResponseError(doc: document) == .authenticationRequired) } @Test @@ -52,13 +72,42 @@ struct DownloadPageErrorParserTests: TestHelper { encoding: .utf8 ) - #expect(Parser.parseDownloadPageError(doc: document) == .notFound) - #expect(Parser.parseDownloadPageError(content: "Gallery not found") == .notFound) - #expect(Parser.parseDownloadPageError(content: "Keep trying") == .notFound) + #expect(Parser.parseResponseError(doc: document) == .notFound) + #expect(Parser.parseResponseError(content: "Gallery not found") == .notFound) + #expect(Parser.parseResponseError(content: "Keep trying") == .notFound) } @Test func testGalleryNotAvailableIsNotHardMappedToDownloadError() { - #expect(Parser.parseDownloadPageError(content: "Gallery Not Available") == nil) + #expect(Parser.parseResponseError(content: "Gallery Not Available") == nil) + } + + @Test + func testMapAppErrorUsesResponseErrorFromParserFailure() async throws { + let document = try htmlDocument(filename: .ipBanned) + let result = await FailingHTMLRequest(document: document).response() + + switch result { + case .success: + Issue.record("Expected response parser to map the IP ban failure.") + case .failure(let error): + #expect(error == .ipBanned(.minutes(59, seconds: 48))) + } + } +} + +private struct FailingHTMLRequest: Request { + let document: HTMLDocument + + var publisher: AnyPublisher { + Just(document) + .setFailureType(to: AppError.self) + .tryMap { document in + try parseResponse(doc: document) { _ in + throw AppError.parseFailed + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() } } From 0858b5dec60d8ac3c30e0a66a3cfc16d03200af5 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 23:30:03 +0800 Subject: [PATCH 069/614] Route download requests --- .../Clients/DownloadClient+Execution.swift | 1 - .../DownloadClient+ExecutionFetch.swift | 30 ++-- .../DownloadClient+ExecutionSupport.swift | 57 ++++--- .../Clients/DownloadClient+Networking.swift | 142 +----------------- .../DownloadClient+PageDownloadHelpers.swift | 3 +- EhPanda/Network/Request+Detail.swift | 20 ++- EhPanda/Network/Request+Image.swift | 110 +++++++++----- EhPanda/Network/Request.swift | 29 ++++ 8 files changed, 164 insertions(+), 228 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index f1e9a62dc..3d79109bc 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -3,7 +3,6 @@ // EhPanda // -import Kanna import Foundation // MARK: - Process Download diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index 1275b568d..3df9fbc75 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -3,7 +3,6 @@ // EhPanda // -import Kanna import Foundation // MARK: - Fetch & Normalize Payload @@ -20,25 +19,16 @@ extension DownloadManager { ) async throws -> FetchLatestPayloadResult { let galleryURL = download.gallery.galleryURL guard let galleryURL else { throw AppError.notFound } - let (detail, galleryState) = try await withRetry( - operation: "fetchLatestPayload", - context: [ - "gid": download.gid, - "mode": mode.rawValue, - "galleryURL": galleryURL.absoluteString - ] - ) { - let doc = try await htmlDocument( - url: URLUtil.galleryDetail(url: galleryURL), - allowsCellular: - download.downloadOptionsSnapshot.allowCellular, - retriesRequest: false - ) - return try Parser.parseGalleryDetail( - doc: doc, - gid: download.gid - ) - } + let detailResponse = try await GalleryDetailRequest( + gid: download.gid, + galleryURL: galleryURL, + urlSession: urlSession, + allowsCellular: download.downloadOptionsSnapshot.allowCellular + ) + .response() + .get() + let detail = detailResponse.galleryDetail + let galleryState = detailResponse.galleryState let components = buildGalleryComponents( download: download, detail: detail, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 5a06560ed..b8d02bea3 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -130,12 +130,14 @@ extension DownloadManager { .sorted() var thumbnailURLs = [Int: URL]() for pageNumber in requiredPageNumbers { - let pageURLs = try await fetchThumbnailURLs( - galleryURL: - payload.gallery.galleryURL.forceUnwrapped, + let pageURLs = try await ThumbnailURLsRequest( + galleryURL: payload.gallery.galleryURL.forceUnwrapped, pageNum: pageNumber, + urlSession: urlSession, allowsCellular: payload.options.allowCellular ) + .response() + .get() thumbnailURLs .merge(pageURLs, uniquingKeysWith: { _, new in new }) } @@ -147,11 +149,14 @@ extension DownloadManager { } if firstURL.pathComponents.count > 1, firstURL.pathComponents[1] == "mpv" { - let mpvResult = try await fetchMPVKeys( + let (mpvKey, imageKeys) = try await MPVKeysRequest( mpvURL: firstURL, + urlSession: urlSession, allowsCellular: payload.options.allowCellular ) - return .mpv(mpvResult.mpvKey, mpvResult.imageKeys) + .response() + .get() + return .mpv(mpvKey, imageKeys) } else { return .normal(thumbnailURLs) } @@ -251,38 +256,46 @@ extension DownloadManager { func resolvedImageSource( index: Int, payload: DownloadRequestPayload, - source: ResolvedSource, - retriesRequest: Bool + source: ResolvedSource ) async throws -> ResolvedImageSource { switch source { case .normal(let thumbnailURLs): guard let thumbnailURL = thumbnailURLs[index] else { throw AppError.notFound } - let doc = try await htmlDocument( - url: thumbnailURL, - allowsCellular: payload.options.allowCellular, - retriesRequest: retriesRequest + let (imageURLs, _) = try await GalleryNormalImageURLsRequest( + thumbnailURLs: [index: thumbnailURL], + urlSession: urlSession, + allowsCellular: payload.options.allowCellular ) - let imageInfo = - try Parser.parseGalleryNormalImageURL( - doc: doc, - index: index - ) - return .init(imageURL: imageInfo.imageURL) + .response() + .get() + guard let imageURL = imageURLs[index] else { + throw AppError.notFound + } + return .init(imageURL: imageURL) case .mpv(let mpvKey, let imageKeys): + guard let gid = Int(payload.gallery.gid) else { + throw AppError.notFound + } guard let imageKey = imageKeys[index] else { throw AppError.notFound } - let imageURL = try await fetchMPVImageURL( - payload: payload, + let response = try await GalleryMPVImageURLRequest( + gid: gid, index: index, mpvKey: mpvKey, - imageKey: imageKey, - retriesRequest: retriesRequest + mpvImageKey: imageKey, + skipServerIdentifier: nil, + apiURL: payload.host.url.appendingPathComponent("api.php"), + urlSession: urlSession, + allowsCellular: payload.options.allowCellular, + requiresSkipServerIdentifier: false ) - return .init(imageURL: imageURL) + .response() + .get() + return .init(imageURL: response.imageURL) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift index c02c3ad47..a9e76f775 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift @@ -3,45 +3,10 @@ // EhPanda // -import Kanna import Foundation -// MARK: - HTML & Network +// MARK: - Network extension DownloadManager { - func htmlDocument( - url: URL, - allowsCellular: Bool, - retriesRequest: Bool = true - ) async throws -> HTMLDocument { - var request = URLRequest(url: url) - request.allowsCellularAccess = allowsCellular - let (data, response) = try await dataResponse( - for: request, - retriesRequest: retriesRequest - ) - if let error = detectResponseError( - data: data, - response: response, - requestURL: request.url, - expectsHTML: true - ) { - throw error - } - if let document = try? Kanna.HTML( - html: data, - encoding: .utf8 - ) { - return document - } - if let document = try? Kanna.HTML( - html: data.utf8InvalidCharactersRipped, - encoding: .utf8 - ) { - return document - } - throw AppError.parseFailed - } - func downloadResponse( url: URL, allowsCellular: Bool, @@ -197,111 +162,6 @@ extension DownloadManager { } } } - - func fetchThumbnailURLs( - galleryURL: URL, - pageNum: Int, - allowsCellular: Bool - ) async throws -> [Int: URL] { - let detailPageURL = URLUtil.detailPage( - url: galleryURL, - pageNum: pageNum - ) - let urls = try await withRetry( - operation: "fetchThumbnailURLs", - context: [ - "galleryURL": galleryURL.absoluteString, - "detailPageURL": detailPageURL.absoluteString, - "pageNum": pageNum - ] - ) { - let doc = try await htmlDocument( - url: detailPageURL, - allowsCellular: allowsCellular, - retriesRequest: false - ) - return try Parser.parseThumbnailURLs(doc: doc) - } - guard !urls.isEmpty else { throw AppError.notFound } - return urls - } - - struct MPVKeysResult: Sendable { - let mpvKey: String - let imageKeys: [Int: String] - } - - func fetchMPVKeys( - mpvURL: URL, - allowsCellular: Bool - ) async throws -> MPVKeysResult { - let (mpvKey, imageKeys) = try await withRetry( - operation: "fetchMPVKeys", - context: [ - "mpvURL": mpvURL.absoluteString - ] - ) { - let doc = try await htmlDocument( - url: mpvURL, - allowsCellular: allowsCellular, - retriesRequest: false - ) - return try Parser.parseMPVKeys(doc: doc) - } - return MPVKeysResult( - mpvKey: mpvKey, - imageKeys: imageKeys - ) - } - - func fetchMPVImageURL( - payload: DownloadRequestPayload, - index: Int, - mpvKey: String, - imageKey: String, - retriesRequest: Bool = true - ) async throws -> URL { - guard let gidInteger = Int(payload.gallery.gid) else { - throw AppError.notFound - } - let params: [String: Any] = [ - "method": "imagedispatch", - "gid": gidInteger, - "page": index, - "imgkey": imageKey, - "mpvkey": mpvKey - ] - - var request = URLRequest( - url: payload.host.url - .appendingPathComponent("api.php") - ) - request.httpMethod = "POST" - request.httpBody = try JSONSerialization - .data(withJSONObject: params) - request.allowsCellularAccess = - payload.options.allowCellular - - let (data, response) = try await dataResponse( - for: request, - retriesRequest: retriesRequest - ) - if let error = detectResponseError( - data: data, - response: response, - requestURL: request.url - ) { - throw error - } - guard let dictionary = try JSONSerialization - .jsonObject(with: data) as? [String: Any], - let imageURLString = dictionary["i"] as? String, - let imageURL = URL(string: imageURLString) - else { - throw AppError.parseFailed - } - return imageURL - } } // MARK: - File Operations diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift index 5ab926ba0..301a84e7c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -62,8 +62,7 @@ extension DownloadManager { let resolved = try await resolvedImageSource( index: index, payload: payload, - source: source, - retriesRequest: false + source: source ) if let result = try await attemptResolvedCacheRestore( index: index, diff --git a/EhPanda/Network/Request+Detail.swift b/EhPanda/Network/Request+Detail.swift index 0ae34522b..7b9bff66d 100644 --- a/EhPanda/Network/Request+Detail.swift +++ b/EhPanda/Network/Request+Detail.swift @@ -19,9 +19,16 @@ struct GalleryDetailResponse { struct GalleryDetailRequest: Request { let gid: String let galleryURL: URL + var urlSession: URLSession = .shared + var allowsCellular = true var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: URLUtil.galleryDetail(url: galleryURL)) + urlSession.dataTaskPublisher( + for: urlRequest( + url: URLUtil.galleryDetail(url: galleryURL), + allowsCellular: allowsCellular + ) + ) .genericRetry() .tryMap { try htmlDocumentWithUTF8Fallback(data: $0.data) } .tryMap { doc in @@ -116,11 +123,14 @@ struct GalleryVersionMetadataRequest: Request { .genericRetry() .map(\.data) .tryMap { data in - let response = try JSONDecoder().decode(GalleryVersionMetadataAPIResponse.self, from: data) - guard let metadata = response.gmetadata.first?.versionMetadata else { - throw AppError.notFound + try parseResponse(data: data) { + let response = try JSONDecoder() + .decode(GalleryVersionMetadataAPIResponse.self, from: $0) + guard let metadata = response.gmetadata.first?.versionMetadata else { + throw AppError.notFound + } + return metadata } - return metadata } .mapError(mapAppError) .eraseToAnyPublisher() diff --git a/EhPanda/Network/Request+Image.swift b/EhPanda/Network/Request+Image.swift index 8005fc474..8dcd1f7c6 100644 --- a/EhPanda/Network/Request+Image.swift +++ b/EhPanda/Network/Request+Image.swift @@ -17,9 +17,13 @@ struct GalleryMPVImageURLResponse { // MARK: Image Requests struct MPVKeysRequest: Request { let mpvURL: URL + var urlSession: URLSession = .shared + var allowsCellular = true var publisher: AnyPublisher<(String, [Int: String]), AppError> { - URLSession.shared.dataTaskPublisher(for: mpvURL) + urlSession.dataTaskPublisher( + for: urlRequest(url: mpvURL, allowsCellular: allowsCellular) + ) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } .tryMap { try parseResponse(doc: $0, Parser.parseMPVKeys) } @@ -31,10 +35,15 @@ struct MPVKeysRequest: Request { struct ThumbnailURLsRequest: Request { let galleryURL: URL let pageNum: Int + var urlSession: URLSession = .shared + var allowsCellular = true var publisher: AnyPublisher<[Int: URL], AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum) + urlSession.dataTaskPublisher( + for: urlRequest( + url: URLUtil.detailPage(url: galleryURL, pageNum: pageNum), + allowsCellular: allowsCellular + ) ) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -46,11 +55,18 @@ struct ThumbnailURLsRequest: Request { struct GalleryNormalImageURLsRequest: Request { let thumbnailURLs: [Int: URL] + var urlSession: URLSession = .shared + var allowsCellular = true var publisher: AnyPublisher<([Int: URL], [Int: URL]), AppError> { thumbnailURLs.publisher .flatMap { index, url in - URLSession.shared.dataTaskPublisher(for: url) + urlSession.dataTaskPublisher( + for: urlRequest( + url: url, + allowsCellular: allowsCellular + ) + ) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } .tryMap { doc in @@ -89,6 +105,8 @@ struct GalleryNormalImageURLRefetchRequest: Request { let galleryURL: URL let thumbnailURL: URL? let storedImageURL: URL + var urlSession: URLSession = .shared + var allowsCellular = true var publisher: AnyPublisher<([Int: URL], HTTPURLResponse?), AppError> { storedThumbnailURL() @@ -111,8 +129,11 @@ struct GalleryNormalImageURLRefetchRequest: Request { .setFailureType(to: AppError.self) .eraseToAnyPublisher() } else { - return URLSession.shared.dataTaskPublisher( - for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum) + return urlSession.dataTaskPublisher( + for: urlRequest( + url: URLUtil.detailPage(url: galleryURL, pageNum: pageNum), + allowsCellular: allowsCellular + ) ) .tryMap { try htmlDocument(data: $0.data) } .tryMap { try parseResponse(doc: $0, Parser.parseThumbnailURLs) } @@ -124,7 +145,9 @@ struct GalleryNormalImageURLRefetchRequest: Request { func renewThumbnailURL(stored: URL) -> AnyPublisher<(URL, URL), AppError> { - URLSession.shared.dataTaskPublisher(for: stored) + urlSession.dataTaskPublisher( + for: urlRequest(url: stored, allowsCellular: allowsCellular) + ) .tryMap { try htmlDocument(data: $0.data) } .tryMap { doc in try parseResponse(doc: doc) { @@ -146,7 +169,9 @@ struct GalleryNormalImageURLRefetchRequest: Request { func imageURL(thumbnailURL: URL, anotherImageURL: URL) -> AnyPublisher { - URLSession.shared.dataTaskPublisher(for: thumbnailURL) + urlSession.dataTaskPublisher( + for: urlRequest(url: thumbnailURL, allowsCellular: allowsCellular) + ) .tryMap { ( try htmlDocument(data: $0.data), @@ -182,6 +207,10 @@ struct GalleryMPVImageURLRequest: Request { let mpvKey: String let mpvImageKey: String let skipServerIdentifier: String? + var apiURL: URL = Defaults.URL.api + var urlSession: URLSession = .shared + var allowsCellular = true + var requiresSkipServerIdentifier = true var publisher: AnyPublisher { var params: [String: Any] = [ @@ -195,47 +224,54 @@ struct GalleryMPVImageURLRequest: Request { params["nl"] = skipServerIdentifier } - var request = URLRequest(url: Defaults.URL.api) + var request = urlRequest( + url: apiURL, + allowsCellular: allowsCellular + ) request.httpMethod = "POST" request.httpBody = try? JSONSerialization.data( withJSONObject: params, options: [] ) - return URLSession.shared.dataTaskPublisher(for: request) + return urlSession.dataTaskPublisher(for: request) .genericRetry() .map(\.data) .tryMap { data in - guard let dict = try JSONSerialization - .jsonObject(with: data) as? [String: Any], - let imageURLString = dict["i"] as? String, - let imageURL = URL(string: imageURLString) - else { throw AppError.parseFailed } + try parseResponse(data: data) { + guard let dict = try JSONSerialization + .jsonObject(with: $0) as? [String: Any], + let imageURLString = dict["i"] as? String, + let imageURL = URL(string: imageURLString) + else { throw AppError.parseFailed } - var skipServerIdentifier: String? + var skipServerIdentifier: String? - if let integerIdentifier = dict["s"] as? Int { - skipServerIdentifier = integerIdentifier.description - } else if let stringIdentifier = dict["s"] as? String { - skipServerIdentifier = stringIdentifier - } + if let integerIdentifier = dict["s"] as? Int { + skipServerIdentifier = integerIdentifier.description + } else if let stringIdentifier = dict["s"] as? String { + skipServerIdentifier = stringIdentifier + } - guard let skipServerIdentifier - else { throw AppError.parseFailed } + if skipServerIdentifier == nil, + requiresSkipServerIdentifier { + throw AppError.parseFailed + } - if let originalSlice = dict["lf"] as? String { - let originalImageURL = Defaults.URL.host - .appendingPathComponent(originalSlice) - return GalleryMPVImageURLResponse( - imageURL: imageURL, - originalImageURL: originalImageURL, - skipServerIdentifier: skipServerIdentifier - ) - } else { - return GalleryMPVImageURLResponse( - imageURL: imageURL, - originalImageURL: nil, - skipServerIdentifier: skipServerIdentifier - ) + if let originalSlice = dict["lf"] as? String { + let originalImageURL = Defaults.URL.host + .appendingPathComponent(originalSlice) + return GalleryMPVImageURLResponse( + imageURL: imageURL, + originalImageURL: originalImageURL, + skipServerIdentifier: skipServerIdentifier ?? "" + ) + } else { + return GalleryMPVImageURLResponse( + imageURL: imageURL, + originalImageURL: nil, + skipServerIdentifier: skipServerIdentifier ?? "" + ) + } } } .mapError(mapAppError) diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index 802aed7a0..fa6f714a5 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -23,6 +23,15 @@ extension Request { await publisher.receive(on: DispatchQueue.main).async() } + func urlRequest( + url: URL, + allowsCellular: Bool + ) -> URLRequest { + var request = URLRequest(url: url) + request.allowsCellularAccess = allowsCellular + return request + } + func htmlDocument(data: Data) throws -> HTMLDocument { do { return try Kanna.HTML(html: data, encoding: .utf8) @@ -80,6 +89,26 @@ extension Request { } } + func parseResponse( + data: Data, + _ parser: (Data) throws -> T + ) throws -> T { + do { + return try parser(data) + } catch { + let content = String( + data: data.utf8InvalidCharactersRipped, + encoding: .utf8 + ) + throw ResponseParsingError( + underlyingError: error, + responseError: content.flatMap( + Parser.parseResponseError(content:) + ) + ) + } + } + func mapAppError(error: Error) -> AppError { if let responseParsingError = error as? ResponseParsingError { if let responseError = parsedResponseError( From 3abbc69e6a785c628df5c2d61c1feed96d9730d2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 23:42:15 +0800 Subject: [PATCH 070/614] Share image caches --- .../Tools/Clients/DownloadClient+Cache.swift | 35 +--- .../Clients/DownloadClient+Manager.swift | 3 + EhPanda/App/Tools/Clients/ImageClient.swift | 28 ++-- EhPanda/App/Tools/Clients/LibraryClient.swift | 151 +++++++++++++++++- .../GeneralSettingReducer.swift | 14 +- .../View/Setting/SettingReducer+Body.swift | 2 +- .../DownloadManagerRepairSeedTests.swift | 51 ++++++ .../Download/DownloadProcessCacheTests.swift | 21 ++- 8 files changed, 239 insertions(+), 66 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index dc9ebc377..787c3c725 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -4,7 +4,6 @@ // import Foundation -import Kingfisher // MARK: - Cache Operations extension DownloadManager { @@ -26,8 +25,7 @@ extension DownloadManager { } for key in Set(keys) { - try? await KingfisherManager.shared.cache - .removeImage(forKey: key) + await libraryClient.removeCachedImage(key) } } @@ -237,36 +235,7 @@ extension DownloadManager { } func cachedImageData(forKey key: String) async -> Data? { - if let image = KingfisherManager.shared.cache - .retrieveImageInMemoryCache(forKey: key), - let data = image.kf.data(format: .unknown) { - return data - } - - if let data = try? KingfisherManager.shared.cache - .diskStorage.value(forKey: key) { - return data - } - - return await withCheckedContinuation { continuation in - KingfisherManager.shared.cache - .retrieveImage(forKey: key) { result in - switch result { - case .success(let value): - guard let image = value.image, - let data = image.kf - .data(format: .unknown) - else { - continuation.resume(returning: nil) - return - } - continuation.resume(returning: data) - - case .failure: - continuation.resume(returning: nil) - } - } - } + await libraryClient.cachedImageData(key) } func validatedCachedAssetData( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 61c9cd9aa..bd316c4cf 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -134,6 +134,7 @@ actor DownloadManager { let storage: DownloadFileStorage let urlSession: URLSession + let libraryClient: LibraryClient let persistenceContainer: NSPersistentContainer var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() var lastObservedDownloads = [DownloadedGallery]() @@ -149,10 +150,12 @@ actor DownloadManager { init( storage: DownloadFileStorage, urlSession: URLSession, + libraryClient: LibraryClient = .live, persistenceContainer: NSPersistentContainer = PersistenceController.shared.container ) { self.storage = storage self.urlSession = urlSession + self.libraryClient = libraryClient self.persistenceContainer = persistenceContainer } diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 0837168f6..963ef5636 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -15,6 +15,7 @@ struct ImageClient: Sendable { let saveImageToPhotoLibrary: @Sendable (UIImage, Bool) async -> Bool let downloadImage: @Sendable (URL) async -> Result let retrieveImage: @Sendable (String) async -> Result + let isCached: @Sendable (String) -> Bool } extension ImageClient { @@ -88,21 +89,12 @@ extension ImageClient { return result }, retrieveImage: { key in - await withCheckedContinuation { continuation in - KingfisherManager.shared.cache.retrieveImage(forKey: key) { result in - switch result { - case .success(let result): - if let image = result.image { - continuation.resume(returning: .success(image)) - } else { - continuation.resume(returning: .failure(AppError.notFound)) - } - case .failure(let error): - continuation.resume(returning: .failure(error)) - } - } + guard let image = await LibraryClient.live.cachedImage(key) else { + return .failure(AppError.notFound) } - } + return .success(image) + }, + isCached: LibraryClient.live.isCached ) func fetchImage(url: URL) async -> Result { @@ -117,7 +109,7 @@ extension ImageClient { return .failure(AppError.notFound) } for key in url.imageCacheKeys(includeStableAlias: true) - where KingfisherManager.shared.cache.isCached(forKey: key) { + where isCached(key) { return await retrieveImage(key) } return await downloadImage(url) @@ -144,7 +136,8 @@ extension ImageClient { prefetchImages: { _ in }, saveImageToPhotoLibrary: { _, _ in false }, downloadImage: { _ in .success(UIImage()) }, - retrieveImage: { _ in .success(UIImage()) } + retrieveImage: { _ in .success(UIImage()) }, + isCached: { _ in false } ) static func placeholder() -> Result { fatalError() } @@ -153,6 +146,7 @@ extension ImageClient { prefetchImages: IssueReporting.unimplemented(placeholder: placeholder()), saveImageToPhotoLibrary: IssueReporting.unimplemented(placeholder: placeholder()), downloadImage: IssueReporting.unimplemented(placeholder: placeholder()), - retrieveImage: IssueReporting.unimplemented(placeholder: placeholder()) + retrieveImage: IssueReporting.unimplemented(placeholder: placeholder()), + isCached: IssueReporting.unimplemented(placeholder: placeholder()) ) } diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index c74af5ffa..6c10d933b 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -16,7 +16,11 @@ import ComposableArchitecture struct LibraryClient: Sendable { let initializeLogger: @Sendable () -> Void let initializeWebImage: @Sendable () -> Void - let clearWebImageDiskCache: @Sendable () -> Void + let removeAllCachedImages: @Sendable () async -> Void + let cachedImage: @Sendable (String) async -> UIImage? + let cachedImageData: @Sendable (String) async -> Data? + let removeCachedImage: @Sendable (String) async -> Void + let isCached: @Sendable (String) -> Bool let analyzeImageColors: @Sendable (UIImage) async -> [Color]? let calculateWebImageDiskCacheSize: @Sendable () async -> UInt? } @@ -66,9 +70,50 @@ extension LibraryClient { ) SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) }, - clearWebImageDiskCache: { - KingfisherManager.shared.cache.clearDiskCache() - SDImageCache.shared.clearDisk(onCompletion: nil) + removeAllCachedImages: { + KingfisherManager.shared.cache.clearMemoryCache() + SDImageCache.shared.clearMemory() + async let kingfisherClear: Void = withCheckedContinuation { continuation in + KingfisherManager.shared.cache.clearDiskCache { + continuation.resume() + } + } + async let sdWebImageClear: Void = withCheckedContinuation { continuation in + SDImageCache.shared.clearDisk { + continuation.resume() + } + } + _ = await (kingfisherClear, sdWebImageClear) + }, + cachedImage: { key in + if let image = await kingfisherCachedImage(forKey: key) { + return image + } + return await sdWebImageCachedImage(forKey: key) + }, + cachedImageData: { key in + if let data = await kingfisherCachedImageData(forKey: key) { + return data + } + return await sdWebImageCachedImageData(forKey: key) + }, + removeCachedImage: { key in + async let kingfisherRemove: Void = withCheckedContinuation { continuation in + KingfisherManager.shared.cache.removeImage(forKey: key) { + continuation.resume() + } + } + async let sdWebImageRemove: Void = withCheckedContinuation { continuation in + SDImageCache.shared.removeImage(forKey: key) { + continuation.resume() + } + } + _ = await (kingfisherRemove, sdWebImageRemove) + }, + isCached: { key in + KingfisherManager.shared.cache.isCached(forKey: key) + || SDImageCache.shared.imageFromMemoryCache(forKey: key) != nil + || SDImageCache.shared.diskImageDataExists(withKey: key) }, analyzeImageColors: { image in await withCheckedContinuation { continuation in @@ -101,6 +146,92 @@ extension LibraryClient { ) } +private func kingfisherCachedImage(forKey key: String) async -> UIImage? { + if let image = KingfisherManager.shared.cache + .retrieveImageInMemoryCache(forKey: key) { + return image + } + + return await withCheckedContinuation { continuation in + KingfisherManager.shared.cache + .retrieveImage(forKey: key) { result in + switch result { + case .success(let value): + continuation.resume(returning: value.image) + + case .failure: + continuation.resume(returning: nil) + } + } + } +} + +private func kingfisherCachedImageData(forKey key: String) async -> Data? { + if let image = KingfisherManager.shared.cache + .retrieveImageInMemoryCache(forKey: key), + let data = image.kf.data(format: .unknown) { + return data + } + + if let data = try? KingfisherManager.shared.cache + .diskStorage.value(forKey: key) { + return data + } + + return await withCheckedContinuation { continuation in + KingfisherManager.shared.cache + .retrieveImage(forKey: key) { result in + switch result { + case .success(let value): + continuation.resume( + returning: value.image.flatMap { + $0.kf.data(format: .unknown) + } + ) + + case .failure: + continuation.resume(returning: nil) + } + } + } +} + +private func sdWebImageCachedImage(forKey key: String) async -> UIImage? { + if let image = SDImageCache.shared.imageFromCache(forKey: key) { + return image + } + + guard let data = await sdWebImageCachedImageData(forKey: key) else { + return nil + } + return image(from: data) +} + +private func sdWebImageCachedImageData(forKey key: String) async -> Data? { + if let image = SDImageCache.shared.imageFromMemoryCache(forKey: key), + let data = image.animatedSourceData ?? image.sd_imageData() { + return data + } + + if let data = SDImageCache.shared.diskImageData(forKey: key) { + return data + } + + return await withCheckedContinuation { continuation in + SDImageCache.shared.diskImageDataQuery(forKey: key) { data in + continuation.resume(returning: data) + } + } +} + +private func image(from data: Data) -> UIImage? { + if data.animatedImagePasteboardType != nil, + let animatedImage = SDAnimatedImage(data: data) { + return animatedImage + } + return UIImage(data: data) +} + // MARK: API enum LibraryClientKey: DependencyKey { static let liveValue = LibraryClient.live @@ -120,7 +251,11 @@ extension LibraryClient { static let noop: Self = .init( initializeLogger: {}, initializeWebImage: {}, - clearWebImageDiskCache: {}, + removeAllCachedImages: {}, + cachedImage: { _ in nil }, + cachedImageData: { _ in nil }, + removeCachedImage: { _ in }, + isCached: { _ in false }, analyzeImageColors: { _ in .none }, calculateWebImageDiskCacheSize: { .none } ) @@ -130,7 +265,11 @@ extension LibraryClient { static let unimplemented: Self = .init( initializeLogger: IssueReporting.unimplemented(placeholder: placeholder()), initializeWebImage: IssueReporting.unimplemented(placeholder: placeholder()), - clearWebImageDiskCache: IssueReporting.unimplemented(placeholder: placeholder()), + removeAllCachedImages: IssueReporting.unimplemented(placeholder: placeholder()), + cachedImage: IssueReporting.unimplemented(placeholder: placeholder()), + cachedImageData: IssueReporting.unimplemented(placeholder: placeholder()), + removeCachedImage: IssueReporting.unimplemented(placeholder: placeholder()), + isCached: IssueReporting.unimplemented(placeholder: placeholder()), analyzeImageColors: IssueReporting.unimplemented(placeholder: placeholder()), calculateWebImageDiskCacheSize: IssueReporting.unimplemented(placeholder: placeholder()) diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift index 3a0f4eb05..2e36f25f5 100644 --- a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift +++ b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift @@ -3,7 +3,6 @@ // EhPanda // -import Kingfisher import LocalAuthentication import ComposableArchitecture @@ -74,11 +73,14 @@ struct GeneralSettingReducer { return .none case .clearWebImageCache: - return .merge( - .run(operation: { _ in libraryClient.clearWebImageDiskCache() }), - .run(operation: { _ in await databaseClient.removeImageURLs() }), - .send(.calculateWebImageDiskCache) - ) + return .run { send in + async let removeCachedImages: Void = + libraryClient.removeAllCachedImages() + async let removeImageURLs: Void = + databaseClient.removeImageURLs() + _ = await (removeCachedImages, removeImageURLs) + await send(.calculateWebImageDiskCache) + } case .checkPasscodeSetting: state.passcodeNotSet = authorizationClient.passcodeNotSet() diff --git a/EhPanda/View/Setting/SettingReducer+Body.swift b/EhPanda/View/Setting/SettingReducer+Body.swift index 0cdb8f178..fab6646b7 100644 --- a/EhPanda/View/Setting/SettingReducer+Body.swift +++ b/EhPanda/View/Setting/SettingReducer+Body.swift @@ -265,7 +265,7 @@ extension SettingReducer { .send(.syncUser), .run(operation: { _ in cookieClient.clearAll() }), .run(operation: { _ in await databaseClient.removeImageURLs() }), - .run(operation: { _ in libraryClient.clearWebImageDiskCache() }) + .run(operation: { _ in await libraryClient.removeAllCachedImages() }) ) case .account: diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index f7f54772d..c2f0950ee 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -5,6 +5,7 @@ import CoreData import Kingfisher +import SDWebImage import UIKit import Foundation import Testing @@ -108,11 +109,61 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { #expect(fetchedImage.size == image.size) } + @MainActor + @Test + func testImageClientFetchImageUsesSDWebImageStableAliasCacheKey() async throws { + let url = try #require( + URL(string: "https://ehgt.org/ab/cd/0001-1234567890.webp?download=1") + ) + let stableCacheKey = try #require(url.stableImageCacheKey) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer( + size: .init(width: 1, height: 1), + format: format + ) + .image { context in + UIColor.systemGreen.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try #require(image.pngData()) + + await storeSDWebImageData(imageData, forKey: stableCacheKey) + defer { + SDImageCache.shared.removeImage(forKey: stableCacheKey) {} + SDImageCache.shared.removeImage(forKey: url.absoluteString) {} + } + + let client = ImageClient( + prefetchImages: { _ in }, + saveImageToPhotoLibrary: { _, _ in false }, + downloadImage: { _ in + Issue.record("Expected ImageClient to use the cached SDWebImage data.") + return .failure(AppError.notFound) + }, + retrieveImage: ImageClient.live.retrieveImage, + isCached: LibraryClient.live.isCached + ) + + let result = await client.fetchImage(url: url) + let fetchedImage = try result.get() + + #expect(fetchedImage.size == image.size) + } + } // MARK: - Repair Seed Helpers private extension DownloadManagerRepairSeedTests { + func storeSDWebImageData(_ data: Data, forKey key: String) async { + await withCheckedContinuation { continuation in + SDImageCache.shared.storeImageData(data, forKey: key) { + continuation.resume() + } + } + } + func setupRepairSeedFiles( storage: DownloadFileStorage, rootURL: URL, gid: String ) throws { diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 79701a016..ca857ce20 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -5,6 +5,7 @@ import CoreData import Kingfisher +import SDWebImage import UIKit import Foundation import Testing @@ -38,7 +39,12 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { manager: manager, gid: gid, pageIndex: pageIndex, oldVersionSignature: oldVersionSignature ) - defer { cachedKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } + defer { + cachedKeys.forEach { + KingfisherManager.shared.cache.removeImage(forKey: $0) + SDImageCache.shared.removeImage(forKey: $0) {} + } + } await waitUntilCacheReady(for: cachedKeys) @@ -62,7 +68,7 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { for cacheKey in cachedKeys { #expect( - KingfisherManager.shared.cache.isCached(forKey: cacheKey) == false, + LibraryClient.live.isCached(cacheKey) == false, "Expected cache key to be removed after successful download: \(cacheKey)" ) } @@ -237,6 +243,7 @@ private extension DownloadProcessCacheTests { let cachedKeys = Set(cachedURLs.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) for cacheKey in cachedKeys { try await KingfisherManager.shared.cache.storeToDisk(cachedImageData, forKey: cacheKey) + await storeSDWebImageData(cachedImageData, forKey: cacheKey) } return (cachedKeys, coverURL) } @@ -327,9 +334,17 @@ private extension DownloadProcessCacheTests { func waitUntilCacheCleared(cachedKeys: Set) async throws { let clock = ContinuousClock() let deadline = clock.now.advanced(by: .seconds(1)) - while cachedKeys.contains(where: { KingfisherManager.shared.cache.isCached(forKey: $0) }), + while cachedKeys.contains(where: LibraryClient.live.isCached), clock.now < deadline { try? await Task.sleep(for: .milliseconds(10)) } } + + func storeSDWebImageData(_ data: Data, forKey key: String) async { + await withCheckedContinuation { continuation in + SDImageCache.shared.storeImageData(data, forKey: key) { + continuation.resume() + } + } + } } From 82767cd31d3425b1ac712f0dd94c55259c785155 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 8 Jun 2026 23:48:08 +0800 Subject: [PATCH 071/614] Compose reducers --- .../View/Detail/DetailReducer+Actions.swift | 311 +++++++++--------- .../View/Detail/DetailReducer+Download.swift | 99 +++--- EhPanda/View/Detail/DetailReducer+Fetch.swift | 85 +++-- EhPanda/View/Detail/DetailReducer.swift | 30 +- .../View/Reading/ReadingReducer+Body.swift | 129 ++------ .../Reading/ReadingReducer+Database.swift | 57 ++++ .../Reading/ReadingReducer+ImageFetch.swift | 59 ++++ 7 files changed, 385 insertions(+), 385 deletions(-) diff --git a/EhPanda/View/Detail/DetailReducer+Actions.swift b/EhPanda/View/Detail/DetailReducer+Actions.swift index 7521d3ded..8ac6d88a3 100644 --- a/EhPanda/View/Detail/DetailReducer+Actions.swift +++ b/EhPanda/View/Detail/DetailReducer+Actions.swift @@ -8,48 +8,47 @@ import ComposableArchitecture // MARK: - Navigation & UI Action Handlers extension DetailReducer { - func handleNavigationActions( - state: inout State, - action: Action - ) -> Effect? { - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.readingState = .init() - state.archivesState = .init() - state.torrentsState = .init() - state.previewsState = .init() - state.commentsState.wrappedValue = .init() - state.commentContent = .init() - state.postCommentFocused = false - state.galleryInfosState = .init() - state.detailSearchState.wrappedValue = .init() - return .merge( - .send(.reading(.teardown)), - .send(.archives(.teardown)), - .send(.torrents(.teardown)), - .send(.previews(.teardown)), - .send(.comments(.teardown)), - .send(.detailSearch(.teardown)) - ) - - case .onPostCommentAppear: - return .run { send in - try await Task.sleep(for: .milliseconds(750)) - await send(.setPostCommentFocused(true)) - } + var navigationReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .send(.clearSubStates) : .none + + case .clearSubStates: + state.readingState = .init() + state.archivesState = .init() + state.torrentsState = .init() + state.previewsState = .init() + state.commentsState.wrappedValue = .init() + state.commentContent = .init() + state.postCommentFocused = false + state.galleryInfosState = .init() + state.detailSearchState.wrappedValue = .init() + return .merge( + .send(.reading(.teardown)), + .send(.archives(.teardown)), + .send(.torrents(.teardown)), + .send(.previews(.teardown)), + .send(.comments(.teardown)), + .send(.detailSearch(.teardown)) + ) + + case .onPostCommentAppear: + return .run { send in + try await Task.sleep(for: .milliseconds(750)) + await send(.setPostCommentFocused(true)) + } - case .onAppear(let gid, let showsNewDawnGreeting): - return handleOnAppear(gid: gid, showsNewDawnGreeting: showsNewDawnGreeting, state: &state) + case .onAppear(let gid, let showsNewDawnGreeting): + return handleOnAppear(gid: gid, showsNewDawnGreeting: showsNewDawnGreeting, state: &state) - default: - return nil + default: + return .none + } } } @@ -78,138 +77,134 @@ extension DetailReducer { ) } - func handleUIActions( - state: inout State, - action: Action - ) -> Effect? { - switch action { - case .toggleShowFullTitle: - state.showsFullTitle.toggle() - return .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }) - - case .toggleShowUserRating: - state.showsUserRating.toggle() - return .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }) - - case .setCommentContent(let content): - state.commentContent = content - return .none - - case .setPostCommentFocused(let isFocused): - state.postCommentFocused = isFocused - return .none - - case .updateRating(let value): - state.updateRating(value: value) - return .none - - case .confirmRating(let value): - state.updateRating(value: value) - return .merge( - .send(.rateGallery), - .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), - .run { send in - try await Task.sleep(for: .seconds(1)) - await send(.confirmRatingDone) - } - ) - - case .confirmRatingDone: - state.showsUserRating = false - return .none - - default: - return nil + var uiReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .toggleShowFullTitle: + state.showsFullTitle.toggle() + return .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }) + + case .toggleShowUserRating: + state.showsUserRating.toggle() + return .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }) + + case .setCommentContent(let content): + state.commentContent = content + return .none + + case .setPostCommentFocused(let isFocused): + state.postCommentFocused = isFocused + return .none + + case .updateRating(let value): + state.updateRating(value: value) + return .none + + case .confirmRating(let value): + state.updateRating(value: value) + return .merge( + .send(.rateGallery), + .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), + .run { send in + try await Task.sleep(for: .seconds(1)) + await send(.confirmRatingDone) + } + ) + + case .confirmRatingDone: + state.showsUserRating = false + return .none + + default: + return .none + } } } - func handleSyncActions( - state: inout State, - action: Action - ) -> Effect? { - switch action { - case .syncGalleryTags: - return .run { [gid = state.gallery.id, tags = state.galleryTags] _ in - await databaseClient.updateGalleryTags(gid: gid, tags: tags) - } + var syncReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .syncGalleryTags: + return .run { [gid = state.gallery.id, tags = state.galleryTags] _ in + await databaseClient.updateGalleryTags(gid: gid, tags: tags) + } - case .syncGalleryDetail: - guard let detail = state.galleryDetail else { return .none } - return .run(operation: { _ in await databaseClient.cacheGalleryDetail(detail) }) + case .syncGalleryDetail: + guard let detail = state.galleryDetail else { return .none } + return .run(operation: { _ in await databaseClient.cacheGalleryDetail(detail) }) - case .syncGalleryPreviewURLs: - return .run { [gid = state.gallery.id, previewURLs = state.galleryPreviewURLs] _ in - await databaseClient - .updatePreviewURLs(gid: gid, previewURLs: previewURLs) - } + case .syncGalleryPreviewURLs: + return .run { [gid = state.gallery.id, previewURLs = state.galleryPreviewURLs] _ in + await databaseClient + .updatePreviewURLs(gid: gid, previewURLs: previewURLs) + } - case .syncGalleryComments: - return .run { [gid = state.gallery.id, comments = state.galleryComments] _ in - await databaseClient.updateComments(gid: gid, comments: comments) - } + case .syncGalleryComments: + return .run { [gid = state.gallery.id, comments = state.galleryComments] _ in + await databaseClient.updateComments(gid: gid, comments: comments) + } - case .syncGreeting(let greeting): - return .run(operation: { _ in await databaseClient.updateGreeting(greeting) }) + case .syncGreeting(let greeting): + return .run(operation: { _ in await databaseClient.updateGreeting(greeting) }) - case .syncPreviewConfig(let config): - return .run { [gid = state.gallery.id] _ in - await databaseClient.updatePreviewConfig(gid: gid, config: config) - } + case .syncPreviewConfig(let config): + return .run { [gid = state.gallery.id] _ in + await databaseClient.updatePreviewConfig(gid: gid, config: config) + } - case .saveGalleryHistory: - return .run { [gid = state.gallery.id] _ in - await databaseClient.updateLastOpenDate(gid: gid) - } + case .saveGalleryHistory: + return .run { [gid = state.gallery.id] _ in + await databaseClient.updateLastOpenDate(gid: gid) + } - case .updateReadingProgress(let progress): - return .run { [gid = state.gallery.id] _ in - await databaseClient.updateReadingProgress(gid: gid, progress: progress) - } + case .updateReadingProgress(let progress): + return .run { [gid = state.gallery.id] _ in + await databaseClient.updateReadingProgress(gid: gid, progress: progress) + } - default: - return nil + default: + return .none + } } } - func handleChildActions( - state: inout State, - action: Action, - self reducer: Reduce - ) -> Effect? { - switch action { - case .reading(.onPerformDismiss): - return .send(.setNavigation(nil)) - - case .reading, .archives, .torrents, .previews, .galleryInfos: - return .none - - case .comments(.performCommentActionDone(let result)): - return .send(.anyGalleryOpsDone(result)) - - case .comments(.detail(let recursiveAction)): - guard state.commentsState.wrappedValue != nil else { return .none } - let effect = reducer._reduce( - // swiftlint:disable:next force_unwrapping - into: &state.commentsState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction - ) - return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) - - case .comments: - return .none - - case .detailSearch(.detail(let recursiveAction)): - guard state.detailSearchState.wrappedValue != nil else { return .none } - let effect = reducer._reduce( - // swiftlint:disable:next force_unwrapping - into: &state.detailSearchState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction - ) - return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) - - case .detailSearch: - return .none - - default: - return nil + func childReducer(_ reducer: Reduce) -> some ReducerOf { + Reduce { state, action in + switch action { + case .reading(.onPerformDismiss): + return .send(.setNavigation(nil)) + + case .reading, .archives, .torrents, .previews, .galleryInfos: + return .none + + case .comments(.performCommentActionDone(let result)): + return .send(.anyGalleryOpsDone(result)) + + case .comments(.detail(let recursiveAction)): + guard state.commentsState.wrappedValue != nil else { return .none } + let effect = reducer._reduce( + // swiftlint:disable:next force_unwrapping + into: &state.commentsState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction + ) + return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) + + case .comments: + return .none + + case .detailSearch(.detail(let recursiveAction)): + guard state.detailSearchState.wrappedValue != nil else { return .none } + let effect = reducer._reduce( + // swiftlint:disable:next force_unwrapping + into: &state.detailSearchState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction + ) + return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) + + case .detailSearch: + return .none + + default: + return .none + } } } } diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index 874c1be62..c5123f6af 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -8,65 +8,46 @@ import ComposableArchitecture // MARK: - Download Action Handlers extension DetailReducer { - func handleDownloadActions( - state: inout State, - action: Action - ) -> Effect? { - handleDownloadBadgeActions(state: &state, action: action) - ?? handleDownloadLifecycleActions(state: &state, action: action) - } - - private func handleDownloadBadgeActions( - state: inout State, - action: Action - ) -> Effect? { - switch action { - case .fetchDownloadBadge: - return handleFetchDownloadBadge(state: &state) - case .fetchDownloadBadgeDone(let badge): - return handleFetchDownloadBadgeDone(badge: badge, state: &state) - case .observeDownload: - return handleObserveDownload(state: &state) - case .observeDownloadDone(let badge): - return handleObserveDownloadDone(badge: badge, state: &state) - case .loadLocalPreviewURLs: - return handleLoadLocalPreviewURLs(state: &state) - case .loadLocalPreviewURLsDone(let requestID, let urls): - return handleLoadLocalPreviewURLsDone(requestID: requestID, urls: urls, state: &state) - case .openReading: - return handleOpenReading(state: &state) - case .openReadingDone(let result): - return handleOpenReadingDone(result: result, state: &state) - default: - return nil - } - } - - private func handleDownloadLifecycleActions( - state: inout State, - action: Action - ) -> Effect? { - switch action { - case .runLaunchAutomationIfNeeded(let options): - return handleRunLaunchAutomation(options: options, state: &state) - case .startDownload(let options): - return handleStartDownload(options: options, state: &state) - case .startDownloadDone(let result): - return handleStartDownloadDone(result: result, state: &state) - case .toggleDownloadPause: - return handleToggleDownloadPause(state: &state) - case .toggleDownloadPauseDone(let result): - return handleToggleDownloadPauseDone(result: result, state: &state) - case .retryDownload(let mode): - return handleRetryDownload(mode: mode, state: &state) - case .retryDownloadDone(let result): - return handleRetryDownloadDone(result: result, state: &state) - case .deleteDownload: - return handleDeleteDownload(state: state) - case .deleteDownloadDone(let result): - return handleDeleteDownloadDone(result: result, state: &state) - default: - return nil + var downloadReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .fetchDownloadBadge: + return handleFetchDownloadBadge(state: &state) + case .fetchDownloadBadgeDone(let badge): + return handleFetchDownloadBadgeDone(badge: badge, state: &state) + case .observeDownload: + return handleObserveDownload(state: &state) + case .observeDownloadDone(let badge): + return handleObserveDownloadDone(badge: badge, state: &state) + case .loadLocalPreviewURLs: + return handleLoadLocalPreviewURLs(state: &state) + case .loadLocalPreviewURLsDone(let requestID, let urls): + return handleLoadLocalPreviewURLsDone(requestID: requestID, urls: urls, state: &state) + case .openReading: + return handleOpenReading(state: &state) + case .openReadingDone(let result): + return handleOpenReadingDone(result: result, state: &state) + case .runLaunchAutomationIfNeeded(let options): + return handleRunLaunchAutomation(options: options, state: &state) + case .startDownload(let options): + return handleStartDownload(options: options, state: &state) + case .startDownloadDone(let result): + return handleStartDownloadDone(result: result, state: &state) + case .toggleDownloadPause: + return handleToggleDownloadPause(state: &state) + case .toggleDownloadPauseDone(let result): + return handleToggleDownloadPauseDone(result: result, state: &state) + case .retryDownload(let mode): + return handleRetryDownload(mode: mode, state: &state) + case .retryDownloadDone(let result): + return handleRetryDownloadDone(result: result, state: &state) + case .deleteDownload: + return handleDeleteDownload(state: state) + case .deleteDownloadDone(let result): + return handleDeleteDownloadDone(result: result, state: &state) + default: + return .none + } } } diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/EhPanda/View/Detail/DetailReducer+Fetch.swift index e3b7212ad..7ac58ffb0 100644 --- a/EhPanda/View/Detail/DetailReducer+Fetch.swift +++ b/EhPanda/View/Detail/DetailReducer+Fetch.swift @@ -8,38 +8,36 @@ import ComposableArchitecture // MARK: - Fetch & Gallery Ops Action Handlers extension DetailReducer { - func handleFetchActions( - state: inout State, - action: Action, - self reducer: Reduce - ) -> Effect? { - switch action { - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) + func fetchReducer(_ reducer: Reduce) -> some ReducerOf { + Reduce { state, action in + switch action { + case .teardown: + return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .fetchDatabaseInfos(let gid): - return handleFetchDatabaseInfos(gid: gid, state: &state) + case .fetchDatabaseInfos(let gid): + return handleFetchDatabaseInfos(gid: gid, state: &state) - case .fetchDatabaseInfosDone(let galleryState): - return handleFetchDatabaseInfosDone(galleryState: galleryState, state: &state) + case .fetchDatabaseInfosDone(let galleryState): + return handleFetchDatabaseInfosDone(galleryState: galleryState, state: &state) - case .fetchGalleryDetail: - return handleFetchGalleryDetail(state: &state) + case .fetchGalleryDetail: + return handleFetchGalleryDetail(state: &state) - case .fetchGalleryDetailDone(let result): - return handleFetchGalleryDetailDone(result: result, state: &state) + case .fetchGalleryDetailDone(let result): + return handleFetchGalleryDetailDone(result: result, state: &state) - case .fetchVersionMetadataIfNeeded: - return handleFetchVersionMetadataIfNeeded(state: &state) + case .fetchVersionMetadataIfNeeded: + return handleFetchVersionMetadataIfNeeded(state: &state) - case .fetchVersionMetadataDone(let result): - if case .success(let metadata) = result { - state.galleryVersionMetadata = metadata - } - return .none + case .fetchVersionMetadataDone(let result): + if case .success(let metadata) = result { + state.galleryVersionMetadata = metadata + } + return .none - default: - return nil + default: + return .none + } } } @@ -170,25 +168,24 @@ extension DetailReducer { .cancellable(id: CancelID.fetchVersionMetadata, cancelInFlight: true) } - func handleGalleryOpsActions( - state: inout State, - action: Action - ) -> Effect? { - switch action { - case .rateGallery: - return handleRateGallery(state: state) - case .favorGallery(let favIndex): - return handleFavorGallery(favIndex: favIndex, state: state) - case .unfavorGallery: - return handleUnfavorGallery(state: state) - case .postComment(let galleryURL): - return handlePostComment(galleryURL: galleryURL, state: state) - case .voteTag(let tag, let vote): - return handleVoteTag(tag: tag, vote: vote, state: state) - case .anyGalleryOpsDone(let result): - return handleAnyGalleryOpsDone(result: result) - default: - return nil + var galleryOpsReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .rateGallery: + return handleRateGallery(state: state) + case .favorGallery(let favIndex): + return handleFavorGallery(favIndex: favIndex, state: state) + case .unfavorGallery: + return handleUnfavorGallery(state: state) + case .postComment(let galleryURL): + return handlePostComment(galleryURL: galleryURL, state: state) + case .voteTag(let tag, let vote): + return handleVoteTag(tag: tag, vote: vote, state: state) + case .anyGalleryOpsDone(let result): + return handleAnyGalleryOpsDone(result: result) + default: + return .none + } } } diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 3853368a9..0d46caa30 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -154,28 +154,20 @@ struct DetailReducer { // MARK: - Reducer Body extension DetailReducer { - func coreReducer(self: Reduce) -> some Reducer { - Reduce { state, action in - if let effect = handleNavigationActions(state: &state, action: action) { return effect } - if let effect = handleUIActions(state: &state, action: action) { return effect } - if let effect = handleSyncActions(state: &state, action: action) { return effect } - if let effect = handleDownloadActions(state: &state, action: action) { return effect } - if let effect = handleFetchActions(state: &state, action: action, self: self) { return effect } - if let effect = handleGalleryOpsActions(state: &state, action: action) { return effect } - if let effect = handleChildActions(state: &state, action: action, self: self) { return effect } - return .none - } - .ifLet(\.commentsState.wrappedValue, action: \.comments, then: CommentsReducer.init) - .ifLet(\.detailSearchState.wrappedValue, action: \.detailSearch, then: DetailSearchReducer.init) - } - var detailBody: some Reducer { RecurseReducer { (self) in BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none } - coreReducer(self: self) + navigationReducer + uiReducer + syncReducer + downloadReducer + fetchReducer(self) + galleryOpsReducer + childReducer(self) + optionalChildReducers Scope(state: \.readingState, action: \.reading, child: ReadingReducer.init) Scope(state: \.archivesState, action: \.archives, child: ArchivesReducer.init) Scope(state: \.torrentsState, action: \.torrents, child: TorrentsReducer.init) @@ -183,6 +175,12 @@ extension DetailReducer { Scope(state: \.galleryInfosState, action: \.galleryInfos, child: GalleryInfosReducer.init) } } + + var optionalChildReducers: some ReducerOf { + Reduce { _, _ in .none } + .ifLet(\.commentsState.wrappedValue, action: \.comments, then: CommentsReducer.init) + .ifLet(\.detailSearchState.wrappedValue, action: \.detailSearch, then: DetailSearchReducer.init) + } } // MARK: - Haptics diff --git a/EhPanda/View/Reading/ReadingReducer+Body.swift b/EhPanda/View/Reading/ReadingReducer+Body.swift index 9c35d59e8..4eac9aad3 100644 --- a/EhPanda/View/Reading/ReadingReducer+Body.swift +++ b/EhPanda/View/Reading/ReadingReducer+Body.swift @@ -32,7 +32,25 @@ extension ReadingReducer { mainReducer } - var mainReducer: some Reducer { + var mainReducer: some ReducerOf { + CombineReducers { + lifecycleReducer + databaseReducer + imageFetchReducer + } + .haptics( + unwrapping: \.route, + case: \.readingSetting, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: \.share, + hapticsClient: hapticsClient + ) + } + + var lifecycleReducer: some ReducerOf { Reduce { state, action in switch action { case .binding: @@ -95,118 +113,13 @@ extension ReadingReducer { case .fetchImageDone(let action, let result): return reduceFetchImageDone(state: &state, action: action, result: result) - case .syncReadingProgress(let progress): - return .run { [state] _ in - await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) - } - - case .syncPreviewURLs(let previewURLs): - guard state.contentSource == .remote else { return .none } - return .run { [state] _ in - await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) - } - - case .syncThumbnailURLs(let thumbnailURLs): - guard state.contentSource == .remote else { return .none } - return .run { [state] _ in - await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) - } - - case .syncImageURLs(let imageURLs, let originalImageURLs): - guard state.contentSource == .remote else { return .none } - return .run { [state] _ in - await databaseClient.updateImageURLs( - gid: state.gallery.id, - imageURLs: imageURLs, - originalImageURLs: originalImageURLs - ) - } - case .teardown: return reduceTeardown() - case .fetchDatabaseInfos(let gid): - return reduceFetchDatabaseInfos(state: &state, gid: gid) - - case .fetchDatabaseInfosDone(let galleryState): - return reduceFetchDatabaseInfosDone(state: &state, galleryState: galleryState) - - case .observeDownloads(let gid): - return reduceObserveDownloads(gid: gid) - - case .observeDownloadsDone: - guard state.gallery.id.isValidGID else { return .none } - return .send(.loadLocalPageURLs(state.gallery.id)) - - case .loadLocalPageURLs(let gid): - return reduceLoadLocalPageURLs(state: &state, gid: gid) - - case .loadLocalPageURLsDone(let requestID, let localPageURLs): - return reduceLoadLocalPageURLsDone( - state: &state, requestID: requestID, localPageURLs: localPageURLs - ) - - case .fetchPreviewURLs(let index): - return reduceFetchPreviewURLs(state: &state, index: index) - - case .fetchPreviewURLsDone(let index, let result): - return reduceFetchPreviewURLsDone(state: &state, index: index, result: result) - - case .fetchImageURLs(let index): - return reduceFetchImageURLs(state: &state, index: index) - - case .refetchImageURLs(let index): - return reduceRefetchImageURLs(state: &state, index: index) - - case .prefetchImages(let index, let prefetchLimit): - return reducePrefetchImages(state: &state, index: index, prefetchLimit: prefetchLimit) - - case .fetchThumbnailURLs(let index): - return reduceFetchThumbnailURLs(state: &state, index: index) - - case .fetchThumbnailURLsDone(let index, let result): - return reduceFetchThumbnailURLsDone(state: &state, index: index, result: result) - - case .fetchNormalImageURLs(let index, let thumbnailURLs): - return reduceFetchNormalImageURLs( - state: &state, index: index, thumbnailURLs: thumbnailURLs - ) - - case .fetchNormalImageURLsDone(let index, let result): - return reduceFetchNormalImageURLsDone(state: &state, index: index, result: result) - - case .refetchNormalImageURLs(let index): - return reduceRefetchNormalImageURLs(state: &state, index: index) - - case .refetchNormalImageURLsDone(let index, let result): - return reduceRefetchNormalImageURLsDone(state: &state, index: index, result: result) - - case .fetchMPVKeys(let index, let mpvURL): - return reduceFetchMPVKeys(state: &state, index: index, mpvURL: mpvURL) - - case .fetchMPVKeysDone(let index, let result): - return reduceFetchMPVKeysDone(state: &state, index: index, result: result) - - case .fetchMPVImageURL(let index, let isRefresh): - return reduceFetchMPVImageURL(state: &state, index: index, isRefresh: isRefresh) - - case .fetchMPVImageURLDone(let index, let result): - return reduceFetchMPVImageURLDone(state: &state, index: index, result: result) - - case .captureCachedPage(let index): - return reduceCaptureCachedPage(state: &state, index: index) + default: + return .none } } - .haptics( - unwrapping: \.route, - case: \.readingSetting, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.share, - hapticsClient: hapticsClient - ) } } diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift index bbded780c..4f7b2429c 100644 --- a/EhPanda/View/Reading/ReadingReducer+Database.swift +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -7,6 +7,63 @@ import ComposableArchitecture // MARK: - Database & Download Actions extension ReadingReducer { + var databaseReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .syncReadingProgress(let progress): + return .run { [state] _ in + await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) + } + + case .syncPreviewURLs(let previewURLs): + guard state.contentSource == .remote else { return .none } + return .run { [state] _ in + await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) + } + + case .syncThumbnailURLs(let thumbnailURLs): + guard state.contentSource == .remote else { return .none } + return .run { [state] _ in + await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) + } + + case .syncImageURLs(let imageURLs, let originalImageURLs): + guard state.contentSource == .remote else { return .none } + return .run { [state] _ in + await databaseClient.updateImageURLs( + gid: state.gallery.id, + imageURLs: imageURLs, + originalImageURLs: originalImageURLs + ) + } + + case .fetchDatabaseInfos(let gid): + return reduceFetchDatabaseInfos(state: &state, gid: gid) + + case .fetchDatabaseInfosDone(let galleryState): + return reduceFetchDatabaseInfosDone(state: &state, galleryState: galleryState) + + case .observeDownloads(let gid): + return reduceObserveDownloads(gid: gid) + + case .observeDownloadsDone: + guard state.gallery.id.isValidGID else { return .none } + return .send(.loadLocalPageURLs(state.gallery.id)) + + case .loadLocalPageURLs(let gid): + return reduceLoadLocalPageURLs(state: &state, gid: gid) + + case .loadLocalPageURLsDone(let requestID, let localPageURLs): + return reduceLoadLocalPageURLsDone( + state: &state, requestID: requestID, localPageURLs: localPageURLs + ) + + default: + return .none + } + } + } + func reduceTeardown() -> Effect { var effects: [Effect] = [ .merge(ReadingCancelID.allCases.map(Effect.cancel(id:))) diff --git a/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift b/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift index 9d93d9445..8614febd3 100644 --- a/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift +++ b/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift @@ -7,6 +7,65 @@ import ComposableArchitecture // MARK: - Image URL Fetch Actions extension ReadingReducer { + var imageFetchReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .fetchPreviewURLs(let index): + return reduceFetchPreviewURLs(state: &state, index: index) + + case .fetchPreviewURLsDone(let index, let result): + return reduceFetchPreviewURLsDone(state: &state, index: index, result: result) + + case .fetchImageURLs(let index): + return reduceFetchImageURLs(state: &state, index: index) + + case .refetchImageURLs(let index): + return reduceRefetchImageURLs(state: &state, index: index) + + case .prefetchImages(let index, let prefetchLimit): + return reducePrefetchImages(state: &state, index: index, prefetchLimit: prefetchLimit) + + case .fetchThumbnailURLs(let index): + return reduceFetchThumbnailURLs(state: &state, index: index) + + case .fetchThumbnailURLsDone(let index, let result): + return reduceFetchThumbnailURLsDone(state: &state, index: index, result: result) + + case .fetchNormalImageURLs(let index, let thumbnailURLs): + return reduceFetchNormalImageURLs( + state: &state, index: index, thumbnailURLs: thumbnailURLs + ) + + case .fetchNormalImageURLsDone(let index, let result): + return reduceFetchNormalImageURLsDone(state: &state, index: index, result: result) + + case .refetchNormalImageURLs(let index): + return reduceRefetchNormalImageURLs(state: &state, index: index) + + case .refetchNormalImageURLsDone(let index, let result): + return reduceRefetchNormalImageURLsDone(state: &state, index: index, result: result) + + case .fetchMPVKeys(let index, let mpvURL): + return reduceFetchMPVKeys(state: &state, index: index, mpvURL: mpvURL) + + case .fetchMPVKeysDone(let index, let result): + return reduceFetchMPVKeysDone(state: &state, index: index, result: result) + + case .fetchMPVImageURL(let index, let isRefresh): + return reduceFetchMPVImageURL(state: &state, index: index, isRefresh: isRefresh) + + case .fetchMPVImageURLDone(let index, let result): + return reduceFetchMPVImageURLDone(state: &state, index: index, result: result) + + case .captureCachedPage(let index): + return reduceCaptureCachedPage(state: &state, index: index) + + default: + return .none + } + } + } + func reduceFetchPreviewURLs(state: inout State, index: Int) -> Effect { guard state.contentSource == .remote else { state.previewLoadingStates[index] = .idle From a4ec47c973cff793e8402cd9d23c6ee0c416cad1 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 00:03:43 +0800 Subject: [PATCH 072/614] Add storage foundation --- .../Tools/Utilities/DownloadFileStorage.swift | 102 +++++++++++++++++- .../Persistent/DownloadDisplayStatus.swift | 13 +++ .../DownloadedGallery+Manifest.swift | 4 +- .../DownloadedGallery+SupportTypes.swift | 19 ++++ .../Download/DownloadFileStorageTests.swift | 64 +++++++++++ 5 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 EhPanda/Models/Persistent/DownloadDisplayStatus.swift diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index a96e93f20..22987ac4e 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -11,6 +11,13 @@ enum DownloadValidationState: Equatable, Sendable { case missingFiles(String) } +struct DownloadFolderRecord: Equatable, Sendable { + let relativePath: String + let folderURL: URL + let manifest: DownloadManifest + let modifiedAt: Date? +} + struct DownloadResumeState: Codable, Equatable { let mode: DownloadStartMode let versionSignature: String @@ -189,6 +196,14 @@ struct DownloadFileStorage: Sendable { } func makeFolderRelativePath(gid: String, title: String) -> String { + "\(gid) - \(normalizedFolderTitle(title))" + } + + func makeFolderRelativePath(gid: String, token: String, title: String) -> String { + "[\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))] \(normalizedFolderTitle(title))" + } + + private func normalizedFolderTitle(_ title: String) -> String { let invalidCharacters = CharacterSet(charactersIn: "/\\:") .union(.controlCharacters) let sanitizedScalars = title @@ -215,7 +230,17 @@ struct DownloadFileStorage: Sendable { options: .regularExpression ) let fallbackTitle = limitedSlug.isEmpty ? "Gallery" : limitedSlug - return "\(gid) - \(fallbackTitle)" + return fallbackTitle + } + + private func normalizedIdentityComponent(_ value: String) -> String { + let invalidCharacters = CharacterSet(charactersIn: "/\\[]:") + .union(.controlCharacters) + .union(.whitespacesAndNewlines) + let sanitized = value.unicodeScalars + .map { invalidCharacters.contains($0) ? "_" : String($0) } + .joined() + return sanitized.isEmpty ? "unknown" : sanitized } func makePageRelativePath(index: Int, fileExtension: String) -> String { @@ -228,6 +253,50 @@ struct DownloadFileStorage: Sendable { "cover.\(fileExtension.lowercased())" } + func makePageRelativePath(gid: String, token: String, index: Int, fileExtension: String) -> String { + [ + normalizedIdentityComponent(gid), + normalizedIdentityComponent(token), + String(index) + ].joined(separator: "_") + ".\(fileExtension.lowercased())" + } + + func makeCoverRelativePath(gid: String, token: String, fileExtension: String) -> String { + "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_cover.\(fileExtension.lowercased())" + } + + func existingPageFileURL(folderURL: URL, gid: String, token: String, index: Int) -> URL? { + existingAssetFileURL( + folderURL: folderURL, + prefix: "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_\(index)." + ) + } + + func existingCoverFileURL(folderURL: URL, gid: String, token: String) -> URL? { + existingAssetFileURL( + folderURL: folderURL, + prefix: "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_cover." + ) + } + + private func existingAssetFileURL(folderURL: URL, prefix: String) -> URL? { + guard let fileURLs = try? fileManager.operate({ + try $0.contentsOfDirectory( + at: folderURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey] + ) + }) else { + return nil + } + + return fileURLs + .sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) + .first(where: { + $0.lastPathComponent.hasPrefix(prefix) + && sanitizeAssetFileIfNeeded(at: $0) + }) + } + func writeManifest(_ manifest: DownloadManifest, folderURL: URL) throws { try writeJSON(manifest, to: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest)) } @@ -236,6 +305,37 @@ struct DownloadFileStorage: Sendable { try readJSON(DownloadManifest.self, from: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest)) } + func scanDownloadFolders() throws -> [DownloadFolderRecord] { + guard fileManager.operate({ $0.fileExists(atPath: rootURL.path) }) else { + return [] + } + + let folderURLs = try fileManager.operate { + try $0.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) + } + + return folderURLs.compactMap { folderURL in + let resourceValues = try? folderURL.resourceValues( + forKeys: [.isDirectoryKey, .contentModificationDateKey] + ) + guard resourceValues?.isDirectory == true, + let manifest = try? readManifest(folderURL: folderURL) + else { + return nil + } + return DownloadFolderRecord( + relativePath: folderURL.lastPathComponent, + folderURL: folderURL, + manifest: manifest, + modifiedAt: resourceValues?.contentModificationDate + ) + } + } + func fileHash(at url: URL) throws -> String { let handle = try FileHandle(forReadingFrom: url) defer { try? handle.close() } diff --git a/EhPanda/Models/Persistent/DownloadDisplayStatus.swift b/EhPanda/Models/Persistent/DownloadDisplayStatus.swift new file mode 100644 index 000000000..823ce2789 --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadDisplayStatus.swift @@ -0,0 +1,13 @@ +// +// DownloadDisplayStatus.swift +// EhPanda +// + +enum DownloadDisplayStatus: Int, Equatable, CaseIterable, Sendable { + case active + case queued + case updateAvailable + case error + case inactive + case completed +} diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift index 006934b72..04c7814e8 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -5,8 +5,8 @@ import Foundation -struct DownloadManifest: Codable, Equatable { - struct Page: Codable, Equatable, Identifiable { +struct DownloadManifest: Codable, Equatable, Sendable { + struct Page: Codable, Equatable, Identifiable, Sendable { var id: Int { index } let index: Int diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index 991767127..c74c67997 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -121,6 +121,25 @@ extension DownloadedGallery { } } + var displayStatus: DownloadDisplayStatus { + if status == .updateAvailable || hasUpdate { + return .updateAvailable + } + if status == .completed { + return .completed + } + if status == .downloading { + return .active + } + if isQueuedWorkItem { + return .queued + } + if lastError != nil || [.failed, .missingFiles].contains(status) { + return .error + } + return .inactive + } + var sortPriority: Int { if isQueuedWorkItem { return 1 diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 8168ba300..41d2f6632 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -237,6 +237,70 @@ struct DownloadFileStorageTests { #expect(relativePath.hasSuffix(".") == false) #expect(relativePath.count <= "123 - ".count + 96) } + + @Test + func testFinalFolderRelativePathUsesIdentityPrefixAndSanitizedTitle() { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + let unsafeTitle = " /Alpha\\\\Beta:\n\tGamma Delta. " + let relativePath = storage.makeFolderRelativePath(gid: "123", token: "tok/en", title: unsafeTitle) + + #expect(relativePath == "[123_tok_en] Alpha Beta Gamma Delta") + } + + @Test + func testFinalAssetRelativePathsUseIdentityAndUnpaddedPageIndex() { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + #expect( + storage.makePageRelativePath(gid: "123", token: "token", index: 7, fileExtension: "JPG") + == "123_token_7.jpg" + ) + #expect( + storage.makeCoverRelativePath(gid: "123", token: "token", fileExtension: "PNG") + == "123_token_cover.png" + ) + } + + @Test + func testScanDownloadFoldersReadsOnlyFoldersWithManifests() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let downloadFolderURL = storage.folderURL(relativePath: "[123_token] Sample") + let ignoredFolderURL = storage.folderURL(relativePath: "[456_token] Missing manifest") + let hiddenFolderURL = storage.folderURL(relativePath: ".tmp-789") + try FileManager.default.createDirectory(at: downloadFolderURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: ignoredFolderURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: hiddenFolderURL, withIntermediateDirectories: true) + try storage.writeManifest(sampleManifest(pageCount: 2), folderURL: downloadFolderURL) + + let records = try storage.scanDownloadFolders() + + #expect(records.map(\.relativePath) == ["[123_token] Sample"]) + #expect(records.first?.manifest.gid == "123") + #expect(records.first?.folderURL == downloadFolderURL) + } + + @Test + func testExistingFinalAssetFileURLsUseIdentityPrefix() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[123_token] Sample") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + let pageURL = folderURL.appendingPathComponent("123_token_2.webp") + let coverURL = folderURL.appendingPathComponent("123_token_cover.jpg") + try Data([0x01]).write(to: pageURL, options: .atomic) + try Data([0x02]).write(to: coverURL, options: .atomic) + + #expect(storage.existingPageFileURL(folderURL: folderURL, gid: "123", token: "token", index: 2) == pageURL) + #expect(storage.existingCoverFileURL(folderURL: folderURL, gid: "123", token: "token") == coverURL) + } } private final class ThrowingAttributesFileManager: FileManager { From c74e21bc10f5dc6b153fbf6aa3ded1c382aca0d4 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 00:07:24 +0800 Subject: [PATCH 073/614] Add download queue --- .../Clients/DownloadClient+Manager.swift | 3 ++ .../Tools/Utilities/DownloadFileStorage.swift | 4 ++ .../Tools/Utilities/DownloadQueueStore.swift | 45 ++++++++++++++++++ .../Download/DownloadQueueStoreTests.swift | 47 +++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 EhPanda/App/Tools/Utilities/DownloadQueueStore.swift create mode 100644 EhPandaTests/Tests/Download/DownloadQueueStoreTests.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index bd316c4cf..02681007d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -135,6 +135,7 @@ actor DownloadManager { let storage: DownloadFileStorage let urlSession: URLSession let libraryClient: LibraryClient + let queueStore: DownloadQueueStore let persistenceContainer: NSPersistentContainer var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() var lastObservedDownloads = [DownloadedGallery]() @@ -151,11 +152,13 @@ actor DownloadManager { storage: DownloadFileStorage, urlSession: URLSession, libraryClient: LibraryClient = .live, + queueStore: DownloadQueueStore? = nil, persistenceContainer: NSPersistentContainer = PersistenceController.shared.container ) { self.storage = storage self.urlSession = urlSession self.libraryClient = libraryClient + self.queueStore = queueStore ?? DownloadQueueStore(fileURL: storage.queueURL()) self.persistenceContainer = persistenceContainer } diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 22987ac4e..46c97ad5a 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -95,6 +95,10 @@ struct DownloadFileStorage: Sendable { .appendingPathComponent(Defaults.FilePath.downloadManifest) } + func queueURL() -> URL { + rootURL.appendingPathComponent(".queue.json") + } + func temporaryFolderURL(gid: String) -> URL { rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) } diff --git a/EhPanda/App/Tools/Utilities/DownloadQueueStore.swift b/EhPanda/App/Tools/Utilities/DownloadQueueStore.swift new file mode 100644 index 000000000..8eef41675 --- /dev/null +++ b/EhPanda/App/Tools/Utilities/DownloadQueueStore.swift @@ -0,0 +1,45 @@ +// +// DownloadQueueStore.swift +// EhPanda +// + +import ComposableArchitecture +import Foundation + +struct DownloadQueueStore: Sendable { + private let identifiers: Shared<[String]> + + init(fileURL: URL) { + identifiers = Shared(wrappedValue: [], .fileStorage(fileURL)) + } + + var gids: [String] { + identifiers.wrappedValue + } + + func contains(_ gid: String) -> Bool { + identifiers.wrappedValue.contains(gid) + } + + func enqueue(_ gid: String) async { + identifiers.withLock { gids in + guard !gids.contains(gid) else { return } + gids.append(gid) + } + try? await identifiers.save() + } + + func remove(_ gid: String) async { + identifiers.withLock { gids in + gids.removeAll { $0 == gid } + } + try? await identifiers.save() + } + + func removeAll() async { + identifiers.withLock { gids in + gids.removeAll() + } + try? await identifiers.save() + } +} diff --git a/EhPandaTests/Tests/Download/DownloadQueueStoreTests.swift b/EhPandaTests/Tests/Download/DownloadQueueStoreTests.swift new file mode 100644 index 000000000..84bd61c08 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadQueueStoreTests.swift @@ -0,0 +1,47 @@ +// +// DownloadQueueStoreTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +struct DownloadQueueStoreTests { + @Test + func testEnqueueDeduplicatesAndPreservesOrder() async { + let (store, rootURL) = makeStore() + defer { try? FileManager.default.removeItem(at: rootURL) } + + await store.enqueue("123") + await store.enqueue("456") + await store.enqueue("123") + + #expect(store.gids == ["123", "456"]) + } + + @Test + func testRemoveAndRemoveAllUpdateQueue() async { + let (store, rootURL) = makeStore() + defer { try? FileManager.default.removeItem(at: rootURL) } + + await store.enqueue("123") + await store.enqueue("456") + await store.remove("123") + + #expect(store.gids == ["456"]) + + await store.removeAll() + + #expect(store.gids.isEmpty) + } +} + +private extension DownloadQueueStoreTests { + func makeStore() -> (DownloadQueueStore, URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let fileURL = rootURL.appendingPathComponent(".queue.json") + return (DownloadQueueStore(fileURL: fileURL), rootURL) + } +} From b7867042051901efd7dc2f103b6980a3a859eefd Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 00:10:38 +0800 Subject: [PATCH 074/614] Bridge manifest model --- .../DownloadedGallery+Manifest.swift | 10 +++ .../Models/Persistent/DownloadedGallery.swift | 50 ++++++++++++++ .../DownloadedGalleryManifestModelTests.swift | 69 +++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift index 04c7814e8..6100b1fed 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -92,3 +92,13 @@ struct DownloadManifest: Codable, Equatable, Sendable { }) } } + +extension DownloadManifest { + var completedPageCount: Int { + pages.filter { $0.fileHash?.isEmpty == false }.count + } + + var isComplete: Bool { + !pages.isEmpty && completedPageCount == pages.count + } +} diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index b1611f782..c0011ec51 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -254,4 +254,54 @@ struct DownloadedGallery: Identifiable, Equatable { self.pendingOperation = pendingOperation } + init( + manifest: DownloadManifest, + folderRelativePath: String, + modifiedAt: Date?, + displayStatus: DownloadDisplayStatus, + lastError: DownloadFailure? = nil + ) { + self.init( + gid: manifest.gid, + host: manifest.host, + token: manifest.token, + title: manifest.title, + jpnTitle: manifest.jpnTitle, + uploader: manifest.uploader, + category: manifest.category, + tags: manifest.tags, + pageCount: manifest.pageCount, + postedDate: manifest.postedDate, + rating: manifest.rating, + onlineCoverURL: nil, + folderRelativePath: folderRelativePath, + coverRelativePath: manifest.coverRelativePath, + status: displayStatus.downloadStatus, + completedPageCount: manifest.completedPageCount, + lastDownloadedAt: modifiedAt ?? manifest.downloadedAt, + lastError: lastError, + downloadOptionsSnapshot: manifest.downloadOptions, + remoteVersionSignature: manifest.versionSignature, + latestRemoteVersionSignature: manifest.versionSignature + ) + } +} + +private extension DownloadDisplayStatus { + var downloadStatus: DownloadStatus { + switch self { + case .active: + return .downloading + case .queued: + return .queued + case .updateAvailable: + return .updateAvailable + case .error: + return .failed + case .inactive: + return .paused + case .completed: + return .completed + } + } } diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift new file mode 100644 index 000000000..2e3ba7a18 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -0,0 +1,69 @@ +// +// DownloadedGalleryManifestModelTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +struct DownloadedGalleryManifestModelTests { + @Test + func testManifestCompletedPageCountDerivesFromNonEmptyHashes() throws { + let manifest = try sampleManifest(pageHashes: [1: "sha256:a", 2: "", 3: nil]) + + #expect(manifest.completedPageCount == 1) + #expect(manifest.isComplete == false) + } + + @Test + func testDownloadedGalleryViewModelUsesManifestAndRuntimeStatus() throws { + let modifiedAt = Date(timeIntervalSince1970: 1_234) + let manifest = try sampleManifest(pageHashes: [1: "sha256:a", 2: "sha256:b"]) + + let download = DownloadedGallery( + manifest: manifest, + folderRelativePath: "[123_token] Sample", + modifiedAt: modifiedAt, + displayStatus: .queued + ) + + #expect(download.gid == "123") + #expect(download.folderRelativePath == "[123_token] Sample") + #expect(download.status == .queued) + #expect(download.completedPageCount == 2) + #expect(download.lastDownloadedAt == modifiedAt) + #expect(download.downloadOptionsSnapshot.threadLimit == 3) + } +} + +private extension DownloadedGalleryManifestModelTests { + func sampleManifest(pageHashes: [Int: String?]) throws -> DownloadManifest { + DownloadManifest( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: "サンプル", + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: Date(timeIntervalSince1970: 1_000), + pageCount: pageHashes.count, + coverRelativePath: "123_token_cover.jpg", + galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), + rating: 4, + downloadOptions: .init(threadLimit: 3), + versionSignature: "hash:v1", + downloadedAt: Date(timeIntervalSince1970: 1_111), + pages: pageHashes.sorted(by: { $0.key < $1.key }).map { index, hash in + .init( + index: index, + relativePath: "123_token_\(index).jpg", + fileHash: hash + ) + } + ) + } +} From ce72a0240df16a24bc4059a59f4cc5fac5f7f95c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 00:14:43 +0800 Subject: [PATCH 075/614] Seed manifest on enqueue --- .../Clients/DownloadClient+PublicAPI.swift | 56 +++++++++++++++++ .../DownloadEnqueueManifestTests.swift | 63 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 4dcf1f1f6..7e5ae8476 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -155,10 +155,16 @@ extension DownloadManager { ) let folderRelativePath = storage.makeFolderRelativePath( gid: payload.gallery.gid, + token: payload.gallery.token, title: payload.galleryDetail.trimmedTitle.isEmpty ? payload.gallery.title : payload.galleryDetail.trimmedTitle ) + try writeInitialManifest( + payload: payload, + folderRelativePath: folderRelativePath, + versionSignature: versionSignature + ) try await updateDownloadRecord(gid: payload.gallery.gid) { record in record.gid = payload.gallery.gid record.host = payload.host.rawValue @@ -182,6 +188,7 @@ extension DownloadManager { record.pendingOperation = nil record.status = DownloadStatus.queued.rawValue } + await queueStore.enqueue(payload.gallery.gid) await notifyObservers() await scheduleNextIfNeeded() return .success(()) @@ -193,6 +200,55 @@ extension DownloadManager { } } + private func writeInitialManifest( + payload: DownloadRequestPayload, + folderRelativePath: String, + versionSignature: String + ) throws { + guard let galleryURL = payload.gallery.galleryURL else { + throw AppError.notFound + } + let folderURL = storage.folderURL(relativePath: folderRelativePath) + try createDirectory(at: folderURL) + let pageCount = payload.galleryDetail.pageCount + let pages = pageCount > 0 + ? (1...pageCount).map { index in + DownloadManifest.Page( + index: index, + relativePath: storage.makePageRelativePath( + gid: payload.gallery.gid, + token: payload.gallery.token, + index: index, + fileExtension: "pending" + ) + ) + } + : [] + try storage.writeManifest( + DownloadManifest( + gid: payload.gallery.gid, + host: payload.host, + token: payload.gallery.token, + title: payload.gallery.title, + jpnTitle: payload.galleryDetail.jpnTitle, + category: payload.gallery.category, + language: payload.galleryDetail.language, + uploader: payload.galleryDetail.uploader, + tags: payload.gallery.tags, + postedDate: payload.galleryDetail.postedDate, + pageCount: pageCount, + coverRelativePath: nil, + galleryURL: galleryURL, + rating: payload.galleryDetail.rating, + downloadOptions: payload.options, + versionSignature: versionSignature, + downloadedAt: .now, + pages: pages + ), + folderURL: folderURL + ) + } + func togglePause(gid: String) async -> Result { guard let download = await fetchDownload(gid: gid) else { return .failure(.notFound) diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift new file mode 100644 index 000000000..e83cce5bb --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -0,0 +1,63 @@ +// +// DownloadEnqueueManifestTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { + @Test + func testEnqueueWritesInitialManifestAndQueueIntent() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + queueStore: queueStore, + persistenceContainer: try makeInMemoryContainer() + ) + await manager.testingInstallActiveTask(gid: "busy", task: Task {}) + + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: "Downloaded / Gallery") + let payload = DownloadRequestPayload( + gallery: gallery, + galleryDetail: detail, + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + options: .init(threadLimit: 3), + mode: .initial + ) + + let result = await manager.enqueue(payload: payload) + + guard case .success = result else { + Issue.record("Expected enqueue to succeed, got \(result).") + return + } + + let folderRelativePath = storage.makeFolderRelativePath( + gid: gallery.gid, + token: gallery.token, + title: detail.trimmedTitle + ) + let manifest = try storage.readManifest( + folderURL: storage.folderURL(relativePath: folderRelativePath) + ) + + #expect(queueStore.gids == [gallery.gid]) + #expect(manifest.gid == gallery.gid) + #expect(manifest.token == gallery.token) + #expect(manifest.pageCount == detail.pageCount) + #expect(manifest.pages.count == detail.pageCount) + #expect(manifest.pages.first?.relativePath == "\(gallery.gid)_\(gallery.token)_1.pending") + #expect(manifest.downloadOptions.threadLimit == 3) + } +} From 1b5d4aa6fc590f9e5826f2ab290335b0ac9fab6a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 00:27:17 +0800 Subject: [PATCH 076/614] Use final asset names --- .../Tools/Clients/DownloadClient+Cache.swift | 5 ++++ .../DownloadClient+ExecutionSupport.swift | 16 +++++++++-- .../Clients/DownloadClient+Manager.swift | 2 ++ .../Clients/DownloadClient+PageDownload.swift | 1 + .../DownloadClient+PageDownloadHelpers.swift | 27 +++++++++++-------- .../Clients/DownloadClient+PublicAPI.swift | 2 ++ .../Download/DownloadFeatureTestHelpers.swift | 6 ++--- 7 files changed, 43 insertions(+), 16 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 787c3c725..6e9734a33 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -77,6 +77,7 @@ extension DownloadManager { } func restorePendingPagesFromStoredCache( + payload: DownloadRequestPayload, indices: [Int], temporaryFolderURL: URL, existingPages: [Int: String], @@ -91,6 +92,8 @@ extension DownloadManager { storedGalleryImageState ) let cacheSource = CacheRestoreSource( + gid: payload.gallery.gid, + token: payload.gallery.token, cacheURLs: cacheURLs, referenceURL: cacheURLs .compactMap(\.self).first, @@ -136,6 +139,8 @@ extension DownloadManager { prefixData: cachedData ) relativePath = storage.makePageRelativePath( + gid: source.gid, + token: source.token, index: index, fileExtension: ext ) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index b8d02bea3..ebaa67c8a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -32,11 +32,13 @@ extension DownloadManager { return try saveCoverFromCache( cachedData: cachedData, coverURL: coverURL, + payload: payload, temporaryFolderURL: temporaryFolderURL ) } return try await downloadCoverFromNetwork( coverURL: coverURL, + payload: payload, temporaryFolderURL: temporaryFolderURL, allowsCellular: payload.options.allowCellular ) @@ -45,6 +47,7 @@ extension DownloadManager { private func saveCoverFromCache( cachedData: Data, coverURL: URL, + payload: DownloadRequestPayload, temporaryFolderURL: URL ) throws -> String { let ext = fileExtension( @@ -53,7 +56,11 @@ extension DownloadManager { prefixData: cachedData ) let relativePath = storage - .makeCoverRelativePath(fileExtension: ext) + .makeCoverRelativePath( + gid: payload.gallery.gid, + token: payload.gallery.token, + fileExtension: ext + ) let fileURL = temporaryFolderURL .appendingPathComponent(relativePath) try write(data: cachedData, to: fileURL) @@ -62,6 +69,7 @@ extension DownloadManager { private func downloadCoverFromNetwork( coverURL: URL, + payload: DownloadRequestPayload, temporaryFolderURL: URL, allowsCellular: Bool ) async throws -> String { @@ -79,7 +87,11 @@ extension DownloadManager { prefixData: prefixData ) let relativePath = storage - .makeCoverRelativePath(fileExtension: ext) + .makeCoverRelativePath( + gid: payload.gallery.gid, + token: payload.gallery.token, + fileExtension: ext + ) let fileURL = temporaryFolderURL .appendingPathComponent(relativePath) try moveDownloadedFile( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 02681007d..bdaa89744 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -92,6 +92,8 @@ actor DownloadManager { } struct CacheRestoreSource: Sendable { + let gid: String + let token: String let cacheURLs: [URL?] let referenceURL: URL? let imageURL: URL? diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index e7b372047..8dda90e76 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -112,6 +112,7 @@ extension DownloadManager { let payload = context.payload let restoredCachedPages = try await restorePendingPagesFromStoredCache( + payload: payload, indices: pendingPageIndices, temporaryFolderURL: context.temporaryFolderURL, existingPages: existingPages, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift index 301a84e7c..9f556fd9e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -46,12 +46,10 @@ extension DownloadManager { ) async throws -> PageResult { let payload = context.payload let temporaryFolderURL = context.temporaryFolderURL - let storedGalleryImageState = context.storedGalleryImageState if let result = try await attemptCacheRestore( index: index, - storedGalleryImageState: storedGalleryImageState, - temporaryFolderURL: temporaryFolderURL, + context: context, preferredRelativePath: preferredRelativePath ) { return result @@ -67,8 +65,7 @@ extension DownloadManager { if let result = try await attemptResolvedCacheRestore( index: index, resolvedImageSource: resolved, - storedGalleryImageState: storedGalleryImageState, - temporaryFolderURL: temporaryFolderURL, + context: context, preferredRelativePath: preferredRelativePath ) { return result @@ -84,16 +81,19 @@ extension DownloadManager { private func attemptCacheRestore( index: Int, - storedGalleryImageState: CachedGalleryImageState?, - temporaryFolderURL: URL, + context: PageDownloadContext, preferredRelativePath: String? ) async throws -> PageResult? { + let payload = context.payload + let storedGalleryImageState = context.storedGalleryImageState let storedCacheURLs = pageImageCacheURLs( resolvedImageSource: nil, index: index, storedGalleryImageState: storedGalleryImageState ) let storedSource = CacheRestoreSource( + gid: payload.gallery.gid, + token: payload.gallery.token, cacheURLs: storedCacheURLs, referenceURL: storedCacheURLs .compactMap(\.self).first, @@ -103,7 +103,7 @@ extension DownloadManager { return try await restorePageFromCache( index: index, source: storedSource, - folderURL: temporaryFolderURL, + folderURL: context.temporaryFolderURL, preferredRelativePath: preferredRelativePath ) } @@ -111,16 +111,19 @@ extension DownloadManager { private func attemptResolvedCacheRestore( index: Int, resolvedImageSource: ResolvedImageSource, - storedGalleryImageState: CachedGalleryImageState?, - temporaryFolderURL: URL, + context: PageDownloadContext, preferredRelativePath: String? ) async throws -> PageResult? { + let payload = context.payload + let storedGalleryImageState = context.storedGalleryImageState let resolvedCacheURLs = pageImageCacheURLs( resolvedImageSource: resolvedImageSource, index: index, storedGalleryImageState: storedGalleryImageState ) let resolvedSource = CacheRestoreSource( + gid: payload.gallery.gid, + token: payload.gallery.token, cacheURLs: resolvedCacheURLs, referenceURL: preferredPageReferenceURL( resolvedImageSource: resolvedImageSource @@ -130,7 +133,7 @@ extension DownloadManager { return try await restorePageFromCache( index: index, source: resolvedSource, - folderURL: temporaryFolderURL, + folderURL: context.temporaryFolderURL, preferredRelativePath: preferredRelativePath ) } @@ -162,6 +165,8 @@ extension DownloadManager { prefixData: prefixData ) relativePath = storage.makePageRelativePath( + gid: payload.gallery.gid, + token: payload.gallery.token, index: index, fileExtension: ext ) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 7e5ae8476..4d089ac5b 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -366,6 +366,8 @@ extension DownloadManager { do { let cacheURLs = pageImageCacheURLs(imageURL: imageURL) let cacheSource = CacheRestoreSource( + gid: download.gid, + token: download.token, cacheURLs: cacheURLs, referenceURL: preferredPageReferenceURL(imageURL: imageURL), imageURL: imageURL diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift index abdc6f0a6..7f0ef22ef 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -75,15 +75,15 @@ extension DownloadFeatureTestCase { let clock = ContinuousClock() let deadline = clock.now.advanced(by: timeout) - while !cacheKeys.allSatisfy({ KingfisherManager.shared.cache.isCached(forKey: $0) }), + while !cacheKeys.allSatisfy(LibraryClient.live.isCached), clock.now < deadline { try? await clock.sleep(until: clock.now.advanced(by: .milliseconds(10)), tolerance: .zero) } - let missingKeys = cacheKeys.filter { !KingfisherManager.shared.cache.isCached(forKey: $0) } + let missingKeys = cacheKeys.filter { !LibraryClient.live.isCached($0) } #expect( missingKeys.isEmpty, - "Timed out waiting for Kingfisher cache visibility for keys: \(missingKeys)" + "Timed out waiting for cache visibility for keys: \(missingKeys)" ) } From bfecfdda868bc703133ba10a4e5f7a6b5e653cc0 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 00:32:47 +0800 Subject: [PATCH 077/614] Detect final assets --- .../Tools/Utilities/DownloadFileStorage.swift | 43 ++++++++++++++- .../Download/DownloadFileStorageTests.swift | 54 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 46c97ad5a..74bddd817 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -150,6 +150,36 @@ struct DownloadFileStorage: Sendable { func existingPageRelativePaths( folderURL: URL, expectedPageCount: Int + ) -> [Int: String] { + var relativePaths = existingLegacyPageRelativePaths( + folderURL: folderURL, + expectedPageCount: expectedPageCount + ) + guard let finalPageURLs = try? fileManager.operate({ + try $0.contentsOfDirectory( + at: folderURL, + includingPropertiesForKeys: nil + ) + }) else { + return relativePaths + } + + for pageURL in finalPageURLs { + guard sanitizeAssetFileIfNeeded(at: pageURL), + let index = finalPageIndex(from: pageURL), + index >= 1, + index <= expectedPageCount + else { + continue + } + relativePaths[index] = pageURL.lastPathComponent + } + return relativePaths + } + + private func existingLegacyPageRelativePaths( + folderURL: URL, + expectedPageCount: Int ) -> [Int: String] { let pagesFolderURL = folderURL.appendingPathComponent( Defaults.FilePath.downloadPages, @@ -181,6 +211,15 @@ struct DownloadFileStorage: Sendable { return relativePaths } + private func finalPageIndex(from pageURL: URL) -> Int? { + let filename = pageURL.deletingPathExtension().lastPathComponent + guard let separatorIndex = filename.lastIndex(of: "_") else { + return nil + } + let indexStart = filename.index(after: separatorIndex) + return Int(filename[indexStart...]) + } + func existingCoverRelativePath(folderURL: URL) -> String? { guard let fileURLs = try? fileManager.operate({ try $0.contentsOfDirectory( @@ -192,8 +231,10 @@ struct DownloadFileStorage: Sendable { } return fileURLs + .sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) .first(where: { - $0.lastPathComponent.hasPrefix("cover.") + let filename = $0.deletingPathExtension().lastPathComponent + return (filename == "cover" || filename.hasSuffix("_cover")) && sanitizeAssetFileIfNeeded(at: $0) })? .lastPathComponent diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 41d2f6632..c6923025b 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -178,6 +178,27 @@ struct DownloadFileStorageTests { ) } + @Test + func testExistingPageRelativePathsDetectsFinalAssetFiles() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[123_token] Sample") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try Data([0x01]).write(to: folderURL.appendingPathComponent("123_token_1.webp"), options: .atomic) + try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_2.jpg"), options: .atomic) + try Data([0x03]).write(to: folderURL.appendingPathComponent("123_token_27.jpg"), options: .atomic) + try Data([0x04]).write(to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic) + + #expect( + storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2) == [ + 1: "123_token_1.webp", + 2: "123_token_2.jpg" + ] + ) + } + @Test func testExistingPageRelativePathsRemovesZeroByteFiles() throws { let (storage, rootURL) = makeStorage() @@ -202,6 +223,39 @@ struct DownloadFileStorageTests { #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) } + @Test + func testExistingPageRelativePathsRemovesZeroByteFinalAssetFiles() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[123_token] Sample") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + let emptyPageURL = folderURL.appendingPathComponent("123_token_1.jpg") + try Data().write(to: emptyPageURL, options: .atomic) + try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_2.png"), options: .atomic) + + #expect( + storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2) == [ + 2: "123_token_2.png" + ] + ) + #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) + } + + @Test + func testExistingCoverRelativePathDetectsFinalAssetFile() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[123_token] Sample") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic) + + #expect(storage.existingCoverRelativePath(folderURL: folderURL) == "123_token_cover.jpg") + } + @Test func testIsReadableAssetFileDoesNotDeleteFileWhenAttributesLookupFails() throws { let rootURL = FileManager.default.temporaryDirectory From b6a4ee1edff03c3affbd53008a7f4ae585816703 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 00:49:07 +0800 Subject: [PATCH 078/614] Use final folder --- .../Clients/DownloadClient+Execution.swift | 11 +-- .../DownloadClient+ExecutionPerform.swift | 57 +++++------ .../DownloadClient+ExecutionSupport.swift | 96 +++++++++++++------ .../Clients/DownloadClient+Manager.swift | 1 - .../Clients/DownloadClient+PageDownload.swift | 1 + .../Clients/DownloadClient+PublicAPI.swift | 8 +- .../Clients/DownloadClient+Testing.swift | 9 +- .../Tools/Utilities/DownloadFileStorage.swift | 6 +- .../Download/DownloadFileStorageTests.swift | 22 +++++ .../Tests/Download/DownloadProcessTests.swift | 53 +++++++--- 10 files changed, 164 insertions(+), 100 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 3d79109bc..f0b684cde 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -125,9 +125,9 @@ extension DownloadManager { download: DownloadedGallery, mode: DownloadStartMode ) async throws -> ProcessDownloadResult { - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let existingFolderURL = download.resolvedFolderURL(rootURL: storage.rootURL) let existingResumeState = try? storage - .readResumeState(folderURL: temporaryFolderURL) + .readResumeState(folderURL: existingFolderURL) let rawPageSelection = existingResumeState?.pageSelection let fetchResult = try await fetchLatestPayload( for: download, @@ -141,12 +141,7 @@ extension DownloadManager { existingResumeState: existingResumeState, rawPageSelection: rawPageSelection ) - let folderRelativePath = storage.makeFolderRelativePath( - gid: payload.gallery.gid, - title: payload.galleryDetail.trimmedTitle.isEmpty - ? payload.gallery.title - : payload.galleryDetail.trimmedTitle - ) + let folderRelativePath = folderRelativePath(for: payload) let downloadResult = try await performDownload( payload: payload, versionSignature: fetchResult.versionSignature, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index c174449b3..94aa559dd 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -20,17 +20,18 @@ extension DownloadManager { ) async throws -> PerformDownloadResult { try storage.ensureRootDirectory() - let temporaryFolderURL = storage - .temporaryFolderURL(gid: payload.gallery.gid) + let workingFolderURL = storage.folderURL( + relativePath: folderRelativePath + ) let workingSeed = try prepareWorkingSeed( payload: payload, existingDownload: existingDownload, - temporaryFolderURL: temporaryFolderURL, + folderURL: workingFolderURL, versionSignature: versionSignature ) let pendingIndices = pendingPageIndices( payload: payload, - folderURL: temporaryFolderURL, + folderURL: workingFolderURL, existingPageRelativePaths: workingSeed.existingPages ) try storage.writeResumeState( @@ -41,20 +42,19 @@ extension DownloadManager { downloadOptions: payload.options, pageSelection: payload.pageSelection?.sorted() ), - folderURL: temporaryFolderURL + folderURL: workingFolderURL ) let executionContext = DownloadExecutionContext( existingDownload: existingDownload, - versionSignature: versionSignature, - folderRelativePath: folderRelativePath + versionSignature: versionSignature ) do { let batchAndCover = try await executePageDownloads( payload: payload, workingSeed: workingSeed, pendingIndices: pendingIndices, - temporaryFolderURL: temporaryFolderURL, + workingFolderURL: workingFolderURL, executionContext: executionContext ) return batchAndCover @@ -69,33 +69,32 @@ extension DownloadManager { payload: DownloadRequestPayload, workingSeed: WorkingSeed, pendingIndices: [Int], - temporaryFolderURL: URL, + workingFolderURL: URL, executionContext: DownloadExecutionContext ) async throws -> PerformDownloadResult { let existingDownload = executionContext.existingDownload let versionSignature = executionContext.versionSignature - let folderRelativePath = executionContext.folderRelativePath let storedGalleryImageState = await fetchCachedGalleryImageState( gid: payload.gallery.gid ) let coverRelativePath = try await downloadAndPersistCoverIfNeeded( payload: payload, - temporaryFolderURL: temporaryFolderURL, + folderURL: workingFolderURL, existingCoverRelativePath: workingSeed.coverRelativePath, existingDownload: existingDownload ) let source = try await resolveSourceIfNeeded( payload: payload, pendingIndices: pendingIndices, - temporaryFolderURL: temporaryFolderURL, + folderURL: workingFolderURL, existingPages: workingSeed.existingPages, storedGalleryImageState: storedGalleryImageState ) let downloadContext = PageDownloadContext( payload: payload, source: source, - temporaryFolderURL: temporaryFolderURL, + temporaryFolderURL: workingFolderURL, storedGalleryImageState: storedGalleryImageState ) let batchResult = try await downloadPages( @@ -114,8 +113,7 @@ extension DownloadManager { try await finalizeBatchResult( context: finalizeCtx, payload: payload, - temporaryFolderURL: temporaryFolderURL, - folderRelativePath: folderRelativePath + folderURL: workingFolderURL ) return PerformDownloadResult( coverRelativePath: coverRelativePath, @@ -125,13 +123,13 @@ extension DownloadManager { private func downloadAndPersistCoverIfNeeded( payload: DownloadRequestPayload, - temporaryFolderURL: URL, + folderURL: URL, existingCoverRelativePath: String?, existingDownload: DownloadedGallery ) async throws -> String? { let coverRelativePath = try await downloadCoverImage( payload: payload, - temporaryFolderURL: temporaryFolderURL, + temporaryFolderURL: folderURL, existingCoverRelativePath: existingCoverRelativePath ) if coverRelativePath != existingDownload.coverRelativePath { @@ -148,8 +146,7 @@ extension DownloadManager { private func finalizeBatchResult( context: FinalizeContext, payload: DownloadRequestPayload, - temporaryFolderURL: URL, - folderRelativePath: String + folderURL: URL ) async throws { if payload.pageSelection != nil { try? storage.writeResumeState( @@ -159,7 +156,7 @@ extension DownloadManager { pageCount: payload.galleryDetail.pageCount, downloadOptions: payload.options ), - folderURL: temporaryFolderURL + folderURL: folderURL ) } if !context.batchResult.failedPages.isEmpty { @@ -169,8 +166,7 @@ extension DownloadManager { } try await finalizeDownload( payload: payload, - temporaryFolderURL: temporaryFolderURL, - folderRelativePath: folderRelativePath, + folderURL: folderURL, finalizeContext: context ) } @@ -178,14 +174,14 @@ extension DownloadManager { private func resolveSourceIfNeeded( payload: DownloadRequestPayload, pendingIndices: [Int], - temporaryFolderURL: URL, + folderURL: URL, existingPages: [Int: String], storedGalleryImageState: CachedGalleryImageState? ) async throws -> ResolvedSource? { let canSatisfyFromCache = await canSatisfyPendingPageDownloadsFromCache( pendingPageIndices: pendingIndices, - temporaryFolderURL: temporaryFolderURL, + temporaryFolderURL: folderURL, existingPageRelativePaths: existingPages, storedGalleryImageState: storedGalleryImageState ) @@ -200,8 +196,7 @@ extension DownloadManager { private func finalizeDownload( payload: DownloadRequestPayload, - temporaryFolderURL: URL, - folderRelativePath: String, + folderURL: URL, finalizeContext: FinalizeContext ) async throws { let versionSignature = finalizeContext.versionSignature @@ -216,18 +211,14 @@ extension DownloadManager { ) let hashedManifest = try storage.addingCurrentFileHashes( to: manifest, - folderURL: temporaryFolderURL + folderURL: folderURL ) try storage.writeManifest( hashedManifest, - folderURL: temporaryFolderURL + folderURL: folderURL ) try? storage.removeFailedPages( - folderURL: temporaryFolderURL - ) - try storage.replaceFolder( - relativePath: folderRelativePath, - with: temporaryFolderURL + folderURL: folderURL ) await cleanupCachedRemoteAssetsAfterSuccessfulDownload( payload: payload, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index ebaa67c8a..6e7db1eab 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -7,6 +7,16 @@ import Foundation // MARK: - Execution Support extension DownloadManager { + func folderRelativePath(for payload: DownloadRequestPayload) -> String { + storage.makeFolderRelativePath( + gid: payload.gallery.gid, + token: payload.gallery.token, + title: payload.galleryDetail.trimmedTitle.isEmpty + ? payload.gallery.title + : payload.galleryDetail.trimmedTitle + ) + } + func downloadCoverImage( payload: DownloadRequestPayload, temporaryFolderURL: URL, @@ -177,72 +187,102 @@ extension DownloadManager { func prepareWorkingSeed( payload: DownloadRequestPayload, existingDownload: DownloadedGallery, - temporaryFolderURL: URL, + folderURL: URL, versionSignature: String ) throws -> WorkingSeed { let resumeState = try? storage - .readResumeState(folderURL: temporaryFolderURL) - let shouldReuseTemporaryFolder = resumeState?.matches( - mode: payload.mode, - versionSignature: versionSignature, - pageCount: payload.galleryDetail.pageCount, - downloadOptions: payload.options - ) == true - && fileManager.operate { - $0.fileExists(atPath: temporaryFolderURL.path) - } - + .readResumeState(folderURL: folderURL) + let shouldReuseFolder = shouldReuseWorkingFolder( + payload: payload, + resumeState: resumeState, + folderURL: folderURL, + versionSignature: versionSignature + ) let seedContext = RepairSeedContext( existingDownload: existingDownload, payload: payload, versionSignature: versionSignature ) - try setupTemporaryFolder( - temporaryFolderURL: temporaryFolderURL, - shouldReuse: shouldReuseTemporaryFolder, + try setupWorkingFolder( + folderURL: folderURL, + shouldReuse: shouldReuseFolder, seedContext: seedContext ) let manifest = validatedManifest( - at: temporaryFolderURL, + at: folderURL, gid: payload.gallery.gid, pageCount: payload.galleryDetail.pageCount, versionSignature: versionSignature, downloadOptions: payload.options ) let existingPages = storage.existingPageRelativePaths( - folderURL: temporaryFolderURL, + folderURL: folderURL, expectedPageCount: payload.galleryDetail.pageCount ) let coverRelativePath = manifest?.coverRelativePath ?? storage.existingCoverRelativePath( - folderURL: temporaryFolderURL + folderURL: folderURL ) return .init( - folderURL: temporaryFolderURL, + folderURL: folderURL, manifest: manifest, existingPages: existingPages, coverRelativePath: coverRelativePath ) } + private func shouldReuseWorkingFolder( + payload: DownloadRequestPayload, + resumeState: DownloadResumeState?, + folderURL: URL, + versionSignature: String + ) -> Bool { + guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { + return false + } + if resumeState?.matches( + mode: payload.mode, + versionSignature: versionSignature, + pageCount: payload.galleryDetail.pageCount, + downloadOptions: payload.options + ) == true { + return true + } + switch payload.mode { + case .initial: + guard let manifest = try? storage.readManifest(folderURL: folderURL) else { + return true + } + return manifest.gid == payload.gallery.gid + && manifest.token == payload.gallery.token + && manifest.pageCount == payload.galleryDetail.pageCount + && manifest.versionSignature == versionSignature + && manifest.downloadOptions == payload.options + case .repair: + return true + case .redownload, .update: + return false + } + } + private struct RepairSeedContext { let existingDownload: DownloadedGallery let payload: DownloadRequestPayload let versionSignature: String } - private func setupTemporaryFolder( - temporaryFolderURL: URL, + private func setupWorkingFolder( + folderURL: URL, shouldReuse: Bool, seedContext: RepairSeedContext ) throws { if !shouldReuse { try? fileManager.operate { - try $0.removeItem(at: temporaryFolderURL) + try $0.removeItem(at: folderURL) } } - if !fileManager.operate({ $0.fileExists(atPath: temporaryFolderURL.path) }) { + if !fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) { if let seed = repairSeed( for: seedContext.existingDownload, payload: seedContext.payload, @@ -251,18 +291,12 @@ extension DownloadManager { try storage.materializeRepairSeed( from: seed.folderURL, manifest: seed.manifest, - to: temporaryFolderURL + to: folderURL ) } else { - try createDirectory(at: temporaryFolderURL) + try createDirectory(at: folderURL) } } - let pagesFolderURL = temporaryFolderURL - .appendingPathComponent( - Defaults.FilePath.downloadPages, - isDirectory: true - ) - try createDirectory(at: pagesFolderURL) } func resolvedImageSource( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index bdaa89744..3d682ffed 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -123,7 +123,6 @@ actor DownloadManager { struct DownloadExecutionContext: Sendable { let existingDownload: DownloadedGallery let versionSignature: String - let folderRelativePath: String } struct FinalizeContext: Sendable { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index 8dda90e76..530ff6d45 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -172,6 +172,7 @@ extension DownloadManager { let manifestPages = Dictionary( uniqueKeysWithValues: (existingManifest?.pages ?? []) + .filter { !$0.relativePath.hasSuffix(".pending") } .map { ($0.index, $0.relativePath) } ) return manifestPages.merging( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 4d089ac5b..8a21d9ccf 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -153,13 +153,7 @@ extension DownloadManager { previewURLs: payload.previewURLs, versionMetadata: payload.versionMetadata ) - let folderRelativePath = storage.makeFolderRelativePath( - gid: payload.gallery.gid, - token: payload.gallery.token, - title: payload.galleryDetail.trimmedTitle.isEmpty - ? payload.gallery.title - : payload.galleryDetail.trimmedTitle - ) + let folderRelativePath = folderRelativePath(for: payload) try writeInitialManifest( payload: payload, folderRelativePath: folderRelativePath, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 3af992f58..3690d67a8 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -105,15 +105,16 @@ extension DownloadManager { existingDownload: DownloadedGallery, versionSignature: String ) throws -> PrepareWorkingSeedResult { - let temporaryFolderURL = storage - .temporaryFolderURL(gid: payload.gallery.gid) + let folderURL = storage.folderURL( + relativePath: folderRelativePath(for: payload) + ) try? fileManager.operate { - try $0.removeItem(at: temporaryFolderURL) + try $0.removeItem(at: folderURL) } let workingSeed = try prepareWorkingSeed( payload: payload, existingDownload: existingDownload, - temporaryFolderURL: temporaryFolderURL, + folderURL: folderURL, versionSignature: versionSignature ) return PrepareWorkingSeedResult( diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 74bddd817..3d9bf3591 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -165,10 +165,10 @@ struct DownloadFileStorage: Sendable { } for pageURL in finalPageURLs { - guard sanitizeAssetFileIfNeeded(at: pageURL), - let index = finalPageIndex(from: pageURL), + guard let index = finalPageIndex(from: pageURL), index >= 1, - index <= expectedPageCount + index <= expectedPageCount, + sanitizeAssetFileIfNeeded(at: pageURL) else { continue } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index c6923025b..bc341d5ab 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -199,6 +199,28 @@ struct DownloadFileStorageTests { ) } + @Test + func testExistingPageRelativePathsPreservesLegacyPagesFolderWhenScanningFinalAssets() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[123_token] Sample") + let pagesFolderURL = folderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, + isDirectory: true + ) + try FileManager.default.createDirectory(at: pagesFolderURL, withIntermediateDirectories: true) + try Data([0x01]).write(to: pagesFolderURL.appendingPathComponent("0001.jpg"), options: .atomic) + + #expect( + storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 1) == [ + 1: "pages/0001.jpg" + ] + ) + #expect(FileManager.default.fileExists(atPath: pagesFolderURL.path)) + } + @Test func testExistingPageRelativePathsRemovesZeroByteFiles() throws { let (storage, rootURL) = makeStorage() diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 76e83520c..173576430 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -103,7 +103,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { let beforeProcess = await manager.testingFetchDownload(gid: gid) #expect(beforeProcess?.hasUpdate ?? true == false) - let temporaryFolderURL = try prepareStaleTemporaryFolder( + let staleFolderURL = try prepareStaleExistingFolder( storage: storage, gid: gid, pageIndex: pageIndex, oldPageCount: oldPageCount, oldVersionSignature: oldVersionSignature ) @@ -116,7 +116,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { gid: gid, updatedPageCount: updatedPageCount, updatedVersionSignature: updatedVersionSignature, - temporaryFolderURL: temporaryFolderURL + staleFolderURL: staleFolderURL ) ) } @@ -165,7 +165,7 @@ private struct ProcessVerificationContext { let gid: String let updatedPageCount: Int let updatedVersionSignature: String - let temporaryFolderURL: URL + let staleFolderURL: URL } private extension DownloadProcessTests { @@ -207,7 +207,7 @@ private extension DownloadProcessTests { return (updatedPageCount, updatedVersionSignature) } - func prepareStaleTemporaryFolder( + func prepareStaleExistingFolder( storage: DownloadFileStorage, gid: String, pageIndex: Int, oldPageCount: Int, oldVersionSignature: String ) throws -> URL { @@ -215,19 +215,36 @@ private extension DownloadProcessTests { gid: gid, title: "Pause Race", pageCount: oldPageCount, versionSignature: oldVersionSignature ) - try writeTemporaryManifestAndPages( - storage: storage, gid: gid, manifest: staleManifest, - pageCount: 0, versionSignature: oldVersionSignature, - pageSelection: [pageIndex] + let folderURL = storage.folderURL(relativePath: "\(gid) - Pause Race") + try? FileManager.default.removeItem(at: folderURL) + try FileManager.default.createDirectory( + at: folderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, isDirectory: true + ), + withIntermediateDirectories: true + ) + try storage.writeManifest(staleManifest, folderURL: folderURL) + try Data([0x00]).write( + to: folderURL.appendingPathComponent("cover.jpg"), + options: .atomic ) - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) try Data([UInt8(pageIndex % 255)]).write( - to: temporaryFolderURL.appendingPathComponent( + to: folderURL.appendingPathComponent( "pages/\(String(format: "%04d", pageIndex)).jpg" ), options: .atomic ) - return temporaryFolderURL + try storage.writeResumeState( + .init( + mode: .redownload, + versionSignature: oldVersionSignature, + pageCount: oldPageCount, + downloadOptions: .init(), + pageSelection: [pageIndex] + ), + folderURL: folderURL + ) + return folderURL } func verifyCompletedProcess( @@ -250,15 +267,25 @@ private extension DownloadProcessTests { #expect(manifest.pages.count == context.updatedPageCount) #expect( FileManager.default.fileExists( - atPath: completedFolderURL.appendingPathComponent("pages/0001.jpg").path + atPath: completedFolderURL.appendingPathComponent("\(context.gid)_token_1.jpg").path ) ) + #expect( + FileManager.default.fileExists( + atPath: completedFolderURL.appendingPathComponent("pages/0001.jpg").path + ) == false + ) let resumeState = try storage.readResumeState(folderURL: completedFolderURL) #expect(resumeState.mode == .redownload) #expect(resumeState.versionSignature == context.updatedVersionSignature) #expect(resumeState.pageCount == context.updatedPageCount) #expect(resumeState.pageSelection == nil) - #expect(FileManager.default.fileExists(atPath: context.temporaryFolderURL.path) == false) + #expect(FileManager.default.fileExists(atPath: context.staleFolderURL.path) == false) + #expect( + FileManager.default.fileExists( + atPath: storage.temporaryFolderURL(gid: context.gid).path + ) == false + ) } } From 96a6b38ea7df39bc09cd0cfe813164d45ac51367 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 00:59:48 +0800 Subject: [PATCH 079/614] Add download index --- .../Clients/DownloadClient+Manager.swift | 3 + .../Clients/DownloadClient+Persistence.swift | 101 ++++++++ .../Clients/DownloadClient+Testing.swift | 18 ++ .../DownloadManagerStorageTests.swift | 220 ++++++++++++++++++ 4 files changed, 342 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 3d682ffed..b40b36548 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -138,6 +138,9 @@ actor DownloadManager { let libraryClient: LibraryClient let queueStore: DownloadQueueStore let persistenceContainer: NSPersistentContainer + var downloadIndex = [String: DownloadFolderRecord]() + var downloadErrors = [String: DownloadFailure]() + var updatedGalleryIDs = Set() var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() var lastObservedDownloads = [DownloadedGallery]() var activeGalleryID: String? diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 7e68f18ab..060f06a3e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -6,6 +6,107 @@ import CoreData import Foundation +// MARK: - Disk Index +extension DownloadManager { + @discardableResult + func reloadDownloadIndex() async -> [DownloadedGallery] { + do { + let records = try storage.scanDownloadFolders() + downloadIndex = deduplicatedDownloadIndex(from: records) + return downloads(from: records) + } catch { + Logger.error(error) + downloadIndex = [:] + return [] + } + } + + func indexedDownload(gid: String) -> DownloadedGallery? { + guard let record = downloadIndex[gid] else { return nil } + return downloadedGallery(from: record) + } + + func indexedDownloads() -> [DownloadedGallery] { + downloads(from: Array(downloadIndex.values)) + } + + private func downloads( + from records: [DownloadFolderRecord] + ) -> [DownloadedGallery] { + deduplicatedDownloadIndex(from: records).values + .map { downloadedGallery(from: $0) } + .sorted(by: sortDownloadsByDisplayStatus) + } + + private func deduplicatedDownloadIndex( + from records: [DownloadFolderRecord] + ) -> [String: DownloadFolderRecord] { + records.reduce(into: [:]) { index, record in + let gid = record.manifest.gid + guard let currentRecord = index[gid] else { + index[gid] = record + return + } + if record.displayDate > currentRecord.displayDate { + index[gid] = record + } + } + } + + private func downloadedGallery( + from record: DownloadFolderRecord + ) -> DownloadedGallery { + let gid = record.manifest.gid + return DownloadedGallery( + manifest: record.manifest, + folderRelativePath: record.relativePath, + modifiedAt: record.modifiedAt, + displayStatus: displayStatus(for: record), + lastError: downloadErrors[gid] + ) + } + + private func displayStatus( + for record: DownloadFolderRecord + ) -> DownloadDisplayStatus { + let gid = record.manifest.gid + if record.manifest.isComplete, + updatedGalleryIDs.contains(gid) { + return .updateAvailable + } + if record.manifest.isComplete { + return .completed + } + if activeGalleryID == gid { + return .active + } + if queueStore.contains(gid) { + return .queued + } + if downloadErrors[gid] != nil { + return .error + } + return .inactive + } + + private func sortDownloadsByDisplayStatus( + _ lhs: DownloadedGallery, + _ rhs: DownloadedGallery + ) -> Bool { + if lhs.displayStatus != rhs.displayStatus { + return lhs.displayStatus.rawValue < rhs.displayStatus.rawValue + } + return (lhs.lastDownloadedAt ?? .distantPast) + > (rhs.lastDownloadedAt ?? .distantPast) + } +} + +private extension DownloadFolderRecord { + var displayDate: Date { + modifiedAt ?? manifest.downloadedAt + } +} + // MARK: - Core Data Operations extension DownloadManager { func fetchDownload( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 3690d67a8..eca31f021 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -35,6 +35,24 @@ extension DownloadManager { testingScheduledGalleryIDHistory } + func testingSetQueuedGalleryIDs(_ gids: [String]) async { + await queueStore.removeAll() + for gid in gids { + await queueStore.enqueue(gid) + } + } + + func testingSetDownloadError( + _ failure: DownloadFailure?, + gid: String + ) { + downloadErrors[gid] = failure + } + + func testingSetUpdatedGalleryIDs(_ gids: Set) { + updatedGalleryIDs = gids + } + func testingHasActiveTask() -> Bool { activeTask != nil } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 0514a4f95..cc28e848b 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -12,6 +12,166 @@ import Testing @Suite(.serialized) struct DownloadManagerStorageTests: DownloadFeatureTestCase { + @Test + func testDownloadManagerReloadDownloadIndexScansManifestFolders() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + persistenceContainer: container + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "[100_token] Complete", + manifest: indexedManifest( + gid: "100", + title: "Complete", + pageHashes: ["sha256:1", "sha256:2"], + downloadedAt: Date(timeIntervalSince1970: 100) + ) + ) + try writeIndexedManifest( + storage: storage, + relativePath: "[200_token] Queued", + manifest: indexedManifest( + gid: "200", + title: "Queued", + pageHashes: ["sha256:1", ""], + downloadedAt: Date(timeIntervalSince1970: 200) + ) + ) + try FileManager.default.createDirectory( + at: rootURL.appendingPathComponent("No Manifest", isDirectory: true), + withIntermediateDirectories: true + ) + await manager.testingSetQueuedGalleryIDs(["200"]) + + let downloads = await manager.reloadDownloadIndex() + + #expect(downloads.map(\.gid) == ["200", "100"]) + let queuedDownload = try #require(downloads.first { $0.gid == "200" }) + let completedDownload = try #require(downloads.first { $0.gid == "100" }) + #expect(queuedDownload.displayStatus == .queued) + #expect(queuedDownload.completedPageCount == 1) + #expect(completedDownload.displayStatus == .completed) + #expect(completedDownload.completedPageCount == 2) + #expect((await manager.indexedDownload(gid: "100")) == completedDownload) + #expect(await manager.indexedDownload(gid: "missing") == nil) + } + + @Test + func testDownloadManagerReloadDownloadIndexKeepsNewestDuplicateFolder() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + persistenceContainer: container + ) + + let olderDate = Date(timeIntervalSince1970: 100) + let newerDate = Date(timeIntervalSince1970: 200) + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "[500_token] Old", + manifest: indexedManifest( + gid: "500", + title: "Old", + pageHashes: ["sha256:old"], + downloadedAt: olderDate + ) + ) + try setFolderModificationDate( + olderDate, + storage: storage, + relativePath: "[500_token] Old" + ) + try writeIndexedManifest( + storage: storage, + relativePath: "[500_token] New", + manifest: indexedManifest( + gid: "500", + title: "New", + pageHashes: ["sha256:new"], + downloadedAt: newerDate + ) + ) + try setFolderModificationDate( + newerDate, + storage: storage, + relativePath: "[500_token] New" + ) + + let downloads = await manager.reloadDownloadIndex() + + #expect(downloads.map(\.gid) == ["500"]) + let download = try #require(downloads.first) + #expect(download.title == "New") + #expect(download.folderRelativePath == "[500_token] New") + #expect(download.lastDownloadedAt == newerDate) + #expect((await manager.indexedDownload(gid: "500")) == download) + } + + @Test + func testDownloadManagerIndexAppliesSessionOnlyFlags() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + persistenceContainer: container + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "[300_token] Updated", + manifest: indexedManifest( + gid: "300", + title: "Updated", + pageHashes: ["sha256:1"] + ) + ) + try writeIndexedManifest( + storage: storage, + relativePath: "[400_token] Failed", + manifest: indexedManifest( + gid: "400", + title: "Failed", + pageHashes: [""] + ) + ) + await manager.testingSetUpdatedGalleryIDs(["300"]) + await manager.testingSetDownloadError( + .init(code: .networkingFailed, message: "Network Error"), + gid: "400" + ) + + let downloads = await manager.reloadDownloadIndex() + + let updatedDownload = try #require(downloads.first { $0.gid == "300" }) + let failedDownload = try #require(downloads.first { $0.gid == "400" }) + #expect(updatedDownload.displayStatus == .updateAvailable) + #expect(failedDownload.displayStatus == .error) + #expect(failedDownload.lastError?.code == .networkingFailed) + } + @Test func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { let container = try makeInMemoryContainer() @@ -190,3 +350,63 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } } + +private extension DownloadManagerStorageTests { + func writeIndexedManifest( + storage: DownloadFileStorage, + relativePath: String, + manifest: DownloadManifest + ) throws { + let folderURL = storage.folderURL(relativePath: relativePath) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest(manifest, folderURL: folderURL) + } + + func indexedManifest( + gid: String, + title: String, + pageHashes: [String], + downloadedAt: Date = .now + ) throws -> DownloadManifest { + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: downloadedAt, + pageCount: pageHashes.count, + coverRelativePath: nil, + galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), + rating: 4, + downloadOptions: DownloadOptionsSnapshot(), + versionSignature: "hash:v1", + downloadedAt: downloadedAt, + pages: pageHashes.enumerated().map { offset, hash in + DownloadManifest.Page( + index: offset + 1, + relativePath: "\(gid)_token_\(offset + 1).jpg", + fileHash: hash + ) + } + ) + } + + func setFolderModificationDate( + _ date: Date, + storage: DownloadFileStorage, + relativePath: String + ) throws { + try FileManager.default.setAttributes( + [.modificationDate: date], + ofItemAtPath: storage.folderURL(relativePath: relativePath).path + ) + } +} From b14b31eb0cbacbccbcb210bca360b14a3495a260 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 01:09:27 +0800 Subject: [PATCH 080/614] Use index reads --- .../Clients/DownloadClient+Persistence.swift | 45 ++++++-- .../Clients/DownloadClient+PublicAPI.swift | 2 +- .../DownloadManagerStorageTests.swift | 109 +++++++++++++++++- 3 files changed, 145 insertions(+), 11 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 060f06a3e..a56c17f4c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -111,6 +111,42 @@ private extension DownloadFolderRecord { extension DownloadManager { func fetchDownload( gid: String + ) async -> DownloadedGallery? { + _ = await reloadDownloadIndex() + if let indexedDownload = indexedDownload(gid: gid) { + return indexedDownload + } + return await fetchDownloadFromCoreData(gid: gid) + } + + func fetchDownloadsFromStore() async -> [DownloadedGallery] { +#if DEBUG + if let testingFetchDownloadsFromStoreHook { + await testingFetchDownloadsFromStoreHook() + } +#endif + let downloads = await reloadDownloadIndex() + guard downloads.isEmpty else { return downloads } + return sortDownloads(await fetchDownloadsFromCoreData()) + } + + func fetchDownloadsFromStore( + gids: [String] + ) async -> [DownloadedGallery] { + let gidSet = Set(gids) + let indexedDownloads = await reloadDownloadIndex() + .filter { gidSet.contains($0.gid) } + let indexedGIDs = Set(indexedDownloads.map(\.gid)) + let missingGIDs = gids.filter { !indexedGIDs.contains($0) } + guard !missingGIDs.isEmpty else { return indexedDownloads } + let persistedDownloads = await fetchDownloadsFromCoreData( + gids: missingGIDs + ) + return sortDownloads(indexedDownloads + persistedDownloads) + } + + private func fetchDownloadFromCoreData( + gid: String ) async -> DownloadedGallery? { await MainActor.run { let context = persistenceContainer.viewContext @@ -126,12 +162,7 @@ extension DownloadManager { } } - func fetchDownloadsFromStore() async -> [DownloadedGallery] { -#if DEBUG - if let testingFetchDownloadsFromStoreHook { - await testingFetchDownloadsFromStoreHook() - } -#endif + private func fetchDownloadsFromCoreData() async -> [DownloadedGallery] { return await MainActor.run { let context = persistenceContainer.viewContext let request = NSFetchRequest( @@ -149,7 +180,7 @@ extension DownloadManager { } } - func fetchDownloadsFromStore( + private func fetchDownloadsFromCoreData( gids: [String] ) async -> [DownloadedGallery] { await MainActor.run { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 8a21d9ccf..20024b18c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -24,7 +24,7 @@ extension DownloadManager { } func fetchDownloads() async -> [DownloadedGallery] { - sortDownloads(await fetchDownloadsFromStore()) + await fetchDownloadsFromStore() } func reconcileDownloads() async { diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index cc28e848b..622e0b5f4 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -124,6 +124,105 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect((await manager.indexedDownload(gid: "500")) == download) } + @Test + func testDownloadManagerFetchesDownloadsFromManifestIndex() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + persistenceContainer: container + ) + + try insertPersistedDownload( + in: container, + gid: "600", + status: .failed, + completedPageCount: 0, + pageCount: 1 + ) + try insertPersistedDownload( + in: container, + gid: "601", + status: .completed, + completedPageCount: 1, + pageCount: 1 + ) + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "[600_token] Disk", + manifest: indexedManifest( + gid: "600", + title: "Disk", + pageHashes: [""] + ) + ) + await manager.testingSetQueuedGalleryIDs(["600"]) + + let downloads = await manager.fetchDownloads() + let indexedDownload = try #require(await manager.fetchDownload(gid: "600")) + let fallbackDownload = try #require(await manager.fetchDownload(gid: "601")) + let badges = await manager.badges(for: ["600", "601"]) + + #expect(downloads.map(\.gid) == ["600"]) + #expect(indexedDownload.title == "Disk") + #expect(indexedDownload.displayStatus == .queued) + #expect(indexedDownload.status == .queued) + #expect(fallbackDownload.gid == "601") + #expect(fallbackDownload.status == .completed) + #expect(badges["600"] == .queued) + #expect(badges["601"] == .downloaded) + } + + @Test + func testDownloadManagerObserverInitialSnapshotUsesManifestIndex() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + persistenceContainer: container + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "[700_token] Observed", + manifest: indexedManifest( + gid: "700", + title: "Observed", + pageHashes: ["sha256:1"] + ) + ) + + let stream = await manager.observeDownloads() + let initialSnapshotTask = Task<[DownloadedGallery]?, Never> { + var iterator = stream.makeAsyncIterator() + return await iterator.next() + } + + let snapshot = try await waitForTaskValue( + initialSnapshotTask, + timeout: .seconds(1), + description: "initial download observer snapshot" + ) + let downloads = try #require(snapshot) + let download = try #require(downloads.first) + + #expect(downloads.map(\.gid) == ["700"]) + #expect(download.title == "Observed") + #expect(download.displayStatus == .completed) + } + @Test func testDownloadManagerIndexAppliesSessionOnlyFlags() async throws { let container = try makeInMemoryContainer() @@ -257,7 +356,11 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - let manifest = try sampleManifest(gid: gid, title: "Pause Race") + let manifest = try indexedManifest( + gid: gid, + title: "Pause Race", + pageHashes: ["sha256:1", "sha256:2"] + ) try JSONEncoder().encode(manifest).write( to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), options: .atomic @@ -266,10 +369,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { to: completedFolderURL.appendingPathComponent("cover.jpg"), options: .atomic ) - let completedPageURL = completedFolderURL.appendingPathComponent("pages/0001.jpg") + let completedPageURL = completedFolderURL.appendingPathComponent("\(gid)_token_1.jpg") try Data([0x01]).write(to: completedPageURL, options: .atomic) try Data([0x02]).write( - to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), + to: completedFolderURL.appendingPathComponent("\(gid)_token_2.jpg"), options: .atomic ) From 5d6e3518c84e5eb2a9fba3083e36ac206642ec9c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 01:14:42 +0800 Subject: [PATCH 081/614] Stop enqueue MO --- .../Clients/DownloadClient+PublicAPI.swift | 23 ------------------- .../DownloadEnqueueManifestTests.swift | 9 +++++++- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 20024b18c..515480ab4 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -159,29 +159,6 @@ extension DownloadManager { folderRelativePath: folderRelativePath, versionSignature: versionSignature ) - try await updateDownloadRecord(gid: payload.gallery.gid) { record in - record.gid = payload.gallery.gid - record.host = payload.host.rawValue - record.token = payload.gallery.token - record.title = payload.gallery.title - record.jpnTitle = payload.galleryDetail.jpnTitle - record.uploader = payload.galleryDetail.uploader - record.category = payload.gallery.category.rawValue - record.tags = payload.gallery.tags.toData() - record.pageCount = Int64(payload.galleryDetail.pageCount) - record.postedDate = payload.galleryDetail.postedDate - record.rating = payload.galleryDetail.rating - record.onlineCoverURL = - payload.galleryDetail.coverURL ?? payload.gallery.coverURL - record.folderRelativePath = folderRelativePath - record.downloadOptionsSnapshot = payload.options.toData() - record.completedPageCount = 0 - record.lastDownloadedAt = .now - record.lastError = nil - record.latestRemoteVersionSignature = versionSignature - record.pendingOperation = nil - record.status = DownloadStatus.queued.rawValue - } await queueStore.enqueue(payload.gallery.gid) await notifyObservers() await scheduleNextIfNeeded() diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index e83cce5bb..3c79a9060 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -3,6 +3,7 @@ // EhPandaTests // +import CoreData import Foundation import Testing @testable import EhPanda @@ -16,11 +17,12 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let container = try makeInMemoryContainer() let manager = DownloadManager( storage: storage, urlSession: .shared, queueStore: queueStore, - persistenceContainer: try makeInMemoryContainer() + persistenceContainer: container ) await manager.testingInstallActiveTask(gid: "busy", task: Task {}) @@ -59,5 +61,10 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { #expect(manifest.pages.count == detail.pageCount) #expect(manifest.pages.first?.relativePath == "\(gallery.gid)_\(gallery.token)_1.pending") #expect(manifest.downloadOptions.threadLimit == 3) + + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + #expect(try container.viewContext.count(for: request) == 0) } } From 43a25cff25d6084d400a2146f62673582a1aa452 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 01:18:32 +0800 Subject: [PATCH 082/614] Settle queue --- .../Clients/DownloadClient+Execution.swift | 3 + .../Clients/DownloadClient+Persistence.swift | 2 + .../Clients/DownloadClient+PublicAPI.swift | 1 + .../DownloadManagerStorageTests.swift | 119 ++++++++++++++++++ 4 files changed, 125 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index f0b684cde..e643ae3de 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -224,6 +224,9 @@ extension DownloadManager { coverRelativePath: String?, versionSignature: String ) async throws { + downloadErrors[gid] = nil + updatedGalleryIDs.remove(gid) + await queueStore.remove(gid) try await updateDownloadRecord( gid: gid, createIfMissing: false diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index a56c17f4c..c6dea761b 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -295,6 +295,8 @@ extension DownloadManager { await testingPersistFailureHook() } #endif + downloadErrors[context.gid] = DownloadFailure(error: error) + await queueStore.remove(context.gid) let workingCompletedPageCount = temporaryCompletedPageCount( gid: context.gid, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 515480ab4..c6bce770d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -254,6 +254,7 @@ extension DownloadManager { taskToCancel = nil } await taskToCancel?.value + await queueStore.remove(gid) guard let download = await fetchDownload(gid: gid) else { return .failure(.notFound) } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 622e0b5f4..62fcb4112 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -271,6 +271,125 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(failedDownload.lastError?.code == .networkingFailed) } + @Test + func testDownloadManagerFailureSettlesQueueIntent() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + queueStore: queueStore, + persistenceContainer: container + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "[800_token] Failing", + manifest: indexedManifest( + gid: "800", + title: "Failing", + pageHashes: [""] + ) + ) + await queueStore.enqueue("800") + let download = try #require(await manager.fetchDownload(gid: "800")) + + await manager.persistFailure( + error: .networkingFailed, + context: .init( + gid: "800", + originalDownload: download, + mode: .initial, + hadReadableFiles: false, + latestSignature: nil + ) + ) + + let failedDownload = try #require(await manager.fetchDownload(gid: "800")) + let badges = await manager.badges(for: ["800"]) + + #expect(queueStore.gids == []) + #expect(failedDownload.displayStatus == .error) + #expect(failedDownload.status == .failed) + #expect(failedDownload.lastError?.code == .networkingFailed) + #expect(badges["800"] == .failed) + } + + @Test + func testDownloadManagerCompletionSettlesQueueIntent() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + queueStore: queueStore, + persistenceContainer: container + ) + + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: "Complete") + let folderRelativePath = storage.makeFolderRelativePath( + gid: gallery.gid, + token: gallery.token, + title: detail.trimmedTitle + ) + let payload = DownloadRequestPayload( + gallery: gallery, + galleryDetail: detail, + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + options: .init(), + mode: .initial + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: folderRelativePath, + manifest: indexedManifest( + gid: gallery.gid, + title: "Complete", + pageHashes: Array( + repeating: "sha256:done", + count: detail.pageCount + ) + ) + ) + await queueStore.enqueue(gallery.gid) + await manager.testingSetDownloadError( + .init(code: .networkingFailed, message: "failed"), + gid: gallery.gid + ) + + try await manager.persistCompletedDownload( + gid: gallery.gid, + payload: payload, + folderRelativePath: folderRelativePath, + coverRelativePath: nil, + versionSignature: "hash:v1" + ) + + let completedDownload = try #require( + await manager.fetchDownload(gid: gallery.gid) + ) + + #expect(queueStore.gids == []) + #expect(completedDownload.displayStatus == .completed) + #expect(completedDownload.lastError == nil) + } + @Test func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { let container = try makeInMemoryContainer() From 88754342f1afa64a13fe1e799afd4b0aa6bcfe8e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 01:22:20 +0800 Subject: [PATCH 083/614] Queue pause --- .../Clients/DownloadClient+Scheduling.swift | 6 ++ .../DownloadManagerStorageTests.swift | 64 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 6dd62eb43..2f7f9b17f 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -152,6 +152,8 @@ extension DownloadManager { gid: String, download: DownloadedGallery ) async throws -> Task? { + downloadErrors[gid] = nil + await queueStore.remove(gid) let initialCount = max( download.completedPageCount, temporaryCompletedPageCount( @@ -183,6 +185,8 @@ extension DownloadManager { gid: String, download: DownloadedGallery ) async throws { + downloadErrors[gid] = nil + await queueStore.remove(gid) let settledCount = max( download.completedPageCount, temporaryCompletedPageCount( @@ -242,6 +246,8 @@ extension DownloadManager { } do { + downloadErrors[gid] = nil + await queueStore.enqueue(gid) let resumedStatus: DownloadStatus = activeTask == nil ? .downloading : .queued try await updateDownloadRecord( diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 62fcb4112..c7922ea20 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -390,6 +390,70 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(completedDownload.lastError == nil) } + @Test + func testDownloadManagerPauseAndResumeMutateQueueIntent() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + queueStore: queueStore, + persistenceContainer: container + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "[820_token] Pausable", + manifest: indexedManifest( + gid: "820", + title: "Pausable", + pageHashes: ["sha256:1", ""] + ) + ) + await queueStore.enqueue("820") + await manager.testingSetDownloadError( + .init(code: .networkingFailed, message: "failed"), + gid: "820" + ) + let activeTask = Task { + do { + try await Task.sleep(for: .seconds(60)) + } catch {} + } + await manager.testingInstallActiveTask(gid: "820", task: activeTask) + + let pauseResult = await manager.pause(gid: "820") + + guard case .success = pauseResult else { + Issue.record("Pause should succeed, got \(pauseResult).") + return + } + let pausedDownload = try #require(await manager.fetchDownload(gid: "820")) + #expect(queueStore.gids == []) + #expect(await manager.testingActiveGalleryID() == nil) + #expect(pausedDownload.displayStatus == .inactive) + #expect(pausedDownload.status == .paused) + #expect(pausedDownload.lastError == nil) + + await manager.testingInstallActiveTask(gid: "busy", task: Task {}) + let resumeResult = await manager.resume(gid: "820") + + guard case .success = resumeResult else { + Issue.record("Resume should succeed, got \(resumeResult).") + return + } + let resumedDownload = try #require(await manager.fetchDownload(gid: "820")) + #expect(queueStore.gids == ["820"]) + #expect(resumedDownload.displayStatus == .queued) + #expect(resumedDownload.status == .queued) + } + @Test func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { let container = try makeInMemoryContainer() From d0fbabdb528dfff582ad57dc9edac20351066e35 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 01:33:46 +0800 Subject: [PATCH 084/614] Queue scheduling --- .../Clients/DownloadClient+Manager.swift | 1 + .../Clients/DownloadClient+Scheduling.swift | 72 +++++++++++++++---- .../Clients/DownloadClient+Testing.swift | 6 ++ .../DownloadManagerStorageTests.swift | 57 +++++++++++++++ .../Download/DownloadSchedulingTests.swift | 9 ++- 5 files changed, 128 insertions(+), 17 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index b40b36548..94a13ae9a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -149,6 +149,7 @@ actor DownloadManager { #if DEBUG var testingFetchDownloadsFromStoreHook: (@Sendable () async -> Void)? var testingPersistFailureHook: (@Sendable () async -> Void)? + var testingScheduledProcessHook: (@Sendable (String) async -> Void)? var testingScheduledGalleryIDHistory = [String]() #endif diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 2f7f9b17f..c47681d08 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -29,16 +29,63 @@ extension DownloadManager { } func scheduleNextIfNeeded() async { - let downloads = await fetchDownloadsFromStore() + let queuedGIDs = queueStore.gids + let downloads = queuedGIDs.isEmpty + ? await fetchDownloadsFromStore() + : await fetchDownloadsFromStore(gids: queuedGIDs) guard activeTask == nil else { await reconcileActiveDownloadState() return } - let nextDownload = downloads - .filter { - !schedulingBlockedGalleryIDs.contains($0.gid) - && shouldSchedule(download: $0) + let nextDownload = queuedGIDs.isEmpty + ? nextLegacyScheduledDownload(from: downloads) + : nextQueuedDownload( + orderedGIDs: queuedGIDs, + downloads: downloads + ) + guard let nextDownload else { return } + +#if DEBUG + testingScheduledGalleryIDHistory.append(nextDownload.gid) +#endif + activeGalleryID = nextDownload.gid + activeTask = Task { [weak self] in + guard let self else { return } + await self.processScheduledDownload(gid: nextDownload.gid) + } + } + + private func processScheduledDownload(gid: String) async { +#if DEBUG + if let testingScheduledProcessHook { + defer { + activeTask = nil + activeGalleryID = nil } + await testingScheduledProcessHook(gid) + return + } +#endif + await processDownload(gid: gid) + } + + private func nextQueuedDownload( + orderedGIDs: [String], + downloads: [DownloadedGallery] + ) -> DownloadedGallery? { + let downloadsByGID = Dictionary( + uniqueKeysWithValues: downloads.map { ($0.gid, $0) } + ) + return orderedGIDs + .compactMap { downloadsByGID[$0] } + .first { isSchedulableDownload($0) } + } + + private func nextLegacyScheduledDownload( + from downloads: [DownloadedGallery] + ) -> DownloadedGallery? { + downloads + .filter(isSchedulableDownload) .sorted { lhs, rhs in let lhsIsDownloading = lhs.status == .downloading let rhsIsDownloading = rhs.status == .downloading @@ -49,16 +96,13 @@ extension DownloadManager { < (rhs.lastDownloadedAt ?? .distantPast) } .first - guard let nextDownload else { return } + } -#if DEBUG - testingScheduledGalleryIDHistory.append(nextDownload.gid) -#endif - activeGalleryID = nextDownload.gid - activeTask = Task { [weak self] in - guard let self else { return } - await self.processDownload(gid: nextDownload.gid) - } + private func isSchedulableDownload( + _ download: DownloadedGallery + ) -> Bool { + !schedulingBlockedGalleryIDs.contains(download.gid) + && shouldSchedule(download: download) } func shouldSchedule(download: DownloadedGallery) -> Bool { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index eca31f021..5c4357448 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -31,6 +31,12 @@ extension DownloadManager { testingPersistFailureHook = hook } + func testingSetScheduledProcessHook( + _ hook: (@Sendable (String) async -> Void)? + ) { + testingScheduledProcessHook = hook + } + func testingScheduledGalleryIDs() -> [String] { testingScheduledGalleryIDHistory } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index c7922ea20..7fb48ecfd 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -454,6 +454,63 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(resumedDownload.status == .queued) } + @Test + func testDownloadManagerSchedulesManifestQueueOrder() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + queueStore: queueStore, + persistenceContainer: container + ) + await manager.testingSetScheduledProcessHook { _ in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + } + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "[830_token] First", + manifest: indexedManifest( + gid: "830", + title: "First", + pageHashes: [""], + downloadedAt: Date(timeIntervalSince1970: 100) + ) + ) + try writeIndexedManifest( + storage: storage, + relativePath: "[831_token] Newer", + manifest: indexedManifest( + gid: "831", + title: "Newer", + pageHashes: [""], + downloadedAt: Date(timeIntervalSince1970: 200) + ) + ) + await queueStore.enqueue("830") + await queueStore.enqueue("831") + + await manager.testingScheduleNextIfNeeded() + + let scheduledGalleryIDs = await manager.testingScheduledGalleryIDs() + #expect(scheduledGalleryIDs == ["830"]) + #expect(await manager.testingActiveGalleryID() == "830") + + guard case .success = await manager.pause(gid: "830") else { + Issue.record("Pause should cancel the active queued test download.") + return + } + } + @Test func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { let container = try makeInMemoryContainer() diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index 7670cd89b..16b37d934 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -17,16 +17,19 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [HangingURLProtocol.self] let manager = DownloadManager( storage: DownloadFileStorage( rootURL: rootURL, fileManager: .default ), - urlSession: URLSession(configuration: configuration), + urlSession: .shared, persistenceContainer: container ) + await manager.testingSetScheduledProcessHook { _ in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + } try insertPersistedDownload( in: container, From b592b216beddf66f53d1b39a03574392f6fe2527 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 01:42:09 +0800 Subject: [PATCH 085/614] Flush manifest --- .../Clients/DownloadClient+Manager.swift | 5 ++ .../Clients/DownloadClient+PageDownload.swift | 19 ++++- .../Clients/DownloadClient+Persistence.swift | 31 ++++++++- .../DownloadFileStorage+Operations.swift | 30 +++++++- .../DownloadManagerStorageTests.swift | 69 ++++++++++++++++++- 5 files changed, 144 insertions(+), 10 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 94a13ae9a..6f103ffd4 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -84,6 +84,11 @@ actor DownloadManager { let latestSignature: String? } + struct ProgressFlushContext: Sendable { + let gid: String + let folderURL: URL + } + struct PageDownloadContext: Sendable { let payload: DownloadRequestPayload let source: ResolvedSource? diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index 530ff6d45..239e7c9e0 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -64,7 +64,10 @@ extension DownloadManager { throw CancellationError() } try await flushDownloadProgress( - gid: context.payload.gallery.gid, + context: .init( + gid: context.payload.gallery.gid, + folderURL: context.temporaryFolderURL + ), pendingResolvedPages: &progress.pendingResolvedPages, completedCount: progress.completedCount, lastFlushDate: &progress.lastFlushDate, @@ -94,6 +97,10 @@ extension DownloadManager { progress.completedCount = progress.results.count guard progress.completedCount > 0 else { return } let completedCount = progress.completedCount + try flushManifestPageProgress( + folderURL: context.temporaryFolderURL, + pages: progress.results + ) try await updateDownloadRecord( gid: payload.gallery.gid, createIfMissing: false @@ -128,7 +135,10 @@ extension DownloadManager { progress.pendingResolvedPages .append(contentsOf: restoredCachedPages) try await flushDownloadProgress( - gid: payload.gallery.gid, + context: .init( + gid: payload.gallery.gid, + folderURL: context.temporaryFolderURL + ), pendingResolvedPages: &progress.pendingResolvedPages, completedCount: progress.completedCount, lastFlushDate: &progress.lastFlushDate, @@ -243,7 +253,10 @@ extension DownloadManager { ) guard !wasCancelled else { continue } try? await flushDownloadProgress( - gid: payload.gallery.gid, + context: .init( + gid: payload.gallery.gid, + folderURL: context.temporaryFolderURL + ), pendingResolvedPages: &progress.pendingResolvedPages, completedCount: progress.completedCount, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index c6dea761b..9be3da393 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -404,7 +404,7 @@ extension DownloadManager { } func flushDownloadProgress( - gid: String, + context: ProgressFlushContext, pendingResolvedPages: inout [PageResult], completedCount: Int, lastFlushDate: inout Date, @@ -418,14 +418,18 @@ extension DownloadManager { guard shouldFlush else { return } let resolvedPages = pendingResolvedPages + try flushManifestPageProgress( + folderURL: context.folderURL, + pages: resolvedPages + ) pendingResolvedPages .removeAll(keepingCapacity: true) await persistResolvedImageURLs( - gid: gid, + gid: context.gid, entries: resolvedPages ) try await updateDownloadRecord( - gid: gid, + gid: context.gid, createIfMissing: false ) { record in record.completedPageCount = @@ -435,6 +439,27 @@ extension DownloadManager { await notifyObservers() } + func flushManifestPageProgress( + folderURL: URL, + pages: [PageResult] + ) throws { + guard !pages.isEmpty else { return } + let manifestURL = folderURL + .appendingPathComponent(Defaults.FilePath.downloadManifest) + guard fileManager.operate({ + $0.fileExists(atPath: manifestURL.path) + }) else { + return + } + let pageRelativePaths = pages.reduce(into: [Int: String]()) { result, page in + result[page.index] = page.relativePath + } + try storage.refreshManifestPageFileHashes( + folderURL: folderURL, + pageRelativePaths: pageRelativePaths + ) + } + func persistResolvedImageURLs( gid: String, index: Int, diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index c30d287dd..71da6797f 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -140,12 +140,38 @@ extension DownloadFileStorage { pageIndex: Int, relativePath: String? = nil ) throws -> DownloadManifest { + if let relativePath { + return try refreshManifestPageFileHashes( + folderURL: folderURL, + pageRelativePaths: [pageIndex: relativePath] + ) + } let manifest = try readManifest(folderURL: folderURL) + guard let page = manifest.pages.first( + where: { $0.index == pageIndex } + ) else { + return manifest + } + return try refreshManifestPageFileHashes( + folderURL: folderURL, + pageRelativePaths: [pageIndex: page.relativePath] + ) + } + + @discardableResult + func refreshManifestPageFileHashes( + folderURL: URL, + pageRelativePaths: [Int: String] + ) throws -> DownloadManifest { + let manifest = try readManifest(folderURL: folderURL) + guard !pageRelativePaths.isEmpty else { return manifest } var didUpdate = false let pages = try manifest.pages.map { page in - guard page.index == pageIndex else { return page } + guard let refreshedRelativePath = + pageRelativePaths[page.index] else { + return page + } didUpdate = true - let refreshedRelativePath = relativePath ?? page.relativePath return DownloadManifest.Page( index: page.index, relativePath: refreshedRelativePath, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 7fb48ecfd..87ba37b39 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -511,6 +511,69 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } } + @Test + func testDownloadManagerFlushProgressUpdatesManifestPageHash() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + persistenceContainer: container + ) + + try storage.ensureRootDirectory() + let folderRelativePath = "[840_token] Progress" + try writeIndexedManifest( + storage: storage, + relativePath: folderRelativePath, + manifest: indexedManifest( + gid: "840", + title: "Progress", + pageHashes: ["", ""], + pageRelativePaths: [ + "840_token_1.pending", + "840_token_2.pending" + ] + ) + ) + let folderURL = storage.folderURL(relativePath: folderRelativePath) + let pageRelativePath = "840_token_1.jpg" + try Data([0x01, 0x02, 0x03]).write( + to: folderURL.appendingPathComponent(pageRelativePath), + options: .atomic + ) + var pendingResolvedPages = [ + DownloadManager.PageResult( + index: 1, + relativePath: pageRelativePath, + imageURL: nil + ) + ] + var lastFlushDate = Date.distantPast + + try await manager.flushDownloadProgress( + context: .init(gid: "840", folderURL: folderURL), + pendingResolvedPages: &pendingResolvedPages, + completedCount: 1, + lastFlushDate: &lastFlushDate, + force: true + ) + + let manifest = try storage.readManifest(folderURL: folderURL) + let download = try #require(await manager.fetchDownload(gid: "840")) + + #expect(pendingResolvedPages.isEmpty) + #expect(manifest.pages[0].relativePath == pageRelativePath) + #expect(manifest.pages[0].fileHash?.hasPrefix("sha256:") == true) + #expect(manifest.pages[1].relativePath == "840_token_2.pending") + #expect(manifest.pages[1].fileHash == "") + #expect(download.completedPageCount == 1) + } + @Test func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { let container = try makeInMemoryContainer() @@ -712,7 +775,8 @@ private extension DownloadManagerStorageTests { gid: String, title: String, pageHashes: [String], - downloadedAt: Date = .now + downloadedAt: Date = .now, + pageRelativePaths: [String]? = nil ) throws -> DownloadManifest { DownloadManifest( gid: gid, @@ -735,7 +799,8 @@ private extension DownloadManagerStorageTests { pages: pageHashes.enumerated().map { offset, hash in DownloadManifest.Page( index: offset + 1, - relativePath: "\(gid)_token_\(offset + 1).jpg", + relativePath: pageRelativePaths?[offset] + ?? "\(gid)_token_\(offset + 1).jpg", fileHash: hash ) } From 04639e19b2145cbf322542884d7c244f89f5e01c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 01:50:30 +0800 Subject: [PATCH 086/614] Drop image cache state --- .../Tools/Clients/DownloadClient+Cache.swift | 80 ------------- .../DownloadClient+ExecutionPerform.swift | 36 +++--- .../DownloadClient+ExecutionSupport.swift | 13 +-- .../Clients/DownloadClient+Manager.swift | 7 -- .../Clients/DownloadClient+PageDownload.swift | 46 +------- .../DownloadClient+PageDownloadHelpers.swift | 41 +------ .../Clients/DownloadClient+Persistence.swift | 98 ---------------- .../Clients/DownloadClient+PublicAPI.swift | 3 - .../Clients/DownloadClient+Testing.swift | 39 ------- .../DownloadImageParsingCacheTests.swift | 45 +------- .../Download/DownloadImageParsingTests.swift | 45 +------- .../Download/DownloadObserverBatchTests.swift | 105 +++++++----------- 12 files changed, 73 insertions(+), 485 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 6e9734a33..f32dc93d0 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -29,92 +29,12 @@ extension DownloadManager { } } - func pageImageCacheURLs( - resolvedImageSource: ResolvedImageSource?, - index: Int, - storedGalleryImageState: CachedGalleryImageState? - ) -> [URL?] { - [ - resolvedImageSource?.imageURL, - storedGalleryImageState?.imageURLs[index] - ] - } - func pageImageCacheURLs( imageURL: URL? ) -> [URL?] { [imageURL] } - func canSatisfyPendingPageDownloadsFromCache( - pendingPageIndices: [Int], - temporaryFolderURL: URL, - existingPageRelativePaths: [Int: String], - storedGalleryImageState: CachedGalleryImageState? - ) async -> Bool { - guard !pendingPageIndices.isEmpty else { return true } - for index in pendingPageIndices { - if let relativePath = - existingPageRelativePaths[index] { - let fileURL = temporaryFolderURL - .appendingPathComponent(relativePath) - if fileManager.operate({ $0.fileExists(atPath: fileURL.path) }) { - continue - } - } - guard await validatedCachedAssetData( - for: pageImageCacheURLs( - resolvedImageSource: nil, - index: index, - storedGalleryImageState: - storedGalleryImageState - ) - ) != nil else { - return false - } - } - return true - } - - func restorePendingPagesFromStoredCache( - payload: DownloadRequestPayload, - indices: [Int], - temporaryFolderURL: URL, - existingPages: [Int: String], - storedGalleryImageState: CachedGalleryImageState? - ) async throws -> [PageResult] { - var restoredPages = [PageResult]() - for index in indices { - let cacheURLs = pageImageCacheURLs( - resolvedImageSource: nil, - index: index, - storedGalleryImageState: - storedGalleryImageState - ) - let cacheSource = CacheRestoreSource( - gid: payload.gallery.gid, - token: payload.gallery.token, - cacheURLs: cacheURLs, - referenceURL: cacheURLs - .compactMap(\.self).first, - imageURL: storedGalleryImageState? - .imageURLs[index] - ) - guard let pageResult = - try await restorePageFromCache( - index: index, - source: cacheSource, - folderURL: temporaryFolderURL, - preferredRelativePath: - existingPages[index] - ) else { - continue - } - restoredPages.append(pageResult) - } - return restoredPages - } - func restorePageFromCache( index: Int, source: CacheRestoreSource, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 94aa559dd..295f104cf 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -74,10 +74,6 @@ extension DownloadManager { ) async throws -> PerformDownloadResult { let existingDownload = executionContext.existingDownload let versionSignature = executionContext.versionSignature - let storedGalleryImageState = - await fetchCachedGalleryImageState( - gid: payload.gallery.gid - ) let coverRelativePath = try await downloadAndPersistCoverIfNeeded( payload: payload, folderURL: workingFolderURL, @@ -88,14 +84,12 @@ extension DownloadManager { payload: payload, pendingIndices: pendingIndices, folderURL: workingFolderURL, - existingPages: workingSeed.existingPages, - storedGalleryImageState: storedGalleryImageState + existingPages: workingSeed.existingPages ) let downloadContext = PageDownloadContext( payload: payload, source: source, - temporaryFolderURL: workingFolderURL, - storedGalleryImageState: storedGalleryImageState + temporaryFolderURL: workingFolderURL ) let batchResult = try await downloadPages( context: downloadContext, @@ -107,7 +101,6 @@ extension DownloadManager { versionSignature: versionSignature, coverRelativePath: coverRelativePath, batchResult: batchResult, - storedGalleryImageState: storedGalleryImageState, existingDownload: existingDownload ) try await finalizeBatchResult( @@ -175,22 +168,23 @@ extension DownloadManager { payload: DownloadRequestPayload, pendingIndices: [Int], folderURL: URL, - existingPages: [Int: String], - storedGalleryImageState: CachedGalleryImageState? + existingPages: [Int: String] ) async throws -> ResolvedSource? { - let canSatisfyFromCache = - await canSatisfyPendingPageDownloadsFromCache( - pendingPageIndices: pendingIndices, - temporaryFolderURL: folderURL, - existingPageRelativePaths: existingPages, - storedGalleryImageState: storedGalleryImageState - ) - if pendingIndices.isEmpty || canSatisfyFromCache { + let missingIndices = pendingIndices.filter { index in + guard let relativePath = existingPages[index] else { + return true + } + let fileURL = folderURL.appendingPathComponent(relativePath) + return !fileManager.operate { + $0.fileExists(atPath: fileURL.path) + } + } + if missingIndices.isEmpty { return nil } return try await resolveSource( payload: payload, - requiredPageIndices: pendingIndices + requiredPageIndices: missingIndices ) } @@ -201,7 +195,6 @@ extension DownloadManager { ) async throws { let versionSignature = finalizeContext.versionSignature let batchResult = finalizeContext.batchResult - let storedGalleryImageState = finalizeContext.storedGalleryImageState let existingDownload = finalizeContext.existingDownload let manifest = makeManifest( payload: payload, @@ -222,7 +215,6 @@ extension DownloadManager { ) await cleanupCachedRemoteAssetsAfterSuccessfulDownload( payload: payload, - storedGalleryImageState: storedGalleryImageState, pages: batchResult.pages, existingDownload: existingDownload ) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 6e7db1eab..a4c451549 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -113,21 +113,12 @@ extension DownloadManager { func cleanupCachedRemoteAssetsAfterSuccessfulDownload( payload: DownloadRequestPayload, - storedGalleryImageState: CachedGalleryImageState?, pages: [PageResult], existingDownload: DownloadedGallery ) async { - let previewURLs = ( - Array(payload.previewURLs.values) - + (storedGalleryImageState.map { - Array($0.previewURLs.values) - } ?? []) - ) - .flatMap { $0.previewCacheCleanupURLs() } + let previewURLs = Array(payload.previewURLs.values) + .flatMap { $0.previewCacheCleanupURLs() } let pageURLs = pages.compactMap(\.imageURL) - + (storedGalleryImageState.map { - Array($0.imageURLs.values) - } ?? []) let coverURLs = [ payload.galleryDetail.coverURL, payload.gallery.coverURL, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 6f103ffd4..28fa290e0 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -67,11 +67,6 @@ actor DownloadManager { let imageURL: URL } - struct CachedGalleryImageState: Sendable { - let previewURLs: [Int: URL] - let imageURLs: [Int: URL] - } - struct PartialDownloadError: Error, Sendable { let failedPages: [DownloadFailedPagesSnapshot.Page] } @@ -93,7 +88,6 @@ actor DownloadManager { let payload: DownloadRequestPayload let source: ResolvedSource? let temporaryFolderURL: URL - let storedGalleryImageState: CachedGalleryImageState? } struct CacheRestoreSource: Sendable { @@ -134,7 +128,6 @@ actor DownloadManager { let versionSignature: String let coverRelativePath: String? let batchResult: DownloadBatchResult - let storedGalleryImageState: CachedGalleryImageState? let existingDownload: DownloadedGallery } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index 239e7c9e0..6b435d2a1 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -37,13 +37,6 @@ extension DownloadManager { progress: &progress ) - try await restoreAndFlushCachedPages( - context: context, - pendingPageIndices: pendingPageIndices, - existingPages: existingPages, - progress: &progress - ) - let restoredIndices = Set( progress.results .prefix(progress.completedCount) @@ -110,42 +103,6 @@ extension DownloadManager { await notifyObservers() } - private func restoreAndFlushCachedPages( - context: PageDownloadContext, - pendingPageIndices: [Int], - existingPages: [Int: String], - progress: inout PageDownloadProgress - ) async throws { - let payload = context.payload - let restoredCachedPages = - try await restorePendingPagesFromStoredCache( - payload: payload, - indices: pendingPageIndices, - temporaryFolderURL: context.temporaryFolderURL, - existingPages: existingPages, - storedGalleryImageState: - context.storedGalleryImageState - ) - guard !restoredCachedPages.isEmpty else { return } - restoredCachedPages.forEach { - progress.failedPages[$0.index] = nil - progress.results.append($0) - } - progress.completedCount += restoredCachedPages.count - progress.pendingResolvedPages - .append(contentsOf: restoredCachedPages) - try await flushDownloadProgress( - context: .init( - gid: payload.gallery.gid, - folderURL: context.temporaryFolderURL - ), - pendingResolvedPages: &progress.pendingResolvedPages, - completedCount: progress.completedCount, - lastFlushDate: &progress.lastFlushDate, - force: true - ) - } - private func buildBatchResult( results: [PageResult], failedPages: [Int: DownloadFailedPagesSnapshot.Page?], @@ -212,8 +169,7 @@ extension DownloadManager { .init( index: index, relativePath: relativePath, - imageURL: context.storedGalleryImageState? - .imageURLs[index] + imageURL: nil ) ) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift index 9f556fd9e..655154d7e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -47,13 +47,6 @@ extension DownloadManager { let payload = context.payload let temporaryFolderURL = context.temporaryFolderURL - if let result = try await attemptCacheRestore( - index: index, - context: context, - preferredRelativePath: preferredRelativePath - ) { - return result - } guard let source = context.source else { throw AppError.notFound } @@ -79,35 +72,6 @@ extension DownloadManager { ) } - private func attemptCacheRestore( - index: Int, - context: PageDownloadContext, - preferredRelativePath: String? - ) async throws -> PageResult? { - let payload = context.payload - let storedGalleryImageState = context.storedGalleryImageState - let storedCacheURLs = pageImageCacheURLs( - resolvedImageSource: nil, - index: index, - storedGalleryImageState: storedGalleryImageState - ) - let storedSource = CacheRestoreSource( - gid: payload.gallery.gid, - token: payload.gallery.token, - cacheURLs: storedCacheURLs, - referenceURL: storedCacheURLs - .compactMap(\.self).first, - imageURL: storedGalleryImageState? - .imageURLs[index] - ) - return try await restorePageFromCache( - index: index, - source: storedSource, - folderURL: context.temporaryFolderURL, - preferredRelativePath: preferredRelativePath - ) - } - private func attemptResolvedCacheRestore( index: Int, resolvedImageSource: ResolvedImageSource, @@ -115,11 +79,8 @@ extension DownloadManager { preferredRelativePath: String? ) async throws -> PageResult? { let payload = context.payload - let storedGalleryImageState = context.storedGalleryImageState let resolvedCacheURLs = pageImageCacheURLs( - resolvedImageSource: resolvedImageSource, - index: index, - storedGalleryImageState: storedGalleryImageState + imageURL: resolvedImageSource.imageURL ) let resolvedSource = CacheRestoreSource( gid: payload.gallery.gid, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 9be3da393..725df7792 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -424,10 +424,6 @@ extension DownloadManager { ) pendingResolvedPages .removeAll(keepingCapacity: true) - await persistResolvedImageURLs( - gid: context.gid, - entries: resolvedPages - ) try await updateDownloadRecord( gid: context.gid, createIfMissing: false @@ -460,98 +456,4 @@ extension DownloadManager { ) } - func persistResolvedImageURLs( - gid: String, - index: Int, - imageURL: URL? - ) async { - await persistResolvedImageURLs( - gid: gid, - entries: [ - .init( - index: index, - relativePath: "", - imageURL: imageURL - ) - ] - ) - } - - func persistResolvedImageURLs( - gid: String, - entries: [PageResult] - ) async { - guard gid.isValidGID else { return } - let validEntries = entries - .filter { $0.imageURL != nil } - guard !validEntries.isEmpty else { return } - - await MainActor.run { - let context = persistenceContainer.viewContext - let request = NSFetchRequest( - entityName: "GalleryStateMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate( - format: "gid == %@", - gid - ) - - let object: GalleryStateMO - if let stored = - try? context.fetch(request).first { - object = stored - } else { - object = GalleryStateMO(context: context) - object.gid = gid - } - - var imageURLs = (object.imageURLs?.toObject() - as [Int: URL]?) ?? [:] - var hasChanges = false - - for entry in validEntries { - if let imageURL = entry.imageURL, - imageURLs[entry.index] != imageURL { - imageURLs[entry.index] = imageURL - hasChanges = true - } - } - - guard hasChanges else { - return - } - - object.imageURLs = imageURLs.toData() - - guard context.hasChanges else { return } - try? context.save() - } - } - - func fetchCachedGalleryImageState( - gid: String - ) async -> CachedGalleryImageState? { - await MainActor.run { - guard gid.isValidGID else { return nil } - let context = persistenceContainer.viewContext - let request = NSFetchRequest( - entityName: "GalleryStateMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate( - format: "gid == %@", - gid - ) - guard let object = - try? context.fetch(request).first else { - return nil - } - let state = object.toEntity() - return .init( - previewURLs: state.previewURLs, - imageURLs: state.imageURLs - ) - } - } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index c6bce770d..ae5cbd03d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -352,9 +352,6 @@ extension DownloadManager { captureTarget.preferredRelativePath ?? existingPages[index], overwriteExistingFile: true ) else { return } - await persistResolvedImageURLs( - gid: gid, index: index, imageURL: pageResult.imageURL - ) if captureTarget.isTemporary { try clearFailedPage( index: index, folderURL: captureTarget.folderURL diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 5c4357448..741368bb7 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -73,45 +73,6 @@ extension DownloadManager { activeGalleryID } - func testingRestoreCachedPages( - payload: DownloadRequestPayload - ) async throws -> Int { - try storage.ensureRootDirectory() - let temporaryFolderURL = storage - .temporaryFolderURL(gid: payload.gallery.gid) - try? fileManager.operate { - try $0.removeItem(at: temporaryFolderURL) - } - try createDirectory(at: temporaryFolderURL) - try createDirectory( - at: temporaryFolderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, - isDirectory: true - ) - ) - - let downloadContext = PageDownloadContext( - payload: payload, - source: nil, - temporaryFolderURL: temporaryFolderURL, - storedGalleryImageState: - await fetchCachedGalleryImageState( - gid: payload.gallery.gid - ) - ) - let batchResult = try await downloadPages( - context: downloadContext, - pendingPageIndices: pendingPageIndices( - payload: payload, - folderURL: temporaryFolderURL, - existingPageRelativePaths: [:] - ), - existingManifest: nil, - existingPageRelativePaths: [:] - ) - return batchResult.pages.count - } - func testingFetchLatestPayload( for download: DownloadedGallery, mode: DownloadStartMode, diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift index 26962776f..99282f4fc 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -12,19 +12,12 @@ import Testing @Suite(.serialized) struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { - func testCachedKokomadePlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { - let container = try makeInMemoryContainer() + func testCachedKokomadePlaceholderStoredUnderNormalImageURLIsRejected() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 33) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) + let manager = makeTestingDownloadManager() let normalImageURL = try #require( URL(string: "https://exhentai.org/fullimg.php?gid=\(gid)&page=1&key=normal-cache-key") ) - try insertPersistedGalleryState(in: container, gid: gid, imageURLs: [1: normalImageURL]) let imageData = try fixtureData(resource: "Kokomade", pathExtension: "jpg") let cacheKeys = normalImageURL.imageCacheKeys(includeStableAlias: true) @@ -34,13 +27,11 @@ struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } await waitUntilCacheReady(for: cacheKeys) - let payload = try makeExhentaiPayload(gid: gid) - let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) - let restoredPageURL = storage.temporaryFolderURL(gid: gid) - .appendingPathComponent("pages/0001.jpg") + let cachedData = await manager.validatedCachedAssetData( + for: [normalImageURL] + ) - #expect(restoredCount == 0) - #expect(FileManager.default.fileExists(atPath: restoredPageURL.path) == false) + #expect(cachedData == nil) } @Test @@ -104,27 +95,3 @@ struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { } } - -// MARK: - Payload Factory - -private extension DownloadImageParsingCacheTests { - func makeExhentaiPayload(gid: String) throws -> DownloadRequestPayload { - DownloadRequestPayload( - gallery: Gallery( - gid: gid, token: "token", title: "Auth Placeholder", rating: 4, - tags: [], category: .doujinshi, uploader: "Uploader", pageCount: 1, postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: try #require(URL(string: "https://exhentai.org/g/\(gid)/token") as URL?) - ), - galleryDetail: GalleryDetail( - gid: gid, title: "Auth Placeholder", jpnTitle: nil, - isFavorited: false, visibility: .yes, rating: 4, userRating: 0, ratingCount: 0, - category: .doujinshi, language: .japanese, uploader: "Uploader", postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - favoritedCount: 0, pageCount: 1, sizeCount: 12, sizeType: "MB", torrentCount: 0 - ), - previewURLs: [:], previewConfig: .normal(rows: 4), - host: .exhentai, options: DownloadOptionsSnapshot(), mode: .initial - ) - } -} diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift index fb02af33e..ed8d30aad 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift @@ -154,19 +154,12 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { @MainActor @Test - func testCachedQuotaPlaceholderStoredUnderNormalImageURLDoesNotRestoreIntoOfflinePages() async throws { - let container = try makeInMemoryContainer() + func testCachedQuotaPlaceholderStoredUnderNormalImageURLIsRejected() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 32) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) + let manager = makeTestingDownloadManager() let normalImageURL = try #require( URL(string: "https://ehgt.org/h/quota-placeholder-cache-\(gid)/1") ) - try insertPersistedGalleryState(in: container, gid: gid, imageURLs: [1: normalImageURL]) let placeholderURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) defer { try? FileManager.default.removeItem(at: placeholderURL) } @@ -178,37 +171,11 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } await waitUntilCacheReady(for: cacheKeys) - let payload = makeEhentaiPayload(gid: gid) - let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) - let restoredPageURL = storage.temporaryFolderURL(gid: gid) - .appendingPathComponent("pages/0001.gif") + let cachedData = await manager.validatedCachedAssetData( + for: [normalImageURL] + ) - #expect(restoredCount == 0) - #expect(FileManager.default.fileExists(atPath: restoredPageURL.path) == false) + #expect(cachedData == nil) } } - -// MARK: - Payload Factory - -private extension DownloadImageParsingTests { - func makeEhentaiPayload(gid: String) -> DownloadRequestPayload { - DownloadRequestPayload( - gallery: Gallery( - gid: gid, token: "token", title: "Quota Placeholder", rating: 4, - tags: [], category: .doujinshi, uploader: "Uploader", pageCount: 1, postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token") - ), - galleryDetail: GalleryDetail( - gid: gid, title: "Quota Placeholder", jpnTitle: nil, - isFavorited: false, visibility: .yes, rating: 4, userRating: 0, ratingCount: 0, - category: .doujinshi, language: .japanese, uploader: "Uploader", postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - favoritedCount: 0, pageCount: 1, sizeCount: 12, sizeType: "MB", torrentCount: 0 - ), - previewURLs: [:], previewConfig: .normal(rows: 4), - host: .ehentai, options: DownloadOptionsSnapshot(), mode: .initial - ) - } -} diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 67a38d348..f20bd7bfb 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -4,10 +4,7 @@ // import Foundation -import CoreData import ComposableArchitecture -import Kingfisher -import UIKit import Testing @testable import EhPanda @@ -67,7 +64,7 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { @MainActor @Test - func testDownloadManagerBatchesObserverUpdatesDuringCachedPageRestore() async throws { + func testDownloadManagerBatchesObserverUpdatesDuringProgressFlush() async throws { let container = try makeInMemoryContainer() let pageCount = 20 let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 104) @@ -77,15 +74,21 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) - try insertPersistedDownload( - in: container, gid: gid, status: .downloading, completedPageCount: 0, pageCount: pageCount - ) - let cacheKeys = try await setupBatchRestoreCachedImages( - container: container, gid: gid, pageCount: pageCount + let folderRelativePath = "\(gid) - Progress Flush" + let folderURL = storage.folderURL(relativePath: folderRelativePath) + try FileManager.default.createDirectory( + at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest( + gid: gid, + title: "Progress Flush", + pageCount: pageCount + ), + folderURL: folderURL ) - defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } - await waitUntilCacheReady(for: cacheKeys) let observationStream = await manager.observeDownloads() let emissionTask = Task { @@ -98,64 +101,42 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { return emissionCount } - let payload = try makeBatchRestorePayload(gid: gid, pageCount: pageCount) - let restoredCount = try await manager.testingRestoreCachedPages(payload: payload) + var pendingResolvedPages = [DownloadManager.PageResult]() + var lastFlushDate = Date.distantPast + for index in 1...pageCount { + let relativePath = "pages/\(String(format: "%04d", index)).jpg" + try Data([UInt8(index)]).write( + to: folderURL.appendingPathComponent(relativePath), + options: .atomic + ) + pendingResolvedPages.append( + .init(index: index, relativePath: relativePath, imageURL: nil) + ) + try await manager.flushDownloadProgress( + context: .init(gid: gid, folderURL: folderURL), + pendingResolvedPages: &pendingResolvedPages, + completedCount: index, + lastFlushDate: &lastFlushDate, + force: false + ) + } + try await manager.flushDownloadProgress( + context: .init(gid: gid, folderURL: folderURL), + pendingResolvedPages: &pendingResolvedPages, + completedCount: pageCount, + lastFlushDate: &lastFlushDate, + force: true + ) + let emissionCount = try await waitForTaskValue( emissionTask, timeout: .seconds(2), - description: "observer updates for cached page restore" + description: "observer updates for progress flush" ) let stored = await manager.testingFetchDownload(gid: gid) - #expect(restoredCount == pageCount) #expect(stored?.completedPageCount == pageCount) #expect(emissionCount < pageCount) - #expect(emissionCount <= 1 + Int(ceil(Double(pageCount) / 8.0))) - } -} - -// MARK: - Setup Helpers - -private extension DownloadObserverBatchTests { - @MainActor - func setupBatchRestoreCachedImages( - container: NSPersistentContainer, - gid: String, - pageCount: Int - ) async throws -> Set { - let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in - UIColor.systemTeal.setFill() - context.fill(.init(x: 0, y: 0, width: 1, height: 1)) - } - let imageData = try #require(cachedImage.jpegData(compressionQuality: 1)) - let imageURLs = try Dictionary(uniqueKeysWithValues: (1...pageCount).map { index in - (index, try #require(URL(string: "https://example.com/pages/\(gid)-\(index).jpg"))) - }) - try insertPersistedGalleryState(in: container, gid: gid, imageURLs: imageURLs) - let cacheKeys = Set(imageURLs.values.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) - for cacheKey in cacheKeys { - try await KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) - } - return cacheKeys - } - - func makeBatchRestorePayload(gid: String, pageCount: Int) throws -> DownloadRequestPayload { - DownloadRequestPayload( - gallery: Gallery( - gid: gid, token: "token", title: "Cached Restore Gallery", rating: 4, - tags: [], category: .doujinshi, uploader: "Uploader", pageCount: pageCount, - postedDate: .now, coverURL: URL(string: "https://example.com/cover.jpg"), - galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token") as URL?) - ), - galleryDetail: GalleryDetail( - gid: gid, title: "Cached Restore Gallery", jpnTitle: nil, - isFavorited: false, visibility: .yes, rating: 4, userRating: 0, ratingCount: 0, - category: .doujinshi, language: .japanese, uploader: "Uploader", postedDate: .now, - coverURL: URL(string: "https://example.com/cover.jpg"), - favoritedCount: 0, pageCount: pageCount, sizeCount: 12, sizeType: "MB", torrentCount: 0 - ), - previewURLs: [:], previewConfig: .normal(rows: 4), - host: .ehentai, options: DownloadOptionsSnapshot(), mode: .initial - ) + #expect(emissionCount <= 2 + Int(ceil(Double(pageCount) / 8.0))) } } From 490ea668d50a71ae5bfa611c65c0d5eef24998f6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 01:53:58 +0800 Subject: [PATCH 087/614] Drop progress MO writes --- .../Clients/DownloadClient+PageDownload.swift | 14 +++----------- .../Tools/Clients/DownloadClient+Persistence.swift | 8 -------- .../Download/DownloadManagerStorageTests.swift | 1 - .../Download/DownloadObserverBatchTests.swift | 2 -- 4 files changed, 3 insertions(+), 22 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index 6b435d2a1..c099cf2da 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -62,7 +62,6 @@ extension DownloadManager { folderURL: context.temporaryFolderURL ), pendingResolvedPages: &progress.pendingResolvedPages, - completedCount: progress.completedCount, lastFlushDate: &progress.lastFlushDate, force: true ) @@ -78,8 +77,9 @@ extension DownloadManager { existingPages: [Int: String], progress: inout PageDownloadProgress ) async throws { - let payload = context.payload - let pageIndices = Array(1...payload.galleryDetail.pageCount) + let pageIndices = Array( + 1...context.payload.galleryDetail.pageCount + ) collectExistingPages( pageIndices: pageIndices, existingPages: existingPages, @@ -89,17 +89,10 @@ extension DownloadManager { ) progress.completedCount = progress.results.count guard progress.completedCount > 0 else { return } - let completedCount = progress.completedCount try flushManifestPageProgress( folderURL: context.temporaryFolderURL, pages: progress.results ) - try await updateDownloadRecord( - gid: payload.gallery.gid, - createIfMissing: false - ) { record in - record.completedPageCount = Int64(completedCount) - } await notifyObservers() } @@ -215,7 +208,6 @@ extension DownloadManager { ), pendingResolvedPages: &progress.pendingResolvedPages, - completedCount: progress.completedCount, lastFlushDate: &progress.lastFlushDate, force: false ) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 725df7792..20452d4c6 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -406,7 +406,6 @@ extension DownloadManager { func flushDownloadProgress( context: ProgressFlushContext, pendingResolvedPages: inout [PageResult], - completedCount: Int, lastFlushDate: inout Date, force: Bool ) async throws { @@ -424,13 +423,6 @@ extension DownloadManager { ) pendingResolvedPages .removeAll(keepingCapacity: true) - try await updateDownloadRecord( - gid: context.gid, - createIfMissing: false - ) { record in - record.completedPageCount = - Int64(completedCount) - } lastFlushDate = Date() await notifyObservers() } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 87ba37b39..44eeb3212 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -558,7 +558,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try await manager.flushDownloadProgress( context: .init(gid: "840", folderURL: folderURL), pendingResolvedPages: &pendingResolvedPages, - completedCount: 1, lastFlushDate: &lastFlushDate, force: true ) diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index f20bd7bfb..d3cb687f0 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -115,7 +115,6 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { try await manager.flushDownloadProgress( context: .init(gid: gid, folderURL: folderURL), pendingResolvedPages: &pendingResolvedPages, - completedCount: index, lastFlushDate: &lastFlushDate, force: false ) @@ -123,7 +122,6 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { try await manager.flushDownloadProgress( context: .init(gid: gid, folderURL: folderURL), pendingResolvedPages: &pendingResolvedPages, - completedCount: pageCount, lastFlushDate: &lastFlushDate, force: true ) From 9cee49010ee9bb0309ade5ddf8712a9c588a6ec3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 02:00:56 +0800 Subject: [PATCH 088/614] Drop cover MO write --- .../DownloadClient+ExecutionPerform.swift | 21 ++++--------- .../Download/DownloadProcessCacheTests.swift | 30 +++---------------- .../Tests/Download/DownloadProcessTests.swift | 1 - 3 files changed, 9 insertions(+), 43 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 295f104cf..5ab39293f 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -74,11 +74,10 @@ extension DownloadManager { ) async throws -> PerformDownloadResult { let existingDownload = executionContext.existingDownload let versionSignature = executionContext.versionSignature - let coverRelativePath = try await downloadAndPersistCoverIfNeeded( + let coverRelativePath = try await downloadCoverIfNeeded( payload: payload, folderURL: workingFolderURL, - existingCoverRelativePath: workingSeed.coverRelativePath, - existingDownload: existingDownload + existingCoverRelativePath: workingSeed.coverRelativePath ) let source = try await resolveSourceIfNeeded( payload: payload, @@ -114,26 +113,16 @@ extension DownloadManager { ) } - private func downloadAndPersistCoverIfNeeded( + private func downloadCoverIfNeeded( payload: DownloadRequestPayload, folderURL: URL, - existingCoverRelativePath: String?, - existingDownload: DownloadedGallery + existingCoverRelativePath: String? ) async throws -> String? { - let coverRelativePath = try await downloadCoverImage( + try await downloadCoverImage( payload: payload, temporaryFolderURL: folderURL, existingCoverRelativePath: existingCoverRelativePath ) - if coverRelativePath != existingDownload.coverRelativePath { - try? await updateDownloadRecord( - gid: payload.gallery.gid, - createIfMissing: false - ) { record in - record.coverRelativePath = coverRelativePath - } - } - return coverRelativePath } private func finalizeBatchResult( diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index ca857ce20..ad0948bd3 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -210,16 +210,6 @@ private extension DownloadProcessCacheTests { let currentPageImageURL = try #require( Self.currentPageImageURL(gid: gid, pageIndex: pageIndex) ) - let staleStoredPageURL = try #require( - URL(string: "https://example.com/stale-image-\(gid)-1.jpg") - ) - let plainPreviewURL = try #require( - URL(string: "https://ehgt.org/preview/\(gid)/1.webp") - ) - let combinedPreviewURL = URLUtil.combinedPreviewURL( - plainURL: plainPreviewURL, width: "200", height: "300", offset: "40" - ) - let scaffoldDownload = sampleDownload( gid: gid, title: "Pause Race", status: .partial, pageCount: 156, completedPageCount: 155, @@ -232,14 +222,16 @@ private extension DownloadProcessCacheTests { let coverURL = try #require( latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL ) + let previewCleanupURLs = latestPayload.previewURLs.values + .flatMap { $0.previewCacheCleanupURLs() } let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { ctx in UIColor.systemTeal.setFill() ctx.fill(.init(x: 0, y: 0, width: 1, height: 1)) } let cachedImageData = try #require(cachedImage.jpegData(compressionQuality: 1)) - let cachedURLs = combinedPreviewURL.previewCacheCleanupURLs() - + [currentPageImageURL, staleStoredPageURL, coverURL] + let cachedURLs = previewCleanupURLs + + [currentPageImageURL, coverURL] let cachedKeys = Set(cachedURLs.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) for cacheKey in cachedKeys { try await KingfisherManager.shared.cache.storeToDisk(cachedImageData, forKey: cacheKey) @@ -249,15 +241,6 @@ private extension DownloadProcessCacheTests { } func setupCacheTestDownload(_ setup: CacheTestDownloadSetup) async throws -> Int { - let staleStoredPageURL = try #require( - URL(string: "https://example.com/stale-image-\(setup.gid)-1.jpg") - ) - let plainPreviewURL = try #require( - URL(string: "https://ehgt.org/preview/\(setup.gid)/1.webp") - ) - let combinedPreviewURL = URLUtil.combinedPreviewURL( - plainURL: plainPreviewURL, width: "200", height: "300", offset: "40" - ) let scaffoldDownload = sampleDownload( gid: setup.gid, title: "Pause Race", status: .partial, pageCount: 156, completedPageCount: 155, @@ -280,11 +263,6 @@ private extension DownloadProcessCacheTests { remoteVersionSignature: setup.oldVersionSignature, latestRemoteVersionSignature: setup.oldVersionSignature ) - try insertPersistedGalleryState( - in: setup.container, gid: setup.gid, - previewURLs: [1: combinedPreviewURL], - imageURLs: [1: staleStoredPageURL] - ) } try setupCacheTestTemporaryFolder( storage: setup.storage, gid: setup.gid, diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 173576430..b84684286 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -277,7 +277,6 @@ private extension DownloadProcessTests { ) let resumeState = try storage.readResumeState(folderURL: completedFolderURL) - #expect(resumeState.mode == .redownload) #expect(resumeState.versionSignature == context.updatedVersionSignature) #expect(resumeState.pageCount == context.updatedPageCount) #expect(resumeState.pageSelection == nil) From 23d8bfba55583bba55d7327112124f970e62239f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 02:06:29 +0800 Subject: [PATCH 089/614] Drop success MO write --- .../Clients/DownloadClient+Execution.swift | 57 ++----------------- .../DownloadManagerStorageTests.swift | 17 +----- 2 files changed, 6 insertions(+), 68 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index e643ae3de..1a77628af 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -37,7 +37,7 @@ extension DownloadManager { ) fetchedVersionSignature = result.versionSignature guard !Task.isCancelled else { return } - try await completeDownload( + await completeDownload( gid: gid, download: download, result: result @@ -60,14 +60,8 @@ extension DownloadManager { gid: String, download: DownloadedGallery, result: ProcessDownloadResult - ) async throws { - try await persistCompletedDownload( - gid: gid, - payload: result.payload, - folderRelativePath: result.folderRelativePath, - coverRelativePath: result.coverRelativePath, - versionSignature: result.versionSignature - ) + ) async { + await settleCompletedDownload(gid: gid) if download.folderRelativePath != result.folderRelativePath { try? storage.removeFolder( relativePath: download.folderRelativePath @@ -99,9 +93,7 @@ extension DownloadManager { } private struct ProcessDownloadResult { - let payload: DownloadRequestPayload let folderRelativePath: String - let coverRelativePath: String? let versionSignature: String } @@ -142,16 +134,14 @@ extension DownloadManager { rawPageSelection: rawPageSelection ) let folderRelativePath = folderRelativePath(for: payload) - let downloadResult = try await performDownload( + _ = try await performDownload( payload: payload, versionSignature: fetchResult.versionSignature, folderRelativePath: folderRelativePath, existingDownload: download ) return ProcessDownloadResult( - payload: payload, folderRelativePath: folderRelativePath, - coverRelativePath: downloadResult.coverRelativePath, versionSignature: fetchResult.versionSignature ) } @@ -217,46 +207,9 @@ extension DownloadManager { await notifyObservers() } - func persistCompletedDownload( - gid: String, - payload: DownloadRequestPayload, - folderRelativePath: String, - coverRelativePath: String?, - versionSignature: String - ) async throws { + func settleCompletedDownload(gid: String) async { downloadErrors[gid] = nil updatedGalleryIDs.remove(gid) await queueStore.remove(gid) - try await updateDownloadRecord( - gid: gid, - createIfMissing: false - ) { record in - record.host = payload.host.rawValue - record.token = payload.gallery.token - record.title = payload.gallery.title - record.jpnTitle = payload.galleryDetail.jpnTitle - record.uploader = payload.galleryDetail.uploader - record.category = payload.gallery.category.rawValue - record.tags = payload.gallery.tags.toData() - record.pageCount = - Int64(payload.galleryDetail.pageCount) - record.postedDate = payload.galleryDetail.postedDate - record.rating = payload.galleryDetail.rating - record.onlineCoverURL = - payload.galleryDetail.coverURL - ?? payload.gallery.coverURL - record.folderRelativePath = folderRelativePath - record.coverRelativePath = coverRelativePath - record.downloadOptionsSnapshot = - payload.options.toData() - record.completedPageCount = - Int64(payload.galleryDetail.pageCount) - record.lastDownloadedAt = .now - record.lastError = nil - record.remoteVersionSignature = versionSignature - record.latestRemoteVersionSignature = versionSignature - record.pendingOperation = nil - record.status = DownloadStatus.completed.rawValue - } } } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 44eeb3212..701bcbb42 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -344,15 +344,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { token: gallery.token, title: detail.trimmedTitle ) - let payload = DownloadRequestPayload( - gallery: gallery, - galleryDetail: detail, - previewURLs: [:], - previewConfig: .normal(rows: 4), - host: .ehentai, - options: .init(), - mode: .initial - ) try storage.ensureRootDirectory() try writeIndexedManifest( @@ -373,13 +364,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { gid: gallery.gid ) - try await manager.persistCompletedDownload( - gid: gallery.gid, - payload: payload, - folderRelativePath: folderRelativePath, - coverRelativePath: nil, - versionSignature: "hash:v1" - ) + await manager.settleCompletedDownload(gid: gallery.gid) let completedDownload = try #require( await manager.fetchDownload(gid: gallery.gid) From fdd6b0e17b6b983320655f1d2d7ef4836c0a8cdc Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 02:10:38 +0800 Subject: [PATCH 090/614] Drop start MO write --- .../Clients/DownloadClient+Execution.swift | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 1a77628af..dcb712596 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -25,10 +25,7 @@ extension DownloadManager { var fetchedVersionSignature: String? do { - try await markDownloadAsDownloading( - gid: gid, - completedPageCount: download.completedPageCount - ) + downloadErrors[gid] = nil await notifyObservers() let result = try await fetchNormalizeAndDownload( gid: gid, @@ -97,21 +94,6 @@ extension DownloadManager { let versionSignature: String } - private func markDownloadAsDownloading( - gid: String, - completedPageCount: Int - ) async throws { - try await updateDownloadRecord( - gid: gid, - createIfMissing: false - ) { record in - record.status = DownloadStatus.downloading.rawValue - record.completedPageCount = Int64(completedPageCount) - record.lastError = nil - record.pendingOperation = nil - } - } - private func fetchNormalizeAndDownload( gid: String, download: DownloadedGallery, From 82fff83ae605cc34619b7aa64236f2cfd8a7fd08 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 02:14:35 +0800 Subject: [PATCH 091/614] Skip failure MO write --- .../Clients/DownloadClient+Persistence.swift | 15 +++++++++++++++ .../Download/DownloadManagerStorageTests.swift | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 20452d4c6..b608593e2 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -297,6 +297,21 @@ extension DownloadManager { #endif downloadErrors[context.gid] = DownloadFailure(error: error) await queueStore.remove(context.gid) + let indexedDownloads = await reloadDownloadIndex() + guard indexedDownloads.contains(where: { $0.gid == context.gid }) + else { + await persistLegacyFailure( + error: error, + context: context + ) + return + } + } + + private func persistLegacyFailure( + error: AppError, + context: FailureContext + ) async { let workingCompletedPageCount = temporaryCompletedPageCount( gid: context.gid, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 701bcbb42..5111ed707 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -288,6 +288,13 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) try storage.ensureRootDirectory() + try insertPersistedDownload( + in: container, + gid: "800", + status: .queued, + completedPageCount: 0, + pageCount: 1 + ) try writeIndexedManifest( storage: storage, relativePath: "[800_token] Failing", @@ -319,6 +326,15 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(failedDownload.status == .failed) #expect(failedDownload.lastError?.code == .networkingFailed) #expect(badges["800"] == .failed) + + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", "800") + let persistedDownload = try container.viewContext.fetch(request).first + #expect(persistedDownload?.status == DownloadStatus.queued.rawValue) + #expect(persistedDownload?.lastError == nil) } @Test From be7d5351ea23070b9f239424c0402d4e194e8a7d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 02:19:48 +0800 Subject: [PATCH 092/614] Skip resume MO write --- .../Clients/DownloadClient+Scheduling.swift | 6 ++++++ .../Download/DownloadManagerStorageTests.swift | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index c47681d08..6a8936475 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -292,6 +292,12 @@ extension DownloadManager { do { downloadErrors[gid] = nil await queueStore.enqueue(gid) + let indexedDownloads = await reloadDownloadIndex() + if indexedDownloads.contains(where: { $0.gid == gid }) { + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } let resumedStatus: DownloadStatus = activeTask == nil ? .downloading : .queued try await updateDownloadRecord( diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 5111ed707..8a332973e 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -442,6 +442,14 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(pausedDownload.status == .paused) #expect(pausedDownload.lastError == nil) + try insertPersistedDownload( + in: container, + gid: "820", + status: .paused, + completedPageCount: 1, + pageCount: 2, + lastError: .init(code: .networkingFailed, message: "stale") + ) await manager.testingInstallActiveTask(gid: "busy", task: Task {}) let resumeResult = await manager.resume(gid: "820") @@ -453,6 +461,16 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(queueStore.gids == ["820"]) #expect(resumedDownload.displayStatus == .queued) #expect(resumedDownload.status == .queued) + #expect(resumedDownload.lastError == nil) + + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", "820") + let persistedDownload = try container.viewContext.fetch(request).first + #expect(persistedDownload?.status == DownloadStatus.paused.rawValue) + #expect(persistedDownload?.lastError != nil) } @Test From e2a4d76863bc44b843b33b218300f3ac5efafa05 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 02:27:06 +0800 Subject: [PATCH 093/614] Skip pause MO write --- .../Clients/DownloadClient+Scheduling.swift | 62 ++++++----- .../DownloadFeatureTestFactories.swift | 103 ++++++++++-------- .../DownloadManagerStorageTests.swift | 18 +-- 3 files changed, 101 insertions(+), 82 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 6a8936475..c93c0398c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -198,21 +198,24 @@ extension DownloadManager { ) async throws -> Task? { downloadErrors[gid] = nil await queueStore.remove(gid) - let initialCount = max( - download.completedPageCount, - temporaryCompletedPageCount( - gid: gid, - expectedPageCount: max(download.pageCount, 1) + let indexedDownloads = await reloadDownloadIndex() + if !indexedDownloads.contains(where: { $0.gid == gid }) { + let initialCount = max( + download.completedPageCount, + temporaryCompletedPageCount( + gid: gid, + expectedPageCount: max(download.pageCount, 1) + ) ) - ) - try await updateDownloadRecord( - gid: gid, - createIfMissing: false - ) { record in - record.status = DownloadStatus.paused.rawValue - record.completedPageCount = Int64(initialCount) - record.lastError = nil - record.lastDownloadedAt = .now + try await updateDownloadRecord( + gid: gid, + createIfMissing: false + ) { record in + record.status = DownloadStatus.paused.rawValue + record.completedPageCount = Int64(initialCount) + record.lastError = nil + record.lastDownloadedAt = .now + } } await notifyObservers() if activeGalleryID == gid { @@ -231,21 +234,24 @@ extension DownloadManager { ) async throws { downloadErrors[gid] = nil await queueStore.remove(gid) - let settledCount = max( - download.completedPageCount, - temporaryCompletedPageCount( - gid: gid, - expectedPageCount: max(download.pageCount, 1) + let indexedDownloads = await reloadDownloadIndex() + if !indexedDownloads.contains(where: { $0.gid == gid }) { + let settledCount = max( + download.completedPageCount, + temporaryCompletedPageCount( + gid: gid, + expectedPageCount: max(download.pageCount, 1) + ) ) - ) - try await updateDownloadRecord( - gid: gid, - createIfMissing: false - ) { record in - record.status = DownloadStatus.paused.rawValue - record.completedPageCount = Int64(settledCount) - record.lastError = nil - record.lastDownloadedAt = .now + try await updateDownloadRecord( + gid: gid, + createIfMissing: false + ) { record in + record.status = DownloadStatus.paused.rawValue + record.completedPageCount = Int64(settledCount) + record.lastError = nil + record.lastDownloadedAt = .now + } } } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index e51a191ad..5a572e2d0 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -162,22 +162,24 @@ extension DownloadFeatureTestCase { in container: NSPersistentContainer ) throws { let context = container.viewContext - let downloadRequest = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - let downloads = try context.fetch(downloadRequest) - for object in downloads { - context.delete(object) - } - let stateRequest = NSFetchRequest( - entityName: "GalleryStateMO" - ) - let states = try context.fetch(stateRequest) - for object in states { - context.delete(object) + try performAndWait(in: context) { + let downloadRequest = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + let downloads = try context.fetch(downloadRequest) + for object in downloads { + context.delete(object) + } + let stateRequest = NSFetchRequest( + entityName: "GalleryStateMO" + ) + let states = try context.fetch(stateRequest) + for object in states { + context.delete(object) + } + guard context.hasChanges else { return } + try context.save() } - guard context.hasChanges else { return } - try context.save() } func insertPersistedDownload( @@ -193,30 +195,32 @@ extension DownloadFeatureTestCase { pendingOperation: DownloadStartMode? = nil ) throws { let context = container.viewContext - let object = DownloadedGalleryMO(context: context) - object.gid = gid - object.host = GalleryHost.ehentai.rawValue - object.token = token - object.title = "Pause Race" - object.jpnTitle = nil - object.uploader = "Uploader" - object.category = Category.doujinshi.rawValue - object.tags = [GalleryTag]().toData() - object.pageCount = Int64(pageCount) - object.postedDate = .now - object.rating = 4 - object.onlineCoverURL = URL(string: "https://example.com/cover.jpg") - object.folderRelativePath = "\(gid) - Pause Race" - object.coverRelativePath = nil - object.status = status.rawValue - object.completedPageCount = Int64(completedPageCount) - object.lastDownloadedAt = .now - object.lastError = lastError?.toData() - object.downloadOptionsSnapshot = DownloadOptionsSnapshot().toData() - object.remoteVersionSignature = remoteVersionSignature - object.latestRemoteVersionSignature = latestRemoteVersionSignature - object.pendingOperation = pendingOperation?.rawValue - try context.save() + try performAndWait(in: context) { + let object = DownloadedGalleryMO(context: context) + object.gid = gid + object.host = GalleryHost.ehentai.rawValue + object.token = token + object.title = "Pause Race" + object.jpnTitle = nil + object.uploader = "Uploader" + object.category = Category.doujinshi.rawValue + object.tags = [GalleryTag]().toData() + object.pageCount = Int64(pageCount) + object.postedDate = .now + object.rating = 4 + object.onlineCoverURL = URL(string: "https://example.com/cover.jpg") + object.folderRelativePath = "\(gid) - Pause Race" + object.coverRelativePath = nil + object.status = status.rawValue + object.completedPageCount = Int64(completedPageCount) + object.lastDownloadedAt = .now + object.lastError = lastError?.toData() + object.downloadOptionsSnapshot = DownloadOptionsSnapshot().toData() + object.remoteVersionSignature = remoteVersionSignature + object.latestRemoteVersionSignature = latestRemoteVersionSignature + object.pendingOperation = pendingOperation?.rawValue + try context.save() + } } func insertPersistedGalleryState( @@ -227,12 +231,21 @@ extension DownloadFeatureTestCase { originalImageURLs: [Int: URL] = [:] ) throws { let context = container.viewContext - let object = GalleryStateMO(context: context) - object.gid = gid - object.previewURLs = previewURLs.toData() - object.imageURLs = imageURLs.toData() - object.originalImageURLs = originalImageURLs.toData() - try context.save() + try performAndWait(in: context) { + let object = GalleryStateMO(context: context) + object.gid = gid + object.previewURLs = previewURLs.toData() + object.imageURLs = imageURLs.toData() + object.originalImageURLs = originalImageURLs.toData() + try context.save() + } + } + + private func performAndWait( + in context: NSManagedObjectContext, + operation: @Sendable () throws -> Void + ) throws { + try context.performAndWait(operation) } } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 8a332973e..1baead094 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -422,6 +422,14 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .init(code: .networkingFailed, message: "failed"), gid: "820" ) + try insertPersistedDownload( + in: container, + gid: "820", + status: .downloading, + completedPageCount: 1, + pageCount: 2, + lastError: .init(code: .networkingFailed, message: "stale") + ) let activeTask = Task { do { try await Task.sleep(for: .seconds(60)) @@ -442,14 +450,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(pausedDownload.status == .paused) #expect(pausedDownload.lastError == nil) - try insertPersistedDownload( - in: container, - gid: "820", - status: .paused, - completedPageCount: 1, - pageCount: 2, - lastError: .init(code: .networkingFailed, message: "stale") - ) await manager.testingInstallActiveTask(gid: "busy", task: Task {}) let resumeResult = await manager.resume(gid: "820") @@ -469,7 +469,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { request.fetchLimit = 1 request.predicate = NSPredicate(format: "gid == %@", "820") let persistedDownload = try container.viewContext.fetch(request).first - #expect(persistedDownload?.status == DownloadStatus.paused.rawValue) + #expect(persistedDownload?.status == DownloadStatus.downloading.rawValue) #expect(persistedDownload?.lastError != nil) } From 41d4e0b9e77d8d7ef5a027253fe3a3a688ecf24f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 02:36:34 +0800 Subject: [PATCH 094/614] Clear indexed cancel --- .../DownloadClient+PersistenceNormalize.swift | 6 ++ .../DownloadManagerStorageTests.swift | 58 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 0d6ee9494..880a9c363 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -96,6 +96,12 @@ extension DownloadManager { || shouldClearCancellationError else { continue } + if downloadIndex[download.gid] != nil { + if shouldClearCancellationError { + downloadErrors[download.gid] = nil + } + continue + } let normalizedCompletedPageCount = max( download.completedPageCount, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 1baead094..8a5a16b9c 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -271,6 +271,64 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(failedDownload.lastError?.code == .networkingFailed) } + @Test + func testDownloadManagerReconcileClearsIndexedCancellationError() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + persistenceContainer: container + ) + let cancellationFailure = DownloadFailure( + code: .fileOperationFailed, + message: "The operation was cancelled." + ) + + try storage.ensureRootDirectory() + try insertPersistedDownload( + in: container, + gid: "410", + status: .failed, + completedPageCount: 0, + pageCount: 1, + lastError: cancellationFailure + ) + try writeIndexedManifest( + storage: storage, + relativePath: "[410_token] Cancelled", + manifest: indexedManifest( + gid: "410", + title: "Cancelled", + pageHashes: [""] + ) + ) + await manager.testingSetDownloadError( + cancellationFailure, + gid: "410" + ) + + await manager.reconcileDownloads() + + let download = try #require(await manager.fetchDownload(gid: "410")) + #expect(download.displayStatus == .inactive) + #expect(download.status == .paused) + #expect(download.lastError == nil) + + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", "410") + let persistedDownload = try container.viewContext.fetch(request).first + #expect(persistedDownload?.status == DownloadStatus.failed.rawValue) + #expect(persistedDownload?.lastError != nil) + } + @Test func testDownloadManagerFailureSettlesQueueIntent() async throws { let container = try makeInMemoryContainer() From b84be0aaad5fa2bb8fe89e7cbe1e4acaadb83626 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 02:40:13 +0800 Subject: [PATCH 095/614] Clear stale active --- .../DownloadClient+PersistenceNormalize.swift | 6 +++ .../Clients/DownloadClient+Testing.swift | 4 ++ .../DownloadManagerStorageTests.swift | 49 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 880a9c363..8bc15d800 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -143,6 +143,12 @@ extension DownloadManager { activeGalleryID: activeGalleryID, hasActiveTask: hasActiveTask ) { + if downloadIndex[download.gid] != nil { + if activeGalleryID == download.gid, !hasActiveTask { + self.activeGalleryID = nil + } + continue + } do { try await updateDownloadRecord( gid: download.gid, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 741368bb7..fa4eef4e1 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -15,6 +15,10 @@ extension DownloadManager { activeTask = task } + func testingSetActiveGalleryID(_ gid: String?) { + activeGalleryID = gid + } + func testingScheduleNextIfNeeded() async { await scheduleNextIfNeeded() } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 8a5a16b9c..4df4c0e25 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -329,6 +329,55 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(persistedDownload?.lastError != nil) } + @Test + func testDownloadManagerReconcileClearsIndexedInterruptedActiveFlag() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + persistenceContainer: container + ) + + try storage.ensureRootDirectory() + try insertPersistedDownload( + in: container, + gid: "420", + status: .downloading, + completedPageCount: 0, + pageCount: 1 + ) + try writeIndexedManifest( + storage: storage, + relativePath: "[420_token] Interrupted", + manifest: indexedManifest( + gid: "420", + title: "Interrupted", + pageHashes: [""] + ) + ) + await manager.testingSetActiveGalleryID("420") + + await manager.reconcileDownloads() + + let download = try #require(await manager.fetchDownload(gid: "420")) + #expect(download.displayStatus == .inactive) + #expect(download.status == .paused) + #expect(await manager.testingActiveGalleryID() == nil) + + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", "420") + let persistedDownload = try container.viewContext.fetch(request).first + #expect(persistedDownload?.status == DownloadStatus.downloading.rawValue) + } + @Test func testDownloadManagerFailureSettlesQueueIntent() async throws { let container = try makeInMemoryContainer() From 06ab495c52f67ff427f05e8a2ed8ddaa81fba14b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 02:43:53 +0800 Subject: [PATCH 096/614] Clear sanitize error --- .../DownloadClient+PersistenceHelpers.swift | 6 ++ .../Clients/DownloadClient+Testing.swift | 10 ++++ .../DownloadManagerStorageTests.swift | 57 +++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index 72d73b365..e8c9edbed 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -69,6 +69,12 @@ extension DownloadManager { guard updateResult.needsUpdate else { return download } + if downloadIndex[gid] != nil { + downloadErrors[gid] = updateResult.lastError + await notifyObservers() + return await fetchDownload(gid: gid) + } + do { try await updateDownloadRecord( gid: gid, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index fa4eef4e1..762fac53a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -59,6 +59,16 @@ extension DownloadManager { downloadErrors[gid] = failure } + func testingSanitizeLocalFilesIfNeeded( + gid: String, + clearingLastError: Bool = false + ) async -> DownloadedGallery? { + await sanitizeLocalFilesIfNeeded( + gid: gid, + clearingLastError: clearingLastError + ) + } + func testingSetUpdatedGalleryIDs(_ gids: Set) { updatedGalleryIDs = gids } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 4df4c0e25..235dfa6e7 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -378,6 +378,63 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(persistedDownload?.status == DownloadStatus.downloading.rawValue) } + @Test + func testDownloadManagerSanitizeClearsIndexedError() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + persistenceContainer: container + ) + let failure = DownloadFailure( + code: .fileOperationFailed, + message: "Page 1 is missing." + ) + + try storage.ensureRootDirectory() + try insertPersistedDownload( + in: container, + gid: "430", + status: .failed, + completedPageCount: 0, + pageCount: 1, + lastError: failure + ) + try writeIndexedManifest( + storage: storage, + relativePath: "[430_token] Sanitize", + manifest: indexedManifest( + gid: "430", + title: "Sanitize", + pageHashes: [""] + ) + ) + await manager.testingSetDownloadError(failure, gid: "430") + + let sanitizedDownload = await manager.testingSanitizeLocalFilesIfNeeded( + gid: "430", + clearingLastError: true + ) + + #expect(sanitizedDownload?.displayStatus == .inactive) + #expect(sanitizedDownload?.status == .paused) + #expect(sanitizedDownload?.lastError == nil) + + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", "430") + let persistedDownload = try container.viewContext.fetch(request).first + #expect(persistedDownload?.status == DownloadStatus.failed.rawValue) + #expect(persistedDownload?.lastError != nil) + } + @Test func testDownloadManagerFailureSettlesQueueIntent() async throws { let container = try makeInMemoryContainer() From 87a079e7b1a1b8d436d5cbbf8637623a2d36deaf Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 02:53:40 +0800 Subject: [PATCH 097/614] Track validation --- .../Clients/DownloadClient+Execution.swift | 2 + .../Clients/DownloadClient+Manager.swift | 1 + .../Clients/DownloadClient+Persistence.swift | 5 +- .../DownloadClient+PersistenceHelpers.swift | 3 + .../DownloadClient+PersistenceNormalize.swift | 19 ++++-- .../Clients/DownloadClient+Scheduling.swift | 3 + .../DownloadManagerCaptureTests.swift | 60 ++++++++++++++++--- .../DownloadManagerStorageTests.swift | 51 ++++++++++++++++ 8 files changed, 130 insertions(+), 14 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index dcb712596..b867f021c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -26,6 +26,7 @@ extension DownloadManager { do { downloadErrors[gid] = nil + validationErrors[gid] = nil await notifyObservers() let result = try await fetchNormalizeAndDownload( gid: gid, @@ -191,6 +192,7 @@ extension DownloadManager { func settleCompletedDownload(gid: String) async { downloadErrors[gid] = nil + validationErrors[gid] = nil updatedGalleryIDs.remove(gid) await queueStore.remove(gid) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 28fa290e0..5c3b8c297 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -138,6 +138,7 @@ actor DownloadManager { let persistenceContainer: NSPersistentContainer var downloadIndex = [String: DownloadFolderRecord]() var downloadErrors = [String: DownloadFailure]() + var validationErrors = [String: DownloadFailure]() var updatedGalleryIDs = Set() var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() var lastObservedDownloads = [DownloadedGallery]() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index b608593e2..8dd34c6a9 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -62,7 +62,7 @@ extension DownloadManager { folderRelativePath: record.relativePath, modifiedAt: record.modifiedAt, displayStatus: displayStatus(for: record), - lastError: downloadErrors[gid] + lastError: validationErrors[gid] ?? downloadErrors[gid] ) } @@ -70,6 +70,9 @@ extension DownloadManager { for record: DownloadFolderRecord ) -> DownloadDisplayStatus { let gid = record.manifest.gid + if validationErrors[gid] != nil { + return .error + } if record.manifest.isComplete, updatedGalleryIDs.contains(gid) { return .updateAvailable diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index e8c9edbed..cc870b0ab 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -71,6 +71,9 @@ extension DownloadManager { if downloadIndex[gid] != nil { downloadErrors[gid] = updateResult.lastError + if updateResult.lastError == nil { + validationErrors[gid] = nil + } await notifyObservers() return await fetchDownload(gid: gid) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 8bc15d800..ec20bdfc8 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -204,9 +204,14 @@ extension DownloadManager { private func validateDownload(_ download: DownloadedGallery) async -> DownloadValidationState { let validation = storage.validate(download: download) + let isIndexedDownload = downloadIndex[download.gid] != nil switch validation { case .valid: refreshMissingManifestHashesIfNeeded(download: download) + if isIndexedDownload { + validationErrors[download.gid] = nil + return validation + } let expectedStatus: DownloadStatus = download.hasUpdate ? .updateAvailable : .completed @@ -224,17 +229,21 @@ extension DownloadManager { } case .missingFiles(let message): + let failure = DownloadFailure( + code: .fileOperationFailed, + message: message + ) + if isIndexedDownload { + validationErrors[download.gid] = failure + return validation + } do { try await updateDownloadRecord( gid: download.gid, createIfMissing: false ) { record in record.status = DownloadStatus.missingFiles.rawValue - record.lastError = DownloadFailure( - code: .fileOperationFailed, - message: message - ) - .toData() + record.lastError = failure.toData() } } catch { Logger.error(error) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index c93c0398c..e2b71a4c7 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -197,6 +197,7 @@ extension DownloadManager { download: DownloadedGallery ) async throws -> Task? { downloadErrors[gid] = nil + validationErrors[gid] = nil await queueStore.remove(gid) let indexedDownloads = await reloadDownloadIndex() if !indexedDownloads.contains(where: { $0.gid == gid }) { @@ -233,6 +234,7 @@ extension DownloadManager { download: DownloadedGallery ) async throws { downloadErrors[gid] = nil + validationErrors[gid] = nil await queueStore.remove(gid) let indexedDownloads = await reloadDownloadIndex() if !indexedDownloads.contains(where: { $0.gid == gid }) { @@ -297,6 +299,7 @@ extension DownloadManager { do { downloadErrors[gid] = nil + validationErrors[gid] = nil await queueStore.enqueue(gid) let indexedDownloads = await reloadDownloadIndex() if indexedDownloads.contains(where: { $0.gid == gid }) { diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index e96fa9d97..8e51cfb02 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -64,7 +64,13 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { #expect(stored?.completedPageCount == 1) let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - #expect(pageURLs[1] == temporaryFolderURL.appendingPathComponent("pages/0001.jpg")) + let pageRelativePath = storage.makePageRelativePath( + gid: gid, + token: "token", + index: 1, + fileExtension: "jpg" + ) + #expect(pageURLs[1] == temporaryFolderURL.appendingPathComponent(pageRelativePath)) } @MainActor @@ -100,7 +106,13 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { #expect(stored?.status == .completed) #expect(stored?.completedPageCount == 2) #expect(stored?.lastError == nil) - #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("pages/0001.jpg")) + let pageRelativePath = storage.makePageRelativePath( + gid: gid, + token: "token", + index: 1, + fileExtension: "jpg" + ) + #expect(pageURLs[1] == completedFolderURL.appendingPathComponent(pageRelativePath)) } } @@ -114,16 +126,48 @@ private extension DownloadManagerCaptureTests { at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - let manifest = try sampleManifest(gid: gid, title: "Pause Race") - try JSONEncoder().encode(manifest).write( - to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) try Data([0x00]).write( to: completedFolderURL.appendingPathComponent("cover.jpg"), options: .atomic ) + let page2RelativePath = "\(gid)_token_2.jpg" + let page2URL = completedFolderURL.appendingPathComponent(page2RelativePath) try Data([0x02]).write( - to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), options: .atomic + to: page2URL, options: .atomic + ) + let manifest = DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: "Pause Race", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: .now, + pageCount: 2, + coverRelativePath: "cover.jpg", + galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), + rating: 4, + downloadOptions: DownloadOptionsSnapshot(), + versionSignature: "hash:v1", + downloadedAt: .now, + pages: [ + .init( + index: 1, + relativePath: "\(gid)_token_1.jpg", + fileHash: "sha256:missing" + ), + .init( + index: 2, + relativePath: page2RelativePath, + fileHash: try DownloadFileStorage().fileHash(at: page2URL) + ) + ] + ) + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic ) return completedFolderURL } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 235dfa6e7..75705ff65 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -435,6 +435,57 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(persistedDownload?.lastError != nil) } + @Test + func testDownloadManagerValidateIndexedMissingFileUsesSessionError() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + persistenceContainer: container + ) + + try storage.ensureRootDirectory() + try insertPersistedDownload( + in: container, + gid: "440", + status: .completed, + completedPageCount: 1, + pageCount: 1 + ) + try writeIndexedManifest( + storage: storage, + relativePath: "[440_token] Missing", + manifest: indexedManifest( + gid: "440", + title: "Missing", + pageHashes: ["sha256:missing"] + ) + ) + + let validation = await manager.validateImageData(gid: "440") + + #expect(validation == .missingFiles("Page 1 is missing.")) + let download = try #require(await manager.fetchDownload(gid: "440")) + #expect(download.displayStatus == .error) + #expect(download.status == .failed) + #expect(download.lastError?.code == .fileOperationFailed) + #expect(download.badge == .failed) + + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", "440") + let persistedDownload = try container.viewContext.fetch(request).first + #expect(persistedDownload?.status == DownloadStatus.completed.rawValue) + #expect(persistedDownload?.lastError == nil) + } + @Test func testDownloadManagerFailureSettlesQueueIntent() async throws { let container = try makeInMemoryContainer() From b62b9373557f65dd9ad95334a275436efbfb05d6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 03:02:28 +0800 Subject: [PATCH 098/614] Queue indexed retry --- .../Clients/DownloadClient+Persistence.swift | 12 ++-- .../Clients/DownloadClient+RetryHelpers.swift | 16 +++++ .../DownloadManagerStorageTests.swift | 63 +++++++++++++++++++ .../Download/DownloadRetryPagesTests.swift | 5 -- 4 files changed, 85 insertions(+), 11 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 8dd34c6a9..dfabcca1c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -73,6 +73,12 @@ extension DownloadManager { if validationErrors[gid] != nil { return .error } + if activeGalleryID == gid { + return .active + } + if queueStore.contains(gid) { + return .queued + } if record.manifest.isComplete, updatedGalleryIDs.contains(gid) { return .updateAvailable @@ -80,12 +86,6 @@ extension DownloadManager { if record.manifest.isComplete { return .completed } - if activeGalleryID == gid { - return .active - } - if queueStore.contains(gid) { - return .queued - } if downloadErrors[gid] != nil { return .error } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index 8cfe37482..aabf6d016 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -49,6 +49,22 @@ extension DownloadManager { if !retryParams.shouldResumeExistingWork { try? storage.removeTemporaryFolder(gid: gid) } + if downloadIndex[gid] != nil { + downloadErrors[gid] = nil + validationErrors[gid] = nil + await queueStore.enqueue(gid) + if fileManager.operate({ $0.fileExists(atPath: temporaryFolderURL.path) }) { + writeRetryResumeState( + download: download, + resolvedMode: resolvedMode, + existingResumeState: existingResumeState, + temporaryFolderURL: temporaryFolderURL + ) + } + await notifyObservers() + await scheduleNextIfNeeded() + return + } try await updateDownloadRecord( gid: gid, createIfMissing: false ) { record in diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 75705ff65..3b247fecc 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -486,6 +486,69 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(persistedDownload?.lastError == nil) } + @Test + func testDownloadManagerRetryIndexedDownloadUsesQueueIntent() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + queueStore: queueStore, + persistenceContainer: container + ) + + try storage.ensureRootDirectory() + try insertPersistedDownload( + in: container, + gid: "450", + status: .completed, + completedPageCount: 1, + pageCount: 1 + ) + try writeIndexedManifest( + storage: storage, + relativePath: "[450_token] Retry", + manifest: indexedManifest( + gid: "450", + title: "Retry", + pageHashes: ["sha256:done"] + ) + ) + let blockingTask = Task { + do { + try await Task.sleep(for: .seconds(60)) + } catch {} + } + defer { blockingTask.cancel() } + await manager.testingInstallActiveTask(gid: "busy", task: blockingTask) + + let result = await manager.retry(gid: "450", mode: .redownload) + + guard case .success = result else { + Issue.record("Retry should succeed, got \(result).") + return + } + let download = try #require(await manager.fetchDownload(gid: "450")) + #expect(queueStore.gids == ["450"]) + #expect(download.displayStatus == .queued) + #expect(download.status == .queued) + #expect(download.pendingOperation == nil) + + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", "450") + let persistedDownload = try container.viewContext.fetch(request).first + #expect(persistedDownload?.status == DownloadStatus.completed.rawValue) + #expect(persistedDownload?.pendingOperation == nil) + } + @Test func testDownloadManagerFailureSettlesQueueIntent() async throws { let container = try makeInMemoryContainer() diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index 9682fb14d..d11292798 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -82,11 +82,6 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - let manifest = try sampleManifest(gid: gid, title: "Pause Race") - try JSONEncoder().encode(manifest).write( - to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) try Data([0x00]).write( to: completedFolderURL.appendingPathComponent("cover.jpg"), options: .atomic From 1c4e526b926b208cc6475bbdde97e65d2a5fbcf9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 03:07:11 +0800 Subject: [PATCH 099/614] Queue retry pages --- .../Clients/DownloadClient+RetryHelpers.swift | 8 ++ .../DownloadManagerStorageTests.swift | 87 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index aabf6d016..72a701948 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -156,6 +156,14 @@ extension DownloadManager { ), folderURL: temporaryFolderURL ) + if downloadIndex[gid] != nil { + downloadErrors[gid] = nil + validationErrors[gid] = nil + await queueStore.enqueue(gid) + await notifyObservers() + await scheduleNextIfNeeded() + return + } try await updateDownloadRecord( gid: gid, createIfMissing: false ) { record in diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 3b247fecc..44024a57f 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -549,6 +549,93 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(persistedDownload?.pendingOperation == nil) } + @Test + func testDownloadManagerRetryPagesIndexedDownloadUsesQueueIntent() async throws { + let container = try makeInMemoryContainer() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + queueStore: queueStore, + persistenceContainer: container + ) + + try storage.ensureRootDirectory() + try insertPersistedDownload( + in: container, + gid: "460", + status: .missingFiles, + completedPageCount: 1, + pageCount: 2, + pendingOperation: .repair + ) + try writeIndexedManifest( + storage: storage, + relativePath: "[460_token] Retry Pages", + manifest: indexedManifest( + gid: "460", + title: "Retry Pages", + pageHashes: ["sha256:done", ""] + ) + ) + let temporaryFolderURL = storage.temporaryFolderURL(gid: "460") + try FileManager.default.createDirectory( + at: temporaryFolderURL, + withIntermediateDirectories: true + ) + try storage.writeFailedPages( + .init(pages: [ + .init( + index: 2, + relativePath: "460_token_2.jpg", + failure: .init( + code: .networkingFailed, + message: "Network Error" + ) + ) + ]), + folderURL: temporaryFolderURL + ) + let blockingTask = Task { + do { + try await Task.sleep(for: .seconds(60)) + } catch {} + } + defer { blockingTask.cancel() } + await manager.testingInstallActiveTask(gid: "busy", task: blockingTask) + + let result = await manager.retryPages(gid: "460", pageIndices: [2]) + + guard case .success = result else { + Issue.record("Retry pages should succeed, got \(result).") + return + } + let download = try #require(await manager.fetchDownload(gid: "460")) + let resumeState = try storage.readResumeState(folderURL: temporaryFolderURL) + #expect(queueStore.gids == ["460"]) + #expect(download.displayStatus == .queued) + #expect(download.status == .queued) + #expect(download.pendingOperation == nil) + #expect(resumeState.pageSelection == [2]) + #expect(FileManager.default.fileExists( + atPath: storage.failedPagesURL(folderURL: temporaryFolderURL).path + ) == false) + + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", "460") + let persistedDownload = try container.viewContext.fetch(request).first + #expect(persistedDownload?.status == DownloadStatus.missingFiles.rawValue) + #expect(persistedDownload?.pendingOperation == DownloadStartMode.repair.rawValue) + } + @Test func testDownloadManagerFailureSettlesQueueIntent() async throws { let container = try makeInMemoryContainer() From b246e2d79239591fb9c971fa7ed7a0a2a072a8aa Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 03:15:19 +0800 Subject: [PATCH 100/614] Add version metadata --- .../Clients/DownloadClient+PublicAPI.swift | 30 +++++ .../App/Tools/Clients/DownloadClient.swift | 9 ++ .../DownloadedGallery+Extensions.swift | 4 + EhPanda/View/Detail/DetailReducer+Fetch.swift | 15 +-- .../Download/DetailReducerMetadataTests.swift | 6 +- .../DetailReducerMetadataUpdateTests.swift | 3 +- .../DownloadVersionSignatureTests.swift | 111 ++++++++++++++++++ 7 files changed, 164 insertions(+), 14 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index ae5cbd03d..05b1b68e9 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -52,6 +52,36 @@ extension DownloadManager { let canonicalizedSignature: String? } + func updateRemoteVersion( + gid: String, + metadata: DownloadVersionMetadata + ) async -> DownloadBadge { + guard let download = await fetchDownload(gid: gid) else { + return .none + } + guard downloadIndex[gid] != nil else { + return await updateRemoteSignature( + gid: gid, + latestSignature: metadata.versionIdentifier + ) + } + guard [.completed, .updateAvailable].contains(download.status) else { + return download.badge + } + + let hadUpdate = updatedGalleryIDs.contains(gid) + let hasUpdate = metadata.hasUpdate(comparedTo: download) + if hasUpdate { + updatedGalleryIDs.insert(gid) + } else { + updatedGalleryIDs.remove(gid) + } + if hadUpdate != hasUpdate { + await notifyObservers() + } + return (await fetchDownload(gid: gid))?.badge ?? .none + } + func updateRemoteSignature( gid: String, latestSignature: String? diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 9352db055..e8f14c87b 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -16,6 +16,7 @@ struct DownloadClient: Sendable { let resumeQueue: @Sendable () async -> Void let badges: @Sendable ([String]) async -> [String: DownloadBadge] let fetchVersionMetadata: @Sendable (String, String) async -> Result + let updateRemoteVersion: @Sendable (String, DownloadVersionMetadata) async -> DownloadBadge let updateRemoteSignature: @Sendable (String, String?) async -> DownloadBadge let enqueue: @Sendable (DownloadRequestPayload) async -> Result let togglePause: @Sendable (String) async -> Result @@ -38,6 +39,8 @@ struct DownloadClient: Sendable { badges: @escaping @Sendable ([String]) async -> [String: DownloadBadge], fetchVersionMetadata: @escaping @Sendable (String, String) async -> Result = { _, _ in .failure(.notFound) }, + updateRemoteVersion: @escaping @Sendable (String, DownloadVersionMetadata) async -> DownloadBadge = + { _, _ in .none }, updateRemoteSignature: @escaping @Sendable (String, String?) async -> DownloadBadge, enqueue: @escaping @Sendable (DownloadRequestPayload) async -> Result, togglePause: @escaping @Sendable (String) async -> Result, @@ -64,6 +67,7 @@ struct DownloadClient: Sendable { self.resumeQueue = resumeQueue self.badges = badges self.fetchVersionMetadata = fetchVersionMetadata + self.updateRemoteVersion = updateRemoteVersion self.updateRemoteSignature = updateRemoteSignature self.enqueue = enqueue self.togglePause = togglePause @@ -126,6 +130,9 @@ extension DownloadClient { fetchVersionMetadata: { gid, token in await manager.fetchVersionMetadata(gid: gid, token: token) }, + updateRemoteVersion: { gid, metadata in + await manager.updateRemoteVersion(gid: gid, metadata: metadata) + }, updateRemoteSignature: { gid, signature in await manager.updateRemoteSignature(gid: gid, latestSignature: signature) }, @@ -177,6 +184,7 @@ extension DownloadClient { resumeQueue: {}, badges: { _ in [:] }, fetchVersionMetadata: { _, _ in .failure(.notFound) }, + updateRemoteVersion: { _, _ in .none }, updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, @@ -201,6 +209,7 @@ extension DownloadClient { resumeQueue: IssueReporting.unimplemented(placeholder: placeholder()), badges: IssueReporting.unimplemented(placeholder: placeholder()), fetchVersionMetadata: IssueReporting.unimplemented(placeholder: placeholder()), + updateRemoteVersion: IssueReporting.unimplemented(placeholder: placeholder()), updateRemoteSignature: IssueReporting.unimplemented(placeholder: placeholder()), enqueue: IssueReporting.unimplemented(placeholder: placeholder()), togglePause: IssueReporting.unimplemented(placeholder: placeholder()), diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift index ca10cfb63..bd0c76124 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -142,6 +142,10 @@ struct DownloadVersionMetadata: Equatable, Codable, Sendable { ) } + func hasUpdate(comparedTo download: DownloadedGallery) -> Bool { + (download.gid, download.token) != (resolvedCurrentGID, resolvedCurrentKey) + } + private var resolvedCurrentGID: String { currentGID?.nonEmpty ?? gid } diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/EhPanda/View/Detail/DetailReducer+Fetch.swift index 7ac58ffb0..c306e1575 100644 --- a/EhPanda/View/Detail/DetailReducer+Fetch.swift +++ b/EhPanda/View/Detail/DetailReducer+Fetch.swift @@ -137,12 +137,12 @@ extension DetailReducer { private func handleFetchVersionMetadataIfNeeded(state: inout State) -> Effect { guard state.shouldCheckForRemoteUpdates, !state.didRequestVersionMetadata, - let detail = state.galleryDetail + state.galleryDetail != nil else { return .none } state.didRequestVersionMetadata = true - return .run { [gallery = state.gallery, previewURLs = state.galleryPreviewURLs, detail] send in + return .run { [gallery = state.gallery] send in let metadata: DownloadVersionMetadata? switch await downloadClient.fetchVersionMetadata(gallery.gid, gallery.token) { case .success(let fetchedMetadata): @@ -152,16 +152,9 @@ extension DetailReducer { } await send(.fetchVersionMetadataDone(.success(metadata))) guard let metadata else { return } - let latestSignature = DownloadSignatureBuilder.make( - gallery: gallery, - detail: detail, - host: AppUtil.galleryHost, - previewURLs: previewURLs, - versionMetadata: metadata - ) - let badge = await downloadClient.updateRemoteSignature( + let badge = await downloadClient.updateRemoteVersion( gallery.gid, - latestSignature + metadata ) await send(.fetchDownloadBadgeDone(badge)) } diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift index 60cffdcb3..4921a330e 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -134,10 +134,11 @@ private extension DetailReducerMetadataTests { fetchVersionMetadata: { _, _ in .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) }, - updateRemoteSignature: { _, _ in + updateRemoteVersion: { _, _ in updateCheckCount.value += 1 return .none }, + updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, @@ -172,10 +173,11 @@ private extension DetailReducerMetadataTests { fetchVersionMetadata: { _, _ in .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) }, - updateRemoteSignature: { _, _ in + updateRemoteVersion: { _, _ in updateCheckCount.value += 1 return .downloaded }, + updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index 953d141e6..987f1bfc2 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -133,10 +133,11 @@ private extension DetailReducerMetadataUpdateTests { fetchVersionMetadata: { _, _ in .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) }, - updateRemoteSignature: { _, _ in + updateRemoteVersion: { _, _ in updateCheckCount.value += 1 return .downloaded }, + updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 4a9b7ba31..635e4c74c 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -156,4 +156,115 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { #expect(stored?.latestRemoteVersionSignature == "chain:\(gid):token") } + @MainActor + @Test + func testUpdateRemoteVersionUsesIndexedSessionFlag() async throws { + let container = try makeInMemoryContainer() + + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 104) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + persistenceContainer: container + ) + try insertPersistedDownload( + in: container, + gid: gid, + status: .completed, + completedPageCount: 1, + pageCount: 1, + token: "token", + remoteVersionSignature: "hash:old", + latestRemoteVersionSignature: "hash:old" + ) + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Indexed") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: "Indexed", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: .now, + pageCount: 1, + coverRelativePath: nil, + galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), + rating: 4, + downloadOptions: DownloadOptionsSnapshot(), + versionSignature: "hash:old", + downloadedAt: .now, + pages: [ + .init( + index: 1, + relativePath: "\(gid)_token_1.jpg", + fileHash: "sha256:done" + ) + ] + ), + folderURL: folderURL + ) + + let updateBadge = await manager.updateRemoteVersion( + gid: gid, + metadata: DownloadVersionMetadata( + gid: gid, + token: "token", + currentGID: gid, + currentKey: "new-token", + parentGID: gid, + parentKey: "token", + firstGID: gid, + firstKey: "token" + ) + ) + let updatedDownload = await manager.testingFetchDownload(gid: gid) + + #expect(updateBadge == .updateAvailable) + #expect(updatedDownload?.displayStatus == .updateAvailable) + #expect(updatedDownload?.status == .updateAvailable) + + let currentBadge = await manager.updateRemoteVersion( + gid: gid, + metadata: DownloadVersionMetadata( + gid: gid, + token: "token", + currentGID: gid, + currentKey: "token", + parentGID: gid, + parentKey: "token", + firstGID: gid, + firstKey: "token" + ) + ) + let currentDownload = await manager.testingFetchDownload(gid: gid) + + #expect(currentBadge == .downloaded) + #expect(currentDownload?.displayStatus == .completed) + #expect(currentDownload?.status == .completed) + + let request = NSFetchRequest( + entityName: "DownloadedGalleryMO" + ) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "gid == %@", gid) + let persistedDownload = try container.viewContext.fetch(request).first + #expect(persistedDownload?.status == DownloadStatus.completed.rawValue) + #expect(persistedDownload?.remoteVersionSignature == "hash:old") + #expect(persistedDownload?.latestRemoteVersionSignature == "hash:old") + } + } From 457d13914553d0d8420bffee0634927bda968637 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 03:20:44 +0800 Subject: [PATCH 101/614] Drop sig API --- EhPanda/App/Tools/Clients/DownloadClient.swift | 8 -------- .../Tests/Download/DetailReducerDownloadTests.swift | 1 - .../Tests/Download/DetailReducerMetadataTests.swift | 2 -- .../Tests/Download/DetailReducerMetadataUpdateTests.swift | 2 -- .../Tests/Download/DetailReducerObserveTests.swift | 3 --- .../Tests/Download/DetailReducerPauseAndGuardTests.swift | 2 -- .../Tests/Download/DownloadInspectorLoadTests.swift | 1 - .../Tests/Download/DownloadInspectorRetryTests.swift | 1 - .../Tests/Download/DownloadInspectorSkipTests.swift | 1 - .../Tests/Download/DownloadObserverBatchTests.swift | 1 - .../Tests/Download/DownloadObserverReadingTests.swift | 2 -- .../Tests/Download/DownloadObserverRefreshTests.swift | 1 - .../Tests/Download/DownloadsReducerActionTests.swift | 4 ---- .../Tests/Download/DownloadsReducerRefreshTests.swift | 3 --- .../Tests/Download/PreviewsReducerDownloadTests.swift | 2 -- .../Tests/Download/ReadingReducerDownloadTests.swift | 2 -- .../Tests/Download/ReadingReducerLocalTests.swift | 1 - 17 files changed, 37 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index e8f14c87b..bde52e359 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -17,7 +17,6 @@ struct DownloadClient: Sendable { let badges: @Sendable ([String]) async -> [String: DownloadBadge] let fetchVersionMetadata: @Sendable (String, String) async -> Result let updateRemoteVersion: @Sendable (String, DownloadVersionMetadata) async -> DownloadBadge - let updateRemoteSignature: @Sendable (String, String?) async -> DownloadBadge let enqueue: @Sendable (DownloadRequestPayload) async -> Result let togglePause: @Sendable (String) async -> Result let retry: @Sendable (String, DownloadStartMode) async -> Result @@ -41,7 +40,6 @@ struct DownloadClient: Sendable { = { _, _ in .failure(.notFound) }, updateRemoteVersion: @escaping @Sendable (String, DownloadVersionMetadata) async -> DownloadBadge = { _, _ in .none }, - updateRemoteSignature: @escaping @Sendable (String, String?) async -> DownloadBadge, enqueue: @escaping @Sendable (DownloadRequestPayload) async -> Result, togglePause: @escaping @Sendable (String) async -> Result, retry: @escaping @Sendable (String, DownloadStartMode) async -> Result, @@ -68,7 +66,6 @@ struct DownloadClient: Sendable { self.badges = badges self.fetchVersionMetadata = fetchVersionMetadata self.updateRemoteVersion = updateRemoteVersion - self.updateRemoteSignature = updateRemoteSignature self.enqueue = enqueue self.togglePause = togglePause self.retry = retry @@ -133,9 +130,6 @@ extension DownloadClient { updateRemoteVersion: { gid, metadata in await manager.updateRemoteVersion(gid: gid, metadata: metadata) }, - updateRemoteSignature: { gid, signature in - await manager.updateRemoteSignature(gid: gid, latestSignature: signature) - }, enqueue: { payload in await manager.enqueue(payload: payload) }, togglePause: { gid in await manager.togglePause(gid: gid) }, retry: { gid, mode in await manager.retry(gid: gid, mode: mode) }, @@ -185,7 +179,6 @@ extension DownloadClient { badges: { _ in [:] }, fetchVersionMetadata: { _, _ in .failure(.notFound) }, updateRemoteVersion: { _, _ in .none }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, @@ -210,7 +203,6 @@ extension DownloadClient { badges: IssueReporting.unimplemented(placeholder: placeholder()), fetchVersionMetadata: IssueReporting.unimplemented(placeholder: placeholder()), updateRemoteVersion: IssueReporting.unimplemented(placeholder: placeholder()), - updateRemoteSignature: IssueReporting.unimplemented(placeholder: placeholder()), enqueue: IssueReporting.unimplemented(placeholder: placeholder()), togglePause: IssueReporting.unimplemented(placeholder: placeholder()), retry: IssueReporting.unimplemented(placeholder: placeholder()), diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index 095ec1a7d..98478f01f 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -150,7 +150,6 @@ private extension DetailReducerDownloadTests { badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, badgeValue) }) }, - updateRemoteSignature: { _, _ in .none }, enqueue: enqueue, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift index 4921a330e..80b880925 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -138,7 +138,6 @@ private extension DetailReducerMetadataTests { updateCheckCount.value += 1 return .none }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, @@ -177,7 +176,6 @@ private extension DetailReducerMetadataTests { updateCheckCount.value += 1 return .downloaded }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index 987f1bfc2..84f1639c4 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -137,7 +137,6 @@ private extension DetailReducerMetadataUpdateTests { updateCheckCount.value += 1 return .downloaded }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, @@ -161,7 +160,6 @@ private extension DetailReducerMetadataUpdateTests { refreshDownloads: {}, resumeQueue: {}, badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift index 5c1087b6c..e7ae30f26 100644 --- a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift @@ -145,7 +145,6 @@ private extension DetailReducerObserveTests { refreshDownloads: {}, resumeQueue: {}, badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, @@ -170,7 +169,6 @@ private extension DetailReducerObserveTests { refreshDownloads: {}, resumeQueue: {}, badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, .downloaded) }) }, - updateRemoteSignature: { _, _ in .downloaded }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, @@ -191,7 +189,6 @@ private extension DetailReducerObserveTests { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index 18e8452d6..f395b3376 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -71,7 +71,6 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in enqueueCount.value += 1 return .success(()) @@ -146,7 +145,6 @@ private extension DetailReducerPauseAndGuardTests { badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, .paused(7, 26)) }) }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in togglePauseCount.value += 1 diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift index 3220de698..a8d9a9b62 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -351,7 +351,6 @@ private extension DownloadInspectorLoadTests { validateImageData: validateImageData ?? { _ in nil }, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: togglePause ?? { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift index c7a43ba38..5890c3f19 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -141,7 +141,6 @@ private extension DownloadInspectorRetryTests { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift index 252df6b0f..bc5d6dc98 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift @@ -40,7 +40,6 @@ struct DownloadInspectorSkipTests: DownloadFeatureTestCase { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index d3cb687f0..0a92be2f4 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -41,7 +41,6 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index a8e5088a6..966d12a5a 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -161,7 +161,6 @@ private extension DownloadObserverReadingTests { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, @@ -197,7 +196,6 @@ private extension DownloadObserverReadingTests { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift index 8771342cc..6efce3ad0 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -147,7 +147,6 @@ private extension DownloadObserverRefreshTests { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift index 520c936f8..19f96c867 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -76,7 +76,6 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { gid, mode in @@ -123,7 +122,6 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, @@ -174,7 +172,6 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, @@ -220,7 +217,6 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { gid in toggled.value.append(gid) diff --git a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift index 2be66a278..b6abe2d2f 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift @@ -40,7 +40,6 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .failure(.networkingFailed) }, retry: { _, _ in .success(()) }, @@ -81,7 +80,6 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { }, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, @@ -122,7 +120,6 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { }, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift index fda6ed382..70c1fac5e 100644 --- a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -100,7 +100,6 @@ private extension PreviewsReducerDownloadTests { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, @@ -130,7 +129,6 @@ private extension PreviewsReducerDownloadTests { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift index 44a85dc33..b13df0f43 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -140,7 +140,6 @@ private extension ReadingReducerDownloadTests { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, @@ -177,7 +176,6 @@ private extension ReadingReducerDownloadTests { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift index 1c67d98aa..568cd1b8a 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -40,7 +40,6 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, - updateRemoteSignature: { _, _ in .none }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, From d23d26408c6e05d5116ee54fa9320fe34815c7c2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 03:24:25 +0800 Subject: [PATCH 102/614] Drop version id --- .../App/Tools/Clients/DownloadClient+PublicAPI.swift | 5 ++++- .../Persistent/DownloadedGallery+Extensions.swift | 11 ++--------- .../DownloadedGallery+SignatureBuilder.swift | 8 ++++++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 05b1b68e9..4f5e07d6c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -62,7 +62,10 @@ extension DownloadManager { guard downloadIndex[gid] != nil else { return await updateRemoteSignature( gid: gid, - latestSignature: metadata.versionIdentifier + latestSignature: DownloadSignatureBuilder.chainVersionIdentifier( + gid: metadata.resolvedCurrentGID, + token: metadata.resolvedCurrentKey + ) ) } guard [.completed, .updateAvailable].contains(download.status) else { diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift index bd0c76124..9442d09f5 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -135,22 +135,15 @@ struct DownloadVersionMetadata: Equatable, Codable, Sendable { let firstGID: String? let firstKey: String? - var versionIdentifier: String? { - DownloadSignatureBuilder.chainVersionIdentifier( - gid: resolvedCurrentGID, - token: resolvedCurrentKey - ) - } - func hasUpdate(comparedTo download: DownloadedGallery) -> Bool { (download.gid, download.token) != (resolvedCurrentGID, resolvedCurrentKey) } - private var resolvedCurrentGID: String { + var resolvedCurrentGID: String { currentGID?.nonEmpty ?? gid } - private var resolvedCurrentKey: String { + var resolvedCurrentKey: String { currentKey?.nonEmpty ?? token } } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift b/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift index c114e7e0e..aac5bc03f 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift @@ -25,8 +25,12 @@ enum DownloadSignatureBuilder { previewURLs: [Int: URL], versionMetadata: DownloadVersionMetadata? = nil ) -> String { - if let versionIdentifier = versionMetadata?.versionIdentifier { - return versionIdentifier + if let versionMetadata, + let chainSignature = chainVersionIdentifier( + gid: versionMetadata.resolvedCurrentGID, + token: versionMetadata.resolvedCurrentKey + ) { + return chainSignature } let previewHash = SHA256.hash( From 9eae73456cf1bf703f2058b3027dacbb9bf6cc9c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 03:30:45 +0800 Subject: [PATCH 103/614] Drop sig hash --- .../DownloadClient+ExecutionFetch.swift | 7 +- .../Clients/DownloadClient+PublicAPI.swift | 7 +- .../DownloadClient+PublicAPIHelpers.swift | 10 + .../DownloadedGallery+SignatureBuilder.swift | 59 ----- .../DownloadEnqueueManifestTests.swift | 1 + .../DownloadSignatureBuilderTests.swift | 147 +++-------- .../DownloadSignaturePreviewTests.swift | 249 ------------------ 7 files changed, 47 insertions(+), 433 deletions(-) delete mode 100644 EhPandaTests/Tests/Download/DownloadSignaturePreviewTests.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index 3df9fbc75..fbad5f357 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -67,11 +67,8 @@ extension DownloadManager { let download = fetchedData.download let detail = fetchedData.detail let versionMetadata = fetchedData.versionMetadata - let versionSignature = DownloadSignatureBuilder.make( - gallery: components.gallery, - detail: detail, - host: download.host, - previewURLs: components.previewURLs, + let versionSignature = manifestVersionSignature( + for: components.gallery, versionMetadata: versionMetadata ) return FetchLatestPayloadResult( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 4f5e07d6c..542737cf8 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -179,11 +179,8 @@ extension DownloadManager { ) async -> Result { do { try storage.ensureRootDirectory() - let versionSignature = DownloadSignatureBuilder.make( - gallery: payload.gallery, - detail: payload.galleryDetail, - host: payload.host, - previewURLs: payload.previewURLs, + let versionSignature = manifestVersionSignature( + for: payload.gallery, versionMetadata: payload.versionMetadata ) let folderRelativePath = folderRelativePath(for: payload) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index 251923ac9..b9eee346b 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -8,6 +8,16 @@ import Foundation // MARK: - Private helpers for public API extension DownloadManager { + func manifestVersionSignature( + for gallery: Gallery, + versionMetadata: DownloadVersionMetadata? + ) -> String { + DownloadSignatureBuilder.chainVersionIdentifier( + gid: versionMetadata?.resolvedCurrentGID ?? gallery.gid, + token: versionMetadata?.resolvedCurrentKey ?? gallery.token + ) ?? "" + } + func buildInspectionPages( download: DownloadedGallery, activeFolderURL: URL?, diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift b/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift index aac5bc03f..91c0ba5fe 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift @@ -3,9 +3,6 @@ // EhPanda // -import Foundation -import CryptoKit - enum DownloadSignatureBuilder { enum SignatureKind: Equatable { case chain(gid: String, token: String) @@ -18,48 +15,6 @@ enum DownloadSignatureBuilder { case incomparable } - static func make( - gallery: Gallery, - detail: GalleryDetail, - host _: GalleryHost, - previewURLs: [Int: URL], - versionMetadata: DownloadVersionMetadata? = nil - ) -> String { - if let versionMetadata, - let chainSignature = chainVersionIdentifier( - gid: versionMetadata.resolvedCurrentGID, - token: versionMetadata.resolvedCurrentKey - ) { - return chainSignature - } - - let previewHash = SHA256.hash( - data: previewURLs - .sorted(by: { $0.key < $1.key }) - .map { "\($0.key)=\(normalizedPreviewSignatureValue(url: $0.value))" } - .joined(separator: "|") - .data(using: .utf8) ?? Data() - ) - - let payload = [ - gallery.gid, - gallery.token, - gallery.title, - detail.jpnTitle ?? "", - String(detail.pageCount), - normalizedCoverSignatureValue(url: detail.coverURL ?? gallery.coverURL), - detail.formattedDateString, - previewHash.compactMap { String(format: "%02x", $0) }.joined() - ] - .joined(separator: "::") - - let digest = SHA256.hash( - data: payload.data(using: String.Encoding.utf8) ?? Data() - ) - let hash = digest.compactMap { String(format: "%02x", $0) }.joined() - return "hash:\(hash)" - } - static func chainVersionIdentifier(gid: String, token: String) -> String? { guard !gid.isEmpty, !token.isEmpty else { return nil } return "chain:\(gid):\(token)" @@ -146,18 +101,4 @@ enum DownloadSignatureBuilder { ) } - private static func normalizedPreviewSignatureValue(url: URL) -> String { - let lastPathComponent = url.lastPathComponent - guard !lastPathComponent.isEmpty else { - return normalizedCoverSignatureValue(url: url) - } - return lastPathComponent - } - - private static func normalizedCoverSignatureValue(url: URL?) -> String { - guard let url else { return "" } - let stablePathComponents = url.pathComponents - .filter { $0 != "/" && !$0.isEmpty } - return stablePathComponents.joined(separator: "/") - } } diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index 3c79a9060..539f9f054 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -61,6 +61,7 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { #expect(manifest.pages.count == detail.pageCount) #expect(manifest.pages.first?.relativePath == "\(gallery.gid)_\(gallery.token)_1.pending") #expect(manifest.downloadOptions.threadLimit == 3) + #expect(manifest.versionSignature == "chain:\(gallery.gid):\(gallery.token)") let request = NSFetchRequest( entityName: "DownloadedGalleryMO" diff --git a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift index 6fa2071f7..46e3d831a 100644 --- a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift @@ -4,69 +4,33 @@ // import Testing -import Foundation @testable import EhPanda struct DownloadSignatureBuilderTests { @Test - func testVersionIdentifierPrefersGalleryChainMetadata() throws { - let signature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" - )) - ], - versionMetadata: .init( - gid: "1394965", - token: "56c35114b6", - currentGID: "2000000", - currentKey: "new-chain-key", - parentGID: "1394965", - parentKey: "56c35114b6", - firstGID: "1394965", - firstKey: "56c35114b6" - ) + func testChainVersionIdentifierBuildsChainSignature() { + #expect( + DownloadSignatureBuilder.chainVersionIdentifier( + gid: sampleGID, + token: sampleToken + ) == "chain:\(sampleGID):\(sampleToken)" ) - - #expect(signature == "chain:2000000:new-chain-key") } @Test - func testVersionIdentifierFallsBackToOriginalGalleryIdentityWhenCurrentChainFieldsAreMissing() { - let signature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [:], - versionMetadata: .init( - gid: sampleGallery.gid, - token: sampleGallery.token, - currentGID: nil, - currentKey: nil, - parentGID: nil, - parentKey: nil, - firstGID: nil, - firstKey: nil - ) + func testChainVersionIdentifierRejectsEmptyIdentity() { + #expect( + DownloadSignatureBuilder.chainVersionIdentifier( + gid: "", + token: sampleToken + ) == nil ) - - #expect(signature == "chain:\(sampleGallery.gid):\(sampleGallery.token)") - } - - @Test - func testMakeReturnsHashPrefixedFallbackSignature() { - let signature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [:] + #expect( + DownloadSignatureBuilder.chainVersionIdentifier( + gid: sampleGID, + token: "" + ) == nil ) - - #expect(signature.hasPrefix("hash:")) } @Test @@ -75,38 +39,38 @@ struct DownloadSignatureBuilderTests { DownloadSignatureBuilder.hasUpdateComparison( remoteVersionSignature: "hash:abc", latestRemoteVersionSignature: "chain:newgid:newtoken", - gid: sampleGallery.gid, - token: sampleGallery.token + gid: sampleGID, + token: sampleToken ) == .incomparable ) #expect( DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( remoteVersionSignature: "hash:abc", latestRemoteVersionSignature: "chain:newgid:newtoken", - gid: sampleGallery.gid, - token: sampleGallery.token + gid: sampleGID, + token: sampleToken ) == nil ) } @Test func testCanonicalizeHashToOriginalChainOnlyWhenLatestMatchesOriginalGalleryIdentity() { - let latestSignature = "chain:\(sampleGallery.gid):\(sampleGallery.token)" + let latestSignature = "chain:\(sampleGID):\(sampleToken)" #expect( DownloadSignatureBuilder.hasUpdateComparison( remoteVersionSignature: "hash:abc", latestRemoteVersionSignature: latestSignature, - gid: sampleGallery.gid, - token: sampleGallery.token + gid: sampleGID, + token: sampleToken ) == .same ) #expect( DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( remoteVersionSignature: "hash:abc", latestRemoteVersionSignature: latestSignature, - gid: sampleGallery.gid, - token: sampleGallery.token + gid: sampleGID, + token: sampleToken ) == latestSignature ) } @@ -117,67 +81,20 @@ struct DownloadSignatureBuilderTests { DownloadSignatureBuilder.hasUpdateComparison( remoteVersionSignature: "hash:abc", latestRemoteVersionSignature: "chain:othergid:othertoken", - gid: sampleGallery.gid, - token: sampleGallery.token + gid: sampleGID, + token: sampleToken ) == .incomparable ) #expect( DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( remoteVersionSignature: "hash:abc", latestRemoteVersionSignature: "chain:othergid:othertoken", - gid: sampleGallery.gid, - token: sampleGallery.token + gid: sampleGID, + token: sampleToken ) == nil ) } - } -private extension DownloadSignatureBuilderTests { - var sampleGallery: Gallery { - Gallery( - gid: "1394965", - token: "56c35114b6", - title: "(C95) [Hoshimame (Hoshimame Mana)] Mugyutto Mugyu Gurumi (Summer Pockets)[Chinese] [红茶汉化组]", - rating: 4.5, - tags: [], - category: .nonH, - uploader: "多路卡", - pageCount: 26, - postedDate: samplePostedDate, - coverURL: URL(string: "https://ehgt.org/cover.webp"), - galleryURL: URL(string: "https://e-hentai.org/g/1394965/56c35114b6/") - ) - } - - var sampleDetail: GalleryDetail { - sampleDetailWithCoverURL("https://ehgt.org/cover.webp") - } - - func sampleDetailWithCoverURL(_ coverURL: String) -> GalleryDetail { - GalleryDetail( - gid: "1394965", - title: sampleGallery.title, - jpnTitle: "(C95) [ほしまめ (星豆まな)] むぎゅっとむぎゅぐるみ (Summer Pockets)[中国翻訳]", - isFavorited: false, - visibility: .yes, - rating: 4.5, - userRating: 0, - ratingCount: 0, - category: .nonH, - language: .chinese, - uploader: "多路卡", - postedDate: samplePostedDate, - coverURL: URL(string: coverURL), - favoritedCount: 0, - pageCount: 26, - sizeCount: 114, - sizeType: "MB", - torrentCount: 0 - ) - } - - var samplePostedDate: Date { - Date(timeIntervalSince1970: 576_346_020) - } -} +private let sampleGID = "1394965" +private let sampleToken = "56c35114b6" diff --git a/EhPandaTests/Tests/Download/DownloadSignaturePreviewTests.swift b/EhPandaTests/Tests/Download/DownloadSignaturePreviewTests.swift deleted file mode 100644 index d6f392975..000000000 --- a/EhPandaTests/Tests/Download/DownloadSignaturePreviewTests.swift +++ /dev/null @@ -1,249 +0,0 @@ -// -// DownloadSignaturePreviewTests.swift -// EhPandaTests -// - -import Testing -import Foundation -@testable import EhPanda - -struct DownloadSignaturePreviewTests { - @Test - func testSignatureIgnoresPreviewHostRotationAndLayoutChanges() throws { - let firstSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" - )), - 2: try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200" - )) - ] - ) - - let secondSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://beta.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0" - )), - 2: try #require(URL( - string: "https://beta.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=250" - )) - ] - ) - - #expect(firstSignature == secondSignature) - } - - @Test - func testSignatureChangesWhenCombinedPreviewAtlasChanges() throws { - let firstSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" - )) - ] - ) - - let secondSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-1.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" - )) - ] - ) - - #expect(firstSignature != secondSignature) - } - - @Test - func testSignatureIgnoresCombinedPreviewTokenRotation() throws { - let firstSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" - )) - ] - ) - - let secondSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL( - string: "https://beta.hath.network/c2/token-b/1394965-0.webp" - + "?ehpandaWidth=250&ehpandaHeight=366&ehpandaOffset=0" - )) - ] - ) - - #expect(firstSignature == secondSignature) - } - - @Test - func testSignatureIgnoresHostRotationForStandalonePreviewURLs() throws { - let firstSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")), - 2: try #require(URL(string: "https://alpha.ehgt.org/t/56/78/preview-2.webp")) - ] - ) - - let secondSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL(string: "https://beta.ehgt.org/t/12/34/preview-1.webp")), - 2: try #require(URL(string: "https://beta.ehgt.org/t/56/78/preview-2.webp")) - ] - ) - - #expect(firstSignature == secondSignature) - } - - @Test - func testSignatureIgnoresCoverHostAndQueryChanges() { - let firstSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetailWithCoverURL("https://ehgt.org/w/00/686/86308-b7cs0xve.webp?dl=1"), - host: .ehentai, - previewURLs: [:] - ) - - let secondSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetailWithCoverURL("https://mirror.ehgt.org/w/00/686/86308-b7cs0xve.webp?source=thumb"), - host: .ehentai, - previewURLs: [:] - ) - - #expect(firstSignature == secondSignature) - } - - @Test - func testSignatureIgnoresGalleryHostTransitions() throws { - let ehSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [ - 1: try #require(URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")) - ] - ) - - let exSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .exhentai, - previewURLs: [ - 1: try #require(URL(string: "https://alpha.ehgt.org/t/12/34/preview-1.webp")) - ] - ) - - #expect(ehSignature == exSignature) - } - - @Test - func testSignatureIsOrderIndependentForSamePreviewURLSet() throws { - let urlA = try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=0" - )) - let urlB = try #require(URL( - string: "https://alpha.hath.network/c2/token-a/1394965-0.webp" - + "?ehpandaWidth=200&ehpandaHeight=293&ehpandaOffset=200" - )) - - let ascendingSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [1: urlA, 2: urlB] - ) - - let descendingSignature = DownloadSignatureBuilder.make( - gallery: sampleGallery, - detail: sampleDetail, - host: .ehentai, - previewURLs: [2: urlB, 1: urlA] - ) - - #expect(ascendingSignature == descendingSignature) - } -} - -private extension DownloadSignaturePreviewTests { - var sampleGallery: Gallery { - Gallery( - gid: "1394965", - token: "56c35114b6", - title: "(C95) [Hoshimame (Hoshimame Mana)] Mugyutto Mugyu Gurumi (Summer Pockets)[Chinese] [红茶汉化组]", - rating: 4.5, - tags: [], - category: .nonH, - uploader: "多路卡", - pageCount: 26, - postedDate: samplePostedDate, - coverURL: URL(string: "https://ehgt.org/cover.webp"), - galleryURL: URL(string: "https://e-hentai.org/g/1394965/56c35114b6/") - ) - } - - var sampleDetail: GalleryDetail { - sampleDetailWithCoverURL("https://ehgt.org/cover.webp") - } - - func sampleDetailWithCoverURL(_ coverURL: String) -> GalleryDetail { - GalleryDetail( - gid: "1394965", - title: sampleGallery.title, - jpnTitle: "(C95) [ほしまめ (星豆まな)] むぎゅっとむぎゅぐるみ (Summer Pockets)[中国翻訳]", - isFavorited: false, - visibility: .yes, - rating: 4.5, - userRating: 0, - ratingCount: 0, - category: .nonH, - language: .chinese, - uploader: "多路卡", - postedDate: samplePostedDate, - coverURL: URL(string: coverURL), - favoritedCount: 0, - pageCount: 26, - sizeCount: 114, - sizeType: "MB", - torrentCount: 0 - ) - } - - var samplePostedDate: Date { - Date(timeIntervalSince1970: 576_346_020) - } -} From d13434069cc3634ba6cbb6e18f17a7573f6735df Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 03:35:43 +0800 Subject: [PATCH 104/614] Use update status --- .../Persistent/DownloadedGallery+SupportTypes.swift | 13 ++++--------- .../Download/DownloadFilterAndBadgeTests.swift | 2 +- .../Download/DownloadRetryUpdateFallbackTests.swift | 6 +++--- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index c74c67997..b6b849afd 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -122,7 +122,7 @@ extension DownloadedGallery { } var displayStatus: DownloadDisplayStatus { - if status == .updateAvailable || hasUpdate { + if status == .updateAvailable { return .updateAvailable } if status == .completed { @@ -215,7 +215,7 @@ extension DownloadedGallery { var canTriggerUpdate: Bool { guard !isQueuedWorkItem, !canPauseOrResume else { return false } - return status == .updateAvailable || ([.completed, .missingFiles].contains(status) && hasUpdate) + return status == .updateAvailable } var isQueuedWorkItem: Bool { @@ -223,12 +223,7 @@ extension DownloadedGallery { } var hasUpdate: Bool { - DownloadSignatureBuilder.hasUpdateComparison( - remoteVersionSignature: remoteVersionSignature, - latestRemoteVersionSignature: latestRemoteVersionSignature, - gid: gid, - token: token - ) == .different + status == .updateAvailable } func needsInterruptedDownloadNormalization( @@ -253,7 +248,7 @@ extension DownloadedGallery { case .failed: return [.partial, .failed, .missingFiles].contains(status) case .update: - return status == .updateAvailable || hasUpdate + return status == .updateAvailable } } diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 732192401..b4cafc50e 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -159,7 +159,7 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { let completedUpdate = sampleDownload( gid: "458", title: "Completed Update", - status: .completed, + status: .updateAvailable, latestRemoteVersionSignature: "hash:v2" ) diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 51ee20e7b..7d59f208e 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -38,7 +38,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) try insertPersistedDownload( - in: container, gid: gid, status: .partial, + in: container, gid: gid, status: .updateAvailable, completedPageCount: oldCount - 1, pageCount: oldCount, remoteVersionSignature: oldVersionSignature, latestRemoteVersionSignature: updatedVersionSignature @@ -57,7 +57,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { } let queued = await queueingManager.testingFetchDownload(gid: gid) - #expect(queued?.status == .partial) + #expect(queued?.status == .updateAvailable) #expect(queued?.pendingOperation == .update) #expect(queued?.lastError == nil) if FileManager.default.fileExists(atPath: temporaryFolderURL.path) { @@ -193,7 +193,7 @@ private extension DownloadRetryUpdateFallbackTests { mode: .update, pageSelection: [context.pageIndex] ) try insertPersistedDownload( - in: container, gid: context.gid, status: .partial, + in: container, gid: context.gid, status: .updateAvailable, completedPageCount: oldCount - 1, pageCount: oldCount, remoteVersionSignature: signatures.old, latestRemoteVersionSignature: signatures.updated From ded5d3ec97f9ec76235a9e5b80f3da9fed935efd Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 03:39:16 +0800 Subject: [PATCH 105/614] Simplify fallback --- .../App/Tools/Clients/DownloadClient+Persistence.swift | 3 +-- .../Clients/DownloadClient+SchedulingHelpers.swift | 10 +--------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index dfabcca1c..bc5458a52 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -404,8 +404,7 @@ extension DownloadManager { ) { record.status = self.fallbackStatus( for: context.originalDownload, - mode: context.mode, - latestSignature: context.latestSignature + mode: context.mode ).rawValue record.completedPageCount = Int64( context.originalDownload.pageCount diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index 9ec30eda6..e93d933a5 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -196,18 +196,10 @@ extension DownloadManager { nonisolated func fallbackStatus( for download: DownloadedGallery, - mode: DownloadStartMode, - latestSignature: String? + mode: DownloadStartMode ) -> DownloadStatus { - let comparison = DownloadSignatureBuilder.hasUpdateComparison( - remoteVersionSignature: download.remoteVersionSignature, - latestRemoteVersionSignature: latestSignature, - gid: download.gid, - token: download.token - ) let shouldKeepUpdateBadge = mode == .update || download.status == .updateAvailable - || comparison == .different return shouldKeepUpdateBadge ? .updateAvailable : .completed } } From 022afef51b2d0241fbf74e792c20983e62c2347e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 03:44:33 +0800 Subject: [PATCH 106/614] Drop signatures --- .../Clients/DownloadClient+PublicAPI.swift | 104 +---------------- .../DownloadClient+PublicAPIHelpers.swift | 8 +- .../DownloadedGallery+SignatureBuilder.swift | 104 ----------------- .../Download/DownloadFeatureTestHelpers.swift | 4 + .../Download/DownloadProcessCacheTests.swift | 4 +- .../Tests/Download/DownloadProcessTests.swift | 4 +- .../DownloadRetryUpdateFallbackTests.swift | 8 +- .../DownloadSignatureBuilderTests.swift | 100 ----------------- .../DownloadVersionSignatureTests.swift | 105 ------------------ 9 files changed, 13 insertions(+), 428 deletions(-) delete mode 100644 EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift delete mode 100644 EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 542737cf8..590897f03 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -45,13 +45,6 @@ extension DownloadManager { return Dictionary(uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) }) } - private struct SignatureUpdateInfo { - let download: DownloadedGallery - let latestSignature: String? - let comparison: DownloadSignatureBuilder.Comparison - let canonicalizedSignature: String? - } - func updateRemoteVersion( gid: String, metadata: DownloadVersionMetadata @@ -60,13 +53,7 @@ extension DownloadManager { return .none } guard downloadIndex[gid] != nil else { - return await updateRemoteSignature( - gid: gid, - latestSignature: DownloadSignatureBuilder.chainVersionIdentifier( - gid: metadata.resolvedCurrentGID, - token: metadata.resolvedCurrentKey - ) - ) + return download.badge } guard [.completed, .updateAvailable].contains(download.status) else { return download.badge @@ -85,95 +72,6 @@ extension DownloadManager { return (await fetchDownload(gid: gid))?.badge ?? .none } - func updateRemoteSignature( - gid: String, - latestSignature: String? - ) async -> DownloadBadge { - guard let download = await fetchDownload(gid: gid) else { - return .none - } - let info = SignatureUpdateInfo( - download: download, - latestSignature: latestSignature, - comparison: DownloadSignatureBuilder.hasUpdateComparison( - remoteVersionSignature: download.remoteVersionSignature, - latestRemoteVersionSignature: latestSignature, - gid: download.gid, - token: download.token - ), - canonicalizedSignature: - DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( - remoteVersionSignature: download.remoteVersionSignature, - latestRemoteVersionSignature: latestSignature, - gid: download.gid, - token: download.token - ) - ) - let didChange = signatureUpdateWouldChange(info: info) - do { - try await updateDownloadRecord( - gid: gid, createIfMissing: false - ) { record in - self.applySignatureUpdate(to: record, info: info) - } - } catch { - Logger.error(error) - } - if didChange { await notifyObservers() } - return (await fetchDownload(gid: gid))?.badge ?? .none - } - - nonisolated private func applySignatureUpdate( - to record: DownloadedGalleryMO, - info: SignatureUpdateInfo - ) { - let download = info.download - let latestSignature = info.latestSignature - if download.latestRemoteVersionSignature != latestSignature { - record.latestRemoteVersionSignature = latestSignature - } - if let canonicalized = info.canonicalizedSignature, - canonicalized != download.remoteVersionSignature { - record.remoteVersionSignature = canonicalized - } - guard latestSignature?.nonEmpty != nil, - [.completed, .updateAvailable].contains(download.status) - else { return } - let desiredStatus: DownloadStatus? - switch info.comparison { - case .different: desiredStatus = .updateAvailable - case .same: desiredStatus = .completed - case .incomparable: desiredStatus = nil - } - if let desiredStatus, desiredStatus != download.status { - record.status = desiredStatus.rawValue - } - } - - nonisolated private func signatureUpdateWouldChange( - info: SignatureUpdateInfo - ) -> Bool { - let download = info.download - let latestSignature = info.latestSignature - if download.latestRemoteVersionSignature != latestSignature { - return true - } - if let canonicalized = info.canonicalizedSignature, - canonicalized != download.remoteVersionSignature { - return true - } - guard latestSignature?.nonEmpty != nil, - [.completed, .updateAvailable].contains(download.status) - else { return false } - let desiredStatus: DownloadStatus? - switch info.comparison { - case .different: desiredStatus = .updateAvailable - case .same: desiredStatus = .completed - case .incomparable: desiredStatus = nil - } - return desiredStatus != nil && desiredStatus != download.status - } - func enqueue( payload: DownloadRequestPayload ) async -> Result { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index b9eee346b..822eca772 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -12,10 +12,10 @@ extension DownloadManager { for gallery: Gallery, versionMetadata: DownloadVersionMetadata? ) -> String { - DownloadSignatureBuilder.chainVersionIdentifier( - gid: versionMetadata?.resolvedCurrentGID ?? gallery.gid, - token: versionMetadata?.resolvedCurrentKey ?? gallery.token - ) ?? "" + let gid = versionMetadata?.resolvedCurrentGID ?? gallery.gid + let token = versionMetadata?.resolvedCurrentKey ?? gallery.token + guard !gid.isEmpty, !token.isEmpty else { return "" } + return "chain:\(gid):\(token)" } func buildInspectionPages( diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift b/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift deleted file mode 100644 index 91c0ba5fe..000000000 --- a/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// DownloadedGallery+SignatureBuilder.swift -// EhPanda -// - -enum DownloadSignatureBuilder { - enum SignatureKind: Equatable { - case chain(gid: String, token: String) - case hash(String) - } - - enum Comparison: Equatable { - case same - case different - case incomparable - } - - static func chainVersionIdentifier(gid: String, token: String) -> String? { - guard !gid.isEmpty, !token.isEmpty else { return nil } - return "chain:\(gid):\(token)" - } - - static func parse(_ value: String?) -> SignatureKind? { - guard let value, !value.isEmpty else { return nil } - - if value.hasPrefix("chain:") { - let components = value.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) - guard components.count == 3, - !components[1].isEmpty, - !components[2].isEmpty - else { - return nil - } - return .chain(gid: String(components[1]), token: String(components[2])) - } - - if value.hasPrefix("hash:") { - let hash = String(value.dropFirst("hash:".count)) - guard !hash.isEmpty else { return nil } - return .hash(hash) - } - - return nil - } - - static func compare( - remoteVersionSignature: String, - latestRemoteVersionSignature: String?, - gid: String, - token: String - ) -> Comparison { - guard let storedSignature = parse(remoteVersionSignature), - let latestSignature = parse(latestRemoteVersionSignature) - else { - return .incomparable - } - - switch (storedSignature, latestSignature) { - case let (.chain(storedGID, storedToken), .chain(latestGID, latestToken)): - return storedGID == latestGID && storedToken == latestToken ? .same : .different - - case let (.hash(storedHash), .hash(latestHash)): - return storedHash == latestHash ? .same : .different - - case (.hash, .chain): - return latestRemoteVersionSignature == chainVersionIdentifier(gid: gid, token: token) - ? .same - : .incomparable - - case (.chain, .hash): - return .incomparable - } - } - - static func canonicalizeStoredSignatureIfSafe( - remoteVersionSignature: String, - latestRemoteVersionSignature: String?, - gid: String, - token: String - ) -> String? { - guard case .hash = parse(remoteVersionSignature), - case .chain = parse(latestRemoteVersionSignature), - latestRemoteVersionSignature == chainVersionIdentifier(gid: gid, token: token) - else { - return nil - } - return latestRemoteVersionSignature - } - - static func hasUpdateComparison( - remoteVersionSignature: String, - latestRemoteVersionSignature: String?, - gid: String, - token: String - ) -> Comparison { - compare( - remoteVersionSignature: remoteVersionSignature, - latestRemoteVersionSignature: latestRemoteVersionSignature, - gid: gid, - token: token - ) - } - -} diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift index 7f0ef22ef..2f9bb11f2 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -67,6 +67,10 @@ protocol DownloadFeatureTestCase: TestHelper { // MARK: - Default Implementations extension DownloadFeatureTestCase { + func chainVersionSignature(gid: String, token: String) -> String { + "chain:\(gid):\(token)" + } + func waitUntilCacheReady( for keys: Keys, timeout: Duration = .seconds(1) diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index ad0948bd3..d326d3866 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -20,9 +20,7 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 402) let pageIndex = 42 - let oldVersionSignature = try #require( - DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") - ) + let oldVersionSignature = chainVersionSignature(gid: gid, token: "token") let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index b84684286..6708ca4ee 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -76,9 +76,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 401) let pageIndex = 42 - let oldVersionSignature = try #require( - DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") - ) + let oldVersionSignature = chainVersionSignature(gid: gid, token: "token") let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 7d59f208e..7c71e6f58 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -16,9 +16,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) let pageIndex = 42 - let oldVersionSignature = try #require( - DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") - ) + let oldVersionSignature = chainVersionSignature(gid: gid, token: "token") let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -73,9 +71,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) let pageIndex = 42 - let oldVersionSignature = try #require( - DownloadSignatureBuilder.chainVersionIdentifier(gid: gid, token: "token") - ) + let oldVersionSignature = chainVersionSignature(gid: gid, token: "token") let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } diff --git a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift deleted file mode 100644 index 46e3d831a..000000000 --- a/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// DownloadSignatureBuilderTests.swift -// EhPandaTests -// - -import Testing -@testable import EhPanda - -struct DownloadSignatureBuilderTests { - @Test - func testChainVersionIdentifierBuildsChainSignature() { - #expect( - DownloadSignatureBuilder.chainVersionIdentifier( - gid: sampleGID, - token: sampleToken - ) == "chain:\(sampleGID):\(sampleToken)" - ) - } - - @Test - func testChainVersionIdentifierRejectsEmptyIdentity() { - #expect( - DownloadSignatureBuilder.chainVersionIdentifier( - gid: "", - token: sampleToken - ) == nil - ) - #expect( - DownloadSignatureBuilder.chainVersionIdentifier( - gid: sampleGID, - token: "" - ) == nil - ) - } - - @Test - func testHashAndChainSignaturesAreIncomparableForUpdateCheck() { - #expect( - DownloadSignatureBuilder.hasUpdateComparison( - remoteVersionSignature: "hash:abc", - latestRemoteVersionSignature: "chain:newgid:newtoken", - gid: sampleGID, - token: sampleToken - ) == .incomparable - ) - #expect( - DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( - remoteVersionSignature: "hash:abc", - latestRemoteVersionSignature: "chain:newgid:newtoken", - gid: sampleGID, - token: sampleToken - ) == nil - ) - } - - @Test - func testCanonicalizeHashToOriginalChainOnlyWhenLatestMatchesOriginalGalleryIdentity() { - let latestSignature = "chain:\(sampleGID):\(sampleToken)" - - #expect( - DownloadSignatureBuilder.hasUpdateComparison( - remoteVersionSignature: "hash:abc", - latestRemoteVersionSignature: latestSignature, - gid: sampleGID, - token: sampleToken - ) == .same - ) - #expect( - DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( - remoteVersionSignature: "hash:abc", - latestRemoteVersionSignature: latestSignature, - gid: sampleGID, - token: sampleToken - ) == latestSignature - ) - } - - @Test - func testDoNotCanonicalizeHashWhenLatestChainPointsToDifferentCurrentGallery() { - #expect( - DownloadSignatureBuilder.hasUpdateComparison( - remoteVersionSignature: "hash:abc", - latestRemoteVersionSignature: "chain:othergid:othertoken", - gid: sampleGID, - token: sampleToken - ) == .incomparable - ) - #expect( - DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( - remoteVersionSignature: "hash:abc", - latestRemoteVersionSignature: "chain:othergid:othertoken", - gid: sampleGID, - token: sampleToken - ) == nil - ) - } -} - -private let sampleGID = "1394965" -private let sampleToken = "56c35114b6" diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 635e4c74c..51c2bdc48 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -51,111 +51,6 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { #expect(localPages[1] == temporaryFolderURL.appendingPathComponent("pages/0001.jpg")) } - @MainActor - @Test - func testUpdateRemoteSignatureSkipsUpdateWhenStoredChainAndLatestHashDiffer() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 101) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared, - persistenceContainer: container - ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .completed, - completedPageCount: 26, - token: "token", - remoteVersionSignature: "chain:\(gid):token" - ) - - let badge = await manager.updateRemoteSignature(gid: gid, latestSignature: "hash:new") - let stored = await manager.testingFetchDownload(gid: gid) - - #expect(badge == .downloaded) - #expect(stored?.status == .completed) - #expect(stored?.remoteVersionSignature == "chain:\(gid):token") - #expect(stored?.latestRemoteVersionSignature == "hash:new") - } - - @MainActor - @Test - func testUpdateRemoteSignatureSkipsUpdateWhenStoredHashAndLatestNonOriginalChainDiffer() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 102) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared, - persistenceContainer: container - ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .completed, - completedPageCount: 26, - token: "token", - remoteVersionSignature: "hash:old" - ) - - let badge = await manager.updateRemoteSignature( - gid: gid, - latestSignature: "chain:othergid:othertoken" - ) - let stored = await manager.testingFetchDownload(gid: gid) - - #expect(badge == .downloaded) - #expect(stored?.status == .completed) - #expect(stored?.remoteVersionSignature == "hash:old") - #expect(stored?.latestRemoteVersionSignature == "chain:othergid:othertoken") - } - - @MainActor - @Test - func testUpdateRemoteSignatureCanonicalizesStoredHashToOriginalChainWithoutMarkingUpdate() async throws { - let container = try makeInMemoryContainer() - - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 103) - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared, - persistenceContainer: container - ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .completed, - completedPageCount: 26, - token: "token", - remoteVersionSignature: "hash:old" - ) - - let badge = await manager.updateRemoteSignature( - gid: gid, - latestSignature: "chain:\(gid):token" - ) - let stored = await manager.testingFetchDownload(gid: gid) - - #expect(badge == .downloaded) - #expect(stored?.status == .completed) - #expect(stored?.remoteVersionSignature == "chain:\(gid):token") - #expect(stored?.latestRemoteVersionSignature == "chain:\(gid):token") - } - @MainActor @Test func testUpdateRemoteVersionUsesIndexedSessionFlag() async throws { From 082e6100f8a8c966d53d6006400bac5439d6a2d7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 04:04:14 +0800 Subject: [PATCH 107/614] Drop manifest sig --- .../DownloadClient+ExecutionPerform.swift | 17 +++++++-------- .../DownloadClient+ExecutionSupport.swift | 15 ++++--------- .../Clients/DownloadClient+Manager.swift | 1 - .../DownloadClient+PersistenceNormalize.swift | 2 -- .../Clients/DownloadClient+PublicAPI.swift | 11 ++-------- .../DownloadClient+SchedulingHelpers.swift | 6 ++---- .../DownloadFileStorage+Operations.swift | 1 - .../DownloadedGallery+Manifest.swift | 3 --- .../Models/Persistent/DownloadedGallery.swift | 4 ++-- .../DownloadEnqueueManifestTests.swift | 1 - .../DownloadFeatureTestFactories.swift | 3 +-- .../DownloadFileStorageHashTests.swift | 1 - .../DownloadFileStorageRepairTests.swift | 3 +-- .../Download/DownloadFileStorageTests.swift | 1 - .../DownloadManagerCaptureTests.swift | 1 - .../DownloadManagerRepairSeedTests.swift | 21 ++++++++++--------- .../DownloadManagerStorageTests.swift | 1 - .../Tests/Download/DownloadProcessTests.swift | 3 --- .../DownloadVersionSignatureTests.swift | 1 - .../DownloadedGalleryManifestModelTests.swift | 1 - 20 files changed, 30 insertions(+), 67 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 5ab39293f..adc1259fa 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -97,7 +97,6 @@ extension DownloadManager { existingPageRelativePaths: workingSeed.existingPages ) let finalizeCtx = FinalizeContext( - versionSignature: versionSignature, coverRelativePath: coverRelativePath, batchResult: batchResult, existingDownload: existingDownload @@ -105,7 +104,8 @@ extension DownloadManager { try await finalizeBatchResult( context: finalizeCtx, payload: payload, - folderURL: workingFolderURL + folderURL: workingFolderURL, + versionSignature: versionSignature ) return PerformDownloadResult( coverRelativePath: coverRelativePath, @@ -128,13 +128,14 @@ extension DownloadManager { private func finalizeBatchResult( context: FinalizeContext, payload: DownloadRequestPayload, - folderURL: URL + folderURL: URL, + versionSignature: String ) async throws { if payload.pageSelection != nil { try? storage.writeResumeState( .init( mode: payload.mode, - versionSignature: context.versionSignature, + versionSignature: versionSignature, pageCount: payload.galleryDetail.pageCount, downloadOptions: payload.options ), @@ -182,14 +183,12 @@ extension DownloadManager { folderURL: URL, finalizeContext: FinalizeContext ) async throws { - let versionSignature = finalizeContext.versionSignature let batchResult = finalizeContext.batchResult let existingDownload = finalizeContext.existingDownload let manifest = makeManifest( payload: payload, coverRelativePath: finalizeContext.coverRelativePath, - batchResult: batchResult, - versionSignature: versionSignature + batchResult: batchResult ) let hashedManifest = try storage.addingCurrentFileHashes( to: manifest, @@ -212,8 +211,7 @@ extension DownloadManager { private func makeManifest( payload: DownloadRequestPayload, coverRelativePath: String?, - batchResult: DownloadBatchResult, - versionSignature: String + batchResult: DownloadBatchResult ) -> DownloadManifest { DownloadManifest( gid: payload.gallery.gid, @@ -231,7 +229,6 @@ extension DownloadManager { galleryURL: payload.gallery.galleryURL.forceUnwrapped, rating: payload.galleryDetail.rating, downloadOptions: payload.options, - versionSignature: versionSignature, downloadedAt: .now, pages: batchResult.pages .sorted(by: { $0.index < $1.index }) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index a4c451549..d474e195c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -191,8 +191,7 @@ extension DownloadManager { ) let seedContext = RepairSeedContext( existingDownload: existingDownload, - payload: payload, - versionSignature: versionSignature + payload: payload ) try setupWorkingFolder( folderURL: folderURL, @@ -204,7 +203,6 @@ extension DownloadManager { at: folderURL, gid: payload.gallery.gid, pageCount: payload.galleryDetail.pageCount, - versionSignature: versionSignature, downloadOptions: payload.options ) let existingPages = storage.existingPageRelativePaths( @@ -248,7 +246,6 @@ extension DownloadManager { return manifest.gid == payload.gallery.gid && manifest.token == payload.gallery.token && manifest.pageCount == payload.galleryDetail.pageCount - && manifest.versionSignature == versionSignature && manifest.downloadOptions == payload.options case .repair: return true @@ -260,7 +257,6 @@ extension DownloadManager { private struct RepairSeedContext { let existingDownload: DownloadedGallery let payload: DownloadRequestPayload - let versionSignature: String } private func setupWorkingFolder( @@ -276,8 +272,7 @@ extension DownloadManager { if !fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) { if let seed = repairSeed( for: seedContext.existingDownload, - payload: seedContext.payload, - versionSignature: seedContext.versionSignature + payload: seedContext.payload ) { try storage.materializeRepairSeed( from: seed.folderURL, @@ -338,8 +333,7 @@ extension DownloadManager { func repairSeed( for download: DownloadedGallery, - payload: DownloadRequestPayload, - versionSignature: String + payload: DownloadRequestPayload ) -> RepairSeed? { let folderURL = download .resolvedFolderURL(rootURL: storage.rootURL) @@ -352,8 +346,7 @@ extension DownloadManager { manifest.gid == download.gid, manifest.pageCount == payload.galleryDetail.pageCount, - manifest.pages.count == manifest.pageCount, - manifest.versionSignature == versionSignature + manifest.pages.count == manifest.pageCount else { return nil } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 5c3b8c297..d1a8aa69a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -125,7 +125,6 @@ actor DownloadManager { } struct FinalizeContext: Sendable { - let versionSignature: String let coverRelativePath: String? let batchResult: DownloadBatchResult let existingDownload: DownloadedGallery diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index ec20bdfc8..5fc62a746 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -11,7 +11,6 @@ extension DownloadManager { at folderURL: URL, gid: String, pageCount: Int, - versionSignature: String, downloadOptions: DownloadOptionsSnapshot ) -> DownloadManifest? { guard let manifest = try? storage @@ -19,7 +18,6 @@ extension DownloadManager { manifest.gid == gid, manifest.pageCount == pageCount, manifest.pages.count == pageCount, - manifest.versionSignature == versionSignature, manifest.downloadOptions == downloadOptions else { return nil diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 590897f03..4dd435a99 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -77,15 +77,10 @@ extension DownloadManager { ) async -> Result { do { try storage.ensureRootDirectory() - let versionSignature = manifestVersionSignature( - for: payload.gallery, - versionMetadata: payload.versionMetadata - ) let folderRelativePath = folderRelativePath(for: payload) try writeInitialManifest( payload: payload, - folderRelativePath: folderRelativePath, - versionSignature: versionSignature + folderRelativePath: folderRelativePath ) await queueStore.enqueue(payload.gallery.gid) await notifyObservers() @@ -101,8 +96,7 @@ extension DownloadManager { private func writeInitialManifest( payload: DownloadRequestPayload, - folderRelativePath: String, - versionSignature: String + folderRelativePath: String ) throws { guard let galleryURL = payload.gallery.galleryURL else { throw AppError.notFound @@ -140,7 +134,6 @@ extension DownloadManager { galleryURL: galleryURL, rating: payload.galleryDetail.rating, downloadOptions: payload.options, - versionSignature: versionSignature, downloadedAt: .now, pages: pages ), diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index e93d933a5..e3cee076b 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -132,8 +132,7 @@ extension DownloadManager { if let manifest = try? storage .readManifest(folderURL: temporaryFolderURL), - manifest.gid == download.gid, - manifest.versionSignature == versionSignature { + manifest.gid == download.gid { return manifest.pageCount } @@ -181,8 +180,7 @@ extension DownloadManager { let manifest = try? storage.readManifest( folderURL: storage.temporaryFolderURL(gid: download.gid) ), - manifest.gid == download.gid, - manifest.versionSignature == versionSignature { + manifest.gid == download.gid { return manifest.pageCount == pageCount } diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index 71da6797f..7debe620f 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -357,7 +357,6 @@ private extension DownloadManifest { galleryURL: galleryURL, rating: rating, downloadOptions: downloadOptions, - versionSignature: versionSignature, downloadedAt: downloadedAt, pages: pages ) diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift index 6100b1fed..0aa14ef71 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -40,7 +40,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { let galleryURL: URL let rating: Float let downloadOptions: DownloadOptionsSnapshot - let versionSignature: String let downloadedAt: Date let pages: [Page] @@ -61,7 +60,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { galleryURL: URL, rating: Float, downloadOptions: DownloadOptionsSnapshot, - versionSignature: String, downloadedAt: Date, pages: [Page] ) { @@ -81,7 +79,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { self.galleryURL = galleryURL self.rating = rating self.downloadOptions = downloadOptions - self.versionSignature = versionSignature self.downloadedAt = downloadedAt self.pages = pages } diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index c0011ec51..192b9a609 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -281,8 +281,8 @@ struct DownloadedGallery: Identifiable, Equatable { lastDownloadedAt: modifiedAt ?? manifest.downloadedAt, lastError: lastError, downloadOptionsSnapshot: manifest.downloadOptions, - remoteVersionSignature: manifest.versionSignature, - latestRemoteVersionSignature: manifest.versionSignature + remoteVersionSignature: "chain:\(manifest.gid):\(manifest.token)", + latestRemoteVersionSignature: "chain:\(manifest.gid):\(manifest.token)" ) } } diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index 539f9f054..3c79a9060 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -61,7 +61,6 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { #expect(manifest.pages.count == detail.pageCount) #expect(manifest.pages.first?.relativePath == "\(gallery.gid)_\(gallery.token)_1.pending") #expect(manifest.downloadOptions.threadLimit == 3) - #expect(manifest.versionSignature == "chain:\(gallery.gid):\(gallery.token)") let request = NSFetchRequest( entityName: "DownloadedGalleryMO" diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 5a572e2d0..3f7e295cf 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -15,7 +15,7 @@ extension DownloadFeatureTestCase { gid: String, title: String, pageCount: Int = 2, - versionSignature: String = "hash:v1" + versionSignature _: String = "hash:v1" ) throws -> DownloadManifest { DownloadManifest( gid: gid, @@ -33,7 +33,6 @@ extension DownloadFeatureTestCase { galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), - versionSignature: versionSignature, downloadedAt: .now, pages: (1...pageCount).map { .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index df11fbd93..df53a452c 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -132,7 +132,6 @@ struct DownloadFileStorageHashTests { galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), - versionSignature: "hash:v1", downloadedAt: .now, pages: (1...pageCount).map { .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift index 5092cdae7..136356be6 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift @@ -114,7 +114,7 @@ private extension DownloadFileStorageRepairTests { category: .doujinshi, language: .japanese, uploader: "Uploader", tags: [], postedDate: .now, pageCount: 2, coverRelativePath: "cover.jpg", galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), - rating: 4, downloadOptions: DownloadOptionsSnapshot(), versionSignature: "hash:v1", + rating: 4, downloadOptions: DownloadOptionsSnapshot(), downloadedAt: .now, pages: [ .init(index: 1, relativePath: "pages/0001.jpg"), @@ -211,7 +211,6 @@ private extension DownloadFileStorageRepairTests { galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), - versionSignature: "hash:v1", downloadedAt: .now, pages: (1...pageCount).map { .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index bc341d5ab..ff829f2a4 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -451,7 +451,6 @@ private extension DownloadFileStorageTests { galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), - versionSignature: "hash:v1", downloadedAt: .now, pages: (1...pageCount).map { .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index 8e51cfb02..818ef494e 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -150,7 +150,6 @@ private extension DownloadManagerCaptureTests { galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), - versionSignature: "hash:v1", downloadedAt: .now, pages: [ .init( diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index c2f0950ee..815ec292d 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -14,7 +14,7 @@ import Testing @Suite(.serialized) struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { @Test - func testRepairSeedRejectsOldCompletedVersionWhenGalleryUpdatedButPageCountMatches() async throws { + func testRepairSeedReusesCompletedFilesWhenPageCountMatches() async throws { let gid = "repair-seed-\(UUID().uuidString)" let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -38,23 +38,27 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { versionSignature: "hash:v2" ) - #expect(workingSeed.manifest == nil) - #expect(workingSeed.existingPages.isEmpty) - #expect(workingSeed.coverRelativePath == nil) + let manifest = try #require(workingSeed.manifest) + #expect(manifest.gid == gid) + #expect(workingSeed.existingPages == [ + 1: "pages/0001.jpg", + 2: "pages/0002.jpg" + ]) + #expect(workingSeed.coverRelativePath == "cover.jpg") #expect( FileManager.default.fileExists( atPath: workingSeed.folderURL.appendingPathComponent("pages/0001.jpg").path - ) == false + ) ) #expect( FileManager.default.fileExists( atPath: workingSeed.folderURL.appendingPathComponent("pages/0002.jpg").path - ) == false + ) ) } @Test - func testDownloadManagerLoadLocalPageURLsMarksCompletedDownloadMissingFilesWhenZeroBytePageIsFound() async throws { + func testDownloadManagerLoadLocalPageURLsRemovesZeroBytePage() async throws { let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 13) @@ -75,13 +79,10 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { ) let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - let stored = await manager.testingFetchDownload(gid: gid) #expect(pageURLs[1] == nil) #expect(pageURLs[2] == goodPageURL) #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) - #expect(stored?.status == .missingFiles) - #expect(stored?.completedPageCount == 1) } @MainActor diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 44024a57f..9321e94bc 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -1177,7 +1177,6 @@ private extension DownloadManagerStorageTests { galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), - versionSignature: "hash:v1", downloadedAt: downloadedAt, pages: pageHashes.enumerated().map { offset, hash in DownloadManifest.Page( diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 6708ca4ee..0c16b799f 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -255,12 +255,9 @@ private extension DownloadProcessTests { #expect(unwrapped.status == .completed) #expect(unwrapped.pageCount == context.updatedPageCount) #expect(unwrapped.completedPageCount == context.updatedPageCount) - #expect(unwrapped.remoteVersionSignature == context.updatedVersionSignature) - #expect(unwrapped.latestRemoteVersionSignature == context.updatedVersionSignature) let completedFolderURL = storage.folderURL(relativePath: unwrapped.folderRelativePath) let manifest = try storage.readManifest(folderURL: completedFolderURL) - #expect(manifest.versionSignature == context.updatedVersionSignature) #expect(manifest.pageCount == context.updatedPageCount) #expect(manifest.pages.count == context.updatedPageCount) #expect( diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 51c2bdc48..1fbede46c 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -100,7 +100,6 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), - versionSignature: "hash:old", downloadedAt: .now, pages: [ .init( diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index 2e3ba7a18..9d497bef7 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -55,7 +55,6 @@ private extension DownloadedGalleryManifestModelTests { galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), rating: 4, downloadOptions: .init(threadLimit: 3), - versionSignature: "hash:v1", downloadedAt: Date(timeIntervalSince1970: 1_111), pages: pageHashes.sorted(by: { $0.key < $1.key }).map { index, hash in .init( From 4f5d3495239f3285a871aaa92ae6d84e55786cbd Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 04:25:06 +0800 Subject: [PATCH 108/614] Drop resume sig --- .../Clients/DownloadClient+Execution.swift | 5 +- .../DownloadClient+ExecutionFetch.swift | 3 +- .../DownloadClient+ExecutionPerform.swift | 17 ++----- .../DownloadClient+ExecutionSupport.swift | 50 +++++++++++++++---- .../Clients/DownloadClient+Manager.swift | 1 - .../DownloadClient+PublicAPIHelpers.swift | 7 --- .../Clients/DownloadClient+RetryHelpers.swift | 5 -- .../DownloadClient+SchedulingHelpers.swift | 16 +----- .../Clients/DownloadClient+Testing.swift | 5 +- .../Tools/Utilities/DownloadFileStorage.swift | 5 -- .../DownloadFeatureTestTemporaryStorage.swift | 5 +- .../DownloadFileStorageStateTests.swift | 1 - .../Download/DownloadProcessCacheTests.swift | 2 +- .../Tests/Download/DownloadProcessTests.swift | 11 ++-- .../DownloadRetryUpdateFallbackTests.swift | 1 - 15 files changed, 60 insertions(+), 74 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index b867f021c..2883e91db 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -101,8 +101,11 @@ extension DownloadManager { mode: DownloadStartMode ) async throws -> ProcessDownloadResult { let existingFolderURL = download.resolvedFolderURL(rootURL: storage.rootURL) - let existingResumeState = try? storage + let existingResumeState = (try? storage .readResumeState(folderURL: existingFolderURL) + ) ?? (try? storage.readResumeState( + folderURL: storage.temporaryFolderURL(gid: gid) + )) let rawPageSelection = existingResumeState?.pageSelection let fetchResult = try await fetchLatestPayload( for: download, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index fbad5f357..40b07ae4d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -148,7 +148,7 @@ extension DownloadManager { func normalizeFetchedPayload( _ payload: DownloadRequestPayload, mode: DownloadStartMode, - versionSignature: String, + versionSignature _: String, existingResumeState: DownloadResumeState?, rawPageSelection: [Int]? ) -> DownloadRequestPayload { @@ -156,7 +156,6 @@ extension DownloadManager { rawPageSelection?.isEmpty == false && existingResumeState?.matches( mode: mode, - versionSignature: versionSignature, pageCount: payload.galleryDetail.pageCount, downloadOptions: payload.options ) == true diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index adc1259fa..3f02b3f3e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -14,7 +14,7 @@ extension DownloadManager { func performDownload( payload: DownloadRequestPayload, - versionSignature: String, + versionSignature _: String, folderRelativePath: String, existingDownload: DownloadedGallery ) async throws -> PerformDownloadResult { @@ -26,8 +26,7 @@ extension DownloadManager { let workingSeed = try prepareWorkingSeed( payload: payload, existingDownload: existingDownload, - folderURL: workingFolderURL, - versionSignature: versionSignature + folderURL: workingFolderURL ) let pendingIndices = pendingPageIndices( payload: payload, @@ -37,7 +36,6 @@ extension DownloadManager { try storage.writeResumeState( .init( mode: payload.mode, - versionSignature: versionSignature, pageCount: payload.galleryDetail.pageCount, downloadOptions: payload.options, pageSelection: payload.pageSelection?.sorted() @@ -46,8 +44,7 @@ extension DownloadManager { ) let executionContext = DownloadExecutionContext( - existingDownload: existingDownload, - versionSignature: versionSignature + existingDownload: existingDownload ) do { let batchAndCover = try await executePageDownloads( @@ -73,7 +70,6 @@ extension DownloadManager { executionContext: DownloadExecutionContext ) async throws -> PerformDownloadResult { let existingDownload = executionContext.existingDownload - let versionSignature = executionContext.versionSignature let coverRelativePath = try await downloadCoverIfNeeded( payload: payload, folderURL: workingFolderURL, @@ -104,8 +100,7 @@ extension DownloadManager { try await finalizeBatchResult( context: finalizeCtx, payload: payload, - folderURL: workingFolderURL, - versionSignature: versionSignature + folderURL: workingFolderURL ) return PerformDownloadResult( coverRelativePath: coverRelativePath, @@ -128,14 +123,12 @@ extension DownloadManager { private func finalizeBatchResult( context: FinalizeContext, payload: DownloadRequestPayload, - folderURL: URL, - versionSignature: String + folderURL: URL ) async throws { if payload.pageSelection != nil { try? storage.writeResumeState( .init( mode: payload.mode, - versionSignature: versionSignature, pageCount: payload.galleryDetail.pageCount, downloadOptions: payload.options ), diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index d474e195c..5694a8d67 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -178,20 +178,22 @@ extension DownloadManager { func prepareWorkingSeed( payload: DownloadRequestPayload, existingDownload: DownloadedGallery, - folderURL: URL, - versionSignature: String + folderURL: URL ) throws -> WorkingSeed { let resumeState = try? storage .readResumeState(folderURL: folderURL) let shouldReuseFolder = shouldReuseWorkingFolder( payload: payload, resumeState: resumeState, - folderURL: folderURL, - versionSignature: versionSignature + folderURL: folderURL ) let seedContext = RepairSeedContext( existingDownload: existingDownload, - payload: payload + payload: payload, + temporarySeed: temporaryWorkingSeed( + payload: payload, + folderURL: folderURL + ) ) try setupWorkingFolder( folderURL: folderURL, @@ -224,15 +226,13 @@ extension DownloadManager { private func shouldReuseWorkingFolder( payload: DownloadRequestPayload, resumeState: DownloadResumeState?, - folderURL: URL, - versionSignature: String + folderURL: URL ) -> Bool { guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return false } if resumeState?.matches( mode: payload.mode, - versionSignature: versionSignature, pageCount: payload.galleryDetail.pageCount, downloadOptions: payload.options ) == true { @@ -257,6 +257,7 @@ extension DownloadManager { private struct RepairSeedContext { let existingDownload: DownloadedGallery let payload: DownloadRequestPayload + let temporarySeed: RepairSeed? } private func setupWorkingFolder( @@ -270,7 +271,7 @@ extension DownloadManager { } } if !fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) { - if let seed = repairSeed( + if let seed = seedContext.temporarySeed ?? repairSeed( for: seedContext.existingDownload, payload: seedContext.payload ) { @@ -285,6 +286,37 @@ extension DownloadManager { } } + private func temporaryWorkingSeed( + payload: DownloadRequestPayload, + folderURL: URL + ) -> RepairSeed? { + let temporaryFolderURL = storage + .temporaryFolderURL(gid: payload.gallery.gid) + guard temporaryFolderURL != folderURL, + fileManager.operate({ + $0.fileExists(atPath: temporaryFolderURL.path) + }), + let resumeState = try? storage.readResumeState( + folderURL: temporaryFolderURL + ), + resumeState.matches( + mode: payload.mode, + pageCount: payload.galleryDetail.pageCount, + downloadOptions: payload.options + ), + let manifest = validatedManifest( + at: temporaryFolderURL, + gid: payload.gallery.gid, + pageCount: payload.galleryDetail.pageCount, + downloadOptions: payload.options + ), + manifest.token == payload.gallery.token + else { + return nil + } + return .init(folderURL: temporaryFolderURL, manifest: manifest) + } + func resolvedImageSource( index: Int, payload: DownloadRequestPayload, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index d1a8aa69a..5ebe0dc85 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -121,7 +121,6 @@ actor DownloadManager { struct DownloadExecutionContext: Sendable { let existingDownload: DownloadedGallery - let versionSignature: String } struct FinalizeContext: Sendable { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index 822eca772..98df967a8 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -196,21 +196,14 @@ extension DownloadManager { temporaryFolderURL: URL ) { let downloadOptions = download.downloadOptionsSnapshot - let versionSignature = preferredVersionSignature( - for: download, - mode: resolvedMode, - resumeState: existingResumeState - ) let pageCount = preferredWorkingPageCount( for: download, mode: resolvedMode, - versionSignature: versionSignature, resumeState: existingResumeState ) try? storage.writeResumeState( .init( mode: resolvedMode, - versionSignature: versionSignature, pageCount: pageCount, downloadOptions: downloadOptions ), diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index 72a701948..a58491d36 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -130,12 +130,8 @@ extension DownloadManager { let existingResumeState = try? storage.readResumeState( folderURL: temporaryFolderURL ) - let versionSignature = preferredVersionSignature( - for: download, mode: mode, resumeState: existingResumeState - ) let pageCount = preferredWorkingPageCount( for: download, mode: mode, - versionSignature: versionSignature, resumeState: existingResumeState ) let resumedStatus: DownloadStatus = @@ -149,7 +145,6 @@ extension DownloadManager { try storage.writeResumeState( .init( mode: mode, - versionSignature: versionSignature, pageCount: pageCount, downloadOptions: download.downloadOptionsSnapshot, pageSelection: selectedPageIndices diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index e3cee076b..b9836089d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -100,11 +100,6 @@ extension DownloadManager { break } - if let resumeState, - !resumeState.versionSignature.isEmpty { - return resumeState.versionSignature - } - if !download.remoteVersionSignature.isEmpty { return download.remoteVersionSignature } @@ -115,7 +110,6 @@ extension DownloadManager { func preferredWorkingPageCount( for download: DownloadedGallery, mode: DownloadStartMode, - versionSignature: String, resumeState: DownloadResumeState? ) -> Int { guard mode == .update else { @@ -136,8 +130,7 @@ extension DownloadManager { return manifest.pageCount } - if let resumeState, - resumeState.versionSignature == versionSignature { + if let resumeState { return resumeState.pageCount } @@ -156,20 +149,13 @@ extension DownloadManager { return false } - let versionSignature = preferredVersionSignature( - for: download, - mode: mode, - resumeState: resumeState - ) let pageCount = preferredWorkingPageCount( for: download, mode: mode, - versionSignature: versionSignature, resumeState: resumeState ) guard resumeState.mode == mode, - resumeState.versionSignature == versionSignature, resumeState.downloadOptions == download.downloadOptionsSnapshot else { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 762fac53a..39245e127 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -102,7 +102,7 @@ extension DownloadManager { func testingPrepareWorkingSeed( payload: DownloadRequestPayload, existingDownload: DownloadedGallery, - versionSignature: String + versionSignature _: String ) throws -> PrepareWorkingSeedResult { let folderURL = storage.folderURL( relativePath: folderRelativePath(for: payload) @@ -113,8 +113,7 @@ extension DownloadManager { let workingSeed = try prepareWorkingSeed( payload: payload, existingDownload: existingDownload, - folderURL: folderURL, - versionSignature: versionSignature + folderURL: folderURL ) return PrepareWorkingSeedResult( folderURL: workingSeed.folderURL, diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 3d9bf3591..22e335f4b 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -20,20 +20,17 @@ struct DownloadFolderRecord: Equatable, Sendable { struct DownloadResumeState: Codable, Equatable { let mode: DownloadStartMode - let versionSignature: String let pageCount: Int let downloadOptions: DownloadOptionsSnapshot let pageSelection: [Int]? init( mode: DownloadStartMode, - versionSignature: String, pageCount: Int, downloadOptions: DownloadOptionsSnapshot, pageSelection: [Int]? = nil ) { self.mode = mode - self.versionSignature = versionSignature self.pageCount = pageCount self.downloadOptions = downloadOptions self.pageSelection = pageSelection @@ -41,12 +38,10 @@ struct DownloadResumeState: Codable, Equatable { func matches( mode: DownloadStartMode, - versionSignature: String, pageCount: Int, downloadOptions: DownloadOptionsSnapshot ) -> Bool { self.mode == mode - && self.versionSignature == versionSignature && self.pageCount == pageCount && self.downloadOptions == downloadOptions } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift index 7d369c1bf..a2893349f 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift @@ -14,7 +14,7 @@ extension DownloadFeatureTestCase { storage: DownloadFileStorage, gid: String, manifest: DownloadManifest, pageCount: Int, omittingPage pageToOmit: Int? = nil, - versionSignature: String, + versionSignature _: String, mode: DownloadStartMode = .redownload, pageSelection: [Int]? = nil ) throws { @@ -46,8 +46,7 @@ extension DownloadFeatureTestCase { } try storage.writeResumeState( .init( - mode: mode, versionSignature: versionSignature, - pageCount: pageCount, downloadOptions: .init(), + mode: mode, pageCount: pageCount, downloadOptions: .init(), pageSelection: pageSelection ), folderURL: folderURL diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift index 7666d9fae..055562125 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift @@ -19,7 +19,6 @@ struct DownloadFileStorageStateTests { let resumeState = DownloadResumeState( mode: .update, - versionSignature: "hash:v2", pageCount: 27, downloadOptions: .init( threadLimit: 4, diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index d326d3866..87bf033e0 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -300,7 +300,7 @@ private extension DownloadProcessCacheTests { ) try storage.writeResumeState( .init( - mode: .redownload, versionSignature: oldVersionSignature, + mode: .redownload, pageCount: oldPageCount, downloadOptions: .init(), pageSelection: [pageIndex] ), folderURL: temporaryFolderURL diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 0c16b799f..0c967b04c 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -86,7 +86,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { ) defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } - let (updatedPageCount, updatedVersionSignature) = try await fetchAndInstallStub( + let updatedPageCount = try await fetchAndInstallStub( manager: manager, sessionID: sessionID, gid: gid, pageIndex: pageIndex, oldVersionSignature: oldVersionSignature ) @@ -113,7 +113,6 @@ struct DownloadProcessTests: DownloadFeatureTestCase { context: ProcessVerificationContext( gid: gid, updatedPageCount: updatedPageCount, - updatedVersionSignature: updatedVersionSignature, staleFolderURL: staleFolderURL ) ) @@ -162,7 +161,6 @@ private actor ProcessCompletionProbe { private struct ProcessVerificationContext { let gid: String let updatedPageCount: Int - let updatedVersionSignature: String let staleFolderURL: URL } @@ -170,7 +168,7 @@ private extension DownloadProcessTests { func fetchAndInstallStub( manager: DownloadManager, sessionID: String, gid: String, pageIndex: Int, oldVersionSignature: String - ) async throws -> (Int, String) { + ) async throws -> Int { let stubContent = StubHandlerContent( detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), mpvHTML: try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html"), @@ -191,7 +189,6 @@ private extension DownloadProcessTests { for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] ) let latestPayload = fetchResult.payload - let updatedVersionSignature = fetchResult.versionSignature if let coverURL = latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL { allowedImageURLs.insert(coverURL.absoluteString) installDownloadStubHandler( @@ -202,7 +199,7 @@ private extension DownloadProcessTests { let updatedPageCount = latestPayload.galleryDetail.pageCount #expect(updatedPageCount > pageIndex) #expect(updatedPageCount > 5) - return (updatedPageCount, updatedVersionSignature) + return updatedPageCount } func prepareStaleExistingFolder( @@ -235,7 +232,6 @@ private extension DownloadProcessTests { try storage.writeResumeState( .init( mode: .redownload, - versionSignature: oldVersionSignature, pageCount: oldPageCount, downloadOptions: .init(), pageSelection: [pageIndex] @@ -272,7 +268,6 @@ private extension DownloadProcessTests { ) let resumeState = try storage.readResumeState(folderURL: completedFolderURL) - #expect(resumeState.versionSignature == context.updatedVersionSignature) #expect(resumeState.pageCount == context.updatedPageCount) #expect(resumeState.pageSelection == nil) #expect(FileManager.default.fileExists(atPath: context.staleFolderURL.path) == false) diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 7c71e6f58..413345bcd 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -109,7 +109,6 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) let resumedState = try storage.readResumeState(folderURL: temporaryFolderURL) #expect(resumedState.mode == .update) - #expect(resumedState.versionSignature == updatedVersionSignature) #expect(resumedState.pageCount == pageCount) #expect(resumedState.pageSelection == nil) let resumedDownload = await immediateManager.testingFetchDownload(gid: gid) From 86594db7758611ae63a2dd0639bd3db72851a30e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 04:29:21 +0800 Subject: [PATCH 109/614] Drop preferred sig --- .../DownloadClient+SchedulingHelpers.swift | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index b9836089d..76b2aa2c2 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -84,29 +84,6 @@ extension DownloadManager { return .update } - func preferredVersionSignature( - for download: DownloadedGallery, - mode: DownloadStartMode, - resumeState: DownloadResumeState? - ) -> String { - switch mode { - case .update: - if let latestSignature = - download.latestRemoteVersionSignature, - !latestSignature.isEmpty { - return latestSignature - } - case .initial, .redownload, .repair: - break - } - - if !download.remoteVersionSignature.isEmpty { - return download.remoteVersionSignature - } - - return download.latestRemoteVersionSignature ?? "" - } - func preferredWorkingPageCount( for download: DownloadedGallery, mode: DownloadStartMode, From aa767e7494c851f1b96ab80d7075afbca342cdc0 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 04:34:09 +0800 Subject: [PATCH 110/614] Drop fetch sig --- .../App/Tools/Clients/DownloadClient+Execution.swift | 9 +-------- .../Clients/DownloadClient+ExecutionFetch.swift | 9 +-------- .../Clients/DownloadClient+ExecutionPerform.swift | 1 - .../App/Tools/Clients/DownloadClient+Manager.swift | 1 - .../Tools/Clients/DownloadClient+Persistence.swift | 12 ++++-------- .../Clients/DownloadClient+PublicAPIHelpers.swift | 10 ---------- .../Tests/Download/DownloadManagerStorageTests.swift | 3 +-- .../Download/DownloadRetryMinimalSourceTests.swift | 2 +- .../Download/DownloadRetryUpdateFallbackTests.swift | 3 ++- 9 files changed, 10 insertions(+), 40 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 2883e91db..e968e35c2 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -22,7 +22,6 @@ extension DownloadManager { let mode = queuedMode(for: download) let hadReadableFiles = storage.validate(download: download) == .valid - var fetchedVersionSignature: String? do { downloadErrors[gid] = nil @@ -33,7 +32,6 @@ extension DownloadManager { download: download, mode: mode ) - fetchedVersionSignature = result.versionSignature guard !Task.isCancelled else { return } await completeDownload( gid: gid, @@ -48,7 +46,6 @@ extension DownloadManager { originalDownload: download, mode: mode, hadReadableFiles: hadReadableFiles, - latestSignature: fetchedVersionSignature ) await handleProcessDownloadError(error: error, context: context) } @@ -92,7 +89,6 @@ extension DownloadManager { private struct ProcessDownloadResult { let folderRelativePath: String - let versionSignature: String } private func fetchNormalizeAndDownload( @@ -115,20 +111,17 @@ extension DownloadManager { let payload = normalizeFetchedPayload( fetchResult.payload, mode: mode, - versionSignature: fetchResult.versionSignature, existingResumeState: existingResumeState, rawPageSelection: rawPageSelection ) let folderRelativePath = folderRelativePath(for: payload) _ = try await performDownload( payload: payload, - versionSignature: fetchResult.versionSignature, folderRelativePath: folderRelativePath, existingDownload: download ) return ProcessDownloadResult( - folderRelativePath: folderRelativePath, - versionSignature: fetchResult.versionSignature + folderRelativePath: folderRelativePath ) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index 40b07ae4d..75605d11b 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -9,7 +9,6 @@ import Foundation extension DownloadManager { struct FetchLatestPayloadResult: Sendable { let payload: DownloadRequestPayload - let versionSignature: String } func fetchLatestPayload( @@ -67,10 +66,6 @@ extension DownloadManager { let download = fetchedData.download let detail = fetchedData.detail let versionMetadata = fetchedData.versionMetadata - let versionSignature = manifestVersionSignature( - for: components.gallery, - versionMetadata: versionMetadata - ) return FetchLatestPayloadResult( payload: .init( gallery: components.gallery, @@ -82,8 +77,7 @@ extension DownloadManager { options: download.downloadOptionsSnapshot, mode: mode, pageSelection: pageSelection.map(Set.init) - ), - versionSignature: versionSignature + ) ) } @@ -148,7 +142,6 @@ extension DownloadManager { func normalizeFetchedPayload( _ payload: DownloadRequestPayload, mode: DownloadStartMode, - versionSignature _: String, existingResumeState: DownloadResumeState?, rawPageSelection: [Int]? ) -> DownloadRequestPayload { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 3f02b3f3e..1f82c9264 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -14,7 +14,6 @@ extension DownloadManager { func performDownload( payload: DownloadRequestPayload, - versionSignature _: String, folderRelativePath: String, existingDownload: DownloadedGallery ) async throws -> PerformDownloadResult { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 5ebe0dc85..a06007d72 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -76,7 +76,6 @@ actor DownloadManager { let originalDownload: DownloadedGallery let mode: DownloadStartMode let hadReadableFiles: Bool - let latestSignature: String? } struct ProgressFlushContext: Sendable { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index bc5458a52..132282e88 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -368,14 +368,12 @@ extension DownloadManager { record.status = DownloadStatus.partial.rawValue record.completedPageCount = Int64(workingCompletedPageCount) record.latestRemoteVersionSignature = - context.latestSignature - ?? context.originalDownload.latestRemoteVersionSignature + context.originalDownload.latestRemoteVersionSignature } else { record.status = DownloadStatus.partial.rawValue record.completedPageCount = Int64(recoveredCompletedPageCount) record.latestRemoteVersionSignature = - context.latestSignature - ?? context.originalDownload.latestRemoteVersionSignature + context.originalDownload.latestRemoteVersionSignature } } @@ -394,8 +392,7 @@ extension DownloadManager { record.remoteVersionSignature = context.originalDownload.remoteVersionSignature record.latestRemoteVersionSignature = - context.latestSignature - ?? context.originalDownload.latestRemoteVersionSignature + context.originalDownload.latestRemoteVersionSignature } nonisolated private func applyFallbackFailureStatus( @@ -416,8 +413,7 @@ extension DownloadManager { record.remoteVersionSignature = context.originalDownload.remoteVersionSignature record.latestRemoteVersionSignature = - context.latestSignature - ?? context.originalDownload.latestRemoteVersionSignature + context.originalDownload.latestRemoteVersionSignature } func flushDownloadProgress( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index 98df967a8..da91fd545 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -8,16 +8,6 @@ import Foundation // MARK: - Private helpers for public API extension DownloadManager { - func manifestVersionSignature( - for gallery: Gallery, - versionMetadata: DownloadVersionMetadata? - ) -> String { - let gid = versionMetadata?.resolvedCurrentGID ?? gallery.gid - let token = versionMetadata?.resolvedCurrentKey ?? gallery.token - guard !gid.isEmpty, !token.isEmpty else { return "" } - return "chain:\(gid):\(token)" - } - func buildInspectionPages( download: DownloadedGallery, activeFolderURL: URL?, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 9321e94bc..b7e5cad1d 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -678,8 +678,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { gid: "800", originalDownload: download, mode: .initial, - hadReadableFiles: false, - latestSignature: nil + hadReadableFiles: false ) ) diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 28547724b..a3442503b 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -133,7 +133,7 @@ private extension DownloadRetryMinimalSourceTests { recorder.reset() return MinimalSourceTestResult( recorder: recorder, - versionSignature: fetchResult.versionSignature, + versionSignature: chainVersionSignature(gid: gid, token: "token"), pageCount: fetchResult.payload.galleryDetail.pageCount ) } diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 413345bcd..79049a120 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -168,7 +168,8 @@ private extension DownloadRetryUpdateFallbackTests { #expect(pageCount > pageIndex) #expect(pageCount > 5) return UpdateFallbackPayloadResult( - versionSignature: fetchResult.versionSignature, pageCount: pageCount + versionSignature: chainVersionSignature(gid: gid, token: "updated-key"), + pageCount: pageCount ) } From acf74bf2e086958a2bbf4694b74fba1103350c88 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 04:38:26 +0800 Subject: [PATCH 111/614] Return payload --- .../Clients/DownloadClient+Execution.swift | 4 +-- .../DownloadClient+ExecutionFetch.swift | 34 ++++++++----------- .../Clients/DownloadClient+Testing.swift | 2 +- .../Download/DownloadProcessCacheTests.swift | 4 +-- .../Tests/Download/DownloadProcessTests.swift | 3 +- .../DownloadRetryMinimalSourceTests.swift | 4 +-- .../DownloadRetryUpdateFallbackTests.swift | 4 +-- 7 files changed, 24 insertions(+), 31 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index e968e35c2..0cdb05335 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -103,13 +103,13 @@ extension DownloadManager { folderURL: storage.temporaryFolderURL(gid: gid) )) let rawPageSelection = existingResumeState?.pageSelection - let fetchResult = try await fetchLatestPayload( + let fetchedPayload = try await fetchLatestPayload( for: download, mode: mode, pageSelection: rawPageSelection ) let payload = normalizeFetchedPayload( - fetchResult.payload, + fetchedPayload, mode: mode, existingResumeState: existingResumeState, rawPageSelection: rawPageSelection diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index 75605d11b..b59646f31 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -7,15 +7,11 @@ import Foundation // MARK: - Fetch & Normalize Payload extension DownloadManager { - struct FetchLatestPayloadResult: Sendable { - let payload: DownloadRequestPayload - } - func fetchLatestPayload( for download: DownloadedGallery, mode: DownloadStartMode, pageSelection: [Int]? - ) async throws -> FetchLatestPayloadResult { + ) async throws -> DownloadRequestPayload { let galleryURL = download.gallery.galleryURL guard let galleryURL else { throw AppError.notFound } let detailResponse = try await GalleryDetailRequest( @@ -43,7 +39,7 @@ extension DownloadManager { detail: detail, versionMetadata: versionMetadata ) - return buildFetchResult( + return buildPayload( fetchedData: fetchedData, components: components, mode: mode, @@ -57,27 +53,25 @@ extension DownloadManager { let versionMetadata: DownloadVersionMetadata? } - private func buildFetchResult( + private func buildPayload( fetchedData: FetchedGalleryData, components: GalleryComponents, mode: DownloadStartMode, pageSelection: [Int]? - ) -> FetchLatestPayloadResult { + ) -> DownloadRequestPayload { let download = fetchedData.download let detail = fetchedData.detail let versionMetadata = fetchedData.versionMetadata - return FetchLatestPayloadResult( - payload: .init( - gallery: components.gallery, - galleryDetail: detail, - previewURLs: components.previewURLs, - previewConfig: components.previewConfig, - host: download.host, - versionMetadata: versionMetadata, - options: download.downloadOptionsSnapshot, - mode: mode, - pageSelection: pageSelection.map(Set.init) - ) + return .init( + gallery: components.gallery, + galleryDetail: detail, + previewURLs: components.previewURLs, + previewConfig: components.previewConfig, + host: download.host, + versionMetadata: versionMetadata, + options: download.downloadOptionsSnapshot, + mode: mode, + pageSelection: pageSelection.map(Set.init) ) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 39245e127..945e53e84 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -91,7 +91,7 @@ extension DownloadManager { for download: DownloadedGallery, mode: DownloadStartMode, pageSelection: [Int]? = nil - ) async throws -> FetchLatestPayloadResult { + ) async throws -> DownloadRequestPayload { try await fetchLatestPayload( for: download, mode: mode, diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 87bf033e0..103db00d1 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -216,7 +216,7 @@ private extension DownloadProcessCacheTests { ) let latestPayload = try await manager.testingFetchLatestPayload( for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] - ).payload + ) let coverURL = try #require( latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL ) @@ -248,7 +248,7 @@ private extension DownloadProcessCacheTests { let latestPayload = try await setup.manager.testingFetchLatestPayload( for: scaffoldDownload, mode: .redownload, pageSelection: [setup.pageIndex] - ).payload + ) let updatedPageCount = latestPayload.galleryDetail.pageCount let oldPageCount = updatedPageCount - 5 #expect(updatedPageCount > setup.pageIndex) diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 0c967b04c..4915a5e0f 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -185,10 +185,9 @@ private extension DownloadProcessTests { remoteVersionSignature: oldVersionSignature, latestRemoteVersionSignature: oldVersionSignature ) - let fetchResult = try await manager.testingFetchLatestPayload( + let latestPayload = try await manager.testingFetchLatestPayload( for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] ) - let latestPayload = fetchResult.payload if let coverURL = latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL { allowedImageURLs.insert(coverURL.absoluteString) installDownloadStubHandler( diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index a3442503b..12dc0c9d9 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -127,14 +127,14 @@ private extension DownloadRetryMinimalSourceTests { gid: gid, title: "Pause Race", status: .partial, pageCount: 156, completedPageCount: 155 ) - let fetchResult = try await manager.testingFetchLatestPayload( + let fetchedPayload = try await manager.testingFetchLatestPayload( for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] ) recorder.reset() return MinimalSourceTestResult( recorder: recorder, versionSignature: chainVersionSignature(gid: gid, token: "token"), - pageCount: fetchResult.payload.galleryDetail.pageCount + pageCount: fetchedPayload.galleryDetail.pageCount ) } } diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 79049a120..e0928eb3a 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -161,10 +161,10 @@ private extension DownloadRetryUpdateFallbackTests { remoteVersionSignature: oldVersionSignature, latestRemoteVersionSignature: "" ) - let fetchResult = try await manager.testingFetchLatestPayload( + let fetchedPayload = try await manager.testingFetchLatestPayload( for: scaffoldDownload, mode: .update ) - let pageCount = fetchResult.payload.galleryDetail.pageCount + let pageCount = fetchedPayload.galleryDetail.pageCount #expect(pageCount > pageIndex) #expect(pageCount > 5) return UpdateFallbackPayloadResult( From 34d13a1fec5d3335825553d21ebbdd42f7c4ca9d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 04:39:20 +0800 Subject: [PATCH 112/614] Derive gallery URL --- .../Clients/DownloadClient+ExecutionPerform.swift | 1 - .../Tools/Clients/DownloadClient+PublicAPI.swift | 4 ---- .../Utilities/DownloadFileStorage+Operations.swift | 1 - .../Persistent/DownloadedGallery+Manifest.swift | 10 +++++++--- .../Download/DownloadFeatureTestFactories.swift | 1 - .../Download/DownloadFileStorageHashTests.swift | 1 - .../Download/DownloadFileStorageRepairTests.swift | 2 -- .../Tests/Download/DownloadFileStorageTests.swift | 1 - .../Download/DownloadManagerCaptureTests.swift | 1 - .../Download/DownloadManagerStorageTests.swift | 1 - .../Download/DownloadVersionSignatureTests.swift | 1 - .../DownloadedGalleryManifestModelTests.swift | 13 ++++++++++++- 12 files changed, 19 insertions(+), 18 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 1f82c9264..b0eaee011 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -218,7 +218,6 @@ extension DownloadManager { postedDate: payload.galleryDetail.postedDate, pageCount: payload.galleryDetail.pageCount, coverRelativePath: coverRelativePath, - galleryURL: payload.gallery.galleryURL.forceUnwrapped, rating: payload.galleryDetail.rating, downloadOptions: payload.options, downloadedAt: .now, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 4dd435a99..f4abf37bc 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -98,9 +98,6 @@ extension DownloadManager { payload: DownloadRequestPayload, folderRelativePath: String ) throws { - guard let galleryURL = payload.gallery.galleryURL else { - throw AppError.notFound - } let folderURL = storage.folderURL(relativePath: folderRelativePath) try createDirectory(at: folderURL) let pageCount = payload.galleryDetail.pageCount @@ -131,7 +128,6 @@ extension DownloadManager { postedDate: payload.galleryDetail.postedDate, pageCount: pageCount, coverRelativePath: nil, - galleryURL: galleryURL, rating: payload.galleryDetail.rating, downloadOptions: payload.options, downloadedAt: .now, diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index 7debe620f..8b2b9d049 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -354,7 +354,6 @@ private extension DownloadManifest { pageCount: pageCount, coverRelativePath: coverRelativePath, coverFileHash: coverFileHash, - galleryURL: galleryURL, rating: rating, downloadOptions: downloadOptions, downloadedAt: downloadedAt, diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift index 0aa14ef71..abf6964ce 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -37,7 +37,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { let pageCount: Int let coverRelativePath: String? let coverFileHash: String? - let galleryURL: URL let rating: Float let downloadOptions: DownloadOptionsSnapshot let downloadedAt: Date @@ -57,7 +56,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { pageCount: Int, coverRelativePath: String?, coverFileHash: String? = nil, - galleryURL: URL, rating: Float, downloadOptions: DownloadOptionsSnapshot, downloadedAt: Date, @@ -76,7 +74,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { self.pageCount = pageCount self.coverRelativePath = coverRelativePath self.coverFileHash = coverFileHash - self.galleryURL = galleryURL self.rating = rating self.downloadOptions = downloadOptions self.downloadedAt = downloadedAt @@ -91,6 +88,13 @@ struct DownloadManifest: Codable, Equatable, Sendable { } extension DownloadManifest { + var galleryURL: URL { + host.url + .appendingPathComponent("g") + .appendingPathComponent(gid) + .appendingPathComponent(token) + } + var completedPageCount: Int { pages.filter { $0.fileHash?.isEmpty == false }.count } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 3f7e295cf..ef9c8c7c2 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -30,7 +30,6 @@ extension DownloadFeatureTestCase { postedDate: .now, pageCount: pageCount, coverRelativePath: "cover.jpg", - galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), downloadedAt: .now, diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index df53a452c..6cb5fe7d6 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -129,7 +129,6 @@ struct DownloadFileStorageHashTests { postedDate: .now, pageCount: pageCount, coverRelativePath: "cover.jpg", - galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), downloadedAt: .now, diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift index 136356be6..294954e27 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift @@ -113,7 +113,6 @@ private extension DownloadFileStorageRepairTests { gid: "123", host: .ehentai, token: "token", title: "Sample", jpnTitle: nil, category: .doujinshi, language: .japanese, uploader: "Uploader", tags: [], postedDate: .now, pageCount: 2, coverRelativePath: "cover.jpg", - galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), downloadedAt: .now, pages: [ @@ -208,7 +207,6 @@ private extension DownloadFileStorageRepairTests { postedDate: .now, pageCount: pageCount, coverRelativePath: "cover.jpg", - galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), downloadedAt: .now, diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index ff829f2a4..e250f4363 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -448,7 +448,6 @@ private extension DownloadFileStorageTests { postedDate: .now, pageCount: pageCount, coverRelativePath: "cover.jpg", - galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), downloadedAt: .now, diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index 818ef494e..a261653b1 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -147,7 +147,6 @@ private extension DownloadManagerCaptureTests { postedDate: .now, pageCount: 2, coverRelativePath: "cover.jpg", - galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), downloadedAt: .now, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index b7e5cad1d..9543b0214 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -1173,7 +1173,6 @@ private extension DownloadManagerStorageTests { postedDate: downloadedAt, pageCount: pageHashes.count, coverRelativePath: nil, - galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), downloadedAt: downloadedAt, diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 1fbede46c..af89e62cb 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -97,7 +97,6 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { postedDate: .now, pageCount: 1, coverRelativePath: nil, - galleryURL: try #require(URL(string: "https://e-hentai.org/g/\(gid)/token")), rating: 4, downloadOptions: DownloadOptionsSnapshot(), downloadedAt: .now, diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index 9d497bef7..9759b611f 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -16,6 +16,18 @@ struct DownloadedGalleryManifestModelTests { #expect(manifest.isComplete == false) } + @Test + func testManifestGalleryURLDerivesFromIdentityAndIsNotEncoded() throws { + let manifest = try sampleManifest(pageHashes: [1: "sha256:a"]) + let encoded = try JSONEncoder().encode(manifest) + let object = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + + #expect(manifest.galleryURL == URL(string: "https://e-hentai.org/g/123/token")) + #expect(object["galleryURL"] == nil) + } + @Test func testDownloadedGalleryViewModelUsesManifestAndRuntimeStatus() throws { let modifiedAt = Date(timeIntervalSince1970: 1_234) @@ -52,7 +64,6 @@ private extension DownloadedGalleryManifestModelTests { postedDate: Date(timeIntervalSince1970: 1_000), pageCount: pageHashes.count, coverRelativePath: "123_token_cover.jpg", - galleryURL: try #require(URL(string: "https://e-hentai.org/g/123/token")), rating: 4, downloadOptions: .init(threadLimit: 3), downloadedAt: Date(timeIntervalSince1970: 1_111), From 11c00dd36d5342481c143468c908fd57fcccce08 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 04:44:11 +0800 Subject: [PATCH 113/614] Derive page count --- .../Tools/Clients/DownloadClient+ExecutionPerform.swift | 1 - .../Tools/Clients/DownloadClient+ExecutionSupport.swift | 3 +-- .../Clients/DownloadClient+PersistenceNormalize.swift | 1 - EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift | 1 - .../Tools/Utilities/DownloadFileStorage+Operations.swift | 4 ---- EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift | 7 ++++--- .../Tests/Download/DownloadFeatureTestFactories.swift | 1 - .../Tests/Download/DownloadFileStorageHashTests.swift | 1 - .../Tests/Download/DownloadFileStorageRepairTests.swift | 3 +-- EhPandaTests/Tests/Download/DownloadFileStorageTests.swift | 1 - .../Tests/Download/DownloadManagerCaptureTests.swift | 1 - .../Tests/Download/DownloadManagerStorageTests.swift | 1 - .../Tests/Download/DownloadVersionSignatureTests.swift | 1 - .../Download/DownloadedGalleryManifestModelTests.swift | 7 ++++--- 14 files changed, 10 insertions(+), 23 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index b0eaee011..f46ee1eb0 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -216,7 +216,6 @@ extension DownloadManager { uploader: payload.galleryDetail.uploader, tags: payload.gallery.tags, postedDate: payload.galleryDetail.postedDate, - pageCount: payload.galleryDetail.pageCount, coverRelativePath: coverRelativePath, rating: payload.galleryDetail.rating, downloadOptions: payload.options, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 5694a8d67..884a7d1fd 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -377,8 +377,7 @@ extension DownloadManager { .readManifest(folderURL: folderURL), manifest.gid == download.gid, manifest.pageCount == - payload.galleryDetail.pageCount, - manifest.pages.count == manifest.pageCount + payload.galleryDetail.pageCount else { return nil } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 5fc62a746..d129eeb7d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -17,7 +17,6 @@ extension DownloadManager { .readManifest(folderURL: folderURL), manifest.gid == gid, manifest.pageCount == pageCount, - manifest.pages.count == pageCount, manifest.downloadOptions == downloadOptions else { return nil diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index f4abf37bc..531ef6557 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -126,7 +126,6 @@ extension DownloadManager { uploader: payload.galleryDetail.uploader, tags: payload.gallery.tags, postedDate: payload.galleryDetail.postedDate, - pageCount: pageCount, coverRelativePath: nil, rating: payload.galleryDetail.rating, downloadOptions: payload.options, diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index 8b2b9d049..c144dca1a 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -234,9 +234,6 @@ extension DownloadFileStorage { guard let manifest = try? readManifest(folderURL: folderURL) else { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestCorrupted) } - guard manifest.pageCount == manifest.pages.count else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadedPagesIncomplete) - } if let coverValidationFailure = validateCover( folderURL: folderURL, manifest: manifest @@ -351,7 +348,6 @@ private extension DownloadManifest { uploader: uploader, tags: tags, postedDate: postedDate, - pageCount: pageCount, coverRelativePath: coverRelativePath, coverFileHash: coverFileHash, rating: rating, diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift index abf6964ce..b160acbf4 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -34,7 +34,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { let uploader: String? let tags: [GalleryTag] let postedDate: Date - let pageCount: Int let coverRelativePath: String? let coverFileHash: String? let rating: Float @@ -53,7 +52,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { uploader: String?, tags: [GalleryTag], postedDate: Date, - pageCount: Int, coverRelativePath: String?, coverFileHash: String? = nil, rating: Float, @@ -71,7 +69,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { self.uploader = uploader self.tags = tags self.postedDate = postedDate - self.pageCount = pageCount self.coverRelativePath = coverRelativePath self.coverFileHash = coverFileHash self.rating = rating @@ -88,6 +85,10 @@ struct DownloadManifest: Codable, Equatable, Sendable { } extension DownloadManifest { + var pageCount: Int { + pages.count + } + var galleryURL: URL { host.url .appendingPathComponent("g") diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index ef9c8c7c2..0dedcb07b 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -28,7 +28,6 @@ extension DownloadFeatureTestCase { uploader: "Uploader", tags: [], postedDate: .now, - pageCount: pageCount, coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index 6cb5fe7d6..87cb9bcd6 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -127,7 +127,6 @@ struct DownloadFileStorageHashTests { uploader: "Uploader", tags: [], postedDate: .now, - pageCount: pageCount, coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift index 294954e27..384cba1b2 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift @@ -112,7 +112,7 @@ private extension DownloadFileStorageRepairTests { let manifest = DownloadManifest( gid: "123", host: .ehentai, token: "token", title: "Sample", jpnTitle: nil, category: .doujinshi, language: .japanese, uploader: "Uploader", tags: [], - postedDate: .now, pageCount: 2, coverRelativePath: "cover.jpg", + postedDate: .now, coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), downloadedAt: .now, pages: [ @@ -205,7 +205,6 @@ private extension DownloadFileStorageRepairTests { uploader: "Uploader", tags: [], postedDate: .now, - pageCount: pageCount, coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index e250f4363..b801ccd94 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -446,7 +446,6 @@ private extension DownloadFileStorageTests { uploader: "Uploader", tags: [], postedDate: .now, - pageCount: pageCount, coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index a261653b1..87c42e08b 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -145,7 +145,6 @@ private extension DownloadManagerCaptureTests { uploader: "Uploader", tags: [], postedDate: .now, - pageCount: 2, coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 9543b0214..c032c8962 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -1171,7 +1171,6 @@ private extension DownloadManagerStorageTests { uploader: "Uploader", tags: [], postedDate: downloadedAt, - pageCount: pageHashes.count, coverRelativePath: nil, rating: 4, downloadOptions: DownloadOptionsSnapshot(), diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index af89e62cb..39efbfe89 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -95,7 +95,6 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { uploader: "Uploader", tags: [], postedDate: .now, - pageCount: 1, coverRelativePath: nil, rating: 4, downloadOptions: DownloadOptionsSnapshot(), diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index 9759b611f..d4d0e4a0c 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -17,14 +17,16 @@ struct DownloadedGalleryManifestModelTests { } @Test - func testManifestGalleryURLDerivesFromIdentityAndIsNotEncoded() throws { - let manifest = try sampleManifest(pageHashes: [1: "sha256:a"]) + func testManifestDerivedFieldsAreNotEncoded() throws { + let manifest = try sampleManifest(pageHashes: [1: "sha256:a", 2: "sha256:b"]) let encoded = try JSONEncoder().encode(manifest) let object = try #require( JSONSerialization.jsonObject(with: encoded) as? [String: Any] ) + #expect(manifest.pageCount == 2) #expect(manifest.galleryURL == URL(string: "https://e-hentai.org/g/123/token")) + #expect(object["pageCount"] == nil) #expect(object["galleryURL"] == nil) } @@ -62,7 +64,6 @@ private extension DownloadedGalleryManifestModelTests { uploader: "Uploader", tags: [], postedDate: Date(timeIntervalSince1970: 1_000), - pageCount: pageHashes.count, coverRelativePath: "123_token_cover.jpg", rating: 4, downloadOptions: .init(threadLimit: 3), From d52a3a08259c5780b5986435f5b75e2aeec7a629 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 04:49:33 +0800 Subject: [PATCH 114/614] Drop downloaded date --- .../DownloadClient+ExecutionPerform.swift | 1 - .../Clients/DownloadClient+Persistence.swift | 2 +- .../Clients/DownloadClient+PublicAPI.swift | 1 - .../DownloadFileStorage+Operations.swift | 1 - .../DownloadedGallery+Manifest.swift | 3 --- .../Models/Persistent/DownloadedGallery.swift | 2 +- .../DownloadFeatureTestFactories.swift | 1 - .../DownloadFileStorageHashTests.swift | 1 - .../DownloadFileStorageRepairTests.swift | 2 -- .../Download/DownloadFileStorageTests.swift | 1 - .../DownloadManagerCaptureTests.swift | 1 - .../DownloadManagerStorageTests.swift | 21 +++++++++++-------- .../DownloadVersionSignatureTests.swift | 1 - .../DownloadedGalleryManifestModelTests.swift | 2 +- 14 files changed, 15 insertions(+), 25 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index f46ee1eb0..9f9912e49 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -219,7 +219,6 @@ extension DownloadManager { coverRelativePath: coverRelativePath, rating: payload.galleryDetail.rating, downloadOptions: payload.options, - downloadedAt: .now, pages: batchResult.pages .sorted(by: { $0.index < $1.index }) .map { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 132282e88..104ea3f2a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -106,7 +106,7 @@ extension DownloadManager { private extension DownloadFolderRecord { var displayDate: Date { - modifiedAt ?? manifest.downloadedAt + modifiedAt ?? .distantPast } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 531ef6557..afb16c7c9 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -129,7 +129,6 @@ extension DownloadManager { coverRelativePath: nil, rating: payload.galleryDetail.rating, downloadOptions: payload.options, - downloadedAt: .now, pages: pages ), folderURL: folderURL diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index c144dca1a..1f71a0d7a 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -352,7 +352,6 @@ private extension DownloadManifest { coverFileHash: coverFileHash, rating: rating, downloadOptions: downloadOptions, - downloadedAt: downloadedAt, pages: pages ) } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift index b160acbf4..a120ffc16 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -38,7 +38,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { let coverFileHash: String? let rating: Float let downloadOptions: DownloadOptionsSnapshot - let downloadedAt: Date let pages: [Page] init( @@ -56,7 +55,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { coverFileHash: String? = nil, rating: Float, downloadOptions: DownloadOptionsSnapshot, - downloadedAt: Date, pages: [Page] ) { self.gid = gid @@ -73,7 +71,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { self.coverFileHash = coverFileHash self.rating = rating self.downloadOptions = downloadOptions - self.downloadedAt = downloadedAt self.pages = pages } diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 192b9a609..bfe093ee2 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -278,7 +278,7 @@ struct DownloadedGallery: Identifiable, Equatable { coverRelativePath: manifest.coverRelativePath, status: displayStatus.downloadStatus, completedPageCount: manifest.completedPageCount, - lastDownloadedAt: modifiedAt ?? manifest.downloadedAt, + lastDownloadedAt: modifiedAt, lastError: lastError, downloadOptionsSnapshot: manifest.downloadOptions, remoteVersionSignature: "chain:\(manifest.gid):\(manifest.token)", diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 0dedcb07b..5d00da0de 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -31,7 +31,6 @@ extension DownloadFeatureTestCase { coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), - downloadedAt: .now, pages: (1...pageCount).map { .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index 87cb9bcd6..2134fc9e6 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -130,7 +130,6 @@ struct DownloadFileStorageHashTests { coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), - downloadedAt: .now, pages: (1...pageCount).map { .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift index 384cba1b2..875b261ab 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift @@ -114,7 +114,6 @@ private extension DownloadFileStorageRepairTests { category: .doujinshi, language: .japanese, uploader: "Uploader", tags: [], postedDate: .now, coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), - downloadedAt: .now, pages: [ .init(index: 1, relativePath: "pages/0001.jpg"), .init(index: 2, relativePath: "../escape.jpg") @@ -208,7 +207,6 @@ private extension DownloadFileStorageRepairTests { coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), - downloadedAt: .now, pages: (1...pageCount).map { .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index b801ccd94..9bee1a35f 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -449,7 +449,6 @@ private extension DownloadFileStorageTests { coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), - downloadedAt: .now, pages: (1...pageCount).map { .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") } diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index 87c42e08b..65c53de39 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -148,7 +148,6 @@ private extension DownloadManagerCaptureTests { coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), - downloadedAt: .now, pages: [ .init( index: 1, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index c032c8962..753a131a3 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -34,7 +34,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { gid: "100", title: "Complete", pageHashes: ["sha256:1", "sha256:2"], - downloadedAt: Date(timeIntervalSince1970: 100) + modifiedAt: Date(timeIntervalSince1970: 100) ) ) try writeIndexedManifest( @@ -44,7 +44,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { gid: "200", title: "Queued", pageHashes: ["sha256:1", ""], - downloadedAt: Date(timeIntervalSince1970: 200) + modifiedAt: Date(timeIntervalSince1970: 200) ) ) try FileManager.default.createDirectory( @@ -90,7 +90,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { gid: "500", title: "Old", pageHashes: ["sha256:old"], - downloadedAt: olderDate + modifiedAt: olderDate ) ) try setFolderModificationDate( @@ -105,7 +105,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { gid: "500", title: "New", pageHashes: ["sha256:new"], - downloadedAt: newerDate + modifiedAt: newerDate ) ) try setFolderModificationDate( @@ -866,7 +866,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { gid: "830", title: "First", pageHashes: [""], - downloadedAt: Date(timeIntervalSince1970: 100) + modifiedAt: Date(timeIntervalSince1970: 100) ) ) try writeIndexedManifest( @@ -876,7 +876,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { gid: "831", title: "Newer", pageHashes: [""], - downloadedAt: Date(timeIntervalSince1970: 200) + modifiedAt: Date(timeIntervalSince1970: 200) ) ) await queueStore.enqueue("830") @@ -1151,13 +1151,17 @@ private extension DownloadManagerStorageTests { withIntermediateDirectories: true ) try storage.writeManifest(manifest, folderURL: folderURL) + try FileManager.default.setAttributes( + [.modificationDate: manifest.postedDate], + ofItemAtPath: folderURL.path + ) } func indexedManifest( gid: String, title: String, pageHashes: [String], - downloadedAt: Date = .now, + modifiedAt: Date = .now, pageRelativePaths: [String]? = nil ) throws -> DownloadManifest { DownloadManifest( @@ -1170,11 +1174,10 @@ private extension DownloadManagerStorageTests { language: .japanese, uploader: "Uploader", tags: [], - postedDate: downloadedAt, + postedDate: modifiedAt, coverRelativePath: nil, rating: 4, downloadOptions: DownloadOptionsSnapshot(), - downloadedAt: downloadedAt, pages: pageHashes.enumerated().map { offset, hash in DownloadManifest.Page( index: offset + 1, diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 39efbfe89..0cd6ef520 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -98,7 +98,6 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { coverRelativePath: nil, rating: 4, downloadOptions: DownloadOptionsSnapshot(), - downloadedAt: .now, pages: [ .init( index: 1, diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index d4d0e4a0c..290b6b690 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -28,6 +28,7 @@ struct DownloadedGalleryManifestModelTests { #expect(manifest.galleryURL == URL(string: "https://e-hentai.org/g/123/token")) #expect(object["pageCount"] == nil) #expect(object["galleryURL"] == nil) + #expect(object["downloadedAt"] == nil) } @Test @@ -67,7 +68,6 @@ private extension DownloadedGalleryManifestModelTests { coverRelativePath: "123_token_cover.jpg", rating: 4, downloadOptions: .init(threadLimit: 3), - downloadedAt: Date(timeIntervalSince1970: 1_111), pages: pageHashes.sorted(by: { $0.key < $1.key }).map { index, hash in .init( index: index, From 896b6a6cc0a3fb769df7df230ed15453dae6aa94 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 04:54:42 +0800 Subject: [PATCH 115/614] Drop cover hash --- .../DownloadClient+PersistenceNormalize.swift | 4 +-- .../DownloadFileStorage+Operations.swift | 23 ------------ .../DownloadedGallery+Manifest.swift | 35 ------------------- .../DownloadedGalleryManifestModelTests.swift | 1 + 4 files changed, 2 insertions(+), 61 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index d129eeb7d..5a7f66d16 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -270,8 +270,6 @@ extension DownloadManager { private extension DownloadManifest { var needsFileHashRefresh: Bool { - let needsCoverHash = coverRelativePath?.nonEmpty != nil - && coverFileHash == nil - return needsCoverHash || pages.contains { $0.fileHash == nil } + pages.contains { $0.fileHash == nil } } } diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index 1f71a0d7a..fad80a84e 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -91,18 +91,6 @@ extension DownloadFileStorage { to manifest: DownloadManifest, folderURL: URL ) throws -> DownloadManifest { - let coverFileHash: String? - if let coverRelativePath = manifest.coverRelativePath, - !coverRelativePath.isEmpty { - coverFileHash = try hashReadableAsset( - folderURL: folderURL, - relativePath: coverRelativePath, - missingMessage: L10n.Localizable.DownloadFileStorage.Validation.coverImageMissing - ) - } else { - coverFileHash = nil - } - let pages = try manifest.pages.map { page in DownloadManifest.Page( index: page.index, @@ -116,7 +104,6 @@ extension DownloadFileStorage { } return manifest.replacing( - coverFileHash: coverFileHash, pages: pages ) } @@ -186,7 +173,6 @@ extension DownloadFileStorage { guard didUpdate else { return manifest } let refreshedManifest = manifest.replacing( - coverFileHash: manifest.coverFileHash, pages: pages ) if refreshedManifest != manifest { @@ -289,13 +275,6 @@ extension DownloadFileStorage { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.coverImageMissing) } - if let expectedHash = manifest.coverFileHash, - (try? fileHash(at: coverURL)) != expectedHash { - return .missingFiles( - L10n.Localizable.DownloadFileStorage.Validation.coverImageCorrupted - ) - } - return nil } @@ -334,7 +313,6 @@ extension DownloadFileStorage { private extension DownloadManifest { func replacing( - coverFileHash: String?, pages: [Page] ) -> DownloadManifest { DownloadManifest( @@ -349,7 +327,6 @@ private extension DownloadManifest { tags: tags, postedDate: postedDate, coverRelativePath: coverRelativePath, - coverFileHash: coverFileHash, rating: rating, downloadOptions: downloadOptions, pages: pages diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift index a120ffc16..70b075ba5 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -35,45 +35,10 @@ struct DownloadManifest: Codable, Equatable, Sendable { let tags: [GalleryTag] let postedDate: Date let coverRelativePath: String? - let coverFileHash: String? let rating: Float let downloadOptions: DownloadOptionsSnapshot let pages: [Page] - init( - gid: String, - host: GalleryHost, - token: String, - title: String, - jpnTitle: String?, - category: Category, - language: Language, - uploader: String?, - tags: [GalleryTag], - postedDate: Date, - coverRelativePath: String?, - coverFileHash: String? = nil, - rating: Float, - downloadOptions: DownloadOptionsSnapshot, - pages: [Page] - ) { - self.gid = gid - self.host = host - self.token = token - self.title = title - self.jpnTitle = jpnTitle - self.category = category - self.language = language - self.uploader = uploader - self.tags = tags - self.postedDate = postedDate - self.coverRelativePath = coverRelativePath - self.coverFileHash = coverFileHash - self.rating = rating - self.downloadOptions = downloadOptions - self.pages = pages - } - func imageURLs(folderURL: URL) -> [Int: URL] { Dictionary(uniqueKeysWithValues: pages.map { ($0.index, folderURL.appendingPathComponent($0.relativePath)) diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index 290b6b690..52e2b4626 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -28,6 +28,7 @@ struct DownloadedGalleryManifestModelTests { #expect(manifest.galleryURL == URL(string: "https://e-hentai.org/g/123/token")) #expect(object["pageCount"] == nil) #expect(object["galleryURL"] == nil) + #expect(object["coverFileHash"] == nil) #expect(object["downloadedAt"] == nil) } From 28956c0403c2946c905c819d03967ecf2905aecb Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 05:05:23 +0800 Subject: [PATCH 116/614] Drop cover path --- .../DownloadClient+ExecutionPerform.swift | 1 - .../DownloadClient+ExecutionSupport.swift | 7 ++--- .../Clients/DownloadClient+PublicAPI.swift | 1 - .../DownloadFileStorage+Operations.swift | 27 +---------------- .../DownloadedGallery+Manifest.swift | 1 - .../DownloadedGallery+SupportTypes.swift | 30 ++++++++----------- .../Models/Persistent/DownloadedGallery.swift | 2 +- .../DownloadFeatureTestFactories.swift | 1 - .../DownloadFileStorageHashTests.swift | 1 - .../DownloadFileStorageRepairTests.swift | 3 +- .../Download/DownloadFileStorageTests.swift | 1 - .../DownloadManagerCaptureTests.swift | 9 +++--- .../DownloadManagerStorageTests.swift | 1 - .../DownloadVersionSignatureTests.swift | 1 - .../DownloadedGalleryManifestModelTests.swift | 2 +- 15 files changed, 24 insertions(+), 64 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 9f9912e49..ba38760e2 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -216,7 +216,6 @@ extension DownloadManager { uploader: payload.galleryDetail.uploader, tags: payload.gallery.tags, postedDate: payload.galleryDetail.postedDate, - coverRelativePath: coverRelativePath, rating: payload.galleryDetail.rating, downloadOptions: payload.options, pages: batchResult.pages diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 884a7d1fd..1d4cc36ef 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -211,10 +211,9 @@ extension DownloadManager { folderURL: folderURL, expectedPageCount: payload.galleryDetail.pageCount ) - let coverRelativePath = manifest?.coverRelativePath - ?? storage.existingCoverRelativePath( - folderURL: folderURL - ) + let coverRelativePath = storage.existingCoverRelativePath( + folderURL: folderURL + ) return .init( folderURL: folderURL, manifest: manifest, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index afb16c7c9..ecc4694fb 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -126,7 +126,6 @@ extension DownloadManager { uploader: payload.galleryDetail.uploader, tags: payload.gallery.tags, postedDate: payload.galleryDetail.postedDate, - coverRelativePath: nil, rating: payload.galleryDetail.rating, downloadOptions: payload.options, pages: pages diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index fad80a84e..0aca3a623 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -69,8 +69,7 @@ extension DownloadFileStorage { to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) ) - if let coverRelativePath = manifest.coverRelativePath, - !coverRelativePath.isEmpty, + if let coverRelativePath = existingCoverRelativePath(folderURL: sourceFolderURL), let sourceCoverURL = validatedChildURL(root: sourceFolderURL, relativePath: coverRelativePath), let destCoverURL = validatedChildURL(root: temporaryFolderURL, relativePath: coverRelativePath) { if sanitizeAssetFileIfNeeded(at: sourceCoverURL) { @@ -220,12 +219,6 @@ extension DownloadFileStorage { guard let manifest = try? readManifest(folderURL: folderURL) else { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestCorrupted) } - if let coverValidationFailure = validateCover( - folderURL: folderURL, - manifest: manifest - ) { - return coverValidationFailure - } if let pageValidationFailure = validatePages( folderURL: folderURL, pages: manifest.pages @@ -261,23 +254,6 @@ extension DownloadFileStorage { return try fileHash(at: fileURL) } - private func validateCover( - folderURL: URL, - manifest: DownloadManifest - ) -> DownloadValidationState? { - guard let coverRelativePath = manifest.coverRelativePath, - !coverRelativePath.isEmpty - else { return nil } - - guard let coverURL = validatedChildURL(root: folderURL, relativePath: coverRelativePath), - sanitizeAssetFileIfNeeded(at: coverURL) - else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.coverImageMissing) - } - - return nil - } - private func validatePages( folderURL: URL, pages: [DownloadManifest.Page] @@ -326,7 +302,6 @@ private extension DownloadManifest { uploader: uploader, tags: tags, postedDate: postedDate, - coverRelativePath: coverRelativePath, rating: rating, downloadOptions: downloadOptions, pages: pages diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift index 70b075ba5..e22469697 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -34,7 +34,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { let uploader: String? let tags: [GalleryTag] let postedDate: Date - let coverRelativePath: String? let rating: Float let downloadOptions: DownloadOptionsSnapshot let pages: [Page] diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index b6b849afd..ed7f0f21e 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -34,15 +34,18 @@ extension DownloadedGallery { } func resolvedLocalCoverURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL? { - guard let coverRelativePath, - !coverRelativePath.isEmpty - else { return nil } let folderURL = resolvedFolderURL(rootURL: rootURL) - let coverURL = folderURL.appendingPathComponent(coverRelativePath) - guard isReadableLocalAssetFile(coverURL) else { - return nil + if let coverRelativePath, + !coverRelativePath.isEmpty { + let coverURL = folderURL.appendingPathComponent(coverRelativePath) + if isReadableLocalAssetFile(coverURL) { + return coverURL + } } - return coverURL + + return DownloadFileStorage(rootURL: rootURL) + .existingCoverRelativePath(folderURL: folderURL) + .map { folderURL.appendingPathComponent($0) } } func resolvedTemporaryCoverURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL? { @@ -63,16 +66,9 @@ extension DownloadedGallery { } } - guard let fileURLs = try? FileManager.default.contentsOfDirectory( - at: temporaryFolderURL, - includingPropertiesForKeys: nil - ) else { - return nil - } - - return fileURLs.first(where: { - $0.lastPathComponent.hasPrefix("cover.") && isReadableLocalAssetFile($0) - }) + return DownloadFileStorage(rootURL: rootURL) + .existingCoverRelativePath(folderURL: temporaryFolderURL) + .map { temporaryFolderURL.appendingPathComponent($0) } } func resolvedCoverURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL? { diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index bfe093ee2..26c02c4d5 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -275,7 +275,7 @@ struct DownloadedGallery: Identifiable, Equatable { rating: manifest.rating, onlineCoverURL: nil, folderRelativePath: folderRelativePath, - coverRelativePath: manifest.coverRelativePath, + coverRelativePath: nil, status: displayStatus.downloadStatus, completedPageCount: manifest.completedPageCount, lastDownloadedAt: modifiedAt, diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 5d00da0de..b400f807f 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -28,7 +28,6 @@ extension DownloadFeatureTestCase { uploader: "Uploader", tags: [], postedDate: .now, - coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), pages: (1...pageCount).map { diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index 2134fc9e6..52ee7684a 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -127,7 +127,6 @@ struct DownloadFileStorageHashTests { uploader: "Uploader", tags: [], postedDate: .now, - coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), pages: (1...pageCount).map { diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift index 875b261ab..65bf6f1d6 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift @@ -112,7 +112,7 @@ private extension DownloadFileStorageRepairTests { let manifest = DownloadManifest( gid: "123", host: .ehentai, token: "token", title: "Sample", jpnTitle: nil, category: .doujinshi, language: .japanese, uploader: "Uploader", tags: [], - postedDate: .now, coverRelativePath: "cover.jpg", + postedDate: .now, rating: 4, downloadOptions: DownloadOptionsSnapshot(), pages: [ .init(index: 1, relativePath: "pages/0001.jpg"), @@ -204,7 +204,6 @@ private extension DownloadFileStorageRepairTests { uploader: "Uploader", tags: [], postedDate: .now, - coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), pages: (1...pageCount).map { diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 9bee1a35f..5d7d882c0 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -446,7 +446,6 @@ private extension DownloadFileStorageTests { uploader: "Uploader", tags: [], postedDate: .now, - coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), pages: (1...pageCount).map { diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index 65c53de39..cec2b917b 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -41,7 +41,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { withIntermediateDirectories: true ) - let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg")) + let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in UIColor.systemBlue.setFill() context.fill(.init(x: 0, y: 0, width: 1, height: 1)) @@ -92,7 +92,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { let completedFolderURL = try setupCaptureMissingFilesFolder( rootURL: rootURL, gid: gid ) - let (imageURL, cacheKey) = try await setupCaptureCachedImage() + let (imageURL, cacheKey) = try await setupCaptureCachedImage(gid: gid) defer { KingfisherManager.shared.cache.removeImage(forKey: cacheKey) KingfisherManager.shared.cache.removeImage(forKey: imageURL.absoluteString) @@ -145,7 +145,6 @@ private extension DownloadManagerCaptureTests { uploader: "Uploader", tags: [], postedDate: .now, - coverRelativePath: "cover.jpg", rating: 4, downloadOptions: DownloadOptionsSnapshot(), pages: [ @@ -169,8 +168,8 @@ private extension DownloadManagerCaptureTests { } @MainActor - func setupCaptureCachedImage() async throws -> (URL, String) { - let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg")) + func setupCaptureCachedImage(gid: String) async throws -> (URL, String) { + let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in UIColor.systemOrange.setFill() context.fill(.init(x: 0, y: 0, width: 1, height: 1)) diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 753a131a3..82c38528e 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -1175,7 +1175,6 @@ private extension DownloadManagerStorageTests { uploader: "Uploader", tags: [], postedDate: modifiedAt, - coverRelativePath: nil, rating: 4, downloadOptions: DownloadOptionsSnapshot(), pages: pageHashes.enumerated().map { offset, hash in diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 0cd6ef520..2cad82d3b 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -95,7 +95,6 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { uploader: "Uploader", tags: [], postedDate: .now, - coverRelativePath: nil, rating: 4, downloadOptions: DownloadOptionsSnapshot(), pages: [ diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index 52e2b4626..89bbf8b97 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -29,6 +29,7 @@ struct DownloadedGalleryManifestModelTests { #expect(object["pageCount"] == nil) #expect(object["galleryURL"] == nil) #expect(object["coverFileHash"] == nil) + #expect(object["coverRelativePath"] == nil) #expect(object["downloadedAt"] == nil) } @@ -66,7 +67,6 @@ private extension DownloadedGalleryManifestModelTests { uploader: "Uploader", tags: [], postedDate: Date(timeIntervalSince1970: 1_000), - coverRelativePath: "123_token_cover.jpg", rating: 4, downloadOptions: .init(threadLimit: 3), pages: pageHashes.sorted(by: { $0.key < $1.key }).map { index, hash in From ba4cab659abb9f56bac0fad4759f86717f8f6ec9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 05:16:41 +0800 Subject: [PATCH 117/614] Drop options --- .../DownloadClient+ExecutionPerform.swift | 1 - .../DownloadClient+ExecutionSupport.swift | 7 ++-- .../Clients/DownloadClient+Manager.swift | 5 +++ .../Clients/DownloadClient+Persistence.swift | 32 +++++++++++++------ .../DownloadClient+PersistenceNormalize.swift | 6 ++-- .../Clients/DownloadClient+PublicAPI.swift | 1 - .../App/Tools/Clients/DownloadClient.swift | 5 ++- .../DownloadFileStorage+Operations.swift | 1 - .../DownloadedGallery+Manifest.swift | 1 - .../Models/Persistent/DownloadedGallery.swift | 3 +- .../DownloadEnqueueManifestTests.swift | 11 ++++++- .../DownloadFeatureTestFactories.swift | 1 - .../DownloadFileStorageHashTests.swift | 1 - .../DownloadFileStorageRepairTests.swift | 3 +- .../Download/DownloadFileStorageTests.swift | 1 - .../DownloadManagerCaptureTests.swift | 1 - .../DownloadManagerStorageTests.swift | 1 - .../DownloadVersionSignatureTests.swift | 1 - .../DownloadedGalleryManifestModelTests.swift | 5 +-- 19 files changed, 51 insertions(+), 36 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index ba38760e2..99fe32e50 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -217,7 +217,6 @@ extension DownloadManager { tags: payload.gallery.tags, postedDate: payload.galleryDetail.postedDate, rating: payload.galleryDetail.rating, - downloadOptions: payload.options, pages: batchResult.pages .sorted(by: { $0.index < $1.index }) .map { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 1d4cc36ef..45f577e93 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -204,8 +204,7 @@ extension DownloadManager { let manifest = validatedManifest( at: folderURL, gid: payload.gallery.gid, - pageCount: payload.galleryDetail.pageCount, - downloadOptions: payload.options + pageCount: payload.galleryDetail.pageCount ) let existingPages = storage.existingPageRelativePaths( folderURL: folderURL, @@ -245,7 +244,6 @@ extension DownloadManager { return manifest.gid == payload.gallery.gid && manifest.token == payload.gallery.token && manifest.pageCount == payload.galleryDetail.pageCount - && manifest.downloadOptions == payload.options case .repair: return true case .redownload, .update: @@ -306,8 +304,7 @@ extension DownloadManager { let manifest = validatedManifest( at: temporaryFolderURL, gid: payload.gallery.gid, - pageCount: payload.galleryDetail.pageCount, - downloadOptions: payload.options + pageCount: payload.galleryDetail.pageCount ), manifest.token == payload.gallery.token else { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index a06007d72..84cd7625e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -131,6 +131,7 @@ actor DownloadManager { let storage: DownloadFileStorage let urlSession: URLSession let libraryClient: LibraryClient + let downloadOptionsProvider: @Sendable () async -> DownloadOptionsSnapshot let queueStore: DownloadQueueStore let persistenceContainer: NSPersistentContainer var downloadIndex = [String: DownloadFolderRecord]() @@ -153,12 +154,16 @@ actor DownloadManager { storage: DownloadFileStorage, urlSession: URLSession, libraryClient: LibraryClient = .live, + downloadOptionsProvider: @escaping @Sendable () async -> DownloadOptionsSnapshot = { + DownloadOptionsSnapshot() + }, queueStore: DownloadQueueStore? = nil, persistenceContainer: NSPersistentContainer = PersistenceController.shared.container ) { self.storage = storage self.urlSession = urlSession self.libraryClient = libraryClient + self.downloadOptionsProvider = downloadOptionsProvider self.queueStore = queueStore ?? DownloadQueueStore(fileURL: storage.queueURL()) self.persistenceContainer = persistenceContainer } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 104ea3f2a..6ea312b38 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -13,7 +13,7 @@ extension DownloadManager { do { let records = try storage.scanDownloadFolders() downloadIndex = deduplicatedDownloadIndex(from: records) - return downloads(from: records) + return await downloads(from: records) } catch { Logger.error(error) downloadIndex = [:] @@ -21,20 +21,30 @@ extension DownloadManager { } } - func indexedDownload(gid: String) -> DownloadedGallery? { + func indexedDownload(gid: String) async -> DownloadedGallery? { guard let record = downloadIndex[gid] else { return nil } - return downloadedGallery(from: record) + let downloadOptionsSnapshot = await downloadOptionsProvider() + return downloadedGallery( + from: record, + downloadOptionsSnapshot: downloadOptionsSnapshot + ) } - func indexedDownloads() -> [DownloadedGallery] { - downloads(from: Array(downloadIndex.values)) + func indexedDownloads() async -> [DownloadedGallery] { + await downloads(from: Array(downloadIndex.values)) } private func downloads( from records: [DownloadFolderRecord] - ) -> [DownloadedGallery] { - deduplicatedDownloadIndex(from: records).values - .map { downloadedGallery(from: $0) } + ) async -> [DownloadedGallery] { + let downloadOptionsSnapshot = await downloadOptionsProvider() + return deduplicatedDownloadIndex(from: records).values + .map { + downloadedGallery( + from: $0, + downloadOptionsSnapshot: downloadOptionsSnapshot + ) + } .sorted(by: sortDownloadsByDisplayStatus) } @@ -54,7 +64,8 @@ extension DownloadManager { } private func downloadedGallery( - from record: DownloadFolderRecord + from record: DownloadFolderRecord, + downloadOptionsSnapshot: DownloadOptionsSnapshot ) -> DownloadedGallery { let gid = record.manifest.gid return DownloadedGallery( @@ -62,6 +73,7 @@ extension DownloadManager { folderRelativePath: record.relativePath, modifiedAt: record.modifiedAt, displayStatus: displayStatus(for: record), + downloadOptionsSnapshot: downloadOptionsSnapshot, lastError: validationErrors[gid] ?? downloadErrors[gid] ) } @@ -116,7 +128,7 @@ extension DownloadManager { gid: String ) async -> DownloadedGallery? { _ = await reloadDownloadIndex() - if let indexedDownload = indexedDownload(gid: gid) { + if let indexedDownload = await indexedDownload(gid: gid) { return indexedDownload } return await fetchDownloadFromCoreData(gid: gid) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 5a7f66d16..261708c34 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -10,14 +10,12 @@ extension DownloadManager { func validatedManifest( at folderURL: URL, gid: String, - pageCount: Int, - downloadOptions: DownloadOptionsSnapshot + pageCount: Int ) -> DownloadManifest? { guard let manifest = try? storage .readManifest(folderURL: folderURL), manifest.gid == gid, - manifest.pageCount == pageCount, - manifest.downloadOptions == downloadOptions + manifest.pageCount == pageCount else { return nil } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index ecc4694fb..4fc126230 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -127,7 +127,6 @@ extension DownloadManager { tags: payload.gallery.tags, postedDate: payload.galleryDetail.postedDate, rating: payload.galleryDetail.rating, - downloadOptions: payload.options, pages: pages ), folderURL: folderURL diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index bde52e359..75abd7038 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -86,7 +86,10 @@ extension DownloadClient { ) -> Self { let manager = DownloadManager( storage: .init(rootURL: rootURL, fileManager: fileManager), - urlSession: urlSession + urlSession: urlSession, + downloadOptionsProvider: { + await DatabaseClient.live.fetchAppEnv().setting.downloadOptionsSnapshot + } ) Task { await manager.reconcileDownloads() diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index 0aca3a623..395f24a33 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -303,7 +303,6 @@ private extension DownloadManifest { tags: tags, postedDate: postedDate, rating: rating, - downloadOptions: downloadOptions, pages: pages ) } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift index e22469697..d220f5c4c 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -35,7 +35,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { let tags: [GalleryTag] let postedDate: Date let rating: Float - let downloadOptions: DownloadOptionsSnapshot let pages: [Page] func imageURLs(folderURL: URL) -> [Int: URL] { diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 26c02c4d5..8f812b130 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -259,6 +259,7 @@ struct DownloadedGallery: Identifiable, Equatable { folderRelativePath: String, modifiedAt: Date?, displayStatus: DownloadDisplayStatus, + downloadOptionsSnapshot: DownloadOptionsSnapshot, lastError: DownloadFailure? = nil ) { self.init( @@ -280,7 +281,7 @@ struct DownloadedGallery: Identifiable, Equatable { completedPageCount: manifest.completedPageCount, lastDownloadedAt: modifiedAt, lastError: lastError, - downloadOptionsSnapshot: manifest.downloadOptions, + downloadOptionsSnapshot: downloadOptionsSnapshot, remoteVersionSignature: "chain:\(manifest.gid):\(manifest.token)", latestRemoteVersionSignature: "chain:\(manifest.gid):\(manifest.token)" ) diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index 3c79a9060..0fa96e156 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -60,7 +60,16 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { #expect(manifest.pageCount == detail.pageCount) #expect(manifest.pages.count == detail.pageCount) #expect(manifest.pages.first?.relativePath == "\(gallery.gid)_\(gallery.token)_1.pending") - #expect(manifest.downloadOptions.threadLimit == 3) + + let manifestData = try Data( + contentsOf: storage + .folderURL(relativePath: folderRelativePath) + .appendingPathComponent(Defaults.FilePath.downloadManifest) + ) + let manifestObject = try #require( + JSONSerialization.jsonObject(with: manifestData) as? [String: Any] + ) + #expect(manifestObject["downloadOptions"] == nil) let request = NSFetchRequest( entityName: "DownloadedGalleryMO" diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index b400f807f..b3b248a25 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -29,7 +29,6 @@ extension DownloadFeatureTestCase { tags: [], postedDate: .now, rating: 4, - downloadOptions: DownloadOptionsSnapshot(), pages: (1...pageCount).map { .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index 52ee7684a..a91ef1278 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -128,7 +128,6 @@ struct DownloadFileStorageHashTests { tags: [], postedDate: .now, rating: 4, - downloadOptions: DownloadOptionsSnapshot(), pages: (1...pageCount).map { .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift index 65bf6f1d6..a79814091 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift @@ -113,7 +113,7 @@ private extension DownloadFileStorageRepairTests { gid: "123", host: .ehentai, token: "token", title: "Sample", jpnTitle: nil, category: .doujinshi, language: .japanese, uploader: "Uploader", tags: [], postedDate: .now, - rating: 4, downloadOptions: DownloadOptionsSnapshot(), + rating: 4, pages: [ .init(index: 1, relativePath: "pages/0001.jpg"), .init(index: 2, relativePath: "../escape.jpg") @@ -205,7 +205,6 @@ private extension DownloadFileStorageRepairTests { tags: [], postedDate: .now, rating: 4, - downloadOptions: DownloadOptionsSnapshot(), pages: (1...pageCount).map { .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 5d7d882c0..ce5d1d573 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -447,7 +447,6 @@ private extension DownloadFileStorageTests { tags: [], postedDate: .now, rating: 4, - downloadOptions: DownloadOptionsSnapshot(), pages: (1...pageCount).map { .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") } diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index cec2b917b..9d819dc2f 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -146,7 +146,6 @@ private extension DownloadManagerCaptureTests { tags: [], postedDate: .now, rating: 4, - downloadOptions: DownloadOptionsSnapshot(), pages: [ .init( index: 1, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 82c38528e..4a98b4fd2 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -1176,7 +1176,6 @@ private extension DownloadManagerStorageTests { tags: [], postedDate: modifiedAt, rating: 4, - downloadOptions: DownloadOptionsSnapshot(), pages: pageHashes.enumerated().map { offset, hash in DownloadManifest.Page( index: offset + 1, diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 2cad82d3b..a8fef630d 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -96,7 +96,6 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { tags: [], postedDate: .now, rating: 4, - downloadOptions: DownloadOptionsSnapshot(), pages: [ .init( index: 1, diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index 89bbf8b97..03129a9ea 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -30,6 +30,7 @@ struct DownloadedGalleryManifestModelTests { #expect(object["galleryURL"] == nil) #expect(object["coverFileHash"] == nil) #expect(object["coverRelativePath"] == nil) + #expect(object["downloadOptions"] == nil) #expect(object["downloadedAt"] == nil) } @@ -42,7 +43,8 @@ struct DownloadedGalleryManifestModelTests { manifest: manifest, folderRelativePath: "[123_token] Sample", modifiedAt: modifiedAt, - displayStatus: .queued + displayStatus: .queued, + downloadOptionsSnapshot: .init(threadLimit: 3) ) #expect(download.gid == "123") @@ -68,7 +70,6 @@ private extension DownloadedGalleryManifestModelTests { tags: [], postedDate: Date(timeIntervalSince1970: 1_000), rating: 4, - downloadOptions: .init(threadLimit: 3), pages: pageHashes.sorted(by: { $0.key < $1.key }).map { index, hash in .init( index: index, From 01ddc11341d143e101fb8a3d81484aebc3881f3f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 05:35:53 +0800 Subject: [PATCH 118/614] Drop Core Data fallback --- .../Clients/DownloadClient+Manager.swift | 6 +- .../Clients/DownloadClient+Persistence.swift | 279 +----------------- .../DownloadClient+PersistenceHelpers.swift | 28 +- .../DownloadClient+PersistenceNormalize.swift | 108 +------ .../Clients/DownloadClient+PublicAPI.swift | 2 - .../DownloadClient+PublicAPIHelpers.swift | 1 - .../Clients/DownloadClient+RetryHelpers.swift | 52 +--- .../Clients/DownloadClient+Scheduling.swift | 114 +------ .../DownloadEnqueueManifestTests.swift | 12 +- .../DownloadFeatureTestFactories.swift | 7 +- .../Download/DownloadFeatureTestHelpers.swift | 3 +- .../Tests/Download/DownloadIpBanTests.swift | 4 +- .../DownloadManagerCaptureTests.swift | 62 ++-- .../DownloadManagerRepairSeedTests.swift | 10 +- .../DownloadManagerStorageTests.swift | 265 ++--------------- .../Download/DownloadObserverBatchTests.swift | 3 +- .../DownloadPauseAndReconcileTests.swift | 140 +++++---- .../Download/DownloadProcessCacheTests.swift | 52 ++-- .../Tests/Download/DownloadProcessTests.swift | 49 +-- .../DownloadRetryMinimalSourceTests.swift | 35 +-- .../Download/DownloadRetryPagesTests.swift | 87 +++--- .../DownloadRetryUpdateFallbackTests.swift | 107 ++++--- .../Download/DownloadSchedulingTests.swift | 47 ++- .../DownloadVersionSignatureTests.swift | 52 +--- 24 files changed, 447 insertions(+), 1078 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 84cd7625e..ea9005f17 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -3,7 +3,6 @@ // EhPanda // -import CoreData import Foundation actor DownloadManager { @@ -133,7 +132,6 @@ actor DownloadManager { let libraryClient: LibraryClient let downloadOptionsProvider: @Sendable () async -> DownloadOptionsSnapshot let queueStore: DownloadQueueStore - let persistenceContainer: NSPersistentContainer var downloadIndex = [String: DownloadFolderRecord]() var downloadErrors = [String: DownloadFailure]() var validationErrors = [String: DownloadFailure]() @@ -157,15 +155,13 @@ actor DownloadManager { downloadOptionsProvider: @escaping @Sendable () async -> DownloadOptionsSnapshot = { DownloadOptionsSnapshot() }, - queueStore: DownloadQueueStore? = nil, - persistenceContainer: NSPersistentContainer = PersistenceController.shared.container + queueStore: DownloadQueueStore? = nil ) { self.storage = storage self.urlSession = urlSession self.libraryClient = libraryClient self.downloadOptionsProvider = downloadOptionsProvider self.queueStore = queueStore ?? DownloadQueueStore(fileURL: storage.queueURL()) - self.persistenceContainer = persistenceContainer } var fileManager: DownloadFileManager { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 6ea312b38..c1f06d722 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -3,7 +3,6 @@ // EhPanda // -import CoreData import Foundation // MARK: - Disk Index @@ -122,16 +121,13 @@ private extension DownloadFolderRecord { } } -// MARK: - Core Data Operations +// MARK: - Store Operations extension DownloadManager { func fetchDownload( gid: String ) async -> DownloadedGallery? { _ = await reloadDownloadIndex() - if let indexedDownload = await indexedDownload(gid: gid) { - return indexedDownload - } - return await fetchDownloadFromCoreData(gid: gid) + return await indexedDownload(gid: gid) } func fetchDownloadsFromStore() async -> [DownloadedGallery] { @@ -140,162 +136,20 @@ extension DownloadManager { await testingFetchDownloadsFromStoreHook() } #endif - let downloads = await reloadDownloadIndex() - guard downloads.isEmpty else { return downloads } - return sortDownloads(await fetchDownloadsFromCoreData()) + return await reloadDownloadIndex() } func fetchDownloadsFromStore( gids: [String] ) async -> [DownloadedGallery] { +#if DEBUG + if let testingFetchDownloadsFromStoreHook { + await testingFetchDownloadsFromStoreHook() + } +#endif let gidSet = Set(gids) - let indexedDownloads = await reloadDownloadIndex() + return await reloadDownloadIndex() .filter { gidSet.contains($0.gid) } - let indexedGIDs = Set(indexedDownloads.map(\.gid)) - let missingGIDs = gids.filter { !indexedGIDs.contains($0) } - guard !missingGIDs.isEmpty else { return indexedDownloads } - let persistedDownloads = await fetchDownloadsFromCoreData( - gids: missingGIDs - ) - return sortDownloads(indexedDownloads + persistedDownloads) - } - - private func fetchDownloadFromCoreData( - gid: String - ) async -> DownloadedGallery? { - await MainActor.run { - let context = persistenceContainer.viewContext - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate( - format: "gid == %@", - gid - ) - return try? context.fetch(request).first?.toEntity() - } - } - - private func fetchDownloadsFromCoreData() async -> [DownloadedGallery] { - return await MainActor.run { - let context = persistenceContainer.viewContext - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.sortDescriptors = [ - NSSortDescriptor( - keyPath: \DownloadedGalleryMO - .lastDownloadedAt, - ascending: false - ) - ] - let objects = (try? context.fetch(request)) ?? [] - return objects.map { $0.toEntity() } - } - } - - private func fetchDownloadsFromCoreData( - gids: [String] - ) async -> [DownloadedGallery] { - await MainActor.run { - let context = persistenceContainer.viewContext - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.predicate = NSPredicate( - format: "gid IN %@", - gids - ) - request.sortDescriptors = [ - NSSortDescriptor( - keyPath: \DownloadedGalleryMO - .lastDownloadedAt, - ascending: false - ) - ] - let objects = (try? context.fetch(request)) ?? [] - return objects.map { $0.toEntity() } - } - } - - func updateDownloadRecord( - gid: String, - createIfMissing: Bool = true, - update: @MainActor @Sendable @escaping (DownloadedGalleryMO) -> Void - ) async throws { - try await MainActor.run { - let context = persistenceContainer.viewContext - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate( - format: "gid == %@", - gid - ) - - let object: DownloadedGalleryMO - if let storedObject = - try context.fetch(request).first { - object = storedObject - } else if !createIfMissing { - return - } else { - object = DownloadedGalleryMO(context: context) - object.gid = gid - object.host = GalleryHost.ehentai.rawValue - object.token = "" - object.title = "" - object.category = - Category.private.rawValue - object.pageCount = 0 - object.postedDate = .now - object.rating = 0 - object.folderRelativePath = gid - object.status = - DownloadStatus.queued.rawValue - object.remoteVersionSignature = "" - object.completedPageCount = 0 - } - - update(object) - guard context.hasChanges else { return } - do { - try context.save() - } catch { - throw AppError.databaseCorrupted( - error.localizedDescription - ) - } - } - } - - func deleteDownloadRecord(gid: String) async throws { - try await MainActor.run { - let context = persistenceContainer.viewContext - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate( - format: "gid == %@", - gid - ) - guard let object = - try context.fetch(request).first else { - return - } - context.delete(object) - guard context.hasChanges else { return } - do { - try context.save() - } catch { - throw AppError.databaseCorrupted( - error.localizedDescription - ) - } - } } } @@ -312,120 +166,7 @@ extension DownloadManager { #endif downloadErrors[context.gid] = DownloadFailure(error: error) await queueStore.remove(context.gid) - let indexedDownloads = await reloadDownloadIndex() - guard indexedDownloads.contains(where: { $0.gid == context.gid }) - else { - await persistLegacyFailure( - error: error, - context: context - ) - return - } - } - - private func persistLegacyFailure( - error: AppError, - context: FailureContext - ) async { - let workingCompletedPageCount = - temporaryCompletedPageCount( - gid: context.gid, - expectedPageCount: - context.originalDownload.pageCount - ) - let hasTemporaryWorkingSet = storage - .temporaryFolderExists(gid: context.gid) - let recoveredCompletedPageCount = - hasTemporaryWorkingSet - ? workingCompletedPageCount - : max( - context.originalDownload - .completedPageCount, - workingCompletedPageCount - ) - do { - try await updateDownloadRecord( - gid: context.gid, - createIfMissing: false - ) { record in - record.lastError = - DownloadFailure(error: error).toData() - record.pendingOperation = nil - self.applyFailureStatus( - to: record, - context: context, - workingCompletedPageCount: - workingCompletedPageCount, - recoveredCompletedPageCount: - recoveredCompletedPageCount - ) - } - } catch { - Logger.error(error) - } - } - - nonisolated private func applyFailureStatus( - to record: DownloadedGalleryMO, - context: FailureContext, - workingCompletedPageCount: Int, - recoveredCompletedPageCount: Int - ) { - if context.mode == .repair { - applyRepairFailureStatus(to: record, context: context) - } else if context.hadReadableFiles, - [.update, .redownload].contains(context.mode) { - applyFallbackFailureStatus(to: record, context: context) - } else if workingCompletedPageCount > 0 { - record.status = DownloadStatus.partial.rawValue - record.completedPageCount = Int64(workingCompletedPageCount) - record.latestRemoteVersionSignature = - context.originalDownload.latestRemoteVersionSignature - } else { - record.status = DownloadStatus.partial.rawValue - record.completedPageCount = Int64(recoveredCompletedPageCount) - record.latestRemoteVersionSignature = - context.originalDownload.latestRemoteVersionSignature - } - } - - nonisolated private func applyRepairFailureStatus( - to record: DownloadedGalleryMO, - context: FailureContext - ) { - record.status = DownloadStatus.missingFiles.rawValue - record.completedPageCount = Int64( - context.originalDownload.completedPageCount - ) - record.folderRelativePath = - context.originalDownload.folderRelativePath - record.coverRelativePath = - context.originalDownload.coverRelativePath - record.remoteVersionSignature = - context.originalDownload.remoteVersionSignature - record.latestRemoteVersionSignature = - context.originalDownload.latestRemoteVersionSignature - } - - nonisolated private func applyFallbackFailureStatus( - to record: DownloadedGalleryMO, - context: FailureContext - ) { - record.status = self.fallbackStatus( - for: context.originalDownload, - mode: context.mode - ).rawValue - record.completedPageCount = Int64( - context.originalDownload.pageCount - ) - record.folderRelativePath = - context.originalDownload.folderRelativePath - record.coverRelativePath = - context.originalDownload.coverRelativePath - record.remoteVersionSignature = - context.originalDownload.remoteVersionSignature - record.latestRemoteVersionSignature = - context.originalDownload.latestRemoteVersionSignature + _ = await reloadDownloadIndex() } func flushDownloadProgress( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index cc870b0ab..344d52c76 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -3,7 +3,6 @@ // EhPanda // -import CoreData import Foundation // MARK: - Validation & Sanitization @@ -69,30 +68,11 @@ extension DownloadManager { guard updateResult.needsUpdate else { return download } - if downloadIndex[gid] != nil { - downloadErrors[gid] = updateResult.lastError - if updateResult.lastError == nil { - validationErrors[gid] = nil - } - await notifyObservers() - return await fetchDownload(gid: gid) - } - - do { - try await updateDownloadRecord( - gid: gid, - createIfMissing: false - ) { record in - record.status = updateResult.status.rawValue - record.completedPageCount = - Int64(updateResult.completedPageCount) - record.lastError = - updateResult.lastError?.toData() - } - await notifyObservers() - } catch { - Logger.error(error) + downloadErrors[gid] = updateResult.lastError + if updateResult.lastError == nil { + validationErrors[gid] = nil } + await notifyObservers() return await fetchDownload(gid: gid) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 261708c34..e978c3e70 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -91,39 +91,8 @@ extension DownloadManager { || shouldClearCancellationError else { continue } - if downloadIndex[download.gid] != nil { - if shouldClearCancellationError { - downloadErrors[download.gid] = nil - } - continue - } - - let normalizedCompletedPageCount = max( - download.completedPageCount, - temporaryCompletedPageCount( - gid: download.gid, - expectedPageCount: - max(download.pageCount, 1) - ) - ) - do { - try await updateDownloadRecord( - gid: download.gid, - createIfMissing: false - ) { record in - if download.status == .failed { - record.status = - DownloadStatus.partial.rawValue - record.completedPageCount = Int64( - normalizedCompletedPageCount - ) - } - if shouldClearCancellationError { - record.lastError = nil - } - } - } catch { - Logger.error(error) + if shouldClearCancellationError { + downloadErrors[download.gid] = nil } } } @@ -138,22 +107,8 @@ extension DownloadManager { activeGalleryID: activeGalleryID, hasActiveTask: hasActiveTask ) { - if downloadIndex[download.gid] != nil { - if activeGalleryID == download.gid, !hasActiveTask { - self.activeGalleryID = nil - } - continue - } - do { - try await updateDownloadRecord( - gid: download.gid, - createIfMissing: false - ) { record in - record.status = - DownloadStatus.paused.rawValue - } - } catch { - Logger.error(error) + if activeGalleryID == download.gid, !hasActiveTask { + self.activeGalleryID = nil } } } @@ -161,24 +116,10 @@ extension DownloadManager { func reconcileActiveDownloadState() async { guard activeTask != nil, let activeGalleryID, - let activeDownload = await fetchDownload( - gid: activeGalleryID - ), - activeDownload.status != .downloading + await fetchDownload(gid: activeGalleryID) != nil else { return } - do { - try await updateDownloadRecord( - gid: activeGalleryID, - createIfMissing: false - ) { record in - record.status = - DownloadStatus.downloading.rawValue - record.lastError = nil - } - } catch { - Logger.error(error) - } + downloadErrors[activeGalleryID] = nil } func validateDownloads() async { @@ -199,50 +140,17 @@ extension DownloadManager { private func validateDownload(_ download: DownloadedGallery) async -> DownloadValidationState { let validation = storage.validate(download: download) - let isIndexedDownload = downloadIndex[download.gid] != nil switch validation { case .valid: refreshMissingManifestHashesIfNeeded(download: download) - if isIndexedDownload { - validationErrors[download.gid] = nil - return validation - } - let expectedStatus: DownloadStatus = - download.hasUpdate - ? .updateAvailable : .completed - guard download.status != expectedStatus - else { return validation } - do { - try await updateDownloadRecord( - gid: download.gid, - createIfMissing: false - ) { record in - record.status = expectedStatus.rawValue - } - } catch { - Logger.error(error) - } + validationErrors[download.gid] = nil case .missingFiles(let message): let failure = DownloadFailure( code: .fileOperationFailed, message: message ) - if isIndexedDownload { - validationErrors[download.gid] = failure - return validation - } - do { - try await updateDownloadRecord( - gid: download.gid, - createIfMissing: false - ) { record in - record.status = DownloadStatus.missingFiles.rawValue - record.lastError = failure.toData() - } - } catch { - Logger.error(error) - } + validationErrors[download.gid] = failure } return validation } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 4fc126230..c05db2b5f 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -3,7 +3,6 @@ // EhPanda // -import CoreData import Foundation // MARK: - Public API @@ -174,7 +173,6 @@ extension DownloadManager { do { try? storage.removeTemporaryFolder(gid: gid) try storage.removeFolder(relativePath: download.folderRelativePath) - try await deleteDownloadRecord(gid: gid) await notifyObservers() await scheduleNextIfNeeded() return .success(()) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index da91fd545..c610914a6 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -3,7 +3,6 @@ // EhPanda // -import CoreData import Foundation // MARK: - Private helpers for public API diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index a58491d36..f25974001 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -3,7 +3,6 @@ // EhPanda // -import CoreData import Foundation // MARK: - Retry & RetryPages @@ -49,31 +48,9 @@ extension DownloadManager { if !retryParams.shouldResumeExistingWork { try? storage.removeTemporaryFolder(gid: gid) } - if downloadIndex[gid] != nil { - downloadErrors[gid] = nil - validationErrors[gid] = nil - await queueStore.enqueue(gid) - if fileManager.operate({ $0.fileExists(atPath: temporaryFolderURL.path) }) { - writeRetryResumeState( - download: download, - resolvedMode: resolvedMode, - existingResumeState: existingResumeState, - temporaryFolderURL: temporaryFolderURL - ) - } - await notifyObservers() - await scheduleNextIfNeeded() - return - } - try await updateDownloadRecord( - gid: gid, createIfMissing: false - ) { record in - record.status = retryParams.resumedStatus.rawValue - record.completedPageCount = Int64(retryParams.completedPageCount) - record.lastDownloadedAt = .now - record.lastError = nil - record.pendingOperation = retryParams.pendingOperation?.rawValue - } + downloadErrors[gid] = nil + validationErrors[gid] = nil + await queueStore.enqueue(gid) if fileManager.operate({ $0.fileExists(atPath: temporaryFolderURL.path) }) { writeRetryResumeState( download: download, @@ -134,10 +111,6 @@ extension DownloadManager { for: download, mode: mode, resumeState: existingResumeState ) - let resumedStatus: DownloadStatus = - activeTask == nil || activeGalleryID == gid - ? .downloading : .queued - clearSelectedFailedPages( selectedPageIndices: selectedPageIndices, temporaryFolderURL: temporaryFolderURL @@ -151,22 +124,9 @@ extension DownloadManager { ), folderURL: temporaryFolderURL ) - if downloadIndex[gid] != nil { - downloadErrors[gid] = nil - validationErrors[gid] = nil - await queueStore.enqueue(gid) - await notifyObservers() - await scheduleNextIfNeeded() - return - } - try await updateDownloadRecord( - gid: gid, createIfMissing: false - ) { record in - record.status = resumedStatus.rawValue - record.lastDownloadedAt = .now - record.lastError = nil - record.pendingOperation = nil - } + downloadErrors[gid] = nil + validationErrors[gid] = nil + await queueStore.enqueue(gid) await notifyObservers() await scheduleNextIfNeeded() } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index e2b71a4c7..c8130a374 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -199,25 +199,7 @@ extension DownloadManager { downloadErrors[gid] = nil validationErrors[gid] = nil await queueStore.remove(gid) - let indexedDownloads = await reloadDownloadIndex() - if !indexedDownloads.contains(where: { $0.gid == gid }) { - let initialCount = max( - download.completedPageCount, - temporaryCompletedPageCount( - gid: gid, - expectedPageCount: max(download.pageCount, 1) - ) - ) - try await updateDownloadRecord( - gid: gid, - createIfMissing: false - ) { record in - record.status = DownloadStatus.paused.rawValue - record.completedPageCount = Int64(initialCount) - record.lastError = nil - record.lastDownloadedAt = .now - } - } + _ = await reloadDownloadIndex() await notifyObservers() if activeGalleryID == gid { let task = activeTask @@ -236,25 +218,7 @@ extension DownloadManager { downloadErrors[gid] = nil validationErrors[gid] = nil await queueStore.remove(gid) - let indexedDownloads = await reloadDownloadIndex() - if !indexedDownloads.contains(where: { $0.gid == gid }) { - let settledCount = max( - download.completedPageCount, - temporaryCompletedPageCount( - gid: gid, - expectedPageCount: max(download.pageCount, 1) - ) - ) - try await updateDownloadRecord( - gid: gid, - createIfMissing: false - ) { record in - record.status = DownloadStatus.paused.rawValue - record.completedPageCount = Int64(settledCount) - record.lastError = nil - record.lastDownloadedAt = .now - } - } + _ = await reloadDownloadIndex() } func cancelQueuedWorkItem( @@ -268,28 +232,8 @@ extension DownloadManager { break } - let restoredStatus = download.status - let restoredCompletedPageCount = - validatedCompletedPageCount(download) - do { - try await updateDownloadRecord( - gid: download.gid, - createIfMissing: false - ) { record in - record.status = restoredStatus.rawValue - record.completedPageCount = - Int64(restoredCompletedPageCount) - record.lastDownloadedAt = .now - record.pendingOperation = nil - } - await notifyObservers() - return .success(()) - } catch let error as AppError { - return .failure(error) - } catch { - Logger.error(error) - return .failure(.unknown) - } + await notifyObservers() + return .success(()) } func resume(gid: String) async -> Result { @@ -297,49 +241,13 @@ extension DownloadManager { return .failure(.notFound) } - do { - downloadErrors[gid] = nil - validationErrors[gid] = nil - await queueStore.enqueue(gid) - let indexedDownloads = await reloadDownloadIndex() - if indexedDownloads.contains(where: { $0.gid == gid }) { - await notifyObservers() - await scheduleNextIfNeeded() - return .success(()) - } - let resumedStatus: DownloadStatus = - activeTask == nil ? .downloading : .queued - try await updateDownloadRecord( - gid: gid, - createIfMissing: false - ) { record in - record.status = resumedStatus.rawValue - record.lastError = nil - record.lastDownloadedAt = .now - record.pendingOperation = nil - } - await notifyObservers() - await scheduleNextIfNeeded() - return .success(()) - } catch let error as AppError { - return .failure(error) - } catch { - Logger.error(error) - return .failure(.unknown) - } + downloadErrors[gid] = nil + validationErrors[gid] = nil + await queueStore.enqueue(gid) + _ = await reloadDownloadIndex() + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) } - func sortDownloads( - _ downloads: [DownloadedGallery] - ) -> [DownloadedGallery] { - downloads.sorted { lhs, rhs in - let lhsPriority = lhs.sortPriority - let rhsPriority = rhs.sortPriority - if lhsPriority != rhsPriority { - return lhsPriority < rhsPriority - } - return (lhs.lastDownloadedAt ?? .distantPast) - > (rhs.lastDownloadedAt ?? .distantPast) - } - } } diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index 0fa96e156..653a28369 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -3,7 +3,6 @@ // EhPandaTests // -import CoreData import Foundation import Testing @testable import EhPanda @@ -17,12 +16,10 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) - let container = try makeInMemoryContainer() let manager = DownloadManager( storage: storage, urlSession: .shared, - queueStore: queueStore, - persistenceContainer: container + queueStore: queueStore ) await manager.testingInstallActiveTask(gid: "busy", task: Task {}) @@ -71,9 +68,8 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { ) #expect(manifestObject["downloadOptions"] == nil) - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - #expect(try container.viewContext.count(for: request) == 0) + let queuedDownload = await manager.testingFetchDownload(gid: gallery.gid) + #expect(queuedDownload?.status == .queued) + #expect(queuedDownload?.pageCount == detail.pageCount) } } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index b3b248a25..8d8dcdfa5 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -265,8 +265,7 @@ struct StubRouteContext: Sendable { extension DownloadFeatureTestCase { func makeStubbedDownloadManager( rootURL: URL, - sessionID: String, - persistenceContainer: NSPersistentContainer? = nil + sessionID: String ) -> (DownloadFileStorage, DownloadManager) { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] @@ -276,11 +275,9 @@ extension DownloadFeatureTestCase { let storage = DownloadFileStorage( rootURL: rootURL, fileManager: .default ) - let container = persistenceContainer ?? PersistenceController.shared.container let manager = DownloadManager( storage: storage, - urlSession: URLSession(configuration: configuration), - persistenceContainer: container + urlSession: URLSession(configuration: configuration) ) return (storage, manager) } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift index 2f9bb11f2..ea8b1889a 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -159,8 +159,7 @@ extension DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) return DownloadManager( storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared, - persistenceContainer: PersistenceController.shared.container + urlSession: .shared ) } diff --git a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift index d80a84ae6..64f1b00f8 100644 --- a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift +++ b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift @@ -3,7 +3,6 @@ // EhPandaTests // -import CoreData import Foundation import Testing @testable import EhPanda @@ -22,8 +21,7 @@ struct DownloadIpBanTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true), fileManager: .default ), - urlSession: URLSession(configuration: configuration), - persistenceContainer: PersistenceController.shared.container + urlSession: URLSession(configuration: configuration) ) let recorder = RequestRecorder() let ipBannedHTML = try fixtureData(resource: HTMLFilename.ipBanned.rawValue, pathExtension: "html") diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index 9d819dc2f..0993232d2 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -3,7 +3,6 @@ // EhPandaTests // -import CoreData import Kingfisher import UIKit import Foundation @@ -13,9 +12,7 @@ import Testing @Suite(.serialized) struct DownloadManagerCaptureTests: DownloadFeatureTestCase { @Test - func testDownloadManagerCaptureCachedPageRestoresTemporaryPageAndUpdatesCompletedCount() async throws { - let container = try makeInMemoryContainer() - + func testDownloadManagerCaptureCachedPageRestoresTemporaryPage() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 27) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -24,15 +21,21 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container + urlSession: .shared ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .downloading, - completedPageCount: 0, - pageCount: 2 + + let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Capture", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest( + gid: gid, + title: "Capture", + pageCount: 2 + ), + folderURL: completedFolderURL ) let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) @@ -40,6 +43,22 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) + try storage.writeManifest( + sampleManifest( + gid: gid, + title: "Capture", + pageCount: 2 + ), + folderURL: temporaryFolderURL + ) + try storage.writeResumeState( + .init( + mode: .initial, + pageCount: 2, + downloadOptions: .init() + ), + folderURL: temporaryFolderURL + ) let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in @@ -60,35 +79,20 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { imageURL: imageURL ) - let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.completedPageCount == 1) - let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - let pageRelativePath = storage.makePageRelativePath( - gid: gid, - token: "token", - index: 1, - fileExtension: "jpg" - ) - #expect(pageURLs[1] == temporaryFolderURL.appendingPathComponent(pageRelativePath)) + #expect(pageURLs[1] == temporaryFolderURL.appendingPathComponent("pages/0001.jpg")) } @MainActor @Test func testDownloadManagerCaptureCachedPageRepairsCompletedDownloadWithLatestRemoteImage() async throws { - let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 28) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) - try insertPersistedDownload( - in: container, gid: gid, status: .missingFiles, completedPageCount: 1, pageCount: 2, - lastError: .init(code: .fileOperationFailed, message: "Page 1 is missing.") - ) - + let manager = DownloadManager(storage: storage, urlSession: .shared) let completedFolderURL = try setupCaptureMissingFilesFolder( rootURL: rootURL, gid: gid ) diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index 815ec292d..a2479457c 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -3,7 +3,6 @@ // EhPandaTests // -import CoreData import Kingfisher import SDWebImage import UIKit @@ -59,20 +58,13 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { @Test func testDownloadManagerLoadLocalPageURLsRemovesZeroBytePage() async throws { - let container = try makeInMemoryContainer() - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 13) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) - - try insertPersistedDownload( - in: container, gid: gid, status: .completed, - completedPageCount: 2, pageCount: 2 - ) + let manager = DownloadManager(storage: storage, urlSession: .shared) let (emptyPageURL, goodPageURL) = try setupZeroBytePageFiles( rootURL: rootURL, gid: gid, storage: storage diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 4a98b4fd2..c123792c3 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -3,7 +3,6 @@ // EhPandaTests // -import CoreData import Kingfisher import UIKit import Foundation @@ -14,7 +13,6 @@ import Testing struct DownloadManagerStorageTests: DownloadFeatureTestCase { @Test func testDownloadManagerReloadDownloadIndexScansManifestFolders() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -22,8 +20,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container + urlSession: .shared ) try storage.ensureRootDirectory() @@ -68,7 +65,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { @Test func testDownloadManagerReloadDownloadIndexKeepsNewestDuplicateFolder() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -76,8 +72,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container + urlSession: .shared ) let olderDate = Date(timeIntervalSince1970: 100) @@ -126,7 +121,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { @Test func testDownloadManagerFetchesDownloadsFromManifestIndex() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -134,24 +128,9 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container + urlSession: .shared ) - try insertPersistedDownload( - in: container, - gid: "600", - status: .failed, - completedPageCount: 0, - pageCount: 1 - ) - try insertPersistedDownload( - in: container, - gid: "601", - status: .completed, - completedPageCount: 1, - pageCount: 1 - ) try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, @@ -166,22 +145,19 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let downloads = await manager.fetchDownloads() let indexedDownload = try #require(await manager.fetchDownload(gid: "600")) - let fallbackDownload = try #require(await manager.fetchDownload(gid: "601")) let badges = await manager.badges(for: ["600", "601"]) #expect(downloads.map(\.gid) == ["600"]) #expect(indexedDownload.title == "Disk") #expect(indexedDownload.displayStatus == .queued) #expect(indexedDownload.status == .queued) - #expect(fallbackDownload.gid == "601") - #expect(fallbackDownload.status == .completed) + #expect(await manager.fetchDownload(gid: "601") == nil) #expect(badges["600"] == .queued) - #expect(badges["601"] == .downloaded) + #expect(badges["601"] == nil) } @Test func testDownloadManagerObserverInitialSnapshotUsesManifestIndex() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -189,8 +165,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container + urlSession: .shared ) try storage.ensureRootDirectory() @@ -225,7 +200,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { @Test func testDownloadManagerIndexAppliesSessionOnlyFlags() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -233,8 +207,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container + urlSession: .shared ) try storage.ensureRootDirectory() @@ -273,7 +246,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { @Test func testDownloadManagerReconcileClearsIndexedCancellationError() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -281,8 +253,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container + urlSession: .shared ) let cancellationFailure = DownloadFailure( code: .fileOperationFailed, @@ -290,14 +261,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) try storage.ensureRootDirectory() - try insertPersistedDownload( - in: container, - gid: "410", - status: .failed, - completedPageCount: 0, - pageCount: 1, - lastError: cancellationFailure - ) try writeIndexedManifest( storage: storage, relativePath: "[410_token] Cancelled", @@ -318,20 +281,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(download.displayStatus == .inactive) #expect(download.status == .paused) #expect(download.lastError == nil) - - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", "410") - let persistedDownload = try container.viewContext.fetch(request).first - #expect(persistedDownload?.status == DownloadStatus.failed.rawValue) - #expect(persistedDownload?.lastError != nil) } @Test func testDownloadManagerReconcileClearsIndexedInterruptedActiveFlag() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -339,18 +292,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container + urlSession: .shared ) try storage.ensureRootDirectory() - try insertPersistedDownload( - in: container, - gid: "420", - status: .downloading, - completedPageCount: 0, - pageCount: 1 - ) try writeIndexedManifest( storage: storage, relativePath: "[420_token] Interrupted", @@ -368,19 +313,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(download.displayStatus == .inactive) #expect(download.status == .paused) #expect(await manager.testingActiveGalleryID() == nil) - - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", "420") - let persistedDownload = try container.viewContext.fetch(request).first - #expect(persistedDownload?.status == DownloadStatus.downloading.rawValue) } @Test func testDownloadManagerSanitizeClearsIndexedError() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -388,8 +324,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container + urlSession: .shared ) let failure = DownloadFailure( code: .fileOperationFailed, @@ -397,14 +332,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) try storage.ensureRootDirectory() - try insertPersistedDownload( - in: container, - gid: "430", - status: .failed, - completedPageCount: 0, - pageCount: 1, - lastError: failure - ) try writeIndexedManifest( storage: storage, relativePath: "[430_token] Sanitize", @@ -424,20 +351,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(sanitizedDownload?.displayStatus == .inactive) #expect(sanitizedDownload?.status == .paused) #expect(sanitizedDownload?.lastError == nil) - - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", "430") - let persistedDownload = try container.viewContext.fetch(request).first - #expect(persistedDownload?.status == DownloadStatus.failed.rawValue) - #expect(persistedDownload?.lastError != nil) } @Test func testDownloadManagerValidateIndexedMissingFileUsesSessionError() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -445,18 +362,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container + urlSession: .shared ) try storage.ensureRootDirectory() - try insertPersistedDownload( - in: container, - gid: "440", - status: .completed, - completedPageCount: 1, - pageCount: 1 - ) try writeIndexedManifest( storage: storage, relativePath: "[440_token] Missing", @@ -475,20 +384,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(download.status == .failed) #expect(download.lastError?.code == .fileOperationFailed) #expect(download.badge == .failed) - - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", "440") - let persistedDownload = try container.viewContext.fetch(request).first - #expect(persistedDownload?.status == DownloadStatus.completed.rawValue) - #expect(persistedDownload?.lastError == nil) } @Test func testDownloadManagerRetryIndexedDownloadUsesQueueIntent() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -498,18 +397,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let manager = DownloadManager( storage: storage, urlSession: .shared, - queueStore: queueStore, - persistenceContainer: container + queueStore: queueStore ) try storage.ensureRootDirectory() - try insertPersistedDownload( - in: container, - gid: "450", - status: .completed, - completedPageCount: 1, - pageCount: 1 - ) try writeIndexedManifest( storage: storage, relativePath: "[450_token] Retry", @@ -538,20 +429,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(download.displayStatus == .queued) #expect(download.status == .queued) #expect(download.pendingOperation == nil) - - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", "450") - let persistedDownload = try container.viewContext.fetch(request).first - #expect(persistedDownload?.status == DownloadStatus.completed.rawValue) - #expect(persistedDownload?.pendingOperation == nil) } @Test func testDownloadManagerRetryPagesIndexedDownloadUsesQueueIntent() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -561,19 +442,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let manager = DownloadManager( storage: storage, urlSession: .shared, - queueStore: queueStore, - persistenceContainer: container + queueStore: queueStore ) try storage.ensureRootDirectory() - try insertPersistedDownload( - in: container, - gid: "460", - status: .missingFiles, - completedPageCount: 1, - pageCount: 2, - pendingOperation: .repair - ) try writeIndexedManifest( storage: storage, relativePath: "[460_token] Retry Pages", @@ -625,20 +497,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(FileManager.default.fileExists( atPath: storage.failedPagesURL(folderURL: temporaryFolderURL).path ) == false) - - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", "460") - let persistedDownload = try container.viewContext.fetch(request).first - #expect(persistedDownload?.status == DownloadStatus.missingFiles.rawValue) - #expect(persistedDownload?.pendingOperation == DownloadStartMode.repair.rawValue) } @Test func testDownloadManagerFailureSettlesQueueIntent() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -648,18 +510,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let manager = DownloadManager( storage: storage, urlSession: .shared, - queueStore: queueStore, - persistenceContainer: container + queueStore: queueStore ) try storage.ensureRootDirectory() - try insertPersistedDownload( - in: container, - gid: "800", - status: .queued, - completedPageCount: 0, - pageCount: 1 - ) try writeIndexedManifest( storage: storage, relativePath: "[800_token] Failing", @@ -690,20 +544,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(failedDownload.status == .failed) #expect(failedDownload.lastError?.code == .networkingFailed) #expect(badges["800"] == .failed) - - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", "800") - let persistedDownload = try container.viewContext.fetch(request).first - #expect(persistedDownload?.status == DownloadStatus.queued.rawValue) - #expect(persistedDownload?.lastError == nil) } @Test func testDownloadManagerCompletionSettlesQueueIntent() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -713,8 +557,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let manager = DownloadManager( storage: storage, urlSession: .shared, - queueStore: queueStore, - persistenceContainer: container + queueStore: queueStore ) let gallery = sampleGallery() @@ -757,7 +600,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { @Test func testDownloadManagerPauseAndResumeMutateQueueIntent() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -767,8 +609,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let manager = DownloadManager( storage: storage, urlSession: .shared, - queueStore: queueStore, - persistenceContainer: container + queueStore: queueStore ) try storage.ensureRootDirectory() @@ -786,14 +627,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .init(code: .networkingFailed, message: "failed"), gid: "820" ) - try insertPersistedDownload( - in: container, - gid: "820", - status: .downloading, - completedPageCount: 1, - pageCount: 2, - lastError: .init(code: .networkingFailed, message: "stale") - ) let activeTask = Task { do { try await Task.sleep(for: .seconds(60)) @@ -826,20 +659,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(resumedDownload.displayStatus == .queued) #expect(resumedDownload.status == .queued) #expect(resumedDownload.lastError == nil) - - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", "820") - let persistedDownload = try container.viewContext.fetch(request).first - #expect(persistedDownload?.status == DownloadStatus.downloading.rawValue) - #expect(persistedDownload?.lastError != nil) } @Test func testDownloadManagerSchedulesManifestQueueOrder() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -849,8 +672,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let manager = DownloadManager( storage: storage, urlSession: .shared, - queueStore: queueStore, - persistenceContainer: container + queueStore: queueStore ) await manager.testingSetScheduledProcessHook { _ in while !Task.isCancelled { @@ -896,7 +718,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { @Test func testDownloadManagerFlushProgressUpdatesManifestPageHash() async throws { - let container = try makeInMemoryContainer() let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -904,8 +725,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container + urlSession: .shared ) try storage.ensureRootDirectory() @@ -958,25 +778,26 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { @Test func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { - let container = try makeInMemoryContainer() - let gid = String(Int(Date().timeIntervalSince1970 * 1000)) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared, - persistenceContainer: container + storage: storage, + urlSession: .shared ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .failed, - completedPageCount: 1, - pageCount: 2 + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "[\(gid)_token] Inspect", + manifest: indexedManifest( + gid: gid, + title: "Inspect", + pageHashes: ["sha256:done", ""] + ) ) let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) @@ -1014,8 +835,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { @Test func testDownloadManagerLoadLocalPageURLsPrefersCompletedFolderForCompletedDownload() async throws { - let container = try makeInMemoryContainer() - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 11) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -1024,16 +843,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .completed, - completedPageCount: 2, - pageCount: 2 + urlSession: .shared ) let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) @@ -1078,8 +888,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { @Test func testDownloadManagerLoadLocalPageURLsMergesReadableCompletedPagesWithTemporaryPages() async throws { - let container = try makeInMemoryContainer() - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 12) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -1088,16 +896,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container - ) - - try insertPersistedDownload( - in: container, - gid: gid, - status: .downloading, - completedPageCount: 2, - pageCount: 2 + urlSession: .shared ) let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 0a92be2f4..279ef7af5 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -64,7 +64,6 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { @MainActor @Test func testDownloadManagerBatchesObserverUpdatesDuringProgressFlush() async throws { - let container = try makeInMemoryContainer() let pageCount = 20 let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 104) let rootURL = FileManager.default.temporaryDirectory @@ -72,7 +71,7 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) + let manager = DownloadManager(storage: storage, urlSession: .shared) let folderRelativePath = "\(gid) - Progress Flush" let folderURL = storage.folderURL(relativePath: folderRelativePath) diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index c391069a7..c937e2139 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -4,7 +4,6 @@ // import Foundation -import CoreData import ComposableArchitecture import Kingfisher import UIKit @@ -22,8 +21,6 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { @Test func testPauseKeepsActiveDownloadPausedWhenDeferredSchedulingRuns() async throws { - let container = try makeInMemoryContainer() - let gid = String(Int(Date().timeIntervalSince1970 * 1000)) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -31,17 +28,18 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: URLSession(configuration: configuration), - persistenceContainer: container + storage: storage, + urlSession: URLSession(configuration: configuration) ) - try insertPersistedDownload( - in: container, + try writeManifestFolder( + storage: storage, gid: gid, - status: .downloading, - completedPageCount: 7 + title: "Pausable", + pageHashes: Array(repeating: "sha256:done", count: 7) + + Array(repeating: "", count: 19) ) let activeTask = Task { [manager] in @@ -70,9 +68,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { } @Test - func testPauseUsesTemporaryWorkingSetProgressWhenCancelling() async throws { - let container = try makeInMemoryContainer() - + func testPauseKeepsIndexedManifestProgressWhenCancelling() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 1) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -83,16 +79,14 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: URLSession(configuration: configuration), - persistenceContainer: container + urlSession: URLSession(configuration: configuration) ) - try insertPersistedDownload( - in: container, + try writeManifestFolder( + storage: storage, gid: gid, - status: .downloading, - completedPageCount: 1, - pageCount: 2 + title: "Pausable", + pageHashes: ["sha256:done", ""] ) let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) @@ -127,14 +121,13 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) #expect(stored?.status == .paused) - #expect(stored?.completedPageCount == 2) - #expect(stored?.badge == .paused(2, 2)) + #expect(stored?.completedPageCount == 1) + #expect(stored?.badge == .paused(1, 2)) + #expect(FileManager.default.fileExists(atPath: temporaryFolderURL.path)) } @Test - func testReconcileDownloadsNormalizesLegacyFailedStatusToNeedsAttention() async throws { - let container = try makeInMemoryContainer() - + func testReconcileDownloadsKeepsIndexedSessionFailure() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -142,31 +135,32 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: URLSession(configuration: configuration), - persistenceContainer: container + storage: storage, + urlSession: URLSession(configuration: configuration) ) - try insertPersistedDownload( - in: container, + try writeManifestFolder( + storage: storage, gid: gid, - status: .failed, - completedPageCount: 0, - pageCount: 18 + title: "Failed", + pageHashes: Array(repeating: "", count: 18) + ) + await manager.testingSetDownloadError( + .init(code: .networkingFailed, message: "Network Error"), + gid: gid ) await manager.reconcileDownloads() let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.status == .partial) - #expect(stored?.badge == .partial(0, 18)) + #expect(stored?.status == .failed) + #expect(stored?.badge == .failed) } @Test func testReconcileDownloadsClearsCancellationLikeGalleryError() async throws { - let container = try makeInMemoryContainer() - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 3) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -174,34 +168,36 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: URLSession(configuration: configuration), - persistenceContainer: container + storage: storage, + urlSession: URLSession(configuration: configuration) ) - try insertPersistedDownload( - in: container, + try writeManifestFolder( + storage: storage, gid: gid, - status: .partial, - completedPageCount: 4, - pageCount: 18, - lastError: .init( + title: "Cancelled", + pageHashes: Array(repeating: "sha256:done", count: 4) + + Array(repeating: "", count: 14) + ) + await manager.testingSetDownloadError( + .init( code: .fileOperationFailed, message: "The operation could not be completed. (Swift.CancellationError error 1.)" - ) + ), + gid: gid ) await manager.reconcileDownloads() let stored = await manager.testingFetchDownload(gid: gid) #expect(stored?.lastError == nil) - #expect(stored?.status == .partial) + #expect(stored?.status == .paused) } @Test func testLoadInspectionFiltersCancellationFailuresIntoPendingPages() async throws { - let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 4) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -211,10 +207,13 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { configuration.protocolClasses = [FailFastURLProtocol.self] let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( - storage: storage, urlSession: URLSession(configuration: configuration), persistenceContainer: container + storage: storage, urlSession: URLSession(configuration: configuration) ) - try insertPersistedDownload( - in: container, gid: gid, status: .partial, completedPageCount: 1, pageCount: 2 + try writeManifestFolder( + storage: storage, + gid: gid, + title: "Inspection", + pageHashes: ["sha256:done", ""] ) let temporaryFolderURL = try setupCancellationFilterTestFolder(storage: storage, gid: gid) @@ -233,6 +232,43 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { // MARK: - Setup Helpers private extension DownloadPauseAndReconcileTests { + func writeManifestFolder( + storage: DownloadFileStorage, + gid: String, + title: String, + pageHashes: [String] + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: pageHashes.enumerated().map { offset, hash in + .init( + index: offset + 1, + relativePath: "pages/\(String(format: "%04d", offset + 1)).jpg", + fileHash: hash + ) + } + ), + folderURL: folderURL + ) + } + @discardableResult func setupCancellationFilterTestFolder( storage: DownloadFileStorage, diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 103db00d1..a5142acd2 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -3,7 +3,6 @@ // EhPandaTests // -import CoreData import Kingfisher import SDWebImage import UIKit @@ -16,7 +15,6 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { @MainActor @Test func testProcessDownloadClearsRemoteAssetCacheAfterSuccessfulDownload() async throws { - let container = try makeInMemoryContainer() let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 402) let pageIndex = 42 @@ -26,8 +24,7 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let cacheTestManager = try makeCacheTestManager( - rootURL: rootURL, sessionID: sessionID, gid: gid, pageIndex: pageIndex, - persistenceContainer: container + rootURL: rootURL, sessionID: sessionID, gid: gid, pageIndex: pageIndex ) let storage = cacheTestManager.storage let manager = cacheTestManager.manager @@ -44,11 +41,10 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { } } - await waitUntilCacheReady(for: cachedKeys) + await waitUntilCacheReady(for: cachedKeys, timeout: .seconds(3)) let updatedPageCount = try await setupCacheTestDownload( .init( - container: container, storage: storage, manager: manager, gid: gid, @@ -84,7 +80,6 @@ struct CacheTestManagerResult { } private struct CacheTestDownloadSetup { - let container: NSPersistentContainer let storage: DownloadFileStorage let manager: DownloadManager let gid: String @@ -96,8 +91,7 @@ private struct CacheTestDownloadSetup { private extension DownloadProcessCacheTests { func makeCacheTestManager( - rootURL: URL, sessionID: String, gid: String, pageIndex: Int, - persistenceContainer: NSPersistentContainer = PersistenceController.shared.container + rootURL: URL, sessionID: String, gid: String, pageIndex: Int ) throws -> CacheTestManagerResult { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] @@ -105,8 +99,7 @@ private extension DownloadProcessCacheTests { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: URLSession(configuration: configuration), - persistenceContainer: persistenceContainer + urlSession: URLSession(configuration: configuration) ) let content = StubHandlerContent( detailHTML: try makeUniqueDetailHTML(gid: gid), @@ -220,16 +213,12 @@ private extension DownloadProcessCacheTests { let coverURL = try #require( latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL ) - let previewCleanupURLs = latestPayload.previewURLs.values - .flatMap { $0.previewCacheCleanupURLs() } - let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { ctx in UIColor.systemTeal.setFill() ctx.fill(.init(x: 0, y: 0, width: 1, height: 1)) } let cachedImageData = try #require(cachedImage.jpegData(compressionQuality: 1)) - let cachedURLs = previewCleanupURLs - + [currentPageImageURL, coverURL] + let cachedURLs = [currentPageImageURL, coverURL] let cachedKeys = Set(cachedURLs.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) for cacheKey in cachedKeys { try await KingfisherManager.shared.cache.storeToDisk(cachedImageData, forKey: cacheKey) @@ -254,14 +243,11 @@ private extension DownloadProcessCacheTests { #expect(updatedPageCount > setup.pageIndex) #expect(oldPageCount > 0) - try await MainActor.run { - try insertPersistedDownload( - in: setup.container, gid: setup.gid, status: .partial, - completedPageCount: oldPageCount - 1, pageCount: oldPageCount, - remoteVersionSignature: setup.oldVersionSignature, - latestRemoteVersionSignature: setup.oldVersionSignature - ) - } + try setupCacheTestFinalFolder( + storage: setup.storage, gid: setup.gid, + oldPageCount: oldPageCount, + oldVersionSignature: setup.oldVersionSignature + ) try setupCacheTestTemporaryFolder( storage: setup.storage, gid: setup.gid, pageIndex: setup.pageIndex, oldPageCount: oldPageCount, @@ -270,6 +256,24 @@ private extension DownloadProcessCacheTests { return updatedPageCount } + func setupCacheTestFinalFolder( + storage: DownloadFileStorage, gid: String, + oldPageCount: Int, oldVersionSignature: String + ) throws { + let completedFolderURL = storage.folderURL(relativePath: "\(gid) - Pause Race") + try FileManager.default.createDirectory( + at: completedFolderURL.appendingPathComponent( + Defaults.FilePath.downloadPages, isDirectory: true + ), + withIntermediateDirectories: true + ) + let staleManifest = try sampleManifest( + gid: gid, title: "Pause Race", + pageCount: oldPageCount, versionSignature: oldVersionSignature + ) + try storage.writeManifest(staleManifest, folderURL: completedFolderURL) + } + func setupCacheTestTemporaryFolder( storage: DownloadFileStorage, gid: String, pageIndex: Int, oldPageCount: Int, oldVersionSignature: String diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 4915a5e0f..5d7791833 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -3,7 +3,6 @@ // EhPandaTests // -import CoreData import Foundation import Testing @testable import EhPanda @@ -12,30 +11,28 @@ import Testing struct DownloadProcessTests: DownloadFeatureTestCase { @Test func testFailurePersistenceCompletesBeforeRescheduling() async throws { - let container = try makeInMemoryContainer() let sessionID = UUID().uuidString let gid = "100010" let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let (_, manager) = makeStubbedDownloadManager( + let (storage, manager) = makeStubbedDownloadManager( rootURL: rootURL, - sessionID: sessionID, - persistenceContainer: container + sessionID: sessionID ) SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in throw URLError(.notConnectedToInternet) } defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } - try insertPersistedDownload( - in: container, + try writeProcessManifestFolder( + storage: storage, gid: gid, - status: .queued, - completedPageCount: 0, + title: "Queued Failure", pageCount: 2 ) + await manager.testingSetQueuedGalleryIDs([gid]) let persistenceGate = FailurePersistenceGate() await manager.testingSetPersistFailureHook { @@ -61,7 +58,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { await manager.testingSetPersistFailureHook(nil) let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.status == .partial) + #expect(stored?.status == .failed) #expect(stored?.lastError?.code == .networkingFailed) await manager.testingScheduleNextIfNeeded() @@ -72,7 +69,6 @@ struct DownloadProcessTests: DownloadFeatureTestCase { @Test func testProcessDownloadClearsStalePageSelectionWhenLatestPayloadRevealsUpdate() async throws { - let container = try makeInMemoryContainer() let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 401) let pageIndex = 42 @@ -82,7 +78,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let (storage, manager) = makeStubbedDownloadManager( - rootURL: rootURL, sessionID: sessionID, persistenceContainer: container + rootURL: rootURL, sessionID: sessionID ) defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } @@ -92,19 +88,12 @@ struct DownloadProcessTests: DownloadFeatureTestCase { ) let oldPageCount = updatedPageCount - 5 - try insertPersistedDownload( - in: container, gid: gid, status: .partial, - completedPageCount: oldPageCount - 1, pageCount: oldPageCount, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: oldVersionSignature - ) - let beforeProcess = await manager.testingFetchDownload(gid: gid) - #expect(beforeProcess?.hasUpdate ?? true == false) - let staleFolderURL = try prepareStaleExistingFolder( storage: storage, gid: gid, pageIndex: pageIndex, oldPageCount: oldPageCount, oldVersionSignature: oldVersionSignature ) + let beforeProcess = await manager.testingFetchDownload(gid: gid) + #expect(beforeProcess?.hasUpdate ?? true == false) await manager.testingProcessDownload(gid: gid) @@ -165,6 +154,24 @@ private struct ProcessVerificationContext { } private extension DownloadProcessTests { + func writeProcessManifestFolder( + storage: DownloadFileStorage, + gid: String, + title: String, + pageCount: Int + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: title, pageCount: pageCount), + folderURL: folderURL + ) + } + func fetchAndInstallStub( manager: DownloadManager, sessionID: String, gid: String, pageIndex: Int, oldVersionSignature: String diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 12dc0c9d9..38c0d67d7 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -3,7 +3,6 @@ // EhPandaTests // -import CoreData import Foundation import Testing @testable import EhPanda @@ -12,7 +11,6 @@ import Testing struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { @Test func testRetryPagesUsesMinimalSourceResolutionAndSkipsWhenNoPendingPages() async throws { - let container = try makeInMemoryContainer() let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 200) let pageIndex = 42 @@ -21,7 +19,7 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let (storage, manager) = makeStubbedDownloadManager( - rootURL: rootURL, sessionID: sessionID, persistenceContainer: container + rootURL: rootURL, sessionID: sessionID ) let setup = try await setupMinimalSourceTest( manager: manager, sessionID: sessionID, gid: gid, pageIndex: pageIndex @@ -32,12 +30,7 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { gid: gid, title: "Pause Race", pageCount: setup.pageCount, versionSignature: setup.versionSignature ) - try insertPersistedDownload( - in: container, gid: gid, status: .partial, - completedPageCount: setup.pageCount - 1, pageCount: setup.pageCount, - remoteVersionSignature: setup.versionSignature, - latestRemoteVersionSignature: setup.versionSignature - ) + try writeFinalManifest(storage: storage, gid: gid, manifest: manifest) try writeTemporaryManifestAndPages( storage: storage, gid: gid, manifest: manifest, pageCount: setup.pageCount, omittingPage: pageIndex, @@ -52,7 +45,6 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { setup.recorder.reset() try await assertRetrySkipsCompletedSelection( .init( - container: container, storage: storage, manager: manager, gid: gid, @@ -73,7 +65,6 @@ private struct MinimalSourceTestResult { } private struct MinimalSourceRetrySkipContext { - let container: NSPersistentContainer let storage: DownloadFileStorage let manager: DownloadManager let gid: String @@ -88,14 +79,6 @@ private extension DownloadRetryMinimalSourceTests { func assertRetrySkipsCompletedSelection( _ context: MinimalSourceRetrySkipContext ) async throws { - try clearPersistedDownloads(in: context.container) - try insertPersistedDownload( - in: context.container, gid: context.gid, status: .partial, - completedPageCount: context.setup.pageCount, - pageCount: context.setup.pageCount, - remoteVersionSignature: context.setup.versionSignature, - latestRemoteVersionSignature: context.setup.versionSignature - ) try writeTemporaryManifestAndPages( storage: context.storage, gid: context.gid, manifest: context.manifest, @@ -110,6 +93,20 @@ private extension DownloadRetryMinimalSourceTests { #expect(snapshot.imageDispatchRequests == 0) } + func writeFinalManifest( + storage: DownloadFileStorage, + gid: String, + manifest: DownloadManifest + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Pause Race") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest(manifest, folderURL: folderURL) + } + func setupMinimalSourceTest( manager: DownloadManager, sessionID: String, gid: String, pageIndex: Int ) async throws -> MinimalSourceTestResult { diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index d11292798..cb0b463f2 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -3,7 +3,6 @@ // EhPandaTests // -import CoreData import Foundation import Testing @testable import EhPanda @@ -12,16 +11,18 @@ import Testing struct DownloadRetryPagesTests: DownloadFeatureTestCase { @Test func testRetryPagesQueuesWorkWhenAnotherDownloadIsActive() async throws { - let container = try makeInMemoryContainer() let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) - try insertPersistedDownload( - in: container, gid: gid, status: .partial, completedPageCount: 1, pageCount: 2 + let manager = DownloadManager(storage: storage, urlSession: .shared) + try writeManifestFolder( + storage: storage, + gid: gid, + title: "Retry Pages", + pageHashes: ["sha256:done", ""] ) let temporaryFolderURL = try setupRetryPagesPartialFolder(storage: storage, gid: gid) @@ -51,9 +52,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { } @Test - func testCancelQueuedRepairRestoresReadableCountAndClearsPendingOperation() async throws { - let container = try makeInMemoryContainer() - + func testCancelQueuedWorkClearsQueueIntent() async throws { let gid = "cancel-repair-\(UUID().uuidString)" let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -62,45 +61,28 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container + urlSession: .shared ) - try insertPersistedDownload( - in: container, + try writeManifestFolder( + storage: storage, gid: gid, - status: .missingFiles, - completedPageCount: 0, - pageCount: 2, - remoteVersionSignature: "hash:v1", - latestRemoteVersionSignature: "hash:v1", - pendingOperation: .repair - ) - - let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) - try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - try Data([0x01]).write( - to: completedFolderURL.appendingPathComponent("pages/0001.jpg"), - options: .atomic + title: "Queued", + pageHashes: ["sha256:done", ""] ) + await manager.testingSetQueuedGalleryIDs([gid]) let result = await manager.togglePause(gid: gid) guard case .success = result else { - Issue.record("Cancelling queued repair should succeed, got \(result)") + Issue.record("Cancelling queued work should succeed, got \(result)") return } let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.status == .missingFiles) + #expect(stored?.status == .paused) #expect(stored?.completedPageCount == 1) #expect(stored?.pendingOperation == nil) + #expect(stored?.badge == .paused(1, 2)) } } @@ -108,6 +90,43 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { // MARK: - Setup Helpers private extension DownloadRetryPagesTests { + func writeManifestFolder( + storage: DownloadFileStorage, + gid: String, + title: String, + pageHashes: [String] + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: pageHashes.enumerated().map { offset, hash in + .init( + index: offset + 1, + relativePath: "pages/\(String(format: "%04d", offset + 1)).jpg", + fileHash: hash + ) + } + ), + folderURL: folderURL + ) + } + @discardableResult func setupRetryPagesPartialFolder( storage: DownloadFileStorage, diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index e0928eb3a..a8bafac8e 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -3,7 +3,6 @@ // EhPandaTests // -import CoreData import Foundation import Testing @testable import EhPanda @@ -12,7 +11,6 @@ import Testing struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { @Test func testRetryPagesQueuesFullUpdateWhenGalleryHasUpdate() async throws { - let container = try makeInMemoryContainer() let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) let pageIndex = 42 @@ -22,7 +20,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let (storage, queueingManager) = makeStubbedDownloadManager( - rootURL: rootURL, sessionID: sessionID, persistenceContainer: container + rootURL: rootURL, sessionID: sessionID ) defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } @@ -30,17 +28,15 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { manager: queueingManager, sessionID: sessionID, gid: gid, pageIndex: pageIndex, oldVersionSignature: oldVersionSignature ) - let updatedVersionSignature = fallbackResult.versionSignature let pageCount = fallbackResult.pageCount let oldCount = pageCount - 5 - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try insertPersistedDownload( - in: container, gid: gid, status: .updateAvailable, - completedPageCount: oldCount - 1, pageCount: oldCount, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: updatedVersionSignature + try writeFinalManifest( + storage: storage, + gid: gid, + pageCount: oldCount ) + await queueingManager.testingSetUpdatedGalleryIDs([gid]) let queuedCandidate = await queueingManager.testingFetchDownload(gid: gid) #expect(queuedCandidate?.hasUpdate == true) @@ -55,19 +51,14 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { } let queued = await queueingManager.testingFetchDownload(gid: gid) - #expect(queued?.status == .updateAvailable) - #expect(queued?.pendingOperation == .update) + #expect(queued?.status == .queued) + #expect(queued?.badge == .queued) + #expect(queued?.pendingOperation == nil) #expect(queued?.lastError == nil) - if FileManager.default.fileExists(atPath: temporaryFolderURL.path) { - let queuedResumeState = try storage.readResumeState(folderURL: temporaryFolderURL) - #expect(queuedResumeState.mode == .update) - #expect(queuedResumeState.pageSelection == nil) - } } @Test func testRetryPagesNormalizesImmediateUpdateWhenGalleryHasUpdate() async throws { - let container = try makeInMemoryContainer() let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) let pageIndex = 42 @@ -77,7 +68,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let (storage, immediateManager) = makeStubbedDownloadManager( - rootURL: rootURL, sessionID: sessionID, persistenceContainer: container + rootURL: rootURL, sessionID: sessionID ) defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } @@ -89,10 +80,11 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { let pageCount = updateResult.pageCount try setupImmediateUpdateTestState( - container: container, storage: storage, + storage: storage, context: DownloadPageContext(gid: gid, pageIndex: pageIndex, pageCount: pageCount), - signatures: VersionSignaturePair(old: oldVersionSignature, updated: updatedVersionSignature) + updatedVersionSignature: updatedVersionSignature ) + await immediateManager.testingSetUpdatedGalleryIDs([gid]) let immediateBlockerTask = Task { try? await Task.sleep(nanoseconds: 5_000_000_000) @@ -125,13 +117,6 @@ private struct UpdateFallbackPayloadResult { let pageCount: Int } -// MARK: - Version Signature Pair - -private struct VersionSignaturePair { - let old: String - let updated: String -} - // MARK: - Download Page Context private struct DownloadPageContext { @@ -174,25 +159,73 @@ private extension DownloadRetryUpdateFallbackTests { } func setupImmediateUpdateTestState( - container: NSPersistentContainer, storage: DownloadFileStorage, - context: DownloadPageContext, signatures: VersionSignaturePair + storage: DownloadFileStorage, + context: DownloadPageContext, updatedVersionSignature: String ) throws { let oldCount = context.pageCount - 5 let manifest = try sampleManifest( gid: context.gid, title: "Pause Race", - pageCount: context.pageCount, versionSignature: signatures.updated + pageCount: context.pageCount, versionSignature: updatedVersionSignature ) try writeTemporaryManifestAndPages( storage: storage, gid: context.gid, manifest: manifest, pageCount: context.pageCount, omittingPage: context.pageIndex, - versionSignature: signatures.updated, + versionSignature: updatedVersionSignature, mode: .update, pageSelection: [context.pageIndex] ) - try insertPersistedDownload( - in: container, gid: context.gid, status: .updateAvailable, - completedPageCount: oldCount - 1, pageCount: oldCount, - remoteVersionSignature: signatures.old, - latestRemoteVersionSignature: signatures.updated + try writeFinalManifest( + storage: storage, + gid: context.gid, + pageCount: oldCount + ) + } + + func writeFinalManifest( + storage: DownloadFileStorage, + gid: String, + pageCount: Int + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Pause Race") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + completeManifest(gid: gid, title: "Pause Race", pageCount: pageCount), + folderURL: folderURL + ) + } + + func completeManifest( + gid: String, + title: String, + pageCount: Int + ) throws -> DownloadManifest { + let manifest = try sampleManifest( + gid: gid, + title: title, + pageCount: pageCount + ) + return DownloadManifest( + gid: manifest.gid, + host: manifest.host, + token: manifest.token, + title: manifest.title, + jpnTitle: manifest.jpnTitle, + category: manifest.category, + language: manifest.language, + uploader: manifest.uploader, + tags: manifest.tags, + postedDate: manifest.postedDate, + rating: manifest.rating, + pages: manifest.pages.map { + DownloadManifest.Page( + index: $0.index, + relativePath: $0.relativePath, + fileHash: "sha256:\($0.index)" + ) + } ) } } diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index 16b37d934..a9f2e4176 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -11,19 +11,18 @@ import Testing struct DownloadSchedulingTests: DownloadFeatureTestCase { @Test func testConcurrentSchedulingCreatesOnlyOneActiveTask() async throws { - let container = try makeInMemoryContainer() let gid = "100001" let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } + let storage = DownloadFileStorage( + rootURL: rootURL, + fileManager: .default + ) let manager = DownloadManager( - storage: DownloadFileStorage( - rootURL: rootURL, - fileManager: .default - ), - urlSession: .shared, - persistenceContainer: container + storage: storage, + urlSession: .shared ) await manager.testingSetScheduledProcessHook { _ in while !Task.isCancelled { @@ -31,12 +30,36 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { } } - try insertPersistedDownload( - in: container, - gid: gid, - status: .queued, - completedPageCount: 0 + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Queued") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: "Queued", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: [ + .init( + index: 1, + relativePath: "pages/0001.jpg", + fileHash: "" + ) + ] + ), + folderURL: folderURL ) + await manager.testingSetQueuedGalleryIDs([gid]) let gate = ScheduleFetchGate() await manager.testingSetFetchDownloadsFromStoreHook { diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index a8fef630d..27f68c1fd 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -3,7 +3,6 @@ // EhPandaTests // -import CoreData import Foundation import Testing @testable import EhPanda @@ -11,23 +10,23 @@ import Testing @Suite(.serialized) struct DownloadVersionSignatureTests: DownloadFeatureTestCase { @Test - func testDownloadManagerReconcileNormalizesFailedDownloadBeforeTempCleanup() async throws { - let container = try makeInMemoryContainer() - + func testDownloadManagerReconcilePreservesIndexedTemporaryFolder() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 31) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared, persistenceContainer: container) - try insertPersistedDownload( - in: container, - gid: gid, - status: .failed, - completedPageCount: 0, - pageCount: 2, - lastError: .init(code: .networkingFailed, message: "Network Error") + let manager = DownloadManager(storage: storage, urlSession: .shared) + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Indexed") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: "Indexed", pageCount: 2), + folderURL: folderURL ) let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) @@ -45,8 +44,8 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) let localPages = try await manager.loadLocalPageURLs(gid: gid).get() - #expect(stored?.status == .partial) - #expect(stored?.completedPageCount == 1) + #expect(stored?.status == .paused) + #expect(stored?.completedPageCount == 0) #expect(FileManager.default.fileExists(atPath: temporaryFolderURL.path)) #expect(localPages[1] == temporaryFolderURL.appendingPathComponent("pages/0001.jpg")) } @@ -54,8 +53,6 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { @MainActor @Test func testUpdateRemoteVersionUsesIndexedSessionFlag() async throws { - let container = try makeInMemoryContainer() - let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 104) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -64,18 +61,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: .shared, - persistenceContainer: container - ) - try insertPersistedDownload( - in: container, - gid: gid, - status: .completed, - completedPageCount: 1, - pageCount: 1, - token: "token", - remoteVersionSignature: "hash:old", - latestRemoteVersionSignature: "hash:old" + urlSession: .shared ) try storage.ensureRootDirectory() let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Indexed") @@ -144,16 +130,6 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { #expect(currentBadge == .downloaded) #expect(currentDownload?.displayStatus == .completed) #expect(currentDownload?.status == .completed) - - let request = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - request.fetchLimit = 1 - request.predicate = NSPredicate(format: "gid == %@", gid) - let persistedDownload = try container.viewContext.fetch(request).first - #expect(persistedDownload?.status == DownloadStatus.completed.rawValue) - #expect(persistedDownload?.remoteVersionSignature == "hash:old") - #expect(persistedDownload?.latestRemoteVersionSignature == "hash:old") } } From 84bff470c167c20a6032e8fbd577ef7638485001 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 05:57:10 +0800 Subject: [PATCH 119/614] Drop resume state --- .../Clients/DownloadClient+Execution.swift | 11 +-- .../DownloadClient+ExecutionFetch.swift | 18 ++-- .../DownloadClient+ExecutionPerform.swift | 19 ---- .../DownloadClient+ExecutionSupport.swift | 50 +--------- .../Clients/DownloadClient+Manager.swift | 2 + .../Clients/DownloadClient+Persistence.swift | 2 + .../Clients/DownloadClient+PublicAPI.swift | 2 + .../DownloadClient+PublicAPIHelpers.swift | 77 +-------------- .../Clients/DownloadClient+RetryHelpers.swift | 53 ++--------- .../Clients/DownloadClient+Scheduling.swift | 21 +++-- .../DownloadClient+SchedulingHelpers.swift | 94 +++---------------- EhPanda/App/Tools/Defaults.swift | 1 - .../Tools/Utilities/DownloadFileStorage.swift | 45 --------- .../DownloadFeatureTestTemporaryStorage.swift | 11 +-- .../DownloadFileStorageStateTests.swift | 23 ----- .../DownloadManagerCaptureTests.swift | 9 -- .../DownloadManagerStorageTests.swift | 15 +-- .../Download/DownloadProcessCacheTests.swift | 7 -- .../Tests/Download/DownloadProcessTests.swift | 12 --- .../DownloadRetryMinimalSourceTests.swift | 45 ++++++--- .../Download/DownloadRetryPagesTests.swift | 29 ++---- .../DownloadRetryUpdateFallbackTests.swift | 5 - 22 files changed, 100 insertions(+), 451 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 0cdb05335..5a1937218 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -96,13 +96,7 @@ extension DownloadManager { download: DownloadedGallery, mode: DownloadStartMode ) async throws -> ProcessDownloadResult { - let existingFolderURL = download.resolvedFolderURL(rootURL: storage.rootURL) - let existingResumeState = (try? storage - .readResumeState(folderURL: existingFolderURL) - ) ?? (try? storage.readResumeState( - folderURL: storage.temporaryFolderURL(gid: gid) - )) - let rawPageSelection = existingResumeState?.pageSelection + let rawPageSelection = queuedPageSelections[gid] let fetchedPayload = try await fetchLatestPayload( for: download, mode: mode, @@ -111,7 +105,6 @@ extension DownloadManager { let payload = normalizeFetchedPayload( fetchedPayload, mode: mode, - existingResumeState: existingResumeState, rawPageSelection: rawPageSelection ) let folderRelativePath = folderRelativePath(for: payload) @@ -190,6 +183,8 @@ extension DownloadManager { downloadErrors[gid] = nil validationErrors[gid] = nil updatedGalleryIDs.remove(gid) + queuedModes[gid] = nil + queuedPageSelections[gid] = nil await queueStore.remove(gid) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index b59646f31..e31b9ce70 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -136,19 +136,15 @@ extension DownloadManager { func normalizeFetchedPayload( _ payload: DownloadRequestPayload, mode: DownloadStartMode, - existingResumeState: DownloadResumeState?, rawPageSelection: [Int]? ) -> DownloadRequestPayload { - let shouldPreservePageSelection = - rawPageSelection?.isEmpty == false - && existingResumeState?.matches( - mode: mode, - pageCount: payload.galleryDetail.pageCount, - downloadOptions: payload.options - ) == true - && mode != .update + let validPageSelection = rawPageSelection? + .filter { (1...payload.galleryDetail.pageCount).contains($0) } + let pageSelection = validPageSelection?.isEmpty == false && mode != .update + ? validPageSelection + : nil - guard !shouldPreservePageSelection else { + guard pageSelection != rawPageSelection else { return payload } @@ -161,7 +157,7 @@ extension DownloadManager { versionMetadata: payload.versionMetadata, options: payload.options, mode: payload.mode, - pageSelection: nil + pageSelection: pageSelection.map(Set.init) ) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 99fe32e50..d99646d12 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -32,15 +32,6 @@ extension DownloadManager { folderURL: workingFolderURL, existingPageRelativePaths: workingSeed.existingPages ) - try storage.writeResumeState( - .init( - mode: payload.mode, - pageCount: payload.galleryDetail.pageCount, - downloadOptions: payload.options, - pageSelection: payload.pageSelection?.sorted() - ), - folderURL: workingFolderURL - ) let executionContext = DownloadExecutionContext( existingDownload: existingDownload @@ -124,16 +115,6 @@ extension DownloadManager { payload: DownloadRequestPayload, folderURL: URL ) async throws { - if payload.pageSelection != nil { - try? storage.writeResumeState( - .init( - mode: payload.mode, - pageCount: payload.galleryDetail.pageCount, - downloadOptions: payload.options - ), - folderURL: folderURL - ) - } if !context.batchResult.failedPages.isEmpty { throw PartialDownloadError( failedPages: context.batchResult.failedPages diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 45f577e93..da7f14d89 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -180,20 +180,13 @@ extension DownloadManager { existingDownload: DownloadedGallery, folderURL: URL ) throws -> WorkingSeed { - let resumeState = try? storage - .readResumeState(folderURL: folderURL) let shouldReuseFolder = shouldReuseWorkingFolder( payload: payload, - resumeState: resumeState, folderURL: folderURL ) let seedContext = RepairSeedContext( existingDownload: existingDownload, - payload: payload, - temporarySeed: temporaryWorkingSeed( - payload: payload, - folderURL: folderURL - ) + payload: payload ) try setupWorkingFolder( folderURL: folderURL, @@ -223,19 +216,11 @@ extension DownloadManager { private func shouldReuseWorkingFolder( payload: DownloadRequestPayload, - resumeState: DownloadResumeState?, folderURL: URL ) -> Bool { guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return false } - if resumeState?.matches( - mode: payload.mode, - pageCount: payload.galleryDetail.pageCount, - downloadOptions: payload.options - ) == true { - return true - } switch payload.mode { case .initial: guard let manifest = try? storage.readManifest(folderURL: folderURL) else { @@ -254,7 +239,6 @@ extension DownloadManager { private struct RepairSeedContext { let existingDownload: DownloadedGallery let payload: DownloadRequestPayload - let temporarySeed: RepairSeed? } private func setupWorkingFolder( @@ -268,7 +252,7 @@ extension DownloadManager { } } if !fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) { - if let seed = seedContext.temporarySeed ?? repairSeed( + if let seed = repairSeed( for: seedContext.existingDownload, payload: seedContext.payload ) { @@ -283,36 +267,6 @@ extension DownloadManager { } } - private func temporaryWorkingSeed( - payload: DownloadRequestPayload, - folderURL: URL - ) -> RepairSeed? { - let temporaryFolderURL = storage - .temporaryFolderURL(gid: payload.gallery.gid) - guard temporaryFolderURL != folderURL, - fileManager.operate({ - $0.fileExists(atPath: temporaryFolderURL.path) - }), - let resumeState = try? storage.readResumeState( - folderURL: temporaryFolderURL - ), - resumeState.matches( - mode: payload.mode, - pageCount: payload.galleryDetail.pageCount, - downloadOptions: payload.options - ), - let manifest = validatedManifest( - at: temporaryFolderURL, - gid: payload.gallery.gid, - pageCount: payload.galleryDetail.pageCount - ), - manifest.token == payload.gallery.token - else { - return nil - } - return .init(folderURL: temporaryFolderURL, manifest: manifest) - } - func resolvedImageSource( index: Int, payload: DownloadRequestPayload, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index ea9005f17..43c56a48c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -136,6 +136,8 @@ actor DownloadManager { var downloadErrors = [String: DownloadFailure]() var validationErrors = [String: DownloadFailure]() var updatedGalleryIDs = Set() + var queuedModes = [String: DownloadStartMode]() + var queuedPageSelections = [String: [Int]]() var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() var lastObservedDownloads = [DownloadedGallery]() var activeGalleryID: String? diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index c1f06d722..2bd87606a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -165,6 +165,8 @@ extension DownloadManager { } #endif downloadErrors[context.gid] = DownloadFailure(error: error) + queuedModes[context.gid] = nil + queuedPageSelections[context.gid] = nil await queueStore.remove(context.gid) _ = await reloadDownloadIndex() } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index c05db2b5f..546143f90 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -166,6 +166,8 @@ extension DownloadManager { taskToCancel = nil } await taskToCancel?.value + queuedModes[gid] = nil + queuedPageSelections[gid] = nil await queueStore.remove(gid) guard let download = await fetchDownload(gid: gid) else { return .failure(.notFound) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index c610914a6..9524ded6c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -131,93 +131,24 @@ extension DownloadManager { return .success(completedPageURLs) } - struct RetryParams { - let shouldResumeExistingWork: Bool - let resumedStatus: DownloadStatus - let completedPageCount: Int - let pendingOperation: DownloadStartMode? - } - - func computeRetryParams( - download: DownloadedGallery, - resolvedMode: DownloadStartMode, - existingResumeState: DownloadResumeState?, - gid: String - ) -> RetryParams { - let shouldResumeExisting = shouldResumeExistingWorkingSet( - for: download, - mode: resolvedMode, - resumeState: existingResumeState - ) - let shouldStartImmediately = - activeTask == nil || activeGalleryID == gid - let resumedStatus: DownloadStatus - let completedPageCount: Int - let pendingOperation: DownloadStartMode? - - if shouldResumeExisting { - resumedStatus = shouldStartImmediately - ? .downloading : .queued - completedPageCount = download.completedPageCount - pendingOperation = nil - } else if shouldStartImmediately { - resumedStatus = .downloading - completedPageCount = validatedCompletedPageCount(download) - pendingOperation = nil - } else { - resumedStatus = download.status - completedPageCount = validatedCompletedPageCount(download) - pendingOperation = resolvedMode - } - - return RetryParams( - shouldResumeExistingWork: shouldResumeExisting, - resumedStatus: resumedStatus, - completedPageCount: completedPageCount, - pendingOperation: pendingOperation - ) - } - - func writeRetryResumeState( - download: DownloadedGallery, - resolvedMode: DownloadStartMode, - existingResumeState: DownloadResumeState?, - temporaryFolderURL: URL - ) { - let downloadOptions = download.downloadOptionsSnapshot - let pageCount = preferredWorkingPageCount( - for: download, - mode: resolvedMode, - resumeState: existingResumeState - ) - try? storage.writeResumeState( - .init( - mode: resolvedMode, - pageCount: pageCount, - downloadOptions: downloadOptions - ), - folderURL: temporaryFolderURL - ) - } - func clearSelectedFailedPages( selectedPageIndices: [Int], - temporaryFolderURL: URL + folderURL: URL ) { if let failedSnapshot = try? storage.readFailedPages( - folderURL: temporaryFolderURL + folderURL: folderURL ) { let remainingPages = failedSnapshot.pages.filter { !selectedPageIndices.contains($0.index) } if remainingPages.isEmpty { try? storage.removeFailedPages( - folderURL: temporaryFolderURL + folderURL: folderURL ) } else { try? storage.writeFailedPages( .init(pages: remainingPages), - folderURL: temporaryFolderURL + folderURL: folderURL ) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index f25974001..45920f9b3 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -33,32 +33,11 @@ extension DownloadManager { let resolvedMode = effectiveRetryMode( for: download, requestedMode: mode ) - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let existingResumeState = fileManager.operate { - $0.fileExists(atPath: temporaryFolderURL.path) - } - ? (try? storage.readResumeState(folderURL: temporaryFolderURL)) - : nil - let retryParams = computeRetryParams( - download: download, - resolvedMode: resolvedMode, - existingResumeState: existingResumeState, - gid: gid - ) - if !retryParams.shouldResumeExistingWork { - try? storage.removeTemporaryFolder(gid: gid) - } + queuedModes[gid] = resolvedMode + queuedPageSelections[gid] = nil downloadErrors[gid] = nil validationErrors[gid] = nil await queueStore.enqueue(gid) - if fileManager.operate({ $0.fileExists(atPath: temporaryFolderURL.path) }) { - writeRetryResumeState( - download: download, - resolvedMode: resolvedMode, - existingResumeState: existingResumeState, - temporaryFolderURL: temporaryFolderURL - ) - } await notifyObservers() await scheduleNextIfNeeded() } @@ -76,8 +55,8 @@ extension DownloadManager { let selectedPageIndices = Array(Set(pageIndices)).sorted() guard !selectedPageIndices.isEmpty else { return .success(()) } - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - guard fileManager.operate({ $0.fileExists(atPath: temporaryFolderURL.path) }) else { + let folderURL = download.resolvedFolderURL(rootURL: storage.rootURL) + guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return .failure(.notFound) } do { @@ -86,7 +65,7 @@ extension DownloadManager { download: download, mode: mode, selectedPageIndices: selectedPageIndices, - temporaryFolderURL: temporaryFolderURL + folderURL: folderURL ) return .success(()) } catch let error as AppError { @@ -102,28 +81,14 @@ extension DownloadManager { download: DownloadedGallery, mode: DownloadStartMode, selectedPageIndices: [Int], - temporaryFolderURL: URL + folderURL: URL ) async throws { - let existingResumeState = try? storage.readResumeState( - folderURL: temporaryFolderURL - ) - let pageCount = preferredWorkingPageCount( - for: download, mode: mode, - resumeState: existingResumeState - ) clearSelectedFailedPages( selectedPageIndices: selectedPageIndices, - temporaryFolderURL: temporaryFolderURL - ) - try storage.writeResumeState( - .init( - mode: mode, - pageCount: pageCount, - downloadOptions: download.downloadOptionsSnapshot, - pageSelection: selectedPageIndices - ), - folderURL: temporaryFolderURL + folderURL: folderURL ) + queuedModes[gid] = mode + queuedPageSelections[gid] = selectedPageIndices downloadErrors[gid] = nil validationErrors[gid] = nil await queueStore.enqueue(gid) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index c8130a374..9e8815942 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -114,15 +114,7 @@ extension DownloadManager { return false } - let temporaryFolderURL = storage - .temporaryFolderURL(gid: download.gid) - guard let resumeState = try? storage - .readResumeState(folderURL: temporaryFolderURL), - let pageSelection = resumeState.pageSelection - else { - return false - } - return !pageSelection.isEmpty + return queuedPageSelections[download.gid]?.isEmpty == false } func syncDownloadsState(scheduleNext: Bool) async { @@ -198,6 +190,8 @@ extension DownloadManager { ) async throws -> Task? { downloadErrors[gid] = nil validationErrors[gid] = nil + queuedModes[gid] = nil + queuedPageSelections[gid] = nil await queueStore.remove(gid) _ = await reloadDownloadIndex() await notifyObservers() @@ -217,6 +211,8 @@ extension DownloadManager { ) async throws { downloadErrors[gid] = nil validationErrors[gid] = nil + queuedModes[gid] = nil + queuedPageSelections[gid] = nil await queueStore.remove(gid) _ = await reloadDownloadIndex() } @@ -232,17 +228,22 @@ extension DownloadManager { break } + queuedModes[download.gid] = nil + queuedPageSelections[download.gid] = nil + await queueStore.remove(download.gid) await notifyObservers() return .success(()) } func resume(gid: String) async -> Result { - guard await fetchDownload(gid: gid) != nil else { + guard let download = await fetchDownload(gid: gid) else { return .failure(.notFound) } downloadErrors[gid] = nil validationErrors[gid] = nil + queuedModes[gid] = resumeMode(for: download) + queuedPageSelections[gid] = nil await queueStore.enqueue(gid) _ = await reloadDownloadIndex() await notifyObservers() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index 76b2aa2c2..e67452b6c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -10,6 +10,12 @@ extension DownloadManager { func queuedMode( for download: DownloadedGallery ) -> DownloadStartMode { + if let mode = queuedModes[download.gid] { + return effectiveRetryMode( + for: download, + requestedMode: mode + ) + } if let pendingOperation = download.pendingOperation { return pendingOperation } @@ -37,12 +43,11 @@ extension DownloadManager { case .paused: return resumeMode(for: download) case .queued, .downloading: - return readResumeMode(gid: download.gid) - ?? effectiveRetryMode( - for: download, - requestedMode: download.remoteVersionSignature.isEmpty - ? .initial : .redownload - ) + return effectiveRetryMode( + for: download, + requestedMode: download.remoteVersionSignature.isEmpty + ? .initial : .redownload + ) } } @@ -55,12 +60,6 @@ extension DownloadManager { if download.hasUpdate { return .update } - if let mode = readResumeMode(gid: download.gid) { - return effectiveRetryMode( - for: download, - requestedMode: mode - ) - } if download.status == .partial { return effectiveRetryMode( for: download, @@ -84,77 +83,6 @@ extension DownloadManager { return .update } - func preferredWorkingPageCount( - for download: DownloadedGallery, - mode: DownloadStartMode, - resumeState: DownloadResumeState? - ) -> Int { - guard mode == .update else { - return download.pageCount - } - - let temporaryFolderURL = storage - .temporaryFolderURL(gid: download.gid) - guard fileManager.operate({ - $0.fileExists(atPath: temporaryFolderURL.path) - }) else { - return download.pageCount - } - - if let manifest = try? storage - .readManifest(folderURL: temporaryFolderURL), - manifest.gid == download.gid { - return manifest.pageCount - } - - if let resumeState { - return resumeState.pageCount - } - - return download.pageCount - } - - func shouldResumeExistingWorkingSet( - for download: DownloadedGallery, - mode: DownloadStartMode, - resumeState: DownloadResumeState? - ) -> Bool { - guard download.status == .failed - || storage.temporaryFolderExists(gid: download.gid), - let resumeState - else { - return false - } - - let pageCount = preferredWorkingPageCount( - for: download, - mode: mode, - resumeState: resumeState - ) - - guard resumeState.mode == mode, - resumeState.downloadOptions == - download.downloadOptionsSnapshot - else { - return false - } - - if mode == .update, - let manifest = try? storage.readManifest( - folderURL: storage.temporaryFolderURL(gid: download.gid) - ), - manifest.gid == download.gid { - return manifest.pageCount == pageCount - } - - return resumeState.pageCount == pageCount - } - - func readResumeMode(gid: String) -> DownloadStartMode? { - let folderURL = storage.temporaryFolderURL(gid: gid) - return try? storage.readResumeState(folderURL: folderURL).mode - } - nonisolated func fallbackStatus( for download: DownloadedGallery, mode: DownloadStartMode diff --git a/EhPanda/App/Tools/Defaults.swift b/EhPanda/App/Tools/Defaults.swift index 99746f93c..84c297ec1 100644 --- a/EhPanda/App/Tools/Defaults.swift +++ b/EhPanda/App/Tools/Defaults.swift @@ -67,7 +67,6 @@ struct Defaults { static let downloads = "Downloads" static let downloadPages = "pages" static let downloadManifest = "manifest.json" - static let downloadResumeState = ".resume.json" static let downloadFailedPages = ".failed-pages.json" } struct Regex { diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 22e335f4b..fa2b0ea74 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -18,35 +18,6 @@ struct DownloadFolderRecord: Equatable, Sendable { let modifiedAt: Date? } -struct DownloadResumeState: Codable, Equatable { - let mode: DownloadStartMode - let pageCount: Int - let downloadOptions: DownloadOptionsSnapshot - let pageSelection: [Int]? - - init( - mode: DownloadStartMode, - pageCount: Int, - downloadOptions: DownloadOptionsSnapshot, - pageSelection: [Int]? = nil - ) { - self.mode = mode - self.pageCount = pageCount - self.downloadOptions = downloadOptions - self.pageSelection = pageSelection - } - - func matches( - mode: DownloadStartMode, - pageCount: Int, - downloadOptions: DownloadOptionsSnapshot - ) -> Bool { - self.mode == mode - && self.pageCount == pageCount - && self.downloadOptions == downloadOptions - } -} - struct DownloadFileStorage: Sendable { let rootURL: URL let fileManager: DownloadFileManager @@ -98,10 +69,6 @@ struct DownloadFileStorage: Sendable { rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) } - func temporaryFolderExists(gid: String) -> Bool { - fileManager.operate { $0.fileExists(atPath: temporaryFolderURL(gid: gid).path) } - } - func removeTemporaryFolder(gid: String) throws { let targetURL = temporaryFolderURL(gid: gid) try fileManager.operate { @@ -110,22 +77,10 @@ struct DownloadFileStorage: Sendable { } } - func resumeStateURL(folderURL: URL) -> URL { - folderURL.appendingPathComponent(Defaults.FilePath.downloadResumeState) - } - func failedPagesURL(folderURL: URL) -> URL { folderURL.appendingPathComponent(Defaults.FilePath.downloadFailedPages) } - func writeResumeState(_ state: DownloadResumeState, folderURL: URL) throws { - try writeJSON(state, to: resumeStateURL(folderURL: folderURL)) - } - - func readResumeState(folderURL: URL) throws -> DownloadResumeState { - try readJSON(DownloadResumeState.self, from: resumeStateURL(folderURL: folderURL)) - } - func writeFailedPages(_ snapshot: DownloadFailedPagesSnapshot, folderURL: URL) throws { try writeJSON(snapshot, to: failedPagesURL(folderURL: folderURL)) } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift index a2893349f..663cf19eb 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift @@ -15,8 +15,8 @@ extension DownloadFeatureTestCase { manifest: DownloadManifest, pageCount: Int, omittingPage pageToOmit: Int? = nil, versionSignature _: String, - mode: DownloadStartMode = .redownload, - pageSelection: [Int]? = nil + mode _: DownloadStartMode = .redownload, + pageSelection _: [Int]? = nil ) throws { let folderURL = storage.temporaryFolderURL(gid: gid) try? FileManager.default.removeItem(at: folderURL) @@ -44,12 +44,5 @@ extension DownloadFeatureTestCase { options: .atomic ) } - try storage.writeResumeState( - .init( - mode: mode, pageCount: pageCount, downloadOptions: .init(), - pageSelection: pageSelection - ), - folderURL: folderURL - ) } } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift index 055562125..6f4a4b304 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift @@ -8,29 +8,6 @@ import Testing @testable import EhPanda struct DownloadFileStorageStateTests { - @Test - func testWriteAndReadResumeState() throws { - let (storage, rootURL) = makeStorage() - defer { try? FileManager.default.removeItem(at: rootURL) } - - try storage.ensureRootDirectory() - let folderURL = storage.temporaryFolderURL(gid: "123") - try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - - let resumeState = DownloadResumeState( - mode: .update, - pageCount: 27, - downloadOptions: .init( - threadLimit: 4, - allowCellular: false, - autoRetryFailedPages: false - ) - ) - try storage.writeResumeState(resumeState, folderURL: folderURL) - - #expect(try storage.readResumeState(folderURL: folderURL) == resumeState) - } - @Test func testWriteReadAndRemoveFailedPagesSnapshot() throws { let (storage, rootURL) = makeStorage() diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index 0993232d2..a22da1410 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -51,15 +51,6 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { ), folderURL: temporaryFolderURL ) - try storage.writeResumeState( - .init( - mode: .initial, - pageCount: 2, - downloadOptions: .init() - ), - folderURL: temporaryFolderURL - ) - let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in UIColor.systemBlue.setFill() diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index c123792c3..2bc02053f 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -446,20 +446,17 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) try storage.ensureRootDirectory() + let folderRelativePath = "[460_token] Retry Pages" try writeIndexedManifest( storage: storage, - relativePath: "[460_token] Retry Pages", + relativePath: folderRelativePath, manifest: indexedManifest( gid: "460", title: "Retry Pages", pageHashes: ["sha256:done", ""] ) ) - let temporaryFolderURL = storage.temporaryFolderURL(gid: "460") - try FileManager.default.createDirectory( - at: temporaryFolderURL, - withIntermediateDirectories: true - ) + let folderURL = storage.folderURL(relativePath: folderRelativePath) try storage.writeFailedPages( .init(pages: [ .init( @@ -471,7 +468,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) ) ]), - folderURL: temporaryFolderURL + folderURL: folderURL ) let blockingTask = Task { do { @@ -488,14 +485,12 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { return } let download = try #require(await manager.fetchDownload(gid: "460")) - let resumeState = try storage.readResumeState(folderURL: temporaryFolderURL) #expect(queueStore.gids == ["460"]) #expect(download.displayStatus == .queued) #expect(download.status == .queued) #expect(download.pendingOperation == nil) - #expect(resumeState.pageSelection == [2]) #expect(FileManager.default.fileExists( - atPath: storage.failedPagesURL(folderURL: temporaryFolderURL).path + atPath: storage.failedPagesURL(folderURL: folderURL).path ) == false) } diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index a5142acd2..9f1e71795 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -302,13 +302,6 @@ private extension DownloadProcessCacheTests { ), options: .atomic ) - try storage.writeResumeState( - .init( - mode: .redownload, - pageCount: oldPageCount, downloadOptions: .init(), pageSelection: [pageIndex] - ), - folderURL: temporaryFolderURL - ) } func waitUntilCacheCleared(cachedKeys: Set) async throws { diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 5d7791833..39c67837c 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -235,15 +235,6 @@ private extension DownloadProcessTests { ), options: .atomic ) - try storage.writeResumeState( - .init( - mode: .redownload, - pageCount: oldPageCount, - downloadOptions: .init(), - pageSelection: [pageIndex] - ), - folderURL: folderURL - ) return folderURL } @@ -273,9 +264,6 @@ private extension DownloadProcessTests { ) == false ) - let resumeState = try storage.readResumeState(folderURL: completedFolderURL) - #expect(resumeState.pageCount == context.updatedPageCount) - #expect(resumeState.pageSelection == nil) #expect(FileManager.default.fileExists(atPath: context.staleFolderURL.path) == false) #expect( FileManager.default.fileExists( diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 38c0d67d7..691afd0eb 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -31,16 +31,22 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { pageCount: setup.pageCount, versionSignature: setup.versionSignature ) try writeFinalManifest(storage: storage, gid: gid, manifest: manifest) - try writeTemporaryManifestAndPages( - storage: storage, gid: gid, manifest: manifest, - pageCount: setup.pageCount, omittingPage: pageIndex, - versionSignature: setup.versionSignature, - pageSelection: [pageIndex] - ) + let blocker = Task { + try? await Task.sleep(for: .seconds(60)) + } + defer { blocker.cancel() } + await manager.testingInstallActiveTask(gid: "busy", task: blocker) + guard case .success = await manager.retryPages(gid: gid, pageIndices: [pageIndex]) else { + Issue.record("retryPages should queue the selected page.") + return + } await manager.testingProcessDownload(gid: gid) let firstRunSnapshot = setup.recorder.snapshot() - #expect(firstRunSnapshot.previewPageNumbers == [1]) + #expect( + firstRunSnapshot.previewPageNumbers == [1], + "\(firstRunSnapshot)" + ) setup.recorder.reset() try await assertRetrySkipsCompletedSelection( @@ -79,16 +85,21 @@ private extension DownloadRetryMinimalSourceTests { func assertRetrySkipsCompletedSelection( _ context: MinimalSourceRetrySkipContext ) async throws { - try writeTemporaryManifestAndPages( - storage: context.storage, gid: context.gid, - manifest: context.manifest, - pageCount: context.setup.pageCount, - versionSignature: context.setup.versionSignature, - pageSelection: [context.pageIndex] - ) + let blocker = Task { + try? await Task.sleep(for: .seconds(60)) + } + defer { blocker.cancel() } + await context.manager.testingInstallActiveTask(gid: "busy", task: blocker) + guard case .success = await context.manager.retryPages( + gid: context.gid, + pageIndices: [context.pageIndex] + ) else { + Issue.record("retryPages should queue the selected page.") + return + } await context.manager.testingProcessDownload(gid: context.gid) let snapshot = context.setup.recorder.snapshot() - #expect(snapshot.previewPageNumbers.isEmpty) + #expect(snapshot.previewPageNumbers.isEmpty, "\(snapshot)") #expect(snapshot.mpvRequests == 0) #expect(snapshot.imageDispatchRequests == 0) } @@ -104,6 +115,10 @@ private extension DownloadRetryMinimalSourceTests { at: folderURL, withIntermediateDirectories: true ) + try Data([0x00]).write( + to: folderURL.appendingPathComponent("cover.jpg"), + options: .atomic + ) try storage.writeManifest(manifest, folderURL: folderURL) } diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index cb0b463f2..698ed93da 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -18,13 +18,13 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) - try writeManifestFolder( + let folderURL = try writeManifestFolder( storage: storage, gid: gid, title: "Retry Pages", pageHashes: ["sha256:done", ""] ) - let temporaryFolderURL = try setupRetryPagesPartialFolder(storage: storage, gid: gid) + try setupRetryPagesFailedPage(storage: storage, folderURL: folderURL) let blockingTask = Task { _ = try? await Task.sleep(for: .seconds(60)) } defer { blockingTask.cancel() } @@ -42,12 +42,8 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { #expect(stored?.pendingOperation == nil) #expect(stored?.lastError == nil) - let resumeState = try storage.readResumeState(folderURL: temporaryFolderURL) - #expect(resumeState.pageSelection == [2]) #expect(FileManager.default.fileExists( - atPath: temporaryFolderURL - .appendingPathComponent(Defaults.FilePath.downloadFailedPages) - .path + atPath: storage.failedPagesURL(folderURL: folderURL).path ) == false) } @@ -90,12 +86,13 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { // MARK: - Setup Helpers private extension DownloadRetryPagesTests { + @discardableResult func writeManifestFolder( storage: DownloadFileStorage, gid: String, title: String, pageHashes: [String] - ) throws { + ) throws -> URL { try storage.ensureRootDirectory() let folderURL = storage.folderURL(relativePath: "[\(gid)_token] \(title)") try FileManager.default.createDirectory( @@ -125,18 +122,13 @@ private extension DownloadRetryPagesTests { ), folderURL: folderURL ) + return folderURL } - @discardableResult - func setupRetryPagesPartialFolder( + func setupRetryPagesFailedPage( storage: DownloadFileStorage, - gid: String - ) throws -> URL { - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL, - withIntermediateDirectories: true - ) + folderURL: URL + ) throws { try storage.writeFailedPages( .init(pages: [ .init( @@ -145,8 +137,7 @@ private extension DownloadRetryPagesTests { failure: .init(code: .networkingFailed, message: "Network Error") ) ]), - folderURL: temporaryFolderURL + folderURL: folderURL ) - return temporaryFolderURL } } diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index a8bafac8e..f2a921c8b 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -98,11 +98,6 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { return } - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let resumedState = try storage.readResumeState(folderURL: temporaryFolderURL) - #expect(resumedState.mode == .update) - #expect(resumedState.pageCount == pageCount) - #expect(resumedState.pageSelection == nil) let resumedDownload = await immediateManager.testingFetchDownload(gid: gid) #expect(resumedDownload?.status == .downloading) #expect(resumedDownload?.pendingOperation == nil) From 0bf90fb2764c7450e5b073c15e2f5f31513ba486 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 06:13:49 +0800 Subject: [PATCH 120/614] Drop temp folders --- .../Tools/Clients/DownloadClient+Cache.swift | 7 -- .../DownloadClient+ExecutionPerform.swift | 4 +- .../DownloadClient+ExecutionSupport.swift | 16 +-- .../Clients/DownloadClient+Manager.swift | 3 +- .../Clients/DownloadClient+PageDownload.swift | 18 ++-- .../DownloadClient+PageDownloadHelpers.swift | 10 +- .../DownloadClient+PersistenceHelpers.swift | 99 +------------------ .../DownloadClient+PersistenceNormalize.swift | 19 +--- .../Clients/DownloadClient+PublicAPI.swift | 6 -- .../DownloadClient+PublicAPIHelpers.swift | 49 +-------- .../Clients/DownloadClient+RetryHelpers.swift | 15 +-- .../Clients/DownloadClient+Scheduling.swift | 10 -- .../DownloadFileStorage+Operations.swift | 45 ++------- .../Tools/Utilities/DownloadFileStorage.swift | 12 --- .../DownloadedGallery+SupportTypes.swift | 29 ------ .../Download/DownloadBadgeSortTests.swift | 35 +++---- .../DownloadFeatureTestTemporaryStorage.swift | 48 --------- .../DownloadFileStorageRepairTests.swift | 32 +++--- .../DownloadFileStorageStateTests.swift | 2 +- .../Download/DownloadFileStorageTests.swift | 40 +------- .../DownloadManagerCaptureTests.swift | 17 +--- .../DownloadManagerStorageTests.swift | 44 +++------ .../DownloadPauseAndReconcileTests.swift | 24 ++--- .../Download/DownloadProcessCacheTests.swift | 35 ------- .../Tests/Download/DownloadProcessTests.swift | 6 +- .../DownloadRetryUpdateFallbackTests.swift | 16 +-- .../DownloadVersionSignatureTests.swift | 12 +-- 27 files changed, 102 insertions(+), 551 deletions(-) delete mode 100644 EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index f32dc93d0..9afc4cd9d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -117,13 +117,6 @@ extension DownloadManager { } } - func shouldExposeTemporaryWorkingSet( - for download: DownloadedGallery - ) -> Bool { - download.shouldPreserveTemporaryWorkingSet - || download.status == .failed - } - func cachedImageData(for url: URL) async -> Data? { await cachedImageData( for: [url], diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index d99646d12..80dd9d3c9 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -74,7 +74,7 @@ extension DownloadManager { let downloadContext = PageDownloadContext( payload: payload, source: source, - temporaryFolderURL: workingFolderURL + folderURL: workingFolderURL ) let batchResult = try await downloadPages( context: downloadContext, @@ -105,7 +105,7 @@ extension DownloadManager { ) async throws -> String? { try await downloadCoverImage( payload: payload, - temporaryFolderURL: folderURL, + folderURL: folderURL, existingCoverRelativePath: existingCoverRelativePath ) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index da7f14d89..522d10196 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -19,12 +19,12 @@ extension DownloadManager { func downloadCoverImage( payload: DownloadRequestPayload, - temporaryFolderURL: URL, + folderURL: URL, existingCoverRelativePath: String? ) async throws -> String? { if let coverRelativePath = existingCoverRelativePath, !coverRelativePath.isEmpty { - let localCoverURL = temporaryFolderURL + let localCoverURL = folderURL .appendingPathComponent(coverRelativePath) if fileManager.operate({ $0.fileExists(atPath: localCoverURL.path) }) { return coverRelativePath @@ -43,13 +43,13 @@ extension DownloadManager { cachedData: cachedData, coverURL: coverURL, payload: payload, - temporaryFolderURL: temporaryFolderURL + folderURL: folderURL ) } return try await downloadCoverFromNetwork( coverURL: coverURL, payload: payload, - temporaryFolderURL: temporaryFolderURL, + folderURL: folderURL, allowsCellular: payload.options.allowCellular ) } @@ -58,7 +58,7 @@ extension DownloadManager { cachedData: Data, coverURL: URL, payload: DownloadRequestPayload, - temporaryFolderURL: URL + folderURL: URL ) throws -> String { let ext = fileExtension( for: coverURL, @@ -71,7 +71,7 @@ extension DownloadManager { token: payload.gallery.token, fileExtension: ext ) - let fileURL = temporaryFolderURL + let fileURL = folderURL .appendingPathComponent(relativePath) try write(data: cachedData, to: fileURL) return relativePath @@ -80,7 +80,7 @@ extension DownloadManager { private func downloadCoverFromNetwork( coverURL: URL, payload: DownloadRequestPayload, - temporaryFolderURL: URL, + folderURL: URL, allowsCellular: Bool ) async throws -> String { let (downloadedFileURL, response) = @@ -102,7 +102,7 @@ extension DownloadManager { token: payload.gallery.token, fileExtension: ext ) - let fileURL = temporaryFolderURL + let fileURL = folderURL .appendingPathComponent(relativePath) try moveDownloadedFile( from: downloadedFileURL, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 43c56a48c..884d78405 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -85,7 +85,7 @@ actor DownloadManager { struct PageDownloadContext: Sendable { let payload: DownloadRequestPayload let source: ResolvedSource? - let temporaryFolderURL: URL + let folderURL: URL } struct CacheRestoreSource: Sendable { @@ -99,7 +99,6 @@ actor DownloadManager { struct CaptureTargetResult: Sendable { let folderURL: URL let preferredRelativePath: String? - let isTemporary: Bool } struct PrepareWorkingSeedResult: Sendable { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index c099cf2da..5b4c2f0da 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -28,7 +28,7 @@ extension DownloadManager { var progress = PageDownloadProgress() progress.failedPages = (try? storage .readFailedPages( - folderURL: context.temporaryFolderURL + folderURL: context.folderURL ).map) ?? [:] try await initializePageDownloadState( @@ -59,7 +59,7 @@ extension DownloadManager { try await flushDownloadProgress( context: .init( gid: context.payload.gallery.gid, - folderURL: context.temporaryFolderURL + folderURL: context.folderURL ), pendingResolvedPages: &progress.pendingResolvedPages, lastFlushDate: &progress.lastFlushDate, @@ -68,7 +68,7 @@ extension DownloadManager { return try buildBatchResult( results: progress.results, failedPages: progress.failedPages, - temporaryFolderURL: context.temporaryFolderURL + folderURL: context.folderURL ) } @@ -90,7 +90,7 @@ extension DownloadManager { progress.completedCount = progress.results.count guard progress.completedCount > 0 else { return } try flushManifestPageProgress( - folderURL: context.temporaryFolderURL, + folderURL: context.folderURL, pages: progress.results ) await notifyObservers() @@ -99,7 +99,7 @@ extension DownloadManager { private func buildBatchResult( results: [PageResult], failedPages: [Int: DownloadFailedPagesSnapshot.Page?], - temporaryFolderURL: URL + folderURL: URL ) throws -> DownloadBatchResult { let failedSnapshot = DownloadFailedPagesSnapshot( pages: failedPages.values @@ -111,12 +111,12 @@ extension DownloadManager { ) if failedSnapshot.pages.isEmpty { try? storage.removeFailedPages( - folderURL: temporaryFolderURL + folderURL: folderURL ) } else { try storage.writeFailedPages( failedSnapshot, - folderURL: temporaryFolderURL + folderURL: folderURL ) } return .init( @@ -152,7 +152,7 @@ extension DownloadManager { guard let relativePath = existingPages[index] else { continue } - let fileURL = context.temporaryFolderURL + let fileURL = context.folderURL .appendingPathComponent(relativePath) guard fileManager.operate({ $0.fileExists(atPath: fileURL.path) }) else { continue @@ -204,7 +204,7 @@ extension DownloadManager { try? await flushDownloadProgress( context: .init( gid: payload.gallery.gid, - folderURL: context.temporaryFolderURL + folderURL: context.folderURL ), pendingResolvedPages: &progress.pendingResolvedPages, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift index 655154d7e..49d1416ea 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -45,7 +45,7 @@ extension DownloadManager { preferredRelativePath: String? ) async throws -> PageResult { let payload = context.payload - let temporaryFolderURL = context.temporaryFolderURL + let folderURL = context.folderURL guard let source = context.source else { throw AppError.notFound @@ -67,7 +67,7 @@ extension DownloadManager { index: index, resolvedImageSource: resolved, payload: payload, - temporaryFolderURL: temporaryFolderURL, + folderURL: folderURL, preferredRelativePath: preferredRelativePath ) } @@ -94,7 +94,7 @@ extension DownloadManager { return try await restorePageFromCache( index: index, source: resolvedSource, - folderURL: context.temporaryFolderURL, + folderURL: context.folderURL, preferredRelativePath: preferredRelativePath ) } @@ -103,7 +103,7 @@ extension DownloadManager { index: Int, resolvedImageSource: ResolvedImageSource, payload: DownloadRequestPayload, - temporaryFolderURL: URL, + folderURL: URL, preferredRelativePath: String? ) async throws -> PageResult { let targetURL = resolvedImageSource.imageURL @@ -132,7 +132,7 @@ extension DownloadManager { fileExtension: ext ) } - let fileURL = temporaryFolderURL + let fileURL = folderURL .appendingPathComponent(relativePath) try moveDownloadedFile( from: downloadedFileURL, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index 344d52c76..5a947d6eb 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -7,21 +7,6 @@ import Foundation // MARK: - Validation & Sanitization extension DownloadManager { - func temporaryCompletedPageCount( - gid: String, - expectedPageCount: Int - ) -> Int { - let folderURL = storage.temporaryFolderURL(gid: gid) - guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { - return 0 - } - return storage.existingPageRelativePaths( - folderURL: folderURL, - expectedPageCount: expectedPageCount - ) - .count - } - func validatedCompletedPageCount( _ download: DownloadedGallery ) -> Int { @@ -55,14 +40,10 @@ extension DownloadManager { guard let download = await fetchDownload(gid: gid) else { return nil } - let (hasTemporaryFolder, temporaryCompletedCount) = - scanTemporaryFolder(gid: gid, download: download) scanCompletedFolder(download: download) let updateResult = computeSanitizeUpdate( download: download, - hasTemporaryFolder: hasTemporaryFolder, - temporaryCompletedCount: temporaryCompletedCount, clearingLastError: clearingLastError ) @@ -77,28 +58,6 @@ extension DownloadManager { return await fetchDownload(gid: gid) } - private func scanTemporaryFolder( - gid: String, - download: DownloadedGallery - ) -> (hasTemporaryFolder: Bool, temporaryCompletedCount: Int) { - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let hasTemporaryFolder = fileManager.operate { - $0.fileExists(atPath: temporaryFolderURL.path) - } - let temporaryCompletedCount = hasTemporaryFolder - ? storage.existingPageRelativePaths( - folderURL: temporaryFolderURL, - expectedPageCount: download.pageCount - ).count - : 0 - if hasTemporaryFolder { - _ = storage.existingCoverRelativePath( - folderURL: temporaryFolderURL - ) - } - return (hasTemporaryFolder, temporaryCompletedCount) - } - private func scanCompletedFolder(download: DownloadedGallery) { let completedFolderURL = download .resolvedFolderURL(rootURL: storage.rootURL) @@ -130,8 +89,6 @@ extension DownloadManager { private func computeSanitizeUpdate( download: DownloadedGallery, - hasTemporaryFolder: Bool, - temporaryCompletedCount: Int, clearingLastError: Bool ) -> SanitizeUpdateResult { var state = MutableSanitizeState( @@ -140,12 +97,6 @@ extension DownloadManager { lastError: download.lastError, needsUpdate: false ) - applyTemporaryFolderUpdate( - download: download, - hasTemporaryFolder: hasTemporaryFolder, - temporaryCompletedCount: temporaryCompletedCount, - state: &state - ) applyCompletedStatusUpdate( download: download, clearingLastError: clearingLastError, @@ -159,26 +110,6 @@ extension DownloadManager { ) } - private func applyTemporaryFolderUpdate( - download: DownloadedGallery, - hasTemporaryFolder: Bool, - temporaryCompletedCount: Int, - state: inout MutableSanitizeState - ) { - guard hasTemporaryFolder, - shouldExposeTemporaryWorkingSet(for: download) - else { return } - - if state.completedPageCount != temporaryCompletedCount { - state.completedPageCount = temporaryCompletedCount - state.needsUpdate = true - } - if download.status == .failed { - state.status = .partial - state.needsUpdate = true - } - } - private func applyCompletedStatusUpdate( download: DownloadedGallery, clearingLastError: Bool, @@ -236,33 +167,6 @@ extension DownloadManager { for download: DownloadedGallery, index: Int ) -> CaptureTargetResult? { - let temporaryFolderURL = storage - .temporaryFolderURL(gid: download.gid) - if shouldExposeTemporaryWorkingSet(for: download), - fileManager.operate({ - $0.fileExists(atPath: temporaryFolderURL.path) - }) { - let temporaryPages = - storage.existingPageRelativePaths( - folderURL: temporaryFolderURL, - expectedPageCount: download.pageCount - ) - let manifestRelativePath = (try? storage - .readManifest( - folderURL: temporaryFolderURL - ))? - .pages - .first(where: { $0.index == index })? - .relativePath - let preferredRelativePath = temporaryPages[index] - ?? manifestRelativePath - return CaptureTargetResult( - folderURL: temporaryFolderURL, - preferredRelativePath: preferredRelativePath, - isTemporary: true - ) - } - let completedFolderURL = download .resolvedFolderURL(rootURL: storage.rootURL) guard fileManager.operate({ @@ -286,8 +190,7 @@ extension DownloadManager { ?? manifestRelativePath return CaptureTargetResult( folderURL: completedFolderURL, - preferredRelativePath: preferredRelativePath, - isTemporary: false + preferredRelativePath: preferredRelativePath ) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index e978c3e70..63ca164af 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -25,29 +25,12 @@ extension DownloadManager { func activeInspectionFolderURL( for download: DownloadedGallery ) -> URL? { - let temporaryFolderURL = storage - .temporaryFolderURL(gid: download.gid) let completedFolderURL = download .resolvedFolderURL(rootURL: storage.rootURL) - let temporaryFolderExists = fileManager.operate { - $0.fileExists(atPath: temporaryFolderURL.path) - } let completedFolderExists = fileManager.operate { $0.fileExists(atPath: completedFolderURL.path) } - - if shouldExposeTemporaryWorkingSet(for: download) { - return temporaryFolderExists - ? temporaryFolderURL - : completedFolderURL - } - if completedFolderExists { - return completedFolderURL - } - if temporaryFolderExists { - return temporaryFolderURL - } - return nil + return completedFolderExists ? completedFolderURL : nil } func sanitizedFailedPages( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 546143f90..5b31529c6 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -173,7 +173,6 @@ extension DownloadManager { return .failure(.notFound) } do { - try? storage.removeTemporaryFolder(gid: gid) try storage.removeFolder(relativePath: download.folderRelativePath) await notifyObservers() await scheduleNextIfNeeded() @@ -265,11 +264,6 @@ extension DownloadManager { captureTarget.preferredRelativePath ?? existingPages[index], overwriteExistingFile: true ) else { return } - if captureTarget.isTemporary { - try clearFailedPage( - index: index, folderURL: captureTarget.folderURL - ) - } _ = try? storage.refreshManifestPageFileHash( folderURL: captureTarget.folderURL, pageIndex: index, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index 9524ded6c..c73899530 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -67,30 +67,10 @@ extension DownloadManager { } } - func buildTemporaryPageURLs( - hasTemporaryFolder: Bool, - temporaryFolderURL: URL, - download: DownloadedGallery - ) -> [Int: URL] { - let temporaryPageRelativePaths = hasTemporaryFolder - ? storage.existingPageRelativePaths( - folderURL: temporaryFolderURL, - expectedPageCount: download.pageCount - ) - : [:] - return temporaryPageRelativePaths - .reduce(into: [Int: URL]()) { result, entry in - result[entry.key] = temporaryFolderURL - .appendingPathComponent(entry.value) - } - } - func resolveLocalPageURLs( completedValidation: DownloadValidationState, completedFolderURL: URL?, - completedPageURLs: [Int: URL], - temporaryPageURLs: [Int: URL], - shouldExposeTemp: Bool + completedPageURLs: [Int: URL] ) -> Result<[Int: URL], AppError> { if completedValidation == .valid, let completedFolderURL, @@ -98,36 +78,11 @@ extension DownloadManager { let manifest = try? storage.readManifest( folderURL: completedFolderURL ) { - let completedManifestPageURLs = manifest + return .success(manifest .imageURLs(folderURL: completedFolderURL) - guard shouldExposeTemp else { - return .success(completedManifestPageURLs) - } - return .success( - completedManifestPageURLs.merging( - temporaryPageURLs, - uniquingKeysWith: { _, temporary in temporary } - ) ) } - guard shouldExposeTemp else { - return .success(completedPageURLs) - } - - if !completedPageURLs.isEmpty, !temporaryPageURLs.isEmpty { - return .success( - completedPageURLs.merging( - temporaryPageURLs, - uniquingKeysWith: { _, temporary in temporary } - ) - ) - } - - if !temporaryPageURLs.isEmpty { - return .success(temporaryPageURLs) - } - return .success(completedPageURLs) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index 45920f9b3..e0b292933 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -112,30 +112,17 @@ extension DownloadManager { let completedFolderURL = download .resolvedFolderURL(rootURL: storage.rootURL) - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - let hasTemporaryFolder = fileManager.operate { - $0.fileExists(atPath: temporaryFolderURL.path) - } - let shouldExposeTemp = hasTemporaryFolder - && self.shouldExposeTemporaryWorkingSet(for: download) let completedValidation = storage.validate(download: download) let completedPageURLs = buildCompletedPageURLs( completedFolderURL: completedFolderURL, download: download ) - let temporaryPageURLs = buildTemporaryPageURLs( - hasTemporaryFolder: hasTemporaryFolder, - temporaryFolderURL: temporaryFolderURL, - download: download - ) return resolveLocalPageURLs( completedValidation: completedValidation, completedFolderURL: completedFolderURL, - completedPageURLs: completedPageURLs, - temporaryPageURLs: temporaryPageURLs, - shouldExposeTemp: shouldExposeTemp + completedPageURLs: completedPageURLs ) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 9e8815942..64f144e27 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -122,18 +122,8 @@ extension DownloadManager { await normalizeNeedsAttentionDownloads(downloads) await normalizeInterruptedDownloads(downloads) - let normalizedDownloads = await fetchDownloadsFromStore() do { try storage.ensureRootDirectory() - try storage.cleanupTemporaryFolders( - preservingGIDs: Set( - normalizedDownloads.compactMap { download in - download.shouldPreserveTemporaryWorkingSet - ? download.gid - : nil - } - ) - ) } catch { Logger.error(error) } diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index 395f24a33..63967a13a 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -6,20 +6,6 @@ import Foundation extension DownloadFileStorage { - func replaceFolder(relativePath: String, with temporaryFolderURL: URL) throws { - let targetURL = folderURL(relativePath: relativePath) - try fileManager.operate { - if $0.fileExists(atPath: targetURL.path) { - _ = try $0.replaceItemAt( - targetURL, - withItemAt: temporaryFolderURL - ) - } else { - try $0.moveItem(at: temporaryFolderURL, to: targetURL) - } - } - } - func linkOrCopyReadableAsset(at sourceURL: URL, to destinationURL: URL) throws { guard sanitizeAssetFileIfNeeded(at: sourceURL) else { throw AppError.fileOperationFailed( @@ -51,12 +37,12 @@ extension DownloadFileStorage { func materializeRepairSeed( from sourceFolderURL: URL, manifest: DownloadManifest, - to temporaryFolderURL: URL + to destinationFolderURL: URL ) throws { try fileManager.operate { - try $0.createDirectory(at: temporaryFolderURL, withIntermediateDirectories: true) + try $0.createDirectory(at: destinationFolderURL, withIntermediateDirectories: true) try $0.createDirectory( - at: temporaryFolderURL.appendingPathComponent( + at: destinationFolderURL.appendingPathComponent( Defaults.FilePath.downloadPages, isDirectory: true ), @@ -66,12 +52,12 @@ extension DownloadFileStorage { try linkOrCopyReadableAsset( at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) + to: destinationFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) ) if let coverRelativePath = existingCoverRelativePath(folderURL: sourceFolderURL), let sourceCoverURL = validatedChildURL(root: sourceFolderURL, relativePath: coverRelativePath), - let destCoverURL = validatedChildURL(root: temporaryFolderURL, relativePath: coverRelativePath) { + let destCoverURL = validatedChildURL(root: destinationFolderURL, relativePath: coverRelativePath) { if sanitizeAssetFileIfNeeded(at: sourceCoverURL) { try linkOrCopyReadableAsset(at: sourceCoverURL, to: destCoverURL) } @@ -79,7 +65,7 @@ extension DownloadFileStorage { for page in manifest.pages { guard let sourcePageURL = validatedChildURL(root: sourceFolderURL, relativePath: page.relativePath), - let destPageURL = validatedChildURL(root: temporaryFolderURL, relativePath: page.relativePath) + let destPageURL = validatedChildURL(root: destinationFolderURL, relativePath: page.relativePath) else { continue } guard sanitizeAssetFileIfNeeded(at: sourcePageURL) else { continue } try linkOrCopyReadableAsset(at: sourcePageURL, to: destPageURL) @@ -188,25 +174,6 @@ extension DownloadFileStorage { } } - func cleanupTemporaryFolders(preservingGIDs: Set = []) throws { - let urls = try fileManager.operate { - guard $0.fileExists(atPath: rootURL.path) else { return [URL]() } - return try $0.contentsOfDirectory( - at: rootURL, - includingPropertiesForKeys: nil - ) - } - for url in urls where url.lastPathComponent.hasPrefix(".tmp-") { - let gid = String(url.lastPathComponent.dropFirst(".tmp-".count)) - if preservingGIDs.contains(gid) { - continue - } - try? fileManager.operate { - try $0.removeItem(at: url) - } - } - } - func validate(download: DownloadedGallery) -> DownloadValidationState { let folderURL = download.resolvedFolderURL(rootURL: rootURL) guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index fa2b0ea74..250063b7d 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -65,18 +65,6 @@ struct DownloadFileStorage: Sendable { rootURL.appendingPathComponent(".queue.json") } - func temporaryFolderURL(gid: String) -> URL { - rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) - } - - func removeTemporaryFolder(gid: String) throws { - let targetURL = temporaryFolderURL(gid: gid) - try fileManager.operate { - guard $0.fileExists(atPath: targetURL.path) else { return } - try $0.removeItem(at: targetURL) - } - } - func failedPagesURL(folderURL: URL) -> URL { folderURL.appendingPathComponent(Defaults.FilePath.downloadFailedPages) } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index ed7f0f21e..e5e1c511f 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -48,32 +48,8 @@ extension DownloadedGallery { .map { folderURL.appendingPathComponent($0) } } - func resolvedTemporaryCoverURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL? { - guard shouldPreserveTemporaryWorkingSet else { - return nil - } - - let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) - guard FileManager.default.fileExists(atPath: temporaryFolderURL.path) else { - return nil - } - - if let coverRelativePath, - !coverRelativePath.isEmpty { - let coverURL = temporaryFolderURL.appendingPathComponent(coverRelativePath) - if isReadableLocalAssetFile(coverURL) { - return coverURL - } - } - - return DownloadFileStorage(rootURL: rootURL) - .existingCoverRelativePath(folderURL: temporaryFolderURL) - .map { temporaryFolderURL.appendingPathComponent($0) } - } - func resolvedCoverURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL? { resolvedLocalCoverURL(rootURL: rootURL) - ?? resolvedTemporaryCoverURL(rootURL: rootURL) ?? onlineCoverURL } @@ -196,11 +172,6 @@ extension DownloadedGallery { canPauseOrResume || isPendingQueue } - var shouldPreserveTemporaryWorkingSet: Bool { - pendingOperation != nil - || [.queued, .downloading, .paused, .partial].contains(status) - } - var isPendingQueue: Bool { badge == .queued } diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index ccd76650d..2cca8532b 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -90,41 +90,28 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { } @Test - func testInProgressDownloadPrefersTemporaryCoverURL() throws { + func testInProgressDownloadUsesFinalCoverURL() throws { let gid = "811" let download = sampleDownload( gid: gid, - title: "Temporary Cover Archive", + title: "Local Cover Archive", status: .downloading, completedPageCount: 3 ) let rootURL = FileUtil.downloadsDirectoryURL - - let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) - try? FileManager.default.removeItem(at: temporaryFolderURL) - defer { try? FileManager.default.removeItem(at: temporaryFolderURL) } - - try FileManager.default.createDirectory( - at: temporaryFolderURL, - withIntermediateDirectories: true + let folderURL = rootURL.appendingPathComponent( + "\(gid) - Local Cover Archive", + isDirectory: true ) - let temporaryCoverURL = temporaryFolderURL.appendingPathComponent("cover.jpg") - try Data([0xFF, 0xD8, 0xFF]).write(to: temporaryCoverURL, options: .atomic) - - #expect(download.resolvedCoverURL(rootURL: rootURL) == temporaryCoverURL) - } + try? FileManager.default.removeItem(at: folderURL) + defer { try? FileManager.default.removeItem(at: folderURL) } - @Test - func testQueuedDownloadPreservesTemporaryWorkingSet() { - let queuedDownload = sampleDownload( - gid: "809", - title: "Queued Archive", - status: .queued, - completedPageCount: 3 - ) + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + let coverURL = folderURL.appendingPathComponent("cover.jpg") + try Data([0xFF, 0xD8, 0xFF]).write(to: coverURL, options: .atomic) - #expect(queuedDownload.shouldPreserveTemporaryWorkingSet) + #expect(download.resolvedCoverURL(rootURL: rootURL) == coverURL) } @Test diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift deleted file mode 100644 index 663cf19eb..000000000 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// DownloadFeatureTestTemporaryStorage.swift -// EhPandaTests -// - -import Foundation -import Testing -@testable import EhPanda - -// MARK: - Temporary Storage Helpers - -extension DownloadFeatureTestCase { - func writeTemporaryManifestAndPages( - storage: DownloadFileStorage, gid: String, - manifest: DownloadManifest, pageCount: Int, - omittingPage pageToOmit: Int? = nil, - versionSignature _: String, - mode _: DownloadStartMode = .redownload, - pageSelection _: [Int]? = nil - ) throws { - let folderURL = storage.temporaryFolderURL(gid: gid) - try? FileManager.default.removeItem(at: folderURL) - try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, isDirectory: true - ), - withIntermediateDirectories: true - ) - try JSONEncoder().encode(manifest).write( - to: folderURL.appendingPathComponent( - Defaults.FilePath.downloadManifest - ), - options: .atomic - ) - try Data([0x00]).write( - to: folderURL.appendingPathComponent("cover.jpg"), - options: .atomic - ) - for index in 1...max(1, pageCount) where index != pageToOmit && pageCount > 0 { - try Data([UInt8(index % 255)]).write( - to: folderURL.appendingPathComponent( - "pages/\(String(format: "%04d", index)).jpg" - ), - options: .atomic - ) - } - } -} diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift index a79814091..a19ad9ea0 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift @@ -15,17 +15,17 @@ struct DownloadFileStorageRepairTests { try storage.ensureRootDirectory() let sourceFolderURL = storage.folderURL(relativePath: "123 - Source") - let tempFolderURL = storage.temporaryFolderURL(gid: "123") + let destinationFolderURL = storage.folderURL(relativePath: "[123_token] Destination") let manifest = try sampleManifest(pageCount: 3) try setupRepairSourceFiles( sourceFolderURL: sourceFolderURL, storage: storage, manifest: manifest ) try storage.materializeRepairSeed( - from: sourceFolderURL, manifest: manifest, to: tempFolderURL + from: sourceFolderURL, manifest: manifest, to: destinationFolderURL ) - verifyRepairSeedResult(tempFolderURL: tempFolderURL) + verifyRepairSeedResult(destinationFolderURL: destinationFolderURL) } @Test @@ -44,14 +44,14 @@ struct DownloadFileStorageRepairTests { ) try env.destStorage.materializeRepairSeed( - from: env.sourceFolderURL, manifest: env.manifest, to: env.tempFolderURL + from: env.sourceFolderURL, manifest: env.manifest, to: env.destinationFolderURL ) #expect(FileManager.default.fileExists( - atPath: env.tempFolderURL.appendingPathComponent("pages/0001.jpg").path + atPath: env.destinationFolderURL.appendingPathComponent("pages/0001.jpg").path )) #expect(FileManager.default.fileExists( - atPath: env.tempFolderURL.appendingPathComponent("../escape.jpg").standardizedFileURL.path + atPath: env.destinationFolderURL.appendingPathComponent("../escape.jpg").standardizedFileURL.path ) == false) #expect(FileManager.default.fileExists( atPath: destRootURL.appendingPathComponent("escape.jpg").path @@ -89,7 +89,7 @@ private struct TraversalTestEnvironment { let sourceStorage: DownloadFileStorage let destStorage: DownloadFileStorage let sourceFolderURL: URL - let tempFolderURL: URL + let destinationFolderURL: URL let manifest: DownloadManifest } @@ -102,7 +102,7 @@ private extension DownloadFileStorageRepairTests { try sourceStorage.ensureRootDirectory() try destStorage.ensureRootDirectory() let sourceFolderURL = sourceStorage.folderURL(relativePath: "123 - Source") - let tempFolderURL = destStorage.temporaryFolderURL(gid: "123") + let destinationFolderURL = destStorage.folderURL(relativePath: "[123_token] Destination") try FileManager.default.createDirectory( at: sourceFolderURL.appendingPathComponent( Defaults.FilePath.downloadPages, isDirectory: true @@ -130,7 +130,7 @@ private extension DownloadFileStorageRepairTests { try Data([0x99]).write(to: escapeURL, options: .atomic) return TraversalTestEnvironment( sourceStorage: sourceStorage, destStorage: destStorage, - sourceFolderURL: sourceFolderURL, tempFolderURL: tempFolderURL, manifest: manifest + sourceFolderURL: sourceFolderURL, destinationFolderURL: destinationFolderURL, manifest: manifest ) } @@ -162,24 +162,24 @@ private extension DownloadFileStorageRepairTests { ) } - func verifyRepairSeedResult(tempFolderURL: URL) { + func verifyRepairSeedResult(destinationFolderURL: URL) { #expect(FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest).path + atPath: destinationFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest).path )) #expect(FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent("cover.jpg").path + atPath: destinationFolderURL.appendingPathComponent("cover.jpg").path )) #expect(FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent("pages/0001.jpg").path + atPath: destinationFolderURL.appendingPathComponent("pages/0001.jpg").path )) #expect(FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent("pages/0002.jpg").path + atPath: destinationFolderURL.appendingPathComponent("pages/0002.jpg").path ) == false) #expect(FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent("pages/0003.jpg").path + atPath: destinationFolderURL.appendingPathComponent("pages/0003.jpg").path )) #expect(FileManager.default.fileExists( - atPath: tempFolderURL.appendingPathComponent("nested/ignored.bin").path + atPath: destinationFolderURL.appendingPathComponent("nested/ignored.bin").path ) == false) } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift index 6f4a4b304..789daaccc 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift @@ -14,7 +14,7 @@ struct DownloadFileStorageStateTests { defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let folderURL = storage.temporaryFolderURL(gid: "123") + let folderURL = storage.folderURL(relativePath: "[123_token] Sample") try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) let snapshot = DownloadFailedPagesSnapshot( diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index ce5d1d573..011de6ef1 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -119,47 +119,13 @@ struct DownloadFileStorageTests { ) } - @Test - func testCleanupTemporaryFoldersRemovesOnlyTemporaryArtifacts() throws { - let (storage, rootURL) = makeStorage() - defer { try? FileManager.default.removeItem(at: rootURL) } - - try storage.ensureRootDirectory() - let temporaryURL = storage.temporaryFolderURL(gid: "123") - let regularURL = storage.folderURL(relativePath: "123 - Sample") - try FileManager.default.createDirectory(at: temporaryURL, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: regularURL, withIntermediateDirectories: true) - - try storage.cleanupTemporaryFolders() - - #expect(FileManager.default.fileExists(atPath: temporaryURL.path) == false) - #expect(FileManager.default.fileExists(atPath: regularURL.path)) - } - - @Test - func testCleanupTemporaryFoldersPreservesSpecifiedGalleryFolders() throws { - let (storage, rootURL) = makeStorage() - defer { try? FileManager.default.removeItem(at: rootURL) } - - try storage.ensureRootDirectory() - let preservedURL = storage.temporaryFolderURL(gid: "123") - let removedURL = storage.temporaryFolderURL(gid: "456") - try FileManager.default.createDirectory(at: preservedURL, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: removedURL, withIntermediateDirectories: true) - - try storage.cleanupTemporaryFolders(preservingGIDs: ["123"]) - - #expect(FileManager.default.fileExists(atPath: preservedURL.path)) - #expect(FileManager.default.fileExists(atPath: removedURL.path) == false) - } - @Test func testExistingPageRelativePathsDetectsCompletedPages() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let folderURL = storage.temporaryFolderURL(gid: "123") + let folderURL = storage.folderURL(relativePath: "[123_token] Sample") let pagesURL = folderURL.appendingPathComponent( Defaults.FilePath.downloadPages, isDirectory: true @@ -227,7 +193,7 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let folderURL = storage.temporaryFolderURL(gid: "123") + let folderURL = storage.folderURL(relativePath: "[123_token] Sample") let pagesURL = folderURL.appendingPathComponent( Defaults.FilePath.downloadPages, isDirectory: true @@ -348,7 +314,7 @@ struct DownloadFileStorageTests { try storage.ensureRootDirectory() let downloadFolderURL = storage.folderURL(relativePath: "[123_token] Sample") let ignoredFolderURL = storage.folderURL(relativePath: "[456_token] Missing manifest") - let hiddenFolderURL = storage.folderURL(relativePath: ".tmp-789") + let hiddenFolderURL = storage.folderURL(relativePath: "[789_token] Missing manifest") try FileManager.default.createDirectory(at: downloadFolderURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: ignoredFolderURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: hiddenFolderURL, withIntermediateDirectories: true) diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index a22da1410..65a6475af 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -12,7 +12,7 @@ import Testing @Suite(.serialized) struct DownloadManagerCaptureTests: DownloadFeatureTestCase { @Test - func testDownloadManagerCaptureCachedPageRestoresTemporaryPage() async throws { + func testDownloadManagerCaptureCachedPageRestoresFinalPage() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 27) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -38,19 +38,6 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { folderURL: completedFolderURL ) - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - try storage.writeManifest( - sampleManifest( - gid: gid, - title: "Capture", - pageCount: 2 - ), - folderURL: temporaryFolderURL - ) let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in UIColor.systemBlue.setFill() @@ -71,7 +58,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { ) let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - #expect(pageURLs[1] == temporaryFolderURL.appendingPathComponent("pages/0001.jpg")) + #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("pages/0001.jpg")) } @MainActor diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 2bc02053f..f39ec2319 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -772,7 +772,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerLoadInspectionUsesTemporaryFailedPagesSnapshot() async throws { + func testDownloadManagerLoadInspectionUsesFinalFailedPagesSnapshot() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000)) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -785,27 +785,27 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) try storage.ensureRootDirectory() + let folderRelativePath = "[\(gid)_token] Inspect" try writeIndexedManifest( storage: storage, - relativePath: "[\(gid)_token] Inspect", + relativePath: folderRelativePath, manifest: indexedManifest( gid: gid, title: "Inspect", pageHashes: ["sha256:done", ""] ) ) - - let temporaryFolderURL = rootURL.appendingPathComponent(".tmp-\(gid)", isDirectory: true) + let folderURL = storage.folderURL(relativePath: folderRelativePath) try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) try Data([0x01]).write( - to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), + to: folderURL.appendingPathComponent("pages/0001.jpg"), options: .atomic ) - try JSONEncoder().encode( - DownloadFailedPagesSnapshot( + try storage.writeFailedPages( + .init( pages: [ .init( index: 2, @@ -813,11 +813,8 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { failure: .init(code: .networkingFailed, message: "Network Error") ) ] - ) - ) - .write( - to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadFailedPages), - options: .atomic + ), + folderURL: folderURL ) let result = await manager.loadInspection(gid: gid) @@ -866,23 +863,14 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { options: .atomic ) - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let temporaryPageURL = temporaryFolderURL.appendingPathComponent("pages/0001.jpg") - try Data([0x02]).write(to: temporaryPageURL, options: .atomic) - let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() #expect(pageURLs[1] == completedPageURL) - #expect(pageURLs[1] != temporaryPageURL) #expect(pageURLs[3] == nil) } @Test - func testDownloadManagerLoadLocalPageURLsMergesReadableCompletedPagesWithTemporaryPages() async throws { + func testDownloadManagerLoadLocalPageURLsUsesReadableCompletedPages() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 12) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -917,18 +905,10 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { options: .atomic ) - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) - let temporaryPageURL = temporaryFolderURL.appendingPathComponent("pages/0002.jpg") - try Data([0x02]).write(to: temporaryPageURL, options: .atomic) - let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("pages/0001.jpg")) - #expect(pageURLs[2] == temporaryPageURL) + #expect(pageURLs[2] == completedFolderURL.appendingPathComponent("pages/0002.jpg")) } } diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index c937e2139..9bb518beb 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -89,17 +89,17 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { pageHashes: ["sha256:done", ""] ) - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Pausable") try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) try Data([0x01]).write( - to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), + to: folderURL.appendingPathComponent("pages/0001.jpg"), options: .atomic ) try Data([0x02]).write( - to: temporaryFolderURL.appendingPathComponent("pages/0002.jpg"), + to: folderURL.appendingPathComponent("pages/0002.jpg"), options: .atomic ) @@ -123,7 +123,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { #expect(stored?.status == .paused) #expect(stored?.completedPageCount == 1) #expect(stored?.badge == .paused(1, 2)) - #expect(FileManager.default.fileExists(atPath: temporaryFolderURL.path)) + #expect(FileManager.default.fileExists(atPath: folderURL.path)) } @Test @@ -215,7 +215,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { title: "Inspection", pageHashes: ["sha256:done", ""] ) - let temporaryFolderURL = try setupCancellationFilterTestFolder(storage: storage, gid: gid) + let folderURL = try setupCancellationFilterTestFolder(storage: storage, gid: gid) let result = await manager.loadInspection(gid: gid) guard case .success(let inspection) = result else { @@ -225,7 +225,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { #expect(inspection.pages[0].status == .downloaded) #expect(inspection.pages[1].status == .pending) - #expect((try? storage.readFailedPages(folderURL: temporaryFolderURL).pages.isEmpty) ?? true) + #expect((try? storage.readFailedPages(folderURL: folderURL).pages.isEmpty) ?? true) } } @@ -274,13 +274,13 @@ private extension DownloadPauseAndReconcileTests { storage: DownloadFileStorage, gid: String ) throws -> URL { - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) + let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Inspection") try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) try Data([0x01]).write( - to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), + to: folderURL.appendingPathComponent("pages/0001.jpg"), options: .atomic ) try storage.writeFailedPages( @@ -294,8 +294,8 @@ private extension DownloadPauseAndReconcileTests { ) ) ]), - folderURL: temporaryFolderURL + folderURL: folderURL ) - return temporaryFolderURL + return folderURL } } diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 9f1e71795..ac59c63d8 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -248,11 +248,6 @@ private extension DownloadProcessCacheTests { oldPageCount: oldPageCount, oldVersionSignature: setup.oldVersionSignature ) - try setupCacheTestTemporaryFolder( - storage: setup.storage, gid: setup.gid, - pageIndex: setup.pageIndex, oldPageCount: oldPageCount, - oldVersionSignature: setup.oldVersionSignature - ) return updatedPageCount } @@ -274,36 +269,6 @@ private extension DownloadProcessCacheTests { try storage.writeManifest(staleManifest, folderURL: completedFolderURL) } - func setupCacheTestTemporaryFolder( - storage: DownloadFileStorage, gid: String, - pageIndex: Int, oldPageCount: Int, oldVersionSignature: String - ) throws { - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) - try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, isDirectory: true - ), - withIntermediateDirectories: true - ) - let staleManifest = try sampleManifest( - gid: gid, title: "Pause Race", - pageCount: oldPageCount, versionSignature: oldVersionSignature - ) - try JSONEncoder().encode(staleManifest).write( - to: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), - options: .atomic - ) - try Data([0x00]).write( - to: temporaryFolderURL.appendingPathComponent("cover.jpg"), options: .atomic - ) - try Data([UInt8(pageIndex % 255)]).write( - to: temporaryFolderURL.appendingPathComponent( - "pages/\(String(format: "%04d", pageIndex)).jpg" - ), - options: .atomic - ) - } - func waitUntilCacheCleared(cachedKeys: Set) async throws { let clock = ContinuousClock() let deadline = clock.now.advanced(by: .seconds(1)) diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 39c67837c..6b5fbfdda 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -265,10 +265,6 @@ private extension DownloadProcessTests { ) #expect(FileManager.default.fileExists(atPath: context.staleFolderURL.path) == false) - #expect( - FileManager.default.fileExists( - atPath: storage.temporaryFolderURL(gid: context.gid).path - ) == false - ) + #expect(FileManager.default.fileExists(atPath: completedFolderURL.path)) } } diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index f2a921c8b..6e61b04aa 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -76,13 +76,11 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { manager: immediateManager, sessionID: sessionID, gid: gid, pageIndex: pageIndex, oldVersionSignature: oldVersionSignature ) - let updatedVersionSignature = updateResult.versionSignature let pageCount = updateResult.pageCount try setupImmediateUpdateTestState( storage: storage, - context: DownloadPageContext(gid: gid, pageIndex: pageIndex, pageCount: pageCount), - updatedVersionSignature: updatedVersionSignature + context: DownloadPageContext(gid: gid, pageIndex: pageIndex, pageCount: pageCount) ) await immediateManager.testingSetUpdatedGalleryIDs([gid]) @@ -155,19 +153,9 @@ private extension DownloadRetryUpdateFallbackTests { func setupImmediateUpdateTestState( storage: DownloadFileStorage, - context: DownloadPageContext, updatedVersionSignature: String + context: DownloadPageContext ) throws { let oldCount = context.pageCount - 5 - let manifest = try sampleManifest( - gid: context.gid, title: "Pause Race", - pageCount: context.pageCount, versionSignature: updatedVersionSignature - ) - try writeTemporaryManifestAndPages( - storage: storage, gid: context.gid, manifest: manifest, - pageCount: context.pageCount, omittingPage: context.pageIndex, - versionSignature: updatedVersionSignature, - mode: .update, pageSelection: [context.pageIndex] - ) try writeFinalManifest( storage: storage, gid: context.gid, diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 27f68c1fd..ff52d77c8 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -10,7 +10,7 @@ import Testing @Suite(.serialized) struct DownloadVersionSignatureTests: DownloadFeatureTestCase { @Test - func testDownloadManagerReconcilePreservesIndexedTemporaryFolder() async throws { + func testDownloadManagerReconcilePreservesIndexedFinalFolder() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 31) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -29,13 +29,13 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { folderURL: folderURL ) - let temporaryFolderURL = storage.temporaryFolderURL(gid: gid) try FileManager.default.createDirectory( - at: temporaryFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) + let pageURL = folderURL.appendingPathComponent("pages/0001.jpg") try Data([0x01]).write( - to: temporaryFolderURL.appendingPathComponent("pages/0001.jpg"), + to: pageURL, options: .atomic ) @@ -46,8 +46,8 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { #expect(stored?.status == .paused) #expect(stored?.completedPageCount == 0) - #expect(FileManager.default.fileExists(atPath: temporaryFolderURL.path)) - #expect(localPages[1] == temporaryFolderURL.appendingPathComponent("pages/0001.jpg")) + #expect(FileManager.default.fileExists(atPath: folderURL.path)) + #expect(localPages[1] == pageURL) } @MainActor From 6ba8cec418cafad68d2bba45dc51720dddb1c600 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 06:29:31 +0800 Subject: [PATCH 121/614] Store manifest hashes --- .../DownloadClient+ExecutionPerform.swift | 14 +- .../Clients/DownloadClient+PageDownload.swift | 17 +- .../DownloadClient+PersistenceHelpers.swift | 9 +- .../DownloadClient+PersistenceNormalize.swift | 25 --- .../Clients/DownloadClient+PublicAPI.swift | 14 +- .../DownloadFileStorage+Operations.swift | 153 ++++++++++-------- .../DownloadedGallery+Manifest.swift | 33 ++-- .../DownloadEnqueueManifestTests.swift | 2 +- .../DownloadFeatureTestFactories.swift | 6 +- .../DownloadFileStorageHashTests.swift | 10 +- .../DownloadFileStorageRepairTests.swift | 10 +- .../Download/DownloadFileStorageTests.swift | 41 +++-- .../DownloadManagerCaptureTests.swift | 23 +-- .../DownloadManagerStorageTests.swift | 27 +--- .../DownloadPauseAndReconcileTests.swift | 11 +- .../Download/DownloadRetryPagesTests.swift | 11 +- .../DownloadRetryUpdateFallbackTests.swift | 11 +- .../Download/DownloadSchedulingTests.swift | 8 +- .../DownloadVersionSignatureTests.swift | 8 +- .../DownloadedGalleryManifestModelTests.swift | 11 +- 20 files changed, 196 insertions(+), 248 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 80dd9d3c9..feb2525b8 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -198,14 +198,12 @@ extension DownloadManager { tags: payload.gallery.tags, postedDate: payload.galleryDetail.postedDate, rating: payload.galleryDetail.rating, - pages: batchResult.pages - .sorted(by: { $0.index < $1.index }) - .map { - .init( - index: $0.index, - relativePath: $0.relativePath - ) - } + pages: payload.galleryDetail.pageCount > 0 + ? Dictionary( + uniqueKeysWithValues: + (1...payload.galleryDetail.pageCount).map { ($0, "") } + ) + : [:] ) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index 5b4c2f0da..daf586085 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -129,16 +129,13 @@ extension DownloadManager { existingManifest: DownloadManifest?, existingPageRelativePaths: [Int: String] ) -> [Int: String] { - let manifestPages = Dictionary( - uniqueKeysWithValues: - (existingManifest?.pages ?? []) - .filter { !$0.relativePath.hasSuffix(".pending") } - .map { ($0.index, $0.relativePath) } - ) - return manifestPages.merging( - existingPageRelativePaths, - uniquingKeysWith: { manifestPath, _ in manifestPath } - ) + guard let existingManifest else { + return existingPageRelativePaths + } + let manifestPageIndices = Set(existingManifest.pages.keys) + return existingPageRelativePaths.filter { + manifestPageIndices.contains($0.key) + } } private func collectExistingPages( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index 5a947d6eb..c234c7fa4 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -181,16 +181,9 @@ extension DownloadManager { folderURL: completedFolderURL, expectedPageCount: download.pageCount ) - let manifestRelativePath = (try? storage - .readManifest(folderURL: completedFolderURL))? - .pages - .first(where: { $0.index == index })? - .relativePath - let preferredRelativePath = completedPages[index] - ?? manifestRelativePath return CaptureTargetResult( folderURL: completedFolderURL, - preferredRelativePath: preferredRelativePath + preferredRelativePath: completedPages[index] ) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 63ca164af..3a396ff65 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -125,7 +125,6 @@ extension DownloadManager { let validation = storage.validate(download: download) switch validation { case .valid: - refreshMissingManifestHashesIfNeeded(download: download) validationErrors[download.gid] = nil case .missingFiles(let message): @@ -137,28 +136,4 @@ extension DownloadManager { } return validation } - - private func refreshMissingManifestHashesIfNeeded( - download: DownloadedGallery - ) { - let folderURL = download - .resolvedFolderURL(rootURL: storage.rootURL) - guard let manifest = try? storage.readManifest(folderURL: folderURL), - manifest.needsFileHashRefresh - else { - return - } - - do { - try storage.refreshManifestFileHashes(folderURL: folderURL) - } catch { - Logger.error(error) - } - } -} - -private extension DownloadManifest { - var needsFileHashRefresh: Bool { - pages.contains { $0.fileHash == nil } - } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 5b31529c6..ba5426e2a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -101,18 +101,8 @@ extension DownloadManager { try createDirectory(at: folderURL) let pageCount = payload.galleryDetail.pageCount let pages = pageCount > 0 - ? (1...pageCount).map { index in - DownloadManifest.Page( - index: index, - relativePath: storage.makePageRelativePath( - gid: payload.gallery.gid, - token: payload.gallery.token, - index: index, - fileExtension: "pending" - ) - ) - } - : [] + ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) + : [:] try storage.writeManifest( DownloadManifest( gid: payload.gallery.gid, diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index 63967a13a..efbb1a062 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -63,9 +63,14 @@ extension DownloadFileStorage { } } - for page in manifest.pages { - guard let sourcePageURL = validatedChildURL(root: sourceFolderURL, relativePath: page.relativePath), - let destPageURL = validatedChildURL(root: destinationFolderURL, relativePath: page.relativePath) + let existingPages = existingPageRelativePaths( + folderURL: sourceFolderURL, + expectedPageCount: manifest.pageCount + ) + for index in manifest.pages.keys.sorted() { + guard let relativePath = existingPages[index], + let sourcePageURL = validatedChildURL(root: sourceFolderURL, relativePath: relativePath), + let destPageURL = validatedChildURL(root: destinationFolderURL, relativePath: relativePath) else { continue } guard sanitizeAssetFileIfNeeded(at: sourcePageURL) else { continue } try linkOrCopyReadableAsset(at: sourcePageURL, to: destPageURL) @@ -76,34 +81,25 @@ extension DownloadFileStorage { to manifest: DownloadManifest, folderURL: URL ) throws -> DownloadManifest { - let pages = try manifest.pages.map { page in - DownloadManifest.Page( - index: page.index, - relativePath: page.relativePath, - fileHash: try hashReadableAsset( + let existingPages = existingPageRelativePaths( + folderURL: folderURL, + expectedPageCount: manifest.pageCount + ) + let pages = try manifest.pages.keys.sorted() + .reduce(into: [Int: String]()) { result, index in + guard let relativePath = existingPages[index] else { + throw AppError.fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index) + ) + } + result[index] = try hashReadableAsset( folderURL: folderURL, - relativePath: page.relativePath, - missingMessage: L10n.Localizable.DownloadFileStorage.Validation.pageMissing(page.index) + relativePath: relativePath, + missingMessage: L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index) ) - ) - } - - return manifest.replacing( - pages: pages - ) - } + } - @discardableResult - func refreshManifestFileHashes(folderURL: URL) throws -> DownloadManifest { - let manifest = try readManifest(folderURL: folderURL) - let hashedManifest = try addingCurrentFileHashes( - to: manifest, - folderURL: folderURL - ) - if hashedManifest != manifest { - try writeManifest(hashedManifest, folderURL: folderURL) - } - return hashedManifest + return manifest.replacing(pages: pages) } @discardableResult @@ -112,21 +108,21 @@ extension DownloadFileStorage { pageIndex: Int, relativePath: String? = nil ) throws -> DownloadManifest { + let resolvedRelativePath: String? if let relativePath { - return try refreshManifestPageFileHashes( + resolvedRelativePath = relativePath + } else { + resolvedRelativePath = existingPageRelativePaths( folderURL: folderURL, - pageRelativePaths: [pageIndex: relativePath] - ) + expectedPageCount: (try? readManifest(folderURL: folderURL).pageCount) ?? pageIndex + )[pageIndex] } - let manifest = try readManifest(folderURL: folderURL) - guard let page = manifest.pages.first( - where: { $0.index == pageIndex } - ) else { - return manifest + guard let resolvedRelativePath else { + return try readManifest(folderURL: folderURL) } return try refreshManifestPageFileHashes( folderURL: folderURL, - pageRelativePaths: [pageIndex: page.relativePath] + pageRelativePaths: [pageIndex: resolvedRelativePath] ) } @@ -137,35 +133,44 @@ extension DownloadFileStorage { ) throws -> DownloadManifest { let manifest = try readManifest(folderURL: folderURL) guard !pageRelativePaths.isEmpty else { return manifest } + var pages = manifest.pages var didUpdate = false - let pages = try manifest.pages.map { page in - guard let refreshedRelativePath = - pageRelativePaths[page.index] else { - return page + for index in pageRelativePaths.keys.sorted() { + guard pages[index] != nil, + let refreshedRelativePath = pageRelativePaths[index] + else { + continue } - didUpdate = true - return DownloadManifest.Page( - index: page.index, + pages[index] = try hashReadableAsset( + folderURL: folderURL, relativePath: refreshedRelativePath, - fileHash: try hashReadableAsset( - folderURL: folderURL, - relativePath: refreshedRelativePath, - missingMessage: L10n.Localizable.DownloadFileStorage.Validation.pageMissing(page.index) - ) + missingMessage: L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index) ) + didUpdate = true } guard didUpdate else { return manifest } - let refreshedManifest = manifest.replacing( - pages: pages - ) + let refreshedManifest = manifest.replacing(pages: pages) if refreshedManifest != manifest { try writeManifest(refreshedManifest, folderURL: folderURL) } return refreshedManifest } + @discardableResult + func refreshManifestFileHashes(folderURL: URL) throws -> DownloadManifest { + let manifest = try readManifest(folderURL: folderURL) + let hashedManifest = try addingCurrentFileHashes( + to: manifest, + folderURL: folderURL + ) + if hashedManifest != manifest { + try writeManifest(hashedManifest, folderURL: folderURL) + } + return hashedManifest + } + func removeFolder(relativePath: String) throws { let targetURL = folderURL(relativePath: relativePath) try fileManager.operate { @@ -188,7 +193,7 @@ extension DownloadFileStorage { } if let pageValidationFailure = validatePages( folderURL: folderURL, - pages: manifest.pages + manifest: manifest ) { return pageValidationFailure } @@ -196,8 +201,14 @@ extension DownloadFileStorage { } func validPageCount(folderURL: URL, manifest: DownloadManifest) -> Int { - manifest.pages.reduce(into: 0) { count, page in - guard let pageURL = validatedChildURL(root: folderURL, relativePath: page.relativePath) else { return } + let existingPages = existingPageRelativePaths( + folderURL: folderURL, + expectedPageCount: manifest.pageCount + ) + return manifest.pages.keys.reduce(into: 0) { count, index in + guard let relativePath = existingPages[index], + let pageURL = validatedChildURL(root: folderURL, relativePath: relativePath) + else { return } if sanitizeAssetFileIfNeeded(at: pageURL) { count += 1 } @@ -223,10 +234,19 @@ extension DownloadFileStorage { private func validatePages( folderURL: URL, - pages: [DownloadManifest.Page] + manifest: DownloadManifest ) -> DownloadValidationState? { - for page in pages { - if let validationFailure = validatePage(folderURL: folderURL, page: page) { + let existingPages = existingPageRelativePaths( + folderURL: folderURL, + expectedPageCount: manifest.pageCount + ) + for index in manifest.pages.keys.sorted() { + if let validationFailure = validatePage( + folderURL: folderURL, + index: index, + expectedHash: manifest.pages[index] ?? "", + existingPageRelativePaths: existingPages + ) { return validationFailure } } @@ -235,18 +255,21 @@ extension DownloadFileStorage { private func validatePage( folderURL: URL, - page: DownloadManifest.Page + index: Int, + expectedHash: String, + existingPageRelativePaths: [Int: String] ) -> DownloadValidationState? { - guard let pageURL = validatedChildURL(root: folderURL, relativePath: page.relativePath), + guard !expectedHash.isEmpty, + let relativePath = existingPageRelativePaths[index], + let pageURL = validatedChildURL(root: folderURL, relativePath: relativePath), sanitizeAssetFileIfNeeded(at: pageURL) else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.pageMissing(page.index)) + return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index)) } - if let expectedHash = page.fileHash, - (try? fileHash(at: pageURL)) != expectedHash { + if (try? fileHash(at: pageURL)) != expectedHash { return .missingFiles( - L10n.Localizable.DownloadFileStorage.Validation.pageImageCorrupted(page.index) + L10n.Localizable.DownloadFileStorage.Validation.pageImageCorrupted(index) ) } @@ -256,7 +279,7 @@ extension DownloadFileStorage { private extension DownloadManifest { func replacing( - pages: [Page] + pages: [Int: String] ) -> DownloadManifest { DownloadManifest( gid: gid, diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift index d220f5c4c..6f64885fd 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -6,24 +6,6 @@ import Foundation struct DownloadManifest: Codable, Equatable, Sendable { - struct Page: Codable, Equatable, Identifiable, Sendable { - var id: Int { index } - - let index: Int - let relativePath: String - let fileHash: String? - - init( - index: Int, - relativePath: String, - fileHash: String? = nil - ) { - self.index = index - self.relativePath = relativePath - self.fileHash = fileHash - } - } - let gid: String let host: GalleryHost let token: String @@ -35,12 +17,17 @@ struct DownloadManifest: Codable, Equatable, Sendable { let tags: [GalleryTag] let postedDate: Date let rating: Float - let pages: [Page] + let pages: [Int: String] func imageURLs(folderURL: URL) -> [Int: URL] { - Dictionary(uniqueKeysWithValues: pages.map { - ($0.index, folderURL.appendingPathComponent($0.relativePath)) - }) + DownloadFileStorage(rootURL: folderURL.deletingLastPathComponent()) + .existingPageRelativePaths( + folderURL: folderURL, + expectedPageCount: pageCount + ) + .reduce(into: [Int: URL]()) { result, entry in + result[entry.key] = folderURL.appendingPathComponent(entry.value) + } } } @@ -57,7 +44,7 @@ extension DownloadManifest { } var completedPageCount: Int { - pages.filter { $0.fileHash?.isEmpty == false }.count + pages.values.filter { !$0.isEmpty }.count } var isComplete: Bool { diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index 653a28369..83719dd5e 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -56,7 +56,7 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { #expect(manifest.token == gallery.token) #expect(manifest.pageCount == detail.pageCount) #expect(manifest.pages.count == detail.pageCount) - #expect(manifest.pages.first?.relativePath == "\(gallery.gid)_\(gallery.token)_1.pending") + #expect(manifest.pages[1] == "") let manifestData = try Data( contentsOf: storage diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 8d8dcdfa5..2116aa74e 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -29,9 +29,9 @@ extension DownloadFeatureTestCase { tags: [], postedDate: .now, rating: 4, - pages: (1...pageCount).map { - .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") - } + pages: pageCount > 0 + ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) + : [:] ) } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index a91ef1278..e6e1df5f7 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -49,8 +49,8 @@ struct DownloadFileStorageHashTests { pageIndex: 2 ) - #expect(refreshedManifest.pages[0].fileHash == manifest.pages[0].fileHash) - #expect(refreshedManifest.pages[1].fileHash != manifest.pages[1].fileHash) + #expect(refreshedManifest.pages[1] == manifest.pages[1]) + #expect(refreshedManifest.pages[2] != manifest.pages[2]) #expect(storage.validate(download: download) == .valid) } @@ -128,9 +128,9 @@ struct DownloadFileStorageHashTests { tags: [], postedDate: .now, rating: 4, - pages: (1...pageCount).map { - .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") - } + pages: pageCount > 0 + ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) + : [:] ) } } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift index a19ad9ea0..664cbf16b 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift @@ -115,8 +115,8 @@ private extension DownloadFileStorageRepairTests { postedDate: .now, rating: 4, pages: [ - .init(index: 1, relativePath: "pages/0001.jpg"), - .init(index: 2, relativePath: "../escape.jpg") + 1: "", + 2: "" ] ) try sourceStorage.writeManifest(manifest, folderURL: sourceFolderURL) @@ -205,9 +205,9 @@ private extension DownloadFileStorageRepairTests { tags: [], postedDate: .now, rating: 4, - pages: (1...pageCount).map { - .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") - } + pages: pageCount > 0 + ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) + : [:] ) } } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 011de6ef1..10950cf84 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -21,9 +21,6 @@ struct DownloadFileStorageTests { at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - - let manifest = try sampleManifest(pageCount: 2) - try storage.writeManifest(manifest, folderURL: folderURL) try Data([0xFF, 0xD8, 0xFF]).write( to: folderURL.appendingPathComponent("cover.jpg"), options: .atomic @@ -36,6 +33,11 @@ struct DownloadFileStorageTests { to: folderURL.appendingPathComponent("pages/0002.jpg"), options: .atomic ) + let manifest = try storage.addingCurrentFileHashes( + to: sampleManifest(pageCount: 2), + folderURL: folderURL + ) + try storage.writeManifest(manifest, folderURL: folderURL) let loadedManifest = try storage.readManifest(folderURL: folderURL) @@ -67,14 +69,18 @@ struct DownloadFileStorageTests { at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - try storage.writeManifest(sampleManifest(pageCount: 2), folderURL: folderURL) try Data([0xFF, 0xD8, 0xFF]).write( to: folderURL.appendingPathComponent("cover.jpg"), options: .atomic ) - try Data([0x01]).write( - to: folderURL.appendingPathComponent("pages/0001.jpg"), - options: .atomic + let page1URL = folderURL.appendingPathComponent("pages/0001.jpg") + try Data([0x01]).write(to: page1URL, options: .atomic) + try storage.writeManifest( + sampleManifest(pageHashes: [ + 1: try storage.fileHash(at: page1URL), + 2: "sha256:missing" + ]), + folderURL: folderURL ) #expect( @@ -95,7 +101,6 @@ struct DownloadFileStorageTests { at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), withIntermediateDirectories: true ) - try storage.writeManifest(sampleManifest(pageCount: 2), folderURL: folderURL) try Data([0xFF, 0xD8, 0xFF]).write( to: folderURL.appendingPathComponent("cover.jpg"), options: .atomic @@ -108,6 +113,14 @@ struct DownloadFileStorageTests { to: folderURL.appendingPathComponent("pages/0002.jpg"), options: .atomic ) + let page2URL = folderURL.appendingPathComponent("pages/0002.jpg") + try storage.writeManifest( + sampleManifest(pageHashes: [ + 1: "sha256:missing", + 2: try storage.fileHash(at: page2URL) + ]), + folderURL: folderURL + ) #expect( storage.validate(download: download) == .missingFiles("Page 1 is missing.") @@ -401,6 +414,14 @@ private extension DownloadFileStorageTests { } func sampleManifest(pageCount: Int) throws -> DownloadManifest { + try sampleManifest( + pageHashes: pageCount > 0 + ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) + : [:] + ) + } + + func sampleManifest(pageHashes: [Int: String]) throws -> DownloadManifest { DownloadManifest( gid: "123", host: .ehentai, @@ -413,9 +434,7 @@ private extension DownloadFileStorageTests { tags: [], postedDate: .now, rating: 4, - pages: (1...pageCount).map { - .init(index: $0, relativePath: "pages/\(String(format: "%04d", $0)).jpg") - } + pages: pageHashes ) } } diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index 65a6475af..d74d68b14 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -58,7 +58,16 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { ) let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("pages/0001.jpg")) + #expect( + pageURLs[1] == completedFolderURL.appendingPathComponent( + storage.makePageRelativePath( + gid: gid, + token: "token", + index: 1, + fileExtension: "jpg" + ) + ) + ) } @MainActor @@ -129,16 +138,8 @@ private extension DownloadManagerCaptureTests { postedDate: .now, rating: 4, pages: [ - .init( - index: 1, - relativePath: "\(gid)_token_1.jpg", - fileHash: "sha256:missing" - ), - .init( - index: 2, - relativePath: page2RelativePath, - fileHash: try DownloadFileStorage().fileHash(at: page2URL) - ) + 1: "sha256:missing", + 2: try DownloadFileStorage().fileHash(at: page2URL) ] ) try JSONEncoder().encode(manifest).write( diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index f39ec2319..e243c7968 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -731,11 +731,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { manifest: indexedManifest( gid: "840", title: "Progress", - pageHashes: ["", ""], - pageRelativePaths: [ - "840_token_1.pending", - "840_token_2.pending" - ] + pageHashes: ["", ""] ) ) let folderURL = storage.folderURL(relativePath: folderRelativePath) @@ -764,10 +760,8 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let download = try #require(await manager.fetchDownload(gid: "840")) #expect(pendingResolvedPages.isEmpty) - #expect(manifest.pages[0].relativePath == pageRelativePath) - #expect(manifest.pages[0].fileHash?.hasPrefix("sha256:") == true) - #expect(manifest.pages[1].relativePath == "840_token_2.pending") - #expect(manifest.pages[1].fileHash == "") + #expect(manifest.pages[1]?.hasPrefix("sha256:") == true) + #expect(manifest.pages[2] == "") #expect(download.completedPageCount == 1) } @@ -935,8 +929,7 @@ private extension DownloadManagerStorageTests { gid: String, title: String, pageHashes: [String], - modifiedAt: Date = .now, - pageRelativePaths: [String]? = nil + modifiedAt: Date = .now ) throws -> DownloadManifest { DownloadManifest( gid: gid, @@ -950,14 +943,10 @@ private extension DownloadManagerStorageTests { tags: [], postedDate: modifiedAt, rating: 4, - pages: pageHashes.enumerated().map { offset, hash in - DownloadManifest.Page( - index: offset + 1, - relativePath: pageRelativePaths?[offset] - ?? "\(gid)_token_\(offset + 1).jpg", - fileHash: hash - ) - } + pages: Dictionary( + uniqueKeysWithValues: + pageHashes.enumerated().map { ($0.offset + 1, $0.element) } + ) ) } diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index 9bb518beb..84848bdfe 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -257,13 +257,10 @@ private extension DownloadPauseAndReconcileTests { tags: [], postedDate: .now, rating: 4, - pages: pageHashes.enumerated().map { offset, hash in - .init( - index: offset + 1, - relativePath: "pages/\(String(format: "%04d", offset + 1)).jpg", - fileHash: hash - ) - } + pages: Dictionary( + uniqueKeysWithValues: + pageHashes.enumerated().map { ($0.offset + 1, $0.element) } + ) ), folderURL: folderURL ) diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index 698ed93da..e3beaa48b 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -112,13 +112,10 @@ private extension DownloadRetryPagesTests { tags: [], postedDate: .now, rating: 4, - pages: pageHashes.enumerated().map { offset, hash in - .init( - index: offset + 1, - relativePath: "pages/\(String(format: "%04d", offset + 1)).jpg", - fileHash: hash - ) - } + pages: Dictionary( + uniqueKeysWithValues: + pageHashes.enumerated().map { ($0.offset + 1, $0.element) } + ) ), folderURL: folderURL ) diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 6e61b04aa..061ce9f30 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -202,13 +202,10 @@ private extension DownloadRetryUpdateFallbackTests { tags: manifest.tags, postedDate: manifest.postedDate, rating: manifest.rating, - pages: manifest.pages.map { - DownloadManifest.Page( - index: $0.index, - relativePath: $0.relativePath, - fileHash: "sha256:\($0.index)" - ) - } + pages: Dictionary( + uniqueKeysWithValues: + manifest.pages.keys.sorted().map { ($0, "sha256:\($0)") } + ) ) } } diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index a9f2e4176..4ea3313b4 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -49,13 +49,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { tags: [], postedDate: .now, rating: 4, - pages: [ - .init( - index: 1, - relativePath: "pages/0001.jpg", - fileHash: "" - ) - ] + pages: [1: ""] ), folderURL: folderURL ) diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index ff52d77c8..2029c9fd2 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -82,13 +82,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { tags: [], postedDate: .now, rating: 4, - pages: [ - .init( - index: 1, - relativePath: "\(gid)_token_1.jpg", - fileHash: "sha256:done" - ) - ] + pages: [1: "sha256:done"] ), folderURL: folderURL ) diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index 03129a9ea..5428286d1 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -70,13 +70,10 @@ private extension DownloadedGalleryManifestModelTests { tags: [], postedDate: Date(timeIntervalSince1970: 1_000), rating: 4, - pages: pageHashes.sorted(by: { $0.key < $1.key }).map { index, hash in - .init( - index: index, - relativePath: "123_token_\(index).jpg", - fileHash: hash - ) - } + pages: Dictionary( + uniqueKeysWithValues: + pageHashes.map { index, hash in (index, hash ?? "") } + ) ) } } From 9a596fa8949e04eedcd77f7ecd6225705d31ea78 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 06:36:00 +0800 Subject: [PATCH 122/614] Store remote cover --- .../App/Tools/Clients/DownloadClient+ExecutionPerform.swift | 2 ++ EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift | 2 ++ .../App/Tools/Utilities/DownloadFileStorage+Operations.swift | 1 + EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift | 1 + EhPanda/Models/Persistent/DownloadedGallery.swift | 2 +- .../Tests/Download/DownloadEnqueueManifestTests.swift | 2 ++ .../Tests/Download/DownloadFeatureTestFactories.swift | 1 + .../Tests/Download/DownloadFileStorageHashTests.swift | 1 + .../Tests/Download/DownloadFileStorageRepairTests.swift | 5 ++++- EhPandaTests/Tests/Download/DownloadFileStorageTests.swift | 1 + .../Tests/Download/DownloadManagerCaptureTests.swift | 1 + .../Tests/Download/DownloadManagerStorageTests.swift | 1 + .../Tests/Download/DownloadPauseAndReconcileTests.swift | 1 + EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift | 1 + .../Tests/Download/DownloadRetryUpdateFallbackTests.swift | 1 + EhPandaTests/Tests/Download/DownloadSchedulingTests.swift | 1 + .../Tests/Download/DownloadVersionSignatureTests.swift | 1 + .../Tests/Download/DownloadedGalleryManifestModelTests.swift | 2 ++ 18 files changed, 25 insertions(+), 2 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index feb2525b8..077f77f10 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -194,6 +194,8 @@ extension DownloadManager { jpnTitle: payload.galleryDetail.jpnTitle, category: payload.gallery.category, language: payload.galleryDetail.language, + remoteCoverURL: + payload.galleryDetail.coverURL ?? payload.gallery.coverURL, uploader: payload.galleryDetail.uploader, tags: payload.gallery.tags, postedDate: payload.galleryDetail.postedDate, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index ba5426e2a..3d6feb167 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -112,6 +112,8 @@ extension DownloadManager { jpnTitle: payload.galleryDetail.jpnTitle, category: payload.gallery.category, language: payload.galleryDetail.language, + remoteCoverURL: + payload.galleryDetail.coverURL ?? payload.gallery.coverURL, uploader: payload.galleryDetail.uploader, tags: payload.gallery.tags, postedDate: payload.galleryDetail.postedDate, diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index efbb1a062..66a8683c4 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -289,6 +289,7 @@ private extension DownloadManifest { jpnTitle: jpnTitle, category: category, language: language, + remoteCoverURL: remoteCoverURL, uploader: uploader, tags: tags, postedDate: postedDate, diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift index 6f64885fd..411432fc7 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -13,6 +13,7 @@ struct DownloadManifest: Codable, Equatable, Sendable { let jpnTitle: String? let category: Category let language: Language + let remoteCoverURL: URL? let uploader: String? let tags: [GalleryTag] let postedDate: Date diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 8f812b130..4c36b588c 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -274,7 +274,7 @@ struct DownloadedGallery: Identifiable, Equatable { pageCount: manifest.pageCount, postedDate: manifest.postedDate, rating: manifest.rating, - onlineCoverURL: nil, + onlineCoverURL: manifest.remoteCoverURL, folderRelativePath: folderRelativePath, coverRelativePath: nil, status: displayStatus.downloadStatus, diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index 83719dd5e..ff4e4b018 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -55,6 +55,7 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { #expect(manifest.gid == gallery.gid) #expect(manifest.token == gallery.token) #expect(manifest.pageCount == detail.pageCount) + #expect(manifest.remoteCoverURL == detail.coverURL) #expect(manifest.pages.count == detail.pageCount) #expect(manifest.pages[1] == "") @@ -70,6 +71,7 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { let queuedDownload = await manager.testingFetchDownload(gid: gallery.gid) #expect(queuedDownload?.status == .queued) + #expect(queuedDownload?.onlineCoverURL == detail.coverURL) #expect(queuedDownload?.pageCount == detail.pageCount) } } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 2116aa74e..8bcda03dd 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -25,6 +25,7 @@ extension DownloadFeatureTestCase { jpnTitle: nil, category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), uploader: "Uploader", tags: [], postedDate: .now, diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index e6e1df5f7..a518e795f 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -124,6 +124,7 @@ struct DownloadFileStorageHashTests { jpnTitle: nil, category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), uploader: "Uploader", tags: [], postedDate: .now, diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift index 664cbf16b..7d940c960 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift @@ -111,7 +111,9 @@ private extension DownloadFileStorageRepairTests { ) let manifest = DownloadManifest( gid: "123", host: .ehentai, token: "token", title: "Sample", jpnTitle: nil, - category: .doujinshi, language: .japanese, uploader: "Uploader", tags: [], + category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", tags: [], postedDate: .now, rating: 4, pages: [ @@ -201,6 +203,7 @@ private extension DownloadFileStorageRepairTests { jpnTitle: nil, category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), uploader: "Uploader", tags: [], postedDate: .now, diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 10950cf84..879f5302e 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -430,6 +430,7 @@ private extension DownloadFileStorageTests { jpnTitle: nil, category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), uploader: "Uploader", tags: [], postedDate: .now, diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index d74d68b14..a382b0936 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -133,6 +133,7 @@ private extension DownloadManagerCaptureTests { jpnTitle: nil, category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), uploader: "Uploader", tags: [], postedDate: .now, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index e243c7968..7a53e3ef2 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -939,6 +939,7 @@ private extension DownloadManagerStorageTests { jpnTitle: nil, category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), uploader: "Uploader", tags: [], postedDate: modifiedAt, diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index 84848bdfe..f57e2b238 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -253,6 +253,7 @@ private extension DownloadPauseAndReconcileTests { jpnTitle: nil, category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), uploader: "Uploader", tags: [], postedDate: .now, diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index e3beaa48b..2d3cd7e6d 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -108,6 +108,7 @@ private extension DownloadRetryPagesTests { jpnTitle: nil, category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), uploader: "Uploader", tags: [], postedDate: .now, diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 061ce9f30..9fdd55a8d 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -198,6 +198,7 @@ private extension DownloadRetryUpdateFallbackTests { jpnTitle: manifest.jpnTitle, category: manifest.category, language: manifest.language, + remoteCoverURL: manifest.remoteCoverURL, uploader: manifest.uploader, tags: manifest.tags, postedDate: manifest.postedDate, diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index 4ea3313b4..2e5a74f4c 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -45,6 +45,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { jpnTitle: nil, category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), uploader: "Uploader", tags: [], postedDate: .now, diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 2029c9fd2..63a7436a0 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -78,6 +78,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { jpnTitle: nil, category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), uploader: "Uploader", tags: [], postedDate: .now, diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index 5428286d1..df9a2ca14 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -50,6 +50,7 @@ struct DownloadedGalleryManifestModelTests { #expect(download.gid == "123") #expect(download.folderRelativePath == "[123_token] Sample") #expect(download.status == .queued) + #expect(download.onlineCoverURL == manifest.remoteCoverURL) #expect(download.completedPageCount == 2) #expect(download.lastDownloadedAt == modifiedAt) #expect(download.downloadOptionsSnapshot.threadLimit == 3) @@ -66,6 +67,7 @@ private extension DownloadedGalleryManifestModelTests { jpnTitle: "サンプル", category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), uploader: "Uploader", tags: [], postedDate: Date(timeIntervalSince1970: 1_000), From a91d240c55ad4ecc03439ef8749f747154b13bc7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 06:40:22 +0800 Subject: [PATCH 123/614] Split value types --- EhPanda/Models/Persistent/DownloadBadge.swift | 16 +++ .../Models/Persistent/DownloadFailure.swift | 67 ++++++++++++ .../Persistent/DownloadInspection.swift | 32 ++++++ .../Models/Persistent/DownloadedGallery.swift | 101 ------------------ 4 files changed, 115 insertions(+), 101 deletions(-) create mode 100644 EhPanda/Models/Persistent/DownloadBadge.swift create mode 100644 EhPanda/Models/Persistent/DownloadFailure.swift create mode 100644 EhPanda/Models/Persistent/DownloadInspection.swift diff --git a/EhPanda/Models/Persistent/DownloadBadge.swift b/EhPanda/Models/Persistent/DownloadBadge.swift new file mode 100644 index 000000000..a02e23cd8 --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadBadge.swift @@ -0,0 +1,16 @@ +// +// DownloadBadge.swift +// EhPanda +// + +enum DownloadBadge: Equatable { + case none + case queued + case downloading(Int, Int) + case paused(Int, Int) + case partial(Int, Int) + case downloaded + case failed + case updateAvailable + case missingFiles +} diff --git a/EhPanda/Models/Persistent/DownloadFailure.swift b/EhPanda/Models/Persistent/DownloadFailure.swift new file mode 100644 index 000000000..2feb7ed63 --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadFailure.swift @@ -0,0 +1,67 @@ +// +// DownloadFailure.swift +// EhPanda +// + +enum DownloadFailureCode: String, Codable, Equatable, Sendable { + case quotaExceeded + case authenticationRequired + case fileOperationFailed + case ipBanned + case networkingFailed + case parseFailed + case notFound + case unknown +} + +struct DownloadFailure: Codable, Equatable, Sendable { + var code: DownloadFailureCode + var message: String + + init(code: DownloadFailureCode, message: String) { + self.code = code + self.message = message + } + + init(error: AppError) { + switch error { + case .quotaExceeded: + self = .init(code: .quotaExceeded, message: error.alertText) + case .authenticationRequired: + self = .init(code: .authenticationRequired, message: error.alertText) + case .fileOperationFailed(let reason): + self = .init(code: .fileOperationFailed, message: reason) + case .ipBanned(let interval): + self = .init(code: .ipBanned, message: interval.description) + case .networkingFailed: + self = .init(code: .networkingFailed, message: error.alertText) + case .parseFailed: + self = .init(code: .parseFailed, message: error.alertText) + case .notFound: + self = .init(code: .notFound, message: error.alertText) + default: + self = .init(code: .unknown, message: error.alertText) + } + } + + var appError: AppError { + switch code { + case .quotaExceeded: + return .quotaExceeded + case .authenticationRequired: + return .authenticationRequired + case .fileOperationFailed: + return .fileOperationFailed(message) + case .ipBanned: + return .ipBanned(.unrecognized(content: message)) + case .networkingFailed: + return .networkingFailed + case .parseFailed: + return .parseFailed + case .notFound: + return .notFound + case .unknown: + return .unknown + } + } +} diff --git a/EhPanda/Models/Persistent/DownloadInspection.swift b/EhPanda/Models/Persistent/DownloadInspection.swift new file mode 100644 index 000000000..e21065842 --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadInspection.swift @@ -0,0 +1,32 @@ +// +// DownloadInspection.swift +// EhPanda +// + +import Foundation + +enum DownloadPageStatus: String, Equatable, CaseIterable, Sendable { + case pending + case downloaded + case failed +} + +struct DownloadPageInspection: Equatable, Identifiable, Sendable { + var id: Int { index } + + let index: Int + let status: DownloadPageStatus + let relativePath: String? + let fileURL: URL? + let failure: DownloadFailure? +} + +struct DownloadInspection: Equatable, Sendable { + let download: DownloadedGallery + let coverURL: URL? + let pages: [DownloadPageInspection] + + var failedPageIndices: [Int] { + pages.filter { $0.status == .failed }.map(\.index) + } +} diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 4c36b588c..425c1c215 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -56,69 +56,6 @@ enum DownloadStatus: String, Codable, Equatable, CaseIterable, Sendable { case missingFiles } -enum DownloadFailureCode: String, Codable, Equatable, Sendable { - case quotaExceeded - case authenticationRequired - case fileOperationFailed - case ipBanned - case networkingFailed - case parseFailed - case notFound - case unknown -} - -struct DownloadFailure: Codable, Equatable, Sendable { - var code: DownloadFailureCode - var message: String - - init(code: DownloadFailureCode, message: String) { - self.code = code - self.message = message - } - - init(error: AppError) { - switch error { - case .quotaExceeded: - self = .init(code: .quotaExceeded, message: error.alertText) - case .authenticationRequired: - self = .init(code: .authenticationRequired, message: error.alertText) - case .fileOperationFailed(let reason): - self = .init(code: .fileOperationFailed, message: reason) - case .ipBanned(let interval): - self = .init(code: .ipBanned, message: interval.description) - case .networkingFailed: - self = .init(code: .networkingFailed, message: error.alertText) - case .parseFailed: - self = .init(code: .parseFailed, message: error.alertText) - case .notFound: - self = .init(code: .notFound, message: error.alertText) - default: - self = .init(code: .unknown, message: error.alertText) - } - } - - var appError: AppError { - switch code { - case .quotaExceeded: - return .quotaExceeded - case .authenticationRequired: - return .authenticationRequired - case .fileOperationFailed: - return .fileOperationFailed(message) - case .ipBanned: - return .ipBanned(.unrecognized(content: message)) - case .networkingFailed: - return .networkingFailed - case .parseFailed: - return .parseFailed - case .notFound: - return .notFound - case .unknown: - return .unknown - } - } -} - enum DownloadStartMode: String, Codable, Equatable, Sendable { case initial case update @@ -142,44 +79,6 @@ struct DownloadFailedPagesSnapshot: Codable, Equatable, Sendable { } } -enum DownloadPageStatus: String, Equatable, CaseIterable, Sendable { - case pending - case downloaded - case failed -} - -struct DownloadPageInspection: Equatable, Identifiable, Sendable { - var id: Int { index } - - let index: Int - let status: DownloadPageStatus - let relativePath: String? - let fileURL: URL? - let failure: DownloadFailure? -} - -struct DownloadInspection: Equatable, Sendable { - let download: DownloadedGallery - let coverURL: URL? - let pages: [DownloadPageInspection] - - var failedPageIndices: [Int] { - pages.filter { $0.status == .failed }.map(\.index) - } -} - -enum DownloadBadge: Equatable { - case none - case queued - case downloading(Int, Int) - case paused(Int, Int) - case partial(Int, Int) - case downloaded - case failed - case updateAvailable - case missingFiles -} - struct DownloadedGallery: Identifiable, Equatable { var id: String { gid } From f81fe4998429b1548d2eb7d3e40ffe4578a8f8f2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 06:47:32 +0800 Subject: [PATCH 124/614] Remove badge store --- EhPanda/View/Favorites/FavoritesReducer.swift | 23 +-------- .../View/Home/History/HistoryReducer.swift | 17 +------ EhPanda/View/Home/HomeReducer+Body.swift | 37 ++------------ EhPanda/View/Home/HomeReducer.swift | 10 ---- .../View/Home/Watched/WatchedReducer.swift | 23 +-------- EhPanda/View/Search/SearchReducer.swift | 23 +-------- .../Components/DownloadBadgeStore.swift | 48 ------------------- .../DownloadObserverReadingTests.swift | 13 +++++ 8 files changed, 25 insertions(+), 169 deletions(-) delete mode 100644 EhPanda/View/Support/Components/DownloadBadgeStore.swift diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift index 4b89e8e0a..5ee3c12c5 100644 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -74,8 +74,6 @@ struct FavoritesReducer { case fetchGalleriesDone(Int, Result) case fetchMoreGalleries case fetchMoreGalleriesDone(Int, Result) - case fetchDownloadBadges([String]) - case fetchDownloadBadgesDone([String: DownloadBadge]) case observeDownloads case observeDownloadsDone([DownloadedGallery]) @@ -150,10 +148,7 @@ struct FavoritesReducer { state.rawPageNumber[targetFavIndex] = pageNumber state.rawGalleries[targetFavIndex] = galleries state.sortOrder = fetchResult.sortOrder - return .merge( - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), - .send(.fetchDownloadBadges(galleries.map(\.gid))) - ) + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.rawLoadingState[targetFavIndex] = .failed(error) } @@ -195,7 +190,6 @@ struct FavoritesReducer { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.rawLoadingState[targetFavIndex] = .idle - effects.append(.send(.fetchDownloadBadges((state.galleries ?? []).map(\.gid)))) } return .merge(effects) @@ -204,15 +198,6 @@ struct FavoritesReducer { } return .none - case .fetchDownloadBadges(let gids): - return .run { send in - await send(.fetchDownloadBadgesDone(await downloadClient.badges(gids))) - } - - case .fetchDownloadBadgesDone(let badges): - state.downloadBadges.merge(badges, uniquingKeysWith: { _, new in new }) - return .none - case .observeDownloads: return .run { send in for await downloads in downloadClient.observeDownloads() { @@ -222,12 +207,8 @@ struct FavoritesReducer { .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) case .observeDownloadsDone(let downloads): - let visibleGIDs = Set((state.galleries ?? []).map(\.gid)) state.downloadBadges = Dictionary( - uniqueKeysWithValues: downloads.compactMap { download in - guard visibleGIDs.contains(download.gid) else { return nil } - return (download.gid, download.badge) - } + uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) } ) return .none diff --git a/EhPanda/View/Home/History/HistoryReducer.swift b/EhPanda/View/Home/History/HistoryReducer.swift index 532c6c6e2..de50eaf00 100644 --- a/EhPanda/View/Home/History/HistoryReducer.swift +++ b/EhPanda/View/Home/History/HistoryReducer.swift @@ -48,8 +48,6 @@ struct HistoryReducer { case fetchGalleries case fetchGalleriesDone([Gallery]) - case fetchDownloadBadges([String]) - case fetchDownloadBadgesDone([String: DownloadBadge]) case observeDownloads case observeDownloadsDone([DownloadedGallery]) @@ -106,15 +104,6 @@ struct HistoryReducer { } else { state.galleries = galleries } - return .send(.fetchDownloadBadges(galleries.map(\.gid))) - - case .fetchDownloadBadges(let gids): - return .run { send in - await send(.fetchDownloadBadgesDone(await downloadClient.badges(gids))) - } - - case .fetchDownloadBadgesDone(let badges): - state.downloadBadges = badges return .none case .observeDownloads: @@ -126,12 +115,8 @@ struct HistoryReducer { .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) case .observeDownloadsDone(let downloads): - let visibleGIDs = Set(state.galleries.map(\.gid)) state.downloadBadges = Dictionary( - uniqueKeysWithValues: downloads.compactMap { download in - guard visibleGIDs.contains(download.gid) else { return nil } - return (download.gid, download.badge) - } + uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) } ) return .none diff --git a/EhPanda/View/Home/HomeReducer+Body.swift b/EhPanda/View/Home/HomeReducer+Body.swift index 79e35f738..2cc3b9b81 100644 --- a/EhPanda/View/Home/HomeReducer+Body.swift +++ b/EhPanda/View/Home/HomeReducer+Body.swift @@ -88,10 +88,7 @@ extension HomeReducer { return .none } state.setPopularGalleries(galleries) - return .merge( - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), - .send(.fetchDownloadBadges(galleries.map(\.gid))) - ) + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.popularLoadingState = .failed(error) } @@ -115,10 +112,7 @@ extension HomeReducer { return .none } state.setFrontpageGalleries(galleries) - return .merge( - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), - .send(.fetchDownloadBadges(galleries.map(\.gid))) - ) + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.frontpageLoadingState = .failed(error) } @@ -141,10 +135,7 @@ extension HomeReducer { return .none } state.toplistsGalleries[index] = galleries - return .merge( - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), - .send(.fetchDownloadBadges(galleries.map(\.gid))) - ) + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.toplistsLoadingState[index] = .failed(error) } @@ -161,15 +152,6 @@ extension HomeReducer { state.rawCardColors[gid] = colors return .none - case .fetchDownloadBadges(let gids): - return .run { send in - await send(.fetchDownloadBadgesDone(await downloadClient.badges(gids))) - } - - case .fetchDownloadBadgesDone(let badges): - state.downloadBadges.merge(badges, uniquingKeysWith: { _, new in new }) - return .none - case .observeDownloads: return .run { send in for await downloads in downloadClient.observeDownloads() { @@ -179,18 +161,9 @@ extension HomeReducer { .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) case .observeDownloadsDone(let downloads): - let visibleGIDs = state.visibleGalleryIDs - let downloadedGIDs = Set(downloads.map(\.gid)) - let newBadges = [String: DownloadBadge]( - uniqueKeysWithValues: downloads.compactMap { download in - guard visibleGIDs.contains(download.gid) else { return nil } - return (download.gid, download.badge) - } + state.downloadBadges = Dictionary( + uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) } ) - state.downloadBadges.merge(newBadges, uniquingKeysWith: { _, new in new }) - for gid in state.downloadBadges.keys where !downloadedGIDs.contains(gid) { - state.downloadBadges.removeValue(forKey: gid) - } return .none case .frontpage: diff --git a/EhPanda/View/Home/HomeReducer.swift b/EhPanda/View/Home/HomeReducer.swift index 8a8001b45..43ef90ae3 100644 --- a/EhPanda/View/Home/HomeReducer.swift +++ b/EhPanda/View/Home/HomeReducer.swift @@ -69,14 +69,6 @@ struct HomeReducer { .removeDuplicates(by: \.trimmedTitle) } - var visibleGalleryIDs: Set { - var gids = Set(popularGalleries.map(\.gid)) - gids.formUnion(frontpageGalleries.map(\.gid)) - toplistsGalleries.values.flatMap(\.self).forEach { - gids.insert($0.gid) - } - return gids - } } enum Action: BindableAction { @@ -96,8 +88,6 @@ struct HomeReducer { case fetchFrontpageGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchToplistsGalleries(Int, Int? = nil) case fetchToplistsGalleriesDone(Int, Result<(PageNumber, [Gallery]), AppError>) - case fetchDownloadBadges([String]) - case fetchDownloadBadgesDone([String: DownloadBadge]) case observeDownloads case observeDownloadsDone([DownloadedGallery]) diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift index 4765ce6a1..7a3984a26 100644 --- a/EhPanda/View/Home/Watched/WatchedReducer.swift +++ b/EhPanda/View/Home/Watched/WatchedReducer.swift @@ -58,8 +58,6 @@ struct WatchedReducer { case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case fetchDownloadBadges([String]) - case fetchDownloadBadgesDone([String: DownloadBadge]) case observeDownloads case observeDownloadsDone([DownloadedGallery]) @@ -130,10 +128,7 @@ struct WatchedReducer { } state.pageNumber = pageNumber state.galleries = galleries - return .merge( - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), - .send(.fetchDownloadBadges(galleries.map(\.gid))) - ) + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.loadingState = .failed(error) } @@ -170,7 +165,6 @@ struct WatchedReducer { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.loadingState = .idle - effects.append(.send(.fetchDownloadBadges(state.galleries.map(\.gid)))) } return .merge(effects) @@ -179,15 +173,6 @@ struct WatchedReducer { } return .none - case .fetchDownloadBadges(let gids): - return .run { send in - await send(.fetchDownloadBadgesDone(await downloadClient.badges(gids))) - } - - case .fetchDownloadBadgesDone(let badges): - state.downloadBadges.merge(badges, uniquingKeysWith: { _, new in new }) - return .none - case .observeDownloads: return .run { send in for await downloads in downloadClient.observeDownloads() { @@ -197,12 +182,8 @@ struct WatchedReducer { .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) case .observeDownloadsDone(let downloads): - let visibleGIDs = Set(state.galleries.map(\.gid)) state.downloadBadges = Dictionary( - uniqueKeysWithValues: downloads.compactMap { download in - guard visibleGIDs.contains(download.gid) else { return nil } - return (download.gid, download.badge) - } + uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) } ) return .none diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index 2b8311ee0..fb53627ad 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -58,8 +58,6 @@ struct SearchReducer { case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case fetchDownloadBadges([String]) - case fetchDownloadBadgesDone([String: DownloadBadge]) case observeDownloads case observeDownloadsDone([DownloadedGallery]) @@ -134,10 +132,7 @@ struct SearchReducer { } state.pageNumber = pageNumber state.galleries = galleries - return .merge( - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), - .send(.fetchDownloadBadges(galleries.map(\.gid))) - ) + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.loadingState = .failed(error) } @@ -174,7 +169,6 @@ struct SearchReducer { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.loadingState = .idle - effects.append(.send(.fetchDownloadBadges(state.galleries.map(\.gid)))) } return .merge(effects) @@ -183,15 +177,6 @@ struct SearchReducer { } return .none - case .fetchDownloadBadges(let gids): - return .run { send in - await send(.fetchDownloadBadgesDone(await downloadClient.badges(gids))) - } - - case .fetchDownloadBadgesDone(let badges): - state.downloadBadges.merge(badges, uniquingKeysWith: { _, new in new }) - return .none - case .observeDownloads: return .run { send in for await downloads in downloadClient.observeDownloads() { @@ -201,12 +186,8 @@ struct SearchReducer { .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) case .observeDownloadsDone(let downloads): - let visibleGIDs = Set(state.galleries.map(\.gid)) state.downloadBadges = Dictionary( - uniqueKeysWithValues: downloads.compactMap { download in - guard visibleGIDs.contains(download.gid) else { return nil } - return (download.gid, download.badge) - } + uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) } ) return .none diff --git a/EhPanda/View/Support/Components/DownloadBadgeStore.swift b/EhPanda/View/Support/Components/DownloadBadgeStore.swift deleted file mode 100644 index bf24c63fc..000000000 --- a/EhPanda/View/Support/Components/DownloadBadgeStore.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// DownloadBadgeStore.swift -// EhPanda -// - -import Foundation -import Observation - -@Observable -@MainActor -final class DownloadBadgeStore { - static let shared = DownloadBadgeStore(client: DownloadClientKey.liveValue) - - private(set) var badges = [String: DownloadBadge]() - private(set) var downloads = [String: DownloadedGallery]() - - @ObservationIgnored - private let client: DownloadClient - @ObservationIgnored - private var observeTask: Task? - - init(client: DownloadClient) { - self.client = client - observeTask = Task { [weak self] in - guard let self else { return } - await self.apply(downloads: client.fetchDownloads()) - for await downloads in client.observeDownloads() { - self.apply(downloads: downloads) - } - } - } - - private func apply(downloads: [DownloadedGallery]) { - let resolvedDownloads = Dictionary(uniqueKeysWithValues: downloads.map { ($0.gid, $0) }) - let resolvedBadges = Dictionary(uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) }) - - guard self.downloads != resolvedDownloads || badges != resolvedBadges else { - return - } - - self.downloads = resolvedDownloads - badges = resolvedBadges - } - - deinit { - observeTask?.cancel() - } -} diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index 966d12a5a..8d08eab78 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -35,6 +35,19 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { } store.exhaustivity = .off let folderURL = download.folderURL + defer { try? FileManager.default.removeItem(at: folderURL) } + try FileManager.default.createDirectory( + at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + withIntermediateDirectories: true + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent("pages/0001.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: folderURL.appendingPathComponent("pages/0002.jpg"), + options: .atomic + ) await store.send(.fetchDatabaseInfos(download.gid)) { $0.gallery = download.gallery From 1eba119863d278ce6786a4e27e4023bf99ac732b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 06:59:57 +0800 Subject: [PATCH 125/614] Drop signature fields --- .../DownloadClient+SchedulingHelpers.swift | 18 +++++++++--------- .../Tools/Clients/DownloadClient+Testing.swift | 3 +-- .../DownloadedGalleryMO+CoreDataClass.swift | 2 -- .../Models/Persistent/DownloadedGallery.swift | 10 +--------- .../DownloadFeatureTestFactories.swift | 4 ---- .../DownloadFileStorageHashTests.swift | 4 +--- .../Download/DownloadFileStorageTests.swift | 4 +--- .../Download/DownloadFilterAndBadgeTests.swift | 17 +++++------------ .../DownloadManagerRepairSeedTests.swift | 7 ++----- .../Download/DownloadProcessCacheTests.swift | 8 ++------ .../Tests/Download/DownloadProcessTests.swift | 4 +--- .../DownloadRetryUpdateFallbackTests.swift | 4 +--- .../Download/DownloadsReducerActionTests.swift | 3 +-- 13 files changed, 25 insertions(+), 63 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index e67452b6c..cd6af3eef 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -37,16 +37,14 @@ extension DownloadManager { case .failed: return effectiveRetryMode( for: download, - requestedMode: download.remoteVersionSignature.isEmpty - ? .initial : .redownload + requestedMode: initialOrRedownloadMode(for: download) ) case .paused: return resumeMode(for: download) case .queued, .downloading: return effectiveRetryMode( for: download, - requestedMode: download.remoteVersionSignature.isEmpty - ? .initial : .redownload + requestedMode: initialOrRedownloadMode(for: download) ) } } @@ -54,17 +52,13 @@ extension DownloadManager { func resumeMode( for download: DownloadedGallery ) -> DownloadStartMode { - if download.remoteVersionSignature.isEmpty { - return .initial - } if download.hasUpdate { return .update } if download.status == .partial { return effectiveRetryMode( for: download, - requestedMode: download.remoteVersionSignature.isEmpty - ? .initial : .redownload + requestedMode: .redownload ) } if case .missingFiles = storage.validate(download: download) { @@ -73,6 +67,12 @@ extension DownloadManager { return .redownload } + private func initialOrRedownloadMode( + for download: DownloadedGallery + ) -> DownloadStartMode { + download.completedPageCount == 0 ? .initial : .redownload + } + func effectiveRetryMode( for download: DownloadedGallery, requestedMode: DownloadStartMode diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 945e53e84..22cb3e2d7 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -101,8 +101,7 @@ extension DownloadManager { func testingPrepareWorkingSeed( payload: DownloadRequestPayload, - existingDownload: DownloadedGallery, - versionSignature _: String + existingDownload: DownloadedGallery ) throws -> PrepareWorkingSeedResult { let folderURL = storage.folderURL( relativePath: folderRelativePath(for: payload) diff --git a/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift index 191158c10..195fdb84f 100644 --- a/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift @@ -29,8 +29,6 @@ extension DownloadedGalleryMO: ManagedObjectProtocol { lastDownloadedAt: lastDownloadedAt, lastError: lastError?.toObject(), downloadOptionsSnapshot: downloadOptionsSnapshot?.toObject() ?? .init(), - remoteVersionSignature: remoteVersionSignature, - latestRemoteVersionSignature: latestRemoteVersionSignature, pendingOperation: pendingOperation.flatMap(DownloadStartMode.init(rawValue:)) ) } diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 425c1c215..0caf927a8 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -101,8 +101,6 @@ struct DownloadedGallery: Identifiable, Equatable { let lastDownloadedAt: Date? let lastError: DownloadFailure? let downloadOptionsSnapshot: DownloadOptionsSnapshot - let remoteVersionSignature: String - let latestRemoteVersionSignature: String? let pendingOperation: DownloadStartMode? init( @@ -125,8 +123,6 @@ struct DownloadedGallery: Identifiable, Equatable { lastDownloadedAt: Date?, lastError: DownloadFailure?, downloadOptionsSnapshot: DownloadOptionsSnapshot, - remoteVersionSignature: String, - latestRemoteVersionSignature: String?, pendingOperation: DownloadStartMode? = nil ) { self.gid = gid @@ -148,8 +144,6 @@ struct DownloadedGallery: Identifiable, Equatable { self.lastDownloadedAt = lastDownloadedAt self.lastError = lastError self.downloadOptionsSnapshot = downloadOptionsSnapshot - self.remoteVersionSignature = remoteVersionSignature - self.latestRemoteVersionSignature = latestRemoteVersionSignature self.pendingOperation = pendingOperation } @@ -180,9 +174,7 @@ struct DownloadedGallery: Identifiable, Equatable { completedPageCount: manifest.completedPageCount, lastDownloadedAt: modifiedAt, lastError: lastError, - downloadOptionsSnapshot: downloadOptionsSnapshot, - remoteVersionSignature: "chain:\(manifest.gid):\(manifest.token)", - latestRemoteVersionSignature: "chain:\(manifest.gid):\(manifest.token)" + downloadOptionsSnapshot: downloadOptionsSnapshot ) } } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 8bcda03dd..0938ab00e 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -69,8 +69,6 @@ extension DownloadFeatureTestCase { pageCount: Int = 12, completedPageCount: Int? = nil, lastDownloadedAt: Date? = .now, - remoteVersionSignature: String = "hash:v1", - latestRemoteVersionSignature: String = "hash:v1", lastError: DownloadFailure? = nil, pendingOperation: DownloadStartMode? = nil ) -> DownloadedGallery { @@ -94,8 +92,6 @@ extension DownloadFeatureTestCase { lastDownloadedAt: lastDownloadedAt, lastError: lastError, downloadOptionsSnapshot: DownloadOptionsSnapshot(), - remoteVersionSignature: remoteVersionSignature, - latestRemoteVersionSignature: latestRemoteVersionSignature, pendingOperation: pendingOperation ) } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index a518e795f..78abebf52 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -109,9 +109,7 @@ struct DownloadFileStorageHashTests { completedPageCount: 2, lastDownloadedAt: .now, lastError: nil, - downloadOptionsSnapshot: DownloadOptionsSnapshot(), - remoteVersionSignature: "hash:v1", - latestRemoteVersionSignature: "hash:v1" + downloadOptionsSnapshot: DownloadOptionsSnapshot() ) } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 879f5302e..320f98da6 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -407,9 +407,7 @@ private extension DownloadFileStorageTests { completedPageCount: status == .completed ? 2 : 0, lastDownloadedAt: .now, lastError: nil, - downloadOptionsSnapshot: DownloadOptionsSnapshot(), - remoteVersionSignature: "hash:v1", - latestRemoteVersionSignature: "hash:v1" + downloadOptionsSnapshot: DownloadOptionsSnapshot() ) } diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index b4cafc50e..53d88e09f 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -52,9 +52,7 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { completedPageCount: 1, lastDownloadedAt: .now, lastError: nil, - downloadOptionsSnapshot: DownloadOptionsSnapshot(), - remoteVersionSignature: "hash:v1", - latestRemoteVersionSignature: "hash:v1" + downloadOptionsSnapshot: DownloadOptionsSnapshot() ) #expect(download.searchableText == ["Solo Title", Category.doujinshi.value].joined(separator: " ")) @@ -99,7 +97,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { title: "Updated Archive", status: .updateAvailable, completedPageCount: 12, - latestRemoteVersionSignature: "hash:v2", pendingOperation: .update ) @@ -116,8 +113,7 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { title: "Resumed Update", status: .queued, pageCount: 26, - completedPageCount: 7, - latestRemoteVersionSignature: "hash:v2" + completedPageCount: 7 ) #expect(resumedUpdate.pendingOperation == nil) @@ -146,21 +142,18 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { gid: "456", title: "Downloading Update", status: .downloading, - completedPageCount: 5, - latestRemoteVersionSignature: "hash:v2" + completedPageCount: 5 ) let pausedUpdate = sampleDownload( gid: "457", title: "Paused Update", status: .paused, - completedPageCount: 5, - latestRemoteVersionSignature: "hash:v2" + completedPageCount: 5 ) let completedUpdate = sampleDownload( gid: "458", title: "Completed Update", - status: .updateAvailable, - latestRemoteVersionSignature: "hash:v2" + status: .updateAvailable ) #expect(downloadingUpdate.canTriggerUpdate == false) diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index a2479457c..b4847b212 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -25,16 +25,13 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { let existingDownload = sampleDownload( gid: gid, title: "Mixed Version", status: .missingFiles, - pageCount: 2, completedPageCount: 2, - remoteVersionSignature: "hash:v1", - latestRemoteVersionSignature: "hash:v2" + pageCount: 2, completedPageCount: 2 ) try setupRepairSeedFiles(storage: storage, rootURL: rootURL, gid: gid) let payload = makeRepairSeedPayload(gid: gid) let workingSeed = try await manager.testingPrepareWorkingSeed( - payload: payload, existingDownload: existingDownload, - versionSignature: "hash:v2" + payload: payload, existingDownload: existingDownload ) let manifest = try #require(workingSeed.manifest) diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index ac59c63d8..74492b354 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -203,9 +203,7 @@ private extension DownloadProcessCacheTests { ) let scaffoldDownload = sampleDownload( gid: gid, title: "Pause Race", status: .partial, - pageCount: 156, completedPageCount: 155, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: oldVersionSignature + pageCount: 156, completedPageCount: 155 ) let latestPayload = try await manager.testingFetchLatestPayload( for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] @@ -230,9 +228,7 @@ private extension DownloadProcessCacheTests { func setupCacheTestDownload(_ setup: CacheTestDownloadSetup) async throws -> Int { let scaffoldDownload = sampleDownload( gid: setup.gid, title: "Pause Race", status: .partial, - pageCount: 156, completedPageCount: 155, - remoteVersionSignature: setup.oldVersionSignature, - latestRemoteVersionSignature: setup.oldVersionSignature + pageCount: 156, completedPageCount: 155 ) let latestPayload = try await setup.manager.testingFetchLatestPayload( for: scaffoldDownload, mode: .redownload, diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 6b5fbfdda..75d9d96e1 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -188,9 +188,7 @@ private extension DownloadProcessTests { ) let scaffoldDownload = sampleDownload( gid: gid, title: "Pause Race", status: .partial, - pageCount: 156, completedPageCount: 155, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: oldVersionSignature + pageCount: 156, completedPageCount: 155 ) let latestPayload = try await manager.testingFetchLatestPayload( for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 9fdd55a8d..5d18860b5 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -135,9 +135,7 @@ private extension DownloadRetryUpdateFallbackTests { ) let scaffoldDownload = sampleDownload( gid: gid, title: "Pause Race", status: .partial, - pageCount: 156, completedPageCount: 155, - remoteVersionSignature: oldVersionSignature, - latestRemoteVersionSignature: "" + pageCount: 156, completedPageCount: 155 ) let fetchedPayload = try await manager.testingFetchLatestPayload( for: scaffoldDownload, mode: .update diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift index 19f96c867..b37812329 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -56,8 +56,7 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { let download = sampleDownload( gid: "123456", title: "Completed Gallery", - status: .updateAvailable, - latestRemoteVersionSignature: "hash:v2" + status: .updateAvailable ) var initialState = DownloadsReducer.State() initialState.downloads = [download] From 80db51a8b6cad998ae55f23a07d3d56816626cf9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 07:04:37 +0800 Subject: [PATCH 126/614] Delete download MO --- .../DownloadedGalleryMO+CoreDataClass.swift | 35 ---------- ...wnloadedGalleryMO+CoreDataProperties.swift | 35 ---------- .../Model 8.xcdatamodel/contents | 30 --------- .../DownloadFeatureTestFactories.swift | 65 ------------------- .../Download/DownloadFeatureTestHelpers.swift | 1 - 5 files changed, 166 deletions(-) delete mode 100644 EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift delete mode 100644 EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataProperties.swift diff --git a/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift deleted file mode 100644 index 195fdb84f..000000000 --- a/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// DownloadedGalleryMO+CoreDataClass.swift -// EhPanda -// - -import CoreData - -public class DownloadedGalleryMO: NSManagedObject {} - -extension DownloadedGalleryMO: ManagedObjectProtocol { - func toEntity() -> DownloadedGallery { - DownloadedGallery( - gid: gid, - host: GalleryHost(rawValue: host) ?? .ehentai, - token: token, - title: title, - jpnTitle: jpnTitle, - uploader: uploader, - category: Category(rawValue: category) ?? .private, - tags: tags?.toObject() ?? [], - pageCount: Int(pageCount), - postedDate: postedDate, - rating: rating, - onlineCoverURL: onlineCoverURL, - folderRelativePath: folderRelativePath, - coverRelativePath: coverRelativePath, - status: DownloadStatus(rawValue: status) ?? .queued, - completedPageCount: Int(completedPageCount), - lastDownloadedAt: lastDownloadedAt, - lastError: lastError?.toObject(), - downloadOptionsSnapshot: downloadOptionsSnapshot?.toObject() ?? .init(), - pendingOperation: pendingOperation.flatMap(DownloadStartMode.init(rawValue:)) - ) - } -} diff --git a/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataProperties.swift deleted file mode 100644 index b3f10ed1c..000000000 --- a/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataProperties.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// DownloadedGalleryMO+CoreDataProperties.swift -// EhPanda -// - -import CoreData - -extension DownloadedGalleryMO: GalleryIdentifiable { - @nonobjc public class func fetchRequest() -> NSFetchRequest { - NSFetchRequest(entityName: "DownloadedGalleryMO") - } - - @NSManaged public var category: String - @NSManaged public var completedPageCount: Int64 - @NSManaged public var coverRelativePath: String? - @NSManaged public var downloadOptionsSnapshot: Data? - @NSManaged public var folderRelativePath: String - @NSManaged public var gid: String - @NSManaged public var host: String - @NSManaged public var jpnTitle: String? - @NSManaged public var lastDownloadedAt: Date? - @NSManaged public var lastError: Data? - @NSManaged public var latestRemoteVersionSignature: String? - @NSManaged public var onlineCoverURL: URL? - @NSManaged public var pageCount: Int64 - @NSManaged public var pendingOperation: String? - @NSManaged public var postedDate: Date - @NSManaged public var rating: Float - @NSManaged public var remoteVersionSignature: String - @NSManaged public var status: String - @NSManaged public var tags: Data? - @NSManaged public var title: String - @NSManaged public var token: String - @NSManaged public var uploader: String? -} diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 8.xcdatamodel/contents b/EhPanda/Database/Model.xcdatamodeld/Model 8.xcdatamodel/contents index b6dbe1d3a..3780a4207 100644 --- a/EhPanda/Database/Model.xcdatamodeld/Model 8.xcdatamodel/contents +++ b/EhPanda/Database/Model.xcdatamodeld/Model 8.xcdatamodel/contents @@ -57,38 +57,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 0938ab00e..434f598ff 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -149,71 +149,6 @@ extension DownloadFeatureTestCase { return container } - func clearPersistedDownloads( - in container: NSPersistentContainer - ) throws { - let context = container.viewContext - try performAndWait(in: context) { - let downloadRequest = NSFetchRequest( - entityName: "DownloadedGalleryMO" - ) - let downloads = try context.fetch(downloadRequest) - for object in downloads { - context.delete(object) - } - let stateRequest = NSFetchRequest( - entityName: "GalleryStateMO" - ) - let states = try context.fetch(stateRequest) - for object in states { - context.delete(object) - } - guard context.hasChanges else { return } - try context.save() - } - } - - func insertPersistedDownload( - in container: NSPersistentContainer, - gid: String, - status: DownloadStatus, - completedPageCount: Int, - pageCount: Int = 26, - token: String = "token", - remoteVersionSignature: String = "", - latestRemoteVersionSignature: String = "", - lastError: DownloadFailure? = nil, - pendingOperation: DownloadStartMode? = nil - ) throws { - let context = container.viewContext - try performAndWait(in: context) { - let object = DownloadedGalleryMO(context: context) - object.gid = gid - object.host = GalleryHost.ehentai.rawValue - object.token = token - object.title = "Pause Race" - object.jpnTitle = nil - object.uploader = "Uploader" - object.category = Category.doujinshi.rawValue - object.tags = [GalleryTag]().toData() - object.pageCount = Int64(pageCount) - object.postedDate = .now - object.rating = 4 - object.onlineCoverURL = URL(string: "https://example.com/cover.jpg") - object.folderRelativePath = "\(gid) - Pause Race" - object.coverRelativePath = nil - object.status = status.rawValue - object.completedPageCount = Int64(completedPageCount) - object.lastDownloadedAt = .now - object.lastError = lastError?.toData() - object.downloadOptionsSnapshot = DownloadOptionsSnapshot().toData() - object.remoteVersionSignature = remoteVersionSignature - object.latestRemoteVersionSignature = latestRemoteVersionSignature - object.pendingOperation = pendingOperation?.rawValue - try context.save() - } - } - func insertPersistedGalleryState( in container: NSPersistentContainer, gid: String, diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift index ea8b1889a..c7facf666 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -54,7 +54,6 @@ protocol DownloadFeatureTestCase: TestHelper { manifest: DownloadManifest ) throws -> URL func makeInMemoryContainer() throws -> NSPersistentContainer - func clearPersistedDownloads(in container: NSPersistentContainer) throws func insertPersistedGalleryState( in container: NSPersistentContainer, gid: String, From 3d7512ca54acd973fd95d782bd34c13b38375ef4 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 07:09:43 +0800 Subject: [PATCH 127/614] Drop pending op --- .../Clients/DownloadClient+PublicAPI.swift | 4 ++-- .../DownloadClient+SchedulingHelpers.swift | 3 --- .../DownloadedGallery+SupportTypes.swift | 2 +- .../Models/Persistent/DownloadedGallery.swift | 5 +---- .../Download/DownloadBadgeSortTests.swift | 15 ++++++--------- .../DownloadFeatureTestFactories.swift | 6 ++---- .../DownloadFilterAndBadgeTests.swift | 19 ++++++------------- .../DownloadManagerStorageTests.swift | 2 -- .../Download/DownloadRetryPagesTests.swift | 2 -- .../DownloadRetryUpdateFallbackTests.swift | 2 -- 10 files changed, 18 insertions(+), 42 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 3d6feb167..3d73fff10 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -129,8 +129,8 @@ extension DownloadManager { return .failure(.notFound) } - if let pendingMode = download.pendingOperation { - return await cancelQueuedWorkItem(download, mode: pendingMode) + if let queuedMode = queuedModes[gid] { + return await cancelQueuedWorkItem(download, mode: queuedMode) } switch download.status { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index cd6af3eef..f8f3e1cfb 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -16,9 +16,6 @@ extension DownloadManager { requestedMode: mode ) } - if let pendingOperation = download.pendingOperation { - return pendingOperation - } switch download.status { case .missingFiles: return effectiveRetryMode( diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index e5e1c511f..7b3e0297a 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -186,7 +186,7 @@ extension DownloadedGallery { } var isQueuedWorkItem: Bool { - status == .queued || pendingOperation != nil + status == .queued } var hasUpdate: Bool { diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 0caf927a8..660704951 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -101,7 +101,6 @@ struct DownloadedGallery: Identifiable, Equatable { let lastDownloadedAt: Date? let lastError: DownloadFailure? let downloadOptionsSnapshot: DownloadOptionsSnapshot - let pendingOperation: DownloadStartMode? init( gid: String, @@ -122,8 +121,7 @@ struct DownloadedGallery: Identifiable, Equatable { completedPageCount: Int, lastDownloadedAt: Date?, lastError: DownloadFailure?, - downloadOptionsSnapshot: DownloadOptionsSnapshot, - pendingOperation: DownloadStartMode? = nil + downloadOptionsSnapshot: DownloadOptionsSnapshot ) { self.gid = gid self.host = host @@ -144,7 +142,6 @@ struct DownloadedGallery: Identifiable, Equatable { self.lastDownloadedAt = lastDownloadedAt self.lastError = lastError self.downloadOptionsSnapshot = downloadOptionsSnapshot - self.pendingOperation = pendingOperation } init( diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index 2cca8532b..d00cbce8a 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -27,9 +27,8 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { let queuedRedownload = sampleDownload( gid: "505", title: "Delta Archive", - status: .completed, - completedPageCount: 12, - pendingOperation: .redownload + status: .queued, + completedPageCount: 12 ) #expect(queuedRedownload.matches(filter: .completed) == false) @@ -41,9 +40,8 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { let queuedRepair = sampleDownload( gid: "606", title: "Repair Archive", - status: .missingFiles, - completedPageCount: 3, - pendingOperation: .repair + status: .queued, + completedPageCount: 3 ) let missingFilesWithoutQueuedWork = sampleDownload( gid: "607", @@ -71,10 +69,9 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { let queuedRedownload = sampleDownload( gid: "808", title: "Queued Archive", - status: .completed, + status: .queued, completedPageCount: 12, - lastDownloadedAt: .distantPast, - pendingOperation: .redownload + lastDownloadedAt: .distantPast ) let sortedDownloads = [completedDownload, queuedRedownload].sorted { lhs, rhs in diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 434f598ff..7724a8603 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -69,8 +69,7 @@ extension DownloadFeatureTestCase { pageCount: Int = 12, completedPageCount: Int? = nil, lastDownloadedAt: Date? = .now, - lastError: DownloadFailure? = nil, - pendingOperation: DownloadStartMode? = nil + lastError: DownloadFailure? = nil ) -> DownloadedGallery { DownloadedGallery( gid: gid, @@ -91,8 +90,7 @@ extension DownloadFeatureTestCase { completedPageCount: completedPageCount ?? (status == .completed ? pageCount : 0), lastDownloadedAt: lastDownloadedAt, lastError: lastError, - downloadOptionsSnapshot: DownloadOptionsSnapshot(), - pendingOperation: pendingOperation + downloadOptionsSnapshot: DownloadOptionsSnapshot() ) } diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 53d88e09f..322530e3d 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -65,12 +65,10 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { let queuedRedownload = sampleDownload( gid: "303", title: "Gamma Archive", - status: .completed, - completedPageCount: 12, - pendingOperation: .redownload + status: .queued, + completedPageCount: 12 ) - #expect(queuedRedownload.pendingOperation == .redownload) #expect(queuedRedownload.badge == .queued) #expect(queuedRedownload.matches(filter: .active)) } @@ -80,12 +78,10 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { let queuedRepair = sampleDownload( gid: "404", title: "Broken Archive", - status: .missingFiles, - completedPageCount: 3, - pendingOperation: .repair + status: .queued, + completedPageCount: 3 ) - #expect(queuedRepair.pendingOperation == .repair) #expect(queuedRepair.badge == .queued) #expect(queuedRepair.matches(filter: .active)) } @@ -95,12 +91,10 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { let queuedUpdate = sampleDownload( gid: "414", title: "Updated Archive", - status: .updateAvailable, - completedPageCount: 12, - pendingOperation: .update + status: .queued, + completedPageCount: 12 ) - #expect(queuedUpdate.pendingOperation == .update) #expect(queuedUpdate.badge == .queued) #expect(queuedUpdate.matches(filter: .active)) #expect(queuedUpdate.matches(filter: .update) == false) @@ -116,7 +110,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { completedPageCount: 7 ) - #expect(resumedUpdate.pendingOperation == nil) #expect(resumedUpdate.isQueuedWorkItem) #expect(resumedUpdate.badge == .queued) #expect(resumedUpdate.matches(filter: .active)) diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 7a53e3ef2..9fa29c3d9 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -428,7 +428,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(queueStore.gids == ["450"]) #expect(download.displayStatus == .queued) #expect(download.status == .queued) - #expect(download.pendingOperation == nil) } @Test @@ -488,7 +487,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(queueStore.gids == ["460"]) #expect(download.displayStatus == .queued) #expect(download.status == .queued) - #expect(download.pendingOperation == nil) #expect(FileManager.default.fileExists( atPath: storage.failedPagesURL(folderURL: folderURL).path ) == false) diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index 2d3cd7e6d..720e970c6 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -39,7 +39,6 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) #expect(stored?.status == .queued) #expect(stored?.badge == .queued) - #expect(stored?.pendingOperation == nil) #expect(stored?.lastError == nil) #expect(FileManager.default.fileExists( @@ -77,7 +76,6 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) #expect(stored?.status == .paused) #expect(stored?.completedPageCount == 1) - #expect(stored?.pendingOperation == nil) #expect(stored?.badge == .paused(1, 2)) } diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 5d18860b5..3f069dcbc 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -53,7 +53,6 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { let queued = await queueingManager.testingFetchDownload(gid: gid) #expect(queued?.status == .queued) #expect(queued?.badge == .queued) - #expect(queued?.pendingOperation == nil) #expect(queued?.lastError == nil) } @@ -98,7 +97,6 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { let resumedDownload = await immediateManager.testingFetchDownload(gid: gid) #expect(resumedDownload?.status == .downloading) - #expect(resumedDownload?.pendingOperation == nil) #expect(resumedDownload?.lastError == nil) } } From a17d705573214afd3137dabaae7dfaa0b7cf98b4 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 07:15:05 +0800 Subject: [PATCH 128/614] Drop signature tests --- .../Download/DownloadFeatureTestFactories.swift | 3 +-- .../Download/DownloadFeatureTestHelpers.swift | 7 +------ .../DownloadManagerRepairSeedTests.swift | 2 +- .../Download/DownloadProcessCacheTests.swift | 16 ++++++---------- .../Tests/Download/DownloadProcessTests.swift | 11 +++++------ .../DownloadRetryMinimalSourceTests.swift | 4 +--- .../DownloadRetryUpdateFallbackTests.swift | 14 ++++---------- .../Download/DownloadsReducerActionTests.swift | 3 +-- 8 files changed, 20 insertions(+), 40 deletions(-) diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 7724a8603..ce9e30de8 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -14,8 +14,7 @@ extension DownloadFeatureTestCase { func sampleManifest( gid: String, title: String, - pageCount: Int = 2, - versionSignature _: String = "hash:v1" + pageCount: Int = 2 ) throws -> DownloadManifest { DownloadManifest( gid: gid, diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift index c7facf666..acdeb681d 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -45,8 +45,7 @@ protocol DownloadFeatureTestCase: TestHelper { func sampleManifest( gid: String, title: String, - pageCount: Int, - versionSignature: String + pageCount: Int ) throws -> DownloadManifest func sampleInspection(download: DownloadedGallery) -> DownloadInspection func prepareLocalDownloadFiles( @@ -66,10 +65,6 @@ protocol DownloadFeatureTestCase: TestHelper { // MARK: - Default Implementations extension DownloadFeatureTestCase { - func chainVersionSignature(gid: String, token: String) -> String { - "chain:\(gid):\(token)" - } - func waitUntilCacheReady( for keys: Keys, timeout: Duration = .seconds(1) diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index b4847b212..8b233942a 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -168,7 +168,7 @@ private extension DownloadManagerRepairSeedTests { ) let oldManifest = try sampleManifest( gid: gid, title: "Mixed Version", - pageCount: 2, versionSignature: "hash:v1" + pageCount: 2 ) try JSONEncoder().encode(oldManifest).write( to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 74492b354..b1288772b 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -18,7 +18,6 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 402) let pageIndex = 42 - let oldVersionSignature = chainVersionSignature(gid: gid, token: "token") let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -32,7 +31,7 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { let (cachedKeys, _) = try await prepareCacheTestAssets( manager: manager, gid: gid, - pageIndex: pageIndex, oldVersionSignature: oldVersionSignature + pageIndex: pageIndex ) defer { cachedKeys.forEach { @@ -48,8 +47,7 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { storage: storage, manager: manager, gid: gid, - pageIndex: pageIndex, - oldVersionSignature: oldVersionSignature + pageIndex: pageIndex ) ) @@ -84,7 +82,6 @@ private struct CacheTestDownloadSetup { let manager: DownloadManager let gid: String let pageIndex: Int - let oldVersionSignature: String } // MARK: - Cache Test Helpers @@ -196,7 +193,7 @@ private extension DownloadProcessCacheTests { @MainActor func prepareCacheTestAssets( manager: DownloadManager, gid: String, - pageIndex: Int, oldVersionSignature: String + pageIndex: Int ) async throws -> (Set, URL) { let currentPageImageURL = try #require( Self.currentPageImageURL(gid: gid, pageIndex: pageIndex) @@ -241,15 +238,14 @@ private extension DownloadProcessCacheTests { try setupCacheTestFinalFolder( storage: setup.storage, gid: setup.gid, - oldPageCount: oldPageCount, - oldVersionSignature: setup.oldVersionSignature + oldPageCount: oldPageCount ) return updatedPageCount } func setupCacheTestFinalFolder( storage: DownloadFileStorage, gid: String, - oldPageCount: Int, oldVersionSignature: String + oldPageCount: Int ) throws { let completedFolderURL = storage.folderURL(relativePath: "\(gid) - Pause Race") try FileManager.default.createDirectory( @@ -260,7 +256,7 @@ private extension DownloadProcessCacheTests { ) let staleManifest = try sampleManifest( gid: gid, title: "Pause Race", - pageCount: oldPageCount, versionSignature: oldVersionSignature + pageCount: oldPageCount ) try storage.writeManifest(staleManifest, folderURL: completedFolderURL) } diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 75d9d96e1..db0a8b4ae 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -72,7 +72,6 @@ struct DownloadProcessTests: DownloadFeatureTestCase { let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 401) let pageIndex = 42 - let oldVersionSignature = chainVersionSignature(gid: gid, token: "token") let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -84,13 +83,13 @@ struct DownloadProcessTests: DownloadFeatureTestCase { let updatedPageCount = try await fetchAndInstallStub( manager: manager, sessionID: sessionID, gid: gid, - pageIndex: pageIndex, oldVersionSignature: oldVersionSignature + pageIndex: pageIndex ) let oldPageCount = updatedPageCount - 5 let staleFolderURL = try prepareStaleExistingFolder( storage: storage, gid: gid, pageIndex: pageIndex, - oldPageCount: oldPageCount, oldVersionSignature: oldVersionSignature + oldPageCount: oldPageCount ) let beforeProcess = await manager.testingFetchDownload(gid: gid) #expect(beforeProcess?.hasUpdate ?? true == false) @@ -174,7 +173,7 @@ private extension DownloadProcessTests { func fetchAndInstallStub( manager: DownloadManager, sessionID: String, gid: String, - pageIndex: Int, oldVersionSignature: String + pageIndex: Int ) async throws -> Int { let stubContent = StubHandlerContent( detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), @@ -208,11 +207,11 @@ private extension DownloadProcessTests { func prepareStaleExistingFolder( storage: DownloadFileStorage, gid: String, pageIndex: Int, - oldPageCount: Int, oldVersionSignature: String + oldPageCount: Int ) throws -> URL { let staleManifest = try sampleManifest( gid: gid, title: "Pause Race", - pageCount: oldPageCount, versionSignature: oldVersionSignature + pageCount: oldPageCount ) let folderURL = storage.folderURL(relativePath: "\(gid) - Pause Race") try? FileManager.default.removeItem(at: folderURL) diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 691afd0eb..a5fbd8473 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -28,7 +28,7 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { let manifest = try sampleManifest( gid: gid, title: "Pause Race", - pageCount: setup.pageCount, versionSignature: setup.versionSignature + pageCount: setup.pageCount ) try writeFinalManifest(storage: storage, gid: gid, manifest: manifest) let blocker = Task { @@ -66,7 +66,6 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { private struct MinimalSourceTestResult { let recorder: RequestRecorder - let versionSignature: String let pageCount: Int } @@ -145,7 +144,6 @@ private extension DownloadRetryMinimalSourceTests { recorder.reset() return MinimalSourceTestResult( recorder: recorder, - versionSignature: chainVersionSignature(gid: gid, token: "token"), pageCount: fetchedPayload.galleryDetail.pageCount ) } diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 3f069dcbc..94dfce8e0 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -14,7 +14,6 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) let pageIndex = 42 - let oldVersionSignature = chainVersionSignature(gid: gid, token: "token") let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -26,7 +25,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { let fallbackResult = try await fetchUpdateFallbackPayload( manager: queueingManager, sessionID: sessionID, gid: gid, - pageIndex: pageIndex, oldVersionSignature: oldVersionSignature + pageIndex: pageIndex ) let pageCount = fallbackResult.pageCount let oldCount = pageCount - 5 @@ -61,7 +60,6 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) let pageIndex = 42 - let oldVersionSignature = chainVersionSignature(gid: gid, token: "token") let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -73,7 +71,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { let updateResult = try await fetchUpdateFallbackPayload( manager: immediateManager, sessionID: sessionID, gid: gid, - pageIndex: pageIndex, oldVersionSignature: oldVersionSignature + pageIndex: pageIndex ) let pageCount = updateResult.pageCount @@ -104,7 +102,6 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { // MARK: - Update Fallback Payload Result private struct UpdateFallbackPayloadResult { - let versionSignature: String let pageCount: Int } @@ -121,7 +118,7 @@ private struct DownloadPageContext { private extension DownloadRetryUpdateFallbackTests { func fetchUpdateFallbackPayload( manager: DownloadManager, sessionID: String, gid: String, - pageIndex: Int, oldVersionSignature: String + pageIndex: Int ) async throws -> UpdateFallbackPayloadResult { let stubContent = StubHandlerContent( detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), @@ -141,10 +138,7 @@ private extension DownloadRetryUpdateFallbackTests { let pageCount = fetchedPayload.galleryDetail.pageCount #expect(pageCount > pageIndex) #expect(pageCount > 5) - return UpdateFallbackPayloadResult( - versionSignature: chainVersionSignature(gid: gid, token: "updated-key"), - pageCount: pageCount - ) + return UpdateFallbackPayloadResult(pageCount: pageCount) } func setupImmediateUpdateTestState( diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift index b37812329..70da73837 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -151,8 +151,7 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { let manifest = try sampleManifest( gid: download.gid, title: download.title, - pageCount: 2, - versionSignature: "hash:v1" + pageCount: 2 ) var initialState = DownloadsReducer.State() initialState.downloads = [download] From 088787c8a0fbf46eb580b327be30b9c21618c91f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 07:22:41 +0800 Subject: [PATCH 129/614] Drop failed pages file --- .../Tools/Clients/DownloadClient+Cache.swift | 23 --------- .../Clients/DownloadClient+Execution.swift | 6 ++- .../DownloadClient+ExecutionPerform.swift | 3 -- .../Clients/DownloadClient+Manager.swift | 5 +- .../Clients/DownloadClient+PageDownload.swift | 48 +++++------------ .../DownloadClient+PersistenceNormalize.swift | 29 ----------- .../Clients/DownloadClient+PublicAPI.swift | 4 +- .../DownloadClient+PublicAPIHelpers.swift | 39 +++++--------- .../Clients/DownloadClient+RetryHelpers.swift | 6 +-- .../Clients/DownloadClient+Testing.swift | 9 ++++ EhPanda/App/Tools/Defaults.swift | 1 - .../Tools/Utilities/DownloadFileStorage.swift | 20 -------- .../Models/Persistent/DownloadedGallery.swift | 16 ------ .../DownloadFileStorageStateTests.swift | 51 ------------------- .../DownloadManagerStorageTests.swift | 37 +++++--------- .../DownloadPauseAndReconcileTests.swift | 32 +++++------- .../Download/DownloadRetryPagesTests.swift | 31 ++++------- 17 files changed, 88 insertions(+), 272 deletions(-) delete mode 100644 EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 9afc4cd9d..328e06d98 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -94,29 +94,6 @@ extension DownloadManager { imageURL } - func clearFailedPage( - index: Int, - folderURL: URL - ) throws { - guard let failedSnapshot = try? storage - .readFailedPages(folderURL: folderURL) else { - return - } - let remainingPages = failedSnapshot.pages - .filter { $0.index != index } - if remainingPages.count == failedSnapshot.pages.count { - return - } - if remainingPages.isEmpty { - try? storage.removeFailedPages(folderURL: folderURL) - } else { - try storage.writeFailedPages( - .init(pages: remainingPages), - folderURL: folderURL - ) - } - } - func cachedImageData(for url: URL) async -> Data? { await cachedImageData( for: [url], diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 5a1937218..9249b73be 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -143,11 +143,14 @@ extension DownloadManager { context: FailureContext ) async { let pageError = - error.failedPages.first?.failure.appError ?? .unknown + error.failedPages.first?.error ?? .unknown guard !isCancellationLikeAppError(pageError) else { return } guard !shouldSuppressFailurePersistence(for: context.gid) else { return } + failedPageErrors[context.gid] = Dictionary( + uniqueKeysWithValues: error.failedPages.map { ($0.index, $0) } + ) Logger.error( "Download partially failed.", context: [ @@ -182,6 +185,7 @@ extension DownloadManager { func settleCompletedDownload(gid: String) async { downloadErrors[gid] = nil validationErrors[gid] = nil + failedPageErrors[gid] = nil updatedGalleryIDs.remove(gid) queuedModes[gid] = nil queuedPageSelections[gid] = nil diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 077f77f10..dbbf9625b 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -171,9 +171,6 @@ extension DownloadManager { hashedManifest, folderURL: folderURL ) - try? storage.removeFailedPages( - folderURL: folderURL - ) await cleanupCachedRemoteAssetsAfterSuccessfulDownload( payload: payload, pages: batchResult.pages, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 884d78405..ce3db321e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -36,7 +36,7 @@ actor DownloadManager { struct DownloadBatchResult: Sendable { let pages: [PageResult] - let failedPages: [DownloadFailedPagesSnapshot.Page] + let failedPages: [PageFailure] } enum PageTaskOutcome: Sendable { @@ -67,7 +67,7 @@ actor DownloadManager { } struct PartialDownloadError: Error, Sendable { - let failedPages: [DownloadFailedPagesSnapshot.Page] + let failedPages: [PageFailure] } struct FailureContext: Sendable { @@ -134,6 +134,7 @@ actor DownloadManager { var downloadIndex = [String: DownloadFolderRecord]() var downloadErrors = [String: DownloadFailure]() var validationErrors = [String: DownloadFailure]() + var failedPageErrors = [String: [Int: PageFailure]]() var updatedGalleryIDs = Set() var queuedModes = [String: DownloadStartMode]() var queuedPageSelections = [String: [Int]]() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index daf586085..836fda2e4 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -9,7 +9,7 @@ import Foundation extension DownloadManager { private struct PageDownloadProgress { var results: [PageResult] = [] - var failedPages: [Int: DownloadFailedPagesSnapshot.Page?] = [:] + var failedPages: [Int: PageFailure?] = [:] var completedCount: Int = 0 var pendingResolvedPages: [PageResult] = [] var lastFlushDate: Date = Date() @@ -26,10 +26,8 @@ extension DownloadManager { existingPageRelativePaths: existingPageRelativePaths ) var progress = PageDownloadProgress() - progress.failedPages = (try? storage - .readFailedPages( - folderURL: context.folderURL - ).map) ?? [:] + progress.failedPages = failedPageErrors[context.payload.gallery.gid]? + .mapValues(Optional.some) ?? [:] try await initializePageDownloadState( context: context, @@ -67,8 +65,7 @@ extension DownloadManager { ) return try buildBatchResult( results: progress.results, - failedPages: progress.failedPages, - folderURL: context.folderURL + failedPages: progress.failedPages ) } @@ -98,30 +95,17 @@ extension DownloadManager { private func buildBatchResult( results: [PageResult], - failedPages: [Int: DownloadFailedPagesSnapshot.Page?], - folderURL: URL + failedPages: [Int: PageFailure?] ) throws -> DownloadBatchResult { - let failedSnapshot = DownloadFailedPagesSnapshot( - pages: failedPages.values - .compactMap { $0 } - .filter { - !isCancellationLikeAppError($0.failure.appError) - } - .sorted(by: { $0.index < $1.index }) - ) - if failedSnapshot.pages.isEmpty { - try? storage.removeFailedPages( - folderURL: folderURL - ) - } else { - try storage.writeFailedPages( - failedSnapshot, - folderURL: folderURL - ) - } + let activeFailedPages = failedPages.values + .compactMap { $0 } + .filter { + !isCancellationLikeAppError($0.error) + } + .sorted(by: { $0.index < $1.index }) return .init( pages: results, - failedPages: failedSnapshot.pages + failedPages: activeFailedPages ) } @@ -143,7 +127,7 @@ extension DownloadManager { existingPages: [Int: String], context: PageDownloadContext, results: inout [PageResult], - failedPages: inout [Int: DownloadFailedPagesSnapshot.Page?] + failedPages: inout [Int: PageFailure?] ) { for index in pageIndices { guard let relativePath = existingPages[index] else { @@ -258,11 +242,7 @@ extension DownloadManager { group.cancelAll() return } - progress.failedPages[failure.index] = .init( - index: failure.index, - relativePath: failure.relativePath, - failure: .init(error: failure.error) - ) + progress.failedPages[failure.index] = failure case .cancelled: wasCancelled = true diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 3a396ff65..cd11e7b5a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -33,35 +33,6 @@ extension DownloadManager { return completedFolderExists ? completedFolderURL : nil } - func sanitizedFailedPages( - folderURL: URL - ) -> [Int: DownloadFailedPagesSnapshot.Page] { - guard var snapshot = try? storage - .readFailedPages(folderURL: folderURL) else { - return [:] - } - let filteredPages = snapshot.pages.filter { - !isCancellationLikeAppError($0.failure.appError) - } - guard filteredPages.count != snapshot.pages.count - else { - return snapshot.map - } - - snapshot.pages = filteredPages - if filteredPages.isEmpty { - try? storage.removeFailedPages( - folderURL: folderURL - ) - } else { - try? storage.writeFailedPages( - snapshot, - folderURL: folderURL - ) - } - return snapshot.map - } - func normalizeNeedsAttentionDownloads( _ downloads: [DownloadedGallery] ) async { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 3d73fff10..6c900abe8 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -282,8 +282,8 @@ extension DownloadManager { expectedPageCount: download.pageCount ) } ?? [:] - let failedPages = activeFolderURL - .map(sanitizedFailedPages(folderURL:)) ?? [:] + let failedPages = (failedPageErrors[gid] ?? [:]) + .filter { !isCancellationLikeAppError($0.value.error) } let pages = buildInspectionPages( download: download, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index c73899530..53ee39e89 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -11,7 +11,7 @@ extension DownloadManager { download: DownloadedGallery, activeFolderURL: URL?, existingRelativePaths: [Int: String], - failedPages: [Int: DownloadFailedPagesSnapshot.Page] + failedPages: [Int: PageFailure] ) -> [DownloadPageInspection] { (1...download.pageCount).map { index -> DownloadPageInspection in if let relativePath = existingRelativePaths[index], @@ -30,13 +30,13 @@ extension DownloadManager { } if let failedPage = failedPages[index] { - return .init( - index: index, - status: .failed, - relativePath: failedPage.relativePath, - fileURL: nil, - failure: failedPage.failure - ) + return .init( + index: index, + status: .failed, + relativePath: failedPage.relativePath, + fileURL: nil, + failure: .init(error: failedPage.error) + ) } return .init( @@ -87,25 +87,14 @@ extension DownloadManager { } func clearSelectedFailedPages( + gid: String, selectedPageIndices: [Int], - folderURL: URL ) { - if let failedSnapshot = try? storage.readFailedPages( - folderURL: folderURL - ) { - let remainingPages = failedSnapshot.pages.filter { - !selectedPageIndices.contains($0.index) - } - if remainingPages.isEmpty { - try? storage.removeFailedPages( - folderURL: folderURL - ) - } else { - try? storage.writeFailedPages( - .init(pages: remainingPages), - folderURL: folderURL - ) - } + for index in selectedPageIndices { + failedPageErrors[gid]?[index] = nil + } + if failedPageErrors[gid]?.isEmpty == true { + failedPageErrors[gid] = nil } } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index e0b292933..46bf65c68 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -35,6 +35,7 @@ extension DownloadManager { ) queuedModes[gid] = resolvedMode queuedPageSelections[gid] = nil + failedPageErrors[gid] = nil downloadErrors[gid] = nil validationErrors[gid] = nil await queueStore.enqueue(gid) @@ -83,10 +84,7 @@ extension DownloadManager { selectedPageIndices: [Int], folderURL: URL ) async throws { - clearSelectedFailedPages( - selectedPageIndices: selectedPageIndices, - folderURL: folderURL - ) + clearSelectedFailedPages(gid: gid, selectedPageIndices: selectedPageIndices) queuedModes[gid] = mode queuedPageSelections[gid] = selectedPageIndices downloadErrors[gid] = nil diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 22cb3e2d7..dfb9e2591 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -59,6 +59,15 @@ extension DownloadManager { downloadErrors[gid] = failure } + func testingSetFailedPageErrors( + _ failures: [PageFailure], + gid: String + ) { + failedPageErrors[gid] = Dictionary( + uniqueKeysWithValues: failures.map { ($0.index, $0) } + ) + } + func testingSanitizeLocalFilesIfNeeded( gid: String, clearingLastError: Bool = false diff --git a/EhPanda/App/Tools/Defaults.swift b/EhPanda/App/Tools/Defaults.swift index 84c297ec1..f3b96eb67 100644 --- a/EhPanda/App/Tools/Defaults.swift +++ b/EhPanda/App/Tools/Defaults.swift @@ -67,7 +67,6 @@ struct Defaults { static let downloads = "Downloads" static let downloadPages = "pages" static let downloadManifest = "manifest.json" - static let downloadFailedPages = ".failed-pages.json" } struct Regex { static let tagSuggestion: NSRegularExpression? = try? .init(pattern: "(\\S+:\".+?\"|\".+?\"|\\S+:\\S+|\\S+)") diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 250063b7d..63e06c154 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -65,26 +65,6 @@ struct DownloadFileStorage: Sendable { rootURL.appendingPathComponent(".queue.json") } - func failedPagesURL(folderURL: URL) -> URL { - folderURL.appendingPathComponent(Defaults.FilePath.downloadFailedPages) - } - - func writeFailedPages(_ snapshot: DownloadFailedPagesSnapshot, folderURL: URL) throws { - try writeJSON(snapshot, to: failedPagesURL(folderURL: folderURL)) - } - - func readFailedPages(folderURL: URL) throws -> DownloadFailedPagesSnapshot { - try readJSON(DownloadFailedPagesSnapshot.self, from: failedPagesURL(folderURL: folderURL)) - } - - func removeFailedPages(folderURL: URL) throws { - let url = failedPagesURL(folderURL: folderURL) - try fileManager.operate { - guard $0.fileExists(atPath: url.path) else { return } - try $0.removeItem(at: url) - } - } - func existingPageRelativePaths( folderURL: URL, expectedPageCount: Int diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 660704951..696a2f5b3 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -63,22 +63,6 @@ enum DownloadStartMode: String, Codable, Equatable, Sendable { case repair } -struct DownloadFailedPagesSnapshot: Codable, Equatable, Sendable { - struct Page: Codable, Equatable, Identifiable, Sendable { - var id: Int { index } - - let index: Int - let relativePath: String? - let failure: DownloadFailure - } - - var pages: [Page] - - var map: [Int: Page] { - Dictionary(uniqueKeysWithValues: pages.map { ($0.index, $0) }) - } -} - struct DownloadedGallery: Identifiable, Equatable { var id: String { gid } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift deleted file mode 100644 index 789daaccc..000000000 --- a/EhPandaTests/Tests/Download/DownloadFileStorageStateTests.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// DownloadFileStorageStateTests.swift -// EhPandaTests -// - -import Foundation -import Testing -@testable import EhPanda - -struct DownloadFileStorageStateTests { - @Test - func testWriteReadAndRemoveFailedPagesSnapshot() throws { - let (storage, rootURL) = makeStorage() - defer { try? FileManager.default.removeItem(at: rootURL) } - - try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[123_token] Sample") - try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - - let snapshot = DownloadFailedPagesSnapshot( - pages: [ - .init( - index: 3, - relativePath: "pages/0003.jpg", - failure: .init(code: .networkingFailed, message: "Network Error") - ) - ] - ) - - try storage.writeFailedPages(snapshot, folderURL: folderURL) - #expect(try storage.readFailedPages(folderURL: folderURL) == snapshot) - - try storage.removeFailedPages(folderURL: folderURL) - do { - _ = try storage.readFailedPages(folderURL: folderURL) - Issue.record("Expected readFailedPages to throw after removing the snapshot.") - } catch { - } - } -} - -private extension DownloadFileStorageStateTests { - func makeStorage() -> (DownloadFileStorage, URL) { - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - return ( - DownloadFileStorage(rootURL: rootURL, fileManager: .default), - rootURL - ) - } -} diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 9fa29c3d9..98386cab7 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -455,19 +455,15 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { pageHashes: ["sha256:done", ""] ) ) - let folderURL = storage.folderURL(relativePath: folderRelativePath) - try storage.writeFailedPages( - .init(pages: [ + await manager.testingSetFailedPageErrors( + [ .init( index: 2, relativePath: "460_token_2.jpg", - failure: .init( - code: .networkingFailed, - message: "Network Error" - ) + error: .networkingFailed ) - ]), - folderURL: folderURL + ], + gid: "460" ) let blockingTask = Task { do { @@ -487,9 +483,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(queueStore.gids == ["460"]) #expect(download.displayStatus == .queued) #expect(download.status == .queued) - #expect(FileManager.default.fileExists( - atPath: storage.failedPagesURL(folderURL: folderURL).path - ) == false) } @Test @@ -796,17 +789,15 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { to: folderURL.appendingPathComponent("pages/0001.jpg"), options: .atomic ) - try storage.writeFailedPages( - .init( - pages: [ - .init( - index: 2, - relativePath: "pages/0002.jpg", - failure: .init(code: .networkingFailed, message: "Network Error") - ) - ] - ), - folderURL: folderURL + await manager.testingSetFailedPageErrors( + [ + .init( + index: 2, + relativePath: "pages/0002.jpg", + error: .networkingFailed + ) + ], + gid: gid ) let result = await manager.loadInspection(gid: gid) diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index f57e2b238..733f824ba 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -215,7 +215,19 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { title: "Inspection", pageHashes: ["sha256:done", ""] ) - let folderURL = try setupCancellationFilterTestFolder(storage: storage, gid: gid) + try setupCancellationFilterTestFolder(storage: storage, gid: gid) + await manager.testingSetFailedPageErrors( + [ + .init( + index: 2, + relativePath: "pages/0002.jpg", + error: .fileOperationFailed( + "The operation could not be completed. (Swift.CancellationError error 1.)" + ) + ) + ], + gid: gid + ) let result = await manager.loadInspection(gid: gid) guard case .success(let inspection) = result else { @@ -225,7 +237,6 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { #expect(inspection.pages[0].status == .downloaded) #expect(inspection.pages[1].status == .pending) - #expect((try? storage.readFailedPages(folderURL: folderURL).pages.isEmpty) ?? true) } } @@ -267,11 +278,10 @@ private extension DownloadPauseAndReconcileTests { ) } - @discardableResult func setupCancellationFilterTestFolder( storage: DownloadFileStorage, gid: String - ) throws -> URL { + ) throws { let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Inspection") try FileManager.default.createDirectory( at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), @@ -281,19 +291,5 @@ private extension DownloadPauseAndReconcileTests { to: folderURL.appendingPathComponent("pages/0001.jpg"), options: .atomic ) - try storage.writeFailedPages( - .init(pages: [ - .init( - index: 2, - relativePath: "pages/0002.jpg", - failure: .init( - code: .fileOperationFailed, - message: "The operation could not be completed. (Swift.CancellationError error 1.)" - ) - ) - ]), - folderURL: folderURL - ) - return folderURL } } diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index 720e970c6..8daa3d652 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -18,13 +18,22 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) - let folderURL = try writeManifestFolder( + try writeManifestFolder( storage: storage, gid: gid, title: "Retry Pages", pageHashes: ["sha256:done", ""] ) - try setupRetryPagesFailedPage(storage: storage, folderURL: folderURL) + await manager.testingSetFailedPageErrors( + [ + .init( + index: 2, + relativePath: "pages/0002.jpg", + error: .networkingFailed + ) + ], + gid: gid + ) let blockingTask = Task { _ = try? await Task.sleep(for: .seconds(60)) } defer { blockingTask.cancel() } @@ -41,9 +50,6 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { #expect(stored?.badge == .queued) #expect(stored?.lastError == nil) - #expect(FileManager.default.fileExists( - atPath: storage.failedPagesURL(folderURL: folderURL).path - ) == false) } @Test @@ -121,19 +127,4 @@ private extension DownloadRetryPagesTests { return folderURL } - func setupRetryPagesFailedPage( - storage: DownloadFileStorage, - folderURL: URL - ) throws { - try storage.writeFailedPages( - .init(pages: [ - .init( - index: 2, - relativePath: "pages/0002.jpg", - failure: .init(code: .networkingFailed, message: "Network Error") - ) - ]), - folderURL: folderURL - ) - } } From 77fbb4a481a7d7fd01fce692c3bc4a76a89c5fe9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 07:26:04 +0800 Subject: [PATCH 130/614] Drop sort priority --- .../DownloadedGallery+SupportTypes.swift | 25 ------------------- .../Download/DownloadBadgeSortTests.swift | 8 +++--- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index 7b3e0297a..5396e70c5 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -112,31 +112,6 @@ extension DownloadedGallery { return .inactive } - var sortPriority: Int { - if isQueuedWorkItem { - return 1 - } - - switch status { - case .downloading: - return 0 - case .paused: - return 1 - case .queued: - return 2 - case .partial: - return 3 - case .updateAvailable: - return 4 - case .missingFiles: - return 5 - case .failed: - return 6 - case .completed: - return 7 - } - } - var gallery: Gallery { Gallery( gid: gid, diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index d00cbce8a..ed74519b2 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -75,14 +75,14 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { ) let sortedDownloads = [completedDownload, queuedRedownload].sorted { lhs, rhs in - if lhs.sortPriority != rhs.sortPriority { - return lhs.sortPriority < rhs.sortPriority + if lhs.displayStatus != rhs.displayStatus { + return lhs.displayStatus.rawValue < rhs.displayStatus.rawValue } return (lhs.lastDownloadedAt ?? .distantPast) > (rhs.lastDownloadedAt ?? .distantPast) } - #expect(queuedRedownload.sortPriority == 1) - #expect(completedDownload.sortPriority == 7) + #expect(queuedRedownload.displayStatus == .queued) + #expect(completedDownload.displayStatus == .completed) #expect(sortedDownloads.map(\.gid) == [queuedRedownload.gid, completedDownload.gid]) } From e773c4c4aaac105ddb7820418313fe303524454b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 07:30:08 +0800 Subject: [PATCH 131/614] Drop view cover path --- .../DownloadedGallery+SupportTypes.swift | 18 ------------------ .../Models/Persistent/DownloadedGallery.swift | 4 ---- .../DownloadFeatureTestFactories.swift | 1 - .../DownloadFileStorageHashTests.swift | 1 - .../Download/DownloadFileStorageTests.swift | 1 - .../Download/DownloadFilterAndBadgeTests.swift | 1 - 6 files changed, 26 deletions(-) diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index 5396e70c5..9f2da0c1c 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -35,14 +35,6 @@ extension DownloadedGallery { func resolvedLocalCoverURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL? { let folderURL = resolvedFolderURL(rootURL: rootURL) - if let coverRelativePath, - !coverRelativePath.isEmpty { - let coverURL = folderURL.appendingPathComponent(coverRelativePath) - if isReadableLocalAssetFile(coverURL) { - return coverURL - } - } - return DownloadFileStorage(rootURL: rootURL) .existingCoverRelativePath(folderURL: folderURL) .map { folderURL.appendingPathComponent($0) } @@ -196,16 +188,6 @@ extension DownloadedGallery { } -extension DownloadedGallery { - func isReadableLocalAssetFile(_ url: URL) -> Bool { - guard FileManager.default.fileExists(atPath: url.path) else { return false } - let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) - let isRegularFile = values?.isRegularFile ?? true - let fileSize = values?.fileSize ?? 0 - return isRegularFile && fileSize > 0 - } -} - extension DownloadInspection { var hasDownloadedPages: Bool { pages.contains { $0.status == .downloaded } diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 696a2f5b3..49c22b3cd 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -79,7 +79,6 @@ struct DownloadedGallery: Identifiable, Equatable { let rating: Float let onlineCoverURL: URL? let folderRelativePath: String - let coverRelativePath: String? let status: DownloadStatus let completedPageCount: Int let lastDownloadedAt: Date? @@ -100,7 +99,6 @@ struct DownloadedGallery: Identifiable, Equatable { rating: Float, onlineCoverURL: URL?, folderRelativePath: String, - coverRelativePath: String?, status: DownloadStatus, completedPageCount: Int, lastDownloadedAt: Date?, @@ -120,7 +118,6 @@ struct DownloadedGallery: Identifiable, Equatable { self.rating = rating self.onlineCoverURL = onlineCoverURL self.folderRelativePath = folderRelativePath - self.coverRelativePath = coverRelativePath self.status = status self.completedPageCount = completedPageCount self.lastDownloadedAt = lastDownloadedAt @@ -150,7 +147,6 @@ struct DownloadedGallery: Identifiable, Equatable { rating: manifest.rating, onlineCoverURL: manifest.remoteCoverURL, folderRelativePath: folderRelativePath, - coverRelativePath: nil, status: displayStatus.downloadStatus, completedPageCount: manifest.completedPageCount, lastDownloadedAt: modifiedAt, diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index ce9e30de8..96df8785a 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -84,7 +84,6 @@ extension DownloadFeatureTestCase { rating: 4, onlineCoverURL: URL(string: "https://example.com/cover.jpg"), folderRelativePath: "\(gid) - \(title)", - coverRelativePath: "cover.jpg", status: status, completedPageCount: completedPageCount ?? (status == .completed ? pageCount : 0), lastDownloadedAt: lastDownloadedAt, diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index 78abebf52..903ec1142 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -104,7 +104,6 @@ struct DownloadFileStorageHashTests { rating: 4, onlineCoverURL: URL(string: "https://example.com/cover.jpg"), folderRelativePath: folderRelativePath, - coverRelativePath: "cover.jpg", status: .completed, completedPageCount: 2, lastDownloadedAt: .now, diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 320f98da6..21f43a023 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -402,7 +402,6 @@ private extension DownloadFileStorageTests { rating: 4, onlineCoverURL: URL(string: "https://example.com/cover.jpg"), folderRelativePath: folderRelativePath, - coverRelativePath: "cover.jpg", status: status, completedPageCount: status == .completed ? 2 : 0, lastDownloadedAt: .now, diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 322530e3d..fa5ab7fb6 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -47,7 +47,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { rating: 4, onlineCoverURL: nil, folderRelativePath: "111 - Solo Title", - coverRelativePath: nil, status: .completed, completedPageCount: 1, lastDownloadedAt: .now, From b3d60b5c9b707b1f5749cc028723f3e9afad05e6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 07:37:46 +0800 Subject: [PATCH 132/614] Drop view options --- .../DownloadClient+ExecutionFetch.swift | 7 ++- .../Clients/DownloadClient+Persistence.swift | 16 ++---- .../Models/Persistent/DownloadedGallery.swift | 9 +--- .../DownloadFeatureTestFactories.swift | 11 +++-- .../DownloadFileStorageHashTests.swift | 3 +- .../Download/DownloadFileStorageTests.swift | 3 +- .../DownloadFilterAndBadgeTests.swift | 3 +- .../Tests/Download/DownloadProcessTests.swift | 49 +++++++++++++++++++ .../DownloadedGalleryManifestModelTests.swift | 4 +- 9 files changed, 70 insertions(+), 35 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index e31b9ce70..2911bdca5 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -14,11 +14,12 @@ extension DownloadManager { ) async throws -> DownloadRequestPayload { let galleryURL = download.gallery.galleryURL guard let galleryURL else { throw AppError.notFound } + let options = await downloadOptionsProvider() let detailResponse = try await GalleryDetailRequest( gid: download.gid, galleryURL: galleryURL, urlSession: urlSession, - allowsCellular: download.downloadOptionsSnapshot.allowCellular + allowsCellular: options.allowCellular ) .response() .get() @@ -43,6 +44,7 @@ extension DownloadManager { fetchedData: fetchedData, components: components, mode: mode, + options: options, pageSelection: pageSelection ) } @@ -57,6 +59,7 @@ extension DownloadManager { fetchedData: FetchedGalleryData, components: GalleryComponents, mode: DownloadStartMode, + options: DownloadOptionsSnapshot, pageSelection: [Int]? ) -> DownloadRequestPayload { let download = fetchedData.download @@ -69,7 +72,7 @@ extension DownloadManager { previewConfig: components.previewConfig, host: download.host, versionMetadata: versionMetadata, - options: download.downloadOptionsSnapshot, + options: options, mode: mode, pageSelection: pageSelection.map(Set.init) ) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 2bd87606a..c368fcaab 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -22,11 +22,7 @@ extension DownloadManager { func indexedDownload(gid: String) async -> DownloadedGallery? { guard let record = downloadIndex[gid] else { return nil } - let downloadOptionsSnapshot = await downloadOptionsProvider() - return downloadedGallery( - from: record, - downloadOptionsSnapshot: downloadOptionsSnapshot - ) + return downloadedGallery(from: record) } func indexedDownloads() async -> [DownloadedGallery] { @@ -36,13 +32,9 @@ extension DownloadManager { private func downloads( from records: [DownloadFolderRecord] ) async -> [DownloadedGallery] { - let downloadOptionsSnapshot = await downloadOptionsProvider() return deduplicatedDownloadIndex(from: records).values .map { - downloadedGallery( - from: $0, - downloadOptionsSnapshot: downloadOptionsSnapshot - ) + downloadedGallery(from: $0) } .sorted(by: sortDownloadsByDisplayStatus) } @@ -63,8 +55,7 @@ extension DownloadManager { } private func downloadedGallery( - from record: DownloadFolderRecord, - downloadOptionsSnapshot: DownloadOptionsSnapshot + from record: DownloadFolderRecord ) -> DownloadedGallery { let gid = record.manifest.gid return DownloadedGallery( @@ -72,7 +63,6 @@ extension DownloadManager { folderRelativePath: record.relativePath, modifiedAt: record.modifiedAt, displayStatus: displayStatus(for: record), - downloadOptionsSnapshot: downloadOptionsSnapshot, lastError: validationErrors[gid] ?? downloadErrors[gid] ) } diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 49c22b3cd..901f679d4 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -83,7 +83,6 @@ struct DownloadedGallery: Identifiable, Equatable { let completedPageCount: Int let lastDownloadedAt: Date? let lastError: DownloadFailure? - let downloadOptionsSnapshot: DownloadOptionsSnapshot init( gid: String, @@ -102,8 +101,7 @@ struct DownloadedGallery: Identifiable, Equatable { status: DownloadStatus, completedPageCount: Int, lastDownloadedAt: Date?, - lastError: DownloadFailure?, - downloadOptionsSnapshot: DownloadOptionsSnapshot + lastError: DownloadFailure? ) { self.gid = gid self.host = host @@ -122,7 +120,6 @@ struct DownloadedGallery: Identifiable, Equatable { self.completedPageCount = completedPageCount self.lastDownloadedAt = lastDownloadedAt self.lastError = lastError - self.downloadOptionsSnapshot = downloadOptionsSnapshot } init( @@ -130,7 +127,6 @@ struct DownloadedGallery: Identifiable, Equatable { folderRelativePath: String, modifiedAt: Date?, displayStatus: DownloadDisplayStatus, - downloadOptionsSnapshot: DownloadOptionsSnapshot, lastError: DownloadFailure? = nil ) { self.init( @@ -150,8 +146,7 @@ struct DownloadedGallery: Identifiable, Equatable { status: displayStatus.downloadStatus, completedPageCount: manifest.completedPageCount, lastDownloadedAt: modifiedAt, - lastError: lastError, - downloadOptionsSnapshot: downloadOptionsSnapshot + lastError: lastError ) } } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 96df8785a..2b760f4d7 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -87,8 +87,7 @@ extension DownloadFeatureTestCase { status: status, completedPageCount: completedPageCount ?? (status == .completed ? pageCount : 0), lastDownloadedAt: lastDownloadedAt, - lastError: lastError, - downloadOptionsSnapshot: DownloadOptionsSnapshot() + lastError: lastError ) } @@ -193,7 +192,10 @@ struct StubRouteContext: Sendable { extension DownloadFeatureTestCase { func makeStubbedDownloadManager( rootURL: URL, - sessionID: String + sessionID: String, + downloadOptionsProvider: @escaping @Sendable () async -> DownloadOptionsSnapshot = { + DownloadOptionsSnapshot() + } ) -> (DownloadFileStorage, DownloadManager) { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] @@ -205,7 +207,8 @@ extension DownloadFeatureTestCase { ) let manager = DownloadManager( storage: storage, - urlSession: URLSession(configuration: configuration) + urlSession: URLSession(configuration: configuration), + downloadOptionsProvider: downloadOptionsProvider ) return (storage, manager) } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index 903ec1142..22e46881a 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -107,8 +107,7 @@ struct DownloadFileStorageHashTests { status: .completed, completedPageCount: 2, lastDownloadedAt: .now, - lastError: nil, - downloadOptionsSnapshot: DownloadOptionsSnapshot() + lastError: nil ) } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 21f43a023..890be887a 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -405,8 +405,7 @@ private extension DownloadFileStorageTests { status: status, completedPageCount: status == .completed ? 2 : 0, lastDownloadedAt: .now, - lastError: nil, - downloadOptionsSnapshot: DownloadOptionsSnapshot() + lastError: nil ) } diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index fa5ab7fb6..865ab4b79 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -50,8 +50,7 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { status: .completed, completedPageCount: 1, lastDownloadedAt: .now, - lastError: nil, - downloadOptionsSnapshot: DownloadOptionsSnapshot() + lastError: nil ) #expect(download.searchableText == ["Solo Title", Category.doujinshi.value].joined(separator: " ")) diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index db0a8b4ae..deb135312 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -105,6 +105,55 @@ struct DownloadProcessTests: DownloadFeatureTestCase { ) ) } + + @Test + func testFetchLatestPayloadUsesLiveDownloadOptionsProvider() async throws { + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 403) + let pageIndex = 42 + let options = DownloadOptionsSnapshot( + threadLimit: 3, + allowCellular: false, + autoRetryFailedPages: false + ) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (_, manager) = makeStubbedDownloadManager( + rootURL: rootURL, + sessionID: sessionID, + downloadOptionsProvider: { options } + ) + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let stubContent = StubHandlerContent( + detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), + mpvHTML: try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html"), + metadataResponse: try makeMetadataResponseData(gid: gid) + ) + installDownloadStubHandler( + sessionID: sessionID, + gid: gid, + pageIndex: pageIndex, + content: stubContent + ) + + let download = sampleDownload( + gid: gid, + title: "Options Gallery", + status: .partial, + pageCount: 156, + completedPageCount: 155 + ) + let payload = try await manager.testingFetchLatestPayload( + for: download, + mode: .redownload, + pageSelection: [pageIndex] + ) + + #expect(payload.options == options) + } } private actor FailurePersistenceGate { diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index df9a2ca14..a6bc532ae 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -43,8 +43,7 @@ struct DownloadedGalleryManifestModelTests { manifest: manifest, folderRelativePath: "[123_token] Sample", modifiedAt: modifiedAt, - displayStatus: .queued, - downloadOptionsSnapshot: .init(threadLimit: 3) + displayStatus: .queued ) #expect(download.gid == "123") @@ -53,7 +52,6 @@ struct DownloadedGalleryManifestModelTests { #expect(download.onlineCoverURL == manifest.remoteCoverURL) #expect(download.completedPageCount == 2) #expect(download.lastDownloadedAt == modifiedAt) - #expect(download.downloadOptionsSnapshot.threadLimit == 3) } } From 1e7c2f6708cccfc55affcf1f939b5f893e199174 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 07:45:53 +0800 Subject: [PATCH 133/614] Drop folder path --- .../Tools/Clients/DownloadClient+Execution.swift | 9 +++++---- .../Clients/DownloadClient+Persistence.swift | 2 +- .../Tools/Clients/DownloadClient+PublicAPI.swift | 2 +- .../DownloadFileStorage+Operations.swift | 8 ++++++++ .../DownloadedGallery+SupportTypes.swift | 14 ++++---------- .../Models/Persistent/DownloadedGallery.swift | 10 +++++----- .../Download/DownloadFeatureTestFactories.swift | 3 ++- .../Download/DownloadFileStorageHashTests.swift | 8 ++++---- .../Download/DownloadFileStorageTests.swift | 16 ++++++++-------- .../Download/DownloadFilterAndBadgeTests.swift | 2 +- .../Download/DownloadManagerStorageTests.swift | 2 +- .../Tests/Download/DownloadProcessTests.swift | 2 +- .../DownloadedGalleryManifestModelTests.swift | 4 ++-- 13 files changed, 43 insertions(+), 39 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 9249b73be..8cfc39218 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -57,10 +57,11 @@ extension DownloadManager { result: ProcessDownloadResult ) async { await settleCompletedDownload(gid: gid) - if download.folderRelativePath != result.folderRelativePath { - try? storage.removeFolder( - relativePath: download.folderRelativePath - ) + let completedFolderURL = storage.folderURL( + relativePath: result.folderRelativePath + ) + if download.folderURL != completedFolderURL { + try? storage.removeFolder(at: download.folderURL) } await notifyObservers() } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index c368fcaab..23689f8cb 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -60,7 +60,7 @@ extension DownloadManager { let gid = record.manifest.gid return DownloadedGallery( manifest: record.manifest, - folderRelativePath: record.relativePath, + folderURL: record.folderURL, modifiedAt: record.modifiedAt, displayStatus: displayStatus(for: record), lastError: validationErrors[gid] ?? downloadErrors[gid] diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 6c900abe8..dd8c00b85 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -165,7 +165,7 @@ extension DownloadManager { return .failure(.notFound) } do { - try storage.removeFolder(relativePath: download.folderRelativePath) + try storage.removeFolder(at: download.folderURL) await notifyObservers() await scheduleNextIfNeeded() return .success(()) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index 66a8683c4..b33c19080 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -173,6 +173,14 @@ extension DownloadFileStorage { func removeFolder(relativePath: String) throws { let targetURL = folderURL(relativePath: relativePath) + try removeFolder(at: targetURL) + } + + func removeFolder(at folderURL: URL) throws { + let targetURL = folderURL.standardizedFileURL + guard targetURL.path.hasPrefix(rootURL.standardizedFileURL.path + "/") else { + throw AppError.fileOperationFailed(targetURL.path) + } try fileManager.operate { guard $0.fileExists(atPath: targetURL.path) else { return } try $0.removeItem(at: targetURL) diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index 9f2da0c1c..f6403e074 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -24,17 +24,15 @@ extension DownloadedGallery { .joined(separator: " ") } - func resolvedFolderURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL { - rootURL.appendingPathComponent(folderRelativePath, isDirectory: true) + func resolvedFolderURL(rootURL _: URL = FileUtil.downloadsDirectoryURL) -> URL { + folderURL } - func resolvedManifestURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL { - resolvedFolderURL(rootURL: rootURL) - .appendingPathComponent(Defaults.FilePath.downloadManifest) + func resolvedManifestURL(rootURL _: URL = FileUtil.downloadsDirectoryURL) -> URL { + folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) } func resolvedLocalCoverURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL? { - let folderURL = resolvedFolderURL(rootURL: rootURL) return DownloadFileStorage(rootURL: rootURL) .existingCoverRelativePath(folderURL: folderURL) .map { folderURL.appendingPathComponent($0) } @@ -45,10 +43,6 @@ extension DownloadedGallery { ?? onlineCoverURL } - var folderURL: URL { - resolvedFolderURL() - } - var manifestURL: URL { resolvedManifestURL() } diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 901f679d4..3654afe2d 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -78,7 +78,7 @@ struct DownloadedGallery: Identifiable, Equatable { let postedDate: Date let rating: Float let onlineCoverURL: URL? - let folderRelativePath: String + let folderURL: URL let status: DownloadStatus let completedPageCount: Int let lastDownloadedAt: Date? @@ -97,7 +97,7 @@ struct DownloadedGallery: Identifiable, Equatable { postedDate: Date, rating: Float, onlineCoverURL: URL?, - folderRelativePath: String, + folderURL: URL, status: DownloadStatus, completedPageCount: Int, lastDownloadedAt: Date?, @@ -115,7 +115,7 @@ struct DownloadedGallery: Identifiable, Equatable { self.postedDate = postedDate self.rating = rating self.onlineCoverURL = onlineCoverURL - self.folderRelativePath = folderRelativePath + self.folderURL = folderURL self.status = status self.completedPageCount = completedPageCount self.lastDownloadedAt = lastDownloadedAt @@ -124,7 +124,7 @@ struct DownloadedGallery: Identifiable, Equatable { init( manifest: DownloadManifest, - folderRelativePath: String, + folderURL: URL, modifiedAt: Date?, displayStatus: DownloadDisplayStatus, lastError: DownloadFailure? = nil @@ -142,7 +142,7 @@ struct DownloadedGallery: Identifiable, Equatable { postedDate: manifest.postedDate, rating: manifest.rating, onlineCoverURL: manifest.remoteCoverURL, - folderRelativePath: folderRelativePath, + folderURL: folderURL, status: displayStatus.downloadStatus, completedPageCount: manifest.completedPageCount, lastDownloadedAt: modifiedAt, diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 2b760f4d7..224930d1a 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -83,7 +83,8 @@ extension DownloadFeatureTestCase { postedDate: .now, rating: 4, onlineCoverURL: URL(string: "https://example.com/cover.jpg"), - folderRelativePath: "\(gid) - \(title)", + folderURL: FileUtil.downloadsDirectoryURL + .appendingPathComponent("\(gid) - \(title)", isDirectory: true), status: status, completedPageCount: completedPageCount ?? (status == .completed ? pageCount : 0), lastDownloadedAt: lastDownloadedAt, diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index 22e46881a..252e48192 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -58,8 +58,8 @@ struct DownloadFileStorageHashTests { storage: DownloadFileStorage ) throws -> (DownloadedGallery, URL) { try storage.ensureRootDirectory() - let download = sampleDownload(folderRelativePath: "123 - Sample") - let folderURL = storage.folderURL(relativePath: download.folderRelativePath) + let folderURL = storage.folderURL(relativePath: "123 - Sample") + let download = sampleDownload(folderURL: folderURL) try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) try FileManager.default.createDirectory( at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), @@ -89,7 +89,7 @@ struct DownloadFileStorageHashTests { ) } - private func sampleDownload(folderRelativePath: String) -> DownloadedGallery { + private func sampleDownload(folderURL: URL) -> DownloadedGallery { DownloadedGallery( gid: "123", host: .ehentai, @@ -103,7 +103,7 @@ struct DownloadFileStorageHashTests { postedDate: .now, rating: 4, onlineCoverURL: URL(string: "https://example.com/cover.jpg"), - folderRelativePath: folderRelativePath, + folderURL: folderURL, status: .completed, completedPageCount: 2, lastDownloadedAt: .now, diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 890be887a..29d6213c9 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -14,8 +14,8 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let download = sampleDownload(folderRelativePath: "123 - Sample") - let folderURL = storage.folderURL(relativePath: download.folderRelativePath) + let folderURL = storage.folderURL(relativePath: "123 - Sample") + let download = sampleDownload(folderURL: folderURL) try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) try FileManager.default.createDirectory( at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), @@ -62,8 +62,8 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let download = sampleDownload(folderRelativePath: "123 - Sample") - let folderURL = storage.folderURL(relativePath: download.folderRelativePath) + let folderURL = storage.folderURL(relativePath: "123 - Sample") + let download = sampleDownload(folderURL: folderURL) try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) try FileManager.default.createDirectory( at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), @@ -94,8 +94,8 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let download = sampleDownload(folderRelativePath: "123 - Sample") - let folderURL = storage.folderURL(relativePath: download.folderRelativePath) + let folderURL = storage.folderURL(relativePath: "123 - Sample") + let download = sampleDownload(folderURL: folderURL) try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) try FileManager.default.createDirectory( at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), @@ -386,7 +386,7 @@ private extension DownloadFileStorageTests { func sampleDownload( status: DownloadStatus = .completed, - folderRelativePath: String + folderURL: URL ) -> DownloadedGallery { DownloadedGallery( gid: "123", @@ -401,7 +401,7 @@ private extension DownloadFileStorageTests { postedDate: .now, rating: 4, onlineCoverURL: URL(string: "https://example.com/cover.jpg"), - folderRelativePath: folderRelativePath, + folderURL: folderURL, status: status, completedPageCount: status == .completed ? 2 : 0, lastDownloadedAt: .now, diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 865ab4b79..c43ef99f4 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -46,7 +46,7 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { postedDate: .now, rating: 4, onlineCoverURL: nil, - folderRelativePath: "111 - Solo Title", + folderURL: URL(fileURLWithPath: "/tmp/111 - Solo Title", isDirectory: true), status: .completed, completedPageCount: 1, lastDownloadedAt: .now, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 98386cab7..ec0471f35 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -114,7 +114,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(downloads.map(\.gid) == ["500"]) let download = try #require(downloads.first) #expect(download.title == "New") - #expect(download.folderRelativePath == "[500_token] New") + #expect(download.folderURL == storage.folderURL(relativePath: "[500_token] New")) #expect(download.lastDownloadedAt == newerDate) #expect((await manager.indexedDownload(gid: "500")) == download) } diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index deb135312..f2a47bada 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -295,7 +295,7 @@ private extension DownloadProcessTests { #expect(unwrapped.pageCount == context.updatedPageCount) #expect(unwrapped.completedPageCount == context.updatedPageCount) - let completedFolderURL = storage.folderURL(relativePath: unwrapped.folderRelativePath) + let completedFolderURL = unwrapped.folderURL let manifest = try storage.readManifest(folderURL: completedFolderURL) #expect(manifest.pageCount == context.updatedPageCount) #expect(manifest.pages.count == context.updatedPageCount) diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index a6bc532ae..98f538db6 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -41,13 +41,13 @@ struct DownloadedGalleryManifestModelTests { let download = DownloadedGallery( manifest: manifest, - folderRelativePath: "[123_token] Sample", + folderURL: URL(fileURLWithPath: "/tmp/[123_token] Sample", isDirectory: true), modifiedAt: modifiedAt, displayStatus: .queued ) #expect(download.gid == "123") - #expect(download.folderRelativePath == "[123_token] Sample") + #expect(download.folderURL.lastPathComponent == "[123_token] Sample") #expect(download.status == .queued) #expect(download.onlineCoverURL == manifest.remoteCoverURL) #expect(download.completedPageCount == 2) From 422d118e7f87ae1b1f11046cbb36d4875f8e0cc9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 08:15:42 +0800 Subject: [PATCH 134/614] Drop status enum --- .../DownloadClient+PersistenceHelpers.swift | 48 ++++-------- .../DownloadClient+PersistenceNormalize.swift | 2 +- .../Clients/DownloadClient+PublicAPI.swift | 10 +-- .../Clients/DownloadClient+Scheduling.swift | 12 +-- .../DownloadClient+SchedulingHelpers.swift | 23 ++---- .../DownloadedGallery+SupportTypes.swift | 78 ++++++++----------- .../Models/Persistent/DownloadedGallery.swift | 38 +-------- .../Downloads/DownloadInspectorReducer.swift | 6 +- .../Downloads/DownloadsView+Subviews.swift | 4 +- EhPanda/View/Downloads/DownloadsView.swift | 10 +-- .../DownloadEnqueueManifestTests.swift | 2 +- .../DownloadFeatureTestFactories.swift | 59 +++++++++++++- .../DownloadFileStorageHashTests.swift | 2 +- .../Download/DownloadFileStorageTests.swift | 6 +- .../DownloadFilterAndBadgeTests.swift | 2 +- .../DownloadManagerCaptureTests.swift | 2 +- .../DownloadManagerStorageTests.swift | 20 ++--- .../DownloadPauseAndReconcileTests.swift | 8 +- .../Download/DownloadProcessCacheTests.swift | 2 +- .../Tests/Download/DownloadProcessTests.swift | 4 +- .../Download/DownloadRetryPagesTests.swift | 4 +- .../DownloadRetryUpdateFallbackTests.swift | 4 +- .../DownloadVersionSignatureTests.swift | 6 +- .../DownloadedGalleryManifestModelTests.swift | 2 +- 24 files changed, 166 insertions(+), 188 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index c234c7fa4..896613309 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -75,14 +75,10 @@ extension DownloadManager { private struct SanitizeUpdateResult { let needsUpdate: Bool - let status: DownloadStatus - let completedPageCount: Int let lastError: DownloadFailure? } private struct MutableSanitizeState { - var status: DownloadStatus - var completedPageCount: Int var lastError: DownloadFailure? var needsUpdate: Bool } @@ -92,8 +88,6 @@ extension DownloadManager { clearingLastError: Bool ) -> SanitizeUpdateResult { var state = MutableSanitizeState( - status: download.status, - completedPageCount: download.completedPageCount, lastError: download.lastError, needsUpdate: false ) @@ -104,8 +98,6 @@ extension DownloadManager { ) return SanitizeUpdateResult( needsUpdate: state.needsUpdate, - status: state.status, - completedPageCount: state.completedPageCount, lastError: state.lastError ) } @@ -115,39 +107,28 @@ extension DownloadManager { clearingLastError: Bool, state: inout MutableSanitizeState ) { - if [.completed, .updateAvailable, .missingFiles] - .contains(download.status) { + if clearingLastError { + if state.lastError != nil { + state.lastError = nil + state.needsUpdate = true + } + return + } + + let shouldValidateFiles = + [.completed, .updateAvailable].contains(download.displayStatus) + || download.lastError?.code == .fileOperationFailed + if shouldValidateFiles { let validation = storage .validate(download: download) - let completedPageCount = - validatedCompletedPageCount(download) switch validation { case .valid: - let expectedStatus: DownloadStatus = - download.hasUpdate - ? .updateAvailable : .completed - if state.status != expectedStatus { - state.status = expectedStatus - state.needsUpdate = true - } - if state.completedPageCount != completedPageCount { - state.completedPageCount = completedPageCount - state.needsUpdate = true - } - if clearingLastError || state.lastError != nil { + if state.lastError != nil { state.lastError = nil state.needsUpdate = true } case .missingFiles(let message): - if state.status != .missingFiles { - state.status = .missingFiles - state.needsUpdate = true - } - if state.completedPageCount != completedPageCount { - state.completedPageCount = completedPageCount - state.needsUpdate = true - } let failure = DownloadFailure( code: .fileOperationFailed, message: message @@ -157,9 +138,6 @@ extension DownloadManager { state.needsUpdate = true } } - } else if clearingLastError, state.lastError != nil { - state.lastError = nil - state.needsUpdate = true } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index cd11e7b5a..2839764dd 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -41,7 +41,7 @@ extension DownloadManager { download.lastError.map { isCancellationLikeAppError($0.appError) } ?? false - guard download.status == .failed + guard download.displayStatus == .error || shouldClearCancellationError else { continue } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index dd8c00b85..9540fda3e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -54,7 +54,7 @@ extension DownloadManager { guard downloadIndex[gid] != nil else { return download.badge } - guard [.completed, .updateAvailable].contains(download.status) else { + guard [.completed, .updateAvailable].contains(download.displayStatus) else { return download.badge } @@ -133,12 +133,12 @@ extension DownloadManager { return await cancelQueuedWorkItem(download, mode: queuedMode) } - switch download.status { - case .queued, .downloading: + switch download.displayStatus { + case .queued, .active: return await pause(gid: gid) - case .paused: + case .inactive: return await resume(gid: gid) - case .partial, .completed, .failed, .updateAvailable, .missingFiles: + case .completed, .error, .updateAvailable: return .failure(.unknown) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 64f144e27..1055a3afd 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -87,8 +87,8 @@ extension DownloadManager { downloads .filter(isSchedulableDownload) .sorted { lhs, rhs in - let lhsIsDownloading = lhs.status == .downloading - let rhsIsDownloading = rhs.status == .downloading + let lhsIsDownloading = lhs.displayStatus == .active + let rhsIsDownloading = rhs.displayStatus == .active if lhsIsDownloading != rhsIsDownloading { return lhsIsDownloading } @@ -106,11 +106,11 @@ extension DownloadManager { } func shouldSchedule(download: DownloadedGallery) -> Bool { - if download.status == .downloading || download.isQueuedWorkItem { + if download.displayStatus == .active || download.isQueuedWorkItem { return true } - guard download.status == .partial else { + guard download.displayStatus == .inactive, download.isIncomplete else { return false } @@ -147,8 +147,8 @@ extension DownloadManager { else { return .failure(.notFound) } - guard [.queued, .downloading] - .contains(currentDownload.status) + guard [.queued, .active] + .contains(currentDownload.displayStatus) else { await notifyObservers() await scheduleNextIfNeeded() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index f8f3e1cfb..e82330cef 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -16,29 +16,27 @@ extension DownloadManager { requestedMode: mode ) } - switch download.status { - case .missingFiles: + switch download.displayStatus { + case .error where download.lastError?.code == .fileOperationFailed: return effectiveRetryMode( for: download, requestedMode: .repair ) case .updateAvailable: return .update - case .partial: + case .inactive: return resumeMode(for: download) case .completed: return effectiveRetryMode( for: download, requestedMode: .redownload ) - case .failed: + case .error: return effectiveRetryMode( for: download, requestedMode: initialOrRedownloadMode(for: download) ) - case .paused: - return resumeMode(for: download) - case .queued, .downloading: + case .queued, .active: return effectiveRetryMode( for: download, requestedMode: initialOrRedownloadMode(for: download) @@ -52,7 +50,7 @@ extension DownloadManager { if download.hasUpdate { return .update } - if download.status == .partial { + if download.displayStatus == .inactive, download.isIncomplete { return effectiveRetryMode( for: download, requestedMode: .redownload @@ -79,13 +77,4 @@ extension DownloadManager { } return .update } - - nonisolated func fallbackStatus( - for download: DownloadedGallery, - mode: DownloadStartMode - ) -> DownloadStatus { - let shouldKeepUpdateBadge = mode == .update - || download.status == .updateAvailable - return shouldKeepUpdateBadge ? .updateAvailable : .completed - } } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index f6403e074..2a22a99fb 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -56,46 +56,27 @@ extension DownloadedGallery { } var badge: DownloadBadge { - if isQueuedWorkItem { - return .queued - } - switch status { + switch displayStatus { + case .active: + return .downloading(completedPageCount, pageCount) case .queued: return .queued - case .downloading: - return .downloading(completedPageCount, pageCount) - case .paused: + case .inactive: return .paused(completedPageCount, pageCount) - case .partial: - return .partial(completedPageCount, pageCount) - case .completed: - return .downloaded - case .failed: - return .failed case .updateAvailable: return .updateAvailable - case .missingFiles: - return .missingFiles - } - } - - var displayStatus: DownloadDisplayStatus { - if status == .updateAvailable { - return .updateAvailable - } - if status == .completed { - return .completed - } - if status == .downloading { - return .active - } - if isQueuedWorkItem { - return .queued - } - if lastError != nil || [.failed, .missingFiles].contains(status) { - return .error + case .error: + if completedPageCount > 0, completedPageCount < pageCount { + return .partial(completedPageCount, pageCount) + } + if lastError?.code == .fileOperationFailed, + completedPageCount == 0 { + return .missingFiles + } + return .failed + case .completed: + return .downloaded } - return .inactive } var gallery: Gallery { @@ -118,15 +99,16 @@ extension DownloadedGallery { } var canRetry: Bool { - [.partial, .failed, .missingFiles].contains(status) + displayStatus == .error } var canValidateImageData: Bool { - [.completed, .updateAvailable, .missingFiles].contains(status) + [.completed, .updateAvailable].contains(displayStatus) + || lastError?.code == .fileOperationFailed } var canPauseOrResume: Bool { - [.downloading, .paused].contains(status) + [.active, .inactive].contains(displayStatus) } var canTogglePause: Bool { @@ -138,27 +120,31 @@ extension DownloadedGallery { } var canCancelFromDetailAction: Bool { - isPendingQueue || canPauseOrResume || [.partial, .completed].contains(status) + isPendingQueue || canPauseOrResume || displayStatus == .completed } var canTriggerUpdate: Bool { guard !isQueuedWorkItem, !canPauseOrResume else { return false } - return status == .updateAvailable + return displayStatus == .updateAvailable } var isQueuedWorkItem: Bool { - status == .queued + displayStatus == .queued } var hasUpdate: Bool { - status == .updateAvailable + displayStatus == .updateAvailable + } + + var isIncomplete: Bool { + completedPageCount < pageCount } func needsInterruptedDownloadNormalization( activeGalleryID: String?, hasActiveTask: Bool ) -> Bool { - status == .downloading && !(hasActiveTask && activeGalleryID == gid) + displayStatus == .active && !(hasActiveTask && activeGalleryID == gid) } func matches(filter: DownloadListFilter) -> Bool { @@ -170,13 +156,13 @@ extension DownloadedGallery { case .all: return true case .active: - return [.downloading, .paused].contains(status) + return [.active, .inactive].contains(displayStatus) case .completed: - return status == .completed + return displayStatus == .completed case .failed: - return [.partial, .failed, .missingFiles].contains(status) + return displayStatus == .error case .update: - return status == .updateAvailable + return displayStatus == .updateAvailable } } diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 3654afe2d..2b526c53b 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -45,17 +45,6 @@ struct DownloadOptionsSnapshot: Codable, Equatable, Sendable { } } -enum DownloadStatus: String, Codable, Equatable, CaseIterable, Sendable { - case queued - case downloading - case paused - case partial - case completed - case failed - case updateAvailable - case missingFiles -} - enum DownloadStartMode: String, Codable, Equatable, Sendable { case initial case update @@ -79,7 +68,7 @@ struct DownloadedGallery: Identifiable, Equatable { let rating: Float let onlineCoverURL: URL? let folderURL: URL - let status: DownloadStatus + let displayStatus: DownloadDisplayStatus let completedPageCount: Int let lastDownloadedAt: Date? let lastError: DownloadFailure? @@ -98,7 +87,7 @@ struct DownloadedGallery: Identifiable, Equatable { rating: Float, onlineCoverURL: URL?, folderURL: URL, - status: DownloadStatus, + displayStatus: DownloadDisplayStatus, completedPageCount: Int, lastDownloadedAt: Date?, lastError: DownloadFailure? @@ -116,7 +105,7 @@ struct DownloadedGallery: Identifiable, Equatable { self.rating = rating self.onlineCoverURL = onlineCoverURL self.folderURL = folderURL - self.status = status + self.displayStatus = displayStatus self.completedPageCount = completedPageCount self.lastDownloadedAt = lastDownloadedAt self.lastError = lastError @@ -143,29 +132,10 @@ struct DownloadedGallery: Identifiable, Equatable { rating: manifest.rating, onlineCoverURL: manifest.remoteCoverURL, folderURL: folderURL, - status: displayStatus.downloadStatus, + displayStatus: displayStatus, completedPageCount: manifest.completedPageCount, lastDownloadedAt: modifiedAt, lastError: lastError ) } } - -private extension DownloadDisplayStatus { - var downloadStatus: DownloadStatus { - switch self { - case .active: - return .downloading - case .queued: - return .queued - case .updateAvailable: - return .updateAvailable - case .error: - return .failed - case .inactive: - return .paused - case .completed: - return .completed - } - } -} diff --git a/EhPanda/View/Downloads/DownloadInspectorReducer.swift b/EhPanda/View/Downloads/DownloadInspectorReducer.swift index 5b7f07169..0891c9879 100644 --- a/EhPanda/View/Downloads/DownloadInspectorReducer.swift +++ b/EhPanda/View/Downloads/DownloadInspectorReducer.swift @@ -276,7 +276,11 @@ extension DownloadInspectorReducer.State { func shouldKeepRetryPending(for download: DownloadedGallery) -> Bool { download.canPauseOrResume || download.isPendingQueue - || (download.status == .partial && download.lastError == nil) + || ( + [.inactive, .error].contains(download.displayStatus) + && download.isIncomplete + && download.lastError == nil + ) } func overlayRetryingPages(in inspection: DownloadInspection) -> DownloadInspection { diff --git a/EhPanda/View/Downloads/DownloadsView+Subviews.swift b/EhPanda/View/Downloads/DownloadsView+Subviews.swift index 51609f2ca..926454925 100644 --- a/EhPanda/View/Downloads/DownloadsView+Subviews.swift +++ b/EhPanda/View/Downloads/DownloadsView+Subviews.swift @@ -278,13 +278,13 @@ private extension DownloadPageStatus { private extension DownloadedGallery { var inspectorPauseResumeTitle: String { - status == .paused + displayStatus == .inactive ? L10n.Localizable.DownloadsView.Swipe.Button.resume : L10n.Localizable.DownloadsView.Swipe.Button.pause } var inspectorPauseResumeSymbol: SFSymbol { - status == .paused ? .playFill : .pauseFill + displayStatus == .inactive ? .playFill : .pauseFill } } diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift index d2f366826..4b1080527 100644 --- a/EhPanda/View/Downloads/DownloadsView.swift +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -201,15 +201,15 @@ private extension DownloadsView { store.send(.toggleDownloadPause(download.gid)) } label: { Label( - download.status == .paused + download.displayStatus == .inactive ? L10n.Localizable.DownloadsView.Swipe.Button.resume : L10n.Localizable.DownloadsView.Swipe.Button.pause, - systemImage: download.status == .paused + systemImage: download.displayStatus == .inactive ? "play.fill" : "pause.fill" ) } - .tint(download.status == .paused ? .green : .indigo) + .tint(download.displayStatus == .inactive ? .green : .indigo) } Button(role: .destructive) { @@ -259,10 +259,10 @@ private extension DownloadsView { store.send(.toggleDownloadPause(download.gid)) } label: { Label( - download.status == .paused + download.displayStatus == .inactive ? L10n.Localizable.DownloadsView.Swipe.Button.resume : L10n.Localizable.DownloadsView.Swipe.Button.pause, - systemImage: download.status == .paused + systemImage: download.displayStatus == .inactive ? "play.fill" : "pause.fill" ) diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index ff4e4b018..be61eeed3 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -70,7 +70,7 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { #expect(manifestObject["downloadOptions"] == nil) let queuedDownload = await manager.testingFetchDownload(gid: gallery.gid) - #expect(queuedDownload?.status == .queued) + #expect(queuedDownload?.displayStatus == .queued) #expect(queuedDownload?.onlineCoverURL == detail.coverURL) #expect(queuedDownload?.pageCount == detail.pageCount) } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 224930d1a..f350dcc1f 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -10,6 +10,56 @@ import Testing // MARK: - Sample Data Factories & CoreData Helpers +enum DownloadFixtureStatus { + case queued + case downloading + case paused + case partial + case completed + case failed + case updateAvailable + case missingFiles + + var displayStatus: DownloadDisplayStatus { + switch self { + case .queued: + return .queued + case .downloading: + return .active + case .paused: + return .inactive + case .partial: + return .error + case .completed: + return .completed + case .failed, .missingFiles: + return .error + case .updateAvailable: + return .updateAvailable + } + } + + var defaultLastError: DownloadFailure? { + switch self { + case .failed: + return .init(code: .networkingFailed, message: "Network Error") + case .missingFiles: + return .init(code: .fileOperationFailed, message: "Page 2 is missing.") + case .queued, .downloading, .paused, .partial, .completed, .updateAvailable: + return nil + } + } + + func defaultCompletedPageCount(pageCount: Int) -> Int { + switch self { + case .completed, .updateAvailable: + return pageCount + case .queued, .downloading, .paused, .partial, .failed, .missingFiles: + return 0 + } + } +} + extension DownloadFeatureTestCase { func sampleManifest( gid: String, @@ -63,7 +113,7 @@ extension DownloadFeatureTestCase { func sampleDownload( gid: String, title: String, - status: DownloadStatus, + status: DownloadFixtureStatus, category: EhPanda.Category = .doujinshi, pageCount: Int = 12, completedPageCount: Int? = nil, @@ -85,10 +135,11 @@ extension DownloadFeatureTestCase { onlineCoverURL: URL(string: "https://example.com/cover.jpg"), folderURL: FileUtil.downloadsDirectoryURL .appendingPathComponent("\(gid) - \(title)", isDirectory: true), - status: status, - completedPageCount: completedPageCount ?? (status == .completed ? pageCount : 0), + displayStatus: status.displayStatus, + completedPageCount: completedPageCount + ?? status.defaultCompletedPageCount(pageCount: pageCount), lastDownloadedAt: lastDownloadedAt, - lastError: lastError + lastError: lastError ?? status.defaultLastError ) } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index 252e48192..95fad9da4 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -104,7 +104,7 @@ struct DownloadFileStorageHashTests { rating: 4, onlineCoverURL: URL(string: "https://example.com/cover.jpg"), folderURL: folderURL, - status: .completed, + displayStatus: .completed, completedPageCount: 2, lastDownloadedAt: .now, lastError: nil diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 29d6213c9..a89564c4d 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -385,7 +385,7 @@ private extension DownloadFileStorageTests { } func sampleDownload( - status: DownloadStatus = .completed, + displayStatus: DownloadDisplayStatus = .completed, folderURL: URL ) -> DownloadedGallery { DownloadedGallery( @@ -402,8 +402,8 @@ private extension DownloadFileStorageTests { rating: 4, onlineCoverURL: URL(string: "https://example.com/cover.jpg"), folderURL: folderURL, - status: status, - completedPageCount: status == .completed ? 2 : 0, + displayStatus: displayStatus, + completedPageCount: displayStatus == .completed ? 2 : 0, lastDownloadedAt: .now, lastError: nil ) diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index c43ef99f4..92ce1ffee 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -47,7 +47,7 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { rating: 4, onlineCoverURL: nil, folderURL: URL(fileURLWithPath: "/tmp/111 - Solo Title", isDirectory: true), - status: .completed, + displayStatus: .completed, completedPageCount: 1, lastDownloadedAt: .now, lastError: nil diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index a382b0936..1a8c6c213 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -94,7 +94,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - #expect(stored?.status == .completed) + #expect(stored?.displayStatus == .completed) #expect(stored?.completedPageCount == 2) #expect(stored?.lastError == nil) let pageRelativePath = storage.makePageRelativePath( diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index ec0471f35..af2cf915d 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -150,7 +150,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(downloads.map(\.gid) == ["600"]) #expect(indexedDownload.title == "Disk") #expect(indexedDownload.displayStatus == .queued) - #expect(indexedDownload.status == .queued) + #expect(indexedDownload.displayStatus == .queued) #expect(await manager.fetchDownload(gid: "601") == nil) #expect(badges["600"] == .queued) #expect(badges["601"] == nil) @@ -279,7 +279,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let download = try #require(await manager.fetchDownload(gid: "410")) #expect(download.displayStatus == .inactive) - #expect(download.status == .paused) + #expect(download.displayStatus == .inactive) #expect(download.lastError == nil) } @@ -311,7 +311,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let download = try #require(await manager.fetchDownload(gid: "420")) #expect(download.displayStatus == .inactive) - #expect(download.status == .paused) + #expect(download.displayStatus == .inactive) #expect(await manager.testingActiveGalleryID() == nil) } @@ -349,7 +349,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) #expect(sanitizedDownload?.displayStatus == .inactive) - #expect(sanitizedDownload?.status == .paused) + #expect(sanitizedDownload?.displayStatus == .inactive) #expect(sanitizedDownload?.lastError == nil) } @@ -381,7 +381,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(validation == .missingFiles("Page 1 is missing.")) let download = try #require(await manager.fetchDownload(gid: "440")) #expect(download.displayStatus == .error) - #expect(download.status == .failed) + #expect(download.displayStatus == .error) #expect(download.lastError?.code == .fileOperationFailed) #expect(download.badge == .failed) } @@ -427,7 +427,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let download = try #require(await manager.fetchDownload(gid: "450")) #expect(queueStore.gids == ["450"]) #expect(download.displayStatus == .queued) - #expect(download.status == .queued) + #expect(download.displayStatus == .queued) } @Test @@ -482,7 +482,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let download = try #require(await manager.fetchDownload(gid: "460")) #expect(queueStore.gids == ["460"]) #expect(download.displayStatus == .queued) - #expect(download.status == .queued) + #expect(download.displayStatus == .queued) } @Test @@ -527,7 +527,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(queueStore.gids == []) #expect(failedDownload.displayStatus == .error) - #expect(failedDownload.status == .failed) + #expect(failedDownload.displayStatus == .error) #expect(failedDownload.lastError?.code == .networkingFailed) #expect(badges["800"] == .failed) } @@ -630,7 +630,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(queueStore.gids == []) #expect(await manager.testingActiveGalleryID() == nil) #expect(pausedDownload.displayStatus == .inactive) - #expect(pausedDownload.status == .paused) + #expect(pausedDownload.displayStatus == .inactive) #expect(pausedDownload.lastError == nil) await manager.testingInstallActiveTask(gid: "busy", task: Task {}) @@ -643,7 +643,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let resumedDownload = try #require(await manager.fetchDownload(gid: "820")) #expect(queueStore.gids == ["820"]) #expect(resumedDownload.displayStatus == .queued) - #expect(resumedDownload.status == .queued) + #expect(resumedDownload.displayStatus == .queued) #expect(resumedDownload.lastError == nil) } diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index 733f824ba..8035f1704 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -62,7 +62,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) let activeGalleryID = await manager.testingActiveGalleryID() - #expect(stored?.status == .paused) + #expect(stored?.displayStatus == .inactive) #expect(stored?.badge == .paused(7, 26)) #expect(activeGalleryID == nil) } @@ -120,7 +120,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { } let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.status == .paused) + #expect(stored?.displayStatus == .inactive) #expect(stored?.completedPageCount == 1) #expect(stored?.badge == .paused(1, 2)) #expect(FileManager.default.fileExists(atPath: folderURL.path)) @@ -155,7 +155,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { await manager.reconcileDownloads() let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.status == .failed) + #expect(stored?.displayStatus == .error) #expect(stored?.badge == .failed) } @@ -193,7 +193,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) #expect(stored?.lastError == nil) - #expect(stored?.status == .paused) + #expect(stored?.displayStatus == .inactive) } @Test diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index b1288772b..a88d46e6e 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -54,7 +54,7 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { await manager.testingProcessDownload(gid: gid) let completedDownload = await manager.testingFetchDownload(gid: gid) - #expect(completedDownload?.status == .completed) + #expect(completedDownload?.displayStatus == .completed) try await waitUntilCacheCleared(cachedKeys: cachedKeys) diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index f2a47bada..dc52e0eb0 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -58,7 +58,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { await manager.testingSetPersistFailureHook(nil) let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.status == .failed) + #expect(stored?.displayStatus == .error) #expect(stored?.lastError?.code == .networkingFailed) await manager.testingScheduleNextIfNeeded() @@ -291,7 +291,7 @@ private extension DownloadProcessTests { ) async throws { let completedDownload = await manager.testingFetchDownload(gid: context.gid) let unwrapped = try #require(completedDownload) - #expect(unwrapped.status == .completed) + #expect(unwrapped.displayStatus == .completed) #expect(unwrapped.pageCount == context.updatedPageCount) #expect(unwrapped.completedPageCount == context.updatedPageCount) diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index 8daa3d652..cff798893 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -46,7 +46,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { } let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.status == .queued) + #expect(stored?.displayStatus == .queued) #expect(stored?.badge == .queued) #expect(stored?.lastError == nil) @@ -80,7 +80,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { } let stored = await manager.testingFetchDownload(gid: gid) - #expect(stored?.status == .paused) + #expect(stored?.displayStatus == .inactive) #expect(stored?.completedPageCount == 1) #expect(stored?.badge == .paused(1, 2)) } diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 94dfce8e0..dab5771d5 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -50,7 +50,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { } let queued = await queueingManager.testingFetchDownload(gid: gid) - #expect(queued?.status == .queued) + #expect(queued?.displayStatus == .queued) #expect(queued?.badge == .queued) #expect(queued?.lastError == nil) } @@ -94,7 +94,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { } let resumedDownload = await immediateManager.testingFetchDownload(gid: gid) - #expect(resumedDownload?.status == .downloading) + #expect(resumedDownload?.displayStatus == .active) #expect(resumedDownload?.lastError == nil) } } diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 63a7436a0..6e2c3329f 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -44,7 +44,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) let localPages = try await manager.loadLocalPageURLs(gid: gid).get() - #expect(stored?.status == .paused) + #expect(stored?.displayStatus == .inactive) #expect(stored?.completedPageCount == 0) #expect(FileManager.default.fileExists(atPath: folderURL.path)) #expect(localPages[1] == pageURL) @@ -105,7 +105,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { #expect(updateBadge == .updateAvailable) #expect(updatedDownload?.displayStatus == .updateAvailable) - #expect(updatedDownload?.status == .updateAvailable) + #expect(updatedDownload?.displayStatus == .updateAvailable) let currentBadge = await manager.updateRemoteVersion( gid: gid, @@ -124,7 +124,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { #expect(currentBadge == .downloaded) #expect(currentDownload?.displayStatus == .completed) - #expect(currentDownload?.status == .completed) + #expect(currentDownload?.displayStatus == .completed) } } diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index 98f538db6..b24359f7a 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -48,7 +48,7 @@ struct DownloadedGalleryManifestModelTests { #expect(download.gid == "123") #expect(download.folderURL.lastPathComponent == "[123_token] Sample") - #expect(download.status == .queued) + #expect(download.displayStatus == .queued) #expect(download.onlineCoverURL == manifest.remoteCoverURL) #expect(download.completedPageCount == 2) #expect(download.lastDownloadedAt == modifiedAt) From 4d088756378ad74d23102a0a26033a3abb095eec Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 08:21:23 +0800 Subject: [PATCH 135/614] Wrap manifest --- .../Models/Persistent/DownloadedGallery.swift | 87 +++++++++---------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 2b526c53b..ec1be2c72 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -55,24 +55,26 @@ enum DownloadStartMode: String, Codable, Equatable, Sendable { struct DownloadedGallery: Identifiable, Equatable { var id: String { gid } - let gid: String - let host: GalleryHost - let token: String - let title: String - let jpnTitle: String? - let uploader: String? - let category: Category - let tags: [GalleryTag] - let pageCount: Int - let postedDate: Date - let rating: Float - let onlineCoverURL: URL? + let manifest: DownloadManifest let folderURL: URL let displayStatus: DownloadDisplayStatus - let completedPageCount: Int let lastDownloadedAt: Date? let lastError: DownloadFailure? + var gid: String { manifest.gid } + var host: GalleryHost { manifest.host } + var token: String { manifest.token } + var title: String { manifest.title } + var jpnTitle: String? { manifest.jpnTitle } + var uploader: String? { manifest.uploader } + var category: Category { manifest.category } + var tags: [GalleryTag] { manifest.tags } + var pageCount: Int { manifest.pageCount } + var postedDate: Date { manifest.postedDate } + var rating: Float { manifest.rating } + var onlineCoverURL: URL? { manifest.remoteCoverURL } + var completedPageCount: Int { manifest.completedPageCount } + init( gid: String, host: GalleryHost, @@ -92,21 +94,30 @@ struct DownloadedGallery: Identifiable, Equatable { lastDownloadedAt: Date?, lastError: DownloadFailure? ) { - self.gid = gid - self.host = host - self.token = token - self.title = title - self.jpnTitle = jpnTitle - self.uploader = uploader - self.category = category - self.tags = tags - self.pageCount = pageCount - self.postedDate = postedDate - self.rating = rating - self.onlineCoverURL = onlineCoverURL + let clampedCompletedPageCount = min(max(completedPageCount, 0), pageCount) + self.manifest = DownloadManifest( + gid: gid, + host: host, + token: token, + title: title, + jpnTitle: jpnTitle, + category: category, + language: .japanese, + remoteCoverURL: onlineCoverURL, + uploader: uploader, + tags: tags, + postedDate: postedDate, + rating: rating, + pages: pageCount > 0 + ? Dictionary( + uniqueKeysWithValues: (1...pageCount).map { + ($0, $0 <= clampedCompletedPageCount ? "sha256:fixture-\($0)" : "") + } + ) + : [:] + ) self.folderURL = folderURL self.displayStatus = displayStatus - self.completedPageCount = completedPageCount self.lastDownloadedAt = lastDownloadedAt self.lastError = lastError } @@ -118,24 +129,10 @@ struct DownloadedGallery: Identifiable, Equatable { displayStatus: DownloadDisplayStatus, lastError: DownloadFailure? = nil ) { - self.init( - gid: manifest.gid, - host: manifest.host, - token: manifest.token, - title: manifest.title, - jpnTitle: manifest.jpnTitle, - uploader: manifest.uploader, - category: manifest.category, - tags: manifest.tags, - pageCount: manifest.pageCount, - postedDate: manifest.postedDate, - rating: manifest.rating, - onlineCoverURL: manifest.remoteCoverURL, - folderURL: folderURL, - displayStatus: displayStatus, - completedPageCount: manifest.completedPageCount, - lastDownloadedAt: modifiedAt, - lastError: lastError - ) + self.manifest = manifest + self.folderURL = folderURL + self.displayStatus = displayStatus + self.lastDownloadedAt = modifiedAt + self.lastError = lastError } } From 80afffa9f45b73fd2ca8bf1033d92c338540cf40 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 08:27:28 +0800 Subject: [PATCH 136/614] Split options --- .../DownloadClient+ExecutionFetch.swift | 2 +- .../Clients/DownloadClient+Manager.swift | 6 +-- .../App/Tools/Clients/DownloadClient.swift | 2 +- .../Persistent/DownloadRequestOptions.swift | 14 ++++++ .../Models/Persistent/DownloadStartMode.swift | 11 +++++ .../DownloadedGallery+Extensions.swift | 4 +- .../Models/Persistent/DownloadedGallery.swift | 47 ------------------- EhPanda/Models/Persistent/Setting.swift | 2 +- .../View/Detail/DetailReducer+Download.swift | 4 +- EhPanda/View/Detail/DetailReducer.swift | 4 +- EhPanda/View/Detail/DetailView.swift | 4 +- .../Download/DetailReducerDownloadTests.swift | 6 +-- .../DetailReducerPauseAndGuardTests.swift | 4 +- .../DownloadFeatureTestFactories.swift | 4 +- .../Tests/Download/DownloadProcessTests.swift | 2 +- .../Parser/Other/SettingDownloadTests.swift | 23 ++------- 16 files changed, 51 insertions(+), 88 deletions(-) create mode 100644 EhPanda/Models/Persistent/DownloadRequestOptions.swift create mode 100644 EhPanda/Models/Persistent/DownloadStartMode.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index 2911bdca5..c00b538a8 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -59,7 +59,7 @@ extension DownloadManager { fetchedData: FetchedGalleryData, components: GalleryComponents, mode: DownloadStartMode, - options: DownloadOptionsSnapshot, + options: DownloadRequestOptions, pageSelection: [Int]? ) -> DownloadRequestPayload { let download = fetchedData.download diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index ce3db321e..a4092ffb7 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -129,7 +129,7 @@ actor DownloadManager { let storage: DownloadFileStorage let urlSession: URLSession let libraryClient: LibraryClient - let downloadOptionsProvider: @Sendable () async -> DownloadOptionsSnapshot + let downloadOptionsProvider: @Sendable () async -> DownloadRequestOptions let queueStore: DownloadQueueStore var downloadIndex = [String: DownloadFolderRecord]() var downloadErrors = [String: DownloadFailure]() @@ -154,8 +154,8 @@ actor DownloadManager { storage: DownloadFileStorage, urlSession: URLSession, libraryClient: LibraryClient = .live, - downloadOptionsProvider: @escaping @Sendable () async -> DownloadOptionsSnapshot = { - DownloadOptionsSnapshot() + downloadOptionsProvider: @escaping @Sendable () async -> DownloadRequestOptions = { + DownloadRequestOptions() }, queueStore: DownloadQueueStore? = nil ) { diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 75abd7038..523335466 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -88,7 +88,7 @@ extension DownloadClient { storage: .init(rootURL: rootURL, fileManager: fileManager), urlSession: urlSession, downloadOptionsProvider: { - await DatabaseClient.live.fetchAppEnv().setting.downloadOptionsSnapshot + await DatabaseClient.live.fetchAppEnv().setting.downloadRequestOptions } ) Task { diff --git a/EhPanda/Models/Persistent/DownloadRequestOptions.swift b/EhPanda/Models/Persistent/DownloadRequestOptions.swift new file mode 100644 index 000000000..b3ac29b8e --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadRequestOptions.swift @@ -0,0 +1,14 @@ +// +// DownloadRequestOptions.swift +// EhPanda +// + +struct DownloadRequestOptions: Equatable, Sendable { + var threadLimit = 1 + var allowCellular = true + var autoRetryFailedPages = true + + var workerCount: Int { + threadLimit + } +} diff --git a/EhPanda/Models/Persistent/DownloadStartMode.swift b/EhPanda/Models/Persistent/DownloadStartMode.swift new file mode 100644 index 000000000..58f89140a --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadStartMode.swift @@ -0,0 +1,11 @@ +// +// DownloadStartMode.swift +// EhPanda +// + +enum DownloadStartMode: String, Equatable, Sendable { + case initial + case update + case redownload + case repair +} diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift index 9442d09f5..4dcc6c7e2 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -91,7 +91,7 @@ struct DownloadRequestPayload: Equatable, Sendable { let previewConfig: PreviewConfig let host: GalleryHost let versionMetadata: DownloadVersionMetadata? - let options: DownloadOptionsSnapshot + let options: DownloadRequestOptions let mode: DownloadStartMode let pageSelection: Set? @@ -102,7 +102,7 @@ struct DownloadRequestPayload: Equatable, Sendable { previewConfig: PreviewConfig, host: GalleryHost, versionMetadata: DownloadVersionMetadata? = nil, - options: DownloadOptionsSnapshot, + options: DownloadRequestOptions, mode: DownloadStartMode, pageSelection: Set? = nil ) { diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index ec1be2c72..7e30e63be 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -5,53 +5,6 @@ import SwiftUI -struct DownloadOptionsSnapshot: Codable, Equatable, Sendable { - var threadLimit = 1 - var allowCellular = true - var autoRetryFailedPages = true - - var workerCount: Int { - threadLimit - } - - private enum CodingKeys: String, CodingKey { - case threadLimit - case allowCellular - case autoRetryFailedPages - } - - init( - threadLimit: Int = 1, - allowCellular: Bool = true, - autoRetryFailedPages: Bool = true - ) { - self.threadLimit = threadLimit - self.allowCellular = allowCellular - self.autoRetryFailedPages = autoRetryFailedPages - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - threadLimit = try container.decodeIfPresent(Int.self, forKey: .threadLimit) ?? 1 - allowCellular = try container.decodeIfPresent(Bool.self, forKey: .allowCellular) ?? true - autoRetryFailedPages = try container.decodeIfPresent(Bool.self, forKey: .autoRetryFailedPages) ?? true - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(threadLimit, forKey: .threadLimit) - try container.encode(allowCellular, forKey: .allowCellular) - try container.encode(autoRetryFailedPages, forKey: .autoRetryFailedPages) - } -} - -enum DownloadStartMode: String, Codable, Equatable, Sendable { - case initial - case update - case redownload - case repair -} - struct DownloadedGallery: Identifiable, Equatable { var id: String { gid } diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift index 3f6b94c86..6df802e36 100644 --- a/EhPanda/Models/Persistent/Setting.swift +++ b/EhPanda/Models/Persistent/Setting.swift @@ -59,7 +59,7 @@ struct Setting: Codable, Equatable { } extension Setting { - var downloadOptionsSnapshot: DownloadOptionsSnapshot { + var downloadRequestOptions: DownloadRequestOptions { .init( threadLimit: downloadThreadLimit, allowCellular: downloadAllowCellular, diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index c5123f6af..e1918e8c2 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -148,7 +148,7 @@ extension DetailReducer { } private func handleRunLaunchAutomation( - options: DownloadOptionsSnapshot, + options: DownloadRequestOptions, state: inout State ) -> Effect { guard !state.didRunLaunchAutomation, @@ -162,7 +162,7 @@ extension DetailReducer { } private func handleStartDownload( - options: DownloadOptionsSnapshot, + options: DownloadRequestOptions, state: inout State ) -> Effect { guard !state.isPreparingDownload else { return .none } diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 0d46caa30..cef9a67e6 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -113,8 +113,8 @@ struct DetailReducer { case loadLocalPreviewURLsDone(UUID, [Int: URL]) case openReading case openReadingDone(Result<(DownloadedGallery, DownloadManifest), AppError>) - case runLaunchAutomationIfNeeded(DownloadOptionsSnapshot) - case startDownload(DownloadOptionsSnapshot) + case runLaunchAutomationIfNeeded(DownloadRequestOptions) + case startDownload(DownloadRequestOptions) case startDownloadDone(Result) case toggleDownloadPause case toggleDownloadPauseDone(Result) diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index daf1dae4a..c7be720e1 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -331,7 +331,7 @@ private extension DetailView { // MARK: Actions private extension DetailView { private func handleDownloadAction() { - let options = setting.downloadOptionsSnapshot + let options = setting.downloadRequestOptions switch store.downloadBadge { case .none: store.send(.startDownload(options)) @@ -349,7 +349,7 @@ private extension DetailView { } private func runLaunchAutomationIfNeeded() { - store.send(.runLaunchAutomationIfNeeded(setting.downloadOptionsSnapshot)) + store.send(.runLaunchAutomationIfNeeded(setting.downloadRequestOptions)) } @ViewBuilder private func offlineFallbackNotice(error: AppError) -> some View { diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index 98478f01f..3440c0029 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -17,7 +17,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { let capturedPayload = UncheckedBox(nil) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let options = DownloadOptionsSnapshot( + let options = DownloadRequestOptions( threadLimit: 4, allowCellular: false, autoRetryFailedPages: false @@ -53,7 +53,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { func testDetailReducerStartDownloadUnlocksActionsAfterQueueing() async throws { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let options = DownloadOptionsSnapshot() + let options = DownloadRequestOptions() let previewURL = try #require(URL(string: "https://example.com/1.jpg")) let store = makeDownloadTestStore( gallery: gallery, detail: detail, @@ -85,7 +85,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { let capturedPayload = UncheckedBox(nil) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let options = DownloadOptionsSnapshot() + let options = DownloadRequestOptions() let previewURL = try #require(URL(string: "https://example.com/1.jpg")) setenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID", gallery.gid, 1) diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index f395b3376..113f5d399 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -16,7 +16,7 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { func testDetailReducerLaunchAutomationDoesNotRedownloadWhenBadgeIsResolved() async { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let options = DownloadOptionsSnapshot() + let options = DownloadRequestOptions() var initialState = DetailReducer.State() initialState.gallery = gallery initialState.galleryDetail = detail @@ -49,7 +49,7 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let enqueueCount = UncheckedBox(0) - let options = DownloadOptionsSnapshot() + let options = DownloadRequestOptions() var initialState = DetailReducer.State() initialState.gid = gallery.gid diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index f350dcc1f..411fe53b9 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -245,8 +245,8 @@ extension DownloadFeatureTestCase { func makeStubbedDownloadManager( rootURL: URL, sessionID: String, - downloadOptionsProvider: @escaping @Sendable () async -> DownloadOptionsSnapshot = { - DownloadOptionsSnapshot() + downloadOptionsProvider: @escaping @Sendable () async -> DownloadRequestOptions = { + DownloadRequestOptions() } ) -> (DownloadFileStorage, DownloadManager) { let configuration = URLSessionConfiguration.ephemeral diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index dc52e0eb0..ff8e6faca 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -111,7 +111,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 403) let pageIndex = 42 - let options = DownloadOptionsSnapshot( + let options = DownloadRequestOptions( threadLimit: 3, allowCellular: false, autoRetryFailedPages: false diff --git a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift index c0a413d38..d138520c5 100644 --- a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -25,14 +25,14 @@ struct SettingDownloadTests { } @Test - func testDownloadOptionsSnapshotMatchesSettingValues() { + func testDownloadRequestOptionsMatchesSettingValues() { var setting = Setting() setting.downloadThreadLimit = 4 setting.downloadAllowCellular = false setting.downloadAutoRetryFailedPages = false #expect( - setting.downloadOptionsSnapshot == DownloadOptionsSnapshot( + setting.downloadRequestOptions == DownloadRequestOptions( threadLimit: 4, allowCellular: false, autoRetryFailedPages: false @@ -41,24 +41,9 @@ struct SettingDownloadTests { } @Test - func testLegacyDownloadOptionsSnapshotDecodesWithoutOriginalImageField() throws { - let data = Data(""" - { - "threadLimit": 3, - "useOriginalImages": true, - "allowCellular": false, - "autoRetryFailedPages": false - } - """.utf8) - - let snapshot = try JSONDecoder().decode(DownloadOptionsSnapshot.self, from: data) - + func testDownloadRequestOptionsDefaultsMatchSettingDefaults() { #expect( - snapshot == DownloadOptionsSnapshot( - threadLimit: 3, - allowCellular: false, - autoRetryFailedPages: false - ) + Setting().downloadRequestOptions == DownloadRequestOptions() ) } From c6852965572ddbee52122bd94ee28d0790ab639e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 08:48:48 +0800 Subject: [PATCH 137/614] Drop legacy paths --- .../DownloadFileStorage+Operations.swift | 7 -- .../Tools/Utilities/DownloadFileStorage.swift | 53 +-------- .../Download/DownloadBadgeSortTests.swift | 7 +- .../DownloadFeatureTestFactories.swift | 19 ++-- .../DownloadFileStorageHashTests.swift | 12 +- .../DownloadFileStorageRepairTests.swift | 26 ++--- .../Download/DownloadFileStorageTests.swift | 103 +++++------------- .../Download/DownloadInspectorLoadTests.swift | 6 +- .../DownloadInspectorRetryTests.swift | 4 +- .../DownloadManagerCaptureTests.swift | 4 +- .../DownloadManagerRepairSeedTests.swift | 67 ++++++++---- .../DownloadManagerStorageTests.swift | 22 ++-- .../Download/DownloadObserverBatchTests.swift | 2 +- .../DownloadObserverReadingTests.swift | 10 +- .../DownloadPauseAndReconcileTests.swift | 12 +- .../Download/DownloadProcessCacheTests.swift | 4 +- .../Tests/Download/DownloadProcessTests.swift | 12 +- .../DownloadRetryMinimalSourceTests.swift | 6 +- .../Download/DownloadRetryPagesTests.swift | 2 +- .../DownloadVersionSignatureTests.swift | 4 +- .../Download/ReadingReducerLocalTests.swift | 8 +- 21 files changed, 148 insertions(+), 242 deletions(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index b33c19080..66908d3b5 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -41,13 +41,6 @@ extension DownloadFileStorage { ) throws { try fileManager.operate { try $0.createDirectory(at: destinationFolderURL, withIntermediateDirectories: true) - try $0.createDirectory( - at: destinationFolderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, - isDirectory: true - ), - withIntermediateDirectories: true - ) } try linkOrCopyReadableAsset( diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 63e06c154..136daefd0 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -69,19 +69,16 @@ struct DownloadFileStorage: Sendable { folderURL: URL, expectedPageCount: Int ) -> [Int: String] { - var relativePaths = existingLegacyPageRelativePaths( - folderURL: folderURL, - expectedPageCount: expectedPageCount - ) guard let finalPageURLs = try? fileManager.operate({ try $0.contentsOfDirectory( at: folderURL, includingPropertiesForKeys: nil ) }) else { - return relativePaths + return [:] } + var relativePaths = [Int: String]() for pageURL in finalPageURLs { guard let index = finalPageIndex(from: pageURL), index >= 1, @@ -95,40 +92,6 @@ struct DownloadFileStorage: Sendable { return relativePaths } - private func existingLegacyPageRelativePaths( - folderURL: URL, - expectedPageCount: Int - ) -> [Int: String] { - let pagesFolderURL = folderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, - isDirectory: true - ) - guard let pageURLs = try? fileManager.operate({ - try $0.contentsOfDirectory( - at: pagesFolderURL, - includingPropertiesForKeys: nil - ) - }) else { - return [:] - } - - var relativePaths = [Int: String]() - for pageURL in pageURLs { - guard sanitizeAssetFileIfNeeded(at: pageURL) else { - continue - } - let filename = pageURL.deletingPathExtension().lastPathComponent - guard let index = Int(filename), - index >= 1, - index <= expectedPageCount - else { - continue - } - relativePaths[index] = Defaults.FilePath.downloadPages + "/\(pageURL.lastPathComponent)" - } - return relativePaths - } - private func finalPageIndex(from pageURL: URL) -> Int? { let filename = pageURL.deletingPathExtension().lastPathComponent guard let separatorIndex = filename.lastIndex(of: "_") else { @@ -152,7 +115,7 @@ struct DownloadFileStorage: Sendable { .sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) .first(where: { let filename = $0.deletingPathExtension().lastPathComponent - return (filename == "cover" || filename.hasSuffix("_cover")) + return filename.hasSuffix("_cover") && sanitizeAssetFileIfNeeded(at: $0) })? .lastPathComponent @@ -206,16 +169,6 @@ struct DownloadFileStorage: Sendable { return sanitized.isEmpty ? "unknown" : sanitized } - func makePageRelativePath(index: Int, fileExtension: String) -> String { - let ext = fileExtension.lowercased() - let paddedIndex = String(format: "%04d", index) - return Defaults.FilePath.downloadPages + "/\(paddedIndex).\(ext)" - } - - func makeCoverRelativePath(fileExtension: String) -> String { - "cover.\(fileExtension.lowercased())" - } - func makePageRelativePath(gid: String, token: String, index: Int, fileExtension: String) -> String { [ normalizedIdentityComponent(gid), diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index ed74519b2..77a2af03a 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -97,15 +97,12 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { ) let rootURL = FileUtil.downloadsDirectoryURL - let folderURL = rootURL.appendingPathComponent( - "\(gid) - Local Cover Archive", - isDirectory: true - ) + let folderURL = download.folderURL try? FileManager.default.removeItem(at: folderURL) defer { try? FileManager.default.removeItem(at: folderURL) } try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - let coverURL = folderURL.appendingPathComponent("cover.jpg") + let coverURL = folderURL.appendingPathComponent("\(gid)_token_cover.jpg") try Data([0xFF, 0xD8, 0xFF]).write(to: coverURL, options: .atomic) #expect(download.resolvedCoverURL(rootURL: rootURL) == coverURL) diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 411fe53b9..d02763cc4 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -95,14 +95,14 @@ extension DownloadFeatureTestCase { .init( index: 1, status: .downloaded, - relativePath: "pages/0001.jpg", + relativePath: "123_token_1.jpg", fileURL: URL(fileURLWithPath: "/tmp/0001.jpg"), failure: nil ), .init( index: 2, status: .failed, - relativePath: "pages/0002.jpg", + relativePath: "123_token_2.jpg", fileURL: nil, failure: .init(code: .networkingFailed, message: "Network Error") ) @@ -118,7 +118,8 @@ extension DownloadFeatureTestCase { pageCount: Int = 12, completedPageCount: Int? = nil, lastDownloadedAt: Date? = .now, - lastError: DownloadFailure? = nil + lastError: DownloadFailure? = nil, + folderURL: URL? = nil ) -> DownloadedGallery { DownloadedGallery( gid: gid, @@ -133,8 +134,8 @@ extension DownloadFeatureTestCase { postedDate: .now, rating: 4, onlineCoverURL: URL(string: "https://example.com/cover.jpg"), - folderURL: FileUtil.downloadsDirectoryURL - .appendingPathComponent("\(gid) - \(title)", isDirectory: true), + folderURL: folderURL ?? FileUtil.downloadsDirectoryURL + .appendingPathComponent("[\(gid)_token] \(title)", isDirectory: true), displayStatus: status.displayStatus, completedPageCount: completedPageCount ?? status.defaultCompletedPageCount(pageCount: pageCount), @@ -150,9 +151,7 @@ extension DownloadFeatureTestCase { let folderURL = download.folderURL try? FileManager.default.removeItem(at: folderURL) try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, isDirectory: true - ), + at: folderURL, withIntermediateDirectories: true ) try JSONEncoder().encode(manifest).write( @@ -160,11 +159,11 @@ extension DownloadFeatureTestCase { options: .atomic ) try Data([0x01]).write( - to: folderURL.appendingPathComponent("pages/0001.jpg"), + to: folderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic ) try Data([0x02]).write( - to: folderURL.appendingPathComponent("pages/0002.jpg"), + to: folderURL.appendingPathComponent("123_token_2.jpg"), options: .atomic ) return folderURL diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index 95fad9da4..dbe699ed5 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -14,7 +14,7 @@ struct DownloadFileStorageHashTests { defer { try? FileManager.default.removeItem(at: rootURL) } let (download, folderURL) = try makePreparedDownload(storage: storage) - let pageTwoURL = folderURL.appendingPathComponent("pages/0002.jpg") + let pageTwoURL = folderURL.appendingPathComponent("123_token_2.jpg") let manifest = try storage.addingCurrentFileHashes( to: sampleManifest(pageCount: 2), @@ -35,7 +35,7 @@ struct DownloadFileStorageHashTests { defer { try? FileManager.default.removeItem(at: rootURL) } let (download, folderURL) = try makePreparedDownload(storage: storage) - let pageTwoURL = folderURL.appendingPathComponent("pages/0002.jpg") + let pageTwoURL = folderURL.appendingPathComponent("123_token_2.jpg") let manifest = try storage.addingCurrentFileHashes( to: sampleManifest(pageCount: 2), @@ -62,19 +62,19 @@ struct DownloadFileStorageHashTests { let download = sampleDownload(folderURL: folderURL) try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: folderURL, withIntermediateDirectories: true ) try Data([0xFF, 0xD8, 0xFF]).write( - to: folderURL.appendingPathComponent("cover.jpg"), + to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic ) try Data([0x01]).write( - to: folderURL.appendingPathComponent("pages/0001.jpg"), + to: folderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic ) try Data([0x02]).write( - to: folderURL.appendingPathComponent("pages/0002.jpg"), + to: folderURL.appendingPathComponent("123_token_2.jpg"), options: .atomic ) return (download, folderURL) diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift index 7d940c960..6649124d1 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift @@ -48,7 +48,7 @@ struct DownloadFileStorageRepairTests { ) #expect(FileManager.default.fileExists( - atPath: env.destinationFolderURL.appendingPathComponent("pages/0001.jpg").path + atPath: env.destinationFolderURL.appendingPathComponent("123_token_1.jpg").path )) #expect(FileManager.default.fileExists( atPath: env.destinationFolderURL.appendingPathComponent("../escape.jpg").standardizedFileURL.path @@ -104,9 +104,7 @@ private extension DownloadFileStorageRepairTests { let sourceFolderURL = sourceStorage.folderURL(relativePath: "123 - Source") let destinationFolderURL = destStorage.folderURL(relativePath: "[123_token] Destination") try FileManager.default.createDirectory( - at: sourceFolderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, isDirectory: true - ), + at: sourceFolderURL, withIntermediateDirectories: true ) let manifest = DownloadManifest( @@ -123,10 +121,10 @@ private extension DownloadFileStorageRepairTests { ) try sourceStorage.writeManifest(manifest, folderURL: sourceFolderURL) try Data([0xFF, 0xD8, 0xFF]).write( - to: sourceFolderURL.appendingPathComponent("cover.jpg"), options: .atomic + to: sourceFolderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic ) try Data([0x01]).write( - to: sourceFolderURL.appendingPathComponent("pages/0001.jpg"), options: .atomic + to: sourceFolderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic ) let escapeURL = sourceFolderURL.deletingLastPathComponent().appendingPathComponent("escape.jpg") try Data([0x99]).write(to: escapeURL, options: .atomic) @@ -142,18 +140,18 @@ private extension DownloadFileStorageRepairTests { manifest: DownloadManifest ) throws { try FileManager.default.createDirectory( - at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: sourceFolderURL, withIntermediateDirectories: true ) try storage.writeManifest(manifest, folderURL: sourceFolderURL) try Data([0xFF, 0xD8, 0xFF]).write( - to: sourceFolderURL.appendingPathComponent("cover.jpg"), options: .atomic + to: sourceFolderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic ) try Data([0x01]).write( - to: sourceFolderURL.appendingPathComponent("pages/0001.jpg"), options: .atomic + to: sourceFolderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic ) try Data([0x03]).write( - to: sourceFolderURL.appendingPathComponent("pages/0003.jpg"), options: .atomic + to: sourceFolderURL.appendingPathComponent("123_token_3.jpg"), options: .atomic ) try FileManager.default.createDirectory( at: sourceFolderURL.appendingPathComponent("nested", isDirectory: true), @@ -169,16 +167,16 @@ private extension DownloadFileStorageRepairTests { atPath: destinationFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest).path )) #expect(FileManager.default.fileExists( - atPath: destinationFolderURL.appendingPathComponent("cover.jpg").path + atPath: destinationFolderURL.appendingPathComponent("123_token_cover.jpg").path )) #expect(FileManager.default.fileExists( - atPath: destinationFolderURL.appendingPathComponent("pages/0001.jpg").path + atPath: destinationFolderURL.appendingPathComponent("123_token_1.jpg").path )) #expect(FileManager.default.fileExists( - atPath: destinationFolderURL.appendingPathComponent("pages/0002.jpg").path + atPath: destinationFolderURL.appendingPathComponent("123_token_2.jpg").path ) == false) #expect(FileManager.default.fileExists( - atPath: destinationFolderURL.appendingPathComponent("pages/0003.jpg").path + atPath: destinationFolderURL.appendingPathComponent("123_token_3.jpg").path )) #expect(FileManager.default.fileExists( atPath: destinationFolderURL.appendingPathComponent("nested/ignored.bin").path diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index a89564c4d..127de9ea1 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -17,20 +17,16 @@ struct DownloadFileStorageTests { let folderURL = storage.folderURL(relativePath: "123 - Sample") let download = sampleDownload(folderURL: folderURL) try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) try Data([0xFF, 0xD8, 0xFF]).write( - to: folderURL.appendingPathComponent("cover.jpg"), + to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic ) try Data([0x01]).write( - to: folderURL.appendingPathComponent("pages/0001.jpg"), + to: folderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic ) try Data([0x02]).write( - to: folderURL.appendingPathComponent("pages/0002.jpg"), + to: folderURL.appendingPathComponent("123_token_2.jpg"), options: .atomic ) let manifest = try storage.addingCurrentFileHashes( @@ -65,15 +61,11 @@ struct DownloadFileStorageTests { let folderURL = storage.folderURL(relativePath: "123 - Sample") let download = sampleDownload(folderURL: folderURL) try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) try Data([0xFF, 0xD8, 0xFF]).write( - to: folderURL.appendingPathComponent("cover.jpg"), + to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic ) - let page1URL = folderURL.appendingPathComponent("pages/0001.jpg") + let page1URL = folderURL.appendingPathComponent("123_token_1.jpg") try Data([0x01]).write(to: page1URL, options: .atomic) try storage.writeManifest( sampleManifest(pageHashes: [ @@ -97,23 +89,19 @@ struct DownloadFileStorageTests { let folderURL = storage.folderURL(relativePath: "123 - Sample") let download = sampleDownload(folderURL: folderURL) try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), - withIntermediateDirectories: true - ) try Data([0xFF, 0xD8, 0xFF]).write( - to: folderURL.appendingPathComponent("cover.jpg"), + to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic ) try Data().write( - to: folderURL.appendingPathComponent("pages/0001.jpg"), + to: folderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic ) try Data([0x02]).write( - to: folderURL.appendingPathComponent("pages/0002.jpg"), + to: folderURL.appendingPathComponent("123_token_2.jpg"), options: .atomic ) - let page2URL = folderURL.appendingPathComponent("pages/0002.jpg") + let page2URL = folderURL.appendingPathComponent("123_token_2.jpg") try storage.writeManifest( sampleManifest(pageHashes: [ 1: "sha256:missing", @@ -127,36 +115,11 @@ struct DownloadFileStorageTests { ) #expect( FileManager.default.fileExists( - atPath: folderURL.appendingPathComponent("pages/0001.jpg").path + atPath: folderURL.appendingPathComponent("123_token_1.jpg").path ) == false ) } - @Test - func testExistingPageRelativePathsDetectsCompletedPages() throws { - let (storage, rootURL) = makeStorage() - defer { try? FileManager.default.removeItem(at: rootURL) } - - try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[123_token] Sample") - let pagesURL = folderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, - isDirectory: true - ) - try FileManager.default.createDirectory(at: pagesURL, withIntermediateDirectories: true) - try Data([0x01]).write(to: pagesURL.appendingPathComponent("0001.jpg"), options: .atomic) - try Data([0x02]).write(to: pagesURL.appendingPathComponent("0002.png"), options: .atomic) - try Data([0x03]).write(to: pagesURL.appendingPathComponent("0027.jpg"), options: .atomic) - try Data([0x04]).write(to: pagesURL.appendingPathComponent("invalid.jpg"), options: .atomic) - - #expect( - storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2) == [ - 1: "pages/0001.jpg", - 2: "pages/0002.png" - ] - ) - } - @Test func testExistingPageRelativePathsDetectsFinalAssetFiles() throws { let (storage, rootURL) = makeStorage() @@ -179,69 +142,55 @@ struct DownloadFileStorageTests { } @Test - func testExistingPageRelativePathsPreservesLegacyPagesFolderWhenScanningFinalAssets() throws { + func testExistingPageRelativePathsIgnoresLegacyPagesFolder() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() let folderURL = storage.folderURL(relativePath: "[123_token] Sample") - let pagesFolderURL = folderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, - isDirectory: true - ) + let pagesFolderURL = folderURL.appendingPathComponent("pages", isDirectory: true) try FileManager.default.createDirectory(at: pagesFolderURL, withIntermediateDirectories: true) try Data([0x01]).write(to: pagesFolderURL.appendingPathComponent("0001.jpg"), options: .atomic) - #expect( - storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 1) == [ - 1: "pages/0001.jpg" - ] - ) + #expect(storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 1) == [:]) #expect(FileManager.default.fileExists(atPath: pagesFolderURL.path)) } @Test - func testExistingPageRelativePathsRemovesZeroByteFiles() throws { + func testExistingPageRelativePathsRemovesZeroByteFinalAssetFiles() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() let folderURL = storage.folderURL(relativePath: "[123_token] Sample") - let pagesURL = folderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, - isDirectory: true - ) - try FileManager.default.createDirectory(at: pagesURL, withIntermediateDirectories: true) - let emptyPageURL = pagesURL.appendingPathComponent("0001.jpg") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + let emptyPageURL = folderURL.appendingPathComponent("123_token_1.jpg") try Data().write(to: emptyPageURL, options: .atomic) - try Data([0x02]).write(to: pagesURL.appendingPathComponent("0002.png"), options: .atomic) + try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_2.png"), options: .atomic) #expect( storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2) == [ - 2: "pages/0002.png" + 2: "123_token_2.png" ] ) #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) } @Test - func testExistingPageRelativePathsRemovesZeroByteFinalAssetFiles() throws { + func testExistingPageRelativePathsIgnoresZeroByteLegacyFiles() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() let folderURL = storage.folderURL(relativePath: "[123_token] Sample") - try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - let emptyPageURL = folderURL.appendingPathComponent("123_token_1.jpg") + let pagesURL = folderURL.appendingPathComponent("pages", isDirectory: true) + try FileManager.default.createDirectory(at: pagesURL, withIntermediateDirectories: true) + let emptyPageURL = pagesURL.appendingPathComponent("0001.jpg") try Data().write(to: emptyPageURL, options: .atomic) - try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_2.png"), options: .atomic) + try Data([0x02]).write(to: pagesURL.appendingPathComponent("0002.png"), options: .atomic) - #expect( - storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2) == [ - 2: "123_token_2.png" - ] - ) - #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) + #expect(storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2) == [:]) + #expect(FileManager.default.fileExists(atPath: emptyPageURL.path)) } @Test @@ -264,7 +213,7 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) - let fileURL = rootURL.appendingPathComponent("cover.jpg") + let fileURL = rootURL.appendingPathComponent("123_token_cover.jpg") try Data([0xFF, 0xD8, 0xFF]).write(to: fileURL, options: .atomic) let storage = DownloadFileStorage( rootURL: rootURL, diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift index a8d9a9b62..0a3bfde95 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -101,11 +101,11 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { coverURL: inspection.coverURL, pages: [ .init( - index: 1, status: .downloaded, relativePath: "pages/0001.jpg", + index: 1, status: .downloaded, relativePath: "123_token_1.jpg", fileURL: URL(fileURLWithPath: "/tmp/0001.jpg"), failure: nil ), .init( - index: 2, status: .pending, relativePath: "pages/0002.jpg", + index: 2, status: .pending, relativePath: "123_token_2.jpg", fileURL: nil, failure: nil ) ] @@ -267,7 +267,7 @@ extension DownloadInspectorLoadTests { fileURL: nil, failure: nil ), .init( - index: 2, status: .failed, relativePath: "pages/0002.jpg", + index: 2, status: .failed, relativePath: "123_token_2.jpg", fileURL: nil, failure: .init(code: .networkingFailed, message: "Network Error") ) diff --git a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift index 5890c3f19..e2dcd171a 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -39,7 +39,7 @@ struct DownloadInspectorRetryTests: DownloadFeatureTestCase { pages: [ refreshedInspection.pages[0], .init( - index: 2, status: .pending, relativePath: "pages/0002.jpg", + index: 2, status: .pending, relativePath: "123_token_2.jpg", fileURL: nil, failure: nil ) ] @@ -97,7 +97,7 @@ struct DownloadInspectorRetryTests: DownloadFeatureTestCase { pages: [ stableInspection.pages[0], .init( - index: 2, status: .pending, relativePath: "pages/0002.jpg", + index: 2, status: .pending, relativePath: "123_token_2.jpg", fileURL: nil, failure: nil ) ] diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index 1a8c6c213..d50e3c156 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -114,11 +114,11 @@ private extension DownloadManagerCaptureTests { func setupCaptureMissingFilesFolder(rootURL: URL, gid: String) throws -> URL { let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: completedFolderURL, withIntermediateDirectories: true ) try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("cover.jpg"), options: .atomic + to: completedFolderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic ) let page2RelativePath = "\(gid)_token_2.jpg" let page2URL = completedFolderURL.appendingPathComponent(page2RelativePath) diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index 8b233942a..db4031f7e 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -23,32 +23,47 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { let manager = DownloadManager(storage: storage, urlSession: .shared) try storage.ensureRootDirectory() + let sourceFolderURL = storage.folderURL(relativePath: "[\(gid)_token] Existing") let existingDownload = sampleDownload( gid: gid, title: "Mixed Version", status: .missingFiles, - pageCount: 2, completedPageCount: 2 + pageCount: 2, completedPageCount: 2, + folderURL: sourceFolderURL + ) + try setupRepairSeedFiles( + storage: storage, + sourceFolderURL: sourceFolderURL, + gid: gid ) - try setupRepairSeedFiles(storage: storage, rootURL: rootURL, gid: gid) let payload = makeRepairSeedPayload(gid: gid) let workingSeed = try await manager.testingPrepareWorkingSeed( payload: payload, existingDownload: existingDownload ) + let pageOneRelativePath = storage.makePageRelativePath( + gid: gid, token: "token", index: 1, fileExtension: "jpg" + ) + let pageTwoRelativePath = storage.makePageRelativePath( + gid: gid, token: "token", index: 2, fileExtension: "jpg" + ) + let coverRelativePath = storage.makeCoverRelativePath( + gid: gid, token: "token", fileExtension: "jpg" + ) let manifest = try #require(workingSeed.manifest) #expect(manifest.gid == gid) #expect(workingSeed.existingPages == [ - 1: "pages/0001.jpg", - 2: "pages/0002.jpg" + 1: pageOneRelativePath, + 2: pageTwoRelativePath ]) - #expect(workingSeed.coverRelativePath == "cover.jpg") + #expect(workingSeed.coverRelativePath == coverRelativePath) #expect( FileManager.default.fileExists( - atPath: workingSeed.folderURL.appendingPathComponent("pages/0001.jpg").path + atPath: workingSeed.folderURL.appendingPathComponent(pageOneRelativePath).path ) ) #expect( FileManager.default.fileExists( - atPath: workingSeed.folderURL.appendingPathComponent("pages/0002.jpg").path + atPath: workingSeed.folderURL.appendingPathComponent(pageTwoRelativePath).path ) ) } @@ -155,15 +170,12 @@ private extension DownloadManagerRepairSeedTests { } func setupRepairSeedFiles( - storage: DownloadFileStorage, rootURL: URL, gid: String + storage: DownloadFileStorage, + sourceFolderURL: URL, + gid: String ) throws { - let completedFolderURL = rootURL.appendingPathComponent( - "\(gid) - Mixed Version", isDirectory: true - ) try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, isDirectory: true - ), + at: sourceFolderURL, withIntermediateDirectories: true ) let oldManifest = try sampleManifest( @@ -171,17 +183,26 @@ private extension DownloadManagerRepairSeedTests { pageCount: 2 ) try JSONEncoder().encode(oldManifest).write( - to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + to: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), options: .atomic ) try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("cover.jpg"), options: .atomic + to: sourceFolderURL.appendingPathComponent( + storage.makeCoverRelativePath(gid: gid, token: "token", fileExtension: "jpg") + ), + options: .atomic ) try Data([0x01]).write( - to: completedFolderURL.appendingPathComponent("pages/0001.jpg"), options: .atomic + to: sourceFolderURL.appendingPathComponent( + storage.makePageRelativePath(gid: gid, token: "token", index: 1, fileExtension: "jpg") + ), + options: .atomic ) try Data([0x02]).write( - to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), options: .atomic + to: sourceFolderURL.appendingPathComponent( + storage.makePageRelativePath(gid: gid, token: "token", index: 2, fileExtension: "jpg") + ), + options: .atomic ) } @@ -216,9 +237,7 @@ private extension DownloadManagerRepairSeedTests { "\(gid) - Pause Race", isDirectory: true ) try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, isDirectory: true - ), + at: completedFolderURL, withIntermediateDirectories: true ) let manifest = try sampleManifest(gid: gid, title: "Pause Race") @@ -227,11 +246,11 @@ private extension DownloadManagerRepairSeedTests { options: .atomic ) try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("cover.jpg"), options: .atomic + to: completedFolderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic ) - let emptyPageURL = completedFolderURL.appendingPathComponent("pages/0001.jpg") + let emptyPageURL = completedFolderURL.appendingPathComponent("123_token_1.jpg") try Data().write(to: emptyPageURL, options: .atomic) - let goodPageURL = completedFolderURL.appendingPathComponent("pages/0002.jpg") + let goodPageURL = completedFolderURL.appendingPathComponent("123_token_2.jpg") try Data([0x02]).write(to: goodPageURL, options: .atomic) return (emptyPageURL, goodPageURL) } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index af2cf915d..c9fe10020 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -782,18 +782,18 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) let folderURL = storage.folderURL(relativePath: folderRelativePath) try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: folderURL, withIntermediateDirectories: true ) try Data([0x01]).write( - to: folderURL.appendingPathComponent("pages/0001.jpg"), + to: folderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic ) await manager.testingSetFailedPageErrors( [ .init( index: 2, - relativePath: "pages/0002.jpg", + relativePath: "123_token_2.jpg", error: .networkingFailed ) ], @@ -823,7 +823,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: completedFolderURL, withIntermediateDirectories: true ) let manifest = try indexedManifest( @@ -836,7 +836,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { options: .atomic ) try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("cover.jpg"), + to: completedFolderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic ) let completedPageURL = completedFolderURL.appendingPathComponent("\(gid)_token_1.jpg") @@ -867,7 +867,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: completedFolderURL, withIntermediateDirectories: true ) let manifest = try sampleManifest(gid: gid, title: "Pause Race") @@ -876,22 +876,22 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { options: .atomic ) try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("cover.jpg"), + to: completedFolderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic ) try Data([0x01]).write( - to: completedFolderURL.appendingPathComponent("pages/0001.jpg"), + to: completedFolderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic ) try Data([0x09]).write( - to: completedFolderURL.appendingPathComponent("pages/0002.jpg"), + to: completedFolderURL.appendingPathComponent("123_token_2.jpg"), options: .atomic ) let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("pages/0001.jpg")) - #expect(pageURLs[2] == completedFolderURL.appendingPathComponent("pages/0002.jpg")) + #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("123_token_1.jpg")) + #expect(pageURLs[2] == completedFolderURL.appendingPathComponent("123_token_2.jpg")) } } diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 279ef7af5..248d03156 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -76,7 +76,7 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { let folderRelativePath = "\(gid) - Progress Flush" let folderURL = storage.folderURL(relativePath: folderRelativePath) try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: folderURL, withIntermediateDirectories: true ) try storage.writeManifest( diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index 8d08eab78..3404a1983 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -37,15 +37,15 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { let folderURL = download.folderURL defer { try? FileManager.default.removeItem(at: folderURL) } try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: folderURL, withIntermediateDirectories: true ) try Data([0x01]).write( - to: folderURL.appendingPathComponent("pages/0001.jpg"), + to: folderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic ) try Data([0x02]).write( - to: folderURL.appendingPathComponent("pages/0002.jpg"), + to: folderURL.appendingPathComponent("123_token_2.jpg"), options: .atomic ) @@ -53,8 +53,8 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { $0.gallery = download.gallery $0.language = manifest.language $0.localPageURLs = [ - 1: folderURL.appendingPathComponent("pages/0001.jpg"), - 2: folderURL.appendingPathComponent("pages/0002.jpg") + 1: folderURL.appendingPathComponent("123_token_1.jpg"), + 2: folderURL.appendingPathComponent("123_token_2.jpg") ] $0.previewConfig = .normal(rows: 4) $0.previewURLs = $0.localPageURLs diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index 8035f1704..c931be502 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -91,15 +91,15 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Pausable") try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: folderURL, withIntermediateDirectories: true ) try Data([0x01]).write( - to: folderURL.appendingPathComponent("pages/0001.jpg"), + to: folderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic ) try Data([0x02]).write( - to: folderURL.appendingPathComponent("pages/0002.jpg"), + to: folderURL.appendingPathComponent("123_token_2.jpg"), options: .atomic ) @@ -220,7 +220,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { [ .init( index: 2, - relativePath: "pages/0002.jpg", + relativePath: "123_token_2.jpg", error: .fileOperationFailed( "The operation could not be completed. (Swift.CancellationError error 1.)" ) @@ -284,11 +284,11 @@ private extension DownloadPauseAndReconcileTests { ) throws { let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Inspection") try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: folderURL, withIntermediateDirectories: true ) try Data([0x01]).write( - to: folderURL.appendingPathComponent("pages/0001.jpg"), + to: folderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic ) } diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index a88d46e6e..233d8aa5d 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -249,9 +249,7 @@ private extension DownloadProcessCacheTests { ) throws { let completedFolderURL = storage.folderURL(relativePath: "\(gid) - Pause Race") try FileManager.default.createDirectory( - at: completedFolderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, isDirectory: true - ), + at: completedFolderURL, withIntermediateDirectories: true ) let staleManifest = try sampleManifest( diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index ff8e6faca..992633b24 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -265,20 +265,16 @@ private extension DownloadProcessTests { let folderURL = storage.folderURL(relativePath: "\(gid) - Pause Race") try? FileManager.default.removeItem(at: folderURL) try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent( - Defaults.FilePath.downloadPages, isDirectory: true - ), + at: folderURL, withIntermediateDirectories: true ) try storage.writeManifest(staleManifest, folderURL: folderURL) try Data([0x00]).write( - to: folderURL.appendingPathComponent("cover.jpg"), + to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic ) try Data([UInt8(pageIndex % 255)]).write( - to: folderURL.appendingPathComponent( - "pages/\(String(format: "%04d", pageIndex)).jpg" - ), + to: folderURL.appendingPathComponent("\(gid)_token_\(pageIndex).jpg"), options: .atomic ) return folderURL @@ -306,7 +302,7 @@ private extension DownloadProcessTests { ) #expect( FileManager.default.fileExists( - atPath: completedFolderURL.appendingPathComponent("pages/0001.jpg").path + atPath: completedFolderURL.appendingPathComponent("123_token_1.jpg").path ) == false ) diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index a5fbd8473..41dfc4374 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -31,6 +31,10 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { pageCount: setup.pageCount ) try writeFinalManifest(storage: storage, gid: gid, manifest: manifest) + await manager.testingSetDownloadError( + .init(code: .fileOperationFailed, message: "Page \(pageIndex) is missing."), + gid: gid + ) let blocker = Task { try? await Task.sleep(for: .seconds(60)) } @@ -115,7 +119,7 @@ private extension DownloadRetryMinimalSourceTests { withIntermediateDirectories: true ) try Data([0x00]).write( - to: folderURL.appendingPathComponent("cover.jpg"), + to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic ) try storage.writeManifest(manifest, folderURL: folderURL) diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index cff798893..136441e57 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -28,7 +28,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { [ .init( index: 2, - relativePath: "pages/0002.jpg", + relativePath: "123_token_2.jpg", error: .networkingFailed ) ], diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 6e2c3329f..f8d05b338 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -30,10 +30,10 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { ) try FileManager.default.createDirectory( - at: folderURL.appendingPathComponent(Defaults.FilePath.downloadPages, isDirectory: true), + at: folderURL, withIntermediateDirectories: true ) - let pageURL = folderURL.appendingPathComponent("pages/0001.jpg") + let pageURL = folderURL.appendingPathComponent("123_token_1.jpg") try Data([0x01]).write( to: pageURL, options: .atomic diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift index 568cd1b8a..1a6acda63 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -95,16 +95,16 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { await store.send(.fetchDatabaseInfos(download.gid)) #expect(store.state.gallery.id == download.gid) - #expect(store.state.imageURLs[1] == folderURL.appendingPathComponent("pages/0001.jpg")) - #expect(store.state.imageURLs[2] == folderURL.appendingPathComponent("pages/0002.jpg")) + #expect(store.state.imageURLs[1] == folderURL.appendingPathComponent("123_token_1.jpg")) + #expect(store.state.imageURLs[2] == folderURL.appendingPathComponent("123_token_2.jpg")) await store.send(.fetchImageURLs(1)) { $0.imageURLLoadingStates[1] = .idle } await store.send(.reloadAllWebImages) - #expect(store.state.imageURLs[1] == folderURL.appendingPathComponent("pages/0001.jpg")) - #expect(store.state.imageURLs[2] == folderURL.appendingPathComponent("pages/0002.jpg")) + #expect(store.state.imageURLs[1] == folderURL.appendingPathComponent("123_token_1.jpg")) + #expect(store.state.imageURLs[2] == folderURL.appendingPathComponent("123_token_2.jpg")) } } From 307c37f269778efc8940ce8604465712aa928ff9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 08:54:23 +0800 Subject: [PATCH 138/614] Check SD cache From b5a348d2a280e211001da866187deade57b924c7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 08:54:28 +0800 Subject: [PATCH 139/614] Drop quick search From 127d56c23f17936dee10b3c18ed2faf2f28387d5 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 9 Jun 2026 08:58:26 +0800 Subject: [PATCH 140/614] Fix observer test --- .../Tests/Download/DownloadObserverBatchTests.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 248d03156..057209ede 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -102,7 +102,12 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { var pendingResolvedPages = [DownloadManager.PageResult]() var lastFlushDate = Date.distantPast for index in 1...pageCount { - let relativePath = "pages/\(String(format: "%04d", index)).jpg" + let relativePath = storage.makePageRelativePath( + gid: gid, + token: "token", + index: index, + fileExtension: "jpg" + ) try Data([UInt8(index)]).write( to: folderURL.appendingPathComponent(relativePath), options: .atomic From e360ff82183e37599192a72d633d9bbc96d98312 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 08:51:59 +0800 Subject: [PATCH 141/614] Isolate automation tests --- .../Clients/AppLaunchAutomationClient.swift | 43 +++++++ EhPanda/App/Tools/Clients/CookieClient.swift | 113 +++++++++++++++--- .../Tools/Utilities/AppLaunchAutomation.swift | 4 +- EhPanda/DataFlow/AppReducer.swift | 8 +- .../View/Detail/DetailReducer+Download.swift | 2 +- EhPanda/View/Detail/DetailReducer.swift | 1 + EhPanda/View/TabBar/TabBarView.swift | 2 +- .../Download/DetailReducerDownloadTests.swift | 10 +- .../DetailReducerPauseAndGuardTests.swift | 6 +- .../Download/DownloadAutomationTests.swift | 69 ++++++----- 10 files changed, 197 insertions(+), 61 deletions(-) create mode 100644 EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift diff --git a/EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift b/EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift new file mode 100644 index 000000000..8b1ae8287 --- /dev/null +++ b/EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift @@ -0,0 +1,43 @@ +// +// AppLaunchAutomationClient.swift +// EhPanda +// + +import Dependencies + +struct AppLaunchAutomationClient: Sendable { + let current: @Sendable () -> AppLaunchAutomation? +} + +extension AppLaunchAutomationClient { + static let live: Self = .init( + current: { + AppLaunchAutomation.current + } + ) +} + +enum AppLaunchAutomationClientKey: DependencyKey { + static let liveValue = AppLaunchAutomationClient.live + static let previewValue = AppLaunchAutomationClient.none + static let testValue = AppLaunchAutomationClient.unimplemented +} + +extension DependencyValues { + var appLaunchAutomationClient: AppLaunchAutomationClient { + get { self[AppLaunchAutomationClientKey.self] } + set { self[AppLaunchAutomationClientKey.self] = newValue } + } +} + +extension AppLaunchAutomationClient { + static let none: Self = .init( + current: { nil } + ) + + static func placeholder() -> Result { fatalError() } + + static let unimplemented: Self = .init( + current: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift index 673b80fbc..de6c27d3a 100644 --- a/EhPanda/App/Tools/Clients/CookieClient.swift +++ b/EhPanda/App/Tools/Clients/CookieClient.swift @@ -5,6 +5,9 @@ import Foundation import ComposableArchitecture +#if DEBUG +import Synchronization +#endif struct CookieClient: Sendable { let clearAll: @Sendable () -> Void @@ -12,6 +15,7 @@ struct CookieClient: Sendable { private let removeCookie: @Sendable (URL, String) -> Void private let checkExistence: @Sendable (URL, String) -> Bool private let initializeCookie: @Sendable (HTTPCookie, String) -> HTTPCookie + private let setCookieValue: @Sendable (URL, String, String, String, TimeInterval, Bool) -> Void } extension CookieClient { @@ -74,6 +78,21 @@ extension CookieClient { var properties = cookie.properties properties?[.value] = value return HTTPCookie(properties: properties ?? [:]) ?? HTTPCookie() + }, + setCookieValue: { url, key, value, path, expiresTime, sessionOnly in + let properties: [HTTPCookiePropertyKey: Any] = [ + .path: path, .name: key, .value: value, + .originURL: url + ] + var mutableProperties = properties + if sessionOnly { + mutableProperties[.discard] = "TRUE" + } else { + mutableProperties[.expires] = Date(timeIntervalSinceNow: expiresTime) + } + if let cookie = HTTPCookie(properties: mutableProperties) { + HTTPCookieStorage.shared.setCookie(cookie) + } } ) } @@ -128,19 +147,7 @@ extension CookieClient { expiresTime: TimeInterval = .oneYear, sessionOnly: Bool = false ) { - let properties: [HTTPCookiePropertyKey: Any] = [ - .path: path, .name: key, .value: value, - .originURL: url - ] - var mutableProperties = properties - if sessionOnly { - mutableProperties[.discard] = "TRUE" - } else { - mutableProperties[.expires] = Date(timeIntervalSinceNow: expiresTime) - } - if let cookie = HTTPCookie(properties: mutableProperties) { - HTTPCookieStorage.shared.setCookie(cookie) - } + setCookieValue(url, key, value, path, expiresTime, sessionOnly) } func editCookie(for url: URL, key: String, value: String) { var newCookie: HTTPCookie? @@ -317,7 +324,8 @@ extension CookieClient { getCookie: { _, _ in .empty }, removeCookie: { _, _ in }, checkExistence: { _, _ in false }, - initializeCookie: { _, _ in .init() } + initializeCookie: { _, _ in .init() }, + setCookieValue: { _, _, _, _, _, _ in } ) static func placeholder() -> Result { fatalError() } @@ -327,6 +335,81 @@ extension CookieClient { getCookie: IssueReporting.unimplemented(placeholder: placeholder()), removeCookie: IssueReporting.unimplemented(placeholder: placeholder()), checkExistence: IssueReporting.unimplemented(placeholder: placeholder()), - initializeCookie: IssueReporting.unimplemented(placeholder: placeholder()) + initializeCookie: IssueReporting.unimplemented(placeholder: placeholder()), + setCookieValue: IssueReporting.unimplemented(placeholder: placeholder()) ) } + +#if DEBUG +private final class CookieClientTestingStore: Sendable { + private let cookies: Mutex<[String: String]> + + init(cookies: [String: String]) { + self.cookies = Mutex(cookies) + } + + func value(for url: URL, key: String) -> String { + cookies.withLock { $0[storageKey(url: url, key: key)] ?? "" } + } + + func setValue(_ value: String, for url: URL, key: String) { + cookies.withLock { $0[storageKey(url: url, key: key)] = value } + } + + func removeValue(for url: URL, key: String) { + cookies.withLock { $0[storageKey(url: url, key: key)] = nil } + } + + func removeAll() { + cookies.withLock { $0.removeAll() } + } + + private func storageKey(url: URL, key: String) -> String { + "\(url.absoluteString)|\(key)" + } +} + +extension CookieClient { + static func testing( + memberID: String = "", + passHash: String = "", + igneous: String? = nil + ) -> Self { + let store = CookieClientTestingStore(cookies: [:]) + for url in [Defaults.URL.ehentai, Defaults.URL.exhentai, Defaults.URL.sexhentai] { + if !memberID.isEmpty { + store.setValue(memberID, for: url, key: Defaults.Cookie.ipbMemberId) + } + if !passHash.isEmpty { + store.setValue(passHash, for: url, key: Defaults.Cookie.ipbPassHash) + } + } + if let igneous, !igneous.isEmpty { + for url in [Defaults.URL.exhentai, Defaults.URL.sexhentai] { + store.setValue(igneous, for: url, key: Defaults.Cookie.igneous) + } + } + + return .init( + clearAll: { + store.removeAll() + }, + getCookie: { url, key in + .init(rawValue: store.value(for: url, key: key), localizedString: "") + }, + removeCookie: { url, key in + store.removeValue(for: url, key: key) + }, + checkExistence: { _, _ in + false + }, + initializeCookie: { _, _ in + .init() + }, + setCookieValue: { url, key, value, _, _, _ in + store.setValue(value, for: url, key: key) + } + ) + } +} +#endif diff --git a/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift b/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift index 0fe291f27..3cf3e4349 100644 --- a/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift +++ b/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift @@ -5,8 +5,8 @@ import Foundation -struct AppLaunchAutomation { - struct LoginCookies { +struct AppLaunchAutomation: Sendable { + struct LoginCookies: Sendable { let memberID: String let passHash: String let igneous: String? diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index b74865971..38e83d236 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -45,6 +45,7 @@ struct AppReducer { @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient @Dependency(\.deviceClient) private var deviceClient + @Dependency(\.appLaunchAutomationClient) private var appLaunchAutomationClient @Dependency(\.urlClient) private var urlClient var body: some Reducer { @@ -81,7 +82,7 @@ struct AppReducer { case .runLaunchAutomation: guard !state.didRunLaunchAutomation, - let automation = AppLaunchAutomation.current + let automation = appLaunchAutomationClient.current() else { return .none } state.didRunLaunchAutomation = true @@ -95,8 +96,9 @@ struct AppReducer { } case .appDelegate(.migration(.onDatabasePreparationSuccess)): + let loginCookies = appLaunchAutomationClient.current()?.loginCookies return .run { send in - if let loginCookies = AppLaunchAutomation.current?.loginCookies { + if let loginCookies { cookieClient.importAutomationCookies( memberID: loginCookies.memberID, passHash: loginCookies.passHash, @@ -265,7 +267,7 @@ private extension AppReducer { func shouldDelayLaunchAutomationUntilIgneous(state: State) -> Bool { guard !state.didRunLaunchAutomation, cookieClient.shouldFetchIgneous, - let automation = AppLaunchAutomation.current + let automation = appLaunchAutomationClient.current() else { return false } if let galleryURL = automation.galleryURL, diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index e1918e8c2..345d55bfe 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -152,7 +152,7 @@ extension DetailReducer { state: inout State ) -> Effect { guard !state.didRunLaunchAutomation, - AppLaunchAutomation.current?.autoDownloadGID == state.gallery.id, + appLaunchAutomationClient.current()?.autoDownloadGID == state.gallery.id, state.galleryDetail != nil, state.hasLoadedDownloadBadge else { return .none } diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index cef9a67e6..05025c4e9 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -148,6 +148,7 @@ struct DetailReducer { @Dependency(\.downloadClient) var downloadClient @Dependency(\.hapticsClient) var hapticsClient @Dependency(\.cookieClient) var cookieClient + @Dependency(\.appLaunchAutomationClient) var appLaunchAutomationClient var body: some Reducer { detailBody } } diff --git a/EhPanda/View/TabBar/TabBarView.swift b/EhPanda/View/TabBar/TabBarView.swift index f70aff1c1..56e9ac154 100644 --- a/EhPanda/View/TabBar/TabBarView.swift +++ b/EhPanda/View/TabBar/TabBarView.swift @@ -124,7 +124,7 @@ struct TabBarView: View { } // MARK: TabType -enum TabBarItemType: Int, CaseIterable, Identifiable { +enum TabBarItemType: Int, CaseIterable, Identifiable, Sendable { var id: Int { rawValue } case home diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index 3440c0029..816adba3d 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -88,12 +88,10 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { let options = DownloadRequestOptions() let previewURL = try #require(URL(string: "https://example.com/1.jpg")) - setenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID", gallery.gid, 1) - defer { unsetenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID") } - let store = makeDownloadTestStore( gallery: gallery, detail: detail, badgeValue: .queued, + automationGID: gallery.gid, configure: { state in state.gid = "" state.galleryPreviewURLs = [1: previewURL] @@ -128,6 +126,7 @@ private extension DetailReducerDownloadTests { func makeDownloadTestStore( gallery: Gallery, detail: GalleryDetail, badgeValue: DownloadBadge, + automationGID: String? = nil, configure: (inout DetailReducer.State) -> Void = { _ in }, enqueue: @escaping @Sendable (DownloadRequestPayload) async -> Result ) -> TestStoreOf { @@ -159,6 +158,11 @@ private extension DetailReducerDownloadTests { $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop + if let automationGID { + $0.appLaunchAutomationClient = appLaunchAutomationClient( + autoDownloadGID: automationGID + ) + } } } } diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index 113f5d399..4cae2eadc 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -21,12 +21,12 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { initialState.gallery = gallery initialState.galleryDetail = detail - setenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID", gallery.gid, 1) - defer { unsetenv("EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID") } - let store = TestStore(initialState: initialState) { DetailReducer() } withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient( + autoDownloadGID: gallery.gid + ) $0.downloadClient = .noop $0.hapticsClient = .noop $0.databaseClient = .noop diff --git a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift index ce3f676ef..5e7aa157a 100644 --- a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift @@ -66,16 +66,17 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { @MainActor @Test func testRunLaunchAutomationFallsBackToInitialTabWhenGalleryURLIsUnhandleable() async { - setenv("EHPANDA_AUTOMATION_TAB", "downloads", 1) - setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://example.com/not-a-gallery", 1) - defer { - unsetenv("EHPANDA_AUTOMATION_TAB") - unsetenv("EHPANDA_AUTOMATION_GALLERY_URL") - } + let automation = AppLaunchAutomation( + initialTab: .downloads, + autoDownloadGID: nil, + loginCookies: nil, + galleryURL: URL(string: "https://example.com/not-a-gallery") + ) let store = TestStore(initialState: AppReducer.State()) { AppReducer() } withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) $0.cookieClient = .noop $0.deviceClient = .noop $0.hapticsClient = .noop @@ -97,19 +98,22 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { @MainActor @Test func testDatabasePreparationImportsAutomationCookiesBeforeLoadingSettings() async { - let cookieClient = CookieClient.live - cookieClient.clearAll() - setenv("EHPANDA_AUTOMATION_IPB_MEMBER_ID", "4172984", 1) - setenv("EHPANDA_AUTOMATION_IPB_PASS_HASH", "pass-hash", 1) - defer { - cookieClient.clearAll() - unsetenv("EHPANDA_AUTOMATION_IPB_MEMBER_ID") - unsetenv("EHPANDA_AUTOMATION_IPB_PASS_HASH") - } + let cookieClient = CookieClient.testing() + let automation = AppLaunchAutomation( + initialTab: nil, + autoDownloadGID: nil, + loginCookies: .init( + memberID: "4172984", + passHash: "pass-hash", + igneous: nil + ), + galleryURL: nil + ) let store = TestStore(initialState: AppReducer.State()) { AppReducer() } withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) $0.cookieClient = cookieClient $0.databaseClient = .noop $0.deviceClient = .noop @@ -127,29 +131,29 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { await store.send(.appDelegate(.migration(.onDatabasePreparationSuccess))) await store.receive(\.appDelegate.removeExpiredImageURLs) - #expect(cookieClient.didLogin) + #expect(cookieClient.shouldFetchIgneous) await store.receive(\.setting.loadUserSettings) } @MainActor @Test func testLoadUserSettingsDefersExLaunchAutomationUntilIgneousArrives() async throws { - let cookieClient = CookieClient.live - cookieClient.clearAll() - cookieClient.importAutomationCookies( + let cookieClient = CookieClient.testing( memberID: "4172984", passHash: "pass-hash", igneous: nil ) - setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://exhentai.org/g/1394965/56c35114b6/", 1) - defer { - cookieClient.clearAll() - unsetenv("EHPANDA_AUTOMATION_GALLERY_URL") - } + let automation = AppLaunchAutomation( + initialTab: nil, + autoDownloadGID: nil, + loginCookies: nil, + galleryURL: URL(string: "https://exhentai.org/g/1394965/56c35114b6/") + ) let store = TestStore(initialState: AppReducer.State()) { AppReducer() } withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) $0.cookieClient = cookieClient $0.databaseClient = .noop $0.deviceClient = .noop @@ -191,22 +195,22 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { @MainActor @Test func testLoadUserSettingsKeepsExLaunchAutomationDeferredWhenIgneousFetchFails() async { - let cookieClient = CookieClient.live - cookieClient.clearAll() - cookieClient.importAutomationCookies( + let cookieClient = CookieClient.testing( memberID: "4172984", passHash: "pass-hash", igneous: nil ) - setenv("EHPANDA_AUTOMATION_GALLERY_URL", "https://exhentai.org/g/1394965/56c35114b6/", 1) - defer { - cookieClient.clearAll() - unsetenv("EHPANDA_AUTOMATION_GALLERY_URL") - } + let automation = AppLaunchAutomation( + initialTab: nil, + autoDownloadGID: nil, + loginCookies: nil, + galleryURL: URL(string: "https://exhentai.org/g/1394965/56c35114b6/") + ) let store = TestStore(initialState: AppReducer.State()) { AppReducer() } withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) $0.cookieClient = cookieClient $0.databaseClient = .noop $0.deviceClient = .noop @@ -235,5 +239,4 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { #expect(store.state.didRunLaunchAutomation == false) #expect(store.state.isAwaitingIgneousForLaunchAutomation) } - } From f1e43a895d5cbf5cf580f6f021220e316bde5d36 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 08:52:20 +0800 Subject: [PATCH 142/614] Clean download models --- .../Persistent/DownloadDisplayStatus.swift | 21 +++- .../DownloadedGallery+Manifest.swift | 11 -- .../DownloadedGallery+SupportTypes.swift | 40 +------ .../Models/Persistent/DownloadedGallery.swift | 53 ++-------- .../Downloads/DownloadInspectorReducer.swift | 2 +- .../Reading/ReadingReducer+Database.swift | 4 +- .../Download/DownloadBadgeSortTests.swift | 14 +-- .../DownloadFeatureTestFactories.swift | 100 ++++++++++++++++-- .../Download/DownloadInspectorLoadTests.swift | 9 +- .../DownloadInspectorRetryTests.swift | 6 +- .../DownloadObserverReadingTests.swift | 12 ++- .../DownloadedGalleryManifestModelTests.swift | 2 + .../Download/ReadingReducerLocalTests.swift | 18 +++- 13 files changed, 169 insertions(+), 123 deletions(-) diff --git a/EhPanda/Models/Persistent/DownloadDisplayStatus.swift b/EhPanda/Models/Persistent/DownloadDisplayStatus.swift index 823ce2789..b05741a6c 100644 --- a/EhPanda/Models/Persistent/DownloadDisplayStatus.swift +++ b/EhPanda/Models/Persistent/DownloadDisplayStatus.swift @@ -3,7 +3,7 @@ // EhPanda // -enum DownloadDisplayStatus: Int, Equatable, CaseIterable, Sendable { +enum DownloadDisplayStatus: Equatable, CaseIterable, Sendable { case active case queued case updateAvailable @@ -11,3 +11,22 @@ enum DownloadDisplayStatus: Int, Equatable, CaseIterable, Sendable { case inactive case completed } + +extension DownloadDisplayStatus { + var sortPriority: Int { + switch self { + case .active: + return 0 + case .queued: + return 1 + case .updateAvailable: + return 2 + case .error: + return 3 + case .inactive: + return 4 + case .completed: + return 5 + } + } +} diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift index 411432fc7..0cd1f504d 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift @@ -19,17 +19,6 @@ struct DownloadManifest: Codable, Equatable, Sendable { let postedDate: Date let rating: Float let pages: [Int: String] - - func imageURLs(folderURL: URL) -> [Int: URL] { - DownloadFileStorage(rootURL: folderURL.deletingLastPathComponent()) - .existingPageRelativePaths( - folderURL: folderURL, - expectedPageCount: pageCount - ) - .reduce(into: [Int: URL]()) { result, entry in - result[entry.key] = folderURL.appendingPathComponent(entry.value) - } - } } extension DownloadManifest { diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index 2a22a99fb..0ba1564d9 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -24,35 +24,12 @@ extension DownloadedGallery { .joined(separator: " ") } - func resolvedFolderURL(rootURL _: URL = FileUtil.downloadsDirectoryURL) -> URL { - folderURL - } - - func resolvedManifestURL(rootURL _: URL = FileUtil.downloadsDirectoryURL) -> URL { - folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) - } - - func resolvedLocalCoverURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL? { - return DownloadFileStorage(rootURL: rootURL) - .existingCoverRelativePath(folderURL: folderURL) - .map { folderURL.appendingPathComponent($0) } - } - - func resolvedCoverURL(rootURL: URL = FileUtil.downloadsDirectoryURL) -> URL? { - resolvedLocalCoverURL(rootURL: rootURL) - ?? onlineCoverURL - } - var manifestURL: URL { - resolvedManifestURL() - } - - var localCoverURL: URL? { - resolvedLocalCoverURL() + folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) } var coverURL: URL? { - resolvedCoverURL() + localCoverURL ?? onlineCoverURL } var badge: DownloadBadge { @@ -91,10 +68,7 @@ extension DownloadedGallery { pageCount: pageCount, postedDate: postedDate, coverURL: coverURL, - galleryURL: host.url - .appendingPathComponent("g") - .appendingPathComponent(gid) - .appendingPathComponent(token) + galleryURL: manifest.galleryURL ) } @@ -112,15 +86,11 @@ extension DownloadedGallery { } var canTogglePause: Bool { - canPauseOrResume || isPendingQueue - } - - var isPendingQueue: Bool { - badge == .queued + canPauseOrResume || isQueuedWorkItem } var canCancelFromDetailAction: Bool { - isPendingQueue || canPauseOrResume || displayStatus == .completed + isQueuedWorkItem || canPauseOrResume || displayStatus == .completed } var canTriggerUpdate: Bool { diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift index 7e30e63be..8fb00204c 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -10,6 +10,8 @@ struct DownloadedGallery: Identifiable, Equatable { let manifest: DownloadManifest let folderURL: URL + let localCoverURL: URL? + let localPageURLs: [Int: URL] let displayStatus: DownloadDisplayStatus let lastDownloadedAt: Date? let lastError: DownloadFailure? @@ -28,62 +30,19 @@ struct DownloadedGallery: Identifiable, Equatable { var onlineCoverURL: URL? { manifest.remoteCoverURL } var completedPageCount: Int { manifest.completedPageCount } - init( - gid: String, - host: GalleryHost, - token: String, - title: String, - jpnTitle: String?, - uploader: String?, - category: Category, - tags: [GalleryTag], - pageCount: Int, - postedDate: Date, - rating: Float, - onlineCoverURL: URL?, - folderURL: URL, - displayStatus: DownloadDisplayStatus, - completedPageCount: Int, - lastDownloadedAt: Date?, - lastError: DownloadFailure? - ) { - let clampedCompletedPageCount = min(max(completedPageCount, 0), pageCount) - self.manifest = DownloadManifest( - gid: gid, - host: host, - token: token, - title: title, - jpnTitle: jpnTitle, - category: category, - language: .japanese, - remoteCoverURL: onlineCoverURL, - uploader: uploader, - tags: tags, - postedDate: postedDate, - rating: rating, - pages: pageCount > 0 - ? Dictionary( - uniqueKeysWithValues: (1...pageCount).map { - ($0, $0 <= clampedCompletedPageCount ? "sha256:fixture-\($0)" : "") - } - ) - : [:] - ) - self.folderURL = folderURL - self.displayStatus = displayStatus - self.lastDownloadedAt = lastDownloadedAt - self.lastError = lastError - } - init( manifest: DownloadManifest, folderURL: URL, + localCoverURL: URL?, + localPageURLs: [Int: URL], modifiedAt: Date?, displayStatus: DownloadDisplayStatus, lastError: DownloadFailure? = nil ) { self.manifest = manifest self.folderURL = folderURL + self.localCoverURL = localCoverURL + self.localPageURLs = localPageURLs self.displayStatus = displayStatus self.lastDownloadedAt = modifiedAt self.lastError = lastError diff --git a/EhPanda/View/Downloads/DownloadInspectorReducer.swift b/EhPanda/View/Downloads/DownloadInspectorReducer.swift index 0891c9879..83d2a1e15 100644 --- a/EhPanda/View/Downloads/DownloadInspectorReducer.swift +++ b/EhPanda/View/Downloads/DownloadInspectorReducer.swift @@ -275,7 +275,7 @@ private extension Optional where Wrapped == DownloadValidationState { extension DownloadInspectorReducer.State { func shouldKeepRetryPending(for download: DownloadedGallery) -> Bool { download.canPauseOrResume - || download.isPendingQueue + || download.isQueuedWorkItem || ( [.inactive, .error].contains(download.displayStatus) && download.isIncomplete diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift index 4f7b2429c..70c604281 100644 --- a/EhPanda/View/Reading/ReadingReducer+Database.swift +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -175,11 +175,9 @@ extension ReadingReducer { download: DownloadedGallery, manifest: DownloadManifest ) { - let folderURL = download.folderURL - state.gallery = download.gallery state.language = manifest.language - let imageURLs = manifest.imageURLs(folderURL: folderURL) + let imageURLs = download.localPageURLs state.localPageURLs = imageURLs state.previewConfig = .normal(rows: 4) state.previewURLs = imageURLs diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index 77a2af03a..000341618 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -76,7 +76,7 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { let sortedDownloads = [completedDownload, queuedRedownload].sorted { lhs, rhs in if lhs.displayStatus != rhs.displayStatus { - return lhs.displayStatus.rawValue < rhs.displayStatus.rawValue + return lhs.displayStatus.sortPriority < rhs.displayStatus.sortPriority } return (lhs.lastDownloadedAt ?? .distantPast) > (rhs.lastDownloadedAt ?? .distantPast) } @@ -89,23 +89,25 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { @Test func testInProgressDownloadUsesFinalCoverURL() throws { let gid = "811" + let folderURL = FileUtil.downloadsDirectoryURL + .appendingPathComponent("[\(gid)_token] Local Cover Archive", isDirectory: true) + let coverURL = folderURL.appendingPathComponent("\(gid)_token_cover.jpg") let download = sampleDownload( gid: gid, title: "Local Cover Archive", status: .downloading, - completedPageCount: 3 + completedPageCount: 3, + folderURL: folderURL, + localCoverURL: coverURL ) - let rootURL = FileUtil.downloadsDirectoryURL - let folderURL = download.folderURL try? FileManager.default.removeItem(at: folderURL) defer { try? FileManager.default.removeItem(at: folderURL) } try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - let coverURL = folderURL.appendingPathComponent("\(gid)_token_cover.jpg") try Data([0xFF, 0xD8, 0xFF]).write(to: coverURL, options: .atomic) - #expect(download.resolvedCoverURL(rootURL: rootURL) == coverURL) + #expect(download.coverURL == coverURL) } @Test diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index d02763cc4..eb71dba4e 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -60,7 +60,82 @@ enum DownloadFixtureStatus { } } +extension DownloadedGallery { + init( + gid: String, + host: GalleryHost, + token: String, + title: String, + jpnTitle: String?, + uploader: String?, + category: EhPanda.Category, + tags: [GalleryTag], + pageCount: Int, + postedDate: Date, + rating: Float, + onlineCoverURL: URL?, + folderURL: URL, + localCoverURL: URL? = nil, + localPageURLs: [Int: URL] = [:], + displayStatus: DownloadDisplayStatus, + completedPageCount: Int, + lastDownloadedAt: Date?, + lastError: DownloadFailure? + ) { + let clampedCompletedPageCount = min(max(completedPageCount, 0), pageCount) + let manifest = DownloadManifest( + gid: gid, + host: host, + token: token, + title: title, + jpnTitle: jpnTitle, + category: category, + language: .japanese, + remoteCoverURL: onlineCoverURL, + uploader: uploader, + tags: tags, + postedDate: postedDate, + rating: rating, + pages: pageCount > 0 + ? Dictionary( + uniqueKeysWithValues: (1...pageCount).map { + ($0, $0 <= clampedCompletedPageCount ? "sha256:fixture-\($0)" : "") + } + ) + : [:] + ) + self.init( + manifest: manifest, + folderURL: folderURL, + localCoverURL: localCoverURL, + localPageURLs: localPageURLs, + modifiedAt: lastDownloadedAt, + displayStatus: displayStatus, + lastError: lastError + ) + } +} + extension DownloadFeatureTestCase { + func appLaunchAutomationClient( + _ automation: AppLaunchAutomation? + ) -> AppLaunchAutomationClient { + .init(current: { automation }) + } + + func appLaunchAutomationClient( + autoDownloadGID: String + ) -> AppLaunchAutomationClient { + appLaunchAutomationClient( + AppLaunchAutomation( + initialTab: nil, + autoDownloadGID: autoDownloadGID, + loginCookies: nil, + galleryURL: nil + ) + ) + } + func sampleManifest( gid: String, title: String, @@ -95,14 +170,14 @@ extension DownloadFeatureTestCase { .init( index: 1, status: .downloaded, - relativePath: "123_token_1.jpg", + relativePath: "\(download.gid)_\(download.token)_1.jpg", fileURL: URL(fileURLWithPath: "/tmp/0001.jpg"), failure: nil ), .init( index: 2, status: .failed, - relativePath: "123_token_2.jpg", + relativePath: "\(download.gid)_\(download.token)_2.jpg", fileURL: nil, failure: .init(code: .networkingFailed, message: "Network Error") ) @@ -119,9 +194,13 @@ extension DownloadFeatureTestCase { completedPageCount: Int? = nil, lastDownloadedAt: Date? = .now, lastError: DownloadFailure? = nil, - folderURL: URL? = nil + folderURL: URL? = nil, + localCoverURL: URL? = nil, + localPageURLs: [Int: URL] = [:] ) -> DownloadedGallery { - DownloadedGallery( + let resolvedFolderURL = folderURL ?? FileUtil.downloadsDirectoryURL + .appendingPathComponent("[\(gid)_token] \(title)", isDirectory: true) + return DownloadedGallery( gid: gid, host: .ehentai, token: "token", @@ -134,8 +213,9 @@ extension DownloadFeatureTestCase { postedDate: .now, rating: 4, onlineCoverURL: URL(string: "https://example.com/cover.jpg"), - folderURL: folderURL ?? FileUtil.downloadsDirectoryURL - .appendingPathComponent("[\(gid)_token] \(title)", isDirectory: true), + folderURL: resolvedFolderURL, + localCoverURL: localCoverURL, + localPageURLs: localPageURLs, displayStatus: status.displayStatus, completedPageCount: completedPageCount ?? status.defaultCompletedPageCount(pageCount: pageCount), @@ -159,11 +239,15 @@ extension DownloadFeatureTestCase { options: .atomic ) try Data([0x01]).write( - to: folderURL.appendingPathComponent("123_token_1.jpg"), + to: folderURL.appendingPathComponent( + "\(manifest.gid)_\(manifest.token)_1.jpg" + ), options: .atomic ) try Data([0x02]).write( - to: folderURL.appendingPathComponent("123_token_2.jpg"), + to: folderURL.appendingPathComponent( + "\(manifest.gid)_\(manifest.token)_2.jpg" + ), options: .atomic ) return folderURL diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift index 0a3bfde95..2e043d64b 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -101,11 +101,13 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { coverURL: inspection.coverURL, pages: [ .init( - index: 1, status: .downloaded, relativePath: "123_token_1.jpg", + index: 1, status: .downloaded, + relativePath: "\(download.gid)_\(download.token)_1.jpg", fileURL: URL(fileURLWithPath: "/tmp/0001.jpg"), failure: nil ), .init( - index: 2, status: .pending, relativePath: "123_token_2.jpg", + index: 2, status: .pending, + relativePath: "\(download.gid)_\(download.token)_2.jpg", fileURL: nil, failure: nil ) ] @@ -267,7 +269,8 @@ extension DownloadInspectorLoadTests { fileURL: nil, failure: nil ), .init( - index: 2, status: .failed, relativePath: "123_token_2.jpg", + index: 2, status: .failed, + relativePath: "\(download.gid)_\(download.token)_2.jpg", fileURL: nil, failure: .init(code: .networkingFailed, message: "Network Error") ) diff --git a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift index e2dcd171a..afc951632 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -39,7 +39,8 @@ struct DownloadInspectorRetryTests: DownloadFeatureTestCase { pages: [ refreshedInspection.pages[0], .init( - index: 2, status: .pending, relativePath: "123_token_2.jpg", + index: 2, status: .pending, + relativePath: "\(download.gid)_\(download.token)_2.jpg", fileURL: nil, failure: nil ) ] @@ -97,7 +98,8 @@ struct DownloadInspectorRetryTests: DownloadFeatureTestCase { pages: [ stableInspection.pages[0], .init( - index: 2, status: .pending, relativePath: "123_token_2.jpg", + index: 2, status: .pending, + relativePath: "\(download.gid)_\(download.token)_2.jpg", fileURL: nil, failure: nil ) ] diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index 3404a1983..650572412 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -14,8 +14,17 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { @MainActor @Test func testReadingReducerLocalSourceWithoutGalleryStateDoesNotStayLoading() async throws { + let gid = "700001" + let title = "Offline Gallery" + let folderURL = FileUtil.downloadsDirectoryURL + .appendingPathComponent("[\(gid)_token] \(title)", isDirectory: true) + let localPageURLs = [ + 1: folderURL.appendingPathComponent("123_token_1.jpg"), + 2: folderURL.appendingPathComponent("123_token_2.jpg") + ] let download = sampleDownload( - gid: "700001", title: "Offline Gallery", status: .completed, pageCount: 2, completedPageCount: 2 + gid: gid, title: title, status: .completed, pageCount: 2, completedPageCount: 2, + folderURL: folderURL, localPageURLs: localPageURLs ) let manifest = try sampleManifest(gid: download.gid, title: download.title) let store = TestStore( @@ -34,7 +43,6 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { $0.urlClient = .noop } store.exhaustivity = .off - let folderURL = download.folderURL defer { try? FileManager.default.removeItem(at: folderURL) } try FileManager.default.createDirectory( at: folderURL, diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index b24359f7a..5e508f353 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -42,6 +42,8 @@ struct DownloadedGalleryManifestModelTests { let download = DownloadedGallery( manifest: manifest, folderURL: URL(fileURLWithPath: "/tmp/[123_token] Sample", isDirectory: true), + localCoverURL: nil, + localPageURLs: [:], modifiedAt: modifiedAt, displayStatus: .queued ) diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift index 1a6acda63..20a650efc 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -67,14 +67,24 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { @MainActor @Test func testReadingReducerLocalSourceLoadsOfflineImagesWithoutNetwork() async throws { + let gid = "777" + let title = "Offline Archive" + let folderURL = FileUtil.downloadsDirectoryURL + .appendingPathComponent("[\(gid)_token] \(title)", isDirectory: true) + let localPageURLs = [ + 1: folderURL.appendingPathComponent("123_token_1.jpg"), + 2: folderURL.appendingPathComponent("123_token_2.jpg") + ] let download = sampleDownload( - gid: "777", - title: "Offline Archive", + gid: gid, + title: title, status: .completed, - pageCount: 2 + pageCount: 2, + folderURL: folderURL, + localPageURLs: localPageURLs ) let manifest = try sampleManifest(gid: download.gid, title: download.title) - let folderURL = try prepareLocalDownloadFiles(download: download, manifest: manifest) + _ = try prepareLocalDownloadFiles(download: download, manifest: manifest) defer { try? FileManager.default.removeItem(at: folderURL) } let store = TestStore( From 51059d4a7a7ce2373a94885c227121d102850245 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 09:23:34 +0800 Subject: [PATCH 143/614] Fix storage identity --- .../DownloadFileStorage+Operations.swift | 33 +++++++---- .../Tools/Utilities/DownloadFileStorage.swift | 57 ++++++++----------- .../Download/DownloadFileStorageTests.swift | 24 ++++---- .../DownloadManagerRepairSeedTests.swift | 13 ++++- .../DownloadManagerStorageTests.swift | 18 +++--- .../DownloadPauseAndReconcileTests.swift | 8 +-- .../Download/DownloadProcessCacheTests.swift | 20 +++++-- .../DownloadRetryMinimalSourceTests.swift | 18 +++++- .../DownloadVersionSignatureTests.swift | 2 +- 9 files changed, 113 insertions(+), 80 deletions(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index 66908d3b5..ddb8783d4 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -58,7 +58,7 @@ extension DownloadFileStorage { let existingPages = existingPageRelativePaths( folderURL: sourceFolderURL, - expectedPageCount: manifest.pageCount + manifest: manifest ) for index in manifest.pages.keys.sorted() { guard let relativePath = existingPages[index], @@ -76,7 +76,7 @@ extension DownloadFileStorage { ) throws -> DownloadManifest { let existingPages = existingPageRelativePaths( folderURL: folderURL, - expectedPageCount: manifest.pageCount + manifest: manifest ) let pages = try manifest.pages.keys.sorted() .reduce(into: [Int: String]()) { result, index in @@ -105,10 +105,16 @@ extension DownloadFileStorage { if let relativePath { resolvedRelativePath = relativePath } else { - resolvedRelativePath = existingPageRelativePaths( - folderURL: folderURL, - expectedPageCount: (try? readManifest(folderURL: folderURL).pageCount) ?? pageIndex - )[pageIndex] + let manifest = try readManifest(folderURL: folderURL) + resolvedRelativePath = manifest.pages[pageIndex].flatMap { _ in + existingPageFileURL( + folderURL: folderURL, + gid: manifest.gid, + token: manifest.token, + index: pageIndex + )? + .lastPathComponent + } } guard let resolvedRelativePath else { return try readManifest(folderURL: folderURL) @@ -181,11 +187,11 @@ extension DownloadFileStorage { } func validate(download: DownloadedGallery) -> DownloadValidationState { - let folderURL = download.resolvedFolderURL(rootURL: rootURL) + let folderURL = download.folderURL guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadFolderMissing) } - let manifestURL = download.resolvedManifestURL(rootURL: rootURL) + let manifestURL = download.manifestURL guard fileManager.operate({ $0.fileExists(atPath: manifestURL.path) }) else { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestMissing) } @@ -204,7 +210,7 @@ extension DownloadFileStorage { func validPageCount(folderURL: URL, manifest: DownloadManifest) -> Int { let existingPages = existingPageRelativePaths( folderURL: folderURL, - expectedPageCount: manifest.pageCount + manifest: manifest ) return manifest.pages.keys.reduce(into: 0) { count, index in guard let relativePath = existingPages[index], @@ -239,7 +245,7 @@ extension DownloadFileStorage { ) -> DownloadValidationState? { let existingPages = existingPageRelativePaths( folderURL: folderURL, - expectedPageCount: manifest.pageCount + manifest: manifest ) for index in manifest.pages.keys.sorted() { if let validationFailure = validatePage( @@ -260,8 +266,11 @@ extension DownloadFileStorage { expectedHash: String, existingPageRelativePaths: [Int: String] ) -> DownloadValidationState? { - guard !expectedHash.isEmpty, - let relativePath = existingPageRelativePaths[index], + guard !expectedHash.isEmpty else { + return nil + } + + guard let relativePath = existingPageRelativePaths[index], let pageURL = validatedChildURL(root: folderURL, relativePath: relativePath), sanitizeAssetFileIfNeeded(at: pageURL) else { diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 136daefd0..a22a6edae 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -19,6 +19,8 @@ struct DownloadFolderRecord: Equatable, Sendable { } struct DownloadFileStorage: Sendable { + private static let maxFolderTitleLength = 96 + let rootURL: URL let fileManager: DownloadFileManager @@ -65,40 +67,31 @@ struct DownloadFileStorage: Sendable { rootURL.appendingPathComponent(".queue.json") } - func existingPageRelativePaths( - folderURL: URL, - expectedPageCount: Int - ) -> [Int: String] { - guard let finalPageURLs = try? fileManager.operate({ - try $0.contentsOfDirectory( - at: folderURL, - includingPropertiesForKeys: nil - ) - }) else { - return [:] + func existingPageRelativePaths(folderURL: URL, manifest: DownloadManifest) -> [Int: String] { + manifest.pages.keys.sorted().reduce(into: [:]) { result, index in + guard let fileURL = existingPageFileURL( + folderURL: folderURL, + gid: manifest.gid, + token: manifest.token, + index: index + ) else { return } + result[index] = fileURL.lastPathComponent } + } - var relativePaths = [Int: String]() - for pageURL in finalPageURLs { - guard let index = finalPageIndex(from: pageURL), - index >= 1, - index <= expectedPageCount, - sanitizeAssetFileIfNeeded(at: pageURL) - else { - continue + func imageURLs(folderURL: URL, manifest: DownloadManifest) -> [Int: URL] { + existingPageRelativePaths(folderURL: folderURL, manifest: manifest) + .reduce(into: [Int: URL]()) { result, entry in + result[entry.key] = folderURL.appendingPathComponent(entry.value) } - relativePaths[index] = pageURL.lastPathComponent - } - return relativePaths } - private func finalPageIndex(from pageURL: URL) -> Int? { - let filename = pageURL.deletingPathExtension().lastPathComponent - guard let separatorIndex = filename.lastIndex(of: "_") else { - return nil - } - let indexStart = filename.index(after: separatorIndex) - return Int(filename[indexStart...]) + func localCoverURL(folderURL: URL, manifest: DownloadManifest) -> URL? { + existingCoverFileURL( + folderURL: folderURL, + gid: manifest.gid, + token: manifest.token + ) } func existingCoverRelativePath(folderURL: URL) -> String? { @@ -121,10 +114,6 @@ struct DownloadFileStorage: Sendable { .lastPathComponent } - func makeFolderRelativePath(gid: String, title: String) -> String { - "\(gid) - \(normalizedFolderTitle(title))" - } - func makeFolderRelativePath(gid: String, token: String, title: String) -> String { "[\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))] \(normalizedFolderTitle(title))" } @@ -149,7 +138,7 @@ struct DownloadFileStorage: Sendable { with: "", options: .regularExpression ) - let limitedSlug = String(trimmedSlug.prefix(96)) + let limitedSlug = String(trimmedSlug.prefix(Self.maxFolderTitleLength)) .replacingOccurrences( of: "[\\s.]+$", with: "", diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 127de9ea1..48023f092 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -132,9 +132,10 @@ struct DownloadFileStorageTests { try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_2.jpg"), options: .atomic) try Data([0x03]).write(to: folderURL.appendingPathComponent("123_token_27.jpg"), options: .atomic) try Data([0x04]).write(to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic) + let manifest = sampleManifest(pageCount: 2) #expect( - storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2) == [ + storage.existingPageRelativePaths(folderURL: folderURL, manifest: manifest) == [ 1: "123_token_1.webp", 2: "123_token_2.jpg" ] @@ -151,8 +152,9 @@ struct DownloadFileStorageTests { let pagesFolderURL = folderURL.appendingPathComponent("pages", isDirectory: true) try FileManager.default.createDirectory(at: pagesFolderURL, withIntermediateDirectories: true) try Data([0x01]).write(to: pagesFolderURL.appendingPathComponent("0001.jpg"), options: .atomic) + let manifest = sampleManifest(pageCount: 1) - #expect(storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 1) == [:]) + #expect(storage.existingPageRelativePaths(folderURL: folderURL, manifest: manifest) == [:]) #expect(FileManager.default.fileExists(atPath: pagesFolderURL.path)) } @@ -167,9 +169,10 @@ struct DownloadFileStorageTests { let emptyPageURL = folderURL.appendingPathComponent("123_token_1.jpg") try Data().write(to: emptyPageURL, options: .atomic) try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_2.png"), options: .atomic) + let manifest = sampleManifest(pageCount: 2) #expect( - storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2) == [ + storage.existingPageRelativePaths(folderURL: folderURL, manifest: manifest) == [ 2: "123_token_2.png" ] ) @@ -188,8 +191,9 @@ struct DownloadFileStorageTests { let emptyPageURL = pagesURL.appendingPathComponent("0001.jpg") try Data().write(to: emptyPageURL, options: .atomic) try Data([0x02]).write(to: pagesURL.appendingPathComponent("0002.png"), options: .atomic) + let manifest = sampleManifest(pageCount: 2) - #expect(storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2) == [:]) + #expect(storage.existingPageRelativePaths(folderURL: folderURL, manifest: manifest) == [:]) #expect(FileManager.default.fileExists(atPath: emptyPageURL.path)) } @@ -230,16 +234,16 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } let unsafeTitle = " /Alpha\\\\Beta:\n\tGamma Delta \(String(repeating: "X", count: 200)). " - let relativePath = storage.makeFolderRelativePath(gid: "123", title: unsafeTitle) + let relativePath = storage.makeFolderRelativePath(gid: "123", token: "token", title: unsafeTitle) - #expect(relativePath.hasPrefix("123 - ")) + #expect(relativePath.hasPrefix("[123_token] ")) #expect(relativePath.contains("/") == false) #expect(relativePath.contains("\\") == false) #expect(relativePath.contains(":") == false) #expect(relativePath.contains("\n") == false) #expect(relativePath.hasSuffix(" ") == false) #expect(relativePath.hasSuffix(".") == false) - #expect(relativePath.count <= "123 - ".count + 96) + #expect(relativePath.count <= "[123_token] ".count + 96) } @Test @@ -358,15 +362,15 @@ private extension DownloadFileStorageTests { ) } - func sampleManifest(pageCount: Int) throws -> DownloadManifest { - try sampleManifest( + func sampleManifest(pageCount: Int) -> DownloadManifest { + sampleManifest( pageHashes: pageCount > 0 ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) : [:] ) } - func sampleManifest(pageHashes: [Int: String]) throws -> DownloadManifest { + func sampleManifest(pageHashes: [Int: String]) -> DownloadManifest { DownloadManifest( gid: "123", host: .ehentai, diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index db4031f7e..8109b53d8 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -246,11 +246,18 @@ private extension DownloadManagerRepairSeedTests { options: .atomic ) try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic + to: completedFolderURL.appendingPathComponent( + storage.makeCoverRelativePath(gid: gid, token: "token", fileExtension: "jpg") + ), + options: .atomic + ) + let emptyPageURL = completedFolderURL.appendingPathComponent( + storage.makePageRelativePath(gid: gid, token: "token", index: 1, fileExtension: "jpg") ) - let emptyPageURL = completedFolderURL.appendingPathComponent("123_token_1.jpg") try Data().write(to: emptyPageURL, options: .atomic) - let goodPageURL = completedFolderURL.appendingPathComponent("123_token_2.jpg") + let goodPageURL = completedFolderURL.appendingPathComponent( + storage.makePageRelativePath(gid: gid, token: "token", index: 2, fileExtension: "jpg") + ) try Data([0x02]).write(to: goodPageURL, options: .atomic) return (emptyPageURL, goodPageURL) } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index c9fe10020..8227f8ba0 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -786,14 +786,14 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { withIntermediateDirectories: true ) try Data([0x01]).write( - to: folderURL.appendingPathComponent("123_token_1.jpg"), + to: folderURL.appendingPathComponent("\(gid)_token_1.jpg"), options: .atomic ) await manager.testingSetFailedPageErrors( [ .init( index: 2, - relativePath: "123_token_2.jpg", + relativePath: "\(gid)_token_2.jpg", error: .networkingFailed ) ], @@ -836,7 +836,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { options: .atomic ) try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("123_token_cover.jpg"), + to: completedFolderURL.appendingPathComponent("\(gid)_token_cover.jpg"), options: .atomic ) let completedPageURL = completedFolderURL.appendingPathComponent("\(gid)_token_1.jpg") @@ -876,22 +876,24 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { options: .atomic ) try Data([0x00]).write( - to: completedFolderURL.appendingPathComponent("123_token_cover.jpg"), + to: completedFolderURL.appendingPathComponent("\(gid)_token_cover.jpg"), options: .atomic ) + let page1URL = completedFolderURL.appendingPathComponent("\(gid)_token_1.jpg") + let page2URL = completedFolderURL.appendingPathComponent("\(gid)_token_2.jpg") try Data([0x01]).write( - to: completedFolderURL.appendingPathComponent("123_token_1.jpg"), + to: page1URL, options: .atomic ) try Data([0x09]).write( - to: completedFolderURL.appendingPathComponent("123_token_2.jpg"), + to: page2URL, options: .atomic ) let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() - #expect(pageURLs[1] == completedFolderURL.appendingPathComponent("123_token_1.jpg")) - #expect(pageURLs[2] == completedFolderURL.appendingPathComponent("123_token_2.jpg")) + #expect(pageURLs[1] == page1URL) + #expect(pageURLs[2] == page2URL) } } diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index c931be502..227e3d135 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -95,11 +95,11 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { withIntermediateDirectories: true ) try Data([0x01]).write( - to: folderURL.appendingPathComponent("123_token_1.jpg"), + to: folderURL.appendingPathComponent("\(gid)_token_1.jpg"), options: .atomic ) try Data([0x02]).write( - to: folderURL.appendingPathComponent("123_token_2.jpg"), + to: folderURL.appendingPathComponent("\(gid)_token_2.jpg"), options: .atomic ) @@ -220,7 +220,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { [ .init( index: 2, - relativePath: "123_token_2.jpg", + relativePath: "\(gid)_token_2.jpg", error: .fileOperationFailed( "The operation could not be completed. (Swift.CancellationError error 1.)" ) @@ -288,7 +288,7 @@ private extension DownloadPauseAndReconcileTests { withIntermediateDirectories: true ) try Data([0x01]).write( - to: folderURL.appendingPathComponent("123_token_1.jpg"), + to: folderURL.appendingPathComponent("\(gid)_token_1.jpg"), options: .atomic ) } diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 233d8aa5d..ffc9a9c53 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -232,20 +232,20 @@ private extension DownloadProcessCacheTests { pageSelection: [setup.pageIndex] ) let updatedPageCount = latestPayload.galleryDetail.pageCount - let oldPageCount = updatedPageCount - 5 #expect(updatedPageCount > setup.pageIndex) - #expect(oldPageCount > 0) try setupCacheTestFinalFolder( storage: setup.storage, gid: setup.gid, - oldPageCount: oldPageCount + pageCount: updatedPageCount, + missingPageIndex: setup.pageIndex ) return updatedPageCount } func setupCacheTestFinalFolder( storage: DownloadFileStorage, gid: String, - oldPageCount: Int + pageCount: Int, + missingPageIndex: Int ) throws { let completedFolderURL = storage.folderURL(relativePath: "\(gid) - Pause Race") try FileManager.default.createDirectory( @@ -254,8 +254,18 @@ private extension DownloadProcessCacheTests { ) let staleManifest = try sampleManifest( gid: gid, title: "Pause Race", - pageCount: oldPageCount + pageCount: pageCount ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("\(gid)_token_cover.jpg"), + options: .atomic + ) + for index in staleManifest.pages.keys where index != missingPageIndex { + try Data([UInt8(index % 255)]).write( + to: completedFolderURL.appendingPathComponent("\(gid)_token_\(index).jpg"), + options: .atomic + ) + } try storage.writeManifest(staleManifest, folderURL: completedFolderURL) } diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 41dfc4374..fe197a51b 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -30,7 +30,12 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { gid: gid, title: "Pause Race", pageCount: setup.pageCount ) - try writeFinalManifest(storage: storage, gid: gid, manifest: manifest) + try writeFinalManifest( + storage: storage, + gid: gid, + manifest: manifest, + missingPageIndex: pageIndex + ) await manager.testingSetDownloadError( .init(code: .fileOperationFailed, message: "Page \(pageIndex) is missing."), gid: gid @@ -110,7 +115,8 @@ private extension DownloadRetryMinimalSourceTests { func writeFinalManifest( storage: DownloadFileStorage, gid: String, - manifest: DownloadManifest + manifest: DownloadManifest, + missingPageIndex: Int ) throws { try storage.ensureRootDirectory() let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Pause Race") @@ -119,9 +125,15 @@ private extension DownloadRetryMinimalSourceTests { withIntermediateDirectories: true ) try Data([0x00]).write( - to: folderURL.appendingPathComponent("123_token_cover.jpg"), + to: folderURL.appendingPathComponent("\(gid)_token_cover.jpg"), options: .atomic ) + for index in manifest.pages.keys where index != missingPageIndex { + try Data([UInt8(index % 255)]).write( + to: folderURL.appendingPathComponent("\(gid)_token_\(index).jpg"), + options: .atomic + ) + } try storage.writeManifest(manifest, folderURL: folderURL) } diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index f8d05b338..6e08827d9 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -33,7 +33,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { at: folderURL, withIntermediateDirectories: true ) - let pageURL = folderURL.appendingPathComponent("123_token_1.jpg") + let pageURL = folderURL.appendingPathComponent("\(gid)_token_1.jpg") try Data([0x01]).write( to: pageURL, options: .atomic From 763349740a67caa1d423f900f5688a29565b912c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 09:58:14 +0800 Subject: [PATCH 144/614] Clean manager state --- .../Clients/DownloadClient+Execution.swift | 12 ++---- .../DownloadClient+ExecutionPerform.swift | 34 +-------------- .../DownloadClient+ExecutionSupport.swift | 29 +++++++++++-- .../Clients/DownloadClient+Manager.swift | 33 +++++++++++++++ .../Clients/DownloadClient+Persistence.swift | 32 +++++++++++--- .../DownloadClient+PersistenceHelpers.swift | 15 +++---- .../DownloadClient+PersistenceNormalize.swift | 3 +- .../Clients/DownloadClient+PublicAPI.swift | 42 ++++++------------- .../DownloadClient+PublicAPIHelpers.swift | 23 ++++------ .../Clients/DownloadClient+RetryHelpers.swift | 14 +++---- .../Clients/DownloadClient+Scheduling.swift | 23 ++++------ .../DownloadClient+SchedulingHelpers.swift | 2 +- .../Tools/Utilities/DownloadQueueStore.swift | 14 +++++-- 13 files changed, 144 insertions(+), 132 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 8cfc39218..ebd09fafe 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -24,8 +24,7 @@ extension DownloadManager { storage.validate(download: download) == .valid do { - downloadErrors[gid] = nil - validationErrors[gid] = nil + clearDownloadFailureState(gid: gid, includePageFailures: false) await notifyObservers() let result = try await fetchNormalizeAndDownload( gid: gid, @@ -45,7 +44,7 @@ extension DownloadManager { gid: gid, originalDownload: download, mode: mode, - hadReadableFiles: hadReadableFiles, + hadReadableFiles: hadReadableFiles ) await handleProcessDownloadError(error: error, context: context) } @@ -184,12 +183,7 @@ extension DownloadManager { } func settleCompletedDownload(gid: String) async { - downloadErrors[gid] = nil - validationErrors[gid] = nil - failedPageErrors[gid] = nil - updatedGalleryIDs.remove(gid) - queuedModes[gid] = nil - queuedPageSelections[gid] = nil + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index dbbf9625b..80569b60e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -158,11 +158,7 @@ extension DownloadManager { ) async throws { let batchResult = finalizeContext.batchResult let existingDownload = finalizeContext.existingDownload - let manifest = makeManifest( - payload: payload, - coverRelativePath: finalizeContext.coverRelativePath, - batchResult: batchResult - ) + let manifest = makeInitialManifest(payload: payload) let hashedManifest = try storage.addingCurrentFileHashes( to: manifest, folderURL: folderURL @@ -177,32 +173,4 @@ extension DownloadManager { existingDownload: existingDownload ) } - - private func makeManifest( - payload: DownloadRequestPayload, - coverRelativePath: String?, - batchResult: DownloadBatchResult - ) -> DownloadManifest { - DownloadManifest( - gid: payload.gallery.gid, - host: payload.host, - token: payload.gallery.token, - title: payload.gallery.title, - jpnTitle: payload.galleryDetail.jpnTitle, - category: payload.gallery.category, - language: payload.galleryDetail.language, - remoteCoverURL: - payload.galleryDetail.coverURL ?? payload.gallery.coverURL, - uploader: payload.galleryDetail.uploader, - tags: payload.gallery.tags, - postedDate: payload.galleryDetail.postedDate, - rating: payload.galleryDetail.rating, - pages: payload.galleryDetail.pageCount > 0 - ? Dictionary( - uniqueKeysWithValues: - (1...payload.galleryDetail.pageCount).map { ($0, "") } - ) - : [:] - ) - } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 522d10196..168fa2dab 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -7,6 +7,29 @@ import Foundation // MARK: - Execution Support extension DownloadManager { + func makeInitialManifest(payload: DownloadRequestPayload) -> DownloadManifest { + let pageCount = payload.galleryDetail.pageCount + let pages = pageCount > 0 + ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) + : [:] + return DownloadManifest( + gid: payload.gallery.gid, + host: payload.host, + token: payload.gallery.token, + title: payload.gallery.title, + jpnTitle: payload.galleryDetail.jpnTitle, + category: payload.gallery.category, + language: payload.galleryDetail.language, + remoteCoverURL: + payload.galleryDetail.coverURL ?? payload.gallery.coverURL, + uploader: payload.galleryDetail.uploader, + tags: payload.gallery.tags, + postedDate: payload.galleryDetail.postedDate, + rating: payload.galleryDetail.rating, + pages: pages + ) + } + func folderRelativePath(for payload: DownloadRequestPayload) -> String { storage.makeFolderRelativePath( gid: payload.gallery.gid, @@ -199,9 +222,10 @@ extension DownloadManager { gid: payload.gallery.gid, pageCount: payload.galleryDetail.pageCount ) + let lookupManifest = manifest ?? makeInitialManifest(payload: payload) let existingPages = storage.existingPageRelativePaths( folderURL: folderURL, - expectedPageCount: payload.galleryDetail.pageCount + manifest: lookupManifest ) let coverRelativePath = storage.existingCoverRelativePath( folderURL: folderURL @@ -317,8 +341,7 @@ extension DownloadManager { for download: DownloadedGallery, payload: DownloadRequestPayload ) -> RepairSeed? { - let folderURL = download - .resolvedFolderURL(rootURL: storage.rootURL) + let folderURL = download.folderURL guard payload.mode == .repair, fileManager.operate({ $0.fileExists(atPath: folderURL.path) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index a4092ffb7..c63b97b1d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -170,3 +170,36 @@ actor DownloadManager { storage.fileManager } } + +extension DownloadManager { + func clearDownloadFailureState( + gid: String, + includePageFailures: Bool = true + ) { + downloadErrors[gid] = nil + validationErrors[gid] = nil + if includePageFailures { + failedPageErrors[gid] = nil + } + } + + func clearDownloadQueueIntent(gid: String) { + queuedModes[gid] = nil + queuedPageSelections[gid] = nil + } + + func clearDownloadSessionState( + gid: String, + includePageFailures: Bool = true, + includeUpdateFlag: Bool = false + ) { + clearDownloadFailureState( + gid: gid, + includePageFailures: includePageFailures + ) + clearDownloadQueueIntent(gid: gid) + if includeUpdateFlag { + updatedGalleryIDs.remove(gid) + } + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 23689f8cb..ea763894b 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -61,6 +61,14 @@ extension DownloadManager { return DownloadedGallery( manifest: record.manifest, folderURL: record.folderURL, + localCoverURL: storage.localCoverURL( + folderURL: record.folderURL, + manifest: record.manifest + ), + localPageURLs: storage.imageURLs( + folderURL: record.folderURL, + manifest: record.manifest + ), modifiedAt: record.modifiedAt, displayStatus: displayStatus(for: record), lastError: validationErrors[gid] ?? downloadErrors[gid] @@ -98,7 +106,7 @@ extension DownloadManager { _ rhs: DownloadedGallery ) -> Bool { if lhs.displayStatus != rhs.displayStatus { - return lhs.displayStatus.rawValue < rhs.displayStatus.rawValue + return lhs.displayStatus.sortPriority < rhs.displayStatus.sortPriority } return (lhs.lastDownloadedAt ?? .distantPast) > (rhs.lastDownloadedAt ?? .distantPast) @@ -116,7 +124,9 @@ extension DownloadManager { func fetchDownload( gid: String ) async -> DownloadedGallery? { - _ = await reloadDownloadIndex() + if downloadIndex[gid] == nil { + _ = await reloadDownloadIndex() + } return await indexedDownload(gid: gid) } @@ -155,8 +165,7 @@ extension DownloadManager { } #endif downloadErrors[context.gid] = DownloadFailure(error: error) - queuedModes[context.gid] = nil - queuedPageSelections[context.gid] = nil + clearDownloadQueueIntent(gid: context.gid) await queueStore.remove(context.gid) _ = await reloadDownloadIndex() } @@ -200,10 +209,23 @@ extension DownloadManager { let pageRelativePaths = pages.reduce(into: [Int: String]()) { result, page in result[page.index] = page.relativePath } - try storage.refreshManifestPageFileHashes( + let manifest = try storage.refreshManifestPageFileHashes( folderURL: folderURL, pageRelativePaths: pageRelativePaths ) + updateDownloadIndex(folderURL: folderURL, manifest: manifest) } + func updateDownloadIndex(folderURL: URL, manifest: DownloadManifest) { + let modifiedAt = try? folderURL.resourceValues( + forKeys: [.contentModificationDateKey] + ) + .contentModificationDate + downloadIndex[manifest.gid] = DownloadFolderRecord( + relativePath: folderURL.lastPathComponent, + folderURL: folderURL, + manifest: manifest, + modifiedAt: modifiedAt + ) + } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index 896613309..22c25a525 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -10,8 +10,7 @@ extension DownloadManager { func validatedCompletedPageCount( _ download: DownloadedGallery ) -> Int { - let folderURL = download - .resolvedFolderURL(rootURL: storage.rootURL) + let folderURL = download.folderURL guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return 0 @@ -21,7 +20,7 @@ extension DownloadManager { .readManifest(folderURL: folderURL) else { return storage.existingPageRelativePaths( folderURL: folderURL, - expectedPageCount: download.pageCount + manifest: download.manifest ) .count } @@ -59,14 +58,13 @@ extension DownloadManager { } private func scanCompletedFolder(download: DownloadedGallery) { - let completedFolderURL = download - .resolvedFolderURL(rootURL: storage.rootURL) + let completedFolderURL = download.folderURL guard fileManager.operate({ $0.fileExists(atPath: completedFolderURL.path) }) else { return } _ = storage.existingPageRelativePaths( folderURL: completedFolderURL, - expectedPageCount: download.pageCount + manifest: download.manifest ) _ = storage.existingCoverRelativePath( folderURL: completedFolderURL @@ -145,8 +143,7 @@ extension DownloadManager { for download: DownloadedGallery, index: Int ) -> CaptureTargetResult? { - let completedFolderURL = download - .resolvedFolderURL(rootURL: storage.rootURL) + let completedFolderURL = download.folderURL guard fileManager.operate({ $0.fileExists(atPath: completedFolderURL.path) }) @@ -157,7 +154,7 @@ extension DownloadManager { let completedPages = storage.existingPageRelativePaths( folderURL: completedFolderURL, - expectedPageCount: download.pageCount + manifest: download.manifest ) return CaptureTargetResult( folderURL: completedFolderURL, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 2839764dd..704f04f88 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -25,8 +25,7 @@ extension DownloadManager { func activeInspectionFolderURL( for download: DownloadedGallery ) -> URL? { - let completedFolderURL = download - .resolvedFolderURL(rootURL: storage.rootURL) + let completedFolderURL = download.folderURL let completedFolderExists = fileManager.operate { $0.fileExists(atPath: completedFolderURL.path) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 9540fda3e..716ef8944 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -23,7 +23,10 @@ extension DownloadManager { } func fetchDownloads() async -> [DownloadedGallery] { - await fetchDownloadsFromStore() + if downloadIndex.isEmpty { + return await fetchDownloadsFromStore() + } + return await indexedDownloads() } func reconcileDownloads() async { @@ -99,29 +102,9 @@ extension DownloadManager { ) throws { let folderURL = storage.folderURL(relativePath: folderRelativePath) try createDirectory(at: folderURL) - let pageCount = payload.galleryDetail.pageCount - let pages = pageCount > 0 - ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) - : [:] - try storage.writeManifest( - DownloadManifest( - gid: payload.gallery.gid, - host: payload.host, - token: payload.gallery.token, - title: payload.gallery.title, - jpnTitle: payload.galleryDetail.jpnTitle, - category: payload.gallery.category, - language: payload.galleryDetail.language, - remoteCoverURL: - payload.galleryDetail.coverURL ?? payload.gallery.coverURL, - uploader: payload.galleryDetail.uploader, - tags: payload.gallery.tags, - postedDate: payload.galleryDetail.postedDate, - rating: payload.galleryDetail.rating, - pages: pages - ), - folderURL: folderURL - ) + let manifest = makeInitialManifest(payload: payload) + try storage.writeManifest(manifest, folderURL: folderURL) + updateDownloadIndex(folderURL: folderURL, manifest: manifest) } func togglePause(gid: String) async -> Result { @@ -158,14 +141,14 @@ extension DownloadManager { taskToCancel = nil } await taskToCancel?.value - queuedModes[gid] = nil - queuedPageSelections[gid] = nil + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) guard let download = await fetchDownload(gid: gid) else { return .failure(.notFound) } do { try storage.removeFolder(at: download.folderURL) + downloadIndex[gid] = nil await notifyObservers() await scheduleNextIfNeeded() return .success(()) @@ -190,7 +173,6 @@ extension DownloadManager { guard let download = resolvedDownload else { return .failure(.notFound) } - let folderURL = download.resolvedFolderURL(rootURL: storage.rootURL) switch storage.validate(download: download) { case .valid: break @@ -198,7 +180,7 @@ extension DownloadManager { return .failure(.fileOperationFailed(message)) } do { - let manifest = try storage.readManifest(folderURL: folderURL) + let manifest = try storage.readManifest(folderURL: download.folderURL) return .success((download, manifest)) } catch { return .failure(.fileOperationFailed(error.localizedDescription)) @@ -237,7 +219,7 @@ extension DownloadManager { ) async { let existingPages = storage.existingPageRelativePaths( folderURL: captureTarget.folderURL, - expectedPageCount: download.pageCount + manifest: download.manifest ) do { let cacheURLs = pageImageCacheURLs(imageURL: imageURL) @@ -279,7 +261,7 @@ extension DownloadManager { let existingRelativePaths = activeFolderURL.map { storage.existingPageRelativePaths( folderURL: $0, - expectedPageCount: download.pageCount + manifest: download.manifest ) } ?? [:] let failedPages = (failedPageErrors[gid] ?? [:]) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index 53ee39e89..fe8a32316 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -53,18 +53,11 @@ extension DownloadManager { completedFolderURL: URL?, download: DownloadedGallery ) -> [Int: URL] { - let completedPageRelativePaths = completedFolderURL.map { - storage.existingPageRelativePaths( - folderURL: $0, - expectedPageCount: download.pageCount - ) - } ?? [:] - return completedPageRelativePaths - .reduce(into: [Int: URL]()) { result, entry in - guard let folderURL = completedFolderURL else { return } - result[entry.key] = folderURL - .appendingPathComponent(entry.value) - } + guard let completedFolderURL else { return [:] } + return storage.imageURLs( + folderURL: completedFolderURL, + manifest: download.manifest + ) } func resolveLocalPageURLs( @@ -78,8 +71,10 @@ extension DownloadManager { let manifest = try? storage.readManifest( folderURL: completedFolderURL ) { - return .success(manifest - .imageURLs(folderURL: completedFolderURL) + return .success(storage.imageURLs( + folderURL: completedFolderURL, + manifest: manifest + ) ) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index 46bf65c68..8d1f8466f 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -33,11 +33,9 @@ extension DownloadManager { let resolvedMode = effectiveRetryMode( for: download, requestedMode: mode ) + clearDownloadSessionState(gid: gid) queuedModes[gid] = resolvedMode queuedPageSelections[gid] = nil - failedPageErrors[gid] = nil - downloadErrors[gid] = nil - validationErrors[gid] = nil await queueStore.enqueue(gid) await notifyObservers() await scheduleNextIfNeeded() @@ -56,7 +54,7 @@ extension DownloadManager { let selectedPageIndices = Array(Set(pageIndices)).sorted() guard !selectedPageIndices.isEmpty else { return .success(()) } - let folderURL = download.resolvedFolderURL(rootURL: storage.rootURL) + let folderURL = download.folderURL guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return .failure(.notFound) } @@ -64,7 +62,7 @@ extension DownloadManager { try await performRetryPages( gid: gid, download: download, - mode: mode, + mode: .repair, selectedPageIndices: selectedPageIndices, folderURL: folderURL ) @@ -85,10 +83,9 @@ extension DownloadManager { folderURL: URL ) async throws { clearSelectedFailedPages(gid: gid, selectedPageIndices: selectedPageIndices) + clearDownloadFailureState(gid: gid, includePageFailures: false) queuedModes[gid] = mode queuedPageSelections[gid] = selectedPageIndices - downloadErrors[gid] = nil - validationErrors[gid] = nil await queueStore.enqueue(gid) await notifyObservers() await scheduleNextIfNeeded() @@ -108,8 +105,7 @@ extension DownloadManager { return .failure(.notFound) } - let completedFolderURL = download - .resolvedFolderURL(rootURL: storage.rootURL) + let completedFolderURL = download.folderURL let completedValidation = storage.validate(download: download) let completedPageURLs = buildCompletedPageURLs( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 1055a3afd..35df3c595 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -38,7 +38,7 @@ extension DownloadManager { return } let nextDownload = queuedGIDs.isEmpty - ? nextLegacyScheduledDownload(from: downloads) + ? nextUnqueuedSchedulableDownload(from: downloads) : nextQueuedDownload( orderedGIDs: queuedGIDs, downloads: downloads @@ -81,9 +81,12 @@ extension DownloadManager { .first { isSchedulableDownload($0) } } - private func nextLegacyScheduledDownload( + private func nextUnqueuedSchedulableDownload( from downloads: [DownloadedGallery] ) -> DownloadedGallery? { + // Some transient actor state, such as an interrupted active download or + // selected page retry, can be schedulable before it is reflected in the + // persisted queue. downloads .filter(isSchedulableDownload) .sorted { lhs, rhs in @@ -178,10 +181,7 @@ extension DownloadManager { gid: String, download: DownloadedGallery ) async throws -> Task? { - downloadErrors[gid] = nil - validationErrors[gid] = nil - queuedModes[gid] = nil - queuedPageSelections[gid] = nil + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) _ = await reloadDownloadIndex() await notifyObservers() @@ -199,10 +199,7 @@ extension DownloadManager { gid: String, download: DownloadedGallery ) async throws { - downloadErrors[gid] = nil - validationErrors[gid] = nil - queuedModes[gid] = nil - queuedPageSelections[gid] = nil + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) _ = await reloadDownloadIndex() } @@ -218,8 +215,7 @@ extension DownloadManager { break } - queuedModes[download.gid] = nil - queuedPageSelections[download.gid] = nil + clearDownloadQueueIntent(gid: download.gid) await queueStore.remove(download.gid) await notifyObservers() return .success(()) @@ -230,8 +226,7 @@ extension DownloadManager { return .failure(.notFound) } - downloadErrors[gid] = nil - validationErrors[gid] = nil + clearDownloadFailureState(gid: gid) queuedModes[gid] = resumeMode(for: download) queuedPageSelections[gid] = nil await queueStore.enqueue(gid) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index e82330cef..7d4717a31 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -53,7 +53,7 @@ extension DownloadManager { if download.displayStatus == .inactive, download.isIncomplete { return effectiveRetryMode( for: download, - requestedMode: .redownload + requestedMode: .repair ) } if case .missingFiles = storage.validate(download: download) { diff --git a/EhPanda/App/Tools/Utilities/DownloadQueueStore.swift b/EhPanda/App/Tools/Utilities/DownloadQueueStore.swift index 8eef41675..c3e0e99fa 100644 --- a/EhPanda/App/Tools/Utilities/DownloadQueueStore.swift +++ b/EhPanda/App/Tools/Utilities/DownloadQueueStore.swift @@ -26,20 +26,28 @@ struct DownloadQueueStore: Sendable { guard !gids.contains(gid) else { return } gids.append(gid) } - try? await identifiers.save() + await save() } func remove(_ gid: String) async { identifiers.withLock { gids in gids.removeAll { $0 == gid } } - try? await identifiers.save() + await save() } func removeAll() async { identifiers.withLock { gids in gids.removeAll() } - try? await identifiers.save() + await save() + } + + private func save() async { + do { + try await identifiers.save() + } catch { + Logger.error(error) + } } } From 1a2b6d87364df235b0a563f19b80ed5833174320 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 11:02:22 +0800 Subject: [PATCH 145/614] Use single folder scan --- .../Tools/Utilities/DownloadFileStorage.swift | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index a22a6edae..ee0fba17a 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -68,12 +68,15 @@ struct DownloadFileStorage: Sendable { } func existingPageRelativePaths(folderURL: URL, manifest: DownloadManifest) -> [Int: String] { + let fileURLs = existingAssetFileURLs(folderURL: folderURL) manifest.pages.keys.sorted().reduce(into: [:]) { result, index in - guard let fileURL = existingPageFileURL( - folderURL: folderURL, - gid: manifest.gid, - token: manifest.token, - index: index + guard let fileURL = existingAssetFileURL( + in: fileURLs, + prefix: pageFilePrefix( + gid: manifest.gid, + token: manifest.token, + index: index + ) ) else { return } result[index] = fileURL.lastPathComponent } @@ -173,35 +176,53 @@ struct DownloadFileStorage: Sendable { func existingPageFileURL(folderURL: URL, gid: String, token: String, index: Int) -> URL? { existingAssetFileURL( folderURL: folderURL, - prefix: "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_\(index)." + prefix: pageFilePrefix(gid: gid, token: token, index: index) ) } func existingCoverFileURL(folderURL: URL, gid: String, token: String) -> URL? { existingAssetFileURL( folderURL: folderURL, - prefix: "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_cover." + prefix: coverFilePrefix(gid: gid, token: token) ) } private func existingAssetFileURL(folderURL: URL, prefix: String) -> URL? { + existingAssetFileURL( + in: existingAssetFileURLs(folderURL: folderURL), + prefix: prefix + ) + } + + private func existingAssetFileURLs(folderURL: URL) -> [URL] { guard let fileURLs = try? fileManager.operate({ try $0.contentsOfDirectory( at: folderURL, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey] ) }) else { - return nil + return [] } - return fileURLs - .sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) + return fileURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) + } + + private func existingAssetFileURL(in fileURLs: [URL], prefix: String) -> URL? { + fileURLs .first(where: { $0.lastPathComponent.hasPrefix(prefix) && sanitizeAssetFileIfNeeded(at: $0) }) } + private func pageFilePrefix(gid: String, token: String, index: Int) -> String { + "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_\(index)." + } + + private func coverFilePrefix(gid: String, token: String) -> String { + "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_cover." + } + func writeManifest(_ manifest: DownloadManifest, folderURL: URL) throws { try writeJSON(manifest, to: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest)) } From 2f6c62b8a861542b2ac080ba869e3b233c705962 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 11:02:54 +0800 Subject: [PATCH 146/614] Drop dead validation --- EhPanda/App/Tools/Clients/DownloadClient+Execution.swift | 5 +---- EhPanda/App/Tools/Clients/DownloadClient+Manager.swift | 1 - .../Tests/Download/DownloadManagerStorageTests.swift | 3 +-- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index ebd09fafe..9b6543a29 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -20,8 +20,6 @@ extension DownloadManager { return } let mode = queuedMode(for: download) - let hadReadableFiles = - storage.validate(download: download) == .valid do { clearDownloadFailureState(gid: gid, includePageFailures: false) @@ -43,8 +41,7 @@ extension DownloadManager { let context = FailureContext( gid: gid, originalDownload: download, - mode: mode, - hadReadableFiles: hadReadableFiles + mode: mode ) await handleProcessDownloadError(error: error, context: context) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index c63b97b1d..9093a2396 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -74,7 +74,6 @@ actor DownloadManager { let gid: String let originalDownload: DownloadedGallery let mode: DownloadStartMode - let hadReadableFiles: Bool } struct ProgressFlushContext: Sendable { diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 8227f8ba0..9fa39e0bd 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -517,8 +517,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { context: .init( gid: "800", originalDownload: download, - mode: .initial, - hadReadableFiles: false + mode: .initial ) ) From 4e2a82ec5576a7da43afe23f13dab76a1b2e64fd Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 11:04:30 +0800 Subject: [PATCH 147/614] Use identity cover lookup --- .../DownloadClient+ExecutionSupport.swift | 3 ++- .../DownloadClient+PersistenceHelpers.swift | 3 ++- .../Clients/DownloadClient+PublicAPI.swift | 5 ++++- .../DownloadFileStorage+Operations.swift | 5 ++++- .../Tools/Utilities/DownloadFileStorage.swift | 19 ++----------------- .../Download/DownloadFileStorageTests.swift | 7 ++++++- 6 files changed, 20 insertions(+), 22 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 168fa2dab..5d0ed6b73 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -228,7 +228,8 @@ extension DownloadManager { manifest: lookupManifest ) let coverRelativePath = storage.existingCoverRelativePath( - folderURL: folderURL + folderURL: folderURL, + manifest: lookupManifest ) return .init( folderURL: folderURL, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index 22c25a525..e3d410e75 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -67,7 +67,8 @@ extension DownloadManager { manifest: download.manifest ) _ = storage.existingCoverRelativePath( - folderURL: completedFolderURL + folderURL: completedFolderURL, + manifest: download.manifest ) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 716ef8944..b8e430568 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -275,7 +275,10 @@ extension DownloadManager { ) let coverURL = activeFolderURL.flatMap { folderURL in - storage.existingCoverRelativePath(folderURL: folderURL).map { + storage.existingCoverRelativePath( + folderURL: folderURL, + manifest: download.manifest + ).map { folderURL.appendingPathComponent($0) } } ?? download.coverURL diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index ddb8783d4..f71d91775 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -48,7 +48,10 @@ extension DownloadFileStorage { to: destinationFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) ) - if let coverRelativePath = existingCoverRelativePath(folderURL: sourceFolderURL), + if let coverRelativePath = existingCoverRelativePath( + folderURL: sourceFolderURL, + manifest: manifest + ), let sourceCoverURL = validatedChildURL(root: sourceFolderURL, relativePath: coverRelativePath), let destCoverURL = validatedChildURL(root: destinationFolderURL, relativePath: coverRelativePath) { if sanitizeAssetFileIfNeeded(at: sourceCoverURL) { diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index ee0fba17a..650d1f066 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -97,23 +97,8 @@ struct DownloadFileStorage: Sendable { ) } - func existingCoverRelativePath(folderURL: URL) -> String? { - guard let fileURLs = try? fileManager.operate({ - try $0.contentsOfDirectory( - at: folderURL, - includingPropertiesForKeys: nil - ) - }) else { - return nil - } - - return fileURLs - .sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) - .first(where: { - let filename = $0.deletingPathExtension().lastPathComponent - return filename.hasSuffix("_cover") - && sanitizeAssetFileIfNeeded(at: $0) - })? + func existingCoverRelativePath(folderURL: URL, manifest: DownloadManifest) -> String? { + localCoverURL(folderURL: folderURL, manifest: manifest)? .lastPathComponent } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 48023f092..0fcb5cb4a 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -205,9 +205,14 @@ struct DownloadFileStorageTests { try storage.ensureRootDirectory() let folderURL = storage.folderURL(relativePath: "[123_token] Sample") try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try Data([0x01]).write(to: folderURL.appendingPathComponent("other_token_cover.jpg"), options: .atomic) try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic) + let manifest = sampleManifest(pageCount: 1) - #expect(storage.existingCoverRelativePath(folderURL: folderURL) == "123_token_cover.jpg") + #expect( + storage.existingCoverRelativePath(folderURL: folderURL, manifest: manifest) + == "123_token_cover.jpg" + ) } @Test From de5db59c3c2a7f7c3be0780f9bbeff7d0a5bbe86 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 11:09:25 +0800 Subject: [PATCH 148/614] Return page path map --- EhPanda/App/Tools/Utilities/DownloadFileStorage.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 650d1f066..f0855b025 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -69,7 +69,7 @@ struct DownloadFileStorage: Sendable { func existingPageRelativePaths(folderURL: URL, manifest: DownloadManifest) -> [Int: String] { let fileURLs = existingAssetFileURLs(folderURL: folderURL) - manifest.pages.keys.sorted().reduce(into: [:]) { result, index in + return manifest.pages.keys.sorted().reduce(into: [:]) { result, index in guard let fileURL = existingAssetFileURL( in: fileURLs, prefix: pageFilePrefix( From e746895b727a67f0ec0cd07ae03f89d81a66d890 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 11:16:49 +0800 Subject: [PATCH 149/614] Isolate cookie tests --- EhPanda/App/Tools/Clients/CookieClient.swift | 311 ++++++++++++------ .../Clients/DownloadClient+Manager.swift | 5 + ...loadClient+ResponseValidationHelpers.swift | 3 +- .../Download/DownloadAutomationTests.swift | 4 +- .../Download/DownloadFeatureTestHelpers.swift | 9 +- .../DownloadImageParsingCacheTests.swift | 14 +- 6 files changed, 239 insertions(+), 107 deletions(-) diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift index de6c27d3a..2f6a22880 100644 --- a/EhPanda/App/Tools/Clients/CookieClient.swift +++ b/EhPanda/App/Tools/Clients/CookieClient.swift @@ -12,89 +12,105 @@ import Synchronization struct CookieClient: Sendable { let clearAll: @Sendable () -> Void let getCookie: @Sendable (URL, String) -> CookieValue + private let cookiesForURL: @Sendable (URL) -> [HTTPCookie] private let removeCookie: @Sendable (URL, String) -> Void private let checkExistence: @Sendable (URL, String) -> Bool private let initializeCookie: @Sendable (HTTPCookie, String) -> HTTPCookie + private let storeCookie: @Sendable (HTTPCookie) -> Void private let setCookieValue: @Sendable (URL, String, String, String, TimeInterval, Bool) -> Void } extension CookieClient { - static let live: Self = .init( - clearAll: { - if let historyCookies = HTTPCookieStorage.shared.cookies { - historyCookies.forEach { - HTTPCookieStorage.shared.deleteCookie($0) - } - } - }, - getCookie: { url, key in - var value = CookieValue( - rawValue: "", localizedString: L10n.Localizable.Struct.CookieValue.LocalizedString.none - ) - guard let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty else { return value } - - cookies.forEach { cookie in - guard cookie.name == key && !cookie.value.isEmpty else { return } - if let expiresDate = cookie.expiresDate, - expiresDate <= .now { - value = CookieValue( - rawValue: "", localizedString: L10n.Localizable.Struct.CookieValue.LocalizedString.expired - ) - return - } - guard cookie.value != Defaults.Cookie.mystery else { - value = CookieValue( - rawValue: cookie.value, localizedString: - L10n.Localizable.Struct.CookieValue.LocalizedString.mystery - ) - return + static let live: Self = live(cookieStorage: .shared) + + static func live(cookieStorage: HTTPCookieStorage) -> Self { + .init( + clearAll: { + if let historyCookies = cookieStorage.cookies { + historyCookies.forEach { + cookieStorage.deleteCookie($0) + } } - value = CookieValue(rawValue: cookie.value, localizedString: "") - } + }, + getCookie: { url, key in + var value = CookieValue( + rawValue: "", localizedString: L10n.Localizable.Struct.CookieValue.LocalizedString.none + ) + guard let cookies = cookieStorage.cookies(for: url), !cookies.isEmpty else { return value } - return value - }, - removeCookie: { url, key in - if let cookies = HTTPCookieStorage.shared.cookies(for: url) { cookies.forEach { cookie in - guard cookie.name == key else { return } - HTTPCookieStorage.shared.deleteCookie(cookie) + guard cookie.name == key && !cookie.value.isEmpty else { return } + if let expiresDate = cookie.expiresDate, + expiresDate <= .now { + value = CookieValue( + rawValue: "", + localizedString: L10n.Localizable.Struct.CookieValue.LocalizedString.expired + ) + return + } + guard cookie.value != Defaults.Cookie.mystery else { + value = CookieValue( + rawValue: cookie.value, localizedString: + L10n.Localizable.Struct.CookieValue.LocalizedString.mystery + ) + return + } + value = CookieValue(rawValue: cookie.value, localizedString: "") } - } - }, - checkExistence: { url, key in - if let cookies = HTTPCookieStorage.shared.cookies(for: url) { - var existence: HTTPCookie? - cookies.forEach { cookie in - guard cookie.name == key else { return } - existence = cookie + + return value + }, + cookiesForURL: { url in + cookieStorage.cookies(for: url) ?? [] + }, + removeCookie: { url, key in + if let cookies = cookieStorage.cookies(for: url) { + cookies.forEach { cookie in + guard cookie.name == key else { return } + cookieStorage.deleteCookie(cookie) + } + } + }, + checkExistence: { url, key in + if let cookies = cookieStorage.cookies(for: url) { + var existence: HTTPCookie? + cookies.forEach { cookie in + guard cookie.name == key else { return } + existence = cookie + } + return existence != nil + } else { + return false + } + }, + initializeCookie: { cookie, value in + var properties = cookie.properties + properties?[.value] = value + return HTTPCookie(properties: properties ?? [:]) ?? HTTPCookie() + }, + storeCookie: { cookie in + cookieStorage.setCookie(cookie) + }, + setCookieValue: { url, key, value, path, expiresTime, sessionOnly in + let properties: [HTTPCookiePropertyKey: Any] = [ + .path: path, .name: key, .value: value, + .originURL: url + ] + var mutableProperties = properties + if let host = url.host { + mutableProperties[.domain] = host + } + if sessionOnly { + mutableProperties[.discard] = "TRUE" + } else { + mutableProperties[.expires] = Date(timeIntervalSinceNow: expiresTime) + } + if let cookie = HTTPCookie(properties: mutableProperties) { + cookieStorage.setCookie(cookie) } - return existence != nil - } else { - return false - } - }, - initializeCookie: { cookie, value in - var properties = cookie.properties - properties?[.value] = value - return HTTPCookie(properties: properties ?? [:]) ?? HTTPCookie() - }, - setCookieValue: { url, key, value, path, expiresTime, sessionOnly in - let properties: [HTTPCookiePropertyKey: Any] = [ - .path: path, .name: key, .value: value, - .originURL: url - ] - var mutableProperties = properties - if sessionOnly { - mutableProperties[.discard] = "TRUE" - } else { - mutableProperties[.expires] = Date(timeIntervalSinceNow: expiresTime) - } - if let cookie = HTTPCookie(properties: mutableProperties) { - HTTPCookieStorage.shared.setCookie(cookie) } - } - ) + ) + } } // MARK: Foundation @@ -151,15 +167,13 @@ extension CookieClient { } func editCookie(for url: URL, key: String, value: String) { var newCookie: HTTPCookie? - if let cookies = HTTPCookieStorage.shared.cookies(for: url) { - cookies.forEach { cookie in - guard cookie.name == key else { return } - newCookie = initializeCookie(cookie, value) - removeCookie(url, key) - } + cookiesForURL(url).forEach { cookie in + guard cookie.name == key else { return } + newCookie = initializeCookie(cookie, value) + removeCookie(url, key) } guard let cookie = newCookie else { return } - HTTPCookieStorage.shared.setCookie(cookie) + storeCookie(cookie) } func setOrEditCookie(for url: URL, key: String, value: String) { if checkExistence(url, key) { @@ -168,12 +182,22 @@ extension CookieClient { setCookie(for: url, key: key, value: value) } } + func cookies(for url: URL) -> [HTTPCookie] { + cookiesForURL(url) + } } // MARK: Accessor extension CookieClient { var didLogin: Bool { - CookieUtil.didLogin + let ehHasAuth = !getCookie(Defaults.URL.ehentai, Defaults.Cookie.ipbMemberId).rawValue.isEmpty + && !getCookie(Defaults.URL.ehentai, Defaults.Cookie.ipbPassHash).rawValue.isEmpty + let exIgneous = getCookie(Defaults.URL.exhentai, Defaults.Cookie.igneous).rawValue + let exHasAuth = !getCookie(Defaults.URL.exhentai, Defaults.Cookie.ipbMemberId).rawValue.isEmpty + && !getCookie(Defaults.URL.exhentai, Defaults.Cookie.ipbPassHash).rawValue.isEmpty + && !exIgneous.isEmpty + && exIgneous != Defaults.Cookie.mystery + return ehHasAuth || exHasAuth } var apiuid: String { getCookie(Defaults.URL.host, Defaults.Cookie.ipbMemberId).rawValue @@ -322,9 +346,11 @@ extension CookieClient { static let noop: Self = .init( clearAll: {}, getCookie: { _, _ in .empty }, + cookiesForURL: { _ in [] }, removeCookie: { _, _ in }, checkExistence: { _, _ in false }, initializeCookie: { _, _ in .init() }, + storeCookie: { _ in }, setCookieValue: { _, _, _, _, _, _ in } ) @@ -333,39 +359,125 @@ extension CookieClient { static let unimplemented: Self = .init( clearAll: IssueReporting.unimplemented(placeholder: placeholder()), getCookie: IssueReporting.unimplemented(placeholder: placeholder()), + cookiesForURL: IssueReporting.unimplemented(placeholder: placeholder()), removeCookie: IssueReporting.unimplemented(placeholder: placeholder()), checkExistence: IssueReporting.unimplemented(placeholder: placeholder()), initializeCookie: IssueReporting.unimplemented(placeholder: placeholder()), + storeCookie: IssueReporting.unimplemented(placeholder: placeholder()), setCookieValue: IssueReporting.unimplemented(placeholder: placeholder()) ) } #if DEBUG +private struct CookieClientTestingCookie: Sendable { + var domain: String + var path: String + var name: String + var value: String + var expiresDate: Date? + var isSessionOnly: Bool + + func matches(url: URL, key: String? = nil) -> Bool { + guard let host = url.host?.lowercased() else { return false } + let normalizedDomain = domain.lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let domainMatches = host == normalizedDomain + || host.hasSuffix(".\(normalizedDomain)") + let keyMatches = key.map { name == $0 } ?? true + return domainMatches && keyMatches + } + + func httpCookie() -> HTTPCookie? { + var properties: [HTTPCookiePropertyKey: Any] = [ + .domain: domain, + .path: path, + .name: name, + .value: value + ] + if isSessionOnly { + properties[.discard] = "TRUE" + } else if let expiresDate { + properties[.expires] = expiresDate + } + return HTTPCookie(properties: properties) + } +} + private final class CookieClientTestingStore: Sendable { - private let cookies: Mutex<[String: String]> + private let cookies: Mutex<[String: CookieClientTestingCookie]> - init(cookies: [String: String]) { + init(cookies: [String: CookieClientTestingCookie]) { self.cookies = Mutex(cookies) } func value(for url: URL, key: String) -> String { - cookies.withLock { $0[storageKey(url: url, key: key)] ?? "" } + cookie(for: url, key: key)?.value ?? "" } - func setValue(_ value: String, for url: URL, key: String) { - cookies.withLock { $0[storageKey(url: url, key: key)] = value } + func setValue( + _ value: String, + for url: URL, + key: String, + path: String = "/", + expiresTime: TimeInterval = .oneYear, + sessionOnly: Bool = false + ) { + guard let domain = url.host else { return } + let cookie = CookieClientTestingCookie( + domain: domain, + path: path, + name: key, + value: value, + expiresDate: sessionOnly ? nil : Date(timeIntervalSinceNow: expiresTime), + isSessionOnly: sessionOnly + ) + cookies.withLock { $0[storageKey(domain: domain, key: key)] = cookie } } func removeValue(for url: URL, key: String) { - cookies.withLock { $0[storageKey(url: url, key: key)] = nil } + cookies.withLock { storage in + storage = storage.filter { !$0.value.matches(url: url, key: key) } + } + } + + func containsValue(for url: URL, key: String) -> Bool { + cookie(for: url, key: key) != nil + } + + func cookies(for url: URL) -> [HTTPCookie] { + cookies.withLock { storage in + storage.values + .filter { $0.matches(url: url) } + .compactMap { $0.httpCookie() } + } + } + + func store(_ cookie: HTTPCookie) { + let testingCookie = CookieClientTestingCookie( + domain: cookie.domain, + path: cookie.path, + name: cookie.name, + value: cookie.value, + expiresDate: cookie.expiresDate, + isSessionOnly: cookie.isSessionOnly + ) + cookies.withLock { + $0[storageKey(domain: cookie.domain, key: cookie.name)] = testingCookie + } } func removeAll() { cookies.withLock { $0.removeAll() } } - private func storageKey(url: URL, key: String) -> String { - "\(url.absoluteString)|\(key)" + private func cookie(for url: URL, key: String) -> CookieClientTestingCookie? { + cookies.withLock { storage in + storage.values.first { $0.matches(url: url, key: key) } + } + } + + private func storageKey(domain: String, key: String) -> String { + "\(domain.lowercased())|\(key)" } } @@ -397,17 +509,32 @@ extension CookieClient { getCookie: { url, key in .init(rawValue: store.value(for: url, key: key), localizedString: "") }, + cookiesForURL: { url in + store.cookies(for: url) + }, removeCookie: { url, key in store.removeValue(for: url, key: key) }, - checkExistence: { _, _ in - false + checkExistence: { url, key in + store.containsValue(for: url, key: key) + }, + initializeCookie: { cookie, value in + var properties = cookie.properties + properties?[.value] = value + return HTTPCookie(properties: properties ?? [:]) ?? HTTPCookie() }, - initializeCookie: { _, _ in - .init() + storeCookie: { cookie in + store.store(cookie) }, - setCookieValue: { url, key, value, _, _, _ in - store.setValue(value, for: url, key: key) + setCookieValue: { url, key, value, path, expiresTime, sessionOnly in + store.setValue( + value, + for: url, + key: key, + path: path, + expiresTime: expiresTime, + sessionOnly: sessionOnly + ) } ) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 9093a2396..027474550 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -127,6 +127,7 @@ actor DownloadManager { let storage: DownloadFileStorage let urlSession: URLSession + let storedCookiesProvider: @Sendable (URL) -> [HTTPCookie] let libraryClient: LibraryClient let downloadOptionsProvider: @Sendable () async -> DownloadRequestOptions let queueStore: DownloadQueueStore @@ -152,6 +153,9 @@ actor DownloadManager { init( storage: DownloadFileStorage, urlSession: URLSession, + storedCookiesProvider: @escaping @Sendable (URL) -> [HTTPCookie] = { + HTTPCookieStorage.shared.cookies(for: $0) ?? [] + }, libraryClient: LibraryClient = .live, downloadOptionsProvider: @escaping @Sendable () async -> DownloadRequestOptions = { DownloadRequestOptions() @@ -160,6 +164,7 @@ actor DownloadManager { ) { self.storage = storage self.urlSession = urlSession + self.storedCookiesProvider = storedCookiesProvider self.libraryClient = libraryClient self.downloadOptionsProvider = downloadOptionsProvider self.queueStore = queueStore ?? DownloadQueueStore(fileURL: storage.queueURL()) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index 9f0bf31ca..301862ceb 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -257,8 +257,7 @@ extension DownloadManager { } for url in uniqueURLs { - cookies += HTTPCookieStorage.shared - .cookies(for: url) ?? [] + cookies += storedCookiesProvider(url) } return cookies } diff --git a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift index 5e7aa157a..5049d2c9c 100644 --- a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift @@ -33,7 +33,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { @Test func testImportAutomationCookiesClearsStaleIgneousAndUsesSessionCookies() { - let cookieClient = CookieClient.live + let cookieClient = CookieClient.testing() cookieClient.clearAll() defer { cookieClient.clearAll() } @@ -49,7 +49,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { igneous: nil ) - let exCookies = HTTPCookieStorage.shared.cookies(for: Defaults.URL.exhentai) ?? [] + let exCookies = cookieClient.cookies(for: Defaults.URL.exhentai) let memberCookie = exCookies.first { $0.name == Defaults.Cookie.ipbMemberId } let passHashCookie = exCookies.first { $0.name == Defaults.Cookie.ipbPassHash } let igneousCookie = exCookies.first { $0.name == Defaults.Cookie.igneous } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift index acdeb681d..f05552c81 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -149,11 +149,18 @@ extension DownloadFeatureTestCase { } func makeTestingDownloadManager() -> DownloadManager { + makeTestingDownloadManager(storedCookiesProvider: { _ in [] }) + } + + func makeTestingDownloadManager( + storedCookiesProvider: @escaping @Sendable (URL) -> [HTTPCookie] + ) -> DownloadManager { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) return DownloadManager( storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), - urlSession: .shared + urlSession: .shared, + storedCookiesProvider: storedCookiesProvider ) } diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift index 99282f4fc..0f55557ff 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -39,19 +39,13 @@ struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { let fileURL = try writeFixtureToTemporaryFile(filename: .exLoginRequired) defer { try? FileManager.default.removeItem(at: fileURL) } - let cookieClient = CookieClient.live - cookieClient.clearAll() - defer { cookieClient.clearAll() } - cookieClient.setOrEditCookie( - for: Defaults.URL.exhentai, - key: Defaults.Cookie.yay, - value: "louder" - ) - let manager = makeTestingDownloadManager() let response = try makeResponse( url: Defaults.URL.exhentai, - contentType: "text/html" + contentType: "text/html", + headers: [ + "Set-Cookie": "\(Defaults.Cookie.yay)=louder; Path=/" + ] ) let error = await manager.testingDetectResponseError( fileURL: fileURL, From 7a795f9a786fd5f9bfc815ac5ad7504efee37061 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 16:04:34 +0800 Subject: [PATCH 150/614] Repair interrupted downloads --- .../DownloadClient+SchedulingHelpers.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index 7d4717a31..4bb17ddf5 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -31,15 +31,10 @@ extension DownloadManager { for: download, requestedMode: .redownload ) - case .error: + case .error, .queued, .active: return effectiveRetryMode( for: download, - requestedMode: initialOrRedownloadMode(for: download) - ) - case .queued, .active: - return effectiveRetryMode( - for: download, - requestedMode: initialOrRedownloadMode(for: download) + requestedMode: interruptedWorkMode(for: download) ) } } @@ -62,10 +57,14 @@ extension DownloadManager { return .redownload } - private func initialOrRedownloadMode( + // Queued, active, or errored downloads reach this fallback only when the + // in-memory queue intent is gone, typically after a relaunch interrupted + // the session; resuming in place must not discard downloaded pages, so + // anything with progress repairs instead of redownloading. + private func interruptedWorkMode( for download: DownloadedGallery ) -> DownloadStartMode { - download.completedPageCount == 0 ? .initial : .redownload + download.completedPageCount == 0 ? .initial : .repair } func effectiveRetryMode( From c60910800d78b9a8fef31ba49eeb685a337e1e79 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 16:04:34 +0800 Subject: [PATCH 151/614] Keep manifest in working folder --- .../DownloadClient+ExecutionSupport.swift | 32 +++++++++++++++---- .../Clients/DownloadClient+Manager.swift | 4 +-- .../Clients/DownloadClient+PageDownload.swift | 7 ++-- .../DownloadManagerRepairSeedTests.swift | 2 +- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 5d0ed6b73..e2ade9d10 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -217,19 +217,17 @@ extension DownloadManager { seedContext: seedContext ) - let manifest = validatedManifest( - at: folderURL, - gid: payload.gallery.gid, - pageCount: payload.galleryDetail.pageCount + let manifest = try ensureWorkingManifest( + payload: payload, + folderURL: folderURL ) - let lookupManifest = manifest ?? makeInitialManifest(payload: payload) let existingPages = storage.existingPageRelativePaths( folderURL: folderURL, - manifest: lookupManifest + manifest: manifest ) let coverRelativePath = storage.existingCoverRelativePath( folderURL: folderURL, - manifest: lookupManifest + manifest: manifest ) return .init( folderURL: folderURL, @@ -239,6 +237,26 @@ extension DownloadManager { ) } + // The disk index drops manifest-less folders and progress flushes skip + // them, so the working folder must carry a manifest before any page + // lands; otherwise an interruption strands the folder invisibly. + private func ensureWorkingManifest( + payload: DownloadRequestPayload, + folderURL: URL + ) throws -> DownloadManifest { + if let manifest = validatedManifest( + at: folderURL, + gid: payload.gallery.gid, + pageCount: payload.galleryDetail.pageCount + ) { + return manifest + } + let manifest = makeInitialManifest(payload: payload) + try storage.writeManifest(manifest, folderURL: folderURL) + updateDownloadIndex(folderURL: folderURL, manifest: manifest) + return manifest + } + private func shouldReuseWorkingFolder( payload: DownloadRequestPayload, folderURL: URL diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 027474550..062f6c1f7 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -52,7 +52,7 @@ actor DownloadManager { struct WorkingSeed: Sendable { let folderURL: URL - let manifest: DownloadManifest? + let manifest: DownloadManifest let existingPages: [Int: String] let coverRelativePath: String? } @@ -102,7 +102,7 @@ actor DownloadManager { struct PrepareWorkingSeedResult: Sendable { let folderURL: URL - let manifest: DownloadManifest? + let manifest: DownloadManifest let existingPages: [Int: String] let coverRelativePath: String? } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index 836fda2e4..b617a85bd 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -18,7 +18,7 @@ extension DownloadManager { func downloadPages( context: PageDownloadContext, pendingPageIndices: [Int], - existingManifest: DownloadManifest?, + existingManifest: DownloadManifest, existingPageRelativePaths: [Int: String] ) async throws -> DownloadBatchResult { let existingPages = buildExistingPages( @@ -110,12 +110,9 @@ extension DownloadManager { } private func buildExistingPages( - existingManifest: DownloadManifest?, + existingManifest: DownloadManifest, existingPageRelativePaths: [Int: String] ) -> [Int: String] { - guard let existingManifest else { - return existingPageRelativePaths - } let manifestPageIndices = Set(existingManifest.pages.keys) return existingPageRelativePaths.filter { manifestPageIndices.contains($0.key) diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index 8109b53d8..e705a0184 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -49,7 +49,7 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { let coverRelativePath = storage.makeCoverRelativePath( gid: gid, token: "token", fileExtension: "jpg" ) - let manifest = try #require(workingSeed.manifest) + let manifest = workingSeed.manifest #expect(manifest.gid == gid) #expect(workingSeed.existingPages == [ 1: pageOneRelativePath, From 1c788b83a7498779acde9589a04de5f45ecebd60 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 16:04:34 +0800 Subject: [PATCH 152/614] Sweep superseded gallery folders --- .../Clients/DownloadClient+Execution.swift | 18 +- .../DownloadInterruptedResumeTests.swift | 231 ++++++++++++++++++ 2 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 9b6543a29..afd038979 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -56,12 +56,24 @@ extension DownloadManager { let completedFolderURL = storage.folderURL( relativePath: result.folderRelativePath ) - if download.folderURL != completedFolderURL { - try? storage.removeFolder(at: download.folderURL) - } + removeSupersededFolders(gid: gid, keeping: completedFolderURL) await notifyObservers() } + // A download can finish in a different folder than it started in + // (re-slot after a title change), and an interrupted session can leave + // both behind; only the completed folder may survive, or the stale + // duplicate resurfaces once the surviving record is deleted. + func removeSupersededFolders(gid: String, keeping folderURL: URL) { + let keptPath = folderURL.standardizedFileURL.path + let records = (try? storage.scanDownloadFolders()) ?? [] + for record in records + where record.manifest.gid == gid + && record.folderURL.standardizedFileURL.path != keptPath { + try? storage.removeFolder(at: record.folderURL) + } + } + private func handleProcessDownloadError( error: Error, context: FailureContext diff --git a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift new file mode 100644 index 000000000..8f0bc9744 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -0,0 +1,231 @@ +// +// DownloadInterruptedResumeTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +@Suite +struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { + @Test + func testInterruptedSessionResolvesNonDestructiveResumeMode() async throws { + let manager = makeTestingDownloadManager() + + let queuedPartial = sampleDownload( + gid: "913000001", title: "Interrupted", + status: .queued, pageCount: 26, completedPageCount: 7 + ) + let activePartial = sampleDownload( + gid: "913000002", title: "Interrupted", + status: .downloading, pageCount: 26, completedPageCount: 7 + ) + let queuedUntouched = sampleDownload( + gid: "913000003", title: "Interrupted", + status: .queued, pageCount: 26, completedPageCount: 0 + ) + let queuedComplete = sampleDownload( + gid: "913000004", title: "Interrupted", + status: .queued, pageCount: 26, completedPageCount: 26 + ) + + #expect(await manager.queuedMode(for: queuedPartial) == .repair) + #expect(await manager.queuedMode(for: activePartial) == .repair) + #expect(await manager.queuedMode(for: queuedUntouched) == .initial) + #expect(await manager.queuedMode(for: queuedComplete) == .repair) + } + + @Test + func testWipedWorkingFolderStaysIndexedWithFreshManifest() async throws { + let gid = "913000005" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + + let folderURL = try writeManifestFolder( + storage: storage, + gid: gid, + title: "Redownload", + pageHashes: ["sha256:done", ""] + ) + let stalePageURL = folderURL.appendingPathComponent("\(gid)_token_1.jpg") + let download = sampleDownload( + gid: gid, title: "Redownload", + status: .queued, pageCount: 2, completedPageCount: 1, + folderURL: folderURL + ) + + let workingSeed = try await manager.prepareWorkingSeed( + payload: makePayload(gid: gid, title: "Redownload", mode: .redownload), + existingDownload: download, + folderURL: folderURL + ) + + #expect(!FileManager.default.fileExists(atPath: stalePageURL.path)) + let persistedManifest = try storage.readManifest(folderURL: folderURL) + #expect(persistedManifest == workingSeed.manifest) + #expect(persistedManifest.pageCount == 2) + #expect(persistedManifest.completedPageCount == 0) + let stored = await manager.testingFetchDownload(gid: gid) + #expect(stored != nil) + } + + @Test + func testPauseAfterInterruptedRedownloadKeepsDownloadListed() async throws { + let gid = "913000006" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + + let folderURL = try writeManifestFolder( + storage: storage, + gid: gid, + title: "Pause Survives", + pageHashes: ["sha256:done", ""] + ) + let download = sampleDownload( + gid: gid, title: "Pause Survives", + status: .queued, pageCount: 2, completedPageCount: 1, + folderURL: folderURL + ) + + _ = try await manager.prepareWorkingSeed( + payload: makePayload(gid: gid, title: "Pause Survives", mode: .redownload), + existingDownload: download, + folderURL: folderURL + ) + await manager.testingSetQueuedGalleryIDs([gid]) + let activeTask = Task { + do { + try await Task.sleep(for: .seconds(60)) + } catch {} + } + await manager.testingInstallActiveTask(gid: gid, task: activeTask) + + let result = await manager.togglePause(gid: gid) + + guard case .success = result else { + Issue.record("Pause should succeed, got \(result)") + return + } + let stored = await manager.testingFetchDownload(gid: gid) + #expect(stored?.displayStatus == .inactive) + #expect(stored?.badge == .paused(0, 2)) + #expect(FileManager.default.fileExists(atPath: folderURL.path)) + } + + @Test + func testRemoveSupersededFoldersKeepsOnlyCompletedFolder() async throws { + let gid = "913000007" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + + let oldFolderURL = try writeManifestFolder( + storage: storage, gid: gid, title: "Old Title", + pageHashes: ["sha256:done", "sha256:done"] + ) + let completedFolderURL = try writeManifestFolder( + storage: storage, gid: gid, title: "New Title", + pageHashes: ["sha256:done", "sha256:done"] + ) + let unrelatedFolderURL = try writeManifestFolder( + storage: storage, gid: "913000008", title: "Unrelated", + pageHashes: ["sha256:done"] + ) + + await manager.removeSupersededFolders( + gid: gid, + keeping: completedFolderURL + ) + + #expect(!FileManager.default.fileExists(atPath: oldFolderURL.path)) + #expect(FileManager.default.fileExists(atPath: completedFolderURL.path)) + #expect(FileManager.default.fileExists(atPath: unrelatedFolderURL.path)) + } +} + +// MARK: - Setup Helpers + +private extension DownloadInterruptedResumeTests { + @discardableResult + func writeManifestFolder( + storage: DownloadFileStorage, + gid: String, + title: String, + pageHashes: [String] + ) throws -> URL { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: nil, + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: Dictionary( + uniqueKeysWithValues: + pageHashes.enumerated().map { ($0.offset + 1, $0.element) } + ) + ), + folderURL: folderURL + ) + for (offset, hash) in pageHashes.enumerated() where !hash.isEmpty { + try Data([UInt8(offset + 1)]).write( + to: folderURL.appendingPathComponent("\(gid)_token_\(offset + 1).jpg"), + options: .atomic + ) + } + return folderURL + } + + func makePayload( + gid: String, + title: String, + mode: DownloadStartMode + ) -> DownloadRequestPayload { + DownloadRequestPayload( + gallery: Gallery( + gid: gid, token: "token", title: title, + rating: 4, tags: [], category: .doujinshi, + uploader: "Uploader", pageCount: 2, postedDate: .now, + coverURL: nil, + galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token") + ), + galleryDetail: GalleryDetail( + gid: gid, title: title, jpnTitle: nil, + isFavorited: false, visibility: .yes, + rating: 4, userRating: 0, ratingCount: 1, + category: .doujinshi, language: .japanese, + uploader: "Uploader", postedDate: .now, + coverURL: nil, + favoritedCount: 0, pageCount: 2, + sizeCount: 1, sizeType: "MB", torrentCount: 0 + ), + previewURLs: [:], previewConfig: .normal(rows: 4), + host: .ehentai, options: .init(), mode: mode + ) + } +} From 85d66bb03430b46ee403f1e137bfdf89c26956cf Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 10 Jun 2026 22:33:14 +0800 Subject: [PATCH 153/614] Split badge status from progress --- .../Clients/DownloadClient+PublicAPI.swift | 6 +- .../App/Tools/Clients/DownloadClient.swift | 8 +- EhPanda/Models/Persistent/DownloadBadge.swift | 82 +++++++++++++++--- .../DownloadedGallery+Extensions.swift | 76 ++++++++++------- .../DownloadedGallery+SupportTypes.swift | 39 +++++---- .../View/Detail/DetailReducer+Download.swift | 24 +++--- EhPanda/View/Detail/DetailReducer.swift | 14 ++-- .../Detail/DetailView+HeaderSection.swift | 83 ++++++++++--------- EhPanda/View/Detail/DetailView.swift | 16 ++-- EhPanda/View/Home/HomeView+Sections.swift | 6 +- .../Components/Cells/GalleryCardCell.swift | 6 +- .../Components/Cells/GalleryDetailCell.swift | 10 +-- .../Components/Cells/GalleryRankingCell.swift | 4 +- .../Cells/GalleryThumbnailCell.swift | 6 +- .../Components/DownloadBadgeLabel.swift | 31 ++----- .../View/Support/Components/GenericList.swift | 4 +- .../Download/DetailReducerDownloadTests.swift | 6 +- .../Download/DetailReducerMetadataTests.swift | 16 +++- .../DetailReducerMetadataUpdateTests.swift | 2 +- .../Download/DetailReducerObserveTests.swift | 12 ++- .../Download/DownloadBadgeSortTests.swift | 23 ++++- .../DownloadFilterAndBadgeTests.swift | 8 +- .../DownloadManagerStorageTests.swift | 6 +- .../DownloadPauseAndReconcileTests.swift | 2 +- .../Download/DownloadRetryPagesTests.swift | 2 +- .../DownloadRetryUpdateFallbackTests.swift | 2 +- .../DownloadVersionSignatureTests.swift | 4 +- .../DownloadsReducerActionTests.swift | 2 +- 28 files changed, 302 insertions(+), 198 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index b8e430568..26437a31d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -50,9 +50,9 @@ extension DownloadManager { func updateRemoteVersion( gid: String, metadata: DownloadVersionMetadata - ) async -> DownloadBadge { + ) async -> DownloadBadge? { guard let download = await fetchDownload(gid: gid) else { - return .none + return nil } guard downloadIndex[gid] != nil else { return download.badge @@ -71,7 +71,7 @@ extension DownloadManager { if hadUpdate != hasUpdate { await notifyObservers() } - return (await fetchDownload(gid: gid))?.badge ?? .none + return (await fetchDownload(gid: gid))?.badge } func enqueue( diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 523335466..522c6336d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -16,7 +16,7 @@ struct DownloadClient: Sendable { let resumeQueue: @Sendable () async -> Void let badges: @Sendable ([String]) async -> [String: DownloadBadge] let fetchVersionMetadata: @Sendable (String, String) async -> Result - let updateRemoteVersion: @Sendable (String, DownloadVersionMetadata) async -> DownloadBadge + let updateRemoteVersion: @Sendable (String, DownloadVersionMetadata) async -> DownloadBadge? let enqueue: @Sendable (DownloadRequestPayload) async -> Result let togglePause: @Sendable (String) async -> Result let retry: @Sendable (String, DownloadStartMode) async -> Result @@ -38,8 +38,8 @@ struct DownloadClient: Sendable { badges: @escaping @Sendable ([String]) async -> [String: DownloadBadge], fetchVersionMetadata: @escaping @Sendable (String, String) async -> Result = { _, _ in .failure(.notFound) }, - updateRemoteVersion: @escaping @Sendable (String, DownloadVersionMetadata) async -> DownloadBadge = - { _, _ in .none }, + updateRemoteVersion: @escaping @Sendable (String, DownloadVersionMetadata) async -> DownloadBadge? = + { _, _ in nil }, enqueue: @escaping @Sendable (DownloadRequestPayload) async -> Result, togglePause: @escaping @Sendable (String) async -> Result, retry: @escaping @Sendable (String, DownloadStartMode) async -> Result, @@ -181,7 +181,7 @@ extension DownloadClient { resumeQueue: {}, badges: { _ in [:] }, fetchVersionMetadata: { _, _ in .failure(.notFound) }, - updateRemoteVersion: { _, _ in .none }, + updateRemoteVersion: { _, _ in nil }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPanda/Models/Persistent/DownloadBadge.swift b/EhPanda/Models/Persistent/DownloadBadge.swift index a02e23cd8..7cb11d75e 100644 --- a/EhPanda/Models/Persistent/DownloadBadge.swift +++ b/EhPanda/Models/Persistent/DownloadBadge.swift @@ -3,14 +3,76 @@ // EhPanda // -enum DownloadBadge: Equatable { - case none - case queued - case downloading(Int, Int) - case paused(Int, Int) - case partial(Int, Int) - case downloaded - case failed - case updateAvailable - case missingFiles +struct DownloadProgress: Equatable, Sendable { + let completedPageCount: Int + let pageCount: Int + + var displayPageCount: Int { + max(pageCount, 1) + } + + var fraction: Double { + Double(completedPageCount) / Double(displayPageCount) + } +} + +// Presentation model for download indicators: the semantic state and the +// page progress stay separate so views can combine them per status and per +// layout (compact or full) without destructuring payloads. "Not downloaded" +// is `nil` at the use site, not a status. +struct DownloadBadge: Equatable, Sendable { + enum Failure: Equatable, Sendable { + case general + case partial + case missingFiles + } + + let status: DownloadDisplayStatus + let failure: Failure? + let progress: DownloadProgress? + + init( + status: DownloadDisplayStatus, + failure: Failure? = nil, + progress: DownloadProgress? = nil + ) { + self.status = status + self.failure = failure + self.progress = progress + } + + var resolvedProgress: DownloadProgress { + progress ?? DownloadProgress(completedPageCount: 0, pageCount: 1) + } +} + +// MARK: Presets +extension DownloadBadge { + static let queued = DownloadBadge(status: .queued) + static let downloaded = DownloadBadge(status: .completed) + static let updateAvailable = DownloadBadge(status: .updateAvailable) + static let failed = DownloadBadge(status: .error, failure: .general) + static let missingFiles = DownloadBadge(status: .error, failure: .missingFiles) + + static func downloading(_ completedPageCount: Int, _ pageCount: Int) -> DownloadBadge { + .init( + status: .active, + progress: .init(completedPageCount: completedPageCount, pageCount: pageCount) + ) + } + + static func paused(_ completedPageCount: Int, _ pageCount: Int) -> DownloadBadge { + .init( + status: .inactive, + progress: .init(completedPageCount: completedPageCount, pageCount: pageCount) + ) + } + + static func partial(_ completedPageCount: Int, _ pageCount: Int) -> DownloadBadge { + .init( + status: .error, + failure: .partial, + progress: .init(completedPageCount: completedPageCount, pageCount: pageCount) + ) + } } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift index 4dcc6c7e2..4870acf0a 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -8,51 +8,69 @@ import SwiftUI // MARK: - DownloadBadge extension DownloadBadge { var text: String { - switch self { - case .none: - return "" + switch status { case .queued: return L10n.Localizable.Struct.DownloadBadge.Text.queued - case .downloading(let completed, let total): - return L10n.Localizable.Struct.DownloadBadge.Text.downloading(completed, max(total, 1)) - case .paused(let completed, let total): - return L10n.Localizable.Struct.DownloadBadge.Text.paused(completed, max(total, 1)) - case .partial(let completed, let total): - return L10n.Localizable.Struct.DownloadBadge.Text.needsAttentionProgress( - completed, - max(total, 1) + case .active: + return L10n.Localizable.Struct.DownloadBadge.Text.downloading( + resolvedProgress.completedPageCount, + resolvedProgress.displayPageCount + ) + case .inactive: + return L10n.Localizable.Struct.DownloadBadge.Text.paused( + resolvedProgress.completedPageCount, + resolvedProgress.displayPageCount ) - case .downloaded: - return L10n.Localizable.Struct.DownloadBadge.Text.downloaded - case .failed: - return L10n.Localizable.Struct.DownloadBadge.Text.needsAttention case .updateAvailable: return L10n.Localizable.Struct.DownloadBadge.Text.updateAvailable - case .missingFiles: - return L10n.Localizable.Struct.DownloadBadge.Text.needsRepair + case .completed: + return L10n.Localizable.Struct.DownloadBadge.Text.downloaded + case .error: + switch failure { + case .partial: + return L10n.Localizable.Struct.DownloadBadge.Text.needsAttentionProgress( + resolvedProgress.completedPageCount, + resolvedProgress.displayPageCount + ) + case .missingFiles: + return L10n.Localizable.Struct.DownloadBadge.Text.needsRepair + case .general, nil: + return L10n.Localizable.Struct.DownloadBadge.Text.needsAttention + } + } + } + + var compactText: String { + switch status { + case .active: + return L10n.Localizable.Struct.DownloadBadge.Compact.downloading + case .inactive: + return L10n.Localizable.Struct.DownloadBadge.Compact.paused + case .completed: + return L10n.Localizable.Struct.DownloadBadge.Compact.done + case .error: + return failure == .missingFiles + ? text + : L10n.Localizable.Struct.DownloadBadge.Compact.needsAttention + case .queued, .updateAvailable: + return text } } var color: Color { - switch self { - case .none: - return .clear + switch status { case .queued: return .orange - case .downloading: + case .active: return .blue - case .paused: + case .inactive: return .indigo - case .partial: - return .orange - case .downloaded: + case .completed: return .green - case .failed: - return .orange case .updateAvailable: return .yellow - case .missingFiles: - return .pink + case .error: + return failure == .missingFiles ? .pink : .orange } } } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index 0ba1564d9..e850cdcd1 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -33,27 +33,26 @@ extension DownloadedGallery { } var badge: DownloadBadge { - switch displayStatus { - case .active: - return .downloading(completedPageCount, pageCount) - case .queued: - return .queued - case .inactive: - return .paused(completedPageCount, pageCount) - case .updateAvailable: - return .updateAvailable - case .error: - if completedPageCount > 0, completedPageCount < pageCount { - return .partial(completedPageCount, pageCount) - } - if lastError?.code == .fileOperationFailed, - completedPageCount == 0 { - return .missingFiles - } - return .failed - case .completed: - return .downloaded + DownloadBadge( + status: displayStatus, + failure: badgeFailure, + progress: DownloadProgress( + completedPageCount: completedPageCount, + pageCount: pageCount + ) + ) + } + + private var badgeFailure: DownloadBadge.Failure? { + guard displayStatus == .error else { return nil } + if completedPageCount > 0, completedPageCount < pageCount { + return .partial + } + if lastError?.code == .fileOperationFailed, + completedPageCount == 0 { + return .missingFiles } + return .general } var gallery: Gallery { diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index 345d55bfe..0fe062e39 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -54,13 +54,13 @@ extension DetailReducer { private func handleFetchDownloadBadge(state: inout State) -> Effect { guard state.gid.isValidGID else { return .none } return .run { [galleryID = state.gid] send in - let badge = await downloadClient.badges([galleryID])[galleryID] ?? .none + let badge = await downloadClient.badges([galleryID])[galleryID] await send(.fetchDownloadBadgeDone(badge)) } .cancellable(id: CancelID.fetchDownloadBadge, cancelInFlight: true) } - private func handleFetchDownloadBadgeDone(badge: DownloadBadge, state: inout State) -> Effect { + private func handleFetchDownloadBadgeDone(badge: DownloadBadge?, state: inout State) -> Effect { _ = applyDownloadBadge(badge, state: &state) var effects: [Effect] = [.send(.loadLocalPreviewURLs)] if shouldRequestVersionMetadata(state: state) { @@ -73,14 +73,14 @@ extension DetailReducer { guard state.gid.isValidGID else { return .none } return .run { [galleryID = state.gid] send in for await downloads in downloadClient.observeDownloads() { - let badge = downloads.first(where: { $0.gid == galleryID })?.badge ?? .none + let badge = downloads.first(where: { $0.gid == galleryID })?.badge await send(.observeDownloadDone(badge)) } } .cancellable(id: CancelID.observeDownload, cancelInFlight: true) } - private func handleObserveDownloadDone(badge: DownloadBadge, state: inout State) -> Effect { + private func handleObserveDownloadDone(badge: DownloadBadge?, state: inout State) -> Effect { let didChangeBadge = applyDownloadBadge(badge, state: &state) guard didChangeBadge else { return .none } var effects: [Effect] = [.send(.loadLocalPreviewURLs)] @@ -157,7 +157,7 @@ extension DetailReducer { state.hasLoadedDownloadBadge else { return .none } state.didRunLaunchAutomation = true - guard state.downloadBadge == .none else { return .none } + guard state.downloadBadge == nil else { return .none } return .send(.startDownload(options)) } @@ -214,15 +214,19 @@ extension DetailReducer { ) -> Effect { state.isPreparingDownload = false if case .success = result { - switch state.downloadBadge { - case .downloading(let completed, let total): - state.downloadBadge = .paused(completed, total) - case .paused: + switch state.downloadBadge?.status { + case .active: + state.downloadBadge = DownloadBadge( + status: .inactive, + failure: state.downloadBadge?.failure, + progress: state.downloadBadge?.progress + ) + case .inactive: state.downloadBadge = .queued default: break } - state.hasLoadedDownloadBadge = state.downloadBadge != .none + state.hasLoadedDownloadBadge = state.downloadBadge != nil return .merge( .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), .send(.fetchDownloadBadge) diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 05025c4e9..41ead26fa 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -58,7 +58,7 @@ struct DetailReducer { var localPreviewURLs = [Int: URL]() var galleryComments = [GalleryComment]() var previewConfig: PreviewConfig = .normal(rows: 4) - var downloadBadge: DownloadBadge = .none + var downloadBadge: DownloadBadge? var isPreparingDownload = false var hasLoadedDownloadBadge = false var didRunLaunchAutomation = false @@ -106,9 +106,9 @@ struct DetailReducer { case saveGalleryHistory case updateReadingProgress(Int) case fetchDownloadBadge - case fetchDownloadBadgeDone(DownloadBadge) + case fetchDownloadBadgeDone(DownloadBadge?) case observeDownload - case observeDownloadDone(DownloadBadge) + case observeDownloadDone(DownloadBadge?) case loadLocalPreviewURLs case loadLocalPreviewURLsDone(UUID, [Int: URL]) case openReading @@ -202,13 +202,13 @@ extension DetailReducer { // MARK: - Helpers extension DetailReducer { - func applyDownloadBadge(_ badge: DownloadBadge, state: inout State) -> Bool { + func applyDownloadBadge(_ badge: DownloadBadge?, state: inout State) -> Bool { let didChangeBadge = badge != state.downloadBadge || !state.hasLoadedDownloadBadge state.downloadBadge = badge - if badge != .none { state.isPreparingDownload = false } + if badge != nil { state.isPreparingDownload = false } state.hasLoadedDownloadBadge = true - state.shouldCheckForRemoteUpdates = badge != .none - if badge == .none { + state.shouldCheckForRemoteUpdates = badge != nil + if badge == nil { state.galleryVersionMetadata = nil state.didRequestVersionMetadata = false } diff --git a/EhPanda/View/Detail/DetailView+HeaderSection.swift b/EhPanda/View/Detail/DetailView+HeaderSection.swift index 6c6eeb4e4..9ff5cccd6 100644 --- a/EhPanda/View/Detail/DetailView+HeaderSection.swift +++ b/EhPanda/View/Detail/DetailView+HeaderSection.swift @@ -11,7 +11,7 @@ struct HeaderSection: View { let gallery: Gallery let galleryDetail: GalleryDetail let user: User - let downloadBadge: DownloadBadge + let downloadBadge: DownloadBadge? let isPreparingDownload: Bool let canDownload: Bool let displaysJapaneseTitle: Bool @@ -30,17 +30,16 @@ struct HeaderSection: View { let normalTitle = galleryDetail.title return displaysJapaneseTitle ? galleryDetail.jpnTitle ?? normalTitle : normalTitle } - private var showsMetadataPreparation: Bool { isPreparingDownload && downloadBadge == .none } + private var showsMetadataPreparation: Bool { isPreparingDownload && downloadBadge == nil } private var isDownloadActionDisabled: Bool { guard canDownload else { return true } return isPreparingDownload } private var downloadButtonTint: Color { - switch downloadBadge { + switch downloadBadge?.status { case .updateAvailable: return .orange - case .downloaded: return .red - case .partial: return .orange - case .failed, .missingFiles: return .red + case .completed: return .red + case .error: return downloadBadge?.failure == .partial ? .orange : .red default: return .accentColor } } @@ -183,33 +182,30 @@ struct HeaderSection: View { } } private var queuedDownloadProgress: Double? { - if case .queued = downloadBadge { return 0 } - return nil + downloadBadge?.status == .queued ? 0 : nil } private var activeDownloadProgress: Double? { - if case .downloading(let completed, let total) = downloadBadge { - return Double(completed) / Double(max(total, 1)) - } - if case .paused(let completed, let total) = downloadBadge { - return Double(completed) / Double(max(total, 1)) - } - return nil + guard let badge = downloadBadge, + [.active, .inactive].contains(badge.status) + else { return nil } + return badge.resolvedProgress.fraction } private var activeDownloadIconSystemName: String { - switch downloadBadge { - case .paused: return "play.fill" - case .downloading: return "pause.fill" + switch downloadBadge?.status { + case .inactive: return "play.fill" + case .active: return "pause.fill" default: return downloadIconSystemName } } private var downloadIconSystemName: String { - switch downloadBadge { - case .downloaded: return "trash" + switch downloadBadge?.status { + case .completed: return "trash" case .updateAvailable: return "arrow.triangle.2.circlepath" - case .partial: return "exclamationmark.circle" - case .failed: return "exclamationmark.circle" - case .missingFiles: return "wrench.and.screwdriver" - case .paused: return "play.fill" + case .error: + return downloadBadge?.failure == .missingFiles + ? "wrench.and.screwdriver" + : "exclamationmark.circle" + case .inactive: return "play.fill" default: return "icloud.and.arrow.down" } } @@ -252,29 +248,38 @@ extension HeaderSection { return downloadBadgeAccessibilityLabel } var downloadBadgeAccessibilityLabel: String { - switch downloadBadge { - case .none: + guard let badge = downloadBadge else { return L10n.Localizable.DetailView.Accessibility.DownloadButton.download + } + let progress = badge.resolvedProgress + switch badge.status { case .queued: return L10n.Localizable.DetailView.Accessibility.DownloadButton.queued - case .downloading(let completed, let total): - let progress = L10n.Localizable.DetailView.Accessibility.DownloadButton.downloading( - completed, max(total, 1) + case .active: + let downloading = L10n.Localizable.DetailView.Accessibility.DownloadButton.downloading( + progress.completedPageCount, progress.displayPageCount ) - return [progress, L10n.Localizable.DetailView.Accessibility.DownloadButton.pauseAction] + return [downloading, L10n.Localizable.DetailView.Accessibility.DownloadButton.pauseAction] .joined(separator: ". ") - case .paused(let completed, let total): - return L10n.Localizable.DetailView.Accessibility.DownloadButton.paused(completed, max(total, 1)) - case .downloaded: + case .inactive: + return L10n.Localizable.DetailView.Accessibility.DownloadButton.paused( + progress.completedPageCount, progress.displayPageCount + ) + case .completed: return L10n.Localizable.DetailView.Accessibility.DownloadButton.downloaded case .updateAvailable: return L10n.Localizable.DetailView.Accessibility.DownloadButton.update - case .partial(let completed, let total): - return L10n.Localizable.DetailView.Accessibility.DownloadButton.partial(completed, max(total, 1)) - case .failed: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.retry - case .missingFiles: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.repair + case .error: + switch badge.failure { + case .partial: + return L10n.Localizable.DetailView.Accessibility.DownloadButton.partial( + progress.completedPageCount, progress.displayPageCount + ) + case .missingFiles: + return L10n.Localizable.DetailView.Accessibility.DownloadButton.repair + case .general, nil: + return L10n.Localizable.DetailView.Accessibility.DownloadButton.retry + } } } } diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index c7be720e1..b3d6390c7 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -332,19 +332,19 @@ private extension DetailView { private extension DetailView { private func handleDownloadAction() { let options = setting.downloadRequestOptions - switch store.downloadBadge { - case .none: + switch store.downloadBadge?.status { + case nil: store.send(.startDownload(options)) - case .queued, .downloading, .paused: + case .queued, .active, .inactive: store.send(.toggleDownloadPause) - case .downloaded: + case .completed: downloadDialog = .delete(isActiveDownload: false) - case .failed, .partial: - downloadDialog = .retry(.redownload) + case .error: + downloadDialog = store.downloadBadge?.failure == .missingFiles + ? .retry(.repair) + : .retry(.redownload) case .updateAvailable: downloadDialog = .retry(.update) - case .missingFiles: - downloadDialog = .retry(.repair) } } diff --git a/EhPanda/View/Home/HomeView+Sections.swift b/EhPanda/View/Home/HomeView+Sections.swift index d9ab4eeef..b76d00bb2 100644 --- a/EhPanda/View/Home/HomeView+Sections.swift +++ b/EhPanda/View/Home/HomeView+Sections.swift @@ -54,7 +54,7 @@ struct CardSlideSection: View, Equatable { webImageSuccessAction: { webImageSuccessAction(gallery.gid, $0) }, - downloadBadge: downloadBadges[gallery.gid] ?? .none + downloadBadge: downloadBadges[gallery.gid] ) .tint(.primary) .multilineTextAlignment(.leading) @@ -155,7 +155,7 @@ struct VerticalCoverStack: View { .frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH).cornerRadius(2) .overlay(alignment: .topTrailing) { DownloadBadgeLabel( - badge: downloadBadges[gallery.gid] ?? .none, + badge: downloadBadges[gallery.gid], compact: true ) .padding(6) @@ -278,7 +278,7 @@ struct VerticalToplistsStack: View { GalleryRankingCell( gallery: galleries[index], ranking: startRanking + index, - downloadBadge: downloadBadges[galleries[index].gid] ?? .none + downloadBadge: downloadBadges[galleries[index].gid] ) .tint(.primary).multilineTextAlignment(.leading) } diff --git a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift index 4b80da2f1..3872d46a5 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift @@ -16,7 +16,7 @@ struct GalleryCardCell: View { private let webImageSuccessAction: (RetrieveImageResult) -> Void private let gallery: Gallery - private let downloadBadge: DownloadBadge + private let downloadBadge: DownloadBadge? private let animation: Animation = .interpolatingSpring(stiffness: 50, damping: 1).speed(0.2) @@ -24,7 +24,7 @@ struct GalleryCardCell: View { init( gallery: Gallery, currentID: String, colors: [Color], webImageSuccessAction: @escaping (RetrieveImageResult) -> Void, - downloadBadge: DownloadBadge = .none + downloadBadge: DownloadBadge? = nil ) { self.gallery = gallery self.currentID = currentID @@ -63,7 +63,7 @@ struct GalleryCardCell: View { VStack(alignment: .leading) { Text(title) .font(.title3.bold()) - .lineLimit(downloadBadge == .none ? 4 : 2) + .lineLimit(downloadBadge == nil ? 4 : 2) DownloadBadgeLabel(badge: downloadBadge, compact: true) Spacer() RatingView(rating: gallery.rating).foregroundColor(.yellow) diff --git a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift index 76c267fca..b0f2f480e 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift @@ -18,14 +18,14 @@ struct GalleryDetailCell: View { private let coverSource: CoverSource private let setting: Setting private let translateAction: ((String) -> (String, TagTranslation?))? - private let downloadBadge: DownloadBadge + private let downloadBadge: DownloadBadge? init( gallery: Gallery, coverSource: CoverSource = .dynamic, setting: Setting, translateAction: ((String) -> (String, TagTranslation?))? = nil, - downloadBadge: DownloadBadge = .none + downloadBadge: DownloadBadge? = nil ) { self.gallery = gallery self.coverSource = coverSource @@ -61,7 +61,7 @@ private struct GalleryDetailCellContent: View { private let setting: Setting private let colorScheme: ColorScheme private let translateAction: ((String) -> (String, TagTranslation?))? - private let downloadBadge: DownloadBadge + private let downloadBadge: DownloadBadge? init( gallery: Gallery, @@ -69,7 +69,7 @@ private struct GalleryDetailCellContent: View { setting: Setting, colorScheme: ColorScheme, translateAction: ((String) -> (String, TagTranslation?))?, - downloadBadge: DownloadBadge + downloadBadge: DownloadBadge? ) { self.gallery = gallery self.resolvedCoverURL = resolvedCoverURL @@ -90,7 +90,7 @@ private struct GalleryDetailCellContent: View { .defaultModifier().scaledToFit().frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH) VStack(alignment: .leading, spacing: 5) { Text(gallery.title) - .lineLimit(downloadBadge == .none ? 3 : 2) + .lineLimit(downloadBadge == nil ? 3 : 2) .font(.headline) .foregroundStyle(.primary) .fixedSize(horizontal: false, vertical: true) diff --git a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift index 075490009..1a3774f40 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift @@ -9,9 +9,9 @@ import Kingfisher struct GalleryRankingCell: View { private let gallery: Gallery private let ranking: Int - private let downloadBadge: DownloadBadge + private let downloadBadge: DownloadBadge? - init(gallery: Gallery, ranking: Int, downloadBadge: DownloadBadge = .none) { + init(gallery: Gallery, ranking: Int, downloadBadge: DownloadBadge? = nil) { self.gallery = gallery self.ranking = ranking self.downloadBadge = downloadBadge diff --git a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift index 8c746251f..6bf47fb9c 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -12,13 +12,13 @@ struct GalleryThumbnailCell: View { private let gallery: Gallery private let setting: Setting private let translateAction: ((String) -> (String, TagTranslation?))? - private let downloadBadge: DownloadBadge + private let downloadBadge: DownloadBadge? init( gallery: Gallery, setting: Setting, translateAction: ((String) -> (String, TagTranslation?))? = nil, - downloadBadge: DownloadBadge = .none + downloadBadge: DownloadBadge? = nil ) { self.gallery = gallery self.setting = setting @@ -62,7 +62,7 @@ struct GalleryThumbnailCell: View { VStack(alignment: .leading, spacing: 5) { Text(gallery.title) .font(.callout.bold()) - .lineLimit(downloadBadge == .none ? 3 : 2) + .lineLimit(downloadBadge == nil ? 3 : 2) let tagContents = gallery.tagContents(maximum: setting.listTagsNumberMaximum) if setting.showsTagsInList, !tagContents.isEmpty { TagCloudView(data: tagContents) { content in diff --git a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift index c12718510..2421c603b 100644 --- a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift +++ b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift @@ -9,8 +9,8 @@ struct DownloadBadgeLabel: View { private let badge: DownloadBadge private let isCompactStyle: Bool - init?(badge: DownloadBadge, compact: Bool = false) { - guard badge != .none else { return nil } + init?(badge: DownloadBadge?, compact: Bool = false) { + guard let badge else { return nil } self.badge = badge self.isCompactStyle = compact @@ -18,6 +18,7 @@ struct DownloadBadgeLabel: View { var body: some View { labelText + .lineLimit(1) .foregroundStyle(foregroundColor) .padding(.horizontal, isCompactStyle ? 6 : 8) .padding(.vertical, isCompactStyle ? 3 : 4) @@ -27,7 +28,7 @@ struct DownloadBadgeLabel: View { private var labelText: Text { if isCompactStyle { - Text(compactText) + Text(badge.compactText) .font(.caption2.bold()) } else { Text(badge.text) @@ -35,33 +36,11 @@ struct DownloadBadgeLabel: View { } } - private var compactText: String { - switch badge { - case .downloading: - return L10n.Localizable.Struct.DownloadBadge.Compact.downloading - case .paused: - return L10n.Localizable.Struct.DownloadBadge.Compact.paused - case .partial: - return L10n.Localizable.Struct.DownloadBadge.Compact.needsAttention - case .downloaded: - return L10n.Localizable.Struct.DownloadBadge.Compact.done - case .failed: - return L10n.Localizable.Struct.DownloadBadge.Compact.needsAttention - default: - return badge.text - } - } - private var backgroundColor: Color { badge.color.opacity(0.15) } private var foregroundColor: Color { - switch badge { - case .updateAvailable: - return .orange - default: - return badge.color - } + badge.status == .updateAvailable ? .orange : badge.color } } diff --git a/EhPanda/View/Support/Components/GenericList.swift b/EhPanda/View/Support/Components/GenericList.swift index 69e70a6d9..02579f075 100644 --- a/EhPanda/View/Support/Components/GenericList.swift +++ b/EhPanda/View/Support/Components/GenericList.swift @@ -123,7 +123,7 @@ private struct DetailList: View { gallery: gallery, setting: setting, translateAction: translateAction, - downloadBadge: downloadBadges[gallery.gid] ?? .none + downloadBadge: downloadBadges[gallery.gid] ) } .foregroundColor(.primary) @@ -194,7 +194,7 @@ private struct WaterfallList: View { gallery: gallery, setting: setting, translateAction: translateAction, - downloadBadge: downloadBadges[gallery.gid] ?? .none + downloadBadge: downloadBadges[gallery.gid] ) .tint(.primary).multilineTextAlignment(.leading) } diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index 816adba3d..b9e57aee1 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -125,7 +125,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { private extension DetailReducerDownloadTests { func makeDownloadTestStore( gallery: Gallery, detail: GalleryDetail, - badgeValue: DownloadBadge, + badgeValue: DownloadBadge?, automationGID: String? = nil, configure: (inout DetailReducer.State) -> Void = { _ in }, enqueue: @escaping @Sendable (DownloadRequestPayload) async -> Result @@ -147,7 +147,9 @@ private extension DetailReducerDownloadTests { refreshDownloads: {}, resumeQueue: {}, badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, badgeValue) }) + badgeValue.map { value in + Dictionary(uniqueKeysWithValues: gids.map { ($0, value) }) + } ?? [:] }, enqueue: enqueue, togglePause: { _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift index 80b880925..0e16d4d35 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -114,7 +114,7 @@ struct DetailReducerMetadataTests: DownloadFeatureTestCase { private extension DetailReducerMetadataTests { func makeMetadataTestStore( gid: String, gallery: Gallery, - badgeValue: DownloadBadge, updateCheckCount: UncheckedBox + badgeValue: DownloadBadge?, updateCheckCount: UncheckedBox ) -> TestStoreOf { var initialState = DetailReducer.State() initialState.gid = gid @@ -130,7 +130,11 @@ private extension DetailReducerMetadataTests { fetchDownload: { _ in nil }, refreshDownloads: {}, resumeQueue: {}, - badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, badgeValue) }) }, + badges: { gids in + badgeValue.map { value in + Dictionary(uniqueKeysWithValues: gids.map { ($0, value) }) + } ?? [:] + }, fetchVersionMetadata: { _, _ in .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) }, @@ -152,7 +156,7 @@ private extension DetailReducerMetadataTests { func makeDownloadedMetadataTestStore( gid: String, gallery: Gallery, - badgeValue: DownloadBadge, updateCheckCount: UncheckedBox + badgeValue: DownloadBadge?, updateCheckCount: UncheckedBox ) -> TestStoreOf { var initialState = DetailReducer.State() initialState.gid = gid @@ -168,7 +172,11 @@ private extension DetailReducerMetadataTests { fetchDownload: { _ in nil }, refreshDownloads: {}, resumeQueue: {}, - badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, badgeValue) }) }, + badges: { gids in + badgeValue.map { value in + Dictionary(uniqueKeysWithValues: gids.map { ($0, value) }) + } ?? [:] + }, fetchVersionMetadata: { _, _ in .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index 84f1639c4..966435f29 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -159,7 +159,7 @@ private extension DetailReducerMetadataUpdateTests { fetchDownload: { gid in gid == download.gid ? download : nil }, refreshDownloads: {}, resumeQueue: {}, - badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) }, + badges: { _ in [:] }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift index e7ae30f26..393876538 100644 --- a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift @@ -35,7 +35,10 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { sampleDownload(gid: gallery.gid, title: gallery.title, status: .queued) ]) await store.receive(\.observeDownloadDone) { - $0.downloadBadge = .queued + $0.downloadBadge = DownloadBadge( + status: .queued, + progress: .init(completedPageCount: 0, pageCount: 12) + ) $0.hasLoadedDownloadBadge = true } @@ -57,7 +60,10 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { ) ]) await store.receive(\.observeDownloadDone) { - $0.downloadBadge = .downloaded + $0.downloadBadge = DownloadBadge( + status: .completed, + progress: .init(completedPageCount: 26, pageCount: 26) + ) $0.hasLoadedDownloadBadge = true } @@ -144,7 +150,7 @@ private extension DetailReducerObserveTests { fetchDownload: { _ in nil }, refreshDownloads: {}, resumeQueue: {}, - badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, .none) }) }, + badges: { _ in [:] }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index 000341618..026278302 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -9,6 +9,27 @@ import Testing @testable import EhPanda struct DownloadBadgeSortTests: DownloadFeatureTestCase { + @Test + func testBadgeSeparatesDisplayStatusFromProgress() { + let activeDownload = sampleDownload( + gid: "479", + title: "Active Archive", + status: .downloading, + pageCount: 26, + completedPageCount: 7 + ) + let badge = activeDownload.badge + + #expect(badge.status == activeDownload.displayStatus) + #expect(badge.progress == DownloadProgress(completedPageCount: 7, pageCount: 26)) + #expect(badge.failure == nil) + #expect(badge.compactText == L10n.Localizable.Struct.DownloadBadge.Compact.downloading) + #expect( + badge.text + == L10n.Localizable.Struct.DownloadBadge.Text.downloading(7, 26) + ) + } + @Test func testPartialDownloadBadgeUsesNeedsAttentionCopy() { let partialDownload = sampleDownload( @@ -53,7 +74,7 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { #expect(queuedRepair.matches(filter: .failed) == false) #expect(queuedRepair.matches(filter: .update) == false) - #expect(missingFilesWithoutQueuedWork.badge == .missingFiles) + #expect(missingFilesWithoutQueuedWork.badge.failure == .missingFiles) #expect(missingFilesWithoutQueuedWork.matches(filter: .failed)) } diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 92ce1ffee..fe336b348 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -67,7 +67,7 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { completedPageCount: 12 ) - #expect(queuedRedownload.badge == .queued) + #expect(queuedRedownload.badge.status == .queued) #expect(queuedRedownload.matches(filter: .active)) } @@ -80,7 +80,7 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { completedPageCount: 3 ) - #expect(queuedRepair.badge == .queued) + #expect(queuedRepair.badge.status == .queued) #expect(queuedRepair.matches(filter: .active)) } @@ -93,7 +93,7 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { completedPageCount: 12 ) - #expect(queuedUpdate.badge == .queued) + #expect(queuedUpdate.badge.status == .queued) #expect(queuedUpdate.matches(filter: .active)) #expect(queuedUpdate.matches(filter: .update) == false) } @@ -109,7 +109,7 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { ) #expect(resumedUpdate.isQueuedWorkItem) - #expect(resumedUpdate.badge == .queued) + #expect(resumedUpdate.badge.status == .queued) #expect(resumedUpdate.matches(filter: .active)) } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 9fa39e0bd..86b78e125 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -152,7 +152,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(indexedDownload.displayStatus == .queued) #expect(indexedDownload.displayStatus == .queued) #expect(await manager.fetchDownload(gid: "601") == nil) - #expect(badges["600"] == .queued) + #expect(badges["600"]?.status == .queued) #expect(badges["601"] == nil) } @@ -383,7 +383,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(download.displayStatus == .error) #expect(download.displayStatus == .error) #expect(download.lastError?.code == .fileOperationFailed) - #expect(download.badge == .failed) + #expect(download.badge.failure == .general) } @Test @@ -528,7 +528,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(failedDownload.displayStatus == .error) #expect(failedDownload.displayStatus == .error) #expect(failedDownload.lastError?.code == .networkingFailed) - #expect(badges["800"] == .failed) + #expect(badges["800"]?.failure == .general) } @Test diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index 227e3d135..b6e2143e4 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -156,7 +156,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) #expect(stored?.displayStatus == .error) - #expect(stored?.badge == .failed) + #expect(stored?.badge.failure == .general) } @Test diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index 136441e57..cb993232e 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -47,7 +47,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) #expect(stored?.displayStatus == .queued) - #expect(stored?.badge == .queued) + #expect(stored?.badge.status == .queued) #expect(stored?.lastError == nil) } diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index dab5771d5..9e69258bd 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -51,7 +51,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { let queued = await queueingManager.testingFetchDownload(gid: gid) #expect(queued?.displayStatus == .queued) - #expect(queued?.badge == .queued) + #expect(queued?.badge.status == .queued) #expect(queued?.lastError == nil) } diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 6e08827d9..572645720 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -103,7 +103,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { ) let updatedDownload = await manager.testingFetchDownload(gid: gid) - #expect(updateBadge == .updateAvailable) + #expect(updateBadge?.status == .updateAvailable) #expect(updatedDownload?.displayStatus == .updateAvailable) #expect(updatedDownload?.displayStatus == .updateAvailable) @@ -122,7 +122,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { ) let currentDownload = await manager.testingFetchDownload(gid: gid) - #expect(currentBadge == .downloaded) + #expect(currentBadge?.status == .completed) #expect(currentDownload?.displayStatus == .completed) #expect(currentDownload?.displayStatus == .completed) } diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift index 70da73837..4107310b8 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -45,7 +45,7 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { #expect(store.state.route == .detail(download.gid)) #expect(store.state.detailState.wrappedValue?.gid == download.gid) #expect(store.state.detailState.wrappedValue?.gallery.id == download.gid) - #expect(store.state.detailState.wrappedValue?.downloadBadge == .downloaded) + #expect(store.state.detailState.wrappedValue?.downloadBadge?.status == .completed) #expect(store.state.detailState.wrappedValue?.shouldCheckForRemoteUpdates == true) } From 10cbc1eed3621c889c15818b79eba4fbf900fbf3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 15:38:21 +0800 Subject: [PATCH 154/614] Split progress out of status keys --- EhPanda/App/Generated/Strings.swift | 28 +++------ EhPanda/App/de.lproj/Localizable.strings | 10 +--- EhPanda/App/en.lproj/Localizable.strings | 10 +--- EhPanda/App/ja.lproj/Localizable.strings | 10 +--- EhPanda/App/ko.lproj/Localizable.strings | 10 +--- EhPanda/App/zh-Hans.lproj/Localizable.strings | 10 +--- .../App/zh-Hant-HK.lproj/Localizable.strings | 10 +--- .../App/zh-Hant-TW.lproj/Localizable.strings | 10 +--- EhPanda/App/zh-Hant.lproj/Localizable.strings | 10 +--- .../DownloadedGallery+Extensions.swift | 58 +++++++++---------- .../Components/DownloadBadgeLabel.swift | 2 +- .../Download/DownloadBadgeSortTests.swift | 18 ++++-- 12 files changed, 71 insertions(+), 115 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 2b2cebe89..876ae3da9 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -2393,35 +2393,21 @@ internal enum L10n { } } internal enum DownloadBadge { - internal enum Compact { - /// Done - internal static let done = L10n.tr("Localizable", "struct.download_badge.compact.done", fallback: "Done") - /// DL - internal static let downloading = L10n.tr("Localizable", "struct.download_badge.compact.downloading", fallback: "DL") - /// Needs Attention - internal static let needsAttention = L10n.tr("Localizable", "struct.download_badge.compact.needs_attention", fallback: "Needs Attention") - /// Pause - internal static let paused = L10n.tr("Localizable", "struct.download_badge.compact.paused", fallback: "Pause") + /// %d/%d + internal static func progress(_ p1: Int, _ p2: Int) -> String { + return L10n.tr("Localizable", "struct.download_badge.progress", p1, p2, fallback: "%d/%d") } internal enum Text { /// Downloaded internal static let downloaded = L10n.tr("Localizable", "struct.download_badge.text.downloaded", fallback: "Downloaded") - /// Downloading %d/%d - internal static func downloading(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "struct.download_badge.text.downloading", p1, p2, fallback: "Downloading %d/%d") - } + /// Downloading + internal static let downloading = L10n.tr("Localizable", "struct.download_badge.text.downloading", fallback: "Downloading") /// Needs Attention internal static let needsAttention = L10n.tr("Localizable", "struct.download_badge.text.needs_attention", fallback: "Needs Attention") - /// Needs Attention %d/%d - internal static func needsAttentionProgress(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "struct.download_badge.text.needs_attention_progress", p1, p2, fallback: "Needs Attention %d/%d") - } /// Needs Repair internal static let needsRepair = L10n.tr("Localizable", "struct.download_badge.text.needs_repair", fallback: "Needs Repair") - /// Paused %d/%d - internal static func paused(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "struct.download_badge.text.paused", p1, p2, fallback: "Paused %d/%d") - } + /// Paused + internal static let paused = L10n.tr("Localizable", "struct.download_badge.text.paused", fallback: "Paused") /// Queued internal static let queued = L10n.tr("Localizable", "struct.download_badge.text.queued", fallback: "Queued") /// Update Available diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index f3db0ae38..ece1c2188 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -1002,17 +1002,13 @@ "enum.download_list_filter.title.failed" = "Benötigt Aufmerksamkeit"; "enum.download_list_filter.title.update" = "Update verfügbar"; "struct.download_badge.text.queued" = "In Warteschlange"; -"struct.download_badge.text.downloading" = "Lädt %d/%d herunter"; -"struct.download_badge.text.paused" = "Pausiert %d/%d"; -"struct.download_badge.text.needs_attention_progress" = "Benötigt Aufmerksamkeit %d/%d"; +"struct.download_badge.text.downloading" = "Lädt herunter"; +"struct.download_badge.text.paused" = "Pausiert"; "struct.download_badge.text.downloaded" = "Heruntergeladen"; "struct.download_badge.text.needs_attention" = "Benötigt Aufmerksamkeit"; "struct.download_badge.text.update_available" = "Update verfügbar"; "struct.download_badge.text.needs_repair" = "Reparatur nötig"; -"struct.download_badge.compact.downloading" = "DL"; -"struct.download_badge.compact.paused" = "Pause"; -"struct.download_badge.compact.needs_attention" = "Achtung"; -"struct.download_badge.compact.done" = "Fertig"; +"struct.download_badge.progress" = "%d/%d"; "download_file_storage.error.asset_unreadable" = "Asset-Datei ist nicht lesbar: %@"; "download_file_storage.validation.download_folder_unresolved" = "Download-Ordner konnte nicht aufgelöst werden."; "download_file_storage.validation.download_folder_missing" = "Download-Ordner fehlt."; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index b68e23d4d..4bafbd9f3 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -459,17 +459,13 @@ // MARK: DownloadBadge "struct.download_badge.text.queued" = "Queued"; -"struct.download_badge.text.downloading" = "Downloading %d/%d"; -"struct.download_badge.text.paused" = "Paused %d/%d"; -"struct.download_badge.text.needs_attention_progress" = "Needs Attention %d/%d"; +"struct.download_badge.text.downloading" = "Downloading"; +"struct.download_badge.text.paused" = "Paused"; "struct.download_badge.text.downloaded" = "Downloaded"; "struct.download_badge.text.needs_attention" = "Needs Attention"; "struct.download_badge.text.update_available" = "Update Available"; "struct.download_badge.text.needs_repair" = "Needs Repair"; -"struct.download_badge.compact.downloading" = "DL"; -"struct.download_badge.compact.paused" = "Pause"; -"struct.download_badge.compact.needs_attention" = "Needs Attention"; -"struct.download_badge.compact.done" = "Done"; +"struct.download_badge.progress" = "%d/%d"; // MARK: DownloadFileStorage "download_file_storage.error.asset_unreadable" = "Asset file is unreadable: %@"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 9a608f3b4..475fc5827 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -1002,17 +1002,13 @@ "enum.download_list_filter.title.failed" = "要対応"; "enum.download_list_filter.title.update" = "更新あり"; "struct.download_badge.text.queued" = "待機中"; -"struct.download_badge.text.downloading" = "%d/%d をダウンロード中"; -"struct.download_badge.text.paused" = "%d/%d で一時停止"; -"struct.download_badge.text.needs_attention_progress" = "要対応 %d/%d"; +"struct.download_badge.text.downloading" = "ダウンロード中"; +"struct.download_badge.text.paused" = "一時停止"; "struct.download_badge.text.downloaded" = "ダウンロード済み"; "struct.download_badge.text.needs_attention" = "要対応"; "struct.download_badge.text.update_available" = "更新あり"; "struct.download_badge.text.needs_repair" = "要修復"; -"struct.download_badge.compact.downloading" = "DL"; -"struct.download_badge.compact.paused" = "一時停止"; -"struct.download_badge.compact.needs_attention" = "要対応"; -"struct.download_badge.compact.done" = "完了"; +"struct.download_badge.progress" = "%d/%d"; "download_file_storage.error.asset_unreadable" = "アセットファイルを読み取れません: %@"; "download_file_storage.validation.download_folder_unresolved" = "ダウンロードフォルダを解決できませんでした。"; "download_file_storage.validation.download_folder_missing" = "ダウンロードフォルダが見つかりません。"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index bfd6c7e57..acadf81cc 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -1002,17 +1002,13 @@ "enum.download_list_filter.title.failed" = "조치 필요"; "enum.download_list_filter.title.update" = "업데이트 가능"; "struct.download_badge.text.queued" = "대기 중"; -"struct.download_badge.text.downloading" = "다운로드 중 %d/%d"; -"struct.download_badge.text.paused" = "일시 정지 %d/%d"; -"struct.download_badge.text.needs_attention_progress" = "조치 필요 %d/%d"; +"struct.download_badge.text.downloading" = "다운로드 중"; +"struct.download_badge.text.paused" = "일시 정지"; "struct.download_badge.text.downloaded" = "다운로드됨"; "struct.download_badge.text.needs_attention" = "조치 필요"; "struct.download_badge.text.update_available" = "업데이트 가능"; "struct.download_badge.text.needs_repair" = "복구 필요"; -"struct.download_badge.compact.downloading" = "DL"; -"struct.download_badge.compact.paused" = "일시정지"; -"struct.download_badge.compact.needs_attention" = "조치 필요"; -"struct.download_badge.compact.done" = "완료"; +"struct.download_badge.progress" = "%d/%d"; "download_file_storage.error.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; "download_file_storage.validation.download_folder_unresolved" = "다운로드 폴더를 확인할 수 없습니다."; "download_file_storage.validation.download_folder_missing" = "다운로드 폴더가 없습니다."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 2418e7f6b..df89b5329 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -458,17 +458,13 @@ // MARK: DownloadBadge "struct.download_badge.text.queued" = "已排队"; -"struct.download_badge.text.downloading" = "下载中 %d/%d"; -"struct.download_badge.text.paused" = "已暂停 %d/%d"; -"struct.download_badge.text.needs_attention_progress" = "需处理 %d/%d"; +"struct.download_badge.text.downloading" = "下载中"; +"struct.download_badge.text.paused" = "已暂停"; "struct.download_badge.text.downloaded" = "已下载"; "struct.download_badge.text.needs_attention" = "需处理"; "struct.download_badge.text.update_available" = "有可更新"; "struct.download_badge.text.needs_repair" = "需修复"; -"struct.download_badge.compact.downloading" = "下载中"; -"struct.download_badge.compact.paused" = "暂停"; -"struct.download_badge.compact.needs_attention" = "需处理"; -"struct.download_badge.compact.done" = "完成"; +"struct.download_badge.progress" = "%d/%d"; // MARK: DownloadFileStorage "download_file_storage.error.asset_unreadable" = "资源文件无法读取:%@"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 1f0634099..2e3b2961d 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -999,17 +999,13 @@ "enum.download_list_filter.title.failed" = "需處理"; "enum.download_list_filter.title.update" = "有可更新"; "struct.download_badge.text.queued" = "已排隊"; -"struct.download_badge.text.downloading" = "下載中 %d/%d"; -"struct.download_badge.text.paused" = "已暫停 %d/%d"; -"struct.download_badge.text.needs_attention_progress" = "需處理 %d/%d"; +"struct.download_badge.text.downloading" = "下載中"; +"struct.download_badge.text.paused" = "已暫停"; "struct.download_badge.text.downloaded" = "已下載"; "struct.download_badge.text.needs_attention" = "需處理"; "struct.download_badge.text.update_available" = "有可更新"; "struct.download_badge.text.needs_repair" = "需修復"; -"struct.download_badge.compact.downloading" = "下載中"; -"struct.download_badge.compact.paused" = "暫停"; -"struct.download_badge.compact.needs_attention" = "需處理"; -"struct.download_badge.compact.done" = "完成"; +"struct.download_badge.progress" = "%d/%d"; "download_file_storage.error.asset_unreadable" = "資源檔案無法讀取:%@"; "download_file_storage.validation.download_folder_unresolved" = "無法解析下載資料夾。"; "download_file_storage.validation.download_folder_missing" = "下載資料夾缺失。"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 01d0caed4..ceee38475 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -1000,17 +1000,13 @@ "enum.download_list_filter.title.failed" = "需處理"; "enum.download_list_filter.title.update" = "有可更新"; "struct.download_badge.text.queued" = "已排隊"; -"struct.download_badge.text.downloading" = "下載中 %d/%d"; -"struct.download_badge.text.paused" = "已暫停 %d/%d"; -"struct.download_badge.text.needs_attention_progress" = "需處理 %d/%d"; +"struct.download_badge.text.downloading" = "下載中"; +"struct.download_badge.text.paused" = "已暫停"; "struct.download_badge.text.downloaded" = "已下載"; "struct.download_badge.text.needs_attention" = "需處理"; "struct.download_badge.text.update_available" = "有可更新"; "struct.download_badge.text.needs_repair" = "需修復"; -"struct.download_badge.compact.downloading" = "下載中"; -"struct.download_badge.compact.paused" = "暫停"; -"struct.download_badge.compact.needs_attention" = "需處理"; -"struct.download_badge.compact.done" = "完成"; +"struct.download_badge.progress" = "%d/%d"; "download_file_storage.error.asset_unreadable" = "資源檔案無法讀取:%@"; "download_file_storage.validation.download_folder_unresolved" = "無法解析下載資料夾。"; "download_file_storage.validation.download_folder_missing" = "下載資料夾缺失。"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 2afee3f5e..c79e06171 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -1000,17 +1000,13 @@ "enum.download_list_filter.title.failed" = "需處理"; "enum.download_list_filter.title.update" = "有可更新"; "struct.download_badge.text.queued" = "已排隊"; -"struct.download_badge.text.downloading" = "下載中 %d/%d"; -"struct.download_badge.text.paused" = "已暫停 %d/%d"; -"struct.download_badge.text.needs_attention_progress" = "需處理 %d/%d"; +"struct.download_badge.text.downloading" = "下載中"; +"struct.download_badge.text.paused" = "已暫停"; "struct.download_badge.text.downloaded" = "已下載"; "struct.download_badge.text.needs_attention" = "需處理"; "struct.download_badge.text.update_available" = "有可更新"; "struct.download_badge.text.needs_repair" = "需修復"; -"struct.download_badge.compact.downloading" = "下載中"; -"struct.download_badge.compact.paused" = "暫停"; -"struct.download_badge.compact.needs_attention" = "需處理"; -"struct.download_badge.compact.done" = "完成"; +"struct.download_badge.progress" = "%d/%d"; "download_file_storage.error.asset_unreadable" = "資源檔案無法讀取:%@"; "download_file_storage.validation.download_folder_unresolved" = "無法解析下載資料夾。"; "download_file_storage.validation.download_folder_missing" = "下載資料夾缺失。"; diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift index 4870acf0a..318bf99fb 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -7,53 +7,47 @@ import SwiftUI // MARK: - DownloadBadge extension DownloadBadge { - var text: String { + var statusText: String { switch status { case .queued: return L10n.Localizable.Struct.DownloadBadge.Text.queued case .active: - return L10n.Localizable.Struct.DownloadBadge.Text.downloading( - resolvedProgress.completedPageCount, - resolvedProgress.displayPageCount - ) + return L10n.Localizable.Struct.DownloadBadge.Text.downloading case .inactive: - return L10n.Localizable.Struct.DownloadBadge.Text.paused( - resolvedProgress.completedPageCount, - resolvedProgress.displayPageCount - ) + return L10n.Localizable.Struct.DownloadBadge.Text.paused case .updateAvailable: return L10n.Localizable.Struct.DownloadBadge.Text.updateAvailable case .completed: return L10n.Localizable.Struct.DownloadBadge.Text.downloaded case .error: - switch failure { - case .partial: - return L10n.Localizable.Struct.DownloadBadge.Text.needsAttentionProgress( - resolvedProgress.completedPageCount, - resolvedProgress.displayPageCount - ) - case .missingFiles: - return L10n.Localizable.Struct.DownloadBadge.Text.needsRepair - case .general, nil: - return L10n.Localizable.Struct.DownloadBadge.Text.needsAttention - } + return failure == .missingFiles + ? L10n.Localizable.Struct.DownloadBadge.Text.needsRepair + : L10n.Localizable.Struct.DownloadBadge.Text.needsAttention } } - var compactText: String { + var progressText: String? { + guard showsProgressText, let progress else { return nil } + return L10n.Localizable.Struct.DownloadBadge.progress( + progress.completedPageCount, + progress.displayPageCount + ) + } + + var text: String { + [statusText, progressText] + .compactMap { $0 } + .joined(separator: " ") + } + + private var showsProgressText: Bool { switch status { - case .active: - return L10n.Localizable.Struct.DownloadBadge.Compact.downloading - case .inactive: - return L10n.Localizable.Struct.DownloadBadge.Compact.paused - case .completed: - return L10n.Localizable.Struct.DownloadBadge.Compact.done + case .active, .inactive: + return true case .error: - return failure == .missingFiles - ? text - : L10n.Localizable.Struct.DownloadBadge.Compact.needsAttention - case .queued, .updateAvailable: - return text + return failure == .partial + case .queued, .updateAvailable, .completed: + return false } } diff --git a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift index 2421c603b..fe845dc5b 100644 --- a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift +++ b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift @@ -28,7 +28,7 @@ struct DownloadBadgeLabel: View { private var labelText: Text { if isCompactStyle { - Text(badge.compactText) + Text(badge.statusText) .font(.caption2.bold()) } else { Text(badge.text) diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index 026278302..e3ba3b3de 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -23,11 +23,19 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { #expect(badge.status == activeDownload.displayStatus) #expect(badge.progress == DownloadProgress(completedPageCount: 7, pageCount: 26)) #expect(badge.failure == nil) - #expect(badge.compactText == L10n.Localizable.Struct.DownloadBadge.Compact.downloading) - #expect( - badge.text - == L10n.Localizable.Struct.DownloadBadge.Text.downloading(7, 26) - ) + #expect(badge.statusText == "Downloading") + #expect(badge.progressText == "7/26") + #expect(badge.text == "Downloading 7/26") + + let completedBadge = sampleDownload( + gid: "481", + title: "Done Archive", + status: .completed, + pageCount: 26 + ).badge + + #expect(completedBadge.progressText == nil) + #expect(completedBadge.text == completedBadge.statusText) } @Test From 76ace70751620aebf56015ff5b0438b3fbdbdaf9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 17:56:44 +0800 Subject: [PATCH 155/614] Render badges as ringed symbols and progress text --- .../Clients/DownloadClient+PublicAPI.swift | 8 +- .../App/Tools/Clients/DownloadClient.swift | 4 +- EhPanda/Models/Persistent/DownloadBadge.swift | 65 +------------- .../Models/Persistent/DownloadProgress.swift | 20 +++++ .../DownloadedGallery+Extensions.swift | 67 ++++---------- .../DownloadedGallery+SupportTypes.swift | 13 --- .../View/Detail/DetailReducer+Download.swift | 60 ++++++++----- EhPanda/View/Detail/DetailReducer+Fetch.swift | 4 +- EhPanda/View/Detail/DetailReducer.swift | 15 +++- .../Detail/DetailView+HeaderSection.swift | 24 ++--- EhPanda/View/Detail/DetailView.swift | 3 +- EhPanda/View/Downloads/DownloadsReducer.swift | 2 +- EhPanda/View/Home/HomeView+Sections.swift | 9 +- .../Components/Cells/GalleryCardCell.swift | 4 +- .../Components/Cells/GalleryDetailCell.swift | 4 +- .../Components/Cells/GalleryRankingCell.swift | 4 +- .../Cells/GalleryThumbnailCell.swift | 4 +- .../Components/DownloadBadgeLabel.swift | 87 ++++++++++++++----- .../Download/DetailReducerDownloadTests.swift | 29 ++++--- .../Download/DetailReducerMetadataTests.swift | 41 ++++----- .../DetailReducerMetadataUpdateTests.swift | 19 ++-- .../Download/DetailReducerObserveTests.swift | 9 +- .../DetailReducerPauseAndGuardTests.swift | 40 ++++++--- .../Download/DownloadBadgeSortTests.swift | 25 ++++-- .../DownloadFilterAndBadgeTests.swift | 17 +++- .../DownloadInterruptedResumeTests.swift | 7 +- .../DownloadManagerStorageTests.swift | 3 +- .../DownloadPauseAndReconcileTests.swift | 16 +++- .../Download/DownloadRetryPagesTests.swift | 7 +- .../DownloadVersionSignatureTests.swift | 8 +- 30 files changed, 347 insertions(+), 271 deletions(-) create mode 100644 EhPanda/Models/Persistent/DownloadProgress.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 26437a31d..e903964b6 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -50,15 +50,15 @@ extension DownloadManager { func updateRemoteVersion( gid: String, metadata: DownloadVersionMetadata - ) async -> DownloadBadge? { + ) async -> DownloadedGallery? { guard let download = await fetchDownload(gid: gid) else { return nil } guard downloadIndex[gid] != nil else { - return download.badge + return download } guard [.completed, .updateAvailable].contains(download.displayStatus) else { - return download.badge + return download } let hadUpdate = updatedGalleryIDs.contains(gid) @@ -71,7 +71,7 @@ extension DownloadManager { if hadUpdate != hasUpdate { await notifyObservers() } - return (await fetchDownload(gid: gid))?.badge + return await fetchDownload(gid: gid) } func enqueue( diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 522c6336d..b94898be1 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -16,7 +16,7 @@ struct DownloadClient: Sendable { let resumeQueue: @Sendable () async -> Void let badges: @Sendable ([String]) async -> [String: DownloadBadge] let fetchVersionMetadata: @Sendable (String, String) async -> Result - let updateRemoteVersion: @Sendable (String, DownloadVersionMetadata) async -> DownloadBadge? + let updateRemoteVersion: @Sendable (String, DownloadVersionMetadata) async -> DownloadedGallery? let enqueue: @Sendable (DownloadRequestPayload) async -> Result let togglePause: @Sendable (String) async -> Result let retry: @Sendable (String, DownloadStartMode) async -> Result @@ -38,7 +38,7 @@ struct DownloadClient: Sendable { badges: @escaping @Sendable ([String]) async -> [String: DownloadBadge], fetchVersionMetadata: @escaping @Sendable (String, String) async -> Result = { _, _ in .failure(.notFound) }, - updateRemoteVersion: @escaping @Sendable (String, DownloadVersionMetadata) async -> DownloadBadge? = + updateRemoteVersion: @escaping @Sendable (String, DownloadVersionMetadata) async -> DownloadedGallery? = { _, _ in nil }, enqueue: @escaping @Sendable (DownloadRequestPayload) async -> Result, togglePause: @escaping @Sendable (String) async -> Result, diff --git a/EhPanda/Models/Persistent/DownloadBadge.swift b/EhPanda/Models/Persistent/DownloadBadge.swift index 7cb11d75e..4a1234c2e 100644 --- a/EhPanda/Models/Persistent/DownloadBadge.swift +++ b/EhPanda/Models/Persistent/DownloadBadge.swift @@ -3,76 +3,15 @@ // EhPanda // -struct DownloadProgress: Equatable, Sendable { - let completedPageCount: Int - let pageCount: Int - - var displayPageCount: Int { - max(pageCount, 1) - } - - var fraction: Double { - Double(completedPageCount) / Double(displayPageCount) - } -} - -// Presentation model for download indicators: the semantic state and the -// page progress stay separate so views can combine them per status and per -// layout (compact or full) without destructuring payloads. "Not downloaded" -// is `nil` at the use site, not a status. struct DownloadBadge: Equatable, Sendable { - enum Failure: Equatable, Sendable { - case general - case partial - case missingFiles - } - let status: DownloadDisplayStatus - let failure: Failure? - let progress: DownloadProgress? + let progress: DownloadProgress init( status: DownloadDisplayStatus, - failure: Failure? = nil, - progress: DownloadProgress? = nil + progress: DownloadProgress ) { self.status = status - self.failure = failure self.progress = progress } - - var resolvedProgress: DownloadProgress { - progress ?? DownloadProgress(completedPageCount: 0, pageCount: 1) - } -} - -// MARK: Presets -extension DownloadBadge { - static let queued = DownloadBadge(status: .queued) - static let downloaded = DownloadBadge(status: .completed) - static let updateAvailable = DownloadBadge(status: .updateAvailable) - static let failed = DownloadBadge(status: .error, failure: .general) - static let missingFiles = DownloadBadge(status: .error, failure: .missingFiles) - - static func downloading(_ completedPageCount: Int, _ pageCount: Int) -> DownloadBadge { - .init( - status: .active, - progress: .init(completedPageCount: completedPageCount, pageCount: pageCount) - ) - } - - static func paused(_ completedPageCount: Int, _ pageCount: Int) -> DownloadBadge { - .init( - status: .inactive, - progress: .init(completedPageCount: completedPageCount, pageCount: pageCount) - ) - } - - static func partial(_ completedPageCount: Int, _ pageCount: Int) -> DownloadBadge { - .init( - status: .error, - failure: .partial, - progress: .init(completedPageCount: completedPageCount, pageCount: pageCount) - ) - } } diff --git a/EhPanda/Models/Persistent/DownloadProgress.swift b/EhPanda/Models/Persistent/DownloadProgress.swift new file mode 100644 index 000000000..ed6a3b7f9 --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadProgress.swift @@ -0,0 +1,20 @@ +// +// DownloadProgress.swift +// EhPanda +// + +struct DownloadProgress: Equatable, Sendable { + let completedPageCount: Int + let pageCount: Int + + var displayPageCount: Int { + max(pageCount, 1) + } + var displayCompletedPageCount: Int { + min(max(completedPageCount, 0), displayPageCount) + } + + var fraction: Double { + Double(displayCompletedPageCount) / Double(displayPageCount) + } +} diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift index 318bf99fb..ce75cb23d 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -4,67 +4,38 @@ // import SwiftUI +import SFSafeSymbols // MARK: - DownloadBadge extension DownloadBadge { - var statusText: String { + var symbol: SFSymbol { switch status { - case .queued: - return L10n.Localizable.Struct.DownloadBadge.Text.queued - case .active: - return L10n.Localizable.Struct.DownloadBadge.Text.downloading - case .inactive: - return L10n.Localizable.Struct.DownloadBadge.Text.paused - case .updateAvailable: - return L10n.Localizable.Struct.DownloadBadge.Text.updateAvailable - case .completed: - return L10n.Localizable.Struct.DownloadBadge.Text.downloaded - case .error: - return failure == .missingFiles - ? L10n.Localizable.Struct.DownloadBadge.Text.needsRepair - : L10n.Localizable.Struct.DownloadBadge.Text.needsAttention + case .active: .playFill + case .queued: .listDash + case .inactive: .pauseFill + case .completed: .checkmarkCircleFill + case .updateAvailable: .arrowUpCircleFill + case .error: .exclamationmarkTriangleFill } } - var progressText: String? { - guard showsProgressText, let progress else { return nil } - return L10n.Localizable.Struct.DownloadBadge.progress( - progress.completedPageCount, - progress.displayPageCount - ) - } - - var text: String { - [statusText, progressText] - .compactMap { $0 } - .joined(separator: " ") - } - - private var showsProgressText: Bool { + var ringSymbol: SFSymbol { switch status { - case .active, .inactive: - return true - case .error: - return failure == .partial - case .queued, .updateAvailable, .completed: - return false + case .active: .playFill + case .queued: .listDash + case .inactive: .pauseFill + case .completed: .checkmark + case .updateAvailable: .arrowUp + case .error: .exclamationmark } } var color: Color { switch status { - case .queued: - return .orange - case .active: - return .blue - case .inactive: - return .indigo - case .completed: - return .green - case .updateAvailable: - return .yellow - case .error: - return failure == .missingFiles ? .pink : .orange + case .active, .queued: .green + case .inactive, .completed: .gray + case .updateAvailable: .blue + case .error: .yellow } } } diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift index e850cdcd1..2f66de693 100644 --- a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -35,7 +35,6 @@ extension DownloadedGallery { var badge: DownloadBadge { DownloadBadge( status: displayStatus, - failure: badgeFailure, progress: DownloadProgress( completedPageCount: completedPageCount, pageCount: pageCount @@ -43,18 +42,6 @@ extension DownloadedGallery { ) } - private var badgeFailure: DownloadBadge.Failure? { - guard displayStatus == .error else { return nil } - if completedPageCount > 0, completedPageCount < pageCount { - return .partial - } - if lastError?.code == .fileOperationFailed, - completedPageCount == 0 { - return .missingFiles - } - return .general - } - var gallery: Gallery { Gallery( gid: gid, diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index 0fe062e39..3b0dac72b 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -13,12 +13,12 @@ extension DetailReducer { switch action { case .fetchDownloadBadge: return handleFetchDownloadBadge(state: &state) - case .fetchDownloadBadgeDone(let badge): - return handleFetchDownloadBadgeDone(badge: badge, state: &state) + case .fetchDownloadBadgeDone(let download): + return handleFetchDownloadBadgeDone(download: download, state: &state) case .observeDownload: return handleObserveDownload(state: &state) - case .observeDownloadDone(let badge): - return handleObserveDownloadDone(badge: badge, state: &state) + case .observeDownloadDone(let download): + return handleObserveDownloadDone(download: download, state: &state) case .loadLocalPreviewURLs: return handleLoadLocalPreviewURLs(state: &state) case .loadLocalPreviewURLsDone(let requestID, let urls): @@ -54,14 +54,17 @@ extension DetailReducer { private func handleFetchDownloadBadge(state: inout State) -> Effect { guard state.gid.isValidGID else { return .none } return .run { [galleryID = state.gid] send in - let badge = await downloadClient.badges([galleryID])[galleryID] - await send(.fetchDownloadBadgeDone(badge)) + let download = await downloadClient.fetchDownload(galleryID) + await send(.fetchDownloadBadgeDone(download)) } .cancellable(id: CancelID.fetchDownloadBadge, cancelInFlight: true) } - private func handleFetchDownloadBadgeDone(badge: DownloadBadge?, state: inout State) -> Effect { - _ = applyDownloadBadge(badge, state: &state) + private func handleFetchDownloadBadgeDone( + download: DownloadedGallery?, + state: inout State + ) -> Effect { + _ = applyDownload(download, state: &state) var effects: [Effect] = [.send(.loadLocalPreviewURLs)] if shouldRequestVersionMetadata(state: state) { effects.append(.send(.fetchVersionMetadataIfNeeded)) @@ -73,15 +76,18 @@ extension DetailReducer { guard state.gid.isValidGID else { return .none } return .run { [galleryID = state.gid] send in for await downloads in downloadClient.observeDownloads() { - let badge = downloads.first(where: { $0.gid == galleryID })?.badge - await send(.observeDownloadDone(badge)) + let download = downloads.first(where: { $0.gid == galleryID }) + await send(.observeDownloadDone(download)) } } .cancellable(id: CancelID.observeDownload, cancelInFlight: true) } - private func handleObserveDownloadDone(badge: DownloadBadge?, state: inout State) -> Effect { - let didChangeBadge = applyDownloadBadge(badge, state: &state) + private func handleObserveDownloadDone( + download: DownloadedGallery?, + state: inout State + ) -> Effect { + let didChangeBadge = applyDownload(download, state: &state) guard didChangeBadge else { return .none } var effects: [Effect] = [.send(.loadLocalPreviewURLs)] if shouldRequestVersionMetadata(state: state) { @@ -190,7 +196,14 @@ extension DetailReducer { ) -> Effect { state.isPreparingDownload = false if case .success = result { - state.downloadBadge = .queued + state.downloadBadge = DownloadBadge( + status: .queued, + progress: DownloadProgress( + completedPageCount: 0, + pageCount: state.galleryDetail?.pageCount ?? 0 + ) + ) + state.downloadFailureCode = nil state.hasLoadedDownloadBadge = true return .merge( .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), @@ -216,13 +229,13 @@ extension DetailReducer { if case .success = result { switch state.downloadBadge?.status { case .active: - state.downloadBadge = DownloadBadge( - status: .inactive, - failure: state.downloadBadge?.failure, - progress: state.downloadBadge?.progress - ) + if let badge = state.downloadBadge { + state.downloadBadge = DownloadBadge(status: .inactive, progress: badge.progress) + } case .inactive: - state.downloadBadge = .queued + if let badge = state.downloadBadge { + state.downloadBadge = DownloadBadge(status: .queued, progress: badge.progress) + } default: break } @@ -252,7 +265,14 @@ extension DetailReducer { ) -> Effect { state.isPreparingDownload = false if case .success = result { - state.downloadBadge = .queued + state.downloadBadge = DownloadBadge( + status: .queued, + progress: state.downloadBadge?.progress ?? DownloadProgress( + completedPageCount: 0, + pageCount: state.galleryDetail?.pageCount ?? 0 + ) + ) + state.downloadFailureCode = nil state.hasLoadedDownloadBadge = true return .merge( .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/EhPanda/View/Detail/DetailReducer+Fetch.swift index c306e1575..c08dcfb04 100644 --- a/EhPanda/View/Detail/DetailReducer+Fetch.swift +++ b/EhPanda/View/Detail/DetailReducer+Fetch.swift @@ -152,11 +152,11 @@ extension DetailReducer { } await send(.fetchVersionMetadataDone(.success(metadata))) guard let metadata else { return } - let badge = await downloadClient.updateRemoteVersion( + let download = await downloadClient.updateRemoteVersion( gallery.gid, metadata ) - await send(.fetchDownloadBadgeDone(badge)) + await send(.fetchDownloadBadgeDone(download)) } .cancellable(id: CancelID.fetchVersionMetadata, cancelInFlight: true) } diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 41ead26fa..dca995d01 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -59,8 +59,15 @@ struct DetailReducer { var galleryComments = [GalleryComment]() var previewConfig: PreviewConfig = .normal(rows: 4) var downloadBadge: DownloadBadge? + var downloadFailureCode: DownloadFailureCode? var isPreparingDownload = false var hasLoadedDownloadBadge = false + + var downloadNeedsRepair: Bool { + guard let badge = downloadBadge, badge.status == .error else { return false } + return badge.progress.completedPageCount == 0 + && downloadFailureCode == .fileOperationFailed + } var didRunLaunchAutomation = false var shouldCheckForRemoteUpdates = false var didRequestVersionMetadata = false @@ -106,9 +113,9 @@ struct DetailReducer { case saveGalleryHistory case updateReadingProgress(Int) case fetchDownloadBadge - case fetchDownloadBadgeDone(DownloadBadge?) + case fetchDownloadBadgeDone(DownloadedGallery?) case observeDownload - case observeDownloadDone(DownloadBadge?) + case observeDownloadDone(DownloadedGallery?) case loadLocalPreviewURLs case loadLocalPreviewURLsDone(UUID, [Int: URL]) case openReading @@ -202,9 +209,11 @@ extension DetailReducer { // MARK: - Helpers extension DetailReducer { - func applyDownloadBadge(_ badge: DownloadBadge?, state: inout State) -> Bool { + func applyDownload(_ download: DownloadedGallery?, state: inout State) -> Bool { + let badge = download?.badge let didChangeBadge = badge != state.downloadBadge || !state.hasLoadedDownloadBadge state.downloadBadge = badge + state.downloadFailureCode = download?.lastError?.code if badge != nil { state.isPreparingDownload = false } state.hasLoadedDownloadBadge = true state.shouldCheckForRemoteUpdates = badge != nil diff --git a/EhPanda/View/Detail/DetailView+HeaderSection.swift b/EhPanda/View/Detail/DetailView+HeaderSection.swift index 9ff5cccd6..c0ca341dd 100644 --- a/EhPanda/View/Detail/DetailView+HeaderSection.swift +++ b/EhPanda/View/Detail/DetailView+HeaderSection.swift @@ -12,6 +12,7 @@ struct HeaderSection: View { let galleryDetail: GalleryDetail let user: User let downloadBadge: DownloadBadge? + let downloadNeedsRepair: Bool let isPreparingDownload: Bool let canDownload: Bool let displaysJapaneseTitle: Bool @@ -39,10 +40,15 @@ struct HeaderSection: View { switch downloadBadge?.status { case .updateAvailable: return .orange case .completed: return .red - case .error: return downloadBadge?.failure == .partial ? .orange : .red + case .error: return isPartialDownloadError ? .orange : .red default: return .accentColor } } + private var isPartialDownloadError: Bool { + guard let badge = downloadBadge, badge.status == .error else { return false } + return badge.progress.completedPageCount > 0 + && badge.progress.completedPageCount < badge.progress.pageCount + } private var categoryLabel: some View { CategoryLabel( text: gallery.category.value, color: gallery.color, font: .headline, @@ -188,7 +194,7 @@ struct HeaderSection: View { guard let badge = downloadBadge, [.active, .inactive].contains(badge.status) else { return nil } - return badge.resolvedProgress.fraction + return badge.progress.fraction } private var activeDownloadIconSystemName: String { switch downloadBadge?.status { @@ -202,7 +208,7 @@ struct HeaderSection: View { case .completed: return "trash" case .updateAvailable: return "arrow.triangle.2.circlepath" case .error: - return downloadBadge?.failure == .missingFiles + return downloadNeedsRepair ? "wrench.and.screwdriver" : "exclamationmark.circle" case .inactive: return "play.fill" @@ -251,7 +257,7 @@ extension HeaderSection { guard let badge = downloadBadge else { return L10n.Localizable.DetailView.Accessibility.DownloadButton.download } - let progress = badge.resolvedProgress + let progress = badge.progress switch badge.status { case .queued: return L10n.Localizable.DetailView.Accessibility.DownloadButton.queued @@ -270,16 +276,14 @@ extension HeaderSection { case .updateAvailable: return L10n.Localizable.DetailView.Accessibility.DownloadButton.update case .error: - switch badge.failure { - case .partial: + if isPartialDownloadError { return L10n.Localizable.DetailView.Accessibility.DownloadButton.partial( progress.completedPageCount, progress.displayPageCount ) - case .missingFiles: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.repair - case .general, nil: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.retry } + return downloadNeedsRepair + ? L10n.Localizable.DetailView.Accessibility.DownloadButton.repair + : L10n.Localizable.DetailView.Accessibility.DownloadButton.retry } } } diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index b3d6390c7..f28d3657d 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -155,6 +155,7 @@ private extension DetailView { galleryDetail: store.galleryDetail ?? .empty, user: user, downloadBadge: store.downloadBadge, + downloadNeedsRepair: store.downloadNeedsRepair, isPreparingDownload: store.isPreparingDownload, canDownload: !store.gallery.id.isEmpty && (AppUtil.galleryHost == .ehentai || CookieUtil.didLogin), @@ -340,7 +341,7 @@ private extension DetailView { case .completed: downloadDialog = .delete(isActiveDownload: false) case .error: - downloadDialog = store.downloadBadge?.failure == .missingFiles + downloadDialog = store.downloadNeedsRepair ? .retry(.repair) : .retry(.redownload) case .updateAvailable: diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index 24398370b..99b60824e 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -96,7 +96,7 @@ struct DownloadsReducer { var detailState = DetailReducer.State() detailState.gid = download.gid detailState.gallery = download.gallery - _ = DetailReducer().applyDownloadBadge(download.badge, state: &detailState) + _ = DetailReducer().applyDownload(download, state: &detailState) state.detailState.wrappedValue = detailState } else if case .inspector(let gid) = route { state.inspectorState = .init(gid: gid) diff --git a/EhPanda/View/Home/HomeView+Sections.swift b/EhPanda/View/Home/HomeView+Sections.swift index b76d00bb2..c6bf29922 100644 --- a/EhPanda/View/Home/HomeView+Sections.swift +++ b/EhPanda/View/Home/HomeView+Sections.swift @@ -154,11 +154,10 @@ struct VerticalCoverStack: View { .scaledToFill() .frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH).cornerRadius(2) .overlay(alignment: .topTrailing) { - DownloadBadgeLabel( - badge: downloadBadges[gallery.gid], - compact: true - ) - .padding(6) + if let downloadBadge = downloadBadges[gallery.gid] { + DownloadBadgeLabel(badge: downloadBadge, isCompactStyle: true) + .padding(6) + } } } } diff --git a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift index 3872d46a5..d89b119ce 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift @@ -64,7 +64,9 @@ struct GalleryCardCell: View { Text(title) .font(.title3.bold()) .lineLimit(downloadBadge == nil ? 4 : 2) - DownloadBadgeLabel(badge: downloadBadge, compact: true) + if let downloadBadge { + DownloadBadgeLabel(badge: downloadBadge, isCompactStyle: true) + } Spacer() RatingView(rating: gallery.rating).foregroundColor(.yellow) } diff --git a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift index b0f2f480e..68a9d1613 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift @@ -102,7 +102,9 @@ private struct GalleryDetailCellContent: View { .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) - DownloadBadgeLabel(badge: downloadBadge) + if let downloadBadge { + DownloadBadgeLabel(badge: downloadBadge) + } } let tagContents = gallery.tagContents(maximum: setting.listTagsNumberMaximum) diff --git a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift index 1a3774f40..9e994af89 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift @@ -30,7 +30,9 @@ struct GalleryRankingCell: View { Text(String(ranking)).fontWeight(.medium).font(.title2).padding(.horizontal) VStack(alignment: .leading) { Text(gallery.trimmedTitle).bold().lineLimit(2).fixedSize(horizontal: false, vertical: true) - DownloadBadgeLabel(badge: downloadBadge, compact: true) + if let downloadBadge { + DownloadBadgeLabel(badge: downloadBadge, isCompactStyle: true) + } if let uploader = gallery.uploader { Text(uploader).foregroundColor(.secondary).lineLimit(1) } diff --git a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift index 6bf47fb9c..6367fea47 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -48,7 +48,9 @@ struct GalleryThumbnailCell: View { .fade(duration: 0.25).resizable().scaledToFit().overlay { VStack { HStack { - DownloadBadgeLabel(badge: downloadBadge, compact: true) + if let downloadBadge { + DownloadBadgeLabel(badge: downloadBadge, isCompactStyle: true) + } Spacer() CategoryLabel( text: gallery.category.value, color: gallery.color, diff --git a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift index fe845dc5b..43668ab0b 100644 --- a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift +++ b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift @@ -6,41 +6,86 @@ import SwiftUI struct DownloadBadgeLabel: View { + private static let ringDiameter: CGFloat = 26 + private static let ringLineWidth: CGFloat = 2.5 + private let badge: DownloadBadge private let isCompactStyle: Bool - init?(badge: DownloadBadge?, compact: Bool = false) { - guard let badge else { return nil } - + init(badge: DownloadBadge, isCompactStyle: Bool = false) { self.badge = badge - self.isCompactStyle = compact + self.isCompactStyle = isCompactStyle } var body: some View { - labelText - .lineLimit(1) - .foregroundStyle(foregroundColor) - .padding(.horizontal, isCompactStyle ? 6 : 8) - .padding(.vertical, isCompactStyle ? 3 : 4) - .background(backgroundColor) - .clipShape(.capsule) + Group { + if isCompactStyle { + ringSymbol + } else { + textLabel + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityText) } - private var labelText: Text { - if isCompactStyle { - Text(badge.statusText) - .font(.caption2.bold()) - } else { - Text(badge.text) + private var ringSymbol: some View { + ZStack { + Circle() + .stroke(badge.color.opacity(0.18), lineWidth: Self.ringLineWidth) + Circle() + .trim(from: 0, to: badge.progress.fraction) + .stroke(badge.color, style: .init(lineWidth: Self.ringLineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Image(systemSymbol: badge.ringSymbol) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(badge.color) + } + .padding(Self.ringLineWidth / 2) + .frame(width: Self.ringDiameter, height: Self.ringDiameter) + } + + private var textLabel: some View { + HStack(spacing: 4) { + Image(systemSymbol: badge.symbol) + .font(.caption.bold()) + Text(progressText) .font(.caption.bold().monospacedDigit()) + .lineLimit(1) } + .foregroundStyle(badge.color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(badge.color.opacity(0.15)) + .clipShape(.capsule) + } + + private var progressText: String { + L10n.Localizable.Struct.DownloadBadge.progress( + badge.progress.displayCompletedPageCount, + badge.progress.displayPageCount + ) } - private var backgroundColor: Color { - badge.color.opacity(0.15) + private var statusText: String { + typealias BadgeText = L10n.Localizable.Struct.DownloadBadge.Text + switch badge.status { + case .queued: + return BadgeText.queued + case .active: + return BadgeText.downloading + case .inactive: + return BadgeText.paused + case .completed: + return BadgeText.downloaded + case .updateAvailable: + return BadgeText.updateAvailable + case .error: + return BadgeText.needsAttention + } } - private var foregroundColor: Color { - badge.status == .updateAvailable ? .orange : badge.color + private var accessibilityText: String { + [statusText, progressText].joined(separator: " ") } } diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index b9e57aee1..68bd1316e 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -17,6 +17,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { let capturedPayload = UncheckedBox(nil) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let queuedDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .queued) let options = DownloadRequestOptions( threadLimit: 4, allowCellular: false, @@ -25,7 +26,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { let previewURL = try #require(URL(string: "https://example.com/1.jpg")) let store = makeDownloadTestStore( gallery: gallery, detail: detail, - badgeValue: .queued, + downloadValue: queuedDownload, configure: { state in state.galleryPreviewURLs = [1: previewURL] state.previewConfig = .large(rows: 2) @@ -45,7 +46,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { #expect(capturedPayload.value?.previewConfig == .large(rows: 2)) #expect(capturedPayload.value?.options == options) #expect(capturedPayload.value?.mode == .initial) - #expect(store.state.downloadBadge == .queued) + #expect(store.state.downloadBadge == queuedDownload.badge) } @MainActor @@ -53,11 +54,12 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { func testDetailReducerStartDownloadUnlocksActionsAfterQueueing() async throws { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let queuedDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .queued) let options = DownloadRequestOptions() let previewURL = try #require(URL(string: "https://example.com/1.jpg")) let store = makeDownloadTestStore( gallery: gallery, detail: detail, - badgeValue: .queued, + downloadValue: queuedDownload, configure: { state in state.galleryPreviewURLs = [1: previewURL] }, enqueue: { _ in .success(()) } ) @@ -69,12 +71,15 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { } await store.receive(\.startDownloadDone) { $0.isPreparingDownload = false - $0.downloadBadge = .queued + $0.downloadBadge = DownloadBadge( + status: .queued, + progress: .init(completedPageCount: 0, pageCount: detail.pageCount) + ) $0.hasLoadedDownloadBadge = true } await store.receive(\.fetchDownloadBadge) - await store.receive(\.fetchDownloadBadgeDone, .queued) { - $0.downloadBadge = .queued + await store.receive(\.fetchDownloadBadgeDone, queuedDownload) { + $0.downloadBadge = queuedDownload.badge $0.hasLoadedDownloadBadge = true } } @@ -90,7 +95,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { let store = makeDownloadTestStore( gallery: gallery, detail: detail, - badgeValue: .queued, + downloadValue: nil, automationGID: gallery.gid, configure: { state in state.gid = "" @@ -125,7 +130,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { private extension DetailReducerDownloadTests { func makeDownloadTestStore( gallery: Gallery, detail: GalleryDetail, - badgeValue: DownloadBadge?, + downloadValue: DownloadedGallery?, automationGID: String? = nil, configure: (inout DetailReducer.State) -> Void = { _ in }, enqueue: @escaping @Sendable (DownloadRequestPayload) async -> Result @@ -143,14 +148,10 @@ private extension DetailReducerDownloadTests { AsyncStream { continuation in continuation.finish() } }, fetchDownloads: { [] }, - fetchDownload: { _ in nil }, + fetchDownload: { _ in downloadValue }, refreshDownloads: {}, resumeQueue: {}, - badges: { gids in - badgeValue.map { value in - Dictionary(uniqueKeysWithValues: gids.map { ($0, value) }) - } ?? [:] - }, + badges: { _ in [:] }, enqueue: enqueue, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift index 0e16d4d35..f87813a58 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -21,7 +21,7 @@ struct DetailReducerMetadataTests: DownloadFeatureTestCase { let store = makeMetadataTestStore( gid: gallery.gid, gallery: gallery, - badgeValue: .none, updateCheckCount: updateCheckCount + downloadValue: nil, updateCheckCount: updateCheckCount ) store.exhaustivity = .off @@ -48,9 +48,12 @@ struct DetailReducerMetadataTests: DownloadFeatureTestCase { let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let galleryState = try sampleGalleryState(gid: gallery.gid) + let completedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed + ) let store = makeDownloadedMetadataTestStore( gid: gallery.gid, gallery: gallery, - badgeValue: .none, updateCheckCount: updateCheckCount + downloadValue: nil, updateCheckCount: updateCheckCount ) store.exhaustivity = .off @@ -60,7 +63,7 @@ struct DetailReducerMetadataTests: DownloadFeatureTestCase { await store.skipReceivedActions(strict: false) #expect(updateCheckCount.value == 0) - await store.send(.fetchDownloadBadgeDone(.downloaded)) + await store.send(.fetchDownloadBadgeDone(completedDownload)) await drainDetailMetadataEffects( store, condition: { @@ -82,13 +85,16 @@ struct DetailReducerMetadataTests: DownloadFeatureTestCase { let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let galleryState = try sampleGalleryState(gid: gallery.gid) + let completedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed + ) let store = makeDownloadedMetadataTestStore( gid: gallery.gid, gallery: gallery, - badgeValue: .downloaded, updateCheckCount: updateCheckCount + downloadValue: completedDownload, updateCheckCount: updateCheckCount ) store.exhaustivity = .off - await store.send(.fetchDownloadBadgeDone(.downloaded)) + await store.send(.fetchDownloadBadgeDone(completedDownload)) await store.skipReceivedActions(strict: false) #expect(updateCheckCount.value == 0) @@ -114,7 +120,7 @@ struct DetailReducerMetadataTests: DownloadFeatureTestCase { private extension DetailReducerMetadataTests { func makeMetadataTestStore( gid: String, gallery: Gallery, - badgeValue: DownloadBadge?, updateCheckCount: UncheckedBox + downloadValue: DownloadedGallery?, updateCheckCount: UncheckedBox ) -> TestStoreOf { var initialState = DetailReducer.State() initialState.gid = gid @@ -127,14 +133,10 @@ private extension DetailReducerMetadataTests { AsyncStream { continuation in continuation.finish() } }, fetchDownloads: { [] }, - fetchDownload: { _ in nil }, + fetchDownload: { _ in downloadValue }, refreshDownloads: {}, resumeQueue: {}, - badges: { gids in - badgeValue.map { value in - Dictionary(uniqueKeysWithValues: gids.map { ($0, value) }) - } ?? [:] - }, + badges: { _ in [:] }, fetchVersionMetadata: { _, _ in .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) }, @@ -156,8 +158,11 @@ private extension DetailReducerMetadataTests { func makeDownloadedMetadataTestStore( gid: String, gallery: Gallery, - badgeValue: DownloadBadge?, updateCheckCount: UncheckedBox + downloadValue: DownloadedGallery?, updateCheckCount: UncheckedBox ) -> TestStoreOf { + let updatedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed + ) var initialState = DetailReducer.State() initialState.gid = gid initialState.gallery = gallery @@ -169,20 +174,16 @@ private extension DetailReducerMetadataTests { AsyncStream { continuation in continuation.finish() } }, fetchDownloads: { [] }, - fetchDownload: { _ in nil }, + fetchDownload: { _ in downloadValue }, refreshDownloads: {}, resumeQueue: {}, - badges: { gids in - badgeValue.map { value in - Dictionary(uniqueKeysWithValues: gids.map { ($0, value) }) - } ?? [:] - }, + badges: { _ in [:] }, fetchVersionMetadata: { _, _ in .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) }, updateRemoteVersion: { _, _ in updateCheckCount.value += 1 - return .downloaded + return updatedDownload }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index 966435f29..b5e4e539b 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -17,18 +17,22 @@ struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let completedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed + ) let store = makeUpdateTestStore( gid: gallery.gid, gallery: gallery, detail: detail, + updatedDownload: completedDownload, updateCheckCount: updateCheckCount ) store.exhaustivity = .off - await store.send(.observeDownloadDone(.downloaded)) + await store.send(.observeDownloadDone(completedDownload)) await drainDetailMetadataEffects(store, condition: { updateCheckCount.value == 1 }) #expect(updateCheckCount.value == 1) - await store.send(.observeDownloadDone(.downloaded)) + await store.send(.observeDownloadDone(completedDownload)) await store.skipReceivedActions(strict: false) #expect(updateCheckCount.value == 1) } @@ -39,14 +43,18 @@ struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { let updateCheckCount = UncheckedBox(0) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let completedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed + ) let store = makeUpdateTestStore( gid: gallery.gid, gallery: gallery, detail: detail, + updatedDownload: completedDownload, updateCheckCount: updateCheckCount ) store.exhaustivity = .off - await store.send(.fetchDownloadBadgeDone(.downloaded)) + await store.send(.fetchDownloadBadgeDone(completedDownload)) await drainDetailMetadataEffects( store, condition: { @@ -112,6 +120,7 @@ struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { private extension DetailReducerMetadataUpdateTests { func makeUpdateTestStore( gid: String, gallery: Gallery, detail: GalleryDetail, + updatedDownload: DownloadedGallery, updateCheckCount: UncheckedBox ) -> TestStoreOf { var initialState = DetailReducer.State() @@ -126,7 +135,7 @@ private extension DetailReducerMetadataUpdateTests { AsyncStream { continuation in continuation.finish() } }, fetchDownloads: { [] }, - fetchDownload: { _ in nil }, + fetchDownload: { _ in updatedDownload }, refreshDownloads: {}, resumeQueue: {}, badges: { _ in [:] }, @@ -135,7 +144,7 @@ private extension DetailReducerMetadataUpdateTests { }, updateRemoteVersion: { _, _ in updateCheckCount.value += 1 - return .downloaded + return updatedDownload }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift index 393876538..f0d9be060 100644 --- a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift @@ -49,7 +49,10 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { ) ]) await store.receive(\.observeDownloadDone) { - $0.downloadBadge = .downloading(7, 26) + $0.downloadBadge = DownloadBadge( + status: .active, + progress: .init(completedPageCount: 7, pageCount: 26) + ) $0.hasLoadedDownloadBadge = true } @@ -174,7 +177,9 @@ private extension DetailReducerObserveTests { fetchDownload: { gid in gid == download.gid ? download : nil }, refreshDownloads: {}, resumeQueue: {}, - badges: { gids in Dictionary(uniqueKeysWithValues: gids.map { ($0, .downloaded) }) }, + badges: { gids in + Dictionary(uniqueKeysWithValues: gids.map { ($0, download.badge) }) + }, enqueue: { _ in .success(()) }, togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index 4cae2eadc..60a11e78d 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -16,6 +16,9 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { func testDetailReducerLaunchAutomationDoesNotRedownloadWhenBadgeIsResolved() async { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let completedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed + ) let options = DownloadRequestOptions() var initialState = DetailReducer.State() initialState.gallery = gallery @@ -34,8 +37,8 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { } store.exhaustivity = .off - await store.send(.fetchDownloadBadgeDone(.downloaded)) { - $0.downloadBadge = .downloaded + await store.send(.fetchDownloadBadgeDone(completedDownload)) { + $0.downloadBadge = completedDownload.badge $0.hasLoadedDownloadBadge = true } await store.send(.runLaunchAutomationIfNeeded(options)) { @@ -97,30 +100,44 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { func testDetailReducerTogglesPauseForActiveDownload() async { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let pausedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .paused, + pageCount: 26, completedPageCount: 7 + ) let togglePauseCount = UncheckedBox(0) var initialState = DetailReducer.State() initialState.gid = gallery.gid initialState.gallery = gallery initialState.galleryDetail = detail - initialState.downloadBadge = .downloading(7, 26) + initialState.downloadBadge = DownloadBadge( + status: .active, + progress: .init(completedPageCount: 7, pageCount: 26) + ) - let store = makeTogglePauseStore(initialState: initialState, togglePauseCount: togglePauseCount) + let store = makeTogglePauseStore( + initialState: initialState, + pausedDownload: pausedDownload, + togglePauseCount: togglePauseCount + ) await store.send(.toggleDownloadPause) { $0.isPreparingDownload = true } await store.receive(\.toggleDownloadPauseDone) { $0.isPreparingDownload = false - $0.downloadBadge = .paused(7, 26) + $0.downloadBadge = DownloadBadge( + status: .inactive, + progress: .init(completedPageCount: 7, pageCount: 26) + ) $0.hasLoadedDownloadBadge = true } await store.receive(\.fetchDownloadBadge) - await store.receive(\.fetchDownloadBadgeDone, .paused(7, 26)) { - $0.downloadBadge = .paused(7, 26) + await store.receive(\.fetchDownloadBadgeDone, pausedDownload) { + $0.downloadBadge = pausedDownload.badge $0.hasLoadedDownloadBadge = true } #expect(togglePauseCount.value == 1) - #expect(store.state.downloadBadge == .paused(7, 26)) + #expect(store.state.downloadBadge == pausedDownload.badge) #expect(store.state.isPreparingDownload == false) } @@ -131,6 +148,7 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { private extension DetailReducerPauseAndGuardTests { func makeTogglePauseStore( initialState: DetailReducer.State, + pausedDownload: DownloadedGallery, togglePauseCount: UncheckedBox ) -> TestStoreOf { let store = TestStore(initialState: initialState) { @@ -139,12 +157,10 @@ private extension DetailReducerPauseAndGuardTests { $0.downloadClient = .init( observeDownloads: { AsyncStream { continuation in continuation.finish() } }, fetchDownloads: { [] }, - fetchDownload: { _ in nil }, + fetchDownload: { _ in pausedDownload }, refreshDownloads: {}, resumeQueue: {}, - badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, .paused(7, 26)) }) - }, + badges: { _ in [:] }, enqueue: { _ in .success(()) }, togglePause: { _ in togglePauseCount.value += 1 diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index e3ba3b3de..56a08dd66 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -3,7 +3,9 @@ // EhPandaTests // +import SwiftUI import Foundation +import SFSafeSymbols import ComposableArchitecture import Testing @testable import EhPanda @@ -22,10 +24,9 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { #expect(badge.status == activeDownload.displayStatus) #expect(badge.progress == DownloadProgress(completedPageCount: 7, pageCount: 26)) - #expect(badge.failure == nil) - #expect(badge.statusText == "Downloading") - #expect(badge.progressText == "7/26") - #expect(badge.text == "Downloading 7/26") + #expect(badge.symbol == .playFill) + #expect(badge.ringSymbol == .playFill) + #expect(badge.color == .green) let completedBadge = sampleDownload( gid: "481", @@ -34,8 +35,10 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { pageCount: 26 ).badge - #expect(completedBadge.progressText == nil) - #expect(completedBadge.text == completedBadge.statusText) + #expect(completedBadge.symbol == .checkmarkCircleFill) + #expect(completedBadge.ringSymbol == .checkmark) + #expect(completedBadge.color == .gray) + #expect(completedBadge.progress.fraction == 1) } @Test @@ -47,7 +50,13 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { pageCount: 12, completedPageCount: 5 ) - #expect(partialDownload.badge.text == "Needs Attention 5/12") + #expect(partialDownload.badge.symbol == .exclamationmarkTriangleFill) + #expect(partialDownload.badge.ringSymbol == .exclamationmark) + #expect(partialDownload.badge.color == .yellow) + #expect( + partialDownload.badge.progress + == DownloadProgress(completedPageCount: 5, pageCount: 12) + ) #expect(DownloadListFilter.failed.title == "Needs Attention") } @@ -82,7 +91,7 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { #expect(queuedRepair.matches(filter: .failed) == false) #expect(queuedRepair.matches(filter: .update) == false) - #expect(missingFilesWithoutQueuedWork.badge.failure == .missingFiles) + #expect(missingFilesWithoutQueuedWork.lastError?.code == .fileOperationFailed) #expect(missingFilesWithoutQueuedWork.matches(filter: .failed)) } diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index fe336b348..8acc4b38f 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -3,7 +3,9 @@ // EhPandaTests // +import SwiftUI import Foundation +import SFSafeSymbols import ComposableArchitecture import Testing @testable import EhPanda @@ -123,7 +125,12 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { completedPageCount: 4 ) - #expect(pausedDownload.badge == .paused(4, 12)) + #expect( + pausedDownload.badge == DownloadBadge( + status: .inactive, + progress: .init(completedPageCount: 4, pageCount: 12) + ) + ) #expect(pausedDownload.matches(filter: .active)) } @@ -215,7 +222,13 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { completedPageCount: 5 ) - #expect(partialDownload.badge.text == "Needs Attention 5/12") + #expect(partialDownload.badge.symbol == .exclamationmarkTriangleFill) + #expect(partialDownload.badge.ringSymbol == .exclamationmark) + #expect(partialDownload.badge.color == .yellow) + #expect( + partialDownload.badge.progress + == DownloadProgress(completedPageCount: 5, pageCount: 12) + ) #expect(DownloadListFilter.failed.title == "Needs Attention") } diff --git a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift index 8f0bc9744..d8e46bb1d 100644 --- a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -117,7 +117,12 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { } let stored = await manager.testingFetchDownload(gid: gid) #expect(stored?.displayStatus == .inactive) - #expect(stored?.badge == .paused(0, 2)) + #expect( + stored?.badge == DownloadBadge( + status: .inactive, + progress: .init(completedPageCount: 0, pageCount: 2) + ) + ) #expect(FileManager.default.fileExists(atPath: folderURL.path)) } diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 86b78e125..874dd7aa3 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -383,7 +383,6 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(download.displayStatus == .error) #expect(download.displayStatus == .error) #expect(download.lastError?.code == .fileOperationFailed) - #expect(download.badge.failure == .general) } @Test @@ -528,7 +527,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(failedDownload.displayStatus == .error) #expect(failedDownload.displayStatus == .error) #expect(failedDownload.lastError?.code == .networkingFailed) - #expect(badges["800"]?.failure == .general) + #expect(badges["800"]?.status == .error) } @Test diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index b6e2143e4..7630a164f 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -63,7 +63,12 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) let activeGalleryID = await manager.testingActiveGalleryID() #expect(stored?.displayStatus == .inactive) - #expect(stored?.badge == .paused(7, 26)) + #expect( + stored?.badge == DownloadBadge( + status: .inactive, + progress: .init(completedPageCount: 7, pageCount: 26) + ) + ) #expect(activeGalleryID == nil) } @@ -122,7 +127,12 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) #expect(stored?.displayStatus == .inactive) #expect(stored?.completedPageCount == 1) - #expect(stored?.badge == .paused(1, 2)) + #expect( + stored?.badge == DownloadBadge( + status: .inactive, + progress: .init(completedPageCount: 1, pageCount: 2) + ) + ) #expect(FileManager.default.fileExists(atPath: folderURL.path)) } @@ -156,7 +166,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) #expect(stored?.displayStatus == .error) - #expect(stored?.badge.failure == .general) + #expect(stored?.lastError?.code == .networkingFailed) } @Test diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index cb993232e..a02b6aa85 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -82,7 +82,12 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { let stored = await manager.testingFetchDownload(gid: gid) #expect(stored?.displayStatus == .inactive) #expect(stored?.completedPageCount == 1) - #expect(stored?.badge == .paused(1, 2)) + #expect( + stored?.badge == DownloadBadge( + status: .inactive, + progress: .init(completedPageCount: 1, pageCount: 2) + ) + ) } } diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 572645720..1a250c5b2 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -88,7 +88,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { folderURL: folderURL ) - let updateBadge = await manager.updateRemoteVersion( + let updateResult = await manager.updateRemoteVersion( gid: gid, metadata: DownloadVersionMetadata( gid: gid, @@ -103,11 +103,11 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { ) let updatedDownload = await manager.testingFetchDownload(gid: gid) - #expect(updateBadge?.status == .updateAvailable) + #expect(updateResult?.displayStatus == .updateAvailable) #expect(updatedDownload?.displayStatus == .updateAvailable) #expect(updatedDownload?.displayStatus == .updateAvailable) - let currentBadge = await manager.updateRemoteVersion( + let currentResult = await manager.updateRemoteVersion( gid: gid, metadata: DownloadVersionMetadata( gid: gid, @@ -122,7 +122,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { ) let currentDownload = await manager.testingFetchDownload(gid: gid) - #expect(currentBadge?.status == .completed) + #expect(currentResult?.displayStatus == .completed) #expect(currentDownload?.displayStatus == .completed) #expect(currentDownload?.displayStatus == .completed) } From 5b7f561d0f843ca3fbfb8a333f4a3a22e6981eca Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 17:58:13 +0800 Subject: [PATCH 156/614] Move download models out of Persistent --- EhPanda/Models/{Persistent => Download}/DownloadBadge.swift | 0 .../Models/{Persistent => Download}/DownloadDisplayStatus.swift | 0 EhPanda/Models/{Persistent => Download}/DownloadFailure.swift | 0 EhPanda/Models/{Persistent => Download}/DownloadInspection.swift | 0 EhPanda/Models/{Persistent => Download}/DownloadProgress.swift | 0 .../Models/{Persistent => Download}/DownloadRequestOptions.swift | 0 EhPanda/Models/{Persistent => Download}/DownloadStartMode.swift | 0 .../{Persistent => Download}/DownloadedGallery+Extensions.swift | 0 .../{Persistent => Download}/DownloadedGallery+Manifest.swift | 0 .../{Persistent => Download}/DownloadedGallery+SupportTypes.swift | 0 EhPanda/Models/{Persistent => Download}/DownloadedGallery.swift | 0 11 files changed, 0 insertions(+), 0 deletions(-) rename EhPanda/Models/{Persistent => Download}/DownloadBadge.swift (100%) rename EhPanda/Models/{Persistent => Download}/DownloadDisplayStatus.swift (100%) rename EhPanda/Models/{Persistent => Download}/DownloadFailure.swift (100%) rename EhPanda/Models/{Persistent => Download}/DownloadInspection.swift (100%) rename EhPanda/Models/{Persistent => Download}/DownloadProgress.swift (100%) rename EhPanda/Models/{Persistent => Download}/DownloadRequestOptions.swift (100%) rename EhPanda/Models/{Persistent => Download}/DownloadStartMode.swift (100%) rename EhPanda/Models/{Persistent => Download}/DownloadedGallery+Extensions.swift (100%) rename EhPanda/Models/{Persistent => Download}/DownloadedGallery+Manifest.swift (100%) rename EhPanda/Models/{Persistent => Download}/DownloadedGallery+SupportTypes.swift (100%) rename EhPanda/Models/{Persistent => Download}/DownloadedGallery.swift (100%) diff --git a/EhPanda/Models/Persistent/DownloadBadge.swift b/EhPanda/Models/Download/DownloadBadge.swift similarity index 100% rename from EhPanda/Models/Persistent/DownloadBadge.swift rename to EhPanda/Models/Download/DownloadBadge.swift diff --git a/EhPanda/Models/Persistent/DownloadDisplayStatus.swift b/EhPanda/Models/Download/DownloadDisplayStatus.swift similarity index 100% rename from EhPanda/Models/Persistent/DownloadDisplayStatus.swift rename to EhPanda/Models/Download/DownloadDisplayStatus.swift diff --git a/EhPanda/Models/Persistent/DownloadFailure.swift b/EhPanda/Models/Download/DownloadFailure.swift similarity index 100% rename from EhPanda/Models/Persistent/DownloadFailure.swift rename to EhPanda/Models/Download/DownloadFailure.swift diff --git a/EhPanda/Models/Persistent/DownloadInspection.swift b/EhPanda/Models/Download/DownloadInspection.swift similarity index 100% rename from EhPanda/Models/Persistent/DownloadInspection.swift rename to EhPanda/Models/Download/DownloadInspection.swift diff --git a/EhPanda/Models/Persistent/DownloadProgress.swift b/EhPanda/Models/Download/DownloadProgress.swift similarity index 100% rename from EhPanda/Models/Persistent/DownloadProgress.swift rename to EhPanda/Models/Download/DownloadProgress.swift diff --git a/EhPanda/Models/Persistent/DownloadRequestOptions.swift b/EhPanda/Models/Download/DownloadRequestOptions.swift similarity index 100% rename from EhPanda/Models/Persistent/DownloadRequestOptions.swift rename to EhPanda/Models/Download/DownloadRequestOptions.swift diff --git a/EhPanda/Models/Persistent/DownloadStartMode.swift b/EhPanda/Models/Download/DownloadStartMode.swift similarity index 100% rename from EhPanda/Models/Persistent/DownloadStartMode.swift rename to EhPanda/Models/Download/DownloadStartMode.swift diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift similarity index 100% rename from EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift rename to EhPanda/Models/Download/DownloadedGallery+Extensions.swift diff --git a/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift b/EhPanda/Models/Download/DownloadedGallery+Manifest.swift similarity index 100% rename from EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift rename to EhPanda/Models/Download/DownloadedGallery+Manifest.swift diff --git a/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift similarity index 100% rename from EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift rename to EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift diff --git a/EhPanda/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Download/DownloadedGallery.swift similarity index 100% rename from EhPanda/Models/Persistent/DownloadedGallery.swift rename to EhPanda/Models/Download/DownloadedGallery.swift From 4b9459197bda87f503bd348fdf6a5a9d4ec3cf65 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 18:01:39 +0800 Subject: [PATCH 157/614] Remove download list filter --- EhPanda/App/Generated/Strings.swift | 14 ---------- EhPanda/App/de.lproj/Localizable.strings | 5 ---- EhPanda/App/en.lproj/Localizable.strings | 6 ----- EhPanda/App/ja.lproj/Localizable.strings | 5 ---- EhPanda/App/ko.lproj/Localizable.strings | 5 ---- EhPanda/App/zh-Hans.lproj/Localizable.strings | 6 ----- .../App/zh-Hant-HK.lproj/Localizable.strings | 5 ---- .../App/zh-Hant-TW.lproj/Localizable.strings | 5 ---- EhPanda/App/zh-Hant.lproj/Localizable.strings | 5 ---- .../DownloadedGallery+Extensions.swift | 26 ------------------- .../DownloadedGallery+SupportTypes.swift | 19 -------------- EhPanda/View/Downloads/DownloadsReducer.swift | 8 ++---- EhPanda/View/Downloads/DownloadsView.swift | 21 --------------- .../Download/DownloadBadgeSortTests.swift | 21 +++------------ .../DownloadFilterAndBadgeTests.swift | 8 ------ 15 files changed, 5 insertions(+), 154 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 876ae3da9..0fca7e112 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -1511,20 +1511,6 @@ internal enum L10n { internal static let western = L10n.tr("Localizable", "enum.category.value.western", fallback: "Western") } } - internal enum DownloadListFilter { - internal enum Title { - /// Active - internal static let active = L10n.tr("Localizable", "enum.download_list_filter.title.active", fallback: "Active") - /// All - internal static let all = L10n.tr("Localizable", "enum.download_list_filter.title.all", fallback: "All") - /// Downloaded - internal static let completed = L10n.tr("Localizable", "enum.download_list_filter.title.completed", fallback: "Downloaded") - /// Needs Attention - internal static let failed = L10n.tr("Localizable", "enum.download_list_filter.title.failed", fallback: "Needs Attention") - /// Update Available - internal static let update = L10n.tr("Localizable", "enum.download_list_filter.title.update", fallback: "Update Available") - } - } internal enum EhSetting { internal enum ArchiverBehavior { internal enum Value { diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index ece1c2188..abd1b31fb 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -996,11 +996,6 @@ "enum.download_thread_mode.value.triple" = "3 Bilder gleichzeitig"; "enum.download_thread_mode.value.quadruple" = "4 Bilder gleichzeitig"; "enum.download_thread_mode.value.quintuple" = "5 Bilder gleichzeitig"; -"enum.download_list_filter.title.all" = "Alle"; -"enum.download_list_filter.title.active" = "Aktiv"; -"enum.download_list_filter.title.completed" = "Heruntergeladen"; -"enum.download_list_filter.title.failed" = "Benötigt Aufmerksamkeit"; -"enum.download_list_filter.title.update" = "Update verfügbar"; "struct.download_badge.text.queued" = "In Warteschlange"; "struct.download_badge.text.downloading" = "Lädt herunter"; "struct.download_badge.text.paused" = "Pausiert"; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 4bafbd9f3..e2e39e8a8 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -450,12 +450,6 @@ // AutoPlayPolicy "enum.auto_play_policy.value.off" = "Off"; -// MARK: DownloadListFilter -"enum.download_list_filter.title.all" = "All"; -"enum.download_list_filter.title.active" = "Active"; -"enum.download_list_filter.title.completed" = "Downloaded"; -"enum.download_list_filter.title.failed" = "Needs Attention"; -"enum.download_list_filter.title.update" = "Update Available"; // MARK: DownloadBadge "struct.download_badge.text.queued" = "Queued"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 475fc5827..6f6bac33a 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -996,11 +996,6 @@ "enum.download_thread_mode.value.triple" = "3 枚ずつダウンロード"; "enum.download_thread_mode.value.quadruple" = "4 枚ずつダウンロード"; "enum.download_thread_mode.value.quintuple" = "5 枚ずつダウンロード"; -"enum.download_list_filter.title.all" = "すべて"; -"enum.download_list_filter.title.active" = "進行中"; -"enum.download_list_filter.title.completed" = "ダウンロード済み"; -"enum.download_list_filter.title.failed" = "要対応"; -"enum.download_list_filter.title.update" = "更新あり"; "struct.download_badge.text.queued" = "待機中"; "struct.download_badge.text.downloading" = "ダウンロード中"; "struct.download_badge.text.paused" = "一時停止"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index acadf81cc..36715875a 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -996,11 +996,6 @@ "enum.download_thread_mode.value.triple" = "한 번에 3장 다운로드"; "enum.download_thread_mode.value.quadruple" = "한 번에 4장 다운로드"; "enum.download_thread_mode.value.quintuple" = "한 번에 5장 다운로드"; -"enum.download_list_filter.title.all" = "전체"; -"enum.download_list_filter.title.active" = "진행 중"; -"enum.download_list_filter.title.completed" = "다운로드됨"; -"enum.download_list_filter.title.failed" = "조치 필요"; -"enum.download_list_filter.title.update" = "업데이트 가능"; "struct.download_badge.text.queued" = "대기 중"; "struct.download_badge.text.downloading" = "다운로드 중"; "struct.download_badge.text.paused" = "일시 정지"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index df89b5329..dab6c4512 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -449,12 +449,6 @@ // AutoPlayPolicy "enum.auto_play_policy.value.off" = "不启用"; -// MARK: DownloadListFilter -"enum.download_list_filter.title.all" = "全部"; -"enum.download_list_filter.title.active" = "进行中"; -"enum.download_list_filter.title.completed" = "已下载"; -"enum.download_list_filter.title.failed" = "需处理"; -"enum.download_list_filter.title.update" = "有可更新"; // MARK: DownloadBadge "struct.download_badge.text.queued" = "已排队"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 2e3b2961d..c51e1b142 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -993,11 +993,6 @@ "enum.download_thread_mode.value.triple" = "同時下載 3 張圖片"; "enum.download_thread_mode.value.quadruple" = "同時下載 4 張圖片"; "enum.download_thread_mode.value.quintuple" = "同時下載 5 張圖片"; -"enum.download_list_filter.title.all" = "全部"; -"enum.download_list_filter.title.active" = "進行中"; -"enum.download_list_filter.title.completed" = "已下載"; -"enum.download_list_filter.title.failed" = "需處理"; -"enum.download_list_filter.title.update" = "有可更新"; "struct.download_badge.text.queued" = "已排隊"; "struct.download_badge.text.downloading" = "下載中"; "struct.download_badge.text.paused" = "已暫停"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index ceee38475..b297b77de 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -994,11 +994,6 @@ "enum.download_thread_mode.value.triple" = "同時下載 3 張圖片"; "enum.download_thread_mode.value.quadruple" = "同時下載 4 張圖片"; "enum.download_thread_mode.value.quintuple" = "同時下載 5 張圖片"; -"enum.download_list_filter.title.all" = "全部"; -"enum.download_list_filter.title.active" = "進行中"; -"enum.download_list_filter.title.completed" = "已下載"; -"enum.download_list_filter.title.failed" = "需處理"; -"enum.download_list_filter.title.update" = "有可更新"; "struct.download_badge.text.queued" = "已排隊"; "struct.download_badge.text.downloading" = "下載中"; "struct.download_badge.text.paused" = "已暫停"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index c79e06171..e51827a98 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -994,11 +994,6 @@ "enum.download_thread_mode.value.triple" = "同時下載 3 張圖片"; "enum.download_thread_mode.value.quadruple" = "同時下載 4 張圖片"; "enum.download_thread_mode.value.quintuple" = "同時下載 5 張圖片"; -"enum.download_list_filter.title.all" = "全部"; -"enum.download_list_filter.title.active" = "進行中"; -"enum.download_list_filter.title.completed" = "已下載"; -"enum.download_list_filter.title.failed" = "需處理"; -"enum.download_list_filter.title.update" = "有可更新"; "struct.download_badge.text.queued" = "已排隊"; "struct.download_badge.text.downloading" = "下載中"; "struct.download_badge.text.paused" = "已暫停"; diff --git a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift index ce75cb23d..13a847292 100644 --- a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift @@ -40,32 +40,6 @@ extension DownloadBadge { } } -// MARK: - DownloadListFilter -enum DownloadListFilter: String, CaseIterable, Identifiable { - case all - case active - case completed - case failed - case update - - var id: String { rawValue } - - var title: String { - switch self { - case .all: - return L10n.Localizable.Enum.DownloadListFilter.Title.all - case .active: - return L10n.Localizable.Enum.DownloadListFilter.Title.active - case .completed: - return L10n.Localizable.Enum.DownloadListFilter.Title.completed - case .failed: - return L10n.Localizable.Enum.DownloadListFilter.Title.failed - case .update: - return L10n.Localizable.Enum.DownloadListFilter.Title.update - } - } -} - // MARK: - DownloadRequestPayload struct DownloadRequestPayload: Equatable, Sendable { let gallery: Gallery diff --git a/EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift index 2f66de693..568f52cca 100644 --- a/EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift @@ -103,25 +103,6 @@ extension DownloadedGallery { displayStatus == .active && !(hasActiveTask && activeGalleryID == gid) } - func matches(filter: DownloadListFilter) -> Bool { - if isQueuedWorkItem { - return filter == .all || filter == .active - } - - switch filter { - case .all: - return true - case .active: - return [.active, .inactive].contains(displayStatus) - case .completed: - return displayStatus == .completed - case .failed: - return displayStatus == .error - case .update: - return displayStatus == .updateAvailable - } - } - } extension DownloadInspection { diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index 99b60824e..4a194adad 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -23,7 +23,6 @@ struct DownloadsReducer { struct State: Equatable { var route: Route? var keyword = "" - var filter: DownloadListFilter = .all var downloads = [DownloadedGallery]() var loadingState: LoadingState = .loading var hasLoadedInitialDownloads = false @@ -39,11 +38,8 @@ struct DownloadsReducer { var filteredDownloads: [DownloadedGallery] { downloads.filter { - $0.matches(filter: filter) - && ( - keyword.isEmpty - || $0.searchableText.caseInsensitiveContains(keyword) - ) + keyword.isEmpty + || $0.searchableText.caseInsensitiveContains(keyword) } } } diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift index 4b1080527..8e43517ed 100644 --- a/EhPanda/View/Downloads/DownloadsView.swift +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -144,7 +144,6 @@ struct DownloadsView: View { .background(navigationLink) .navigationTitle(L10n.Localizable.DownloadsView.Title.downloads) .navigationBarTitleDisplayMode(.large) - .toolbar(content: toolbar) } } @@ -306,31 +305,11 @@ private extension DownloadsView { ) { AlertViewButton(title: L10n.Localizable.DownloadsView.Button.clearFilters) { store.keyword = "" - store.filter = .all } } } } - @ToolbarContentBuilder private func toolbar() -> some ToolbarContent { - CustomToolbarItem { - Menu { - ForEach(DownloadListFilter.allCases) { filter in - Button { - store.filter = filter - } label: { - Text(filter.title) - if store.filter == filter { - Image(systemSymbol: .checkmark) - } - } - } - } label: { - Image(systemSymbol: .dialLow) - .symbolRenderingMode(.hierarchical) - } - } - } } struct DownloadsView_Previews: PreviewProvider { diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index 56a08dd66..18869e173 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -57,24 +57,10 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { partialDownload.badge.progress == DownloadProgress(completedPageCount: 5, pageCount: 12) ) - #expect(DownloadListFilter.failed.title == "Needs Attention") } @Test - func testQueuedRedownloadDoesNotLeakIntoCompletedFilter() { - let queuedRedownload = sampleDownload( - gid: "505", - title: "Delta Archive", - status: .queued, - completedPageCount: 12 - ) - - #expect(queuedRedownload.matches(filter: .completed) == false) - #expect(queuedRedownload.matches(filter: .update) == false) - } - - @Test - func testQueuedRepairDoesNotLeakIntoFailedFilter() { + func testQueuedRepairKeepsQueuedStatusWhileMissingFilesStaysError() { let queuedRepair = sampleDownload( gid: "606", title: "Repair Archive", @@ -89,10 +75,9 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { completedPageCount: 0 ) - #expect(queuedRepair.matches(filter: .failed) == false) - #expect(queuedRepair.matches(filter: .update) == false) + #expect(queuedRepair.displayStatus == .queued) + #expect(missingFilesWithoutQueuedWork.displayStatus == .error) #expect(missingFilesWithoutQueuedWork.lastError?.code == .fileOperationFailed) - #expect(missingFilesWithoutQueuedWork.matches(filter: .failed)) } @Test diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 8acc4b38f..ab9fca9d2 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -27,7 +27,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { var state = DownloadsReducer.State() state.downloads = [activeDownload, completedDownload] - state.filter = .active state.keyword = "alpha" #expect(state.filteredDownloads == [activeDownload]) @@ -70,7 +69,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { ) #expect(queuedRedownload.badge.status == .queued) - #expect(queuedRedownload.matches(filter: .active)) } @Test @@ -83,7 +81,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { ) #expect(queuedRepair.badge.status == .queued) - #expect(queuedRepair.matches(filter: .active)) } @Test @@ -96,8 +93,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { ) #expect(queuedUpdate.badge.status == .queued) - #expect(queuedUpdate.matches(filter: .active)) - #expect(queuedUpdate.matches(filter: .update) == false) } @Test @@ -112,7 +107,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { #expect(resumedUpdate.isQueuedWorkItem) #expect(resumedUpdate.badge.status == .queued) - #expect(resumedUpdate.matches(filter: .active)) } @Test @@ -131,7 +125,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { progress: .init(completedPageCount: 4, pageCount: 12) ) ) - #expect(pausedDownload.matches(filter: .active)) } @Test @@ -229,7 +222,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { partialDownload.badge.progress == DownloadProgress(completedPageCount: 5, pageCount: 12) ) - #expect(DownloadListFilter.failed.title == "Needs Attention") } private func queryItems(for url: URL) -> [String: String] { From 40c7d97265b49a7e8dfbe199ba4906bc39474c28 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 18:33:02 +0800 Subject: [PATCH 158/614] Store downloads in user folders --- EhPanda/App/Generated/Strings.swift | 8 + .../Clients/DownloadClient+Execution.swift | 5 +- .../DownloadClient+ExecutionFetch.swift | 2 + .../DownloadClient+ExecutionSupport.swift | 8 +- .../Clients/DownloadClient+Folders.swift | 188 ++++++++++++++ .../Clients/DownloadClient+Manager.swift | 1 + .../Clients/DownloadClient+Persistence.swift | 15 +- .../Clients/DownloadClient+PublicAPI.swift | 20 +- .../Clients/DownloadClient+Testing.swift | 5 +- .../App/Tools/Clients/DownloadClient.swift | 46 +++- EhPanda/App/Tools/Defaults.swift | 1 + .../Tools/Utilities/AppLaunchAutomation.swift | 6 + .../Tools/Utilities/DownloadFileStorage.swift | 136 +++++++++-- EhPanda/App/de.lproj/Localizable.strings | 4 + EhPanda/App/en.lproj/Localizable.strings | 4 + EhPanda/App/ja.lproj/Localizable.strings | 4 + EhPanda/App/ko.lproj/Localizable.strings | 4 + EhPanda/App/zh-Hans.lproj/Localizable.strings | 4 + .../App/zh-Hant-HK.lproj/Localizable.strings | 4 + .../App/zh-Hant-TW.lproj/Localizable.strings | 4 + EhPanda/App/zh-Hant.lproj/Localizable.strings | 4 + .../DownloadedGallery+Extensions.swift | 3 + .../Models/Download/DownloadedGallery.swift | 3 + .../View/Detail/DetailReducer+Download.swift | 16 +- EhPanda/View/Detail/DetailReducer.swift | 2 +- EhPanda/View/Detail/DetailView.swift | 5 +- .../Download/DetailReducerDownloadTests.swift | 7 +- .../DetailReducerPauseAndGuardTests.swift | 2 +- .../Download/DownloadAutomationTests.swift | 6 + .../DownloadEnqueueManifestTests.swift | 3 +- .../DownloadFeatureTestFactories.swift | 6 + .../DownloadFileStorageRepairTests.swift | 4 +- .../Download/DownloadFileStorageTests.swift | 79 +++++- .../DownloadFolderOperationTests.swift | 229 ++++++++++++++++++ .../DownloadInterruptedResumeTests.swift | 4 +- .../DownloadManagerCaptureTests.swift | 4 +- .../DownloadManagerRepairSeedTests.swift | 6 +- .../DownloadManagerStorageTests.swift | 52 ++-- .../Download/DownloadObserverBatchTests.swift | 2 +- .../DownloadPauseAndReconcileTests.swift | 6 +- .../Download/DownloadProcessCacheTests.swift | 2 +- .../Tests/Download/DownloadProcessTests.swift | 4 +- .../DownloadRetryMinimalSourceTests.swift | 2 +- .../Download/DownloadRetryPagesTests.swift | 2 +- .../DownloadRetryUpdateFallbackTests.swift | 2 +- .../Download/DownloadSchedulingTests.swift | 2 +- .../DownloadVersionSignatureTests.swift | 4 +- .../DownloadedGalleryManifestModelTests.swift | 3 +- 48 files changed, 829 insertions(+), 104 deletions(-) create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+Folders.swift create mode 100644 EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 0fca7e112..6acde1ecc 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -591,6 +591,14 @@ internal enum L10n { internal static func assetUnreadable(_ p1: Any) -> String { return L10n.tr("Localizable", "download_file_storage.error.asset_unreadable", String(describing: p1), fallback: "Asset file is unreadable: %@") } + /// The download is currently active. + internal static let downloadBusy = L10n.tr("Localizable", "download_file_storage.error.download_busy", fallback: "The download is currently active.") + /// A folder with this name already exists. + internal static let folderAlreadyExists = L10n.tr("Localizable", "download_file_storage.error.folder_already_exists", fallback: "A folder with this name already exists.") + /// The folder contains an active download. + internal static let folderBusyDownloading = L10n.tr("Localizable", "download_file_storage.error.folder_busy_downloading", fallback: "The folder contains an active download.") + /// The folder name is invalid. + internal static let invalidFolderName = L10n.tr("Localizable", "download_file_storage.error.invalid_folder_name", fallback: "The folder name is invalid.") } internal enum Validation { /// Cover image data is corrupted. diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index afd038979..790c4ca33 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -116,7 +116,10 @@ extension DownloadManager { mode: mode, rawPageSelection: rawPageSelection ) - let folderRelativePath = folderRelativePath(for: payload) + let folderRelativePath = folderRelativePath( + for: payload, + parentFolderName: download.folderName + ) _ = try await performDownload( payload: payload, folderRelativePath: folderRelativePath, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index c00b538a8..98853f8b1 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -71,6 +71,7 @@ extension DownloadManager { previewURLs: components.previewURLs, previewConfig: components.previewConfig, host: download.host, + folderName: download.folderName, versionMetadata: versionMetadata, options: options, mode: mode, @@ -157,6 +158,7 @@ extension DownloadManager { previewURLs: payload.previewURLs, previewConfig: payload.previewConfig, host: payload.host, + folderName: payload.folderName, versionMetadata: payload.versionMetadata, options: payload.options, mode: payload.mode, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index e2ade9d10..2e5435dbd 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -30,14 +30,18 @@ extension DownloadManager { ) } - func folderRelativePath(for payload: DownloadRequestPayload) -> String { - storage.makeFolderRelativePath( + func folderRelativePath( + for payload: DownloadRequestPayload, + parentFolderName: String + ) -> String { + let galleryFolderName = storage.makeFolderRelativePath( gid: payload.gallery.gid, token: payload.gallery.token, title: payload.galleryDetail.trimmedTitle.isEmpty ? payload.gallery.title : payload.galleryDetail.trimmedTitle ) + return "\(parentFolderName)/\(galleryFolderName)" } func downloadCoverImage( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift new file mode 100644 index 000000000..e3d83ec9e --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift @@ -0,0 +1,188 @@ +// +// DownloadClient+Folders.swift +// EhPanda +// + +import Foundation + +// MARK: - User Folder Operations +extension DownloadManager { + func fetchFolders() async -> [String] { + _ = await reloadDownloadIndex() + return userFolders + } + + func createFolder(name: String) async -> Result { + guard let normalizedName = storage.normalizedUserFolderName(name) else { + return .failure( + .fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Error.invalidFolderName + ) + ) + } + let folderURL = storage.userFolderURL(name: normalizedName) + guard !fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { + return .failure( + .fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Error.folderAlreadyExists + ) + ) + } + do { + try storage.ensureRootDirectory() + try createDirectory(at: folderURL) + } catch { + Logger.error(error) + return .failure(.fileOperationFailed(error.localizedDescription)) + } + _ = await reloadDownloadIndex() + return .success(()) + } + + func renameFolder( + oldName: String, + newName: String + ) async -> Result { + guard let normalizedName = storage.normalizedUserFolderName(newName) else { + return .failure( + .fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Error.invalidFolderName + ) + ) + } + let sourceURL = storage.userFolderURL(name: oldName) + let destinationURL = storage.userFolderURL(name: normalizedName) + guard sourceURL.standardizedFileURL != destinationURL.standardizedFileURL else { + return .success(()) + } + guard fileManager.operate({ $0.fileExists(atPath: sourceURL.path) }) else { + return .failure(.notFound) + } + guard !fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) else { + return .failure( + .fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Error.folderAlreadyExists + ) + ) + } + // The active task holds absolute paths inside the folder; renaming + // underneath it would resurrect the old directory on the next write. + if let activeGalleryID, + downloadIndex[activeGalleryID]?.parentFolderName == oldName { + return .failure( + .fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Error.folderBusyDownloading + ) + ) + } + do { + try fileManager.operate { + try $0.moveItem(at: sourceURL, to: destinationURL) + } + } catch { + Logger.error(error) + return .failure(.fileOperationFailed(error.localizedDescription)) + } + _ = await reloadDownloadIndex() + await notifyObservers() + return .success(()) + } + + func deleteFolder(name: String) async -> Result { + let folderURL = storage.userFolderURL(name: name) + guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { + return .failure(.notFound) + } + let containedGIDs = downloadIndex.values + .filter { $0.parentFolderName == name } + .map(\.manifest.gid) + for gid in containedGIDs { + schedulingBlockedGalleryIDs.insert(gid) + } + defer { + for gid in containedGIDs { + schedulingBlockedGalleryIDs.remove(gid) + } + } + if let activeGalleryID, + containedGIDs.contains(activeGalleryID) { + let taskToCancel = activeTask + activeTask?.cancel() + activeTask = nil + self.activeGalleryID = nil + await taskToCancel?.value + } + for gid in containedGIDs { + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) + await queueStore.remove(gid) + downloadIndex[gid] = nil + } + do { + try storage.removeFolder(at: folderURL) + } catch let error as AppError { + return .failure(error) + } catch { + Logger.error(error) + return .failure(.fileOperationFailed(error.localizedDescription)) + } + _ = await reloadDownloadIndex() + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } + + func moveDownload( + gid: String, + toFolderName folderName: String + ) async -> Result { + guard let normalizedName = storage.normalizedUserFolderName(folderName) else { + return .failure( + .fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Error.invalidFolderName + ) + ) + } + schedulingBlockedGalleryIDs.insert(gid) + defer { + schedulingBlockedGalleryIDs.remove(gid) + } + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + guard activeGalleryID != gid else { + return .failure( + .fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Error.downloadBusy + ) + ) + } + let destinationParentURL = storage.userFolderURL(name: normalizedName) + let destinationURL = destinationParentURL.appendingPathComponent( + download.folderURL.lastPathComponent, + isDirectory: true + ) + guard destinationURL.standardizedFileURL != download.folderURL.standardizedFileURL else { + return .success(()) + } + guard !fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) else { + return .failure( + .fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Error.folderAlreadyExists + ) + ) + } + do { + // Recreate the destination folder if it vanished via the Files app. + try createDirectory(at: destinationParentURL) + try fileManager.operate { + try $0.moveItem(at: download.folderURL, to: destinationURL) + } + } catch { + Logger.error(error) + return .failure(.fileOperationFailed(error.localizedDescription)) + } + _ = await reloadDownloadIndex() + await notifyObservers() + return .success(()) + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 062f6c1f7..d408f19c1 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -132,6 +132,7 @@ actor DownloadManager { let downloadOptionsProvider: @Sendable () async -> DownloadRequestOptions let queueStore: DownloadQueueStore var downloadIndex = [String: DownloadFolderRecord]() + var userFolders = [String]() var downloadErrors = [String: DownloadFailure]() var validationErrors = [String: DownloadFailure]() var failedPageErrors = [String: [Int: PageFailure]]() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index ea763894b..c5ea32ee1 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -10,12 +10,14 @@ extension DownloadManager { @discardableResult func reloadDownloadIndex() async -> [DownloadedGallery] { do { - let records = try storage.scanDownloadFolders() - downloadIndex = deduplicatedDownloadIndex(from: records) - return await downloads(from: records) + let scanResult = try storage.scanDownloads() + downloadIndex = deduplicatedDownloadIndex(from: scanResult.records) + userFolders = scanResult.userFolders + return await downloads(from: scanResult.records) } catch { Logger.error(error) downloadIndex = [:] + userFolders = [] return [] } } @@ -61,6 +63,7 @@ extension DownloadManager { return DownloadedGallery( manifest: record.manifest, folderURL: record.folderURL, + folderName: record.parentFolderName, localCoverURL: storage.localCoverURL( folderURL: record.folderURL, manifest: record.manifest @@ -222,10 +225,12 @@ extension DownloadManager { ) .contentModificationDate downloadIndex[manifest.gid] = DownloadFolderRecord( - relativePath: folderURL.lastPathComponent, + relativePath: storage.rootRelativePath(forFolderURL: folderURL) + ?? folderURL.lastPathComponent, folderURL: folderURL, manifest: manifest, - modifiedAt: modifiedAt + modifiedAt: modifiedAt, + parentFolderName: storage.parentFolderName(forFolderURL: folderURL) ?? "" ) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index e903964b6..c292053ae 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -79,7 +79,25 @@ extension DownloadManager { ) async -> Result { do { try storage.ensureRootDirectory() - let folderRelativePath = folderRelativePath(for: payload) + // An already-known gallery keeps its current folder; only brand-new + // downloads land in the folder carried by the payload. + let parentFolderName: String + if let record = downloadIndex[payload.gallery.gid] { + parentFolderName = record.parentFolderName + } else if let normalizedName = storage.normalizedUserFolderName(payload.folderName) { + parentFolderName = normalizedName + } else { + return .failure( + .fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Error.invalidFolderName + ) + ) + } + try createDirectory(at: storage.userFolderURL(name: parentFolderName)) + let folderRelativePath = folderRelativePath( + for: payload, + parentFolderName: parentFolderName + ) try writeInitialManifest( payload: payload, folderRelativePath: folderRelativePath diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index dfb9e2591..1699e93db 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -113,7 +113,10 @@ extension DownloadManager { existingDownload: DownloadedGallery ) throws -> PrepareWorkingSeedResult { let folderURL = storage.folderURL( - relativePath: folderRelativePath(for: payload) + relativePath: folderRelativePath( + for: payload, + parentFolderName: existingDownload.folderName + ) ) try? fileManager.operate { try $0.removeItem(at: folderURL) diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index b94898be1..a6ee94bc8 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -26,6 +26,11 @@ struct DownloadClient: Sendable { let loadLocalPageURLs: @Sendable (String) async -> Result<[Int: URL], AppError> let captureCachedPage: @Sendable (String, Int, URL?) async -> Void let loadInspection: @Sendable (String) async -> Result + let fetchFolders: @Sendable () async -> [String] + let createFolder: @Sendable (String) async -> Result + let renameFolder: @Sendable (String, String) async -> Result + let deleteFolder: @Sendable (String) async -> Result + let moveDownload: @Sendable (String, String) async -> Result init( observeDownloads: @escaping @Sendable () -> AsyncStream<[DownloadedGallery]>, @@ -54,7 +59,16 @@ struct DownloadClient: Sendable { captureCachedPage: @escaping @Sendable (String, Int, URL?) async -> Void = { _, _, _ in }, loadInspection: @escaping @Sendable (String) async -> Result< DownloadInspection, AppError - > = { _ in .failure(.notFound) } + > = { _ in .failure(.notFound) }, + fetchFolders: @escaping @Sendable () async -> [String] = { [] }, + createFolder: @escaping @Sendable (String) async -> Result + = { _ in .success(()) }, + renameFolder: @escaping @Sendable (String, String) async -> Result + = { _, _ in .success(()) }, + deleteFolder: @escaping @Sendable (String) async -> Result + = { _ in .success(()) }, + moveDownload: @escaping @Sendable (String, String) async -> Result + = { _, _ in .success(()) } ) { self.observeDownloads = observeDownloads self.fetchDownloads = fetchDownloads @@ -75,6 +89,11 @@ struct DownloadClient: Sendable { self.loadLocalPageURLs = loadLocalPageURLs self.captureCachedPage = captureCachedPage self.loadInspection = loadInspection + self.fetchFolders = fetchFolders + self.createFolder = createFolder + self.renameFolder = renameFolder + self.deleteFolder = deleteFolder + self.moveDownload = moveDownload } } @@ -145,7 +164,16 @@ extension DownloadClient { captureCachedPage: { gid, index, imageURL in await manager.captureCachedPage(gid: gid, index: index, imageURL: imageURL) }, - loadInspection: { gid in await manager.loadInspection(gid: gid) } + loadInspection: { gid in await manager.loadInspection(gid: gid) }, + fetchFolders: { await manager.fetchFolders() }, + createFolder: { name in await manager.createFolder(name: name) }, + renameFolder: { oldName, newName in + await manager.renameFolder(oldName: oldName, newName: newName) + }, + deleteFolder: { name in await manager.deleteFolder(name: name) }, + moveDownload: { gid, folderName in + await manager.moveDownload(gid: gid, toFolderName: folderName) + } ) } } @@ -190,7 +218,12 @@ extension DownloadClient { loadManifest: { _ in .failure(.notFound) }, loadLocalPageURLs: { _ in .failure(.notFound) }, captureCachedPage: { _, _, _ in }, - loadInspection: { _ in .failure(.notFound) } + loadInspection: { _ in .failure(.notFound) }, + fetchFolders: { [] }, + createFolder: { _ in .success(()) }, + renameFolder: { _, _ in .success(()) }, + deleteFolder: { _ in .success(()) }, + moveDownload: { _, _ in .success(()) } ) static func placeholder() -> Result { fatalError() } @@ -214,6 +247,11 @@ extension DownloadClient { loadManifest: IssueReporting.unimplemented(placeholder: placeholder()), loadLocalPageURLs: IssueReporting.unimplemented(placeholder: placeholder()), captureCachedPage: IssueReporting.unimplemented(placeholder: placeholder()), - loadInspection: IssueReporting.unimplemented(placeholder: placeholder()) + loadInspection: IssueReporting.unimplemented(placeholder: placeholder()), + fetchFolders: IssueReporting.unimplemented(placeholder: placeholder()), + createFolder: IssueReporting.unimplemented(placeholder: placeholder()), + renameFolder: IssueReporting.unimplemented(placeholder: placeholder()), + deleteFolder: IssueReporting.unimplemented(placeholder: placeholder()), + moveDownload: IssueReporting.unimplemented(placeholder: placeholder()) ) } diff --git a/EhPanda/App/Tools/Defaults.swift b/EhPanda/App/Tools/Defaults.swift index f3b96eb67..a68690946 100644 --- a/EhPanda/App/Tools/Defaults.swift +++ b/EhPanda/App/Tools/Defaults.swift @@ -67,6 +67,7 @@ struct Defaults { static let downloads = "Downloads" static let downloadPages = "pages" static let downloadManifest = "manifest.json" + static let automationDownloadFolder = "Automation" } struct Regex { static let tagSuggestion: NSRegularExpression? = try? .init(pattern: "(\\S+:\".+?\"|\".+?\"|\\S+:\\S+|\\S+)") diff --git a/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift b/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift index 3cf3e4349..3596919dc 100644 --- a/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift +++ b/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift @@ -14,6 +14,7 @@ struct AppLaunchAutomation: Sendable { let initialTab: TabBarItemType? let autoDownloadGID: String? + let downloadFolderName: String? let loginCookies: LoginCookies? let galleryURL: URL? @@ -33,6 +34,10 @@ struct AppLaunchAutomation: Sendable { environment: environment, key: "EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID" ) + let downloadFolderName = trimmedValue( + environment: environment, + key: "EHPANDA_AUTOMATION_DOWNLOAD_FOLDER" + ) let galleryURL = trimmedValue( environment: environment, key: "EHPANDA_AUTOMATION_GALLERY_URL" @@ -70,6 +75,7 @@ struct AppLaunchAutomation: Sendable { return .init( initialTab: initialTab, autoDownloadGID: autoDownloadGID, + downloadFolderName: downloadFolderName, loginCookies: loginCookies, galleryURL: galleryURL ) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index f0855b025..4218af65a 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -16,6 +16,12 @@ struct DownloadFolderRecord: Equatable, Sendable { let folderURL: URL let manifest: DownloadManifest let modifiedAt: Date? + let parentFolderName: String +} + +struct DownloadScanResult: Equatable, Sendable { + let records: [DownloadFolderRecord] + let userFolders: [String] } struct DownloadFileStorage: Sendable { @@ -46,6 +52,24 @@ struct DownloadFileStorage: Sendable { rootURL.appendingPathComponent(relativePath, isDirectory: true) } + func userFolderURL(name: String) -> URL { + rootURL.appendingPathComponent(name, isDirectory: true) + } + + func rootRelativePath(forFolderURL url: URL) -> String? { + let rootPath = rootURL.standardizedFileURL.path + "/" + let path = url.standardizedFileURL.path + guard path.hasPrefix(rootPath) else { return nil } + return String(path.dropFirst(rootPath.count)) + } + + func parentFolderName(forFolderURL url: URL) -> String? { + guard let relativePath = rootRelativePath(forFolderURL: url) else { return nil } + let components = relativePath.split(separator: "/") + guard components.count >= 2 else { return nil } + return String(components[0]) + } + func validatedChildURL( root: URL, relativePath: String ) -> URL? { @@ -106,6 +130,40 @@ struct DownloadFileStorage: Sendable { "[\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))] \(normalizedFolderTitle(title))" } + func isGalleryFolderLikeName(_ name: String) -> Bool { + name.range(of: #"^\[[^\]]*_[^\]]*\] "#, options: .regularExpression) != nil + } + + func normalizedUserFolderName(_ name: String) -> String? { + let invalidCharacters = CharacterSet(charactersIn: "/\\:") + .union(.controlCharacters) + let sanitizedScalars = name + .trimmingCharacters(in: .whitespacesAndNewlines) + .unicodeScalars + .map { invalidCharacters.contains($0) ? " " : String($0) } + .joined() + let collapsedWhitespace = sanitizedScalars.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ) + let trimmedName = collapsedWhitespace.replacingOccurrences( + of: "^[\\s.]+|[\\s.]+$", + with: "", + options: .regularExpression + ) + let limitedName = String(trimmedName.prefix(Self.maxFolderTitleLength)) + .replacingOccurrences( + of: "[\\s.]+$", + with: "", + options: .regularExpression + ) + guard !limitedName.isEmpty, !isGalleryFolderLikeName(limitedName) else { + return nil + } + return limitedName + } + private func normalizedFolderTitle(_ title: String) -> String { let invalidCharacters = CharacterSet(charactersIn: "/\\:") .union(.controlCharacters) @@ -217,34 +275,74 @@ struct DownloadFileStorage: Sendable { } func scanDownloadFolders() throws -> [DownloadFolderRecord] { + try scanDownloads().records + } + + func scanDownloads() throws -> DownloadScanResult { guard fileManager.operate({ $0.fileExists(atPath: rootURL.path) }) else { - return [] + return .init(records: [], userFolders: []) } - let folderURLs = try fileManager.operate { + var records = [DownloadFolderRecord]() + var userFolders = [String]() + for folderURL in directoryURLs(in: rootURL) { + let folderName = folderURL.lastPathComponent + // Gallery folders dropped directly under the root, including broken + // manifest-less ones, are invisible to the app and never become + // user folders. + guard (try? readManifest(folderURL: folderURL)) == nil else { continue } + guard !isGalleryFolderLikeName(folderName) else { continue } + + userFolders.append(folderName) + for galleryFolderURL in directoryURLs(in: folderURL) { + guard let manifest = try? readManifest(folderURL: galleryFolderURL) else { + continue + } + records.append( + galleryFolderRecord( + folderURL: galleryFolderURL, + manifest: manifest, + parentFolderName: folderName + ) + ) + } + } + return .init( + records: records, + userFolders: userFolders.sorted { + $0.localizedStandardCompare($1) == .orderedAscending + } + ) + } + + private func directoryURLs(in parentURL: URL) -> [URL] { + let contents = (try? fileManager.operate { try $0.contentsOfDirectory( - at: rootURL, + at: parentURL, includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], options: [.skipsHiddenFiles] ) + }) ?? [] + return contents.filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true } + } - return folderURLs.compactMap { folderURL in - let resourceValues = try? folderURL.resourceValues( - forKeys: [.isDirectoryKey, .contentModificationDateKey] - ) - guard resourceValues?.isDirectory == true, - let manifest = try? readManifest(folderURL: folderURL) - else { - return nil - } - return DownloadFolderRecord( - relativePath: folderURL.lastPathComponent, - folderURL: folderURL, - manifest: manifest, - modifiedAt: resourceValues?.contentModificationDate - ) - } + private func galleryFolderRecord( + folderURL: URL, + manifest: DownloadManifest, + parentFolderName: String + ) -> DownloadFolderRecord { + let resourceValues = try? folderURL.resourceValues( + forKeys: [.contentModificationDateKey] + ) + return DownloadFolderRecord( + relativePath: "\(parentFolderName)/\(folderURL.lastPathComponent)", + folderURL: folderURL, + manifest: manifest, + modifiedAt: resourceValues?.contentModificationDate, + parentFolderName: parentFolderName + ) } func fileHash(at url: URL) throws -> String { diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index abd1b31fb..918883659 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -1005,6 +1005,10 @@ "struct.download_badge.text.needs_repair" = "Reparatur nötig"; "struct.download_badge.progress" = "%d/%d"; "download_file_storage.error.asset_unreadable" = "Asset-Datei ist nicht lesbar: %@"; +"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; +"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; +"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; +"download_file_storage.error.download_busy" = "The download is currently active."; "download_file_storage.validation.download_folder_unresolved" = "Download-Ordner konnte nicht aufgelöst werden."; "download_file_storage.validation.download_folder_missing" = "Download-Ordner fehlt."; "download_file_storage.validation.manifest_missing" = "Manifest-Datei fehlt."; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index e2e39e8a8..ed609d43c 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -463,6 +463,10 @@ // MARK: DownloadFileStorage "download_file_storage.error.asset_unreadable" = "Asset file is unreadable: %@"; +"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; +"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; +"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; +"download_file_storage.error.download_busy" = "The download is currently active."; "download_file_storage.validation.download_folder_unresolved" = "Download folder could not be resolved."; "download_file_storage.validation.download_folder_missing" = "Download folder is missing."; "download_file_storage.validation.manifest_missing" = "Manifest file is missing."; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 6f6bac33a..da5e49c2c 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -1005,6 +1005,10 @@ "struct.download_badge.text.needs_repair" = "要修復"; "struct.download_badge.progress" = "%d/%d"; "download_file_storage.error.asset_unreadable" = "アセットファイルを読み取れません: %@"; +"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; +"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; +"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; +"download_file_storage.error.download_busy" = "The download is currently active."; "download_file_storage.validation.download_folder_unresolved" = "ダウンロードフォルダを解決できませんでした。"; "download_file_storage.validation.download_folder_missing" = "ダウンロードフォルダが見つかりません。"; "download_file_storage.validation.manifest_missing" = "マニフェストファイルが見つかりません。"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 36715875a..a862a8bb6 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -1005,6 +1005,10 @@ "struct.download_badge.text.needs_repair" = "복구 필요"; "struct.download_badge.progress" = "%d/%d"; "download_file_storage.error.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; +"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; +"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; +"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; +"download_file_storage.error.download_busy" = "The download is currently active."; "download_file_storage.validation.download_folder_unresolved" = "다운로드 폴더를 확인할 수 없습니다."; "download_file_storage.validation.download_folder_missing" = "다운로드 폴더가 없습니다."; "download_file_storage.validation.manifest_missing" = "매니페스트 파일이 없습니다."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index dab6c4512..46f99f459 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -462,6 +462,10 @@ // MARK: DownloadFileStorage "download_file_storage.error.asset_unreadable" = "资源文件无法读取:%@"; +"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; +"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; +"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; +"download_file_storage.error.download_busy" = "The download is currently active."; "download_file_storage.validation.download_folder_unresolved" = "无法解析下载文件夹。"; "download_file_storage.validation.download_folder_missing" = "下载文件夹缺失。"; "download_file_storage.validation.manifest_missing" = "Manifest 文件缺失。"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index c51e1b142..fd15fa1a9 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -1002,6 +1002,10 @@ "struct.download_badge.text.needs_repair" = "需修復"; "struct.download_badge.progress" = "%d/%d"; "download_file_storage.error.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; +"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; +"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; +"download_file_storage.error.download_busy" = "The download is currently active."; "download_file_storage.validation.download_folder_unresolved" = "無法解析下載資料夾。"; "download_file_storage.validation.download_folder_missing" = "下載資料夾缺失。"; "download_file_storage.validation.manifest_missing" = "Manifest 檔案缺失。"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index b297b77de..58ddc3dd6 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -1003,6 +1003,10 @@ "struct.download_badge.text.needs_repair" = "需修復"; "struct.download_badge.progress" = "%d/%d"; "download_file_storage.error.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; +"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; +"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; +"download_file_storage.error.download_busy" = "The download is currently active."; "download_file_storage.validation.download_folder_unresolved" = "無法解析下載資料夾。"; "download_file_storage.validation.download_folder_missing" = "下載資料夾缺失。"; "download_file_storage.validation.manifest_missing" = "Manifest 檔案缺失。"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index e51827a98..3727c360b 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -1003,6 +1003,10 @@ "struct.download_badge.text.needs_repair" = "需修復"; "struct.download_badge.progress" = "%d/%d"; "download_file_storage.error.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; +"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; +"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; +"download_file_storage.error.download_busy" = "The download is currently active."; "download_file_storage.validation.download_folder_unresolved" = "無法解析下載資料夾。"; "download_file_storage.validation.download_folder_missing" = "下載資料夾缺失。"; "download_file_storage.validation.manifest_missing" = "Manifest 檔案缺失。"; diff --git a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift index 13a847292..68cbec229 100644 --- a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift @@ -47,6 +47,7 @@ struct DownloadRequestPayload: Equatable, Sendable { let previewURLs: [Int: URL] let previewConfig: PreviewConfig let host: GalleryHost + let folderName: String let versionMetadata: DownloadVersionMetadata? let options: DownloadRequestOptions let mode: DownloadStartMode @@ -58,6 +59,7 @@ struct DownloadRequestPayload: Equatable, Sendable { previewURLs: [Int: URL], previewConfig: PreviewConfig, host: GalleryHost, + folderName: String, versionMetadata: DownloadVersionMetadata? = nil, options: DownloadRequestOptions, mode: DownloadStartMode, @@ -68,6 +70,7 @@ struct DownloadRequestPayload: Equatable, Sendable { self.previewURLs = previewURLs self.previewConfig = previewConfig self.host = host + self.folderName = folderName self.versionMetadata = versionMetadata self.options = options self.mode = mode diff --git a/EhPanda/Models/Download/DownloadedGallery.swift b/EhPanda/Models/Download/DownloadedGallery.swift index 8fb00204c..8618799db 100644 --- a/EhPanda/Models/Download/DownloadedGallery.swift +++ b/EhPanda/Models/Download/DownloadedGallery.swift @@ -10,6 +10,7 @@ struct DownloadedGallery: Identifiable, Equatable { let manifest: DownloadManifest let folderURL: URL + let folderName: String let localCoverURL: URL? let localPageURLs: [Int: URL] let displayStatus: DownloadDisplayStatus @@ -33,6 +34,7 @@ struct DownloadedGallery: Identifiable, Equatable { init( manifest: DownloadManifest, folderURL: URL, + folderName: String, localCoverURL: URL?, localPageURLs: [Int: URL], modifiedAt: Date?, @@ -41,6 +43,7 @@ struct DownloadedGallery: Identifiable, Equatable { ) { self.manifest = manifest self.folderURL = folderURL + self.folderName = folderName self.localCoverURL = localCoverURL self.localPageURLs = localPageURLs self.displayStatus = displayStatus diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index 3b0dac72b..a8f48c969 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -29,8 +29,8 @@ extension DetailReducer { return handleOpenReadingDone(result: result, state: &state) case .runLaunchAutomationIfNeeded(let options): return handleRunLaunchAutomation(options: options, state: &state) - case .startDownload(let options): - return handleStartDownload(options: options, state: &state) + case .startDownload(let options, let folderName): + return handleStartDownload(options: options, folderName: folderName, state: &state) case .startDownloadDone(let result): return handleStartDownloadDone(result: result, state: &state) case .toggleDownloadPause: @@ -158,17 +158,24 @@ extension DetailReducer { state: inout State ) -> Effect { guard !state.didRunLaunchAutomation, - appLaunchAutomationClient.current()?.autoDownloadGID == state.gallery.id, + let automation = appLaunchAutomationClient.current(), + automation.autoDownloadGID == state.gallery.id, state.galleryDetail != nil, state.hasLoadedDownloadBadge else { return .none } state.didRunLaunchAutomation = true guard state.downloadBadge == nil else { return .none } - return .send(.startDownload(options)) + return .send( + .startDownload( + options, + automation.downloadFolderName ?? Defaults.FilePath.automationDownloadFolder + ) + ) } private func handleStartDownload( options: DownloadRequestOptions, + folderName: String, state: inout State ) -> Effect { guard !state.isPreparingDownload else { return .none } @@ -181,6 +188,7 @@ extension DetailReducer { previewURLs: state.galleryPreviewURLs, previewConfig: state.previewConfig, host: AppUtil.galleryHost, + folderName: folderName, versionMetadata: state.galleryVersionMetadata, options: options, mode: .initial diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index dca995d01..563c0149e 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -121,7 +121,7 @@ struct DetailReducer { case openReading case openReadingDone(Result<(DownloadedGallery, DownloadManifest), AppError>) case runLaunchAutomationIfNeeded(DownloadRequestOptions) - case startDownload(DownloadRequestOptions) + case startDownload(DownloadRequestOptions, String) case startDownloadDone(Result) case toggleDownloadPause case toggleDownloadPauseDone(Result) diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index f28d3657d..1ae8beb62 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -332,10 +332,11 @@ private extension DetailView { // MARK: Actions private extension DetailView { private func handleDownloadAction() { - let options = setting.downloadRequestOptions switch store.downloadBadge?.status { case nil: - store.send(.startDownload(options)) + // Starting a new download requires picking a folder; the download + // button presents a folder menu for this case instead. + break case .queued, .active, .inactive: store.send(.toggleDownloadPause) case .completed: diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index 68bd1316e..ffdaf8fe7 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -38,7 +38,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { ) store.exhaustivity = .off - await store.send(.startDownload(options)) + await store.send(.startDownload(options, "Folder")) await store.skipReceivedActions(strict: false) #expect(capturedPayload.value?.gallery.gid == gallery.gid) @@ -65,7 +65,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { ) store.exhaustivity = .off - await store.send(.startDownload(options)) { + await store.send(.startDownload(options, "Folder")) { $0.isPreparingDownload = true $0.didRunLaunchAutomation = true } @@ -118,10 +118,11 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { await store.send(.runLaunchAutomationIfNeeded(options)) { $0.didRunLaunchAutomation = true } - await store.receive(\.startDownload, options) + await store.receive(\.startDownload) await store.skipReceivedActions(strict: false) #expect(capturedPayload.value?.gallery.gid == gallery.gid) + #expect(capturedPayload.value?.folderName == Defaults.FilePath.automationDownloadFolder) } } diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index 60a11e78d..b6f71ced8 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -88,7 +88,7 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { $0.cookieClient = .noop } - await store.send(.startDownload(options)) + await store.send(.startDownload(options, "Folder")) #expect(enqueueCount.value == 0) #expect(store.state.isPreparingDownload) diff --git a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift index 5049d2c9c..39f3d92db 100644 --- a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift @@ -15,6 +15,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { let automation = AppLaunchAutomation.resolve(environment: [ "EHPANDA_AUTOMATION_TAB": "downloads", "EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID": "1394965", + "EHPANDA_AUTOMATION_DOWNLOAD_FOLDER": " UI Tests ", "EHPANDA_AUTOMATION_GALLERY_URL": "https://e-hentai.org/g/1394965/56c35114b6/", "EHPANDA_AUTOMATION_IPB_MEMBER_ID": "4172984", "EHPANDA_AUTOMATION_IPB_PASS_HASH": "pass-hash", @@ -23,6 +24,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { #expect(automation?.initialTab == .downloads) #expect(automation?.autoDownloadGID == "1394965") + #expect(automation?.downloadFolderName == "UI Tests") #expect( automation?.galleryURL == URL(string: "https://e-hentai.org/g/1394965/56c35114b6/") ) @@ -69,6 +71,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { let automation = AppLaunchAutomation( initialTab: .downloads, autoDownloadGID: nil, + downloadFolderName: nil, loginCookies: nil, galleryURL: URL(string: "https://example.com/not-a-gallery") ) @@ -102,6 +105,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { let automation = AppLaunchAutomation( initialTab: nil, autoDownloadGID: nil, + downloadFolderName: nil, loginCookies: .init( memberID: "4172984", passHash: "pass-hash", @@ -146,6 +150,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { let automation = AppLaunchAutomation( initialTab: nil, autoDownloadGID: nil, + downloadFolderName: nil, loginCookies: nil, galleryURL: URL(string: "https://exhentai.org/g/1394965/56c35114b6/") ) @@ -203,6 +208,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { let automation = AppLaunchAutomation( initialTab: nil, autoDownloadGID: nil, + downloadFolderName: nil, loginCookies: nil, galleryURL: URL(string: "https://exhentai.org/g/1394965/56c35114b6/") ) diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index be61eeed3..ec71e127e 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -31,6 +31,7 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { previewURLs: [:], previewConfig: .normal(rows: 4), host: .ehentai, + folderName: "Folder", options: .init(threadLimit: 3), mode: .initial ) @@ -42,7 +43,7 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { return } - let folderRelativePath = storage.makeFolderRelativePath( + let folderRelativePath = "Folder/" + storage.makeFolderRelativePath( gid: gallery.gid, token: gallery.token, title: detail.trimmedTitle diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index eb71dba4e..7fc49cb8b 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -75,6 +75,7 @@ extension DownloadedGallery { rating: Float, onlineCoverURL: URL?, folderURL: URL, + folderName: String = "Folder", localCoverURL: URL? = nil, localPageURLs: [Int: URL] = [:], displayStatus: DownloadDisplayStatus, @@ -107,6 +108,7 @@ extension DownloadedGallery { self.init( manifest: manifest, folderURL: folderURL, + folderName: folderName, localCoverURL: localCoverURL, localPageURLs: localPageURLs, modifiedAt: lastDownloadedAt, @@ -130,6 +132,7 @@ extension DownloadFeatureTestCase { AppLaunchAutomation( initialTab: nil, autoDownloadGID: autoDownloadGID, + downloadFolderName: nil, loginCookies: nil, galleryURL: nil ) @@ -195,10 +198,12 @@ extension DownloadFeatureTestCase { lastDownloadedAt: Date? = .now, lastError: DownloadFailure? = nil, folderURL: URL? = nil, + folderName: String = "Folder", localCoverURL: URL? = nil, localPageURLs: [Int: URL] = [:] ) -> DownloadedGallery { let resolvedFolderURL = folderURL ?? FileUtil.downloadsDirectoryURL + .appendingPathComponent(folderName, isDirectory: true) .appendingPathComponent("[\(gid)_token] \(title)", isDirectory: true) return DownloadedGallery( gid: gid, @@ -214,6 +219,7 @@ extension DownloadFeatureTestCase { rating: 4, onlineCoverURL: URL(string: "https://example.com/cover.jpg"), folderURL: resolvedFolderURL, + folderName: folderName, localCoverURL: localCoverURL, localPageURLs: localPageURLs, displayStatus: status.displayStatus, diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift index 6649124d1..a1fbd968f 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift @@ -15,7 +15,7 @@ struct DownloadFileStorageRepairTests { try storage.ensureRootDirectory() let sourceFolderURL = storage.folderURL(relativePath: "123 - Source") - let destinationFolderURL = storage.folderURL(relativePath: "[123_token] Destination") + let destinationFolderURL = storage.folderURL(relativePath: "Folder/[123_token] Destination") let manifest = try sampleManifest(pageCount: 3) try setupRepairSourceFiles( sourceFolderURL: sourceFolderURL, storage: storage, manifest: manifest @@ -102,7 +102,7 @@ private extension DownloadFileStorageRepairTests { try sourceStorage.ensureRootDirectory() try destStorage.ensureRootDirectory() let sourceFolderURL = sourceStorage.folderURL(relativePath: "123 - Source") - let destinationFolderURL = destStorage.folderURL(relativePath: "[123_token] Destination") + let destinationFolderURL = destStorage.folderURL(relativePath: "Folder/[123_token] Destination") try FileManager.default.createDirectory( at: sourceFolderURL, withIntermediateDirectories: true diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 0fcb5cb4a..a560b2272 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -126,7 +126,7 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[123_token] Sample") + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) try Data([0x01]).write(to: folderURL.appendingPathComponent("123_token_1.webp"), options: .atomic) try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_2.jpg"), options: .atomic) @@ -148,7 +148,7 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[123_token] Sample") + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") let pagesFolderURL = folderURL.appendingPathComponent("pages", isDirectory: true) try FileManager.default.createDirectory(at: pagesFolderURL, withIntermediateDirectories: true) try Data([0x01]).write(to: pagesFolderURL.appendingPathComponent("0001.jpg"), options: .atomic) @@ -164,7 +164,7 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[123_token] Sample") + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) let emptyPageURL = folderURL.appendingPathComponent("123_token_1.jpg") try Data().write(to: emptyPageURL, options: .atomic) @@ -185,7 +185,7 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[123_token] Sample") + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") let pagesURL = folderURL.appendingPathComponent("pages", isDirectory: true) try FileManager.default.createDirectory(at: pagesURL, withIntermediateDirectories: true) let emptyPageURL = pagesURL.appendingPathComponent("0001.jpg") @@ -203,7 +203,7 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[123_token] Sample") + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) try Data([0x01]).write(to: folderURL.appendingPathComponent("other_token_cover.jpg"), options: .atomic) try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic) @@ -283,9 +283,9 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let downloadFolderURL = storage.folderURL(relativePath: "[123_token] Sample") - let ignoredFolderURL = storage.folderURL(relativePath: "[456_token] Missing manifest") - let hiddenFolderURL = storage.folderURL(relativePath: "[789_token] Missing manifest") + let downloadFolderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") + let ignoredFolderURL = storage.folderURL(relativePath: "Folder/[456_token] Missing manifest") + let hiddenFolderURL = storage.folderURL(relativePath: "Folder/[789_token] Missing manifest") try FileManager.default.createDirectory(at: downloadFolderURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: ignoredFolderURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: hiddenFolderURL, withIntermediateDirectories: true) @@ -293,9 +293,68 @@ struct DownloadFileStorageTests { let records = try storage.scanDownloadFolders() - #expect(records.map(\.relativePath) == ["[123_token] Sample"]) + #expect(records.map(\.relativePath) == ["Folder/[123_token] Sample"]) #expect(records.first?.manifest.gid == "123") #expect(records.first?.folderURL == downloadFolderURL) + #expect(records.first?.parentFolderName == "Folder") + } + + @Test + func testScanDownloadsIgnoresRootGalleryFoldersAndListsEmptyUserFolders() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + // A gallery folder dropped directly at the root stays invisible. + let rootGalleryURL = storage.folderURL(relativePath: "[123_token] Sample") + try FileManager.default.createDirectory(at: rootGalleryURL, withIntermediateDirectories: true) + try storage.writeManifest(sampleManifest(pageCount: 2), folderURL: rootGalleryURL) + // A broken gallery-like folder without a manifest is not a user folder. + let brokenGalleryURL = storage.folderURL(relativePath: "[456_token] Broken") + try FileManager.default.createDirectory(at: brokenGalleryURL, withIntermediateDirectories: true) + // User folders are listed even when empty. + let emptyFolderURL = storage.userFolderURL(name: "Empty Folder") + try FileManager.default.createDirectory(at: emptyFolderURL, withIntermediateDirectories: true) + // A populated user folder yields records carrying its name. + let galleryFolderURL = storage.folderURL(relativePath: "Library/[789_token] Inside") + try FileManager.default.createDirectory(at: galleryFolderURL, withIntermediateDirectories: true) + try storage.writeManifest( + DownloadManifest( + gid: "789", + host: .ehentai, + token: "token", + title: "Inside", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: nil, + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: [1: "", 2: ""] + ), + folderURL: galleryFolderURL + ) + + let scanResult = try storage.scanDownloads() + + #expect(scanResult.userFolders == ["Empty Folder", "Library"]) + #expect(scanResult.records.map(\.relativePath) == ["Library/[789_token] Inside"]) + #expect(scanResult.records.first?.parentFolderName == "Library") + } + + @Test + func testUserFolderNameNormalizationRejectsInvalidNames() { + let (storage, _) = makeStorage() + + #expect(storage.normalizedUserFolderName(" My Folder ") == "My Folder") + #expect(storage.normalizedUserFolderName("a/b:c") == "a b c") + #expect(storage.normalizedUserFolderName("...") == nil) + #expect(storage.normalizedUserFolderName(" ") == nil) + #expect(storage.normalizedUserFolderName("") == nil) + #expect(storage.normalizedUserFolderName(".hidden") == "hidden") + #expect(storage.normalizedUserFolderName("[123_token] Sample") == nil) } @Test @@ -304,7 +363,7 @@ struct DownloadFileStorageTests { defer { try? FileManager.default.removeItem(at: rootURL) } try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[123_token] Sample") + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) let pageURL = folderURL.appendingPathComponent("123_token_2.webp") let coverURL = folderURL.appendingPathComponent("123_token_cover.jpg") diff --git a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift new file mode 100644 index 000000000..8f3b66d4a --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift @@ -0,0 +1,229 @@ +// +// DownloadFolderOperationTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadFolderOperationTests: DownloadFeatureTestCase { + @Test + func testCreateFolderListsFolderAndRejectsDuplicatesAndInvalidNames() async throws { + let (storage, manager, rootURL) = makeManager() + defer { try? FileManager.default.removeItem(at: rootURL) } + try storage.ensureRootDirectory() + + let created = await manager.createFolder(name: " Favorites ") + guard case .success = created else { + Issue.record("Expected create to succeed, got \(created)") + return + } + #expect(await manager.fetchFolders() == ["Favorites"]) + + let duplicate = await manager.createFolder(name: "Favorites") + guard case .failure = duplicate else { + Issue.record("Expected duplicate create to fail") + return + } + + let invalid = await manager.createFolder(name: " ") + guard case .failure = invalid else { + Issue.record("Expected invalid name to fail") + return + } + + let galleryLike = await manager.createFolder(name: "[123_token] Sample") + guard case .failure = galleryLike else { + Issue.record("Expected gallery-like name to fail") + return + } + } + + @Test + func testRenameFolderRepointsContainedDownloads() async throws { + let (storage, manager, rootURL) = makeManager() + defer { try? FileManager.default.removeItem(at: rootURL) } + let gid = "311" + try writeGalleryFolder(storage: storage, folderName: "Old Name", gid: gid) + + let result = await manager.renameFolder(oldName: "Old Name", newName: "New Name") + guard case .success = result else { + Issue.record("Expected rename to succeed, got \(result)") + return + } + + let download = await manager.testingFetchDownload(gid: gid) + #expect(await manager.fetchFolders() == ["New Name"]) + #expect(download?.folderName == "New Name") + #expect(download?.folderURL.path.contains("/New Name/") == true) + } + + @Test + func testRenameFolderRejectsActiveDownloadInside() async throws { + let (storage, manager, rootURL) = makeManager() + defer { try? FileManager.default.removeItem(at: rootURL) } + let gid = "312" + try writeGalleryFolder(storage: storage, folderName: "Busy", gid: gid) + _ = await manager.reconcileDownloads() + let blockingTask = Task { _ = try? await Task.sleep(for: .seconds(60)) } + defer { blockingTask.cancel() } + await manager.testingInstallActiveTask(gid: gid, task: blockingTask) + + let result = await manager.renameFolder(oldName: "Busy", newName: "Renamed") + guard case .failure = result else { + Issue.record("Expected rename to fail while downloading") + return + } + #expect(await manager.fetchFolders() == ["Busy"]) + } + + @Test + func testDeleteFolderRemovesContainedDownloadsAndQueueIntents() async throws { + let (storage, manager, rootURL) = makeManager() + defer { try? FileManager.default.removeItem(at: rootURL) } + let gid = "313" + let folderURL = try writeGalleryFolder(storage: storage, folderName: "Doomed", gid: gid) + await manager.testingSetQueuedGalleryIDs([gid]) + + let result = await manager.deleteFolder(name: "Doomed") + guard case .success = result else { + Issue.record("Expected delete to succeed, got \(result)") + return + } + + #expect(await manager.fetchFolders().isEmpty) + #expect(await manager.testingFetchDownload(gid: gid) == nil) + #expect(!FileManager.default.fileExists(atPath: folderURL.path)) + } + + @Test + func testMoveDownloadRelocatesGalleryFolder() async throws { + let (storage, manager, rootURL) = makeManager() + defer { try? FileManager.default.removeItem(at: rootURL) } + let gid = "314" + let sourceURL = try writeGalleryFolder(storage: storage, folderName: "Source", gid: gid) + + let result = await manager.moveDownload(gid: gid, toFolderName: "Target") + guard case .success = result else { + Issue.record("Expected move to succeed, got \(result)") + return + } + + let download = await manager.testingFetchDownload(gid: gid) + #expect(download?.folderName == "Target") + #expect(download?.folderURL.path.contains("/Target/") == true) + #expect(!FileManager.default.fileExists(atPath: sourceURL.path)) + #expect(await manager.fetchFolders() == ["Source", "Target"]) + } + + @Test + func testMoveDownloadIntoSameFolderIsNoOp() async throws { + let (storage, manager, rootURL) = makeManager() + defer { try? FileManager.default.removeItem(at: rootURL) } + let gid = "315" + let folderURL = try writeGalleryFolder(storage: storage, folderName: "Home", gid: gid) + + let result = await manager.moveDownload(gid: gid, toFolderName: "Home") + guard case .success = result else { + Issue.record("Expected same-folder move to succeed, got \(result)") + return + } + #expect(FileManager.default.fileExists(atPath: folderURL.path)) + } + + @Test + func testMoveDownloadRejectsActivelyDownloadingGallery() async throws { + let (storage, manager, rootURL) = makeManager() + defer { try? FileManager.default.removeItem(at: rootURL) } + let gid = "316" + let folderURL = try writeGalleryFolder(storage: storage, folderName: "Working", gid: gid) + _ = await manager.reconcileDownloads() + let blockingTask = Task { _ = try? await Task.sleep(for: .seconds(60)) } + defer { blockingTask.cancel() } + await manager.testingInstallActiveTask(gid: gid, task: blockingTask) + + let result = await manager.moveDownload(gid: gid, toFolderName: "Elsewhere") + guard case .failure = result else { + Issue.record("Expected move of active download to fail") + return + } + #expect(FileManager.default.fileExists(atPath: folderURL.path)) + } + + @Test + func testEnqueueKeepsExistingDownloadInItsFolder() async throws { + let (storage, manager, rootURL) = makeManager() + defer { try? FileManager.default.removeItem(at: rootURL) } + await manager.testingInstallActiveTask(gid: "busy", task: Task {}) + + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let galleryFolderName = storage.makeFolderRelativePath( + gid: gallery.gid, + token: gallery.token, + title: detail.trimmedTitle + ) + try writeGalleryFolder( + storage: storage, + folderName: "Original", + gid: gallery.gid, + galleryFolderName: galleryFolderName + ) + _ = await manager.reconcileDownloads() + + let payload = DownloadRequestPayload( + gallery: gallery, + galleryDetail: detail, + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + folderName: "Requested Elsewhere", + options: .init(), + mode: .initial + ) + let result = await manager.enqueue(payload: payload) + guard case .success = result else { + Issue.record("Expected enqueue to succeed, got \(result)") + return + } + + let download = await manager.testingFetchDownload(gid: gallery.gid) + #expect(download?.folderName == "Original") + } +} + +// MARK: - Setup Helpers + +private extension DownloadFolderOperationTests { + func makeManager() -> (DownloadFileStorage, DownloadManager, URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + return (storage, manager, rootURL) + } + + @discardableResult + func writeGalleryFolder( + storage: DownloadFileStorage, + folderName: String, + gid: String, + galleryFolderName: String? = nil + ) throws -> URL { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL( + relativePath: "\(folderName)/\(galleryFolderName ?? "[\(gid)_token] Sample")" + ) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: "Sample", pageCount: 2), + folderURL: folderURL + ) + return folderURL + } +} diff --git a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift index d8e46bb1d..fbc1d73ab 100644 --- a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -171,7 +171,7 @@ private extension DownloadInterruptedResumeTests { pageHashes: [String] ) throws -> URL { try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[\(gid)_token] \(title)") + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") try FileManager.default.createDirectory( at: folderURL, withIntermediateDirectories: true @@ -230,7 +230,7 @@ private extension DownloadInterruptedResumeTests { sizeCount: 1, sizeType: "MB", torrentCount: 0 ), previewURLs: [:], previewConfig: .normal(rows: 4), - host: .ehentai, options: .init(), mode: mode + host: .ehentai, folderName: "Folder", options: .init(), mode: mode ) } } diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index d50e3c156..cafe8084e 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -24,7 +24,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { urlSession: .shared ) - let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Capture", isDirectory: true) + let completedFolderURL = rootURL.appendingPathComponent("Folder/\(gid) - Capture", isDirectory: true) try FileManager.default.createDirectory( at: completedFolderURL, withIntermediateDirectories: true @@ -112,7 +112,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { private extension DownloadManagerCaptureTests { func setupCaptureMissingFilesFolder(rootURL: URL, gid: String) throws -> URL { - let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) + let completedFolderURL = rootURL.appendingPathComponent("Folder/\(gid) - Pause Race", isDirectory: true) try FileManager.default.createDirectory( at: completedFolderURL, withIntermediateDirectories: true diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index e705a0184..169d6aadb 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -23,7 +23,7 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { let manager = DownloadManager(storage: storage, urlSession: .shared) try storage.ensureRootDirectory() - let sourceFolderURL = storage.folderURL(relativePath: "[\(gid)_token] Existing") + let sourceFolderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Existing") let existingDownload = sampleDownload( gid: gid, title: "Mixed Version", status: .missingFiles, pageCount: 2, completedPageCount: 2, @@ -226,7 +226,7 @@ private extension DownloadManagerRepairSeedTests { sizeCount: 1, sizeType: "MB", torrentCount: 0 ), previewURLs: [:], previewConfig: .normal(rows: 4), - host: .ehentai, options: .init(), mode: .repair + host: .ehentai, folderName: "Folder", options: .init(), mode: .repair ) } @@ -234,7 +234,7 @@ private extension DownloadManagerRepairSeedTests { rootURL: URL, gid: String, storage: DownloadFileStorage ) throws -> (URL, URL) { let completedFolderURL = rootURL.appendingPathComponent( - "\(gid) - Pause Race", isDirectory: true + "Folder/\(gid) - Pause Race", isDirectory: true ) try FileManager.default.createDirectory( at: completedFolderURL, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 874dd7aa3..ba045df8d 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -26,7 +26,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[100_token] Complete", + relativePath: "Folder/[100_token] Complete", manifest: indexedManifest( gid: "100", title: "Complete", @@ -36,7 +36,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) try writeIndexedManifest( storage: storage, - relativePath: "[200_token] Queued", + relativePath: "Folder/[200_token] Queued", manifest: indexedManifest( gid: "200", title: "Queued", @@ -80,7 +80,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[500_token] Old", + relativePath: "Folder/[500_token] Old", manifest: indexedManifest( gid: "500", title: "Old", @@ -91,11 +91,11 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try setFolderModificationDate( olderDate, storage: storage, - relativePath: "[500_token] Old" + relativePath: "Folder/[500_token] Old" ) try writeIndexedManifest( storage: storage, - relativePath: "[500_token] New", + relativePath: "Folder/[500_token] New", manifest: indexedManifest( gid: "500", title: "New", @@ -106,7 +106,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try setFolderModificationDate( newerDate, storage: storage, - relativePath: "[500_token] New" + relativePath: "Folder/[500_token] New" ) let downloads = await manager.reloadDownloadIndex() @@ -114,7 +114,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(downloads.map(\.gid) == ["500"]) let download = try #require(downloads.first) #expect(download.title == "New") - #expect(download.folderURL == storage.folderURL(relativePath: "[500_token] New")) + #expect(download.folderURL == storage.folderURL(relativePath: "Folder/[500_token] New")) #expect(download.lastDownloadedAt == newerDate) #expect((await manager.indexedDownload(gid: "500")) == download) } @@ -134,7 +134,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[600_token] Disk", + relativePath: "Folder/[600_token] Disk", manifest: indexedManifest( gid: "600", title: "Disk", @@ -171,7 +171,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[700_token] Observed", + relativePath: "Folder/[700_token] Observed", manifest: indexedManifest( gid: "700", title: "Observed", @@ -213,7 +213,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[300_token] Updated", + relativePath: "Folder/[300_token] Updated", manifest: indexedManifest( gid: "300", title: "Updated", @@ -222,7 +222,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) try writeIndexedManifest( storage: storage, - relativePath: "[400_token] Failed", + relativePath: "Folder/[400_token] Failed", manifest: indexedManifest( gid: "400", title: "Failed", @@ -263,7 +263,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[410_token] Cancelled", + relativePath: "Folder/[410_token] Cancelled", manifest: indexedManifest( gid: "410", title: "Cancelled", @@ -298,7 +298,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[420_token] Interrupted", + relativePath: "Folder/[420_token] Interrupted", manifest: indexedManifest( gid: "420", title: "Interrupted", @@ -334,7 +334,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[430_token] Sanitize", + relativePath: "Folder/[430_token] Sanitize", manifest: indexedManifest( gid: "430", title: "Sanitize", @@ -368,7 +368,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[440_token] Missing", + relativePath: "Folder/[440_token] Missing", manifest: indexedManifest( gid: "440", title: "Missing", @@ -402,7 +402,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[450_token] Retry", + relativePath: "Folder/[450_token] Retry", manifest: indexedManifest( gid: "450", title: "Retry", @@ -444,7 +444,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) try storage.ensureRootDirectory() - let folderRelativePath = "[460_token] Retry Pages" + let folderRelativePath = "Folder/[460_token] Retry Pages" try writeIndexedManifest( storage: storage, relativePath: folderRelativePath, @@ -501,7 +501,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[800_token] Failing", + relativePath: "Folder/[800_token] Failing", manifest: indexedManifest( gid: "800", title: "Failing", @@ -546,7 +546,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: "Complete") - let folderRelativePath = storage.makeFolderRelativePath( + let folderRelativePath = "Folder/" + storage.makeFolderRelativePath( gid: gallery.gid, token: gallery.token, title: detail.trimmedTitle @@ -599,7 +599,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[820_token] Pausable", + relativePath: "Folder/[820_token] Pausable", manifest: indexedManifest( gid: "820", title: "Pausable", @@ -667,7 +667,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { try storage.ensureRootDirectory() try writeIndexedManifest( storage: storage, - relativePath: "[830_token] First", + relativePath: "Folder/[830_token] First", manifest: indexedManifest( gid: "830", title: "First", @@ -677,7 +677,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) try writeIndexedManifest( storage: storage, - relativePath: "[831_token] Newer", + relativePath: "Folder/[831_token] Newer", manifest: indexedManifest( gid: "831", title: "Newer", @@ -713,7 +713,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) try storage.ensureRootDirectory() - let folderRelativePath = "[840_token] Progress" + let folderRelativePath = "Folder/[840_token] Progress" try writeIndexedManifest( storage: storage, relativePath: folderRelativePath, @@ -768,7 +768,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) try storage.ensureRootDirectory() - let folderRelativePath = "[\(gid)_token] Inspect" + let folderRelativePath = "Folder/[\(gid)_token] Inspect" try writeIndexedManifest( storage: storage, relativePath: folderRelativePath, @@ -819,7 +819,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { urlSession: .shared ) - let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) + let completedFolderURL = rootURL.appendingPathComponent("Folder/\(gid) - Pause Race", isDirectory: true) try FileManager.default.createDirectory( at: completedFolderURL, withIntermediateDirectories: true @@ -863,7 +863,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { urlSession: .shared ) - let completedFolderURL = rootURL.appendingPathComponent("\(gid) - Pause Race", isDirectory: true) + let completedFolderURL = rootURL.appendingPathComponent("Folder/\(gid) - Pause Race", isDirectory: true) try FileManager.default.createDirectory( at: completedFolderURL, withIntermediateDirectories: true diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 057209ede..a466056df 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -73,7 +73,7 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) - let folderRelativePath = "\(gid) - Progress Flush" + let folderRelativePath = "Folder/\(gid) - Progress Flush" let folderURL = storage.folderURL(relativePath: folderRelativePath) try FileManager.default.createDirectory( at: folderURL, diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index 7630a164f..afeda4038 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -94,7 +94,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { pageHashes: ["sha256:done", ""] ) - let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Pausable") + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Pausable") try FileManager.default.createDirectory( at: folderURL, withIntermediateDirectories: true @@ -260,7 +260,7 @@ private extension DownloadPauseAndReconcileTests { pageHashes: [String] ) throws { try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[\(gid)_token] \(title)") + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") try FileManager.default.createDirectory( at: folderURL, withIntermediateDirectories: true @@ -292,7 +292,7 @@ private extension DownloadPauseAndReconcileTests { storage: DownloadFileStorage, gid: String ) throws { - let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Inspection") + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Inspection") try FileManager.default.createDirectory( at: folderURL, withIntermediateDirectories: true diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index ffc9a9c53..6e906a8f6 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -247,7 +247,7 @@ private extension DownloadProcessCacheTests { pageCount: Int, missingPageIndex: Int ) throws { - let completedFolderURL = storage.folderURL(relativePath: "\(gid) - Pause Race") + let completedFolderURL = storage.folderURL(relativePath: "Folder/\(gid) - Pause Race") try FileManager.default.createDirectory( at: completedFolderURL, withIntermediateDirectories: true diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 992633b24..8c8ec8962 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -209,7 +209,7 @@ private extension DownloadProcessTests { pageCount: Int ) throws { try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[\(gid)_token] \(title)") + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") try FileManager.default.createDirectory( at: folderURL, withIntermediateDirectories: true @@ -262,7 +262,7 @@ private extension DownloadProcessTests { gid: gid, title: "Pause Race", pageCount: oldPageCount ) - let folderURL = storage.folderURL(relativePath: "\(gid) - Pause Race") + let folderURL = storage.folderURL(relativePath: "Folder/\(gid) - Pause Race") try? FileManager.default.removeItem(at: folderURL) try FileManager.default.createDirectory( at: folderURL, diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index fe197a51b..5d08d4a67 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -119,7 +119,7 @@ private extension DownloadRetryMinimalSourceTests { missingPageIndex: Int ) throws { try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Pause Race") + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Pause Race") try FileManager.default.createDirectory( at: folderURL, withIntermediateDirectories: true diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index a02b6aa85..c87fc0feb 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -103,7 +103,7 @@ private extension DownloadRetryPagesTests { pageHashes: [String] ) throws -> URL { try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[\(gid)_token] \(title)") + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") try FileManager.default.createDirectory( at: folderURL, withIntermediateDirectories: true diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 9e69258bd..97789d05d 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -159,7 +159,7 @@ private extension DownloadRetryUpdateFallbackTests { pageCount: Int ) throws { try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Pause Race") + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Pause Race") try FileManager.default.createDirectory( at: folderURL, withIntermediateDirectories: true diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index 2e5a74f4c..052cd4f24 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -31,7 +31,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { } try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Queued") + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Queued") try FileManager.default.createDirectory( at: folderURL, withIntermediateDirectories: true diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 1a250c5b2..6b04ef5b3 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -19,7 +19,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Indexed") + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Indexed") try FileManager.default.createDirectory( at: folderURL, withIntermediateDirectories: true @@ -64,7 +64,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { urlSession: .shared ) try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "[\(gid)_token] Indexed") + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Indexed") try FileManager.default.createDirectory( at: folderURL, withIntermediateDirectories: true diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index 5e508f353..7aa08fcdf 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -41,7 +41,8 @@ struct DownloadedGalleryManifestModelTests { let download = DownloadedGallery( manifest: manifest, - folderURL: URL(fileURLWithPath: "/tmp/[123_token] Sample", isDirectory: true), + folderURL: URL(fileURLWithPath: "/tmp/Folder/[123_token] Sample", isDirectory: true), + folderName: "Folder", localCoverURL: nil, localPageURLs: [:], modifiedAt: modifiedAt, From 5869365c276e840c4f26a9833262f022a69c15ee Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 18:36:15 +0800 Subject: [PATCH 159/614] Add folder manager sheet --- EhPanda/App/Generated/Strings.swift | 24 +++ EhPanda/App/de.lproj/Localizable.strings | 6 + EhPanda/App/en.lproj/Localizable.strings | 6 + EhPanda/App/ja.lproj/Localizable.strings | 6 + EhPanda/App/ko.lproj/Localizable.strings | 6 + EhPanda/App/zh-Hans.lproj/Localizable.strings | 6 + .../App/zh-Hant-HK.lproj/Localizable.strings | 6 + .../App/zh-Hant-TW.lproj/Localizable.strings | 6 + EhPanda/App/zh-Hant.lproj/Localizable.strings | 6 + .../View/Downloads/FolderManagerReducer.swift | 115 ++++++++++++ .../View/Downloads/FolderManagerView.swift | 169 ++++++++++++++++++ .../Download/FolderManagerReducerTests.swift | 151 ++++++++++++++++ 12 files changed, 507 insertions(+) create mode 100644 EhPanda/View/Downloads/FolderManagerReducer.swift create mode 100644 EhPanda/View/Downloads/FolderManagerView.swift create mode 100644 EhPandaTests/Tests/Download/FolderManagerReducerTests.swift diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 6acde1ecc..ddd5e02db 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -2025,6 +2025,30 @@ internal enum L10n { internal static let setPagesRange = L10n.tr("Localizable", "filters_view.title.set_pages_range", fallback: "Set pages range") } } + internal enum FolderManagerView { + internal enum Dialog { + internal enum Message { + /// This will delete the folder and all downloaded galleries inside it. + internal static let deleteFolder = L10n.tr("Localizable", "folder_manager_view.dialog.message.delete_folder", fallback: "This will delete the folder and all downloaded galleries inside it.") + } + } + internal enum EmptyState { + /// Folders you create will appear here. + internal static let folders = L10n.tr("Localizable", "folder_manager_view.empty_state.folders", fallback: "Folders you create will appear here.") + } + internal enum Placeholder { + /// Folder name + internal static let folderName = L10n.tr("Localizable", "folder_manager_view.placeholder.folder_name", fallback: "Folder name") + } + internal enum Title { + /// Folders + internal static let folders = L10n.tr("Localizable", "folder_manager_view.title.folders", fallback: "Folders") + /// New Folder + internal static let newFolder = L10n.tr("Localizable", "folder_manager_view.title.new_folder", fallback: "New Folder") + /// Rename Folder + internal static let renameFolder = L10n.tr("Localizable", "folder_manager_view.title.rename_folder", fallback: "Rename Folder") + } + } internal enum FrontpageView { internal enum Title { /// Frontpage diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 918883659..91034c031 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -957,6 +957,12 @@ "detail_view.dialog.button.update" = "Aktualisieren"; "detail_view.dialog.button.redownload" = "Erneut laden"; "detail_view.offline_notice.saved_details" = "Online-Details konnten nicht aktualisiert werden. Stattdessen werden gespeicherte Details angezeigt."; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.title.new_folder" = "New Folder"; +"folder_manager_view.title.rename_folder" = "Rename Folder"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; "downloads_view.title.downloads" = "Downloads"; "downloads_view.search.prompt.downloads" = "Downloads durchsuchen"; "downloads_view.dialog.title.delete_download" = "Download löschen?"; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index ed609d43c..c72462eb7 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -387,6 +387,12 @@ "tag_detail_view.section.title.links" = "Links"; // MARK: DownloadsView +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.title.new_folder" = "New Folder"; +"folder_manager_view.title.rename_folder" = "Rename Folder"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; "downloads_view.title.downloads" = "Downloads"; "downloads_view.search.prompt.downloads" = "Search downloads"; "downloads_view.dialog.title.delete_download" = "Delete Download?"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index da5e49c2c..fe31755b3 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -957,6 +957,12 @@ "detail_view.dialog.button.update" = "更新"; "detail_view.dialog.button.redownload" = "再ダウンロード"; "detail_view.offline_notice.saved_details" = "オンラインの詳細を更新できなかったため、保存済みの詳細を表示しています。"; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.title.new_folder" = "New Folder"; +"folder_manager_view.title.rename_folder" = "Rename Folder"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; "downloads_view.title.downloads" = "ダウンロード"; "downloads_view.search.prompt.downloads" = "ダウンロードを検索"; "downloads_view.dialog.title.delete_download" = "ダウンロードを削除しますか?"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index a862a8bb6..b6edcb5f2 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -957,6 +957,12 @@ "detail_view.dialog.button.update" = "업데이트"; "detail_view.dialog.button.redownload" = "다시 다운로드"; "detail_view.offline_notice.saved_details" = "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다."; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.title.new_folder" = "New Folder"; +"folder_manager_view.title.rename_folder" = "Rename Folder"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; "downloads_view.title.downloads" = "다운로드"; "downloads_view.search.prompt.downloads" = "다운로드 검색"; "downloads_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 46f99f459..58be2d49d 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -387,6 +387,12 @@ "tag_detail_view.section.title.links" = "链接"; // MARK: DownloadsView +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.title.new_folder" = "New Folder"; +"folder_manager_view.title.rename_folder" = "Rename Folder"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; "downloads_view.title.downloads" = "下载"; "downloads_view.search.prompt.downloads" = "搜索下载"; "downloads_view.dialog.title.delete_download" = "删除下载?"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index fd15fa1a9..7a1c9c852 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -954,6 +954,12 @@ "detail_view.dialog.button.update" = "更新"; "detail_view.dialog.button.redownload" = "重新下載"; "detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.title.new_folder" = "New Folder"; +"folder_manager_view.title.rename_folder" = "Rename Folder"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; "downloads_view.title.downloads" = "下載"; "downloads_view.search.prompt.downloads" = "搜尋下載"; "downloads_view.dialog.title.delete_download" = "刪除下載?"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 58ddc3dd6..5a0119c56 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -955,6 +955,12 @@ "detail_view.dialog.button.update" = "更新"; "detail_view.dialog.button.redownload" = "重新下載"; "detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.title.new_folder" = "New Folder"; +"folder_manager_view.title.rename_folder" = "Rename Folder"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; "downloads_view.title.downloads" = "下載"; "downloads_view.search.prompt.downloads" = "搜尋下載"; "downloads_view.dialog.title.delete_download" = "刪除下載?"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 3727c360b..19b3969fd 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -955,6 +955,12 @@ "detail_view.dialog.button.update" = "更新"; "detail_view.dialog.button.redownload" = "重新下載"; "detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.title.new_folder" = "New Folder"; +"folder_manager_view.title.rename_folder" = "Rename Folder"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; "downloads_view.title.downloads" = "下載"; "downloads_view.search.prompt.downloads" = "搜尋下載"; "downloads_view.dialog.title.delete_download" = "刪除下載?"; diff --git a/EhPanda/View/Downloads/FolderManagerReducer.swift b/EhPanda/View/Downloads/FolderManagerReducer.swift new file mode 100644 index 000000000..65c6f84a8 --- /dev/null +++ b/EhPanda/View/Downloads/FolderManagerReducer.swift @@ -0,0 +1,115 @@ +// +// FolderManagerReducer.swift +// EhPanda +// + +import SwiftUI +import ComposableArchitecture + +@Reducer +struct FolderManagerReducer { + @CasePathable + enum Route: Equatable { + case newFolder + case renameFolder(String) + case deleteFolder(String) + } + + private enum CancelID { + case fetchFolders + } + + @ObservableState + struct State: Equatable { + var route: Route? + var editingFolderName = "" + var loadingState: LoadingState = .idle + var folders = [String]() + + var isEditingNameValid: Bool { + let trimmedName = editingFolderName + .trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmedName.isEmpty && !folders.contains(trimmedName) + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + + case createFolder + case createFolderDone(Result) + case renameFolder(String) + case renameFolderDone(Result) + case deleteFolder(String) + case deleteFolderDone(Result) + + case teardown + case fetchFolders + case fetchFoldersDone([String]) + } + + @Dependency(\.downloadClient) private var downloadClient + + var body: some Reducer { + BindingReducer() + .onChange(of: \.route) { _, state in + state.route == nil ? .send(.clearSubStates) : .none + } + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .send(.clearSubStates) : .none + + case .clearSubStates: + state.editingFolderName = "" + return .none + + case .createFolder: + return .run { [name = state.editingFolderName] send in + await send(.createFolderDone(await downloadClient.createFolder(name))) + } + + case .createFolderDone: + return .send(.fetchFolders) + + case .renameFolder(let oldName): + return .run { [newName = state.editingFolderName] send in + await send(.renameFolderDone(await downloadClient.renameFolder(oldName, newName))) + } + + case .renameFolderDone: + return .send(.fetchFolders) + + case .deleteFolder(let name): + return .run { send in + await send(.deleteFolderDone(await downloadClient.deleteFolder(name))) + } + + case .deleteFolderDone: + return .send(.fetchFolders) + + case .teardown: + return .cancel(id: CancelID.fetchFolders) + + case .fetchFolders: + state.loadingState = .loading + return .run { send in + await send(.fetchFoldersDone(await downloadClient.fetchFolders())) + } + .cancellable(id: CancelID.fetchFolders, cancelInFlight: true) + + case .fetchFoldersDone(let folders): + state.loadingState = .idle + state.folders = folders + return .none + } + } + } +} diff --git a/EhPanda/View/Downloads/FolderManagerView.swift b/EhPanda/View/Downloads/FolderManagerView.swift new file mode 100644 index 000000000..06cc05e5d --- /dev/null +++ b/EhPanda/View/Downloads/FolderManagerView.swift @@ -0,0 +1,169 @@ +// +// FolderManagerView.swift +// EhPanda +// + +import SwiftUI +import SFSafeSymbols +import ComposableArchitecture + +struct FolderManagerView: View { + @Bindable private var store: StoreOf + @Environment(\.dismiss) private var dismiss + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + NavigationView { + ZStack { + List { + ForEach(store.folders, id: \.self) { folder in + Label(folder, systemSymbol: .folder) + .padding(5) + .swipeActions(edge: .trailing) { + Button { + store.send(.setNavigation(.deleteFolder(folder))) + } label: { + Image(systemSymbol: .trash) + } + .tint(.red) + Button { + store.editingFolderName = folder + store.send(.setNavigation(.renameFolder(folder))) + } label: { + Image(systemSymbol: .squareAndPencil) + } + } + .confirmationDialog( + message: L10n.Localizable.FolderManagerView.Dialog.Message.deleteFolder, + unwrapping: $store.route, + case: \.deleteFolder, + matching: folder + ) { route in + Button(L10n.Localizable.ConfirmationDialog.Button.delete, role: .destructive) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + store.send(.deleteFolder(route)) + } + } + } + } + } + + LoadingView().opacity( + store.loadingState == .loading && store.folders.isEmpty ? 1 : 0 + ) + AlertView( + symbol: .folder, + message: L10n.Localizable.FolderManagerView.EmptyState.folders + ) { + EmptyView() + } + .opacity( + store.loadingState != .loading && store.folders.isEmpty ? 1 : 0 + ) + } + .animation(.default, value: store.folders) + .onAppear { + store.send(.fetchFolders) + } + .toolbar(content: toolbar) + .background(navigationLinks) + .navigationTitle(L10n.Localizable.FolderManagerView.Title.folders) + .navigationBarTitleDisplayMode(.inline) + } + } + + private func toolbar() -> some ToolbarContent { + Group { + ToolbarItem(placement: .cancellationAction) { + Button(role: .close, action: dismiss.callAsFunction) + } + CustomToolbarItem { + Button { + store.editingFolderName = "" + store.send(.setNavigation(.newFolder)) + } label: { + Image(systemSymbol: .plus) + } + } + } + } + + @ViewBuilder private var navigationLinks: some View { + NavigationLink(unwrapping: $store.route, case: \.newFolder) { _ in + EditFolderView( + title: L10n.Localizable.FolderManagerView.Title.newFolder, + folderName: $store.editingFolderName, + isNameValid: store.isEditingNameValid, + confirmAction: { + store.send(.createFolder) + store.send(.setNavigation(nil)) + } + ) + } + NavigationLink(unwrapping: $store.route, case: \.renameFolder) { route in + EditFolderView( + title: L10n.Localizable.FolderManagerView.Title.renameFolder, + folderName: $store.editingFolderName, + isNameValid: store.isEditingNameValid, + confirmAction: { + store.send(.renameFolder(route.wrappedValue)) + store.send(.setNavigation(nil)) + } + ) + } + } +} + +extension FolderManagerView { + // MARK: EditFolderView + struct EditFolderView: View { + private let title: String + @Binding private var folderName: String + private let isNameValid: Bool + private let confirmAction: () -> Void + + init( + title: String, + folderName: Binding, + isNameValid: Bool, + confirmAction: @escaping () -> Void + ) { + self.title = title + _folderName = folderName + self.isNameValid = isNameValid + self.confirmAction = confirmAction + } + + var body: some View { + Form { + Section { + TextField( + L10n.Localizable.FolderManagerView.Placeholder.folderName, + text: $folderName + ) + .disableAutocorrection(true) + } + } + .toolbar(content: toolbar) + .navigationTitle(title) + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + Button(role: .confirm, action: confirmAction) + .disabled(!isNameValid) + } + } + } +} + +struct FolderManagerView_Previews: PreviewProvider { + static var previews: some View { + FolderManagerView( + store: .init(initialState: .init(), reducer: FolderManagerReducer.init) + ) + } +} diff --git a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift new file mode 100644 index 000000000..c66ecc6a3 --- /dev/null +++ b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift @@ -0,0 +1,151 @@ +// +// FolderManagerReducerTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +@MainActor +struct FolderManagerReducerTests: DownloadFeatureTestCase { + @MainActor + @Test + func testFetchFoldersPopulatesState() async { + let store = makeStore(folders: { ["Alpha", "Beta"] }) + + await store.send(.fetchFolders) { + $0.loadingState = .loading + } + await store.receive(\.fetchFoldersDone, ["Alpha", "Beta"]) { + $0.loadingState = .idle + $0.folders = ["Alpha", "Beta"] + } + } + + @MainActor + @Test + func testCreateFolderForwardsEditingNameAndRefetches() async { + let createdName = UncheckedBox(nil) + let store = makeStore( + folders: { createdName.value.map { [$0] } ?? [] }, + createFolder: { name in + createdName.value = name + return .success(()) + } + ) + store.exhaustivity = .off + + await store.send(.binding(.set(\.editingFolderName, "Favorites"))) + await store.send(.createFolder) + await store.receive(\.createFolderDone) + await store.receive(\.fetchFolders) + await store.receive(\.fetchFoldersDone) { + $0.folders = ["Favorites"] + } + + #expect(createdName.value == "Favorites") + } + + @MainActor + @Test + func testRenameFolderForwardsOriginalAndEditedNames() async { + let renamedPair = UncheckedBox<(String, String)?>(nil) + let store = makeStore( + folders: { ["New Name"] }, + renameFolder: { oldName, newName in + renamedPair.value = (oldName, newName) + return .success(()) + } + ) + store.exhaustivity = .off + + await store.send(.binding(.set(\.editingFolderName, "New Name"))) + await store.send(.renameFolder("Old Name")) + await store.receive(\.renameFolderDone) + await store.receive(\.fetchFoldersDone) { + $0.folders = ["New Name"] + } + + #expect(renamedPair.value?.0 == "Old Name") + #expect(renamedPair.value?.1 == "New Name") + } + + @MainActor + @Test + func testDeleteFolderForwardsNameAndRefetches() async { + let deletedName = UncheckedBox(nil) + let store = makeStore( + folders: { deletedName.value == nil ? ["Doomed"] : [] }, + deleteFolder: { name in + deletedName.value = name + return .success(()) + } + ) + store.exhaustivity = .off + + await store.send(.deleteFolder("Doomed")) + await store.receive(\.deleteFolderDone) + await store.receive(\.fetchFoldersDone) { + $0.folders = [] + } + + #expect(deletedName.value == "Doomed") + } + + @MainActor + @Test + func testEditingNameValidationRejectsBlankAndDuplicateNames() { + var state = FolderManagerReducer.State() + state.folders = ["Existing"] + + state.editingFolderName = " " + #expect(state.isEditingNameValid == false) + + state.editingFolderName = "Existing" + #expect(state.isEditingNameValid == false) + + state.editingFolderName = "Fresh" + #expect(state.isEditingNameValid) + } +} + +// MARK: - Store Factory Helpers + +private extension FolderManagerReducerTests { + func makeStore( + folders: @escaping @Sendable () -> [String], + createFolder: @escaping @Sendable (String) async -> Result + = { _ in .success(()) }, + renameFolder: @escaping @Sendable (String, String) async -> Result + = { _, _ in .success(()) }, + deleteFolder: @escaping @Sendable (String) async -> Result + = { _ in .success(()) } + ) -> TestStoreOf { + TestStore(initialState: FolderManagerReducer.State()) { + FolderManagerReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + fetchFolders: { folders() }, + createFolder: createFolder, + renameFolder: renameFolder, + deleteFolder: deleteFolder + ) + } + } +} From bcd9348cb94f800b0984fe4cc68b004ec27cc729 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 20:47:13 +0800 Subject: [PATCH 160/614] Filter and move downloads by folder --- EhPanda/App/Generated/Strings.swift | 16 +++ EhPanda/App/de.lproj/Localizable.strings | 4 + EhPanda/App/en.lproj/Localizable.strings | 4 + EhPanda/App/ja.lproj/Localizable.strings | 4 + EhPanda/App/ko.lproj/Localizable.strings | 4 + EhPanda/App/zh-Hans.lproj/Localizable.strings | 4 + .../App/zh-Hant-HK.lproj/Localizable.strings | 4 + .../App/zh-Hant-TW.lproj/Localizable.strings | 4 + EhPanda/App/zh-Hant.lproj/Localizable.strings | 4 + .../Download/DownloadFolderFilter.swift | 29 +++++ EhPanda/View/Downloads/DownloadsReducer.swift | 64 ++++++++++- EhPanda/View/Downloads/DownloadsView.swift | 100 ++++++++++++++++++ .../DownloadsReducerActionTests.swift | 84 +++++++++++++++ .../DownloadsReducerRefreshTests.swift | 4 + 14 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 EhPanda/Models/Download/DownloadFolderFilter.swift diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index ddd5e02db..c4af28287 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -720,6 +720,14 @@ internal enum L10n { internal static let downloadStatus = L10n.tr("Localizable", "downloads_view.inspector.title.download_status", fallback: "Download Status") } } + internal enum Menu { + internal enum Button { + /// Manage Folders + internal static let manageFolders = L10n.tr("Localizable", "downloads_view.menu.button.manage_folders", fallback: "Manage Folders") + /// Move to Folder + internal static let moveToFolder = L10n.tr("Localizable", "downloads_view.menu.button.move_to_folder", fallback: "Move to Folder") + } + } internal enum Search { internal enum Prompt { /// Search downloads @@ -728,6 +736,8 @@ internal enum L10n { } internal enum Swipe { internal enum Button { + /// Move + internal static let move = L10n.tr("Localizable", "downloads_view.swipe.button.move", fallback: "Move") /// Pages internal static let pages = L10n.tr("Localizable", "downloads_view.swipe.button.pages", fallback: "Pages") /// Pause @@ -1519,6 +1529,12 @@ internal enum L10n { internal static let western = L10n.tr("Localizable", "enum.category.value.western", fallback: "Western") } } + internal enum DownloadFolderFilter { + internal enum Title { + /// All + internal static let all = L10n.tr("Localizable", "enum.download_folder_filter.title.all", fallback: "All") + } + } internal enum EhSetting { internal enum ArchiverBehavior { internal enum Value { diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 91034c031..5f101fa18 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -957,6 +957,10 @@ "detail_view.dialog.button.update" = "Aktualisieren"; "detail_view.dialog.button.redownload" = "Erneut laden"; "detail_view.offline_notice.saved_details" = "Online-Details konnten nicht aktualisiert werden. Stattdessen werden gespeicherte Details angezeigt."; +"enum.download_folder_filter.title.all" = "All"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.title.rename_folder" = "Rename Folder"; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index c72462eb7..de71bf303 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -387,6 +387,10 @@ "tag_detail_view.section.title.links" = "Links"; // MARK: DownloadsView +"enum.download_folder_filter.title.all" = "All"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.title.rename_folder" = "Rename Folder"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index fe31755b3..6655f3de5 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -957,6 +957,10 @@ "detail_view.dialog.button.update" = "更新"; "detail_view.dialog.button.redownload" = "再ダウンロード"; "detail_view.offline_notice.saved_details" = "オンラインの詳細を更新できなかったため、保存済みの詳細を表示しています。"; +"enum.download_folder_filter.title.all" = "All"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.title.rename_folder" = "Rename Folder"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index b6edcb5f2..da29736f6 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -957,6 +957,10 @@ "detail_view.dialog.button.update" = "업데이트"; "detail_view.dialog.button.redownload" = "다시 다운로드"; "detail_view.offline_notice.saved_details" = "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다."; +"enum.download_folder_filter.title.all" = "All"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.title.rename_folder" = "Rename Folder"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 58be2d49d..df9fc7c00 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -387,6 +387,10 @@ "tag_detail_view.section.title.links" = "链接"; // MARK: DownloadsView +"enum.download_folder_filter.title.all" = "All"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.title.rename_folder" = "Rename Folder"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 7a1c9c852..ec2dbfc82 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -954,6 +954,10 @@ "detail_view.dialog.button.update" = "更新"; "detail_view.dialog.button.redownload" = "重新下載"; "detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; +"enum.download_folder_filter.title.all" = "All"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.title.rename_folder" = "Rename Folder"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 5a0119c56..a6cc321db 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -955,6 +955,10 @@ "detail_view.dialog.button.update" = "更新"; "detail_view.dialog.button.redownload" = "重新下載"; "detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; +"enum.download_folder_filter.title.all" = "All"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.title.rename_folder" = "Rename Folder"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 19b3969fd..3e7bebb79 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -955,6 +955,10 @@ "detail_view.dialog.button.update" = "更新"; "detail_view.dialog.button.redownload" = "重新下載"; "detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; +"enum.download_folder_filter.title.all" = "All"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.title.rename_folder" = "Rename Folder"; diff --git a/EhPanda/Models/Download/DownloadFolderFilter.swift b/EhPanda/Models/Download/DownloadFolderFilter.swift new file mode 100644 index 000000000..df7a60a94 --- /dev/null +++ b/EhPanda/Models/Download/DownloadFolderFilter.swift @@ -0,0 +1,29 @@ +// +// DownloadFolderFilter.swift +// EhPanda +// + +enum DownloadFolderFilter: Equatable { + case all + case folder(String) + + var title: String { + switch self { + case .all: + return L10n.Localizable.Enum.DownloadFolderFilter.Title.all + case .folder(let name): + return name + } + } +} + +extension DownloadedGallery { + func matches(folderFilter: DownloadFolderFilter) -> Bool { + switch folderFilter { + case .all: + return true + case .folder(let name): + return folderName == name + } + } +} diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index 4a194adad..cc141ebd2 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -13,16 +13,20 @@ struct DownloadsReducer { case inspector(String) case detail(String) case reading(String) + case folderManager(EquatableVoid = .init()) } private enum CancelID { case observeDownloads + case fetchFolders } @ObservableState struct State: Equatable { var route: Route? var keyword = "" + var folderFilter: DownloadFolderFilter = .all + var folders = [String]() var downloads = [DownloadedGallery]() var loadingState: LoadingState = .loading var hasLoadedInitialDownloads = false @@ -30,6 +34,7 @@ struct DownloadsReducer { var detailState: Heap var readingState = ReadingReducer.State() var inspectorState = DownloadInspectorReducer.State() + var folderManagerState = FolderManagerReducer.State() var readingRequestID = UUID() init() { @@ -38,8 +43,11 @@ struct DownloadsReducer { var filteredDownloads: [DownloadedGallery] { downloads.filter { - keyword.isEmpty - || $0.searchableText.caseInsensitiveContains(keyword) + $0.matches(folderFilter: folderFilter) + && ( + keyword.isEmpty + || $0.searchableText.caseInsensitiveContains(keyword) + ) } } } @@ -58,6 +66,10 @@ struct DownloadsReducer { case observeDownloadsDone([DownloadedGallery]) case refreshDownloads case refreshDownloadsDone + case fetchFolders + case fetchFoldersDone([String]) + case moveDownload(String, String) + case moveDownloadDone(Result) case openReading(String) case openReadingDone(UUID, String, Result<(DownloadedGallery, DownloadManifest), AppError>) case toggleDownloadPause(String) @@ -70,6 +82,7 @@ struct DownloadsReducer { case detail(DetailReducer.Action) case reading(ReadingReducer.Action) case inspector(DownloadInspectorReducer.Action) + case folderManager(FolderManagerReducer.Action) } @Dependency(\.downloadClient) private var downloadClient @@ -103,14 +116,16 @@ struct DownloadsReducer { state.detailState.wrappedValue = .init() state.readingState = .init() state.inspectorState = .init() + state.folderManagerState = .init() return .merge( .send(.detail(.teardown)), .send(.reading(.teardown)), - .send(.inspector(.teardown)) + .send(.inspector(.teardown)), + .send(.folderManager(.teardown)) ) case .onAppear: - guard !state.hasLoadedInitialDownloads else { return .none } + guard !state.hasLoadedInitialDownloads else { return .send(.fetchFolders) } state.hasLoadedInitialDownloads = true return .merge( .send(.fetchDownloads), @@ -119,7 +134,10 @@ struct DownloadsReducer { ) case .teardown: - return .cancel(id: CancelID.observeDownloads) + return .merge( + .cancel(id: CancelID.observeDownloads), + .cancel(id: CancelID.fetchFolders) + ) case .bootstrapDownloads: return .run { send in @@ -156,8 +174,35 @@ struct DownloadsReducer { } case .refreshDownloadsDone: + return .send(.fetchFolders) + + case .fetchFolders: + return .run { send in + await send(.fetchFoldersDone(await downloadClient.fetchFolders())) + } + .cancellable(id: CancelID.fetchFolders, cancelInFlight: true) + + case .fetchFoldersDone(let folders): + state.folders = folders + if case .folder(let name) = state.folderFilter, + !folders.contains(name) { + state.folderFilter = .all + } return .none + case .moveDownload(let gid, let folderName): + return .run { send in + await send(.moveDownloadDone(await downloadClient.moveDownload(gid, folderName))) + } + + case .moveDownloadDone(let result): + if case .failure = result { + return .run { _ in + await downloadClient.reconcileDownloads() + } + } + return .send(.fetchFolders) + case .openReading(let gid): let requestID = UUID() state.readingRequestID = requestID @@ -223,12 +268,21 @@ struct DownloadsReducer { case .inspector: return .none + + case .folderManager(.createFolderDone), + .folderManager(.renameFolderDone), + .folderManager(.deleteFolderDone): + return .send(.fetchFolders) + + case .folderManager: + return .none } } Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) Scope(state: \.readingState, action: \.reading, child: ReadingReducer.init) Scope(state: \.inspectorState, action: \.inspector, child: DownloadInspectorReducer.init) + Scope(state: \.folderManagerState, action: \.folderManager, child: FolderManagerReducer.init) } } diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift index 8e43517ed..8f17a30d0 100644 --- a/EhPanda/View/Downloads/DownloadsView.swift +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -21,6 +21,7 @@ struct DownloadsView: View { @Bindable private var store: StoreOf @State private var rowDialog: RowDialog? + @State private var moveDialogDownload: DownloadedGallery? @Binding private var setting: Setting private let user: User private let blurRadius: Double @@ -100,6 +101,13 @@ struct DownloadsView: View { .autoBlur(radius: blurRadius) .navigationViewStyle(.stack) } + .sheet(item: $store.route.sending(\.setNavigation).folderManager) { _ in + FolderManagerView( + store: store.scope(state: \.folderManagerState, action: \.folderManager) + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } .fullScreenCover(item: $store.route.sending(\.setNavigation).reading, id: \.self) { route in ReadingView( store: store.scope(state: \.readingState, action: \.reading), @@ -141,9 +149,29 @@ struct DownloadsView: View { ) } } + .confirmationDialog( + L10n.Localizable.DownloadsView.Menu.Button.moveToFolder, + isPresented: Binding( + get: { moveDialogDownload != nil }, + set: { if !$0 { moveDialogDownload = nil } } + ), + titleVisibility: .visible, + presenting: moveDialogDownload + ) { download in + ForEach(moveDestinations(for: download), id: \.self) { folder in + Button(folder) { + store.send(.moveDownload(download.gid, folder)) + moveDialogDownload = nil + } + } + Button(L10n.Localizable.Common.Button.cancel, role: .cancel) { + moveDialogDownload = nil + } + } .background(navigationLink) .navigationTitle(L10n.Localizable.DownloadsView.Title.downloads) .navigationBarTitleDisplayMode(.large) + .toolbar(content: toolbar) } } @@ -181,6 +209,18 @@ private extension DownloadsView { ) } .tint(setting.accentColor) + + if canMove(download) { + Button { + moveDialogDownload = download + } label: { + Label( + L10n.Localizable.DownloadsView.Swipe.Button.move, + systemSymbol: .folder + ) + } + .tint(.teal) + } } .swipeActions(edge: .trailing, allowsFullSwipe: false) { if download.canTriggerUpdate { @@ -242,6 +282,21 @@ private extension DownloadsView { ) } + if canMove(download) { + Menu { + ForEach(moveDestinations(for: download), id: \.self) { folder in + Button(folder) { + store.send(.moveDownload(download.gid, folder)) + } + } + } label: { + Label( + L10n.Localizable.DownloadsView.Menu.Button.moveToFolder, + systemSymbol: .folder + ) + } + } + if download.canTriggerUpdate { Button { store.send(.updateDownload(download.gid)) @@ -305,11 +360,56 @@ private extension DownloadsView { ) { AlertViewButton(title: L10n.Localizable.DownloadsView.Button.clearFilters) { store.keyword = "" + store.folderFilter = .all } } } } + private func canMove(_ download: DownloadedGallery) -> Bool { + download.displayStatus != .active && !moveDestinations(for: download).isEmpty + } + + private func moveDestinations(for download: DownloadedGallery) -> [String] { + store.folders.filter { $0 != download.folderName } + } + + @ToolbarContentBuilder private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + Menu { + Section { + Button { + store.send(.setNavigation(.folderManager())) + } label: { + Label( + L10n.Localizable.DownloadsView.Menu.Button.manageFolders, + systemSymbol: .folderBadgeGearshape + ) + } + } + Section { + folderFilterButton(.all) + ForEach(store.folders, id: \.self) { folder in + folderFilterButton(.folder(folder)) + } + } + } label: { + Image(systemSymbol: .dialLow) + .symbolRenderingMode(.hierarchical) + } + } + } + + private func folderFilterButton(_ filter: DownloadFolderFilter) -> some View { + Button { + store.folderFilter = filter + } label: { + Text(filter.title) + if store.folderFilter == filter { + Image(systemSymbol: .checkmark) + } + } + } } struct DownloadsView_Previews: PreviewProvider { diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift index 4107310b8..04e902975 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -49,6 +49,90 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { #expect(store.state.detailState.wrappedValue?.shouldCheckForRemoteUpdates == true) } + @MainActor + @Test + func testDownloadsReducerFolderFilterNarrowsDownloads() async { + let libraryDownload = sampleDownload( + gid: "111", title: "Library Archive", status: .completed, folderName: "Library" + ) + let otherDownload = sampleDownload( + gid: "222", title: "Other Archive", status: .completed, folderName: "Other" + ) + var state = DownloadsReducer.State() + state.downloads = [libraryDownload, otherDownload] + + state.folderFilter = .all + #expect(state.filteredDownloads == [libraryDownload, otherDownload]) + + state.folderFilter = .folder("Library") + #expect(state.filteredDownloads == [libraryDownload]) + + state.folderFilter = .folder("Vanished") + #expect(state.filteredDownloads.isEmpty) + } + + @MainActor + @Test + func testDownloadsReducerPrunesStaleFolderFilterAfterFetch() async { + var initialState = DownloadsReducer.State() + initialState.folderFilter = .folder("Vanished") + + let store = TestStore(initialState: initialState) { + DownloadsReducer() + } + + await store.send(.fetchFoldersDone(["Library"])) { + $0.folders = ["Library"] + $0.folderFilter = .all + } + + await store.send(.fetchFoldersDone(["Library", "Other"])) { + $0.folders = ["Library", "Other"] + } + } + + @MainActor + @Test + func testDownloadsReducerMoveActionUsesDownloadClientMove() async { + let moved = UncheckedBox<(String, String)?>(nil) + let store = TestStore(initialState: DownloadsReducer.State()) { + DownloadsReducer() + } withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + fetchFolders: { ["Library"] }, + moveDownload: { gid, folder in + moved.value = (gid, folder) + return .success(()) + } + ) + } + store.exhaustivity = .off + + await store.send(.moveDownload("123456", "Library")) + await store.receive(\.moveDownloadDone) + await store.receive(\.fetchFoldersDone) { + $0.folders = ["Library"] + } + + #expect(moved.value?.0 == "123456") + #expect(moved.value?.1 == "Library") + } + @MainActor @Test func testDownloadsReducerUpdateActionUsesDownloadClientRetry() async { diff --git a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift index b6abe2d2f..5246fef8a 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift @@ -90,6 +90,8 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { await store.send(.refreshDownloads) await store.receive(\.refreshDownloadsDone) + await store.receive(\.fetchFolders) + await store.receive(\.fetchFoldersDone) #expect(refreshCount.value == 1) #expect(reconcileCount.value == 0) @@ -130,6 +132,8 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { await store.send(.bootstrapDownloads) await store.receive(\.refreshDownloadsDone) + await store.receive(\.fetchFolders) + await store.receive(\.fetchFoldersDone) #expect(refreshCount.value == 1) #expect(reconcileCount.value == 0) From 12135de9dc803729d90694fe5a4d63a4f833c4c8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 20:51:30 +0800 Subject: [PATCH 161/614] Pick a folder when starting downloads --- EhPanda/App/Generated/Strings.swift | 10 ++++ EhPanda/App/de.lproj/Localizable.strings | 2 + EhPanda/App/en.lproj/Localizable.strings | 2 + EhPanda/App/ja.lproj/Localizable.strings | 2 + EhPanda/App/ko.lproj/Localizable.strings | 2 + EhPanda/App/zh-Hans.lproj/Localizable.strings | 2 + .../App/zh-Hant-HK.lproj/Localizable.strings | 2 + .../App/zh-Hant-TW.lproj/Localizable.strings | 2 + EhPanda/App/zh-Hant.lproj/Localizable.strings | 2 + .../View/Detail/DetailReducer+Actions.swift | 3 ++ .../View/Detail/DetailReducer+Download.swift | 12 +++++ EhPanda/View/Detail/DetailReducer.swift | 8 +++ .../Detail/DetailView+HeaderSection.swift | 50 ++++++++++++++++--- EhPanda/View/Detail/DetailView.swift | 12 +++++ EhPanda/View/Downloads/DownloadsReducer.swift | 5 ++ .../Download/DetailReducerDownloadTests.swift | 27 +++++++++- 16 files changed, 134 insertions(+), 9 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index c4af28287..36f979866 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -562,6 +562,16 @@ internal enum L10n { internal static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.title.update_download", fallback: "Update Download?") } } + internal enum Menu { + internal enum Button { + /// Manage Folders + internal static let manageFolders = L10n.tr("Localizable", "detail_view.menu.button.manage_folders", fallback: "Manage Folders") + } + internal enum Text { + /// No folders yet + internal static let noFolders = L10n.tr("Localizable", "detail_view.menu.text.no_folders", fallback: "No folders yet") + } + } internal enum OfflineNotice { /// Couldn't refresh online details. Showing saved details instead. internal static let savedDetails = L10n.tr("Localizable", "detail_view.offline_notice.saved_details", fallback: "Couldn't refresh online details. Showing saved details instead.") diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 5f101fa18..21bb57929 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -958,6 +958,8 @@ "detail_view.dialog.button.redownload" = "Erneut laden"; "detail_view.offline_notice.saved_details" = "Online-Details konnten nicht aktualisiert werden. Stattdessen werden gespeicherte Details angezeigt."; "enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index de71bf303..75eb88c6c 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -388,6 +388,8 @@ // MARK: DownloadsView "enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 6655f3de5..285228d2a 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -958,6 +958,8 @@ "detail_view.dialog.button.redownload" = "再ダウンロード"; "detail_view.offline_notice.saved_details" = "オンラインの詳細を更新できなかったため、保存済みの詳細を表示しています。"; "enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index da29736f6..c3952d912 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -958,6 +958,8 @@ "detail_view.dialog.button.redownload" = "다시 다운로드"; "detail_view.offline_notice.saved_details" = "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다."; "enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index df9fc7c00..485b89aea 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -388,6 +388,8 @@ // MARK: DownloadsView "enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index ec2dbfc82..4b6cb18b5 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -955,6 +955,8 @@ "detail_view.dialog.button.redownload" = "重新下載"; "detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; "enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index a6cc321db..edf2fa075 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -956,6 +956,8 @@ "detail_view.dialog.button.redownload" = "重新下載"; "detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; "enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 3e7bebb79..b26703e40 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -956,6 +956,8 @@ "detail_view.dialog.button.redownload" = "重新下載"; "detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; "enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; diff --git a/EhPanda/View/Detail/DetailReducer+Actions.swift b/EhPanda/View/Detail/DetailReducer+Actions.swift index 8ac6d88a3..328399e25 100644 --- a/EhPanda/View/Detail/DetailReducer+Actions.swift +++ b/EhPanda/View/Detail/DetailReducer+Actions.swift @@ -27,6 +27,7 @@ extension DetailReducer { state.commentContent = .init() state.postCommentFocused = false state.galleryInfosState = .init() + state.folderManagerState = .init() state.detailSearchState.wrappedValue = .init() return .merge( .send(.reading(.teardown)), @@ -34,6 +35,7 @@ extension DetailReducer { .send(.torrents(.teardown)), .send(.previews(.teardown)), .send(.comments(.teardown)), + .send(.folderManager(.teardown)), .send(.detailSearch(.teardown)) ) @@ -72,6 +74,7 @@ extension DetailReducer { return .merge( .send(.fetchDatabaseInfos(gid)), .send(.fetchDownloadBadge), + .send(.fetchDownloadFolders), .send(.observeDownload), .send(.loadLocalPreviewURLs) ) diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index a8f48c969..684412d5b 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -15,6 +15,18 @@ extension DetailReducer { return handleFetchDownloadBadge(state: &state) case .fetchDownloadBadgeDone(let download): return handleFetchDownloadBadgeDone(download: download, state: &state) + case .fetchDownloadFolders: + return .run { send in + await send(.fetchDownloadFoldersDone(await downloadClient.fetchFolders())) + } + .cancellable(id: CancelID.fetchDownloadFolders, cancelInFlight: true) + case .fetchDownloadFoldersDone(let folders): + state.downloadFolders = folders + return .none + case .folderManager(.createFolderDone), + .folderManager(.renameFolderDone), + .folderManager(.deleteFolderDone): + return .send(.fetchDownloadFolders) case .observeDownload: return handleObserveDownload(state: &state) case .observeDownloadDone(let download): diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 563c0149e..c22d1f067 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -22,6 +22,7 @@ struct DetailReducer { case detailSearch(String) case tagDetail(TagDetail) case galleryInfos(Gallery, GalleryDetail) + case folderManager(EquatableVoid = .init()) } enum CancelID: CaseIterable { @@ -29,6 +30,7 @@ struct DetailReducer { case fetchGalleryDetail case fetchVersionMetadata case fetchDownloadBadge + case fetchDownloadFolders case observeDownload case loadLocalPreviewURLs case rateGallery @@ -60,6 +62,7 @@ struct DetailReducer { var previewConfig: PreviewConfig = .normal(rows: 4) var downloadBadge: DownloadBadge? var downloadFailureCode: DownloadFailureCode? + var downloadFolders = [String]() var isPreparingDownload = false var hasLoadedDownloadBadge = false @@ -78,6 +81,7 @@ struct DetailReducer { var previewsState = PreviewsReducer.State() var commentsState: Heap var galleryInfosState = GalleryInfosReducer.State() + var folderManagerState = FolderManagerReducer.State() var detailSearchState: Heap init() { @@ -114,6 +118,8 @@ struct DetailReducer { case updateReadingProgress(Int) case fetchDownloadBadge case fetchDownloadBadgeDone(DownloadedGallery?) + case fetchDownloadFolders + case fetchDownloadFoldersDone([String]) case observeDownload case observeDownloadDone(DownloadedGallery?) case loadLocalPreviewURLs @@ -148,6 +154,7 @@ struct DetailReducer { case previews(PreviewsReducer.Action) case comments(CommentsReducer.Action) case galleryInfos(GalleryInfosReducer.Action) + case folderManager(FolderManagerReducer.Action) case detailSearch(DetailSearchReducer.Action) } @@ -181,6 +188,7 @@ extension DetailReducer { Scope(state: \.torrentsState, action: \.torrents, child: TorrentsReducer.init) Scope(state: \.previewsState, action: \.previews, child: PreviewsReducer.init) Scope(state: \.galleryInfosState, action: \.galleryInfos, child: GalleryInfosReducer.init) + Scope(state: \.folderManagerState, action: \.folderManager, child: FolderManagerReducer.init) } } diff --git a/EhPanda/View/Detail/DetailView+HeaderSection.swift b/EhPanda/View/Detail/DetailView+HeaderSection.swift index c0ca341dd..44eb40152 100644 --- a/EhPanda/View/Detail/DetailView+HeaderSection.swift +++ b/EhPanda/View/Detail/DetailView+HeaderSection.swift @@ -13,12 +13,15 @@ struct HeaderSection: View { let user: User let downloadBadge: DownloadBadge? let downloadNeedsRepair: Bool + let downloadFolders: [String] let isPreparingDownload: Bool let canDownload: Bool let displaysJapaneseTitle: Bool let showFullTitle: Bool let showFullTitleAction: () -> Void let downloadAction: () -> Void + let downloadToFolderAction: (String) -> Void + let manageFoldersAction: () -> Void let favorAction: (Int) -> Void let unfavorAction: () -> Void let navigateReadingAction: () -> Void @@ -79,14 +82,31 @@ struct HeaderSection: View { } .buttonStyle(.glass(.regular.interactive())) .buttonBorderShape(.circle) - } else { - Button(action: downloadAction) { - Image(systemName: downloadIconSystemName) - .font(actionIconFont) - .foregroundStyle(canDownload ? downloadButtonTint : .secondary) - .rotationEffect(.degrees(showsMetadataPreparation ? 360 : 0)) - .frame(width: actionIconButtonSize, height: actionIconButtonSize) - .contentShape(Circle()) + } else if downloadBadge == nil { + Menu { + Section { + Button(action: manageFoldersAction) { + Label( + L10n.Localizable.DetailView.Menu.Button.manageFolders, + systemSymbol: .folderBadgeGearshape + ) + } + } + Section { + if downloadFolders.isEmpty { + Text(L10n.Localizable.DetailView.Menu.Text.noFolders) + } else { + ForEach(downloadFolders, id: \.self) { folder in + Button { + downloadToFolderAction(folder) + } label: { + Label(folder, systemSymbol: .folder) + } + } + } + } + } label: { + downloadIconLabel } .buttonStyle(.glass(.regular.interactive())) .buttonBorderShape(.circle) @@ -95,12 +115,26 @@ struct HeaderSection: View { ? .linear(duration: 0.9).repeatForever(autoreverses: false) : .default, value: showsMetadataPreparation ) + } else { + Button(action: downloadAction) { + downloadIconLabel + } + .buttonStyle(.glass(.regular.interactive())) + .buttonBorderShape(.circle) } } .disabled(isDownloadActionDisabled) .frame(width: actionIconButtonSize, height: actionIconButtonSize) .accessibilityLabel(downloadButtonAccessibilityLabel) } + private var downloadIconLabel: some View { + Image(systemName: downloadIconSystemName) + .font(actionIconFont) + .foregroundStyle(canDownload ? downloadButtonTint : .secondary) + .rotationEffect(.degrees(showsMetadataPreparation ? 360 : 0)) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + .contentShape(Circle()) + } private var favoriteButton: some View { ZStack { Button(action: unfavorAction) { diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 1ae8beb62..dc6d420b2 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -156,6 +156,7 @@ private extension DetailView { user: user, downloadBadge: store.downloadBadge, downloadNeedsRepair: store.downloadNeedsRepair, + downloadFolders: store.downloadFolders, isPreparingDownload: store.isPreparingDownload, canDownload: !store.gallery.id.isEmpty && (AppUtil.galleryHost == .ehentai || CookieUtil.didLogin), @@ -163,6 +164,10 @@ private extension DetailView { showFullTitle: store.showsFullTitle, showFullTitleAction: { store.send(.toggleShowFullTitle) }, downloadAction: { handleDownloadAction() }, + downloadToFolderAction: { + store.send(.startDownload(setting.downloadRequestOptions, $0)) + }, + manageFoldersAction: { store.send(.setNavigation(.folderManager())) }, favorAction: { store.send(.favorGallery($0)) }, unfavorAction: { store.send(.unfavorGallery) }, navigateReadingAction: { store.send(.openReading) }, @@ -321,6 +326,13 @@ private extension DetailView { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } + .sheet(item: $store.route.sending(\.setNavigation).folderManager) { _ in + FolderManagerView( + store: store.scope(state: \.folderManagerState, action: \.folderManager) + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } .sheet(item: $store.route.sending(\.setNavigation).share, id: \.absoluteString) { url in ActivityView(activityItems: [url]) .autoBlur(radius: blurRadius) diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index cc141ebd2..a7a4a376e 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -257,6 +257,11 @@ struct DownloadsReducer { case .deleteDownloadDone: return .none + case .detail(.folderManager(.createFolderDone)), + .detail(.folderManager(.renameFolderDone)), + .detail(.folderManager(.deleteFolderDone)): + return .send(.fetchFolders) + case .detail: return .none diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index ffdaf8fe7..97525fd20 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -84,6 +84,29 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { } } + @MainActor + @Test + func testDetailReducerFetchDownloadFoldersPopulatesStateAndRefetchesAfterChanges() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let store = makeDownloadTestStore( + gallery: gallery, detail: detail, + downloadValue: nil, + folders: { ["Library"] }, + enqueue: { _ in .success(()) } + ) + store.exhaustivity = .off + + await store.send(.fetchDownloadFolders) + await store.receive(\.fetchDownloadFoldersDone, ["Library"]) { + $0.downloadFolders = ["Library"] + } + + await store.send(.folderManager(.createFolderDone(.success(())))) + await store.receive(\.fetchDownloadFolders) + await store.skipReceivedActions(strict: false) + } + @MainActor @Test func testDetailReducerLaunchAutomationWaitsForResolvedDownloadBadge() async throws { @@ -133,6 +156,7 @@ private extension DetailReducerDownloadTests { gallery: Gallery, detail: GalleryDetail, downloadValue: DownloadedGallery?, automationGID: String? = nil, + folders: @escaping @Sendable () -> [String] = { [] }, configure: (inout DetailReducer.State) -> Void = { _ in }, enqueue: @escaping @Sendable (DownloadRequestPayload) async -> Result ) -> TestStoreOf { @@ -157,7 +181,8 @@ private extension DetailReducerDownloadTests { togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } + loadManifest: { _ in .failure(.notFound) }, + fetchFolders: { folders() } ) $0.hapticsClient = .noop $0.databaseClient = .noop From e1e45a4c70ed977b7b8d1e943033cb74da32b883 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 21:33:53 +0800 Subject: [PATCH 162/614] Refactor DownloadBadgeLabel and call sites --- .../DownloadedGallery+Extensions.swift | 11 ----- EhPanda/View/Home/HomeReducer+Body.swift | 17 ------- EhPanda/View/Home/HomeReducer.swift | 9 ---- EhPanda/View/Home/HomeView+Sections.swift | 49 +++---------------- EhPanda/View/Home/HomeView.swift | 4 -- .../Components/Cells/GalleryCardCell.swift | 12 +---- .../Components/Cells/GalleryDetailCell.swift | 15 +++--- .../Components/Cells/GalleryRankingCell.swift | 7 +-- .../Cells/GalleryThumbnailCell.swift | 41 +++++++++------- .../Components/DownloadBadgeLabel.swift | 37 ++------------ .../Download/DownloadBadgeSortTests.swift | 3 -- .../DownloadFilterAndBadgeTests.swift | 1 - 12 files changed, 44 insertions(+), 162 deletions(-) diff --git a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift index 68cbec229..3d9eaed98 100644 --- a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift @@ -19,17 +19,6 @@ extension DownloadBadge { } } - var ringSymbol: SFSymbol { - switch status { - case .active: .playFill - case .queued: .listDash - case .inactive: .pauseFill - case .completed: .checkmark - case .updateAvailable: .arrowUp - case .error: .exclamationmark - } - } - var color: Color { switch status { case .active, .queued: .green diff --git a/EhPanda/View/Home/HomeReducer+Body.swift b/EhPanda/View/Home/HomeReducer+Body.swift index 2cc3b9b81..aee5569c8 100644 --- a/EhPanda/View/Home/HomeReducer+Body.swift +++ b/EhPanda/View/Home/HomeReducer+Body.swift @@ -29,9 +29,6 @@ extension HomeReducer { case .binding: return .none - case .onAppear: - return .send(.observeDownloads) - case .setNavigation(let route): state.route = route return route == nil ? .send(.clearSubStates) : .none @@ -152,20 +149,6 @@ extension HomeReducer { state.rawCardColors[gid] = colors return .none - case .observeDownloads: - return .run { send in - for await downloads in downloadClient.observeDownloads() { - await send(.observeDownloadsDone(downloads)) - } - } - .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) - - case .observeDownloadsDone(let downloads): - state.downloadBadges = Dictionary( - uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) } - ) - return .none - case .frontpage: return .none diff --git a/EhPanda/View/Home/HomeReducer.swift b/EhPanda/View/Home/HomeReducer.swift index 43ef90ae3..a2b655b22 100644 --- a/EhPanda/View/Home/HomeReducer.swift +++ b/EhPanda/View/Home/HomeReducer.swift @@ -9,10 +9,6 @@ import ComposableArchitecture @Reducer struct HomeReducer { - enum CancelID { - case observeDownloads - } - @CasePathable enum Route: Equatable, Hashable { case detail(String) @@ -37,7 +33,6 @@ struct HomeReducer { var frontpageLoadingState: LoadingState = .idle var toplistsGalleries = [Int: [Gallery]]() var toplistsLoadingState = [Int: LoadingState]() - var downloadBadges = [String: DownloadBadge]() var frontpageState = FrontpageReducer.State() var toplistsState = ToplistsReducer.State() @@ -73,7 +68,6 @@ struct HomeReducer { enum Action: BindableAction { case binding(BindingAction) - case onAppear case setNavigation(Route?) case clearSubStates case setAllowsCardHitTesting(Bool) @@ -88,8 +82,6 @@ struct HomeReducer { case fetchFrontpageGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchToplistsGalleries(Int, Int? = nil) case fetchToplistsGalleriesDone(Int, Result<(PageNumber, [Gallery]), AppError>) - case observeDownloads - case observeDownloadsDone([DownloadedGallery]) case frontpage(FrontpageReducer.Action) case toplists(ToplistsReducer.Action) @@ -100,7 +92,6 @@ struct HomeReducer { } @Dependency(\.databaseClient) var databaseClient - @Dependency(\.downloadClient) var downloadClient @Dependency(\.libraryClient) var libraryClient var body: some Reducer { reducerBody } diff --git a/EhPanda/View/Home/HomeView+Sections.swift b/EhPanda/View/Home/HomeView+Sections.swift index c6bf29922..33ab3f794 100644 --- a/EhPanda/View/Home/HomeView+Sections.swift +++ b/EhPanda/View/Home/HomeView+Sections.swift @@ -16,13 +16,12 @@ struct CardSlideSection: View, Equatable { private let galleries: [Gallery] private let currentID: String private let colors: [Color] - private let downloadBadges: [String: DownloadBadge] private let navigateAction: (String) -> Void private let webImageSuccessAction: (String, RetrieveImageResult) -> Void init( galleries: [Gallery], pageIndex: Binding, currentID: String, - colors: [Color], downloadBadges: [String: DownloadBadge], + colors: [Color], navigateAction: @escaping (String) -> Void, webImageSuccessAction: @escaping (String, RetrieveImageResult) -> Void ) { @@ -30,7 +29,6 @@ struct CardSlideSection: View, Equatable { _pageIndex = pageIndex self.currentID = currentID self.colors = colors - self.downloadBadges = downloadBadges self.navigateAction = navigateAction self.webImageSuccessAction = webImageSuccessAction } @@ -39,7 +37,6 @@ struct CardSlideSection: View, Equatable { lhs.galleries == rhs.galleries && lhs.currentID == rhs.currentID && lhs.colors == rhs.colors - && lhs.downloadBadges == rhs.downloadBadges } var body: some View { @@ -53,8 +50,7 @@ struct CardSlideSection: View, Equatable { colors: colors, webImageSuccessAction: { webImageSuccessAction(gallery.gid, $0) - }, - downloadBadge: downloadBadges[gallery.gid] + } ) .tint(.primary) .multilineTextAlignment(.leading) @@ -72,20 +68,18 @@ struct CardSlideSection: View, Equatable { struct CoverWallSection: View { private let galleries: [Gallery] private let isLoading: Bool - private let downloadBadges: [String: DownloadBadge] private let navigateAction: (String) -> Void private let showAllAction: () -> Void private let reloadAction: () -> Void init( - galleries: [Gallery], isLoading: Bool, downloadBadges: [String: DownloadBadge], + galleries: [Gallery], isLoading: Bool, navigateAction: @escaping (String) -> Void, showAllAction: @escaping () -> Void, reloadAction: @escaping () -> Void ) { self.galleries = galleries self.isLoading = isLoading - self.downloadBadges = downloadBadges self.navigateAction = navigateAction self.showAllAction = showAllAction self.reloadAction = reloadAction @@ -112,11 +106,7 @@ struct CoverWallSection: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 20) { ForEach(dataSource, id: \.first) { - VerticalCoverStack( - galleries: $0, - downloadBadges: downloadBadges, - navigateAction: navigateAction - ) + VerticalCoverStack(galleries: $0, navigateAction: navigateAction) } .withHorizontalSpacing(width: 0) } @@ -128,16 +118,10 @@ struct CoverWallSection: View { struct VerticalCoverStack: View { private let galleries: [Gallery] - private let downloadBadges: [String: DownloadBadge] private let navigateAction: (String) -> Void - init( - galleries: [Gallery], - downloadBadges: [String: DownloadBadge], - navigateAction: @escaping (String) -> Void - ) { + init(galleries: [Gallery], navigateAction: @escaping (String) -> Void) { self.galleries = galleries - self.downloadBadges = downloadBadges self.navigateAction = navigateAction } @@ -153,12 +137,6 @@ struct VerticalCoverStack: View { .defaultModifier() .scaledToFill() .frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH).cornerRadius(2) - .overlay(alignment: .topTrailing) { - if let downloadBadge = downloadBadges[gallery.gid] { - DownloadBadgeLabel(badge: downloadBadge, isCompactStyle: true) - .padding(6) - } - } } } @@ -173,20 +151,18 @@ struct VerticalCoverStack: View { struct ToplistsSection: View { private let galleries: [Int: [Gallery]] private let isLoading: Bool - private let downloadBadges: [String: DownloadBadge] private let navigateAction: (String) -> Void private let showAllAction: () -> Void private let reloadAction: () -> Void init( - galleries: [Int: [Gallery]], isLoading: Bool, downloadBadges: [String: DownloadBadge], + galleries: [Int: [Gallery]], isLoading: Bool, navigateAction: @escaping (String) -> Void, showAllAction: @escaping () -> Void, reloadAction: @escaping () -> Void ) { self.galleries = galleries self.isLoading = isLoading - self.downloadBadges = downloadBadges self.navigateAction = navigateAction self.showAllAction = showAllAction self.reloadAction = reloadAction @@ -233,13 +209,11 @@ struct ToplistsSection: View { HStack { VerticalToplistsStack( galleries: galleries(type: type, range: 0...2), startRanking: 1, - downloadBadges: downloadBadges, navigateAction: navigateAction ) if DeviceUtil.isPad { VerticalToplistsStack( galleries: galleries(type: type, range: 3...5), startRanking: 4, - downloadBadges: downloadBadges, navigateAction: navigateAction ) } @@ -252,18 +226,15 @@ struct ToplistsSection: View { struct VerticalToplistsStack: View { private let galleries: [Gallery] private let startRanking: Int - private let downloadBadges: [String: DownloadBadge] private let navigateAction: (String) -> Void init( galleries: [Gallery], startRanking: Int, - downloadBadges: [String: DownloadBadge], navigateAction: @escaping (String) -> Void ) { self.galleries = galleries self.startRanking = startRanking - self.downloadBadges = downloadBadges self.navigateAction = navigateAction } @@ -274,12 +245,8 @@ struct VerticalToplistsStack: View { Button { navigateAction(galleries[index].id) } label: { - GalleryRankingCell( - gallery: galleries[index], - ranking: startRanking + index, - downloadBadge: downloadBadges[galleries[index].gid] - ) - .tint(.primary).multilineTextAlignment(.leading) + GalleryRankingCell(gallery: galleries[index], ranking: startRanking + index) + .tint(.primary).multilineTextAlignment(.leading) } Divider().opacity(index == galleries.count - 1 ? 0 : 1) } diff --git a/EhPanda/View/Home/HomeView.swift b/EhPanda/View/Home/HomeView.swift index 25fefcef4..9bb6beda7 100644 --- a/EhPanda/View/Home/HomeView.swift +++ b/EhPanda/View/Home/HomeView.swift @@ -39,7 +39,6 @@ struct HomeView: View { pageIndex: $store.cardPageIndex, currentID: store.currentCardID, colors: store.cardColors, - downloadBadges: store.downloadBadges, navigateAction: navigateTo(gid:), webImageSuccessAction: { gid, result in store.send(.analyzeImageColors(gid, result)) @@ -52,7 +51,6 @@ struct HomeView: View { CoverWallSection( galleries: store.frontpageGalleries, isLoading: store.frontpageLoadingState == .loading, - downloadBadges: store.downloadBadges, navigateAction: navigateTo(gid:), showAllAction: { store.send(.setNavigation(.section(.frontpage))) }, reloadAction: { store.send(.fetchFrontpageGalleries) } @@ -62,7 +60,6 @@ struct HomeView: View { galleries: store.toplistsGalleries, isLoading: !store.toplistsLoadingState .values.allSatisfy({ $0 != .loading }), - downloadBadges: store.downloadBadges, navigateAction: navigateTo(gid:), showAllAction: { store.send(.setNavigation(.section(.toplists))) }, reloadAction: { store.send(.fetchAllToplistsGalleries) } @@ -90,7 +87,6 @@ struct HomeView: View { } .animation(.default, value: store.popularLoadingState) .onAppear { - store.send(.onAppear) if store.popularGalleries.isEmpty { store.send(.fetchAllGalleries) } diff --git a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift index d89b119ce..9b0357617 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift @@ -16,21 +16,18 @@ struct GalleryCardCell: View { private let webImageSuccessAction: (RetrieveImageResult) -> Void private let gallery: Gallery - private let downloadBadge: DownloadBadge? private let animation: Animation = .interpolatingSpring(stiffness: 50, damping: 1).speed(0.2) init( gallery: Gallery, currentID: String, colors: [Color], - webImageSuccessAction: @escaping (RetrieveImageResult) -> Void, - downloadBadge: DownloadBadge? = nil + webImageSuccessAction: @escaping (RetrieveImageResult) -> Void ) { self.gallery = gallery self.currentID = currentID self.colors = colors self.webImageSuccessAction = webImageSuccessAction - self.downloadBadge = downloadBadge } private var animated: Bool { @@ -61,12 +58,7 @@ struct GalleryCardCell: View { .frame(width: Defaults.ImageSize.headerW, height: Defaults.ImageSize.headerH) .cornerRadius(5) VStack(alignment: .leading) { - Text(title) - .font(.title3.bold()) - .lineLimit(downloadBadge == nil ? 4 : 2) - if let downloadBadge { - DownloadBadgeLabel(badge: downloadBadge, isCompactStyle: true) - } + Text(title).font(.title3.bold()).lineLimit(4) Spacer() RatingView(rating: gallery.rating).foregroundColor(.yellow) } diff --git a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift index 68a9d1613..424935b35 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift @@ -102,9 +102,7 @@ private struct GalleryDetailCellContent: View { .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) - if let downloadBadge { - DownloadBadgeLabel(badge: downloadBadge) - } + Text(gallery.language?.value ?? "") } let tagContents = gallery.tagContents(maximum: setting.listTagsNumberMaximum) @@ -123,15 +121,18 @@ private struct GalleryDetailCellContent: View { } HStack { RatingView(rating: gallery.rating).font(.caption).foregroundStyle(.yellow) - Spacer() - HStack(spacing: 10) { - Text(gallery.language?.value ?? "") + + Spacer(minLength: 8) + + if let downloadBadge { + DownloadBadgeLabel(badge: downloadBadge) + } else { HStack(spacing: 2) { Image(systemSymbol: .photoOnRectangleAngled) Text(String(gallery.pageCount)) } + .lineLimit(1).font(.footnote).foregroundStyle(.secondary).minimumScaleFactor(0.75) } - .lineLimit(1).font(.footnote).foregroundStyle(.secondary).minimumScaleFactor(0.75) } HStack(alignment: .bottom) { CategoryLabel(text: gallery.category.value, color: gallery.color) diff --git a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift index 9e994af89..7a6ac1433 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift @@ -9,12 +9,10 @@ import Kingfisher struct GalleryRankingCell: View { private let gallery: Gallery private let ranking: Int - private let downloadBadge: DownloadBadge? - init(gallery: Gallery, ranking: Int, downloadBadge: DownloadBadge? = nil) { + init(gallery: Gallery, ranking: Int) { self.gallery = gallery self.ranking = ranking - self.downloadBadge = downloadBadge } private var resolvedCoverURL: URL? { @@ -30,9 +28,6 @@ struct GalleryRankingCell: View { Text(String(ranking)).fontWeight(.medium).font(.title2).padding(.horizontal) VStack(alignment: .leading) { Text(gallery.trimmedTitle).bold().lineLimit(2).fixedSize(horizontal: false, vertical: true) - if let downloadBadge { - DownloadBadgeLabel(badge: downloadBadge, isCompactStyle: true) - } if let uploader = gallery.uploader { Text(uploader).foregroundColor(.secondary).lineLimit(1) } diff --git a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift index 6367fea47..989f4736d 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -45,21 +45,16 @@ struct GalleryThumbnailCell: View { minAspect: Defaults.ImageSize.webtoonMinAspect, idealAspect: Defaults.ImageSize.webtoonIdealAspect )) - .fade(duration: 0.25).resizable().scaledToFit().overlay { - VStack { - HStack { - if let downloadBadge { - DownloadBadgeLabel(badge: downloadBadge, isCompactStyle: true) - } - Spacer() - CategoryLabel( - text: gallery.category.value, color: gallery.color, - insets: .init(top: 3, leading: 6, bottom: 3, trailing: 6), - cornerRadius: 15, corners: .bottomLeft - ) - } - Spacer() - } + .fade(duration: 0.25) + .resizable() + .scaledToFit() + .overlay { + CategoryLabel( + text: gallery.category.value, color: gallery.color, + insets: .init(top: 3, leading: 6, bottom: 3, trailing: 6), + cornerRadius: 15, corners: .bottomLeft + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) } VStack(alignment: .leading, spacing: 5) { Text(gallery.title) @@ -80,15 +75,23 @@ struct GalleryThumbnailCell: View { } } HStack(spacing: 10) { + if let downloadBadge { + DownloadBadgeLabel(badge: downloadBadge) + } else { + HStack(spacing: 2) { + Image(systemSymbol: .photoOnRectangleAngled) + Text(String(gallery.pageCount)) + } + } + + Spacer(minLength: 8) + if let language = gallery.language { Text(language.value) } - HStack(spacing: 2) { - Image(systemSymbol: .photoOnRectangleAngled) - Text(String(gallery.pageCount)) - } } .lineLimit(1).font(.footnote).foregroundStyle(.secondary) + RatingView(rating: gallery.rating).foregroundColor(.yellow).font(.caption) } .padding() diff --git a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift index 43668ab0b..36d612c48 100644 --- a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift +++ b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift @@ -6,46 +6,13 @@ import SwiftUI struct DownloadBadgeLabel: View { - private static let ringDiameter: CGFloat = 26 - private static let ringLineWidth: CGFloat = 2.5 - private let badge: DownloadBadge - private let isCompactStyle: Bool - init(badge: DownloadBadge, isCompactStyle: Bool = false) { + init(badge: DownloadBadge) { self.badge = badge - self.isCompactStyle = isCompactStyle } var body: some View { - Group { - if isCompactStyle { - ringSymbol - } else { - textLabel - } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityText) - } - - private var ringSymbol: some View { - ZStack { - Circle() - .stroke(badge.color.opacity(0.18), lineWidth: Self.ringLineWidth) - Circle() - .trim(from: 0, to: badge.progress.fraction) - .stroke(badge.color, style: .init(lineWidth: Self.ringLineWidth, lineCap: .round)) - .rotationEffect(.degrees(-90)) - Image(systemSymbol: badge.ringSymbol) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(badge.color) - } - .padding(Self.ringLineWidth / 2) - .frame(width: Self.ringDiameter, height: Self.ringDiameter) - } - - private var textLabel: some View { HStack(spacing: 4) { Image(systemSymbol: badge.symbol) .font(.caption.bold()) @@ -58,6 +25,8 @@ struct DownloadBadgeLabel: View { .padding(.vertical, 4) .background(badge.color.opacity(0.15)) .clipShape(.capsule) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityText) } private var progressText: String { diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index 18869e173..2a2b3cae1 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -25,7 +25,6 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { #expect(badge.status == activeDownload.displayStatus) #expect(badge.progress == DownloadProgress(completedPageCount: 7, pageCount: 26)) #expect(badge.symbol == .playFill) - #expect(badge.ringSymbol == .playFill) #expect(badge.color == .green) let completedBadge = sampleDownload( @@ -36,7 +35,6 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { ).badge #expect(completedBadge.symbol == .checkmarkCircleFill) - #expect(completedBadge.ringSymbol == .checkmark) #expect(completedBadge.color == .gray) #expect(completedBadge.progress.fraction == 1) } @@ -51,7 +49,6 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { completedPageCount: 5 ) #expect(partialDownload.badge.symbol == .exclamationmarkTriangleFill) - #expect(partialDownload.badge.ringSymbol == .exclamationmark) #expect(partialDownload.badge.color == .yellow) #expect( partialDownload.badge.progress diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index ab9fca9d2..59a9f359f 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -216,7 +216,6 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { ) #expect(partialDownload.badge.symbol == .exclamationmarkTriangleFill) - #expect(partialDownload.badge.ringSymbol == .exclamationmark) #expect(partialDownload.badge.color == .yellow) #expect( partialDownload.badge.progress From 1375feaa5cce71c321276c6726873dac40fc3240 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 22:46:53 +0800 Subject: [PATCH 163/614] Rename folders inline --- EhPanda/App/Generated/Strings.swift | 2 - EhPanda/App/de.lproj/Localizable.strings | 1 - EhPanda/App/en.lproj/Localizable.strings | 1 - EhPanda/App/ja.lproj/Localizable.strings | 1 - EhPanda/App/ko.lproj/Localizable.strings | 1 - EhPanda/App/zh-Hans.lproj/Localizable.strings | 1 - .../App/zh-Hant-HK.lproj/Localizable.strings | 1 - .../App/zh-Hant-TW.lproj/Localizable.strings | 1 - EhPanda/App/zh-Hant.lproj/Localizable.strings | 1 - .../View/Downloads/FolderManagerView.swift | 46 ++++++++++++++----- 10 files changed, 34 insertions(+), 22 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 36f979866..9a51523ca 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -2071,8 +2071,6 @@ internal enum L10n { internal static let folders = L10n.tr("Localizable", "folder_manager_view.title.folders", fallback: "Folders") /// New Folder internal static let newFolder = L10n.tr("Localizable", "folder_manager_view.title.new_folder", fallback: "New Folder") - /// Rename Folder - internal static let renameFolder = L10n.tr("Localizable", "folder_manager_view.title.rename_folder", fallback: "Rename Folder") } } internal enum FrontpageView { diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 21bb57929..383c138bf 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -965,7 +965,6 @@ "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; -"folder_manager_view.title.rename_folder" = "Rename Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 75eb88c6c..6a64c2222 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -395,7 +395,6 @@ "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; -"folder_manager_view.title.rename_folder" = "Rename Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 285228d2a..953fcf16f 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -965,7 +965,6 @@ "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; -"folder_manager_view.title.rename_folder" = "Rename Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index c3952d912..95b021dcd 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -965,7 +965,6 @@ "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; -"folder_manager_view.title.rename_folder" = "Rename Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 485b89aea..e04e77ad0 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -395,7 +395,6 @@ "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; -"folder_manager_view.title.rename_folder" = "Rename Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 4b6cb18b5..a5db4d380 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -962,7 +962,6 @@ "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; -"folder_manager_view.title.rename_folder" = "Rename Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index edf2fa075..bb08422a2 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -963,7 +963,6 @@ "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; -"folder_manager_view.title.rename_folder" = "Rename Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index b26703e40..206f37fce 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -963,7 +963,6 @@ "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; "folder_manager_view.title.new_folder" = "New Folder"; -"folder_manager_view.title.rename_folder" = "Rename Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/View/Downloads/FolderManagerView.swift b/EhPanda/View/Downloads/FolderManagerView.swift index 06cc05e5d..435419ca6 100644 --- a/EhPanda/View/Downloads/FolderManagerView.swift +++ b/EhPanda/View/Downloads/FolderManagerView.swift @@ -9,6 +9,7 @@ import ComposableArchitecture struct FolderManagerView: View { @Bindable private var store: StoreOf + @FocusState private var renamingFolderName: String? @Environment(\.dismiss) private var dismiss init(store: StoreOf) { @@ -20,7 +21,7 @@ struct FolderManagerView: View { ZStack { List { ForEach(store.folders, id: \.self) { folder in - Label(folder, systemSymbol: .folder) + folderRow(folder) .padding(5) .swipeActions(edge: .trailing) { Button { @@ -65,6 +66,11 @@ struct FolderManagerView: View { ) } .animation(.default, value: store.folders) + .onChange(of: renamingFolderName) { oldValue, newValue in + if newValue == nil, let oldValue, store.route == .renameFolder(oldValue) { + store.send(.setNavigation(nil)) + } + } .onAppear { store.send(.fetchFolders) } @@ -75,6 +81,33 @@ struct FolderManagerView: View { } } + @ViewBuilder private func folderRow(_ folder: String) -> some View { + if store.route == .renameFolder(folder) { + Label { + TextField( + L10n.Localizable.FolderManagerView.Placeholder.folderName, + text: $store.editingFolderName + ) + .disableAutocorrection(true) + .submitLabel(.done) + .focused($renamingFolderName, equals: folder) + .onAppear { + renamingFolderName = folder + } + .onSubmit { + if store.isEditingNameValid { + store.send(.renameFolder(folder)) + } + store.send(.setNavigation(nil)) + } + } icon: { + Image(systemSymbol: .folder) + } + } else { + Label(folder, systemSymbol: .folder) + } + } + private func toolbar() -> some ToolbarContent { Group { ToolbarItem(placement: .cancellationAction) { @@ -103,17 +136,6 @@ struct FolderManagerView: View { } ) } - NavigationLink(unwrapping: $store.route, case: \.renameFolder) { route in - EditFolderView( - title: L10n.Localizable.FolderManagerView.Title.renameFolder, - folderName: $store.editingFolderName, - isNameValid: store.isEditingNameValid, - confirmAction: { - store.send(.renameFolder(route.wrappedValue)) - store.send(.setNavigation(nil)) - } - ) - } } } From fc1f2139adc4f6592170cf5bed2d73e7a0d54c61 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 22:56:56 +0800 Subject: [PATCH 164/614] Create folders inline --- EhPanda/App/Generated/Strings.swift | 2 - EhPanda/App/de.lproj/Localizable.strings | 1 - EhPanda/App/en.lproj/Localizable.strings | 1 - EhPanda/App/ja.lproj/Localizable.strings | 1 - EhPanda/App/ko.lproj/Localizable.strings | 1 - EhPanda/App/zh-Hans.lproj/Localizable.strings | 1 - .../App/zh-Hant-HK.lproj/Localizable.strings | 1 - .../App/zh-Hant-TW.lproj/Localizable.strings | 1 - EhPanda/App/zh-Hant.lproj/Localizable.strings | 1 - .../View/Downloads/FolderManagerReducer.swift | 2 +- .../View/Downloads/FolderManagerView.swift | 118 ++++++------------ 11 files changed, 41 insertions(+), 89 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 9a51523ca..9aed487b7 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -2069,8 +2069,6 @@ internal enum L10n { internal enum Title { /// Folders internal static let folders = L10n.tr("Localizable", "folder_manager_view.title.folders", fallback: "Folders") - /// New Folder - internal static let newFolder = L10n.tr("Localizable", "folder_manager_view.title.new_folder", fallback: "New Folder") } } internal enum FrontpageView { diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 383c138bf..0ff5f80d6 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -964,7 +964,6 @@ "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 6a64c2222..da6b081ff 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -394,7 +394,6 @@ "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 953fcf16f..7a8089caf 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -964,7 +964,6 @@ "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 95b021dcd..a102f7c93 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -964,7 +964,6 @@ "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index e04e77ad0..4ae857e58 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -394,7 +394,6 @@ "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index a5db4d380..03c137e09 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -961,7 +961,6 @@ "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index bb08422a2..4dd49eb82 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -962,7 +962,6 @@ "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 206f37fce..1376ac061 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -962,7 +962,6 @@ "downloads_view.menu.button.move_to_folder" = "Move to Folder"; "downloads_view.swipe.button.move" = "Move"; "folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.title.new_folder" = "New Folder"; "folder_manager_view.placeholder.folder_name" = "Folder name"; "folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_state.folders" = "Folders you create will appear here."; diff --git a/EhPanda/View/Downloads/FolderManagerReducer.swift b/EhPanda/View/Downloads/FolderManagerReducer.swift index 65c6f84a8..5f0940d92 100644 --- a/EhPanda/View/Downloads/FolderManagerReducer.swift +++ b/EhPanda/View/Downloads/FolderManagerReducer.swift @@ -9,7 +9,7 @@ import ComposableArchitecture @Reducer struct FolderManagerReducer { @CasePathable - enum Route: Equatable { + enum Route: Equatable, Hashable { case newFolder case renameFolder(String) case deleteFolder(String) diff --git a/EhPanda/View/Downloads/FolderManagerView.swift b/EhPanda/View/Downloads/FolderManagerView.swift index 435419ca6..683f00bd8 100644 --- a/EhPanda/View/Downloads/FolderManagerView.swift +++ b/EhPanda/View/Downloads/FolderManagerView.swift @@ -9,7 +9,7 @@ import ComposableArchitecture struct FolderManagerView: View { @Bindable private var store: StoreOf - @FocusState private var renamingFolderName: String? + @FocusState private var focusedRoute: FolderManagerReducer.Route? @Environment(\.dismiss) private var dismiss init(store: StoreOf) { @@ -20,6 +20,10 @@ struct FolderManagerView: View { NavigationView { ZStack { List { + if store.route == .newFolder { + newFolderRow + .padding(5) + } ForEach(store.folders, id: \.self) { folder in folderRow(folder) .padding(5) @@ -62,12 +66,14 @@ struct FolderManagerView: View { EmptyView() } .opacity( - store.loadingState != .loading && store.folders.isEmpty ? 1 : 0 + store.loadingState != .loading && store.folders.isEmpty + && store.route != .newFolder ? 1 : 0 ) } .animation(.default, value: store.folders) - .onChange(of: renamingFolderName) { oldValue, newValue in - if newValue == nil, let oldValue, store.route == .renameFolder(oldValue) { + .animation(.default, value: store.route) + .onChange(of: focusedRoute) { oldValue, newValue in + if newValue == nil, let oldValue, store.route == oldValue { store.send(.setNavigation(nil)) } } @@ -75,31 +81,23 @@ struct FolderManagerView: View { store.send(.fetchFolders) } .toolbar(content: toolbar) - .background(navigationLinks) .navigationTitle(L10n.Localizable.FolderManagerView.Title.folders) .navigationBarTitleDisplayMode(.inline) } } + private var newFolderRow: some View { + Label { + editingTextField(route: .newFolder, submitAction: .createFolder) + } icon: { + Image(systemSymbol: .folderBadgePlus) + } + } + @ViewBuilder private func folderRow(_ folder: String) -> some View { if store.route == .renameFolder(folder) { Label { - TextField( - L10n.Localizable.FolderManagerView.Placeholder.folderName, - text: $store.editingFolderName - ) - .disableAutocorrection(true) - .submitLabel(.done) - .focused($renamingFolderName, equals: folder) - .onAppear { - renamingFolderName = folder - } - .onSubmit { - if store.isEditingNameValid { - store.send(.renameFolder(folder)) - } - store.send(.setNavigation(nil)) - } + editingTextField(route: .renameFolder(folder), submitAction: .renameFolder(folder)) } icon: { Image(systemSymbol: .folder) } @@ -108,6 +106,27 @@ struct FolderManagerView: View { } } + private func editingTextField( + route: FolderManagerReducer.Route, submitAction: FolderManagerReducer.Action + ) -> some View { + TextField( + L10n.Localizable.FolderManagerView.Placeholder.folderName, + text: $store.editingFolderName + ) + .disableAutocorrection(true) + .submitLabel(.done) + .focused($focusedRoute, equals: route) + .onAppear { + focusedRoute = route + } + .onSubmit { + if store.isEditingNameValid { + store.send(submitAction) + } + store.send(.setNavigation(nil)) + } + } + private func toolbar() -> some ToolbarContent { Group { ToolbarItem(placement: .cancellationAction) { @@ -123,63 +142,6 @@ struct FolderManagerView: View { } } } - - @ViewBuilder private var navigationLinks: some View { - NavigationLink(unwrapping: $store.route, case: \.newFolder) { _ in - EditFolderView( - title: L10n.Localizable.FolderManagerView.Title.newFolder, - folderName: $store.editingFolderName, - isNameValid: store.isEditingNameValid, - confirmAction: { - store.send(.createFolder) - store.send(.setNavigation(nil)) - } - ) - } - } -} - -extension FolderManagerView { - // MARK: EditFolderView - struct EditFolderView: View { - private let title: String - @Binding private var folderName: String - private let isNameValid: Bool - private let confirmAction: () -> Void - - init( - title: String, - folderName: Binding, - isNameValid: Bool, - confirmAction: @escaping () -> Void - ) { - self.title = title - _folderName = folderName - self.isNameValid = isNameValid - self.confirmAction = confirmAction - } - - var body: some View { - Form { - Section { - TextField( - L10n.Localizable.FolderManagerView.Placeholder.folderName, - text: $folderName - ) - .disableAutocorrection(true) - } - } - .toolbar(content: toolbar) - .navigationTitle(title) - } - - private func toolbar() -> some ToolbarContent { - CustomToolbarItem { - Button(role: .confirm, action: confirmAction) - .disabled(!isNameValid) - } - } - } } struct FolderManagerView_Previews: PreviewProvider { From be211fde776ccff0b8f466f86adffed507d07650 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 23:07:17 +0800 Subject: [PATCH 165/614] Split folder editing state from Route --- .../View/Downloads/FolderManagerReducer.swift | 39 ++++++-- .../View/Downloads/FolderManagerView.swift | 39 ++++---- .../Download/FolderManagerReducerTests.swift | 95 +++++++++++++++++++ 3 files changed, 141 insertions(+), 32 deletions(-) diff --git a/EhPanda/View/Downloads/FolderManagerReducer.swift b/EhPanda/View/Downloads/FolderManagerReducer.swift index 5f0940d92..d9e58e22e 100644 --- a/EhPanda/View/Downloads/FolderManagerReducer.swift +++ b/EhPanda/View/Downloads/FolderManagerReducer.swift @@ -9,10 +9,13 @@ import ComposableArchitecture @Reducer struct FolderManagerReducer { @CasePathable - enum Route: Equatable, Hashable { + enum Route: Equatable { + case deleteFolder(String) + } + + enum EditingField: Equatable, Hashable { case newFolder case renameFolder(String) - case deleteFolder(String) } private enum CancelID { @@ -22,6 +25,7 @@ struct FolderManagerReducer { @ObservableState struct State: Equatable { var route: Route? + var editingField: EditingField? var editingFolderName = "" var loadingState: LoadingState = .idle var folders = [String]() @@ -36,7 +40,8 @@ struct FolderManagerReducer { enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) - case clearSubStates + case setEditingField(EditingField?) + case submitEditingField case createFolder case createFolderDone(Result) @@ -54,9 +59,6 @@ struct FolderManagerReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } Reduce { state, action in switch action { @@ -65,12 +67,31 @@ struct FolderManagerReducer { case .setNavigation(let route): state.route = route - return route == nil ? .send(.clearSubStates) : .none + return .none - case .clearSubStates: - state.editingFolderName = "" + case .setEditingField(let editingField): + state.editingField = editingField + switch editingField { + case .renameFolder(let folderName): + state.editingFolderName = folderName + case .newFolder, nil: + state.editingFolderName = "" + } return .none + case .submitEditingField: + let editingField = state.editingField + state.editingField = nil + guard state.isEditingNameValid else { return .none } + switch editingField { + case .newFolder: + return .send(.createFolder) + case .renameFolder(let oldName): + return .send(.renameFolder(oldName)) + case nil: + return .none + } + case .createFolder: return .run { [name = state.editingFolderName] send in await send(.createFolderDone(await downloadClient.createFolder(name))) diff --git a/EhPanda/View/Downloads/FolderManagerView.swift b/EhPanda/View/Downloads/FolderManagerView.swift index 683f00bd8..c3ff33029 100644 --- a/EhPanda/View/Downloads/FolderManagerView.swift +++ b/EhPanda/View/Downloads/FolderManagerView.swift @@ -9,7 +9,7 @@ import ComposableArchitecture struct FolderManagerView: View { @Bindable private var store: StoreOf - @FocusState private var focusedRoute: FolderManagerReducer.Route? + @FocusState private var focusedField: FolderManagerReducer.EditingField? @Environment(\.dismiss) private var dismiss init(store: StoreOf) { @@ -20,7 +20,7 @@ struct FolderManagerView: View { NavigationView { ZStack { List { - if store.route == .newFolder { + if store.editingField == .newFolder { newFolderRow .padding(5) } @@ -35,8 +35,7 @@ struct FolderManagerView: View { } .tint(.red) Button { - store.editingFolderName = folder - store.send(.setNavigation(.renameFolder(folder))) + store.send(.setEditingField(.renameFolder(folder))) } label: { Image(systemSymbol: .squareAndPencil) } @@ -67,14 +66,14 @@ struct FolderManagerView: View { } .opacity( store.loadingState != .loading && store.folders.isEmpty - && store.route != .newFolder ? 1 : 0 + && store.editingField != .newFolder ? 1 : 0 ) } .animation(.default, value: store.folders) - .animation(.default, value: store.route) - .onChange(of: focusedRoute) { oldValue, newValue in - if newValue == nil, let oldValue, store.route == oldValue { - store.send(.setNavigation(nil)) + .animation(.default, value: store.editingField) + .onChange(of: focusedField) { oldValue, newValue in + if newValue == nil, let oldValue, store.editingField == oldValue { + store.send(.setEditingField(nil)) } } .onAppear { @@ -88,16 +87,16 @@ struct FolderManagerView: View { private var newFolderRow: some View { Label { - editingTextField(route: .newFolder, submitAction: .createFolder) + editingTextField(.newFolder) } icon: { Image(systemSymbol: .folderBadgePlus) } } @ViewBuilder private func folderRow(_ folder: String) -> some View { - if store.route == .renameFolder(folder) { + if store.editingField == .renameFolder(folder) { Label { - editingTextField(route: .renameFolder(folder), submitAction: .renameFolder(folder)) + editingTextField(.renameFolder(folder)) } icon: { Image(systemSymbol: .folder) } @@ -106,24 +105,19 @@ struct FolderManagerView: View { } } - private func editingTextField( - route: FolderManagerReducer.Route, submitAction: FolderManagerReducer.Action - ) -> some View { + private func editingTextField(_ field: FolderManagerReducer.EditingField) -> some View { TextField( L10n.Localizable.FolderManagerView.Placeholder.folderName, text: $store.editingFolderName ) .disableAutocorrection(true) .submitLabel(.done) - .focused($focusedRoute, equals: route) + .focused($focusedField, equals: field) .onAppear { - focusedRoute = route + focusedField = field } .onSubmit { - if store.isEditingNameValid { - store.send(submitAction) - } - store.send(.setNavigation(nil)) + store.send(.submitEditingField) } } @@ -134,8 +128,7 @@ struct FolderManagerView: View { } CustomToolbarItem { Button { - store.editingFolderName = "" - store.send(.setNavigation(.newFolder)) + store.send(.setEditingField(.newFolder)) } label: { Image(systemSymbol: .plus) } diff --git a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift index c66ecc6a3..cf47fd9c5 100644 --- a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift +++ b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift @@ -95,6 +95,101 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { #expect(deletedName.value == "Doomed") } + @MainActor + @Test + func testSetEditingFieldPrefillsAndClearsEditingName() async { + let store = makeStore(folders: { [] }) + + await store.send(.setEditingField(.renameFolder("Old Name"))) { + $0.editingField = .renameFolder("Old Name") + $0.editingFolderName = "Old Name" + } + await store.send(.setEditingField(.newFolder)) { + $0.editingField = .newFolder + $0.editingFolderName = "" + } + await store.send(.binding(.set(\.editingFolderName, "Drafted"))) { + $0.editingFolderName = "Drafted" + } + await store.send(.setEditingField(nil)) { + $0.editingField = nil + $0.editingFolderName = "" + } + } + + @MainActor + @Test + func testSubmitEditingFieldCreatesFolderWhenNameIsValid() async { + let createdName = UncheckedBox(nil) + let store = makeStore( + folders: { createdName.value.map { [$0] } ?? [] }, + createFolder: { name in + createdName.value = name + return .success(()) + } + ) + store.exhaustivity = .off + + await store.send(.setEditingField(.newFolder)) { + $0.editingField = .newFolder + } + await store.send(.binding(.set(\.editingFolderName, "Favorites"))) + await store.send(.submitEditingField) { + $0.editingField = nil + } + await store.receive(\.createFolder) + await store.receive(\.createFolderDone) + await store.receive(\.fetchFoldersDone) { + $0.folders = ["Favorites"] + } + + #expect(createdName.value == "Favorites") + } + + @MainActor + @Test + func testSubmitEditingFieldRenamesFolderWithOriginalName() async { + let renamedPair = UncheckedBox<(String, String)?>(nil) + let store = makeStore( + folders: { renamedPair.value == nil ? ["Old Name"] : ["New Name"] }, + renameFolder: { oldName, newName in + renamedPair.value = (oldName, newName) + return .success(()) + } + ) + store.exhaustivity = .off + + await store.send(.setEditingField(.renameFolder("Old Name"))) { + $0.editingField = .renameFolder("Old Name") + $0.editingFolderName = "Old Name" + } + await store.send(.binding(.set(\.editingFolderName, "New Name"))) + await store.send(.submitEditingField) { + $0.editingField = nil + } + await store.receive(\.renameFolder) + await store.receive(\.renameFolderDone) + await store.receive(\.fetchFoldersDone) { + $0.folders = ["New Name"] + } + + #expect(renamedPair.value?.0 == "Old Name") + #expect(renamedPair.value?.1 == "New Name") + } + + @MainActor + @Test + func testSubmitEditingFieldWithInvalidNameOnlyDismissesField() async { + let store = makeStore(folders: { [] }) + + await store.send(.setEditingField(.newFolder)) { + $0.editingField = .newFolder + } + await store.send(.submitEditingField) { + $0.editingField = nil + } + } + @MainActor @Test func testEditingNameValidationRejectsBlankAndDuplicateNames() { From 87c3c39fb70564d4327adafb515deb22f749477b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 11 Jun 2026 23:18:29 +0800 Subject: [PATCH 166/614] Drive editing focus from state --- EhPanda/View/Downloads/FolderManagerView.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/EhPanda/View/Downloads/FolderManagerView.swift b/EhPanda/View/Downloads/FolderManagerView.swift index c3ff33029..2a0435f4e 100644 --- a/EhPanda/View/Downloads/FolderManagerView.swift +++ b/EhPanda/View/Downloads/FolderManagerView.swift @@ -71,11 +71,7 @@ struct FolderManagerView: View { } .animation(.default, value: store.folders) .animation(.default, value: store.editingField) - .onChange(of: focusedField) { oldValue, newValue in - if newValue == nil, let oldValue, store.editingField == oldValue { - store.send(.setEditingField(nil)) - } - } + .synchronize($store.editingField, $focusedField) .onAppear { store.send(.fetchFolders) } @@ -113,9 +109,6 @@ struct FolderManagerView: View { .disableAutocorrection(true) .submitLabel(.done) .focused($focusedField, equals: field) - .onAppear { - focusedField = field - } .onSubmit { store.send(.submitEditingField) } From 2f8882f69b193138db01785c2cbbe774058be354 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 12 Jun 2026 22:51:48 +0800 Subject: [PATCH 167/614] Remove automatic image-data validation --- .../DownloadClient+PersistenceHelpers.swift | 109 +----------------- .../DownloadClient+PersistenceNormalize.swift | 22 +--- .../Clients/DownloadClient+PublicAPI.swift | 14 +-- .../DownloadClient+PublicAPIHelpers.swift | 32 ----- .../Clients/DownloadClient+RetryHelpers.swift | 27 +---- .../Clients/DownloadClient+Scheduling.swift | 1 - .../DownloadClient+SchedulingHelpers.swift | 5 +- .../DownloadFileStorage+Operations.swift | 19 ++- .../DownloadFileStorageHashTests.swift | 4 +- .../Download/DownloadFileStorageTests.swift | 6 +- 10 files changed, 44 insertions(+), 195 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index e3d410e75..708634760 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -5,32 +5,8 @@ import Foundation -// MARK: - Validation & Sanitization +// MARK: - Sanitization extension DownloadManager { - func validatedCompletedPageCount( - _ download: DownloadedGallery - ) -> Int { - let folderURL = download.folderURL - guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) - else { - return 0 - } - - guard let manifest = try? storage - .readManifest(folderURL: folderURL) else { - return storage.existingPageRelativePaths( - folderURL: folderURL, - manifest: download.manifest - ) - .count - } - - return storage.validPageCount( - folderURL: folderURL, - manifest: manifest - ) - } - @discardableResult func sanitizeLocalFilesIfNeeded( gid: String, @@ -41,17 +17,12 @@ extension DownloadManager { scanCompletedFolder(download: download) - let updateResult = computeSanitizeUpdate( - download: download, - clearingLastError: clearingLastError - ) - - guard updateResult.needsUpdate else { return download } - - downloadErrors[gid] = updateResult.lastError - if updateResult.lastError == nil { - validationErrors[gid] = nil + guard clearingLastError, download.lastError != nil else { + return download } + + downloadErrors[gid] = nil + validationErrors[gid] = nil await notifyObservers() return await fetchDownload(gid: gid) @@ -72,74 +43,6 @@ extension DownloadManager { ) } - private struct SanitizeUpdateResult { - let needsUpdate: Bool - let lastError: DownloadFailure? - } - - private struct MutableSanitizeState { - var lastError: DownloadFailure? - var needsUpdate: Bool - } - - private func computeSanitizeUpdate( - download: DownloadedGallery, - clearingLastError: Bool - ) -> SanitizeUpdateResult { - var state = MutableSanitizeState( - lastError: download.lastError, - needsUpdate: false - ) - applyCompletedStatusUpdate( - download: download, - clearingLastError: clearingLastError, - state: &state - ) - return SanitizeUpdateResult( - needsUpdate: state.needsUpdate, - lastError: state.lastError - ) - } - - private func applyCompletedStatusUpdate( - download: DownloadedGallery, - clearingLastError: Bool, - state: inout MutableSanitizeState - ) { - if clearingLastError { - if state.lastError != nil { - state.lastError = nil - state.needsUpdate = true - } - return - } - - let shouldValidateFiles = - [.completed, .updateAvailable].contains(download.displayStatus) - || download.lastError?.code == .fileOperationFailed - if shouldValidateFiles { - let validation = storage - .validate(download: download) - switch validation { - case .valid: - if state.lastError != nil { - state.lastError = nil - state.needsUpdate = true - } - - case .missingFiles(let message): - let failure = DownloadFailure( - code: .fileOperationFailed, - message: message - ) - if state.lastError != failure { - state.lastError = failure - state.needsUpdate = true - } - } - } - } - func captureTarget( for download: DownloadedGallery, index: Int diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 704f04f88..a7ded62b3 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -75,35 +75,25 @@ extension DownloadManager { downloadErrors[activeGalleryID] = nil } - func validateDownloads() async { - let downloads = await fetchDownloadsFromStore() - for download in downloads where download.canValidateImageData { - _ = await validateDownload(download) - } - } - func validateImageData(gid: String) async -> DownloadValidationState? { guard let download = await fetchDownload(gid: gid), download.canValidateImageData else { return nil } - let validation = await validateDownload(download) - await notifyObservers() - return validation - } - - private func validateDownload(_ download: DownloadedGallery) async -> DownloadValidationState { - let validation = storage.validate(download: download) + let validation = storage.validate( + download: download, + verifiesContentHashes: true + ) switch validation { case .valid: validationErrors[download.gid] = nil case .missingFiles(let message): - let failure = DownloadFailure( + validationErrors[download.gid] = DownloadFailure( code: .fileOperationFailed, message: message ) - validationErrors[download.gid] = failure } + await notifyObservers() return validation } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index c292053ae..a8627fdd5 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -181,17 +181,13 @@ extension DownloadManager { func loadManifest( gid: String ) async -> Result<(DownloadedGallery, DownloadManifest), AppError> { - let sanitizedDownload = await sanitizeLocalFilesIfNeeded(gid: gid) - let resolvedDownload: DownloadedGallery? - if let sanitizedDownload { - resolvedDownload = sanitizedDownload - } else { - resolvedDownload = await fetchDownload(gid: gid) - } - guard let download = resolvedDownload else { + guard let download = await sanitizeLocalFilesIfNeeded(gid: gid) else { return .failure(.notFound) } - switch storage.validate(download: download) { + switch storage.validate( + download: download, + verifiesContentHashes: false + ) { case .valid: break case .missingFiles(let message): diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index fe8a32316..7f08e9543 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -49,38 +49,6 @@ extension DownloadManager { } } - func buildCompletedPageURLs( - completedFolderURL: URL?, - download: DownloadedGallery - ) -> [Int: URL] { - guard let completedFolderURL else { return [:] } - return storage.imageURLs( - folderURL: completedFolderURL, - manifest: download.manifest - ) - } - - func resolveLocalPageURLs( - completedValidation: DownloadValidationState, - completedFolderURL: URL?, - completedPageURLs: [Int: URL] - ) -> Result<[Int: URL], AppError> { - if completedValidation == .valid, - let completedFolderURL, - fileManager.operate({ $0.fileExists(atPath: completedFolderURL.path) }), - let manifest = try? storage.readManifest( - folderURL: completedFolderURL - ) { - return .success(storage.imageURLs( - folderURL: completedFolderURL, - manifest: manifest - ) - ) - } - - return .success(completedPageURLs) - } - func clearSelectedFailedPages( gid: String, selectedPageIndices: [Int], diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index 8d1f8466f..d6d3c781d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -94,29 +94,12 @@ extension DownloadManager { func loadLocalPageURLs( gid: String ) async -> Result<[Int: URL], AppError> { - let sanitizedDownload = await sanitizeLocalFilesIfNeeded(gid: gid) - let resolvedDownload: DownloadedGallery? - if let sanitizedDownload { - resolvedDownload = sanitizedDownload - } else { - resolvedDownload = await fetchDownload(gid: gid) - } - guard let download = resolvedDownload else { + guard let download = await sanitizeLocalFilesIfNeeded(gid: gid) else { return .failure(.notFound) } - - let completedFolderURL = download.folderURL - let completedValidation = storage.validate(download: download) - - let completedPageURLs = buildCompletedPageURLs( - completedFolderURL: completedFolderURL, - download: download - ) - - return resolveLocalPageURLs( - completedValidation: completedValidation, - completedFolderURL: completedFolderURL, - completedPageURLs: completedPageURLs - ) + return .success(storage.imageURLs( + folderURL: download.folderURL, + manifest: download.manifest + )) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 35df3c595..3b99976e7 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -131,7 +131,6 @@ extension DownloadManager { Logger.error(error) } await reconcileActiveDownloadState() - await validateDownloads() await notifyObservers() guard scheduleNext else { return } await scheduleNextIfNeeded() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index 4bb17ddf5..729387ee2 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -51,7 +51,10 @@ extension DownloadManager { requestedMode: .repair ) } - if case .missingFiles = storage.validate(download: download) { + if case .missingFiles = storage.validate( + download: download, + verifiesContentHashes: false + ) { return .repair } return .redownload diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index f71d91775..74ddd23fd 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -189,7 +189,10 @@ extension DownloadFileStorage { } } - func validate(download: DownloadedGallery) -> DownloadValidationState { + func validate( + download: DownloadedGallery, + verifiesContentHashes: Bool + ) -> DownloadValidationState { let folderURL = download.folderURL guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadFolderMissing) @@ -203,7 +206,8 @@ extension DownloadFileStorage { } if let pageValidationFailure = validatePages( folderURL: folderURL, - manifest: manifest + manifest: manifest, + verifiesContentHashes: verifiesContentHashes ) { return pageValidationFailure } @@ -244,7 +248,8 @@ extension DownloadFileStorage { private func validatePages( folderURL: URL, - manifest: DownloadManifest + manifest: DownloadManifest, + verifiesContentHashes: Bool ) -> DownloadValidationState? { let existingPages = existingPageRelativePaths( folderURL: folderURL, @@ -255,7 +260,8 @@ extension DownloadFileStorage { folderURL: folderURL, index: index, expectedHash: manifest.pages[index] ?? "", - existingPageRelativePaths: existingPages + existingPageRelativePaths: existingPages, + verifiesContentHash: verifiesContentHashes ) { return validationFailure } @@ -267,7 +273,8 @@ extension DownloadFileStorage { folderURL: URL, index: Int, expectedHash: String, - existingPageRelativePaths: [Int: String] + existingPageRelativePaths: [Int: String], + verifiesContentHash: Bool ) -> DownloadValidationState? { guard !expectedHash.isEmpty else { return nil @@ -280,7 +287,7 @@ extension DownloadFileStorage { return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index)) } - if (try? fileHash(at: pageURL)) != expectedHash { + if verifiesContentHash, (try? fileHash(at: pageURL)) != expectedHash { return .missingFiles( L10n.Localizable.DownloadFileStorage.Validation.pageImageCorrupted(index) ) diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index dbe699ed5..b608fcc82 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -24,7 +24,7 @@ struct DownloadFileStorageHashTests { try Data([0x03]).write(to: pageTwoURL, options: .atomic) #expect( - storage.validate(download: download) + storage.validate(download: download, verifiesContentHashes: true) == .missingFiles("Page 2 image data is corrupted.") ) } @@ -51,7 +51,7 @@ struct DownloadFileStorageHashTests { #expect(refreshedManifest.pages[1] == manifest.pages[1]) #expect(refreshedManifest.pages[2] != manifest.pages[2]) - #expect(storage.validate(download: download) == .valid) + #expect(storage.validate(download: download, verifiesContentHashes: true) == .valid) } private func makePreparedDownload( diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index a560b2272..e0d0b098f 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -38,7 +38,7 @@ struct DownloadFileStorageTests { let loadedManifest = try storage.readManifest(folderURL: folderURL) #expect(loadedManifest == manifest) - #expect(storage.validate(download: download) == .valid) + #expect(storage.validate(download: download, verifiesContentHashes: true) == .valid) } @Test @@ -76,7 +76,7 @@ struct DownloadFileStorageTests { ) #expect( - storage.validate(download: download) == .missingFiles("Page 2 is missing.") + storage.validate(download: download, verifiesContentHashes: true) == .missingFiles("Page 2 is missing.") ) } @@ -111,7 +111,7 @@ struct DownloadFileStorageTests { ) #expect( - storage.validate(download: download) == .missingFiles("Page 1 is missing.") + storage.validate(download: download, verifiesContentHashes: true) == .missingFiles("Page 1 is missing.") ) #expect( FileManager.default.fileExists( From 011e6e17ea7ec8ed14994fb10b784939f2c04029 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 22:49:08 +0800 Subject: [PATCH 168/614] Use stable cache keys for prefetch --- EhPanda/App/Tools/Clients/ImageClient.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 963ef5636..4e36e4c0e 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -21,15 +21,22 @@ struct ImageClient: Sendable { extension ImageClient { static let live: Self = .init( prefetchImages: { urls in - let (sdWebImageURLs, kingfisherURLs) = urls.reduce(into: ([URL](), [URL]())) { result, url in + let (sdWebImageURLs, kingfisherResources) = urls.reduce( + into: ([URL](), [any Resource]()) + ) { result, url in if url.isPotentiallyAnimatedImage { result.0.append(url) } else { - result.1.append(url) + result.1.append( + ImageResource( + downloadURL: url, + cacheKey: url.stableImageCacheKey ?? url.absoluteString + ) + ) } } - if !kingfisherURLs.isEmpty { - ImagePrefetcher(urls: kingfisherURLs).start() + if !kingfisherResources.isEmpty { + ImagePrefetcher(resources: kingfisherResources).start() } if !sdWebImageURLs.isEmpty { SDWebImagePrefetcher.shared.prefetchURLs( From b5029ad5bc32ba40353970af8fbda418f91047ec Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 22:50:28 +0800 Subject: [PATCH 169/614] Remove duplicate Core Data model version --- .../Migration/CoreDataMigrationVersion.swift | 4 +- .../Model.xcdatamodeld/.xccurrentversion | 2 +- .../Model 8.xcdatamodel/contents | 66 ------------------- 3 files changed, 2 insertions(+), 70 deletions(-) delete mode 100644 EhPanda/Database/Model.xcdatamodeld/Model 8.xcdatamodel/contents diff --git a/EhPanda/Database/Migration/CoreDataMigrationVersion.swift b/EhPanda/Database/Migration/CoreDataMigrationVersion.swift index ad0b4d8e5..b34968416 100755 --- a/EhPanda/Database/Migration/CoreDataMigrationVersion.swift +++ b/EhPanda/Database/Migration/CoreDataMigrationVersion.swift @@ -14,7 +14,6 @@ enum CoreDataMigrationVersion: String, CaseIterable { case version5 = "Model 5" case version6 = "Model 6" case version7 = "Model 7" - case version8 = "Model 8" static func current() throws -> CoreDataMigrationVersion { guard let latest = allCases.last else { @@ -31,8 +30,7 @@ enum CoreDataMigrationVersion: String, CaseIterable { case .version4: return .version5 case .version5: return .version6 case .version6: return .version7 - case .version7: return .version8 - case .version8: return nil + case .version7: return nil } } } diff --git a/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion b/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion index e46b68c8e..f5b3fac01 100644 --- a/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion +++ b/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Model 8.xcdatamodel + Model 7.xcdatamodel diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 8.xcdatamodel/contents b/EhPanda/Database/Model.xcdatamodeld/Model 8.xcdatamodel/contents deleted file mode 100644 index 3780a4207..000000000 --- a/EhPanda/Database/Model.xcdatamodeld/Model 8.xcdatamodel/contents +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 5eec76898febfb29c06562f14e65b0ff87125c57 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 22:55:31 +0800 Subject: [PATCH 170/614] Delete superseded gallery folders --- .../Clients/DownloadClient+Execution.swift | 28 +++- .../Clients/DownloadClient+PublicAPI.swift | 2 +- .../Tools/Utilities/DownloadFileStorage.swift | 16 +- .../DownloadFolderOperationTests.swift | 151 +++++++++++------- .../DownloadInterruptedResumeTests.swift | 1 + 5 files changed, 131 insertions(+), 67 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 790c4ca33..2357022c6 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -56,7 +56,11 @@ extension DownloadManager { let completedFolderURL = storage.folderURL( relativePath: result.folderRelativePath ) - removeSupersededFolders(gid: gid, keeping: completedFolderURL) + removeSupersededFolders( + gid: gid, + token: download.token, + keeping: completedFolderURL + ) await notifyObservers() } @@ -64,13 +68,21 @@ extension DownloadManager { // (re-slot after a title change), and an interrupted session can leave // both behind; only the completed folder may survive, or the stale // duplicate resurfaces once the surviving record is deleted. - func removeSupersededFolders(gid: String, keeping folderURL: URL) { - let keptPath = folderURL.standardizedFileURL.path - let records = (try? storage.scanDownloadFolders()) ?? [] - for record in records - where record.manifest.gid == gid - && record.folderURL.standardizedFileURL.path != keptPath { - try? storage.removeFolder(at: record.folderURL) + func removeSupersededFolders(gid: String, token: String, keeping folderURL: URL) { + do { + try removeGalleryFolders(gid: gid, token: token, keeping: folderURL) + } catch { + Logger.error(error) + } + } + + func removeGalleryFolders(gid: String, token: String, keeping folderURL: URL? = nil) throws { + let keptPath = folderURL?.standardizedFileURL.path + for galleryFolderURL in storage.galleryFolderURLs(gid: gid, token: token) { + guard galleryFolderURL.standardizedFileURL.path != keptPath else { + continue + } + try storage.removeFolder(at: galleryFolderURL) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index a8627fdd5..976122ca6 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -165,7 +165,7 @@ extension DownloadManager { return .failure(.notFound) } do { - try storage.removeFolder(at: download.folderURL) + try removeGalleryFolders(gid: download.gid, token: download.token) downloadIndex[gid] = nil await notifyObservers() await scheduleNextIfNeeded() diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 4218af65a..770ab4ec0 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -127,7 +127,21 @@ struct DownloadFileStorage: Sendable { } func makeFolderRelativePath(gid: String, token: String, title: String) -> String { - "[\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))] \(normalizedFolderTitle(title))" + "\(galleryFolderNamePrefix(gid: gid, token: token))\(normalizedFolderTitle(title))" + } + + func galleryFolderNamePrefix(gid: String, token: String) -> String { + "[\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))] " + } + + func galleryFolderURLs(gid: String, token: String) -> [URL] { + guard fileManager.operate({ $0.fileExists(atPath: rootURL.path) }) else { + return [] + } + let prefix = galleryFolderNamePrefix(gid: gid, token: token) + return directoryURLs(in: rootURL) + .flatMap { directoryURLs(in: $0) } + .filter { $0.lastPathComponent.hasPrefix(prefix) } } func isGalleryFolderLikeName(_ name: String) -> Bool { diff --git a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift index 8f3b66d4a..0924aed34 100644 --- a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift @@ -11,30 +11,30 @@ import Testing struct DownloadFolderOperationTests: DownloadFeatureTestCase { @Test func testCreateFolderListsFolderAndRejectsDuplicatesAndInvalidNames() async throws { - let (storage, manager, rootURL) = makeManager() - defer { try? FileManager.default.removeItem(at: rootURL) } - try storage.ensureRootDirectory() + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + try environment.storage.ensureRootDirectory() - let created = await manager.createFolder(name: " Favorites ") + let created = await environment.manager.createFolder(name: " Favorites ") guard case .success = created else { Issue.record("Expected create to succeed, got \(created)") return } - #expect(await manager.fetchFolders() == ["Favorites"]) + #expect(await environment.manager.fetchFolders() == ["Favorites"]) - let duplicate = await manager.createFolder(name: "Favorites") + let duplicate = await environment.manager.createFolder(name: "Favorites") guard case .failure = duplicate else { Issue.record("Expected duplicate create to fail") return } - let invalid = await manager.createFolder(name: " ") + let invalid = await environment.manager.createFolder(name: " ") guard case .failure = invalid else { Issue.record("Expected invalid name to fail") return } - let galleryLike = await manager.createFolder(name: "[123_token] Sample") + let galleryLike = await environment.manager.createFolder(name: "[123_token] Sample") guard case .failure = galleryLike else { Issue.record("Expected gallery-like name to fail") return @@ -43,89 +43,120 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { @Test func testRenameFolderRepointsContainedDownloads() async throws { - let (storage, manager, rootURL) = makeManager() - defer { try? FileManager.default.removeItem(at: rootURL) } + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } let gid = "311" - try writeGalleryFolder(storage: storage, folderName: "Old Name", gid: gid) + try writeGalleryFolder(storage: environment.storage, folderName: "Old Name", gid: gid) - let result = await manager.renameFolder(oldName: "Old Name", newName: "New Name") + let result = await environment.manager.renameFolder(oldName: "Old Name", newName: "New Name") guard case .success = result else { Issue.record("Expected rename to succeed, got \(result)") return } - let download = await manager.testingFetchDownload(gid: gid) - #expect(await manager.fetchFolders() == ["New Name"]) + let download = await environment.manager.testingFetchDownload(gid: gid) + #expect(await environment.manager.fetchFolders() == ["New Name"]) #expect(download?.folderName == "New Name") #expect(download?.folderURL.path.contains("/New Name/") == true) } @Test func testRenameFolderRejectsActiveDownloadInside() async throws { - let (storage, manager, rootURL) = makeManager() - defer { try? FileManager.default.removeItem(at: rootURL) } + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } let gid = "312" - try writeGalleryFolder(storage: storage, folderName: "Busy", gid: gid) - _ = await manager.reconcileDownloads() + try writeGalleryFolder(storage: environment.storage, folderName: "Busy", gid: gid) + _ = await environment.manager.reconcileDownloads() let blockingTask = Task { _ = try? await Task.sleep(for: .seconds(60)) } defer { blockingTask.cancel() } - await manager.testingInstallActiveTask(gid: gid, task: blockingTask) + await environment.manager.testingInstallActiveTask(gid: gid, task: blockingTask) - let result = await manager.renameFolder(oldName: "Busy", newName: "Renamed") + let result = await environment.manager.renameFolder(oldName: "Busy", newName: "Renamed") guard case .failure = result else { Issue.record("Expected rename to fail while downloading") return } - #expect(await manager.fetchFolders() == ["Busy"]) + #expect(await environment.manager.fetchFolders() == ["Busy"]) } @Test func testDeleteFolderRemovesContainedDownloadsAndQueueIntents() async throws { - let (storage, manager, rootURL) = makeManager() - defer { try? FileManager.default.removeItem(at: rootURL) } + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } let gid = "313" - let folderURL = try writeGalleryFolder(storage: storage, folderName: "Doomed", gid: gid) - await manager.testingSetQueuedGalleryIDs([gid]) + let folderURL = try writeGalleryFolder(storage: environment.storage, folderName: "Doomed", gid: gid) + await environment.manager.testingSetQueuedGalleryIDs([gid]) - let result = await manager.deleteFolder(name: "Doomed") + let result = await environment.manager.deleteFolder(name: "Doomed") guard case .success = result else { Issue.record("Expected delete to succeed, got \(result)") return } - #expect(await manager.fetchFolders().isEmpty) - #expect(await manager.testingFetchDownload(gid: gid) == nil) + #expect(await environment.manager.fetchFolders().isEmpty) + #expect(await environment.manager.testingFetchDownload(gid: gid) == nil) #expect(!FileManager.default.fileExists(atPath: folderURL.path)) } @Test - func testMoveDownloadRelocatesGalleryFolder() async throws { - let (storage, manager, rootURL) = makeManager() - defer { try? FileManager.default.removeItem(at: rootURL) } + func testDeleteDownloadRemovesSupersededSameIdentityFolders() async throws { + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } let gid = "314" - let sourceURL = try writeGalleryFolder(storage: storage, folderName: "Source", gid: gid) + let oldFolderURL = try writeGalleryFolder( + storage: environment.storage, + folderName: "Saved", + gid: gid, + galleryFolderName: "[\(gid)_token] Old Title" + ) + let currentFolderURL = try writeGalleryFolder( + storage: environment.storage, + folderName: "Saved", + gid: gid, + galleryFolderName: "[\(gid)_token] Current Title" + ) + await environment.manager.reconcileDownloads() + + let result = await environment.manager.delete(gid: gid) + guard case .success = result else { + Issue.record("Expected delete to succeed, got \(result)") + return + } + + await environment.manager.reconcileDownloads() + #expect(await environment.manager.testingFetchDownload(gid: gid) == nil) + #expect(!FileManager.default.fileExists(atPath: oldFolderURL.path)) + #expect(!FileManager.default.fileExists(atPath: currentFolderURL.path)) + } + + @Test + func testMoveDownloadRelocatesGalleryFolder() async throws { + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + let gid = "315" + let sourceURL = try writeGalleryFolder(storage: environment.storage, folderName: "Source", gid: gid) - let result = await manager.moveDownload(gid: gid, toFolderName: "Target") + let result = await environment.manager.moveDownload(gid: gid, toFolderName: "Target") guard case .success = result else { Issue.record("Expected move to succeed, got \(result)") return } - let download = await manager.testingFetchDownload(gid: gid) + let download = await environment.manager.testingFetchDownload(gid: gid) #expect(download?.folderName == "Target") #expect(download?.folderURL.path.contains("/Target/") == true) #expect(!FileManager.default.fileExists(atPath: sourceURL.path)) - #expect(await manager.fetchFolders() == ["Source", "Target"]) + #expect(await environment.manager.fetchFolders() == ["Source", "Target"]) } @Test func testMoveDownloadIntoSameFolderIsNoOp() async throws { - let (storage, manager, rootURL) = makeManager() - defer { try? FileManager.default.removeItem(at: rootURL) } - let gid = "315" - let folderURL = try writeGalleryFolder(storage: storage, folderName: "Home", gid: gid) + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + let gid = "316" + let folderURL = try writeGalleryFolder(storage: environment.storage, folderName: "Home", gid: gid) - let result = await manager.moveDownload(gid: gid, toFolderName: "Home") + let result = await environment.manager.moveDownload(gid: gid, toFolderName: "Home") guard case .success = result else { Issue.record("Expected same-folder move to succeed, got \(result)") return @@ -135,16 +166,16 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { @Test func testMoveDownloadRejectsActivelyDownloadingGallery() async throws { - let (storage, manager, rootURL) = makeManager() - defer { try? FileManager.default.removeItem(at: rootURL) } - let gid = "316" - let folderURL = try writeGalleryFolder(storage: storage, folderName: "Working", gid: gid) - _ = await manager.reconcileDownloads() + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + let gid = "317" + let folderURL = try writeGalleryFolder(storage: environment.storage, folderName: "Working", gid: gid) + _ = await environment.manager.reconcileDownloads() let blockingTask = Task { _ = try? await Task.sleep(for: .seconds(60)) } defer { blockingTask.cancel() } - await manager.testingInstallActiveTask(gid: gid, task: blockingTask) + await environment.manager.testingInstallActiveTask(gid: gid, task: blockingTask) - let result = await manager.moveDownload(gid: gid, toFolderName: "Elsewhere") + let result = await environment.manager.moveDownload(gid: gid, toFolderName: "Elsewhere") guard case .failure = result else { Issue.record("Expected move of active download to fail") return @@ -154,24 +185,24 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { @Test func testEnqueueKeepsExistingDownloadInItsFolder() async throws { - let (storage, manager, rootURL) = makeManager() - defer { try? FileManager.default.removeItem(at: rootURL) } - await manager.testingInstallActiveTask(gid: "busy", task: Task {}) + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + await environment.manager.testingInstallActiveTask(gid: "busy", task: Task {}) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let galleryFolderName = storage.makeFolderRelativePath( + let galleryFolderName = environment.storage.makeFolderRelativePath( gid: gallery.gid, token: gallery.token, title: detail.trimmedTitle ) try writeGalleryFolder( - storage: storage, + storage: environment.storage, folderName: "Original", gid: gallery.gid, galleryFolderName: galleryFolderName ) - _ = await manager.reconcileDownloads() + _ = await environment.manager.reconcileDownloads() let payload = DownloadRequestPayload( gallery: gallery, @@ -183,26 +214,32 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { options: .init(), mode: .initial ) - let result = await manager.enqueue(payload: payload) + let result = await environment.manager.enqueue(payload: payload) guard case .success = result else { Issue.record("Expected enqueue to succeed, got \(result)") return } - let download = await manager.testingFetchDownload(gid: gallery.gid) + let download = await environment.manager.testingFetchDownload(gid: gallery.gid) #expect(download?.folderName == "Original") } } // MARK: - Setup Helpers +private struct DownloadFolderOperationTestEnvironment { + let storage: DownloadFileStorage + let manager: DownloadManager + let rootURL: URL +} + private extension DownloadFolderOperationTests { - func makeManager() -> (DownloadFileStorage, DownloadManager, URL) { + func makeManager() -> DownloadFolderOperationTestEnvironment { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) - return (storage, manager, rootURL) + return .init(storage: storage, manager: manager, rootURL: rootURL) } @discardableResult diff --git a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift index fbc1d73ab..6abfdaba8 100644 --- a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -151,6 +151,7 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { await manager.removeSupersededFolders( gid: gid, + token: "token", keeping: completedFolderURL ) From 819117dc7ea0d3a58411a36d1288852264e73d81 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 22:57:12 +0800 Subject: [PATCH 171/614] Fix preview batch boundary --- EhPanda/Models/Gallery/GalleryState.swift | 2 +- .../Tests/Download/DownloadRetryMinimalSourceTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/EhPanda/Models/Gallery/GalleryState.swift b/EhPanda/Models/Gallery/GalleryState.swift index 204a8f1a1..5744e4b56 100644 --- a/EhPanda/Models/Gallery/GalleryState.swift +++ b/EhPanda/Models/Gallery/GalleryState.swift @@ -89,7 +89,7 @@ extension PreviewConfig { } func pageNumber(index: Int) -> Int { - index / batchSize + max(index - 1, 0) / batchSize } func batchRange(index: Int) -> ClosedRange { let lowerBound = pageNumber(index: index) * batchSize + 1 diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 5d08d4a67..71e676cb7 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -13,7 +13,7 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { func testRetryPagesUsesMinimalSourceResolutionAndSkipsWhenNoPendingPages() async throws { let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 200) - let pageIndex = 42 + let pageIndex = 40 let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -53,7 +53,7 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { let firstRunSnapshot = setup.recorder.snapshot() #expect( - firstRunSnapshot.previewPageNumbers == [1], + firstRunSnapshot.previewPageNumbers == [0], "\(firstRunSnapshot)" ) From b98c3d8f03e6da09866bab0f92a2b018894f1a01 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 23:01:10 +0800 Subject: [PATCH 172/614] Stop page batch on fatal account errors --- .../Clients/DownloadClient+PageDownload.swift | 31 ++++++- .../Download/DownloadImageParsingTests.swift | 91 +++++++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index b617a85bd..544edbe2a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -43,12 +43,14 @@ extension DownloadManager { let remainingPageIndices = pendingPageIndices .filter { !restoredIndices.contains($0) } var wasCancelled = false + var didAbortForFatalError = false await processRemainingPages( context: context, remainingPageIndices: remainingPageIndices, existingPages: existingPages, progress: &progress, - wasCancelled: &wasCancelled + wasCancelled: &wasCancelled, + didAbortForFatalError: &didAbortForFatalError ) if wasCancelled || Task.isCancelled { @@ -151,7 +153,8 @@ extension DownloadManager { remainingPageIndices: [Int], existingPages: [Int: String], progress: inout PageDownloadProgress, - wasCancelled: inout Bool + wasCancelled: inout Bool, + didAbortForFatalError: inout Bool ) async { let payload = context.payload await withTaskGroup(of: PageTaskOutcome.self) { group in @@ -165,6 +168,10 @@ extension DownloadManager { existingPages: existingPages ) while let outcome = await group.next() { + guard !didAbortForFatalError else { + group.cancelAll() + continue + } if wasCancelled || Task.isCancelled || schedulingBlockedGalleryIDs .contains(payload.gallery.gid) { @@ -176,9 +183,10 @@ extension DownloadManager { outcome, progress: &progress, wasCancelled: &wasCancelled, + didAbortForFatalError: &didAbortForFatalError, group: &group ) - guard !wasCancelled else { continue } + guard !wasCancelled, !didAbortForFatalError else { continue } try? await flushDownloadProgress( context: .init( gid: payload.gallery.gid, @@ -224,6 +232,7 @@ extension DownloadManager { _ outcome: PageTaskOutcome, progress: inout PageDownloadProgress, wasCancelled: inout Bool, + didAbortForFatalError: inout Bool, group: inout TaskGroup ) { switch outcome { @@ -240,8 +249,13 @@ extension DownloadManager { return } progress.failedPages[failure.index] = failure + if isFatalAccountAppError(failure.error) { + didAbortForFatalError = true + group.cancelAll() + } case .cancelled: + guard !didAbortForFatalError else { return } wasCancelled = true group.cancelAll() } @@ -289,4 +303,15 @@ extension DownloadManager { } } } + + private func isFatalAccountAppError(_ error: AppError) -> Bool { + switch error { + case .quotaExceeded, .authenticationRequired, .ipBanned: + return true + case .databaseCorrupted, .copyrightClaim, .expunged, .networkingFailed, + .webImageFailed, .parseFailed, .fileOperationFailed, .noUpdates, + .notFound, .unknown: + return false + } + } } diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift index ed8d30aad..db9bad8fb 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift @@ -152,6 +152,97 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { #expect(error == .quotaExceeded) } + @Test + func testFatalAccountPageFailureStopsSchedulingRemainingPages() async throws { + let sessionID = UUID().uuidString + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [ + SharedSessionStubURLProtocol.headerKey: sessionID + ] + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + let recorder = RequestRecorder() + let quotaHTML = Data(""" + You have exceeded your image viewing limits + """.utf8) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + recorder.recordImageDownload() + return ( + try #require(HTTPURLResponse( + url: request.url ?? Defaults.URL.ehentai, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html"] + )), + quotaHTML + ) + } + defer { + SharedSessionStubURLProtocol.removeHandler(for: sessionID) + } + + var gallery = sampleGallery() + gallery.pageCount = 3 + var detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + detail.pageCount = 3 + var options = DownloadRequestOptions() + options.threadLimit = 1 + options.autoRetryFailedPages = false + let payload = DownloadRequestPayload( + gallery: gallery, + galleryDetail: detail, + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + folderName: "Folder", + options: options, + mode: .initial + ) + let galleryFolderName = storage.makeFolderRelativePath( + gid: gallery.gid, + token: gallery.token, + title: detail.trimmedTitle + ) + let folderURL = storage.folderURL( + relativePath: "Folder/\(galleryFolderName)" + ) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + let manifest = try sampleManifest( + gid: gallery.gid, + title: gallery.title, + pageCount: detail.pageCount + ) + let batchResult = try await manager.downloadPages( + context: .init( + payload: payload, + source: .normal([ + 1: try #require(URL(string: "https://example.com/1.html")), + 2: try #require(URL(string: "https://example.com/2.html")), + 3: try #require(URL(string: "https://example.com/3.html")) + ]), + folderURL: folderURL + ), + pendingPageIndices: [1, 2, 3], + existingManifest: manifest, + existingPageRelativePaths: [:] + ) + + #expect(batchResult.failedPages.map(\.index) == [1]) + #expect(batchResult.failedPages.first?.error == .quotaExceeded) + #expect(recorder.snapshot().imageDownloads == 1) + } + @MainActor @Test func testCachedQuotaPlaceholderStoredUnderNormalImageURLIsRejected() async throws { From 43b4463a5e84c7d683ea5e4522e89677e3018d24 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 23:03:16 +0800 Subject: [PATCH 173/614] Preserve manifest on re-enqueue --- .../Clients/DownloadClient+PublicAPI.swift | 27 +++++++ .../DownloadEnqueueManifestTests.swift | 71 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 976122ca6..2cb33cac4 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -120,11 +120,38 @@ extension DownloadManager { ) throws { let folderURL = storage.folderURL(relativePath: folderRelativePath) try createDirectory(at: folderURL) + if let existingManifest = reusableExistingManifest( + payload: payload, + folderURL: folderURL + ) { + updateDownloadIndex(folderURL: folderURL, manifest: existingManifest) + return + } let manifest = makeInitialManifest(payload: payload) try storage.writeManifest(manifest, folderURL: folderURL) updateDownloadIndex(folderURL: folderURL, manifest: manifest) } + private func reusableExistingManifest( + payload: DownloadRequestPayload, + folderURL: URL + ) -> DownloadManifest? { + guard let manifest = try? storage.readManifest(folderURL: folderURL), + manifest.gid == payload.gallery.gid, + manifest.token == payload.gallery.token, + manifest.host == payload.host + else { + return nil + } + let expectedPageIndices = payload.galleryDetail.pageCount > 0 + ? Set(1...payload.galleryDetail.pageCount) + : Set() + guard Set(manifest.pages.keys) == expectedPageIndices else { + return nil + } + return manifest + } + func togglePause(gid: String) async -> Result { guard let download = await fetchDownload(gid: gid) else { return .failure(.notFound) diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index ec71e127e..309de75dd 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -75,4 +75,75 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { #expect(queuedDownload?.onlineCoverURL == detail.coverURL) #expect(queuedDownload?.pageCount == detail.pageCount) } + + @Test + func testEnqueuePreservesExistingManifestHashes() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + queueStore: queueStore + ) + await manager.testingInstallActiveTask(gid: "busy", task: Task {}) + + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let folderRelativePath = "Folder/" + storage.makeFolderRelativePath( + gid: gallery.gid, + token: gallery.token, + title: detail.trimmedTitle + ) + let folderURL = storage.folderURL(relativePath: folderRelativePath) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + let pages = Dictionary( + uniqueKeysWithValues: (1...detail.pageCount).map { + ($0, "sha256:existing-\($0)") + } + ) + let existingManifest = DownloadManifest( + gid: gallery.gid, + host: .ehentai, + token: gallery.token, + title: gallery.title, + jpnTitle: detail.jpnTitle, + category: gallery.category, + language: detail.language, + remoteCoverURL: detail.coverURL, + uploader: detail.uploader, + tags: gallery.tags, + postedDate: detail.postedDate, + rating: detail.rating, + pages: pages + ) + try storage.writeManifest(existingManifest, folderURL: folderURL) + + let result = await manager.enqueue(payload: .init( + gallery: gallery, + galleryDetail: detail, + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + folderName: "Folder", + options: .init(threadLimit: 3), + mode: .initial + )) + + guard case .success = result else { + Issue.record("Expected enqueue to succeed, got \(result).") + return + } + + let preservedManifest = try storage.readManifest(folderURL: folderURL) + #expect(preservedManifest.pages == pages) + #expect(queueStore.gids == [gallery.gid]) + #expect(await manager.testingFetchDownload(gid: gallery.gid)?.completedPageCount == detail.pageCount) + } } From b2b83e933f565355c250ad0e3d12b1f2fca32b38 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 23:05:51 +0800 Subject: [PATCH 174/614] Truncate folder names by UTF-8 bytes --- .../Tools/Utilities/DownloadFileStorage.swift | 97 ++++++++++++------- .../Download/DownloadFileStorageTests.swift | 29 +++++- 2 files changed, 86 insertions(+), 40 deletions(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 770ab4ec0..ab86974ba 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -25,7 +25,7 @@ struct DownloadScanResult: Equatable, Sendable { } struct DownloadFileStorage: Sendable { - private static let maxFolderTitleLength = 96 + private static let maxFolderComponentByteCount = 255 let rootURL: URL let fileManager: DownloadFileManager @@ -127,7 +127,9 @@ struct DownloadFileStorage: Sendable { } func makeFolderRelativePath(gid: String, token: String, title: String) -> String { - "\(galleryFolderNamePrefix(gid: gid, token: token))\(normalizedFolderTitle(title))" + let prefix = galleryFolderNamePrefix(gid: gid, token: token) + let titleByteCount = max(Self.maxFolderComponentByteCount - prefix.utf8.count, 0) + return "\(prefix)\(normalizedFolderTitle(title, maximumUTF8ByteCount: titleByteCount))" } func galleryFolderNamePrefix(gid: String, token: String) -> String { @@ -149,6 +151,38 @@ struct DownloadFileStorage: Sendable { } func normalizedUserFolderName(_ name: String) -> String? { + guard let limitedName = normalizedFolderName( + name, + trimsLeadingDots: true, + fallback: nil, + maximumUTF8ByteCount: Self.maxFolderComponentByteCount + ) else { + return nil + } + guard !limitedName.isEmpty, !isGalleryFolderLikeName(limitedName) else { + return nil + } + return limitedName + } + + private func normalizedFolderTitle( + _ title: String, + maximumUTF8ByteCount: Int + ) -> String { + normalizedFolderName( + title, + trimsLeadingDots: false, + fallback: "Gallery", + maximumUTF8ByteCount: maximumUTF8ByteCount + ) ?? "Gallery" + } + + private func normalizedFolderName( + _ name: String, + trimsLeadingDots: Bool, + fallback: String?, + maximumUTF8ByteCount: Int + ) -> String? { let invalidCharacters = CharacterSet(charactersIn: "/\\:") .union(.controlCharacters) let sanitizedScalars = name @@ -161,53 +195,27 @@ struct DownloadFileStorage: Sendable { with: " ", options: .regularExpression ) + let trimPattern = trimsLeadingDots + ? "^[\\s.]+|[\\s.]+$" + : "^\\s+|[\\s.]+$" let trimmedName = collapsedWhitespace.replacingOccurrences( - of: "^[\\s.]+|[\\s.]+$", + of: trimPattern, with: "", options: .regularExpression ) - let limitedName = String(trimmedName.prefix(Self.maxFolderTitleLength)) + let limitedName = trimmedName + .truncatedToUTF8ByteCount(maximumUTF8ByteCount) .replacingOccurrences( of: "[\\s.]+$", with: "", options: .regularExpression ) - guard !limitedName.isEmpty, !isGalleryFolderLikeName(limitedName) else { - return nil + if limitedName.isEmpty { + return fallback } return limitedName } - private func normalizedFolderTitle(_ title: String) -> String { - let invalidCharacters = CharacterSet(charactersIn: "/\\:") - .union(.controlCharacters) - let sanitizedScalars = title - .trimmingCharacters(in: .whitespacesAndNewlines) - .unicodeScalars - .map { invalidCharacters.contains($0) ? " " : String($0) } - .joined() - let collapsedWhitespace = sanitizedScalars.replacingOccurrences( - of: "\\s+", - with: " ", - options: .regularExpression - ) - let trimmedSlug = collapsedWhitespace - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences( - of: "[\\s.]+$", - with: "", - options: .regularExpression - ) - let limitedSlug = String(trimmedSlug.prefix(Self.maxFolderTitleLength)) - .replacingOccurrences( - of: "[\\s.]+$", - with: "", - options: .regularExpression - ) - let fallbackTitle = limitedSlug.isEmpty ? "Gallery" : limitedSlug - return fallbackTitle - } - private func normalizedIdentityComponent(_ value: String) -> String { let invalidCharacters = CharacterSet(charactersIn: "/\\[]:") .union(.controlCharacters) @@ -410,3 +418,20 @@ struct DownloadFileStorage: Sendable { } } } + +private extension String { + func truncatedToUTF8ByteCount(_ maximumByteCount: Int) -> String { + guard maximumByteCount > 0 else { return "" } + var byteCount = 0 + var result = "" + for character in self { + let characterByteCount = String(character).utf8.count + guard byteCount + characterByteCount <= maximumByteCount else { + break + } + result.append(character) + byteCount += characterByteCount + } + return result + } +} diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index e0d0b098f..1cf1a3a8c 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -234,7 +234,7 @@ struct DownloadFileStorageTests { } @Test - func testMakeFolderRelativePathSanitizesSeparatorsWhitespaceAndLength() { + func testMakeFolderRelativePathSanitizesSeparatorsWhitespaceAndLength() throws { let (storage, rootURL) = makeStorage() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -248,7 +248,18 @@ struct DownloadFileStorageTests { #expect(relativePath.contains("\n") == false) #expect(relativePath.hasSuffix(" ") == false) #expect(relativePath.hasSuffix(".") == false) - #expect(relativePath.count <= "[123_token] ".count + 96) + #expect(relativePath.utf8.count <= 255) + + let cjkRelativePath = storage.makeFolderRelativePath( + gid: "123", + token: "token", + title: String(repeating: "語", count: 120) + ) + #expect(cjkRelativePath.utf8.count <= 255) + try FileManager.default.createDirectory( + at: storage.folderURL(relativePath: "Folder/\(cjkRelativePath)"), + withIntermediateDirectories: true + ) } @Test @@ -345,8 +356,9 @@ struct DownloadFileStorageTests { } @Test - func testUserFolderNameNormalizationRejectsInvalidNames() { - let (storage, _) = makeStorage() + func testUserFolderNameNormalizationRejectsInvalidNames() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } #expect(storage.normalizedUserFolderName(" My Folder ") == "My Folder") #expect(storage.normalizedUserFolderName("a/b:c") == "a b c") @@ -355,6 +367,15 @@ struct DownloadFileStorageTests { #expect(storage.normalizedUserFolderName("") == nil) #expect(storage.normalizedUserFolderName(".hidden") == "hidden") #expect(storage.normalizedUserFolderName("[123_token] Sample") == nil) + + let cjkName = try #require( + storage.normalizedUserFolderName(String(repeating: "語", count: 120)) + ) + #expect(cjkName.utf8.count <= 255) + try FileManager.default.createDirectory( + at: storage.userFolderURL(name: cjkName), + withIntermediateDirectories: true + ) } @Test From 95bcb2555af397b6d363aef320f56349648b1869 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 23:12:54 +0800 Subject: [PATCH 175/614] Normalize folder edits and show failures --- .../Tools/Utilities/DownloadFileStorage.swift | 18 ++- .../View/Downloads/FolderManagerReducer.swift | 55 ++++++-- .../View/Downloads/FolderManagerView.swift | 36 +++-- .../Download/FolderManagerReducerTests.swift | 131 ++++++++++++++++-- 4 files changed, 201 insertions(+), 39 deletions(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index ab86974ba..010da16fc 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -146,16 +146,20 @@ struct DownloadFileStorage: Sendable { .filter { $0.lastPathComponent.hasPrefix(prefix) } } - func isGalleryFolderLikeName(_ name: String) -> Bool { + static func isGalleryFolderLikeName(_ name: String) -> Bool { name.range(of: #"^\[[^\]]*_[^\]]*\] "#, options: .regularExpression) != nil } - func normalizedUserFolderName(_ name: String) -> String? { + func isGalleryFolderLikeName(_ name: String) -> Bool { + Self.isGalleryFolderLikeName(name) + } + + static func normalizedUserFolderName(_ name: String) -> String? { guard let limitedName = normalizedFolderName( name, trimsLeadingDots: true, fallback: nil, - maximumUTF8ByteCount: Self.maxFolderComponentByteCount + maximumUTF8ByteCount: maxFolderComponentByteCount ) else { return nil } @@ -165,11 +169,15 @@ struct DownloadFileStorage: Sendable { return limitedName } + func normalizedUserFolderName(_ name: String) -> String? { + Self.normalizedUserFolderName(name) + } + private func normalizedFolderTitle( _ title: String, maximumUTF8ByteCount: Int ) -> String { - normalizedFolderName( + Self.normalizedFolderName( title, trimsLeadingDots: false, fallback: "Gallery", @@ -177,7 +185,7 @@ struct DownloadFileStorage: Sendable { ) ?? "Gallery" } - private func normalizedFolderName( + private static func normalizedFolderName( _ name: String, trimsLeadingDots: Bool, fallback: String?, diff --git a/EhPanda/View/Downloads/FolderManagerReducer.swift b/EhPanda/View/Downloads/FolderManagerReducer.swift index d9e58e22e..86744dd8d 100644 --- a/EhPanda/View/Downloads/FolderManagerReducer.swift +++ b/EhPanda/View/Downloads/FolderManagerReducer.swift @@ -22,6 +22,12 @@ struct FolderManagerReducer { case fetchFolders } + private static var invalidFolderNameError: AppError { + .fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Error.invalidFolderName + ) + } + @ObservableState struct State: Equatable { var route: Route? @@ -30,10 +36,20 @@ struct FolderManagerReducer { var loadingState: LoadingState = .idle var folders = [String]() + var normalizedEditingFolderName: String? { + DownloadFileStorage.normalizedUserFolderName(editingFolderName) + } + var isEditingNameValid: Bool { - let trimmedName = editingFolderName - .trimmingCharacters(in: .whitespacesAndNewlines) - return !trimmedName.isEmpty && !folders.contains(trimmedName) + guard let normalizedName = normalizedEditingFolderName else { + return false + } + switch editingField { + case .renameFolder(let oldName): + return normalizedName == oldName || !folders.contains(normalizedName) + case .newFolder, nil: + return !folders.contains(normalizedName) + } } } @@ -93,29 +109,52 @@ struct FolderManagerReducer { } case .createFolder: - return .run { [name = state.editingFolderName] send in + guard let name = state.normalizedEditingFolderName else { + state.loadingState = .failed(Self.invalidFolderNameError) + return .none + } + state.loadingState = .loading + return .run { send in await send(.createFolderDone(await downloadClient.createFolder(name))) } - case .createFolderDone: + case .createFolderDone(.success): return .send(.fetchFolders) + case .createFolderDone(.failure(let error)): + state.loadingState = .failed(error) + return .none + case .renameFolder(let oldName): - return .run { [newName = state.editingFolderName] send in + guard let newName = state.normalizedEditingFolderName else { + state.loadingState = .failed(Self.invalidFolderNameError) + return .none + } + state.loadingState = .loading + return .run { send in await send(.renameFolderDone(await downloadClient.renameFolder(oldName, newName))) } - case .renameFolderDone: + case .renameFolderDone(.success): return .send(.fetchFolders) + case .renameFolderDone(.failure(let error)): + state.loadingState = .failed(error) + return .none + case .deleteFolder(let name): + state.loadingState = .loading return .run { send in await send(.deleteFolderDone(await downloadClient.deleteFolder(name))) } - case .deleteFolderDone: + case .deleteFolderDone(.success): return .send(.fetchFolders) + case .deleteFolderDone(.failure(let error)): + state.loadingState = .failed(error) + return .none + case .teardown: return .cancel(id: CancelID.fetchFolders) diff --git a/EhPanda/View/Downloads/FolderManagerView.swift b/EhPanda/View/Downloads/FolderManagerView.swift index 2a0435f4e..e35b09f01 100644 --- a/EhPanda/View/Downloads/FolderManagerView.swift +++ b/EhPanda/View/Downloads/FolderManagerView.swift @@ -55,19 +55,7 @@ struct FolderManagerView: View { } } - LoadingView().opacity( - store.loadingState == .loading && store.folders.isEmpty ? 1 : 0 - ) - AlertView( - symbol: .folder, - message: L10n.Localizable.FolderManagerView.EmptyState.folders - ) { - EmptyView() - } - .opacity( - store.loadingState != .loading && store.folders.isEmpty - && store.editingField != .newFolder ? 1 : 0 - ) + stateOverlay } .animation(.default, value: store.folders) .animation(.default, value: store.editingField) @@ -81,6 +69,28 @@ struct FolderManagerView: View { } } + @ViewBuilder private var stateOverlay: some View { + switch store.loadingState { + case .loading where store.folders.isEmpty: + LoadingView() + + case .failed(let error): + ErrorView(error: error) { + store.send(.fetchFolders) + } + + case .idle, .loading: + if store.folders.isEmpty && store.editingField != .newFolder { + AlertView( + symbol: .folder, + message: L10n.Localizable.FolderManagerView.EmptyState.folders + ) { + EmptyView() + } + } + } + } + private var newFolderRow: some View { Label { editingTextField(.newFolder) diff --git a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift index cf47fd9c5..4011ca22f 100644 --- a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift +++ b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift @@ -27,7 +27,7 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { @MainActor @Test - func testCreateFolderForwardsEditingNameAndRefetches() async { + func testCreateFolderForwardsNormalizedEditingNameAndRefetches() async { let createdName = UncheckedBox(nil) let store = makeStore( folders: { createdName.value.map { [$0] } ?? [] }, @@ -38,20 +38,23 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { ) store.exhaustivity = .off - await store.send(.binding(.set(\.editingFolderName, "Favorites"))) - await store.send(.createFolder) + await store.send(.binding(.set(\.editingFolderName, " Favorites/2026 "))) + await store.send(.createFolder) { + $0.loadingState = .loading + } await store.receive(\.createFolderDone) await store.receive(\.fetchFolders) await store.receive(\.fetchFoldersDone) { - $0.folders = ["Favorites"] + $0.loadingState = .idle + $0.folders = ["Favorites 2026"] } - #expect(createdName.value == "Favorites") + #expect(createdName.value == "Favorites 2026") } @MainActor @Test - func testRenameFolderForwardsOriginalAndEditedNames() async { + func testRenameFolderForwardsOriginalAndNormalizedEditedNames() async { let renamedPair = UncheckedBox<(String, String)?>(nil) let store = makeStore( folders: { ["New Name"] }, @@ -62,10 +65,14 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { ) store.exhaustivity = .off - await store.send(.binding(.set(\.editingFolderName, "New Name"))) - await store.send(.renameFolder("Old Name")) + await store.send(.binding(.set(\.editingFolderName, " New/Name "))) + await store.send(.renameFolder("Old Name")) { + $0.loadingState = .loading + } await store.receive(\.renameFolderDone) + await store.receive(\.fetchFolders) await store.receive(\.fetchFoldersDone) { + $0.loadingState = .idle $0.folders = ["New Name"] } @@ -86,9 +93,13 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { ) store.exhaustivity = .off - await store.send(.deleteFolder("Doomed")) + await store.send(.deleteFolder("Doomed")) { + $0.loadingState = .loading + } await store.receive(\.deleteFolderDone) + await store.receive(\.fetchFolders) await store.receive(\.fetchFoldersDone) { + $0.loadingState = .idle $0.folders = [] } @@ -137,9 +148,13 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { await store.send(.submitEditingField) { $0.editingField = nil } - await store.receive(\.createFolder) + await store.receive(\.createFolder) { + $0.loadingState = .loading + } await store.receive(\.createFolderDone) + await store.receive(\.fetchFolders) await store.receive(\.fetchFoldersDone) { + $0.loadingState = .idle $0.folders = ["Favorites"] } @@ -167,9 +182,13 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { await store.send(.submitEditingField) { $0.editingField = nil } - await store.receive(\.renameFolder) + await store.receive(\.renameFolder) { + $0.loadingState = .loading + } await store.receive(\.renameFolderDone) + await store.receive(\.fetchFolders) await store.receive(\.fetchFoldersDone) { + $0.loadingState = .idle $0.folders = ["New Name"] } @@ -192,9 +211,9 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { @MainActor @Test - func testEditingNameValidationRejectsBlankAndDuplicateNames() { + func testEditingNameValidationRejectsBlankAndNormalizedDuplicateNames() { var state = FolderManagerReducer.State() - state.folders = ["Existing"] + state.folders = ["Existing", "a b c"] state.editingFolderName = " " #expect(state.isEditingNameValid == false) @@ -202,9 +221,95 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { state.editingFolderName = "Existing" #expect(state.isEditingNameValid == false) + state.editingFolderName = "a/b:c" + #expect(state.isEditingNameValid == false) + state.editingFolderName = "Fresh" #expect(state.isEditingNameValid) } + + @MainActor + @Test + func testRenameValidationAllowsNormalizedCurrentFolderName() { + var state = FolderManagerReducer.State() + state.editingField = .renameFolder("a b c") + state.folders = ["a b c"] + state.editingFolderName = "a/b:c" + + #expect(state.isEditingNameValid) + } + + @MainActor + @Test + func testCreateFolderFailureSetsFailedStateWithoutRefetching() async { + let fetchCount = UncheckedBox(0) + let error = AppError.fileOperationFailed("disk full") + let store = makeStore( + folders: { + fetchCount.value += 1 + return [] + }, + createFolder: { _ in .failure(error) } + ) + + await store.send(.binding(.set(\.editingFolderName, "Favorites"))) { + $0.editingFolderName = "Favorites" + } + await store.send(.createFolder) { + $0.loadingState = .loading + } + await store.receive(\.createFolderDone) { + $0.loadingState = .failed(error) + } + #expect(fetchCount.value == 0) + } + + @MainActor + @Test + func testRenameFolderFailureSetsFailedStateWithoutRefetching() async { + let fetchCount = UncheckedBox(0) + let error = AppError.fileOperationFailed("folder busy") + let store = makeStore( + folders: { + fetchCount.value += 1 + return [] + }, + renameFolder: { _, _ in .failure(error) } + ) + + await store.send(.binding(.set(\.editingFolderName, "New Name"))) { + $0.editingFolderName = "New Name" + } + await store.send(.renameFolder("Old Name")) { + $0.loadingState = .loading + } + await store.receive(\.renameFolderDone) { + $0.loadingState = .failed(error) + } + #expect(fetchCount.value == 0) + } + + @MainActor + @Test + func testDeleteFolderFailureSetsFailedStateWithoutRefetching() async { + let fetchCount = UncheckedBox(0) + let error = AppError.fileOperationFailed("permission denied") + let store = makeStore( + folders: { + fetchCount.value += 1 + return [] + }, + deleteFolder: { _ in .failure(error) } + ) + + await store.send(.deleteFolder("Doomed")) { + $0.loadingState = .loading + } + await store.receive(\.deleteFolderDone) { + $0.loadingState = .failed(error) + } + #expect(fetchCount.value == 0) + } } // MARK: - Store Factory Helpers From f8a46305ca5b40bcc99c2aa207bfdc1522824508 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 23:16:34 +0800 Subject: [PATCH 176/614] Scope detail cancellation by gallery --- .../View/Detail/DetailReducer+Download.swift | 9 ++-- EhPanda/View/Detail/DetailReducer+Fetch.swift | 29 +++++++----- EhPanda/View/Detail/DetailReducer.swift | 47 ++++++++++++++----- .../Download/DetailReducerObserveTests.swift | 10 ++++ 4 files changed, 67 insertions(+), 28 deletions(-) diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index 684412d5b..e30863425 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -16,10 +16,11 @@ extension DetailReducer { case .fetchDownloadBadgeDone(let download): return handleFetchDownloadBadgeDone(download: download, state: &state) case .fetchDownloadFolders: + let cancellationID = CancelID.fetchDownloadFolders(state.cancellationGalleryID) return .run { send in await send(.fetchDownloadFoldersDone(await downloadClient.fetchFolders())) } - .cancellable(id: CancelID.fetchDownloadFolders, cancelInFlight: true) + .cancellable(id: cancellationID, cancelInFlight: true) case .fetchDownloadFoldersDone(let folders): state.downloadFolders = folders return .none @@ -69,7 +70,7 @@ extension DetailReducer { let download = await downloadClient.fetchDownload(galleryID) await send(.fetchDownloadBadgeDone(download)) } - .cancellable(id: CancelID.fetchDownloadBadge, cancelInFlight: true) + .cancellable(id: CancelID.fetchDownloadBadge(state.gid), cancelInFlight: true) } private func handleFetchDownloadBadgeDone( @@ -92,7 +93,7 @@ extension DetailReducer { await send(.observeDownloadDone(download)) } } - .cancellable(id: CancelID.observeDownload, cancelInFlight: true) + .cancellable(id: CancelID.observeDownload(state.gid), cancelInFlight: true) } private func handleObserveDownloadDone( @@ -126,7 +127,7 @@ extension DetailReducer { } await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) } - .cancellable(id: CancelID.loadLocalPreviewURLs, cancelInFlight: true) + .cancellable(id: CancelID.loadLocalPreviewURLs(state.gid), cancelInFlight: true) } private func handleLoadLocalPreviewURLsDone( diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/EhPanda/View/Detail/DetailReducer+Fetch.swift index c08dcfb04..9782305b0 100644 --- a/EhPanda/View/Detail/DetailReducer+Fetch.swift +++ b/EhPanda/View/Detail/DetailReducer+Fetch.swift @@ -12,7 +12,11 @@ extension DetailReducer { Reduce { state, action in switch action { case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) + return .merge( + CancelID + .all(for: state.cancellationGalleryID) + .map(Effect.cancel(id:)) + ) case .fetchDatabaseInfos(let gid): return handleFetchDatabaseInfos(gid: gid, state: &state) @@ -57,7 +61,7 @@ extension DetailReducer { guard let dbState = await databaseClient.fetchGalleryState(gid: galleryID) else { return } await send(.fetchDatabaseInfosDone(dbState)) } - .cancellable(id: CancelID.fetchDatabaseInfos) + .cancellable(id: CancelID.fetchDatabaseInfos(gid)) ) } @@ -75,14 +79,15 @@ extension DetailReducer { guard state.loadingState != .loading, let galleryURL = state.gallery.galleryURL else { return .none } + let galleryID = state.gallery.id state.loadingState = .loading state.didRequestVersionMetadata = false state.galleryVersionMetadata = nil - return .run { [galleryID = state.gallery.id] send in + return .run { send in let response = await GalleryDetailRequest(gid: galleryID, galleryURL: galleryURL).response() await send(.fetchGalleryDetailDone(response)) } - .cancellable(id: CancelID.fetchGalleryDetail) + .cancellable(id: CancelID.fetchGalleryDetail(galleryID)) } private func handleFetchGalleryDetailDone( @@ -142,7 +147,8 @@ extension DetailReducer { return .none } state.didRequestVersionMetadata = true - return .run { [gallery = state.gallery] send in + let gallery = state.gallery + return .run { send in let metadata: DownloadVersionMetadata? switch await downloadClient.fetchVersionMetadata(gallery.gid, gallery.token) { case .success(let fetchedMetadata): @@ -158,7 +164,7 @@ extension DetailReducer { ) await send(.fetchDownloadBadgeDone(download)) } - .cancellable(id: CancelID.fetchVersionMetadata, cancelInFlight: true) + .cancellable(id: CancelID.fetchVersionMetadata(gallery.gid), cancelInFlight: true) } var galleryOpsReducer: some ReducerOf { @@ -191,7 +197,8 @@ extension DetailReducer { gid: gid, token: token, rating: rating ).response() await send(.anyGalleryOpsDone(response)) - }.cancellable(id: CancelID.rateGallery) + } + .cancellable(id: CancelID.rateGallery(state.gallery.id)) } private func handleFavorGallery(favIndex: Int, state: State) -> Effect { @@ -201,7 +208,7 @@ extension DetailReducer { ).response() await send(.anyGalleryOpsDone(response)) } - .cancellable(id: CancelID.favorGallery) + .cancellable(id: CancelID.favorGallery(state.gallery.id)) } private func handleUnfavorGallery(state: State) -> Effect { @@ -209,7 +216,7 @@ extension DetailReducer { let response = await UnfavorGalleryRequest(gid: galleryID).response() await send(.anyGalleryOpsDone(response)) } - .cancellable(id: CancelID.unfavorGallery) + .cancellable(id: CancelID.unfavorGallery(state.gallery.id)) } private func handlePostComment(galleryURL: URL, state: State) -> Effect { @@ -220,7 +227,7 @@ extension DetailReducer { ).response() await send(.anyGalleryOpsDone(response)) } - .cancellable(id: CancelID.postComment) + .cancellable(id: CancelID.postComment(state.gallery.id)) } private func handleVoteTag(tag: String, vote: Int, state: State) -> Effect { @@ -233,7 +240,7 @@ extension DetailReducer { ).response() await send(.anyGalleryOpsDone(response)) } - .cancellable(id: CancelID.voteTag) + .cancellable(id: CancelID.voteTag(state.gallery.id)) } private func handleAnyGalleryOpsDone(result: Result) -> Effect { diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index c22d1f067..ca4fe27a4 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -25,19 +25,36 @@ struct DetailReducer { case folderManager(EquatableVoid = .init()) } - enum CancelID: CaseIterable { - case fetchDatabaseInfos - case fetchGalleryDetail - case fetchVersionMetadata - case fetchDownloadBadge - case fetchDownloadFolders - case observeDownload - case loadLocalPreviewURLs - case rateGallery - case favorGallery - case unfavorGallery - case postComment - case voteTag + enum CancelID: Hashable { + case fetchDatabaseInfos(String) + case fetchGalleryDetail(String) + case fetchVersionMetadata(String) + case fetchDownloadBadge(String) + case fetchDownloadFolders(String) + case observeDownload(String) + case loadLocalPreviewURLs(String) + case rateGallery(String) + case favorGallery(String) + case unfavorGallery(String) + case postComment(String) + case voteTag(String) + + static func all(for gid: String) -> [Self] { + [ + .fetchDatabaseInfos(gid), + .fetchGalleryDetail(gid), + .fetchVersionMetadata(gid), + .fetchDownloadBadge(gid), + .fetchDownloadFolders(gid), + .observeDownload(gid), + .loadLocalPreviewURLs(gid), + .rateGallery(gid), + .favorGallery(gid), + .unfavorGallery(gid), + .postComment(gid), + .voteTag(gid) + ] + } } @ObservableState @@ -66,6 +83,10 @@ struct DetailReducer { var isPreparingDownload = false var hasLoadedDownloadBadge = false + var cancellationGalleryID: String { + gid.isEmpty ? gallery.id : gid + } + var downloadNeedsRepair: Bool { guard let badge = downloadBadge, badge.status == .error else { return false } return badge.progress.completedPageCount == 0 diff --git a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift index f0d9be060..4734d80e5 100644 --- a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift @@ -11,6 +11,16 @@ import Testing @Suite(.serialized) @MainActor struct DetailReducerObserveTests: DownloadFeatureTestCase { + @Test + func testDetailCancellationIDsAreScopedByGallery() { + let firstGalleryIDs = DetailReducer.CancelID.all(for: "100") + let secondGalleryIDs = DetailReducer.CancelID.all(for: "200") + + #expect(firstGalleryIDs.count == 12) + #expect(secondGalleryIDs.count == 12) + #expect(Set(firstGalleryIDs).isDisjoint(with: Set(secondGalleryIDs))) + } + @MainActor @Test func testDetailReducerObservesDownloadBadgeTransitions() async { From 5e221db1399f9a6816e9c0c836fd51992dbcbbcb Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 23:22:04 +0800 Subject: [PATCH 177/614] Avoid finalizing incomplete page retries --- .../Clients/DownloadClient+Execution.swift | 18 +++++ .../DownloadClient+ExecutionPerform.swift | 23 +++++++ .../Clients/DownloadClient+Manager.swift | 4 ++ .../DownloadRetryMinimalSourceTests.swift | 69 ++++++++++++++++++- 4 files changed, 113 insertions(+), 1 deletion(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 2357022c6..76ec613ec 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -100,6 +100,11 @@ extension DownloadManager { error: partialError, context: context ) + } else if let incompleteError = error as? IncompleteDownloadError { + await handleProcessDownloadIncompleteError( + error: incompleteError, + context: context + ) } else { await handleProcessDownloadGenericError( error: error, @@ -187,6 +192,19 @@ extension DownloadManager { await notifyObservers() } + private func handleProcessDownloadIncompleteError( + error _: IncompleteDownloadError, + context: FailureContext + ) async { + guard !shouldSuppressFailurePersistence(for: context.gid) else { + return + } + clearDownloadQueueIntent(gid: context.gid) + await queueStore.remove(context.gid) + _ = await reloadDownloadIndex() + await notifyObservers() + } + private func handleProcessDownloadGenericError( error: Error, context: FailureContext diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 80569b60e..1b5474275 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -120,6 +120,15 @@ extension DownloadManager { failedPages: context.batchResult.failedPages ) } + let missingPageIndices = missingFinalizedPageIndices( + payload: payload, + folderURL: folderURL + ) + guard missingPageIndices.isEmpty else { + throw IncompleteDownloadError( + missingPageIndices: missingPageIndices + ) + } try await finalizeDownload( payload: payload, folderURL: folderURL, @@ -127,6 +136,20 @@ extension DownloadManager { ) } + private func missingFinalizedPageIndices( + payload: DownloadRequestPayload, + folderURL: URL + ) -> [Int] { + let manifest = makeInitialManifest(payload: payload) + let existingPages = storage.existingPageRelativePaths( + folderURL: folderURL, + manifest: manifest + ) + return manifest.pages.keys.sorted().filter { index in + existingPages[index] == nil + } + } + private func resolveSourceIfNeeded( payload: DownloadRequestPayload, pendingIndices: [Int], diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index d408f19c1..b36bb3148 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -70,6 +70,10 @@ actor DownloadManager { let failedPages: [PageFailure] } + struct IncompleteDownloadError: Error, Sendable { + let missingPageIndices: [Int] + } + struct FailureContext: Sendable { let gid: String let originalDownload: DownloadedGallery diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 71e676cb7..7c09e21d9 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -69,6 +69,59 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { ) ) } + + @Test + func testPartialRetryDoesNotOverwriteLastErrorWhenUnselectedPagesRemainMissing() async throws { + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 201) + let pageIndex = 40 + let remainingMissingPageIndex = 41 + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, manager) = makeStubbedDownloadManager( + rootURL: rootURL, sessionID: sessionID + ) + let setup = try await setupMinimalSourceTest( + manager: manager, sessionID: sessionID, gid: gid, pageIndex: pageIndex + ) + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let manifest = try sampleManifest( + gid: gid, title: "Pause Race", + pageCount: setup.pageCount + ) + try writeFinalManifest( + storage: storage, + gid: gid, + manifest: manifest, + missingPageIndices: [pageIndex, remainingMissingPageIndex] + ) + await manager.testingSetDownloadError( + .init(code: .networkingFailed, message: "Original retry failure."), + gid: gid + ) + let blocker = Task { + try? await Task.sleep(for: .seconds(60)) + } + defer { blocker.cancel() } + await manager.testingInstallActiveTask(gid: "busy", task: blocker) + guard case .success = await manager.retryPages(gid: gid, pageIndices: [pageIndex]) else { + Issue.record("retryPages should queue the selected page.") + return + } + + await manager.testingProcessDownload(gid: gid) + + let snapshot = setup.recorder.snapshot() + #expect(snapshot.previewPageNumbers == [0], "\(snapshot)") + + let stored = try #require(await manager.testingFetchDownload(gid: gid)) + #expect(stored.displayStatus == .inactive) + #expect(stored.lastError == nil) + #expect(stored.completedPageCount == setup.pageCount - 1) + } } // MARK: - Minimal Source Test Result @@ -117,6 +170,20 @@ private extension DownloadRetryMinimalSourceTests { gid: String, manifest: DownloadManifest, missingPageIndex: Int + ) throws { + try writeFinalManifest( + storage: storage, + gid: gid, + manifest: manifest, + missingPageIndices: [missingPageIndex] + ) + } + + func writeFinalManifest( + storage: DownloadFileStorage, + gid: String, + manifest: DownloadManifest, + missingPageIndices: Set ) throws { try storage.ensureRootDirectory() let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Pause Race") @@ -128,7 +195,7 @@ private extension DownloadRetryMinimalSourceTests { to: folderURL.appendingPathComponent("\(gid)_token_cover.jpg"), options: .atomic ) - for index in manifest.pages.keys where index != missingPageIndex { + for index in manifest.pages.keys where !missingPageIndices.contains(index) { try Data([UInt8(index % 255)]).write( to: folderURL.appendingPathComponent("\(gid)_token_\(index).jpg"), options: .atomic From 87d004f297822511155c0d9e1c5be4f697a1c531 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 23:27:08 +0800 Subject: [PATCH 178/614] Validate decoded manifests --- .../Tools/Utilities/DownloadFileStorage.swift | 20 +++++++++- EhPanda/View/Reading/ReadingReducer.swift | 2 + .../Download/DownloadFileStorageTests.swift | 38 +++++++++++++++++++ .../Download/ReadingReducerLocalTests.swift | 16 ++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 010da16fc..fd3f934b7 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -301,7 +301,25 @@ struct DownloadFileStorage: Sendable { } func readManifest(folderURL: URL) throws -> DownloadManifest { - try readJSON(DownloadManifest.self, from: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest)) + let manifest = try readJSON( + DownloadManifest.self, + from: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) + ) + try validateDecodedManifest(manifest) + return manifest + } + + private func validateDecodedManifest(_ manifest: DownloadManifest) throws { + guard manifest.pages.isEmpty == false else { + throw manifestCorruptedError() + } + guard manifest.pages.keys.sorted() == Array(1...manifest.pages.count) else { + throw manifestCorruptedError() + } + } + + private func manifestCorruptedError() -> AppError { + .fileOperationFailed(L10n.Localizable.DownloadFileStorage.Validation.manifestCorrupted) } func scanDownloadFolders() throws -> [DownloadFolderRecord] { diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index 659c51be3..6ada307ec 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -86,6 +86,8 @@ struct ReadingReducer { // Image func containerDataSource(setting: Setting, isLandscape: Bool) -> [Int] { + guard gallery.pageCount > 0 else { return [] } + let defaultData = Array(1...gallery.pageCount) guard isLandscape && setting.enablesDualPageMode && setting.readingDirection != .vertical diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift index 1cf1a3a8c..dfa6b17b5 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -41,6 +41,44 @@ struct DownloadFileStorageTests { #expect(storage.validate(download: download, verifiesContentHashes: true) == .valid) } + @Test + func testReadManifestRejectsEmptyPages() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "123 - Empty") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try storage.writeManifest(sampleManifest(pageCount: 0), folderURL: folderURL) + + #expect( + throws: AppError.fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Validation.manifestCorrupted + ) + ) { + try storage.readManifest(folderURL: folderURL) + } + } + + @Test + func testReadManifestRejectsNonContiguousPages() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "123 - Sparse") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try storage.writeManifest(sampleManifest(pageHashes: [1: "", 3: ""]), folderURL: folderURL) + + #expect( + throws: AppError.fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Validation.manifestCorrupted + ) + ) { + try storage.readManifest(folderURL: folderURL) + } + } + @Test func testEnsureRootDirectoryMarksDownloadsFolderExcludedFromBackup() throws { let (storage, rootURL) = makeStorage() diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift index 20a650efc..c4b70b1db 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -10,6 +10,22 @@ import Testing @Suite(.serialized) struct ReadingReducerLocalTests: DownloadFeatureTestCase { + @Test + func testContainerDataSourceHandlesZeroPageGallery() { + var gallery = sampleGallery() + gallery.pageCount = 0 + var state = ReadingReducer.State() + state.gallery = gallery + + var dualPageSetting = Setting() + dualPageSetting.enablesDualPageMode = true + dualPageSetting.readingDirection = .leftToRight + dualPageSetting.exceptCover = true + + #expect(state.containerDataSource(setting: Setting(), isLandscape: false) == []) + #expect(state.containerDataSource(setting: dualPageSetting, isLandscape: true) == []) + } + @MainActor func testReadingReducerOnWebImageSucceededDoesNotCaptureAlreadyLocalPage() async { let capturedCalls = UncheckedBox([(String, Int, URL?)]()) From 4ca59b30805155bf1e076b58bb062c10efaa3a34 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 23:29:25 +0800 Subject: [PATCH 179/614] Fall back after cache retrieval failures --- EhPanda/App/Tools/Clients/ImageClient.swift | 5 +++- .../DownloadManagerRepairSeedTests.swift | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 4e36e4c0e..c5f0b9f3a 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -117,7 +117,10 @@ extension ImageClient { } for key in url.imageCacheKeys(includeStableAlias: true) where isCached(key) { - return await retrieveImage(key) + let result = await retrieveImage(key) + if case .success = result { + return result + } } return await downloadImage(url) } diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index 169d6aadb..4f3acce8f 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -156,6 +156,36 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { #expect(fetchedImage.size == image.size) } + @MainActor + @Test + func testImageClientFetchImageDownloadsWhenCachedRetrievalFails() async throws { + let url = try #require( + URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") + ) + let expectedCacheKeys = url.imageCacheKeys(includeStableAlias: true) + let retrievedCacheKeys = UncheckedBox([String]()) + let downloadedURLs = UncheckedBox([URL]()) + let client = ImageClient( + prefetchImages: { _ in }, + saveImageToPhotoLibrary: { _, _ in false }, + downloadImage: { downloadURL in + downloadedURLs.value.append(downloadURL) + return .success(UIImage()) + }, + retrieveImage: { cacheKey in + retrievedCacheKeys.value.append(cacheKey) + return .failure(AppError.notFound) + }, + isCached: { _ in true } + ) + + let result = await client.fetchImage(url: url) + _ = try result.get() + + #expect(retrievedCacheKeys.value == expectedCacheKeys) + #expect(downloadedURLs.value == [url]) + } + } // MARK: - Repair Seed Helpers From 45c7653ff0ee1d2705c5110be593b5478bd6c132 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 23:30:15 +0800 Subject: [PATCH 180/614] Localize download settings title --- EhPanda/App/de.lproj/Localizable.strings | 1 + EhPanda/App/ja.lproj/Localizable.strings | 1 + EhPanda/App/ko.lproj/Localizable.strings | 1 + EhPanda/App/zh-Hans.lproj/Localizable.strings | 1 + EhPanda/App/zh-Hant-HK.lproj/Localizable.strings | 1 + EhPanda/App/zh-Hant-TW.lproj/Localizable.strings | 1 + EhPanda/App/zh-Hant.lproj/Localizable.strings | 1 + 7 files changed, 7 insertions(+) diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 0ff5f80d6..960129579 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -995,6 +995,7 @@ "downloads_view.inspector.status.pending" = "Ausstehend"; "downloads_view.inspector.status.downloaded" = "Heruntergeladen"; "downloads_view.inspector.status.failed" = "Fehlgeschlagen"; +"download_setting_view.title" = "Download"; "download_setting_view.section.title.download_queue" = "Download-Warteschlange"; "download_setting_view.section.title.network" = "Netzwerk"; "download_setting_view.title.concurrent_image_downloads" = "Gleichzeitige Bilddownloads"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 7a8089caf..413539c5b 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -995,6 +995,7 @@ "downloads_view.inspector.status.pending" = "待機中"; "downloads_view.inspector.status.downloaded" = "ダウンロード済み"; "downloads_view.inspector.status.failed" = "失敗"; +"download_setting_view.title" = "ダウンロード"; "download_setting_view.section.title.download_queue" = "ダウンロードキュー"; "download_setting_view.section.title.network" = "ネットワーク"; "download_setting_view.title.concurrent_image_downloads" = "同時画像ダウンロード数"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index a102f7c93..29f0ab966 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -995,6 +995,7 @@ "downloads_view.inspector.status.pending" = "대기 중"; "downloads_view.inspector.status.downloaded" = "다운로드됨"; "downloads_view.inspector.status.failed" = "실패"; +"download_setting_view.title" = "다운로드"; "download_setting_view.section.title.download_queue" = "다운로드 대기열"; "download_setting_view.section.title.network" = "네트워크"; "download_setting_view.title.concurrent_image_downloads" = "동시 이미지 다운로드 수"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 4ae857e58..7f68f74ae 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -427,6 +427,7 @@ "downloads_view.inspector.status.failed" = "失败"; // MARK: DownloadSettingView +"download_setting_view.title" = "下载"; "download_setting_view.section.title.download_queue" = "下载队列"; "download_setting_view.section.title.network" = "网络"; "download_setting_view.title.concurrent_image_downloads" = "并发图片下载"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 03c137e09..dd719d0c0 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -992,6 +992,7 @@ "downloads_view.inspector.status.pending" = "等待中"; "downloads_view.inspector.status.downloaded" = "已下載"; "downloads_view.inspector.status.failed" = "失敗"; +"download_setting_view.title" = "下載"; "download_setting_view.section.title.download_queue" = "下載佇列"; "download_setting_view.section.title.network" = "網絡"; "download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 4dd49eb82..65b74aedf 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -993,6 +993,7 @@ "downloads_view.inspector.status.pending" = "等待中"; "downloads_view.inspector.status.downloaded" = "已下載"; "downloads_view.inspector.status.failed" = "失敗"; +"download_setting_view.title" = "下載"; "download_setting_view.section.title.download_queue" = "下載佇列"; "download_setting_view.section.title.network" = "網路"; "download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 1376ac061..ff50a64b3 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -993,6 +993,7 @@ "downloads_view.inspector.status.pending" = "等待中"; "downloads_view.inspector.status.downloaded" = "已下載"; "downloads_view.inspector.status.failed" = "失敗"; +"download_setting_view.title" = "下載"; "download_setting_view.section.title.download_queue" = "下載佇列"; "download_setting_view.section.title.network" = "網絡"; "download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; From 21260351dd456478deacf6541221a36d123eee5f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 23:34:33 +0800 Subject: [PATCH 181/614] Cache Kingfisher downloads --- EhPanda/App/Tools/Clients/ImageClient.swift | 11 +++- .../DownloadManagerRepairSeedTests.swift | 59 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index c5f0b9f3a..59ae0d215 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -86,8 +86,15 @@ extension ImageClient { let result: Result = await withCheckedContinuation { continuation in KingfisherManager.shared.downloader.downloadImage(with: url, options: nil) { result in switch result { - case .success(let result): - continuation.resume(returning: .success(result.image)) + case .success(let downloadResult): + KingfisherManager.shared.cache.store( + downloadResult.image, + original: downloadResult.originalData, + forKey: url.stableImageCacheKey ?? url.absoluteString, + completionHandler: { _ in + continuation.resume(returning: .success(downloadResult.image)) + } + ) case .failure(let error): continuation.resume(returning: .failure(error)) } diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index 4f3acce8f..d91557124 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -114,6 +114,65 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { #expect(fetchedImage.size == image.size) } + @MainActor + @Test + func testImageClientDownloadImageCachesKingfisherOriginalUnderStableKey() async throws { + let sessionID = UUID().uuidString + let url = try #require( + URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") + ) + let stableCacheKey = try #require(url.stableImageCacheKey) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer( + size: .init(width: 1, height: 1), + format: format + ) + .image { context in + UIColor.systemBlue.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try #require(image.pngData()) + let originalDownloader = KingfisherManager.shared.downloader + let downloader = ImageDownloader(name: "test-\(sessionID)") + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [ + SharedSessionStubURLProtocol.headerKey: sessionID + ] + downloader.sessionConfiguration = configuration + KingfisherManager.shared.downloader = downloader + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + #expect(request.url == url) + return ( + try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: [ + "Content-Type": "image/png", + "Content-Length": "\(imageData.count)" + ] + )), + imageData + ) + } + defer { + SharedSessionStubURLProtocol.removeHandler(for: sessionID) + KingfisherManager.shared.downloader = originalDownloader + KingfisherManager.shared.cache.removeImage(forKey: stableCacheKey) + KingfisherManager.shared.cache.removeImage(forKey: url.absoluteString) + } + + let result = await ImageClient.live.downloadImage(url) + let downloadedImage = try result.get() + + #expect(downloadedImage.size == image.size) + await waitUntilCacheReady(for: [stableCacheKey], timeout: .seconds(3)) + let cachedImage = try #require(await LibraryClient.live.cachedImage(stableCacheKey)) + #expect(cachedImage.size == image.size) + } + @MainActor @Test func testImageClientFetchImageUsesSDWebImageStableAliasCacheKey() async throws { From 4fe6043bf953bc8591706e6ba9100ee921b2803b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 23:42:59 +0800 Subject: [PATCH 182/614] Cancel image downloads --- EhPanda/App/Tools/Clients/ImageClient.swift | 183 +++++++++++++++--- .../DownloadManagerRepairSeedTests.swift | 58 ++++++ 2 files changed, 212 insertions(+), 29 deletions(-) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 59ae0d215..299bd982d 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -7,7 +7,8 @@ import Photos import SwiftUI import Combine import Kingfisher -import SDWebImage +@preconcurrency import SDWebImage +import Synchronization import ComposableArchitecture struct ImageClient: Sendable { @@ -67,49 +68,89 @@ extension ImageClient { }, downloadImage: { url in if url.isPotentiallyAnimatedImage { - let result: Result = await withCheckedContinuation { continuation in - SDWebImageManager.shared.loadImage( - with: url, - options: [.retryFailed, .continueInBackground, .handleCookies], - context: [.callbackQueue: SDCallbackQueue.main], - progress: nil - ) { image, _, error, _, _, _ in - if let image { - continuation.resume(returning: .success(image)) - } else { - continuation.resume(returning: .failure(error ?? AppError.notFound)) - } + return await ImageClient.downloadAnimatedImage(url: url) + } + return await ImageClient.downloadStaticImage(url: url) + }, + retrieveImage: { key in + guard let image = await LibraryClient.live.cachedImage(key) else { + return .failure(AppError.notFound) + } + return .success(image) + }, + isCached: LibraryClient.live.isCached + ) + + static func downloadAnimatedImage( + url: URL, + manager: SDWebImageManager = .shared + ) async -> Result { + let continuationBox = ImageDownloadContinuationBox() + let result: Result = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + continuationBox.setContinuation(continuation) + let operation = manager.loadImage( + with: url, + options: [.retryFailed, .continueInBackground, .handleCookies], + context: [.callbackQueue: SDCallbackQueue.main], + progress: nil + ) { image, _, error, _, _, _ in + if let image { + continuationBox.resume(returning: .success(image)) + } else { + continuationBox.resume(returning: .failure(error ?? AppError.notFound)) } } - return result + guard let operation else { + continuationBox.resume(returning: .failure(AppError.notFound)) + return + } + continuationBox.setCancelOperation { + operation.cancel() + } } - let result: Result = await withCheckedContinuation { continuation in - KingfisherManager.shared.downloader.downloadImage(with: url, options: nil) { result in + } onCancel: { + continuationBox.cancel() + } + return result + } + + static func downloadStaticImage( + url: URL, + downloader: ImageDownloader = KingfisherManager.shared.downloader, + cache: ImageCache = KingfisherManager.shared.cache + ) async -> Result { + let continuationBox = ImageDownloadContinuationBox() + let result: Result = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + continuationBox.setContinuation(continuation) + let downloadTask = downloader.downloadImage( + with: url, + options: nil + ) { result in switch result { case .success(let downloadResult): - KingfisherManager.shared.cache.store( + cache.store( downloadResult.image, original: downloadResult.originalData, forKey: url.stableImageCacheKey ?? url.absoluteString, completionHandler: { _ in - continuation.resume(returning: .success(downloadResult.image)) + continuationBox.resume(returning: .success(downloadResult.image)) } ) case .failure(let error): - continuation.resume(returning: .failure(error)) + continuationBox.resume(returning: .failure(error)) } } + continuationBox.setCancelOperation { + downloadTask.cancel() + } } - return result - }, - retrieveImage: { key in - guard let image = await LibraryClient.live.cachedImage(key) else { - return .failure(AppError.notFound) - } - return .success(image) - }, - isCached: LibraryClient.live.isCached - ) + } onCancel: { + continuationBox.cancel() + } + return result + } func fetchImage(url: URL) async -> Result { if url.isFileURL { @@ -133,6 +174,90 @@ extension ImageClient { } } +// The callback APIs expose cancellation tokens after Swift task cancellation can already arrive. +private final class ImageDownloadContinuationBox: Sendable { + private struct State: Sendable { + var cancelOperation: (@Sendable () -> Void)? + var continuation: CheckedContinuation, Never>? + var isCancelled = false + var isFinished = false + } + + private let state = Mutex(State()) + + func setContinuation(_ continuation: CheckedContinuation, Never>) { + let shouldResumeCancellation = state.withLock { state in + if state.isCancelled || state.isFinished { + state.isFinished = true + return true + } + state.continuation = continuation + return false + } + + if shouldResumeCancellation { + continuation.resume(returning: .failure(CancellationError())) + } + } + + func setCancelOperation(_ cancelOperation: @escaping @Sendable () -> Void) { + let shouldCancel = state.withLock { state in + if state.isCancelled { + return true + } + if !state.isFinished { + state.cancelOperation = cancelOperation + } + return false + } + + if shouldCancel { + cancelOperation() + } + } + + func resume(returning result: Result) { + let continuation = state.withLock { state in + guard !state.isFinished else { + return nil as CheckedContinuation, Never>? + } + state.isFinished = true + let continuation = state.continuation + state.continuation = nil + state.cancelOperation = nil + return continuation + } + + continuation?.resume(returning: result) + } + + func cancel() { + let cancellation = state.withLock { state in + guard !state.isFinished else { + return ( + cancelOperation: nil as (@Sendable () -> Void)?, + continuation: nil as CheckedContinuation, Never>? + ) + } + state.isCancelled = true + let cancelOperation = state.cancelOperation + state.cancelOperation = nil + let continuation = state.continuation + if continuation != nil { + state.isFinished = true + state.continuation = nil + } + return ( + cancelOperation: cancelOperation, + continuation: continuation + ) + } + + cancellation.cancelOperation?() + cancellation.continuation?.resume(returning: .failure(CancellationError())) + } +} + // MARK: API enum ImageClientKey: DependencyKey { static let liveValue = ImageClient.live diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index d91557124..f04b78b65 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -173,6 +173,64 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { #expect(cachedImage.size == image.size) } + @MainActor + @Test + func testImageClientDownloadImageCancelsKingfisherTask() async throws { + let url = try #require( + URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") + ) + let downloader = ImageDownloader(name: "cancel-\(UUID().uuidString)") + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [HangingURLProtocol.self] + downloader.sessionConfiguration = configuration + let task = Task { + await ImageClient.downloadStaticImage( + url: url, + downloader: downloader, + cache: KingfisherManager.shared.cache + ) + } + + task.cancel() + let result = try await waitForTaskValue( + task, + timeout: .seconds(1), + description: "Kingfisher image cancellation" + ) + + #expect(throws: CancellationError.self) { + try result.get() + } + } + + @MainActor + @Test + func testImageClientDownloadImageCancelsSDWebImageOperation() async throws { + let url = try #require( + URL(string: "https://ehgt.org/ab/cd/0001-1234567890.webp?download=1") + ) + let downloaderConfig = SDWebImageDownloaderConfig() + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [HangingURLProtocol.self] + downloaderConfig.sessionConfiguration = configuration + let downloader = SDWebImageDownloader(config: downloaderConfig) + let manager = SDWebImageManager(cache: SDImageCache.shared, loader: downloader) + let task = Task { + await ImageClient.downloadAnimatedImage(url: url, manager: manager) + } + + task.cancel() + let result = try await waitForTaskValue( + task, + timeout: .seconds(1), + description: "SDWebImage image cancellation" + ) + + #expect(throws: CancellationError.self) { + try result.get() + } + } + @MainActor @Test func testImageClientFetchImageUsesSDWebImageStableAliasCacheKey() async throws { From 3aa5233c6c9a4ec8b1fed490c381198c18a9d564 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 13 Jun 2026 23:45:19 +0800 Subject: [PATCH 183/614] Guard MPV URL path components --- EhPanda/App/Tools/Clients/URLClient.swift | 2 +- .../Tests/Parser/Other/SettingDownloadTests.swift | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/EhPanda/App/Tools/Clients/URLClient.swift b/EhPanda/App/Tools/Clients/URLClient.swift index 7876ae44d..102f52ff8 100644 --- a/EhPanda/App/Tools/Clients/URLClient.swift +++ b/EhPanda/App/Tools/Clients/URLClient.swift @@ -28,7 +28,7 @@ extension URLClient { }, checkIfMPVURL: { guard let url = $0 else { return false } - return url.pathComponents.count >= 1 && url.pathComponents[1] == "mpv" + return url.pathComponents.count >= 2 && url.pathComponents[1] == "mpv" }, parseGalleryID: { url in var gid = url.pathComponents[2] diff --git a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift index d138520c5..096af6477 100644 --- a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -97,4 +97,18 @@ struct SettingDownloadTests { #expect(combinedURL.previewCacheCleanupURLs() == [combinedURL, plainURL]) #expect(plainURL.previewCacheCleanupURLs() == [plainURL]) } + + @Test + func testCheckIfMPVURLHandlesHostOnlyURL() throws { + let url = try #require(URL(string: "https://e-hentai.org")) + + #expect(!URLClient.live.checkIfMPVURL(url)) + } + + @Test + func testCheckIfMPVURLDetectsMPVPath() throws { + let url = try #require(URL(string: "https://e-hentai.org/mpv/123456/token")) + + #expect(URLClient.live.checkIfMPVURL(url)) + } } From d534aa2b330112b4f69652ac2cee632432e555c8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 00:07:45 +0800 Subject: [PATCH 184/614] Remove manifest-matched superseded folders --- EhPanda/App/Tools/Utilities/DownloadFileStorage.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index fd3f934b7..d64ed3d7a 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -143,7 +143,15 @@ struct DownloadFileStorage: Sendable { let prefix = galleryFolderNamePrefix(gid: gid, token: token) return directoryURLs(in: rootURL) .flatMap { directoryURLs(in: $0) } - .filter { $0.lastPathComponent.hasPrefix(prefix) } + .filter { folderURL in + guard !folderURL.lastPathComponent.hasPrefix(prefix) else { + return true + } + guard let manifest = try? readManifest(folderURL: folderURL) else { + return false + } + return manifest.gid == gid && manifest.token == token + } } static func isGalleryFolderLikeName(_ name: String) -> Bool { From d84b77063784626203c77acf703db2c48e805e65 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 00:07:54 +0800 Subject: [PATCH 185/614] Isolate Kingfisher cache test --- .../Tests/Download/DownloadManagerRepairSeedTests.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index f04b78b65..a12297127 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -133,7 +133,6 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { context.fill(.init(x: 0, y: 0, width: 1, height: 1)) } let imageData = try #require(image.pngData()) - let originalDownloader = KingfisherManager.shared.downloader let downloader = ImageDownloader(name: "test-\(sessionID)") let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] @@ -141,7 +140,6 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { SharedSessionStubURLProtocol.headerKey: sessionID ] downloader.sessionConfiguration = configuration - KingfisherManager.shared.downloader = downloader SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in #expect(request.url == url) return ( @@ -159,12 +157,15 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { } defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) - KingfisherManager.shared.downloader = originalDownloader KingfisherManager.shared.cache.removeImage(forKey: stableCacheKey) KingfisherManager.shared.cache.removeImage(forKey: url.absoluteString) } - let result = await ImageClient.live.downloadImage(url) + let result = await ImageClient.downloadStaticImage( + url: url, + downloader: downloader, + cache: KingfisherManager.shared.cache + ) let downloadedImage = try result.get() #expect(downloadedImage.size == image.size) From f9f378004ced5e16894953f217938c5630ff82c0 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 00:08:05 +0800 Subject: [PATCH 186/614] Qualify Kingfisher prefetch resource --- EhPanda/App/Tools/Clients/ImageClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 299bd982d..62bfd6ea9 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -29,7 +29,7 @@ extension ImageClient { result.0.append(url) } else { result.1.append( - ImageResource( + KF.ImageResource( downloadURL: url, cacheKey: url.stableImageCacheKey ?? url.absoluteString ) From 1a05545b191c6ed13de507f908fcb4a2b1f79b14 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 00:08:14 +0800 Subject: [PATCH 187/614] Clean download warning state --- .../Clients/DownloadClient+PageDownload.swift | 28 ++++++++++--------- EhPanda/Models/Download/DownloadBadge.swift | 8 ------ 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index 544edbe2a..04d8592bb 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -15,6 +15,11 @@ extension DownloadManager { var lastFlushDate: Date = Date() } + private struct PageDownloadControl { + var wasCancelled = false + var didAbortForFatalError = false + } + func downloadPages( context: PageDownloadContext, pendingPageIndices: [Int], @@ -42,18 +47,16 @@ extension DownloadManager { ) let remainingPageIndices = pendingPageIndices .filter { !restoredIndices.contains($0) } - var wasCancelled = false - var didAbortForFatalError = false + var control = PageDownloadControl() await processRemainingPages( context: context, remainingPageIndices: remainingPageIndices, existingPages: existingPages, progress: &progress, - wasCancelled: &wasCancelled, - didAbortForFatalError: &didAbortForFatalError + control: &control ) - if wasCancelled || Task.isCancelled { + if control.wasCancelled || Task.isCancelled { throw CancellationError() } try await flushDownloadProgress( @@ -153,8 +156,7 @@ extension DownloadManager { remainingPageIndices: [Int], existingPages: [Int: String], progress: inout PageDownloadProgress, - wasCancelled: inout Bool, - didAbortForFatalError: inout Bool + control: inout PageDownloadControl ) async { let payload = context.payload await withTaskGroup(of: PageTaskOutcome.self) { group in @@ -168,25 +170,25 @@ extension DownloadManager { existingPages: existingPages ) while let outcome = await group.next() { - guard !didAbortForFatalError else { + guard !control.didAbortForFatalError else { group.cancelAll() continue } - if wasCancelled || Task.isCancelled + if control.wasCancelled || Task.isCancelled || schedulingBlockedGalleryIDs .contains(payload.gallery.gid) { - wasCancelled = true + control.wasCancelled = true group.cancelAll() continue } applyPageTaskOutcome( outcome, progress: &progress, - wasCancelled: &wasCancelled, - didAbortForFatalError: &didAbortForFatalError, + wasCancelled: &control.wasCancelled, + didAbortForFatalError: &control.didAbortForFatalError, group: &group ) - guard !wasCancelled, !didAbortForFatalError else { continue } + guard !control.wasCancelled, !control.didAbortForFatalError else { continue } try? await flushDownloadProgress( context: .init( gid: payload.gallery.gid, diff --git a/EhPanda/Models/Download/DownloadBadge.swift b/EhPanda/Models/Download/DownloadBadge.swift index 4a1234c2e..2c854ae26 100644 --- a/EhPanda/Models/Download/DownloadBadge.swift +++ b/EhPanda/Models/Download/DownloadBadge.swift @@ -6,12 +6,4 @@ struct DownloadBadge: Equatable, Sendable { let status: DownloadDisplayStatus let progress: DownloadProgress - - init( - status: DownloadDisplayStatus, - progress: DownloadProgress - ) { - self.status = status - self.progress = progress - } } From 60a442fdaf3d0695b5ef8e46af4ce03b9d9722d3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 08:26:16 +0800 Subject: [PATCH 188/614] Enforce SwiftLint rules --- .swiftlint.yml | 104 ++++--- EhPanda/App/Tools/Clients/ImageClient.swift | 27 +- EhPanda/DataFlow/AppDelegateReducer.swift | 4 +- .../Download/DetailReducerDownloadTests.swift | 54 ++-- .../Download/DetailReducerMetadataTests.swift | 122 ++++---- .../DetailReducerMetadataUpdateTests.swift | 80 +++--- .../Download/DetailReducerObserveTests.swift | 78 +++--- .../DetailReducerPauseAndGuardTests.swift | 128 +++++---- .../Download/DownloadAutomationTests.swift | 152 +++++----- .../Download/DownloadInspectorLoadTests.swift | 46 +-- .../DownloadInspectorRetryTests.swift | 44 +-- .../Download/DownloadInspectorSkipTests.swift | 56 ++-- .../Download/DownloadObserverBatchTests.swift | 50 ++-- .../DownloadObserverReadingTests.swift | 112 ++++---- .../DownloadObserverRefreshTests.swift | 52 ++-- .../DownloadsReducerActionTests.swift | 264 +++++++++--------- .../DownloadsReducerReadingDismissTests.swift | 16 +- .../DownloadsReducerRefreshTests.swift | 158 ++++++----- .../Download/FolderManagerReducerTests.swift | 48 ++-- .../PreviewsReducerDownloadTests.swift | 60 ++-- .../ReadingReducerDownloadTests.swift | 118 ++++---- .../Download/ReadingReducerLocalTests.swift | 64 +++-- 22 files changed, 981 insertions(+), 856 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index de2281578..acd29298e 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,47 +1,83 @@ disabled_rules: - - file_length - - opening_brace - - type_body_length - - function_body_length - - cyclomatic_complexity - - blanket_disable_command - - multiple_closures_with_trailing_closure + - opening_brace + - type_body_length + - function_body_length + - cyclomatic_complexity + - blanket_disable_command + - multiple_closures_with_trailing_closure opt_in_rules: - - force_try - - force_unwrapping + - force_try + - force_unwrapping force_try: - severity: error + severity: error force_unwrapping: - severity: error + severity: error line_length: - warning: 120 - error: 120 + warning: 120 + error: 120 + +file_length: + warning: 1000 + error: 1000 custom_rules: - no_unchecked_sendable: - name: "No @unchecked Sendable" - regex: "@unchecked\\s+Sendable" - message: "@unchecked Sendable is banned - use a real Sendable value type, an actor, or Mutex." - severity: error - no_nslock: - name: "No NSLock" - regex: "\\bNSLock\\b" - message: "NSLock is banned - use Mutex (Synchronization) instead." - severity: error - scope_reducer_child_shorthand: - name: "Scope child shorthand" - regex: 'Scope\([^)]*\)\s*\{\s*[A-Z]\w*\(\)\s*\}' - message: "Use Scope(state:action:child: Reducer.init) instead of expanding a closure for a single bare Reducer()." - severity: error - foreach_reducer_element_shorthand: - name: "forEach element shorthand" - regex: 'forEach\([^)]*\)\s*\{\s*[A-Z]\w*\(\)\s*\}' - message: "Use .forEach(_:action:element: Reducer.init) instead of expanding a closure for a single bare Reducer()." - severity: error + child_reducer_shorthand_foreach: + name: "Child reducer shorthand (forEach)" + regex: 'forEach\([^)]*\)\s*\{\s*[A-Z]\w*\(\)\s*\}' + message: "Use .forEach(_:action:element: Reducer.init) instead of expanding a closure for a single bare Reducer()." + excluded_match_kinds: + - comment + - string + severity: error + + child_reducer_shorthand_scope: + name: "Child reducer shorthand (Scope)" + regex: 'Scope\([^)]*\)\s*\{\s*[A-Z]\w*\(\)\s*\}' + message: "Use Scope(state:action:child: Reducer.init) instead of expanding a closure for a single bare Reducer()." + excluded_match_kinds: + - comment + - string + severity: error + + child_reducer_shorthand_store: + name: "Child reducer shorthand (Store)" + regex: '\b(?:Store|TestStore)\(\s*initialState:\s*(?:[^()]|\([^()]*\))*\)\s*\{\s*[A-Z]\w*\(\)\s*\}' + message: "Use Store/TestStore initialState:reducer: instead of expanding a closure for a single bare Reducer()." + excluded_match_kinds: + - comment + - string + severity: error + + no_nslock: + name: "No NSLock" + regex: "\\bNSLock\\b" + message: "NSLock is banned, use Mutex (Synchronization) instead." + excluded_match_kinds: + - comment + - string + severity: error + + no_preconcurrency: + name: "No @preconcurrency" + regex: "@preconcurrency\\b" + message: "@preconcurrency is banned, resolve the underlying Sendable / concurrency issue instead of suppressing it." + excluded_match_kinds: + - comment + - string + severity: error + + no_unchecked_sendable: + name: "No @unchecked Sendable" + regex: "@unchecked\\s+Sendable" + message: "@unchecked Sendable is banned, use a real Sendable value type, an actor, or Mutex." + excluded_match_kinds: + - comment + - string + severity: error excluded: - - EhPanda/App/Generated + - EhPanda/App/Generated diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 62bfd6ea9..59ddf27a0 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -7,7 +7,7 @@ import Photos import SwiftUI import Combine import Kingfisher -@preconcurrency import SDWebImage +import SDWebImage import Synchronization import ComposableArchitecture @@ -81,11 +81,15 @@ extension ImageClient { isCached: LibraryClient.live.isCached ) + // Runs on the `MainActor` so the non-`Sendable` `SDWebImageCombinedOperation` it creates + // never leaves a single isolation domain, see `AnimatedImageOperationBox`. + @MainActor static func downloadAnimatedImage( url: URL, manager: SDWebImageManager = .shared ) async -> Result { let continuationBox = ImageDownloadContinuationBox() + let operationBox = AnimatedImageOperationBox() let result: Result = await withTaskCancellationHandler { await withCheckedContinuation { continuation in continuationBox.setContinuation(continuation) @@ -105,8 +109,9 @@ extension ImageClient { continuationBox.resume(returning: .failure(AppError.notFound)) return } + operationBox.track(operation) continuationBox.setCancelOperation { - operation.cancel() + Task { @MainActor in operationBox.cancel() } } } } onCancel: { @@ -258,6 +263,24 @@ private final class ImageDownloadContinuationBox: Sendable { } } +// Holds the in-flight `SDWebImageCombinedOperation` so it can be cancelled when the awaiting task +// is cancelled. The operation is an Objective-C type with no `Sendable` annotation and models live +// work, so it cannot be transferred across isolation domains; confining the box to the `MainActor` +// keeps it within the actor that `downloadAnimatedImage` already runs on. The cancel handle stored +// in `ImageDownloadContinuationBox` reaches it by hopping back to the `MainActor`. +@MainActor private final class AnimatedImageOperationBox { + private var operation: SDWebImageCombinedOperation? + + func track(_ operation: SDWebImageCombinedOperation) { + self.operation = operation + } + + func cancel() { + operation?.cancel() + operation = nil + } +} + // MARK: API enum ImageClientKey: DependencyKey { static let liveValue = ImageClient.live diff --git a/EhPanda/DataFlow/AppDelegateReducer.swift b/EhPanda/DataFlow/AppDelegateReducer.swift index 7dca95ccd..83b777883 100644 --- a/EhPanda/DataFlow/AppDelegateReducer.swift +++ b/EhPanda/DataFlow/AppDelegateReducer.swift @@ -53,9 +53,7 @@ struct AppDelegateReducer { // MARK: AppDelegate class AppDelegate: UIResponder, UIApplicationDelegate { - let store = Store(initialState: .init()) { - AppReducer() - } + let store = Store(initialState: .init(), reducer: AppReducer.init) static var orientationMask: UIInterfaceOrientationMask = DeviceUtil.isPad ? .all : [.portrait, .portraitUpsideDown] diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index 97525fd20..261605967 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -165,33 +165,35 @@ private extension DetailReducerDownloadTests { initialState.gallery = gallery initialState.galleryDetail = detail configure(&initialState) - return TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in downloadValue }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: enqueue, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - fetchFolders: { folders() } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - if let automationGID { - $0.appLaunchAutomationClient = appLaunchAutomationClient( - autoDownloadGID: automationGID + return TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in downloadValue }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: enqueue, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + fetchFolders: { folders() } ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + if let automationGID { + $0.appLaunchAutomationClient = appLaunchAutomationClient( + autoDownloadGID: automationGID + ) + } } - } + ) } } diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift index f87813a58..a22101ba3 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -125,35 +125,37 @@ private extension DetailReducerMetadataTests { var initialState = DetailReducer.State() initialState.gid = gid initialState.gallery = gallery - return TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in downloadValue }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - fetchVersionMetadata: { _, _ in - .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) - }, - updateRemoteVersion: { _, _ in - updateCheckCount.value += 1 - return .none - }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } + return TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in downloadValue }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + fetchVersionMetadata: { _, _ in + .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) + }, + updateRemoteVersion: { _, _ in + updateCheckCount.value += 1 + return .none + }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + ) } func makeDownloadedMetadataTestStore( @@ -166,35 +168,37 @@ private extension DetailReducerMetadataTests { var initialState = DetailReducer.State() initialState.gid = gid initialState.gallery = gallery - return TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in downloadValue }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - fetchVersionMetadata: { _, _ in - .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) - }, - updateRemoteVersion: { _, _ in - updateCheckCount.value += 1 - return updatedDownload - }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in .success([:]) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } + return TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in downloadValue }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + fetchVersionMetadata: { _, _ in + .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) + }, + updateRemoteVersion: { _, _ in + updateCheckCount.value += 1 + return updatedDownload + }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in .success([:]) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + ) } } diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index b5e4e539b..b83158470 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -91,14 +91,16 @@ struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { initialState.didRequestVersionMetadata = true initialState.shouldCheckForRemoteUpdates = true - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = makeDeleteTestClient(download: download) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = makeDeleteTestClient(download: download) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + ) store.exhaustivity = .off await store.send(.deleteDownloadDone(.success(()))) { @@ -127,36 +129,38 @@ private extension DetailReducerMetadataUpdateTests { initialState.gid = gid initialState.gallery = gallery initialState.galleryDetail = detail - return TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in updatedDownload }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - fetchVersionMetadata: { _, _ in - .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) - }, - updateRemoteVersion: { _, _ in - updateCheckCount.value += 1 - return updatedDownload - }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in .success([:]) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } + return TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in updatedDownload }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + fetchVersionMetadata: { _, _ in + .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) + }, + updateRemoteVersion: { _, _ in + updateCheckCount.value += 1 + return updatedDownload + }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { _ in .success([:]) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + ) } func makeDeleteTestClient(download: DownloadedGallery) -> DownloadClient { diff --git a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift index 4734d80e5..a0b2dab15 100644 --- a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift @@ -92,14 +92,16 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { initialState.gallery = download.gallery initialState.galleryDetail = sampleGalleryDetail(gid: download.gid, title: download.title) - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = makeLocalManifestClient(download: download, manifest: manifest) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = makeLocalManifestClient(download: download, manifest: manifest) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + ) store.exhaustivity = .off await store.send(.openReading) @@ -122,14 +124,16 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { initialState.gallery = gallery initialState.galleryDetail = detail - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = makeNoManifestClient() - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = makeNoManifestClient() + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + ) store.exhaustivity = .off await store.send(.openReading) @@ -154,26 +158,28 @@ private extension DetailReducerObserveTests { var initialState = DetailReducer.State() initialState.gallery = gallery initialState.galleryDetail = detail - return TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { stream }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } + return TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { stream }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + ) } func makeLocalManifestClient( diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index b6f71ced8..7ef6a2d33 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -24,17 +24,19 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { initialState.gallery = gallery initialState.galleryDetail = detail - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.appLaunchAutomationClient = appLaunchAutomationClient( - autoDownloadGID: gallery.gid - ) - $0.downloadClient = .noop - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient( + autoDownloadGID: gallery.gid + ) + $0.downloadClient = .noop + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + ) store.exhaustivity = .off await store.send(.fetchDownloadBadgeDone(completedDownload)) { @@ -60,33 +62,35 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { initialState.galleryDetail = detail initialState.isPreparingDownload = true - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in - enqueueCount.value += 1 - return .success(()) - }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in + enqueueCount.value += 1 + return .success(()) + }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + ) await store.send(.startDownload(options, "Folder")) @@ -151,29 +155,31 @@ private extension DetailReducerPauseAndGuardTests { pausedDownload: DownloadedGallery, togglePauseCount: UncheckedBox ) -> TestStoreOf { - let store = TestStore(initialState: initialState) { - DetailReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { AsyncStream { continuation in continuation.finish() } }, - fetchDownloads: { [] }, - fetchDownload: { _ in pausedDownload }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in - togglePauseCount.value += 1 - return .success(()) - }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - $0.hapticsClient = .noop - $0.databaseClient = .noop - $0.cookieClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { AsyncStream { continuation in continuation.finish() } }, + fetchDownloads: { [] }, + fetchDownload: { _ in pausedDownload }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in + togglePauseCount.value += 1 + return .success(()) + }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + $0.hapticsClient = .noop + $0.databaseClient = .noop + $0.cookieClient = .noop + } + ) store.exhaustivity = .off return store } diff --git a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift index 39f3d92db..90bd94b0b 100644 --- a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift @@ -76,19 +76,21 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { galleryURL: URL(string: "https://example.com/not-a-gallery") ) - let store = TestStore(initialState: AppReducer.State()) { - AppReducer() - } withDependencies: { - $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) - $0.cookieClient = .noop - $0.deviceClient = .noop - $0.hapticsClient = .noop - $0.urlClient = .init( - checkIfHandleable: { _ in false }, - checkIfMPVURL: { _ in false }, - parseGalleryID: { _ in .init() } - ) - } + let store = TestStore( + initialState: AppReducer.State(), + reducer: AppReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) + $0.cookieClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.urlClient = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + } + ) await store.send(.runLaunchAutomation) { $0.didRunLaunchAutomation = true @@ -114,23 +116,25 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { galleryURL: nil ) - let store = TestStore(initialState: AppReducer.State()) { - AppReducer() - } withDependencies: { - $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) - $0.cookieClient = cookieClient - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.hapticsClient = .noop - $0.uiApplicationClient = .noop - $0.userDefaultsClient = .noop - $0.appDelegateClient = .noop - $0.libraryClient = .noop - $0.loggerClient = .noop - $0.fileClient = .noop - $0.dfClient = .noop - $0.urlClient = .noop - } + let store = TestStore( + initialState: AppReducer.State(), + reducer: AppReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) + $0.cookieClient = cookieClient + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.uiApplicationClient = .noop + $0.userDefaultsClient = .noop + $0.appDelegateClient = .noop + $0.libraryClient = .noop + $0.loggerClient = .noop + $0.fileClient = .noop + $0.dfClient = .noop + $0.urlClient = .noop + } + ) store.exhaustivity = .off await store.send(.appDelegate(.migration(.onDatabasePreparationSuccess))) @@ -155,27 +159,29 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { galleryURL: URL(string: "https://exhentai.org/g/1394965/56c35114b6/") ) - let store = TestStore(initialState: AppReducer.State()) { - AppReducer() - } withDependencies: { - $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) - $0.cookieClient = cookieClient - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.hapticsClient = .noop - $0.uiApplicationClient = .noop - $0.userDefaultsClient = .noop - $0.appDelegateClient = .noop - $0.libraryClient = .noop - $0.loggerClient = .noop - $0.fileClient = .noop - $0.dfClient = .noop - $0.urlClient = .init( - checkIfHandleable: { _ in false }, - checkIfMPVURL: { _ in false }, - parseGalleryID: { _ in .init() } - ) - } + let store = TestStore( + initialState: AppReducer.State(), + reducer: AppReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) + $0.cookieClient = cookieClient + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.uiApplicationClient = .noop + $0.userDefaultsClient = .noop + $0.appDelegateClient = .noop + $0.libraryClient = .noop + $0.loggerClient = .noop + $0.fileClient = .noop + $0.dfClient = .noop + $0.urlClient = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + } + ) store.exhaustivity = .off await store.send(.setting(.loadUserSettingsDone)) @@ -213,27 +219,29 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { galleryURL: URL(string: "https://exhentai.org/g/1394965/56c35114b6/") ) - let store = TestStore(initialState: AppReducer.State()) { - AppReducer() - } withDependencies: { - $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) - $0.cookieClient = cookieClient - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.hapticsClient = .noop - $0.uiApplicationClient = .noop - $0.userDefaultsClient = .noop - $0.appDelegateClient = .noop - $0.libraryClient = .noop - $0.loggerClient = .noop - $0.fileClient = .noop - $0.dfClient = .noop - $0.urlClient = .init( - checkIfHandleable: { _ in false }, - checkIfMPVURL: { _ in false }, - parseGalleryID: { _ in .init() } - ) - } + let store = TestStore( + initialState: AppReducer.State(), + reducer: AppReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) + $0.cookieClient = cookieClient + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.uiApplicationClient = .noop + $0.userDefaultsClient = .noop + $0.appDelegateClient = .noop + $0.libraryClient = .noop + $0.loggerClient = .noop + $0.fileClient = .noop + $0.dfClient = .noop + $0.urlClient = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + } + ) store.exhaustivity = .off await store.send(.setting(.loadUserSettingsDone)) diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift index 2e043d64b..96af140f8 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -341,27 +341,29 @@ private extension DownloadInspectorLoadTests { var initialState = DownloadInspectorReducer.State(gid: gid) initialState.inspection = initialInspection if initialInspection != nil { initialState.loadingState = .idle } - return TestStore(initialState: initialState) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - validateImageData: validateImageData ?? { _ in nil }, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: togglePause ?? { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: retryPages ?? { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: loadInspection - ) - } + return TestStore( + initialState: initialState, + reducer: DownloadInspectorReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + validateImageData: validateImageData ?? { _ in nil }, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: togglePause ?? { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: retryPages ?? { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: loadInspection + ) + } + ) } } diff --git a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift index afc951632..45ad17108 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -131,26 +131,28 @@ private extension DownloadInspectorRetryTests { initialState: DownloadInspectorReducer.State, loadInspection: @escaping @Sendable (String) async -> Result ) -> TestStoreOf { - TestStore(initialState: initialState) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: loadInspection - ) - } + TestStore( + initialState: initialState, + reducer: DownloadInspectorReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: loadInspection + ) + } + ) } } diff --git a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift index bc5d6dc98..e79ab2e5f 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift @@ -26,32 +26,34 @@ struct DownloadInspectorSkipTests: DownloadFeatureTestCase { initialState.inspection = inspection initialState.loadingState = .idle - let store = TestStore(initialState: initialState) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() + let store = TestStore( + initialState: initialState, + reducer: DownloadInspectorReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + retryPages: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in + loadInspectionCount.value += 1 + return .success(inspection) } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in - loadInspectionCount.value += 1 - return .success(inspection) - } - ) - } + ) + } + ) store.exhaustivity = .off await store.send(.observeDownloadsDone([download])) @@ -82,9 +84,7 @@ struct DownloadInspectorSkipTests: DownloadFeatureTestCase { initialState.loadingState = .loading initialState.inspectionRequestID = secondRequestID - let store = TestStore(initialState: initialState) { - DownloadInspectorReducer() - } + let store = TestStore(initialState: initialState, reducer: DownloadInspectorReducer.init) store.exhaustivity = .off await store.send(.loadInspectionDone(firstRequestID, .success(staleInspection))) diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index a466056df..4bda1178a 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -25,30 +25,32 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { initialState.retryingPageIndices = [2] initialState.loadingState = .idle - let store = TestStore(initialState: initialState) { - DownloadInspectorReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.yield([download]) - continuation.yield([]) - continuation.finish() - } - }, - fetchDownloads: { [download] }, - fetchDownload: { gid in gid == download.gid ? download : nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in .success(inspection) } - ) - } + let store = TestStore( + initialState: initialState, + reducer: DownloadInspectorReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.yield([download]) + continuation.yield([]) + continuation.finish() + } + }, + fetchDownloads: { [download] }, + fetchDownload: { gid in gid == download.gid ? download : nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadInspection: { _ in .success(inspection) } + ) + } + ) store.exhaustivity = .off await store.send(.observeDownloads) diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index 650572412..9cd24d48a 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -167,36 +167,38 @@ private extension DownloadObserverReadingTests { expectedGID: String, loadCount: UncheckedBox ) -> TestStoreOf { - let store = TestStore(initialState: initialState) { - ReadingReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.clipboardClient = .noop - $0.cookieClient = .noop - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { stream }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { gid in - #expect(gid == expectedGID) - loadCount.value += 1 - return .success([:]) - } - ) - $0.hapticsClient = .noop - $0.imageClient = .noop - $0.urlClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: ReadingReducer.init, + withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { stream }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { gid in + #expect(gid == expectedGID) + loadCount.value += 1 + return .success([:]) + } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + ) store.exhaustivity = .off return store } @@ -207,30 +209,32 @@ private extension DownloadObserverReadingTests { expectedGID: String, loadCount: UncheckedBox ) -> TestStoreOf { - let store = TestStore(initialState: initialState) { - PreviewsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { stream }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { gid in - #expect(gid == expectedGID) - loadCount.value += 1 - return .success([:]) - } - ) - $0.databaseClient = .noop - $0.hapticsClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: PreviewsReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { stream }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { gid in + #expect(gid == expectedGID) + loadCount.value += 1 + return .success([:]) + } + ) + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + ) store.exhaustivity = .off return store } diff --git a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift index 6efce3ad0..c667bebad 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -99,21 +99,23 @@ private extension DownloadObserverRefreshTests { stream: AsyncStream<[DownloadedGallery]>, loadLocalPageURLs: @escaping @Sendable (String) async -> Result<[Int: URL], AppError> ) -> TestStoreOf { - let store = TestStore(initialState: initialState) { - ReadingReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.clipboardClient = .noop - $0.cookieClient = .noop - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.downloadClient = makeObserveDownloadClient( - stream: stream, loadLocalPageURLs: loadLocalPageURLs - ) - $0.hapticsClient = .noop - $0.imageClient = .noop - $0.urlClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: ReadingReducer.init, + withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = makeObserveDownloadClient( + stream: stream, loadLocalPageURLs: loadLocalPageURLs + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + ) store.exhaustivity = .off return store } @@ -123,15 +125,17 @@ private extension DownloadObserverRefreshTests { stream: AsyncStream<[DownloadedGallery]>, loadLocalPageURLs: @escaping @Sendable (String) async -> Result<[Int: URL], AppError> ) -> TestStoreOf { - let store = TestStore(initialState: initialState) { - PreviewsReducer() - } withDependencies: { - $0.downloadClient = makeObserveDownloadClient( - stream: stream, loadLocalPageURLs: loadLocalPageURLs - ) - $0.databaseClient = .noop - $0.hapticsClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: PreviewsReducer.init, + withDependencies: { + $0.downloadClient = makeObserveDownloadClient( + stream: stream, loadLocalPageURLs: loadLocalPageURLs + ) + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + ) store.exhaustivity = .off return store } diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift index 04e902975..ef7c052b8 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -13,9 +13,7 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { @MainActor @Test func testDownloadsReducerKeepsIdleStateForEmptyLibrary() async { - let store = TestStore(initialState: DownloadsReducer.State()) { - DownloadsReducer() - } + let store = TestStore(initialState: DownloadsReducer.State(), reducer: DownloadsReducer.init) await store.send(.fetchDownloadsDone([])) { $0.loadingState = .idle @@ -35,9 +33,7 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { var initialState = DownloadsReducer.State() initialState.downloads = [download] - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } + let store = TestStore(initialState: initialState, reducer: DownloadsReducer.init) store.exhaustivity = .off await store.send(.setNavigation(.detail(download.gid))) @@ -77,9 +73,7 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { var initialState = DownloadsReducer.State() initialState.folderFilter = .folder("Vanished") - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } + let store = TestStore(initialState: initialState, reducer: DownloadsReducer.init) await store.send(.fetchFoldersDone(["Library"])) { $0.folders = ["Library"] @@ -95,32 +89,34 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { @Test func testDownloadsReducerMoveActionUsesDownloadClientMove() async { let moved = UncheckedBox<(String, String)?>(nil) - let store = TestStore(initialState: DownloadsReducer.State()) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() + let store = TestStore( + initialState: DownloadsReducer.State(), + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + fetchFolders: { ["Library"] }, + moveDownload: { gid, folder in + moved.value = (gid, folder) + return .success(()) } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - fetchFolders: { ["Library"] }, - moveDownload: { gid, folder in - moved.value = (gid, folder) - return .success(()) - } - ) - } + ) + } + ) store.exhaustivity = .off await store.send(.moveDownload("123456", "Library")) @@ -145,32 +141,34 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { var initialState = DownloadsReducer.State() initialState.downloads = [download] - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { gid, mode in - if mode == .update { - retried.value.append(gid) - } - return .success(()) - }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - } + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { gid, mode in + if mode == .update { + retried.value.append(gid) + } + return .success(()) + }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + ) store.exhaustivity = .off await store.send(.updateDownload(download.gid)) @@ -191,30 +189,32 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { var initialState = DownloadsReducer.State() initialState.downloads = [download] - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { gid in - deleted.value.append(gid) - return .success(()) - }, - loadManifest: { _ in .failure(.notFound) } - ) - } + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { gid in + deleted.value.append(gid) + return .success(()) + }, + loadManifest: { _ in .failure(.notFound) } + ) + } + ) store.exhaustivity = .off await store.send(.deleteDownload(download.gid)) @@ -240,29 +240,31 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { var initialState = DownloadsReducer.State() initialState.downloads = [download] - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { gid in + gid == download.gid ? .success((download, manifest)) : .failure(.notFound) } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { gid in - gid == download.gid ? .success((download, manifest)) : .failure(.notFound) - } - ) - } + ) + } + ) store.exhaustivity = .off await store.send(.openReading(download.gid)) @@ -285,30 +287,32 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { var initialState = DownloadsReducer.State() initialState.downloads = [download] - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { gid in - toggled.value.append(gid) - return .success(()) - }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - } + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { gid in + toggled.value.append(gid) + return .success(()) + }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + ) store.exhaustivity = .off await store.send(.toggleDownloadPause(download.gid)) diff --git a/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift index 4b090d6d7..b67d24600 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift @@ -15,13 +15,15 @@ struct DownloadsReducerReadingDismissTests { var initialState = DownloadsReducer.State() initialState.route = .reading(gid) - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.deviceClient = .noop - $0.hapticsClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.appDelegateClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + } + ) store.exhaustivity = .off await store.send(.reading(.onPerformDismiss)) diff --git a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift index 5246fef8a..af5d3b2ff 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift @@ -23,30 +23,32 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { var initialState = DownloadsReducer.State() initialState.downloads = [download] - let store = TestStore(initialState: initialState) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [download] }, - fetchDownload: { _ in nil }, - reconcileDownloads: { - reconcileCount.value += 1 - }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .failure(.networkingFailed) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - } + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [download] }, + fetchDownload: { _ in nil }, + reconcileDownloads: { + reconcileCount.value += 1 + }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .failure(.networkingFailed) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + ) await store.send(.toggleDownloadPause(download.gid)) await store.receive(\.toggleDownloadPauseDone) @@ -61,32 +63,34 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { let refreshCount = UncheckedBox(0) let reconcileCount = UncheckedBox(0) - let store = TestStore(initialState: DownloadsReducer.State()) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - reconcileDownloads: { - reconcileCount.value += 1 - }, - refreshDownloads: { - refreshCount.value += 1 - }, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - } + let store = TestStore( + initialState: DownloadsReducer.State(), + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + reconcileDownloads: { + reconcileCount.value += 1 + }, + refreshDownloads: { + refreshCount.value += 1 + }, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + ) await store.send(.refreshDownloads) await store.receive(\.refreshDownloadsDone) @@ -103,32 +107,34 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { let refreshCount = UncheckedBox(0) let reconcileCount = UncheckedBox(0) - let store = TestStore(initialState: DownloadsReducer.State()) { - DownloadsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - reconcileDownloads: { - reconcileCount.value += 1 - }, - refreshDownloads: { - refreshCount.value += 1 - }, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) - } + let store = TestStore( + initialState: DownloadsReducer.State(), + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + reconcileDownloads: { + reconcileCount.value += 1 + }, + refreshDownloads: { + refreshCount.value += 1 + }, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + ) await store.send(.bootstrapDownloads) await store.receive(\.refreshDownloadsDone) diff --git a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift index 4011ca22f..21d688efb 100644 --- a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift +++ b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift @@ -324,28 +324,30 @@ private extension FolderManagerReducerTests { deleteFolder: @escaping @Sendable (String) async -> Result = { _ in .success(()) } ) -> TestStoreOf { - TestStore(initialState: FolderManagerReducer.State()) { - FolderManagerReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - fetchFolders: { folders() }, - createFolder: createFolder, - renameFolder: renameFolder, - deleteFolder: deleteFolder - ) - } + TestStore( + initialState: FolderManagerReducer.State(), + reducer: FolderManagerReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in continuation.finish() } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + fetchFolders: { folders() }, + createFolder: createFolder, + renameFolder: renameFolder, + deleteFolder: deleteFolder + ) + } + ) } } diff --git a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift index 70c1fac5e..8d086fb41 100644 --- a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -90,27 +90,29 @@ private extension PreviewsReducerDownloadTests { ) -> TestStoreOf { var initialState = PreviewsReducer.State() initialState.gallery = download.gallery - let store = TestStore(initialState: initialState) { - PreviewsReducer() - } withDependencies: { - $0.downloadClient = .init( - observeDownloads: { AsyncStream { continuation in continuation.finish() } }, - fetchDownloads: { [download] }, - fetchDownload: { gid in gid == download.gid ? download : nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { gid in - gid == download.gid ? .success((download, manifest)) : .failure(.notFound) - } - ) - $0.databaseClient = .noop - $0.hapticsClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: PreviewsReducer.init, + withDependencies: { + $0.downloadClient = .init( + observeDownloads: { AsyncStream { continuation in continuation.finish() } }, + fetchDownloads: { [download] }, + fetchDownload: { gid in gid == download.gid ? download : nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { gid in + gid == download.gid ? .success((download, manifest)) : .failure(.notFound) + } + ) + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + ) store.exhaustivity = .off return store } @@ -143,13 +145,15 @@ private extension PreviewsReducerDownloadTests { withLoadLocalPageURLs: Bool ) -> TestStoreOf { let downloadClient = makePreviewsNoManifestClient(loadLocalPageURLs: withLoadLocalPageURLs) - let store = TestStore(initialState: initialState) { - PreviewsReducer() - } withDependencies: { - $0.downloadClient = downloadClient - $0.databaseClient = .noop - $0.hapticsClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: PreviewsReducer.init, + withDependencies: { + $0.downloadClient = downloadClient + $0.databaseClient = .noop + $0.hapticsClient = .noop + } + ) store.exhaustivity = .off return store } diff --git a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift index b13df0f43..83085c2d9 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -28,7 +28,7 @@ struct ReadingReducerDownloadTests: DownloadFeatureTestCase { firstGID: download.gid, firstKey: download.token ) - let store = TestStore(initialState: initialState) { DetailReducer() } + let store = TestStore(initialState: initialState, reducer: DetailReducer.init) await store.send(.fetchVersionMetadataDone(.success(metadata))) { $0.galleryVersionMetadata = metadata } @@ -125,34 +125,36 @@ private extension ReadingReducerDownloadTests { gallery: Gallery, localPageURL: URL ) -> TestStoreOf { - let store = TestStore(initialState: initialState) { - ReadingReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.clipboardClient = .noop - $0.cookieClient = .noop - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { AsyncStream { $0.yield([]); $0.finish() } }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { gid in - gid == gallery.gid ? .success([1: localPageURL]) : .failure(.notFound) - } - ) - $0.hapticsClient = .noop - $0.imageClient = .noop - $0.urlClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: ReadingReducer.init, + withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { AsyncStream { $0.yield([]); $0.finish() } }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: { gid in + gid == gallery.gid ? .success([1: localPageURL]) : .failure(.notFound) + } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + ) store.exhaustivity = .off return store } @@ -161,34 +163,36 @@ private extension ReadingReducerDownloadTests { initialState: ReadingReducer.State, capturedCalls: UncheckedBox<[CapturedPageCall]> ) -> TestStoreOf { - let store = TestStore(initialState: initialState) { - ReadingReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.clipboardClient = .noop - $0.cookieClient = .noop - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { AsyncStream { $0.finish() } }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - captureCachedPage: { gid, index, imageURL in - capturedCalls.value.append(CapturedPageCall(gid: gid, index: index, imageURL: imageURL)) - } - ) - $0.hapticsClient = .noop - $0.imageClient = .noop - $0.urlClient = .noop - } + let store = TestStore( + initialState: initialState, + reducer: ReadingReducer.init, + withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { AsyncStream { $0.finish() } }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + captureCachedPage: { gid, index, imageURL in + capturedCalls.value.append(CapturedPageCall(gid: gid, index: index, imageURL: imageURL)) + } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + ) store.exhaustivity = .off return store } diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift index c4b70b1db..7f16357a6 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -37,38 +37,40 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { initialState.gallery = gallery initialState.localPageURLs = [1: localPageURL] - let store = TestStore(initialState: initialState) { - ReadingReducer() - } withDependencies: { - $0.appDelegateClient = .noop - $0.clipboardClient = .noop - $0.cookieClient = .noop - $0.databaseClient = .noop - $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() + let store = TestStore( + initialState: initialState, + reducer: ReadingReducer.init, + withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.databaseClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .init( + observeDownloads: { + AsyncStream { continuation in + continuation.finish() + } + }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + captureCachedPage: { gid, index, imageURL in + capturedCalls.value.append((gid, index, imageURL)) } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - captureCachedPage: { gid, index, imageURL in - capturedCalls.value.append((gid, index, imageURL)) - } - ) - $0.hapticsClient = .noop - $0.imageClient = .noop - $0.urlClient = .noop - } + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + ) store.exhaustivity = .off await store.send(.onWebImageSucceeded(1)) { From 46b8306fa099b891d1e351fb9e08acd7f3513910 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 09:13:21 +0800 Subject: [PATCH 189/614] Unify detail cancel IDs on cancellationGalleryID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every .cancellable(id:) registration now keys off state.cancellationGalleryID, the same value teardown's CancelID.all(for:) uses. Previously registrations split across state.gid, state.gallery.id, and cancellationGalleryID, so an effect keyed under an empty gid could be missed by teardown — re-leaking the cross-detail cancellation BUG-15 set out to fix. Also note that all(for:) must be hand-kept in sync with the cases (no CaseIterable check). --- EhPanda/View/Detail/DetailReducer+Download.swift | 6 +++--- EhPanda/View/Detail/DetailReducer+Fetch.swift | 16 ++++++++-------- EhPanda/View/Detail/DetailReducer.swift | 2 ++ 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index e30863425..8a34822b2 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -70,7 +70,7 @@ extension DetailReducer { let download = await downloadClient.fetchDownload(galleryID) await send(.fetchDownloadBadgeDone(download)) } - .cancellable(id: CancelID.fetchDownloadBadge(state.gid), cancelInFlight: true) + .cancellable(id: CancelID.fetchDownloadBadge(state.cancellationGalleryID), cancelInFlight: true) } private func handleFetchDownloadBadgeDone( @@ -93,7 +93,7 @@ extension DetailReducer { await send(.observeDownloadDone(download)) } } - .cancellable(id: CancelID.observeDownload(state.gid), cancelInFlight: true) + .cancellable(id: CancelID.observeDownload(state.cancellationGalleryID), cancelInFlight: true) } private func handleObserveDownloadDone( @@ -127,7 +127,7 @@ extension DetailReducer { } await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) } - .cancellable(id: CancelID.loadLocalPreviewURLs(state.gid), cancelInFlight: true) + .cancellable(id: CancelID.loadLocalPreviewURLs(state.cancellationGalleryID), cancelInFlight: true) } private func handleLoadLocalPreviewURLsDone( diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/EhPanda/View/Detail/DetailReducer+Fetch.swift index 9782305b0..2942e2d3d 100644 --- a/EhPanda/View/Detail/DetailReducer+Fetch.swift +++ b/EhPanda/View/Detail/DetailReducer+Fetch.swift @@ -61,7 +61,7 @@ extension DetailReducer { guard let dbState = await databaseClient.fetchGalleryState(gid: galleryID) else { return } await send(.fetchDatabaseInfosDone(dbState)) } - .cancellable(id: CancelID.fetchDatabaseInfos(gid)) + .cancellable(id: CancelID.fetchDatabaseInfos(state.cancellationGalleryID)) ) } @@ -87,7 +87,7 @@ extension DetailReducer { let response = await GalleryDetailRequest(gid: galleryID, galleryURL: galleryURL).response() await send(.fetchGalleryDetailDone(response)) } - .cancellable(id: CancelID.fetchGalleryDetail(galleryID)) + .cancellable(id: CancelID.fetchGalleryDetail(state.cancellationGalleryID)) } private func handleFetchGalleryDetailDone( @@ -164,7 +164,7 @@ extension DetailReducer { ) await send(.fetchDownloadBadgeDone(download)) } - .cancellable(id: CancelID.fetchVersionMetadata(gallery.gid), cancelInFlight: true) + .cancellable(id: CancelID.fetchVersionMetadata(state.cancellationGalleryID), cancelInFlight: true) } var galleryOpsReducer: some ReducerOf { @@ -198,7 +198,7 @@ extension DetailReducer { ).response() await send(.anyGalleryOpsDone(response)) } - .cancellable(id: CancelID.rateGallery(state.gallery.id)) + .cancellable(id: CancelID.rateGallery(state.cancellationGalleryID)) } private func handleFavorGallery(favIndex: Int, state: State) -> Effect { @@ -208,7 +208,7 @@ extension DetailReducer { ).response() await send(.anyGalleryOpsDone(response)) } - .cancellable(id: CancelID.favorGallery(state.gallery.id)) + .cancellable(id: CancelID.favorGallery(state.cancellationGalleryID)) } private func handleUnfavorGallery(state: State) -> Effect { @@ -216,7 +216,7 @@ extension DetailReducer { let response = await UnfavorGalleryRequest(gid: galleryID).response() await send(.anyGalleryOpsDone(response)) } - .cancellable(id: CancelID.unfavorGallery(state.gallery.id)) + .cancellable(id: CancelID.unfavorGallery(state.cancellationGalleryID)) } private func handlePostComment(galleryURL: URL, state: State) -> Effect { @@ -227,7 +227,7 @@ extension DetailReducer { ).response() await send(.anyGalleryOpsDone(response)) } - .cancellable(id: CancelID.postComment(state.gallery.id)) + .cancellable(id: CancelID.postComment(state.cancellationGalleryID)) } private func handleVoteTag(tag: String, vote: Int, state: State) -> Effect { @@ -240,7 +240,7 @@ extension DetailReducer { ).response() await send(.anyGalleryOpsDone(response)) } - .cancellable(id: CancelID.voteTag(state.gallery.id)) + .cancellable(id: CancelID.voteTag(state.cancellationGalleryID)) } private func handleAnyGalleryOpsDone(result: Result) -> Effect { diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index ca4fe27a4..724982918 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -39,6 +39,8 @@ struct DetailReducer { case postComment(String) case voteTag(String) + // Teardown cancels this whole set; keep it in sync with the cases above. + // Dropping `CaseIterable` (associated values) means the compiler can't check the list for us. static func all(for gid: String) -> [Self] { [ .fetchDatabaseInfos(gid), From a937f37c50dcbeae48da4b48f0c0755ceba7f689 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 09:13:28 +0800 Subject: [PATCH 190/614] Reconcile downloads on failed list mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateDownloadDone/deleteDownloadDone discarded their Result entirely, unlike moveDownloadDone/toggleDownloadPauseDone which reconcile on failure. Handle all four consistently: a failed list-level mutation reconciles the write-through cache against filesystem truth (DES-3). Documents the deliberate choice not to surface a per-op HUD here — the observeDownloads stream is the user feedback. --- EhPanda/View/Downloads/DownloadsReducer.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index a7a4a376e..245ce7afd 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -246,7 +246,17 @@ struct DownloadsReducer { await send(.updateDownloadDone(await downloadClient.retry(gid, .update))) } - case .updateDownloadDone: + // Like `moveDownloadDone`/`toggleDownloadPauseDone`, list-level mutations don't surface a + // per-op HUD: the `observeDownloads` stream is the user-facing feedback, continuously + // reflecting filesystem truth (DES-3). A failure means the observed state simply doesn't + // change; we still reconcile to repair any divergence a partial mutation left in the + // write-through cache. + case .updateDownloadDone(let result): + if case .failure = result { + return .run { _ in + await downloadClient.reconcileDownloads() + } + } return .none case .deleteDownload(let gid): @@ -254,7 +264,12 @@ struct DownloadsReducer { await send(.deleteDownloadDone(await downloadClient.delete(gid))) } - case .deleteDownloadDone: + case .deleteDownloadDone(let result): + if case .failure = result { + return .run { _ in + await downloadClient.reconcileDownloads() + } + } return .none case .detail(.folderManager(.createFolderDone)), From 032dadcb8be4bdd848fa4319f081d190606d7f43 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 09:13:36 +0800 Subject: [PATCH 191/614] Document account-level fatal error scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a /// on isFatalAccountAppError explaining the batch-abort set is intentionally account-level only (quota/auth/ban), and that gallery-level errors (.expunged/.copyrightClaim) are deliberately non-fatal here — so the exhaustive switch reads as a documented choice, not an oversight. --- .../App/Tools/Clients/DownloadClient+PageDownload.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index 04d8592bb..df0fd6dd5 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -306,6 +306,13 @@ extension DownloadManager { } } + /// Whether an error is account-level fatal and must abort the whole page batch. + /// + /// Scope is intentionally **account-level only**: quota, auth, and IP ban affect every + /// in-flight and queued page, so continuing the batch only wastes requests and can worsen a ban. + /// Gallery-level errors (`.expunged`, `.copyrightClaim`) are deliberately *not* fatal here — they + /// mean the gallery is gone, but they surface before per-page download and are handled upstream, + /// so a per-page occurrence is treated like any other page failure rather than aborting the batch. private func isFatalAccountAppError(_ error: AppError) -> Bool { switch error { case .quotaExceeded, .authenticationRequired, .ipBanned: From 1d13aff83baaa0f51c990d3e40f31376f049f91a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 09:13:45 +0800 Subject: [PATCH 192/614] Tidy folder name helpers Count UTF-8 bytes via character.utf8.count instead of allocating String(character) per character in truncatedToUTF8ByteCount. Drop the redundant instance overload of isGalleryFolderLikeName (pure delegate to the static) and qualify its sole caller. --- EhPanda/App/Tools/Utilities/DownloadFileStorage.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index d64ed3d7a..47f1a8cc7 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -158,10 +158,6 @@ struct DownloadFileStorage: Sendable { name.range(of: #"^\[[^\]]*_[^\]]*\] "#, options: .regularExpression) != nil } - func isGalleryFolderLikeName(_ name: String) -> Bool { - Self.isGalleryFolderLikeName(name) - } - static func normalizedUserFolderName(_ name: String) -> String? { guard let limitedName = normalizedFolderName( name, @@ -347,7 +343,7 @@ struct DownloadFileStorage: Sendable { // manifest-less ones, are invisible to the app and never become // user folders. guard (try? readManifest(folderURL: folderURL)) == nil else { continue } - guard !isGalleryFolderLikeName(folderName) else { continue } + guard !Self.isGalleryFolderLikeName(folderName) else { continue } userFolders.append(folderName) for galleryFolderURL in directoryURLs(in: folderURL) { @@ -459,7 +455,7 @@ private extension String { var byteCount = 0 var result = "" for character in self { - let characterByteCount = String(character).utf8.count + let characterByteCount = character.utf8.count guard byteCount + characterByteCount <= maximumByteCount else { break } From fbdfb3f11dab7dcf0ed2109db3b1b61857e5fe6c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 09:51:58 +0800 Subject: [PATCH 193/614] Rework download scan triggers --- .../Clients/DownloadClient+Execution.swift | 5 +- .../Clients/DownloadClient+Folders.swift | 53 ++++++++++++++++--- .../Clients/DownloadClient+Manager.swift | 1 + .../Clients/DownloadClient+Persistence.swift | 40 ++++++++++++-- .../Clients/DownloadClient+PublicAPI.swift | 6 +-- .../Clients/DownloadClient+Scheduling.swift | 7 +-- .../Tools/Utilities/DownloadFileStorage.swift | 16 ++++++ EhPanda/DataFlow/AppReducer.swift | 18 ++++++- EhPanda/View/Downloads/DownloadsReducer.swift | 46 ++++------------ EhPanda/View/Downloads/DownloadsView.swift | 2 +- .../Download/DownloadAutomationTests.swift | 47 ++++++++++++++++ .../DownloadFolderOperationTests.swift | 4 ++ .../DownloadManagerCaptureTests.swift | 2 + .../DownloadManagerStorageTests.swift | 41 ++++++++++++++ .../DownloadsReducerRefreshTests.swift | 38 ++++++++----- 15 files changed, 252 insertions(+), 74 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 76ec613ec..f92170e9c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -201,7 +201,10 @@ extension DownloadManager { } clearDownloadQueueIntent(gid: context.gid) await queueStore.remove(context.gid) - _ = await reloadDownloadIndex() + await reloadDownloadRecord( + gid: context.gid, + token: context.originalDownload.token + ) await notifyObservers() } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift index e3d83ec9e..26eed2969 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift @@ -8,7 +8,6 @@ import Foundation // MARK: - User Folder Operations extension DownloadManager { func fetchFolders() async -> [String] { - _ = await reloadDownloadIndex() return userFolders } @@ -35,7 +34,7 @@ extension DownloadManager { Logger.error(error) return .failure(.fileOperationFailed(error.localizedDescription)) } - _ = await reloadDownloadIndex() + insertUserFolder(normalizedName) return .success(()) } @@ -81,9 +80,10 @@ extension DownloadManager { } } catch { Logger.error(error) + await reloadDownloadRecordIfPossible(gidInFolder: oldName) return .failure(.fileOperationFailed(error.localizedDescription)) } - _ = await reloadDownloadIndex() + renameUserFolder(oldName: oldName, newName: normalizedName) await notifyObservers() return .success(()) } @@ -93,9 +93,9 @@ extension DownloadManager { guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return .failure(.notFound) } - let containedGIDs = downloadIndex.values + let containedRecords = downloadIndex.values .filter { $0.parentFolderName == name } - .map(\.manifest.gid) + let containedGIDs = containedRecords.map(\.manifest.gid) for gid in containedGIDs { schedulingBlockedGalleryIDs.insert(gid) } @@ -120,12 +120,14 @@ extension DownloadManager { do { try storage.removeFolder(at: folderURL) } catch let error as AppError { + await reloadDownloadRecords(containedRecords) return .failure(error) } catch { Logger.error(error) + await reloadDownloadRecords(containedRecords) return .failure(.fileOperationFailed(error.localizedDescription)) } - _ = await reloadDownloadIndex() + userFolders.removeAll { $0 == name } await notifyObservers() await scheduleNextIfNeeded() return .success(()) @@ -179,10 +181,47 @@ extension DownloadManager { } } catch { Logger.error(error) + await reloadDownloadRecord(gid: download.gid, token: download.token) return .failure(.fileOperationFailed(error.localizedDescription)) } - _ = await reloadDownloadIndex() + await reloadDownloadRecord(gid: download.gid, token: download.token) await notifyObservers() return .success(()) } + + private func insertUserFolder(_ name: String) { + guard !userFolders.contains(name) else { return } + userFolders.append(name) + userFolders.sort { + $0.localizedStandardCompare($1) == .orderedAscending + } + } + + private func renameUserFolder(oldName: String, newName: String) { + userFolders.removeAll { $0 == oldName } + insertUserFolder(newName) + let movedRecords = downloadIndex.values.filter { $0.parentFolderName == oldName } + for record in movedRecords { + let destinationFolderURL = storage.userFolderURL(name: newName) + .appendingPathComponent(record.folderURL.lastPathComponent, isDirectory: true) + downloadIndex[record.manifest.gid] = DownloadFolderRecord( + relativePath: "\(newName)/\(record.folderURL.lastPathComponent)", + folderURL: destinationFolderURL, + manifest: record.manifest, + modifiedAt: record.modifiedAt, + parentFolderName: newName + ) + } + } + + private func reloadDownloadRecordIfPossible(gidInFolder folderName: String) async { + let records = downloadIndex.values.filter { $0.parentFolderName == folderName } + await reloadDownloadRecords(records) + } + + private func reloadDownloadRecords(_ records: [DownloadFolderRecord]) async { + for record in records { + await reloadDownloadRecord(gid: record.manifest.gid, token: record.manifest.token) + } + } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index b36bb3148..8e9580d9d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -136,6 +136,7 @@ actor DownloadManager { let downloadOptionsProvider: @Sendable () async -> DownloadRequestOptions let queueStore: DownloadQueueStore var downloadIndex = [String: DownloadFolderRecord]() + var hasLoadedIndex = false var userFolders = [String]() var downloadErrors = [String: DownloadFailure]() var validationErrors = [String: DownloadFailure]() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index c5ea32ee1..2238ab3ec 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -13,22 +13,29 @@ extension DownloadManager { let scanResult = try storage.scanDownloads() downloadIndex = deduplicatedDownloadIndex(from: scanResult.records) userFolders = scanResult.userFolders + hasLoadedIndex = true return await downloads(from: scanResult.records) } catch { Logger.error(error) downloadIndex = [:] userFolders = [] + hasLoadedIndex = true return [] } } + /// The filesystem is the durable source of truth, but this actor's index is the read authority + /// between explicit sync points. Hot lookups must not walk download folders; app launch, + /// foreground return, pull-to-refresh, and targeted surprise repair are the scan boundaries. func indexedDownload(gid: String) async -> DownloadedGallery? { + guard hasLoadedIndex else { return nil } guard let record = downloadIndex[gid] else { return nil } return downloadedGallery(from: record) } func indexedDownloads() async -> [DownloadedGallery] { - await downloads(from: Array(downloadIndex.values)) + guard hasLoadedIndex else { return [] } + return await downloads(from: Array(downloadIndex.values)) } private func downloads( @@ -41,6 +48,14 @@ extension DownloadManager { .sorted(by: sortDownloadsByDisplayStatus) } + func indexedDownloads(gids: [String]) async -> [DownloadedGallery] { + guard hasLoadedIndex else { return [] } + let gidSet = Set(gids) + return await downloads( + from: downloadIndex.values.filter { gidSet.contains($0.manifest.gid) } + ) + } + private func deduplicatedDownloadIndex( from records: [DownloadFolderRecord] ) -> [String: DownloadFolderRecord] { @@ -127,9 +142,6 @@ extension DownloadManager { func fetchDownload( gid: String ) async -> DownloadedGallery? { - if downloadIndex[gid] == nil { - _ = await reloadDownloadIndex() - } return await indexedDownload(gid: gid) } @@ -154,6 +166,24 @@ extension DownloadManager { return await reloadDownloadIndex() .filter { gidSet.contains($0.gid) } } + + @discardableResult + func reloadDownloadRecord(gid: String, token: String) async -> DownloadedGallery? { + let records = storage.galleryFolderRecords(gid: gid, token: token) + hasLoadedIndex = true + guard let record = deduplicatedDownloadIndex(from: records).values.first else { + downloadIndex[gid] = nil + return nil + } + downloadIndex[gid] = record + if !userFolders.contains(record.parentFolderName) { + userFolders.append(record.parentFolderName) + userFolders.sort { + $0.localizedStandardCompare($1) == .orderedAscending + } + } + return await indexedDownload(gid: gid) + } } // MARK: - Persist Failure & Progress @@ -170,7 +200,6 @@ extension DownloadManager { downloadErrors[context.gid] = DownloadFailure(error: error) clearDownloadQueueIntent(gid: context.gid) await queueStore.remove(context.gid) - _ = await reloadDownloadIndex() } func flushDownloadProgress( @@ -224,6 +253,7 @@ extension DownloadManager { forKeys: [.contentModificationDateKey] ) .contentModificationDate + hasLoadedIndex = true downloadIndex[manifest.gid] = DownloadFolderRecord( relativePath: storage.rootRelativePath(forFolderURL: folderURL) ?? folderURL.lastPathComponent, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 2cb33cac4..25d6e9bd5 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -23,9 +23,6 @@ extension DownloadManager { } func fetchDownloads() async -> [DownloadedGallery] { - if downloadIndex.isEmpty { - return await fetchDownloadsFromStore() - } return await indexedDownloads() } @@ -43,7 +40,7 @@ extension DownloadManager { func badges(for gids: [String]) async -> [String: DownloadBadge] { guard !gids.isEmpty else { return [:] } - let downloads = await fetchDownloadsFromStore(gids: gids) + let downloads = await indexedDownloads(gids: gids) return Dictionary(uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) }) } @@ -233,6 +230,7 @@ extension DownloadManager { index: Int, imageURL: URL? ) async { + guard downloadIndex[gid] != nil else { return } guard let download = await fetchDownload(gid: gid), index >= 1, index <= max(download.pageCount, 1) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 3b99976e7..2e2832681 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -31,8 +31,8 @@ extension DownloadManager { func scheduleNextIfNeeded() async { let queuedGIDs = queueStore.gids let downloads = queuedGIDs.isEmpty - ? await fetchDownloadsFromStore() - : await fetchDownloadsFromStore(gids: queuedGIDs) + ? await indexedDownloads() + : await indexedDownloads(gids: queuedGIDs) guard activeTask == nil else { await reconcileActiveDownloadState() return @@ -182,7 +182,6 @@ extension DownloadManager { ) async throws -> Task? { clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) - _ = await reloadDownloadIndex() await notifyObservers() if activeGalleryID == gid { let task = activeTask @@ -200,7 +199,6 @@ extension DownloadManager { ) async throws { clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) - _ = await reloadDownloadIndex() } func cancelQueuedWorkItem( @@ -229,7 +227,6 @@ extension DownloadManager { queuedModes[gid] = resumeMode(for: download) queuedPageSelections[gid] = nil await queueStore.enqueue(gid) - _ = await reloadDownloadIndex() await notifyObservers() await scheduleNextIfNeeded() return .success(()) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 47f1a8cc7..573bb7327 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -154,6 +154,22 @@ struct DownloadFileStorage: Sendable { } } + func galleryFolderRecords(gid: String, token: String) -> [DownloadFolderRecord] { + galleryFolderURLs(gid: gid, token: token).compactMap { folderURL in + guard let manifest = try? readManifest(folderURL: folderURL), + manifest.gid == gid, + manifest.token == token + else { + return nil + } + return galleryFolderRecord( + folderURL: folderURL, + manifest: manifest, + parentFolderName: parentFolderName(forFolderURL: folderURL) ?? "" + ) + } + } + static func isGalleryFolderLikeName(_ name: String) -> Bool { name.range(of: #"^\[[^\]]*_[^\]]*\] "#, options: .regularExpression) != nil } diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index 38e83d236..4d67b8124 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -19,6 +19,7 @@ struct AppReducer { var searchRootState = SearchRootReducer.State() var downloadsState = DownloadsReducer.State() var settingState = SettingReducer.State() + var scenePhase = ScenePhase.active var didRunLaunchAutomation = false var isAwaitingIgneousForLaunchAutomation = false } @@ -45,6 +46,7 @@ struct AppReducer { @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient @Dependency(\.deviceClient) private var deviceClient + @Dependency(\.downloadClient) private var downloadClient @Dependency(\.appLaunchAutomationClient) private var appLaunchAutomationClient @Dependency(\.urlClient) private var urlClient @@ -64,13 +66,25 @@ struct AppReducer { return .none case .onScenePhaseChange(let scenePhase): + let previousScenePhase = state.scenePhase + state.scenePhase = scenePhase guard state.settingState.hasLoadedInitialSetting else { return .none } switch scenePhase { case .active: let threshold = state.settingState.setting.autoLockPolicy.rawValue let blurRadius = state.settingState.setting.backgroundBlurRadius - return .send(.appLock(.onBecomeActive(threshold, blurRadius))) + var effects: [Effect] = [ + .send(.appLock(.onBecomeActive(threshold, blurRadius))) + ] + if previousScenePhase == .background { + effects.append( + .run { _ in + await downloadClient.reconcileDownloads() + } + ) + } + return .merge(effects) case .inactive: let blurRadius = state.settingState.setting.backgroundBlurRadius @@ -168,7 +182,7 @@ struct AppReducer { if state.downloadsState.route != nil { effects.append(.send(.downloads(.setNavigation(nil)))) } else { - effects.append(.send(.downloads(.refreshDownloads))) + effects.append(.send(.downloads(.fetchDownloads))) } effects.append(hapticEffect) case .setting: diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index 245ce7afd..964245bc1 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -59,7 +59,6 @@ struct DownloadsReducer { case onAppear case teardown - case bootstrapDownloads case fetchDownloads case fetchDownloadsDone([DownloadedGallery]) case observeDownloads @@ -130,7 +129,7 @@ struct DownloadsReducer { return .merge( .send(.fetchDownloads), .send(.observeDownloads), - .send(.bootstrapDownloads) + .send(.fetchFolders) ) case .teardown: @@ -139,12 +138,6 @@ struct DownloadsReducer { .cancel(id: CancelID.fetchFolders) ) - case .bootstrapDownloads: - return .run { send in - await downloadClient.refreshDownloads() - await send(.refreshDownloadsDone) - } - case .fetchDownloads: state.loadingState = .loading return .run { send in @@ -196,12 +189,10 @@ struct DownloadsReducer { } case .moveDownloadDone(let result): - if case .failure = result { - return .run { _ in - await downloadClient.reconcileDownloads() - } + if case .success = result { + return .send(.fetchFolders) } - return .send(.fetchFolders) + return .none case .openReading(let gid): let requestID = UUID() @@ -233,12 +224,7 @@ struct DownloadsReducer { await send(.toggleDownloadPauseDone(await downloadClient.togglePause(gid))) } - case .toggleDownloadPauseDone(let result): - if case .failure = result { - return .run { _ in - await downloadClient.reconcileDownloads() - } - } + case .toggleDownloadPauseDone: return .none case .updateDownload(let gid): @@ -246,17 +232,10 @@ struct DownloadsReducer { await send(.updateDownloadDone(await downloadClient.retry(gid, .update))) } - // Like `moveDownloadDone`/`toggleDownloadPauseDone`, list-level mutations don't surface a - // per-op HUD: the `observeDownloads` stream is the user-facing feedback, continuously - // reflecting filesystem truth (DES-3). A failure means the observed state simply doesn't - // change; we still reconcile to repair any divergence a partial mutation left in the - // write-through cache. - case .updateDownloadDone(let result): - if case .failure = result { - return .run { _ in - await downloadClient.reconcileDownloads() - } - } + // List-level mutations don't surface a per-op HUD: the `observeDownloads` stream is the + // user-facing feedback from the DES-3 write-through index. Failures leave the current + // observed state in place; the download client performs any targeted surprise repair. + case .updateDownloadDone: return .none case .deleteDownload(let gid): @@ -264,12 +243,7 @@ struct DownloadsReducer { await send(.deleteDownloadDone(await downloadClient.delete(gid))) } - case .deleteDownloadDone(let result): - if case .failure = result { - return .run { _ in - await downloadClient.reconcileDownloads() - } - } + case .deleteDownloadDone: return .none case .detail(.folderManager(.createFolderDone)), diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift index 8f17a30d0..d396adaa5 100644 --- a/EhPanda/View/Downloads/DownloadsView.swift +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -184,7 +184,7 @@ private extension DownloadsView { LoadingView() case .failed(let error) where store.downloads.isEmpty: - ErrorView(error: error, action: { store.send(.refreshDownloads) }) + ErrorView(error: error, action: { store.send(.fetchDownloads) }) default: List { diff --git a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift index 90bd94b0b..9ce809ce9 100644 --- a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift @@ -4,12 +4,59 @@ // import Foundation +import SwiftUI import ComposableArchitecture import Testing @testable import EhPanda @Suite(.serialized) struct DownloadAutomationTests: DownloadFeatureTestCase { + @MainActor + @Test + func testAppForegroundReturnReconcilesDownloads() async { + let reconcileCount = UncheckedBox(0) + var initialState = AppReducer.State() + initialState.scenePhase = .background + initialState.settingState.hasLoadedInitialSetting = true + + let store = TestStore( + initialState: initialState, + reducer: AppReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = .none + $0.cookieClient = .noop + $0.downloadClient = .init( + observeDownloads: { .init { $0.finish() } }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + reconcileDownloads: { + reconcileCount.value += 1 + }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) } + ) + } + ) + store.exhaustivity = .off + + await store.send(.onScenePhaseChange(.active)) { + $0.scenePhase = .active + } + await store.receive(\.appLock, .onBecomeActive(-1, 10)) + await store.receive(\.appLock, .unlockApp) { + $0.appLockState.blurRadius = 0.00001 + } + await store.finish() + + #expect(reconcileCount.value == 1) + } + @Test func testAppLaunchAutomationResolveParsesGalleryURLAndCookies() { let automation = AppLaunchAutomation.resolve(environment: [ diff --git a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift index 0924aed34..ef659671e 100644 --- a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift @@ -47,6 +47,7 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: environment.rootURL) } let gid = "311" try writeGalleryFolder(storage: environment.storage, folderName: "Old Name", gid: gid) + await environment.manager.reconcileDownloads() let result = await environment.manager.renameFolder(oldName: "Old Name", newName: "New Name") guard case .success = result else { @@ -85,6 +86,7 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: environment.rootURL) } let gid = "313" let folderURL = try writeGalleryFolder(storage: environment.storage, folderName: "Doomed", gid: gid) + await environment.manager.reconcileDownloads() await environment.manager.testingSetQueuedGalleryIDs([gid]) let result = await environment.manager.deleteFolder(name: "Doomed") @@ -135,6 +137,7 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: environment.rootURL) } let gid = "315" let sourceURL = try writeGalleryFolder(storage: environment.storage, folderName: "Source", gid: gid) + await environment.manager.reconcileDownloads() let result = await environment.manager.moveDownload(gid: gid, toFolderName: "Target") guard case .success = result else { @@ -155,6 +158,7 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: environment.rootURL) } let gid = "316" let folderURL = try writeGalleryFolder(storage: environment.storage, folderName: "Home", gid: gid) + await environment.manager.reconcileDownloads() let result = await environment.manager.moveDownload(gid: gid, toFolderName: "Home") guard case .success = result else { diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index cafe8084e..c610b3de2 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -37,6 +37,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { ), folderURL: completedFolderURL ) + await manager.reloadDownloadIndex() let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in @@ -83,6 +84,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { let completedFolderURL = try setupCaptureMissingFilesFolder( rootURL: rootURL, gid: gid ) + await manager.reloadDownloadIndex() let (imageURL, cacheKey) = try await setupCaptureCachedImage(gid: gid) defer { KingfisherManager.shared.cache.removeImage(forKey: cacheKey) diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index ba045df8d..e9785dbe1 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -142,6 +142,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) ) await manager.testingSetQueuedGalleryIDs(["600"]) + await manager.reloadDownloadIndex() let downloads = await manager.fetchDownloads() let indexedDownload = try #require(await manager.fetchDownload(gid: "600")) @@ -156,6 +157,34 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { #expect(badges["601"] == nil) } + @Test + func testDownloadManagerWarmIndexMissDoesNotRescanDisk() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[610_token] Warm", + manifest: indexedManifest(gid: "610", title: "Warm", pageHashes: ["sha256:known"]) + ) + await manager.reloadDownloadIndex() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[611_token] Later", + manifest: indexedManifest(gid: "611", title: "Later", pageHashes: ["sha256:new"]) + ) + + let badges = await manager.badges(for: ["611"]) + + #expect(await manager.fetchDownload(gid: "611") == nil) + #expect(badges.isEmpty) + } + @Test func testDownloadManagerObserverInitialSnapshotUsesManifestIndex() async throws { let rootURL = FileManager.default.temporaryDirectory @@ -178,6 +207,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { pageHashes: ["sha256:1"] ) ) + await manager.reloadDownloadIndex() let stream = await manager.observeDownloads() let initialSnapshotTask = Task<[DownloadedGallery]?, Never> { @@ -341,6 +371,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { pageHashes: [""] ) ) + await manager.reloadDownloadIndex() await manager.testingSetDownloadError(failure, gid: "430") let sanitizedDownload = await manager.testingSanitizeLocalFilesIfNeeded( @@ -375,6 +406,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { pageHashes: ["sha256:missing"] ) ) + await manager.reloadDownloadIndex() let validation = await manager.validateImageData(gid: "440") @@ -409,6 +441,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { pageHashes: ["sha256:done"] ) ) + await manager.reloadDownloadIndex() let blockingTask = Task { do { try await Task.sleep(for: .seconds(60)) @@ -454,6 +487,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { pageHashes: ["sha256:done", ""] ) ) + await manager.reloadDownloadIndex() await manager.testingSetFailedPageErrors( [ .init( @@ -508,6 +542,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { pageHashes: [""] ) ) + await manager.reloadDownloadIndex() await queueStore.enqueue("800") let download = try #require(await manager.fetchDownload(gid: "800")) @@ -565,6 +600,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) ) ) + await manager.reloadDownloadIndex() await queueStore.enqueue(gallery.gid) await manager.testingSetDownloadError( .init(code: .networkingFailed, message: "failed"), @@ -606,6 +642,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { pageHashes: ["sha256:1", ""] ) ) + await manager.reloadDownloadIndex() await queueStore.enqueue("820") await manager.testingSetDownloadError( .init(code: .networkingFailed, message: "failed"), @@ -685,6 +722,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { modifiedAt: Date(timeIntervalSince1970: 200) ) ) + await manager.reloadDownloadIndex() await queueStore.enqueue("830") await queueStore.enqueue("831") @@ -778,6 +816,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { pageHashes: ["sha256:done", ""] ) ) + await manager.reloadDownloadIndex() let folderURL = storage.folderURL(relativePath: folderRelativePath) try FileManager.default.createDirectory( at: folderURL, @@ -843,6 +882,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { to: completedFolderURL.appendingPathComponent("\(gid)_token_2.jpg"), options: .atomic ) + await manager.reloadDownloadIndex() let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() @@ -887,6 +927,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { to: page2URL, options: .atomic ) + await manager.reloadDownloadIndex() let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() diff --git a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift index af5d3b2ff..03960a601 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift @@ -12,7 +12,7 @@ import Testing struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { @MainActor @Test - func testDownloadsReducerRefreshesWithoutResumingQueueAfterPauseFailure() async { + func testDownloadsReducerDoesNotReconcileAfterPauseFailure() async { let download = sampleDownload( gid: "987655", title: "Queued Gallery", @@ -54,7 +54,7 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { await store.receive(\.toggleDownloadPauseDone) await store.finish() - #expect(reconcileCount.value == 1) + #expect(reconcileCount.value == 0) } @MainActor @@ -103,9 +103,10 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { @MainActor @Test - func testDownloadsReducerBootstrapUsesClientRefresh() async { + func testDownloadsReducerOnAppearUsesCachedIndexWithoutRefresh() async { + let fetchCount = UncheckedBox(0) + let folderFetchCount = UncheckedBox(0) let refreshCount = UncheckedBox(0) - let reconcileCount = UncheckedBox(0) let store = TestStore( initialState: DownloadsReducer.State(), @@ -117,11 +118,11 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { continuation.finish() } }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - reconcileDownloads: { - reconcileCount.value += 1 + fetchDownloads: { + fetchCount.value += 1 + return [] }, + fetchDownload: { _ in nil }, refreshDownloads: { refreshCount.value += 1 }, @@ -131,18 +132,29 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { togglePause: { _ in .success(()) }, retry: { _, _ in .success(()) }, delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } + loadManifest: { _ in .failure(.notFound) }, + fetchFolders: { + folderFetchCount.value += 1 + return [] + } ) } ) - await store.send(.bootstrapDownloads) - await store.receive(\.refreshDownloadsDone) + await store.send(.onAppear) { + $0.hasLoadedInitialDownloads = true + } + await store.receive(\.fetchDownloads) + await store.receive(\.observeDownloads) await store.receive(\.fetchFolders) + await store.receive(\.fetchDownloadsDone) { + $0.loadingState = .idle + } await store.receive(\.fetchFoldersDone) - #expect(refreshCount.value == 1) - #expect(reconcileCount.value == 0) + #expect(fetchCount.value == 1) + #expect(folderFetchCount.value == 1) + #expect(refreshCount.value == 0) } } From a590a53561404558c08bb6a6969e88ea9c0c7488 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 09:57:47 +0800 Subject: [PATCH 194/614] Cache local download asset URLs --- .../Clients/DownloadClient+Folders.swift | 6 ++ .../Clients/DownloadClient+Persistence.swift | 19 +---- .../Clients/DownloadClient+PublicAPI.swift | 3 +- .../Clients/DownloadClient+RetryHelpers.swift | 7 +- .../Tools/Utilities/DownloadFileStorage.swift | 6 +- .../DownloadManagerCachedURLTests.swift | 75 +++++++++++++++++++ 6 files changed, 93 insertions(+), 23 deletions(-) create mode 100644 EhPandaTests/Tests/Download/DownloadManagerCachedURLTests.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift index 26eed2969..db7c6310c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift @@ -208,6 +208,12 @@ extension DownloadManager { relativePath: "\(newName)/\(record.folderURL.lastPathComponent)", folderURL: destinationFolderURL, manifest: record.manifest, + localCoverURL: record.localCoverURL.map { + destinationFolderURL.appendingPathComponent($0.lastPathComponent) + }, + localPageURLs: record.localPageURLs.mapValues { + destinationFolderURL.appendingPathComponent($0.lastPathComponent) + }, modifiedAt: record.modifiedAt, parentFolderName: newName ) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 2238ab3ec..a08da80ad 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -79,14 +79,8 @@ extension DownloadManager { manifest: record.manifest, folderURL: record.folderURL, folderName: record.parentFolderName, - localCoverURL: storage.localCoverURL( - folderURL: record.folderURL, - manifest: record.manifest - ), - localPageURLs: storage.imageURLs( - folderURL: record.folderURL, - manifest: record.manifest - ), + localCoverURL: record.localCoverURL, + localPageURLs: record.localPageURLs, modifiedAt: record.modifiedAt, displayStatus: displayStatus(for: record), lastError: validationErrors[gid] ?? downloadErrors[gid] @@ -249,17 +243,10 @@ extension DownloadManager { } func updateDownloadIndex(folderURL: URL, manifest: DownloadManifest) { - let modifiedAt = try? folderURL.resourceValues( - forKeys: [.contentModificationDateKey] - ) - .contentModificationDate hasLoadedIndex = true - downloadIndex[manifest.gid] = DownloadFolderRecord( - relativePath: storage.rootRelativePath(forFolderURL: folderURL) - ?? folderURL.lastPathComponent, + downloadIndex[manifest.gid] = storage.galleryFolderRecord( folderURL: folderURL, manifest: manifest, - modifiedAt: modifiedAt, parentFolderName: storage.parentFolderName(forFolderURL: folderURL) ?? "" ) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 25d6e9bd5..755a7f0e3 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -277,11 +277,12 @@ extension DownloadManager { captureTarget.preferredRelativePath ?? existingPages[index], overwriteExistingFile: true ) else { return } - _ = try? storage.refreshManifestPageFileHash( + let manifest = try storage.refreshManifestPageFileHash( folderURL: captureTarget.folderURL, pageIndex: index, relativePath: pageResult.relativePath ) + updateDownloadIndex(folderURL: captureTarget.folderURL, manifest: manifest) _ = await sanitizeLocalFilesIfNeeded(gid: gid, clearingLastError: true) } catch { Logger.error(error) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index d6d3c781d..6870e74a4 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -94,12 +94,9 @@ extension DownloadManager { func loadLocalPageURLs( gid: String ) async -> Result<[Int: URL], AppError> { - guard let download = await sanitizeLocalFilesIfNeeded(gid: gid) else { + guard let download = await fetchDownload(gid: gid) else { return .failure(.notFound) } - return .success(storage.imageURLs( - folderURL: download.folderURL, - manifest: download.manifest - )) + return .success(download.localPageURLs) } } diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 573bb7327..37df308bd 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -15,6 +15,8 @@ struct DownloadFolderRecord: Equatable, Sendable { let relativePath: String let folderURL: URL let manifest: DownloadManifest + let localCoverURL: URL? + let localPageURLs: [Int: URL] let modifiedAt: Date? let parentFolderName: String } @@ -396,7 +398,7 @@ struct DownloadFileStorage: Sendable { } } - private func galleryFolderRecord( + func galleryFolderRecord( folderURL: URL, manifest: DownloadManifest, parentFolderName: String @@ -408,6 +410,8 @@ struct DownloadFileStorage: Sendable { relativePath: "\(parentFolderName)/\(folderURL.lastPathComponent)", folderURL: folderURL, manifest: manifest, + localCoverURL: localCoverURL(folderURL: folderURL, manifest: manifest), + localPageURLs: imageURLs(folderURL: folderURL, manifest: manifest), modifiedAt: resourceValues?.contentModificationDate, parentFolderName: parentFolderName ) diff --git a/EhPandaTests/Tests/Download/DownloadManagerCachedURLTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCachedURLTests.swift new file mode 100644 index 000000000..bffa4b845 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadManagerCachedURLTests.swift @@ -0,0 +1,75 @@ +// +// DownloadManagerCachedURLTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadManagerCachedURLTests { + @Test + func testIndexedDownloadUsesCachedLocalURLsUntilExplicitReload() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + let folderRelativePath = "Folder/[900_token] Cached" + let folderURL = storage.folderURL(relativePath: folderRelativePath) + let page1URL = folderURL.appendingPathComponent("900_token_1.jpg") + let page2URL = folderURL.appendingPathComponent("900_token_2.jpg") + let coverURL = folderURL.appendingPathComponent("900_token_cover.jpg") + + try storage.ensureRootDirectory() + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest(manifest(), folderURL: folderURL) + try Data([0x01]).write(to: page1URL, options: .atomic) + await manager.reloadDownloadIndex() + + let initialDownload = try #require(await manager.fetchDownload(gid: "900")) + #expect(initialDownload.localCoverURL == nil) + #expect(initialDownload.localPageURLs == [1: page1URL]) + + try Data([0x02]).write(to: page2URL, options: .atomic) + try Data([0x03]).write(to: coverURL, options: .atomic) + + let cachedDownload = try #require(await manager.fetchDownload(gid: "900")) + let cachedPageURLs = try await manager.loadLocalPageURLs(gid: "900").get() + + #expect(cachedDownload.localCoverURL == nil) + #expect(cachedDownload.localPageURLs == [1: page1URL]) + #expect(cachedPageURLs == [1: page1URL]) + + await manager.reloadDownloadIndex() + + let reloadedDownload = try #require(await manager.fetchDownload(gid: "900")) + #expect(reloadedDownload.localCoverURL == coverURL) + #expect(reloadedDownload.localPageURLs == [1: page1URL, 2: page2URL]) + } +} + +private extension DownloadManagerCachedURLTests { + func manifest() -> DownloadManifest { + DownloadManifest( + gid: "900", + host: .ehentai, + token: "token", + title: "Cached", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: Date(timeIntervalSince1970: 1_000), + rating: 4, + pages: [1: "", 2: ""] + ) + } +} From 41e77779bd17b7413387578b84ea52f078b68ad3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 10:00:19 +0800 Subject: [PATCH 195/614] Make page asset discovery single pass --- .../Tools/Utilities/DownloadFileStorage.swift | 28 +++++++++++++------ .../Download/DownloadFileStorageTests.swift | 22 +++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 37df308bd..8f4971a59 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -94,16 +94,26 @@ struct DownloadFileStorage: Sendable { } func existingPageRelativePaths(folderURL: URL, manifest: DownloadManifest) -> [Int: String] { + let pageIndices = Set(manifest.pages.keys) + guard !pageIndices.isEmpty else { return [:] } + let fileURLs = existingAssetFileURLs(folderURL: folderURL) - return manifest.pages.keys.sorted().reduce(into: [:]) { result, index in - guard let fileURL = existingAssetFileURL( - in: fileURLs, - prefix: pageFilePrefix( - gid: manifest.gid, - token: manifest.token, - index: index - ) - ) else { return } + let identityPrefix = + "\(normalizedIdentityComponent(manifest.gid))_\(normalizedIdentityComponent(manifest.token))_" + + return fileURLs.reduce(into: [:]) { result, fileURL in + let fileName = fileURL.lastPathComponent + guard fileName.hasPrefix(identityPrefix) else { return } + let suffix = fileName.dropFirst(identityPrefix.count) + guard let dotIndex = suffix.firstIndex(of: ".") else { return } + let indexText = String(suffix[.. Date: Sun, 14 Jun 2026 10:06:28 +0800 Subject: [PATCH 196/614] Preserve flushed page hashes --- .../DownloadClient+ExecutionPerform.swift | 13 +++---- .../DownloadFileStorage+Operations.swift | 25 ++++++------ .../DownloadFileStorageHashTests.swift | 38 +++++++++++++++++-- .../Tests/Download/DownloadProcessTests.swift | 2 + 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 1b5474275..b3ad8267f 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -120,10 +120,7 @@ extension DownloadManager { failedPages: context.batchResult.failedPages ) } - let missingPageIndices = missingFinalizedPageIndices( - payload: payload, - folderURL: folderURL - ) + let missingPageIndices = try missingFinalizedPageIndices(folderURL: folderURL) guard missingPageIndices.isEmpty else { throw IncompleteDownloadError( missingPageIndices: missingPageIndices @@ -137,10 +134,9 @@ extension DownloadManager { } private func missingFinalizedPageIndices( - payload: DownloadRequestPayload, folderURL: URL - ) -> [Int] { - let manifest = makeInitialManifest(payload: payload) + ) throws -> [Int] { + let manifest = try storage.readManifest(folderURL: folderURL) let existingPages = storage.existingPageRelativePaths( folderURL: folderURL, manifest: manifest @@ -181,7 +177,7 @@ extension DownloadManager { ) async throws { let batchResult = finalizeContext.batchResult let existingDownload = finalizeContext.existingDownload - let manifest = makeInitialManifest(payload: payload) + let manifest = try storage.readManifest(folderURL: folderURL) let hashedManifest = try storage.addingCurrentFileHashes( to: manifest, folderURL: folderURL @@ -190,6 +186,7 @@ extension DownloadManager { hashedManifest, folderURL: folderURL ) + updateDownloadIndex(folderURL: folderURL, manifest: hashedManifest) await cleanupCachedRemoteAssetsAfterSuccessfulDownload( payload: payload, pages: batchResult.pages, diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift index 74ddd23fd..56301aaae 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -81,19 +81,22 @@ extension DownloadFileStorage { folderURL: folderURL, manifest: manifest ) - let pages = try manifest.pages.keys.sorted() - .reduce(into: [Int: String]()) { result, index in - guard let relativePath = existingPages[index] else { - throw AppError.fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index) - ) - } - result[index] = try hashReadableAsset( - folderURL: folderURL, - relativePath: relativePath, - missingMessage: L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index) + var pages = manifest.pages + for index in manifest.pages.keys.sorted() { + guard pages[index]?.isEmpty != false else { + continue + } + guard let relativePath = existingPages[index] else { + throw AppError.fileOperationFailed( + L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index) ) } + pages[index] = try hashReadableAsset( + folderURL: folderURL, + relativePath: relativePath, + missingMessage: L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index) + ) + } return manifest.replacing(pages: pages) } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift index b608fcc82..421ddecc5 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift @@ -54,6 +54,32 @@ struct DownloadFileStorageHashTests { #expect(storage.validate(download: download, verifiesContentHashes: true) == .valid) } + @Test + func testAddingCurrentFileHashesPreservesExistingHashesAndFillsEmptyHashes() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (_, folderURL) = try makePreparedDownload(storage: storage) + let pageOneURL = folderURL.appendingPathComponent("123_token_1.jpg") + let existingPageOneHash = "sha256:already-flushed" + try Data([0x09]).write(to: pageOneURL, options: .atomic) + let manifest = try sampleManifest( + pageHashes: [ + 1: existingPageOneHash, + 2: "" + ] + ) + + let hashedManifest = try storage.addingCurrentFileHashes( + to: manifest, + folderURL: folderURL + ) + + #expect(hashedManifest.pages[1] == existingPageOneHash) + #expect(hashedManifest.pages[2]?.hasPrefix("sha256:") == true) + #expect(hashedManifest.pages[2]?.isEmpty == false) + } + private func makePreparedDownload( storage: DownloadFileStorage ) throws -> (DownloadedGallery, URL) { @@ -112,6 +138,14 @@ struct DownloadFileStorageHashTests { } private func sampleManifest(pageCount: Int) throws -> DownloadManifest { + try sampleManifest( + pageHashes: pageCount > 0 + ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) + : [:] + ) + } + + private func sampleManifest(pageHashes: [Int: String]) throws -> DownloadManifest { DownloadManifest( gid: "123", host: .ehentai, @@ -125,9 +159,7 @@ struct DownloadFileStorageHashTests { tags: [], postedDate: .now, rating: 4, - pages: pageCount > 0 - ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) - : [:] + pages: pageHashes ) } } diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 8c8ec8962..6e732fa62 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -32,6 +32,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { title: "Queued Failure", pageCount: 2 ) + await manager.reloadDownloadIndex() await manager.testingSetQueuedGalleryIDs([gid]) let persistenceGate = FailurePersistenceGate() @@ -91,6 +92,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { storage: storage, gid: gid, pageIndex: pageIndex, oldPageCount: oldPageCount ) + await manager.reloadDownloadIndex() let beforeProcess = await manager.testingFetchDownload(gid: gid) #expect(beforeProcess?.hasUpdate ?? true == false) From aadb3698bc4236587c4dbb8f7327a00eb9289a89 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 10:15:32 +0800 Subject: [PATCH 197/614] Resolve download options at runtime --- .../Clients/DownloadClient+Execution.swift | 9 ++- .../DownloadClient+ExecutionFetch.swift | 6 +- .../DownloadClient+ExecutionPerform.swift | 10 ++++ .../DownloadClient+ExecutionSupport.swift | 13 ++-- .../Clients/DownloadClient+Manager.swift | 5 ++ .../Clients/DownloadClient+PageDownload.swift | 2 +- .../DownloadClient+PageDownloadHelpers.swift | 8 ++- .../Clients/DownloadClient+Testing.swift | 2 + .../DownloadedGallery+Extensions.swift | 3 - .../View/Detail/DetailReducer+Download.swift | 20 ++----- EhPanda/View/Detail/DetailReducer.swift | 4 +- EhPanda/View/Detail/DetailView.swift | 4 +- .../Download/DetailReducerDownloadTests.swift | 18 ++---- .../DetailReducerPauseAndGuardTests.swift | 6 +- .../DownloadEnqueueManifestTests.swift | 2 - .../DownloadFolderOperationTests.swift | 1 - .../Download/DownloadImageParsingTests.swift | 2 +- .../DownloadInterruptedResumeTests.swift | 2 +- .../DownloadManagerRepairSeedTests.swift | 3 +- .../Tests/Download/DownloadProcessTests.swift | 60 ++++++++++--------- 20 files changed, 93 insertions(+), 87 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index f92170e9c..3854c5cd3 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -20,6 +20,7 @@ extension DownloadManager { return } let mode = queuedMode(for: download) + let options = await downloadOptionsProvider() do { clearDownloadFailureState(gid: gid, includePageFailures: false) @@ -27,7 +28,8 @@ extension DownloadManager { let result = try await fetchNormalizeAndDownload( gid: gid, download: download, - mode: mode + mode: mode, + options: options ) guard !Task.isCancelled else { return } await completeDownload( @@ -120,12 +122,14 @@ extension DownloadManager { private func fetchNormalizeAndDownload( gid: String, download: DownloadedGallery, - mode: DownloadStartMode + mode: DownloadStartMode, + options: DownloadRequestOptions ) async throws -> ProcessDownloadResult { let rawPageSelection = queuedPageSelections[gid] let fetchedPayload = try await fetchLatestPayload( for: download, mode: mode, + options: options, pageSelection: rawPageSelection ) let payload = normalizeFetchedPayload( @@ -139,6 +143,7 @@ extension DownloadManager { ) _ = try await performDownload( payload: payload, + options: options, folderRelativePath: folderRelativePath, existingDownload: download ) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index 98853f8b1..efdf59c03 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -10,11 +10,11 @@ extension DownloadManager { func fetchLatestPayload( for download: DownloadedGallery, mode: DownloadStartMode, + options: DownloadRequestOptions, pageSelection: [Int]? ) async throws -> DownloadRequestPayload { let galleryURL = download.gallery.galleryURL guard let galleryURL else { throw AppError.notFound } - let options = await downloadOptionsProvider() let detailResponse = try await GalleryDetailRequest( gid: download.gid, galleryURL: galleryURL, @@ -44,7 +44,6 @@ extension DownloadManager { fetchedData: fetchedData, components: components, mode: mode, - options: options, pageSelection: pageSelection ) } @@ -59,7 +58,6 @@ extension DownloadManager { fetchedData: FetchedGalleryData, components: GalleryComponents, mode: DownloadStartMode, - options: DownloadRequestOptions, pageSelection: [Int]? ) -> DownloadRequestPayload { let download = fetchedData.download @@ -73,7 +71,6 @@ extension DownloadManager { host: download.host, folderName: download.folderName, versionMetadata: versionMetadata, - options: options, mode: mode, pageSelection: pageSelection.map(Set.init) ) @@ -160,7 +157,6 @@ extension DownloadManager { host: payload.host, folderName: payload.folderName, versionMetadata: payload.versionMetadata, - options: payload.options, mode: payload.mode, pageSelection: pageSelection.map(Set.init) ) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index b3ad8267f..f6e16c954 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -14,6 +14,7 @@ extension DownloadManager { func performDownload( payload: DownloadRequestPayload, + options: DownloadRequestOptions, folderRelativePath: String, existingDownload: DownloadedGallery ) async throws -> PerformDownloadResult { @@ -39,6 +40,7 @@ extension DownloadManager { do { let batchAndCover = try await executePageDownloads( payload: payload, + options: options, workingSeed: workingSeed, pendingIndices: pendingIndices, workingFolderURL: workingFolderURL, @@ -54,6 +56,7 @@ extension DownloadManager { private func executePageDownloads( payload: DownloadRequestPayload, + options: DownloadRequestOptions, workingSeed: WorkingSeed, pendingIndices: [Int], workingFolderURL: URL, @@ -62,17 +65,20 @@ extension DownloadManager { let existingDownload = executionContext.existingDownload let coverRelativePath = try await downloadCoverIfNeeded( payload: payload, + options: options, folderURL: workingFolderURL, existingCoverRelativePath: workingSeed.coverRelativePath ) let source = try await resolveSourceIfNeeded( payload: payload, + options: options, pendingIndices: pendingIndices, folderURL: workingFolderURL, existingPages: workingSeed.existingPages ) let downloadContext = PageDownloadContext( payload: payload, + options: options, source: source, folderURL: workingFolderURL ) @@ -100,11 +106,13 @@ extension DownloadManager { private func downloadCoverIfNeeded( payload: DownloadRequestPayload, + options: DownloadRequestOptions, folderURL: URL, existingCoverRelativePath: String? ) async throws -> String? { try await downloadCoverImage( payload: payload, + options: options, folderURL: folderURL, existingCoverRelativePath: existingCoverRelativePath ) @@ -148,6 +156,7 @@ extension DownloadManager { private func resolveSourceIfNeeded( payload: DownloadRequestPayload, + options: DownloadRequestOptions, pendingIndices: [Int], folderURL: URL, existingPages: [Int: String] @@ -166,6 +175,7 @@ extension DownloadManager { } return try await resolveSource( payload: payload, + options: options, requiredPageIndices: missingIndices ) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 2e5435dbd..02dd8fa50 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -46,6 +46,7 @@ extension DownloadManager { func downloadCoverImage( payload: DownloadRequestPayload, + options: DownloadRequestOptions, folderURL: URL, existingCoverRelativePath: String? ) async throws -> String? { @@ -77,7 +78,7 @@ extension DownloadManager { coverURL: coverURL, payload: payload, folderURL: folderURL, - allowsCellular: payload.options.allowCellular + allowsCellular: options.allowCellular ) } @@ -160,6 +161,7 @@ extension DownloadManager { func resolveSource( payload: DownloadRequestPayload, + options: DownloadRequestOptions, requiredPageIndices: [Int] ) async throws -> ResolvedSource { let requiredPageNumbers = Array( @@ -174,7 +176,7 @@ extension DownloadManager { galleryURL: payload.gallery.galleryURL.forceUnwrapped, pageNum: pageNumber, urlSession: urlSession, - allowsCellular: payload.options.allowCellular + allowsCellular: options.allowCellular ) .response() .get() @@ -192,7 +194,7 @@ extension DownloadManager { let (mpvKey, imageKeys) = try await MPVKeysRequest( mpvURL: firstURL, urlSession: urlSession, - allowsCellular: payload.options.allowCellular + allowsCellular: options.allowCellular ) .response() .get() @@ -317,6 +319,7 @@ extension DownloadManager { func resolvedImageSource( index: Int, payload: DownloadRequestPayload, + options: DownloadRequestOptions, source: ResolvedSource ) async throws -> ResolvedImageSource { switch source { @@ -327,7 +330,7 @@ extension DownloadManager { let (imageURLs, _) = try await GalleryNormalImageURLsRequest( thumbnailURLs: [index: thumbnailURL], urlSession: urlSession, - allowsCellular: payload.options.allowCellular + allowsCellular: options.allowCellular ) .response() .get() @@ -351,7 +354,7 @@ extension DownloadManager { skipServerIdentifier: nil, apiURL: payload.host.url.appendingPathComponent("api.php"), urlSession: urlSession, - allowsCellular: payload.options.allowCellular, + allowsCellular: options.allowCellular, requiresSkipServerIdentifier: false ) .response() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 8e9580d9d..f8c640617 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -87,6 +87,7 @@ actor DownloadManager { struct PageDownloadContext: Sendable { let payload: DownloadRequestPayload + let options: DownloadRequestOptions let source: ResolvedSource? let folderURL: URL } @@ -133,6 +134,10 @@ actor DownloadManager { let urlSession: URLSession let storedCookiesProvider: @Sendable (URL) -> [HTTPCookie] let libraryClient: LibraryClient + /// Supplies the latest runtime settings immediately before a queued download starts. + /// + /// Options are not stored in manifests or request payloads so settings changed while + /// a gallery is queued apply to the eventual detail fetch and page workers. let downloadOptionsProvider: @Sendable () async -> DownloadRequestOptions let queueStore: DownloadQueueStore var downloadIndex = [String: DownloadFolderRecord]() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index df0fd6dd5..917c5af6c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -218,7 +218,7 @@ extension DownloadManager { pageCount: Int, existingPages: [Int: String] ) { - let workerCount = context.payload.options.workerCount + let workerCount = context.options.workerCount for _ in 0.. PageResult { - let payload = context.payload - let attempts = payload.options.autoRetryFailedPages ? 2 : 1 + let attempts = context.options.autoRetryFailedPages ? 2 : 1 var capturedError: AppError = .unknown for _ in 0.. PageResult { @@ -110,7 +112,7 @@ extension DownloadManager { let (downloadedFileURL, response) = try await downloadResponse( url: targetURL, - allowsCellular: payload.options.allowCellular, + allowsCellular: options.allowCellular, retriesRequest: false ) let relativePath: String diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 1699e93db..19188dce1 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -99,11 +99,13 @@ extension DownloadManager { func testingFetchLatestPayload( for download: DownloadedGallery, mode: DownloadStartMode, + options: DownloadRequestOptions = .init(), pageSelection: [Int]? = nil ) async throws -> DownloadRequestPayload { try await fetchLatestPayload( for: download, mode: mode, + options: options, pageSelection: pageSelection ) } diff --git a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift index 3d9eaed98..db04f66ac 100644 --- a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift @@ -38,7 +38,6 @@ struct DownloadRequestPayload: Equatable, Sendable { let host: GalleryHost let folderName: String let versionMetadata: DownloadVersionMetadata? - let options: DownloadRequestOptions let mode: DownloadStartMode let pageSelection: Set? @@ -50,7 +49,6 @@ struct DownloadRequestPayload: Equatable, Sendable { host: GalleryHost, folderName: String, versionMetadata: DownloadVersionMetadata? = nil, - options: DownloadRequestOptions, mode: DownloadStartMode, pageSelection: Set? = nil ) { @@ -61,7 +59,6 @@ struct DownloadRequestPayload: Equatable, Sendable { self.host = host self.folderName = folderName self.versionMetadata = versionMetadata - self.options = options self.mode = mode self.pageSelection = pageSelection } diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index 8a34822b2..b8caa78b8 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -40,10 +40,10 @@ extension DetailReducer { return handleOpenReading(state: &state) case .openReadingDone(let result): return handleOpenReadingDone(result: result, state: &state) - case .runLaunchAutomationIfNeeded(let options): - return handleRunLaunchAutomation(options: options, state: &state) - case .startDownload(let options, let folderName): - return handleStartDownload(options: options, folderName: folderName, state: &state) + case .runLaunchAutomationIfNeeded: + return handleRunLaunchAutomation(state: &state) + case .startDownload(let folderName): + return handleStartDownload(folderName: folderName, state: &state) case .startDownloadDone(let result): return handleStartDownloadDone(result: result, state: &state) case .toggleDownloadPause: @@ -166,10 +166,7 @@ extension DetailReducer { return .none } - private func handleRunLaunchAutomation( - options: DownloadRequestOptions, - state: inout State - ) -> Effect { + private func handleRunLaunchAutomation(state: inout State) -> Effect { guard !state.didRunLaunchAutomation, let automation = appLaunchAutomationClient.current(), automation.autoDownloadGID == state.gallery.id, @@ -179,15 +176,11 @@ extension DetailReducer { state.didRunLaunchAutomation = true guard state.downloadBadge == nil else { return .none } return .send( - .startDownload( - options, - automation.downloadFolderName ?? Defaults.FilePath.automationDownloadFolder - ) + .startDownload(automation.downloadFolderName ?? Defaults.FilePath.automationDownloadFolder) ) } private func handleStartDownload( - options: DownloadRequestOptions, folderName: String, state: inout State ) -> Effect { @@ -203,7 +196,6 @@ extension DetailReducer { host: AppUtil.galleryHost, folderName: folderName, versionMetadata: state.galleryVersionMetadata, - options: options, mode: .initial ) return .run { send in diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 724982918..dc982386a 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -149,8 +149,8 @@ struct DetailReducer { case loadLocalPreviewURLsDone(UUID, [Int: URL]) case openReading case openReadingDone(Result<(DownloadedGallery, DownloadManifest), AppError>) - case runLaunchAutomationIfNeeded(DownloadRequestOptions) - case startDownload(DownloadRequestOptions, String) + case runLaunchAutomationIfNeeded + case startDownload(String) case startDownloadDone(Result) case toggleDownloadPause case toggleDownloadPauseDone(Result) diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index dc6d420b2..6653b7835 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -165,7 +165,7 @@ private extension DetailView { showFullTitleAction: { store.send(.toggleShowFullTitle) }, downloadAction: { handleDownloadAction() }, downloadToFolderAction: { - store.send(.startDownload(setting.downloadRequestOptions, $0)) + store.send(.startDownload($0)) }, manageFoldersAction: { store.send(.setNavigation(.folderManager())) }, favorAction: { store.send(.favorGallery($0)) }, @@ -363,7 +363,7 @@ private extension DetailView { } private func runLaunchAutomationIfNeeded() { - store.send(.runLaunchAutomationIfNeeded(setting.downloadRequestOptions)) + store.send(.runLaunchAutomationIfNeeded) } @ViewBuilder private func offlineFallbackNotice(error: AppError) -> some View { diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index 261605967..89d5a586f 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -13,16 +13,11 @@ import Testing struct DetailReducerDownloadTests: DownloadFeatureTestCase { @MainActor @Test - func testDetailReducerStartDownloadEnqueuesGalleryWithSnapshotOptions() async throws { + func testDetailReducerStartDownloadEnqueuesGalleryPayload() async throws { let capturedPayload = UncheckedBox(nil) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let queuedDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .queued) - let options = DownloadRequestOptions( - threadLimit: 4, - allowCellular: false, - autoRetryFailedPages: false - ) let previewURL = try #require(URL(string: "https://example.com/1.jpg")) let store = makeDownloadTestStore( gallery: gallery, detail: detail, @@ -38,13 +33,12 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { ) store.exhaustivity = .off - await store.send(.startDownload(options, "Folder")) + await store.send(.startDownload("Folder")) await store.skipReceivedActions(strict: false) #expect(capturedPayload.value?.gallery.gid == gallery.gid) #expect(capturedPayload.value?.galleryDetail == detail) #expect(capturedPayload.value?.previewConfig == .large(rows: 2)) - #expect(capturedPayload.value?.options == options) #expect(capturedPayload.value?.mode == .initial) #expect(store.state.downloadBadge == queuedDownload.badge) } @@ -55,7 +49,6 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let queuedDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .queued) - let options = DownloadRequestOptions() let previewURL = try #require(URL(string: "https://example.com/1.jpg")) let store = makeDownloadTestStore( gallery: gallery, detail: detail, @@ -65,7 +58,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { ) store.exhaustivity = .off - await store.send(.startDownload(options, "Folder")) { + await store.send(.startDownload("Folder")) { $0.isPreparingDownload = true $0.didRunLaunchAutomation = true } @@ -113,7 +106,6 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { let capturedPayload = UncheckedBox(nil) let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - let options = DownloadRequestOptions() let previewURL = try #require(URL(string: "https://example.com/1.jpg")) let store = makeDownloadTestStore( @@ -131,14 +123,14 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { ) store.exhaustivity = .off - await store.send(.runLaunchAutomationIfNeeded(options)) + await store.send(.runLaunchAutomationIfNeeded) #expect(capturedPayload.value == nil) #expect(store.state.didRunLaunchAutomation == false) await store.send(.fetchDownloadBadgeDone(.none)) { $0.hasLoadedDownloadBadge = true } - await store.send(.runLaunchAutomationIfNeeded(options)) { + await store.send(.runLaunchAutomationIfNeeded) { $0.didRunLaunchAutomation = true } await store.receive(\.startDownload) diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index 7ef6a2d33..a74637aa7 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -19,7 +19,6 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { let completedDownload = sampleDownload( gid: gallery.gid, title: gallery.title, status: .completed ) - let options = DownloadRequestOptions() var initialState = DetailReducer.State() initialState.gallery = gallery initialState.galleryDetail = detail @@ -43,7 +42,7 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { $0.downloadBadge = completedDownload.badge $0.hasLoadedDownloadBadge = true } - await store.send(.runLaunchAutomationIfNeeded(options)) { + await store.send(.runLaunchAutomationIfNeeded) { $0.didRunLaunchAutomation = true } } @@ -54,7 +53,6 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let enqueueCount = UncheckedBox(0) - let options = DownloadRequestOptions() var initialState = DetailReducer.State() initialState.gid = gallery.gid @@ -92,7 +90,7 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { } ) - await store.send(.startDownload(options, "Folder")) + await store.send(.startDownload("Folder")) #expect(enqueueCount.value == 0) #expect(store.state.isPreparingDownload) diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index 309de75dd..6799e4c5b 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -32,7 +32,6 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { previewConfig: .normal(rows: 4), host: .ehentai, folderName: "Folder", - options: .init(threadLimit: 3), mode: .initial ) @@ -132,7 +131,6 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { previewConfig: .normal(rows: 4), host: .ehentai, folderName: "Folder", - options: .init(threadLimit: 3), mode: .initial )) diff --git a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift index ef659671e..8cd4c4cc4 100644 --- a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift @@ -215,7 +215,6 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { previewConfig: .normal(rows: 4), host: .ehentai, folderName: "Requested Elsewhere", - options: .init(), mode: .initial ) let result = await environment.manager.enqueue(payload: payload) diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift index db9bad8fb..54d97f86f 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift @@ -203,7 +203,6 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { previewConfig: .normal(rows: 4), host: .ehentai, folderName: "Folder", - options: options, mode: .initial ) let galleryFolderName = storage.makeFolderRelativePath( @@ -226,6 +225,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { let batchResult = try await manager.downloadPages( context: .init( payload: payload, + options: options, source: .normal([ 1: try #require(URL(string: "https://example.com/1.html")), 2: try #require(URL(string: "https://example.com/2.html")), diff --git a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift index 6abfdaba8..9879b5e7a 100644 --- a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -231,7 +231,7 @@ private extension DownloadInterruptedResumeTests { sizeCount: 1, sizeType: "MB", torrentCount: 0 ), previewURLs: [:], previewConfig: .normal(rows: 4), - host: .ehentai, folderName: "Folder", options: .init(), mode: mode + host: .ehentai, folderName: "Folder", mode: mode ) } } diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index a12297127..45edcc175 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -81,6 +81,7 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { let (emptyPageURL, goodPageURL) = try setupZeroBytePageFiles( rootURL: rootURL, gid: gid, storage: storage ) + await manager.reloadDownloadIndex() let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() @@ -374,7 +375,7 @@ private extension DownloadManagerRepairSeedTests { sizeCount: 1, sizeType: "MB", torrentCount: 0 ), previewURLs: [:], previewConfig: .normal(rows: 4), - host: .ehentai, folderName: "Folder", options: .init(), mode: .repair + host: .ehentai, folderName: "Folder", mode: .repair ) } diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index 6e732fa62..ddf1feaa4 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -109,52 +109,58 @@ struct DownloadProcessTests: DownloadFeatureTestCase { } @Test - func testFetchLatestPayloadUsesLiveDownloadOptionsProvider() async throws { + func testProcessDownloadUsesLiveOptionsWhenQueuedDownloadStarts() async throws { let sessionID = UUID().uuidString let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 403) - let pageIndex = 42 - let options = DownloadRequestOptions( + let latestOptions = DownloadRequestOptions( threadLimit: 3, allowCellular: false, autoRetryFailedPages: false ) + let optionsBox = UncheckedBox(DownloadRequestOptions(allowCellular: true)) + let detailAllowsCellular = UncheckedBox(nil) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let (_, manager) = makeStubbedDownloadManager( + let (storage, manager) = makeStubbedDownloadManager( rootURL: rootURL, sessionID: sessionID, - downloadOptionsProvider: { options } + downloadOptionsProvider: { optionsBox.value } ) defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } - let stubContent = StubHandlerContent( - detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), - mpvHTML: try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html"), - metadataResponse: try makeMetadataResponseData(gid: gid) - ) - installDownloadStubHandler( - sessionID: sessionID, - gid: gid, - pageIndex: pageIndex, - content: stubContent - ) + let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path.contains("/g/\(gid)/token") { + detailAllowsCellular.value = request.allowsCellularAccess + return ( + try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )), + detailHTML + ) + } + throw URLError(.notConnectedToInternet) + } - let download = sampleDownload( + try writeProcessManifestFolder( + storage: storage, gid: gid, - title: "Options Gallery", - status: .partial, - pageCount: 156, - completedPageCount: 155 - ) - let payload = try await manager.testingFetchLatestPayload( - for: download, - mode: .redownload, - pageSelection: [pageIndex] + title: "Queued Options", + pageCount: 2 ) + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([gid]) + + optionsBox.value = latestOptions + await manager.testingProcessDownload(gid: gid) - #expect(payload.options == options) + #expect(detailAllowsCellular.value == false) } } From 7645eb31a5238f7956df3dcc6095506496c9a2ca Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 10:34:27 +0800 Subject: [PATCH 198/614] Stabilize download verification tests --- .../Download/DownloadProcessCacheTests.swift | 99 ++++++++++++------- .../Download/DownloadRetryPagesTests.swift | 1 + 2 files changed, 65 insertions(+), 35 deletions(-) diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 6e906a8f6..3c65303f5 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -3,8 +3,6 @@ // EhPandaTests // -import Kingfisher -import SDWebImage import UIKit import Foundation import Testing @@ -21,26 +19,29 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } + let cachedKeysBox = UncheckedBox(Set()) + let libraryClient = try makeCacheLibraryClient( + cachedKeys: cachedKeysBox + ) let cacheTestManager = try makeCacheTestManager( - rootURL: rootURL, sessionID: sessionID, gid: gid, pageIndex: pageIndex + rootURL: rootURL, + sessionID: sessionID, + gid: gid, + pageIndex: pageIndex, + libraryClient: libraryClient ) let storage = cacheTestManager.storage let manager = cacheTestManager.manager defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } - let (cachedKeys, _) = try await prepareCacheTestAssets( + let cachedKeys = try await prepareCacheTestAssets( manager: manager, gid: gid, - pageIndex: pageIndex + pageIndex: pageIndex, + cachedKeysBox: cachedKeysBox ) - defer { - cachedKeys.forEach { - KingfisherManager.shared.cache.removeImage(forKey: $0) - SDImageCache.shared.removeImage(forKey: $0) {} - } - } - await waitUntilCacheReady(for: cachedKeys, timeout: .seconds(3)) + #expect(cachedKeys.allSatisfy(libraryClient.isCached)) let updatedPageCount = try await setupCacheTestDownload( .init( @@ -56,11 +57,14 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { let completedDownload = await manager.testingFetchDownload(gid: gid) #expect(completedDownload?.displayStatus == .completed) - try await waitUntilCacheCleared(cachedKeys: cachedKeys) + try await waitUntilCacheCleared( + cachedKeys: cachedKeys, + isCached: libraryClient.isCached + ) for cacheKey in cachedKeys { #expect( - LibraryClient.live.isCached(cacheKey) == false, + libraryClient.isCached(cacheKey) == false, "Expected cache key to be removed after successful download: \(cacheKey)" ) } @@ -88,7 +92,11 @@ private struct CacheTestDownloadSetup { private extension DownloadProcessCacheTests { func makeCacheTestManager( - rootURL: URL, sessionID: String, gid: String, pageIndex: Int + rootURL: URL, + sessionID: String, + gid: String, + pageIndex: Int, + libraryClient: LibraryClient ) throws -> CacheTestManagerResult { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] @@ -96,7 +104,8 @@ private extension DownloadProcessCacheTests { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, - urlSession: URLSession(configuration: configuration) + urlSession: URLSession(configuration: configuration), + libraryClient: libraryClient ) let content = StubHandlerContent( detailHTML: try makeUniqueDetailHTML(gid: gid), @@ -193,8 +202,9 @@ private extension DownloadProcessCacheTests { @MainActor func prepareCacheTestAssets( manager: DownloadManager, gid: String, - pageIndex: Int - ) async throws -> (Set, URL) { + pageIndex: Int, + cachedKeysBox: UncheckedBox> + ) async throws -> Set { let currentPageImageURL = try #require( Self.currentPageImageURL(gid: gid, pageIndex: pageIndex) ) @@ -208,18 +218,10 @@ private extension DownloadProcessCacheTests { let coverURL = try #require( latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL ) - let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { ctx in - UIColor.systemTeal.setFill() - ctx.fill(.init(x: 0, y: 0, width: 1, height: 1)) - } - let cachedImageData = try #require(cachedImage.jpegData(compressionQuality: 1)) let cachedURLs = [currentPageImageURL, coverURL] let cachedKeys = Set(cachedURLs.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) - for cacheKey in cachedKeys { - try await KingfisherManager.shared.cache.storeToDisk(cachedImageData, forKey: cacheKey) - await storeSDWebImageData(cachedImageData, forKey: cacheKey) - } - return (cachedKeys, coverURL) + cachedKeysBox.value = cachedKeys + return cachedKeys } func setupCacheTestDownload(_ setup: CacheTestDownloadSetup) async throws -> Int { @@ -269,20 +271,47 @@ private extension DownloadProcessCacheTests { try storage.writeManifest(staleManifest, folderURL: completedFolderURL) } - func waitUntilCacheCleared(cachedKeys: Set) async throws { + func waitUntilCacheCleared( + cachedKeys: Set, + isCached: @Sendable (String) -> Bool + ) async throws { let clock = ContinuousClock() let deadline = clock.now.advanced(by: .seconds(1)) - while cachedKeys.contains(where: LibraryClient.live.isCached), + while cachedKeys.contains(where: isCached), clock.now < deadline { try? await Task.sleep(for: .milliseconds(10)) } } - func storeSDWebImageData(_ data: Data, forKey key: String) async { - await withCheckedContinuation { continuation in - SDImageCache.shared.storeImageData(data, forKey: key) { - continuation.resume() - } + @MainActor + func makeCacheLibraryClient( + cachedKeys: UncheckedBox> + ) throws -> LibraryClient { + let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemTeal.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) } + let cachedImageData = try #require(cachedImage.jpegData(compressionQuality: 1)) + return .init( + initializeLogger: {}, + initializeWebImage: {}, + removeAllCachedImages: { + cachedKeys.value = [] + }, + cachedImage: { _ in nil }, + cachedImageData: { key in + cachedKeys.value.contains(key) ? cachedImageData : nil + }, + removeCachedImage: { key in + var keys = cachedKeys.value + keys.remove(key) + cachedKeys.value = keys + }, + isCached: { key in + cachedKeys.value.contains(key) + }, + analyzeImageColors: { _ in nil }, + calculateWebImageDiskCacheSize: { 0 } + ) } } diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index c87fc0feb..8e4e38d72 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -24,6 +24,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { title: "Retry Pages", pageHashes: ["sha256:done", ""] ) + await manager.reloadDownloadIndex() await manager.testingSetFailedPageErrors( [ .init( From cd1c0561b84a98dc063ecc80a94f0d019f5edb91 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 11:10:08 +0800 Subject: [PATCH 199/614] Sync disk-seeded download tests --- .../Tests/Download/DownloadPauseAndReconcileTests.swift | 3 +++ EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift | 1 + .../Tests/Download/DownloadRetryMinimalSourceTests.swift | 2 ++ EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift | 1 + .../Tests/Download/DownloadRetryUpdateFallbackTests.swift | 2 ++ EhPandaTests/Tests/Download/DownloadSchedulingTests.swift | 1 + .../Tests/Download/DownloadVersionSignatureTests.swift | 1 + 7 files changed, 11 insertions(+) diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index afeda4038..cbb6567b8 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -41,6 +41,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { pageHashes: Array(repeating: "sha256:done", count: 7) + Array(repeating: "", count: 19) ) + await manager.reloadDownloadIndex() let activeTask = Task { [manager] in do { @@ -107,6 +108,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { to: folderURL.appendingPathComponent("\(gid)_token_2.jpg"), options: .atomic ) + await manager.reloadDownloadIndex() let activeTask = Task { [manager] in do { @@ -226,6 +228,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { pageHashes: ["sha256:done", ""] ) try setupCancellationFilterTestFolder(storage: storage, gid: gid) + await manager.reloadDownloadIndex() await manager.testingSetFailedPageErrors( [ .init( diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 3c65303f5..793b6f1b1 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -51,6 +51,7 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { pageIndex: pageIndex ) ) + await manager.reloadDownloadIndex() await manager.testingProcessDownload(gid: gid) diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 7c09e21d9..92d86552e 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -36,6 +36,7 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { manifest: manifest, missingPageIndex: pageIndex ) + await manager.reloadDownloadIndex() await manager.testingSetDownloadError( .init(code: .fileOperationFailed, message: "Page \(pageIndex) is missing."), gid: gid @@ -98,6 +99,7 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { manifest: manifest, missingPageIndices: [pageIndex, remainingMissingPageIndex] ) + await manager.reloadDownloadIndex() await manager.testingSetDownloadError( .init(code: .networkingFailed, message: "Original retry failure."), gid: gid diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index 8e4e38d72..6c06ca991 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -72,6 +72,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { title: "Queued", pageHashes: ["sha256:done", ""] ) + await manager.reloadDownloadIndex() await manager.testingSetQueuedGalleryIDs([gid]) let result = await manager.togglePause(gid: gid) diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 97789d05d..d95e2b30a 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -35,6 +35,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { gid: gid, pageCount: oldCount ) + await queueingManager.reloadDownloadIndex() await queueingManager.testingSetUpdatedGalleryIDs([gid]) let queuedCandidate = await queueingManager.testingFetchDownload(gid: gid) #expect(queuedCandidate?.hasUpdate == true) @@ -79,6 +80,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { storage: storage, context: DownloadPageContext(gid: gid, pageIndex: pageIndex, pageCount: pageCount) ) + await immediateManager.reloadDownloadIndex() await immediateManager.testingSetUpdatedGalleryIDs([gid]) let immediateBlockerTask = Task { diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index 052cd4f24..787615beb 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -54,6 +54,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { ), folderURL: folderURL ) + await manager.reloadDownloadIndex() await manager.testingSetQueuedGalleryIDs([gid]) let gate = ScheduleFetchGate() diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 6b04ef5b3..f0fd01b23 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -87,6 +87,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { ), folderURL: folderURL ) + await manager.reloadDownloadIndex() let updateResult = await manager.updateRemoteVersion( gid: gid, From 07653a1f844e28d18749375e398354c0518aa1a8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 11:33:10 +0800 Subject: [PATCH 200/614] Stabilize scheduling race test --- EhPanda/App/Tools/Clients/DownloadClient+Manager.swift | 2 +- .../App/Tools/Clients/DownloadClient+Persistence.swift | 10 ---------- .../App/Tools/Clients/DownloadClient+Scheduling.swift | 5 +++++ EhPanda/App/Tools/Clients/DownloadClient+Testing.swift | 4 ++-- .../Tests/Download/DownloadSchedulingTests.swift | 4 ++-- 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index f8c640617..b73737697 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -155,8 +155,8 @@ actor DownloadManager { var activeTask: Task? var schedulingBlockedGalleryIDs = Set() #if DEBUG - var testingFetchDownloadsFromStoreHook: (@Sendable () async -> Void)? var testingPersistFailureHook: (@Sendable () async -> Void)? + var testingScheduleBeforeActiveCheckHook: (@Sendable () async -> Void)? var testingScheduledProcessHook: (@Sendable (String) async -> Void)? var testingScheduledGalleryIDHistory = [String]() #endif diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index a08da80ad..a532a5a76 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -140,22 +140,12 @@ extension DownloadManager { } func fetchDownloadsFromStore() async -> [DownloadedGallery] { -#if DEBUG - if let testingFetchDownloadsFromStoreHook { - await testingFetchDownloadsFromStoreHook() - } -#endif return await reloadDownloadIndex() } func fetchDownloadsFromStore( gids: [String] ) async -> [DownloadedGallery] { -#if DEBUG - if let testingFetchDownloadsFromStoreHook { - await testingFetchDownloadsFromStoreHook() - } -#endif let gidSet = Set(gids) return await reloadDownloadIndex() .filter { gidSet.contains($0.gid) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 2e2832681..4fc239022 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -33,6 +33,11 @@ extension DownloadManager { let downloads = queuedGIDs.isEmpty ? await indexedDownloads() : await indexedDownloads(gids: queuedGIDs) +#if DEBUG + if let testingScheduleBeforeActiveCheckHook { + await testingScheduleBeforeActiveCheckHook() + } +#endif guard activeTask == nil else { await reconcileActiveDownloadState() return diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 19188dce1..a7ecddff7 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -23,10 +23,10 @@ extension DownloadManager { await scheduleNextIfNeeded() } - func testingSetFetchDownloadsFromStoreHook( + func testingSetScheduleBeforeActiveCheckHook( _ hook: (@Sendable () async -> Void)? ) { - testingFetchDownloadsFromStoreHook = hook + testingScheduleBeforeActiveCheckHook = hook } func testingSetPersistFailureHook( diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index 787615beb..566b52341 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -58,7 +58,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { await manager.testingSetQueuedGalleryIDs([gid]) let gate = ScheduleFetchGate() - await manager.testingSetFetchDownloadsFromStoreHook { + await manager.testingSetScheduleBeforeActiveCheckHook { await gate.waitAtGate() } @@ -70,7 +70,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { await gate.waitForBothArrivals() await gate.releaseAll() _ = await (firstSchedule, secondSchedule) - await manager.testingSetFetchDownloadsFromStoreHook(nil) + await manager.testingSetScheduleBeforeActiveCheckHook(nil) let scheduledGalleryIDs = await manager .testingScheduledGalleryIDs() From 0eb8f01c5e24fb3cd9dbe5a059f63f51930ba640 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 11:36:46 +0800 Subject: [PATCH 201/614] Fold download execution contexts --- .../DownloadClient+ExecutionPerform.swift | 30 +++++++++---------- .../Clients/DownloadClient+Manager.swift | 2 ++ .../DownloadClient+PageDownloadHelpers.swift | 14 ++++----- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index f6e16c954..6d8ca042c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -35,16 +35,15 @@ extension DownloadManager { ) let executionContext = DownloadExecutionContext( + payload: payload, + options: options, existingDownload: existingDownload ) do { let batchAndCover = try await executePageDownloads( - payload: payload, - options: options, + context: executionContext, workingSeed: workingSeed, - pendingIndices: pendingIndices, - workingFolderURL: workingFolderURL, - executionContext: executionContext + pendingIndices: pendingIndices ) return batchAndCover } catch is CancellationError { @@ -55,32 +54,31 @@ extension DownloadManager { } private func executePageDownloads( - payload: DownloadRequestPayload, - options: DownloadRequestOptions, + context: DownloadExecutionContext, workingSeed: WorkingSeed, - pendingIndices: [Int], - workingFolderURL: URL, - executionContext: DownloadExecutionContext + pendingIndices: [Int] ) async throws -> PerformDownloadResult { - let existingDownload = executionContext.existingDownload + let payload = context.payload + let options = context.options + let folderURL = workingSeed.folderURL let coverRelativePath = try await downloadCoverIfNeeded( payload: payload, options: options, - folderURL: workingFolderURL, + folderURL: folderURL, existingCoverRelativePath: workingSeed.coverRelativePath ) let source = try await resolveSourceIfNeeded( payload: payload, options: options, pendingIndices: pendingIndices, - folderURL: workingFolderURL, + folderURL: folderURL, existingPages: workingSeed.existingPages ) let downloadContext = PageDownloadContext( payload: payload, options: options, source: source, - folderURL: workingFolderURL + folderURL: folderURL ) let batchResult = try await downloadPages( context: downloadContext, @@ -91,12 +89,12 @@ extension DownloadManager { let finalizeCtx = FinalizeContext( coverRelativePath: coverRelativePath, batchResult: batchResult, - existingDownload: existingDownload + existingDownload: context.existingDownload ) try await finalizeBatchResult( context: finalizeCtx, payload: payload, - folderURL: workingFolderURL + folderURL: folderURL ) return PerformDownloadResult( coverRelativePath: coverRelativePath, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index b73737697..0eb5be5d4 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -121,6 +121,8 @@ actor DownloadManager { } struct DownloadExecutionContext: Sendable { + let payload: DownloadRequestPayload + let options: DownloadRequestOptions let existingDownload: DownloadedGallery } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift index 8d3a190c7..e65b88f9d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -44,7 +44,6 @@ extension DownloadManager { preferredRelativePath: String? ) async throws -> PageResult { let payload = context.payload - let folderURL = context.folderURL guard let source = context.source else { throw AppError.notFound @@ -66,9 +65,7 @@ extension DownloadManager { return try await downloadAndSavePage( index: index, resolvedImageSource: resolved, - payload: payload, - options: context.options, - folderURL: folderURL, + context: context, preferredRelativePath: preferredRelativePath ) } @@ -103,16 +100,15 @@ extension DownloadManager { private func downloadAndSavePage( index: Int, resolvedImageSource: ResolvedImageSource, - payload: DownloadRequestPayload, - options: DownloadRequestOptions, - folderURL: URL, + context: PageDownloadContext, preferredRelativePath: String? ) async throws -> PageResult { + let payload = context.payload let targetURL = resolvedImageSource.imageURL let (downloadedFileURL, response) = try await downloadResponse( url: targetURL, - allowsCellular: options.allowCellular, + allowsCellular: context.options.allowCellular, retriesRequest: false ) let relativePath: String @@ -134,7 +130,7 @@ extension DownloadManager { fileExtension: ext ) } - let fileURL = folderURL + let fileURL = context.folderURL .appendingPathComponent(relativePath) try moveDownloadedFile( from: downloadedFileURL, From bc693dbc39251e70dd60f59c1767dc2f9f5ca400 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 13:06:55 +0800 Subject: [PATCH 202/614] Latch background entry for foreground reconcile The "return to foreground" DES-3 scan trigger checked whether the immediately-previous scene phase was .background. On real iOS a foreground return is .background -> .inactive -> .active, so the previous phase is always .inactive and the reconcile never fired; after a long background the index only refreshed on pull-to-refresh. Latch the background entry with a State flag instead: set it on the .background phase and consume it on the next .active, so the reconcile fires once per real background cycle and never on a transient .inactive -> .active blip (Control Center, notification pulldown). Update the foreground-return test to walk the full .active -> .inactive -> .background -> .inactive -> .active sequence, which previously skipped the .inactive step iOS always interposes. --- EhPanda/DataFlow/AppReducer.swift | 14 ++++++++++++-- .../Download/DownloadAutomationTests.swift | 19 ++++++++++++++----- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index 4d67b8124..9549092df 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -20,6 +20,7 @@ struct AppReducer { var downloadsState = DownloadsReducer.State() var settingState = SettingReducer.State() var scenePhase = ScenePhase.active + var hasEnteredBackground = false var didRunLaunchAutomation = false var isAwaitingIgneousForLaunchAutomation = false } @@ -66,7 +67,6 @@ struct AppReducer { return .none case .onScenePhaseChange(let scenePhase): - let previousScenePhase = state.scenePhase state.scenePhase = scenePhase guard state.settingState.hasLoadedInitialSetting else { return .none } @@ -77,7 +77,13 @@ struct AppReducer { var effects: [Effect] = [ .send(.appLock(.onBecomeActive(threshold, blurRadius))) ] - if previousScenePhase == .background { + // iOS interposes .inactive on a foreground return + // (.background -> .inactive -> .active), so the previous + // phase is never .background here. Latch the background + // entry instead: reconcile once per cycle, never on a + // transient .inactive blip (Control Center, notifications). + if state.hasEnteredBackground { + state.hasEnteredBackground = false effects.append( .run { _ in await downloadClient.reconcileDownloads() @@ -90,6 +96,10 @@ struct AppReducer { let blurRadius = state.settingState.setting.backgroundBlurRadius return .send(.appLock(.onBecomeInactive(blurRadius))) + case .background: + state.hasEnteredBackground = true + return .none + default: return .none } diff --git a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift index 9ce809ce9..d40e94467 100644 --- a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift @@ -16,7 +16,6 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { func testAppForegroundReturnReconcilesDownloads() async { let reconcileCount = UncheckedBox(0) var initialState = AppReducer.State() - initialState.scenePhase = .background initialState.settingState.hasLoadedInitialSetting = true let store = TestStore( @@ -45,12 +44,22 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { ) store.exhaustivity = .off + // iOS always interposes .inactive on both edges of a background cycle: + // .active -> .inactive -> .background -> .inactive -> .active. The + // reconcile must survive the trailing .inactive and fire exactly once. + await store.send(.onScenePhaseChange(.inactive)) { + $0.scenePhase = .inactive + } + await store.send(.onScenePhaseChange(.background)) { + $0.scenePhase = .background + $0.hasEnteredBackground = true + } + await store.send(.onScenePhaseChange(.inactive)) { + $0.scenePhase = .inactive + } await store.send(.onScenePhaseChange(.active)) { $0.scenePhase = .active - } - await store.receive(\.appLock, .onBecomeActive(-1, 10)) - await store.receive(\.appLock, .unlockApp) { - $0.appLockState.blurRadius = 0.00001 + $0.hasEnteredBackground = false } await store.finish() From c1630aae6a5e01334f0a02d44dbbbb25230daf1d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 13:09:14 +0800 Subject: [PATCH 203/614] Repair delete failure paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partially-failed delete left the in-memory index diverged from disk with no immediate repair, and pre-cleared session/queue state up front that was never restored on failure — silently dequeuing a queued download on a rare delete error. delete(gid:): re-sync the index on the failure paths via reloadDownloadRecord (scan-on-surprise, matching moveDownload), and defer the session/queue clear until after removeGalleryFolders succeeds so a failed removal no longer dequeues the gallery. deleteFolder(name:): defer the contained-gids session/queue clear until after removeFolder succeeds, for the same reason; the failure path still re-syncs the index via reloadDownloadRecords to cover a partial removal. --- .../Tools/Clients/DownloadClient+Folders.swift | 13 ++++++++----- .../Clients/DownloadClient+PublicAPI.swift | 18 ++++++++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift index db7c6310c..e68a44632 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift @@ -112,11 +112,6 @@ extension DownloadManager { self.activeGalleryID = nil await taskToCancel?.value } - for gid in containedGIDs { - clearDownloadSessionState(gid: gid, includeUpdateFlag: true) - await queueStore.remove(gid) - downloadIndex[gid] = nil - } do { try storage.removeFolder(at: folderURL) } catch let error as AppError { @@ -127,6 +122,14 @@ extension DownloadManager { await reloadDownloadRecords(containedRecords) return .failure(.fileOperationFailed(error.localizedDescription)) } + // Clear session and queue state only once the folder is gone; a failed + // removal above leaves the galleries intact and must not silently + // dequeue a download that lived inside the folder. + for gid in containedGIDs { + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) + await queueStore.remove(gid) + downloadIndex[gid] = nil + } userFolders.removeAll { $0 == name } await notifyObservers() await scheduleNextIfNeeded() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 755a7f0e3..021c6fd84 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -183,23 +183,29 @@ extension DownloadManager { taskToCancel = nil } await taskToCancel?.value - clearDownloadSessionState(gid: gid, includeUpdateFlag: true) - await queueStore.remove(gid) guard let download = await fetchDownload(gid: gid) else { + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) + await queueStore.remove(gid) return .failure(.notFound) } do { try removeGalleryFolders(gid: download.gid, token: download.token) - downloadIndex[gid] = nil - await notifyObservers() - await scheduleNextIfNeeded() - return .success(()) } catch let error as AppError { + await reloadDownloadRecord(gid: download.gid, token: download.token) return .failure(error) } catch { Logger.error(error) + await reloadDownloadRecord(gid: download.gid, token: download.token) return .failure(.fileOperationFailed(error.localizedDescription)) } + // Clear session and queue state only once the folders are gone; a failed + // removal above leaves the gallery intact and must not silently dequeue it. + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) + await queueStore.remove(gid) + downloadIndex[gid] = nil + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) } func loadManifest( From 66e9ca92ae1d75a36d47a265e3ef9dcf70b2b962 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 13:10:19 +0800 Subject: [PATCH 204/614] Remove orphaned gid-filtered store fetch [Bug-16] rewired the only two callers of fetchDownloadsFromStore(gids:) (scheduleNextIfNeeded, badges) to the warm-index indexedDownloads(gids:), leaving the overload with zero callers. Delete it; the no-arg fetchDownloadsFromStore() remains the scan path used by syncDownloadsState. --- .../App/Tools/Clients/DownloadClient+Persistence.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index a532a5a76..c1a4d7334 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -143,14 +143,6 @@ extension DownloadManager { return await reloadDownloadIndex() } - func fetchDownloadsFromStore( - gids: [String] - ) async -> [DownloadedGallery] { - let gidSet = Set(gids) - return await reloadDownloadIndex() - .filter { gidSet.contains($0.gid) } - } - @discardableResult func reloadDownloadRecord(gid: String, token: String) async -> DownloadedGallery? { let records = storage.galleryFolderRecords(gid: gid, token: token) From c85d7923e1d19fd268fc79826b8438ef8922f94c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 13:27:36 +0800 Subject: [PATCH 205/614] Make only the full scan mark the index loaded reloadDownloadRecord and updateDownloadIndex set the global hasLoadedIndex flag, so a single-gallery patch marked the entire index "loaded." That was only safe because launch ordering guarantees the full reconcile runs before any targeted reload; if a targeted patch ever ran first, fetchDownloads() would return a 1-entry "complete" list. Encode the invariant in code instead: only the full-scan path (reloadDownloadIndex) marks the index loaded; targeted paths just patch entries and inherit the flag. Tests that drove enqueue / progress flush / working-seed prep directly relied on a targeted patch being the first load. Warm the index with an explicit reloadDownloadIndex() first, matching how launch establishes the warm-index baseline (the same adaptation [Bug-21]/[Bug-22] made for disk-seeded tests). --- EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift | 2 -- .../Tests/Download/DownloadEnqueueManifestTests.swift | 4 ++++ .../Tests/Download/DownloadInterruptedResumeTests.swift | 2 ++ .../Tests/Download/DownloadManagerStorageTests.swift | 1 + EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift | 5 +++++ 5 files changed, 12 insertions(+), 2 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index c1a4d7334..e7c7c2154 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -146,7 +146,6 @@ extension DownloadManager { @discardableResult func reloadDownloadRecord(gid: String, token: String) async -> DownloadedGallery? { let records = storage.galleryFolderRecords(gid: gid, token: token) - hasLoadedIndex = true guard let record = deduplicatedDownloadIndex(from: records).values.first else { downloadIndex[gid] = nil return nil @@ -225,7 +224,6 @@ extension DownloadManager { } func updateDownloadIndex(folderURL: URL, manifest: DownloadManifest) { - hasLoadedIndex = true downloadIndex[manifest.gid] = storage.galleryFolderRecord( folderURL: folderURL, manifest: manifest, diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index 6799e4c5b..631910ad7 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -35,6 +35,8 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { mode: .initial ) + // Warm the index the way launch does; enqueue then patches it in place. + await manager.reloadDownloadIndex() let result = await manager.enqueue(payload: payload) guard case .success = result else { @@ -124,6 +126,8 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { ) try storage.writeManifest(existingManifest, folderURL: folderURL) + // Warm the index the way launch does; enqueue then patches it in place. + await manager.reloadDownloadIndex() let result = await manager.enqueue(payload: .init( gallery: gallery, galleryDetail: detail, diff --git a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift index 9879b5e7a..18060612f 100644 --- a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -52,6 +52,7 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { title: "Redownload", pageHashes: ["sha256:done", ""] ) + await manager.reloadDownloadIndex() let stalePageURL = folderURL.appendingPathComponent("\(gid)_token_1.jpg") let download = sampleDownload( gid: gid, title: "Redownload", @@ -90,6 +91,7 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { title: "Pause Survives", pageHashes: ["sha256:done", ""] ) + await manager.reloadDownloadIndex() let download = sampleDownload( gid: gid, title: "Pause Survives", status: .queued, pageCount: 2, completedPageCount: 1, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index e9785dbe1..0d72b34ad 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -767,6 +767,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { to: folderURL.appendingPathComponent(pageRelativePath), options: .atomic ) + await manager.reloadDownloadIndex() var pendingResolvedPages = [ DownloadManager.PageResult( index: 1, diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 4bda1178a..706dc852b 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -75,6 +75,11 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) + // Warm the (empty) index before seeding so the gallery surfaces only + // through flush updates, mirroring an active download whose folder is + // patched into the index rather than re-scanned per progress tick. + await manager.reloadDownloadIndex() + let folderRelativePath = "Folder/\(gid) - Progress Flush" let folderURL = storage.folderURL(relativePath: folderRelativePath) try FileManager.default.createDirectory( From 78b54c9ae2034f9f23343b717a8e28f823662192 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 13:29:51 +0800 Subject: [PATCH 206/614] Extract shared identity-prefix builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gid/token identity prefix ("__") was hand-built in five places: existingPageRelativePaths, pageFilePrefix, coverFilePrefix, makePageRelativePath, and makeCoverRelativePath. Extract a single private identityPrefix(gid:token:) and build all of them on top of it. Pure refactor — the produced names are byte-for-byte identical. --- .../Tools/Utilities/DownloadFileStorage.swift | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 8f4971a59..9c2148dd4 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -98,13 +98,12 @@ struct DownloadFileStorage: Sendable { guard !pageIndices.isEmpty else { return [:] } let fileURLs = existingAssetFileURLs(folderURL: folderURL) - let identityPrefix = - "\(normalizedIdentityComponent(manifest.gid))_\(normalizedIdentityComponent(manifest.token))_" + let prefix = identityPrefix(gid: manifest.gid, token: manifest.token) return fileURLs.reduce(into: [:]) { result, fileURL in let fileName = fileURL.lastPathComponent - guard fileName.hasPrefix(identityPrefix) else { return } - let suffix = fileName.dropFirst(identityPrefix.count) + guard fileName.hasPrefix(prefix) else { return } + let suffix = fileName.dropFirst(prefix.count) guard let dotIndex = suffix.firstIndex(of: ".") else { return } let indexText = String(suffix[.. String { + "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_" + } + func makePageRelativePath(gid: String, token: String, index: Int, fileExtension: String) -> String { - [ - normalizedIdentityComponent(gid), - normalizedIdentityComponent(token), - String(index) - ].joined(separator: "_") + ".\(fileExtension.lowercased())" + "\(identityPrefix(gid: gid, token: token))\(index).\(fileExtension.lowercased())" } func makeCoverRelativePath(gid: String, token: String, fileExtension: String) -> String { - "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_cover.\(fileExtension.lowercased())" + "\(identityPrefix(gid: gid, token: token))cover.\(fileExtension.lowercased())" } func existingPageFileURL(folderURL: URL, gid: String, token: String, index: Int) -> URL? { @@ -321,11 +320,11 @@ struct DownloadFileStorage: Sendable { } private func pageFilePrefix(gid: String, token: String, index: Int) -> String { - "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_\(index)." + "\(identityPrefix(gid: gid, token: token))\(index)." } private func coverFilePrefix(gid: String, token: String) -> String { - "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_cover." + "\(identityPrefix(gid: gid, token: token))cover." } func writeManifest(_ manifest: DownloadManifest, folderURL: URL) throws { From ded18aa78b3e65c06b7f332f55c1dd38ae6f77e5 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 14:25:55 +0800 Subject: [PATCH 207/614] Adopt @DependencyClient on DownloadClient Apply @DependencyClient to the DownloadClient facade and convert every Result<_, AppError>/non-optional-value endpoint to async throws, so the macro's auto-unimplemented testValue fails loudly on un-wired paths instead of returning silent .success/[] defaults. Optional and Void endpoints stay non-throwing (the macro still reports loudly). Reducer call sites bridge throws back into the existing Result-typed *Done actions via .run { } catch:, preserving AppError where handlers read it (loadInspection, folder ops). Manager methods keep returning Result internally; the facade adapts via .get(). Delete the dead resumeQueue + badges endpoints and their per-test stubs (CLN-4); replace the ~120-line init/.noop/.unimplemented ceremony with the macro (CLN-5). Test doubles move from the now all-or-nothing generated init to a .noop base with per-endpoint overrides. testValue = DownloadClient(); previewValue = .noop. Build + 235 tests green. --- .../Clients/DownloadClient+PublicAPI.swift | 6 - .../App/Tools/Clients/DownloadClient.swift | 209 +++++------------- .../View/Detail/DetailReducer+Download.swift | 34 +-- EhPanda/View/Detail/DetailReducer+Fetch.swift | 13 +- .../Detail/Previews/PreviewsReducer.swift | 12 +- .../Downloads/DownloadInspectorReducer.swift | 19 +- EhPanda/View/Downloads/DownloadsReducer.swift | 28 ++- .../View/Downloads/FolderManagerReducer.swift | 17 +- .../Reading/ReadingReducer+Database.swift | 8 +- .../Download/DetailReducerDownloadTests.swift | 37 ++-- .../Download/DetailReducerMetadataTests.swift | 84 ++++--- .../DetailReducerMetadataUpdateTests.swift | 73 +++--- .../Download/DetailReducerObserveTests.swift | 86 ++++--- .../DetailReducerPauseAndGuardTests.swift | 64 +++--- .../Download/DownloadAutomationTests.swift | 29 ++- .../Download/DownloadInspectorLoadTests.swift | 67 +++--- .../DownloadInspectorRetryTests.swift | 39 ++-- .../Download/DownloadInspectorSkipTests.swift | 39 ++-- .../DownloadManagerStorageTests.swift | 10 +- .../Download/DownloadObserverBatchTests.swift | 37 ++-- .../DownloadObserverReadingTests.swift | 66 +++--- .../DownloadObserverRefreshTests.swift | 36 ++- .../DownloadsReducerActionTests.swift | 184 +++++++-------- .../DownloadsReducerRefreshTests.swift | 125 +++++------ .../Download/FolderManagerReducerTests.swift | 58 +++-- .../PreviewsReducerDownloadTests.swift | 60 +++-- .../ReadingReducerDownloadTests.swift | 59 +++-- .../Download/ReadingReducerLocalTests.swift | 35 ++- 28 files changed, 668 insertions(+), 866 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 021c6fd84..c70e7306d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -38,12 +38,6 @@ extension DownloadManager { await scheduleNextIfNeeded() } - func badges(for gids: [String]) async -> [String: DownloadBadge] { - guard !gids.isEmpty else { return [:] } - let downloads = await indexedDownloads(gids: gids) - return Dictionary(uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) }) - } - func updateRemoteVersion( gid: String, metadata: DownloadVersionMetadata diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index a6ee94bc8..b2c230d48 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -6,95 +6,30 @@ import Foundation import ComposableArchitecture +@DependencyClient struct DownloadClient: Sendable { - let observeDownloads: @Sendable () -> AsyncStream<[DownloadedGallery]> - let fetchDownloads: @Sendable () async -> [DownloadedGallery] - let fetchDownload: @Sendable (String) async -> DownloadedGallery? - let reconcileDownloads: @Sendable () async -> Void - let refreshDownloads: @Sendable () async -> Void - let validateImageData: @Sendable (String) async -> DownloadValidationState? - let resumeQueue: @Sendable () async -> Void - let badges: @Sendable ([String]) async -> [String: DownloadBadge] - let fetchVersionMetadata: @Sendable (String, String) async -> Result - let updateRemoteVersion: @Sendable (String, DownloadVersionMetadata) async -> DownloadedGallery? - let enqueue: @Sendable (DownloadRequestPayload) async -> Result - let togglePause: @Sendable (String) async -> Result - let retry: @Sendable (String, DownloadStartMode) async -> Result - let retryPages: @Sendable (String, [Int]) async -> Result - let delete: @Sendable (String) async -> Result - let loadManifest: @Sendable (String) async -> Result<(DownloadedGallery, DownloadManifest), AppError> - let loadLocalPageURLs: @Sendable (String) async -> Result<[Int: URL], AppError> - let captureCachedPage: @Sendable (String, Int, URL?) async -> Void - let loadInspection: @Sendable (String) async -> Result - let fetchFolders: @Sendable () async -> [String] - let createFolder: @Sendable (String) async -> Result - let renameFolder: @Sendable (String, String) async -> Result - let deleteFolder: @Sendable (String) async -> Result - let moveDownload: @Sendable (String, String) async -> Result - - init( - observeDownloads: @escaping @Sendable () -> AsyncStream<[DownloadedGallery]>, - fetchDownloads: @escaping @Sendable () async -> [DownloadedGallery], - fetchDownload: @escaping @Sendable (String) async -> DownloadedGallery?, - reconcileDownloads: @escaping @Sendable () async -> Void = {}, - refreshDownloads: @escaping @Sendable () async -> Void, - validateImageData: @escaping @Sendable (String) async -> DownloadValidationState? = { _ in nil }, - resumeQueue: @escaping @Sendable () async -> Void, - badges: @escaping @Sendable ([String]) async -> [String: DownloadBadge], - fetchVersionMetadata: @escaping @Sendable (String, String) async -> Result - = { _, _ in .failure(.notFound) }, - updateRemoteVersion: @escaping @Sendable (String, DownloadVersionMetadata) async -> DownloadedGallery? = - { _, _ in nil }, - enqueue: @escaping @Sendable (DownloadRequestPayload) async -> Result, - togglePause: @escaping @Sendable (String) async -> Result, - retry: @escaping @Sendable (String, DownloadStartMode) async -> Result, - retryPages: @escaping @Sendable (String, [Int]) async -> Result = { _, _ in .success(()) }, - delete: @escaping @Sendable (String) async -> Result, - loadManifest: @escaping @Sendable (String) async -> Result< - (DownloadedGallery, DownloadManifest), AppError - >, - loadLocalPageURLs: @escaping @Sendable (String) async -> Result< - [Int: URL], AppError - > = { _ in .failure(.notFound) }, - captureCachedPage: @escaping @Sendable (String, Int, URL?) async -> Void = { _, _, _ in }, - loadInspection: @escaping @Sendable (String) async -> Result< - DownloadInspection, AppError - > = { _ in .failure(.notFound) }, - fetchFolders: @escaping @Sendable () async -> [String] = { [] }, - createFolder: @escaping @Sendable (String) async -> Result - = { _ in .success(()) }, - renameFolder: @escaping @Sendable (String, String) async -> Result - = { _, _ in .success(()) }, - deleteFolder: @escaping @Sendable (String) async -> Result - = { _ in .success(()) }, - moveDownload: @escaping @Sendable (String, String) async -> Result - = { _, _ in .success(()) } - ) { - self.observeDownloads = observeDownloads - self.fetchDownloads = fetchDownloads - self.fetchDownload = fetchDownload - self.reconcileDownloads = reconcileDownloads - self.refreshDownloads = refreshDownloads - self.validateImageData = validateImageData - self.resumeQueue = resumeQueue - self.badges = badges - self.fetchVersionMetadata = fetchVersionMetadata - self.updateRemoteVersion = updateRemoteVersion - self.enqueue = enqueue - self.togglePause = togglePause - self.retry = retry - self.retryPages = retryPages - self.delete = delete - self.loadManifest = loadManifest - self.loadLocalPageURLs = loadLocalPageURLs - self.captureCachedPage = captureCachedPage - self.loadInspection = loadInspection - self.fetchFolders = fetchFolders - self.createFolder = createFolder - self.renameFolder = renameFolder - self.deleteFolder = deleteFolder - self.moveDownload = moveDownload - } + var observeDownloads: @Sendable () -> AsyncStream<[DownloadedGallery]> = { AsyncStream { $0.finish() } } + var fetchDownloads: @Sendable () async throws -> [DownloadedGallery] + var fetchDownload: @Sendable (String) async -> DownloadedGallery? + var reconcileDownloads: @Sendable () async -> Void + var refreshDownloads: @Sendable () async -> Void + var validateImageData: @Sendable (String) async -> DownloadValidationState? + var fetchVersionMetadata: @Sendable (String, String) async throws -> DownloadVersionMetadata + var updateRemoteVersion: @Sendable (String, DownloadVersionMetadata) async -> DownloadedGallery? + var enqueue: @Sendable (DownloadRequestPayload) async throws -> Void + var togglePause: @Sendable (String) async throws -> Void + var retry: @Sendable (String, DownloadStartMode) async throws -> Void + var retryPages: @Sendable (String, [Int]) async throws -> Void + var delete: @Sendable (String) async throws -> Void + var loadManifest: @Sendable (String) async throws -> (DownloadedGallery, DownloadManifest) + var loadLocalPageURLs: @Sendable (String) async throws -> [Int: URL] + var captureCachedPage: @Sendable (String, Int, URL?) async -> Void + var loadInspection: @Sendable (String) async throws -> DownloadInspection + var fetchFolders: @Sendable () async throws -> [String] + var createFolder: @Sendable (String) async throws -> Void + var renameFolder: @Sendable (String, String) async throws -> Void + var deleteFolder: @Sendable (String) async throws -> Void + var moveDownload: @Sendable (String, String) async throws -> Void } extension DownloadClient { @@ -144,35 +79,33 @@ extension DownloadClient { reconcileDownloads: { await manager.reconcileDownloads() }, refreshDownloads: { await manager.refreshDownloads() }, validateImageData: { gid in await manager.validateImageData(gid: gid) }, - resumeQueue: { await manager.resumeQueue() }, - badges: { gids in await manager.badges(for: gids) }, fetchVersionMetadata: { gid, token in - await manager.fetchVersionMetadata(gid: gid, token: token) + try await manager.fetchVersionMetadata(gid: gid, token: token).get() }, updateRemoteVersion: { gid, metadata in await manager.updateRemoteVersion(gid: gid, metadata: metadata) }, - enqueue: { payload in await manager.enqueue(payload: payload) }, - togglePause: { gid in await manager.togglePause(gid: gid) }, - retry: { gid, mode in await manager.retry(gid: gid, mode: mode) }, + enqueue: { payload in try await manager.enqueue(payload: payload).get() }, + togglePause: { gid in try await manager.togglePause(gid: gid).get() }, + retry: { gid, mode in try await manager.retry(gid: gid, mode: mode).get() }, retryPages: { gid, pageIndices in - await manager.retryPages(gid: gid, pageIndices: pageIndices) + try await manager.retryPages(gid: gid, pageIndices: pageIndices).get() }, - delete: { gid in await manager.delete(gid: gid) }, - loadManifest: { gid in await manager.loadManifest(gid: gid) }, - loadLocalPageURLs: { gid in await manager.loadLocalPageURLs(gid: gid) }, + delete: { gid in try await manager.delete(gid: gid).get() }, + loadManifest: { gid in try await manager.loadManifest(gid: gid).get() }, + loadLocalPageURLs: { gid in try await manager.loadLocalPageURLs(gid: gid).get() }, captureCachedPage: { gid, index, imageURL in await manager.captureCachedPage(gid: gid, index: index, imageURL: imageURL) }, - loadInspection: { gid in await manager.loadInspection(gid: gid) }, + loadInspection: { gid in try await manager.loadInspection(gid: gid).get() }, fetchFolders: { await manager.fetchFolders() }, - createFolder: { name in await manager.createFolder(name: name) }, + createFolder: { name in try await manager.createFolder(name: name).get() }, renameFolder: { oldName, newName in - await manager.renameFolder(oldName: oldName, newName: newName) + try await manager.renameFolder(oldName: oldName, newName: newName).get() }, - deleteFolder: { name in await manager.deleteFolder(name: name) }, + deleteFolder: { name in try await manager.deleteFolder(name: name).get() }, moveDownload: { gid, folderName in - await manager.moveDownload(gid: gid, toFolderName: folderName) + try await manager.moveDownload(gid: gid, toFolderName: folderName).get() } ) } @@ -182,7 +115,7 @@ extension DownloadClient { enum DownloadClientKey: DependencyKey { static let liveValue = DownloadClient.live() static let previewValue = DownloadClient.noop - static let testValue = DownloadClient.unimplemented + static let testValue = DownloadClient() } extension DependencyValues { @@ -192,66 +125,30 @@ extension DependencyValues { } } -// MARK: Test +// MARK: Preview extension DownloadClient { - static let noop: Self = .init( - observeDownloads: { - .init { continuation in - continuation.yield([]) - continuation.finish() - } - }, + static let noop = Self( + observeDownloads: { AsyncStream { $0.finish() } }, fetchDownloads: { [] }, fetchDownload: { _ in nil }, reconcileDownloads: {}, refreshDownloads: {}, validateImageData: { _ in nil }, - resumeQueue: {}, - badges: { _ in [:] }, - fetchVersionMetadata: { _, _ in .failure(.notFound) }, + fetchVersionMetadata: { _, _ in throw AppError.notFound }, updateRemoteVersion: { _, _ in nil }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in .failure(.notFound) }, + enqueue: { _ in }, + togglePause: { _ in }, + retry: { _, _ in }, + retryPages: { _, _ in }, + delete: { _ in }, + loadManifest: { _ in throw AppError.notFound }, + loadLocalPageURLs: { _ in throw AppError.notFound }, captureCachedPage: { _, _, _ in }, - loadInspection: { _ in .failure(.notFound) }, + loadInspection: { _ in throw AppError.notFound }, fetchFolders: { [] }, - createFolder: { _ in .success(()) }, - renameFolder: { _, _ in .success(()) }, - deleteFolder: { _ in .success(()) }, - moveDownload: { _, _ in .success(()) } - ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - observeDownloads: IssueReporting.unimplemented(placeholder: placeholder()), - fetchDownloads: IssueReporting.unimplemented(placeholder: placeholder()), - fetchDownload: IssueReporting.unimplemented(placeholder: placeholder()), - reconcileDownloads: IssueReporting.unimplemented(placeholder: placeholder()), - refreshDownloads: IssueReporting.unimplemented(placeholder: placeholder()), - validateImageData: IssueReporting.unimplemented(placeholder: placeholder()), - resumeQueue: IssueReporting.unimplemented(placeholder: placeholder()), - badges: IssueReporting.unimplemented(placeholder: placeholder()), - fetchVersionMetadata: IssueReporting.unimplemented(placeholder: placeholder()), - updateRemoteVersion: IssueReporting.unimplemented(placeholder: placeholder()), - enqueue: IssueReporting.unimplemented(placeholder: placeholder()), - togglePause: IssueReporting.unimplemented(placeholder: placeholder()), - retry: IssueReporting.unimplemented(placeholder: placeholder()), - retryPages: IssueReporting.unimplemented(placeholder: placeholder()), - delete: IssueReporting.unimplemented(placeholder: placeholder()), - loadManifest: IssueReporting.unimplemented(placeholder: placeholder()), - loadLocalPageURLs: IssueReporting.unimplemented(placeholder: placeholder()), - captureCachedPage: IssueReporting.unimplemented(placeholder: placeholder()), - loadInspection: IssueReporting.unimplemented(placeholder: placeholder()), - fetchFolders: IssueReporting.unimplemented(placeholder: placeholder()), - createFolder: IssueReporting.unimplemented(placeholder: placeholder()), - renameFolder: IssueReporting.unimplemented(placeholder: placeholder()), - deleteFolder: IssueReporting.unimplemented(placeholder: placeholder()), - moveDownload: IssueReporting.unimplemented(placeholder: placeholder()) + createFolder: { _ in }, + renameFolder: { _, _ in }, + deleteFolder: { _ in }, + moveDownload: { _, _ in } ) } diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index b8caa78b8..d37bbec0b 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -18,7 +18,7 @@ extension DetailReducer { case .fetchDownloadFolders: let cancellationID = CancelID.fetchDownloadFolders(state.cancellationGalleryID) return .run { send in - await send(.fetchDownloadFoldersDone(await downloadClient.fetchFolders())) + await send(.fetchDownloadFoldersDone(try await downloadClient.fetchFolders())) } .cancellable(id: cancellationID, cancelInFlight: true) case .fetchDownloadFoldersDone(let folders): @@ -118,13 +118,7 @@ extension DetailReducer { let requestID = UUID() state.localPreviewRequestID = requestID return .run { [galleryID = state.gid] send in - let localPreviewURLs: [Int: URL] - switch await downloadClient.loadLocalPageURLs(galleryID) { - case .success(let pageURLs): - localPreviewURLs = pageURLs - case .failure: - localPreviewURLs = [:] - } + let localPreviewURLs = (try? await downloadClient.loadLocalPageURLs(galleryID)) ?? [:] await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) } .cancellable(id: CancelID.loadLocalPreviewURLs(state.cancellationGalleryID), cancelInFlight: true) @@ -148,7 +142,9 @@ extension DetailReducer { await send(.openReadingDone(.failure(.notFound))) return } - await send(.openReadingDone(await downloadClient.loadManifest(galleryID))) + await send(.openReadingDone(.success(try await downloadClient.loadManifest(galleryID)))) + } catch: { error, send in + await send(.openReadingDone(.failure(error as? AppError ?? .unknown))) } } @@ -199,7 +195,10 @@ extension DetailReducer { mode: .initial ) return .run { send in - await send(.startDownloadDone(await downloadClient.enqueue(payload))) + try await downloadClient.enqueue(payload) + await send(.startDownloadDone(.success(()))) + } catch: { error, send in + await send(.startDownloadDone(.failure(error as? AppError ?? .unknown))) } } @@ -230,7 +229,10 @@ extension DetailReducer { guard !state.isPreparingDownload else { return .none } state.isPreparingDownload = true return .run { [galleryID = state.gallery.id] send in - await send(.toggleDownloadPauseDone(await downloadClient.togglePause(galleryID))) + try await downloadClient.togglePause(galleryID) + await send(.toggleDownloadPauseDone(.success(()))) + } catch: { error, send in + await send(.toggleDownloadPauseDone(.failure(error as? AppError ?? .unknown))) } } @@ -268,7 +270,10 @@ extension DetailReducer { guard !state.isPreparingDownload else { return .none } state.isPreparingDownload = true return .run { [galleryID = state.gallery.id] send in - await send(.retryDownloadDone(await downloadClient.retry(galleryID, mode))) + try await downloadClient.retry(galleryID, mode) + await send(.retryDownloadDone(.success(()))) + } catch: { error, send in + await send(.retryDownloadDone(.failure(error as? AppError ?? .unknown))) } } @@ -297,7 +302,10 @@ extension DetailReducer { private func handleDeleteDownload(state: State) -> Effect { .run { [galleryID = state.gallery.id] send in - await send(.deleteDownloadDone(await downloadClient.delete(galleryID))) + try await downloadClient.delete(galleryID) + await send(.deleteDownloadDone(.success(()))) + } catch: { error, send in + await send(.deleteDownloadDone(.failure(error as? AppError ?? .unknown))) } } diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/EhPanda/View/Detail/DetailReducer+Fetch.swift index 2942e2d3d..15f4cea90 100644 --- a/EhPanda/View/Detail/DetailReducer+Fetch.swift +++ b/EhPanda/View/Detail/DetailReducer+Fetch.swift @@ -149,19 +149,10 @@ extension DetailReducer { state.didRequestVersionMetadata = true let gallery = state.gallery return .run { send in - let metadata: DownloadVersionMetadata? - switch await downloadClient.fetchVersionMetadata(gallery.gid, gallery.token) { - case .success(let fetchedMetadata): - metadata = fetchedMetadata - case .failure: - metadata = nil - } + let metadata = try? await downloadClient.fetchVersionMetadata(gallery.gid, gallery.token) await send(.fetchVersionMetadataDone(.success(metadata))) guard let metadata else { return } - let download = await downloadClient.updateRemoteVersion( - gallery.gid, - metadata - ) + let download = await downloadClient.updateRemoteVersion(gallery.gid, metadata) await send(.fetchDownloadBadgeDone(download)) } .cancellable(id: CancelID.fetchVersionMetadata(state.cancellationGalleryID), cancelInFlight: true) diff --git a/EhPanda/View/Detail/Previews/PreviewsReducer.swift b/EhPanda/View/Detail/Previews/PreviewsReducer.swift index 085cd1a67..4fdfa5b7a 100644 --- a/EhPanda/View/Detail/Previews/PreviewsReducer.swift +++ b/EhPanda/View/Detail/Previews/PreviewsReducer.swift @@ -156,13 +156,7 @@ struct PreviewsReducer { let requestID = UUID() state.localPreviewRequestID = requestID return .run { send in - let localPreviewURLs: [Int: URL] - switch await downloadClient.loadLocalPageURLs(gid) { - case .success(let pageURLs): - localPreviewURLs = pageURLs - case .failure: - localPreviewURLs = [:] - } + let localPreviewURLs = (try? await downloadClient.loadLocalPageURLs(gid)) ?? [:] await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) } .cancellable(id: CancelID.loadLocalPreviewURLs, cancelInFlight: true) @@ -180,7 +174,9 @@ struct PreviewsReducer { await send(.openReadingDone(.failure(.notFound))) return } - await send(.openReadingDone(await downloadClient.loadManifest(galleryID))) + await send(.openReadingDone(.success(try await downloadClient.loadManifest(galleryID)))) + } catch: { error, send in + await send(.openReadingDone(.failure(error as? AppError ?? .unknown))) } case .openReadingDone(let result): diff --git a/EhPanda/View/Downloads/DownloadInspectorReducer.swift b/EhPanda/View/Downloads/DownloadInspectorReducer.swift index 83d2a1e15..5f975a105 100644 --- a/EhPanda/View/Downloads/DownloadInspectorReducer.swift +++ b/EhPanda/View/Downloads/DownloadInspectorReducer.swift @@ -85,7 +85,9 @@ struct DownloadInspectorReducer { let requestID = UUID() state.inspectionRequestID = requestID return .run { [gid = state.gid] send in - await send(.loadInspectionDone(requestID, await downloadClient.loadInspection(gid))) + await send(.loadInspectionDone(requestID, .success(try await downloadClient.loadInspection(gid)))) + } catch: { error, send in + await send(.loadInspectionDone(requestID, .failure(error as? AppError ?? .unknown))) } .cancellable(id: CancelID.loadInspection, cancelInFlight: true) @@ -168,7 +170,10 @@ struct DownloadInspectorReducer { return .merge( .cancel(id: CancelID.loadInspection), .run { [gid = state.gid] send in - await send(.retryPageDone(await downloadClient.retryPages(gid, [index]))) + try await downloadClient.retryPages(gid, [index]) + await send(.retryPageDone(.success(()))) + } catch: { error, send in + await send(.retryPageDone(.failure(error as? AppError ?? .unknown))) } ) @@ -208,7 +213,10 @@ struct DownloadInspectorReducer { return .merge( .cancel(id: CancelID.loadInspection), .run { send in - await send(.retryFailedPagesDone(await downloadClient.retryPages(gid, failedPageIndices))) + try await downloadClient.retryPages(gid, failedPageIndices) + await send(.retryFailedPagesDone(.success(()))) + } catch: { error, send in + await send(.retryFailedPagesDone(.failure(error as? AppError ?? .unknown))) } ) @@ -224,7 +232,10 @@ struct DownloadInspectorReducer { download.canTogglePause else { return .none } return .run { send in - await send(.toggleDownloadPauseDone(await downloadClient.togglePause(download.gid))) + try await downloadClient.togglePause(download.gid) + await send(.toggleDownloadPauseDone(.success(()))) + } catch: { error, send in + await send(.toggleDownloadPauseDone(.failure(error as? AppError ?? .unknown))) } case .toggleDownloadPauseDone(let result): diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index 964245bc1..fc39d93e5 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -141,7 +141,7 @@ struct DownloadsReducer { case .fetchDownloads: state.loadingState = .loading return .run { send in - await send(.fetchDownloadsDone(await downloadClient.fetchDownloads())) + await send(.fetchDownloadsDone(try await downloadClient.fetchDownloads())) } case .fetchDownloadsDone(let downloads), .observeDownloadsDone(let downloads): @@ -171,7 +171,7 @@ struct DownloadsReducer { case .fetchFolders: return .run { send in - await send(.fetchFoldersDone(await downloadClient.fetchFolders())) + await send(.fetchFoldersDone(try await downloadClient.fetchFolders())) } .cancellable(id: CancelID.fetchFolders, cancelInFlight: true) @@ -185,7 +185,10 @@ struct DownloadsReducer { case .moveDownload(let gid, let folderName): return .run { send in - await send(.moveDownloadDone(await downloadClient.moveDownload(gid, folderName))) + try await downloadClient.moveDownload(gid, folderName) + await send(.moveDownloadDone(.success(()))) + } catch: { error, send in + await send(.moveDownloadDone(.failure(error as? AppError ?? .unknown))) } case .moveDownloadDone(let result): @@ -206,9 +209,11 @@ struct DownloadsReducer { .openReadingDone( requestID, gid, - await downloadClient.loadManifest(gid) + .success(try await downloadClient.loadManifest(gid)) ) ) + } catch: { error, send in + await send(.openReadingDone(requestID, gid, .failure(error as? AppError ?? .unknown))) } case .openReadingDone(let requestID, let gid, let result): @@ -221,7 +226,10 @@ struct DownloadsReducer { case .toggleDownloadPause(let gid): return .run { send in - await send(.toggleDownloadPauseDone(await downloadClient.togglePause(gid))) + try await downloadClient.togglePause(gid) + await send(.toggleDownloadPauseDone(.success(()))) + } catch: { error, send in + await send(.toggleDownloadPauseDone(.failure(error as? AppError ?? .unknown))) } case .toggleDownloadPauseDone: @@ -229,7 +237,10 @@ struct DownloadsReducer { case .updateDownload(let gid): return .run { send in - await send(.updateDownloadDone(await downloadClient.retry(gid, .update))) + try await downloadClient.retry(gid, .update) + await send(.updateDownloadDone(.success(()))) + } catch: { error, send in + await send(.updateDownloadDone(.failure(error as? AppError ?? .unknown))) } // List-level mutations don't surface a per-op HUD: the `observeDownloads` stream is the @@ -240,7 +251,10 @@ struct DownloadsReducer { case .deleteDownload(let gid): return .run { send in - await send(.deleteDownloadDone(await downloadClient.delete(gid))) + try await downloadClient.delete(gid) + await send(.deleteDownloadDone(.success(()))) + } catch: { error, send in + await send(.deleteDownloadDone(.failure(error as? AppError ?? .unknown))) } case .deleteDownloadDone: diff --git a/EhPanda/View/Downloads/FolderManagerReducer.swift b/EhPanda/View/Downloads/FolderManagerReducer.swift index 86744dd8d..a73588fa6 100644 --- a/EhPanda/View/Downloads/FolderManagerReducer.swift +++ b/EhPanda/View/Downloads/FolderManagerReducer.swift @@ -115,7 +115,10 @@ struct FolderManagerReducer { } state.loadingState = .loading return .run { send in - await send(.createFolderDone(await downloadClient.createFolder(name))) + try await downloadClient.createFolder(name) + await send(.createFolderDone(.success(()))) + } catch: { error, send in + await send(.createFolderDone(.failure(error as? AppError ?? .unknown))) } case .createFolderDone(.success): @@ -132,7 +135,10 @@ struct FolderManagerReducer { } state.loadingState = .loading return .run { send in - await send(.renameFolderDone(await downloadClient.renameFolder(oldName, newName))) + try await downloadClient.renameFolder(oldName, newName) + await send(.renameFolderDone(.success(()))) + } catch: { error, send in + await send(.renameFolderDone(.failure(error as? AppError ?? .unknown))) } case .renameFolderDone(.success): @@ -145,7 +151,10 @@ struct FolderManagerReducer { case .deleteFolder(let name): state.loadingState = .loading return .run { send in - await send(.deleteFolderDone(await downloadClient.deleteFolder(name))) + try await downloadClient.deleteFolder(name) + await send(.deleteFolderDone(.success(()))) + } catch: { error, send in + await send(.deleteFolderDone(.failure(error as? AppError ?? .unknown))) } case .deleteFolderDone(.success): @@ -161,7 +170,7 @@ struct FolderManagerReducer { case .fetchFolders: state.loadingState = .loading return .run { send in - await send(.fetchFoldersDone(await downloadClient.fetchFolders())) + await send(.fetchFoldersDone(try await downloadClient.fetchFolders())) } .cancellable(id: CancelID.fetchFolders, cancelInFlight: true) diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift index 70c604281..79d4604d7 100644 --- a/EhPanda/View/Reading/ReadingReducer+Database.swift +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -137,13 +137,7 @@ extension ReadingReducer { let requestID = UUID() state.localPageRequestID = requestID return .run { send in - let localPageURLs: [Int: URL] - switch await downloadClient.loadLocalPageURLs(gid) { - case .success(let pageURLs): - localPageURLs = pageURLs - case .failure: - localPageURLs = [:] - } + let localPageURLs = (try? await downloadClient.loadLocalPageURLs(gid)) ?? [:] await send(.loadLocalPageURLsDone(requestID, localPageURLs)) } .cancellable(id: ReadingCancelID.loadLocalPageURLs, cancelInFlight: true) diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index 89d5a586f..c346fd92e 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -28,7 +28,6 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { }, enqueue: { payload in capturedPayload.value = payload - return .success(()) } ) store.exhaustivity = .off @@ -54,7 +53,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { gallery: gallery, detail: detail, downloadValue: queuedDownload, configure: { state in state.galleryPreviewURLs = [1: previewURL] }, - enqueue: { _ in .success(()) } + enqueue: { _ in } ) store.exhaustivity = .off @@ -86,7 +85,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { gallery: gallery, detail: detail, downloadValue: nil, folders: { ["Library"] }, - enqueue: { _ in .success(()) } + enqueue: { _ in } ) store.exhaustivity = .off @@ -118,7 +117,6 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { }, enqueue: { payload in capturedPayload.value = payload - return .success(()) } ) store.exhaustivity = .off @@ -150,7 +148,7 @@ private extension DetailReducerDownloadTests { automationGID: String? = nil, folders: @escaping @Sendable () -> [String] = { [] }, configure: (inout DetailReducer.State) -> Void = { _ in }, - enqueue: @escaping @Sendable (DownloadRequestPayload) async -> Result + enqueue: @escaping @Sendable (DownloadRequestPayload) async throws -> Void ) -> TestStoreOf { var initialState = DetailReducer.State() initialState.gid = gallery.gid @@ -161,22 +159,19 @@ private extension DetailReducerDownloadTests { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in downloadValue }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: enqueue, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - fetchFolders: { folders() } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in downloadValue } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = enqueue + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.fetchFolders = { folders() } $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift index a22101ba3..1579738ff 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -129,28 +129,25 @@ private extension DetailReducerMetadataTests { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in downloadValue }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - fetchVersionMetadata: { _, _ in - .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) - }, - updateRemoteVersion: { _, _ in - updateCheckCount.value += 1 - return .none - }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in downloadValue } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.fetchVersionMetadata = { _, _ in + sampleVersionMetadata(gid: gallery.gid, token: gallery.token) + } + $0.downloadClient.updateRemoteVersion = { _, _ in + updateCheckCount.value += 1 + return .none + } + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop @@ -172,29 +169,26 @@ private extension DetailReducerMetadataTests { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in downloadValue }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - fetchVersionMetadata: { _, _ in - .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) - }, - updateRemoteVersion: { _, _ in - updateCheckCount.value += 1 - return updatedDownload - }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in .success([:]) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in downloadValue } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.fetchVersionMetadata = { _, _ in + sampleVersionMetadata(gid: gallery.gid, token: gallery.token) + } + $0.downloadClient.updateRemoteVersion = { _, _ in + updateCheckCount.value += 1 + return updatedDownload + } + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { _ in [:] } $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index b83158470..19cf505cd 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -133,29 +133,26 @@ private extension DetailReducerMetadataUpdateTests { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in updatedDownload }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - fetchVersionMetadata: { _, _ in - .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) - }, - updateRemoteVersion: { _, _ in - updateCheckCount.value += 1 - return updatedDownload - }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in .success([:]) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in updatedDownload } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.fetchVersionMetadata = { _, _ in + sampleVersionMetadata(gid: gallery.gid, token: gallery.token) + } + $0.downloadClient.updateRemoteVersion = { _, _ in + updateCheckCount.value += 1 + return updatedDownload + } + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { _ in [:] } $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop @@ -164,21 +161,19 @@ private extension DetailReducerMetadataUpdateTests { } func makeDeleteTestClient(download: DownloadedGallery) -> DownloadClient { - .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [download] }, - fetchDownload: { gid in gid == download.gid ? download : nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { _ in .success([:]) } - ) + var client = DownloadClient.noop + client.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + client.fetchDownloads = { [download] } + client.fetchDownload = { gid in gid == download.gid ? download : nil } + client.refreshDownloads = {} + client.enqueue = { _ in } + client.togglePause = { _ in } + client.retry = { _, _ in } + client.delete = { _ in } + client.loadManifest = { _ in throw AppError.notFound } + client.loadLocalPageURLs = { _ in [:] } + return client } } diff --git a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift index a0b2dab15..f5282fd2d 100644 --- a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift @@ -162,19 +162,16 @@ private extension DetailReducerObserveTests { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { stream }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { stream } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop @@ -185,42 +182,37 @@ private extension DetailReducerObserveTests { func makeLocalManifestClient( download: DownloadedGallery, manifest: DownloadManifest ) -> DownloadClient { - .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [download] }, - fetchDownload: { gid in gid == download.gid ? download : nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { gids in - Dictionary(uniqueKeysWithValues: gids.map { ($0, download.badge) }) - }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { gid in - gid == download.gid ? .success((download, manifest)) : .failure(.notFound) - } - ) + var client = DownloadClient.noop + client.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + client.fetchDownloads = { [download] } + client.fetchDownload = { gid in gid == download.gid ? download : nil } + client.refreshDownloads = {} + client.enqueue = { _ in } + client.togglePause = { _ in } + client.retry = { _, _ in } + client.delete = { _ in } + client.loadManifest = { gid in + guard gid == download.gid else { throw AppError.notFound } + return (download, manifest) + } + return client } func makeNoManifestClient() -> DownloadClient { - .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) + var client = DownloadClient.noop + client.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + client.fetchDownloads = { [] } + client.fetchDownload = { _ in nil } + client.refreshDownloads = {} + client.enqueue = { _ in } + client.togglePause = { _ in } + client.retry = { _, _ in } + client.delete = { _ in } + client.loadManifest = { _ in throw AppError.notFound } + return client } } diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index a74637aa7..9581fe1a7 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -64,26 +64,22 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in - enqueueCount.value += 1 - return .success(()) - }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in + enqueueCount.value += 1 + } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop @@ -157,22 +153,18 @@ private extension DetailReducerPauseAndGuardTests { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { AsyncStream { continuation in continuation.finish() } }, - fetchDownloads: { [] }, - fetchDownload: { _ in pausedDownload }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in - togglePauseCount.value += 1 - return .success(()) - }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in pausedDownload } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in + togglePauseCount.value += 1 + } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop diff --git a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift index d40e94467..9af9c0018 100644 --- a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift @@ -24,22 +24,19 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { withDependencies: { $0.appLaunchAutomationClient = .none $0.cookieClient = .noop - $0.downloadClient = .init( - observeDownloads: { .init { $0.finish() } }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - reconcileDownloads: { - reconcileCount.value += 1 - }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { .init { $0.finish() } } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.reconcileDownloads = { + reconcileCount.value += 1 + } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } } ) store.exhaustivity = .off diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift index 96af140f8..3e0892848 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -21,7 +21,7 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { let inspection = sampleInspection(download: download) let store = makeInspectorStore( gid: download.gid, - loadInspection: { _ in .success(inspection) } + loadInspection: { _ in inspection } ) store.exhaustivity = .off @@ -51,13 +51,12 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { retryPages: { _, pageIndices in retried.value = pageIndices confirm() - return .success(()) }, loadInspection: { [initialState] _ in guard let inspection = initialState.inspection else { - return .failure(.notFound) + throw AppError.notFound } - return .success(inspection) + return inspection } ) store.exhaustivity = .off @@ -83,13 +82,12 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { initialInspection: initialState.inspection, retryPages: { _, pageIndices in retried.value = pageIndices - return .success(()) }, loadInspection: { [initialState] _ in guard let inspection = initialState.inspection else { - return .failure(.notFound) + throw AppError.notFound } - return .success(inspection) + return inspection } ) store.exhaustivity = .off @@ -147,7 +145,8 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { return .valid }, loadInspection: { gid in - gid == download.gid ? .success(refreshedInspection) : .failure(.notFound) + guard gid == download.gid else { throw AppError.notFound } + return refreshedInspection } ) store.exhaustivity = .off @@ -186,9 +185,8 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { initialInspection: inspection, togglePause: { gid in toggledGID.value = gid - return .success(()) }, - loadInspection: { _ in .success(inspection) } + loadInspection: { _ in inspection } ) store.exhaustivity = .off @@ -212,9 +210,8 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { initialInspection: inspection, togglePause: { gid in toggledGID.value = gid - return .success(()) }, - loadInspection: { _ in .success(inspection) } + loadInspection: { _ in inspection } ) store.exhaustivity = .off @@ -238,9 +235,8 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { initialInspection: inspection, togglePause: { _ in didToggle.value = true - return .success(()) }, - loadInspection: { _ in .success(inspection) } + loadInspection: { _ in inspection } ) store.exhaustivity = .off @@ -283,7 +279,7 @@ extension DownloadInspectorLoadTests { didValidate.value = true return .valid }, - loadInspection: { _ in .success(inspection) } + loadInspection: { _ in inspection } ) store.exhaustivity = .off @@ -306,7 +302,7 @@ extension DownloadInspectorLoadTests { validateImageData: { _ in .missingFiles("Page 2 image data is corrupted.") }, - loadInspection: { _ in .success(inspection) } + loadInspection: { _ in inspection } ) store.exhaustivity = .off @@ -333,10 +329,10 @@ private extension DownloadInspectorLoadTests { func makeInspectorStore( gid: String, initialInspection: DownloadInspection? = nil, - retryPages: (@Sendable (String, [Int]) async -> Result)? = nil, + retryPages: (@Sendable (String, [Int]) async throws -> Void)? = nil, validateImageData: (@Sendable (String) async -> DownloadValidationState?)? = nil, - togglePause: (@Sendable (String) async -> Result)? = nil, - loadInspection: @escaping @Sendable (String) async -> Result + togglePause: (@Sendable (String) async throws -> Void)? = nil, + loadInspection: @escaping @Sendable (String) async throws -> DownloadInspection ) -> TestStoreOf { var initialState = DownloadInspectorReducer.State(gid: gid) initialState.inspection = initialInspection @@ -345,24 +341,21 @@ private extension DownloadInspectorLoadTests { initialState: initialState, reducer: DownloadInspectorReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - validateImageData: validateImageData ?? { _ in nil }, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: togglePause ?? { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: retryPages ?? { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: loadInspection - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.validateImageData = validateImageData ?? { _ in nil } + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = togglePause ?? { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.retryPages = retryPages ?? { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadInspection = loadInspection } ) } diff --git a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift index 45ad17108..1460940cc 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -27,7 +27,7 @@ struct DownloadInspectorRetryTests: DownloadFeatureTestCase { let store = makeRetryTestStore( initialState: initialState, - loadInspection: { _ in .success(refreshedInspection) } + loadInspection: { _ in refreshedInspection } ) store.exhaustivity = .off @@ -70,7 +70,7 @@ struct DownloadInspectorRetryTests: DownloadFeatureTestCase { let store = makeRetryTestStore( initialState: initialState, - loadInspection: { _ in .success(settledInspection) } + loadInspection: { _ in settledInspection } ) store.exhaustivity = .off @@ -110,7 +110,7 @@ struct DownloadInspectorRetryTests: DownloadFeatureTestCase { let store = makeRetryTestStore( initialState: initialState, - loadInspection: { _ in .failure(.networkingFailed) } + loadInspection: { _ in throw AppError.networkingFailed } ) store.exhaustivity = .off @@ -129,29 +129,26 @@ struct DownloadInspectorRetryTests: DownloadFeatureTestCase { private extension DownloadInspectorRetryTests { func makeRetryTestStore( initialState: DownloadInspectorReducer.State, - loadInspection: @escaping @Sendable (String) async -> Result + loadInspection: @escaping @Sendable (String) async throws -> DownloadInspection ) -> TestStoreOf { TestStore( initialState: initialState, reducer: DownloadInspectorReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: loadInspection - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.retryPages = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadInspection = loadInspection } ) } diff --git a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift index e79ab2e5f..2b4b36890 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift @@ -30,28 +30,25 @@ struct DownloadInspectorSkipTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadInspectorReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - retryPages: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in - loadInspectionCount.value += 1 - return .success(inspection) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() } - ) + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.retryPages = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadInspection = { _ in + loadInspectionCount.value += 1 + return inspection + } } ) store.exhaustivity = .off diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 0d72b34ad..b24070d01 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -146,15 +146,13 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let downloads = await manager.fetchDownloads() let indexedDownload = try #require(await manager.fetchDownload(gid: "600")) - let badges = await manager.badges(for: ["600", "601"]) #expect(downloads.map(\.gid) == ["600"]) #expect(indexedDownload.title == "Disk") #expect(indexedDownload.displayStatus == .queued) #expect(indexedDownload.displayStatus == .queued) #expect(await manager.fetchDownload(gid: "601") == nil) - #expect(badges["600"]?.status == .queued) - #expect(badges["601"] == nil) + #expect(indexedDownload.badge.status == .queued) } @Test @@ -179,10 +177,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { manifest: indexedManifest(gid: "611", title: "Later", pageHashes: ["sha256:new"]) ) - let badges = await manager.badges(for: ["611"]) - #expect(await manager.fetchDownload(gid: "611") == nil) - #expect(badges.isEmpty) } @Test @@ -556,13 +551,12 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) let failedDownload = try #require(await manager.fetchDownload(gid: "800")) - let badges = await manager.badges(for: ["800"]) #expect(queueStore.gids == []) #expect(failedDownload.displayStatus == .error) #expect(failedDownload.displayStatus == .error) #expect(failedDownload.lastError?.code == .networkingFailed) - #expect(badges["800"]?.status == .error) + #expect(failedDownload.badge.status == .error) } @Test diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 706dc852b..9e0a8865b 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -29,26 +29,23 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadInspectorReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.yield([download]) - continuation.yield([]) - continuation.finish() - } - }, - fetchDownloads: { [download] }, - fetchDownload: { gid in gid == download.gid ? download : nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadInspection: { _ in .success(inspection) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.yield([download]) + continuation.yield([]) + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [download] } + $0.downloadClient.fetchDownload = { gid in gid == download.gid ? download : nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadInspection = { _ in inspection } } ) store.exhaustivity = .off diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index 9cd24d48a..8097ce26d 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -176,24 +176,21 @@ private extension DownloadObserverReadingTests { $0.cookieClient = .noop $0.databaseClient = .noop $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { stream }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { gid in - #expect(gid == expectedGID) - loadCount.value += 1 - return .success([:]) - } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { stream } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { gid in + #expect(gid == expectedGID) + loadCount.value += 1 + return [:] + } $0.hapticsClient = .noop $0.imageClient = .noop $0.urlClient = .noop @@ -213,24 +210,21 @@ private extension DownloadObserverReadingTests { initialState: initialState, reducer: PreviewsReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { stream }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { gid in - #expect(gid == expectedGID) - loadCount.value += 1 - return .success([:]) - } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { stream } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { gid in + #expect(gid == expectedGID) + loadCount.value += 1 + return [:] + } $0.databaseClient = .noop $0.hapticsClient = .noop } diff --git a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift index c667bebad..075432430 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -27,7 +27,7 @@ struct DownloadObserverRefreshTests: DownloadFeatureTestCase { stream: stream, loadLocalPageURLs: { _ in loadCount.value += 1 - return .success([:]) + return [:] } ) @@ -63,7 +63,7 @@ struct DownloadObserverRefreshTests: DownloadFeatureTestCase { stream: stream, loadLocalPageURLs: { _ in loadCount.value += 1 - return .success([:]) + return [:] } ) @@ -97,7 +97,7 @@ private extension DownloadObserverRefreshTests { func makeReadingObserverStore( initialState: ReadingReducer.State, stream: AsyncStream<[DownloadedGallery]>, - loadLocalPageURLs: @escaping @Sendable (String) async -> Result<[Int: URL], AppError> + loadLocalPageURLs: @escaping @Sendable (String) async throws -> [Int: URL] ) -> TestStoreOf { let store = TestStore( initialState: initialState, @@ -123,7 +123,7 @@ private extension DownloadObserverRefreshTests { func makePreviewsObserverStore( initialState: PreviewsReducer.State, stream: AsyncStream<[DownloadedGallery]>, - loadLocalPageURLs: @escaping @Sendable (String) async -> Result<[Int: URL], AppError> + loadLocalPageURLs: @escaping @Sendable (String) async throws -> [Int: URL] ) -> TestStoreOf { let store = TestStore( initialState: initialState, @@ -142,21 +142,19 @@ private extension DownloadObserverRefreshTests { func makeObserveDownloadClient( stream: AsyncStream<[DownloadedGallery]>, - loadLocalPageURLs: @escaping @Sendable (String) async -> Result<[Int: URL], AppError> + loadLocalPageURLs: @escaping @Sendable (String) async throws -> [Int: URL] ) -> DownloadClient { - .init( - observeDownloads: { stream }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: loadLocalPageURLs - ) + var client = DownloadClient.noop + client.observeDownloads = { stream } + client.fetchDownloads = { [] } + client.fetchDownload = { _ in nil } + client.refreshDownloads = {} + client.enqueue = { _ in } + client.togglePause = { _ in } + client.retry = { _, _ in } + client.delete = { _ in } + client.loadManifest = { _ in throw AppError.notFound } + client.loadLocalPageURLs = loadLocalPageURLs + return client } } diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift index ef7c052b8..3b3c02979 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -93,28 +93,24 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { initialState: DownloadsReducer.State(), reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - fetchFolders: { ["Library"] }, - moveDownload: { gid, folder in - moved.value = (gid, folder) - return .success(()) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() } - ) + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.fetchFolders = { ["Library"] } + $0.downloadClient.moveDownload = { gid, folder in + moved.value = (gid, folder) + } } ) store.exhaustivity = .off @@ -145,28 +141,24 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { gid, mode in - if mode == .update { - retried.value.append(gid) - } - return .success(()) - }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { gid, mode in + if mode == .update { + retried.value.append(gid) + } + } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } } ) store.exhaustivity = .off @@ -193,26 +185,22 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { gid in - deleted.value.append(gid) - return .success(()) - }, - loadManifest: { _ in .failure(.notFound) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { gid in + deleted.value.append(gid) + } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } } ) store.exhaustivity = .off @@ -244,25 +232,23 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { gid in - gid == download.gid ? .success((download, manifest)) : .failure(.notFound) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() } - ) + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { gid in + guard gid == download.gid else { throw AppError.notFound } + return (download, manifest) + } } ) store.exhaustivity = .off @@ -291,26 +277,22 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { gid in - toggled.value.append(gid) - return .success(()) - }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { gid in + toggled.value.append(gid) + } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } } ) store.exhaustivity = .off diff --git a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift index 03960a601..181048565 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift @@ -27,26 +27,23 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [download] }, - fetchDownload: { _ in nil }, - reconcileDownloads: { - reconcileCount.value += 1 - }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .failure(.networkingFailed) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [download] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.reconcileDownloads = { + reconcileCount.value += 1 + } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in throw AppError.networkingFailed } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } } ) @@ -67,28 +64,25 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { initialState: DownloadsReducer.State(), reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - reconcileDownloads: { - reconcileCount.value += 1 - }, - refreshDownloads: { - refreshCount.value += 1 - }, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.reconcileDownloads = { + reconcileCount.value += 1 + } + $0.downloadClient.refreshDownloads = { + refreshCount.value += 1 + } + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } } ) @@ -112,32 +106,29 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { initialState: DownloadsReducer.State(), reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { - fetchCount.value += 1 - return [] - }, - fetchDownload: { _ in nil }, - refreshDownloads: { - refreshCount.value += 1 - }, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - fetchFolders: { - folderFetchCount.value += 1 - return [] + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() } - ) + } + $0.downloadClient.fetchDownloads = { + fetchCount.value += 1 + return [] + } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = { + refreshCount.value += 1 + } + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.fetchFolders = { + folderFetchCount.value += 1 + return [] + } } ) diff --git a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift index 21d688efb..f6b471c58 100644 --- a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift +++ b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift @@ -33,7 +33,6 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { folders: { createdName.value.map { [$0] } ?? [] }, createFolder: { name in createdName.value = name - return .success(()) } ) store.exhaustivity = .off @@ -60,7 +59,6 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { folders: { ["New Name"] }, renameFolder: { oldName, newName in renamedPair.value = (oldName, newName) - return .success(()) } ) store.exhaustivity = .off @@ -88,7 +86,6 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { folders: { deletedName.value == nil ? ["Doomed"] : [] }, deleteFolder: { name in deletedName.value = name - return .success(()) } ) store.exhaustivity = .off @@ -136,7 +133,6 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { folders: { createdName.value.map { [$0] } ?? [] }, createFolder: { name in createdName.value = name - return .success(()) } ) store.exhaustivity = .off @@ -169,7 +165,6 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { folders: { renamedPair.value == nil ? ["Old Name"] : ["New Name"] }, renameFolder: { oldName, newName in renamedPair.value = (oldName, newName) - return .success(()) } ) store.exhaustivity = .off @@ -249,7 +244,7 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { fetchCount.value += 1 return [] }, - createFolder: { _ in .failure(error) } + createFolder: { _ in throw error } ) await store.send(.binding(.set(\.editingFolderName, "Favorites"))) { @@ -274,7 +269,7 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { fetchCount.value += 1 return [] }, - renameFolder: { _, _ in .failure(error) } + renameFolder: { _, _ in throw error } ) await store.send(.binding(.set(\.editingFolderName, "New Name"))) { @@ -299,7 +294,7 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { fetchCount.value += 1 return [] }, - deleteFolder: { _ in .failure(error) } + deleteFolder: { _ in throw error } ) await store.send(.deleteFolder("Doomed")) { @@ -317,36 +312,33 @@ struct FolderManagerReducerTests: DownloadFeatureTestCase { private extension FolderManagerReducerTests { func makeStore( folders: @escaping @Sendable () -> [String], - createFolder: @escaping @Sendable (String) async -> Result - = { _ in .success(()) }, - renameFolder: @escaping @Sendable (String, String) async -> Result - = { _, _ in .success(()) }, - deleteFolder: @escaping @Sendable (String) async -> Result - = { _ in .success(()) } + createFolder: @escaping @Sendable (String) async throws -> Void + = { _ in }, + renameFolder: @escaping @Sendable (String, String) async throws -> Void + = { _, _ in }, + deleteFolder: @escaping @Sendable (String) async throws -> Void + = { _ in } ) -> TestStoreOf { TestStore( initialState: FolderManagerReducer.State(), reducer: FolderManagerReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in continuation.finish() } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - fetchFolders: { folders() }, - createFolder: createFolder, - renameFolder: renameFolder, - deleteFolder: deleteFolder - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.fetchFolders = { folders() } + $0.downloadClient.createFolder = createFolder + $0.downloadClient.renameFolder = renameFolder + $0.downloadClient.deleteFolder = deleteFolder } ) } diff --git a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift index 8d086fb41..817d89cd4 100644 --- a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -94,21 +94,19 @@ private extension PreviewsReducerDownloadTests { initialState: initialState, reducer: PreviewsReducer.init, withDependencies: { - $0.downloadClient = .init( - observeDownloads: { AsyncStream { continuation in continuation.finish() } }, - fetchDownloads: { [download] }, - fetchDownload: { gid in gid == download.gid ? download : nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { gid in - gid == download.gid ? .success((download, manifest)) : .failure(.notFound) - } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } + $0.downloadClient.fetchDownloads = { [download] } + $0.downloadClient.fetchDownload = { gid in gid == download.gid ? download : nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { gid in + guard gid == download.gid else { throw AppError.notFound } + return (download, manifest) + } $0.databaseClient = .noop $0.hapticsClient = .noop } @@ -118,26 +116,24 @@ private extension PreviewsReducerDownloadTests { } func makePreviewsNoManifestClient(loadLocalPageURLs: Bool) -> DownloadClient { - let loadLocalPageURLsResult: @Sendable (String) async -> Result<[Int: URL], AppError> + let loadLocalPageURLsResult: @Sendable (String) async throws -> [Int: URL] if loadLocalPageURLs { - loadLocalPageURLsResult = { _ in .success([:]) } + loadLocalPageURLsResult = { _ in [:] } } else { - loadLocalPageURLsResult = { _ in .failure(.notFound) } + loadLocalPageURLsResult = { _ in throw AppError.notFound } } - return .init( - observeDownloads: { AsyncStream { continuation in continuation.finish() } }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: loadLocalPageURLsResult - ) + var client = DownloadClient.noop + client.observeDownloads = { AsyncStream { continuation in continuation.finish() } } + client.fetchDownloads = { [] } + client.fetchDownload = { _ in nil } + client.refreshDownloads = {} + client.enqueue = { _ in } + client.togglePause = { _ in } + client.retry = { _, _ in } + client.delete = { _ in } + client.loadManifest = { _ in throw AppError.notFound } + client.loadLocalPageURLs = loadLocalPageURLsResult + return client } func makePreviewsNoManifestStore( diff --git a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift index 83085c2d9..462ec27ca 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -134,22 +134,20 @@ private extension ReadingReducerDownloadTests { $0.cookieClient = .noop $0.databaseClient = .noop $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { AsyncStream { $0.yield([]); $0.finish() } }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - loadLocalPageURLs: { gid in - gid == gallery.gid ? .success([1: localPageURL]) : .failure(.notFound) - } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { AsyncStream { $0.yield([]); $0.finish() } } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { gid in + guard gid == gallery.gid else { throw AppError.notFound } + return [1: localPageURL] + } $0.hapticsClient = .noop $0.imageClient = .noop $0.urlClient = .noop @@ -172,22 +170,19 @@ private extension ReadingReducerDownloadTests { $0.cookieClient = .noop $0.databaseClient = .noop $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { AsyncStream { $0.finish() } }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - captureCachedPage: { gid, index, imageURL in - capturedCalls.value.append(CapturedPageCall(gid: gid, index: index, imageURL: imageURL)) - } - ) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { AsyncStream { $0.finish() } } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.captureCachedPage = { gid, index, imageURL in + capturedCalls.value.append(CapturedPageCall(gid: gid, index: index, imageURL: imageURL)) + } $0.hapticsClient = .noop $0.imageClient = .noop $0.urlClient = .noop diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift index 7f16357a6..b704ea234 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -46,26 +46,23 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { $0.cookieClient = .noop $0.databaseClient = .noop $0.deviceClient = .noop - $0.downloadClient = .init( - observeDownloads: { - AsyncStream { continuation in - continuation.finish() - } - }, - fetchDownloads: { [] }, - fetchDownload: { _ in nil }, - refreshDownloads: {}, - resumeQueue: {}, - badges: { _ in [:] }, - enqueue: { _ in .success(()) }, - togglePause: { _ in .success(()) }, - retry: { _, _ in .success(()) }, - delete: { _ in .success(()) }, - loadManifest: { _ in .failure(.notFound) }, - captureCachedPage: { gid, index, imageURL in - capturedCalls.value.append((gid, index, imageURL)) + $0.downloadClient = .noop + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() } - ) + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.captureCachedPage = { gid, index, imageURL in + capturedCalls.value.append((gid, index, imageURL)) + } $0.hapticsClient = .noop $0.imageClient = .noop $0.urlClient = .noop From d3c72bfc516d8d8f58440232c8d9cf616ad51f92 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 15:01:32 +0800 Subject: [PATCH 208/614] Add AppError(_:) coercion init Replace the 18x dead-defensive `error as? AppError ?? .unknown` in the download reducers' .run catch: handlers with `AppError(error)`. The live facade only ever throws AppError (via .get()), so the cast always succeeds; centralizing it removes the repetition. --- EhPanda/Models/Support/AppError.swift | 4 ++++ EhPanda/View/Detail/DetailReducer+Download.swift | 10 +++++----- EhPanda/View/Detail/Previews/PreviewsReducer.swift | 2 +- EhPanda/View/Downloads/DownloadInspectorReducer.swift | 8 ++++---- EhPanda/View/Downloads/DownloadsReducer.swift | 10 +++++----- EhPanda/View/Downloads/FolderManagerReducer.swift | 6 +++--- 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/EhPanda/Models/Support/AppError.swift b/EhPanda/Models/Support/AppError.swift index b7b8d1a92..1718ebc42 100644 --- a/EhPanda/Models/Support/AppError.swift +++ b/EhPanda/Models/Support/AppError.swift @@ -9,6 +9,10 @@ import SFSafeSymbols enum AppError: Error, Identifiable, Equatable, Hashable, Sendable { var id: String { localizedDescription } + init(_ error: any Error) { + self = error as? AppError ?? .unknown + } + case databaseCorrupted(String?) case copyrightClaim(String) case ipBanned(BanInterval) diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index d37bbec0b..81bb02b17 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -144,7 +144,7 @@ extension DetailReducer { } await send(.openReadingDone(.success(try await downloadClient.loadManifest(galleryID)))) } catch: { error, send in - await send(.openReadingDone(.failure(error as? AppError ?? .unknown))) + await send(.openReadingDone(.failure(AppError(error)))) } } @@ -198,7 +198,7 @@ extension DetailReducer { try await downloadClient.enqueue(payload) await send(.startDownloadDone(.success(()))) } catch: { error, send in - await send(.startDownloadDone(.failure(error as? AppError ?? .unknown))) + await send(.startDownloadDone(.failure(AppError(error)))) } } @@ -232,7 +232,7 @@ extension DetailReducer { try await downloadClient.togglePause(galleryID) await send(.toggleDownloadPauseDone(.success(()))) } catch: { error, send in - await send(.toggleDownloadPauseDone(.failure(error as? AppError ?? .unknown))) + await send(.toggleDownloadPauseDone(.failure(AppError(error)))) } } @@ -273,7 +273,7 @@ extension DetailReducer { try await downloadClient.retry(galleryID, mode) await send(.retryDownloadDone(.success(()))) } catch: { error, send in - await send(.retryDownloadDone(.failure(error as? AppError ?? .unknown))) + await send(.retryDownloadDone(.failure(AppError(error)))) } } @@ -305,7 +305,7 @@ extension DetailReducer { try await downloadClient.delete(galleryID) await send(.deleteDownloadDone(.success(()))) } catch: { error, send in - await send(.deleteDownloadDone(.failure(error as? AppError ?? .unknown))) + await send(.deleteDownloadDone(.failure(AppError(error)))) } } diff --git a/EhPanda/View/Detail/Previews/PreviewsReducer.swift b/EhPanda/View/Detail/Previews/PreviewsReducer.swift index 4fdfa5b7a..25449f582 100644 --- a/EhPanda/View/Detail/Previews/PreviewsReducer.swift +++ b/EhPanda/View/Detail/Previews/PreviewsReducer.swift @@ -176,7 +176,7 @@ struct PreviewsReducer { } await send(.openReadingDone(.success(try await downloadClient.loadManifest(galleryID)))) } catch: { error, send in - await send(.openReadingDone(.failure(error as? AppError ?? .unknown))) + await send(.openReadingDone(.failure(AppError(error)))) } case .openReadingDone(let result): diff --git a/EhPanda/View/Downloads/DownloadInspectorReducer.swift b/EhPanda/View/Downloads/DownloadInspectorReducer.swift index 5f975a105..b98177f9a 100644 --- a/EhPanda/View/Downloads/DownloadInspectorReducer.swift +++ b/EhPanda/View/Downloads/DownloadInspectorReducer.swift @@ -87,7 +87,7 @@ struct DownloadInspectorReducer { return .run { [gid = state.gid] send in await send(.loadInspectionDone(requestID, .success(try await downloadClient.loadInspection(gid)))) } catch: { error, send in - await send(.loadInspectionDone(requestID, .failure(error as? AppError ?? .unknown))) + await send(.loadInspectionDone(requestID, .failure(AppError(error)))) } .cancellable(id: CancelID.loadInspection, cancelInFlight: true) @@ -173,7 +173,7 @@ struct DownloadInspectorReducer { try await downloadClient.retryPages(gid, [index]) await send(.retryPageDone(.success(()))) } catch: { error, send in - await send(.retryPageDone(.failure(error as? AppError ?? .unknown))) + await send(.retryPageDone(.failure(AppError(error)))) } ) @@ -216,7 +216,7 @@ struct DownloadInspectorReducer { try await downloadClient.retryPages(gid, failedPageIndices) await send(.retryFailedPagesDone(.success(()))) } catch: { error, send in - await send(.retryFailedPagesDone(.failure(error as? AppError ?? .unknown))) + await send(.retryFailedPagesDone(.failure(AppError(error)))) } ) @@ -235,7 +235,7 @@ struct DownloadInspectorReducer { try await downloadClient.togglePause(download.gid) await send(.toggleDownloadPauseDone(.success(()))) } catch: { error, send in - await send(.toggleDownloadPauseDone(.failure(error as? AppError ?? .unknown))) + await send(.toggleDownloadPauseDone(.failure(AppError(error)))) } case .toggleDownloadPauseDone(let result): diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index fc39d93e5..f6462e610 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -188,7 +188,7 @@ struct DownloadsReducer { try await downloadClient.moveDownload(gid, folderName) await send(.moveDownloadDone(.success(()))) } catch: { error, send in - await send(.moveDownloadDone(.failure(error as? AppError ?? .unknown))) + await send(.moveDownloadDone(.failure(AppError(error)))) } case .moveDownloadDone(let result): @@ -213,7 +213,7 @@ struct DownloadsReducer { ) ) } catch: { error, send in - await send(.openReadingDone(requestID, gid, .failure(error as? AppError ?? .unknown))) + await send(.openReadingDone(requestID, gid, .failure(AppError(error)))) } case .openReadingDone(let requestID, let gid, let result): @@ -229,7 +229,7 @@ struct DownloadsReducer { try await downloadClient.togglePause(gid) await send(.toggleDownloadPauseDone(.success(()))) } catch: { error, send in - await send(.toggleDownloadPauseDone(.failure(error as? AppError ?? .unknown))) + await send(.toggleDownloadPauseDone(.failure(AppError(error)))) } case .toggleDownloadPauseDone: @@ -240,7 +240,7 @@ struct DownloadsReducer { try await downloadClient.retry(gid, .update) await send(.updateDownloadDone(.success(()))) } catch: { error, send in - await send(.updateDownloadDone(.failure(error as? AppError ?? .unknown))) + await send(.updateDownloadDone(.failure(AppError(error)))) } // List-level mutations don't surface a per-op HUD: the `observeDownloads` stream is the @@ -254,7 +254,7 @@ struct DownloadsReducer { try await downloadClient.delete(gid) await send(.deleteDownloadDone(.success(()))) } catch: { error, send in - await send(.deleteDownloadDone(.failure(error as? AppError ?? .unknown))) + await send(.deleteDownloadDone(.failure(AppError(error)))) } case .deleteDownloadDone: diff --git a/EhPanda/View/Downloads/FolderManagerReducer.swift b/EhPanda/View/Downloads/FolderManagerReducer.swift index a73588fa6..d0028ac6c 100644 --- a/EhPanda/View/Downloads/FolderManagerReducer.swift +++ b/EhPanda/View/Downloads/FolderManagerReducer.swift @@ -118,7 +118,7 @@ struct FolderManagerReducer { try await downloadClient.createFolder(name) await send(.createFolderDone(.success(()))) } catch: { error, send in - await send(.createFolderDone(.failure(error as? AppError ?? .unknown))) + await send(.createFolderDone(.failure(AppError(error)))) } case .createFolderDone(.success): @@ -138,7 +138,7 @@ struct FolderManagerReducer { try await downloadClient.renameFolder(oldName, newName) await send(.renameFolderDone(.success(()))) } catch: { error, send in - await send(.renameFolderDone(.failure(error as? AppError ?? .unknown))) + await send(.renameFolderDone(.failure(AppError(error)))) } case .renameFolderDone(.success): @@ -154,7 +154,7 @@ struct FolderManagerReducer { try await downloadClient.deleteFolder(name) await send(.deleteFolderDone(.success(()))) } catch: { error, send in - await send(.deleteFolderDone(.failure(error as? AppError ?? .unknown))) + await send(.deleteFolderDone(.failure(AppError(error)))) } case .deleteFolderDone(.success): From f1f67ce949c90fc8d5b7f7f3df8eee2a55601e46 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 15:01:32 +0800 Subject: [PATCH 209/614] Make fetchVersionMetadata a non-throwing Optional Every caller treated a thrown error as 'no metadata' via try? -> nil. An Optional return (async -> DownloadVersionMetadata?) keeps the macro's loud auto-unimplemented default (reportIssue + nil) while letting the call site drop try?. Facade swallows the manager's Result via try?.get(). loadLocalPageURLs has the same try?-swallow shape but is reworked in Step 3 (DES-1); its signature is folded in there per review guidance. --- EhPanda/App/Tools/Clients/DownloadClient.swift | 6 +++--- EhPanda/View/Detail/DetailReducer+Fetch.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index b2c230d48..f8fd8e424 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -14,7 +14,7 @@ struct DownloadClient: Sendable { var reconcileDownloads: @Sendable () async -> Void var refreshDownloads: @Sendable () async -> Void var validateImageData: @Sendable (String) async -> DownloadValidationState? - var fetchVersionMetadata: @Sendable (String, String) async throws -> DownloadVersionMetadata + var fetchVersionMetadata: @Sendable (String, String) async -> DownloadVersionMetadata? var updateRemoteVersion: @Sendable (String, DownloadVersionMetadata) async -> DownloadedGallery? var enqueue: @Sendable (DownloadRequestPayload) async throws -> Void var togglePause: @Sendable (String) async throws -> Void @@ -80,7 +80,7 @@ extension DownloadClient { refreshDownloads: { await manager.refreshDownloads() }, validateImageData: { gid in await manager.validateImageData(gid: gid) }, fetchVersionMetadata: { gid, token in - try await manager.fetchVersionMetadata(gid: gid, token: token).get() + try? await manager.fetchVersionMetadata(gid: gid, token: token).get() }, updateRemoteVersion: { gid, metadata in await manager.updateRemoteVersion(gid: gid, metadata: metadata) @@ -134,7 +134,7 @@ extension DownloadClient { reconcileDownloads: {}, refreshDownloads: {}, validateImageData: { _ in nil }, - fetchVersionMetadata: { _, _ in throw AppError.notFound }, + fetchVersionMetadata: { _, _ in nil }, updateRemoteVersion: { _, _ in nil }, enqueue: { _ in }, togglePause: { _ in }, diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/EhPanda/View/Detail/DetailReducer+Fetch.swift index 15f4cea90..bd61f5bd9 100644 --- a/EhPanda/View/Detail/DetailReducer+Fetch.swift +++ b/EhPanda/View/Detail/DetailReducer+Fetch.swift @@ -149,7 +149,7 @@ extension DetailReducer { state.didRequestVersionMetadata = true let gallery = state.gallery return .run { send in - let metadata = try? await downloadClient.fetchVersionMetadata(gallery.gid, gallery.token) + let metadata = await downloadClient.fetchVersionMetadata(gallery.gid, gallery.token) await send(.fetchVersionMetadataDone(.success(metadata))) guard let metadata else { return } let download = await downloadClient.updateRemoteVersion(gallery.gid, metadata) From 06669f1c1c1c84cf85448c5cfd0fb80110702157 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 15:22:37 +0800 Subject: [PATCH 210/614] Migrate download test doubles to the loud base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DES-4 migration left each reducer-test double on a .noop base ($0.downloadClient = .noop / var client = DownloadClient.noop) plus specific overrides, which re-enabled silent benign defaults for the un-overridden endpoints — leaving the loud-unimplemented safety net unrealized exactly where it matters for the upcoming DES-2 carve. Flip the migrated doubles onto the loud testValue base (DownloadClient()), so any endpoint a test does NOT wire fails loudly. The flip surfaced the hidden reliance the review predicted: 5 Detail/Downloads doubles silently leaned on loadLocalPageURLs / fetchVersionMetadata / fetchFolders through their onAppear/refresh flows. Each is now wired explicitly to its benign value (matching the old .noop behavior), so behavior is unchanged while every unexercised endpoint is loud. The two pre-existing all-benign .noop fixtures (PauseAndGuard, Reading observer) are left as-is — they deliberately don't exercise downloads. Build + 235 tests green. --- .../Tests/Download/DetailReducerDownloadTests.swift | 4 +++- .../Tests/Download/DetailReducerMetadataTests.swift | 5 +++-- .../Download/DetailReducerMetadataUpdateTests.swift | 4 ++-- .../Tests/Download/DetailReducerObserveTests.swift | 9 ++++++--- .../Download/DetailReducerPauseAndGuardTests.swift | 6 ++++-- .../Tests/Download/DownloadAutomationTests.swift | 2 +- .../Tests/Download/DownloadInspectorLoadTests.swift | 2 +- .../Tests/Download/DownloadInspectorRetryTests.swift | 2 +- .../Tests/Download/DownloadInspectorSkipTests.swift | 2 +- .../Tests/Download/DownloadObserverBatchTests.swift | 2 +- .../Tests/Download/DownloadObserverReadingTests.swift | 4 ++-- .../Tests/Download/DownloadObserverRefreshTests.swift | 2 +- .../Tests/Download/DownloadsReducerActionTests.swift | 10 +++++----- .../Tests/Download/DownloadsReducerRefreshTests.swift | 7 ++++--- .../Tests/Download/FolderManagerReducerTests.swift | 2 +- .../Tests/Download/PreviewsReducerDownloadTests.swift | 4 ++-- .../Tests/Download/ReadingReducerDownloadTests.swift | 4 ++-- .../Tests/Download/ReadingReducerLocalTests.swift | 2 +- 18 files changed, 41 insertions(+), 32 deletions(-) diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index c346fd92e..b4ea3970c 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -159,7 +159,7 @@ private extension DetailReducerDownloadTests { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } @@ -171,6 +171,8 @@ private extension DetailReducerDownloadTests { $0.downloadClient.retry = { _, _ in } $0.downloadClient.delete = { _ in } $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { _ in [:] } + $0.downloadClient.fetchVersionMetadata = { _, _ in nil } $0.downloadClient.fetchFolders = { folders() } $0.hapticsClient = .noop $0.databaseClient = .noop diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift index 1579738ff..2acf0580b 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -129,7 +129,7 @@ private extension DetailReducerMetadataTests { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } @@ -148,6 +148,7 @@ private extension DetailReducerMetadataTests { $0.downloadClient.retry = { _, _ in } $0.downloadClient.delete = { _ in } $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { _ in [:] } $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop @@ -169,7 +170,7 @@ private extension DetailReducerMetadataTests { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index 19cf505cd..0dd1cff7d 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -133,7 +133,7 @@ private extension DetailReducerMetadataUpdateTests { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } @@ -161,7 +161,7 @@ private extension DetailReducerMetadataUpdateTests { } func makeDeleteTestClient(download: DownloadedGallery) -> DownloadClient { - var client = DownloadClient.noop + var client = DownloadClient() client.observeDownloads = { AsyncStream { continuation in continuation.finish() } } diff --git a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift index f5282fd2d..10716989a 100644 --- a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift @@ -162,7 +162,7 @@ private extension DetailReducerObserveTests { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { stream } $0.downloadClient.fetchDownloads = { [] } $0.downloadClient.fetchDownload = { _ in nil } @@ -172,6 +172,9 @@ private extension DetailReducerObserveTests { $0.downloadClient.retry = { _, _ in } $0.downloadClient.delete = { _ in } $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { _ in [:] } + $0.downloadClient.fetchVersionMetadata = { _, _ in nil } + $0.downloadClient.fetchFolders = { [] } $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop @@ -182,7 +185,7 @@ private extension DetailReducerObserveTests { func makeLocalManifestClient( download: DownloadedGallery, manifest: DownloadManifest ) -> DownloadClient { - var client = DownloadClient.noop + var client = DownloadClient() client.observeDownloads = { AsyncStream { continuation in continuation.finish() } } @@ -201,7 +204,7 @@ private extension DetailReducerObserveTests { } func makeNoManifestClient() -> DownloadClient { - var client = DownloadClient.noop + var client = DownloadClient() client.observeDownloads = { AsyncStream { continuation in continuation.finish() } } diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index 9581fe1a7..c0235feaf 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -64,7 +64,7 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() @@ -153,7 +153,7 @@ private extension DetailReducerPauseAndGuardTests { initialState: initialState, reducer: DetailReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } $0.downloadClient.fetchDownloads = { [] } $0.downloadClient.fetchDownload = { _ in pausedDownload } @@ -165,6 +165,8 @@ private extension DetailReducerPauseAndGuardTests { $0.downloadClient.retry = { _, _ in } $0.downloadClient.delete = { _ in } $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { _ in [:] } + $0.downloadClient.fetchVersionMetadata = { _, _ in nil } $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop diff --git a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift index 9af9c0018..dc06d93d9 100644 --- a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift @@ -24,7 +24,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { withDependencies: { $0.appLaunchAutomationClient = .none $0.cookieClient = .noop - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { .init { $0.finish() } } $0.downloadClient.fetchDownloads = { [] } $0.downloadClient.fetchDownload = { _ in nil } diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift index 3e0892848..379257051 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -341,7 +341,7 @@ private extension DownloadInspectorLoadTests { initialState: initialState, reducer: DownloadInspectorReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } diff --git a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift index 1460940cc..ff813ae5c 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -135,7 +135,7 @@ private extension DownloadInspectorRetryTests { initialState: initialState, reducer: DownloadInspectorReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } diff --git a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift index 2b4b36890..107b32891 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift @@ -30,7 +30,7 @@ struct DownloadInspectorSkipTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadInspectorReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 9e0a8865b..25319bdf2 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -29,7 +29,7 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadInspectorReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.yield([download]) diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index 8097ce26d..ee36f7dce 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -176,7 +176,7 @@ private extension DownloadObserverReadingTests { $0.cookieClient = .noop $0.databaseClient = .noop $0.deviceClient = .noop - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { stream } $0.downloadClient.fetchDownloads = { [] } $0.downloadClient.fetchDownload = { _ in nil } @@ -210,7 +210,7 @@ private extension DownloadObserverReadingTests { initialState: initialState, reducer: PreviewsReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { stream } $0.downloadClient.fetchDownloads = { [] } $0.downloadClient.fetchDownload = { _ in nil } diff --git a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift index 075432430..ea07f68ce 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -144,7 +144,7 @@ private extension DownloadObserverRefreshTests { stream: AsyncStream<[DownloadedGallery]>, loadLocalPageURLs: @escaping @Sendable (String) async throws -> [Int: URL] ) -> DownloadClient { - var client = DownloadClient.noop + var client = DownloadClient() client.observeDownloads = { stream } client.fetchDownloads = { [] } client.fetchDownload = { _ in nil } diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift index 3b3c02979..8a05a5b12 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -93,7 +93,7 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { initialState: DownloadsReducer.State(), reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() @@ -141,7 +141,7 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() @@ -185,7 +185,7 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() @@ -232,7 +232,7 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() @@ -277,7 +277,7 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() diff --git a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift index 181048565..3327a9c0f 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift @@ -27,7 +27,7 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { initialState: initialState, reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() @@ -64,7 +64,7 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { initialState: DownloadsReducer.State(), reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() @@ -83,6 +83,7 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { $0.downloadClient.retry = { _, _ in } $0.downloadClient.delete = { _ in } $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.fetchFolders = { [] } } ) @@ -106,7 +107,7 @@ struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { initialState: DownloadsReducer.State(), reducer: DownloadsReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() diff --git a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift index f6b471c58..4981e4efc 100644 --- a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift +++ b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift @@ -323,7 +323,7 @@ private extension FolderManagerReducerTests { initialState: FolderManagerReducer.State(), reducer: FolderManagerReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } diff --git a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift index 817d89cd4..af1266733 100644 --- a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -94,7 +94,7 @@ private extension PreviewsReducerDownloadTests { initialState: initialState, reducer: PreviewsReducer.init, withDependencies: { - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } $0.downloadClient.fetchDownloads = { [download] } $0.downloadClient.fetchDownload = { gid in gid == download.gid ? download : nil } @@ -122,7 +122,7 @@ private extension PreviewsReducerDownloadTests { } else { loadLocalPageURLsResult = { _ in throw AppError.notFound } } - var client = DownloadClient.noop + var client = DownloadClient() client.observeDownloads = { AsyncStream { continuation in continuation.finish() } } client.fetchDownloads = { [] } client.fetchDownload = { _ in nil } diff --git a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift index 462ec27ca..dce552839 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -134,7 +134,7 @@ private extension ReadingReducerDownloadTests { $0.cookieClient = .noop $0.databaseClient = .noop $0.deviceClient = .noop - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { $0.yield([]); $0.finish() } } $0.downloadClient.fetchDownloads = { [] } $0.downloadClient.fetchDownload = { _ in nil } @@ -170,7 +170,7 @@ private extension ReadingReducerDownloadTests { $0.cookieClient = .noop $0.databaseClient = .noop $0.deviceClient = .noop - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { $0.finish() } } $0.downloadClient.fetchDownloads = { [] } $0.downloadClient.fetchDownload = { _ in nil } diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift index b704ea234..caa71a4d0 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -46,7 +46,7 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { $0.cookieClient = .noop $0.databaseClient = .noop $0.deviceClient = .noop - $0.downloadClient = .noop + $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() From 6b9fb4e497845fe9317ed3959a74dc34ff6fea7b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 16:31:18 +0800 Subject: [PATCH 211/614] Extract download observer hub --- .../Clients/DownloadClient+Manager.swift | 36 ++++++++- .../Clients/DownloadClient+PublicAPI.swift | 16 +--- .../Clients/DownloadClient+Scheduling.swift | 20 +---- .../Download/DownloadObserverBatchTests.swift | 76 +++++++++++++++++++ 4 files changed, 115 insertions(+), 33 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 0eb5be5d4..d59fc4d11 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -142,6 +142,7 @@ actor DownloadManager { /// a gallery is queued apply to the eventual detail fetch and page workers. let downloadOptionsProvider: @Sendable () async -> DownloadRequestOptions let queueStore: DownloadQueueStore + let observerHub = DownloadObserverHub() var downloadIndex = [String: DownloadFolderRecord]() var hasLoadedIndex = false var userFolders = [String]() @@ -151,8 +152,6 @@ actor DownloadManager { var updatedGalleryIDs = Set() var queuedModes = [String: DownloadStartMode]() var queuedPageSelections = [String: [Int]]() - var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() - var lastObservedDownloads = [DownloadedGallery]() var activeGalleryID: String? var activeTask: Task? var schedulingBlockedGalleryIDs = Set() @@ -188,6 +187,39 @@ actor DownloadManager { } } +actor DownloadObserverHub { + private var lastObservedDownloads = [DownloadedGallery]() + private var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() + + func observe( + initialDownloads: [DownloadedGallery] + ) -> AsyncStream<[DownloadedGallery]> { + let identifier = UUID() + let (stream, continuation) = AsyncStream.makeStream( + of: [DownloadedGallery].self + ) + observers[identifier] = continuation + continuation.yield(initialDownloads) + continuation.onTermination = { [weak self] _ in + guard let self else { return } + Task { + await self.removeObserver(id: identifier) + } + } + return stream + } + + func notify(_ downloads: [DownloadedGallery]) { + guard downloads != lastObservedDownloads else { return } + lastObservedDownloads = downloads + observers.values.forEach { $0.yield(downloads) } + } + + private func removeObserver(id: UUID) { + observers[id] = nil + } +} + extension DownloadManager { func clearDownloadFailureState( gid: String, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index c70e7306d..2a634037a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -7,19 +7,9 @@ import Foundation // MARK: - Public API extension DownloadManager { - func observeDownloads() -> AsyncStream<[DownloadedGallery]> { - let identifier = UUID() - return AsyncStream { continuation in - continuation.onTermination = { [weak self] _ in - guard let self else { return } - Task { - await self.removeObserver(id: identifier) - } - } - Task { - await self.addObserver(id: identifier, continuation: continuation) - } - } + func observeDownloads() async -> AsyncStream<[DownloadedGallery]> { + let downloads = await indexedDownloads() + return await observerHub.observe(initialDownloads: downloads) } func fetchDownloads() async -> [DownloadedGallery] { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 4fc239022..db3e18948 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -7,25 +7,9 @@ import Foundation // MARK: - Observer Management & Scheduling extension DownloadManager { - func addObserver( - id: UUID, - continuation: AsyncStream<[DownloadedGallery]>.Continuation - ) async { - observers[id] = continuation - let downloads = await fetchDownloads() - lastObservedDownloads = downloads - continuation.yield(downloads) - } - - func removeObserver(id: UUID) { - observers[id] = nil - } - func notifyObservers() async { - let downloads = await fetchDownloads() - guard downloads != lastObservedDownloads else { return } - lastObservedDownloads = downloads - observers.values.forEach { $0.yield(downloads) } + let downloads = await indexedDownloads() + await observerHub.notify(downloads) } func scheduleNextIfNeeded() async { diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 25319bdf2..24b24e640 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -144,4 +144,80 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { #expect(emissionCount < pageCount) #expect(emissionCount <= 2 + Int(ceil(Double(pageCount) / 8.0))) } + + @Test + func testObserverHubBroadcastIsNotSuppressedByLateObserverInitialSnapshot() async throws { + let hub = DownloadObserverHub() + let initialDownload = sampleDownload( + gid: "observer-race", + title: "Observer Race", + status: .queued, + completedPageCount: 0 + ) + let updatedDownload = sampleDownload( + gid: initialDownload.gid, + title: initialDownload.title, + status: .completed, + completedPageCount: initialDownload.pageCount + ) + + await hub.notify([initialDownload]) + let existingObserverStream = await hub.observe(initialDownloads: [initialDownload]) + let existingObserverTask = collectEmissions( + from: existingObserverStream, + count: 2 + ) + + let lateObserverStream = await hub.observe(initialDownloads: [updatedDownload]) + let lateObserverTask = collectEmissions( + from: lateObserverStream, + count: 1 + ) + let lateObserverEmissions = try await waitForTaskValue( + lateObserverTask, + timeout: .seconds(1), + description: "late observer initial snapshot" + ) + #expect(lateObserverEmissions == [[updatedDownload]]) + + await hub.notify([updatedDownload]) + + let existingObserverEmissions = try await waitForTaskValue( + existingObserverTask, + timeout: .seconds(1), + description: "existing observer update after late observer registration" + ) + #expect(existingObserverEmissions == [[initialDownload], [updatedDownload]]) + } + + @Test + func testObserverHubRegistersObserverBeforeReturningStream() async { + let hub = DownloadObserverHub() + let initialDownload = sampleDownload( + gid: "observer-registration", + title: "Observer Registration", + status: .completed + ) + let stream = await hub.observe(initialDownloads: [initialDownload]) + let observerTask = collectEmissions(from: stream, count: 1) + + let emissions = await observerTask.value + #expect(emissions == [[initialDownload]]) + } +} + +private func collectEmissions( + from stream: AsyncStream<[DownloadedGallery]>, + count: Int +) -> Task<[[DownloadedGallery]], Never> { + Task { + var emissions = [[DownloadedGallery]]() + for await downloads in stream { + emissions.append(downloads) + if emissions.count == count { + break + } + } + return emissions + } } From 12eb76d6baf7bcdd3e65d4261c0a680cf5975106 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 16:35:07 +0800 Subject: [PATCH 212/614] Route active pause before queued intent --- .../Clients/DownloadClient+PublicAPI.swift | 4 ++ .../DownloadPauseAndReconcileTests.swift | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 2a634037a..bfc605f4e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -138,6 +138,10 @@ extension DownloadManager { return .failure(.notFound) } + if activeGalleryID == gid { + return await pause(gid: gid) + } + if let queuedMode = queuedModes[gid] { return await cancelQueuedWorkItem(download, mode: queuedMode) } diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index cbb6567b8..dae35f7bf 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -73,6 +73,48 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { #expect(activeGalleryID == nil) } + @Test + func testTogglePausePausesActiveRetryBeforeClearingQueuedIntent() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 10) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + + try writeManifestFolder( + storage: storage, + gid: gid, + title: "Active Repair", + pageHashes: ["sha256:done", ""] + ) + await manager.reloadDownloadIndex() + + let activeTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + } + await manager.testingInstallActiveTask(gid: gid, task: activeTask) + + guard case .success = await manager.retry(gid: gid, mode: .repair) else { + Issue.record("Retry should set the active gallery's queued intent.") + return + } + guard case .success = await manager.togglePause(gid: gid) else { + Issue.record("First pause tap should pause active retry work.") + return + } + + let stored = await manager.testingFetchDownload(gid: gid) + let activeGalleryID = await manager.testingActiveGalleryID() + let hasActiveTask = await manager.testingHasActiveTask() + #expect(stored?.displayStatus == .inactive) + #expect(activeGalleryID == nil) + #expect(!hasActiveTask) + } + @Test func testPauseKeepsIndexedManifestProgressWhenCancelling() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 1) From 09cca6652109b8764bea9924be0392d199c1f3c8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 16:40:29 +0800 Subject: [PATCH 213/614] Guard active task cleanup ownership --- .../Clients/DownloadClient+Execution.swift | 43 ++++- .../Clients/DownloadClient+Manager.swift | 1 + .../Clients/DownloadClient+Scheduling.swift | 21 ++- .../Clients/DownloadClient+Testing.swift | 1 + .../Download/DownloadSchedulingTests.swift | 150 ++++++++++++++++++ 5 files changed, 205 insertions(+), 11 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 3854c5cd3..4a2fa6b44 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -7,13 +7,16 @@ import Foundation // MARK: - Process Download extension DownloadManager { - func processDownload(gid: String) async { + func processDownload( + gid: String, + generation: Int? = nil + ) async { defer { - activeTask = nil - activeGalleryID = nil - Task { - await self.scheduleNextIfNeeded() - } + finishActiveTaskIfOwned( + gid: gid, + generation: generation, + schedulesNext: true + ) } guard let download = await fetchDownload(gid: gid) else { @@ -236,4 +239,32 @@ extension DownloadManager { clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) } + + func finishActiveTaskIfOwned( + gid: String, + generation: Int?, + schedulesNext: Bool + ) { + guard isActiveTaskOwner(gid: gid, generation: generation) else { + return + } + activeTask = nil + activeGalleryID = nil + guard schedulesNext else { return } + Task { + await self.scheduleNextIfNeeded() + } + } + + private func isActiveTaskOwner( + gid: String, + generation: Int? + ) -> Bool { + if let generation { + return activeGalleryID == gid + && activeTaskGeneration == generation + } + guard activeTask == nil else { return false } + return activeGalleryID == nil || activeGalleryID == gid + } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index d59fc4d11..6b2ec38e9 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -154,6 +154,7 @@ actor DownloadManager { var queuedPageSelections = [String: [Int]]() var activeGalleryID: String? var activeTask: Task? + var activeTaskGeneration = 0 var schedulingBlockedGalleryIDs = Set() #if DEBUG var testingPersistFailureHook: (@Sendable () async -> Void)? diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index db3e18948..82e454d0e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -37,25 +37,36 @@ extension DownloadManager { #if DEBUG testingScheduledGalleryIDHistory.append(nextDownload.gid) #endif + activeTaskGeneration += 1 + let generation = activeTaskGeneration activeGalleryID = nextDownload.gid activeTask = Task { [weak self] in guard let self else { return } - await self.processScheduledDownload(gid: nextDownload.gid) + await self.processScheduledDownload( + gid: nextDownload.gid, + generation: generation + ) } } - private func processScheduledDownload(gid: String) async { + private func processScheduledDownload( + gid: String, + generation: Int + ) async { #if DEBUG if let testingScheduledProcessHook { defer { - activeTask = nil - activeGalleryID = nil + finishActiveTaskIfOwned( + gid: gid, + generation: generation, + schedulesNext: false + ) } await testingScheduledProcessHook(gid) return } #endif - await processDownload(gid: gid) + await processDownload(gid: gid, generation: generation) } private func nextQueuedDownload( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index a7ecddff7..695d94f68 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -11,6 +11,7 @@ extension DownloadManager { gid: String, task: Task ) { + activeTaskGeneration += 1 activeGalleryID = gid activeTask = task } diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index 566b52341..ee4811140 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -85,6 +85,98 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { return } } + + @Test + func testCancelledProcessCleanupDoesNotClearNewerActiveTask() async throws { + let firstGID = "100011" + let secondGID = "100012" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage( + rootURL: rootURL, + fileManager: .default + ) + let manager = DownloadManager( + storage: storage, + urlSession: .shared + ) + let gate = ScheduledProcessCleanupGate(firstGID: firstGID) + await manager.testingSetScheduledProcessHook { gid in + await gate.run(gid: gid) + } + + try writeQueuedManifest(storage: storage, gid: firstGID, title: "First") + try writeQueuedManifest(storage: storage, gid: secondGID, title: "Second") + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([firstGID, secondGID]) + + await manager.testingScheduleNextIfNeeded() + await gate.waitForFirstArrival() + + let pauseTask = Task { + await manager.pause(gid: firstGID) + } + try await waitForActiveGalleryID(manager, toEqual: nil) + + await manager.testingScheduleNextIfNeeded() + await gate.waitForSecondStart() + await gate.releaseFirst() + + guard case .success = await pauseTask.value else { + Issue.record("Pause should succeed for the canceled first download.") + return + } + + let activeGalleryID = await manager.testingActiveGalleryID() + let hasActiveTask = await manager.testingHasActiveTask() + #expect(activeGalleryID == secondGID) + #expect(hasActiveTask) + + guard case .success = await manager.pause(gid: secondGID) else { + Issue.record("Cleanup pause should succeed for the second download.") + return + } + await manager.testingSetScheduledProcessHook(nil) + } +} + +private extension DownloadSchedulingTests { + func writeQueuedManifest( + storage: DownloadFileStorage, + gid: String, + title: String + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: title), + folderURL: folderURL + ) + } + + func waitForActiveGalleryID( + _ manager: DownloadManager, + toEqual expected: String?, + timeout: Duration = .seconds(1) + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while await manager.testingActiveGalleryID() != expected, + clock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + try #require( + await manager.testingActiveGalleryID() == expected, + "Timed out waiting for activeGalleryID to become \(String(describing: expected))." + ) + } } private actor ScheduleFetchGate { @@ -115,3 +207,61 @@ private actor ScheduleFetchGate { releaseContinuations.removeAll() } } + +private actor ScheduledProcessCleanupGate { + private let firstGID: String + private var firstArrived = false + private var secondStarted = false + private var firstArrivalContinuation: CheckedContinuation? + private var secondStartContinuation: CheckedContinuation? + private var releaseFirstContinuation: CheckedContinuation? + + init(firstGID: String) { + self.firstGID = firstGID + } + + func run(gid: String) async { + if gid == firstGID { + await waitForRelease() + } else { + startSecond() + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + } + } + + func waitForFirstArrival() async { + guard !firstArrived else { return } + await withCheckedContinuation { continuation in + firstArrivalContinuation = continuation + } + } + + func waitForSecondStart() async { + guard !secondStarted else { return } + await withCheckedContinuation { continuation in + secondStartContinuation = continuation + } + } + + func releaseFirst() { + releaseFirstContinuation?.resume() + releaseFirstContinuation = nil + } + + private func waitForRelease() async { + firstArrived = true + firstArrivalContinuation?.resume() + firstArrivalContinuation = nil + await withCheckedContinuation { continuation in + releaseFirstContinuation = continuation + } + } + + private func startSecond() { + secondStarted = true + secondStartContinuation?.resume() + secondStartContinuation = nil + } +} From aa8e63cb437dd7f510994dc7a91907d9cf843af1 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 16:50:43 +0800 Subject: [PATCH 214/614] Replace download scheduling debug hooks --- .../Clients/DownloadClient+Manager.swift | 46 ++++++++++++++--- .../Clients/DownloadClient+Persistence.swift | 6 +-- .../Clients/DownloadClient+Scheduling.swift | 31 ++++------- .../Clients/DownloadClient+Testing.swift | 22 -------- .../DownloadFeatureTestFactories.swift | 6 ++- .../DownloadFeatureTestSupportTypes.swift | 12 +++++ .../DownloadManagerStorageTests.swift | 22 +++++--- .../Tests/Download/DownloadProcessTests.swift | 25 +++++---- .../Download/DownloadSchedulingTests.swift | 51 +++++++++++-------- 9 files changed, 126 insertions(+), 95 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 6b2ec38e9..ee60278ff 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -5,6 +5,41 @@ import Foundation +typealias ScheduledDownloadOperation = @Sendable () async -> Void + +enum ScheduledDownloadRunResult: Equatable, Sendable { + case ranOperation + case skippedOperation +} + +struct DownloadTaskRunner: Sendable { + var beforeActiveTaskCheck: @Sendable () async -> Void + var recordScheduledGallery: @Sendable (String) async -> Void + var runScheduledDownload: @Sendable ( + String, + @escaping ScheduledDownloadOperation + ) async -> ScheduledDownloadRunResult + var beforeFailurePersistence: @Sendable () async -> Void + + init( + beforeActiveTaskCheck: @escaping @Sendable () async -> Void = {}, + recordScheduledGallery: @escaping @Sendable (String) async -> Void = { _ in }, + runScheduledDownload: @escaping @Sendable ( + String, + @escaping ScheduledDownloadOperation + ) async -> ScheduledDownloadRunResult = { _, operation in + await operation() + return .ranOperation + }, + beforeFailurePersistence: @escaping @Sendable () async -> Void = {} + ) { + self.beforeActiveTaskCheck = beforeActiveTaskCheck + self.recordScheduledGallery = recordScheduledGallery + self.runScheduledDownload = runScheduledDownload + self.beforeFailurePersistence = beforeFailurePersistence + } +} + actor DownloadManager { static let retryLimit = 3 static let progressFlushPageInterval = 8 @@ -142,6 +177,7 @@ actor DownloadManager { /// a gallery is queued apply to the eventual detail fetch and page workers. let downloadOptionsProvider: @Sendable () async -> DownloadRequestOptions let queueStore: DownloadQueueStore + let taskRunner: DownloadTaskRunner let observerHub = DownloadObserverHub() var downloadIndex = [String: DownloadFolderRecord]() var hasLoadedIndex = false @@ -156,12 +192,6 @@ actor DownloadManager { var activeTask: Task? var activeTaskGeneration = 0 var schedulingBlockedGalleryIDs = Set() -#if DEBUG - var testingPersistFailureHook: (@Sendable () async -> Void)? - var testingScheduleBeforeActiveCheckHook: (@Sendable () async -> Void)? - var testingScheduledProcessHook: (@Sendable (String) async -> Void)? - var testingScheduledGalleryIDHistory = [String]() -#endif init( storage: DownloadFileStorage, @@ -173,7 +203,8 @@ actor DownloadManager { downloadOptionsProvider: @escaping @Sendable () async -> DownloadRequestOptions = { DownloadRequestOptions() }, - queueStore: DownloadQueueStore? = nil + queueStore: DownloadQueueStore? = nil, + taskRunner: DownloadTaskRunner = .init() ) { self.storage = storage self.urlSession = urlSession @@ -181,6 +212,7 @@ actor DownloadManager { self.libraryClient = libraryClient self.downloadOptionsProvider = downloadOptionsProvider self.queueStore = queueStore ?? DownloadQueueStore(fileURL: storage.queueURL()) + self.taskRunner = taskRunner } var fileManager: DownloadFileManager { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index e7c7c2154..25798f958 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -167,11 +167,7 @@ extension DownloadManager { error: AppError, context: FailureContext ) async { -#if DEBUG - if let testingPersistFailureHook { - await testingPersistFailureHook() - } -#endif + await taskRunner.beforeFailurePersistence() downloadErrors[context.gid] = DownloadFailure(error: error) clearDownloadQueueIntent(gid: context.gid) await queueStore.remove(context.gid) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 82e454d0e..4fa35a2fa 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -17,11 +17,7 @@ extension DownloadManager { let downloads = queuedGIDs.isEmpty ? await indexedDownloads() : await indexedDownloads(gids: queuedGIDs) -#if DEBUG - if let testingScheduleBeforeActiveCheckHook { - await testingScheduleBeforeActiveCheckHook() - } -#endif + await taskRunner.beforeActiveTaskCheck() guard activeTask == nil else { await reconcileActiveDownloadState() return @@ -34,9 +30,7 @@ extension DownloadManager { ) guard let nextDownload else { return } -#if DEBUG - testingScheduledGalleryIDHistory.append(nextDownload.gid) -#endif + await taskRunner.recordScheduledGallery(nextDownload.gid) activeTaskGeneration += 1 let generation = activeTaskGeneration activeGalleryID = nextDownload.gid @@ -53,20 +47,15 @@ extension DownloadManager { gid: String, generation: Int ) async { -#if DEBUG - if let testingScheduledProcessHook { - defer { - finishActiveTaskIfOwned( - gid: gid, - generation: generation, - schedulesNext: false - ) - } - await testingScheduledProcessHook(gid) - return + let result = await taskRunner.runScheduledDownload(gid) { + await self.processDownload(gid: gid, generation: generation) } -#endif - await processDownload(gid: gid, generation: generation) + guard result == .skippedOperation else { return } + finishActiveTaskIfOwned( + gid: gid, + generation: generation, + schedulesNext: false + ) } private func nextQueuedDownload( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 695d94f68..677a5eda1 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -24,28 +24,6 @@ extension DownloadManager { await scheduleNextIfNeeded() } - func testingSetScheduleBeforeActiveCheckHook( - _ hook: (@Sendable () async -> Void)? - ) { - testingScheduleBeforeActiveCheckHook = hook - } - - func testingSetPersistFailureHook( - _ hook: (@Sendable () async -> Void)? - ) { - testingPersistFailureHook = hook - } - - func testingSetScheduledProcessHook( - _ hook: (@Sendable (String) async -> Void)? - ) { - testingScheduledProcessHook = hook - } - - func testingScheduledGalleryIDs() -> [String] { - testingScheduledGalleryIDHistory - } - func testingSetQueuedGalleryIDs(_ gids: [String]) async { await queueStore.removeAll() for gid in gids { diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 7fc49cb8b..90171e123 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -336,7 +336,8 @@ extension DownloadFeatureTestCase { sessionID: String, downloadOptionsProvider: @escaping @Sendable () async -> DownloadRequestOptions = { DownloadRequestOptions() - } + }, + taskRunner: DownloadTaskRunner = .init() ) -> (DownloadFileStorage, DownloadManager) { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] @@ -349,7 +350,8 @@ extension DownloadFeatureTestCase { let manager = DownloadManager( storage: storage, urlSession: URLSession(configuration: configuration), - downloadOptionsProvider: downloadOptionsProvider + downloadOptionsProvider: downloadOptionsProvider, + taskRunner: taskRunner ) return (storage, manager) } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift index b853a50a6..b7ac5a70c 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift @@ -67,6 +67,18 @@ final class RequestRecorder: Sendable { } } +final class ScheduledGalleryRecorder: Sendable { + private let state = Mutex([String]()) + + func record(_ gid: String) { + state.withLock { $0.append(gid) } + } + + func snapshot() -> [String] { + state.withLock { $0 } + } +} + func requestBodyData(from request: URLRequest) -> Data? { if let httpBody = request.httpBody { return httpBody diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index b24070d01..73aa7609c 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -684,16 +684,24 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let scheduledRecorder = ScheduledGalleryRecorder() + let taskRunner = DownloadTaskRunner( + recordScheduledGallery: { gid in + scheduledRecorder.record(gid) + }, + runScheduledDownload: { _, _ in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + return .skippedOperation + } + ) let manager = DownloadManager( storage: storage, urlSession: .shared, - queueStore: queueStore + queueStore: queueStore, + taskRunner: taskRunner ) - await manager.testingSetScheduledProcessHook { _ in - while !Task.isCancelled { - try? await Task.sleep(for: .milliseconds(10)) - } - } try storage.ensureRootDirectory() try writeIndexedManifest( @@ -722,7 +730,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { await manager.testingScheduleNextIfNeeded() - let scheduledGalleryIDs = await manager.testingScheduledGalleryIDs() + let scheduledGalleryIDs = scheduledRecorder.snapshot() #expect(scheduledGalleryIDs == ["830"]) #expect(await manager.testingActiveGalleryID() == "830") diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index ddf1feaa4..e850ab4c3 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -17,9 +17,20 @@ struct DownloadProcessTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } + let persistenceGate = FailurePersistenceGate() + let scheduledRecorder = ScheduledGalleryRecorder() + let taskRunner = DownloadTaskRunner( + recordScheduledGallery: { gid in + scheduledRecorder.record(gid) + }, + beforeFailurePersistence: { + await persistenceGate.waitAtGate() + } + ) let (storage, manager) = makeStubbedDownloadManager( rootURL: rootURL, - sessionID: sessionID + sessionID: sessionID, + taskRunner: taskRunner ) SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in throw URLError(.notConnectedToInternet) @@ -35,11 +46,6 @@ struct DownloadProcessTests: DownloadFeatureTestCase { await manager.reloadDownloadIndex() await manager.testingSetQueuedGalleryIDs([gid]) - let persistenceGate = FailurePersistenceGate() - await manager.testingSetPersistFailureHook { - await persistenceGate.waitAtGate() - } - let completionProbe = ProcessCompletionProbe() let processTask = Task { await manager.testingProcessDownload(gid: gid) @@ -49,22 +55,19 @@ struct DownloadProcessTests: DownloadFeatureTestCase { await persistenceGate.waitForArrival() let completedBeforePersistence = await completionProbe .isFinished() - let scheduledBeforePersistence = await manager - .testingScheduledGalleryIDs() + let scheduledBeforePersistence = scheduledRecorder.snapshot() #expect(completedBeforePersistence == false) #expect(scheduledBeforePersistence.isEmpty) await persistenceGate.release() await processTask.value - await manager.testingSetPersistFailureHook(nil) let stored = await manager.testingFetchDownload(gid: gid) #expect(stored?.displayStatus == .error) #expect(stored?.lastError?.code == .networkingFailed) await manager.testingScheduleNextIfNeeded() - let scheduledAfterFailure = await manager - .testingScheduledGalleryIDs() + let scheduledAfterFailure = scheduledRecorder.snapshot() #expect(scheduledAfterFailure.isEmpty) } diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index ee4811140..f3346d4f6 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -20,15 +20,27 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { rootURL: rootURL, fileManager: .default ) + let gate = ScheduleFetchGate() + let scheduledRecorder = ScheduledGalleryRecorder() + let taskRunner = DownloadTaskRunner( + beforeActiveTaskCheck: { + await gate.waitAtGate() + }, + recordScheduledGallery: { gid in + scheduledRecorder.record(gid) + }, + runScheduledDownload: { _, _ in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + return .skippedOperation + } + ) let manager = DownloadManager( storage: storage, - urlSession: .shared + urlSession: .shared, + taskRunner: taskRunner ) - await manager.testingSetScheduledProcessHook { _ in - while !Task.isCancelled { - try? await Task.sleep(for: .milliseconds(10)) - } - } try storage.ensureRootDirectory() let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Queued") @@ -57,11 +69,6 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { await manager.reloadDownloadIndex() await manager.testingSetQueuedGalleryIDs([gid]) - let gate = ScheduleFetchGate() - await manager.testingSetScheduleBeforeActiveCheckHook { - await gate.waitAtGate() - } - async let firstSchedule: Void = manager.testingScheduleNextIfNeeded() async let secondSchedule: Void = @@ -70,10 +77,8 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { await gate.waitForBothArrivals() await gate.releaseAll() _ = await (firstSchedule, secondSchedule) - await manager.testingSetScheduleBeforeActiveCheckHook(nil) - let scheduledGalleryIDs = await manager - .testingScheduledGalleryIDs() + let scheduledGalleryIDs = scheduledRecorder.snapshot() let hasActiveTask = await manager.testingHasActiveTask() let activeGalleryID = await manager.testingActiveGalleryID() #expect(scheduledGalleryIDs.count == 1) @@ -98,14 +103,18 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { rootURL: rootURL, fileManager: .default ) + let gate = ScheduledProcessCleanupGate(firstGID: firstGID) + let taskRunner = DownloadTaskRunner( + runScheduledDownload: { gid, _ in + await gate.run(gid: gid) + return .skippedOperation + } + ) let manager = DownloadManager( storage: storage, - urlSession: .shared + urlSession: .shared, + taskRunner: taskRunner ) - let gate = ScheduledProcessCleanupGate(firstGID: firstGID) - await manager.testingSetScheduledProcessHook { gid in - await gate.run(gid: gid) - } try writeQueuedManifest(storage: storage, gid: firstGID, title: "First") try writeQueuedManifest(storage: storage, gid: secondGID, title: "Second") @@ -138,7 +147,6 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { Issue.record("Cleanup pause should succeed for the second download.") return } - await manager.testingSetScheduledProcessHook(nil) } } @@ -181,10 +189,12 @@ private extension DownloadSchedulingTests { private actor ScheduleFetchGate { private var arrivalCount = 0 + private var isReleased = false private var bothArrivedContinuation: CheckedContinuation? private var releaseContinuations = [CheckedContinuation]() func waitAtGate() async { + guard !isReleased else { return } arrivalCount += 1 if arrivalCount == 2 { bothArrivedContinuation?.resume() @@ -203,6 +213,7 @@ private actor ScheduleFetchGate { } func releaseAll() { + isReleased = true releaseContinuations.forEach { $0.resume() } releaseContinuations.removeAll() } From b5d1f47d758a304c52b22f74cf944382f2c6062e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 16:54:57 +0800 Subject: [PATCH 215/614] Name download store boundary --- EhPanda/App/Tools/Clients/DownloadClient+Manager.swift | 4 ++-- EhPanda/App/Tools/Utilities/DownloadFileStorage.swift | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index ee60278ff..b5070ef31 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -167,7 +167,7 @@ actor DownloadManager { let existingDownload: DownloadedGallery } - let storage: DownloadFileStorage + let storage: DownloadStore let urlSession: URLSession let storedCookiesProvider: @Sendable (URL) -> [HTTPCookie] let libraryClient: LibraryClient @@ -194,7 +194,7 @@ actor DownloadManager { var schedulingBlockedGalleryIDs = Set() init( - storage: DownloadFileStorage, + storage: DownloadStore, urlSession: URLSession, storedCookiesProvider: @escaping @Sendable (URL) -> [HTTPCookie] = { HTTPCookieStorage.shared.cookies(for: $0) ?? [] diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 9c2148dd4..c6d16c21b 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -26,7 +26,9 @@ struct DownloadScanResult: Equatable, Sendable { let userFolders: [String] } -struct DownloadFileStorage: Sendable { +typealias DownloadFileStorage = DownloadStore + +struct DownloadStore: Sendable { private static let maxFolderComponentByteCount = 255 let rootURL: URL From 2bdc01d4c8710d6bf589f3311c3f767bea8d3177 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 16:57:11 +0800 Subject: [PATCH 216/614] Name download coordinator actor --- EhPanda/App/Tools/Clients/DownloadClient+Cache.swift | 2 +- EhPanda/App/Tools/Clients/DownloadClient+Execution.swift | 2 +- .../App/Tools/Clients/DownloadClient+ExecutionFetch.swift | 2 +- .../App/Tools/Clients/DownloadClient+ExecutionPerform.swift | 2 +- .../App/Tools/Clients/DownloadClient+ExecutionSupport.swift | 2 +- EhPanda/App/Tools/Clients/DownloadClient+Folders.swift | 2 +- EhPanda/App/Tools/Clients/DownloadClient+Manager.swift | 6 ++++-- EhPanda/App/Tools/Clients/DownloadClient+Networking.swift | 4 ++-- EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift | 2 +- .../Tools/Clients/DownloadClient+PageDownloadHelpers.swift | 2 +- EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift | 6 +++--- .../Tools/Clients/DownloadClient+PersistenceHelpers.swift | 2 +- .../Tools/Clients/DownloadClient+PersistenceNormalize.swift | 2 +- EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift | 2 +- .../App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift | 2 +- .../Tools/Clients/DownloadClient+ResponseValidation.swift | 2 +- .../Clients/DownloadClient+ResponseValidationHelpers.swift | 2 +- EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift | 2 +- EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift | 4 ++-- .../Tools/Clients/DownloadClient+SchedulingHelpers.swift | 2 +- EhPanda/App/Tools/Clients/DownloadClient+Testing.swift | 2 +- EhPanda/App/Tools/Clients/DownloadClient.swift | 6 +++--- 22 files changed, 31 insertions(+), 29 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 328e06d98..197045c6d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Cache Operations -extension DownloadManager { +extension DownloadCoordinator { func cacheKeys( for url: URL, includeStableAlias: Bool diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 4a2fa6b44..7d1546896 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Process Download -extension DownloadManager { +extension DownloadCoordinator { func processDownload( gid: String, generation: Int? = nil diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index efdf59c03..7c4059f90 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Fetch & Normalize Payload -extension DownloadManager { +extension DownloadCoordinator { func fetchLatestPayload( for download: DownloadedGallery, mode: DownloadStartMode, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 6d8ca042c..1e932d339 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Perform Download -extension DownloadManager { +extension DownloadCoordinator { struct PerformDownloadResult { let coverRelativePath: String? let pages: [PageResult] diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 02dd8fa50..f540fa7da 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Execution Support -extension DownloadManager { +extension DownloadCoordinator { func makeInitialManifest(payload: DownloadRequestPayload) -> DownloadManifest { let pageCount = payload.galleryDetail.pageCount let pages = pageCount > 0 diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift index e68a44632..2f2a47d3a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - User Folder Operations -extension DownloadManager { +extension DownloadCoordinator { func fetchFolders() async -> [String] { return userFolders } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index b5070ef31..c89d97a12 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -40,7 +40,9 @@ struct DownloadTaskRunner: Sendable { } } -actor DownloadManager { +typealias DownloadManager = DownloadCoordinator + +actor DownloadCoordinator { static let retryLimit = 3 static let progressFlushPageInterval = 8 static let progressFlushMinimumInterval: TimeInterval = 0.4 @@ -253,7 +255,7 @@ actor DownloadObserverHub { } } -extension DownloadManager { +extension DownloadCoordinator { func clearDownloadFailureState( gid: String, includePageFailures: Bool = true diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift index a9e76f775..d978d63b3 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Network -extension DownloadManager { +extension DownloadCoordinator { func downloadResponse( url: URL, allowsCellular: Bool, @@ -165,7 +165,7 @@ extension DownloadManager { } // MARK: - File Operations -extension DownloadManager { +extension DownloadCoordinator { func fileExtension( for url: URL, response: URLResponse?, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index 917c5af6c..fca50dd20 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Download Pages -extension DownloadManager { +extension DownloadCoordinator { private struct PageDownloadProgress { var results: [PageResult] = [] var failedPages: [Int: PageFailure?] = [:] diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift index e65b88f9d..9cab9c5db 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Download Single Page -extension DownloadManager { +extension DownloadCoordinator { func downloadPage( index: Int, context: PageDownloadContext, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 25798f958..fee0d4c00 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Disk Index -extension DownloadManager { +extension DownloadCoordinator { @discardableResult func reloadDownloadIndex() async -> [DownloadedGallery] { do { @@ -132,7 +132,7 @@ private extension DownloadFolderRecord { } // MARK: - Store Operations -extension DownloadManager { +extension DownloadCoordinator { func fetchDownload( gid: String ) async -> DownloadedGallery? { @@ -162,7 +162,7 @@ extension DownloadManager { } // MARK: - Persist Failure & Progress -extension DownloadManager { +extension DownloadCoordinator { func persistFailure( error: AppError, context: FailureContext diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index 708634760..99e1ecaf7 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Sanitization -extension DownloadManager { +extension DownloadCoordinator { @discardableResult func sanitizeLocalFilesIfNeeded( gid: String, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index a7ded62b3..15eb8af16 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Manifest, Folder & Normalize -extension DownloadManager { +extension DownloadCoordinator { func validatedManifest( at folderURL: URL, gid: String, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index bfc605f4e..07be9a36a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Public API -extension DownloadManager { +extension DownloadCoordinator { func observeDownloads() async -> AsyncStream<[DownloadedGallery]> { let downloads = await indexedDownloads() return await observerHub.observe(initialDownloads: downloads) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index 7f08e9543..37e2475e5 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Private helpers for public API -extension DownloadManager { +extension DownloadCoordinator { func buildInspectionPages( download: DownloadedGallery, activeFolderURL: URL?, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift index aa551f47e..47b153660 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift @@ -9,7 +9,7 @@ import Foundation import ImageIO // MARK: - Response Error Detection -extension DownloadManager { +extension DownloadCoordinator { func detectResponseError( data: Data, response: URLResponse, diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index 301862ceb..64aa35294 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -9,7 +9,7 @@ import Foundation import ImageIO // MARK: - Response Inspection Helpers -extension DownloadManager { +extension DownloadCoordinator { func normalizedMimeType( _ response: URLResponse ) -> String? { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index 6870e74a4..94d4ec82b 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Retry & RetryPages -extension DownloadManager { +extension DownloadCoordinator { func retry( gid: String, mode: DownloadStartMode diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 4fa35a2fa..6b3d1f99e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Observer Management & Scheduling -extension DownloadManager { +extension DownloadCoordinator { func notifyObservers() async { let downloads = await indexedDownloads() await observerHub.notify(downloads) @@ -127,7 +127,7 @@ extension DownloadManager { } // MARK: - Pause & Resume -extension DownloadManager { +extension DownloadCoordinator { func pause(gid: String) async -> Result { do { schedulingBlockedGalleryIDs.insert(gid) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index 729387ee2..5886ad08f 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -6,7 +6,7 @@ import Foundation // MARK: - Mode Resolution -extension DownloadManager { +extension DownloadCoordinator { func queuedMode( for download: DownloadedGallery ) -> DownloadStartMode { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 677a5eda1..1490987b6 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -6,7 +6,7 @@ import Foundation #if DEBUG -extension DownloadManager { +extension DownloadCoordinator { func testingInstallActiveTask( gid: String, task: Task diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index f8fd8e424..c8b3a86c4 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -38,7 +38,7 @@ extension DownloadClient { urlSession: URLSession = .shared, fileManager: sending FileManager = .default ) -> Self { - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: .init(rootURL: rootURL, fileManager: fileManager), urlSession: urlSession, downloadOptionsProvider: { @@ -53,7 +53,7 @@ extension DownloadClient { } private static func makeObserveDownloadsStream( - manager: DownloadManager + manager: DownloadCoordinator ) -> AsyncStream<[DownloadedGallery]> { AsyncStream { continuation in let task = Task { @@ -70,7 +70,7 @@ extension DownloadClient { } private static func makeDownloadClient( - manager: DownloadManager + manager: DownloadCoordinator ) -> Self { .init( observeDownloads: { makeObserveDownloadsStream(manager: manager) }, From 8ba0d32f720f99d1e2d8d7c2d0142fbe670418be Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 17:00:56 +0800 Subject: [PATCH 217/614] Collapse reader local data plane --- EhPanda/View/Reading/ReadingReducer+Database.swift | 12 +++++------- .../Download/DownloadObserverReadingTests.swift | 5 ----- .../Tests/Download/ReadingReducerLocalTests.swift | 13 +++++++++---- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift index 79d4604d7..bfddf819d 100644 --- a/EhPanda/View/Reading/ReadingReducer+Database.swift +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -171,13 +171,11 @@ extension ReadingReducer { ) { state.gallery = download.gallery state.language = manifest.language - let imageURLs = download.localPageURLs - state.localPageURLs = imageURLs - state.previewConfig = .normal(rows: 4) - state.previewURLs = imageURLs - state.thumbnailURLs = imageURLs - state.imageURLs = imageURLs - state.originalImageURLs = imageURLs + state.localPageURLs = download.localPageURLs + state.previewURLs = .init() + state.thumbnailURLs = .init() + state.imageURLs = .init() + state.originalImageURLs = .init() state.mpvKey = nil state.mpvImageKeys = .init() state.mpvSkipServerIdentifiers = .init() diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index ee36f7dce..7bd3b4b21 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -64,11 +64,6 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { 1: folderURL.appendingPathComponent("123_token_1.jpg"), 2: folderURL.appendingPathComponent("123_token_2.jpg") ] - $0.previewConfig = .normal(rows: 4) - $0.previewURLs = $0.localPageURLs - $0.thumbnailURLs = $0.localPageURLs - $0.imageURLs = $0.localPageURLs - $0.originalImageURLs = $0.localPageURLs $0.databaseLoadingState = .idle } await store.finish() diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift index caa71a4d0..23c361da6 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -120,16 +120,21 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { await store.send(.fetchDatabaseInfos(download.gid)) #expect(store.state.gallery.id == download.gid) - #expect(store.state.imageURLs[1] == folderURL.appendingPathComponent("123_token_1.jpg")) - #expect(store.state.imageURLs[2] == folderURL.appendingPathComponent("123_token_2.jpg")) + #expect(store.state.localPageURLs[1] == folderURL.appendingPathComponent("123_token_1.jpg")) + #expect(store.state.localPageURLs[2] == folderURL.appendingPathComponent("123_token_2.jpg")) + #expect(store.state.imageURLs.isEmpty) + #expect(store.state.previewURLs.isEmpty) + #expect(store.state.thumbnailURLs.isEmpty) + #expect(store.state.originalImageURLs.isEmpty) await store.send(.fetchImageURLs(1)) { $0.imageURLLoadingStates[1] = .idle } await store.send(.reloadAllWebImages) - #expect(store.state.imageURLs[1] == folderURL.appendingPathComponent("123_token_1.jpg")) - #expect(store.state.imageURLs[2] == folderURL.appendingPathComponent("123_token_2.jpg")) + #expect(store.state.localPageURLs[1] == folderURL.appendingPathComponent("123_token_1.jpg")) + #expect(store.state.localPageURLs[2] == folderURL.appendingPathComponent("123_token_2.jpg")) + #expect(store.state.imageURLs.isEmpty) } } From 94b9f9d7a30460c8d3c15a735dd7c373b3f465ad Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 17:15:16 +0800 Subject: [PATCH 218/614] Add reader data cache --- EhPanda/App/Tools/Clients/LibraryClient.swift | 7 +- EhPanda/App/Tools/Utilities/DataCache.swift | 289 ++++++++++++++++++ .../Tests/Download/DataCacheTests.swift | 92 ++++++ 3 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 EhPanda/App/Tools/Utilities/DataCache.swift create mode 100644 EhPandaTests/Tests/Download/DataCacheTests.swift diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index 6c10d933b..1eef02224 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -69,10 +69,12 @@ extension LibraryClient { forHTTPHeaderField: "Accept" ) SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) + DataCache.installSystemPurgeObservers() }, removeAllCachedImages: { KingfisherManager.shared.cache.clearMemoryCache() SDImageCache.shared.clearMemory() + async let dataCacheClear: Void = DataCache.shared.removeAll() async let kingfisherClear: Void = withCheckedContinuation { continuation in KingfisherManager.shared.cache.clearDiskCache { continuation.resume() @@ -83,7 +85,7 @@ extension LibraryClient { continuation.resume() } } - _ = await (kingfisherClear, sdWebImageClear) + _ = try? await (dataCacheClear, kingfisherClear, sdWebImageClear) }, cachedImage: { key in if let image = await kingfisherCachedImage(forKey: key) { @@ -141,7 +143,8 @@ extension LibraryClient { continuation.resume(returning: UInt(totalSize)) } } - return await (kingfisherSize ?? 0) + (sdWebImageSize ?? 0) + async let dataCacheSize = try? DataCache.shared.totalSize() + return await (kingfisherSize ?? 0) + (sdWebImageSize ?? 0) + UInt(dataCacheSize ?? 0) } ) } diff --git a/EhPanda/App/Tools/Utilities/DataCache.swift b/EhPanda/App/Tools/Utilities/DataCache.swift new file mode 100644 index 000000000..7dcecdb02 --- /dev/null +++ b/EhPanda/App/Tools/Utilities/DataCache.swift @@ -0,0 +1,289 @@ +// +// DataCache.swift +// EhPanda +// + +import CryptoKit +import Foundation +import UIKit + +actor DataCache { + struct Configuration: Equatable, Sendable { + var rootURL: URL + var memoryCostLimit: Int + var maxDiskAge: TimeInterval + var diskSizeLimit: UInt64 + var sweepByteInterval: UInt64 + + init( + rootURL: URL = FileUtil.cachesDirectory + .appendingPathComponent("DataCache.reading", isDirectory: true), + memoryCostLimit: Int = Int(ProcessInfo.processInfo.physicalMemory / 4), + maxDiskAge: TimeInterval = 7 * 24 * 60 * 60, + diskSizeLimit: UInt64 = 0 + ) { + self.rootURL = rootURL + self.memoryCostLimit = memoryCostLimit + self.maxDiskAge = maxDiskAge + self.diskSizeLimit = diskSizeLimit + self.sweepByteInterval = diskSizeLimit == 0 ? 0 : max(diskSizeLimit / 8, 1) + } + } + + static let shared = DataCache() + + private let configuration: Configuration + private let fileManager: FileManager + private let memoryCache = NSCache() + private var bytesWrittenSinceSweep: UInt64 = 0 + + init( + configuration: Configuration = .init(), + fileManager: sending FileManager = .default + ) { + self.configuration = configuration + self.fileManager = fileManager + memoryCache.totalCostLimit = configuration.memoryCostLimit + } + + nonisolated static func installSystemPurgeObservers() { + Task { @MainActor in + _ = dataCacheSystemPurgeObserver + } + } + + func data(forKey key: String) throws -> Data? { + if let data = memoryCache.object(forKey: key as NSString) { + return Data(referencing: data) + } + + let fileURL = fileURL(forKey: key) + guard fileManager.fileExists(atPath: fileURL.path) else { return nil } + if isExpired(fileURL) { + try? fileManager.removeItem(at: fileURL) + return nil + } + + let data = try Data(contentsOf: fileURL) + memoryCache.setObject(data as NSData, forKey: key as NSString, cost: data.count) + try touchAccessDate(for: fileURL) + return data + } + + func store(_ data: Data, forKey key: String) throws { + memoryCache.setObject(data as NSData, forKey: key as NSString, cost: data.count) + let fileURL = fileURL(forKey: key) + try write(data, to: fileURL, canRetryDirectoryCreation: true) + bytesWrittenSinceSweep += UInt64(data.count) + if configuration.sweepByteInterval > 0, + bytesWrittenSinceSweep >= configuration.sweepByteInterval { + bytesWrittenSinceSweep = 0 + try sweepDisk() + } + } + + func removeData(forKey key: String) throws { + memoryCache.removeObject(forKey: key as NSString) + let fileURL = fileURL(forKey: key) + guard fileManager.fileExists(atPath: fileURL.path) else { return } + try fileManager.removeItem(at: fileURL) + } + + func removeAll() throws { + memoryCache.removeAllObjects() + if fileManager.fileExists(atPath: configuration.rootURL.path) { + try fileManager.removeItem(at: configuration.rootURL) + } + try ensureDirectory() + bytesWrittenSinceSweep = 0 + } + + func removeAllMemory() { + memoryCache.removeAllObjects() + } + + func totalSize() throws -> UInt64 { + guard fileManager.fileExists(atPath: configuration.rootURL.path) else { return 0 } + guard let enumerator = fileManager.enumerator( + at: configuration.rootURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } + + var total: UInt64 = 0 + for case let fileURL as URL in enumerator { + autoreleasepool { + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values?.isRegularFile == true else { return } + total += UInt64(values?.fileSize ?? 0) + } + } + return total + } + + func sweepDisk() throws { + guard fileManager.fileExists(atPath: configuration.rootURL.path) else { return } + var didRemoveDiskEntries = false + defer { + if didRemoveDiskEntries { + memoryCache.removeAllObjects() + } + } + var entries = try diskEntries() + let now = Date() + if configuration.maxDiskAge > 0 { + for entry in entries where now.timeIntervalSince(entry.accessDate) > configuration.maxDiskAge { + try? fileManager.removeItem(at: entry.url) + didRemoveDiskEntries = true + } + entries.removeAll { now.timeIntervalSince($0.accessDate) > configuration.maxDiskAge } + } + + guard configuration.diskSizeLimit > 0 else { return } + var totalSize = entries.reduce(UInt64(0)) { $0 + $1.size } + guard totalSize > configuration.diskSizeLimit else { return } + let targetSize = configuration.diskSizeLimit / 2 + for entry in entries.sorted(by: { $0.accessDate < $1.accessDate }) { + try? fileManager.removeItem(at: entry.url) + didRemoveDiskEntries = true + totalSize = totalSize > entry.size ? totalSize - entry.size : 0 + guard totalSize > targetSize else { break } + } + } + + private func write( + _ data: Data, + to fileURL: URL, + canRetryDirectoryCreation: Bool + ) throws { + do { + try ensureDirectory() + try data.write(to: fileURL, options: .atomic) + try touchAccessDate(for: fileURL) + } catch { + guard canRetryDirectoryCreation else { throw error } + try? fileManager.removeItem(at: configuration.rootURL) + try ensureDirectory() + try write(data, to: fileURL, canRetryDirectoryCreation: false) + } + } + + private func ensureDirectory() throws { + try fileManager.createDirectory( + at: configuration.rootURL, + withIntermediateDirectories: true + ) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + var directoryURL = configuration.rootURL + try? directoryURL.setResourceValues(resourceValues) + } + + private func fileURL(forKey key: String) -> URL { + configuration.rootURL.appendingPathComponent(Self.filename(forKey: key)) + } + + private static func filename(forKey key: String) -> String { + SHA256.hash(data: Data(key.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } + + private func isExpired(_ fileURL: URL) -> Bool { + guard configuration.maxDiskAge > 0 else { return false } + let accessDate = accessDate(for: fileURL) + return Date().timeIntervalSince(accessDate) > configuration.maxDiskAge + } + + private func touchAccessDate(for fileURL: URL) throws { + try fileManager.setAttributes( + [.creationDate: Date(), .modificationDate: Date()], + ofItemAtPath: fileURL.path + ) + var resourceValues = URLResourceValues() + resourceValues.contentAccessDate = Date() + var mutableURL = fileURL + try? mutableURL.setResourceValues(resourceValues) + } + + private func accessDate(for fileURL: URL) -> Date { + if let date = try? fileURL.resourceValues(forKeys: [.contentAccessDateKey]).contentAccessDate { + return date + } + let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path) + return attributes?[.modificationDate] as? Date ?? .distantPast + } + + private func diskEntries() throws -> [DiskEntry] { + guard let enumerator = fileManager.enumerator( + at: configuration.rootURL, + includingPropertiesForKeys: [ + .contentAccessDateKey, + .fileSizeKey, + .isRegularFileKey + ], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + var entries = [DiskEntry]() + for case let fileURL as URL in enumerator { + let values = try fileURL.resourceValues(forKeys: [ + .contentAccessDateKey, + .fileSizeKey, + .isRegularFileKey + ]) + guard values.isRegularFile == true else { continue } + entries.append( + DiskEntry( + url: fileURL, + size: UInt64(values.fileSize ?? 0), + accessDate: values.contentAccessDate ?? accessDate(for: fileURL) + ) + ) + } + return entries + } +} + +@MainActor +private let dataCacheSystemPurgeObserver = DataCacheSystemPurgeObserver(cache: .shared) + +@MainActor +private final class DataCacheSystemPurgeObserver { + private let tokens: [NSObjectProtocol] + + init(cache: DataCache) { + let center = NotificationCenter.default + tokens = [ + center.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak cache] _ in + Task { + await cache?.removeAllMemory() + } + }, + center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak cache] _ in + Task { + await cache?.removeAllMemory() + try? await cache?.sweepDisk() + } + } + ] + } +} + +private struct DiskEntry { + let url: URL + let size: UInt64 + let accessDate: Date +} diff --git a/EhPandaTests/Tests/Download/DataCacheTests.swift b/EhPandaTests/Tests/Download/DataCacheTests.swift new file mode 100644 index 000000000..09294cee3 --- /dev/null +++ b/EhPandaTests/Tests/Download/DataCacheTests.swift @@ -0,0 +1,92 @@ +// +// DataCacheTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DataCacheTests { + @Test + func testStoreAndReadDataFromDiskWithHashedFilename() async throws { + let rootURL = makeRootURL() + defer { try? FileManager.default.removeItem(at: rootURL) } + let cache = DataCache( + configuration: .init(rootURL: rootURL, memoryCostLimit: 1, maxDiskAge: 60) + ) + let key = "https://example.com/reader/1.webp" + let data = Data([0x01, 0x02, 0x03]) + + try await cache.store(data, forKey: key) + await cache.removeAllMemory() + + #expect(try await cache.data(forKey: key) == data) + let files = try FileManager.default.contentsOfDirectory(atPath: rootURL.path) + #expect(files.count == 1) + #expect(files.first != key) + let resourceValues = try rootURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) + #expect(resourceValues.isExcludedFromBackup == true) + } + + @Test + func testExpiredDataIsRemovedOnRead() async throws { + let rootURL = makeRootURL() + defer { try? FileManager.default.removeItem(at: rootURL) } + let cache = DataCache( + configuration: .init(rootURL: rootURL, maxDiskAge: 0.01) + ) + + try await cache.store(Data([0x01]), forKey: "expired") + try await Task.sleep(for: .milliseconds(20)) + await cache.removeAllMemory() + + #expect(try await cache.data(forKey: "expired") == nil) + #expect(try await cache.totalSize() == 0) + } + + @Test + func testDiskSweepEvictsOldestEntriesToHalfLimit() async throws { + let rootURL = makeRootURL() + defer { try? FileManager.default.removeItem(at: rootURL) } + let cache = DataCache( + configuration: .init( + rootURL: rootURL, + maxDiskAge: 60, + diskSizeLimit: 10 + ) + ) + + try await cache.store(Data(repeating: 0x01, count: 4), forKey: "old") + try await Task.sleep(for: .milliseconds(10)) + try await cache.store(Data(repeating: 0x02, count: 4), forKey: "middle") + try await Task.sleep(for: .milliseconds(10)) + try await cache.store(Data(repeating: 0x03, count: 4), forKey: "new") + + #expect(try await cache.data(forKey: "old") == nil) + #expect(try await cache.data(forKey: "middle") == nil) + #expect(try await cache.data(forKey: "new") == Data(repeating: 0x03, count: 4)) + #expect(try await cache.totalSize() <= 5) + } + + @Test + func testRemoveAllClearsMemoryAndDisk() async throws { + let rootURL = makeRootURL() + defer { try? FileManager.default.removeItem(at: rootURL) } + let cache = DataCache( + configuration: .init(rootURL: rootURL) + ) + + try await cache.store(Data([0x01]), forKey: "page") + try await cache.removeAll() + + #expect(try await cache.data(forKey: "page") == nil) + #expect(try await cache.totalSize() == 0) + } + + private func makeRootURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + } +} From 8e327402a21cd9e43f4e61a5f402198ab3b4229a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 17:49:05 +0800 Subject: [PATCH 219/614] Route reader bytes through DataCache --- .../App/Tools/Clients/ClipboardClient.swift | 18 ++- .../Tools/Clients/DownloadClient+Cache.swift | 17 ++- .../App/Tools/Clients/DownloadClient.swift | 6 +- EhPanda/App/Tools/Clients/ImageClient.swift | 108 ++++++++++++-- EhPanda/App/Tools/Clients/LibraryClient.swift | 6 +- .../Extensions/AnimatedImage_Extension.swift | 139 +++++++++++++++++- EhPanda/App/Tools/Utilities/DataCache.swift | 26 ++++ .../View/Detail/DetailReducer+Download.swift | 2 +- .../Detail/Previews/PreviewsReducer.swift | 2 +- .../View/Reading/ReadingReducer+Body.swift | 22 ++- .../Reading/ReadingReducer+Database.swift | 2 +- EhPanda/View/Reading/ReadingReducer.swift | 2 +- EhPanda/View/Reading/ReadingView.swift | 55 ++++--- .../View/Reading/ReadingViewComponents.swift | 21 ++- .../Tests/Download/DataCacheTests.swift | 19 +++ .../DownloadManagerCaptureTests.swift | 25 ++-- .../DownloadManagerRepairSeedTests.swift | 8 +- .../DownloadObserverRefreshTests.swift | 6 +- .../PreviewsReducerDownloadTests.swift | 4 +- .../ReadingReducerDownloadTests.swift | 2 +- .../Parser/Other/AnimatedImageDataTests.swift | 120 +++++++++++++++ 21 files changed, 509 insertions(+), 101 deletions(-) create mode 100644 EhPandaTests/Tests/Parser/Other/AnimatedImageDataTests.swift diff --git a/EhPanda/App/Tools/Clients/ClipboardClient.swift b/EhPanda/App/Tools/Clients/ClipboardClient.swift index c7f9fc338..70ef7479e 100644 --- a/EhPanda/App/Tools/Clients/ClipboardClient.swift +++ b/EhPanda/App/Tools/Clients/ClipboardClient.swift @@ -11,6 +11,7 @@ struct ClipboardClient: Sendable { let changeCount: @Sendable () -> Int let saveText: @Sendable (String) -> Void let saveImage: @Sendable (UIImage, Bool) -> Void + let saveImageData: @Sendable (Data) -> Bool } extension ClipboardClient { @@ -41,6 +42,17 @@ extension ClipboardClient { } else { UIPasteboard.general.image = image } + }, + saveImageData: { data in + if let pasteboardType = data.animatedImagePasteboardType { + UIPasteboard.general.setData(data, forPasteboardType: pasteboardType) + return true + } + guard let image = data.decodedImage else { + return false + } + UIPasteboard.general.image = image + return true } ) } @@ -65,7 +77,8 @@ extension ClipboardClient { url: { nil }, changeCount: { 0 }, saveText: { _ in }, - saveImage: { _, _ in } + saveImage: { _, _ in }, + saveImageData: { _ in false } ) static func placeholder() -> Result { fatalError() } @@ -74,6 +87,7 @@ extension ClipboardClient { url: IssueReporting.unimplemented(placeholder: placeholder()), changeCount: IssueReporting.unimplemented(placeholder: placeholder()), saveText: IssueReporting.unimplemented(placeholder: placeholder()), - saveImage: IssueReporting.unimplemented(placeholder: placeholder()) + saveImage: IssueReporting.unimplemented(placeholder: placeholder()), + saveImageData: IssueReporting.unimplemented(placeholder: placeholder()) ) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 197045c6d..34cda7c66 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -24,7 +24,9 @@ extension DownloadCoordinator { cacheKeys(for: $0, includeStableAlias: includeStableAlias) } - for key in Set(keys) { + let uniqueKeys = Array(Set(keys)) + try? await DataCache.shared.removeData(forKeys: uniqueKeys) + for key in uniqueKeys { await libraryClient.removeCachedImage(key) } } @@ -121,8 +123,12 @@ extension DownloadCoordinator { partialResult.append(key) } + if let data = try? await DataCache.shared.data(forKeys: keys) { + return data + } for key in keys { if let data = await cachedImageData(forKey: key) { + try? await DataCache.shared.store(data, forKeys: keys) return data } } @@ -130,7 +136,14 @@ extension DownloadCoordinator { } func cachedImageData(forKey key: String) async -> Data? { - await libraryClient.cachedImageData(key) + if let data = try? await DataCache.shared.data(forKey: key) { + return data + } + guard let data = await libraryClient.cachedImageData(key) else { + return nil + } + try? await DataCache.shared.store(data, forKey: key) + return data } func validatedCachedAssetData( diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index c8b3a86c4..9dfe967ce 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -22,7 +22,7 @@ struct DownloadClient: Sendable { var retryPages: @Sendable (String, [Int]) async throws -> Void var delete: @Sendable (String) async throws -> Void var loadManifest: @Sendable (String) async throws -> (DownloadedGallery, DownloadManifest) - var loadLocalPageURLs: @Sendable (String) async throws -> [Int: URL] + var loadLocalPageURLs: @Sendable (String) async -> [Int: URL]? var captureCachedPage: @Sendable (String, Int, URL?) async -> Void var loadInspection: @Sendable (String) async throws -> DownloadInspection var fetchFolders: @Sendable () async throws -> [String] @@ -93,7 +93,7 @@ extension DownloadClient { }, delete: { gid in try await manager.delete(gid: gid).get() }, loadManifest: { gid in try await manager.loadManifest(gid: gid).get() }, - loadLocalPageURLs: { gid in try await manager.loadLocalPageURLs(gid: gid).get() }, + loadLocalPageURLs: { gid in try? await manager.loadLocalPageURLs(gid: gid).get() }, captureCachedPage: { gid, index, imageURL in await manager.captureCachedPage(gid: gid, index: index, imageURL: imageURL) }, @@ -142,7 +142,7 @@ extension DownloadClient { retryPages: { _, _ in }, delete: { _ in }, loadManifest: { _ in throw AppError.notFound }, - loadLocalPageURLs: { _ in throw AppError.notFound }, + loadLocalPageURLs: { _ in nil }, captureCachedPage: { _, _, _ in }, loadInspection: { _ in throw AppError.notFound }, fetchFolders: { [] }, diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 59ddf27a0..45081d395 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -12,8 +12,18 @@ import Synchronization import ComposableArchitecture struct ImageClient: Sendable { + struct ImageAsset { + let image: UIImage + let data: Data + + var isAnimated: Bool { + data.isAnimatedImageData || image.hasAnimatedFrames + } + } + let prefetchImages: @Sendable ([URL]) -> Void let saveImageToPhotoLibrary: @Sendable (UIImage, Bool) async -> Bool + let saveImageDataToPhotoLibrary: @Sendable (Data) async -> Bool let downloadImage: @Sendable (URL) async -> Result let retrieveImage: @Sendable (String) async -> Result let isCached: @Sendable (String) -> Bool @@ -66,6 +76,9 @@ extension ImageClient { } } }, + saveImageDataToPhotoLibrary: { data in + await Self.saveImageDataToPhotoLibrary(data) + }, downloadImage: { url in if url.isPotentiallyAnimatedImage { return await ImageClient.downloadAnimatedImage(url: url) @@ -81,6 +94,17 @@ extension ImageClient { isCached: LibraryClient.live.isCached ) + static func saveImageDataToPhotoLibrary(_ data: Data) async -> Bool { + await withCheckedContinuation { continuation in + PHPhotoLibrary.shared().performChanges { + let request = PHAssetCreationRequest.forAsset() + request.addResource(with: .photo, data: data, options: nil) + } completionHandler: { isSuccess, _ in + continuation.resume(returning: isSuccess) + } + } + } + // Runs on the `MainActor` so the non-`Sendable` `SDWebImageCombinedOperation` it creates // never leaves a single isolation domain, see `AnimatedImageOperationBox`. @MainActor @@ -98,8 +122,16 @@ extension ImageClient { options: [.retryFailed, .continueInBackground, .handleCookies], context: [.callbackQueue: SDCallbackQueue.main], progress: nil - ) { image, _, error, _, _, _ in + ) { image, data, error, _, _, _ in if let image { + if let data { + Task { + try? await DataCache.shared.store( + data, + forKeys: url.imageCacheKeys(includeStableAlias: true) + ) + } + } continuationBox.resume(returning: .success(image)) } else { continuationBox.resume(returning: .failure(error ?? AppError.notFound)) @@ -135,6 +167,12 @@ extension ImageClient { ) { result in switch result { case .success(let downloadResult): + Task { + try? await DataCache.shared.store( + downloadResult.originalData, + forKeys: url.imageCacheKeys(includeStableAlias: true) + ) + } cache.store( downloadResult.image, original: downloadResult.originalData, @@ -157,25 +195,65 @@ extension ImageClient { return result } + func fetchImageAsset(url: URL) async -> Result { + do { + let data = try await imageData(url: url) + guard let image = data.decodedImage else { + return .failure(AppError.parseFailed) + } + return .success(.init(image: image, data: data)) + } catch { + return .failure(error) + } + } + func fetchImage(url: URL) async -> Result { + switch await fetchImageAsset(url: url) { + case .success(let asset): + return .success(asset.image) + case .failure(let error): + return .failure(error) + } + } + + private func imageData(url: URL) async throws -> Data { if url.isFileURL { - if let image = UIImage(contentsOfFile: url.path) { - return .success(image) - } - if let data = try? Data(contentsOf: url), - let image = UIImage(data: data) { - return .success(image) + return try Data(contentsOf: url) + } + + let cacheKeys = url.imageCacheKeys(includeStableAlias: true) + if let data = try await DataCache.shared.data(forKeys: cacheKeys) { + return data + } + + for key in cacheKeys { + guard isCached(key) else { continue } + guard case .success(let image) = await retrieveImage(key), + let data = Self.data(from: image) + else { + continue } - return .failure(AppError.notFound) + try? await DataCache.shared.store(data, forKeys: cacheKeys) + return data } - for key in url.imageCacheKeys(includeStableAlias: true) - where isCached(key) { - let result = await retrieveImage(key) - if case .success = result { - return result + + switch await downloadImage(url) { + case .success(let image): + guard let data = Self.data(from: image) else { + throw AppError.notFound } + try? await DataCache.shared.store(data, forKeys: cacheKeys) + return data + + case .failure(let error): + throw error } - return await downloadImage(url) + } + + private static func data(from image: UIImage) -> Data? { + image.animatedSourceData + ?? image.sd_imageData() + ?? image.kf.data(format: .unknown) } } @@ -300,6 +378,7 @@ extension ImageClient { static let noop: Self = .init( prefetchImages: { _ in }, saveImageToPhotoLibrary: { _, _ in false }, + saveImageDataToPhotoLibrary: { _ in false }, downloadImage: { _ in .success(UIImage()) }, retrieveImage: { _ in .success(UIImage()) }, isCached: { _ in false } @@ -310,6 +389,7 @@ extension ImageClient { static let unimplemented: Self = .init( prefetchImages: IssueReporting.unimplemented(placeholder: placeholder()), saveImageToPhotoLibrary: IssueReporting.unimplemented(placeholder: placeholder()), + saveImageDataToPhotoLibrary: IssueReporting.unimplemented(placeholder: placeholder()), downloadImage: IssueReporting.unimplemented(placeholder: placeholder()), retrieveImage: IssueReporting.unimplemented(placeholder: placeholder()), isCached: IssueReporting.unimplemented(placeholder: placeholder()) diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index 1eef02224..a924e713b 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -228,11 +228,7 @@ private func sdWebImageCachedImageData(forKey key: String) async -> Data? { } private func image(from data: Data) -> UIImage? { - if data.animatedImagePasteboardType != nil, - let animatedImage = SDAnimatedImage(data: data) { - return animatedImage - } - return UIImage(data: data) + data.decodedImage } // MARK: API diff --git a/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift index 44ca3720d..be678c169 100644 --- a/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift +++ b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift @@ -10,10 +10,14 @@ import UniformTypeIdentifiers private enum ImageDataSignature { static let jpeg: [UInt8] = [0xFF, 0xD8, 0xFF] static let png: [UInt8] = [0x89, 0x50, 0x4E, 0x47] + static let pngComplete: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] static let gif = Array("GIF".utf8) static let riff = Array("RIFF".utf8) static let webp = Array("WEBP".utf8) + static let webPExtended = Array("VP8X".utf8) + static let webPAnimation = Array("ANIM".utf8) static let apngAnimationControl = Array("acTL".utf8) + static let pngImageData = Array("IDAT".utf8) } extension Data { @@ -46,7 +50,23 @@ extension Data { } var isAPNGFormat: Bool { - isPNGFormat && range(of: Data(ImageDataSignature.apngAnimationControl)) != nil + guard starts(with: ImageDataSignature.pngComplete) else { return false } + let bytes = Array(self) + var offset = ImageDataSignature.pngComplete.count + while offset + 12 <= bytes.count { + let chunkLength = Int(Self.bigEndianUInt32(bytes, offset: offset)) + let chunkTypeOffset = offset + 4 + let chunkDataOffset = offset + 8 + guard chunkDataOffset + chunkLength + 4 <= bytes.count else { return false } + if Self.matches(ImageDataSignature.apngAnimationControl, in: bytes, at: chunkTypeOffset) { + return true + } + if Self.matches(ImageDataSignature.pngImageData, in: bytes, at: chunkTypeOffset) { + return false + } + offset = chunkDataOffset + chunkLength + 4 + } + return false } var isGIFFormat: Bool { @@ -58,25 +78,138 @@ extension Data { && hasBytes(ImageDataSignature.webp, at: 8) } + var isAnimatedImageData: Bool { + isAnimatedGIFFormat || isAPNGFormat || isAnimatedWebPFormat + } + var animatedImagePasteboardType: String? { - if isWebPFormat { + if isAnimatedWebPFormat { return UTType.webP.identifier } if isAPNGFormat { return UTType.png.identifier } - if isGIFFormat { + if isAnimatedGIFFormat { return UTType.gif.identifier } return nil } + var decodedImage: UIImage? { + if isAnimatedImageData, let animatedImage = SDAnimatedImage(data: self) { + return animatedImage + } + return UIImage(data: self) + } + private func hasBytes(_ bytes: [UInt8], at offset: Int) -> Bool { guard count >= offset + bytes.count else { return false } let start = index(startIndex, offsetBy: offset) let end = index(start, offsetBy: bytes.count) return self[start..= 13 else { return false } + + var offset = 13 + if bytes[10] & 0x80 != 0 { + offset += Self.colorTableByteCount(packedField: bytes[10]) + } + + var imageCount = 0 + while offset < bytes.count { + switch bytes[offset] { + case 0x2C: + imageCount += 1 + guard imageCount <= 1 else { return true } + guard offset + 10 <= bytes.count else { return false } + let packedField = bytes[offset + 9] + offset += 10 + if packedField & 0x80 != 0 { + offset += Self.colorTableByteCount(packedField: packedField) + } + guard offset < bytes.count else { return false } + offset += 1 + guard Self.skipGIFSubBlocks(bytes, offset: &offset) else { return false } + + case 0x21: + offset += 2 + guard Self.skipGIFSubBlocks(bytes, offset: &offset) else { return false } + + case 0x3B: + return false + + default: + return false + } + } + return false + } + + private var isAnimatedWebPFormat: Bool { + guard isWebPFormat else { return false } + let bytes = Array(self) + var offset = 12 + while offset + 8 <= bytes.count { + let chunkTypeOffset = offset + let chunkSize = Int(Self.littleEndianUInt32(bytes, offset: offset + 4)) + let chunkDataOffset = offset + 8 + let paddedChunkSize = chunkSize + (chunkSize % 2) + guard chunkDataOffset + paddedChunkSize <= bytes.count else { return false } + + if Self.matches(ImageDataSignature.webPExtended, in: bytes, at: chunkTypeOffset) { + guard chunkSize >= 1 else { return false } + return bytes[chunkDataOffset] & 0x02 != 0 + } + if Self.matches(ImageDataSignature.webPAnimation, in: bytes, at: chunkTypeOffset) { + return true + } + offset = chunkDataOffset + paddedChunkSize + } + return false + } + + private static func colorTableByteCount(packedField: UInt8) -> Int { + 3 * (1 << Int((packedField & 0x07) + 1)) + } + + private static func skipGIFSubBlocks(_ bytes: [UInt8], offset: inout Int) -> Bool { + while offset < bytes.count { + let blockSize = Int(bytes[offset]) + offset += 1 + guard blockSize > 0 else { return true } + guard offset + blockSize <= bytes.count else { return false } + offset += blockSize + } + return false + } + + private static func matches(_ expected: [UInt8], in bytes: [UInt8], at offset: Int) -> Bool { + guard offset >= 0, offset + expected.count <= bytes.count else { return false } + for index in expected.indices where bytes[offset + index] != expected[index] { + return false + } + return true + } + + private static func littleEndianUInt32(_ bytes: [UInt8], offset: Int) -> UInt32 { + guard offset + 4 <= bytes.count else { return 0 } + return UInt32(bytes[offset]) + | UInt32(bytes[offset + 1]) << 8 + | UInt32(bytes[offset + 2]) << 16 + | UInt32(bytes[offset + 3]) << 24 + } + + private static func bigEndianUInt32(_ bytes: [UInt8], offset: Int) -> UInt32 { + guard offset + 4 <= bytes.count else { return 0 } + return UInt32(bytes[offset]) << 24 + | UInt32(bytes[offset + 1]) << 16 + | UInt32(bytes[offset + 2]) << 8 + | UInt32(bytes[offset + 3]) + } } extension UIImage { diff --git a/EhPanda/App/Tools/Utilities/DataCache.swift b/EhPanda/App/Tools/Utilities/DataCache.swift index 7dcecdb02..1e3847bbb 100644 --- a/EhPanda/App/Tools/Utilities/DataCache.swift +++ b/EhPanda/App/Tools/Utilities/DataCache.swift @@ -70,6 +70,15 @@ actor DataCache { return data } + func data(forKeys keys: [String]) throws -> Data? { + for key in Self.uniqued(keys) { + if let data = try data(forKey: key) { + return data + } + } + return nil + } + func store(_ data: Data, forKey key: String) throws { memoryCache.setObject(data as NSData, forKey: key as NSString, cost: data.count) let fileURL = fileURL(forKey: key) @@ -82,6 +91,12 @@ actor DataCache { } } + func store(_ data: Data, forKeys keys: [String]) throws { + for key in Self.uniqued(keys) { + try store(data, forKey: key) + } + } + func removeData(forKey key: String) throws { memoryCache.removeObject(forKey: key as NSString) let fileURL = fileURL(forKey: key) @@ -89,6 +104,12 @@ actor DataCache { try fileManager.removeItem(at: fileURL) } + func removeData(forKeys keys: [String]) throws { + for key in Self.uniqued(keys) { + try removeData(forKey: key) + } + } + func removeAll() throws { memoryCache.removeAllObjects() if fileManager.fileExists(atPath: configuration.rootURL.path) { @@ -191,6 +212,11 @@ actor DataCache { .joined() } + private nonisolated static func uniqued(_ keys: [String]) -> [String] { + var seen = Set() + return keys.filter { seen.insert($0).inserted } + } + private func isExpired(_ fileURL: URL) -> Bool { guard configuration.maxDiskAge > 0 else { return false } let accessDate = accessDate(for: fileURL) diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index 81bb02b17..89b21259d 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -118,7 +118,7 @@ extension DetailReducer { let requestID = UUID() state.localPreviewRequestID = requestID return .run { [galleryID = state.gid] send in - let localPreviewURLs = (try? await downloadClient.loadLocalPageURLs(galleryID)) ?? [:] + let localPreviewURLs = await downloadClient.loadLocalPageURLs(galleryID) ?? [:] await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) } .cancellable(id: CancelID.loadLocalPreviewURLs(state.cancellationGalleryID), cancelInFlight: true) diff --git a/EhPanda/View/Detail/Previews/PreviewsReducer.swift b/EhPanda/View/Detail/Previews/PreviewsReducer.swift index 25449f582..024087806 100644 --- a/EhPanda/View/Detail/Previews/PreviewsReducer.swift +++ b/EhPanda/View/Detail/Previews/PreviewsReducer.swift @@ -156,7 +156,7 @@ struct PreviewsReducer { let requestID = UUID() state.localPreviewRequestID = requestID return .run { send in - let localPreviewURLs = (try? await downloadClient.loadLocalPageURLs(gid)) ?? [:] + let localPreviewURLs = await downloadClient.loadLocalPageURLs(gid) ?? [:] await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) } .cancellable(id: CancelID.loadLocalPreviewURLs, cancelInFlight: true) diff --git a/EhPanda/View/Reading/ReadingReducer+Body.swift b/EhPanda/View/Reading/ReadingReducer+Body.swift index 4eac9aad3..e387ba124 100644 --- a/EhPanda/View/Reading/ReadingReducer+Body.swift +++ b/EhPanda/View/Reading/ReadingReducer+Body.swift @@ -105,7 +105,7 @@ extension ReadingReducer { case .fetchImage(let action, let imageURL): return .run { send in - let result = await imageClient.fetchImage(url: imageURL) + let result = await imageClient.fetchImageAsset(url: imageURL) await send(.fetchImageDone(action, result)) } .cancellable(id: ReadingCancelID.fetchImage) @@ -198,30 +198,26 @@ extension ReadingReducer { func reduceFetchImageDone( state: inout State, action: ImageAction, - result: Result + result: Result ) -> Effect { - if case .success(let image) = result { + if case .success(let asset) = result { switch action { case .copy: - let isAnimated = image.hasAnimatedFrames state.hudConfig = .copiedToClipboardSucceeded return .merge( .send(.setNavigation(.hud)), - .run(operation: { _ in clipboardClient.saveImage(image, isAnimated) }) + .run(operation: { _ in _ = clipboardClient.saveImageData(asset.data) }) ) case .save: - let isAnimated = image.hasAnimatedFrames return .run { send in - let success = await imageClient.saveImageToPhotoLibrary(image, isAnimated) + let success = await imageClient.saveImageDataToPhotoLibrary(asset.data) await send(.saveImageDone(success)) } case .share: - let isAnimated = image.hasAnimatedFrames - if isAnimated, let data = image.animatedSourceData { - return .send(.setNavigation(.share(.init(value: .data(data))))) - } else { - return .send(.setNavigation(.share(.init(value: .image(image))))) - } + let shareItem: ShareItem = asset.isAnimated + ? .data(asset.data) + : .image(asset.image) + return .send(.setNavigation(.share(.init(value: shareItem)))) } } else { state.hudConfig = .error() diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift index bfddf819d..eced0a6bc 100644 --- a/EhPanda/View/Reading/ReadingReducer+Database.swift +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -137,7 +137,7 @@ extension ReadingReducer { let requestID = UUID() state.localPageRequestID = requestID return .run { send in - let localPageURLs = (try? await downloadClient.loadLocalPageURLs(gid)) ?? [:] + let localPageURLs = await downloadClient.loadLocalPageURLs(gid) ?? [:] await send(.loadLocalPageURLsDone(requestID, localPageURLs)) } .cancellable(id: ReadingCancelID.loadLocalPageURLs, cancelInFlight: true) diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index 6ada307ec..124844b46 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -140,7 +140,7 @@ struct ReadingReducer { case saveImageDone(Bool) case shareImage(URL) case fetchImage(ImageAction, URL) - case fetchImageDone(ImageAction, Result) + case fetchImageDone(ImageAction, Result) case syncReadingProgress(Int) case syncPreviewURLs([Int: URL]) diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 16d3da9ef..0eec2e5c4 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -4,7 +4,6 @@ // import SwiftUI -import Kingfisher import Observation import SwiftUIPager import ComposableArchitecture @@ -302,48 +301,44 @@ extension ReadingView { analyzeLocalImage(at: imageURL, index: index) return } - let cacheKeys = imageURL.imageCacheKeys(includeStableAlias: true) Task { - await retrieveCachedImage(cacheKeys: ArraySlice(cacheKeys), index: index) + await analyzeCachedImageData( + cacheKeys: imageURL.imageCacheKeys(includeStableAlias: true), + index: index + ) } } private func analyzeLocalImage(at imageURL: URL, index: Int) { - if let image = UIImage(contentsOfFile: imageURL.path) - ?? ((try? Data(contentsOf: imageURL)).flatMap(UIImage.init(data:))), - let cgImage = image.cgImage { - liveTextHandler.analyzeImage( - cgImage, size: image.size, index: index, recognitionLanguages: - store.language?.codes - ) - } else { + guard let data = try? Data(contentsOf: imageURL), + !data.isAnimatedImageData, + let image = data.decodedImage, + let cgImage = image.cgImage + else { Logger.info("analyzeImageForLiveText local image not found", context: ["index": index]) + return } + + liveTextHandler.analyzeImage( + cgImage, size: image.size, index: index, recognitionLanguages: + store.language?.codes + ) } - private func retrieveCachedImage(cacheKeys: ArraySlice, index: Int) async { - guard let cacheKey = cacheKeys.first else { + private func analyzeCachedImageData(cacheKeys: [String], index: Int) async { + guard let data = try? await DataCache.shared.data(forKeys: cacheKeys), + !data.isAnimatedImageData, + let image = data.decodedImage, + let cgImage = image.cgImage + else { Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) return } - do { - let result = try await KingfisherManager.shared.cache.retrieveImage(forKey: cacheKey) - if let image = result.image, let cgImage = image.cgImage { - liveTextHandler.analyzeImage( - cgImage, size: image.size, index: index, recognitionLanguages: - store.language?.codes - ) - } else { - await retrieveCachedImage(cacheKeys: cacheKeys.dropFirst(), index: index) - } - } catch { - if cacheKeys.count > 1 { - await retrieveCachedImage(cacheKeys: cacheKeys.dropFirst(), index: index) - } else { - Logger.info("analyzeImageForLiveText failed", context: ["index": index]) - } - } + liveTextHandler.analyzeImage( + cgImage, size: image.size, index: index, recognitionLanguages: + store.language?.codes + ) } } diff --git a/EhPanda/View/Reading/ReadingViewComponents.swift b/EhPanda/View/Reading/ReadingViewComponents.swift index 5e998f66c..1a08dd036 100644 --- a/EhPanda/View/Reading/ReadingViewComponents.swift +++ b/EhPanda/View/Reading/ReadingViewComponents.swift @@ -254,7 +254,13 @@ struct ImageContainer: View { imageView.stopAnimating() } } - .onSuccess(perform: { _, _, _ in loadSucceededAction(index) }) + .onSuccess(perform: { image, data, _ in + cacheImageData( + data ?? image.animatedSourceData ?? image.sd_imageData(), + for: url + ) + loadSucceededAction(index) + }) .onFailure(perform: { _ in loadFailedAction(index) }) .clipped() } else { @@ -311,7 +317,10 @@ struct ImageContainer: View { } } } - private func onSuccess(_: RetrieveImageResult) { + private func onSuccess(_ result: RetrieveImageResult) { + if let imageURL { + cacheImageData(result.data(), for: imageURL) + } loadSucceededAction(index) } private func onFailure(_: KingfisherError) { @@ -334,4 +343,12 @@ struct ImageContainer: View { let fileSize = resourceValues?.fileSize ?? 0 return "local::\(url.path)#\(fileSize)#\(modificationStamp)" } + + private func cacheImageData(_ data: Data?, for url: URL) { + guard let data, !url.isFileURL else { return } + let keys = url.imageCacheKeys(includeStableAlias: true) + Task { + try? await DataCache.shared.store(data, forKeys: keys) + } + } } diff --git a/EhPandaTests/Tests/Download/DataCacheTests.swift b/EhPandaTests/Tests/Download/DataCacheTests.swift index 09294cee3..b45dcfda1 100644 --- a/EhPandaTests/Tests/Download/DataCacheTests.swift +++ b/EhPandaTests/Tests/Download/DataCacheTests.swift @@ -30,6 +30,25 @@ struct DataCacheTests { #expect(resourceValues.isExcludedFromBackup == true) } + @Test + func testStoreReadAndRemoveDataForOrderedKeys() async throws { + let rootURL = makeRootURL() + defer { try? FileManager.default.removeItem(at: rootURL) } + let cache = DataCache(configuration: .init(rootURL: rootURL)) + let data = Data([0x0A, 0x0B]) + + try await cache.store(data, forKeys: ["stable", "absolute", "stable"]) + + #expect(try await cache.data(forKeys: ["missing", "absolute"]) == data) + let files = try FileManager.default.contentsOfDirectory(atPath: rootURL.path) + #expect(files.count == 2) + + try await cache.removeData(forKeys: ["stable", "absolute", "stable"]) + + #expect(try await cache.data(forKeys: ["stable", "absolute"]) == nil) + #expect(try await cache.totalSize() == 0) + } + @Test func testExpiredDataIsRemovedOnRead() async throws { let rootURL = makeRootURL() diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index c610b3de2..ebca37798 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -3,7 +3,6 @@ // EhPandaTests // -import Kingfisher import UIKit import Foundation import Testing @@ -45,12 +44,8 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { context.fill(.init(x: 0, y: 0, width: 1, height: 1)) } let imageData = try #require(image.jpegData(compressionQuality: 1)) - let cacheKey = try #require(imageURL.stableImageCacheKey) - try await KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) - defer { - KingfisherManager.shared.cache.removeImage(forKey: cacheKey) - KingfisherManager.shared.cache.removeImage(forKey: imageURL.absoluteString) - } + let cacheKeys = imageURL.imageCacheKeys(includeStableAlias: true) + try await DataCache.shared.store(imageData, forKeys: cacheKeys) await manager.captureCachedPage( gid: gid, @@ -69,6 +64,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { ) ) ) + try? await DataCache.shared.removeData(forKeys: cacheKeys) } @MainActor @@ -85,11 +81,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { rootURL: rootURL, gid: gid ) await manager.reloadDownloadIndex() - let (imageURL, cacheKey) = try await setupCaptureCachedImage(gid: gid) - defer { - KingfisherManager.shared.cache.removeImage(forKey: cacheKey) - KingfisherManager.shared.cache.removeImage(forKey: imageURL.absoluteString) - } + let (imageURL, cacheKeys) = try await setupCaptureCachedImage(gid: gid) await manager.captureCachedPage(gid: gid, index: 1, imageURL: imageURL) @@ -106,6 +98,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { fileExtension: "jpg" ) #expect(pageURLs[1] == completedFolderURL.appendingPathComponent(pageRelativePath)) + try? await DataCache.shared.removeData(forKeys: cacheKeys) } } @@ -153,15 +146,15 @@ private extension DownloadManagerCaptureTests { } @MainActor - func setupCaptureCachedImage(gid: String) async throws -> (URL, String) { + func setupCaptureCachedImage(gid: String) async throws -> (URL, [String]) { let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in UIColor.systemOrange.setFill() context.fill(.init(x: 0, y: 0, width: 1, height: 1)) } let imageData = try #require(image.jpegData(compressionQuality: 1)) - let cacheKey = try #require(imageURL.stableImageCacheKey) - try await KingfisherManager.shared.cache.store(image, original: imageData, forKey: cacheKey) - return (imageURL, cacheKey) + let cacheKeys = imageURL.imageCacheKeys(includeStableAlias: true) + try await DataCache.shared.store(imageData, forKeys: cacheKeys) + return (imageURL, cacheKeys) } } diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index 45edcc175..78ac348a8 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -261,6 +261,7 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { let client = ImageClient( prefetchImages: { _ in }, saveImageToPhotoLibrary: { _, _ in false }, + saveImageDataToPhotoLibrary: { _ in false }, downloadImage: { _ in Issue.record("Expected ImageClient to use the cached SDWebImage data.") return .failure(AppError.notFound) @@ -284,12 +285,17 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { let expectedCacheKeys = url.imageCacheKeys(includeStableAlias: true) let retrievedCacheKeys = UncheckedBox([String]()) let downloadedURLs = UncheckedBox([URL]()) + let downloadedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemBlue.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } let client = ImageClient( prefetchImages: { _ in }, saveImageToPhotoLibrary: { _, _ in false }, + saveImageDataToPhotoLibrary: { _ in false }, downloadImage: { downloadURL in downloadedURLs.value.append(downloadURL) - return .success(UIImage()) + return .success(downloadedImage) }, retrieveImage: { cacheKey in retrievedCacheKeys.value.append(cacheKey) diff --git a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift index ea07f68ce..40c4afa6e 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -97,7 +97,7 @@ private extension DownloadObserverRefreshTests { func makeReadingObserverStore( initialState: ReadingReducer.State, stream: AsyncStream<[DownloadedGallery]>, - loadLocalPageURLs: @escaping @Sendable (String) async throws -> [Int: URL] + loadLocalPageURLs: @escaping @Sendable (String) async -> [Int: URL]? ) -> TestStoreOf { let store = TestStore( initialState: initialState, @@ -123,7 +123,7 @@ private extension DownloadObserverRefreshTests { func makePreviewsObserverStore( initialState: PreviewsReducer.State, stream: AsyncStream<[DownloadedGallery]>, - loadLocalPageURLs: @escaping @Sendable (String) async throws -> [Int: URL] + loadLocalPageURLs: @escaping @Sendable (String) async -> [Int: URL]? ) -> TestStoreOf { let store = TestStore( initialState: initialState, @@ -142,7 +142,7 @@ private extension DownloadObserverRefreshTests { func makeObserveDownloadClient( stream: AsyncStream<[DownloadedGallery]>, - loadLocalPageURLs: @escaping @Sendable (String) async throws -> [Int: URL] + loadLocalPageURLs: @escaping @Sendable (String) async -> [Int: URL]? ) -> DownloadClient { var client = DownloadClient() client.observeDownloads = { stream } diff --git a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift index af1266733..4abda500c 100644 --- a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -116,11 +116,11 @@ private extension PreviewsReducerDownloadTests { } func makePreviewsNoManifestClient(loadLocalPageURLs: Bool) -> DownloadClient { - let loadLocalPageURLsResult: @Sendable (String) async throws -> [Int: URL] + let loadLocalPageURLsResult: @Sendable (String) async -> [Int: URL]? if loadLocalPageURLs { loadLocalPageURLsResult = { _ in [:] } } else { - loadLocalPageURLsResult = { _ in throw AppError.notFound } + loadLocalPageURLsResult = { _ in nil } } var client = DownloadClient() client.observeDownloads = { AsyncStream { continuation in continuation.finish() } } diff --git a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift index dce552839..cbe55257c 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -145,7 +145,7 @@ private extension ReadingReducerDownloadTests { $0.downloadClient.delete = { _ in } $0.downloadClient.loadManifest = { _ in throw AppError.notFound } $0.downloadClient.loadLocalPageURLs = { gid in - guard gid == gallery.gid else { throw AppError.notFound } + guard gid == gallery.gid else { return nil } return [1: localPageURL] } $0.hapticsClient = .noop diff --git a/EhPandaTests/Tests/Parser/Other/AnimatedImageDataTests.swift b/EhPandaTests/Tests/Parser/Other/AnimatedImageDataTests.swift new file mode 100644 index 000000000..77b796743 --- /dev/null +++ b/EhPandaTests/Tests/Parser/Other/AnimatedImageDataTests.swift @@ -0,0 +1,120 @@ +// +// AnimatedImageDataTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +struct AnimatedImageDataTests { + @Test + func testGIFAnimationRequiresMultipleImageDescriptors() { + #expect(Data(singleFrameGIFBytes).isAnimatedImageData == false) + #expect(Data(animatedGIFBytes).isAnimatedImageData == true) + #expect(Data(singleFrameGIFBytes).animatedImagePasteboardType == nil) + #expect(Data(animatedGIFBytes).animatedImagePasteboardType != nil) + } + + @Test + func testAPNGAnimationControlMustAppearBeforeImageData() { + let animatedAPNG = Data(Self.pngSignature + pngChunk("acTL") + pngChunk("IDAT")) + let staticPNG = Data(Self.pngSignature + pngChunk("IDAT") + pngChunk("acTL")) + + #expect(animatedAPNG.isAnimatedImageData == true) + #expect(staticPNG.isAnimatedImageData == false) + #expect(animatedAPNG.animatedImagePasteboardType != nil) + #expect(staticPNG.animatedImagePasteboardType == nil) + } + + @Test + func testWebPAnimationUsesExtendedAnimationFlag() { + let animatedWebP = Data(webPFile(chunks: [ + webPChunk("VP8X", payload: [0x02] + Array(repeating: 0, count: 9)) + ])) + let staticWebP = Data(webPFile(chunks: [ + webPChunk("VP8X", payload: [0x00] + Array(repeating: 0, count: 9)) + ])) + + #expect(animatedWebP.isAnimatedImageData == true) + #expect(staticWebP.isAnimatedImageData == false) + #expect(animatedWebP.animatedImagePasteboardType != nil) + #expect(staticWebP.animatedImagePasteboardType == nil) + } + + private static let pngSignature: [UInt8] = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A + ] + + private var singleFrameGIFBytes: [UInt8] { + Array("GIF89a".utf8) + [ + 0x01, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, + 0x2C, + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, + 0x00, + 0x02, + 0x02, 0x4C, 0x01, + 0x00, + 0x3B + ] + } + + private var animatedGIFBytes: [UInt8] { + Array(singleFrameGIFBytes.dropLast()) + [ + 0x2C, + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, + 0x00, + 0x02, + 0x02, 0x4C, 0x01, + 0x00, + 0x3B + ] + } + + private func pngChunk(_ type: String, payload: [UInt8] = []) -> [UInt8] { + return bigEndianUInt32(UInt32(payload.count)) + + Array(type.utf8) + + payload + + [0x00, 0x00, 0x00, 0x00] + } + + private func webPFile(chunks: [[UInt8]]) -> [UInt8] { + let payload = chunks.flatMap { $0 } + return Array("RIFF".utf8) + + littleEndianUInt32(4 + payload.count) + + Array("WEBP".utf8) + + payload + } + + private func webPChunk(_ type: String, payload: [UInt8]) -> [UInt8] { + return Array(type.utf8) + + littleEndianUInt32(UInt32(payload.count)) + + payload + + (payload.count.isMultiple(of: 2) ? [] : [0x00]) + } + + private func bigEndianUInt32(_ value: UInt32) -> [UInt8] { + [ + UInt8((value >> 24) & 0xFF), + UInt8((value >> 16) & 0xFF), + UInt8((value >> 8) & 0xFF), + UInt8(value & 0xFF) + ] + } + + private func littleEndianUInt32(_ value: Int) -> [UInt8] { + littleEndianUInt32(UInt32(value)) + } + + private func littleEndianUInt32(_ value: UInt32) -> [UInt8] { + [ + UInt8(value & 0xFF), + UInt8((value >> 8) & 0xFF), + UInt8((value >> 16) & 0xFF), + UInt8((value >> 24) & 0xFF) + ] + } +} From a4d6fa68eb47f492eb0bc67f265d9225f8b5be13 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 17:55:09 +0800 Subject: [PATCH 220/614] Persist background task ownership --- .../DownloadBackgroundTaskStore.swift | 94 +++++++++++++++++++ .../Tools/Utilities/DownloadFileStorage.swift | 4 + .../DownloadBackgroundTaskStoreTests.swift | 69 ++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift create mode 100644 EhPandaTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift diff --git a/EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift b/EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift new file mode 100644 index 000000000..20783bef4 --- /dev/null +++ b/EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift @@ -0,0 +1,94 @@ +// +// DownloadBackgroundTaskStore.swift +// EhPanda +// + +import Foundation + +actor DownloadBackgroundTaskStore { + struct Record: Codable, Equatable, Sendable { + let gid: String + let pageIndex: Int + } + + private let fileURL: URL + private let fileManager: DownloadFileManager + private var records: [Int: Record] + + init( + fileURL: URL, + fileManager: sending FileManager = .default + ) { + self.fileURL = fileURL + self.fileManager = DownloadFileManager(fileManager) + self.records = Self.loadRecords( + fileURL: fileURL, + fileManager: self.fileManager + ) + } + + func record( + taskIdentifier: Int, + gid: String, + pageIndex: Int + ) async { + records[taskIdentifier] = .init(gid: gid, pageIndex: pageIndex) + await save() + } + + func record(taskIdentifier: Int) -> Record? { + records[taskIdentifier] + } + + func records(for gid: String) -> [Int: Record] { + records.filter { $0.value.gid == gid } + } + + @discardableResult + func remove(taskIdentifier: Int) async -> Record? { + let record = records.removeValue(forKey: taskIdentifier) + await save() + return record + } + + func removeAll(for gid: String) async { + records = records.filter { $0.value.gid != gid } + await save() + } + + func removeAll() async { + records.removeAll() + await save() + } + + private static func loadRecords( + fileURL: URL, + fileManager: DownloadFileManager + ) -> [Int: Record] { + guard fileManager.operate({ $0.fileExists(atPath: fileURL.path) }) else { + return [:] + } + do { + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode([Int: Record].self, from: data) + } catch { + Logger.error(error) + return [:] + } + } + + private func save() async { + do { + try fileManager.operate { + try $0.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + } + let data = try JSONEncoder().encode(records) + try data.write(to: fileURL, options: .atomic) + } catch { + Logger.error(error) + } + } +} diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index c6d16c21b..25291f8e5 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -95,6 +95,10 @@ struct DownloadStore: Sendable { rootURL.appendingPathComponent(".queue.json") } + func backgroundTaskRegistryURL() -> URL { + rootURL.appendingPathComponent(".background-tasks.json") + } + func existingPageRelativePaths(folderURL: URL, manifest: DownloadManifest) -> [Int: String] { let pageIndices = Set(manifest.pages.keys) guard !pageIndices.isEmpty else { return [:] } diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift new file mode 100644 index 000000000..8a72ba255 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift @@ -0,0 +1,69 @@ +// +// DownloadBackgroundTaskStoreTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +struct DownloadBackgroundTaskStoreTests { + @Test + func testRecordsPersistByTaskIdentifier() async { + let fixture = makeStore() + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + + await fixture.store.record(taskIdentifier: 41, gid: "123", pageIndex: 7) + + let reloadedStore = DownloadBackgroundTaskStore(fileURL: fixture.fileURL) + #expect(await reloadedStore.record(taskIdentifier: 41) == .init(gid: "123", pageIndex: 7)) + #expect(await reloadedStore.records(for: "123") == [41: .init(gid: "123", pageIndex: 7)]) + } + + @Test + func testRemoveSingleRecord() async { + let fixture = makeStore() + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + + await fixture.store.record(taskIdentifier: 41, gid: "123", pageIndex: 7) + await fixture.store.record(taskIdentifier: 42, gid: "456", pageIndex: 8) + + let removed = await fixture.store.remove(taskIdentifier: 41) + + #expect(removed == .init(gid: "123", pageIndex: 7)) + #expect(await fixture.store.record(taskIdentifier: 41) == nil) + #expect(await fixture.store.record(taskIdentifier: 42) == .init(gid: "456", pageIndex: 8)) + } + + @Test + func testRemoveAllForGallery() async { + let fixture = makeStore() + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + + await fixture.store.record(taskIdentifier: 41, gid: "123", pageIndex: 7) + await fixture.store.record(taskIdentifier: 42, gid: "123", pageIndex: 8) + await fixture.store.record(taskIdentifier: 43, gid: "456", pageIndex: 9) + + await fixture.store.removeAll(for: "123") + + #expect(await fixture.store.records(for: "123").isEmpty) + #expect(await fixture.store.record(taskIdentifier: 43) == .init(gid: "456", pageIndex: 9)) + } + + private struct StoreFixture { + let store: DownloadBackgroundTaskStore + let rootURL: URL + let fileURL: URL + } + + private func makeStore() -> StoreFixture { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let fileURL = rootURL.appendingPathComponent(".background-tasks.json") + return .init( + store: DownloadBackgroundTaskStore(fileURL: fileURL), + rootURL: rootURL, + fileURL: fileURL + ) + } +} From cb9a0b74c01dbfd46e7b33826471349ee72ceaff Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 18:01:51 +0800 Subject: [PATCH 221/614] Add page download transfer adapter --- .../Clients/DownloadClient+Manager.swift | 8 + .../Clients/DownloadClient+Networking.swift | 82 +++++ .../DownloadClient+PageDownloadHelpers.swift | 30 +- .../Clients/DownloadPageDownloader.swift | 316 ++++++++++++++++++ 4 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 EhPanda/App/Tools/Clients/DownloadPageDownloader.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index c89d97a12..b037488cb 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -171,6 +171,8 @@ actor DownloadCoordinator { let storage: DownloadStore let urlSession: URLSession + let pageDownloader: DownloadPageDownloader + let backgroundTaskStore: DownloadBackgroundTaskStore let storedCookiesProvider: @Sendable (URL) -> [HTTPCookie] let libraryClient: LibraryClient /// Supplies the latest runtime settings immediately before a queued download starts. @@ -198,6 +200,8 @@ actor DownloadCoordinator { init( storage: DownloadStore, urlSession: URLSession, + pageDownloader: DownloadPageDownloader? = nil, + backgroundTaskStore: DownloadBackgroundTaskStore? = nil, storedCookiesProvider: @escaping @Sendable (URL) -> [HTTPCookie] = { HTTPCookieStorage.shared.cookies(for: $0) ?? [] }, @@ -210,6 +214,10 @@ actor DownloadCoordinator { ) { self.storage = storage self.urlSession = urlSession + self.pageDownloader = pageDownloader ?? .foreground(urlSession: urlSession) + self.backgroundTaskStore = backgroundTaskStore ?? DownloadBackgroundTaskStore( + fileURL: storage.backgroundTaskRegistryURL() + ) self.storedCookiesProvider = storedCookiesProvider self.libraryClient = libraryClient self.downloadOptionsProvider = downloadOptionsProvider diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift index d978d63b3..cbe513eec 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift @@ -119,6 +119,88 @@ extension DownloadCoordinator { } } + func pageDownloadResponse( + url: URL, + allowsCellular: Bool, + context: DownloadPageTaskContext, + retriesRequest: Bool = true + ) async throws -> DownloadPageTransfer { + var request = URLRequest(url: url) + request.allowsCellularAccess = allowsCellular + return try await pageDownloadResponse( + for: request, + context: context, + retriesRequest: retriesRequest + ) + } + + func pageDownloadResponse( + for request: URLRequest, + context: DownloadPageTaskContext, + retriesRequest: Bool = true + ) async throws -> DownloadPageTransfer { + let performRequest = { + try await self.rawPageDownloadResponse( + for: request, + context: context + ) + } + + let transfer: DownloadPageTransfer + if retriesRequest { + transfer = try await withRetry( + operation: "pageDownloadResponse", + context: [ + "url": request.url?.absoluteString ?? "" + ] + ) { + try await performRequest() + } + } else { + transfer = try await performRequest() + } + + if let error = detectResponseError( + fileURL: transfer.fileURL, + response: transfer.response, + requestURL: request.url + ) { + try? fileManager.operate { + try $0.removeItem(at: transfer.fileURL) + } + if let taskIdentifier = transfer.taskIdentifier { + await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) + } + throw error + } + + return transfer + } + + func rawPageDownloadResponse( + for request: URLRequest, + context: DownloadPageTaskContext + ) async throws -> DownloadPageTransfer { + do { + return try await pageDownloader.download(request, context) + } catch let error as AppError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError + where error.code == .cancelled { + throw CancellationError() + } catch { + if Self.isCancellationLikeError(error) { + throw CancellationError() + } + if error is URLError { + throw AppError.networkingFailed + } + throw AppError.unknown + } + } + func withRetry( operation: String, context: [String: Any], diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift index 9cab9c5db..3d07e408e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -105,10 +105,14 @@ extension DownloadCoordinator { ) async throws -> PageResult { let payload = context.payload let targetURL = resolvedImageSource.imageURL - let (downloadedFileURL, response) = - try await downloadResponse( + let transfer = + try await pageDownloadResponse( url: targetURL, allowsCellular: context.options.allowCellular, + context: .init( + gid: payload.gallery.gid, + pageIndex: index + ), retriesRequest: false ) let relativePath: String @@ -116,11 +120,11 @@ extension DownloadCoordinator { relativePath = preferredRelativePath } else { let prefixData = try readResponsePrefixData( - at: downloadedFileURL + at: transfer.fileURL ) let ext = fileExtension( for: targetURL, - response: response, + response: transfer.response, prefixData: prefixData ) relativePath = storage.makePageRelativePath( @@ -132,10 +136,20 @@ extension DownloadCoordinator { } let fileURL = context.folderURL .appendingPathComponent(relativePath) - try moveDownloadedFile( - from: downloadedFileURL, - to: fileURL - ) + do { + try moveDownloadedFile( + from: transfer.fileURL, + to: fileURL + ) + if let taskIdentifier = transfer.taskIdentifier { + await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) + } + } catch { + if let taskIdentifier = transfer.taskIdentifier { + await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) + } + throw error + } return .init( index: index, relativePath: relativePath, diff --git a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift new file mode 100644 index 000000000..968bcedec --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift @@ -0,0 +1,316 @@ +// +// DownloadPageDownloader.swift +// EhPanda +// + +import Foundation + +struct DownloadPageTaskContext: Equatable, Sendable { + let gid: String + let pageIndex: Int +} + +struct DownloadPageTransfer: Sendable { + let fileURL: URL + let response: URLResponse + let taskIdentifier: Int? +} + +struct DownloadPageDownloader: Sendable { + var download: @Sendable (URLRequest, DownloadPageTaskContext) async throws -> DownloadPageTransfer + + static func foreground(urlSession: URLSession) -> Self { + .init { request, _ in + let (fileURL, response) = try await urlSession.download(for: request) + return .init( + fileURL: fileURL, + response: response, + taskIdentifier: nil + ) + } + } + + static func background( + identifier: String, + taskStore: DownloadBackgroundTaskStore, + holdingDirectory: URL, + fileManager: sending FileManager = .default, + orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void = { _, _, _ in } + ) -> Self { + let session = BackgroundPageDownloadSession( + identifier: identifier, + taskStore: taskStore, + holdingDirectory: holdingDirectory, + fileManager: fileManager, + orphanedCompletionHandler: orphanedCompletionHandler + ) + return .init { request, context in + try await session.download(for: request, context: context) + } + } +} + +private actor BackgroundDownloadTaskHub { + private enum Failure: Error, Sendable { + case cancelled + case app(AppError) + + var error: Error { + switch self { + case .cancelled: + return CancellationError() + case .app(let appError): + return appError + } + } + } + + private var continuations = [Int: CheckedContinuation]() + private var completions = [Int: DownloadPageTransfer]() + private var failures = [Int: Failure]() + + func wait( + taskIdentifier: Int, + startTask: @escaping @Sendable () -> Void, + cancelTask: @escaping @Sendable () -> Void + ) async throws -> DownloadPageTransfer { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + register( + continuation, + taskIdentifier: taskIdentifier + ) + startTask() + } + } onCancel: { + cancelTask() + Task { + await self.cancel(taskIdentifier: taskIdentifier) + } + } + } + + func succeed( + taskIdentifier: Int, + transfer: DownloadPageTransfer + ) -> Bool { + if let continuation = continuations.removeValue(forKey: taskIdentifier) { + continuation.resume(returning: transfer) + return true + } + completions[taskIdentifier] = transfer + return false + } + + func fail( + taskIdentifier: Int, + error: Error + ) -> Bool { + let failure = Self.failure(from: error) + if let continuation = continuations.removeValue(forKey: taskIdentifier) { + continuation.resume(throwing: failure.error) + return true + } + failures[taskIdentifier] = failure + return false + } + + private func register( + _ continuation: CheckedContinuation, + taskIdentifier: Int + ) { + if let transfer = completions.removeValue(forKey: taskIdentifier) { + continuation.resume(returning: transfer) + return + } + if let failure = failures.removeValue(forKey: taskIdentifier) { + continuation.resume(throwing: failure.error) + return + } + continuations[taskIdentifier] = continuation + } + + private func cancel(taskIdentifier: Int) { + if let continuation = continuations.removeValue(forKey: taskIdentifier) { + continuation.resume(throwing: CancellationError()) + return + } + failures[taskIdentifier] = .cancelled + } + + private static func failure(from error: Error) -> Failure { + if error is CancellationError { + return .cancelled + } + if let error = error as? AppError { + return .app(error) + } + if let error = error as? URLError, + error.code == .cancelled { + return .cancelled + } + if DownloadCoordinator.isCancellationLikeError(error) { + return .cancelled + } + if error is URLError { + return .app(.networkingFailed) + } + return .app(.unknown) + } +} + +private actor BackgroundPageDownloadSession { + private let taskStore: DownloadBackgroundTaskStore + private let hub = BackgroundDownloadTaskHub() + private let delegate: BackgroundPageDownloadDelegate + private let session: URLSession + + init( + identifier: String, + taskStore: DownloadBackgroundTaskStore, + holdingDirectory: URL, + fileManager: sending FileManager, + orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void + ) { + self.taskStore = taskStore + let delegate = BackgroundPageDownloadDelegate( + hub: hub, + taskStore: taskStore, + holdingDirectory: holdingDirectory, + fileManager: fileManager, + orphanedCompletionHandler: orphanedCompletionHandler + ) + self.delegate = delegate + let configuration = URLSessionConfiguration.background(withIdentifier: identifier) + configuration.sessionSendsLaunchEvents = true + configuration.isDiscretionary = false + configuration.waitsForConnectivity = true + self.session = URLSession( + configuration: configuration, + delegate: delegate, + delegateQueue: nil + ) + } + + func download( + for request: URLRequest, + context: DownloadPageTaskContext + ) async throws -> DownloadPageTransfer { + let task = session.downloadTask(with: request) + await taskStore.record( + taskIdentifier: task.taskIdentifier, + gid: context.gid, + pageIndex: context.pageIndex + ) + do { + return try await hub.wait( + taskIdentifier: task.taskIdentifier, + startTask: { task.resume() }, + cancelTask: { task.cancel() } + ) + } catch { + await taskStore.remove(taskIdentifier: task.taskIdentifier) + throw error + } + } +} + +private final class BackgroundPageDownloadDelegate: NSObject, URLSessionDownloadDelegate { + private let hub: BackgroundDownloadTaskHub + private let taskStore: DownloadBackgroundTaskStore + private let holdingDirectory: URL + private let fileManager: DownloadFileManager + private let orphanedCompletionHandler: @Sendable (Int, URL, URLResponse) async -> Void + + init( + hub: BackgroundDownloadTaskHub, + taskStore: DownloadBackgroundTaskStore, + holdingDirectory: URL, + fileManager: sending FileManager, + orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void + ) { + self.hub = hub + self.taskStore = taskStore + self.holdingDirectory = holdingDirectory + self.fileManager = DownloadFileManager(fileManager) + self.orphanedCompletionHandler = orphanedCompletionHandler + super.init() + } + + func urlSession( + _: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + let taskIdentifier = downloadTask.taskIdentifier + guard let response = downloadTask.response else { + complete(taskIdentifier: taskIdentifier, error: AppError.notFound) + return + } + + do { + let stagedURL = try stageDownload( + at: location, + taskIdentifier: taskIdentifier + ) + let transfer = DownloadPageTransfer( + fileURL: stagedURL, + response: response, + taskIdentifier: taskIdentifier + ) + Task { + let consumed = await hub.succeed( + taskIdentifier: taskIdentifier, + transfer: transfer + ) + if !consumed { + await orphanedCompletionHandler( + taskIdentifier, + stagedURL, + response + ) + } + } + } catch { + complete(taskIdentifier: taskIdentifier, error: error) + } + } + + func urlSession( + _: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard let error else { return } + complete(taskIdentifier: task.taskIdentifier, error: error) + } + + private func complete( + taskIdentifier: Int, + error: Error + ) { + Task { + _ = await taskStore.remove(taskIdentifier: taskIdentifier) + _ = await hub.fail( + taskIdentifier: taskIdentifier, + error: error + ) + } + } + + private func stageDownload( + at location: URL, + taskIdentifier: Int + ) throws -> URL { + let stagedURL = holdingDirectory + .appendingPathComponent("\(taskIdentifier)-\(UUID().uuidString).download") + try fileManager.operate { + try $0.createDirectory( + at: holdingDirectory, + withIntermediateDirectories: true + ) + try $0.moveItem(at: location, to: stagedURL) + } + return stagedURL + } +} From c94a3a94416bfc40f465933abed3fa9b3731fb4c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 18:07:00 +0800 Subject: [PATCH 222/614] Wire background page downloads --- .../DownloadClient+BackgroundDownloads.swift | 143 ++++++++++++++++++ .../App/Tools/Clients/DownloadClient.swift | 22 ++- .../Clients/DownloadPageDownloader.swift | 38 +++++ .../Tools/Utilities/DownloadFileStorage.swift | 4 + EhPanda/DataFlow/AppDelegateReducer.swift | 11 ++ .../DownloadBackgroundCompletionTests.swift | 98 ++++++++++++ 6 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift create mode 100644 EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift new file mode 100644 index 000000000..95bb9e714 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift @@ -0,0 +1,143 @@ +// +// DownloadClient+BackgroundDownloads.swift +// EhPanda +// + +import Foundation + +actor BackgroundPageCompletionReceiver { + private var coordinator: DownloadCoordinator? + + func setCoordinator(_ coordinator: DownloadCoordinator) { + self.coordinator = coordinator + } + + func handleCompletion( + taskIdentifier: Int, + fileURL: URL, + response: URLResponse + ) async { + await coordinator?.handleBackgroundPageDownloadCompleted( + taskIdentifier: taskIdentifier, + fileURL: fileURL, + response: response + ) + } +} + +extension DownloadCoordinator { + func handleBackgroundPageDownloadCompleted( + taskIdentifier: Int, + fileURL: URL, + response: URLResponse + ) async { + guard let record = await backgroundTaskStore.record( + taskIdentifier: taskIdentifier + ) else { + removeStagedBackgroundFile(fileURL) + return + } + + do { + try await attachBackgroundPageDownload( + record: record, + fileURL: fileURL, + response: response + ) + } catch { + Logger.error(error) + removeStagedBackgroundFile(fileURL) + } + + await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) + await notifyObservers() + await scheduleNextIfNeeded() + } + + private func attachBackgroundPageDownload( + record: DownloadBackgroundTaskStore.Record, + fileURL: URL, + response: URLResponse + ) async throws { + if !hasLoadedIndex { + await reloadDownloadIndex() + } + guard let folderRecord = downloadIndex[record.gid] else { + throw AppError.notFound + } + guard folderRecord.manifest.pages[record.pageIndex] != nil else { + throw AppError.notFound + } + if let error = detectResponseError( + fileURL: fileURL, + response: response, + requestURL: response.url + ) { + failedPageErrors[record.gid, default: [:]][record.pageIndex] = .init( + index: record.pageIndex, + relativePath: nil, + error: error + ) + throw error + } + + let relativePath = try backgroundPageRelativePath( + record: record, + fileURL: fileURL, + response: response, + folderRecord: folderRecord + ) + let destinationURL = folderRecord.folderURL + .appendingPathComponent(relativePath) + if fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) { + removeStagedBackgroundFile(fileURL) + } else { + try moveDownloadedFile(from: fileURL, to: destinationURL) + } + try flushManifestPageProgress( + folderURL: folderRecord.folderURL, + pages: [ + .init( + index: record.pageIndex, + relativePath: relativePath, + imageURL: response.url + ) + ] + ) + } + + private func backgroundPageRelativePath( + record: DownloadBackgroundTaskStore.Record, + fileURL: URL, + response: URLResponse, + folderRecord: DownloadFolderRecord + ) throws -> String { + let existingPages = storage.existingPageRelativePaths( + folderURL: folderRecord.folderURL, + manifest: folderRecord.manifest + ) + if let relativePath = existingPages[record.pageIndex] { + return relativePath + } + + let prefixData = try readResponsePrefixData(at: fileURL) + let ext = fileExtension( + for: response.url ?? URL(fileURLWithPath: "download"), + response: response, + prefixData: prefixData + ) + return storage.makePageRelativePath( + gid: folderRecord.manifest.gid, + token: folderRecord.manifest.token, + index: record.pageIndex, + fileExtension: ext + ) + } + + private func removeStagedBackgroundFile(_ fileURL: URL) { + try? fileManager.operate { + guard $0.fileExists(atPath: fileURL.path) else { return } + try $0.removeItem(at: fileURL) + } + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 9dfe967ce..d67ee8644 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -38,14 +38,34 @@ extension DownloadClient { urlSession: URLSession = .shared, fileManager: sending FileManager = .default ) -> Self { + let storage = DownloadStore(rootURL: rootURL, fileManager: fileManager) + let backgroundTaskStore = DownloadBackgroundTaskStore( + fileURL: storage.backgroundTaskRegistryURL() + ) + let completionReceiver = BackgroundPageCompletionReceiver() + let pageDownloader = DownloadPageDownloader.background( + identifier: DownloadBackgroundSessionEvents.pageSessionIdentifier, + taskStore: backgroundTaskStore, + holdingDirectory: storage.backgroundTransferHoldingDirectoryURL(), + orphanedCompletionHandler: { taskIdentifier, fileURL, response in + await completionReceiver.handleCompletion( + taskIdentifier: taskIdentifier, + fileURL: fileURL, + response: response + ) + } + ) let manager = DownloadCoordinator( - storage: .init(rootURL: rootURL, fileManager: fileManager), + storage: storage, urlSession: urlSession, + pageDownloader: pageDownloader, + backgroundTaskStore: backgroundTaskStore, downloadOptionsProvider: { await DatabaseClient.live.fetchAppEnv().setting.downloadRequestOptions } ) Task { + await completionReceiver.setCoordinator(manager) await manager.reconcileDownloads() await manager.resumeQueue() } diff --git a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift index 968bcedec..5882c8ec4 100644 --- a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift +++ b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift @@ -50,6 +50,34 @@ struct DownloadPageDownloader: Sendable { } } +enum DownloadBackgroundSessionEvents { + static var pageSessionIdentifier: String { + let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.ehpanda" + return "\(bundleIdentifier).downloads.pages" + } + + @MainActor + private static var completionHandlers = [String: () -> Void]() + + @MainActor + static func setCompletionHandler( + _ completionHandler: @escaping () -> Void, + for identifier: String + ) { + completionHandlers[identifier] = completionHandler + } + + @MainActor + static func finishEvents(for identifier: String?) { + guard let identifier, + let completionHandler = completionHandlers.removeValue( + forKey: identifier + ) + else { return } + completionHandler() + } +} + private actor BackgroundDownloadTaskHub { private enum Failure: Error, Sendable { case cancelled @@ -285,6 +313,16 @@ private final class BackgroundPageDownloadDelegate: NSObject, URLSessionDownload complete(taskIdentifier: task.taskIdentifier, error: error) } + func urlSessionDidFinishEvents( + forBackgroundURLSession session: URLSession + ) { + Task { @MainActor in + DownloadBackgroundSessionEvents.finishEvents( + for: session.configuration.identifier + ) + } + } + private func complete( taskIdentifier: Int, error: Error diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift index 25291f8e5..a14d251b1 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -99,6 +99,10 @@ struct DownloadStore: Sendable { rootURL.appendingPathComponent(".background-tasks.json") } + func backgroundTransferHoldingDirectoryURL() -> URL { + rootURL.appendingPathComponent(".background-downloads", isDirectory: true) + } + func existingPageRelativePaths(folderURL: URL, manifest: DownloadManifest) -> [Int: String] { let pageIndices = Set(manifest.pages.keys) guard !pageIndices.isEmpty else { return [:] } diff --git a/EhPanda/DataFlow/AppDelegateReducer.swift b/EhPanda/DataFlow/AppDelegateReducer.swift index 83b777883..67c069e89 100644 --- a/EhPanda/DataFlow/AppDelegateReducer.swift +++ b/EhPanda/DataFlow/AppDelegateReducer.swift @@ -70,4 +70,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } return true } + + func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + DownloadBackgroundSessionEvents.setCompletionHandler( + completionHandler, + for: identifier + ) + } } diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift new file mode 100644 index 000000000..106dafd8a --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift @@ -0,0 +1,98 @@ +// +// DownloadBackgroundCompletionTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { + @Test + func testOrphanedBackgroundCompletionAttachesPageAndClearsTaskRecord() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 901) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore + ) + let folderURL = try writeDownloadFolder( + storage: storage, + gid: gid + ) + await manager.reloadDownloadIndex() + + let taskIdentifier = 77 + let stagedURL = try writeStagedBackgroundFile(storage: storage) + await taskStore.record( + taskIdentifier: taskIdentifier, + gid: gid, + pageIndex: 1 + ) + let responseURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) + let response = try #require(HTTPURLResponse( + url: responseURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/jpeg"] + )) + + await manager.handleBackgroundPageDownloadCompleted( + taskIdentifier: taskIdentifier, + fileURL: stagedURL, + response: response + ) + + let pageRelativePath = storage.makePageRelativePath( + gid: gid, + token: "token", + index: 1, + fileExtension: "jpg" + ) + let pageURL = folderURL.appendingPathComponent(pageRelativePath) + let manifest = try storage.readManifest(folderURL: folderURL) + + #expect(await taskStore.record(taskIdentifier: taskIdentifier) == nil) + #expect(FileManager.default.fileExists(atPath: pageURL.path)) + #expect(FileManager.default.fileExists(atPath: stagedURL.path) == false) + #expect(manifest.pages[1]?.hasPrefix("sha256:") == true) + #expect(try await manager.loadLocalPageURLs(gid: gid).get()[1] == pageURL) + } + + private func writeDownloadFolder( + storage: DownloadFileStorage, + gid: String + ) throws -> URL { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Background") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: "Background", pageCount: 2), + folderURL: folderURL + ) + return folderURL + } + + private func writeStagedBackgroundFile( + storage: DownloadFileStorage + ) throws -> URL { + let holdingDirectory = storage.backgroundTransferHoldingDirectoryURL() + try FileManager.default.createDirectory( + at: holdingDirectory, + withIntermediateDirectories: true + ) + let fileURL = holdingDirectory.appendingPathComponent(UUID().uuidString) + try Data([0x01, 0x02, 0x03]).write(to: fileURL, options: .atomic) + return fileURL + } +} From fd1f6bd3cb3ff27ee9a49be859396a9d60da114a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 18:25:38 +0800 Subject: [PATCH 223/614] Isolate shared cache in fetch fallback test testImageClientFetchImageDownloadsWhenCachedRetrievalFails asserts the retrieve/download fallback runs, but an earlier serialized sibling warms DataCache.shared for the same keys (its defer only clears Kingfisher), so fetchImage short-circuited and the test failed on every run after the first. Clear the keys from DataCache.shared before fetching. --- .../Tests/Download/DownloadManagerRepairSeedTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index 78ac348a8..0044972d3 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -283,6 +283,10 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") ) let expectedCacheKeys = url.imageCacheKeys(includeStableAlias: true) + // Earlier serialized tests in this suite warm DataCache.shared for these + // keys, which would short-circuit fetchImage before the retrieve/download + // fallback this test asserts on. Clear them so the fallback path runs. + try await DataCache.shared.removeData(forKeys: expectedCacheKeys) let retrievedCacheKeys = UncheckedBox([String]()) let downloadedURLs = UncheckedBox([URL]()) let downloadedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in From 3ba3069dcbe78836b234ba9b707ccd209d9df564 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 18:25:46 +0800 Subject: [PATCH 224/614] Clear background task records on download teardown Delete, pause, and settle paths now drop persisted background task records for the gallery, so an orphaned background completion delivered after teardown cannot reattach a stale page to a re-downloaded gallery. --- .../Clients/DownloadClient+Execution.swift | 1 + .../Clients/DownloadClient+PublicAPI.swift | 2 ++ .../Clients/DownloadClient+Scheduling.swift | 2 ++ .../DownloadBackgroundCompletionTests.swift | 30 +++++++++++++++++++ 4 files changed, 35 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index 7d1546896..ded2ae06a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -238,6 +238,7 @@ extension DownloadCoordinator { func settleCompletedDownload(gid: String) async { clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) + await backgroundTaskStore.removeAll(for: gid) } func finishActiveTaskIfOwned( diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 07be9a36a..3c143bfd2 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -174,6 +174,7 @@ extension DownloadCoordinator { guard let download = await fetchDownload(gid: gid) else { clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) + await backgroundTaskStore.removeAll(for: gid) return .failure(.notFound) } do { @@ -190,6 +191,7 @@ extension DownloadCoordinator { // removal above leaves the gallery intact and must not silently dequeue it. clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) + await backgroundTaskStore.removeAll(for: gid) downloadIndex[gid] = nil await notifyObservers() await scheduleNextIfNeeded() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 6b3d1f99e..32e7b8ade 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -171,6 +171,7 @@ extension DownloadCoordinator { ) async throws -> Task? { clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) + await backgroundTaskStore.removeAll(for: gid) await notifyObservers() if activeGalleryID == gid { let task = activeTask @@ -188,6 +189,7 @@ extension DownloadCoordinator { ) async throws { clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) + await backgroundTaskStore.removeAll(for: gid) } func cancelQueuedWorkItem( diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift index 106dafd8a..155aa66d1 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift @@ -66,6 +66,36 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { #expect(try await manager.loadLocalPageURLs(gid: gid).get()[1] == pageURL) } + @Test + func testDeleteClearsPersistedBackgroundTaskRecords() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 902) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore + ) + _ = try writeDownloadFolder(storage: storage, gid: gid) + await manager.reloadDownloadIndex() + + await taskStore.record(taskIdentifier: 77, gid: gid, pageIndex: 1) + await taskStore.record(taskIdentifier: 78, gid: "other", pageIndex: 1) + + let result = await manager.delete(gid: gid) + guard case .success = result else { + Issue.record("Expected delete to succeed, got \(result)") + return + } + + #expect(await taskStore.records(for: gid).isEmpty) + #expect(await taskStore.record(taskIdentifier: 78) == .init(gid: "other", pageIndex: 1)) + } + private func writeDownloadFolder( storage: DownloadFileStorage, gid: String From dd41f50d7e1f3364ddf7f881608a0974f089e0d8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 19:13:30 +0800 Subject: [PATCH 225/614] Clear background task records on folder deletion deleteFolder cancels an active download inside the folder and removes its galleries, so it must also drop their persisted background task records. Otherwise an orphaned completion delivered later could reattach a stale page to a re-downloaded gallery, the same gap delete already guards. --- .../Clients/DownloadClient+Folders.swift | 1 + .../DownloadBackgroundCompletionTests.swift | 35 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift index 2f2a47d3a..d4b5acd05 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift @@ -128,6 +128,7 @@ extension DownloadCoordinator { for gid in containedGIDs { clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) + await backgroundTaskStore.removeAll(for: gid) downloadIndex[gid] = nil } userFolders.removeAll { $0 == name } diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift index 155aa66d1..758cf0748 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift @@ -96,12 +96,43 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { #expect(await taskStore.record(taskIdentifier: 78) == .init(gid: "other", pageIndex: 1)) } + @Test + func testDeleteFolderClearsPersistedBackgroundTaskRecords() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 903) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore + ) + _ = try writeDownloadFolder(storage: storage, gid: gid, folderName: "Doomed") + await manager.reconcileDownloads() + + await taskStore.record(taskIdentifier: 81, gid: gid, pageIndex: 1) + await taskStore.record(taskIdentifier: 82, gid: "other", pageIndex: 1) + + let result = await manager.deleteFolder(name: "Doomed") + guard case .success = result else { + Issue.record("Expected folder delete to succeed, got \(result)") + return + } + + #expect(await taskStore.records(for: gid).isEmpty) + #expect(await taskStore.record(taskIdentifier: 82) == .init(gid: "other", pageIndex: 1)) + } + private func writeDownloadFolder( storage: DownloadFileStorage, - gid: String + gid: String, + folderName: String = "Folder" ) throws -> URL { try storage.ensureRootDirectory() - let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Background") + let folderURL = storage.folderURL(relativePath: "\(folderName)/[\(gid)_token] Background") try FileManager.default.createDirectory( at: folderURL, withIntermediateDirectories: true From 6286e1621ae2d6795e8b49bb54f68c722a6dbfe2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 19:32:07 +0800 Subject: [PATCH 226/614] Inject DataCache into ImageClient for hermetic image tests ImageClient hardcoded the process-global DataCache.shared (7-day on-disk TTL), so DownloadManagerRepairSeedTests' fetchImage tests were green on a clean simulator cache but flaky on repeated local runs. Make dataCache an injectable property and give those tests a throwaway-dir DataCache. Also assert pixel dimensions, not point size: fetchImage round-trips images through Data, which drops UIImage scale. Supersedes the DataCache.shared key-clear in the earlier fetch-fallback test fix. --- EhPanda/App/Tools/Clients/ImageClient.swift | 7 +-- .../DownloadManagerRepairSeedTests.swift | 46 +++++++++++++++---- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 45081d395..357637df1 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -27,6 +27,7 @@ struct ImageClient: Sendable { let downloadImage: @Sendable (URL) async -> Result let retrieveImage: @Sendable (String) async -> Result let isCached: @Sendable (String) -> Bool + var dataCache: DataCache = .shared } extension ImageClient { @@ -222,7 +223,7 @@ extension ImageClient { } let cacheKeys = url.imageCacheKeys(includeStableAlias: true) - if let data = try await DataCache.shared.data(forKeys: cacheKeys) { + if let data = try await dataCache.data(forKeys: cacheKeys) { return data } @@ -233,7 +234,7 @@ extension ImageClient { else { continue } - try? await DataCache.shared.store(data, forKeys: cacheKeys) + try? await dataCache.store(data, forKeys: cacheKeys) return data } @@ -242,7 +243,7 @@ extension ImageClient { guard let data = Self.data(from: image) else { throw AppError.notFound } - try? await DataCache.shared.store(data, forKeys: cacheKeys) + try? await dataCache.store(data, forKeys: cacheKeys) return data case .failure(let error): diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index 0044972d3..0c8320ee0 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -109,10 +109,15 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { KingfisherManager.shared.cache.removeImage(forKey: url.absoluteString) } - let result = await ImageClient.live.fetchImage(url: url) + let (cache, cacheRootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: cacheRootURL) } + var client = ImageClient.live + client.dataCache = cache + + let result = await client.fetchImage(url: url) let fetchedImage = try result.get() - #expect(fetchedImage.size == image.size) + #expect(pixelSize(fetchedImage) == pixelSize(image)) } @MainActor @@ -258,6 +263,8 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { SDImageCache.shared.removeImage(forKey: url.absoluteString) {} } + let (cache, cacheRootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: cacheRootURL) } let client = ImageClient( prefetchImages: { _ in }, saveImageToPhotoLibrary: { _, _ in false }, @@ -267,7 +274,8 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { return .failure(AppError.notFound) }, retrieveImage: ImageClient.live.retrieveImage, - isCached: LibraryClient.live.isCached + isCached: LibraryClient.live.isCached, + dataCache: cache ) let result = await client.fetchImage(url: url) @@ -283,10 +291,8 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") ) let expectedCacheKeys = url.imageCacheKeys(includeStableAlias: true) - // Earlier serialized tests in this suite warm DataCache.shared for these - // keys, which would short-circuit fetchImage before the retrieve/download - // fallback this test asserts on. Clear them so the fallback path runs. - try await DataCache.shared.removeData(forKeys: expectedCacheKeys) + let (cache, cacheRootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: cacheRootURL) } let retrievedCacheKeys = UncheckedBox([String]()) let downloadedURLs = UncheckedBox([URL]()) let downloadedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in @@ -305,7 +311,8 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { retrievedCacheKeys.value.append(cacheKey) return .failure(AppError.notFound) }, - isCached: { _ in true } + isCached: { _ in true }, + dataCache: cache ) let result = await client.fetchImage(url: url) @@ -328,6 +335,29 @@ private extension DownloadManagerRepairSeedTests { } } + /// A `DataCache` backed by a throwaway directory, isolated from `DataCache.shared`. + /// + /// `ImageClient.imageData` consults its `dataCache` before the retrieve/download + /// closures, and `.shared` persists on disk across runs. Injecting a per-test cache + /// keeps these tests hermetic and repeatable without clearing the simulator cache. + func makeIsolatedDataCache() -> (cache: DataCache, rootURL: URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return (DataCache(configuration: .init(rootURL: rootURL)), rootURL) + } + + /// The image's dimensions in pixels (`size` in points × `scale`). + /// + /// `fetchImage` round-trips images through `Data`, which yields a scale-1 image with + /// the original pixel dimensions. Comparing pixels keeps cache-hit assertions stable + /// regardless of the stored image's scale. + func pixelSize(_ image: UIImage) -> CGSize { + .init( + width: image.size.width * image.scale, + height: image.size.height * image.scale + ) + } + func setupRepairSeedFiles( storage: DownloadFileStorage, sourceFolderURL: URL, From 623b6b07a20566ff8550390f9afb3fa9d5e23744 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 21:17:03 +0800 Subject: [PATCH 227/614] Add nl= failover to download page retry On a retryable page-download failure, re-resolve the image URL with the parsed nl/skip-server token (normal: GalleryNormalImageURLRefetchRequest; mpv: skipServerIdentifier) so a broken assigned H@H server fails over to a different one instead of deterministically re-failing both attempts. --- .../DownloadClient+ExecutionSupport.swift | 31 +++++++-- .../Clients/DownloadClient+Manager.swift | 1 + .../DownloadClient+PageDownloadHelpers.swift | 55 +++++++--------- .../Download/DownloadImageParsingTests.swift | 64 +++++++++++++++++++ 4 files changed, 113 insertions(+), 38 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index f540fa7da..bf5f827f2 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -320,13 +320,31 @@ extension DownloadCoordinator { index: Int, payload: DownloadRequestPayload, options: DownloadRequestOptions, - source: ResolvedSource + source: ResolvedSource, + failover: ResolvedImageSource? = nil ) async throws -> ResolvedImageSource { switch source { case .normal(let thumbnailURLs): guard let thumbnailURL = thumbnailURLs[index] else { throw AppError.notFound } + if let failover { + let (imageURLs, _) = try await GalleryNormalImageURLRefetchRequest( + index: index, + pageNum: 0, + galleryURL: payload.gallery.galleryURL ?? payload.host.url, + thumbnailURL: thumbnailURL, + storedImageURL: failover.imageURL, + urlSession: urlSession, + allowsCellular: options.allowCellular + ) + .response() + .get() + guard let imageURL = imageURLs[index] else { + throw AppError.notFound + } + return .init(imageURL: imageURL, mpvSkipServerIdentifier: nil) + } let (imageURLs, _) = try await GalleryNormalImageURLsRequest( thumbnailURLs: [index: thumbnailURL], urlSession: urlSession, @@ -337,7 +355,7 @@ extension DownloadCoordinator { guard let imageURL = imageURLs[index] else { throw AppError.notFound } - return .init(imageURL: imageURL) + return .init(imageURL: imageURL, mpvSkipServerIdentifier: nil) case .mpv(let mpvKey, let imageKeys): guard let gid = Int(payload.gallery.gid) else { @@ -351,15 +369,18 @@ extension DownloadCoordinator { index: index, mpvKey: mpvKey, mpvImageKey: imageKey, - skipServerIdentifier: nil, + skipServerIdentifier: failover?.mpvSkipServerIdentifier, apiURL: payload.host.url.appendingPathComponent("api.php"), urlSession: urlSession, allowsCellular: options.allowCellular, - requiresSkipServerIdentifier: false + requiresSkipServerIdentifier: failover != nil ) .response() .get() - return .init(imageURL: response.imageURL) + return .init( + imageURL: response.imageURL, + mpvSkipServerIdentifier: response.skipServerIdentifier + ) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index b037488cb..c3dfcfd95 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -101,6 +101,7 @@ actor DownloadCoordinator { struct ResolvedImageSource: Sendable { let imageURL: URL + var mpvSkipServerIdentifier: String? } struct PartialDownloadError: Error, Sendable { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift index 3d07e408e..fab550175 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -12,13 +12,34 @@ extension DownloadCoordinator { context: PageDownloadContext, preferredRelativePath: String? ) async throws -> PageResult { + guard let source = context.source else { + throw AppError.notFound + } let attempts = context.options.autoRetryFailedPages ? 2 : 1 var capturedError: AppError = .unknown + var failover: ResolvedImageSource? for _ in 0.. PageResult { - let payload = context.payload - - guard let source = context.source else { - throw AppError.notFound - } - let resolved = try await resolvedImageSource( - index: index, - payload: payload, - options: context.options, - source: source - ) - if let result = try await attemptResolvedCacheRestore( - index: index, - resolvedImageSource: resolved, - context: context, - preferredRelativePath: preferredRelativePath - ) { - return result - } - return try await downloadAndSavePage( - index: index, - resolvedImageSource: resolved, - context: context, - preferredRelativePath: preferredRelativePath - ) - } - private func attemptResolvedCacheRestore( index: Int, resolvedImageSource: ResolvedImageSource, diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift index 54d97f86f..8ca6ec90e 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift @@ -269,4 +269,68 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { #expect(cachedData == nil) } + @Test + func testMPVImageResolutionFailsOverWithSkipServerTokenOnRetry() async throws { + let sessionID = UUID().uuidString + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [ + SharedSessionStubURLProtocol.headerKey: sessionID + ] + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + + let receivedSkipServerTokens = UncheckedBox([String?]()) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + let body = requestBodyData(from: request) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + let skipServer = body?["nl"] as? String + receivedSkipServerTokens.value.append(skipServer) + let imageURL = skipServer == nil + ? "https://example.com/server-a.jpg" + : "https://example.com/server-b.jpg" + let data = try JSONSerialization.data(withJSONObject: ["i": imageURL, "s": "42"]) + return ( + try #require(HTTPURLResponse( + url: request.url ?? Defaults.URL.api, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )), + data + ) + } + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let payload = DownloadRequestPayload( + gallery: sampleGallery(), + galleryDetail: sampleGalleryDetail(gid: "123456", title: "Sample Gallery"), + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + folderName: "Folder", + mode: .initial + ) + let source = DownloadManager.ResolvedSource.mpv("mpvkey", [1: "imgkey1"]) + + let first = try await manager.resolvedImageSource( + index: 1, payload: payload, options: .init(), source: source, failover: nil + ) + let second = try await manager.resolvedImageSource( + index: 1, payload: payload, options: .init(), source: source, failover: first + ) + + #expect(receivedSkipServerTokens.value == [nil, "42"]) + #expect(first.imageURL.absoluteString == "https://example.com/server-a.jpg") + #expect(first.mpvSkipServerIdentifier == "42") + #expect(second.imageURL.absoluteString == "https://example.com/server-b.jpg") + } + } From 5cd0eb145ae572c4d2548f69017bf0da600e4dea Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 21:24:05 +0800 Subject: [PATCH 228/614] Rescan single gallery on reader local-read miss When a local page render fails (file deleted externally since the last scan), trigger a targeted reloadDownloadRecord rescan via the new rescanLocalPageURLs endpoint and refresh localPageURLs, dropping the stale entry instead of leaving a broken placeholder until the next global scan. DES-3-safe: scan only on the surprise, never on the hot path. --- .../Clients/DownloadClient+RetryHelpers.swift | 7 ++++ .../App/Tools/Clients/DownloadClient.swift | 3 ++ .../View/Reading/ReadingReducer+Body.swift | 18 ++++++++- .../DownloadManagerRepairSeedTests.swift | 37 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index 94d4ec82b..2bc107151 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -99,4 +99,11 @@ extension DownloadCoordinator { } return .success(download.localPageURLs) } + + func rescanLocalPageURLs( + gid: String + ) async -> [Int: URL]? { + guard let token = downloadIndex[gid]?.manifest.token else { return nil } + return await reloadDownloadRecord(gid: gid, token: token)?.localPageURLs + } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index d67ee8644..379396ad1 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -23,6 +23,7 @@ struct DownloadClient: Sendable { var delete: @Sendable (String) async throws -> Void var loadManifest: @Sendable (String) async throws -> (DownloadedGallery, DownloadManifest) var loadLocalPageURLs: @Sendable (String) async -> [Int: URL]? + var rescanLocalPageURLs: @Sendable (String) async -> [Int: URL]? var captureCachedPage: @Sendable (String, Int, URL?) async -> Void var loadInspection: @Sendable (String) async throws -> DownloadInspection var fetchFolders: @Sendable () async throws -> [String] @@ -114,6 +115,7 @@ extension DownloadClient { delete: { gid in try await manager.delete(gid: gid).get() }, loadManifest: { gid in try await manager.loadManifest(gid: gid).get() }, loadLocalPageURLs: { gid in try? await manager.loadLocalPageURLs(gid: gid).get() }, + rescanLocalPageURLs: { gid in await manager.rescanLocalPageURLs(gid: gid) }, captureCachedPage: { gid, index, imageURL in await manager.captureCachedPage(gid: gid, index: index, imageURL: imageURL) }, @@ -163,6 +165,7 @@ extension DownloadClient { delete: { _ in }, loadManifest: { _ in throw AppError.notFound }, loadLocalPageURLs: { _ in nil }, + rescanLocalPageURLs: { _ in nil }, captureCachedPage: { _, _, _ in }, loadInspection: { _ in throw AppError.notFound }, fetchFolders: { [] }, diff --git a/EhPanda/View/Reading/ReadingReducer+Body.swift b/EhPanda/View/Reading/ReadingReducer+Body.swift index e387ba124..1271cf7c2 100644 --- a/EhPanda/View/Reading/ReadingReducer+Body.swift +++ b/EhPanda/View/Reading/ReadingReducer+Body.swift @@ -82,7 +82,7 @@ extension ReadingReducer { case .onWebImageFailed(let index): state.imageURLLoadingStates[index] = .failed(.webImageFailed) - return .none + return reduceLocalPageMiss(state: &state, index: index) case .reloadAllWebImages: return reduceReloadAllWebImages(state: &state) @@ -160,6 +160,22 @@ extension ReadingReducer { return .send(.captureCachedPage(index)) } + func reduceLocalPageMiss(state: inout State, index: Int) -> Effect { + guard let url = state.localPageURLs[index], url.isFileURL, + state.gallery.id.isValidGID + else { + return .none + } + let gid = state.gallery.id + let requestID = UUID() + state.localPageRequestID = requestID + return .run { send in + let localPageURLs = await downloadClient.rescanLocalPageURLs(gid) ?? [:] + await send(.loadLocalPageURLsDone(requestID, localPageURLs)) + } + .cancellable(id: ReadingCancelID.loadLocalPageURLs, cancelInFlight: true) + } + func reduceReloadAllWebImages(state: inout State) -> Effect { guard state.contentSource == .remote else { if case .local(let download, let manifest) = state.contentSource { diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index 0c8320ee0..cb3d62b73 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -90,6 +90,43 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) } + @Test + func testRescanLocalPageURLsDropsExternallyDeletedPage() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 71) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let manager = DownloadManager(storage: storage, urlSession: .shared) + + let folderURL = rootURL.appendingPathComponent( + "Folder/\(gid) - Rescan", isDirectory: true + ) + try FileManager.default.createDirectory( + at: folderURL, withIntermediateDirectories: true + ) + try JSONEncoder().encode(sampleManifest(gid: gid, title: "Rescan")).write( + to: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + let pageOneURL = folderURL.appendingPathComponent( + storage.makePageRelativePath(gid: gid, token: "token", index: 1, fileExtension: "jpg") + ) + let pageTwoURL = folderURL.appendingPathComponent( + storage.makePageRelativePath(gid: gid, token: "token", index: 2, fileExtension: "jpg") + ) + try Data([0x01]).write(to: pageOneURL, options: .atomic) + try Data([0x02]).write(to: pageTwoURL, options: .atomic) + await manager.reloadDownloadIndex() + + #expect(await manager.rescanLocalPageURLs(gid: gid) == [1: pageOneURL, 2: pageTwoURL]) + + try FileManager.default.removeItem(at: pageOneURL) + + #expect(await manager.rescanLocalPageURLs(gid: gid) == [2: pageTwoURL]) + } + @MainActor @Test func testImageClientFetchImageUsesStableAliasCacheKey() async throws { From e790ee64c7cc88987aad68d62f46eae8124d864f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 21:32:26 +0800 Subject: [PATCH 229/614] Collapse reader content-source guards to one offline flag Replace the scattered contentSource == .remote fetch/sync guards with a single State.isOffline flag; per-page local routing already lives in the localPageURLs checks and displayImageURLs merge. Genuine mode/render branches (.local re-apply, DB-load-for-remote, original-URL render) survive. Behavior-preserving; keeps .local as the explicit offline mode. --- .../View/Reading/ReadingReducer+Body.swift | 4 ++-- .../Reading/ReadingReducer+Database.swift | 6 +++--- .../Reading/ReadingReducer+ImageFetch.swift | 20 +++++++++---------- EhPanda/View/Reading/ReadingReducer.swift | 2 ++ 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/EhPanda/View/Reading/ReadingReducer+Body.swift b/EhPanda/View/Reading/ReadingReducer+Body.swift index 1271cf7c2..792cb46fc 100644 --- a/EhPanda/View/Reading/ReadingReducer+Body.swift +++ b/EhPanda/View/Reading/ReadingReducer+Body.swift @@ -151,7 +151,7 @@ extension ReadingReducer { func reduceWebImageSucceeded(state: inout State, index: Int) -> Effect { state.imageURLLoadingStates[index] = .idle state.webImageLoadSuccessIndices.insert(index) - guard state.contentSource == .remote, + guard !state.isOffline, state.gallery.id.isValidGID, state.localPageURLs[index] == nil else { @@ -197,7 +197,7 @@ extension ReadingReducer { } func reduceRetryAllFailedWebImages(state: inout State) -> Effect { - guard state.contentSource == .remote else { return .none } + guard !state.isOffline else { return .none } state.imageURLLoadingStates.forEach { (index, loadingState) in if case .failed = loadingState { state.imageURLLoadingStates[index] = .idle diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift index eced0a6bc..6faafdd79 100644 --- a/EhPanda/View/Reading/ReadingReducer+Database.swift +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -16,19 +16,19 @@ extension ReadingReducer { } case .syncPreviewURLs(let previewURLs): - guard state.contentSource == .remote else { return .none } + guard !state.isOffline else { return .none } return .run { [state] _ in await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) } case .syncThumbnailURLs(let thumbnailURLs): - guard state.contentSource == .remote else { return .none } + guard !state.isOffline else { return .none } return .run { [state] _ in await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) } case .syncImageURLs(let imageURLs, let originalImageURLs): - guard state.contentSource == .remote else { return .none } + guard !state.isOffline else { return .none } return .run { [state] _ in await databaseClient.updateImageURLs( gid: state.gallery.id, diff --git a/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift b/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift index 8614febd3..af33f3ad6 100644 --- a/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift +++ b/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift @@ -67,7 +67,7 @@ extension ReadingReducer { } func reduceFetchPreviewURLs(state: inout State, index: Int) -> Effect { - guard state.contentSource == .remote else { + guard !state.isOffline else { state.previewLoadingStates[index] = .idle return .none } @@ -102,7 +102,7 @@ extension ReadingReducer { } func reduceFetchImageURLs(state: inout State, index: Int) -> Effect { - guard state.contentSource == .remote else { + guard !state.isOffline else { state.imageURLLoadingStates[index] = .idle return .none } @@ -118,7 +118,7 @@ extension ReadingReducer { } func reduceRefetchImageURLs(state: inout State, index: Int) -> Effect { - guard state.contentSource == .remote else { + guard !state.isOffline else { state.imageURLLoadingStates[index] = .idle return .none } @@ -136,7 +136,7 @@ extension ReadingReducer { func reducePrefetchImages( state: inout State, index: Int, prefetchLimit: Int ) -> Effect { - guard state.contentSource == .remote else { return .none } + guard !state.isOffline else { return .none } func getPrefetchImageURLs(range: ClosedRange) -> [URL] { (range.lowerBound...range.upperBound).compactMap { index in if let url = state.localPageURLs[index], !url.isFileURL { @@ -187,7 +187,7 @@ extension ReadingReducer { } func reduceFetchThumbnailURLs(state: inout State, index: Int) -> Effect { - guard state.contentSource == .remote else { + guard !state.isOffline else { state.imageURLLoadingStates[index] = .idle return .none } @@ -237,7 +237,7 @@ extension ReadingReducer { func reduceFetchNormalImageURLs( state: inout State, index: Int, thumbnailURLs: [Int: URL] ) -> Effect { - guard state.contentSource == .remote else { + guard !state.isOffline else { state.imageURLLoadingStates[index] = .idle return .none } @@ -275,7 +275,7 @@ extension ReadingReducer { } func reduceRefetchNormalImageURLs(state: inout State, index: Int) -> Effect { - guard state.contentSource == .remote else { + guard !state.isOffline else { state.imageURLLoadingStates[index] = .idle return .none } @@ -329,7 +329,7 @@ extension ReadingReducer { func reduceFetchMPVKeys( state: inout State, index: Int, mpvURL: URL ) -> Effect { - guard state.contentSource == .remote else { + guard !state.isOffline else { state.imageURLLoadingStates[index] = .idle return .none } @@ -375,7 +375,7 @@ extension ReadingReducer { func reduceFetchMPVImageURL( state: inout State, index: Int, isRefresh: Bool ) -> Effect { - guard state.contentSource == .remote else { + guard !state.isOffline else { state.imageURLLoadingStates[index] = .idle return .none } @@ -420,7 +420,7 @@ extension ReadingReducer { } func reduceCaptureCachedPage(state: inout State, index: Int) -> Effect { - guard state.contentSource == .remote, + guard !state.isOffline, state.gallery.id.isValidGID else { return .none diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index 124844b46..091c7a512 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -68,6 +68,8 @@ struct ReadingReducer { self.contentSource = contentSource } + var isOffline: Bool { contentSource != .remote } + // Update func update(stored: inout [Int: T], new: [Int: T], replaceExisting: Bool = true) { guard !new.isEmpty else { return } From de94ef2e3e66589b7575bbb1b1498d7520124e40 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 22:14:47 +0800 Subject: [PATCH 230/614] Route reader render by image bytes via owned fetch Replace the URL-extension dual KF/SD reader pipeline with a single owned fetch (DataCache -> cookied URLSession) feeding a byte-routed render: animated bytes -> SDWebImage AnimatedImage(data:), still bytes -> UIImage. ImageClient.prefetchImages now warms the one DataCache via that path, so prefetch and display share a cache (BUG-1). Local pages render from bytes and never touch SD's disk cache, dropping the .cacheMemoryOnly patch (BUG-13). Covers/previews/cells stay pure Kingfisher (DES-1.3). --- EhPanda/App/Tools/Clients/ImageClient.swift | 64 ++++++---- .../View/Reading/ReadingViewComponents.swift | 109 ++++++++---------- 2 files changed, 85 insertions(+), 88 deletions(-) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 357637df1..f5afca074 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -28,37 +28,23 @@ struct ImageClient: Sendable { let retrieveImage: @Sendable (String) async -> Result let isCached: @Sendable (String) -> Bool var dataCache: DataCache = .shared + var urlSession: URLSession = .shared } extension ImageClient { static let live: Self = .init( prefetchImages: { urls in - let (sdWebImageURLs, kingfisherResources) = urls.reduce( - into: ([URL](), [any Resource]()) - ) { result, url in - if url.isPotentiallyAnimatedImage { - result.0.append(url) - } else { - result.1.append( - KF.ImageResource( - downloadURL: url, - cacheKey: url.stableImageCacheKey ?? url.absoluteString - ) - ) + Task { + await withTaskGroup(of: Void.self) { group in + for url in urls { + group.addTask { + _ = try? await ImageClient.readerImageData( + url: url, dataCache: .shared, urlSession: .shared + ) + } + } } } - if !kingfisherResources.isEmpty { - ImagePrefetcher(resources: kingfisherResources).start() - } - if !sdWebImageURLs.isEmpty { - SDWebImagePrefetcher.shared.prefetchURLs( - sdWebImageURLs, - options: [.lowPriority, .continueInBackground, .handleCookies], - context: [.animatedImageClass: SDAnimatedImage.self], - progress: nil, - completed: nil - ) - } }, saveImageToPhotoLibrary: { (image, isAnimated) in await withCheckedContinuation { continuation in @@ -217,6 +203,36 @@ extension ImageClient { } } + func fetchReaderImageAsset(url: URL) async -> ImageAsset? { + guard let data = try? await Self.readerImageData( + url: url, dataCache: dataCache, urlSession: urlSession + ), let image = data.decodedImage else { + return nil + } + return .init(image: image, data: data) + } + + static func readerImageData( + url: URL, + dataCache: DataCache, + urlSession: URLSession + ) async throws -> Data { + if url.isFileURL { + return try Data(contentsOf: url) + } + let cacheKeys = url.imageCacheKeys(includeStableAlias: true) + if let data = try await dataCache.data(forKeys: cacheKeys) { + return data + } + let (data, response) = try await urlSession.data(for: URLRequest(url: url)) + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) else { + throw AppError.networkingFailed + } + try? await dataCache.store(data, forKeys: cacheKeys) + return data + } + private func imageData(url: URL) async throws -> Data { if url.isFileURL { return try Data(contentsOf: url) diff --git a/EhPanda/View/Reading/ReadingViewComponents.swift b/EhPanda/View/Reading/ReadingViewComponents.swift index 1a08dd036..0dcebf6f8 100644 --- a/EhPanda/View/Reading/ReadingViewComponents.swift +++ b/EhPanda/View/Reading/ReadingViewComponents.swift @@ -240,43 +240,13 @@ struct ImageContainer: View { .frame(width: width, height: height) } @ViewBuilder private func image(url: URL?) -> some View { - if let url, url.isPotentiallyAnimatedImage { - AnimatedImage( - url: url, - options: [.retryFailed, .continueInBackground, .handleCookies], - context: [.callbackQueue: SDCallbackQueue.main], - isAnimating: .constant(isActive), - placeholder: { placeholder(nil) } - ) - .resizable() - .onViewUpdate { imageView, _ in - if !isActive { - imageView.stopAnimating() - } - } - .onSuccess(perform: { image, data, _ in - cacheImageData( - data ?? image.animatedSourceData ?? image.sd_imageData(), - for: url - ) - loadSucceededAction(index) - }) - .onFailure(perform: { _ in loadFailedAction(index) }) - .clipped() - } else { - let isFileURL = url?.isFileURL ?? false - let cacheKey = url.map { url in - isFileURL - ? localFileCacheKey(url) - : url.stableImageCacheKey ?? url.absoluteString - } - KFImage.url(url, cacheKey: cacheKey) - .cacheMemoryOnly(isFileURL) - .placeholder(placeholder) - .defaultModifier(withRoundedCorners: false) - .onSuccess(onSuccess) - .onFailure(onFailure) - } + ByteRoutedReaderImage( + url: url, + isActive: isActive, + placeholder: { placeholder(nil) }, + onSucceeded: { loadSucceededAction(index) }, + onFailed: { loadFailedAction(index) } + ) } var body: some View { @@ -317,38 +287,49 @@ struct ImageContainer: View { } } } - private func onSuccess(_ result: RetrieveImageResult) { - if let imageURL { - cacheImageData(result.data(), for: imageURL) - } - loadSucceededAction(index) - } - private func onFailure(_: KingfisherError) { - if imageURL != nil { - loadFailedAction(index) - } - } +} + +// Renders a reader page from bytes loaded through the owned ImageClient fetch +// (DataCache → cookied URLSession), routing animated bytes to SDWebImage and +// still bytes to UIImage so the engine decides by content, not URL extension. +private struct ByteRoutedReaderImage: View { + let url: URL? + let isActive: Bool + @ViewBuilder let placeholder: () -> Placeholder + let onSucceeded: () -> Void + let onFailed: () -> Void + + @State private var stillImage: UIImage? + @State private var animatedData: Data? - private var emptyProgress: Progress { - Progress(totalUnitCount: 1) + var body: some View { + content.task(id: url) { await load() } } - private func localFileCacheKey(_ url: URL) -> String { - let resourceValues = try? url.resourceValues(forKeys: [ - .contentModificationDateKey, - .fileSizeKey - ]) - let modificationStamp = resourceValues?.contentModificationDate? - .timeIntervalSinceReferenceDate ?? .zero - let fileSize = resourceValues?.fileSize ?? 0 - return "local::\(url.path)#\(fileSize)#\(modificationStamp)" + @ViewBuilder private var content: some View { + if let animatedData { + AnimatedImage(data: animatedData, isAnimating: .constant(isActive)) + .resizable() + } else if let stillImage { + Image(uiImage: stillImage).resizable() + } else { + placeholder() + } } - private func cacheImageData(_ data: Data?, for url: URL) { - guard let data, !url.isFileURL else { return } - let keys = url.imageCacheKeys(includeStableAlias: true) - Task { - try? await DataCache.shared.store(data, forKeys: keys) + private func load() async { + stillImage = nil + animatedData = nil + guard let url else { return } + guard let asset = await ImageClient.live.fetchReaderImageAsset(url: url) else { + onFailed() + return + } + if asset.isAnimated { + animatedData = asset.data + } else { + stillImage = asset.image } + onSucceeded() } } From 925c234e4ea2ee4f365b424ef5d10d94e70bb319 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 22:19:45 +0800 Subject: [PATCH 231/614] Read captureCachedPage bytes from DataCache only Drop the engine->KF/SD cache reach (libraryClient.cachedImageData) from the download cache lookup now that the reader caches page bytes in the owned DataCache. captureCachedPage and download cache-restore read the single DataCache by stable key; removes the now-dead cachedImageData(forKey:). --- .../Tools/Clients/DownloadClient+Cache.swift | 32 ++----------------- 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 34cda7c66..6d36a7074 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -107,7 +107,7 @@ extension DownloadCoordinator { for urls: [URL?], includeStableAlias: Bool ) async -> Data? { - let allKeys = urls + let keys = urls .compactMap { $0 } .flatMap { cacheKeys( @@ -115,35 +115,7 @@ extension DownloadCoordinator { includeStableAlias: includeStableAlias ) } - let keys = allKeys - .reduce(into: [String]()) { partialResult, key in - guard !partialResult.contains(key) else { - return - } - partialResult.append(key) - } - - if let data = try? await DataCache.shared.data(forKeys: keys) { - return data - } - for key in keys { - if let data = await cachedImageData(forKey: key) { - try? await DataCache.shared.store(data, forKeys: keys) - return data - } - } - return nil - } - - func cachedImageData(forKey key: String) async -> Data? { - if let data = try? await DataCache.shared.data(forKey: key) { - return data - } - guard let data = await libraryClient.cachedImageData(key) else { - return nil - } - try? await DataCache.shared.store(data, forKey: key) - return data + return try? await DataCache.shared.data(forKeys: keys) } func validatedCachedAssetData( From ca36c5d34c095c4325f71dee51c6cea8093f107e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 23:29:59 +0800 Subject: [PATCH 232/614] Make DataCache access-date touch non-fatal and scope sweep purge A failed contentAccessDate bump no longer throws out of an otherwise- successful read/write (the read in write() previously fell into the directory-recreation retry, wiping the cache root). The memory cache is now keyed by the on-disk hashed filename so sweepDisk evicts only the swept entries instead of purging the whole memory front. --- EhPanda/App/Tools/Utilities/DataCache.swift | 47 +++++++++++---------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/EhPanda/App/Tools/Utilities/DataCache.swift b/EhPanda/App/Tools/Utilities/DataCache.swift index 1e3847bbb..73149262f 100644 --- a/EhPanda/App/Tools/Utilities/DataCache.swift +++ b/EhPanda/App/Tools/Utilities/DataCache.swift @@ -53,11 +53,12 @@ actor DataCache { } func data(forKey key: String) throws -> Data? { - if let data = memoryCache.object(forKey: key as NSString) { + let filename = Self.filename(forKey: key) + if let data = memoryCache.object(forKey: filename as NSString) { return Data(referencing: data) } - let fileURL = fileURL(forKey: key) + let fileURL = configuration.rootURL.appendingPathComponent(filename) guard fileManager.fileExists(atPath: fileURL.path) else { return nil } if isExpired(fileURL) { try? fileManager.removeItem(at: fileURL) @@ -65,8 +66,9 @@ actor DataCache { } let data = try Data(contentsOf: fileURL) - memoryCache.setObject(data as NSData, forKey: key as NSString, cost: data.count) - try touchAccessDate(for: fileURL) + memoryCache.setObject(data as NSData, forKey: filename as NSString, cost: data.count) + // A failed access-date bump must not fail an otherwise-successful read. + try? touchAccessDate(for: fileURL) return data } @@ -80,8 +82,9 @@ actor DataCache { } func store(_ data: Data, forKey key: String) throws { - memoryCache.setObject(data as NSData, forKey: key as NSString, cost: data.count) - let fileURL = fileURL(forKey: key) + let filename = Self.filename(forKey: key) + memoryCache.setObject(data as NSData, forKey: filename as NSString, cost: data.count) + let fileURL = configuration.rootURL.appendingPathComponent(filename) try write(data, to: fileURL, canRetryDirectoryCreation: true) bytesWrittenSinceSweep += UInt64(data.count) if configuration.sweepByteInterval > 0, @@ -98,8 +101,9 @@ actor DataCache { } func removeData(forKey key: String) throws { - memoryCache.removeObject(forKey: key as NSString) - let fileURL = fileURL(forKey: key) + let filename = Self.filename(forKey: key) + memoryCache.removeObject(forKey: filename as NSString) + let fileURL = configuration.rootURL.appendingPathComponent(filename) guard fileManager.fileExists(atPath: fileURL.path) else { return } try fileManager.removeItem(at: fileURL) } @@ -146,18 +150,11 @@ actor DataCache { func sweepDisk() throws { guard fileManager.fileExists(atPath: configuration.rootURL.path) else { return } - var didRemoveDiskEntries = false - defer { - if didRemoveDiskEntries { - memoryCache.removeAllObjects() - } - } var entries = try diskEntries() let now = Date() if configuration.maxDiskAge > 0 { for entry in entries where now.timeIntervalSince(entry.accessDate) > configuration.maxDiskAge { - try? fileManager.removeItem(at: entry.url) - didRemoveDiskEntries = true + evictDiskEntry(entry) } entries.removeAll { now.timeIntervalSince($0.accessDate) > configuration.maxDiskAge } } @@ -167,13 +164,20 @@ actor DataCache { guard totalSize > configuration.diskSizeLimit else { return } let targetSize = configuration.diskSizeLimit / 2 for entry in entries.sorted(by: { $0.accessDate < $1.accessDate }) { - try? fileManager.removeItem(at: entry.url) - didRemoveDiskEntries = true + evictDiskEntry(entry) totalSize = totalSize > entry.size ? totalSize - entry.size : 0 guard totalSize > targetSize else { break } } } + // Evicts a single entry from disk and drops only its matching memory object. + // The memory cache is keyed by the on-disk hashed filename, so eviction stays + // scoped to the swept keys instead of purging the whole memory front. + private func evictDiskEntry(_ entry: DiskEntry) { + try? fileManager.removeItem(at: entry.url) + memoryCache.removeObject(forKey: entry.url.lastPathComponent as NSString) + } + private func write( _ data: Data, to fileURL: URL, @@ -182,7 +186,8 @@ actor DataCache { do { try ensureDirectory() try data.write(to: fileURL, options: .atomic) - try touchAccessDate(for: fileURL) + // A failed access-date bump must not fail an otherwise-successful write. + try? touchAccessDate(for: fileURL) } catch { guard canRetryDirectoryCreation else { throw error } try? fileManager.removeItem(at: configuration.rootURL) @@ -202,10 +207,6 @@ actor DataCache { try? directoryURL.setResourceValues(resourceValues) } - private func fileURL(forKey key: String) -> URL { - configuration.rootURL.appendingPathComponent(Self.filename(forKey: key)) - } - private static func filename(forKey key: String) -> String { SHA256.hash(data: Data(key.utf8)) .map { String(format: "%02x", $0) } From 07e74ff20291dbdaa4e8955bacfeb03bafd5172e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 23:31:03 +0800 Subject: [PATCH 233/614] Probe image animation bytes in place without full-image copy The APNG/GIF/WebP animation probes copied the entire image via Array(self) before their bounded walk, paying a full-image copy on the reader hot path per probe (every still PNG decode hit the APNG walk). Walk the bytes through withUnsafeBytes instead, so a still image returns after reading only a handful of header bytes with no allocation. --- .../Extensions/AnimatedImage_Extension.swift | 137 +++++++++--------- 1 file changed, 71 insertions(+), 66 deletions(-) diff --git a/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift index be678c169..a0bfcb3e2 100644 --- a/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift +++ b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift @@ -51,22 +51,25 @@ extension Data { var isAPNGFormat: Bool { guard starts(with: ImageDataSignature.pngComplete) else { return false } - let bytes = Array(self) - var offset = ImageDataSignature.pngComplete.count - while offset + 12 <= bytes.count { - let chunkLength = Int(Self.bigEndianUInt32(bytes, offset: offset)) - let chunkTypeOffset = offset + 4 - let chunkDataOffset = offset + 8 - guard chunkDataOffset + chunkLength + 4 <= bytes.count else { return false } - if Self.matches(ImageDataSignature.apngAnimationControl, in: bytes, at: chunkTypeOffset) { - return true - } - if Self.matches(ImageDataSignature.pngImageData, in: bytes, at: chunkTypeOffset) { - return false + // Walk the chunk headers in place; a still PNG returns at the first `IDAT` + // after reading only a few header bytes, so no full-image copy is needed. + return withUnsafeBytes { bytes in + var offset = ImageDataSignature.pngComplete.count + while offset + 12 <= bytes.count { + let chunkLength = Int(Self.bigEndianUInt32(bytes, offset: offset)) + let chunkTypeOffset = offset + 4 + let chunkDataOffset = offset + 8 + guard chunkDataOffset + chunkLength + 4 <= bytes.count else { return false } + if Self.matches(ImageDataSignature.apngAnimationControl, in: bytes, at: chunkTypeOffset) { + return true + } + if Self.matches(ImageDataSignature.pngImageData, in: bytes, at: chunkTypeOffset) { + return false + } + offset = chunkDataOffset + chunkLength + 4 } - offset = chunkDataOffset + chunkLength + 4 + return false } - return false } var isGIFFormat: Bool { @@ -111,72 +114,74 @@ extension Data { private var isAnimatedGIFFormat: Bool { guard isGIFFormat else { return false } - let bytes = Array(self) - guard bytes.count >= 13 else { return false } + return withUnsafeBytes { bytes in + guard bytes.count >= 13 else { return false } - var offset = 13 - if bytes[10] & 0x80 != 0 { - offset += Self.colorTableByteCount(packedField: bytes[10]) - } + var offset = 13 + if bytes[10] & 0x80 != 0 { + offset += Self.colorTableByteCount(packedField: bytes[10]) + } - var imageCount = 0 - while offset < bytes.count { - switch bytes[offset] { - case 0x2C: - imageCount += 1 - guard imageCount <= 1 else { return true } - guard offset + 10 <= bytes.count else { return false } - let packedField = bytes[offset + 9] - offset += 10 - if packedField & 0x80 != 0 { - offset += Self.colorTableByteCount(packedField: packedField) + var imageCount = 0 + while offset < bytes.count { + switch bytes[offset] { + case 0x2C: + imageCount += 1 + guard imageCount <= 1 else { return true } + guard offset + 10 <= bytes.count else { return false } + let packedField = bytes[offset + 9] + offset += 10 + if packedField & 0x80 != 0 { + offset += Self.colorTableByteCount(packedField: packedField) + } + guard offset < bytes.count else { return false } + offset += 1 + guard Self.skipGIFSubBlocks(bytes, offset: &offset) else { return false } + + case 0x21: + offset += 2 + guard Self.skipGIFSubBlocks(bytes, offset: &offset) else { return false } + + case 0x3B: + return false + + default: + return false } - guard offset < bytes.count else { return false } - offset += 1 - guard Self.skipGIFSubBlocks(bytes, offset: &offset) else { return false } - - case 0x21: - offset += 2 - guard Self.skipGIFSubBlocks(bytes, offset: &offset) else { return false } - - case 0x3B: - return false - - default: - return false } + return false } - return false } private var isAnimatedWebPFormat: Bool { guard isWebPFormat else { return false } - let bytes = Array(self) - var offset = 12 - while offset + 8 <= bytes.count { - let chunkTypeOffset = offset - let chunkSize = Int(Self.littleEndianUInt32(bytes, offset: offset + 4)) - let chunkDataOffset = offset + 8 - let paddedChunkSize = chunkSize + (chunkSize % 2) - guard chunkDataOffset + paddedChunkSize <= bytes.count else { return false } - - if Self.matches(ImageDataSignature.webPExtended, in: bytes, at: chunkTypeOffset) { - guard chunkSize >= 1 else { return false } - return bytes[chunkDataOffset] & 0x02 != 0 - } - if Self.matches(ImageDataSignature.webPAnimation, in: bytes, at: chunkTypeOffset) { - return true + return withUnsafeBytes { bytes in + var offset = 12 + while offset + 8 <= bytes.count { + let chunkTypeOffset = offset + let chunkSize = Int(Self.littleEndianUInt32(bytes, offset: offset + 4)) + let chunkDataOffset = offset + 8 + let paddedChunkSize = chunkSize + (chunkSize % 2) + guard chunkDataOffset + paddedChunkSize <= bytes.count else { return false } + + if Self.matches(ImageDataSignature.webPExtended, in: bytes, at: chunkTypeOffset) { + guard chunkSize >= 1 else { return false } + return bytes[chunkDataOffset] & 0x02 != 0 + } + if Self.matches(ImageDataSignature.webPAnimation, in: bytes, at: chunkTypeOffset) { + return true + } + offset = chunkDataOffset + paddedChunkSize } - offset = chunkDataOffset + paddedChunkSize + return false } - return false } private static func colorTableByteCount(packedField: UInt8) -> Int { 3 * (1 << Int((packedField & 0x07) + 1)) } - private static func skipGIFSubBlocks(_ bytes: [UInt8], offset: inout Int) -> Bool { + private static func skipGIFSubBlocks(_ bytes: UnsafeRawBufferPointer, offset: inout Int) -> Bool { while offset < bytes.count { let blockSize = Int(bytes[offset]) offset += 1 @@ -187,7 +192,7 @@ extension Data { return false } - private static func matches(_ expected: [UInt8], in bytes: [UInt8], at offset: Int) -> Bool { + private static func matches(_ expected: [UInt8], in bytes: UnsafeRawBufferPointer, at offset: Int) -> Bool { guard offset >= 0, offset + expected.count <= bytes.count else { return false } for index in expected.indices where bytes[offset + index] != expected[index] { return false @@ -195,7 +200,7 @@ extension Data { return true } - private static func littleEndianUInt32(_ bytes: [UInt8], offset: Int) -> UInt32 { + private static func littleEndianUInt32(_ bytes: UnsafeRawBufferPointer, offset: Int) -> UInt32 { guard offset + 4 <= bytes.count else { return 0 } return UInt32(bytes[offset]) | UInt32(bytes[offset + 1]) << 8 @@ -203,7 +208,7 @@ extension Data { | UInt32(bytes[offset + 3]) << 24 } - private static func bigEndianUInt32(_ bytes: [UInt8], offset: Int) -> UInt32 { + private static func bigEndianUInt32(_ bytes: UnsafeRawBufferPointer, offset: Int) -> UInt32 { guard offset + 4 <= bytes.count else { return 0 } return UInt32(bytes[offset]) << 24 | UInt32(bytes[offset + 1]) << 16 From a4ee5aac3177171a37e9d321181ef3ca02e245f0 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 23:34:23 +0800 Subject: [PATCH 234/614] Cache only decodable reader image bytes readerImageData stored response bytes after only an HTTP-status check, so a 200 carrying a non-image/error body (HTML bandwidth notice) could poison the key until expiry. Require data.decodedImage != nil before storing. --- EhPanda/App/Tools/Clients/ImageClient.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index f5afca074..7f3c5b3a0 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -229,6 +229,11 @@ extension ImageClient { (200..<300).contains(httpResponse.statusCode) else { throw AppError.networkingFailed } + // Only cache decodable image bytes so a 200 carrying an HTML/error body + // (e.g. an E-H bandwidth notice) can't poison the key until expiry. + guard data.decodedImage != nil else { + throw AppError.parseFailed + } try? await dataCache.store(data, forKeys: cacheKeys) return data } From 0b54268fed5ffd5ceaf36e95ea3740abb77a9b21 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 23:36:14 +0800 Subject: [PATCH 235/614] Inject reader image client via @Dependency ByteRoutedReaderImage hardcoded ImageClient.live, bypassing the dependency system so previews/tests couldn't override the reader's image client. Resolve it through @Dependency(\.imageClient) instead. --- EhPanda/View/Reading/ReadingViewComponents.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/EhPanda/View/Reading/ReadingViewComponents.swift b/EhPanda/View/Reading/ReadingViewComponents.swift index 0dcebf6f8..4cd704af9 100644 --- a/EhPanda/View/Reading/ReadingViewComponents.swift +++ b/EhPanda/View/Reading/ReadingViewComponents.swift @@ -6,6 +6,7 @@ import SwiftUI import Kingfisher import SDWebImage import SDWebImageSwiftUI +import ComposableArchitecture // MARK: ImageStackConfig struct ImageStackConfig { @@ -299,6 +300,7 @@ private struct ByteRoutedReaderImage: View { let onSucceeded: () -> Void let onFailed: () -> Void + @Dependency(\.imageClient) private var imageClient @State private var stillImage: UIImage? @State private var animatedData: Data? @@ -321,7 +323,7 @@ private struct ByteRoutedReaderImage: View { stillImage = nil animatedData = nil guard let url else { return } - guard let asset = await ImageClient.live.fetchReaderImageAsset(url: url) else { + guard let asset = await imageClient.fetchReaderImageAsset(url: url) else { onFailed() return } From b0bea6cb79ad427076d50240a8bac70ba9833ab2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 23:38:01 +0800 Subject: [PATCH 236/614] Restore per-image reader download progress The owned reader fetch used urlSession.data(for:), which reports no progress, so the reader showed a progress-less placeholder. When a progress handler is supplied the fetch now streams via bytes(for:) and drives the existing Placeholder progress from expectedContentLength; ByteRoutedReaderImage owns the Progress and the prefetch path keeps the single-shot data(for:) so it stays fast. --- EhPanda/App/Tools/Clients/ImageClient.swift | 58 ++++++++++++++++--- .../View/Reading/ReadingViewComponents.swift | 20 +++++-- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 7f3c5b3a0..a03d520bf 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -203,9 +203,12 @@ extension ImageClient { } } - func fetchReaderImageAsset(url: URL) async -> ImageAsset? { + func fetchReaderImageAsset( + url: URL, + onProgress: (@MainActor @Sendable (Double) -> Void)? = nil + ) async -> ImageAsset? { guard let data = try? await Self.readerImageData( - url: url, dataCache: dataCache, urlSession: urlSession + url: url, dataCache: dataCache, urlSession: urlSession, onProgress: onProgress ), let image = data.decodedImage else { return nil } @@ -215,7 +218,8 @@ extension ImageClient { static func readerImageData( url: URL, dataCache: DataCache, - urlSession: URLSession + urlSession: URLSession, + onProgress: (@MainActor @Sendable (Double) -> Void)? = nil ) async throws -> Data { if url.isFileURL { return try Data(contentsOf: url) @@ -224,11 +228,9 @@ extension ImageClient { if let data = try await dataCache.data(forKeys: cacheKeys) { return data } - let (data, response) = try await urlSession.data(for: URLRequest(url: url)) - guard let httpResponse = response as? HTTPURLResponse, - (200..<300).contains(httpResponse.statusCode) else { - throw AppError.networkingFailed - } + let data = try await downloadReaderData( + url: url, urlSession: urlSession, onProgress: onProgress + ) // Only cache decodable image bytes so a 200 carrying an HTML/error body // (e.g. an E-H bandwidth notice) can't poison the key until expiry. guard data.decodedImage != nil else { @@ -238,6 +240,46 @@ extension ImageClient { return data } + // Streams the body via `bytes(for:)` to drive per-image progress when a + // handler is supplied; otherwise the single-shot `data(for:)` keeps the + // progress-less prefetch path fast. + private static func downloadReaderData( + url: URL, + urlSession: URLSession, + onProgress: (@MainActor @Sendable (Double) -> Void)? + ) async throws -> Data { + guard let onProgress else { + let (data, response) = try await urlSession.data(for: URLRequest(url: url)) + try validateReaderResponse(response) + return data + } + let (bytes, response) = try await urlSession.bytes(for: URLRequest(url: url)) + try validateReaderResponse(response) + let expectedLength = response.expectedContentLength + var data = Data() + if expectedLength > 0 { + data.reserveCapacity(Int(expectedLength)) + } + var lastReportedFraction = 0.0 + for try await byte in bytes { + data.append(byte) + guard expectedLength > 0 else { continue } + let fraction = min(Double(data.count) / Double(expectedLength), 1) + if fraction - lastReportedFraction >= 0.01 { + lastReportedFraction = fraction + await onProgress(fraction) + } + } + return data + } + + private static func validateReaderResponse(_ response: URLResponse) throws { + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) else { + throw AppError.networkingFailed + } + } + private func imageData(url: URL) async throws -> Data { if url.isFileURL { return try Data(contentsOf: url) diff --git a/EhPanda/View/Reading/ReadingViewComponents.swift b/EhPanda/View/Reading/ReadingViewComponents.swift index 4cd704af9..d4ee3871d 100644 --- a/EhPanda/View/Reading/ReadingViewComponents.swift +++ b/EhPanda/View/Reading/ReadingViewComponents.swift @@ -244,7 +244,7 @@ struct ImageContainer: View { ByteRoutedReaderImage( url: url, isActive: isActive, - placeholder: { placeholder(nil) }, + placeholder: { progress in placeholder(progress) }, onSucceeded: { loadSucceededAction(index) }, onFailed: { loadFailedAction(index) } ) @@ -294,15 +294,18 @@ struct ImageContainer: View { // (DataCache → cookied URLSession), routing animated bytes to SDWebImage and // still bytes to UIImage so the engine decides by content, not URL extension. private struct ByteRoutedReaderImage: View { + private static var progressUnitCount: Int64 { 10_000 } + let url: URL? let isActive: Bool - @ViewBuilder let placeholder: () -> Placeholder + @ViewBuilder let placeholder: (Progress?) -> Placeholder let onSucceeded: () -> Void let onFailed: () -> Void @Dependency(\.imageClient) private var imageClient @State private var stillImage: UIImage? @State private var animatedData: Data? + @State private var progress: Progress? var body: some View { content.task(id: url) { await load() } @@ -315,15 +318,22 @@ private struct ByteRoutedReaderImage: View { } else if let stillImage { Image(uiImage: stillImage).resizable() } else { - placeholder() + placeholder(progress) } } - private func load() async { + @MainActor private func load() async { stillImage = nil animatedData = nil + progress = nil guard let url else { return } - guard let asset = await imageClient.fetchReaderImageAsset(url: url) else { + let downloadProgress = Progress(totalUnitCount: Self.progressUnitCount) + progress = downloadProgress + let asset = await imageClient.fetchReaderImageAsset(url: url) { fraction in + downloadProgress.completedUnitCount = Int64(fraction * Double(Self.progressUnitCount)) + } + progress = nil + guard let asset else { onFailed() return } From d4cb7e722a5c6a6ce6cef16b01710d0cd4a1153b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 23:41:17 +0800 Subject: [PATCH 237/614] Add readerImageData unit tests Cover the owned reader fetch contract with a stubbed URLSession: cache miss fetches once and stores, a cached key is served without a network hit, an HTTP error throws and skips the cache, and a non-decodable 200 body (HTML) is rejected and not cached. --- .../Tests/Download/ReaderImageDataTests.swift | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 EhPandaTests/Tests/Download/ReaderImageDataTests.swift diff --git a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift new file mode 100644 index 000000000..b9a3c34e2 --- /dev/null +++ b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift @@ -0,0 +1,154 @@ +// +// ReaderImageDataTests.swift +// EhPandaTests +// + +import Foundation +import Testing +import UIKit +@testable import EhPanda + +@Suite(.serialized) +struct ReaderImageDataTests { + @Test + func testFetchesAndStoresOnCacheMiss() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/fetch.png")) + let imageData = try makePNGData() + let requestCount = UncheckedBox(0) + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + requestCount.value += 1 + return (try makeHTTPResponse(url: url, statusCode: 200), imageData) + } + + let data = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + + #expect(data == imageData) + #expect(requestCount.value == 1) + let cached = try await cache.data( + forKeys: url.imageCacheKeys(includeStableAlias: true) + ) + #expect(cached == imageData) + } + + @Test + func testReturnsCachedBytesWithoutNetwork() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/cached.png")) + let imageData = try makePNGData() + try await cache.store( + imageData, forKeys: url.imageCacheKeys(includeStableAlias: true) + ) + let requestCount = UncheckedBox(0) + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + requestCount.value += 1 + return (try makeHTTPResponse(url: url, statusCode: 200), imageData) + } + + let data = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + + #expect(data == imageData) + #expect(requestCount.value == 0) + } + + @Test + func testThrowsAndSkipsCacheOnHTTPError() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/error.png")) + let imageData = try makePNGData() + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + (try makeHTTPResponse(url: url, statusCode: 503), imageData) + } + + do { + _ = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + Issue.record("Expected readerImageData to throw on an HTTP error") + } catch let error as AppError { + #expect(error == .networkingFailed) + } catch { + Issue.record("Unexpected error: \(error)") + } + let cached = try await cache.data( + forKeys: url.imageCacheKeys(includeStableAlias: true) + ) + #expect(cached == nil) + } + + @Test + func testRejectsAndSkipsCacheForNonDecodableBody() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/notimage.png")) + let htmlData = try #require( + "Your IP has been temporarily banned".data(using: .utf8) + ) + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + (try makeHTTPResponse(url: url, statusCode: 200), htmlData) + } + + do { + _ = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + Issue.record("Expected readerImageData to reject a non-decodable body") + } catch let error as AppError { + #expect(error == .parseFailed) + } catch { + Issue.record("Unexpected error: \(error)") + } + let cached = try await cache.data( + forKeys: url.imageCacheKeys(includeStableAlias: true) + ) + #expect(cached == nil) + } + + private func makeIsolatedDataCache() -> (cache: DataCache, rootURL: URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return (DataCache(configuration: .init(rootURL: rootURL)), rootURL) + } + + private func makeStubbedSession() -> (session: URLSession, sessionID: String) { + let sessionID = UUID().uuidString + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [ + SharedSessionStubURLProtocol.headerKey: sessionID + ] + return (URLSession(configuration: configuration), sessionID) + } + + private func makePNGData() throws -> Data { + let image = UIGraphicsImageRenderer(size: .init(width: 2, height: 2)).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 2, height: 2)) + } + return try #require(image.pngData()) + } +} + +private func makeHTTPResponse(url: URL, statusCode: Int) throws -> HTTPURLResponse { + guard let response = HTTPURLResponse( + url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil + ) else { + throw AppError.unknown + } + return response +} From 622fbfd46b24e201d172e37b08bd769d5ac19577 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 23:45:10 +0800 Subject: [PATCH 238/614] Close observer snapshot-vs-register gap observeDownloads() captured the snapshot on the coordinator actor then registered on the hub actor (two hops); a notify landing in that window stranded a new observer on a stale initial. The hub now pulls the snapshot inside observe(): it registers first, resolves the snapshot via an injected provider, and delivers it only if no notify arrived in the meantime (a generation guard), so the observer never misses an update or ends on a stale value. --- .../Clients/DownloadClient+Manager.swift | 18 ++++++-- .../Clients/DownloadClient+PublicAPI.swift | 8 +++- .../Download/DownloadObserverBatchTests.swift | 42 +++++++++++++++++-- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index c3dfcfd95..9f967d28d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -234,28 +234,40 @@ actor DownloadCoordinator { actor DownloadObserverHub { private var lastObservedDownloads = [DownloadedGallery]() private var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() + private var notifyGeneration = 0 func observe( - initialDownloads: [DownloadedGallery] - ) -> AsyncStream<[DownloadedGallery]> { + snapshot: @Sendable () async -> [DownloadedGallery] + ) async -> AsyncStream<[DownloadedGallery]> { let identifier = UUID() let (stream, continuation) = AsyncStream.makeStream( of: [DownloadedGallery].self ) + // Register before the snapshot resolves so a `notify` landing while the + // snapshot is in flight reaches this observer instead of being missed. observers[identifier] = continuation - continuation.yield(initialDownloads) continuation.onTermination = { [weak self] _ in guard let self else { return } Task { await self.removeObserver(id: identifier) } } + + let generationBeforeSnapshot = notifyGeneration + let initialDownloads = await snapshot() + if notifyGeneration == generationBeforeSnapshot { + // No notify reached this observer during resolution; deliver the snapshot. + continuation.yield(initialDownloads) + } + // Otherwise a fresher value already arrived via notify; skipping the now-stale + // snapshot keeps emissions ordered newest-last. return stream } func notify(_ downloads: [DownloadedGallery]) { guard downloads != lastObservedDownloads else { return } lastObservedDownloads = downloads + notifyGeneration += 1 observers.values.forEach { $0.yield(downloads) } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index 3c143bfd2..d69edf201 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -8,8 +8,12 @@ import Foundation // MARK: - Public API extension DownloadCoordinator { func observeDownloads() async -> AsyncStream<[DownloadedGallery]> { - let downloads = await indexedDownloads() - return await observerHub.observe(initialDownloads: downloads) + // Let the hub pull the snapshot inside its own registration so the + // capture-then-register hop can't strand a new observer on a stale + // initial when a notify lands in the window (BUG-16). + await observerHub.observe { + await self.indexedDownloads() + } } func fetchDownloads() async -> [DownloadedGallery] { diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 24b24e640..e2178f55e 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -162,13 +162,15 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { ) await hub.notify([initialDownload]) - let existingObserverStream = await hub.observe(initialDownloads: [initialDownload]) + let existingSnapshot = [initialDownload] + let existingObserverStream = await hub.observe { existingSnapshot } let existingObserverTask = collectEmissions( from: existingObserverStream, count: 2 ) - let lateObserverStream = await hub.observe(initialDownloads: [updatedDownload]) + let lateSnapshot = [updatedDownload] + let lateObserverStream = await hub.observe { lateSnapshot } let lateObserverTask = collectEmissions( from: lateObserverStream, count: 1 @@ -198,12 +200,46 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { title: "Observer Registration", status: .completed ) - let stream = await hub.observe(initialDownloads: [initialDownload]) + let registrationSnapshot = [initialDownload] + let stream = await hub.observe { registrationSnapshot } let observerTask = collectEmissions(from: stream, count: 1) let emissions = await observerTask.value #expect(emissions == [[initialDownload]]) } + + @Test + func testObserveDeliversNotifyArrivingDuringSnapshotResolution() async throws { + let hub = DownloadObserverHub() + let initialDownload = sampleDownload( + gid: "observer-window", + title: "Observer Window", + status: .queued, + completedPageCount: 0 + ) + let updatedDownload = sampleDownload( + gid: initialDownload.gid, + title: initialDownload.title, + status: .completed, + completedPageCount: initialDownload.pageCount + ) + + // The provider notifies the hub mid-resolution, reproducing a state change + // landing in the capture->register window. The observer must receive the + // notify value, not the stale snapshot captured before it. + let stream = await hub.observe { + await hub.notify([updatedDownload]) + return [initialDownload] + } + let observerTask = collectEmissions(from: stream, count: 1) + + let emissions = try await waitForTaskValue( + observerTask, + timeout: .seconds(1), + description: "observer notify during snapshot resolution" + ) + #expect(emissions == [[updatedDownload]]) + } } private func collectEmissions( From 5e74b7a2ec5d29cdc02a76bf250a0510f46ad225 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 23:48:52 +0800 Subject: [PATCH 239/614] Finish DownloadStore rename and drop the typealias Migrate every DownloadFileStorage type reference (source + tests) to the contract name DownloadStore, drop the back-compat typealias, and rename the three storage files plus their three test files to match. The unrelated L10n.Localizable.DownloadFileStorage string namespace is left intact (a localization key group, not the Swift type). --- ...g.swift => DownloadStore+JSONCoding.swift} | 4 +- ...s.swift => DownloadStore+Operations.swift} | 4 +- ...dFileStorage.swift => DownloadStore.swift} | 4 +- .../View/Downloads/FolderManagerReducer.swift | 2 +- .../DownloadBackgroundCompletionTests.swift | 10 ++--- .../DownloadEnqueueManifestTests.swift | 4 +- .../DownloadFeatureTestFactories.swift | 4 +- .../Download/DownloadFeatureTestHelpers.swift | 2 +- .../DownloadFolderOperationTests.swift | 6 +-- .../Download/DownloadImageParsingTests.swift | 4 +- .../DownloadInterruptedResumeTests.swift | 8 ++-- .../Tests/Download/DownloadIpBanTests.swift | 2 +- .../DownloadManagerCachedURLTests.swift | 2 +- .../DownloadManagerCaptureTests.swift | 6 +-- .../DownloadManagerRepairSeedTests.swift | 10 ++--- .../DownloadManagerStorageTests.swift | 44 +++++++++---------- .../Download/DownloadObserverBatchTests.swift | 2 +- .../DownloadPauseAndReconcileTests.swift | 16 +++---- .../Download/DownloadProcessCacheTests.swift | 8 ++-- .../Tests/Download/DownloadProcessTests.swift | 6 +-- .../DownloadRetryMinimalSourceTests.swift | 6 +-- .../Download/DownloadRetryPagesTests.swift | 6 +-- .../DownloadRetryUpdateFallbackTests.swift | 4 +- .../Download/DownloadSchedulingTests.swift | 6 +-- ...sts.swift => DownloadStoreHashTests.swift} | 10 ++--- ...s.swift => DownloadStoreRepairTests.swift} | 22 +++++----- ...geTests.swift => DownloadStoreTests.swift} | 12 ++--- .../DownloadVersionSignatureTests.swift | 4 +- 28 files changed, 108 insertions(+), 110 deletions(-) rename EhPanda/App/Tools/Utilities/{DownloadFileStorage+JSONCoding.swift => DownloadStore+JSONCoding.swift} (82%) rename EhPanda/App/Tools/Utilities/{DownloadFileStorage+Operations.swift => DownloadStore+Operations.swift} (99%) rename EhPanda/App/Tools/Utilities/{DownloadFileStorage.swift => DownloadStore.swift} (99%) rename EhPandaTests/Tests/Download/{DownloadFileStorageHashTests.swift => DownloadStoreHashTests.swift} (95%) rename EhPandaTests/Tests/Download/{DownloadFileStorageRepairTests.swift => DownloadStoreRepairTests.swift} (92%) rename EhPandaTests/Tests/Download/{DownloadFileStorageTests.swift => DownloadStoreTests.swift} (98%) diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+JSONCoding.swift b/EhPanda/App/Tools/Utilities/DownloadStore+JSONCoding.swift similarity index 82% rename from EhPanda/App/Tools/Utilities/DownloadFileStorage+JSONCoding.swift rename to EhPanda/App/Tools/Utilities/DownloadStore+JSONCoding.swift index 77d030089..87d97987c 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+JSONCoding.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore+JSONCoding.swift @@ -1,11 +1,11 @@ // -// DownloadFileStorage+JSONCoding.swift +// DownloadStore+JSONCoding.swift // EhPanda // import Foundation -extension DownloadFileStorage { +extension DownloadStore { func writeJSON(_ value: T, to url: URL) throws { try JSONEncoder().encode(value).write(to: url, options: .atomic) } diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift similarity index 99% rename from EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift rename to EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift index 56301aaae..b11d9b343 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift @@ -1,11 +1,11 @@ // -// DownloadFileStorage+Operations.swift +// DownloadStore+Operations.swift // EhPanda // import Foundation -extension DownloadFileStorage { +extension DownloadStore { func linkOrCopyReadableAsset(at sourceURL: URL, to destinationURL: URL) throws { guard sanitizeAssetFileIfNeeded(at: sourceURL) else { throw AppError.fileOperationFailed( diff --git a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift b/EhPanda/App/Tools/Utilities/DownloadStore.swift similarity index 99% rename from EhPanda/App/Tools/Utilities/DownloadFileStorage.swift rename to EhPanda/App/Tools/Utilities/DownloadStore.swift index a14d251b1..2c856cbab 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore.swift @@ -1,5 +1,5 @@ // -// DownloadFileStorage.swift +// DownloadStore.swift // EhPanda // @@ -26,8 +26,6 @@ struct DownloadScanResult: Equatable, Sendable { let userFolders: [String] } -typealias DownloadFileStorage = DownloadStore - struct DownloadStore: Sendable { private static let maxFolderComponentByteCount = 255 diff --git a/EhPanda/View/Downloads/FolderManagerReducer.swift b/EhPanda/View/Downloads/FolderManagerReducer.swift index d0028ac6c..0286e3bb6 100644 --- a/EhPanda/View/Downloads/FolderManagerReducer.swift +++ b/EhPanda/View/Downloads/FolderManagerReducer.swift @@ -37,7 +37,7 @@ struct FolderManagerReducer { var folders = [String]() var normalizedEditingFolderName: String? { - DownloadFileStorage.normalizedUserFolderName(editingFolderName) + DownloadStore.normalizedUserFolderName(editingFolderName) } var isEditingNameValid: Bool { diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift index 758cf0748..9523acf21 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift @@ -16,7 +16,7 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) let manager = DownloadManager( storage: storage, @@ -73,7 +73,7 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) let manager = DownloadManager( storage: storage, @@ -103,7 +103,7 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) let manager = DownloadManager( storage: storage, @@ -127,7 +127,7 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { } private func writeDownloadFolder( - storage: DownloadFileStorage, + storage: DownloadStore, gid: String, folderName: String = "Folder" ) throws -> URL { @@ -145,7 +145,7 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { } private func writeStagedBackgroundFile( - storage: DownloadFileStorage + storage: DownloadStore ) throws -> URL { let holdingDirectory = storage.backgroundTransferHoldingDirectoryURL() try FileManager.default.createDirectory( diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index 631910ad7..b94d79726 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -14,7 +14,7 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) let manager = DownloadManager( storage: storage, @@ -83,7 +83,7 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) let manager = DownloadManager( storage: storage, diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 90171e123..e3f9cfe42 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -338,13 +338,13 @@ extension DownloadFeatureTestCase { DownloadRequestOptions() }, taskRunner: DownloadTaskRunner = .init() - ) -> (DownloadFileStorage, DownloadManager) { + ) -> (DownloadStore, DownloadManager) { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] configuration.httpAdditionalHeaders = [ SharedSessionStubURLProtocol.headerKey: sessionID ] - let storage = DownloadFileStorage( + let storage = DownloadStore( rootURL: rootURL, fileManager: .default ) let manager = DownloadManager( diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift index f05552c81..6ad25d6ed 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -158,7 +158,7 @@ extension DownloadFeatureTestCase { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) return DownloadManager( - storage: DownloadFileStorage(rootURL: rootURL, fileManager: .default), + storage: DownloadStore(rootURL: rootURL, fileManager: .default), urlSession: .shared, storedCookiesProvider: storedCookiesProvider ) diff --git a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift index 8cd4c4cc4..c35dd2007 100644 --- a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift @@ -231,7 +231,7 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { // MARK: - Setup Helpers private struct DownloadFolderOperationTestEnvironment { - let storage: DownloadFileStorage + let storage: DownloadStore let manager: DownloadManager let rootURL: URL } @@ -240,14 +240,14 @@ private extension DownloadFolderOperationTests { func makeManager() -> DownloadFolderOperationTestEnvironment { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) return .init(storage: storage, manager: manager, rootURL: rootURL) } @discardableResult func writeGalleryFolder( - storage: DownloadFileStorage, + storage: DownloadStore, folderName: String, gid: String, galleryFolderName: String? = nil diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift index 8ca6ec90e..9eeebebc2 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift @@ -164,7 +164,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { configuration.httpAdditionalHeaders = [ SharedSessionStubURLProtocol.headerKey: sessionID ] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: URLSession(configuration: configuration) @@ -281,7 +281,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { configuration.httpAdditionalHeaders = [ SharedSessionStubURLProtocol.headerKey: sessionID ] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: URLSession(configuration: configuration) diff --git a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift index 18060612f..6de86b5fa 100644 --- a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -43,7 +43,7 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) let folderURL = try writeManifestFolder( @@ -82,7 +82,7 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) let folderURL = try writeManifestFolder( @@ -135,7 +135,7 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) let oldFolderURL = try writeManifestFolder( @@ -168,7 +168,7 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { private extension DownloadInterruptedResumeTests { @discardableResult func writeManifestFolder( - storage: DownloadFileStorage, + storage: DownloadStore, gid: String, title: String, pageHashes: [String] diff --git a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift index 64f1b00f8..65bfcc29d 100644 --- a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift +++ b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift @@ -16,7 +16,7 @@ struct DownloadIpBanTests: DownloadFeatureTestCase { configuration.protocolClasses = [SharedSessionStubURLProtocol.self] configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] let manager = DownloadManager( - storage: DownloadFileStorage( + storage: DownloadStore( rootURL: FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true), fileManager: .default diff --git a/EhPandaTests/Tests/Download/DownloadManagerCachedURLTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCachedURLTests.swift index bffa4b845..a3cf40139 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCachedURLTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCachedURLTests.swift @@ -15,7 +15,7 @@ struct DownloadManagerCachedURLTests { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) let folderRelativePath = "Folder/[900_token] Cached" let folderURL = storage.folderURL(relativePath: folderRelativePath) diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index ebca37798..6a36ed4c7 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -17,7 +17,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -75,7 +75,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) let completedFolderURL = try setupCaptureMissingFilesFolder( rootURL: rootURL, gid: gid @@ -135,7 +135,7 @@ private extension DownloadManagerCaptureTests { rating: 4, pages: [ 1: "sha256:missing", - 2: try DownloadFileStorage().fileHash(at: page2URL) + 2: try DownloadStore().fileHash(at: page2URL) ] ) try JSONEncoder().encode(manifest).write( diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index cb3d62b73..e0588cec6 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -19,7 +19,7 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) try storage.ensureRootDirectory() @@ -75,7 +75,7 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) let (emptyPageURL, goodPageURL) = try setupZeroBytePageFiles( @@ -97,7 +97,7 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) let folderURL = rootURL.appendingPathComponent( @@ -396,7 +396,7 @@ private extension DownloadManagerRepairSeedTests { } func setupRepairSeedFiles( - storage: DownloadFileStorage, + storage: DownloadStore, sourceFolderURL: URL, gid: String ) throws { @@ -457,7 +457,7 @@ private extension DownloadManagerRepairSeedTests { } func setupZeroBytePageFiles( - rootURL: URL, gid: String, storage: DownloadFileStorage + rootURL: URL, gid: String, storage: DownloadStore ) throws -> (URL, URL) { let completedFolderURL = rootURL.appendingPathComponent( "Folder/\(gid) - Pause Race", isDirectory: true diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index 73aa7609c..b381b65f5 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -17,7 +17,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -69,7 +69,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -125,7 +125,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -161,7 +161,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) try storage.ensureRootDirectory() @@ -186,7 +186,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -229,7 +229,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -275,7 +275,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -314,7 +314,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -346,7 +346,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -385,7 +385,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -418,7 +418,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) let manager = DownloadManager( storage: storage, @@ -463,7 +463,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) let manager = DownloadManager( storage: storage, @@ -519,7 +519,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) let manager = DownloadManager( storage: storage, @@ -565,7 +565,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) let manager = DownloadManager( storage: storage, @@ -618,7 +618,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) let manager = DownloadManager( storage: storage, @@ -682,7 +682,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) let scheduledRecorder = ScheduledGalleryRecorder() let taskRunner = DownloadTaskRunner( @@ -746,7 +746,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -802,7 +802,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -855,7 +855,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -900,7 +900,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -942,7 +942,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { private extension DownloadManagerStorageTests { func writeIndexedManifest( - storage: DownloadFileStorage, + storage: DownloadStore, relativePath: String, manifest: DownloadManifest ) throws { @@ -986,7 +986,7 @@ private extension DownloadManagerStorageTests { func setFolderModificationDate( _ date: Date, - storage: DownloadFileStorage, + storage: DownloadStore, relativePath: String ) throws { try FileManager.default.setAttributes( diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index e2178f55e..c19fb9770 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -69,7 +69,7 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) // Warm the (empty) index before seeding so the gallery surfaces only diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index dae35f7bf..4ae417a5f 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -28,7 +28,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: URLSession(configuration: configuration) @@ -80,7 +80,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) try writeManifestFolder( @@ -124,7 +124,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: URLSession(configuration: configuration) @@ -189,7 +189,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: URLSession(configuration: configuration) @@ -222,7 +222,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: URLSession(configuration: configuration) @@ -259,7 +259,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: URLSession(configuration: configuration) ) @@ -299,7 +299,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { private extension DownloadPauseAndReconcileTests { func writeManifestFolder( - storage: DownloadFileStorage, + storage: DownloadStore, gid: String, title: String, pageHashes: [String] @@ -334,7 +334,7 @@ private extension DownloadPauseAndReconcileTests { } func setupCancellationFilterTestFolder( - storage: DownloadFileStorage, + storage: DownloadStore, gid: String ) throws { let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Inspection") diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 793b6f1b1..1a1699d40 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -77,13 +77,13 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { // MARK: - Cache Test Manager Result struct CacheTestManagerResult { - let storage: DownloadFileStorage + let storage: DownloadStore let manager: DownloadManager let metadataResponse: Data } private struct CacheTestDownloadSetup { - let storage: DownloadFileStorage + let storage: DownloadStore let manager: DownloadManager let gid: String let pageIndex: Int @@ -102,7 +102,7 @@ private extension DownloadProcessCacheTests { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: URLSession(configuration: configuration), @@ -246,7 +246,7 @@ private extension DownloadProcessCacheTests { } func setupCacheTestFinalFolder( - storage: DownloadFileStorage, gid: String, + storage: DownloadStore, gid: String, pageCount: Int, missingPageIndex: Int ) throws { diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index e850ab4c3..c648adf44 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -214,7 +214,7 @@ private struct ProcessVerificationContext { private extension DownloadProcessTests { func writeProcessManifestFolder( - storage: DownloadFileStorage, + storage: DownloadStore, gid: String, title: String, pageCount: Int @@ -266,7 +266,7 @@ private extension DownloadProcessTests { } func prepareStaleExistingFolder( - storage: DownloadFileStorage, gid: String, pageIndex: Int, + storage: DownloadStore, gid: String, pageIndex: Int, oldPageCount: Int ) throws -> URL { let staleManifest = try sampleManifest( @@ -293,7 +293,7 @@ private extension DownloadProcessTests { func verifyCompletedProcess( manager: DownloadManager, - storage: DownloadFileStorage, + storage: DownloadStore, context: ProcessVerificationContext ) async throws { let completedDownload = await manager.testingFetchDownload(gid: context.gid) diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 92d86552e..9ec1009cb 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -134,7 +134,7 @@ private struct MinimalSourceTestResult { } private struct MinimalSourceRetrySkipContext { - let storage: DownloadFileStorage + let storage: DownloadStore let manager: DownloadManager let gid: String let pageIndex: Int @@ -168,7 +168,7 @@ private extension DownloadRetryMinimalSourceTests { } func writeFinalManifest( - storage: DownloadFileStorage, + storage: DownloadStore, gid: String, manifest: DownloadManifest, missingPageIndex: Int @@ -182,7 +182,7 @@ private extension DownloadRetryMinimalSourceTests { } func writeFinalManifest( - storage: DownloadFileStorage, + storage: DownloadStore, gid: String, manifest: DownloadManifest, missingPageIndices: Set diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index 6c06ca991..148bbdd90 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -16,7 +16,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) try writeManifestFolder( storage: storage, @@ -60,7 +60,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared @@ -99,7 +99,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { private extension DownloadRetryPagesTests { @discardableResult func writeManifestFolder( - storage: DownloadFileStorage, + storage: DownloadStore, gid: String, title: String, pageHashes: [String] diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index d95e2b30a..432288cf7 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -144,7 +144,7 @@ private extension DownloadRetryUpdateFallbackTests { } func setupImmediateUpdateTestState( - storage: DownloadFileStorage, + storage: DownloadStore, context: DownloadPageContext ) throws { let oldCount = context.pageCount - 5 @@ -156,7 +156,7 @@ private extension DownloadRetryUpdateFallbackTests { } func writeFinalManifest( - storage: DownloadFileStorage, + storage: DownloadStore, gid: String, pageCount: Int ) throws { diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index f3346d4f6..480db60eb 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -16,7 +16,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage( + let storage = DownloadStore( rootURL: rootURL, fileManager: .default ) @@ -99,7 +99,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage( + let storage = DownloadStore( rootURL: rootURL, fileManager: .default ) @@ -152,7 +152,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { private extension DownloadSchedulingTests { func writeQueuedManifest( - storage: DownloadFileStorage, + storage: DownloadStore, gid: String, title: String ) throws { diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift similarity index 95% rename from EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift rename to EhPandaTests/Tests/Download/DownloadStoreHashTests.swift index 421ddecc5..fec606693 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift @@ -1,5 +1,5 @@ // -// DownloadFileStorageHashTests.swift +// DownloadStoreHashTests.swift // EhPandaTests // @@ -7,7 +7,7 @@ import Foundation import Testing @testable import EhPanda -struct DownloadFileStorageHashTests { +struct DownloadStoreHashTests { @Test func testValidateReportsCorruptedPageImageData() throws { let (storage, rootURL) = makeStorage() @@ -81,7 +81,7 @@ struct DownloadFileStorageHashTests { } private func makePreparedDownload( - storage: DownloadFileStorage + storage: DownloadStore ) throws -> (DownloadedGallery, URL) { try storage.ensureRootDirectory() let folderURL = storage.folderURL(relativePath: "123 - Sample") @@ -106,11 +106,11 @@ struct DownloadFileStorageHashTests { return (download, folderURL) } - private func makeStorage() -> (DownloadFileStorage, URL) { + private func makeStorage() -> (DownloadStore, URL) { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) return ( - DownloadFileStorage(rootURL: rootURL, fileManager: .default), + DownloadStore(rootURL: rootURL, fileManager: .default), rootURL ) } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadStoreRepairTests.swift similarity index 92% rename from EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift rename to EhPandaTests/Tests/Download/DownloadStoreRepairTests.swift index a1fbd968f..6a64f35d6 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadStoreRepairTests.swift @@ -1,5 +1,5 @@ // -// DownloadFileStorageRepairTests.swift +// DownloadStoreRepairTests.swift // EhPandaTests // @@ -7,7 +7,7 @@ import Foundation import Testing @testable import EhPanda -struct DownloadFileStorageRepairTests { +struct DownloadStoreRepairTests { @Test func testMaterializeRepairSeedCopiesOnlyManifestCoverAndExistingPageFiles() throws { let (storage, rootURL) = makeStorage() @@ -65,7 +65,7 @@ struct DownloadFileStorageRepairTests { defer { try? FileManager.default.removeItem(at: rootURL) } let fileManager = LinkFailingFileManager() - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: fileManager) + let storage = DownloadStore(rootURL: rootURL, fileManager: fileManager) try storage.ensureRootDirectory() let sourceURL = rootURL.appendingPathComponent("source.bin") @@ -86,19 +86,19 @@ private final class LinkFailingFileManager: FileManager { } private struct TraversalTestEnvironment { - let sourceStorage: DownloadFileStorage - let destStorage: DownloadFileStorage + let sourceStorage: DownloadStore + let destStorage: DownloadStore let sourceFolderURL: URL let destinationFolderURL: URL let manifest: DownloadManifest } -private extension DownloadFileStorageRepairTests { +private extension DownloadStoreRepairTests { func setupTraversalTestEnvironment( sourceRootURL: URL, destRootURL: URL ) throws -> TraversalTestEnvironment { - let sourceStorage = DownloadFileStorage(rootURL: sourceRootURL, fileManager: .default) - let destStorage = DownloadFileStorage(rootURL: destRootURL, fileManager: .default) + let sourceStorage = DownloadStore(rootURL: sourceRootURL, fileManager: .default) + let destStorage = DownloadStore(rootURL: destRootURL, fileManager: .default) try sourceStorage.ensureRootDirectory() try destStorage.ensureRootDirectory() let sourceFolderURL = sourceStorage.folderURL(relativePath: "123 - Source") @@ -136,7 +136,7 @@ private extension DownloadFileStorageRepairTests { func setupRepairSourceFiles( sourceFolderURL: URL, - storage: DownloadFileStorage, + storage: DownloadStore, manifest: DownloadManifest ) throws { try FileManager.default.createDirectory( @@ -183,11 +183,11 @@ private extension DownloadFileStorageRepairTests { ) == false) } - func makeStorage() -> (DownloadFileStorage, URL) { + func makeStorage() -> (DownloadStore, URL) { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) return ( - DownloadFileStorage(rootURL: rootURL, fileManager: .default), + DownloadStore(rootURL: rootURL, fileManager: .default), rootURL ) } diff --git a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift b/EhPandaTests/Tests/Download/DownloadStoreTests.swift similarity index 98% rename from EhPandaTests/Tests/Download/DownloadFileStorageTests.swift rename to EhPandaTests/Tests/Download/DownloadStoreTests.swift index a4949057d..51dfeb8c6 100644 --- a/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadStoreTests.swift @@ -1,5 +1,5 @@ // -// DownloadFileStorageTests.swift +// DownloadStoreTests.swift // EhPandaTests // @@ -7,7 +7,7 @@ import Foundation import Testing @testable import EhPanda -struct DownloadFileStorageTests { +struct DownloadStoreTests { @Test func testWriteReadAndValidateManifest() throws { let (storage, rootURL) = makeStorage() @@ -284,7 +284,7 @@ struct DownloadFileStorageTests { try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) let fileURL = rootURL.appendingPathComponent("123_token_cover.jpg") try Data([0xFF, 0xD8, 0xFF]).write(to: fileURL, options: .atomic) - let storage = DownloadFileStorage( + let storage = DownloadStore( rootURL: rootURL, fileManager: ThrowingAttributesFileManager(failingPath: fileURL.path) ) @@ -472,12 +472,12 @@ private final class ThrowingAttributesFileManager: FileManager { } } -private extension DownloadFileStorageTests { - func makeStorage() -> (DownloadFileStorage, URL) { +private extension DownloadStoreTests { + func makeStorage() -> (DownloadStore, URL) { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) return ( - DownloadFileStorage(rootURL: rootURL, fileManager: .default), + DownloadStore(rootURL: rootURL, fileManager: .default), rootURL ) } diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index f0fd01b23..449c6b89d 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -16,7 +16,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager(storage: storage, urlSession: .shared) try storage.ensureRootDirectory() let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Indexed") @@ -58,7 +58,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let storage = DownloadFileStorage(rootURL: rootURL, fileManager: .default) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let manager = DownloadManager( storage: storage, urlSession: .shared From 5512d2e601e68fcd94a615dc9c6945070e681844 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 23:52:11 +0800 Subject: [PATCH 240/614] Bound BackgroundDownloadTaskHub stash Orphaned tasks and cancel-vs-complete races stash a transfer/failure that no later registration ever claims, so completions/failures could grow without bound over a long-lived session. Back both with a FIFO-bounded map that evicts the oldest entry past a fixed capacity. --- .../Clients/DownloadPageDownloader.swift | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift index 5882c8ec4..7cfe18b06 100644 --- a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift +++ b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift @@ -78,6 +78,40 @@ enum DownloadBackgroundSessionEvents { } } +// A FIFO-bounded map: stash entries left behind by orphaned tasks or cancel-vs- +// complete races (no registration ever claims them) can't grow without bound over +// a long-lived session. +private struct BoundedStash { + private let capacity: Int + private var order = [Int]() + private var entries = [Int: Value]() + + init(capacity: Int) { + self.capacity = capacity + } + + mutating func insert(_ value: Value, forKey key: Int) { + if entries[key] == nil { + order.append(key) + } + entries[key] = value + while order.count > capacity { + let evictedKey = order.removeFirst() + entries[evictedKey] = nil + } + } + + mutating func removeValue(forKey key: Int) -> Value? { + guard let value = entries.removeValue(forKey: key) else { + return nil + } + if let index = order.firstIndex(of: key) { + order.remove(at: index) + } + return value + } +} + private actor BackgroundDownloadTaskHub { private enum Failure: Error, Sendable { case cancelled @@ -93,9 +127,15 @@ private actor BackgroundDownloadTaskHub { } } + private static let stashCapacity = 256 + private var continuations = [Int: CheckedContinuation]() - private var completions = [Int: DownloadPageTransfer]() - private var failures = [Int: Failure]() + private var completions = BoundedStash( + capacity: BackgroundDownloadTaskHub.stashCapacity + ) + private var failures = BoundedStash( + capacity: BackgroundDownloadTaskHub.stashCapacity + ) func wait( taskIdentifier: Int, @@ -126,7 +166,7 @@ private actor BackgroundDownloadTaskHub { continuation.resume(returning: transfer) return true } - completions[taskIdentifier] = transfer + completions.insert(transfer, forKey: taskIdentifier) return false } @@ -139,7 +179,7 @@ private actor BackgroundDownloadTaskHub { continuation.resume(throwing: failure.error) return true } - failures[taskIdentifier] = failure + failures.insert(failure, forKey: taskIdentifier) return false } @@ -163,7 +203,7 @@ private actor BackgroundDownloadTaskHub { continuation.resume(throwing: CancellationError()) return } - failures[taskIdentifier] = .cancelled + failures.insert(.cancelled, forKey: taskIdentifier) } private static func failure(from error: Error) -> Failure { From 623dca59a8278987b143267edb4e6454446c0dcd Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 14 Jun 2026 23:56:04 +0800 Subject: [PATCH 241/614] Route orphaned background page-download failures A background task that failed while the app was dead stashed in the hub's failures map with no consumer. Mirror the orphaned-completion path: when hub.fail finds no waiter, route the error to the coordinator, which records a page failure (in-memory, DES-8) for real errors and clears the persisted task record for both errors and cancellations. The delegate no longer pre-removes the record so the orphan handler can resolve it. --- .../DownloadClient+BackgroundDownloads.swift | 41 +++++++++++ .../App/Tools/Clients/DownloadClient.swift | 6 ++ .../Clients/DownloadPageDownloader.swift | 40 +++++++++-- .../DownloadBackgroundCompletionTests.swift | 68 +++++++++++++++++++ 4 files changed, 148 insertions(+), 7 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift index 95bb9e714..e8d42e30f 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift @@ -23,6 +23,16 @@ actor BackgroundPageCompletionReceiver { response: response ) } + + func handleFailure( + taskIdentifier: Int, + error: AppError? + ) async { + await coordinator?.handleBackgroundPageDownloadFailed( + taskIdentifier: taskIdentifier, + error: error + ) + } } extension DownloadCoordinator { @@ -54,6 +64,37 @@ extension DownloadCoordinator { await scheduleNextIfNeeded() } + func handleBackgroundPageDownloadFailed( + taskIdentifier: Int, + error: AppError? + ) async { + guard let record = await backgroundTaskStore.record( + taskIdentifier: taskIdentifier + ) else { + return + } + + // A non-cancellation error surfaces as a page failure (DES-8: in-memory); + // a cancellation only cleans up the persisted task record below. + if let error { + if !hasLoadedIndex { + await reloadDownloadIndex() + } + if let folderRecord = downloadIndex[record.gid], + folderRecord.manifest.pages[record.pageIndex] != nil { + failedPageErrors[record.gid, default: [:]][record.pageIndex] = .init( + index: record.pageIndex, + relativePath: nil, + error: error + ) + } + } + + await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) + await notifyObservers() + await scheduleNextIfNeeded() + } + private func attachBackgroundPageDownload( record: DownloadBackgroundTaskStore.Record, fileURL: URL, diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 379396ad1..75fd444b7 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -54,6 +54,12 @@ extension DownloadClient { fileURL: fileURL, response: response ) + }, + orphanedFailureHandler: { taskIdentifier, error in + await completionReceiver.handleFailure( + taskIdentifier: taskIdentifier, + error: error + ) } ) let manager = DownloadCoordinator( diff --git a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift index 7cfe18b06..fdbf6294d 100644 --- a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift +++ b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift @@ -35,14 +35,16 @@ struct DownloadPageDownloader: Sendable { taskStore: DownloadBackgroundTaskStore, holdingDirectory: URL, fileManager: sending FileManager = .default, - orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void = { _, _, _ in } + orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void = { _, _, _ in }, + orphanedFailureHandler: @escaping @Sendable (Int, AppError?) async -> Void = { _, _ in } ) -> Self { let session = BackgroundPageDownloadSession( identifier: identifier, taskStore: taskStore, holdingDirectory: holdingDirectory, fileManager: fileManager, - orphanedCompletionHandler: orphanedCompletionHandler + orphanedCompletionHandler: orphanedCompletionHandler, + orphanedFailureHandler: orphanedFailureHandler ) return .init { request, context in try await session.download(for: request, context: context) @@ -225,6 +227,17 @@ private actor BackgroundDownloadTaskHub { } return .app(.unknown) } + + // Classifies a delegate error for the orphaned-failure route: `nil` for a + // cancellation (clean up the task record only), the AppError otherwise. + static func orphanedFailureError(from error: Error) -> AppError? { + switch failure(from: error) { + case .cancelled: + return nil + case .app(let appError): + return appError + } + } } private actor BackgroundPageDownloadSession { @@ -238,7 +251,8 @@ private actor BackgroundPageDownloadSession { taskStore: DownloadBackgroundTaskStore, holdingDirectory: URL, fileManager: sending FileManager, - orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void + orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void, + orphanedFailureHandler: @escaping @Sendable (Int, AppError?) async -> Void ) { self.taskStore = taskStore let delegate = BackgroundPageDownloadDelegate( @@ -246,7 +260,8 @@ private actor BackgroundPageDownloadSession { taskStore: taskStore, holdingDirectory: holdingDirectory, fileManager: fileManager, - orphanedCompletionHandler: orphanedCompletionHandler + orphanedCompletionHandler: orphanedCompletionHandler, + orphanedFailureHandler: orphanedFailureHandler ) self.delegate = delegate let configuration = URLSessionConfiguration.background(withIdentifier: identifier) @@ -289,19 +304,22 @@ private final class BackgroundPageDownloadDelegate: NSObject, URLSessionDownload private let holdingDirectory: URL private let fileManager: DownloadFileManager private let orphanedCompletionHandler: @Sendable (Int, URL, URLResponse) async -> Void + private let orphanedFailureHandler: @Sendable (Int, AppError?) async -> Void init( hub: BackgroundDownloadTaskHub, taskStore: DownloadBackgroundTaskStore, holdingDirectory: URL, fileManager: sending FileManager, - orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void + orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void, + orphanedFailureHandler: @escaping @Sendable (Int, AppError?) async -> Void ) { self.hub = hub self.taskStore = taskStore self.holdingDirectory = holdingDirectory self.fileManager = DownloadFileManager(fileManager) self.orphanedCompletionHandler = orphanedCompletionHandler + self.orphanedFailureHandler = orphanedFailureHandler super.init() } @@ -368,11 +386,19 @@ private final class BackgroundPageDownloadDelegate: NSObject, URLSessionDownload error: Error ) { Task { - _ = await taskStore.remove(taskIdentifier: taskIdentifier) - _ = await hub.fail( + // The in-process waiter removes its own task record when `wait` throws; + // an orphaned failure (no waiter) is routed instead, mirroring the + // orphaned-completion path, so it isn't stashed and dropped silently. + let consumed = await hub.fail( taskIdentifier: taskIdentifier, error: error ) + if !consumed { + await orphanedFailureHandler( + taskIdentifier, + BackgroundDownloadTaskHub.orphanedFailureError(from: error) + ) + } } } diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift index 9523acf21..3113739ff 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift @@ -66,6 +66,74 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { #expect(try await manager.loadLocalPageURLs(gid: gid).get()[1] == pageURL) } + @Test + func testOrphanedBackgroundFailureRecordsPageFailureAndClearsTaskRecord() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 904) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore + ) + _ = try writeDownloadFolder(storage: storage, gid: gid) + await manager.reloadDownloadIndex() + + let taskIdentifier = 91 + await taskStore.record( + taskIdentifier: taskIdentifier, + gid: gid, + pageIndex: 1 + ) + + await manager.handleBackgroundPageDownloadFailed( + taskIdentifier: taskIdentifier, + error: .networkingFailed + ) + + #expect(await taskStore.record(taskIdentifier: taskIdentifier) == nil) + let inspection = try await manager.loadInspection(gid: gid).get() + #expect(inspection.failedPageIndices.contains(1)) + } + + @Test + func testOrphanedBackgroundCancellationClearsTaskRecordWithoutPageFailure() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 905) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let manager = DownloadManager( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore + ) + _ = try writeDownloadFolder(storage: storage, gid: gid) + await manager.reloadDownloadIndex() + + let taskIdentifier = 92 + await taskStore.record( + taskIdentifier: taskIdentifier, + gid: gid, + pageIndex: 1 + ) + + await manager.handleBackgroundPageDownloadFailed( + taskIdentifier: taskIdentifier, + error: nil + ) + + #expect(await taskStore.record(taskIdentifier: taskIdentifier) == nil) + let inspection = try await manager.loadInspection(gid: gid).get() + #expect(inspection.failedPageIndices.isEmpty) + } + @Test func testDeleteClearsPersistedBackgroundTaskRecords() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 902) From 275bb6eab81836a93434c40b6ec6a3624b5ecdb3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 00:31:39 +0800 Subject: [PATCH 242/614] Rename L10n namespace to DownloadStore Finish the DownloadStore rename in the localization layer: rename the download_file_storage.* string keys to download_store.* across all 8 localizations (en, zh-Hans, de, ja, ko, zh-Hant, zh-Hant-TW, zh-Hant-HK), regenerate Strings.swift via SwiftGen, and update the L10n.Localizable .DownloadFileStorage call sites. No DownloadFileStorage reference remains. Build + 262 tests green. --- EhPanda/App/Generated/Strings.swift | 78 +++++++++---------- .../Clients/DownloadClient+Folders.swift | 16 ++-- .../Clients/DownloadClient+PublicAPI.swift | 2 +- .../Utilities/DownloadStore+Operations.swift | 18 ++--- .../App/Tools/Utilities/DownloadStore.swift | 2 +- EhPanda/App/de.lproj/Localizable.strings | 28 +++---- EhPanda/App/en.lproj/Localizable.strings | 30 +++---- EhPanda/App/ja.lproj/Localizable.strings | 28 +++---- EhPanda/App/ko.lproj/Localizable.strings | 44 +++++------ EhPanda/App/zh-Hans.lproj/Localizable.strings | 30 +++---- .../App/zh-Hant-HK.lproj/Localizable.strings | 28 +++---- .../App/zh-Hant-TW.lproj/Localizable.strings | 28 +++---- EhPanda/App/zh-Hant.lproj/Localizable.strings | 28 +++---- .../View/Downloads/FolderManagerReducer.swift | 2 +- .../Tests/Download/DownloadStoreTests.swift | 4 +- 15 files changed, 183 insertions(+), 183 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 9aed487b7..6515ea05f 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -595,70 +595,70 @@ internal enum L10n { } } } - internal enum DownloadFileStorage { + internal enum DownloadSettingView { + /// Download + internal static let title = L10n.tr("Localizable", "download_setting_view.title", fallback: "Download") + internal enum Footer { + /// Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder. + internal static let network = L10n.tr("Localizable", "download_setting_view.footer.network", fallback: "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder.") + } + internal enum Section { + internal enum Title { + /// Download Queue + internal static let downloadQueue = L10n.tr("Localizable", "download_setting_view.section.title.download_queue", fallback: "Download Queue") + /// Network + internal static let network = L10n.tr("Localizable", "download_setting_view.section.title.network", fallback: "Network") + } + } + internal enum Title { + /// Allow cellular downloads + internal static let allowCellularDownloads = L10n.tr("Localizable", "download_setting_view.title.allow_cellular_downloads", fallback: "Allow cellular downloads") + /// Concurrent image downloads + internal static let concurrentImageDownloads = L10n.tr("Localizable", "download_setting_view.title.concurrent_image_downloads", fallback: "Concurrent image downloads") + /// Retry failed pages automatically + internal static let retryFailedPagesAutomatically = L10n.tr("Localizable", "download_setting_view.title.retry_failed_pages_automatically", fallback: "Retry failed pages automatically") + } + } + internal enum DownloadStore { internal enum Error { /// Asset file is unreadable: %@ internal static func assetUnreadable(_ p1: Any) -> String { - return L10n.tr("Localizable", "download_file_storage.error.asset_unreadable", String(describing: p1), fallback: "Asset file is unreadable: %@") + return L10n.tr("Localizable", "download_store.error.asset_unreadable", String(describing: p1), fallback: "Asset file is unreadable: %@") } /// The download is currently active. - internal static let downloadBusy = L10n.tr("Localizable", "download_file_storage.error.download_busy", fallback: "The download is currently active.") + internal static let downloadBusy = L10n.tr("Localizable", "download_store.error.download_busy", fallback: "The download is currently active.") /// A folder with this name already exists. - internal static let folderAlreadyExists = L10n.tr("Localizable", "download_file_storage.error.folder_already_exists", fallback: "A folder with this name already exists.") + internal static let folderAlreadyExists = L10n.tr("Localizable", "download_store.error.folder_already_exists", fallback: "A folder with this name already exists.") /// The folder contains an active download. - internal static let folderBusyDownloading = L10n.tr("Localizable", "download_file_storage.error.folder_busy_downloading", fallback: "The folder contains an active download.") + internal static let folderBusyDownloading = L10n.tr("Localizable", "download_store.error.folder_busy_downloading", fallback: "The folder contains an active download.") /// The folder name is invalid. - internal static let invalidFolderName = L10n.tr("Localizable", "download_file_storage.error.invalid_folder_name", fallback: "The folder name is invalid.") + internal static let invalidFolderName = L10n.tr("Localizable", "download_store.error.invalid_folder_name", fallback: "The folder name is invalid.") } internal enum Validation { /// Cover image data is corrupted. - internal static let coverImageCorrupted = L10n.tr("Localizable", "download_file_storage.validation.cover_image_corrupted", fallback: "Cover image data is corrupted.") + internal static let coverImageCorrupted = L10n.tr("Localizable", "download_store.validation.cover_image_corrupted", fallback: "Cover image data is corrupted.") /// Cover image is missing. - internal static let coverImageMissing = L10n.tr("Localizable", "download_file_storage.validation.cover_image_missing", fallback: "Cover image is missing.") + internal static let coverImageMissing = L10n.tr("Localizable", "download_store.validation.cover_image_missing", fallback: "Cover image is missing.") /// Download folder is missing. - internal static let downloadFolderMissing = L10n.tr("Localizable", "download_file_storage.validation.download_folder_missing", fallback: "Download folder is missing.") + internal static let downloadFolderMissing = L10n.tr("Localizable", "download_store.validation.download_folder_missing", fallback: "Download folder is missing.") /// Download folder could not be resolved. - internal static let downloadFolderUnresolved = L10n.tr("Localizable", "download_file_storage.validation.download_folder_unresolved", fallback: "Download folder could not be resolved.") + internal static let downloadFolderUnresolved = L10n.tr("Localizable", "download_store.validation.download_folder_unresolved", fallback: "Download folder could not be resolved.") /// Downloaded pages are incomplete. - internal static let downloadedPagesIncomplete = L10n.tr("Localizable", "download_file_storage.validation.downloaded_pages_incomplete", fallback: "Downloaded pages are incomplete.") + internal static let downloadedPagesIncomplete = L10n.tr("Localizable", "download_store.validation.downloaded_pages_incomplete", fallback: "Downloaded pages are incomplete.") /// Manifest file is corrupted. - internal static let manifestCorrupted = L10n.tr("Localizable", "download_file_storage.validation.manifest_corrupted", fallback: "Manifest file is corrupted.") + internal static let manifestCorrupted = L10n.tr("Localizable", "download_store.validation.manifest_corrupted", fallback: "Manifest file is corrupted.") /// Manifest file is missing. - internal static let manifestMissing = L10n.tr("Localizable", "download_file_storage.validation.manifest_missing", fallback: "Manifest file is missing.") + internal static let manifestMissing = L10n.tr("Localizable", "download_store.validation.manifest_missing", fallback: "Manifest file is missing.") /// Page %d image data is corrupted. internal static func pageImageCorrupted(_ p1: Int) -> String { - return L10n.tr("Localizable", "download_file_storage.validation.page_image_corrupted", p1, fallback: "Page %d image data is corrupted.") + return L10n.tr("Localizable", "download_store.validation.page_image_corrupted", p1, fallback: "Page %d image data is corrupted.") } /// Page %d is missing. internal static func pageMissing(_ p1: Int) -> String { - return L10n.tr("Localizable", "download_file_storage.validation.page_missing", p1, fallback: "Page %d is missing.") + return L10n.tr("Localizable", "download_store.validation.page_missing", p1, fallback: "Page %d is missing.") } } } - internal enum DownloadSettingView { - /// Download - internal static let title = L10n.tr("Localizable", "download_setting_view.title", fallback: "Download") - internal enum Footer { - /// Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder. - internal static let network = L10n.tr("Localizable", "download_setting_view.footer.network", fallback: "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder.") - } - internal enum Section { - internal enum Title { - /// Download Queue - internal static let downloadQueue = L10n.tr("Localizable", "download_setting_view.section.title.download_queue", fallback: "Download Queue") - /// Network - internal static let network = L10n.tr("Localizable", "download_setting_view.section.title.network", fallback: "Network") - } - } - internal enum Title { - /// Allow cellular downloads - internal static let allowCellularDownloads = L10n.tr("Localizable", "download_setting_view.title.allow_cellular_downloads", fallback: "Allow cellular downloads") - /// Concurrent image downloads - internal static let concurrentImageDownloads = L10n.tr("Localizable", "download_setting_view.title.concurrent_image_downloads", fallback: "Concurrent image downloads") - /// Retry failed pages automatically - internal static let retryFailedPagesAutomatically = L10n.tr("Localizable", "download_setting_view.title.retry_failed_pages_automatically", fallback: "Retry failed pages automatically") - } - } internal enum DownloadsView { internal enum Button { /// Clear Filters diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift index d4b5acd05..0b5d32250 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift @@ -15,7 +15,7 @@ extension DownloadCoordinator { guard let normalizedName = storage.normalizedUserFolderName(name) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Error.invalidFolderName + L10n.Localizable.DownloadStore.Error.invalidFolderName ) ) } @@ -23,7 +23,7 @@ extension DownloadCoordinator { guard !fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Error.folderAlreadyExists + L10n.Localizable.DownloadStore.Error.folderAlreadyExists ) ) } @@ -45,7 +45,7 @@ extension DownloadCoordinator { guard let normalizedName = storage.normalizedUserFolderName(newName) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Error.invalidFolderName + L10n.Localizable.DownloadStore.Error.invalidFolderName ) ) } @@ -60,7 +60,7 @@ extension DownloadCoordinator { guard !fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Error.folderAlreadyExists + L10n.Localizable.DownloadStore.Error.folderAlreadyExists ) ) } @@ -70,7 +70,7 @@ extension DownloadCoordinator { downloadIndex[activeGalleryID]?.parentFolderName == oldName { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Error.folderBusyDownloading + L10n.Localizable.DownloadStore.Error.folderBusyDownloading ) ) } @@ -144,7 +144,7 @@ extension DownloadCoordinator { guard let normalizedName = storage.normalizedUserFolderName(folderName) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Error.invalidFolderName + L10n.Localizable.DownloadStore.Error.invalidFolderName ) ) } @@ -158,7 +158,7 @@ extension DownloadCoordinator { guard activeGalleryID != gid else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Error.downloadBusy + L10n.Localizable.DownloadStore.Error.downloadBusy ) ) } @@ -173,7 +173,7 @@ extension DownloadCoordinator { guard !fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Error.folderAlreadyExists + L10n.Localizable.DownloadStore.Error.folderAlreadyExists ) ) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index d69edf201..d6bfe52d9 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -74,7 +74,7 @@ extension DownloadCoordinator { } else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Error.invalidFolderName + L10n.Localizable.DownloadStore.Error.invalidFolderName ) ) } diff --git a/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift index b11d9b343..450a31966 100644 --- a/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift @@ -9,7 +9,7 @@ extension DownloadStore { func linkOrCopyReadableAsset(at sourceURL: URL, to destinationURL: URL) throws { guard sanitizeAssetFileIfNeeded(at: sourceURL) else { throw AppError.fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Error.assetUnreadable(sourceURL.lastPathComponent) + L10n.Localizable.DownloadStore.Error.assetUnreadable(sourceURL.lastPathComponent) ) } @@ -88,13 +88,13 @@ extension DownloadStore { } guard let relativePath = existingPages[index] else { throw AppError.fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index) + L10n.Localizable.DownloadStore.Validation.pageMissing(index) ) } pages[index] = try hashReadableAsset( folderURL: folderURL, relativePath: relativePath, - missingMessage: L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index) + missingMessage: L10n.Localizable.DownloadStore.Validation.pageMissing(index) ) } @@ -149,7 +149,7 @@ extension DownloadStore { pages[index] = try hashReadableAsset( folderURL: folderURL, relativePath: refreshedRelativePath, - missingMessage: L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index) + missingMessage: L10n.Localizable.DownloadStore.Validation.pageMissing(index) ) didUpdate = true } @@ -198,14 +198,14 @@ extension DownloadStore { ) -> DownloadValidationState { let folderURL = download.folderURL guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.downloadFolderMissing) + return .missingFiles(L10n.Localizable.DownloadStore.Validation.downloadFolderMissing) } let manifestURL = download.manifestURL guard fileManager.operate({ $0.fileExists(atPath: manifestURL.path) }) else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestMissing) + return .missingFiles(L10n.Localizable.DownloadStore.Validation.manifestMissing) } guard let manifest = try? readManifest(folderURL: folderURL) else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.manifestCorrupted) + return .missingFiles(L10n.Localizable.DownloadStore.Validation.manifestCorrupted) } if let pageValidationFailure = validatePages( folderURL: folderURL, @@ -287,12 +287,12 @@ extension DownloadStore { let pageURL = validatedChildURL(root: folderURL, relativePath: relativePath), sanitizeAssetFileIfNeeded(at: pageURL) else { - return .missingFiles(L10n.Localizable.DownloadFileStorage.Validation.pageMissing(index)) + return .missingFiles(L10n.Localizable.DownloadStore.Validation.pageMissing(index)) } if verifiesContentHash, (try? fileHash(at: pageURL)) != expectedHash { return .missingFiles( - L10n.Localizable.DownloadFileStorage.Validation.pageImageCorrupted(index) + L10n.Localizable.DownloadStore.Validation.pageImageCorrupted(index) ) } diff --git a/EhPanda/App/Tools/Utilities/DownloadStore.swift b/EhPanda/App/Tools/Utilities/DownloadStore.swift index 2c856cbab..7357ff120 100644 --- a/EhPanda/App/Tools/Utilities/DownloadStore.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore.swift @@ -358,7 +358,7 @@ struct DownloadStore: Sendable { } private func manifestCorruptedError() -> AppError { - .fileOperationFailed(L10n.Localizable.DownloadFileStorage.Validation.manifestCorrupted) + .fileOperationFailed(L10n.Localizable.DownloadStore.Validation.manifestCorrupted) } func scanDownloadFolders() throws -> [DownloadFolderRecord] { diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 960129579..07d2d7f06 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -1015,17 +1015,17 @@ "struct.download_badge.text.update_available" = "Update verfügbar"; "struct.download_badge.text.needs_repair" = "Reparatur nötig"; "struct.download_badge.progress" = "%d/%d"; -"download_file_storage.error.asset_unreadable" = "Asset-Datei ist nicht lesbar: %@"; -"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; -"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; -"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; -"download_file_storage.error.download_busy" = "The download is currently active."; -"download_file_storage.validation.download_folder_unresolved" = "Download-Ordner konnte nicht aufgelöst werden."; -"download_file_storage.validation.download_folder_missing" = "Download-Ordner fehlt."; -"download_file_storage.validation.manifest_missing" = "Manifest-Datei fehlt."; -"download_file_storage.validation.manifest_corrupted" = "Manifest-Datei ist beschädigt."; -"download_file_storage.validation.downloaded_pages_incomplete" = "Heruntergeladene Seiten sind unvollständig."; -"download_file_storage.validation.cover_image_missing" = "Coverbild fehlt."; -"download_file_storage.validation.page_missing" = "Seite %d fehlt."; -"download_file_storage.validation.cover_image_corrupted" = "Coverbilddaten sind beschädigt."; -"download_file_storage.validation.page_image_corrupted" = "Bilddaten von Seite %d sind beschädigt."; +"download_store.error.asset_unreadable" = "Asset-Datei ist nicht lesbar: %@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "Download-Ordner konnte nicht aufgelöst werden."; +"download_store.validation.download_folder_missing" = "Download-Ordner fehlt."; +"download_store.validation.manifest_missing" = "Manifest-Datei fehlt."; +"download_store.validation.manifest_corrupted" = "Manifest-Datei ist beschädigt."; +"download_store.validation.downloaded_pages_incomplete" = "Heruntergeladene Seiten sind unvollständig."; +"download_store.validation.cover_image_missing" = "Coverbild fehlt."; +"download_store.validation.page_missing" = "Seite %d fehlt."; +"download_store.validation.cover_image_corrupted" = "Coverbilddaten sind beschädigt."; +"download_store.validation.page_image_corrupted" = "Bilddaten von Seite %d sind beschädigt."; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index da6b081ff..62f05ce72 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -471,21 +471,21 @@ "struct.download_badge.text.needs_repair" = "Needs Repair"; "struct.download_badge.progress" = "%d/%d"; -// MARK: DownloadFileStorage -"download_file_storage.error.asset_unreadable" = "Asset file is unreadable: %@"; -"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; -"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; -"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; -"download_file_storage.error.download_busy" = "The download is currently active."; -"download_file_storage.validation.download_folder_unresolved" = "Download folder could not be resolved."; -"download_file_storage.validation.download_folder_missing" = "Download folder is missing."; -"download_file_storage.validation.manifest_missing" = "Manifest file is missing."; -"download_file_storage.validation.manifest_corrupted" = "Manifest file is corrupted."; -"download_file_storage.validation.downloaded_pages_incomplete" = "Downloaded pages are incomplete."; -"download_file_storage.validation.cover_image_missing" = "Cover image is missing."; -"download_file_storage.validation.page_missing" = "Page %d is missing."; -"download_file_storage.validation.cover_image_corrupted" = "Cover image data is corrupted."; -"download_file_storage.validation.page_image_corrupted" = "Page %d image data is corrupted."; +// MARK: DownloadStore +"download_store.error.asset_unreadable" = "Asset file is unreadable: %@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "Download folder could not be resolved."; +"download_store.validation.download_folder_missing" = "Download folder is missing."; +"download_store.validation.manifest_missing" = "Manifest file is missing."; +"download_store.validation.manifest_corrupted" = "Manifest file is corrupted."; +"download_store.validation.downloaded_pages_incomplete" = "Downloaded pages are incomplete."; +"download_store.validation.cover_image_missing" = "Cover image is missing."; +"download_store.validation.page_missing" = "Page %d is missing."; +"download_store.validation.cover_image_corrupted" = "Cover image data is corrupted."; +"download_store.validation.page_image_corrupted" = "Page %d image data is corrupted."; // MARK: FiltersView "filters_view.title.filters" = "Filters"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 413539c5b..1aae44ffc 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -1015,17 +1015,17 @@ "struct.download_badge.text.update_available" = "更新あり"; "struct.download_badge.text.needs_repair" = "要修復"; "struct.download_badge.progress" = "%d/%d"; -"download_file_storage.error.asset_unreadable" = "アセットファイルを読み取れません: %@"; -"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; -"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; -"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; -"download_file_storage.error.download_busy" = "The download is currently active."; -"download_file_storage.validation.download_folder_unresolved" = "ダウンロードフォルダを解決できませんでした。"; -"download_file_storage.validation.download_folder_missing" = "ダウンロードフォルダが見つかりません。"; -"download_file_storage.validation.manifest_missing" = "マニフェストファイルが見つかりません。"; -"download_file_storage.validation.manifest_corrupted" = "マニフェストファイルが破損しています。"; -"download_file_storage.validation.downloaded_pages_incomplete" = "ダウンロード済みページが不完全です。"; -"download_file_storage.validation.cover_image_missing" = "表紙画像が見つかりません。"; -"download_file_storage.validation.page_missing" = "ページ %d が見つかりません。"; -"download_file_storage.validation.cover_image_corrupted" = "表紙画像データが破損しています。"; -"download_file_storage.validation.page_image_corrupted" = "ページ %d の画像データが破損しています。"; +"download_store.error.asset_unreadable" = "アセットファイルを読み取れません: %@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "ダウンロードフォルダを解決できませんでした。"; +"download_store.validation.download_folder_missing" = "ダウンロードフォルダが見つかりません。"; +"download_store.validation.manifest_missing" = "マニフェストファイルが見つかりません。"; +"download_store.validation.manifest_corrupted" = "マニフェストファイルが破損しています。"; +"download_store.validation.downloaded_pages_incomplete" = "ダウンロード済みページが不完全です。"; +"download_store.validation.cover_image_missing" = "表紙画像が見つかりません。"; +"download_store.validation.page_missing" = "ページ %d が見つかりません。"; +"download_store.validation.cover_image_corrupted" = "表紙画像データが破損しています。"; +"download_store.validation.page_image_corrupted" = "ページ %d の画像データが破損しています。"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 29f0ab966..576ad5eaf 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -981,13 +981,13 @@ "downloads_view.button.clear_filters" = "필터 지우기"; "downloads_view.button.validate_image_data" = "이미지 데이터 검증"; "downloads_view.inspector.section.actions" = "동작"; -"downloads_view.inspector.section.pages" = "페이지"; -"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도"; -"downloads_view.inspector.button.validating_image_data" = "이미지 데이터 검증 중..."; -"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; -"downloads_view.inspector.hud.image_data_valid" = "이미지 데이터가 유효합니다"; -"downloads_view.inspector.hud.image_data_unavailable" = "이미지 데이터를 검증할 수 없습니다."; -"downloads_view.inspector.title.download_status" = "다운로드 상태"; +"downloads_view.inspector.section.pages" = "페이지"; +"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도"; +"downloads_view.inspector.button.validating_image_data" = "이미지 데이터 검증 중..."; +"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; +"downloads_view.inspector.hud.image_data_valid" = "이미지 데이터가 유효합니다"; +"downloads_view.inspector.hud.image_data_unavailable" = "이미지 데이터를 검증할 수 없습니다."; +"downloads_view.inspector.title.download_status" = "다운로드 상태"; "downloads_view.inspector.page.pending" = "대기 중"; "downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; "downloads_view.inspector.page.title" = "페이지 %d"; @@ -1014,18 +1014,18 @@ "struct.download_badge.text.needs_attention" = "조치 필요"; "struct.download_badge.text.update_available" = "업데이트 가능"; "struct.download_badge.text.needs_repair" = "복구 필요"; -"struct.download_badge.progress" = "%d/%d"; -"download_file_storage.error.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; -"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; -"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; -"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; -"download_file_storage.error.download_busy" = "The download is currently active."; -"download_file_storage.validation.download_folder_unresolved" = "다운로드 폴더를 확인할 수 없습니다."; -"download_file_storage.validation.download_folder_missing" = "다운로드 폴더가 없습니다."; -"download_file_storage.validation.manifest_missing" = "매니페스트 파일이 없습니다."; -"download_file_storage.validation.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; -"download_file_storage.validation.downloaded_pages_incomplete" = "다운로드한 페이지가 불완전합니다."; -"download_file_storage.validation.cover_image_missing" = "표지 이미지가 없습니다."; -"download_file_storage.validation.page_missing" = "페이지 %d가 없습니다."; -"download_file_storage.validation.cover_image_corrupted" = "표지 이미지 데이터가 손상되었습니다."; -"download_file_storage.validation.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; +"struct.download_badge.progress" = "%d/%d"; +"download_store.error.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "다운로드 폴더를 확인할 수 없습니다."; +"download_store.validation.download_folder_missing" = "다운로드 폴더가 없습니다."; +"download_store.validation.manifest_missing" = "매니페스트 파일이 없습니다."; +"download_store.validation.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; +"download_store.validation.downloaded_pages_incomplete" = "다운로드한 페이지가 불완전합니다."; +"download_store.validation.cover_image_missing" = "표지 이미지가 없습니다."; +"download_store.validation.page_missing" = "페이지 %d가 없습니다."; +"download_store.validation.cover_image_corrupted" = "표지 이미지 데이터가 손상되었습니다."; +"download_store.validation.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 7f68f74ae..4e7f3bd83 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -471,21 +471,21 @@ "struct.download_badge.text.needs_repair" = "需修复"; "struct.download_badge.progress" = "%d/%d"; -// MARK: DownloadFileStorage -"download_file_storage.error.asset_unreadable" = "资源文件无法读取:%@"; -"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; -"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; -"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; -"download_file_storage.error.download_busy" = "The download is currently active."; -"download_file_storage.validation.download_folder_unresolved" = "无法解析下载文件夹。"; -"download_file_storage.validation.download_folder_missing" = "下载文件夹缺失。"; -"download_file_storage.validation.manifest_missing" = "Manifest 文件缺失。"; -"download_file_storage.validation.manifest_corrupted" = "Manifest 文件已损坏。"; -"download_file_storage.validation.downloaded_pages_incomplete" = "下载页面不完整。"; -"download_file_storage.validation.cover_image_missing" = "封面图片缺失。"; -"download_file_storage.validation.page_missing" = "第 %d 页缺失。"; -"download_file_storage.validation.cover_image_corrupted" = "封面图片数据已损坏。"; -"download_file_storage.validation.page_image_corrupted" = "第 %d 页图片数据已损坏。"; +// MARK: DownloadStore +"download_store.error.asset_unreadable" = "资源文件无法读取:%@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "无法解析下载文件夹。"; +"download_store.validation.download_folder_missing" = "下载文件夹缺失。"; +"download_store.validation.manifest_missing" = "Manifest 文件缺失。"; +"download_store.validation.manifest_corrupted" = "Manifest 文件已损坏。"; +"download_store.validation.downloaded_pages_incomplete" = "下载页面不完整。"; +"download_store.validation.cover_image_missing" = "封面图片缺失。"; +"download_store.validation.page_missing" = "第 %d 页缺失。"; +"download_store.validation.cover_image_corrupted" = "封面图片数据已损坏。"; +"download_store.validation.page_image_corrupted" = "第 %d 页图片数据已损坏。"; // MARK: FiltersView "filters_view.title.filters" = "筛选"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index dd719d0c0..210bd4add 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -1012,17 +1012,17 @@ "struct.download_badge.text.update_available" = "有可更新"; "struct.download_badge.text.needs_repair" = "需修復"; "struct.download_badge.progress" = "%d/%d"; -"download_file_storage.error.asset_unreadable" = "資源檔案無法讀取:%@"; -"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; -"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; -"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; -"download_file_storage.error.download_busy" = "The download is currently active."; -"download_file_storage.validation.download_folder_unresolved" = "無法解析下載資料夾。"; -"download_file_storage.validation.download_folder_missing" = "下載資料夾缺失。"; -"download_file_storage.validation.manifest_missing" = "Manifest 檔案缺失。"; -"download_file_storage.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; -"download_file_storage.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; -"download_file_storage.validation.cover_image_missing" = "封面圖片缺失。"; -"download_file_storage.validation.page_missing" = "第 %d 頁缺失。"; -"download_file_storage.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; -"download_file_storage.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; +"download_store.error.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "無法解析下載資料夾。"; +"download_store.validation.download_folder_missing" = "下載資料夾缺失。"; +"download_store.validation.manifest_missing" = "Manifest 檔案缺失。"; +"download_store.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; +"download_store.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; +"download_store.validation.cover_image_missing" = "封面圖片缺失。"; +"download_store.validation.page_missing" = "第 %d 頁缺失。"; +"download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; +"download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 65b74aedf..769368517 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -1013,17 +1013,17 @@ "struct.download_badge.text.update_available" = "有可更新"; "struct.download_badge.text.needs_repair" = "需修復"; "struct.download_badge.progress" = "%d/%d"; -"download_file_storage.error.asset_unreadable" = "資源檔案無法讀取:%@"; -"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; -"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; -"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; -"download_file_storage.error.download_busy" = "The download is currently active."; -"download_file_storage.validation.download_folder_unresolved" = "無法解析下載資料夾。"; -"download_file_storage.validation.download_folder_missing" = "下載資料夾缺失。"; -"download_file_storage.validation.manifest_missing" = "Manifest 檔案缺失。"; -"download_file_storage.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; -"download_file_storage.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; -"download_file_storage.validation.cover_image_missing" = "封面圖片缺失。"; -"download_file_storage.validation.page_missing" = "第 %d 頁缺失。"; -"download_file_storage.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; -"download_file_storage.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; +"download_store.error.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "無法解析下載資料夾。"; +"download_store.validation.download_folder_missing" = "下載資料夾缺失。"; +"download_store.validation.manifest_missing" = "Manifest 檔案缺失。"; +"download_store.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; +"download_store.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; +"download_store.validation.cover_image_missing" = "封面圖片缺失。"; +"download_store.validation.page_missing" = "第 %d 頁缺失。"; +"download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; +"download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index ff50a64b3..4b4388e17 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -1013,17 +1013,17 @@ "struct.download_badge.text.update_available" = "有可更新"; "struct.download_badge.text.needs_repair" = "需修復"; "struct.download_badge.progress" = "%d/%d"; -"download_file_storage.error.asset_unreadable" = "資源檔案無法讀取:%@"; -"download_file_storage.error.invalid_folder_name" = "The folder name is invalid."; -"download_file_storage.error.folder_already_exists" = "A folder with this name already exists."; -"download_file_storage.error.folder_busy_downloading" = "The folder contains an active download."; -"download_file_storage.error.download_busy" = "The download is currently active."; -"download_file_storage.validation.download_folder_unresolved" = "無法解析下載資料夾。"; -"download_file_storage.validation.download_folder_missing" = "下載資料夾缺失。"; -"download_file_storage.validation.manifest_missing" = "Manifest 檔案缺失。"; -"download_file_storage.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; -"download_file_storage.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; -"download_file_storage.validation.cover_image_missing" = "封面圖片缺失。"; -"download_file_storage.validation.page_missing" = "第 %d 頁缺失。"; -"download_file_storage.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; -"download_file_storage.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; +"download_store.error.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "無法解析下載資料夾。"; +"download_store.validation.download_folder_missing" = "下載資料夾缺失。"; +"download_store.validation.manifest_missing" = "Manifest 檔案缺失。"; +"download_store.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; +"download_store.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; +"download_store.validation.cover_image_missing" = "封面圖片缺失。"; +"download_store.validation.page_missing" = "第 %d 頁缺失。"; +"download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; +"download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; diff --git a/EhPanda/View/Downloads/FolderManagerReducer.swift b/EhPanda/View/Downloads/FolderManagerReducer.swift index 0286e3bb6..7dfca80cb 100644 --- a/EhPanda/View/Downloads/FolderManagerReducer.swift +++ b/EhPanda/View/Downloads/FolderManagerReducer.swift @@ -24,7 +24,7 @@ struct FolderManagerReducer { private static var invalidFolderNameError: AppError { .fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Error.invalidFolderName + L10n.Localizable.DownloadStore.Error.invalidFolderName ) } diff --git a/EhPandaTests/Tests/Download/DownloadStoreTests.swift b/EhPandaTests/Tests/Download/DownloadStoreTests.swift index 51dfeb8c6..b9b0f8f0b 100644 --- a/EhPandaTests/Tests/Download/DownloadStoreTests.swift +++ b/EhPandaTests/Tests/Download/DownloadStoreTests.swift @@ -53,7 +53,7 @@ struct DownloadStoreTests { #expect( throws: AppError.fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Validation.manifestCorrupted + L10n.Localizable.DownloadStore.Validation.manifestCorrupted ) ) { try storage.readManifest(folderURL: folderURL) @@ -72,7 +72,7 @@ struct DownloadStoreTests { #expect( throws: AppError.fileOperationFailed( - L10n.Localizable.DownloadFileStorage.Validation.manifestCorrupted + L10n.Localizable.DownloadStore.Validation.manifestCorrupted ) ) { try storage.readManifest(folderURL: folderURL) From 01616d735a9479f30f8e55b532b5ac78a0c55bfa Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 12:42:23 +0800 Subject: [PATCH 243/614] Unify download inspector page-retry actions Delete the dead DownloadInspectorPageRow (zero refs) and the unreachable .retryPage/.retryPageDone actions, and collapse .retryFailedPages into a single .retryPages([Int]). The unified handler keeps the inspectionRequestID reset + stableInspection snapshot and generalizes to N indices; the retry-all button sends .retryPages(failedPageIndices). --- .../Downloads/DownloadInspectorReducer.swift | 69 ++++------------- .../Downloads/DownloadsView+Subviews.swift | 75 +------------------ .../Download/DownloadInspectorLoadTests.swift | 6 +- 3 files changed, 17 insertions(+), 133 deletions(-) diff --git a/EhPanda/View/Downloads/DownloadInspectorReducer.swift b/EhPanda/View/Downloads/DownloadInspectorReducer.swift index b98177f9a..9332b9b69 100644 --- a/EhPanda/View/Downloads/DownloadInspectorReducer.swift +++ b/EhPanda/View/Downloads/DownloadInspectorReducer.swift @@ -44,10 +44,8 @@ struct DownloadInspectorReducer { case loadInspectionDone(UUID, Result) case observeDownloads case observeDownloadsDone([DownloadedGallery]) - case retryPage(Int) - case retryPageDone(Result) - case retryFailedPages - case retryFailedPagesDone(Result) + case retryPages([Int]) + case retryPagesDone(Result) case toggleDownloadPause case toggleDownloadPauseDone(Result) case validateImageData @@ -146,60 +144,19 @@ struct DownloadInspectorReducer { guard previousDownload != latestDownload else { return .none } return .send(.loadInspection) - case .retryPage(let index): - guard !state.gid.isEmpty else { return .none } - state.inspectionRequestID = UUID() - state.retryingPageIndices.insert(index) - state.stableInspection = state.inspection ?? state.stableInspection - if let inspection = state.inspection { - state.inspection = .init( - download: inspection.download, - coverURL: inspection.coverURL, - pages: inspection.pages.map { page in - guard page.index == index else { return page } - return .init( - index: index, - status: .pending, - relativePath: page.relativePath, - fileURL: nil, - failure: nil - ) - } - ) - } - return .merge( - .cancel(id: CancelID.loadInspection), - .run { [gid = state.gid] send in - try await downloadClient.retryPages(gid, [index]) - await send(.retryPageDone(.success(()))) - } catch: { error, send in - await send(.retryPageDone(.failure(AppError(error)))) - } - ) - - case .retryPageDone(let result): - if case .failure = result { - state.retryingPageIndices = .init() - return .send(.loadInspection) - } - return .none - - case .retryFailedPages: - guard let failedPageIndices = state.inspection?.failedPageIndices, - let gid = state.inspection?.download.gid, - !failedPageIndices.isEmpty - else { - return .none - } + case .retryPages(let indices): + let retryingPageIndices = Set(indices) + let pageIndices = retryingPageIndices.sorted() + guard !state.gid.isEmpty, !pageIndices.isEmpty else { return .none } state.inspectionRequestID = UUID() - state.retryingPageIndices.formUnion(failedPageIndices) + state.retryingPageIndices.formUnion(retryingPageIndices) state.stableInspection = state.inspection ?? state.stableInspection if let inspection = state.inspection { state.inspection = .init( download: inspection.download, coverURL: inspection.coverURL, pages: inspection.pages.map { page in - guard failedPageIndices.contains(page.index) else { return page } + guard retryingPageIndices.contains(page.index) else { return page } return .init( index: page.index, status: .pending, @@ -212,15 +169,15 @@ struct DownloadInspectorReducer { } return .merge( .cancel(id: CancelID.loadInspection), - .run { send in - try await downloadClient.retryPages(gid, failedPageIndices) - await send(.retryFailedPagesDone(.success(()))) + .run { [gid = state.gid] send in + try await downloadClient.retryPages(gid, pageIndices) + await send(.retryPagesDone(.success(()))) } catch: { error, send in - await send(.retryFailedPagesDone(.failure(AppError(error)))) + await send(.retryPagesDone(.failure(AppError(error)))) } ) - case .retryFailedPagesDone(let result): + case .retryPagesDone(let result): if case .failure = result { state.retryingPageIndices = .init() return .send(.loadInspection) diff --git a/EhPanda/View/Downloads/DownloadsView+Subviews.swift b/EhPanda/View/Downloads/DownloadsView+Subviews.swift index 926454925..9553323f0 100644 --- a/EhPanda/View/Downloads/DownloadsView+Subviews.swift +++ b/EhPanda/View/Downloads/DownloadsView+Subviews.swift @@ -84,7 +84,7 @@ struct DownloadInspectorView: View { .disabled(isPauseResumeDisabled) Button { - store.send(.retryFailedPages) + store.send(.retryPages(inspection.failedPageIndices)) } label: { Label( L10n.Localizable.DownloadsView.Inspector.Button.retryFailedPages, @@ -326,76 +326,3 @@ struct DownloadListRow: View { .accessibilityLabel(download.title) } } - -struct DownloadInspectorPageRow: View { - let page: DownloadPageInspection - let retryAction: () -> Void - - private var symbol: SFSymbol { - switch page.status { - case .pending: - return .clock - case .downloaded: - return .checkmarkCircle - case .failed: - return .exclamationmarkCircle - } - } - - private var tint: Color { - switch page.status { - case .pending: - return .secondary - case .downloaded: - return .green - case .failed: - return .red - } - } - - private var subtitle: String { - switch page.status { - case .pending: - return L10n.Localizable.DownloadsView.Inspector.Page.pending - case .downloaded: - return page.relativePath ?? L10n.Localizable.Struct.DownloadBadge.Text.downloaded - case .failed: - return page.failure?.message ?? L10n.Localizable.DownloadsView.Inspector.Page.tapToRetry - } - } - - var body: some View { - Group { - if page.status == .failed { - Button(action: retryAction) { - rowContent - } - .buttonStyle(.plain) - } else { - rowContent - } - } - } - - private var rowContent: some View { - HStack(spacing: 12) { - Image(systemSymbol: symbol) - .foregroundStyle(tint) - .font(.title3) - VStack(alignment: .leading, spacing: 4) { - Text(L10n.Localizable.DownloadsView.Inspector.Page.title(page.index)) - .font(.body.weight(.medium)) - Text(subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(2) - } - Spacer() - if page.status == .failed { - Image(systemSymbol: .arrowClockwise) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } -} diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift index 379257051..549a777a4 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -35,7 +35,7 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { @MainActor @Test - func testDownloadInspectorReducerRetryPageUsesDownloadClientRetryPages() async { + func testDownloadInspectorReducerRetryPagesUsesDownloadClientRetryPages() async { await confirmation(expectedCount: 1) { confirm in let retried = UncheckedBox<[Int]>([]) let download = sampleDownload( @@ -61,7 +61,7 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { ) store.exhaustivity = .off - await store.send(.retryPage(2)) + await store.send(.retryPages([2])) #expect(retried.value == [2]) } } @@ -92,7 +92,7 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { ) store.exhaustivity = .off - await store.send(.retryFailedPages) { + await store.send(.retryPages([2])) { guard let inspection = $0.inspection else { return } $0.inspection = .init( download: inspection.download, From c506d46068ba18130e3414471701638c8f6c3d71 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 12:42:23 +0800 Subject: [PATCH 244/614] Remove test-only forwarding wrappers and duplicate struct Delete the pure-rename testing* wrappers in DownloadClient+Testing (the underlying methods are reachable directly via @testable) and the duplicate PrepareWorkingSeedResult struct; keep the 9 actor-state seams. Tests now call the real internal methods. fetchLatestPayload drops its test-only default params (callers pass explicit options/pageSelection). --- .../Clients/DownloadClient+Manager.swift | 7 -- .../Clients/DownloadClient+Testing.swift | 78 ------------------- .../DownloadEnqueueManifestTests.swift | 4 +- .../DownloadFolderOperationTests.swift | 10 +-- .../Download/DownloadImageErrorTests.swift | 12 +-- .../DownloadImageParsingCacheTests.swift | 4 +- .../Download/DownloadImageParsingTests.swift | 12 +-- .../DownloadInterruptedResumeTests.swift | 4 +- .../Tests/Download/DownloadIpBanTests.swift | 6 +- .../DownloadManagerCaptureTests.swift | 2 +- .../DownloadManagerRepairSeedTests.swift | 12 ++- .../DownloadManagerStorageTests.swift | 2 +- .../Download/DownloadObserverBatchTests.swift | 2 +- .../DownloadPauseAndReconcileTests.swift | 14 ++-- .../Download/DownloadProcessCacheTests.swift | 12 +-- .../Tests/Download/DownloadProcessTests.swift | 18 ++--- .../DownloadRetryMinimalSourceTests.swift | 12 +-- .../Download/DownloadRetryPagesTests.swift | 4 +- .../DownloadRetryUpdateFallbackTests.swift | 10 +-- .../Download/DownloadSchedulingTests.swift | 8 +- .../DownloadVersionSignatureTests.swift | 6 +- 21 files changed, 82 insertions(+), 157 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 9f967d28d..817e94ba4 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -143,13 +143,6 @@ actor DownloadCoordinator { let preferredRelativePath: String? } - struct PrepareWorkingSeedResult: Sendable { - let folderURL: URL - let manifest: DownloadManifest - let existingPages: [Int: String] - let coverRelativePath: String? - } - struct HTMLResponseContext { let prefixData: Data let fullData: Data? diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 1490987b6..b151d6f8c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -20,10 +20,6 @@ extension DownloadCoordinator { activeGalleryID = gid } - func testingScheduleNextIfNeeded() async { - await scheduleNextIfNeeded() - } - func testingSetQueuedGalleryIDs(_ gids: [String]) async { await queueStore.removeAll() for gid in gids { @@ -65,82 +61,8 @@ extension DownloadCoordinator { activeTask != nil } - func testingFetchDownload( - gid: String - ) async -> DownloadedGallery? { - await fetchDownload(gid: gid) - } - func testingActiveGalleryID() -> String? { activeGalleryID } - - func testingFetchLatestPayload( - for download: DownloadedGallery, - mode: DownloadStartMode, - options: DownloadRequestOptions = .init(), - pageSelection: [Int]? = nil - ) async throws -> DownloadRequestPayload { - try await fetchLatestPayload( - for: download, - mode: mode, - options: options, - pageSelection: pageSelection - ) - } - - func testingPrepareWorkingSeed( - payload: DownloadRequestPayload, - existingDownload: DownloadedGallery - ) throws -> PrepareWorkingSeedResult { - let folderURL = storage.folderURL( - relativePath: folderRelativePath( - for: payload, - parentFolderName: existingDownload.folderName - ) - ) - try? fileManager.operate { - try $0.removeItem(at: folderURL) - } - let workingSeed = try prepareWorkingSeed( - payload: payload, - existingDownload: existingDownload, - folderURL: folderURL - ) - return PrepareWorkingSeedResult( - folderURL: workingSeed.folderURL, - manifest: workingSeed.manifest, - existingPages: workingSeed.existingPages, - coverRelativePath: workingSeed.coverRelativePath - ) - } - - func testingProcessDownload(gid: String) async { - await processDownload(gid: gid) - } - - func testingDetectResponseError( - fileURL: URL, - response: URLResponse, - requestURL: URL? - ) -> AppError? { - detectResponseError( - fileURL: fileURL, - response: response, - requestURL: requestURL - ) - } - - func testingDetectResponseError( - data: Data, - response: URLResponse, - requestURL: URL? - ) -> AppError? { - detectResponseError( - data: data, - response: response, - requestURL: requestURL - ) - } } #endif diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index b94d79726..211f385b9 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -71,7 +71,7 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { ) #expect(manifestObject["downloadOptions"] == nil) - let queuedDownload = await manager.testingFetchDownload(gid: gallery.gid) + let queuedDownload = await manager.fetchDownload(gid: gallery.gid) #expect(queuedDownload?.displayStatus == .queued) #expect(queuedDownload?.onlineCoverURL == detail.coverURL) #expect(queuedDownload?.pageCount == detail.pageCount) @@ -146,6 +146,6 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { let preservedManifest = try storage.readManifest(folderURL: folderURL) #expect(preservedManifest.pages == pages) #expect(queueStore.gids == [gallery.gid]) - #expect(await manager.testingFetchDownload(gid: gallery.gid)?.completedPageCount == detail.pageCount) + #expect(await manager.fetchDownload(gid: gallery.gid)?.completedPageCount == detail.pageCount) } } diff --git a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift index c35dd2007..6d435e634 100644 --- a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift @@ -55,7 +55,7 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { return } - let download = await environment.manager.testingFetchDownload(gid: gid) + let download = await environment.manager.fetchDownload(gid: gid) #expect(await environment.manager.fetchFolders() == ["New Name"]) #expect(download?.folderName == "New Name") #expect(download?.folderURL.path.contains("/New Name/") == true) @@ -96,7 +96,7 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { } #expect(await environment.manager.fetchFolders().isEmpty) - #expect(await environment.manager.testingFetchDownload(gid: gid) == nil) + #expect(await environment.manager.fetchDownload(gid: gid) == nil) #expect(!FileManager.default.fileExists(atPath: folderURL.path)) } @@ -126,7 +126,7 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { } await environment.manager.reconcileDownloads() - #expect(await environment.manager.testingFetchDownload(gid: gid) == nil) + #expect(await environment.manager.fetchDownload(gid: gid) == nil) #expect(!FileManager.default.fileExists(atPath: oldFolderURL.path)) #expect(!FileManager.default.fileExists(atPath: currentFolderURL.path)) } @@ -145,7 +145,7 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { return } - let download = await environment.manager.testingFetchDownload(gid: gid) + let download = await environment.manager.fetchDownload(gid: gid) #expect(download?.folderName == "Target") #expect(download?.folderURL.path.contains("/Target/") == true) #expect(!FileManager.default.fileExists(atPath: sourceURL.path)) @@ -223,7 +223,7 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { return } - let download = await environment.manager.testingFetchDownload(gid: gallery.gid) + let download = await environment.manager.fetchDownload(gid: gallery.gid) #expect(download?.folderName == "Original") } } diff --git a/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift b/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift index f26fbe646..0c35dd6d1 100644 --- a/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift @@ -28,7 +28,7 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { url: galleryURL, contentType: "text/html" ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: response, requestURL: galleryURL @@ -55,7 +55,7 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { url: pageURL, contentType: "text/html" ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: response, requestURL: pageURL @@ -80,7 +80,7 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { statusCode: 404, contentType: "text/html" ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: response, requestURL: notFoundURL @@ -111,7 +111,7 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { statusCode: 404, contentType: "text/html" ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: response, requestURL: galleryURL @@ -131,7 +131,7 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { url: bannedURL, contentType: "text/html; charset=utf-8" ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: response, requestURL: bannedURL @@ -169,7 +169,7 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { ] let data = try JSONSerialization.data(withJSONObject: responsePayload) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( data: data, response: response, requestURL: apiURL diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift index 0f55557ff..1edb73fc9 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -47,7 +47,7 @@ struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { "Set-Cookie": "\(Defaults.Cookie.yay)=louder; Path=/" ] ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: response, requestURL: URL(string: "https://exhentai.org/g/1/1/") @@ -79,7 +79,7 @@ struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { url: Defaults.URL.exhentai, contentType: "text/html" ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: response, requestURL: URL(string: "https://exhentai.org/g/1/1/") diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift index 9eeebebc2..101e5cb11 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift @@ -23,7 +23,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { contentType: "image/gif", contentLength: 28658 ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: response, requestURL: quotaImageURL @@ -47,7 +47,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { contentType: "image/gif", contentLength: data.count ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: response, requestURL: quotaImageURL @@ -73,7 +73,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { contentType: "image/gif", contentLength: imageData.count ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: response, requestURL: URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1") @@ -94,7 +94,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { contentType: "image/gif", contentLength: 28658 ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: response, requestURL: normalImageURL @@ -112,7 +112,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { let normalImageURL = try #require( URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1&key=normal-cache-key") ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: try makeResponse( url: normalImageURL, @@ -143,7 +143,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { url: quotaURL, contentType: "text/html" ) - let error = await manager.testingDetectResponseError( + let error = await manager.detectResponseError( fileURL: fileURL, response: response, requestURL: quotaURL diff --git a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift index 6de86b5fa..cb219e9bf 100644 --- a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -71,7 +71,7 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { #expect(persistedManifest == workingSeed.manifest) #expect(persistedManifest.pageCount == 2) #expect(persistedManifest.completedPageCount == 0) - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) #expect(stored != nil) } @@ -117,7 +117,7 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { Issue.record("Pause should succeed, got \(result)") return } - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) #expect(stored?.displayStatus == .inactive) #expect( stored?.badge == DownloadBadge( diff --git a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift index 65bfcc29d..2a08b06fc 100644 --- a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift +++ b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift @@ -49,9 +49,11 @@ struct DownloadIpBanTests: DownloadFeatureTestCase { ) do { - _ = try await manager.testingFetchLatestPayload( + _ = try await manager.fetchLatestPayload( for: download, - mode: .redownload + mode: .redownload, + options: .init(), + pageSelection: nil ) Issue.record("Expected ipBanned error") } catch let error as AppError { diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift index 6a36ed4c7..4194246ab 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -85,7 +85,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { await manager.captureCachedPage(gid: gid, index: 1, imageURL: imageURL) - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() #expect(stored?.displayStatus == .completed) diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index e0588cec6..5d9e7c3ac 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -36,8 +36,16 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { ) let payload = makeRepairSeedPayload(gid: gid) - let workingSeed = try await manager.testingPrepareWorkingSeed( - payload: payload, existingDownload: existingDownload + let folderRelativePath = await manager.folderRelativePath( + for: payload, + parentFolderName: existingDownload.folderName + ) + let folderURL = storage.folderURL(relativePath: folderRelativePath) + try? FileManager.default.removeItem(at: folderURL) + let workingSeed = try await manager.prepareWorkingSeed( + payload: payload, + existingDownload: existingDownload, + folderURL: folderURL ) let pageOneRelativePath = storage.makePageRelativePath( diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift index b381b65f5..9d0231a2c 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -728,7 +728,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { await queueStore.enqueue("830") await queueStore.enqueue("831") - await manager.testingScheduleNextIfNeeded() + await manager.scheduleNextIfNeeded() let scheduledGalleryIDs = scheduledRecorder.snapshot() #expect(scheduledGalleryIDs == ["830"]) diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index c19fb9770..3a0063c22 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -138,7 +138,7 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { timeout: .seconds(2), description: "observer updates for progress flush" ) - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) #expect(stored?.completedPageCount == pageCount) #expect(emissionCount < pageCount) diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index 4ae417a5f..7dd72d217 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -47,7 +47,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { do { try await Task.sleep(for: .seconds(60)) } catch is CancellationError { - await manager.testingScheduleNextIfNeeded() + await manager.scheduleNextIfNeeded() } catch {} } await manager.testingInstallActiveTask(gid: gid, task: activeTask) @@ -61,7 +61,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { try await Task.sleep(for: .milliseconds(100)) - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) let activeGalleryID = await manager.testingActiveGalleryID() #expect(stored?.displayStatus == .inactive) #expect( @@ -107,7 +107,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { return } - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) let activeGalleryID = await manager.testingActiveGalleryID() let hasActiveTask = await manager.testingHasActiveTask() #expect(stored?.displayStatus == .inactive) @@ -156,7 +156,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { do { try await Task.sleep(for: .seconds(60)) } catch is CancellationError { - await manager.testingScheduleNextIfNeeded() + await manager.scheduleNextIfNeeded() } catch {} } await manager.testingInstallActiveTask(gid: gid, task: activeTask) @@ -168,7 +168,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { return } - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) #expect(stored?.displayStatus == .inactive) #expect(stored?.completedPageCount == 1) #expect( @@ -208,7 +208,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { await manager.reconcileDownloads() - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) #expect(stored?.displayStatus == .error) #expect(stored?.lastError?.code == .networkingFailed) } @@ -245,7 +245,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { await manager.reconcileDownloads() - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) #expect(stored?.lastError == nil) #expect(stored?.displayStatus == .inactive) } diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 1a1699d40..ef6ad8f33 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -53,9 +53,9 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { ) await manager.reloadDownloadIndex() - await manager.testingProcessDownload(gid: gid) + await manager.processDownload(gid: gid) - let completedDownload = await manager.testingFetchDownload(gid: gid) + let completedDownload = await manager.fetchDownload(gid: gid) #expect(completedDownload?.displayStatus == .completed) try await waitUntilCacheCleared( @@ -213,8 +213,8 @@ private extension DownloadProcessCacheTests { gid: gid, title: "Pause Race", status: .partial, pageCount: 156, completedPageCount: 155 ) - let latestPayload = try await manager.testingFetchLatestPayload( - for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] + let latestPayload = try await manager.fetchLatestPayload( + for: scaffoldDownload, mode: .redownload, options: .init(), pageSelection: [pageIndex] ) let coverURL = try #require( latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL @@ -230,9 +230,9 @@ private extension DownloadProcessCacheTests { gid: setup.gid, title: "Pause Race", status: .partial, pageCount: 156, completedPageCount: 155 ) - let latestPayload = try await setup.manager.testingFetchLatestPayload( + let latestPayload = try await setup.manager.fetchLatestPayload( for: scaffoldDownload, mode: .redownload, - pageSelection: [setup.pageIndex] + options: .init(), pageSelection: [setup.pageIndex] ) let updatedPageCount = latestPayload.galleryDetail.pageCount #expect(updatedPageCount > setup.pageIndex) diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index c648adf44..ce849528b 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -48,7 +48,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { let completionProbe = ProcessCompletionProbe() let processTask = Task { - await manager.testingProcessDownload(gid: gid) + await manager.processDownload(gid: gid) await completionProbe.finish() } @@ -62,11 +62,11 @@ struct DownloadProcessTests: DownloadFeatureTestCase { await persistenceGate.release() await processTask.value - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) #expect(stored?.displayStatus == .error) #expect(stored?.lastError?.code == .networkingFailed) - await manager.testingScheduleNextIfNeeded() + await manager.scheduleNextIfNeeded() let scheduledAfterFailure = scheduledRecorder.snapshot() #expect(scheduledAfterFailure.isEmpty) } @@ -96,10 +96,10 @@ struct DownloadProcessTests: DownloadFeatureTestCase { oldPageCount: oldPageCount ) await manager.reloadDownloadIndex() - let beforeProcess = await manager.testingFetchDownload(gid: gid) + let beforeProcess = await manager.fetchDownload(gid: gid) #expect(beforeProcess?.hasUpdate ?? true == false) - await manager.testingProcessDownload(gid: gid) + await manager.processDownload(gid: gid) try await verifyCompletedProcess( manager: manager, storage: storage, @@ -161,7 +161,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { await manager.testingSetQueuedGalleryIDs([gid]) optionsBox.value = latestOptions - await manager.testingProcessDownload(gid: gid) + await manager.processDownload(gid: gid) #expect(detailAllowsCellular.value == false) } @@ -249,8 +249,8 @@ private extension DownloadProcessTests { gid: gid, title: "Pause Race", status: .partial, pageCount: 156, completedPageCount: 155 ) - let latestPayload = try await manager.testingFetchLatestPayload( - for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] + let latestPayload = try await manager.fetchLatestPayload( + for: scaffoldDownload, mode: .redownload, options: .init(), pageSelection: [pageIndex] ) if let coverURL = latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL { allowedImageURLs.insert(coverURL.absoluteString) @@ -296,7 +296,7 @@ private extension DownloadProcessTests { storage: DownloadStore, context: ProcessVerificationContext ) async throws { - let completedDownload = await manager.testingFetchDownload(gid: context.gid) + let completedDownload = await manager.fetchDownload(gid: context.gid) let unwrapped = try #require(completedDownload) #expect(unwrapped.displayStatus == .completed) #expect(unwrapped.pageCount == context.updatedPageCount) diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 9ec1009cb..82e1bb699 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -50,7 +50,7 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { Issue.record("retryPages should queue the selected page.") return } - await manager.testingProcessDownload(gid: gid) + await manager.processDownload(gid: gid) let firstRunSnapshot = setup.recorder.snapshot() #expect( @@ -114,12 +114,12 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { return } - await manager.testingProcessDownload(gid: gid) + await manager.processDownload(gid: gid) let snapshot = setup.recorder.snapshot() #expect(snapshot.previewPageNumbers == [0], "\(snapshot)") - let stored = try #require(await manager.testingFetchDownload(gid: gid)) + let stored = try #require(await manager.fetchDownload(gid: gid)) #expect(stored.displayStatus == .inactive) #expect(stored.lastError == nil) #expect(stored.completedPageCount == setup.pageCount - 1) @@ -160,7 +160,7 @@ private extension DownloadRetryMinimalSourceTests { Issue.record("retryPages should queue the selected page.") return } - await context.manager.testingProcessDownload(gid: context.gid) + await context.manager.processDownload(gid: context.gid) let snapshot = context.setup.recorder.snapshot() #expect(snapshot.previewPageNumbers.isEmpty, "\(snapshot)") #expect(snapshot.mpvRequests == 0) @@ -223,8 +223,8 @@ private extension DownloadRetryMinimalSourceTests { gid: gid, title: "Pause Race", status: .partial, pageCount: 156, completedPageCount: 155 ) - let fetchedPayload = try await manager.testingFetchLatestPayload( - for: scaffoldDownload, mode: .redownload, pageSelection: [pageIndex] + let fetchedPayload = try await manager.fetchLatestPayload( + for: scaffoldDownload, mode: .redownload, options: .init(), pageSelection: [pageIndex] ) recorder.reset() return MinimalSourceTestResult( diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index 148bbdd90..4cc2fc61e 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -46,7 +46,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { return } - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) #expect(stored?.displayStatus == .queued) #expect(stored?.badge.status == .queued) #expect(stored?.lastError == nil) @@ -81,7 +81,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { return } - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) #expect(stored?.displayStatus == .inactive) #expect(stored?.completedPageCount == 1) #expect( diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 432288cf7..4fe860cce 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -37,7 +37,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { ) await queueingManager.reloadDownloadIndex() await queueingManager.testingSetUpdatedGalleryIDs([gid]) - let queuedCandidate = await queueingManager.testingFetchDownload(gid: gid) + let queuedCandidate = await queueingManager.fetchDownload(gid: gid) #expect(queuedCandidate?.hasUpdate == true) let blockerTask = Task { try? await Task.sleep(nanoseconds: 5_000_000_000) } @@ -50,7 +50,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { return } - let queued = await queueingManager.testingFetchDownload(gid: gid) + let queued = await queueingManager.fetchDownload(gid: gid) #expect(queued?.displayStatus == .queued) #expect(queued?.badge.status == .queued) #expect(queued?.lastError == nil) @@ -95,7 +95,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { return } - let resumedDownload = await immediateManager.testingFetchDownload(gid: gid) + let resumedDownload = await immediateManager.fetchDownload(gid: gid) #expect(resumedDownload?.displayStatus == .active) #expect(resumedDownload?.lastError == nil) } @@ -134,8 +134,8 @@ private extension DownloadRetryUpdateFallbackTests { gid: gid, title: "Pause Race", status: .partial, pageCount: 156, completedPageCount: 155 ) - let fetchedPayload = try await manager.testingFetchLatestPayload( - for: scaffoldDownload, mode: .update + let fetchedPayload = try await manager.fetchLatestPayload( + for: scaffoldDownload, mode: .update, options: .init(), pageSelection: nil ) let pageCount = fetchedPayload.galleryDetail.pageCount #expect(pageCount > pageIndex) diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index 480db60eb..cb097b038 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -70,9 +70,9 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { await manager.testingSetQueuedGalleryIDs([gid]) async let firstSchedule: Void = - manager.testingScheduleNextIfNeeded() + manager.scheduleNextIfNeeded() async let secondSchedule: Void = - manager.testingScheduleNextIfNeeded() + manager.scheduleNextIfNeeded() await gate.waitForBothArrivals() await gate.releaseAll() @@ -121,7 +121,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { await manager.reloadDownloadIndex() await manager.testingSetQueuedGalleryIDs([firstGID, secondGID]) - await manager.testingScheduleNextIfNeeded() + await manager.scheduleNextIfNeeded() await gate.waitForFirstArrival() let pauseTask = Task { @@ -129,7 +129,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { } try await waitForActiveGalleryID(manager, toEqual: nil) - await manager.testingScheduleNextIfNeeded() + await manager.scheduleNextIfNeeded() await gate.waitForSecondStart() await gate.releaseFirst() diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 449c6b89d..fdf02bed3 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -41,7 +41,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { await manager.reconcileDownloads() - let stored = await manager.testingFetchDownload(gid: gid) + let stored = await manager.fetchDownload(gid: gid) let localPages = try await manager.loadLocalPageURLs(gid: gid).get() #expect(stored?.displayStatus == .inactive) @@ -102,7 +102,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { firstKey: "token" ) ) - let updatedDownload = await manager.testingFetchDownload(gid: gid) + let updatedDownload = await manager.fetchDownload(gid: gid) #expect(updateResult?.displayStatus == .updateAvailable) #expect(updatedDownload?.displayStatus == .updateAvailable) @@ -121,7 +121,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { firstKey: "token" ) ) - let currentDownload = await manager.testingFetchDownload(gid: gid) + let currentDownload = await manager.fetchDownload(gid: gid) #expect(currentResult?.displayStatus == .completed) #expect(currentDownload?.displayStatus == .completed) From caceef20dabe384f116e10895c4469283a63417c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 12:42:23 +0800 Subject: [PATCH 245/614] Inline no-op do/catch and cover passthrough Replace the rethrow-only do/catch in performDownload with a direct return, and drop the downloadCoverIfNeeded passthrough in favor of downloadCoverImage. --- .../DownloadClient+ExecutionPerform.swift | 33 ++++--------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 1e932d339..047ce9f4d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -39,18 +39,11 @@ extension DownloadCoordinator { options: options, existingDownload: existingDownload ) - do { - let batchAndCover = try await executePageDownloads( - context: executionContext, - workingSeed: workingSeed, - pendingIndices: pendingIndices - ) - return batchAndCover - } catch is CancellationError { - throw CancellationError() - } catch { - throw error - } + return try await executePageDownloads( + context: executionContext, + workingSeed: workingSeed, + pendingIndices: pendingIndices + ) } private func executePageDownloads( @@ -61,7 +54,7 @@ extension DownloadCoordinator { let payload = context.payload let options = context.options let folderURL = workingSeed.folderURL - let coverRelativePath = try await downloadCoverIfNeeded( + let coverRelativePath = try await downloadCoverImage( payload: payload, options: options, folderURL: folderURL, @@ -102,20 +95,6 @@ extension DownloadCoordinator { ) } - private func downloadCoverIfNeeded( - payload: DownloadRequestPayload, - options: DownloadRequestOptions, - folderURL: URL, - existingCoverRelativePath: String? - ) async throws -> String? { - try await downloadCoverImage( - payload: payload, - options: options, - folderURL: folderURL, - existingCoverRelativePath: existingCoverRelativePath - ) - } - private func finalizeBatchResult( context: FinalizeContext, payload: DownloadRequestPayload, From 7c30b35ed19b533db85b70f5d68d3959f2ae0ab5 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 12:42:23 +0800 Subject: [PATCH 246/614] Build replacing(pages:) by copy-mutate Make DownloadManifest.pages a var and copy-mutate the single field instead of re-listing all 13 fields. --- .../Utilities/DownloadStore+Operations.swift | 18 +++--------------- .../Download/DownloadedGallery+Manifest.swift | 2 +- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift index 450a31966..f92cbd3e0 100644 --- a/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift @@ -304,20 +304,8 @@ private extension DownloadManifest { func replacing( pages: [Int: String] ) -> DownloadManifest { - DownloadManifest( - gid: gid, - host: host, - token: token, - title: title, - jpnTitle: jpnTitle, - category: category, - language: language, - remoteCoverURL: remoteCoverURL, - uploader: uploader, - tags: tags, - postedDate: postedDate, - rating: rating, - pages: pages - ) + var manifest = self + manifest.pages = pages + return manifest } } diff --git a/EhPanda/Models/Download/DownloadedGallery+Manifest.swift b/EhPanda/Models/Download/DownloadedGallery+Manifest.swift index 0cd1f504d..63e66717b 100644 --- a/EhPanda/Models/Download/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Download/DownloadedGallery+Manifest.swift @@ -18,7 +18,7 @@ struct DownloadManifest: Codable, Equatable, Sendable { let tags: [GalleryTag] let postedDate: Date let rating: Float - let pages: [Int: String] + var pages: [Int: String] } extension DownloadManifest { From cb400de3152b0bfc650fe147ab58e7e034090772 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 12:42:23 +0800 Subject: [PATCH 247/614] Unify MPV URL check on URLClient.isMPVURL Extract the count>=2 MPV path check into URLClient.isMPVURL and route both the inline ExecutionSupport check and the checkIfMPVURL endpoint through it. --- .../Clients/DownloadClient+ExecutionSupport.swift | 3 +-- EhPanda/App/Tools/Clients/URLClient.swift | 10 ++++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index bf5f827f2..c674a96e6 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -189,8 +189,7 @@ extension DownloadCoordinator { else { throw AppError.notFound } - if firstURL.pathComponents.count > 1, - firstURL.pathComponents[1] == "mpv" { + if URLClient.isMPVURL(firstURL) { let (mpvKey, imageKeys) = try await MPVKeysRequest( mpvURL: firstURL, urlSession: urlSession, diff --git a/EhPanda/App/Tools/Clients/URLClient.swift b/EhPanda/App/Tools/Clients/URLClient.swift index 102f52ff8..092ba9d35 100644 --- a/EhPanda/App/Tools/Clients/URLClient.swift +++ b/EhPanda/App/Tools/Clients/URLClient.swift @@ -19,6 +19,11 @@ struct URLClient: Sendable { } extension URLClient { + static func isMPVURL(_ url: URL?) -> Bool { + guard let url else { return false } + return url.pathComponents.count >= 2 && url.pathComponents[1] == "mpv" + } + static let live: Self = .init( checkIfHandleable: { url in (url.absoluteString.contains(Defaults.URL.ehentai.absoluteString) @@ -26,10 +31,7 @@ extension URLClient { && url.pathComponents.count >= 4 && ["g", "s"].contains(url.pathComponents[1]) && !url.pathComponents[2].isEmpty && !url.pathComponents[3].isEmpty }, - checkIfMPVURL: { - guard let url = $0 else { return false } - return url.pathComponents.count >= 2 && url.pathComponents[1] == "mpv" - }, + checkIfMPVURL: Self.isMPVURL, parseGalleryID: { url in var gid = url.pathComponents[2] let token = url.pathComponents[3] From da2bfc1f8125b96a158a6d7073f1265da27a95ef Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 12:42:23 +0800 Subject: [PATCH 248/614] Add UTType fallback to extensionFromMimeType Fall back to UTType(mimeType:).preferredFilenameExtension for unmapped image MIME types (AVIF/JXL), gated on an image/ prefix so non-image types yield nil. --- EhPanda/App/Tools/Clients/DownloadClient+Networking.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift index cbe513eec..71a256c5a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift @@ -4,6 +4,7 @@ // import Foundation +import UniformTypeIdentifiers // MARK: - Network extension DownloadCoordinator { @@ -279,7 +280,8 @@ extension DownloadCoordinator { case "image/webp": return "webp" default: - return nil + guard mimeType.hasPrefix("image/") else { return nil } + return UTType(mimeType: mimeType)?.preferredFilenameExtension } } From 1ff0e002b6257e5340696f667b84ca4e9ab4888e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 12:42:23 +0800 Subject: [PATCH 249/614] Give the FileManager Mutex an owned instance Default the sending FileManager to FileManager() instead of .default so the Mutex wraps a privately-owned instance and its exclusivity claim is honest. --- EhPanda/App/Tools/Clients/DownloadClient.swift | 2 +- EhPanda/App/Tools/Clients/DownloadPageDownloader.swift | 2 +- EhPanda/App/Tools/Utilities/DataCache.swift | 2 +- EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift | 2 +- EhPanda/App/Tools/Utilities/DownloadStore.swift | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 75fd444b7..989028140 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -37,7 +37,7 @@ extension DownloadClient { static func live( rootURL: URL = FileUtil.downloadsDirectoryURL, urlSession: URLSession = .shared, - fileManager: sending FileManager = .default + fileManager: sending FileManager = FileManager() ) -> Self { let storage = DownloadStore(rootURL: rootURL, fileManager: fileManager) let backgroundTaskStore = DownloadBackgroundTaskStore( diff --git a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift index fdbf6294d..b58aa4ff0 100644 --- a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift +++ b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift @@ -34,7 +34,7 @@ struct DownloadPageDownloader: Sendable { identifier: String, taskStore: DownloadBackgroundTaskStore, holdingDirectory: URL, - fileManager: sending FileManager = .default, + fileManager: sending FileManager = FileManager(), orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void = { _, _, _ in }, orphanedFailureHandler: @escaping @Sendable (Int, AppError?) async -> Void = { _, _ in } ) -> Self { diff --git a/EhPanda/App/Tools/Utilities/DataCache.swift b/EhPanda/App/Tools/Utilities/DataCache.swift index 73149262f..5b3cd346d 100644 --- a/EhPanda/App/Tools/Utilities/DataCache.swift +++ b/EhPanda/App/Tools/Utilities/DataCache.swift @@ -39,7 +39,7 @@ actor DataCache { init( configuration: Configuration = .init(), - fileManager: sending FileManager = .default + fileManager: sending FileManager = FileManager() ) { self.configuration = configuration self.fileManager = fileManager diff --git a/EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift b/EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift index 20783bef4..bcaaf9af9 100644 --- a/EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift +++ b/EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift @@ -17,7 +17,7 @@ actor DownloadBackgroundTaskStore { init( fileURL: URL, - fileManager: sending FileManager = .default + fileManager: sending FileManager = FileManager() ) { self.fileURL = fileURL self.fileManager = DownloadFileManager(fileManager) diff --git a/EhPanda/App/Tools/Utilities/DownloadStore.swift b/EhPanda/App/Tools/Utilities/DownloadStore.swift index 7357ff120..8a15f1914 100644 --- a/EhPanda/App/Tools/Utilities/DownloadStore.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore.swift @@ -34,7 +34,7 @@ struct DownloadStore: Sendable { init( rootURL: URL = FileUtil.downloadsDirectoryURL, - fileManager: sending FileManager = .default + fileManager: sending FileManager = FileManager() ) { self.rootURL = rootURL self.fileManager = DownloadFileManager(fileManager) From e13182de3d380ef8cdf89f680cf422d75cb8b465 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 16:21:47 +0800 Subject: [PATCH 250/614] Unify reader image export onto the owned fetch Export (save/share/copy) fetched cache-misses through the legacy extension-routed downloadImage (Kingfisher/SD), diverging from the DES-1 reader display path. That left a re-encode + racing double-store on every export miss, a DataCache.shared coupling that bypassed the injectable-cache test isolation, and a static-export corner for animated URLs without an animation-indicating extension (e.g. fullimg.php). Route fetchImageAsset through readerImageData so display and export share one owned, byte-pure, injectable-cache fetch. Delete the now-dead cluster: downloadImage/downloadStaticImage/downloadAnimatedImage, the SD-operation continuation/MainActor bridging boxes, URL.isPotentiallyAnimatedImage, and the test-only fetchImage(url:). BUG-23/24 caching+cancellation are subsumed by DataCache + URLSession Task cancellation; the export corner is closed. Tests: drop the six legacy ImageClient tests (they covered removed behavior) and add one locking the new export-reads-owned-cache contract. 257 pass. --- EhPanda/App/Tools/Clients/ImageClient.swift | 277 +----------------- EhPanda/App/Tools/Extensions/Extensions.swift | 7 - .../DownloadManagerRepairSeedTests.swift | 266 ----------------- .../Tests/Download/ReaderImageDataTests.swift | 27 ++ 4 files changed, 35 insertions(+), 542 deletions(-) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index a03d520bf..b6be66ab7 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -7,8 +7,6 @@ import Photos import SwiftUI import Combine import Kingfisher -import SDWebImage -import Synchronization import ComposableArchitecture struct ImageClient: Sendable { @@ -24,9 +22,6 @@ struct ImageClient: Sendable { let prefetchImages: @Sendable ([URL]) -> Void let saveImageToPhotoLibrary: @Sendable (UIImage, Bool) async -> Bool let saveImageDataToPhotoLibrary: @Sendable (Data) async -> Bool - let downloadImage: @Sendable (URL) async -> Result - let retrieveImage: @Sendable (String) async -> Result - let isCached: @Sendable (String) -> Bool var dataCache: DataCache = .shared var urlSession: URLSession = .shared } @@ -65,20 +60,7 @@ extension ImageClient { }, saveImageDataToPhotoLibrary: { data in await Self.saveImageDataToPhotoLibrary(data) - }, - downloadImage: { url in - if url.isPotentiallyAnimatedImage { - return await ImageClient.downloadAnimatedImage(url: url) - } - return await ImageClient.downloadStaticImage(url: url) - }, - retrieveImage: { key in - guard let image = await LibraryClient.live.cachedImage(key) else { - return .failure(AppError.notFound) - } - return .success(image) - }, - isCached: LibraryClient.live.isCached + } ) static func saveImageDataToPhotoLibrary(_ data: Data) async -> Bool { @@ -92,99 +74,13 @@ extension ImageClient { } } - // Runs on the `MainActor` so the non-`Sendable` `SDWebImageCombinedOperation` it creates - // never leaves a single isolation domain, see `AnimatedImageOperationBox`. - @MainActor - static func downloadAnimatedImage( - url: URL, - manager: SDWebImageManager = .shared - ) async -> Result { - let continuationBox = ImageDownloadContinuationBox() - let operationBox = AnimatedImageOperationBox() - let result: Result = await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - continuationBox.setContinuation(continuation) - let operation = manager.loadImage( - with: url, - options: [.retryFailed, .continueInBackground, .handleCookies], - context: [.callbackQueue: SDCallbackQueue.main], - progress: nil - ) { image, data, error, _, _, _ in - if let image { - if let data { - Task { - try? await DataCache.shared.store( - data, - forKeys: url.imageCacheKeys(includeStableAlias: true) - ) - } - } - continuationBox.resume(returning: .success(image)) - } else { - continuationBox.resume(returning: .failure(error ?? AppError.notFound)) - } - } - guard let operation else { - continuationBox.resume(returning: .failure(AppError.notFound)) - return - } - operationBox.track(operation) - continuationBox.setCancelOperation { - Task { @MainActor in operationBox.cancel() } - } - } - } onCancel: { - continuationBox.cancel() - } - return result - } - - static func downloadStaticImage( - url: URL, - downloader: ImageDownloader = KingfisherManager.shared.downloader, - cache: ImageCache = KingfisherManager.shared.cache - ) async -> Result { - let continuationBox = ImageDownloadContinuationBox() - let result: Result = await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - continuationBox.setContinuation(continuation) - let downloadTask = downloader.downloadImage( - with: url, - options: nil - ) { result in - switch result { - case .success(let downloadResult): - Task { - try? await DataCache.shared.store( - downloadResult.originalData, - forKeys: url.imageCacheKeys(includeStableAlias: true) - ) - } - cache.store( - downloadResult.image, - original: downloadResult.originalData, - forKey: url.stableImageCacheKey ?? url.absoluteString, - completionHandler: { _ in - continuationBox.resume(returning: .success(downloadResult.image)) - } - ) - case .failure(let error): - continuationBox.resume(returning: .failure(error)) - } - } - continuationBox.setCancelOperation { - downloadTask.cancel() - } - } - } onCancel: { - continuationBox.cancel() - } - return result - } - + // Exports read the same owned bytes the reader display caches, so save/share/copy + // route by image content (DES-1) instead of the request URL's extension. func fetchImageAsset(url: URL) async -> Result { do { - let data = try await imageData(url: url) + let data = try await Self.readerImageData( + url: url, dataCache: dataCache, urlSession: urlSession + ) guard let image = data.decodedImage else { return .failure(AppError.parseFailed) } @@ -194,15 +90,6 @@ extension ImageClient { } } - func fetchImage(url: URL) async -> Result { - switch await fetchImageAsset(url: url) { - case .success(let asset): - return .success(asset.image) - case .failure(let error): - return .failure(error) - } - } - func fetchReaderImageAsset( url: URL, onProgress: (@MainActor @Sendable (Double) -> Void)? = nil @@ -279,148 +166,6 @@ extension ImageClient { throw AppError.networkingFailed } } - - private func imageData(url: URL) async throws -> Data { - if url.isFileURL { - return try Data(contentsOf: url) - } - - let cacheKeys = url.imageCacheKeys(includeStableAlias: true) - if let data = try await dataCache.data(forKeys: cacheKeys) { - return data - } - - for key in cacheKeys { - guard isCached(key) else { continue } - guard case .success(let image) = await retrieveImage(key), - let data = Self.data(from: image) - else { - continue - } - try? await dataCache.store(data, forKeys: cacheKeys) - return data - } - - switch await downloadImage(url) { - case .success(let image): - guard let data = Self.data(from: image) else { - throw AppError.notFound - } - try? await dataCache.store(data, forKeys: cacheKeys) - return data - - case .failure(let error): - throw error - } - } - - private static func data(from image: UIImage) -> Data? { - image.animatedSourceData - ?? image.sd_imageData() - ?? image.kf.data(format: .unknown) - } -} - -// The callback APIs expose cancellation tokens after Swift task cancellation can already arrive. -private final class ImageDownloadContinuationBox: Sendable { - private struct State: Sendable { - var cancelOperation: (@Sendable () -> Void)? - var continuation: CheckedContinuation, Never>? - var isCancelled = false - var isFinished = false - } - - private let state = Mutex(State()) - - func setContinuation(_ continuation: CheckedContinuation, Never>) { - let shouldResumeCancellation = state.withLock { state in - if state.isCancelled || state.isFinished { - state.isFinished = true - return true - } - state.continuation = continuation - return false - } - - if shouldResumeCancellation { - continuation.resume(returning: .failure(CancellationError())) - } - } - - func setCancelOperation(_ cancelOperation: @escaping @Sendable () -> Void) { - let shouldCancel = state.withLock { state in - if state.isCancelled { - return true - } - if !state.isFinished { - state.cancelOperation = cancelOperation - } - return false - } - - if shouldCancel { - cancelOperation() - } - } - - func resume(returning result: Result) { - let continuation = state.withLock { state in - guard !state.isFinished else { - return nil as CheckedContinuation, Never>? - } - state.isFinished = true - let continuation = state.continuation - state.continuation = nil - state.cancelOperation = nil - return continuation - } - - continuation?.resume(returning: result) - } - - func cancel() { - let cancellation = state.withLock { state in - guard !state.isFinished else { - return ( - cancelOperation: nil as (@Sendable () -> Void)?, - continuation: nil as CheckedContinuation, Never>? - ) - } - state.isCancelled = true - let cancelOperation = state.cancelOperation - state.cancelOperation = nil - let continuation = state.continuation - if continuation != nil { - state.isFinished = true - state.continuation = nil - } - return ( - cancelOperation: cancelOperation, - continuation: continuation - ) - } - - cancellation.cancelOperation?() - cancellation.continuation?.resume(returning: .failure(CancellationError())) - } -} - -// Holds the in-flight `SDWebImageCombinedOperation` so it can be cancelled when the awaiting task -// is cancelled. The operation is an Objective-C type with no `Sendable` annotation and models live -// work, so it cannot be transferred across isolation domains; confining the box to the `MainActor` -// keeps it within the actor that `downloadAnimatedImage` already runs on. The cancel handle stored -// in `ImageDownloadContinuationBox` reaches it by hopping back to the `MainActor`. -@MainActor private final class AnimatedImageOperationBox { - private var operation: SDWebImageCombinedOperation? - - func track(_ operation: SDWebImageCombinedOperation) { - self.operation = operation - } - - func cancel() { - operation?.cancel() - operation = nil - } } // MARK: API @@ -442,10 +187,7 @@ extension ImageClient { static let noop: Self = .init( prefetchImages: { _ in }, saveImageToPhotoLibrary: { _, _ in false }, - saveImageDataToPhotoLibrary: { _ in false }, - downloadImage: { _ in .success(UIImage()) }, - retrieveImage: { _ in .success(UIImage()) }, - isCached: { _ in false } + saveImageDataToPhotoLibrary: { _ in false } ) static func placeholder() -> Result { fatalError() } @@ -453,9 +195,6 @@ extension ImageClient { static let unimplemented: Self = .init( prefetchImages: IssueReporting.unimplemented(placeholder: placeholder()), saveImageToPhotoLibrary: IssueReporting.unimplemented(placeholder: placeholder()), - saveImageDataToPhotoLibrary: IssueReporting.unimplemented(placeholder: placeholder()), - downloadImage: IssueReporting.unimplemented(placeholder: placeholder()), - retrieveImage: IssueReporting.unimplemented(placeholder: placeholder()), - isCached: IssueReporting.unimplemented(placeholder: placeholder()) + saveImageDataToPhotoLibrary: IssueReporting.unimplemented(placeholder: placeholder()) ) } diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift index d141ff63c..d7e0db7c1 100644 --- a/EhPanda/App/Tools/Extensions/Extensions.swift +++ b/EhPanda/App/Tools/Extensions/Extensions.swift @@ -61,13 +61,6 @@ extension Float { extension URL { static let mock = Defaults.URL.ehentai - var isPotentiallyAnimatedImage: Bool { - switch pathExtension.lowercased() { - case "apng", "gif", "png", "webp": true - default: false - } - } - func previewCacheCleanupURLs() -> [URL] { guard let info = Parser.parsePreviewConfigs(url: self), info.plainURL != self diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift index 5d9e7c3ac..c679fd47d 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift @@ -3,9 +3,6 @@ // EhPandaTests // -import Kingfisher -import SDWebImage -import UIKit import Foundation import Testing @testable import EhPanda @@ -135,274 +132,11 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { #expect(await manager.rescanLocalPageURLs(gid: gid) == [2: pageTwoURL]) } - @MainActor - @Test - func testImageClientFetchImageUsesStableAliasCacheKey() async throws { - let url = try #require( - URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") - ) - let stableCacheKey = try #require(url.stableImageCacheKey) - let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in - UIColor.systemRed.setFill() - context.fill(.init(x: 0, y: 0, width: 1, height: 1)) - } - let imageData = try #require(image.pngData()) - - try await KingfisherManager.shared.cache.store(image, original: imageData, forKey: stableCacheKey) - defer { - KingfisherManager.shared.cache.removeImage(forKey: stableCacheKey) - KingfisherManager.shared.cache.removeImage(forKey: url.absoluteString) - } - - let (cache, cacheRootURL) = makeIsolatedDataCache() - defer { try? FileManager.default.removeItem(at: cacheRootURL) } - var client = ImageClient.live - client.dataCache = cache - - let result = await client.fetchImage(url: url) - let fetchedImage = try result.get() - - #expect(pixelSize(fetchedImage) == pixelSize(image)) - } - - @MainActor - @Test - func testImageClientDownloadImageCachesKingfisherOriginalUnderStableKey() async throws { - let sessionID = UUID().uuidString - let url = try #require( - URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") - ) - let stableCacheKey = try #require(url.stableImageCacheKey) - let format = UIGraphicsImageRendererFormat() - format.scale = 1 - let image = UIGraphicsImageRenderer( - size: .init(width: 1, height: 1), - format: format - ) - .image { context in - UIColor.systemBlue.setFill() - context.fill(.init(x: 0, y: 0, width: 1, height: 1)) - } - let imageData = try #require(image.pngData()) - let downloader = ImageDownloader(name: "test-\(sessionID)") - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [SharedSessionStubURLProtocol.self] - configuration.httpAdditionalHeaders = [ - SharedSessionStubURLProtocol.headerKey: sessionID - ] - downloader.sessionConfiguration = configuration - SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in - #expect(request.url == url) - return ( - try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: [ - "Content-Type": "image/png", - "Content-Length": "\(imageData.count)" - ] - )), - imageData - ) - } - defer { - SharedSessionStubURLProtocol.removeHandler(for: sessionID) - KingfisherManager.shared.cache.removeImage(forKey: stableCacheKey) - KingfisherManager.shared.cache.removeImage(forKey: url.absoluteString) - } - - let result = await ImageClient.downloadStaticImage( - url: url, - downloader: downloader, - cache: KingfisherManager.shared.cache - ) - let downloadedImage = try result.get() - - #expect(downloadedImage.size == image.size) - await waitUntilCacheReady(for: [stableCacheKey], timeout: .seconds(3)) - let cachedImage = try #require(await LibraryClient.live.cachedImage(stableCacheKey)) - #expect(cachedImage.size == image.size) - } - - @MainActor - @Test - func testImageClientDownloadImageCancelsKingfisherTask() async throws { - let url = try #require( - URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") - ) - let downloader = ImageDownloader(name: "cancel-\(UUID().uuidString)") - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [HangingURLProtocol.self] - downloader.sessionConfiguration = configuration - let task = Task { - await ImageClient.downloadStaticImage( - url: url, - downloader: downloader, - cache: KingfisherManager.shared.cache - ) - } - - task.cancel() - let result = try await waitForTaskValue( - task, - timeout: .seconds(1), - description: "Kingfisher image cancellation" - ) - - #expect(throws: CancellationError.self) { - try result.get() - } - } - - @MainActor - @Test - func testImageClientDownloadImageCancelsSDWebImageOperation() async throws { - let url = try #require( - URL(string: "https://ehgt.org/ab/cd/0001-1234567890.webp?download=1") - ) - let downloaderConfig = SDWebImageDownloaderConfig() - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [HangingURLProtocol.self] - downloaderConfig.sessionConfiguration = configuration - let downloader = SDWebImageDownloader(config: downloaderConfig) - let manager = SDWebImageManager(cache: SDImageCache.shared, loader: downloader) - let task = Task { - await ImageClient.downloadAnimatedImage(url: url, manager: manager) - } - - task.cancel() - let result = try await waitForTaskValue( - task, - timeout: .seconds(1), - description: "SDWebImage image cancellation" - ) - - #expect(throws: CancellationError.self) { - try result.get() - } - } - - @MainActor - @Test - func testImageClientFetchImageUsesSDWebImageStableAliasCacheKey() async throws { - let url = try #require( - URL(string: "https://ehgt.org/ab/cd/0001-1234567890.webp?download=1") - ) - let stableCacheKey = try #require(url.stableImageCacheKey) - let format = UIGraphicsImageRendererFormat() - format.scale = 1 - let image = UIGraphicsImageRenderer( - size: .init(width: 1, height: 1), - format: format - ) - .image { context in - UIColor.systemGreen.setFill() - context.fill(.init(x: 0, y: 0, width: 1, height: 1)) - } - let imageData = try #require(image.pngData()) - - await storeSDWebImageData(imageData, forKey: stableCacheKey) - defer { - SDImageCache.shared.removeImage(forKey: stableCacheKey) {} - SDImageCache.shared.removeImage(forKey: url.absoluteString) {} - } - - let (cache, cacheRootURL) = makeIsolatedDataCache() - defer { try? FileManager.default.removeItem(at: cacheRootURL) } - let client = ImageClient( - prefetchImages: { _ in }, - saveImageToPhotoLibrary: { _, _ in false }, - saveImageDataToPhotoLibrary: { _ in false }, - downloadImage: { _ in - Issue.record("Expected ImageClient to use the cached SDWebImage data.") - return .failure(AppError.notFound) - }, - retrieveImage: ImageClient.live.retrieveImage, - isCached: LibraryClient.live.isCached, - dataCache: cache - ) - - let result = await client.fetchImage(url: url) - let fetchedImage = try result.get() - - #expect(fetchedImage.size == image.size) - } - - @MainActor - @Test - func testImageClientFetchImageDownloadsWhenCachedRetrievalFails() async throws { - let url = try #require( - URL(string: "https://ehgt.org/ab/cd/0001-1234567890.jpg?download=1") - ) - let expectedCacheKeys = url.imageCacheKeys(includeStableAlias: true) - let (cache, cacheRootURL) = makeIsolatedDataCache() - defer { try? FileManager.default.removeItem(at: cacheRootURL) } - let retrievedCacheKeys = UncheckedBox([String]()) - let downloadedURLs = UncheckedBox([URL]()) - let downloadedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in - UIColor.systemBlue.setFill() - context.fill(.init(x: 0, y: 0, width: 1, height: 1)) - } - let client = ImageClient( - prefetchImages: { _ in }, - saveImageToPhotoLibrary: { _, _ in false }, - saveImageDataToPhotoLibrary: { _ in false }, - downloadImage: { downloadURL in - downloadedURLs.value.append(downloadURL) - return .success(downloadedImage) - }, - retrieveImage: { cacheKey in - retrievedCacheKeys.value.append(cacheKey) - return .failure(AppError.notFound) - }, - isCached: { _ in true }, - dataCache: cache - ) - - let result = await client.fetchImage(url: url) - _ = try result.get() - - #expect(retrievedCacheKeys.value == expectedCacheKeys) - #expect(downloadedURLs.value == [url]) - } - } // MARK: - Repair Seed Helpers private extension DownloadManagerRepairSeedTests { - func storeSDWebImageData(_ data: Data, forKey key: String) async { - await withCheckedContinuation { continuation in - SDImageCache.shared.storeImageData(data, forKey: key) { - continuation.resume() - } - } - } - - /// A `DataCache` backed by a throwaway directory, isolated from `DataCache.shared`. - /// - /// `ImageClient.imageData` consults its `dataCache` before the retrieve/download - /// closures, and `.shared` persists on disk across runs. Injecting a per-test cache - /// keeps these tests hermetic and repeatable without clearing the simulator cache. - func makeIsolatedDataCache() -> (cache: DataCache, rootURL: URL) { - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - return (DataCache(configuration: .init(rootURL: rootURL)), rootURL) - } - - /// The image's dimensions in pixels (`size` in points × `scale`). - /// - /// `fetchImage` round-trips images through `Data`, which yields a scale-1 image with - /// the original pixel dimensions. Comparing pixels keeps cache-hit assertions stable - /// regardless of the stored image's scale. - func pixelSize(_ image: UIImage) -> CGSize { - .init( - width: image.size.width * image.scale, - height: image.size.height * image.scale - ) - } - func setupRepairSeedFiles( storage: DownloadStore, sourceFolderURL: URL, diff --git a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift index b9a3c34e2..d0b1a1e42 100644 --- a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift +++ b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift @@ -119,6 +119,33 @@ struct ReaderImageDataTests { #expect(cached == nil) } + @Test + func testFetchImageAssetServesOwnedCacheAndRoutesByBytes() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/export.png")) + let imageData = try makePNGData() + try await cache.store( + imageData, forKeys: url.imageCacheKeys(includeStableAlias: true) + ) + let requestCount = UncheckedBox(0) + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + requestCount.value += 1 + return (try makeHTTPResponse(url: url, statusCode: 200), imageData) + } + var client = ImageClient.live + client.dataCache = cache + client.urlSession = session + + let asset = try await client.fetchImageAsset(url: url).get() + + #expect(asset.data == imageData) + #expect(asset.isAnimated == false) + #expect(requestCount.value == 0) + } + private func makeIsolatedDataCache() -> (cache: DataCache, rootURL: URL) { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) From 84f1fc14012918dc9c33fd353f06d47f75013ab8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 16:27:58 +0800 Subject: [PATCH 251/614] Complete the DownloadCoordinator rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DES-2 renamed the god-actor to DownloadCoordinator but left a `typealias DownloadManager = DownloadCoordinator` alive purely so the test suite could keep the old name — a production alias with zero production consumers, asymmetric with B8's complete DownloadStore migration (which dropped its typealias and renamed every reference). Drop the typealias and migrate every test reference — types, the makeTesting/makeStubbed helpers, the four file names, and the suite and method names — to DownloadCoordinator, so one struct has exactly one name. No production code referenced the alias; behavior is unchanged. --- .../Clients/DownloadClient+Manager.swift | 2 - .../DownloadBackgroundCompletionTests.swift | 10 +-- ...> DownloadCoordinatorCachedURLTests.swift} | 8 +- ... => DownloadCoordinatorCaptureTests.swift} | 14 +-- ... DownloadCoordinatorRepairSeedTests.swift} | 14 +-- ... => DownloadCoordinatorStorageTests.swift} | 88 +++++++++---------- .../DownloadEnqueueManifestTests.swift | 4 +- .../DownloadFeatureTestFactories.swift | 6 +- .../Download/DownloadFeatureTestHelpers.swift | 12 +-- .../DownloadFolderOperationTests.swift | 4 +- .../Download/DownloadImageErrorTests.swift | 12 +-- .../DownloadImageParsingCacheTests.swift | 6 +- .../Download/DownloadImageParsingTests.swift | 20 ++--- .../DownloadInterruptedResumeTests.swift | 8 +- .../Tests/Download/DownloadIpBanTests.swift | 2 +- .../Download/DownloadObserverBatchTests.swift | 6 +- .../DownloadPauseAndReconcileTests.swift | 12 +-- .../Download/DownloadProcessCacheTests.swift | 8 +- .../Tests/Download/DownloadProcessTests.swift | 10 +-- .../DownloadRetryMinimalSourceTests.swift | 8 +- .../Download/DownloadRetryPagesTests.swift | 4 +- .../DownloadRetryUpdateFallbackTests.swift | 6 +- .../Download/DownloadSchedulingTests.swift | 6 +- .../DownloadVersionSignatureTests.swift | 6 +- 24 files changed, 137 insertions(+), 139 deletions(-) rename EhPandaTests/Tests/Download/{DownloadManagerCachedURLTests.swift => DownloadCoordinatorCachedURLTests.swift} (92%) rename EhPandaTests/Tests/Download/{DownloadManagerCaptureTests.swift => DownloadCoordinatorCaptureTests.swift} (92%) rename EhPandaTests/Tests/Download/{DownloadManagerRepairSeedTests.swift => DownloadCoordinatorRepairSeedTests.swift} (94%) rename EhPandaTests/Tests/Download/{DownloadManagerStorageTests.swift => DownloadCoordinatorStorageTests.swift} (92%) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 817e94ba4..135cbe299 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -40,8 +40,6 @@ struct DownloadTaskRunner: Sendable { } } -typealias DownloadManager = DownloadCoordinator - actor DownloadCoordinator { static let retryLimit = 3 static let progressFlushPageInterval = 8 diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift index 3113739ff..41ee24c10 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift @@ -18,7 +18,7 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, backgroundTaskStore: taskStore @@ -75,7 +75,7 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, backgroundTaskStore: taskStore @@ -109,7 +109,7 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, backgroundTaskStore: taskStore @@ -143,7 +143,7 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, backgroundTaskStore: taskStore @@ -173,7 +173,7 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, backgroundTaskStore: taskStore diff --git a/EhPandaTests/Tests/Download/DownloadManagerCachedURLTests.swift b/EhPandaTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift similarity index 92% rename from EhPandaTests/Tests/Download/DownloadManagerCachedURLTests.swift rename to EhPandaTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift index a3cf40139..5ebb8c909 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCachedURLTests.swift +++ b/EhPandaTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift @@ -1,5 +1,5 @@ // -// DownloadManagerCachedURLTests.swift +// DownloadCoordinatorCachedURLTests.swift // EhPandaTests // @@ -8,7 +8,7 @@ import Testing @testable import EhPanda @Suite(.serialized) -struct DownloadManagerCachedURLTests { +struct DownloadCoordinatorCachedURLTests { @Test func testIndexedDownloadUsesCachedLocalURLsUntilExplicitReload() async throws { let rootURL = FileManager.default.temporaryDirectory @@ -16,7 +16,7 @@ struct DownloadManagerCachedURLTests { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) let folderRelativePath = "Folder/[900_token] Cached" let folderURL = storage.folderURL(relativePath: folderRelativePath) let page1URL = folderURL.appendingPathComponent("900_token_1.jpg") @@ -54,7 +54,7 @@ struct DownloadManagerCachedURLTests { } } -private extension DownloadManagerCachedURLTests { +private extension DownloadCoordinatorCachedURLTests { func manifest() -> DownloadManifest { DownloadManifest( gid: "900", diff --git a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift similarity index 92% rename from EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift rename to EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift index 4194246ab..b8b399388 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift @@ -1,5 +1,5 @@ // -// DownloadManagerCaptureTests.swift +// DownloadCoordinatorCaptureTests.swift // EhPandaTests // @@ -9,16 +9,16 @@ import Testing @testable import EhPanda @Suite(.serialized) -struct DownloadManagerCaptureTests: DownloadFeatureTestCase { +struct DownloadCoordinatorCaptureTests: DownloadFeatureTestCase { @Test - func testDownloadManagerCaptureCachedPageRestoresFinalPage() async throws { + func testDownloadCoordinatorCaptureCachedPageRestoresFinalPage() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 27) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -69,14 +69,14 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { @MainActor @Test - func testDownloadManagerCaptureCachedPageRepairsCompletedDownloadWithLatestRemoteImage() async throws { + func testDownloadCoordinatorCaptureCachedPageRepairsCompletedDownloadWithLatestRemoteImage() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 28) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) let completedFolderURL = try setupCaptureMissingFilesFolder( rootURL: rootURL, gid: gid ) @@ -105,7 +105,7 @@ struct DownloadManagerCaptureTests: DownloadFeatureTestCase { // MARK: - Setup Helpers -private extension DownloadManagerCaptureTests { +private extension DownloadCoordinatorCaptureTests { func setupCaptureMissingFilesFolder(rootURL: URL, gid: String) throws -> URL { let completedFolderURL = rootURL.appendingPathComponent("Folder/\(gid) - Pause Race", isDirectory: true) try FileManager.default.createDirectory( diff --git a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift similarity index 94% rename from EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift rename to EhPandaTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift index c679fd47d..0586c504b 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift @@ -1,5 +1,5 @@ // -// DownloadManagerRepairSeedTests.swift +// DownloadCoordinatorRepairSeedTests.swift // EhPandaTests // @@ -8,7 +8,7 @@ import Testing @testable import EhPanda @Suite(.serialized) -struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { +struct DownloadCoordinatorRepairSeedTests: DownloadFeatureTestCase { @Test func testRepairSeedReusesCompletedFilesWhenPageCountMatches() async throws { let gid = "repair-seed-\(UUID().uuidString)" @@ -17,7 +17,7 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) try storage.ensureRootDirectory() let sourceFolderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Existing") @@ -74,14 +74,14 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerLoadLocalPageURLsRemovesZeroBytePage() async throws { + func testDownloadCoordinatorLoadLocalPageURLsRemovesZeroBytePage() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 13) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) let (emptyPageURL, goodPageURL) = try setupZeroBytePageFiles( rootURL: rootURL, gid: gid, storage: storage @@ -103,7 +103,7 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) let folderURL = rootURL.appendingPathComponent( "Folder/\(gid) - Rescan", isDirectory: true @@ -136,7 +136,7 @@ struct DownloadManagerRepairSeedTests: DownloadFeatureTestCase { // MARK: - Repair Seed Helpers -private extension DownloadManagerRepairSeedTests { +private extension DownloadCoordinatorRepairSeedTests { func setupRepairSeedFiles( storage: DownloadStore, sourceFolderURL: URL, diff --git a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift b/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift similarity index 92% rename from EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift rename to EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift index 9d0231a2c..e537ebcce 100644 --- a/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift @@ -1,5 +1,5 @@ // -// DownloadManagerStorageTests.swift +// DownloadCoordinatorStorageTests.swift // EhPandaTests // @@ -10,15 +10,15 @@ import Testing @testable import EhPanda @Suite(.serialized) -struct DownloadManagerStorageTests: DownloadFeatureTestCase { +struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { @Test - func testDownloadManagerReloadDownloadIndexScansManifestFolders() async throws { + func testDownloadCoordinatorReloadDownloadIndexScansManifestFolders() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -64,13 +64,13 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerReloadDownloadIndexKeepsNewestDuplicateFolder() async throws { + func testDownloadCoordinatorReloadDownloadIndexKeepsNewestDuplicateFolder() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -120,13 +120,13 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerFetchesDownloadsFromManifestIndex() async throws { + func testDownloadCoordinatorFetchesDownloadsFromManifestIndex() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -156,13 +156,13 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerWarmIndexMissDoesNotRescanDisk() async throws { + func testDownloadCoordinatorWarmIndexMissDoesNotRescanDisk() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) try storage.ensureRootDirectory() try writeIndexedManifest( @@ -181,13 +181,13 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerObserverInitialSnapshotUsesManifestIndex() async throws { + func testDownloadCoordinatorObserverInitialSnapshotUsesManifestIndex() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -224,13 +224,13 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerIndexAppliesSessionOnlyFlags() async throws { + func testDownloadCoordinatorIndexAppliesSessionOnlyFlags() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -270,13 +270,13 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerReconcileClearsIndexedCancellationError() async throws { + func testDownloadCoordinatorReconcileClearsIndexedCancellationError() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -309,13 +309,13 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerReconcileClearsIndexedInterruptedActiveFlag() async throws { + func testDownloadCoordinatorReconcileClearsIndexedInterruptedActiveFlag() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -341,13 +341,13 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerSanitizeClearsIndexedError() async throws { + func testDownloadCoordinatorSanitizeClearsIndexedError() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -380,13 +380,13 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerValidateIndexedMissingFileUsesSessionError() async throws { + func testDownloadCoordinatorValidateIndexedMissingFileUsesSessionError() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -413,14 +413,14 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerRetryIndexedDownloadUsesQueueIntent() async throws { + func testDownloadCoordinatorRetryIndexedDownloadUsesQueueIntent() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, queueStore: queueStore @@ -458,14 +458,14 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerRetryPagesIndexedDownloadUsesQueueIntent() async throws { + func testDownloadCoordinatorRetryPagesIndexedDownloadUsesQueueIntent() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, queueStore: queueStore @@ -514,14 +514,14 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerFailureSettlesQueueIntent() async throws { + func testDownloadCoordinatorFailureSettlesQueueIntent() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, queueStore: queueStore @@ -560,14 +560,14 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerCompletionSettlesQueueIntent() async throws { + func testDownloadCoordinatorCompletionSettlesQueueIntent() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, queueStore: queueStore @@ -613,14 +613,14 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerPauseAndResumeMutateQueueIntent() async throws { + func testDownloadCoordinatorPauseAndResumeMutateQueueIntent() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, queueStore: queueStore @@ -677,7 +677,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerSchedulesManifestQueueOrder() async throws { + func testDownloadCoordinatorSchedulesManifestQueueOrder() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } @@ -696,7 +696,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { return .skippedOperation } ) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, queueStore: queueStore, @@ -741,13 +741,13 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerFlushProgressUpdatesManifestPageHash() async throws { + func testDownloadCoordinatorFlushProgressUpdatesManifestPageHash() async throws { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -771,7 +771,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { ) await manager.reloadDownloadIndex() var pendingResolvedPages = [ - DownloadManager.PageResult( + DownloadCoordinator.PageResult( index: 1, relativePath: pageRelativePath, imageURL: nil @@ -796,14 +796,14 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerLoadInspectionUsesFinalFailedPagesSnapshot() async throws { + func testDownloadCoordinatorLoadInspectionUsesFinalFailedPagesSnapshot() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000)) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -849,14 +849,14 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerLoadLocalPageURLsPrefersCompletedFolderForCompletedDownload() async throws { + func testDownloadCoordinatorLoadLocalPageURLsPrefersCompletedFolderForCompletedDownload() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 11) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -894,14 +894,14 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } @Test - func testDownloadManagerLoadLocalPageURLsUsesReadableCompletedPages() async throws { + func testDownloadCoordinatorLoadLocalPageURLsUsesReadableCompletedPages() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 12) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) @@ -940,7 +940,7 @@ struct DownloadManagerStorageTests: DownloadFeatureTestCase { } -private extension DownloadManagerStorageTests { +private extension DownloadCoordinatorStorageTests { func writeIndexedManifest( storage: DownloadStore, relativePath: String, diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index 211f385b9..7ea61b441 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -16,7 +16,7 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, queueStore: queueStore @@ -85,7 +85,7 @@ struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { let storage = DownloadStore(rootURL: rootURL, fileManager: .default) let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, queueStore: queueStore diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index e3f9cfe42..8a0464482 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -331,14 +331,14 @@ struct StubRouteContext: Sendable { // MARK: - Stub Manager & Handler Helpers extension DownloadFeatureTestCase { - func makeStubbedDownloadManager( + func makeStubbedDownloadCoordinator( rootURL: URL, sessionID: String, downloadOptionsProvider: @escaping @Sendable () async -> DownloadRequestOptions = { DownloadRequestOptions() }, taskRunner: DownloadTaskRunner = .init() - ) -> (DownloadStore, DownloadManager) { + ) -> (DownloadStore, DownloadCoordinator) { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] configuration.httpAdditionalHeaders = [ @@ -347,7 +347,7 @@ extension DownloadFeatureTestCase { let storage = DownloadStore( rootURL: rootURL, fileManager: .default ) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: URLSession(configuration: configuration), downloadOptionsProvider: downloadOptionsProvider, diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift index 6ad25d6ed..32bff8191 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -27,7 +27,7 @@ protocol DownloadFeatureTestCase: TestHelper { func sampleGalleryState(gid: String) throws -> GalleryState func sampleVersionMetadata(gid: String, token: String) -> DownloadVersionMetadata - func makeTestingDownloadManager() -> DownloadManager + func makeTestingDownloadCoordinator() -> DownloadCoordinator func makeResponse( url: URL, statusCode: Int, @@ -148,16 +148,16 @@ extension DownloadFeatureTestCase { ) } - func makeTestingDownloadManager() -> DownloadManager { - makeTestingDownloadManager(storedCookiesProvider: { _ in [] }) + func makeTestingDownloadCoordinator() -> DownloadCoordinator { + makeTestingDownloadCoordinator(storedCookiesProvider: { _ in [] }) } - func makeTestingDownloadManager( + func makeTestingDownloadCoordinator( storedCookiesProvider: @escaping @Sendable (URL) -> [HTTPCookie] - ) -> DownloadManager { + ) -> DownloadCoordinator { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) - return DownloadManager( + return DownloadCoordinator( storage: DownloadStore(rootURL: rootURL, fileManager: .default), urlSession: .shared, storedCookiesProvider: storedCookiesProvider diff --git a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift index 6d435e634..fce3bb770 100644 --- a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift @@ -232,7 +232,7 @@ struct DownloadFolderOperationTests: DownloadFeatureTestCase { private struct DownloadFolderOperationTestEnvironment { let storage: DownloadStore - let manager: DownloadManager + let manager: DownloadCoordinator let rootURL: URL } @@ -241,7 +241,7 @@ private extension DownloadFolderOperationTests { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) return .init(storage: storage, manager: manager, rootURL: rootURL) } diff --git a/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift b/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift index 0c35dd6d1..03c81fa56 100644 --- a/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift @@ -22,7 +22,7 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { """.utf8) try invalidPageData.write(to: fileURL, options: .atomic) - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let galleryURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) let response = try makeResponse( url: galleryURL, @@ -49,7 +49,7 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { ) try keepTryingData.write(to: fileURL, options: .atomic) - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let pageURL = try #require(URL(string: "https://e-hentai.org/s/1/1-1")) let response = try makeResponse( url: pageURL, @@ -73,7 +73,7 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { try Data("Not here".utf8).write(to: fileURL, options: .atomic) - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let notFoundURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) let response = try makeResponse( url: notFoundURL, @@ -104,7 +104,7 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { """.utf8) try galleryNotAvailableData.write(to: fileURL, options: .atomic) - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let galleryURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) let response = try makeResponse( url: galleryURL, @@ -125,7 +125,7 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { let fileURL = try writeFixtureToTemporaryFile(filename: .ipBanned) defer { try? FileManager.default.removeItem(at: fileURL) } - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let bannedURL = try #require(URL(string: "https://example.com/banned")) let response = try makeResponse( url: bannedURL, @@ -146,7 +146,7 @@ struct DownloadImageErrorTests: DownloadFeatureTestCase { @Test func testTextHTMLJSONAPIResponseDoesNotMapToParseFailed() async throws { - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let apiURL = try #require(URL(string: "https://e-hentai.org/api.php")) let response = try makeResponse( url: apiURL, diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift index 1edb73fc9..64f76db65 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -14,7 +14,7 @@ import Testing struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { func testCachedKokomadePlaceholderStoredUnderNormalImageURLIsRejected() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 33) - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let normalImageURL = try #require( URL(string: "https://exhentai.org/fullimg.php?gid=\(gid)&page=1&key=normal-cache-key") ) @@ -39,7 +39,7 @@ struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { let fileURL = try writeFixtureToTemporaryFile(filename: .exLoginRequired) defer { try? FileManager.default.removeItem(at: fileURL) } - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let response = try makeResponse( url: Defaults.URL.exhentai, contentType: "text/html", @@ -74,7 +74,7 @@ struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { """.utf8) try authHTMLData.write(to: fileURL, options: .atomic) - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let response = try makeResponse( url: Defaults.URL.exhentai, contentType: "text/html" diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift index 101e5cb11..0ec1996f4 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift @@ -16,7 +16,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) defer { try? FileManager.default.removeItem(at: fileURL) } - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let quotaImageURL = try #require(URL(string: "https://ehgt.org/g/509.gif")) let response = try makeResponse( url: quotaImageURL, @@ -37,7 +37,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) defer { try? FileManager.default.removeItem(at: fileURL) } - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() var data = try Data(contentsOf: fileURL) data[0] = 0 try data.write(to: fileURL, options: .atomic) @@ -66,7 +66,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { let imageData = try #require(Data(base64Encoded: "R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")) try imageData.write(to: fileURL, options: .atomic) - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let kokomadeURL = try #require(URL(string: "https://exhentai.org/img/kokomade.jpg")) let response = try makeResponse( url: kokomadeURL, @@ -87,7 +87,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) defer { try? FileManager.default.removeItem(at: fileURL) } - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let normalImageURL = try #require(URL(string: "https://ehgt.org/h/normal-image-cache-key/1")) let response = try makeResponse( url: normalImageURL, @@ -108,7 +108,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { let fileURL = try writeFixtureToTemporaryFile(resource: "Kokomade", pathExtension: "jpg") defer { try? FileManager.default.removeItem(at: fileURL) } - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let normalImageURL = try #require( URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1&key=normal-cache-key") ) @@ -137,7 +137,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { """.utf8) try htmlData.write(to: fileURL, options: .atomic) - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let quotaURL = try #require(URL(string: "https://e-hentai.org/s/1/1-1")) let response = try makeResponse( url: quotaURL, @@ -165,7 +165,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { SharedSessionStubURLProtocol.headerKey: sessionID ] let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: URLSession(configuration: configuration) ) @@ -247,7 +247,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { @Test func testCachedQuotaPlaceholderStoredUnderNormalImageURLIsRejected() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 32) - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let normalImageURL = try #require( URL(string: "https://ehgt.org/h/quota-placeholder-cache-\(gid)/1") ) @@ -282,7 +282,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { SharedSessionStubURLProtocol.headerKey: sessionID ] let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: URLSession(configuration: configuration) ) @@ -318,7 +318,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { folderName: "Folder", mode: .initial ) - let source = DownloadManager.ResolvedSource.mpv("mpvkey", [1: "imgkey1"]) + let source = DownloadCoordinator.ResolvedSource.mpv("mpvkey", [1: "imgkey1"]) let first = try await manager.resolvedImageSource( index: 1, payload: payload, options: .init(), source: source, failover: nil diff --git a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift index cb219e9bf..ab6c16865 100644 --- a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -11,7 +11,7 @@ import Testing struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { @Test func testInterruptedSessionResolvesNonDestructiveResumeMode() async throws { - let manager = makeTestingDownloadManager() + let manager = makeTestingDownloadCoordinator() let queuedPartial = sampleDownload( gid: "913000001", title: "Interrupted", @@ -44,7 +44,7 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) let folderURL = try writeManifestFolder( storage: storage, @@ -83,7 +83,7 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) let folderURL = try writeManifestFolder( storage: storage, @@ -136,7 +136,7 @@ struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) let oldFolderURL = try writeManifestFolder( storage: storage, gid: gid, title: "Old Title", diff --git a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift index 2a08b06fc..027063700 100644 --- a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift +++ b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift @@ -15,7 +15,7 @@ struct DownloadIpBanTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [SharedSessionStubURLProtocol.self] configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: DownloadStore( rootURL: FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true), diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 3a0063c22..5707e3afd 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -62,7 +62,7 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { @MainActor @Test - func testDownloadManagerBatchesObserverUpdatesDuringProgressFlush() async throws { + func testDownloadCoordinatorBatchesObserverUpdatesDuringProgressFlush() async throws { let pageCount = 20 let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 104) let rootURL = FileManager.default.temporaryDirectory @@ -70,7 +70,7 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) // Warm the (empty) index before seeding so the gallery surfaces only // through flush updates, mirroring an active download whose folder is @@ -103,7 +103,7 @@ struct DownloadObserverBatchTests: DownloadFeatureTestCase { return emissionCount } - var pendingResolvedPages = [DownloadManager.PageResult]() + var pendingResolvedPages = [DownloadCoordinator.PageResult]() var lastFlushDate = Date.distantPast for index in 1...pageCount { let relativePath = storage.makePageRelativePath( diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index 7dd72d217..5cfeb7372 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -29,7 +29,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: URLSession(configuration: configuration) ) @@ -81,7 +81,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) try writeManifestFolder( storage: storage, @@ -125,7 +125,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: URLSession(configuration: configuration) ) @@ -190,7 +190,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: URLSession(configuration: configuration) ) @@ -223,7 +223,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: URLSession(configuration: configuration) ) @@ -260,7 +260,7 @@ struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FailFastURLProtocol.self] let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: URLSession(configuration: configuration) ) try writeManifestFolder( diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index ef6ad8f33..00aa27018 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -78,13 +78,13 @@ struct DownloadProcessCacheTests: DownloadFeatureTestCase { struct CacheTestManagerResult { let storage: DownloadStore - let manager: DownloadManager + let manager: DownloadCoordinator let metadataResponse: Data } private struct CacheTestDownloadSetup { let storage: DownloadStore - let manager: DownloadManager + let manager: DownloadCoordinator let gid: String let pageIndex: Int } @@ -103,7 +103,7 @@ private extension DownloadProcessCacheTests { configuration.protocolClasses = [SharedSessionStubURLProtocol.self] configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: URLSession(configuration: configuration), libraryClient: libraryClient @@ -202,7 +202,7 @@ private extension DownloadProcessCacheTests { @MainActor func prepareCacheTestAssets( - manager: DownloadManager, gid: String, + manager: DownloadCoordinator, gid: String, pageIndex: Int, cachedKeysBox: UncheckedBox> ) async throws -> Set { diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index ce849528b..f0e2e4adc 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -27,7 +27,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { await persistenceGate.waitAtGate() } ) - let (storage, manager) = makeStubbedDownloadManager( + let (storage, manager) = makeStubbedDownloadCoordinator( rootURL: rootURL, sessionID: sessionID, taskRunner: taskRunner @@ -80,7 +80,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let (storage, manager) = makeStubbedDownloadManager( + let (storage, manager) = makeStubbedDownloadCoordinator( rootURL: rootURL, sessionID: sessionID ) defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } @@ -126,7 +126,7 @@ struct DownloadProcessTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let (storage, manager) = makeStubbedDownloadManager( + let (storage, manager) = makeStubbedDownloadCoordinator( rootURL: rootURL, sessionID: sessionID, downloadOptionsProvider: { optionsBox.value } @@ -232,7 +232,7 @@ private extension DownloadProcessTests { } func fetchAndInstallStub( - manager: DownloadManager, sessionID: String, gid: String, + manager: DownloadCoordinator, sessionID: String, gid: String, pageIndex: Int ) async throws -> Int { let stubContent = StubHandlerContent( @@ -292,7 +292,7 @@ private extension DownloadProcessTests { } func verifyCompletedProcess( - manager: DownloadManager, + manager: DownloadCoordinator, storage: DownloadStore, context: ProcessVerificationContext ) async throws { diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 82e1bb699..c9a0d3200 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -18,7 +18,7 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let (storage, manager) = makeStubbedDownloadManager( + let (storage, manager) = makeStubbedDownloadCoordinator( rootURL: rootURL, sessionID: sessionID ) let setup = try await setupMinimalSourceTest( @@ -81,7 +81,7 @@ struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let (storage, manager) = makeStubbedDownloadManager( + let (storage, manager) = makeStubbedDownloadCoordinator( rootURL: rootURL, sessionID: sessionID ) let setup = try await setupMinimalSourceTest( @@ -135,7 +135,7 @@ private struct MinimalSourceTestResult { private struct MinimalSourceRetrySkipContext { let storage: DownloadStore - let manager: DownloadManager + let manager: DownloadCoordinator let gid: String let pageIndex: Int let setup: MinimalSourceTestResult @@ -207,7 +207,7 @@ private extension DownloadRetryMinimalSourceTests { } func setupMinimalSourceTest( - manager: DownloadManager, sessionID: String, gid: String, pageIndex: Int + manager: DownloadCoordinator, sessionID: String, gid: String, pageIndex: Int ) async throws -> MinimalSourceTestResult { let recorder = RequestRecorder() let stubContent = StubHandlerContent( diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index 4cc2fc61e..049635f6d 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -17,7 +17,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) try writeManifestFolder( storage: storage, gid: gid, @@ -61,7 +61,7 @@ struct DownloadRetryPagesTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 4fe860cce..2be2aaff0 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -18,7 +18,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let (storage, queueingManager) = makeStubbedDownloadManager( + let (storage, queueingManager) = makeStubbedDownloadCoordinator( rootURL: rootURL, sessionID: sessionID ) defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } @@ -65,7 +65,7 @@ struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } - let (storage, immediateManager) = makeStubbedDownloadManager( + let (storage, immediateManager) = makeStubbedDownloadCoordinator( rootURL: rootURL, sessionID: sessionID ) defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } @@ -119,7 +119,7 @@ private struct DownloadPageContext { private extension DownloadRetryUpdateFallbackTests { func fetchUpdateFallbackPayload( - manager: DownloadManager, sessionID: String, gid: String, + manager: DownloadCoordinator, sessionID: String, gid: String, pageIndex: Int ) async throws -> UpdateFallbackPayloadResult { let stubContent = StubHandlerContent( diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index cb097b038..d19e51c15 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -36,7 +36,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { return .skippedOperation } ) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, taskRunner: taskRunner @@ -110,7 +110,7 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { return .skippedOperation } ) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared, taskRunner: taskRunner @@ -169,7 +169,7 @@ private extension DownloadSchedulingTests { } func waitForActiveGalleryID( - _ manager: DownloadManager, + _ manager: DownloadCoordinator, toEqual expected: String?, timeout: Duration = .seconds(1) ) async throws { diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index fdf02bed3..0ec905a09 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -10,14 +10,14 @@ import Testing @Suite(.serialized) struct DownloadVersionSignatureTests: DownloadFeatureTestCase { @Test - func testDownloadManagerReconcilePreservesIndexedFinalFolder() async throws { + func testDownloadCoordinatorReconcilePreservesIndexedFinalFolder() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 31) let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager(storage: storage, urlSession: .shared) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) try storage.ensureRootDirectory() let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Indexed") try FileManager.default.createDirectory( @@ -59,7 +59,7 @@ struct DownloadVersionSignatureTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: rootURL) } let storage = DownloadStore(rootURL: rootURL, fileManager: .default) - let manager = DownloadManager( + let manager = DownloadCoordinator( storage: storage, urlSession: .shared ) From 78ad9ffcd10b63076cef23ad5f2b1160893c4db7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 16:32:37 +0800 Subject: [PATCH 252/614] Shed off-screen preview thumbnail decodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalPreviewImageView ran the thumbnail decode in a Task.detached spawned from .task(id:), so the decode never observed the cell scrolling off screen — fast scrolling piled up one un-sheddable decode per cell shown. Decode via a @concurrent function awaited directly from the .task instead, so SwiftUI cancellation propagates into the work, and check Task.isCancelled before each CGImageSource call so a torn-down cell sheds its in-flight decode. Matches the codebase's @concurrent idiom (LiveTextHandler). --- .../Support/Components/PreviewImageView.swift | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/EhPanda/View/Support/Components/PreviewImageView.swift b/EhPanda/View/Support/Components/PreviewImageView.swift index 7ba74a178..2866c2e17 100644 --- a/EhPanda/View/Support/Components/PreviewImageView.swift +++ b/EhPanda/View/Support/Components/PreviewImageView.swift @@ -100,16 +100,16 @@ private struct LocalPreviewImageView: View { return } - let fileURL = fileURL - let maxPixelSize = maxPixelSize - let generatedThumbnail = await Task.detached(priority: .utility) { - LocalPreviewThumbnailGenerator.make( - fileURL: fileURL, - maxPixelSize: maxPixelSize - ) - } - .value - + // Decode off the main actor via `@concurrent`; awaiting it directly (instead + // of a detached task) lets the SwiftUI `.task` cancellation propagate, so a + // cell that scrolls off screen sheds its in-flight decode rather than piling + // up un-sheddable work during fast scrolling. + let generatedThumbnail = await LocalPreviewThumbnailGenerator.make( + fileURL: fileURL, + maxPixelSize: maxPixelSize + ) + + guard !Task.isCancelled else { return } if let generatedThumbnail { LocalPreviewThumbnailCache.shared.store(generatedThumbnail, forKey: cacheKey) } @@ -119,8 +119,10 @@ private struct LocalPreviewImageView: View { } private enum LocalPreviewThumbnailGenerator { - static func make(fileURL: URL, maxPixelSize: CGFloat) -> UIImage? { - guard let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil) else { + @concurrent + static func make(fileURL: URL, maxPixelSize: CGFloat) async -> UIImage? { + guard !Task.isCancelled, + let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil) else { return nil } @@ -131,11 +133,12 @@ private enum LocalPreviewThumbnailGenerator { kCGImageSourceThumbnailMaxPixelSize: max(Int(maxPixelSize.rounded(.up)), 1) ] - guard let imageRef = CGImageSourceCreateThumbnailAtIndex( - imageSource, - .zero, - options as CFDictionary - ) else { + guard !Task.isCancelled, + let imageRef = CGImageSourceCreateThumbnailAtIndex( + imageSource, + .zero, + options as CFDictionary + ) else { return nil } From a8a25d375fecd1ab0d4ddec1b1767a0027dbdeb3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 16:34:15 +0800 Subject: [PATCH 253/614] Use non-optional Data(_:) in reader image test Clears a pre-existing non_optional_string_data_conversion SwiftLint warning surfaced while testing the OV2 changes in this file: build the HTML fixture with Data(_:.utf8) instead of the optional String.data(using:) + try #require. --- EhPandaTests/Tests/Download/ReaderImageDataTests.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift index d0b1a1e42..2766daeff 100644 --- a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift +++ b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift @@ -94,9 +94,7 @@ struct ReaderImageDataTests { let (cache, rootURL) = makeIsolatedDataCache() defer { try? FileManager.default.removeItem(at: rootURL) } let url = try #require(URL(string: "https://example.com/reader/notimage.png")) - let htmlData = try #require( - "Your IP has been temporarily banned".data(using: .utf8) - ) + let htmlData = Data("Your IP has been temporarily banned".utf8) let (session, sessionID) = makeStubbedSession() defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in From e4b2b832a9d942e5229326c287874ca0852f9ae9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 17:50:15 +0800 Subject: [PATCH 254/614] Self-heal DataCache reads instead of stranding the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataCache.data(forKey:) did `try Data(contentsOf:)`, so a file that exists but can't be read (corrupt, truncated, or purged between the existence check and the read) threw out through data(forKeys:) and readerImageData, skipping the network re-download — and the entry wasn't removed, so reload re-threw until the 7-day expiry. That reintroduced the BUG-20 anti-pattern (failed cache read with no fallback) in the owned cache. Treat a failed read as a miss: remove the bad entry and return nil so the caller re-downloads (mirrors the expired-entry handling). The read can no longer fail, so drop the now-vestigial `throws` from data(forKey:)/ data(forKeys:) and the `try`/`try?` at the call sites. Add a regression test (unreadable on-disk entry -> miss + removed). --- .../Tools/Clients/DownloadClient+Cache.swift | 2 +- EhPanda/App/Tools/Clients/ImageClient.swift | 2 +- EhPanda/App/Tools/Utilities/DataCache.swift | 15 ++++++-- EhPanda/View/Reading/ReadingView.swift | 2 +- .../Tests/Download/DataCacheTests.swift | 37 +++++++++++++++---- .../Tests/Download/ReaderImageDataTests.swift | 6 +-- 6 files changed, 46 insertions(+), 18 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 6d36a7074..30b970ddd 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -115,7 +115,7 @@ extension DownloadCoordinator { includeStableAlias: includeStableAlias ) } - return try? await DataCache.shared.data(forKeys: keys) + return await DataCache.shared.data(forKeys: keys) } func validatedCachedAssetData( diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index b6be66ab7..ada257e2f 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -112,7 +112,7 @@ extension ImageClient { return try Data(contentsOf: url) } let cacheKeys = url.imageCacheKeys(includeStableAlias: true) - if let data = try await dataCache.data(forKeys: cacheKeys) { + if let data = await dataCache.data(forKeys: cacheKeys) { return data } let data = try await downloadReaderData( diff --git a/EhPanda/App/Tools/Utilities/DataCache.swift b/EhPanda/App/Tools/Utilities/DataCache.swift index 5b3cd346d..fc27e5bc4 100644 --- a/EhPanda/App/Tools/Utilities/DataCache.swift +++ b/EhPanda/App/Tools/Utilities/DataCache.swift @@ -52,7 +52,7 @@ actor DataCache { } } - func data(forKey key: String) throws -> Data? { + func data(forKey key: String) -> Data? { let filename = Self.filename(forKey: key) if let data = memoryCache.object(forKey: filename as NSString) { return Data(referencing: data) @@ -65,16 +65,23 @@ actor DataCache { return nil } - let data = try Data(contentsOf: fileURL) + // A file that exists but can't be read — corrupt, truncated, or purged + // between the existence check and the read — is treated as a miss and + // removed, so the caller re-downloads instead of sticking on the broken + // entry until it expires. + guard let data = try? Data(contentsOf: fileURL) else { + try? fileManager.removeItem(at: fileURL) + return nil + } memoryCache.setObject(data as NSData, forKey: filename as NSString, cost: data.count) // A failed access-date bump must not fail an otherwise-successful read. try? touchAccessDate(for: fileURL) return data } - func data(forKeys keys: [String]) throws -> Data? { + func data(forKeys keys: [String]) -> Data? { for key in Self.uniqued(keys) { - if let data = try data(forKey: key) { + if let data = data(forKey: key) { return data } } diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 0eec2e5c4..e1e865fc0 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -326,7 +326,7 @@ extension ReadingView { } private func analyzeCachedImageData(cacheKeys: [String], index: Int) async { - guard let data = try? await DataCache.shared.data(forKeys: cacheKeys), + guard let data = await DataCache.shared.data(forKeys: cacheKeys), !data.isAnimatedImageData, let image = data.decodedImage, let cgImage = image.cgImage diff --git a/EhPandaTests/Tests/Download/DataCacheTests.swift b/EhPandaTests/Tests/Download/DataCacheTests.swift index b45dcfda1..adaa58bde 100644 --- a/EhPandaTests/Tests/Download/DataCacheTests.swift +++ b/EhPandaTests/Tests/Download/DataCacheTests.swift @@ -22,7 +22,7 @@ struct DataCacheTests { try await cache.store(data, forKey: key) await cache.removeAllMemory() - #expect(try await cache.data(forKey: key) == data) + #expect(await cache.data(forKey: key) == data) let files = try FileManager.default.contentsOfDirectory(atPath: rootURL.path) #expect(files.count == 1) #expect(files.first != key) @@ -39,13 +39,13 @@ struct DataCacheTests { try await cache.store(data, forKeys: ["stable", "absolute", "stable"]) - #expect(try await cache.data(forKeys: ["missing", "absolute"]) == data) + #expect(await cache.data(forKeys: ["missing", "absolute"]) == data) let files = try FileManager.default.contentsOfDirectory(atPath: rootURL.path) #expect(files.count == 2) try await cache.removeData(forKeys: ["stable", "absolute", "stable"]) - #expect(try await cache.data(forKeys: ["stable", "absolute"]) == nil) + #expect(await cache.data(forKeys: ["stable", "absolute"]) == nil) #expect(try await cache.totalSize() == 0) } @@ -61,7 +61,7 @@ struct DataCacheTests { try await Task.sleep(for: .milliseconds(20)) await cache.removeAllMemory() - #expect(try await cache.data(forKey: "expired") == nil) + #expect(await cache.data(forKey: "expired") == nil) #expect(try await cache.totalSize() == 0) } @@ -83,9 +83,9 @@ struct DataCacheTests { try await Task.sleep(for: .milliseconds(10)) try await cache.store(Data(repeating: 0x03, count: 4), forKey: "new") - #expect(try await cache.data(forKey: "old") == nil) - #expect(try await cache.data(forKey: "middle") == nil) - #expect(try await cache.data(forKey: "new") == Data(repeating: 0x03, count: 4)) + #expect(await cache.data(forKey: "old") == nil) + #expect(await cache.data(forKey: "middle") == nil) + #expect(await cache.data(forKey: "new") == Data(repeating: 0x03, count: 4)) #expect(try await cache.totalSize() <= 5) } @@ -100,7 +100,28 @@ struct DataCacheTests { try await cache.store(Data([0x01]), forKey: "page") try await cache.removeAll() - #expect(try await cache.data(forKey: "page") == nil) + #expect(await cache.data(forKey: "page") == nil) + #expect(try await cache.totalSize() == 0) + } + + @Test + func testUnreadableDiskEntryIsTreatedAsMissAndRemoved() async throws { + let rootURL = makeRootURL() + defer { try? FileManager.default.removeItem(at: rootURL) } + let cache = DataCache(configuration: .init(rootURL: rootURL)) + + try await cache.store(Data([0x01, 0x02]), forKey: "page") + await cache.removeAllMemory() + + // Make the on-disk entry unreadable by replacing the cached file with a + // directory, which `Data(contentsOf:)` cannot read — the same failure mode + // as a corrupt or mid-read-purged file. + let files = try FileManager.default.contentsOfDirectory(atPath: rootURL.path) + let entryURL = rootURL.appendingPathComponent(try #require(files.first)) + try FileManager.default.removeItem(at: entryURL) + try FileManager.default.createDirectory(at: entryURL, withIntermediateDirectories: false) + + #expect(await cache.data(forKey: "page") == nil) #expect(try await cache.totalSize() == 0) } diff --git a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift index 2766daeff..c6cba8b69 100644 --- a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift +++ b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift @@ -30,7 +30,7 @@ struct ReaderImageDataTests { #expect(data == imageData) #expect(requestCount.value == 1) - let cached = try await cache.data( + let cached = await cache.data( forKeys: url.imageCacheKeys(includeStableAlias: true) ) #expect(cached == imageData) @@ -83,7 +83,7 @@ struct ReaderImageDataTests { } catch { Issue.record("Unexpected error: \(error)") } - let cached = try await cache.data( + let cached = await cache.data( forKeys: url.imageCacheKeys(includeStableAlias: true) ) #expect(cached == nil) @@ -111,7 +111,7 @@ struct ReaderImageDataTests { } catch { Issue.record("Unexpected error: \(error)") } - let cached = try await cache.data( + let cached = await cache.data( forKeys: url.imageCacheKeys(includeStableAlias: true) ) #expect(cached == nil) From 8ebf7f1204950ca7d721ff146d2d29694034e4a3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 17:52:22 +0800 Subject: [PATCH 255/614] Remove the dead saveImageToPhotoLibrary endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UIImage-based saveImageToPhotoLibrary lost its last caller when the reader save path moved to saveImageDataToPhotoLibrary(asset.data) (DES-1 byte exports) — it was already dead at the OV review base. Delete the field and its live/noop/unimplemented impls, per the remove-emptied-endpoints rule (same cleanup DES-4 applied to resumeQueue/badges). Drop the now-unused import Kingfisher (its only use was image.kf inside that closure). --- EhPanda/App/Tools/Clients/ImageClient.swift | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index ada257e2f..4d9484c44 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -6,7 +6,6 @@ import Photos import SwiftUI import Combine -import Kingfisher import ComposableArchitecture struct ImageClient: Sendable { @@ -20,7 +19,6 @@ struct ImageClient: Sendable { } let prefetchImages: @Sendable ([URL]) -> Void - let saveImageToPhotoLibrary: @Sendable (UIImage, Bool) async -> Bool let saveImageDataToPhotoLibrary: @Sendable (Data) async -> Bool var dataCache: DataCache = .shared var urlSession: URLSession = .shared @@ -41,23 +39,6 @@ extension ImageClient { } } }, - saveImageToPhotoLibrary: { (image, isAnimated) in - await withCheckedContinuation { continuation in - let data = isAnimated - ? image.animatedSourceData - : image.kf.data(format: .unknown) - if let data { - PHPhotoLibrary.shared().performChanges { - let request = PHAssetCreationRequest.forAsset() - request.addResource(with: .photo, data: data, options: nil) - } completionHandler: { (isSuccess, _) in - continuation.resume(returning: isSuccess) - } - } else { - continuation.resume(returning: false) - } - } - }, saveImageDataToPhotoLibrary: { data in await Self.saveImageDataToPhotoLibrary(data) } @@ -186,7 +167,6 @@ extension DependencyValues { extension ImageClient { static let noop: Self = .init( prefetchImages: { _ in }, - saveImageToPhotoLibrary: { _, _ in false }, saveImageDataToPhotoLibrary: { _ in false } ) @@ -194,7 +174,6 @@ extension ImageClient { static let unimplemented: Self = .init( prefetchImages: IssueReporting.unimplemented(placeholder: placeholder()), - saveImageToPhotoLibrary: IssueReporting.unimplemented(placeholder: placeholder()), saveImageDataToPhotoLibrary: IssueReporting.unimplemented(placeholder: placeholder()) ) } From 1191377c517033240dcc0cb238047a7327434a09 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 17:54:01 +0800 Subject: [PATCH 256/614] Drop the last pure-rename test wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testingSanitizeLocalFilesIfNeeded was a pure forwarder to the internal sanitizeLocalFilesIfNeeded, reachable via @testable — the same category CLN-6 removed six of; this seventh was missed. Delete it and call the internal method directly from its sole caller. --- EhPanda/App/Tools/Clients/DownloadClient+Testing.swift | 10 ---------- .../Download/DownloadCoordinatorStorageTests.swift | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index b151d6f8c..fea81ef5a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -43,16 +43,6 @@ extension DownloadCoordinator { ) } - func testingSanitizeLocalFilesIfNeeded( - gid: String, - clearingLastError: Bool = false - ) async -> DownloadedGallery? { - await sanitizeLocalFilesIfNeeded( - gid: gid, - clearingLastError: clearingLastError - ) - } - func testingSetUpdatedGalleryIDs(_ gids: Set) { updatedGalleryIDs = gids } diff --git a/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift b/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift index e537ebcce..d1280a8af 100644 --- a/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift @@ -369,7 +369,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { await manager.reloadDownloadIndex() await manager.testingSetDownloadError(failure, gid: "430") - let sanitizedDownload = await manager.testingSanitizeLocalFilesIfNeeded( + let sanitizedDownload = await manager.sanitizeLocalFilesIfNeeded( gid: "430", clearingLastError: true ) From 46d4cfd959fc6c5c9253e4e19fba8c57ae6a441b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 17:56:55 +0800 Subject: [PATCH 257/614] Enable three regression tests that never ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three test functions were missing @Test, so Swift Testing silently skipped them since they were added — a copy-paste oversight (their siblings carry @Test). They are not helpers (zero call sites, no parameters) and pass when enabled: positive 509 -> .quotaExceeded (the BUG-6 coverage Phase 1's DoD required; only the negative case ran), kokomade cache-poison rejection, and no-re-capture of an already-local page. Suite 258 -> 261. --- EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift | 1 + EhPandaTests/Tests/Download/DownloadImageParsingTests.swift | 1 + EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift | 1 + 3 files changed, 3 insertions(+) diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift index 64f76db65..046ce5693 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -12,6 +12,7 @@ import Testing @Suite(.serialized) struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { + @Test func testCachedKokomadePlaceholderStoredUnderNormalImageURLIsRejected() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 33) let manager = makeTestingDownloadCoordinator() diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift index 0ec1996f4..ed7a091a1 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift @@ -12,6 +12,7 @@ import Testing @Suite(.serialized) struct DownloadImageParsingTests: DownloadFeatureTestCase { + @Test func testFileBasedQuotaImageMapsToQuotaExceeded() async throws { let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) defer { try? FileManager.default.removeItem(at: fileURL) } diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift index 23c361da6..9b27b3e33 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -27,6 +27,7 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { } @MainActor + @Test func testReadingReducerOnWebImageSucceededDoesNotCaptureAlreadyLocalPage() async { let capturedCalls = UncheckedBox([(String, Int, URL?)]()) let gallery = sampleGallery() From 2102a39ea032b04dd63005a6fd6efbe9f3c4092d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 17:59:57 +0800 Subject: [PATCH 258/614] Cover owned reader-fetch cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OVR-B2's predecessor (OV2) removed the legacy KF/SD download cluster and its two cancel tests; the new dismiss-mid-fetch cancellation runs through the owned URLSession fetch and had no coverage. Add a unit test that a cancelled Task running readerImageData against a hanging session stops (throws) rather than completing — BUG-24 at the new layer. Suite 261 -> 262. --- .../Tests/Download/ReaderImageDataTests.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift index c6cba8b69..f1b821fe7 100644 --- a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift +++ b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift @@ -144,6 +144,32 @@ struct ReaderImageDataTests { #expect(requestCount.value == 0) } + @Test + func testCancellationStopsTheOwnedFetch() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/hang.png")) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [HangingURLProtocol.self] + let session = URLSession(configuration: configuration) + + // Dismissing the reader mid-fetch cancels the Task; the owned URLSession + // fetch must honor that and stop instead of completing (BUG-24). + let task = Task { + try await ImageClient.readerImageData(url: url, dataCache: cache, urlSession: session) + } + task.cancel() + + do { + _ = try await task.value + Issue.record("Expected the cancelled owned fetch to throw") + } catch is CancellationError { + } catch let error as URLError where error.code == .cancelled { + } catch { + Issue.record("Unexpected error: \(error)") + } + } + private func makeIsolatedDataCache() -> (cache: DataCache, rootURL: URL) { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) From 22407399ba243777c0f88f6373a906b10877ba39 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 19:39:39 +0800 Subject: [PATCH 259/614] Don't treat reader image cancellation as a page failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ByteRoutedReaderImage.load() called onFailed() whenever fetchReaderImageAsset returned nil, with no Task.isCancelled check — and fetchReaderImageAsset erases CancellationError via try?. So a normal .task(id:) cancellation (scrolling away, URL change) sent .onWebImageFailed, marking the page .failed(.webImageFailed) and triggering reduceLocalPageMiss (a rescan). Guard `!Task.isCancelled` after the fetch so a cancelled load reports neither success nor failure. Add a test that a cancelled fetchReaderImageAsset returns nil (the precondition the guard relies on). Suite 262 -> 263. --- .../View/Reading/ReadingViewComponents.swift | 3 +++ .../Tests/Download/ReaderImageDataTests.swift | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/EhPanda/View/Reading/ReadingViewComponents.swift b/EhPanda/View/Reading/ReadingViewComponents.swift index d4ee3871d..0ceb6cf37 100644 --- a/EhPanda/View/Reading/ReadingViewComponents.swift +++ b/EhPanda/View/Reading/ReadingViewComponents.swift @@ -333,6 +333,9 @@ private struct ByteRoutedReaderImage: View { downloadProgress.completedUnitCount = Int64(fraction * Double(Self.progressUnitCount)) } progress = nil + // A cancelled `.task(id:)` (scrolled off screen, URL changed) surfaces as a + // nil asset; it is not a load failure, so report neither success nor failure. + guard !Task.isCancelled else { return } guard let asset else { onFailed() return diff --git a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift index f1b821fe7..b845d95ce 100644 --- a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift +++ b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift @@ -170,6 +170,26 @@ struct ReaderImageDataTests { } } + @Test + func testCancelledReaderImageAssetFetchReturnsNil() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/hang-asset.png")) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [HangingURLProtocol.self] + var client = ImageClient.noop + client.dataCache = cache + client.urlSession = URLSession(configuration: configuration) + + // A cancelled fetch must surface as nil (not a thrown failure), so the reader + // can distinguish "scrolled away" from a real load failure via Task.isCancelled. + let task = Task { await client.fetchReaderImageAsset(url: url) } + task.cancel() + let asset = await task.value + + #expect(asset == nil) + } + private func makeIsolatedDataCache() -> (cache: DataCache, rootURL: URL) { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) From 169d38e16998a94bb24fd394c61033450929be3d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 19:42:57 +0800 Subject: [PATCH 260/614] Reclaim orphaned background-transfer staging files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two paths stranded files in the hidden .background-downloads holding dir: (a) a process that dies between staging in didFinishDownloadingTo and the async consume — the system won't re-deliver the completion, the launch scan uses .skipsHiddenFiles so it can't see the dir, and nothing purged it, so the files accumulated forever; (b) the moveDownloadedFile failure catch in the page-download helper removed the task record but not transfer.fileURL, unlike its three sibling failure paths. (a) Purge the holding dir in DownloadClient.live before the background session is recreated — at that instant the session doesn't exist, so anything there is definitionally an orphan from a dead prior process (race-free). (b) Remove the staged file in the move-failure catch. Regression test for the purge. --- .../DownloadClient+BackgroundDownloads.swift | 2 +- .../DownloadClient+PageDownloadHelpers.swift | 3 +++ .../App/Tools/Clients/DownloadClient.swift | 4 ++++ .../App/Tools/Utilities/DownloadStore.swift | 15 +++++++++++++++ .../Tests/Download/DownloadStoreTests.swift | 19 +++++++++++++++++++ 5 files changed, 42 insertions(+), 1 deletion(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift index e8d42e30f..80623b42c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift @@ -175,7 +175,7 @@ extension DownloadCoordinator { ) } - private func removeStagedBackgroundFile(_ fileURL: URL) { + func removeStagedBackgroundFile(_ fileURL: URL) { try? fileManager.operate { guard $0.fileExists(atPath: fileURL.path) else { return } try $0.removeItem(at: fileURL) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift index fab550175..8c0b4cb21 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -136,6 +136,9 @@ extension DownloadCoordinator { } catch { if let taskIdentifier = transfer.taskIdentifier { await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) + // The move never consumed the staged file; drop it so it doesn't + // strand in the holding dir, matching the sibling failure paths. + removeStagedBackgroundFile(transfer.fileURL) } throw error } diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 989028140..5ac48b1d1 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -40,6 +40,10 @@ extension DownloadClient { fileManager: sending FileManager = FileManager() ) -> Self { let storage = DownloadStore(rootURL: rootURL, fileManager: fileManager) + // Reclaim any background-transfer files stranded by a prior process that died + // between staging and consuming them. Safe here because the background session + // does not exist yet, so the holding dir can only hold orphans. + storage.purgeBackgroundTransferHoldingDirectory() let backgroundTaskStore = DownloadBackgroundTaskStore( fileURL: storage.backgroundTaskRegistryURL() ) diff --git a/EhPanda/App/Tools/Utilities/DownloadStore.swift b/EhPanda/App/Tools/Utilities/DownloadStore.swift index 8a15f1914..b0df873a4 100644 --- a/EhPanda/App/Tools/Utilities/DownloadStore.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore.swift @@ -101,6 +101,21 @@ struct DownloadStore: Sendable { rootURL.appendingPathComponent(".background-downloads", isDirectory: true) } + /// Removes the background-transfer holding directory and everything in it. + /// + /// The holding dir is hidden, so the launch scan (`.skipsHiddenFiles`) can't see + /// it, and a process that dies between staging and consuming a file leaves it + /// stranded. Call this only while no background session exists (e.g. at `.live` + /// construction) so anything present is definitionally an orphan; the downloader + /// recreates the directory on the next stage. + func purgeBackgroundTransferHoldingDirectory() { + let holdingDirectory = backgroundTransferHoldingDirectoryURL() + try? fileManager.operate { + guard $0.fileExists(atPath: holdingDirectory.path) else { return } + try $0.removeItem(at: holdingDirectory) + } + } + func existingPageRelativePaths(folderURL: URL, manifest: DownloadManifest) -> [Int: String] { let pageIndices = Set(manifest.pages.keys) guard !pageIndices.isEmpty else { return [:] } diff --git a/EhPandaTests/Tests/Download/DownloadStoreTests.swift b/EhPandaTests/Tests/Download/DownloadStoreTests.swift index b9b0f8f0b..d65eaecc1 100644 --- a/EhPandaTests/Tests/Download/DownloadStoreTests.swift +++ b/EhPandaTests/Tests/Download/DownloadStoreTests.swift @@ -41,6 +41,25 @@ struct DownloadStoreTests { #expect(storage.validate(download: download, verifiesContentHashes: true) == .valid) } + @Test + func testPurgeBackgroundTransferHoldingDirectoryRemovesOrphans() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + let holdingDirectory = storage.backgroundTransferHoldingDirectoryURL() + try FileManager.default.createDirectory( + at: holdingDirectory, withIntermediateDirectories: true + ) + let orphan = holdingDirectory.appendingPathComponent("orphan.tmp") + try Data([0x01]).write(to: orphan, options: .atomic) + #expect(FileManager.default.fileExists(atPath: orphan.path)) + + storage.purgeBackgroundTransferHoldingDirectory() + + #expect(FileManager.default.fileExists(atPath: orphan.path) == false) + #expect(FileManager.default.fileExists(atPath: holdingDirectory.path) == false) + } + @Test func testReadManifestRejectsEmptyPages() throws { let (storage, rootURL) = makeStorage() From cbd4e646ce42c6f70e786d9bada563bef5c12b89 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 19:48:18 +0800 Subject: [PATCH 261/614] Settle the download on a fatal orphaned background failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foreground path settles a fatal account error (quota/auth/ban) via persistFailure — surfacing downloadErrors and clearing the queue intent so the download can't auto-resume against the ban (BUG-6 / no-auto-retry). The background/orphan completion handlers only recorded a per-page failure, then called scheduleNextIfNeeded — leaving a persisted queue item that could auto-resume. Extract the settle into settleDownloadFailure(gid:error:) (shared by persistFailure), make isFatalAccountAppError internal, and call the settle from both background handlers when the error is fatal. Transient errors stay a retryable page failure as before. Regression test: a fatal orphaned failure clears the queue and surfaces .error. --- .../DownloadClient+BackgroundDownloads.swift | 9 +++++ .../Clients/DownloadClient+PageDownload.swift | 2 +- .../Clients/DownloadClient+Persistence.swift | 13 +++++-- .../DownloadBackgroundCompletionTests.swift | 36 +++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift index 80623b42c..48849adaf 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift @@ -57,6 +57,12 @@ extension DownloadCoordinator { } catch { Logger.error(error) removeStagedBackgroundFile(fileURL) + // A fatal account error (quota/auth/ban) detected on an orphaned page must + // settle the whole download like the foreground does, so scheduleNextIfNeeded + // below can't auto-resume it against the ban (BUG-6 / no-auto-retry). + if let appError = error as? AppError, isFatalAccountAppError(appError) { + await settleDownloadFailure(gid: record.gid, error: appError) + } } await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) @@ -88,6 +94,9 @@ extension DownloadCoordinator { error: error ) } + if isFatalAccountAppError(error) { + await settleDownloadFailure(gid: record.gid, error: error) + } } await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index fca50dd20..db87de724 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -313,7 +313,7 @@ extension DownloadCoordinator { /// Gallery-level errors (`.expunged`, `.copyrightClaim`) are deliberately *not* fatal here — they /// mean the gallery is gone, but they surface before per-page download and are handled upstream, /// so a per-page occurrence is treated like any other page failure rather than aborting the batch. - private func isFatalAccountAppError(_ error: AppError) -> Bool { + func isFatalAccountAppError(_ error: AppError) -> Bool { switch error { case .quotaExceeded, .authenticationRequired, .ipBanned: return true diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index fee0d4c00..ca876ec88 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -168,9 +168,16 @@ extension DownloadCoordinator { context: FailureContext ) async { await taskRunner.beforeFailurePersistence() - downloadErrors[context.gid] = DownloadFailure(error: error) - clearDownloadQueueIntent(gid: context.gid) - await queueStore.remove(context.gid) + await settleDownloadFailure(gid: context.gid, error: error) + } + + /// Surfaces a download-level failure and clears its queue intent so it does not + /// auto-resume. Shared by the foreground `persistFailure` and the background/orphan + /// fatal-error paths so a fatal 509/auth/ban settles identically either way. + func settleDownloadFailure(gid: String, error: AppError) async { + downloadErrors[gid] = DownloadFailure(error: error) + clearDownloadQueueIntent(gid: gid) + await queueStore.remove(gid) } func flushDownloadProgress( diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift index 41ee24c10..7e6cd4425 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift @@ -100,6 +100,42 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { #expect(inspection.failedPageIndices.contains(1)) } + @Test + func testOrphanedBackgroundFatalFailureSettlesQueueAndSurfacesError() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 907) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore, + queueStore: queueStore + ) + _ = try writeDownloadFolder(storage: storage, gid: gid) + await manager.reloadDownloadIndex() + await queueStore.enqueue(gid) + + let taskIdentifier = 92 + await taskStore.record(taskIdentifier: taskIdentifier, gid: gid, pageIndex: 1) + + // A fatal account error on an orphaned background page must settle the whole + // download like the foreground path, not just record a page failure. + await manager.handleBackgroundPageDownloadFailed( + taskIdentifier: taskIdentifier, + error: .authenticationRequired + ) + + let failed = try #require(await manager.fetchDownload(gid: gid)) + #expect(queueStore.gids == []) + #expect(failed.displayStatus == .error) + #expect(failed.lastError?.code == .authenticationRequired) + } + @Test func testOrphanedBackgroundCancellationClearsTaskRecordWithoutPageFailure() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 905) From e30f69b4016cdc06346b23e5443fc6220f2c3e34 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 19:50:05 +0800 Subject: [PATCH 262/614] Normalize ko.lproj to LF line endings The Korean Localizable.strings was the only one of the 8 languages encoded with CRLF, so every line the feature added to it tripped git diff --check ("trailing whitespace" on the \r). Convert the whole file to LF, matching the other 7 languages and clearing the check. Content is byte-identical under git diff -w (line endings only). --- EhPanda/App/ko.lproj/Localizable.strings | 2062 +++++++++++----------- 1 file changed, 1031 insertions(+), 1031 deletions(-) diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 576ad5eaf..73c127e07 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -1,1031 +1,1031 @@ -/* - Localizable.strings - EhPanda -*/ - -// MARK: BanInterval -"enum.ban_interval.description.and" = "and"; - -// MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "어제"; -"enum.toplists_type.value.past_month" = "지난 달"; -"enum.toplists_type.value.past_year" = "지난 해"; -"enum.toplists_type.value.all_time" = "전체"; - -// MARK: Response -"website.response.hath_client_not_found" = "H@H 클라이언트를 아이디에 연동시킨 후 사용해주세요."; -"website.response.hath_client_not_online" = "H@H 클라이언트가 오프라인인 것 같네요. 클라이언트를 켜고 다시 시도해주세요."; -"website.response.invalid_resolution" = "이 콘텐츠는 선택한 해상도로 다운로드할 수 없어요."; - -// MARK: HUD -"hud.title.error" = "실패"; -"hud.title.success" = "성공"; -"hud.title.loading" = "로딩 중..."; -"hud.title.communicating" = "접속 중..."; -"hud.caption.copied_to_clipboard" = "클립보드에 복사되었어요"; -"hud.caption.saved_to_photo_library" = "이미지 저장"; - -// MARK: AutoLock -"local_authorization.reason" = "자동 잠금으로 앱이 잠겼어요."; - -// MARK: Common value -"common.value.stars" = "%@별"; -"common.value.pages" = "%@페이지"; -"common.value.times" = "%@번"; -"common.value.day" = "%@ day"; -"common.value.days" = "%@ days"; -"common.value.hour" = "%@ hour"; -"common.value.hours" = "%@ hours"; -"common.value.minute" = "%@ 분"; -"common.value.minutes" = "%@ 분"; -"common.value.second" = "%@ 초"; -"common.value.seconds" = "%@ 초"; -"common.value.records" = "%@ 기록수"; - -// MARK: TabItem -"tab_item.title.home" = "Home"; -"tab_item.title.favorites" = "즐겨찾기"; -"tab_item.title.search" = "검색"; -"tab_item.title.setting" = "설정"; - -// MARK: ToolbarItem -"toolbar_item.button.filters" = "필터"; -"toolbar_item.button.jump_page" = "페이지 이동"; -"toolbar_item.button.quick_search" = "빠른 검색"; - -// MARK: JumpPage -"jump_page_view.title.jump_page" = "페이지 이동"; -"jump_page_view.button.confirm" = "확인"; - -// MARK: AlertView -"loading_view.title.loading" = "로딩 중..."; -"loading_view.title.preparing_database" = "Preparing the database..."; -"not_login_view.title.need_login" = "You need to login to access this feature."; -"not_login_view.button.login" = "Login"; -"error_view.button.retry" = "재시도"; -"error_view.button.drop_database" = "Drop the database"; -"error_view.title.try_later" = "잠시 후 다시 시도해 주세요."; -"error_view.title.network" = "인터넷 접속 오류가 발생했어요."; -"error_view.title.parsing" = "구분 분석 오류가 발생했어요."; -"error_view.title.unknown" = "알 수 없는 오류가 발생했어요."; -"error_view.title.not_found" = "여기가 아무도 없는 것 같습니다."; -"error_view.title.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; -"error_view.title.ip_banned" = "자동화된 미러링/수집 소프트웨어를 사용 중임을 나타내는 과도한 페이지 로드로 인해 IP 주소가 일시적으로 금지되었습니다. 금지효과는 %@ 에서 만료되었습니다."; -"error_view.title.copyright_claim" = "%@의 저작권 요청으로 인하여 이 갤러리를 사용할 수 없어요."; -"error_view.title.gallery_unavailable" = "이 갤러리는 제거되었거나 사용할 수 없어요."; - -// MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "You will lose all your data in this app.\nAre you sure to drop the database?"; -"confirmation_dialog.title.remove_custom_translations" = "Are you sure to remove your custom translations?"; -"confirmation_dialog.title.logout" = "로그아웃 하시겠어요?"; -"confirmation_dialog.title.delete" = "Are you sure to delete this item?"; -"confirmation_dialog.title.clear" = "삭제하시겠어요?"; -"confirmation_dialog.title.reset" = "초기화하시겠어요?"; -"confirmation_dialog.button.drop_database" = "Drop the database"; -"confirmation_dialog.button.remove" = "Remove"; -"confirmation_dialog.button.logout" = "로그아웃"; -"confirmation_dialog.button.delete" = "삭제"; -"confirmation_dialog.button.clear" = "삭제"; -"confirmation_dialog.button.reset" = "초기화"; - -// MARK: SubSection -"sub_section.button.show_all" = "모두 보기"; - -// MARK: NewDawnView -"new_dawn_view.title.first" = "새로운 하루가 시작되었어요!"; -"new_dawn_view.title.second" = "지금까지의 여정을 돌이켜보면, 당신은 조금 더 현명해진 것 같죠?"; -// Greeting -"struct.greeting.mark.start" = ""; -"struct.greeting.mark.separator" = ", "; -"struct.greeting.mark.and" = " 과 "; -"struct.greeting.mark.end" = "획득했어요!"; - -// MARK: HomeView -"home_view.title.home" = "홈"; -"home_view.section.title.frontpage" = "프론트 페이지"; -"home_view.section.title.toplists" = "상위 목록"; -"home_view.section.title.other" = "Other"; -// HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "인기 작품"; -"enum.home_misc_grid_type.title.watched" = "주시 태그"; -"enum.home_misc_grid_type.title.history" = "읽은 목록"; - -// MARK: FrontpageView -"frontpage_view.title.frontpage" = "프론트 페이지"; - -// MARK: ToplistsView -"toplists_view.title.toplists" = "상위 목록"; - -// MARK: PopularView -"popular_view.title.popular" = "인기 작품"; - -// MARK: WatchedView -"watched_view.title.watched" = "주시 태그"; - -// MARK: HistoryView -"history_view.title.history" = "읽은 목록"; - -// MARK: FavoritesView -"favorites_view.title.favorites" = "즐겨찾기"; -// FavoriteCategory -"struct.user.favorite_category.default" = "즐겨찾기 %@"; -"struct.user.favorite_category.all" = "모두"; - -// MARK: SearchView -"search_view.title.search" = "검색"; -"search_view.section.title.recently_searched" = "Recently searched"; -"search_view.section.title.recently_seen" = "Recently seen"; -"search_view.section.title.quick_search" = "빠른 검색"; -// Searchable -"searchable.prompt.filter" = "Filter"; -"searchable.title.matches_count" = "Found %d matches."; - -// MARK: QuickSearchView -"quick_search_view.title.quick_search" = "빠른 검색"; -"quick_search_view.title.edit_word" = "Edit word"; -"quick_search_view.title.new_word" = "New word"; -"quick_search_view.title.content" = "Content"; -"quick_search_view.title.name" = "Name"; -"quick_search_view.placeholder.optional" = "Optional"; - -// MARK: SettingView -"setting_view.title.setting" = "설정"; -// SettingStateRoute -"enum.setting_state_route.value.account" = "계정"; -"enum.setting_state_route.value.general" = "일반"; -"enum.setting_state_route.value.appearance" = "외관"; -"enum.setting_state_route.value.reading" = "읽기"; -"enum.setting_state_route.value.download" = "다운로드"; -"enum.setting_state_route.value.laboratory" = "실험실"; -"enum.setting_state_route.value.about" = "About"; - -// MARK: AccountSettingView -"account_setting_view.title.account" = "계정"; -"account_setting_view.title.shows_new_dawn_greeting" = "새벽 인사 구독하기"; -"account_setting_view.button.login" = "로그인"; -"account_setting_view.button.logout" = "로그아웃"; -"account_setting_view.button.account_configuration" = "계정 설정"; -"account_setting_view.button.tags_management" = "태그 구독 관리"; -"account_setting_view.button.copy_cookies" = "쿠키 복사하기"; -// CookieValue -"struct.cookie_value.localized_string.expired" = "만료됨"; -"struct.cookie_value.localized_string.mystery" = "거절됨"; -"struct.cookie_value.localized_string.none" = "None"; - -// MARK: LoginView -"login_view.title.login" = "로그인"; -"login_view.title.username" = "이름"; -"login_view.title.password" = "비밀번호"; - -// MARK: GeneralSettingView -"general_setting_view.title.general" = "일반"; -"general_setting_view.title.language" = "언어"; -"general_setting_view.title.auto_lock" = "앱 자동 잠금"; -"general_setting_view.title.enables_tags_extension" = "Enables tags extension"; -"general_setting_view.title.translates_tags" = "태그 번역하기"; -"general_setting_view.title.shows_tags_search_suggestion" = "Shows tags search suggestion"; -"general_setting_view.title.shows_images_in_tags" = "Shows images in tags"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "선택한 서버로 이동하기"; -"general_setting_view.title.detects_links_from_clipboard" = "클립보드의 링크 인식하기"; -"general_setting_view.title.background_blur_radius" = "Background blur radius"; -"general_setting_view.button.logs" = "로그"; -"general_setting_view.button.import_custom_translations" = "Import custom translations"; -"general_setting_view.button.remove_custom_translations" = "Remove custom translations"; -"general_setting_view.button.clear_image_caches" = "이미지 캐시 지우기"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "Tags"; -"general_setting_view.section.title.navigation" = "내비게이션"; -"general_setting_view.section.title.security" = "개인 정보 보호"; -"general_setting_view.section.title.caches" = "캐시"; -// AutoLockPolicy -"enum.auto_lock_policy.value.never" = "안 함"; -"enum.auto_lock_policy.value.instantly" = "즉시"; - -// MARK: LogsView -"logs_view.title.logs" = "로그"; -"logs_view.title.latest" = "마지막"; - -// MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "외관"; -"appearance_setting_view.title.theme" = "테마"; -"appearance_setting_view.title.tint_color" = "액센트 색상"; -"appearance_setting_view.title.display_mode" = "표시방식"; -"appearance_setting_view.title.shows_tags_in_list" = "리스트에서 태그 보여주기"; -"appearance_setting_view.title.maximum_number_of_tags" = "태그 갯수"; -"appearance_setting_view.title.displays_japanese_title" = "Displays Japanese title"; -"appearance_setting_view.button.app_icon" = "앱 아이콘"; -"appearance_setting_view.menu.title.infite" = "제한 없음"; -"appearance_setting_view.section.title.list" = "리스트"; -"appearance_setting_view.section.title.gallery" = "Gallery"; -// PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "자동"; -"enum.preferred_color_scheme.value.light" = "라이트"; -"enum.preferred_color_scheme.value.dark" = "다크"; -// AppIconType -"enum.app_icon_type.value.default" = "기본"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; -// ListDisplayMode -"enum.list_display_mode.value.detail" = "자세히"; -"enum.list_display_mode.value.thumbnail" = "썸네일"; - -// MARK: AppIconView -"app_icon_view.title.app_icon" = "앱 아이콘"; - -// MARK: reading_settingView -"reading_setting_view.title.reading" = "읽기"; -"reading_setting_view.title.direction" = "방향"; -"reading_setting_view.title.preload_limit" = "페이지 미리 로딩"; -"reading_setting_view.title.enables_landscape" = "Enables landscape"; -"reading_setting_view.title.separator_height" = "페이지 간 여백 두께"; -"reading_setting_view.title.maximum_scale_factor" = "최대 확대 비율"; -"reading_setting_view.title.double_tap_scale_factor" = "더블 탭 확대 비율"; -"reading_setting_view.section.title.appearance" = "외관"; -// ReadingDirection -"enum.reading_direction.value.vertical" = "위에서 아래로"; -"enum.reading_direction.value.right_to_left" = "오른쪽에서 왼쪽으로"; -"enum.reading_direction.value.left_to_right" = "왼쪽에서 오른쪽으로"; - -// MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "실험실"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "SNI 차단 우회"; - -// MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "웹사이트"; -"about_view.button.altStore_source" = "AltStore 소스"; -"about_view.title.version" = "버전"; -"about_view.section.title.special_thanks" = "Special thanks"; -"about_view.section.title.code_level_contributors" = "Code-level contributors"; -"about_view.section.title.translation_contributors" = "Translation contributors"; -"about_view.section.title.acknowledgements" = "도움을 주신 분들"; - -// MARK: DetailView -"detail_view.button.download_login" = "로그인"; -"detail_view.button.download_get" = "받기"; -"detail_view.button.download_wait" = "대기"; -"detail_view.button.download_done" = "완료"; -"detail_view.button.download_update" = "업데이트"; -"detail_view.button.download_retry" = "재시도"; -"detail_view.button.download_repair" = "복구"; -"detail_view.button.read" = "읽기"; -"detail_view.button.post_comment" = "평가 남기기"; -"detail_view.accessibility.download_button.login" = "다운로드하려면 로그인해야 합니다"; -"detail_view.accessibility.download_button.download" = "다운로드"; -"detail_view.accessibility.download_button.queued" = "다운로드 대기 중"; -"detail_view.accessibility.download_button.downloading" = "%d / %d 페이지 다운로드 중"; -"detail_view.accessibility.download_button.downloaded" = "다운로드한 갤러리 삭제"; -"detail_view.accessibility.download_button.update" = "다운로드 업데이트"; -"detail_view.accessibility.download_button.retry" = "다운로드 다시 시도"; -"detail_view.accessibility.download_button.repair" = "다운로드 복구"; -"detail_view.accessibility.download_button.preparing" = "다운로드 정보를 불러오는 중"; -"detail_view.toolbar_item.button.archives" = "아카이브"; -"detail_view.toolbar_item.button.torrents" = "토렌트"; -"detail_view.toolbar_item.button.share" = "공유"; -"detail_view.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "Withdraw vote"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "즐겨찾기"; -"detail_view.description_section.title.language" = "언어"; -"detail_view.description_section.title.ratings" = "%@명의 별점"; -"detail_view.description_section.title.page_count" = "페이지 수"; -"detail_view.description_section.title.file_size" = "파일 크기"; -"detail_view.description_section.description.favorited" = "번"; -"detail_view.description_section.description.page_count" = "페이지"; -"detail_view.action_section.button.give_a_rating" = "별점 주기"; -"detail_view.action_section.button.similar_gallery" = "비슷한 작품"; -"detail_view.section.title.previews" = "미리보기"; -"detail_view.section.title.comments" = "댓글"; - -// MARK: ArchivesView -"archives_view.title.archives" = "아카이브"; -"archives_view.button.download_to_hath_client" = "H@H 클라이언트로 저장"; -// HathArchive -"struct.hath_archive.price.free" = "무료"; -"struct.hath_archive.price.not_available" = "무효"; -// ArchiveResolution -"enum.archive_resolution.value.original" = "원본"; - -// MARK: TorrentsView -"torrents_view.title.torrents" = "토렌트"; - -// MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "갤러리 정보"; -"gallery_infos_view.title.id" = "ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "제목"; -"gallery_infos_view.title.japanese_title" = "일본어 제목"; -"gallery_infos_view.title.gallery_URL" = "갤러리 주소"; -"gallery_infos_view.title.cover_URL" = "표지 주소"; -"gallery_infos_view.title.archive_URL" = "아카이브 주소"; -"gallery_infos_view.title.torrent_URL" = "토렌트 주소"; -"gallery_infos_view.title.parent_URL" = "부모 갤러리 링크"; -"gallery_infos_view.title.category" = "장르"; -"gallery_infos_view.title.uploader" = "업로드"; -"gallery_infos_view.title.posted_date" = "업로드된 날짜"; -"gallery_infos_view.title.visibility" = "가시성"; -"gallery_infos_view.title.language" = "언어"; -"gallery_infos_view.title.page_count" = "페이지 수"; -"gallery_infos_view.title.file_size" = "파일 크기"; -"gallery_infos_view.title.favorited_times" = "즐겨찾기된 수"; -"gallery_infos_view.title.favorited" = "즐겨찾기에 저장 됨"; -"gallery_infos_view.title.rating_count" = "별점 갯수"; -"gallery_infos_view.title.average_rating" = "평균 별점"; -"gallery_infos_view.title.my_rating" = "My rating"; -"gallery_infos_view.title.torrent_count" = "토렌트 수"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "네"; -"gallery_infos_view.value.no" = "아니요"; -// GalleryVisibility -"enum.gallery_visibility.value.yes" = "네"; -"enum.gallery_visibility.value.no" = "아니요 (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "삭제됨"; - -// MARK: TagDetailView -"tag_detail_view.section.title.images" = "Images"; -"tag_detail_view.section.title.links" = "Links"; - -// MARK: CommentsView -"comments_view.title.comments" = "댓글"; - -// MARK: PostCommentView -"post_comment_view.title.post_comment" = "평가 남기기"; -"post_comment_view.title.edit_comment" = "평가 수정"; - -// MARK: PreviewsView -"previews_view.title.previews" = "미리보기"; - -// MARK: ReadingView -"reading_view.context_menu.button.reload" = "재시도"; -"reading_view.context_menu.button.copy" = "복사"; -"reading_view.context_menu.button.save" = "저장"; -"reading_view.context_menu.button.save_original" = "Save original"; -"reading_view.context_menu.button.share" = "공유"; -"reading_view.toolbar_item.title.auto_play" = "자동 재생"; -"reading_view.toolbar_item.title.dual_page_mode" = "두 장을 한 화면으로 보기"; -"reading_view.toolbar_item.title.except_the_cover" = "표지 제외하기"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "Retry failed images"; -"reading_view.toolbar_item.button.reload_all_images" = "Reload all images"; -"reading_view.toolbar_item.button.reading_setting" = "Reading setting"; -// AutoPlayPolicy -"enum.auto_play_policy.value.off" = "Off"; - -// MARK: FiltersView -"filters_view.title.filters" = "필터"; -"filters_view.title.advanced_settings" = "고급 설정"; -"filters_view.title.search_gallery_name" = "갤러리 이름을 찾아보기"; -"filters_view.title.search_gallery_tags" = "갤러리 태그를 찾아보기"; -"filters_view.title.search_gallery_description" = "갤러리 설명을 찾아보기"; -"filters_view.title.search_torrent_filenames" = "토렌트 파일 이름을 찾아보기"; -"filters_view.title.only_show_galleries_with_torrents" = "토렌트 있는 갤러리만 보이기"; -"filters_view.title.search_low_power_tags" = "인기가 없는 태그를 찾아보기"; -"filters_view.title.search_downvoted_tags" = "낮은 평가의 태그를 찾아보기"; -"filters_view.title.search_expunged_galleries" = "삭제된 갤러리를 보여주기"; -"filters_view.title.set_minimum_rating" = "최소 별점 설정하기"; -"filters_view.title.minimum_rating" = "최소 별점"; -"filters_view.title.set_pages_range" = "페이지 범위 설정"; -"filters_view.title.pages_range" = "페이지 범위"; -"filters_view.title.disable_language_filter" = "언어 필터 끄기"; -"filters_view.title.disable_uploader_filter" = "업로더 필터 끄기"; -"filters_view.title.disable_tags_filter" = "태그 필터 끄기"; -"filters_view.button.reset_filters" = "모든 필터 초기화"; -"filters_view.section.title.advanced" = "고급"; -"filters_view.section.title.default_filter" = "기본 옵션"; -// FilterRange -"enum.filter_range.value.search" = "검색"; -"enum.filter_range.value.global" = "전체"; -"enum.filter_range.value.watched" = "주시 태그"; - -// MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 설정"; -"eh_setting_view.section.title.profile_settings" = "프로필 설정"; -"eh_setting_view.title.selected_profile" = "선택한 프로필"; -"eh_setting_view.button.set_as_default" = "기본으로 설정"; -"eh_setting_view.button.delete_profile" = "프로필 삭제"; -"eh_setting_view.button.rename" = "이름 변경"; -"eh_setting_view.button.create_new" = "추가"; -"eh_setting_view.toolbar_item.button.done" = "Done"; - -"eh_setting_view.section.title.image_load_settings" = "이미지 로드 설정"; -"eh_setting_view.title.load_images_through_the_hath_network" = "Hath 네트워크를 통하여 이미지 로드"; -"eh_setting_view.title.browsing_country" = "브라우징하는 나라"; -"eh_setting_view.description.browsing_country" = "**%@**에서 사이트를 탐색하거나 이 나라에서 VPN이나 프록시를 사용하려고 하는 것 같네요. 이런 경우엔 사이트에서 이 지역의 H@H 클라이언트의 이미지를 로드하려고 시도할 거에요. 만약에 이 나라가 잘못되었거나 분할 터널링 VPN을 사용하는 경우와 같이 어떤 이유로든 다른 지역을 사용하려는 경우라면, 아래에서 다른 나라를 선택할 수 있어요."; -// EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "어떤 클라이언트에서든"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "기본 포트 클라이언트만"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "아닙니다 [Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "아닙니다 [Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "추천."; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "더 느려질 수 있어요. 나가는 비표준 포트를 차단하는 방화벽/프록시가 있는 경우 사용하세요."; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "기부자 전용 기능이에요. 심각한 문제가 있는 경우를 제외하고는 사용하지 말아주세요."; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; - -"eh_setting_view.section.title.image_size_settings" = "이미지 사이즈 설정"; -"eh_setting_view.title.image_resolution" = "이미지 해상도"; -"eh_setting_view.description.image_resolution" = "일반적으로 이미지는 온라인 뷰어를 위해 1280 픽셀의 수평 해상도로 작아져요. 아래의 압축된 해상도 중 하나를 선택할 수 있어요. 서버 과부하를 막기 위해, 1280 이상의 해상도는 도네이션을 한 사람, hath perk를 가진 사람, 그리고 UID가 300만 이하인 사람들로 일시적으로 제한되어요."; -"eh_setting_view.title.image_size" = "이미지 사이즈"; -"eh_setting_view.description.image_size" = "사이트가 사용자의 화면 너비에 맞게 이미지를 자동으로 축소시키지만, 수동으로 크기를 정할 수도 있어요. 크기 조정은 브라우저 측에서 수행되므로 이미지가 다시 샘플링되지 않아요. (0 = no limit)"; -"eh_setting_view.title.horizontal" = "가로"; -"eh_setting_view.title.vertical" = "세로"; -// EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "자동"; - -"eh_setting_view.section.title.gallery_name_display" = "갤러리 이름 보이기"; -"eh_setting_view.title.gallery_name" = "갤러리 이름"; -"eh_setting_view.description.gallery_name" = "영어 제목과 일본어 제목 중 기본값으로 보일 언어를 선택해주세요."; -// EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "영어 제목"; -"enum.eh_setting.gallery_name.value.japanese" = "일본어 제목(가능하면)"; - -"eh_setting_view.section.title.archiver_settings" = "아카이버"; -"eh_setting_view.title.archiver_behavior" = "아카이버 동작 방법 설정"; -"eh_setting_view.description.archiver_behavior" = "아카이버의 기본 동작은 원본 또는 저화질 갤러리 저장에 대한 비용과 선택을 확인한 다음 다른 곳에서 클릭하거나 복사할 수 있는 링크를 표시하는 것입니다. 여기서 이 동작을 변경할 수 있습니다."; -// EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "수동 선택, 수동 시작 (기본)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "수동 선택, 자동 시작"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "자동으로 원본을 선택, 수동 시작"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "자동으로 원본을 선택, 자동 시작"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "자동으로 저화질을 선택, 수동 시작"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "자동으로 저화질을 선택, 자동 시작"; - -"eh_setting_view.section.title.front_page_settings" = "프론트 페이지 설정"; -"eh_setting_view.title.display_mode" = "표시방식"; -"eh_setting_view.description.display_mode" = "프론트와 검색 페이지에서 사용할 디스플레이 모드를 선택하세요."; -"eh_setting_view.section.title.show_search_range_indicator" = "Search Range Indicator"; -"eh_setting_view.title.show_search_range_indicator" = "Show search range indicator"; -"eh_setting_view.description.gallery_category" = "프론트와 검색 페이지에서 어떤 카테고리가 보여지도록 할까요?"; -// EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "Compact"; -"enum.eh_setting.display_mode.value.thumbnail" = "Thumbnail"; -"enum.eh_setting.display_mode.value.extended" = "Extended"; -"enum.eh_setting.display_mode.value.minimal" = "Minimal"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "Optional UI Elements"; -"eh_setting_view.description.optional_UI_elements" = "Some historic UI elements are now disabled by default. You can enable those here."; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; - -"eh_setting_view.section.title.favorites" = "즐겨찾기"; -"eh_setting_view.description.favorite_categories" = "여기서 좋아하는 장르들을 선택하고 이름을 바꿀 수 있어요."; -"eh_setting_view.title.favorites_sort_order" = "관심 순서를 배열"; -"eh_setting_view.description.favorites_sort_order" = "당신의 관심 페이지의 기본 정렬 방식을 선택할 수 있어요. 2016년 3월 개정 전에 추가된 즐겨찾기는 타임스탬프가 저장되지 않아 이 설정에 관계없이 갤러리가 게시된 시간으로 정렬되어요."; -// EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "마지막 업데이트 시간으로"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "별점 시간으로"; - -"eh_setting_view.section.title.ratings" = "별점"; -"eh_setting_view.title.ratings_color" = "별점 색깔"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "기본적으로 등급을 매긴 갤러리는 별 2개 이하의 등급에 대해 빨간색, 2.5~4개의 등급에 대해 녹색, 4.5~5개의 등급에 대해 파란색 별로 표시되어요. 아래에 원하는 색상 조합을 입력하여 사용자 정의할 수 있어요. 각 문자는 별 하나를 표현해요. 기본 RRGGB는 첫 번째와 두 번째 별의 경우 R(ed), 세 번째와 네 번째 별의 경우 G(reen), 다섯 번째 별의 경우 B(lue)를 의미해요. 일반 별에 (Y)ellow를 사용할 수도 있어요. 모든 5글자의 R/G/B/Y 콤보가 작동해요."; - -"eh_setting_view.section.title.tag_filtering_threshold" = "태그 필터링 임계값"; -"eh_setting_view.title.tag_filtering_threshold" = "태그 필터링 임계값"; -"eh_setting_view.description.tag_filtering_threshold" = "마이너스 가중치로 My Tags에 추가하여 태그를 소프트 필터할 수 있어요. 갤러리에 이 값 이하의 가중치를 추가하는 태그가 있으면 보기에서 필터링되어요. 이 임계값은 0과 -9999 사이에서 설정할 수 있어요."; - -"eh_setting_view.section.title.tag_watching_threshold" = "태그 보여주기 임계값"; -"eh_setting_view.title.tag_watching_threshold" = "태그 보여주기 임계값"; -"eh_setting_view.description.tag_watching_threshold" = "최근에 업로드된 갤러리는 최소 1개의 Watched 태그가 있고 Watched 태그의 가중치의 합이 이 값 이상이 될 경우 Watched 화면에 포함되어요. 이 임계값은 0과 9999 사이에서 설정할 수 있어요."; - -"eh_setting_view.section.title.filtered_removal_count" = "Show Filtered Removal Count"; -"eh_setting_view.description.filtered_removal_count" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; -"eh_setting_view.title.show_filtered_removal_count" = "Show filtered removal count"; - -"eh_setting_view.section.title.excluded_languages" = "제외된 언어"; -"eh_setting_view.description.excluded_languages" = "갤러리 목록에서 특정 언어로 된 갤러리를 숨기고 검색하려면 아래 목록에서 해당 갤러리를 선택해주세요. 검색어에 관계없이 일치하는 갤러리는 나타나지 않아요."; -// EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "원본"; -"enum.eh_setting.excluded_languages_category.value.translated" = "번역됨"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "다시 쓰기"; - -"eh_setting_view.section.title.excluded_uploaders" = "제외된 업로드"; -"eh_setting_view.description.excluded_uploaders" = "갤러리 목록 및 검색에서 특정 업로더의 갤러리를 숨기려면 아래에 해당 갤러리를 추가해주세요. 한 줄에 하나의 사용자 이름을 입력해주세요. 이러한 업로더의 갤러리는 검색 쿼리에 관계없이 나타나지 않아요."; -"eh_setting_view.description.excluded_uploaders_count" = "**%@ / %@** 개의 슬롯을 사용하고 있어요."; - -"eh_setting_view.section.title.search_result_count" = "검색 결과 수"; -"eh_setting_view.title.result_count" = "결과 수"; -"eh_setting_view.description.result_count" = "인덱스 / 검색 / 토렌트 검색 페이지에 대해 페이지당 몇 개의 결과를 원하시나요?\n(Hath Perk: 페이징 확장 필요)"; - -"eh_setting_view.section.title.thumbnail_settings" = "썸네일 설정"; -"eh_setting_view.title.thumbnail_load_timing" = "썸네일 로드 시간"; -"eh_setting_view.description.thumbnail_load_timing" = "목록 모드를 사용할 때 앞 페이지의 마우스 오버 미리 보기를 어떻게 로드할까요?"; -"eh_setting_view.description.thumbnail_configuration" = "모든 방문한 갤러리에 대하여 기본 썸네일을 설정할 수 있어요."; -"eh_setting_view.title.thumbnail_size" = "사이즈"; -"eh_setting_view.title.thumbnail_row_count" = "줄"; -// EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "마우스를 올릴 때"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "페이지 로드될 때"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "페이지가 더 빨리 로드되지만 엄지손가락이 나타나기 전까지 약간의 지연이 있을 수 있어요."; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "페이지 로드에 시간이 더 오래 걸리지만, 페이지가 로드된 후 썸네일을 로드하는데 지연이 없어요."; -// EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "보통"; -"enum.eh_setting.thumbnail_size.value.large" = "크게"; -"enum.eh_setting.thumbnail_size.value.small" = "Small"; -"enum.eh_setting.thumbnail_size.value.auto" = "Auto"; - -"eh_setting_view.section.title.cover_scaling" = "Cover Scaling"; -"eh_setting_view.title.scale_factor" = "크기 비율"; -"eh_setting_view.description.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; - -"eh_setting_view.section.title.viewport_override" = "뷰포트 조정"; -"eh_setting_view.title.virtual_width" = "가상 너비"; -"eh_setting_view.description.virtual_width" = "모바일 장치의 사이트 가상 너비를 설정할 수 있어요. 일반적으로 DPI에 따라 장치에 의해 자동으로 결정되어요. 100%% 썸네일 스케일의 추천 값은 640에서 1400 사이에요."; - -"eh_setting_view.section.title.gallery_comments" = "갤러리 댓글"; -"eh_setting_view.title.comments_sort_order" = "댓글 순서"; -"eh_setting_view.title.comments_votes_show_timing" = "평가의 시간을 보이기"; -// EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "가장 이른 순서"; -"enum.eh_setting.comments_sort_order.value.recent" = "최신순"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "평가가 가장 높은 순서"; -// EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "점수를 가리키커나 클리하기"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "항상"; - -"eh_setting_view.section.title.gallery_tags" = "갤러리 태그"; -"eh_setting_view.title.tags_sort_order" = "태그 순서를 배열"; -// EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "알파벳순으로"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "태크 가중치로"; - -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; - -"eh_setting_view.section.title.hath_local_network_host" = "Hath 로컬 네트워크 호스트"; -"eh_setting_view.title.ip_address_port" = "IP주소:포트"; -"eh_setting_view.description.ip_address_port" = "이 설정은 사이트를 검색하는 것과 동일한 공용 IP로 로컬 네트워크에서 H@H 클라이언트를 실행하는 경우 사용할 수 있습니다. 일부 라우터는 버그가 있어 요청을 자신의 IP로 다시 라우팅할 수 없기에, 아래를 따라서 이 문제를 해결할 수 있습니다.\n찾아보는 동일한 장치에서 클라이언트를 실행하는 경우 루프백 주소(127.0.0.1:port)를 사용할 수 있습니다. 클라이언트가 네트워크의 다른 장치에서 실행 중인 경우 로컬 네트워크 IP를 사용할 수 있습니다. 일부 브라우저 구성에서는 외부 웹 사이트가 로컬 네트워크 IP가 있는 URL에 액세스할 수 없도록 합니다. 그런 다음 사이트가 작동하려면 사이트를 화이트리스트에 추가해야 합니다."; - -"eh_setting_view.section.title.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; -"eh_setting_view.title.use_original_images" = "원본 뷰어 적용"; - -"eh_setting_view.section.title.multi_page_viewer" = "멀티 페이지 뷰어"; -"eh_setting_view.title.use_multi_page_viewer" = "다중 페이지 뷰어 적용"; -"eh_setting_view.title.display_style" = "보여주기 스타일"; -"eh_setting_view.title.show_thumbnail_pane" = "썸네일 창 표시"; -// EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "왼쪽 정렬, 너비 초과할 때 크기 맞추기"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "가운데 정렬, 너비 초과할 때 크기 맞추기"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "가운데 정렬, 항상 크기 맞추기"; -// EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "None"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "Page Number Only"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "Page Number + Name"; - -// MARK: Category -"enum.category.value.doujinshi" = "동인지"; -"enum.category.value.manga" = "만화"; -"enum.category.value.artist_CG" = "일러스트"; -"enum.category.value.game_CG" = "게임 CG"; -"enum.category.value.western" = "서양"; -"enum.category.value.non_h" = "Non-H"; -"enum.category.value.image_set" = "포토북"; -"enum.category.value.cosplay" = "코스프레"; -"enum.category.value.asian_porn" = "Asian Porn"; -"enum.category.value.misc" = "기타"; -"enum.category.value.private" = "Private"; - -// MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "재분류"; -"enum.tag_namespace.value.language" = "언어"; -"enum.tag_namespace.value.parody" = "원작"; -"enum.tag_namespace.value.character" = "캐릭터"; -"enum.tag_namespace.value.group" = "그룹"; -"enum.tag_namespace.value.artist" = "작가"; -"enum.tag_namespace.value.male" = "남성"; -"enum.tag_namespace.value.female" = "여성"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "Other"; -"enum.tag_namespace.value.temp" = "Temp"; - -// MARK: Language -"enum.language.value.invalid" = "무효"; -"enum.language.value.other" = "Other"; -"enum.language.value.afrikaans" = "아프리칸스어"; -"enum.language.value.albanian" = "알바니아어"; -"enum.language.value.arabic" = "아랍어"; -"enum.language.value.bengali" = "벵갈어"; -"enum.language.value.bosnian" = "보스니아어"; -"enum.language.value.bulgarian" = "불가리아어"; -"enum.language.value.burmese" = "버마어"; -"enum.language.value.catalan" = "카탈루냐어"; -"enum.language.value.cebuano" = "세부어"; -"enum.language.value.chinese" = "중국어"; -"enum.language.value.croatian" = "크로아티아어"; -"enum.language.value.czech" = "체코어"; -"enum.language.value.danish" = "덴마크어"; -"enum.language.value.dutch" = "네덜란드어"; -"enum.language.value.english" = "영어"; -"enum.language.value.esperanto" = "국제어"; -"enum.language.value.estonian" = "에스토니아어"; -"enum.language.value.finnish" = "핀란드어"; -"enum.language.value.french" = "프랑스어"; -"enum.language.value.georgian" = "그루지야어"; -"enum.language.value.german" = "독일어"; -"enum.language.value.greek" = "그리스어"; -"enum.language.value.hebrew" = "히브리어"; -"enum.language.value.hindi" = "힌디어"; -"enum.language.value.hmong" = "묘어"; -"enum.language.value.hungarian" = "헝가리어"; -"enum.language.value.indonesian" = "인도네시아어"; -"enum.language.value.italian" = "이탈리아어"; -"enum.language.value.japanese" = "일본어"; -"enum.language.value.kazakh" = "카자흐어"; -"enum.language.value.khmer" = "크메르원"; -"enum.language.value.korean" = "한국어"; -"enum.language.value.kurdish" = "쿠르드어"; -"enum.language.value.lao" = "라오스어"; -"enum.language.value.latin" = "라틴어"; -"enum.language.value.mongolian" = "몽골어"; -"enum.language.value.ndebele" = "은데벨리어"; -"enum.language.value.nepali" = "네팔어"; -"enum.language.value.norwegian" = "노르웨이어로"; -"enum.language.value.oromo" = "오로모어"; -"enum.language.value.pashto" = "파슈토어"; -"enum.language.value.persian" = "페르시아어"; -"enum.language.value.polish" = "폴란드어"; -"enum.language.value.portuguese" = "포르투갈어"; -"enum.language.value.punjabi" = "펀자브어"; -"enum.language.value.romanian" = "루마니아어"; -"enum.language.value.russian" = "러시아어"; -"enum.language.value.sango" = "쌍고어"; -"enum.language.value.serbian" = "세르비아어"; -"enum.language.value.shona" = "쇼나어"; -"enum.language.value.slovak" = "슬로바키아어"; -"enum.language.value.slovenian" = "슬로베니아어"; -"enum.language.value.somali" = "소말리아어"; -"enum.language.value.spanish" = "스페인어"; -"enum.language.value.swahili" = "스와히리어로"; -"enum.language.value.swedish" = "스웨덴어"; -"enum.language.value.tagalog" = "타갈로어"; -"enum.language.value.thai" = "타이어"; -"enum.language.value.tigrinya" = "티글리니아어"; -"enum.language.value.turkish" = "터키어"; -"enum.language.value.ukrainian" = "우크라이나어"; -"enum.language.value.urdu" = "우르두어"; -"enum.language.value.vietnamese" = "베트남어"; -"enum.language.value.zulu" = "줄루어"; - -// MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "자동으로 설정"; -"enum.browsing_country.name.afghanistan" = "아프가니스탄"; -"enum.browsing_country.name.aland_islands" = "알란드 제도"; -"enum.browsing_country.name.albania" = "알바니아"; -"enum.browsing_country.name.algeria" = "알제리아"; -"enum.browsing_country.name.american_samoa" = "아메리칸 사모아"; -"enum.browsing_country.name.andorra" = "안도라"; -"enum.browsing_country.name.angola" = "앙골라"; -"enum.browsing_country.name.anguilla" = "안젤라"; -"enum.browsing_country.name.antarctica" = "남극"; -"enum.browsing_country.name.antigua_and_barbuda" = "앤티가 바부다"; -"enum.browsing_country.name.argentina" = "아르헨티나"; -"enum.browsing_country.name.armenia" = "아르메니아"; -"enum.browsing_country.name.aruba" = "아루바 섬"; -"enum.browsing_country.name.asia_pacific_region" = "아시아 태평양 영역"; -"enum.browsing_country.name.australia" = "호주"; -"enum.browsing_country.name.austria" = "오스트리아"; -"enum.browsing_country.name.azerbaijan" = "아제르바이잔"; -"enum.browsing_country.name.bahamas" = "바하마스"; -"enum.browsing_country.name.bahrain" = "바레인"; -"enum.browsing_country.name.bangladesh" = "방글라데시"; -"enum.browsing_country.name.barbados" = "바베이도스"; -"enum.browsing_country.name.belarus" = "벨라루스"; -"enum.browsing_country.name.belgium" = "벨기에"; -"enum.browsing_country.name.belize" = "벨리즈"; -"enum.browsing_country.name.benin" = "베냉"; -"enum.browsing_country.name.bermuda" = "버뮤다"; -"enum.browsing_country.name.bhutan" = "부탄"; -"enum.browsing_country.name.bolivia" = "볼리비아"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "보네르 성 유스타티우스와 사바"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "보스니아 헤르체코비나 "; -"enum.browsing_country.name.botswana" = "보츠와나"; -"enum.browsing_country.name.bouvet_island" = "부베섬"; -"enum.browsing_country.name.brazil" = "브라질"; -"enum.browsing_country.name.british_indian_ocean_territory" = "영국령 인도양 식민지"; -"enum.browsing_country.name.brunei_darussalam" = "브루나이 다루살람"; -"enum.browsing_country.name.bulgaria" = "불가리아"; -"enum.browsing_country.name.burkina_faso" = "부르키나 파소"; -"enum.browsing_country.name.burundi" = "부룬디"; -"enum.browsing_country.name.cambodia" = "캄보디아"; -"enum.browsing_country.name.cameroon" = "카메룬"; -"enum.browsing_country.name.canada" = "캐나다"; -"enum.browsing_country.name.cape_verde" = "포르투갈어"; -"enum.browsing_country.name.cayman_islands" = "케이맨 제도"; -"enum.browsing_country.name.central_african_republic" = "중앙아프리카 공화국"; -"enum.browsing_country.name.chad" = "차드"; -"enum.browsing_country.name.chile" = "칠레"; -"enum.browsing_country.name.china" = "중국"; -"enum.browsing_country.name.christmas_island" = "크리스마스 섬"; -"enum.browsing_country.name.cocos_islands" = "코코스 제도"; -"enum.browsing_country.name.colombia" = "콜롬비아"; -"enum.browsing_country.name.comoros" = "코모로"; -"enum.browsing_country.name.congo" = "콩고"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "콩고민주공화국"; -"enum.browsing_country.name.cook_islands" = "쿡제도"; -"enum.browsing_country.name.costa_rica" = "코스타리카"; -"enum.browsing_country.name.cote_d_ivoire" = "코트디부아르"; -"enum.browsing_country.name.croatia" = "크로아티아"; -"enum.browsing_country.name.cuba" = "쿠바"; -"enum.browsing_country.name.curacao" = "큐라소"; -"enum.browsing_country.name.cyprus" = "키프로스"; -"enum.browsing_country.name.czech_republic" = "체코 공화국"; -"enum.browsing_country.name.denmark" = "덴마크"; -"enum.browsing_country.name.djibouti" = "지부티"; -"enum.browsing_country.name.dominica" = "도미니카"; -"enum.browsing_country.name.dominican_republic" = "도미니카 공화국"; -"enum.browsing_country.name.ecuador" = "에콰도르"; -"enum.browsing_country.name.egypt" = "이집트"; -"enum.browsing_country.name.el_salvador" = "엘살바도르"; -"enum.browsing_country.name.equatorial_guinea" = "적도 기니"; -"enum.browsing_country.name.eritrea" = "에리트레아"; -"enum.browsing_country.name.estonia" = "에스토니아"; -"enum.browsing_country.name.ethiopia" = "에티오피아"; -"enum.browsing_country.name.europe" = "유럽"; -"enum.browsing_country.name.falkland_islands" = "포클랜드 제도"; -"enum.browsing_country.name.faroe_islands" = "페로스 제도"; -"enum.browsing_country.name.fiji" = "피지"; -"enum.browsing_country.name.finland" = "핀란드"; -"enum.browsing_country.name.france" = "프랑스"; -"enum.browsing_country.name.french_guiana" = "프랑스령 기아나"; -"enum.browsing_country.name.french_polynesia" = "프랑스령 폴리네시아"; -"enum.browsing_country.name.french_southern_territories" = "프랑스령 남부와 남극지역"; -"enum.browsing_country.name.gabon" = "가봉"; -"enum.browsing_country.name.gambia" = "감비아"; -"enum.browsing_country.name.georgia" = "그루지야"; -"enum.browsing_country.name.germany" = "독일"; -"enum.browsing_country.name.ghana" = "가나"; -"enum.browsing_country.name.gibraltar" = "지브롤터"; -"enum.browsing_country.name.greece" = "희랍"; -"enum.browsing_country.name.greenland" = "그린란드"; -"enum.browsing_country.name.grenada" = "그레나다"; -"enum.browsing_country.name.guadeloupe" = "과들루프 섬"; -"enum.browsing_country.name.guam" = "괌"; -"enum.browsing_country.name.guatemala" = "과테말라"; -"enum.browsing_country.name.guernsey" = "건지종 젖소"; -"enum.browsing_country.name.guinea" = "기니"; -"enum.browsing_country.name.guinea_bissau" = "기니비사우"; -"enum.browsing_country.name.guyana" = "가이아나"; -"enum.browsing_country.name.haiti" = "아이티"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "허드 맥도널드 제도"; -"enum.browsing_country.name.vatican_city_state" = "바티칸 시국"; -"enum.browsing_country.name.honduras" = "온두라스"; -"enum.browsing_country.name.hong_kong" = "홍콩"; -"enum.browsing_country.name.hungary" = "헝가리"; -"enum.browsing_country.name.iceland" = "Iceland"; -"enum.browsing_country.name.india" = "인도"; -"enum.browsing_country.name.indonesia" = "인도네시아"; -"enum.browsing_country.name.iran" = "이란"; -"enum.browsing_country.name.iraq" = "이라크"; -"enum.browsing_country.name.ireland" = "아일랜드"; -"enum.browsing_country.name.isle_of_man" = "맨 섬"; -"enum.browsing_country.name.israel" = "이스라엘"; -"enum.browsing_country.name.italy" = "이탈리아"; -"enum.browsing_country.name.jamaica" = "자마이카"; -"enum.browsing_country.name.japan" = "일본"; -"enum.browsing_country.name.jersey" = "저시"; -"enum.browsing_country.name.jordan" = "요단"; -"enum.browsing_country.name.kazakhstan" = "카자흐스탄"; -"enum.browsing_country.name.kenya" = "케냐"; -"enum.browsing_country.name.kiribati" = "키리바시"; -"enum.browsing_country.name.kuwait" = "쿠웨이트"; -"enum.browsing_country.name.kyrgyzstan" = "키르기스스탄"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "라오 인민민주공화국"; -"enum.browsing_country.name.latvia" = "라트비아"; -"enum.browsing_country.name.lebanon" = "레바논"; -"enum.browsing_country.name.lesotho" = "레소토"; -"enum.browsing_country.name.liberia" = "리베리아"; -"enum.browsing_country.name.libya" = "리비아"; -"enum.browsing_country.name.liechtenstein" = "리히텐슈타인"; -"enum.browsing_country.name.lithuania" = "리투아니아"; -"enum.browsing_country.name.luxembourg" = "룩셈부르크"; -"enum.browsing_country.name.macau" = "마카오"; -"enum.browsing_country.name.macedonia" = "마케도니아"; -"enum.browsing_country.name.madagascar" = "마다스카르"; -"enum.browsing_country.name.malawi" = "말라위"; -"enum.browsing_country.name.malaysia" = "말레이시아"; -"enum.browsing_country.name.maldives" = "말디브"; -"enum.browsing_country.name.mali" = "말리"; -"enum.browsing_country.name.malta" = "말타"; -"enum.browsing_country.name.marshall_islands" = "마샬군도"; -"enum.browsing_country.name.martinique" = "마르티니크"; -"enum.browsing_country.name.mauritania" = "모리타니아"; -"enum.browsing_country.name.mauritius" = "모리셔스"; -"enum.browsing_country.name.mayotte" = "마요트 섬"; -"enum.browsing_country.name.mexico" = "맥시코"; -"enum.browsing_country.name.micronesia" = "마크로네시아"; -"enum.browsing_country.name.moldova" = "몰도바"; -"enum.browsing_country.name.monaco" = "모나코"; -"enum.browsing_country.name.mongolia" = "몽콜"; -"enum.browsing_country.name.montenegro" = "몬테네그로"; -"enum.browsing_country.name.montserrat" = "몬트세라트섬"; -"enum.browsing_country.name.morocco" = "모로코가족"; -"enum.browsing_country.name.mozambique" = "모잠비크"; -"enum.browsing_country.name.myanmar" = "미얀마"; -"enum.browsing_country.name.namibia" = "나미비아"; -"enum.browsing_country.name.nauru" = "나우루"; -"enum.browsing_country.name.nepal" = "네팔"; -"enum.browsing_country.name.netherlands" = "네덜란드"; -"enum.browsing_country.name.new_caledonia" = "뉴칼레도니아"; -"enum.browsing_country.name.new_zealand" = "뉴질랜드"; -"enum.browsing_country.name.nicaragua" = "나카라과"; -"enum.browsing_country.name.niger" = "니제르"; -"enum.browsing_country.name.nigeria" = "나이지리아"; -"enum.browsing_country.name.niue" = "니우에 섬"; -"enum.browsing_country.name.norfolk_island" = "노퍽섬"; -"enum.browsing_country.name.north_korea" = "북한"; -"enum.browsing_country.name.northern_mariana_islands" = "북마리아나제도"; -"enum.browsing_country.name.norway" = "노르웨이"; -"enum.browsing_country.name.oman" = "오만"; -"enum.browsing_country.name.pakistan" = "파키스탄"; -"enum.browsing_country.name.palau" = "팔라우"; -"enum.browsing_country.name.palestinian_territory" = "팔레스타인의 지역"; -"enum.browsing_country.name.panama" = "파나마모자"; -"enum.browsing_country.name.papua_new_guinea" = "파푸아뉴기니"; -"enum.browsing_country.name.paraguay" = "파라과이"; -"enum.browsing_country.name.peru" = "페루"; -"enum.browsing_country.name.philippines" = "필리핀"; -"enum.browsing_country.name.pitcairn_islands" = "핏케언 제도"; -"enum.browsing_country.name.poland" = "폴란드"; -"enum.browsing_country.name.portugal" = "포르투갈"; -"enum.browsing_country.name.puerto_rico" = "푸에르토리코"; -"enum.browsing_country.name.qatar" = "카타로"; -"enum.browsing_country.name.reunion" = "레워니옹"; -"enum.browsing_country.name.romania" = "루마니아"; -"enum.browsing_country.name.russian_federation" = "러시아 연방"; -"enum.browsing_country.name.rwanda" = "르완다"; -"enum.browsing_country.name.saint_barthelemy" = "생바르텔레미"; -"enum.browsing_country.name.saint_helena" = "세인츠헬레나 섬"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "세인트키츠네비스"; -"enum.browsing_country.name.saint_lucia" = "세인트루시아"; -"enum.browsing_country.name.saint_martin" = "세인트 마틴"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "생피에르 미글롱"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "세인트빈센트 그레나딘"; -"enum.browsing_country.name.samoa" = "사모아"; -"enum.browsing_country.name.san_marino" = "산마리노"; -"enum.browsing_country.name.sao_tome_and_principe" = "상투메 프린시페 도브라"; -"enum.browsing_country.name.saudi_arabia" = "사우디 아라비아"; -"enum.browsing_country.name.senegal" = "세네갈"; -"enum.browsing_country.name.serbia" = "세르비아"; -"enum.browsing_country.name.seychelles" = "세이셸"; -"enum.browsing_country.name.sierra_leone" = "시에라리온"; -"enum.browsing_country.name.singapore" = "싱가포르"; -"enum.browsing_country.name.sint_maarten" = "신트마르턴"; -"enum.browsing_country.name.slovakia" = "슬로바키아"; -"enum.browsing_country.name.slovenia" = "슬로베니아"; -"enum.browsing_country.name.solomon_islands" = "솔로몬 제도"; -"enum.browsing_country.name.somalia" = "소말리아"; -"enum.browsing_country.name.south_africa" = "남아프리카"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "사우스조지아 사우스샌드위치 제도"; -"enum.browsing_country.name.south_korea" = "한국"; -"enum.browsing_country.name.south_sudan" = "남수단"; -"enum.browsing_country.name.spain" = "스페인"; -"enum.browsing_country.name.sri_lanka" = "스리랑카"; -"enum.browsing_country.name.sudan" = "수단"; -"enum.browsing_country.name.suriname" = "수리남"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "스발바르 얀마옌 제도"; -"enum.browsing_country.name.swaziland" = "스와질란드"; -"enum.browsing_country.name.sweden" = "스웨덴"; -"enum.browsing_country.name.switzerland" = "스위스"; -"enum.browsing_country.name.syrian_arab_republic" = "시리아"; -"enum.browsing_country.name.taiwan" = "대만"; -"enum.browsing_country.name.tajikistan" = "타지키스탄"; -"enum.browsing_country.name.tanzania" = "탄지니아"; -"enum.browsing_country.name.thailand" = "태국"; -"enum.browsing_country.name.timor_leste" = "동티모르"; -"enum.browsing_country.name.togo" = "토고"; -"enum.browsing_country.name.tokelau" = "토켈라우"; -"enum.browsing_country.name.tonga" = "통가"; -"enum.browsing_country.name.trinidad_and_tobago" = "트리니다드토바고"; -"enum.browsing_country.name.tunisia" = "튀니지"; -"enum.browsing_country.name.turkey" = "터키"; -"enum.browsing_country.name.turkmenistan" = "투르크메니스탄"; -"enum.browsing_country.name.turks_and_caicos_islands" = "터크스카이코스 제도"; -"enum.browsing_country.name.tuvalu" = "투발루"; -"enum.browsing_country.name.uganda" = "우간다"; -"enum.browsing_country.name.ukraine" = "우크라이나"; -"enum.browsing_country.name.united_arab_emirates" = "아랍 에미리트 연합국"; -"enum.browsing_country.name.united_kingdom" = "영국"; -"enum.browsing_country.name.united_states" = "미국"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "미국령 군소 제도"; -"enum.browsing_country.name.uruguay" = "우루과이"; -"enum.browsing_country.name.uzbekistan" = "우즈베키스탄"; -"enum.browsing_country.name.vanuatu" = "바누어투"; -"enum.browsing_country.name.venezuela" = "베네수엘라"; -"enum.browsing_country.name.vietnam" = "베트남"; -"enum.browsing_country.name.virgin_islands_british" = "영국령 버진 제도"; -"enum.browsing_country.name.virgin_islands_US" = "세인트존 섬"; -"enum.browsing_country.name.wallis_and_futuna" = "월리스 푸투나제도"; -"enum.browsing_country.name.western_sahara" = "서사하라"; -"enum.browsing_country.name.yemen" = "예멘"; -"enum.browsing_country.name.zambia" = "잠비아"; -"enum.browsing_country.name.zimbabwe" = "짐바브웨"; - -// MARK: Download Localization Additions -"common.button.cancel" = "취소"; -"tab_item.title.downloads" = "다운로드"; -"app_error.localized_description.database_corrupted" = "데이터베이스 손상"; -"app_error.localized_description.copyright_claim" = "저작권 신고"; -"app_error.localized_description.ip_banned" = "IP 차단됨"; -"app_error.localized_description.gallery_expunged" = "갤러리 삭제됨"; -"app_error.localized_description.network_error" = "네트워크 오류"; -"app_error.localized_description.web_image_loading_error" = "웹 이미지 로드 오류"; -"app_error.localized_description.parse_error" = "파싱 오류"; -"app_error.localized_description.quota_exceeded" = "할당량 초과"; -"app_error.localized_description.authentication_required" = "인증 필요"; -"app_error.localized_description.file_operation_failed" = "파일 작업 실패"; -"app_error.localized_description.no_updates_available" = "사용 가능한 업데이트 없음"; -"app_error.localized_description.not_found" = "찾을 수 없음"; -"app_error.localized_description.unknown_error" = "알 수 없는 오류"; -"app_error.alert.quota_exceeded" = "이미지 할당량을 모두 사용했습니다.\n잠시 후 다시 시도해 주세요."; -"app_error.alert.authentication_required" = "이 다운로드에 접근하려면 로그인해야 합니다."; -"app_error.alert.local_file_operation_failed" = "로컬 파일 작업에 실패했습니다."; -"detail_view.accessibility.download_button.pause_action" = "다운로드 일시 정지"; -"detail_view.accessibility.download_button.paused" = "다운로드 다시 시작. %d / %d 페이지에서 일시 정지됨"; -"detail_view.accessibility.download_button.partial" = "다운로드 다시 시도. 이미 %d / %d 페이지를 사용할 수 있습니다."; -"detail_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; -"detail_view.dialog.title.repair_download" = "다운로드를 복구할까요?"; -"detail_view.dialog.title.update_download" = "다운로드를 업데이트할까요?"; -"detail_view.dialog.title.redownload_gallery" = "갤러리를 다시 다운로드할까요?"; -"detail_view.dialog.message.delete_active_download" = "현재 다운로드를 중지하고 이 기기에서 갤러리를 삭제합니다."; -"detail_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; -"detail_view.dialog.message.repair_download" = "이 갤러리의 오프라인 파일을 지금 복구할까요?"; -"detail_view.dialog.message.update_download" = "이 갤러리를 지금 온라인 최신 버전으로 업데이트할까요?"; -"detail_view.dialog.message.redownload_gallery" = "이 갤러리를 지금 처음부터 다시 다운로드할까요?"; -"detail_view.dialog.button.repair" = "복구"; -"detail_view.dialog.button.update" = "업데이트"; -"detail_view.dialog.button.redownload" = "다시 다운로드"; -"detail_view.offline_notice.saved_details" = "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다."; -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "다운로드"; -"downloads_view.search.prompt.downloads" = "다운로드 검색"; -"downloads_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; -"downloads_view.dialog.message.delete_active_download" = "현재 다운로드를 취소하고 이 기기에서 삭제합니다."; -"downloads_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; -"downloads_view.swipe.button.pages" = "페이지"; -"downloads_view.swipe.button.update" = "업데이트"; -"downloads_view.swipe.button.resume" = "재개"; -"downloads_view.swipe.button.pause" = "일시 정지"; -"downloads_view.empty_state.downloads" = "다운로드한 갤러리가 여기에 표시됩니다."; -"downloads_view.empty_state.no_matching_filters" = "현재 필터와 일치하는 다운로드가 없습니다."; -"downloads_view.button.clear_filters" = "필터 지우기"; -"downloads_view.button.validate_image_data" = "이미지 데이터 검증"; -"downloads_view.inspector.section.actions" = "동작"; -"downloads_view.inspector.section.pages" = "페이지"; -"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도"; -"downloads_view.inspector.button.validating_image_data" = "이미지 데이터 검증 중..."; -"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; -"downloads_view.inspector.hud.image_data_valid" = "이미지 데이터가 유효합니다"; -"downloads_view.inspector.hud.image_data_unavailable" = "이미지 데이터를 검증할 수 없습니다."; -"downloads_view.inspector.title.download_status" = "다운로드 상태"; -"downloads_view.inspector.page.pending" = "대기 중"; -"downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; -"downloads_view.inspector.page.title" = "페이지 %d"; -"downloads_view.inspector.page.none" = "페이지 없음"; -"downloads_view.inspector.status.pending" = "대기 중"; -"downloads_view.inspector.status.downloaded" = "다운로드됨"; -"downloads_view.inspector.status.failed" = "실패"; -"download_setting_view.title" = "다운로드"; -"download_setting_view.section.title.download_queue" = "다운로드 대기열"; -"download_setting_view.section.title.network" = "네트워크"; -"download_setting_view.title.concurrent_image_downloads" = "동시 이미지 다운로드 수"; -"download_setting_view.title.retry_failed_pages_automatically" = "실패한 페이지 자동 재시도"; -"download_setting_view.title.allow_cellular_downloads" = "셀룰러 다운로드 허용"; -"download_setting_view.footer.network" = "한 번에 하나의 갤러리만 다운로드됩니다. 이 설정으로 한 갤러리 안에서 동시에 다운로드할 페이지 수, 셀룰러 다운로드 허용 여부, 그리고 파일을 앱의 Downloads 폴더에 저장하는 방식을 제어합니다."; -"enum.download_thread_mode.value.single" = "한 번에 1장 다운로드"; -"enum.download_thread_mode.value.double" = "한 번에 2장 다운로드"; -"enum.download_thread_mode.value.triple" = "한 번에 3장 다운로드"; -"enum.download_thread_mode.value.quadruple" = "한 번에 4장 다운로드"; -"enum.download_thread_mode.value.quintuple" = "한 번에 5장 다운로드"; -"struct.download_badge.text.queued" = "대기 중"; -"struct.download_badge.text.downloading" = "다운로드 중"; -"struct.download_badge.text.paused" = "일시 정지"; -"struct.download_badge.text.downloaded" = "다운로드됨"; -"struct.download_badge.text.needs_attention" = "조치 필요"; -"struct.download_badge.text.update_available" = "업데이트 가능"; -"struct.download_badge.text.needs_repair" = "복구 필요"; -"struct.download_badge.progress" = "%d/%d"; -"download_store.error.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "다운로드 폴더를 확인할 수 없습니다."; -"download_store.validation.download_folder_missing" = "다운로드 폴더가 없습니다."; -"download_store.validation.manifest_missing" = "매니페스트 파일이 없습니다."; -"download_store.validation.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; -"download_store.validation.downloaded_pages_incomplete" = "다운로드한 페이지가 불완전합니다."; -"download_store.validation.cover_image_missing" = "표지 이미지가 없습니다."; -"download_store.validation.page_missing" = "페이지 %d가 없습니다."; -"download_store.validation.cover_image_corrupted" = "표지 이미지 데이터가 손상되었습니다."; -"download_store.validation.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; +/* + Localizable.strings + EhPanda +*/ + +// MARK: BanInterval +"enum.ban_interval.description.and" = "and"; + +// MARK: ToplistsType +"enum.toplists_type.value.yesterday" = "어제"; +"enum.toplists_type.value.past_month" = "지난 달"; +"enum.toplists_type.value.past_year" = "지난 해"; +"enum.toplists_type.value.all_time" = "전체"; + +// MARK: Response +"website.response.hath_client_not_found" = "H@H 클라이언트를 아이디에 연동시킨 후 사용해주세요."; +"website.response.hath_client_not_online" = "H@H 클라이언트가 오프라인인 것 같네요. 클라이언트를 켜고 다시 시도해주세요."; +"website.response.invalid_resolution" = "이 콘텐츠는 선택한 해상도로 다운로드할 수 없어요."; + +// MARK: HUD +"hud.title.error" = "실패"; +"hud.title.success" = "성공"; +"hud.title.loading" = "로딩 중..."; +"hud.title.communicating" = "접속 중..."; +"hud.caption.copied_to_clipboard" = "클립보드에 복사되었어요"; +"hud.caption.saved_to_photo_library" = "이미지 저장"; + +// MARK: AutoLock +"local_authorization.reason" = "자동 잠금으로 앱이 잠겼어요."; + +// MARK: Common value +"common.value.stars" = "%@별"; +"common.value.pages" = "%@페이지"; +"common.value.times" = "%@번"; +"common.value.day" = "%@ day"; +"common.value.days" = "%@ days"; +"common.value.hour" = "%@ hour"; +"common.value.hours" = "%@ hours"; +"common.value.minute" = "%@ 분"; +"common.value.minutes" = "%@ 분"; +"common.value.second" = "%@ 초"; +"common.value.seconds" = "%@ 초"; +"common.value.records" = "%@ 기록수"; + +// MARK: TabItem +"tab_item.title.home" = "Home"; +"tab_item.title.favorites" = "즐겨찾기"; +"tab_item.title.search" = "검색"; +"tab_item.title.setting" = "설정"; + +// MARK: ToolbarItem +"toolbar_item.button.filters" = "필터"; +"toolbar_item.button.jump_page" = "페이지 이동"; +"toolbar_item.button.quick_search" = "빠른 검색"; + +// MARK: JumpPage +"jump_page_view.title.jump_page" = "페이지 이동"; +"jump_page_view.button.confirm" = "확인"; + +// MARK: AlertView +"loading_view.title.loading" = "로딩 중..."; +"loading_view.title.preparing_database" = "Preparing the database..."; +"not_login_view.title.need_login" = "You need to login to access this feature."; +"not_login_view.button.login" = "Login"; +"error_view.button.retry" = "재시도"; +"error_view.button.drop_database" = "Drop the database"; +"error_view.title.try_later" = "잠시 후 다시 시도해 주세요."; +"error_view.title.network" = "인터넷 접속 오류가 발생했어요."; +"error_view.title.parsing" = "구분 분석 오류가 발생했어요."; +"error_view.title.unknown" = "알 수 없는 오류가 발생했어요."; +"error_view.title.not_found" = "여기가 아무도 없는 것 같습니다."; +"error_view.title.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; +"error_view.title.ip_banned" = "자동화된 미러링/수집 소프트웨어를 사용 중임을 나타내는 과도한 페이지 로드로 인해 IP 주소가 일시적으로 금지되었습니다. 금지효과는 %@ 에서 만료되었습니다."; +"error_view.title.copyright_claim" = "%@의 저작권 요청으로 인하여 이 갤러리를 사용할 수 없어요."; +"error_view.title.gallery_unavailable" = "이 갤러리는 제거되었거나 사용할 수 없어요."; + +// MARK: ConfirmationDialog +"confirmation_dialog.title.drop_database" = "You will lose all your data in this app.\nAre you sure to drop the database?"; +"confirmation_dialog.title.remove_custom_translations" = "Are you sure to remove your custom translations?"; +"confirmation_dialog.title.logout" = "로그아웃 하시겠어요?"; +"confirmation_dialog.title.delete" = "Are you sure to delete this item?"; +"confirmation_dialog.title.clear" = "삭제하시겠어요?"; +"confirmation_dialog.title.reset" = "초기화하시겠어요?"; +"confirmation_dialog.button.drop_database" = "Drop the database"; +"confirmation_dialog.button.remove" = "Remove"; +"confirmation_dialog.button.logout" = "로그아웃"; +"confirmation_dialog.button.delete" = "삭제"; +"confirmation_dialog.button.clear" = "삭제"; +"confirmation_dialog.button.reset" = "초기화"; + +// MARK: SubSection +"sub_section.button.show_all" = "모두 보기"; + +// MARK: NewDawnView +"new_dawn_view.title.first" = "새로운 하루가 시작되었어요!"; +"new_dawn_view.title.second" = "지금까지의 여정을 돌이켜보면, 당신은 조금 더 현명해진 것 같죠?"; +// Greeting +"struct.greeting.mark.start" = ""; +"struct.greeting.mark.separator" = ", "; +"struct.greeting.mark.and" = " 과 "; +"struct.greeting.mark.end" = "획득했어요!"; + +// MARK: HomeView +"home_view.title.home" = "홈"; +"home_view.section.title.frontpage" = "프론트 페이지"; +"home_view.section.title.toplists" = "상위 목록"; +"home_view.section.title.other" = "Other"; +// HomeMiscGridType +"enum.home_misc_grid_type.title.popular" = "인기 작품"; +"enum.home_misc_grid_type.title.watched" = "주시 태그"; +"enum.home_misc_grid_type.title.history" = "읽은 목록"; + +// MARK: FrontpageView +"frontpage_view.title.frontpage" = "프론트 페이지"; + +// MARK: ToplistsView +"toplists_view.title.toplists" = "상위 목록"; + +// MARK: PopularView +"popular_view.title.popular" = "인기 작품"; + +// MARK: WatchedView +"watched_view.title.watched" = "주시 태그"; + +// MARK: HistoryView +"history_view.title.history" = "읽은 목록"; + +// MARK: FavoritesView +"favorites_view.title.favorites" = "즐겨찾기"; +// FavoriteCategory +"struct.user.favorite_category.default" = "즐겨찾기 %@"; +"struct.user.favorite_category.all" = "모두"; + +// MARK: SearchView +"search_view.title.search" = "검색"; +"search_view.section.title.recently_searched" = "Recently searched"; +"search_view.section.title.recently_seen" = "Recently seen"; +"search_view.section.title.quick_search" = "빠른 검색"; +// Searchable +"searchable.prompt.filter" = "Filter"; +"searchable.title.matches_count" = "Found %d matches."; + +// MARK: QuickSearchView +"quick_search_view.title.quick_search" = "빠른 검색"; +"quick_search_view.title.edit_word" = "Edit word"; +"quick_search_view.title.new_word" = "New word"; +"quick_search_view.title.content" = "Content"; +"quick_search_view.title.name" = "Name"; +"quick_search_view.placeholder.optional" = "Optional"; + +// MARK: SettingView +"setting_view.title.setting" = "설정"; +// SettingStateRoute +"enum.setting_state_route.value.account" = "계정"; +"enum.setting_state_route.value.general" = "일반"; +"enum.setting_state_route.value.appearance" = "외관"; +"enum.setting_state_route.value.reading" = "읽기"; +"enum.setting_state_route.value.download" = "다운로드"; +"enum.setting_state_route.value.laboratory" = "실험실"; +"enum.setting_state_route.value.about" = "About"; + +// MARK: AccountSettingView +"account_setting_view.title.account" = "계정"; +"account_setting_view.title.shows_new_dawn_greeting" = "새벽 인사 구독하기"; +"account_setting_view.button.login" = "로그인"; +"account_setting_view.button.logout" = "로그아웃"; +"account_setting_view.button.account_configuration" = "계정 설정"; +"account_setting_view.button.tags_management" = "태그 구독 관리"; +"account_setting_view.button.copy_cookies" = "쿠키 복사하기"; +// CookieValue +"struct.cookie_value.localized_string.expired" = "만료됨"; +"struct.cookie_value.localized_string.mystery" = "거절됨"; +"struct.cookie_value.localized_string.none" = "None"; + +// MARK: LoginView +"login_view.title.login" = "로그인"; +"login_view.title.username" = "이름"; +"login_view.title.password" = "비밀번호"; + +// MARK: GeneralSettingView +"general_setting_view.title.general" = "일반"; +"general_setting_view.title.language" = "언어"; +"general_setting_view.title.auto_lock" = "앱 자동 잠금"; +"general_setting_view.title.enables_tags_extension" = "Enables tags extension"; +"general_setting_view.title.translates_tags" = "태그 번역하기"; +"general_setting_view.title.shows_tags_search_suggestion" = "Shows tags search suggestion"; +"general_setting_view.title.shows_images_in_tags" = "Shows images in tags"; +"general_setting_view.title.redirects_links_to_the_selected_host" = "선택한 서버로 이동하기"; +"general_setting_view.title.detects_links_from_clipboard" = "클립보드의 링크 인식하기"; +"general_setting_view.title.background_blur_radius" = "Background blur radius"; +"general_setting_view.button.logs" = "로그"; +"general_setting_view.button.import_custom_translations" = "Import custom translations"; +"general_setting_view.button.remove_custom_translations" = "Remove custom translations"; +"general_setting_view.button.clear_image_caches" = "이미지 캐시 지우기"; +"general_setting_view.value.default_language_description" = "N/A"; +"general_setting_view.section.title.tags" = "Tags"; +"general_setting_view.section.title.navigation" = "내비게이션"; +"general_setting_view.section.title.security" = "개인 정보 보호"; +"general_setting_view.section.title.caches" = "캐시"; +// AutoLockPolicy +"enum.auto_lock_policy.value.never" = "안 함"; +"enum.auto_lock_policy.value.instantly" = "즉시"; + +// MARK: LogsView +"logs_view.title.logs" = "로그"; +"logs_view.title.latest" = "마지막"; + +// MARK: AppearanceSettingView +"appearance_setting_view.title.appearance" = "외관"; +"appearance_setting_view.title.theme" = "테마"; +"appearance_setting_view.title.tint_color" = "액센트 색상"; +"appearance_setting_view.title.display_mode" = "표시방식"; +"appearance_setting_view.title.shows_tags_in_list" = "리스트에서 태그 보여주기"; +"appearance_setting_view.title.maximum_number_of_tags" = "태그 갯수"; +"appearance_setting_view.title.displays_japanese_title" = "Displays Japanese title"; +"appearance_setting_view.button.app_icon" = "앱 아이콘"; +"appearance_setting_view.menu.title.infite" = "제한 없음"; +"appearance_setting_view.section.title.list" = "리스트"; +"appearance_setting_view.section.title.gallery" = "Gallery"; +// PreferredColorScheme +"enum.preferred_color_scheme.value.automatic" = "자동"; +"enum.preferred_color_scheme.value.light" = "라이트"; +"enum.preferred_color_scheme.value.dark" = "다크"; +// AppIconType +"enum.app_icon_type.value.default" = "기본"; +"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; +"enum.app_icon_type.value.developer" = "Developer"; +"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; +"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; +// ListDisplayMode +"enum.list_display_mode.value.detail" = "자세히"; +"enum.list_display_mode.value.thumbnail" = "썸네일"; + +// MARK: AppIconView +"app_icon_view.title.app_icon" = "앱 아이콘"; + +// MARK: reading_settingView +"reading_setting_view.title.reading" = "읽기"; +"reading_setting_view.title.direction" = "방향"; +"reading_setting_view.title.preload_limit" = "페이지 미리 로딩"; +"reading_setting_view.title.enables_landscape" = "Enables landscape"; +"reading_setting_view.title.separator_height" = "페이지 간 여백 두께"; +"reading_setting_view.title.maximum_scale_factor" = "최대 확대 비율"; +"reading_setting_view.title.double_tap_scale_factor" = "더블 탭 확대 비율"; +"reading_setting_view.section.title.appearance" = "외관"; +// ReadingDirection +"enum.reading_direction.value.vertical" = "위에서 아래로"; +"enum.reading_direction.value.right_to_left" = "오른쪽에서 왼쪽으로"; +"enum.reading_direction.value.left_to_right" = "왼쪽에서 오른쪽으로"; + +// MARK: LaboratorySettingView +"laboratory_setting_view.title.laboratory" = "실험실"; +"laboratory_setting_view.title.bypasses_SNI_filtering" = "SNI 차단 우회"; + +// MARK: AboutView +"about_view.title.ehPanda" = "EhPanda"; +"about_view.button.website" = "웹사이트"; +"about_view.button.altStore_source" = "AltStore 소스"; +"about_view.title.version" = "버전"; +"about_view.section.title.special_thanks" = "Special thanks"; +"about_view.section.title.code_level_contributors" = "Code-level contributors"; +"about_view.section.title.translation_contributors" = "Translation contributors"; +"about_view.section.title.acknowledgements" = "도움을 주신 분들"; + +// MARK: DetailView +"detail_view.button.download_login" = "로그인"; +"detail_view.button.download_get" = "받기"; +"detail_view.button.download_wait" = "대기"; +"detail_view.button.download_done" = "완료"; +"detail_view.button.download_update" = "업데이트"; +"detail_view.button.download_retry" = "재시도"; +"detail_view.button.download_repair" = "복구"; +"detail_view.button.read" = "읽기"; +"detail_view.button.post_comment" = "평가 남기기"; +"detail_view.accessibility.download_button.login" = "다운로드하려면 로그인해야 합니다"; +"detail_view.accessibility.download_button.download" = "다운로드"; +"detail_view.accessibility.download_button.queued" = "다운로드 대기 중"; +"detail_view.accessibility.download_button.downloading" = "%d / %d 페이지 다운로드 중"; +"detail_view.accessibility.download_button.downloaded" = "다운로드한 갤러리 삭제"; +"detail_view.accessibility.download_button.update" = "다운로드 업데이트"; +"detail_view.accessibility.download_button.retry" = "다운로드 다시 시도"; +"detail_view.accessibility.download_button.repair" = "다운로드 복구"; +"detail_view.accessibility.download_button.preparing" = "다운로드 정보를 불러오는 중"; +"detail_view.toolbar_item.button.archives" = "아카이브"; +"detail_view.toolbar_item.button.torrents" = "토렌트"; +"detail_view.toolbar_item.button.share" = "공유"; +"detail_view.context_menu.button.detail" = "Detail"; +"detail_view.context_menu.button.withdraw_vote" = "Withdraw vote"; +"detail_view.context_menu.button.vote_up" = "Vote up"; +"detail_view.context_menu.button.vote_down" = "Vote down"; +"detail_view.description_section.title.favorited" = "즐겨찾기"; +"detail_view.description_section.title.language" = "언어"; +"detail_view.description_section.title.ratings" = "%@명의 별점"; +"detail_view.description_section.title.page_count" = "페이지 수"; +"detail_view.description_section.title.file_size" = "파일 크기"; +"detail_view.description_section.description.favorited" = "번"; +"detail_view.description_section.description.page_count" = "페이지"; +"detail_view.action_section.button.give_a_rating" = "별점 주기"; +"detail_view.action_section.button.similar_gallery" = "비슷한 작품"; +"detail_view.section.title.previews" = "미리보기"; +"detail_view.section.title.comments" = "댓글"; + +// MARK: ArchivesView +"archives_view.title.archives" = "아카이브"; +"archives_view.button.download_to_hath_client" = "H@H 클라이언트로 저장"; +// HathArchive +"struct.hath_archive.price.free" = "무료"; +"struct.hath_archive.price.not_available" = "무효"; +// ArchiveResolution +"enum.archive_resolution.value.original" = "원본"; + +// MARK: TorrentsView +"torrents_view.title.torrents" = "토렌트"; + +// MARK: GalleryInfosView +"gallery_infos_view.title.gallery_infos" = "갤러리 정보"; +"gallery_infos_view.title.id" = "ID"; +"gallery_infos_view.title.token" = "Token"; +"gallery_infos_view.title.title" = "제목"; +"gallery_infos_view.title.japanese_title" = "일본어 제목"; +"gallery_infos_view.title.gallery_URL" = "갤러리 주소"; +"gallery_infos_view.title.cover_URL" = "표지 주소"; +"gallery_infos_view.title.archive_URL" = "아카이브 주소"; +"gallery_infos_view.title.torrent_URL" = "토렌트 주소"; +"gallery_infos_view.title.parent_URL" = "부모 갤러리 링크"; +"gallery_infos_view.title.category" = "장르"; +"gallery_infos_view.title.uploader" = "업로드"; +"gallery_infos_view.title.posted_date" = "업로드된 날짜"; +"gallery_infos_view.title.visibility" = "가시성"; +"gallery_infos_view.title.language" = "언어"; +"gallery_infos_view.title.page_count" = "페이지 수"; +"gallery_infos_view.title.file_size" = "파일 크기"; +"gallery_infos_view.title.favorited_times" = "즐겨찾기된 수"; +"gallery_infos_view.title.favorited" = "즐겨찾기에 저장 됨"; +"gallery_infos_view.title.rating_count" = "별점 갯수"; +"gallery_infos_view.title.average_rating" = "평균 별점"; +"gallery_infos_view.title.my_rating" = "My rating"; +"gallery_infos_view.title.torrent_count" = "토렌트 수"; +"gallery_infos_view.value.none" = "None"; +"gallery_infos_view.value.yes" = "네"; +"gallery_infos_view.value.no" = "아니요"; +// GalleryVisibility +"enum.gallery_visibility.value.yes" = "네"; +"enum.gallery_visibility.value.no" = "아니요 (%@)"; +"enum.gallery_visibility.value.no.reason.expunged" = "삭제됨"; + +// MARK: TagDetailView +"tag_detail_view.section.title.images" = "Images"; +"tag_detail_view.section.title.links" = "Links"; + +// MARK: CommentsView +"comments_view.title.comments" = "댓글"; + +// MARK: PostCommentView +"post_comment_view.title.post_comment" = "평가 남기기"; +"post_comment_view.title.edit_comment" = "평가 수정"; + +// MARK: PreviewsView +"previews_view.title.previews" = "미리보기"; + +// MARK: ReadingView +"reading_view.context_menu.button.reload" = "재시도"; +"reading_view.context_menu.button.copy" = "복사"; +"reading_view.context_menu.button.save" = "저장"; +"reading_view.context_menu.button.save_original" = "Save original"; +"reading_view.context_menu.button.share" = "공유"; +"reading_view.toolbar_item.title.auto_play" = "자동 재생"; +"reading_view.toolbar_item.title.dual_page_mode" = "두 장을 한 화면으로 보기"; +"reading_view.toolbar_item.title.except_the_cover" = "표지 제외하기"; +"reading_view.toolbar_item.button.retry_all_failed_images" = "Retry failed images"; +"reading_view.toolbar_item.button.reload_all_images" = "Reload all images"; +"reading_view.toolbar_item.button.reading_setting" = "Reading setting"; +// AutoPlayPolicy +"enum.auto_play_policy.value.off" = "Off"; + +// MARK: FiltersView +"filters_view.title.filters" = "필터"; +"filters_view.title.advanced_settings" = "고급 설정"; +"filters_view.title.search_gallery_name" = "갤러리 이름을 찾아보기"; +"filters_view.title.search_gallery_tags" = "갤러리 태그를 찾아보기"; +"filters_view.title.search_gallery_description" = "갤러리 설명을 찾아보기"; +"filters_view.title.search_torrent_filenames" = "토렌트 파일 이름을 찾아보기"; +"filters_view.title.only_show_galleries_with_torrents" = "토렌트 있는 갤러리만 보이기"; +"filters_view.title.search_low_power_tags" = "인기가 없는 태그를 찾아보기"; +"filters_view.title.search_downvoted_tags" = "낮은 평가의 태그를 찾아보기"; +"filters_view.title.search_expunged_galleries" = "삭제된 갤러리를 보여주기"; +"filters_view.title.set_minimum_rating" = "최소 별점 설정하기"; +"filters_view.title.minimum_rating" = "최소 별점"; +"filters_view.title.set_pages_range" = "페이지 범위 설정"; +"filters_view.title.pages_range" = "페이지 범위"; +"filters_view.title.disable_language_filter" = "언어 필터 끄기"; +"filters_view.title.disable_uploader_filter" = "업로더 필터 끄기"; +"filters_view.title.disable_tags_filter" = "태그 필터 끄기"; +"filters_view.button.reset_filters" = "모든 필터 초기화"; +"filters_view.section.title.advanced" = "고급"; +"filters_view.section.title.default_filter" = "기본 옵션"; +// FilterRange +"enum.filter_range.value.search" = "검색"; +"enum.filter_range.value.global" = "전체"; +"enum.filter_range.value.watched" = "주시 태그"; + +// MARK: EhSettingView +"eh_setting_view.title.host_settings" = "%@ 설정"; +"eh_setting_view.section.title.profile_settings" = "프로필 설정"; +"eh_setting_view.title.selected_profile" = "선택한 프로필"; +"eh_setting_view.button.set_as_default" = "기본으로 설정"; +"eh_setting_view.button.delete_profile" = "프로필 삭제"; +"eh_setting_view.button.rename" = "이름 변경"; +"eh_setting_view.button.create_new" = "추가"; +"eh_setting_view.toolbar_item.button.done" = "Done"; + +"eh_setting_view.section.title.image_load_settings" = "이미지 로드 설정"; +"eh_setting_view.title.load_images_through_the_hath_network" = "Hath 네트워크를 통하여 이미지 로드"; +"eh_setting_view.title.browsing_country" = "브라우징하는 나라"; +"eh_setting_view.description.browsing_country" = "**%@**에서 사이트를 탐색하거나 이 나라에서 VPN이나 프록시를 사용하려고 하는 것 같네요. 이런 경우엔 사이트에서 이 지역의 H@H 클라이언트의 이미지를 로드하려고 시도할 거에요. 만약에 이 나라가 잘못되었거나 분할 터널링 VPN을 사용하는 경우와 같이 어떤 이유로든 다른 지역을 사용하려는 경우라면, 아래에서 다른 나라를 선택할 수 있어요."; +// EhSetting.LoadThroughHathSetting +"enum.eh_setting.load_through_hath_setting.value.any_client" = "어떤 클라이언트에서든"; +"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "기본 포트 클라이언트만"; +"enum.eh_setting.load_through_hath_setting.value.modern_no" = "아닙니다 [Modern/HTTPS]"; +"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "아닙니다 [Legacy/HTTP]"; +"enum.eh_setting.load_through_hath_setting.description.any_client" = "추천."; +"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "더 느려질 수 있어요. 나가는 비표준 포트를 차단하는 방화벽/프록시가 있는 경우 사용하세요."; +"enum.eh_setting.load_through_hath_setting.description.modern_no" = "기부자 전용 기능이에요. 심각한 문제가 있는 경우를 제외하고는 사용하지 말아주세요."; +"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; + +"eh_setting_view.section.title.image_size_settings" = "이미지 사이즈 설정"; +"eh_setting_view.title.image_resolution" = "이미지 해상도"; +"eh_setting_view.description.image_resolution" = "일반적으로 이미지는 온라인 뷰어를 위해 1280 픽셀의 수평 해상도로 작아져요. 아래의 압축된 해상도 중 하나를 선택할 수 있어요. 서버 과부하를 막기 위해, 1280 이상의 해상도는 도네이션을 한 사람, hath perk를 가진 사람, 그리고 UID가 300만 이하인 사람들로 일시적으로 제한되어요."; +"eh_setting_view.title.image_size" = "이미지 사이즈"; +"eh_setting_view.description.image_size" = "사이트가 사용자의 화면 너비에 맞게 이미지를 자동으로 축소시키지만, 수동으로 크기를 정할 수도 있어요. 크기 조정은 브라우저 측에서 수행되므로 이미지가 다시 샘플링되지 않아요. (0 = no limit)"; +"eh_setting_view.title.horizontal" = "가로"; +"eh_setting_view.title.vertical" = "세로"; +// EhSetting.ImageResolution +"enum.eh_setting.image_resolution.value.auto" = "자동"; + +"eh_setting_view.section.title.gallery_name_display" = "갤러리 이름 보이기"; +"eh_setting_view.title.gallery_name" = "갤러리 이름"; +"eh_setting_view.description.gallery_name" = "영어 제목과 일본어 제목 중 기본값으로 보일 언어를 선택해주세요."; +// EhSetting.GalleryName +"enum.eh_setting.gallery_name.value.default" = "영어 제목"; +"enum.eh_setting.gallery_name.value.japanese" = "일본어 제목(가능하면)"; + +"eh_setting_view.section.title.archiver_settings" = "아카이버"; +"eh_setting_view.title.archiver_behavior" = "아카이버 동작 방법 설정"; +"eh_setting_view.description.archiver_behavior" = "아카이버의 기본 동작은 원본 또는 저화질 갤러리 저장에 대한 비용과 선택을 확인한 다음 다른 곳에서 클릭하거나 복사할 수 있는 링크를 표시하는 것입니다. 여기서 이 동작을 변경할 수 있습니다."; +// EhSetting.ArchiverBehavior +"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "수동 선택, 수동 시작 (기본)"; +"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "수동 선택, 자동 시작"; +"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "자동으로 원본을 선택, 수동 시작"; +"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "자동으로 원본을 선택, 자동 시작"; +"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "자동으로 저화질을 선택, 수동 시작"; +"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "자동으로 저화질을 선택, 자동 시작"; + +"eh_setting_view.section.title.front_page_settings" = "프론트 페이지 설정"; +"eh_setting_view.title.display_mode" = "표시방식"; +"eh_setting_view.description.display_mode" = "프론트와 검색 페이지에서 사용할 디스플레이 모드를 선택하세요."; +"eh_setting_view.section.title.show_search_range_indicator" = "Search Range Indicator"; +"eh_setting_view.title.show_search_range_indicator" = "Show search range indicator"; +"eh_setting_view.description.gallery_category" = "프론트와 검색 페이지에서 어떤 카테고리가 보여지도록 할까요?"; +// EhSetting.DisplayMode +"enum.eh_setting.display_mode.value.compact" = "Compact"; +"enum.eh_setting.display_mode.value.thumbnail" = "Thumbnail"; +"enum.eh_setting.display_mode.value.extended" = "Extended"; +"enum.eh_setting.display_mode.value.minimal" = "Minimal"; +"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; + +"eh_setting_view.section.title.optional_UI_elements" = "Optional UI Elements"; +"eh_setting_view.description.optional_UI_elements" = "Some historic UI elements are now disabled by default. You can enable those here."; +"eh_setting_view.title.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; + +"eh_setting_view.section.title.favorites" = "즐겨찾기"; +"eh_setting_view.description.favorite_categories" = "여기서 좋아하는 장르들을 선택하고 이름을 바꿀 수 있어요."; +"eh_setting_view.title.favorites_sort_order" = "관심 순서를 배열"; +"eh_setting_view.description.favorites_sort_order" = "당신의 관심 페이지의 기본 정렬 방식을 선택할 수 있어요. 2016년 3월 개정 전에 추가된 즐겨찾기는 타임스탬프가 저장되지 않아 이 설정에 관계없이 갤러리가 게시된 시간으로 정렬되어요."; +// EhSetting.FavoritesSortOrder +"enum.eh_setting.favorites_sort_order.value.last_update_time" = "마지막 업데이트 시간으로"; +"enum.eh_setting.favorites_sort_order.value.favorited_time" = "별점 시간으로"; + +"eh_setting_view.section.title.ratings" = "별점"; +"eh_setting_view.title.ratings_color" = "별점 색깔"; +"eh_setting_view.promt.ratings_color" = "RRGGB"; +"eh_setting_view.description.ratings_color" = "기본적으로 등급을 매긴 갤러리는 별 2개 이하의 등급에 대해 빨간색, 2.5~4개의 등급에 대해 녹색, 4.5~5개의 등급에 대해 파란색 별로 표시되어요. 아래에 원하는 색상 조합을 입력하여 사용자 정의할 수 있어요. 각 문자는 별 하나를 표현해요. 기본 RRGGB는 첫 번째와 두 번째 별의 경우 R(ed), 세 번째와 네 번째 별의 경우 G(reen), 다섯 번째 별의 경우 B(lue)를 의미해요. 일반 별에 (Y)ellow를 사용할 수도 있어요. 모든 5글자의 R/G/B/Y 콤보가 작동해요."; + +"eh_setting_view.section.title.tag_filtering_threshold" = "태그 필터링 임계값"; +"eh_setting_view.title.tag_filtering_threshold" = "태그 필터링 임계값"; +"eh_setting_view.description.tag_filtering_threshold" = "마이너스 가중치로 My Tags에 추가하여 태그를 소프트 필터할 수 있어요. 갤러리에 이 값 이하의 가중치를 추가하는 태그가 있으면 보기에서 필터링되어요. 이 임계값은 0과 -9999 사이에서 설정할 수 있어요."; + +"eh_setting_view.section.title.tag_watching_threshold" = "태그 보여주기 임계값"; +"eh_setting_view.title.tag_watching_threshold" = "태그 보여주기 임계값"; +"eh_setting_view.description.tag_watching_threshold" = "최근에 업로드된 갤러리는 최소 1개의 Watched 태그가 있고 Watched 태그의 가중치의 합이 이 값 이상이 될 경우 Watched 화면에 포함되어요. 이 임계값은 0과 9999 사이에서 설정할 수 있어요."; + +"eh_setting_view.section.title.filtered_removal_count" = "Show Filtered Removal Count"; +"eh_setting_view.description.filtered_removal_count" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; +"eh_setting_view.title.show_filtered_removal_count" = "Show filtered removal count"; + +"eh_setting_view.section.title.excluded_languages" = "제외된 언어"; +"eh_setting_view.description.excluded_languages" = "갤러리 목록에서 특정 언어로 된 갤러리를 숨기고 검색하려면 아래 목록에서 해당 갤러리를 선택해주세요. 검색어에 관계없이 일치하는 갤러리는 나타나지 않아요."; +// EhSetting.ExcludedLanguagesCategory +"enum.eh_setting.excluded_languages_category.value.original" = "원본"; +"enum.eh_setting.excluded_languages_category.value.translated" = "번역됨"; +"enum.eh_setting.excluded_languages_category.value.rewrite" = "다시 쓰기"; + +"eh_setting_view.section.title.excluded_uploaders" = "제외된 업로드"; +"eh_setting_view.description.excluded_uploaders" = "갤러리 목록 및 검색에서 특정 업로더의 갤러리를 숨기려면 아래에 해당 갤러리를 추가해주세요. 한 줄에 하나의 사용자 이름을 입력해주세요. 이러한 업로더의 갤러리는 검색 쿼리에 관계없이 나타나지 않아요."; +"eh_setting_view.description.excluded_uploaders_count" = "**%@ / %@** 개의 슬롯을 사용하고 있어요."; + +"eh_setting_view.section.title.search_result_count" = "검색 결과 수"; +"eh_setting_view.title.result_count" = "결과 수"; +"eh_setting_view.description.result_count" = "인덱스 / 검색 / 토렌트 검색 페이지에 대해 페이지당 몇 개의 결과를 원하시나요?\n(Hath Perk: 페이징 확장 필요)"; + +"eh_setting_view.section.title.thumbnail_settings" = "썸네일 설정"; +"eh_setting_view.title.thumbnail_load_timing" = "썸네일 로드 시간"; +"eh_setting_view.description.thumbnail_load_timing" = "목록 모드를 사용할 때 앞 페이지의 마우스 오버 미리 보기를 어떻게 로드할까요?"; +"eh_setting_view.description.thumbnail_configuration" = "모든 방문한 갤러리에 대하여 기본 썸네일을 설정할 수 있어요."; +"eh_setting_view.title.thumbnail_size" = "사이즈"; +"eh_setting_view.title.thumbnail_row_count" = "줄"; +// EhSetting.ThumbnailLoadTiming +"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "마우스를 올릴 때"; +"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "페이지 로드될 때"; +"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "페이지가 더 빨리 로드되지만 엄지손가락이 나타나기 전까지 약간의 지연이 있을 수 있어요."; +"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "페이지 로드에 시간이 더 오래 걸리지만, 페이지가 로드된 후 썸네일을 로드하는데 지연이 없어요."; +// EhSetting.ThumbnailSize +"enum.eh_setting.thumbnail_size.value.normal" = "보통"; +"enum.eh_setting.thumbnail_size.value.large" = "크게"; +"enum.eh_setting.thumbnail_size.value.small" = "Small"; +"enum.eh_setting.thumbnail_size.value.auto" = "Auto"; + +"eh_setting_view.section.title.cover_scaling" = "Cover Scaling"; +"eh_setting_view.title.scale_factor" = "크기 비율"; +"eh_setting_view.description.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; + +"eh_setting_view.section.title.viewport_override" = "뷰포트 조정"; +"eh_setting_view.title.virtual_width" = "가상 너비"; +"eh_setting_view.description.virtual_width" = "모바일 장치의 사이트 가상 너비를 설정할 수 있어요. 일반적으로 DPI에 따라 장치에 의해 자동으로 결정되어요. 100%% 썸네일 스케일의 추천 값은 640에서 1400 사이에요."; + +"eh_setting_view.section.title.gallery_comments" = "갤러리 댓글"; +"eh_setting_view.title.comments_sort_order" = "댓글 순서"; +"eh_setting_view.title.comments_votes_show_timing" = "평가의 시간을 보이기"; +// EhSetting.CommentsSortOrder +"enum.eh_setting.comments_sort_order.value.oldest" = "가장 이른 순서"; +"enum.eh_setting.comments_sort_order.value.recent" = "최신순"; +"enum.eh_setting.comments_sort_order.value.highest_score" = "평가가 가장 높은 순서"; +// EhSetting.CommentVotesShowTiming +"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "점수를 가리키커나 클리하기"; +"enum.eh_setting.comments_votes_show_timing.value.always" = "항상"; + +"eh_setting_view.section.title.gallery_tags" = "갤러리 태그"; +"eh_setting_view.title.tags_sort_order" = "태그 순서를 배열"; +// EhSetting.tags_sort_order +"enum.eh_setting.tags_sort_order.value.alphabetical" = "알파벳순으로"; +"enum.eh_setting.tags_sort_order.value.tag_power" = "태크 가중치로"; + +"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; +"eh_setting_view.title.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; + +"eh_setting_view.section.title.hath_local_network_host" = "Hath 로컬 네트워크 호스트"; +"eh_setting_view.title.ip_address_port" = "IP주소:포트"; +"eh_setting_view.description.ip_address_port" = "이 설정은 사이트를 검색하는 것과 동일한 공용 IP로 로컬 네트워크에서 H@H 클라이언트를 실행하는 경우 사용할 수 있습니다. 일부 라우터는 버그가 있어 요청을 자신의 IP로 다시 라우팅할 수 없기에, 아래를 따라서 이 문제를 해결할 수 있습니다.\n찾아보는 동일한 장치에서 클라이언트를 실행하는 경우 루프백 주소(127.0.0.1:port)를 사용할 수 있습니다. 클라이언트가 네트워크의 다른 장치에서 실행 중인 경우 로컬 네트워크 IP를 사용할 수 있습니다. 일부 브라우저 구성에서는 외부 웹 사이트가 로컬 네트워크 IP가 있는 URL에 액세스할 수 없도록 합니다. 그런 다음 사이트가 작동하려면 사이트를 화이트리스트에 추가해야 합니다."; + +"eh_setting_view.section.title.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; +"eh_setting_view.title.use_original_images" = "원본 뷰어 적용"; + +"eh_setting_view.section.title.multi_page_viewer" = "멀티 페이지 뷰어"; +"eh_setting_view.title.use_multi_page_viewer" = "다중 페이지 뷰어 적용"; +"eh_setting_view.title.display_style" = "보여주기 스타일"; +"eh_setting_view.title.show_thumbnail_pane" = "썸네일 창 표시"; +// EhSetting.MultiplePageViewerStyle +"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "왼쪽 정렬, 너비 초과할 때 크기 맞추기"; +"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "가운데 정렬, 너비 초과할 때 크기 맞추기"; +"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "가운데 정렬, 항상 크기 맞추기"; +// EhSetting.GalleryPageNumbering +"enum.eh_setting.gallery_page_numbering.value.none" = "None"; +"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "Page Number Only"; +"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "Page Number + Name"; + +// MARK: Category +"enum.category.value.doujinshi" = "동인지"; +"enum.category.value.manga" = "만화"; +"enum.category.value.artist_CG" = "일러스트"; +"enum.category.value.game_CG" = "게임 CG"; +"enum.category.value.western" = "서양"; +"enum.category.value.non_h" = "Non-H"; +"enum.category.value.image_set" = "포토북"; +"enum.category.value.cosplay" = "코스프레"; +"enum.category.value.asian_porn" = "Asian Porn"; +"enum.category.value.misc" = "기타"; +"enum.category.value.private" = "Private"; + +// MARK: TagNamespace +"enum.tag_namespace.value.reclass" = "재분류"; +"enum.tag_namespace.value.language" = "언어"; +"enum.tag_namespace.value.parody" = "원작"; +"enum.tag_namespace.value.character" = "캐릭터"; +"enum.tag_namespace.value.group" = "그룹"; +"enum.tag_namespace.value.artist" = "작가"; +"enum.tag_namespace.value.male" = "남성"; +"enum.tag_namespace.value.female" = "여성"; +"enum.tag_namespace.value.mixed" = "Mixed"; +"enum.tag_namespace.value.cosplayer" = "Cosplayer"; +"enum.tag_namespace.value.other" = "Other"; +"enum.tag_namespace.value.temp" = "Temp"; + +// MARK: Language +"enum.language.value.invalid" = "무효"; +"enum.language.value.other" = "Other"; +"enum.language.value.afrikaans" = "아프리칸스어"; +"enum.language.value.albanian" = "알바니아어"; +"enum.language.value.arabic" = "아랍어"; +"enum.language.value.bengali" = "벵갈어"; +"enum.language.value.bosnian" = "보스니아어"; +"enum.language.value.bulgarian" = "불가리아어"; +"enum.language.value.burmese" = "버마어"; +"enum.language.value.catalan" = "카탈루냐어"; +"enum.language.value.cebuano" = "세부어"; +"enum.language.value.chinese" = "중국어"; +"enum.language.value.croatian" = "크로아티아어"; +"enum.language.value.czech" = "체코어"; +"enum.language.value.danish" = "덴마크어"; +"enum.language.value.dutch" = "네덜란드어"; +"enum.language.value.english" = "영어"; +"enum.language.value.esperanto" = "국제어"; +"enum.language.value.estonian" = "에스토니아어"; +"enum.language.value.finnish" = "핀란드어"; +"enum.language.value.french" = "프랑스어"; +"enum.language.value.georgian" = "그루지야어"; +"enum.language.value.german" = "독일어"; +"enum.language.value.greek" = "그리스어"; +"enum.language.value.hebrew" = "히브리어"; +"enum.language.value.hindi" = "힌디어"; +"enum.language.value.hmong" = "묘어"; +"enum.language.value.hungarian" = "헝가리어"; +"enum.language.value.indonesian" = "인도네시아어"; +"enum.language.value.italian" = "이탈리아어"; +"enum.language.value.japanese" = "일본어"; +"enum.language.value.kazakh" = "카자흐어"; +"enum.language.value.khmer" = "크메르원"; +"enum.language.value.korean" = "한국어"; +"enum.language.value.kurdish" = "쿠르드어"; +"enum.language.value.lao" = "라오스어"; +"enum.language.value.latin" = "라틴어"; +"enum.language.value.mongolian" = "몽골어"; +"enum.language.value.ndebele" = "은데벨리어"; +"enum.language.value.nepali" = "네팔어"; +"enum.language.value.norwegian" = "노르웨이어로"; +"enum.language.value.oromo" = "오로모어"; +"enum.language.value.pashto" = "파슈토어"; +"enum.language.value.persian" = "페르시아어"; +"enum.language.value.polish" = "폴란드어"; +"enum.language.value.portuguese" = "포르투갈어"; +"enum.language.value.punjabi" = "펀자브어"; +"enum.language.value.romanian" = "루마니아어"; +"enum.language.value.russian" = "러시아어"; +"enum.language.value.sango" = "쌍고어"; +"enum.language.value.serbian" = "세르비아어"; +"enum.language.value.shona" = "쇼나어"; +"enum.language.value.slovak" = "슬로바키아어"; +"enum.language.value.slovenian" = "슬로베니아어"; +"enum.language.value.somali" = "소말리아어"; +"enum.language.value.spanish" = "스페인어"; +"enum.language.value.swahili" = "스와히리어로"; +"enum.language.value.swedish" = "스웨덴어"; +"enum.language.value.tagalog" = "타갈로어"; +"enum.language.value.thai" = "타이어"; +"enum.language.value.tigrinya" = "티글리니아어"; +"enum.language.value.turkish" = "터키어"; +"enum.language.value.ukrainian" = "우크라이나어"; +"enum.language.value.urdu" = "우르두어"; +"enum.language.value.vietnamese" = "베트남어"; +"enum.language.value.zulu" = "줄루어"; + +// MARK: BrowsingCountry +"enum.browsing_country.name.auto_detect" = "자동으로 설정"; +"enum.browsing_country.name.afghanistan" = "아프가니스탄"; +"enum.browsing_country.name.aland_islands" = "알란드 제도"; +"enum.browsing_country.name.albania" = "알바니아"; +"enum.browsing_country.name.algeria" = "알제리아"; +"enum.browsing_country.name.american_samoa" = "아메리칸 사모아"; +"enum.browsing_country.name.andorra" = "안도라"; +"enum.browsing_country.name.angola" = "앙골라"; +"enum.browsing_country.name.anguilla" = "안젤라"; +"enum.browsing_country.name.antarctica" = "남극"; +"enum.browsing_country.name.antigua_and_barbuda" = "앤티가 바부다"; +"enum.browsing_country.name.argentina" = "아르헨티나"; +"enum.browsing_country.name.armenia" = "아르메니아"; +"enum.browsing_country.name.aruba" = "아루바 섬"; +"enum.browsing_country.name.asia_pacific_region" = "아시아 태평양 영역"; +"enum.browsing_country.name.australia" = "호주"; +"enum.browsing_country.name.austria" = "오스트리아"; +"enum.browsing_country.name.azerbaijan" = "아제르바이잔"; +"enum.browsing_country.name.bahamas" = "바하마스"; +"enum.browsing_country.name.bahrain" = "바레인"; +"enum.browsing_country.name.bangladesh" = "방글라데시"; +"enum.browsing_country.name.barbados" = "바베이도스"; +"enum.browsing_country.name.belarus" = "벨라루스"; +"enum.browsing_country.name.belgium" = "벨기에"; +"enum.browsing_country.name.belize" = "벨리즈"; +"enum.browsing_country.name.benin" = "베냉"; +"enum.browsing_country.name.bermuda" = "버뮤다"; +"enum.browsing_country.name.bhutan" = "부탄"; +"enum.browsing_country.name.bolivia" = "볼리비아"; +"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "보네르 성 유스타티우스와 사바"; +"enum.browsing_country.name.bosnia_and_herzegovina" = "보스니아 헤르체코비나 "; +"enum.browsing_country.name.botswana" = "보츠와나"; +"enum.browsing_country.name.bouvet_island" = "부베섬"; +"enum.browsing_country.name.brazil" = "브라질"; +"enum.browsing_country.name.british_indian_ocean_territory" = "영국령 인도양 식민지"; +"enum.browsing_country.name.brunei_darussalam" = "브루나이 다루살람"; +"enum.browsing_country.name.bulgaria" = "불가리아"; +"enum.browsing_country.name.burkina_faso" = "부르키나 파소"; +"enum.browsing_country.name.burundi" = "부룬디"; +"enum.browsing_country.name.cambodia" = "캄보디아"; +"enum.browsing_country.name.cameroon" = "카메룬"; +"enum.browsing_country.name.canada" = "캐나다"; +"enum.browsing_country.name.cape_verde" = "포르투갈어"; +"enum.browsing_country.name.cayman_islands" = "케이맨 제도"; +"enum.browsing_country.name.central_african_republic" = "중앙아프리카 공화국"; +"enum.browsing_country.name.chad" = "차드"; +"enum.browsing_country.name.chile" = "칠레"; +"enum.browsing_country.name.china" = "중국"; +"enum.browsing_country.name.christmas_island" = "크리스마스 섬"; +"enum.browsing_country.name.cocos_islands" = "코코스 제도"; +"enum.browsing_country.name.colombia" = "콜롬비아"; +"enum.browsing_country.name.comoros" = "코모로"; +"enum.browsing_country.name.congo" = "콩고"; +"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "콩고민주공화국"; +"enum.browsing_country.name.cook_islands" = "쿡제도"; +"enum.browsing_country.name.costa_rica" = "코스타리카"; +"enum.browsing_country.name.cote_d_ivoire" = "코트디부아르"; +"enum.browsing_country.name.croatia" = "크로아티아"; +"enum.browsing_country.name.cuba" = "쿠바"; +"enum.browsing_country.name.curacao" = "큐라소"; +"enum.browsing_country.name.cyprus" = "키프로스"; +"enum.browsing_country.name.czech_republic" = "체코 공화국"; +"enum.browsing_country.name.denmark" = "덴마크"; +"enum.browsing_country.name.djibouti" = "지부티"; +"enum.browsing_country.name.dominica" = "도미니카"; +"enum.browsing_country.name.dominican_republic" = "도미니카 공화국"; +"enum.browsing_country.name.ecuador" = "에콰도르"; +"enum.browsing_country.name.egypt" = "이집트"; +"enum.browsing_country.name.el_salvador" = "엘살바도르"; +"enum.browsing_country.name.equatorial_guinea" = "적도 기니"; +"enum.browsing_country.name.eritrea" = "에리트레아"; +"enum.browsing_country.name.estonia" = "에스토니아"; +"enum.browsing_country.name.ethiopia" = "에티오피아"; +"enum.browsing_country.name.europe" = "유럽"; +"enum.browsing_country.name.falkland_islands" = "포클랜드 제도"; +"enum.browsing_country.name.faroe_islands" = "페로스 제도"; +"enum.browsing_country.name.fiji" = "피지"; +"enum.browsing_country.name.finland" = "핀란드"; +"enum.browsing_country.name.france" = "프랑스"; +"enum.browsing_country.name.french_guiana" = "프랑스령 기아나"; +"enum.browsing_country.name.french_polynesia" = "프랑스령 폴리네시아"; +"enum.browsing_country.name.french_southern_territories" = "프랑스령 남부와 남극지역"; +"enum.browsing_country.name.gabon" = "가봉"; +"enum.browsing_country.name.gambia" = "감비아"; +"enum.browsing_country.name.georgia" = "그루지야"; +"enum.browsing_country.name.germany" = "독일"; +"enum.browsing_country.name.ghana" = "가나"; +"enum.browsing_country.name.gibraltar" = "지브롤터"; +"enum.browsing_country.name.greece" = "희랍"; +"enum.browsing_country.name.greenland" = "그린란드"; +"enum.browsing_country.name.grenada" = "그레나다"; +"enum.browsing_country.name.guadeloupe" = "과들루프 섬"; +"enum.browsing_country.name.guam" = "괌"; +"enum.browsing_country.name.guatemala" = "과테말라"; +"enum.browsing_country.name.guernsey" = "건지종 젖소"; +"enum.browsing_country.name.guinea" = "기니"; +"enum.browsing_country.name.guinea_bissau" = "기니비사우"; +"enum.browsing_country.name.guyana" = "가이아나"; +"enum.browsing_country.name.haiti" = "아이티"; +"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "허드 맥도널드 제도"; +"enum.browsing_country.name.vatican_city_state" = "바티칸 시국"; +"enum.browsing_country.name.honduras" = "온두라스"; +"enum.browsing_country.name.hong_kong" = "홍콩"; +"enum.browsing_country.name.hungary" = "헝가리"; +"enum.browsing_country.name.iceland" = "Iceland"; +"enum.browsing_country.name.india" = "인도"; +"enum.browsing_country.name.indonesia" = "인도네시아"; +"enum.browsing_country.name.iran" = "이란"; +"enum.browsing_country.name.iraq" = "이라크"; +"enum.browsing_country.name.ireland" = "아일랜드"; +"enum.browsing_country.name.isle_of_man" = "맨 섬"; +"enum.browsing_country.name.israel" = "이스라엘"; +"enum.browsing_country.name.italy" = "이탈리아"; +"enum.browsing_country.name.jamaica" = "자마이카"; +"enum.browsing_country.name.japan" = "일본"; +"enum.browsing_country.name.jersey" = "저시"; +"enum.browsing_country.name.jordan" = "요단"; +"enum.browsing_country.name.kazakhstan" = "카자흐스탄"; +"enum.browsing_country.name.kenya" = "케냐"; +"enum.browsing_country.name.kiribati" = "키리바시"; +"enum.browsing_country.name.kuwait" = "쿠웨이트"; +"enum.browsing_country.name.kyrgyzstan" = "키르기스스탄"; +"enum.browsing_country.name.lao_peoples_democratic_republic" = "라오 인민민주공화국"; +"enum.browsing_country.name.latvia" = "라트비아"; +"enum.browsing_country.name.lebanon" = "레바논"; +"enum.browsing_country.name.lesotho" = "레소토"; +"enum.browsing_country.name.liberia" = "리베리아"; +"enum.browsing_country.name.libya" = "리비아"; +"enum.browsing_country.name.liechtenstein" = "리히텐슈타인"; +"enum.browsing_country.name.lithuania" = "리투아니아"; +"enum.browsing_country.name.luxembourg" = "룩셈부르크"; +"enum.browsing_country.name.macau" = "마카오"; +"enum.browsing_country.name.macedonia" = "마케도니아"; +"enum.browsing_country.name.madagascar" = "마다스카르"; +"enum.browsing_country.name.malawi" = "말라위"; +"enum.browsing_country.name.malaysia" = "말레이시아"; +"enum.browsing_country.name.maldives" = "말디브"; +"enum.browsing_country.name.mali" = "말리"; +"enum.browsing_country.name.malta" = "말타"; +"enum.browsing_country.name.marshall_islands" = "마샬군도"; +"enum.browsing_country.name.martinique" = "마르티니크"; +"enum.browsing_country.name.mauritania" = "모리타니아"; +"enum.browsing_country.name.mauritius" = "모리셔스"; +"enum.browsing_country.name.mayotte" = "마요트 섬"; +"enum.browsing_country.name.mexico" = "맥시코"; +"enum.browsing_country.name.micronesia" = "마크로네시아"; +"enum.browsing_country.name.moldova" = "몰도바"; +"enum.browsing_country.name.monaco" = "모나코"; +"enum.browsing_country.name.mongolia" = "몽콜"; +"enum.browsing_country.name.montenegro" = "몬테네그로"; +"enum.browsing_country.name.montserrat" = "몬트세라트섬"; +"enum.browsing_country.name.morocco" = "모로코가족"; +"enum.browsing_country.name.mozambique" = "모잠비크"; +"enum.browsing_country.name.myanmar" = "미얀마"; +"enum.browsing_country.name.namibia" = "나미비아"; +"enum.browsing_country.name.nauru" = "나우루"; +"enum.browsing_country.name.nepal" = "네팔"; +"enum.browsing_country.name.netherlands" = "네덜란드"; +"enum.browsing_country.name.new_caledonia" = "뉴칼레도니아"; +"enum.browsing_country.name.new_zealand" = "뉴질랜드"; +"enum.browsing_country.name.nicaragua" = "나카라과"; +"enum.browsing_country.name.niger" = "니제르"; +"enum.browsing_country.name.nigeria" = "나이지리아"; +"enum.browsing_country.name.niue" = "니우에 섬"; +"enum.browsing_country.name.norfolk_island" = "노퍽섬"; +"enum.browsing_country.name.north_korea" = "북한"; +"enum.browsing_country.name.northern_mariana_islands" = "북마리아나제도"; +"enum.browsing_country.name.norway" = "노르웨이"; +"enum.browsing_country.name.oman" = "오만"; +"enum.browsing_country.name.pakistan" = "파키스탄"; +"enum.browsing_country.name.palau" = "팔라우"; +"enum.browsing_country.name.palestinian_territory" = "팔레스타인의 지역"; +"enum.browsing_country.name.panama" = "파나마모자"; +"enum.browsing_country.name.papua_new_guinea" = "파푸아뉴기니"; +"enum.browsing_country.name.paraguay" = "파라과이"; +"enum.browsing_country.name.peru" = "페루"; +"enum.browsing_country.name.philippines" = "필리핀"; +"enum.browsing_country.name.pitcairn_islands" = "핏케언 제도"; +"enum.browsing_country.name.poland" = "폴란드"; +"enum.browsing_country.name.portugal" = "포르투갈"; +"enum.browsing_country.name.puerto_rico" = "푸에르토리코"; +"enum.browsing_country.name.qatar" = "카타로"; +"enum.browsing_country.name.reunion" = "레워니옹"; +"enum.browsing_country.name.romania" = "루마니아"; +"enum.browsing_country.name.russian_federation" = "러시아 연방"; +"enum.browsing_country.name.rwanda" = "르완다"; +"enum.browsing_country.name.saint_barthelemy" = "생바르텔레미"; +"enum.browsing_country.name.saint_helena" = "세인츠헬레나 섬"; +"enum.browsing_country.name.saint_kitts_and_nevis" = "세인트키츠네비스"; +"enum.browsing_country.name.saint_lucia" = "세인트루시아"; +"enum.browsing_country.name.saint_martin" = "세인트 마틴"; +"enum.browsing_country.name.saint_pierre_and_miquelon" = "생피에르 미글롱"; +"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "세인트빈센트 그레나딘"; +"enum.browsing_country.name.samoa" = "사모아"; +"enum.browsing_country.name.san_marino" = "산마리노"; +"enum.browsing_country.name.sao_tome_and_principe" = "상투메 프린시페 도브라"; +"enum.browsing_country.name.saudi_arabia" = "사우디 아라비아"; +"enum.browsing_country.name.senegal" = "세네갈"; +"enum.browsing_country.name.serbia" = "세르비아"; +"enum.browsing_country.name.seychelles" = "세이셸"; +"enum.browsing_country.name.sierra_leone" = "시에라리온"; +"enum.browsing_country.name.singapore" = "싱가포르"; +"enum.browsing_country.name.sint_maarten" = "신트마르턴"; +"enum.browsing_country.name.slovakia" = "슬로바키아"; +"enum.browsing_country.name.slovenia" = "슬로베니아"; +"enum.browsing_country.name.solomon_islands" = "솔로몬 제도"; +"enum.browsing_country.name.somalia" = "소말리아"; +"enum.browsing_country.name.south_africa" = "남아프리카"; +"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "사우스조지아 사우스샌드위치 제도"; +"enum.browsing_country.name.south_korea" = "한국"; +"enum.browsing_country.name.south_sudan" = "남수단"; +"enum.browsing_country.name.spain" = "스페인"; +"enum.browsing_country.name.sri_lanka" = "스리랑카"; +"enum.browsing_country.name.sudan" = "수단"; +"enum.browsing_country.name.suriname" = "수리남"; +"enum.browsing_country.name.svalbard_and_jan_mayen" = "스발바르 얀마옌 제도"; +"enum.browsing_country.name.swaziland" = "스와질란드"; +"enum.browsing_country.name.sweden" = "스웨덴"; +"enum.browsing_country.name.switzerland" = "스위스"; +"enum.browsing_country.name.syrian_arab_republic" = "시리아"; +"enum.browsing_country.name.taiwan" = "대만"; +"enum.browsing_country.name.tajikistan" = "타지키스탄"; +"enum.browsing_country.name.tanzania" = "탄지니아"; +"enum.browsing_country.name.thailand" = "태국"; +"enum.browsing_country.name.timor_leste" = "동티모르"; +"enum.browsing_country.name.togo" = "토고"; +"enum.browsing_country.name.tokelau" = "토켈라우"; +"enum.browsing_country.name.tonga" = "통가"; +"enum.browsing_country.name.trinidad_and_tobago" = "트리니다드토바고"; +"enum.browsing_country.name.tunisia" = "튀니지"; +"enum.browsing_country.name.turkey" = "터키"; +"enum.browsing_country.name.turkmenistan" = "투르크메니스탄"; +"enum.browsing_country.name.turks_and_caicos_islands" = "터크스카이코스 제도"; +"enum.browsing_country.name.tuvalu" = "투발루"; +"enum.browsing_country.name.uganda" = "우간다"; +"enum.browsing_country.name.ukraine" = "우크라이나"; +"enum.browsing_country.name.united_arab_emirates" = "아랍 에미리트 연합국"; +"enum.browsing_country.name.united_kingdom" = "영국"; +"enum.browsing_country.name.united_states" = "미국"; +"enum.browsing_country.name.united_states_minor_outlying_islands" = "미국령 군소 제도"; +"enum.browsing_country.name.uruguay" = "우루과이"; +"enum.browsing_country.name.uzbekistan" = "우즈베키스탄"; +"enum.browsing_country.name.vanuatu" = "바누어투"; +"enum.browsing_country.name.venezuela" = "베네수엘라"; +"enum.browsing_country.name.vietnam" = "베트남"; +"enum.browsing_country.name.virgin_islands_british" = "영국령 버진 제도"; +"enum.browsing_country.name.virgin_islands_US" = "세인트존 섬"; +"enum.browsing_country.name.wallis_and_futuna" = "월리스 푸투나제도"; +"enum.browsing_country.name.western_sahara" = "서사하라"; +"enum.browsing_country.name.yemen" = "예멘"; +"enum.browsing_country.name.zambia" = "잠비아"; +"enum.browsing_country.name.zimbabwe" = "짐바브웨"; + +// MARK: Download Localization Additions +"common.button.cancel" = "취소"; +"tab_item.title.downloads" = "다운로드"; +"app_error.localized_description.database_corrupted" = "데이터베이스 손상"; +"app_error.localized_description.copyright_claim" = "저작권 신고"; +"app_error.localized_description.ip_banned" = "IP 차단됨"; +"app_error.localized_description.gallery_expunged" = "갤러리 삭제됨"; +"app_error.localized_description.network_error" = "네트워크 오류"; +"app_error.localized_description.web_image_loading_error" = "웹 이미지 로드 오류"; +"app_error.localized_description.parse_error" = "파싱 오류"; +"app_error.localized_description.quota_exceeded" = "할당량 초과"; +"app_error.localized_description.authentication_required" = "인증 필요"; +"app_error.localized_description.file_operation_failed" = "파일 작업 실패"; +"app_error.localized_description.no_updates_available" = "사용 가능한 업데이트 없음"; +"app_error.localized_description.not_found" = "찾을 수 없음"; +"app_error.localized_description.unknown_error" = "알 수 없는 오류"; +"app_error.alert.quota_exceeded" = "이미지 할당량을 모두 사용했습니다.\n잠시 후 다시 시도해 주세요."; +"app_error.alert.authentication_required" = "이 다운로드에 접근하려면 로그인해야 합니다."; +"app_error.alert.local_file_operation_failed" = "로컬 파일 작업에 실패했습니다."; +"detail_view.accessibility.download_button.pause_action" = "다운로드 일시 정지"; +"detail_view.accessibility.download_button.paused" = "다운로드 다시 시작. %d / %d 페이지에서 일시 정지됨"; +"detail_view.accessibility.download_button.partial" = "다운로드 다시 시도. 이미 %d / %d 페이지를 사용할 수 있습니다."; +"detail_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; +"detail_view.dialog.title.repair_download" = "다운로드를 복구할까요?"; +"detail_view.dialog.title.update_download" = "다운로드를 업데이트할까요?"; +"detail_view.dialog.title.redownload_gallery" = "갤러리를 다시 다운로드할까요?"; +"detail_view.dialog.message.delete_active_download" = "현재 다운로드를 중지하고 이 기기에서 갤러리를 삭제합니다."; +"detail_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; +"detail_view.dialog.message.repair_download" = "이 갤러리의 오프라인 파일을 지금 복구할까요?"; +"detail_view.dialog.message.update_download" = "이 갤러리를 지금 온라인 최신 버전으로 업데이트할까요?"; +"detail_view.dialog.message.redownload_gallery" = "이 갤러리를 지금 처음부터 다시 다운로드할까요?"; +"detail_view.dialog.button.repair" = "복구"; +"detail_view.dialog.button.update" = "업데이트"; +"detail_view.dialog.button.redownload" = "다시 다운로드"; +"detail_view.offline_notice.saved_details" = "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다."; +"enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.text.no_folders" = "No folders yet"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; +"downloads_view.title.downloads" = "다운로드"; +"downloads_view.search.prompt.downloads" = "다운로드 검색"; +"downloads_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; +"downloads_view.dialog.message.delete_active_download" = "현재 다운로드를 취소하고 이 기기에서 삭제합니다."; +"downloads_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; +"downloads_view.swipe.button.pages" = "페이지"; +"downloads_view.swipe.button.update" = "업데이트"; +"downloads_view.swipe.button.resume" = "재개"; +"downloads_view.swipe.button.pause" = "일시 정지"; +"downloads_view.empty_state.downloads" = "다운로드한 갤러리가 여기에 표시됩니다."; +"downloads_view.empty_state.no_matching_filters" = "현재 필터와 일치하는 다운로드가 없습니다."; +"downloads_view.button.clear_filters" = "필터 지우기"; +"downloads_view.button.validate_image_data" = "이미지 데이터 검증"; +"downloads_view.inspector.section.actions" = "동작"; +"downloads_view.inspector.section.pages" = "페이지"; +"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도"; +"downloads_view.inspector.button.validating_image_data" = "이미지 데이터 검증 중..."; +"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; +"downloads_view.inspector.hud.image_data_valid" = "이미지 데이터가 유효합니다"; +"downloads_view.inspector.hud.image_data_unavailable" = "이미지 데이터를 검증할 수 없습니다."; +"downloads_view.inspector.title.download_status" = "다운로드 상태"; +"downloads_view.inspector.page.pending" = "대기 중"; +"downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; +"downloads_view.inspector.page.title" = "페이지 %d"; +"downloads_view.inspector.page.none" = "페이지 없음"; +"downloads_view.inspector.status.pending" = "대기 중"; +"downloads_view.inspector.status.downloaded" = "다운로드됨"; +"downloads_view.inspector.status.failed" = "실패"; +"download_setting_view.title" = "다운로드"; +"download_setting_view.section.title.download_queue" = "다운로드 대기열"; +"download_setting_view.section.title.network" = "네트워크"; +"download_setting_view.title.concurrent_image_downloads" = "동시 이미지 다운로드 수"; +"download_setting_view.title.retry_failed_pages_automatically" = "실패한 페이지 자동 재시도"; +"download_setting_view.title.allow_cellular_downloads" = "셀룰러 다운로드 허용"; +"download_setting_view.footer.network" = "한 번에 하나의 갤러리만 다운로드됩니다. 이 설정으로 한 갤러리 안에서 동시에 다운로드할 페이지 수, 셀룰러 다운로드 허용 여부, 그리고 파일을 앱의 Downloads 폴더에 저장하는 방식을 제어합니다."; +"enum.download_thread_mode.value.single" = "한 번에 1장 다운로드"; +"enum.download_thread_mode.value.double" = "한 번에 2장 다운로드"; +"enum.download_thread_mode.value.triple" = "한 번에 3장 다운로드"; +"enum.download_thread_mode.value.quadruple" = "한 번에 4장 다운로드"; +"enum.download_thread_mode.value.quintuple" = "한 번에 5장 다운로드"; +"struct.download_badge.text.queued" = "대기 중"; +"struct.download_badge.text.downloading" = "다운로드 중"; +"struct.download_badge.text.paused" = "일시 정지"; +"struct.download_badge.text.downloaded" = "다운로드됨"; +"struct.download_badge.text.needs_attention" = "조치 필요"; +"struct.download_badge.text.update_available" = "업데이트 가능"; +"struct.download_badge.text.needs_repair" = "복구 필요"; +"struct.download_badge.progress" = "%d/%d"; +"download_store.error.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "다운로드 폴더를 확인할 수 없습니다."; +"download_store.validation.download_folder_missing" = "다운로드 폴더가 없습니다."; +"download_store.validation.manifest_missing" = "매니페스트 파일이 없습니다."; +"download_store.validation.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; +"download_store.validation.downloaded_pages_incomplete" = "다운로드한 페이지가 불완전합니다."; +"download_store.validation.cover_image_missing" = "표지 이미지가 없습니다."; +"download_store.validation.page_missing" = "페이지 %d가 없습니다."; +"download_store.validation.cover_image_corrupted" = "표지 이미지 데이터가 손상되었습니다."; +"download_store.validation.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; From 3024d53fec793c1645fdb16f28a36c71916174ea Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 20:13:09 +0800 Subject: [PATCH 263/614] Remove the staged file unconditionally on move failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to OVR-B7(b): the staged-file removal sat inside the `if let taskIdentifier` block. It's non-functional today — the sole DownloadPageTransfer construction site always sets a non-nil taskIdentifier (downloadTask.taskIdentifier) — but hoisting it out matches the sibling failure path (DownloadClient+Networking.swift, which removes the file unconditionally) and stays robust if a nil-id transfer path is ever added. --- .../Tools/Clients/DownloadClient+PageDownloadHelpers.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift index 8c0b4cb21..e7a31954a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -134,11 +134,11 @@ extension DownloadCoordinator { await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) } } catch { + // The move never consumed the staged file; drop it unconditionally so it + // can't strand, matching the sibling failure paths. + removeStagedBackgroundFile(transfer.fileURL) if let taskIdentifier = transfer.taskIdentifier { await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) - // The move never consumed the staged file; drop it so it doesn't - // strand in the holding dir, matching the sibling failure paths. - removeStagedBackgroundFile(transfer.fileURL) } throw error } From d2c6cff606cc443b9c373764f29388f65fa4495b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 21:22:00 +0800 Subject: [PATCH 264/614] Reject E-H asset placeholders in the owned reader fetch The reader's owned fetch (readerImageData) returned cache hits unvalidated and cached any decodable bytes. The E-H quota (509) and login-wall (kokomade) placeholders are valid 200 images, so they slipped past the decode guard: the reader cached them under the page's stable key (poisoning the shared DataCache until expiry), re-served them after the limit lifted, and exported them via save/share/copy as if they were the real page. The download pipeline already rejects these via detectResponseError; the reader bypassed that. Extract the placeholder fingerprints (exact byte count + SHA-1) into a shared, dependency-free ImagePlaceholderFingerprint so the download pipeline and the reader reject them identically. readerImageData now purges a cached placeholder and re-fetches (so a lifted limit recovers on its own), and surfaces a freshly fetched placeholder as its AppError (.quotaExceeded/.authenticationRequired) instead of caching it. The download-side detectors become thin wrappers over the shared matcher, dropping the duplicated hashing and now-dead CryptoKit imports. Adds reader regression tests for both placeholder kinds (network reject + cached purge-and-refetch) using the existing BandwidthExceeded/Kokomade fixtures. --- .../Clients/DownloadClient+Manager.swift | 4 - .../DownloadClient+ResponseValidation.swift | 5 +- ...loadClient+ResponseValidationHelpers.swift | 22 +---- EhPanda/App/Tools/Clients/ImageClient.swift | 16 +++- .../ImagePlaceholderFingerprint.swift | 55 +++++++++++ .../Tests/Download/ReaderImageDataTests.swift | 94 +++++++++++++++++++ 6 files changed, 168 insertions(+), 28 deletions(-) create mode 100644 EhPanda/App/Tools/Utilities/ImagePlaceholderFingerprint.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 135cbe299..993fb7e2e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -45,13 +45,9 @@ actor DownloadCoordinator { static let progressFlushPageInterval = 8 static let progressFlushMinimumInterval: TimeInterval = 0.4 static let responseInspectionPrefixLength = 4096 - static let kokomadeImageByteCount = 144844 - static let kokomadeImageSHA1 = "e48ed350e902a51581246d2a764fa7827e8e6988" static let kokomadeImageURLSuffixes = [ "exhentai.org/img/kokomade.jpg" ] - static let quotaExceededImageByteCount = 28658 - static let quotaExceededImageSHA1 = "f54b887b017694dc25eb1a1404f71981885f8ed9" static let quotaExceededImageURLSuffixes = [ "exhentai.org/img/509.gif", "ehgt.org/g/509.gif" diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift index 47b153660..0aff24a71 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift @@ -4,7 +4,6 @@ // import Kanna -import CryptoKit import Foundation import ImageIO @@ -279,8 +278,8 @@ extension DownloadCoordinator { let byteCount = responseContentLength(response) ?? fileSize(at: fileURL) guard let byteCount, - byteCount == Self.kokomadeImageByteCount - || byteCount == Self.quotaExceededImageByteCount + byteCount == ImagePlaceholderFingerprint.authenticationRequiredByteCount + || byteCount == ImagePlaceholderFingerprint.quotaExceededByteCount else { return nil } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index 64aa35294..ce8d92744 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -4,7 +4,6 @@ // import Kanna -import CryptoKit import Foundation import ImageIO @@ -142,7 +141,7 @@ extension DownloadCoordinator { let byteCount = fullData?.count ?? responseContentLength(response) ?? fileSize(at: fileURL) - guard byteCount == Self.quotaExceededImageByteCount + guard byteCount == ImagePlaceholderFingerprint.quotaExceededByteCount else { return false } @@ -301,26 +300,11 @@ extension DownloadCoordinator { func isAuthenticationRequiredPlaceholderImageData( _ data: Data ) -> Bool { - guard data.count == Self.kokomadeImageByteCount else { - return false - } - return sha1Hex(for: data) == Self.kokomadeImageSHA1 + ImagePlaceholderFingerprint.match(data) == .authenticationRequired } func isQuotaExceededAssetData(_ data: Data) -> Bool { - guard data.count == Self.quotaExceededImageByteCount - else { - return false - } - return sha1Hex(for: data) - == Self.quotaExceededImageSHA1 - } - - func sha1Hex(for data: Data) -> String { - let digest = Insecure.SHA1.hash(data: data) - return digest - .map { String(format: "%02x", $0) } - .joined() + ImagePlaceholderFingerprint.match(data) == .quotaExceeded } func isDecodableImageData(_ data: Data) -> Bool { diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 4d9484c44..a1b84dd9c 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -94,13 +94,25 @@ extension ImageClient { } let cacheKeys = url.imageCacheKeys(includeStableAlias: true) if let data = await dataCache.data(forKeys: cacheKeys) { - return data + // A known placeholder (quota/login wall) that was cached before this + // guard existed must not be re-served; purge it and re-fetch so a + // since-lifted limit recovers on its own. + if ImagePlaceholderFingerprint.match(data) == nil { + return data + } + try? await dataCache.removeData(forKeys: cacheKeys) } let data = try await downloadReaderData( url: url, urlSession: urlSession, onProgress: onProgress ) + // E-H serves quota/auth placeholders as valid 200 images; reject them as the + // errors they are (matching the download pipeline) so they're never cached, + // displayed, or exported as the real page. + if let placeholder = ImagePlaceholderFingerprint.match(data) { + throw placeholder.error + } // Only cache decodable image bytes so a 200 carrying an HTML/error body - // (e.g. an E-H bandwidth notice) can't poison the key until expiry. + // (e.g. an unexpected notice page) can't poison the key until expiry. guard data.decodedImage != nil else { throw AppError.parseFailed } diff --git a/EhPanda/App/Tools/Utilities/ImagePlaceholderFingerprint.swift b/EhPanda/App/Tools/Utilities/ImagePlaceholderFingerprint.swift new file mode 100644 index 000000000..4395d6b62 --- /dev/null +++ b/EhPanda/App/Tools/Utilities/ImagePlaceholderFingerprint.swift @@ -0,0 +1,55 @@ +// +// ImagePlaceholderFingerprint.swift +// EhPanda +// + +import CryptoKit +import Foundation + +/// A known E-H asset placeholder that decodes as a valid image but is *not* page +/// content: the ExHentai "kokomade" login wall and the H@H `509` bandwidth notice. +/// +/// Both the download pipeline and the reader's owned fetch must reject these +/// identically — a placeholder that slips through poisons the shared image cache +/// for its full expiry window and can be displayed or exported as if it were the +/// real page. Centralising the fingerprints keeps the two paths in lockstep. +enum ImagePlaceholderFingerprint: Sendable { + case authenticationRequired + case quotaExceeded + + var error: AppError { + switch self { + case .authenticationRequired: .authenticationRequired + case .quotaExceeded: .quotaExceeded + } + } + + static let authenticationRequiredByteCount = 144_844 + static let authenticationRequiredSHA1 = "e48ed350e902a51581246d2a764fa7827e8e6988" + static let quotaExceededByteCount = 28_658 + static let quotaExceededSHA1 = "f54b887b017694dc25eb1a1404f71981885f8ed9" + + /// Returns the matching placeholder when `data` is byte-for-byte one of the + /// known fixtures (exact length plus SHA-1), otherwise `nil`. The length gate + /// makes the common, non-placeholder case cost one comparison. + static func match(_ data: Data) -> Self? { + if matches(data, byteCount: authenticationRequiredByteCount, sha1: authenticationRequiredSHA1) { + return .authenticationRequired + } + if matches(data, byteCount: quotaExceededByteCount, sha1: quotaExceededSHA1) { + return .quotaExceeded + } + return nil + } + + private static func matches(_ data: Data, byteCount: Int, sha1: String) -> Bool { + guard data.count == byteCount else { return false } + return sha1Hex(for: data) == sha1 + } + + static func sha1Hex(for data: Data) -> String { + Insecure.SHA1.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + } +} diff --git a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift index b845d95ce..a6bd1a53a 100644 --- a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift +++ b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift @@ -117,6 +117,93 @@ struct ReaderImageDataTests { #expect(cached == nil) } + @Test + func testRejectsAndSkipsCacheForQuotaPlaceholderFromNetwork() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://ehgt.org/g/509.gif")) + let placeholderData = try fixtureData(resource: "BandwidthExceeded", pathExtension: "html") + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + (try makeHTTPResponse(url: url, statusCode: 200), placeholderData) + } + + // The H@H `509` notice is a valid 200 GIF; the owned fetch must surface it as + // `.quotaExceeded` and never cache it, or it would poison the key until expiry. + do { + _ = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + Issue.record("Expected readerImageData to reject the quota placeholder") + } catch let error as AppError { + #expect(error == .quotaExceeded) + } catch { + Issue.record("Unexpected error: \(error)") + } + let cached = await cache.data( + forKeys: url.imageCacheKeys(includeStableAlias: true) + ) + #expect(cached == nil) + } + + @Test + func testRejectsAndSkipsCacheForAuthPlaceholderFromNetwork() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://exhentai.org/img/kokomade.jpg")) + let placeholderData = try fixtureData(resource: "Kokomade", pathExtension: "jpg") + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + (try makeHTTPResponse(url: url, statusCode: 200), placeholderData) + } + + do { + _ = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + Issue.record("Expected readerImageData to reject the auth placeholder") + } catch let error as AppError { + #expect(error == .authenticationRequired) + } catch { + Issue.record("Unexpected error: \(error)") + } + let cached = await cache.data( + forKeys: url.imageCacheKeys(includeStableAlias: true) + ) + #expect(cached == nil) + } + + @Test + func testPurgesCachedPlaceholderAndRefetches() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://ehgt.org/g/509.gif")) + let cacheKeys = url.imageCacheKeys(includeStableAlias: true) + let placeholderData = try fixtureData(resource: "BandwidthExceeded", pathExtension: "html") + try await cache.store(placeholderData, forKeys: cacheKeys) + let realImageData = try makePNGData() + let requestCount = UncheckedBox(0) + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + requestCount.value += 1 + return (try makeHTTPResponse(url: url, statusCode: 200), realImageData) + } + + // A placeholder cached before the guard existed must be purged and re-fetched, + // so a lifted limit recovers without the user clearing the cache by hand. + let data = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + + #expect(data == realImageData) + #expect(requestCount.value == 1) + let cached = await cache.data(forKeys: cacheKeys) + #expect(cached == realImageData) + } + @Test func testFetchImageAssetServesOwnedCacheAndRoutesByBytes() async throws { let (cache, rootURL) = makeIsolatedDataCache() @@ -190,6 +277,13 @@ struct ReaderImageDataTests { #expect(asset == nil) } + private func fixtureData(resource: String, pathExtension: String) throws -> Data { + let fixtureURL = try #require( + Bundle(for: TestBundleLocator.self).url(forResource: resource, withExtension: pathExtension) + ) + return try Data(contentsOf: fixtureURL) + } + private func makeIsolatedDataCache() -> (cache: DataCache, rootURL: URL) { let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) From d84731b59f0a79f5856e53e8d2ddeae9c6fd2276 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 21:23:53 +0800 Subject: [PATCH 265/614] Store reader cache entries under the primary key only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readerImageData is the sole writer into the shared reading DataCache, and it stored each page under both imageCacheKeys: the stable alias and the absolute URL. Reads (data(forKeys:)) check the stable alias first and it's non-nil for ~all page URLs, so the absolute-URL copy was written but never the one retrieved — pure 2x disk and 2x memory (halving effective NSCache capacity at RAM/4) for no retrieval benefit. The absolute alias still earns its keep for empty-path URLs, where it's the sole (primary) key anyway. Write under cacheKeys.first only; reads keep iterating both, so the absolute alias remains a read fallback without doubling writes. Adds a test asserting the bytes land under the stable key and the absolute alias holds no separate copy. --- EhPanda/App/Tools/Clients/ImageClient.swift | 7 +++++- .../Tests/Download/ReaderImageDataTests.swift | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index a1b84dd9c..3648bc186 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -116,7 +116,12 @@ extension ImageClient { guard data.decodedImage != nil else { throw AppError.parseFailed } - try? await dataCache.store(data, forKeys: cacheKeys) + // Store under the primary key only. Reads check the stable alias first and + // it's non-nil for ~all page URLs, so also writing the absolute-URL alias + // doubled disk + memory for an entry that retrieval never reaches. + if let primaryKey = cacheKeys.first { + try? await dataCache.store(data, forKey: primaryKey) + } return data } diff --git a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift index a6bd1a53a..b3a23f35b 100644 --- a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift +++ b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift @@ -36,6 +36,29 @@ struct ReaderImageDataTests { #expect(cached == imageData) } + @Test + func testStoresUnderPrimaryKeyOnly() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://ehgt.org/h/abc/primary-key-page/1.jpg")) + let stableKey = try #require(url.stableImageCacheKey) + let imageData = try makePNGData() + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + (try makeHTTPResponse(url: url, statusCode: 200), imageData) + } + + _ = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + + // The bytes are written under the stable primary key only; the absolute-URL + // alias stays a read fallback, never a second stored copy. + #expect(await cache.data(forKeys: [stableKey]) == imageData) + #expect(await cache.data(forKeys: [url.absoluteString]) == nil) + } + @Test func testReturnsCachedBytesWithoutNetwork() async throws { let (cache, rootURL) = makeIsolatedDataCache() From 69f59805960e18e92cbfef304acc7141570c12d0 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 21:27:20 +0800 Subject: [PATCH 266/614] Remove dead cachedImageData(for:) overload and always-true includeStableAlias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-URL cachedImageData(for:) overload had zero callers and was the only site passing includeStableAlias: false, so the false branch of the cache-key builder was unreachable. With it gone every remaining call passed includeStableAlias: true, making the parameter dead across the whole chain. Delete the overload and thread the removal through: URL.imageCacheKeys becomes a paramless computed property that always includes the stable alias; the pure-rename cacheKeys(for:) wrapper is inlined to `$0.imageCacheKeys` and dropped; and the parameter is removed from removeCachedImages(for:) and cachedImageData(for:) and their call sites (ExecutionSupport, validatedCachedAssetData, plus ImageClient, ReadingView, and the cache tests). No behavior change — pure dead-code removal. --- .../Tools/Clients/DownloadClient+Cache.swift | 41 +++---------------- .../DownloadClient+ExecutionSupport.swift | 2 +- EhPanda/App/Tools/Clients/ImageClient.swift | 2 +- .../Tools/Extensions/URL+ImageCacheKey.swift | 8 +++- EhPanda/View/Reading/ReadingView.swift | 2 +- .../DownloadCoordinatorCaptureTests.swift | 4 +- .../DownloadImageParsingCacheTests.swift | 2 +- .../Download/DownloadImageParsingTests.swift | 2 +- .../Download/DownloadProcessCacheTests.swift | 2 +- .../Tests/Download/ReaderImageDataTests.swift | 16 ++++---- .../Parser/Other/SettingDownloadTests.swift | 2 +- 11 files changed, 29 insertions(+), 54 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 30b970ddd..39ba9dc68 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -7,22 +7,12 @@ import Foundation // MARK: - Cache Operations extension DownloadCoordinator { - func cacheKeys( - for url: URL, - includeStableAlias: Bool - ) -> [String] { - url.imageCacheKeys(includeStableAlias: includeStableAlias) - } - func removeCachedImages( - for urls: [URL?], - includeStableAlias: Bool + for urls: [URL?] ) async { let keys = urls .compactMap(\.self) - .flatMap { - cacheKeys(for: $0, includeStableAlias: includeStableAlias) - } + .flatMap(\.imageCacheKeys) let uniqueKeys = Array(Set(keys)) try? await DataCache.shared.removeData(forKeys: uniqueKeys) @@ -96,45 +86,26 @@ extension DownloadCoordinator { imageURL } - func cachedImageData(for url: URL) async -> Data? { - await cachedImageData( - for: [url], - includeStableAlias: false - ) - } - func cachedImageData( - for urls: [URL?], - includeStableAlias: Bool + for urls: [URL?] ) async -> Data? { let keys = urls .compactMap { $0 } - .flatMap { - cacheKeys( - for: $0, - includeStableAlias: includeStableAlias - ) - } + .flatMap(\.imageCacheKeys) return await DataCache.shared.data(forKeys: keys) } func validatedCachedAssetData( for urls: [URL?] ) async -> Data? { - guard let cachedData = await cachedImageData( - for: urls, - includeStableAlias: true - ) else { + guard let cachedData = await cachedImageData(for: urls) else { return nil } guard detectCachedAssetError( data: cachedData, referenceURLs: urls ) == nil else { - await removeCachedImages( - for: urls, - includeStableAlias: true - ) + await removeCachedImages(for: urls) return nil } return cachedData diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index c674a96e6..72d4ec0fb 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -156,7 +156,7 @@ extension DownloadCoordinator { let urls = Array(Set(previewURLs + pageURLs + coverURLs)) .map(Optional.some) - await removeCachedImages(for: urls, includeStableAlias: true) + await removeCachedImages(for: urls) } func resolveSource( diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 3648bc186..857753e15 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -92,7 +92,7 @@ extension ImageClient { if url.isFileURL { return try Data(contentsOf: url) } - let cacheKeys = url.imageCacheKeys(includeStableAlias: true) + let cacheKeys = url.imageCacheKeys if let data = await dataCache.data(forKeys: cacheKeys) { // A known placeholder (quota/login wall) that was cached before this // guard existed must not be re-served; purge it and re-fetch so a diff --git a/EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift b/EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift index 6c7a1bb84..ff761a1d9 100644 --- a/EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift +++ b/EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift @@ -30,9 +30,13 @@ extension URL { return "download::\(normalizedPath)?\(normalizedQuery)" } - func imageCacheKeys(includeStableAlias: Bool) -> [String] { + /// The keys an image is cached under, primary first: the stable alias (when the + /// path yields one) so differing query/host variants of the same page collide, + /// then the absolute URL as an exact-match fallback. Writers store under the + /// primary key; readers check them in order. + var imageCacheKeys: [String] { var keys = [String]() - if includeStableAlias, let stableImageCacheKey { + if let stableImageCacheKey { keys.append(stableImageCacheKey) } keys.append(absoluteString) diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index e1e865fc0..3e7274797 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -303,7 +303,7 @@ extension ReadingView { } Task { await analyzeCachedImageData( - cacheKeys: imageURL.imageCacheKeys(includeStableAlias: true), + cacheKeys: imageURL.imageCacheKeys, index: index ) } diff --git a/EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift index b8b399388..4fb995bc7 100644 --- a/EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift @@ -44,7 +44,7 @@ struct DownloadCoordinatorCaptureTests: DownloadFeatureTestCase { context.fill(.init(x: 0, y: 0, width: 1, height: 1)) } let imageData = try #require(image.jpegData(compressionQuality: 1)) - let cacheKeys = imageURL.imageCacheKeys(includeStableAlias: true) + let cacheKeys = imageURL.imageCacheKeys try await DataCache.shared.store(imageData, forKeys: cacheKeys) await manager.captureCachedPage( @@ -153,7 +153,7 @@ private extension DownloadCoordinatorCaptureTests { context.fill(.init(x: 0, y: 0, width: 1, height: 1)) } let imageData = try #require(image.jpegData(compressionQuality: 1)) - let cacheKeys = imageURL.imageCacheKeys(includeStableAlias: true) + let cacheKeys = imageURL.imageCacheKeys try await DataCache.shared.store(imageData, forKeys: cacheKeys) return (imageURL, cacheKeys) } diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift index 046ce5693..1fb8f5d94 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -21,7 +21,7 @@ struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { ) let imageData = try fixtureData(resource: "Kokomade", pathExtension: "jpg") - let cacheKeys = normalImageURL.imageCacheKeys(includeStableAlias: true) + let cacheKeys = normalImageURL.imageCacheKeys for cacheKey in cacheKeys { try await KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) } diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift index ed7a091a1..624643e21 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift @@ -256,7 +256,7 @@ struct DownloadImageParsingTests: DownloadFeatureTestCase { let placeholderURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) defer { try? FileManager.default.removeItem(at: placeholderURL) } let placeholderData = try Data(contentsOf: placeholderURL) - let cacheKeys = normalImageURL.imageCacheKeys(includeStableAlias: true) + let cacheKeys = normalImageURL.imageCacheKeys for cacheKey in cacheKeys { try await KingfisherManager.shared.cache.storeToDisk(placeholderData, forKey: cacheKey) } diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 00aa27018..83d504fa2 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -220,7 +220,7 @@ private extension DownloadProcessCacheTests { latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL ) let cachedURLs = [currentPageImageURL, coverURL] - let cachedKeys = Set(cachedURLs.flatMap { $0.imageCacheKeys(includeStableAlias: true) }) + let cachedKeys = Set(cachedURLs.flatMap { $0.imageCacheKeys }) cachedKeysBox.value = cachedKeys return cachedKeys } diff --git a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift index b3a23f35b..dee534093 100644 --- a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift +++ b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift @@ -31,7 +31,7 @@ struct ReaderImageDataTests { #expect(data == imageData) #expect(requestCount.value == 1) let cached = await cache.data( - forKeys: url.imageCacheKeys(includeStableAlias: true) + forKeys: url.imageCacheKeys ) #expect(cached == imageData) } @@ -66,7 +66,7 @@ struct ReaderImageDataTests { let url = try #require(URL(string: "https://example.com/reader/cached.png")) let imageData = try makePNGData() try await cache.store( - imageData, forKeys: url.imageCacheKeys(includeStableAlias: true) + imageData, forKeys: url.imageCacheKeys ) let requestCount = UncheckedBox(0) let (session, sessionID) = makeStubbedSession() @@ -107,7 +107,7 @@ struct ReaderImageDataTests { Issue.record("Unexpected error: \(error)") } let cached = await cache.data( - forKeys: url.imageCacheKeys(includeStableAlias: true) + forKeys: url.imageCacheKeys ) #expect(cached == nil) } @@ -135,7 +135,7 @@ struct ReaderImageDataTests { Issue.record("Unexpected error: \(error)") } let cached = await cache.data( - forKeys: url.imageCacheKeys(includeStableAlias: true) + forKeys: url.imageCacheKeys ) #expect(cached == nil) } @@ -165,7 +165,7 @@ struct ReaderImageDataTests { Issue.record("Unexpected error: \(error)") } let cached = await cache.data( - forKeys: url.imageCacheKeys(includeStableAlias: true) + forKeys: url.imageCacheKeys ) #expect(cached == nil) } @@ -193,7 +193,7 @@ struct ReaderImageDataTests { Issue.record("Unexpected error: \(error)") } let cached = await cache.data( - forKeys: url.imageCacheKeys(includeStableAlias: true) + forKeys: url.imageCacheKeys ) #expect(cached == nil) } @@ -203,7 +203,7 @@ struct ReaderImageDataTests { let (cache, rootURL) = makeIsolatedDataCache() defer { try? FileManager.default.removeItem(at: rootURL) } let url = try #require(URL(string: "https://ehgt.org/g/509.gif")) - let cacheKeys = url.imageCacheKeys(includeStableAlias: true) + let cacheKeys = url.imageCacheKeys let placeholderData = try fixtureData(resource: "BandwidthExceeded", pathExtension: "html") try await cache.store(placeholderData, forKeys: cacheKeys) let realImageData = try makePNGData() @@ -234,7 +234,7 @@ struct ReaderImageDataTests { let url = try #require(URL(string: "https://example.com/reader/export.png")) let imageData = try makePNGData() try await cache.store( - imageData, forKeys: url.imageCacheKeys(includeStableAlias: true) + imageData, forKeys: url.imageCacheKeys ) let requestCount = UncheckedBox(0) let (session, sessionID) = makeStubbedSession() diff --git a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift index 096af6477..ea6c69c45 100644 --- a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -52,7 +52,7 @@ struct SettingDownloadTests { let url = try #require(URL(string: "https://alpha.hath.network/h/123/456/image.webp?download=1")) #expect( - url.imageCacheKeys(includeStableAlias: true) == [ + url.imageCacheKeys == [ "download::h/123/456/image.webp", "https://alpha.hath.network/h/123/456/image.webp?download=1" ] From bbab7e78d0d91bb25c1841f857a842d85cb08fae Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 21:30:21 +0800 Subject: [PATCH 267/614] Buffer background completions until the coordinator is installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background URLSession is created synchronously in DownloadClient.live, so iOS can replay a stored completion onto the delegate the instant it exists — but the coordinator is only injected into BackgroundPageCompletionReceiver one task-hop later. An event arriving in that window hit `await coordinator?.handle…` while coordinator was nil and was silently dropped: the staged file stranded (purged next launch), the persisted task record lingered, and resumeQueue re-downloaded the already-finished page — burning H@H quota and defeating the page-finished- while-app-was-dead guarantee DES-9 exists for. The init is genuinely circular (session → receiver → coordinator → session), so reordering can't close it. Buffer completion/failure events in the receiver while coordinator is nil and drain them on setCoordinator (snapshot-then-clear before the first await so a reentrant arrival during the drain goes straight to the now-set coordinator). Mirrors the stash-and-replay already used by BackgroundDownloadTaskHub. Adds a test asserting a completion delivered before setCoordinator is replayed (page attached, staged file consumed, task record cleared) rather than dropped. --- .../DownloadClient+BackgroundDownloads.swift | 53 +++++++++++++++++-- .../DownloadBackgroundCompletionTests.swift | 52 ++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift index 48849adaf..0f83f9c4a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift @@ -6,10 +6,26 @@ import Foundation actor BackgroundPageCompletionReceiver { - private var coordinator: DownloadCoordinator? + private enum PendingEvent { + case completion(taskIdentifier: Int, fileURL: URL, response: URLResponse) + case failure(taskIdentifier: Int, error: AppError?) + } - func setCoordinator(_ coordinator: DownloadCoordinator) { + private var coordinator: DownloadCoordinator? + private var pendingEvents = [PendingEvent]() + + // The background URLSession is live the moment it's created, so iOS can replay a + // stored completion before the coordinator is installed one task-hop later. Buffer + // anything that arrives in that window and drain it here, or the event would no-op + // against a nil coordinator — stranding the staged file and letting resumeQueue + // re-download an already-finished page (defeats the offline-finish guarantee). + func setCoordinator(_ coordinator: DownloadCoordinator) async { self.coordinator = coordinator + let bufferedEvents = pendingEvents + pendingEvents.removeAll() + for event in bufferedEvents { + await deliver(event, to: coordinator) + } } func handleCompletion( @@ -17,7 +33,13 @@ actor BackgroundPageCompletionReceiver { fileURL: URL, response: URLResponse ) async { - await coordinator?.handleBackgroundPageDownloadCompleted( + guard let coordinator else { + pendingEvents.append( + .completion(taskIdentifier: taskIdentifier, fileURL: fileURL, response: response) + ) + return + } + await coordinator.handleBackgroundPageDownloadCompleted( taskIdentifier: taskIdentifier, fileURL: fileURL, response: response @@ -28,11 +50,34 @@ actor BackgroundPageCompletionReceiver { taskIdentifier: Int, error: AppError? ) async { - await coordinator?.handleBackgroundPageDownloadFailed( + guard let coordinator else { + pendingEvents.append(.failure(taskIdentifier: taskIdentifier, error: error)) + return + } + await coordinator.handleBackgroundPageDownloadFailed( taskIdentifier: taskIdentifier, error: error ) } + + private func deliver( + _ event: PendingEvent, + to coordinator: DownloadCoordinator + ) async { + switch event { + case let .completion(taskIdentifier, fileURL, response): + await coordinator.handleBackgroundPageDownloadCompleted( + taskIdentifier: taskIdentifier, + fileURL: fileURL, + response: response + ) + case let .failure(taskIdentifier, error): + await coordinator.handleBackgroundPageDownloadFailed( + taskIdentifier: taskIdentifier, + error: error + ) + } + } } extension DownloadCoordinator { diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift index 7e6cd4425..9a5a0755c 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift @@ -66,6 +66,58 @@ struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { #expect(try await manager.loadLocalPageURLs(gid: gid).get()[1] == pageURL) } + @Test + func testReceiverReplaysCompletionBufferedBeforeCoordinatorIsSet() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 908) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore + ) + let folderURL = try writeDownloadFolder(storage: storage, gid: gid) + await manager.reloadDownloadIndex() + + let taskIdentifier = 88 + let stagedURL = try writeStagedBackgroundFile(storage: storage) + await taskStore.record(taskIdentifier: taskIdentifier, gid: gid, pageIndex: 1) + let responseURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) + let response = try #require(HTTPURLResponse( + url: responseURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/jpeg"] + )) + + // A completion replayed by iOS in the window before the coordinator is installed + // must be buffered and drained on setCoordinator, not dropped against a nil + // coordinator (which would strand the page and let resumeQueue re-download it). + let receiver = BackgroundPageCompletionReceiver() + await receiver.handleCompletion( + taskIdentifier: taskIdentifier, + fileURL: stagedURL, + response: response + ) + await receiver.setCoordinator(manager) + + let pageRelativePath = storage.makePageRelativePath( + gid: gid, + token: "token", + index: 1, + fileExtension: "jpg" + ) + let pageURL = folderURL.appendingPathComponent(pageRelativePath) + + #expect(await taskStore.record(taskIdentifier: taskIdentifier) == nil) + #expect(FileManager.default.fileExists(atPath: pageURL.path)) + #expect(FileManager.default.fileExists(atPath: stagedURL.path) == false) + } + @Test func testOrphanedBackgroundFailureRecordsPageFailureAndClearsTaskRecord() async throws { let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 904) From e44ad042dc6bad4543dd5889638c9d115f79b421 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 15 Jun 2026 21:32:04 +0800 Subject: [PATCH 268/614] Delete dead, cross-language-inconsistent download_thread_mode keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5 enum.download_thread_mode.value.{single,double,triple,quadruple,quintuple} keys are a vestige of an abandoned thread-mode enum picker — the live control is a numeric Slider(value:, in: 1...5) in DownloadSettingView. They have zero Swift references and aren't in Generated/Strings.swift, yet they existed in 6 of 8 languages (de, ja, ko, zh-Hant, zh-Hant-TW, zh-Hant-HK) and were absent from en + zh-Hans — the exact dead + missing-in-some-languages asymmetry. Remove all 30 lines, restoring full key parity across the 8 Localizable.strings files. --- EhPanda/App/de.lproj/Localizable.strings | 5 ----- EhPanda/App/ja.lproj/Localizable.strings | 5 ----- EhPanda/App/ko.lproj/Localizable.strings | 5 ----- EhPanda/App/zh-Hant-HK.lproj/Localizable.strings | 5 ----- EhPanda/App/zh-Hant-TW.lproj/Localizable.strings | 5 ----- EhPanda/App/zh-Hant.lproj/Localizable.strings | 5 ----- 6 files changed, 30 deletions(-) diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 07d2d7f06..5e0ceed20 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -1002,11 +1002,6 @@ "download_setting_view.title.retry_failed_pages_automatically" = "Fehlgeschlagene Seiten automatisch erneut versuchen"; "download_setting_view.title.allow_cellular_downloads" = "Downloads über Mobilfunk erlauben"; "download_setting_view.footer.network" = "Es wird immer nur eine Galerie gleichzeitig heruntergeladen. Mit dieser Einstellung steuerst du, wie viele Galerieseiten parallel geladen werden, ob Mobilfunk erlaubt ist und dass Dateien im Downloads-Ordner der App gespeichert werden."; -"enum.download_thread_mode.value.single" = "1 Bild gleichzeitig"; -"enum.download_thread_mode.value.double" = "2 Bilder gleichzeitig"; -"enum.download_thread_mode.value.triple" = "3 Bilder gleichzeitig"; -"enum.download_thread_mode.value.quadruple" = "4 Bilder gleichzeitig"; -"enum.download_thread_mode.value.quintuple" = "5 Bilder gleichzeitig"; "struct.download_badge.text.queued" = "In Warteschlange"; "struct.download_badge.text.downloading" = "Lädt herunter"; "struct.download_badge.text.paused" = "Pausiert"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 1aae44ffc..a6d2b33b8 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -1002,11 +1002,6 @@ "download_setting_view.title.retry_failed_pages_automatically" = "失敗したページを自動で再試行"; "download_setting_view.title.allow_cellular_downloads" = "モバイル通信でのダウンロードを許可"; "download_setting_view.footer.network" = "一度にダウンロードされるギャラリーは 1 件だけです。この設定では、1 つのギャラリー内で同時にダウンロードするページ数、モバイル通信の許可または禁止、そしてファイルをアプリの Downloads フォルダに保存する動作を管理します。"; -"enum.download_thread_mode.value.single" = "1 枚ずつダウンロード"; -"enum.download_thread_mode.value.double" = "2 枚ずつダウンロード"; -"enum.download_thread_mode.value.triple" = "3 枚ずつダウンロード"; -"enum.download_thread_mode.value.quadruple" = "4 枚ずつダウンロード"; -"enum.download_thread_mode.value.quintuple" = "5 枚ずつダウンロード"; "struct.download_badge.text.queued" = "待機中"; "struct.download_badge.text.downloading" = "ダウンロード中"; "struct.download_badge.text.paused" = "一時停止"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 73c127e07..02007f0f0 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -1002,11 +1002,6 @@ "download_setting_view.title.retry_failed_pages_automatically" = "실패한 페이지 자동 재시도"; "download_setting_view.title.allow_cellular_downloads" = "셀룰러 다운로드 허용"; "download_setting_view.footer.network" = "한 번에 하나의 갤러리만 다운로드됩니다. 이 설정으로 한 갤러리 안에서 동시에 다운로드할 페이지 수, 셀룰러 다운로드 허용 여부, 그리고 파일을 앱의 Downloads 폴더에 저장하는 방식을 제어합니다."; -"enum.download_thread_mode.value.single" = "한 번에 1장 다운로드"; -"enum.download_thread_mode.value.double" = "한 번에 2장 다운로드"; -"enum.download_thread_mode.value.triple" = "한 번에 3장 다운로드"; -"enum.download_thread_mode.value.quadruple" = "한 번에 4장 다운로드"; -"enum.download_thread_mode.value.quintuple" = "한 번에 5장 다운로드"; "struct.download_badge.text.queued" = "대기 중"; "struct.download_badge.text.downloading" = "다운로드 중"; "struct.download_badge.text.paused" = "일시 정지"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 210bd4add..b2358cff5 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -999,11 +999,6 @@ "download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; "download_setting_view.title.allow_cellular_downloads" = "允許流動網絡下載"; "download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許流動網絡下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; -"enum.download_thread_mode.value.single" = "同時下載 1 張圖片"; -"enum.download_thread_mode.value.double" = "同時下載 2 張圖片"; -"enum.download_thread_mode.value.triple" = "同時下載 3 張圖片"; -"enum.download_thread_mode.value.quadruple" = "同時下載 4 張圖片"; -"enum.download_thread_mode.value.quintuple" = "同時下載 5 張圖片"; "struct.download_badge.text.queued" = "已排隊"; "struct.download_badge.text.downloading" = "下載中"; "struct.download_badge.text.paused" = "已暫停"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 769368517..1ff0077dc 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -1000,11 +1000,6 @@ "download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; "download_setting_view.title.allow_cellular_downloads" = "允許行動網路下載"; "download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許行動網路下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; -"enum.download_thread_mode.value.single" = "同時下載 1 張圖片"; -"enum.download_thread_mode.value.double" = "同時下載 2 張圖片"; -"enum.download_thread_mode.value.triple" = "同時下載 3 張圖片"; -"enum.download_thread_mode.value.quadruple" = "同時下載 4 張圖片"; -"enum.download_thread_mode.value.quintuple" = "同時下載 5 張圖片"; "struct.download_badge.text.queued" = "已排隊"; "struct.download_badge.text.downloading" = "下載中"; "struct.download_badge.text.paused" = "已暫停"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 4b4388e17..401183ff8 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -1000,11 +1000,6 @@ "download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; "download_setting_view.title.allow_cellular_downloads" = "允許流動網絡下載"; "download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許流動網絡下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; -"enum.download_thread_mode.value.single" = "同時下載 1 張圖片"; -"enum.download_thread_mode.value.double" = "同時下載 2 張圖片"; -"enum.download_thread_mode.value.triple" = "同時下載 3 張圖片"; -"enum.download_thread_mode.value.quadruple" = "同時下載 4 張圖片"; -"enum.download_thread_mode.value.quintuple" = "同時下載 5 張圖片"; "struct.download_badge.text.queued" = "已排隊"; "struct.download_badge.text.downloading" = "下載中"; "struct.download_badge.text.paused" = "已暫停"; From 0d161a91859d5e80ce64d91a71296b9a69382692 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 17 Jun 2026 22:38:40 +0800 Subject: [PATCH 269/614] Fix gallery cell language layout --- .../View/Support/Components/Cells/GalleryDetailCell.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift index 424935b35..3408ee993 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift @@ -97,13 +97,13 @@ private struct GalleryDetailCellContent: View { HStack { Text(gallery.uploader ?? "") - .lineLimit(1) - .font(.subheadline) - .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Text(gallery.language?.value ?? "") } + .foregroundStyle(.secondary) + .font(.subheadline) + .lineLimit(1) let tagContents = gallery.tagContents(maximum: setting.listTagsNumberMaximum) if setting.showsTagsInList, !tagContents.isEmpty { From 629b5db47e80c4a0c834b831392a2c9b70f17cfe Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 17 Jun 2026 23:49:24 +0800 Subject: [PATCH 270/614] Enforce SwiftLint rules --- .swiftlint.yml | 131 +++++++++++++++++- .../Clients/DownloadClient+Folders.swift | 2 +- .../Clients/DownloadClient+Persistence.swift | 8 +- .../Clients/DownloadClient+Scheduling.swift | 4 +- .../App/Tools/Utilities/DownloadStore.swift | 4 +- .../Model5toModel6MigrationPolicy.swift | 1 + .../Models/Download/DownloadedGallery.swift | 6 +- EhPanda/Models/Persistent/Setting.swift | 1 + EhPanda/Models/Support/BrowsingCountry.swift | 1 + .../Detail/DetailView+HeaderSection.swift | 35 ++--- EhPanda/View/Detail/DetailView.swift | 2 +- .../Downloads/DownloadsView+Subviews.swift | 2 +- .../Reading/Support/LiveTextHandler.swift | 3 +- .../AppearanceSettingView.swift | 4 +- EhPanda/View/Setting/SettingView.swift | 2 +- .../Support/Components/PreviewImageView.swift | 7 +- .../Components/TagSuggestionView.swift | 2 +- .../Download/DownloadBadgeSortTests.swift | 6 +- .../DownloadCoordinatorStorageTests.swift | 18 +-- .../DownloadFeatureTestFactories.swift | 8 +- .../DownloadFilterAndBadgeTests.swift | 2 +- .../Download/DownloadStoreHashTests.swift | 2 +- .../Tests/Download/DownloadStoreTests.swift | 2 +- .../DownloadedGalleryManifestModelTests.swift | 6 +- 24 files changed, 189 insertions(+), 70 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index acd29298e..537dd09d3 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -25,10 +25,36 @@ file_length: error: 1000 custom_rules: + accessibility_empty_string: + name: "Accessibility Empty String" + regex: '\.accessibility(Label|Value|Hint|InputLabels)\s*\((?:[^()]|\([^()]*\))*?(?:Text\s*\(\s*(?:verbatim:\s*)?""|"")' + message: 'Accessibility modifiers should not receive an empty string. Apply the modifier conditionally instead of passing "" or Text(verbatim: "").' + excluded_match_kinds: + - comment + severity: error + + accessibility_text_argument: + name: "Accessibility Text Argument" + regex: '\.accessibility(Label|Value|Hint|InputLabels)\s*\((?:[^()]|\([^()]*\))*?\bText\s*\(' + message: "Accessibility modifiers should not wrap their content in Text. Pass a LocalizedStringResource (or String) directly, e.g. .accessibilityLabel(.someKey)." + excluded_match_kinds: + - comment + - string + severity: error + +# binding_initializer: +# name: "Binding Initializer" +# regex: "\\bBinding\\s*<[^>]+>\\s*\\(|\\bBinding\\s*\\(" +# message: "Manual Binding initializers should be avoided. Prefer a projected Binding." +# excluded_match_kinds: +# - comment +# - string +# severity: error + child_reducer_shorthand_foreach: name: "Child reducer shorthand (forEach)" - regex: 'forEach\([^)]*\)\s*\{\s*[A-Z]\w*\(\)\s*\}' - message: "Use .forEach(_:action:element: Reducer.init) instead of expanding a closure for a single bare Reducer()." + regex: 'forEach\([^)]*\)\s*\{\s*[A-Z]\w*\(\)\s*\}|forEach\((?:[^()]|\([^()]*\))*?\belement:\s*\{\s*[A-Z]\w*\(\)\s*\}' + message: "Use .forEach(_:action:element: Reducer.init) instead of wrapping a single bare Reducer() in a closure." excluded_match_kinds: - comment - string @@ -36,8 +62,8 @@ custom_rules: child_reducer_shorthand_scope: name: "Child reducer shorthand (Scope)" - regex: 'Scope\([^)]*\)\s*\{\s*[A-Z]\w*\(\)\s*\}' - message: "Use Scope(state:action:child: Reducer.init) instead of expanding a closure for a single bare Reducer()." + regex: 'Scope\([^)]*\)\s*\{\s*[A-Z]\w*\(\)\s*\}|Scope\((?:[^()]|\([^()]*\))*?\bchild:\s*\{\s*[A-Z]\w*\(\)\s*\}' + message: "Use Scope(state:action:child: Reducer.init) instead of wrapping a single bare Reducer() in a closure." excluded_match_kinds: - comment - string @@ -45,8 +71,44 @@ custom_rules: child_reducer_shorthand_store: name: "Child reducer shorthand (Store)" - regex: '\b(?:Store|TestStore)\(\s*initialState:\s*(?:[^()]|\([^()]*\))*\)\s*\{\s*[A-Z]\w*\(\)\s*\}' - message: "Use Store/TestStore initialState:reducer: instead of expanding a closure for a single bare Reducer()." + regex: '\b(?:Store|TestStore)\(\s*initialState:\s*(?:[^()]|\([^()]*\))*\)\s*\{\s*[A-Z]\w*\(\)\s*\}|\b(?:Store|TestStore)\((?:[^()]|\([^()]*\))*?\breducer:\s*\{\s*[A-Z]\w*\(\)\s*\}' + message: "Use Store/TestStore(initialState:reducer:) with reducer: Reducer.init instead of wrapping a single bare Reducer() in a closure." + excluded_match_kinds: + - comment + - string + severity: error + + date_property_at_suffix: + name: "Date Property At Suffix" + regex: '\b(?:let|var)\s+\w*[a-z]At\b' + message: "Avoid the 'At' suffix on date properties. Use a noun form, e.g. creationDate instead of createdAt." + excluded_match_kinds: + - comment + - string + severity: error + + label_text_image_shorthand: + name: "Label Text + Image Shorthand" + regex: 'Label\s*\{\s*Text\s*\((?:[^()]|\([^()]*\))*\)\s*\}\s*icon:\s*\{\s*Image\s*\(\s*systemSymbol:\s*(?:[^()]|\([^()]*\))*\)\s*\}' + message: "Use Label(_ titleResource:systemSymbol:) instead of an unmodified Text + Image(systemSymbol:) Label." + excluded_match_kinds: + - comment + - string + severity: error + +# lifecycle_modifiers: +# name: "Lifecycle Modifiers" +# regex: "\\.(onAppear|onDisappear|task)\\s*(\\(|\\{)" +# message: "SwiftUI lifecycle modifiers should be avoided. Prefer a reducer action." +# excluded_match_kinds: +# - comment +# - string +# severity: error + + no_case_check_property: + name: "No Case-Check Property" + regex: '\bvar\s+\w+\s*:\s*Bool\s*\{\s*if\s+case\s+\.[A-Za-z_]\w*[^{}]*\{\s*return\s+\w+\s*\}\s*(?:else\s*\{\s*return\s+\w+\s*\}|;?\s*return\s+\w+)\s*\}' + message: 'Avoid computed properties that only translate an if-case enum check into a Bool. Check the case at the call site instead, e.g. value.is(\.case) for @CasePathable types.' excluded_match_kinds: - comment - string @@ -79,5 +141,62 @@ custom_rules: - string severity: error +# optional_try: +# name: "try?" +# regex: "\\btry\\?\\s*" +# message: "try? should be avoided. Properly handle every errors." +# excluded_match_kinds: +# - comment +# - string +# severity: error + + shape_initializer_argument: + name: "Shape Initializer Argument" + regex: '(?:[,(]\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*:\s*)?)(?:SwiftUI\.|SwiftUICore\.)?(?:Rectangle|RoundedRectangle|UnevenRoundedRectangle|Capsule|Ellipse|Circle|ConcentricRectangle|ContainerRelativeShape|ButtonBorderShape)\s*\((?:[^()]|\([^()]*\))*\)(?!\s*\.)' + message: "Use the corresponding SwiftUI shape shorthand when passing a standalone shape as an argument. `Shape().modifier()` is allowed." + excluded_match_kinds: + - comment + - string + severity: error + +# single_line_trailing_closure: +# name: "Single-Line Trailing Closure" +# regex: '\.(map|compactMap|flatMap|filter|reduce|sorted|sort|forEach|first|last|firstIndex|contains|allSatisfy|withValue|withLock|withDependencies|onSubmit|onChange|onAppear|onDisappear|sink|task)\s*(?:\([^()\n]*\))?\s*\{(?!\s*(?:return|break|continue|throw|fallthrough)\b)[^{}\n]*\}' +# message: "Wrap a single-line trailing closure in parentheses, e.g. foo.map({ ... }), or break the closure onto multiple lines." +# excluded_match_kinds: +# - comment +# - string +# severity: error + + swiftlint_disable_requires_reason: + name: "SwiftLint Disable Requires Reason" + regex: "(?m)^(?!//\\s*reason:)[^\\n]*\\n(//\\s*swiftlint:disable(?::(?:next|this|previous))?\\s+[^\\n]+)$" + match_kinds: + - comment + message: "swiftlint:disable should include a preceding reason comment, for example: // reason: explanation" + severity: error + capture_group: 1 + + system_name_parameter: + name: "systemName Parameter" + regex: "\\bsystemName\\s*:" + message: "`systemName` parameters should be avoided. Prefer `systemSymbol`." + excluded_match_kinds: + - comment + - string + severity: error + +# unchecked_subscript_index_access: +# name: "Unchecked Subscript Index Access" +# regex: "\\b[A-Za-z_][A-Za-z0-9_]*\\s*\\[(?:\\d+|[A-Za-z_][A-Za-z0-9_]*\\s*[+\\-]\\s*\\d+|i|j|k|idx|index|offset|position|row|section)\\]" +# message: "Subscript index access should be guarded by an index check." +# excluded_match_kinds: +# - comment +# - string +# severity: error +# excluded: +# - ".*/[^/]*Tests\\.swift$" +# - "\\[validatedIndex\\]" + excluded: - EhPanda/App/Generated diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift index 0b5d32250..096901eb0 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift @@ -218,7 +218,7 @@ extension DownloadCoordinator { localPageURLs: record.localPageURLs.mapValues { destinationFolderURL.appendingPathComponent($0.lastPathComponent) }, - modifiedAt: record.modifiedAt, + modificationDate: record.modificationDate, parentFolderName: newName ) } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index ca876ec88..9b6cf8f82 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -81,7 +81,7 @@ extension DownloadCoordinator { folderName: record.parentFolderName, localCoverURL: record.localCoverURL, localPageURLs: record.localPageURLs, - modifiedAt: record.modifiedAt, + modificationDate: record.modificationDate, displayStatus: displayStatus(for: record), lastError: validationErrors[gid] ?? downloadErrors[gid] ) @@ -120,14 +120,14 @@ extension DownloadCoordinator { if lhs.displayStatus != rhs.displayStatus { return lhs.displayStatus.sortPriority < rhs.displayStatus.sortPriority } - return (lhs.lastDownloadedAt ?? .distantPast) - > (rhs.lastDownloadedAt ?? .distantPast) + return (lhs.lastDownloadedDate ?? .distantPast) + > (rhs.lastDownloadedDate ?? .distantPast) } } private extension DownloadFolderRecord { var displayDate: Date { - modifiedAt ?? .distantPast + modificationDate ?? .distantPast } } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 32e7b8ade..6bc381e75 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -84,8 +84,8 @@ extension DownloadCoordinator { if lhsIsDownloading != rhsIsDownloading { return lhsIsDownloading } - return (lhs.lastDownloadedAt ?? .distantPast) - < (rhs.lastDownloadedAt ?? .distantPast) + return (lhs.lastDownloadedDate ?? .distantPast) + < (rhs.lastDownloadedDate ?? .distantPast) } .first } diff --git a/EhPanda/App/Tools/Utilities/DownloadStore.swift b/EhPanda/App/Tools/Utilities/DownloadStore.swift index b0df873a4..e29298e6b 100644 --- a/EhPanda/App/Tools/Utilities/DownloadStore.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore.swift @@ -17,7 +17,7 @@ struct DownloadFolderRecord: Equatable, Sendable { let manifest: DownloadManifest let localCoverURL: URL? let localPageURLs: [Int: URL] - let modifiedAt: Date? + let modificationDate: Date? let parentFolderName: String } @@ -444,7 +444,7 @@ struct DownloadStore: Sendable { manifest: manifest, localCoverURL: localCoverURL(folderURL: folderURL, manifest: manifest), localPageURLs: imageURLs(folderURL: folderURL, manifest: manifest), - modifiedAt: resourceValues?.contentModificationDate, + modificationDate: resourceValues?.contentModificationDate, parentFolderName: parentFolderName ) } diff --git a/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift b/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift index dd6102261..3494c6013 100644 --- a/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift +++ b/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift @@ -5,6 +5,7 @@ import CoreData +// reason: the migration policy name must encode both the source and destination model versions // swiftlint:disable type_name final class GalleryMO5toGalleryMO6MigrationPolicy: NSEntityMigrationPolicy { override func createDestinationInstances( diff --git a/EhPanda/Models/Download/DownloadedGallery.swift b/EhPanda/Models/Download/DownloadedGallery.swift index 8618799db..769f0baf7 100644 --- a/EhPanda/Models/Download/DownloadedGallery.swift +++ b/EhPanda/Models/Download/DownloadedGallery.swift @@ -14,7 +14,7 @@ struct DownloadedGallery: Identifiable, Equatable { let localCoverURL: URL? let localPageURLs: [Int: URL] let displayStatus: DownloadDisplayStatus - let lastDownloadedAt: Date? + let lastDownloadedDate: Date? let lastError: DownloadFailure? var gid: String { manifest.gid } @@ -37,7 +37,7 @@ struct DownloadedGallery: Identifiable, Equatable { folderName: String, localCoverURL: URL?, localPageURLs: [Int: URL], - modifiedAt: Date?, + modificationDate: Date?, displayStatus: DownloadDisplayStatus, lastError: DownloadFailure? = nil ) { @@ -47,7 +47,7 @@ struct DownloadedGallery: Identifiable, Equatable { self.localCoverURL = localCoverURL self.localPageURLs = localPageURLs self.displayStatus = displayStatus - self.lastDownloadedAt = modifiedAt + self.lastDownloadedDate = modificationDate self.lastError = lastError } } diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift index 6df802e36..cc18c5a77 100644 --- a/EhPanda/Models/Persistent/Setting.swift +++ b/EhPanda/Models/Persistent/Setting.swift @@ -196,6 +196,7 @@ extension ListDisplayMode { } } +// reason: the manual Decodable initializer has long per-key decode/default lines // swiftlint:disable line_length // MARK: Manually decode extension Setting { diff --git a/EhPanda/Models/Support/BrowsingCountry.swift b/EhPanda/Models/Support/BrowsingCountry.swift index 6e3b751c1..e2594c6bf 100644 --- a/EhPanda/Models/Support/BrowsingCountry.swift +++ b/EhPanda/Models/Support/BrowsingCountry.swift @@ -5,6 +5,7 @@ import Foundation +// reason: the exhaustive ISO country list is kept dense, one case per line // swiftlint:disable line_length extension EhSetting { enum BrowsingCountry: String, CaseIterable, Identifiable, Equatable { diff --git a/EhPanda/View/Detail/DetailView+HeaderSection.swift b/EhPanda/View/Detail/DetailView+HeaderSection.swift index 44eb40152..e01f812bf 100644 --- a/EhPanda/View/Detail/DetailView+HeaderSection.swift +++ b/EhPanda/View/Detail/DetailView+HeaderSection.swift @@ -5,6 +5,7 @@ import SwiftUI import Kingfisher +import SFSafeSymbols // MARK: HeaderSection struct HeaderSection: View { @@ -67,7 +68,7 @@ struct HeaderSection: View { progressIndicator( progress: progress, isDeterminate: true, - centerSystemName: activeDownloadIconSystemName + centerSymbol: activeDownloadIconSymbol ) } .buttonStyle(.glass(.regular.interactive())) @@ -77,7 +78,7 @@ struct HeaderSection: View { progressIndicator( progress: progress, isDeterminate: false, - centerSystemName: activeDownloadIconSystemName + centerSymbol: activeDownloadIconSymbol ) } .buttonStyle(.glass(.regular.interactive())) @@ -128,12 +129,12 @@ struct HeaderSection: View { .accessibilityLabel(downloadButtonAccessibilityLabel) } private var downloadIconLabel: some View { - Image(systemName: downloadIconSystemName) + Image(systemSymbol: downloadIconSymbol) .font(actionIconFont) .foregroundStyle(canDownload ? downloadButtonTint : .secondary) .rotationEffect(.degrees(showsMetadataPreparation ? 360 : 0)) .frame(width: actionIconButtonSize, height: actionIconButtonSize) - .contentShape(Circle()) + .contentShape(.circle) } private var favoriteButton: some View { ZStack { @@ -171,7 +172,7 @@ struct HeaderSection: View { .accessibilityLabel(L10n.Localizable.DetailView.Button.read) } private func progressIndicator( - progress: Double, isDeterminate: Bool, centerSystemName: String + progress: Double, isDeterminate: Bool, centerSymbol: SFSymbol ) -> some View { ZStack { if isDeterminate { @@ -187,7 +188,7 @@ struct HeaderSection: View { .tint(downloadButtonTint) .controlSize(.small) } - Image(systemName: centerSystemName) + Image(systemSymbol: centerSymbol) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(downloadButtonTint) } @@ -230,23 +231,23 @@ struct HeaderSection: View { else { return nil } return badge.progress.fraction } - private var activeDownloadIconSystemName: String { + private var activeDownloadIconSymbol: SFSymbol { switch downloadBadge?.status { - case .inactive: return "play.fill" - case .active: return "pause.fill" - default: return downloadIconSystemName + case .inactive: return .playFill + case .active: return .pauseFill + default: return downloadIconSymbol } } - private var downloadIconSystemName: String { + private var downloadIconSymbol: SFSymbol { switch downloadBadge?.status { - case .completed: return "trash" - case .updateAvailable: return "arrow.triangle.2.circlepath" + case .completed: return .trash + case .updateAvailable: return .arrowTrianglehead2ClockwiseRotate90 case .error: return downloadNeedsRepair - ? "wrench.and.screwdriver" - : "exclamationmark.circle" - case .inactive: return "play.fill" - default: return "icloud.and.arrow.down" + ? .wrenchAndScrewdriver + : .exclamationmarkCircle + case .inactive: return .playFill + default: return .icloudAndArrowDown } } private var resolvedCoverURL: URL? { gallery.coverURL } diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 6653b7835..2d123154c 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -384,7 +384,7 @@ private extension DetailView { } .frame(maxWidth: .infinity, alignment: .leading) .padding(14) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .background(.regularMaterial, in: .rect(cornerRadius: 18)) } } diff --git a/EhPanda/View/Downloads/DownloadsView+Subviews.swift b/EhPanda/View/Downloads/DownloadsView+Subviews.swift index 9553323f0..e02dac82a 100644 --- a/EhPanda/View/Downloads/DownloadsView+Subviews.swift +++ b/EhPanda/View/Downloads/DownloadsView+Subviews.swift @@ -320,7 +320,7 @@ struct DownloadListRow: View { Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) + .contentShape(.rect) .onTapGesture(perform: openAction) .accessibilityAddTraits(.isButton) .accessibilityLabel(download.title) diff --git a/EhPanda/View/Reading/Support/LiveTextHandler.swift b/EhPanda/View/Reading/Support/LiveTextHandler.swift index a7e4e46d9..e10e60533 100644 --- a/EhPanda/View/Reading/Support/LiveTextHandler.swift +++ b/EhPanda/View/Reading/Support/LiveTextHandler.swift @@ -2,8 +2,9 @@ // LiveTextHandler.swift // EhPanda // +// reason: the reference URLs below exceed the line-length limit and cannot wrap // swiftlint:disable line_length -// Refercence +// Reference // https://www.codeproject.com/Articles/15573/2D-Polygon-Collision-Detection // https://developer.apple.com/documentation/vision/recognizing_text_in_images // https://github.com/TelegramMessenger/Telegram-iOS/blob/2a32c871882c4e1b1ccdecd34fccd301723b30d9/submodules/Translate/Sources/Translate.swift diff --git a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift index 8b0053a90..6349492f3 100644 --- a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift +++ b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift @@ -126,7 +126,7 @@ private struct AppIconView: View { filename: icon.filename, isSelected: icon == appIconType ) - .contentShape(Rectangle()) + .contentShape(.rect) .onTapGesture { appIconType = icon } } } @@ -154,7 +154,7 @@ private struct AppIconRow: View { .resizable() .scaledToFit() .frame(width: 60, height: 60) - .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) + .clipShape(.rect(cornerRadius: 15)) .padding(.vertical, 10) Text(iconName) diff --git a/EhPanda/View/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift index e3e759097..d9eb46bad 100644 --- a/EhPanda/View/Setting/SettingView.swift +++ b/EhPanda/View/Setting/SettingView.swift @@ -138,7 +138,7 @@ private struct SettingRow: View { .font(.title3).foregroundColor(color) Spacer() } - .contentShape(Rectangle()).padding(.vertical, 10) + .contentShape(.rect).padding(.vertical, 10) .padding(.horizontal, 20).background(backgroundColor) .cornerRadius(10).onTapGesture { tapAction(rowType) } .onLongPressGesture( diff --git a/EhPanda/View/Support/Components/PreviewImageView.swift b/EhPanda/View/Support/Components/PreviewImageView.swift index 2866c2e17..8431238ca 100644 --- a/EhPanda/View/Support/Components/PreviewImageView.swift +++ b/EhPanda/View/Support/Components/PreviewImageView.swift @@ -78,12 +78,7 @@ private struct LocalPreviewImageView: View { Image(uiImage: thumbnail) .resizable() .scaledToFit() - .clipShape( - RoundedRectangle( - cornerRadius: 5, - style: .continuous - ) - ) + .clipShape(.rect(cornerRadius: 5)) } else { placeholder } diff --git a/EhPanda/View/Support/Components/TagSuggestionView.swift b/EhPanda/View/Support/Components/TagSuggestionView.swift index de8173210..4cf77d551 100644 --- a/EhPanda/View/Support/Components/TagSuggestionView.swift +++ b/EhPanda/View/Support/Components/TagSuggestionView.swift @@ -93,7 +93,7 @@ private struct SuggestionCell: View { Spacer() } - .contentShape(Rectangle()) + .contentShape(.rect) .onTapGesture(perform: action) } else { Text("\(Text(displayValue.localizedKey))\n\(Text(suggestion.displayKey.localizedKey))") diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index 2a2b3cae1..7e6bb8f4f 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -83,7 +83,7 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { gid: "707", title: "Completed Archive", status: .completed, - lastDownloadedAt: .distantFuture + lastDownloadedDate: .distantFuture ) let queuedRedownload = sampleDownload( @@ -91,14 +91,14 @@ struct DownloadBadgeSortTests: DownloadFeatureTestCase { title: "Queued Archive", status: .queued, completedPageCount: 12, - lastDownloadedAt: .distantPast + lastDownloadedDate: .distantPast ) let sortedDownloads = [completedDownload, queuedRedownload].sorted { lhs, rhs in if lhs.displayStatus != rhs.displayStatus { return lhs.displayStatus.sortPriority < rhs.displayStatus.sortPriority } - return (lhs.lastDownloadedAt ?? .distantPast) > (rhs.lastDownloadedAt ?? .distantPast) + return (lhs.lastDownloadedDate ?? .distantPast) > (rhs.lastDownloadedDate ?? .distantPast) } #expect(queuedRedownload.displayStatus == .queued) diff --git a/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift b/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift index d1280a8af..20f8b865d 100644 --- a/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift @@ -31,7 +31,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { gid: "100", title: "Complete", pageHashes: ["sha256:1", "sha256:2"], - modifiedAt: Date(timeIntervalSince1970: 100) + modificationDate: Date(timeIntervalSince1970: 100) ) ) try writeIndexedManifest( @@ -41,7 +41,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { gid: "200", title: "Queued", pageHashes: ["sha256:1", ""], - modifiedAt: Date(timeIntervalSince1970: 200) + modificationDate: Date(timeIntervalSince1970: 200) ) ) try FileManager.default.createDirectory( @@ -85,7 +85,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { gid: "500", title: "Old", pageHashes: ["sha256:old"], - modifiedAt: olderDate + modificationDate: olderDate ) ) try setFolderModificationDate( @@ -100,7 +100,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { gid: "500", title: "New", pageHashes: ["sha256:new"], - modifiedAt: newerDate + modificationDate: newerDate ) ) try setFolderModificationDate( @@ -115,7 +115,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { let download = try #require(downloads.first) #expect(download.title == "New") #expect(download.folderURL == storage.folderURL(relativePath: "Folder/[500_token] New")) - #expect(download.lastDownloadedAt == newerDate) + #expect(download.lastDownloadedDate == newerDate) #expect((await manager.indexedDownload(gid: "500")) == download) } @@ -711,7 +711,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { gid: "830", title: "First", pageHashes: [""], - modifiedAt: Date(timeIntervalSince1970: 100) + modificationDate: Date(timeIntervalSince1970: 100) ) ) try writeIndexedManifest( @@ -721,7 +721,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { gid: "831", title: "Newer", pageHashes: [""], - modifiedAt: Date(timeIntervalSince1970: 200) + modificationDate: Date(timeIntervalSince1970: 200) ) ) await manager.reloadDownloadIndex() @@ -962,7 +962,7 @@ private extension DownloadCoordinatorStorageTests { gid: String, title: String, pageHashes: [String], - modifiedAt: Date = .now + modificationDate: Date = .now ) throws -> DownloadManifest { DownloadManifest( gid: gid, @@ -975,7 +975,7 @@ private extension DownloadCoordinatorStorageTests { remoteCoverURL: URL(string: "https://example.com/cover.jpg"), uploader: "Uploader", tags: [], - postedDate: modifiedAt, + postedDate: modificationDate, rating: 4, pages: Dictionary( uniqueKeysWithValues: diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 8a0464482..662d5e380 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -80,7 +80,7 @@ extension DownloadedGallery { localPageURLs: [Int: URL] = [:], displayStatus: DownloadDisplayStatus, completedPageCount: Int, - lastDownloadedAt: Date?, + lastDownloadedDate: Date?, lastError: DownloadFailure? ) { let clampedCompletedPageCount = min(max(completedPageCount, 0), pageCount) @@ -111,7 +111,7 @@ extension DownloadedGallery { folderName: folderName, localCoverURL: localCoverURL, localPageURLs: localPageURLs, - modifiedAt: lastDownloadedAt, + modificationDate: lastDownloadedDate, displayStatus: displayStatus, lastError: lastError ) @@ -195,7 +195,7 @@ extension DownloadFeatureTestCase { category: EhPanda.Category = .doujinshi, pageCount: Int = 12, completedPageCount: Int? = nil, - lastDownloadedAt: Date? = .now, + lastDownloadedDate: Date? = .now, lastError: DownloadFailure? = nil, folderURL: URL? = nil, folderName: String = "Folder", @@ -225,7 +225,7 @@ extension DownloadFeatureTestCase { displayStatus: status.displayStatus, completedPageCount: completedPageCount ?? status.defaultCompletedPageCount(pageCount: pageCount), - lastDownloadedAt: lastDownloadedAt, + lastDownloadedDate: lastDownloadedDate, lastError: lastError ?? status.defaultLastError ) } diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 59a9f359f..240b9a509 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -50,7 +50,7 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { folderURL: URL(fileURLWithPath: "/tmp/111 - Solo Title", isDirectory: true), displayStatus: .completed, completedPageCount: 1, - lastDownloadedAt: .now, + lastDownloadedDate: .now, lastError: nil ) diff --git a/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift b/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift index fec606693..7dff76834 100644 --- a/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift @@ -132,7 +132,7 @@ struct DownloadStoreHashTests { folderURL: folderURL, displayStatus: .completed, completedPageCount: 2, - lastDownloadedAt: .now, + lastDownloadedDate: .now, lastError: nil ) } diff --git a/EhPandaTests/Tests/Download/DownloadStoreTests.swift b/EhPandaTests/Tests/Download/DownloadStoreTests.swift index d65eaecc1..66a133055 100644 --- a/EhPandaTests/Tests/Download/DownloadStoreTests.swift +++ b/EhPandaTests/Tests/Download/DownloadStoreTests.swift @@ -521,7 +521,7 @@ private extension DownloadStoreTests { folderURL: folderURL, displayStatus: displayStatus, completedPageCount: displayStatus == .completed ? 2 : 0, - lastDownloadedAt: .now, + lastDownloadedDate: .now, lastError: nil ) } diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index 7aa08fcdf..c2e3af2c8 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -36,7 +36,7 @@ struct DownloadedGalleryManifestModelTests { @Test func testDownloadedGalleryViewModelUsesManifestAndRuntimeStatus() throws { - let modifiedAt = Date(timeIntervalSince1970: 1_234) + let modificationDate = Date(timeIntervalSince1970: 1_234) let manifest = try sampleManifest(pageHashes: [1: "sha256:a", 2: "sha256:b"]) let download = DownloadedGallery( @@ -45,7 +45,7 @@ struct DownloadedGalleryManifestModelTests { folderName: "Folder", localCoverURL: nil, localPageURLs: [:], - modifiedAt: modifiedAt, + modificationDate: modificationDate, displayStatus: .queued ) @@ -54,7 +54,7 @@ struct DownloadedGalleryManifestModelTests { #expect(download.displayStatus == .queued) #expect(download.onlineCoverURL == manifest.remoteCoverURL) #expect(download.completedPageCount == 2) - #expect(download.lastDownloadedAt == modifiedAt) + #expect(download.lastDownloadedDate == modificationDate) } } From dfe46e8ee07ae4f38cf4e84a6a0818bca6fe886e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 18 Jun 2026 21:47:03 +0800 Subject: [PATCH 271/614] Hold a background-task assertion while downloads run iOS suspends the process within seconds of backgrounding, freezing the in-process page-orchestration loop so queued downloads stop almost immediately. Hold a UIApplication background-task assertion whenever the queue has pending work, keeping the orchestration alive through the OS grace window after backgrounding. Drive begin/end from a single idempotent reconcile at the tail of scheduleNextIfNeeded(), the point every queue mutation converges on, so the assertion is never leaked when the last active download is paused or deleted (those paths null activeTask directly but still reschedule). A reentrancy guard plus a re-check after the begin main-actor hop close the concurrent-begin and drain-across-suspension races. --- .../Tools/Clients/BackgroundTaskClient.swift | 56 +++++ .../DownloadClient+BackgroundAssertion.swift | 59 +++++ .../Clients/DownloadClient+Execution.swift | 7 +- .../Clients/DownloadClient+Manager.swift | 7 + .../Clients/DownloadClient+Scheduling.swift | 7 + .../Clients/DownloadClient+Testing.swift | 4 + .../App/Tools/Clients/DownloadClient.swift | 1 + .../DownloadBackgroundAssertionTests.swift | 222 ++++++++++++++++++ 8 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 EhPanda/App/Tools/Clients/BackgroundTaskClient.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+BackgroundAssertion.swift create mode 100644 EhPandaTests/Tests/Download/DownloadBackgroundAssertionTests.swift diff --git a/EhPanda/App/Tools/Clients/BackgroundTaskClient.swift b/EhPanda/App/Tools/Clients/BackgroundTaskClient.swift new file mode 100644 index 000000000..4931a1c33 --- /dev/null +++ b/EhPanda/App/Tools/Clients/BackgroundTaskClient.swift @@ -0,0 +1,56 @@ +// +// BackgroundTaskClient.swift +// EhPanda +// + +import UIKit +import ComposableArchitecture + +typealias BackgroundTaskToken = UIBackgroundTaskIdentifier + +/// Wraps `UIApplication`'s background-task assertion API so the download coordinator +/// can hold an OS execution assertion while a download is in flight, keeping the +/// in-process orchestration alive through iOS's grace window after backgrounding +/// instead of being suspended within seconds. +/// +/// Mirrors `AppDelegateClient`: a plain `Sendable` struct of `@MainActor` closures +/// rather than a `@DependencyClient`, because `begin` both returns a value and takes +/// an escaping handler. It is injected straight into `DownloadCoordinator` (like +/// `pageDownloader`) instead of being resolved through `DependencyValues`. +struct BackgroundTaskClient: Sendable { + /// Begins a background-task assertion and returns its token. `expirationHandler` + /// fires when the OS is about to reclaim the assertion; the caller must end it then. + let begin: @MainActor @Sendable (_ expirationHandler: @escaping @Sendable () -> Void) -> BackgroundTaskToken + /// Ends a previously begun assertion. A no-op for `.invalid` tokens. + let end: @MainActor @Sendable (BackgroundTaskToken) -> Void +} + +extension BackgroundTaskClient { + static let live = Self( + begin: { expirationHandler in + UIApplication.shared.beginBackgroundTask( + withName: "app.ehpanda.downloads.assertion", + expirationHandler: expirationHandler + ) + }, + end: { token in + guard token != .invalid else { return } + UIApplication.shared.endBackgroundTask(token) + } + ) +} + +// MARK: Test +extension BackgroundTaskClient { + static let noop = Self( + begin: { _ in .invalid }, + end: { _ in } + ) + + static func placeholder() -> Result { fatalError() } + + static let unimplemented = Self( + begin: IssueReporting.unimplemented(placeholder: placeholder()), + end: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundAssertion.swift b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundAssertion.swift new file mode 100644 index 000000000..ec56e3f3e --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundAssertion.swift @@ -0,0 +1,59 @@ +// +// DownloadClient+BackgroundAssertion.swift +// EhPanda +// + +import Foundation + +// MARK: - Background Execution Assertion +extension DownloadCoordinator { + /// Whether any download still needs the in-process orchestration to run. + /// + /// Drives the background-task assertion and the `BGProcessingTask` drain loop, so + /// it must agree with the scheduler about what counts as schedulable work. + func hasPendingWork() async -> Bool { + // A running task is unambiguous work; skip the disk-backed index read. + if activeTask != nil { return true } + let queuedGIDs = queueStore.gids + let downloads = queuedGIDs.isEmpty + ? await indexedDownloads() + : await indexedDownloads(gids: queuedGIDs) + return downloads.contains { + !schedulingBlockedGalleryIDs.contains($0.gid) && shouldSchedule(download: $0) + } + } + + /// Begins or ends the OS background-task assertion to match the current queue + /// state. Invoked from the tail of `scheduleNextIfNeeded()`, the single point every + /// queue mutation converges on, so the assertion can never be leaked when the last + /// active download is paused or deleted (those paths null `activeTask` directly but + /// still reschedule afterward). + func reconcileBackgroundAssertion() async { + guard await hasPendingWork() else { + await endBackgroundAssertion() + return + } + guard backgroundAssertionToken == nil, !isBeginningBackgroundAssertion else { + return + } + isBeginningBackgroundAssertion = true + let token = await backgroundTaskClient.begin { [weak self] in + Task { await self?.endBackgroundAssertion() } + } + // `begin` hops to the main actor, a suspension point across which the queue may + // have drained; re-validate before committing to holding the assertion. + if await hasPendingWork() { + backgroundAssertionToken = token + isBeginningBackgroundAssertion = false + } else { + isBeginningBackgroundAssertion = false + await backgroundTaskClient.end(token) + } + } + + private func endBackgroundAssertion() async { + guard let token = backgroundAssertionToken else { return } + backgroundAssertionToken = nil + await backgroundTaskClient.end(token) + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index ded2ae06a..a5645df20 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -251,7 +251,12 @@ extension DownloadCoordinator { } activeTask = nil activeGalleryID = nil - guard schedulesNext else { return } + guard schedulesNext else { + // The collision-cleanup path skips rescheduling, but the assertion still + // has to be released if this was the last in-flight download. + Task { await self.reconcileBackgroundAssertion() } + return + } Task { await self.scheduleNextIfNeeded() } diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 993fb7e2e..76c37a428 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -161,6 +161,7 @@ actor DownloadCoordinator { let urlSession: URLSession let pageDownloader: DownloadPageDownloader let backgroundTaskStore: DownloadBackgroundTaskStore + let backgroundTaskClient: BackgroundTaskClient let storedCookiesProvider: @Sendable (URL) -> [HTTPCookie] let libraryClient: LibraryClient /// Supplies the latest runtime settings immediately before a queued download starts. @@ -184,12 +185,17 @@ actor DownloadCoordinator { var activeTask: Task? var activeTaskGeneration = 0 var schedulingBlockedGalleryIDs = Set() + var backgroundAssertionToken: BackgroundTaskToken? + /// Set synchronously across the `begin` MainActor hop so a concurrent reconcile + /// cannot issue a second assertion before the first token is recorded. + var isBeginningBackgroundAssertion = false init( storage: DownloadStore, urlSession: URLSession, pageDownloader: DownloadPageDownloader? = nil, backgroundTaskStore: DownloadBackgroundTaskStore? = nil, + backgroundTaskClient: BackgroundTaskClient = .noop, storedCookiesProvider: @escaping @Sendable (URL) -> [HTTPCookie] = { HTTPCookieStorage.shared.cookies(for: $0) ?? [] }, @@ -206,6 +212,7 @@ actor DownloadCoordinator { self.backgroundTaskStore = backgroundTaskStore ?? DownloadBackgroundTaskStore( fileURL: storage.backgroundTaskRegistryURL() ) + self.backgroundTaskClient = backgroundTaskClient self.storedCookiesProvider = storedCookiesProvider self.libraryClient = libraryClient self.downloadOptionsProvider = downloadOptionsProvider diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index 6bc381e75..cb3210b8f 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -13,6 +13,13 @@ extension DownloadCoordinator { } func scheduleNextIfNeeded() async { + await scheduleNextIfNeededCore() + // Reconcile on every exit path of the core (both early-return guards and the + // happy path), so the background-task assertion always matches queue state. + await reconcileBackgroundAssertion() + } + + private func scheduleNextIfNeededCore() async { let queuedGIDs = queueStore.gids let downloads = queuedGIDs.isEmpty ? await indexedDownloads() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index fea81ef5a..01220edab 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -54,5 +54,9 @@ extension DownloadCoordinator { func testingActiveGalleryID() -> String? { activeGalleryID } + + func testingHasBackgroundAssertion() -> Bool { + backgroundAssertionToken != nil + } } #endif diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 5ac48b1d1..f41067f5d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -71,6 +71,7 @@ extension DownloadClient { urlSession: urlSession, pageDownloader: pageDownloader, backgroundTaskStore: backgroundTaskStore, + backgroundTaskClient: .live, downloadOptionsProvider: { await DatabaseClient.live.fetchAppEnv().setting.downloadRequestOptions } diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundAssertionTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundAssertionTests.swift new file mode 100644 index 000000000..a05e6cb1d --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadBackgroundAssertionTests.swift @@ -0,0 +1,222 @@ +// +// DownloadBackgroundAssertionTests.swift +// EhPandaTests +// + +import Foundation +import Synchronization +import UIKit +import Testing +@testable import EhPanda + +@Suite +struct DownloadBackgroundAssertionTests: DownloadFeatureTestCase { + @Test + func testBeginsAssertionWhenWorkScheduled() async throws { + let gid = "200001" + let context = try await makeBlockingCoordinator(gid: gid, title: "Queued") + defer { try? FileManager.default.removeItem(at: context.rootURL) } + + await context.manager.scheduleNextIfNeeded() + + #expect(context.spy.beginCount == 1) + #expect(context.spy.endCount == 0) + #expect(await context.manager.testingHasBackgroundAssertion()) + + _ = await context.manager.pause(gid: gid) + } + + @Test + func testEndsAssertionWhenQueueDrainsViaPause() async throws { + let gid = "200002" + let context = try await makeBlockingCoordinator(gid: gid, title: "Queued") + defer { try? FileManager.default.removeItem(at: context.rootURL) } + + await context.manager.scheduleNextIfNeeded() + #expect(context.spy.beginCount == 1) + + guard case .success = await context.manager.pause(gid: gid) else { + Issue.record("Pause should succeed for the active test download.") + return + } + + // Regression: the queue draining to empty must release the assertion, even + // though `pause` nulls `activeTask` directly instead of via the finish path. + #expect(context.spy.beginCount == 1) + #expect(context.spy.endCount == 1) + #expect(!(await context.manager.testingHasBackgroundAssertion())) + } + + @Test + func testRepeatedSchedulingBeginsAssertionOnce() async throws { + let gid = "200003" + let context = try await makeBlockingCoordinator(gid: gid, title: "Queued") + defer { try? FileManager.default.removeItem(at: context.rootURL) } + + await context.manager.scheduleNextIfNeeded() + await context.manager.scheduleNextIfNeeded() + + #expect(context.spy.beginCount == 1) + #expect(context.spy.endCount == 0) + + _ = await context.manager.pause(gid: gid) + } + + @Test + func testExpirationHandlerReleasesAssertion() async throws { + let gid = "200004" + let context = try await makeBlockingCoordinator(gid: gid, title: "Queued") + defer { try? FileManager.default.removeItem(at: context.rootURL) } + + await context.manager.scheduleNextIfNeeded() + #expect(await context.manager.testingHasBackgroundAssertion()) + + context.spy.fireExpiration() + + try await waitUntil { + await !context.manager.testingHasBackgroundAssertion() + } + #expect(context.spy.beginCount == 1) + #expect(context.spy.endCount == 1) + + _ = await context.manager.pause(gid: gid) + } + + @Test + func testDeleteOfLastActiveDownloadReleasesAssertion() async throws { + let gid = "200005" + let context = try await makeBlockingCoordinator(gid: gid, title: "Queued") + defer { try? FileManager.default.removeItem(at: context.rootURL) } + + await context.manager.scheduleNextIfNeeded() + #expect(context.spy.beginCount == 1) + + guard case .success = await context.manager.delete(gid: gid) else { + Issue.record("Delete should succeed for the active test download.") + return + } + + #expect(context.spy.endCount == 1) + #expect(!(await context.manager.testingHasBackgroundAssertion())) + } + + @Test + func testDeleteFolderOfLastActiveDownloadReleasesAssertion() async throws { + let gid = "200006" + let context = try await makeBlockingCoordinator(gid: gid, title: "Queued") + defer { try? FileManager.default.removeItem(at: context.rootURL) } + + await context.manager.scheduleNextIfNeeded() + #expect(context.spy.beginCount == 1) + + guard case .success = await context.manager.deleteFolder(name: "Folder") else { + Issue.record("Delete folder should succeed for the active test download.") + return + } + + #expect(context.spy.endCount == 1) + #expect(!(await context.manager.testingHasBackgroundAssertion())) + } +} + +// MARK: - Helpers + +private extension DownloadBackgroundAssertionTests { + struct BlockingCoordinatorContext { + let manager: DownloadCoordinator + let storage: DownloadStore + let spy: BackgroundTaskClientSpy + let rootURL: URL + } + + /// Builds a coordinator whose single queued download blocks forever once scheduled, + /// so `activeTask` stays installed and the assertion lifecycle can be observed. + func makeBlockingCoordinator( + gid: String, + title: String + ) async throws -> BlockingCoordinatorContext { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let spy = BackgroundTaskClientSpy() + let taskRunner = DownloadTaskRunner( + runScheduledDownload: { _, _ in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + return .skippedOperation + } + ) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + backgroundTaskClient: spy.client, + taskRunner: taskRunner + ) + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: title), + folderURL: folderURL + ) + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([gid]) + return BlockingCoordinatorContext( + manager: manager, + storage: storage, + spy: spy, + rootURL: rootURL + ) + } + + func waitUntil( + timeout: Duration = .seconds(1), + _ condition: @Sendable () async -> Bool + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while await !condition(), clock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + try #require(await condition(), "Timed out waiting for condition.") + } +} + +// MARK: - Spy + +final class BackgroundTaskClientSpy: Sendable { + private struct State { + var beginCount = 0 + var endCount = 0 + var expirationHandler: (@Sendable () -> Void)? + } + private let state = Mutex(State()) + + var beginCount: Int { state.withLock { $0.beginCount } } + var endCount: Int { state.withLock { $0.endCount } } + + func fireExpiration() { + let handler = state.withLock { $0.expirationHandler } + handler?() + } + + var client: BackgroundTaskClient { + BackgroundTaskClient( + begin: { handler in + self.state.withLock { + $0.beginCount += 1 + $0.expirationHandler = handler + } + return UIBackgroundTaskIdentifier(rawValue: 1) + }, + end: { _ in + self.state.withLock { $0.endCount += 1 } + } + ) + } +} From 25ff6bf81fb1f93956a78fc6e57798e8c41380be Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 18 Jun 2026 21:59:28 +0800 Subject: [PATCH 272/614] Drain the download queue in a BGProcessingTask window The beginBackgroundTask assertion only covers the brief grace period right after backgrounding; a large gallery still needs the system to hand back time later. Declare UIBackgroundModes: [processing], register a BGProcessingTask handler at launch, and submit a request whenever work is pending as the app backgrounds. iOS then relaunches the app in a discretionary, multi-minute window to drain the queue, rescheduling if work remains. This also restores the per-app Background App Refresh toggle. The drain pumps the scheduler itself rather than relying on the detached reschedule hop, and cancels the in-flight download on task expiration so it returns promptly instead of waiting out a transfer that may not finish before suspension. --- EhPanda/App/Info.plist | 8 + .../Clients/BackgroundProcessingClient.swift | 102 +++++++++ .../DownloadClient+BackgroundProcessing.swift | 37 ++++ .../App/Tools/Clients/DownloadClient.swift | 10 +- EhPanda/DataFlow/AppDelegateReducer.swift | 29 +++ EhPanda/DataFlow/AppReducer.swift | 10 +- .../Download/DownloadAutomationTests.swift | 1 + .../DownloadBackgroundProcessingTests.swift | 194 ++++++++++++++++++ 8 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift create mode 100644 EhPanda/App/Tools/Clients/DownloadClient+BackgroundProcessing.swift create mode 100644 EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift diff --git a/EhPanda/App/Info.plist b/EhPanda/App/Info.plist index 2a009ffc0..12259a40e 100644 --- a/EhPanda/App/Info.plist +++ b/EhPanda/App/Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + app.ehpanda.downloads.processing + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -151,6 +155,10 @@ UIApplicationSupportsIndirectInputEvents + UIBackgroundModes + + processing + UIFileSharingEnabled UILaunchScreen diff --git a/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift b/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift new file mode 100644 index 000000000..e448fc767 --- /dev/null +++ b/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift @@ -0,0 +1,102 @@ +// +// BackgroundProcessingClient.swift +// EhPanda +// + +import BackgroundTasks +import ComposableArchitecture + +enum BackgroundProcessing { + /// Fixed task identifier, independent of the bundle id, so the local + /// `app.ehpanda.personal` re-sign does not change it. Must stay in sync with the + /// `BGTaskSchedulerPermittedIdentifiers` entry in Info.plist. + static let downloadTaskIdentifier = "app.ehpanda.downloads.processing" +} + +/// Wraps `BGTaskScheduler` so the app can ask iOS to relaunch it in a discretionary, +/// multi-minute background window to drain the download queue after the foreground +/// grace period ends. Unlike `BackgroundTaskClient`, this is resolved through +/// `DependencyValues` because both the AppDelegate (registration) and `AppReducer` +/// (scheduling) need it. +struct BackgroundProcessingClient: Sendable { + /// Registers the launch handler for the download processing task. Must be called + /// before the app finishes launching. Returns whether registration succeeded. + var register: @MainActor @Sendable (_ handler: @escaping @MainActor @Sendable (BGProcessingTask) -> Void) -> Bool + /// Submits a processing-task request. Returns `false` when the system refuses it + /// (Background App Refresh disabled, identifier not permitted, etc.) — tolerated. + var schedule: @Sendable () -> Bool + /// Cancels any pending download processing-task request. + var cancel: @Sendable () -> Void +} + +extension BackgroundProcessingClient { + static let live = Self( + register: { handler in + BGTaskScheduler.shared.register( + forTaskWithIdentifier: BackgroundProcessing.downloadTaskIdentifier, + using: .main + ) { task in + // Registered against the main queue, so the launch handler runs on the + // main thread; bridge the un-annotated callback onto the main actor. + MainActor.assumeIsolated { + guard let processingTask = task as? BGProcessingTask else { + task.setTaskCompleted(success: false) + return + } + handler(processingTask) + } + } + }, + schedule: { + let request = BGProcessingTaskRequest( + identifier: BackgroundProcessing.downloadTaskIdentifier + ) + request.requiresNetworkConnectivity = true + request.requiresExternalPower = false + request.earliestBeginDate = nil + do { + try BGTaskScheduler.shared.submit(request) + return true + } catch { + Logger.error(error) + return false + } + }, + cancel: { + BGTaskScheduler.shared.cancel( + taskRequestWithIdentifier: BackgroundProcessing.downloadTaskIdentifier + ) + } + ) +} + +// MARK: API +enum BackgroundProcessingClientKey: DependencyKey { + static let liveValue = BackgroundProcessingClient.live + static let previewValue = BackgroundProcessingClient.noop + static let testValue = BackgroundProcessingClient.unimplemented +} + +extension DependencyValues { + var backgroundProcessingClient: BackgroundProcessingClient { + get { self[BackgroundProcessingClientKey.self] } + set { self[BackgroundProcessingClientKey.self] = newValue } + } +} + +// MARK: Test +extension BackgroundProcessingClient { + static let noop = Self( + register: { _ in false }, + schedule: { false }, + cancel: {} + ) + + static func placeholder() -> Result { fatalError() } + + static let unimplemented = Self( + register: IssueReporting.unimplemented(placeholder: placeholder()), + schedule: IssueReporting.unimplemented(placeholder: placeholder()), + cancel: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundProcessing.swift b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundProcessing.swift new file mode 100644 index 000000000..21deb3bea --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundProcessing.swift @@ -0,0 +1,37 @@ +// +// DownloadClient+BackgroundProcessing.swift +// EhPanda +// + +import Foundation + +// MARK: - Background Processing Drain +extension DownloadCoordinator { + /// Drives the queue to completion (or until cancelled), pumping the scheduler + /// itself rather than relying on the detached reschedule `Task` that + /// `finishActiveTaskIfOwned` installs one hop later. + /// + /// Invoked from the `BGProcessingTask` handler. On cancellation (the task's + /// expiration), the in-flight download is cancelled so the loop can observe the + /// cancellation and return promptly instead of waiting out a transfer that may not + /// finish before the process is suspended. + func runQueueUntilIdle() async { + while !Task.isCancelled { + await scheduleNextIfNeeded() + guard let task = activeTask else { + if await hasPendingWork() { + // The reschedule hop has not installed the next task yet; yield and + // re-check rather than declaring the queue idle prematurely. + await Task.yield() + continue + } + break + } + await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + } + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index f41067f5d..74a73a975 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -31,6 +31,8 @@ struct DownloadClient: Sendable { var renameFolder: @Sendable (String, String) async throws -> Void var deleteFolder: @Sendable (String) async throws -> Void var moveDownload: @Sendable (String, String) async throws -> Void + var hasPendingWork: @Sendable () async -> Bool = { false } + var runBackgroundProcessing: @Sendable () async -> Void } extension DownloadClient { @@ -139,7 +141,9 @@ extension DownloadClient { deleteFolder: { name in try await manager.deleteFolder(name: name).get() }, moveDownload: { gid, folderName in try await manager.moveDownload(gid: gid, toFolderName: folderName).get() - } + }, + hasPendingWork: { await manager.hasPendingWork() }, + runBackgroundProcessing: { await manager.runQueueUntilIdle() } ) } } @@ -183,6 +187,8 @@ extension DownloadClient { createFolder: { _ in }, renameFolder: { _, _ in }, deleteFolder: { _ in }, - moveDownload: { _, _ in } + moveDownload: { _, _ in }, + hasPendingWork: { false }, + runBackgroundProcessing: {} ) } diff --git a/EhPanda/DataFlow/AppDelegateReducer.swift b/EhPanda/DataFlow/AppDelegateReducer.swift index 67c069e89..2450a53b2 100644 --- a/EhPanda/DataFlow/AppDelegateReducer.swift +++ b/EhPanda/DataFlow/AppDelegateReducer.swift @@ -4,6 +4,7 @@ // import SwiftUI +import BackgroundTasks import SwiftyBeaver import ComposableArchitecture @@ -67,10 +68,38 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) -> Bool { if !AppUtil.isTesting { store.send(.appDelegate(.onLaunchFinish)) + // Must register before launch completes so iOS can relaunch us later to + // drain the download queue in a discretionary background window. + _ = BackgroundProcessingClient.live.register { task in + AppDelegate.handleProcessingTask(task) + } } return true } + /// Drains the download queue in the granted background window. On expiration the + /// in-flight work is cancelled and a fresh request is scheduled so iOS can hand the + /// remaining work back later. + @MainActor + static func handleProcessingTask(_ task: BGProcessingTask) { + @Dependency(\.downloadClient) var downloadClient + @Dependency(\.backgroundProcessingClient) var backgroundProcessingClient + + let work = Task { @MainActor in + await downloadClient.runBackgroundProcessing() + // Reschedule only if we stopped on our own with work still pending; an + // expiration cancels this task and reschedules from its own handler. + if !Task.isCancelled, await downloadClient.hasPendingWork() { + _ = backgroundProcessingClient.schedule() + } + task.setTaskCompleted(success: !Task.isCancelled) + } + task.expirationHandler = { + work.cancel() + _ = backgroundProcessingClient.schedule() + } + } + func application( _ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index 9549092df..e9dc17856 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -48,6 +48,7 @@ struct AppReducer { @Dependency(\.cookieClient) private var cookieClient @Dependency(\.deviceClient) private var deviceClient @Dependency(\.downloadClient) private var downloadClient + @Dependency(\.backgroundProcessingClient) private var backgroundProcessingClient @Dependency(\.appLaunchAutomationClient) private var appLaunchAutomationClient @Dependency(\.urlClient) private var urlClient @@ -98,7 +99,14 @@ struct AppReducer { case .background: state.hasEnteredBackground = true - return .none + // Ask iOS for a later background window to finish the queue; the + // beginBackgroundTask assertion only covers the brief grace + // period right after backgrounding. + return .run { _ in + if await downloadClient.hasPendingWork() { + _ = backgroundProcessingClient.schedule() + } + } default: return .none diff --git a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift index dc06d93d9..106c95494 100644 --- a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift @@ -31,6 +31,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { $0.downloadClient.reconcileDownloads = { reconcileCount.value += 1 } + $0.downloadClient.hasPendingWork = { false } $0.downloadClient.refreshDownloads = {} $0.downloadClient.enqueue = { _ in } $0.downloadClient.togglePause = { _ in } diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift new file mode 100644 index 000000000..e53191d42 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift @@ -0,0 +1,194 @@ +// +// DownloadBackgroundProcessingTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadBackgroundProcessingTests: DownloadFeatureTestCase { + @Test + func testHasPendingWorkReflectsQueueState() async throws { + let gid = "210001" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + + await manager.reloadDownloadIndex() + #expect(!(await manager.hasPendingWork())) + + try writeQueuedManifest(storage: storage, gid: gid, title: "Queued") + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([gid]) + #expect(await manager.hasPendingWork()) + } + + @Test + func testRunQueueUntilIdleDrainsAllQueuedItems() async throws { + let sessionID = UUID().uuidString + let gids = ["210011", "210012", "210013"] + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, manager) = makeStubbedDownloadCoordinator( + rootURL: rootURL, + sessionID: sessionID + ) + // Every request fails, so each scheduled download settles to .error and leaves + // the queue — letting the drain converge without a live network. + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + throw URLError(.notConnectedToInternet) + } + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + for gid in gids { + try writeQueuedManifest(storage: storage, gid: gid, title: "Queued \(gid)") + } + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs(gids) + #expect(await manager.hasPendingWork()) + + await manager.runQueueUntilIdle() + + #expect(!(await manager.hasPendingWork())) + for gid in gids { + #expect(await manager.fetchDownload(gid: gid)?.displayStatus == .error) + } + } + + @Test + func testRunQueueUntilIdleReturnsPromptlyOnCancellation() async throws { + let gid = "210021" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskRunner = DownloadTaskRunner( + runScheduledDownload: { _, _ in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + return .skippedOperation + } + ) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + taskRunner: taskRunner + ) + + try writeQueuedManifest(storage: storage, gid: gid, title: "Blocking") + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([gid]) + + let drainTask = Task { await manager.runQueueUntilIdle() } + try await waitUntil { await manager.testingHasActiveTask() } + drainTask.cancel() + + // Without the cancellation handler the drain would block on the never-finishing + // transfer; cancelling it must cancel the active task and return. + _ = try await waitForTaskValue( + drainTask, + timeout: .seconds(2), + description: "runQueueUntilIdle cancellation" + ) + } + + @MainActor + @Test + func testBackgroundSchedulesProcessingWhenWorkPending() async { + let scheduleCount = UncheckedBox(0) + let store = makeBackgroundStore(hasPendingWork: true, scheduleCount: scheduleCount) + + await store.send(.onScenePhaseChange(.background)) { + $0.scenePhase = .background + $0.hasEnteredBackground = true + } + await store.finish() + + #expect(scheduleCount.value == 1) + } + + @MainActor + @Test + func testBackgroundSkipsSchedulingWhenIdle() async { + let scheduleCount = UncheckedBox(0) + let store = makeBackgroundStore(hasPendingWork: false, scheduleCount: scheduleCount) + + await store.send(.onScenePhaseChange(.background)) { + $0.scenePhase = .background + $0.hasEnteredBackground = true + } + await store.finish() + + #expect(scheduleCount.value == 0) + } +} + +// MARK: - Helpers + +private extension DownloadBackgroundProcessingTests { + func writeQueuedManifest( + storage: DownloadStore, + gid: String, + title: String + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: title), + folderURL: folderURL + ) + } + + func waitUntil( + timeout: Duration = .seconds(1), + _ condition: @Sendable () async -> Bool + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while await !condition(), clock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + try #require(await condition(), "Timed out waiting for condition.") + } + + @MainActor + func makeBackgroundStore( + hasPendingWork: Bool, + scheduleCount: UncheckedBox + ) -> TestStoreOf { + var initialState = AppReducer.State() + initialState.settingState.hasLoadedInitialSetting = true + let store = TestStore( + initialState: initialState, + reducer: AppReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = .none + $0.cookieClient = .noop + $0.downloadClient = DownloadClient() + $0.downloadClient.hasPendingWork = { hasPendingWork } + $0.backgroundProcessingClient = BackgroundProcessingClient( + register: { _ in true }, + schedule: { + scheduleCount.value += 1 + return true + }, + cancel: {} + ) + } + ) + store.exhaustivity = .off + return store + } +} From a43cc6c9ac3cef80e5a3b09b870474613e1b2ebc Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 21 Jun 2026 15:22:01 +0800 Subject: [PATCH 273/614] Update setting page layout --- EhPanda/View/Setting/SettingReducer.swift | 2 +- EhPanda/View/Setting/SettingView.swift | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/EhPanda/View/Setting/SettingReducer.swift b/EhPanda/View/Setting/SettingReducer.swift index 320b381eb..5b4c522f6 100644 --- a/EhPanda/View/Setting/SettingReducer.swift +++ b/EhPanda/View/Setting/SettingReducer.swift @@ -15,8 +15,8 @@ struct SettingReducer { case account case general case appearance - case reading case download + case reading case laboratory case about } diff --git a/EhPanda/View/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift index d9eb46bad..7a6800abb 100644 --- a/EhPanda/View/Setting/SettingView.swift +++ b/EhPanda/View/Setting/SettingView.swift @@ -133,7 +133,7 @@ private struct SettingRow: View { HStack { Image(systemSymbol: rowType.symbol) .font(.largeTitle).foregroundColor(color) - .padding(.trailing, 20).frame(width: 45) + .padding(.trailing, 20).frame(width: 45, height: 45) Text(rowType.value).fontWeight(.medium) .font(.title3).foregroundColor(color) Spacer() @@ -158,10 +158,10 @@ extension SettingReducer.Route { return L10n.Localizable.Enum.SettingStateRoute.Value.general case .appearance: return L10n.Localizable.Enum.SettingStateRoute.Value.appearance - case .reading: - return L10n.Localizable.Enum.SettingStateRoute.Value.reading case .download: return L10n.Localizable.Enum.SettingStateRoute.Value.download + case .reading: + return L10n.Localizable.Enum.SettingStateRoute.Value.reading case .laboratory: return L10n.Localizable.Enum.SettingStateRoute.Value.laboratory case .about: @@ -176,14 +176,14 @@ extension SettingReducer.Route { return .switch2 case .appearance: return .circleRighthalfFilled - case .reading: - return .newspaperFill case .download: - return .squareAndArrowDownOnSquareFill + return .squareAndArrowDownOnSquare + case .reading: + return .newspaper case .laboratory: return .testtube2 case .about: - return .infoCircleFill + return .infoCircle } } } From b7425e0852278c23f78e651a9e9eeba132812e4d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 21 Jun 2026 16:00:25 +0800 Subject: [PATCH 274/614] Drop unnecessary MainActor.assumeIsolated --- .../Clients/BackgroundProcessingClient.swift | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift b/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift index e448fc767..e58652dac 100644 --- a/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift +++ b/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift @@ -7,8 +7,7 @@ import BackgroundTasks import ComposableArchitecture enum BackgroundProcessing { - /// Fixed task identifier, independent of the bundle id, so the local - /// `app.ehpanda.personal` re-sign does not change it. Must stay in sync with the + /// Fixed task identifier, independent of the bundle id. Must stay in sync with the /// `BGTaskSchedulerPermittedIdentifiers` entry in Info.plist. static let downloadTaskIdentifier = "app.ehpanda.downloads.processing" } @@ -36,15 +35,11 @@ extension BackgroundProcessingClient { forTaskWithIdentifier: BackgroundProcessing.downloadTaskIdentifier, using: .main ) { task in - // Registered against the main queue, so the launch handler runs on the - // main thread; bridge the un-annotated callback onto the main actor. - MainActor.assumeIsolated { - guard let processingTask = task as? BGProcessingTask else { - task.setTaskCompleted(success: false) - return - } - handler(processingTask) + guard let processingTask = task as? BGProcessingTask else { + task.setTaskCompleted(success: false) + return } + handler(processingTask) } }, schedule: { From 92773510338f222eb1093003b4e38e4ca2e55f00 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 21 Jun 2026 16:32:34 +0800 Subject: [PATCH 275/614] Document deliberate download and reading-pipeline designs Add /// doc comments capturing the rationale behind the non-obvious deliberate designs in the download + WebP-support features, so the intent reads from the code instead of looking like a bug: - Owned DataCache + byte-based animated/still routing (DataCache, Data.isAnimatedImageData, ImageAsset.isAnimated); Live Text scans still images only. - DownloadCoordinator / DownloadStore / DownloadObserverHub split by invariant ownership; downloadIndex as a write-through cache. - Hashes recorded at write time only; content validation is user-initiated (validateImageData). - ReadingContentSource .local as an explicit offline mode (network kill-switch + manifest metadata provenance + auto-promote); localPageURLs as the single local source of truth. - DownloadRequestOptions as always-latest execution policy, never persisted and kept out of DownloadRequestPayload. - manifest.json holds identity; the [gid_token] Title folder layout is a deliberate Files-app bet with membership following filesystem location. - The four failure/status fields are intentionally in-memory only. --- .../Clients/DownloadClient+Manager.swift | 24 +++++++++++++++++++ .../DownloadClient+PersistenceNormalize.swift | 5 ++++ EhPanda/App/Tools/Clients/ImageClient.swift | 3 +++ .../Extensions/AnimatedImage_Extension.swift | 6 +++++ EhPanda/App/Tools/Utilities/DataCache.swift | 12 ++++++++++ .../Utilities/DownloadStore+Operations.swift | 5 ++++ .../App/Tools/Utilities/DownloadStore.swift | 14 +++++++++++ .../Download/DownloadRequestOptions.swift | 5 ++++ .../DownloadedGallery+Extensions.swift | 11 +++++++++ .../Download/DownloadedGallery+Manifest.swift | 6 +++++ .../Reading/ReadingReducer+Database.swift | 4 ++++ EhPanda/View/Reading/ReadingReducer.swift | 3 +++ EhPanda/View/Reading/ReadingView.swift | 6 +++++ 13 files changed, 104 insertions(+) diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 76c37a428..66ca4f6f7 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -40,6 +40,15 @@ struct DownloadTaskRunner: Sendable { } } +/// The brain of the download subsystem: the in-memory read model (`downloadIndex`, +/// `userFolders`) fused with scheduling (`activeGalleryID`, `activeTask`, queued +/// modes / selections). It is one of three types the old monolith was split into by +/// invariant ownership, alongside `DownloadStore` (pure disk I/O) and +/// `DownloadObserverHub` (observer fan-out), all behind the unchanged `DownloadClient` +/// facade. Read model and scheduling stay fused on purpose: only one gallery downloads at +/// a time (E-Hentai rate-limits gallery downloads, so concurrency is unwanted), and +/// scheduling reads and writes the index on every step, so splitting them would buy nothing +/// and reintroduce the cross-actor races this single actor exists to prevent. actor DownloadCoordinator { static let retryLimit = 3 static let progressFlushPageInterval = 8 @@ -172,9 +181,20 @@ actor DownloadCoordinator { let queueStore: DownloadQueueStore let taskRunner: DownloadTaskRunner let observerHub = DownloadObserverHub() + /// Write-through cache of the on-disk download tree and the read authority between the + /// explicit scan boundaries (see `indexedDownload(gid:)`). The filesystem stays the + /// source of truth, so this is rebuilt from disk only at those boundaries, never on a + /// hot lookup. var downloadIndex = [String: DownloadFolderRecord]() var hasLoadedIndex = false var userFolders = [String]() + /// Transient, session-scoped status: deliberately in-memory only, never written to disk. + /// Download-level errors, per-page failures, validation results, and the update-available + /// set are status *about* a download, not durable properties of it; they are cheap to + /// re-derive and re-derivation yields the *current* truth (e.g. a lifted quota simply + /// succeeds on the next attempt). Durable facts (downloaded pages, hashes, metadata) + /// live in the manifest. The accepted cost is that after relaunch a failed download + /// surfaces as inactive ("Paused") until its error re-surfaces on the next manual retry. var downloadErrors = [String: DownloadFailure]() var validationErrors = [String: DownloadFailure]() var failedPageErrors = [String: [Int: PageFailure]]() @@ -225,6 +245,10 @@ actor DownloadCoordinator { } } +/// Owns the observer continuations and the last snapshot broadcast to them, kept apart from +/// the coordinator's state so notification can never interleave with a state mutation. The +/// coordinator computes a snapshot and hands it here to fan out; this type holds no download +/// state of its own. actor DownloadObserverHub { private var lastObservedDownloads = [DownloadedGallery]() private var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 15eb8af16..0a59c9180 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -75,6 +75,11 @@ extension DownloadCoordinator { downloadErrors[activeGalleryID] = nil } + /// User-initiated integrity check (the inspector's "validate" action): the only path + /// that re-reads page bytes and verifies them against their recorded hashes + /// (`verifiesContentHashes: true`). Routine scans and opens check file *presence* only; + /// automatic content re-validation was removed because it re-hashed whole galleries on + /// hot paths. The result is session-scoped status (`validationErrors`), not persisted. func validateImageData(gid: String) async -> DownloadValidationState? { guard let download = await fetchDownload(gid: gid), download.canValidateImageData diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 857753e15..c87d5c338 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -13,6 +13,9 @@ struct ImageClient: Sendable { let image: UIImage let data: Data + /// Render / export routing for this asset: `true` → SDWebImage (animated), + /// `false` → Kingfisher / `UIImage` (still). Decided from the actual bytes, not the + /// request URL. See `Data.isAnimatedImageData`. var isAnimated: Bool { data.isAnimatedImageData || image.hasAnimatedFrames } diff --git a/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift index a0bfcb3e2..ea3a4098b 100644 --- a/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift +++ b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift @@ -81,6 +81,12 @@ extension Data { && hasBytes(ImageDataSignature.webp, at: 8) } + /// Whether the bytes are an image that *actually animates*. This is the routing key for + /// rendering and export: animated data is rendered by SDWebImage, still data by + /// Kingfisher / `UIImage`. The decision is made from the bytes themselves (GIF frame + /// count, APNG `acTL` before `IDAT`, WebP VP8X animation bit), never the URL's file + /// extension: the extension is known before any bytes exist and is wrong for mislabeled + /// or content-negotiated images. var isAnimatedImageData: Bool { isAnimatedGIFFormat || isAPNGFormat || isAnimatedWebPFormat } diff --git a/EhPanda/App/Tools/Utilities/DataCache.swift b/EhPanda/App/Tools/Utilities/DataCache.swift index fc27e5bc4..7432851a0 100644 --- a/EhPanda/App/Tools/Utilities/DataCache.swift +++ b/EhPanda/App/Tools/Utilities/DataCache.swift @@ -7,6 +7,18 @@ import CryptoKit import Foundation import UIKit +/// Owned byte-level cache for the reader page pipeline and its export actions +/// (copy / save / share). It is the one place the app downloads and stores reading-page +/// image bytes itself instead of letting an image library own the transfer. +/// +/// Two render engines are kept on purpose: Kingfisher is primary, and SDWebImage stays +/// only because it renders animated images (WebP / APNG / GIF) that KingfisherWebP +/// renders poorly. Letting each engine fetch and cache on its own split one logical cache +/// in two and forced animated-vs-still routing to be decided from the URL before any bytes +/// existed. Owning the bytes here collapses that into a single fetch + cache; the decoded +/// data is then handed to whichever engine renders it (routed by `Data.isAnimatedImageData`). +/// Scope is deliberately the reader and its exports only. Covers, previews, and cells stay +/// on plain Kingfisher URL-mode caching. actor DataCache { struct Configuration: Equatable, Sendable { var rootURL: URL diff --git a/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift index f92cbd3e0..e18a2aa38 100644 --- a/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift @@ -73,6 +73,11 @@ extension DownloadStore { } } + /// Returns the manifest with any missing page hashes filled in. Hashes are recorded at + /// write time (per page as it downloads, and on capture-restore), so finalize only + /// *merges*: it hashes a page solely when its recorded hash is empty, never re-hashing + /// the whole gallery. There is no automatic re-validation anywhere; verifying existing + /// bytes against their hashes is a user-initiated action (`validateImageData(gid:)`). func addingCurrentFileHashes( to manifest: DownloadManifest, folderURL: URL diff --git a/EhPanda/App/Tools/Utilities/DownloadStore.swift b/EhPanda/App/Tools/Utilities/DownloadStore.swift index e29298e6b..2da6a932e 100644 --- a/EhPanda/App/Tools/Utilities/DownloadStore.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore.swift @@ -26,6 +26,11 @@ struct DownloadScanResult: Equatable, Sendable { let userFolders: [String] } +/// Pure filesystem / manifest / hash I/O for downloads. The filesystem is the source of +/// truth (per-folder `manifest.json` + page files), so this type holds no cross-call +/// in-memory state and is race-free by construction: every method reads or writes disk and +/// returns. It is the I/O half of the download subsystem split; the `DownloadCoordinator` +/// actor owns the mutable read model and scheduling on top of it. struct DownloadStore: Sendable { private static let maxFolderComponentByteCount = 255 @@ -160,6 +165,11 @@ struct DownloadStore: Sendable { .lastPathComponent } + /// Builds the on-disk folder name as `[gid_token] Title`. The readable title is a + /// deliberate Files-app-integration bet (the app sets `UIFileSharingEnabled` / + /// `LSSupportsOpeningDocumentsInPlace`), and the `[gid_token]` prefix keeps identity + /// resolvable from the name alone. The title is truncated to keep the whole component + /// within the filesystem's per-name byte limit. func makeFolderRelativePath(gid: String, token: String, title: String) -> String { let prefix = galleryFolderNamePrefix(gid: gid, token: token) let titleByteCount = max(Self.maxFolderComponentByteCount - prefix.utf8.count, 0) @@ -170,6 +180,10 @@ struct DownloadStore: Sendable { "[\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))] " } + /// Finds every folder belonging to a gallery, because folder membership follows + /// filesystem location, not a stored list. A folder the user moved in the Files app is + /// still found. The `[gid_token]` name prefix is the fast path; any folder without it is + /// confirmed by reading its manifest's `gid` / `token`, so a renamed folder still matches. func galleryFolderURLs(gid: String, token: String) -> [URL] { guard fileManager.operate({ $0.fileExists(atPath: rootURL.path) }) else { return [] diff --git a/EhPanda/Models/Download/DownloadRequestOptions.swift b/EhPanda/Models/Download/DownloadRequestOptions.swift index b3ac29b8e..89f989465 100644 --- a/EhPanda/Models/Download/DownloadRequestOptions.swift +++ b/EhPanda/Models/Download/DownloadRequestOptions.swift @@ -3,6 +3,11 @@ // EhPanda // +/// Execution policy for a download: *how* to fetch (thread limit, cellular, auto-retry), +/// not *what* to fetch. Deliberately separate from `DownloadRequestPayload` and never +/// persisted to a manifest or request: it is resolved fresh from the latest settings once +/// per run (see `downloadOptionsProvider`) and threaded to the workers, so a settings change +/// while a gallery sits queued takes effect when it finally starts. struct DownloadRequestOptions: Equatable, Sendable { var threadLimit = 1 var allowCellular = true diff --git a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift index db04f66ac..3ea7d03ac 100644 --- a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift @@ -30,6 +30,10 @@ extension DownloadBadge { } // MARK: - DownloadRequestPayload +/// Request *identity*: *what* to download. It intentionally carries no execution options +/// (thread limit, cellular, auto-retry); those are always-latest policy resolved per run, so +/// keeping them out of the payload makes "never persisted, always fresh" a structural +/// guarantee rather than a convention. See `DownloadRequestOptions`. struct DownloadRequestPayload: Equatable, Sendable { let gallery: Gallery let galleryDetail: GalleryDetail @@ -65,6 +69,13 @@ struct DownloadRequestPayload: Equatable, Sendable { } // MARK: - ReadingContentSource +/// How the reader sources its pages. `.local` is an explicit *offline mode*, not a redundant +/// copy of `.remote`: it is a wholesale network kill-switch (remote mode reads a downloaded +/// file per page when present, so a single missing entry would otherwise trigger a live, +/// quota-burning H@H fetch; the offline gate prevents that for offline reads) and it carries +/// manifest metadata provenance (gallery + language seeded from the manifest, so a downloaded +/// gallery is readable with no database record). When the local files turn up empty it +/// auto-promotes to `.remote`. enum ReadingContentSource: Equatable { case remote case local(DownloadedGallery, DownloadManifest) diff --git a/EhPanda/Models/Download/DownloadedGallery+Manifest.swift b/EhPanda/Models/Download/DownloadedGallery+Manifest.swift index 63e66717b..37d6b12f1 100644 --- a/EhPanda/Models/Download/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Download/DownloadedGallery+Manifest.swift @@ -5,6 +5,12 @@ import Foundation +/// The identity record for a downloaded gallery, written to `manifest.json` in its folder. +/// Identity lives *here* (`gid` / `token`), not in the folder path: the human-readable +/// `[gid_token] Title` folder name is presentation, and the title in it can change and +/// re-slot the directory without affecting identity. Folder membership follows the file's +/// location on disk, so this manifest is what re-establishes identity after the gallery is +/// moved or renamed via the Files app. struct DownloadManifest: Codable, Equatable, Sendable { let gid: String let host: GalleryHost diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift index 6faafdd79..36b306ac2 100644 --- a/EhPanda/View/Reading/ReadingReducer+Database.swift +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -147,6 +147,7 @@ extension ReadingReducer { state: inout State, requestID: UUID, localPageURLs: [Int: URL] ) -> Effect { guard state.localPageRequestID == requestID else { return .none } + // Local files turned up empty; fall back to remote so the gallery is still readable. if case .local = state.contentSource, localPageURLs.isEmpty { state.contentSource = .remote @@ -164,6 +165,9 @@ extension ReadingReducer { return .none } + /// Enters offline mode: seeds the gallery and language from the manifest (so a downloaded + /// gallery reads with no database record) and makes `localPageURLs` the only page source, + /// clearing the remote URL maps that don't apply offline. func applyLocalSource( state: inout State, download: DownloadedGallery, diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index 091c7a512..b7e9e98dd 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -50,6 +50,9 @@ struct ReadingReducer { var previewConfig: PreviewConfig = .normal(rows: 4) var previewURLs = [Int: URL]() + /// The single source of truth for downloaded page files. It is not copied into the + /// other URL maps; both offline reads and the opportunistic "use the downloaded file + /// if present" check in remote mode resolve a page through this map alone. var localPageURLs = [Int: URL]() var localPageRequestID = UUID() diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 3e7274797..d53ffa15e 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -309,6 +309,9 @@ extension ReadingView { } } + /// Runs Live Text over a downloaded page file. Animated images are skipped by design + /// (Live Text scans still images only), so a single non-animating frame is never lifted + /// out of an animation. private func analyzeLocalImage(at imageURL: URL, index: Int) { guard let data = try? Data(contentsOf: imageURL), !data.isAnimatedImageData, @@ -325,6 +328,9 @@ extension ReadingView { ) } + /// Runs Live Text over a remote page's cached bytes, read from the owned `DataCache` + /// (the reader's cache, not Kingfisher's). Animated images are skipped by design + /// (Live Text scans still images only). private func analyzeCachedImageData(cacheKeys: [String], index: Int) async { guard let data = await DataCache.shared.data(forKeys: cacheKeys), !data.isAnimatedImageData, From a2d2f08cafc3529fc894805cd4f4186b4a7ee6e3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 21 Jun 2026 17:06:18 +0800 Subject: [PATCH 276/614] Adopt @DependencyClient for AppLaunchAutomationClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its single endpoint returns an Optional, so the macro generates a loud unimplemented default (reportIssue + nil) for the test value — matching the hand-written `.unimplemented` it replaces, with no loss of the test-failure safety net. Drops the manual init/unimplemented/placeholder ceremony, aligning with DownloadClient. The other branch-new clients are deliberately left on the manual pattern: BackgroundProcessingClient (register/schedule return non-Optional Bool, which @DependencyClient can only default silently), and BackgroundTaskClient and DownloadPageDownloader (injected straight into DownloadCoordinator, not resolved through DependencyValues). --- .../Tools/Clients/AppLaunchAutomationClient.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift b/EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift index 8b1ae8287..5a12f3378 100644 --- a/EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift +++ b/EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift @@ -3,10 +3,11 @@ // EhPanda // -import Dependencies +import ComposableArchitecture +@DependencyClient struct AppLaunchAutomationClient: Sendable { - let current: @Sendable () -> AppLaunchAutomation? + var current: @Sendable () -> AppLaunchAutomation? } extension AppLaunchAutomationClient { @@ -20,7 +21,7 @@ extension AppLaunchAutomationClient { enum AppLaunchAutomationClientKey: DependencyKey { static let liveValue = AppLaunchAutomationClient.live static let previewValue = AppLaunchAutomationClient.none - static let testValue = AppLaunchAutomationClient.unimplemented + static let testValue = AppLaunchAutomationClient() } extension DependencyValues { @@ -34,10 +35,4 @@ extension AppLaunchAutomationClient { static let none: Self = .init( current: { nil } ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - current: IssueReporting.unimplemented(placeholder: placeholder()) - ) } From 625a3bde63b33a2db8da9671f5b764bacd108d5f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 21 Jun 2026 17:28:46 +0800 Subject: [PATCH 277/614] Adopt @DependencyClient for BackgroundProcessingClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the Dependencies docs, @DependencyClient only generates a loud unimplemented test value for throws/Void/Optional endpoints; a non-Optional value return (Bool) would force an explicit silent default, reintroducing the silent-default hazard. register/schedule returned Bool that every caller discards (`_ = …`), so modeling fire-and-forget work as Bool was the smell. Convert register/schedule to Void to match real usage, then apply the macro: all three endpoints now get loud unimplemented test values, and the manual init/unimplemented/placeholder ceremony is gone. The live `schedule` still logs a refused submission internally. testValue becomes BackgroundProcessingClient(). --- .../Clients/BackgroundProcessingClient.swift | 30 +++++++------------ EhPanda/DataFlow/AppDelegateReducer.swift | 6 ++-- EhPanda/DataFlow/AppReducer.swift | 2 +- .../DownloadBackgroundProcessingTests.swift | 3 +- 4 files changed, 16 insertions(+), 25 deletions(-) diff --git a/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift b/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift index e58652dac..7e2244c7c 100644 --- a/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift +++ b/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift @@ -17,13 +17,15 @@ enum BackgroundProcessing { /// grace period ends. Unlike `BackgroundTaskClient`, this is resolved through /// `DependencyValues` because both the AppDelegate (registration) and `AppReducer` /// (scheduling) need it. +@DependencyClient struct BackgroundProcessingClient: Sendable { /// Registers the launch handler for the download processing task. Must be called - /// before the app finishes launching. Returns whether registration succeeded. - var register: @MainActor @Sendable (_ handler: @escaping @MainActor @Sendable (BGProcessingTask) -> Void) -> Bool - /// Submits a processing-task request. Returns `false` when the system refuses it - /// (Background App Refresh disabled, identifier not permitted, etc.) — tolerated. - var schedule: @Sendable () -> Bool + /// before the app finishes launching. + var register: @MainActor @Sendable (@escaping @MainActor @Sendable (BGProcessingTask) -> Void) -> Void + /// Submits a processing-task request. Best-effort and fire-and-forget: the system may + /// refuse it (Background App Refresh disabled, identifier not permitted), which the + /// live implementation logs and tolerates. + var schedule: @Sendable () -> Void /// Cancels any pending download processing-task request. var cancel: @Sendable () -> Void } @@ -31,7 +33,7 @@ struct BackgroundProcessingClient: Sendable { extension BackgroundProcessingClient { static let live = Self( register: { handler in - BGTaskScheduler.shared.register( + _ = BGTaskScheduler.shared.register( forTaskWithIdentifier: BackgroundProcessing.downloadTaskIdentifier, using: .main ) { task in @@ -51,10 +53,8 @@ extension BackgroundProcessingClient { request.earliestBeginDate = nil do { try BGTaskScheduler.shared.submit(request) - return true } catch { Logger.error(error) - return false } }, cancel: { @@ -69,7 +69,7 @@ extension BackgroundProcessingClient { enum BackgroundProcessingClientKey: DependencyKey { static let liveValue = BackgroundProcessingClient.live static let previewValue = BackgroundProcessingClient.noop - static let testValue = BackgroundProcessingClient.unimplemented + static let testValue = BackgroundProcessingClient() } extension DependencyValues { @@ -82,16 +82,8 @@ extension DependencyValues { // MARK: Test extension BackgroundProcessingClient { static let noop = Self( - register: { _ in false }, - schedule: { false }, + register: { _ in }, + schedule: {}, cancel: {} ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented = Self( - register: IssueReporting.unimplemented(placeholder: placeholder()), - schedule: IssueReporting.unimplemented(placeholder: placeholder()), - cancel: IssueReporting.unimplemented(placeholder: placeholder()) - ) } diff --git a/EhPanda/DataFlow/AppDelegateReducer.swift b/EhPanda/DataFlow/AppDelegateReducer.swift index 2450a53b2..970582ac3 100644 --- a/EhPanda/DataFlow/AppDelegateReducer.swift +++ b/EhPanda/DataFlow/AppDelegateReducer.swift @@ -70,7 +70,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { store.send(.appDelegate(.onLaunchFinish)) // Must register before launch completes so iOS can relaunch us later to // drain the download queue in a discretionary background window. - _ = BackgroundProcessingClient.live.register { task in + BackgroundProcessingClient.live.register { task in AppDelegate.handleProcessingTask(task) } } @@ -90,13 +90,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Reschedule only if we stopped on our own with work still pending; an // expiration cancels this task and reschedules from its own handler. if !Task.isCancelled, await downloadClient.hasPendingWork() { - _ = backgroundProcessingClient.schedule() + backgroundProcessingClient.schedule() } task.setTaskCompleted(success: !Task.isCancelled) } task.expirationHandler = { work.cancel() - _ = backgroundProcessingClient.schedule() + backgroundProcessingClient.schedule() } } diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index e9dc17856..07ee2d145 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -104,7 +104,7 @@ struct AppReducer { // period right after backgrounding. return .run { _ in if await downloadClient.hasPendingWork() { - _ = backgroundProcessingClient.schedule() + backgroundProcessingClient.schedule() } } diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift index e53191d42..7e503ad1b 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift @@ -179,10 +179,9 @@ private extension DownloadBackgroundProcessingTests { $0.downloadClient = DownloadClient() $0.downloadClient.hasPendingWork = { hasPendingWork } $0.backgroundProcessingClient = BackgroundProcessingClient( - register: { _ in true }, + register: { _ in }, schedule: { scheduleCount.value += 1 - return true }, cancel: {} ) From 8fc478994fcfcb7da910e87c8d380927d9342fc6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 21 Jun 2026 17:30:29 +0800 Subject: [PATCH 278/614] Correct BackgroundTaskClient's @DependencyClient rationale Its doc comment claimed it stays a plain struct partly because begin takes an escaping handler, but @DependencyClient handles escaping-closure endpoints fine (BackgroundProcessingClient.register is one). The real reason is that it is injected directly into DownloadCoordinator rather than resolved through DependencyValues, so the macro's auto-generated unimplemented testValue has nowhere to apply. --- EhPanda/App/Tools/Clients/BackgroundTaskClient.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/EhPanda/App/Tools/Clients/BackgroundTaskClient.swift b/EhPanda/App/Tools/Clients/BackgroundTaskClient.swift index 4931a1c33..af41eb190 100644 --- a/EhPanda/App/Tools/Clients/BackgroundTaskClient.swift +++ b/EhPanda/App/Tools/Clients/BackgroundTaskClient.swift @@ -14,9 +14,9 @@ typealias BackgroundTaskToken = UIBackgroundTaskIdentifier /// instead of being suspended within seconds. /// /// Mirrors `AppDelegateClient`: a plain `Sendable` struct of `@MainActor` closures -/// rather than a `@DependencyClient`, because `begin` both returns a value and takes -/// an escaping handler. It is injected straight into `DownloadCoordinator` (like -/// `pageDownloader`) instead of being resolved through `DependencyValues`. +/// rather than a `@DependencyClient`. It is injected straight into `DownloadCoordinator` +/// (like `pageDownloader`) rather than being resolved through `DependencyValues`, so it +/// has no place for the macro's auto-generated unimplemented `testValue` to live. struct BackgroundTaskClient: Sendable { /// Begins a background-task assertion and returns its token. `expirationHandler` /// fires when the OS is about to reclaim the assertion; the caller must end it then. From eedf2e82d5128298c22c11a1be0be201a6589d38 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 22 Jun 2026 05:44:42 +0800 Subject: [PATCH 279/614] Implement create default folder button --- EhPanda/App/Generated/Strings.swift | 2 ++ EhPanda/App/Tools/Defaults.swift | 1 + EhPanda/App/de.lproj/Localizable.strings | 1 + EhPanda/App/en.lproj/Localizable.strings | 1 + EhPanda/App/ja.lproj/Localizable.strings | 1 + EhPanda/App/ko.lproj/Localizable.strings | 1 + EhPanda/App/zh-Hans.lproj/Localizable.strings | 1 + .../App/zh-Hant-HK.lproj/Localizable.strings | 1 + .../App/zh-Hant-TW.lproj/Localizable.strings | 1 + EhPanda/App/zh-Hant.lproj/Localizable.strings | 1 + .../View/Detail/DetailReducer+Download.swift | 25 +++++++++++++++++ EhPanda/View/Detail/DetailReducer.swift | 2 ++ .../Detail/DetailView+HeaderSection.swift | 13 +++++++++ EhPanda/View/Detail/DetailView.swift | 1 + .../Download/DetailReducerDownloadTests.swift | 27 +++++++++++++++++++ 15 files changed, 79 insertions(+) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 6515ea05f..2d421b044 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -564,6 +564,8 @@ internal enum L10n { } internal enum Menu { internal enum Button { + /// Create Default Folder + internal static let createDefaultFolder = L10n.tr("Localizable", "detail_view.menu.button.create_default_folder", fallback: "Create Default Folder") /// Manage Folders internal static let manageFolders = L10n.tr("Localizable", "detail_view.menu.button.manage_folders", fallback: "Manage Folders") } diff --git a/EhPanda/App/Tools/Defaults.swift b/EhPanda/App/Tools/Defaults.swift index a68690946..b14cd412e 100644 --- a/EhPanda/App/Tools/Defaults.swift +++ b/EhPanda/App/Tools/Defaults.swift @@ -68,6 +68,7 @@ struct Defaults { static let downloadPages = "pages" static let downloadManifest = "manifest.json" static let automationDownloadFolder = "Automation" + static let defaultDownloadFolder = "Default" } struct Regex { static let tagSuggestion: NSRegularExpression? = try? .init(pattern: "(\\S+:\".+?\"|\".+?\"|\\S+:\\S+|\\S+)") diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 5e0ceed20..5f674aed7 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -959,6 +959,7 @@ "detail_view.offline_notice.saved_details" = "Online-Details konnten nicht aktualisiert werden. Stattdessen werden gespeicherte Details angezeigt."; "enum.download_folder_filter.title.all" = "All"; "detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; "detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 62f05ce72..64d8a53e3 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -389,6 +389,7 @@ // MARK: DownloadsView "enum.download_folder_filter.title.all" = "All"; "detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; "detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index a6d2b33b8..6f8673847 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -959,6 +959,7 @@ "detail_view.offline_notice.saved_details" = "オンラインの詳細を更新できなかったため、保存済みの詳細を表示しています。"; "enum.download_folder_filter.title.all" = "All"; "detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; "detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 02007f0f0..c98593458 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -959,6 +959,7 @@ "detail_view.offline_notice.saved_details" = "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다."; "enum.download_folder_filter.title.all" = "All"; "detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; "detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 4e7f3bd83..57ff81b92 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -389,6 +389,7 @@ // MARK: DownloadsView "enum.download_folder_filter.title.all" = "All"; "detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; "detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index b2358cff5..f6181dc91 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -956,6 +956,7 @@ "detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; "enum.download_folder_filter.title.all" = "All"; "detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; "detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 1ff0077dc..594e8d2a9 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -957,6 +957,7 @@ "detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; "enum.download_folder_filter.title.all" = "All"; "detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; "detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 401183ff8..9f0eefa5c 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -957,6 +957,7 @@ "detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; "enum.download_folder_filter.title.all" = "All"; "detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; "detail_view.menu.text.no_folders" = "No folders yet"; "downloads_view.menu.button.manage_folders" = "Manage Folders"; "downloads_view.menu.button.move_to_folder" = "Move to Folder"; diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index 89b21259d..3939ccf0c 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -24,6 +24,10 @@ extension DetailReducer { case .fetchDownloadFoldersDone(let folders): state.downloadFolders = folders return .none + case .createDefaultFolder: + return handleCreateDefaultFolder() + case .createDefaultFolderDone(let result): + return handleCreateDefaultFolderDone(result: result) case .folderManager(.createFolderDone), .folderManager(.renameFolderDone), .folderManager(.deleteFolderDone): @@ -85,6 +89,27 @@ extension DetailReducer { return .merge(effects) } + private func handleCreateDefaultFolder() -> Effect { + .run { send in + try await downloadClient.createFolder(Defaults.FilePath.defaultDownloadFolder) + await send(.createDefaultFolderDone(.success(()))) + } catch: { error, send in + await send(.createDefaultFolderDone(.failure(AppError(error)))) + } + } + + private func handleCreateDefaultFolderDone( + result: Result + ) -> Effect { + if case .success = result { + return .merge( + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadFolders) + ) + } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + } + private func handleObserveDownload(state: inout State) -> Effect { guard state.gid.isValidGID else { return .none } return .run { [galleryID = state.gid] send in diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index dc982386a..a869aaee9 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -143,6 +143,8 @@ struct DetailReducer { case fetchDownloadBadgeDone(DownloadedGallery?) case fetchDownloadFolders case fetchDownloadFoldersDone([String]) + case createDefaultFolder + case createDefaultFolderDone(Result) case observeDownload case observeDownloadDone(DownloadedGallery?) case loadLocalPreviewURLs diff --git a/EhPanda/View/Detail/DetailView+HeaderSection.swift b/EhPanda/View/Detail/DetailView+HeaderSection.swift index e01f812bf..80b660451 100644 --- a/EhPanda/View/Detail/DetailView+HeaderSection.swift +++ b/EhPanda/View/Detail/DetailView+HeaderSection.swift @@ -23,6 +23,7 @@ struct HeaderSection: View { let downloadAction: () -> Void let downloadToFolderAction: (String) -> Void let manageFoldersAction: () -> Void + let createDefaultFolderAction: () -> Void let favorAction: (Int) -> Void let unfavorAction: () -> Void let navigateReadingAction: () -> Void @@ -92,6 +93,18 @@ struct HeaderSection: View { systemSymbol: .folderBadgeGearshape ) } + // Without any folder there is nowhere to download to, so offer a + // one-tap shortcut to bootstrap one instead of forcing a trip + // through the folder manager. + if downloadFolders.isEmpty { + Button(action: createDefaultFolderAction) { + Label( + L10n.Localizable.DetailView.Menu.Button.createDefaultFolder, + systemSymbol: .folderBadgePlus + ) + } + .menuActionDismissBehavior(.disabled) + } } Section { if downloadFolders.isEmpty { diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 2d123154c..9fbfdecc5 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -168,6 +168,7 @@ private extension DetailView { store.send(.startDownload($0)) }, manageFoldersAction: { store.send(.setNavigation(.folderManager())) }, + createDefaultFolderAction: { store.send(.createDefaultFolder) }, favorAction: { store.send(.favorGallery($0)) }, unfavorAction: { store.send(.unfavorGallery) }, navigateReadingAction: { store.send(.openReading) }, diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index b4ea3970c..5b3287d6b 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -99,6 +99,31 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { await store.skipReceivedActions(strict: false) } + @MainActor + @Test + func testDetailReducerCreateDefaultFolderCreatesNamedFolderAndRefetches() async { + let capturedFolderName = UncheckedBox(nil) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let store = makeDownloadTestStore( + gallery: gallery, detail: detail, + downloadValue: nil, + folders: { [Defaults.FilePath.defaultDownloadFolder] }, + createFolder: { name in capturedFolderName.value = name }, + enqueue: { _ in } + ) + store.exhaustivity = .off + + await store.send(.createDefaultFolder) + await store.receive(\.createDefaultFolderDone) + await store.receive(\.fetchDownloadFolders) + await store.receive(\.fetchDownloadFoldersDone, [Defaults.FilePath.defaultDownloadFolder]) { + $0.downloadFolders = [Defaults.FilePath.defaultDownloadFolder] + } + + #expect(capturedFolderName.value == Defaults.FilePath.defaultDownloadFolder) + } + @MainActor @Test func testDetailReducerLaunchAutomationWaitsForResolvedDownloadBadge() async throws { @@ -147,6 +172,7 @@ private extension DetailReducerDownloadTests { downloadValue: DownloadedGallery?, automationGID: String? = nil, folders: @escaping @Sendable () -> [String] = { [] }, + createFolder: @escaping @Sendable (String) async throws -> Void = { _ in }, configure: (inout DetailReducer.State) -> Void = { _ in }, enqueue: @escaping @Sendable (DownloadRequestPayload) async throws -> Void ) -> TestStoreOf { @@ -174,6 +200,7 @@ private extension DetailReducerDownloadTests { $0.downloadClient.loadLocalPageURLs = { _ in [:] } $0.downloadClient.fetchVersionMetadata = { _, _ in nil } $0.downloadClient.fetchFolders = { folders() } + $0.downloadClient.createFolder = createFolder $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop From 952c35ca9e756bd0dce25800cbf06ff1b2102c4c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 22 Jun 2026 05:49:53 +0800 Subject: [PATCH 280/614] Remove wrapper methods --- EhPanda/View/Detail/DetailView+HeaderSection.swift | 3 +-- EhPanda/View/Support/Components/Cells/GalleryCardCell.swift | 6 +----- .../View/Support/Components/Cells/GalleryHistoryCell.swift | 6 +----- .../View/Support/Components/Cells/GalleryRankingCell.swift | 6 +----- .../Support/Components/Cells/GalleryThumbnailCell.swift | 6 +----- 5 files changed, 5 insertions(+), 22 deletions(-) diff --git a/EhPanda/View/Detail/DetailView+HeaderSection.swift b/EhPanda/View/Detail/DetailView+HeaderSection.swift index 80b660451..570ba7bb1 100644 --- a/EhPanda/View/Detail/DetailView+HeaderSection.swift +++ b/EhPanda/View/Detail/DetailView+HeaderSection.swift @@ -263,11 +263,10 @@ struct HeaderSection: View { default: return .icloudAndArrowDown } } - private var resolvedCoverURL: URL? { gallery.coverURL } var body: some View { HStack { - KFImage(resolvedCoverURL) + KFImage(gallery.coverURL) .placeholder({ Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }) .defaultModifier() .scaledToFit() diff --git a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift index 9b0357617..5eb828b79 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift @@ -42,17 +42,13 @@ struct GalleryCardCell: View { return trimmedTitle } - private var resolvedCoverURL: URL? { - gallery.coverURL - } - var body: some View { ZStack { Color.gray.opacity(0.2) ColorfulView(animated: animated, animation: animation, colors: colors) .id(currentID + animated.description) HStack { - KFImage(resolvedCoverURL) + KFImage(gallery.coverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) } .onSuccess(webImageSuccessAction).defaultModifier().scaledToFill() .frame(width: Defaults.ImageSize.headerW, height: Defaults.ImageSize.headerH) diff --git a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift index 9d2477993..96be0877b 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift @@ -13,13 +13,9 @@ struct GalleryHistoryCell: View { self.gallery = gallery } - private var resolvedCoverURL: URL? { - gallery.coverURL - } - var body: some View { HStack(spacing: 20) { - KFImage(resolvedCoverURL) + KFImage(gallery.coverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }.defaultModifier() .scaledToFill().frame(width: Defaults.ImageSize.rowW * 0.75, height: Defaults.ImageSize.rowH * 0.75) .cornerRadius(2) diff --git a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift index 7a6ac1433..ff688ce54 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift @@ -15,13 +15,9 @@ struct GalleryRankingCell: View { self.ranking = ranking } - private var resolvedCoverURL: URL? { - gallery.coverURL - } - var body: some View { HStack { - KFImage(resolvedCoverURL) + KFImage(gallery.coverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }.defaultModifier() .scaledToFill().frame(width: Defaults.ImageSize.rowW * 0.75, height: Defaults.ImageSize.rowH * 0.75) .cornerRadius(2) diff --git a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift index 989f4736d..9841ddd82 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -33,13 +33,9 @@ struct GalleryThumbnailCell: View { colorScheme == .light ? Color(.systemGray5) : Color(.systemGray4) } - private var resolvedCoverURL: URL? { - gallery.coverURL - } - var body: some View { VStack(alignment: .leading, spacing: 0) { - KFImage(resolvedCoverURL) + KFImage(gallery.coverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.rowAspect)) } .imageModifier(WebtoonModifier( minAspect: Defaults.ImageSize.webtoonMinAspect, From f0b3b1e68ed7dffb08afbeae501ff894c77aa6e3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 22 Jun 2026 06:01:05 +0800 Subject: [PATCH 281/614] Inline reducer action handlers Remove the per-action handle*/reduce* wrapper methods in the Detail and Reading reducers and write their bodies directly into the switch statements, making each reducer self-contained. --- .../View/Detail/DetailReducer+Download.swift | 515 ++++++------- EhPanda/View/Detail/DetailReducer+Fetch.swift | 330 ++++----- .../View/Reading/ReadingReducer+Body.swift | 217 +++--- .../Reading/ReadingReducer+ImageFetch.swift | 680 ++++++++---------- 4 files changed, 740 insertions(+), 1002 deletions(-) diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index 3939ccf0c..e0b034844 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -12,341 +12,254 @@ extension DetailReducer { Reduce { state, action in switch action { case .fetchDownloadBadge: - return handleFetchDownloadBadge(state: &state) + guard state.gid.isValidGID else { return .none } + return .run { [galleryID = state.gid] send in + let download = await downloadClient.fetchDownload(galleryID) + await send(.fetchDownloadBadgeDone(download)) + } + .cancellable(id: CancelID.fetchDownloadBadge(state.cancellationGalleryID), cancelInFlight: true) + case .fetchDownloadBadgeDone(let download): - return handleFetchDownloadBadgeDone(download: download, state: &state) + _ = applyDownload(download, state: &state) + var effects: [Effect] = [.send(.loadLocalPreviewURLs)] + if shouldRequestVersionMetadata(state: state) { + effects.append(.send(.fetchVersionMetadataIfNeeded)) + } + return .merge(effects) + case .fetchDownloadFolders: let cancellationID = CancelID.fetchDownloadFolders(state.cancellationGalleryID) return .run { send in await send(.fetchDownloadFoldersDone(try await downloadClient.fetchFolders())) } .cancellable(id: cancellationID, cancelInFlight: true) + case .fetchDownloadFoldersDone(let folders): state.downloadFolders = folders return .none + case .createDefaultFolder: - return handleCreateDefaultFolder() + return .run { send in + try await downloadClient.createFolder(Defaults.FilePath.defaultDownloadFolder) + await send(.createDefaultFolderDone(.success(()))) + } catch: { error, send in + await send(.createDefaultFolderDone(.failure(AppError(error)))) + } + case .createDefaultFolderDone(let result): - return handleCreateDefaultFolderDone(result: result) + if case .success = result { + return .merge( + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadFolders) + ) + } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + case .folderManager(.createFolderDone), .folderManager(.renameFolderDone), .folderManager(.deleteFolderDone): return .send(.fetchDownloadFolders) - case .observeDownload: - return handleObserveDownload(state: &state) - case .observeDownloadDone(let download): - return handleObserveDownloadDone(download: download, state: &state) - case .loadLocalPreviewURLs: - return handleLoadLocalPreviewURLs(state: &state) - case .loadLocalPreviewURLsDone(let requestID, let urls): - return handleLoadLocalPreviewURLsDone(requestID: requestID, urls: urls, state: &state) - case .openReading: - return handleOpenReading(state: &state) - case .openReadingDone(let result): - return handleOpenReadingDone(result: result, state: &state) - case .runLaunchAutomationIfNeeded: - return handleRunLaunchAutomation(state: &state) - case .startDownload(let folderName): - return handleStartDownload(folderName: folderName, state: &state) - case .startDownloadDone(let result): - return handleStartDownloadDone(result: result, state: &state) - case .toggleDownloadPause: - return handleToggleDownloadPause(state: &state) - case .toggleDownloadPauseDone(let result): - return handleToggleDownloadPauseDone(result: result, state: &state) - case .retryDownload(let mode): - return handleRetryDownload(mode: mode, state: &state) - case .retryDownloadDone(let result): - return handleRetryDownloadDone(result: result, state: &state) - case .deleteDownload: - return handleDeleteDownload(state: state) - case .deleteDownloadDone(let result): - return handleDeleteDownloadDone(result: result, state: &state) - default: - return .none - } - } - } - private func handleFetchDownloadBadge(state: inout State) -> Effect { - guard state.gid.isValidGID else { return .none } - return .run { [galleryID = state.gid] send in - let download = await downloadClient.fetchDownload(galleryID) - await send(.fetchDownloadBadgeDone(download)) - } - .cancellable(id: CancelID.fetchDownloadBadge(state.cancellationGalleryID), cancelInFlight: true) - } - - private func handleFetchDownloadBadgeDone( - download: DownloadedGallery?, - state: inout State - ) -> Effect { - _ = applyDownload(download, state: &state) - var effects: [Effect] = [.send(.loadLocalPreviewURLs)] - if shouldRequestVersionMetadata(state: state) { - effects.append(.send(.fetchVersionMetadataIfNeeded)) - } - return .merge(effects) - } + case .observeDownload: + guard state.gid.isValidGID else { return .none } + return .run { [galleryID = state.gid] send in + for await downloads in downloadClient.observeDownloads() { + let download = downloads.first(where: { $0.gid == galleryID }) + await send(.observeDownloadDone(download)) + } + } + .cancellable(id: CancelID.observeDownload(state.cancellationGalleryID), cancelInFlight: true) - private func handleCreateDefaultFolder() -> Effect { - .run { send in - try await downloadClient.createFolder(Defaults.FilePath.defaultDownloadFolder) - await send(.createDefaultFolderDone(.success(()))) - } catch: { error, send in - await send(.createDefaultFolderDone(.failure(AppError(error)))) - } - } + case .observeDownloadDone(let download): + let didChangeBadge = applyDownload(download, state: &state) + guard didChangeBadge else { return .none } + var effects: [Effect] = [.send(.loadLocalPreviewURLs)] + if shouldRequestVersionMetadata(state: state) { + effects.append(.send(.fetchVersionMetadataIfNeeded)) + } + return .merge(effects) - private func handleCreateDefaultFolderDone( - result: Result - ) -> Effect { - if case .success = result { - return .merge( - .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), - .send(.fetchDownloadFolders) - ) - } - return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - } + case .loadLocalPreviewURLs: + guard state.gid.isValidGID else { + state.localPreviewRequestID = UUID() + state.localPreviewURLs = .init() + return .none + } + let requestID = UUID() + state.localPreviewRequestID = requestID + return .run { [galleryID = state.gid] send in + let localPreviewURLs = await downloadClient.loadLocalPageURLs(galleryID) ?? [:] + await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) + } + .cancellable(id: CancelID.loadLocalPreviewURLs(state.cancellationGalleryID), cancelInFlight: true) - private func handleObserveDownload(state: inout State) -> Effect { - guard state.gid.isValidGID else { return .none } - return .run { [galleryID = state.gid] send in - for await downloads in downloadClient.observeDownloads() { - let download = downloads.first(where: { $0.gid == galleryID }) - await send(.observeDownloadDone(download)) - } - } - .cancellable(id: CancelID.observeDownload(state.cancellationGalleryID), cancelInFlight: true) - } + case .loadLocalPreviewURLsDone(let requestID, let localPreviewURLs): + guard state.localPreviewRequestID == requestID else { return .none } + guard state.localPreviewURLs != localPreviewURLs else { return .none } + state.localPreviewURLs = localPreviewURLs + return .none - private func handleObserveDownloadDone( - download: DownloadedGallery?, - state: inout State - ) -> Effect { - let didChangeBadge = applyDownload(download, state: &state) - guard didChangeBadge else { return .none } - var effects: [Effect] = [.send(.loadLocalPreviewURLs)] - if shouldRequestVersionMetadata(state: state) { - effects.append(.send(.fetchVersionMetadataIfNeeded)) - } - return .merge(effects) - } + case .openReading: + state.readingState = .init(contentSource: .remote) + return .run { [galleryID = state.gallery.id] send in + guard galleryID.isValidGID else { + await send(.openReadingDone(.failure(.notFound))) + return + } + await send(.openReadingDone(.success(try await downloadClient.loadManifest(galleryID)))) + } catch: { error, send in + await send(.openReadingDone(.failure(AppError(error)))) + } - private func handleLoadLocalPreviewURLs(state: inout State) -> Effect { - guard state.gid.isValidGID else { - state.localPreviewRequestID = UUID() - state.localPreviewURLs = .init() - return .none - } - let requestID = UUID() - state.localPreviewRequestID = requestID - return .run { [galleryID = state.gid] send in - let localPreviewURLs = await downloadClient.loadLocalPageURLs(galleryID) ?? [:] - await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) - } - .cancellable(id: CancelID.loadLocalPreviewURLs(state.cancellationGalleryID), cancelInFlight: true) - } + case .openReadingDone(let result): + if case .success(let (download, manifest)) = result { + state.readingState = .init(contentSource: .local(download, manifest)) + } else { + state.readingState.contentSource = .remote + state.readingState.localPageURLs = state.localPreviewURLs + } + state.route = .reading() + return .none - private func handleLoadLocalPreviewURLsDone( - requestID: UUID, - urls localPreviewURLs: [Int: URL], - state: inout State - ) -> Effect { - guard state.localPreviewRequestID == requestID else { return .none } - guard state.localPreviewURLs != localPreviewURLs else { return .none } - state.localPreviewURLs = localPreviewURLs - return .none - } + case .runLaunchAutomationIfNeeded: + guard !state.didRunLaunchAutomation, + let automation = appLaunchAutomationClient.current(), + automation.autoDownloadGID == state.gallery.id, + state.galleryDetail != nil, + state.hasLoadedDownloadBadge + else { return .none } + state.didRunLaunchAutomation = true + guard state.downloadBadge == nil else { return .none } + return .send( + .startDownload(automation.downloadFolderName ?? Defaults.FilePath.automationDownloadFolder) + ) - private func handleOpenReading(state: inout State) -> Effect { - state.readingState = .init(contentSource: .remote) - return .run { [galleryID = state.gallery.id] send in - guard galleryID.isValidGID else { - await send(.openReadingDone(.failure(.notFound))) - return - } - await send(.openReadingDone(.success(try await downloadClient.loadManifest(galleryID)))) - } catch: { error, send in - await send(.openReadingDone(.failure(AppError(error)))) - } - } + case .startDownload(let folderName): + guard !state.isPreparingDownload else { return .none } + state.didRunLaunchAutomation = true + guard let detail = state.galleryDetail else { return .none } + state.isPreparingDownload = true + let payload = DownloadRequestPayload( + gallery: state.gallery, + galleryDetail: detail, + previewURLs: state.galleryPreviewURLs, + previewConfig: state.previewConfig, + host: AppUtil.galleryHost, + folderName: folderName, + versionMetadata: state.galleryVersionMetadata, + mode: .initial + ) + return .run { send in + try await downloadClient.enqueue(payload) + await send(.startDownloadDone(.success(()))) + } catch: { error, send in + await send(.startDownloadDone(.failure(AppError(error)))) + } - private func handleOpenReadingDone( - result: Result<(DownloadedGallery, DownloadManifest), AppError>, - state: inout State - ) -> Effect { - if case .success(let (download, manifest)) = result { - state.readingState = .init(contentSource: .local(download, manifest)) - } else { - state.readingState.contentSource = .remote - state.readingState.localPageURLs = state.localPreviewURLs - } - state.route = .reading() - return .none - } + case .startDownloadDone(let result): + state.isPreparingDownload = false + if case .success = result { + state.downloadBadge = DownloadBadge( + status: .queued, + progress: DownloadProgress( + completedPageCount: 0, + pageCount: state.galleryDetail?.pageCount ?? 0 + ) + ) + state.downloadFailureCode = nil + state.hasLoadedDownloadBadge = true + return .merge( + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - private func handleRunLaunchAutomation(state: inout State) -> Effect { - guard !state.didRunLaunchAutomation, - let automation = appLaunchAutomationClient.current(), - automation.autoDownloadGID == state.gallery.id, - state.galleryDetail != nil, - state.hasLoadedDownloadBadge - else { return .none } - state.didRunLaunchAutomation = true - guard state.downloadBadge == nil else { return .none } - return .send( - .startDownload(automation.downloadFolderName ?? Defaults.FilePath.automationDownloadFolder) - ) - } + case .toggleDownloadPause: + guard !state.isPreparingDownload else { return .none } + state.isPreparingDownload = true + return .run { [galleryID = state.gallery.id] send in + try await downloadClient.togglePause(galleryID) + await send(.toggleDownloadPauseDone(.success(()))) + } catch: { error, send in + await send(.toggleDownloadPauseDone(.failure(AppError(error)))) + } - private func handleStartDownload( - folderName: String, - state: inout State - ) -> Effect { - guard !state.isPreparingDownload else { return .none } - state.didRunLaunchAutomation = true - guard let detail = state.galleryDetail else { return .none } - state.isPreparingDownload = true - let payload = DownloadRequestPayload( - gallery: state.gallery, - galleryDetail: detail, - previewURLs: state.galleryPreviewURLs, - previewConfig: state.previewConfig, - host: AppUtil.galleryHost, - folderName: folderName, - versionMetadata: state.galleryVersionMetadata, - mode: .initial - ) - return .run { send in - try await downloadClient.enqueue(payload) - await send(.startDownloadDone(.success(()))) - } catch: { error, send in - await send(.startDownloadDone(.failure(AppError(error)))) - } - } + case .toggleDownloadPauseDone(let result): + state.isPreparingDownload = false + if case .success = result { + switch state.downloadBadge?.status { + case .active: + if let badge = state.downloadBadge { + state.downloadBadge = DownloadBadge(status: .inactive, progress: badge.progress) + } + case .inactive: + if let badge = state.downloadBadge { + state.downloadBadge = DownloadBadge(status: .queued, progress: badge.progress) + } + default: + break + } + state.hasLoadedDownloadBadge = state.downloadBadge != nil + return .merge( + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - private func handleStartDownloadDone( - result: Result, - state: inout State - ) -> Effect { - state.isPreparingDownload = false - if case .success = result { - state.downloadBadge = DownloadBadge( - status: .queued, - progress: DownloadProgress( - completedPageCount: 0, - pageCount: state.galleryDetail?.pageCount ?? 0 - ) - ) - state.downloadFailureCode = nil - state.hasLoadedDownloadBadge = true - return .merge( - .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), - .send(.fetchDownloadBadge) - ) - } - return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - } + case .retryDownload(let mode): + guard !state.isPreparingDownload else { return .none } + state.isPreparingDownload = true + return .run { [galleryID = state.gallery.id] send in + try await downloadClient.retry(galleryID, mode) + await send(.retryDownloadDone(.success(()))) + } catch: { error, send in + await send(.retryDownloadDone(.failure(AppError(error)))) + } - private func handleToggleDownloadPause(state: inout State) -> Effect { - guard !state.isPreparingDownload else { return .none } - state.isPreparingDownload = true - return .run { [galleryID = state.gallery.id] send in - try await downloadClient.togglePause(galleryID) - await send(.toggleDownloadPauseDone(.success(()))) - } catch: { error, send in - await send(.toggleDownloadPauseDone(.failure(AppError(error)))) - } - } + case .retryDownloadDone(let result): + state.isPreparingDownload = false + if case .success = result { + state.downloadBadge = DownloadBadge( + status: .queued, + progress: state.downloadBadge?.progress ?? DownloadProgress( + completedPageCount: 0, + pageCount: state.galleryDetail?.pageCount ?? 0 + ) + ) + state.downloadFailureCode = nil + state.hasLoadedDownloadBadge = true + return .merge( + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - private func handleToggleDownloadPauseDone( - result: Result, - state: inout State - ) -> Effect { - state.isPreparingDownload = false - if case .success = result { - switch state.downloadBadge?.status { - case .active: - if let badge = state.downloadBadge { - state.downloadBadge = DownloadBadge(status: .inactive, progress: badge.progress) + case .deleteDownload: + return .run { [galleryID = state.gallery.id] send in + try await downloadClient.delete(galleryID) + await send(.deleteDownloadDone(.success(()))) + } catch: { error, send in + await send(.deleteDownloadDone(.failure(AppError(error)))) } - case .inactive: - if let badge = state.downloadBadge { - state.downloadBadge = DownloadBadge(status: .queued, progress: badge.progress) + + case .deleteDownloadDone(let result): + if case .success = result { + state.galleryVersionMetadata = nil + state.didRequestVersionMetadata = false + state.shouldCheckForRemoteUpdates = false + return .merge( + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + default: - break + return .none } - state.hasLoadedDownloadBadge = state.downloadBadge != nil - return .merge( - .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), - .send(.fetchDownloadBadge) - ) - } - return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - } - - private func handleRetryDownload( - mode: DownloadStartMode, - state: inout State - ) -> Effect { - guard !state.isPreparingDownload else { return .none } - state.isPreparingDownload = true - return .run { [galleryID = state.gallery.id] send in - try await downloadClient.retry(galleryID, mode) - await send(.retryDownloadDone(.success(()))) - } catch: { error, send in - await send(.retryDownloadDone(.failure(AppError(error)))) - } - } - - private func handleRetryDownloadDone( - result: Result, - state: inout State - ) -> Effect { - state.isPreparingDownload = false - if case .success = result { - state.downloadBadge = DownloadBadge( - status: .queued, - progress: state.downloadBadge?.progress ?? DownloadProgress( - completedPageCount: 0, - pageCount: state.galleryDetail?.pageCount ?? 0 - ) - ) - state.downloadFailureCode = nil - state.hasLoadedDownloadBadge = true - return .merge( - .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), - .send(.fetchDownloadBadge) - ) - } - return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - } - - private func handleDeleteDownload(state: State) -> Effect { - .run { [galleryID = state.gallery.id] send in - try await downloadClient.delete(galleryID) - await send(.deleteDownloadDone(.success(()))) - } catch: { error, send in - await send(.deleteDownloadDone(.failure(AppError(error)))) - } - } - - private func handleDeleteDownloadDone( - result: Result, - state: inout State - ) -> Effect { - if case .success = result { - state.galleryVersionMetadata = nil - state.didRequestVersionMetadata = false - state.shouldCheckForRemoteUpdates = false - return .merge( - .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), - .send(.fetchDownloadBadge) - ) } - return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } } diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/EhPanda/View/Detail/DetailReducer+Fetch.swift index bd61f5bd9..bbbdd3c16 100644 --- a/EhPanda/View/Detail/DetailReducer+Fetch.swift +++ b/EhPanda/View/Detail/DetailReducer+Fetch.swift @@ -19,19 +19,102 @@ extension DetailReducer { ) case .fetchDatabaseInfos(let gid): - return handleFetchDatabaseInfos(gid: gid, state: &state) + if let gallery = databaseClient.fetchGallery(gid: gid) { + state.gallery = gallery + } else if state.gallery.id != gid { + return .none + } + if let detail = databaseClient.fetchGalleryDetail(gid: gid) { + state.galleryDetail = detail + } + return .merge( + .send(.fetchDownloadBadge), + .send(.saveGalleryHistory), + .run { [galleryID = state.gallery.id] send in + guard let dbState = await databaseClient.fetchGalleryState(gid: galleryID) else { return } + await send(.fetchDatabaseInfosDone(dbState)) + } + .cancellable(id: CancelID.fetchDatabaseInfos(state.cancellationGalleryID)) + ) case .fetchDatabaseInfosDone(let galleryState): - return handleFetchDatabaseInfosDone(galleryState: galleryState, state: &state) + state.galleryTags = galleryState.tags + state.galleryPreviewURLs = galleryState.previewURLs + state.galleryComments = galleryState.comments + if let previewConfig = galleryState.previewConfig { + state.previewConfig = previewConfig + } + return .send(.fetchGalleryDetail) case .fetchGalleryDetail: - return handleFetchGalleryDetail(state: &state) + guard state.loadingState != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + let galleryID = state.gallery.id + state.loadingState = .loading + state.didRequestVersionMetadata = false + state.galleryVersionMetadata = nil + return .run { send in + let response = await GalleryDetailRequest(gid: galleryID, galleryURL: galleryURL).response() + await send(.fetchGalleryDetailDone(response)) + } + .cancellable(id: CancelID.fetchGalleryDetail(state.cancellationGalleryID)) case .fetchGalleryDetailDone(let result): - return handleFetchGalleryDetailDone(result: result, state: &state) + state.loadingState = .idle + switch result { + case .success(let response): + var effects: [Effect] = [ + .send(.syncGalleryTags), + .send(.syncGalleryDetail), + .send(.syncGalleryPreviewURLs), + .send(.syncGalleryComments), + .send(.fetchDownloadBadge) + ] + state.apiKey = response.apiKey + state.galleryDetail = response.galleryDetail + state.galleryTags = response.galleryState.tags + state.galleryPreviewURLs = response.galleryState.previewURLs + state.galleryComments = response.galleryState.comments + if let config = response.galleryState.previewConfig { + state.previewConfig = config + } + state.userRating = Int(response.galleryDetail.userRating) * 2 + if shouldRequestVersionMetadata(state: state) { + effects.append(.send(.fetchVersionMetadataIfNeeded)) + } + if let greeting = response.greeting { + effects.append(.send(.syncGreeting(greeting))) + if !greeting.gainedNothing && state.showsNewDawnGreeting { + effects.append(.send(.setNavigation(.newDawn(greeting)))) + } + } + if let config = response.galleryState.previewConfig { + effects.append(.send(.syncPreviewConfig(config))) + } + return .merge(effects) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none case .fetchVersionMetadataIfNeeded: - return handleFetchVersionMetadataIfNeeded(state: &state) + guard state.shouldCheckForRemoteUpdates, + !state.didRequestVersionMetadata, + state.galleryDetail != nil + else { + return .none + } + state.didRequestVersionMetadata = true + let gallery = state.gallery + return .run { send in + let metadata = await downloadClient.fetchVersionMetadata(gallery.gid, gallery.token) + await send(.fetchVersionMetadataDone(.success(metadata))) + guard let metadata else { return } + let download = await downloadClient.updateRemoteVersion(gallery.gid, metadata) + await send(.fetchDownloadBadgeDone(download)) + } + .cancellable(id: CancelID.fetchVersionMetadata(state.cancellationGalleryID), cancelInFlight: true) case .fetchVersionMetadataDone(let result): if case .success(let metadata) = result { @@ -45,202 +128,71 @@ extension DetailReducer { } } - private func handleFetchDatabaseInfos(gid: String, state: inout State) -> Effect { - if let gallery = databaseClient.fetchGallery(gid: gid) { - state.gallery = gallery - } else if state.gallery.id != gid { - return .none - } - if let detail = databaseClient.fetchGalleryDetail(gid: gid) { - state.galleryDetail = detail - } - return .merge( - .send(.fetchDownloadBadge), - .send(.saveGalleryHistory), - .run { [galleryID = state.gallery.id] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: galleryID) else { return } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: CancelID.fetchDatabaseInfos(state.cancellationGalleryID)) - ) - } - - private func handleFetchDatabaseInfosDone(galleryState: GalleryState, state: inout State) -> Effect { - state.galleryTags = galleryState.tags - state.galleryPreviewURLs = galleryState.previewURLs - state.galleryComments = galleryState.comments - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - return .send(.fetchGalleryDetail) - } - - private func handleFetchGalleryDetail(state: inout State) -> Effect { - guard state.loadingState != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - let galleryID = state.gallery.id - state.loadingState = .loading - state.didRequestVersionMetadata = false - state.galleryVersionMetadata = nil - return .run { send in - let response = await GalleryDetailRequest(gid: galleryID, galleryURL: galleryURL).response() - await send(.fetchGalleryDetailDone(response)) - } - .cancellable(id: CancelID.fetchGalleryDetail(state.cancellationGalleryID)) - } - - private func handleFetchGalleryDetailDone( - result: Result, - state: inout State - ) -> Effect { - state.loadingState = .idle - switch result { - case .success(let response): - return applyGalleryDetailResponse(response, state: &state) - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - } - - private func applyGalleryDetailResponse( - _ response: GalleryDetailResponse, - state: inout State - ) -> Effect { - var effects: [Effect] = [ - .send(.syncGalleryTags), - .send(.syncGalleryDetail), - .send(.syncGalleryPreviewURLs), - .send(.syncGalleryComments), - .send(.fetchDownloadBadge) - ] - state.apiKey = response.apiKey - state.galleryDetail = response.galleryDetail - state.galleryTags = response.galleryState.tags - state.galleryPreviewURLs = response.galleryState.previewURLs - state.galleryComments = response.galleryState.comments - if let config = response.galleryState.previewConfig { - state.previewConfig = config - } - state.userRating = Int(response.galleryDetail.userRating) * 2 - if shouldRequestVersionMetadata(state: state) { - effects.append(.send(.fetchVersionMetadataIfNeeded)) - } - if let greeting = response.greeting { - effects.append(.send(.syncGreeting(greeting))) - if !greeting.gainedNothing && state.showsNewDawnGreeting { - effects.append(.send(.setNavigation(.newDawn(greeting)))) - } - } - if let config = response.galleryState.previewConfig { - effects.append(.send(.syncPreviewConfig(config))) - } - return .merge(effects) - } - - private func handleFetchVersionMetadataIfNeeded(state: inout State) -> Effect { - guard state.shouldCheckForRemoteUpdates, - !state.didRequestVersionMetadata, - state.galleryDetail != nil - else { - return .none - } - state.didRequestVersionMetadata = true - let gallery = state.gallery - return .run { send in - let metadata = await downloadClient.fetchVersionMetadata(gallery.gid, gallery.token) - await send(.fetchVersionMetadataDone(.success(metadata))) - guard let metadata else { return } - let download = await downloadClient.updateRemoteVersion(gallery.gid, metadata) - await send(.fetchDownloadBadgeDone(download)) - } - .cancellable(id: CancelID.fetchVersionMetadata(state.cancellationGalleryID), cancelInFlight: true) - } - var galleryOpsReducer: some ReducerOf { Reduce { state, action in switch action { case .rateGallery: - return handleRateGallery(state: state) + guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) + else { return .none } + return .run { [apiKey = state.apiKey, token = state.gallery.token, rating = state.userRating] send in + let response = await RateGalleryRequest( + apiuid: apiuid, apikey: apiKey, + gid: gid, token: token, rating: rating + ).response() + await send(.anyGalleryOpsDone(response)) + } + .cancellable(id: CancelID.rateGallery(state.cancellationGalleryID)) + case .favorGallery(let favIndex): - return handleFavorGallery(favIndex: favIndex, state: state) + return .run { [gid = state.gallery.id, token = state.gallery.token] send in + let response = await FavorGalleryRequest( + gid: gid, token: token, favIndex: favIndex + ).response() + await send(.anyGalleryOpsDone(response)) + } + .cancellable(id: CancelID.favorGallery(state.cancellationGalleryID)) + case .unfavorGallery: - return handleUnfavorGallery(state: state) + return .run { [galleryID = state.gallery.id] send in + let response = await UnfavorGalleryRequest(gid: galleryID).response() + await send(.anyGalleryOpsDone(response)) + } + .cancellable(id: CancelID.unfavorGallery(state.cancellationGalleryID)) + case .postComment(let galleryURL): - return handlePostComment(galleryURL: galleryURL, state: state) + guard !state.commentContent.isEmpty else { return .none } + return .run { [commentContent = state.commentContent] send in + let response = await CommentGalleryRequest( + content: commentContent, galleryURL: galleryURL + ).response() + await send(.anyGalleryOpsDone(response)) + } + .cancellable(id: CancelID.postComment(state.cancellationGalleryID)) + case .voteTag(let tag, let vote): - return handleVoteTag(tag: tag, vote: vote, state: state) + guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) + else { return .none } + return .run { [apiKey = state.apiKey, token = state.gallery.token] send in + let response = await VoteGalleryTagRequest( + apiuid: apiuid, apikey: apiKey, + gid: gid, token: token, tag: tag, vote: vote + ).response() + await send(.anyGalleryOpsDone(response)) + } + .cancellable(id: CancelID.voteTag(state.cancellationGalleryID)) + case .anyGalleryOpsDone(let result): - return handleAnyGalleryOpsDone(result: result) + if case .success = result { + return .merge( + .send(.fetchGalleryDetail), + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) + ) + } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + default: return .none } } } - - private func handleRateGallery(state: State) -> Effect { - guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) - else { return .none } - return .run { [apiKey = state.apiKey, token = state.gallery.token, rating = state.userRating] send in - let response = await RateGalleryRequest( - apiuid: apiuid, apikey: apiKey, - gid: gid, token: token, rating: rating - ).response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.rateGallery(state.cancellationGalleryID)) - } - - private func handleFavorGallery(favIndex: Int, state: State) -> Effect { - .run { [gid = state.gallery.id, token = state.gallery.token] send in - let response = await FavorGalleryRequest( - gid: gid, token: token, favIndex: favIndex - ).response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.favorGallery(state.cancellationGalleryID)) - } - - private func handleUnfavorGallery(state: State) -> Effect { - .run { [galleryID = state.gallery.id] send in - let response = await UnfavorGalleryRequest(gid: galleryID).response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.unfavorGallery(state.cancellationGalleryID)) - } - - private func handlePostComment(galleryURL: URL, state: State) -> Effect { - guard !state.commentContent.isEmpty else { return .none } - return .run { [commentContent = state.commentContent] send in - let response = await CommentGalleryRequest( - content: commentContent, galleryURL: galleryURL - ).response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.postComment(state.cancellationGalleryID)) - } - - private func handleVoteTag(tag: String, vote: Int, state: State) -> Effect { - guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) - else { return .none } - return .run { [apiKey = state.apiKey, token = state.gallery.token] send in - let response = await VoteGalleryTagRequest( - apiuid: apiuid, apikey: apiKey, - gid: gid, token: token, tag: tag, vote: vote - ).response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.voteTag(state.cancellationGalleryID)) - } - - private func handleAnyGalleryOpsDone(result: Result) -> Effect { - if case .success = result { - return .merge( - .send(.fetchGalleryDetail), - .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) - ) - } - return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - } } diff --git a/EhPanda/View/Reading/ReadingReducer+Body.swift b/EhPanda/View/Reading/ReadingReducer+Body.swift index 792cb46fc..12f4533a2 100644 --- a/EhPanda/View/Reading/ReadingReducer+Body.swift +++ b/EhPanda/View/Reading/ReadingReducer+Body.swift @@ -65,30 +65,92 @@ extension ReadingReducer { return .none case .setOrientationPortrait(let isPortrait): - return reduceOrientation(isPortrait: isPortrait) + var effects = [Effect]() + if isPortrait { + effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientationMask() })) + effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientation() })) + } else { + effects.append(.run(operation: { _ in await appDelegateClient.setAllOrientationMask() })) + } + return .merge(effects) case .onPerformDismiss: return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) case .onAppear(let gid, let enablesLandscape): - return reduceOnAppear(gid: gid, enablesLandscape: enablesLandscape) + var effects: [Effect] = [ + .send(.fetchDatabaseInfos(gid)), + .send(.observeDownloads(gid)), + .send(.loadLocalPageURLs(gid)) + ] + if enablesLandscape { + effects.append(.send(.setOrientationPortrait(false))) + } + return .merge(effects) case .onWebImageRetry(let index): state.imageURLLoadingStates[index] = .idle return .none case .onWebImageSucceeded(let index): - return reduceWebImageSucceeded(state: &state, index: index) + state.imageURLLoadingStates[index] = .idle + state.webImageLoadSuccessIndices.insert(index) + guard !state.isOffline, + state.gallery.id.isValidGID, + state.localPageURLs[index] == nil + else { + return .none + } + return .send(.captureCachedPage(index)) case .onWebImageFailed(let index): state.imageURLLoadingStates[index] = .failed(.webImageFailed) - return reduceLocalPageMiss(state: &state, index: index) + guard let url = state.localPageURLs[index], url.isFileURL, + state.gallery.id.isValidGID + else { + return .none + } + let gid = state.gallery.id + let requestID = UUID() + state.localPageRequestID = requestID + return .run { send in + let localPageURLs = await downloadClient.rescanLocalPageURLs(gid) ?? [:] + await send(.loadLocalPageURLsDone(requestID, localPageURLs)) + } + .cancellable(id: ReadingCancelID.loadLocalPageURLs, cancelInFlight: true) case .reloadAllWebImages: - return reduceReloadAllWebImages(state: &state) + guard state.contentSource == .remote else { + if case .local(let download, let manifest) = state.contentSource { + applyLocalSource(state: &state, download: download, manifest: manifest) + } + return .none + } + state.previewURLs = .init() + state.thumbnailURLs = .init() + state.imageURLs = .init() + state.originalImageURLs = .init() + state.mpvKey = nil + state.mpvImageKeys = .init() + state.mpvSkipServerIdentifiers = .init() + state.forceRefreshID = .init() + return .run { [state] _ in + await databaseClient.removeImageURLs(gid: state.gallery.id) + } case .retryAllFailedWebImages: - return reduceRetryAllFailedWebImages(state: &state) + guard !state.isOffline else { return .none } + state.imageURLLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.imageURLLoadingStates[index] = .idle + } + } + state.previewLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.previewLoadingStates[index] = .idle + } + } + return .none case .copyImage(let imageURL): return .send(.fetchImage(.copy, imageURL)) @@ -111,7 +173,29 @@ extension ReadingReducer { .cancellable(id: ReadingCancelID.fetchImage) case .fetchImageDone(let action, let result): - return reduceFetchImageDone(state: &state, action: action, result: result) + if case .success(let asset) = result { + switch action { + case .copy: + state.hudConfig = .copiedToClipboardSucceeded + return .merge( + .send(.setNavigation(.hud)), + .run(operation: { _ in _ = clipboardClient.saveImageData(asset.data) }) + ) + case .save: + return .run { send in + let success = await imageClient.saveImageDataToPhotoLibrary(asset.data) + await send(.saveImageDone(success)) + } + case .share: + let shareItem: ShareItem = asset.isAnimated + ? .data(asset.data) + : .image(asset.image) + return .send(.setNavigation(.share(.init(value: shareItem)))) + } + } else { + state.hudConfig = .error() + return .send(.setNavigation(.hud)) + } case .teardown: return reduceTeardown() @@ -122,122 +206,3 @@ extension ReadingReducer { } } } - -// MARK: - UI Actions -extension ReadingReducer { - func reduceOrientation(isPortrait: Bool) -> Effect { - var effects = [Effect]() - if isPortrait { - effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientationMask() })) - effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientation() })) - } else { - effects.append(.run(operation: { _ in await appDelegateClient.setAllOrientationMask() })) - } - return .merge(effects) - } - - func reduceOnAppear(gid: String, enablesLandscape: Bool) -> Effect { - var effects: [Effect] = [ - .send(.fetchDatabaseInfos(gid)), - .send(.observeDownloads(gid)), - .send(.loadLocalPageURLs(gid)) - ] - if enablesLandscape { - effects.append(.send(.setOrientationPortrait(false))) - } - return .merge(effects) - } - - func reduceWebImageSucceeded(state: inout State, index: Int) -> Effect { - state.imageURLLoadingStates[index] = .idle - state.webImageLoadSuccessIndices.insert(index) - guard !state.isOffline, - state.gallery.id.isValidGID, - state.localPageURLs[index] == nil - else { - return .none - } - return .send(.captureCachedPage(index)) - } - - func reduceLocalPageMiss(state: inout State, index: Int) -> Effect { - guard let url = state.localPageURLs[index], url.isFileURL, - state.gallery.id.isValidGID - else { - return .none - } - let gid = state.gallery.id - let requestID = UUID() - state.localPageRequestID = requestID - return .run { send in - let localPageURLs = await downloadClient.rescanLocalPageURLs(gid) ?? [:] - await send(.loadLocalPageURLsDone(requestID, localPageURLs)) - } - .cancellable(id: ReadingCancelID.loadLocalPageURLs, cancelInFlight: true) - } - - func reduceReloadAllWebImages(state: inout State) -> Effect { - guard state.contentSource == .remote else { - if case .local(let download, let manifest) = state.contentSource { - applyLocalSource(state: &state, download: download, manifest: manifest) - } - return .none - } - state.previewURLs = .init() - state.thumbnailURLs = .init() - state.imageURLs = .init() - state.originalImageURLs = .init() - state.mpvKey = nil - state.mpvImageKeys = .init() - state.mpvSkipServerIdentifiers = .init() - state.forceRefreshID = .init() - return .run { [state] _ in - await databaseClient.removeImageURLs(gid: state.gallery.id) - } - } - - func reduceRetryAllFailedWebImages(state: inout State) -> Effect { - guard !state.isOffline else { return .none } - state.imageURLLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.imageURLLoadingStates[index] = .idle - } - } - state.previewLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.previewLoadingStates[index] = .idle - } - } - return .none - } - - func reduceFetchImageDone( - state: inout State, - action: ImageAction, - result: Result - ) -> Effect { - if case .success(let asset) = result { - switch action { - case .copy: - state.hudConfig = .copiedToClipboardSucceeded - return .merge( - .send(.setNavigation(.hud)), - .run(operation: { _ in _ = clipboardClient.saveImageData(asset.data) }) - ) - case .save: - return .run { send in - let success = await imageClient.saveImageDataToPhotoLibrary(asset.data) - await send(.saveImageDone(success)) - } - case .share: - let shareItem: ShareItem = asset.isAnimated - ? .data(asset.data) - : .image(asset.image) - return .send(.setNavigation(.share(.init(value: shareItem)))) - } - } else { - state.hudConfig = .error() - return .send(.setNavigation(.hud)) - } - } -} diff --git a/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift b/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift index af33f3ad6..74d4f2dde 100644 --- a/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift +++ b/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift @@ -11,428 +11,336 @@ extension ReadingReducer { Reduce { state, action in switch action { case .fetchPreviewURLs(let index): - return reduceFetchPreviewURLs(state: &state, index: index) + guard !state.isOffline else { + state.previewLoadingStates[index] = .idle + return .none + } + guard state.previewLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.previewLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + return .run { send in + let response = await GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() + await send(.fetchPreviewURLsDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchPreviewURLs) case .fetchPreviewURLsDone(let index, let result): - return reduceFetchPreviewURLsDone(state: &state, index: index, result: result) + switch result { + case .success(let previewURLs): + guard !previewURLs.isEmpty else { + state.previewLoadingStates[index] = .failed(.notFound) + return .none + } + state.previewLoadingStates[index] = .idle + state.updatePreviewURLs(previewURLs) + return .send(.syncPreviewURLs(previewURLs)) + case .failure(let error): + state.previewLoadingStates[index] = .failed(error) + } + return .none case .fetchImageURLs(let index): - return reduceFetchImageURLs(state: &state, index: index) + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.localPageURLs[index] == nil else { + state.imageURLLoadingStates[index] = .idle + return .none + } + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, false)) + } else { + return .send(.fetchThumbnailURLs(index)) + } case .refetchImageURLs(let index): - return reduceRefetchImageURLs(state: &state, index: index) + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.localPageURLs[index] == nil else { + state.imageURLLoadingStates[index] = .idle + return .none + } + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, true)) + } else { + return .send(.refetchNormalImageURLs(index)) + } case .prefetchImages(let index, let prefetchLimit): - return reducePrefetchImages(state: &state, index: index, prefetchLimit: prefetchLimit) + guard !state.isOffline else { return .none } + func getPrefetchImageURLs(range: ClosedRange) -> [URL] { + (range.lowerBound...range.upperBound).compactMap { index in + if let url = state.localPageURLs[index], !url.isFileURL { + return url + } + if let url = state.imageURLs[index] { + return url + } + return nil + } + } + func getFetchImageURLIndices(range: ClosedRange) -> [Int] { + (range.lowerBound...range.upperBound).compactMap { index in + if state.localPageURLs[index] != nil { + return nil + } + if state.imageURLs[index] == nil, + state.imageURLLoadingStates[index] != .loading { + return index + } + return nil + } + } + var prefetchImageURLs = [URL]() + var fetchImageURLIndices = [Int]() + var effects = [Effect]() + let previousUpperBound = max(index - 2, 1) + let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) + if previousUpperBound - previousLowerBound > 0 { + prefetchImageURLs += getPrefetchImageURLs(range: previousLowerBound...previousUpperBound) + fetchImageURLIndices += getFetchImageURLIndices(range: previousLowerBound...previousUpperBound) + } + let nextLowerBound = min(index + 2, state.gallery.pageCount) + let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) + if nextUpperBound - nextLowerBound > 0 { + prefetchImageURLs += getPrefetchImageURLs(range: nextLowerBound...nextUpperBound) + fetchImageURLIndices += getFetchImageURLIndices(range: nextLowerBound...nextUpperBound) + } + fetchImageURLIndices.forEach { + effects.append(.send(.fetchImageURLs($0))) + } + effects.append( + .run { [prefetchImageURLs] _ in + imageClient.prefetchImages(prefetchImageURLs) + } + ) + return .merge(effects) case .fetchThumbnailURLs(let index): - return reduceFetchThumbnailURLs(state: &state, index: index) + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.previewConfig.batchRange(index: index).forEach { + state.imageURLLoadingStates[$0] = .loading + } + let pageNum = state.previewConfig.pageNumber(index: index) + return .run { send in + let response = await ThumbnailURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() + await send(.fetchThumbnailURLsDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchThumbnailURLs) case .fetchThumbnailURLsDone(let index, let result): - return reduceFetchThumbnailURLsDone(state: &state, index: index, result: result) + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let thumbnailURLs): + guard !thumbnailURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { + return .send(.fetchMPVKeys(index, url)) + } else { + state.updateThumbnailURLs(thumbnailURLs) + return .merge( + .send(.syncThumbnailURLs(thumbnailURLs)), + .send(.fetchNormalImageURLs(index, thumbnailURLs)) + ) + } + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none case .fetchNormalImageURLs(let index, let thumbnailURLs): - return reduceFetchNormalImageURLs( - state: &state, index: index, thumbnailURLs: thumbnailURLs - ) + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + return .run { send in + let response = await GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs).response() + await send(.fetchNormalImageURLsDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchNormalImageURLs) case .fetchNormalImageURLsDone(let index, let result): - return reduceFetchNormalImageURLsDone(state: &state, index: index, result: result) + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let (imageURLs, originalImageURLs)): + guard !imageURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.updateImageURLs(imageURLs, originalImageURLs) + return .send(.syncImageURLs(imageURLs, originalImageURLs)) + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none case .refetchNormalImageURLs(let index): - return reduceRefetchNormalImageURLs(state: &state, index: index) + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL, + let imageURL = state.imageURLs[index] + else { return .none } + state.imageURLLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + return .run { [thumbnailURL = state.thumbnailURLs[index]] send in + let response = await GalleryNormalImageURLRefetchRequest( + index: index, + pageNum: pageNum, + galleryURL: galleryURL, + thumbnailURL: thumbnailURL, + storedImageURL: imageURL + ) + .response() + await send(.refetchNormalImageURLsDone(index, response)) + } + .cancellable(id: ReadingCancelID.refetchNormalImageURLs) case .refetchNormalImageURLsDone(let index, let result): - return reduceRefetchNormalImageURLsDone(state: &state, index: index, result: result) + switch result { + case .success(let (imageURLs, response)): + var effects = [Effect]() + if let response = response { + effects.append(.run(operation: { _ in cookieClient.setSkipServer(response: response) })) + } + guard !imageURLs.isEmpty else { + state.imageURLLoadingStates[index] = .failed(.notFound) + return effects.isEmpty ? .none : .merge(effects) + } + state.imageURLLoadingStates[index] = .idle + state.updateImageURLs(imageURLs, [:]) + effects.append(.send(.syncImageURLs(imageURLs, [:]))) + return .merge(effects) + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + } + return .none case .fetchMPVKeys(let index, let mpvURL): - return reduceFetchMPVKeys(state: &state, index: index, mpvURL: mpvURL) + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + return .run { send in + let response = await MPVKeysRequest(mpvURL: mpvURL).response() + await send(.fetchMPVKeysDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchMPVKeys) case .fetchMPVKeysDone(let index, let result): - return reduceFetchMPVKeysDone(state: &state, index: index, result: result) - - case .fetchMPVImageURL(let index, let isRefresh): - return reduceFetchMPVImageURL(state: &state, index: index, isRefresh: isRefresh) - - case .fetchMPVImageURLDone(let index, let result): - return reduceFetchMPVImageURLDone(state: &state, index: index, result: result) - - case .captureCachedPage(let index): - return reduceCaptureCachedPage(state: &state, index: index) - - default: - return .none - } - } - } - - func reduceFetchPreviewURLs(state: inout State, index: Int) -> Effect { - guard !state.isOffline else { - state.previewLoadingStates[index] = .idle - return .none - } - guard state.previewLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.previewLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { send in - let response = await GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() - await send(.fetchPreviewURLsDone(index, response)) - } - .cancellable(id: ReadingCancelID.fetchPreviewURLs) - } - - func reduceFetchPreviewURLsDone( - state: inout State, index: Int, result: Result<[Int: URL], AppError> - ) -> Effect { - switch result { - case .success(let previewURLs): - guard !previewURLs.isEmpty else { - state.previewLoadingStates[index] = .failed(.notFound) + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let (mpvKey, mpvImageKeys)): + let pageCount = state.gallery.pageCount + guard mpvImageKeys.count == pageCount else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.mpvKey = mpvKey + state.mpvImageKeys = mpvImageKeys + return .merge( + Array(1...min(3, max(1, pageCount))).map { + .send(.fetchMPVImageURL($0, false)) + } + ) + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } return .none - } - state.previewLoadingStates[index] = .idle - state.updatePreviewURLs(previewURLs) - return .send(.syncPreviewURLs(previewURLs)) - case .failure(let error): - state.previewLoadingStates[index] = .failed(error) - } - return .none - } - - func reduceFetchImageURLs(state: inout State, index: Int) -> Effect { - guard !state.isOffline else { - state.imageURLLoadingStates[index] = .idle - return .none - } - guard state.localPageURLs[index] == nil else { - state.imageURLLoadingStates[index] = .idle - return .none - } - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, false)) - } else { - return .send(.fetchThumbnailURLs(index)) - } - } - - func reduceRefetchImageURLs(state: inout State, index: Int) -> Effect { - guard !state.isOffline else { - state.imageURLLoadingStates[index] = .idle - return .none - } - guard state.localPageURLs[index] == nil else { - state.imageURLLoadingStates[index] = .idle - return .none - } - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, true)) - } else { - return .send(.refetchNormalImageURLs(index)) - } - } - func reducePrefetchImages( - state: inout State, index: Int, prefetchLimit: Int - ) -> Effect { - guard !state.isOffline else { return .none } - func getPrefetchImageURLs(range: ClosedRange) -> [URL] { - (range.lowerBound...range.upperBound).compactMap { index in - if let url = state.localPageURLs[index], !url.isFileURL { - return url - } - if let url = state.imageURLs[index] { - return url - } - return nil - } - } - func getFetchImageURLIndices(range: ClosedRange) -> [Int] { - (range.lowerBound...range.upperBound).compactMap { index in - if state.localPageURLs[index] != nil { - return nil + case .fetchMPVImageURL(let index, let isRefresh): + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none } - if state.imageURLs[index] == nil, - state.imageURLLoadingStates[index] != .loading { - return index + guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, + let mpvImageKey = state.mpvImageKeys[index], + state.imageURLLoadingStates[index] != .loading + else { return .none } + state.imageURLLoadingStates[index] = .loading + let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil + return .run { send in + let response = await GalleryMPVImageURLRequest( + gid: gidInteger, + index: index, + mpvKey: mpvKey, + mpvImageKey: mpvImageKey, + skipServerIdentifier: skipServerIdentifier + ) + .response() + await send(.fetchMPVImageURLDone(index, response)) } - return nil - } - } - var prefetchImageURLs = [URL]() - var fetchImageURLIndices = [Int]() - var effects = [Effect]() - let previousUpperBound = max(index - 2, 1) - let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) - if previousUpperBound - previousLowerBound > 0 { - prefetchImageURLs += getPrefetchImageURLs(range: previousLowerBound...previousUpperBound) - fetchImageURLIndices += getFetchImageURLIndices(range: previousLowerBound...previousUpperBound) - } - let nextLowerBound = min(index + 2, state.gallery.pageCount) - let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) - if nextUpperBound - nextLowerBound > 0 { - prefetchImageURLs += getPrefetchImageURLs(range: nextLowerBound...nextUpperBound) - fetchImageURLIndices += getFetchImageURLIndices(range: nextLowerBound...nextUpperBound) - } - fetchImageURLIndices.forEach { - effects.append(.send(.fetchImageURLs($0))) - } - effects.append( - .run { [prefetchImageURLs] _ in - imageClient.prefetchImages(prefetchImageURLs) - } - ) - return .merge(effects) - } - - func reduceFetchThumbnailURLs(state: inout State, index: Int) -> Effect { - guard !state.isOffline else { - state.imageURLLoadingStates[index] = .idle - return .none - } - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.previewConfig.batchRange(index: index).forEach { - state.imageURLLoadingStates[$0] = .loading - } - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { send in - let response = await ThumbnailURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() - await send(.fetchThumbnailURLsDone(index, response)) - } - .cancellable(id: ReadingCancelID.fetchThumbnailURLs) - } + .cancellable(id: ReadingCancelID.fetchMPVImageURL) - func reduceFetchThumbnailURLsDone( - state: inout State, index: Int, result: Result<[Int: URL], AppError> - ) -> Effect { - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let thumbnailURLs): - guard !thumbnailURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) + case .fetchMPVImageURLDone(let index, let result): + switch result { + case .success(let mpvResult): + let imageURLs: [Int: URL] = [index: mpvResult.imageURL] + var originalImageURLs = [Int: URL]() + if let originalImageURL = mpvResult.originalImageURL { + originalImageURLs[index] = originalImageURL + } + state.imageURLLoadingStates[index] = .idle + state.mpvSkipServerIdentifiers[index] = mpvResult.skipServerIdentifier + state.updateImageURLs(imageURLs, originalImageURLs) + return .send(.syncImageURLs(imageURLs, originalImageURLs)) + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) } return .none - } - if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { - return .send(.fetchMPVKeys(index, url)) - } else { - state.updateThumbnailURLs(thumbnailURLs) - return .merge( - .send(.syncThumbnailURLs(thumbnailURLs)), - .send(.fetchNormalImageURLs(index, thumbnailURLs)) - ) - } - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - } - - func reduceFetchNormalImageURLs( - state: inout State, index: Int, thumbnailURLs: [Int: URL] - ) -> Effect { - guard !state.isOffline else { - state.imageURLLoadingStates[index] = .idle - return .none - } - return .run { send in - let response = await GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs).response() - await send(.fetchNormalImageURLsDone(index, response)) - } - .cancellable(id: ReadingCancelID.fetchNormalImageURLs) - } - func reduceFetchNormalImageURLsDone( - state: inout State, index: Int, - result: Result<([Int: URL], [Int: URL]), AppError> - ) -> Effect { - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let (imageURLs, originalImageURLs)): - guard !imageURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) + case .captureCachedPage(let index): + guard !state.isOffline, + state.gallery.id.isValidGID + else { + return .none } - return .none - } - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - } - - func reduceRefetchNormalImageURLs(state: inout State, index: Int) -> Effect { - guard !state.isOffline else { - state.imageURLLoadingStates[index] = .idle - return .none - } - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL, - let imageURL = state.imageURLs[index] - else { return .none } - state.imageURLLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { [thumbnailURL = state.thumbnailURLs[index]] send in - let response = await GalleryNormalImageURLRefetchRequest( - index: index, - pageNum: pageNum, - galleryURL: galleryURL, - thumbnailURL: thumbnailURL, - storedImageURL: imageURL - ) - .response() - await send(.refetchNormalImageURLsDone(index, response)) - } - .cancellable(id: ReadingCancelID.refetchNormalImageURLs) - } - - func reduceRefetchNormalImageURLsDone( - state: inout State, index: Int, - result: Result<([Int: URL], HTTPURLResponse?), AppError> - ) -> Effect { - switch result { - case .success(let (imageURLs, response)): - var effects = [Effect]() - if let response = response { - effects.append(.run(operation: { _ in cookieClient.setSkipServer(response: response) })) - } - guard !imageURLs.isEmpty else { - state.imageURLLoadingStates[index] = .failed(.notFound) - return effects.isEmpty ? .none : .merge(effects) - } - state.imageURLLoadingStates[index] = .idle - state.updateImageURLs(imageURLs, [:]) - effects.append(.send(.syncImageURLs(imageURLs, [:]))) - return .merge(effects) - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) - } - return .none - } -} - -// MARK: - MPV Actions -extension ReadingReducer { - func reduceFetchMPVKeys( - state: inout State, index: Int, mpvURL: URL - ) -> Effect { - guard !state.isOffline else { - state.imageURLLoadingStates[index] = .idle - return .none - } - return .run { send in - let response = await MPVKeysRequest(mpvURL: mpvURL).response() - await send(.fetchMPVKeysDone(index, response)) - } - .cancellable(id: ReadingCancelID.fetchMPVKeys) - } - - func reduceFetchMPVKeysDone( - state: inout State, index: Int, - result: Result<(String, [Int: String]), AppError> - ) -> Effect { - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let (mpvKey, mpvImageKeys)): - let pageCount = state.gallery.pageCount - guard mpvImageKeys.count == pageCount else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) + let gid = state.gallery.id + let imageURL = state.imageURLs[index] + return .run { _ in + await downloadClient.captureCachedPage( + gid, + index, + imageURL + ) } - return .none - } - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.mpvKey = mpvKey - state.mpvImageKeys = mpvImageKeys - return .merge( - Array(1...min(3, max(1, pageCount))).map { - .send(.fetchMPVImageURL($0, false)) - } - ) - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - } - - func reduceFetchMPVImageURL( - state: inout State, index: Int, isRefresh: Bool - ) -> Effect { - guard !state.isOffline else { - state.imageURLLoadingStates[index] = .idle - return .none - } - guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, - let mpvImageKey = state.mpvImageKeys[index], - state.imageURLLoadingStates[index] != .loading - else { return .none } - state.imageURLLoadingStates[index] = .loading - let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil - return .run { send in - let response = await GalleryMPVImageURLRequest( - gid: gidInteger, - index: index, - mpvKey: mpvKey, - mpvImageKey: mpvImageKey, - skipServerIdentifier: skipServerIdentifier - ) - .response() - await send(.fetchMPVImageURLDone(index, response)) - } - .cancellable(id: ReadingCancelID.fetchMPVImageURL) - } - func reduceFetchMPVImageURLDone( - state: inout State, index: Int, result: Result - ) -> Effect { - switch result { - case .success(let mpvResult): - let imageURLs: [Int: URL] = [index: mpvResult.imageURL] - var originalImageURLs = [Int: URL]() - if let originalImageURL = mpvResult.originalImageURL { - originalImageURLs[index] = originalImageURL + default: + return .none } - state.imageURLLoadingStates[index] = .idle - state.mpvSkipServerIdentifiers[index] = mpvResult.skipServerIdentifier - state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) - } - return .none - } - - func reduceCaptureCachedPage(state: inout State, index: Int) -> Effect { - guard !state.isOffline, - state.gallery.id.isValidGID - else { - return .none - } - let gid = state.gallery.id - let imageURL = state.imageURLs[index] - return .run { _ in - await downloadClient.captureCachedPage( - gid, - index, - imageURL - ) } } } From c731887c6467d46e336a013e1562b54da4195b85 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 22 Jun 2026 06:03:57 +0800 Subject: [PATCH 282/614] Code cleanup --- .../App/Tools/Clients/DownloadPageDownloader.swift | 5 +---- EhPanda/App/Tools/Clients/FileClient.swift | 2 +- EhPanda/App/Tools/Utilities/DataCache.swift | 2 +- EhPanda/App/Tools/Utilities/FileUtil.swift | 13 ++----------- EhPanda/Database/Migration/CoreDataMigrator.swift | 2 +- .../Models/Download/DownloadRequestOptions.swift | 6 +++--- EhPanda/Models/Persistent/Setting.swift | 10 +++++++--- 7 files changed, 16 insertions(+), 24 deletions(-) diff --git a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift index b58aa4ff0..caba3b84e 100644 --- a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift +++ b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift @@ -53,10 +53,7 @@ struct DownloadPageDownloader: Sendable { } enum DownloadBackgroundSessionEvents { - static var pageSessionIdentifier: String { - let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.ehpanda" - return "\(bundleIdentifier).downloads.pages" - } + static let pageSessionIdentifier: String = "app.ehpanda.downloads.pages" @MainActor private static var completionHandlers = [String: () -> Void]() diff --git a/EhPanda/App/Tools/Clients/FileClient.swift b/EhPanda/App/Tools/Clients/FileClient.swift index 42954b1de..1dfa0f995 100644 --- a/EhPanda/App/Tools/Clients/FileClient.swift +++ b/EhPanda/App/Tools/Clients/FileClient.swift @@ -76,7 +76,7 @@ extension FileClient { ) func saveTorrent(hash: String, data: Data) -> URL? { - let torrentDirectory = FileUtil.cachesDirectory.appendingPathComponent("\(hash).torrent") + let torrentDirectory = URL.cachesDirectory.appendingPathComponent("\(hash).torrent") return createFile(torrentDirectory.path, data) ? torrentDirectory : nil } } diff --git a/EhPanda/App/Tools/Utilities/DataCache.swift b/EhPanda/App/Tools/Utilities/DataCache.swift index 7432851a0..dfd9226e2 100644 --- a/EhPanda/App/Tools/Utilities/DataCache.swift +++ b/EhPanda/App/Tools/Utilities/DataCache.swift @@ -28,7 +28,7 @@ actor DataCache { var sweepByteInterval: UInt64 init( - rootURL: URL = FileUtil.cachesDirectory + rootURL: URL = URL.cachesDirectory .appendingPathComponent("DataCache.reading", isDirectory: true), memoryCostLimit: Int = Int(ProcessInfo.processInfo.physicalMemory / 4), maxDiskAge: TimeInterval = 7 * 24 * 60 * 60, diff --git a/EhPanda/App/Tools/Utilities/FileUtil.swift b/EhPanda/App/Tools/Utilities/FileUtil.swift index d52be7720..592779bf9 100644 --- a/EhPanda/App/Tools/Utilities/FileUtil.swift +++ b/EhPanda/App/Tools/Utilities/FileUtil.swift @@ -6,22 +6,13 @@ import Foundation struct FileUtil { - static var documentDirectory: URL { - .documentsDirectory - } - static var cachesDirectory: URL { - .cachesDirectory - } static var logsDirectoryURL: URL { - documentDirectory.appendingPathComponent(Defaults.FilePath.logs) + .documentsDirectory.appendingPathComponent(Defaults.FilePath.logs) } static var downloadsDirectoryURL: URL { - documentDirectory.appendingPathComponent( + .documentsDirectory.appendingPathComponent( Defaults.FilePath.downloads, isDirectory: true ) } - static var temporaryDirectory: URL { - .init(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - } } diff --git a/EhPanda/Database/Migration/CoreDataMigrator.swift b/EhPanda/Database/Migration/CoreDataMigrator.swift index dbef28a6b..98c395ef9 100755 --- a/EhPanda/Database/Migration/CoreDataMigrator.swift +++ b/EhPanda/Database/Migration/CoreDataMigrator.swift @@ -26,7 +26,7 @@ final class CoreDataMigrator: CoreDataMigratorProtocol, Sendable { let manager = NSMigrationManager( sourceModel: migrationStep.sourceModel, destinationModel: migrationStep.destinationModel ) - let destinationURL = FileUtil.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let destinationURL = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) do { try manager.migrateStore( diff --git a/EhPanda/Models/Download/DownloadRequestOptions.swift b/EhPanda/Models/Download/DownloadRequestOptions.swift index 89f989465..2a96ef56b 100644 --- a/EhPanda/Models/Download/DownloadRequestOptions.swift +++ b/EhPanda/Models/Download/DownloadRequestOptions.swift @@ -9,9 +9,9 @@ /// per run (see `downloadOptionsProvider`) and threaded to the workers, so a settings change /// while a gallery sits queued takes effect when it finally starts. struct DownloadRequestOptions: Equatable, Sendable { - var threadLimit = 1 - var allowCellular = true - var autoRetryFailedPages = true + var threadLimit = Setting.downloadThreadLimitDefaultValue + var allowCellular = Setting.downloadAllowCellularDefaultValue + var autoRetryFailedPages = Setting.downloadAutoRetryFailedPagesDefaultValue var workerCount: Int { threadLimit diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift index cc18c5a77..4b1fa2ea0 100644 --- a/EhPanda/Models/Persistent/Setting.swift +++ b/EhPanda/Models/Persistent/Setting.swift @@ -50,9 +50,13 @@ struct Setting: Codable, Equatable { var doubleTapScaleFactor: Double = 2 // Downloads - var downloadThreadLimit = 1 - var downloadAllowCellular = true - var downloadAutoRetryFailedPages = true + static let downloadThreadLimitDefaultValue = 1 + static let downloadAllowCellularDefaultValue = true + static let downloadAutoRetryFailedPagesDefaultValue = true + + var downloadThreadLimit = Self.downloadThreadLimitDefaultValue + var downloadAllowCellular = Self.downloadAllowCellularDefaultValue + var downloadAutoRetryFailedPages = Self.downloadAutoRetryFailedPagesDefaultValue // Laboratory var bypassesSNIFiltering = false From 64b01cc84818ecb6a41e1117c854d19c3c24695d Mon Sep 17 00:00:00 2001 From: kaed3mi Date: Tue, 16 Jun 2026 12:59:07 +0800 Subject: [PATCH 283/614] feat: Add gallery date jump navigation feature - Add date jump button to Frontpage and Search views - Support both E-Hentai and ExHentai date navigation - Parse jump navigation metadata from JavaScript variables - Add JumpGalleriesRequest for date-based gallery fetching - Implement DateJumpView with date picker and seek buttons - Add 8-language localization support (en, zh-Hans, zh-Hant, ja, ko, de, etc.) - Add Parser functions for script variable parsing - Update FrontpageReducer and SearchReducer with jump actions - Add unit tests for list parser --- .github/workflows/build-unsigned-ipa.yml | 70 +++++++++++ .github/workflows/dependencies.yml | 2 +- .github/workflows/deploy-pre-release.yml | 2 +- .github/workflows/test.yml | 2 +- EhPanda/App/Generated/Strings.swift | 20 ++++ .../Tools/Extensions/AlertKit_Extension.swift | 61 ++++++++++ EhPanda/App/Tools/Parser/Parser+Misc.swift | 11 +- EhPanda/App/Tools/Parser/Parser+Shared.swift | 70 +++++++++++ EhPanda/App/de.lproj/Localizable.strings | 7 ++ EhPanda/App/en.lproj/Localizable.strings | 7 ++ EhPanda/App/ja.lproj/Localizable.strings | 7 ++ EhPanda/App/ko.lproj/Localizable.strings | 8 ++ EhPanda/App/zh-Hans.lproj/Localizable.strings | 7 ++ .../App/zh-Hant-HK.lproj/Localizable.strings | 7 ++ .../App/zh-Hant-TW.lproj/Localizable.strings | 7 ++ EhPanda/App/zh-Hant.lproj/Localizable.strings | 7 ++ EhPanda/Models/Support/Misc.swift | 49 ++++++++ EhPanda/Network/Request+Gallery.swift | 17 +++ .../Home/Frontpage/FrontpageReducer.swift | 53 ++++++++- .../View/Home/Frontpage/FrontpageView.swift | 12 ++ EhPanda/View/Search/SearchReducer.swift | 53 ++++++++- EhPanda/View/Search/SearchView.swift | 12 ++ .../Support/Components/ToolbarItems.swift | 22 ++++ .../Tests/Parser/List/ListParserTests.swift | 109 ++++++++++++++++++ 24 files changed, 615 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/build-unsigned-ipa.yml diff --git a/.github/workflows/build-unsigned-ipa.yml b/.github/workflows/build-unsigned-ipa.yml new file mode 100644 index 000000000..d0eca20c4 --- /dev/null +++ b/.github/workflows/build-unsigned-ipa.yml @@ -0,0 +1,70 @@ +name: Build Unsigned IPA + +on: + workflow_dispatch: + push: + branches: + - main + +env: + DEVELOPER_DIR: /Applications/Xcode_26.4.1.app + SCHEME_NAME: EhPanda + BUILDS_PATH: /tmp/action-builds + PAYLOAD_PATH: /tmp/action-builds/Payload + ARCHIVE_PATH: /tmp/action-builds/EhPanda.xcarchive + IPA_OUTPUT_PATH: /tmp/action-builds/EhPanda-unsigned.ipa + THIN_PAYLOAD_SCRIPT_PATH: ./actions-tool/thin-payload.sh + +jobs: + build-unsigned-ipa: + runs-on: macos-26 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install iOS 26 Platform + uses: nick-fields/retry@v4 + with: + retry_on: error + max_attempts: 10 + timeout_minutes: 999 + command: xcodebuild -downloadPlatform iOS + + - name: Show Xcode version + run: xcodebuild -version + + - name: Run tests + run: xcodebuild clean test + -skipMacroValidation + -scheme ${{ env.SCHEME_NAME }} + -destination 'platform=iOS Simulator,name=iPhone Air' + + - name: Xcode archive without signing + run: xcodebuild archive + -skipMacroValidation + -scheme ${{ env.SCHEME_NAME }} + -destination 'generic/platform=iOS' + -archivePath ${{ env.ARCHIVE_PATH }} + CODE_SIGN_IDENTITY= + CODE_SIGN_ENTITLEMENTS= + CODE_SIGNING_ALLOWED=NO + CODE_SIGNING_REQUIRED=NO + GCC_OPTIMIZATION_LEVEL=s + SWIFT_OPTIMIZATION_LEVEL=-O + + - name: Export unsigned IPA + run: | + mkdir -p "${{ env.PAYLOAD_PATH }}" + mv "${{ env.ARCHIVE_PATH }}/Products/Applications/${{ env.SCHEME_NAME }}.app" "${{ env.PAYLOAD_PATH }}/${{ env.SCHEME_NAME }}.app" + sh "${{ env.THIN_PAYLOAD_SCRIPT_PATH }}" "${{ env.PAYLOAD_PATH }}/${{ env.SCHEME_NAME }}.app" + pushd "${{ env.BUILDS_PATH }}" + zip -r "${{ env.IPA_OUTPUT_PATH }}" ./Payload + popd + test -f "${{ env.IPA_OUTPUT_PATH }}" + + - name: Upload unsigned IPA + uses: actions/upload-artifact@v5 + with: + name: EhPanda-unsigned-ipa + path: ${{ env.IPA_OUTPUT_PATH }} + if-no-files-found: error diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 247e9c111..35bf94636 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -19,7 +19,7 @@ jobs: with: forceResolution: true failWhenOutdated: false - xcodePath: '/Applications/Xcode_26.5.app' + xcodePath: '/Applications/Xcode_26.4.1.app' - name: Create Pull Request if: steps.resolution.outputs.dependenciesChanged == 'true' uses: peter-evans/create-pull-request@v8 diff --git a/.github/workflows/deploy-pre-release.yml b/.github/workflows/deploy-pre-release.yml index 2bd7cfe8d..418309c7f 100644 --- a/.github/workflows/deploy-pre-release.yml +++ b/.github/workflows/deploy-pre-release.yml @@ -11,7 +11,7 @@ on: required: false type: string env: - DEVELOPER_DIR: /Applications/Xcode_26.5.app + DEVELOPER_DIR: /Applications/Xcode_26.4.1.app SCHEME_NAME: 'EhPanda' ALTSTORE_JSON_PATH: './AltStore.json' BUILDS_PATH: '/tmp/action-builds' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1bd40ae33..770cab27a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Test on: [push, workflow_dispatch] env: SCHEME_NAME: 'EhPanda' - DEVELOPER_DIR: /Applications/Xcode_26.5.app + DEVELOPER_DIR: /Applications/Xcode_26.4.1.app jobs: Test: runs-on: macos-26 diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 2d421b044..44398b5f2 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -2227,6 +2227,24 @@ internal enum L10n { internal static let success = L10n.tr("Localizable", "hud.title.success", fallback: "Success") } } + internal enum DateJumpView { + internal enum Button { + /// Seek Newer + internal static let seekNewer = L10n.tr("Localizable", "date_jump_view.button.seek_newer", fallback: "Seek Newer") + /// Seek Older + internal static let seekOlder = L10n.tr("Localizable", "date_jump_view.button.seek_older", fallback: "Seek Older") + } + internal enum Footer { + /// Seek to galleries around the selected date. + internal static let seekAroundDate = L10n.tr("Localizable", "date_jump_view.footer.seek_around_date", fallback: "Seek to galleries around the selected date.") + } + internal enum Title { + /// Date + internal static let date = L10n.tr("Localizable", "date_jump_view.title.date", fallback: "Date") + /// Date Jump + internal static let dateJump = L10n.tr("Localizable", "date_jump_view.title.date_jump", fallback: "Date Jump") + } + } internal enum JumpPageView { internal enum Button { /// Confirm @@ -2519,6 +2537,8 @@ internal enum L10n { } internal enum ToolbarItem { internal enum Button { + /// Date Jump + internal static let dateJump = L10n.tr("Localizable", "toolbar_item.button.date_jump", fallback: "Date Jump") /// Filters internal static let filters = L10n.tr("Localizable", "toolbar_item.button.filters", fallback: "Filters") /// Jump page diff --git a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift index 16574b855..ed44b248c 100644 --- a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift +++ b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift @@ -77,3 +77,64 @@ private struct JumpPageAlert: View { .synchronize($isPresented, $manager.isPresented) } } + +struct DateJumpView: View { + let pageNumber: PageNumber + @Binding var selectedDate: Date + let jumpAction: (PageJumpDirection) -> Void + + private var navigation: PageJumpNavigation? { + pageNumber.jumpNavigation + } + private var dateRange: ClosedRange { + navigation?.dateRange ?? Date.distantPast...Date.distantFuture + } + private var showsNewerButton: Bool { + navigation?.previousURL != nil + } + private var showsOlderButton: Bool { + navigation?.nextURL != nil + } + + var body: some View { + NavigationView { + Form { + Section { + DatePicker( + L10n.Localizable.DateJumpView.Title.date, + selection: $selectedDate, + in: dateRange, + displayedComponents: .date + ) + .datePickerStyle(.graphical) + } footer: { + Text(L10n.Localizable.DateJumpView.Footer.seekAroundDate) + } + + Section { + if showsNewerButton { + Button { + jumpAction(.newer) + } label: { + Label(L10n.Localizable.DateJumpView.Button.seekNewer, systemImage: "chevron.left") + } + } + if showsOlderButton { + Button { + jumpAction(.older) + } label: { + Label(L10n.Localizable.DateJumpView.Button.seekOlder, systemImage: "chevron.right") + } + } + } + } + .navigationTitle(L10n.Localizable.DateJumpView.Title.dateJump) + .navigationBarTitleDisplayMode(.inline) + } + .onAppear { + if let navigation { + selectedDate = navigation.clampedDate(selectedDate) + } + } + } +} diff --git a/EhPanda/App/Tools/Parser/Parser+Misc.swift b/EhPanda/App/Tools/Parser/Parser+Misc.swift index d0fef0cec..b2e395d32 100644 --- a/EhPanda/App/Tools/Parser/Parser+Misc.swift +++ b/EhPanda/App/Tools/Parser/Parser+Misc.swift @@ -52,9 +52,16 @@ extension Parser { break } - return PageNumber(lastItemTimestamp: timestamp, isNextButtonEnabled: isEnabled) + return PageNumber( + lastItemTimestamp: timestamp, + isNextButtonEnabled: isEnabled, + jumpNavigation: parsePageJumpNavigation(doc: doc) + ) } else { - return PageNumber(isNextButtonEnabled: false) + return PageNumber( + isNextButtonEnabled: false, + jumpNavigation: parsePageJumpNavigation(doc: doc) + ) } } diff --git a/EhPanda/App/Tools/Parser/Parser+Shared.swift b/EhPanda/App/Tools/Parser/Parser+Shared.swift index 3ca68c388..3f14b588e 100644 --- a/EhPanda/App/Tools/Parser/Parser+Shared.swift +++ b/EhPanda/App/Tools/Parser/Parser+Shared.swift @@ -26,6 +26,76 @@ extension Parser { return date } + static func parseScriptVariable(name: String, doc: HTMLDocument) -> String? { + let escapedName = NSRegularExpression.escapedPattern(for: name) + let pattern = #"var\s+\#(escapedName)\s*=\s*["']([^"']*)["']\s*;"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + + for script in doc.xpath("//script") { + guard let text = script.text else { continue } + let range = NSRange(text.startIndex..., in: text) + guard let match = regex.firstMatch(in: text, range: range), + let valueRange = Range(match.range(at: 1), in: text) + else { continue } + + return String(text[valueRange]) + } + return nil + } + + static func parseScriptURL(name: String, doc: HTMLDocument) -> URL? { + guard var value = parseScriptVariable(name: name, doc: doc)?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { return nil } + value = value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "\\u0026", with: "&") + + let baseURL = Defaults.URL.host + let parsedURL: URL? + if let url = URL(string: value), url.scheme != nil { + parsedURL = url + } else { + parsedURL = URL(string: value, relativeTo: baseURL)?.absoluteURL + } + + guard let parsedURL else { return nil } + guard var components = URLComponents(url: parsedURL, resolvingAgainstBaseURL: false) else { + return parsedURL + } + + let knownGalleryHosts = [ + Defaults.URL.ehentai.host, + Defaults.URL.exhentai.host, + Defaults.URL.sexhentai.host + ] + .compactMap { $0?.lowercased() } + + if let host = components.host?.lowercased(), + knownGalleryHosts.contains(host), + let baseComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + { + components.scheme = baseComponents.scheme + components.host = baseComponents.host + } + return components.url + } + + static func parseScriptDate(name: String, doc: HTMLDocument) -> Date? { + guard let value = parseScriptVariable(name: name, doc: doc), !value.isEmpty else { return nil } + return try? parseDate(time: value, format: "yyyy-MM-dd") + } + + static func parsePageJumpNavigation(doc: HTMLDocument) -> PageJumpNavigation? { + let navigation = PageJumpNavigation( + previousURL: parseScriptURL(name: "prevurl", doc: doc), + nextURL: parseScriptURL(name: "nexturl", doc: doc), + minimumDate: parseScriptDate(name: "mindate", doc: doc), + maximumDate: parseScriptDate(name: "maxdate", doc: doc) + ) + return navigation.isEnabled ? navigation : nil + } + // swiftlint:disable cyclomatic_complexity /// Returns ratings parsed from stars image / text and if the return contains a userRating . static func parseRating(node: XMLElement) throws -> RatingResult { diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 5f674aed7..86015f17e 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "Filters"; "toolbar_item.button.jump_page" = "Jump page"; +"toolbar_item.button.date_jump" = "Datumssprung"; "toolbar_item.button.quick_search" = "Quick search"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "Datumssprung"; +"date_jump_view.title.date" = "Datum"; +"date_jump_view.footer.seek_around_date" = "Zu Galerien um das ausgewählte Datum springen."; +"date_jump_view.button.seek_newer" = "Zu neueren springen"; +"date_jump_view.button.seek_older" = "Zu älteren springen"; // MARK: JumpPage "jump_page_view.title.jump_page" = "Jump page"; "jump_page_view.button.confirm" = "Confirm"; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 64d8a53e3..03191db52 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -55,8 +55,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "Filters"; "toolbar_item.button.jump_page" = "Jump page"; +"toolbar_item.button.date_jump" = "Date Jump"; "toolbar_item.button.quick_search" = "Quick search"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "Date Jump"; +"date_jump_view.title.date" = "Date"; +"date_jump_view.footer.seek_around_date" = "Seek to galleries around the selected date."; +"date_jump_view.button.seek_newer" = "Seek Newer"; +"date_jump_view.button.seek_older" = "Seek Older"; // MARK: JumpPage "jump_page_view.title.jump_page" = "Jump page"; "jump_page_view.button.confirm" = "Confirm"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 6f8673847..6358627d2 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "フィルター"; "toolbar_item.button.jump_page" = "ページジャンプ"; +"toolbar_item.button.date_jump" = "日付ジャンプ"; "toolbar_item.button.quick_search" = "クイック検索"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "日付ジャンプ"; +"date_jump_view.title.date" = "日付"; +"date_jump_view.footer.seek_around_date" = "選択した日付付近のギャラリーへ移動します。"; +"date_jump_view.button.seek_newer" = "新しい方へ移動"; +"date_jump_view.button.seek_older" = "古い方へ移動"; // MARK: JumpPage "jump_page_view.title.jump_page" = "ページジャンプ"; "jump_page_view.button.confirm" = "確認"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index c98593458..2c240d958 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -52,11 +52,19 @@ "toolbar_item.button.filters" = "필터"; "toolbar_item.button.jump_page" = "페이지 이동"; "toolbar_item.button.quick_search" = "빠른 검색"; +"toolbar_item.button.date_jump" = "날짜 이동"; // MARK: JumpPage "jump_page_view.title.jump_page" = "페이지 이동"; "jump_page_view.button.confirm" = "확인"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "날짜 이동"; +"date_jump_view.title.date" = "날짜"; +"date_jump_view.footer.seek_around_date" = "선택한 날짜 근처의 갤러리로 이동합니다."; +"date_jump_view.button.seek_newer" = "새 항목으로 이동"; +"date_jump_view.button.seek_older" = "오래된 항목으로 이동"; + // MARK: AlertView "loading_view.title.loading" = "로딩 중..."; "loading_view.title.preparing_database" = "Preparing the database..."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 57ff81b92..a8f473996 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -55,8 +55,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "筛选"; "toolbar_item.button.jump_page" = "页码跳转"; +"toolbar_item.button.date_jump" = "日期跳转"; "toolbar_item.button.quick_search" = "快速搜索"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "日期跳转"; +"date_jump_view.title.date" = "日期"; +"date_jump_view.footer.seek_around_date" = "跳转到所选日期附近的画廊。"; +"date_jump_view.button.seek_newer" = "跳到较新"; +"date_jump_view.button.seek_older" = "跳到较旧"; // MARK: JumpPage "jump_page_view.title.jump_page" = "页码跳转"; "jump_page_view.button.confirm" = "确认"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index f6181dc91..91bfd08e1 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "過濾"; "toolbar_item.button.jump_page" = "跳到..."; +"toolbar_item.button.date_jump" = "日期跳轉"; "toolbar_item.button.quick_search" = "快速搜尋"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "日期跳轉"; +"date_jump_view.title.date" = "日期"; +"date_jump_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; +"date_jump_view.button.seek_newer" = "跳到較新"; +"date_jump_view.button.seek_older" = "跳到較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 594e8d2a9..091567fcc 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "過濾"; "toolbar_item.button.jump_page" = "跳到..."; +"toolbar_item.button.date_jump" = "日期跳轉"; "toolbar_item.button.quick_search" = "快速搜尋"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "日期跳轉"; +"date_jump_view.title.date" = "日期"; +"date_jump_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; +"date_jump_view.button.seek_newer" = "跳到較新"; +"date_jump_view.button.seek_older" = "跳到較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 9f0eefa5c..fe1758872 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "過濾"; "toolbar_item.button.jump_page" = "跳到..."; +"toolbar_item.button.date_jump" = "日期跳轉"; "toolbar_item.button.quick_search" = "快速搜尋"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "日期跳轉"; +"date_jump_view.title.date" = "日期"; +"date_jump_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; +"date_jump_view.button.seek_newer" = "跳到較新"; +"date_jump_view.button.seek_older" = "跳到較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift index 6f210aadc..6c51b005b 100644 --- a/EhPanda/Models/Support/Misc.swift +++ b/EhPanda/Models/Support/Misc.swift @@ -10,6 +10,54 @@ import SwiftyBeaver typealias Logger = SwiftyBeaver typealias FavoritesSortOrder = EhSetting.FavoritesSortOrder +enum PageJumpDirection: Equatable { + case newer + case older +} + +struct PageJumpNavigation: Equatable { + var previousURL: URL? + var nextURL: URL? + var minimumDate: Date? + var maximumDate: Date? + + var isEnabled: Bool { + previousURL != nil || nextURL != nil + } + var dateRange: ClosedRange { + (minimumDate ?? .distantPast)...(maximumDate ?? .distantFuture) + } + + func clampedDate(_ date: Date = Date()) -> Date { + if let maximumDate, date > maximumDate { + return maximumDate + } + if let minimumDate, date < minimumDate { + return minimumDate + } + return date + } + + func seekURL(date: Date, direction: PageJumpDirection) -> URL? { + let baseURL: URL? + switch direction { + case .newer: + baseURL = previousURL + case .older: + baseURL = nextURL + } + return baseURL?.appending(queryItems: ["seek": Self.dateFormatter.string(from: date)]) + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() +} + protocol DateFormattable { var originalDate: Date { get } } @@ -29,6 +77,7 @@ struct PageNumber: Equatable { var maximum = 0 var lastItemTimestamp: String? var isNextButtonEnabled = false + var jumpNavigation: PageJumpNavigation? var isSinglePage: Bool { current == 0 && maximum == 0 diff --git a/EhPanda/Network/Request+Gallery.swift b/EhPanda/Network/Request+Gallery.swift index 750994199..2baca54e0 100644 --- a/EhPanda/Network/Request+Gallery.swift +++ b/EhPanda/Network/Request+Gallery.swift @@ -49,6 +49,23 @@ struct MoreSearchGalleriesRequest: Request { } } +struct JumpGalleriesRequest: Request { + let url: URL + + var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + URLSession.shared.dataTaskPublisher(for: url) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + struct FrontpageGalleriesRequest: Request { let filter: Filter diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift index aaf28198a..d67805026 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift @@ -4,6 +4,7 @@ // import ComposableArchitecture +import Foundation @Reducer struct FrontpageReducer { @@ -14,7 +15,7 @@ struct FrontpageReducer { } private enum CancelID: CaseIterable { - case fetchGalleries, fetchMoreGalleries + case fetchGalleries, fetchMoreGalleries, fetchJumpGalleries } @ObservableState @@ -28,6 +29,8 @@ struct FrontpageReducer { } var galleries = [Gallery]() var pageNumber = PageNumber() + var dateJumpDate = Date() + var dateJumpSheetPresented = false var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle @@ -57,6 +60,9 @@ struct FrontpageReducer { case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case presentDateJump + case jumpToDate(PageJumpDirection) + case jumpToDateDone(Result<(PageNumber, [Gallery]), AppError>) case filters(FiltersReducer.Action) case detail(DetailReducer.Action) @@ -152,6 +158,51 @@ struct FrontpageReducer { } return .none + case .presentDateJump: + guard let navigation = state.pageNumber.jumpNavigation, navigation.isEnabled else { + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + } + state.dateJumpDate = navigation.clampedDate(state.dateJumpDate) + state.dateJumpSheetPresented = true + return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) + + case .jumpToDate(let direction): + guard state.loadingState != .loading, + let url = state.pageNumber.jumpNavigation?.seekURL( + date: state.dateJumpDate, direction: direction + ) + else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } + + state.dateJumpSheetPresented = false + state.loadingState = .loading + state.footerLoadingState = .idle + state.pageNumber.resetPages() + return .run { send in + let response = await JumpGalleriesRequest(url: url).response() + await send(.jumpToDateDone(response)) + } + .cancellable(id: CancelID.fetchJumpGalleries) + + case .jumpToDateDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + guard pageNumber.hasNextPage() else { return .none } + return .send(.fetchMoreGalleries) + } + state.pageNumber = pageNumber + if let navigation = pageNumber.jumpNavigation { + state.dateJumpDate = navigation.clampedDate(state.dateJumpDate) + } + state.galleries = galleries + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + case .filters: return .none diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index b49134fb6..b681d80ba 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -44,6 +44,15 @@ struct FrontpageView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } + .sheet(isPresented: $store.dateJumpSheetPresented) { + DateJumpView( + pageNumber: store.pageNumber, + selectedDate: $store.dateJumpDate, + jumpAction: { store.send(.jumpToDate($0)) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) .onAppear { if store.galleries.isEmpty { @@ -86,6 +95,9 @@ struct FrontpageView: View { } private func toolbar() -> some ToolbarContent { CustomToolbarItem { + DateJumpButton(pageNumber: store.pageNumber, hideText: true) { + store.send(.presentDateJump) + } FiltersButton(hideText: true) { store.send(.setNavigation(.filters())) } diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index fb53627ad..cf5f15cae 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -4,6 +4,7 @@ // import ComposableArchitecture +import Foundation @Reducer struct SearchReducer { @@ -15,7 +16,7 @@ struct SearchReducer { } private enum CancelID: CaseIterable { - case fetchGalleries, fetchMoreGalleries, observeDownloads + case fetchGalleries, fetchMoreGalleries, observeDownloads, fetchJumpGalleries } @ObservableState @@ -26,6 +27,8 @@ struct SearchReducer { var galleries = [Gallery]() var pageNumber = PageNumber() + var dateJumpDate = Date() + var dateJumpSheetPresented = false var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle var downloadBadges = [String: DownloadBadge]() @@ -60,6 +63,9 @@ struct SearchReducer { case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case observeDownloads case observeDownloadsDone([DownloadedGallery]) + case presentDateJump + case jumpToDate(PageJumpDirection) + case jumpToDateDone(Result<(PageNumber, [Gallery]), AppError>) case detail(DetailReducer.Action) case filters(FiltersReducer.Action) @@ -191,6 +197,51 @@ struct SearchReducer { ) return .none + case .presentDateJump: + guard let navigation = state.pageNumber.jumpNavigation, navigation.isEnabled else { + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + } + state.dateJumpDate = navigation.clampedDate(state.dateJumpDate) + state.dateJumpSheetPresented = true + return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) + + case .jumpToDate(let direction): + guard state.loadingState != .loading, + let url = state.pageNumber.jumpNavigation?.seekURL( + date: state.dateJumpDate, direction: direction + ) + else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } + + state.dateJumpSheetPresented = false + state.loadingState = .loading + state.footerLoadingState = .idle + state.pageNumber.resetPages() + return .run { send in + let response = await JumpGalleriesRequest(url: url).response() + await send(.jumpToDateDone(response)) + } + .cancellable(id: CancelID.fetchJumpGalleries) + + case .jumpToDateDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + guard pageNumber.hasNextPage() else { return .none } + return .send(.fetchMoreGalleries) + } + state.pageNumber = pageNumber + if let navigation = pageNumber.jumpNavigation { + state.dateJumpDate = navigation.clampedDate(state.dateJumpDate) + } + state.galleries = galleries + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + case .detail: return .none diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index 2de69ee5a..efad18b3b 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -56,6 +56,15 @@ struct SearchView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } + .sheet(isPresented: $store.dateJumpSheetPresented) { + DateJumpView( + pageNumber: store.pageNumber, + selectedDate: $store.dateJumpDate, + jumpAction: { store.send(.jumpToDate($0)) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( @@ -109,6 +118,9 @@ struct SearchView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { ToolbarFeaturesMenu { + DateJumpButton(pageNumber: store.pageNumber) { + store.send(.presentDateJump) + } FiltersButton { store.send(.setNavigation(.filters())) } diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/EhPanda/View/Support/Components/ToolbarItems.swift index ef4d850e6..5c64dacdf 100644 --- a/EhPanda/View/Support/Components/ToolbarItems.swift +++ b/EhPanda/View/Support/Components/ToolbarItems.swift @@ -111,6 +111,28 @@ struct JumpPageButton: View { } } +struct DateJumpButton: View { + private let pageNumber: PageNumber + private let hideText: Bool + private let action: () -> Void + + init(pageNumber: PageNumber, hideText: Bool = false, action: @escaping () -> Void) { + self.pageNumber = pageNumber + self.hideText = hideText + self.action = action + } + + var body: some View { + Button(action: action) { + Image(systemName: "calendar") + if !hideText { + Text(L10n.Localizable.ToolbarItem.Button.dateJump) + } + } + .disabled(pageNumber.jumpNavigation?.isEnabled != true) + } +} + struct FavoritesIndexMenu: View { private let user: User private let index: Int diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index 72e2c9478..8093b1359 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -24,4 +24,113 @@ struct ListParserTests: TestHelper { } } } + + func testPageJumpNavigation() throws { + let document = try htmlDocument(filename: .frontPageMinimalList) + let pageNumber = Parser.parsePageNum(doc: document) + let navigation = try XCTUnwrap(pageNumber.jumpNavigation) + + XCTAssertTrue(pageNumber.hasNextPage()) + XCTAssertEqual(pageNumber.lastItemTimestamp, "2668517") + XCTAssertNil(navigation.previousURL) + XCTAssertEqual(navigation.nextURL?.absoluteString, "https://e-hentai.org/?next=2668517") + XCTAssertEqual(Self.dateFormatter.string(from: try XCTUnwrap(navigation.minimumDate)), "2007-03-20") + XCTAssertEqual(Self.dateFormatter.string(from: try XCTUnwrap(navigation.maximumDate)), "2023-09-08") + } + + func testPageJumpSeekURL() throws { + let document = try htmlDocument(filename: .frontPageMinimalList) + let pageNumber = Parser.parsePageNum(doc: document) + let navigation = try XCTUnwrap(pageNumber.jumpNavigation) + let maximumDate = try XCTUnwrap(navigation.maximumDate) + let url = try XCTUnwrap(navigation.seekURL(date: maximumDate, direction: .older)) + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + + XCTAssertEqual(queryItems?.first(where: { $0.name == "next" })?.value, "2668517") + XCTAssertEqual(queryItems?.first(where: { $0.name == "seek" })?.value, "2023-09-08") + XCTAssertNil(navigation.seekURL(date: maximumDate, direction: .newer)) + } + + func testPageJumpNavigationNormalizesExHentaiHost() throws { + let originalHost: String? = UserDefaultsUtil.value(forKey: .galleryHost) + UserDefaults.standard.set(GalleryHost.exhentai.rawValue, forKey: AppUserDefaults.galleryHost.rawValue) + defer { + if let originalHost { + UserDefaults.standard.set(originalHost, forKey: AppUserDefaults.galleryHost.rawValue) + } else { + UserDefaults.standard.removeObject(forKey: AppUserDefaults.galleryHost.rawValue) + } + } + + let document = try Kanna.HTML(html: """ + + + + + + + """, encoding: .utf8) + + let navigation = try XCTUnwrap(Parser.parsePageNum(doc: document).jumpNavigation) + + XCTAssertEqual(navigation.previousURL?.host, "exhentai.org") + XCTAssertEqual(navigation.nextURL?.host, "exhentai.org") + XCTAssertEqual( + URLComponents(url: try XCTUnwrap(navigation.previousURL), resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "page" })? + .value, + "1" + ) + XCTAssertEqual( + URLComponents(url: try XCTUnwrap(navigation.nextURL), resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "next" })? + .value, + "456" + ) + } + + func testPageJumpNavigationIsPreservedWithNumericPager() throws { + let document = try Kanna.HTML(html: """ + + + + + + + + + +
123
+ + + """, encoding: .utf8) + + let pageNumber = Parser.parsePageNum(doc: document) + let navigation = try XCTUnwrap(pageNumber.jumpNavigation) + + XCTAssertEqual(pageNumber.current, 1) + XCTAssertEqual(pageNumber.maximum, 2) + XCTAssertEqual(navigation.previousURL?.absoluteString, "https://e-hentai.org/?prev=123") + XCTAssertEqual(navigation.nextURL?.absoluteString, "https://e-hentai.org/?next=456") + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() } From 7412ff542e6f457ebb08ed2769c9a6970d025d36 Mon Sep 17 00:00:00 2001 From: kaed3mi Date: Mon, 22 Jun 2026 11:15:46 +0800 Subject: [PATCH 284/614] fix: Resolve date jump CI failures --- EhPanda/View/Support/Components/ToolbarItems.swift | 2 +- .../Tests/Download/DownloadCoordinatorStorageTests.swift | 2 +- EhPandaTests/Tests/Download/DownloadStoreHashTests.swift | 4 +++- EhPandaTests/Tests/Download/DownloadStoreTests.swift | 8 ++++++-- EhPandaTests/Tests/Parser/List/ListParserTests.swift | 2 ++ 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/EhPanda/View/Support/Components/ToolbarItems.swift index 5c64dacdf..0fc56a0c4 100644 --- a/EhPanda/View/Support/Components/ToolbarItems.swift +++ b/EhPanda/View/Support/Components/ToolbarItems.swift @@ -124,7 +124,7 @@ struct DateJumpButton: View { var body: some View { Button(action: action) { - Image(systemName: "calendar") + Image(systemSymbol: .calendar) if !hideText { Text(L10n.Localizable.ToolbarItem.Button.dateJump) } diff --git a/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift b/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift index 20f8b865d..2957d0cd8 100644 --- a/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift @@ -405,7 +405,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { let validation = await manager.validateImageData(gid: "440") - #expect(validation == .missingFiles("Page 1 is missing.")) + #expect(validation == .missingFiles(L10n.Localizable.DownloadStore.Validation.pageMissing(1))) let download = try #require(await manager.fetchDownload(gid: "440")) #expect(download.displayStatus == .error) #expect(download.displayStatus == .error) diff --git a/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift b/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift index 7dff76834..909100b41 100644 --- a/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift @@ -25,7 +25,9 @@ struct DownloadStoreHashTests { #expect( storage.validate(download: download, verifiesContentHashes: true) - == .missingFiles("Page 2 image data is corrupted.") + == .missingFiles( + L10n.Localizable.DownloadStore.Validation.pageImageCorrupted(2) + ) ) } diff --git a/EhPandaTests/Tests/Download/DownloadStoreTests.swift b/EhPandaTests/Tests/Download/DownloadStoreTests.swift index 66a133055..03a47be05 100644 --- a/EhPandaTests/Tests/Download/DownloadStoreTests.swift +++ b/EhPandaTests/Tests/Download/DownloadStoreTests.swift @@ -133,7 +133,9 @@ struct DownloadStoreTests { ) #expect( - storage.validate(download: download, verifiesContentHashes: true) == .missingFiles("Page 2 is missing.") + storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( + L10n.Localizable.DownloadStore.Validation.pageMissing(2) + ) ) } @@ -168,7 +170,9 @@ struct DownloadStoreTests { ) #expect( - storage.validate(download: download, verifiesContentHashes: true) == .missingFiles("Page 1 is missing.") + storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( + L10n.Localizable.DownloadStore.Validation.pageMissing(1) + ) ) #expect( FileManager.default.fileExists( diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index 8093b1359..6a1f8eaba 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -3,8 +3,10 @@ // EhPandaTests // +import Foundation import Kanna import Testing +import XCTest @testable import EhPanda struct ListParserTests: TestHelper { From 59eb7762020e71c87cf75b9f69881d43d5a902e8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 24 Jun 2026 00:06:16 +0800 Subject: [PATCH 285/614] Revert unrelated GitHub Actions workflow changes These CI changes (new build-unsigned-ipa.yml workflow and the Xcode 26.5 -> 26.4.1 downgrade) were bundled into the date-jump PR but are unrelated to the feature. Restore them to the develop baseline. --- .github/workflows/build-unsigned-ipa.yml | 70 ------------------------ .github/workflows/dependencies.yml | 2 +- .github/workflows/deploy-pre-release.yml | 2 +- .github/workflows/test.yml | 2 +- 4 files changed, 3 insertions(+), 73 deletions(-) delete mode 100644 .github/workflows/build-unsigned-ipa.yml diff --git a/.github/workflows/build-unsigned-ipa.yml b/.github/workflows/build-unsigned-ipa.yml deleted file mode 100644 index d0eca20c4..000000000 --- a/.github/workflows/build-unsigned-ipa.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Build Unsigned IPA - -on: - workflow_dispatch: - push: - branches: - - main - -env: - DEVELOPER_DIR: /Applications/Xcode_26.4.1.app - SCHEME_NAME: EhPanda - BUILDS_PATH: /tmp/action-builds - PAYLOAD_PATH: /tmp/action-builds/Payload - ARCHIVE_PATH: /tmp/action-builds/EhPanda.xcarchive - IPA_OUTPUT_PATH: /tmp/action-builds/EhPanda-unsigned.ipa - THIN_PAYLOAD_SCRIPT_PATH: ./actions-tool/thin-payload.sh - -jobs: - build-unsigned-ipa: - runs-on: macos-26 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install iOS 26 Platform - uses: nick-fields/retry@v4 - with: - retry_on: error - max_attempts: 10 - timeout_minutes: 999 - command: xcodebuild -downloadPlatform iOS - - - name: Show Xcode version - run: xcodebuild -version - - - name: Run tests - run: xcodebuild clean test - -skipMacroValidation - -scheme ${{ env.SCHEME_NAME }} - -destination 'platform=iOS Simulator,name=iPhone Air' - - - name: Xcode archive without signing - run: xcodebuild archive - -skipMacroValidation - -scheme ${{ env.SCHEME_NAME }} - -destination 'generic/platform=iOS' - -archivePath ${{ env.ARCHIVE_PATH }} - CODE_SIGN_IDENTITY= - CODE_SIGN_ENTITLEMENTS= - CODE_SIGNING_ALLOWED=NO - CODE_SIGNING_REQUIRED=NO - GCC_OPTIMIZATION_LEVEL=s - SWIFT_OPTIMIZATION_LEVEL=-O - - - name: Export unsigned IPA - run: | - mkdir -p "${{ env.PAYLOAD_PATH }}" - mv "${{ env.ARCHIVE_PATH }}/Products/Applications/${{ env.SCHEME_NAME }}.app" "${{ env.PAYLOAD_PATH }}/${{ env.SCHEME_NAME }}.app" - sh "${{ env.THIN_PAYLOAD_SCRIPT_PATH }}" "${{ env.PAYLOAD_PATH }}/${{ env.SCHEME_NAME }}.app" - pushd "${{ env.BUILDS_PATH }}" - zip -r "${{ env.IPA_OUTPUT_PATH }}" ./Payload - popd - test -f "${{ env.IPA_OUTPUT_PATH }}" - - - name: Upload unsigned IPA - uses: actions/upload-artifact@v5 - with: - name: EhPanda-unsigned-ipa - path: ${{ env.IPA_OUTPUT_PATH }} - if-no-files-found: error diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 35bf94636..247e9c111 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -19,7 +19,7 @@ jobs: with: forceResolution: true failWhenOutdated: false - xcodePath: '/Applications/Xcode_26.4.1.app' + xcodePath: '/Applications/Xcode_26.5.app' - name: Create Pull Request if: steps.resolution.outputs.dependenciesChanged == 'true' uses: peter-evans/create-pull-request@v8 diff --git a/.github/workflows/deploy-pre-release.yml b/.github/workflows/deploy-pre-release.yml index 418309c7f..2bd7cfe8d 100644 --- a/.github/workflows/deploy-pre-release.yml +++ b/.github/workflows/deploy-pre-release.yml @@ -11,7 +11,7 @@ on: required: false type: string env: - DEVELOPER_DIR: /Applications/Xcode_26.4.1.app + DEVELOPER_DIR: /Applications/Xcode_26.5.app SCHEME_NAME: 'EhPanda' ALTSTORE_JSON_PATH: './AltStore.json' BUILDS_PATH: '/tmp/action-builds' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 770cab27a..1bd40ae33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Test on: [push, workflow_dispatch] env: SCHEME_NAME: 'EhPanda' - DEVELOPER_DIR: /Applications/Xcode_26.4.1.app + DEVELOPER_DIR: /Applications/Xcode_26.5.app jobs: Test: runs-on: macos-26 From ea98900d509a9dc8a880704f1243871dc08722b1 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 24 Jun 2026 07:10:09 +0800 Subject: [PATCH 286/614] Rename Date Jump feature to Date Seek MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature drives E-Hentai's date *Seek* navigation (?seek=YYYY-MM-DD), not its relative *Jump* mode, so "Date Jump" was both an awkward calque and a misnomer. Rename the whole feature to Date Seek: - User-facing label "Seek to Date" (en), localized in all 8 languages: zh 日期定位, ja 日付指定, ko 날짜로 이동, de Datum aufsuchen. - Code root DateSeek: DateSeekNavigation, DateSeekDirection, DateSeekView, DateSeekButton, DateSeekGalleriesRequest, parseDateSeekNavigation, present/performDateSeek(+Done) actions, dateSeekDate/dateSeekSheetPresented. - L10n keys date_seek_view.* and toolbar_item.button.date_seek. The existing numeric "Jump page" feature is left untouched (separate rename). --- EhPanda/App/Generated/Strings.swift | 14 +++---- .../Tools/Extensions/AlertKit_Extension.swift | 18 ++++----- EhPanda/App/Tools/Parser/Parser+Misc.swift | 4 +- EhPanda/App/Tools/Parser/Parser+Shared.swift | 4 +- EhPanda/App/de.lproj/Localizable.strings | 14 +++---- EhPanda/App/en.lproj/Localizable.strings | 14 +++---- EhPanda/App/ja.lproj/Localizable.strings | 14 +++---- EhPanda/App/ko.lproj/Localizable.strings | 14 +++---- EhPanda/App/zh-Hans.lproj/Localizable.strings | 14 +++---- .../App/zh-Hant-HK.lproj/Localizable.strings | 14 +++---- .../App/zh-Hant-TW.lproj/Localizable.strings | 14 +++---- EhPanda/App/zh-Hant.lproj/Localizable.strings | 14 +++---- EhPanda/Models/Support/Misc.swift | 8 ++-- EhPanda/Network/Request+Gallery.swift | 2 +- .../Home/Frontpage/FrontpageReducer.swift | 40 +++++++++---------- .../View/Home/Frontpage/FrontpageView.swift | 12 +++--- EhPanda/View/Search/SearchReducer.swift | 40 +++++++++---------- EhPanda/View/Search/SearchView.swift | 12 +++--- .../Support/Components/ToolbarItems.swift | 6 +-- .../Tests/Parser/List/ListParserTests.swift | 16 ++++---- 20 files changed, 144 insertions(+), 144 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 44398b5f2..5716bf3f8 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -2227,22 +2227,22 @@ internal enum L10n { internal static let success = L10n.tr("Localizable", "hud.title.success", fallback: "Success") } } - internal enum DateJumpView { + internal enum DateSeekView { internal enum Button { /// Seek Newer - internal static let seekNewer = L10n.tr("Localizable", "date_jump_view.button.seek_newer", fallback: "Seek Newer") + internal static let seekNewer = L10n.tr("Localizable", "date_seek_view.button.seek_newer", fallback: "Seek Newer") /// Seek Older - internal static let seekOlder = L10n.tr("Localizable", "date_jump_view.button.seek_older", fallback: "Seek Older") + internal static let seekOlder = L10n.tr("Localizable", "date_seek_view.button.seek_older", fallback: "Seek Older") } internal enum Footer { /// Seek to galleries around the selected date. - internal static let seekAroundDate = L10n.tr("Localizable", "date_jump_view.footer.seek_around_date", fallback: "Seek to galleries around the selected date.") + internal static let seekAroundDate = L10n.tr("Localizable", "date_seek_view.footer.seek_around_date", fallback: "Seek to galleries around the selected date.") } internal enum Title { /// Date - internal static let date = L10n.tr("Localizable", "date_jump_view.title.date", fallback: "Date") + internal static let date = L10n.tr("Localizable", "date_seek_view.title.date", fallback: "Date") /// Date Jump - internal static let dateJump = L10n.tr("Localizable", "date_jump_view.title.date_jump", fallback: "Date Jump") + internal static let dateSeek = L10n.tr("Localizable", "date_seek_view.title.date_seek", fallback: "Seek to Date") } } internal enum JumpPageView { @@ -2538,7 +2538,7 @@ internal enum L10n { internal enum ToolbarItem { internal enum Button { /// Date Jump - internal static let dateJump = L10n.tr("Localizable", "toolbar_item.button.date_jump", fallback: "Date Jump") + internal static let dateSeek = L10n.tr("Localizable", "toolbar_item.button.date_seek", fallback: "Seek to Date") /// Filters internal static let filters = L10n.tr("Localizable", "toolbar_item.button.filters", fallback: "Filters") /// Jump page diff --git a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift index ed44b248c..d1607f2fc 100644 --- a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift +++ b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift @@ -78,13 +78,13 @@ private struct JumpPageAlert: View { } } -struct DateJumpView: View { +struct DateSeekView: View { let pageNumber: PageNumber @Binding var selectedDate: Date - let jumpAction: (PageJumpDirection) -> Void + let jumpAction: (DateSeekDirection) -> Void - private var navigation: PageJumpNavigation? { - pageNumber.jumpNavigation + private var navigation: DateSeekNavigation? { + pageNumber.dateSeekNavigation } private var dateRange: ClosedRange { navigation?.dateRange ?? Date.distantPast...Date.distantFuture @@ -101,14 +101,14 @@ struct DateJumpView: View { Form { Section { DatePicker( - L10n.Localizable.DateJumpView.Title.date, + L10n.Localizable.DateSeekView.Title.date, selection: $selectedDate, in: dateRange, displayedComponents: .date ) .datePickerStyle(.graphical) } footer: { - Text(L10n.Localizable.DateJumpView.Footer.seekAroundDate) + Text(L10n.Localizable.DateSeekView.Footer.seekAroundDate) } Section { @@ -116,19 +116,19 @@ struct DateJumpView: View { Button { jumpAction(.newer) } label: { - Label(L10n.Localizable.DateJumpView.Button.seekNewer, systemImage: "chevron.left") + Label(L10n.Localizable.DateSeekView.Button.seekNewer, systemImage: "chevron.left") } } if showsOlderButton { Button { jumpAction(.older) } label: { - Label(L10n.Localizable.DateJumpView.Button.seekOlder, systemImage: "chevron.right") + Label(L10n.Localizable.DateSeekView.Button.seekOlder, systemImage: "chevron.right") } } } } - .navigationTitle(L10n.Localizable.DateJumpView.Title.dateJump) + .navigationTitle(L10n.Localizable.DateSeekView.Title.dateSeek) .navigationBarTitleDisplayMode(.inline) } .onAppear { diff --git a/EhPanda/App/Tools/Parser/Parser+Misc.swift b/EhPanda/App/Tools/Parser/Parser+Misc.swift index b2e395d32..abd5a740a 100644 --- a/EhPanda/App/Tools/Parser/Parser+Misc.swift +++ b/EhPanda/App/Tools/Parser/Parser+Misc.swift @@ -55,12 +55,12 @@ extension Parser { return PageNumber( lastItemTimestamp: timestamp, isNextButtonEnabled: isEnabled, - jumpNavigation: parsePageJumpNavigation(doc: doc) + dateSeekNavigation: parseDateSeekNavigation(doc: doc) ) } else { return PageNumber( isNextButtonEnabled: false, - jumpNavigation: parsePageJumpNavigation(doc: doc) + dateSeekNavigation: parseDateSeekNavigation(doc: doc) ) } } diff --git a/EhPanda/App/Tools/Parser/Parser+Shared.swift b/EhPanda/App/Tools/Parser/Parser+Shared.swift index 3f14b588e..283e90299 100644 --- a/EhPanda/App/Tools/Parser/Parser+Shared.swift +++ b/EhPanda/App/Tools/Parser/Parser+Shared.swift @@ -86,8 +86,8 @@ extension Parser { return try? parseDate(time: value, format: "yyyy-MM-dd") } - static func parsePageJumpNavigation(doc: HTMLDocument) -> PageJumpNavigation? { - let navigation = PageJumpNavigation( + static func parseDateSeekNavigation(doc: HTMLDocument) -> DateSeekNavigation? { + let navigation = DateSeekNavigation( previousURL: parseScriptURL(name: "prevurl", doc: doc), nextURL: parseScriptURL(name: "nexturl", doc: doc), minimumDate: parseScriptDate(name: "mindate", doc: doc), diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 86015f17e..9ef0d79ca 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -51,15 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "Filters"; "toolbar_item.button.jump_page" = "Jump page"; -"toolbar_item.button.date_jump" = "Datumssprung"; +"toolbar_item.button.date_seek" = "Datum aufsuchen"; "toolbar_item.button.quick_search" = "Quick search"; -// MARK: DateJump -"date_jump_view.title.date_jump" = "Datumssprung"; -"date_jump_view.title.date" = "Datum"; -"date_jump_view.footer.seek_around_date" = "Zu Galerien um das ausgewählte Datum springen."; -"date_jump_view.button.seek_newer" = "Zu neueren springen"; -"date_jump_view.button.seek_older" = "Zu älteren springen"; +// MARK: DateSeek +"date_seek_view.title.date_seek" = "Datum aufsuchen"; +"date_seek_view.title.date" = "Datum"; +"date_seek_view.footer.seek_around_date" = "Zu Galerien um das ausgewählte Datum springen."; +"date_seek_view.button.seek_newer" = "Zu neueren springen"; +"date_seek_view.button.seek_older" = "Zu älteren springen"; // MARK: JumpPage "jump_page_view.title.jump_page" = "Jump page"; "jump_page_view.button.confirm" = "Confirm"; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 03191db52..00530b47e 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -55,15 +55,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "Filters"; "toolbar_item.button.jump_page" = "Jump page"; -"toolbar_item.button.date_jump" = "Date Jump"; +"toolbar_item.button.date_seek" = "Seek to Date"; "toolbar_item.button.quick_search" = "Quick search"; -// MARK: DateJump -"date_jump_view.title.date_jump" = "Date Jump"; -"date_jump_view.title.date" = "Date"; -"date_jump_view.footer.seek_around_date" = "Seek to galleries around the selected date."; -"date_jump_view.button.seek_newer" = "Seek Newer"; -"date_jump_view.button.seek_older" = "Seek Older"; +// MARK: DateSeek +"date_seek_view.title.date_seek" = "Seek to Date"; +"date_seek_view.title.date" = "Date"; +"date_seek_view.footer.seek_around_date" = "Seek to galleries around the selected date."; +"date_seek_view.button.seek_newer" = "Seek Newer"; +"date_seek_view.button.seek_older" = "Seek Older"; // MARK: JumpPage "jump_page_view.title.jump_page" = "Jump page"; "jump_page_view.button.confirm" = "Confirm"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 6358627d2..0137fc8cf 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -51,15 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "フィルター"; "toolbar_item.button.jump_page" = "ページジャンプ"; -"toolbar_item.button.date_jump" = "日付ジャンプ"; +"toolbar_item.button.date_seek" = "日付指定"; "toolbar_item.button.quick_search" = "クイック検索"; -// MARK: DateJump -"date_jump_view.title.date_jump" = "日付ジャンプ"; -"date_jump_view.title.date" = "日付"; -"date_jump_view.footer.seek_around_date" = "選択した日付付近のギャラリーへ移動します。"; -"date_jump_view.button.seek_newer" = "新しい方へ移動"; -"date_jump_view.button.seek_older" = "古い方へ移動"; +// MARK: DateSeek +"date_seek_view.title.date_seek" = "日付指定"; +"date_seek_view.title.date" = "日付"; +"date_seek_view.footer.seek_around_date" = "選択した日付付近のギャラリーへ移動します。"; +"date_seek_view.button.seek_newer" = "新しい方へ移動"; +"date_seek_view.button.seek_older" = "古い方へ移動"; // MARK: JumpPage "jump_page_view.title.jump_page" = "ページジャンプ"; "jump_page_view.button.confirm" = "確認"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 2c240d958..99f3c52dd 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -52,18 +52,18 @@ "toolbar_item.button.filters" = "필터"; "toolbar_item.button.jump_page" = "페이지 이동"; "toolbar_item.button.quick_search" = "빠른 검색"; -"toolbar_item.button.date_jump" = "날짜 이동"; +"toolbar_item.button.date_seek" = "날짜로 이동"; // MARK: JumpPage "jump_page_view.title.jump_page" = "페이지 이동"; "jump_page_view.button.confirm" = "확인"; -// MARK: DateJump -"date_jump_view.title.date_jump" = "날짜 이동"; -"date_jump_view.title.date" = "날짜"; -"date_jump_view.footer.seek_around_date" = "선택한 날짜 근처의 갤러리로 이동합니다."; -"date_jump_view.button.seek_newer" = "새 항목으로 이동"; -"date_jump_view.button.seek_older" = "오래된 항목으로 이동"; +// MARK: DateSeek +"date_seek_view.title.date_seek" = "날짜로 이동"; +"date_seek_view.title.date" = "날짜"; +"date_seek_view.footer.seek_around_date" = "선택한 날짜 근처의 갤러리로 이동합니다."; +"date_seek_view.button.seek_newer" = "새 항목으로 이동"; +"date_seek_view.button.seek_older" = "오래된 항목으로 이동"; // MARK: AlertView "loading_view.title.loading" = "로딩 중..."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index a8f473996..038149fcc 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -55,15 +55,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "筛选"; "toolbar_item.button.jump_page" = "页码跳转"; -"toolbar_item.button.date_jump" = "日期跳转"; +"toolbar_item.button.date_seek" = "日期定位"; "toolbar_item.button.quick_search" = "快速搜索"; -// MARK: DateJump -"date_jump_view.title.date_jump" = "日期跳转"; -"date_jump_view.title.date" = "日期"; -"date_jump_view.footer.seek_around_date" = "跳转到所选日期附近的画廊。"; -"date_jump_view.button.seek_newer" = "跳到较新"; -"date_jump_view.button.seek_older" = "跳到较旧"; +// MARK: DateSeek +"date_seek_view.title.date_seek" = "日期定位"; +"date_seek_view.title.date" = "日期"; +"date_seek_view.footer.seek_around_date" = "跳转到所选日期附近的画廊。"; +"date_seek_view.button.seek_newer" = "跳到较新"; +"date_seek_view.button.seek_older" = "跳到较旧"; // MARK: JumpPage "jump_page_view.title.jump_page" = "页码跳转"; "jump_page_view.button.confirm" = "确认"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 91bfd08e1..b83051bcc 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -51,15 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "過濾"; "toolbar_item.button.jump_page" = "跳到..."; -"toolbar_item.button.date_jump" = "日期跳轉"; +"toolbar_item.button.date_seek" = "日期定位"; "toolbar_item.button.quick_search" = "快速搜尋"; -// MARK: DateJump -"date_jump_view.title.date_jump" = "日期跳轉"; -"date_jump_view.title.date" = "日期"; -"date_jump_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; -"date_jump_view.button.seek_newer" = "跳到較新"; -"date_jump_view.button.seek_older" = "跳到較舊"; +// MARK: DateSeek +"date_seek_view.title.date_seek" = "日期定位"; +"date_seek_view.title.date" = "日期"; +"date_seek_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; +"date_seek_view.button.seek_newer" = "跳到較新"; +"date_seek_view.button.seek_older" = "跳到較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 091567fcc..5909fd9a2 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -51,15 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "過濾"; "toolbar_item.button.jump_page" = "跳到..."; -"toolbar_item.button.date_jump" = "日期跳轉"; +"toolbar_item.button.date_seek" = "日期定位"; "toolbar_item.button.quick_search" = "快速搜尋"; -// MARK: DateJump -"date_jump_view.title.date_jump" = "日期跳轉"; -"date_jump_view.title.date" = "日期"; -"date_jump_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; -"date_jump_view.button.seek_newer" = "跳到較新"; -"date_jump_view.button.seek_older" = "跳到較舊"; +// MARK: DateSeek +"date_seek_view.title.date_seek" = "日期定位"; +"date_seek_view.title.date" = "日期"; +"date_seek_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; +"date_seek_view.button.seek_newer" = "跳到較新"; +"date_seek_view.button.seek_older" = "跳到較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index fe1758872..7ac122205 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -51,15 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "過濾"; "toolbar_item.button.jump_page" = "跳到..."; -"toolbar_item.button.date_jump" = "日期跳轉"; +"toolbar_item.button.date_seek" = "日期定位"; "toolbar_item.button.quick_search" = "快速搜尋"; -// MARK: DateJump -"date_jump_view.title.date_jump" = "日期跳轉"; -"date_jump_view.title.date" = "日期"; -"date_jump_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; -"date_jump_view.button.seek_newer" = "跳到較新"; -"date_jump_view.button.seek_older" = "跳到較舊"; +// MARK: DateSeek +"date_seek_view.title.date_seek" = "日期定位"; +"date_seek_view.title.date" = "日期"; +"date_seek_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; +"date_seek_view.button.seek_newer" = "跳到較新"; +"date_seek_view.button.seek_older" = "跳到較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift index 6c51b005b..9aef4928b 100644 --- a/EhPanda/Models/Support/Misc.swift +++ b/EhPanda/Models/Support/Misc.swift @@ -10,12 +10,12 @@ import SwiftyBeaver typealias Logger = SwiftyBeaver typealias FavoritesSortOrder = EhSetting.FavoritesSortOrder -enum PageJumpDirection: Equatable { +enum DateSeekDirection: Equatable { case newer case older } -struct PageJumpNavigation: Equatable { +struct DateSeekNavigation: Equatable { var previousURL: URL? var nextURL: URL? var minimumDate: Date? @@ -38,7 +38,7 @@ struct PageJumpNavigation: Equatable { return date } - func seekURL(date: Date, direction: PageJumpDirection) -> URL? { + func seekURL(date: Date, direction: DateSeekDirection) -> URL? { let baseURL: URL? switch direction { case .newer: @@ -77,7 +77,7 @@ struct PageNumber: Equatable { var maximum = 0 var lastItemTimestamp: String? var isNextButtonEnabled = false - var jumpNavigation: PageJumpNavigation? + var dateSeekNavigation: DateSeekNavigation? var isSinglePage: Bool { current == 0 && maximum == 0 diff --git a/EhPanda/Network/Request+Gallery.swift b/EhPanda/Network/Request+Gallery.swift index 2baca54e0..ce793d2c9 100644 --- a/EhPanda/Network/Request+Gallery.swift +++ b/EhPanda/Network/Request+Gallery.swift @@ -49,7 +49,7 @@ struct MoreSearchGalleriesRequest: Request { } } -struct JumpGalleriesRequest: Request { +struct DateSeekGalleriesRequest: Request { let url: URL var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift index d67805026..a5ef5b5ba 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift @@ -15,7 +15,7 @@ struct FrontpageReducer { } private enum CancelID: CaseIterable { - case fetchGalleries, fetchMoreGalleries, fetchJumpGalleries + case fetchGalleries, fetchMoreGalleries, fetchDateSeekGalleries } @ObservableState @@ -29,8 +29,8 @@ struct FrontpageReducer { } var galleries = [Gallery]() var pageNumber = PageNumber() - var dateJumpDate = Date() - var dateJumpSheetPresented = false + var dateSeekDate = Date() + var dateSeekSheetPresented = false var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle @@ -60,9 +60,9 @@ struct FrontpageReducer { case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case presentDateJump - case jumpToDate(PageJumpDirection) - case jumpToDateDone(Result<(PageNumber, [Gallery]), AppError>) + case presentDateSeek + case performDateSeek(DateSeekDirection) + case performDateSeekDone(Result<(PageNumber, [Gallery]), AppError>) case filters(FiltersReducer.Action) case detail(DetailReducer.Action) @@ -158,32 +158,32 @@ struct FrontpageReducer { } return .none - case .presentDateJump: - guard let navigation = state.pageNumber.jumpNavigation, navigation.isEnabled else { + case .presentDateSeek: + guard let navigation = state.pageNumber.dateSeekNavigation, navigation.isEnabled else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } - state.dateJumpDate = navigation.clampedDate(state.dateJumpDate) - state.dateJumpSheetPresented = true + state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) + state.dateSeekSheetPresented = true return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) - case .jumpToDate(let direction): + case .performDateSeek(let direction): guard state.loadingState != .loading, - let url = state.pageNumber.jumpNavigation?.seekURL( - date: state.dateJumpDate, direction: direction + let url = state.pageNumber.dateSeekNavigation?.seekURL( + date: state.dateSeekDate, direction: direction ) else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } - state.dateJumpSheetPresented = false + state.dateSeekSheetPresented = false state.loadingState = .loading state.footerLoadingState = .idle state.pageNumber.resetPages() return .run { send in - let response = await JumpGalleriesRequest(url: url).response() - await send(.jumpToDateDone(response)) + let response = await DateSeekGalleriesRequest(url: url).response() + await send(.performDateSeekDone(response)) } - .cancellable(id: CancelID.fetchJumpGalleries) + .cancellable(id: CancelID.fetchDateSeekGalleries) - case .jumpToDateDone(let result): + case .performDateSeekDone(let result): state.loadingState = .idle switch result { case .success(let (pageNumber, galleries)): @@ -193,8 +193,8 @@ struct FrontpageReducer { return .send(.fetchMoreGalleries) } state.pageNumber = pageNumber - if let navigation = pageNumber.jumpNavigation { - state.dateJumpDate = navigation.clampedDate(state.dateJumpDate) + if let navigation = pageNumber.dateSeekNavigation { + state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) } state.galleries = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index b681d80ba..141b3abbf 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -44,11 +44,11 @@ struct FrontpageView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .sheet(isPresented: $store.dateJumpSheetPresented) { - DateJumpView( + .sheet(isPresented: $store.dateSeekSheetPresented) { + DateSeekView( pageNumber: store.pageNumber, - selectedDate: $store.dateJumpDate, - jumpAction: { store.send(.jumpToDate($0)) } + selectedDate: $store.dateSeekDate, + jumpAction: { store.send(.performDateSeek($0)) } ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) @@ -95,8 +95,8 @@ struct FrontpageView: View { } private func toolbar() -> some ToolbarContent { CustomToolbarItem { - DateJumpButton(pageNumber: store.pageNumber, hideText: true) { - store.send(.presentDateJump) + DateSeekButton(pageNumber: store.pageNumber, hideText: true) { + store.send(.presentDateSeek) } FiltersButton(hideText: true) { store.send(.setNavigation(.filters())) diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index cf5f15cae..1e0581456 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -16,7 +16,7 @@ struct SearchReducer { } private enum CancelID: CaseIterable { - case fetchGalleries, fetchMoreGalleries, observeDownloads, fetchJumpGalleries + case fetchGalleries, fetchMoreGalleries, observeDownloads, fetchDateSeekGalleries } @ObservableState @@ -27,8 +27,8 @@ struct SearchReducer { var galleries = [Gallery]() var pageNumber = PageNumber() - var dateJumpDate = Date() - var dateJumpSheetPresented = false + var dateSeekDate = Date() + var dateSeekSheetPresented = false var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle var downloadBadges = [String: DownloadBadge]() @@ -63,9 +63,9 @@ struct SearchReducer { case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case observeDownloads case observeDownloadsDone([DownloadedGallery]) - case presentDateJump - case jumpToDate(PageJumpDirection) - case jumpToDateDone(Result<(PageNumber, [Gallery]), AppError>) + case presentDateSeek + case performDateSeek(DateSeekDirection) + case performDateSeekDone(Result<(PageNumber, [Gallery]), AppError>) case detail(DetailReducer.Action) case filters(FiltersReducer.Action) @@ -197,32 +197,32 @@ struct SearchReducer { ) return .none - case .presentDateJump: - guard let navigation = state.pageNumber.jumpNavigation, navigation.isEnabled else { + case .presentDateSeek: + guard let navigation = state.pageNumber.dateSeekNavigation, navigation.isEnabled else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } - state.dateJumpDate = navigation.clampedDate(state.dateJumpDate) - state.dateJumpSheetPresented = true + state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) + state.dateSeekSheetPresented = true return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) - case .jumpToDate(let direction): + case .performDateSeek(let direction): guard state.loadingState != .loading, - let url = state.pageNumber.jumpNavigation?.seekURL( - date: state.dateJumpDate, direction: direction + let url = state.pageNumber.dateSeekNavigation?.seekURL( + date: state.dateSeekDate, direction: direction ) else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } - state.dateJumpSheetPresented = false + state.dateSeekSheetPresented = false state.loadingState = .loading state.footerLoadingState = .idle state.pageNumber.resetPages() return .run { send in - let response = await JumpGalleriesRequest(url: url).response() - await send(.jumpToDateDone(response)) + let response = await DateSeekGalleriesRequest(url: url).response() + await send(.performDateSeekDone(response)) } - .cancellable(id: CancelID.fetchJumpGalleries) + .cancellable(id: CancelID.fetchDateSeekGalleries) - case .jumpToDateDone(let result): + case .performDateSeekDone(let result): state.loadingState = .idle switch result { case .success(let (pageNumber, galleries)): @@ -232,8 +232,8 @@ struct SearchReducer { return .send(.fetchMoreGalleries) } state.pageNumber = pageNumber - if let navigation = pageNumber.jumpNavigation { - state.dateJumpDate = navigation.clampedDate(state.dateJumpDate) + if let navigation = pageNumber.dateSeekNavigation { + state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) } state.galleries = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index efad18b3b..9d8b46d37 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -56,11 +56,11 @@ struct SearchView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } - .sheet(isPresented: $store.dateJumpSheetPresented) { - DateJumpView( + .sheet(isPresented: $store.dateSeekSheetPresented) { + DateSeekView( pageNumber: store.pageNumber, - selectedDate: $store.dateJumpDate, - jumpAction: { store.send(.jumpToDate($0)) } + selectedDate: $store.dateSeekDate, + jumpAction: { store.send(.performDateSeek($0)) } ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) @@ -118,8 +118,8 @@ struct SearchView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { ToolbarFeaturesMenu { - DateJumpButton(pageNumber: store.pageNumber) { - store.send(.presentDateJump) + DateSeekButton(pageNumber: store.pageNumber) { + store.send(.presentDateSeek) } FiltersButton { store.send(.setNavigation(.filters())) diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/EhPanda/View/Support/Components/ToolbarItems.swift index 0fc56a0c4..8748c734a 100644 --- a/EhPanda/View/Support/Components/ToolbarItems.swift +++ b/EhPanda/View/Support/Components/ToolbarItems.swift @@ -111,7 +111,7 @@ struct JumpPageButton: View { } } -struct DateJumpButton: View { +struct DateSeekButton: View { private let pageNumber: PageNumber private let hideText: Bool private let action: () -> Void @@ -126,10 +126,10 @@ struct DateJumpButton: View { Button(action: action) { Image(systemSymbol: .calendar) if !hideText { - Text(L10n.Localizable.ToolbarItem.Button.dateJump) + Text(L10n.Localizable.ToolbarItem.Button.dateSeek) } } - .disabled(pageNumber.jumpNavigation?.isEnabled != true) + .disabled(pageNumber.dateSeekNavigation?.isEnabled != true) } } diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index 6a1f8eaba..dbe0918fc 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -27,10 +27,10 @@ struct ListParserTests: TestHelper { } } - func testPageJumpNavigation() throws { + func testDateSeekNavigation() throws { let document = try htmlDocument(filename: .frontPageMinimalList) let pageNumber = Parser.parsePageNum(doc: document) - let navigation = try XCTUnwrap(pageNumber.jumpNavigation) + let navigation = try XCTUnwrap(pageNumber.dateSeekNavigation) XCTAssertTrue(pageNumber.hasNextPage()) XCTAssertEqual(pageNumber.lastItemTimestamp, "2668517") @@ -40,10 +40,10 @@ struct ListParserTests: TestHelper { XCTAssertEqual(Self.dateFormatter.string(from: try XCTUnwrap(navigation.maximumDate)), "2023-09-08") } - func testPageJumpSeekURL() throws { + func testDateSeekURL() throws { let document = try htmlDocument(filename: .frontPageMinimalList) let pageNumber = Parser.parsePageNum(doc: document) - let navigation = try XCTUnwrap(pageNumber.jumpNavigation) + let navigation = try XCTUnwrap(pageNumber.dateSeekNavigation) let maximumDate = try XCTUnwrap(navigation.maximumDate) let url = try XCTUnwrap(navigation.seekURL(date: maximumDate, direction: .older)) let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems @@ -53,7 +53,7 @@ struct ListParserTests: TestHelper { XCTAssertNil(navigation.seekURL(date: maximumDate, direction: .newer)) } - func testPageJumpNavigationNormalizesExHentaiHost() throws { + func testDateSeekNavigationNormalizesExHentaiHost() throws { let originalHost: String? = UserDefaultsUtil.value(forKey: .galleryHost) UserDefaults.standard.set(GalleryHost.exhentai.rawValue, forKey: AppUserDefaults.galleryHost.rawValue) defer { @@ -78,7 +78,7 @@ struct ListParserTests: TestHelper { """, encoding: .utf8) - let navigation = try XCTUnwrap(Parser.parsePageNum(doc: document).jumpNavigation) + let navigation = try XCTUnwrap(Parser.parsePageNum(doc: document).dateSeekNavigation) XCTAssertEqual(navigation.previousURL?.host, "exhentai.org") XCTAssertEqual(navigation.nextURL?.host, "exhentai.org") @@ -98,7 +98,7 @@ struct ListParserTests: TestHelper { ) } - func testPageJumpNavigationIsPreservedWithNumericPager() throws { + func testDateSeekNavigationIsPreservedWithNumericPager() throws { let document = try Kanna.HTML(html: """ @@ -120,7 +120,7 @@ struct ListParserTests: TestHelper { """, encoding: .utf8) let pageNumber = Parser.parsePageNum(doc: document) - let navigation = try XCTUnwrap(pageNumber.jumpNavigation) + let navigation = try XCTUnwrap(pageNumber.dateSeekNavigation) XCTAssertEqual(pageNumber.current, 1) XCTAssertEqual(pageNumber.maximum, 2) From 16e96d1ab269d2b55d106d1a797a2b6917d3f47f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 24 Jun 2026 07:13:46 +0800 Subject: [PATCH 287/614] Run the Date Seek parser tests under Swift Testing The four parser tests were plain methods in a Swift Testing suite with no @Test annotation, using XCTest assertions. A non-XCTestCase struct is not discovered by XCTest, and Swift Testing only runs @Test members, so these tests never executed. Annotate them with @Test and convert XCTAssert*/XCTUnwrap to #expect/#require; drop the unused import XCTest. --- .../Tests/Parser/List/ListParserTests.swift | 65 ++++++++++--------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index dbe0918fc..df914c46c 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -6,7 +6,6 @@ import Foundation import Kanna import Testing -import XCTest @testable import EhPanda struct ListParserTests: TestHelper { @@ -27,32 +26,37 @@ struct ListParserTests: TestHelper { } } + @Test func testDateSeekNavigation() throws { let document = try htmlDocument(filename: .frontPageMinimalList) let pageNumber = Parser.parsePageNum(doc: document) - let navigation = try XCTUnwrap(pageNumber.dateSeekNavigation) + let navigation = try #require(pageNumber.dateSeekNavigation) + let minimumDate = try #require(navigation.minimumDate) + let maximumDate = try #require(navigation.maximumDate) - XCTAssertTrue(pageNumber.hasNextPage()) - XCTAssertEqual(pageNumber.lastItemTimestamp, "2668517") - XCTAssertNil(navigation.previousURL) - XCTAssertEqual(navigation.nextURL?.absoluteString, "https://e-hentai.org/?next=2668517") - XCTAssertEqual(Self.dateFormatter.string(from: try XCTUnwrap(navigation.minimumDate)), "2007-03-20") - XCTAssertEqual(Self.dateFormatter.string(from: try XCTUnwrap(navigation.maximumDate)), "2023-09-08") + #expect(pageNumber.hasNextPage()) + #expect(pageNumber.lastItemTimestamp == "2668517") + #expect(navigation.previousURL == nil) + #expect(navigation.nextURL?.absoluteString == "https://e-hentai.org/?next=2668517") + #expect(Self.dateFormatter.string(from: minimumDate) == "2007-03-20") + #expect(Self.dateFormatter.string(from: maximumDate) == "2023-09-08") } + @Test func testDateSeekURL() throws { let document = try htmlDocument(filename: .frontPageMinimalList) let pageNumber = Parser.parsePageNum(doc: document) - let navigation = try XCTUnwrap(pageNumber.dateSeekNavigation) - let maximumDate = try XCTUnwrap(navigation.maximumDate) - let url = try XCTUnwrap(navigation.seekURL(date: maximumDate, direction: .older)) + let navigation = try #require(pageNumber.dateSeekNavigation) + let maximumDate = try #require(navigation.maximumDate) + let url = try #require(navigation.seekURL(date: maximumDate, direction: .older)) let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems - XCTAssertEqual(queryItems?.first(where: { $0.name == "next" })?.value, "2668517") - XCTAssertEqual(queryItems?.first(where: { $0.name == "seek" })?.value, "2023-09-08") - XCTAssertNil(navigation.seekURL(date: maximumDate, direction: .newer)) + #expect(queryItems?.first(where: { $0.name == "next" })?.value == "2668517") + #expect(queryItems?.first(where: { $0.name == "seek" })?.value == "2023-09-08") + #expect(navigation.seekURL(date: maximumDate, direction: .newer) == nil) } + @Test func testDateSeekNavigationNormalizesExHentaiHost() throws { let originalHost: String? = UserDefaultsUtil.value(forKey: .galleryHost) UserDefaults.standard.set(GalleryHost.exhentai.rawValue, forKey: AppUserDefaults.galleryHost.rawValue) @@ -78,26 +82,27 @@ struct ListParserTests: TestHelper { """, encoding: .utf8) - let navigation = try XCTUnwrap(Parser.parsePageNum(doc: document).dateSeekNavigation) + let navigation = try #require(Parser.parsePageNum(doc: document).dateSeekNavigation) + let previousURL = try #require(navigation.previousURL) + let nextURL = try #require(navigation.nextURL) - XCTAssertEqual(navigation.previousURL?.host, "exhentai.org") - XCTAssertEqual(navigation.nextURL?.host, "exhentai.org") - XCTAssertEqual( - URLComponents(url: try XCTUnwrap(navigation.previousURL), resolvingAgainstBaseURL: false)? + #expect(previousURL.host == "exhentai.org") + #expect(nextURL.host == "exhentai.org") + #expect( + URLComponents(url: previousURL, resolvingAgainstBaseURL: false)? .queryItems? .first(where: { $0.name == "page" })? - .value, - "1" + .value == "1" ) - XCTAssertEqual( - URLComponents(url: try XCTUnwrap(navigation.nextURL), resolvingAgainstBaseURL: false)? + #expect( + URLComponents(url: nextURL, resolvingAgainstBaseURL: false)? .queryItems? .first(where: { $0.name == "next" })? - .value, - "456" + .value == "456" ) } + @Test func testDateSeekNavigationIsPreservedWithNumericPager() throws { let document = try Kanna.HTML(html: """ @@ -120,12 +125,12 @@ struct ListParserTests: TestHelper { """, encoding: .utf8) let pageNumber = Parser.parsePageNum(doc: document) - let navigation = try XCTUnwrap(pageNumber.dateSeekNavigation) + let navigation = try #require(pageNumber.dateSeekNavigation) - XCTAssertEqual(pageNumber.current, 1) - XCTAssertEqual(pageNumber.maximum, 2) - XCTAssertEqual(navigation.previousURL?.absoluteString, "https://e-hentai.org/?prev=123") - XCTAssertEqual(navigation.nextURL?.absoluteString, "https://e-hentai.org/?next=456") + #expect(pageNumber.current == 1) + #expect(pageNumber.maximum == 2) + #expect(navigation.previousURL?.absoluteString == "https://e-hentai.org/?prev=123") + #expect(navigation.nextURL?.absoluteString == "https://e-hentai.org/?next=456") } private static let dateFormatter: DateFormatter = { From 0879802c4f11a2fcba48d8541a1c0cd067f3384b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 24 Jun 2026 07:15:51 +0800 Subject: [PATCH 288/614] Drop the unreachable empty-result fallback in date seek On an empty Seek result, performDateSeekDone set .failed(.notFound) and then conditionally sent .fetchMoreGalleries. But performDateSeek already reset state.pageNumber and the empty branch never reassigns it to the response's pager, so fetchMoreGalleries always no-ops (its hasNextPage() guard reads the reset state.pageNumber). Report not-found directly. Applies to both the Frontpage and Search reducers. --- EhPanda/View/Home/Frontpage/FrontpageReducer.swift | 3 +-- EhPanda/View/Search/SearchReducer.swift | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift index a5ef5b5ba..c129441b0 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift @@ -189,8 +189,7 @@ struct FrontpageReducer { case .success(let (pageNumber, galleries)): guard !galleries.isEmpty else { state.loadingState = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } - return .send(.fetchMoreGalleries) + return .none } state.pageNumber = pageNumber if let navigation = pageNumber.dateSeekNavigation { diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index 1e0581456..6be56063b 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -228,8 +228,7 @@ struct SearchReducer { case .success(let (pageNumber, galleries)): guard !galleries.isEmpty else { state.loadingState = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } - return .send(.fetchMoreGalleries) + return .none } state.pageNumber = pageNumber if let navigation = pageNumber.dateSeekNavigation { From 6bd1183efbd9eea494ce45d0e1cbd33f0cc3e580 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 24 Jun 2026 07:17:44 +0800 Subject: [PATCH 289/614] Share the seek-date formatter between model and tests ListParserTests redefined the exact yyyy-MM-dd / UTC / POSIX DateFormatter already held by DateSeekNavigation. Expose the model's formatter as the canonical seek-date formatter and use it from the tests, dropping the duplicate definition. --- EhPanda/Models/Support/Misc.swift | 3 ++- EhPandaTests/Tests/Parser/List/ListParserTests.swift | 12 ++---------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift index 9aef4928b..f5cb71b00 100644 --- a/EhPanda/Models/Support/Misc.swift +++ b/EhPanda/Models/Support/Misc.swift @@ -49,7 +49,8 @@ struct DateSeekNavigation: Equatable { return baseURL?.appending(queryItems: ["seek": Self.dateFormatter.string(from: date)]) } - private static let dateFormatter: DateFormatter = { + /// Formatter for the `seek` query parameter: fixed `yyyy-MM-dd`, UTC, POSIX locale. + static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" formatter.timeZone = TimeZone(secondsFromGMT: 0) diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index df914c46c..1e4041a02 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -38,8 +38,8 @@ struct ListParserTests: TestHelper { #expect(pageNumber.lastItemTimestamp == "2668517") #expect(navigation.previousURL == nil) #expect(navigation.nextURL?.absoluteString == "https://e-hentai.org/?next=2668517") - #expect(Self.dateFormatter.string(from: minimumDate) == "2007-03-20") - #expect(Self.dateFormatter.string(from: maximumDate) == "2023-09-08") + #expect(DateSeekNavigation.dateFormatter.string(from: minimumDate) == "2007-03-20") + #expect(DateSeekNavigation.dateFormatter.string(from: maximumDate) == "2023-09-08") } @Test @@ -132,12 +132,4 @@ struct ListParserTests: TestHelper { #expect(navigation.previousURL?.absoluteString == "https://e-hentai.org/?prev=123") #expect(navigation.nextURL?.absoluteString == "https://e-hentai.org/?next=456") } - - private static let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.locale = Locale(identifier: "en_US_POSIX") - return formatter - }() } From 7d046e9b3c52f25eda0bbf727338f011eca0c6c7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 24 Jun 2026 08:07:03 +0800 Subject: [PATCH 290/614] Inject the gallery host into the list parser instead of reading global state parseScriptURL read Defaults.URL.host (global UserDefaults via AppUtil.galleryHost) to normalize the date-seek script URLs, so parsing depended on the user's current host. Under Swift Testing's parallel execution that let tests pollute each other through UserDefaults.standard (the ExHentai host test bled into testDateSeekNavigation). Thread an explicit host through parseScriptURL / parseDateSeekNavigation / parsePageNum; parsePageNum keeps a Defaults.URL.host default so production callers are unchanged. The parser tests now inject the host directly, dropping the UserDefaults mutation entirely. --- EhPanda/App/Tools/Parser/Parser+Misc.swift | 9 ++++++--- EhPanda/App/Tools/Parser/Parser+Shared.swift | 10 +++++----- .../Tests/Parser/List/ListParserTests.swift | 19 +++++-------------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/EhPanda/App/Tools/Parser/Parser+Misc.swift b/EhPanda/App/Tools/Parser/Parser+Misc.swift index abd5a740a..349554878 100644 --- a/EhPanda/App/Tools/Parser/Parser+Misc.swift +++ b/EhPanda/App/Tools/Parser/Parser+Misc.swift @@ -27,7 +27,10 @@ extension Parser { return apikey } - static func parsePageNum(doc: HTMLDocument) -> PageNumber { + /// Parses the gallery-list pager. `host` normalizes the date-seek script URLs to the user's + /// gallery host; it defaults to the current host but is injectable so parsing stays deterministic + /// (independent of global state) in tests. + static func parsePageNum(doc: HTMLDocument, host: URL = Defaults.URL.host) -> PageNumber { var current = 0 var maximum = 0 @@ -55,12 +58,12 @@ extension Parser { return PageNumber( lastItemTimestamp: timestamp, isNextButtonEnabled: isEnabled, - dateSeekNavigation: parseDateSeekNavigation(doc: doc) + dateSeekNavigation: parseDateSeekNavigation(doc: doc, host: host) ) } else { return PageNumber( isNextButtonEnabled: false, - dateSeekNavigation: parseDateSeekNavigation(doc: doc) + dateSeekNavigation: parseDateSeekNavigation(doc: doc, host: host) ) } } diff --git a/EhPanda/App/Tools/Parser/Parser+Shared.swift b/EhPanda/App/Tools/Parser/Parser+Shared.swift index 283e90299..481be6c52 100644 --- a/EhPanda/App/Tools/Parser/Parser+Shared.swift +++ b/EhPanda/App/Tools/Parser/Parser+Shared.swift @@ -43,7 +43,7 @@ extension Parser { return nil } - static func parseScriptURL(name: String, doc: HTMLDocument) -> URL? { + static func parseScriptURL(name: String, doc: HTMLDocument, host: URL) -> URL? { guard var value = parseScriptVariable(name: name, doc: doc)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } @@ -51,7 +51,7 @@ extension Parser { .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "\\u0026", with: "&") - let baseURL = Defaults.URL.host + let baseURL = host let parsedURL: URL? if let url = URL(string: value), url.scheme != nil { parsedURL = url @@ -86,10 +86,10 @@ extension Parser { return try? parseDate(time: value, format: "yyyy-MM-dd") } - static func parseDateSeekNavigation(doc: HTMLDocument) -> DateSeekNavigation? { + static func parseDateSeekNavigation(doc: HTMLDocument, host: URL) -> DateSeekNavigation? { let navigation = DateSeekNavigation( - previousURL: parseScriptURL(name: "prevurl", doc: doc), - nextURL: parseScriptURL(name: "nexturl", doc: doc), + previousURL: parseScriptURL(name: "prevurl", doc: doc, host: host), + nextURL: parseScriptURL(name: "nexturl", doc: doc, host: host), minimumDate: parseScriptDate(name: "mindate", doc: doc), maximumDate: parseScriptDate(name: "maxdate", doc: doc) ) diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index 1e4041a02..2f61a290a 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -29,7 +29,7 @@ struct ListParserTests: TestHelper { @Test func testDateSeekNavigation() throws { let document = try htmlDocument(filename: .frontPageMinimalList) - let pageNumber = Parser.parsePageNum(doc: document) + let pageNumber = Parser.parsePageNum(doc: document, host: Defaults.URL.ehentai) let navigation = try #require(pageNumber.dateSeekNavigation) let minimumDate = try #require(navigation.minimumDate) let maximumDate = try #require(navigation.maximumDate) @@ -45,7 +45,7 @@ struct ListParserTests: TestHelper { @Test func testDateSeekURL() throws { let document = try htmlDocument(filename: .frontPageMinimalList) - let pageNumber = Parser.parsePageNum(doc: document) + let pageNumber = Parser.parsePageNum(doc: document, host: Defaults.URL.ehentai) let navigation = try #require(pageNumber.dateSeekNavigation) let maximumDate = try #require(navigation.maximumDate) let url = try #require(navigation.seekURL(date: maximumDate, direction: .older)) @@ -58,16 +58,6 @@ struct ListParserTests: TestHelper { @Test func testDateSeekNavigationNormalizesExHentaiHost() throws { - let originalHost: String? = UserDefaultsUtil.value(forKey: .galleryHost) - UserDefaults.standard.set(GalleryHost.exhentai.rawValue, forKey: AppUserDefaults.galleryHost.rawValue) - defer { - if let originalHost { - UserDefaults.standard.set(originalHost, forKey: AppUserDefaults.galleryHost.rawValue) - } else { - UserDefaults.standard.removeObject(forKey: AppUserDefaults.galleryHost.rawValue) - } - } - let document = try Kanna.HTML(html: """ @@ -82,7 +72,8 @@ struct ListParserTests: TestHelper { """, encoding: .utf8) - let navigation = try #require(Parser.parsePageNum(doc: document).dateSeekNavigation) + let pageNumber = Parser.parsePageNum(doc: document, host: Defaults.URL.exhentai) + let navigation = try #require(pageNumber.dateSeekNavigation) let previousURL = try #require(navigation.previousURL) let nextURL = try #require(navigation.nextURL) @@ -124,7 +115,7 @@ struct ListParserTests: TestHelper { """, encoding: .utf8) - let pageNumber = Parser.parsePageNum(doc: document) + let pageNumber = Parser.parsePageNum(doc: document, host: Defaults.URL.ehentai) let navigation = try #require(pageNumber.dateSeekNavigation) #expect(pageNumber.current == 1) From d76e7f27b622707706b51b3971105fae9f795d13 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 24 Jun 2026 08:14:16 +0800 Subject: [PATCH 291/614] Parse date-seek navigation on every pager path parsePageNum attached dateSeekNavigation only on the two no-numeric-pager paths; when a ptt numeric pager was present it returned early without it, so the Seek-to-Date button was disabled on numeric-paged lists even when the page exposed the jumpbar. Parse it once up front and pass it on all three return paths. --- EhPanda/App/Tools/Parser/Parser+Misc.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/EhPanda/App/Tools/Parser/Parser+Misc.swift b/EhPanda/App/Tools/Parser/Parser+Misc.swift index 349554878..d621b4441 100644 --- a/EhPanda/App/Tools/Parser/Parser+Misc.swift +++ b/EhPanda/App/Tools/Parser/Parser+Misc.swift @@ -31,6 +31,7 @@ extension Parser { /// gallery host; it defaults to the current host but is injectable so parsing stays deterministic /// (independent of global state) in tests. static func parsePageNum(doc: HTMLDocument, host: URL = Defaults.URL.host) -> PageNumber { + let dateSeekNavigation = parseDateSeekNavigation(doc: doc, host: host) var current = 0 var maximum = 0 @@ -58,12 +59,12 @@ extension Parser { return PageNumber( lastItemTimestamp: timestamp, isNextButtonEnabled: isEnabled, - dateSeekNavigation: parseDateSeekNavigation(doc: doc, host: host) + dateSeekNavigation: dateSeekNavigation ) } else { return PageNumber( isNextButtonEnabled: false, - dateSeekNavigation: parseDateSeekNavigation(doc: doc, host: host) + dateSeekNavigation: dateSeekNavigation ) } } @@ -78,6 +79,6 @@ extension Parser { maximum = num - 1 } } - return PageNumber(current: current, maximum: maximum) + return PageNumber(current: current, maximum: maximum, dateSeekNavigation: dateSeekNavigation) } } From 8ba35a0da2cecc9ecb7a9b0eda8adfd8949877df Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 24 Jun 2026 08:14:16 +0800 Subject: [PATCH 292/614] Regenerate Strings.swift after the Date Seek rename The rename hand-edited the generated file, leaving the DateSeekView enum in its old (DateJumpView) alphabetical slot and a stale '/// Date Jump' doc comment. Run it through SwiftGen so the committed output matches the build's output (canonical ordering, refreshed comments) and the file no longer dirties on every build. --- EhPanda/App/Generated/Strings.swift | 38 ++++++++++++++--------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 5716bf3f8..fe8895e16 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -433,6 +433,24 @@ internal enum L10n { internal static let reset = L10n.tr("Localizable", "confirmation_dialog.title.reset", fallback: "Are you sure to reset?") } } + internal enum DateSeekView { + internal enum Button { + /// Seek Newer + internal static let seekNewer = L10n.tr("Localizable", "date_seek_view.button.seek_newer", fallback: "Seek Newer") + /// Seek Older + internal static let seekOlder = L10n.tr("Localizable", "date_seek_view.button.seek_older", fallback: "Seek Older") + } + internal enum Footer { + /// Seek to galleries around the selected date. + internal static let seekAroundDate = L10n.tr("Localizable", "date_seek_view.footer.seek_around_date", fallback: "Seek to galleries around the selected date.") + } + internal enum Title { + /// Date + internal static let date = L10n.tr("Localizable", "date_seek_view.title.date", fallback: "Date") + /// Seek to Date + internal static let dateSeek = L10n.tr("Localizable", "date_seek_view.title.date_seek", fallback: "Seek to Date") + } + } internal enum DetailView { internal enum Accessibility { internal enum DownloadButton { @@ -2227,24 +2245,6 @@ internal enum L10n { internal static let success = L10n.tr("Localizable", "hud.title.success", fallback: "Success") } } - internal enum DateSeekView { - internal enum Button { - /// Seek Newer - internal static let seekNewer = L10n.tr("Localizable", "date_seek_view.button.seek_newer", fallback: "Seek Newer") - /// Seek Older - internal static let seekOlder = L10n.tr("Localizable", "date_seek_view.button.seek_older", fallback: "Seek Older") - } - internal enum Footer { - /// Seek to galleries around the selected date. - internal static let seekAroundDate = L10n.tr("Localizable", "date_seek_view.footer.seek_around_date", fallback: "Seek to galleries around the selected date.") - } - internal enum Title { - /// Date - internal static let date = L10n.tr("Localizable", "date_seek_view.title.date", fallback: "Date") - /// Date Jump - internal static let dateSeek = L10n.tr("Localizable", "date_seek_view.title.date_seek", fallback: "Seek to Date") - } - } internal enum JumpPageView { internal enum Button { /// Confirm @@ -2537,7 +2537,7 @@ internal enum L10n { } internal enum ToolbarItem { internal enum Button { - /// Date Jump + /// Seek to Date internal static let dateSeek = L10n.tr("Localizable", "toolbar_item.button.date_seek", fallback: "Seek to Date") /// Filters internal static let filters = L10n.tr("Localizable", "toolbar_item.button.filters", fallback: "Filters") From f13ffef2ee8eb090ad401422cdd1b7a4b7888a4d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 25 Jun 2026 17:08:14 +0800 Subject: [PATCH 293/614] Naturalize the Date Seek direction-button and footer wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Chinese 跳到/跳转 calque with 前往, trim the ja/ko/de buttons to short direction forms (新しい方へ / 새로운 쪽으로 / Neuere · Ältere), and align the German footer to aufsuchen (matching the 'Datum aufsuchen' title). English and Chinese keep their concise verb form; en is unchanged, so the generated Strings.swift needs no update. Validated with plutil -lint. --- EhPanda/App/de.lproj/Localizable.strings | 6 +++--- EhPanda/App/ja.lproj/Localizable.strings | 4 ++-- EhPanda/App/ko.lproj/Localizable.strings | 4 ++-- EhPanda/App/zh-Hans.lproj/Localizable.strings | 6 +++--- EhPanda/App/zh-Hant-HK.lproj/Localizable.strings | 6 +++--- EhPanda/App/zh-Hant-TW.lproj/Localizable.strings | 6 +++--- EhPanda/App/zh-Hant.lproj/Localizable.strings | 6 +++--- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 9ef0d79ca..0b52cd94a 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -57,9 +57,9 @@ // MARK: DateSeek "date_seek_view.title.date_seek" = "Datum aufsuchen"; "date_seek_view.title.date" = "Datum"; -"date_seek_view.footer.seek_around_date" = "Zu Galerien um das ausgewählte Datum springen."; -"date_seek_view.button.seek_newer" = "Zu neueren springen"; -"date_seek_view.button.seek_older" = "Zu älteren springen"; +"date_seek_view.footer.seek_around_date" = "Galerien rund um das gewählte Datum aufsuchen."; +"date_seek_view.button.seek_newer" = "Neuere"; +"date_seek_view.button.seek_older" = "Ältere"; // MARK: JumpPage "jump_page_view.title.jump_page" = "Jump page"; "jump_page_view.button.confirm" = "Confirm"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 0137fc8cf..b4ffde6a1 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -58,8 +58,8 @@ "date_seek_view.title.date_seek" = "日付指定"; "date_seek_view.title.date" = "日付"; "date_seek_view.footer.seek_around_date" = "選択した日付付近のギャラリーへ移動します。"; -"date_seek_view.button.seek_newer" = "新しい方へ移動"; -"date_seek_view.button.seek_older" = "古い方へ移動"; +"date_seek_view.button.seek_newer" = "新しい方へ"; +"date_seek_view.button.seek_older" = "古い方へ"; // MARK: JumpPage "jump_page_view.title.jump_page" = "ページジャンプ"; "jump_page_view.button.confirm" = "確認"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 99f3c52dd..8398b9f8c 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -62,8 +62,8 @@ "date_seek_view.title.date_seek" = "날짜로 이동"; "date_seek_view.title.date" = "날짜"; "date_seek_view.footer.seek_around_date" = "선택한 날짜 근처의 갤러리로 이동합니다."; -"date_seek_view.button.seek_newer" = "새 항목으로 이동"; -"date_seek_view.button.seek_older" = "오래된 항목으로 이동"; +"date_seek_view.button.seek_newer" = "새로운 쪽으로"; +"date_seek_view.button.seek_older" = "오래된 쪽으로"; // MARK: AlertView "loading_view.title.loading" = "로딩 중..."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 038149fcc..99cd00bc4 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -61,9 +61,9 @@ // MARK: DateSeek "date_seek_view.title.date_seek" = "日期定位"; "date_seek_view.title.date" = "日期"; -"date_seek_view.footer.seek_around_date" = "跳转到所选日期附近的画廊。"; -"date_seek_view.button.seek_newer" = "跳到较新"; -"date_seek_view.button.seek_older" = "跳到较旧"; +"date_seek_view.footer.seek_around_date" = "前往所选日期附近的画廊。"; +"date_seek_view.button.seek_newer" = "前往较新"; +"date_seek_view.button.seek_older" = "前往较旧"; // MARK: JumpPage "jump_page_view.title.jump_page" = "页码跳转"; "jump_page_view.button.confirm" = "确认"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index b83051bcc..bc65be39a 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -57,9 +57,9 @@ // MARK: DateSeek "date_seek_view.title.date_seek" = "日期定位"; "date_seek_view.title.date" = "日期"; -"date_seek_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; -"date_seek_view.button.seek_newer" = "跳到較新"; -"date_seek_view.button.seek_older" = "跳到較舊"; +"date_seek_view.footer.seek_around_date" = "前往所選日期附近的畫廊。"; +"date_seek_view.button.seek_newer" = "前往較新"; +"date_seek_view.button.seek_older" = "前往較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 5909fd9a2..5e393c963 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -57,9 +57,9 @@ // MARK: DateSeek "date_seek_view.title.date_seek" = "日期定位"; "date_seek_view.title.date" = "日期"; -"date_seek_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; -"date_seek_view.button.seek_newer" = "跳到較新"; -"date_seek_view.button.seek_older" = "跳到較舊"; +"date_seek_view.footer.seek_around_date" = "前往所選日期附近的畫廊。"; +"date_seek_view.button.seek_newer" = "前往較新"; +"date_seek_view.button.seek_older" = "前往較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 7ac122205..f1416750c 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -57,9 +57,9 @@ // MARK: DateSeek "date_seek_view.title.date_seek" = "日期定位"; "date_seek_view.title.date" = "日期"; -"date_seek_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; -"date_seek_view.button.seek_newer" = "跳到較新"; -"date_seek_view.button.seek_older" = "跳到較舊"; +"date_seek_view.footer.seek_around_date" = "前往所選日期附近的畫廊。"; +"date_seek_view.button.seek_newer" = "前往較新"; +"date_seek_view.button.seek_older" = "前往較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; From 25d8664a5ecf02e1d1fa55c9024a17d638037437 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 26 Jun 2026 09:00:23 +0800 Subject: [PATCH 294/614] Extend Date Seek to the Watched and Favorites lists --- EhPanda/View/Favorites/FavoritesReducer.swift | 48 +++++++++++++++++ EhPanda/View/Favorites/FavoritesView.swift | 12 +++++ .../View/Home/Watched/WatchedReducer.swift | 52 ++++++++++++++++++- EhPanda/View/Home/Watched/WatchedView.swift | 12 +++++ 4 files changed, 123 insertions(+), 1 deletion(-) diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift index 5ee3c12c5..fd3a3ad98 100644 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -26,6 +26,8 @@ struct FavoritesReducer { var index = -1 var sortOrder: FavoritesSortOrder? + var dateSeekDate = Date() + var dateSeekSheetPresented = false var rawGalleries = [Int: [Gallery]]() var rawPageNumber = [Int: PageNumber]() @@ -76,6 +78,9 @@ struct FavoritesReducer { case fetchMoreGalleriesDone(Int, Result) case observeDownloads case observeDownloadsDone([DownloadedGallery]) + case presentDateSeek + case performDateSeek(DateSeekDirection) + case performDateSeekDone(Int, Result<(PageNumber, [Gallery]), AppError>) case detail(DetailReducer.Action) case quickSearch(QuickSearchReducer.Action) @@ -212,6 +217,49 @@ struct FavoritesReducer { ) return .none + case .presentDateSeek: + guard let navigation = state.pageNumber?.dateSeekNavigation, navigation.isEnabled else { + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + } + state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) + state.dateSeekSheetPresented = true + return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) + + case .performDateSeek(let direction): + guard state.loadingState != .loading, + let url = state.pageNumber?.dateSeekNavigation?.seekURL( + date: state.dateSeekDate, direction: direction + ) + else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } + + state.dateSeekSheetPresented = false + state.rawLoadingState[state.index] = .loading + state.rawFooterLoadingState[state.index] = .idle + state.rawPageNumber[state.index]?.resetPages() + return .run { [index = state.index] send in + let response = await DateSeekGalleriesRequest(url: url).response() + await send(.performDateSeekDone(index, response)) + } + + case .performDateSeekDone(let targetFavIndex, let result): + state.rawLoadingState[targetFavIndex] = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + state.rawLoadingState[targetFavIndex] = .failed(.notFound) + return .none + } + state.rawPageNumber[targetFavIndex] = pageNumber + if let navigation = pageNumber.dateSeekNavigation { + state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) + } + state.rawGalleries[targetFavIndex] = galleries + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + case .failure(let error): + state.rawLoadingState[targetFavIndex] = .failed(error) + } + return .none + case .detail: return .none diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index 6554db7bb..1136444e8 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -63,6 +63,15 @@ struct FavoritesView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } + .sheet(isPresented: $store.dateSeekSheetPresented) { + DateSeekView( + pageNumber: store.pageNumber ?? .init(), + selectedDate: $store.dateSeekDate, + jumpAction: { store.send(.performDateSeek($0)) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( @@ -126,6 +135,9 @@ struct FavoritesView: View { store.send(.fetchGalleries(nil, order)) } } + DateSeekButton(pageNumber: store.pageNumber ?? .init(), hideText: true) { + store.send(.presentDateSeek) + } QuickSearchButton(hideText: true) { store.send(.setNavigation(.quickSearch())) } diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift index 7a3984a26..44bdf0a59 100644 --- a/EhPanda/View/Home/Watched/WatchedReducer.swift +++ b/EhPanda/View/Home/Watched/WatchedReducer.swift @@ -4,6 +4,7 @@ // import ComposableArchitecture +import Foundation @Reducer struct WatchedReducer { @@ -15,7 +16,7 @@ struct WatchedReducer { } private enum CancelID: CaseIterable { - case fetchGalleries, fetchMoreGalleries, observeDownloads + case fetchGalleries, fetchMoreGalleries, observeDownloads, fetchDateSeekGalleries } @ObservableState @@ -25,6 +26,8 @@ struct WatchedReducer { var galleries = [Gallery]() var pageNumber = PageNumber() + var dateSeekDate = Date() + var dateSeekSheetPresented = false var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle var downloadBadges = [String: DownloadBadge]() @@ -60,6 +63,9 @@ struct WatchedReducer { case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case observeDownloads case observeDownloadsDone([DownloadedGallery]) + case presentDateSeek + case performDateSeek(DateSeekDirection) + case performDateSeekDone(Result<(PageNumber, [Gallery]), AppError>) case filters(FiltersReducer.Action) case detail(DetailReducer.Action) @@ -187,6 +193,50 @@ struct WatchedReducer { ) return .none + case .presentDateSeek: + guard let navigation = state.pageNumber.dateSeekNavigation, navigation.isEnabled else { + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + } + state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) + state.dateSeekSheetPresented = true + return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) + + case .performDateSeek(let direction): + guard state.loadingState != .loading, + let url = state.pageNumber.dateSeekNavigation?.seekURL( + date: state.dateSeekDate, direction: direction + ) + else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } + + state.dateSeekSheetPresented = false + state.loadingState = .loading + state.footerLoadingState = .idle + state.pageNumber.resetPages() + return .run { send in + let response = await DateSeekGalleriesRequest(url: url).response() + await send(.performDateSeekDone(response)) + } + .cancellable(id: CancelID.fetchDateSeekGalleries) + + case .performDateSeekDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + return .none + } + state.pageNumber = pageNumber + if let navigation = pageNumber.dateSeekNavigation { + state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) + } + state.galleries = galleries + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + case .quickSearch: return .none diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift index b2824d7cc..0b8213363 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -60,6 +60,15 @@ struct WatchedView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } + .sheet(isPresented: $store.dateSeekSheetPresented) { + DateSeekView( + pageNumber: store.pageNumber, + selectedDate: $store.dateSeekDate, + jumpAction: { store.send(.performDateSeek($0)) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( @@ -113,6 +122,9 @@ struct WatchedView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { ToolbarFeaturesMenu { + DateSeekButton(pageNumber: store.pageNumber) { + store.send(.presentDateSeek) + } FiltersButton { store.send(.setNavigation(.filters())) } From 6191e6ecac9b3a642aab3af9d8996d6d351d7b5d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 26 Jun 2026 09:01:10 +0800 Subject: [PATCH 295/614] Lowercase the English "Seek to date" label --- EhPanda/App/Generated/Strings.swift | 8 ++++---- EhPanda/App/en.lproj/Localizable.strings | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index fe8895e16..0d9af7797 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -447,8 +447,8 @@ internal enum L10n { internal enum Title { /// Date internal static let date = L10n.tr("Localizable", "date_seek_view.title.date", fallback: "Date") - /// Seek to Date - internal static let dateSeek = L10n.tr("Localizable", "date_seek_view.title.date_seek", fallback: "Seek to Date") + /// Seek to date + internal static let dateSeek = L10n.tr("Localizable", "date_seek_view.title.date_seek", fallback: "Seek to date") } } internal enum DetailView { @@ -2537,8 +2537,8 @@ internal enum L10n { } internal enum ToolbarItem { internal enum Button { - /// Seek to Date - internal static let dateSeek = L10n.tr("Localizable", "toolbar_item.button.date_seek", fallback: "Seek to Date") + /// Seek to date + internal static let dateSeek = L10n.tr("Localizable", "toolbar_item.button.date_seek", fallback: "Seek to date") /// Filters internal static let filters = L10n.tr("Localizable", "toolbar_item.button.filters", fallback: "Filters") /// Jump page diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 00530b47e..f0c250a9f 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -55,11 +55,11 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "Filters"; "toolbar_item.button.jump_page" = "Jump page"; -"toolbar_item.button.date_seek" = "Seek to Date"; +"toolbar_item.button.date_seek" = "Seek to date"; "toolbar_item.button.quick_search" = "Quick search"; // MARK: DateSeek -"date_seek_view.title.date_seek" = "Seek to Date"; +"date_seek_view.title.date_seek" = "Seek to date"; "date_seek_view.title.date" = "Date"; "date_seek_view.footer.seek_around_date" = "Seek to galleries around the selected date."; "date_seek_view.button.seek_newer" = "Seek Newer"; From 84e4c9fe80d2fdaae331d9971479e71e3090a362 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 26 Jun 2026 09:27:59 +0800 Subject: [PATCH 296/614] Extract Date Seek into a reusable DateSeekReducer The date-seek picker state and trigger logic were duplicated across the Frontpage, Search, Watched, and Favorites reducers. Extract them into a headless, embeddable DateSeekReducer that owns the picker UI state, validates/clamps the date, and resolves a seek URL handed back via delegate(.performSeek). Each host embeds it with Scope, keeps its navigation synced from pageNumber, and performs the request itself since the gallery list and loading state belong to the host. Rename DateSeekView to the store-agnostic DateSeekPickerView and move it to Support/Components. --- .../Tools/Extensions/AlertKit_Extension.swift | 61 ---------------- EhPanda/View/Favorites/FavoritesReducer.swift | 35 ++++------ EhPanda/View/Favorites/FavoritesView.swift | 12 ++-- .../Home/Frontpage/FrontpageReducer.swift | 35 ++++------ .../View/Home/Frontpage/FrontpageView.swift | 12 ++-- .../View/Home/Watched/WatchedReducer.swift | 36 ++++------ EhPanda/View/Home/Watched/WatchedView.swift | 12 ++-- EhPanda/View/Search/SearchReducer.swift | 35 ++++------ EhPanda/View/Search/SearchView.swift | 12 ++-- .../Components/DateSeekPickerView.swift | 69 +++++++++++++++++++ EhPanda/View/Support/DateSeekReducer.swift | 66 ++++++++++++++++++ 11 files changed, 207 insertions(+), 178 deletions(-) create mode 100644 EhPanda/View/Support/Components/DateSeekPickerView.swift create mode 100644 EhPanda/View/Support/DateSeekReducer.swift diff --git a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift index d1607f2fc..16574b855 100644 --- a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift +++ b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift @@ -77,64 +77,3 @@ private struct JumpPageAlert: View { .synchronize($isPresented, $manager.isPresented) } } - -struct DateSeekView: View { - let pageNumber: PageNumber - @Binding var selectedDate: Date - let jumpAction: (DateSeekDirection) -> Void - - private var navigation: DateSeekNavigation? { - pageNumber.dateSeekNavigation - } - private var dateRange: ClosedRange { - navigation?.dateRange ?? Date.distantPast...Date.distantFuture - } - private var showsNewerButton: Bool { - navigation?.previousURL != nil - } - private var showsOlderButton: Bool { - navigation?.nextURL != nil - } - - var body: some View { - NavigationView { - Form { - Section { - DatePicker( - L10n.Localizable.DateSeekView.Title.date, - selection: $selectedDate, - in: dateRange, - displayedComponents: .date - ) - .datePickerStyle(.graphical) - } footer: { - Text(L10n.Localizable.DateSeekView.Footer.seekAroundDate) - } - - Section { - if showsNewerButton { - Button { - jumpAction(.newer) - } label: { - Label(L10n.Localizable.DateSeekView.Button.seekNewer, systemImage: "chevron.left") - } - } - if showsOlderButton { - Button { - jumpAction(.older) - } label: { - Label(L10n.Localizable.DateSeekView.Button.seekOlder, systemImage: "chevron.right") - } - } - } - } - .navigationTitle(L10n.Localizable.DateSeekView.Title.dateSeek) - .navigationBarTitleDisplayMode(.inline) - } - .onAppear { - if let navigation { - selectedDate = navigation.clampedDate(selectedDate) - } - } - } -} diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift index fd3a3ad98..bb347df07 100644 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -26,8 +26,6 @@ struct FavoritesReducer { var index = -1 var sortOrder: FavoritesSortOrder? - var dateSeekDate = Date() - var dateSeekSheetPresented = false var rawGalleries = [Int: [Gallery]]() var rawPageNumber = [Int: PageNumber]() @@ -48,6 +46,7 @@ struct FavoritesReducer { rawFooterLoadingState[index] } + var dateSeek = DateSeekReducer.State() var detailState: Heap var quickSearchState = QuickSearchReducer.State() @@ -78,10 +77,9 @@ struct FavoritesReducer { case fetchMoreGalleriesDone(Int, Result) case observeDownloads case observeDownloadsDone([DownloadedGallery]) - case presentDateSeek - case performDateSeek(DateSeekDirection) case performDateSeekDone(Int, Result<(PageNumber, [Gallery]), AppError>) + case dateSeek(DateSeekReducer.Action) case detail(DetailReducer.Action) case quickSearch(QuickSearchReducer.Action) } @@ -217,22 +215,8 @@ struct FavoritesReducer { ) return .none - case .presentDateSeek: - guard let navigation = state.pageNumber?.dateSeekNavigation, navigation.isEnabled else { - return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - } - state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) - state.dateSeekSheetPresented = true - return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) - - case .performDateSeek(let direction): - guard state.loadingState != .loading, - let url = state.pageNumber?.dateSeekNavigation?.seekURL( - date: state.dateSeekDate, direction: direction - ) - else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } - - state.dateSeekSheetPresented = false + case .dateSeek(.delegate(.performSeek(let url))): + guard state.loadingState != .loading else { return .none } state.rawLoadingState[state.index] = .loading state.rawFooterLoadingState[state.index] = .idle state.rawPageNumber[state.index]?.resetPages() @@ -250,9 +234,6 @@ struct FavoritesReducer { return .none } state.rawPageNumber[targetFavIndex] = pageNumber - if let navigation = pageNumber.dateSeekNavigation { - state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) - } state.rawGalleries[targetFavIndex] = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): @@ -260,6 +241,9 @@ struct FavoritesReducer { } return .none + case .dateSeek: + return .none + case .detail: return .none @@ -267,12 +251,17 @@ struct FavoritesReducer { return .none } } + .onChange(of: \.pageNumber) { _, state in + state.dateSeek.navigation = state.pageNumber?.dateSeekNavigation + return .none + } .haptics( unwrapping: \.route, case: \.quickSearch, hapticsClient: hapticsClient ) + Scope(state: \.dateSeek, action: \.dateSeek, child: DateSeekReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) } diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index 1136444e8..b50925a13 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -63,11 +63,11 @@ struct FavoritesView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(isPresented: $store.dateSeekSheetPresented) { - DateSeekView( - pageNumber: store.pageNumber ?? .init(), - selectedDate: $store.dateSeekDate, - jumpAction: { store.send(.performDateSeek($0)) } + .sheet(isPresented: $store.dateSeek.sheetPresented) { + DateSeekPickerView( + navigation: store.dateSeek.navigation, + selectedDate: $store.dateSeek.date, + seekAction: { store.send(.dateSeek(.performSeek($0))) } ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) @@ -136,7 +136,7 @@ struct FavoritesView: View { } } DateSeekButton(pageNumber: store.pageNumber ?? .init(), hideText: true) { - store.send(.presentDateSeek) + store.send(.dateSeek(.present)) } QuickSearchButton(hideText: true) { store.send(.setNavigation(.quickSearch())) diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift index c129441b0..e65165bae 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift @@ -29,11 +29,10 @@ struct FrontpageReducer { } var galleries = [Gallery]() var pageNumber = PageNumber() - var dateSeekDate = Date() - var dateSeekSheetPresented = false var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle + var dateSeek = DateSeekReducer.State() var filtersState = FiltersReducer.State() var detailState: Heap @@ -60,10 +59,9 @@ struct FrontpageReducer { case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case presentDateSeek - case performDateSeek(DateSeekDirection) case performDateSeekDone(Result<(PageNumber, [Gallery]), AppError>) + case dateSeek(DateSeekReducer.Action) case filters(FiltersReducer.Action) case detail(DetailReducer.Action) } @@ -158,22 +156,8 @@ struct FrontpageReducer { } return .none - case .presentDateSeek: - guard let navigation = state.pageNumber.dateSeekNavigation, navigation.isEnabled else { - return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - } - state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) - state.dateSeekSheetPresented = true - return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) - - case .performDateSeek(let direction): - guard state.loadingState != .loading, - let url = state.pageNumber.dateSeekNavigation?.seekURL( - date: state.dateSeekDate, direction: direction - ) - else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } - - state.dateSeekSheetPresented = false + case .dateSeek(.delegate(.performSeek(let url))): + guard state.loadingState != .loading else { return .none } state.loadingState = .loading state.footerLoadingState = .idle state.pageNumber.resetPages() @@ -192,9 +176,6 @@ struct FrontpageReducer { return .none } state.pageNumber = pageNumber - if let navigation = pageNumber.dateSeekNavigation { - state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) - } state.galleries = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): @@ -202,6 +183,9 @@ struct FrontpageReducer { } return .none + case .dateSeek: + return .none + case .filters: return .none @@ -209,12 +193,17 @@ struct FrontpageReducer { return .none } } + .onChange(of: \.pageNumber) { _, state in + state.dateSeek.navigation = state.pageNumber.dateSeekNavigation + return .none + } .haptics( unwrapping: \.route, case: \.filters, hapticsClient: hapticsClient ) + Scope(state: \.dateSeek, action: \.dateSeek, child: DateSeekReducer.init) Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index 141b3abbf..546b1476d 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -44,11 +44,11 @@ struct FrontpageView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .sheet(isPresented: $store.dateSeekSheetPresented) { - DateSeekView( - pageNumber: store.pageNumber, - selectedDate: $store.dateSeekDate, - jumpAction: { store.send(.performDateSeek($0)) } + .sheet(isPresented: $store.dateSeek.sheetPresented) { + DateSeekPickerView( + navigation: store.dateSeek.navigation, + selectedDate: $store.dateSeek.date, + seekAction: { store.send(.dateSeek(.performSeek($0))) } ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) @@ -96,7 +96,7 @@ struct FrontpageView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { DateSeekButton(pageNumber: store.pageNumber, hideText: true) { - store.send(.presentDateSeek) + store.send(.dateSeek(.present)) } FiltersButton(hideText: true) { store.send(.setNavigation(.filters())) diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift index 44bdf0a59..0195cf241 100644 --- a/EhPanda/View/Home/Watched/WatchedReducer.swift +++ b/EhPanda/View/Home/Watched/WatchedReducer.swift @@ -4,7 +4,6 @@ // import ComposableArchitecture -import Foundation @Reducer struct WatchedReducer { @@ -26,12 +25,11 @@ struct WatchedReducer { var galleries = [Gallery]() var pageNumber = PageNumber() - var dateSeekDate = Date() - var dateSeekSheetPresented = false var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle var downloadBadges = [String: DownloadBadge]() + var dateSeek = DateSeekReducer.State() var filtersState = FiltersReducer.State() var quickSearchState = QuickSearchReducer.State() var detailState: Heap @@ -63,10 +61,9 @@ struct WatchedReducer { case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case observeDownloads case observeDownloadsDone([DownloadedGallery]) - case presentDateSeek - case performDateSeek(DateSeekDirection) case performDateSeekDone(Result<(PageNumber, [Gallery]), AppError>) + case dateSeek(DateSeekReducer.Action) case filters(FiltersReducer.Action) case detail(DetailReducer.Action) case quickSearch(QuickSearchReducer.Action) @@ -193,22 +190,8 @@ struct WatchedReducer { ) return .none - case .presentDateSeek: - guard let navigation = state.pageNumber.dateSeekNavigation, navigation.isEnabled else { - return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - } - state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) - state.dateSeekSheetPresented = true - return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) - - case .performDateSeek(let direction): - guard state.loadingState != .loading, - let url = state.pageNumber.dateSeekNavigation?.seekURL( - date: state.dateSeekDate, direction: direction - ) - else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } - - state.dateSeekSheetPresented = false + case .dateSeek(.delegate(.performSeek(let url))): + guard state.loadingState != .loading else { return .none } state.loadingState = .loading state.footerLoadingState = .idle state.pageNumber.resetPages() @@ -227,9 +210,6 @@ struct WatchedReducer { return .none } state.pageNumber = pageNumber - if let navigation = pageNumber.dateSeekNavigation { - state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) - } state.galleries = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): @@ -237,6 +217,9 @@ struct WatchedReducer { } return .none + case .dateSeek: + return .none + case .quickSearch: return .none @@ -247,6 +230,10 @@ struct WatchedReducer { return .none } } + .onChange(of: \.pageNumber) { _, state in + state.dateSeek.navigation = state.pageNumber.dateSeekNavigation + return .none + } .haptics( unwrapping: \.route, case: \.quickSearch, @@ -258,6 +245,7 @@ struct WatchedReducer { hapticsClient: hapticsClient ) + Scope(state: \.dateSeek, action: \.dateSeek, child: DateSeekReducer.init) Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift index 0b8213363..ef5002353 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -60,11 +60,11 @@ struct WatchedView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .sheet(isPresented: $store.dateSeekSheetPresented) { - DateSeekView( - pageNumber: store.pageNumber, - selectedDate: $store.dateSeekDate, - jumpAction: { store.send(.performDateSeek($0)) } + .sheet(isPresented: $store.dateSeek.sheetPresented) { + DateSeekPickerView( + navigation: store.dateSeek.navigation, + selectedDate: $store.dateSeek.date, + seekAction: { store.send(.dateSeek(.performSeek($0))) } ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) @@ -123,7 +123,7 @@ struct WatchedView: View { CustomToolbarItem { ToolbarFeaturesMenu { DateSeekButton(pageNumber: store.pageNumber) { - store.send(.presentDateSeek) + store.send(.dateSeek(.present)) } FiltersButton { store.send(.setNavigation(.filters())) diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index 6be56063b..25df3f8fb 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -27,12 +27,11 @@ struct SearchReducer { var galleries = [Gallery]() var pageNumber = PageNumber() - var dateSeekDate = Date() - var dateSeekSheetPresented = false var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle var downloadBadges = [String: DownloadBadge]() + var dateSeek = DateSeekReducer.State() var filtersState = FiltersReducer.State() var detailState: Heap var quickSearchState = QuickSearchReducer.State() @@ -63,10 +62,9 @@ struct SearchReducer { case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case observeDownloads case observeDownloadsDone([DownloadedGallery]) - case presentDateSeek - case performDateSeek(DateSeekDirection) case performDateSeekDone(Result<(PageNumber, [Gallery]), AppError>) + case dateSeek(DateSeekReducer.Action) case detail(DetailReducer.Action) case filters(FiltersReducer.Action) case quickSearch(QuickSearchReducer.Action) @@ -197,22 +195,8 @@ struct SearchReducer { ) return .none - case .presentDateSeek: - guard let navigation = state.pageNumber.dateSeekNavigation, navigation.isEnabled else { - return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - } - state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) - state.dateSeekSheetPresented = true - return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) - - case .performDateSeek(let direction): - guard state.loadingState != .loading, - let url = state.pageNumber.dateSeekNavigation?.seekURL( - date: state.dateSeekDate, direction: direction - ) - else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } - - state.dateSeekSheetPresented = false + case .dateSeek(.delegate(.performSeek(let url))): + guard state.loadingState != .loading else { return .none } state.loadingState = .loading state.footerLoadingState = .idle state.pageNumber.resetPages() @@ -231,9 +215,6 @@ struct SearchReducer { return .none } state.pageNumber = pageNumber - if let navigation = pageNumber.dateSeekNavigation { - state.dateSeekDate = navigation.clampedDate(state.dateSeekDate) - } state.galleries = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): @@ -241,6 +222,9 @@ struct SearchReducer { } return .none + case .dateSeek: + return .none + case .detail: return .none @@ -251,6 +235,10 @@ struct SearchReducer { return .none } } + .onChange(of: \.pageNumber) { _, state in + state.dateSeek.navigation = state.pageNumber.dateSeekNavigation + return .none + } .haptics( unwrapping: \.route, case: \.quickSearch, @@ -262,6 +250,7 @@ struct SearchReducer { hapticsClient: hapticsClient ) + Scope(state: \.dateSeek, action: \.dateSeek, child: DateSeekReducer.init) Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index 9d8b46d37..9b1256ec3 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -56,11 +56,11 @@ struct SearchView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } - .sheet(isPresented: $store.dateSeekSheetPresented) { - DateSeekView( - pageNumber: store.pageNumber, - selectedDate: $store.dateSeekDate, - jumpAction: { store.send(.performDateSeek($0)) } + .sheet(isPresented: $store.dateSeek.sheetPresented) { + DateSeekPickerView( + navigation: store.dateSeek.navigation, + selectedDate: $store.dateSeek.date, + seekAction: { store.send(.dateSeek(.performSeek($0))) } ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) @@ -119,7 +119,7 @@ struct SearchView: View { CustomToolbarItem { ToolbarFeaturesMenu { DateSeekButton(pageNumber: store.pageNumber) { - store.send(.presentDateSeek) + store.send(.dateSeek(.present)) } FiltersButton { store.send(.setNavigation(.filters())) diff --git a/EhPanda/View/Support/Components/DateSeekPickerView.swift b/EhPanda/View/Support/Components/DateSeekPickerView.swift new file mode 100644 index 000000000..0fd6f9b29 --- /dev/null +++ b/EhPanda/View/Support/Components/DateSeekPickerView.swift @@ -0,0 +1,69 @@ +// +// DateSeekPickerView.swift +// EhPanda +// + +import SwiftUI + +/// The "Seek to date" sheet content: a graphical date picker plus newer/older direction buttons. +/// +/// This is a store-agnostic, reusable component — it is driven entirely by the values passed in, +/// not by a dedicated reducer. Hosts typically wire it to an embedded `DateSeekReducer`, but it +/// has no dependency on one. +struct DateSeekPickerView: View { + let navigation: DateSeekNavigation? + @Binding var selectedDate: Date + let seekAction: (DateSeekDirection) -> Void + + private var dateRange: ClosedRange { + navigation?.dateRange ?? Date.distantPast...Date.distantFuture + } + private var showsNewerButton: Bool { + navigation?.previousURL != nil + } + private var showsOlderButton: Bool { + navigation?.nextURL != nil + } + + var body: some View { + NavigationView { + Form { + Section { + DatePicker( + L10n.Localizable.DateSeekView.Title.date, + selection: $selectedDate, + in: dateRange, + displayedComponents: .date + ) + .datePickerStyle(.graphical) + } footer: { + Text(L10n.Localizable.DateSeekView.Footer.seekAroundDate) + } + + Section { + if showsNewerButton { + Button { + seekAction(.newer) + } label: { + Label(L10n.Localizable.DateSeekView.Button.seekNewer, systemImage: "chevron.left") + } + } + if showsOlderButton { + Button { + seekAction(.older) + } label: { + Label(L10n.Localizable.DateSeekView.Button.seekOlder, systemImage: "chevron.right") + } + } + } + } + .navigationTitle(L10n.Localizable.DateSeekView.Title.dateSeek) + .navigationBarTitleDisplayMode(.inline) + } + .onAppear { + if let navigation { + selectedDate = navigation.clampedDate(selectedDate) + } + } + } +} diff --git a/EhPanda/View/Support/DateSeekReducer.swift b/EhPanda/View/Support/DateSeekReducer.swift new file mode 100644 index 000000000..82947f387 --- /dev/null +++ b/EhPanda/View/Support/DateSeekReducer.swift @@ -0,0 +1,66 @@ +// +// DateSeekReducer.swift +// EhPanda +// + +import ComposableArchitecture +import Foundation + +/// A headless, reusable sub-reducer for the "Seek to date" control. +/// +/// Despite the matching name, this reducer is **not** the companion of `DateSeekPickerView`: it +/// owns no view, and the picker owns no reducer. `DateSeekPickerView` is a store-agnostic +/// presentation component, while `DateSeekReducer` is logic-only, designed to be embedded — via +/// `Scope` — into any gallery-list reducer that exposes a `DateSeekNavigation`. +/// +/// It owns the picker's UI state (selected date, sheet flag), validates and clamps the date, and +/// resolves a seek `URL`, which it hands back to its host through `delegate(.performSeek)`. The +/// host performs the request and stores the result, because the gallery list and its loading state +/// belong to the host — not to this control. +@Reducer +struct DateSeekReducer { + @ObservableState + struct State: Equatable { + /// Kept in sync by the host whenever its page number changes. + var navigation: DateSeekNavigation? + var date = Date() + var sheetPresented = false + } + + enum Action { + case present + case performSeek(DateSeekDirection) + case delegate(Delegate) + } + + @CasePathable + enum Delegate: Equatable { + case performSeek(URL) + } + + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some Reducer { + Reduce { state, action in + switch action { + case .present: + guard let navigation = state.navigation, navigation.isEnabled else { + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + } + state.date = navigation.clampedDate(state.date) + state.sheetPresented = true + return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) + + case .performSeek(let direction): + guard let url = state.navigation?.seekURL(date: state.date, direction: direction) else { + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + } + state.sheetPresented = false + return .send(.delegate(.performSeek(url))) + + case .delegate: + return .none + } + } + } +} From 04006545c774de7993de6c0766635bc244c6dfc2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 26 Jun 2026 10:43:10 +0800 Subject: [PATCH 297/614] Hide the Favorites Date Seek button until a page loads Favorites' pageNumber is per-index and nil before its list loads. Render the Date Seek button only when a real PageNumber exists instead of fabricating an empty one with ?? .init(). --- EhPanda/View/Favorites/FavoritesView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index b50925a13..7ca26dcec 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -135,8 +135,10 @@ struct FavoritesView: View { store.send(.fetchGalleries(nil, order)) } } - DateSeekButton(pageNumber: store.pageNumber ?? .init(), hideText: true) { - store.send(.dateSeek(.present)) + if let pageNumber = store.pageNumber { + DateSeekButton(pageNumber: pageNumber, hideText: true) { + store.send(.dateSeek(.present)) + } } QuickSearchButton(hideText: true) { store.send(.setNavigation(.quickSearch())) From 90d2822c6369e49e22a6c5541da022ee5367dfb8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 26 Jun 2026 13:42:38 +0800 Subject: [PATCH 298/614] Tighten DateSeekPickerView and DateSeekButton interfaces DateSeekPickerView.navigation was only optional because it mirrored the optional source; the sheet is presented solely via the present action, which guards navigation non-nil, so make it non-optional and unwrap once per call site, deleting the dead nil fallbacks. Drop DateSeekButton's hideText flag in favor of an adaptive Label: the toolbar renders it icon-only and a Menu renders it with its title, which reproduces both prior placements without the parameter. --- EhPanda/View/Favorites/FavoritesView.swift | 18 ++++++++++-------- .../View/Home/Frontpage/FrontpageView.swift | 18 ++++++++++-------- EhPanda/View/Home/Watched/WatchedView.swift | 16 +++++++++------- EhPanda/View/Search/SearchView.swift | 16 +++++++++------- .../Components/DateSeekPickerView.swift | 15 +++++---------- .../View/Support/Components/ToolbarItems.swift | 9 ++------- 6 files changed, 45 insertions(+), 47 deletions(-) diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index 7ca26dcec..8da003614 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -64,13 +64,15 @@ struct FavoritesView: View { .autoBlur(radius: blurRadius) } .sheet(isPresented: $store.dateSeek.sheetPresented) { - DateSeekPickerView( - navigation: store.dateSeek.navigation, - selectedDate: $store.dateSeek.date, - seekAction: { store.send(.dateSeek(.performSeek($0))) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) + if let navigation = store.dateSeek.navigation { + DateSeekPickerView( + navigation: navigation, + selectedDate: $store.dateSeek.date, + seekAction: { store.send(.dateSeek(.performSeek($0))) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } } .searchable(text: $store.keyword) .searchSuggestions { @@ -136,7 +138,7 @@ struct FavoritesView: View { } } if let pageNumber = store.pageNumber { - DateSeekButton(pageNumber: pageNumber, hideText: true) { + DateSeekButton(pageNumber: pageNumber) { store.send(.dateSeek(.present)) } } diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index 546b1476d..d0ea86dd5 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -45,13 +45,15 @@ struct FrontpageView: View { .autoBlur(radius: blurRadius).environment(\.inSheet, true) } .sheet(isPresented: $store.dateSeek.sheetPresented) { - DateSeekPickerView( - navigation: store.dateSeek.navigation, - selectedDate: $store.dateSeek.date, - seekAction: { store.send(.dateSeek(.performSeek($0))) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) + if let navigation = store.dateSeek.navigation { + DateSeekPickerView( + navigation: navigation, + selectedDate: $store.dateSeek.date, + seekAction: { store.send(.dateSeek(.performSeek($0))) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } } .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) .onAppear { @@ -95,7 +97,7 @@ struct FrontpageView: View { } private func toolbar() -> some ToolbarContent { CustomToolbarItem { - DateSeekButton(pageNumber: store.pageNumber, hideText: true) { + DateSeekButton(pageNumber: store.pageNumber) { store.send(.dateSeek(.present)) } FiltersButton(hideText: true) { diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift index ef5002353..2e72c77d3 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -61,13 +61,15 @@ struct WatchedView: View { .autoBlur(radius: blurRadius).environment(\.inSheet, true) } .sheet(isPresented: $store.dateSeek.sheetPresented) { - DateSeekPickerView( - navigation: store.dateSeek.navigation, - selectedDate: $store.dateSeek.date, - seekAction: { store.send(.dateSeek(.performSeek($0))) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) + if let navigation = store.dateSeek.navigation { + DateSeekPickerView( + navigation: navigation, + selectedDate: $store.dateSeek.date, + seekAction: { store.send(.dateSeek(.performSeek($0))) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } } .searchable(text: $store.keyword) .searchSuggestions { diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index 9b1256ec3..8b784f99e 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -57,13 +57,15 @@ struct SearchView: View { .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } .sheet(isPresented: $store.dateSeek.sheetPresented) { - DateSeekPickerView( - navigation: store.dateSeek.navigation, - selectedDate: $store.dateSeek.date, - seekAction: { store.send(.dateSeek(.performSeek($0))) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) + if let navigation = store.dateSeek.navigation { + DateSeekPickerView( + navigation: navigation, + selectedDate: $store.dateSeek.date, + seekAction: { store.send(.dateSeek(.performSeek($0))) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } } .searchable(text: $store.keyword) .searchSuggestions { diff --git a/EhPanda/View/Support/Components/DateSeekPickerView.swift b/EhPanda/View/Support/Components/DateSeekPickerView.swift index 0fd6f9b29..13a43f666 100644 --- a/EhPanda/View/Support/Components/DateSeekPickerView.swift +++ b/EhPanda/View/Support/Components/DateSeekPickerView.swift @@ -11,18 +11,15 @@ import SwiftUI /// not by a dedicated reducer. Hosts typically wire it to an embedded `DateSeekReducer`, but it /// has no dependency on one. struct DateSeekPickerView: View { - let navigation: DateSeekNavigation? + let navigation: DateSeekNavigation @Binding var selectedDate: Date let seekAction: (DateSeekDirection) -> Void - private var dateRange: ClosedRange { - navigation?.dateRange ?? Date.distantPast...Date.distantFuture - } private var showsNewerButton: Bool { - navigation?.previousURL != nil + navigation.previousURL != nil } private var showsOlderButton: Bool { - navigation?.nextURL != nil + navigation.nextURL != nil } var body: some View { @@ -32,7 +29,7 @@ struct DateSeekPickerView: View { DatePicker( L10n.Localizable.DateSeekView.Title.date, selection: $selectedDate, - in: dateRange, + in: navigation.dateRange, displayedComponents: .date ) .datePickerStyle(.graphical) @@ -61,9 +58,7 @@ struct DateSeekPickerView: View { .navigationBarTitleDisplayMode(.inline) } .onAppear { - if let navigation { - selectedDate = navigation.clampedDate(selectedDate) - } + selectedDate = navigation.clampedDate(selectedDate) } } } diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/EhPanda/View/Support/Components/ToolbarItems.swift index 8748c734a..a78764891 100644 --- a/EhPanda/View/Support/Components/ToolbarItems.swift +++ b/EhPanda/View/Support/Components/ToolbarItems.swift @@ -113,21 +113,16 @@ struct JumpPageButton: View { struct DateSeekButton: View { private let pageNumber: PageNumber - private let hideText: Bool private let action: () -> Void - init(pageNumber: PageNumber, hideText: Bool = false, action: @escaping () -> Void) { + init(pageNumber: PageNumber, action: @escaping () -> Void) { self.pageNumber = pageNumber - self.hideText = hideText self.action = action } var body: some View { Button(action: action) { - Image(systemSymbol: .calendar) - if !hideText { - Text(L10n.Localizable.ToolbarItem.Button.dateSeek) - } + Label(L10n.Localizable.ToolbarItem.Button.dateSeek, systemSymbol: .calendar) } .disabled(pageNumber.dateSeekNavigation?.isEnabled != true) } From 85f2c017fdf2138f8f2599c271f0d87f8b16ae00 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 26 Jun 2026 14:02:46 +0800 Subject: [PATCH 299/614] Drive the Date Seek sheet with a Route instead of a Bool flag Replace DateSeekReducer's sheetPresented: Bool with route: Route?, whose .picker case carries the DateSeekNavigation in scope. present snapshots the validated navigation into the route, and hosts present via .sheet(item: ...route...picker, id: \.self), matching the codebase's existing Route presentation convention. This makes non-optional navigation a precondition of entering the sheet: DateSeekPickerView receives it straight from the route case, so the per-call-site `if let navigation` unwraps are gone. DateSeekNavigation gains Hashable for the sheet(item:id:) identity. --- EhPanda/Models/Support/Misc.swift | 2 +- EhPanda/View/Favorites/FavoritesView.swift | 18 +++++------- .../View/Home/Frontpage/FrontpageView.swift | 18 +++++------- EhPanda/View/Home/Watched/WatchedView.swift | 18 +++++------- EhPanda/View/Search/SearchView.swift | 18 +++++------- EhPanda/View/Support/DateSeekReducer.swift | 29 ++++++++++++++----- 6 files changed, 54 insertions(+), 49 deletions(-) diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift index f5cb71b00..6eae3d1c2 100644 --- a/EhPanda/Models/Support/Misc.swift +++ b/EhPanda/Models/Support/Misc.swift @@ -15,7 +15,7 @@ enum DateSeekDirection: Equatable { case older } -struct DateSeekNavigation: Equatable { +struct DateSeekNavigation: Hashable { var previousURL: URL? var nextURL: URL? var minimumDate: Date? diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index 8da003614..4e826e853 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -63,16 +63,14 @@ struct FavoritesView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(isPresented: $store.dateSeek.sheetPresented) { - if let navigation = store.dateSeek.navigation { - DateSeekPickerView( - navigation: navigation, - selectedDate: $store.dateSeek.date, - seekAction: { store.send(.dateSeek(.performSeek($0))) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } + .sheet(item: $store.dateSeek.route.sending(\.dateSeek.setRoute).picker, id: \.self) { navigation in + DateSeekPickerView( + navigation: navigation.wrappedValue, + selectedDate: $store.dateSeek.date, + seekAction: { store.send(.dateSeek(.performSeek($0))) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) } .searchable(text: $store.keyword) .searchSuggestions { diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index d0ea86dd5..ca764dc18 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -44,16 +44,14 @@ struct FrontpageView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .sheet(isPresented: $store.dateSeek.sheetPresented) { - if let navigation = store.dateSeek.navigation { - DateSeekPickerView( - navigation: navigation, - selectedDate: $store.dateSeek.date, - seekAction: { store.send(.dateSeek(.performSeek($0))) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } + .sheet(item: $store.dateSeek.route.sending(\.dateSeek.setRoute).picker, id: \.self) { navigation in + DateSeekPickerView( + navigation: navigation.wrappedValue, + selectedDate: $store.dateSeek.date, + seekAction: { store.send(.dateSeek(.performSeek($0))) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) } .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) .onAppear { diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift index 2e72c77d3..1ba8ae3ae 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -60,16 +60,14 @@ struct WatchedView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .sheet(isPresented: $store.dateSeek.sheetPresented) { - if let navigation = store.dateSeek.navigation { - DateSeekPickerView( - navigation: navigation, - selectedDate: $store.dateSeek.date, - seekAction: { store.send(.dateSeek(.performSeek($0))) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } + .sheet(item: $store.dateSeek.route.sending(\.dateSeek.setRoute).picker, id: \.self) { navigation in + DateSeekPickerView( + navigation: navigation.wrappedValue, + selectedDate: $store.dateSeek.date, + seekAction: { store.send(.dateSeek(.performSeek($0))) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) } .searchable(text: $store.keyword) .searchSuggestions { diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index 8b784f99e..36d7af840 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -56,16 +56,14 @@ struct SearchView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } - .sheet(isPresented: $store.dateSeek.sheetPresented) { - if let navigation = store.dateSeek.navigation { - DateSeekPickerView( - navigation: navigation, - selectedDate: $store.dateSeek.date, - seekAction: { store.send(.dateSeek(.performSeek($0))) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } + .sheet(item: $store.dateSeek.route.sending(\.dateSeek.setRoute).picker, id: \.self) { navigation in + DateSeekPickerView( + navigation: navigation.wrappedValue, + selectedDate: $store.dateSeek.date, + seekAction: { store.send(.dateSeek(.performSeek($0))) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) } .searchable(text: $store.keyword) .searchSuggestions { diff --git a/EhPanda/View/Support/DateSeekReducer.swift b/EhPanda/View/Support/DateSeekReducer.swift index 82947f387..92d8ebc75 100644 --- a/EhPanda/View/Support/DateSeekReducer.swift +++ b/EhPanda/View/Support/DateSeekReducer.swift @@ -13,22 +13,29 @@ import Foundation /// presentation component, while `DateSeekReducer` is logic-only, designed to be embedded — via /// `Scope` — into any gallery-list reducer that exposes a `DateSeekNavigation`. /// -/// It owns the picker's UI state (selected date, sheet flag), validates and clamps the date, and -/// resolves a seek `URL`, which it hands back to its host through `delegate(.performSeek)`. The -/// host performs the request and stores the result, because the gallery list and its loading state -/// belong to the host — not to this control. +/// It owns the picker's UI state (selected date, presentation route), validates and clamps the +/// date, and resolves a seek `URL`, which it hands back to its host through `delegate(.performSeek)`. +/// The host performs the request and stores the result, because the gallery list and its loading +/// state belong to the host — not to this control. @Reducer struct DateSeekReducer { + @CasePathable + enum Route: Equatable { + /// Carries the navigation in scope, so the picker is only ever entered with a non-optional one. + case picker(DateSeekNavigation) + } + @ObservableState struct State: Equatable { /// Kept in sync by the host whenever its page number changes. var navigation: DateSeekNavigation? var date = Date() - var sheetPresented = false + var route: Route? } enum Action { case present + case setRoute(Route?) case performSeek(DateSeekDirection) case delegate(Delegate) } @@ -48,14 +55,20 @@ struct DateSeekReducer { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } state.date = navigation.clampedDate(state.date) - state.sheetPresented = true + state.route = .picker(navigation) return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) + case .setRoute(let route): + state.route = route + return .none + case .performSeek(let direction): - guard let url = state.navigation?.seekURL(date: state.date, direction: direction) else { + guard case let .picker(navigation)? = state.route, + let url = navigation.seekURL(date: state.date, direction: direction) + else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } - state.sheetPresented = false + state.route = nil return .send(.delegate(.performSeek(url))) case .delegate: From e032ad0d08c2364a726a340511ca78dd78484f97 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 26 Jun 2026 14:35:38 +0800 Subject: [PATCH 300/614] Model DateSeekNavigation directions as a sum type; present it directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DateSeekNavigation's two independent optional URLs allowed an illegal both-nil state and an always-true isEnabled guard. Replace them with a non-optional Directions enum (.newer / .older / .both), built by a failable init that returns nil only when neither URL is present — so the "no seek" state is the nil of a DateSeekNavigation? rather than an internal flag. minimumDate/maximumDate become non-optional (the parser guards mindate/maxdate, dropping the .distantPast/.distantFuture fallbacks in dateRange/clampedDate). isEnabled is gone; the button disables on dateSeekNavigation == nil. DateSeekReducer drops its Route: the navigation property is now the .sheet(item:) value itself (nil = dismissed). present takes the tapped page's navigation, which retires the synced field and the four onChange(of: \.pageNumber) syncs across the hosts. --- EhPanda/App/Tools/Parser/Parser+Shared.swift | 15 +++-- EhPanda/Models/Support/Misc.swift | 66 ++++++++++++++----- EhPanda/View/Favorites/FavoritesReducer.swift | 4 -- EhPanda/View/Favorites/FavoritesView.swift | 4 +- .../Home/Frontpage/FrontpageReducer.swift | 4 -- .../View/Home/Frontpage/FrontpageView.swift | 4 +- .../View/Home/Watched/WatchedReducer.swift | 4 -- EhPanda/View/Home/Watched/WatchedView.swift | 4 +- EhPanda/View/Search/SearchReducer.swift | 4 -- EhPanda/View/Search/SearchView.swift | 4 +- .../Components/DateSeekPickerView.swift | 4 +- .../Support/Components/ToolbarItems.swift | 2 +- EhPanda/View/Support/DateSeekReducer.swift | 31 ++++----- .../Tests/Parser/List/ListParserTests.swift | 31 ++++----- 14 files changed, 93 insertions(+), 88 deletions(-) diff --git a/EhPanda/App/Tools/Parser/Parser+Shared.swift b/EhPanda/App/Tools/Parser/Parser+Shared.swift index 481be6c52..5dad123f8 100644 --- a/EhPanda/App/Tools/Parser/Parser+Shared.swift +++ b/EhPanda/App/Tools/Parser/Parser+Shared.swift @@ -87,13 +87,14 @@ extension Parser { } static func parseDateSeekNavigation(doc: HTMLDocument, host: URL) -> DateSeekNavigation? { - let navigation = DateSeekNavigation( - previousURL: parseScriptURL(name: "prevurl", doc: doc, host: host), - nextURL: parseScriptURL(name: "nexturl", doc: doc, host: host), - minimumDate: parseScriptDate(name: "mindate", doc: doc), - maximumDate: parseScriptDate(name: "maxdate", doc: doc) - ) - return navigation.isEnabled ? navigation : nil + guard let minimumDate = parseScriptDate(name: "mindate", doc: doc), + let maximumDate = parseScriptDate(name: "maxdate", doc: doc), + let directions = DateSeekNavigation.Directions( + newer: parseScriptURL(name: "prevurl", doc: doc, host: host), + older: parseScriptURL(name: "nexturl", doc: doc, host: host) + ) + else { return nil } + return DateSeekNavigation(directions: directions, minimumDate: minimumDate, maximumDate: maximumDate) } // swiftlint:disable cyclomatic_complexity diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift index 6eae3d1c2..9d0d81b53 100644 --- a/EhPanda/Models/Support/Misc.swift +++ b/EhPanda/Models/Support/Misc.swift @@ -16,35 +16,65 @@ enum DateSeekDirection: Equatable { } struct DateSeekNavigation: Hashable { - var previousURL: URL? - var nextURL: URL? - var minimumDate: Date? - var maximumDate: Date? + /// The seekable directions available from the current page. Non-optional: a navigation only + /// exists when at least one direction is, so the "neither" state is unrepresentable here — it + /// is the `nil` of a `DateSeekNavigation?` instead. + enum Directions: Hashable { + case newer(URL) + case older(URL) + case both(newer: URL, older: URL) - var isEnabled: Bool { - previousURL != nil || nextURL != nil - } - var dateRange: ClosedRange { - (minimumDate ?? .distantPast)...(maximumDate ?? .distantFuture) - } + /// `nil` when neither URL is present — i.e. the page offers no date seek. + init?(newer: URL?, older: URL?) { + switch (newer, older) { + case let (newer?, older?): + self = .both(newer: newer, older: older) + case let (newer?, nil): + self = .newer(newer) + case let (nil, older?): + self = .older(older) + case (nil, nil): + return nil + } + } - func clampedDate(_ date: Date = Date()) -> Date { - if let maximumDate, date > maximumDate { - return maximumDate + var newerURL: URL? { + switch self { + case .newer(let url), .both(newer: let url, older: _): + return url + case .older: + return nil + } } - if let minimumDate, date < minimumDate { - return minimumDate + var olderURL: URL? { + switch self { + case .older(let url), .both(newer: _, older: let url): + return url + case .newer: + return nil + } } - return date + } + + var directions: Directions + var minimumDate: Date + var maximumDate: Date + + var newerURL: URL? { directions.newerURL } + var olderURL: URL? { directions.olderURL } + var dateRange: ClosedRange { minimumDate...maximumDate } + + func clampedDate(_ date: Date = Date()) -> Date { + min(max(date, minimumDate), maximumDate) } func seekURL(date: Date, direction: DateSeekDirection) -> URL? { let baseURL: URL? switch direction { case .newer: - baseURL = previousURL + baseURL = newerURL case .older: - baseURL = nextURL + baseURL = olderURL } return baseURL?.appending(queryItems: ["seek": Self.dateFormatter.string(from: date)]) } diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift index bb347df07..173583447 100644 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -251,10 +251,6 @@ struct FavoritesReducer { return .none } } - .onChange(of: \.pageNumber) { _, state in - state.dateSeek.navigation = state.pageNumber?.dateSeekNavigation - return .none - } .haptics( unwrapping: \.route, case: \.quickSearch, diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index 4e826e853..a9d506225 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -63,7 +63,7 @@ struct FavoritesView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(item: $store.dateSeek.route.sending(\.dateSeek.setRoute).picker, id: \.self) { navigation in + .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in DateSeekPickerView( navigation: navigation.wrappedValue, selectedDate: $store.dateSeek.date, @@ -137,7 +137,7 @@ struct FavoritesView: View { } if let pageNumber = store.pageNumber { DateSeekButton(pageNumber: pageNumber) { - store.send(.dateSeek(.present)) + store.send(.dateSeek(.present(pageNumber.dateSeekNavigation))) } } QuickSearchButton(hideText: true) { diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift index e65165bae..c288fa24c 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift @@ -193,10 +193,6 @@ struct FrontpageReducer { return .none } } - .onChange(of: \.pageNumber) { _, state in - state.dateSeek.navigation = state.pageNumber.dateSeekNavigation - return .none - } .haptics( unwrapping: \.route, case: \.filters, diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index ca764dc18..e56e38541 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -44,7 +44,7 @@ struct FrontpageView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .sheet(item: $store.dateSeek.route.sending(\.dateSeek.setRoute).picker, id: \.self) { navigation in + .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in DateSeekPickerView( navigation: navigation.wrappedValue, selectedDate: $store.dateSeek.date, @@ -96,7 +96,7 @@ struct FrontpageView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { DateSeekButton(pageNumber: store.pageNumber) { - store.send(.dateSeek(.present)) + store.send(.dateSeek(.present(store.pageNumber.dateSeekNavigation))) } FiltersButton(hideText: true) { store.send(.setNavigation(.filters())) diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift index 0195cf241..9559e3465 100644 --- a/EhPanda/View/Home/Watched/WatchedReducer.swift +++ b/EhPanda/View/Home/Watched/WatchedReducer.swift @@ -230,10 +230,6 @@ struct WatchedReducer { return .none } } - .onChange(of: \.pageNumber) { _, state in - state.dateSeek.navigation = state.pageNumber.dateSeekNavigation - return .none - } .haptics( unwrapping: \.route, case: \.quickSearch, diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift index 1ba8ae3ae..233bc8c89 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -60,7 +60,7 @@ struct WatchedView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .sheet(item: $store.dateSeek.route.sending(\.dateSeek.setRoute).picker, id: \.self) { navigation in + .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in DateSeekPickerView( navigation: navigation.wrappedValue, selectedDate: $store.dateSeek.date, @@ -123,7 +123,7 @@ struct WatchedView: View { CustomToolbarItem { ToolbarFeaturesMenu { DateSeekButton(pageNumber: store.pageNumber) { - store.send(.dateSeek(.present)) + store.send(.dateSeek(.present(store.pageNumber.dateSeekNavigation))) } FiltersButton { store.send(.setNavigation(.filters())) diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index 25df3f8fb..b7f852515 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -235,10 +235,6 @@ struct SearchReducer { return .none } } - .onChange(of: \.pageNumber) { _, state in - state.dateSeek.navigation = state.pageNumber.dateSeekNavigation - return .none - } .haptics( unwrapping: \.route, case: \.quickSearch, diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index 36d7af840..792823e92 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -56,7 +56,7 @@ struct SearchView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } - .sheet(item: $store.dateSeek.route.sending(\.dateSeek.setRoute).picker, id: \.self) { navigation in + .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in DateSeekPickerView( navigation: navigation.wrappedValue, selectedDate: $store.dateSeek.date, @@ -119,7 +119,7 @@ struct SearchView: View { CustomToolbarItem { ToolbarFeaturesMenu { DateSeekButton(pageNumber: store.pageNumber) { - store.send(.dateSeek(.present)) + store.send(.dateSeek(.present(store.pageNumber.dateSeekNavigation))) } FiltersButton { store.send(.setNavigation(.filters())) diff --git a/EhPanda/View/Support/Components/DateSeekPickerView.swift b/EhPanda/View/Support/Components/DateSeekPickerView.swift index 13a43f666..18da39c5e 100644 --- a/EhPanda/View/Support/Components/DateSeekPickerView.swift +++ b/EhPanda/View/Support/Components/DateSeekPickerView.swift @@ -16,10 +16,10 @@ struct DateSeekPickerView: View { let seekAction: (DateSeekDirection) -> Void private var showsNewerButton: Bool { - navigation.previousURL != nil + navigation.newerURL != nil } private var showsOlderButton: Bool { - navigation.nextURL != nil + navigation.olderURL != nil } var body: some View { diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/EhPanda/View/Support/Components/ToolbarItems.swift index a78764891..f0cb97ceb 100644 --- a/EhPanda/View/Support/Components/ToolbarItems.swift +++ b/EhPanda/View/Support/Components/ToolbarItems.swift @@ -124,7 +124,7 @@ struct DateSeekButton: View { Button(action: action) { Label(L10n.Localizable.ToolbarItem.Button.dateSeek, systemSymbol: .calendar) } - .disabled(pageNumber.dateSeekNavigation?.isEnabled != true) + .disabled(pageNumber.dateSeekNavigation == nil) } } diff --git a/EhPanda/View/Support/DateSeekReducer.swift b/EhPanda/View/Support/DateSeekReducer.swift index 92d8ebc75..8f5214cb5 100644 --- a/EhPanda/View/Support/DateSeekReducer.swift +++ b/EhPanda/View/Support/DateSeekReducer.swift @@ -13,29 +13,22 @@ import Foundation /// presentation component, while `DateSeekReducer` is logic-only, designed to be embedded — via /// `Scope` — into any gallery-list reducer that exposes a `DateSeekNavigation`. /// -/// It owns the picker's UI state (selected date, presentation route), validates and clamps the +/// It owns the picker's UI state (selected date, presented navigation), validates and clamps the /// date, and resolves a seek `URL`, which it hands back to its host through `delegate(.performSeek)`. /// The host performs the request and stores the result, because the gallery list and its loading /// state belong to the host — not to this control. @Reducer struct DateSeekReducer { - @CasePathable - enum Route: Equatable { - /// Carries the navigation in scope, so the picker is only ever entered with a non-optional one. - case picker(DateSeekNavigation) - } - @ObservableState struct State: Equatable { - /// Kept in sync by the host whenever its page number changes. - var navigation: DateSeekNavigation? var date = Date() - var route: Route? + /// The navigation whose picker is presented; `nil` while the sheet is dismissed. + var navigation: DateSeekNavigation? } enum Action { - case present - case setRoute(Route?) + case present(DateSeekNavigation?) + case setNavigation(DateSeekNavigation?) case performSeek(DateSeekDirection) case delegate(Delegate) } @@ -50,25 +43,25 @@ struct DateSeekReducer { var body: some Reducer { Reduce { state, action in switch action { - case .present: - guard let navigation = state.navigation, navigation.isEnabled else { + case .present(let navigation): + guard let navigation else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } state.date = navigation.clampedDate(state.date) - state.route = .picker(navigation) + state.navigation = navigation return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) - case .setRoute(let route): - state.route = route + case .setNavigation(let navigation): + state.navigation = navigation return .none case .performSeek(let direction): - guard case let .picker(navigation)? = state.route, + guard let navigation = state.navigation, let url = navigation.seekURL(date: state.date, direction: direction) else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } - state.route = nil + state.navigation = nil return .send(.delegate(.performSeek(url))) case .delegate: diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index 2f61a290a..c15eb9fbb 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -31,15 +31,13 @@ struct ListParserTests: TestHelper { let document = try htmlDocument(filename: .frontPageMinimalList) let pageNumber = Parser.parsePageNum(doc: document, host: Defaults.URL.ehentai) let navigation = try #require(pageNumber.dateSeekNavigation) - let minimumDate = try #require(navigation.minimumDate) - let maximumDate = try #require(navigation.maximumDate) #expect(pageNumber.hasNextPage()) #expect(pageNumber.lastItemTimestamp == "2668517") - #expect(navigation.previousURL == nil) - #expect(navigation.nextURL?.absoluteString == "https://e-hentai.org/?next=2668517") - #expect(DateSeekNavigation.dateFormatter.string(from: minimumDate) == "2007-03-20") - #expect(DateSeekNavigation.dateFormatter.string(from: maximumDate) == "2023-09-08") + #expect(navigation.newerURL == nil) + #expect(navigation.olderURL?.absoluteString == "https://e-hentai.org/?next=2668517") + #expect(DateSeekNavigation.dateFormatter.string(from: navigation.minimumDate) == "2007-03-20") + #expect(DateSeekNavigation.dateFormatter.string(from: navigation.maximumDate) == "2023-09-08") } @Test @@ -47,13 +45,12 @@ struct ListParserTests: TestHelper { let document = try htmlDocument(filename: .frontPageMinimalList) let pageNumber = Parser.parsePageNum(doc: document, host: Defaults.URL.ehentai) let navigation = try #require(pageNumber.dateSeekNavigation) - let maximumDate = try #require(navigation.maximumDate) - let url = try #require(navigation.seekURL(date: maximumDate, direction: .older)) + let url = try #require(navigation.seekURL(date: navigation.maximumDate, direction: .older)) let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems #expect(queryItems?.first(where: { $0.name == "next" })?.value == "2668517") #expect(queryItems?.first(where: { $0.name == "seek" })?.value == "2023-09-08") - #expect(navigation.seekURL(date: maximumDate, direction: .newer) == nil) + #expect(navigation.seekURL(date: navigation.maximumDate, direction: .newer) == nil) } @Test @@ -74,19 +71,19 @@ struct ListParserTests: TestHelper { let pageNumber = Parser.parsePageNum(doc: document, host: Defaults.URL.exhentai) let navigation = try #require(pageNumber.dateSeekNavigation) - let previousURL = try #require(navigation.previousURL) - let nextURL = try #require(navigation.nextURL) + let newerURL = try #require(navigation.newerURL) + let olderURL = try #require(navigation.olderURL) - #expect(previousURL.host == "exhentai.org") - #expect(nextURL.host == "exhentai.org") + #expect(newerURL.host == "exhentai.org") + #expect(olderURL.host == "exhentai.org") #expect( - URLComponents(url: previousURL, resolvingAgainstBaseURL: false)? + URLComponents(url: newerURL, resolvingAgainstBaseURL: false)? .queryItems? .first(where: { $0.name == "page" })? .value == "1" ) #expect( - URLComponents(url: nextURL, resolvingAgainstBaseURL: false)? + URLComponents(url: olderURL, resolvingAgainstBaseURL: false)? .queryItems? .first(where: { $0.name == "next" })? .value == "456" @@ -120,7 +117,7 @@ struct ListParserTests: TestHelper { #expect(pageNumber.current == 1) #expect(pageNumber.maximum == 2) - #expect(navigation.previousURL?.absoluteString == "https://e-hentai.org/?prev=123") - #expect(navigation.nextURL?.absoluteString == "https://e-hentai.org/?next=456") + #expect(navigation.newerURL?.absoluteString == "https://e-hentai.org/?prev=123") + #expect(navigation.olderURL?.absoluteString == "https://e-hentai.org/?next=456") } } From caeef3554f3e88d20f67cc6afe2c47d7e2c4499c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 26 Jun 2026 15:05:26 +0800 Subject: [PATCH 301/614] Extract dateSeekNavigation out of PageNumber PageNumber carried a dateSeekNavigation that most consumers (GenericList, Toplists, History, Popular) ignored and that parsePageNum parsed for every list. Move it off the page cursor: parsePageNum returns pure pagination (and no longer needs the host param), and the date-seek lists parse the jumpbar separately via parseDateSeekNavigation, now the public entry with a defaulted host. The four date-seek requests return a GalleriesResult (pageNumber, dateSeekNavigation, galleries); FavoritesGalleriesResult gains the same field. Each host stores its own dateSeekNavigation (Favorites per-index via rawDateSeekNavigation) and the DateSeekButton takes the navigation directly, so present is non-optional. The two non-date-seek consumers of the shared requests (Home, DetailSearch) map the result back to (PageNumber, [Gallery]) in their effect, leaving their actions untouched. Navigation parsing stays non-fatal: it returns DateSeekNavigation? (never throws) independently of parsePageNum/parseGalleries, so a missing or malformed jumpbar yields nil (no date seek) without affecting the gallery or pagination parse. --- EhPanda/App/Tools/Parser/Parser+Misc.swift | 18 ++---- EhPanda/App/Tools/Parser/Parser+Shared.swift | 2 +- EhPanda/Models/Support/Misc.swift | 1 - EhPanda/Network/Request+Gallery.swift | 58 ++++++++++++++----- EhPanda/Network/Request.swift | 7 +++ .../DetailSearch/DetailSearchReducer.swift | 4 +- EhPanda/View/Favorites/FavoritesReducer.swift | 14 ++++- EhPanda/View/Favorites/FavoritesView.swift | 6 +- .../Home/Frontpage/FrontpageReducer.swift | 29 ++++++---- .../View/Home/Frontpage/FrontpageView.swift | 4 +- EhPanda/View/Home/HomeReducer+Body.swift | 2 +- .../View/Home/Watched/WatchedReducer.swift | 29 ++++++---- EhPanda/View/Home/Watched/WatchedView.swift | 4 +- EhPanda/View/Search/SearchReducer.swift | 29 ++++++---- EhPanda/View/Search/SearchView.swift | 4 +- .../Support/Components/ToolbarItems.swift | 14 +++-- EhPanda/View/Support/DateSeekReducer.swift | 5 +- .../Tests/Parser/List/ListParserTests.swift | 14 ++--- 18 files changed, 150 insertions(+), 94 deletions(-) diff --git a/EhPanda/App/Tools/Parser/Parser+Misc.swift b/EhPanda/App/Tools/Parser/Parser+Misc.swift index d621b4441..9cc626369 100644 --- a/EhPanda/App/Tools/Parser/Parser+Misc.swift +++ b/EhPanda/App/Tools/Parser/Parser+Misc.swift @@ -27,11 +27,9 @@ extension Parser { return apikey } - /// Parses the gallery-list pager. `host` normalizes the date-seek script URLs to the user's - /// gallery host; it defaults to the current host but is injectable so parsing stays deterministic - /// (independent of global state) in tests. - static func parsePageNum(doc: HTMLDocument, host: URL = Defaults.URL.host) -> PageNumber { - let dateSeekNavigation = parseDateSeekNavigation(doc: doc, host: host) + /// Parses the gallery-list pager. Date-seek navigation is a separate concern parsed via + /// `parseDateSeekNavigation`, so the page cursor stays independent of the jumpbar. + static func parsePageNum(doc: HTMLDocument) -> PageNumber { var current = 0 var maximum = 0 @@ -58,14 +56,10 @@ extension Parser { return PageNumber( lastItemTimestamp: timestamp, - isNextButtonEnabled: isEnabled, - dateSeekNavigation: dateSeekNavigation + isNextButtonEnabled: isEnabled ) } else { - return PageNumber( - isNextButtonEnabled: false, - dateSeekNavigation: dateSeekNavigation - ) + return PageNumber(isNextButtonEnabled: false) } } @@ -79,6 +73,6 @@ extension Parser { maximum = num - 1 } } - return PageNumber(current: current, maximum: maximum, dateSeekNavigation: dateSeekNavigation) + return PageNumber(current: current, maximum: maximum) } } diff --git a/EhPanda/App/Tools/Parser/Parser+Shared.swift b/EhPanda/App/Tools/Parser/Parser+Shared.swift index 5dad123f8..f6ccc545f 100644 --- a/EhPanda/App/Tools/Parser/Parser+Shared.swift +++ b/EhPanda/App/Tools/Parser/Parser+Shared.swift @@ -86,7 +86,7 @@ extension Parser { return try? parseDate(time: value, format: "yyyy-MM-dd") } - static func parseDateSeekNavigation(doc: HTMLDocument, host: URL) -> DateSeekNavigation? { + static func parseDateSeekNavigation(doc: HTMLDocument, host: URL = Defaults.URL.host) -> DateSeekNavigation? { guard let minimumDate = parseScriptDate(name: "mindate", doc: doc), let maximumDate = parseScriptDate(name: "maxdate", doc: doc), let directions = DateSeekNavigation.Directions( diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift index 9d0d81b53..8dbf9cd2c 100644 --- a/EhPanda/Models/Support/Misc.swift +++ b/EhPanda/Models/Support/Misc.swift @@ -108,7 +108,6 @@ struct PageNumber: Equatable { var maximum = 0 var lastItemTimestamp: String? var isNextButtonEnabled = false - var dateSeekNavigation: DateSeekNavigation? var isSinglePage: Bool { current == 0 && maximum == 0 diff --git a/EhPanda/Network/Request+Gallery.swift b/EhPanda/Network/Request+Gallery.swift index ce793d2c9..53bc247b2 100644 --- a/EhPanda/Network/Request+Gallery.swift +++ b/EhPanda/Network/Request+Gallery.swift @@ -12,7 +12,7 @@ struct SearchGalleriesRequest: Request { let keyword: String let filter: Filter - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher( for: URLUtil.searchList(keyword: keyword, filter: filter) ) @@ -20,7 +20,11 @@ struct SearchGalleriesRequest: Request { .tryMap { try htmlDocument(data: $0.data) } .tryMap { try parseResponse(doc: $0) { - (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) } } .mapError(mapAppError) @@ -33,7 +37,7 @@ struct MoreSearchGalleriesRequest: Request { let filter: Filter let lastID: String - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher( for: URLUtil.moreSearchList(keyword: keyword, filter: filter, lastID: lastID) ) @@ -41,7 +45,11 @@ struct MoreSearchGalleriesRequest: Request { .tryMap { try htmlDocument(data: $0.data) } .tryMap { try parseResponse(doc: $0) { - (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) } } .mapError(mapAppError) @@ -52,13 +60,17 @@ struct MoreSearchGalleriesRequest: Request { struct DateSeekGalleriesRequest: Request { let url: URL - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: url) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } .tryMap { try parseResponse(doc: $0) { - (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) } } .mapError(mapAppError) @@ -69,13 +81,17 @@ struct DateSeekGalleriesRequest: Request { struct FrontpageGalleriesRequest: Request { let filter: Filter - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: URLUtil.frontpageList(filter: filter)) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } .tryMap { try parseResponse(doc: $0) { - (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) } } .mapError(mapAppError) @@ -87,13 +103,17 @@ struct MoreFrontpageGalleriesRequest: Request { let filter: Filter let lastID: String - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: URLUtil.moreFrontpageList(filter: filter, lastID: lastID)) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } .tryMap { try parseResponse(doc: $0) { - (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) } } .mapError(mapAppError) @@ -118,13 +138,17 @@ struct WatchedGalleriesRequest: Request { let filter: Filter let keyword: String - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: URLUtil.watchedList(filter: filter, keyword: keyword)) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } .tryMap { try parseResponse(doc: $0) { - (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) } } .mapError(mapAppError) @@ -137,7 +161,7 @@ struct MoreWatchedGalleriesRequest: Request { let lastID: String let keyword: String - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher( for: URLUtil.moreWatchedList(filter: filter, lastID: lastID, keyword: keyword) ) @@ -145,7 +169,11 @@ struct MoreWatchedGalleriesRequest: Request { .tryMap { try htmlDocument(data: $0.data) } .tryMap { try parseResponse(doc: $0) { - (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) } } .mapError(mapAppError) @@ -168,6 +196,7 @@ struct FavoritesGalleriesRequest: Request { try parseResponse(doc: doc) { FavoritesGalleriesResult( pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), sortOrder: Parser.parseFavoritesSortOrder(doc: $0), galleries: try Parser.parseGalleries(doc: $0) ) @@ -196,6 +225,7 @@ struct MoreFavoritesGalleriesRequest: Request { try parseResponse(doc: doc) { FavoritesGalleriesResult( pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), sortOrder: Parser.parseFavoritesSortOrder(doc: $0), galleries: try Parser.parseGalleries(doc: $0) ) diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index fa6f714a5..fce06340d 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -204,8 +204,15 @@ private extension URL { // MARK: - Response Types +struct GalleriesResult { + let pageNumber: PageNumber + let dateSeekNavigation: DateSeekNavigation? + let galleries: [Gallery] +} + struct FavoritesGalleriesResult { let pageNumber: PageNumber + let dateSeekNavigation: DateSeekNavigation? let sortOrder: FavoritesSortOrder? let galleries: [Gallery] } diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift index 5080f9052..6cee897d8 100644 --- a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -109,7 +109,7 @@ struct DetailSearchReducer { let filter = databaseClient.fetchFilterSynchronously(range: .search) return .run { [lastKeyword = state.lastKeyword] send in let response = await SearchGalleriesRequest(keyword: lastKeyword, filter: filter).response() - await send(.fetchGalleriesDone(response)) + await send(.fetchGalleriesDone(response.map { ($0.pageNumber, $0.galleries) })) } .cancellable(id: CancelID.fetchGalleries) @@ -143,7 +143,7 @@ struct DetailSearchReducer { keyword: lastKeyword, filter: filter, lastID: lastID ) .response() - await send(.fetchMoreGalleriesDone(response)) + await send(.fetchMoreGalleriesDone(response.map { ($0.pageNumber, $0.galleries) })) } .cancellable(id: CancelID.fetchMoreGalleries) diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift index 173583447..cbd4c51b7 100644 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -29,6 +29,7 @@ struct FavoritesReducer { var rawGalleries = [Int: [Gallery]]() var rawPageNumber = [Int: PageNumber]() + var rawDateSeekNavigation = [Int: DateSeekNavigation]() var rawLoadingState = [Int: LoadingState]() var rawFooterLoadingState = [Int: LoadingState]() var downloadBadges = [String: DownloadBadge]() @@ -39,6 +40,9 @@ struct FavoritesReducer { var pageNumber: PageNumber? { rawPageNumber[index] } + var dateSeekNavigation: DateSeekNavigation? { + rawDateSeekNavigation[index] + } var loadingState: LoadingState? { rawLoadingState[index] } @@ -77,7 +81,7 @@ struct FavoritesReducer { case fetchMoreGalleriesDone(Int, Result) case observeDownloads case observeDownloadsDone([DownloadedGallery]) - case performDateSeekDone(Int, Result<(PageNumber, [Gallery]), AppError>) + case performDateSeekDone(Int, Result) case dateSeek(DateSeekReducer.Action) case detail(DetailReducer.Action) @@ -149,6 +153,7 @@ struct FavoritesReducer { return .send(.fetchMoreGalleries) } state.rawPageNumber[targetFavIndex] = pageNumber + state.rawDateSeekNavigation[targetFavIndex] = fetchResult.dateSeekNavigation state.rawGalleries[targetFavIndex] = galleries state.sortOrder = fetchResult.sortOrder return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) @@ -183,6 +188,7 @@ struct FavoritesReducer { let pageNumber = fetchResult.pageNumber let galleries = fetchResult.galleries state.rawPageNumber[targetFavIndex] = pageNumber + state.rawDateSeekNavigation[targetFavIndex] = fetchResult.dateSeekNavigation state.insertGalleries(index: targetFavIndex, galleries: galleries) state.sortOrder = fetchResult.sortOrder @@ -228,12 +234,14 @@ struct FavoritesReducer { case .performDateSeekDone(let targetFavIndex, let result): state.rawLoadingState[targetFavIndex] = .idle switch result { - case .success(let (pageNumber, galleries)): + case .success(let response): + let galleries = response.galleries guard !galleries.isEmpty else { state.rawLoadingState[targetFavIndex] = .failed(.notFound) return .none } - state.rawPageNumber[targetFavIndex] = pageNumber + state.rawPageNumber[targetFavIndex] = response.pageNumber + state.rawDateSeekNavigation[targetFavIndex] = response.dateSeekNavigation state.rawGalleries[targetFavIndex] = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index a9d506225..3b87e0a3b 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -135,9 +135,9 @@ struct FavoritesView: View { store.send(.fetchGalleries(nil, order)) } } - if let pageNumber = store.pageNumber { - DateSeekButton(pageNumber: pageNumber) { - store.send(.dateSeek(.present(pageNumber.dateSeekNavigation))) + if store.pageNumber != nil { + DateSeekButton(navigation: store.dateSeekNavigation) { navigation in + store.send(.dateSeek(.present(navigation))) } } QuickSearchButton(hideText: true) { diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift index c288fa24c..96b56980c 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift @@ -29,6 +29,7 @@ struct FrontpageReducer { } var galleries = [Gallery]() var pageNumber = PageNumber() + var dateSeekNavigation: DateSeekNavigation? var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle @@ -56,10 +57,10 @@ struct FrontpageReducer { case teardown case fetchGalleries - case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchGalleriesDone(Result) case fetchMoreGalleries - case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case performDateSeekDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleriesDone(Result) + case performDateSeekDone(Result) case dateSeek(DateSeekReducer.Action) case filters(FiltersReducer.Action) @@ -106,13 +107,15 @@ struct FrontpageReducer { case .fetchGalleriesDone(let result): state.loadingState = .idle switch result { - case .success(let (pageNumber, galleries)): + case .success(let response): + let galleries = response.galleries guard !galleries.isEmpty else { state.loadingState = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } + guard response.pageNumber.hasNextPage() else { return .none } return .send(.fetchMoreGalleries) } - state.pageNumber = pageNumber + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation state.galleries = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): @@ -137,14 +140,16 @@ struct FrontpageReducer { case .fetchMoreGalleriesDone(let result): state.footerLoadingState = .idle switch result { - case .success(let (pageNumber, galleries)): - state.pageNumber = pageNumber + case .success(let response): + let galleries = response.galleries + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation state.insertGalleries(galleries) var effects: [Effect] = [ .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) ] - if galleries.isEmpty, pageNumber.hasNextPage() { + if galleries.isEmpty, response.pageNumber.hasNextPage() { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.loadingState = .idle @@ -170,12 +175,14 @@ struct FrontpageReducer { case .performDateSeekDone(let result): state.loadingState = .idle switch result { - case .success(let (pageNumber, galleries)): + case .success(let response): + let galleries = response.galleries guard !galleries.isEmpty else { state.loadingState = .failed(.notFound) return .none } - state.pageNumber = pageNumber + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation state.galleries = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index e56e38541..059dcb48f 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -95,8 +95,8 @@ struct FrontpageView: View { } private func toolbar() -> some ToolbarContent { CustomToolbarItem { - DateSeekButton(pageNumber: store.pageNumber) { - store.send(.dateSeek(.present(store.pageNumber.dateSeekNavigation))) + DateSeekButton(navigation: store.dateSeekNavigation) { navigation in + store.send(.dateSeek(.present(navigation))) } FiltersButton(hideText: true) { store.send(.setNavigation(.filters())) diff --git a/EhPanda/View/Home/HomeReducer+Body.swift b/EhPanda/View/Home/HomeReducer+Body.swift index aee5569c8..650b9a570 100644 --- a/EhPanda/View/Home/HomeReducer+Body.swift +++ b/EhPanda/View/Home/HomeReducer+Body.swift @@ -97,7 +97,7 @@ extension HomeReducer { let filter = databaseClient.fetchFilterSynchronously(range: .global) return .run { send in let response = await FrontpageGalleriesRequest(filter: filter).response() - await send(.fetchFrontpageGalleriesDone(response)) + await send(.fetchFrontpageGalleriesDone(response.map { ($0.pageNumber, $0.galleries) })) } case .fetchFrontpageGalleriesDone(let result): diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift index 9559e3465..9846873ca 100644 --- a/EhPanda/View/Home/Watched/WatchedReducer.swift +++ b/EhPanda/View/Home/Watched/WatchedReducer.swift @@ -25,6 +25,7 @@ struct WatchedReducer { var galleries = [Gallery]() var pageNumber = PageNumber() + var dateSeekNavigation: DateSeekNavigation? var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle var downloadBadges = [String: DownloadBadge]() @@ -56,12 +57,12 @@ struct WatchedReducer { case teardown case fetchGalleries(String? = nil) - case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchGalleriesDone(Result) case fetchMoreGalleries - case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleriesDone(Result) case observeDownloads case observeDownloadsDone([DownloadedGallery]) - case performDateSeekDone(Result<(PageNumber, [Gallery]), AppError>) + case performDateSeekDone(Result) case dateSeek(DateSeekReducer.Action) case filters(FiltersReducer.Action) @@ -123,13 +124,15 @@ struct WatchedReducer { case .fetchGalleriesDone(let result): state.loadingState = .idle switch result { - case .success(let (pageNumber, galleries)): + case .success(let response): + let galleries = response.galleries guard !galleries.isEmpty else { state.loadingState = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } + guard response.pageNumber.hasNextPage() else { return .none } return .send(.fetchMoreGalleries) } - state.pageNumber = pageNumber + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation state.galleries = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): @@ -157,14 +160,16 @@ struct WatchedReducer { case .fetchMoreGalleriesDone(let result): state.footerLoadingState = .idle switch result { - case .success(let (pageNumber, galleries)): - state.pageNumber = pageNumber + case .success(let response): + let galleries = response.galleries + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation state.insertGalleries(galleries) var effects: [Effect] = [ .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) ] - if galleries.isEmpty, pageNumber.hasNextPage() { + if galleries.isEmpty, response.pageNumber.hasNextPage() { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.loadingState = .idle @@ -204,12 +209,14 @@ struct WatchedReducer { case .performDateSeekDone(let result): state.loadingState = .idle switch result { - case .success(let (pageNumber, galleries)): + case .success(let response): + let galleries = response.galleries guard !galleries.isEmpty else { state.loadingState = .failed(.notFound) return .none } - state.pageNumber = pageNumber + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation state.galleries = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift index 233bc8c89..e82ddc845 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -122,8 +122,8 @@ struct WatchedView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { ToolbarFeaturesMenu { - DateSeekButton(pageNumber: store.pageNumber) { - store.send(.dateSeek(.present(store.pageNumber.dateSeekNavigation))) + DateSeekButton(navigation: store.dateSeekNavigation) { navigation in + store.send(.dateSeek(.present(navigation))) } FiltersButton { store.send(.setNavigation(.filters())) diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index b7f852515..6c3af030f 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -27,6 +27,7 @@ struct SearchReducer { var galleries = [Gallery]() var pageNumber = PageNumber() + var dateSeekNavigation: DateSeekNavigation? var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle var downloadBadges = [String: DownloadBadge]() @@ -57,12 +58,12 @@ struct SearchReducer { case teardown case fetchGalleries(String? = nil) - case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchGalleriesDone(Result) case fetchMoreGalleries - case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleriesDone(Result) case observeDownloads case observeDownloadsDone([DownloadedGallery]) - case performDateSeekDone(Result<(PageNumber, [Gallery]), AppError>) + case performDateSeekDone(Result) case dateSeek(DateSeekReducer.Action) case detail(DetailReducer.Action) @@ -128,13 +129,15 @@ struct SearchReducer { case .fetchGalleriesDone(let result): state.loadingState = .idle switch result { - case .success(let (pageNumber, galleries)): + case .success(let response): + let galleries = response.galleries guard !galleries.isEmpty else { state.loadingState = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } + guard response.pageNumber.hasNextPage() else { return .none } return .send(.fetchMoreGalleries) } - state.pageNumber = pageNumber + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation state.galleries = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): @@ -162,14 +165,16 @@ struct SearchReducer { case .fetchMoreGalleriesDone(let result): state.footerLoadingState = .idle switch result { - case .success(let (pageNumber, galleries)): - state.pageNumber = pageNumber + case .success(let response): + let galleries = response.galleries + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation state.insertGalleries(galleries) var effects: [Effect] = [ .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) ] - if galleries.isEmpty, pageNumber.hasNextPage() { + if galleries.isEmpty, response.pageNumber.hasNextPage() { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.loadingState = .idle @@ -209,12 +214,14 @@ struct SearchReducer { case .performDateSeekDone(let result): state.loadingState = .idle switch result { - case .success(let (pageNumber, galleries)): + case .success(let response): + let galleries = response.galleries guard !galleries.isEmpty else { state.loadingState = .failed(.notFound) return .none } - state.pageNumber = pageNumber + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation state.galleries = galleries return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index 792823e92..fc5c0b69c 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -118,8 +118,8 @@ struct SearchView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { ToolbarFeaturesMenu { - DateSeekButton(pageNumber: store.pageNumber) { - store.send(.dateSeek(.present(store.pageNumber.dateSeekNavigation))) + DateSeekButton(navigation: store.dateSeekNavigation) { navigation in + store.send(.dateSeek(.present(navigation))) } FiltersButton { store.send(.setNavigation(.filters())) diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/EhPanda/View/Support/Components/ToolbarItems.swift index f0cb97ceb..2c4349ea3 100644 --- a/EhPanda/View/Support/Components/ToolbarItems.swift +++ b/EhPanda/View/Support/Components/ToolbarItems.swift @@ -112,19 +112,21 @@ struct JumpPageButton: View { } struct DateSeekButton: View { - private let pageNumber: PageNumber - private let action: () -> Void + private let navigation: DateSeekNavigation? + private let action: (DateSeekNavigation) -> Void - init(pageNumber: PageNumber, action: @escaping () -> Void) { - self.pageNumber = pageNumber + init(navigation: DateSeekNavigation?, action: @escaping (DateSeekNavigation) -> Void) { + self.navigation = navigation self.action = action } var body: some View { - Button(action: action) { + Button { + navigation.map(action) + } label: { Label(L10n.Localizable.ToolbarItem.Button.dateSeek, systemSymbol: .calendar) } - .disabled(pageNumber.dateSeekNavigation == nil) + .disabled(navigation == nil) } } diff --git a/EhPanda/View/Support/DateSeekReducer.swift b/EhPanda/View/Support/DateSeekReducer.swift index 8f5214cb5..a56fb3342 100644 --- a/EhPanda/View/Support/DateSeekReducer.swift +++ b/EhPanda/View/Support/DateSeekReducer.swift @@ -27,7 +27,7 @@ struct DateSeekReducer { } enum Action { - case present(DateSeekNavigation?) + case present(DateSeekNavigation) case setNavigation(DateSeekNavigation?) case performSeek(DateSeekDirection) case delegate(Delegate) @@ -44,9 +44,6 @@ struct DateSeekReducer { Reduce { state, action in switch action { case .present(let navigation): - guard let navigation else { - return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - } state.date = navigation.clampedDate(state.date) state.navigation = navigation return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index c15eb9fbb..2e5340fa0 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -29,8 +29,8 @@ struct ListParserTests: TestHelper { @Test func testDateSeekNavigation() throws { let document = try htmlDocument(filename: .frontPageMinimalList) - let pageNumber = Parser.parsePageNum(doc: document, host: Defaults.URL.ehentai) - let navigation = try #require(pageNumber.dateSeekNavigation) + let pageNumber = Parser.parsePageNum(doc: document) + let navigation = try #require(Parser.parseDateSeekNavigation(doc: document, host: Defaults.URL.ehentai)) #expect(pageNumber.hasNextPage()) #expect(pageNumber.lastItemTimestamp == "2668517") @@ -43,8 +43,7 @@ struct ListParserTests: TestHelper { @Test func testDateSeekURL() throws { let document = try htmlDocument(filename: .frontPageMinimalList) - let pageNumber = Parser.parsePageNum(doc: document, host: Defaults.URL.ehentai) - let navigation = try #require(pageNumber.dateSeekNavigation) + let navigation = try #require(Parser.parseDateSeekNavigation(doc: document, host: Defaults.URL.ehentai)) let url = try #require(navigation.seekURL(date: navigation.maximumDate, direction: .older)) let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems @@ -69,8 +68,7 @@ struct ListParserTests: TestHelper { """, encoding: .utf8) - let pageNumber = Parser.parsePageNum(doc: document, host: Defaults.URL.exhentai) - let navigation = try #require(pageNumber.dateSeekNavigation) + let navigation = try #require(Parser.parseDateSeekNavigation(doc: document, host: Defaults.URL.exhentai)) let newerURL = try #require(navigation.newerURL) let olderURL = try #require(navigation.olderURL) @@ -112,8 +110,8 @@ struct ListParserTests: TestHelper { """, encoding: .utf8) - let pageNumber = Parser.parsePageNum(doc: document, host: Defaults.URL.ehentai) - let navigation = try #require(pageNumber.dateSeekNavigation) + let pageNumber = Parser.parsePageNum(doc: document) + let navigation = try #require(Parser.parseDateSeekNavigation(doc: document, host: Defaults.URL.ehentai)) #expect(pageNumber.current == 1) #expect(pageNumber.maximum == 2) From 2be9643a4cdd369a4cddfdbd3673cc72083f4edc Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 08:16:08 +0800 Subject: [PATCH 302/614] Code Review --- .swiftlint.yml | 8 +- EhPanda/App/Generated/Strings.swift | 8 +- EhPanda/App/Tools/Parser/Parser+Misc.swift | 3 +- EhPanda/App/en.lproj/Localizable.strings | 4 +- EhPanda/App/ja.lproj/Localizable.strings | 4 +- EhPanda/App/ko.lproj/Localizable.strings | 4 +- EhPanda/App/zh-Hans.lproj/Localizable.strings | 4 +- .../App/zh-Hant-HK.lproj/Localizable.strings | 4 +- .../App/zh-Hant-TW.lproj/Localizable.strings | 4 +- EhPanda/App/zh-Hant.lproj/Localizable.strings | 4 +- EhPanda/View/Detail/DetailView.swift | 2 +- EhPanda/View/Downloads/DownloadsView.swift | 22 +-- EhPanda/View/Favorites/FavoritesView.swift | 8 +- .../View/Home/Frontpage/FrontpageView.swift | 2 +- EhPanda/View/Home/Watched/WatchedView.swift | 2 +- EhPanda/View/Search/SearchView.swift | 2 +- .../Components/DateSeekPickerView.swift | 135 +++++++++++++++--- 17 files changed, 154 insertions(+), 66 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 537dd09d3..e0789333c 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -177,10 +177,10 @@ custom_rules: severity: error capture_group: 1 - system_name_parameter: - name: "systemName Parameter" - regex: "\\bsystemName\\s*:" - message: "`systemName` parameters should be avoided. Prefer `systemSymbol`." + system_name_image_parameter: + name: "systemName / systemImage Parameter" + regex: "\\b(?:systemName|systemImage)\\s*:" + message: "`systemName` and `systemImage` parameters should be avoided. Prefer `systemSymbol`." excluded_match_kinds: - comment - string diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 0d9af7797..397347f36 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -435,10 +435,10 @@ internal enum L10n { } internal enum DateSeekView { internal enum Button { - /// Seek Newer - internal static let seekNewer = L10n.tr("Localizable", "date_seek_view.button.seek_newer", fallback: "Seek Newer") - /// Seek Older - internal static let seekOlder = L10n.tr("Localizable", "date_seek_view.button.seek_older", fallback: "Seek Older") + /// Newer + internal static let seekNewer = L10n.tr("Localizable", "date_seek_view.button.seek_newer", fallback: "Newer") + /// Older + internal static let seekOlder = L10n.tr("Localizable", "date_seek_view.button.seek_older", fallback: "Older") } internal enum Footer { /// Seek to galleries around the selected date. diff --git a/EhPanda/App/Tools/Parser/Parser+Misc.swift b/EhPanda/App/Tools/Parser/Parser+Misc.swift index 9cc626369..9fabfe6fc 100644 --- a/EhPanda/App/Tools/Parser/Parser+Misc.swift +++ b/EhPanda/App/Tools/Parser/Parser+Misc.swift @@ -27,8 +27,7 @@ extension Parser { return apikey } - /// Parses the gallery-list pager. Date-seek navigation is a separate concern parsed via - /// `parseDateSeekNavigation`, so the page cursor stays independent of the jumpbar. + /// Parses the gallery-list pager. static func parsePageNum(doc: HTMLDocument) -> PageNumber { var current = 0 var maximum = 0 diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index f0c250a9f..a54afb184 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -62,8 +62,8 @@ "date_seek_view.title.date_seek" = "Seek to date"; "date_seek_view.title.date" = "Date"; "date_seek_view.footer.seek_around_date" = "Seek to galleries around the selected date."; -"date_seek_view.button.seek_newer" = "Seek Newer"; -"date_seek_view.button.seek_older" = "Seek Older"; +"date_seek_view.button.seek_newer" = "Newer"; +"date_seek_view.button.seek_older" = "Older"; // MARK: JumpPage "jump_page_view.title.jump_page" = "Jump page"; "jump_page_view.button.confirm" = "Confirm"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index b4ffde6a1..3e9afdb27 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -58,8 +58,8 @@ "date_seek_view.title.date_seek" = "日付指定"; "date_seek_view.title.date" = "日付"; "date_seek_view.footer.seek_around_date" = "選択した日付付近のギャラリーへ移動します。"; -"date_seek_view.button.seek_newer" = "新しい方へ"; -"date_seek_view.button.seek_older" = "古い方へ"; +"date_seek_view.button.seek_newer" = "新しい方"; +"date_seek_view.button.seek_older" = "古い方"; // MARK: JumpPage "jump_page_view.title.jump_page" = "ページジャンプ"; "jump_page_view.button.confirm" = "確認"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 8398b9f8c..70aaa4bb5 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -62,8 +62,8 @@ "date_seek_view.title.date_seek" = "날짜로 이동"; "date_seek_view.title.date" = "날짜"; "date_seek_view.footer.seek_around_date" = "선택한 날짜 근처의 갤러리로 이동합니다."; -"date_seek_view.button.seek_newer" = "새로운 쪽으로"; -"date_seek_view.button.seek_older" = "오래된 쪽으로"; +"date_seek_view.button.seek_newer" = "새로운 쪽"; +"date_seek_view.button.seek_older" = "오래된 쪽"; // MARK: AlertView "loading_view.title.loading" = "로딩 중..."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 99cd00bc4..70dffa7b8 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -62,8 +62,8 @@ "date_seek_view.title.date_seek" = "日期定位"; "date_seek_view.title.date" = "日期"; "date_seek_view.footer.seek_around_date" = "前往所选日期附近的画廊。"; -"date_seek_view.button.seek_newer" = "前往较新"; -"date_seek_view.button.seek_older" = "前往较旧"; +"date_seek_view.button.seek_newer" = "较新"; +"date_seek_view.button.seek_older" = "较旧"; // MARK: JumpPage "jump_page_view.title.jump_page" = "页码跳转"; "jump_page_view.button.confirm" = "确认"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index bc65be39a..08c8f97ca 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -58,8 +58,8 @@ "date_seek_view.title.date_seek" = "日期定位"; "date_seek_view.title.date" = "日期"; "date_seek_view.footer.seek_around_date" = "前往所選日期附近的畫廊。"; -"date_seek_view.button.seek_newer" = "前往較新"; -"date_seek_view.button.seek_older" = "前往較舊"; +"date_seek_view.button.seek_newer" = "較新"; +"date_seek_view.button.seek_older" = "較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 5e393c963..34cbc0aa9 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -58,8 +58,8 @@ "date_seek_view.title.date_seek" = "日期定位"; "date_seek_view.title.date" = "日期"; "date_seek_view.footer.seek_around_date" = "前往所選日期附近的畫廊。"; -"date_seek_view.button.seek_newer" = "前往較新"; -"date_seek_view.button.seek_older" = "前往較舊"; +"date_seek_view.button.seek_newer" = "較新"; +"date_seek_view.button.seek_older" = "較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index f1416750c..8b5f26f58 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -58,8 +58,8 @@ "date_seek_view.title.date_seek" = "日期定位"; "date_seek_view.title.date" = "日期"; "date_seek_view.footer.seek_around_date" = "前往所選日期附近的畫廊。"; -"date_seek_view.button.seek_newer" = "前往較新"; -"date_seek_view.button.seek_older" = "前往較舊"; +"date_seek_view.button.seek_newer" = "較新"; +"date_seek_view.button.seek_older" = "較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 9fbfdecc5..7431a9d8e 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -371,7 +371,7 @@ private extension DetailView { VStack(alignment: .leading, spacing: 10) { Label( L10n.Localizable.DetailView.OfflineNotice.savedDetails, - systemImage: "wifi.exclamationmark" + systemSymbol: .wifiExclamationmark ) .font(.subheadline.weight(.semibold)) .foregroundStyle(.orange) diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift index d396adaa5..c537dca84 100644 --- a/EhPanda/View/Downloads/DownloadsView.swift +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -205,7 +205,7 @@ private extension DownloadsView { } label: { Label( L10n.Localizable.DownloadsView.Swipe.Button.pages, - systemImage: "list.bullet.rectangle.portrait" + systemSymbol: .listBulletRectanglePortrait ) } .tint(setting.accentColor) @@ -229,7 +229,7 @@ private extension DownloadsView { } label: { Label( L10n.Localizable.DownloadsView.Swipe.Button.update, - systemImage: "arrow.triangle.2.circlepath" + systemSymbol: .arrowTrianglehead2ClockwiseRotate90 ) } .tint(.orange) @@ -243,9 +243,9 @@ private extension DownloadsView { download.displayStatus == .inactive ? L10n.Localizable.DownloadsView.Swipe.Button.resume : L10n.Localizable.DownloadsView.Swipe.Button.pause, - systemImage: download.displayStatus == .inactive - ? "play.fill" - : "pause.fill" + systemSymbol: download.displayStatus == .inactive + ? .playFill + : .pauseFill ) } .tint(download.displayStatus == .inactive ? .green : .indigo) @@ -269,7 +269,7 @@ private extension DownloadsView { } label: { Label( L10n.Localizable.DetailView.ContextMenu.Button.detail, - systemImage: "info.circle" + systemSymbol: .infoCircle ) } @@ -278,7 +278,7 @@ private extension DownloadsView { } label: { Label( L10n.Localizable.DownloadsView.Swipe.Button.pages, - systemImage: "list.bullet.rectangle.portrait" + systemSymbol: .listBulletRectanglePortrait ) } @@ -303,7 +303,7 @@ private extension DownloadsView { } label: { Label( L10n.Localizable.DownloadsView.Swipe.Button.update, - systemImage: "arrow.triangle.2.circlepath" + systemSymbol: .arrowTrianglehead2ClockwiseRotate90 ) } } @@ -316,9 +316,9 @@ private extension DownloadsView { download.displayStatus == .inactive ? L10n.Localizable.DownloadsView.Swipe.Button.resume : L10n.Localizable.DownloadsView.Swipe.Button.pause, - systemImage: download.displayStatus == .inactive - ? "play.fill" - : "pause.fill" + systemSymbol: download.displayStatus == .inactive + ? .playFill + : .pauseFill ) } } diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index 3b87e0a3b..0e6982e02 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -65,8 +65,8 @@ struct FavoritesView: View { } .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in DateSeekPickerView( - navigation: navigation.wrappedValue, selectedDate: $store.dateSeek.date, + navigation: navigation.wrappedValue, seekAction: { store.send(.dateSeek(.performSeek($0))) } ) .accentColor(setting.accentColor) @@ -135,10 +135,8 @@ struct FavoritesView: View { store.send(.fetchGalleries(nil, order)) } } - if store.pageNumber != nil { - DateSeekButton(navigation: store.dateSeekNavigation) { navigation in - store.send(.dateSeek(.present(navigation))) - } + DateSeekButton(navigation: store.dateSeekNavigation) { navigation in + store.send(.dateSeek(.present(navigation))) } QuickSearchButton(hideText: true) { store.send(.setNavigation(.quickSearch())) diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index 059dcb48f..ebdf0d563 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -46,8 +46,8 @@ struct FrontpageView: View { } .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in DateSeekPickerView( - navigation: navigation.wrappedValue, selectedDate: $store.dateSeek.date, + navigation: navigation.wrappedValue, seekAction: { store.send(.dateSeek(.performSeek($0))) } ) .accentColor(setting.accentColor) diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift index e82ddc845..e3e1f269e 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -62,8 +62,8 @@ struct WatchedView: View { } .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in DateSeekPickerView( - navigation: navigation.wrappedValue, selectedDate: $store.dateSeek.date, + navigation: navigation.wrappedValue, seekAction: { store.send(.dateSeek(.performSeek($0))) } ) .accentColor(setting.accentColor) diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index fc5c0b69c..052039621 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -58,8 +58,8 @@ struct SearchView: View { } .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in DateSeekPickerView( - navigation: navigation.wrappedValue, selectedDate: $store.dateSeek.date, + navigation: navigation.wrappedValue, seekAction: { store.send(.dateSeek(.performSeek($0))) } ) .accentColor(setting.accentColor) diff --git a/EhPanda/View/Support/Components/DateSeekPickerView.swift b/EhPanda/View/Support/Components/DateSeekPickerView.swift index 18da39c5e..de16bf45f 100644 --- a/EhPanda/View/Support/Components/DateSeekPickerView.swift +++ b/EhPanda/View/Support/Components/DateSeekPickerView.swift @@ -3,6 +3,7 @@ // EhPanda // +import SFSafeSymbols import SwiftUI /// The "Seek to date" sheet content: a graphical date picker plus newer/older direction buttons. @@ -10,18 +11,15 @@ import SwiftUI /// This is a store-agnostic, reusable component — it is driven entirely by the values passed in, /// not by a dedicated reducer. Hosts typically wire it to an embedded `DateSeekReducer`, but it /// has no dependency on one. +/// +/// - Precondition: `selectedDate` lies within `navigation.dateRange`. The picker renders the +/// binding as-is and does not clamp it; keeping the date in range is the responsibility of +/// whoever owns the date state (the embedded `DateSeekReducer` does so in its `present` action). struct DateSeekPickerView: View { - let navigation: DateSeekNavigation @Binding var selectedDate: Date + let navigation: DateSeekNavigation let seekAction: (DateSeekDirection) -> Void - private var showsNewerButton: Bool { - navigation.newerURL != nil - } - private var showsOlderButton: Bool { - navigation.olderURL != nil - } - var body: some View { NavigationView { Form { @@ -38,27 +36,120 @@ struct DateSeekPickerView: View { } Section { - if showsNewerButton { - Button { - seekAction(.newer) - } label: { - Label(L10n.Localizable.DateSeekView.Button.seekNewer, systemImage: "chevron.left") + let seekOlderButton = + SeekButton( + symbol: .chevronLeftChevronLeftDotted, + title: L10n.Localizable.DateSeekView.Button.seekOlder, + reversedIconTitlePosition: false, + action: { seekAction(.older) } + ) + .disabled(navigation.olderURL == nil) + + let seekNewerButton = + SeekButton( + symbol: .chevronRightDottedChevronRight, + title: L10n.Localizable.DateSeekView.Button.seekNewer, + reversedIconTitlePosition: true, + action: { seekAction(.newer) } + ) + .disabled(navigation.newerURL == nil) + + ViewThatFits(in: .horizontal) { + HStack { + seekOlderButton + Spacer(minLength: 8) + seekNewerButton } - } - if showsOlderButton { - Button { - seekAction(.older) - } label: { - Label(L10n.Localizable.DateSeekView.Button.seekOlder, systemImage: "chevron.right") + VStack { + seekOlderButton + .frame(maxWidth: .infinity, alignment: .leading) + + seekNewerButton + .frame(maxWidth: .infinity, alignment: .trailing) } } + .listRowBackground(Color.clear) + .listRowInsets(.init()) } } .navigationTitle(L10n.Localizable.DateSeekView.Title.dateSeek) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.large) } - .onAppear { - selectedDate = navigation.clampedDate(selectedDate) + } +} + +private struct SeekButton: View { + let symbol: SFSymbol + let title: String + let reversedIconTitlePosition: Bool + let action: () -> Void + + var symbolImage: some View { + Image(systemSymbol: symbol) + } + + var titleLabel: some View { + Text(title) + .font(.subheadline.bold()) + } + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + if reversedIconTitlePosition { + titleLabel + symbolImage + } else { + symbolImage + titleLabel + } + } + .lineLimit(1) + .padding(.vertical, 4) + .padding(.horizontal, 8) } + .buttonBorderShape(.buttonBorder) + .buttonStyle(.glass(.clear)) + } +} + +private extension DateSeekNavigation { + /// A navigation spanning a fixed sample range, used only to drive the previews below. + static func preview(_ directions: Directions) -> Self { + .init( + directions: directions, + minimumDate: dateFormatter.date(from: "2007-03-20").forceUnwrapped, + maximumDate: dateFormatter.date(from: "2023-09-08").forceUnwrapped + ) } } + +private let previewNewerURL: URL = .init(string: "https://e-hentai.org/?prev=2563984").forceUnwrapped +private let previewOlderURL: URL = .init(string: "https://e-hentai.org/?next=2668517").forceUnwrapped + +#Preview("Both directions") { + @Previewable @State var date: Date = DateSeekNavigation.dateFormatter.date(from: "2015-06-01").forceUnwrapped + DateSeekPickerView( + selectedDate: $date, + navigation: .preview(.both(newer: previewNewerURL, older: previewOlderURL)), + seekAction: { _ in } + ) +} + +#Preview("Newer only") { + @Previewable @State var date: Date = DateSeekNavigation.dateFormatter.date(from: "2015-06-01").forceUnwrapped + DateSeekPickerView( + selectedDate: $date, + navigation: .preview(.newer(previewNewerURL)), + seekAction: { _ in } + ) +} + +#Preview("Older only") { + @Previewable @State var date: Date = DateSeekNavigation.dateFormatter.date(from: "2015-06-01").forceUnwrapped + DateSeekPickerView( + selectedDate: $date, + navigation: .preview(.older(previewOlderURL)), + seekAction: { _ in } + ) +} From 4e9970abf1829880449ad4f84a67854eab6f412e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 14:54:06 +0800 Subject: [PATCH 303/614] Strip file header banners from all Swift files Remove the leading // name-banner comment block from all 337 Swift files under EhPanda/, EhPandaTests/, and ShareExtension/. Pre-modularization cleanup: the banners embed the module name (// EhPanda), so stripping them now avoids pointless header churn when files later move into AppPackage modules. Untouched: the generated Strings.swift (starts with a swiftlint directive) and the 16 Parser files that had no banner. In-code comments are preserved, including the LiveTextHandler swiftlint:disable line_length URL block. Verified: app builds and all 286 Swift Testing tests pass with no new warnings or lint violations (the pre-existing SwiftUINavigation_Extension iOS 16 deprecation remains the only warning). --- EhPanda/App/EhPandaApp.swift | 5 ----- EhPanda/App/Tools/Clients/AppDelegateClient.swift | 5 ----- EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift | 5 ----- EhPanda/App/Tools/Clients/AuthorizationClient.swift | 5 ----- EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift | 5 ----- EhPanda/App/Tools/Clients/BackgroundTaskClient.swift | 5 ----- EhPanda/App/Tools/Clients/ClipboardClient.swift | 5 ----- EhPanda/App/Tools/Clients/CookieClient.swift | 5 ----- EhPanda/App/Tools/Clients/DFClient.swift | 5 ----- EhPanda/App/Tools/Clients/DatabaseClient+Updates.swift | 5 ----- EhPanda/App/Tools/Clients/DatabaseClient.swift | 5 ----- EhPanda/App/Tools/Clients/DeviceClient.swift | 5 ----- .../Tools/Clients/DownloadClient+BackgroundAssertion.swift | 5 ----- .../Tools/Clients/DownloadClient+BackgroundDownloads.swift | 5 ----- .../Tools/Clients/DownloadClient+BackgroundProcessing.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadClient+Cache.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadClient+Execution.swift | 5 ----- .../App/Tools/Clients/DownloadClient+ExecutionFetch.swift | 5 ----- .../App/Tools/Clients/DownloadClient+ExecutionPerform.swift | 5 ----- .../App/Tools/Clients/DownloadClient+ExecutionSupport.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadClient+Folders.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadClient+Manager.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadClient+Networking.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift | 5 ----- .../Tools/Clients/DownloadClient+PageDownloadHelpers.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift | 5 ----- .../Tools/Clients/DownloadClient+PersistenceHelpers.swift | 5 ----- .../Tools/Clients/DownloadClient+PersistenceNormalize.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift | 5 ----- .../App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift | 5 ----- .../Tools/Clients/DownloadClient+ResponseValidation.swift | 5 ----- .../Clients/DownloadClient+ResponseValidationHelpers.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift | 5 ----- .../App/Tools/Clients/DownloadClient+SchedulingHelpers.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadClient+Testing.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadClient.swift | 5 ----- EhPanda/App/Tools/Clients/DownloadPageDownloader.swift | 5 ----- EhPanda/App/Tools/Clients/FileClient.swift | 5 ----- EhPanda/App/Tools/Clients/HapticsClient.swift | 5 ----- EhPanda/App/Tools/Clients/ImageClient.swift | 5 ----- EhPanda/App/Tools/Clients/LibraryClient.swift | 5 ----- EhPanda/App/Tools/Clients/LoggerClient.swift | 5 ----- EhPanda/App/Tools/Clients/UIApplicationClient.swift | 5 ----- EhPanda/App/Tools/Clients/URLClient.swift | 5 ----- EhPanda/App/Tools/Clients/UserDefaultsClient.swift | 5 ----- EhPanda/App/Tools/ColorCodable.swift | 4 ---- EhPanda/App/Tools/Defaults.swift | 5 ----- EhPanda/App/Tools/EnvironmentKeys.swift | 5 ----- EhPanda/App/Tools/EquatableVoid.swift | 5 ----- EhPanda/App/Tools/Extensions/AlertKit_Extension.swift | 5 ----- EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift | 5 ----- EhPanda/App/Tools/Extensions/Extensions.swift | 5 ----- EhPanda/App/Tools/Extensions/Reducer_Extension.swift | 5 ----- .../App/Tools/Extensions/SwiftUINavigation_Extension.swift | 5 ----- EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift | 5 ----- EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift | 5 ----- EhPanda/App/Tools/Extensions/ViewModifiers.swift | 5 ----- EhPanda/App/Tools/IdentifiableBox.swift | 5 ----- EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift | 5 ----- EhPanda/App/Tools/Utilities/AppUtil.swift | 5 ----- EhPanda/App/Tools/Utilities/CookieUtil.swift | 5 ----- EhPanda/App/Tools/Utilities/DataCache.swift | 5 ----- EhPanda/App/Tools/Utilities/DeviceUtil.swift | 5 ----- .../App/Tools/Utilities/DownloadBackgroundTaskStore.swift | 5 ----- EhPanda/App/Tools/Utilities/DownloadFileManager.swift | 5 ----- EhPanda/App/Tools/Utilities/DownloadQueueStore.swift | 5 ----- EhPanda/App/Tools/Utilities/DownloadStore+JSONCoding.swift | 5 ----- EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift | 5 ----- EhPanda/App/Tools/Utilities/DownloadStore.swift | 5 ----- EhPanda/App/Tools/Utilities/FileUtil.swift | 5 ----- EhPanda/App/Tools/Utilities/HapticsUtil.swift | 5 ----- .../App/Tools/Utilities/ImagePlaceholderFingerprint.swift | 5 ----- EhPanda/App/Tools/Utilities/MarkdownUtil.swift | 5 ----- EhPanda/App/Tools/Utilities/URLUtil.swift | 5 ----- EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift | 5 ----- EhPanda/DataFlow/AppDelegateReducer.swift | 5 ----- EhPanda/DataFlow/AppLockReducer.swift | 5 ----- EhPanda/DataFlow/AppReducer.swift | 5 ----- EhPanda/DataFlow/AppRouteReducer.swift | 5 ----- EhPanda/DataFlow/Heap.swift | 5 ----- .../FileManager/FileManager+ApplicationSupport.swift | 5 ----- .../NSManagedObjectModel+Compatible.swift | 5 ----- .../NSManagedObjectModel/NSManagedObjectModel+Resource.swift | 5 ----- .../NSPersistentStoreCoordinator+SQLite.swift | 5 ----- EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift | 5 ----- .../Database/MODefinition/AppEnvMO+CoreDataProperties.swift | 5 ----- .../MODefinition/GalleryDetailMO+CoreDataClass.swift | 5 ----- .../MODefinition/GalleryDetailMO+CoreDataProperties.swift | 5 ----- EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift | 5 ----- .../Database/MODefinition/GalleryMO+CoreDataProperties.swift | 5 ----- .../Database/MODefinition/GalleryStateMO+CoreDataClass.swift | 5 ----- .../MODefinition/GalleryStateMO+CoreDataProperties.swift | 5 ----- EhPanda/Database/Migration/CoreDataMigrationStep.swift | 5 ----- EhPanda/Database/Migration/CoreDataMigrationVersion.swift | 5 ----- EhPanda/Database/Migration/CoreDataMigrator.swift | 5 ----- .../Migration/Policies/Model5toModel6MigrationPolicy.swift | 5 ----- EhPanda/Database/Persistence.swift | 5 ----- EhPanda/Models/Download/DownloadBadge.swift | 5 ----- EhPanda/Models/Download/DownloadDisplayStatus.swift | 5 ----- EhPanda/Models/Download/DownloadFailure.swift | 5 ----- EhPanda/Models/Download/DownloadFolderFilter.swift | 5 ----- EhPanda/Models/Download/DownloadInspection.swift | 5 ----- EhPanda/Models/Download/DownloadProgress.swift | 5 ----- EhPanda/Models/Download/DownloadRequestOptions.swift | 5 ----- EhPanda/Models/Download/DownloadStartMode.swift | 5 ----- EhPanda/Models/Download/DownloadedGallery+Extensions.swift | 5 ----- EhPanda/Models/Download/DownloadedGallery+Manifest.swift | 5 ----- EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift | 5 ----- EhPanda/Models/Download/DownloadedGallery.swift | 5 ----- EhPanda/Models/Gallery/Category.swift | 5 ----- EhPanda/Models/Gallery/Gallery.swift | 5 ----- EhPanda/Models/Gallery/GalleryArchive.swift | 5 ----- EhPanda/Models/Gallery/GalleryComment.swift | 5 ----- EhPanda/Models/Gallery/GalleryDetail.swift | 5 ----- EhPanda/Models/Gallery/GalleryState.swift | 5 ----- EhPanda/Models/Gallery/GalleryTorrent.swift | 5 ----- EhPanda/Models/Gallery/Language.swift | 5 ----- EhPanda/Models/Persistent/AppEnv.swift | 5 ----- EhPanda/Models/Persistent/Filter.swift | 5 ----- EhPanda/Models/Persistent/Greeting.swift | 5 ----- EhPanda/Models/Persistent/Setting.swift | 5 ----- EhPanda/Models/Persistent/User.swift | 5 ----- EhPanda/Models/Support/AppError.swift | 5 ----- EhPanda/Models/Support/BrowsingCountry+EnglishName.swift | 5 ----- EhPanda/Models/Support/BrowsingCountry.swift | 5 ----- EhPanda/Models/Support/EhSetting+Enums.swift | 5 ----- EhPanda/Models/Support/EhSetting+Extensions.swift | 5 ----- EhPanda/Models/Support/EhSetting.swift | 5 ----- EhPanda/Models/Support/LiveText.swift | 5 ----- EhPanda/Models/Support/Misc.swift | 5 ----- EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift | 5 ----- EhPanda/Models/Tags/TagDetail.swift | 5 ----- EhPanda/Models/Tags/TagNamespace.swift | 5 ----- EhPanda/Models/Tags/TagSuggestion.swift | 5 ----- EhPanda/Models/Tags/TagTranslation.swift | 5 ----- EhPanda/Models/Tags/TagTranslator.swift | 5 ----- EhPanda/Models/Tags/TranslatableLanguage.swift | 5 ----- EhPanda/Network/DFExtensions.swift | 5 ----- EhPanda/Network/DFRequest.swift | 5 ----- EhPanda/Network/DFStreamHandler.swift | 5 ----- EhPanda/Network/DFURLProtocol.swift | 5 ----- EhPanda/Network/DomainResolver.swift | 5 ----- EhPanda/Network/Request+Account.swift | 5 ----- EhPanda/Network/Request+Detail.swift | 5 ----- EhPanda/Network/Request+Gallery.swift | 5 ----- EhPanda/Network/Request+Image.swift | 5 ----- EhPanda/Network/Request.swift | 4 ---- EhPanda/View/Detail/Archives/ArchivesReducer.swift | 5 ----- EhPanda/View/Detail/Archives/ArchivesView.swift | 5 ----- EhPanda/View/Detail/Comments/CommentsReducer.swift | 5 ----- EhPanda/View/Detail/Comments/CommentsView.swift | 5 ----- EhPanda/View/Detail/Components/LinkedText.swift | 4 ---- EhPanda/View/Detail/Components/PostCommentView.swift | 5 ----- EhPanda/View/Detail/Components/RatingView.swift | 5 ----- EhPanda/View/Detail/Components/TagDetailView.swift | 5 ----- EhPanda/View/Detail/DetailReducer+Actions.swift | 5 ----- EhPanda/View/Detail/DetailReducer+Download.swift | 5 ----- EhPanda/View/Detail/DetailReducer+Fetch.swift | 5 ----- EhPanda/View/Detail/DetailReducer.swift | 5 ----- EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift | 5 ----- EhPanda/View/Detail/DetailSearch/DetailSearchView.swift | 5 ----- EhPanda/View/Detail/DetailView+CommentCells.swift | 5 ----- EhPanda/View/Detail/DetailView+HeaderSection.swift | 5 ----- EhPanda/View/Detail/DetailView+Navigation.swift | 5 ----- EhPanda/View/Detail/DetailView+Subviews.swift | 5 ----- EhPanda/View/Detail/DetailView.swift | 5 ----- EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift | 5 ----- EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift | 5 ----- EhPanda/View/Detail/Previews/PreviewsReducer.swift | 5 ----- EhPanda/View/Detail/Previews/PreviewsView.swift | 5 ----- EhPanda/View/Detail/Torrents/TorrentsReducer.swift | 5 ----- EhPanda/View/Detail/Torrents/TorrentsView.swift | 5 ----- EhPanda/View/Downloads/DownloadInspectorReducer.swift | 5 ----- EhPanda/View/Downloads/DownloadsReducer.swift | 5 ----- EhPanda/View/Downloads/DownloadsView+Subviews.swift | 5 ----- EhPanda/View/Downloads/DownloadsView.swift | 5 ----- EhPanda/View/Downloads/FolderManagerReducer.swift | 5 ----- EhPanda/View/Downloads/FolderManagerView.swift | 5 ----- EhPanda/View/Favorites/FavoritesReducer.swift | 5 ----- EhPanda/View/Favorites/FavoritesView.swift | 5 ----- EhPanda/View/Home/Frontpage/FrontpageReducer.swift | 5 ----- EhPanda/View/Home/Frontpage/FrontpageView.swift | 5 ----- EhPanda/View/Home/History/HistoryReducer.swift | 5 ----- EhPanda/View/Home/History/HistoryView.swift | 5 ----- EhPanda/View/Home/HomeReducer+Body.swift | 5 ----- EhPanda/View/Home/HomeReducer.swift | 5 ----- EhPanda/View/Home/HomeView+Sections.swift | 5 ----- EhPanda/View/Home/HomeView.swift | 5 ----- EhPanda/View/Home/Popular/PopularReducer.swift | 5 ----- EhPanda/View/Home/Popular/PopularView.swift | 5 ----- EhPanda/View/Home/Toplists/ToplistsReducer.swift | 5 ----- EhPanda/View/Home/Toplists/ToplistsView.swift | 5 ----- EhPanda/View/Home/Watched/WatchedReducer.swift | 5 ----- EhPanda/View/Home/Watched/WatchedView.swift | 5 ----- EhPanda/View/Migration/MigrationReducer.swift | 5 ----- EhPanda/View/Migration/MigrationView.swift | 5 ----- EhPanda/View/Reading/ReadingReducer+Body.swift | 4 ---- EhPanda/View/Reading/ReadingReducer+Database.swift | 4 ---- EhPanda/View/Reading/ReadingReducer+ImageFetch.swift | 4 ---- EhPanda/View/Reading/ReadingReducer.swift | 5 ----- EhPanda/View/Reading/ReadingView+Gestures.swift | 5 ----- EhPanda/View/Reading/ReadingView.swift | 5 ----- EhPanda/View/Reading/ReadingViewComponents.swift | 4 ---- EhPanda/View/Reading/Support/AdvancedList.swift | 5 ----- EhPanda/View/Reading/Support/AutoPlayHandler.swift | 5 ----- EhPanda/View/Reading/Support/ControlPanel.swift | 5 ----- EhPanda/View/Reading/Support/GestureHandler.swift | 5 ----- EhPanda/View/Reading/Support/LiveTextHandler.swift | 4 ---- EhPanda/View/Reading/Support/LiveTextView.swift | 5 ----- EhPanda/View/Reading/Support/PageHandler.swift | 5 ----- EhPanda/View/Search/SearchReducer.swift | 5 ----- EhPanda/View/Search/SearchRootReducer.swift | 5 ----- EhPanda/View/Search/SearchRootView+Keywords.swift | 5 ----- EhPanda/View/Search/SearchRootView.swift | 5 ----- EhPanda/View/Search/SearchView.swift | 5 ----- EhPanda/View/Search/Support/QuickSearchReducer.swift | 5 ----- EhPanda/View/Search/Support/QuickSearchView.swift | 5 ----- .../View/Setting/AccountSetting/AccountSettingReducer.swift | 5 ----- EhPanda/View/Setting/AccountSetting/AccountSettingView.swift | 5 ----- .../Setting/AppearanceSetting/AppearanceSettingReducer.swift | 5 ----- .../Setting/AppearanceSetting/AppearanceSettingView.swift | 5 ----- EhPanda/View/Setting/Components/AboutView.swift | 5 ----- EhPanda/View/Setting/Components/DownloadSettingView.swift | 5 ----- EhPanda/View/Setting/Components/LaboratorySettingView.swift | 5 ----- EhPanda/View/Setting/Components/ReadingSettingView.swift | 5 ----- EhPanda/View/Setting/Components/WebView.swift | 5 ----- EhPanda/View/Setting/EhSetting/EhSettingReducer.swift | 5 ----- EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift | 5 ----- EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift | 5 ----- EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift | 5 ----- EhPanda/View/Setting/EhSetting/EhSettingView.swift | 5 ----- .../View/Setting/GeneralSetting/GeneralSettingReducer.swift | 5 ----- EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift | 5 ----- EhPanda/View/Setting/Login/LoginReducer.swift | 5 ----- EhPanda/View/Setting/Login/LoginView.swift | 5 ----- EhPanda/View/Setting/Logs/LogsReducer.swift | 5 ----- EhPanda/View/Setting/Logs/LogsView.swift | 5 ----- EhPanda/View/Setting/SettingReducer+Body.swift | 5 ----- EhPanda/View/Setting/SettingReducer+Helpers.swift | 5 ----- EhPanda/View/Setting/SettingReducer.swift | 5 ----- EhPanda/View/Setting/SettingView.swift | 5 ----- EhPanda/View/Support/Components/ActivityView.swift | 5 ----- EhPanda/View/Support/Components/AlertView.swift | 5 ----- EhPanda/View/Support/Components/CategoryView.swift | 5 ----- EhPanda/View/Support/Components/Cells/GalleryCardCell.swift | 5 ----- .../View/Support/Components/Cells/GalleryDetailCell.swift | 5 ----- .../View/Support/Components/Cells/GalleryHistoryCell.swift | 5 ----- .../View/Support/Components/Cells/GalleryRankingCell.swift | 5 ----- .../View/Support/Components/Cells/GalleryThumbnailCell.swift | 5 ----- EhPanda/View/Support/Components/DateSeekPickerView.swift | 5 ----- EhPanda/View/Support/Components/DownloadBadgeLabel.swift | 5 ----- EhPanda/View/Support/Components/GenericList.swift | 5 ----- EhPanda/View/Support/Components/Placeholder.swift | 5 ----- EhPanda/View/Support/Components/PreviewImageView.swift | 5 ----- EhPanda/View/Support/Components/SettingTextField.swift | 5 ----- EhPanda/View/Support/Components/SubSection.swift | 5 ----- EhPanda/View/Support/Components/TagCloudView.swift | 4 ---- EhPanda/View/Support/Components/TagSuggestionView.swift | 5 ----- EhPanda/View/Support/Components/ToolbarItems.swift | 5 ----- EhPanda/View/Support/Components/WaveForm.swift | 4 ---- EhPanda/View/Support/DateSeekReducer.swift | 5 ----- EhPanda/View/Support/FiltersReducer.swift | 5 ----- EhPanda/View/Support/FiltersView.swift | 5 ----- EhPanda/View/Support/NewDawnView.swift | 5 ----- EhPanda/View/TabBar/TabBarReducer.swift | 5 ----- EhPanda/View/TabBar/TabBarView.swift | 5 ----- EhPandaTests/Models/HTMLFilename.swift | 5 ----- EhPandaTests/Models/ListParserTestType.swift | 5 ----- EhPandaTests/Models/TestError.swift | 5 ----- EhPandaTests/Resources/Utility/TestHelper.swift | 5 ----- EhPandaTests/Tests/Download/DataCacheTests.swift | 5 ----- EhPandaTests/Tests/Download/DatabaseClientUpdateTests.swift | 5 ----- EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift | 5 ----- EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift | 5 ----- .../Tests/Download/DetailReducerMetadataUpdateTests.swift | 5 ----- EhPandaTests/Tests/Download/DetailReducerObserveTests.swift | 5 ----- .../Tests/Download/DetailReducerPauseAndGuardTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadAutomationTests.swift | 5 ----- .../Tests/Download/DownloadBackgroundAssertionTests.swift | 5 ----- .../Tests/Download/DownloadBackgroundCompletionTests.swift | 5 ----- .../Tests/Download/DownloadBackgroundProcessingTests.swift | 5 ----- .../Tests/Download/DownloadBackgroundTaskStoreTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift | 5 ----- .../Tests/Download/DownloadCoordinatorCachedURLTests.swift | 5 ----- .../Tests/Download/DownloadCoordinatorCaptureTests.swift | 5 ----- .../Tests/Download/DownloadCoordinatorRepairSeedTests.swift | 5 ----- .../Tests/Download/DownloadCoordinatorStorageTests.swift | 5 ----- .../Tests/Download/DownloadEnqueueManifestTests.swift | 5 ----- .../Tests/Download/DownloadFeatureTestFactories.swift | 5 ----- EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift | 5 ----- .../Tests/Download/DownloadFeatureTestSupportTypes.swift | 5 ----- .../Tests/Download/DownloadFilterAndBadgeTests.swift | 5 ----- .../Tests/Download/DownloadFolderOperationTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadImageErrorTests.swift | 5 ----- .../Tests/Download/DownloadImageParsingCacheTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadImageParsingTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift | 5 ----- .../Tests/Download/DownloadInspectorRetryTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift | 5 ----- .../Tests/Download/DownloadInterruptedResumeTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadIpBanTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift | 5 ----- .../Tests/Download/DownloadObserverReadingTests.swift | 5 ----- .../Tests/Download/DownloadObserverRefreshTests.swift | 5 ----- .../Tests/Download/DownloadPauseAndReconcileTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadProcessTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadQueueStoreTests.swift | 5 ----- .../Tests/Download/DownloadRetryMinimalSourceTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift | 5 ----- .../Tests/Download/DownloadRetryUpdateFallbackTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadSchedulingTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadStoreHashTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadStoreRepairTests.swift | 5 ----- EhPandaTests/Tests/Download/DownloadStoreTests.swift | 5 ----- .../Tests/Download/DownloadVersionSignatureTests.swift | 5 ----- .../Tests/Download/DownloadedGalleryManifestModelTests.swift | 5 ----- .../Tests/Download/DownloadsReducerActionTests.swift | 5 ----- .../Tests/Download/DownloadsReducerReadingDismissTests.swift | 5 ----- .../Tests/Download/DownloadsReducerRefreshTests.swift | 5 ----- EhPandaTests/Tests/Download/FolderManagerReducerTests.swift | 5 ----- .../Tests/Download/PreviewsReducerDownloadTests.swift | 5 ----- EhPandaTests/Tests/Download/ReaderImageDataTests.swift | 5 ----- .../Tests/Download/ReadingReducerDownloadTests.swift | 5 ----- EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift | 5 ----- .../Tests/Parser/Gallery/GalleryDetailParserTests.swift | 5 ----- .../Tests/Parser/Gallery/GalleryImageURLParserTests.swift | 5 ----- .../Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift | 5 ----- EhPandaTests/Tests/Parser/List/ListParserTests.swift | 5 ----- EhPandaTests/Tests/Parser/Other/AnimatedImageDataTests.swift | 5 ----- EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift | 5 ----- .../Tests/Parser/Other/DownloadPageErrorParserTests.swift | 5 ----- EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift | 5 ----- EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift | 5 ----- EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift | 5 ----- ShareExtension/ShareViewController.swift | 5 ----- 337 files changed, 1675 deletions(-) diff --git a/EhPanda/App/EhPandaApp.swift b/EhPanda/App/EhPandaApp.swift index 80ec487f9..d9a7b4478 100644 --- a/EhPanda/App/EhPandaApp.swift +++ b/EhPanda/App/EhPandaApp.swift @@ -1,8 +1,3 @@ -// -// EhPandaApp.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/AppDelegateClient.swift b/EhPanda/App/Tools/Clients/AppDelegateClient.swift index b484f7d14..109ac06c7 100644 --- a/EhPanda/App/Tools/Clients/AppDelegateClient.swift +++ b/EhPanda/App/Tools/Clients/AppDelegateClient.swift @@ -1,8 +1,3 @@ -// -// AppDelegateClient.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift b/EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift index 5a12f3378..a3b9fa6b5 100644 --- a/EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift +++ b/EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift @@ -1,8 +1,3 @@ -// -// AppLaunchAutomationClient.swift -// EhPanda -// - import ComposableArchitecture @DependencyClient diff --git a/EhPanda/App/Tools/Clients/AuthorizationClient.swift b/EhPanda/App/Tools/Clients/AuthorizationClient.swift index 1f3a791bc..b7f02e1ad 100644 --- a/EhPanda/App/Tools/Clients/AuthorizationClient.swift +++ b/EhPanda/App/Tools/Clients/AuthorizationClient.swift @@ -1,8 +1,3 @@ -// -// AuthorizationClient.swift -// EhPanda -// - import Combine import LocalAuthentication import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift b/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift index 7e2244c7c..0ae72511e 100644 --- a/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift +++ b/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift @@ -1,8 +1,3 @@ -// -// BackgroundProcessingClient.swift -// EhPanda -// - import BackgroundTasks import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/BackgroundTaskClient.swift b/EhPanda/App/Tools/Clients/BackgroundTaskClient.swift index af41eb190..4ea384191 100644 --- a/EhPanda/App/Tools/Clients/BackgroundTaskClient.swift +++ b/EhPanda/App/Tools/Clients/BackgroundTaskClient.swift @@ -1,8 +1,3 @@ -// -// BackgroundTaskClient.swift -// EhPanda -// - import UIKit import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/ClipboardClient.swift b/EhPanda/App/Tools/Clients/ClipboardClient.swift index 70ef7479e..c9768f44d 100644 --- a/EhPanda/App/Tools/Clients/ClipboardClient.swift +++ b/EhPanda/App/Tools/Clients/ClipboardClient.swift @@ -1,8 +1,3 @@ -// -// ClipboardClient.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift index 2f6a22880..b869ae47d 100644 --- a/EhPanda/App/Tools/Clients/CookieClient.swift +++ b/EhPanda/App/Tools/Clients/CookieClient.swift @@ -1,8 +1,3 @@ -// -// CookieClient.swift -// EhPanda -// - import Foundation import ComposableArchitecture #if DEBUG diff --git a/EhPanda/App/Tools/Clients/DFClient.swift b/EhPanda/App/Tools/Clients/DFClient.swift index 389095876..e8f363586 100644 --- a/EhPanda/App/Tools/Clients/DFClient.swift +++ b/EhPanda/App/Tools/Clients/DFClient.swift @@ -1,8 +1,3 @@ -// -// DFClient.swift -// EhPanda -// - import Foundation import Kingfisher import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/DatabaseClient+Updates.swift b/EhPanda/App/Tools/Clients/DatabaseClient+Updates.swift index 8e9e8c944..11678c1b2 100644 --- a/EhPanda/App/Tools/Clients/DatabaseClient+Updates.swift +++ b/EhPanda/App/Tools/Clients/DatabaseClient+Updates.swift @@ -1,8 +1,3 @@ -// -// DatabaseClient+Updates.swift -// EhPanda -// - import SwiftUI import CoreData diff --git a/EhPanda/App/Tools/Clients/DatabaseClient.swift b/EhPanda/App/Tools/Clients/DatabaseClient.swift index 47eb72f07..8a2143c88 100644 --- a/EhPanda/App/Tools/Clients/DatabaseClient.swift +++ b/EhPanda/App/Tools/Clients/DatabaseClient.swift @@ -1,8 +1,3 @@ -// -// DatabaseClient.swift -// EhPanda -// - import SwiftUI import Combine import CoreData diff --git a/EhPanda/App/Tools/Clients/DeviceClient.swift b/EhPanda/App/Tools/Clients/DeviceClient.swift index 9abbc8c4b..efe3bcd22 100644 --- a/EhPanda/App/Tools/Clients/DeviceClient.swift +++ b/EhPanda/App/Tools/Clients/DeviceClient.swift @@ -1,8 +1,3 @@ -// -// DeviceClient.swift -// EhPanda -// - import SwiftUI import Dependencies diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundAssertion.swift b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundAssertion.swift index ec56e3f3e..14a59267f 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundAssertion.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundAssertion.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+BackgroundAssertion.swift -// EhPanda -// - import Foundation // MARK: - Background Execution Assertion diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift index 0f83f9c4a..9ff520cf7 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+BackgroundDownloads.swift -// EhPanda -// - import Foundation actor BackgroundPageCompletionReceiver { diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundProcessing.swift b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundProcessing.swift index 21deb3bea..e5979e0a5 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundProcessing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+BackgroundProcessing.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+BackgroundProcessing.swift -// EhPanda -// - import Foundation // MARK: - Background Processing Drain diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift index 39ba9dc68..f25bacd47 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+Cache.swift -// EhPanda -// - import Foundation // MARK: - Cache Operations diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift index a5645df20..4806b99f9 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+Execution.swift -// EhPanda -// - import Foundation // MARK: - Process Download diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift index 7c4059f90..646a86cf5 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+ExecutionFetch.swift -// EhPanda -// - import Foundation // MARK: - Fetch & Normalize Payload diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift index 047ce9f4d..fa1dbff8d 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+ExecutionPerform.swift -// EhPanda -// - import Foundation // MARK: - Perform Download diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift index 72d4ec0fb..d37487b28 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+ExecutionSupport.swift -// EhPanda -// - import Foundation // MARK: - Execution Support diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift index 096901eb0..67c434d1c 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+Folders.swift -// EhPanda -// - import Foundation // MARK: - User Folder Operations diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift index 66ca4f6f7..7761dc7a6 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+Manager.swift -// EhPanda -// - import Foundation typealias ScheduledDownloadOperation = @Sendable () async -> Void diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift index 71a256c5a..2c2131083 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+Networking.swift -// EhPanda -// - import Foundation import UniformTypeIdentifiers diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift index db87de724..86efa0e0e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+PageDownload.swift -// EhPanda -// - import Foundation // MARK: - Download Pages diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift index e7a31954a..afa674405 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+PageDownloadHelpers.swift -// EhPanda -// - import Foundation // MARK: - Download Single Page diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift index 9b6cf8f82..4f7e9d50f 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+Persistence.swift -// EhPanda -// - import Foundation // MARK: - Disk Index diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift index 99e1ecaf7..a95e05051 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+PersistenceHelpers.swift -// EhPanda -// - import Foundation // MARK: - Sanitization diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift index 0a59c9180..cfea30674 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+PersistenceNormalize.swift -// EhPanda -// - import Foundation // MARK: - Manifest, Folder & Normalize diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift index d6bfe52d9..1e4a56d18 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+PublicAPI.swift -// EhPanda -// - import Foundation // MARK: - Public API diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index 37e2475e5..82b9bf36a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+PublicAPIHelpers.swift -// EhPanda -// - import Foundation // MARK: - Private helpers for public API diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift index 0aff24a71..7623a2a8a 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+ResponseValidation.swift -// EhPanda -// - import Kanna import Foundation import ImageIO diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index ce8d92744..93aa20110 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+ResponseValidationHelpers.swift -// EhPanda -// - import Kanna import Foundation import ImageIO diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift index 2bc107151..66ee01c26 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+RetryHelpers.swift -// EhPanda -// - import Foundation // MARK: - Retry & RetryPages diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift index cb3210b8f..e72c01bce 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+Scheduling.swift -// EhPanda -// - import Foundation // MARK: - Observer Management & Scheduling diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift index 5886ad08f..a663f8439 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+SchedulingHelpers.swift -// EhPanda -// - import Foundation // MARK: - Mode Resolution diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift index 01220edab..9e2bc077e 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -1,8 +1,3 @@ -// -// DownloadClient+Testing.swift -// EhPanda -// - import Foundation #if DEBUG diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift index 74a73a975..653280112 100644 --- a/EhPanda/App/Tools/Clients/DownloadClient.swift +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -1,8 +1,3 @@ -// -// DownloadClient.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift index caba3b84e..43f181fd9 100644 --- a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift +++ b/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift @@ -1,8 +1,3 @@ -// -// DownloadPageDownloader.swift -// EhPanda -// - import Foundation struct DownloadPageTaskContext: Equatable, Sendable { diff --git a/EhPanda/App/Tools/Clients/FileClient.swift b/EhPanda/App/Tools/Clients/FileClient.swift index 1dfa0f995..7891201a7 100644 --- a/EhPanda/App/Tools/Clients/FileClient.swift +++ b/EhPanda/App/Tools/Clients/FileClient.swift @@ -1,8 +1,3 @@ -// -// FileClient.swift -// EhPanda -// - import Combine import Foundation import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/HapticsClient.swift b/EhPanda/App/Tools/Clients/HapticsClient.swift index 58eef165c..23bc698c1 100644 --- a/EhPanda/App/Tools/Clients/HapticsClient.swift +++ b/EhPanda/App/Tools/Clients/HapticsClient.swift @@ -1,8 +1,3 @@ -// -// HapticsClient.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index c87d5c338..7719b480d 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -1,8 +1,3 @@ -// -// ImageClient.swift -// EhPanda -// - import Photos import SwiftUI import Combine diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index a924e713b..fd8b0d30c 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -1,8 +1,3 @@ -// -// LibraryClient.swift -// EhPanda -// - import SwiftUI import Combine import Foundation diff --git a/EhPanda/App/Tools/Clients/LoggerClient.swift b/EhPanda/App/Tools/Clients/LoggerClient.swift index e24df02ad..bb6cc0b8b 100644 --- a/EhPanda/App/Tools/Clients/LoggerClient.swift +++ b/EhPanda/App/Tools/Clients/LoggerClient.swift @@ -1,8 +1,3 @@ -// -// LoggerClient.swift -// EhPanda -// - import ComposableArchitecture struct LoggerClient: Sendable { diff --git a/EhPanda/App/Tools/Clients/UIApplicationClient.swift b/EhPanda/App/Tools/Clients/UIApplicationClient.swift index 3bc539b6b..3afe606fc 100644 --- a/EhPanda/App/Tools/Clients/UIApplicationClient.swift +++ b/EhPanda/App/Tools/Clients/UIApplicationClient.swift @@ -1,8 +1,3 @@ -// -// UIApplicationClient.swift -// EhPanda -// - import SwiftUI import Combine import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/URLClient.swift b/EhPanda/App/Tools/Clients/URLClient.swift index 092ba9d35..e4657e46e 100644 --- a/EhPanda/App/Tools/Clients/URLClient.swift +++ b/EhPanda/App/Tools/Clients/URLClient.swift @@ -1,8 +1,3 @@ -// -// URLClient.swift -// EhPanda -// - import SwiftUI import Dependencies diff --git a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift index 8e747a7ec..12cbfefb5 100644 --- a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift +++ b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift @@ -1,8 +1,3 @@ -// -// UserDefaultsClient.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/App/Tools/ColorCodable.swift b/EhPanda/App/Tools/ColorCodable.swift index 4e50b0bc2..5becd3d9b 100644 --- a/EhPanda/App/Tools/ColorCodable.swift +++ b/EhPanda/App/Tools/ColorCodable.swift @@ -1,7 +1,3 @@ -// -// ColorCodable.swift -// EhPanda -// // Copied from https://brunowernimont.me/howtos/make-swiftui-color-codable // diff --git a/EhPanda/App/Tools/Defaults.swift b/EhPanda/App/Tools/Defaults.swift index b14cd412e..2a4d08fbd 100644 --- a/EhPanda/App/Tools/Defaults.swift +++ b/EhPanda/App/Tools/Defaults.swift @@ -1,8 +1,3 @@ -// -// Defaults.swift -// EhPanda -// - import UIKit import Foundation diff --git a/EhPanda/App/Tools/EnvironmentKeys.swift b/EhPanda/App/Tools/EnvironmentKeys.swift index 583f6cc33..f468b0b6e 100644 --- a/EhPanda/App/Tools/EnvironmentKeys.swift +++ b/EhPanda/App/Tools/EnvironmentKeys.swift @@ -1,8 +1,3 @@ -// -// EnvironmentKeys.swift -// EhPanda -// - import SwiftUI struct InSheetKey: EnvironmentKey { diff --git a/EhPanda/App/Tools/EquatableVoid.swift b/EhPanda/App/Tools/EquatableVoid.swift index d2d3dc744..ff7172376 100644 --- a/EhPanda/App/Tools/EquatableVoid.swift +++ b/EhPanda/App/Tools/EquatableVoid.swift @@ -1,8 +1,3 @@ -// -// EquatableVoid.swift -// EhPanda -// - import Foundation public struct EquatableVoid: Hashable, Sendable, Identifiable { diff --git a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift index 16574b855..24ea6fc9c 100644 --- a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift +++ b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift @@ -1,8 +1,3 @@ -// -// AlertKit_Extension.swift -// EhPanda -// - import SwiftUI import AlertKit diff --git a/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift index ea3a4098b..33364acbf 100644 --- a/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift +++ b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift @@ -1,8 +1,3 @@ -// -// AnimatedImage_Extension.swift -// EhPanda -// - import UIKit import SDWebImage import UniformTypeIdentifiers diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift index d7e0db7c1..135ded190 100644 --- a/EhPanda/App/Tools/Extensions/Extensions.swift +++ b/EhPanda/App/Tools/Extensions/Extensions.swift @@ -1,8 +1,3 @@ -// -// Extensions.swift -// EhPanda -// - import SwiftUI import Foundation diff --git a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift index 5c73c9778..5ecd9e1cf 100644 --- a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift +++ b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift @@ -1,8 +1,3 @@ -// -// Reducer_Extension.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift b/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift index 219ae1d49..83cceb609 100644 --- a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift +++ b/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift @@ -1,8 +1,3 @@ -// -// SwiftUINavigation_Extension.swift -// EhPanda -// - import SwiftUI import TTProgressHUD import SwiftUINavigation diff --git a/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift b/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift index 9aa23bd96..06375f924 100644 --- a/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift +++ b/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift @@ -1,8 +1,3 @@ -// -// TTProgressHUD_Extension.swift -// EhPanda -// - import TTProgressHUD enum ProgressHUDConfigState: Equatable, Sendable { diff --git a/EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift b/EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift index ff761a1d9..15c7ed134 100644 --- a/EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift +++ b/EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift @@ -1,8 +1,3 @@ -// -// URL+ImageCacheKey.swift -// EhPanda -// - import Foundation extension URL { diff --git a/EhPanda/App/Tools/Extensions/ViewModifiers.swift b/EhPanda/App/Tools/Extensions/ViewModifiers.swift index d3e905ba8..c665709b0 100644 --- a/EhPanda/App/Tools/Extensions/ViewModifiers.swift +++ b/EhPanda/App/Tools/Extensions/ViewModifiers.swift @@ -1,8 +1,3 @@ -// -// ViewModifiers.swift -// EhPanda -// - import SwiftUI import Kingfisher diff --git a/EhPanda/App/Tools/IdentifiableBox.swift b/EhPanda/App/Tools/IdentifiableBox.swift index 91d12811e..6c72026c9 100644 --- a/EhPanda/App/Tools/IdentifiableBox.swift +++ b/EhPanda/App/Tools/IdentifiableBox.swift @@ -1,8 +1,3 @@ -// -// IdentifiableBox.swift -// EhPanda -// - import Foundation public struct IdentifiableBox: Identifiable { diff --git a/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift b/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift index 3596919dc..6f41945f4 100644 --- a/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift +++ b/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift @@ -1,8 +1,3 @@ -// -// AppLaunchAutomation.swift -// EhPanda -// - import Foundation struct AppLaunchAutomation: Sendable { diff --git a/EhPanda/App/Tools/Utilities/AppUtil.swift b/EhPanda/App/Tools/Utilities/AppUtil.swift index a73c66018..91cded213 100644 --- a/EhPanda/App/Tools/Utilities/AppUtil.swift +++ b/EhPanda/App/Tools/Utilities/AppUtil.swift @@ -1,8 +1,3 @@ -// -// AppUtil.swift -// EhPanda -// - import Foundation struct AppUtil { diff --git a/EhPanda/App/Tools/Utilities/CookieUtil.swift b/EhPanda/App/Tools/Utilities/CookieUtil.swift index 15225db3b..5b0b32702 100644 --- a/EhPanda/App/Tools/Utilities/CookieUtil.swift +++ b/EhPanda/App/Tools/Utilities/CookieUtil.swift @@ -1,8 +1,3 @@ -// -// CookieUtil.swift -// EhPanda -// - import Foundation // MARK: Cookie diff --git a/EhPanda/App/Tools/Utilities/DataCache.swift b/EhPanda/App/Tools/Utilities/DataCache.swift index dfd9226e2..1052379d9 100644 --- a/EhPanda/App/Tools/Utilities/DataCache.swift +++ b/EhPanda/App/Tools/Utilities/DataCache.swift @@ -1,8 +1,3 @@ -// -// DataCache.swift -// EhPanda -// - import CryptoKit import Foundation import UIKit diff --git a/EhPanda/App/Tools/Utilities/DeviceUtil.swift b/EhPanda/App/Tools/Utilities/DeviceUtil.swift index 23018c871..2c63487b8 100644 --- a/EhPanda/App/Tools/Utilities/DeviceUtil.swift +++ b/EhPanda/App/Tools/Utilities/DeviceUtil.swift @@ -1,8 +1,3 @@ -// -// DeviceUtil.swift -// EhPanda -// - import SwiftUI import Foundation diff --git a/EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift b/EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift index bcaaf9af9..143187daf 100644 --- a/EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift +++ b/EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift @@ -1,8 +1,3 @@ -// -// DownloadBackgroundTaskStore.swift -// EhPanda -// - import Foundation actor DownloadBackgroundTaskStore { diff --git a/EhPanda/App/Tools/Utilities/DownloadFileManager.swift b/EhPanda/App/Tools/Utilities/DownloadFileManager.swift index dc6aeec4b..60c9ec2b9 100644 --- a/EhPanda/App/Tools/Utilities/DownloadFileManager.swift +++ b/EhPanda/App/Tools/Utilities/DownloadFileManager.swift @@ -1,8 +1,3 @@ -// -// DownloadFileManager.swift -// EhPanda -// - import Foundation import Synchronization diff --git a/EhPanda/App/Tools/Utilities/DownloadQueueStore.swift b/EhPanda/App/Tools/Utilities/DownloadQueueStore.swift index c3e0e99fa..3d04e6e8e 100644 --- a/EhPanda/App/Tools/Utilities/DownloadQueueStore.swift +++ b/EhPanda/App/Tools/Utilities/DownloadQueueStore.swift @@ -1,8 +1,3 @@ -// -// DownloadQueueStore.swift -// EhPanda -// - import ComposableArchitecture import Foundation diff --git a/EhPanda/App/Tools/Utilities/DownloadStore+JSONCoding.swift b/EhPanda/App/Tools/Utilities/DownloadStore+JSONCoding.swift index 87d97987c..208b5ac2f 100644 --- a/EhPanda/App/Tools/Utilities/DownloadStore+JSONCoding.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore+JSONCoding.swift @@ -1,8 +1,3 @@ -// -// DownloadStore+JSONCoding.swift -// EhPanda -// - import Foundation extension DownloadStore { diff --git a/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift index e18a2aa38..c9eb752bb 100644 --- a/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift @@ -1,8 +1,3 @@ -// -// DownloadStore+Operations.swift -// EhPanda -// - import Foundation extension DownloadStore { diff --git a/EhPanda/App/Tools/Utilities/DownloadStore.swift b/EhPanda/App/Tools/Utilities/DownloadStore.swift index 2da6a932e..e1061592f 100644 --- a/EhPanda/App/Tools/Utilities/DownloadStore.swift +++ b/EhPanda/App/Tools/Utilities/DownloadStore.swift @@ -1,8 +1,3 @@ -// -// DownloadStore.swift -// EhPanda -// - import Foundation import CryptoKit diff --git a/EhPanda/App/Tools/Utilities/FileUtil.swift b/EhPanda/App/Tools/Utilities/FileUtil.swift index 592779bf9..70a0e0800 100644 --- a/EhPanda/App/Tools/Utilities/FileUtil.swift +++ b/EhPanda/App/Tools/Utilities/FileUtil.swift @@ -1,8 +1,3 @@ -// -// FileUtil.swift -// EhPanda -// - import Foundation struct FileUtil { diff --git a/EhPanda/App/Tools/Utilities/HapticsUtil.swift b/EhPanda/App/Tools/Utilities/HapticsUtil.swift index 27ae1d054..dd8d68c0b 100644 --- a/EhPanda/App/Tools/Utilities/HapticsUtil.swift +++ b/EhPanda/App/Tools/Utilities/HapticsUtil.swift @@ -1,8 +1,3 @@ -// -// HapticsUtil.swift -// EhPanda -// - import SwiftUI import AudioToolbox diff --git a/EhPanda/App/Tools/Utilities/ImagePlaceholderFingerprint.swift b/EhPanda/App/Tools/Utilities/ImagePlaceholderFingerprint.swift index 4395d6b62..f7313d488 100644 --- a/EhPanda/App/Tools/Utilities/ImagePlaceholderFingerprint.swift +++ b/EhPanda/App/Tools/Utilities/ImagePlaceholderFingerprint.swift @@ -1,8 +1,3 @@ -// -// ImagePlaceholderFingerprint.swift -// EhPanda -// - import CryptoKit import Foundation diff --git a/EhPanda/App/Tools/Utilities/MarkdownUtil.swift b/EhPanda/App/Tools/Utilities/MarkdownUtil.swift index e735a3420..f39bcb21d 100644 --- a/EhPanda/App/Tools/Utilities/MarkdownUtil.swift +++ b/EhPanda/App/Tools/Utilities/MarkdownUtil.swift @@ -1,8 +1,3 @@ -// -// MarkdownUtil.swift -// EhPanda -// - import CasePaths import CommonMark import Foundation diff --git a/EhPanda/App/Tools/Utilities/URLUtil.swift b/EhPanda/App/Tools/Utilities/URLUtil.swift index e16fd1150..fec216814 100644 --- a/EhPanda/App/Tools/Utilities/URLUtil.swift +++ b/EhPanda/App/Tools/Utilities/URLUtil.swift @@ -1,8 +1,3 @@ -// -// URLUtil.swift -// EhPanda -// - import Foundation struct URLUtil { diff --git a/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift b/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift index f727a2d06..502ed429d 100644 --- a/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift +++ b/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift @@ -1,8 +1,3 @@ -// -// UserDefaultsUtil.swift -// EhPanda -// - import Foundation struct UserDefaultsUtil { diff --git a/EhPanda/DataFlow/AppDelegateReducer.swift b/EhPanda/DataFlow/AppDelegateReducer.swift index 970582ac3..da0dfadd0 100644 --- a/EhPanda/DataFlow/AppDelegateReducer.swift +++ b/EhPanda/DataFlow/AppDelegateReducer.swift @@ -1,8 +1,3 @@ -// -// AppDelegateReducer.swift -// EhPanda -// - import SwiftUI import BackgroundTasks import SwiftyBeaver diff --git a/EhPanda/DataFlow/AppLockReducer.swift b/EhPanda/DataFlow/AppLockReducer.swift index 2bfdce399..639f3ac2e 100644 --- a/EhPanda/DataFlow/AppLockReducer.swift +++ b/EhPanda/DataFlow/AppLockReducer.swift @@ -1,8 +1,3 @@ -// -// AppLockReducer.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index 07ee2d145..4706a08ba 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -1,8 +1,3 @@ -// -// AppReducer.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/DataFlow/AppRouteReducer.swift b/EhPanda/DataFlow/AppRouteReducer.swift index 85570679b..cec0c34eb 100644 --- a/EhPanda/DataFlow/AppRouteReducer.swift +++ b/EhPanda/DataFlow/AppRouteReducer.swift @@ -1,8 +1,3 @@ -// -// AppRouteReducer.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/DataFlow/Heap.swift b/EhPanda/DataFlow/Heap.swift index 21fff54f8..6454ac5a9 100755 --- a/EhPanda/DataFlow/Heap.swift +++ b/EhPanda/DataFlow/Heap.swift @@ -1,8 +1,3 @@ -// -// Heap.swift -// EhPanda -// - private final class Reference: Equatable { var value: T init(_ value: T) { diff --git a/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift b/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift index 749ac489f..a9cedc637 100755 --- a/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift +++ b/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift @@ -1,8 +1,3 @@ -// -// FileManager+ApplicationSupport.swift -// CoreDataMigration-Example -// - import Foundation extension FileManager { diff --git a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift b/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift index 4e6d00b9a..ecfea5b85 100755 --- a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift +++ b/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift @@ -1,8 +1,3 @@ -// -// NSManagedObjectModel+Compatible.swift -// CoreDataMigration-Example -// - import Foundation import CoreData diff --git a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift b/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift index 683efe310..123cd81a0 100755 --- a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift +++ b/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift @@ -1,8 +1,3 @@ -// -// NSManagedObjectModel+Resource.swift -// CoreDataMigration-Example -// - import Foundation import CoreData diff --git a/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift b/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift index 5fac1e09e..b4501ea11 100755 --- a/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift +++ b/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift @@ -1,8 +1,3 @@ -// -// NSPersistentStoreCoordinator+SQLite.swift -// CoreDataMigration-Example -// - import CoreData extension NSPersistentStoreCoordinator { diff --git a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift index 43a3f32c6..375f16103 100644 --- a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift @@ -1,8 +1,3 @@ -// -// AppEnvMO+CoreDataClass.swift -// EhPanda -// - import CoreData public class AppEnvMO: NSManagedObject {} diff --git a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift index 63f3c000a..2ecabaacb 100644 --- a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift +++ b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift @@ -1,8 +1,3 @@ -// -// AppEnvMO+CoreDataProperties.swift -// EhPanda -// - import CoreData extension AppEnvMO { diff --git a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift index 8b108ac10..f9c17ceb8 100644 --- a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift @@ -1,8 +1,3 @@ -// -// GalleryDetailMO+CoreDataClass.swift -// EhPanda -// - import CoreData public class GalleryDetailMO: NSManagedObject {} diff --git a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift index b4b1ee26d..d9bd2e89d 100644 --- a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift +++ b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift @@ -1,8 +1,3 @@ -// -// GalleryDetailMO+CoreDataProperties.swift -// EhPanda -// - import CoreData extension GalleryDetailMO: GalleryIdentifiable { diff --git a/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift index 01fb5db5b..2f3e69bda 100644 --- a/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift @@ -1,8 +1,3 @@ -// -// GalleryMO+CoreDataClass.swift -// EhPanda -// - import CoreData public class GalleryMO: NSManagedObject {} diff --git a/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift index 11167c46d..08913e1da 100644 --- a/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift +++ b/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift @@ -1,8 +1,3 @@ -// -// GalleryMO+CoreDataProperties.swift -// EhPanda -// - import CoreData extension GalleryMO: GalleryIdentifiable { diff --git a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift index f3bec3872..8d4e7786a 100644 --- a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift @@ -1,8 +1,3 @@ -// -// GalleryStateMO+CoreDataClass.swift -// EhPanda -// - import SwiftUI import CoreData diff --git a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift index 06b5e8e1c..36ed13839 100644 --- a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift +++ b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift @@ -1,8 +1,3 @@ -// -// GalleryStateMO+CoreDataProperties.swift -// EhPanda -// - import CoreData extension GalleryStateMO: GalleryIdentifiable { diff --git a/EhPanda/Database/Migration/CoreDataMigrationStep.swift b/EhPanda/Database/Migration/CoreDataMigrationStep.swift index 9842587eb..3764532df 100755 --- a/EhPanda/Database/Migration/CoreDataMigrationStep.swift +++ b/EhPanda/Database/Migration/CoreDataMigrationStep.swift @@ -1,8 +1,3 @@ -// -// CoreDataMigrationStep.swift -// CoreDataMigration-Example -// - import CoreData struct CoreDataMigrationStep { diff --git a/EhPanda/Database/Migration/CoreDataMigrationVersion.swift b/EhPanda/Database/Migration/CoreDataMigrationVersion.swift index b34968416..535068d80 100755 --- a/EhPanda/Database/Migration/CoreDataMigrationVersion.swift +++ b/EhPanda/Database/Migration/CoreDataMigrationVersion.swift @@ -1,8 +1,3 @@ -// -// CoreDataVersion.swift -// CoreDataMigration-Example -// - import Foundation import CoreData diff --git a/EhPanda/Database/Migration/CoreDataMigrator.swift b/EhPanda/Database/Migration/CoreDataMigrator.swift index 98c395ef9..adfd5d227 100755 --- a/EhPanda/Database/Migration/CoreDataMigrator.swift +++ b/EhPanda/Database/Migration/CoreDataMigrator.swift @@ -1,8 +1,3 @@ -// -// CoreDataMigrator.swift -// CoreDataMigration-Example -// - import CoreData protocol CoreDataMigratorProtocol { diff --git a/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift b/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift index 3494c6013..f71721c35 100644 --- a/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift +++ b/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift @@ -1,8 +1,3 @@ -// -// Model5toModel6MigrationPolicy.swift -// EhPanda -// - import CoreData // reason: the migration policy name must encode both the source and destination model versions diff --git a/EhPanda/Database/Persistence.swift b/EhPanda/Database/Persistence.swift index 8dd59cc9f..94138cf72 100644 --- a/EhPanda/Database/Persistence.swift +++ b/EhPanda/Database/Persistence.swift @@ -1,8 +1,3 @@ -// -// Persistence.swift -// EhPanda -// - import CoreData struct PersistenceController: Sendable { diff --git a/EhPanda/Models/Download/DownloadBadge.swift b/EhPanda/Models/Download/DownloadBadge.swift index 2c854ae26..2d6289c65 100644 --- a/EhPanda/Models/Download/DownloadBadge.swift +++ b/EhPanda/Models/Download/DownloadBadge.swift @@ -1,8 +1,3 @@ -// -// DownloadBadge.swift -// EhPanda -// - struct DownloadBadge: Equatable, Sendable { let status: DownloadDisplayStatus let progress: DownloadProgress diff --git a/EhPanda/Models/Download/DownloadDisplayStatus.swift b/EhPanda/Models/Download/DownloadDisplayStatus.swift index b05741a6c..50dc06b3b 100644 --- a/EhPanda/Models/Download/DownloadDisplayStatus.swift +++ b/EhPanda/Models/Download/DownloadDisplayStatus.swift @@ -1,8 +1,3 @@ -// -// DownloadDisplayStatus.swift -// EhPanda -// - enum DownloadDisplayStatus: Equatable, CaseIterable, Sendable { case active case queued diff --git a/EhPanda/Models/Download/DownloadFailure.swift b/EhPanda/Models/Download/DownloadFailure.swift index 2feb7ed63..37554f8bc 100644 --- a/EhPanda/Models/Download/DownloadFailure.swift +++ b/EhPanda/Models/Download/DownloadFailure.swift @@ -1,8 +1,3 @@ -// -// DownloadFailure.swift -// EhPanda -// - enum DownloadFailureCode: String, Codable, Equatable, Sendable { case quotaExceeded case authenticationRequired diff --git a/EhPanda/Models/Download/DownloadFolderFilter.swift b/EhPanda/Models/Download/DownloadFolderFilter.swift index df7a60a94..95866f1f1 100644 --- a/EhPanda/Models/Download/DownloadFolderFilter.swift +++ b/EhPanda/Models/Download/DownloadFolderFilter.swift @@ -1,8 +1,3 @@ -// -// DownloadFolderFilter.swift -// EhPanda -// - enum DownloadFolderFilter: Equatable { case all case folder(String) diff --git a/EhPanda/Models/Download/DownloadInspection.swift b/EhPanda/Models/Download/DownloadInspection.swift index e21065842..72bcc33a8 100644 --- a/EhPanda/Models/Download/DownloadInspection.swift +++ b/EhPanda/Models/Download/DownloadInspection.swift @@ -1,8 +1,3 @@ -// -// DownloadInspection.swift -// EhPanda -// - import Foundation enum DownloadPageStatus: String, Equatable, CaseIterable, Sendable { diff --git a/EhPanda/Models/Download/DownloadProgress.swift b/EhPanda/Models/Download/DownloadProgress.swift index ed6a3b7f9..a0e2c1cf0 100644 --- a/EhPanda/Models/Download/DownloadProgress.swift +++ b/EhPanda/Models/Download/DownloadProgress.swift @@ -1,8 +1,3 @@ -// -// DownloadProgress.swift -// EhPanda -// - struct DownloadProgress: Equatable, Sendable { let completedPageCount: Int let pageCount: Int diff --git a/EhPanda/Models/Download/DownloadRequestOptions.swift b/EhPanda/Models/Download/DownloadRequestOptions.swift index 2a96ef56b..5825c31ba 100644 --- a/EhPanda/Models/Download/DownloadRequestOptions.swift +++ b/EhPanda/Models/Download/DownloadRequestOptions.swift @@ -1,8 +1,3 @@ -// -// DownloadRequestOptions.swift -// EhPanda -// - /// Execution policy for a download: *how* to fetch (thread limit, cellular, auto-retry), /// not *what* to fetch. Deliberately separate from `DownloadRequestPayload` and never /// persisted to a manifest or request: it is resolved fresh from the latest settings once diff --git a/EhPanda/Models/Download/DownloadStartMode.swift b/EhPanda/Models/Download/DownloadStartMode.swift index 58f89140a..d61039ee6 100644 --- a/EhPanda/Models/Download/DownloadStartMode.swift +++ b/EhPanda/Models/Download/DownloadStartMode.swift @@ -1,8 +1,3 @@ -// -// DownloadStartMode.swift -// EhPanda -// - enum DownloadStartMode: String, Equatable, Sendable { case initial case update diff --git a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift index 3ea7d03ac..555702182 100644 --- a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift +++ b/EhPanda/Models/Download/DownloadedGallery+Extensions.swift @@ -1,8 +1,3 @@ -// -// DownloadedGallery+Extensions.swift -// EhPanda -// - import SwiftUI import SFSafeSymbols diff --git a/EhPanda/Models/Download/DownloadedGallery+Manifest.swift b/EhPanda/Models/Download/DownloadedGallery+Manifest.swift index 37d6b12f1..389d84972 100644 --- a/EhPanda/Models/Download/DownloadedGallery+Manifest.swift +++ b/EhPanda/Models/Download/DownloadedGallery+Manifest.swift @@ -1,8 +1,3 @@ -// -// DownloadedGallery+Manifest.swift -// EhPanda -// - import Foundation /// The identity record for a downloaded gallery, written to `manifest.json` in its folder. diff --git a/EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift b/EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift index 568f52cca..6a320c206 100644 --- a/EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift +++ b/EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift @@ -1,8 +1,3 @@ -// -// DownloadedGallery+SupportTypes.swift -// EhPanda -// - import SwiftUI // MARK: DownloadedGallery Computed Properties diff --git a/EhPanda/Models/Download/DownloadedGallery.swift b/EhPanda/Models/Download/DownloadedGallery.swift index 769f0baf7..43e45648c 100644 --- a/EhPanda/Models/Download/DownloadedGallery.swift +++ b/EhPanda/Models/Download/DownloadedGallery.swift @@ -1,8 +1,3 @@ -// -// DownloadedGallery.swift -// EhPanda -// - import SwiftUI struct DownloadedGallery: Identifiable, Equatable { diff --git a/EhPanda/Models/Gallery/Category.swift b/EhPanda/Models/Gallery/Category.swift index 64cf724b6..acacadf0b 100644 --- a/EhPanda/Models/Gallery/Category.swift +++ b/EhPanda/Models/Gallery/Category.swift @@ -1,8 +1,3 @@ -// -// Category.swift -// EhPanda -// - import SwiftUI enum Category: String, Codable, CaseIterable, Identifiable, Sendable { diff --git a/EhPanda/Models/Gallery/Gallery.swift b/EhPanda/Models/Gallery/Gallery.swift index 451bc548e..b5df8ea43 100644 --- a/EhPanda/Models/Gallery/Gallery.swift +++ b/EhPanda/Models/Gallery/Gallery.swift @@ -1,8 +1,3 @@ -// -// Gallery.swift -// EhPanda -// - import SwiftUI struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { diff --git a/EhPanda/Models/Gallery/GalleryArchive.swift b/EhPanda/Models/Gallery/GalleryArchive.swift index eccd39233..e7cc10799 100644 --- a/EhPanda/Models/Gallery/GalleryArchive.swift +++ b/EhPanda/Models/Gallery/GalleryArchive.swift @@ -1,8 +1,3 @@ -// -// GalleryArchive.swift -// EhPanda -// - import Foundation struct GalleryArchive: Codable, Equatable { diff --git a/EhPanda/Models/Gallery/GalleryComment.swift b/EhPanda/Models/Gallery/GalleryComment.swift index 75428e3f0..7f91dfe6a 100644 --- a/EhPanda/Models/Gallery/GalleryComment.swift +++ b/EhPanda/Models/Gallery/GalleryComment.swift @@ -1,8 +1,3 @@ -// -// GalleryComment.swift -// EhPanda -// - import Foundation struct GalleryComment: Identifiable, Equatable, Codable { diff --git a/EhPanda/Models/Gallery/GalleryDetail.swift b/EhPanda/Models/Gallery/GalleryDetail.swift index 7cbc4a23a..565b65018 100644 --- a/EhPanda/Models/Gallery/GalleryDetail.swift +++ b/EhPanda/Models/Gallery/GalleryDetail.swift @@ -1,8 +1,3 @@ -// -// GalleryDetail.swift -// EhPanda -// - import Foundation struct GalleryDetail: Codable, Equatable, Sendable { diff --git a/EhPanda/Models/Gallery/GalleryState.swift b/EhPanda/Models/Gallery/GalleryState.swift index 5744e4b56..f8f87f01e 100644 --- a/EhPanda/Models/Gallery/GalleryState.swift +++ b/EhPanda/Models/Gallery/GalleryState.swift @@ -1,8 +1,3 @@ -// -// GalleryState.swift -// EhPanda -// - import SwiftUI import Foundation diff --git a/EhPanda/Models/Gallery/GalleryTorrent.swift b/EhPanda/Models/Gallery/GalleryTorrent.swift index c6dfe5ed2..fffb48a65 100644 --- a/EhPanda/Models/Gallery/GalleryTorrent.swift +++ b/EhPanda/Models/Gallery/GalleryTorrent.swift @@ -1,8 +1,3 @@ -// -// GalleryTorrent.swift -// EhPanda -// - import Foundation struct GalleryTorrent: Identifiable, Codable, Equatable { diff --git a/EhPanda/Models/Gallery/Language.swift b/EhPanda/Models/Gallery/Language.swift index 044ef6752..267596bda 100644 --- a/EhPanda/Models/Gallery/Language.swift +++ b/EhPanda/Models/Gallery/Language.swift @@ -1,8 +1,3 @@ -// -// Language.swift -// EhPanda -// - enum Language: String, Codable, Sendable { static let allExcludedCases: [Self] = [ .japanese, .english, .chinese, .dutch, .french, .german, .hungarian, .italian, diff --git a/EhPanda/Models/Persistent/AppEnv.swift b/EhPanda/Models/Persistent/AppEnv.swift index cdc7d4335..2a5b9bac8 100644 --- a/EhPanda/Models/Persistent/AppEnv.swift +++ b/EhPanda/Models/Persistent/AppEnv.swift @@ -1,8 +1,3 @@ -// -// AppEnv.swift -// EhPanda -// - struct AppEnv: Codable, Equatable { let user: User let setting: Setting diff --git a/EhPanda/Models/Persistent/Filter.swift b/EhPanda/Models/Persistent/Filter.swift index ef7d4e09f..798f0646e 100644 --- a/EhPanda/Models/Persistent/Filter.swift +++ b/EhPanda/Models/Persistent/Filter.swift @@ -1,8 +1,3 @@ -// -// Filter.swift -// EhPanda -// - import SwiftUI struct Filter: Codable, Equatable { diff --git a/EhPanda/Models/Persistent/Greeting.swift b/EhPanda/Models/Persistent/Greeting.swift index 8b65bf940..201228e8f 100644 --- a/EhPanda/Models/Persistent/Greeting.swift +++ b/EhPanda/Models/Persistent/Greeting.swift @@ -1,8 +1,3 @@ -// -// Greeting.swift -// EhPanda -// - import Foundation struct Greeting: Codable, Equatable, Hashable, Identifiable { diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift index 4b1fa2ea0..0f2b15bad 100644 --- a/EhPanda/Models/Persistent/Setting.swift +++ b/EhPanda/Models/Persistent/Setting.swift @@ -1,8 +1,3 @@ -// -// Setting.swift -// EhPanda -// - import SwiftUI import Foundation import ComposableArchitecture diff --git a/EhPanda/Models/Persistent/User.swift b/EhPanda/Models/Persistent/User.swift index f6a082db8..27f6e4863 100644 --- a/EhPanda/Models/Persistent/User.swift +++ b/EhPanda/Models/Persistent/User.swift @@ -1,8 +1,3 @@ -// -// User.swift -// EhPanda -// - import Foundation struct User: Codable, Equatable { diff --git a/EhPanda/Models/Support/AppError.swift b/EhPanda/Models/Support/AppError.swift index 1718ebc42..2ef2eee11 100644 --- a/EhPanda/Models/Support/AppError.swift +++ b/EhPanda/Models/Support/AppError.swift @@ -1,8 +1,3 @@ -// -// AppError.swift -// EhPanda -// - import Foundation import SFSafeSymbols diff --git a/EhPanda/Models/Support/BrowsingCountry+EnglishName.swift b/EhPanda/Models/Support/BrowsingCountry+EnglishName.swift index 9c61cd733..f5be9c3f3 100644 --- a/EhPanda/Models/Support/BrowsingCountry+EnglishName.swift +++ b/EhPanda/Models/Support/BrowsingCountry+EnglishName.swift @@ -1,8 +1,3 @@ -// -// BrowsingCountry+EnglishName.swift -// EhPanda -// - extension EhSetting.BrowsingCountry { var englishName: String { switch self { diff --git a/EhPanda/Models/Support/BrowsingCountry.swift b/EhPanda/Models/Support/BrowsingCountry.swift index e2594c6bf..4da75454a 100644 --- a/EhPanda/Models/Support/BrowsingCountry.swift +++ b/EhPanda/Models/Support/BrowsingCountry.swift @@ -1,8 +1,3 @@ -// -// BrowsingCountry.swift -// EhPanda -// - import Foundation // reason: the exhaustive ISO country list is kept dense, one case per line diff --git a/EhPanda/Models/Support/EhSetting+Enums.swift b/EhPanda/Models/Support/EhSetting+Enums.swift index eeb717f54..f5ab29243 100644 --- a/EhPanda/Models/Support/EhSetting+Enums.swift +++ b/EhPanda/Models/Support/EhSetting+Enums.swift @@ -1,8 +1,3 @@ -// -// EhSetting+Enums.swift -// EhPanda -// - // MARK: CommentsSortOrder extension EhSetting { enum CommentsSortOrder: Int, CaseIterable, Identifiable { diff --git a/EhPanda/Models/Support/EhSetting+Extensions.swift b/EhPanda/Models/Support/EhSetting+Extensions.swift index 121b76389..4e319a6a8 100644 --- a/EhPanda/Models/Support/EhSetting+Extensions.swift +++ b/EhPanda/Models/Support/EhSetting+Extensions.swift @@ -1,8 +1,3 @@ -// -// EhSetting+Extensions.swift -// EhPanda -// - // MARK: ThumbnailLoadTiming extension EhSetting { enum ThumbnailLoadTiming: Int, CaseIterable, Identifiable { diff --git a/EhPanda/Models/Support/EhSetting.swift b/EhPanda/Models/Support/EhSetting.swift index cbb6a2df4..aba123d6d 100644 --- a/EhPanda/Models/Support/EhSetting.swift +++ b/EhPanda/Models/Support/EhSetting.swift @@ -1,8 +1,3 @@ -// -// EhSetting.swift -// EhSetting -// - // MARK: EhSetting struct EhSetting: Equatable { // swiftlint:disable line_length diff --git a/EhPanda/Models/Support/LiveText.swift b/EhPanda/Models/Support/LiveText.swift index 6eccdeedc..f7dceffd8 100644 --- a/EhPanda/Models/Support/LiveText.swift +++ b/EhPanda/Models/Support/LiveText.swift @@ -1,8 +1,3 @@ -// -// LiveText.swift -// EhPanda -// - import SwiftUI import Foundation diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift index 8dbf9cd2c..525c321b1 100644 --- a/EhPanda/Models/Support/Misc.swift +++ b/EhPanda/Models/Support/Misc.swift @@ -1,8 +1,3 @@ -// -// Misc.swift -// EhPanda -// - import CasePaths import Foundation import SwiftyBeaver diff --git a/EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift b/EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift index fa30aa91a..533f22a60 100644 --- a/EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift +++ b/EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift @@ -1,8 +1,3 @@ -// -// EhTagTranslationDatabaseModel.swift -// EhPanda -// - import Foundation struct EhTagTranslationDatabaseResponse: Codable { diff --git a/EhPanda/Models/Tags/TagDetail.swift b/EhPanda/Models/Tags/TagDetail.swift index 4a4a7da14..c707923a5 100644 --- a/EhPanda/Models/Tags/TagDetail.swift +++ b/EhPanda/Models/Tags/TagDetail.swift @@ -1,8 +1,3 @@ -// -// TagDetail.swift -// EhPanda -// - import Foundation struct TagDetail: Equatable { diff --git a/EhPanda/Models/Tags/TagNamespace.swift b/EhPanda/Models/Tags/TagNamespace.swift index 83a124a85..592bb6b03 100644 --- a/EhPanda/Models/Tags/TagNamespace.swift +++ b/EhPanda/Models/Tags/TagNamespace.swift @@ -1,8 +1,3 @@ -// -// TagCategory.swift -// EhPanda -// - enum TagNamespace: String, Codable, CaseIterable, Sendable { case reclass case language diff --git a/EhPanda/Models/Tags/TagSuggestion.swift b/EhPanda/Models/Tags/TagSuggestion.swift index 37cb849f5..35316fd0b 100644 --- a/EhPanda/Models/Tags/TagSuggestion.swift +++ b/EhPanda/Models/Tags/TagSuggestion.swift @@ -1,8 +1,3 @@ -// -// TagSuggestion.swift -// EhPanda -// - import SwiftUI struct TagSuggestion: Equatable, Hashable, Identifiable { diff --git a/EhPanda/Models/Tags/TagTranslation.swift b/EhPanda/Models/Tags/TagTranslation.swift index 00d8dbecd..c04c860d3 100644 --- a/EhPanda/Models/Tags/TagTranslation.swift +++ b/EhPanda/Models/Tags/TagTranslation.swift @@ -1,8 +1,3 @@ -// -// TagTranslation.swift -// EhPanda -// - import OpenCC import Foundation diff --git a/EhPanda/Models/Tags/TagTranslator.swift b/EhPanda/Models/Tags/TagTranslator.swift index 315721194..1307fc76a 100644 --- a/EhPanda/Models/Tags/TagTranslator.swift +++ b/EhPanda/Models/Tags/TagTranslator.swift @@ -1,8 +1,3 @@ -// -// TagTranslator.swift -// EhPanda -// - import Foundation struct TagTranslator: Codable, Equatable { diff --git a/EhPanda/Models/Tags/TranslatableLanguage.swift b/EhPanda/Models/Tags/TranslatableLanguage.swift index ec46b8a96..88256bb3f 100644 --- a/EhPanda/Models/Tags/TranslatableLanguage.swift +++ b/EhPanda/Models/Tags/TranslatableLanguage.swift @@ -1,8 +1,3 @@ -// -// TranslatableLanguage.swift -// EhPanda -// - import Foundation enum TranslatableLanguage: Codable, CaseIterable { diff --git a/EhPanda/Network/DFExtensions.swift b/EhPanda/Network/DFExtensions.swift index 1171a2d82..7768aa5f2 100644 --- a/EhPanda/Network/DFExtensions.swift +++ b/EhPanda/Network/DFExtensions.swift @@ -1,8 +1,3 @@ -// -// DFExtensions.swift -// EhPanda -// - import Foundation import DeprecatedAPI diff --git a/EhPanda/Network/DFRequest.swift b/EhPanda/Network/DFRequest.swift index 1a1c2a9a6..4978dfd32 100644 --- a/EhPanda/Network/DFRequest.swift +++ b/EhPanda/Network/DFRequest.swift @@ -1,8 +1,3 @@ -// -// DFRequest.swift -// EhPanda -// - import Foundation struct DFRequest { diff --git a/EhPanda/Network/DFStreamHandler.swift b/EhPanda/Network/DFStreamHandler.swift index 237df1701..d3597eb2f 100644 --- a/EhPanda/Network/DFStreamHandler.swift +++ b/EhPanda/Network/DFStreamHandler.swift @@ -1,8 +1,3 @@ -// -// DFStreamEventHandler.swift -// EhPanda -// - import Foundation class DFStreamEventHandler: NSObject { diff --git a/EhPanda/Network/DFURLProtocol.swift b/EhPanda/Network/DFURLProtocol.swift index 30d798775..d431fd7da 100644 --- a/EhPanda/Network/DFURLProtocol.swift +++ b/EhPanda/Network/DFURLProtocol.swift @@ -1,8 +1,3 @@ -// -// DFURLProtocol.swift -// EhPanda -// - import Foundation class DFURLProtocol: URLProtocol { diff --git a/EhPanda/Network/DomainResolver.swift b/EhPanda/Network/DomainResolver.swift index 02a20e75e..073c80ecf 100644 --- a/EhPanda/Network/DomainResolver.swift +++ b/EhPanda/Network/DomainResolver.swift @@ -1,8 +1,3 @@ -// -// DomainResolver.swift -// EhPanda -// - struct DomainResolver { static func resolve(domain: String) -> String? { ResolvableDomain(rawValue: domain)?.ipPool.randomElement() diff --git a/EhPanda/Network/Request+Account.swift b/EhPanda/Network/Request+Account.swift index 777d11558..7fc96c0e5 100644 --- a/EhPanda/Network/Request+Account.swift +++ b/EhPanda/Network/Request+Account.swift @@ -1,8 +1,3 @@ -// -// Request+Account.swift -// EhPanda -// - import Kanna import Combine import Foundation diff --git a/EhPanda/Network/Request+Detail.swift b/EhPanda/Network/Request+Detail.swift index 7b9bff66d..720c85340 100644 --- a/EhPanda/Network/Request+Detail.swift +++ b/EhPanda/Network/Request+Detail.swift @@ -1,8 +1,3 @@ -// -// Request+Detail.swift -// EhPanda -// - import Kanna import Combine import Foundation diff --git a/EhPanda/Network/Request+Gallery.swift b/EhPanda/Network/Request+Gallery.swift index 53bc247b2..404a5ba06 100644 --- a/EhPanda/Network/Request+Gallery.swift +++ b/EhPanda/Network/Request+Gallery.swift @@ -1,8 +1,3 @@ -// -// Request+Gallery.swift -// EhPanda -// - import Kanna import Combine import Foundation diff --git a/EhPanda/Network/Request+Image.swift b/EhPanda/Network/Request+Image.swift index 8dcd1f7c6..fa06e7b30 100644 --- a/EhPanda/Network/Request+Image.swift +++ b/EhPanda/Network/Request+Image.swift @@ -1,8 +1,3 @@ -// -// Request+Image.swift -// EhPanda -// - import Kanna import Combine import Foundation diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index fce06340d..36af5106f 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -1,7 +1,3 @@ -// -// Request.swift -// EhPanda - import Kanna import Combine import Foundation diff --git a/EhPanda/View/Detail/Archives/ArchivesReducer.swift b/EhPanda/View/Detail/Archives/ArchivesReducer.swift index 05e7c3b86..97ab0b0d1 100644 --- a/EhPanda/View/Detail/Archives/ArchivesReducer.swift +++ b/EhPanda/View/Detail/Archives/ArchivesReducer.swift @@ -1,8 +1,3 @@ -// -// ArchivesReducer.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Detail/Archives/ArchivesView.swift b/EhPanda/View/Detail/Archives/ArchivesView.swift index e53a95f2b..8a88e0f5b 100644 --- a/EhPanda/View/Detail/Archives/ArchivesView.swift +++ b/EhPanda/View/Detail/Archives/ArchivesView.swift @@ -1,8 +1,3 @@ -// -// ArchivesView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Detail/Comments/CommentsReducer.swift b/EhPanda/View/Detail/Comments/CommentsReducer.swift index 7bc4c6d8b..4877bea1b 100644 --- a/EhPanda/View/Detail/Comments/CommentsReducer.swift +++ b/EhPanda/View/Detail/Comments/CommentsReducer.swift @@ -1,8 +1,3 @@ -// -// CommentsReducer.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Detail/Comments/CommentsView.swift b/EhPanda/View/Detail/Comments/CommentsView.swift index 839dbcbdd..46577b6c5 100644 --- a/EhPanda/View/Detail/Comments/CommentsView.swift +++ b/EhPanda/View/Detail/Comments/CommentsView.swift @@ -1,8 +1,3 @@ -// -// CommentsView.swift -// EhPanda -// - import SwiftUI import Kingfisher import ComposableArchitecture diff --git a/EhPanda/View/Detail/Components/LinkedText.swift b/EhPanda/View/Detail/Components/LinkedText.swift index 189cff6c4..cba372e86 100644 --- a/EhPanda/View/Detail/Components/LinkedText.swift +++ b/EhPanda/View/Detail/Components/LinkedText.swift @@ -1,7 +1,3 @@ -// -// LinkedText.swift -// EhPanda -// // Copied from https://gist.github.com/mjm/0581781f85db45b05e8e2c5c33696f88 // diff --git a/EhPanda/View/Detail/Components/PostCommentView.swift b/EhPanda/View/Detail/Components/PostCommentView.swift index f99e998d6..e14249f2d 100644 --- a/EhPanda/View/Detail/Components/PostCommentView.swift +++ b/EhPanda/View/Detail/Components/PostCommentView.swift @@ -1,8 +1,3 @@ -// -// PostCommentView.swift -// EhPanda -// - import SwiftUI struct PostCommentView: View { diff --git a/EhPanda/View/Detail/Components/RatingView.swift b/EhPanda/View/Detail/Components/RatingView.swift index 56098966a..a82719064 100644 --- a/EhPanda/View/Detail/Components/RatingView.swift +++ b/EhPanda/View/Detail/Components/RatingView.swift @@ -1,8 +1,3 @@ -// -// RatingView.swift -// EhPanda -// - import SwiftUI struct RatingView: View { diff --git a/EhPanda/View/Detail/Components/TagDetailView.swift b/EhPanda/View/Detail/Components/TagDetailView.swift index d6b0cfd6b..75f086db2 100644 --- a/EhPanda/View/Detail/Components/TagDetailView.swift +++ b/EhPanda/View/Detail/Components/TagDetailView.swift @@ -1,8 +1,3 @@ -// -// TagDetailView.swift -// EhPanda -// - import SwiftUI import Kingfisher diff --git a/EhPanda/View/Detail/DetailReducer+Actions.swift b/EhPanda/View/Detail/DetailReducer+Actions.swift index 328399e25..cc71a4b16 100644 --- a/EhPanda/View/Detail/DetailReducer+Actions.swift +++ b/EhPanda/View/Detail/DetailReducer+Actions.swift @@ -1,8 +1,3 @@ -// -// DetailReducer+Actions.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift index e0b034844..3ae66a6e3 100644 --- a/EhPanda/View/Detail/DetailReducer+Download.swift +++ b/EhPanda/View/Detail/DetailReducer+Download.swift @@ -1,8 +1,3 @@ -// -// DetailReducer+Download.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/EhPanda/View/Detail/DetailReducer+Fetch.swift index bbbdd3c16..8c13d935a 100644 --- a/EhPanda/View/Detail/DetailReducer+Fetch.swift +++ b/EhPanda/View/Detail/DetailReducer+Fetch.swift @@ -1,8 +1,3 @@ -// -// DetailReducer+Fetch.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index a869aaee9..f85ce7eb6 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -1,8 +1,3 @@ -// -// DetailReducer.swift -// EhPanda -// - import SwiftUI import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift index 6cee897d8..685be14a6 100644 --- a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -1,8 +1,3 @@ -// -// DetailSearchReducer.swift -// EhPanda -// - import ComposableArchitecture @Reducer diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift index 29d7f8420..6e782a845 100644 --- a/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift +++ b/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift @@ -1,8 +1,3 @@ -// -// DetailSearchView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Detail/DetailView+CommentCells.swift b/EhPanda/View/Detail/DetailView+CommentCells.swift index 8a1567729..18ad65828 100644 --- a/EhPanda/View/Detail/DetailView+CommentCells.swift +++ b/EhPanda/View/Detail/DetailView+CommentCells.swift @@ -1,8 +1,3 @@ -// -// DetailView+CommentCells.swift -// EhPanda -// - import SwiftUI extension DetailView { diff --git a/EhPanda/View/Detail/DetailView+HeaderSection.swift b/EhPanda/View/Detail/DetailView+HeaderSection.swift index 570ba7bb1..072b08796 100644 --- a/EhPanda/View/Detail/DetailView+HeaderSection.swift +++ b/EhPanda/View/Detail/DetailView+HeaderSection.swift @@ -1,8 +1,3 @@ -// -// DetailView+HeaderSection.swift -// EhPanda -// - import SwiftUI import Kingfisher import SFSafeSymbols diff --git a/EhPanda/View/Detail/DetailView+Navigation.swift b/EhPanda/View/Detail/DetailView+Navigation.swift index 5eac25698..9b862ad44 100644 --- a/EhPanda/View/Detail/DetailView+Navigation.swift +++ b/EhPanda/View/Detail/DetailView+Navigation.swift @@ -1,8 +1,3 @@ -// -// DetailView+Navigation.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Detail/DetailView+Subviews.swift b/EhPanda/View/Detail/DetailView+Subviews.swift index 339e32af2..e2ddc6d04 100644 --- a/EhPanda/View/Detail/DetailView+Subviews.swift +++ b/EhPanda/View/Detail/DetailView+Subviews.swift @@ -1,8 +1,3 @@ -// -// DetailView+Subviews.swift -// EhPanda -// - import SwiftUI import Kingfisher diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 7431a9d8e..c750cce67 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -1,8 +1,3 @@ -// -// DetailView.swift -// EhPanda -// - import SwiftUI import Kingfisher import ComposableArchitecture diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift index 68f2b54d9..fefc29b72 100644 --- a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift +++ b/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift @@ -1,8 +1,3 @@ -// -// GalleryInfosReducer.swift -// EhPanda -// - import ComposableArchitecture @Reducer diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift index edf9f11b6..2c748cdff 100644 --- a/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift +++ b/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift @@ -1,8 +1,3 @@ -// -// GalleryInfosView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Detail/Previews/PreviewsReducer.swift b/EhPanda/View/Detail/Previews/PreviewsReducer.swift index 024087806..fe2f6a8f7 100644 --- a/EhPanda/View/Detail/Previews/PreviewsReducer.swift +++ b/EhPanda/View/Detail/Previews/PreviewsReducer.swift @@ -1,8 +1,3 @@ -// -// PreviewsReducer.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Detail/Previews/PreviewsView.swift b/EhPanda/View/Detail/Previews/PreviewsView.swift index cefc17a47..e7225c01c 100644 --- a/EhPanda/View/Detail/Previews/PreviewsView.swift +++ b/EhPanda/View/Detail/Previews/PreviewsView.swift @@ -1,8 +1,3 @@ -// -// PreviewsView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Detail/Torrents/TorrentsReducer.swift b/EhPanda/View/Detail/Torrents/TorrentsReducer.swift index bfaff6678..8ae3934f4 100644 --- a/EhPanda/View/Detail/Torrents/TorrentsReducer.swift +++ b/EhPanda/View/Detail/Torrents/TorrentsReducer.swift @@ -1,8 +1,3 @@ -// -// TorrentsReducer.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Detail/Torrents/TorrentsView.swift b/EhPanda/View/Detail/Torrents/TorrentsView.swift index 531e4e916..1d95144f5 100644 --- a/EhPanda/View/Detail/Torrents/TorrentsView.swift +++ b/EhPanda/View/Detail/Torrents/TorrentsView.swift @@ -1,8 +1,3 @@ -// -// TorrentsView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Downloads/DownloadInspectorReducer.swift b/EhPanda/View/Downloads/DownloadInspectorReducer.swift index 9332b9b69..b575565c2 100644 --- a/EhPanda/View/Downloads/DownloadInspectorReducer.swift +++ b/EhPanda/View/Downloads/DownloadInspectorReducer.swift @@ -1,8 +1,3 @@ -// -// DownloadInspectorReducer.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/EhPanda/View/Downloads/DownloadsReducer.swift index f6462e610..4c271efb1 100644 --- a/EhPanda/View/Downloads/DownloadsReducer.swift +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -1,8 +1,3 @@ -// -// DownloadsReducer.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Downloads/DownloadsView+Subviews.swift b/EhPanda/View/Downloads/DownloadsView+Subviews.swift index e02dac82a..62732f11d 100644 --- a/EhPanda/View/Downloads/DownloadsView+Subviews.swift +++ b/EhPanda/View/Downloads/DownloadsView+Subviews.swift @@ -1,8 +1,3 @@ -// -// DownloadsView+Subviews.swift -// EhPanda -// - import SwiftUI import SFSafeSymbols import ComposableArchitecture diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift index c537dca84..563cb1d2b 100644 --- a/EhPanda/View/Downloads/DownloadsView.swift +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -1,8 +1,3 @@ -// -// DownloadsView.swift -// EhPanda -// - import SwiftUI import SFSafeSymbols import ComposableArchitecture diff --git a/EhPanda/View/Downloads/FolderManagerReducer.swift b/EhPanda/View/Downloads/FolderManagerReducer.swift index 7dfca80cb..771ea679c 100644 --- a/EhPanda/View/Downloads/FolderManagerReducer.swift +++ b/EhPanda/View/Downloads/FolderManagerReducer.swift @@ -1,8 +1,3 @@ -// -// FolderManagerReducer.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Downloads/FolderManagerView.swift b/EhPanda/View/Downloads/FolderManagerView.swift index e35b09f01..e73d22976 100644 --- a/EhPanda/View/Downloads/FolderManagerView.swift +++ b/EhPanda/View/Downloads/FolderManagerView.swift @@ -1,8 +1,3 @@ -// -// FolderManagerView.swift -// EhPanda -// - import SwiftUI import SFSafeSymbols import ComposableArchitecture diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift index cbd4c51b7..fe16a9cb1 100644 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -1,8 +1,3 @@ -// -// FavoritesReducer.swift -// EhPanda -// - import SwiftUI import IdentifiedCollections import ComposableArchitecture diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index 0e6982e02..ca8557daf 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -1,8 +1,3 @@ -// -// FavoritesView.swift -// EhPanda -// - import SwiftUI import AlertKit import ComposableArchitecture diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift index 96b56980c..6197dbcb6 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift @@ -1,8 +1,3 @@ -// -// FrontpageReducer.swift -// EhPanda -// - import ComposableArchitecture import Foundation diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index ebdf0d563..43663fca5 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -1,8 +1,3 @@ -// -// FrontpageView.swift -// EhPanda -// - import SwiftUI import AlertKit import ComposableArchitecture diff --git a/EhPanda/View/Home/History/HistoryReducer.swift b/EhPanda/View/Home/History/HistoryReducer.swift index de50eaf00..30c9bba4d 100644 --- a/EhPanda/View/Home/History/HistoryReducer.swift +++ b/EhPanda/View/Home/History/HistoryReducer.swift @@ -1,8 +1,3 @@ -// -// HistoryReducer.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Home/History/HistoryView.swift b/EhPanda/View/Home/History/HistoryView.swift index 26bdb04c7..9828bded4 100644 --- a/EhPanda/View/Home/History/HistoryView.swift +++ b/EhPanda/View/Home/History/HistoryView.swift @@ -1,8 +1,3 @@ -// -// HistoryView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Home/HomeReducer+Body.swift b/EhPanda/View/Home/HomeReducer+Body.swift index 650b9a570..4937cd152 100644 --- a/EhPanda/View/Home/HomeReducer+Body.swift +++ b/EhPanda/View/Home/HomeReducer+Body.swift @@ -1,8 +1,3 @@ -// -// HomeReducer+Body.swift -// EhPanda -// - import SwiftUI import Kingfisher import ComposableArchitecture diff --git a/EhPanda/View/Home/HomeReducer.swift b/EhPanda/View/Home/HomeReducer.swift index a2b655b22..18a5c7ea4 100644 --- a/EhPanda/View/Home/HomeReducer.swift +++ b/EhPanda/View/Home/HomeReducer.swift @@ -1,8 +1,3 @@ -// -// HomeReducer.swift -// EhPanda -// - import SwiftUI import Kingfisher import ComposableArchitecture diff --git a/EhPanda/View/Home/HomeView+Sections.swift b/EhPanda/View/Home/HomeView+Sections.swift index 33ab3f794..7213a32fa 100644 --- a/EhPanda/View/Home/HomeView+Sections.swift +++ b/EhPanda/View/Home/HomeView+Sections.swift @@ -1,8 +1,3 @@ -// -// HomeView+Sections.swift -// EhPanda -// - import SwiftUI import Kingfisher import SwiftUIPager diff --git a/EhPanda/View/Home/HomeView.swift b/EhPanda/View/Home/HomeView.swift index 9bb6beda7..7055f9fbc 100644 --- a/EhPanda/View/Home/HomeView.swift +++ b/EhPanda/View/Home/HomeView.swift @@ -1,8 +1,3 @@ -// -// HomeView.swift -// EhPanda -// - import SwiftUI import Kingfisher import SFSafeSymbols diff --git a/EhPanda/View/Home/Popular/PopularReducer.swift b/EhPanda/View/Home/Popular/PopularReducer.swift index 4095641b9..6e0065363 100644 --- a/EhPanda/View/Home/Popular/PopularReducer.swift +++ b/EhPanda/View/Home/Popular/PopularReducer.swift @@ -1,8 +1,3 @@ -// -// PopularReducer.swift -// EhPanda -// - import ComposableArchitecture @Reducer diff --git a/EhPanda/View/Home/Popular/PopularView.swift b/EhPanda/View/Home/Popular/PopularView.swift index e98b7c75d..219715a0d 100644 --- a/EhPanda/View/Home/Popular/PopularView.swift +++ b/EhPanda/View/Home/Popular/PopularView.swift @@ -1,8 +1,3 @@ -// -// PopularView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Home/Toplists/ToplistsReducer.swift b/EhPanda/View/Home/Toplists/ToplistsReducer.swift index 0226766de..062de9fd8 100644 --- a/EhPanda/View/Home/Toplists/ToplistsReducer.swift +++ b/EhPanda/View/Home/Toplists/ToplistsReducer.swift @@ -1,8 +1,3 @@ -// -// ToplistsReducer.swift -// EhPanda -// - import ComposableArchitecture @Reducer diff --git a/EhPanda/View/Home/Toplists/ToplistsView.swift b/EhPanda/View/Home/Toplists/ToplistsView.swift index 26a3881b7..8dc068f9c 100644 --- a/EhPanda/View/Home/Toplists/ToplistsView.swift +++ b/EhPanda/View/Home/Toplists/ToplistsView.swift @@ -1,8 +1,3 @@ -// -// ToplistsView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift index 9846873ca..55809402d 100644 --- a/EhPanda/View/Home/Watched/WatchedReducer.swift +++ b/EhPanda/View/Home/Watched/WatchedReducer.swift @@ -1,8 +1,3 @@ -// -// WatchedReducer.swift -// EhPanda -// - import ComposableArchitecture @Reducer diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift index e3e1f269e..b97f2eb31 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -1,8 +1,3 @@ -// -// WatchedView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Migration/MigrationReducer.swift b/EhPanda/View/Migration/MigrationReducer.swift index a8a8c54a9..6a9a3b836 100644 --- a/EhPanda/View/Migration/MigrationReducer.swift +++ b/EhPanda/View/Migration/MigrationReducer.swift @@ -1,8 +1,3 @@ -// -// MigrationReducer.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Migration/MigrationView.swift b/EhPanda/View/Migration/MigrationView.swift index 071a19e91..2b0a6fcc1 100644 --- a/EhPanda/View/Migration/MigrationView.swift +++ b/EhPanda/View/Migration/MigrationView.swift @@ -1,8 +1,3 @@ -// -// MigrationView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Reading/ReadingReducer+Body.swift b/EhPanda/View/Reading/ReadingReducer+Body.swift index 12f4533a2..513d3bf76 100644 --- a/EhPanda/View/Reading/ReadingReducer+Body.swift +++ b/EhPanda/View/Reading/ReadingReducer+Body.swift @@ -1,7 +1,3 @@ -// -// ReadingReducer+Body.swift -// EhPanda - import SwiftUI import Kingfisher import TTProgressHUD diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/EhPanda/View/Reading/ReadingReducer+Database.swift index 36b306ac2..b193ff5d5 100644 --- a/EhPanda/View/Reading/ReadingReducer+Database.swift +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -1,7 +1,3 @@ -// -// ReadingReducer+Database.swift -// EhPanda - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift b/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift index 74d4f2dde..837099ba0 100644 --- a/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift +++ b/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift @@ -1,7 +1,3 @@ -// -// ReadingReducer+ImageFetch.swift -// EhPanda - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index b7e9e98dd..89473ab3a 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -1,8 +1,3 @@ -// -// ReadingReducer.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Reading/ReadingView+Gestures.swift b/EhPanda/View/Reading/ReadingView+Gestures.swift index 5776ea8a2..55524b796 100644 --- a/EhPanda/View/Reading/ReadingView+Gestures.swift +++ b/EhPanda/View/Reading/ReadingView+Gestures.swift @@ -1,8 +1,3 @@ -// -// ReadingView+Gestures.swift -// EhPanda -// - import SwiftUI // MARK: Gesture diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index d53ffa15e..86b3b9550 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -1,8 +1,3 @@ -// -// ReadingView.swift -// EhPanda -// - import SwiftUI import Observation import SwiftUIPager diff --git a/EhPanda/View/Reading/ReadingViewComponents.swift b/EhPanda/View/Reading/ReadingViewComponents.swift index 0ceb6cf37..c367f74a4 100644 --- a/EhPanda/View/Reading/ReadingViewComponents.swift +++ b/EhPanda/View/Reading/ReadingViewComponents.swift @@ -1,7 +1,3 @@ -// -// ReadingViewComponents.swift -// EhPanda - import SwiftUI import Kingfisher import SDWebImage diff --git a/EhPanda/View/Reading/Support/AdvancedList.swift b/EhPanda/View/Reading/Support/AdvancedList.swift index 22eb2bcf4..7e83eb249 100644 --- a/EhPanda/View/Reading/Support/AdvancedList.swift +++ b/EhPanda/View/Reading/Support/AdvancedList.swift @@ -1,8 +1,3 @@ -// -// AdvancedList.swift -// EhPanda -// - import SwiftUI import SwiftUIPager diff --git a/EhPanda/View/Reading/Support/AutoPlayHandler.swift b/EhPanda/View/Reading/Support/AutoPlayHandler.swift index ccec77319..c3a898c3a 100644 --- a/EhPanda/View/Reading/Support/AutoPlayHandler.swift +++ b/EhPanda/View/Reading/Support/AutoPlayHandler.swift @@ -1,8 +1,3 @@ -// -// AutoPlayHandler.swift -// EhPanda -// - import SwiftUI import Observation diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift index da6569007..7bd901849 100644 --- a/EhPanda/View/Reading/Support/ControlPanel.swift +++ b/EhPanda/View/Reading/Support/ControlPanel.swift @@ -1,8 +1,3 @@ -// -// ControlPanel.swift -// EhPanda -// - import SwiftUI // MARK: ControlPanel diff --git a/EhPanda/View/Reading/Support/GestureHandler.swift b/EhPanda/View/Reading/Support/GestureHandler.swift index 68e028c16..57daa2590 100644 --- a/EhPanda/View/Reading/Support/GestureHandler.swift +++ b/EhPanda/View/Reading/Support/GestureHandler.swift @@ -1,8 +1,3 @@ -// -// GestureHandler.swift -// EhPanda -// - import SwiftUI import Observation diff --git a/EhPanda/View/Reading/Support/LiveTextHandler.swift b/EhPanda/View/Reading/Support/LiveTextHandler.swift index e10e60533..ffd786094 100644 --- a/EhPanda/View/Reading/Support/LiveTextHandler.swift +++ b/EhPanda/View/Reading/Support/LiveTextHandler.swift @@ -1,7 +1,3 @@ -// -// LiveTextHandler.swift -// EhPanda -// // reason: the reference URLs below exceed the line-length limit and cannot wrap // swiftlint:disable line_length // Reference diff --git a/EhPanda/View/Reading/Support/LiveTextView.swift b/EhPanda/View/Reading/Support/LiveTextView.swift index 27f76c0d8..98b9164e6 100644 --- a/EhPanda/View/Reading/Support/LiveTextView.swift +++ b/EhPanda/View/Reading/Support/LiveTextView.swift @@ -1,8 +1,3 @@ -// -// LiveTextView.swift -// EhPanda -// - import SwiftUI struct LiveTextView: View { diff --git a/EhPanda/View/Reading/Support/PageHandler.swift b/EhPanda/View/Reading/Support/PageHandler.swift index f4f066eea..5bedebfaf 100644 --- a/EhPanda/View/Reading/Support/PageHandler.swift +++ b/EhPanda/View/Reading/Support/PageHandler.swift @@ -1,8 +1,3 @@ -// -// PageHandler.swift -// EhPanda -// - import SwiftUI import Observation diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index 6c3af030f..f51d1d21d 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -1,8 +1,3 @@ -// -// SearchReducer.swift -// EhPanda -// - import ComposableArchitecture import Foundation diff --git a/EhPanda/View/Search/SearchRootReducer.swift b/EhPanda/View/Search/SearchRootReducer.swift index bc681d373..65ac797e9 100644 --- a/EhPanda/View/Search/SearchRootReducer.swift +++ b/EhPanda/View/Search/SearchRootReducer.swift @@ -1,8 +1,3 @@ -// -// SearchRootReducer.swift -// EhPanda -// - import ComposableArchitecture @Reducer diff --git a/EhPanda/View/Search/SearchRootView+Keywords.swift b/EhPanda/View/Search/SearchRootView+Keywords.swift index a352b1c3e..bec26f990 100644 --- a/EhPanda/View/Search/SearchRootView+Keywords.swift +++ b/EhPanda/View/Search/SearchRootView+Keywords.swift @@ -1,8 +1,3 @@ -// -// SearchRootView+Keywords.swift -// EhPanda -// - import SwiftUI // MARK: DoubleVerticalKeywordsStack diff --git a/EhPanda/View/Search/SearchRootView.swift b/EhPanda/View/Search/SearchRootView.swift index d3e0b4032..fce3d23b1 100644 --- a/EhPanda/View/Search/SearchRootView.swift +++ b/EhPanda/View/Search/SearchRootView.swift @@ -1,8 +1,3 @@ -// -// SearchRootView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index 052039621..6c76ad9fd 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -1,8 +1,3 @@ -// -// SearchView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Search/Support/QuickSearchReducer.swift b/EhPanda/View/Search/Support/QuickSearchReducer.swift index ad6377de4..775904dd7 100644 --- a/EhPanda/View/Search/Support/QuickSearchReducer.swift +++ b/EhPanda/View/Search/Support/QuickSearchReducer.swift @@ -1,8 +1,3 @@ -// -// QuickSearchReducer.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Search/Support/QuickSearchView.swift b/EhPanda/View/Search/Support/QuickSearchView.swift index 0d23787d3..617d3a1a6 100644 --- a/EhPanda/View/Search/Support/QuickSearchView.swift +++ b/EhPanda/View/Search/Support/QuickSearchView.swift @@ -1,8 +1,3 @@ -// -// QuickSearchView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift index 2a03bf6f1..7b72aacf7 100644 --- a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift +++ b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift @@ -1,8 +1,3 @@ -// -// AccountSettingReducer.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift index 09b2d9b95..6b9c715dc 100644 --- a/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift +++ b/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift @@ -1,8 +1,3 @@ -// -// AccountSettingView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift index d1b617c61..4e7e40f8a 100644 --- a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift +++ b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift @@ -1,8 +1,3 @@ -// -// AppearanceSettingReducer.swift -// EhPanda -// - import ComposableArchitecture @Reducer diff --git a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift index 6349492f3..f0083b30a 100644 --- a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift +++ b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift @@ -1,8 +1,3 @@ -// -// AppearanceSettingView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/Components/AboutView.swift b/EhPanda/View/Setting/Components/AboutView.swift index 9b6c91ec5..4f581b9a6 100644 --- a/EhPanda/View/Setting/Components/AboutView.swift +++ b/EhPanda/View/Setting/Components/AboutView.swift @@ -1,8 +1,3 @@ -// -// AboutView.swift -// EhPanda -// - import SwiftUI struct AboutView: View { diff --git a/EhPanda/View/Setting/Components/DownloadSettingView.swift b/EhPanda/View/Setting/Components/DownloadSettingView.swift index 986e96703..7be2c1150 100644 --- a/EhPanda/View/Setting/Components/DownloadSettingView.swift +++ b/EhPanda/View/Setting/Components/DownloadSettingView.swift @@ -1,8 +1,3 @@ -// -// DownloadSettingView.swift -// EhPanda -// - import SwiftUI struct DownloadSettingView: View { diff --git a/EhPanda/View/Setting/Components/LaboratorySettingView.swift b/EhPanda/View/Setting/Components/LaboratorySettingView.swift index 15a75877a..fe12b8384 100644 --- a/EhPanda/View/Setting/Components/LaboratorySettingView.swift +++ b/EhPanda/View/Setting/Components/LaboratorySettingView.swift @@ -1,8 +1,3 @@ -// -// LaboratorySettingView.swift -// LabSettingView -// - import SwiftUI import SFSafeSymbols diff --git a/EhPanda/View/Setting/Components/ReadingSettingView.swift b/EhPanda/View/Setting/Components/ReadingSettingView.swift index 82b39f839..9bb8ce914 100644 --- a/EhPanda/View/Setting/Components/ReadingSettingView.swift +++ b/EhPanda/View/Setting/Components/ReadingSettingView.swift @@ -1,8 +1,3 @@ -// -// ReadingSettingView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/Components/WebView.swift b/EhPanda/View/Setting/Components/WebView.swift index f771497a9..2cc16613e 100644 --- a/EhPanda/View/Setting/Components/WebView.swift +++ b/EhPanda/View/Setting/Components/WebView.swift @@ -1,8 +1,3 @@ -// -// WebView.swift -// EhPanda -// - import WebKit import SwiftUI diff --git a/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift b/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift index 6c80f66d3..0d172cf5d 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift @@ -1,8 +1,3 @@ -// -// EhSettingReducer.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift index a861d7f68..0b4f5ec1c 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift @@ -1,8 +1,3 @@ -// -// EhSettingView+Sections1.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift index 6dd6e8195..46c161715 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift @@ -1,8 +1,3 @@ -// -// EhSettingView+Sections2.swift -// EhPanda -// - import SwiftUI extension EhSettingView { diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift index 94c587223..573d8ceb7 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift @@ -1,8 +1,3 @@ -// -// EhSettingView+Sections3.swift -// EhPanda -// - import SwiftUI extension EhSettingView { diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView.swift b/EhPanda/View/Setting/EhSetting/EhSettingView.swift index ef48541a8..f62ab2dfb 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView.swift @@ -1,8 +1,3 @@ -// -// EhSettingView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift index 2e36f25f5..dd461df27 100644 --- a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift +++ b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift @@ -1,8 +1,3 @@ -// -// GeneralSettingReducer.swift -// EhPanda -// - import LocalAuthentication import ComposableArchitecture diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift index a67f35654..6e7dbdc1e 100644 --- a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift +++ b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift @@ -1,8 +1,3 @@ -// -// GeneralSettingView.swift -// EhPanda -// - import SwiftUI import FilePicker import ComposableArchitecture diff --git a/EhPanda/View/Setting/Login/LoginReducer.swift b/EhPanda/View/Setting/Login/LoginReducer.swift index 3c60ec6d9..faa80d76e 100644 --- a/EhPanda/View/Setting/Login/LoginReducer.swift +++ b/EhPanda/View/Setting/Login/LoginReducer.swift @@ -1,8 +1,3 @@ -// -// LoginReducer.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/Login/LoginView.swift b/EhPanda/View/Setting/Login/LoginView.swift index 7825edb96..f9550c73f 100644 --- a/EhPanda/View/Setting/Login/LoginView.swift +++ b/EhPanda/View/Setting/Login/LoginView.swift @@ -1,8 +1,3 @@ -// -// LoginView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/Logs/LogsReducer.swift b/EhPanda/View/Setting/Logs/LogsReducer.swift index 55df0a967..f03ad0f89 100644 --- a/EhPanda/View/Setting/Logs/LogsReducer.swift +++ b/EhPanda/View/Setting/Logs/LogsReducer.swift @@ -1,8 +1,3 @@ -// -// LogsReducer.swift -// EhPanda -// - import ComposableArchitecture @Reducer diff --git a/EhPanda/View/Setting/Logs/LogsView.swift b/EhPanda/View/Setting/Logs/LogsView.swift index 9c300e692..7591f667b 100644 --- a/EhPanda/View/Setting/Logs/LogsView.swift +++ b/EhPanda/View/Setting/Logs/LogsView.swift @@ -1,8 +1,3 @@ -// -// LogsView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/SettingReducer+Body.swift b/EhPanda/View/Setting/SettingReducer+Body.swift index fab6646b7..1e4fcf3d1 100644 --- a/EhPanda/View/Setting/SettingReducer+Body.swift +++ b/EhPanda/View/Setting/SettingReducer+Body.swift @@ -1,8 +1,3 @@ -// -// SettingReducer+Body.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Setting/SettingReducer+Helpers.swift b/EhPanda/View/Setting/SettingReducer+Helpers.swift index e6e398cb6..b193748b0 100644 --- a/EhPanda/View/Setting/SettingReducer+Helpers.swift +++ b/EhPanda/View/Setting/SettingReducer+Helpers.swift @@ -1,8 +1,3 @@ -// -// SettingReducer+Helpers.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Setting/SettingReducer.swift b/EhPanda/View/Setting/SettingReducer.swift index 5b4c522f6..8d7a05403 100644 --- a/EhPanda/View/Setting/SettingReducer.swift +++ b/EhPanda/View/Setting/SettingReducer.swift @@ -1,8 +1,3 @@ -// -// SettingReducer.swift -// EhPanda -// - import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift index 7a6800abb..ac60709dc 100644 --- a/EhPanda/View/Setting/SettingView.swift +++ b/EhPanda/View/Setting/SettingView.swift @@ -1,8 +1,3 @@ -// -// SettingView.swift -// EhPanda -// - import SwiftUI import SFSafeSymbols import ComposableArchitecture diff --git a/EhPanda/View/Support/Components/ActivityView.swift b/EhPanda/View/Support/Components/ActivityView.swift index 696ff6469..6215324c5 100644 --- a/EhPanda/View/Support/Components/ActivityView.swift +++ b/EhPanda/View/Support/Components/ActivityView.swift @@ -1,8 +1,3 @@ -// -// ActivityView.swift -// EhPanda -// - import SwiftUI struct ActivityView: UIViewControllerRepresentable { diff --git a/EhPanda/View/Support/Components/AlertView.swift b/EhPanda/View/Support/Components/AlertView.swift index d4fe17700..95d933bb4 100644 --- a/EhPanda/View/Support/Components/AlertView.swift +++ b/EhPanda/View/Support/Components/AlertView.swift @@ -1,8 +1,3 @@ -// -// AlertView.swift -// EhPanda -// - import SwiftUI import SFSafeSymbols diff --git a/EhPanda/View/Support/Components/CategoryView.swift b/EhPanda/View/Support/Components/CategoryView.swift index aa70d331c..72019cb48 100644 --- a/EhPanda/View/Support/Components/CategoryView.swift +++ b/EhPanda/View/Support/Components/CategoryView.swift @@ -1,8 +1,3 @@ -// -// CategoryView.swift -// EhPanda -// - import SwiftUI // MARK: CategoryLabel diff --git a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift index 5eb828b79..e9ba58ff7 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift @@ -1,8 +1,3 @@ -// -// GalleryCardCell.swift -// EhPanda -// - import SwiftUI import Colorful import Kingfisher diff --git a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift index 3408ee993..913fb7442 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift @@ -1,8 +1,3 @@ -// -// GalleryDetailCell.swift -// EhPanda -// - import SwiftUI import Kingfisher diff --git a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift index 96be0877b..19c1a3ef6 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift @@ -1,8 +1,3 @@ -// -// GalleryHistoryCell.swift -// EhPanda -// - import SwiftUI import Kingfisher diff --git a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift index ff688ce54..900b1225e 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift @@ -1,8 +1,3 @@ -// -// GalleryRankingCell.swift -// EhPanda -// - import SwiftUI import Kingfisher diff --git a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift index 9841ddd82..0243f434f 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -1,8 +1,3 @@ -// -// GalleryThumbnailCell.swift -// EhPanda -// - import SwiftUI import Kingfisher diff --git a/EhPanda/View/Support/Components/DateSeekPickerView.swift b/EhPanda/View/Support/Components/DateSeekPickerView.swift index de16bf45f..3c3a2b46c 100644 --- a/EhPanda/View/Support/Components/DateSeekPickerView.swift +++ b/EhPanda/View/Support/Components/DateSeekPickerView.swift @@ -1,8 +1,3 @@ -// -// DateSeekPickerView.swift -// EhPanda -// - import SFSafeSymbols import SwiftUI diff --git a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift index 36d612c48..3f796e932 100644 --- a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift +++ b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift @@ -1,8 +1,3 @@ -// -// DownloadBadgeLabel.swift -// EhPanda -// - import SwiftUI struct DownloadBadgeLabel: View { diff --git a/EhPanda/View/Support/Components/GenericList.swift b/EhPanda/View/Support/Components/GenericList.swift index 02579f075..e3419c576 100644 --- a/EhPanda/View/Support/Components/GenericList.swift +++ b/EhPanda/View/Support/Components/GenericList.swift @@ -1,8 +1,3 @@ -// -// GenericList.swift -// EhPanda -// - import SwiftUI import WaterfallGrid import ComposableArchitecture diff --git a/EhPanda/View/Support/Components/Placeholder.swift b/EhPanda/View/Support/Components/Placeholder.swift index db9c3b7fe..cd2ea4cbd 100644 --- a/EhPanda/View/Support/Components/Placeholder.swift +++ b/EhPanda/View/Support/Components/Placeholder.swift @@ -1,8 +1,3 @@ -// -// Placeholder.swift -// EhPanda -// - import SwiftUI struct Placeholder: View { diff --git a/EhPanda/View/Support/Components/PreviewImageView.swift b/EhPanda/View/Support/Components/PreviewImageView.swift index 8431238ca..faed7504f 100644 --- a/EhPanda/View/Support/Components/PreviewImageView.swift +++ b/EhPanda/View/Support/Components/PreviewImageView.swift @@ -1,8 +1,3 @@ -// -// PreviewImageView.swift -// EhPanda -// - import SwiftUI import ImageIO import Kingfisher diff --git a/EhPanda/View/Support/Components/SettingTextField.swift b/EhPanda/View/Support/Components/SettingTextField.swift index 849012073..7b3d5627d 100644 --- a/EhPanda/View/Support/Components/SettingTextField.swift +++ b/EhPanda/View/Support/Components/SettingTextField.swift @@ -1,8 +1,3 @@ -// -// SettingTextField.swift -// SettingTextField -// - import SwiftUI struct SettingTextField: View { diff --git a/EhPanda/View/Support/Components/SubSection.swift b/EhPanda/View/Support/Components/SubSection.swift index 4a9ec67c7..1004fa54e 100644 --- a/EhPanda/View/Support/Components/SubSection.swift +++ b/EhPanda/View/Support/Components/SubSection.swift @@ -1,8 +1,3 @@ -// -// SubSection.swift -// EhPanda -// - import SwiftUI struct SubSection: View { diff --git a/EhPanda/View/Support/Components/TagCloudView.swift b/EhPanda/View/Support/Components/TagCloudView.swift index f043c8a6a..6d5020660 100644 --- a/EhPanda/View/Support/Components/TagCloudView.swift +++ b/EhPanda/View/Support/Components/TagCloudView.swift @@ -1,7 +1,3 @@ -// -// TagCloudView.swift -// EhPanda -// // Copied from https://stackoverflow.com/questions/62102647/ // diff --git a/EhPanda/View/Support/Components/TagSuggestionView.swift b/EhPanda/View/Support/Components/TagSuggestionView.swift index 4cf77d551..c75f09b94 100644 --- a/EhPanda/View/Support/Components/TagSuggestionView.swift +++ b/EhPanda/View/Support/Components/TagSuggestionView.swift @@ -1,8 +1,3 @@ -// -// TagSuggestionView.swift -// EhPanda -// - import SwiftUI import Kingfisher import Observation diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/EhPanda/View/Support/Components/ToolbarItems.swift index 2c4349ea3..7d0ec2781 100644 --- a/EhPanda/View/Support/Components/ToolbarItems.swift +++ b/EhPanda/View/Support/Components/ToolbarItems.swift @@ -1,8 +1,3 @@ -// -// ToolbarItems.swift -// EhPanda -// - import SwiftUI struct CustomToolbarItem: ToolbarContent { diff --git a/EhPanda/View/Support/Components/WaveForm.swift b/EhPanda/View/Support/Components/WaveForm.swift index a61676ae5..43aea4972 100644 --- a/EhPanda/View/Support/Components/WaveForm.swift +++ b/EhPanda/View/Support/Components/WaveForm.swift @@ -1,7 +1,3 @@ -// -// WaveForm.swift -// WaveForm -// // Copied from Kavsoft // diff --git a/EhPanda/View/Support/DateSeekReducer.swift b/EhPanda/View/Support/DateSeekReducer.swift index a56fb3342..6a8c24a8b 100644 --- a/EhPanda/View/Support/DateSeekReducer.swift +++ b/EhPanda/View/Support/DateSeekReducer.swift @@ -1,8 +1,3 @@ -// -// DateSeekReducer.swift -// EhPanda -// - import ComposableArchitecture import Foundation diff --git a/EhPanda/View/Support/FiltersReducer.swift b/EhPanda/View/Support/FiltersReducer.swift index 243c6dc93..6c93d4bdf 100644 --- a/EhPanda/View/Support/FiltersReducer.swift +++ b/EhPanda/View/Support/FiltersReducer.swift @@ -1,8 +1,3 @@ -// -// FiltersReducer.swift -// EhPanda -// - import ComposableArchitecture @Reducer diff --git a/EhPanda/View/Support/FiltersView.swift b/EhPanda/View/Support/FiltersView.swift index e7cdc7804..32d63fe85 100644 --- a/EhPanda/View/Support/FiltersView.swift +++ b/EhPanda/View/Support/FiltersView.swift @@ -1,8 +1,3 @@ -// -// FiltersView.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Support/NewDawnView.swift b/EhPanda/View/Support/NewDawnView.swift index 00667b549..15c15d081 100644 --- a/EhPanda/View/Support/NewDawnView.swift +++ b/EhPanda/View/Support/NewDawnView.swift @@ -1,8 +1,3 @@ -// -// NewDawnView.swift -// EhPanda -// - import SwiftUI struct NewDawnView: View { diff --git a/EhPanda/View/TabBar/TabBarReducer.swift b/EhPanda/View/TabBar/TabBarReducer.swift index d3c78ed2d..6b321e80b 100644 --- a/EhPanda/View/TabBar/TabBarReducer.swift +++ b/EhPanda/View/TabBar/TabBarReducer.swift @@ -1,8 +1,3 @@ -// -// TabBarReducer.swift -// EhPanda -// - import ComposableArchitecture @Reducer diff --git a/EhPanda/View/TabBar/TabBarView.swift b/EhPanda/View/TabBar/TabBarView.swift index 56e9ac154..ea1f48f53 100644 --- a/EhPanda/View/TabBar/TabBarView.swift +++ b/EhPanda/View/TabBar/TabBarView.swift @@ -1,8 +1,3 @@ -// -// TabBarView.swift -// EhPanda -// - import SwiftUI import SFSafeSymbols import ComposableArchitecture diff --git a/EhPandaTests/Models/HTMLFilename.swift b/EhPandaTests/Models/HTMLFilename.swift index 481bf53f7..79724c854 100644 --- a/EhPandaTests/Models/HTMLFilename.swift +++ b/EhPandaTests/Models/HTMLFilename.swift @@ -1,8 +1,3 @@ -// -// HTMLFilename.swift -// EhPandaTests -// - enum HTMLFilename: String { // List // FrontPage diff --git a/EhPandaTests/Models/ListParserTestType.swift b/EhPandaTests/Models/ListParserTestType.swift index c1d9dcdb1..e5cca70f6 100644 --- a/EhPandaTests/Models/ListParserTestType.swift +++ b/EhPandaTests/Models/ListParserTestType.swift @@ -1,8 +1,3 @@ -// -// ListParserTestType.swift -// EhPandaTests -// - enum ListParserTestType: CaseIterable { // FrontPage case frontPageMinimalList diff --git a/EhPandaTests/Models/TestError.swift b/EhPandaTests/Models/TestError.swift index 71f33c6cb..e42d2bd25 100644 --- a/EhPandaTests/Models/TestError.swift +++ b/EhPandaTests/Models/TestError.swift @@ -1,8 +1,3 @@ -// -// TestError.swift -// EhPandaTests -// - enum TestError: Error { case htmlDocumentNotFound(HTMLFilename) case parsingFailed(String) diff --git a/EhPandaTests/Resources/Utility/TestHelper.swift b/EhPandaTests/Resources/Utility/TestHelper.swift index f64de674e..dbb257078 100644 --- a/EhPandaTests/Resources/Utility/TestHelper.swift +++ b/EhPandaTests/Resources/Utility/TestHelper.swift @@ -1,8 +1,3 @@ -// -// TestHelper.swift -// TestHelper -// - import Kanna import Testing import Foundation diff --git a/EhPandaTests/Tests/Download/DataCacheTests.swift b/EhPandaTests/Tests/Download/DataCacheTests.swift index adaa58bde..88c56f637 100644 --- a/EhPandaTests/Tests/Download/DataCacheTests.swift +++ b/EhPandaTests/Tests/Download/DataCacheTests.swift @@ -1,8 +1,3 @@ -// -// DataCacheTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DatabaseClientUpdateTests.swift b/EhPandaTests/Tests/Download/DatabaseClientUpdateTests.swift index 6df10ad11..41fc5cbdc 100644 --- a/EhPandaTests/Tests/Download/DatabaseClientUpdateTests.swift +++ b/EhPandaTests/Tests/Download/DatabaseClientUpdateTests.swift @@ -1,8 +1,3 @@ -// -// DatabaseClientUpdateTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift index 5b3287d6b..cf9715ca3 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -1,8 +1,3 @@ -// -// DetailReducerDownloadTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift index 2acf0580b..80b0ed52e 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -1,8 +1,3 @@ -// -// DetailReducerMetadataTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index 0dd1cff7d..f34296d8f 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -1,8 +1,3 @@ -// -// DetailReducerMetadataUpdateTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift index 10716989a..883929fc5 100644 --- a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift @@ -1,8 +1,3 @@ -// -// DetailReducerObserveTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index c0235feaf..55b16f2cd 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -1,8 +1,3 @@ -// -// DetailReducerPauseAndGuardTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift index 106c95494..bfc541493 100644 --- a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadAutomationTests.swift @@ -1,8 +1,3 @@ -// -// DownloadAutomationTests.swift -// EhPandaTests -// - import Foundation import SwiftUI import ComposableArchitecture diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundAssertionTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundAssertionTests.swift index a05e6cb1d..e0d41f38a 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundAssertionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBackgroundAssertionTests.swift @@ -1,8 +1,3 @@ -// -// DownloadBackgroundAssertionTests.swift -// EhPandaTests -// - import Foundation import Synchronization import UIKit diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift index 9a5a0755c..2ea2e231b 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift @@ -1,8 +1,3 @@ -// -// DownloadBackgroundCompletionTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift index 7e503ad1b..0a609b0f5 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift @@ -1,8 +1,3 @@ -// -// DownloadBackgroundProcessingTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift b/EhPandaTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift index 8a72ba255..764af8b52 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift @@ -1,8 +1,3 @@ -// -// DownloadBackgroundTaskStoreTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift index 7e6bb8f4f..d368de9a1 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift @@ -1,8 +1,3 @@ -// -// DownloadBadgeSortTests.swift -// EhPandaTests -// - import SwiftUI import Foundation import SFSafeSymbols diff --git a/EhPandaTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift b/EhPandaTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift index 5ebb8c909..94f97742f 100644 --- a/EhPandaTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift +++ b/EhPandaTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift @@ -1,8 +1,3 @@ -// -// DownloadCoordinatorCachedURLTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift b/EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift index 4fb995bc7..87cc3659c 100644 --- a/EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift @@ -1,8 +1,3 @@ -// -// DownloadCoordinatorCaptureTests.swift -// EhPandaTests -// - import UIKit import Foundation import Testing diff --git a/EhPandaTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift b/EhPandaTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift index 0586c504b..bb240c16c 100644 --- a/EhPandaTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift +++ b/EhPandaTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift @@ -1,8 +1,3 @@ -// -// DownloadCoordinatorRepairSeedTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift b/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift index 2957d0cd8..6bfd9b8d8 100644 --- a/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift +++ b/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift @@ -1,8 +1,3 @@ -// -// DownloadCoordinatorStorageTests.swift -// EhPandaTests -// - import Kingfisher import UIKit import Foundation diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift index 7ea61b441..1fd34212f 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -1,8 +1,3 @@ -// -// DownloadEnqueueManifestTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift index 662d5e380..e27cb02e5 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -1,8 +1,3 @@ -// -// DownloadFeatureTestFactories.swift -// EhPandaTests -// - import CoreData import Foundation import Testing diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift index 32bff8191..16743cbe1 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -1,8 +1,3 @@ -// -// DownloadFeatureTestHelpers.swift -// EhPandaTests -// - import Foundation import CoreData import ComposableArchitecture diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift index b7ac5a70c..98dea5e17 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift @@ -1,8 +1,3 @@ -// -// DownloadFeatureTestSupportTypes.swift -// EhPandaTests -// - import Foundation import Synchronization @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 240b9a509..3af9de9d1 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -1,8 +1,3 @@ -// -// DownloadFilterAndBadgeTests.swift -// EhPandaTests -// - import SwiftUI import Foundation import SFSafeSymbols diff --git a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift index fce3bb770..5b1eac800 100644 --- a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift +++ b/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift @@ -1,8 +1,3 @@ -// -// DownloadFolderOperationTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift b/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift index 03c81fa56..b5493f4cd 100644 --- a/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift @@ -1,8 +1,3 @@ -// -// DownloadImageErrorTests.swift -// EhPandaTests -// - import CoreData import Foundation import Testing diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift index 1fb8f5d94..79c06feb2 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -1,8 +1,3 @@ -// -// DownloadImageParsingCacheTests.swift -// EhPandaTests -// - import CoreData import Kingfisher import UIKit diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift index 624643e21..c8d3bc37e 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift @@ -1,8 +1,3 @@ -// -// DownloadImageParsingTests.swift -// EhPandaTests -// - import CoreData import Kingfisher import UIKit diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift index 549a777a4..21696dffd 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -1,8 +1,3 @@ -// -// DownloadInspectorLoadTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift index ff813ae5c..318f146ee 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -1,8 +1,3 @@ -// -// DownloadInspectorRetryTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift index 107b32891..e5af1f94d 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift @@ -1,8 +1,3 @@ -// -// DownloadInspectorSkipTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift index ab6c16865..c1c0a4243 100644 --- a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift +++ b/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -1,8 +1,3 @@ -// -// DownloadInterruptedResumeTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift index 027063700..89a6ce5b4 100644 --- a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift +++ b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift @@ -1,8 +1,3 @@ -// -// DownloadIpBanTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift index 5707e3afd..045efa981 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -1,8 +1,3 @@ -// -// DownloadObserverBatchTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift index 7bd3b4b21..b32f5fa47 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -1,8 +1,3 @@ -// -// DownloadObserverReadingTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift index 40c4afa6e..4da67bb99 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -1,8 +1,3 @@ -// -// DownloadObserverRefreshTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift index 5cfeb7372..2fcede268 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -1,8 +1,3 @@ -// -// DownloadPauseAndReconcileTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Kingfisher diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift index 83d504fa2..c914a966a 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -1,8 +1,3 @@ -// -// DownloadProcessCacheTests.swift -// EhPandaTests -// - import UIKit import Foundation import Testing diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/EhPandaTests/Tests/Download/DownloadProcessTests.swift index f0e2e4adc..ef926aadd 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -1,8 +1,3 @@ -// -// DownloadProcessTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadQueueStoreTests.swift b/EhPandaTests/Tests/Download/DownloadQueueStoreTests.swift index 84bd61c08..d4b9449d9 100644 --- a/EhPandaTests/Tests/Download/DownloadQueueStoreTests.swift +++ b/EhPandaTests/Tests/Download/DownloadQueueStoreTests.swift @@ -1,8 +1,3 @@ -// -// DownloadQueueStoreTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index c9a0d3200..9810cacce 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -1,8 +1,3 @@ -// -// DownloadRetryMinimalSourceTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift index 049635f6d..8f938ab6a 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -1,8 +1,3 @@ -// -// DownloadRetryPagesTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 2be2aaff0..d84dd591b 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -1,8 +1,3 @@ -// -// DownloadRetryUpdateFallbackTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift index d19e51c15..9e9708917 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift @@ -1,8 +1,3 @@ -// -// DownloadSchedulingTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift b/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift index 909100b41..efebc96c6 100644 --- a/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift +++ b/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift @@ -1,8 +1,3 @@ -// -// DownloadStoreHashTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadStoreRepairTests.swift b/EhPandaTests/Tests/Download/DownloadStoreRepairTests.swift index 6a64f35d6..7001a8be0 100644 --- a/EhPandaTests/Tests/Download/DownloadStoreRepairTests.swift +++ b/EhPandaTests/Tests/Download/DownloadStoreRepairTests.swift @@ -1,8 +1,3 @@ -// -// DownloadStoreRepairTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadStoreTests.swift b/EhPandaTests/Tests/Download/DownloadStoreTests.swift index 03a47be05..f89956ffb 100644 --- a/EhPandaTests/Tests/Download/DownloadStoreTests.swift +++ b/EhPandaTests/Tests/Download/DownloadStoreTests.swift @@ -1,8 +1,3 @@ -// -// DownloadStoreTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift index 0ec905a09..32d64b723 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -1,8 +1,3 @@ -// -// DownloadVersionSignatureTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index c2e3af2c8..e29f11b47 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -1,8 +1,3 @@ -// -// DownloadedGalleryManifestModelTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift index 8a05a5b12..d510af31e 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -1,8 +1,3 @@ -// -// DownloadsReducerActionTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift index b67d24600..448f0bab5 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift @@ -1,8 +1,3 @@ -// -// DownloadsReducerReadingDismissTests.swift -// EhPandaTests -// - import ComposableArchitecture import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift index 3327a9c0f..0fb6fc610 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift +++ b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift @@ -1,8 +1,3 @@ -// -// DownloadsReducerRefreshTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift index 4981e4efc..f434e9e36 100644 --- a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift +++ b/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift @@ -1,8 +1,3 @@ -// -// FolderManagerReducerTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift index 4abda500c..a3842113f 100644 --- a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -1,8 +1,3 @@ -// -// PreviewsReducerDownloadTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift index dee534093..fc4eee9fd 100644 --- a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift +++ b/EhPandaTests/Tests/Download/ReaderImageDataTests.swift @@ -1,8 +1,3 @@ -// -// ReaderImageDataTests.swift -// EhPandaTests -// - import Foundation import Testing import UIKit diff --git a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift index cbe55257c..926657a72 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -1,8 +1,3 @@ -// -// ReadingReducerDownloadTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift index 9b27b3e33..6ad291f07 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -1,8 +1,3 @@ -// -// ReadingReducerLocalTests.swift -// EhPandaTests -// - import Foundation import ComposableArchitecture import Testing diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift index 27450a53e..06fabd99c 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift @@ -1,8 +1,3 @@ -// -// GalleryDetailParserTests.swift -// EhPandaTests -// - import Kanna import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift index 2c54e43ad..847e384c4 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift @@ -1,8 +1,3 @@ -// -// GalleryImageURLParserTests.swift -// EhPandaTests -// - import Kanna import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift index 8ef61f90b..74b325135 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift @@ -1,8 +1,3 @@ -// -// GalleryMPVKeysParserTests.swift -// EhPandaTests -// - import Kanna import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index 2e5340fa0..31307c162 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -1,8 +1,3 @@ -// -// ListParserTests.swift -// EhPandaTests -// - import Foundation import Kanna import Testing diff --git a/EhPandaTests/Tests/Parser/Other/AnimatedImageDataTests.swift b/EhPandaTests/Tests/Parser/Other/AnimatedImageDataTests.swift index 77b796743..077f0c37b 100644 --- a/EhPandaTests/Tests/Parser/Other/AnimatedImageDataTests.swift +++ b/EhPandaTests/Tests/Parser/Other/AnimatedImageDataTests.swift @@ -1,8 +1,3 @@ -// -// AnimatedImageDataTests.swift -// EhPandaTests -// - import Foundation import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift b/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift index f71298701..2b842110c 100644 --- a/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift @@ -1,8 +1,3 @@ -// -// BanIntervalParserTests.swift -// EhPandaTests -// - import Kanna import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift b/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift index 1ef455f4e..508b08a3e 100644 --- a/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift @@ -1,8 +1,3 @@ -// -// DownloadPageErrorParserTests.swift -// EhPandaTests -// - import Kanna import Combine import Testing diff --git a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift b/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift index 0f37df6d4..b4d11da00 100644 --- a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift @@ -1,8 +1,3 @@ -// -// EhSettingParserTests.swift -// EhPandaTests -// - import Kanna import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift b/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift index b00f8fb6a..968af0d4c 100644 --- a/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift @@ -1,8 +1,3 @@ -// -// GreetingParserTests.swift -// EhPandaTests -// - import Kanna import Testing @testable import EhPanda diff --git a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift index ea6c69c45..7567aaf5c 100644 --- a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -1,8 +1,3 @@ -// -// SettingDownloadTests.swift -// EhPandaTests -// - import SwiftUI import Testing @testable import EhPanda diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index 515b55cab..c51569683 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -1,8 +1,3 @@ -// -// ShareViewController.swift -// ShareExtension -// - import AppIntents import UIKit From 20d3da99a3b19131ed95bae493cb51537212f02e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 15:07:21 +0800 Subject: [PATCH 304/614] Scaffold AppPackage local Swift package Add the AppPackage/ local Swift package skeleton that all modules will migrate into, following the App-shell + local package structure. - Package.swift uses the Module-enum DSL (target/testTarget helpers, library products auto-derived from non-test targets), iOS 26, swift-tools-version 6.3.1. - Declare all 19 former Xcode-level remote dependencies here (TCA, swift-navigation, Kingfisher, SDWebImage*, SFSafeSymbols, Kanna, SwiftyOpenCC, SwiftUIPager, WaterfallGrid, Colorful, UIImageColors, SwiftCommonMark, FilePicker, TTProgressHUD, AlertKit, DeprecatedAPI, SwiftyBeaver, SwiftLintPlugins); AlertKit/TTProgressHUD track branch custom, DeprecatedAPI main. - Two placeholder targets (AppFeature, Resources), each with a .swiftlint.yml chaining to the root config via parent_config. - Add AGENTS.md (+ CLAUDE.md symlink) documenting the target layout and conventions; ignore AppPackage build/swiftpm output. This phase is purely additive: project.pbxproj is untouched, so the app still builds from its own sources. All pbxproj surgery (local package reference + linking + dependency removal) is deferred to the next phase. Verified: AppFeature builds for the iOS simulator with zero warnings/lint; app build + 286 tests still pass. --- .gitignore | 5 + AGENTS.md | 35 ++ AppPackage/Package.resolved | 303 ++++++++++++++++++ AppPackage/Package.swift | 235 ++++++++++++++ AppPackage/Sources/AppFeature/.swiftlint.yml | 1 + .../Sources/AppFeature/AppFeature.swift | 4 + AppPackage/Sources/Resources/.swiftlint.yml | 1 + AppPackage/Sources/Resources/Resources.swift | 4 + CLAUDE.md | 1 + 9 files changed, 589 insertions(+) create mode 100644 AGENTS.md create mode 100644 AppPackage/Package.resolved create mode 100644 AppPackage/Package.swift create mode 100644 AppPackage/Sources/AppFeature/.swiftlint.yml create mode 100644 AppPackage/Sources/AppFeature/AppFeature.swift create mode 100644 AppPackage/Sources/Resources/.swiftlint.yml create mode 100644 AppPackage/Sources/Resources/Resources.swift create mode 120000 CLAUDE.md diff --git a/.gitignore b/.gitignore index 4ebf63436..de898f207 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ EhPanda.xcodeproj/xcuserdata EhPanda.xcodeproj/project.xcworkspace/xcuserdata Config/LocalSigning.xcconfig + +# AppPackage (local Swift package) +AppPackage/.build +AppPackage/.swiftpm/xcode/xcuserdata +AppPackage/.swiftpm/xcode/package.xcworkspace/xcuserdata diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..0342ba9ae --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# AGENTS.md + +This file gives coding agents a reliable working guide for this repository. + +## Project structure + +EhPanda is being modularized to match the App-shell + local-package layout: + +- `App/` — the thin app-shell target. No business logic; it imports `AppFeature` and renders + the root view. +- `AppPackage/` — a local Swift package that holds all logic. Each module is a directory under + `AppPackage/Sources/`, with tests under `AppPackage/Tests/Tests`. +- `ShareExtension/` — the share extension target. +- `EhPanda.xcodeproj` — references `AppPackage` as a local Swift package + (`XCLocalSwiftPackageReference`); the app target links the `AppFeature` product. + +All third-party dependencies are declared in `AppPackage/Package.swift`, not in the Xcode project. + +## Programming Instructions + +**Reducer naming convention**: Name reducers with a `Feature` suffix, for example `SettingFeature`. +This is a project preference that overrides TCA's standard naming convention and any conflicting +guidance from skills, training data, or search results. Follow it unless the user directly +instructs otherwise. + +**SwiftLint coverage for new modules**: When adding a new module, create a `.swiftlint.yml` file at +that module's root. Configure it to reference the appropriate parent SwiftLint config with +`parent_config` (`parent_config: ../../../.swiftlint.yml` for a module under `AppPackage/Sources`) +so the project's SwiftLint rules cover the new module. + +**Read SwiftLint rules**: Before writing or changing Swift code, read the root `.swiftlint.yml` to +learn the project's lint rules, including the custom regex rules and banned APIs it defines. Write +code that conforms to those rules from the start, and resolve every violation at its root. +Suppressing a rule, disabling it, adding a `// swiftlint:disable`, or otherwise removing it, is +forbidden without the user's explicit permission. diff --git a/AppPackage/Package.resolved b/AppPackage/Package.resolved new file mode 100644 index 000000000..8c42d5a78 --- /dev/null +++ b/AppPackage/Package.resolved @@ -0,0 +1,303 @@ +{ + "originHash" : "b1cacab09b6a9292869c6044489096831026b23a039f6b8fb6246abd6fc76d5d", + "pins" : [ + { + "identity" : "alertkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/EhPanda-Team/AlertKit", + "state" : { + "branch" : "custom", + "revision" : "39b01c53ffadf3dab9871dd4c960cd81af5246b6" + } + }, + { + "identity" : "colorful", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Co2333/Colorful", + "state" : { + "revision" : "d673ab1b5aaaf2f968fdd73830e318fd4c6910f3", + "version" : "1.1.1" + } + }, + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "dcccb979a2183b8df3334237e3dc1ae2b4116a86", + "version" : "1.2.0" + } + }, + { + "identity" : "deprecatedapi", + "kind" : "remoteSourceControl", + "location" : "https://github.com/EhPanda-Team/DeprecatedAPI", + "state" : { + "branch" : "main", + "revision" : "021e29675457a9b4b7859a46afbb5d0e37574e84" + } + }, + { + "identity" : "filepicker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/markrenaud/FilePicker", + "state" : { + "revision" : "720f8cb5ca0c0efc982ed381afc84ba3e8b3214e", + "version" : "1.0.1" + } + }, + { + "identity" : "kanna", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tid-kijyun/Kanna", + "state" : { + "revision" : "3c73af6d3859d9240db60aef233941a715387744", + "version" : "6.1.0" + } + }, + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher", + "state" : { + "revision" : "ac632bd26a1c00f139ff62fd01806f21cf67325e", + "version" : "8.10.0" + } + }, + { + "identity" : "libwebp-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", + "state" : { + "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", + "version" : "1.5.0" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage.git", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "sdwebimageswiftui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageSwiftUI", + "state" : { + "revision" : "0e331457ca9af2f0b08bcaa138a91ffb907b004f", + "version" : "3.1.4" + } + }, + { + "identity" : "sdwebimagewebpcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder", + "state" : { + "revision" : "12d83edbcc795fb7b5c0c3cb74d739108d3357d2", + "version" : "0.15.0" + } + }, + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", + "state" : { + "revision" : "e01b3d4f861412f8dcee8d93c417d2c2b0cdfd77", + "version" : "7.0.0" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "1197e80bc7e4b177051b6869ef93d8ac3ad677da", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "72d749bf341b78851203066ab421869b783ec42a", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "state" : { + "revision" : "e2fa1df6cd9eec6fa6314aa20513e47da576f24e", + "version" : "1.26.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "a90e2e40a7a840a853dd29e57cbef5dbb72c9d5b", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "8dc1fbf2f6255a73dec53b4648164884898db4c5", + "version" : "1.14.1" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "6cff006b3607b700029dc44f06b41a6cc7384ac7", + "version" : "2.10.2" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "25ac73741c3436605d61eceb5207e896973918e7", + "version" : "2.0.10" + } + }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-sharing", + "state" : { + "revision" : "c8dd3627eb92cef1bcb44ac5a93d558ab32b82ce", + "version" : "2.9.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" + } + }, + { + "identity" : "swiftcommonmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/SwiftCommonMark", + "state" : { + "revision" : "ed252beaddecce28ea6363f800c773d6169011b8", + "version" : "1.0.0" + } + }, + { + "identity" : "swiftlintplugins", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", + "state" : { + "revision" : "ecb6907e0db6f79ed2c7a569df4e723a47615208", + "version" : "0.64.1" + } + }, + { + "identity" : "swiftuipager", + "kind" : "remoteSourceControl", + "location" : "https://github.com/fermoya/SwiftUIPager", + "state" : { + "revision" : "4ddc04c801aac143090bb14cf26603a3bf9c74cb", + "version" : "2.5.0" + } + }, + { + "identity" : "swiftybeaver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SwiftyBeaver/SwiftyBeaver", + "state" : { + "revision" : "8cba041db09596183331d123f337d0eb2e6e8e91", + "version" : "2.1.1" + } + }, + { + "identity" : "swiftyopencc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ddddxxx/SwiftyOpenCC", + "state" : { + "revision" : "1d8105a0f7199c90af722bff62728050c858e777", + "version" : "2.0.0-beta" + } + }, + { + "identity" : "ttprogresshud", + "kind" : "remoteSourceControl", + "location" : "https://github.com/EhPanda-Team/TTProgressHUD", + "state" : { + "branch" : "custom", + "revision" : "349b595c4f0ff86e8d3c8d65be206a02642fd525" + } + }, + { + "identity" : "uiimagecolors", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jathu/UIImageColors", + "state" : { + "revision" : "e49e6c32ea556e9fa0109dc79686bea4a10d41a2", + "version" : "2.2.0" + } + }, + { + "identity" : "waterfallgrid", + "kind" : "remoteSourceControl", + "location" : "https://github.com/paololeonardi/WaterfallGrid", + "state" : { + "revision" : "c7c08652c3540adf8e48409c351879b4caea7e89", + "version" : "1.1.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "401bf70d95bfe8db2a1dc619f9e175a85c089321", + "version" : "1.10.1" + } + } + ], + "version" : 3 +} diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift new file mode 100644 index 000000000..c6ccdaa2b --- /dev/null +++ b/AppPackage/Package.swift @@ -0,0 +1,235 @@ +// swift-tools-version: 6.3.1 + +import PackageDescription + +// MARK: Dependency +var dependencies: [PackageDescription.Package.Dependency] = [ + .package(url: "https://github.com/Co2333/Colorful", from: "1.0.0"), + .package(url: "https://github.com/EhPanda-Team/AlertKit", branch: "custom"), + .package(url: "https://github.com/EhPanda-Team/DeprecatedAPI", branch: "main"), + .package(url: "https://github.com/EhPanda-Team/TTProgressHUD", branch: "custom"), + .package(url: "https://github.com/SDWebImage/SDWebImageSwiftUI", from: "3.0.0"), + .package(url: "https://github.com/SDWebImage/SDWebImageWebPCoder", from: "0.14.0"), + .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "7.0.0"), + .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.63.0"), + .package(url: "https://github.com/SwiftyBeaver/SwiftyBeaver", from: "2.0.0"), + .package(url: "https://github.com/ddddxxx/SwiftyOpenCC", exact: "2.0.0-beta"), + .package(url: "https://github.com/fermoya/SwiftUIPager", from: "2.5.0"), + .package(url: "https://github.com/gonzalezreal/SwiftCommonMark", from: "1.0.0"), + .package(url: "https://github.com/jathu/UIImageColors", from: "2.2.0"), + .package(url: "https://github.com/markrenaud/FilePicker", from: "1.0.0"), + .package(url: "https://github.com/onevcat/Kingfisher", from: "8.0.0"), + .package(url: "https://github.com/paololeonardi/WaterfallGrid", from: "1.0.0"), + .package( + url: "https://github.com/pointfreeco/swift-composable-architecture", + from: "1.25.0" + ), + .package(url: "https://github.com/pointfreeco/swift-navigation", from: "2.8.0"), + .package(url: "https://github.com/tid-kijyun/Kanna", from: "6.0.0") +] + +extension PackageDescription.Target.Dependency { + static let alertKit: Self = .product(name: "AlertKit", package: "AlertKit") + static let colorful: Self = .product(name: "Colorful", package: "Colorful") + static let commonMark: Self = .product(name: "CommonMark", package: "SwiftCommonMark") + static let composableArchitecture: Self = .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ) + static let deprecatedAPI: Self = .product(name: "DeprecatedAPI", package: "DeprecatedAPI") + static let filePicker: Self = .product(name: "FilePicker", package: "FilePicker") + static let kanna: Self = .product(name: "Kanna", package: "Kanna") + static let kingfisher: Self = .product(name: "Kingfisher", package: "Kingfisher") + static let openCC: Self = .product(name: "OpenCC", package: "SwiftyOpenCC") + static let sdWebImageSwiftUI: Self = .product(name: "SDWebImageSwiftUI", package: "SDWebImageSwiftUI") + static let sdWebImageWebPCoder: Self = .product(name: "SDWebImageWebPCoder", package: "SDWebImageWebPCoder") + static let sfSafeSymbols: Self = .product(name: "SFSafeSymbols", package: "SFSafeSymbols") + static let swiftUINavigation: Self = .product(name: "SwiftUINavigation", package: "swift-navigation") + static let swiftUIPager: Self = .product(name: "SwiftUIPager", package: "SwiftUIPager") + static let swiftyBeaver: Self = .product(name: "SwiftyBeaver", package: "SwiftyBeaver") + static let ttProgressHUD: Self = .product(name: "TTProgressHUD", package: "TTProgressHUD") + static let uiImageColors: Self = .product(name: "UIImageColors", package: "UIImageColors") + static let waterfallGrid: Self = .product(name: "WaterfallGrid", package: "WaterfallGrid") +} + +let swiftLintPlugins: [PackageDescription.Target.PluginUsage] = [ + .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") +] + +// MARK: Module +enum Module: String { + case appFeature = "AppFeature" + case resources = "Resources" +} + +extension Module { + enum Dependency { + case module(Module) + case literal(String) + case targetDependency(PackageDescription.Target.Dependency) + + var targetDependency: PackageDescription.Target.Dependency { + switch self { + case .module(let module): + return .init(stringLiteral: module.rawValue) + + case .literal(let stringLiteral): + return .init(stringLiteral: stringLiteral) + + case .targetDependency(let dependency): + return dependency + } + } + } +} + +// MARK: Exclude +enum Path: String { + case resources = "Resources" +} + +enum Exclude { + case literal(String) + case path(Path) + + var name: String { + switch self { + case .literal(let stringLiteral): + return stringLiteral + + case .path(let path): + return path.rawValue + } + } +} + +// MARK: Resource +enum Resource { + case copy(Path) + case embedInCode(Path) + case process(Path, PackageDescription.Resource.Localization? = nil) + + var value: PackageDescription.Resource { + switch self { + case .copy(let path): + return .copy(path.rawValue) + + case .embedInCode(let path): + return .embedInCode(path.rawValue) + + case .process(let path, let localization): + return .process(path.rawValue, localization: localization) + } + } +} + +// MARK: Helper methods +extension PackageDescription.Target { + static func target( + module: Module, + dependencies: [Module.Dependency] = .init(), + path: String? = nil, + exclude: [Exclude] = .init(), + sources: [String]? = nil, + resources: [Resource]? = nil, + publicHeadersPath: String? = nil, + packageAccess: Bool = true, + cSettings: [PackageDescription.CSetting]? = nil, + cxxSettings: [PackageDescription.CXXSetting]? = nil, + swiftSettings: [PackageDescription.SwiftSetting]? = nil, + linkerSettings: [PackageDescription.LinkerSetting]? = nil, + plugins: [PackageDescription.Target.PluginUsage]? = nil + ) -> PackageDescription.Target { + target( + name: module.rawValue, + dependencies: dependencies.map(\.targetDependency), + path: path, + exclude: exclude.map(\.name), + sources: sources, + resources: resources?.map(\.value), + publicHeadersPath: publicHeadersPath, + packageAccess: packageAccess, + cSettings: cSettings, + cxxSettings: cxxSettings, + swiftSettings: swiftSettings, + linkerSettings: linkerSettings, + plugins: plugins + ) + } + + static func testTarget( + module: Module, + dependencies: [Module.Dependency] = .init(), + path: String? = nil, + exclude: [Exclude] = .init(), + sources: [String]? = nil, + resources: [Resource]? = nil, + packageAccess: Bool = true, + cSettings: [PackageDescription.CSetting]? = nil, + cxxSettings: [PackageDescription.CXXSetting]? = nil, + swiftSettings: [PackageDescription.SwiftSetting]? = nil, + linkerSettings: [PackageDescription.LinkerSetting]? = nil, + plugins: [PackageDescription.Target.PluginUsage]? = nil + ) -> PackageDescription.Target { + testTarget( + name: module.rawValue, + dependencies: dependencies.map(\.targetDependency), + path: path, + exclude: exclude.map(\.name), + sources: sources, + resources: resources?.map(\.value), + packageAccess: packageAccess, + cSettings: cSettings, + cxxSettings: cxxSettings, + swiftSettings: swiftSettings, + linkerSettings: linkerSettings, + plugins: plugins + ) + } +} + +// MARK: Target +let targets: [PackageDescription.Target] = [ + .target( + module: .appFeature, + dependencies: [ + .module(.resources), + .targetDependency(.alertKit), + .targetDependency(.colorful), + .targetDependency(.commonMark), + .targetDependency(.composableArchitecture), + .targetDependency(.deprecatedAPI), + .targetDependency(.filePicker), + .targetDependency(.kanna), + .targetDependency(.kingfisher), + .targetDependency(.openCC), + .targetDependency(.sdWebImageSwiftUI), + .targetDependency(.sdWebImageWebPCoder), + .targetDependency(.sfSafeSymbols), + .targetDependency(.swiftUINavigation), + .targetDependency(.swiftUIPager), + .targetDependency(.swiftyBeaver), + .targetDependency(.ttProgressHUD), + .targetDependency(.uiImageColors), + .targetDependency(.waterfallGrid) + ], + plugins: swiftLintPlugins + ), + .target( + module: .resources, + plugins: swiftLintPlugins + ) +] + +// MARK: Package +let package = Package( + name: "AppPackage", + defaultLocalization: "en", + platforms: [.iOS(.v26)], + products: targets + .filter({ !$0.isTest }) + .map(\.name) + .map({ .library(name: $0, targets: [$0]) }), + dependencies: dependencies, + targets: targets +) diff --git a/AppPackage/Sources/AppFeature/.swiftlint.yml b/AppPackage/Sources/AppFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AppFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/AppFeature.swift b/AppPackage/Sources/AppFeature/AppFeature.swift new file mode 100644 index 000000000..943930d7f --- /dev/null +++ b/AppPackage/Sources/AppFeature/AppFeature.swift @@ -0,0 +1,4 @@ +import Foundation + +// Placeholder. The monolith's sources are migrated into this module in M2. +enum AppFeatureModule {} diff --git a/AppPackage/Sources/Resources/.swiftlint.yml b/AppPackage/Sources/Resources/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/Resources/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/Resources/Resources.swift b/AppPackage/Sources/Resources/Resources.swift new file mode 100644 index 000000000..b30d31257 --- /dev/null +++ b/AppPackage/Sources/Resources/Resources.swift @@ -0,0 +1,4 @@ +import Foundation + +// Placeholder. Generated strings, localizations, and assets land here in M2. +enum ResourcesModule {} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..55bf822df --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +./AGENTS.md \ No newline at end of file From f7a2221a324bf15b7af6e15768fe3e28b3e3b3b2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 15:42:54 +0800 Subject: [PATCH 305/614] Move app code into AppPackage AppFeature module Convert EhPanda from a monolithic app target into a thin app shell linking the local AppPackage, with all logic in one AppFeature module (further split happens in later phases). Sources & shell: - Move DataFlow, Database, Models, Network, View, Tools, and generated strings into AppPackage/Sources/AppFeature. - App/ is now a thin shell: EhPandaApp.swift builds the store and renders the new public RootView; AppDelegate is public. - Localizable/Constant strings move into the module (loaded via Bundle.module by the generated Strings); InfoPlist strings, Assets, Icons, Info.plist, and entitlements stay in App/. Core Data: - Move Model.xcdatamodeld + the mapping model into the module and load them from Bundle.module (the package test runner has no host app, so Bundle.main no longer resolves them). - Qualify each entity's representedClassName with the AppFeature module so the managed-object subclasses resolve. Package / concurrency: - AppFeature processes its resources; enable the upcoming features matching the app's Approachable Concurrency so the moved code keeps compiling. Pin Colorful to 1.0.x (1.1 deprecates ColorfulView). - Move EhPandaTests into AppPackage/Tests/AppFeatureTests (@testable import AppFeature, resources via Bundle.module). Xcode project: - App target links the AppFeature product via a local package reference; remove the 18 remote library dependencies (now vended by the package) and the SwiftGen build phase; keep the SwiftLint plugin. Repoint the synchronized group to App/ and remove the EhPandaTests target. Verified: app clean-builds with no warnings (only the pre-existing SwiftUINavigation deprecation) or lint violations; all 286 package tests pass via the AppPackage-Package scheme. Wiring the package tests into the app scheme is a follow-up. --- .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/100.png | Bin .../AppIcon.appiconset/1024.png | Bin .../AppIcon.appiconset/114.png | Bin .../AppIcon.appiconset/120.png | Bin .../AppIcon.appiconset/128.png | Bin .../AppIcon.appiconset/144.png | Bin .../AppIcon.appiconset/152.png | Bin .../Assets.xcassets/AppIcon.appiconset/16.png | Bin .../AppIcon.appiconset/167.png | Bin .../AppIcon.appiconset/172.png | Bin .../AppIcon.appiconset/180.png | Bin .../AppIcon.appiconset/196.png | Bin .../Assets.xcassets/AppIcon.appiconset/20.png | Bin .../AppIcon.appiconset/216.png | Bin .../AppIcon.appiconset/256.png | Bin .../Assets.xcassets/AppIcon.appiconset/29.png | Bin .../Assets.xcassets/AppIcon.appiconset/32.png | Bin .../Assets.xcassets/AppIcon.appiconset/40.png | Bin .../Assets.xcassets/AppIcon.appiconset/48.png | Bin .../Assets.xcassets/AppIcon.appiconset/50.png | Bin .../AppIcon.appiconset/512.png | Bin .../Assets.xcassets/AppIcon.appiconset/55.png | Bin .../Assets.xcassets/AppIcon.appiconset/57.png | Bin .../Assets.xcassets/AppIcon.appiconset/58.png | Bin .../Assets.xcassets/AppIcon.appiconset/60.png | Bin .../Assets.xcassets/AppIcon.appiconset/64.png | Bin .../Assets.xcassets/AppIcon.appiconset/72.png | Bin .../Assets.xcassets/AppIcon.appiconset/76.png | Bin .../Assets.xcassets/AppIcon.appiconset/80.png | Bin .../Assets.xcassets/AppIcon.appiconset/87.png | Bin .../Assets.xcassets/AppIcon.appiconset/88.png | Bin .../AppIcon.appiconset/Contents.json | 0 .../Category/Colors/Contents.json | 0 .../E-Hentai/Artist CG.colorset/Contents.json | 0 .../Asian Porn.colorset/Contents.json | 0 .../Category/Colors/E-Hentai/Contents.json | 0 .../E-Hentai/Cosplay.colorset/Contents.json | 0 .../E-Hentai/Doujinshi.colorset/Contents.json | 0 .../E-Hentai/Game CG.colorset/Contents.json | 0 .../E-Hentai/Image Set.colorset/Contents.json | 0 .../E-Hentai/Manga.colorset/Contents.json | 0 .../E-Hentai/Misc.colorset/Contents.json | 0 .../E-Hentai/Non-H.colorset/Contents.json | 0 .../E-Hentai/Private.colorset/Contents.json | 0 .../E-Hentai/Western.colorset/Contents.json | 0 .../ExHentai/Artist CG.colorset/Contents.json | 0 .../Asian Porn.colorset/Contents.json | 0 .../Category/Colors/ExHentai/Contents.json | 0 .../ExHentai/Cosplay.colorset/Contents.json | 0 .../ExHentai/Doujinshi.colorset/Contents.json | 0 .../ExHentai/Game CG.colorset/Contents.json | 0 .../ExHentai/Image Set.colorset/Contents.json | 0 .../ExHentai/Manga.colorset/Contents.json | 0 .../ExHentai/Misc.colorset/Contents.json | 0 .../ExHentai/Non-H.colorset/Contents.json | 0 .../ExHentai/Private.colorset/Contents.json | 0 .../ExHentai/Western.colorset/Contents.json | 0 .../Assets.xcassets/Category/Contents.json | 0 .../App => App}/Assets.xcassets/Contents.json | 0 {EhPanda => App}/EhPanda.entitlements | 0 App/EhPandaApp.swift | 12 + .../App => App}/Icons/AppIcon_Default@2x.png | Bin .../App => App}/Icons/AppIcon_Default@3x.png | Bin .../Icons/AppIcon_Default_iPad.png | Bin .../Icons/AppIcon_Default_iPad@2x.png | Bin .../Icons/AppIcon_Default_iPad_Pro@2x.png | Bin .../Icons/AppIcon_Developer@2x.png | Bin .../Icons/AppIcon_Developer@3x.png | Bin .../Icons/AppIcon_Developer_iPad.png | Bin .../Icons/AppIcon_Developer_iPad@2x.png | Bin .../Icons/AppIcon_Developer_iPad_Pro@2x.png | Bin .../Icons/AppIcon_NotMyPresident@2x.png | Bin .../Icons/AppIcon_NotMyPresident@3x.png | Bin .../Icons/AppIcon_NotMyPresident_iPad.png | Bin .../Icons/AppIcon_NotMyPresident_iPad@2x.png | Bin .../AppIcon_NotMyPresident_iPad_Pro@2x.png | Bin .../Icons/AppIcon_StandWithUkraine2022@2x.png | Bin .../Icons/AppIcon_StandWithUkraine2022@3x.png | Bin .../AppIcon_StandWithUkraine2022_iPad.png | Bin .../AppIcon_StandWithUkraine2022_iPad@2x.png | Bin ...pIcon_StandWithUkraine2022_iPad_Pro@2x.png | Bin .../App => App}/Icons/AppIcon_Ukiyoe@2x.png | Bin .../App => App}/Icons/AppIcon_Ukiyoe@3x.png | Bin .../App => App}/Icons/AppIcon_Ukiyoe_iPad.png | Bin .../Icons/AppIcon_Ukiyoe_iPad@2x.png | Bin .../Icons/AppIcon_Ukiyoe_iPad_Pro@2x.png | Bin {EhPanda/App => App}/Info.plist | 0 .../App => App}/de.lproj/InfoPlist.strings | 0 .../App => App}/en.lproj/InfoPlist.strings | 0 .../App => App}/ja.lproj/InfoPlist.strings | 0 .../App => App}/ko.lproj/InfoPlist.strings | 0 .../zh-Hans.lproj/InfoPlist.strings | 0 .../zh-Hant-HK.lproj/InfoPlist.strings | 0 .../zh-Hant-TW.lproj/InfoPlist.strings | 0 .../zh-Hant.lproj/InfoPlist.strings | 0 AppPackage/Package.resolved | 6 +- AppPackage/Package.swift | 31 +- .../Sources/AppFeature/AppFeature.swift | 4 - .../DataFlow/AppDelegateReducer.swift | 12 +- .../AppFeature}/DataFlow/AppLockReducer.swift | 0 .../AppFeature}/DataFlow/AppReducer.swift | 0 .../DataFlow/AppRouteReducer.swift | 0 .../Sources/AppFeature}/DataFlow/Heap.swift | 0 .../FileManager+ApplicationSupport.swift | 0 .../NSManagedObjectModel+Compatible.swift | 2 +- .../NSManagedObjectModel+Resource.swift | 4 +- .../NSPersistentStoreCoordinator+SQLite.swift | 0 .../MODefinition/AppEnvMO+CoreDataClass.swift | 0 .../AppEnvMO+CoreDataProperties.swift | 0 .../GalleryDetailMO+CoreDataClass.swift | 0 .../GalleryDetailMO+CoreDataProperties.swift | 0 .../GalleryMO+CoreDataClass.swift | 0 .../GalleryMO+CoreDataProperties.swift | 0 .../GalleryStateMO+CoreDataClass.swift | 0 .../GalleryStateMO+CoreDataProperties.swift | 0 .../Migration/CoreDataMigrationStep.swift | 2 +- .../Migration/CoreDataMigrationVersion.swift | 0 .../Database/Migration/CoreDataMigrator.swift | 0 .../Model5toModel6MigrationPolicy.swift | 0 .../AppFeature}/Database/Persistence.swift | 6 +- .../AppFeature}/Generated/Strings.swift | 0 .../Models/Download/DownloadBadge.swift | 0 .../Download/DownloadDisplayStatus.swift | 0 .../Models/Download/DownloadFailure.swift | 0 .../Download/DownloadFolderFilter.swift | 0 .../Models/Download/DownloadInspection.swift | 0 .../Models/Download/DownloadProgress.swift | 0 .../Download/DownloadRequestOptions.swift | 0 .../Models/Download/DownloadStartMode.swift | 0 .../DownloadedGallery+Extensions.swift | 0 .../Download/DownloadedGallery+Manifest.swift | 0 .../DownloadedGallery+SupportTypes.swift | 0 .../Models/Download/DownloadedGallery.swift | 0 .../AppFeature}/Models/Gallery/Category.swift | 0 .../AppFeature}/Models/Gallery/Gallery.swift | 0 .../Models/Gallery/GalleryArchive.swift | 0 .../Models/Gallery/GalleryComment.swift | 0 .../Models/Gallery/GalleryDetail.swift | 0 .../Models/Gallery/GalleryState.swift | 0 .../Models/Gallery/GalleryTorrent.swift | 0 .../AppFeature}/Models/Gallery/Language.swift | 0 .../Models/Persistent/AppEnv.swift | 0 .../Models/Persistent/Filter.swift | 0 .../Models/Persistent/Greeting.swift | 0 .../Models/Persistent/Setting.swift | 0 .../AppFeature}/Models/Persistent/User.swift | 0 .../AppFeature}/Models/Support/AppError.swift | 0 .../Support/BrowsingCountry+EnglishName.swift | 0 .../Models/Support/BrowsingCountry.swift | 0 .../Models/Support/EhSetting+Enums.swift | 0 .../Models/Support/EhSetting+Extensions.swift | 0 .../Models/Support/EhSetting.swift | 0 .../AppFeature}/Models/Support/LiveText.swift | 0 .../AppFeature}/Models/Support/Misc.swift | 0 .../Tags/EhTagTranslationDatabaseModel.swift | 0 .../AppFeature}/Models/Tags/TagDetail.swift | 0 .../Models/Tags/TagNamespace.swift | 0 .../Models/Tags/TagSuggestion.swift | 0 .../Models/Tags/TagTranslation.swift | 0 .../Models/Tags/TagTranslator.swift | 0 .../Models/Tags/TranslatableLanguage.swift | 0 .../AppFeature}/Network/DFExtensions.swift | 0 .../AppFeature}/Network/DFRequest.swift | 0 .../AppFeature}/Network/DFStreamHandler.swift | 0 .../AppFeature}/Network/DFURLProtocol.swift | 0 .../AppFeature}/Network/DomainResolver.swift | 0 .../AppFeature}/Network/Request+Account.swift | 0 .../AppFeature}/Network/Request+Detail.swift | 0 .../AppFeature}/Network/Request+Gallery.swift | 0 .../AppFeature}/Network/Request+Image.swift | 0 .../Sources/AppFeature}/Network/Request.swift | 0 .../Model.xcdatamodeld/.xccurrentversion | 0 .../Model 2.xcdatamodel/contents | 8 +- .../Model 3.xcdatamodel/contents | 8 +- .../Model 4.xcdatamodel/contents | 8 +- .../Model 5.xcdatamodel/contents | 8 +- .../Model 6.xcdatamodel/contents | 8 +- .../Model 7.xcdatamodel/contents | 8 +- .../Model.xcdatamodel/contents | 8 +- .../xcmapping.xml | 0 .../Resources}/de.lproj/Localizable.strings | 0 .../Resources}/en.lproj/Constant.strings | 0 .../Resources}/en.lproj/Localizable.strings | 0 .../Resources}/ja.lproj/Localizable.strings | 0 .../Resources}/ko.lproj/Localizable.strings | 0 .../zh-Hans.lproj/Localizable.strings | 0 .../zh-Hant-HK.lproj/Localizable.strings | 0 .../zh-Hant-TW.lproj/Localizable.strings | 0 .../zh-Hant.lproj/Localizable.strings | 0 AppPackage/Sources/AppFeature/RootView.swift | 53 ++ .../Tools/Clients/AppDelegateClient.swift | 0 .../Clients/AppLaunchAutomationClient.swift | 0 .../Tools/Clients/AuthorizationClient.swift | 0 .../Clients/BackgroundProcessingClient.swift | 0 .../Tools/Clients/BackgroundTaskClient.swift | 0 .../Tools/Clients/ClipboardClient.swift | 0 .../Tools/Clients/CookieClient.swift | 0 .../AppFeature}/Tools/Clients/DFClient.swift | 0 .../Clients/DatabaseClient+Updates.swift | 0 .../Tools/Clients/DatabaseClient.swift | 0 .../Tools/Clients/DeviceClient.swift | 0 .../DownloadClient+BackgroundAssertion.swift | 0 .../DownloadClient+BackgroundDownloads.swift | 0 .../DownloadClient+BackgroundProcessing.swift | 0 .../Tools/Clients/DownloadClient+Cache.swift | 0 .../Clients/DownloadClient+Execution.swift | 0 .../DownloadClient+ExecutionFetch.swift | 0 .../DownloadClient+ExecutionPerform.swift | 0 .../DownloadClient+ExecutionSupport.swift | 0 .../Clients/DownloadClient+Folders.swift | 0 .../Clients/DownloadClient+Manager.swift | 0 .../Clients/DownloadClient+Networking.swift | 0 .../Clients/DownloadClient+PageDownload.swift | 0 .../DownloadClient+PageDownloadHelpers.swift | 0 .../Clients/DownloadClient+Persistence.swift | 0 .../DownloadClient+PersistenceHelpers.swift | 0 .../DownloadClient+PersistenceNormalize.swift | 0 .../Clients/DownloadClient+PublicAPI.swift | 0 .../DownloadClient+PublicAPIHelpers.swift | 0 .../DownloadClient+ResponseValidation.swift | 0 ...loadClient+ResponseValidationHelpers.swift | 0 .../Clients/DownloadClient+RetryHelpers.swift | 0 .../Clients/DownloadClient+Scheduling.swift | 0 .../DownloadClient+SchedulingHelpers.swift | 0 .../Clients/DownloadClient+Testing.swift | 0 .../Tools/Clients/DownloadClient.swift | 0 .../Clients/DownloadPageDownloader.swift | 0 .../Tools/Clients/FileClient.swift | 0 .../Tools/Clients/HapticsClient.swift | 0 .../Tools/Clients/ImageClient.swift | 0 .../Tools/Clients/LibraryClient.swift | 0 .../Tools/Clients/LoggerClient.swift | 0 .../Tools/Clients/UIApplicationClient.swift | 0 .../AppFeature}/Tools/Clients/URLClient.swift | 0 .../Tools/Clients/UserDefaultsClient.swift | 0 .../AppFeature}/Tools/ColorCodable.swift | 0 .../Sources/AppFeature}/Tools/Defaults.swift | 0 .../AppFeature}/Tools/EnvironmentKeys.swift | 0 .../AppFeature}/Tools/EquatableVoid.swift | 0 .../Tools/Extensions/AlertKit_Extension.swift | 0 .../Extensions/AnimatedImage_Extension.swift | 0 .../Tools/Extensions/Extensions.swift | 0 .../Tools/Extensions/Reducer_Extension.swift | 0 .../SwiftUINavigation_Extension.swift | 0 .../Extensions/TTProgressHUD_Extension.swift | 0 .../Tools/Extensions/URL+ImageCacheKey.swift | 0 .../Tools/Extensions/ViewModifiers.swift | 0 .../AppFeature}/Tools/IdentifiableBox.swift | 0 .../Tools/Parser/Parser+Archive.swift | 0 .../Tools/Parser/Parser+Comment.swift | 0 .../Tools/Parser/Parser+Detail.swift | 0 .../Tools/Parser/Parser+Favorite.swift | 0 .../Tools/Parser/Parser+Greeting.swift | 0 .../Tools/Parser/Parser+Image.swift | 0 .../Tools/Parser/Parser+List.swift | 0 .../Tools/Parser/Parser+Misc.swift | 0 .../Tools/Parser/Parser+Preview.swift | 0 .../Tools/Parser/Parser+Profile.swift | 0 .../Tools/Parser/Parser+ResponseError.swift | 0 .../Tools/Parser/Parser+Shared.swift | 0 .../Tools/Parser/Parser+Torrent.swift | 0 .../Tools/Parser/Parser+Types.swift | 0 .../Tools/Parser/Parser+User.swift | 0 .../AppFeature}/Tools/Parser/Parser.swift | 0 .../Tools/Utilities/AppLaunchAutomation.swift | 0 .../AppFeature}/Tools/Utilities/AppUtil.swift | 0 .../Tools/Utilities/CookieUtil.swift | 0 .../Tools/Utilities/DataCache.swift | 0 .../Tools/Utilities/DeviceUtil.swift | 0 .../DownloadBackgroundTaskStore.swift | 0 .../Tools/Utilities/DownloadFileManager.swift | 0 .../Tools/Utilities/DownloadQueueStore.swift | 0 .../Utilities/DownloadStore+JSONCoding.swift | 0 .../Utilities/DownloadStore+Operations.swift | 0 .../Tools/Utilities/DownloadStore.swift | 0 .../Tools/Utilities/FileUtil.swift | 0 .../Tools/Utilities/HapticsUtil.swift | 0 .../ImagePlaceholderFingerprint.swift | 0 .../Tools/Utilities/MarkdownUtil.swift | 0 .../AppFeature}/Tools/Utilities/URLUtil.swift | 0 .../Tools/Utilities/UserDefaultsUtil.swift | 0 .../Detail/Archives/ArchivesReducer.swift | 0 .../View/Detail/Archives/ArchivesView.swift | 0 .../Detail/Comments/CommentsReducer.swift | 0 .../View/Detail/Comments/CommentsView.swift | 0 .../View/Detail/Components/LinkedText.swift | 0 .../Detail/Components/PostCommentView.swift | 0 .../View/Detail/Components/RatingView.swift | 0 .../Detail/Components/TagDetailView.swift | 0 .../View/Detail/DetailReducer+Actions.swift | 0 .../View/Detail/DetailReducer+Download.swift | 0 .../View/Detail/DetailReducer+Fetch.swift | 0 .../View/Detail/DetailReducer.swift | 0 .../DetailSearch/DetailSearchReducer.swift | 0 .../DetailSearch/DetailSearchView.swift | 0 .../View/Detail/DetailView+CommentCells.swift | 0 .../Detail/DetailView+HeaderSection.swift | 0 .../View/Detail/DetailView+Navigation.swift | 0 .../View/Detail/DetailView+Subviews.swift | 0 .../AppFeature}/View/Detail/DetailView.swift | 0 .../GalleryInfos/GalleryInfosReducer.swift | 0 .../GalleryInfos/GalleryInfosView.swift | 0 .../Detail/Previews/PreviewsReducer.swift | 0 .../View/Detail/Previews/PreviewsView.swift | 0 .../Detail/Torrents/TorrentsReducer.swift | 0 .../View/Detail/Torrents/TorrentsView.swift | 0 .../Downloads/DownloadInspectorReducer.swift | 0 .../View/Downloads/DownloadsReducer.swift | 0 .../Downloads/DownloadsView+Subviews.swift | 0 .../View/Downloads/DownloadsView.swift | 0 .../View/Downloads/FolderManagerReducer.swift | 0 .../View/Downloads/FolderManagerView.swift | 0 .../View/Favorites/FavoritesReducer.swift | 0 .../View/Favorites/FavoritesView.swift | 0 .../Home/Frontpage/FrontpageReducer.swift | 0 .../View/Home/Frontpage/FrontpageView.swift | 0 .../View/Home/History/HistoryReducer.swift | 0 .../View/Home/History/HistoryView.swift | 0 .../View/Home/HomeReducer+Body.swift | 0 .../AppFeature}/View/Home/HomeReducer.swift | 0 .../View/Home/HomeView+Sections.swift | 0 .../AppFeature}/View/Home/HomeView.swift | 0 .../View/Home/Popular/PopularReducer.swift | 0 .../View/Home/Popular/PopularView.swift | 0 .../View/Home/Toplists/ToplistsReducer.swift | 0 .../View/Home/Toplists/ToplistsView.swift | 0 .../View/Home/Watched/WatchedReducer.swift | 0 .../View/Home/Watched/WatchedView.swift | 0 .../View/Migration/MigrationReducer.swift | 0 .../View/Migration/MigrationView.swift | 0 .../View/Reading/ReadingReducer+Body.swift | 0 .../Reading/ReadingReducer+Database.swift | 0 .../Reading/ReadingReducer+ImageFetch.swift | 0 .../View/Reading/ReadingReducer.swift | 0 .../View/Reading/ReadingView+Gestures.swift | 0 .../View/Reading/ReadingView.swift | 0 .../View/Reading/ReadingViewComponents.swift | 0 .../View/Reading/Support/AdvancedList.swift | 0 .../Reading/Support/AutoPlayHandler.swift | 0 .../View/Reading/Support/ControlPanel.swift | 0 .../View/Reading/Support/GestureHandler.swift | 0 .../Reading/Support/LiveTextHandler.swift | 0 .../View/Reading/Support/LiveTextView.swift | 0 .../View/Reading/Support/PageHandler.swift | 0 .../View/Search/SearchReducer.swift | 0 .../View/Search/SearchRootReducer.swift | 0 .../View/Search/SearchRootView+Keywords.swift | 0 .../View/Search/SearchRootView.swift | 0 .../AppFeature}/View/Search/SearchView.swift | 0 .../Search/Support/QuickSearchReducer.swift | 0 .../View/Search/Support/QuickSearchView.swift | 0 .../AccountSettingReducer.swift | 0 .../AccountSetting/AccountSettingView.swift | 0 .../AppearanceSettingReducer.swift | 0 .../AppearanceSettingView.swift | 0 .../View/Setting/Components/AboutView.swift | 0 .../Components/DownloadSettingView.swift | 0 .../Components/LaboratorySettingView.swift | 0 .../Components/ReadingSettingView.swift | 0 .../View/Setting/Components/WebView.swift | 0 .../Setting/EhSetting/EhSettingReducer.swift | 0 .../EhSetting/EhSettingView+Sections1.swift | 0 .../EhSetting/EhSettingView+Sections2.swift | 0 .../EhSetting/EhSettingView+Sections3.swift | 0 .../Setting/EhSetting/EhSettingView.swift | 0 .../GeneralSettingReducer.swift | 0 .../GeneralSetting/GeneralSettingView.swift | 0 .../View/Setting/Login/LoginReducer.swift | 0 .../View/Setting/Login/LoginView.swift | 0 .../View/Setting/Logs/LogsReducer.swift | 0 .../View/Setting/Logs/LogsView.swift | 0 .../View/Setting/SettingReducer+Body.swift | 0 .../View/Setting/SettingReducer+Helpers.swift | 0 .../View/Setting/SettingReducer.swift | 0 .../View/Setting/SettingView.swift | 0 .../Support/Components/ActivityView.swift | 0 .../View/Support/Components/AlertView.swift | 0 .../Support/Components/CategoryView.swift | 0 .../Components/Cells/GalleryCardCell.swift | 0 .../Components/Cells/GalleryDetailCell.swift | 0 .../Components/Cells/GalleryHistoryCell.swift | 0 .../Components/Cells/GalleryRankingCell.swift | 0 .../Cells/GalleryThumbnailCell.swift | 0 .../Components/DateSeekPickerView.swift | 0 .../Components/DownloadBadgeLabel.swift | 0 .../View/Support/Components/GenericList.swift | 0 .../View/Support/Components/Placeholder.swift | 0 .../Support/Components/PreviewImageView.swift | 0 .../Support/Components/SettingTextField.swift | 0 .../View/Support/Components/SubSection.swift | 0 .../Support/Components/TagCloudView.swift | 0 .../Components/TagSuggestionView.swift | 0 .../Support/Components/ToolbarItems.swift | 0 .../View/Support/Components/WaveForm.swift | 0 .../View/Support/DateSeekReducer.swift | 0 .../View/Support/FiltersReducer.swift | 0 .../View/Support/FiltersView.swift | 0 .../View/Support/NewDawnView.swift | 0 .../View/TabBar/TabBarReducer.swift | 0 .../AppFeature}/View/TabBar/TabBarView.swift | 0 .../Tests/AppFeatureTests/.swiftlint.yml | 1 + .../AppFeatureTests/Helpers}/TestHelper.swift | 2 +- .../Models/HTMLFilename.swift | 0 .../Models/ListParserTestType.swift | 0 .../AppFeatureTests}/Models/TestError.swift | 0 .../Parser/Gallery/GalleryDetail.html | 0 .../Parser/Gallery/GalleryMPVKeys.html | 0 .../Parser/Gallery/GalleryNormalImageURL.html | 0 .../Parser/List/FavoritesCompactList.html | 0 .../Parser/List/FavoritesExtendedList.html | 0 .../Parser/List/FavoritesMinimalList.html | 0 .../Parser/List/FavoritesMinimalPlusList.html | 0 .../Parser/List/FavoritesThumbnailList.html | 0 .../Parser/List/FrontPageCompactList.html | 0 .../Parser/List/FrontPageExtendedList.html | 0 .../Parser/List/FrontPageMinimalList.html | 0 .../Parser/List/FrontPageMinimalPlusList.html | 0 .../Parser/List/FrontPageThumbnailList.html | 0 .../Parser/List/PopularCompactList.html | 0 .../Parser/List/PopularExtendedList.html | 0 .../Parser/List/PopularMinimalList.html | 0 .../Parser/List/PopularMinimalPlusList.html | 0 .../Parser/List/PopularThumbnailList.html | 0 .../Parser/List/ToplistsCompactList.html | 0 .../Parser/List/WatchedCompactList.html | 0 .../Parser/List/WatchedExtendedList.html | 0 .../Parser/List/WatchedMinimalList.html | 0 .../Parser/List/WatchedMinimalPlusList.html | 0 .../Parser/List/WatchedThumbnailList.html | 0 .../Parser/Other/BandwidthExceeded.html | Bin .../Resources/Parser/Other/EhSetting.html | 0 .../Parser/Other/ExLoginRequired.html | 0 .../Other/GalleryDetailWithGreeting.html | 0 .../Resources/Parser/Other/IPBanned.html | 0 .../Resources/Parser/Other/Kokomade.jpg | Bin .../Tests/Download/DataCacheTests.swift | 2 +- .../Download/DatabaseClientUpdateTests.swift | 2 +- .../Download/DetailReducerDownloadTests.swift | 2 +- .../Download/DetailReducerMetadataTests.swift | 2 +- .../DetailReducerMetadataUpdateTests.swift | 2 +- .../Download/DetailReducerObserveTests.swift | 2 +- .../DetailReducerPauseAndGuardTests.swift | 2 +- .../Download/DownloadAutomationTests.swift | 2 +- .../DownloadBackgroundAssertionTests.swift | 2 +- .../DownloadBackgroundCompletionTests.swift | 2 +- .../DownloadBackgroundProcessingTests.swift | 2 +- .../DownloadBackgroundTaskStoreTests.swift | 2 +- .../Download/DownloadBadgeSortTests.swift | 2 +- .../DownloadCoordinatorCachedURLTests.swift | 2 +- .../DownloadCoordinatorCaptureTests.swift | 2 +- .../DownloadCoordinatorRepairSeedTests.swift | 2 +- .../DownloadCoordinatorStorageTests.swift | 2 +- .../DownloadEnqueueManifestTests.swift | 2 +- .../DownloadFeatureTestFactories.swift | 6 +- .../Download/DownloadFeatureTestHelpers.swift | 4 +- .../DownloadFeatureTestSupportTypes.swift | 2 +- .../DownloadFilterAndBadgeTests.swift | 2 +- .../DownloadFolderOperationTests.swift | 2 +- .../Download/DownloadImageErrorTests.swift | 2 +- .../DownloadImageParsingCacheTests.swift | 2 +- .../Download/DownloadImageParsingTests.swift | 2 +- .../Download/DownloadInspectorLoadTests.swift | 2 +- .../DownloadInspectorRetryTests.swift | 2 +- .../Download/DownloadInspectorSkipTests.swift | 2 +- .../DownloadInterruptedResumeTests.swift | 2 +- .../Tests/Download/DownloadIpBanTests.swift | 2 +- .../Download/DownloadObserverBatchTests.swift | 2 +- .../DownloadObserverReadingTests.swift | 2 +- .../DownloadObserverRefreshTests.swift | 2 +- .../DownloadPauseAndReconcileTests.swift | 2 +- .../Download/DownloadProcessCacheTests.swift | 2 +- .../Tests/Download/DownloadProcessTests.swift | 2 +- .../Download/DownloadQueueStoreTests.swift | 2 +- .../DownloadRetryMinimalSourceTests.swift | 2 +- .../Download/DownloadRetryPagesTests.swift | 2 +- .../DownloadRetryUpdateFallbackTests.swift | 2 +- .../Download/DownloadSchedulingTests.swift | 2 +- .../Download/DownloadStoreHashTests.swift | 2 +- .../Download/DownloadStoreRepairTests.swift | 2 +- .../Tests/Download/DownloadStoreTests.swift | 2 +- .../DownloadVersionSignatureTests.swift | 2 +- .../DownloadedGalleryManifestModelTests.swift | 2 +- .../DownloadsReducerActionTests.swift | 2 +- .../DownloadsReducerReadingDismissTests.swift | 2 +- .../DownloadsReducerRefreshTests.swift | 2 +- .../Download/FolderManagerReducerTests.swift | 2 +- .../PreviewsReducerDownloadTests.swift | 2 +- .../Tests/Download/ReaderImageDataTests.swift | 4 +- .../ReadingReducerDownloadTests.swift | 2 +- .../Download/ReadingReducerLocalTests.swift | 2 +- .../Gallery/GalleryDetailParserTests.swift | 2 +- .../Gallery/GalleryImageURLParserTests.swift | 2 +- .../Gallery/GalleryMPVKeysParserTests.swift | 2 +- .../Tests/Parser/List/ListParserTests.swift | 2 +- .../Parser/Other/AnimatedImageDataTests.swift | 2 +- .../Parser/Other/BanIntervalParserTests.swift | 2 +- .../Other/DownloadPageErrorParserTests.swift | 2 +- .../Parser/Other/EhSettingParserTests.swift | 2 +- .../Parser/Other/GreetingParserTests.swift | 2 +- .../Parser/Other/SettingDownloadTests.swift | 2 +- EhPanda.xcodeproj/project.pbxproj | 504 +----------------- .../xcshareddata/swiftpm/Package.resolved | 54 +- .../xcshareddata/xcschemes/EhPanda.xcscheme | 10 - EhPanda/App/EhPandaApp.swift | 52 -- swiftgen.yml | 4 +- 506 files changed, 268 insertions(+), 685 deletions(-) rename {EhPanda/App => App}/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/100.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/1024.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/114.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/120.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/128.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/144.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/152.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/16.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/167.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/172.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/180.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/196.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/20.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/216.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/256.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/29.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/32.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/40.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/48.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/50.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/512.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/55.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/57.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/58.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/60.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/64.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/72.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/76.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/80.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/87.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/88.png (100%) rename {EhPanda/App => App}/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/E-Hentai/Artist CG.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/E-Hentai/Asian Porn.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/E-Hentai/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/E-Hentai/Cosplay.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/E-Hentai/Doujinshi.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/E-Hentai/Game CG.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/E-Hentai/Image Set.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/E-Hentai/Manga.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/E-Hentai/Misc.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/E-Hentai/Non-H.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/E-Hentai/Private.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/E-Hentai/Western.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/ExHentai/Artist CG.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/ExHentai/Asian Porn.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/ExHentai/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/ExHentai/Cosplay.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/ExHentai/Doujinshi.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/ExHentai/Game CG.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/ExHentai/Image Set.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/ExHentai/Manga.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/ExHentai/Misc.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/ExHentai/Non-H.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/ExHentai/Private.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Colors/ExHentai/Western.colorset/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Category/Contents.json (100%) rename {EhPanda/App => App}/Assets.xcassets/Contents.json (100%) rename {EhPanda => App}/EhPanda.entitlements (100%) create mode 100644 App/EhPandaApp.swift rename {EhPanda/App => App}/Icons/AppIcon_Default@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Default@3x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Default_iPad.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Default_iPad@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Default_iPad_Pro@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Developer@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Developer@3x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Developer_iPad.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Developer_iPad@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Developer_iPad_Pro@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_NotMyPresident@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_NotMyPresident@3x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_NotMyPresident_iPad.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_NotMyPresident_iPad@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_NotMyPresident_iPad_Pro@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_StandWithUkraine2022@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_StandWithUkraine2022@3x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_StandWithUkraine2022_iPad.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_StandWithUkraine2022_iPad@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_StandWithUkraine2022_iPad_Pro@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Ukiyoe@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Ukiyoe@3x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Ukiyoe_iPad.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Ukiyoe_iPad@2x.png (100%) rename {EhPanda/App => App}/Icons/AppIcon_Ukiyoe_iPad_Pro@2x.png (100%) rename {EhPanda/App => App}/Info.plist (100%) rename {EhPanda/App => App}/de.lproj/InfoPlist.strings (100%) rename {EhPanda/App => App}/en.lproj/InfoPlist.strings (100%) rename {EhPanda/App => App}/ja.lproj/InfoPlist.strings (100%) rename {EhPanda/App => App}/ko.lproj/InfoPlist.strings (100%) rename {EhPanda/App => App}/zh-Hans.lproj/InfoPlist.strings (100%) rename {EhPanda/App => App}/zh-Hant-HK.lproj/InfoPlist.strings (100%) rename {EhPanda/App => App}/zh-Hant-TW.lproj/InfoPlist.strings (100%) rename {EhPanda/App => App}/zh-Hant.lproj/InfoPlist.strings (100%) delete mode 100644 AppPackage/Sources/AppFeature/AppFeature.swift rename {EhPanda => AppPackage/Sources/AppFeature}/DataFlow/AppDelegateReducer.swift (94%) rename {EhPanda => AppPackage/Sources/AppFeature}/DataFlow/AppLockReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/DataFlow/AppReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/DataFlow/AppRouteReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/DataFlow/Heap.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift (65%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift (70%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/MODefinition/AppEnvMO+CoreDataClass.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/MODefinition/AppEnvMO+CoreDataProperties.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/MODefinition/GalleryMO+CoreDataClass.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/MODefinition/GalleryMO+CoreDataProperties.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/MODefinition/GalleryStateMO+CoreDataClass.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/Migration/CoreDataMigrationStep.swift (94%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/Migration/CoreDataMigrationVersion.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/Migration/CoreDataMigrator.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Database/Persistence.swift (92%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Generated/Strings.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Download/DownloadBadge.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Download/DownloadDisplayStatus.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Download/DownloadFailure.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Download/DownloadFolderFilter.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Download/DownloadInspection.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Download/DownloadProgress.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Download/DownloadRequestOptions.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Download/DownloadStartMode.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Download/DownloadedGallery+Extensions.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Download/DownloadedGallery+Manifest.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Download/DownloadedGallery+SupportTypes.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Download/DownloadedGallery.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Gallery/Category.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Gallery/Gallery.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Gallery/GalleryArchive.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Gallery/GalleryComment.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Gallery/GalleryDetail.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Gallery/GalleryState.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Gallery/GalleryTorrent.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Gallery/Language.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Persistent/AppEnv.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Persistent/Filter.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Persistent/Greeting.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Persistent/Setting.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Persistent/User.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Support/AppError.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Support/BrowsingCountry+EnglishName.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Support/BrowsingCountry.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Support/EhSetting+Enums.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Support/EhSetting+Extensions.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Support/EhSetting.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Support/LiveText.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Support/Misc.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Tags/EhTagTranslationDatabaseModel.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Tags/TagDetail.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Tags/TagNamespace.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Tags/TagSuggestion.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Tags/TagTranslation.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Tags/TagTranslator.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Models/Tags/TranslatableLanguage.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Network/DFExtensions.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Network/DFRequest.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Network/DFStreamHandler.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Network/DFURLProtocol.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Network/DomainResolver.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Network/Request+Account.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Network/Request+Detail.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Network/Request+Gallery.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Network/Request+Image.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/Network/Request.swift (100%) rename {EhPanda/Database => AppPackage/Sources/AppFeature/Resources}/Model.xcdatamodeld/.xccurrentversion (100%) rename {EhPanda/Database => AppPackage/Sources/AppFeature/Resources}/Model.xcdatamodeld/Model 2.xcdatamodel/contents (92%) rename {EhPanda/Database => AppPackage/Sources/AppFeature/Resources}/Model.xcdatamodeld/Model 3.xcdatamodel/contents (92%) rename {EhPanda/Database => AppPackage/Sources/AppFeature/Resources}/Model.xcdatamodeld/Model 4.xcdatamodel/contents (92%) rename {EhPanda/Database => AppPackage/Sources/AppFeature/Resources}/Model.xcdatamodeld/Model 5.xcdatamodel/contents (92%) rename {EhPanda/Database => AppPackage/Sources/AppFeature/Resources}/Model.xcdatamodeld/Model 6.xcdatamodel/contents (93%) rename {EhPanda/Database => AppPackage/Sources/AppFeature/Resources}/Model.xcdatamodeld/Model 7.xcdatamodel/contents (93%) rename {EhPanda/Database => AppPackage/Sources/AppFeature/Resources}/Model.xcdatamodeld/Model.xcdatamodel/contents (92%) rename {EhPanda/Database/Migration/Mappings => AppPackage/Sources/AppFeature/Resources}/Model5toModel6.xcmappingmodel/xcmapping.xml (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature/Resources}/de.lproj/Localizable.strings (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature/Resources}/en.lproj/Constant.strings (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature/Resources}/en.lproj/Localizable.strings (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature/Resources}/ja.lproj/Localizable.strings (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature/Resources}/ko.lproj/Localizable.strings (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature/Resources}/zh-Hans.lproj/Localizable.strings (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature/Resources}/zh-Hant-HK.lproj/Localizable.strings (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature/Resources}/zh-Hant-TW.lproj/Localizable.strings (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature/Resources}/zh-Hant.lproj/Localizable.strings (100%) create mode 100644 AppPackage/Sources/AppFeature/RootView.swift rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/AppDelegateClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/AppLaunchAutomationClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/AuthorizationClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/BackgroundProcessingClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/BackgroundTaskClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/ClipboardClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/CookieClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DFClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DatabaseClient+Updates.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DatabaseClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DeviceClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+BackgroundAssertion.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+BackgroundDownloads.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+BackgroundProcessing.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+Cache.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+Execution.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+ExecutionFetch.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+ExecutionPerform.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+ExecutionSupport.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+Folders.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+Manager.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+Networking.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+PageDownload.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+PageDownloadHelpers.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+Persistence.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+PersistenceHelpers.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+PersistenceNormalize.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+PublicAPI.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+PublicAPIHelpers.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+ResponseValidation.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+RetryHelpers.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+Scheduling.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+SchedulingHelpers.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient+Testing.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/DownloadPageDownloader.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/FileClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/HapticsClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/ImageClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/LibraryClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/LoggerClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/UIApplicationClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/URLClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Clients/UserDefaultsClient.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/ColorCodable.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Defaults.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/EnvironmentKeys.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/EquatableVoid.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Extensions/AlertKit_Extension.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Extensions/AnimatedImage_Extension.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Extensions/Extensions.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Extensions/Reducer_Extension.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Extensions/SwiftUINavigation_Extension.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Extensions/TTProgressHUD_Extension.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Extensions/URL+ImageCacheKey.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Extensions/ViewModifiers.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/IdentifiableBox.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+Archive.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+Comment.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+Detail.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+Favorite.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+Greeting.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+Image.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+List.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+Misc.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+Preview.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+Profile.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+ResponseError.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+Shared.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+Torrent.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+Types.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser+User.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Parser/Parser.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/AppLaunchAutomation.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/AppUtil.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/CookieUtil.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/DataCache.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/DeviceUtil.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/DownloadBackgroundTaskStore.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/DownloadFileManager.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/DownloadQueueStore.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/DownloadStore+JSONCoding.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/DownloadStore+Operations.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/DownloadStore.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/FileUtil.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/HapticsUtil.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/ImagePlaceholderFingerprint.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/MarkdownUtil.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/URLUtil.swift (100%) rename {EhPanda/App => AppPackage/Sources/AppFeature}/Tools/Utilities/UserDefaultsUtil.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/Archives/ArchivesReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/Archives/ArchivesView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/Comments/CommentsReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/Comments/CommentsView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/Components/LinkedText.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/Components/PostCommentView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/Components/RatingView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/Components/TagDetailView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/DetailReducer+Actions.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/DetailReducer+Download.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/DetailReducer+Fetch.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/DetailReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/DetailSearch/DetailSearchReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/DetailSearch/DetailSearchView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/DetailView+CommentCells.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/DetailView+HeaderSection.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/DetailView+Navigation.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/DetailView+Subviews.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/DetailView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/GalleryInfos/GalleryInfosReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/GalleryInfos/GalleryInfosView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/Previews/PreviewsReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/Previews/PreviewsView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/Torrents/TorrentsReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Detail/Torrents/TorrentsView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Downloads/DownloadInspectorReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Downloads/DownloadsReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Downloads/DownloadsView+Subviews.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Downloads/DownloadsView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Downloads/FolderManagerReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Downloads/FolderManagerView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Favorites/FavoritesReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Favorites/FavoritesView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/Frontpage/FrontpageReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/Frontpage/FrontpageView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/History/HistoryReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/History/HistoryView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/HomeReducer+Body.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/HomeReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/HomeView+Sections.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/HomeView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/Popular/PopularReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/Popular/PopularView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/Toplists/ToplistsReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/Toplists/ToplistsView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/Watched/WatchedReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Home/Watched/WatchedView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Migration/MigrationReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Migration/MigrationView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/ReadingReducer+Body.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/ReadingReducer+Database.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/ReadingReducer+ImageFetch.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/ReadingReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/ReadingView+Gestures.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/ReadingView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/ReadingViewComponents.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/Support/AdvancedList.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/Support/AutoPlayHandler.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/Support/ControlPanel.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/Support/GestureHandler.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/Support/LiveTextHandler.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/Support/LiveTextView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Reading/Support/PageHandler.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Search/SearchReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Search/SearchRootReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Search/SearchRootView+Keywords.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Search/SearchRootView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Search/SearchView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Search/Support/QuickSearchReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Search/Support/QuickSearchView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/AccountSetting/AccountSettingReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/AccountSetting/AccountSettingView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/AppearanceSetting/AppearanceSettingView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/Components/AboutView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/Components/DownloadSettingView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/Components/LaboratorySettingView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/Components/ReadingSettingView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/Components/WebView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/EhSetting/EhSettingReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/EhSetting/EhSettingView+Sections1.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/EhSetting/EhSettingView+Sections2.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/EhSetting/EhSettingView+Sections3.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/EhSetting/EhSettingView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/GeneralSetting/GeneralSettingReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/GeneralSetting/GeneralSettingView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/Login/LoginReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/Login/LoginView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/Logs/LogsReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/Logs/LogsView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/SettingReducer+Body.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/SettingReducer+Helpers.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/SettingReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Setting/SettingView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/ActivityView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/AlertView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/CategoryView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/Cells/GalleryCardCell.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/Cells/GalleryDetailCell.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/Cells/GalleryHistoryCell.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/Cells/GalleryRankingCell.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/Cells/GalleryThumbnailCell.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/DateSeekPickerView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/DownloadBadgeLabel.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/GenericList.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/Placeholder.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/PreviewImageView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/SettingTextField.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/SubSection.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/TagCloudView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/TagSuggestionView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/ToolbarItems.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/Components/WaveForm.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/DateSeekReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/FiltersReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/FiltersView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/Support/NewDawnView.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/TabBar/TabBarReducer.swift (100%) rename {EhPanda => AppPackage/Sources/AppFeature}/View/TabBar/TabBarView.swift (100%) create mode 100644 AppPackage/Tests/AppFeatureTests/.swiftlint.yml rename {EhPandaTests/Resources/Utility => AppPackage/Tests/AppFeatureTests/Helpers}/TestHelper.swift (87%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Models/HTMLFilename.swift (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Models/ListParserTestType.swift (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Models/TestError.swift (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/Gallery/GalleryDetail.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/Gallery/GalleryMPVKeys.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/Gallery/GalleryNormalImageURL.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/FavoritesCompactList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/FavoritesExtendedList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/FavoritesMinimalList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/FavoritesMinimalPlusList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/FavoritesThumbnailList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/FrontPageCompactList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/FrontPageExtendedList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/FrontPageMinimalList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/FrontPageMinimalPlusList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/FrontPageThumbnailList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/PopularCompactList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/PopularExtendedList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/PopularMinimalList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/PopularMinimalPlusList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/PopularThumbnailList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/ToplistsCompactList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/WatchedCompactList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/WatchedExtendedList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/WatchedMinimalList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/WatchedMinimalPlusList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/List/WatchedThumbnailList.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/Other/BandwidthExceeded.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/Other/EhSetting.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/Other/ExLoginRequired.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/Other/GalleryDetailWithGreeting.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/Other/IPBanned.html (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Resources/Parser/Other/Kokomade.jpg (100%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DataCacheTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DatabaseClientUpdateTests.swift (98%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DetailReducerDownloadTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DetailReducerMetadataTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DetailReducerMetadataUpdateTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DetailReducerObserveTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DetailReducerPauseAndGuardTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadAutomationTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadBackgroundAssertionTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadBackgroundCompletionTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadBackgroundProcessingTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadBackgroundTaskStoreTests.swift (98%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadBadgeSortTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadCoordinatorCachedURLTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadCoordinatorCaptureTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadCoordinatorRepairSeedTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadCoordinatorStorageTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadEnqueueManifestTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadFeatureTestFactories.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadFeatureTestHelpers.swift (98%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadFeatureTestSupportTypes.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadFilterAndBadgeTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadFolderOperationTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadImageErrorTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadImageParsingCacheTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadImageParsingTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadInspectorLoadTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadInspectorRetryTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadInspectorSkipTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadInterruptedResumeTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadIpBanTests.swift (98%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadObserverBatchTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadObserverReadingTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadObserverRefreshTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadPauseAndReconcileTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadProcessCacheTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadProcessTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadQueueStoreTests.swift (97%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadRetryMinimalSourceTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadRetryPagesTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadRetryUpdateFallbackTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadSchedulingTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadStoreHashTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadStoreRepairTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadStoreTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadVersionSignatureTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadedGalleryManifestModelTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadsReducerActionTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadsReducerReadingDismissTests.swift (96%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/DownloadsReducerRefreshTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/FolderManagerReducerTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/PreviewsReducerDownloadTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/ReaderImageDataTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/ReadingReducerDownloadTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Download/ReadingReducerLocalTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Parser/Gallery/GalleryDetailParserTests.swift (98%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Parser/Gallery/GalleryImageURLParserTests.swift (97%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift (92%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Parser/List/ListParserTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Parser/Other/AnimatedImageDataTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Parser/Other/BanIntervalParserTests.swift (91%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Parser/Other/DownloadPageErrorParserTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Parser/Other/EhSettingParserTests.swift (99%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Parser/Other/GreetingParserTests.swift (95%) rename {EhPandaTests => AppPackage/Tests/AppFeatureTests}/Tests/Parser/Other/SettingDownloadTests.swift (99%) delete mode 100644 EhPanda/App/EhPandaApp.swift diff --git a/EhPanda/App/Assets.xcassets/AccentColor.colorset/Contents.json b/App/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/AccentColor.colorset/Contents.json rename to App/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/100.png b/App/Assets.xcassets/AppIcon.appiconset/100.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/100.png rename to App/Assets.xcassets/AppIcon.appiconset/100.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/1024.png b/App/Assets.xcassets/AppIcon.appiconset/1024.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/1024.png rename to App/Assets.xcassets/AppIcon.appiconset/1024.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/114.png b/App/Assets.xcassets/AppIcon.appiconset/114.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/114.png rename to App/Assets.xcassets/AppIcon.appiconset/114.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/120.png b/App/Assets.xcassets/AppIcon.appiconset/120.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/120.png rename to App/Assets.xcassets/AppIcon.appiconset/120.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/128.png b/App/Assets.xcassets/AppIcon.appiconset/128.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/128.png rename to App/Assets.xcassets/AppIcon.appiconset/128.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/144.png b/App/Assets.xcassets/AppIcon.appiconset/144.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/144.png rename to App/Assets.xcassets/AppIcon.appiconset/144.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/152.png b/App/Assets.xcassets/AppIcon.appiconset/152.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/152.png rename to App/Assets.xcassets/AppIcon.appiconset/152.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/16.png b/App/Assets.xcassets/AppIcon.appiconset/16.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/16.png rename to App/Assets.xcassets/AppIcon.appiconset/16.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/167.png b/App/Assets.xcassets/AppIcon.appiconset/167.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/167.png rename to App/Assets.xcassets/AppIcon.appiconset/167.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/172.png b/App/Assets.xcassets/AppIcon.appiconset/172.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/172.png rename to App/Assets.xcassets/AppIcon.appiconset/172.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/180.png b/App/Assets.xcassets/AppIcon.appiconset/180.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/180.png rename to App/Assets.xcassets/AppIcon.appiconset/180.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/196.png b/App/Assets.xcassets/AppIcon.appiconset/196.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/196.png rename to App/Assets.xcassets/AppIcon.appiconset/196.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/20.png b/App/Assets.xcassets/AppIcon.appiconset/20.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/20.png rename to App/Assets.xcassets/AppIcon.appiconset/20.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/216.png b/App/Assets.xcassets/AppIcon.appiconset/216.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/216.png rename to App/Assets.xcassets/AppIcon.appiconset/216.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/256.png b/App/Assets.xcassets/AppIcon.appiconset/256.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/256.png rename to App/Assets.xcassets/AppIcon.appiconset/256.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/29.png b/App/Assets.xcassets/AppIcon.appiconset/29.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/29.png rename to App/Assets.xcassets/AppIcon.appiconset/29.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/32.png b/App/Assets.xcassets/AppIcon.appiconset/32.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/32.png rename to App/Assets.xcassets/AppIcon.appiconset/32.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/40.png b/App/Assets.xcassets/AppIcon.appiconset/40.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/40.png rename to App/Assets.xcassets/AppIcon.appiconset/40.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/48.png b/App/Assets.xcassets/AppIcon.appiconset/48.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/48.png rename to App/Assets.xcassets/AppIcon.appiconset/48.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/50.png b/App/Assets.xcassets/AppIcon.appiconset/50.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/50.png rename to App/Assets.xcassets/AppIcon.appiconset/50.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/512.png b/App/Assets.xcassets/AppIcon.appiconset/512.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/512.png rename to App/Assets.xcassets/AppIcon.appiconset/512.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/55.png b/App/Assets.xcassets/AppIcon.appiconset/55.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/55.png rename to App/Assets.xcassets/AppIcon.appiconset/55.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/57.png b/App/Assets.xcassets/AppIcon.appiconset/57.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/57.png rename to App/Assets.xcassets/AppIcon.appiconset/57.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/58.png b/App/Assets.xcassets/AppIcon.appiconset/58.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/58.png rename to App/Assets.xcassets/AppIcon.appiconset/58.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/60.png b/App/Assets.xcassets/AppIcon.appiconset/60.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/60.png rename to App/Assets.xcassets/AppIcon.appiconset/60.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/64.png b/App/Assets.xcassets/AppIcon.appiconset/64.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/64.png rename to App/Assets.xcassets/AppIcon.appiconset/64.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/72.png b/App/Assets.xcassets/AppIcon.appiconset/72.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/72.png rename to App/Assets.xcassets/AppIcon.appiconset/72.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/76.png b/App/Assets.xcassets/AppIcon.appiconset/76.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/76.png rename to App/Assets.xcassets/AppIcon.appiconset/76.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/80.png b/App/Assets.xcassets/AppIcon.appiconset/80.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/80.png rename to App/Assets.xcassets/AppIcon.appiconset/80.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/87.png b/App/Assets.xcassets/AppIcon.appiconset/87.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/87.png rename to App/Assets.xcassets/AppIcon.appiconset/87.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/88.png b/App/Assets.xcassets/AppIcon.appiconset/88.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/88.png rename to App/Assets.xcassets/AppIcon.appiconset/88.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/App/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/Contents.json rename to App/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/Contents.json b/App/Assets.xcassets/Category/Colors/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/Contents.json rename to App/Assets.xcassets/Category/Colors/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Artist CG.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Artist CG.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Artist CG.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Artist CG.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Asian Porn.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Asian Porn.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Asian Porn.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Asian Porn.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Cosplay.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Cosplay.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Cosplay.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Cosplay.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Doujinshi.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Doujinshi.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Doujinshi.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Doujinshi.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Game CG.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Game CG.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Game CG.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Game CG.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Image Set.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Image Set.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Image Set.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Image Set.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Manga.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Manga.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Manga.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Manga.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Misc.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Misc.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Misc.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Misc.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Non-H.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Non-H.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Non-H.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Non-H.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Private.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Private.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Private.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Private.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Western.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Western.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Western.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Western.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Artist CG.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Artist CG.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Artist CG.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Artist CG.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Asian Porn.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Asian Porn.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Asian Porn.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Asian Porn.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Cosplay.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Cosplay.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Cosplay.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Cosplay.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Doujinshi.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Doujinshi.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Doujinshi.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Doujinshi.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Game CG.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Game CG.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Game CG.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Game CG.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Image Set.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Image Set.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Image Set.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Image Set.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Manga.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Manga.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Manga.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Manga.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Misc.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Misc.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Misc.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Misc.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Non-H.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Non-H.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Non-H.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Non-H.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Private.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Private.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Private.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Private.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Western.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Western.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Western.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Western.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Contents.json b/App/Assets.xcassets/Category/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Contents.json rename to App/Assets.xcassets/Category/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Contents.json b/App/Assets.xcassets/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Contents.json rename to App/Assets.xcassets/Contents.json diff --git a/EhPanda/EhPanda.entitlements b/App/EhPanda.entitlements similarity index 100% rename from EhPanda/EhPanda.entitlements rename to App/EhPanda.entitlements diff --git a/App/EhPandaApp.swift b/App/EhPandaApp.swift new file mode 100644 index 000000000..5da57be22 --- /dev/null +++ b/App/EhPandaApp.swift @@ -0,0 +1,12 @@ +import AppFeature +import SwiftUI + +@main struct EhPandaApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + RootView(appDelegate: appDelegate) + } + } +} diff --git a/EhPanda/App/Icons/AppIcon_Default@2x.png b/App/Icons/AppIcon_Default@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Default@2x.png rename to App/Icons/AppIcon_Default@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Default@3x.png b/App/Icons/AppIcon_Default@3x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Default@3x.png rename to App/Icons/AppIcon_Default@3x.png diff --git a/EhPanda/App/Icons/AppIcon_Default_iPad.png b/App/Icons/AppIcon_Default_iPad.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Default_iPad.png rename to App/Icons/AppIcon_Default_iPad.png diff --git a/EhPanda/App/Icons/AppIcon_Default_iPad@2x.png b/App/Icons/AppIcon_Default_iPad@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Default_iPad@2x.png rename to App/Icons/AppIcon_Default_iPad@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Default_iPad_Pro@2x.png b/App/Icons/AppIcon_Default_iPad_Pro@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Default_iPad_Pro@2x.png rename to App/Icons/AppIcon_Default_iPad_Pro@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Developer@2x.png b/App/Icons/AppIcon_Developer@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Developer@2x.png rename to App/Icons/AppIcon_Developer@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Developer@3x.png b/App/Icons/AppIcon_Developer@3x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Developer@3x.png rename to App/Icons/AppIcon_Developer@3x.png diff --git a/EhPanda/App/Icons/AppIcon_Developer_iPad.png b/App/Icons/AppIcon_Developer_iPad.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Developer_iPad.png rename to App/Icons/AppIcon_Developer_iPad.png diff --git a/EhPanda/App/Icons/AppIcon_Developer_iPad@2x.png b/App/Icons/AppIcon_Developer_iPad@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Developer_iPad@2x.png rename to App/Icons/AppIcon_Developer_iPad@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Developer_iPad_Pro@2x.png b/App/Icons/AppIcon_Developer_iPad_Pro@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Developer_iPad_Pro@2x.png rename to App/Icons/AppIcon_Developer_iPad_Pro@2x.png diff --git a/EhPanda/App/Icons/AppIcon_NotMyPresident@2x.png b/App/Icons/AppIcon_NotMyPresident@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_NotMyPresident@2x.png rename to App/Icons/AppIcon_NotMyPresident@2x.png diff --git a/EhPanda/App/Icons/AppIcon_NotMyPresident@3x.png b/App/Icons/AppIcon_NotMyPresident@3x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_NotMyPresident@3x.png rename to App/Icons/AppIcon_NotMyPresident@3x.png diff --git a/EhPanda/App/Icons/AppIcon_NotMyPresident_iPad.png b/App/Icons/AppIcon_NotMyPresident_iPad.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_NotMyPresident_iPad.png rename to App/Icons/AppIcon_NotMyPresident_iPad.png diff --git a/EhPanda/App/Icons/AppIcon_NotMyPresident_iPad@2x.png b/App/Icons/AppIcon_NotMyPresident_iPad@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_NotMyPresident_iPad@2x.png rename to App/Icons/AppIcon_NotMyPresident_iPad@2x.png diff --git a/EhPanda/App/Icons/AppIcon_NotMyPresident_iPad_Pro@2x.png b/App/Icons/AppIcon_NotMyPresident_iPad_Pro@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_NotMyPresident_iPad_Pro@2x.png rename to App/Icons/AppIcon_NotMyPresident_iPad_Pro@2x.png diff --git a/EhPanda/App/Icons/AppIcon_StandWithUkraine2022@2x.png b/App/Icons/AppIcon_StandWithUkraine2022@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_StandWithUkraine2022@2x.png rename to App/Icons/AppIcon_StandWithUkraine2022@2x.png diff --git a/EhPanda/App/Icons/AppIcon_StandWithUkraine2022@3x.png b/App/Icons/AppIcon_StandWithUkraine2022@3x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_StandWithUkraine2022@3x.png rename to App/Icons/AppIcon_StandWithUkraine2022@3x.png diff --git a/EhPanda/App/Icons/AppIcon_StandWithUkraine2022_iPad.png b/App/Icons/AppIcon_StandWithUkraine2022_iPad.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_StandWithUkraine2022_iPad.png rename to App/Icons/AppIcon_StandWithUkraine2022_iPad.png diff --git a/EhPanda/App/Icons/AppIcon_StandWithUkraine2022_iPad@2x.png b/App/Icons/AppIcon_StandWithUkraine2022_iPad@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_StandWithUkraine2022_iPad@2x.png rename to App/Icons/AppIcon_StandWithUkraine2022_iPad@2x.png diff --git a/EhPanda/App/Icons/AppIcon_StandWithUkraine2022_iPad_Pro@2x.png b/App/Icons/AppIcon_StandWithUkraine2022_iPad_Pro@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_StandWithUkraine2022_iPad_Pro@2x.png rename to App/Icons/AppIcon_StandWithUkraine2022_iPad_Pro@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Ukiyoe@2x.png b/App/Icons/AppIcon_Ukiyoe@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Ukiyoe@2x.png rename to App/Icons/AppIcon_Ukiyoe@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Ukiyoe@3x.png b/App/Icons/AppIcon_Ukiyoe@3x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Ukiyoe@3x.png rename to App/Icons/AppIcon_Ukiyoe@3x.png diff --git a/EhPanda/App/Icons/AppIcon_Ukiyoe_iPad.png b/App/Icons/AppIcon_Ukiyoe_iPad.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Ukiyoe_iPad.png rename to App/Icons/AppIcon_Ukiyoe_iPad.png diff --git a/EhPanda/App/Icons/AppIcon_Ukiyoe_iPad@2x.png b/App/Icons/AppIcon_Ukiyoe_iPad@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Ukiyoe_iPad@2x.png rename to App/Icons/AppIcon_Ukiyoe_iPad@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Ukiyoe_iPad_Pro@2x.png b/App/Icons/AppIcon_Ukiyoe_iPad_Pro@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Ukiyoe_iPad_Pro@2x.png rename to App/Icons/AppIcon_Ukiyoe_iPad_Pro@2x.png diff --git a/EhPanda/App/Info.plist b/App/Info.plist similarity index 100% rename from EhPanda/App/Info.plist rename to App/Info.plist diff --git a/EhPanda/App/de.lproj/InfoPlist.strings b/App/de.lproj/InfoPlist.strings similarity index 100% rename from EhPanda/App/de.lproj/InfoPlist.strings rename to App/de.lproj/InfoPlist.strings diff --git a/EhPanda/App/en.lproj/InfoPlist.strings b/App/en.lproj/InfoPlist.strings similarity index 100% rename from EhPanda/App/en.lproj/InfoPlist.strings rename to App/en.lproj/InfoPlist.strings diff --git a/EhPanda/App/ja.lproj/InfoPlist.strings b/App/ja.lproj/InfoPlist.strings similarity index 100% rename from EhPanda/App/ja.lproj/InfoPlist.strings rename to App/ja.lproj/InfoPlist.strings diff --git a/EhPanda/App/ko.lproj/InfoPlist.strings b/App/ko.lproj/InfoPlist.strings similarity index 100% rename from EhPanda/App/ko.lproj/InfoPlist.strings rename to App/ko.lproj/InfoPlist.strings diff --git a/EhPanda/App/zh-Hans.lproj/InfoPlist.strings b/App/zh-Hans.lproj/InfoPlist.strings similarity index 100% rename from EhPanda/App/zh-Hans.lproj/InfoPlist.strings rename to App/zh-Hans.lproj/InfoPlist.strings diff --git a/EhPanda/App/zh-Hant-HK.lproj/InfoPlist.strings b/App/zh-Hant-HK.lproj/InfoPlist.strings similarity index 100% rename from EhPanda/App/zh-Hant-HK.lproj/InfoPlist.strings rename to App/zh-Hant-HK.lproj/InfoPlist.strings diff --git a/EhPanda/App/zh-Hant-TW.lproj/InfoPlist.strings b/App/zh-Hant-TW.lproj/InfoPlist.strings similarity index 100% rename from EhPanda/App/zh-Hant-TW.lproj/InfoPlist.strings rename to App/zh-Hant-TW.lproj/InfoPlist.strings diff --git a/EhPanda/App/zh-Hant.lproj/InfoPlist.strings b/App/zh-Hant.lproj/InfoPlist.strings similarity index 100% rename from EhPanda/App/zh-Hant.lproj/InfoPlist.strings rename to App/zh-Hant.lproj/InfoPlist.strings diff --git a/AppPackage/Package.resolved b/AppPackage/Package.resolved index 8c42d5a78..5a5c3e52e 100644 --- a/AppPackage/Package.resolved +++ b/AppPackage/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b1cacab09b6a9292869c6044489096831026b23a039f6b8fb6246abd6fc76d5d", + "originHash" : "d65854d8f89be6f47565ce46f0077cc8f97043049a286f7b9261c6fb4d807e0a", "pins" : [ { "identity" : "alertkit", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Co2333/Colorful", "state" : { - "revision" : "d673ab1b5aaaf2f968fdd73830e318fd4c6910f3", - "version" : "1.1.1" + "revision" : "eb5a350aec759bd413615273cb6d64553aead4d5", + "version" : "1.0.1" } }, { diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index c6ccdaa2b..c4d1e1846 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -4,7 +4,8 @@ import PackageDescription // MARK: Dependency var dependencies: [PackageDescription.Package.Dependency] = [ - .package(url: "https://github.com/Co2333/Colorful", from: "1.0.0"), + // Pinned to match the app's resolved version; 1.1.x deprecates ColorfulView. + .package(url: "https://github.com/Co2333/Colorful", .upToNextMinor(from: "1.0.1")), .package(url: "https://github.com/EhPanda-Team/AlertKit", branch: "custom"), .package(url: "https://github.com/EhPanda-Team/DeprecatedAPI", branch: "main"), .package(url: "https://github.com/EhPanda-Team/TTProgressHUD", branch: "custom"), @@ -56,10 +57,20 @@ let swiftLintPlugins: [PackageDescription.Target.PluginUsage] = [ .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") ] +// Matches the app target's "Approachable Concurrency" (SWIFT_APPROACHABLE_CONCURRENCY) +// so code keeps compiling under the same concurrency posture after moving into the package. +let sharedSwiftSettings: [PackageDescription.SwiftSetting] = [ + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault") +] + // MARK: Module enum Module: String { case appFeature = "AppFeature" case resources = "Resources" + + // Test targets + case appFeatureTests = "AppFeatureTests" } extension Module { @@ -213,10 +224,28 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.uiImageColors), .targetDependency(.waterfallGrid) ], + resources: [.process(.resources)], + swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( module: .resources, + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + + // MARK: Tests + .testTarget( + module: .appFeatureTests, + dependencies: [ + .module(.appFeature), + .targetDependency(.composableArchitecture), + .targetDependency(.kanna), + .targetDependency(.kingfisher), + .targetDependency(.sfSafeSymbols) + ], + resources: [.process(.resources)], + swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ) ] diff --git a/AppPackage/Sources/AppFeature/AppFeature.swift b/AppPackage/Sources/AppFeature/AppFeature.swift deleted file mode 100644 index 943930d7f..000000000 --- a/AppPackage/Sources/AppFeature/AppFeature.swift +++ /dev/null @@ -1,4 +0,0 @@ -import Foundation - -// Placeholder. The monolith's sources are migrated into this module in M2. -enum AppFeatureModule {} diff --git a/EhPanda/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift similarity index 94% rename from EhPanda/DataFlow/AppDelegateReducer.swift rename to AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index da0dfadd0..d346ddca9 100644 --- a/EhPanda/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -48,16 +48,20 @@ struct AppDelegateReducer { } // MARK: AppDelegate -class AppDelegate: UIResponder, UIApplicationDelegate { +public class AppDelegate: UIResponder, UIApplicationDelegate { let store = Store(initialState: .init(), reducer: AppReducer.init) + public override init() { + super.init() + } + static var orientationMask: UIInterfaceOrientationMask = DeviceUtil.isPad ? .all : [.portrait, .portraitUpsideDown] - func application( + public func application( _ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow? ) -> UIInterfaceOrientationMask { AppDelegate.orientationMask } - func application( + public func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { @@ -95,7 +99,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - func application( + public func application( _ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void diff --git a/EhPanda/DataFlow/AppLockReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift similarity index 100% rename from EhPanda/DataFlow/AppLockReducer.swift rename to AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift diff --git a/EhPanda/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift similarity index 100% rename from EhPanda/DataFlow/AppReducer.swift rename to AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift diff --git a/EhPanda/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift similarity index 100% rename from EhPanda/DataFlow/AppRouteReducer.swift rename to AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift diff --git a/EhPanda/DataFlow/Heap.swift b/AppPackage/Sources/AppFeature/DataFlow/Heap.swift similarity index 100% rename from EhPanda/DataFlow/Heap.swift rename to AppPackage/Sources/AppFeature/DataFlow/Heap.swift diff --git a/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift b/AppPackage/Sources/AppFeature/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift similarity index 100% rename from EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift rename to AppPackage/Sources/AppFeature/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift diff --git a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift b/AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift similarity index 65% rename from EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift rename to AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift index ecfea5b85..c2750dfe7 100755 --- a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift +++ b/AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift @@ -3,6 +3,6 @@ import CoreData extension NSManagedObjectModel { static func compatibleModelForStoreMetadata(_ metadata: [String: Any]) -> NSManagedObjectModel? { - NSManagedObjectModel.mergedModel(from: [Bundle.main], forStoreMetadata: metadata) + NSManagedObjectModel.mergedModel(from: [Bundle.module], forStoreMetadata: metadata) } } diff --git a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift b/AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift similarity index 70% rename from EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift rename to AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift index 123cd81a0..25f87f53f 100755 --- a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift +++ b/AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift @@ -4,8 +4,8 @@ import CoreData extension NSManagedObjectModel { static func managedObjectModel(forResource resource: String) throws -> NSManagedObjectModel { let subdirectory = "Model.momd" - let omoURL = Bundle.main.url(forResource: resource, withExtension: "omo", subdirectory: subdirectory) - let momURL = Bundle.main.url(forResource: resource, withExtension: "mom", subdirectory: subdirectory) + let omoURL = Bundle.module.url(forResource: resource, withExtension: "omo", subdirectory: subdirectory) + let momURL = Bundle.module.url(forResource: resource, withExtension: "mom", subdirectory: subdirectory) guard let url = omoURL ?? momURL else { throw AppError.databaseCorrupted("Unable to find model in bundle.") diff --git a/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift b/AppPackage/Sources/AppFeature/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift similarity index 100% rename from EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift rename to AppPackage/Sources/AppFeature/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift diff --git a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift similarity index 100% rename from EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift rename to AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift diff --git a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataProperties.swift similarity index 100% rename from EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift rename to AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataProperties.swift diff --git a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift similarity index 100% rename from EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift rename to AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift diff --git a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift similarity index 100% rename from EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift rename to AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift diff --git a/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift similarity index 100% rename from EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift rename to AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift diff --git a/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataProperties.swift similarity index 100% rename from EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift rename to AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataProperties.swift diff --git a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift similarity index 100% rename from EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift rename to AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift diff --git a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift similarity index 100% rename from EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift rename to AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift diff --git a/EhPanda/Database/Migration/CoreDataMigrationStep.swift b/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationStep.swift similarity index 94% rename from EhPanda/Database/Migration/CoreDataMigrationStep.swift rename to AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationStep.swift index 3764532df..40ecca07f 100755 --- a/EhPanda/Database/Migration/CoreDataMigrationStep.swift +++ b/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationStep.swift @@ -41,6 +41,6 @@ struct CoreDataMigrationStep { fromSourceModel sourceModel: NSManagedObjectModel, toDestinationModel destinationModel: NSManagedObjectModel ) -> NSMappingModel? { - NSMappingModel(from: [Bundle.main], forSourceModel: sourceModel, destinationModel: destinationModel) + NSMappingModel(from: [Bundle.module], forSourceModel: sourceModel, destinationModel: destinationModel) } } diff --git a/EhPanda/Database/Migration/CoreDataMigrationVersion.swift b/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationVersion.swift similarity index 100% rename from EhPanda/Database/Migration/CoreDataMigrationVersion.swift rename to AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationVersion.swift diff --git a/EhPanda/Database/Migration/CoreDataMigrator.swift b/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrator.swift similarity index 100% rename from EhPanda/Database/Migration/CoreDataMigrator.swift rename to AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrator.swift diff --git a/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift b/AppPackage/Sources/AppFeature/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift similarity index 100% rename from EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift rename to AppPackage/Sources/AppFeature/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift diff --git a/EhPanda/Database/Persistence.swift b/AppPackage/Sources/AppFeature/Database/Persistence.swift similarity index 92% rename from EhPanda/Database/Persistence.swift rename to AppPackage/Sources/AppFeature/Database/Persistence.swift index 94138cf72..cc19509e2 100644 --- a/EhPanda/Database/Persistence.swift +++ b/AppPackage/Sources/AppFeature/Database/Persistence.swift @@ -5,7 +5,11 @@ struct PersistenceController: Sendable { let migrator = CoreDataMigrator() let container: NSPersistentCloudKitContainer = { - let container = NSPersistentCloudKitContainer(name: "Model") + guard let modelURL = Bundle.module.url(forResource: "Model", withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: modelURL) else { + fatalError("Failed to load the Core Data model from the module bundle.") + } + let container = NSPersistentCloudKitContainer(name: "Model", managedObjectModel: model) let description = container.persistentStoreDescriptions.first description?.shouldInferMappingModelAutomatically = false description?.shouldMigrateStoreAutomatically = false diff --git a/EhPanda/App/Generated/Strings.swift b/AppPackage/Sources/AppFeature/Generated/Strings.swift similarity index 100% rename from EhPanda/App/Generated/Strings.swift rename to AppPackage/Sources/AppFeature/Generated/Strings.swift diff --git a/EhPanda/Models/Download/DownloadBadge.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadBadge.swift similarity index 100% rename from EhPanda/Models/Download/DownloadBadge.swift rename to AppPackage/Sources/AppFeature/Models/Download/DownloadBadge.swift diff --git a/EhPanda/Models/Download/DownloadDisplayStatus.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadDisplayStatus.swift similarity index 100% rename from EhPanda/Models/Download/DownloadDisplayStatus.swift rename to AppPackage/Sources/AppFeature/Models/Download/DownloadDisplayStatus.swift diff --git a/EhPanda/Models/Download/DownloadFailure.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadFailure.swift similarity index 100% rename from EhPanda/Models/Download/DownloadFailure.swift rename to AppPackage/Sources/AppFeature/Models/Download/DownloadFailure.swift diff --git a/EhPanda/Models/Download/DownloadFolderFilter.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadFolderFilter.swift similarity index 100% rename from EhPanda/Models/Download/DownloadFolderFilter.swift rename to AppPackage/Sources/AppFeature/Models/Download/DownloadFolderFilter.swift diff --git a/EhPanda/Models/Download/DownloadInspection.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadInspection.swift similarity index 100% rename from EhPanda/Models/Download/DownloadInspection.swift rename to AppPackage/Sources/AppFeature/Models/Download/DownloadInspection.swift diff --git a/EhPanda/Models/Download/DownloadProgress.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadProgress.swift similarity index 100% rename from EhPanda/Models/Download/DownloadProgress.swift rename to AppPackage/Sources/AppFeature/Models/Download/DownloadProgress.swift diff --git a/EhPanda/Models/Download/DownloadRequestOptions.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadRequestOptions.swift similarity index 100% rename from EhPanda/Models/Download/DownloadRequestOptions.swift rename to AppPackage/Sources/AppFeature/Models/Download/DownloadRequestOptions.swift diff --git a/EhPanda/Models/Download/DownloadStartMode.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadStartMode.swift similarity index 100% rename from EhPanda/Models/Download/DownloadStartMode.swift rename to AppPackage/Sources/AppFeature/Models/Download/DownloadStartMode.swift diff --git a/EhPanda/Models/Download/DownloadedGallery+Extensions.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+Extensions.swift similarity index 100% rename from EhPanda/Models/Download/DownloadedGallery+Extensions.swift rename to AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+Extensions.swift diff --git a/EhPanda/Models/Download/DownloadedGallery+Manifest.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+Manifest.swift similarity index 100% rename from EhPanda/Models/Download/DownloadedGallery+Manifest.swift rename to AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+Manifest.swift diff --git a/EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+SupportTypes.swift similarity index 100% rename from EhPanda/Models/Download/DownloadedGallery+SupportTypes.swift rename to AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+SupportTypes.swift diff --git a/EhPanda/Models/Download/DownloadedGallery.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery.swift similarity index 100% rename from EhPanda/Models/Download/DownloadedGallery.swift rename to AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery.swift diff --git a/EhPanda/Models/Gallery/Category.swift b/AppPackage/Sources/AppFeature/Models/Gallery/Category.swift similarity index 100% rename from EhPanda/Models/Gallery/Category.swift rename to AppPackage/Sources/AppFeature/Models/Gallery/Category.swift diff --git a/EhPanda/Models/Gallery/Gallery.swift b/AppPackage/Sources/AppFeature/Models/Gallery/Gallery.swift similarity index 100% rename from EhPanda/Models/Gallery/Gallery.swift rename to AppPackage/Sources/AppFeature/Models/Gallery/Gallery.swift diff --git a/EhPanda/Models/Gallery/GalleryArchive.swift b/AppPackage/Sources/AppFeature/Models/Gallery/GalleryArchive.swift similarity index 100% rename from EhPanda/Models/Gallery/GalleryArchive.swift rename to AppPackage/Sources/AppFeature/Models/Gallery/GalleryArchive.swift diff --git a/EhPanda/Models/Gallery/GalleryComment.swift b/AppPackage/Sources/AppFeature/Models/Gallery/GalleryComment.swift similarity index 100% rename from EhPanda/Models/Gallery/GalleryComment.swift rename to AppPackage/Sources/AppFeature/Models/Gallery/GalleryComment.swift diff --git a/EhPanda/Models/Gallery/GalleryDetail.swift b/AppPackage/Sources/AppFeature/Models/Gallery/GalleryDetail.swift similarity index 100% rename from EhPanda/Models/Gallery/GalleryDetail.swift rename to AppPackage/Sources/AppFeature/Models/Gallery/GalleryDetail.swift diff --git a/EhPanda/Models/Gallery/GalleryState.swift b/AppPackage/Sources/AppFeature/Models/Gallery/GalleryState.swift similarity index 100% rename from EhPanda/Models/Gallery/GalleryState.swift rename to AppPackage/Sources/AppFeature/Models/Gallery/GalleryState.swift diff --git a/EhPanda/Models/Gallery/GalleryTorrent.swift b/AppPackage/Sources/AppFeature/Models/Gallery/GalleryTorrent.swift similarity index 100% rename from EhPanda/Models/Gallery/GalleryTorrent.swift rename to AppPackage/Sources/AppFeature/Models/Gallery/GalleryTorrent.swift diff --git a/EhPanda/Models/Gallery/Language.swift b/AppPackage/Sources/AppFeature/Models/Gallery/Language.swift similarity index 100% rename from EhPanda/Models/Gallery/Language.swift rename to AppPackage/Sources/AppFeature/Models/Gallery/Language.swift diff --git a/EhPanda/Models/Persistent/AppEnv.swift b/AppPackage/Sources/AppFeature/Models/Persistent/AppEnv.swift similarity index 100% rename from EhPanda/Models/Persistent/AppEnv.swift rename to AppPackage/Sources/AppFeature/Models/Persistent/AppEnv.swift diff --git a/EhPanda/Models/Persistent/Filter.swift b/AppPackage/Sources/AppFeature/Models/Persistent/Filter.swift similarity index 100% rename from EhPanda/Models/Persistent/Filter.swift rename to AppPackage/Sources/AppFeature/Models/Persistent/Filter.swift diff --git a/EhPanda/Models/Persistent/Greeting.swift b/AppPackage/Sources/AppFeature/Models/Persistent/Greeting.swift similarity index 100% rename from EhPanda/Models/Persistent/Greeting.swift rename to AppPackage/Sources/AppFeature/Models/Persistent/Greeting.swift diff --git a/EhPanda/Models/Persistent/Setting.swift b/AppPackage/Sources/AppFeature/Models/Persistent/Setting.swift similarity index 100% rename from EhPanda/Models/Persistent/Setting.swift rename to AppPackage/Sources/AppFeature/Models/Persistent/Setting.swift diff --git a/EhPanda/Models/Persistent/User.swift b/AppPackage/Sources/AppFeature/Models/Persistent/User.swift similarity index 100% rename from EhPanda/Models/Persistent/User.swift rename to AppPackage/Sources/AppFeature/Models/Persistent/User.swift diff --git a/EhPanda/Models/Support/AppError.swift b/AppPackage/Sources/AppFeature/Models/Support/AppError.swift similarity index 100% rename from EhPanda/Models/Support/AppError.swift rename to AppPackage/Sources/AppFeature/Models/Support/AppError.swift diff --git a/EhPanda/Models/Support/BrowsingCountry+EnglishName.swift b/AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry+EnglishName.swift similarity index 100% rename from EhPanda/Models/Support/BrowsingCountry+EnglishName.swift rename to AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry+EnglishName.swift diff --git a/EhPanda/Models/Support/BrowsingCountry.swift b/AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry.swift similarity index 100% rename from EhPanda/Models/Support/BrowsingCountry.swift rename to AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry.swift diff --git a/EhPanda/Models/Support/EhSetting+Enums.swift b/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Enums.swift similarity index 100% rename from EhPanda/Models/Support/EhSetting+Enums.swift rename to AppPackage/Sources/AppFeature/Models/Support/EhSetting+Enums.swift diff --git a/EhPanda/Models/Support/EhSetting+Extensions.swift b/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Extensions.swift similarity index 100% rename from EhPanda/Models/Support/EhSetting+Extensions.swift rename to AppPackage/Sources/AppFeature/Models/Support/EhSetting+Extensions.swift diff --git a/EhPanda/Models/Support/EhSetting.swift b/AppPackage/Sources/AppFeature/Models/Support/EhSetting.swift similarity index 100% rename from EhPanda/Models/Support/EhSetting.swift rename to AppPackage/Sources/AppFeature/Models/Support/EhSetting.swift diff --git a/EhPanda/Models/Support/LiveText.swift b/AppPackage/Sources/AppFeature/Models/Support/LiveText.swift similarity index 100% rename from EhPanda/Models/Support/LiveText.swift rename to AppPackage/Sources/AppFeature/Models/Support/LiveText.swift diff --git a/EhPanda/Models/Support/Misc.swift b/AppPackage/Sources/AppFeature/Models/Support/Misc.swift similarity index 100% rename from EhPanda/Models/Support/Misc.swift rename to AppPackage/Sources/AppFeature/Models/Support/Misc.swift diff --git a/EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift b/AppPackage/Sources/AppFeature/Models/Tags/EhTagTranslationDatabaseModel.swift similarity index 100% rename from EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift rename to AppPackage/Sources/AppFeature/Models/Tags/EhTagTranslationDatabaseModel.swift diff --git a/EhPanda/Models/Tags/TagDetail.swift b/AppPackage/Sources/AppFeature/Models/Tags/TagDetail.swift similarity index 100% rename from EhPanda/Models/Tags/TagDetail.swift rename to AppPackage/Sources/AppFeature/Models/Tags/TagDetail.swift diff --git a/EhPanda/Models/Tags/TagNamespace.swift b/AppPackage/Sources/AppFeature/Models/Tags/TagNamespace.swift similarity index 100% rename from EhPanda/Models/Tags/TagNamespace.swift rename to AppPackage/Sources/AppFeature/Models/Tags/TagNamespace.swift diff --git a/EhPanda/Models/Tags/TagSuggestion.swift b/AppPackage/Sources/AppFeature/Models/Tags/TagSuggestion.swift similarity index 100% rename from EhPanda/Models/Tags/TagSuggestion.swift rename to AppPackage/Sources/AppFeature/Models/Tags/TagSuggestion.swift diff --git a/EhPanda/Models/Tags/TagTranslation.swift b/AppPackage/Sources/AppFeature/Models/Tags/TagTranslation.swift similarity index 100% rename from EhPanda/Models/Tags/TagTranslation.swift rename to AppPackage/Sources/AppFeature/Models/Tags/TagTranslation.swift diff --git a/EhPanda/Models/Tags/TagTranslator.swift b/AppPackage/Sources/AppFeature/Models/Tags/TagTranslator.swift similarity index 100% rename from EhPanda/Models/Tags/TagTranslator.swift rename to AppPackage/Sources/AppFeature/Models/Tags/TagTranslator.swift diff --git a/EhPanda/Models/Tags/TranslatableLanguage.swift b/AppPackage/Sources/AppFeature/Models/Tags/TranslatableLanguage.swift similarity index 100% rename from EhPanda/Models/Tags/TranslatableLanguage.swift rename to AppPackage/Sources/AppFeature/Models/Tags/TranslatableLanguage.swift diff --git a/EhPanda/Network/DFExtensions.swift b/AppPackage/Sources/AppFeature/Network/DFExtensions.swift similarity index 100% rename from EhPanda/Network/DFExtensions.swift rename to AppPackage/Sources/AppFeature/Network/DFExtensions.swift diff --git a/EhPanda/Network/DFRequest.swift b/AppPackage/Sources/AppFeature/Network/DFRequest.swift similarity index 100% rename from EhPanda/Network/DFRequest.swift rename to AppPackage/Sources/AppFeature/Network/DFRequest.swift diff --git a/EhPanda/Network/DFStreamHandler.swift b/AppPackage/Sources/AppFeature/Network/DFStreamHandler.swift similarity index 100% rename from EhPanda/Network/DFStreamHandler.swift rename to AppPackage/Sources/AppFeature/Network/DFStreamHandler.swift diff --git a/EhPanda/Network/DFURLProtocol.swift b/AppPackage/Sources/AppFeature/Network/DFURLProtocol.swift similarity index 100% rename from EhPanda/Network/DFURLProtocol.swift rename to AppPackage/Sources/AppFeature/Network/DFURLProtocol.swift diff --git a/EhPanda/Network/DomainResolver.swift b/AppPackage/Sources/AppFeature/Network/DomainResolver.swift similarity index 100% rename from EhPanda/Network/DomainResolver.swift rename to AppPackage/Sources/AppFeature/Network/DomainResolver.swift diff --git a/EhPanda/Network/Request+Account.swift b/AppPackage/Sources/AppFeature/Network/Request+Account.swift similarity index 100% rename from EhPanda/Network/Request+Account.swift rename to AppPackage/Sources/AppFeature/Network/Request+Account.swift diff --git a/EhPanda/Network/Request+Detail.swift b/AppPackage/Sources/AppFeature/Network/Request+Detail.swift similarity index 100% rename from EhPanda/Network/Request+Detail.swift rename to AppPackage/Sources/AppFeature/Network/Request+Detail.swift diff --git a/EhPanda/Network/Request+Gallery.swift b/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift similarity index 100% rename from EhPanda/Network/Request+Gallery.swift rename to AppPackage/Sources/AppFeature/Network/Request+Gallery.swift diff --git a/EhPanda/Network/Request+Image.swift b/AppPackage/Sources/AppFeature/Network/Request+Image.swift similarity index 100% rename from EhPanda/Network/Request+Image.swift rename to AppPackage/Sources/AppFeature/Network/Request+Image.swift diff --git a/EhPanda/Network/Request.swift b/AppPackage/Sources/AppFeature/Network/Request.swift similarity index 100% rename from EhPanda/Network/Request.swift rename to AppPackage/Sources/AppFeature/Network/Request.swift diff --git a/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/.xccurrentversion similarity index 100% rename from EhPanda/Database/Model.xcdatamodeld/.xccurrentversion rename to AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/.xccurrentversion diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 2.xcdatamodel/contents b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents similarity index 92% rename from EhPanda/Database/Model.xcdatamodeld/Model 2.xcdatamodel/contents rename to AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents index 454ebc253..36fa4bee7 100644 --- a/EhPanda/Database/Model.xcdatamodeld/Model 2.xcdatamodel/contents +++ b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents @@ -1,13 +1,13 @@ - + - + @@ -29,7 +29,7 @@ - + @@ -43,7 +43,7 @@ - + diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 3.xcdatamodel/contents b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents similarity index 92% rename from EhPanda/Database/Model.xcdatamodeld/Model 3.xcdatamodel/contents rename to AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents index 47574e2c5..ae9a5622c 100644 --- a/EhPanda/Database/Model.xcdatamodeld/Model 3.xcdatamodel/contents +++ b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents @@ -1,13 +1,13 @@ - + - + @@ -29,7 +29,7 @@ - + @@ -43,7 +43,7 @@ - + diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 4.xcdatamodel/contents b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents similarity index 92% rename from EhPanda/Database/Model.xcdatamodeld/Model 4.xcdatamodel/contents rename to AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents index dae72033f..9a5e0446a 100644 --- a/EhPanda/Database/Model.xcdatamodeld/Model 4.xcdatamodel/contents +++ b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents @@ -1,6 +1,6 @@ - + @@ -8,7 +8,7 @@ - + @@ -30,7 +30,7 @@ - + @@ -44,7 +44,7 @@ - + diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 5.xcdatamodel/contents b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents similarity index 92% rename from EhPanda/Database/Model.xcdatamodeld/Model 5.xcdatamodel/contents rename to AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents index 8cc55b7f7..094b739cb 100644 --- a/EhPanda/Database/Model.xcdatamodeld/Model 5.xcdatamodel/contents +++ b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents @@ -1,6 +1,6 @@ - + @@ -9,7 +9,7 @@ - + @@ -31,7 +31,7 @@ - + @@ -45,7 +45,7 @@ - + diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 6.xcdatamodel/contents b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents similarity index 93% rename from EhPanda/Database/Model.xcdatamodeld/Model 6.xcdatamodel/contents rename to AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents index 94e260309..6aaf4ac80 100644 --- a/EhPanda/Database/Model.xcdatamodeld/Model 6.xcdatamodel/contents +++ b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents @@ -1,6 +1,6 @@ - + @@ -10,7 +10,7 @@ - + @@ -32,7 +32,7 @@ - + @@ -47,7 +47,7 @@ - + diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 7.xcdatamodel/contents b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents similarity index 93% rename from EhPanda/Database/Model.xcdatamodeld/Model 7.xcdatamodel/contents rename to AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents index b70bddd7d..8cf3007bc 100644 --- a/EhPanda/Database/Model.xcdatamodeld/Model 7.xcdatamodel/contents +++ b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents @@ -1,6 +1,6 @@ - + @@ -10,7 +10,7 @@ - + @@ -32,7 +32,7 @@ - + @@ -46,7 +46,7 @@ - + diff --git a/EhPanda/Database/Model.xcdatamodeld/Model.xcdatamodel/contents b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents similarity index 92% rename from EhPanda/Database/Model.xcdatamodeld/Model.xcdatamodel/contents rename to AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents index a6177140e..3f1d9567c 100644 --- a/EhPanda/Database/Model.xcdatamodeld/Model.xcdatamodel/contents +++ b/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents @@ -1,13 +1,13 @@ - + - + @@ -29,7 +29,7 @@ - + @@ -43,7 +43,7 @@ - + diff --git a/EhPanda/Database/Migration/Mappings/Model5toModel6.xcmappingmodel/xcmapping.xml b/AppPackage/Sources/AppFeature/Resources/Model5toModel6.xcmappingmodel/xcmapping.xml similarity index 100% rename from EhPanda/Database/Migration/Mappings/Model5toModel6.xcmappingmodel/xcmapping.xml rename to AppPackage/Sources/AppFeature/Resources/Model5toModel6.xcmappingmodel/xcmapping.xml diff --git a/EhPanda/App/de.lproj/Localizable.strings b/AppPackage/Sources/AppFeature/Resources/de.lproj/Localizable.strings similarity index 100% rename from EhPanda/App/de.lproj/Localizable.strings rename to AppPackage/Sources/AppFeature/Resources/de.lproj/Localizable.strings diff --git a/EhPanda/App/en.lproj/Constant.strings b/AppPackage/Sources/AppFeature/Resources/en.lproj/Constant.strings similarity index 100% rename from EhPanda/App/en.lproj/Constant.strings rename to AppPackage/Sources/AppFeature/Resources/en.lproj/Constant.strings diff --git a/EhPanda/App/en.lproj/Localizable.strings b/AppPackage/Sources/AppFeature/Resources/en.lproj/Localizable.strings similarity index 100% rename from EhPanda/App/en.lproj/Localizable.strings rename to AppPackage/Sources/AppFeature/Resources/en.lproj/Localizable.strings diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/AppPackage/Sources/AppFeature/Resources/ja.lproj/Localizable.strings similarity index 100% rename from EhPanda/App/ja.lproj/Localizable.strings rename to AppPackage/Sources/AppFeature/Resources/ja.lproj/Localizable.strings diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/AppPackage/Sources/AppFeature/Resources/ko.lproj/Localizable.strings similarity index 100% rename from EhPanda/App/ko.lproj/Localizable.strings rename to AppPackage/Sources/AppFeature/Resources/ko.lproj/Localizable.strings diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/AppFeature/Resources/zh-Hans.lproj/Localizable.strings similarity index 100% rename from EhPanda/App/zh-Hans.lproj/Localizable.strings rename to AppPackage/Sources/AppFeature/Resources/zh-Hans.lproj/Localizable.strings diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/AppPackage/Sources/AppFeature/Resources/zh-Hant-HK.lproj/Localizable.strings similarity index 100% rename from EhPanda/App/zh-Hant-HK.lproj/Localizable.strings rename to AppPackage/Sources/AppFeature/Resources/zh-Hant-HK.lproj/Localizable.strings diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/AppPackage/Sources/AppFeature/Resources/zh-Hant-TW.lproj/Localizable.strings similarity index 100% rename from EhPanda/App/zh-Hant-TW.lproj/Localizable.strings rename to AppPackage/Sources/AppFeature/Resources/zh-Hant-TW.lproj/Localizable.strings diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/AppFeature/Resources/zh-Hant.lproj/Localizable.strings similarity index 100% rename from EhPanda/App/zh-Hant.lproj/Localizable.strings rename to AppPackage/Sources/AppFeature/Resources/zh-Hant.lproj/Localizable.strings diff --git a/AppPackage/Sources/AppFeature/RootView.swift b/AppPackage/Sources/AppFeature/RootView.swift new file mode 100644 index 000000000..4bfd1f441 --- /dev/null +++ b/AppPackage/Sources/AppFeature/RootView.swift @@ -0,0 +1,53 @@ +import ComposableArchitecture +import SwiftUI +import UIKit + +// MARK: RootView +public struct RootView: View { + private let appDelegate: AppDelegate + + public init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + } + + public var body: some View { + ZStack { + let databaseState = appDelegate.store.appDelegateState.migrationState.databaseState + + if databaseState == .idle { + TabBarView(store: appDelegate.store).onAppear(perform: addTouchHandler).accentColor(.primary) + } + MigrationView( + store: appDelegate.store.scope( + state: \.appDelegateState.migrationState, + action: \.appDelegate.migration + ) + ) + .opacity(databaseState != .idle ? 1 : 0) + .animation(.linear(duration: 0.5), value: databaseState) + } + .navigationViewStyle(.stack) + } + + private func addTouchHandler() { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + let tapGesture = UITapGestureRecognizer(target: TouchHandler.shared, action: nil) + tapGesture.delegate = TouchHandler.shared + DeviceUtil.keyWindow?.addGestureRecognizer(tapGesture) + } + } +} + +// MARK: TouchHandler +final class TouchHandler: NSObject, UIGestureRecognizerDelegate { + static let shared = TouchHandler() + var currentPoint: CGPoint? + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + currentPoint = touch.location(in: touch.window) + return false + } +} diff --git a/EhPanda/App/Tools/Clients/AppDelegateClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/AppDelegateClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/AppDelegateClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/AppDelegateClient.swift diff --git a/EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/AppLaunchAutomationClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/AppLaunchAutomationClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/AppLaunchAutomationClient.swift diff --git a/EhPanda/App/Tools/Clients/AuthorizationClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/AuthorizationClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/AuthorizationClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/AuthorizationClient.swift diff --git a/EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/BackgroundProcessingClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift diff --git a/EhPanda/App/Tools/Clients/BackgroundTaskClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundTaskClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/BackgroundTaskClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/BackgroundTaskClient.swift diff --git a/EhPanda/App/Tools/Clients/ClipboardClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/ClipboardClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/ClipboardClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/ClipboardClient.swift diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/CookieClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift diff --git a/EhPanda/App/Tools/Clients/DFClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DFClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DFClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DFClient.swift diff --git a/EhPanda/App/Tools/Clients/DatabaseClient+Updates.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DatabaseClient+Updates.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift diff --git a/EhPanda/App/Tools/Clients/DatabaseClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DatabaseClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift diff --git a/EhPanda/App/Tools/Clients/DeviceClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DeviceClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DeviceClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DeviceClient.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundAssertion.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundAssertion.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+BackgroundAssertion.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundAssertion.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundDownloads.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+BackgroundDownloads.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundDownloads.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+BackgroundProcessing.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundProcessing.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+BackgroundProcessing.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundProcessing.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+Cache.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Execution.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Execution.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+Execution.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Execution.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionPerform.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionPerform.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Folders.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+Folders.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Manager.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+Manager.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+Networking.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownload.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+PageDownload.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownload.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownloadHelpers.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+PageDownloadHelpers.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownloadHelpers.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Persistence.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+Persistence.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Persistence.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceHelpers.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+PersistenceHelpers.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceHelpers.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceNormalize.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceNormalize.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPIHelpers.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+PublicAPIHelpers.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPIHelpers.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+RetryHelpers.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+RetryHelpers.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+RetryHelpers.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Scheduling.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+Scheduling.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Scheduling.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+SchedulingHelpers.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+SchedulingHelpers.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+SchedulingHelpers.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Testing.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient+Testing.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Testing.swift diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift diff --git a/EhPanda/App/Tools/Clients/DownloadPageDownloader.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadPageDownloader.swift similarity index 100% rename from EhPanda/App/Tools/Clients/DownloadPageDownloader.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/DownloadPageDownloader.swift diff --git a/EhPanda/App/Tools/Clients/FileClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/FileClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift diff --git a/EhPanda/App/Tools/Clients/HapticsClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/HapticsClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/HapticsClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/HapticsClient.swift diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/ImageClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/LibraryClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift diff --git a/EhPanda/App/Tools/Clients/LoggerClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/LoggerClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/LoggerClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/LoggerClient.swift diff --git a/EhPanda/App/Tools/Clients/UIApplicationClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/UIApplicationClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift diff --git a/EhPanda/App/Tools/Clients/URLClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/URLClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/URLClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/URLClient.swift diff --git a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/UserDefaultsClient.swift similarity index 100% rename from EhPanda/App/Tools/Clients/UserDefaultsClient.swift rename to AppPackage/Sources/AppFeature/Tools/Clients/UserDefaultsClient.swift diff --git a/EhPanda/App/Tools/ColorCodable.swift b/AppPackage/Sources/AppFeature/Tools/ColorCodable.swift similarity index 100% rename from EhPanda/App/Tools/ColorCodable.swift rename to AppPackage/Sources/AppFeature/Tools/ColorCodable.swift diff --git a/EhPanda/App/Tools/Defaults.swift b/AppPackage/Sources/AppFeature/Tools/Defaults.swift similarity index 100% rename from EhPanda/App/Tools/Defaults.swift rename to AppPackage/Sources/AppFeature/Tools/Defaults.swift diff --git a/EhPanda/App/Tools/EnvironmentKeys.swift b/AppPackage/Sources/AppFeature/Tools/EnvironmentKeys.swift similarity index 100% rename from EhPanda/App/Tools/EnvironmentKeys.swift rename to AppPackage/Sources/AppFeature/Tools/EnvironmentKeys.swift diff --git a/EhPanda/App/Tools/EquatableVoid.swift b/AppPackage/Sources/AppFeature/Tools/EquatableVoid.swift similarity index 100% rename from EhPanda/App/Tools/EquatableVoid.swift rename to AppPackage/Sources/AppFeature/Tools/EquatableVoid.swift diff --git a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift similarity index 100% rename from EhPanda/App/Tools/Extensions/AlertKit_Extension.swift rename to AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift diff --git a/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/AnimatedImage_Extension.swift similarity index 100% rename from EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift rename to AppPackage/Sources/AppFeature/Tools/Extensions/AnimatedImage_Extension.swift diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/Extensions.swift similarity index 100% rename from EhPanda/App/Tools/Extensions/Extensions.swift rename to AppPackage/Sources/AppFeature/Tools/Extensions/Extensions.swift diff --git a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift similarity index 100% rename from EhPanda/App/Tools/Extensions/Reducer_Extension.swift rename to AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift diff --git a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/SwiftUINavigation_Extension.swift similarity index 100% rename from EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift rename to AppPackage/Sources/AppFeature/Tools/Extensions/SwiftUINavigation_Extension.swift diff --git a/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/TTProgressHUD_Extension.swift similarity index 100% rename from EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift rename to AppPackage/Sources/AppFeature/Tools/Extensions/TTProgressHUD_Extension.swift diff --git a/EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/URL+ImageCacheKey.swift similarity index 100% rename from EhPanda/App/Tools/Extensions/URL+ImageCacheKey.swift rename to AppPackage/Sources/AppFeature/Tools/Extensions/URL+ImageCacheKey.swift diff --git a/EhPanda/App/Tools/Extensions/ViewModifiers.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift similarity index 100% rename from EhPanda/App/Tools/Extensions/ViewModifiers.swift rename to AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift diff --git a/EhPanda/App/Tools/IdentifiableBox.swift b/AppPackage/Sources/AppFeature/Tools/IdentifiableBox.swift similarity index 100% rename from EhPanda/App/Tools/IdentifiableBox.swift rename to AppPackage/Sources/AppFeature/Tools/IdentifiableBox.swift diff --git a/EhPanda/App/Tools/Parser/Parser+Archive.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Archive.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+Archive.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+Archive.swift diff --git a/EhPanda/App/Tools/Parser/Parser+Comment.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Comment.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+Comment.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+Comment.swift diff --git a/EhPanda/App/Tools/Parser/Parser+Detail.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+Detail.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift diff --git a/EhPanda/App/Tools/Parser/Parser+Favorite.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Favorite.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+Favorite.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+Favorite.swift diff --git a/EhPanda/App/Tools/Parser/Parser+Greeting.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Greeting.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+Greeting.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+Greeting.swift diff --git a/EhPanda/App/Tools/Parser/Parser+Image.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Image.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+Image.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+Image.swift diff --git a/EhPanda/App/Tools/Parser/Parser+List.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+List.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+List.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+List.swift diff --git a/EhPanda/App/Tools/Parser/Parser+Misc.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Misc.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+Misc.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+Misc.swift diff --git a/EhPanda/App/Tools/Parser/Parser+Preview.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+Preview.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift diff --git a/EhPanda/App/Tools/Parser/Parser+Profile.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Profile.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+Profile.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+Profile.swift diff --git a/EhPanda/App/Tools/Parser/Parser+ResponseError.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+ResponseError.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift diff --git a/EhPanda/App/Tools/Parser/Parser+Shared.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Shared.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+Shared.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+Shared.swift diff --git a/EhPanda/App/Tools/Parser/Parser+Torrent.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Torrent.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+Torrent.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+Torrent.swift diff --git a/EhPanda/App/Tools/Parser/Parser+Types.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Types.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+Types.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+Types.swift diff --git a/EhPanda/App/Tools/Parser/Parser+User.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+User.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser+User.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser+User.swift diff --git a/EhPanda/App/Tools/Parser/Parser.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser.swift similarity index 100% rename from EhPanda/App/Tools/Parser/Parser.swift rename to AppPackage/Sources/AppFeature/Tools/Parser/Parser.swift diff --git a/EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/AppLaunchAutomation.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/AppLaunchAutomation.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/AppLaunchAutomation.swift diff --git a/EhPanda/App/Tools/Utilities/AppUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/AppUtil.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/AppUtil.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/AppUtil.swift diff --git a/EhPanda/App/Tools/Utilities/CookieUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/CookieUtil.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/CookieUtil.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/CookieUtil.swift diff --git a/EhPanda/App/Tools/Utilities/DataCache.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DataCache.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/DataCache.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/DataCache.swift diff --git a/EhPanda/App/Tools/Utilities/DeviceUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DeviceUtil.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/DeviceUtil.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/DeviceUtil.swift diff --git a/EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadBackgroundTaskStore.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/DownloadBackgroundTaskStore.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/DownloadBackgroundTaskStore.swift diff --git a/EhPanda/App/Tools/Utilities/DownloadFileManager.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadFileManager.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/DownloadFileManager.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/DownloadFileManager.swift diff --git a/EhPanda/App/Tools/Utilities/DownloadQueueStore.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadQueueStore.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/DownloadQueueStore.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/DownloadQueueStore.swift diff --git a/EhPanda/App/Tools/Utilities/DownloadStore+JSONCoding.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+JSONCoding.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/DownloadStore+JSONCoding.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+JSONCoding.swift diff --git a/EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/DownloadStore+Operations.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift diff --git a/EhPanda/App/Tools/Utilities/DownloadStore.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/DownloadStore.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift diff --git a/EhPanda/App/Tools/Utilities/FileUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/FileUtil.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/FileUtil.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/FileUtil.swift diff --git a/EhPanda/App/Tools/Utilities/HapticsUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/HapticsUtil.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/HapticsUtil.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/HapticsUtil.swift diff --git a/EhPanda/App/Tools/Utilities/ImagePlaceholderFingerprint.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/ImagePlaceholderFingerprint.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/ImagePlaceholderFingerprint.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/ImagePlaceholderFingerprint.swift diff --git a/EhPanda/App/Tools/Utilities/MarkdownUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/MarkdownUtil.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/MarkdownUtil.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/MarkdownUtil.swift diff --git a/EhPanda/App/Tools/Utilities/URLUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/URLUtil.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/URLUtil.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/URLUtil.swift diff --git a/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/UserDefaultsUtil.swift similarity index 100% rename from EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift rename to AppPackage/Sources/AppFeature/Tools/Utilities/UserDefaultsUtil.swift diff --git a/EhPanda/View/Detail/Archives/ArchivesReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift similarity index 100% rename from EhPanda/View/Detail/Archives/ArchivesReducer.swift rename to AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift diff --git a/EhPanda/View/Detail/Archives/ArchivesView.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift similarity index 100% rename from EhPanda/View/Detail/Archives/ArchivesView.swift rename to AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift diff --git a/EhPanda/View/Detail/Comments/CommentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift similarity index 100% rename from EhPanda/View/Detail/Comments/CommentsReducer.swift rename to AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift diff --git a/EhPanda/View/Detail/Comments/CommentsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift similarity index 100% rename from EhPanda/View/Detail/Comments/CommentsView.swift rename to AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift diff --git a/EhPanda/View/Detail/Components/LinkedText.swift b/AppPackage/Sources/AppFeature/View/Detail/Components/LinkedText.swift similarity index 100% rename from EhPanda/View/Detail/Components/LinkedText.swift rename to AppPackage/Sources/AppFeature/View/Detail/Components/LinkedText.swift diff --git a/EhPanda/View/Detail/Components/PostCommentView.swift b/AppPackage/Sources/AppFeature/View/Detail/Components/PostCommentView.swift similarity index 100% rename from EhPanda/View/Detail/Components/PostCommentView.swift rename to AppPackage/Sources/AppFeature/View/Detail/Components/PostCommentView.swift diff --git a/EhPanda/View/Detail/Components/RatingView.swift b/AppPackage/Sources/AppFeature/View/Detail/Components/RatingView.swift similarity index 100% rename from EhPanda/View/Detail/Components/RatingView.swift rename to AppPackage/Sources/AppFeature/View/Detail/Components/RatingView.swift diff --git a/EhPanda/View/Detail/Components/TagDetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift similarity index 100% rename from EhPanda/View/Detail/Components/TagDetailView.swift rename to AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift diff --git a/EhPanda/View/Detail/DetailReducer+Actions.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Actions.swift similarity index 100% rename from EhPanda/View/Detail/DetailReducer+Actions.swift rename to AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Actions.swift diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift similarity index 100% rename from EhPanda/View/Detail/DetailReducer+Download.swift rename to AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift diff --git a/EhPanda/View/Detail/DetailReducer+Fetch.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Fetch.swift similarity index 100% rename from EhPanda/View/Detail/DetailReducer+Fetch.swift rename to AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Fetch.swift diff --git a/EhPanda/View/Detail/DetailReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift similarity index 100% rename from EhPanda/View/Detail/DetailReducer.swift rename to AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift similarity index 100% rename from EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift rename to AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift similarity index 100% rename from EhPanda/View/Detail/DetailSearch/DetailSearchView.swift rename to AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift diff --git a/EhPanda/View/Detail/DetailView+CommentCells.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift similarity index 100% rename from EhPanda/View/Detail/DetailView+CommentCells.swift rename to AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift diff --git a/EhPanda/View/Detail/DetailView+HeaderSection.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift similarity index 100% rename from EhPanda/View/Detail/DetailView+HeaderSection.swift rename to AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift diff --git a/EhPanda/View/Detail/DetailView+Navigation.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift similarity index 100% rename from EhPanda/View/Detail/DetailView+Navigation.swift rename to AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift diff --git a/EhPanda/View/Detail/DetailView+Subviews.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift similarity index 100% rename from EhPanda/View/Detail/DetailView+Subviews.swift rename to AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift diff --git a/EhPanda/View/Detail/DetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift similarity index 100% rename from EhPanda/View/Detail/DetailView.swift rename to AppPackage/Sources/AppFeature/View/Detail/DetailView.swift diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift similarity index 100% rename from EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift rename to AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift similarity index 100% rename from EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift rename to AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift diff --git a/EhPanda/View/Detail/Previews/PreviewsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift similarity index 100% rename from EhPanda/View/Detail/Previews/PreviewsReducer.swift rename to AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift diff --git a/EhPanda/View/Detail/Previews/PreviewsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift similarity index 100% rename from EhPanda/View/Detail/Previews/PreviewsView.swift rename to AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift diff --git a/EhPanda/View/Detail/Torrents/TorrentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift similarity index 100% rename from EhPanda/View/Detail/Torrents/TorrentsReducer.swift rename to AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift diff --git a/EhPanda/View/Detail/Torrents/TorrentsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift similarity index 100% rename from EhPanda/View/Detail/Torrents/TorrentsView.swift rename to AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift diff --git a/EhPanda/View/Downloads/DownloadInspectorReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift similarity index 100% rename from EhPanda/View/Downloads/DownloadInspectorReducer.swift rename to AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift diff --git a/EhPanda/View/Downloads/DownloadsReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift similarity index 100% rename from EhPanda/View/Downloads/DownloadsReducer.swift rename to AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift diff --git a/EhPanda/View/Downloads/DownloadsView+Subviews.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift similarity index 100% rename from EhPanda/View/Downloads/DownloadsView+Subviews.swift rename to AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift diff --git a/EhPanda/View/Downloads/DownloadsView.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift similarity index 100% rename from EhPanda/View/Downloads/DownloadsView.swift rename to AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift diff --git a/EhPanda/View/Downloads/FolderManagerReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift similarity index 100% rename from EhPanda/View/Downloads/FolderManagerReducer.swift rename to AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift diff --git a/EhPanda/View/Downloads/FolderManagerView.swift b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift similarity index 100% rename from EhPanda/View/Downloads/FolderManagerView.swift rename to AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift similarity index 100% rename from EhPanda/View/Favorites/FavoritesReducer.swift rename to AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift similarity index 100% rename from EhPanda/View/Favorites/FavoritesView.swift rename to AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift similarity index 100% rename from EhPanda/View/Home/Frontpage/FrontpageReducer.swift rename to AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift similarity index 100% rename from EhPanda/View/Home/Frontpage/FrontpageView.swift rename to AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift diff --git a/EhPanda/View/Home/History/HistoryReducer.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift similarity index 100% rename from EhPanda/View/Home/History/HistoryReducer.swift rename to AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift diff --git a/EhPanda/View/Home/History/HistoryView.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift similarity index 100% rename from EhPanda/View/Home/History/HistoryView.swift rename to AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift diff --git a/EhPanda/View/Home/HomeReducer+Body.swift b/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift similarity index 100% rename from EhPanda/View/Home/HomeReducer+Body.swift rename to AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift diff --git a/EhPanda/View/Home/HomeReducer.swift b/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift similarity index 100% rename from EhPanda/View/Home/HomeReducer.swift rename to AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift diff --git a/EhPanda/View/Home/HomeView+Sections.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift similarity index 100% rename from EhPanda/View/Home/HomeView+Sections.swift rename to AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift diff --git a/EhPanda/View/Home/HomeView.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift similarity index 100% rename from EhPanda/View/Home/HomeView.swift rename to AppPackage/Sources/AppFeature/View/Home/HomeView.swift diff --git a/EhPanda/View/Home/Popular/PopularReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift similarity index 100% rename from EhPanda/View/Home/Popular/PopularReducer.swift rename to AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift diff --git a/EhPanda/View/Home/Popular/PopularView.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift similarity index 100% rename from EhPanda/View/Home/Popular/PopularView.swift rename to AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift diff --git a/EhPanda/View/Home/Toplists/ToplistsReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift similarity index 100% rename from EhPanda/View/Home/Toplists/ToplistsReducer.swift rename to AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift diff --git a/EhPanda/View/Home/Toplists/ToplistsView.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift similarity index 100% rename from EhPanda/View/Home/Toplists/ToplistsView.swift rename to AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift similarity index 100% rename from EhPanda/View/Home/Watched/WatchedReducer.swift rename to AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift similarity index 100% rename from EhPanda/View/Home/Watched/WatchedView.swift rename to AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift diff --git a/EhPanda/View/Migration/MigrationReducer.swift b/AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift similarity index 100% rename from EhPanda/View/Migration/MigrationReducer.swift rename to AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift diff --git a/EhPanda/View/Migration/MigrationView.swift b/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift similarity index 100% rename from EhPanda/View/Migration/MigrationView.swift rename to AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift diff --git a/EhPanda/View/Reading/ReadingReducer+Body.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Body.swift similarity index 100% rename from EhPanda/View/Reading/ReadingReducer+Body.swift rename to AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Body.swift diff --git a/EhPanda/View/Reading/ReadingReducer+Database.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Database.swift similarity index 100% rename from EhPanda/View/Reading/ReadingReducer+Database.swift rename to AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Database.swift diff --git a/EhPanda/View/Reading/ReadingReducer+ImageFetch.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+ImageFetch.swift similarity index 100% rename from EhPanda/View/Reading/ReadingReducer+ImageFetch.swift rename to AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+ImageFetch.swift diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift similarity index 100% rename from EhPanda/View/Reading/ReadingReducer.swift rename to AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift diff --git a/EhPanda/View/Reading/ReadingView+Gestures.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingView+Gestures.swift similarity index 100% rename from EhPanda/View/Reading/ReadingView+Gestures.swift rename to AppPackage/Sources/AppFeature/View/Reading/ReadingView+Gestures.swift diff --git a/EhPanda/View/Reading/ReadingView.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift similarity index 100% rename from EhPanda/View/Reading/ReadingView.swift rename to AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift diff --git a/EhPanda/View/Reading/ReadingViewComponents.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift similarity index 100% rename from EhPanda/View/Reading/ReadingViewComponents.swift rename to AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift diff --git a/EhPanda/View/Reading/Support/AdvancedList.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/AdvancedList.swift similarity index 100% rename from EhPanda/View/Reading/Support/AdvancedList.swift rename to AppPackage/Sources/AppFeature/View/Reading/Support/AdvancedList.swift diff --git a/EhPanda/View/Reading/Support/AutoPlayHandler.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/AutoPlayHandler.swift similarity index 100% rename from EhPanda/View/Reading/Support/AutoPlayHandler.swift rename to AppPackage/Sources/AppFeature/View/Reading/Support/AutoPlayHandler.swift diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift similarity index 100% rename from EhPanda/View/Reading/Support/ControlPanel.swift rename to AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift diff --git a/EhPanda/View/Reading/Support/GestureHandler.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/GestureHandler.swift similarity index 100% rename from EhPanda/View/Reading/Support/GestureHandler.swift rename to AppPackage/Sources/AppFeature/View/Reading/Support/GestureHandler.swift diff --git a/EhPanda/View/Reading/Support/LiveTextHandler.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextHandler.swift similarity index 100% rename from EhPanda/View/Reading/Support/LiveTextHandler.swift rename to AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextHandler.swift diff --git a/EhPanda/View/Reading/Support/LiveTextView.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextView.swift similarity index 100% rename from EhPanda/View/Reading/Support/LiveTextView.swift rename to AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextView.swift diff --git a/EhPanda/View/Reading/Support/PageHandler.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/PageHandler.swift similarity index 100% rename from EhPanda/View/Reading/Support/PageHandler.swift rename to AppPackage/Sources/AppFeature/View/Reading/Support/PageHandler.swift diff --git a/EhPanda/View/Search/SearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift similarity index 100% rename from EhPanda/View/Search/SearchReducer.swift rename to AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift diff --git a/EhPanda/View/Search/SearchRootReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift similarity index 100% rename from EhPanda/View/Search/SearchRootReducer.swift rename to AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift diff --git a/EhPanda/View/Search/SearchRootView+Keywords.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootView+Keywords.swift similarity index 100% rename from EhPanda/View/Search/SearchRootView+Keywords.swift rename to AppPackage/Sources/AppFeature/View/Search/SearchRootView+Keywords.swift diff --git a/EhPanda/View/Search/SearchRootView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift similarity index 100% rename from EhPanda/View/Search/SearchRootView.swift rename to AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift diff --git a/EhPanda/View/Search/SearchView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift similarity index 100% rename from EhPanda/View/Search/SearchView.swift rename to AppPackage/Sources/AppFeature/View/Search/SearchView.swift diff --git a/EhPanda/View/Search/Support/QuickSearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift similarity index 100% rename from EhPanda/View/Search/Support/QuickSearchReducer.swift rename to AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift diff --git a/EhPanda/View/Search/Support/QuickSearchView.swift b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift similarity index 100% rename from EhPanda/View/Search/Support/QuickSearchView.swift rename to AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift similarity index 100% rename from EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift rename to AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift similarity index 100% rename from EhPanda/View/Setting/AccountSetting/AccountSettingView.swift rename to AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift diff --git a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift similarity index 100% rename from EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift rename to AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift diff --git a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift similarity index 100% rename from EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift rename to AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift diff --git a/EhPanda/View/Setting/Components/AboutView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift similarity index 100% rename from EhPanda/View/Setting/Components/AboutView.swift rename to AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift diff --git a/EhPanda/View/Setting/Components/DownloadSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/DownloadSettingView.swift similarity index 100% rename from EhPanda/View/Setting/Components/DownloadSettingView.swift rename to AppPackage/Sources/AppFeature/View/Setting/Components/DownloadSettingView.swift diff --git a/EhPanda/View/Setting/Components/LaboratorySettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/LaboratorySettingView.swift similarity index 100% rename from EhPanda/View/Setting/Components/LaboratorySettingView.swift rename to AppPackage/Sources/AppFeature/View/Setting/Components/LaboratorySettingView.swift diff --git a/EhPanda/View/Setting/Components/ReadingSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift similarity index 100% rename from EhPanda/View/Setting/Components/ReadingSettingView.swift rename to AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift diff --git a/EhPanda/View/Setting/Components/WebView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/WebView.swift similarity index 100% rename from EhPanda/View/Setting/Components/WebView.swift rename to AppPackage/Sources/AppFeature/View/Setting/Components/WebView.swift diff --git a/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift similarity index 100% rename from EhPanda/View/Setting/EhSetting/EhSettingReducer.swift rename to AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift similarity index 100% rename from EhPanda/View/Setting/EhSetting/EhSettingView+Sections1.swift rename to AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift similarity index 100% rename from EhPanda/View/Setting/EhSetting/EhSettingView+Sections2.swift rename to AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift similarity index 100% rename from EhPanda/View/Setting/EhSetting/EhSettingView+Sections3.swift rename to AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift similarity index 100% rename from EhPanda/View/Setting/EhSetting/EhSettingView.swift rename to AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift similarity index 100% rename from EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift rename to AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift similarity index 100% rename from EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift rename to AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift diff --git a/EhPanda/View/Setting/Login/LoginReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift similarity index 100% rename from EhPanda/View/Setting/Login/LoginReducer.swift rename to AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift diff --git a/EhPanda/View/Setting/Login/LoginView.swift b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift similarity index 100% rename from EhPanda/View/Setting/Login/LoginView.swift rename to AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift diff --git a/EhPanda/View/Setting/Logs/LogsReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift similarity index 100% rename from EhPanda/View/Setting/Logs/LogsReducer.swift rename to AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift diff --git a/EhPanda/View/Setting/Logs/LogsView.swift b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift similarity index 100% rename from EhPanda/View/Setting/Logs/LogsView.swift rename to AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift diff --git a/EhPanda/View/Setting/SettingReducer+Body.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Body.swift similarity index 100% rename from EhPanda/View/Setting/SettingReducer+Body.swift rename to AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Body.swift diff --git a/EhPanda/View/Setting/SettingReducer+Helpers.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Helpers.swift similarity index 100% rename from EhPanda/View/Setting/SettingReducer+Helpers.swift rename to AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Helpers.swift diff --git a/EhPanda/View/Setting/SettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift similarity index 100% rename from EhPanda/View/Setting/SettingReducer.swift rename to AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift diff --git a/EhPanda/View/Setting/SettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift similarity index 100% rename from EhPanda/View/Setting/SettingView.swift rename to AppPackage/Sources/AppFeature/View/Setting/SettingView.swift diff --git a/EhPanda/View/Support/Components/ActivityView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/ActivityView.swift similarity index 100% rename from EhPanda/View/Support/Components/ActivityView.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/ActivityView.swift diff --git a/EhPanda/View/Support/Components/AlertView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift similarity index 100% rename from EhPanda/View/Support/Components/AlertView.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift diff --git a/EhPanda/View/Support/Components/CategoryView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift similarity index 100% rename from EhPanda/View/Support/Components/CategoryView.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift diff --git a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift similarity index 100% rename from EhPanda/View/Support/Components/Cells/GalleryCardCell.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift diff --git a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift similarity index 100% rename from EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift diff --git a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift similarity index 100% rename from EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift diff --git a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift similarity index 100% rename from EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift diff --git a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift similarity index 100% rename from EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift diff --git a/EhPanda/View/Support/Components/DateSeekPickerView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift similarity index 100% rename from EhPanda/View/Support/Components/DateSeekPickerView.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift diff --git a/EhPanda/View/Support/Components/DownloadBadgeLabel.swift b/AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift similarity index 100% rename from EhPanda/View/Support/Components/DownloadBadgeLabel.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift diff --git a/EhPanda/View/Support/Components/GenericList.swift b/AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift similarity index 100% rename from EhPanda/View/Support/Components/GenericList.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift diff --git a/EhPanda/View/Support/Components/Placeholder.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift similarity index 100% rename from EhPanda/View/Support/Components/Placeholder.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift diff --git a/EhPanda/View/Support/Components/PreviewImageView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift similarity index 100% rename from EhPanda/View/Support/Components/PreviewImageView.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift diff --git a/EhPanda/View/Support/Components/SettingTextField.swift b/AppPackage/Sources/AppFeature/View/Support/Components/SettingTextField.swift similarity index 100% rename from EhPanda/View/Support/Components/SettingTextField.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/SettingTextField.swift diff --git a/EhPanda/View/Support/Components/SubSection.swift b/AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift similarity index 100% rename from EhPanda/View/Support/Components/SubSection.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift diff --git a/EhPanda/View/Support/Components/TagCloudView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift similarity index 100% rename from EhPanda/View/Support/Components/TagCloudView.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift diff --git a/EhPanda/View/Support/Components/TagSuggestionView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift similarity index 100% rename from EhPanda/View/Support/Components/TagSuggestionView.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift similarity index 100% rename from EhPanda/View/Support/Components/ToolbarItems.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift diff --git a/EhPanda/View/Support/Components/WaveForm.swift b/AppPackage/Sources/AppFeature/View/Support/Components/WaveForm.swift similarity index 100% rename from EhPanda/View/Support/Components/WaveForm.swift rename to AppPackage/Sources/AppFeature/View/Support/Components/WaveForm.swift diff --git a/EhPanda/View/Support/DateSeekReducer.swift b/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift similarity index 100% rename from EhPanda/View/Support/DateSeekReducer.swift rename to AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift diff --git a/EhPanda/View/Support/FiltersReducer.swift b/AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift similarity index 100% rename from EhPanda/View/Support/FiltersReducer.swift rename to AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift diff --git a/EhPanda/View/Support/FiltersView.swift b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift similarity index 100% rename from EhPanda/View/Support/FiltersView.swift rename to AppPackage/Sources/AppFeature/View/Support/FiltersView.swift diff --git a/EhPanda/View/Support/NewDawnView.swift b/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift similarity index 100% rename from EhPanda/View/Support/NewDawnView.swift rename to AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift diff --git a/EhPanda/View/TabBar/TabBarReducer.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarReducer.swift similarity index 100% rename from EhPanda/View/TabBar/TabBarReducer.swift rename to AppPackage/Sources/AppFeature/View/TabBar/TabBarReducer.swift diff --git a/EhPanda/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift similarity index 100% rename from EhPanda/View/TabBar/TabBarView.swift rename to AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift diff --git a/AppPackage/Tests/AppFeatureTests/.swiftlint.yml b/AppPackage/Tests/AppFeatureTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/AppFeatureTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPandaTests/Resources/Utility/TestHelper.swift b/AppPackage/Tests/AppFeatureTests/Helpers/TestHelper.swift similarity index 87% rename from EhPandaTests/Resources/Utility/TestHelper.swift rename to AppPackage/Tests/AppFeatureTests/Helpers/TestHelper.swift index dbb257078..1ffda122c 100644 --- a/EhPandaTests/Resources/Utility/TestHelper.swift +++ b/AppPackage/Tests/AppFeatureTests/Helpers/TestHelper.swift @@ -8,7 +8,7 @@ final class TestBundleLocator {} extension TestHelper { func htmlDocument(filename: HTMLFilename) throws -> HTMLDocument { - guard let url = Bundle(for: TestBundleLocator.self) + guard let url = Bundle.module .url(forResource: filename.rawValue, withExtension: "html") else { throw TestError.htmlDocumentNotFound(filename) diff --git a/EhPandaTests/Models/HTMLFilename.swift b/AppPackage/Tests/AppFeatureTests/Models/HTMLFilename.swift similarity index 100% rename from EhPandaTests/Models/HTMLFilename.swift rename to AppPackage/Tests/AppFeatureTests/Models/HTMLFilename.swift diff --git a/EhPandaTests/Models/ListParserTestType.swift b/AppPackage/Tests/AppFeatureTests/Models/ListParserTestType.swift similarity index 100% rename from EhPandaTests/Models/ListParserTestType.swift rename to AppPackage/Tests/AppFeatureTests/Models/ListParserTestType.swift diff --git a/EhPandaTests/Models/TestError.swift b/AppPackage/Tests/AppFeatureTests/Models/TestError.swift similarity index 100% rename from EhPandaTests/Models/TestError.swift rename to AppPackage/Tests/AppFeatureTests/Models/TestError.swift diff --git a/EhPandaTests/Resources/Parser/Gallery/GalleryDetail.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/Gallery/GalleryDetail.html similarity index 100% rename from EhPandaTests/Resources/Parser/Gallery/GalleryDetail.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/Gallery/GalleryDetail.html diff --git a/EhPandaTests/Resources/Parser/Gallery/GalleryMPVKeys.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/Gallery/GalleryMPVKeys.html similarity index 100% rename from EhPandaTests/Resources/Parser/Gallery/GalleryMPVKeys.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/Gallery/GalleryMPVKeys.html diff --git a/EhPandaTests/Resources/Parser/Gallery/GalleryNormalImageURL.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/Gallery/GalleryNormalImageURL.html similarity index 100% rename from EhPandaTests/Resources/Parser/Gallery/GalleryNormalImageURL.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/Gallery/GalleryNormalImageURL.html diff --git a/EhPandaTests/Resources/Parser/List/FavoritesCompactList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesCompactList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FavoritesCompactList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesCompactList.html diff --git a/EhPandaTests/Resources/Parser/List/FavoritesExtendedList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesExtendedList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FavoritesExtendedList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesExtendedList.html diff --git a/EhPandaTests/Resources/Parser/List/FavoritesMinimalList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesMinimalList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FavoritesMinimalList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesMinimalList.html diff --git a/EhPandaTests/Resources/Parser/List/FavoritesMinimalPlusList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesMinimalPlusList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FavoritesMinimalPlusList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesMinimalPlusList.html diff --git a/EhPandaTests/Resources/Parser/List/FavoritesThumbnailList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesThumbnailList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FavoritesThumbnailList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesThumbnailList.html diff --git a/EhPandaTests/Resources/Parser/List/FrontPageCompactList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageCompactList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FrontPageCompactList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageCompactList.html diff --git a/EhPandaTests/Resources/Parser/List/FrontPageExtendedList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageExtendedList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FrontPageExtendedList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageExtendedList.html diff --git a/EhPandaTests/Resources/Parser/List/FrontPageMinimalList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageMinimalList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FrontPageMinimalList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageMinimalList.html diff --git a/EhPandaTests/Resources/Parser/List/FrontPageMinimalPlusList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageMinimalPlusList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FrontPageMinimalPlusList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageMinimalPlusList.html diff --git a/EhPandaTests/Resources/Parser/List/FrontPageThumbnailList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageThumbnailList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FrontPageThumbnailList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageThumbnailList.html diff --git a/EhPandaTests/Resources/Parser/List/PopularCompactList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularCompactList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/PopularCompactList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularCompactList.html diff --git a/EhPandaTests/Resources/Parser/List/PopularExtendedList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularExtendedList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/PopularExtendedList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularExtendedList.html diff --git a/EhPandaTests/Resources/Parser/List/PopularMinimalList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularMinimalList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/PopularMinimalList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularMinimalList.html diff --git a/EhPandaTests/Resources/Parser/List/PopularMinimalPlusList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularMinimalPlusList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/PopularMinimalPlusList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularMinimalPlusList.html diff --git a/EhPandaTests/Resources/Parser/List/PopularThumbnailList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularThumbnailList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/PopularThumbnailList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularThumbnailList.html diff --git a/EhPandaTests/Resources/Parser/List/ToplistsCompactList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/ToplistsCompactList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/ToplistsCompactList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/ToplistsCompactList.html diff --git a/EhPandaTests/Resources/Parser/List/WatchedCompactList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedCompactList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/WatchedCompactList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedCompactList.html diff --git a/EhPandaTests/Resources/Parser/List/WatchedExtendedList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedExtendedList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/WatchedExtendedList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedExtendedList.html diff --git a/EhPandaTests/Resources/Parser/List/WatchedMinimalList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedMinimalList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/WatchedMinimalList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedMinimalList.html diff --git a/EhPandaTests/Resources/Parser/List/WatchedMinimalPlusList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedMinimalPlusList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/WatchedMinimalPlusList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedMinimalPlusList.html diff --git a/EhPandaTests/Resources/Parser/List/WatchedThumbnailList.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedThumbnailList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/WatchedThumbnailList.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedThumbnailList.html diff --git a/EhPandaTests/Resources/Parser/Other/BandwidthExceeded.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/BandwidthExceeded.html similarity index 100% rename from EhPandaTests/Resources/Parser/Other/BandwidthExceeded.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/BandwidthExceeded.html diff --git a/EhPandaTests/Resources/Parser/Other/EhSetting.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/EhSetting.html similarity index 100% rename from EhPandaTests/Resources/Parser/Other/EhSetting.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/EhSetting.html diff --git a/EhPandaTests/Resources/Parser/Other/ExLoginRequired.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/ExLoginRequired.html similarity index 100% rename from EhPandaTests/Resources/Parser/Other/ExLoginRequired.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/ExLoginRequired.html diff --git a/EhPandaTests/Resources/Parser/Other/GalleryDetailWithGreeting.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/GalleryDetailWithGreeting.html similarity index 100% rename from EhPandaTests/Resources/Parser/Other/GalleryDetailWithGreeting.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/GalleryDetailWithGreeting.html diff --git a/EhPandaTests/Resources/Parser/Other/IPBanned.html b/AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/IPBanned.html similarity index 100% rename from EhPandaTests/Resources/Parser/Other/IPBanned.html rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/IPBanned.html diff --git a/EhPandaTests/Resources/Parser/Other/Kokomade.jpg b/AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/Kokomade.jpg similarity index 100% rename from EhPandaTests/Resources/Parser/Other/Kokomade.jpg rename to AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/Kokomade.jpg diff --git a/EhPandaTests/Tests/Download/DataCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DataCacheTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift index 88c56f637..d8c785b26 100644 --- a/EhPandaTests/Tests/Download/DataCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DataCacheTests { diff --git a/EhPandaTests/Tests/Download/DatabaseClientUpdateTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DatabaseClientUpdateTests.swift similarity index 98% rename from EhPandaTests/Tests/Download/DatabaseClientUpdateTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DatabaseClientUpdateTests.swift index 41fc5cbdc..1d08823a9 100644 --- a/EhPandaTests/Tests/Download/DatabaseClientUpdateTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DatabaseClientUpdateTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature struct DatabaseClientUpdateTests: DownloadFeatureTestCase { @MainActor diff --git a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift index cf9715ca3..00f6f3e3d 100644 --- a/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) @MainActor diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift index 80b0ed52e..9f0f9b319 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) @MainActor diff --git a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index f34296d8f..3b3f8c241 100644 --- a/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) @MainActor diff --git a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DetailReducerObserveTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift index 883929fc5..e52f34aec 100644 --- a/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) @MainActor diff --git a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index 55b16f2cd..6e238c035 100644 --- a/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) @MainActor diff --git a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadAutomationTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index bfc541493..6e0f82917 100644 --- a/EhPandaTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -2,7 +2,7 @@ import Foundation import SwiftUI import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadAutomationTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundAssertionTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundAssertionTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadBackgroundAssertionTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundAssertionTests.swift index e0d41f38a..a04c4e9ec 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundAssertionTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundAssertionTests.swift @@ -2,7 +2,7 @@ import Foundation import Synchronization import UIKit import Testing -@testable import EhPanda +@testable import AppFeature @Suite struct DownloadBackgroundAssertionTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundCompletionTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundCompletionTests.swift index 2ea2e231b..a176dc36b 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundCompletionTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundCompletionTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift index 0a609b0f5..c13ba6db6 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundProcessingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadBackgroundProcessingTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift similarity index 98% rename from EhPandaTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift index 764af8b52..c2392cc6d 100644 --- a/EhPandaTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature struct DownloadBackgroundTaskStoreTests { @Test diff --git a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift index d368de9a1..5bc913658 100644 --- a/EhPandaTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift @@ -3,7 +3,7 @@ import Foundation import SFSafeSymbols import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature struct DownloadBadgeSortTests: DownloadFeatureTestCase { @Test diff --git a/EhPandaTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift index 94f97742f..4bc8920c2 100644 --- a/EhPandaTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadCoordinatorCachedURLTests { diff --git a/EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift index 87cc3659c..c626e536b 100644 --- a/EhPandaTests/Tests/Download/DownloadCoordinatorCaptureTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift @@ -1,7 +1,7 @@ import UIKit import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadCoordinatorCaptureTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift index bb240c16c..38d3ec70c 100644 --- a/EhPandaTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadCoordinatorRepairSeedTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift index 6bfd9b8d8..31883b50a 100644 --- a/EhPandaTests/Tests/Download/DownloadCoordinatorStorageTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift @@ -2,7 +2,7 @@ import Kingfisher import UIKit import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift index 1fd34212f..ca9a432de 100644 --- a/EhPandaTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { @Test diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift index e27cb02e5..d6bc8bd11 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -1,7 +1,7 @@ import CoreData import Foundation import Testing -@testable import EhPanda +@testable import AppFeature // MARK: - Sample Data Factories & CoreData Helpers @@ -63,7 +63,7 @@ extension DownloadedGallery { title: String, jpnTitle: String?, uploader: String?, - category: EhPanda.Category, + category: AppFeature.Category, tags: [GalleryTag], pageCount: Int, postedDate: Date, @@ -187,7 +187,7 @@ extension DownloadFeatureTestCase { gid: String, title: String, status: DownloadFixtureStatus, - category: EhPanda.Category = .doujinshi, + category: AppFeature.Category = .doujinshi, pageCount: Int = 12, completedPageCount: Int? = nil, lastDownloadedDate: Date? = .now, diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift similarity index 98% rename from EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift index 16743cbe1..3892b8863 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -4,7 +4,7 @@ import ComposableArchitecture import Kingfisher import UIKit import Testing -@testable import EhPanda +@testable import AppFeature // MARK: - Shared Test Helper Protocol @@ -202,7 +202,7 @@ extension DownloadFeatureTestCase { pathExtension: String ) throws -> Data { let fixtureURL = try #require( - Bundle(for: TestBundleLocator.self).url(forResource: resource, withExtension: pathExtension) + Bundle.module.url(forResource: resource, withExtension: pathExtension) ) return try Data(contentsOf: fixtureURL) } diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestSupportTypes.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestSupportTypes.swift index 98dea5e17..9482178af 100644 --- a/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestSupportTypes.swift @@ -1,6 +1,6 @@ import Foundation import Synchronization -@testable import EhPanda +@testable import AppFeature // MARK: - Supporting Types diff --git a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 3af9de9d1..b02e9271f 100644 --- a/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -3,7 +3,7 @@ import Foundation import SFSafeSymbols import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { @Test diff --git a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift index 5b1eac800..40e3a16dc 100644 --- a/EhPandaTests/Tests/Download/DownloadFolderOperationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadFolderOperationTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageErrorTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadImageErrorTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageErrorTests.swift index b5493f4cd..b6fefcf94 100644 --- a/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageErrorTests.swift @@ -1,7 +1,7 @@ import CoreData import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadImageErrorTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift index 79c06feb2..1b844e381 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -3,7 +3,7 @@ import Kingfisher import UIKit import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadImageParsingTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift index c8d3bc37e..d31ead4f5 100644 --- a/EhPandaTests/Tests/Download/DownloadImageParsingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift @@ -3,7 +3,7 @@ import Kingfisher import UIKit import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadImageParsingTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift index 21696dffd..b18e99f08 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) @MainActor diff --git a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift index 318f146ee..7736e057c 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) @MainActor diff --git a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift index e5af1f94d..2e70ab4ba 100644 --- a/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadInspectorSkipTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift index c1c0a4243..5ef3317d1 100644 --- a/EhPandaTests/Tests/Download/DownloadInterruptedResumeTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift similarity index 98% rename from EhPandaTests/Tests/Download/DownloadIpBanTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift index 89a6ce5b4..de0d9edbd 100644 --- a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadIpBanTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift index 045efa981..098b9f7c5 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadObserverBatchTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index b32f5fa47..719ccefa3 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) @MainActor diff --git a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index 4da67bb99..52656b173 100644 --- a/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) @MainActor diff --git a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift index 2fcede268..ddef398b5 100644 --- a/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -3,7 +3,7 @@ import ComposableArchitecture import Kingfisher import UIKit import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift index c914a966a..debbe683c 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift @@ -1,7 +1,7 @@ import UIKit import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadProcessCacheTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadProcessTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadProcessTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift index ef926aadd..bc2876234 100644 --- a/EhPandaTests/Tests/Download/DownloadProcessTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadProcessTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadQueueStoreTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadQueueStoreTests.swift similarity index 97% rename from EhPandaTests/Tests/Download/DownloadQueueStoreTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadQueueStoreTests.swift index d4b9449d9..7288792da 100644 --- a/EhPandaTests/Tests/Download/DownloadQueueStoreTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadQueueStoreTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature struct DownloadQueueStoreTests { @Test diff --git a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 9810cacce..5179ba688 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift index 8f938ab6a..56bf8fa6b 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadRetryPagesTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index d84dd591b..bb4e0dd42 100644 --- a/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadSchedulingTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift index 9e9708917..43559c0a7 100644 --- a/EhPandaTests/Tests/Download/DownloadSchedulingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite struct DownloadSchedulingTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadStoreHashTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift index efebc96c6..163e4c340 100644 --- a/EhPandaTests/Tests/Download/DownloadStoreHashTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature struct DownloadStoreHashTests { @Test diff --git a/EhPandaTests/Tests/Download/DownloadStoreRepairTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadStoreRepairTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift index 7001a8be0..4b8d17258 100644 --- a/EhPandaTests/Tests/Download/DownloadStoreRepairTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature struct DownloadStoreRepairTests { @Test diff --git a/EhPandaTests/Tests/Download/DownloadStoreTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadStoreTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift index f89956ffb..61691b1d9 100644 --- a/EhPandaTests/Tests/Download/DownloadStoreTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature struct DownloadStoreTests { @Test diff --git a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift index 32d64b723..106f8b9e9 100644 --- a/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadVersionSignatureTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadedGalleryManifestModelTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index e29f11b47..893b5eae6 100644 --- a/EhPandaTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature struct DownloadedGalleryManifestModelTests { @Test diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift index d510af31e..878821bf7 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadsReducerActionTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift similarity index 96% rename from EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift index 448f0bab5..e07f3fe1b 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature struct DownloadsReducerReadingDismissTests { @MainActor diff --git a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift index 0fb6fc610..737e96a56 100644 --- a/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/FolderManagerReducerTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift index f434e9e36..554a25022 100644 --- a/EhPandaTests/Tests/Download/FolderManagerReducerTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) @MainActor diff --git a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift index a3842113f..49ed25d76 100644 --- a/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) @MainActor diff --git a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/ReaderImageDataTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift index fc4eee9fd..b33e4fcb5 100644 --- a/EhPandaTests/Tests/Download/ReaderImageDataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing import UIKit -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct ReaderImageDataTests { @@ -297,7 +297,7 @@ struct ReaderImageDataTests { private func fixtureData(resource: String, pathExtension: String) throws -> Data { let fixtureURL = try #require( - Bundle(for: TestBundleLocator.self).url(forResource: resource, withExtension: pathExtension) + Bundle.module.url(forResource: resource, withExtension: pathExtension) ) return try Data(contentsOf: fixtureURL) } diff --git a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index 926657a72..0c841bce7 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) @MainActor diff --git a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift similarity index 99% rename from EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index 6ad291f07..2d4a063d5 100644 --- a/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import Testing -@testable import EhPanda +@testable import AppFeature @Suite(.serialized) struct ReadingReducerLocalTests: DownloadFeatureTestCase { diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift similarity index 98% rename from EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift index 06fabd99c..d5c47bf97 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift @@ -1,6 +1,6 @@ import Kanna import Testing -@testable import EhPanda +@testable import AppFeature struct GalleryDetailParserTests: TestHelper { @Test diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift similarity index 97% rename from EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift index 847e384c4..f48867ec7 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift @@ -1,6 +1,6 @@ import Kanna import Testing -@testable import EhPanda +@testable import AppFeature struct GalleryImageURLParserTests: TestHelper { @Test diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift similarity index 92% rename from EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift index 74b325135..4acb4ced7 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift @@ -1,6 +1,6 @@ import Kanna import Testing -@testable import EhPanda +@testable import AppFeature struct GalleryMPVKeysParserTests: TestHelper { @Test diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift similarity index 99% rename from EhPandaTests/Tests/Parser/List/ListParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift index 31307c162..eca8b86eb 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift @@ -1,7 +1,7 @@ import Foundation import Kanna import Testing -@testable import EhPanda +@testable import AppFeature struct ListParserTests: TestHelper { @Test diff --git a/EhPandaTests/Tests/Parser/Other/AnimatedImageDataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/AnimatedImageDataTests.swift similarity index 99% rename from EhPandaTests/Tests/Parser/Other/AnimatedImageDataTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/AnimatedImageDataTests.swift index 077f0c37b..7f91fa929 100644 --- a/EhPandaTests/Tests/Parser/Other/AnimatedImageDataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/AnimatedImageDataTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import EhPanda +@testable import AppFeature struct AnimatedImageDataTests { @Test diff --git a/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/BanIntervalParserTests.swift similarity index 91% rename from EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/BanIntervalParserTests.swift index 2b842110c..438eb88b4 100644 --- a/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/BanIntervalParserTests.swift @@ -1,6 +1,6 @@ import Kanna import Testing -@testable import EhPanda +@testable import AppFeature struct BanIntervalParserTests: TestHelper { @Test diff --git a/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift similarity index 99% rename from EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift index 508b08a3e..22a47d589 100644 --- a/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift @@ -1,7 +1,7 @@ import Kanna import Combine import Testing -@testable import EhPanda +@testable import AppFeature struct DownloadPageErrorParserTests: TestHelper { @Test diff --git a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift similarity index 99% rename from EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift index b4d11da00..d6f2fd4c2 100644 --- a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift @@ -1,6 +1,6 @@ import Kanna import Testing -@testable import EhPanda +@testable import AppFeature struct EhSettingParserTests: TestHelper { @Test diff --git a/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/GreetingParserTests.swift similarity index 95% rename from EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/GreetingParserTests.swift index 968af0d4c..3cf64d05b 100644 --- a/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/GreetingParserTests.swift @@ -1,6 +1,6 @@ import Kanna import Testing -@testable import EhPanda +@testable import AppFeature struct GreetingParserTests: TestHelper { @Test diff --git a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift similarity index 99% rename from EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift index 7567aaf5c..f6829caf8 100644 --- a/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -1,6 +1,6 @@ import SwiftUI import Testing -@testable import EhPanda +@testable import AppFeature struct SettingDownloadTests { @Test diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index f78215a13..bc236b25b 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -7,31 +7,14 @@ objects = { /* Begin PBXBuildFile section */ - 82E6B0052F185A0000D1F93A /* SDWebImageWebPCoder in Frameworks */ = {isa = PBXBuildFile; productRef = 82E6B0042F185A0000D1F93A /* SDWebImageWebPCoder */; }; - 82E6B0082F185A0000D1F93A /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 82E6B0072F185A0000D1F93A /* SDWebImageSwiftUI */; }; - AB17573D27675B1E00FD64E2 /* Colorful in Frameworks */ = {isa = PBXBuildFile; productRef = AB17573C27675B1E00FD64E2 /* Colorful */; }; - AB17574027678B3400FD64E2 /* UIImageColors in Frameworks */ = {isa = PBXBuildFile; productRef = AB17573F27678B3400FD64E2 /* UIImageColors */; }; - AB1FA94927C62BC80063EF55 /* CommonMark in Frameworks */ = {isa = PBXBuildFile; productRef = AB1FA94827C62BC80063EF55 /* CommonMark */; }; - AB26F59927ACDB4200AB3468 /* FilePicker in Frameworks */ = {isa = PBXBuildFile; productRef = AB26F59827ACDB4200AB3468 /* FilePicker */; }; - AB2EB99F280251D600011A8A /* TTProgressHUD in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB99E280251D600011A8A /* TTProgressHUD */; }; - AB2EB9A2280251F600011A8A /* AlertKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB9A1280251F600011A8A /* AlertKit */; }; - AB2EB9A52802521700011A8A /* DeprecatedAPI in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB9A42802521700011A8A /* DeprecatedAPI */; }; + A0F00000000000000000F001 /* AppFeature in Frameworks */ = {isa = PBXBuildFile; productRef = A0F00000000000000000F002 /* AppFeature */; }; AB5BE68026B95FDD007D4A55 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - AB60D0E9274C7ECE00F899AB /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = AB60D0E8274C7ECE00F899AB /* WaterfallGrid */; }; - AB6505A026B0027800F91E9D /* SwiftUIPager in Frameworks */ = {isa = PBXBuildFile; productRef = AB65059F26B0027800F91E9D /* SwiftUIPager */; }; - AB86AC1027831AD100E61E6A /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = AB86AC0F27831AD100E61E6A /* ComposableArchitecture */; }; - ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */ = {isa = PBXBuildFile; productRef = ABAC82FD26BC4A96009F5026 /* OpenCC */; }; - ABBB2636278FB888007B6149 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = ABBB2635278FB888007B6149 /* SwiftUINavigation */; }; - ABC4A0792751B40E00968A4F /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = ABC4A0782751B40E00968A4F /* Kingfisher */; }; - ABD49D5D277C6C9D003D1A07 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */; }; - ABD7005926B1C31500DC59C9 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = ABD7005826B1C31500DC59C9 /* Kanna */; }; EA0C92592C3EB49500D211F6 /* README.cht.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92532C3EB49500D211F6 /* README.cht.md */; }; EA0C925A2C3EB49500D211F6 /* README.ko.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92542C3EB49500D211F6 /* README.ko.md */; }; EA0C925B2C3EB49500D211F6 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92552C3EB49500D211F6 /* README.md */; }; EA0C925C2C3EB49500D211F6 /* README.de.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92562C3EB49500D211F6 /* README.de.md */; }; EA0C925D2C3EB49500D211F6 /* README.chs.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92572C3EB49500D211F6 /* README.chs.md */; }; EA0C925E2C3EB49500D211F6 /* README.jpn.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92582C3EB49500D211F6 /* README.jpn.md */; }; - EAE63E2129E2A6330048C601 /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = EAE63E2029E2A6330048C601 /* SwiftyBeaver */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,13 +25,6 @@ remoteGlobalIDString = AB5BE67526B95FDD007D4A55; remoteInfo = ShareExtension; }; - ABF294D026D20F82004DD03A /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = ABC3C74C2593696C00E0C11B /* Project object */; - proxyType = 1; - remoteGlobalIDString = ABC3C7532593696C00E0C11B; - remoteInfo = EhPanda; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -66,7 +42,6 @@ /* Begin PBXFileReference section */ AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; ABC3C7542593696C00E0C11B /* EhPanda.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EhPanda.app; sourceTree = BUILT_PRODUCTS_DIR; }; - ABF294CC26D20F82004DD03A /* EhPandaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EhPandaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EA0C92482C3EB45E00D211F6 /* AltStore.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = AltStore.json; sourceTree = ""; }; EA0C92492C3EB45E00D211F6 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; EA0C924A2C3EB45E00D211F6 /* .gitattributes */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = .gitattributes; sourceTree = ""; }; @@ -81,11 +56,10 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - A6844C242F780C8700BBF6E5 /* Exceptions for "EhPanda" folder in "EhPanda" target */ = { + A6844C242F780C8700BBF6E5 /* Exceptions for "App" folder in "EhPanda" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - "/Localized: App/Constant.strings", - App/Info.plist, + Info.plist, ); target = ABC3C7532593696C00E0C11B /* EhPanda */; }; @@ -104,12 +78,12 @@ path = .github; sourceTree = ""; }; - A6844B3D2F780C8600BBF6E5 /* EhPanda */ = { + A6844B3D2F780C8600BBF6E5 /* App */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( - A6844C242F780C8700BBF6E5 /* Exceptions for "EhPanda" folder in "EhPanda" target */, + A6844C242F780C8700BBF6E5 /* Exceptions for "App" folder in "EhPanda" target */, ); - path = EhPanda; + path = App; sourceTree = ""; }; A6844C272F780C8B00BBF6E5 /* ShareExtension */ = { @@ -120,11 +94,6 @@ path = ShareExtension; sourceTree = ""; }; - A6844C652F780C9C00BBF6E5 /* EhPandaTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = EhPandaTests; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -136,29 +105,7 @@ ABC3C7512593696C00E0C11B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( - AB2EB99F280251D600011A8A /* TTProgressHUD in Frameworks */, - AB2EB9A52802521700011A8A /* DeprecatedAPI in Frameworks */, - AB17574027678B3400FD64E2 /* UIImageColors in Frameworks */, - AB2EB9A2280251F600011A8A /* AlertKit in Frameworks */, - ABD7005926B1C31500DC59C9 /* Kanna in Frameworks */, - AB60D0E9274C7ECE00F899AB /* WaterfallGrid in Frameworks */, - ABC4A0792751B40E00968A4F /* Kingfisher in Frameworks */, - EAE63E2129E2A6330048C601 /* SwiftyBeaver in Frameworks */, - AB26F59927ACDB4200AB3468 /* FilePicker in Frameworks */, - AB6505A026B0027800F91E9D /* SwiftUIPager in Frameworks */, - ABD49D5D277C6C9D003D1A07 /* SFSafeSymbols in Frameworks */, - ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */, - AB86AC1027831AD100E61E6A /* ComposableArchitecture in Frameworks */, - 82E6B0052F185A0000D1F93A /* SDWebImageWebPCoder in Frameworks */, - 82E6B0082F185A0000D1F93A /* SDWebImageSwiftUI in Frameworks */, - ABBB2636278FB888007B6149 /* SwiftUINavigation in Frameworks */, - AB1FA94927C62BC80063EF55 /* CommonMark in Frameworks */, - AB17573D27675B1E00FD64E2 /* Colorful in Frameworks */, - ); - }; - ABF294C926D20F82004DD03A /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - files = ( + A0F00000000000000000F001 /* AppFeature in Frameworks */, ); }; /* End PBXFrameworksBuildPhase section */ @@ -174,9 +121,8 @@ ABC3C74B2593696C00E0C11B = { isa = PBXGroup; children = ( - A6844B3D2F780C8600BBF6E5 /* EhPanda */, + A6844B3D2F780C8600BBF6E5 /* App */, A6844C272F780C8B00BBF6E5 /* ShareExtension */, - A6844C652F780C9C00BBF6E5 /* EhPandaTests */, EA0C92472C3EB44300D211F6 /* Config */, EA0C92422C3EB40100D211F6 /* GitHub */, EA0C92522C3EB47F00D211F6 /* READMEs */, @@ -190,7 +136,6 @@ children = ( ABC3C7542593696C00E0C11B /* EhPanda.app */, AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */, - ABF294CC26D20F82004DD03A /* EhPandaTests.xctest */, ); name = Products; sourceTree = ""; @@ -253,7 +198,6 @@ isa = PBXNativeTarget; buildConfigurationList = ABC3C7632593696E00E0C11B /* Build configuration list for PBXNativeTarget "EhPanda" */; buildPhases = ( - AB2E936227A24E0A00EA99F1 /* SwiftGen */, ABC3C7502593696C00E0C11B /* Sources */, ABC3C7512593696C00E0C11B /* Frameworks */, ABC3C7522593696C00E0C11B /* Resources */, @@ -266,55 +210,16 @@ AB5BE67F26B95FDD007D4A55 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - A6844B3D2F780C8600BBF6E5 /* EhPanda */, + A6844B3D2F780C8600BBF6E5 /* App */, ); name = EhPanda; packageProductDependencies = ( - AB65059F26B0027800F91E9D /* SwiftUIPager */, - ABD7005826B1C31500DC59C9 /* Kanna */, - ABAC82FD26BC4A96009F5026 /* OpenCC */, - AB60D0E8274C7ECE00F899AB /* WaterfallGrid */, - ABC4A0782751B40E00968A4F /* Kingfisher */, - AB17573C27675B1E00FD64E2 /* Colorful */, - AB17573F27678B3400FD64E2 /* UIImageColors */, - ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */, - AB86AC0F27831AD100E61E6A /* ComposableArchitecture */, - ABBB2635278FB888007B6149 /* SwiftUINavigation */, - AB26F59827ACDB4200AB3468 /* FilePicker */, - AB1FA94827C62BC80063EF55 /* CommonMark */, - AB2EB99E280251D600011A8A /* TTProgressHUD */, - AB2EB9A1280251F600011A8A /* AlertKit */, - AB2EB9A42802521700011A8A /* DeprecatedAPI */, - EAE63E2029E2A6330048C601 /* SwiftyBeaver */, - 82E6B0042F185A0000D1F93A /* SDWebImageWebPCoder */, - 82E6B0072F185A0000D1F93A /* SDWebImageSwiftUI */, + A0F00000000000000000F002 /* AppFeature */, ); productName = EhPanda; productReference = ABC3C7542593696C00E0C11B /* EhPanda.app */; productType = "com.apple.product-type.application"; }; - ABF294CB26D20F82004DD03A /* EhPandaTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = ABF294D426D20F82004DD03A /* Build configuration list for PBXNativeTarget "EhPandaTests" */; - buildPhases = ( - ABF294C826D20F82004DD03A /* Sources */, - ABF294C926D20F82004DD03A /* Frameworks */, - ABF294CA26D20F82004DD03A /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - A66A76712F77C89600FC07B8 /* PBXTargetDependency */, - ABF294D126D20F82004DD03A /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - A6844C652F780C9C00BBF6E5 /* EhPandaTests */, - ); - name = EhPandaTests; - productName = EhPandaTests; - productReference = ABF294CC26D20F82004DD03A /* EhPandaTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -331,10 +236,6 @@ ABC3C7532593696C00E0C11B = { CreatedOnToolsVersion = 12.2; }; - ABF294CB26D20F82004DD03A = { - CreatedOnToolsVersion = 13.0; - TestTargetID = ABC3C7532593696C00E0C11B; - }; }; }; buildConfigurationList = ABC3C74F2593696C00E0C11B /* Build configuration list for PBXProject "EhPanda" */; @@ -353,24 +254,7 @@ ); mainGroup = ABC3C74B2593696C00E0C11B; packageReferences = ( - AB65059E26B0027800F91E9D /* XCRemoteSwiftPackageReference "SwiftUIPager" */, - ABD7005726B1C31500DC59C9 /* XCRemoteSwiftPackageReference "Kanna" */, - ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */, - AB60D0E7274C7ECE00F899AB /* XCRemoteSwiftPackageReference "WaterfallGrid" */, - ABC4A0772751B40E00968A4F /* XCRemoteSwiftPackageReference "Kingfisher" */, - AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */, - AB17573E27678B3400FD64E2 /* XCRemoteSwiftPackageReference "UIImageColors" */, - ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, - AB86AC0E27831AD100E61E6A /* XCRemoteSwiftPackageReference "swift-composable-architecture" */, - ABBB2634278FB888007B6149 /* XCRemoteSwiftPackageReference "swift-navigation" */, - AB26F59727ACDB4200AB3468 /* XCRemoteSwiftPackageReference "FilePicker" */, - AB1FA94727C62BC80063EF55 /* XCRemoteSwiftPackageReference "SwiftCommonMark" */, - AB2EB99D280251D600011A8A /* XCRemoteSwiftPackageReference "TTProgressHUD" */, - AB2EB9A0280251F600011A8A /* XCRemoteSwiftPackageReference "AlertKit" */, - AB2EB9A32802521700011A8A /* XCRemoteSwiftPackageReference "DeprecatedAPI" */, - EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */, - 82E6B0032F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */, - 82E6B0062F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, + A0F00000000000000000F003 /* XCLocalSwiftPackageReference "AppPackage" */, A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */, ); preferredProjectObjectVersion = 100; @@ -380,7 +264,6 @@ targets = ( ABC3C7532593696C00E0C11B /* EhPanda */, AB5BE67526B95FDD007D4A55 /* ShareExtension */, - ABF294CB26D20F82004DD03A /* EhPandaTests */, ); }; /* End PBXProject section */ @@ -402,37 +285,9 @@ EA0C925E2C3EB49500D211F6 /* README.jpn.md in Resources */, ); }; - ABF294CA26D20F82004DD03A /* Resources */ = { - isa = PBXResourcesBuildPhase; - files = ( - ); - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - AB2E936227A24E0A00EA99F1 /* SwiftGen */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - name = SwiftGen; - outputPaths = ( - $SRCROOT/EhPanda/App/Generated/Strings.swift, - ); - shellPath = /bin/sh; - shellScript = ( - "if test -d \"/opt/homebrew/bin/\"; then", - " PATH=\"/opt/homebrew/bin/:${PATH}\"", - "fi", - "", - "export PATH", - "", - "if which swiftgen >/dev/null; then", - " swiftgen", - "else", - " echo \"warning: SwiftGen not installed, download from https://github.com/SwiftGen/SwiftGen\"", - "fi", - "", - ); - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -446,11 +301,6 @@ files = ( ); }; - ABF294C826D20F82004DD03A /* Sources */ = { - isa = PBXSourcesBuildPhase; - files = ( - ); - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -462,20 +312,11 @@ isa = PBXTargetDependency; productRef = A66A766E2F77C89100FC07B8 /* SwiftLintBuildToolPlugin */; }; - A66A76712F77C89600FC07B8 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - productRef = A66A76702F77C89600FC07B8 /* SwiftLintBuildToolPlugin */; - }; AB5BE67F26B95FDD007D4A55 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = AB5BE67526B95FDD007D4A55 /* ShareExtension */; targetProxy = AB5BE67E26B95FDD007D4A55 /* PBXContainerItemProxy */; }; - ABF294D126D20F82004DD03A /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = ABC3C7532593696C00E0C11B /* EhPanda */; - targetProxy = ABF294D026D20F82004DD03A /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -666,13 +507,13 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; + CODE_SIGN_ENTITLEMENTS = App/EhPanda.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 158; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_PREVIEWS = YES; - INFOPLIST_FILE = EhPanda/App/Info.plist; + INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -696,13 +537,13 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; + CODE_SIGN_ENTITLEMENTS = App/EhPanda.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 158; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_PREVIEWS = YES; - INFOPLIST_FILE = EhPanda/App/Info.plist; + INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -721,62 +562,6 @@ }; name = Release; }; - ABF294D226D20F82004DD03A /* Debug configuration for PBXNativeTarget "EhPandaTests" */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 157; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.tests; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EhPanda.app/EhPanda"; - }; - name = Debug; - }; - ABF294D326D20F82004DD03A /* Release configuration for PBXNativeTarget "EhPandaTests" */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 157; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.tests; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EhPanda.app/EhPanda"; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -804,33 +589,16 @@ ); defaultConfigurationName = Release; }; - ABF294D426D20F82004DD03A /* Build configuration list for PBXNativeTarget "EhPandaTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - ABF294D226D20F82004DD03A /* Debug configuration for PBXNativeTarget "EhPandaTests" */, - ABF294D326D20F82004DD03A /* Release configuration for PBXNativeTarget "EhPandaTests" */, - ); - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 82E6B0032F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SDWebImage/SDWebImageWebPCoder"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.14.6; - }; - }; - 82E6B0062F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SDWebImage/SDWebImageSwiftUI"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 3.0.0; - }; +/* Begin XCLocalSwiftPackageReference section */ + A0F00000000000000000F003 /* XCLocalSwiftPackageReference "AppPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = AppPackage; }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SimplyDanny/SwiftLintPlugins"; @@ -839,150 +607,13 @@ minimumVersion = 0.0.0; }; }; - AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Co2333/Colorful"; - requirement = { - kind = exactVersion; - version = 1.0.1; - }; - }; - AB17573E27678B3400FD64E2 /* XCRemoteSwiftPackageReference "UIImageColors" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/jathu/UIImageColors"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; - }; - AB1FA94727C62BC80063EF55 /* XCRemoteSwiftPackageReference "SwiftCommonMark" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/gonzalezreal/SwiftCommonMark"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; - AB26F59727ACDB4200AB3468 /* XCRemoteSwiftPackageReference "FilePicker" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/markrenaud/FilePicker"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; - AB2EB99D280251D600011A8A /* XCRemoteSwiftPackageReference "TTProgressHUD" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/EhPanda-Team/TTProgressHUD"; - requirement = { - branch = custom; - kind = branch; - }; - }; - AB2EB9A0280251F600011A8A /* XCRemoteSwiftPackageReference "AlertKit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/EhPanda-Team/AlertKit"; - requirement = { - branch = custom; - kind = branch; - }; - }; - AB2EB9A32802521700011A8A /* XCRemoteSwiftPackageReference "DeprecatedAPI" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/EhPanda-Team/DeprecatedAPI"; - requirement = { - branch = main; - kind = branch; - }; - }; - AB60D0E7274C7ECE00F899AB /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/paololeonardi/WaterfallGrid"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; - AB65059E26B0027800F91E9D /* XCRemoteSwiftPackageReference "SwiftUIPager" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/fermoya/SwiftUIPager"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; - }; - AB86AC0E27831AD100E61E6A /* XCRemoteSwiftPackageReference "swift-composable-architecture" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.25.0; - }; - traits = ( - ComposableArchitecture2DeprecationOverloads, - ComposableArchitecture2Deprecations, - ); - }; - ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/ddddxxx/SwiftyOpenCC"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = "2.0.0-beta"; - }; - }; - ABBB2634278FB888007B6149 /* XCRemoteSwiftPackageReference "swift-navigation" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-navigation"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; - }; - ABC4A0772751B40E00968A4F /* XCRemoteSwiftPackageReference "Kingfisher" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/onevcat/Kingfisher"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 8.0.0; - }; - }; - ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 7.0.0; - }; - }; - ABD7005726B1C31500DC59C9 /* XCRemoteSwiftPackageReference "Kanna" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/tid-kijyun/Kanna"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 6.0.0; - }; - }; - EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SwiftyBeaver/SwiftyBeaver"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 82E6B0042F185A0000D1F93A /* SDWebImageWebPCoder */ = { - isa = XCSwiftPackageProductDependency; - package = 82E6B0032F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */; - productName = SDWebImageWebPCoder; - }; - 82E6B0072F185A0000D1F93A /* SDWebImageSwiftUI */ = { + A0F00000000000000000F002 /* AppFeature */ = { isa = XCSwiftPackageProductDependency; - package = 82E6B0062F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; - productName = SDWebImageSwiftUI; + package = A0F00000000000000000F003 /* XCLocalSwiftPackageReference "AppPackage" */; + productName = AppFeature; }; A66A766C2F77C88A00FC07B8 /* SwiftLintBuildToolPlugin */ = { isa = XCSwiftPackageProductDependency; @@ -994,91 +625,6 @@ package = A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; productName = "plugin:SwiftLintBuildToolPlugin"; }; - A66A76702F77C89600FC07B8 /* SwiftLintBuildToolPlugin */ = { - isa = XCSwiftPackageProductDependency; - package = A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; - productName = "plugin:SwiftLintBuildToolPlugin"; - }; - AB17573C27675B1E00FD64E2 /* Colorful */ = { - isa = XCSwiftPackageProductDependency; - package = AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */; - productName = Colorful; - }; - AB17573F27678B3400FD64E2 /* UIImageColors */ = { - isa = XCSwiftPackageProductDependency; - package = AB17573E27678B3400FD64E2 /* XCRemoteSwiftPackageReference "UIImageColors" */; - productName = UIImageColors; - }; - AB1FA94827C62BC80063EF55 /* CommonMark */ = { - isa = XCSwiftPackageProductDependency; - package = AB1FA94727C62BC80063EF55 /* XCRemoteSwiftPackageReference "SwiftCommonMark" */; - productName = CommonMark; - }; - AB26F59827ACDB4200AB3468 /* FilePicker */ = { - isa = XCSwiftPackageProductDependency; - package = AB26F59727ACDB4200AB3468 /* XCRemoteSwiftPackageReference "FilePicker" */; - productName = FilePicker; - }; - AB2EB99E280251D600011A8A /* TTProgressHUD */ = { - isa = XCSwiftPackageProductDependency; - package = AB2EB99D280251D600011A8A /* XCRemoteSwiftPackageReference "TTProgressHUD" */; - productName = TTProgressHUD; - }; - AB2EB9A1280251F600011A8A /* AlertKit */ = { - isa = XCSwiftPackageProductDependency; - package = AB2EB9A0280251F600011A8A /* XCRemoteSwiftPackageReference "AlertKit" */; - productName = AlertKit; - }; - AB2EB9A42802521700011A8A /* DeprecatedAPI */ = { - isa = XCSwiftPackageProductDependency; - package = AB2EB9A32802521700011A8A /* XCRemoteSwiftPackageReference "DeprecatedAPI" */; - productName = DeprecatedAPI; - }; - AB60D0E8274C7ECE00F899AB /* WaterfallGrid */ = { - isa = XCSwiftPackageProductDependency; - package = AB60D0E7274C7ECE00F899AB /* XCRemoteSwiftPackageReference "WaterfallGrid" */; - productName = WaterfallGrid; - }; - AB65059F26B0027800F91E9D /* SwiftUIPager */ = { - isa = XCSwiftPackageProductDependency; - package = AB65059E26B0027800F91E9D /* XCRemoteSwiftPackageReference "SwiftUIPager" */; - productName = SwiftUIPager; - }; - AB86AC0F27831AD100E61E6A /* ComposableArchitecture */ = { - isa = XCSwiftPackageProductDependency; - package = AB86AC0E27831AD100E61E6A /* XCRemoteSwiftPackageReference "swift-composable-architecture" */; - productName = ComposableArchitecture; - }; - ABAC82FD26BC4A96009F5026 /* OpenCC */ = { - isa = XCSwiftPackageProductDependency; - package = ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */; - productName = OpenCC; - }; - ABBB2635278FB888007B6149 /* SwiftUINavigation */ = { - isa = XCSwiftPackageProductDependency; - package = ABBB2634278FB888007B6149 /* XCRemoteSwiftPackageReference "swift-navigation" */; - productName = SwiftUINavigation; - }; - ABC4A0782751B40E00968A4F /* Kingfisher */ = { - isa = XCSwiftPackageProductDependency; - package = ABC4A0772751B40E00968A4F /* XCRemoteSwiftPackageReference "Kingfisher" */; - productName = Kingfisher; - }; - ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */ = { - isa = XCSwiftPackageProductDependency; - package = ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; - productName = SFSafeSymbols; - }; - ABD7005826B1C31500DC59C9 /* Kanna */ = { - isa = XCSwiftPackageProductDependency; - package = ABD7005726B1C31500DC59C9 /* XCRemoteSwiftPackageReference "Kanna" */; - productName = Kanna; - }; - EAE63E2029E2A6330048C601 /* SwiftyBeaver */ = { - isa = XCSwiftPackageProductDependency; - package = EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */; - productName = SwiftyBeaver; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = ABC3C74C2593696C00E0C11B /* Project object */; diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9021f5014..43dd5856e 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e701a6d79a25f06dac1d0b3156dba1f0ad620d066aae2bdd39ab0ef6f21b8391", + "originHash" : "0e56f677033694d39621023de83f46972d677fe0bbda56c25c4cc4d92f90b765", "pins" : [ { "identity" : "alertkit", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6", - "version" : "1.1.0" + "revision" : "dcccb979a2183b8df3334237e3dc1ae2b4116a86", + "version" : "1.2.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "c152c1915f60c51e4afa0752656993ee5b3c63db", - "version" : "8.8.1" + "revision" : "ac632bd26a1c00f139ff62fd01806f21cf67325e", + "version" : "8.10.0" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "206cbce3882b4de9aee19ce62ac5b7306cadd45b", - "version" : "1.7.3" + "revision" : "1197e80bc7e4b177051b6869ef93d8ac3ad677da", + "version" : "1.8.0" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", - "version" : "1.0.6" + "revision" : "72d749bf341b78851203066ab421869b783ec42a", + "version" : "1.1.0" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", - "version" : "1.4.1" + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "1eaa6fa2ee57ac42843283b9fd3457af408c858d", - "version" : "1.25.5" + "revision" : "e2fa1df6cd9eec6fa6314aa20513e47da576f24e", + "version" : "1.26.0" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" + "revision" : "a90e2e40a7a840a853dd29e57cbef5dbb72c9d5b", + "version" : "1.4.0" } }, { @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "06c57924455064182d6b217f06ebc05d00cb2990", - "version" : "1.5.0" + "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", + "version" : "1.6.1" } }, { @@ -168,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "706feb7858a7f6c242879d137b8ee30926aa5b26", - "version" : "1.12.0" + "revision" : "8dc1fbf2f6255a73dec53b4648164884898db4c5", + "version" : "1.14.1" } }, { @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248", - "version" : "2.8.0" + "revision" : "6cff006b3607b700029dc44f06b41a6cc7384ac7", + "version" : "2.10.2" } }, { @@ -204,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "bc27f8322bc30f6ce7d864d137dc77a6de8b57eb", - "version" : "2.8.0" + "revision" : "c8dd3627eb92cef1bcb44ac5a93d558ab32b82ce", + "version" : "2.9.0" } }, { @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "2b59c0c741e9184ab057fd22950b491076d42e91", - "version" : "603.0.0" + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" } }, { @@ -294,8 +294,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad", - "version" : "1.9.0" + "revision" : "401bf70d95bfe8db2a1dc619f9e175a85c089321", + "version" : "1.10.1" } } ], diff --git a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme index fc7c6b173..ba2fd7137 100644 --- a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme +++ b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme @@ -38,16 +38,6 @@ - - - - Bool { - currentPoint = touch.location(in: touch.window) - return false - } -} -private extension EhPandaApp { - func addTouchHandler() { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - let tapGesture = UITapGestureRecognizer( - target: self, action: nil - ) - tapGesture.delegate = TouchHandler.shared - DeviceUtil.keyWindow?.addGestureRecognizer(tapGesture) - } - } -} diff --git a/swiftgen.yml b/swiftgen.yml index f8c582f83..1709fbf7d 100644 --- a/swiftgen.yml +++ b/swiftgen.yml @@ -1,7 +1,7 @@ -output_dir: EhPanda/App/Generated +output_dir: AppPackage/Sources/AppFeature/Generated strings: - inputs: EhPanda/App/en.lproj + inputs: AppPackage/Sources/AppFeature/Resources/en.lproj outputs: - templateName: structured-swift5 output: Strings.swift From de59e319d7da4d5dbe9ceed995ec4f59e1c36b0e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 16:07:28 +0800 Subject: [PATCH 306/614] Move localized strings into Resources module Promote the placeholder Resources module to the real owner of the app's localized strings, so feature modules extracted later can reach them without depending on AppFeature. - git mv every *.lproj (Localizable.strings + en's Constant.strings) from AppFeature/Resources into Resources/Resources, processed by the Resources target (resources: [.process(.resources)]). - Regenerate SwiftGen Strings.swift into the Resources module with publicAccess, exposing L10n publicly; Bundle.module now resolves to the Resources bundle that carries the .strings tables. - Point swiftgen.yml at the new input/output paths and enable publicAccess. - Add `import Resources` to every AppFeature file and test that uses L10n. - Remove the Resources placeholder and the empty AppFeature/Generated dir. The Core Data model and mapping model stay in AppFeature (their MO classes live there and representedClassName is AppFeature-qualified). Package builds, app builds, all 286 tests pass; only the sanctioned SwiftUINavigation iOS-16 deprecation warning remains. --- AppPackage/Package.swift | 1 + .../AppFeature/DataFlow/AppLockReducer.swift | 1 + .../AppFeature/Generated/Strings.swift | 2602 ----------------- .../Download/DownloadFolderFilter.swift | 2 + .../AppFeature/Models/Gallery/Category.swift | 1 + .../Models/Gallery/GalleryArchive.swift | 1 + .../Models/Gallery/GalleryDetail.swift | 1 + .../AppFeature/Models/Gallery/Language.swift | 2 + .../Models/Persistent/Greeting.swift | 1 + .../Models/Persistent/Setting.swift | 1 + .../AppFeature/Models/Persistent/User.swift | 1 + .../AppFeature/Models/Support/AppError.swift | 1 + .../Models/Support/BrowsingCountry.swift | 1 + .../Models/Support/EhSetting+Enums.swift | 2 + .../Models/Support/EhSetting+Extensions.swift | 2 + .../AppFeature/Models/Support/EhSetting.swift | 2 + .../AppFeature/Models/Tags/TagNamespace.swift | 2 + .../Tools/Clients/CookieClient.swift | 1 + .../Clients/DownloadClient+Folders.swift | 1 + .../Clients/DownloadClient+PublicAPI.swift | 1 + .../Tools/Extensions/AlertKit_Extension.swift | 1 + .../Extensions/TTProgressHUD_Extension.swift | 1 + .../Tools/Parser/Parser+ResponseError.swift | 1 + .../Utilities/DownloadStore+Operations.swift | 1 + .../Tools/Utilities/DownloadStore.swift | 1 + .../Detail/Archives/ArchivesReducer.swift | 1 + .../View/Detail/Archives/ArchivesView.swift | 1 + .../View/Detail/Comments/CommentsView.swift | 1 + .../Detail/Components/TagDetailView.swift | 1 + .../View/Detail/DetailView+CommentCells.swift | 1 + .../Detail/DetailView+HeaderSection.swift | 1 + .../View/Detail/DetailView+Navigation.swift | 1 + .../View/Detail/DetailView+Subviews.swift | 1 + .../AppFeature/View/Detail/DetailView.swift | 1 + .../GalleryInfos/GalleryInfosView.swift | 1 + .../View/Detail/Previews/PreviewsView.swift | 1 + .../View/Detail/Torrents/TorrentsView.swift | 1 + .../Downloads/DownloadInspectorReducer.swift | 1 + .../Downloads/DownloadsView+Subviews.swift | 1 + .../View/Downloads/DownloadsView.swift | 1 + .../View/Downloads/FolderManagerReducer.swift | 1 + .../View/Downloads/FolderManagerView.swift | 1 + .../View/Favorites/FavoritesView.swift | 1 + .../View/Home/Frontpage/FrontpageView.swift | 1 + .../View/Home/History/HistoryView.swift | 1 + .../View/Home/HomeView+Sections.swift | 1 + .../AppFeature/View/Home/HomeView.swift | 1 + .../View/Home/Popular/PopularView.swift | 1 + .../View/Home/Toplists/ToplistsView.swift | 1 + .../View/Home/Watched/WatchedView.swift | 1 + .../View/Migration/MigrationView.swift | 1 + .../View/Reading/ReadingViewComponents.swift | 1 + .../View/Reading/Support/ControlPanel.swift | 1 + .../View/Search/SearchRootView.swift | 1 + .../View/Search/Support/QuickSearchView.swift | 1 + .../AccountSetting/AccountSettingView.swift | 1 + .../AppearanceSettingView.swift | 1 + .../View/Setting/Components/AboutView.swift | 1 + .../Components/DownloadSettingView.swift | 1 + .../Components/LaboratorySettingView.swift | 1 + .../Components/ReadingSettingView.swift | 1 + .../EhSetting/EhSettingView+Sections1.swift | 1 + .../EhSetting/EhSettingView+Sections2.swift | 1 + .../EhSetting/EhSettingView+Sections3.swift | 1 + .../Setting/EhSetting/EhSettingView.swift | 1 + .../GeneralSetting/GeneralSettingView.swift | 1 + .../View/Setting/Login/LoginView.swift | 1 + .../View/Setting/Logs/LogsView.swift | 1 + .../AppFeature/View/Setting/SettingView.swift | 1 + .../View/Support/Components/AlertView.swift | 1 + .../Components/DateSeekPickerView.swift | 1 + .../Components/DownloadBadgeLabel.swift | 1 + .../View/Support/Components/SubSection.swift | 1 + .../Components/TagSuggestionView.swift | 1 + .../Support/Components/ToolbarItems.swift | 1 + .../AppFeature/View/Support/FiltersView.swift | 1 + .../AppFeature/View/Support/NewDawnView.swift | 1 + .../AppFeature/View/TabBar/TabBarView.swift | 1 + AppPackage/Sources/Resources/Resources.swift | 4 - .../Resources/de.lproj/Localizable.strings | 0 .../Resources/en.lproj/Constant.strings | 0 .../Resources/en.lproj/Localizable.strings | 0 .../Resources/ja.lproj/Localizable.strings | 0 .../Resources/ko.lproj/Localizable.strings | 0 .../zh-Hans.lproj/Localizable.strings | 0 .../zh-Hant-HK.lproj/Localizable.strings | 0 .../zh-Hant-TW.lproj/Localizable.strings | 0 .../zh-Hant.lproj/Localizable.strings | 0 AppPackage/Sources/Resources/Strings.swift | 2595 ++++++++++++++++ .../DownloadCoordinatorStorageTests.swift | 1 + .../Download/DownloadInspectorLoadTests.swift | 1 + .../Download/DownloadStoreHashTests.swift | 1 + .../Tests/Download/DownloadStoreTests.swift | 1 + swiftgen.yml | 6 +- 94 files changed, 2686 insertions(+), 2608 deletions(-) delete mode 100644 AppPackage/Sources/AppFeature/Generated/Strings.swift delete mode 100644 AppPackage/Sources/Resources/Resources.swift rename AppPackage/Sources/{AppFeature => Resources}/Resources/de.lproj/Localizable.strings (100%) rename AppPackage/Sources/{AppFeature => Resources}/Resources/en.lproj/Constant.strings (100%) rename AppPackage/Sources/{AppFeature => Resources}/Resources/en.lproj/Localizable.strings (100%) rename AppPackage/Sources/{AppFeature => Resources}/Resources/ja.lproj/Localizable.strings (100%) rename AppPackage/Sources/{AppFeature => Resources}/Resources/ko.lproj/Localizable.strings (100%) rename AppPackage/Sources/{AppFeature => Resources}/Resources/zh-Hans.lproj/Localizable.strings (100%) rename AppPackage/Sources/{AppFeature => Resources}/Resources/zh-Hant-HK.lproj/Localizable.strings (100%) rename AppPackage/Sources/{AppFeature => Resources}/Resources/zh-Hant-TW.lproj/Localizable.strings (100%) rename AppPackage/Sources/{AppFeature => Resources}/Resources/zh-Hant.lproj/Localizable.strings (100%) create mode 100644 AppPackage/Sources/Resources/Strings.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index c4d1e1846..b3a130a66 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -230,6 +230,7 @@ let targets: [PackageDescription.Target] = [ ), .target( module: .resources, + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift index 639f3ac2e..f0f9d3cba 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/Generated/Strings.swift b/AppPackage/Sources/AppFeature/Generated/Strings.swift deleted file mode 100644 index 397347f36..000000000 --- a/AppPackage/Sources/AppFeature/Generated/Strings.swift +++ /dev/null @@ -1,2602 +0,0 @@ -// swiftlint:disable all -// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen - -import Foundation - -// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references - -// MARK: - Strings - -// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length -// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces -internal enum L10n { - internal enum Constant { - internal enum App { - /// Copyright © 2025 EhPanda Team - internal static let copyright = L10n.tr("Constant", "app.copyright", fallback: "Copyright © 2025 EhPanda Team") - internal enum Acknowledgement { - internal enum Link { - /// https://github.com/rebeloper/AlertKit - internal static let alertKit = L10n.tr("Constant", "app.acknowledgement.link.alertKit", fallback: "https://github.com/rebeloper/AlertKit") - /// https://github.com/Co2333/Colorful - internal static let colorful = L10n.tr("Constant", "app.acknowledgement.link.colorful", fallback: "https://github.com/Co2333/Colorful") - /// https://github.com/EhTagTranslation/Database - internal static let ehTagTranslationDatabase = L10n.tr("Constant", "app.acknowledgement.link.ehTagTranslationDatabase", fallback: "https://github.com/EhTagTranslation/Database") - /// https://github.com/markrenaud/FilePicker - internal static let filePicker = L10n.tr("Constant", "app.acknowledgement.link.filePicker", fallback: "https://github.com/markrenaud/FilePicker") - /// https://github.com/tid-kijyun/Kanna - internal static let kanna = L10n.tr("Constant", "app.acknowledgement.link.kanna", fallback: "https://github.com/tid-kijyun/Kanna") - /// https://github.com/onevcat/Kingfisher - internal static let kingfisher = L10n.tr("Constant", "app.acknowledgement.link.kingfisher", fallback: "https://github.com/onevcat/Kingfisher") - /// https://github.com/SFSafeSymbols/SFSafeSymbols - internal static let sfSafeSymbols = L10n.tr("Constant", "app.acknowledgement.link.sfSafeSymbols", fallback: "https://github.com/SFSafeSymbols/SFSafeSymbols") - /// https://github.com/gonzalezreal/SwiftCommonMark - internal static let swiftCommonMark = L10n.tr("Constant", "app.acknowledgement.link.swiftCommonMark", fallback: "https://github.com/gonzalezreal/SwiftCommonMark") - /// https://github.com/SwiftGen/SwiftGen - internal static let swiftGen = L10n.tr("Constant", "app.acknowledgement.link.swiftGen", fallback: "https://github.com/SwiftGen/SwiftGen") - /// https://github.com/pointfreeco/swiftui-navigation - internal static let swiftUINavigation = L10n.tr("Constant", "app.acknowledgement.link.swiftUINavigation", fallback: "https://github.com/pointfreeco/swiftui-navigation") - /// https://github.com/fermoya/SwiftUIPager - internal static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.link.swiftUIPager", fallback: "https://github.com/fermoya/SwiftUIPager") - /// https://github.com/SwiftyBeaver/SwiftyBeaver - internal static let swiftyBeaver = L10n.tr("Constant", "app.acknowledgement.link.swiftyBeaver", fallback: "https://github.com/SwiftyBeaver/SwiftyBeaver") - /// https://github.com/ddddxxx/SwiftyOpenCC - internal static let swiftyOpenCC = L10n.tr("Constant", "app.acknowledgement.link.swiftyOpenCC", fallback: "https://github.com/ddddxxx/SwiftyOpenCC") - /// https://github.com/pointfreeco/swift-composable-architecture - internal static let tca = L10n.tr("Constant", "app.acknowledgement.link.tca", fallback: "https://github.com/pointfreeco/swift-composable-architecture") - /// https://github.com/honkmaster/TTProgressHUD - internal static let ttProgressHUD = L10n.tr("Constant", "app.acknowledgement.link.ttProgressHUD", fallback: "https://github.com/honkmaster/TTProgressHUD") - /// https://github.com/jathu/UIImageColors - internal static let uiImageColors = L10n.tr("Constant", "app.acknowledgement.link.uiImageColors", fallback: "https://github.com/jathu/UIImageColors") - /// https://github.com/paololeonardi/WaterfallGrid - internal static let waterfallGrid = L10n.tr("Constant", "app.acknowledgement.link.waterfallGrid", fallback: "https://github.com/paololeonardi/WaterfallGrid") - } - internal enum Text { - /// AlertKit - internal static let alertKit = L10n.tr("Constant", "app.acknowledgement.text.alertKit", fallback: "AlertKit") - /// Colorful - internal static let colorful = L10n.tr("Constant", "app.acknowledgement.text.colorful", fallback: "Colorful") - /// EhTagTranslation/Database - internal static let ehTagTranslationDatabase = L10n.tr("Constant", "app.acknowledgement.text.ehTagTranslationDatabase", fallback: "EhTagTranslation/Database") - /// FilePicker - internal static let filePicker = L10n.tr("Constant", "app.acknowledgement.text.filePicker", fallback: "FilePicker") - /// Kanna - internal static let kanna = L10n.tr("Constant", "app.acknowledgement.text.kanna", fallback: "Kanna") - /// Kingfisher - internal static let kingfisher = L10n.tr("Constant", "app.acknowledgement.text.kingfisher", fallback: "Kingfisher") - /// SFSafeSymbols - internal static let sfSafeSymbols = L10n.tr("Constant", "app.acknowledgement.text.sfSafeSymbols", fallback: "SFSafeSymbols") - /// SwiftCommonMark - internal static let swiftCommonMark = L10n.tr("Constant", "app.acknowledgement.text.swiftCommonMark", fallback: "SwiftCommonMark") - /// SwiftGen - internal static let swiftGen = L10n.tr("Constant", "app.acknowledgement.text.swiftGen", fallback: "SwiftGen") - /// SwiftUI Navigation - internal static let swiftUINavigation = L10n.tr("Constant", "app.acknowledgement.text.swiftUINavigation", fallback: "SwiftUI Navigation") - /// SwiftUIPager - internal static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.text.swiftUIPager", fallback: "SwiftUIPager") - /// SwiftyBeaver - internal static let swiftyBeaver = L10n.tr("Constant", "app.acknowledgement.text.swiftyBeaver", fallback: "SwiftyBeaver") - /// SwiftyOpenCC - internal static let swiftyOpenCC = L10n.tr("Constant", "app.acknowledgement.text.swiftyOpenCC", fallback: "SwiftyOpenCC") - /// The Composable Architecture - internal static let tca = L10n.tr("Constant", "app.acknowledgement.text.tca", fallback: "The Composable Architecture") - /// TTProgressHUD - internal static let ttProgressHUD = L10n.tr("Constant", "app.acknowledgement.text.ttProgressHUD", fallback: "TTProgressHUD") - /// UIImageColors - internal static let uiImageColors = L10n.tr("Constant", "app.acknowledgement.text.uiImageColors", fallback: "UIImageColors") - /// WaterfallGrid - internal static let waterfallGrid = L10n.tr("Constant", "app.acknowledgement.text.waterfallGrid", fallback: "WaterfallGrid") - } - } - internal enum CodeLevelContributor { - internal enum Link { - /// https://github.com/aalberrty - internal static let aalberrty = L10n.tr("Constant", "app.code_level_contributor.link.aalberrty", fallback: "https://github.com/aalberrty") - /// https://github.com/chihchy - internal static let chihchy = L10n.tr("Constant", "app.code_level_contributor.link.chihchy", fallback: "https://github.com/chihchy") - /// https://github.com/Jimmy-Prime - internal static let jimmyPrime = L10n.tr("Constant", "app.code_level_contributor.link.Jimmy-Prime", fallback: "https://github.com/Jimmy-Prime") - /// https://github.com/vvbbnn00 - internal static let vvbbnn00 = L10n.tr("Constant", "app.code_level_contributor.link.vvbbnn00", fallback: "https://github.com/vvbbnn00") - /// https://github.com/xioxin - internal static let xioxin = L10n.tr("Constant", "app.code_level_contributor.link.xioxin", fallback: "https://github.com/xioxin") - } - internal enum Text { - /// Zack Asahina - internal static let aalberrty = L10n.tr("Constant", "app.code_level_contributor.text.aalberrty", fallback: "Zack Asahina") - /// Chihchy - internal static let chihchy = L10n.tr("Constant", "app.code_level_contributor.text.chihchy", fallback: "Chihchy") - /// Jimmy Prime - internal static let jimmyPrime = L10n.tr("Constant", "app.code_level_contributor.text.Jimmy-Prime", fallback: "Jimmy Prime") - /// vvbbnn00 - internal static let vvbbnn00 = L10n.tr("Constant", "app.code_level_contributor.text.vvbbnn00", fallback: "vvbbnn00") - /// xioxin - internal static let xioxin = L10n.tr("Constant", "app.code_level_contributor.text.xioxin", fallback: "xioxin") - } - } - internal enum Contact { - internal enum Link { - /// altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json - internal static let altStore = L10n.tr("Constant", "app.contact.link.altStore", fallback: "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json") - /// https://discord.gg/BSBE9FCBTq - internal static let discord = L10n.tr("Constant", "app.contact.link.discord", fallback: "https://discord.gg/BSBE9FCBTq") - /// https://github.com/EhPanda-Team/EhPanda - internal static let gitHub = L10n.tr("Constant", "app.contact.link.gitHub", fallback: "https://github.com/EhPanda-Team/EhPanda") - /// https://t.me/ehpanda - internal static let telegram = L10n.tr("Constant", "app.contact.link.telegram", fallback: "https://t.me/ehpanda") - /// https://ehpanda.app - internal static let website = L10n.tr("Constant", "app.contact.link.website", fallback: "https://ehpanda.app") - } - internal enum Text { - /// Discord - internal static let discord = L10n.tr("Constant", "app.contact.text.discord", fallback: "Discord") - /// GitHub - internal static let gitHub = L10n.tr("Constant", "app.contact.text.gitHub", fallback: "GitHub") - /// Telegram - internal static let telegram = L10n.tr("Constant", "app.contact.text.telegram", fallback: "Telegram") - } - } - internal enum SpecialThanks { - internal enum Link { - /// https://github.com/caxerx - internal static let caxerx = L10n.tr("Constant", "app.special_thanks.link.caxerx", fallback: "https://github.com/caxerx") - /// https://github.com/honjow - internal static let honjow = L10n.tr("Constant", "app.special_thanks.link.honjow", fallback: "https://github.com/honjow") - /// - internal static let luminescentYq = L10n.tr("Constant", "app.special_thanks.link.luminescent_yq", fallback: "") - /// https://github.com/taylorlannister - internal static let taylorlannister = L10n.tr("Constant", "app.special_thanks.link.taylorlannister", fallback: "https://github.com/taylorlannister") - } - internal enum Text { - /// caxerx - internal static let caxerx = L10n.tr("Constant", "app.special_thanks.text.caxerx", fallback: "caxerx") - /// honjow - internal static let honjow = L10n.tr("Constant", "app.special_thanks.text.honjow", fallback: "honjow") - /// Luminescent_yq - internal static let luminescentYq = L10n.tr("Constant", "app.special_thanks.text.luminescent_yq", fallback: "Luminescent_yq") - /// taylorlannister - internal static let taylorlannister = L10n.tr("Constant", "app.special_thanks.text.taylorlannister", fallback: "taylorlannister") - } - } - internal enum TranslationContributor { - internal enum Link { - /// https://github.com/caxerx - internal static let caxerx = L10n.tr("Constant", "app.translation_contributor.link.caxerx", fallback: "https://github.com/caxerx") - /// https://github.com/Nebulosa-Cat - internal static let nebulosaCat = L10n.tr("Constant", "app.translation_contributor.link.nebulosa-cat", fallback: "https://github.com/Nebulosa-Cat") - /// https://github.com/NeKoOuO - internal static let neKoOuO = L10n.tr("Constant", "app.translation_contributor.link.NeKoOuO", fallback: "https://github.com/NeKoOuO") - /// https://github.com/PaulHaeussler - internal static let paulHaeussler = L10n.tr("Constant", "app.translation_contributor.link.paulHaeussler", fallback: "https://github.com/PaulHaeussler") - } - internal enum Text { - /// caxerx - internal static let caxerx = L10n.tr("Constant", "app.translation_contributor.text.caxerx", fallback: "caxerx") - /// 雲豹 ΦωΦ - internal static let nebulosaCat = L10n.tr("Constant", "app.translation_contributor.text.nebulosa-cat", fallback: "雲豹 ΦωΦ") - /// ɴᴇᴋᴏ - internal static let neKoOuO = L10n.tr("Constant", "app.translation_contributor.text.NeKoOuO", fallback: "ɴᴇᴋᴏ") - /// PaulHaeussler - internal static let paulHaeussler = L10n.tr("Constant", "app.translation_contributor.text.paulHaeussler", fallback: "PaulHaeussler") - } - } - } - internal enum Website { - internal enum Response { - /// This gallery has been removed or is unavailable. - internal static let galleryUnavailable = L10n.tr("Constant", "website.response.gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") - /// Constant.strings - /// EhPanda - internal static let hathClientNotFound = L10n.tr("Constant", "website.response.hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") - /// Your H@H client appears to be offline. Turn it on, then try again. - internal static let hathClientNotOnline = L10n.tr("Constant", "website.response.hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") - /// The requested gallery cannot be downloaded with the selected resolution. - internal static let invalidResolution = L10n.tr("Constant", "website.response.invalid_resolution", fallback: "The requested gallery cannot be downloaded with the selected resolution.") - } - } - } - internal enum InfoPlist { - /// InfoPlist.strings - /// EhPanda - internal static let nsFaceIDUsageDescription = L10n.tr("InfoPlist", "NSFaceIDUsageDescription", fallback: "We need this permission to provide Face ID option while unlocking the App.") - /// We need this permission to save images to your photo library. - internal static let nsPhotoLibraryAddUsageDescription = L10n.tr("InfoPlist", "NSPhotoLibraryAddUsageDescription", fallback: "We need this permission to save images to your photo library.") - } - internal enum Localizable { - internal enum AboutView { - internal enum Button { - /// AltStore source - internal static let altStoreSource = L10n.tr("Localizable", "about_view.button.altStore_source", fallback: "AltStore source") - /// Website - internal static let website = L10n.tr("Localizable", "about_view.button.website", fallback: "Website") - } - internal enum Section { - internal enum Title { - /// Acknowledgements - internal static let acknowledgements = L10n.tr("Localizable", "about_view.section.title.acknowledgements", fallback: "Acknowledgements") - /// Code-level contributors - internal static let codeLevelContributors = L10n.tr("Localizable", "about_view.section.title.code_level_contributors", fallback: "Code-level contributors") - /// Special thanks - internal static let specialThanks = L10n.tr("Localizable", "about_view.section.title.special_thanks", fallback: "Special thanks") - /// Translation contributors - internal static let translationContributors = L10n.tr("Localizable", "about_view.section.title.translation_contributors", fallback: "Translation contributors") - } - } - internal enum Title { - /// EhPanda - internal static let ehPanda = L10n.tr("Localizable", "about_view.title.ehPanda", fallback: "EhPanda") - /// Version - internal static let version = L10n.tr("Localizable", "about_view.title.version", fallback: "Version") - } - } - internal enum AccountSettingView { - internal enum Button { - /// Account configuration - internal static let accountConfiguration = L10n.tr("Localizable", "account_setting_view.button.account_configuration", fallback: "Account configuration") - /// Copy cookies - internal static let copyCookies = L10n.tr("Localizable", "account_setting_view.button.copy_cookies", fallback: "Copy cookies") - /// Login - internal static let login = L10n.tr("Localizable", "account_setting_view.button.login", fallback: "Login") - /// Logout - internal static let logout = L10n.tr("Localizable", "account_setting_view.button.logout", fallback: "Logout") - /// Manage tags subscription - internal static let tagsManagement = L10n.tr("Localizable", "account_setting_view.button.tags_management", fallback: "Manage tags subscription") - } - internal enum Title { - /// Account - internal static let account = L10n.tr("Localizable", "account_setting_view.title.account", fallback: "Account") - /// Shows new dawn greeting - internal static let showsNewDawnGreeting = L10n.tr("Localizable", "account_setting_view.title.shows_new_dawn_greeting", fallback: "Shows new dawn greeting") - } - } - internal enum AppError { - internal enum Alert { - /// Login required to access this download. - internal static let authenticationRequired = L10n.tr("Localizable", "app_error.alert.authentication_required", fallback: "Login required to access this download.") - /// Local file operation failed. - internal static let localFileOperationFailed = L10n.tr("Localizable", "app_error.alert.local_file_operation_failed", fallback: "Local file operation failed.") - /// Image quota exceeded. - /// Please wait and try again later. - internal static let quotaExceeded = L10n.tr("Localizable", "app_error.alert.quota_exceeded", fallback: "Image quota exceeded.\nPlease wait and try again later.") - } - internal enum LocalizedDescription { - /// Authentication Required - internal static let authenticationRequired = L10n.tr("Localizable", "app_error.localized_description.authentication_required", fallback: "Authentication Required") - /// Copyright Claim - internal static let copyrightClaim = L10n.tr("Localizable", "app_error.localized_description.copyright_claim", fallback: "Copyright Claim") - /// Database Corrupted - internal static let databaseCorrupted = L10n.tr("Localizable", "app_error.localized_description.database_corrupted", fallback: "Database Corrupted") - /// File Operation Failed - internal static let fileOperationFailed = L10n.tr("Localizable", "app_error.localized_description.file_operation_failed", fallback: "File Operation Failed") - /// Gallery Expunged - internal static let galleryExpunged = L10n.tr("Localizable", "app_error.localized_description.gallery_expunged", fallback: "Gallery Expunged") - /// IP Banned - internal static let ipBanned = L10n.tr("Localizable", "app_error.localized_description.ip_banned", fallback: "IP Banned") - /// Network Error - internal static let networkError = L10n.tr("Localizable", "app_error.localized_description.network_error", fallback: "Network Error") - /// No updates available - internal static let noUpdatesAvailable = L10n.tr("Localizable", "app_error.localized_description.no_updates_available", fallback: "No updates available") - /// Not found - internal static let notFound = L10n.tr("Localizable", "app_error.localized_description.not_found", fallback: "Not found") - /// Parse Error - internal static let parseError = L10n.tr("Localizable", "app_error.localized_description.parse_error", fallback: "Parse Error") - /// Quota Exceeded - internal static let quotaExceeded = L10n.tr("Localizable", "app_error.localized_description.quota_exceeded", fallback: "Quota Exceeded") - /// Unknown Error - internal static let unknownError = L10n.tr("Localizable", "app_error.localized_description.unknown_error", fallback: "Unknown Error") - /// Web image loading error - internal static let webImageLoadingError = L10n.tr("Localizable", "app_error.localized_description.web_image_loading_error", fallback: "Web image loading error") - } - } - internal enum AppIconView { - internal enum Title { - /// App icon - internal static let appIcon = L10n.tr("Localizable", "app_icon_view.title.app_icon", fallback: "App icon") - } - } - internal enum AppearanceSettingView { - internal enum Button { - /// App icon - internal static let appIcon = L10n.tr("Localizable", "appearance_setting_view.button.app_icon", fallback: "App icon") - } - internal enum Menu { - internal enum Title { - /// Infite - internal static let infite = L10n.tr("Localizable", "appearance_setting_view.menu.title.infite", fallback: "Infite") - } - } - internal enum Section { - internal enum Title { - /// Gallery - internal static let gallery = L10n.tr("Localizable", "appearance_setting_view.section.title.gallery", fallback: "Gallery") - /// List - internal static let list = L10n.tr("Localizable", "appearance_setting_view.section.title.list", fallback: "List") - } - } - internal enum Title { - /// Appearance - internal static let appearance = L10n.tr("Localizable", "appearance_setting_view.title.appearance", fallback: "Appearance") - /// Display mode - internal static let displayMode = L10n.tr("Localizable", "appearance_setting_view.title.display_mode", fallback: "Display mode") - /// Displays Japanese title - internal static let displaysJapaneseTitle = L10n.tr("Localizable", "appearance_setting_view.title.displays_japanese_title", fallback: "Displays Japanese title") - /// Maximum number of tags - internal static let maximumNumberOfTags = L10n.tr("Localizable", "appearance_setting_view.title.maximum_number_of_tags", fallback: "Maximum number of tags") - /// Shows tags in list - internal static let showsTagsInList = L10n.tr("Localizable", "appearance_setting_view.title.shows_tags_in_list", fallback: "Shows tags in list") - /// Theme - internal static let theme = L10n.tr("Localizable", "appearance_setting_view.title.theme", fallback: "Theme") - /// Tint color - internal static let tintColor = L10n.tr("Localizable", "appearance_setting_view.title.tint_color", fallback: "Tint color") - } - } - internal enum ArchivesView { - internal enum Button { - /// Download To H@H Client - internal static let downloadToHathClient = L10n.tr("Localizable", "archives_view.button.download_to_hath_client", fallback: "Download To H@H Client") - } - internal enum Title { - /// Archives - internal static let archives = L10n.tr("Localizable", "archives_view.title.archives", fallback: "Archives") - } - } - internal enum CommentsView { - internal enum Title { - /// Comments - internal static let comments = L10n.tr("Localizable", "comments_view.title.comments", fallback: "Comments") - } - } - internal enum Common { - internal enum Button { - /// Cancel - internal static let cancel = L10n.tr("Localizable", "common.button.cancel", fallback: "Cancel") - } - internal enum Value { - /// %@ day - internal static func day(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.day", String(describing: p1), fallback: "%@ day") - } - /// %@ days - internal static func days(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.days", String(describing: p1), fallback: "%@ days") - } - /// %@ hour - internal static func hour(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.hour", String(describing: p1), fallback: "%@ hour") - } - /// %@ hours - internal static func hours(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.hours", String(describing: p1), fallback: "%@ hours") - } - /// %@ minute - internal static func minute(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.minute", String(describing: p1), fallback: "%@ minute") - } - /// %@ minutes - internal static func minutes(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.minutes", String(describing: p1), fallback: "%@ minutes") - } - /// %@ pages - internal static func pages(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.pages", String(describing: p1), fallback: "%@ pages") - } - /// %@ records - internal static func records(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.records", String(describing: p1), fallback: "%@ records") - } - /// %@ second - internal static func second(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.second", String(describing: p1), fallback: "%@ second") - } - /// %@ seconds - internal static func seconds(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.seconds", String(describing: p1), fallback: "%@ seconds") - } - /// %@ stars - internal static func stars(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.stars", String(describing: p1), fallback: "%@ stars") - } - /// %@ times - internal static func times(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.times", String(describing: p1), fallback: "%@ times") - } - } - } - internal enum ConfirmationDialog { - internal enum Button { - /// Clear - internal static let clear = L10n.tr("Localizable", "confirmation_dialog.button.clear", fallback: "Clear") - /// Delete - internal static let delete = L10n.tr("Localizable", "confirmation_dialog.button.delete", fallback: "Delete") - /// Drop the database - internal static let dropDatabase = L10n.tr("Localizable", "confirmation_dialog.button.drop_database", fallback: "Drop the database") - /// Logout - internal static let logout = L10n.tr("Localizable", "confirmation_dialog.button.logout", fallback: "Logout") - /// Remove - internal static let remove = L10n.tr("Localizable", "confirmation_dialog.button.remove", fallback: "Remove") - /// Reset - internal static let reset = L10n.tr("Localizable", "confirmation_dialog.button.reset", fallback: "Reset") - } - internal enum Title { - /// Are you sure to clear? - internal static let clear = L10n.tr("Localizable", "confirmation_dialog.title.clear", fallback: "Are you sure to clear?") - /// Are you sure to delete this item? - internal static let delete = L10n.tr("Localizable", "confirmation_dialog.title.delete", fallback: "Are you sure to delete this item?") - /// You will lose all your data in this app. - /// Are you sure to drop the database? - internal static let dropDatabase = L10n.tr("Localizable", "confirmation_dialog.title.drop_database", fallback: "You will lose all your data in this app.\nAre you sure to drop the database?") - /// Are you sure to logout? - internal static let logout = L10n.tr("Localizable", "confirmation_dialog.title.logout", fallback: "Are you sure to logout?") - /// Are you sure to remove your custom translations? - internal static let removeCustomTranslations = L10n.tr("Localizable", "confirmation_dialog.title.remove_custom_translations", fallback: "Are you sure to remove your custom translations?") - /// Are you sure to reset? - internal static let reset = L10n.tr("Localizable", "confirmation_dialog.title.reset", fallback: "Are you sure to reset?") - } - } - internal enum DateSeekView { - internal enum Button { - /// Newer - internal static let seekNewer = L10n.tr("Localizable", "date_seek_view.button.seek_newer", fallback: "Newer") - /// Older - internal static let seekOlder = L10n.tr("Localizable", "date_seek_view.button.seek_older", fallback: "Older") - } - internal enum Footer { - /// Seek to galleries around the selected date. - internal static let seekAroundDate = L10n.tr("Localizable", "date_seek_view.footer.seek_around_date", fallback: "Seek to galleries around the selected date.") - } - internal enum Title { - /// Date - internal static let date = L10n.tr("Localizable", "date_seek_view.title.date", fallback: "Date") - /// Seek to date - internal static let dateSeek = L10n.tr("Localizable", "date_seek_view.title.date_seek", fallback: "Seek to date") - } - } - internal enum DetailView { - internal enum Accessibility { - internal enum DownloadButton { - /// Download - internal static let download = L10n.tr("Localizable", "detail_view.accessibility.download_button.download", fallback: "Download") - /// Delete downloaded gallery - internal static let downloaded = L10n.tr("Localizable", "detail_view.accessibility.download_button.downloaded", fallback: "Delete downloaded gallery") - /// Downloading %d of %d - internal static func downloading(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "detail_view.accessibility.download_button.downloading", p1, p2, fallback: "Downloading %d of %d") - } - /// Log in to download - internal static let login = L10n.tr("Localizable", "detail_view.accessibility.download_button.login", fallback: "Log in to download") - /// Retry download. %d of %d pages are already available. - internal static func partial(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "detail_view.accessibility.download_button.partial", p1, p2, fallback: "Retry download. %d of %d pages are already available.") - } - /// Pause download - internal static let pauseAction = L10n.tr("Localizable", "detail_view.accessibility.download_button.pause_action", fallback: "Pause download") - /// Resume download. Paused at %d of %d - internal static func paused(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "detail_view.accessibility.download_button.paused", p1, p2, fallback: "Resume download. Paused at %d of %d") - } - /// Preparing download - internal static let preparing = L10n.tr("Localizable", "detail_view.accessibility.download_button.preparing", fallback: "Preparing download") - /// Queued - internal static let queued = L10n.tr("Localizable", "detail_view.accessibility.download_button.queued", fallback: "Queued") - /// Repair download - internal static let repair = L10n.tr("Localizable", "detail_view.accessibility.download_button.repair", fallback: "Repair download") - /// Retry download - internal static let retry = L10n.tr("Localizable", "detail_view.accessibility.download_button.retry", fallback: "Retry download") - /// Update download - internal static let update = L10n.tr("Localizable", "detail_view.accessibility.download_button.update", fallback: "Update download") - } - } - internal enum ActionSection { - internal enum Button { - /// Give a Rating - internal static let giveARating = L10n.tr("Localizable", "detail_view.action_section.button.give_a_rating", fallback: "Give a Rating") - /// Similar Gallery - internal static let similarGallery = L10n.tr("Localizable", "detail_view.action_section.button.similar_gallery", fallback: "Similar Gallery") - } - } - internal enum Button { - /// DONE - internal static let downloadDone = L10n.tr("Localizable", "detail_view.button.download_done", fallback: "DONE") - /// GET - internal static let downloadGet = L10n.tr("Localizable", "detail_view.button.download_get", fallback: "GET") - /// LOG IN - internal static let downloadLogin = L10n.tr("Localizable", "detail_view.button.download_login", fallback: "LOG IN") - /// REPAIR - internal static let downloadRepair = L10n.tr("Localizable", "detail_view.button.download_repair", fallback: "REPAIR") - /// RETRY - internal static let downloadRetry = L10n.tr("Localizable", "detail_view.button.download_retry", fallback: "RETRY") - /// UPDATE - internal static let downloadUpdate = L10n.tr("Localizable", "detail_view.button.download_update", fallback: "UPDATE") - /// WAIT - internal static let downloadWait = L10n.tr("Localizable", "detail_view.button.download_wait", fallback: "WAIT") - /// Post comment - internal static let postComment = L10n.tr("Localizable", "detail_view.button.post_comment", fallback: "Post comment") - /// Read - internal static let read = L10n.tr("Localizable", "detail_view.button.read", fallback: "Read") - } - internal enum ContextMenu { - internal enum Button { - /// Detail - internal static let detail = L10n.tr("Localizable", "detail_view.context_menu.button.detail", fallback: "Detail") - /// Vote down - internal static let voteDown = L10n.tr("Localizable", "detail_view.context_menu.button.vote_down", fallback: "Vote down") - /// Vote up - internal static let voteUp = L10n.tr("Localizable", "detail_view.context_menu.button.vote_up", fallback: "Vote up") - /// Withdraw vote - internal static let withdrawVote = L10n.tr("Localizable", "detail_view.context_menu.button.withdraw_vote", fallback: "Withdraw vote") - } - } - internal enum DescriptionSection { - internal enum Description { - /// Times - internal static let favorited = L10n.tr("Localizable", "detail_view.description_section.description.favorited", fallback: "Times") - /// Pages - internal static let pageCount = L10n.tr("Localizable", "detail_view.description_section.description.page_count", fallback: "Pages") - } - internal enum Title { - /// Favorited - internal static let favorited = L10n.tr("Localizable", "detail_view.description_section.title.favorited", fallback: "Favorited") - /// File Size - internal static let fileSize = L10n.tr("Localizable", "detail_view.description_section.title.file_size", fallback: "File Size") - /// Language - internal static let language = L10n.tr("Localizable", "detail_view.description_section.title.language", fallback: "Language") - /// Page Count - internal static let pageCount = L10n.tr("Localizable", "detail_view.description_section.title.page_count", fallback: "Page Count") - /// %@ Ratings - internal static func ratings(_ p1: Any) -> String { - return L10n.tr("Localizable", "detail_view.description_section.title.ratings", String(describing: p1), fallback: "%@ Ratings") - } - } - } - internal enum Dialog { - internal enum Button { - /// Redownload - internal static let redownload = L10n.tr("Localizable", "detail_view.dialog.button.redownload", fallback: "Redownload") - /// Repair - internal static let repair = L10n.tr("Localizable", "detail_view.dialog.button.repair", fallback: "Repair") - /// Update - internal static let update = L10n.tr("Localizable", "detail_view.dialog.button.update", fallback: "Update") - } - internal enum Message { - /// This will stop the current download and remove the gallery from this device. - internal static let deleteActiveDownload = L10n.tr("Localizable", "detail_view.dialog.message.delete_active_download", fallback: "This will stop the current download and remove the gallery from this device.") - /// This will remove the downloaded gallery from this device. - internal static let deleteDownloadedGallery = L10n.tr("Localizable", "detail_view.dialog.message.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") - /// Start a fresh download for this gallery now? - internal static let redownloadGallery = L10n.tr("Localizable", "detail_view.dialog.message.redownload_gallery", fallback: "Start a fresh download for this gallery now?") - /// Repair the offline files for this gallery now? - internal static let repairDownload = L10n.tr("Localizable", "detail_view.dialog.message.repair_download", fallback: "Repair the offline files for this gallery now?") - /// Update this gallery to the newest online version now? - internal static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.message.update_download", fallback: "Update this gallery to the newest online version now?") - } - internal enum Title { - /// Delete Download? - internal static let deleteDownload = L10n.tr("Localizable", "detail_view.dialog.title.delete_download", fallback: "Delete Download?") - /// Redownload Gallery? - internal static let redownloadGallery = L10n.tr("Localizable", "detail_view.dialog.title.redownload_gallery", fallback: "Redownload Gallery?") - /// Repair Download? - internal static let repairDownload = L10n.tr("Localizable", "detail_view.dialog.title.repair_download", fallback: "Repair Download?") - /// Update Download? - internal static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.title.update_download", fallback: "Update Download?") - } - } - internal enum Menu { - internal enum Button { - /// Create Default Folder - internal static let createDefaultFolder = L10n.tr("Localizable", "detail_view.menu.button.create_default_folder", fallback: "Create Default Folder") - /// Manage Folders - internal static let manageFolders = L10n.tr("Localizable", "detail_view.menu.button.manage_folders", fallback: "Manage Folders") - } - internal enum Text { - /// No folders yet - internal static let noFolders = L10n.tr("Localizable", "detail_view.menu.text.no_folders", fallback: "No folders yet") - } - } - internal enum OfflineNotice { - /// Couldn't refresh online details. Showing saved details instead. - internal static let savedDetails = L10n.tr("Localizable", "detail_view.offline_notice.saved_details", fallback: "Couldn't refresh online details. Showing saved details instead.") - } - internal enum Section { - internal enum Title { - /// Comments - internal static let comments = L10n.tr("Localizable", "detail_view.section.title.comments", fallback: "Comments") - /// Previews - internal static let previews = L10n.tr("Localizable", "detail_view.section.title.previews", fallback: "Previews") - } - } - internal enum ToolbarItem { - internal enum Button { - /// Archives - internal static let archives = L10n.tr("Localizable", "detail_view.toolbar_item.button.archives", fallback: "Archives") - /// Share - internal static let share = L10n.tr("Localizable", "detail_view.toolbar_item.button.share", fallback: "Share") - /// Torrents - internal static let torrents = L10n.tr("Localizable", "detail_view.toolbar_item.button.torrents", fallback: "Torrents") - } - } - } - internal enum DownloadSettingView { - /// Download - internal static let title = L10n.tr("Localizable", "download_setting_view.title", fallback: "Download") - internal enum Footer { - /// Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder. - internal static let network = L10n.tr("Localizable", "download_setting_view.footer.network", fallback: "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder.") - } - internal enum Section { - internal enum Title { - /// Download Queue - internal static let downloadQueue = L10n.tr("Localizable", "download_setting_view.section.title.download_queue", fallback: "Download Queue") - /// Network - internal static let network = L10n.tr("Localizable", "download_setting_view.section.title.network", fallback: "Network") - } - } - internal enum Title { - /// Allow cellular downloads - internal static let allowCellularDownloads = L10n.tr("Localizable", "download_setting_view.title.allow_cellular_downloads", fallback: "Allow cellular downloads") - /// Concurrent image downloads - internal static let concurrentImageDownloads = L10n.tr("Localizable", "download_setting_view.title.concurrent_image_downloads", fallback: "Concurrent image downloads") - /// Retry failed pages automatically - internal static let retryFailedPagesAutomatically = L10n.tr("Localizable", "download_setting_view.title.retry_failed_pages_automatically", fallback: "Retry failed pages automatically") - } - } - internal enum DownloadStore { - internal enum Error { - /// Asset file is unreadable: %@ - internal static func assetUnreadable(_ p1: Any) -> String { - return L10n.tr("Localizable", "download_store.error.asset_unreadable", String(describing: p1), fallback: "Asset file is unreadable: %@") - } - /// The download is currently active. - internal static let downloadBusy = L10n.tr("Localizable", "download_store.error.download_busy", fallback: "The download is currently active.") - /// A folder with this name already exists. - internal static let folderAlreadyExists = L10n.tr("Localizable", "download_store.error.folder_already_exists", fallback: "A folder with this name already exists.") - /// The folder contains an active download. - internal static let folderBusyDownloading = L10n.tr("Localizable", "download_store.error.folder_busy_downloading", fallback: "The folder contains an active download.") - /// The folder name is invalid. - internal static let invalidFolderName = L10n.tr("Localizable", "download_store.error.invalid_folder_name", fallback: "The folder name is invalid.") - } - internal enum Validation { - /// Cover image data is corrupted. - internal static let coverImageCorrupted = L10n.tr("Localizable", "download_store.validation.cover_image_corrupted", fallback: "Cover image data is corrupted.") - /// Cover image is missing. - internal static let coverImageMissing = L10n.tr("Localizable", "download_store.validation.cover_image_missing", fallback: "Cover image is missing.") - /// Download folder is missing. - internal static let downloadFolderMissing = L10n.tr("Localizable", "download_store.validation.download_folder_missing", fallback: "Download folder is missing.") - /// Download folder could not be resolved. - internal static let downloadFolderUnresolved = L10n.tr("Localizable", "download_store.validation.download_folder_unresolved", fallback: "Download folder could not be resolved.") - /// Downloaded pages are incomplete. - internal static let downloadedPagesIncomplete = L10n.tr("Localizable", "download_store.validation.downloaded_pages_incomplete", fallback: "Downloaded pages are incomplete.") - /// Manifest file is corrupted. - internal static let manifestCorrupted = L10n.tr("Localizable", "download_store.validation.manifest_corrupted", fallback: "Manifest file is corrupted.") - /// Manifest file is missing. - internal static let manifestMissing = L10n.tr("Localizable", "download_store.validation.manifest_missing", fallback: "Manifest file is missing.") - /// Page %d image data is corrupted. - internal static func pageImageCorrupted(_ p1: Int) -> String { - return L10n.tr("Localizable", "download_store.validation.page_image_corrupted", p1, fallback: "Page %d image data is corrupted.") - } - /// Page %d is missing. - internal static func pageMissing(_ p1: Int) -> String { - return L10n.tr("Localizable", "download_store.validation.page_missing", p1, fallback: "Page %d is missing.") - } - } - } - internal enum DownloadsView { - internal enum Button { - /// Clear Filters - internal static let clearFilters = L10n.tr("Localizable", "downloads_view.button.clear_filters", fallback: "Clear Filters") - /// Validate Image Data - internal static let validateImageData = L10n.tr("Localizable", "downloads_view.button.validate_image_data", fallback: "Validate Image Data") - } - internal enum Dialog { - internal enum Message { - /// This will cancel the current download and remove it from this device. - internal static let deleteActiveDownload = L10n.tr("Localizable", "downloads_view.dialog.message.delete_active_download", fallback: "This will cancel the current download and remove it from this device.") - /// This will remove the downloaded gallery from this device. - internal static let deleteDownloadedGallery = L10n.tr("Localizable", "downloads_view.dialog.message.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") - } - internal enum Title { - /// Delete Download? - internal static let deleteDownload = L10n.tr("Localizable", "downloads_view.dialog.title.delete_download", fallback: "Delete Download?") - } - } - internal enum EmptyState { - /// Downloaded galleries will appear here. - internal static let downloads = L10n.tr("Localizable", "downloads_view.empty_state.downloads", fallback: "Downloaded galleries will appear here.") - /// No downloads match the current filters. - internal static let noMatchingFilters = L10n.tr("Localizable", "downloads_view.empty_state.no_matching_filters", fallback: "No downloads match the current filters.") - } - internal enum Inspector { - internal enum Button { - /// Retry Failed Pages - internal static let retryFailedPages = L10n.tr("Localizable", "downloads_view.inspector.button.retry_failed_pages", fallback: "Retry Failed Pages") - /// Update Download - internal static let updateDownload = L10n.tr("Localizable", "downloads_view.inspector.button.update_download", fallback: "Update Download") - /// Validating Image Data... - internal static let validatingImageData = L10n.tr("Localizable", "downloads_view.inspector.button.validating_image_data", fallback: "Validating Image Data...") - } - internal enum Hud { - /// Image data could not be validated. - internal static let imageDataUnavailable = L10n.tr("Localizable", "downloads_view.inspector.hud.image_data_unavailable", fallback: "Image data could not be validated.") - /// Image data is valid - internal static let imageDataValid = L10n.tr("Localizable", "downloads_view.inspector.hud.image_data_valid", fallback: "Image data is valid") - } - internal enum Page { - /// No pages - internal static let `none` = L10n.tr("Localizable", "downloads_view.inspector.page.none", fallback: "No pages") - /// Pending - internal static let pending = L10n.tr("Localizable", "downloads_view.inspector.page.pending", fallback: "Pending") - /// Tap to retry this page - internal static let tapToRetry = L10n.tr("Localizable", "downloads_view.inspector.page.tap_to_retry", fallback: "Tap to retry this page") - /// Page %d - internal static func title(_ p1: Int) -> String { - return L10n.tr("Localizable", "downloads_view.inspector.page.title", p1, fallback: "Page %d") - } - } - internal enum Section { - /// Actions - internal static let actions = L10n.tr("Localizable", "downloads_view.inspector.section.actions", fallback: "Actions") - /// Pages - internal static let pages = L10n.tr("Localizable", "downloads_view.inspector.section.pages", fallback: "Pages") - } - internal enum Status { - /// Downloaded - internal static let downloaded = L10n.tr("Localizable", "downloads_view.inspector.status.downloaded", fallback: "Downloaded") - /// Failed - internal static let failed = L10n.tr("Localizable", "downloads_view.inspector.status.failed", fallback: "Failed") - /// Pending - internal static let pending = L10n.tr("Localizable", "downloads_view.inspector.status.pending", fallback: "Pending") - } - internal enum Title { - /// Download Status - internal static let downloadStatus = L10n.tr("Localizable", "downloads_view.inspector.title.download_status", fallback: "Download Status") - } - } - internal enum Menu { - internal enum Button { - /// Manage Folders - internal static let manageFolders = L10n.tr("Localizable", "downloads_view.menu.button.manage_folders", fallback: "Manage Folders") - /// Move to Folder - internal static let moveToFolder = L10n.tr("Localizable", "downloads_view.menu.button.move_to_folder", fallback: "Move to Folder") - } - } - internal enum Search { - internal enum Prompt { - /// Search downloads - internal static let downloads = L10n.tr("Localizable", "downloads_view.search.prompt.downloads", fallback: "Search downloads") - } - } - internal enum Swipe { - internal enum Button { - /// Move - internal static let move = L10n.tr("Localizable", "downloads_view.swipe.button.move", fallback: "Move") - /// Pages - internal static let pages = L10n.tr("Localizable", "downloads_view.swipe.button.pages", fallback: "Pages") - /// Pause - internal static let pause = L10n.tr("Localizable", "downloads_view.swipe.button.pause", fallback: "Pause") - /// Resume - internal static let resume = L10n.tr("Localizable", "downloads_view.swipe.button.resume", fallback: "Resume") - /// Update - internal static let update = L10n.tr("Localizable", "downloads_view.swipe.button.update", fallback: "Update") - } - } - internal enum Title { - /// Downloads - internal static let downloads = L10n.tr("Localizable", "downloads_view.title.downloads", fallback: "Downloads") - } - } - internal enum EhSettingView { - internal enum Button { - /// Create new - internal static let createNew = L10n.tr("Localizable", "eh_setting_view.button.create_new", fallback: "Create new") - /// Delete profile - internal static let deleteProfile = L10n.tr("Localizable", "eh_setting_view.button.delete_profile", fallback: "Delete profile") - /// Rename - internal static let rename = L10n.tr("Localizable", "eh_setting_view.button.rename", fallback: "Rename") - /// Set as default - internal static let setAsDefault = L10n.tr("Localizable", "eh_setting_view.button.set_as_default", fallback: "Set as default") - } - internal enum Description { - /// The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here. - internal static let archiverBehavior = L10n.tr("Localizable", "eh_setting_view.description.archiver_behavior", fallback: "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here.") - /// You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below. - internal static func browsingCountry(_ p1: Any) -> String { - return L10n.tr("Localizable", "eh_setting_view.description.browsing_country", String(describing: p1), fallback: "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below.") - } - /// The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes. - internal static let coverScaleFactor = L10n.tr("Localizable", "eh_setting_view.description.cover_scale_factor", fallback: "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes.") - /// Which display mode would you like to use on the front and search pages? - internal static let displayMode = L10n.tr("Localizable", "eh_setting_view.description.display_mode", fallback: "Which display mode would you like to use on the front and search pages?") - /// If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query. - internal static let excludedLanguages = L10n.tr("Localizable", "eh_setting_view.description.excluded_languages", fallback: "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query.") - /// If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query. - internal static let excludedUploaders = L10n.tr("Localizable", "eh_setting_view.description.excluded_uploaders", fallback: "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query.") - /// You are currently using **%@ / %@** exclusion slots. - internal static func excludedUploadersCount(_ p1: Any, _ p2: Any) -> String { - return L10n.tr("Localizable", "eh_setting_view.description.excluded_uploaders_count", String(describing: p1), String(describing: p2), fallback: "You are currently using **%@ / %@** exclusion slots.") - } - /// Here you can choose and rename your favorite categories. - internal static let favoriteCategories = L10n.tr("Localizable", "eh_setting_view.description.favorite_categories", fallback: "Here you can choose and rename your favorite categories.") - /// You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting. - internal static let favoritesSortOrder = L10n.tr("Localizable", "eh_setting_view.description.favorites_sort_order", fallback: "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting.") - /// Show the "Your default filters removed XX galleries from this page" readout? - internal static let filteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.description.filtered_removal_count", fallback: "Show the \"Your default filters removed XX galleries from this page\" readout?") - /// What categories would you like to show by default on the front page and in searches? - internal static let galleryCategory = L10n.tr("Localizable", "eh_setting_view.description.gallery_category", fallback: "What categories would you like to show by default on the front page and in searches?") - /// Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default? - internal static let galleryName = L10n.tr("Localizable", "eh_setting_view.description.gallery_name", fallback: "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?") - /// Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000. - internal static let imageResolution = L10n.tr("Localizable", "eh_setting_view.description.image_resolution", fallback: "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000.") - /// While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit) - internal static let imageSize = L10n.tr("Localizable", "eh_setting_view.description.image_size", fallback: "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)") - /// This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem. - /// If you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work. - internal static let ipAddressPort = L10n.tr("Localizable", "eh_setting_view.description.ip_address_port", fallback: "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work.") - /// Some historic UI elements are now disabled by default. You can enable those here. - internal static let optionalUIElements = L10n.tr("Localizable", "eh_setting_view.description.optional_UI_elements", fallback: "Some historic UI elements are now disabled by default. You can enable those here.") - /// By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works. - internal static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.description.ratings_color", fallback: "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works.") - /// How many results would you like per page for the index/search page and torrent search pages? - /// (Hath Perk: Paging Enlargement Required) - internal static let resultCount = L10n.tr("Localizable", "eh_setting_view.description.result_count", fallback: "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)") - /// You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999. - internal static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.description.tag_filtering_threshold", fallback: "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999.") - /// Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999. - internal static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.description.tag_watching_threshold", fallback: "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999.") - /// You can set a default thumbnail configuration for all galleries you visit. - internal static let thumbnailConfiguration = L10n.tr("Localizable", "eh_setting_view.description.thumbnail_configuration", fallback: "You can set a default thumbnail configuration for all galleries you visit.") - /// How would you like the mouse-over thumbnails on the front page to load when using List Mode? - internal static let thumbnailLoadTiming = L10n.tr("Localizable", "eh_setting_view.description.thumbnail_load_timing", fallback: "How would you like the mouse-over thumbnails on the front page to load when using List Mode?") - /// Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400. - internal static let virtualWidth = L10n.tr("Localizable", "eh_setting_view.description.virtual_width", fallback: "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400.") - } - internal enum Promt { - /// RRGGB - internal static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.promt.ratings_color", fallback: "RRGGB") - } - internal enum Section { - internal enum Title { - /// Archiver Settings - internal static let archiverSettings = L10n.tr("Localizable", "eh_setting_view.section.title.archiver_settings", fallback: "Archiver Settings") - /// Cover Scaling - internal static let coverScaling = L10n.tr("Localizable", "eh_setting_view.section.title.cover_scaling", fallback: "Cover Scaling") - /// Excluded Languages - internal static let excludedLanguages = L10n.tr("Localizable", "eh_setting_view.section.title.excluded_languages", fallback: "Excluded Languages") - /// Excluded Uploaders - internal static let excludedUploaders = L10n.tr("Localizable", "eh_setting_view.section.title.excluded_uploaders", fallback: "Excluded Uploaders") - /// Favorites - internal static let favorites = L10n.tr("Localizable", "eh_setting_view.section.title.favorites", fallback: "Favorites") - /// Show Filtered Removal Count - internal static let filteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.section.title.filtered_removal_count", fallback: "Show Filtered Removal Count") - /// Front Page Settings - internal static let frontPageSettings = L10n.tr("Localizable", "eh_setting_view.section.title.front_page_settings", fallback: "Front Page Settings") - /// Gallery Comments - internal static let galleryComments = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_comments", fallback: "Gallery Comments") - /// Gallery Name Display - internal static let galleryNameDisplay = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_name_display", fallback: "Gallery Name Display") - /// Gallery Page Thumbnail Labeling - internal static let galleryPageThumbnailLabeling = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_page_thumbnail_labeling", fallback: "Gallery Page Thumbnail Labeling") - /// Gallery Tags - internal static let galleryTags = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_tags", fallback: "Gallery Tags") - /// Hath Local Network Host - internal static let hathLocalNetworkHost = L10n.tr("Localizable", "eh_setting_view.section.title.hath_local_network_host", fallback: "Hath Local Network Host") - /// Image Load Settings - internal static let imageLoadSettings = L10n.tr("Localizable", "eh_setting_view.section.title.image_load_settings", fallback: "Image Load Settings") - /// Image Size Settings - internal static let imageSizeSettings = L10n.tr("Localizable", "eh_setting_view.section.title.image_size_settings", fallback: "Image Size Settings") - /// Multi-Page Viewer - internal static let multiPageViewer = L10n.tr("Localizable", "eh_setting_view.section.title.multi_page_viewer", fallback: "Multi-Page Viewer") - /// Optional UI Elements - internal static let optionalUIElements = L10n.tr("Localizable", "eh_setting_view.section.title.optional_UI_elements", fallback: "Optional UI Elements") - /// Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than "Auto" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year). - internal static let originalImages = L10n.tr("Localizable", "eh_setting_view.section.title.original_images", fallback: "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year).") - /// Profile Settings - internal static let profileSettings = L10n.tr("Localizable", "eh_setting_view.section.title.profile_settings", fallback: "Profile Settings") - /// Ratings - internal static let ratings = L10n.tr("Localizable", "eh_setting_view.section.title.ratings", fallback: "Ratings") - /// Search Result Count - internal static let searchResultCount = L10n.tr("Localizable", "eh_setting_view.section.title.search_result_count", fallback: "Search Result Count") - /// Search Range Indicator - internal static let showSearchRangeIndicator = L10n.tr("Localizable", "eh_setting_view.section.title.show_search_range_indicator", fallback: "Search Range Indicator") - /// Tag Filtering Threshold - internal static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.section.title.tag_filtering_threshold", fallback: "Tag Filtering Threshold") - /// Tag Watching Threshold - internal static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.section.title.tag_watching_threshold", fallback: "Tag Watching Threshold") - /// Thumbnail Settings - internal static let thumbnailSettings = L10n.tr("Localizable", "eh_setting_view.section.title.thumbnail_settings", fallback: "Thumbnail Settings") - /// Viewport Override - internal static let viewportOverride = L10n.tr("Localizable", "eh_setting_view.section.title.viewport_override", fallback: "Viewport Override") - } - } - internal enum Title { - /// Archiver behavior - internal static let archiverBehavior = L10n.tr("Localizable", "eh_setting_view.title.archiver_behavior", fallback: "Archiver behavior") - /// Browsing country - internal static let browsingCountry = L10n.tr("Localizable", "eh_setting_view.title.browsing_country", fallback: "Browsing country") - /// Comments sort order - internal static let commentsSortOrder = L10n.tr("Localizable", "eh_setting_view.title.comments_sort_order", fallback: "Comments sort order") - /// Comment votes show timing - internal static let commentsVotesShowTiming = L10n.tr("Localizable", "eh_setting_view.title.comments_votes_show_timing", fallback: "Comment votes show timing") - /// Display mode - internal static let displayMode = L10n.tr("Localizable", "eh_setting_view.title.display_mode", fallback: "Display mode") - /// Display style - internal static let displayStyle = L10n.tr("Localizable", "eh_setting_view.title.display_style", fallback: "Display style") - /// Enable thumbnail selector on gallery screen - internal static let enableGalleryThumbnailSelector = L10n.tr("Localizable", "eh_setting_view.title.enable_gallery_thumbnail_selector", fallback: "Enable thumbnail selector on gallery screen") - /// Favorites sort order - internal static let favoritesSortOrder = L10n.tr("Localizable", "eh_setting_view.title.favorites_sort_order", fallback: "Favorites sort order") - /// Gallery name - internal static let galleryName = L10n.tr("Localizable", "eh_setting_view.title.gallery_name", fallback: "Gallery name") - /// Horizontal - internal static let horizontal = L10n.tr("Localizable", "eh_setting_view.title.horizontal", fallback: "Horizontal") - /// %@ settings - internal static func hostSettings(_ p1: Any) -> String { - return L10n.tr("Localizable", "eh_setting_view.title.host_settings", String(describing: p1), fallback: "%@ settings") - } - /// Image resolution - internal static let imageResolution = L10n.tr("Localizable", "eh_setting_view.title.image_resolution", fallback: "Image resolution") - /// Image size - internal static let imageSize = L10n.tr("Localizable", "eh_setting_view.title.image_size", fallback: "Image size") - /// IP address:Port - internal static let ipAddressPort = L10n.tr("Localizable", "eh_setting_view.title.ip_address_port", fallback: "IP address:Port") - /// Load images through the Hath network - internal static let loadImagesThroughTheHathNetwork = L10n.tr("Localizable", "eh_setting_view.title.load_images_through_the_hath_network", fallback: "Load images through the Hath network") - /// Ratings color - internal static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.title.ratings_color", fallback: "Ratings color") - /// Result count - internal static let resultCount = L10n.tr("Localizable", "eh_setting_view.title.result_count", fallback: "Result count") - /// Scale factor - internal static let scaleFactor = L10n.tr("Localizable", "eh_setting_view.title.scale_factor", fallback: "Scale factor") - /// Selected profile - internal static let selectedProfile = L10n.tr("Localizable", "eh_setting_view.title.selected_profile", fallback: "Selected profile") - /// Show filtered removal count - internal static let showFilteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.title.show_filtered_removal_count", fallback: "Show filtered removal count") - /// Show label below gallery thumbnails - internal static let showLabelBelowGalleryThumbnails = L10n.tr("Localizable", "eh_setting_view.title.show_label_below_gallery_thumbnails", fallback: "Show label below gallery thumbnails") - /// Show search range indicator - internal static let showSearchRangeIndicator = L10n.tr("Localizable", "eh_setting_view.title.show_search_range_indicator", fallback: "Show search range indicator") - /// Show thumbnail pane - internal static let showThumbnailPane = L10n.tr("Localizable", "eh_setting_view.title.show_thumbnail_pane", fallback: "Show thumbnail pane") - /// Tag Filtering Threshold - internal static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.title.tag_filtering_threshold", fallback: "Tag Filtering Threshold") - /// Tag Watching Threshold - internal static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.title.tag_watching_threshold", fallback: "Tag Watching Threshold") - /// Tags sort order - internal static let tagsSortOrder = L10n.tr("Localizable", "eh_setting_view.title.tags_sort_order", fallback: "Tags sort order") - /// Thumbnail load timing - internal static let thumbnailLoadTiming = L10n.tr("Localizable", "eh_setting_view.title.thumbnail_load_timing", fallback: "Thumbnail load timing") - /// Rows - internal static let thumbnailRowCount = L10n.tr("Localizable", "eh_setting_view.title.thumbnail_row_count", fallback: "Rows") - /// Size - internal static let thumbnailSize = L10n.tr("Localizable", "eh_setting_view.title.thumbnail_size", fallback: "Size") - /// Use Multi-Page Viewer - internal static let useMultiPageViewer = L10n.tr("Localizable", "eh_setting_view.title.use_multi_page_viewer", fallback: "Use Multi-Page Viewer") - /// Use original images - internal static let useOriginalImages = L10n.tr("Localizable", "eh_setting_view.title.use_original_images", fallback: "Use original images") - /// Vertical - internal static let vertical = L10n.tr("Localizable", "eh_setting_view.title.vertical", fallback: "Vertical") - /// Virtual width - internal static let virtualWidth = L10n.tr("Localizable", "eh_setting_view.title.virtual_width", fallback: "Virtual width") - } - internal enum ToolbarItem { - internal enum Button { - /// Done - internal static let done = L10n.tr("Localizable", "eh_setting_view.toolbar_item.button.done", fallback: "Done") - } - } - } - internal enum Enum { - internal enum AppIconType { - internal enum Value { - /// Default - internal static let `default` = L10n.tr("Localizable", "enum.app_icon_type.value.default", fallback: "Default") - /// Developer - internal static let developer = L10n.tr("Localizable", "enum.app_icon_type.value.developer", fallback: "Developer") - /// NOT MY PRESIDENT - internal static let notMyPresident = L10n.tr("Localizable", "enum.app_icon_type.value.not_my_president", fallback: "NOT MY PRESIDENT") - /// Stand With Ukraine (2022) - internal static let standWithUkraine2022 = L10n.tr("Localizable", "enum.app_icon_type.value.stand_with_ukraine_2022", fallback: "Stand With Ukraine (2022)") - /// Ukiyo-e - internal static let ukiyoe = L10n.tr("Localizable", "enum.app_icon_type.value.ukiyoe", fallback: "Ukiyo-e") - } - } - internal enum ArchiveResolution { - internal enum Value { - /// Original - internal static let original = L10n.tr("Localizable", "enum.archive_resolution.value.original", fallback: "Original") - } - } - internal enum AutoLockPolicy { - internal enum Value { - /// Instantly - internal static let instantly = L10n.tr("Localizable", "enum.auto_lock_policy.value.instantly", fallback: "Instantly") - /// Never - internal static let never = L10n.tr("Localizable", "enum.auto_lock_policy.value.never", fallback: "Never") - } - } - internal enum AutoPlayPolicy { - internal enum Value { - /// Off - internal static let off = L10n.tr("Localizable", "enum.auto_play_policy.value.off", fallback: "Off") - } - } - internal enum BanInterval { - internal enum Description { - /// Localizable.strings - /// EhPanda - internal static let and = L10n.tr("Localizable", "enum.ban_interval.description.and", fallback: "and") - } - } - internal enum BrowsingCountry { - internal enum Name { - /// Afghanistan - internal static let afghanistan = L10n.tr("Localizable", "enum.browsing_country.name.afghanistan", fallback: "Afghanistan") - /// Aland Islands - internal static let alandIslands = L10n.tr("Localizable", "enum.browsing_country.name.aland_islands", fallback: "Aland Islands") - /// Albania - internal static let albania = L10n.tr("Localizable", "enum.browsing_country.name.albania", fallback: "Albania") - /// Algeria - internal static let algeria = L10n.tr("Localizable", "enum.browsing_country.name.algeria", fallback: "Algeria") - /// American Samoa - internal static let americanSamoa = L10n.tr("Localizable", "enum.browsing_country.name.american_samoa", fallback: "American Samoa") - /// Andorra - internal static let andorra = L10n.tr("Localizable", "enum.browsing_country.name.andorra", fallback: "Andorra") - /// Angola - internal static let angola = L10n.tr("Localizable", "enum.browsing_country.name.angola", fallback: "Angola") - /// Anguilla - internal static let anguilla = L10n.tr("Localizable", "enum.browsing_country.name.anguilla", fallback: "Anguilla") - /// Antarctica - internal static let antarctica = L10n.tr("Localizable", "enum.browsing_country.name.antarctica", fallback: "Antarctica") - /// Antigua and Barbuda - internal static let antiguaAndBarbuda = L10n.tr("Localizable", "enum.browsing_country.name.antigua_and_barbuda", fallback: "Antigua and Barbuda") - /// Argentina - internal static let argentina = L10n.tr("Localizable", "enum.browsing_country.name.argentina", fallback: "Argentina") - /// Armenia - internal static let armenia = L10n.tr("Localizable", "enum.browsing_country.name.armenia", fallback: "Armenia") - /// Aruba - internal static let aruba = L10n.tr("Localizable", "enum.browsing_country.name.aruba", fallback: "Aruba") - /// Asia-Pacific Region - internal static let asiaPacificRegion = L10n.tr("Localizable", "enum.browsing_country.name.asia_pacific_region", fallback: "Asia-Pacific Region") - /// Australia - internal static let australia = L10n.tr("Localizable", "enum.browsing_country.name.australia", fallback: "Australia") - /// Austria - internal static let austria = L10n.tr("Localizable", "enum.browsing_country.name.austria", fallback: "Austria") - /// Auto-Detect - internal static let autoDetect = L10n.tr("Localizable", "enum.browsing_country.name.auto_detect", fallback: "Auto-Detect") - /// Azerbaijan - internal static let azerbaijan = L10n.tr("Localizable", "enum.browsing_country.name.azerbaijan", fallback: "Azerbaijan") - /// Bahamas - internal static let bahamas = L10n.tr("Localizable", "enum.browsing_country.name.bahamas", fallback: "Bahamas") - /// Bahrain - internal static let bahrain = L10n.tr("Localizable", "enum.browsing_country.name.bahrain", fallback: "Bahrain") - /// Bangladesh - internal static let bangladesh = L10n.tr("Localizable", "enum.browsing_country.name.bangladesh", fallback: "Bangladesh") - /// Barbados - internal static let barbados = L10n.tr("Localizable", "enum.browsing_country.name.barbados", fallback: "Barbados") - /// Belarus - internal static let belarus = L10n.tr("Localizable", "enum.browsing_country.name.belarus", fallback: "Belarus") - /// Belgium - internal static let belgium = L10n.tr("Localizable", "enum.browsing_country.name.belgium", fallback: "Belgium") - /// Belize - internal static let belize = L10n.tr("Localizable", "enum.browsing_country.name.belize", fallback: "Belize") - /// Benin - internal static let benin = L10n.tr("Localizable", "enum.browsing_country.name.benin", fallback: "Benin") - /// Bermuda - internal static let bermuda = L10n.tr("Localizable", "enum.browsing_country.name.bermuda", fallback: "Bermuda") - /// Bhutan - internal static let bhutan = L10n.tr("Localizable", "enum.browsing_country.name.bhutan", fallback: "Bhutan") - /// Bolivia - internal static let bolivia = L10n.tr("Localizable", "enum.browsing_country.name.bolivia", fallback: "Bolivia") - /// Bonaire Saint Eustatius and Saba - internal static let bonaireSaintEustatiusAndSaba = L10n.tr("Localizable", "enum.browsing_country.name.bonaire_saint_eustatius_and_saba", fallback: "Bonaire Saint Eustatius and Saba") - /// Bosnia and Herzegovina - internal static let bosniaAndHerzegovina = L10n.tr("Localizable", "enum.browsing_country.name.bosnia_and_herzegovina", fallback: "Bosnia and Herzegovina") - /// Botswana - internal static let botswana = L10n.tr("Localizable", "enum.browsing_country.name.botswana", fallback: "Botswana") - /// Bouvet Island - internal static let bouvetIsland = L10n.tr("Localizable", "enum.browsing_country.name.bouvet_island", fallback: "Bouvet Island") - /// Brazil - internal static let brazil = L10n.tr("Localizable", "enum.browsing_country.name.brazil", fallback: "Brazil") - /// British Indian Ocean Territory - internal static let britishIndianOceanTerritory = L10n.tr("Localizable", "enum.browsing_country.name.british_indian_ocean_territory", fallback: "British Indian Ocean Territory") - /// Brunei Darussalam - internal static let bruneiDarussalam = L10n.tr("Localizable", "enum.browsing_country.name.brunei_darussalam", fallback: "Brunei Darussalam") - /// Bulgaria - internal static let bulgaria = L10n.tr("Localizable", "enum.browsing_country.name.bulgaria", fallback: "Bulgaria") - /// Burkina Faso - internal static let burkinaFaso = L10n.tr("Localizable", "enum.browsing_country.name.burkina_faso", fallback: "Burkina Faso") - /// Burundi - internal static let burundi = L10n.tr("Localizable", "enum.browsing_country.name.burundi", fallback: "Burundi") - /// Cambodia - internal static let cambodia = L10n.tr("Localizable", "enum.browsing_country.name.cambodia", fallback: "Cambodia") - /// Cameroon - internal static let cameroon = L10n.tr("Localizable", "enum.browsing_country.name.cameroon", fallback: "Cameroon") - /// Canada - internal static let canada = L10n.tr("Localizable", "enum.browsing_country.name.canada", fallback: "Canada") - /// Cape Verde - internal static let capeVerde = L10n.tr("Localizable", "enum.browsing_country.name.cape_verde", fallback: "Cape Verde") - /// Cayman Islands - internal static let caymanIslands = L10n.tr("Localizable", "enum.browsing_country.name.cayman_islands", fallback: "Cayman Islands") - /// Central African Republic - internal static let centralAfricanRepublic = L10n.tr("Localizable", "enum.browsing_country.name.central_african_republic", fallback: "Central African Republic") - /// Chad - internal static let chad = L10n.tr("Localizable", "enum.browsing_country.name.chad", fallback: "Chad") - /// Chile - internal static let chile = L10n.tr("Localizable", "enum.browsing_country.name.chile", fallback: "Chile") - /// China - internal static let china = L10n.tr("Localizable", "enum.browsing_country.name.china", fallback: "China") - /// Christmas Island - internal static let christmasIsland = L10n.tr("Localizable", "enum.browsing_country.name.christmas_island", fallback: "Christmas Island") - /// Cocos Islands - internal static let cocosIslands = L10n.tr("Localizable", "enum.browsing_country.name.cocos_islands", fallback: "Cocos Islands") - /// Colombia - internal static let colombia = L10n.tr("Localizable", "enum.browsing_country.name.colombia", fallback: "Colombia") - /// Comoros - internal static let comoros = L10n.tr("Localizable", "enum.browsing_country.name.comoros", fallback: "Comoros") - /// Congo - internal static let congo = L10n.tr("Localizable", "enum.browsing_country.name.congo", fallback: "Congo") - /// Cook Islands - internal static let cookIslands = L10n.tr("Localizable", "enum.browsing_country.name.cook_islands", fallback: "Cook Islands") - /// Costa Rica - internal static let costaRica = L10n.tr("Localizable", "enum.browsing_country.name.costa_rica", fallback: "Costa Rica") - /// Cote D'Ivoire - internal static let coteDIvoire = L10n.tr("Localizable", "enum.browsing_country.name.cote_d_ivoire", fallback: "Cote D'Ivoire") - /// Croatia - internal static let croatia = L10n.tr("Localizable", "enum.browsing_country.name.croatia", fallback: "Croatia") - /// Cuba - internal static let cuba = L10n.tr("Localizable", "enum.browsing_country.name.cuba", fallback: "Cuba") - /// Curacao - internal static let curacao = L10n.tr("Localizable", "enum.browsing_country.name.curacao", fallback: "Curacao") - /// Cyprus - internal static let cyprus = L10n.tr("Localizable", "enum.browsing_country.name.cyprus", fallback: "Cyprus") - /// Czech Republic - internal static let czechRepublic = L10n.tr("Localizable", "enum.browsing_country.name.czech_republic", fallback: "Czech Republic") - /// Denmark - internal static let denmark = L10n.tr("Localizable", "enum.browsing_country.name.denmark", fallback: "Denmark") - /// Djibouti - internal static let djibouti = L10n.tr("Localizable", "enum.browsing_country.name.djibouti", fallback: "Djibouti") - /// Dominica - internal static let dominica = L10n.tr("Localizable", "enum.browsing_country.name.dominica", fallback: "Dominica") - /// Dominican Republic - internal static let dominicanRepublic = L10n.tr("Localizable", "enum.browsing_country.name.dominican_republic", fallback: "Dominican Republic") - /// Ecuador - internal static let ecuador = L10n.tr("Localizable", "enum.browsing_country.name.ecuador", fallback: "Ecuador") - /// Egypt - internal static let egypt = L10n.tr("Localizable", "enum.browsing_country.name.egypt", fallback: "Egypt") - /// El Salvador - internal static let elSalvador = L10n.tr("Localizable", "enum.browsing_country.name.el_salvador", fallback: "El Salvador") - /// Equatorial Guinea - internal static let equatorialGuinea = L10n.tr("Localizable", "enum.browsing_country.name.equatorial_guinea", fallback: "Equatorial Guinea") - /// Eritrea - internal static let eritrea = L10n.tr("Localizable", "enum.browsing_country.name.eritrea", fallback: "Eritrea") - /// Estonia - internal static let estonia = L10n.tr("Localizable", "enum.browsing_country.name.estonia", fallback: "Estonia") - /// Ethiopia - internal static let ethiopia = L10n.tr("Localizable", "enum.browsing_country.name.ethiopia", fallback: "Ethiopia") - /// Europe - internal static let europe = L10n.tr("Localizable", "enum.browsing_country.name.europe", fallback: "Europe") - /// Falkland Islands - internal static let falklandIslands = L10n.tr("Localizable", "enum.browsing_country.name.falkland_islands", fallback: "Falkland Islands") - /// Faroe Islands - internal static let faroeIslands = L10n.tr("Localizable", "enum.browsing_country.name.faroe_islands", fallback: "Faroe Islands") - /// Fiji - internal static let fiji = L10n.tr("Localizable", "enum.browsing_country.name.fiji", fallback: "Fiji") - /// Finland - internal static let finland = L10n.tr("Localizable", "enum.browsing_country.name.finland", fallback: "Finland") - /// France - internal static let france = L10n.tr("Localizable", "enum.browsing_country.name.france", fallback: "France") - /// French Guiana - internal static let frenchGuiana = L10n.tr("Localizable", "enum.browsing_country.name.french_guiana", fallback: "French Guiana") - /// French Polynesia - internal static let frenchPolynesia = L10n.tr("Localizable", "enum.browsing_country.name.french_polynesia", fallback: "French Polynesia") - /// French Southern Territories - internal static let frenchSouthernTerritories = L10n.tr("Localizable", "enum.browsing_country.name.french_southern_territories", fallback: "French Southern Territories") - /// Gabon - internal static let gabon = L10n.tr("Localizable", "enum.browsing_country.name.gabon", fallback: "Gabon") - /// Gambia - internal static let gambia = L10n.tr("Localizable", "enum.browsing_country.name.gambia", fallback: "Gambia") - /// Georgia - internal static let georgia = L10n.tr("Localizable", "enum.browsing_country.name.georgia", fallback: "Georgia") - /// Germany - internal static let germany = L10n.tr("Localizable", "enum.browsing_country.name.germany", fallback: "Germany") - /// Ghana - internal static let ghana = L10n.tr("Localizable", "enum.browsing_country.name.ghana", fallback: "Ghana") - /// Gibraltar - internal static let gibraltar = L10n.tr("Localizable", "enum.browsing_country.name.gibraltar", fallback: "Gibraltar") - /// Greece - internal static let greece = L10n.tr("Localizable", "enum.browsing_country.name.greece", fallback: "Greece") - /// Greenland - internal static let greenland = L10n.tr("Localizable", "enum.browsing_country.name.greenland", fallback: "Greenland") - /// Grenada - internal static let grenada = L10n.tr("Localizable", "enum.browsing_country.name.grenada", fallback: "Grenada") - /// Guadeloupe - internal static let guadeloupe = L10n.tr("Localizable", "enum.browsing_country.name.guadeloupe", fallback: "Guadeloupe") - /// Guam - internal static let guam = L10n.tr("Localizable", "enum.browsing_country.name.guam", fallback: "Guam") - /// Guatemala - internal static let guatemala = L10n.tr("Localizable", "enum.browsing_country.name.guatemala", fallback: "Guatemala") - /// Guernsey - internal static let guernsey = L10n.tr("Localizable", "enum.browsing_country.name.guernsey", fallback: "Guernsey") - /// Guinea - internal static let guinea = L10n.tr("Localizable", "enum.browsing_country.name.guinea", fallback: "Guinea") - /// Guinea-Bissau - internal static let guineaBissau = L10n.tr("Localizable", "enum.browsing_country.name.guinea_bissau", fallback: "Guinea-Bissau") - /// Guyana - internal static let guyana = L10n.tr("Localizable", "enum.browsing_country.name.guyana", fallback: "Guyana") - /// Haiti - internal static let haiti = L10n.tr("Localizable", "enum.browsing_country.name.haiti", fallback: "Haiti") - /// Heard Island and McDonald Islands - internal static let heardIslandAndMcDonaldIslands = L10n.tr("Localizable", "enum.browsing_country.name.heard_island_and_mc_donald_islands", fallback: "Heard Island and McDonald Islands") - /// Honduras - internal static let honduras = L10n.tr("Localizable", "enum.browsing_country.name.honduras", fallback: "Honduras") - /// Hong Kong - internal static let hongKong = L10n.tr("Localizable", "enum.browsing_country.name.hong_kong", fallback: "Hong Kong") - /// Hungary - internal static let hungary = L10n.tr("Localizable", "enum.browsing_country.name.hungary", fallback: "Hungary") - /// Iceland - internal static let iceland = L10n.tr("Localizable", "enum.browsing_country.name.iceland", fallback: "Iceland") - /// India - internal static let india = L10n.tr("Localizable", "enum.browsing_country.name.india", fallback: "India") - /// Indonesia - internal static let indonesia = L10n.tr("Localizable", "enum.browsing_country.name.indonesia", fallback: "Indonesia") - /// Iran - internal static let iran = L10n.tr("Localizable", "enum.browsing_country.name.iran", fallback: "Iran") - /// Iraq - internal static let iraq = L10n.tr("Localizable", "enum.browsing_country.name.iraq", fallback: "Iraq") - /// Ireland - internal static let ireland = L10n.tr("Localizable", "enum.browsing_country.name.ireland", fallback: "Ireland") - /// Isle of Man - internal static let isleOfMan = L10n.tr("Localizable", "enum.browsing_country.name.isle_of_man", fallback: "Isle of Man") - /// Israel - internal static let israel = L10n.tr("Localizable", "enum.browsing_country.name.israel", fallback: "Israel") - /// Italy - internal static let italy = L10n.tr("Localizable", "enum.browsing_country.name.italy", fallback: "Italy") - /// Jamaica - internal static let jamaica = L10n.tr("Localizable", "enum.browsing_country.name.jamaica", fallback: "Jamaica") - /// Japan - internal static let japan = L10n.tr("Localizable", "enum.browsing_country.name.japan", fallback: "Japan") - /// Jersey - internal static let jersey = L10n.tr("Localizable", "enum.browsing_country.name.jersey", fallback: "Jersey") - /// Jordan - internal static let jordan = L10n.tr("Localizable", "enum.browsing_country.name.jordan", fallback: "Jordan") - /// Kazakhstan - internal static let kazakhstan = L10n.tr("Localizable", "enum.browsing_country.name.kazakhstan", fallback: "Kazakhstan") - /// Kenya - internal static let kenya = L10n.tr("Localizable", "enum.browsing_country.name.kenya", fallback: "Kenya") - /// Kiribati - internal static let kiribati = L10n.tr("Localizable", "enum.browsing_country.name.kiribati", fallback: "Kiribati") - /// Kuwait - internal static let kuwait = L10n.tr("Localizable", "enum.browsing_country.name.kuwait", fallback: "Kuwait") - /// Kyrgyzstan - internal static let kyrgyzstan = L10n.tr("Localizable", "enum.browsing_country.name.kyrgyzstan", fallback: "Kyrgyzstan") - /// Lao People's Democratic Republic - internal static let laoPeoplesDemocraticRepublic = L10n.tr("Localizable", "enum.browsing_country.name.lao_peoples_democratic_republic", fallback: "Lao People's Democratic Republic") - /// Latvia - internal static let latvia = L10n.tr("Localizable", "enum.browsing_country.name.latvia", fallback: "Latvia") - /// Lebanon - internal static let lebanon = L10n.tr("Localizable", "enum.browsing_country.name.lebanon", fallback: "Lebanon") - /// Lesotho - internal static let lesotho = L10n.tr("Localizable", "enum.browsing_country.name.lesotho", fallback: "Lesotho") - /// Liberia - internal static let liberia = L10n.tr("Localizable", "enum.browsing_country.name.liberia", fallback: "Liberia") - /// Libya - internal static let libya = L10n.tr("Localizable", "enum.browsing_country.name.libya", fallback: "Libya") - /// Liechtenstein - internal static let liechtenstein = L10n.tr("Localizable", "enum.browsing_country.name.liechtenstein", fallback: "Liechtenstein") - /// Lithuania - internal static let lithuania = L10n.tr("Localizable", "enum.browsing_country.name.lithuania", fallback: "Lithuania") - /// Luxembourg - internal static let luxembourg = L10n.tr("Localizable", "enum.browsing_country.name.luxembourg", fallback: "Luxembourg") - /// Macau - internal static let macau = L10n.tr("Localizable", "enum.browsing_country.name.macau", fallback: "Macau") - /// Macedonia - internal static let macedonia = L10n.tr("Localizable", "enum.browsing_country.name.macedonia", fallback: "Macedonia") - /// Madagascar - internal static let madagascar = L10n.tr("Localizable", "enum.browsing_country.name.madagascar", fallback: "Madagascar") - /// Malawi - internal static let malawi = L10n.tr("Localizable", "enum.browsing_country.name.malawi", fallback: "Malawi") - /// Malaysia - internal static let malaysia = L10n.tr("Localizable", "enum.browsing_country.name.malaysia", fallback: "Malaysia") - /// Maldives - internal static let maldives = L10n.tr("Localizable", "enum.browsing_country.name.maldives", fallback: "Maldives") - /// Mali - internal static let mali = L10n.tr("Localizable", "enum.browsing_country.name.mali", fallback: "Mali") - /// Malta - internal static let malta = L10n.tr("Localizable", "enum.browsing_country.name.malta", fallback: "Malta") - /// Marshall Islands - internal static let marshallIslands = L10n.tr("Localizable", "enum.browsing_country.name.marshall_islands", fallback: "Marshall Islands") - /// Martinique - internal static let martinique = L10n.tr("Localizable", "enum.browsing_country.name.martinique", fallback: "Martinique") - /// Mauritania - internal static let mauritania = L10n.tr("Localizable", "enum.browsing_country.name.mauritania", fallback: "Mauritania") - /// Mauritius - internal static let mauritius = L10n.tr("Localizable", "enum.browsing_country.name.mauritius", fallback: "Mauritius") - /// Mayotte - internal static let mayotte = L10n.tr("Localizable", "enum.browsing_country.name.mayotte", fallback: "Mayotte") - /// Mexico - internal static let mexico = L10n.tr("Localizable", "enum.browsing_country.name.mexico", fallback: "Mexico") - /// Micronesia - internal static let micronesia = L10n.tr("Localizable", "enum.browsing_country.name.micronesia", fallback: "Micronesia") - /// Moldova - internal static let moldova = L10n.tr("Localizable", "enum.browsing_country.name.moldova", fallback: "Moldova") - /// Monaco - internal static let monaco = L10n.tr("Localizable", "enum.browsing_country.name.monaco", fallback: "Monaco") - /// Mongolia - internal static let mongolia = L10n.tr("Localizable", "enum.browsing_country.name.mongolia", fallback: "Mongolia") - /// Montenegro - internal static let montenegro = L10n.tr("Localizable", "enum.browsing_country.name.montenegro", fallback: "Montenegro") - /// Montserrat - internal static let montserrat = L10n.tr("Localizable", "enum.browsing_country.name.montserrat", fallback: "Montserrat") - /// Morocco - internal static let morocco = L10n.tr("Localizable", "enum.browsing_country.name.morocco", fallback: "Morocco") - /// Mozambique - internal static let mozambique = L10n.tr("Localizable", "enum.browsing_country.name.mozambique", fallback: "Mozambique") - /// Myanmar - internal static let myanmar = L10n.tr("Localizable", "enum.browsing_country.name.myanmar", fallback: "Myanmar") - /// Namibia - internal static let namibia = L10n.tr("Localizable", "enum.browsing_country.name.namibia", fallback: "Namibia") - /// Nauru - internal static let nauru = L10n.tr("Localizable", "enum.browsing_country.name.nauru", fallback: "Nauru") - /// Nepal - internal static let nepal = L10n.tr("Localizable", "enum.browsing_country.name.nepal", fallback: "Nepal") - /// Netherlands - internal static let netherlands = L10n.tr("Localizable", "enum.browsing_country.name.netherlands", fallback: "Netherlands") - /// New Caledonia - internal static let newCaledonia = L10n.tr("Localizable", "enum.browsing_country.name.new_caledonia", fallback: "New Caledonia") - /// New Zealand - internal static let newZealand = L10n.tr("Localizable", "enum.browsing_country.name.new_zealand", fallback: "New Zealand") - /// Nicaragua - internal static let nicaragua = L10n.tr("Localizable", "enum.browsing_country.name.nicaragua", fallback: "Nicaragua") - /// Niger - internal static let niger = L10n.tr("Localizable", "enum.browsing_country.name.niger", fallback: "Niger") - /// Nigeria - internal static let nigeria = L10n.tr("Localizable", "enum.browsing_country.name.nigeria", fallback: "Nigeria") - /// Niue - internal static let niue = L10n.tr("Localizable", "enum.browsing_country.name.niue", fallback: "Niue") - /// Norfolk Island - internal static let norfolkIsland = L10n.tr("Localizable", "enum.browsing_country.name.norfolk_island", fallback: "Norfolk Island") - /// North Korea - internal static let northKorea = L10n.tr("Localizable", "enum.browsing_country.name.north_korea", fallback: "North Korea") - /// Northern Mariana Islands - internal static let northernMarianaIslands = L10n.tr("Localizable", "enum.browsing_country.name.northern_mariana_islands", fallback: "Northern Mariana Islands") - /// Norway - internal static let norway = L10n.tr("Localizable", "enum.browsing_country.name.norway", fallback: "Norway") - /// Oman - internal static let oman = L10n.tr("Localizable", "enum.browsing_country.name.oman", fallback: "Oman") - /// Pakistan - internal static let pakistan = L10n.tr("Localizable", "enum.browsing_country.name.pakistan", fallback: "Pakistan") - /// Palau - internal static let palau = L10n.tr("Localizable", "enum.browsing_country.name.palau", fallback: "Palau") - /// Palestinian Territory - internal static let palestinianTerritory = L10n.tr("Localizable", "enum.browsing_country.name.palestinian_territory", fallback: "Palestinian Territory") - /// Panama - internal static let panama = L10n.tr("Localizable", "enum.browsing_country.name.panama", fallback: "Panama") - /// Papua New Guinea - internal static let papuaNewGuinea = L10n.tr("Localizable", "enum.browsing_country.name.papua_new_guinea", fallback: "Papua New Guinea") - /// Paraguay - internal static let paraguay = L10n.tr("Localizable", "enum.browsing_country.name.paraguay", fallback: "Paraguay") - /// Peru - internal static let peru = L10n.tr("Localizable", "enum.browsing_country.name.peru", fallback: "Peru") - /// Philippines - internal static let philippines = L10n.tr("Localizable", "enum.browsing_country.name.philippines", fallback: "Philippines") - /// Pitcairn Islands - internal static let pitcairnIslands = L10n.tr("Localizable", "enum.browsing_country.name.pitcairn_islands", fallback: "Pitcairn Islands") - /// Poland - internal static let poland = L10n.tr("Localizable", "enum.browsing_country.name.poland", fallback: "Poland") - /// Portugal - internal static let portugal = L10n.tr("Localizable", "enum.browsing_country.name.portugal", fallback: "Portugal") - /// Puerto Rico - internal static let puertoRico = L10n.tr("Localizable", "enum.browsing_country.name.puerto_rico", fallback: "Puerto Rico") - /// Qatar - internal static let qatar = L10n.tr("Localizable", "enum.browsing_country.name.qatar", fallback: "Qatar") - /// Reunion - internal static let reunion = L10n.tr("Localizable", "enum.browsing_country.name.reunion", fallback: "Reunion") - /// Romania - internal static let romania = L10n.tr("Localizable", "enum.browsing_country.name.romania", fallback: "Romania") - /// Russian Federation - internal static let russianFederation = L10n.tr("Localizable", "enum.browsing_country.name.russian_federation", fallback: "Russian Federation") - /// Rwanda - internal static let rwanda = L10n.tr("Localizable", "enum.browsing_country.name.rwanda", fallback: "Rwanda") - /// Saint Barthelemy - internal static let saintBarthelemy = L10n.tr("Localizable", "enum.browsing_country.name.saint_barthelemy", fallback: "Saint Barthelemy") - /// Saint Helena - internal static let saintHelena = L10n.tr("Localizable", "enum.browsing_country.name.saint_helena", fallback: "Saint Helena") - /// Saint Kitts and Nevis - internal static let saintKittsAndNevis = L10n.tr("Localizable", "enum.browsing_country.name.saint_kitts_and_nevis", fallback: "Saint Kitts and Nevis") - /// Saint Lucia - internal static let saintLucia = L10n.tr("Localizable", "enum.browsing_country.name.saint_lucia", fallback: "Saint Lucia") - /// Saint Martin - internal static let saintMartin = L10n.tr("Localizable", "enum.browsing_country.name.saint_martin", fallback: "Saint Martin") - /// Saint Pierre and Miquelon - internal static let saintPierreAndMiquelon = L10n.tr("Localizable", "enum.browsing_country.name.saint_pierre_and_miquelon", fallback: "Saint Pierre and Miquelon") - /// Saint Vincent and the Grenadines - internal static let saintVincentAndTheGrenadines = L10n.tr("Localizable", "enum.browsing_country.name.saint_vincent_and_the_grenadines", fallback: "Saint Vincent and the Grenadines") - /// Samoa - internal static let samoa = L10n.tr("Localizable", "enum.browsing_country.name.samoa", fallback: "Samoa") - /// San Marino - internal static let sanMarino = L10n.tr("Localizable", "enum.browsing_country.name.san_marino", fallback: "San Marino") - /// Sao Tome and Principe - internal static let saoTomeAndPrincipe = L10n.tr("Localizable", "enum.browsing_country.name.sao_tome_and_principe", fallback: "Sao Tome and Principe") - /// Saudi Arabia - internal static let saudiArabia = L10n.tr("Localizable", "enum.browsing_country.name.saudi_arabia", fallback: "Saudi Arabia") - /// Senegal - internal static let senegal = L10n.tr("Localizable", "enum.browsing_country.name.senegal", fallback: "Senegal") - /// Serbia - internal static let serbia = L10n.tr("Localizable", "enum.browsing_country.name.serbia", fallback: "Serbia") - /// Seychelles - internal static let seychelles = L10n.tr("Localizable", "enum.browsing_country.name.seychelles", fallback: "Seychelles") - /// Sierra Leone - internal static let sierraLeone = L10n.tr("Localizable", "enum.browsing_country.name.sierra_leone", fallback: "Sierra Leone") - /// Singapore - internal static let singapore = L10n.tr("Localizable", "enum.browsing_country.name.singapore", fallback: "Singapore") - /// Sint Maarten - internal static let sintMaarten = L10n.tr("Localizable", "enum.browsing_country.name.sint_maarten", fallback: "Sint Maarten") - /// Slovakia - internal static let slovakia = L10n.tr("Localizable", "enum.browsing_country.name.slovakia", fallback: "Slovakia") - /// Slovenia - internal static let slovenia = L10n.tr("Localizable", "enum.browsing_country.name.slovenia", fallback: "Slovenia") - /// Solomon Islands - internal static let solomonIslands = L10n.tr("Localizable", "enum.browsing_country.name.solomon_islands", fallback: "Solomon Islands") - /// Somalia - internal static let somalia = L10n.tr("Localizable", "enum.browsing_country.name.somalia", fallback: "Somalia") - /// South Africa - internal static let southAfrica = L10n.tr("Localizable", "enum.browsing_country.name.south_africa", fallback: "South Africa") - /// South Georgia and the South Sandwich Islands - internal static let southGeorgiaAndTheSouthSandwichIslands = L10n.tr("Localizable", "enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands", fallback: "South Georgia and the South Sandwich Islands") - /// South Korea - internal static let southKorea = L10n.tr("Localizable", "enum.browsing_country.name.south_korea", fallback: "South Korea") - /// South Sudan - internal static let southSudan = L10n.tr("Localizable", "enum.browsing_country.name.south_sudan", fallback: "South Sudan") - /// Spain - internal static let spain = L10n.tr("Localizable", "enum.browsing_country.name.spain", fallback: "Spain") - /// Sri Lanka - internal static let sriLanka = L10n.tr("Localizable", "enum.browsing_country.name.sri_lanka", fallback: "Sri Lanka") - /// Sudan - internal static let sudan = L10n.tr("Localizable", "enum.browsing_country.name.sudan", fallback: "Sudan") - /// Suriname - internal static let suriname = L10n.tr("Localizable", "enum.browsing_country.name.suriname", fallback: "Suriname") - /// Svalbard and Jan Mayen - internal static let svalbardAndJanMayen = L10n.tr("Localizable", "enum.browsing_country.name.svalbard_and_jan_mayen", fallback: "Svalbard and Jan Mayen") - /// Swaziland - internal static let swaziland = L10n.tr("Localizable", "enum.browsing_country.name.swaziland", fallback: "Swaziland") - /// Sweden - internal static let sweden = L10n.tr("Localizable", "enum.browsing_country.name.sweden", fallback: "Sweden") - /// Switzerland - internal static let switzerland = L10n.tr("Localizable", "enum.browsing_country.name.switzerland", fallback: "Switzerland") - /// Syrian Arab Republic - internal static let syrianArabRepublic = L10n.tr("Localizable", "enum.browsing_country.name.syrian_arab_republic", fallback: "Syrian Arab Republic") - /// Taiwan - internal static let taiwan = L10n.tr("Localizable", "enum.browsing_country.name.taiwan", fallback: "Taiwan") - /// Tajikistan - internal static let tajikistan = L10n.tr("Localizable", "enum.browsing_country.name.tajikistan", fallback: "Tajikistan") - /// Tanzania - internal static let tanzania = L10n.tr("Localizable", "enum.browsing_country.name.tanzania", fallback: "Tanzania") - /// Thailand - internal static let thailand = L10n.tr("Localizable", "enum.browsing_country.name.thailand", fallback: "Thailand") - /// The Democratic Republic of the Congo - internal static let theDemocraticRepublicOfTheCongo = L10n.tr("Localizable", "enum.browsing_country.name.the_democratic_republic_of_the_congo", fallback: "The Democratic Republic of the Congo") - /// Timor-Leste - internal static let timorLeste = L10n.tr("Localizable", "enum.browsing_country.name.timor_leste", fallback: "Timor-Leste") - /// Togo - internal static let togo = L10n.tr("Localizable", "enum.browsing_country.name.togo", fallback: "Togo") - /// Tokelau - internal static let tokelau = L10n.tr("Localizable", "enum.browsing_country.name.tokelau", fallback: "Tokelau") - /// Tonga - internal static let tonga = L10n.tr("Localizable", "enum.browsing_country.name.tonga", fallback: "Tonga") - /// Trinidad and Tobago - internal static let trinidadAndTobago = L10n.tr("Localizable", "enum.browsing_country.name.trinidad_and_tobago", fallback: "Trinidad and Tobago") - /// Tunisia - internal static let tunisia = L10n.tr("Localizable", "enum.browsing_country.name.tunisia", fallback: "Tunisia") - /// Turkey - internal static let turkey = L10n.tr("Localizable", "enum.browsing_country.name.turkey", fallback: "Turkey") - /// Turkmenistan - internal static let turkmenistan = L10n.tr("Localizable", "enum.browsing_country.name.turkmenistan", fallback: "Turkmenistan") - /// Turks and Caicos Islands - internal static let turksAndCaicosIslands = L10n.tr("Localizable", "enum.browsing_country.name.turks_and_caicos_islands", fallback: "Turks and Caicos Islands") - /// Tuvalu - internal static let tuvalu = L10n.tr("Localizable", "enum.browsing_country.name.tuvalu", fallback: "Tuvalu") - /// Uganda - internal static let uganda = L10n.tr("Localizable", "enum.browsing_country.name.uganda", fallback: "Uganda") - /// Ukraine - internal static let ukraine = L10n.tr("Localizable", "enum.browsing_country.name.ukraine", fallback: "Ukraine") - /// United Arab Emirates - internal static let unitedArabEmirates = L10n.tr("Localizable", "enum.browsing_country.name.united_arab_emirates", fallback: "United Arab Emirates") - /// United Kingdom - internal static let unitedKingdom = L10n.tr("Localizable", "enum.browsing_country.name.united_kingdom", fallback: "United Kingdom") - /// United States - internal static let unitedStates = L10n.tr("Localizable", "enum.browsing_country.name.united_states", fallback: "United States") - /// United States Minor Outlying Islands - internal static let unitedStatesMinorOutlyingIslands = L10n.tr("Localizable", "enum.browsing_country.name.united_states_minor_outlying_islands", fallback: "United States Minor Outlying Islands") - /// Uruguay - internal static let uruguay = L10n.tr("Localizable", "enum.browsing_country.name.uruguay", fallback: "Uruguay") - /// Uzbekistan - internal static let uzbekistan = L10n.tr("Localizable", "enum.browsing_country.name.uzbekistan", fallback: "Uzbekistan") - /// Vanuatu - internal static let vanuatu = L10n.tr("Localizable", "enum.browsing_country.name.vanuatu", fallback: "Vanuatu") - /// Vatican City State - internal static let vaticanCityState = L10n.tr("Localizable", "enum.browsing_country.name.vatican_city_state", fallback: "Vatican City State") - /// Venezuela - internal static let venezuela = L10n.tr("Localizable", "enum.browsing_country.name.venezuela", fallback: "Venezuela") - /// Vietnam - internal static let vietnam = L10n.tr("Localizable", "enum.browsing_country.name.vietnam", fallback: "Vietnam") - /// British Virgin Islands - internal static let virginIslandsBritish = L10n.tr("Localizable", "enum.browsing_country.name.virgin_islands_british", fallback: "British Virgin Islands") - /// U.S. Virgin Islands - internal static let virginIslandsUS = L10n.tr("Localizable", "enum.browsing_country.name.virgin_islands_US", fallback: "U.S. Virgin Islands") - /// Wallis and Futuna - internal static let wallisAndFutuna = L10n.tr("Localizable", "enum.browsing_country.name.wallis_and_futuna", fallback: "Wallis and Futuna") - /// Western Sahara - internal static let westernSahara = L10n.tr("Localizable", "enum.browsing_country.name.western_sahara", fallback: "Western Sahara") - /// Yemen - internal static let yemen = L10n.tr("Localizable", "enum.browsing_country.name.yemen", fallback: "Yemen") - /// Zambia - internal static let zambia = L10n.tr("Localizable", "enum.browsing_country.name.zambia", fallback: "Zambia") - /// Zimbabwe - internal static let zimbabwe = L10n.tr("Localizable", "enum.browsing_country.name.zimbabwe", fallback: "Zimbabwe") - } - } - internal enum Category { - internal enum Value { - /// Artist CG - internal static let artistCG = L10n.tr("Localizable", "enum.category.value.artist_CG", fallback: "Artist CG") - /// Asian Porn - internal static let asianPorn = L10n.tr("Localizable", "enum.category.value.asian_porn", fallback: "Asian Porn") - /// Cosplay - internal static let cosplay = L10n.tr("Localizable", "enum.category.value.cosplay", fallback: "Cosplay") - /// Doujinshi - internal static let doujinshi = L10n.tr("Localizable", "enum.category.value.doujinshi", fallback: "Doujinshi") - /// Game CG - internal static let gameCG = L10n.tr("Localizable", "enum.category.value.game_CG", fallback: "Game CG") - /// Image Set - internal static let imageSet = L10n.tr("Localizable", "enum.category.value.image_set", fallback: "Image Set") - /// Manga - internal static let manga = L10n.tr("Localizable", "enum.category.value.manga", fallback: "Manga") - /// Misc - internal static let misc = L10n.tr("Localizable", "enum.category.value.misc", fallback: "Misc") - /// Non-H - internal static let nonH = L10n.tr("Localizable", "enum.category.value.non_h", fallback: "Non-H") - /// Private - internal static let `private` = L10n.tr("Localizable", "enum.category.value.private", fallback: "Private") - /// Western - internal static let western = L10n.tr("Localizable", "enum.category.value.western", fallback: "Western") - } - } - internal enum DownloadFolderFilter { - internal enum Title { - /// All - internal static let all = L10n.tr("Localizable", "enum.download_folder_filter.title.all", fallback: "All") - } - } - internal enum EhSetting { - internal enum ArchiverBehavior { - internal enum Value { - /// Auto Select Original, Auto Start - internal static let autoSelectOriginalAutoStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start", fallback: "Auto Select Original, Auto Start") - /// Auto Select Original, Manual Start - internal static let autoSelectOriginalManualStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start", fallback: "Auto Select Original, Manual Start") - /// Auto Select Resample, Auto Start - internal static let autoSelectResampleAutoStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start", fallback: "Auto Select Resample, Auto Start") - /// Auto Select Resample, Manual Start - internal static let autoSelectResampleManualStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start", fallback: "Auto Select Resample, Manual Start") - /// Manual Select, Auto Start - internal static let manualSelectAutoStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.manual_select_auto_start", fallback: "Manual Select, Auto Start") - /// Manual Select, Manual Start (Default) - internal static let manualSelectManualStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.manual_select_manual_start", fallback: "Manual Select, Manual Start (Default)") - } - } - internal enum CommentsSortOrder { - internal enum Value { - /// By highest score - internal static let highestScore = L10n.tr("Localizable", "enum.eh_setting.comments_sort_order.value.highest_score", fallback: "By highest score") - /// Oldest comments first - internal static let oldest = L10n.tr("Localizable", "enum.eh_setting.comments_sort_order.value.oldest", fallback: "Oldest comments first") - /// Recent comments first - internal static let recent = L10n.tr("Localizable", "enum.eh_setting.comments_sort_order.value.recent", fallback: "Recent comments first") - } - } - internal enum CommentsVotesShowTiming { - internal enum Value { - /// Always - internal static let always = L10n.tr("Localizable", "enum.eh_setting.comments_votes_show_timing.value.always", fallback: "Always") - /// On score hover or click - internal static let onHoverOrClick = L10n.tr("Localizable", "enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click", fallback: "On score hover or click") - } - } - internal enum DisplayMode { - internal enum Value { - /// Compact - internal static let compact = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.compact", fallback: "Compact") - /// Extended - internal static let extended = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.extended", fallback: "Extended") - /// Minimal - internal static let minimal = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.minimal", fallback: "Minimal") - /// Minimal+ - internal static let minimalPlus = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.minimalPlus", fallback: "Minimal+") - /// Thumbnail - internal static let thumbnail = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.thumbnail", fallback: "Thumbnail") - } - } - internal enum ExcludedLanguagesCategory { - internal enum Value { - /// Original - internal static let original = L10n.tr("Localizable", "enum.eh_setting.excluded_languages_category.value.original", fallback: "Original") - /// Rewrite - internal static let rewrite = L10n.tr("Localizable", "enum.eh_setting.excluded_languages_category.value.rewrite", fallback: "Rewrite") - /// Translated - internal static let translated = L10n.tr("Localizable", "enum.eh_setting.excluded_languages_category.value.translated", fallback: "Translated") - } - } - internal enum FavoritesSortOrder { - internal enum Value { - /// By favorited time - internal static let favoritedTime = L10n.tr("Localizable", "enum.eh_setting.favorites_sort_order.value.favorited_time", fallback: "By favorited time") - /// By last gallery update time - internal static let lastUpdateTime = L10n.tr("Localizable", "enum.eh_setting.favorites_sort_order.value.last_update_time", fallback: "By last gallery update time") - } - } - internal enum GalleryName { - internal enum Value { - /// Default Title - internal static let `default` = L10n.tr("Localizable", "enum.eh_setting.gallery_name.value.default", fallback: "Default Title") - /// Japanese Title (if available) - internal static let japanese = L10n.tr("Localizable", "enum.eh_setting.gallery_name.value.japanese", fallback: "Japanese Title (if available)") - } - } - internal enum GalleryPageNumbering { - internal enum Value { - /// None - internal static let `none` = L10n.tr("Localizable", "enum.eh_setting.gallery_page_numbering.value.none", fallback: "None") - /// Page Number + Name - internal static let pageNumberAndName = L10n.tr("Localizable", "enum.eh_setting.gallery_page_numbering.value.page_number_and_name", fallback: "Page Number + Name") - /// Page Number Only - internal static let pageNumberOnly = L10n.tr("Localizable", "enum.eh_setting.gallery_page_numbering.value.page_number_only", fallback: "Page Number Only") - } - } - internal enum ImageResolution { - internal enum Value { - /// Auto - internal static let auto = L10n.tr("Localizable", "enum.eh_setting.image_resolution.value.auto", fallback: "Auto") - } - } - internal enum LoadThroughHathSetting { - internal enum Description { - /// Recommended. - internal static let anyClient = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.any_client", fallback: "Recommended.") - /// Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports. - internal static let defaultPortOnly = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.default_port_only", fallback: "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports.") - /// Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only. - internal static let legacyNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.legacy_no", fallback: "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only.") - /// Donator only. You will not be able to browse as many pages. Recommended only if having severe problems. - internal static let modernNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.modern_no", fallback: "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems.") - } - internal enum Value { - /// Any client - internal static let anyClient = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.any_client", fallback: "Any client") - /// Default port clients only - internal static let defaultPortOnly = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.default_port_only", fallback: "Default port clients only") - /// No [Legacy/HTTP] - internal static let legacyNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.legacy_no", fallback: "No [Legacy/HTTP]") - /// No [Modern/HTTPS] - internal static let modernNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.modern_no", fallback: "No [Modern/HTTPS]") - } - } - internal enum MultiplePageViewerStyle { - internal enum Value { - /// Align center, always scale - internal static let alignCenterAlwaysScale = L10n.tr("Localizable", "enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale", fallback: "Align center, always scale") - /// Align center, scale if overwidth - internal static let alignCenterScaleIfOverWidth = L10n.tr("Localizable", "enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width", fallback: "Align center, scale if overwidth") - /// Align left, scale if overwidth - internal static let alignLeftScaleIfOverWidth = L10n.tr("Localizable", "enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width", fallback: "Align left, scale if overwidth") - } - } - internal enum TagsSortOrder { - internal enum Value { - /// Alphabetical - internal static let alphabetical = L10n.tr("Localizable", "enum.eh_setting.tags_sort_order.value.alphabetical", fallback: "Alphabetical") - /// By tag power - internal static let tagPower = L10n.tr("Localizable", "enum.eh_setting.tags_sort_order.value.tag_power", fallback: "By tag power") - } - } - internal enum ThumbnailLoadTiming { - internal enum Description { - /// Pages load faster, but there may be a slight delay before a thumb appears. - internal static let onMouseOver = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.description.on_mouse_over", fallback: "Pages load faster, but there may be a slight delay before a thumb appears.") - /// Pages take longer to load, but there is no delay for loading a thumb after the page has loaded. - internal static let onPageLoad = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.description.on_page_load", fallback: "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded.") - } - internal enum Value { - /// On mouse-over - internal static let onMouseOver = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.value.on_mouse_over", fallback: "On mouse-over") - /// On page load - internal static let onPageLoad = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.value.on_page_load", fallback: "On page load") - } - } - internal enum ThumbnailSize { - internal enum Value { - /// Auto - internal static let auto = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.auto", fallback: "Auto") - /// Large - internal static let large = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.large", fallback: "Large") - /// Normal - internal static let normal = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.normal", fallback: "Normal") - /// Small - internal static let small = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.small", fallback: "Small") - } - } - } - internal enum FilterRange { - internal enum Value { - /// Global - internal static let global = L10n.tr("Localizable", "enum.filter_range.value.global", fallback: "Global") - /// Search - internal static let search = L10n.tr("Localizable", "enum.filter_range.value.search", fallback: "Search") - /// Watched - internal static let watched = L10n.tr("Localizable", "enum.filter_range.value.watched", fallback: "Watched") - } - } - internal enum GalleryVisibility { - internal enum Value { - /// No (%@) - internal static func no(_ p1: Any) -> String { - return L10n.tr("Localizable", "enum.gallery_visibility.value.no", String(describing: p1), fallback: "No (%@)") - } - /// Yes - internal static let yes = L10n.tr("Localizable", "enum.gallery_visibility.value.yes", fallback: "Yes") - internal enum No { - internal enum Reason { - /// Expunged - internal static let expunged = L10n.tr("Localizable", "enum.gallery_visibility.value.no.reason.expunged", fallback: "Expunged") - } - } - } - } - internal enum HomeMiscGridType { - internal enum Title { - /// History - internal static let history = L10n.tr("Localizable", "enum.home_misc_grid_type.title.history", fallback: "History") - /// Popular - internal static let popular = L10n.tr("Localizable", "enum.home_misc_grid_type.title.popular", fallback: "Popular") - /// Watched - internal static let watched = L10n.tr("Localizable", "enum.home_misc_grid_type.title.watched", fallback: "Watched") - } - } - internal enum Language { - internal enum Value { - /// Afrikaans - internal static let afrikaans = L10n.tr("Localizable", "enum.language.value.afrikaans", fallback: "Afrikaans") - /// Albanian - internal static let albanian = L10n.tr("Localizable", "enum.language.value.albanian", fallback: "Albanian") - /// Arabic - internal static let arabic = L10n.tr("Localizable", "enum.language.value.arabic", fallback: "Arabic") - /// Bengali - internal static let bengali = L10n.tr("Localizable", "enum.language.value.bengali", fallback: "Bengali") - /// Bosnian - internal static let bosnian = L10n.tr("Localizable", "enum.language.value.bosnian", fallback: "Bosnian") - /// Bulgarian - internal static let bulgarian = L10n.tr("Localizable", "enum.language.value.bulgarian", fallback: "Bulgarian") - /// Burmese - internal static let burmese = L10n.tr("Localizable", "enum.language.value.burmese", fallback: "Burmese") - /// Catalan - internal static let catalan = L10n.tr("Localizable", "enum.language.value.catalan", fallback: "Catalan") - /// Cebuano - internal static let cebuano = L10n.tr("Localizable", "enum.language.value.cebuano", fallback: "Cebuano") - /// Chinese - internal static let chinese = L10n.tr("Localizable", "enum.language.value.chinese", fallback: "Chinese") - /// Croatian - internal static let croatian = L10n.tr("Localizable", "enum.language.value.croatian", fallback: "Croatian") - /// Czech - internal static let czech = L10n.tr("Localizable", "enum.language.value.czech", fallback: "Czech") - /// Danish - internal static let danish = L10n.tr("Localizable", "enum.language.value.danish", fallback: "Danish") - /// Dutch - internal static let dutch = L10n.tr("Localizable", "enum.language.value.dutch", fallback: "Dutch") - /// English - internal static let english = L10n.tr("Localizable", "enum.language.value.english", fallback: "English") - /// Esperanto - internal static let esperanto = L10n.tr("Localizable", "enum.language.value.esperanto", fallback: "Esperanto") - /// Estonian - internal static let estonian = L10n.tr("Localizable", "enum.language.value.estonian", fallback: "Estonian") - /// Finnish - internal static let finnish = L10n.tr("Localizable", "enum.language.value.finnish", fallback: "Finnish") - /// French - internal static let french = L10n.tr("Localizable", "enum.language.value.french", fallback: "French") - /// Georgian - internal static let georgian = L10n.tr("Localizable", "enum.language.value.georgian", fallback: "Georgian") - /// German - internal static let german = L10n.tr("Localizable", "enum.language.value.german", fallback: "German") - /// Greek - internal static let greek = L10n.tr("Localizable", "enum.language.value.greek", fallback: "Greek") - /// Hebrew - internal static let hebrew = L10n.tr("Localizable", "enum.language.value.hebrew", fallback: "Hebrew") - /// Hindi - internal static let hindi = L10n.tr("Localizable", "enum.language.value.hindi", fallback: "Hindi") - /// Hmong - internal static let hmong = L10n.tr("Localizable", "enum.language.value.hmong", fallback: "Hmong") - /// Hungarian - internal static let hungarian = L10n.tr("Localizable", "enum.language.value.hungarian", fallback: "Hungarian") - /// Indonesian - internal static let indonesian = L10n.tr("Localizable", "enum.language.value.indonesian", fallback: "Indonesian") - /// N/A - internal static let invalid = L10n.tr("Localizable", "enum.language.value.invalid", fallback: "N/A") - /// Italian - internal static let italian = L10n.tr("Localizable", "enum.language.value.italian", fallback: "Italian") - /// Japanese - internal static let japanese = L10n.tr("Localizable", "enum.language.value.japanese", fallback: "Japanese") - /// Kazakh - internal static let kazakh = L10n.tr("Localizable", "enum.language.value.kazakh", fallback: "Kazakh") - /// Khmer - internal static let khmer = L10n.tr("Localizable", "enum.language.value.khmer", fallback: "Khmer") - /// Korean - internal static let korean = L10n.tr("Localizable", "enum.language.value.korean", fallback: "Korean") - /// Kurdish - internal static let kurdish = L10n.tr("Localizable", "enum.language.value.kurdish", fallback: "Kurdish") - /// Lao - internal static let lao = L10n.tr("Localizable", "enum.language.value.lao", fallback: "Lao") - /// Latin - internal static let latin = L10n.tr("Localizable", "enum.language.value.latin", fallback: "Latin") - /// Mongolian - internal static let mongolian = L10n.tr("Localizable", "enum.language.value.mongolian", fallback: "Mongolian") - /// Ndebele - internal static let ndebele = L10n.tr("Localizable", "enum.language.value.ndebele", fallback: "Ndebele") - /// Nepali - internal static let nepali = L10n.tr("Localizable", "enum.language.value.nepali", fallback: "Nepali") - /// Norwegian - internal static let norwegian = L10n.tr("Localizable", "enum.language.value.norwegian", fallback: "Norwegian") - /// Oromo - internal static let oromo = L10n.tr("Localizable", "enum.language.value.oromo", fallback: "Oromo") - /// Other - internal static let other = L10n.tr("Localizable", "enum.language.value.other", fallback: "Other") - /// Pashto - internal static let pashto = L10n.tr("Localizable", "enum.language.value.pashto", fallback: "Pashto") - /// Persian - internal static let persian = L10n.tr("Localizable", "enum.language.value.persian", fallback: "Persian") - /// Polish - internal static let polish = L10n.tr("Localizable", "enum.language.value.polish", fallback: "Polish") - /// Portuguese - internal static let portuguese = L10n.tr("Localizable", "enum.language.value.portuguese", fallback: "Portuguese") - /// Punjabi - internal static let punjabi = L10n.tr("Localizable", "enum.language.value.punjabi", fallback: "Punjabi") - /// Romanian - internal static let romanian = L10n.tr("Localizable", "enum.language.value.romanian", fallback: "Romanian") - /// Russian - internal static let russian = L10n.tr("Localizable", "enum.language.value.russian", fallback: "Russian") - /// Sango - internal static let sango = L10n.tr("Localizable", "enum.language.value.sango", fallback: "Sango") - /// Serbian - internal static let serbian = L10n.tr("Localizable", "enum.language.value.serbian", fallback: "Serbian") - /// Shona - internal static let shona = L10n.tr("Localizable", "enum.language.value.shona", fallback: "Shona") - /// Slovak - internal static let slovak = L10n.tr("Localizable", "enum.language.value.slovak", fallback: "Slovak") - /// Slovenian - internal static let slovenian = L10n.tr("Localizable", "enum.language.value.slovenian", fallback: "Slovenian") - /// Somali - internal static let somali = L10n.tr("Localizable", "enum.language.value.somali", fallback: "Somali") - /// Spanish - internal static let spanish = L10n.tr("Localizable", "enum.language.value.spanish", fallback: "Spanish") - /// Swahili - internal static let swahili = L10n.tr("Localizable", "enum.language.value.swahili", fallback: "Swahili") - /// Swedish - internal static let swedish = L10n.tr("Localizable", "enum.language.value.swedish", fallback: "Swedish") - /// Tagalog - internal static let tagalog = L10n.tr("Localizable", "enum.language.value.tagalog", fallback: "Tagalog") - /// Thai - internal static let thai = L10n.tr("Localizable", "enum.language.value.thai", fallback: "Thai") - /// Tigrinya - internal static let tigrinya = L10n.tr("Localizable", "enum.language.value.tigrinya", fallback: "Tigrinya") - /// Turkish - internal static let turkish = L10n.tr("Localizable", "enum.language.value.turkish", fallback: "Turkish") - /// Ukrainian - internal static let ukrainian = L10n.tr("Localizable", "enum.language.value.ukrainian", fallback: "Ukrainian") - /// Urdu - internal static let urdu = L10n.tr("Localizable", "enum.language.value.urdu", fallback: "Urdu") - /// Vietnamese - internal static let vietnamese = L10n.tr("Localizable", "enum.language.value.vietnamese", fallback: "Vietnamese") - /// Zulu - internal static let zulu = L10n.tr("Localizable", "enum.language.value.zulu", fallback: "Zulu") - } - } - internal enum ListDisplayMode { - internal enum Value { - /// Detail - internal static let detail = L10n.tr("Localizable", "enum.list_display_mode.value.detail", fallback: "Detail") - /// Thumbnail - internal static let thumbnail = L10n.tr("Localizable", "enum.list_display_mode.value.thumbnail", fallback: "Thumbnail") - } - } - internal enum PreferredColorScheme { - internal enum Value { - /// Automatic - internal static let automatic = L10n.tr("Localizable", "enum.preferred_color_scheme.value.automatic", fallback: "Automatic") - /// Dark - internal static let dark = L10n.tr("Localizable", "enum.preferred_color_scheme.value.dark", fallback: "Dark") - /// Light - internal static let light = L10n.tr("Localizable", "enum.preferred_color_scheme.value.light", fallback: "Light") - } - } - internal enum ReadingDirection { - internal enum Value { - /// Left-to-right - internal static let leftToRight = L10n.tr("Localizable", "enum.reading_direction.value.left_to_right", fallback: "Left-to-right") - /// Right-to-left - internal static let rightToLeft = L10n.tr("Localizable", "enum.reading_direction.value.right_to_left", fallback: "Right-to-left") - /// Vertical - internal static let vertical = L10n.tr("Localizable", "enum.reading_direction.value.vertical", fallback: "Vertical") - } - } - internal enum SettingStateRoute { - internal enum Value { - /// About - internal static let about = L10n.tr("Localizable", "enum.setting_state_route.value.about", fallback: "About") - /// Account - internal static let account = L10n.tr("Localizable", "enum.setting_state_route.value.account", fallback: "Account") - /// Appearance - internal static let appearance = L10n.tr("Localizable", "enum.setting_state_route.value.appearance", fallback: "Appearance") - /// Download - internal static let download = L10n.tr("Localizable", "enum.setting_state_route.value.download", fallback: "Download") - /// General - internal static let general = L10n.tr("Localizable", "enum.setting_state_route.value.general", fallback: "General") - /// Laboratory - internal static let laboratory = L10n.tr("Localizable", "enum.setting_state_route.value.laboratory", fallback: "Laboratory") - /// Reading - internal static let reading = L10n.tr("Localizable", "enum.setting_state_route.value.reading", fallback: "Reading") - } - } - internal enum TagNamespace { - internal enum Value { - /// Artist - internal static let artist = L10n.tr("Localizable", "enum.tag_namespace.value.artist", fallback: "Artist") - /// Character - internal static let character = L10n.tr("Localizable", "enum.tag_namespace.value.character", fallback: "Character") - /// Cosplayer - internal static let cosplayer = L10n.tr("Localizable", "enum.tag_namespace.value.cosplayer", fallback: "Cosplayer") - /// Female - internal static let female = L10n.tr("Localizable", "enum.tag_namespace.value.female", fallback: "Female") - /// Group - internal static let group = L10n.tr("Localizable", "enum.tag_namespace.value.group", fallback: "Group") - /// Language - internal static let language = L10n.tr("Localizable", "enum.tag_namespace.value.language", fallback: "Language") - /// Male - internal static let male = L10n.tr("Localizable", "enum.tag_namespace.value.male", fallback: "Male") - /// Mixed - internal static let mixed = L10n.tr("Localizable", "enum.tag_namespace.value.mixed", fallback: "Mixed") - /// Other - internal static let other = L10n.tr("Localizable", "enum.tag_namespace.value.other", fallback: "Other") - /// Parody - internal static let parody = L10n.tr("Localizable", "enum.tag_namespace.value.parody", fallback: "Parody") - /// Reclass - internal static let reclass = L10n.tr("Localizable", "enum.tag_namespace.value.reclass", fallback: "Reclass") - /// Temp - internal static let temp = L10n.tr("Localizable", "enum.tag_namespace.value.temp", fallback: "Temp") - } - } - internal enum ToplistsType { - internal enum Value { - /// All time - internal static let allTime = L10n.tr("Localizable", "enum.toplists_type.value.all_time", fallback: "All time") - /// Past month - internal static let pastMonth = L10n.tr("Localizable", "enum.toplists_type.value.past_month", fallback: "Past month") - /// Past year - internal static let pastYear = L10n.tr("Localizable", "enum.toplists_type.value.past_year", fallback: "Past year") - /// Yesterday - internal static let yesterday = L10n.tr("Localizable", "enum.toplists_type.value.yesterday", fallback: "Yesterday") - } - } - } - internal enum ErrorView { - internal enum Button { - /// Drop the database - internal static let dropDatabase = L10n.tr("Localizable", "error_view.button.drop_database", fallback: "Drop the database") - /// Retry - internal static let retry = L10n.tr("Localizable", "error_view.button.retry", fallback: "Retry") - } - internal enum Title { - /// This gallery is unavailable due to a copyright claim by %@. Sorry about that. - internal static func copyrightClaim(_ p1: Any) -> String { - return L10n.tr("Localizable", "error_view.title.copyright_claim", String(describing: p1), fallback: "This gallery is unavailable due to a copyright claim by %@. Sorry about that.") - } - /// The database is corrupted. - /// Please submit an issue on GitHub. - internal static let databaseCorrupted = L10n.tr("Localizable", "error_view.title.database_corrupted", fallback: "The database is corrupted.\nPlease submit an issue on GitHub.") - /// This gallery has been removed or is unavailable. - internal static let galleryUnavailable = L10n.tr("Localizable", "error_view.title.gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") - /// Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@. - internal static func ipBanned(_ p1: Any) -> String { - return L10n.tr("Localizable", "error_view.title.ip_banned", String(describing: p1), fallback: "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@.") - } - /// A network error occurred. - internal static let network = L10n.tr("Localizable", "error_view.title.network", fallback: "A network error occurred.") - /// There seems to be nothing here. - internal static let notFound = L10n.tr("Localizable", "error_view.title.not_found", fallback: "There seems to be nothing here.") - /// A parsing error occurred. - internal static let parsing = L10n.tr("Localizable", "error_view.title.parsing", fallback: "A parsing error occurred.") - /// Please try again later. - internal static let tryLater = L10n.tr("Localizable", "error_view.title.try_later", fallback: "Please try again later.") - /// An unknown error occurred. - internal static let unknown = L10n.tr("Localizable", "error_view.title.unknown", fallback: "An unknown error occurred.") - } - } - internal enum FavoritesView { - internal enum Title { - /// Favorites - internal static let favorites = L10n.tr("Localizable", "favorites_view.title.favorites", fallback: "Favorites") - } - } - internal enum FiltersView { - internal enum Button { - /// Reset filters - internal static let resetFilters = L10n.tr("Localizable", "filters_view.button.reset_filters", fallback: "Reset filters") - } - internal enum Section { - internal enum Title { - /// Advanced - internal static let advanced = L10n.tr("Localizable", "filters_view.section.title.advanced", fallback: "Advanced") - /// Default filter - internal static let defaultFilter = L10n.tr("Localizable", "filters_view.section.title.default_filter", fallback: "Default filter") - } - } - internal enum Title { - /// Advanced settings - internal static let advancedSettings = L10n.tr("Localizable", "filters_view.title.advanced_settings", fallback: "Advanced settings") - /// Disable language filter - internal static let disableLanguageFilter = L10n.tr("Localizable", "filters_view.title.disable_language_filter", fallback: "Disable language filter") - /// Disable tags filter - internal static let disableTagsFilter = L10n.tr("Localizable", "filters_view.title.disable_tags_filter", fallback: "Disable tags filter") - /// Disable uploader filter - internal static let disableUploaderFilter = L10n.tr("Localizable", "filters_view.title.disable_uploader_filter", fallback: "Disable uploader filter") - /// Filters - internal static let filters = L10n.tr("Localizable", "filters_view.title.filters", fallback: "Filters") - /// Minimum rating - internal static let minimumRating = L10n.tr("Localizable", "filters_view.title.minimum_rating", fallback: "Minimum rating") - /// Only show galleries with torrents - internal static let onlyShowGalleriesWithTorrents = L10n.tr("Localizable", "filters_view.title.only_show_galleries_with_torrents", fallback: "Only show galleries with torrents") - /// Pages range - internal static let pagesRange = L10n.tr("Localizable", "filters_view.title.pages_range", fallback: "Pages range") - /// Search downvoted tags - internal static let searchDownvotedTags = L10n.tr("Localizable", "filters_view.title.search_downvoted_tags", fallback: "Search downvoted tags") - /// Search expunged galleries - internal static let searchExpungedGalleries = L10n.tr("Localizable", "filters_view.title.search_expunged_galleries", fallback: "Search expunged galleries") - /// Search gallery description - internal static let searchGalleryDescription = L10n.tr("Localizable", "filters_view.title.search_gallery_description", fallback: "Search gallery description") - /// Search gallery name - internal static let searchGalleryName = L10n.tr("Localizable", "filters_view.title.search_gallery_name", fallback: "Search gallery name") - /// Search gallery tags - internal static let searchGalleryTags = L10n.tr("Localizable", "filters_view.title.search_gallery_tags", fallback: "Search gallery tags") - /// Search Low-Power tags - internal static let searchLowPowerTags = L10n.tr("Localizable", "filters_view.title.search_low_power_tags", fallback: "Search Low-Power tags") - /// Search torrent filenames - internal static let searchTorrentFilenames = L10n.tr("Localizable", "filters_view.title.search_torrent_filenames", fallback: "Search torrent filenames") - /// Set minimum rating - internal static let setMinimumRating = L10n.tr("Localizable", "filters_view.title.set_minimum_rating", fallback: "Set minimum rating") - /// Set pages range - internal static let setPagesRange = L10n.tr("Localizable", "filters_view.title.set_pages_range", fallback: "Set pages range") - } - } - internal enum FolderManagerView { - internal enum Dialog { - internal enum Message { - /// This will delete the folder and all downloaded galleries inside it. - internal static let deleteFolder = L10n.tr("Localizable", "folder_manager_view.dialog.message.delete_folder", fallback: "This will delete the folder and all downloaded galleries inside it.") - } - } - internal enum EmptyState { - /// Folders you create will appear here. - internal static let folders = L10n.tr("Localizable", "folder_manager_view.empty_state.folders", fallback: "Folders you create will appear here.") - } - internal enum Placeholder { - /// Folder name - internal static let folderName = L10n.tr("Localizable", "folder_manager_view.placeholder.folder_name", fallback: "Folder name") - } - internal enum Title { - /// Folders - internal static let folders = L10n.tr("Localizable", "folder_manager_view.title.folders", fallback: "Folders") - } - } - internal enum FrontpageView { - internal enum Title { - /// Frontpage - internal static let frontpage = L10n.tr("Localizable", "frontpage_view.title.frontpage", fallback: "Frontpage") - } - } - internal enum GalleryInfosView { - internal enum Title { - /// Archive URL - internal static let archiveURL = L10n.tr("Localizable", "gallery_infos_view.title.archive_URL", fallback: "Archive URL") - /// Average rating - internal static let averageRating = L10n.tr("Localizable", "gallery_infos_view.title.average_rating", fallback: "Average rating") - /// Category - internal static let category = L10n.tr("Localizable", "gallery_infos_view.title.category", fallback: "Category") - /// Cover URL - internal static let coverURL = L10n.tr("Localizable", "gallery_infos_view.title.cover_URL", fallback: "Cover URL") - /// Favorited - internal static let favorited = L10n.tr("Localizable", "gallery_infos_view.title.favorited", fallback: "Favorited") - /// Favorited times - internal static let favoritedTimes = L10n.tr("Localizable", "gallery_infos_view.title.favorited_times", fallback: "Favorited times") - /// File size - internal static let fileSize = L10n.tr("Localizable", "gallery_infos_view.title.file_size", fallback: "File size") - /// Gallery infos - internal static let galleryInfos = L10n.tr("Localizable", "gallery_infos_view.title.gallery_infos", fallback: "Gallery infos") - /// Gallery URL - internal static let galleryURL = L10n.tr("Localizable", "gallery_infos_view.title.gallery_URL", fallback: "Gallery URL") - /// ID - internal static let id = L10n.tr("Localizable", "gallery_infos_view.title.id", fallback: "ID") - /// Japanese title - internal static let japaneseTitle = L10n.tr("Localizable", "gallery_infos_view.title.japanese_title", fallback: "Japanese title") - /// Language - internal static let language = L10n.tr("Localizable", "gallery_infos_view.title.language", fallback: "Language") - /// My rating - internal static let myRating = L10n.tr("Localizable", "gallery_infos_view.title.my_rating", fallback: "My rating") - /// Page count - internal static let pageCount = L10n.tr("Localizable", "gallery_infos_view.title.page_count", fallback: "Page count") - /// Parent URL - internal static let parentURL = L10n.tr("Localizable", "gallery_infos_view.title.parent_URL", fallback: "Parent URL") - /// Posted date - internal static let postedDate = L10n.tr("Localizable", "gallery_infos_view.title.posted_date", fallback: "Posted date") - /// Rating count - internal static let ratingCount = L10n.tr("Localizable", "gallery_infos_view.title.rating_count", fallback: "Rating count") - /// Title - internal static let title = L10n.tr("Localizable", "gallery_infos_view.title.title", fallback: "Title") - /// Token - internal static let token = L10n.tr("Localizable", "gallery_infos_view.title.token", fallback: "Token") - /// Torrent count - internal static let torrentCount = L10n.tr("Localizable", "gallery_infos_view.title.torrent_count", fallback: "Torrent count") - /// Torrent URL - internal static let torrentURL = L10n.tr("Localizable", "gallery_infos_view.title.torrent_URL", fallback: "Torrent URL") - /// Uploader - internal static let uploader = L10n.tr("Localizable", "gallery_infos_view.title.uploader", fallback: "Uploader") - /// Visibility - internal static let visibility = L10n.tr("Localizable", "gallery_infos_view.title.visibility", fallback: "Visibility") - } - internal enum Value { - /// No - internal static let no = L10n.tr("Localizable", "gallery_infos_view.value.no", fallback: "No") - /// None - internal static let `none` = L10n.tr("Localizable", "gallery_infos_view.value.none", fallback: "None") - /// Yes - internal static let yes = L10n.tr("Localizable", "gallery_infos_view.value.yes", fallback: "Yes") - } - } - internal enum GeneralSettingView { - internal enum Button { - /// Clear image caches - internal static let clearImageCaches = L10n.tr("Localizable", "general_setting_view.button.clear_image_caches", fallback: "Clear image caches") - /// Import custom translations - internal static let importCustomTranslations = L10n.tr("Localizable", "general_setting_view.button.import_custom_translations", fallback: "Import custom translations") - /// Logs - internal static let logs = L10n.tr("Localizable", "general_setting_view.button.logs", fallback: "Logs") - /// Remove custom translations - internal static let removeCustomTranslations = L10n.tr("Localizable", "general_setting_view.button.remove_custom_translations", fallback: "Remove custom translations") - } - internal enum Section { - internal enum Title { - /// Caches - internal static let caches = L10n.tr("Localizable", "general_setting_view.section.title.caches", fallback: "Caches") - /// Navigation - internal static let navigation = L10n.tr("Localizable", "general_setting_view.section.title.navigation", fallback: "Navigation") - /// Security - internal static let security = L10n.tr("Localizable", "general_setting_view.section.title.security", fallback: "Security") - /// Tags - internal static let tags = L10n.tr("Localizable", "general_setting_view.section.title.tags", fallback: "Tags") - } - } - internal enum Title { - /// Auto-Lock - internal static let autoLock = L10n.tr("Localizable", "general_setting_view.title.auto_lock", fallback: "Auto-Lock") - /// Background blur radius - internal static let backgroundBlurRadius = L10n.tr("Localizable", "general_setting_view.title.background_blur_radius", fallback: "Background blur radius") - /// Detects links from the clipboard - internal static let detectsLinksFromClipboard = L10n.tr("Localizable", "general_setting_view.title.detects_links_from_clipboard", fallback: "Detects links from the clipboard") - /// Enables tags extension - internal static let enablesTagsExtension = L10n.tr("Localizable", "general_setting_view.title.enables_tags_extension", fallback: "Enables tags extension") - /// General - internal static let general = L10n.tr("Localizable", "general_setting_view.title.general", fallback: "General") - /// Language - internal static let language = L10n.tr("Localizable", "general_setting_view.title.language", fallback: "Language") - /// Redirects links to the selected host - internal static let redirectsLinksToTheSelectedHost = L10n.tr("Localizable", "general_setting_view.title.redirects_links_to_the_selected_host", fallback: "Redirects links to the selected host") - /// Shows images in tags - internal static let showsImagesInTags = L10n.tr("Localizable", "general_setting_view.title.shows_images_in_tags", fallback: "Shows images in tags") - /// Shows tags search suggestion - internal static let showsTagsSearchSuggestion = L10n.tr("Localizable", "general_setting_view.title.shows_tags_search_suggestion", fallback: "Shows tags search suggestion") - /// Translates tags - internal static let translatesTags = L10n.tr("Localizable", "general_setting_view.title.translates_tags", fallback: "Translates tags") - } - internal enum Value { - /// N/A - internal static let defaultLanguageDescription = L10n.tr("Localizable", "general_setting_view.value.default_language_description", fallback: "N/A") - } - } - internal enum HistoryView { - internal enum Title { - /// History - internal static let history = L10n.tr("Localizable", "history_view.title.history", fallback: "History") - } - } - internal enum HomeView { - internal enum Section { - internal enum Title { - /// Frontpage - internal static let frontpage = L10n.tr("Localizable", "home_view.section.title.frontpage", fallback: "Frontpage") - /// Other - internal static let other = L10n.tr("Localizable", "home_view.section.title.other", fallback: "Other") - /// Toplists - internal static let toplists = L10n.tr("Localizable", "home_view.section.title.toplists", fallback: "Toplists") - } - } - internal enum Title { - /// Home - internal static let home = L10n.tr("Localizable", "home_view.title.home", fallback: "Home") - } - } - internal enum Hud { - internal enum Caption { - /// Copied to clipboard - internal static let copiedToClipboard = L10n.tr("Localizable", "hud.caption.copied_to_clipboard", fallback: "Copied to clipboard") - /// Saved to photo library - internal static let savedToPhotoLibrary = L10n.tr("Localizable", "hud.caption.saved_to_photo_library", fallback: "Saved to photo library") - } - internal enum Title { - /// Communicating... - internal static let communicating = L10n.tr("Localizable", "hud.title.communicating", fallback: "Communicating...") - /// Error - internal static let error = L10n.tr("Localizable", "hud.title.error", fallback: "Error") - /// Loading... - internal static let loading = L10n.tr("Localizable", "hud.title.loading", fallback: "Loading...") - /// Success - internal static let success = L10n.tr("Localizable", "hud.title.success", fallback: "Success") - } - } - internal enum JumpPageView { - internal enum Button { - /// Confirm - internal static let confirm = L10n.tr("Localizable", "jump_page_view.button.confirm", fallback: "Confirm") - } - internal enum Title { - /// Jump page - internal static let jumpPage = L10n.tr("Localizable", "jump_page_view.title.jump_page", fallback: "Jump page") - } - } - internal enum LaboratorySettingView { - internal enum Title { - /// Bypasses SNI Filtering - internal static let bypassesSNIFiltering = L10n.tr("Localizable", "laboratory_setting_view.title.bypasses_SNI_filtering", fallback: "Bypasses SNI Filtering") - /// Laboratory - internal static let laboratory = L10n.tr("Localizable", "laboratory_setting_view.title.laboratory", fallback: "Laboratory") - } - } - internal enum LoadingView { - internal enum Title { - /// Loading... - internal static let loading = L10n.tr("Localizable", "loading_view.title.loading", fallback: "Loading...") - /// Preparing the database... - internal static let preparingDatabase = L10n.tr("Localizable", "loading_view.title.preparing_database", fallback: "Preparing the database...") - } - } - internal enum LocalAuthorization { - /// The App has been locked due to the Auto-Lock expiration. - internal static let reason = L10n.tr("Localizable", "local_authorization.reason", fallback: "The App has been locked due to the Auto-Lock expiration.") - } - internal enum LoginView { - internal enum Title { - /// Login - internal static let login = L10n.tr("Localizable", "login_view.title.login", fallback: "Login") - /// Password - internal static let password = L10n.tr("Localizable", "login_view.title.password", fallback: "Password") - /// Username - internal static let username = L10n.tr("Localizable", "login_view.title.username", fallback: "Username") - } - } - internal enum LogsView { - internal enum Title { - /// Latest - internal static let latest = L10n.tr("Localizable", "logs_view.title.latest", fallback: "Latest") - /// Logs - internal static let logs = L10n.tr("Localizable", "logs_view.title.logs", fallback: "Logs") - } - } - internal enum NewDawnView { - internal enum Title { - /// It is the dawn of a new day! - internal static let first = L10n.tr("Localizable", "new_dawn_view.title.first", fallback: "It is the dawn of a new day!") - /// Reflecting on your journey so far, you find that you are a little wiser. - internal static let second = L10n.tr("Localizable", "new_dawn_view.title.second", fallback: "Reflecting on your journey so far, you find that you are a little wiser.") - } - } - internal enum NotLoginView { - internal enum Button { - /// Login - internal static let login = L10n.tr("Localizable", "not_login_view.button.login", fallback: "Login") - } - internal enum Title { - /// You need to login to access this feature. - internal static let needLogin = L10n.tr("Localizable", "not_login_view.title.need_login", fallback: "You need to login to access this feature.") - } - } - internal enum PopularView { - internal enum Title { - /// Popular - internal static let popular = L10n.tr("Localizable", "popular_view.title.popular", fallback: "Popular") - } - } - internal enum PostCommentView { - internal enum Title { - /// Edit comment - internal static let editComment = L10n.tr("Localizable", "post_comment_view.title.edit_comment", fallback: "Edit comment") - /// Post comment - internal static let postComment = L10n.tr("Localizable", "post_comment_view.title.post_comment", fallback: "Post comment") - } - } - internal enum PreviewsView { - internal enum Title { - /// Previews - internal static let previews = L10n.tr("Localizable", "previews_view.title.previews", fallback: "Previews") - } - } - internal enum QuickSearchView { - internal enum Placeholder { - /// Optional - internal static let `optional` = L10n.tr("Localizable", "quick_search_view.placeholder.optional", fallback: "Optional") - } - internal enum Title { - /// Content - internal static let content = L10n.tr("Localizable", "quick_search_view.title.content", fallback: "Content") - /// Edit word - internal static let editWord = L10n.tr("Localizable", "quick_search_view.title.edit_word", fallback: "Edit word") - /// Name - internal static let name = L10n.tr("Localizable", "quick_search_view.title.name", fallback: "Name") - /// New word - internal static let newWord = L10n.tr("Localizable", "quick_search_view.title.new_word", fallback: "New word") - /// Quick search - internal static let quickSearch = L10n.tr("Localizable", "quick_search_view.title.quick_search", fallback: "Quick search") - } - } - internal enum ReadingSettingView { - internal enum Section { - internal enum Title { - /// Appearance - internal static let appearance = L10n.tr("Localizable", "reading_setting_view.section.title.appearance", fallback: "Appearance") - } - } - internal enum Title { - /// Direction - internal static let direction = L10n.tr("Localizable", "reading_setting_view.title.direction", fallback: "Direction") - /// Double tap scale factor - internal static let doubleTapScaleFactor = L10n.tr("Localizable", "reading_setting_view.title.double_tap_scale_factor", fallback: "Double tap scale factor") - /// Enables landscape - internal static let enablesLandscape = L10n.tr("Localizable", "reading_setting_view.title.enables_landscape", fallback: "Enables landscape") - /// Maximum scale factor - internal static let maximumScaleFactor = L10n.tr("Localizable", "reading_setting_view.title.maximum_scale_factor", fallback: "Maximum scale factor") - /// Preload limit - internal static let preloadLimit = L10n.tr("Localizable", "reading_setting_view.title.preload_limit", fallback: "Preload limit") - /// Reading - internal static let reading = L10n.tr("Localizable", "reading_setting_view.title.reading", fallback: "Reading") - /// Separator height - internal static let separatorHeight = L10n.tr("Localizable", "reading_setting_view.title.separator_height", fallback: "Separator height") - } - } - internal enum ReadingView { - internal enum ContextMenu { - internal enum Button { - /// Copy - internal static let copy = L10n.tr("Localizable", "reading_view.context_menu.button.copy", fallback: "Copy") - /// Reload - internal static let reload = L10n.tr("Localizable", "reading_view.context_menu.button.reload", fallback: "Reload") - /// Save - internal static let save = L10n.tr("Localizable", "reading_view.context_menu.button.save", fallback: "Save") - /// Save original - internal static let saveOriginal = L10n.tr("Localizable", "reading_view.context_menu.button.save_original", fallback: "Save original") - /// Share - internal static let share = L10n.tr("Localizable", "reading_view.context_menu.button.share", fallback: "Share") - } - } - internal enum ToolbarItem { - internal enum Button { - /// Reading setting - internal static let readingSetting = L10n.tr("Localizable", "reading_view.toolbar_item.button.reading_setting", fallback: "Reading setting") - /// Reload all images - internal static let reloadAllImages = L10n.tr("Localizable", "reading_view.toolbar_item.button.reload_all_images", fallback: "Reload all images") - /// Retry all failed images - internal static let retryAllFailedImages = L10n.tr("Localizable", "reading_view.toolbar_item.button.retry_all_failed_images", fallback: "Retry all failed images") - } - internal enum Title { - /// Auto-Play - internal static let autoPlay = L10n.tr("Localizable", "reading_view.toolbar_item.title.auto_play", fallback: "Auto-Play") - /// Dual-Page mode - internal static let dualPageMode = L10n.tr("Localizable", "reading_view.toolbar_item.title.dual_page_mode", fallback: "Dual-Page mode") - /// Except the cover - internal static let exceptTheCover = L10n.tr("Localizable", "reading_view.toolbar_item.title.except_the_cover", fallback: "Except the cover") - } - } - } - internal enum SearchView { - internal enum Section { - internal enum Title { - /// Quick search - internal static let quickSearch = L10n.tr("Localizable", "search_view.section.title.quick_search", fallback: "Quick search") - /// Recently searched - internal static let recentlySearched = L10n.tr("Localizable", "search_view.section.title.recently_searched", fallback: "Recently searched") - /// Recently seen - internal static let recentlySeen = L10n.tr("Localizable", "search_view.section.title.recently_seen", fallback: "Recently seen") - } - } - internal enum Title { - /// Search - internal static let search = L10n.tr("Localizable", "search_view.title.search", fallback: "Search") - } - } - internal enum Searchable { - internal enum Prompt { - /// Filter - internal static let filter = L10n.tr("Localizable", "searchable.prompt.filter", fallback: "Filter") - } - internal enum Title { - /// Found %d matches. - internal static func matchesCount(_ p1: Int) -> String { - return L10n.tr("Localizable", "searchable.title.matches_count", p1, fallback: "Found %d matches.") - } - } - } - internal enum SettingView { - internal enum Title { - /// Setting - internal static let setting = L10n.tr("Localizable", "setting_view.title.setting", fallback: "Setting") - } - } - internal enum Struct { - internal enum CookieValue { - internal enum LocalizedString { - /// Expired - internal static let expired = L10n.tr("Localizable", "struct.cookie_value.localized_string.expired", fallback: "Expired") - /// Rejected - internal static let mystery = L10n.tr("Localizable", "struct.cookie_value.localized_string.mystery", fallback: "Rejected") - /// None - internal static let `none` = L10n.tr("Localizable", "struct.cookie_value.localized_string.none", fallback: "None") - } - } - internal enum DownloadBadge { - /// %d/%d - internal static func progress(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "struct.download_badge.progress", p1, p2, fallback: "%d/%d") - } - internal enum Text { - /// Downloaded - internal static let downloaded = L10n.tr("Localizable", "struct.download_badge.text.downloaded", fallback: "Downloaded") - /// Downloading - internal static let downloading = L10n.tr("Localizable", "struct.download_badge.text.downloading", fallback: "Downloading") - /// Needs Attention - internal static let needsAttention = L10n.tr("Localizable", "struct.download_badge.text.needs_attention", fallback: "Needs Attention") - /// Needs Repair - internal static let needsRepair = L10n.tr("Localizable", "struct.download_badge.text.needs_repair", fallback: "Needs Repair") - /// Paused - internal static let paused = L10n.tr("Localizable", "struct.download_badge.text.paused", fallback: "Paused") - /// Queued - internal static let queued = L10n.tr("Localizable", "struct.download_badge.text.queued", fallback: "Queued") - /// Update Available - internal static let updateAvailable = L10n.tr("Localizable", "struct.download_badge.text.update_available", fallback: "Update Available") - } - } - internal enum Greeting { - internal enum Mark { - /// and - internal static let and = L10n.tr("Localizable", "struct.greeting.mark.and", fallback: " and ") - /// ! - internal static let end = L10n.tr("Localizable", "struct.greeting.mark.end", fallback: "!") - /// , - internal static let separator = L10n.tr("Localizable", "struct.greeting.mark.separator", fallback: ", ") - /// You gain - internal static let start = L10n.tr("Localizable", "struct.greeting.mark.start", fallback: "You gain ") - } - } - internal enum HathArchive { - internal enum Price { - /// Free - internal static let free = L10n.tr("Localizable", "struct.hath_archive.price.free", fallback: "Free") - /// N/A - internal static let notAvailable = L10n.tr("Localizable", "struct.hath_archive.price.not_available", fallback: "N/A") - } - } - internal enum User { - internal enum FavoriteCategory { - /// All - internal static let all = L10n.tr("Localizable", "struct.user.favorite_category.all", fallback: "All") - /// Favorites %@ - internal static func `default`(_ p1: Any) -> String { - return L10n.tr("Localizable", "struct.user.favorite_category.default", String(describing: p1), fallback: "Favorites %@") - } - } - } - } - internal enum SubSection { - internal enum Button { - /// Show all - internal static let showAll = L10n.tr("Localizable", "sub_section.button.show_all", fallback: "Show all") - } - } - internal enum TabItem { - internal enum Title { - /// Downloads - internal static let downloads = L10n.tr("Localizable", "tab_item.title.downloads", fallback: "Downloads") - /// Favorites - internal static let favorites = L10n.tr("Localizable", "tab_item.title.favorites", fallback: "Favorites") - /// Home - internal static let home = L10n.tr("Localizable", "tab_item.title.home", fallback: "Home") - /// Search - internal static let search = L10n.tr("Localizable", "tab_item.title.search", fallback: "Search") - /// Setting - internal static let setting = L10n.tr("Localizable", "tab_item.title.setting", fallback: "Setting") - } - } - internal enum TagDetailView { - internal enum Section { - internal enum Title { - /// Images - internal static let images = L10n.tr("Localizable", "tag_detail_view.section.title.images", fallback: "Images") - /// Links - internal static let links = L10n.tr("Localizable", "tag_detail_view.section.title.links", fallback: "Links") - } - } - } - internal enum ToolbarItem { - internal enum Button { - /// Seek to date - internal static let dateSeek = L10n.tr("Localizable", "toolbar_item.button.date_seek", fallback: "Seek to date") - /// Filters - internal static let filters = L10n.tr("Localizable", "toolbar_item.button.filters", fallback: "Filters") - /// Jump page - internal static let jumpPage = L10n.tr("Localizable", "toolbar_item.button.jump_page", fallback: "Jump page") - /// Quick search - internal static let quickSearch = L10n.tr("Localizable", "toolbar_item.button.quick_search", fallback: "Quick search") - } - } - internal enum ToplistsView { - internal enum Title { - /// Toplists - internal static let toplists = L10n.tr("Localizable", "toplists_view.title.toplists", fallback: "Toplists") - } - } - internal enum TorrentsView { - internal enum Title { - /// Torrents - internal static let torrents = L10n.tr("Localizable", "torrents_view.title.torrents", fallback: "Torrents") - } - } - internal enum WatchedView { - internal enum Title { - /// Watched - internal static let watched = L10n.tr("Localizable", "watched_view.title.watched", fallback: "Watched") - } - } - internal enum Website { - internal enum Response { - /// You must have a H@H client assigned to your account to use this feature. - internal static let hathClientNotFound = L10n.tr("Localizable", "website.response.hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") - /// Your H@H client appears to be offline. Turn it on, then try again. - internal static let hathClientNotOnline = L10n.tr("Localizable", "website.response.hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") - /// The requested gallery cannot be downloaded with the selected resolution. - internal static let invalidResolution = L10n.tr("Localizable", "website.response.invalid_resolution", fallback: "The requested gallery cannot be downloaded with the selected resolution.") - } - } - } -} -// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length -// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces - -// MARK: - Implementation Details - -extension L10n { - private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { - let format = BundleToken.bundle.localizedString(forKey: key, value: value, table: table) - return String(format: format, locale: Locale.current, arguments: args) - } -} - -// swiftlint:disable convenience_type -private final class BundleToken { - static let bundle: Bundle = { - #if SWIFT_PACKAGE - return Bundle.module - #else - return Bundle(for: BundleToken.self) - #endif - }() -} -// swiftlint:enable convenience_type diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadFolderFilter.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadFolderFilter.swift index 95866f1f1..6ad850bdc 100644 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadFolderFilter.swift +++ b/AppPackage/Sources/AppFeature/Models/Download/DownloadFolderFilter.swift @@ -1,3 +1,5 @@ +import Resources + enum DownloadFolderFilter: Equatable { case all case folder(String) diff --git a/AppPackage/Sources/AppFeature/Models/Gallery/Category.swift b/AppPackage/Sources/AppFeature/Models/Gallery/Category.swift index acacadf0b..1124e0faa 100644 --- a/AppPackage/Sources/AppFeature/Models/Gallery/Category.swift +++ b/AppPackage/Sources/AppFeature/Models/Gallery/Category.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources enum Category: String, Codable, CaseIterable, Identifiable, Sendable { var id: String { rawValue } diff --git a/AppPackage/Sources/AppFeature/Models/Gallery/GalleryArchive.swift b/AppPackage/Sources/AppFeature/Models/Gallery/GalleryArchive.swift index e7cc10799..ba32345dc 100644 --- a/AppPackage/Sources/AppFeature/Models/Gallery/GalleryArchive.swift +++ b/AppPackage/Sources/AppFeature/Models/Gallery/GalleryArchive.swift @@ -1,4 +1,5 @@ import Foundation +import Resources struct GalleryArchive: Codable, Equatable { struct HathArchive: Codable, Identifiable, Equatable { diff --git a/AppPackage/Sources/AppFeature/Models/Gallery/GalleryDetail.swift b/AppPackage/Sources/AppFeature/Models/Gallery/GalleryDetail.swift index 565b65018..f9c9b1e8a 100644 --- a/AppPackage/Sources/AppFeature/Models/Gallery/GalleryDetail.swift +++ b/AppPackage/Sources/AppFeature/Models/Gallery/GalleryDetail.swift @@ -1,4 +1,5 @@ import Foundation +import Resources struct GalleryDetail: Codable, Equatable, Sendable { static let empty: Self = .init( diff --git a/AppPackage/Sources/AppFeature/Models/Gallery/Language.swift b/AppPackage/Sources/AppFeature/Models/Gallery/Language.swift index 267596bda..2fd8c1cbd 100644 --- a/AppPackage/Sources/AppFeature/Models/Gallery/Language.swift +++ b/AppPackage/Sources/AppFeature/Models/Gallery/Language.swift @@ -1,3 +1,5 @@ +import Resources + enum Language: String, Codable, Sendable { static let allExcludedCases: [Self] = [ .japanese, .english, .chinese, .dutch, .french, .german, .hungarian, .italian, diff --git a/AppPackage/Sources/AppFeature/Models/Persistent/Greeting.swift b/AppPackage/Sources/AppFeature/Models/Persistent/Greeting.swift index 201228e8f..24aad0ac3 100644 --- a/AppPackage/Sources/AppFeature/Models/Persistent/Greeting.swift +++ b/AppPackage/Sources/AppFeature/Models/Persistent/Greeting.swift @@ -1,4 +1,5 @@ import Foundation +import Resources struct Greeting: Codable, Equatable, Hashable, Identifiable { static let mock: Self = { diff --git a/AppPackage/Sources/AppFeature/Models/Persistent/Setting.swift b/AppPackage/Sources/AppFeature/Models/Persistent/Setting.swift index 0f2b15bad..c11659a56 100644 --- a/AppPackage/Sources/AppFeature/Models/Persistent/Setting.swift +++ b/AppPackage/Sources/AppFeature/Models/Persistent/Setting.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import Foundation import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/Models/Persistent/User.swift b/AppPackage/Sources/AppFeature/Models/Persistent/User.swift index 27f6e4863..359b08e1d 100644 --- a/AppPackage/Sources/AppFeature/Models/Persistent/User.swift +++ b/AppPackage/Sources/AppFeature/Models/Persistent/User.swift @@ -1,4 +1,5 @@ import Foundation +import Resources struct User: Codable, Equatable { static let empty = User() diff --git a/AppPackage/Sources/AppFeature/Models/Support/AppError.swift b/AppPackage/Sources/AppFeature/Models/Support/AppError.swift index 2ef2eee11..a2ac054d8 100644 --- a/AppPackage/Sources/AppFeature/Models/Support/AppError.swift +++ b/AppPackage/Sources/AppFeature/Models/Support/AppError.swift @@ -1,4 +1,5 @@ import Foundation +import Resources import SFSafeSymbols enum AppError: Error, Identifiable, Equatable, Hashable, Sendable { diff --git a/AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry.swift b/AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry.swift index 4da75454a..38002e347 100644 --- a/AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry.swift +++ b/AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry.swift @@ -1,4 +1,5 @@ import Foundation +import Resources // reason: the exhaustive ISO country list is kept dense, one case per line // swiftlint:disable line_length diff --git a/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Enums.swift b/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Enums.swift index f5ab29243..178ddc2d3 100644 --- a/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Enums.swift +++ b/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Enums.swift @@ -1,3 +1,5 @@ +import Resources + // MARK: CommentsSortOrder extension EhSetting { enum CommentsSortOrder: Int, CaseIterable, Identifiable { diff --git a/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Extensions.swift b/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Extensions.swift index 4e319a6a8..e2360aefc 100644 --- a/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Extensions.swift +++ b/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Extensions.swift @@ -1,3 +1,5 @@ +import Resources + // MARK: ThumbnailLoadTiming extension EhSetting { enum ThumbnailLoadTiming: Int, CaseIterable, Identifiable { diff --git a/AppPackage/Sources/AppFeature/Models/Support/EhSetting.swift b/AppPackage/Sources/AppFeature/Models/Support/EhSetting.swift index aba123d6d..15d7ded24 100644 --- a/AppPackage/Sources/AppFeature/Models/Support/EhSetting.swift +++ b/AppPackage/Sources/AppFeature/Models/Support/EhSetting.swift @@ -1,3 +1,5 @@ +import Resources + // MARK: EhSetting struct EhSetting: Equatable { // swiftlint:disable line_length diff --git a/AppPackage/Sources/AppFeature/Models/Tags/TagNamespace.swift b/AppPackage/Sources/AppFeature/Models/Tags/TagNamespace.swift index 592bb6b03..270b83e9c 100644 --- a/AppPackage/Sources/AppFeature/Models/Tags/TagNamespace.swift +++ b/AppPackage/Sources/AppFeature/Models/Tags/TagNamespace.swift @@ -1,3 +1,5 @@ +import Resources + enum TagNamespace: String, Codable, CaseIterable, Sendable { case reclass case language diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift index b869ae47d..001e1cc19 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift @@ -1,4 +1,5 @@ import Foundation +import Resources import ComposableArchitecture #if DEBUG import Synchronization diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift index 67c434d1c..0e858c05c 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift @@ -1,4 +1,5 @@ import Foundation +import Resources // MARK: - User Folder Operations extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift index 1e4a56d18..f1251c8a7 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift @@ -1,4 +1,5 @@ import Foundation +import Resources // MARK: - Public API extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift index 24ea6fc9c..1cba9cfbd 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import AlertKit extension View { diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/TTProgressHUD_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/TTProgressHUD_Extension.swift index 06375f924..2303398aa 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/TTProgressHUD_Extension.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/TTProgressHUD_Extension.swift @@ -1,4 +1,5 @@ import TTProgressHUD +import Resources enum ProgressHUDConfigState: Equatable, Sendable { case loading(title: String? = nil) diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift index 9f78145a6..9a9bfd539 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift @@ -1,4 +1,5 @@ import Kanna +import Resources extension Parser { static func parseResponseError(doc: HTMLDocument) -> AppError? { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift index c9eb752bb..021168f54 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift @@ -1,4 +1,5 @@ import Foundation +import Resources extension DownloadStore { func linkOrCopyReadableAsset(at sourceURL: URL, to destinationURL: URL) throws { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift index e1061592f..ef8aabd81 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift @@ -1,4 +1,5 @@ import Foundation +import Resources import CryptoKit enum DownloadValidationState: Equatable, Sendable { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift index 97ab0b0d1..73655ba5d 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift @@ -1,4 +1,5 @@ import Foundation +import Resources import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift index 8a88e0f5b..92a14af45 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct ArchivesView: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift index 46577b6c5..5a37b2a18 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import Kingfisher import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift index 75f086db2..9cb90b833 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import Kingfisher struct TagDetailView: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift index 18ad65828..b3c04892c 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources extension DetailView { struct CommentCell: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift index 072b08796..776c70d2d 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import Kingfisher import SFSafeSymbols diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift index 9b862ad44..75dfa4a7b 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture // MARK: NavigationLinks diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift index e2ddc6d04..806369194 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import Kingfisher // MARK: DescriptionSection diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift index c750cce67..e71968aa3 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import Kingfisher import ComposableArchitecture import CommonMark diff --git a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift index 2c748cdff..22f6b42c4 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct GalleryInfosView: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift index e7225c01c..bba152a5a 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct PreviewsView: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift index 1d95144f5..c4f615e52 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct TorrentsView: View { diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift index b575565c2..97228b27a 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift @@ -1,4 +1,5 @@ import Foundation +import Resources import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift index 62732f11d..c0b6da0a3 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import SFSafeSymbols import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift index 563cb1d2b..13c2f682c 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import SFSafeSymbols import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift index 771ea679c..8c2de43d8 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift index e73d22976..d44b6814a 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import SFSafeSymbols import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift index ca8557daf..e2f7f394f 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import AlertKit import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift index 43663fca5..c2d424604 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import AlertKit import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift index 9828bded4..c04914cdf 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct HistoryView: View { diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift index 7213a32fa..71eebfe13 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import Kingfisher import SwiftUIPager import SFSafeSymbols diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift index 7055f9fbc..fccaf141d 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import Kingfisher import SFSafeSymbols import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift index 219715a0d..fc3e7d1df 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct PopularView: View { diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift index 8dc068f9c..1952b9e91 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct ToplistsView: View { diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift index b97f2eb31..92d7409d2 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct WatchedView: View { diff --git a/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift b/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift index 2b0a6fcc1..e11cc34c7 100644 --- a/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift +++ b/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct MigrationView: View { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift index c367f74a4..4b8897f07 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import Kingfisher import SDWebImage import SDWebImageSwiftUI diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift index 7bd901849..926e61bd6 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources // MARK: ControlPanel struct ControlPanel: View { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift index fce3d23b1..023bde361 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct SearchRootView: View { diff --git a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift index 617d3a1a6..6507ee7d1 100644 --- a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct QuickSearchView: View { diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift index 6b9c715dc..4a4018b50 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct AccountSettingView: View { diff --git a/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift index f0083b30a..cfa5939d4 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct AppearanceSettingView: View { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift index 4f581b9a6..6a318adc0 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources struct AboutView: View { private var version: String { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/DownloadSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/DownloadSettingView.swift index 7be2c1150..e46a72cde 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Components/DownloadSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Components/DownloadSettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources struct DownloadSettingView: View { @Binding private var downloadThreadLimit: Int diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/LaboratorySettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/LaboratorySettingView.swift index fe12b8384..7826f20a2 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Components/LaboratorySettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Components/LaboratorySettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import SFSafeSymbols struct LaboratorySettingView: View { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift index 9bb8ce914..3eb8bac58 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct ReadingSettingView: View { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift index 0b4f5ec1c..bbcd33ebd 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture extension EhSettingView { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift index 46c161715..6cb6393c5 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources extension EhSettingView { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift index 573d8ceb7..cd5c896d9 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources extension EhSettingView { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift index f62ab2dfb..83170861a 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct EhSettingView: View { diff --git a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift index 6e7dbdc1e..fd4c4302d 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import FilePicker import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift index f9550c73f..7a9be79c1 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct LoginView: View { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift index 7591f667b..df37a806b 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct LogsView: View { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift index ac60709dc..1ef31e229 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import SFSafeSymbols import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift index 95d933bb4..d68dac2ba 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import SFSafeSymbols struct LoadingView: View { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift index 3c3a2b46c..0839a5ad2 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift @@ -1,4 +1,5 @@ import SFSafeSymbols +import Resources import SwiftUI /// The "Seek to date" sheet content: a graphical date picker plus newer/older direction buttons. diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift b/AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift index 3f796e932..a34d729ef 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources struct DownloadBadgeLabel: View { private let badge: DownloadBadge diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift b/AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift index 1004fa54e..7ce8574f3 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources struct SubSection: View { private let title: String diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift index c75f09b94..51ded3fca 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import Kingfisher import Observation diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift b/AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift index 7d0ec2781..bac7a4cae 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources struct CustomToolbarItem: ToolbarContent { private let placement: ToolbarItemPlacement diff --git a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift index 32d63fe85..6ae5cf35d 100644 --- a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import ComposableArchitecture struct FiltersView: View { diff --git a/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift b/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift index 15c15d081..8b2bab532 100644 --- a/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources struct NewDawnView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index ea1f48f53..07b72ed7b 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources import SFSafeSymbols import ComposableArchitecture diff --git a/AppPackage/Sources/Resources/Resources.swift b/AppPackage/Sources/Resources/Resources.swift deleted file mode 100644 index b30d31257..000000000 --- a/AppPackage/Sources/Resources/Resources.swift +++ /dev/null @@ -1,4 +0,0 @@ -import Foundation - -// Placeholder. Generated strings, localizations, and assets land here in M2. -enum ResourcesModule {} diff --git a/AppPackage/Sources/AppFeature/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings similarity index 100% rename from AppPackage/Sources/AppFeature/Resources/de.lproj/Localizable.strings rename to AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings diff --git a/AppPackage/Sources/AppFeature/Resources/en.lproj/Constant.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings similarity index 100% rename from AppPackage/Sources/AppFeature/Resources/en.lproj/Constant.strings rename to AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings diff --git a/AppPackage/Sources/AppFeature/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings similarity index 100% rename from AppPackage/Sources/AppFeature/Resources/en.lproj/Localizable.strings rename to AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings diff --git a/AppPackage/Sources/AppFeature/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings similarity index 100% rename from AppPackage/Sources/AppFeature/Resources/ja.lproj/Localizable.strings rename to AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings diff --git a/AppPackage/Sources/AppFeature/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings similarity index 100% rename from AppPackage/Sources/AppFeature/Resources/ko.lproj/Localizable.strings rename to AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings diff --git a/AppPackage/Sources/AppFeature/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings similarity index 100% rename from AppPackage/Sources/AppFeature/Resources/zh-Hans.lproj/Localizable.strings rename to AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings diff --git a/AppPackage/Sources/AppFeature/Resources/zh-Hant-HK.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings similarity index 100% rename from AppPackage/Sources/AppFeature/Resources/zh-Hant-HK.lproj/Localizable.strings rename to AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings diff --git a/AppPackage/Sources/AppFeature/Resources/zh-Hant-TW.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings similarity index 100% rename from AppPackage/Sources/AppFeature/Resources/zh-Hant-TW.lproj/Localizable.strings rename to AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings diff --git a/AppPackage/Sources/AppFeature/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings similarity index 100% rename from AppPackage/Sources/AppFeature/Resources/zh-Hant.lproj/Localizable.strings rename to AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift new file mode 100644 index 000000000..86c34a318 --- /dev/null +++ b/AppPackage/Sources/Resources/Strings.swift @@ -0,0 +1,2595 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +import Foundation + +// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references + +// MARK: - Strings + +// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces +public enum L10n { + public enum Constant { + public enum App { + /// Copyright © 2025 EhPanda Team + public static let copyright = L10n.tr("Constant", "app.copyright", fallback: "Copyright © 2025 EhPanda Team") + public enum Acknowledgement { + public enum Link { + /// https://github.com/rebeloper/AlertKit + public static let alertKit = L10n.tr("Constant", "app.acknowledgement.link.alertKit", fallback: "https://github.com/rebeloper/AlertKit") + /// https://github.com/Co2333/Colorful + public static let colorful = L10n.tr("Constant", "app.acknowledgement.link.colorful", fallback: "https://github.com/Co2333/Colorful") + /// https://github.com/EhTagTranslation/Database + public static let ehTagTranslationDatabase = L10n.tr("Constant", "app.acknowledgement.link.ehTagTranslationDatabase", fallback: "https://github.com/EhTagTranslation/Database") + /// https://github.com/markrenaud/FilePicker + public static let filePicker = L10n.tr("Constant", "app.acknowledgement.link.filePicker", fallback: "https://github.com/markrenaud/FilePicker") + /// https://github.com/tid-kijyun/Kanna + public static let kanna = L10n.tr("Constant", "app.acknowledgement.link.kanna", fallback: "https://github.com/tid-kijyun/Kanna") + /// https://github.com/onevcat/Kingfisher + public static let kingfisher = L10n.tr("Constant", "app.acknowledgement.link.kingfisher", fallback: "https://github.com/onevcat/Kingfisher") + /// https://github.com/SFSafeSymbols/SFSafeSymbols + public static let sfSafeSymbols = L10n.tr("Constant", "app.acknowledgement.link.sfSafeSymbols", fallback: "https://github.com/SFSafeSymbols/SFSafeSymbols") + /// https://github.com/gonzalezreal/SwiftCommonMark + public static let swiftCommonMark = L10n.tr("Constant", "app.acknowledgement.link.swiftCommonMark", fallback: "https://github.com/gonzalezreal/SwiftCommonMark") + /// https://github.com/SwiftGen/SwiftGen + public static let swiftGen = L10n.tr("Constant", "app.acknowledgement.link.swiftGen", fallback: "https://github.com/SwiftGen/SwiftGen") + /// https://github.com/pointfreeco/swiftui-navigation + public static let swiftUINavigation = L10n.tr("Constant", "app.acknowledgement.link.swiftUINavigation", fallback: "https://github.com/pointfreeco/swiftui-navigation") + /// https://github.com/fermoya/SwiftUIPager + public static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.link.swiftUIPager", fallback: "https://github.com/fermoya/SwiftUIPager") + /// https://github.com/SwiftyBeaver/SwiftyBeaver + public static let swiftyBeaver = L10n.tr("Constant", "app.acknowledgement.link.swiftyBeaver", fallback: "https://github.com/SwiftyBeaver/SwiftyBeaver") + /// https://github.com/ddddxxx/SwiftyOpenCC + public static let swiftyOpenCC = L10n.tr("Constant", "app.acknowledgement.link.swiftyOpenCC", fallback: "https://github.com/ddddxxx/SwiftyOpenCC") + /// https://github.com/pointfreeco/swift-composable-architecture + public static let tca = L10n.tr("Constant", "app.acknowledgement.link.tca", fallback: "https://github.com/pointfreeco/swift-composable-architecture") + /// https://github.com/honkmaster/TTProgressHUD + public static let ttProgressHUD = L10n.tr("Constant", "app.acknowledgement.link.ttProgressHUD", fallback: "https://github.com/honkmaster/TTProgressHUD") + /// https://github.com/jathu/UIImageColors + public static let uiImageColors = L10n.tr("Constant", "app.acknowledgement.link.uiImageColors", fallback: "https://github.com/jathu/UIImageColors") + /// https://github.com/paololeonardi/WaterfallGrid + public static let waterfallGrid = L10n.tr("Constant", "app.acknowledgement.link.waterfallGrid", fallback: "https://github.com/paololeonardi/WaterfallGrid") + } + public enum Text { + /// AlertKit + public static let alertKit = L10n.tr("Constant", "app.acknowledgement.text.alertKit", fallback: "AlertKit") + /// Colorful + public static let colorful = L10n.tr("Constant", "app.acknowledgement.text.colorful", fallback: "Colorful") + /// EhTagTranslation/Database + public static let ehTagTranslationDatabase = L10n.tr("Constant", "app.acknowledgement.text.ehTagTranslationDatabase", fallback: "EhTagTranslation/Database") + /// FilePicker + public static let filePicker = L10n.tr("Constant", "app.acknowledgement.text.filePicker", fallback: "FilePicker") + /// Kanna + public static let kanna = L10n.tr("Constant", "app.acknowledgement.text.kanna", fallback: "Kanna") + /// Kingfisher + public static let kingfisher = L10n.tr("Constant", "app.acknowledgement.text.kingfisher", fallback: "Kingfisher") + /// SFSafeSymbols + public static let sfSafeSymbols = L10n.tr("Constant", "app.acknowledgement.text.sfSafeSymbols", fallback: "SFSafeSymbols") + /// SwiftCommonMark + public static let swiftCommonMark = L10n.tr("Constant", "app.acknowledgement.text.swiftCommonMark", fallback: "SwiftCommonMark") + /// SwiftGen + public static let swiftGen = L10n.tr("Constant", "app.acknowledgement.text.swiftGen", fallback: "SwiftGen") + /// SwiftUI Navigation + public static let swiftUINavigation = L10n.tr("Constant", "app.acknowledgement.text.swiftUINavigation", fallback: "SwiftUI Navigation") + /// SwiftUIPager + public static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.text.swiftUIPager", fallback: "SwiftUIPager") + /// SwiftyBeaver + public static let swiftyBeaver = L10n.tr("Constant", "app.acknowledgement.text.swiftyBeaver", fallback: "SwiftyBeaver") + /// SwiftyOpenCC + public static let swiftyOpenCC = L10n.tr("Constant", "app.acknowledgement.text.swiftyOpenCC", fallback: "SwiftyOpenCC") + /// The Composable Architecture + public static let tca = L10n.tr("Constant", "app.acknowledgement.text.tca", fallback: "The Composable Architecture") + /// TTProgressHUD + public static let ttProgressHUD = L10n.tr("Constant", "app.acknowledgement.text.ttProgressHUD", fallback: "TTProgressHUD") + /// UIImageColors + public static let uiImageColors = L10n.tr("Constant", "app.acknowledgement.text.uiImageColors", fallback: "UIImageColors") + /// WaterfallGrid + public static let waterfallGrid = L10n.tr("Constant", "app.acknowledgement.text.waterfallGrid", fallback: "WaterfallGrid") + } + } + public enum CodeLevelContributor { + public enum Link { + /// https://github.com/aalberrty + public static let aalberrty = L10n.tr("Constant", "app.code_level_contributor.link.aalberrty", fallback: "https://github.com/aalberrty") + /// https://github.com/chihchy + public static let chihchy = L10n.tr("Constant", "app.code_level_contributor.link.chihchy", fallback: "https://github.com/chihchy") + /// https://github.com/Jimmy-Prime + public static let jimmyPrime = L10n.tr("Constant", "app.code_level_contributor.link.Jimmy-Prime", fallback: "https://github.com/Jimmy-Prime") + /// https://github.com/vvbbnn00 + public static let vvbbnn00 = L10n.tr("Constant", "app.code_level_contributor.link.vvbbnn00", fallback: "https://github.com/vvbbnn00") + /// https://github.com/xioxin + public static let xioxin = L10n.tr("Constant", "app.code_level_contributor.link.xioxin", fallback: "https://github.com/xioxin") + } + public enum Text { + /// Zack Asahina + public static let aalberrty = L10n.tr("Constant", "app.code_level_contributor.text.aalberrty", fallback: "Zack Asahina") + /// Chihchy + public static let chihchy = L10n.tr("Constant", "app.code_level_contributor.text.chihchy", fallback: "Chihchy") + /// Jimmy Prime + public static let jimmyPrime = L10n.tr("Constant", "app.code_level_contributor.text.Jimmy-Prime", fallback: "Jimmy Prime") + /// vvbbnn00 + public static let vvbbnn00 = L10n.tr("Constant", "app.code_level_contributor.text.vvbbnn00", fallback: "vvbbnn00") + /// xioxin + public static let xioxin = L10n.tr("Constant", "app.code_level_contributor.text.xioxin", fallback: "xioxin") + } + } + public enum Contact { + public enum Link { + /// altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json + public static let altStore = L10n.tr("Constant", "app.contact.link.altStore", fallback: "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json") + /// https://discord.gg/BSBE9FCBTq + public static let discord = L10n.tr("Constant", "app.contact.link.discord", fallback: "https://discord.gg/BSBE9FCBTq") + /// https://github.com/EhPanda-Team/EhPanda + public static let gitHub = L10n.tr("Constant", "app.contact.link.gitHub", fallback: "https://github.com/EhPanda-Team/EhPanda") + /// https://t.me/ehpanda + public static let telegram = L10n.tr("Constant", "app.contact.link.telegram", fallback: "https://t.me/ehpanda") + /// https://ehpanda.app + public static let website = L10n.tr("Constant", "app.contact.link.website", fallback: "https://ehpanda.app") + } + public enum Text { + /// Discord + public static let discord = L10n.tr("Constant", "app.contact.text.discord", fallback: "Discord") + /// GitHub + public static let gitHub = L10n.tr("Constant", "app.contact.text.gitHub", fallback: "GitHub") + /// Telegram + public static let telegram = L10n.tr("Constant", "app.contact.text.telegram", fallback: "Telegram") + } + } + public enum SpecialThanks { + public enum Link { + /// https://github.com/caxerx + public static let caxerx = L10n.tr("Constant", "app.special_thanks.link.caxerx", fallback: "https://github.com/caxerx") + /// https://github.com/honjow + public static let honjow = L10n.tr("Constant", "app.special_thanks.link.honjow", fallback: "https://github.com/honjow") + /// + public static let luminescentYq = L10n.tr("Constant", "app.special_thanks.link.luminescent_yq", fallback: "") + /// https://github.com/taylorlannister + public static let taylorlannister = L10n.tr("Constant", "app.special_thanks.link.taylorlannister", fallback: "https://github.com/taylorlannister") + } + public enum Text { + /// caxerx + public static let caxerx = L10n.tr("Constant", "app.special_thanks.text.caxerx", fallback: "caxerx") + /// honjow + public static let honjow = L10n.tr("Constant", "app.special_thanks.text.honjow", fallback: "honjow") + /// Luminescent_yq + public static let luminescentYq = L10n.tr("Constant", "app.special_thanks.text.luminescent_yq", fallback: "Luminescent_yq") + /// taylorlannister + public static let taylorlannister = L10n.tr("Constant", "app.special_thanks.text.taylorlannister", fallback: "taylorlannister") + } + } + public enum TranslationContributor { + public enum Link { + /// https://github.com/caxerx + public static let caxerx = L10n.tr("Constant", "app.translation_contributor.link.caxerx", fallback: "https://github.com/caxerx") + /// https://github.com/Nebulosa-Cat + public static let nebulosaCat = L10n.tr("Constant", "app.translation_contributor.link.nebulosa-cat", fallback: "https://github.com/Nebulosa-Cat") + /// https://github.com/NeKoOuO + public static let neKoOuO = L10n.tr("Constant", "app.translation_contributor.link.NeKoOuO", fallback: "https://github.com/NeKoOuO") + /// https://github.com/PaulHaeussler + public static let paulHaeussler = L10n.tr("Constant", "app.translation_contributor.link.paulHaeussler", fallback: "https://github.com/PaulHaeussler") + } + public enum Text { + /// caxerx + public static let caxerx = L10n.tr("Constant", "app.translation_contributor.text.caxerx", fallback: "caxerx") + /// 雲豹 ΦωΦ + public static let nebulosaCat = L10n.tr("Constant", "app.translation_contributor.text.nebulosa-cat", fallback: "雲豹 ΦωΦ") + /// ɴᴇᴋᴏ + public static let neKoOuO = L10n.tr("Constant", "app.translation_contributor.text.NeKoOuO", fallback: "ɴᴇᴋᴏ") + /// PaulHaeussler + public static let paulHaeussler = L10n.tr("Constant", "app.translation_contributor.text.paulHaeussler", fallback: "PaulHaeussler") + } + } + } + public enum Website { + public enum Response { + /// This gallery has been removed or is unavailable. + public static let galleryUnavailable = L10n.tr("Constant", "website.response.gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") + /// Constant.strings + /// EhPanda + public static let hathClientNotFound = L10n.tr("Constant", "website.response.hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") + /// Your H@H client appears to be offline. Turn it on, then try again. + public static let hathClientNotOnline = L10n.tr("Constant", "website.response.hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") + /// The requested gallery cannot be downloaded with the selected resolution. + public static let invalidResolution = L10n.tr("Constant", "website.response.invalid_resolution", fallback: "The requested gallery cannot be downloaded with the selected resolution.") + } + } + } + public enum Localizable { + public enum AboutView { + public enum Button { + /// AltStore source + public static let altStoreSource = L10n.tr("Localizable", "about_view.button.altStore_source", fallback: "AltStore source") + /// Website + public static let website = L10n.tr("Localizable", "about_view.button.website", fallback: "Website") + } + public enum Section { + public enum Title { + /// Acknowledgements + public static let acknowledgements = L10n.tr("Localizable", "about_view.section.title.acknowledgements", fallback: "Acknowledgements") + /// Code-level contributors + public static let codeLevelContributors = L10n.tr("Localizable", "about_view.section.title.code_level_contributors", fallback: "Code-level contributors") + /// Special thanks + public static let specialThanks = L10n.tr("Localizable", "about_view.section.title.special_thanks", fallback: "Special thanks") + /// Translation contributors + public static let translationContributors = L10n.tr("Localizable", "about_view.section.title.translation_contributors", fallback: "Translation contributors") + } + } + public enum Title { + /// EhPanda + public static let ehPanda = L10n.tr("Localizable", "about_view.title.ehPanda", fallback: "EhPanda") + /// Version + public static let version = L10n.tr("Localizable", "about_view.title.version", fallback: "Version") + } + } + public enum AccountSettingView { + public enum Button { + /// Account configuration + public static let accountConfiguration = L10n.tr("Localizable", "account_setting_view.button.account_configuration", fallback: "Account configuration") + /// Copy cookies + public static let copyCookies = L10n.tr("Localizable", "account_setting_view.button.copy_cookies", fallback: "Copy cookies") + /// Login + public static let login = L10n.tr("Localizable", "account_setting_view.button.login", fallback: "Login") + /// Logout + public static let logout = L10n.tr("Localizable", "account_setting_view.button.logout", fallback: "Logout") + /// Manage tags subscription + public static let tagsManagement = L10n.tr("Localizable", "account_setting_view.button.tags_management", fallback: "Manage tags subscription") + } + public enum Title { + /// Account + public static let account = L10n.tr("Localizable", "account_setting_view.title.account", fallback: "Account") + /// Shows new dawn greeting + public static let showsNewDawnGreeting = L10n.tr("Localizable", "account_setting_view.title.shows_new_dawn_greeting", fallback: "Shows new dawn greeting") + } + } + public enum AppError { + public enum Alert { + /// Login required to access this download. + public static let authenticationRequired = L10n.tr("Localizable", "app_error.alert.authentication_required", fallback: "Login required to access this download.") + /// Local file operation failed. + public static let localFileOperationFailed = L10n.tr("Localizable", "app_error.alert.local_file_operation_failed", fallback: "Local file operation failed.") + /// Image quota exceeded. + /// Please wait and try again later. + public static let quotaExceeded = L10n.tr("Localizable", "app_error.alert.quota_exceeded", fallback: "Image quota exceeded.\nPlease wait and try again later.") + } + public enum LocalizedDescription { + /// Authentication Required + public static let authenticationRequired = L10n.tr("Localizable", "app_error.localized_description.authentication_required", fallback: "Authentication Required") + /// Copyright Claim + public static let copyrightClaim = L10n.tr("Localizable", "app_error.localized_description.copyright_claim", fallback: "Copyright Claim") + /// Database Corrupted + public static let databaseCorrupted = L10n.tr("Localizable", "app_error.localized_description.database_corrupted", fallback: "Database Corrupted") + /// File Operation Failed + public static let fileOperationFailed = L10n.tr("Localizable", "app_error.localized_description.file_operation_failed", fallback: "File Operation Failed") + /// Gallery Expunged + public static let galleryExpunged = L10n.tr("Localizable", "app_error.localized_description.gallery_expunged", fallback: "Gallery Expunged") + /// IP Banned + public static let ipBanned = L10n.tr("Localizable", "app_error.localized_description.ip_banned", fallback: "IP Banned") + /// Network Error + public static let networkError = L10n.tr("Localizable", "app_error.localized_description.network_error", fallback: "Network Error") + /// No updates available + public static let noUpdatesAvailable = L10n.tr("Localizable", "app_error.localized_description.no_updates_available", fallback: "No updates available") + /// Not found + public static let notFound = L10n.tr("Localizable", "app_error.localized_description.not_found", fallback: "Not found") + /// Parse Error + public static let parseError = L10n.tr("Localizable", "app_error.localized_description.parse_error", fallback: "Parse Error") + /// Quota Exceeded + public static let quotaExceeded = L10n.tr("Localizable", "app_error.localized_description.quota_exceeded", fallback: "Quota Exceeded") + /// Unknown Error + public static let unknownError = L10n.tr("Localizable", "app_error.localized_description.unknown_error", fallback: "Unknown Error") + /// Web image loading error + public static let webImageLoadingError = L10n.tr("Localizable", "app_error.localized_description.web_image_loading_error", fallback: "Web image loading error") + } + } + public enum AppIconView { + public enum Title { + /// App icon + public static let appIcon = L10n.tr("Localizable", "app_icon_view.title.app_icon", fallback: "App icon") + } + } + public enum AppearanceSettingView { + public enum Button { + /// App icon + public static let appIcon = L10n.tr("Localizable", "appearance_setting_view.button.app_icon", fallback: "App icon") + } + public enum Menu { + public enum Title { + /// Infite + public static let infite = L10n.tr("Localizable", "appearance_setting_view.menu.title.infite", fallback: "Infite") + } + } + public enum Section { + public enum Title { + /// Gallery + public static let gallery = L10n.tr("Localizable", "appearance_setting_view.section.title.gallery", fallback: "Gallery") + /// List + public static let list = L10n.tr("Localizable", "appearance_setting_view.section.title.list", fallback: "List") + } + } + public enum Title { + /// Appearance + public static let appearance = L10n.tr("Localizable", "appearance_setting_view.title.appearance", fallback: "Appearance") + /// Display mode + public static let displayMode = L10n.tr("Localizable", "appearance_setting_view.title.display_mode", fallback: "Display mode") + /// Displays Japanese title + public static let displaysJapaneseTitle = L10n.tr("Localizable", "appearance_setting_view.title.displays_japanese_title", fallback: "Displays Japanese title") + /// Maximum number of tags + public static let maximumNumberOfTags = L10n.tr("Localizable", "appearance_setting_view.title.maximum_number_of_tags", fallback: "Maximum number of tags") + /// Shows tags in list + public static let showsTagsInList = L10n.tr("Localizable", "appearance_setting_view.title.shows_tags_in_list", fallback: "Shows tags in list") + /// Theme + public static let theme = L10n.tr("Localizable", "appearance_setting_view.title.theme", fallback: "Theme") + /// Tint color + public static let tintColor = L10n.tr("Localizable", "appearance_setting_view.title.tint_color", fallback: "Tint color") + } + } + public enum ArchivesView { + public enum Button { + /// Download To H@H Client + public static let downloadToHathClient = L10n.tr("Localizable", "archives_view.button.download_to_hath_client", fallback: "Download To H@H Client") + } + public enum Title { + /// Archives + public static let archives = L10n.tr("Localizable", "archives_view.title.archives", fallback: "Archives") + } + } + public enum CommentsView { + public enum Title { + /// Comments + public static let comments = L10n.tr("Localizable", "comments_view.title.comments", fallback: "Comments") + } + } + public enum Common { + public enum Button { + /// Cancel + public static let cancel = L10n.tr("Localizable", "common.button.cancel", fallback: "Cancel") + } + public enum Value { + /// %@ day + public static func day(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.value.day", String(describing: p1), fallback: "%@ day") + } + /// %@ days + public static func days(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.value.days", String(describing: p1), fallback: "%@ days") + } + /// %@ hour + public static func hour(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.value.hour", String(describing: p1), fallback: "%@ hour") + } + /// %@ hours + public static func hours(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.value.hours", String(describing: p1), fallback: "%@ hours") + } + /// %@ minute + public static func minute(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.value.minute", String(describing: p1), fallback: "%@ minute") + } + /// %@ minutes + public static func minutes(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.value.minutes", String(describing: p1), fallback: "%@ minutes") + } + /// %@ pages + public static func pages(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.value.pages", String(describing: p1), fallback: "%@ pages") + } + /// %@ records + public static func records(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.value.records", String(describing: p1), fallback: "%@ records") + } + /// %@ second + public static func second(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.value.second", String(describing: p1), fallback: "%@ second") + } + /// %@ seconds + public static func seconds(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.value.seconds", String(describing: p1), fallback: "%@ seconds") + } + /// %@ stars + public static func stars(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.value.stars", String(describing: p1), fallback: "%@ stars") + } + /// %@ times + public static func times(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.value.times", String(describing: p1), fallback: "%@ times") + } + } + } + public enum ConfirmationDialog { + public enum Button { + /// Clear + public static let clear = L10n.tr("Localizable", "confirmation_dialog.button.clear", fallback: "Clear") + /// Delete + public static let delete = L10n.tr("Localizable", "confirmation_dialog.button.delete", fallback: "Delete") + /// Drop the database + public static let dropDatabase = L10n.tr("Localizable", "confirmation_dialog.button.drop_database", fallback: "Drop the database") + /// Logout + public static let logout = L10n.tr("Localizable", "confirmation_dialog.button.logout", fallback: "Logout") + /// Remove + public static let remove = L10n.tr("Localizable", "confirmation_dialog.button.remove", fallback: "Remove") + /// Reset + public static let reset = L10n.tr("Localizable", "confirmation_dialog.button.reset", fallback: "Reset") + } + public enum Title { + /// Are you sure to clear? + public static let clear = L10n.tr("Localizable", "confirmation_dialog.title.clear", fallback: "Are you sure to clear?") + /// Are you sure to delete this item? + public static let delete = L10n.tr("Localizable", "confirmation_dialog.title.delete", fallback: "Are you sure to delete this item?") + /// You will lose all your data in this app. + /// Are you sure to drop the database? + public static let dropDatabase = L10n.tr("Localizable", "confirmation_dialog.title.drop_database", fallback: "You will lose all your data in this app.\nAre you sure to drop the database?") + /// Are you sure to logout? + public static let logout = L10n.tr("Localizable", "confirmation_dialog.title.logout", fallback: "Are you sure to logout?") + /// Are you sure to remove your custom translations? + public static let removeCustomTranslations = L10n.tr("Localizable", "confirmation_dialog.title.remove_custom_translations", fallback: "Are you sure to remove your custom translations?") + /// Are you sure to reset? + public static let reset = L10n.tr("Localizable", "confirmation_dialog.title.reset", fallback: "Are you sure to reset?") + } + } + public enum DateSeekView { + public enum Button { + /// Newer + public static let seekNewer = L10n.tr("Localizable", "date_seek_view.button.seek_newer", fallback: "Newer") + /// Older + public static let seekOlder = L10n.tr("Localizable", "date_seek_view.button.seek_older", fallback: "Older") + } + public enum Footer { + /// Seek to galleries around the selected date. + public static let seekAroundDate = L10n.tr("Localizable", "date_seek_view.footer.seek_around_date", fallback: "Seek to galleries around the selected date.") + } + public enum Title { + /// Date + public static let date = L10n.tr("Localizable", "date_seek_view.title.date", fallback: "Date") + /// Seek to date + public static let dateSeek = L10n.tr("Localizable", "date_seek_view.title.date_seek", fallback: "Seek to date") + } + } + public enum DetailView { + public enum Accessibility { + public enum DownloadButton { + /// Download + public static let download = L10n.tr("Localizable", "detail_view.accessibility.download_button.download", fallback: "Download") + /// Delete downloaded gallery + public static let downloaded = L10n.tr("Localizable", "detail_view.accessibility.download_button.downloaded", fallback: "Delete downloaded gallery") + /// Downloading %d of %d + public static func downloading(_ p1: Int, _ p2: Int) -> String { + return L10n.tr("Localizable", "detail_view.accessibility.download_button.downloading", p1, p2, fallback: "Downloading %d of %d") + } + /// Log in to download + public static let login = L10n.tr("Localizable", "detail_view.accessibility.download_button.login", fallback: "Log in to download") + /// Retry download. %d of %d pages are already available. + public static func partial(_ p1: Int, _ p2: Int) -> String { + return L10n.tr("Localizable", "detail_view.accessibility.download_button.partial", p1, p2, fallback: "Retry download. %d of %d pages are already available.") + } + /// Pause download + public static let pauseAction = L10n.tr("Localizable", "detail_view.accessibility.download_button.pause_action", fallback: "Pause download") + /// Resume download. Paused at %d of %d + public static func paused(_ p1: Int, _ p2: Int) -> String { + return L10n.tr("Localizable", "detail_view.accessibility.download_button.paused", p1, p2, fallback: "Resume download. Paused at %d of %d") + } + /// Preparing download + public static let preparing = L10n.tr("Localizable", "detail_view.accessibility.download_button.preparing", fallback: "Preparing download") + /// Queued + public static let queued = L10n.tr("Localizable", "detail_view.accessibility.download_button.queued", fallback: "Queued") + /// Repair download + public static let repair = L10n.tr("Localizable", "detail_view.accessibility.download_button.repair", fallback: "Repair download") + /// Retry download + public static let retry = L10n.tr("Localizable", "detail_view.accessibility.download_button.retry", fallback: "Retry download") + /// Update download + public static let update = L10n.tr("Localizable", "detail_view.accessibility.download_button.update", fallback: "Update download") + } + } + public enum ActionSection { + public enum Button { + /// Give a Rating + public static let giveARating = L10n.tr("Localizable", "detail_view.action_section.button.give_a_rating", fallback: "Give a Rating") + /// Similar Gallery + public static let similarGallery = L10n.tr("Localizable", "detail_view.action_section.button.similar_gallery", fallback: "Similar Gallery") + } + } + public enum Button { + /// DONE + public static let downloadDone = L10n.tr("Localizable", "detail_view.button.download_done", fallback: "DONE") + /// GET + public static let downloadGet = L10n.tr("Localizable", "detail_view.button.download_get", fallback: "GET") + /// LOG IN + public static let downloadLogin = L10n.tr("Localizable", "detail_view.button.download_login", fallback: "LOG IN") + /// REPAIR + public static let downloadRepair = L10n.tr("Localizable", "detail_view.button.download_repair", fallback: "REPAIR") + /// RETRY + public static let downloadRetry = L10n.tr("Localizable", "detail_view.button.download_retry", fallback: "RETRY") + /// UPDATE + public static let downloadUpdate = L10n.tr("Localizable", "detail_view.button.download_update", fallback: "UPDATE") + /// WAIT + public static let downloadWait = L10n.tr("Localizable", "detail_view.button.download_wait", fallback: "WAIT") + /// Post comment + public static let postComment = L10n.tr("Localizable", "detail_view.button.post_comment", fallback: "Post comment") + /// Read + public static let read = L10n.tr("Localizable", "detail_view.button.read", fallback: "Read") + } + public enum ContextMenu { + public enum Button { + /// Detail + public static let detail = L10n.tr("Localizable", "detail_view.context_menu.button.detail", fallback: "Detail") + /// Vote down + public static let voteDown = L10n.tr("Localizable", "detail_view.context_menu.button.vote_down", fallback: "Vote down") + /// Vote up + public static let voteUp = L10n.tr("Localizable", "detail_view.context_menu.button.vote_up", fallback: "Vote up") + /// Withdraw vote + public static let withdrawVote = L10n.tr("Localizable", "detail_view.context_menu.button.withdraw_vote", fallback: "Withdraw vote") + } + } + public enum DescriptionSection { + public enum Description { + /// Times + public static let favorited = L10n.tr("Localizable", "detail_view.description_section.description.favorited", fallback: "Times") + /// Pages + public static let pageCount = L10n.tr("Localizable", "detail_view.description_section.description.page_count", fallback: "Pages") + } + public enum Title { + /// Favorited + public static let favorited = L10n.tr("Localizable", "detail_view.description_section.title.favorited", fallback: "Favorited") + /// File Size + public static let fileSize = L10n.tr("Localizable", "detail_view.description_section.title.file_size", fallback: "File Size") + /// Language + public static let language = L10n.tr("Localizable", "detail_view.description_section.title.language", fallback: "Language") + /// Page Count + public static let pageCount = L10n.tr("Localizable", "detail_view.description_section.title.page_count", fallback: "Page Count") + /// %@ Ratings + public static func ratings(_ p1: Any) -> String { + return L10n.tr("Localizable", "detail_view.description_section.title.ratings", String(describing: p1), fallback: "%@ Ratings") + } + } + } + public enum Dialog { + public enum Button { + /// Redownload + public static let redownload = L10n.tr("Localizable", "detail_view.dialog.button.redownload", fallback: "Redownload") + /// Repair + public static let repair = L10n.tr("Localizable", "detail_view.dialog.button.repair", fallback: "Repair") + /// Update + public static let update = L10n.tr("Localizable", "detail_view.dialog.button.update", fallback: "Update") + } + public enum Message { + /// This will stop the current download and remove the gallery from this device. + public static let deleteActiveDownload = L10n.tr("Localizable", "detail_view.dialog.message.delete_active_download", fallback: "This will stop the current download and remove the gallery from this device.") + /// This will remove the downloaded gallery from this device. + public static let deleteDownloadedGallery = L10n.tr("Localizable", "detail_view.dialog.message.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") + /// Start a fresh download for this gallery now? + public static let redownloadGallery = L10n.tr("Localizable", "detail_view.dialog.message.redownload_gallery", fallback: "Start a fresh download for this gallery now?") + /// Repair the offline files for this gallery now? + public static let repairDownload = L10n.tr("Localizable", "detail_view.dialog.message.repair_download", fallback: "Repair the offline files for this gallery now?") + /// Update this gallery to the newest online version now? + public static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.message.update_download", fallback: "Update this gallery to the newest online version now?") + } + public enum Title { + /// Delete Download? + public static let deleteDownload = L10n.tr("Localizable", "detail_view.dialog.title.delete_download", fallback: "Delete Download?") + /// Redownload Gallery? + public static let redownloadGallery = L10n.tr("Localizable", "detail_view.dialog.title.redownload_gallery", fallback: "Redownload Gallery?") + /// Repair Download? + public static let repairDownload = L10n.tr("Localizable", "detail_view.dialog.title.repair_download", fallback: "Repair Download?") + /// Update Download? + public static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.title.update_download", fallback: "Update Download?") + } + } + public enum Menu { + public enum Button { + /// Create Default Folder + public static let createDefaultFolder = L10n.tr("Localizable", "detail_view.menu.button.create_default_folder", fallback: "Create Default Folder") + /// Manage Folders + public static let manageFolders = L10n.tr("Localizable", "detail_view.menu.button.manage_folders", fallback: "Manage Folders") + } + public enum Text { + /// No folders yet + public static let noFolders = L10n.tr("Localizable", "detail_view.menu.text.no_folders", fallback: "No folders yet") + } + } + public enum OfflineNotice { + /// Couldn't refresh online details. Showing saved details instead. + public static let savedDetails = L10n.tr("Localizable", "detail_view.offline_notice.saved_details", fallback: "Couldn't refresh online details. Showing saved details instead.") + } + public enum Section { + public enum Title { + /// Comments + public static let comments = L10n.tr("Localizable", "detail_view.section.title.comments", fallback: "Comments") + /// Previews + public static let previews = L10n.tr("Localizable", "detail_view.section.title.previews", fallback: "Previews") + } + } + public enum ToolbarItem { + public enum Button { + /// Archives + public static let archives = L10n.tr("Localizable", "detail_view.toolbar_item.button.archives", fallback: "Archives") + /// Share + public static let share = L10n.tr("Localizable", "detail_view.toolbar_item.button.share", fallback: "Share") + /// Torrents + public static let torrents = L10n.tr("Localizable", "detail_view.toolbar_item.button.torrents", fallback: "Torrents") + } + } + } + public enum DownloadSettingView { + /// Download + public static let title = L10n.tr("Localizable", "download_setting_view.title", fallback: "Download") + public enum Footer { + /// Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder. + public static let network = L10n.tr("Localizable", "download_setting_view.footer.network", fallback: "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder.") + } + public enum Section { + public enum Title { + /// Download Queue + public static let downloadQueue = L10n.tr("Localizable", "download_setting_view.section.title.download_queue", fallback: "Download Queue") + /// Network + public static let network = L10n.tr("Localizable", "download_setting_view.section.title.network", fallback: "Network") + } + } + public enum Title { + /// Allow cellular downloads + public static let allowCellularDownloads = L10n.tr("Localizable", "download_setting_view.title.allow_cellular_downloads", fallback: "Allow cellular downloads") + /// Concurrent image downloads + public static let concurrentImageDownloads = L10n.tr("Localizable", "download_setting_view.title.concurrent_image_downloads", fallback: "Concurrent image downloads") + /// Retry failed pages automatically + public static let retryFailedPagesAutomatically = L10n.tr("Localizable", "download_setting_view.title.retry_failed_pages_automatically", fallback: "Retry failed pages automatically") + } + } + public enum DownloadStore { + public enum Error { + /// Asset file is unreadable: %@ + public static func assetUnreadable(_ p1: Any) -> String { + return L10n.tr("Localizable", "download_store.error.asset_unreadable", String(describing: p1), fallback: "Asset file is unreadable: %@") + } + /// The download is currently active. + public static let downloadBusy = L10n.tr("Localizable", "download_store.error.download_busy", fallback: "The download is currently active.") + /// A folder with this name already exists. + public static let folderAlreadyExists = L10n.tr("Localizable", "download_store.error.folder_already_exists", fallback: "A folder with this name already exists.") + /// The folder contains an active download. + public static let folderBusyDownloading = L10n.tr("Localizable", "download_store.error.folder_busy_downloading", fallback: "The folder contains an active download.") + /// The folder name is invalid. + public static let invalidFolderName = L10n.tr("Localizable", "download_store.error.invalid_folder_name", fallback: "The folder name is invalid.") + } + public enum Validation { + /// Cover image data is corrupted. + public static let coverImageCorrupted = L10n.tr("Localizable", "download_store.validation.cover_image_corrupted", fallback: "Cover image data is corrupted.") + /// Cover image is missing. + public static let coverImageMissing = L10n.tr("Localizable", "download_store.validation.cover_image_missing", fallback: "Cover image is missing.") + /// Download folder is missing. + public static let downloadFolderMissing = L10n.tr("Localizable", "download_store.validation.download_folder_missing", fallback: "Download folder is missing.") + /// Download folder could not be resolved. + public static let downloadFolderUnresolved = L10n.tr("Localizable", "download_store.validation.download_folder_unresolved", fallback: "Download folder could not be resolved.") + /// Downloaded pages are incomplete. + public static let downloadedPagesIncomplete = L10n.tr("Localizable", "download_store.validation.downloaded_pages_incomplete", fallback: "Downloaded pages are incomplete.") + /// Manifest file is corrupted. + public static let manifestCorrupted = L10n.tr("Localizable", "download_store.validation.manifest_corrupted", fallback: "Manifest file is corrupted.") + /// Manifest file is missing. + public static let manifestMissing = L10n.tr("Localizable", "download_store.validation.manifest_missing", fallback: "Manifest file is missing.") + /// Page %d image data is corrupted. + public static func pageImageCorrupted(_ p1: Int) -> String { + return L10n.tr("Localizable", "download_store.validation.page_image_corrupted", p1, fallback: "Page %d image data is corrupted.") + } + /// Page %d is missing. + public static func pageMissing(_ p1: Int) -> String { + return L10n.tr("Localizable", "download_store.validation.page_missing", p1, fallback: "Page %d is missing.") + } + } + } + public enum DownloadsView { + public enum Button { + /// Clear Filters + public static let clearFilters = L10n.tr("Localizable", "downloads_view.button.clear_filters", fallback: "Clear Filters") + /// Validate Image Data + public static let validateImageData = L10n.tr("Localizable", "downloads_view.button.validate_image_data", fallback: "Validate Image Data") + } + public enum Dialog { + public enum Message { + /// This will cancel the current download and remove it from this device. + public static let deleteActiveDownload = L10n.tr("Localizable", "downloads_view.dialog.message.delete_active_download", fallback: "This will cancel the current download and remove it from this device.") + /// This will remove the downloaded gallery from this device. + public static let deleteDownloadedGallery = L10n.tr("Localizable", "downloads_view.dialog.message.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") + } + public enum Title { + /// Delete Download? + public static let deleteDownload = L10n.tr("Localizable", "downloads_view.dialog.title.delete_download", fallback: "Delete Download?") + } + } + public enum EmptyState { + /// Downloaded galleries will appear here. + public static let downloads = L10n.tr("Localizable", "downloads_view.empty_state.downloads", fallback: "Downloaded galleries will appear here.") + /// No downloads match the current filters. + public static let noMatchingFilters = L10n.tr("Localizable", "downloads_view.empty_state.no_matching_filters", fallback: "No downloads match the current filters.") + } + public enum Inspector { + public enum Button { + /// Retry Failed Pages + public static let retryFailedPages = L10n.tr("Localizable", "downloads_view.inspector.button.retry_failed_pages", fallback: "Retry Failed Pages") + /// Update Download + public static let updateDownload = L10n.tr("Localizable", "downloads_view.inspector.button.update_download", fallback: "Update Download") + /// Validating Image Data... + public static let validatingImageData = L10n.tr("Localizable", "downloads_view.inspector.button.validating_image_data", fallback: "Validating Image Data...") + } + public enum Hud { + /// Image data could not be validated. + public static let imageDataUnavailable = L10n.tr("Localizable", "downloads_view.inspector.hud.image_data_unavailable", fallback: "Image data could not be validated.") + /// Image data is valid + public static let imageDataValid = L10n.tr("Localizable", "downloads_view.inspector.hud.image_data_valid", fallback: "Image data is valid") + } + public enum Page { + /// No pages + public static let `none` = L10n.tr("Localizable", "downloads_view.inspector.page.none", fallback: "No pages") + /// Pending + public static let pending = L10n.tr("Localizable", "downloads_view.inspector.page.pending", fallback: "Pending") + /// Tap to retry this page + public static let tapToRetry = L10n.tr("Localizable", "downloads_view.inspector.page.tap_to_retry", fallback: "Tap to retry this page") + /// Page %d + public static func title(_ p1: Int) -> String { + return L10n.tr("Localizable", "downloads_view.inspector.page.title", p1, fallback: "Page %d") + } + } + public enum Section { + /// Actions + public static let actions = L10n.tr("Localizable", "downloads_view.inspector.section.actions", fallback: "Actions") + /// Pages + public static let pages = L10n.tr("Localizable", "downloads_view.inspector.section.pages", fallback: "Pages") + } + public enum Status { + /// Downloaded + public static let downloaded = L10n.tr("Localizable", "downloads_view.inspector.status.downloaded", fallback: "Downloaded") + /// Failed + public static let failed = L10n.tr("Localizable", "downloads_view.inspector.status.failed", fallback: "Failed") + /// Pending + public static let pending = L10n.tr("Localizable", "downloads_view.inspector.status.pending", fallback: "Pending") + } + public enum Title { + /// Download Status + public static let downloadStatus = L10n.tr("Localizable", "downloads_view.inspector.title.download_status", fallback: "Download Status") + } + } + public enum Menu { + public enum Button { + /// Manage Folders + public static let manageFolders = L10n.tr("Localizable", "downloads_view.menu.button.manage_folders", fallback: "Manage Folders") + /// Move to Folder + public static let moveToFolder = L10n.tr("Localizable", "downloads_view.menu.button.move_to_folder", fallback: "Move to Folder") + } + } + public enum Search { + public enum Prompt { + /// Search downloads + public static let downloads = L10n.tr("Localizable", "downloads_view.search.prompt.downloads", fallback: "Search downloads") + } + } + public enum Swipe { + public enum Button { + /// Move + public static let move = L10n.tr("Localizable", "downloads_view.swipe.button.move", fallback: "Move") + /// Pages + public static let pages = L10n.tr("Localizable", "downloads_view.swipe.button.pages", fallback: "Pages") + /// Pause + public static let pause = L10n.tr("Localizable", "downloads_view.swipe.button.pause", fallback: "Pause") + /// Resume + public static let resume = L10n.tr("Localizable", "downloads_view.swipe.button.resume", fallback: "Resume") + /// Update + public static let update = L10n.tr("Localizable", "downloads_view.swipe.button.update", fallback: "Update") + } + } + public enum Title { + /// Downloads + public static let downloads = L10n.tr("Localizable", "downloads_view.title.downloads", fallback: "Downloads") + } + } + public enum EhSettingView { + public enum Button { + /// Create new + public static let createNew = L10n.tr("Localizable", "eh_setting_view.button.create_new", fallback: "Create new") + /// Delete profile + public static let deleteProfile = L10n.tr("Localizable", "eh_setting_view.button.delete_profile", fallback: "Delete profile") + /// Rename + public static let rename = L10n.tr("Localizable", "eh_setting_view.button.rename", fallback: "Rename") + /// Set as default + public static let setAsDefault = L10n.tr("Localizable", "eh_setting_view.button.set_as_default", fallback: "Set as default") + } + public enum Description { + /// The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here. + public static let archiverBehavior = L10n.tr("Localizable", "eh_setting_view.description.archiver_behavior", fallback: "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here.") + /// You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below. + public static func browsingCountry(_ p1: Any) -> String { + return L10n.tr("Localizable", "eh_setting_view.description.browsing_country", String(describing: p1), fallback: "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below.") + } + /// The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes. + public static let coverScaleFactor = L10n.tr("Localizable", "eh_setting_view.description.cover_scale_factor", fallback: "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes.") + /// Which display mode would you like to use on the front and search pages? + public static let displayMode = L10n.tr("Localizable", "eh_setting_view.description.display_mode", fallback: "Which display mode would you like to use on the front and search pages?") + /// If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query. + public static let excludedLanguages = L10n.tr("Localizable", "eh_setting_view.description.excluded_languages", fallback: "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query.") + /// If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query. + public static let excludedUploaders = L10n.tr("Localizable", "eh_setting_view.description.excluded_uploaders", fallback: "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query.") + /// You are currently using **%@ / %@** exclusion slots. + public static func excludedUploadersCount(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "eh_setting_view.description.excluded_uploaders_count", String(describing: p1), String(describing: p2), fallback: "You are currently using **%@ / %@** exclusion slots.") + } + /// Here you can choose and rename your favorite categories. + public static let favoriteCategories = L10n.tr("Localizable", "eh_setting_view.description.favorite_categories", fallback: "Here you can choose and rename your favorite categories.") + /// You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting. + public static let favoritesSortOrder = L10n.tr("Localizable", "eh_setting_view.description.favorites_sort_order", fallback: "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting.") + /// Show the "Your default filters removed XX galleries from this page" readout? + public static let filteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.description.filtered_removal_count", fallback: "Show the \"Your default filters removed XX galleries from this page\" readout?") + /// What categories would you like to show by default on the front page and in searches? + public static let galleryCategory = L10n.tr("Localizable", "eh_setting_view.description.gallery_category", fallback: "What categories would you like to show by default on the front page and in searches?") + /// Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default? + public static let galleryName = L10n.tr("Localizable", "eh_setting_view.description.gallery_name", fallback: "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?") + /// Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000. + public static let imageResolution = L10n.tr("Localizable", "eh_setting_view.description.image_resolution", fallback: "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000.") + /// While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit) + public static let imageSize = L10n.tr("Localizable", "eh_setting_view.description.image_size", fallback: "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)") + /// This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem. + /// If you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work. + public static let ipAddressPort = L10n.tr("Localizable", "eh_setting_view.description.ip_address_port", fallback: "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work.") + /// Some historic UI elements are now disabled by default. You can enable those here. + public static let optionalUIElements = L10n.tr("Localizable", "eh_setting_view.description.optional_UI_elements", fallback: "Some historic UI elements are now disabled by default. You can enable those here.") + /// By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works. + public static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.description.ratings_color", fallback: "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works.") + /// How many results would you like per page for the index/search page and torrent search pages? + /// (Hath Perk: Paging Enlargement Required) + public static let resultCount = L10n.tr("Localizable", "eh_setting_view.description.result_count", fallback: "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)") + /// You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999. + public static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.description.tag_filtering_threshold", fallback: "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999.") + /// Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999. + public static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.description.tag_watching_threshold", fallback: "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999.") + /// You can set a default thumbnail configuration for all galleries you visit. + public static let thumbnailConfiguration = L10n.tr("Localizable", "eh_setting_view.description.thumbnail_configuration", fallback: "You can set a default thumbnail configuration for all galleries you visit.") + /// How would you like the mouse-over thumbnails on the front page to load when using List Mode? + public static let thumbnailLoadTiming = L10n.tr("Localizable", "eh_setting_view.description.thumbnail_load_timing", fallback: "How would you like the mouse-over thumbnails on the front page to load when using List Mode?") + /// Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400. + public static let virtualWidth = L10n.tr("Localizable", "eh_setting_view.description.virtual_width", fallback: "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400.") + } + public enum Promt { + /// RRGGB + public static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.promt.ratings_color", fallback: "RRGGB") + } + public enum Section { + public enum Title { + /// Archiver Settings + public static let archiverSettings = L10n.tr("Localizable", "eh_setting_view.section.title.archiver_settings", fallback: "Archiver Settings") + /// Cover Scaling + public static let coverScaling = L10n.tr("Localizable", "eh_setting_view.section.title.cover_scaling", fallback: "Cover Scaling") + /// Excluded Languages + public static let excludedLanguages = L10n.tr("Localizable", "eh_setting_view.section.title.excluded_languages", fallback: "Excluded Languages") + /// Excluded Uploaders + public static let excludedUploaders = L10n.tr("Localizable", "eh_setting_view.section.title.excluded_uploaders", fallback: "Excluded Uploaders") + /// Favorites + public static let favorites = L10n.tr("Localizable", "eh_setting_view.section.title.favorites", fallback: "Favorites") + /// Show Filtered Removal Count + public static let filteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.section.title.filtered_removal_count", fallback: "Show Filtered Removal Count") + /// Front Page Settings + public static let frontPageSettings = L10n.tr("Localizable", "eh_setting_view.section.title.front_page_settings", fallback: "Front Page Settings") + /// Gallery Comments + public static let galleryComments = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_comments", fallback: "Gallery Comments") + /// Gallery Name Display + public static let galleryNameDisplay = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_name_display", fallback: "Gallery Name Display") + /// Gallery Page Thumbnail Labeling + public static let galleryPageThumbnailLabeling = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_page_thumbnail_labeling", fallback: "Gallery Page Thumbnail Labeling") + /// Gallery Tags + public static let galleryTags = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_tags", fallback: "Gallery Tags") + /// Hath Local Network Host + public static let hathLocalNetworkHost = L10n.tr("Localizable", "eh_setting_view.section.title.hath_local_network_host", fallback: "Hath Local Network Host") + /// Image Load Settings + public static let imageLoadSettings = L10n.tr("Localizable", "eh_setting_view.section.title.image_load_settings", fallback: "Image Load Settings") + /// Image Size Settings + public static let imageSizeSettings = L10n.tr("Localizable", "eh_setting_view.section.title.image_size_settings", fallback: "Image Size Settings") + /// Multi-Page Viewer + public static let multiPageViewer = L10n.tr("Localizable", "eh_setting_view.section.title.multi_page_viewer", fallback: "Multi-Page Viewer") + /// Optional UI Elements + public static let optionalUIElements = L10n.tr("Localizable", "eh_setting_view.section.title.optional_UI_elements", fallback: "Optional UI Elements") + /// Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than "Auto" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year). + public static let originalImages = L10n.tr("Localizable", "eh_setting_view.section.title.original_images", fallback: "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year).") + /// Profile Settings + public static let profileSettings = L10n.tr("Localizable", "eh_setting_view.section.title.profile_settings", fallback: "Profile Settings") + /// Ratings + public static let ratings = L10n.tr("Localizable", "eh_setting_view.section.title.ratings", fallback: "Ratings") + /// Search Result Count + public static let searchResultCount = L10n.tr("Localizable", "eh_setting_view.section.title.search_result_count", fallback: "Search Result Count") + /// Search Range Indicator + public static let showSearchRangeIndicator = L10n.tr("Localizable", "eh_setting_view.section.title.show_search_range_indicator", fallback: "Search Range Indicator") + /// Tag Filtering Threshold + public static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.section.title.tag_filtering_threshold", fallback: "Tag Filtering Threshold") + /// Tag Watching Threshold + public static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.section.title.tag_watching_threshold", fallback: "Tag Watching Threshold") + /// Thumbnail Settings + public static let thumbnailSettings = L10n.tr("Localizable", "eh_setting_view.section.title.thumbnail_settings", fallback: "Thumbnail Settings") + /// Viewport Override + public static let viewportOverride = L10n.tr("Localizable", "eh_setting_view.section.title.viewport_override", fallback: "Viewport Override") + } + } + public enum Title { + /// Archiver behavior + public static let archiverBehavior = L10n.tr("Localizable", "eh_setting_view.title.archiver_behavior", fallback: "Archiver behavior") + /// Browsing country + public static let browsingCountry = L10n.tr("Localizable", "eh_setting_view.title.browsing_country", fallback: "Browsing country") + /// Comments sort order + public static let commentsSortOrder = L10n.tr("Localizable", "eh_setting_view.title.comments_sort_order", fallback: "Comments sort order") + /// Comment votes show timing + public static let commentsVotesShowTiming = L10n.tr("Localizable", "eh_setting_view.title.comments_votes_show_timing", fallback: "Comment votes show timing") + /// Display mode + public static let displayMode = L10n.tr("Localizable", "eh_setting_view.title.display_mode", fallback: "Display mode") + /// Display style + public static let displayStyle = L10n.tr("Localizable", "eh_setting_view.title.display_style", fallback: "Display style") + /// Enable thumbnail selector on gallery screen + public static let enableGalleryThumbnailSelector = L10n.tr("Localizable", "eh_setting_view.title.enable_gallery_thumbnail_selector", fallback: "Enable thumbnail selector on gallery screen") + /// Favorites sort order + public static let favoritesSortOrder = L10n.tr("Localizable", "eh_setting_view.title.favorites_sort_order", fallback: "Favorites sort order") + /// Gallery name + public static let galleryName = L10n.tr("Localizable", "eh_setting_view.title.gallery_name", fallback: "Gallery name") + /// Horizontal + public static let horizontal = L10n.tr("Localizable", "eh_setting_view.title.horizontal", fallback: "Horizontal") + /// %@ settings + public static func hostSettings(_ p1: Any) -> String { + return L10n.tr("Localizable", "eh_setting_view.title.host_settings", String(describing: p1), fallback: "%@ settings") + } + /// Image resolution + public static let imageResolution = L10n.tr("Localizable", "eh_setting_view.title.image_resolution", fallback: "Image resolution") + /// Image size + public static let imageSize = L10n.tr("Localizable", "eh_setting_view.title.image_size", fallback: "Image size") + /// IP address:Port + public static let ipAddressPort = L10n.tr("Localizable", "eh_setting_view.title.ip_address_port", fallback: "IP address:Port") + /// Load images through the Hath network + public static let loadImagesThroughTheHathNetwork = L10n.tr("Localizable", "eh_setting_view.title.load_images_through_the_hath_network", fallback: "Load images through the Hath network") + /// Ratings color + public static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.title.ratings_color", fallback: "Ratings color") + /// Result count + public static let resultCount = L10n.tr("Localizable", "eh_setting_view.title.result_count", fallback: "Result count") + /// Scale factor + public static let scaleFactor = L10n.tr("Localizable", "eh_setting_view.title.scale_factor", fallback: "Scale factor") + /// Selected profile + public static let selectedProfile = L10n.tr("Localizable", "eh_setting_view.title.selected_profile", fallback: "Selected profile") + /// Show filtered removal count + public static let showFilteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.title.show_filtered_removal_count", fallback: "Show filtered removal count") + /// Show label below gallery thumbnails + public static let showLabelBelowGalleryThumbnails = L10n.tr("Localizable", "eh_setting_view.title.show_label_below_gallery_thumbnails", fallback: "Show label below gallery thumbnails") + /// Show search range indicator + public static let showSearchRangeIndicator = L10n.tr("Localizable", "eh_setting_view.title.show_search_range_indicator", fallback: "Show search range indicator") + /// Show thumbnail pane + public static let showThumbnailPane = L10n.tr("Localizable", "eh_setting_view.title.show_thumbnail_pane", fallback: "Show thumbnail pane") + /// Tag Filtering Threshold + public static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.title.tag_filtering_threshold", fallback: "Tag Filtering Threshold") + /// Tag Watching Threshold + public static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.title.tag_watching_threshold", fallback: "Tag Watching Threshold") + /// Tags sort order + public static let tagsSortOrder = L10n.tr("Localizable", "eh_setting_view.title.tags_sort_order", fallback: "Tags sort order") + /// Thumbnail load timing + public static let thumbnailLoadTiming = L10n.tr("Localizable", "eh_setting_view.title.thumbnail_load_timing", fallback: "Thumbnail load timing") + /// Rows + public static let thumbnailRowCount = L10n.tr("Localizable", "eh_setting_view.title.thumbnail_row_count", fallback: "Rows") + /// Size + public static let thumbnailSize = L10n.tr("Localizable", "eh_setting_view.title.thumbnail_size", fallback: "Size") + /// Use Multi-Page Viewer + public static let useMultiPageViewer = L10n.tr("Localizable", "eh_setting_view.title.use_multi_page_viewer", fallback: "Use Multi-Page Viewer") + /// Use original images + public static let useOriginalImages = L10n.tr("Localizable", "eh_setting_view.title.use_original_images", fallback: "Use original images") + /// Vertical + public static let vertical = L10n.tr("Localizable", "eh_setting_view.title.vertical", fallback: "Vertical") + /// Virtual width + public static let virtualWidth = L10n.tr("Localizable", "eh_setting_view.title.virtual_width", fallback: "Virtual width") + } + public enum ToolbarItem { + public enum Button { + /// Done + public static let done = L10n.tr("Localizable", "eh_setting_view.toolbar_item.button.done", fallback: "Done") + } + } + } + public enum Enum { + public enum AppIconType { + public enum Value { + /// Default + public static let `default` = L10n.tr("Localizable", "enum.app_icon_type.value.default", fallback: "Default") + /// Developer + public static let developer = L10n.tr("Localizable", "enum.app_icon_type.value.developer", fallback: "Developer") + /// NOT MY PRESIDENT + public static let notMyPresident = L10n.tr("Localizable", "enum.app_icon_type.value.not_my_president", fallback: "NOT MY PRESIDENT") + /// Stand With Ukraine (2022) + public static let standWithUkraine2022 = L10n.tr("Localizable", "enum.app_icon_type.value.stand_with_ukraine_2022", fallback: "Stand With Ukraine (2022)") + /// Ukiyo-e + public static let ukiyoe = L10n.tr("Localizable", "enum.app_icon_type.value.ukiyoe", fallback: "Ukiyo-e") + } + } + public enum ArchiveResolution { + public enum Value { + /// Original + public static let original = L10n.tr("Localizable", "enum.archive_resolution.value.original", fallback: "Original") + } + } + public enum AutoLockPolicy { + public enum Value { + /// Instantly + public static let instantly = L10n.tr("Localizable", "enum.auto_lock_policy.value.instantly", fallback: "Instantly") + /// Never + public static let never = L10n.tr("Localizable", "enum.auto_lock_policy.value.never", fallback: "Never") + } + } + public enum AutoPlayPolicy { + public enum Value { + /// Off + public static let off = L10n.tr("Localizable", "enum.auto_play_policy.value.off", fallback: "Off") + } + } + public enum BanInterval { + public enum Description { + /// Localizable.strings + /// EhPanda + public static let and = L10n.tr("Localizable", "enum.ban_interval.description.and", fallback: "and") + } + } + public enum BrowsingCountry { + public enum Name { + /// Afghanistan + public static let afghanistan = L10n.tr("Localizable", "enum.browsing_country.name.afghanistan", fallback: "Afghanistan") + /// Aland Islands + public static let alandIslands = L10n.tr("Localizable", "enum.browsing_country.name.aland_islands", fallback: "Aland Islands") + /// Albania + public static let albania = L10n.tr("Localizable", "enum.browsing_country.name.albania", fallback: "Albania") + /// Algeria + public static let algeria = L10n.tr("Localizable", "enum.browsing_country.name.algeria", fallback: "Algeria") + /// American Samoa + public static let americanSamoa = L10n.tr("Localizable", "enum.browsing_country.name.american_samoa", fallback: "American Samoa") + /// Andorra + public static let andorra = L10n.tr("Localizable", "enum.browsing_country.name.andorra", fallback: "Andorra") + /// Angola + public static let angola = L10n.tr("Localizable", "enum.browsing_country.name.angola", fallback: "Angola") + /// Anguilla + public static let anguilla = L10n.tr("Localizable", "enum.browsing_country.name.anguilla", fallback: "Anguilla") + /// Antarctica + public static let antarctica = L10n.tr("Localizable", "enum.browsing_country.name.antarctica", fallback: "Antarctica") + /// Antigua and Barbuda + public static let antiguaAndBarbuda = L10n.tr("Localizable", "enum.browsing_country.name.antigua_and_barbuda", fallback: "Antigua and Barbuda") + /// Argentina + public static let argentina = L10n.tr("Localizable", "enum.browsing_country.name.argentina", fallback: "Argentina") + /// Armenia + public static let armenia = L10n.tr("Localizable", "enum.browsing_country.name.armenia", fallback: "Armenia") + /// Aruba + public static let aruba = L10n.tr("Localizable", "enum.browsing_country.name.aruba", fallback: "Aruba") + /// Asia-Pacific Region + public static let asiaPacificRegion = L10n.tr("Localizable", "enum.browsing_country.name.asia_pacific_region", fallback: "Asia-Pacific Region") + /// Australia + public static let australia = L10n.tr("Localizable", "enum.browsing_country.name.australia", fallback: "Australia") + /// Austria + public static let austria = L10n.tr("Localizable", "enum.browsing_country.name.austria", fallback: "Austria") + /// Auto-Detect + public static let autoDetect = L10n.tr("Localizable", "enum.browsing_country.name.auto_detect", fallback: "Auto-Detect") + /// Azerbaijan + public static let azerbaijan = L10n.tr("Localizable", "enum.browsing_country.name.azerbaijan", fallback: "Azerbaijan") + /// Bahamas + public static let bahamas = L10n.tr("Localizable", "enum.browsing_country.name.bahamas", fallback: "Bahamas") + /// Bahrain + public static let bahrain = L10n.tr("Localizable", "enum.browsing_country.name.bahrain", fallback: "Bahrain") + /// Bangladesh + public static let bangladesh = L10n.tr("Localizable", "enum.browsing_country.name.bangladesh", fallback: "Bangladesh") + /// Barbados + public static let barbados = L10n.tr("Localizable", "enum.browsing_country.name.barbados", fallback: "Barbados") + /// Belarus + public static let belarus = L10n.tr("Localizable", "enum.browsing_country.name.belarus", fallback: "Belarus") + /// Belgium + public static let belgium = L10n.tr("Localizable", "enum.browsing_country.name.belgium", fallback: "Belgium") + /// Belize + public static let belize = L10n.tr("Localizable", "enum.browsing_country.name.belize", fallback: "Belize") + /// Benin + public static let benin = L10n.tr("Localizable", "enum.browsing_country.name.benin", fallback: "Benin") + /// Bermuda + public static let bermuda = L10n.tr("Localizable", "enum.browsing_country.name.bermuda", fallback: "Bermuda") + /// Bhutan + public static let bhutan = L10n.tr("Localizable", "enum.browsing_country.name.bhutan", fallback: "Bhutan") + /// Bolivia + public static let bolivia = L10n.tr("Localizable", "enum.browsing_country.name.bolivia", fallback: "Bolivia") + /// Bonaire Saint Eustatius and Saba + public static let bonaireSaintEustatiusAndSaba = L10n.tr("Localizable", "enum.browsing_country.name.bonaire_saint_eustatius_and_saba", fallback: "Bonaire Saint Eustatius and Saba") + /// Bosnia and Herzegovina + public static let bosniaAndHerzegovina = L10n.tr("Localizable", "enum.browsing_country.name.bosnia_and_herzegovina", fallback: "Bosnia and Herzegovina") + /// Botswana + public static let botswana = L10n.tr("Localizable", "enum.browsing_country.name.botswana", fallback: "Botswana") + /// Bouvet Island + public static let bouvetIsland = L10n.tr("Localizable", "enum.browsing_country.name.bouvet_island", fallback: "Bouvet Island") + /// Brazil + public static let brazil = L10n.tr("Localizable", "enum.browsing_country.name.brazil", fallback: "Brazil") + /// British Indian Ocean Territory + public static let britishIndianOceanTerritory = L10n.tr("Localizable", "enum.browsing_country.name.british_indian_ocean_territory", fallback: "British Indian Ocean Territory") + /// Brunei Darussalam + public static let bruneiDarussalam = L10n.tr("Localizable", "enum.browsing_country.name.brunei_darussalam", fallback: "Brunei Darussalam") + /// Bulgaria + public static let bulgaria = L10n.tr("Localizable", "enum.browsing_country.name.bulgaria", fallback: "Bulgaria") + /// Burkina Faso + public static let burkinaFaso = L10n.tr("Localizable", "enum.browsing_country.name.burkina_faso", fallback: "Burkina Faso") + /// Burundi + public static let burundi = L10n.tr("Localizable", "enum.browsing_country.name.burundi", fallback: "Burundi") + /// Cambodia + public static let cambodia = L10n.tr("Localizable", "enum.browsing_country.name.cambodia", fallback: "Cambodia") + /// Cameroon + public static let cameroon = L10n.tr("Localizable", "enum.browsing_country.name.cameroon", fallback: "Cameroon") + /// Canada + public static let canada = L10n.tr("Localizable", "enum.browsing_country.name.canada", fallback: "Canada") + /// Cape Verde + public static let capeVerde = L10n.tr("Localizable", "enum.browsing_country.name.cape_verde", fallback: "Cape Verde") + /// Cayman Islands + public static let caymanIslands = L10n.tr("Localizable", "enum.browsing_country.name.cayman_islands", fallback: "Cayman Islands") + /// Central African Republic + public static let centralAfricanRepublic = L10n.tr("Localizable", "enum.browsing_country.name.central_african_republic", fallback: "Central African Republic") + /// Chad + public static let chad = L10n.tr("Localizable", "enum.browsing_country.name.chad", fallback: "Chad") + /// Chile + public static let chile = L10n.tr("Localizable", "enum.browsing_country.name.chile", fallback: "Chile") + /// China + public static let china = L10n.tr("Localizable", "enum.browsing_country.name.china", fallback: "China") + /// Christmas Island + public static let christmasIsland = L10n.tr("Localizable", "enum.browsing_country.name.christmas_island", fallback: "Christmas Island") + /// Cocos Islands + public static let cocosIslands = L10n.tr("Localizable", "enum.browsing_country.name.cocos_islands", fallback: "Cocos Islands") + /// Colombia + public static let colombia = L10n.tr("Localizable", "enum.browsing_country.name.colombia", fallback: "Colombia") + /// Comoros + public static let comoros = L10n.tr("Localizable", "enum.browsing_country.name.comoros", fallback: "Comoros") + /// Congo + public static let congo = L10n.tr("Localizable", "enum.browsing_country.name.congo", fallback: "Congo") + /// Cook Islands + public static let cookIslands = L10n.tr("Localizable", "enum.browsing_country.name.cook_islands", fallback: "Cook Islands") + /// Costa Rica + public static let costaRica = L10n.tr("Localizable", "enum.browsing_country.name.costa_rica", fallback: "Costa Rica") + /// Cote D'Ivoire + public static let coteDIvoire = L10n.tr("Localizable", "enum.browsing_country.name.cote_d_ivoire", fallback: "Cote D'Ivoire") + /// Croatia + public static let croatia = L10n.tr("Localizable", "enum.browsing_country.name.croatia", fallback: "Croatia") + /// Cuba + public static let cuba = L10n.tr("Localizable", "enum.browsing_country.name.cuba", fallback: "Cuba") + /// Curacao + public static let curacao = L10n.tr("Localizable", "enum.browsing_country.name.curacao", fallback: "Curacao") + /// Cyprus + public static let cyprus = L10n.tr("Localizable", "enum.browsing_country.name.cyprus", fallback: "Cyprus") + /// Czech Republic + public static let czechRepublic = L10n.tr("Localizable", "enum.browsing_country.name.czech_republic", fallback: "Czech Republic") + /// Denmark + public static let denmark = L10n.tr("Localizable", "enum.browsing_country.name.denmark", fallback: "Denmark") + /// Djibouti + public static let djibouti = L10n.tr("Localizable", "enum.browsing_country.name.djibouti", fallback: "Djibouti") + /// Dominica + public static let dominica = L10n.tr("Localizable", "enum.browsing_country.name.dominica", fallback: "Dominica") + /// Dominican Republic + public static let dominicanRepublic = L10n.tr("Localizable", "enum.browsing_country.name.dominican_republic", fallback: "Dominican Republic") + /// Ecuador + public static let ecuador = L10n.tr("Localizable", "enum.browsing_country.name.ecuador", fallback: "Ecuador") + /// Egypt + public static let egypt = L10n.tr("Localizable", "enum.browsing_country.name.egypt", fallback: "Egypt") + /// El Salvador + public static let elSalvador = L10n.tr("Localizable", "enum.browsing_country.name.el_salvador", fallback: "El Salvador") + /// Equatorial Guinea + public static let equatorialGuinea = L10n.tr("Localizable", "enum.browsing_country.name.equatorial_guinea", fallback: "Equatorial Guinea") + /// Eritrea + public static let eritrea = L10n.tr("Localizable", "enum.browsing_country.name.eritrea", fallback: "Eritrea") + /// Estonia + public static let estonia = L10n.tr("Localizable", "enum.browsing_country.name.estonia", fallback: "Estonia") + /// Ethiopia + public static let ethiopia = L10n.tr("Localizable", "enum.browsing_country.name.ethiopia", fallback: "Ethiopia") + /// Europe + public static let europe = L10n.tr("Localizable", "enum.browsing_country.name.europe", fallback: "Europe") + /// Falkland Islands + public static let falklandIslands = L10n.tr("Localizable", "enum.browsing_country.name.falkland_islands", fallback: "Falkland Islands") + /// Faroe Islands + public static let faroeIslands = L10n.tr("Localizable", "enum.browsing_country.name.faroe_islands", fallback: "Faroe Islands") + /// Fiji + public static let fiji = L10n.tr("Localizable", "enum.browsing_country.name.fiji", fallback: "Fiji") + /// Finland + public static let finland = L10n.tr("Localizable", "enum.browsing_country.name.finland", fallback: "Finland") + /// France + public static let france = L10n.tr("Localizable", "enum.browsing_country.name.france", fallback: "France") + /// French Guiana + public static let frenchGuiana = L10n.tr("Localizable", "enum.browsing_country.name.french_guiana", fallback: "French Guiana") + /// French Polynesia + public static let frenchPolynesia = L10n.tr("Localizable", "enum.browsing_country.name.french_polynesia", fallback: "French Polynesia") + /// French Southern Territories + public static let frenchSouthernTerritories = L10n.tr("Localizable", "enum.browsing_country.name.french_southern_territories", fallback: "French Southern Territories") + /// Gabon + public static let gabon = L10n.tr("Localizable", "enum.browsing_country.name.gabon", fallback: "Gabon") + /// Gambia + public static let gambia = L10n.tr("Localizable", "enum.browsing_country.name.gambia", fallback: "Gambia") + /// Georgia + public static let georgia = L10n.tr("Localizable", "enum.browsing_country.name.georgia", fallback: "Georgia") + /// Germany + public static let germany = L10n.tr("Localizable", "enum.browsing_country.name.germany", fallback: "Germany") + /// Ghana + public static let ghana = L10n.tr("Localizable", "enum.browsing_country.name.ghana", fallback: "Ghana") + /// Gibraltar + public static let gibraltar = L10n.tr("Localizable", "enum.browsing_country.name.gibraltar", fallback: "Gibraltar") + /// Greece + public static let greece = L10n.tr("Localizable", "enum.browsing_country.name.greece", fallback: "Greece") + /// Greenland + public static let greenland = L10n.tr("Localizable", "enum.browsing_country.name.greenland", fallback: "Greenland") + /// Grenada + public static let grenada = L10n.tr("Localizable", "enum.browsing_country.name.grenada", fallback: "Grenada") + /// Guadeloupe + public static let guadeloupe = L10n.tr("Localizable", "enum.browsing_country.name.guadeloupe", fallback: "Guadeloupe") + /// Guam + public static let guam = L10n.tr("Localizable", "enum.browsing_country.name.guam", fallback: "Guam") + /// Guatemala + public static let guatemala = L10n.tr("Localizable", "enum.browsing_country.name.guatemala", fallback: "Guatemala") + /// Guernsey + public static let guernsey = L10n.tr("Localizable", "enum.browsing_country.name.guernsey", fallback: "Guernsey") + /// Guinea + public static let guinea = L10n.tr("Localizable", "enum.browsing_country.name.guinea", fallback: "Guinea") + /// Guinea-Bissau + public static let guineaBissau = L10n.tr("Localizable", "enum.browsing_country.name.guinea_bissau", fallback: "Guinea-Bissau") + /// Guyana + public static let guyana = L10n.tr("Localizable", "enum.browsing_country.name.guyana", fallback: "Guyana") + /// Haiti + public static let haiti = L10n.tr("Localizable", "enum.browsing_country.name.haiti", fallback: "Haiti") + /// Heard Island and McDonald Islands + public static let heardIslandAndMcDonaldIslands = L10n.tr("Localizable", "enum.browsing_country.name.heard_island_and_mc_donald_islands", fallback: "Heard Island and McDonald Islands") + /// Honduras + public static let honduras = L10n.tr("Localizable", "enum.browsing_country.name.honduras", fallback: "Honduras") + /// Hong Kong + public static let hongKong = L10n.tr("Localizable", "enum.browsing_country.name.hong_kong", fallback: "Hong Kong") + /// Hungary + public static let hungary = L10n.tr("Localizable", "enum.browsing_country.name.hungary", fallback: "Hungary") + /// Iceland + public static let iceland = L10n.tr("Localizable", "enum.browsing_country.name.iceland", fallback: "Iceland") + /// India + public static let india = L10n.tr("Localizable", "enum.browsing_country.name.india", fallback: "India") + /// Indonesia + public static let indonesia = L10n.tr("Localizable", "enum.browsing_country.name.indonesia", fallback: "Indonesia") + /// Iran + public static let iran = L10n.tr("Localizable", "enum.browsing_country.name.iran", fallback: "Iran") + /// Iraq + public static let iraq = L10n.tr("Localizable", "enum.browsing_country.name.iraq", fallback: "Iraq") + /// Ireland + public static let ireland = L10n.tr("Localizable", "enum.browsing_country.name.ireland", fallback: "Ireland") + /// Isle of Man + public static let isleOfMan = L10n.tr("Localizable", "enum.browsing_country.name.isle_of_man", fallback: "Isle of Man") + /// Israel + public static let israel = L10n.tr("Localizable", "enum.browsing_country.name.israel", fallback: "Israel") + /// Italy + public static let italy = L10n.tr("Localizable", "enum.browsing_country.name.italy", fallback: "Italy") + /// Jamaica + public static let jamaica = L10n.tr("Localizable", "enum.browsing_country.name.jamaica", fallback: "Jamaica") + /// Japan + public static let japan = L10n.tr("Localizable", "enum.browsing_country.name.japan", fallback: "Japan") + /// Jersey + public static let jersey = L10n.tr("Localizable", "enum.browsing_country.name.jersey", fallback: "Jersey") + /// Jordan + public static let jordan = L10n.tr("Localizable", "enum.browsing_country.name.jordan", fallback: "Jordan") + /// Kazakhstan + public static let kazakhstan = L10n.tr("Localizable", "enum.browsing_country.name.kazakhstan", fallback: "Kazakhstan") + /// Kenya + public static let kenya = L10n.tr("Localizable", "enum.browsing_country.name.kenya", fallback: "Kenya") + /// Kiribati + public static let kiribati = L10n.tr("Localizable", "enum.browsing_country.name.kiribati", fallback: "Kiribati") + /// Kuwait + public static let kuwait = L10n.tr("Localizable", "enum.browsing_country.name.kuwait", fallback: "Kuwait") + /// Kyrgyzstan + public static let kyrgyzstan = L10n.tr("Localizable", "enum.browsing_country.name.kyrgyzstan", fallback: "Kyrgyzstan") + /// Lao People's Democratic Republic + public static let laoPeoplesDemocraticRepublic = L10n.tr("Localizable", "enum.browsing_country.name.lao_peoples_democratic_republic", fallback: "Lao People's Democratic Republic") + /// Latvia + public static let latvia = L10n.tr("Localizable", "enum.browsing_country.name.latvia", fallback: "Latvia") + /// Lebanon + public static let lebanon = L10n.tr("Localizable", "enum.browsing_country.name.lebanon", fallback: "Lebanon") + /// Lesotho + public static let lesotho = L10n.tr("Localizable", "enum.browsing_country.name.lesotho", fallback: "Lesotho") + /// Liberia + public static let liberia = L10n.tr("Localizable", "enum.browsing_country.name.liberia", fallback: "Liberia") + /// Libya + public static let libya = L10n.tr("Localizable", "enum.browsing_country.name.libya", fallback: "Libya") + /// Liechtenstein + public static let liechtenstein = L10n.tr("Localizable", "enum.browsing_country.name.liechtenstein", fallback: "Liechtenstein") + /// Lithuania + public static let lithuania = L10n.tr("Localizable", "enum.browsing_country.name.lithuania", fallback: "Lithuania") + /// Luxembourg + public static let luxembourg = L10n.tr("Localizable", "enum.browsing_country.name.luxembourg", fallback: "Luxembourg") + /// Macau + public static let macau = L10n.tr("Localizable", "enum.browsing_country.name.macau", fallback: "Macau") + /// Macedonia + public static let macedonia = L10n.tr("Localizable", "enum.browsing_country.name.macedonia", fallback: "Macedonia") + /// Madagascar + public static let madagascar = L10n.tr("Localizable", "enum.browsing_country.name.madagascar", fallback: "Madagascar") + /// Malawi + public static let malawi = L10n.tr("Localizable", "enum.browsing_country.name.malawi", fallback: "Malawi") + /// Malaysia + public static let malaysia = L10n.tr("Localizable", "enum.browsing_country.name.malaysia", fallback: "Malaysia") + /// Maldives + public static let maldives = L10n.tr("Localizable", "enum.browsing_country.name.maldives", fallback: "Maldives") + /// Mali + public static let mali = L10n.tr("Localizable", "enum.browsing_country.name.mali", fallback: "Mali") + /// Malta + public static let malta = L10n.tr("Localizable", "enum.browsing_country.name.malta", fallback: "Malta") + /// Marshall Islands + public static let marshallIslands = L10n.tr("Localizable", "enum.browsing_country.name.marshall_islands", fallback: "Marshall Islands") + /// Martinique + public static let martinique = L10n.tr("Localizable", "enum.browsing_country.name.martinique", fallback: "Martinique") + /// Mauritania + public static let mauritania = L10n.tr("Localizable", "enum.browsing_country.name.mauritania", fallback: "Mauritania") + /// Mauritius + public static let mauritius = L10n.tr("Localizable", "enum.browsing_country.name.mauritius", fallback: "Mauritius") + /// Mayotte + public static let mayotte = L10n.tr("Localizable", "enum.browsing_country.name.mayotte", fallback: "Mayotte") + /// Mexico + public static let mexico = L10n.tr("Localizable", "enum.browsing_country.name.mexico", fallback: "Mexico") + /// Micronesia + public static let micronesia = L10n.tr("Localizable", "enum.browsing_country.name.micronesia", fallback: "Micronesia") + /// Moldova + public static let moldova = L10n.tr("Localizable", "enum.browsing_country.name.moldova", fallback: "Moldova") + /// Monaco + public static let monaco = L10n.tr("Localizable", "enum.browsing_country.name.monaco", fallback: "Monaco") + /// Mongolia + public static let mongolia = L10n.tr("Localizable", "enum.browsing_country.name.mongolia", fallback: "Mongolia") + /// Montenegro + public static let montenegro = L10n.tr("Localizable", "enum.browsing_country.name.montenegro", fallback: "Montenegro") + /// Montserrat + public static let montserrat = L10n.tr("Localizable", "enum.browsing_country.name.montserrat", fallback: "Montserrat") + /// Morocco + public static let morocco = L10n.tr("Localizable", "enum.browsing_country.name.morocco", fallback: "Morocco") + /// Mozambique + public static let mozambique = L10n.tr("Localizable", "enum.browsing_country.name.mozambique", fallback: "Mozambique") + /// Myanmar + public static let myanmar = L10n.tr("Localizable", "enum.browsing_country.name.myanmar", fallback: "Myanmar") + /// Namibia + public static let namibia = L10n.tr("Localizable", "enum.browsing_country.name.namibia", fallback: "Namibia") + /// Nauru + public static let nauru = L10n.tr("Localizable", "enum.browsing_country.name.nauru", fallback: "Nauru") + /// Nepal + public static let nepal = L10n.tr("Localizable", "enum.browsing_country.name.nepal", fallback: "Nepal") + /// Netherlands + public static let netherlands = L10n.tr("Localizable", "enum.browsing_country.name.netherlands", fallback: "Netherlands") + /// New Caledonia + public static let newCaledonia = L10n.tr("Localizable", "enum.browsing_country.name.new_caledonia", fallback: "New Caledonia") + /// New Zealand + public static let newZealand = L10n.tr("Localizable", "enum.browsing_country.name.new_zealand", fallback: "New Zealand") + /// Nicaragua + public static let nicaragua = L10n.tr("Localizable", "enum.browsing_country.name.nicaragua", fallback: "Nicaragua") + /// Niger + public static let niger = L10n.tr("Localizable", "enum.browsing_country.name.niger", fallback: "Niger") + /// Nigeria + public static let nigeria = L10n.tr("Localizable", "enum.browsing_country.name.nigeria", fallback: "Nigeria") + /// Niue + public static let niue = L10n.tr("Localizable", "enum.browsing_country.name.niue", fallback: "Niue") + /// Norfolk Island + public static let norfolkIsland = L10n.tr("Localizable", "enum.browsing_country.name.norfolk_island", fallback: "Norfolk Island") + /// North Korea + public static let northKorea = L10n.tr("Localizable", "enum.browsing_country.name.north_korea", fallback: "North Korea") + /// Northern Mariana Islands + public static let northernMarianaIslands = L10n.tr("Localizable", "enum.browsing_country.name.northern_mariana_islands", fallback: "Northern Mariana Islands") + /// Norway + public static let norway = L10n.tr("Localizable", "enum.browsing_country.name.norway", fallback: "Norway") + /// Oman + public static let oman = L10n.tr("Localizable", "enum.browsing_country.name.oman", fallback: "Oman") + /// Pakistan + public static let pakistan = L10n.tr("Localizable", "enum.browsing_country.name.pakistan", fallback: "Pakistan") + /// Palau + public static let palau = L10n.tr("Localizable", "enum.browsing_country.name.palau", fallback: "Palau") + /// Palestinian Territory + public static let palestinianTerritory = L10n.tr("Localizable", "enum.browsing_country.name.palestinian_territory", fallback: "Palestinian Territory") + /// Panama + public static let panama = L10n.tr("Localizable", "enum.browsing_country.name.panama", fallback: "Panama") + /// Papua New Guinea + public static let papuaNewGuinea = L10n.tr("Localizable", "enum.browsing_country.name.papua_new_guinea", fallback: "Papua New Guinea") + /// Paraguay + public static let paraguay = L10n.tr("Localizable", "enum.browsing_country.name.paraguay", fallback: "Paraguay") + /// Peru + public static let peru = L10n.tr("Localizable", "enum.browsing_country.name.peru", fallback: "Peru") + /// Philippines + public static let philippines = L10n.tr("Localizable", "enum.browsing_country.name.philippines", fallback: "Philippines") + /// Pitcairn Islands + public static let pitcairnIslands = L10n.tr("Localizable", "enum.browsing_country.name.pitcairn_islands", fallback: "Pitcairn Islands") + /// Poland + public static let poland = L10n.tr("Localizable", "enum.browsing_country.name.poland", fallback: "Poland") + /// Portugal + public static let portugal = L10n.tr("Localizable", "enum.browsing_country.name.portugal", fallback: "Portugal") + /// Puerto Rico + public static let puertoRico = L10n.tr("Localizable", "enum.browsing_country.name.puerto_rico", fallback: "Puerto Rico") + /// Qatar + public static let qatar = L10n.tr("Localizable", "enum.browsing_country.name.qatar", fallback: "Qatar") + /// Reunion + public static let reunion = L10n.tr("Localizable", "enum.browsing_country.name.reunion", fallback: "Reunion") + /// Romania + public static let romania = L10n.tr("Localizable", "enum.browsing_country.name.romania", fallback: "Romania") + /// Russian Federation + public static let russianFederation = L10n.tr("Localizable", "enum.browsing_country.name.russian_federation", fallback: "Russian Federation") + /// Rwanda + public static let rwanda = L10n.tr("Localizable", "enum.browsing_country.name.rwanda", fallback: "Rwanda") + /// Saint Barthelemy + public static let saintBarthelemy = L10n.tr("Localizable", "enum.browsing_country.name.saint_barthelemy", fallback: "Saint Barthelemy") + /// Saint Helena + public static let saintHelena = L10n.tr("Localizable", "enum.browsing_country.name.saint_helena", fallback: "Saint Helena") + /// Saint Kitts and Nevis + public static let saintKittsAndNevis = L10n.tr("Localizable", "enum.browsing_country.name.saint_kitts_and_nevis", fallback: "Saint Kitts and Nevis") + /// Saint Lucia + public static let saintLucia = L10n.tr("Localizable", "enum.browsing_country.name.saint_lucia", fallback: "Saint Lucia") + /// Saint Martin + public static let saintMartin = L10n.tr("Localizable", "enum.browsing_country.name.saint_martin", fallback: "Saint Martin") + /// Saint Pierre and Miquelon + public static let saintPierreAndMiquelon = L10n.tr("Localizable", "enum.browsing_country.name.saint_pierre_and_miquelon", fallback: "Saint Pierre and Miquelon") + /// Saint Vincent and the Grenadines + public static let saintVincentAndTheGrenadines = L10n.tr("Localizable", "enum.browsing_country.name.saint_vincent_and_the_grenadines", fallback: "Saint Vincent and the Grenadines") + /// Samoa + public static let samoa = L10n.tr("Localizable", "enum.browsing_country.name.samoa", fallback: "Samoa") + /// San Marino + public static let sanMarino = L10n.tr("Localizable", "enum.browsing_country.name.san_marino", fallback: "San Marino") + /// Sao Tome and Principe + public static let saoTomeAndPrincipe = L10n.tr("Localizable", "enum.browsing_country.name.sao_tome_and_principe", fallback: "Sao Tome and Principe") + /// Saudi Arabia + public static let saudiArabia = L10n.tr("Localizable", "enum.browsing_country.name.saudi_arabia", fallback: "Saudi Arabia") + /// Senegal + public static let senegal = L10n.tr("Localizable", "enum.browsing_country.name.senegal", fallback: "Senegal") + /// Serbia + public static let serbia = L10n.tr("Localizable", "enum.browsing_country.name.serbia", fallback: "Serbia") + /// Seychelles + public static let seychelles = L10n.tr("Localizable", "enum.browsing_country.name.seychelles", fallback: "Seychelles") + /// Sierra Leone + public static let sierraLeone = L10n.tr("Localizable", "enum.browsing_country.name.sierra_leone", fallback: "Sierra Leone") + /// Singapore + public static let singapore = L10n.tr("Localizable", "enum.browsing_country.name.singapore", fallback: "Singapore") + /// Sint Maarten + public static let sintMaarten = L10n.tr("Localizable", "enum.browsing_country.name.sint_maarten", fallback: "Sint Maarten") + /// Slovakia + public static let slovakia = L10n.tr("Localizable", "enum.browsing_country.name.slovakia", fallback: "Slovakia") + /// Slovenia + public static let slovenia = L10n.tr("Localizable", "enum.browsing_country.name.slovenia", fallback: "Slovenia") + /// Solomon Islands + public static let solomonIslands = L10n.tr("Localizable", "enum.browsing_country.name.solomon_islands", fallback: "Solomon Islands") + /// Somalia + public static let somalia = L10n.tr("Localizable", "enum.browsing_country.name.somalia", fallback: "Somalia") + /// South Africa + public static let southAfrica = L10n.tr("Localizable", "enum.browsing_country.name.south_africa", fallback: "South Africa") + /// South Georgia and the South Sandwich Islands + public static let southGeorgiaAndTheSouthSandwichIslands = L10n.tr("Localizable", "enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands", fallback: "South Georgia and the South Sandwich Islands") + /// South Korea + public static let southKorea = L10n.tr("Localizable", "enum.browsing_country.name.south_korea", fallback: "South Korea") + /// South Sudan + public static let southSudan = L10n.tr("Localizable", "enum.browsing_country.name.south_sudan", fallback: "South Sudan") + /// Spain + public static let spain = L10n.tr("Localizable", "enum.browsing_country.name.spain", fallback: "Spain") + /// Sri Lanka + public static let sriLanka = L10n.tr("Localizable", "enum.browsing_country.name.sri_lanka", fallback: "Sri Lanka") + /// Sudan + public static let sudan = L10n.tr("Localizable", "enum.browsing_country.name.sudan", fallback: "Sudan") + /// Suriname + public static let suriname = L10n.tr("Localizable", "enum.browsing_country.name.suriname", fallback: "Suriname") + /// Svalbard and Jan Mayen + public static let svalbardAndJanMayen = L10n.tr("Localizable", "enum.browsing_country.name.svalbard_and_jan_mayen", fallback: "Svalbard and Jan Mayen") + /// Swaziland + public static let swaziland = L10n.tr("Localizable", "enum.browsing_country.name.swaziland", fallback: "Swaziland") + /// Sweden + public static let sweden = L10n.tr("Localizable", "enum.browsing_country.name.sweden", fallback: "Sweden") + /// Switzerland + public static let switzerland = L10n.tr("Localizable", "enum.browsing_country.name.switzerland", fallback: "Switzerland") + /// Syrian Arab Republic + public static let syrianArabRepublic = L10n.tr("Localizable", "enum.browsing_country.name.syrian_arab_republic", fallback: "Syrian Arab Republic") + /// Taiwan + public static let taiwan = L10n.tr("Localizable", "enum.browsing_country.name.taiwan", fallback: "Taiwan") + /// Tajikistan + public static let tajikistan = L10n.tr("Localizable", "enum.browsing_country.name.tajikistan", fallback: "Tajikistan") + /// Tanzania + public static let tanzania = L10n.tr("Localizable", "enum.browsing_country.name.tanzania", fallback: "Tanzania") + /// Thailand + public static let thailand = L10n.tr("Localizable", "enum.browsing_country.name.thailand", fallback: "Thailand") + /// The Democratic Republic of the Congo + public static let theDemocraticRepublicOfTheCongo = L10n.tr("Localizable", "enum.browsing_country.name.the_democratic_republic_of_the_congo", fallback: "The Democratic Republic of the Congo") + /// Timor-Leste + public static let timorLeste = L10n.tr("Localizable", "enum.browsing_country.name.timor_leste", fallback: "Timor-Leste") + /// Togo + public static let togo = L10n.tr("Localizable", "enum.browsing_country.name.togo", fallback: "Togo") + /// Tokelau + public static let tokelau = L10n.tr("Localizable", "enum.browsing_country.name.tokelau", fallback: "Tokelau") + /// Tonga + public static let tonga = L10n.tr("Localizable", "enum.browsing_country.name.tonga", fallback: "Tonga") + /// Trinidad and Tobago + public static let trinidadAndTobago = L10n.tr("Localizable", "enum.browsing_country.name.trinidad_and_tobago", fallback: "Trinidad and Tobago") + /// Tunisia + public static let tunisia = L10n.tr("Localizable", "enum.browsing_country.name.tunisia", fallback: "Tunisia") + /// Turkey + public static let turkey = L10n.tr("Localizable", "enum.browsing_country.name.turkey", fallback: "Turkey") + /// Turkmenistan + public static let turkmenistan = L10n.tr("Localizable", "enum.browsing_country.name.turkmenistan", fallback: "Turkmenistan") + /// Turks and Caicos Islands + public static let turksAndCaicosIslands = L10n.tr("Localizable", "enum.browsing_country.name.turks_and_caicos_islands", fallback: "Turks and Caicos Islands") + /// Tuvalu + public static let tuvalu = L10n.tr("Localizable", "enum.browsing_country.name.tuvalu", fallback: "Tuvalu") + /// Uganda + public static let uganda = L10n.tr("Localizable", "enum.browsing_country.name.uganda", fallback: "Uganda") + /// Ukraine + public static let ukraine = L10n.tr("Localizable", "enum.browsing_country.name.ukraine", fallback: "Ukraine") + /// United Arab Emirates + public static let unitedArabEmirates = L10n.tr("Localizable", "enum.browsing_country.name.united_arab_emirates", fallback: "United Arab Emirates") + /// United Kingdom + public static let unitedKingdom = L10n.tr("Localizable", "enum.browsing_country.name.united_kingdom", fallback: "United Kingdom") + /// United States + public static let unitedStates = L10n.tr("Localizable", "enum.browsing_country.name.united_states", fallback: "United States") + /// United States Minor Outlying Islands + public static let unitedStatesMinorOutlyingIslands = L10n.tr("Localizable", "enum.browsing_country.name.united_states_minor_outlying_islands", fallback: "United States Minor Outlying Islands") + /// Uruguay + public static let uruguay = L10n.tr("Localizable", "enum.browsing_country.name.uruguay", fallback: "Uruguay") + /// Uzbekistan + public static let uzbekistan = L10n.tr("Localizable", "enum.browsing_country.name.uzbekistan", fallback: "Uzbekistan") + /// Vanuatu + public static let vanuatu = L10n.tr("Localizable", "enum.browsing_country.name.vanuatu", fallback: "Vanuatu") + /// Vatican City State + public static let vaticanCityState = L10n.tr("Localizable", "enum.browsing_country.name.vatican_city_state", fallback: "Vatican City State") + /// Venezuela + public static let venezuela = L10n.tr("Localizable", "enum.browsing_country.name.venezuela", fallback: "Venezuela") + /// Vietnam + public static let vietnam = L10n.tr("Localizable", "enum.browsing_country.name.vietnam", fallback: "Vietnam") + /// British Virgin Islands + public static let virginIslandsBritish = L10n.tr("Localizable", "enum.browsing_country.name.virgin_islands_british", fallback: "British Virgin Islands") + /// U.S. Virgin Islands + public static let virginIslandsUS = L10n.tr("Localizable", "enum.browsing_country.name.virgin_islands_US", fallback: "U.S. Virgin Islands") + /// Wallis and Futuna + public static let wallisAndFutuna = L10n.tr("Localizable", "enum.browsing_country.name.wallis_and_futuna", fallback: "Wallis and Futuna") + /// Western Sahara + public static let westernSahara = L10n.tr("Localizable", "enum.browsing_country.name.western_sahara", fallback: "Western Sahara") + /// Yemen + public static let yemen = L10n.tr("Localizable", "enum.browsing_country.name.yemen", fallback: "Yemen") + /// Zambia + public static let zambia = L10n.tr("Localizable", "enum.browsing_country.name.zambia", fallback: "Zambia") + /// Zimbabwe + public static let zimbabwe = L10n.tr("Localizable", "enum.browsing_country.name.zimbabwe", fallback: "Zimbabwe") + } + } + public enum Category { + public enum Value { + /// Artist CG + public static let artistCG = L10n.tr("Localizable", "enum.category.value.artist_CG", fallback: "Artist CG") + /// Asian Porn + public static let asianPorn = L10n.tr("Localizable", "enum.category.value.asian_porn", fallback: "Asian Porn") + /// Cosplay + public static let cosplay = L10n.tr("Localizable", "enum.category.value.cosplay", fallback: "Cosplay") + /// Doujinshi + public static let doujinshi = L10n.tr("Localizable", "enum.category.value.doujinshi", fallback: "Doujinshi") + /// Game CG + public static let gameCG = L10n.tr("Localizable", "enum.category.value.game_CG", fallback: "Game CG") + /// Image Set + public static let imageSet = L10n.tr("Localizable", "enum.category.value.image_set", fallback: "Image Set") + /// Manga + public static let manga = L10n.tr("Localizable", "enum.category.value.manga", fallback: "Manga") + /// Misc + public static let misc = L10n.tr("Localizable", "enum.category.value.misc", fallback: "Misc") + /// Non-H + public static let nonH = L10n.tr("Localizable", "enum.category.value.non_h", fallback: "Non-H") + /// Private + public static let `private` = L10n.tr("Localizable", "enum.category.value.private", fallback: "Private") + /// Western + public static let western = L10n.tr("Localizable", "enum.category.value.western", fallback: "Western") + } + } + public enum DownloadFolderFilter { + public enum Title { + /// All + public static let all = L10n.tr("Localizable", "enum.download_folder_filter.title.all", fallback: "All") + } + } + public enum EhSetting { + public enum ArchiverBehavior { + public enum Value { + /// Auto Select Original, Auto Start + public static let autoSelectOriginalAutoStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start", fallback: "Auto Select Original, Auto Start") + /// Auto Select Original, Manual Start + public static let autoSelectOriginalManualStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start", fallback: "Auto Select Original, Manual Start") + /// Auto Select Resample, Auto Start + public static let autoSelectResampleAutoStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start", fallback: "Auto Select Resample, Auto Start") + /// Auto Select Resample, Manual Start + public static let autoSelectResampleManualStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start", fallback: "Auto Select Resample, Manual Start") + /// Manual Select, Auto Start + public static let manualSelectAutoStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.manual_select_auto_start", fallback: "Manual Select, Auto Start") + /// Manual Select, Manual Start (Default) + public static let manualSelectManualStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.manual_select_manual_start", fallback: "Manual Select, Manual Start (Default)") + } + } + public enum CommentsSortOrder { + public enum Value { + /// By highest score + public static let highestScore = L10n.tr("Localizable", "enum.eh_setting.comments_sort_order.value.highest_score", fallback: "By highest score") + /// Oldest comments first + public static let oldest = L10n.tr("Localizable", "enum.eh_setting.comments_sort_order.value.oldest", fallback: "Oldest comments first") + /// Recent comments first + public static let recent = L10n.tr("Localizable", "enum.eh_setting.comments_sort_order.value.recent", fallback: "Recent comments first") + } + } + public enum CommentsVotesShowTiming { + public enum Value { + /// Always + public static let always = L10n.tr("Localizable", "enum.eh_setting.comments_votes_show_timing.value.always", fallback: "Always") + /// On score hover or click + public static let onHoverOrClick = L10n.tr("Localizable", "enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click", fallback: "On score hover or click") + } + } + public enum DisplayMode { + public enum Value { + /// Compact + public static let compact = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.compact", fallback: "Compact") + /// Extended + public static let extended = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.extended", fallback: "Extended") + /// Minimal + public static let minimal = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.minimal", fallback: "Minimal") + /// Minimal+ + public static let minimalPlus = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.minimalPlus", fallback: "Minimal+") + /// Thumbnail + public static let thumbnail = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.thumbnail", fallback: "Thumbnail") + } + } + public enum ExcludedLanguagesCategory { + public enum Value { + /// Original + public static let original = L10n.tr("Localizable", "enum.eh_setting.excluded_languages_category.value.original", fallback: "Original") + /// Rewrite + public static let rewrite = L10n.tr("Localizable", "enum.eh_setting.excluded_languages_category.value.rewrite", fallback: "Rewrite") + /// Translated + public static let translated = L10n.tr("Localizable", "enum.eh_setting.excluded_languages_category.value.translated", fallback: "Translated") + } + } + public enum FavoritesSortOrder { + public enum Value { + /// By favorited time + public static let favoritedTime = L10n.tr("Localizable", "enum.eh_setting.favorites_sort_order.value.favorited_time", fallback: "By favorited time") + /// By last gallery update time + public static let lastUpdateTime = L10n.tr("Localizable", "enum.eh_setting.favorites_sort_order.value.last_update_time", fallback: "By last gallery update time") + } + } + public enum GalleryName { + public enum Value { + /// Default Title + public static let `default` = L10n.tr("Localizable", "enum.eh_setting.gallery_name.value.default", fallback: "Default Title") + /// Japanese Title (if available) + public static let japanese = L10n.tr("Localizable", "enum.eh_setting.gallery_name.value.japanese", fallback: "Japanese Title (if available)") + } + } + public enum GalleryPageNumbering { + public enum Value { + /// None + public static let `none` = L10n.tr("Localizable", "enum.eh_setting.gallery_page_numbering.value.none", fallback: "None") + /// Page Number + Name + public static let pageNumberAndName = L10n.tr("Localizable", "enum.eh_setting.gallery_page_numbering.value.page_number_and_name", fallback: "Page Number + Name") + /// Page Number Only + public static let pageNumberOnly = L10n.tr("Localizable", "enum.eh_setting.gallery_page_numbering.value.page_number_only", fallback: "Page Number Only") + } + } + public enum ImageResolution { + public enum Value { + /// Auto + public static let auto = L10n.tr("Localizable", "enum.eh_setting.image_resolution.value.auto", fallback: "Auto") + } + } + public enum LoadThroughHathSetting { + public enum Description { + /// Recommended. + public static let anyClient = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.any_client", fallback: "Recommended.") + /// Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports. + public static let defaultPortOnly = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.default_port_only", fallback: "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports.") + /// Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only. + public static let legacyNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.legacy_no", fallback: "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only.") + /// Donator only. You will not be able to browse as many pages. Recommended only if having severe problems. + public static let modernNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.modern_no", fallback: "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems.") + } + public enum Value { + /// Any client + public static let anyClient = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.any_client", fallback: "Any client") + /// Default port clients only + public static let defaultPortOnly = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.default_port_only", fallback: "Default port clients only") + /// No [Legacy/HTTP] + public static let legacyNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.legacy_no", fallback: "No [Legacy/HTTP]") + /// No [Modern/HTTPS] + public static let modernNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.modern_no", fallback: "No [Modern/HTTPS]") + } + } + public enum MultiplePageViewerStyle { + public enum Value { + /// Align center, always scale + public static let alignCenterAlwaysScale = L10n.tr("Localizable", "enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale", fallback: "Align center, always scale") + /// Align center, scale if overwidth + public static let alignCenterScaleIfOverWidth = L10n.tr("Localizable", "enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width", fallback: "Align center, scale if overwidth") + /// Align left, scale if overwidth + public static let alignLeftScaleIfOverWidth = L10n.tr("Localizable", "enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width", fallback: "Align left, scale if overwidth") + } + } + public enum TagsSortOrder { + public enum Value { + /// Alphabetical + public static let alphabetical = L10n.tr("Localizable", "enum.eh_setting.tags_sort_order.value.alphabetical", fallback: "Alphabetical") + /// By tag power + public static let tagPower = L10n.tr("Localizable", "enum.eh_setting.tags_sort_order.value.tag_power", fallback: "By tag power") + } + } + public enum ThumbnailLoadTiming { + public enum Description { + /// Pages load faster, but there may be a slight delay before a thumb appears. + public static let onMouseOver = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.description.on_mouse_over", fallback: "Pages load faster, but there may be a slight delay before a thumb appears.") + /// Pages take longer to load, but there is no delay for loading a thumb after the page has loaded. + public static let onPageLoad = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.description.on_page_load", fallback: "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded.") + } + public enum Value { + /// On mouse-over + public static let onMouseOver = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.value.on_mouse_over", fallback: "On mouse-over") + /// On page load + public static let onPageLoad = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.value.on_page_load", fallback: "On page load") + } + } + public enum ThumbnailSize { + public enum Value { + /// Auto + public static let auto = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.auto", fallback: "Auto") + /// Large + public static let large = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.large", fallback: "Large") + /// Normal + public static let normal = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.normal", fallback: "Normal") + /// Small + public static let small = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.small", fallback: "Small") + } + } + } + public enum FilterRange { + public enum Value { + /// Global + public static let global = L10n.tr("Localizable", "enum.filter_range.value.global", fallback: "Global") + /// Search + public static let search = L10n.tr("Localizable", "enum.filter_range.value.search", fallback: "Search") + /// Watched + public static let watched = L10n.tr("Localizable", "enum.filter_range.value.watched", fallback: "Watched") + } + } + public enum GalleryVisibility { + public enum Value { + /// No (%@) + public static func no(_ p1: Any) -> String { + return L10n.tr("Localizable", "enum.gallery_visibility.value.no", String(describing: p1), fallback: "No (%@)") + } + /// Yes + public static let yes = L10n.tr("Localizable", "enum.gallery_visibility.value.yes", fallback: "Yes") + public enum No { + public enum Reason { + /// Expunged + public static let expunged = L10n.tr("Localizable", "enum.gallery_visibility.value.no.reason.expunged", fallback: "Expunged") + } + } + } + } + public enum HomeMiscGridType { + public enum Title { + /// History + public static let history = L10n.tr("Localizable", "enum.home_misc_grid_type.title.history", fallback: "History") + /// Popular + public static let popular = L10n.tr("Localizable", "enum.home_misc_grid_type.title.popular", fallback: "Popular") + /// Watched + public static let watched = L10n.tr("Localizable", "enum.home_misc_grid_type.title.watched", fallback: "Watched") + } + } + public enum Language { + public enum Value { + /// Afrikaans + public static let afrikaans = L10n.tr("Localizable", "enum.language.value.afrikaans", fallback: "Afrikaans") + /// Albanian + public static let albanian = L10n.tr("Localizable", "enum.language.value.albanian", fallback: "Albanian") + /// Arabic + public static let arabic = L10n.tr("Localizable", "enum.language.value.arabic", fallback: "Arabic") + /// Bengali + public static let bengali = L10n.tr("Localizable", "enum.language.value.bengali", fallback: "Bengali") + /// Bosnian + public static let bosnian = L10n.tr("Localizable", "enum.language.value.bosnian", fallback: "Bosnian") + /// Bulgarian + public static let bulgarian = L10n.tr("Localizable", "enum.language.value.bulgarian", fallback: "Bulgarian") + /// Burmese + public static let burmese = L10n.tr("Localizable", "enum.language.value.burmese", fallback: "Burmese") + /// Catalan + public static let catalan = L10n.tr("Localizable", "enum.language.value.catalan", fallback: "Catalan") + /// Cebuano + public static let cebuano = L10n.tr("Localizable", "enum.language.value.cebuano", fallback: "Cebuano") + /// Chinese + public static let chinese = L10n.tr("Localizable", "enum.language.value.chinese", fallback: "Chinese") + /// Croatian + public static let croatian = L10n.tr("Localizable", "enum.language.value.croatian", fallback: "Croatian") + /// Czech + public static let czech = L10n.tr("Localizable", "enum.language.value.czech", fallback: "Czech") + /// Danish + public static let danish = L10n.tr("Localizable", "enum.language.value.danish", fallback: "Danish") + /// Dutch + public static let dutch = L10n.tr("Localizable", "enum.language.value.dutch", fallback: "Dutch") + /// English + public static let english = L10n.tr("Localizable", "enum.language.value.english", fallback: "English") + /// Esperanto + public static let esperanto = L10n.tr("Localizable", "enum.language.value.esperanto", fallback: "Esperanto") + /// Estonian + public static let estonian = L10n.tr("Localizable", "enum.language.value.estonian", fallback: "Estonian") + /// Finnish + public static let finnish = L10n.tr("Localizable", "enum.language.value.finnish", fallback: "Finnish") + /// French + public static let french = L10n.tr("Localizable", "enum.language.value.french", fallback: "French") + /// Georgian + public static let georgian = L10n.tr("Localizable", "enum.language.value.georgian", fallback: "Georgian") + /// German + public static let german = L10n.tr("Localizable", "enum.language.value.german", fallback: "German") + /// Greek + public static let greek = L10n.tr("Localizable", "enum.language.value.greek", fallback: "Greek") + /// Hebrew + public static let hebrew = L10n.tr("Localizable", "enum.language.value.hebrew", fallback: "Hebrew") + /// Hindi + public static let hindi = L10n.tr("Localizable", "enum.language.value.hindi", fallback: "Hindi") + /// Hmong + public static let hmong = L10n.tr("Localizable", "enum.language.value.hmong", fallback: "Hmong") + /// Hungarian + public static let hungarian = L10n.tr("Localizable", "enum.language.value.hungarian", fallback: "Hungarian") + /// Indonesian + public static let indonesian = L10n.tr("Localizable", "enum.language.value.indonesian", fallback: "Indonesian") + /// N/A + public static let invalid = L10n.tr("Localizable", "enum.language.value.invalid", fallback: "N/A") + /// Italian + public static let italian = L10n.tr("Localizable", "enum.language.value.italian", fallback: "Italian") + /// Japanese + public static let japanese = L10n.tr("Localizable", "enum.language.value.japanese", fallback: "Japanese") + /// Kazakh + public static let kazakh = L10n.tr("Localizable", "enum.language.value.kazakh", fallback: "Kazakh") + /// Khmer + public static let khmer = L10n.tr("Localizable", "enum.language.value.khmer", fallback: "Khmer") + /// Korean + public static let korean = L10n.tr("Localizable", "enum.language.value.korean", fallback: "Korean") + /// Kurdish + public static let kurdish = L10n.tr("Localizable", "enum.language.value.kurdish", fallback: "Kurdish") + /// Lao + public static let lao = L10n.tr("Localizable", "enum.language.value.lao", fallback: "Lao") + /// Latin + public static let latin = L10n.tr("Localizable", "enum.language.value.latin", fallback: "Latin") + /// Mongolian + public static let mongolian = L10n.tr("Localizable", "enum.language.value.mongolian", fallback: "Mongolian") + /// Ndebele + public static let ndebele = L10n.tr("Localizable", "enum.language.value.ndebele", fallback: "Ndebele") + /// Nepali + public static let nepali = L10n.tr("Localizable", "enum.language.value.nepali", fallback: "Nepali") + /// Norwegian + public static let norwegian = L10n.tr("Localizable", "enum.language.value.norwegian", fallback: "Norwegian") + /// Oromo + public static let oromo = L10n.tr("Localizable", "enum.language.value.oromo", fallback: "Oromo") + /// Other + public static let other = L10n.tr("Localizable", "enum.language.value.other", fallback: "Other") + /// Pashto + public static let pashto = L10n.tr("Localizable", "enum.language.value.pashto", fallback: "Pashto") + /// Persian + public static let persian = L10n.tr("Localizable", "enum.language.value.persian", fallback: "Persian") + /// Polish + public static let polish = L10n.tr("Localizable", "enum.language.value.polish", fallback: "Polish") + /// Portuguese + public static let portuguese = L10n.tr("Localizable", "enum.language.value.portuguese", fallback: "Portuguese") + /// Punjabi + public static let punjabi = L10n.tr("Localizable", "enum.language.value.punjabi", fallback: "Punjabi") + /// Romanian + public static let romanian = L10n.tr("Localizable", "enum.language.value.romanian", fallback: "Romanian") + /// Russian + public static let russian = L10n.tr("Localizable", "enum.language.value.russian", fallback: "Russian") + /// Sango + public static let sango = L10n.tr("Localizable", "enum.language.value.sango", fallback: "Sango") + /// Serbian + public static let serbian = L10n.tr("Localizable", "enum.language.value.serbian", fallback: "Serbian") + /// Shona + public static let shona = L10n.tr("Localizable", "enum.language.value.shona", fallback: "Shona") + /// Slovak + public static let slovak = L10n.tr("Localizable", "enum.language.value.slovak", fallback: "Slovak") + /// Slovenian + public static let slovenian = L10n.tr("Localizable", "enum.language.value.slovenian", fallback: "Slovenian") + /// Somali + public static let somali = L10n.tr("Localizable", "enum.language.value.somali", fallback: "Somali") + /// Spanish + public static let spanish = L10n.tr("Localizable", "enum.language.value.spanish", fallback: "Spanish") + /// Swahili + public static let swahili = L10n.tr("Localizable", "enum.language.value.swahili", fallback: "Swahili") + /// Swedish + public static let swedish = L10n.tr("Localizable", "enum.language.value.swedish", fallback: "Swedish") + /// Tagalog + public static let tagalog = L10n.tr("Localizable", "enum.language.value.tagalog", fallback: "Tagalog") + /// Thai + public static let thai = L10n.tr("Localizable", "enum.language.value.thai", fallback: "Thai") + /// Tigrinya + public static let tigrinya = L10n.tr("Localizable", "enum.language.value.tigrinya", fallback: "Tigrinya") + /// Turkish + public static let turkish = L10n.tr("Localizable", "enum.language.value.turkish", fallback: "Turkish") + /// Ukrainian + public static let ukrainian = L10n.tr("Localizable", "enum.language.value.ukrainian", fallback: "Ukrainian") + /// Urdu + public static let urdu = L10n.tr("Localizable", "enum.language.value.urdu", fallback: "Urdu") + /// Vietnamese + public static let vietnamese = L10n.tr("Localizable", "enum.language.value.vietnamese", fallback: "Vietnamese") + /// Zulu + public static let zulu = L10n.tr("Localizable", "enum.language.value.zulu", fallback: "Zulu") + } + } + public enum ListDisplayMode { + public enum Value { + /// Detail + public static let detail = L10n.tr("Localizable", "enum.list_display_mode.value.detail", fallback: "Detail") + /// Thumbnail + public static let thumbnail = L10n.tr("Localizable", "enum.list_display_mode.value.thumbnail", fallback: "Thumbnail") + } + } + public enum PreferredColorScheme { + public enum Value { + /// Automatic + public static let automatic = L10n.tr("Localizable", "enum.preferred_color_scheme.value.automatic", fallback: "Automatic") + /// Dark + public static let dark = L10n.tr("Localizable", "enum.preferred_color_scheme.value.dark", fallback: "Dark") + /// Light + public static let light = L10n.tr("Localizable", "enum.preferred_color_scheme.value.light", fallback: "Light") + } + } + public enum ReadingDirection { + public enum Value { + /// Left-to-right + public static let leftToRight = L10n.tr("Localizable", "enum.reading_direction.value.left_to_right", fallback: "Left-to-right") + /// Right-to-left + public static let rightToLeft = L10n.tr("Localizable", "enum.reading_direction.value.right_to_left", fallback: "Right-to-left") + /// Vertical + public static let vertical = L10n.tr("Localizable", "enum.reading_direction.value.vertical", fallback: "Vertical") + } + } + public enum SettingStateRoute { + public enum Value { + /// About + public static let about = L10n.tr("Localizable", "enum.setting_state_route.value.about", fallback: "About") + /// Account + public static let account = L10n.tr("Localizable", "enum.setting_state_route.value.account", fallback: "Account") + /// Appearance + public static let appearance = L10n.tr("Localizable", "enum.setting_state_route.value.appearance", fallback: "Appearance") + /// Download + public static let download = L10n.tr("Localizable", "enum.setting_state_route.value.download", fallback: "Download") + /// General + public static let general = L10n.tr("Localizable", "enum.setting_state_route.value.general", fallback: "General") + /// Laboratory + public static let laboratory = L10n.tr("Localizable", "enum.setting_state_route.value.laboratory", fallback: "Laboratory") + /// Reading + public static let reading = L10n.tr("Localizable", "enum.setting_state_route.value.reading", fallback: "Reading") + } + } + public enum TagNamespace { + public enum Value { + /// Artist + public static let artist = L10n.tr("Localizable", "enum.tag_namespace.value.artist", fallback: "Artist") + /// Character + public static let character = L10n.tr("Localizable", "enum.tag_namespace.value.character", fallback: "Character") + /// Cosplayer + public static let cosplayer = L10n.tr("Localizable", "enum.tag_namespace.value.cosplayer", fallback: "Cosplayer") + /// Female + public static let female = L10n.tr("Localizable", "enum.tag_namespace.value.female", fallback: "Female") + /// Group + public static let group = L10n.tr("Localizable", "enum.tag_namespace.value.group", fallback: "Group") + /// Language + public static let language = L10n.tr("Localizable", "enum.tag_namespace.value.language", fallback: "Language") + /// Male + public static let male = L10n.tr("Localizable", "enum.tag_namespace.value.male", fallback: "Male") + /// Mixed + public static let mixed = L10n.tr("Localizable", "enum.tag_namespace.value.mixed", fallback: "Mixed") + /// Other + public static let other = L10n.tr("Localizable", "enum.tag_namespace.value.other", fallback: "Other") + /// Parody + public static let parody = L10n.tr("Localizable", "enum.tag_namespace.value.parody", fallback: "Parody") + /// Reclass + public static let reclass = L10n.tr("Localizable", "enum.tag_namespace.value.reclass", fallback: "Reclass") + /// Temp + public static let temp = L10n.tr("Localizable", "enum.tag_namespace.value.temp", fallback: "Temp") + } + } + public enum ToplistsType { + public enum Value { + /// All time + public static let allTime = L10n.tr("Localizable", "enum.toplists_type.value.all_time", fallback: "All time") + /// Past month + public static let pastMonth = L10n.tr("Localizable", "enum.toplists_type.value.past_month", fallback: "Past month") + /// Past year + public static let pastYear = L10n.tr("Localizable", "enum.toplists_type.value.past_year", fallback: "Past year") + /// Yesterday + public static let yesterday = L10n.tr("Localizable", "enum.toplists_type.value.yesterday", fallback: "Yesterday") + } + } + } + public enum ErrorView { + public enum Button { + /// Drop the database + public static let dropDatabase = L10n.tr("Localizable", "error_view.button.drop_database", fallback: "Drop the database") + /// Retry + public static let retry = L10n.tr("Localizable", "error_view.button.retry", fallback: "Retry") + } + public enum Title { + /// This gallery is unavailable due to a copyright claim by %@. Sorry about that. + public static func copyrightClaim(_ p1: Any) -> String { + return L10n.tr("Localizable", "error_view.title.copyright_claim", String(describing: p1), fallback: "This gallery is unavailable due to a copyright claim by %@. Sorry about that.") + } + /// The database is corrupted. + /// Please submit an issue on GitHub. + public static let databaseCorrupted = L10n.tr("Localizable", "error_view.title.database_corrupted", fallback: "The database is corrupted.\nPlease submit an issue on GitHub.") + /// This gallery has been removed or is unavailable. + public static let galleryUnavailable = L10n.tr("Localizable", "error_view.title.gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") + /// Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@. + public static func ipBanned(_ p1: Any) -> String { + return L10n.tr("Localizable", "error_view.title.ip_banned", String(describing: p1), fallback: "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@.") + } + /// A network error occurred. + public static let network = L10n.tr("Localizable", "error_view.title.network", fallback: "A network error occurred.") + /// There seems to be nothing here. + public static let notFound = L10n.tr("Localizable", "error_view.title.not_found", fallback: "There seems to be nothing here.") + /// A parsing error occurred. + public static let parsing = L10n.tr("Localizable", "error_view.title.parsing", fallback: "A parsing error occurred.") + /// Please try again later. + public static let tryLater = L10n.tr("Localizable", "error_view.title.try_later", fallback: "Please try again later.") + /// An unknown error occurred. + public static let unknown = L10n.tr("Localizable", "error_view.title.unknown", fallback: "An unknown error occurred.") + } + } + public enum FavoritesView { + public enum Title { + /// Favorites + public static let favorites = L10n.tr("Localizable", "favorites_view.title.favorites", fallback: "Favorites") + } + } + public enum FiltersView { + public enum Button { + /// Reset filters + public static let resetFilters = L10n.tr("Localizable", "filters_view.button.reset_filters", fallback: "Reset filters") + } + public enum Section { + public enum Title { + /// Advanced + public static let advanced = L10n.tr("Localizable", "filters_view.section.title.advanced", fallback: "Advanced") + /// Default filter + public static let defaultFilter = L10n.tr("Localizable", "filters_view.section.title.default_filter", fallback: "Default filter") + } + } + public enum Title { + /// Advanced settings + public static let advancedSettings = L10n.tr("Localizable", "filters_view.title.advanced_settings", fallback: "Advanced settings") + /// Disable language filter + public static let disableLanguageFilter = L10n.tr("Localizable", "filters_view.title.disable_language_filter", fallback: "Disable language filter") + /// Disable tags filter + public static let disableTagsFilter = L10n.tr("Localizable", "filters_view.title.disable_tags_filter", fallback: "Disable tags filter") + /// Disable uploader filter + public static let disableUploaderFilter = L10n.tr("Localizable", "filters_view.title.disable_uploader_filter", fallback: "Disable uploader filter") + /// Filters + public static let filters = L10n.tr("Localizable", "filters_view.title.filters", fallback: "Filters") + /// Minimum rating + public static let minimumRating = L10n.tr("Localizable", "filters_view.title.minimum_rating", fallback: "Minimum rating") + /// Only show galleries with torrents + public static let onlyShowGalleriesWithTorrents = L10n.tr("Localizable", "filters_view.title.only_show_galleries_with_torrents", fallback: "Only show galleries with torrents") + /// Pages range + public static let pagesRange = L10n.tr("Localizable", "filters_view.title.pages_range", fallback: "Pages range") + /// Search downvoted tags + public static let searchDownvotedTags = L10n.tr("Localizable", "filters_view.title.search_downvoted_tags", fallback: "Search downvoted tags") + /// Search expunged galleries + public static let searchExpungedGalleries = L10n.tr("Localizable", "filters_view.title.search_expunged_galleries", fallback: "Search expunged galleries") + /// Search gallery description + public static let searchGalleryDescription = L10n.tr("Localizable", "filters_view.title.search_gallery_description", fallback: "Search gallery description") + /// Search gallery name + public static let searchGalleryName = L10n.tr("Localizable", "filters_view.title.search_gallery_name", fallback: "Search gallery name") + /// Search gallery tags + public static let searchGalleryTags = L10n.tr("Localizable", "filters_view.title.search_gallery_tags", fallback: "Search gallery tags") + /// Search Low-Power tags + public static let searchLowPowerTags = L10n.tr("Localizable", "filters_view.title.search_low_power_tags", fallback: "Search Low-Power tags") + /// Search torrent filenames + public static let searchTorrentFilenames = L10n.tr("Localizable", "filters_view.title.search_torrent_filenames", fallback: "Search torrent filenames") + /// Set minimum rating + public static let setMinimumRating = L10n.tr("Localizable", "filters_view.title.set_minimum_rating", fallback: "Set minimum rating") + /// Set pages range + public static let setPagesRange = L10n.tr("Localizable", "filters_view.title.set_pages_range", fallback: "Set pages range") + } + } + public enum FolderManagerView { + public enum Dialog { + public enum Message { + /// This will delete the folder and all downloaded galleries inside it. + public static let deleteFolder = L10n.tr("Localizable", "folder_manager_view.dialog.message.delete_folder", fallback: "This will delete the folder and all downloaded galleries inside it.") + } + } + public enum EmptyState { + /// Folders you create will appear here. + public static let folders = L10n.tr("Localizable", "folder_manager_view.empty_state.folders", fallback: "Folders you create will appear here.") + } + public enum Placeholder { + /// Folder name + public static let folderName = L10n.tr("Localizable", "folder_manager_view.placeholder.folder_name", fallback: "Folder name") + } + public enum Title { + /// Folders + public static let folders = L10n.tr("Localizable", "folder_manager_view.title.folders", fallback: "Folders") + } + } + public enum FrontpageView { + public enum Title { + /// Frontpage + public static let frontpage = L10n.tr("Localizable", "frontpage_view.title.frontpage", fallback: "Frontpage") + } + } + public enum GalleryInfosView { + public enum Title { + /// Archive URL + public static let archiveURL = L10n.tr("Localizable", "gallery_infos_view.title.archive_URL", fallback: "Archive URL") + /// Average rating + public static let averageRating = L10n.tr("Localizable", "gallery_infos_view.title.average_rating", fallback: "Average rating") + /// Category + public static let category = L10n.tr("Localizable", "gallery_infos_view.title.category", fallback: "Category") + /// Cover URL + public static let coverURL = L10n.tr("Localizable", "gallery_infos_view.title.cover_URL", fallback: "Cover URL") + /// Favorited + public static let favorited = L10n.tr("Localizable", "gallery_infos_view.title.favorited", fallback: "Favorited") + /// Favorited times + public static let favoritedTimes = L10n.tr("Localizable", "gallery_infos_view.title.favorited_times", fallback: "Favorited times") + /// File size + public static let fileSize = L10n.tr("Localizable", "gallery_infos_view.title.file_size", fallback: "File size") + /// Gallery infos + public static let galleryInfos = L10n.tr("Localizable", "gallery_infos_view.title.gallery_infos", fallback: "Gallery infos") + /// Gallery URL + public static let galleryURL = L10n.tr("Localizable", "gallery_infos_view.title.gallery_URL", fallback: "Gallery URL") + /// ID + public static let id = L10n.tr("Localizable", "gallery_infos_view.title.id", fallback: "ID") + /// Japanese title + public static let japaneseTitle = L10n.tr("Localizable", "gallery_infos_view.title.japanese_title", fallback: "Japanese title") + /// Language + public static let language = L10n.tr("Localizable", "gallery_infos_view.title.language", fallback: "Language") + /// My rating + public static let myRating = L10n.tr("Localizable", "gallery_infos_view.title.my_rating", fallback: "My rating") + /// Page count + public static let pageCount = L10n.tr("Localizable", "gallery_infos_view.title.page_count", fallback: "Page count") + /// Parent URL + public static let parentURL = L10n.tr("Localizable", "gallery_infos_view.title.parent_URL", fallback: "Parent URL") + /// Posted date + public static let postedDate = L10n.tr("Localizable", "gallery_infos_view.title.posted_date", fallback: "Posted date") + /// Rating count + public static let ratingCount = L10n.tr("Localizable", "gallery_infos_view.title.rating_count", fallback: "Rating count") + /// Title + public static let title = L10n.tr("Localizable", "gallery_infos_view.title.title", fallback: "Title") + /// Token + public static let token = L10n.tr("Localizable", "gallery_infos_view.title.token", fallback: "Token") + /// Torrent count + public static let torrentCount = L10n.tr("Localizable", "gallery_infos_view.title.torrent_count", fallback: "Torrent count") + /// Torrent URL + public static let torrentURL = L10n.tr("Localizable", "gallery_infos_view.title.torrent_URL", fallback: "Torrent URL") + /// Uploader + public static let uploader = L10n.tr("Localizable", "gallery_infos_view.title.uploader", fallback: "Uploader") + /// Visibility + public static let visibility = L10n.tr("Localizable", "gallery_infos_view.title.visibility", fallback: "Visibility") + } + public enum Value { + /// No + public static let no = L10n.tr("Localizable", "gallery_infos_view.value.no", fallback: "No") + /// None + public static let `none` = L10n.tr("Localizable", "gallery_infos_view.value.none", fallback: "None") + /// Yes + public static let yes = L10n.tr("Localizable", "gallery_infos_view.value.yes", fallback: "Yes") + } + } + public enum GeneralSettingView { + public enum Button { + /// Clear image caches + public static let clearImageCaches = L10n.tr("Localizable", "general_setting_view.button.clear_image_caches", fallback: "Clear image caches") + /// Import custom translations + public static let importCustomTranslations = L10n.tr("Localizable", "general_setting_view.button.import_custom_translations", fallback: "Import custom translations") + /// Logs + public static let logs = L10n.tr("Localizable", "general_setting_view.button.logs", fallback: "Logs") + /// Remove custom translations + public static let removeCustomTranslations = L10n.tr("Localizable", "general_setting_view.button.remove_custom_translations", fallback: "Remove custom translations") + } + public enum Section { + public enum Title { + /// Caches + public static let caches = L10n.tr("Localizable", "general_setting_view.section.title.caches", fallback: "Caches") + /// Navigation + public static let navigation = L10n.tr("Localizable", "general_setting_view.section.title.navigation", fallback: "Navigation") + /// Security + public static let security = L10n.tr("Localizable", "general_setting_view.section.title.security", fallback: "Security") + /// Tags + public static let tags = L10n.tr("Localizable", "general_setting_view.section.title.tags", fallback: "Tags") + } + } + public enum Title { + /// Auto-Lock + public static let autoLock = L10n.tr("Localizable", "general_setting_view.title.auto_lock", fallback: "Auto-Lock") + /// Background blur radius + public static let backgroundBlurRadius = L10n.tr("Localizable", "general_setting_view.title.background_blur_radius", fallback: "Background blur radius") + /// Detects links from the clipboard + public static let detectsLinksFromClipboard = L10n.tr("Localizable", "general_setting_view.title.detects_links_from_clipboard", fallback: "Detects links from the clipboard") + /// Enables tags extension + public static let enablesTagsExtension = L10n.tr("Localizable", "general_setting_view.title.enables_tags_extension", fallback: "Enables tags extension") + /// General + public static let general = L10n.tr("Localizable", "general_setting_view.title.general", fallback: "General") + /// Language + public static let language = L10n.tr("Localizable", "general_setting_view.title.language", fallback: "Language") + /// Redirects links to the selected host + public static let redirectsLinksToTheSelectedHost = L10n.tr("Localizable", "general_setting_view.title.redirects_links_to_the_selected_host", fallback: "Redirects links to the selected host") + /// Shows images in tags + public static let showsImagesInTags = L10n.tr("Localizable", "general_setting_view.title.shows_images_in_tags", fallback: "Shows images in tags") + /// Shows tags search suggestion + public static let showsTagsSearchSuggestion = L10n.tr("Localizable", "general_setting_view.title.shows_tags_search_suggestion", fallback: "Shows tags search suggestion") + /// Translates tags + public static let translatesTags = L10n.tr("Localizable", "general_setting_view.title.translates_tags", fallback: "Translates tags") + } + public enum Value { + /// N/A + public static let defaultLanguageDescription = L10n.tr("Localizable", "general_setting_view.value.default_language_description", fallback: "N/A") + } + } + public enum HistoryView { + public enum Title { + /// History + public static let history = L10n.tr("Localizable", "history_view.title.history", fallback: "History") + } + } + public enum HomeView { + public enum Section { + public enum Title { + /// Frontpage + public static let frontpage = L10n.tr("Localizable", "home_view.section.title.frontpage", fallback: "Frontpage") + /// Other + public static let other = L10n.tr("Localizable", "home_view.section.title.other", fallback: "Other") + /// Toplists + public static let toplists = L10n.tr("Localizable", "home_view.section.title.toplists", fallback: "Toplists") + } + } + public enum Title { + /// Home + public static let home = L10n.tr("Localizable", "home_view.title.home", fallback: "Home") + } + } + public enum Hud { + public enum Caption { + /// Copied to clipboard + public static let copiedToClipboard = L10n.tr("Localizable", "hud.caption.copied_to_clipboard", fallback: "Copied to clipboard") + /// Saved to photo library + public static let savedToPhotoLibrary = L10n.tr("Localizable", "hud.caption.saved_to_photo_library", fallback: "Saved to photo library") + } + public enum Title { + /// Communicating... + public static let communicating = L10n.tr("Localizable", "hud.title.communicating", fallback: "Communicating...") + /// Error + public static let error = L10n.tr("Localizable", "hud.title.error", fallback: "Error") + /// Loading... + public static let loading = L10n.tr("Localizable", "hud.title.loading", fallback: "Loading...") + /// Success + public static let success = L10n.tr("Localizable", "hud.title.success", fallback: "Success") + } + } + public enum JumpPageView { + public enum Button { + /// Confirm + public static let confirm = L10n.tr("Localizable", "jump_page_view.button.confirm", fallback: "Confirm") + } + public enum Title { + /// Jump page + public static let jumpPage = L10n.tr("Localizable", "jump_page_view.title.jump_page", fallback: "Jump page") + } + } + public enum LaboratorySettingView { + public enum Title { + /// Bypasses SNI Filtering + public static let bypassesSNIFiltering = L10n.tr("Localizable", "laboratory_setting_view.title.bypasses_SNI_filtering", fallback: "Bypasses SNI Filtering") + /// Laboratory + public static let laboratory = L10n.tr("Localizable", "laboratory_setting_view.title.laboratory", fallback: "Laboratory") + } + } + public enum LoadingView { + public enum Title { + /// Loading... + public static let loading = L10n.tr("Localizable", "loading_view.title.loading", fallback: "Loading...") + /// Preparing the database... + public static let preparingDatabase = L10n.tr("Localizable", "loading_view.title.preparing_database", fallback: "Preparing the database...") + } + } + public enum LocalAuthorization { + /// The App has been locked due to the Auto-Lock expiration. + public static let reason = L10n.tr("Localizable", "local_authorization.reason", fallback: "The App has been locked due to the Auto-Lock expiration.") + } + public enum LoginView { + public enum Title { + /// Login + public static let login = L10n.tr("Localizable", "login_view.title.login", fallback: "Login") + /// Password + public static let password = L10n.tr("Localizable", "login_view.title.password", fallback: "Password") + /// Username + public static let username = L10n.tr("Localizable", "login_view.title.username", fallback: "Username") + } + } + public enum LogsView { + public enum Title { + /// Latest + public static let latest = L10n.tr("Localizable", "logs_view.title.latest", fallback: "Latest") + /// Logs + public static let logs = L10n.tr("Localizable", "logs_view.title.logs", fallback: "Logs") + } + } + public enum NewDawnView { + public enum Title { + /// It is the dawn of a new day! + public static let first = L10n.tr("Localizable", "new_dawn_view.title.first", fallback: "It is the dawn of a new day!") + /// Reflecting on your journey so far, you find that you are a little wiser. + public static let second = L10n.tr("Localizable", "new_dawn_view.title.second", fallback: "Reflecting on your journey so far, you find that you are a little wiser.") + } + } + public enum NotLoginView { + public enum Button { + /// Login + public static let login = L10n.tr("Localizable", "not_login_view.button.login", fallback: "Login") + } + public enum Title { + /// You need to login to access this feature. + public static let needLogin = L10n.tr("Localizable", "not_login_view.title.need_login", fallback: "You need to login to access this feature.") + } + } + public enum PopularView { + public enum Title { + /// Popular + public static let popular = L10n.tr("Localizable", "popular_view.title.popular", fallback: "Popular") + } + } + public enum PostCommentView { + public enum Title { + /// Edit comment + public static let editComment = L10n.tr("Localizable", "post_comment_view.title.edit_comment", fallback: "Edit comment") + /// Post comment + public static let postComment = L10n.tr("Localizable", "post_comment_view.title.post_comment", fallback: "Post comment") + } + } + public enum PreviewsView { + public enum Title { + /// Previews + public static let previews = L10n.tr("Localizable", "previews_view.title.previews", fallback: "Previews") + } + } + public enum QuickSearchView { + public enum Placeholder { + /// Optional + public static let `optional` = L10n.tr("Localizable", "quick_search_view.placeholder.optional", fallback: "Optional") + } + public enum Title { + /// Content + public static let content = L10n.tr("Localizable", "quick_search_view.title.content", fallback: "Content") + /// Edit word + public static let editWord = L10n.tr("Localizable", "quick_search_view.title.edit_word", fallback: "Edit word") + /// Name + public static let name = L10n.tr("Localizable", "quick_search_view.title.name", fallback: "Name") + /// New word + public static let newWord = L10n.tr("Localizable", "quick_search_view.title.new_word", fallback: "New word") + /// Quick search + public static let quickSearch = L10n.tr("Localizable", "quick_search_view.title.quick_search", fallback: "Quick search") + } + } + public enum ReadingSettingView { + public enum Section { + public enum Title { + /// Appearance + public static let appearance = L10n.tr("Localizable", "reading_setting_view.section.title.appearance", fallback: "Appearance") + } + } + public enum Title { + /// Direction + public static let direction = L10n.tr("Localizable", "reading_setting_view.title.direction", fallback: "Direction") + /// Double tap scale factor + public static let doubleTapScaleFactor = L10n.tr("Localizable", "reading_setting_view.title.double_tap_scale_factor", fallback: "Double tap scale factor") + /// Enables landscape + public static let enablesLandscape = L10n.tr("Localizable", "reading_setting_view.title.enables_landscape", fallback: "Enables landscape") + /// Maximum scale factor + public static let maximumScaleFactor = L10n.tr("Localizable", "reading_setting_view.title.maximum_scale_factor", fallback: "Maximum scale factor") + /// Preload limit + public static let preloadLimit = L10n.tr("Localizable", "reading_setting_view.title.preload_limit", fallback: "Preload limit") + /// Reading + public static let reading = L10n.tr("Localizable", "reading_setting_view.title.reading", fallback: "Reading") + /// Separator height + public static let separatorHeight = L10n.tr("Localizable", "reading_setting_view.title.separator_height", fallback: "Separator height") + } + } + public enum ReadingView { + public enum ContextMenu { + public enum Button { + /// Copy + public static let copy = L10n.tr("Localizable", "reading_view.context_menu.button.copy", fallback: "Copy") + /// Reload + public static let reload = L10n.tr("Localizable", "reading_view.context_menu.button.reload", fallback: "Reload") + /// Save + public static let save = L10n.tr("Localizable", "reading_view.context_menu.button.save", fallback: "Save") + /// Save original + public static let saveOriginal = L10n.tr("Localizable", "reading_view.context_menu.button.save_original", fallback: "Save original") + /// Share + public static let share = L10n.tr("Localizable", "reading_view.context_menu.button.share", fallback: "Share") + } + } + public enum ToolbarItem { + public enum Button { + /// Reading setting + public static let readingSetting = L10n.tr("Localizable", "reading_view.toolbar_item.button.reading_setting", fallback: "Reading setting") + /// Reload all images + public static let reloadAllImages = L10n.tr("Localizable", "reading_view.toolbar_item.button.reload_all_images", fallback: "Reload all images") + /// Retry all failed images + public static let retryAllFailedImages = L10n.tr("Localizable", "reading_view.toolbar_item.button.retry_all_failed_images", fallback: "Retry all failed images") + } + public enum Title { + /// Auto-Play + public static let autoPlay = L10n.tr("Localizable", "reading_view.toolbar_item.title.auto_play", fallback: "Auto-Play") + /// Dual-Page mode + public static let dualPageMode = L10n.tr("Localizable", "reading_view.toolbar_item.title.dual_page_mode", fallback: "Dual-Page mode") + /// Except the cover + public static let exceptTheCover = L10n.tr("Localizable", "reading_view.toolbar_item.title.except_the_cover", fallback: "Except the cover") + } + } + } + public enum SearchView { + public enum Section { + public enum Title { + /// Quick search + public static let quickSearch = L10n.tr("Localizable", "search_view.section.title.quick_search", fallback: "Quick search") + /// Recently searched + public static let recentlySearched = L10n.tr("Localizable", "search_view.section.title.recently_searched", fallback: "Recently searched") + /// Recently seen + public static let recentlySeen = L10n.tr("Localizable", "search_view.section.title.recently_seen", fallback: "Recently seen") + } + } + public enum Title { + /// Search + public static let search = L10n.tr("Localizable", "search_view.title.search", fallback: "Search") + } + } + public enum Searchable { + public enum Prompt { + /// Filter + public static let filter = L10n.tr("Localizable", "searchable.prompt.filter", fallback: "Filter") + } + public enum Title { + /// Found %d matches. + public static func matchesCount(_ p1: Int) -> String { + return L10n.tr("Localizable", "searchable.title.matches_count", p1, fallback: "Found %d matches.") + } + } + } + public enum SettingView { + public enum Title { + /// Setting + public static let setting = L10n.tr("Localizable", "setting_view.title.setting", fallback: "Setting") + } + } + public enum Struct { + public enum CookieValue { + public enum LocalizedString { + /// Expired + public static let expired = L10n.tr("Localizable", "struct.cookie_value.localized_string.expired", fallback: "Expired") + /// Rejected + public static let mystery = L10n.tr("Localizable", "struct.cookie_value.localized_string.mystery", fallback: "Rejected") + /// None + public static let `none` = L10n.tr("Localizable", "struct.cookie_value.localized_string.none", fallback: "None") + } + } + public enum DownloadBadge { + /// %d/%d + public static func progress(_ p1: Int, _ p2: Int) -> String { + return L10n.tr("Localizable", "struct.download_badge.progress", p1, p2, fallback: "%d/%d") + } + public enum Text { + /// Downloaded + public static let downloaded = L10n.tr("Localizable", "struct.download_badge.text.downloaded", fallback: "Downloaded") + /// Downloading + public static let downloading = L10n.tr("Localizable", "struct.download_badge.text.downloading", fallback: "Downloading") + /// Needs Attention + public static let needsAttention = L10n.tr("Localizable", "struct.download_badge.text.needs_attention", fallback: "Needs Attention") + /// Needs Repair + public static let needsRepair = L10n.tr("Localizable", "struct.download_badge.text.needs_repair", fallback: "Needs Repair") + /// Paused + public static let paused = L10n.tr("Localizable", "struct.download_badge.text.paused", fallback: "Paused") + /// Queued + public static let queued = L10n.tr("Localizable", "struct.download_badge.text.queued", fallback: "Queued") + /// Update Available + public static let updateAvailable = L10n.tr("Localizable", "struct.download_badge.text.update_available", fallback: "Update Available") + } + } + public enum Greeting { + public enum Mark { + /// and + public static let and = L10n.tr("Localizable", "struct.greeting.mark.and", fallback: " and ") + /// ! + public static let end = L10n.tr("Localizable", "struct.greeting.mark.end", fallback: "!") + /// , + public static let separator = L10n.tr("Localizable", "struct.greeting.mark.separator", fallback: ", ") + /// You gain + public static let start = L10n.tr("Localizable", "struct.greeting.mark.start", fallback: "You gain ") + } + } + public enum HathArchive { + public enum Price { + /// Free + public static let free = L10n.tr("Localizable", "struct.hath_archive.price.free", fallback: "Free") + /// N/A + public static let notAvailable = L10n.tr("Localizable", "struct.hath_archive.price.not_available", fallback: "N/A") + } + } + public enum User { + public enum FavoriteCategory { + /// All + public static let all = L10n.tr("Localizable", "struct.user.favorite_category.all", fallback: "All") + /// Favorites %@ + public static func `default`(_ p1: Any) -> String { + return L10n.tr("Localizable", "struct.user.favorite_category.default", String(describing: p1), fallback: "Favorites %@") + } + } + } + } + public enum SubSection { + public enum Button { + /// Show all + public static let showAll = L10n.tr("Localizable", "sub_section.button.show_all", fallback: "Show all") + } + } + public enum TabItem { + public enum Title { + /// Downloads + public static let downloads = L10n.tr("Localizable", "tab_item.title.downloads", fallback: "Downloads") + /// Favorites + public static let favorites = L10n.tr("Localizable", "tab_item.title.favorites", fallback: "Favorites") + /// Home + public static let home = L10n.tr("Localizable", "tab_item.title.home", fallback: "Home") + /// Search + public static let search = L10n.tr("Localizable", "tab_item.title.search", fallback: "Search") + /// Setting + public static let setting = L10n.tr("Localizable", "tab_item.title.setting", fallback: "Setting") + } + } + public enum TagDetailView { + public enum Section { + public enum Title { + /// Images + public static let images = L10n.tr("Localizable", "tag_detail_view.section.title.images", fallback: "Images") + /// Links + public static let links = L10n.tr("Localizable", "tag_detail_view.section.title.links", fallback: "Links") + } + } + } + public enum ToolbarItem { + public enum Button { + /// Seek to date + public static let dateSeek = L10n.tr("Localizable", "toolbar_item.button.date_seek", fallback: "Seek to date") + /// Filters + public static let filters = L10n.tr("Localizable", "toolbar_item.button.filters", fallback: "Filters") + /// Jump page + public static let jumpPage = L10n.tr("Localizable", "toolbar_item.button.jump_page", fallback: "Jump page") + /// Quick search + public static let quickSearch = L10n.tr("Localizable", "toolbar_item.button.quick_search", fallback: "Quick search") + } + } + public enum ToplistsView { + public enum Title { + /// Toplists + public static let toplists = L10n.tr("Localizable", "toplists_view.title.toplists", fallback: "Toplists") + } + } + public enum TorrentsView { + public enum Title { + /// Torrents + public static let torrents = L10n.tr("Localizable", "torrents_view.title.torrents", fallback: "Torrents") + } + } + public enum WatchedView { + public enum Title { + /// Watched + public static let watched = L10n.tr("Localizable", "watched_view.title.watched", fallback: "Watched") + } + } + public enum Website { + public enum Response { + /// You must have a H@H client assigned to your account to use this feature. + public static let hathClientNotFound = L10n.tr("Localizable", "website.response.hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") + /// Your H@H client appears to be offline. Turn it on, then try again. + public static let hathClientNotOnline = L10n.tr("Localizable", "website.response.hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") + /// The requested gallery cannot be downloaded with the selected resolution. + public static let invalidResolution = L10n.tr("Localizable", "website.response.invalid_resolution", fallback: "The requested gallery cannot be downloaded with the selected resolution.") + } + } + } +} +// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces + +// MARK: - Implementation Details + +extension L10n { + private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { + let format = BundleToken.bundle.localizedString(forKey: key, value: value, table: table) + return String(format: format, locale: Locale.current, arguments: args) + } +} + +// swiftlint:disable convenience_type +private final class BundleToken { + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() +} +// swiftlint:enable convenience_type diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift index 31883b50a..2186b2074 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift @@ -1,4 +1,5 @@ import Kingfisher +import Resources import UIKit import Foundation import Testing diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift index b18e99f08..9c586a704 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -1,4 +1,5 @@ import Foundation +import Resources import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift index 163e4c340..f36f227e9 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift @@ -1,4 +1,5 @@ import Foundation +import Resources import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift index 61691b1d9..3d1f4f9f5 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift @@ -1,4 +1,5 @@ import Foundation +import Resources import Testing @testable import AppFeature diff --git a/swiftgen.yml b/swiftgen.yml index 1709fbf7d..49effcd67 100644 --- a/swiftgen.yml +++ b/swiftgen.yml @@ -1,7 +1,9 @@ -output_dir: AppPackage/Sources/AppFeature/Generated +output_dir: AppPackage/Sources/Resources strings: - inputs: AppPackage/Sources/AppFeature/Resources/en.lproj + inputs: AppPackage/Sources/Resources/Resources/en.lproj outputs: - templateName: structured-swift5 output: Strings.swift + params: + publicAccess: true From 9555b7bdb8b0f1ff1de4162bfbc21ec3611f22b6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 17:17:28 +0800 Subject: [PATCH 307/614] Extract AppModels module Carve the pure value/model layer out of AppFeature into a new leaf AppModels module (depends only on Resources + a few libraries), mirroring the reference projects' Models -> Tools direction. Moved into AppModels: - All of Models/ (Gallery, Download, Persistent, Support, Tags). - Pure value types: ColorCodable, Defaults, EquatableVoid, IdentifiableBox, EnvironmentKeys. - AppIconType (a persisted enum that was stranded in a View file). - MarkdownUtil (used only by TagTranslation, made self-contained). - Foundational leaf helpers the models need: Optional.forceUnwrapped, String.nonEmpty/linkStyled/firstLetterCapitalized/stringsBesideColon/ replacingOccurrences(from:to:with:), and the URL appending(queryItems:) family (tied to Defaults.URL.Component). Broke the Models<->Tools cycles by pushing runtime behavior OUT of the models, keeping call sites unchanged via app-layer extensions: - Category.color is now color(host:) on the model; AppFeature re-adds the zero-arg color via AppUtil.galleryHost. - TranslatableLanguage drops checkUpdateURL/downloadURL; the URLs are built at the Network call sites from repoName/remoteFilename. - Defaults keeps only pure config; the device-derived (FrameSize, ImageSize preview widths) and host-derived (URL.host and friends) members move to Defaults extensions in AppFeature. Cross-module API work: model types and their members are now public, with explicit Sendable conformances and public memberwise inits restored (both were implicit within the old single module). Bare `Category` is qualified as AppModels.Category where it clashed with the Objective-C runtime's Category. 227 AppFeature/test files gained `import AppModels`. Package builds, app builds, all 286 tests pass; only the sanctioned SwiftUINavigation iOS-16 deprecation warning remains. --- AppPackage/Package.swift | 16 + .../AppFeature/DataFlow/AppRouteReducer.swift | 1 + .../NSManagedObjectModel+Resource.swift | 1 + .../NSPersistentStoreCoordinator+SQLite.swift | 1 + .../MODefinition/AppEnvMO+CoreDataClass.swift | 1 + .../GalleryDetailMO+CoreDataClass.swift | 3 +- .../GalleryMO+CoreDataClass.swift | 3 +- .../GalleryStateMO+CoreDataClass.swift | 1 + .../Migration/CoreDataMigrationStep.swift | 1 + .../Migration/CoreDataMigrationVersion.swift | 1 + .../Database/Migration/CoreDataMigrator.swift | 1 + .../Model5toModel6MigrationPolicy.swift | 1 + .../AppFeature/Database/Persistence.swift | 1 + .../Models/Download/DownloadBadge.swift | 4 - .../Models/Download/DownloadInspection.swift | 27 -- .../Models/Download/DownloadProgress.swift | 15 - .../Download/DownloadRequestOptions.swift | 14 - .../Download/DownloadedGallery+Manifest.swift | 44 -- .../Models/Download/DownloadedGallery.swift | 48 -- .../AppFeature/Models/Gallery/Gallery.swift | 99 ---- .../Models/Gallery/GalleryComment.swift | 51 -- .../Models/Gallery/GalleryDetail.swift | 99 ---- .../Models/Gallery/GalleryState.swift | 94 ---- .../Models/Gallery/GalleryTorrent.swift | 23 - .../AppFeature/Models/Persistent/AppEnv.swift | 26 - .../AppFeature/Models/Persistent/User.swift | 47 -- .../AppFeature/Models/Support/EhSetting.swift | 351 -------------- .../Tags/EhTagTranslationDatabaseModel.swift | 32 -- .../AppFeature/Models/Tags/TagDetail.swift | 8 - .../AppFeature/Network/DFExtensions.swift | 1 + .../AppFeature/Network/DFRequest.swift | 1 + .../AppFeature/Network/DFStreamHandler.swift | 1 + .../AppFeature/Network/DFURLProtocol.swift | 1 + .../AppFeature/Network/Request+Account.swift | 1 + .../AppFeature/Network/Request+Detail.swift | 1 + .../AppFeature/Network/Request+Gallery.swift | 1 + .../AppFeature/Network/Request+Image.swift | 1 + .../Sources/AppFeature/Network/Request.swift | 7 +- .../AppFeature/Tools/CategoryColor.swift | 17 + .../Clients/BackgroundProcessingClient.swift | 1 + .../Tools/Clients/CookieClient.swift | 1 + .../Clients/DatabaseClient+Updates.swift | 1 + .../Tools/Clients/DatabaseClient.swift | 1 + .../DownloadClient+BackgroundDownloads.swift | 1 + .../Tools/Clients/DownloadClient+Cache.swift | 1 + .../Clients/DownloadClient+Execution.swift | 1 + .../DownloadClient+ExecutionFetch.swift | 1 + .../DownloadClient+ExecutionPerform.swift | 1 + .../DownloadClient+ExecutionSupport.swift | 1 + .../Clients/DownloadClient+Folders.swift | 1 + .../Clients/DownloadClient+Manager.swift | 1 + .../Clients/DownloadClient+Networking.swift | 1 + .../Clients/DownloadClient+PageDownload.swift | 1 + .../DownloadClient+PageDownloadHelpers.swift | 1 + .../Clients/DownloadClient+Persistence.swift | 1 + .../DownloadClient+PersistenceHelpers.swift | 1 + .../DownloadClient+PersistenceNormalize.swift | 1 + .../Clients/DownloadClient+PublicAPI.swift | 1 + .../DownloadClient+PublicAPIHelpers.swift | 1 + .../DownloadClient+ResponseValidation.swift | 1 + ...loadClient+ResponseValidationHelpers.swift | 1 + .../Clients/DownloadClient+RetryHelpers.swift | 1 + .../Clients/DownloadClient+Scheduling.swift | 1 + .../DownloadClient+SchedulingHelpers.swift | 1 + .../Clients/DownloadClient+Testing.swift | 1 + .../Tools/Clients/DownloadClient.swift | 1 + .../Clients/DownloadPageDownloader.swift | 1 + .../AppFeature/Tools/Clients/FileClient.swift | 1 + .../Tools/Clients/ImageClient.swift | 1 + .../Tools/Clients/LibraryClient.swift | 1 + .../Tools/Clients/LoggerClient.swift | 1 + .../AppFeature/Tools/Clients/URLClient.swift | 1 + .../AppFeature/Tools/Defaults+Runtime.swift | 43 ++ .../Sources/AppFeature/Tools/Defaults.swift | 164 ------- .../Tools/Extensions/AlertKit_Extension.swift | 1 + .../Tools/Extensions/Extensions.swift | 89 +--- .../Tools/Extensions/Reducer_Extension.swift | 1 + .../Tools/Parser/Parser+Archive.swift | 1 + .../Tools/Parser/Parser+Comment.swift | 1 + .../Tools/Parser/Parser+Detail.swift | 3 +- .../Tools/Parser/Parser+Favorite.swift | 1 + .../Tools/Parser/Parser+Greeting.swift | 1 + .../Tools/Parser/Parser+Image.swift | 1 + .../AppFeature/Tools/Parser/Parser+List.swift | 5 +- .../AppFeature/Tools/Parser/Parser+Misc.swift | 1 + .../Tools/Parser/Parser+Preview.swift | 1 + .../Tools/Parser/Parser+Profile.swift | 1 + .../Tools/Parser/Parser+ResponseError.swift | 1 + .../Tools/Parser/Parser+Shared.swift | 1 + .../Tools/Parser/Parser+Torrent.swift | 1 + .../Tools/Parser/Parser+Types.swift | 3 +- .../AppFeature/Tools/Parser/Parser+User.swift | 1 + .../Tools/Utilities/AppLaunchAutomation.swift | 1 + .../AppFeature/Tools/Utilities/AppUtil.swift | 1 + .../Tools/Utilities/CookieUtil.swift | 1 + .../DownloadBackgroundTaskStore.swift | 1 + .../Tools/Utilities/DownloadQueueStore.swift | 1 + .../Utilities/DownloadStore+Operations.swift | 1 + .../Tools/Utilities/DownloadStore.swift | 1 + .../AppFeature/Tools/Utilities/FileUtil.swift | 1 + .../ImagePlaceholderFingerprint.swift | 1 + .../AppFeature/Tools/Utilities/URLUtil.swift | 21 +- .../Detail/Archives/ArchivesReducer.swift | 1 + .../View/Detail/Archives/ArchivesView.swift | 1 + .../Detail/Comments/CommentsReducer.swift | 1 + .../View/Detail/Comments/CommentsView.swift | 1 + .../Detail/Components/TagDetailView.swift | 1 + .../View/Detail/DetailReducer+Download.swift | 1 + .../View/Detail/DetailReducer.swift | 1 + .../DetailSearch/DetailSearchReducer.swift | 1 + .../DetailSearch/DetailSearchView.swift | 1 + .../View/Detail/DetailView+CommentCells.swift | 1 + .../Detail/DetailView+HeaderSection.swift | 1 + .../View/Detail/DetailView+Subviews.swift | 1 + .../AppFeature/View/Detail/DetailView.swift | 1 + .../GalleryInfos/GalleryInfosView.swift | 1 + .../Detail/Previews/PreviewsReducer.swift | 1 + .../View/Detail/Previews/PreviewsView.swift | 1 + .../Detail/Torrents/TorrentsReducer.swift | 1 + .../View/Detail/Torrents/TorrentsView.swift | 1 + .../Downloads/DownloadInspectorReducer.swift | 1 + .../View/Downloads/DownloadsReducer.swift | 1 + .../Downloads/DownloadsView+Subviews.swift | 1 + .../View/Downloads/DownloadsView.swift | 1 + .../View/Downloads/FolderManagerReducer.swift | 1 + .../View/Favorites/FavoritesReducer.swift | 1 + .../View/Favorites/FavoritesView.swift | 1 + .../Home/Frontpage/FrontpageReducer.swift | 1 + .../View/Home/Frontpage/FrontpageView.swift | 1 + .../View/Home/History/HistoryReducer.swift | 1 + .../View/Home/History/HistoryView.swift | 1 + .../AppFeature/View/Home/HomeReducer.swift | 1 + .../View/Home/HomeView+Sections.swift | 1 + .../AppFeature/View/Home/HomeView.swift | 1 + .../View/Home/Popular/PopularReducer.swift | 1 + .../View/Home/Popular/PopularView.swift | 1 + .../View/Home/Toplists/ToplistsReducer.swift | 1 + .../View/Home/Toplists/ToplistsView.swift | 1 + .../View/Home/Watched/WatchedReducer.swift | 1 + .../View/Home/Watched/WatchedView.swift | 1 + .../View/Migration/MigrationReducer.swift | 1 + .../Reading/ReadingReducer+Database.swift | 1 + .../View/Reading/ReadingReducer.swift | 1 + .../View/Reading/ReadingView+Gestures.swift | 1 + .../AppFeature/View/Reading/ReadingView.swift | 1 + .../View/Reading/ReadingViewComponents.swift | 1 + .../Reading/Support/AutoPlayHandler.swift | 1 + .../View/Reading/Support/ControlPanel.swift | 1 + .../View/Reading/Support/GestureHandler.swift | 1 + .../Reading/Support/LiveTextHandler.swift | 1 + .../View/Reading/Support/LiveTextView.swift | 1 + .../View/Reading/Support/PageHandler.swift | 1 + .../View/Search/SearchReducer.swift | 1 + .../View/Search/SearchRootReducer.swift | 1 + .../View/Search/SearchRootView.swift | 1 + .../AppFeature/View/Search/SearchView.swift | 1 + .../Search/Support/QuickSearchReducer.swift | 1 + .../View/Search/Support/QuickSearchView.swift | 1 + .../AccountSettingReducer.swift | 1 + .../AccountSetting/AccountSettingView.swift | 1 + .../AppearanceSettingView.swift | 52 +- .../Components/ReadingSettingView.swift | 1 + .../View/Setting/Components/WebView.swift | 1 + .../Setting/EhSetting/EhSettingReducer.swift | 1 + .../EhSetting/EhSettingView+Sections1.swift | 1 + .../EhSetting/EhSettingView+Sections2.swift | 5 +- .../EhSetting/EhSettingView+Sections3.swift | 1 + .../Setting/EhSetting/EhSettingView.swift | 1 + .../GeneralSettingReducer.swift | 1 + .../GeneralSetting/GeneralSettingView.swift | 1 + .../View/Setting/Login/LoginReducer.swift | 1 + .../View/Setting/Login/LoginView.swift | 1 + .../View/Setting/Logs/LogsReducer.swift | 1 + .../View/Setting/SettingReducer+Body.swift | 1 + .../View/Setting/SettingReducer+Helpers.swift | 1 + .../View/Setting/SettingReducer.swift | 1 + .../View/Support/Components/AlertView.swift | 1 + .../Support/Components/CategoryView.swift | 9 +- .../Components/Cells/GalleryCardCell.swift | 1 + .../Components/Cells/GalleryDetailCell.swift | 1 + .../Components/Cells/GalleryHistoryCell.swift | 1 + .../Components/Cells/GalleryRankingCell.swift | 1 + .../Cells/GalleryThumbnailCell.swift | 1 + .../Components/DateSeekPickerView.swift | 1 + .../Components/DownloadBadgeLabel.swift | 1 + .../View/Support/Components/GenericList.swift | 1 + .../View/Support/Components/Placeholder.swift | 1 + .../Support/Components/PreviewImageView.swift | 1 + .../Components/TagSuggestionView.swift | 1 + .../Support/Components/ToolbarItems.swift | 1 + .../View/Support/DateSeekReducer.swift | 1 + .../View/Support/FiltersReducer.swift | 1 + .../AppFeature/View/Support/FiltersView.swift | 3 +- .../AppFeature/View/Support/NewDawnView.swift | 1 + .../AppFeature/View/TabBar/TabBarView.swift | 1 + AppPackage/Sources/AppModels/.swiftlint.yml | 1 + .../AppModels/Download/DownloadBadge.swift | 11 + .../Download/DownloadDisplayStatus.swift | 4 +- .../Download/DownloadFailure.swift | 14 +- .../Download/DownloadFolderFilter.swift | 6 +- .../Download/DownloadInspection.swift | 49 ++ .../AppModels/Download/DownloadProgress.swift | 22 + .../Download/DownloadRequestOptions.swift | 24 + .../Download/DownloadStartMode.swift | 2 +- .../DownloadedGallery+Extensions.swift | 72 ++- .../Download/DownloadedGallery+Manifest.swift | 74 +++ .../DownloadedGallery+SupportTypes.swift | 38 +- .../Download/DownloadedGallery.swift | 48 ++ .../Gallery/Category.swift | 16 +- .../Sources/AppModels/Gallery/Gallery.swift | 127 +++++ .../Gallery/GalleryArchive.swift | 29 +- .../AppModels/Gallery/GalleryComment.swift | 89 ++++ .../AppModels/Gallery/GalleryDetail.swift | 142 ++++++ .../AppModels/Gallery/GalleryState.swift | 138 ++++++ .../AppModels/Gallery/GalleryTorrent.swift | 46 ++ .../Gallery/Language.swift | 10 +- .../Sources/AppModels/Persistent/AppEnv.swift | 45 ++ .../AppModels/Persistent/AppIconType.swift | 51 ++ .../Persistent/Filter.swift | 117 +++-- .../Persistent/Greeting.swift | 37 +- .../Persistent/Setting.swift | 159 ++++--- .../Sources/AppModels/Persistent/User.swift | 64 +++ .../Support/AppError.swift | 18 +- .../Support/BrowsingCountry+EnglishName.swift | 2 +- .../Support/BrowsingCountry.swift | 6 +- .../Support/EhSetting+Enums.swift | 30 +- .../Support/EhSetting+Extensions.swift | 24 +- .../Sources/AppModels/Support/EhSetting.swift | 445 ++++++++++++++++++ .../Support/LiveText.swift | 65 +-- .../Models => AppModels}/Support/Misc.swift | 97 ++-- .../Tags/EhTagTranslationDatabaseModel.swift | 53 +++ .../Sources/AppModels/Tags/TagDetail.swift | 19 + .../Tags/TagNamespace.swift | 10 +- .../Tags/TagSuggestion.swift | 35 +- .../Tags/TagTranslation.swift | 43 +- .../Tags/TagTranslator.swift | 25 +- .../Tags/TranslatableLanguage.swift | 16 +- .../Utilities/MarkdownUtil.swift | 13 +- .../ValueTypes}/ColorCodable.swift | 2 +- .../AppModels/ValueTypes/Defaults.swift | 138 ++++++ .../ValueTypes}/EnvironmentKeys.swift | 6 +- .../ValueTypes}/EquatableVoid.swift | 0 .../ValueTypes}/IdentifiableBox.swift | 0 .../ValueTypes/Optional+ForceUnwrapped.swift | 14 + .../AppModels/ValueTypes/String+Helpers.swift | 43 ++ .../AppModels/ValueTypes/URL+QueryItems.swift | 37 ++ .../AppFeatureTests/Models/HTMLFilename.swift | 2 + .../Download/DetailReducerDownloadTests.swift | 1 + .../Download/DetailReducerMetadataTests.swift | 1 + .../DetailReducerMetadataUpdateTests.swift | 1 + .../Download/DetailReducerObserveTests.swift | 1 + .../DetailReducerPauseAndGuardTests.swift | 1 + .../Download/DownloadAutomationTests.swift | 1 + .../Download/DownloadBadgeSortTests.swift | 1 + .../DownloadCoordinatorCachedURLTests.swift | 1 + .../DownloadCoordinatorCaptureTests.swift | 1 + .../DownloadCoordinatorRepairSeedTests.swift | 1 + .../DownloadCoordinatorStorageTests.swift | 1 + .../DownloadEnqueueManifestTests.swift | 1 + .../DownloadFeatureTestFactories.swift | 5 +- .../Download/DownloadFeatureTestHelpers.swift | 1 + .../DownloadFilterAndBadgeTests.swift | 3 +- .../DownloadFolderOperationTests.swift | 1 + .../Download/DownloadImageErrorTests.swift | 1 + .../DownloadImageParsingCacheTests.swift | 1 + .../Download/DownloadImageParsingTests.swift | 1 + .../Download/DownloadInspectorLoadTests.swift | 1 + .../DownloadInspectorRetryTests.swift | 1 + .../Download/DownloadInspectorSkipTests.swift | 1 + .../DownloadInterruptedResumeTests.swift | 1 + .../Tests/Download/DownloadIpBanTests.swift | 1 + .../Download/DownloadObserverBatchTests.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../DownloadPauseAndReconcileTests.swift | 1 + .../Download/DownloadProcessCacheTests.swift | 1 + .../Tests/Download/DownloadProcessTests.swift | 1 + .../DownloadRetryMinimalSourceTests.swift | 1 + .../Download/DownloadRetryPagesTests.swift | 1 + .../DownloadRetryUpdateFallbackTests.swift | 1 + .../Download/DownloadSchedulingTests.swift | 1 + .../Download/DownloadStoreHashTests.swift | 1 + .../Download/DownloadStoreRepairTests.swift | 1 + .../Tests/Download/DownloadStoreTests.swift | 1 + .../DownloadVersionSignatureTests.swift | 1 + .../DownloadedGalleryManifestModelTests.swift | 1 + .../DownloadsReducerActionTests.swift | 1 + .../DownloadsReducerRefreshTests.swift | 1 + .../Download/FolderManagerReducerTests.swift | 1 + .../PreviewsReducerDownloadTests.swift | 1 + .../Tests/Download/ReaderImageDataTests.swift | 1 + .../ReadingReducerDownloadTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + .../Tests/Parser/List/ListParserTests.swift | 1 + .../Other/DownloadPageErrorParserTests.swift | 1 + .../Parser/Other/EhSettingParserTests.swift | 1 + .../Parser/Other/SettingDownloadTests.swift | 1 + 297 files changed, 2576 insertions(+), 1646 deletions(-) delete mode 100644 AppPackage/Sources/AppFeature/Models/Download/DownloadBadge.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Download/DownloadInspection.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Download/DownloadProgress.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Download/DownloadRequestOptions.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+Manifest.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Gallery/Gallery.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Gallery/GalleryComment.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Gallery/GalleryDetail.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Gallery/GalleryState.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Gallery/GalleryTorrent.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Persistent/AppEnv.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Persistent/User.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Support/EhSetting.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Tags/EhTagTranslationDatabaseModel.swift delete mode 100644 AppPackage/Sources/AppFeature/Models/Tags/TagDetail.swift create mode 100644 AppPackage/Sources/AppFeature/Tools/CategoryColor.swift create mode 100644 AppPackage/Sources/AppFeature/Tools/Defaults+Runtime.swift delete mode 100644 AppPackage/Sources/AppFeature/Tools/Defaults.swift create mode 100644 AppPackage/Sources/AppModels/.swiftlint.yml create mode 100644 AppPackage/Sources/AppModels/Download/DownloadBadge.swift rename AppPackage/Sources/{AppFeature/Models => AppModels}/Download/DownloadDisplayStatus.swift (81%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Download/DownloadFailure.swift (83%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Download/DownloadFolderFilter.swift (73%) create mode 100644 AppPackage/Sources/AppModels/Download/DownloadInspection.swift create mode 100644 AppPackage/Sources/AppModels/Download/DownloadProgress.swift create mode 100644 AppPackage/Sources/AppModels/Download/DownloadRequestOptions.swift rename AppPackage/Sources/{AppFeature/Models => AppModels}/Download/DownloadStartMode.swift (53%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Download/DownloadedGallery+Extensions.swift (61%) create mode 100644 AppPackage/Sources/AppModels/Download/DownloadedGallery+Manifest.swift rename AppPackage/Sources/{AppFeature/Models => AppModels}/Download/DownloadedGallery+SupportTypes.swift (73%) create mode 100644 AppPackage/Sources/AppModels/Download/DownloadedGallery.swift rename AppPackage/Sources/{AppFeature/Models => AppModels}/Gallery/Category.swift (82%) create mode 100644 AppPackage/Sources/AppModels/Gallery/Gallery.swift rename AppPackage/Sources/{AppFeature/Models => AppModels}/Gallery/GalleryArchive.swift (58%) create mode 100644 AppPackage/Sources/AppModels/Gallery/GalleryComment.swift create mode 100644 AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift create mode 100644 AppPackage/Sources/AppModels/Gallery/GalleryState.swift create mode 100644 AppPackage/Sources/AppModels/Gallery/GalleryTorrent.swift rename AppPackage/Sources/{AppFeature/Models => AppModels}/Gallery/Language.swift (97%) create mode 100644 AppPackage/Sources/AppModels/Persistent/AppEnv.swift create mode 100644 AppPackage/Sources/AppModels/Persistent/AppIconType.swift rename AppPackage/Sources/{AppFeature/Models => AppModels}/Persistent/Filter.swift (51%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Persistent/Greeting.swift (69%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Persistent/Setting.swift (57%) create mode 100644 AppPackage/Sources/AppModels/Persistent/User.swift rename AppPackage/Sources/{AppFeature/Models => AppModels}/Support/AppError.swift (94%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Support/BrowsingCountry+EnglishName.swift (99%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Support/BrowsingCountry.swift (99%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Support/EhSetting+Enums.swift (77%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Support/EhSetting+Extensions.swift (73%) create mode 100644 AppPackage/Sources/AppModels/Support/EhSetting.swift rename AppPackage/Sources/{AppFeature/Models => AppModels}/Support/LiveText.swift (74%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Support/Misc.swift (52%) create mode 100644 AppPackage/Sources/AppModels/Tags/EhTagTranslationDatabaseModel.swift create mode 100644 AppPackage/Sources/AppModels/Tags/TagDetail.swift rename AppPackage/Sources/{AppFeature/Models => AppModels}/Tags/TagNamespace.swift (91%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Tags/TagSuggestion.swift (66%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Tags/TagTranslation.swift (73%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Tags/TagTranslator.swift (50%) rename AppPackage/Sources/{AppFeature/Models => AppModels}/Tags/TranslatableLanguage.swift (75%) rename AppPackage/Sources/{AppFeature/Tools => AppModels}/Utilities/MarkdownUtil.swift (92%) rename AppPackage/Sources/{AppFeature/Tools => AppModels/ValueTypes}/ColorCodable.swift (96%) create mode 100644 AppPackage/Sources/AppModels/ValueTypes/Defaults.swift rename AppPackage/Sources/{AppFeature/Tools => AppModels/ValueTypes}/EnvironmentKeys.swift (53%) rename AppPackage/Sources/{AppFeature/Tools => AppModels/ValueTypes}/EquatableVoid.swift (100%) rename AppPackage/Sources/{AppFeature/Tools => AppModels/ValueTypes}/IdentifiableBox.swift (100%) create mode 100644 AppPackage/Sources/AppModels/ValueTypes/Optional+ForceUnwrapped.swift create mode 100644 AppPackage/Sources/AppModels/ValueTypes/String+Helpers.swift create mode 100644 AppPackage/Sources/AppModels/ValueTypes/URL+QueryItems.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index b3a130a66..a1ec00e5f 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -67,6 +67,7 @@ let sharedSwiftSettings: [PackageDescription.SwiftSetting] = [ // MARK: Module enum Module: String { case appFeature = "AppFeature" + case appModels = "AppModels" case resources = "Resources" // Test targets @@ -204,6 +205,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .appFeature, dependencies: [ + .module(.appModels), .module(.resources), .targetDependency(.alertKit), .targetDependency(.colorful), @@ -228,6 +230,19 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .appModels, + dependencies: [ + .module(.resources), + .targetDependency(.commonMark), + .targetDependency(.composableArchitecture), + .targetDependency(.openCC), + .targetDependency(.sfSafeSymbols), + .targetDependency(.swiftyBeaver) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .resources, resources: [.process(.resources)], @@ -240,6 +255,7 @@ let targets: [PackageDescription.Target] = [ module: .appFeatureTests, dependencies: [ .module(.appFeature), + .module(.appModels), .targetDependency(.composableArchitecture), .targetDependency(.kanna), .targetDependency(.kingfisher), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index cec0c34eb..1007c5c9d 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift b/AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift index 25f87f53f..2f76acfd8 100755 --- a/AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift +++ b/AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import CoreData extension NSManagedObjectModel { diff --git a/AppPackage/Sources/AppFeature/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift b/AppPackage/Sources/AppFeature/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift index b4501ea11..d12ba95b3 100755 --- a/AppPackage/Sources/AppFeature/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift +++ b/AppPackage/Sources/AppFeature/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift @@ -1,4 +1,5 @@ import CoreData +import AppModels extension NSPersistentStoreCoordinator { static func destroyStore(at storeURL: URL) throws { diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift index 375f16103..acd45492c 100644 --- a/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift +++ b/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift @@ -1,4 +1,5 @@ import CoreData +import AppModels public class AppEnvMO: NSManagedObject {} diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift index f9c17ceb8..edc3a0206 100644 --- a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift +++ b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift @@ -1,4 +1,5 @@ import CoreData +import AppModels public class GalleryDetailMO: NSManagedObject {} @@ -8,7 +9,7 @@ extension GalleryDetailMO: ManagedObjectProtocol { gid: gid, title: title, jpnTitle: jpnTitle, isFavorited: isFavorited, visibility: visibility?.toObject() ?? GalleryVisibility.yes, rating: rating, userRating: userRating, ratingCount: Int(ratingCount), - category: Category(rawValue: category).forceUnwrapped, + category: AppModels.Category(rawValue: category).forceUnwrapped, language: Language(rawValue: language).forceUnwrapped, uploader: uploader, postedDate: postedDate, coverURL: coverURL, archiveURL: archiveURL, parentURL: parentURL, diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift index 2f3e69bda..00ed894f0 100644 --- a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift +++ b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift @@ -1,4 +1,5 @@ import CoreData +import AppModels public class GalleryMO: NSManagedObject {} @@ -8,7 +9,7 @@ extension GalleryMO: ManagedObjectProtocol { gid: gid, token: token, title: title, rating: rating, tags: tags?.toObject() ?? [GalleryTag](), - category: Category(rawValue: category) ?? .private, + category: AppModels.Category(rawValue: category) ?? .private, uploader: uploader, pageCount: Int(pageCount), postedDate: postedDate, coverURL: coverURL, galleryURL: galleryURL, diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift index 8d4e7786a..f9c54a4d0 100644 --- a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift +++ b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import CoreData public class GalleryStateMO: NSManagedObject {} diff --git a/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationStep.swift b/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationStep.swift index 40ecca07f..81e8db4f9 100755 --- a/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationStep.swift +++ b/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationStep.swift @@ -1,4 +1,5 @@ import CoreData +import AppModels struct CoreDataMigrationStep { let sourceModel: NSManagedObjectModel diff --git a/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationVersion.swift b/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationVersion.swift index 535068d80..5e1cbc915 100755 --- a/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationVersion.swift +++ b/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationVersion.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import CoreData enum CoreDataMigrationVersion: String, CaseIterable { diff --git a/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrator.swift b/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrator.swift index adfd5d227..5614619a9 100755 --- a/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrator.swift +++ b/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrator.swift @@ -1,4 +1,5 @@ import CoreData +import AppModels protocol CoreDataMigratorProtocol { func requiresMigration(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws -> Bool diff --git a/AppPackage/Sources/AppFeature/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift b/AppPackage/Sources/AppFeature/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift index f71721c35..40519b76a 100644 --- a/AppPackage/Sources/AppFeature/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift +++ b/AppPackage/Sources/AppFeature/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift @@ -1,4 +1,5 @@ import CoreData +import AppModels // reason: the migration policy name must encode both the source and destination model versions // swiftlint:disable type_name diff --git a/AppPackage/Sources/AppFeature/Database/Persistence.swift b/AppPackage/Sources/AppFeature/Database/Persistence.swift index cc19509e2..259ee7158 100644 --- a/AppPackage/Sources/AppFeature/Database/Persistence.swift +++ b/AppPackage/Sources/AppFeature/Database/Persistence.swift @@ -1,4 +1,5 @@ import CoreData +import AppModels struct PersistenceController: Sendable { static let shared = PersistenceController() diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadBadge.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadBadge.swift deleted file mode 100644 index 2d6289c65..000000000 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadBadge.swift +++ /dev/null @@ -1,4 +0,0 @@ -struct DownloadBadge: Equatable, Sendable { - let status: DownloadDisplayStatus - let progress: DownloadProgress -} diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadInspection.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadInspection.swift deleted file mode 100644 index 72bcc33a8..000000000 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadInspection.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -enum DownloadPageStatus: String, Equatable, CaseIterable, Sendable { - case pending - case downloaded - case failed -} - -struct DownloadPageInspection: Equatable, Identifiable, Sendable { - var id: Int { index } - - let index: Int - let status: DownloadPageStatus - let relativePath: String? - let fileURL: URL? - let failure: DownloadFailure? -} - -struct DownloadInspection: Equatable, Sendable { - let download: DownloadedGallery - let coverURL: URL? - let pages: [DownloadPageInspection] - - var failedPageIndices: [Int] { - pages.filter { $0.status == .failed }.map(\.index) - } -} diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadProgress.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadProgress.swift deleted file mode 100644 index a0e2c1cf0..000000000 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadProgress.swift +++ /dev/null @@ -1,15 +0,0 @@ -struct DownloadProgress: Equatable, Sendable { - let completedPageCount: Int - let pageCount: Int - - var displayPageCount: Int { - max(pageCount, 1) - } - var displayCompletedPageCount: Int { - min(max(completedPageCount, 0), displayPageCount) - } - - var fraction: Double { - Double(displayCompletedPageCount) / Double(displayPageCount) - } -} diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadRequestOptions.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadRequestOptions.swift deleted file mode 100644 index 5825c31ba..000000000 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadRequestOptions.swift +++ /dev/null @@ -1,14 +0,0 @@ -/// Execution policy for a download: *how* to fetch (thread limit, cellular, auto-retry), -/// not *what* to fetch. Deliberately separate from `DownloadRequestPayload` and never -/// persisted to a manifest or request: it is resolved fresh from the latest settings once -/// per run (see `downloadOptionsProvider`) and threaded to the workers, so a settings change -/// while a gallery sits queued takes effect when it finally starts. -struct DownloadRequestOptions: Equatable, Sendable { - var threadLimit = Setting.downloadThreadLimitDefaultValue - var allowCellular = Setting.downloadAllowCellularDefaultValue - var autoRetryFailedPages = Setting.downloadAutoRetryFailedPagesDefaultValue - - var workerCount: Int { - threadLimit - } -} diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+Manifest.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+Manifest.swift deleted file mode 100644 index 389d84972..000000000 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+Manifest.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -/// The identity record for a downloaded gallery, written to `manifest.json` in its folder. -/// Identity lives *here* (`gid` / `token`), not in the folder path: the human-readable -/// `[gid_token] Title` folder name is presentation, and the title in it can change and -/// re-slot the directory without affecting identity. Folder membership follows the file's -/// location on disk, so this manifest is what re-establishes identity after the gallery is -/// moved or renamed via the Files app. -struct DownloadManifest: Codable, Equatable, Sendable { - let gid: String - let host: GalleryHost - let token: String - let title: String - let jpnTitle: String? - let category: Category - let language: Language - let remoteCoverURL: URL? - let uploader: String? - let tags: [GalleryTag] - let postedDate: Date - let rating: Float - var pages: [Int: String] -} - -extension DownloadManifest { - var pageCount: Int { - pages.count - } - - var galleryURL: URL { - host.url - .appendingPathComponent("g") - .appendingPathComponent(gid) - .appendingPathComponent(token) - } - - var completedPageCount: Int { - pages.values.filter { !$0.isEmpty }.count - } - - var isComplete: Bool { - !pages.isEmpty && completedPageCount == pages.count - } -} diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery.swift b/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery.swift deleted file mode 100644 index 43e45648c..000000000 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery.swift +++ /dev/null @@ -1,48 +0,0 @@ -import SwiftUI - -struct DownloadedGallery: Identifiable, Equatable { - var id: String { gid } - - let manifest: DownloadManifest - let folderURL: URL - let folderName: String - let localCoverURL: URL? - let localPageURLs: [Int: URL] - let displayStatus: DownloadDisplayStatus - let lastDownloadedDate: Date? - let lastError: DownloadFailure? - - var gid: String { manifest.gid } - var host: GalleryHost { manifest.host } - var token: String { manifest.token } - var title: String { manifest.title } - var jpnTitle: String? { manifest.jpnTitle } - var uploader: String? { manifest.uploader } - var category: Category { manifest.category } - var tags: [GalleryTag] { manifest.tags } - var pageCount: Int { manifest.pageCount } - var postedDate: Date { manifest.postedDate } - var rating: Float { manifest.rating } - var onlineCoverURL: URL? { manifest.remoteCoverURL } - var completedPageCount: Int { manifest.completedPageCount } - - init( - manifest: DownloadManifest, - folderURL: URL, - folderName: String, - localCoverURL: URL?, - localPageURLs: [Int: URL], - modificationDate: Date?, - displayStatus: DownloadDisplayStatus, - lastError: DownloadFailure? = nil - ) { - self.manifest = manifest - self.folderURL = folderURL - self.folderName = folderName - self.localCoverURL = localCoverURL - self.localPageURLs = localPageURLs - self.displayStatus = displayStatus - self.lastDownloadedDate = modificationDate - self.lastError = lastError - } -} diff --git a/AppPackage/Sources/AppFeature/Models/Gallery/Gallery.swift b/AppPackage/Sources/AppFeature/Models/Gallery/Gallery.swift deleted file mode 100644 index b5df8ea43..000000000 --- a/AppPackage/Sources/AppFeature/Models/Gallery/Gallery.swift +++ /dev/null @@ -1,99 +0,0 @@ -import SwiftUI - -struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { - static func == (lhs: Gallery, rhs: Gallery) -> Bool { - lhs.gid == rhs.gid - } - - static func mockGalleries(count: Int, randomID: Bool = true) -> [Gallery] { - guard randomID, count > 0 else { - return Array(repeating: .empty, count: count) - } - return (0...count).map { _ in .empty } - } - static var empty: Gallery { - .init( - gid: UUID().uuidString, - token: "", - title: "", - rating: 0.0, - tags: [], - category: .doujinshi, - uploader: "", - pageCount: 1, - postedDate: .now, - coverURL: nil, - galleryURL: nil - ) - } - static let preview = Gallery( - gid: UUID().uuidString, - token: "", - title: "Preview", - rating: 3.5, - tags: [], - category: .doujinshi, - uploader: "Anonymous", - pageCount: 1, - postedDate: .now, - coverURL: URL( - string: "https://github.com/" - + "EhPanda-Team/Imageset/blob/" - + "main/JPGs/2.jpg?raw=true" - ), - galleryURL: nil - ) - - var trimmedTitle: String { - var title = title - if let range = title.range(of: "|") { - title = String(title[.. [GalleryTag.Content] { - let tagContents = tags.flatMap(\.contents) - guard maximum > 0 else { return tagContents } - return .init(tagContents.prefix(min(tagContents.count, maximum))) - } - - var id: String { gid } - let gid: String - let token: String - - var title: String - var rating: Float - var tags: [GalleryTag] - let category: Category - var uploader: String? - var pageCount: Int - let postedDate: Date - let coverURL: URL? - let galleryURL: URL? - var lastOpenDate: Date? -} - -extension Gallery: DateFormattable, CustomStringConvertible { - var description: String { - "Gallery(\(gid))" - } - - var filledCount: Int { Int(rating) } - var halfFilledCount: Int { Int(rating - 0.5) == filledCount ? 1 : 0 } - var notFilledCount: Int { 5 - filledCount - halfFilledCount } - - var color: Color { - category.color - } - var originalDate: Date { - postedDate - } -} diff --git a/AppPackage/Sources/AppFeature/Models/Gallery/GalleryComment.swift b/AppPackage/Sources/AppFeature/Models/Gallery/GalleryComment.swift deleted file mode 100644 index 7f91dfe6a..000000000 --- a/AppPackage/Sources/AppFeature/Models/Gallery/GalleryComment.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation - -struct GalleryComment: Identifiable, Equatable, Codable { - var id: String { commentID } - - var votedUp: Bool - var votedDown: Bool - let votable: Bool - let editable: Bool - - let score: String? - let author: String - let contents: [CommentContent] - let commentID: String - let commentDate: Date - - var plainTextContent: String { - contents - .filter { [.plainText, .linkedText, .singleLink].contains($0.type) } - .compactMap { $0.type == .singleLink ? $0.link?.absoluteString : $0.text }.joined() - } -} - -extension GalleryComment: DateFormattable { - var originalDate: Date { - commentDate - } -} - -struct CommentContent: Identifiable, Equatable, Codable { - var id: UUID = .init() - let type: CommentContentType - var text: String? - var link: URL? - var imgURL: URL? - - var secondLink: URL? - var secondImgURL: URL? -} - -enum CommentContentType: Int, Codable { - case singleImg - case doubleImg - case linkedImg - case doubleLinkedImg - - case plainText - case linkedText - - case singleLink -} diff --git a/AppPackage/Sources/AppFeature/Models/Gallery/GalleryDetail.swift b/AppPackage/Sources/AppFeature/Models/Gallery/GalleryDetail.swift deleted file mode 100644 index f9c9b1e8a..000000000 --- a/AppPackage/Sources/AppFeature/Models/Gallery/GalleryDetail.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Foundation -import Resources - -struct GalleryDetail: Codable, Equatable, Sendable { - static let empty: Self = .init( - gid: "", title: "", isFavorited: false, - visibility: .yes, rating: 0, userRating: 0, - ratingCount: 0, category: .private, - language: .japanese, uploader: "", - postedDate: .now, coverURL: nil, - favoritedCount: 0, pageCount: 0, - sizeCount: 0, sizeType: "", - torrentCount: 0 - ) - static let preview = GalleryDetail( - gid: "", - title: "Preview", - jpnTitle: "プレビュー", - isFavorited: true, - visibility: .yes, - rating: 3.5, - userRating: 4.0, - ratingCount: 1919, - category: .doujinshi, - language: .japanese, - uploader: "Anonymous", - postedDate: .distantPast, - coverURL: URL( - string: "https://github.com/" - + "EhPanda-Team/Imageset/blob/" - + "main/JPGs/2.jpg?raw=true" - ), - favoritedCount: 514, - pageCount: 114, - sizeCount: 514, - sizeType: "MB", - torrentCount: 101 - ) - - var trimmedTitle: String { - var title = title - if let range = title.range(of: "|") { - title = String(title[.. String { - let namespace = tag.namespace?.abbreviation ?? tag.namespace?.rawValue ?? tag.rawNamespace.lowercased() - return tag.namespace == .temp ? text : [namespace, text].joined(separator: ":") - } - func serachKeyword(tag: GalleryTag) -> String { - let keyword = text.contains(" ") ? "\"\(text)$\"" : "\(text)$" - let namespace = tag.namespace?.abbreviation ?? tag.namespace?.rawValue ?? tag.rawNamespace.lowercased() - return tag.namespace == .temp ? keyword : [namespace, keyword].joined(separator: ":") - } - - let rawNamespace: String - let text: String - let isVotedUp: Bool - let isVotedDown: Bool - let textColor: Color? - let backgroundColor: Color? - } - - var id: String { rawNamespace } - var namespace: TagNamespace? { - .init(rawValue: rawNamespace) - } - - let rawNamespace: String - let contents: [Content] -} - -enum PreviewConfig: Codable, Equatable, Sendable { - case normal(rows: Int) - case large(rows: Int) -} - -extension PreviewConfig { - var batchSize: Int { - switch self { - case .normal(let rows): - return 10 * rows - case .large(let rows): - return 5 * rows - } - } - - func pageNumber(index: Int) -> Int { - max(index - 1, 0) / batchSize - } - func batchRange(index: Int) -> ClosedRange { - let lowerBound = pageNumber(index: index) * batchSize + 1 - let upperBound = lowerBound + batchSize - 1 - return lowerBound...upperBound - } -} diff --git a/AppPackage/Sources/AppFeature/Models/Gallery/GalleryTorrent.swift b/AppPackage/Sources/AppFeature/Models/Gallery/GalleryTorrent.swift deleted file mode 100644 index fffb48a65..000000000 --- a/AppPackage/Sources/AppFeature/Models/Gallery/GalleryTorrent.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -struct GalleryTorrent: Identifiable, Codable, Equatable { - var id: UUID = .init() - let postedDate: Date - let fileSize: String - let seedCount: Int - let peerCount: Int - let downloadCount: Int - let uploader: String - let fileName: String - let hash: String - let torrentURL: URL -} - -extension GalleryTorrent: DateFormattable { - var originalDate: Date { - postedDate - } - var magnetURL: String { - "magnet:?xt=urn:btih:\(hash)" - } -} diff --git a/AppPackage/Sources/AppFeature/Models/Persistent/AppEnv.swift b/AppPackage/Sources/AppFeature/Models/Persistent/AppEnv.swift deleted file mode 100644 index 2a5b9bac8..000000000 --- a/AppPackage/Sources/AppFeature/Models/Persistent/AppEnv.swift +++ /dev/null @@ -1,26 +0,0 @@ -struct AppEnv: Codable, Equatable { - let user: User - let setting: Setting - let searchFilter: Filter - let globalFilter: Filter - let watchedFilter: Filter - let tagTranslator: TagTranslator - let historyKeywords: [String] - let quickSearchWords: [QuickSearchWord] -} - -extension AppEnv: CustomStringConvertible { - var description: String { - let params = String( - describing: [ - "user": user, - "setting": setting, - "tagTranslator": tagTranslator, - "historyKeywordsCount": historyKeywords.count, - "quickSearchWordsCount": quickSearchWords.count - ] - as [String: Any] - ) - return "AppEnv(\(params))" - } -} diff --git a/AppPackage/Sources/AppFeature/Models/Persistent/User.swift b/AppPackage/Sources/AppFeature/Models/Persistent/User.swift deleted file mode 100644 index 359b08e1d..000000000 --- a/AppPackage/Sources/AppFeature/Models/Persistent/User.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation -import Resources - -struct User: Codable, Equatable { - static let empty = User() - - var displayName: String? - var avatarURL: URL? - var apikey: String? - - var credits: String? - var galleryPoints: String? - - var greeting: Greeting? - - var favoriteCategories: [Int: String]? - - func getFavoriteCategory(index: Int) -> String { - guard index != -1 else { return L10n.Localizable.Struct.User.FavoriteCategory.all } - let defaultCategory = L10n.Localizable.Struct.User.FavoriteCategory.default("\(index)") - let category = favoriteCategories?[index] ?? defaultCategory - let isDefault = category == "Favorites \(index)" - return isDefault ? defaultCategory : category - } -} - -enum FavoritesType: String, Codable, CaseIterable { - static func getTypeFrom(index: Int) -> FavoritesType { - FavoritesType.allCases.filter({ $0.index == index }).first ?? .all - } - - var index: Int { - Int(rawValue.replacingOccurrences(of: "favorite_", with: "")) ?? -1 - } - - case all = "all" - case favorite0 = "favorite_0" - case favorite1 = "favorite_1" - case favorite2 = "favorite_2" - case favorite3 = "favorite_3" - case favorite4 = "favorite_4" - case favorite5 = "favorite_5" - case favorite6 = "favorite_6" - case favorite7 = "favorite_7" - case favorite8 = "favorite_8" - case favorite9 = "favorite_9" -} diff --git a/AppPackage/Sources/AppFeature/Models/Support/EhSetting.swift b/AppPackage/Sources/AppFeature/Models/Support/EhSetting.swift deleted file mode 100644 index 15d7ded24..000000000 --- a/AppPackage/Sources/AppFeature/Models/Support/EhSetting.swift +++ /dev/null @@ -1,351 +0,0 @@ -import Resources - -// MARK: EhSetting -struct EhSetting: Equatable { - // swiftlint:disable line_length - static let empty: Self = .init(ehProfiles: [.empty], isCapableOfCreatingNewProfile: true, capableLoadThroughHathSetting: .anyClient, capableImageResolution: .auto, capableSearchResultCount: .fifty, capableThumbnailConfigRowCount: .forty, capableThumbnailConfigSizes: [], loadThroughHathSetting: .anyClient, browsingCountry: .autoDetect, literalBrowsingCountry: "", imageResolution: .auto, imageSizeWidth: 0, imageSizeHeight: 0, galleryName: .default, archiverBehavior: .autoSelectOriginalAutoStart, displayMode: .compact, showSearchRangeIndicator: true, enableGalleryThumbnailSelector: false, disabledCategories: Array(repeating: false, count: 10), favoriteCategories: Array(repeating: "", count: 10), favoritesSortOrder: .favoritedTime, ratingsColor: "", tagFilteringThreshold: 0, tagWatchingThreshold: 0, showFilteredRemovalCount: true, excludedLanguages: Array(repeating: false, count: 50), excludedUploaders: "", searchResultCount: .fifty, thumbnailLoadTiming: .onPageLoad, thumbnailConfigSize: .normal, thumbnailConfigRows: .ten, coverScaleFactor: 0, viewportVirtualWidth: 0, commentsSortOrder: .recent, commentVotesShowTiming: .always, tagsSortOrder: .alphabetical, galleryPageNumbering: .none) - // swiftlint:enable line_length - - static let categoryNames = Category.allFiltersCases.map(\.rawValue).map { value in - value.lowercased().replacingOccurrences(of: " ", with: "") - } - static let languageValues = [ - 1024, 2048, 1, 1025, 2049, 10, 1034, 2058, - 20, 1044, 2068, 30, 1054, 2078, 40, 1064, 2088, - 50, 1074, 2098, 60, 1084, 2108, 70, 1094, 2118, - 80, 1104, 2128, 90, 1114, 2138, 100, 1124, 2148, - 110, 1134, 2158, 120, 1144, 2168, 130, 1154, 2178, - 254, 1278, 2302, 255, 1279, 2303 - ] - - let ehProfiles: [EhProfile] - var ehpandaProfile: EhProfile? { - ehProfiles.filter({ EhSetting.verifyEhPandaProfileName(with: $0.name) }).first - } - static func verifyEhPandaProfileName(with name: String?) -> Bool { - ["EhPanda", "EhPanda (Default)"].contains(name ?? "") - } - - let isCapableOfCreatingNewProfile: Bool - let capableLoadThroughHathSetting: LoadThroughHathSetting - let capableImageResolution: ImageResolution - let capableSearchResultCount: SearchResultCount - let capableThumbnailConfigRowCount: ThumbnailRowCount - let capableThumbnailConfigSizes: [ThumbnailSize] - - var capableLoadThroughHathSettings: [LoadThroughHathSetting] { - LoadThroughHathSetting.allCases.filter { setting in - setting <= capableLoadThroughHathSetting - } - } - var capableImageResolutions: [ImageResolution] { - ImageResolution.allCases.filter { resolution in - resolution <= capableImageResolution - } - } - var capableSearchResultCounts: [SearchResultCount] { - SearchResultCount.allCases.filter { count in - count <= capableSearchResultCount - } - } - var capableThumbnailConfigRowCounts: [ThumbnailRowCount] { - ThumbnailRowCount.allCases.filter { row in - row <= capableThumbnailConfigRowCount - } - } - var localizedLiteralBrowsingCountry: String? { - BrowsingCountry.allCases.first(where: { $0.englishName == literalBrowsingCountry })?.name - } - - var loadThroughHathSetting: LoadThroughHathSetting - var browsingCountry: BrowsingCountry - let literalBrowsingCountry: String - var imageResolution: ImageResolution - var imageSizeWidth: Float - var imageSizeHeight: Float - var galleryName: GalleryName - var archiverBehavior: ArchiverBehavior - var displayMode: DisplayMode - var showSearchRangeIndicator: Bool - var enableGalleryThumbnailSelector: Bool - var disabledCategories: [Bool] - var favoriteCategories: [String] - var favoritesSortOrder: FavoritesSortOrder - var ratingsColor: String - var tagFilteringThreshold: Float - var tagWatchingThreshold: Float - var showFilteredRemovalCount: Bool - var excludedLanguages: [Bool] - var excludedUploaders: String - var searchResultCount: SearchResultCount - var thumbnailLoadTiming: ThumbnailLoadTiming - var thumbnailConfigSize: ThumbnailSize - var thumbnailConfigRows: ThumbnailRowCount - var coverScaleFactor: Float - var viewportVirtualWidth: Float - var commentsSortOrder: CommentsSortOrder - var commentVotesShowTiming: CommentVotesShowTiming - var tagsSortOrder: TagsSortOrder - var galleryPageNumbering: GalleryPageNumbering - var useOriginalImages: Bool? - var useMultiplePageViewer: Bool? - var multiplePageViewerStyle: MultiplePageViewerStyle? - var multiplePageViewerShowThumbnailPane: Bool? -} - -// MARK: EhProfile -struct EhProfile: Comparable, Identifiable, Hashable { - static let empty: Self = .init( - value: 0, name: "", isSelected: true - ) - static func < (lhs: EhProfile, rhs: EhProfile) -> Bool { - lhs.value < rhs.value - } - var id: Int { value } - - let value: Int - let name: String - let isSelected: Bool - var isDefault: Bool { - value == 1 - } -} -enum EhProfileAction: String { - case create - case delete - case rename - case `default` -} - -// MARK: LoadThroughHathSetting -extension EhSetting { - enum LoadThroughHathSetting: Int, CaseIterable, Identifiable, Comparable { - case anyClient - case defaultPortOnly - case modernNo - case legacyNo - } -} -extension EhSetting.LoadThroughHathSetting { - var id: Int { rawValue } - static func < ( - lhs: EhSetting.LoadThroughHathSetting, - rhs: EhSetting.LoadThroughHathSetting - ) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .anyClient: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.anyClient - case .defaultPortOnly: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.defaultPortOnly - case .modernNo: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.modernNo - case .legacyNo: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.legacyNo - } - } - var description: String { - switch self { - case .anyClient: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.anyClient - case .defaultPortOnly: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.defaultPortOnly - case .modernNo: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.modernNo - case .legacyNo: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.legacyNo - } - } -} - -// MARK: ImageResolution -extension EhSetting { - enum ImageResolution: Int, CaseIterable, Identifiable, Comparable, Codable { - case auto - case x780 - /// Deprecated - case x980 - case x1280 - case x1600 - case x2400 - } -} -extension EhSetting.ImageResolution { - var id: Int { rawValue } - static func < (lhs: Self, rhs: Self) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .auto: - return L10n.Localizable.Enum.EhSetting.ImageResolution.Value.auto - case .x780: - return "780x" - case .x980: - return "980x" - case .x1280: - return "1280x" - case .x1600: - return "1600x" - case .x2400: - return "2400x" - } - } -} - -// MARK: GalleryName -extension EhSetting { - enum GalleryName: Int, CaseIterable, Identifiable { - case `default` - case japanese - } -} -extension EhSetting.GalleryName { - var id: Int { rawValue } - - var value: String { - switch self { - case .default: - return L10n.Localizable.Enum.EhSetting.GalleryName.Value.default - case .japanese: - return L10n.Localizable.Enum.EhSetting.GalleryName.Value.japanese - } - } -} - -// MARK: ArchiverBehavior -extension EhSetting { - enum ArchiverBehavior: Int, CaseIterable, Identifiable { - case manualSelectManualStart - case manualSelectAutoStart - case autoSelectOriginalManualStart - case autoSelectOriginalAutoStart - case autoSelectResampleManualStart - case autoSelectResampleAutoStart - } -} -extension EhSetting.ArchiverBehavior { - var id: Int { rawValue } - - var value: String { - switch self { - case .manualSelectManualStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.manualSelectManualStart - case .manualSelectAutoStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.manualSelectAutoStart - case .autoSelectOriginalManualStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectOriginalManualStart - case .autoSelectOriginalAutoStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectOriginalAutoStart - case .autoSelectResampleManualStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectResampleManualStart - case .autoSelectResampleAutoStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectResampleAutoStart - } - } -} - -// MARK: DisplayMode -extension EhSetting { - enum DisplayMode: Int, CaseIterable, Identifiable { - case compact - case thumbnail - case extended - case minimal - case minimalPlus - } -} -extension EhSetting.DisplayMode { - var id: Int { rawValue } - - var value: String { - switch self { - case .compact: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.compact - case .thumbnail: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.thumbnail - case .extended: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.extended - case .minimal: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.minimal - case .minimalPlus: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.minimalPlus - } - } -} - -// MARK: FavoritesSortOrder -extension EhSetting { - enum FavoritesSortOrder: Int, CaseIterable, Identifiable { - case lastUpdateTime - case favoritedTime - } -} -extension EhSetting.FavoritesSortOrder { - var id: Int { rawValue } - - var value: String { - switch self { - case .lastUpdateTime: - return L10n.Localizable.Enum.EhSetting.FavoritesSortOrder.Value.lastUpdateTime - case .favoritedTime: - return L10n.Localizable.Enum.EhSetting.FavoritesSortOrder.Value.favoritedTime - } - } -} - -// MARK: ExcludedLanguagesCategory -extension EhSetting { - enum ExcludedLanguagesCategory: Int, Identifiable, CaseIterable { - case original - case translated - case rewrite - } -} -extension EhSetting.ExcludedLanguagesCategory { - var id: Int { rawValue } - - var value: String { - switch self { - case .original: - return L10n.Localizable.Enum.EhSetting.ExcludedLanguagesCategory.Value.original - case .translated: - return L10n.Localizable.Enum.EhSetting.ExcludedLanguagesCategory.Value.translated - case .rewrite: - return L10n.Localizable.Enum.EhSetting.ExcludedLanguagesCategory.Value.rewrite - } - } -} - -// MARK: SearchResultCount -extension EhSetting { - enum SearchResultCount: Int, CaseIterable, Identifiable, Comparable { - case twentyFive - case fifty - case oneHundred - case twoHundred - } -} -extension EhSetting.SearchResultCount { - var id: Int { rawValue } - static func < (lhs: Self, rhs: Self) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .twentyFive: - return "25" - case .fifty: - return "50" - case .oneHundred: - return "100" - case .twoHundred: - return "200" - } - } -} diff --git a/AppPackage/Sources/AppFeature/Models/Tags/EhTagTranslationDatabaseModel.swift b/AppPackage/Sources/AppFeature/Models/Tags/EhTagTranslationDatabaseModel.swift deleted file mode 100644 index 533f22a60..000000000 --- a/AppPackage/Sources/AppFeature/Models/Tags/EhTagTranslationDatabaseModel.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -struct EhTagTranslationDatabaseResponse: Codable { - struct Item: Codable { - let name: String - var intro: String? - var links: String? - } - - struct Model: Codable { - let namespace: String - let data: [String: Item] - - var tagTranslations: [TagTranslation] { - guard let namespace = TagNamespace(rawValue: namespace) else { return .init() } - return data.map { - .init( - namespace: namespace, key: $0, value: $1.name, - description: $1.intro, linksString: $1.links - ) - } - } - } - - let data: [Model] - - var tagTranslations: [String: TagTranslation] { - .init(uniqueKeysWithValues: data.flatMap(\.tagTranslations).map({ - ($0.namespace.rawValue + $0.key, $0) - })) - } -} diff --git a/AppPackage/Sources/AppFeature/Models/Tags/TagDetail.swift b/AppPackage/Sources/AppFeature/Models/Tags/TagDetail.swift deleted file mode 100644 index c707923a5..000000000 --- a/AppPackage/Sources/AppFeature/Models/Tags/TagDetail.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -struct TagDetail: Equatable { - let title: String - let description: String - let imageURLs: [URL] - let links: [URL] -} diff --git a/AppPackage/Sources/AppFeature/Network/DFExtensions.swift b/AppPackage/Sources/AppFeature/Network/DFExtensions.swift index 7768aa5f2..71970c43e 100644 --- a/AppPackage/Sources/AppFeature/Network/DFExtensions.swift +++ b/AppPackage/Sources/AppFeature/Network/DFExtensions.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import DeprecatedAPI // MARK: Global diff --git a/AppPackage/Sources/AppFeature/Network/DFRequest.swift b/AppPackage/Sources/AppFeature/Network/DFRequest.swift index 4978dfd32..58e06506f 100644 --- a/AppPackage/Sources/AppFeature/Network/DFRequest.swift +++ b/AppPackage/Sources/AppFeature/Network/DFRequest.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels struct DFRequest { var request: URLRequest diff --git a/AppPackage/Sources/AppFeature/Network/DFStreamHandler.swift b/AppPackage/Sources/AppFeature/Network/DFStreamHandler.swift index d3597eb2f..6dce06fb2 100644 --- a/AppPackage/Sources/AppFeature/Network/DFStreamHandler.swift +++ b/AppPackage/Sources/AppFeature/Network/DFStreamHandler.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels class DFStreamEventHandler: NSObject { private var request: DFRequest diff --git a/AppPackage/Sources/AppFeature/Network/DFURLProtocol.swift b/AppPackage/Sources/AppFeature/Network/DFURLProtocol.swift index d431fd7da..b349f60e4 100644 --- a/AppPackage/Sources/AppFeature/Network/DFURLProtocol.swift +++ b/AppPackage/Sources/AppFeature/Network/DFURLProtocol.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels class DFURLProtocol: URLProtocol { private var dfRequest: DFRequest? diff --git a/AppPackage/Sources/AppFeature/Network/Request+Account.swift b/AppPackage/Sources/AppFeature/Network/Request+Account.swift index 7fc96c0e5..187769bdc 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Account.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Account.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Combine import Foundation diff --git a/AppPackage/Sources/AppFeature/Network/Request+Detail.swift b/AppPackage/Sources/AppFeature/Network/Request+Detail.swift index 720c85340..837414396 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Detail.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Detail.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Combine import Foundation diff --git a/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift b/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift index 404a5ba06..2a6ddfae5 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Combine import Foundation diff --git a/AppPackage/Sources/AppFeature/Network/Request+Image.swift b/AppPackage/Sources/AppFeature/Network/Request+Image.swift index fa06e7b30..15107cf98 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Image.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Image.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Combine import Foundation diff --git a/AppPackage/Sources/AppFeature/Network/Request.swift b/AppPackage/Sources/AppFeature/Network/Request.swift index 36af5106f..fb9b69f9f 100644 --- a/AppPackage/Sources/AppFeature/Network/Request.swift +++ b/AppPackage/Sources/AppFeature/Network/Request.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Combine import Foundation import ComposableArchitecture @@ -268,7 +269,7 @@ struct TagTranslatorRequest: Request { } var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: language.checkUpdateURL) + URLSession.shared.dataTaskPublisher(for: URLUtil.githubAPI(repoName: language.repoName)) .genericRetry().tryMap { data, _ -> Date in guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any], let postedDateString = dict["published_at"] as? String, @@ -280,7 +281,9 @@ struct TagTranslatorRequest: Request { return postedDate } .flatMap { date in - URLSession.shared.dataTaskPublisher(for: language.downloadURL) + URLSession.shared.dataTaskPublisher( + for: URLUtil.githubDownload(repoName: language.repoName, fileName: language.remoteFilename) + ) .tryMap { data, _ in let response = try JSONDecoder().decode( EhTagTranslationDatabaseResponse.self, from: data diff --git a/AppPackage/Sources/AppFeature/Tools/CategoryColor.swift b/AppPackage/Sources/AppFeature/Tools/CategoryColor.swift new file mode 100644 index 000000000..55111d8af --- /dev/null +++ b/AppPackage/Sources/AppFeature/Tools/CategoryColor.swift @@ -0,0 +1,17 @@ +import SwiftUI +import AppModels + +// Binds the pure, host-parameterized color on the model types to the host the user is +// currently browsing. The runtime lookup (UserDefaults via AppUtil) lives here in the app +// layer so the model types stay free of that dependency. +extension AppModels.Category { + var color: Color { + color(host: AppUtil.galleryHost) + } +} + +extension Gallery { + var color: Color { + category.color + } +} diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift index 0ae72511e..774aed042 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift @@ -1,4 +1,5 @@ import BackgroundTasks +import AppModels import ComposableArchitecture enum BackgroundProcessing { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift index 001e1cc19..874708d44 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Resources import ComposableArchitecture #if DEBUG diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift index 11678c1b2..e74ea52a2 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import CoreData // MARK: UpdateGalleryState diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift index 8a2143c88..ae450f205 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Combine import CoreData import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundDownloads.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundDownloads.swift index 9ff520cf7..75e6f7581 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundDownloads.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundDownloads.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels actor BackgroundPageCompletionReceiver { private enum PendingEvent { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift index f25bacd47..2aedd9c62 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Cache Operations extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Execution.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Execution.swift index 4806b99f9..5486657b6 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Execution.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Execution.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Process Download extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift index 646a86cf5..13cf8216e 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Fetch & Normalize Payload extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionPerform.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionPerform.swift index fa1dbff8d..da80c201b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Perform Download extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift index d37487b28..b6ea0fd65 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Execution Support extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift index 0e858c05c..007ef5ef6 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Resources // MARK: - User Folder Operations diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift index 7761dc7a6..56730aeeb 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels typealias ScheduledDownloadOperation = @Sendable () async -> Void diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift index 2c2131083..e16f27b2b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import UniformTypeIdentifiers // MARK: - Network diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownload.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownload.swift index 86efa0e0e..dbdd423fa 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownload.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownload.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Download Pages extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownloadHelpers.swift index afa674405..96528ee75 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownloadHelpers.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Download Single Page extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Persistence.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Persistence.swift index 4f7e9d50f..2e1d535c6 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Persistence.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Persistence.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Disk Index extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceHelpers.swift index a95e05051..3d2e54420 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceHelpers.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Sanitization extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceNormalize.swift index cfea30674..a9837498d 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Manifest, Folder & Normalize extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift index f1251c8a7..31b42fff6 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Resources // MARK: - Public API diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPIHelpers.swift index 82b9bf36a..14af59db4 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPIHelpers.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Private helpers for public API extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift index 7623a2a8a..18a59ab26 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Foundation import ImageIO diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index 93aa20110..fbd755f2d 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Foundation import ImageIO diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+RetryHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+RetryHelpers.swift index 66ee01c26..9e3fde3b9 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+RetryHelpers.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Retry & RetryPages extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Scheduling.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Scheduling.swift index e72c01bce..fb054772e 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Scheduling.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Scheduling.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Observer Management & Scheduling extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+SchedulingHelpers.swift index a663f8439..eea0fb3d4 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+SchedulingHelpers.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: - Mode Resolution extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Testing.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Testing.swift index 9e2bc077e..3ceb7c37b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Testing.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Testing.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels #if DEBUG extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift index 653280112..37e12e6bc 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture @DependencyClient diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadPageDownloader.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadPageDownloader.swift index 43f181fd9..e8f08bc34 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadPageDownloader.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadPageDownloader.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels struct DownloadPageTaskContext: Equatable, Sendable { let gid: String diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift index 7891201a7..b831b7407 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift @@ -1,4 +1,5 @@ import Combine +import AppModels import Foundation import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift index 7719b480d..bac4b1cb6 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift @@ -1,4 +1,5 @@ import Photos +import AppModels import SwiftUI import Combine import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift index fd8b0d30c..87b12480c 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Combine import Foundation import Kingfisher diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/LoggerClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/LoggerClient.swift index bb6cc0b8b..9359c9792 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/LoggerClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/LoggerClient.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels struct LoggerClient: Sendable { let info: @Sendable (Any, Any?) -> Void diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/URLClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/URLClient.swift index e4657e46e..7da87147c 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/URLClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/URLClient.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Dependencies struct URLAnalysisResult { diff --git a/AppPackage/Sources/AppFeature/Tools/Defaults+Runtime.swift b/AppPackage/Sources/AppFeature/Tools/Defaults+Runtime.swift new file mode 100644 index 000000000..2046b2bb2 --- /dev/null +++ b/AppPackage/Sources/AppFeature/Tools/Defaults+Runtime.swift @@ -0,0 +1,43 @@ +import AppModels +import CoreGraphics +import Foundation + +// Runtime-derived defaults that depend on app utilities (device metrics, the active gallery host). +// They live in the app layer so AppModels.Defaults stays free of those dependencies. +extension Defaults { + @MainActor + struct FrameSize { + static var archiveGridWidth: CGFloat { + DeviceUtil.isPadWidth ? 175 : DeviceUtil.isSEWidth ? 125 : 150 + } + static var cardCellWidth: CGFloat { DeviceUtil.windowW * 0.8 } + static let cardCellHeight: CGFloat = Defaults.ImageSize.headerH + 20 * 2 + static var cardCellSize: CGSize { + .init(width: cardCellWidth, height: cardCellHeight) + } + static var rankingCellWidth: CGFloat { + (DeviceUtil.isPadWidth ? 0.4 : 0.7) * DeviceUtil.windowW + } + static var alertWidthFactor: Double { + DeviceUtil.isPadWidth ? 0.5 : 1.0 + } + } +} + +extension Defaults.ImageSize { + @MainActor static var previewMinW: CGFloat { DeviceUtil.isPadWidth ? 180 : 100 } + @MainActor static var previewMaxW: CGFloat { DeviceUtil.isPadWidth ? 220 : 120 } + @MainActor static var previewAvgW: CGFloat { (previewMinW + previewMaxW) / 2 } +} + +extension Defaults.URL { + static var host: Foundation.URL { AppUtil.galleryHost == .exhentai ? exhentai : ehentai } + static var api: Foundation.URL { host.appendingPathComponent("api.php") } + static var myTags: Foundation.URL { host.appendingPathComponent("mytags") } + static var uConfig: Foundation.URL { host.appendingPathComponent("uconfig.php") } + static var galleryPopups: Foundation.URL { host.appendingPathComponent("gallerypopups.php") } + static var galleryTorrents: Foundation.URL { host.appendingPathComponent("gallerytorrents.php") } + static var popular: Foundation.URL { host.appendingPathComponent("popular") } + static var watched: Foundation.URL { host.appendingPathComponent("watched") } + static var favorites: Foundation.URL { host.appendingPathComponent("favorites.php") } +} diff --git a/AppPackage/Sources/AppFeature/Tools/Defaults.swift b/AppPackage/Sources/AppFeature/Tools/Defaults.swift deleted file mode 100644 index 2a4d08fbd..000000000 --- a/AppPackage/Sources/AppFeature/Tools/Defaults.swift +++ /dev/null @@ -1,164 +0,0 @@ -import UIKit -import Foundation - -struct Defaults { - @MainActor - struct FrameSize { - static var archiveGridWidth: CGFloat { - DeviceUtil.isPadWidth ? 175 : DeviceUtil.isSEWidth ? 125 : 150 - } - static var cardCellWidth: CGFloat { DeviceUtil.windowW * 0.8 } - static let cardCellHeight: CGFloat = Defaults.ImageSize.headerH + 20 * 2 - static var cardCellSize: CGSize { - .init(width: cardCellWidth, height: cardCellHeight) - } - static var rankingCellWidth: CGFloat { - (DeviceUtil.isPadWidth ? 0.4 : 0.7) * DeviceUtil.windowW - } - static var alertWidthFactor: Double { - DeviceUtil.isPadWidth ? 0.5 : 1.0 - } - } - @MainActor - struct ImageSize { - static let rowAspect: CGFloat = 8/11 - static let headerAspect: CGFloat = 8/11 - static let previewAspect: CGFloat = 8/11 - static let contentAspect: CGFloat = 7/10 - static let webtoonMinAspect: CGFloat = 1/4 - static let webtoonIdealAspect: CGFloat = 2/3 - - static let rowW: CGFloat = rowH * rowAspect - static let rowH: CGFloat = 120 - static let headerW: CGFloat = headerH * headerAspect - static let headerH: CGFloat = 150 - static var previewMinW: CGFloat { DeviceUtil.isPadWidth ? 180 : 100 } - static var previewMaxW: CGFloat { DeviceUtil.isPadWidth ? 220 : 120 } - static var previewAvgW: CGFloat { (previewMinW + previewMaxW) / 2 } - } - struct Cookie { - static let yay = "yay" - static let null = "null" - static let expired = "expired" - static let mystery = "mystery" - static let ignoreOffensive = "nw" - static let selectedProfile = "sp" - static let skipServer = "skipserver" - - static let igneous = "igneous" - static let ipbMemberId = "ipb_member_id" - static let ipbPassHash = "ipb_pass_hash" - } - struct DateFormat { - static let greeting = "dd MMMM yyyy" - static let publish = "yyyy-MM-dd HH:mm" - static let torrent = "yyyy-MM-dd HH:mm" - static let comment = "dd MMMM yyyy, HH:mm" - static let github = "yyyy-MM-dd'T'HH:mm:ss'Z'" - } - struct FilePath { - static let logs = "logs" - static let ehpandaLog = "EhPanda.log" - static let downloads = "Downloads" - static let downloadPages = "pages" - static let downloadManifest = "manifest.json" - static let automationDownloadFolder = "Automation" - static let defaultDownloadFolder = "Default" - } - struct Regex { - static let tagSuggestion: NSRegularExpression? = try? .init(pattern: "(\\S+:\".+?\"|\".+?\"|\\S+:\\S+|\\S+)") - } - struct URL { - static var host: Foundation.URL { AppUtil.galleryHost == .exhentai ? exhentai : ehentai } - static let ehentai: Foundation.URL = .init(string: "https://e-hentai.org/").forceUnwrapped - static let exhentai: Foundation.URL = .init(string: "https://exhentai.org/").forceUnwrapped - static let sexhentai: Foundation.URL = .init(string: "https://s.exhentai.org/").forceUnwrapped - - static let torrentDownload: Foundation.URL = .init(string: "https://ehgt.org/g/t.png").forceUnwrapped - static let torrentDownloadInvalid: Foundation.URL = .init(string: "https://ehgt.org/g/td.png").forceUnwrapped - - static let forum: Foundation.URL = .init(string: "https://forums.e-hentai.org/index.php").forceUnwrapped - static let login = forum.appending(queryItems: [.act: .loginAct, .code: .zeroOne]) - static let webLogin = forum.appending(queryItems: [.act: .loginAct]) - - static var api: Foundation.URL { host.appendingPathComponent("api.php") } - static var myTags: Foundation.URL { host.appendingPathComponent("mytags") } - static let news = ehentai.appendingPathComponent("news.php") - static var uConfig: Foundation.URL { host.appendingPathComponent("uconfig.php") } - static var galleryPopups: Foundation.URL { host.appendingPathComponent("gallerypopups.php") } - static var galleryTorrents: Foundation.URL { host.appendingPathComponent("gallerytorrents.php") } - - static var popular: Foundation.URL { host.appendingPathComponent("popular") } - static var watched: Foundation.URL { host.appendingPathComponent("watched") } - static let toplist = ehentai.appendingPathComponent("toplist.php") - static var favorites: Foundation.URL { host.appendingPathComponent("favorites.php") } - - // GitHub - static let github: Foundation.URL = .init(string: "https://github.com/").forceUnwrapped - static let githubAPI: Foundation.URL = .init(string: "https://api.github.com/repos/").forceUnwrapped - - // swiftlint:disable nesting identifier_name - enum Component { - enum Key: String { - // Functional Pages - case token = "t" - case gid = "gid" - case letterP = "p" - case page = "page" - case from = "from" - case next = "next" - case favcat = "favcat" - case topcat = "tl" - case showUser = "showuser" - case fSearch = "f_search" - - case code = "CODE" - case act = "act" - case showComments = "hc" - case inlineSet = "inline_set" - case skipServerIdentifier = "nl" - - // Search favorites - case sn = "sn" - case st = "st" - case sf = "sf" - - // Filter - case fCats = "f_cats" - case advSearch = "advsearch" - case fSname = "f_sname" - case fStags = "f_stags" - case fSdesc = "f_sdesc" - case fStorr = "f_storr" - case fSto = "f_sto" - case fSdt1 = "f_sdt1" - case fSdt2 = "f_sdt2" - case fSh = "f_sh" - case fSr = "f_sr" - case fSrdd = "f_srdd" - case fSp = "f_sp" - case fSpf = "f_spf" - case fSpt = "f_spt" - case fSfl = "f_sfl" - case fSfu = "f_sfu" - case fSft = "f_sft" - - // Custom - case ehpandaWidth = "ehpandaWidth" - case ehpandaHeight = "ehpandaHeight" - case ehpandaOffset = "ehpandaOffset" - } - enum Value: String { - case one = "1" - case all = "all" - case zeroOne = "01" - case filterOn = "on" - case loginAct = "Login" - case addFavAct = "addfav" - case sortOrderByUpdateTime = "fs_p" - case sortOrderByFavoritedTime = "fs_f" - } - } - // swiftlint:enable nesting identifier_name - } -} diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift index 1cba9cfbd..1a47e4ac1 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import AlertKit diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/Extensions.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/Extensions.swift index 135ded190..4a40a5999 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/Extensions.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/Extensions.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Foundation // MARK: Encodable @@ -66,46 +67,10 @@ extension URL { return [self, info.plainURL] } - func appending(queryItems: [URLQueryItem]) -> URL { - guard !queryItems.isEmpty else { return self } - var components: URLComponents = .init( - url: self, resolvingAgainstBaseURL: false - ) - .forceUnwrapped - if components.queryItems == nil { - components.queryItems = [] - } - components.queryItems?.append(contentsOf: queryItems) - return components.url.forceUnwrapped - } - func appending(queryItems: [String: String]) -> URL { - appending(queryItems: queryItems.map(URLQueryItem.init)) - } - func appending(queryItems: [Defaults.URL.Component.Key: Defaults.URL.Component.Value]) -> URL { - appending(queryItems: queryItems.map({ URLQueryItem(name: $0.rawValue, value: $1.rawValue) })) - } - func appending(queryItems: [Defaults.URL.Component.Key: String]) -> URL { - appending(queryItems: queryItems.map({ URLQueryItem(name: $0.rawValue, value: $1) })) - } - mutating func append(queryItems: [URLQueryItem]) { - self = appending(queryItems: queryItems) - } - mutating func append(queryItems: [String: String]) { - self = appending(queryItems: queryItems) - } - mutating func append(queryItems: [Defaults.URL.Component.Key: Defaults.URL.Component.Value]) { - self = appending(queryItems: queryItems) - } - mutating func append(queryItems: [Defaults.URL.Component.Key: String]) { - self = appending(queryItems: queryItems) - } } // MARK: String extension String { - var nonEmpty: String? { - isEmpty ? nil : self - } var isInteger: Bool { Int(self) != nil } @@ -115,16 +80,6 @@ extension String { var localizedKey: LocalizedStringKey { .init(self) } - var linkStyled: String { - "[\(self)](\(Defaults.URL.ehentai.absoluteString))" - } - var stringsBesideColon: (String?, String) { - let strings = split(separator: ":").map(String.init) - if strings.count == 2, !strings[0].isEmpty { - return (strings[0], strings[1]) - } - return (nil, self) - } var emojisRipped: String { unicodeScalars .filter { !$0.properties.isEmojiPresentation } @@ -137,10 +92,6 @@ extension String { ) ?? "" } - var firstLetterCapitalized: String { - prefix(1).capitalized + dropFirst() - } - var isValidURL: Bool { if let detector = try? NSDataDetector( types: NSTextCheckingResult.CheckingType.link.rawValue @@ -153,30 +104,6 @@ extension String { } else { return false } } - var barcesAndSpacesRemoved: String { - replacingOccurrences(from: "(", to: ")", with: "") - .replacingOccurrences(from: "[", to: "]", with: "") - .replacingOccurrences(from: "{", to: "}", with: "") - .replacingOccurrences(from: "【", to: "】", with: "") - .replacingOccurrences(from: "「", to: "」", with: "") - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - func replacingOccurrences( - from subString1: String, to subString2: String, with replacement: String - ) -> String { - var result = self - - while let rangeA = result.range(of: subString1), - let rangeB = result.range(of: subString2), - rangeA.lowerBound < rangeB.upperBound { - let unwanted = result[rangeA.lowerBound.. Bool { range(of: other, options: .caseInsensitive) != nil } @@ -227,20 +154,6 @@ extension UIImage { } } -// MARK: Optional -extension Optional { - var forceUnwrapped: Wrapped! { - if let value = self { - return value - } - Logger.error( - "Failed in force unwrapping...", - context: ["type": Wrapped.self] - ) - return nil - } -} - // MARK: Color extension Color { init(hex: String) { diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift index 5ecd9e1cf..322daf9d0 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import ComposableArchitecture extension Reducer { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Archive.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Archive.swift index 7c34ecd6e..e87ae59c4 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Archive.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Archive.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Foundation extension Parser { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Comment.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Comment.swift index 17947906a..1ec937d31 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Comment.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Comment.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Foundation extension Parser { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift index 8ca94f86d..50c6a9303 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Foundation extension Parser { @@ -34,7 +35,7 @@ extension Parser { let uploader = try? parseUploader(node: gd3Node), let ratingResult = try? parseRating(node: gdrNode), let ratingCount = Int(gdrNode.at_xpath("//span [@id='rating_count']")?.text ?? ""), - let category = Category(rawValue: gd3Node.at_xpath("//div [@id='gdc']")?.text ?? ""), + let category = AppModels.Category(rawValue: gd3Node.at_xpath("//div [@id='gdc']")?.text ?? ""), let postedDate = try? parseDate(time: infoPanel[0], format: Defaults.DateFormat.publish) else { continue } diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Favorite.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Favorite.swift index 516107c00..f2b1eafd3 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Favorite.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Favorite.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels extension Parser { static func parseFavoritesSortOrder(doc: HTMLDocument) -> FavoritesSortOrder? { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Greeting.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Greeting.swift index e810f42c4..61c67dec5 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Greeting.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Greeting.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Foundation extension Parser { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Image.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Image.swift index 1944d15ed..1f92ccb59 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Image.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Image.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Foundation extension Parser { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+List.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+List.swift index 83728aa37..b529d5933 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+List.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+List.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import SwiftUI extension Parser { @@ -161,7 +162,7 @@ private extension Parser { private extension Parser { static func parseThumbnailPanel(node: XMLElement) throws -> ThumbnailPanelInfo { var tmpCoverURL: URL? - var tmpCategory: Category? + var tmpCategory: AppModels.Category? var tmpPublishedDate: Date? var tmpPageCount: Int? var uploader: String? @@ -173,7 +174,7 @@ private extension Parser { .contains(where: { $0 == urlString }) == false, imgNode["alt"] != "T" { tmpCoverURL = url } - if let rawValue = div.text, let category = Category(rawValue: rawValue) { + if let rawValue = div.text, let category = AppModels.Category(rawValue: rawValue) { tmpCategory = category } if let onClick = div["onclick"], !onClick.isEmpty, let dateString = div.text, diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Misc.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Misc.swift index 9fabfe6fc..30686cf3c 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Misc.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Misc.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Foundation extension Parser { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift index 8691a2b66..fe279dcd6 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Foundation extension Parser { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Profile.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Profile.swift index 6ecbd4212..baf15d10e 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Profile.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Profile.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels extension Parser { static func parseProfileIndex(doc: HTMLDocument) throws -> VerifyEhProfileResponse { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift index 9a9bfd539..2bf81ff85 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Resources extension Parser { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Shared.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Shared.swift index f6ccc545f..53cbfa642 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Shared.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Shared.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Foundation extension Parser { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Torrent.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Torrent.swift index ea6fd4b3d..46998a83f 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Torrent.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Torrent.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Foundation extension Parser { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Types.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Types.swift index 14f458dfe..df2728590 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Types.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Types.swift @@ -1,9 +1,10 @@ import Foundation +import AppModels extension Parser { struct ThumbnailPanelInfo { let coverURL: URL - let category: Category + let category: AppModels.Category let rating: Float let publishedDate: Date let pageCount: Int diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+User.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+User.swift index bb759f2b3..69d323009 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+User.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+User.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Foundation extension Parser { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/AppLaunchAutomation.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/AppLaunchAutomation.swift index 6f41945f4..b01c7d945 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/AppLaunchAutomation.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/AppLaunchAutomation.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels struct AppLaunchAutomation: Sendable { struct LoginCookies: Sendable { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/AppUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/AppUtil.swift index 91cded213..a5b761ef0 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/AppUtil.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/AppUtil.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels struct AppUtil { static var version: String { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/CookieUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/CookieUtil.swift index 5b0b32702..43ce99973 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/CookieUtil.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/CookieUtil.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels // MARK: Cookie struct CookieUtil { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadBackgroundTaskStore.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadBackgroundTaskStore.swift index 143187daf..55287f2d3 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadBackgroundTaskStore.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadBackgroundTaskStore.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels actor DownloadBackgroundTaskStore { struct Record: Codable, Equatable, Sendable { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadQueueStore.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadQueueStore.swift index 3d04e6e8e..ebff86d4f 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadQueueStore.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadQueueStore.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels import Foundation struct DownloadQueueStore: Sendable { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift index 021168f54..6338332e7 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Resources extension DownloadStore { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift index ef8aabd81..c530a79b9 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Resources import CryptoKit diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/FileUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/FileUtil.swift index 70a0e0800..245dd210d 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/FileUtil.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/FileUtil.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels struct FileUtil { static var logsDirectoryURL: URL { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/ImagePlaceholderFingerprint.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/ImagePlaceholderFingerprint.swift index f7313d488..2ec4e7531 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/ImagePlaceholderFingerprint.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/ImagePlaceholderFingerprint.swift @@ -1,4 +1,5 @@ import CryptoKit +import AppModels import Foundation /// A known E-H asset placeholder that decodes as a valid image but is *not* page diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/URLUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/URLUtil.swift index fec216814..949156e03 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/URLUtil.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/URLUtil.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels struct URLUtil { // Fetch @@ -158,16 +159,16 @@ private extension URL { queryItems1: inout [Defaults.URL.Component.Key: String] ) { var categoryValue = 0 - categoryValue += filter.doujinshi ? Category.doujinshi.filterValue : 0 - categoryValue += filter.manga ? Category.manga.filterValue : 0 - categoryValue += filter.artistCG ? Category.artistCG.filterValue : 0 - categoryValue += filter.gameCG ? Category.gameCG.filterValue : 0 - categoryValue += filter.western ? Category.western.filterValue : 0 - categoryValue += filter.nonH ? Category.nonH.filterValue : 0 - categoryValue += filter.imageSet ? Category.imageSet.filterValue : 0 - categoryValue += filter.cosplay ? Category.cosplay.filterValue : 0 - categoryValue += filter.asianPorn ? Category.asianPorn.filterValue : 0 - categoryValue += filter.misc ? Category.misc.filterValue : 0 + categoryValue += filter.doujinshi ? AppModels.Category.doujinshi.filterValue : 0 + categoryValue += filter.manga ? AppModels.Category.manga.filterValue : 0 + categoryValue += filter.artistCG ? AppModels.Category.artistCG.filterValue : 0 + categoryValue += filter.gameCG ? AppModels.Category.gameCG.filterValue : 0 + categoryValue += filter.western ? AppModels.Category.western.filterValue : 0 + categoryValue += filter.nonH ? AppModels.Category.nonH.filterValue : 0 + categoryValue += filter.imageSet ? AppModels.Category.imageSet.filterValue : 0 + categoryValue += filter.cosplay ? AppModels.Category.cosplay.filterValue : 0 + categoryValue += filter.asianPorn ? AppModels.Category.asianPorn.filterValue : 0 + categoryValue += filter.misc ? AppModels.Category.misc.filterValue : 0 if ![0, 1023].contains(categoryValue) { queryItems1[.fCats] = String(categoryValue) } diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift index 73655ba5d..56733503e 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift index 92a14af45..e3099c4d3 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift index 4877bea1b..6005c6bcf 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift index 5a37b2a18..9fdb7d66e 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import Kingfisher import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift index 9cb90b833..f24c6401d 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import Kingfisher diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift index 3ae66a6e3..a2321c67f 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture // MARK: - Download Action Handlers diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift index f85ce7eb6..432fa69f5 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Foundation import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift index 685be14a6..3559eba44 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels @Reducer struct DetailSearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift index 6e782a845..2a2e94c29 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import ComposableArchitecture struct DetailSearchView: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift index b3c04892c..5613539c4 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources extension DetailView { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift index 776c70d2d..91096b580 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import Kingfisher import SFSafeSymbols diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift index 806369194..00654b0b7 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import Kingfisher diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift index e71968aa3..3b3da255a 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import Kingfisher import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift index 22f6b42c4..d48c4b2cc 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift index fe2f6a8f7..33b49e595 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift index bba152a5a..5a84a02d9 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift index 8ae3934f4..cb41f2660 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift index c4f615e52..3cfab872d 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift index 97228b27a..bd67e90f9 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift index 4c271efb1..6c140440d 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift index c0b6da0a3..b2b89f0eb 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import SFSafeSymbols import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift index 13c2f682c..d6dcb873d 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import SFSafeSymbols import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift index 8c2de43d8..3ddad0b5d 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift index fe16a9cb1..8c3c8cfef 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import IdentifiedCollections import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift index e2f7f394f..2ff2e4ae7 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import AlertKit import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift index 6197dbcb6..db224c5b6 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels import Foundation @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift index c2d424604..efa68de39 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import AlertKit import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift index 30c9bba4d..0eee3761b 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift index c04914cdf..5140fd20c 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift b/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift index 18a5c7ea4..b10a333a2 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Kingfisher import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift index 71eebfe13..258a65ec7 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import Kingfisher import SwiftUIPager diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift index fccaf141d..a3fd5d6e4 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import Kingfisher import SFSafeSymbols diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift index 6e0065363..8cc004b65 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels @Reducer struct PopularReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift index fc3e7d1df..1a42f2048 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift index 062de9fd8..ed0d3ef9e 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels @Reducer struct ToplistsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift index 1952b9e91..15f185c93 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift index 55809402d..a71c73662 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels @Reducer struct WatchedReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift index 92d7409d2..eafca901e 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift b/AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift index 6a9a3b836..e0f48f478 100644 --- a/AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Database.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Database.swift index b193ff5d5..709781c2d 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Database.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Database.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import ComposableArchitecture // MARK: - Database & Download Actions diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift index 89473ab3a..8195cea9e 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingView+Gestures.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingView+Gestures.swift index 55524b796..f0dd24cd7 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingView+Gestures.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingView+Gestures.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels // MARK: Gesture extension ReadingView { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift index 86b3b9550..092f45472 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Observation import SwiftUIPager import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift index 4b8897f07..8f4fa65af 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import Kingfisher import SDWebImage diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/AutoPlayHandler.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/AutoPlayHandler.swift index c3a898c3a..095913bb4 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/AutoPlayHandler.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/AutoPlayHandler.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Observation @Observable diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift index 926e61bd6..dfbde95a5 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources // MARK: ControlPanel diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/GestureHandler.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/GestureHandler.swift index 57daa2590..d92a07397 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/GestureHandler.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/GestureHandler.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Observation @Observable diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextHandler.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextHandler.swift index ffd786094..8140fd05d 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextHandler.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextHandler.swift @@ -9,6 +9,7 @@ // import Vision +import AppModels import SwiftUI import Foundation import Observation diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextView.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextView.swift index 98b9164e6..bff25541d 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextView.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels struct LiveTextView: View { private let liveTextGroups: [LiveTextGroup] diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/PageHandler.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/PageHandler.swift index 5bedebfaf..6264b68a2 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/PageHandler.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/PageHandler.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Observation @Observable diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift index f51d1d21d..82975f488 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels import Foundation @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift index 65ac797e9..e17093d9c 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels @Reducer struct SearchRootReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift index 023bde361..2e4d353f2 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift index 6c76ad9fd..dba5501e4 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import ComposableArchitecture struct SearchView: View { diff --git a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift index 775904dd7..2c364bdd3 100644 --- a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift index 6507ee7d1..7269e616c 100644 --- a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift index 7b72aacf7..8ce9f039a 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift index 4a4018b50..c7a907789 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift index cfa5939d4..c563405b0 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture @@ -165,57 +166,6 @@ private struct AppIconRow: View { } } -// MARK: Definition -enum AppIconType: Int, Codable, Identifiable, CaseIterable { - var id: Int { rawValue } - - case `default` - case ukiyoe - case developer - case standWithUkraine2022 - case notMyPresidnet -} - -extension AppIconType { - var name: String { - switch self { - case .default: - return L10n.Localizable.Enum.AppIconType.Value.default - - case .ukiyoe: - return L10n.Localizable.Enum.AppIconType.Value.ukiyoe - - case .developer: - return L10n.Localizable.Enum.AppIconType.Value.developer - - case .standWithUkraine2022: - return L10n.Localizable.Enum.AppIconType.Value.standWithUkraine2022 - - case .notMyPresidnet: - return L10n.Localizable.Enum.AppIconType.Value.notMyPresident - } - } - - var filename: String { - switch self { - case .default: - return "AppIcon_Default" - - case .ukiyoe: - return "AppIcon_Ukiyoe" - - case .developer: - return "AppIcon_Developer" - - case .standWithUkraine2022: - return "AppIcon_StandWithUkraine2022" - - case .notMyPresidnet: - return "AppIcon_NotMyPresident" - } - } -} - struct AppearanceSettingView_Previews: PreviewProvider { static var previews: some View { NavigationView { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift index 3eb8bac58..79446377e 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/WebView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/WebView.swift index 2cc16613e..9186d605a 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Components/WebView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Components/WebView.swift @@ -1,4 +1,5 @@ import WebKit +import AppModels import SwiftUI struct WebView: UIViewControllerRepresentable { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift index 0d172cf5d..4b4894e5b 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift index bbcd33ebd..26b378f45 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift index 6cb6393c5..09ef7be47 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources extension EhSettingView { @@ -27,8 +28,8 @@ struct FavoritesSection: View { @Binding var ehSetting: EhSetting @FocusState private var isFocused - private var tuples: [(Category, Binding)] { - Category.allFavoritesCases.enumerated().map { index, category in + private var tuples: [(AppModels.Category, Binding)] { + AppModels.Category.allFavoritesCases.enumerated().map { index, category in (category, $ehSetting.favoriteCategories[index]) } } diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift index cd5c896d9..2def9ce31 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources extension EhSettingView { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift index 83170861a..f92e89508 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift index dd461df27..cfe6216a7 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift @@ -1,4 +1,5 @@ import LocalAuthentication +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift index fd4c4302d..215b435bd 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import FilePicker import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift index faa80d76e..0431f849c 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift index 7a9be79c1..4d092191f 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift index f03ad0f89..b357ac8f3 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels @Reducer struct LogsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Body.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Body.swift index 1e4fcf3d1..b9f4c8365 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Body.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Body.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture extension SettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Helpers.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Helpers.swift index b193748b0..56af78d33 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Helpers.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Helpers.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture extension SettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift index 8d7a05403..19d9481f6 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture @Reducer diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift index d68dac2ba..e3614022e 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import SFSafeSymbols diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift index 72019cb48..a8bae9288 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels // MARK: CategoryLabel struct CategoryLabel: View { @@ -37,8 +38,8 @@ struct CategoryView: View { private let gridItems = [ GridItem(.adaptive(minimum: DeviceUtil.isPadWidth ? 100 : 80, maximum: 100)) ] - private var tuples: [(Binding, Category)] { - Category.allFiltersCases.enumerated().map { value in + private var tuples: [(Binding, AppModels.Category)] { + AppModels.Category.allFiltersCases.enumerated().map { value in (bindings[value.offset], value.element) } } @@ -61,9 +62,9 @@ struct CategoryView: View { // MARK: CategoryCell private struct CategoryCell: View { @Binding private var isFiltered: Bool - private let category: Category + private let category: AppModels.Category - init(isFiltered: Binding, category: Category) { + init(isFiltered: Binding, category: AppModels.Category) { _isFiltered = isFiltered self.category = category } diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift index e9ba58ff7..46914c323 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Colorful import Kingfisher import UIImageColors diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift index 913fb7442..154586a00 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Kingfisher struct GalleryDetailCell: View { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift index 19c1a3ef6..2a6666211 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Kingfisher struct GalleryHistoryCell: View { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift index 900b1225e..588665fe1 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Kingfisher struct GalleryRankingCell: View { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift index 0243f434f..9c492fa61 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Kingfisher struct GalleryThumbnailCell: View { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift index 0839a5ad2..05d5a40e2 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift @@ -1,4 +1,5 @@ import SFSafeSymbols +import AppModels import Resources import SwiftUI diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift b/AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift index a34d729ef..8162c820d 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources struct DownloadBadgeLabel: View { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift b/AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift index e3419c576..d2ee7be5a 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import WaterfallGrid import ComposableArchitecture diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift index cd2ea4cbd..c2ce9f580 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels struct Placeholder: View { @Environment(\.inSheet) private var inSheet diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift index faed7504f..e34339300 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import ImageIO import Kingfisher diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift index 51ded3fca..726246f64 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import Kingfisher import Observation diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift b/AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift index bac7a4cae..1bba15518 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources struct CustomToolbarItem: ToolbarContent { diff --git a/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift b/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift index 6a8c24a8b..e6b9216af 100644 --- a/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels import Foundation /// A headless, reusable sub-reducer for the "Seek to date" control. diff --git a/AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift b/AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift index 6c93d4bdf..765dcfe88 100644 --- a/AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels @Reducer struct FiltersReducer { diff --git a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift index 6ae5cf35d..b9764ff13 100644 --- a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture @@ -207,7 +208,7 @@ private struct TupleCategory: Identifiable { var id: String { category.rawValue } let isFiltered: Binding - let category: Category + let category: AppModels.Category } enum FilterRange: Int, CaseIterable, Identifiable { diff --git a/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift b/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift index 8b2bab532..ca9978359 100644 --- a/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources struct NewDawnView: View { diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 07b72ed7b..7411f0cb3 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Resources import SFSafeSymbols import ComposableArchitecture diff --git a/AppPackage/Sources/AppModels/.swiftlint.yml b/AppPackage/Sources/AppModels/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AppModels/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppModels/Download/DownloadBadge.swift b/AppPackage/Sources/AppModels/Download/DownloadBadge.swift new file mode 100644 index 000000000..53f5551dd --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadBadge.swift @@ -0,0 +1,11 @@ +public struct DownloadBadge: Equatable, Sendable { + public init( + status: DownloadDisplayStatus, + progress: DownloadProgress + ) { + self.status = status + self.progress = progress + } + public let status: DownloadDisplayStatus + public let progress: DownloadProgress +} diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadDisplayStatus.swift b/AppPackage/Sources/AppModels/Download/DownloadDisplayStatus.swift similarity index 81% rename from AppPackage/Sources/AppFeature/Models/Download/DownloadDisplayStatus.swift rename to AppPackage/Sources/AppModels/Download/DownloadDisplayStatus.swift index 50dc06b3b..8e2f46e8d 100644 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadDisplayStatus.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadDisplayStatus.swift @@ -1,4 +1,4 @@ -enum DownloadDisplayStatus: Equatable, CaseIterable, Sendable { +public enum DownloadDisplayStatus: Equatable, CaseIterable, Sendable { case active case queued case updateAvailable @@ -8,7 +8,7 @@ enum DownloadDisplayStatus: Equatable, CaseIterable, Sendable { } extension DownloadDisplayStatus { - var sortPriority: Int { + public var sortPriority: Int { switch self { case .active: return 0 diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadFailure.swift b/AppPackage/Sources/AppModels/Download/DownloadFailure.swift similarity index 83% rename from AppPackage/Sources/AppFeature/Models/Download/DownloadFailure.swift rename to AppPackage/Sources/AppModels/Download/DownloadFailure.swift index 37554f8bc..dfb208bfa 100644 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadFailure.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadFailure.swift @@ -1,4 +1,4 @@ -enum DownloadFailureCode: String, Codable, Equatable, Sendable { +public enum DownloadFailureCode: String, Codable, Equatable, Sendable { case quotaExceeded case authenticationRequired case fileOperationFailed @@ -9,16 +9,16 @@ enum DownloadFailureCode: String, Codable, Equatable, Sendable { case unknown } -struct DownloadFailure: Codable, Equatable, Sendable { - var code: DownloadFailureCode - var message: String +public struct DownloadFailure: Codable, Equatable, Sendable { + public var code: DownloadFailureCode + public var message: String - init(code: DownloadFailureCode, message: String) { + public init(code: DownloadFailureCode, message: String) { self.code = code self.message = message } - init(error: AppError) { + public init(error: AppError) { switch error { case .quotaExceeded: self = .init(code: .quotaExceeded, message: error.alertText) @@ -39,7 +39,7 @@ struct DownloadFailure: Codable, Equatable, Sendable { } } - var appError: AppError { + public var appError: AppError { switch code { case .quotaExceeded: return .quotaExceeded diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadFolderFilter.swift b/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift similarity index 73% rename from AppPackage/Sources/AppFeature/Models/Download/DownloadFolderFilter.swift rename to AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift index 6ad850bdc..02056e2e3 100644 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadFolderFilter.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift @@ -1,10 +1,10 @@ import Resources -enum DownloadFolderFilter: Equatable { +public enum DownloadFolderFilter: Equatable, Sendable { case all case folder(String) - var title: String { + public var title: String { switch self { case .all: return L10n.Localizable.Enum.DownloadFolderFilter.Title.all @@ -15,7 +15,7 @@ enum DownloadFolderFilter: Equatable { } extension DownloadedGallery { - func matches(folderFilter: DownloadFolderFilter) -> Bool { + public func matches(folderFilter: DownloadFolderFilter) -> Bool { switch folderFilter { case .all: return true diff --git a/AppPackage/Sources/AppModels/Download/DownloadInspection.swift b/AppPackage/Sources/AppModels/Download/DownloadInspection.swift new file mode 100644 index 000000000..743ea979c --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadInspection.swift @@ -0,0 +1,49 @@ +import Foundation + +public enum DownloadPageStatus: String, Equatable, CaseIterable, Sendable { + case pending + case downloaded + case failed +} + +public struct DownloadPageInspection: Equatable, Identifiable, Sendable { + public init( + index: Int, + status: DownloadPageStatus, + relativePath: String? = nil, + fileURL: URL? = nil, + failure: DownloadFailure? = nil + ) { + self.index = index + self.status = status + self.relativePath = relativePath + self.fileURL = fileURL + self.failure = failure + } + public var id: Int { index } + + public let index: Int + public let status: DownloadPageStatus + public let relativePath: String? + public let fileURL: URL? + public let failure: DownloadFailure? +} + +public struct DownloadInspection: Equatable, Sendable { + public init( + download: DownloadedGallery, + coverURL: URL? = nil, + pages: [DownloadPageInspection] + ) { + self.download = download + self.coverURL = coverURL + self.pages = pages + } + public let download: DownloadedGallery + public let coverURL: URL? + public let pages: [DownloadPageInspection] + + public var failedPageIndices: [Int] { + pages.filter { $0.status == .failed }.map(\.index) + } +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadProgress.swift b/AppPackage/Sources/AppModels/Download/DownloadProgress.swift new file mode 100644 index 000000000..11e5f1fde --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadProgress.swift @@ -0,0 +1,22 @@ +public struct DownloadProgress: Equatable, Sendable { + public init( + completedPageCount: Int, + pageCount: Int + ) { + self.completedPageCount = completedPageCount + self.pageCount = pageCount + } + public let completedPageCount: Int + public let pageCount: Int + + public var displayPageCount: Int { + max(pageCount, 1) + } + public var displayCompletedPageCount: Int { + min(max(completedPageCount, 0), displayPageCount) + } + + public var fraction: Double { + Double(displayCompletedPageCount) / Double(displayPageCount) + } +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadRequestOptions.swift b/AppPackage/Sources/AppModels/Download/DownloadRequestOptions.swift new file mode 100644 index 000000000..9beb5d794 --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadRequestOptions.swift @@ -0,0 +1,24 @@ +/// Execution policy for a download: *how* to fetch (thread limit, cellular, auto-retry), +/// not *what* to fetch. Deliberately separate from `DownloadRequestPayload` and never +/// persisted to a manifest or request: it is resolved fresh from the latest settings once +/// per run (see `downloadOptionsProvider`) and threaded to the workers, so a settings change +/// while a gallery sits queued takes effect when it finally starts. +public struct DownloadRequestOptions: Equatable, Sendable { + public var threadLimit = Setting.downloadThreadLimitDefaultValue + public var allowCellular = Setting.downloadAllowCellularDefaultValue + public var autoRetryFailedPages = Setting.downloadAutoRetryFailedPagesDefaultValue + + public init( + threadLimit: Int = Setting.downloadThreadLimitDefaultValue, + allowCellular: Bool = Setting.downloadAllowCellularDefaultValue, + autoRetryFailedPages: Bool = Setting.downloadAutoRetryFailedPagesDefaultValue + ) { + self.threadLimit = threadLimit + self.allowCellular = allowCellular + self.autoRetryFailedPages = autoRetryFailedPages + } + + public var workerCount: Int { + threadLimit + } +} diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadStartMode.swift b/AppPackage/Sources/AppModels/Download/DownloadStartMode.swift similarity index 53% rename from AppPackage/Sources/AppFeature/Models/Download/DownloadStartMode.swift rename to AppPackage/Sources/AppModels/Download/DownloadStartMode.swift index d61039ee6..403e70c8f 100644 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadStartMode.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadStartMode.swift @@ -1,4 +1,4 @@ -enum DownloadStartMode: String, Equatable, Sendable { +public enum DownloadStartMode: String, Equatable, Sendable { case initial case update case redownload diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+Extensions.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift similarity index 61% rename from AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+Extensions.swift rename to AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift index 555702182..b2e712541 100644 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+Extensions.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift @@ -3,7 +3,7 @@ import SFSafeSymbols // MARK: - DownloadBadge extension DownloadBadge { - var symbol: SFSymbol { + public var symbol: SFSymbol { switch status { case .active: .playFill case .queued: .listDash @@ -14,7 +14,7 @@ extension DownloadBadge { } } - var color: Color { + public var color: Color { switch status { case .active, .queued: .green case .inactive, .completed: .gray @@ -29,18 +29,18 @@ extension DownloadBadge { /// (thread limit, cellular, auto-retry); those are always-latest policy resolved per run, so /// keeping them out of the payload makes "never persisted, always fresh" a structural /// guarantee rather than a convention. See `DownloadRequestOptions`. -struct DownloadRequestPayload: Equatable, Sendable { - let gallery: Gallery - let galleryDetail: GalleryDetail - let previewURLs: [Int: URL] - let previewConfig: PreviewConfig - let host: GalleryHost - let folderName: String - let versionMetadata: DownloadVersionMetadata? - let mode: DownloadStartMode - let pageSelection: Set? +public struct DownloadRequestPayload: Equatable, Sendable { + public let gallery: Gallery + public let galleryDetail: GalleryDetail + public let previewURLs: [Int: URL] + public let previewConfig: PreviewConfig + public let host: GalleryHost + public let folderName: String + public let versionMetadata: DownloadVersionMetadata? + public let mode: DownloadStartMode + public let pageSelection: Set? - init( + public init( gallery: Gallery, galleryDetail: GalleryDetail, previewURLs: [Int: URL], @@ -71,31 +71,51 @@ struct DownloadRequestPayload: Equatable, Sendable { /// manifest metadata provenance (gallery + language seeded from the manifest, so a downloaded /// gallery is readable with no database record). When the local files turn up empty it /// auto-promotes to `.remote`. -enum ReadingContentSource: Equatable { +public enum ReadingContentSource: Equatable, Sendable { case remote case local(DownloadedGallery, DownloadManifest) } // MARK: - DownloadVersionMetadata -struct DownloadVersionMetadata: Equatable, Codable, Sendable { - let gid: String - let token: String - let currentGID: String? - let currentKey: String? - let parentGID: String? - let parentKey: String? - let firstGID: String? - let firstKey: String? +public struct DownloadVersionMetadata: Equatable, Codable, Sendable { + public let gid: String + public let token: String + public let currentGID: String? + public let currentKey: String? + public let parentGID: String? + public let parentKey: String? + public let firstGID: String? + public let firstKey: String? - func hasUpdate(comparedTo download: DownloadedGallery) -> Bool { + public init( + gid: String, + token: String, + currentGID: String?, + currentKey: String?, + parentGID: String?, + parentKey: String?, + firstGID: String?, + firstKey: String? + ) { + self.gid = gid + self.token = token + self.currentGID = currentGID + self.currentKey = currentKey + self.parentGID = parentGID + self.parentKey = parentKey + self.firstGID = firstGID + self.firstKey = firstKey + } + + public func hasUpdate(comparedTo download: DownloadedGallery) -> Bool { (download.gid, download.token) != (resolvedCurrentGID, resolvedCurrentKey) } - var resolvedCurrentGID: String { + public var resolvedCurrentGID: String { currentGID?.nonEmpty ?? gid } - var resolvedCurrentKey: String { + public var resolvedCurrentKey: String { currentKey?.nonEmpty ?? token } } diff --git a/AppPackage/Sources/AppModels/Download/DownloadedGallery+Manifest.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Manifest.swift new file mode 100644 index 000000000..53172f76c --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Manifest.swift @@ -0,0 +1,74 @@ +import Foundation + +/// The identity record for a downloaded gallery, written to `manifest.json` in its folder. +/// Identity lives *here* (`gid` / `token`), not in the folder path: the human-readable +/// `[gid_token] Title` folder name is presentation, and the title in it can change and +/// re-slot the directory without affecting identity. Folder membership follows the file's +/// location on disk, so this manifest is what re-establishes identity after the gallery is +/// moved or renamed via the Files app. +public struct DownloadManifest: Codable, Equatable, Sendable { + public let gid: String + public let host: GalleryHost + public let token: String + public let title: String + public let jpnTitle: String? + public let category: Category + public let language: Language + public let remoteCoverURL: URL? + public let uploader: String? + public let tags: [GalleryTag] + public let postedDate: Date + public let rating: Float + public var pages: [Int: String] + + public init( + gid: String, + host: GalleryHost, + token: String, + title: String, + jpnTitle: String?, + category: Category, + language: Language, + remoteCoverURL: URL?, + uploader: String?, + tags: [GalleryTag], + postedDate: Date, + rating: Float, + pages: [Int: String] + ) { + self.gid = gid + self.host = host + self.token = token + self.title = title + self.jpnTitle = jpnTitle + self.category = category + self.language = language + self.remoteCoverURL = remoteCoverURL + self.uploader = uploader + self.tags = tags + self.postedDate = postedDate + self.rating = rating + self.pages = pages + } +} + +extension DownloadManifest { + public var pageCount: Int { + pages.count + } + + public var galleryURL: URL { + host.url + .appendingPathComponent("g") + .appendingPathComponent(gid) + .appendingPathComponent(token) + } + + public var completedPageCount: Int { + pages.values.filter { !$0.isEmpty }.count + } + + public var isComplete: Bool { + !pages.isEmpty && completedPageCount == pages.count + } +} diff --git a/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+SupportTypes.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift similarity index 73% rename from AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+SupportTypes.swift rename to AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift index 6a320c206..ff1b7c6f9 100644 --- a/AppPackage/Sources/AppFeature/Models/Download/DownloadedGallery+SupportTypes.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift @@ -2,11 +2,11 @@ import SwiftUI // MARK: DownloadedGallery Computed Properties extension DownloadedGallery { - var displayTitle: String { + public var displayTitle: String { jpnTitle?.nonEmpty ?? title } - var searchableText: String { + public var searchableText: String { [ title, jpnTitle, @@ -19,15 +19,15 @@ extension DownloadedGallery { .joined(separator: " ") } - var manifestURL: URL { + public var manifestURL: URL { folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) } - var coverURL: URL? { + public var coverURL: URL? { localCoverURL ?? onlineCoverURL } - var badge: DownloadBadge { + public var badge: DownloadBadge { DownloadBadge( status: displayStatus, progress: DownloadProgress( @@ -37,7 +37,7 @@ extension DownloadedGallery { ) } - var gallery: Gallery { + public var gallery: Gallery { Gallery( gid: gid, token: token, @@ -53,45 +53,45 @@ extension DownloadedGallery { ) } - var canRetry: Bool { + public var canRetry: Bool { displayStatus == .error } - var canValidateImageData: Bool { + public var canValidateImageData: Bool { [.completed, .updateAvailable].contains(displayStatus) || lastError?.code == .fileOperationFailed } - var canPauseOrResume: Bool { + public var canPauseOrResume: Bool { [.active, .inactive].contains(displayStatus) } - var canTogglePause: Bool { + public var canTogglePause: Bool { canPauseOrResume || isQueuedWorkItem } - var canCancelFromDetailAction: Bool { + public var canCancelFromDetailAction: Bool { isQueuedWorkItem || canPauseOrResume || displayStatus == .completed } - var canTriggerUpdate: Bool { + public var canTriggerUpdate: Bool { guard !isQueuedWorkItem, !canPauseOrResume else { return false } return displayStatus == .updateAvailable } - var isQueuedWorkItem: Bool { + public var isQueuedWorkItem: Bool { displayStatus == .queued } - var hasUpdate: Bool { + public var hasUpdate: Bool { displayStatus == .updateAvailable } - var isIncomplete: Bool { + public var isIncomplete: Bool { completedPageCount < pageCount } - func needsInterruptedDownloadNormalization( + public func needsInterruptedDownloadNormalization( activeGalleryID: String?, hasActiveTask: Bool ) -> Bool { @@ -101,15 +101,15 @@ extension DownloadedGallery { } extension DownloadInspection { - var hasDownloadedPages: Bool { + public var hasDownloadedPages: Bool { pages.contains { $0.status == .downloaded } } - var canRetryFailedPages: Bool { + public var canRetryFailedPages: Bool { !failedPageIndices.isEmpty } - var canValidateImageData: Bool { + public var canValidateImageData: Bool { hasDownloadedPages && download.canValidateImageData } } diff --git a/AppPackage/Sources/AppModels/Download/DownloadedGallery.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery.swift new file mode 100644 index 000000000..7e38b8e1e --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery.swift @@ -0,0 +1,48 @@ +import SwiftUI + +public struct DownloadedGallery: Identifiable, Equatable, Sendable { + public var id: String { gid } + + public let manifest: DownloadManifest + public let folderURL: URL + public let folderName: String + public let localCoverURL: URL? + public let localPageURLs: [Int: URL] + public let displayStatus: DownloadDisplayStatus + public let lastDownloadedDate: Date? + public let lastError: DownloadFailure? + + public var gid: String { manifest.gid } + public var host: GalleryHost { manifest.host } + public var token: String { manifest.token } + public var title: String { manifest.title } + public var jpnTitle: String? { manifest.jpnTitle } + public var uploader: String? { manifest.uploader } + public var category: Category { manifest.category } + public var tags: [GalleryTag] { manifest.tags } + public var pageCount: Int { manifest.pageCount } + public var postedDate: Date { manifest.postedDate } + public var rating: Float { manifest.rating } + public var onlineCoverURL: URL? { manifest.remoteCoverURL } + public var completedPageCount: Int { manifest.completedPageCount } + + public init( + manifest: DownloadManifest, + folderURL: URL, + folderName: String, + localCoverURL: URL?, + localPageURLs: [Int: URL], + modificationDate: Date?, + displayStatus: DownloadDisplayStatus, + lastError: DownloadFailure? = nil + ) { + self.manifest = manifest + self.folderURL = folderURL + self.folderName = folderName + self.localCoverURL = localCoverURL + self.localPageURLs = localPageURLs + self.displayStatus = displayStatus + self.lastDownloadedDate = modificationDate + self.lastError = lastError + } +} diff --git a/AppPackage/Sources/AppFeature/Models/Gallery/Category.swift b/AppPackage/Sources/AppModels/Gallery/Category.swift similarity index 82% rename from AppPackage/Sources/AppFeature/Models/Gallery/Category.swift rename to AppPackage/Sources/AppModels/Gallery/Category.swift index 1124e0faa..d0e218e2e 100644 --- a/AppPackage/Sources/AppFeature/Models/Gallery/Category.swift +++ b/AppPackage/Sources/AppModels/Gallery/Category.swift @@ -1,11 +1,11 @@ import SwiftUI import Resources -enum Category: String, Codable, CaseIterable, Identifiable, Sendable { - var id: String { rawValue } +public enum Category: String, Codable, CaseIterable, Identifiable, Sendable { + public var id: String { rawValue } - static let allFavoritesCases: [Self] = [.misc] + allCases.dropLast(2) - static let allFiltersCases: [Self] = allCases.dropLast() + public static let allFavoritesCases: [Self] = [.misc] + allCases.dropLast(2) + public static let allFiltersCases: [Self] = allCases.dropLast() case doujinshi = "Doujinshi" case manga = "Manga" @@ -21,10 +21,10 @@ enum Category: String, Codable, CaseIterable, Identifiable, Sendable { } extension Category { - var color: Color { - .init(AppUtil.galleryHost.rawValue + "/" + rawValue) + public func color(host: GalleryHost) -> Color { + .init(host.rawValue + "/" + rawValue) } - var filterValue: Int { + public var filterValue: Int { switch self { case .doujinshi: return 2 case .manga: return 4 @@ -42,7 +42,7 @@ extension Category { fatalError(message) } } - var value: String { + public var value: String { switch self { case .doujinshi: return L10n.Localizable.Enum.Category.Value.doujinshi case .manga: return L10n.Localizable.Enum.Category.Value.manga diff --git a/AppPackage/Sources/AppModels/Gallery/Gallery.swift b/AppPackage/Sources/AppModels/Gallery/Gallery.swift new file mode 100644 index 000000000..a7272604e --- /dev/null +++ b/AppPackage/Sources/AppModels/Gallery/Gallery.swift @@ -0,0 +1,127 @@ +import SwiftUI + +public struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { + public static func == (lhs: Gallery, rhs: Gallery) -> Bool { + lhs.gid == rhs.gid + } + + public static func mockGalleries(count: Int, randomID: Bool = true) -> [Gallery] { + guard randomID, count > 0 else { + return Array(repeating: .empty, count: count) + } + return (0...count).map { _ in .empty } + } + public static var empty: Gallery { + .init( + gid: UUID().uuidString, + token: "", + title: "", + rating: 0.0, + tags: [], + category: .doujinshi, + uploader: "", + pageCount: 1, + postedDate: .now, + coverURL: nil, + galleryURL: nil + ) + } + public static let preview = Gallery( + gid: UUID().uuidString, + token: "", + title: "Preview", + rating: 3.5, + tags: [], + category: .doujinshi, + uploader: "Anonymous", + pageCount: 1, + postedDate: .now, + coverURL: URL( + string: "https://github.com/" + + "EhPanda-Team/Imageset/blob/" + + "main/JPGs/2.jpg?raw=true" + ), + galleryURL: nil + ) + + public var trimmedTitle: String { + var title = title + if let range = title.range(of: "|") { + title = String(title[.. [GalleryTag.Content] { + let tagContents = tags.flatMap(\.contents) + guard maximum > 0 else { return tagContents } + return .init(tagContents.prefix(min(tagContents.count, maximum))) + } + + public var id: String { gid } + public let gid: String + public let token: String + + public var title: String + public var rating: Float + public var tags: [GalleryTag] + public let category: Category + public var uploader: String? + public var pageCount: Int + public let postedDate: Date + public let coverURL: URL? + public let galleryURL: URL? + public var lastOpenDate: Date? + + public init( + gid: String, + token: String, + title: String, + rating: Float, + tags: [GalleryTag], + category: Category, + uploader: String? = nil, + pageCount: Int, + postedDate: Date, + coverURL: URL?, + galleryURL: URL?, + lastOpenDate: Date? = nil + ) { + self.gid = gid + self.token = token + self.title = title + self.rating = rating + self.tags = tags + self.category = category + self.uploader = uploader + self.pageCount = pageCount + self.postedDate = postedDate + self.coverURL = coverURL + self.galleryURL = galleryURL + self.lastOpenDate = lastOpenDate + } +} + +extension Gallery: DateFormattable, CustomStringConvertible { + public var description: String { + "Gallery(\(gid))" + } + + public var filledCount: Int { Int(rating) } + public var halfFilledCount: Int { Int(rating - 0.5) == filledCount ? 1 : 0 } + public var notFilledCount: Int { 5 - filledCount - halfFilledCount } + + public func color(host: GalleryHost) -> Color { + category.color(host: host) + } + public var originalDate: Date { + postedDate + } +} diff --git a/AppPackage/Sources/AppFeature/Models/Gallery/GalleryArchive.swift b/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift similarity index 58% rename from AppPackage/Sources/AppFeature/Models/Gallery/GalleryArchive.swift rename to AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift index ba32345dc..5e766522d 100644 --- a/AppPackage/Sources/AppFeature/Models/Gallery/GalleryArchive.swift +++ b/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift @@ -1,24 +1,29 @@ import Foundation import Resources -struct GalleryArchive: Codable, Equatable { - struct HathArchive: Codable, Identifiable, Equatable { - var id: String { resolution.rawValue } +public struct GalleryArchive: Codable, Equatable, Sendable { + public init( + hathArchives: [HathArchive] + ) { + self.hathArchives = hathArchives + } + public struct HathArchive: Codable, Identifiable, Equatable, Sendable { + public var id: String { resolution.rawValue } - let resolution: ArchiveResolution - let fileSize: String + public let resolution: ArchiveResolution + public let fileSize: String private let gpPrice: String - init(resolution: ArchiveResolution, fileSize: String, gpPrice: String) { + public init(resolution: ArchiveResolution, fileSize: String, gpPrice: String) { self.resolution = resolution self.fileSize = fileSize self.gpPrice = gpPrice } - var isValid: Bool { + public var isValid: Bool { fileSize != "N/A" && gpPrice != "N/A" } - var price: String { + public var price: String { switch gpPrice { case "Free": return L10n.Localizable.Struct.HathArchive.Price.free @@ -28,10 +33,10 @@ struct GalleryArchive: Codable, Equatable { } } - let hathArchives: [HathArchive] + public let hathArchives: [HathArchive] } -enum ArchiveResolution: String, Codable, CaseIterable, Equatable { +public enum ArchiveResolution: String, Codable, CaseIterable, Equatable, Sendable { case x780 = "780x" case x980 = "980x" case x1280 = "1280x" @@ -41,7 +46,7 @@ enum ArchiveResolution: String, Codable, CaseIterable, Equatable { } extension ArchiveResolution { - var value: String { + public var value: String { switch self { case .x780, .x980, .x1280, .x1600, .x2400: return rawValue @@ -49,7 +54,7 @@ extension ArchiveResolution { return L10n.Localizable.Enum.ArchiveResolution.Value.original } } - var parameter: String { + public var parameter: String { switch self { case .original: return "org" diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryComment.swift b/AppPackage/Sources/AppModels/Gallery/GalleryComment.swift new file mode 100644 index 000000000..cfb4ed92d --- /dev/null +++ b/AppPackage/Sources/AppModels/Gallery/GalleryComment.swift @@ -0,0 +1,89 @@ +import Foundation + +public struct GalleryComment: Identifiable, Equatable, Codable, Sendable { + public init( + votedUp: Bool, + votedDown: Bool, + votable: Bool, + editable: Bool, + score: String? = nil, + author: String, + contents: [CommentContent], + commentID: String, + commentDate: Date + ) { + self.votedUp = votedUp + self.votedDown = votedDown + self.votable = votable + self.editable = editable + self.score = score + self.author = author + self.contents = contents + self.commentID = commentID + self.commentDate = commentDate + } + public var id: String { commentID } + + public var votedUp: Bool + public var votedDown: Bool + public let votable: Bool + public let editable: Bool + + public let score: String? + public let author: String + public let contents: [CommentContent] + public let commentID: String + public let commentDate: Date + + public var plainTextContent: String { + contents + .filter { [.plainText, .linkedText, .singleLink].contains($0.type) } + .compactMap { $0.type == .singleLink ? $0.link?.absoluteString : $0.text }.joined() + } +} + +extension GalleryComment: DateFormattable { + public var originalDate: Date { + commentDate + } +} + +public struct CommentContent: Identifiable, Equatable, Codable, Sendable { + public init( + id: UUID = .init(), + type: CommentContentType, + text: String? = nil, + link: URL? = nil, + imgURL: URL? = nil, + secondLink: URL? = nil, + secondImgURL: URL? = nil + ) { + self.id = id + self.type = type + self.text = text + self.link = link + self.imgURL = imgURL + self.secondLink = secondLink + self.secondImgURL = secondImgURL + } + public var id: UUID = .init() + public let type: CommentContentType + public var text: String? + public var link: URL? + public var imgURL: URL? + + public var secondLink: URL? + public var secondImgURL: URL? +} + +public enum CommentContentType: Int, Codable, Sendable { + case singleImg + case doubleImg + case linkedImg + case doubleLinkedImg + + case plainText + case linkedText + + case singleLink +} diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift b/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift new file mode 100644 index 000000000..ea3dda8be --- /dev/null +++ b/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift @@ -0,0 +1,142 @@ +import Foundation +import Resources + +public struct GalleryDetail: Codable, Equatable, Sendable { + public init( + gid: String, + title: String, + jpnTitle: String? = nil, + isFavorited: Bool, + visibility: GalleryVisibility, + rating: Float, + userRating: Float, + ratingCount: Int, + category: Category, + language: Language, + uploader: String, + postedDate: Date, + coverURL: URL? = nil, + archiveURL: URL? = nil, + parentURL: URL? = nil, + favoritedCount: Int, + pageCount: Int, + sizeCount: Float, + sizeType: String, + torrentCount: Int + ) { + self.gid = gid + self.title = title + self.jpnTitle = jpnTitle + self.isFavorited = isFavorited + self.visibility = visibility + self.rating = rating + self.userRating = userRating + self.ratingCount = ratingCount + self.category = category + self.language = language + self.uploader = uploader + self.postedDate = postedDate + self.coverURL = coverURL + self.archiveURL = archiveURL + self.parentURL = parentURL + self.favoritedCount = favoritedCount + self.pageCount = pageCount + self.sizeCount = sizeCount + self.sizeType = sizeType + self.torrentCount = torrentCount + } + public static let empty: Self = .init( + gid: "", title: "", isFavorited: false, + visibility: .yes, rating: 0, userRating: 0, + ratingCount: 0, category: .private, + language: .japanese, uploader: "", + postedDate: .now, coverURL: nil, + favoritedCount: 0, pageCount: 0, + sizeCount: 0, sizeType: "", + torrentCount: 0 + ) + public static let preview = GalleryDetail( + gid: "", + title: "Preview", + jpnTitle: "プレビュー", + isFavorited: true, + visibility: .yes, + rating: 3.5, + userRating: 4.0, + ratingCount: 1919, + category: .doujinshi, + language: .japanese, + uploader: "Anonymous", + postedDate: .distantPast, + coverURL: URL( + string: "https://github.com/" + + "EhPanda-Team/Imageset/blob/" + + "main/JPGs/2.jpg?raw=true" + ), + favoritedCount: 514, + pageCount: 114, + sizeCount: 514, + sizeType: "MB", + torrentCount: 101 + ) + + public var trimmedTitle: String { + var title = title + if let range = title.range(of: "|") { + title = String(title[.. String { + let namespace = tag.namespace?.abbreviation ?? tag.namespace?.rawValue ?? tag.rawNamespace.lowercased() + return tag.namespace == .temp ? text : [namespace, text].joined(separator: ":") + } + public func serachKeyword(tag: GalleryTag) -> String { + let keyword = text.contains(" ") ? "\"\(text)$\"" : "\(text)$" + let namespace = tag.namespace?.abbreviation ?? tag.namespace?.rawValue ?? tag.rawNamespace.lowercased() + return tag.namespace == .temp ? keyword : [namespace, keyword].joined(separator: ":") + } + + public let rawNamespace: String + public let text: String + public let isVotedUp: Bool + public let isVotedDown: Bool + public let textColor: Color? + public let backgroundColor: Color? + } + + public var id: String { rawNamespace } + public var namespace: TagNamespace? { + .init(rawValue: rawNamespace) + } + + public let rawNamespace: String + public let contents: [Content] +} + +public enum PreviewConfig: Codable, Equatable, Sendable { + case normal(rows: Int) + case large(rows: Int) +} + +extension PreviewConfig { + public var batchSize: Int { + switch self { + case .normal(let rows): + return 10 * rows + case .large(let rows): + return 5 * rows + } + } + + public func pageNumber(index: Int) -> Int { + max(index - 1, 0) / batchSize + } + public func batchRange(index: Int) -> ClosedRange { + let lowerBound = pageNumber(index: index) * batchSize + 1 + let upperBound = lowerBound + batchSize - 1 + return lowerBound...upperBound + } +} diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryTorrent.swift b/AppPackage/Sources/AppModels/Gallery/GalleryTorrent.swift new file mode 100644 index 000000000..6fe7a6282 --- /dev/null +++ b/AppPackage/Sources/AppModels/Gallery/GalleryTorrent.swift @@ -0,0 +1,46 @@ +import Foundation + +public struct GalleryTorrent: Identifiable, Codable, Equatable, Sendable { + public init( + id: UUID = .init(), + postedDate: Date, + fileSize: String, + seedCount: Int, + peerCount: Int, + downloadCount: Int, + uploader: String, + fileName: String, + hash: String, + torrentURL: URL + ) { + self.id = id + self.postedDate = postedDate + self.fileSize = fileSize + self.seedCount = seedCount + self.peerCount = peerCount + self.downloadCount = downloadCount + self.uploader = uploader + self.fileName = fileName + self.hash = hash + self.torrentURL = torrentURL + } + public var id: UUID = .init() + public let postedDate: Date + public let fileSize: String + public let seedCount: Int + public let peerCount: Int + public let downloadCount: Int + public let uploader: String + public let fileName: String + public let hash: String + public let torrentURL: URL +} + +extension GalleryTorrent: DateFormattable { + public var originalDate: Date { + postedDate + } + public var magnetURL: String { + "magnet:?xt=urn:btih:\(hash)" + } +} diff --git a/AppPackage/Sources/AppFeature/Models/Gallery/Language.swift b/AppPackage/Sources/AppModels/Gallery/Language.swift similarity index 97% rename from AppPackage/Sources/AppFeature/Models/Gallery/Language.swift rename to AppPackage/Sources/AppModels/Gallery/Language.swift index 2fd8c1cbd..674668092 100644 --- a/AppPackage/Sources/AppFeature/Models/Gallery/Language.swift +++ b/AppPackage/Sources/AppModels/Gallery/Language.swift @@ -1,7 +1,7 @@ import Resources -enum Language: String, Codable, Sendable { - static let allExcludedCases: [Self] = [ +public enum Language: String, Codable, Sendable { + public static let allExcludedCases: [Self] = [ .japanese, .english, .chinese, .dutch, .french, .german, .hungarian, .italian, .korean, .polish, .portuguese, .russian, .spanish, .thai, .vietnamese, .invalid, .other ] @@ -11,7 +11,7 @@ enum Language: String, Codable, Sendable { } extension Language { - var codes: [String]? { + public var codes: [String]? { switch self { case .english: return ["en-US"] case .french: return ["fr-FR"] @@ -23,14 +23,14 @@ extension Language { default: return nil } } - var abbreviation: String { + public var abbreviation: String { switch self { // swiftlint:disable switch_case_alignment line_length case .invalid, .other: return "N/A"; case .afrikaans: return "AF"; case .albanian: return "SQ"; case .arabic: return "AR"; case .bengali: return "BN"; case .bosnian: return "BS"; case .bulgarian: return "BG"; case .burmese: return "MY"; case .catalan: return "CA"; case .cebuano: return "CEB"; case .chinese: return "ZH"; case .croatian: return "HR"; case .czech: return "CS"; case .danish: return "DA"; case .dutch: return "NL"; case .english: return "EN"; case .esperanto: return "EO"; case .estonian: return "ET"; case .finnish: return "FI"; case .french: return "FR"; case .georgian: return "KA"; case .german: return "DE"; case .greek: return "EL"; case .hebrew: return "HE"; case .hindi: return "HI"; case .hmong: return "HMN"; case .hungarian: return "HU"; case .indonesian: return "ID"; case .italian: return "IT"; case .japanese: return "JA"; case .kazakh: return "KK"; case .khmer: return "KM"; case .korean: return "KO"; case .kurdish: return "KU"; case .lao: return "LO"; case .latin: return "LA"; case .mongolian: return "MN"; case .ndebele: return "ND"; case .nepali: return "NE"; case .norwegian: return "NO"; case .oromo: return "OM"; case .pashto: return "PS"; case .persian: return "FA"; case .polish: return "PL"; case .portuguese: return "PT"; case .punjabi: return "PA"; case .romanian: return "RO"; case .russian: return "RU"; case .sango: return "SG"; case .serbian: return "SR"; case .shona: return "SN"; case .slovak: return "SK"; case .slovenian: return "SL"; case .somali: return "SO"; case .spanish: return "ES"; case .swahili: return "SW"; case .swedish: return "SV"; case .tagalog: return "TL"; case .thai: return "TH"; case .tigrinya: return "TI"; case .turkish: return "TR"; case .ukrainian: return "UK"; case .urdu: return "UR"; case .vietnamese: return "VI"; case .zulu: return "ZU" // swiftlint:enable switch_case_alignment line_length } } - var value: String { + public var value: String { switch self { case .invalid: return L10n.Localizable.Enum.Language.Value.invalid case .other: return L10n.Localizable.Enum.Language.Value.other diff --git a/AppPackage/Sources/AppModels/Persistent/AppEnv.swift b/AppPackage/Sources/AppModels/Persistent/AppEnv.swift new file mode 100644 index 000000000..18ef9ea06 --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistent/AppEnv.swift @@ -0,0 +1,45 @@ +public struct AppEnv: Codable, Equatable, Sendable { + public init( + user: User, + setting: Setting, + searchFilter: Filter, + globalFilter: Filter, + watchedFilter: Filter, + tagTranslator: TagTranslator, + historyKeywords: [String], + quickSearchWords: [QuickSearchWord] + ) { + self.user = user + self.setting = setting + self.searchFilter = searchFilter + self.globalFilter = globalFilter + self.watchedFilter = watchedFilter + self.tagTranslator = tagTranslator + self.historyKeywords = historyKeywords + self.quickSearchWords = quickSearchWords + } + public let user: User + public let setting: Setting + public let searchFilter: Filter + public let globalFilter: Filter + public let watchedFilter: Filter + public let tagTranslator: TagTranslator + public let historyKeywords: [String] + public let quickSearchWords: [QuickSearchWord] +} + +extension AppEnv: CustomStringConvertible { + public var description: String { + let params = String( + describing: [ + "user": user, + "setting": setting, + "tagTranslator": tagTranslator, + "historyKeywordsCount": historyKeywords.count, + "quickSearchWordsCount": quickSearchWords.count + ] + as [String: Any] + ) + return "AppEnv(\(params))" + } +} diff --git a/AppPackage/Sources/AppModels/Persistent/AppIconType.swift b/AppPackage/Sources/AppModels/Persistent/AppIconType.swift new file mode 100644 index 000000000..9bff7c66b --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistent/AppIconType.swift @@ -0,0 +1,51 @@ +import Resources + +public enum AppIconType: Int, Codable, Identifiable, CaseIterable, Sendable { + public var id: Int { rawValue } + + case `default` + case ukiyoe + case developer + case standWithUkraine2022 + case notMyPresidnet +} + +extension AppIconType { + public var name: String { + switch self { + case .default: + return L10n.Localizable.Enum.AppIconType.Value.default + + case .ukiyoe: + return L10n.Localizable.Enum.AppIconType.Value.ukiyoe + + case .developer: + return L10n.Localizable.Enum.AppIconType.Value.developer + + case .standWithUkraine2022: + return L10n.Localizable.Enum.AppIconType.Value.standWithUkraine2022 + + case .notMyPresidnet: + return L10n.Localizable.Enum.AppIconType.Value.notMyPresident + } + } + + public var filename: String { + switch self { + case .default: + return "AppIcon_Default" + + case .ukiyoe: + return "AppIcon_Ukiyoe" + + case .developer: + return "AppIcon_Developer" + + case .standWithUkraine2022: + return "AppIcon_StandWithUkraine2022" + + case .notMyPresidnet: + return "AppIcon_NotMyPresident" + } + } +} diff --git a/AppPackage/Sources/AppFeature/Models/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift similarity index 51% rename from AppPackage/Sources/AppFeature/Models/Persistent/Filter.swift rename to AppPackage/Sources/AppModels/Persistent/Filter.swift index 798f0646e..33b2fe712 100644 --- a/AppPackage/Sources/AppFeature/Models/Persistent/Filter.swift +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -1,51 +1,108 @@ import SwiftUI -struct Filter: Codable, Equatable { - var doujinshi = false - var manga = false - var artistCG = false - var gameCG = false - var western = false - var nonH = false - var imageSet = false - var cosplay = false - var asianPorn = false - var misc = false +public struct Filter: Codable, Equatable, Sendable { + public init( + doujinshi: Bool = false, + manga: Bool = false, + artistCG: Bool = false, + gameCG: Bool = false, + western: Bool = false, + nonH: Bool = false, + imageSet: Bool = false, + cosplay: Bool = false, + asianPorn: Bool = false, + misc: Bool = false, + advanced: Bool = false, + galleryName: Bool = true, + galleryTags: Bool = true, + galleryDesc: Bool = false, + torrentFilenames: Bool = false, + onlyWithTorrents: Bool = false, + lowPowerTags: Bool = false, + downvotedTags: Bool = false, + expungedGalleries: Bool = false, + minRatingActivated: Bool = false, + minRating: Int = 2, + pageRangeActivated: Bool = false, + pageLowerBound: String = "", + pageUpperBound: String = "", + disableLanguage: Bool = false, + disableUploader: Bool = false, + disableTags: Bool = false + ) { + self.doujinshi = doujinshi + self.manga = manga + self.artistCG = artistCG + self.gameCG = gameCG + self.western = western + self.nonH = nonH + self.imageSet = imageSet + self.cosplay = cosplay + self.asianPorn = asianPorn + self.misc = misc + self.advanced = advanced + self.galleryName = galleryName + self.galleryTags = galleryTags + self.galleryDesc = galleryDesc + self.torrentFilenames = torrentFilenames + self.onlyWithTorrents = onlyWithTorrents + self.lowPowerTags = lowPowerTags + self.downvotedTags = downvotedTags + self.expungedGalleries = expungedGalleries + self.minRatingActivated = minRatingActivated + self.minRating = minRating + self.pageRangeActivated = pageRangeActivated + self.pageLowerBound = pageLowerBound + self.pageUpperBound = pageUpperBound + self.disableLanguage = disableLanguage + self.disableUploader = disableUploader + self.disableTags = disableTags + } + public var doujinshi = false + public var manga = false + public var artistCG = false + public var gameCG = false + public var western = false + public var nonH = false + public var imageSet = false + public var cosplay = false + public var asianPorn = false + public var misc = false - var advanced = false - var galleryName = true - var galleryTags = true - var galleryDesc = false - var torrentFilenames = false - var onlyWithTorrents = false - var lowPowerTags = false { + public var advanced = false + public var galleryName = true + public var galleryTags = true + public var galleryDesc = false + public var torrentFilenames = false + public var onlyWithTorrents = false + public var lowPowerTags = false { didSet { if lowPowerTags { downvotedTags = false } } } - var downvotedTags = false { + public var downvotedTags = false { didSet { if downvotedTags { lowPowerTags = false } } } - var expungedGalleries = false + public var expungedGalleries = false - var minRatingActivated = false - var minRating = 2 + public var minRatingActivated = false + public var minRating = 2 - var pageRangeActivated = false - var pageLowerBound = "" - var pageUpperBound = "" + public var pageRangeActivated = false + public var pageLowerBound = "" + public var pageUpperBound = "" - var disableLanguage = false - var disableUploader = false - var disableTags = false + public var disableLanguage = false + public var disableUploader = false + public var disableTags = false - mutating func fixInvalidData() { + public mutating func fixInvalidData() { if !pageLowerBound.isEmpty && Int(pageLowerBound) == nil { pageLowerBound = "" } @@ -57,7 +114,7 @@ struct Filter: Codable, Equatable { // MARK: Manually decode extension Filter { - init(from decoder: Decoder) { + public init(from decoder: Decoder) { let container = try? decoder.container(keyedBy: CodingKeys.self) doujinshi = (try? container?.decodeIfPresent(Bool.self, forKey: .doujinshi)) ?? false manga = (try? container?.decodeIfPresent(Bool.self, forKey: .manga)) ?? false diff --git a/AppPackage/Sources/AppFeature/Models/Persistent/Greeting.swift b/AppPackage/Sources/AppModels/Persistent/Greeting.swift similarity index 69% rename from AppPackage/Sources/AppFeature/Models/Persistent/Greeting.swift rename to AppPackage/Sources/AppModels/Persistent/Greeting.swift index 24aad0ac3..730918655 100644 --- a/AppPackage/Sources/AppFeature/Models/Persistent/Greeting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Greeting.swift @@ -1,8 +1,23 @@ import Foundation import Resources -struct Greeting: Codable, Equatable, Hashable, Identifiable { - static let mock: Self = { +public struct Greeting: Codable, Equatable, Hashable, Identifiable, Sendable { + public init( + id: UUID = UUID(), + gainedEXP: Int? = nil, + gainedCredits: Int? = nil, + gainedGP: Int? = nil, + gainedHath: Int? = nil, + updateTime: Date? = nil + ) { + self.id = id + self.gainedEXP = gainedEXP + self.gainedCredits = gainedCredits + self.gainedGP = gainedGP + self.gainedHath = gainedHath + self.updateTime = updateTime + } + public static let mock: Self = { var greeting = Greeting() greeting.gainedEXP = 10 greeting.gainedCredits = 10000 @@ -11,15 +26,15 @@ struct Greeting: Codable, Equatable, Hashable, Identifiable { return greeting }() - var id = UUID() + public var id = UUID() - var gainedEXP: Int? - var gainedCredits: Int? - var gainedGP: Int? - var gainedHath: Int? - var updateTime: Date? + public var gainedEXP: Int? + public var gainedCredits: Int? + public var gainedGP: Int? + public var gainedHath: Int? + public var updateTime: Date? - var rewards: [String] { + public var rewards: [String] { func formatNumber(_ number: Int?) -> String? { guard let number = number else { return nil } let formatter = NumberFormatter() @@ -43,7 +58,7 @@ struct Greeting: Codable, Equatable, Hashable, Identifiable { return rewards } - var gainContent: String? { + public var gainContent: String? { let rewards = rewards guard !rewards.isEmpty else { return nil } let and = L10n.Localizable.Struct.Greeting.Mark.and @@ -63,7 +78,7 @@ struct Greeting: Codable, Equatable, Hashable, Identifiable { return [start, rewardDescription, end].joined() } - var gainedNothing: Bool { + public var gainedNothing: Bool { [gainedEXP, gainedCredits, gainedGP, gainedHath] .compactMap({ $0 }).isEmpty } diff --git a/AppPackage/Sources/AppFeature/Models/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift similarity index 57% rename from AppPackage/Sources/AppFeature/Models/Persistent/Setting.swift rename to AppPackage/Sources/AppModels/Persistent/Setting.swift index c11659a56..fef56432f 100644 --- a/AppPackage/Sources/AppFeature/Models/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -3,13 +3,66 @@ import Resources import Foundation import ComposableArchitecture -struct Setting: Codable, Equatable { +public struct Setting: Codable, Equatable, Sendable { + public init( + galleryHost: GalleryHost = .ehentai, + showsNewDawnGreeting: Bool = false, + enablesTagsExtension: Bool = false, + translatesTags: Bool = false, + showsTagsSearchSuggestion: Bool = false, + showsImagesInTags: Bool = false, + redirectsLinksToSelectedHost: Bool = false, + detectsLinksFromClipboard: Bool = false, + backgroundBlurRadius: Double = 10, + autoLockPolicy: AutoLockPolicy = .never, + listDisplayMode: ListDisplayMode = .detail, + accentColor: Color = .blue, + appIconType: AppIconType = .default, + showsTagsInList: Bool = false, + listTagsNumberMaximum: Int = 0, + displaysJapaneseTitle: Bool = true, + readingDirection: ReadingDirection = .vertical, + prefetchLimit: Int = 10, + enablesLandscape: Bool = false, + enablesDualPageMode: Bool = false, + exceptCover: Bool = false, + contentDividerHeight: Double = 0, + maximumScaleFactor: Double = 3, + doubleTapScaleFactor: Double = 2, + bypassesSNIFiltering: Bool = false + ) { + self.galleryHost = galleryHost + self.showsNewDawnGreeting = showsNewDawnGreeting + self.enablesTagsExtension = enablesTagsExtension + self.translatesTags = translatesTags + self.showsTagsSearchSuggestion = showsTagsSearchSuggestion + self.showsImagesInTags = showsImagesInTags + self.redirectsLinksToSelectedHost = redirectsLinksToSelectedHost + self.detectsLinksFromClipboard = detectsLinksFromClipboard + self.backgroundBlurRadius = backgroundBlurRadius + self.autoLockPolicy = autoLockPolicy + self.listDisplayMode = listDisplayMode + self.accentColor = accentColor + self.appIconType = appIconType + self.showsTagsInList = showsTagsInList + self.listTagsNumberMaximum = listTagsNumberMaximum + self.displaysJapaneseTitle = displaysJapaneseTitle + self.readingDirection = readingDirection + self.prefetchLimit = prefetchLimit + self.enablesLandscape = enablesLandscape + self.enablesDualPageMode = enablesDualPageMode + self.exceptCover = exceptCover + self.contentDividerHeight = contentDividerHeight + self.maximumScaleFactor = maximumScaleFactor + self.doubleTapScaleFactor = doubleTapScaleFactor + self.bypassesSNIFiltering = bypassesSNIFiltering + } // Account - var galleryHost: GalleryHost = .ehentai - var showsNewDawnGreeting = false + public var galleryHost: GalleryHost = .ehentai + public var showsNewDawnGreeting = false // General - var enablesTagsExtension = false { + public var enablesTagsExtension = false { didSet { if !enablesTagsExtension { translatesTags = false @@ -18,48 +71,48 @@ struct Setting: Codable, Equatable { } } } - var translatesTags = false - var showsTagsSearchSuggestion = false - var showsImagesInTags = false - var redirectsLinksToSelectedHost = false - var detectsLinksFromClipboard = false - var backgroundBlurRadius: Double = 10 - var autoLockPolicy: AutoLockPolicy = .never + public var translatesTags = false + public var showsTagsSearchSuggestion = false + public var showsImagesInTags = false + public var redirectsLinksToSelectedHost = false + public var detectsLinksFromClipboard = false + public var backgroundBlurRadius: Double = 10 + public var autoLockPolicy: AutoLockPolicy = .never // Appearance - var listDisplayMode: ListDisplayMode = .detail - var preferredColorScheme = PreferredColorScheme.automatic - var accentColor: Color = .blue - var appIconType: AppIconType = .default - var showsTagsInList = false - var listTagsNumberMaximum = 0 - var displaysJapaneseTitle = true + public var listDisplayMode: ListDisplayMode = .detail + public var preferredColorScheme = PreferredColorScheme.automatic + public var accentColor: Color = .blue + public var appIconType: AppIconType = .default + public var showsTagsInList = false + public var listTagsNumberMaximum = 0 + public var displaysJapaneseTitle = true // Reading - var readingDirection: ReadingDirection = .vertical - var prefetchLimit = 10 - var enablesLandscape = false - var enablesDualPageMode = false - var exceptCover = false - var contentDividerHeight: Double = 0 - var maximumScaleFactor: Double = 3 - var doubleTapScaleFactor: Double = 2 + public var readingDirection: ReadingDirection = .vertical + public var prefetchLimit = 10 + public var enablesLandscape = false + public var enablesDualPageMode = false + public var exceptCover = false + public var contentDividerHeight: Double = 0 + public var maximumScaleFactor: Double = 3 + public var doubleTapScaleFactor: Double = 2 // Downloads - static let downloadThreadLimitDefaultValue = 1 - static let downloadAllowCellularDefaultValue = true - static let downloadAutoRetryFailedPagesDefaultValue = true + public static let downloadThreadLimitDefaultValue = 1 + public static let downloadAllowCellularDefaultValue = true + public static let downloadAutoRetryFailedPagesDefaultValue = true - var downloadThreadLimit = Self.downloadThreadLimitDefaultValue - var downloadAllowCellular = Self.downloadAllowCellularDefaultValue - var downloadAutoRetryFailedPages = Self.downloadAutoRetryFailedPagesDefaultValue + public var downloadThreadLimit = Self.downloadThreadLimitDefaultValue + public var downloadAllowCellular = Self.downloadAllowCellularDefaultValue + public var downloadAutoRetryFailedPages = Self.downloadAutoRetryFailedPagesDefaultValue // Laboratory - var bypassesSNIFiltering = false + public var bypassesSNIFiltering = false } extension Setting { - var downloadRequestOptions: DownloadRequestOptions { + public var downloadRequestOptions: DownloadRequestOptions { .init( threadLimit: downloadThreadLimit, allowCellular: downloadAllowCellular, @@ -68,12 +121,12 @@ extension Setting { } } -enum GalleryHost: String, Codable, Equatable, CaseIterable, Identifiable, Sendable { +public enum GalleryHost: String, Codable, Equatable, CaseIterable, Identifiable, Sendable { case ehentai = "E-Hentai" case exhentai = "ExHentai" - var id: Int { hashValue } - var url: URL { + public var id: Int { hashValue } + public var url: URL { switch self { case .ehentai: return Defaults.URL.ehentai @@ -81,7 +134,7 @@ enum GalleryHost: String, Codable, Equatable, CaseIterable, Identifiable, Sendab return Defaults.URL.exhentai } } - var cookieURLs: [URL] { + public var cookieURLs: [URL] { switch self { case .ehentai: return [Defaults.URL.ehentai] @@ -90,7 +143,7 @@ enum GalleryHost: String, Codable, Equatable, CaseIterable, Identifiable, Sendab return [Defaults.URL.exhentai, Defaults.URL.sexhentai] } } - var abbr: String { + public var abbr: String { switch self { case .ehentai: return "eh" @@ -100,8 +153,8 @@ enum GalleryHost: String, Codable, Equatable, CaseIterable, Identifiable, Sendab } } -enum AutoLockPolicy: Int, Codable, CaseIterable, Identifiable { - var id: Int { rawValue } +public enum AutoLockPolicy: Int, Codable, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } case never = -1 case instantly = 0 @@ -113,7 +166,7 @@ enum AutoLockPolicy: Int, Codable, CaseIterable, Identifiable { } extension AutoLockPolicy { - var value: String { + public var value: String { switch self { case .never: return L10n.Localizable.Enum.AutoLockPolicy.Value.never @@ -129,15 +182,15 @@ extension AutoLockPolicy { } } -enum PreferredColorScheme: Int, Codable, CaseIterable, Identifiable { - var id: Int { rawValue } +public enum PreferredColorScheme: Int, Codable, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } case automatic case light case dark } extension PreferredColorScheme { - var value: String { + public var value: String { switch self { case .automatic: return L10n.Localizable.Enum.PreferredColorScheme.Value.automatic @@ -147,7 +200,7 @@ extension PreferredColorScheme { return L10n.Localizable.Enum.PreferredColorScheme.Value.dark } } - var userInterfaceStyle: UIUserInterfaceStyle { + public var userInterfaceStyle: UIUserInterfaceStyle { switch self { case .automatic: return .unspecified @@ -159,15 +212,15 @@ extension PreferredColorScheme { } } -enum ReadingDirection: Int, Codable, CaseIterable, Identifiable { - var id: Int { rawValue } +public enum ReadingDirection: Int, Codable, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } case vertical case rightToLeft case leftToRight } extension ReadingDirection { - var value: String { + public var value: String { switch self { case .vertical: return L10n.Localizable.Enum.ReadingDirection.Value.vertical @@ -179,14 +232,14 @@ extension ReadingDirection { } } -enum ListDisplayMode: Int, Codable, CaseIterable, Identifiable { - var id: Int { rawValue } +public enum ListDisplayMode: Int, Codable, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } case detail case thumbnail } extension ListDisplayMode { - var value: String { + public var value: String { switch self { case .detail: return L10n.Localizable.Enum.ListDisplayMode.Value.detail @@ -200,7 +253,7 @@ extension ListDisplayMode { // swiftlint:disable line_length // MARK: Manually decode extension Setting { - init(from decoder: Decoder) { + public init(from decoder: Decoder) { let container = try? decoder.container(keyedBy: CodingKeys.self) // Account galleryHost = (try? container?.decodeIfPresent(GalleryHost.self, forKey: .galleryHost)) ?? .ehentai diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift new file mode 100644 index 000000000..6d75b8b3f --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -0,0 +1,64 @@ +import Foundation +import Resources + +public struct User: Codable, Equatable, Sendable { + public init( + displayName: String? = nil, + avatarURL: URL? = nil, + apikey: String? = nil, + credits: String? = nil, + galleryPoints: String? = nil, + greeting: Greeting? = nil, + favoriteCategories: [Int: String]? = nil + ) { + self.displayName = displayName + self.avatarURL = avatarURL + self.apikey = apikey + self.credits = credits + self.galleryPoints = galleryPoints + self.greeting = greeting + self.favoriteCategories = favoriteCategories + } + public static let empty = User() + + public var displayName: String? + public var avatarURL: URL? + public var apikey: String? + + public var credits: String? + public var galleryPoints: String? + + public var greeting: Greeting? + + public var favoriteCategories: [Int: String]? + + public func getFavoriteCategory(index: Int) -> String { + guard index != -1 else { return L10n.Localizable.Struct.User.FavoriteCategory.all } + let defaultCategory = L10n.Localizable.Struct.User.FavoriteCategory.default("\(index)") + let category = favoriteCategories?[index] ?? defaultCategory + let isDefault = category == "Favorites \(index)" + return isDefault ? defaultCategory : category + } +} + +public enum FavoritesType: String, Codable, CaseIterable, Sendable { + public static func getTypeFrom(index: Int) -> FavoritesType { + FavoritesType.allCases.filter({ $0.index == index }).first ?? .all + } + + public var index: Int { + Int(rawValue.replacingOccurrences(of: "favorite_", with: "")) ?? -1 + } + + case all = "all" + case favorite0 = "favorite_0" + case favorite1 = "favorite_1" + case favorite2 = "favorite_2" + case favorite3 = "favorite_3" + case favorite4 = "favorite_4" + case favorite5 = "favorite_5" + case favorite6 = "favorite_6" + case favorite7 = "favorite_7" + case favorite8 = "favorite_8" + case favorite9 = "favorite_9" +} diff --git a/AppPackage/Sources/AppFeature/Models/Support/AppError.swift b/AppPackage/Sources/AppModels/Support/AppError.swift similarity index 94% rename from AppPackage/Sources/AppFeature/Models/Support/AppError.swift rename to AppPackage/Sources/AppModels/Support/AppError.swift index a2ac054d8..17649c784 100644 --- a/AppPackage/Sources/AppFeature/Models/Support/AppError.swift +++ b/AppPackage/Sources/AppModels/Support/AppError.swift @@ -2,10 +2,10 @@ import Foundation import Resources import SFSafeSymbols -enum AppError: Error, Identifiable, Equatable, Hashable, Sendable { - var id: String { localizedDescription } +public enum AppError: Error, Identifiable, Equatable, Hashable, Sendable { + public var id: String { localizedDescription } - init(_ error: any Error) { + public init(_ error: any Error) { self = error as? AppError ?? .unknown } @@ -25,7 +25,7 @@ enum AppError: Error, Identifiable, Equatable, Hashable, Sendable { } extension AppError { - var isRetryable: Bool { + public var isRetryable: Bool { switch self { case .databaseCorrupted, .networkingFailed, .parseFailed, .fileOperationFailed, .noUpdates, .unknown, .webImageFailed: @@ -35,7 +35,7 @@ extension AppError { return false } } - var localizedDescription: String { + public var localizedDescription: String { switch self { case .databaseCorrupted: return L10n.Localizable.AppError.LocalizedDescription.databaseCorrupted @@ -65,7 +65,7 @@ extension AppError { return L10n.Localizable.AppError.LocalizedDescription.unknownError } } - var symbol: SFSymbol { + public var symbol: SFSymbol { switch self { case .databaseCorrupted: return .exclamationmarkTriangleFill @@ -87,7 +87,7 @@ extension AppError { return .questionmarkCircleFill } } - var alertText: String { + public var alertText: String { let tryLater = L10n.Localizable.ErrorView.Title.tryLater switch self { case .databaseCorrupted(let reason): @@ -129,7 +129,7 @@ extension AppError { } } -enum BanInterval: Equatable, Hashable { +public enum BanInterval: Equatable, Hashable, Sendable { case days(_: Int, hours: Int?) case hours(_: Int, minutes: Int?) case minutes(_: Int, seconds: Int?) @@ -137,7 +137,7 @@ enum BanInterval: Equatable, Hashable { } extension BanInterval { - var description: String { + public var description: String { var params: [String] let and = L10n.Localizable.Enum.BanInterval.Description.and diff --git a/AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry+EnglishName.swift b/AppPackage/Sources/AppModels/Support/BrowsingCountry+EnglishName.swift similarity index 99% rename from AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry+EnglishName.swift rename to AppPackage/Sources/AppModels/Support/BrowsingCountry+EnglishName.swift index f5be9c3f3..a5527f9e2 100644 --- a/AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry+EnglishName.swift +++ b/AppPackage/Sources/AppModels/Support/BrowsingCountry+EnglishName.swift @@ -1,5 +1,5 @@ extension EhSetting.BrowsingCountry { - var englishName: String { + public var englishName: String { switch self { case .autoDetect: return "Auto-Detect" case .afghanistan: return "Afghanistan" diff --git a/AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry.swift b/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift similarity index 99% rename from AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry.swift rename to AppPackage/Sources/AppModels/Support/BrowsingCountry.swift index 38002e347..bc0741c2a 100644 --- a/AppPackage/Sources/AppFeature/Models/Support/BrowsingCountry.swift +++ b/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift @@ -4,13 +4,13 @@ import Resources // reason: the exhaustive ISO country list is kept dense, one case per line // swiftlint:disable line_length extension EhSetting { - enum BrowsingCountry: String, CaseIterable, Identifiable, Equatable { + public enum BrowsingCountry: String, CaseIterable, Identifiable, Equatable, Sendable { case autoDetect = "-"; case afghanistan = "AF"; case alandIslands = "AX"; case albania = "AL"; case algeria = "DZ"; case americanSamoa = "AS"; case andorra = "AD"; case angola = "AO"; case anguilla = "AI"; case antarctica = "AQ"; case antiguaAndBarbuda = "AG"; case argentina = "AR"; case armenia = "AM"; case aruba = "AW"; case asiaPacificRegion = "AP"; case australia = "AU"; case austria = "AT"; case azerbaijan = "AZ"; case bahamas = "BS"; case bahrain = "BH"; case bangladesh = "BD"; case barbados = "BB"; case belarus = "BY"; case belgium = "BE"; case belize = "BZ"; case benin = "BJ"; case bermuda = "BM"; case bhutan = "BT"; case bolivia = "BO"; case bonaireSaintEustatiusAndSaba = "BQ"; case bosniaAndHerzegovina = "BA"; case botswana = "BW"; case bouvetIsland = "BV"; case brazil = "BR"; case britishIndianOceanTerritory = "IO"; case bruneiDarussalam = "BN"; case bulgaria = "BG"; case burkinaFaso = "BF"; case burundi = "BI"; case cambodia = "KH"; case cameroon = "CM"; case canada = "CA"; case capeVerde = "CV"; case caymanIslands = "KY"; case centralAfricanRepublic = "CF"; case chad = "TD"; case chile = "CL"; case china = "CN"; case christmasIsland = "CX"; case cocosIslands = "CC"; case colombia = "CO"; case comoros = "KM"; case congo = "CG"; case theDemocraticRepublicOfTheCongo = "CD"; case cookIslands = "CK"; case costaRica = "CR"; case coteDIvoire = "CI"; case croatia = "HR"; case cuba = "CU"; case curacao = "CW"; case cyprus = "CY"; case czechRepublic = "CZ"; case denmark = "DK"; case djibouti = "DJ"; case dominica = "DM"; case dominicanRepublic = "DO"; case ecuador = "EC"; case egypt = "EG"; case elSalvador = "SV"; case equatorialGuinea = "GQ"; case eritrea = "ER"; case estonia = "EE"; case ethiopia = "ET"; case europe = "EU"; case falklandIslands = "FK"; case faroeIslands = "FO"; case fiji = "FJ"; case finland = "FI"; case france = "FR"; case frenchGuiana = "GF"; case frenchPolynesia = "PF"; case frenchSouthernTerritories = "TF"; case gabon = "GA"; case gambia = "GM"; case georgia = "GE"; case germany = "DE"; case ghana = "GH"; case gibraltar = "GI"; case greece = "GR"; case greenland = "GL"; case grenada = "GD"; case guadeloupe = "GP"; case guam = "GU"; case guatemala = "GT"; case guernsey = "GG"; case guinea = "GN"; case guineaBissau = "GW"; case guyana = "GY"; case haiti = "HT"; case heardIslandAndMcDonaldIslands = "HM"; case vaticanCityState = "VA"; case honduras = "HN"; case hongKong = "HK"; case hungary = "HU"; case iceland = "IS"; case india = "IN"; case indonesia = "ID"; case iran = "IR"; case iraq = "IQ"; case ireland = "IE"; case isleOfMan = "IM"; case israel = "IL"; case italy = "IT"; case jamaica = "JM"; case japan = "JP"; case jersey = "JE"; case jordan = "JO"; case kazakhstan = "KZ"; case kenya = "KE"; case kiribati = "KI"; case kuwait = "KW"; case kyrgyzstan = "KG"; case laoPeoplesDemocraticRepublic = "LA"; case latvia = "LV"; case lebanon = "LB"; case lesotho = "LS"; case liberia = "LR"; case libya = "LY"; case liechtenstein = "LI"; case lithuania = "LT"; case luxembourg = "LU"; case macau = "MO"; case macedonia = "MK"; case madagascar = "MG"; case malawi = "MW"; case malaysia = "MY"; case maldives = "MV"; case mali = "ML"; case malta = "MT"; case marshallIslands = "MH"; case martinique = "MQ"; case mauritania = "MR"; case mauritius = "MU"; case mayotte = "YT"; case mexico = "MX"; case micronesia = "FM"; case moldova = "MD"; case monaco = "MC"; case mongolia = "MN"; case montenegro = "ME"; case montserrat = "MS"; case morocco = "MA"; case mozambique = "MZ"; case myanmar = "MM"; case namibia = "NA"; case nauru = "NR"; case nepal = "NP"; case netherlands = "NL"; case newCaledonia = "NC"; case newZealand = "NZ"; case nicaragua = "NI"; case niger = "NE"; case nigeria = "NG"; case niue = "NU"; case norfolkIsland = "NF"; case northKorea = "KP"; case northernMarianaIslands = "MP"; case norway = "NO"; case oman = "OM"; case pakistan = "PK"; case palau = "PW"; case palestinianTerritory = "PS"; case panama = "PA"; case papuaNewGuinea = "PG"; case paraguay = "PY"; case peru = "PE"; case philippines = "PH"; case pitcairnIslands = "PN"; case poland = "PL"; case portugal = "PT"; case puertoRico = "PR"; case qatar = "QA"; case reunion = "RE"; case romania = "RO"; case russianFederation = "RU"; case rwanda = "RW"; case saintBarthelemy = "BL"; case saintHelena = "SH"; case saintKittsAndNevis = "KN"; case saintLucia = "LC"; case saintMartin = "MF"; case saintPierreAndMiquelon = "PM"; case saintVincentAndTheGrenadines = "VC"; case samoa = "WS"; case sanMarino = "SM"; case saoTomeAndPrincipe = "ST"; case saudiArabia = "SA"; case senegal = "SN"; case serbia = "RS"; case seychelles = "SC"; case sierraLeone = "SL"; case singapore = "SG"; case sintMaarten = "SX"; case slovakia = "SK"; case slovenia = "SI"; case solomonIslands = "SB"; case somalia = "SO"; case southAfrica = "ZA"; case southGeorgiaAndTheSouthSandwichIslands = "GS"; case southKorea = "KR"; case southSudan = "SS"; case spain = "ES"; case sriLanka = "LK"; case sudan = "SD"; case suriname = "SR"; case svalbardAndJanMayen = "SJ"; case swaziland = "SZ"; case sweden = "SE"; case switzerland = "CH"; case syrianArabRepublic = "SY"; case taiwan = "TW"; case tajikistan = "TJ"; case tanzania = "TZ"; case thailand = "TH"; case timorLeste = "TL"; case togo = "TG"; case tokelau = "TK"; case tonga = "TO"; case trinidadAndTobago = "TT"; case tunisia = "TN"; case turkey = "TR"; case turkmenistan = "TM"; case turksAndCaicosIslands = "TC"; case tuvalu = "TV"; case uganda = "UG"; case ukraine = "UA"; case unitedArabEmirates = "AE"; case unitedKingdom = "GB"; case unitedStates = "US"; case unitedStatesMinorOutlyingIslands = "UM"; case uruguay = "UY"; case uzbekistan = "UZ"; case vanuatu = "VU"; case venezuela = "VE"; case vietnam = "VN"; case virginIslandsBritish = "VG"; case virginIslandsUS = "VI"; case wallisAndFutuna = "WF"; case westernSahara = "EH"; case yemen = "YE"; case zambia = "ZM"; case zimbabwe = "ZW" } } extension EhSetting.BrowsingCountry { - var id: Int { hashValue } - var name: String { + public var id: Int { hashValue } + public var name: String { switch self { case .autoDetect: return L10n.Localizable.Enum.BrowsingCountry.Name.autoDetect case .afghanistan: return L10n.Localizable.Enum.BrowsingCountry.Name.afghanistan diff --git a/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Enums.swift b/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift similarity index 77% rename from AppPackage/Sources/AppFeature/Models/Support/EhSetting+Enums.swift rename to AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift index 178ddc2d3..5ce6a494e 100644 --- a/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Enums.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift @@ -2,16 +2,16 @@ import Resources // MARK: CommentsSortOrder extension EhSetting { - enum CommentsSortOrder: Int, CaseIterable, Identifiable { + public enum CommentsSortOrder: Int, CaseIterable, Identifiable, Sendable { case oldest case recent case highestScore } } extension EhSetting.CommentsSortOrder { - var id: Int { rawValue } + public var id: Int { rawValue } - var value: String { + public var value: String { switch self { case .oldest: return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.oldest @@ -25,15 +25,15 @@ extension EhSetting.CommentsSortOrder { // MARK: CommentVotesShowTiming extension EhSetting { - enum CommentVotesShowTiming: Int, CaseIterable, Identifiable { + public enum CommentVotesShowTiming: Int, CaseIterable, Identifiable, Sendable { case onHoverOrClick case always } } extension EhSetting.CommentVotesShowTiming { - var id: Int { rawValue } + public var id: Int { rawValue } - var value: String { + public var value: String { switch self { case .onHoverOrClick: return L10n.Localizable.Enum.EhSetting.CommentsVotesShowTiming.Value.onHoverOrClick @@ -45,15 +45,15 @@ extension EhSetting.CommentVotesShowTiming { // MARK: TagsSortOrder extension EhSetting { - enum TagsSortOrder: Int, CaseIterable, Identifiable { + public enum TagsSortOrder: Int, CaseIterable, Identifiable, Sendable { case alphabetical case tagPower } } extension EhSetting.TagsSortOrder { - var id: Int { rawValue } + public var id: Int { rawValue } - var value: String { + public var value: String { switch self { case .alphabetical: return L10n.Localizable.Enum.EhSetting.TagsSortOrder.Value.alphabetical @@ -65,16 +65,16 @@ extension EhSetting.TagsSortOrder { // MARK: MultiplePageViewerStyle extension EhSetting { - enum MultiplePageViewerStyle: Int, CaseIterable, Identifiable { + public enum MultiplePageViewerStyle: Int, CaseIterable, Identifiable, Sendable { case alignLeftScaleIfOverWidth case alignCenterScaleIfOverWidth case alignCenterAlwaysScale } } extension EhSetting.MultiplePageViewerStyle { - var id: Int { rawValue } + public var id: Int { rawValue } - var value: String { + public var value: String { switch self { case .alignLeftScaleIfOverWidth: return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignLeftScaleIfOverWidth @@ -88,16 +88,16 @@ extension EhSetting.MultiplePageViewerStyle { // MARK: GalleryPageNumbering extension EhSetting { - enum GalleryPageNumbering: Int, CaseIterable, Identifiable { + public enum GalleryPageNumbering: Int, CaseIterable, Identifiable, Sendable { case none case pageNumberOnly case pageNumberAndName } } extension EhSetting.GalleryPageNumbering { - var id: Int { rawValue } + public var id: Int { rawValue } - var value: String { + public var value: String { switch self { case .none: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.none case .pageNumberOnly: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.pageNumberOnly diff --git a/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Extensions.swift b/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift similarity index 73% rename from AppPackage/Sources/AppFeature/Models/Support/EhSetting+Extensions.swift rename to AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift index e2360aefc..5723fa4bf 100644 --- a/AppPackage/Sources/AppFeature/Models/Support/EhSetting+Extensions.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift @@ -2,15 +2,15 @@ import Resources // MARK: ThumbnailLoadTiming extension EhSetting { - enum ThumbnailLoadTiming: Int, CaseIterable, Identifiable { + public enum ThumbnailLoadTiming: Int, CaseIterable, Identifiable, Sendable { case onMouseOver case onPageLoad } } extension EhSetting.ThumbnailLoadTiming { - var id: Int { rawValue } + public var id: Int { rawValue } - var value: String { + public var value: String { switch self { case .onMouseOver: return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Value.onMouseOver @@ -18,7 +18,7 @@ extension EhSetting.ThumbnailLoadTiming { return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Value.onPageLoad } } - var description: String { + public var description: String { switch self { case .onMouseOver: return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Description.onMouseOver @@ -30,7 +30,7 @@ extension EhSetting.ThumbnailLoadTiming { // MARK: ThumbnailSize extension EhSetting { - enum ThumbnailSize: Int, CaseIterable, Identifiable, Comparable { + public enum ThumbnailSize: Int, CaseIterable, Identifiable, Comparable, Sendable { case auto case small case normal @@ -39,12 +39,12 @@ extension EhSetting { } } extension EhSetting.ThumbnailSize { - var id: Int { rawValue } - static func < (lhs: Self, rhs: Self) -> Bool { + public var id: Int { rawValue } + public static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } - var value: String { + public var value: String { switch self { case .normal: return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.normal @@ -60,7 +60,7 @@ extension EhSetting.ThumbnailSize { // MARK: ThumbnailRowCount extension EhSetting { - enum ThumbnailRowCount: Int, CaseIterable, Identifiable, Comparable { + public enum ThumbnailRowCount: Int, CaseIterable, Identifiable, Comparable, Sendable { case four case ten case twenty @@ -68,12 +68,12 @@ extension EhSetting { } } extension EhSetting.ThumbnailRowCount { - var id: Int { rawValue } - static func < (lhs: Self, rhs: Self) -> Bool { + public var id: Int { rawValue } + public static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } - var value: String { + public var value: String { switch self { case .four: "4" case .ten: "8" diff --git a/AppPackage/Sources/AppModels/Support/EhSetting.swift b/AppPackage/Sources/AppModels/Support/EhSetting.swift new file mode 100644 index 000000000..2cdc0664b --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/EhSetting.swift @@ -0,0 +1,445 @@ +import Resources + +// MARK: EhSetting +public struct EhSetting: Equatable, Sendable { + public init( + ehProfiles: [EhProfile], + isCapableOfCreatingNewProfile: Bool, + capableLoadThroughHathSetting: LoadThroughHathSetting, + capableImageResolution: ImageResolution, + capableSearchResultCount: SearchResultCount, + capableThumbnailConfigRowCount: ThumbnailRowCount, + capableThumbnailConfigSizes: [ThumbnailSize], + loadThroughHathSetting: LoadThroughHathSetting, + browsingCountry: BrowsingCountry, + literalBrowsingCountry: String, + imageResolution: ImageResolution, + imageSizeWidth: Float, + imageSizeHeight: Float, + galleryName: GalleryName, + archiverBehavior: ArchiverBehavior, + displayMode: DisplayMode, + showSearchRangeIndicator: Bool, + enableGalleryThumbnailSelector: Bool, + disabledCategories: [Bool], + favoriteCategories: [String], + favoritesSortOrder: FavoritesSortOrder, + ratingsColor: String, + tagFilteringThreshold: Float, + tagWatchingThreshold: Float, + showFilteredRemovalCount: Bool, + excludedLanguages: [Bool], + excludedUploaders: String, + searchResultCount: SearchResultCount, + thumbnailLoadTiming: ThumbnailLoadTiming, + thumbnailConfigSize: ThumbnailSize, + thumbnailConfigRows: ThumbnailRowCount, + coverScaleFactor: Float, + viewportVirtualWidth: Float, + commentsSortOrder: CommentsSortOrder, + commentVotesShowTiming: CommentVotesShowTiming, + tagsSortOrder: TagsSortOrder, + galleryPageNumbering: GalleryPageNumbering, + useOriginalImages: Bool? = nil, + useMultiplePageViewer: Bool? = nil, + multiplePageViewerStyle: MultiplePageViewerStyle? = nil, + multiplePageViewerShowThumbnailPane: Bool? = nil + ) { + self.ehProfiles = ehProfiles + self.isCapableOfCreatingNewProfile = isCapableOfCreatingNewProfile + self.capableLoadThroughHathSetting = capableLoadThroughHathSetting + self.capableImageResolution = capableImageResolution + self.capableSearchResultCount = capableSearchResultCount + self.capableThumbnailConfigRowCount = capableThumbnailConfigRowCount + self.capableThumbnailConfigSizes = capableThumbnailConfigSizes + self.loadThroughHathSetting = loadThroughHathSetting + self.browsingCountry = browsingCountry + self.literalBrowsingCountry = literalBrowsingCountry + self.imageResolution = imageResolution + self.imageSizeWidth = imageSizeWidth + self.imageSizeHeight = imageSizeHeight + self.galleryName = galleryName + self.archiverBehavior = archiverBehavior + self.displayMode = displayMode + self.showSearchRangeIndicator = showSearchRangeIndicator + self.enableGalleryThumbnailSelector = enableGalleryThumbnailSelector + self.disabledCategories = disabledCategories + self.favoriteCategories = favoriteCategories + self.favoritesSortOrder = favoritesSortOrder + self.ratingsColor = ratingsColor + self.tagFilteringThreshold = tagFilteringThreshold + self.tagWatchingThreshold = tagWatchingThreshold + self.showFilteredRemovalCount = showFilteredRemovalCount + self.excludedLanguages = excludedLanguages + self.excludedUploaders = excludedUploaders + self.searchResultCount = searchResultCount + self.thumbnailLoadTiming = thumbnailLoadTiming + self.thumbnailConfigSize = thumbnailConfigSize + self.thumbnailConfigRows = thumbnailConfigRows + self.coverScaleFactor = coverScaleFactor + self.viewportVirtualWidth = viewportVirtualWidth + self.commentsSortOrder = commentsSortOrder + self.commentVotesShowTiming = commentVotesShowTiming + self.tagsSortOrder = tagsSortOrder + self.galleryPageNumbering = galleryPageNumbering + self.useOriginalImages = useOriginalImages + self.useMultiplePageViewer = useMultiplePageViewer + self.multiplePageViewerStyle = multiplePageViewerStyle + self.multiplePageViewerShowThumbnailPane = multiplePageViewerShowThumbnailPane + } + // swiftlint:disable line_length + public static let empty: Self = .init(ehProfiles: [.empty], isCapableOfCreatingNewProfile: true, capableLoadThroughHathSetting: .anyClient, capableImageResolution: .auto, capableSearchResultCount: .fifty, capableThumbnailConfigRowCount: .forty, capableThumbnailConfigSizes: [], loadThroughHathSetting: .anyClient, browsingCountry: .autoDetect, literalBrowsingCountry: "", imageResolution: .auto, imageSizeWidth: 0, imageSizeHeight: 0, galleryName: .default, archiverBehavior: .autoSelectOriginalAutoStart, displayMode: .compact, showSearchRangeIndicator: true, enableGalleryThumbnailSelector: false, disabledCategories: Array(repeating: false, count: 10), favoriteCategories: Array(repeating: "", count: 10), favoritesSortOrder: .favoritedTime, ratingsColor: "", tagFilteringThreshold: 0, tagWatchingThreshold: 0, showFilteredRemovalCount: true, excludedLanguages: Array(repeating: false, count: 50), excludedUploaders: "", searchResultCount: .fifty, thumbnailLoadTiming: .onPageLoad, thumbnailConfigSize: .normal, thumbnailConfigRows: .ten, coverScaleFactor: 0, viewportVirtualWidth: 0, commentsSortOrder: .recent, commentVotesShowTiming: .always, tagsSortOrder: .alphabetical, galleryPageNumbering: .none) + // swiftlint:enable line_length + + public static let categoryNames = Category.allFiltersCases.map(\.rawValue).map { value in + value.lowercased().replacingOccurrences(of: " ", with: "") + } + public static let languageValues = [ + 1024, 2048, 1, 1025, 2049, 10, 1034, 2058, + 20, 1044, 2068, 30, 1054, 2078, 40, 1064, 2088, + 50, 1074, 2098, 60, 1084, 2108, 70, 1094, 2118, + 80, 1104, 2128, 90, 1114, 2138, 100, 1124, 2148, + 110, 1134, 2158, 120, 1144, 2168, 130, 1154, 2178, + 254, 1278, 2302, 255, 1279, 2303 + ] + + public let ehProfiles: [EhProfile] + public var ehpandaProfile: EhProfile? { + ehProfiles.filter({ EhSetting.verifyEhPandaProfileName(with: $0.name) }).first + } + public static func verifyEhPandaProfileName(with name: String?) -> Bool { + ["EhPanda", "EhPanda (Default)"].contains(name ?? "") + } + + public let isCapableOfCreatingNewProfile: Bool + public let capableLoadThroughHathSetting: LoadThroughHathSetting + public let capableImageResolution: ImageResolution + public let capableSearchResultCount: SearchResultCount + public let capableThumbnailConfigRowCount: ThumbnailRowCount + public let capableThumbnailConfigSizes: [ThumbnailSize] + + public var capableLoadThroughHathSettings: [LoadThroughHathSetting] { + LoadThroughHathSetting.allCases.filter { setting in + setting <= capableLoadThroughHathSetting + } + } + public var capableImageResolutions: [ImageResolution] { + ImageResolution.allCases.filter { resolution in + resolution <= capableImageResolution + } + } + public var capableSearchResultCounts: [SearchResultCount] { + SearchResultCount.allCases.filter { count in + count <= capableSearchResultCount + } + } + public var capableThumbnailConfigRowCounts: [ThumbnailRowCount] { + ThumbnailRowCount.allCases.filter { row in + row <= capableThumbnailConfigRowCount + } + } + public var localizedLiteralBrowsingCountry: String? { + BrowsingCountry.allCases.first(where: { $0.englishName == literalBrowsingCountry })?.name + } + + public var loadThroughHathSetting: LoadThroughHathSetting + public var browsingCountry: BrowsingCountry + public let literalBrowsingCountry: String + public var imageResolution: ImageResolution + public var imageSizeWidth: Float + public var imageSizeHeight: Float + public var galleryName: GalleryName + public var archiverBehavior: ArchiverBehavior + public var displayMode: DisplayMode + public var showSearchRangeIndicator: Bool + public var enableGalleryThumbnailSelector: Bool + public var disabledCategories: [Bool] + public var favoriteCategories: [String] + public var favoritesSortOrder: FavoritesSortOrder + public var ratingsColor: String + public var tagFilteringThreshold: Float + public var tagWatchingThreshold: Float + public var showFilteredRemovalCount: Bool + public var excludedLanguages: [Bool] + public var excludedUploaders: String + public var searchResultCount: SearchResultCount + public var thumbnailLoadTiming: ThumbnailLoadTiming + public var thumbnailConfigSize: ThumbnailSize + public var thumbnailConfigRows: ThumbnailRowCount + public var coverScaleFactor: Float + public var viewportVirtualWidth: Float + public var commentsSortOrder: CommentsSortOrder + public var commentVotesShowTiming: CommentVotesShowTiming + public var tagsSortOrder: TagsSortOrder + public var galleryPageNumbering: GalleryPageNumbering + public var useOriginalImages: Bool? + public var useMultiplePageViewer: Bool? + public var multiplePageViewerStyle: MultiplePageViewerStyle? + public var multiplePageViewerShowThumbnailPane: Bool? +} + +// MARK: EhProfile +public struct EhProfile: Comparable, Identifiable, Hashable, Sendable { + public init( + value: Int, + name: String, + isSelected: Bool + ) { + self.value = value + self.name = name + self.isSelected = isSelected + } + public static let empty: Self = .init( + value: 0, name: "", isSelected: true + ) + public static func < (lhs: EhProfile, rhs: EhProfile) -> Bool { + lhs.value < rhs.value + } + public var id: Int { value } + + public let value: Int + public let name: String + public let isSelected: Bool + public var isDefault: Bool { + value == 1 + } +} +public enum EhProfileAction: String, Sendable { + case create + case delete + case rename + case `default` +} + +// MARK: LoadThroughHathSetting +extension EhSetting { + public enum LoadThroughHathSetting: Int, CaseIterable, Identifiable, Comparable, Sendable { + case anyClient + case defaultPortOnly + case modernNo + case legacyNo + } +} +extension EhSetting.LoadThroughHathSetting { + public var id: Int { rawValue } + public static func < ( + lhs: EhSetting.LoadThroughHathSetting, + rhs: EhSetting.LoadThroughHathSetting + ) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public var value: String { + switch self { + case .anyClient: + return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.anyClient + case .defaultPortOnly: + return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.defaultPortOnly + case .modernNo: + return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.modernNo + case .legacyNo: + return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.legacyNo + } + } + public var description: String { + switch self { + case .anyClient: + return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.anyClient + case .defaultPortOnly: + return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.defaultPortOnly + case .modernNo: + return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.modernNo + case .legacyNo: + return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.legacyNo + } + } +} + +// MARK: ImageResolution +extension EhSetting { + public enum ImageResolution: Int, CaseIterable, Identifiable, Comparable, Codable, Sendable { + case auto + case x780 + /// Deprecated + case x980 + case x1280 + case x1600 + case x2400 + } +} +extension EhSetting.ImageResolution { + public var id: Int { rawValue } + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public var value: String { + switch self { + case .auto: + return L10n.Localizable.Enum.EhSetting.ImageResolution.Value.auto + case .x780: + return "780x" + case .x980: + return "980x" + case .x1280: + return "1280x" + case .x1600: + return "1600x" + case .x2400: + return "2400x" + } + } +} + +// MARK: GalleryName +extension EhSetting { + public enum GalleryName: Int, CaseIterable, Identifiable, Sendable { + case `default` + case japanese + } +} +extension EhSetting.GalleryName { + public var id: Int { rawValue } + + public var value: String { + switch self { + case .default: + return L10n.Localizable.Enum.EhSetting.GalleryName.Value.default + case .japanese: + return L10n.Localizable.Enum.EhSetting.GalleryName.Value.japanese + } + } +} + +// MARK: ArchiverBehavior +extension EhSetting { + public enum ArchiverBehavior: Int, CaseIterable, Identifiable, Sendable { + case manualSelectManualStart + case manualSelectAutoStart + case autoSelectOriginalManualStart + case autoSelectOriginalAutoStart + case autoSelectResampleManualStart + case autoSelectResampleAutoStart + } +} +extension EhSetting.ArchiverBehavior { + public var id: Int { rawValue } + + public var value: String { + switch self { + case .manualSelectManualStart: + return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.manualSelectManualStart + case .manualSelectAutoStart: + return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.manualSelectAutoStart + case .autoSelectOriginalManualStart: + return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectOriginalManualStart + case .autoSelectOriginalAutoStart: + return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectOriginalAutoStart + case .autoSelectResampleManualStart: + return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectResampleManualStart + case .autoSelectResampleAutoStart: + return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectResampleAutoStart + } + } +} + +// MARK: DisplayMode +extension EhSetting { + public enum DisplayMode: Int, CaseIterable, Identifiable, Sendable { + case compact + case thumbnail + case extended + case minimal + case minimalPlus + } +} +extension EhSetting.DisplayMode { + public var id: Int { rawValue } + + public var value: String { + switch self { + case .compact: + return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.compact + case .thumbnail: + return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.thumbnail + case .extended: + return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.extended + case .minimal: + return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.minimal + case .minimalPlus: + return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.minimalPlus + } + } +} + +// MARK: FavoritesSortOrder +extension EhSetting { + public enum FavoritesSortOrder: Int, CaseIterable, Identifiable, Sendable { + case lastUpdateTime + case favoritedTime + } +} +extension EhSetting.FavoritesSortOrder { + public var id: Int { rawValue } + + public var value: String { + switch self { + case .lastUpdateTime: + return L10n.Localizable.Enum.EhSetting.FavoritesSortOrder.Value.lastUpdateTime + case .favoritedTime: + return L10n.Localizable.Enum.EhSetting.FavoritesSortOrder.Value.favoritedTime + } + } +} + +// MARK: ExcludedLanguagesCategory +extension EhSetting { + public enum ExcludedLanguagesCategory: Int, Identifiable, CaseIterable, Sendable { + case original + case translated + case rewrite + } +} +extension EhSetting.ExcludedLanguagesCategory { + public var id: Int { rawValue } + + public var value: String { + switch self { + case .original: + return L10n.Localizable.Enum.EhSetting.ExcludedLanguagesCategory.Value.original + case .translated: + return L10n.Localizable.Enum.EhSetting.ExcludedLanguagesCategory.Value.translated + case .rewrite: + return L10n.Localizable.Enum.EhSetting.ExcludedLanguagesCategory.Value.rewrite + } + } +} + +// MARK: SearchResultCount +extension EhSetting { + public enum SearchResultCount: Int, CaseIterable, Identifiable, Comparable, Sendable { + case twentyFive + case fifty + case oneHundred + case twoHundred + } +} +extension EhSetting.SearchResultCount { + public var id: Int { rawValue } + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public var value: String { + switch self { + case .twentyFive: + return "25" + case .fifty: + return "50" + case .oneHundred: + return "100" + case .twoHundred: + return "200" + } + } +} diff --git a/AppPackage/Sources/AppFeature/Models/Support/LiveText.swift b/AppPackage/Sources/AppModels/Support/LiveText.swift similarity index 74% rename from AppPackage/Sources/AppFeature/Models/Support/LiveText.swift rename to AppPackage/Sources/AppModels/Support/LiveText.swift index f7dceffd8..7a4c7d75e 100644 --- a/AppPackage/Sources/AppFeature/Models/Support/LiveText.swift +++ b/AppPackage/Sources/AppModels/Support/LiveText.swift @@ -2,43 +2,43 @@ import SwiftUI import Foundation // MARK: LiveTextBounds -struct LiveTextBounds: Equatable, Sendable { - let topLeft: CGPoint - let topRight: CGPoint - let bottomLeft: CGPoint - let bottomRight: CGPoint +public struct LiveTextBounds: Equatable, Sendable { + public let topLeft: CGPoint + public let topRight: CGPoint + public let bottomLeft: CGPoint + public let bottomRight: CGPoint - var edges: [CGPoint] { + public var edges: [CGPoint] { [topLeft, topRight, bottomRight, bottomLeft] } - init(topLeft: CGPoint, topRight: CGPoint, bottomLeft: CGPoint, bottomRight: CGPoint) { + public init(topLeft: CGPoint, topRight: CGPoint, bottomLeft: CGPoint, bottomRight: CGPoint) { self.topLeft = topLeft self.topRight = topRight self.bottomLeft = bottomLeft self.bottomRight = bottomRight } - func expandingHalfHeight(_ size: CGSize) -> Self { + public func expandingHalfHeight(_ size: CGSize) -> Self { expanding(size: size, width: 0, height: getHeight(size) / 2) } - func getHeight(_ size: CGSize) -> Double { + public func getHeight(_ size: CGSize) -> Double { let topLeft = topLeft * size let bottomLeft = bottomLeft * size return abs(sqrt(pow(topLeft.x - bottomLeft.x, 2) + pow(topLeft.y - bottomLeft.y, 2))) } - func getWidth(_ size: CGSize) -> Double { + public func getWidth(_ size: CGSize) -> Double { let topLeft = topLeft * size let topRight = topRight * size return abs(sqrt(pow(topLeft.x - topRight.x, 2) + pow(topLeft.y - topRight.y, 2))) } - func getRadian(_ size: CGSize) -> Double { + public func getRadian(_ size: CGSize) -> Double { let topLeft = topLeft * size let topRight = topRight * size let radian = atan2(topRight.y - topLeft.y, topRight.x - topLeft.x) return radian < 0 ? radian + .pi * 2 : radian } - func getAngle(_ size: CGSize) -> Double { + public func getAngle(_ size: CGSize) -> Double { 180.0 / .pi * getRadian(size) } @@ -83,19 +83,19 @@ struct LiveTextBounds: Equatable, Sendable { } // MARK: LiveTextGroup -struct LiveTextGroup: Equatable, Identifiable, Sendable { - var id: UUID = .init() - let blocks: [LiveTextBlock] - let text: String +public struct LiveTextGroup: Equatable, Identifiable, Sendable { + public var id: UUID = .init() + public let blocks: [LiveTextBlock] + public let text: String - var minX: Double - var maxX: Double - var minY: Double - var maxY: Double - var width: Double! - var height: Double! + public var minX: Double + public var maxX: Double + public var minY: Double + public var maxY: Double + public var width: Double! + public var height: Double! - init?(blocks: [LiveTextBlock]) { + public init?(blocks: [LiveTextBlock]) { guard let firstBlock = blocks.first else { return nil } self.blocks = blocks text = blocks.map(\.text).joined(separator: " ") @@ -114,7 +114,7 @@ struct LiveTextGroup: Equatable, Identifiable, Sendable { } // Returns the rect of a rectangle area which contains all live text blocks - func getRect(width: Double, height: Double, extendSize: Double) -> CGRect { + public func getRect(width: Double, height: Double, extendSize: Double) -> CGRect { .init( x: minX * width - extendSize, y: minY * height - extendSize, @@ -125,11 +125,20 @@ struct LiveTextGroup: Equatable, Identifiable, Sendable { } // MARK: LiveTextBlock -struct LiveTextBlock: Equatable, Identifiable, Sendable { - var id: UUID = .init() +public struct LiveTextBlock: Equatable, Identifiable, Sendable { + public init( + id: UUID = .init(), + text: String, + bounds: LiveTextBounds + ) { + self.id = id + self.text = text + self.bounds = bounds + } + public var id: UUID = .init() - let text: String - let bounds: LiveTextBounds + public let text: String + public let bounds: LiveTextBounds } // MARK: Definition diff --git a/AppPackage/Sources/AppFeature/Models/Support/Misc.swift b/AppPackage/Sources/AppModels/Support/Misc.swift similarity index 52% rename from AppPackage/Sources/AppFeature/Models/Support/Misc.swift rename to AppPackage/Sources/AppModels/Support/Misc.swift index 525c321b1..104b8165a 100644 --- a/AppPackage/Sources/AppFeature/Models/Support/Misc.swift +++ b/AppPackage/Sources/AppModels/Support/Misc.swift @@ -2,25 +2,34 @@ import CasePaths import Foundation import SwiftyBeaver -typealias Logger = SwiftyBeaver -typealias FavoritesSortOrder = EhSetting.FavoritesSortOrder +public typealias Logger = SwiftyBeaver +public typealias FavoritesSortOrder = EhSetting.FavoritesSortOrder -enum DateSeekDirection: Equatable { +public enum DateSeekDirection: Equatable, Sendable { case newer case older } -struct DateSeekNavigation: Hashable { +public struct DateSeekNavigation: Hashable, Sendable { + public init( + directions: Directions, + minimumDate: Date, + maximumDate: Date + ) { + self.directions = directions + self.minimumDate = minimumDate + self.maximumDate = maximumDate + } /// The seekable directions available from the current page. Non-optional: a navigation only /// exists when at least one direction is, so the "neither" state is unrepresentable here — it /// is the `nil` of a `DateSeekNavigation?` instead. - enum Directions: Hashable { + public enum Directions: Hashable, Sendable { case newer(URL) case older(URL) case both(newer: URL, older: URL) /// `nil` when neither URL is present — i.e. the page offers no date seek. - init?(newer: URL?, older: URL?) { + public init?(newer: URL?, older: URL?) { switch (newer, older) { case let (newer?, older?): self = .both(newer: newer, older: older) @@ -33,7 +42,7 @@ struct DateSeekNavigation: Hashable { } } - var newerURL: URL? { + public var newerURL: URL? { switch self { case .newer(let url), .both(newer: let url, older: _): return url @@ -41,7 +50,7 @@ struct DateSeekNavigation: Hashable { return nil } } - var olderURL: URL? { + public var olderURL: URL? { switch self { case .older(let url), .both(newer: _, older: let url): return url @@ -51,19 +60,19 @@ struct DateSeekNavigation: Hashable { } } - var directions: Directions - var minimumDate: Date - var maximumDate: Date + public var directions: Directions + public var minimumDate: Date + public var maximumDate: Date - var newerURL: URL? { directions.newerURL } - var olderURL: URL? { directions.olderURL } - var dateRange: ClosedRange { minimumDate...maximumDate } + public var newerURL: URL? { directions.newerURL } + public var olderURL: URL? { directions.olderURL } + public var dateRange: ClosedRange { minimumDate...maximumDate } - func clampedDate(_ date: Date = Date()) -> Date { + public func clampedDate(_ date: Date = Date()) -> Date { min(max(date, minimumDate), maximumDate) } - func seekURL(date: Date, direction: DateSeekDirection) -> URL? { + public func seekURL(date: Date, direction: DateSeekDirection) -> URL? { let baseURL: URL? switch direction { case .newer: @@ -75,7 +84,7 @@ struct DateSeekNavigation: Hashable { } /// Formatter for the `seek` query parameter: fixed `yyyy-MM-dd`, UTC, POSIX locale. - static let dateFormatter: DateFormatter = { + public static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" formatter.timeZone = TimeZone(secondsFromGMT: 0) @@ -84,11 +93,11 @@ struct DateSeekNavigation: Hashable { }() } -protocol DateFormattable { +public protocol DateFormattable { var originalDate: Date { get } } extension DateFormattable { - var formattedDateString: String { + public var formattedDateString: String { let formatter = DateFormatter() formatter.dateStyle = .short formatter.timeStyle = .short @@ -98,37 +107,57 @@ extension DateFormattable { } } -struct PageNumber: Equatable { - var current = 0 - var maximum = 0 - var lastItemTimestamp: String? - var isNextButtonEnabled = false +public struct PageNumber: Equatable, Sendable { + public init( + current: Int = 0, + maximum: Int = 0, + lastItemTimestamp: String? = nil, + isNextButtonEnabled: Bool = false + ) { + self.current = current + self.maximum = maximum + self.lastItemTimestamp = lastItemTimestamp + self.isNextButtonEnabled = isNextButtonEnabled + } + public var current = 0 + public var maximum = 0 + public var lastItemTimestamp: String? + public var isNextButtonEnabled = false - var isSinglePage: Bool { + public var isSinglePage: Bool { current == 0 && maximum == 0 } - func hasNextPage(isNumericBased: Bool = false) -> Bool { + public func hasNextPage(isNumericBased: Bool = false) -> Bool { isNumericBased ? current < maximum : isNextButtonEnabled } - mutating func resetPages() { + public mutating func resetPages() { self = Self() } } -struct QuickSearchWord: Codable, Equatable, Identifiable { - static var empty: Self { .init(name: "", content: "") } +public struct QuickSearchWord: Codable, Equatable, Identifiable, Sendable { + public init( + id: UUID = .init(), + name: String, + content: String + ) { + self.id = id + self.name = name + self.content = content + } + public static var empty: Self { .init(name: "", content: "") } - var id: UUID = .init() - var name: String - var content: String + public var id: UUID = .init() + public var name: String + public var content: String - var effectiveSearchText: String { + public var effectiveSearchText: String { !content.isEmpty ? content : name } } @dynamicMemberLookup @CasePathable -enum LoadingState: Equatable, Hashable { +public enum LoadingState: Equatable, Hashable, Sendable { case idle case loading case failed(AppError) diff --git a/AppPackage/Sources/AppModels/Tags/EhTagTranslationDatabaseModel.swift b/AppPackage/Sources/AppModels/Tags/EhTagTranslationDatabaseModel.swift new file mode 100644 index 000000000..8f39c2a5d --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/EhTagTranslationDatabaseModel.swift @@ -0,0 +1,53 @@ +import Foundation + +public struct EhTagTranslationDatabaseResponse: Codable, Sendable { + public init( + data: [Model] + ) { + self.data = data + } + public struct Item: Codable, Sendable { + public init( + name: String, + intro: String? = nil, + links: String? = nil + ) { + self.name = name + self.intro = intro + self.links = links + } + public let name: String + public var intro: String? + public var links: String? + } + + public struct Model: Codable, Sendable { + public init( + namespace: String, + data: [String: Item] + ) { + self.namespace = namespace + self.data = data + } + public let namespace: String + public let data: [String: Item] + + public var tagTranslations: [TagTranslation] { + guard let namespace = TagNamespace(rawValue: namespace) else { return .init() } + return data.map { + .init( + namespace: namespace, key: $0, value: $1.name, + description: $1.intro, linksString: $1.links + ) + } + } + } + + public let data: [Model] + + public var tagTranslations: [String: TagTranslation] { + .init(uniqueKeysWithValues: data.flatMap(\.tagTranslations).map({ + ($0.namespace.rawValue + $0.key, $0) + })) + } +} diff --git a/AppPackage/Sources/AppModels/Tags/TagDetail.swift b/AppPackage/Sources/AppModels/Tags/TagDetail.swift new file mode 100644 index 000000000..7d2d441db --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/TagDetail.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct TagDetail: Equatable, Sendable { + public init( + title: String, + description: String, + imageURLs: [URL], + links: [URL] + ) { + self.title = title + self.description = description + self.imageURLs = imageURLs + self.links = links + } + public let title: String + public let description: String + public let imageURLs: [URL] + public let links: [URL] +} diff --git a/AppPackage/Sources/AppFeature/Models/Tags/TagNamespace.swift b/AppPackage/Sources/AppModels/Tags/TagNamespace.swift similarity index 91% rename from AppPackage/Sources/AppFeature/Models/Tags/TagNamespace.swift rename to AppPackage/Sources/AppModels/Tags/TagNamespace.swift index 270b83e9c..122611359 100644 --- a/AppPackage/Sources/AppFeature/Models/Tags/TagNamespace.swift +++ b/AppPackage/Sources/AppModels/Tags/TagNamespace.swift @@ -1,6 +1,6 @@ import Resources -enum TagNamespace: String, Codable, CaseIterable, Sendable { +public enum TagNamespace: String, Codable, CaseIterable, Sendable { case reclass case language case parody @@ -14,7 +14,7 @@ enum TagNamespace: String, Codable, CaseIterable, Sendable { case other case temp - static let abbreviations: [String: String] = { + public static let abbreviations: [String: String] = { let tuples: [(String, String)] = allCases.compactMap { if let abbreviation = $0.abbreviation { return ($0.rawValue, abbreviation) @@ -27,7 +27,7 @@ enum TagNamespace: String, Codable, CaseIterable, Sendable { } extension TagNamespace { - var weight: Float { + public var weight: Float { switch self { case .reclass: return 1 case .language: return 2 @@ -43,7 +43,7 @@ extension TagNamespace { case .temp: return 0.1 } } - var abbreviation: String? { + public var abbreviation: String? { switch self { case .reclass: return "r" case .language: return "l" @@ -59,7 +59,7 @@ extension TagNamespace { case .temp: return nil } } - var value: String { + public var value: String { switch self { case .reclass: return L10n.Localizable.Enum.TagNamespace.Value.reclass case .language: return L10n.Localizable.Enum.TagNamespace.Value.language diff --git a/AppPackage/Sources/AppFeature/Models/Tags/TagSuggestion.swift b/AppPackage/Sources/AppModels/Tags/TagSuggestion.swift similarity index 66% rename from AppPackage/Sources/AppFeature/Models/Tags/TagSuggestion.swift rename to AppPackage/Sources/AppModels/Tags/TagSuggestion.swift index 35316fd0b..d7485b7a3 100644 --- a/AppPackage/Sources/AppFeature/Models/Tags/TagSuggestion.swift +++ b/AppPackage/Sources/AppModels/Tags/TagSuggestion.swift @@ -1,15 +1,30 @@ import SwiftUI -struct TagSuggestion: Equatable, Hashable, Identifiable { - let id: UUID = .init() - let tag: TagTranslation - let weight: Float - let keyRange: Range? - let valueRange: Range? - let originalKeyword: String - let matchesNamespace: Bool +public struct TagSuggestion: Equatable, Hashable, Identifiable, Sendable { + public init( + tag: TagTranslation, + weight: Float, + keyRange: Range? = nil, + valueRange: Range? = nil, + originalKeyword: String, + matchesNamespace: Bool + ) { + self.tag = tag + self.weight = weight + self.keyRange = keyRange + self.valueRange = valueRange + self.originalKeyword = originalKeyword + self.matchesNamespace = matchesNamespace + } + public let id: UUID = .init() + public let tag: TagTranslation + public let weight: Float + public let keyRange: Range? + public let valueRange: Range? + public let originalKeyword: String + public let matchesNamespace: Bool - var displayKey: String { + public var displayKey: String { var namespace = tag.namespace.rawValue let leftSideString = leftSideString(of: keyRange, string: tag.key) var middleString = middleString(of: keyRange, string: tag.key) @@ -18,7 +33,7 @@ struct TagSuggestion: Equatable, Hashable, Identifiable { namespace = matchesNamespace ? namespace.linkStyled : namespace return [namespace, ":", leftSideString, middleString, rightSideString].joined() } - var displayValue: String { + public var displayValue: String { let text = tag.displayValue let leftSideString = leftSideString(of: valueRange, string: text) var middleString = middleString(of: valueRange, string: text) diff --git a/AppPackage/Sources/AppFeature/Models/Tags/TagTranslation.swift b/AppPackage/Sources/AppModels/Tags/TagTranslation.swift similarity index 73% rename from AppPackage/Sources/AppFeature/Models/Tags/TagTranslation.swift rename to AppPackage/Sources/AppModels/Tags/TagTranslation.swift index c04c860d3..a93dc1efb 100644 --- a/AppPackage/Sources/AppFeature/Models/Tags/TagTranslation.swift +++ b/AppPackage/Sources/AppModels/Tags/TagTranslation.swift @@ -1,48 +1,61 @@ import OpenCC import Foundation -struct TagTranslation: Codable, Equatable, Hashable { - let namespace: TagNamespace - let key: String - let value: String - var description: String? - var linksString: String? +public struct TagTranslation: Codable, Equatable, Hashable, Sendable { + public init( + namespace: TagNamespace, + key: String, + value: String, + description: String? = nil, + linksString: String? = nil + ) { + self.namespace = namespace + self.key = key + self.value = value + self.description = description + self.linksString = linksString + } + public let namespace: TagNamespace + public let key: String + public let value: String + public var description: String? + public var linksString: String? - var displayValue: String { + public var displayValue: String { valuePlainText ?? value } - var valuePlainText: String? { + public var valuePlainText: String? { MarkdownUtil.parseTexts(markdown: value).first } - var valueImageURL: URL? { + public var valueImageURL: URL? { MarkdownUtil.parseImages(markdown: value).first } - var descriptionPlainText: String? { + public var descriptionPlainText: String? { if let description = description { return MarkdownUtil.parseTexts(markdown: description.replacingOccurrences(of: "`", with: " ")).joined() } return nil } - var descriptionImageURLs: [URL] { + public var descriptionImageURLs: [URL] { if let description = description { return MarkdownUtil.parseImages(markdown: description) } return .init() } - var links: [URL] { + public var links: [URL] { if let linksString = linksString { return MarkdownUtil.parseLinks(markdown: linksString) } return .init() } - var searchKeyword: String { + public var searchKeyword: String { [namespace.abbreviation ?? namespace.rawValue, ":", key.contains(" ") ? "\"\(key)$\"" : "\(key)$"].joined() } - func getSuggestion(keyword: String, originalKeyword: String, matchesNamespace: Bool) -> TagSuggestion { + public func getSuggestion(keyword: String, originalKeyword: String, matchesNamespace: Bool) -> TagSuggestion { func getWeight(value: String, range: Range) -> Float { namespace.weight * .init(keyword.count + 1) / .init(value.count) * (range.lowerBound == value.startIndex ? 2.0 : 1.0) @@ -61,7 +74,7 @@ struct TagTranslation: Codable, Equatable, Hashable { } extension Dictionary where Value == TagTranslation { - var chtConverted: Self { + public var chtConverted: Self { func customConversion(text: String) -> String { switch text { case "full color": diff --git a/AppPackage/Sources/AppFeature/Models/Tags/TagTranslator.swift b/AppPackage/Sources/AppModels/Tags/TagTranslator.swift similarity index 50% rename from AppPackage/Sources/AppFeature/Models/Tags/TagTranslator.swift rename to AppPackage/Sources/AppModels/Tags/TagTranslator.swift index 1307fc76a..6d7d52658 100644 --- a/AppPackage/Sources/AppFeature/Models/Tags/TagTranslator.swift +++ b/AppPackage/Sources/AppModels/Tags/TagTranslator.swift @@ -1,12 +1,23 @@ import Foundation -struct TagTranslator: Codable, Equatable { - var language: TranslatableLanguage? - var hasCustomTranslations = false - var updatedDate: Date = .distantPast - var translations = [String: TagTranslation]() +public struct TagTranslator: Codable, Equatable, Sendable { + public init( + language: TranslatableLanguage? = nil, + hasCustomTranslations: Bool = false, + updatedDate: Date = .distantPast, + translations: [String: TagTranslation] = [String: TagTranslation]() + ) { + self.language = language + self.hasCustomTranslations = hasCustomTranslations + self.updatedDate = updatedDate + self.translations = translations + } + public var language: TranslatableLanguage? + public var hasCustomTranslations = false + public var updatedDate: Date = .distantPast + public var translations = [String: TagTranslation]() - func lookup(word: String, returnOriginal: Bool) -> (String, TagTranslation?) { + public func lookup(word: String, returnOriginal: Bool) -> (String, TagTranslation?) { guard !returnOriginal else { return (word, nil) } let (lhs, rhs) = word.stringsBesideColon @@ -25,7 +36,7 @@ struct TagTranslator: Codable, Equatable { } extension TagTranslator: CustomStringConvertible { - var description: String { + public var description: String { let params = String(describing: [ "language": language as Any, "updatedDate": updatedDate, diff --git a/AppPackage/Sources/AppFeature/Models/Tags/TranslatableLanguage.swift b/AppPackage/Sources/AppModels/Tags/TranslatableLanguage.swift similarity index 75% rename from AppPackage/Sources/AppFeature/Models/Tags/TranslatableLanguage.swift rename to AppPackage/Sources/AppModels/Tags/TranslatableLanguage.swift index 88256bb3f..1f92f2786 100644 --- a/AppPackage/Sources/AppFeature/Models/Tags/TranslatableLanguage.swift +++ b/AppPackage/Sources/AppModels/Tags/TranslatableLanguage.swift @@ -1,6 +1,6 @@ import Foundation -enum TranslatableLanguage: Codable, CaseIterable { +public enum TranslatableLanguage: Codable, CaseIterable, Sendable { case english case japanese case simplifiedChinese @@ -8,14 +8,14 @@ enum TranslatableLanguage: Codable, CaseIterable { } extension TranslatableLanguage { - static var current: TranslatableLanguage? { + public static var current: TranslatableLanguage? { guard let preferredLanguage = Locale.preferredLanguages.first, let translatableLanguage = TranslatableLanguage.allCases.compactMap({ lang in preferredLanguage.contains(lang.languageCode) ? lang : nil }).first else { return nil } return translatableLanguage } - var languageCode: String { + public var languageCode: String { switch self { case .english: return "en" @@ -27,7 +27,7 @@ extension TranslatableLanguage { return "zh-Hant" } } - var repoName: String { + public var repoName: String { switch self { case .english: return "EhPanda-Team/EhTagTranslation_Database_EN" @@ -37,16 +37,10 @@ extension TranslatableLanguage { return "EhTagTranslation/Database" } } - var remoteFilename: String { + public var remoteFilename: String { switch self { case .english, .japanese, .simplifiedChinese, .traditionalChinese: return "db.raw.json" } } - var checkUpdateURL: URL { - URLUtil.githubAPI(repoName: repoName) - } - var downloadURL: URL { - URLUtil.githubDownload(repoName: repoName, fileName: remoteFilename) - } } diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/MarkdownUtil.swift b/AppPackage/Sources/AppModels/Utilities/MarkdownUtil.swift similarity index 92% rename from AppPackage/Sources/AppFeature/Tools/Utilities/MarkdownUtil.swift rename to AppPackage/Sources/AppModels/Utilities/MarkdownUtil.swift index f39bcb21d..e4b18999c 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/MarkdownUtil.swift +++ b/AppPackage/Sources/AppModels/Utilities/MarkdownUtil.swift @@ -24,15 +24,24 @@ struct MarkdownUtil { .flatMap(\.text) .compactMap({ $0[case: \.image] }) .compactMap { image in - if image.url?.absoluteString.isValidURL == true { + if let absoluteString = image.url?.absoluteString, isValidURL(absoluteString) { return image.url - } else if let title = image.title, title.isValidURL { + } else if let title = image.title, isValidURL(title) { return .init(string: title) } return nil } ?? [] } + + private static func isValidURL(_ string: String) -> Bool { + guard let detector = try? NSDataDetector( + types: NSTextCheckingResult.CheckingType.link.rawValue + ), let match = detector.firstMatch( + in: string, options: [], range: NSRange(location: 0, length: string.utf16.count) + ) else { return false } + return match.range.length == string.utf16.count + } } // MARK: CasePathable diff --git a/AppPackage/Sources/AppFeature/Tools/ColorCodable.swift b/AppPackage/Sources/AppModels/ValueTypes/ColorCodable.swift similarity index 96% rename from AppPackage/Sources/AppFeature/Tools/ColorCodable.swift rename to AppPackage/Sources/AppModels/ValueTypes/ColorCodable.swift index 5becd3d9b..27a4d735e 100644 --- a/AppPackage/Sources/AppFeature/Tools/ColorCodable.swift +++ b/AppPackage/Sources/AppModels/ValueTypes/ColorCodable.swift @@ -41,7 +41,7 @@ private extension Color { } extension Color: @retroactive Codable { - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey, Sendable { case red, green, blue } diff --git a/AppPackage/Sources/AppModels/ValueTypes/Defaults.swift b/AppPackage/Sources/AppModels/ValueTypes/Defaults.swift new file mode 100644 index 000000000..def590f3e --- /dev/null +++ b/AppPackage/Sources/AppModels/ValueTypes/Defaults.swift @@ -0,0 +1,138 @@ +import CoreGraphics +import Foundation + +public struct Defaults: Sendable { + public struct ImageSize: Sendable { + public static let rowAspect: CGFloat = 8/11 + public static let headerAspect: CGFloat = 8/11 + public static let previewAspect: CGFloat = 8/11 + public static let contentAspect: CGFloat = 7/10 + public static let webtoonMinAspect: CGFloat = 1/4 + public static let webtoonIdealAspect: CGFloat = 2/3 + + public static let rowW: CGFloat = rowH * rowAspect + public static let rowH: CGFloat = 120 + public static let headerW: CGFloat = headerH * headerAspect + public static let headerH: CGFloat = 150 + } + public struct Cookie: Sendable { + public static let yay = "yay" + public static let null = "null" + public static let expired = "expired" + public static let mystery = "mystery" + public static let ignoreOffensive = "nw" + public static let selectedProfile = "sp" + public static let skipServer = "skipserver" + + public static let igneous = "igneous" + public static let ipbMemberId = "ipb_member_id" + public static let ipbPassHash = "ipb_pass_hash" + } + public struct DateFormat: Sendable { + public static let greeting = "dd MMMM yyyy" + public static let publish = "yyyy-MM-dd HH:mm" + public static let torrent = "yyyy-MM-dd HH:mm" + public static let comment = "dd MMMM yyyy, HH:mm" + public static let github = "yyyy-MM-dd'T'HH:mm:ss'Z'" + } + public struct FilePath: Sendable { + public static let logs = "logs" + public static let ehpandaLog = "EhPanda.log" + public static let downloads = "Downloads" + public static let downloadPages = "pages" + public static let downloadManifest = "manifest.json" + public static let automationDownloadFolder = "Automation" + public static let defaultDownloadFolder = "Default" + } + public struct Regex: Sendable { + public static let tagSuggestion: NSRegularExpression? = try? .init( + pattern: "(\\S+:\".+?\"|\".+?\"|\\S+:\\S+|\\S+)" + ) + } + public struct URL: Sendable { + public static let ehentai: Foundation.URL = .init(string: "https://e-hentai.org/").forceUnwrapped + public static let exhentai: Foundation.URL = .init(string: "https://exhentai.org/").forceUnwrapped + public static let sexhentai: Foundation.URL = .init(string: "https://s.exhentai.org/").forceUnwrapped + + public static let torrentDownload: Foundation.URL = .init(string: "https://ehgt.org/g/t.png").forceUnwrapped + public static let torrentDownloadInvalid: Foundation.URL = .init( + string: "https://ehgt.org/g/td.png" + ).forceUnwrapped + + public static let forum: Foundation.URL = .init(string: "https://forums.e-hentai.org/index.php").forceUnwrapped + public static let login = forum.appending(queryItems: [.act: .loginAct, .code: .zeroOne]) + public static let webLogin = forum.appending(queryItems: [.act: .loginAct]) + + public static let news = ehentai.appendingPathComponent("news.php") + + public static let toplist = ehentai.appendingPathComponent("toplist.php") + + // GitHub + public static let github: Foundation.URL = .init(string: "https://github.com/").forceUnwrapped + public static let githubAPI: Foundation.URL = .init(string: "https://api.github.com/repos/").forceUnwrapped + + // swiftlint:disable nesting identifier_name + public enum Component: Sendable { + public enum Key: String, Sendable { + // Functional Pages + case token = "t" + case gid = "gid" + case letterP = "p" + case page = "page" + case from = "from" + case next = "next" + case favcat = "favcat" + case topcat = "tl" + case showUser = "showuser" + case fSearch = "f_search" + + case code = "CODE" + case act = "act" + case showComments = "hc" + case inlineSet = "inline_set" + case skipServerIdentifier = "nl" + + // Search favorites + case sn = "sn" + case st = "st" + case sf = "sf" + + // Filter + case fCats = "f_cats" + case advSearch = "advsearch" + case fSname = "f_sname" + case fStags = "f_stags" + case fSdesc = "f_sdesc" + case fStorr = "f_storr" + case fSto = "f_sto" + case fSdt1 = "f_sdt1" + case fSdt2 = "f_sdt2" + case fSh = "f_sh" + case fSr = "f_sr" + case fSrdd = "f_srdd" + case fSp = "f_sp" + case fSpf = "f_spf" + case fSpt = "f_spt" + case fSfl = "f_sfl" + case fSfu = "f_sfu" + case fSft = "f_sft" + + // Custom + case ehpandaWidth = "ehpandaWidth" + case ehpandaHeight = "ehpandaHeight" + case ehpandaOffset = "ehpandaOffset" + } + public enum Value: String, Sendable { + case one = "1" + case all = "all" + case zeroOne = "01" + case filterOn = "on" + case loginAct = "Login" + case addFavAct = "addfav" + case sortOrderByUpdateTime = "fs_p" + case sortOrderByFavoritedTime = "fs_f" + } + } + // swiftlint:enable nesting identifier_name + } +} diff --git a/AppPackage/Sources/AppFeature/Tools/EnvironmentKeys.swift b/AppPackage/Sources/AppModels/ValueTypes/EnvironmentKeys.swift similarity index 53% rename from AppPackage/Sources/AppFeature/Tools/EnvironmentKeys.swift rename to AppPackage/Sources/AppModels/ValueTypes/EnvironmentKeys.swift index f468b0b6e..ab1f51d28 100644 --- a/AppPackage/Sources/AppFeature/Tools/EnvironmentKeys.swift +++ b/AppPackage/Sources/AppModels/ValueTypes/EnvironmentKeys.swift @@ -1,11 +1,11 @@ import SwiftUI -struct InSheetKey: EnvironmentKey { - static let defaultValue = false +public struct InSheetKey: EnvironmentKey, Sendable { + public static let defaultValue = false } extension EnvironmentValues { - var inSheet: Bool { + public var inSheet: Bool { get { self[InSheetKey.self] } set { self[InSheetKey.self] = newValue } } diff --git a/AppPackage/Sources/AppFeature/Tools/EquatableVoid.swift b/AppPackage/Sources/AppModels/ValueTypes/EquatableVoid.swift similarity index 100% rename from AppPackage/Sources/AppFeature/Tools/EquatableVoid.swift rename to AppPackage/Sources/AppModels/ValueTypes/EquatableVoid.swift diff --git a/AppPackage/Sources/AppFeature/Tools/IdentifiableBox.swift b/AppPackage/Sources/AppModels/ValueTypes/IdentifiableBox.swift similarity index 100% rename from AppPackage/Sources/AppFeature/Tools/IdentifiableBox.swift rename to AppPackage/Sources/AppModels/ValueTypes/IdentifiableBox.swift diff --git a/AppPackage/Sources/AppModels/ValueTypes/Optional+ForceUnwrapped.swift b/AppPackage/Sources/AppModels/ValueTypes/Optional+ForceUnwrapped.swift new file mode 100644 index 000000000..7e578ad0a --- /dev/null +++ b/AppPackage/Sources/AppModels/ValueTypes/Optional+ForceUnwrapped.swift @@ -0,0 +1,14 @@ +import Foundation + +extension Optional { + public var forceUnwrapped: Wrapped! { + if let value = self { + return value + } + Logger.error( + "Failed in force unwrapping...", + context: ["type": Wrapped.self] + ) + return nil + } +} diff --git a/AppPackage/Sources/AppModels/ValueTypes/String+Helpers.swift b/AppPackage/Sources/AppModels/ValueTypes/String+Helpers.swift new file mode 100644 index 000000000..83aa58eea --- /dev/null +++ b/AppPackage/Sources/AppModels/ValueTypes/String+Helpers.swift @@ -0,0 +1,43 @@ +import Foundation + +extension String { + public var nonEmpty: String? { + isEmpty ? nil : self + } + public var linkStyled: String { + "[\(self)](\(Defaults.URL.ehentai.absoluteString))" + } + public var firstLetterCapitalized: String { + prefix(1).capitalized + dropFirst() + } + public var stringsBesideColon: (String?, String) { + let strings = split(separator: ":").map(String.init) + if strings.count == 2, !strings[0].isEmpty { + return (strings[0], strings[1]) + } + return (nil, self) + } + public var barcesAndSpacesRemoved: String { + replacingOccurrences(from: "(", to: ")", with: "") + .replacingOccurrences(from: "[", to: "]", with: "") + .replacingOccurrences(from: "{", to: "}", with: "") + .replacingOccurrences(from: "【", to: "】", with: "") + .replacingOccurrences(from: "「", to: "」", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + public func replacingOccurrences( + from subString1: String, to subString2: String, with replacement: String + ) -> String { + var result = self + + while let rangeA = result.range(of: subString1), + let rangeB = result.range(of: subString2), + rangeA.lowerBound < rangeB.upperBound { + let unwanted = result[rangeA.lowerBound.. URL { + guard !queryItems.isEmpty else { return self } + var components: URLComponents = .init( + url: self, resolvingAgainstBaseURL: false + ) + .forceUnwrapped + if components.queryItems == nil { + components.queryItems = [] + } + components.queryItems?.append(contentsOf: queryItems) + return components.url.forceUnwrapped + } + public func appending(queryItems: [String: String]) -> URL { + appending(queryItems: queryItems.map(URLQueryItem.init)) + } + public func appending(queryItems: [Defaults.URL.Component.Key: Defaults.URL.Component.Value]) -> URL { + appending(queryItems: queryItems.map({ URLQueryItem(name: $0.rawValue, value: $1.rawValue) })) + } + public func appending(queryItems: [Defaults.URL.Component.Key: String]) -> URL { + appending(queryItems: queryItems.map({ URLQueryItem(name: $0.rawValue, value: $1) })) + } + public mutating func append(queryItems: [URLQueryItem]) { + self = appending(queryItems: queryItems) + } + public mutating func append(queryItems: [String: String]) { + self = appending(queryItems: queryItems) + } + public mutating func append(queryItems: [Defaults.URL.Component.Key: Defaults.URL.Component.Value]) { + self = appending(queryItems: queryItems) + } + public mutating func append(queryItems: [Defaults.URL.Component.Key: String]) { + self = appending(queryItems: queryItems) + } +} diff --git a/AppPackage/Tests/AppFeatureTests/Models/HTMLFilename.swift b/AppPackage/Tests/AppFeatureTests/Models/HTMLFilename.swift index 79724c854..2bc59e6d2 100644 --- a/AppPackage/Tests/AppFeatureTests/Models/HTMLFilename.swift +++ b/AppPackage/Tests/AppFeatureTests/Models/HTMLFilename.swift @@ -1,3 +1,5 @@ +import AppModels + enum HTMLFilename: String { // List // FrontPage diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift index 00f6f3e3d..66f4b1937 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift index 9f0f9b319..780c5906d 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index 3b3f8c241..d527b8c40 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift index e52f34aec..398ff20c0 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index 6e238c035..8e03cea22 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index 6e0f82917..637c47541 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import SwiftUI import ComposableArchitecture import Testing diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift index 5bc913658..e0b73b40a 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Foundation import SFSafeSymbols import ComposableArchitecture diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift index 4bc8920c2..a0d8a84b3 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift index c626e536b..df9925779 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift @@ -1,4 +1,5 @@ import UIKit +import AppModels import Foundation import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift index 38d3ec70c..4f4e7558b 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift index 2186b2074..97297a7e8 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift @@ -1,4 +1,5 @@ import Kingfisher +import AppModels import Resources import UIKit import Foundation diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift index ca9a432de..ec2a72cb9 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift index d6bc8bd11..7972b2591 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -1,4 +1,5 @@ import CoreData +import AppModels import Foundation import Testing @testable import AppFeature @@ -63,7 +64,7 @@ extension DownloadedGallery { title: String, jpnTitle: String?, uploader: String?, - category: AppFeature.Category, + category: AppModels.Category, tags: [GalleryTag], pageCount: Int, postedDate: Date, @@ -187,7 +188,7 @@ extension DownloadFeatureTestCase { gid: String, title: String, status: DownloadFixtureStatus, - category: AppFeature.Category = .doujinshi, + category: AppModels.Category = .doujinshi, pageCount: Int = 12, completedPageCount: Int? = nil, lastDownloadedDate: Date? = .now, diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift index 3892b8863..88995b05a 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import CoreData import ComposableArchitecture import Kingfisher diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift index b02e9271f..7b7efec8c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Foundation import SFSafeSymbols import ComposableArchitecture @@ -49,7 +50,7 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { lastError: nil ) - #expect(download.searchableText == ["Solo Title", Category.doujinshi.value].joined(separator: " ")) + #expect(download.searchableText == ["Solo Title", AppModels.Category.doujinshi.value].joined(separator: " ")) #expect(!download.searchableText.contains(" ")) #expect(download.searchableText == download.searchableText.trimmingCharacters(in: .whitespaces)) } diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift index 40e3a16dc..2c27afffb 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageErrorTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageErrorTests.swift index b6fefcf94..3ad1a027a 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageErrorTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageErrorTests.swift @@ -1,4 +1,5 @@ import CoreData +import AppModels import Foundation import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift index 1b844e381..4f0b327e7 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -1,4 +1,5 @@ import CoreData +import AppModels import Kingfisher import UIKit import Foundation diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift index d31ead4f5..a55d6afdb 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift @@ -1,4 +1,5 @@ import CoreData +import AppModels import Kingfisher import UIKit import Foundation diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift index 9c586a704..d2d6be6bb 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Resources import ComposableArchitecture import Testing diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift index 7736e057c..936d318cf 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift index 2e70ab4ba..5db3b2ec4 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift index 5ef3317d1..09d83a987 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift index de0d9edbd..0e67383d0 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift index 098b9f7c5..26b75f952 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index 719ccefa3..0111df0ac 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index 52656b173..2aadaaaa8 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift index ddef398b5..2acb106e4 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Kingfisher import UIKit diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift index debbe683c..618aba423 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift @@ -1,4 +1,5 @@ import UIKit +import AppModels import Foundation import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift index bc2876234..12789eca2 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index 5179ba688..bf7cf7245 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift index 56bf8fa6b..0b0ed882c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index bb4e0dd42..1e3902d68 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift index 43559c0a7..083792662 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift index f36f227e9..d69363789 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Resources import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift index 4b8d17258..610288036 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift index 3d1f4f9f5..e57f3c248 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Resources import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift index 106f8b9e9..8a1c9e68f 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadedGalleryManifestModelTests.swift index 893b5eae6..cae65744a 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadedGalleryManifestModelTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadedGalleryManifestModelTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift index 878821bf7..b35a31c75 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift index 737e96a56..762ba13c1 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift index 554a25022..25da28723 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift index 49ed25d76..52de7be2e 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift index b33e4fcb5..b4b2c9efc 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Testing import UIKit @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index 0c841bce7..6ff4f6f22 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index 2d4a063d5..17b71a155 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import ComposableArchitecture import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift index eca8b86eb..56f902805 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift @@ -1,4 +1,5 @@ import Foundation +import AppModels import Kanna import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift index 22a47d589..b5b47bd6b 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Combine import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift index d6f2fd4c2..fdc69d27f 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift @@ -1,4 +1,5 @@ import Kanna +import AppModels import Testing @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift index f6829caf8..f54204fd6 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppModels import Testing @testable import AppFeature From 3ee6fbe0542e11c6c0ec9d364102b13727df32b2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 18:02:01 +0800 Subject: [PATCH 308/614] Extract foundation/navigation extension modules Split App/Tools/Extensions into reference-style per-dependency modules, leaving app-coupled glue behind in AppFeature. - FoundationExt (no deps): the pure Swift/Foundation/UIKit generic extensions (Extensions.swift) plus URL+ImageCacheKey. - ComposableArchitectureExt: the generic RecurseReducer. - SwiftUINavigationExt: the pure SwiftUINavigation/CasePaths helpers (NavigationLink/confirmationDialog/sheet unwrapping, Binding.case, isRemovedDuplicatesPresent). This file carries the sanctioned iOS-16 NavigationLink deprecation. Members that couple back to the app layer stay in AppFeature so the new modules remain clean leaves: - URL.mock / previewCacheCleanupURLs (Defaults, Parser) -> URL+App. - Reducer.haptics (HapticsClient) and LoggingReducer (Logger). - View.progressHUD (TTProgressHUD; moves to DesignSystem later). Add the three targets to Package.swift, wire AppFeature (and the test target's FoundationExt use), and add the import cascade. AppModels is unchanged: it uses none of these symbols. --- AppPackage/Package.swift | 29 ++++++ .../AppFeature/DataFlow/AppRouteReducer.swift | 1 + .../MODefinition/AppEnvMO+CoreDataClass.swift | 1 + .../GalleryDetailMO+CoreDataClass.swift | 1 + .../GalleryMO+CoreDataClass.swift | 1 + .../GalleryStateMO+CoreDataClass.swift | 1 + .../AppFeature/Network/Request+Account.swift | 1 + .../Sources/AppFeature/Network/Request.swift | 1 + .../Tools/Clients/CookieClient.swift | 1 + .../Clients/DatabaseClient+Updates.swift | 1 + .../Tools/Clients/DatabaseClient.swift | 1 + .../Tools/Clients/DownloadClient+Cache.swift | 1 + .../DownloadClient+ResponseValidation.swift | 1 + ...loadClient+ResponseValidationHelpers.swift | 1 + .../Tools/Clients/ImageClient.swift | 1 + .../Tools/Clients/UIApplicationClient.swift | 1 + .../Tools/Extensions/Reducer_Extension.swift | 19 +--- .../SwiftUINavigation_Extension.swift | 96 +----------------- .../AppFeature/Tools/Extensions/URL+App.swift | 16 +++ .../Tools/Extensions/ViewModifiers.swift | 1 + .../Tools/Parser/Parser+Detail.swift | 1 + .../Detail/Comments/CommentsReducer.swift | 1 + .../View/Detail/Comments/CommentsView.swift | 1 + .../View/Detail/Components/RatingView.swift | 1 + .../Detail/Components/TagDetailView.swift | 1 + .../View/Detail/DetailReducer+Download.swift | 1 + .../View/Detail/DetailReducer.swift | 2 + .../DetailSearch/DetailSearchReducer.swift | 1 + .../DetailSearch/DetailSearchView.swift | 1 + .../View/Detail/DetailView+Navigation.swift | 1 + .../View/Detail/DetailView+Subviews.swift | 1 + .../Detail/Previews/PreviewsReducer.swift | 2 + .../Detail/Torrents/TorrentsReducer.swift | 1 + .../View/Downloads/DownloadsReducer.swift | 1 + .../View/Downloads/DownloadsView.swift | 1 + .../View/Downloads/FolderManagerView.swift | 1 + .../View/Favorites/FavoritesReducer.swift | 1 + .../View/Favorites/FavoritesView.swift | 1 + .../Home/Frontpage/FrontpageReducer.swift | 2 + .../View/Home/Frontpage/FrontpageView.swift | 1 + .../View/Home/History/HistoryReducer.swift | 1 + .../View/Home/History/HistoryView.swift | 1 + .../AppFeature/View/Home/HomeReducer.swift | 1 + .../AppFeature/View/Home/HomeView.swift | 1 + .../View/Home/Popular/PopularReducer.swift | 2 + .../View/Home/Popular/PopularView.swift | 1 + .../View/Home/Toplists/ToplistsReducer.swift | 1 + .../View/Home/Toplists/ToplistsView.swift | 1 + .../View/Home/Watched/WatchedReducer.swift | 1 + .../View/Home/Watched/WatchedView.swift | 1 + .../View/Migration/MigrationView.swift | 1 + .../View/Reading/ReadingReducer+Body.swift | 2 + .../Reading/ReadingReducer+Database.swift | 1 + .../Reading/ReadingReducer+ImageFetch.swift | 1 + .../AppFeature/View/Reading/ReadingView.swift | 1 + .../View/Search/SearchReducer.swift | 1 + .../View/Search/SearchRootReducer.swift | 2 + .../View/Search/SearchRootView.swift | 2 + .../AppFeature/View/Search/SearchView.swift | 1 + .../View/Search/Support/QuickSearchView.swift | 1 + .../AccountSettingReducer.swift | 1 + .../AccountSetting/AccountSettingView.swift | 1 + .../AppearanceSettingView.swift | 1 + .../Setting/EhSetting/EhSettingReducer.swift | 1 + .../EhSetting/EhSettingView+Sections1.swift | 2 + .../EhSetting/EhSettingView+Sections3.swift | 1 + .../Setting/EhSetting/EhSettingView.swift | 1 + .../GeneralSetting/GeneralSettingView.swift | 1 + .../View/Setting/Login/LoginReducer.swift | 1 + .../View/Setting/Logs/LogsView.swift | 1 + .../AppFeature/View/Setting/SettingView.swift | 1 + .../Support/Components/PreviewImageView.swift | 1 + .../Support/Components/TagCloudView.swift | 1 + .../Components/TagSuggestionView.swift | 1 + .../AppFeature/View/Support/FiltersView.swift | 1 + .../ComposableArchitectureExt/.swiftlint.yml | 1 + .../RecurseReducer.swift | 19 ++++ .../Sources/FoundationExt/.swiftlint.yml | 1 + .../Extensions.swift | 64 +++++------- .../URL+ImageCacheKey.swift | 4 +- .../SwiftUINavigationExt/.swiftlint.yml | 1 + .../SwiftUINavigation_Extension.swift | 99 +++++++++++++++++++ AppPackage/Sources/Utilities/.swiftlint.yml | 1 + .../DownloadCoordinatorCaptureTests.swift | 1 + .../DownloadFeatureTestFactories.swift | 1 + .../DownloadImageParsingCacheTests.swift | 1 + .../Download/DownloadImageParsingTests.swift | 1 + .../Download/DownloadProcessCacheTests.swift | 1 + .../Tests/Download/ReaderImageDataTests.swift | 1 + .../Parser/Other/SettingDownloadTests.swift | 1 + 90 files changed, 281 insertions(+), 155 deletions(-) create mode 100644 AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift create mode 100644 AppPackage/Sources/ComposableArchitectureExt/.swiftlint.yml create mode 100644 AppPackage/Sources/ComposableArchitectureExt/RecurseReducer.swift create mode 100644 AppPackage/Sources/FoundationExt/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Extensions => FoundationExt}/Extensions.swift (77%) rename AppPackage/Sources/{AppFeature/Tools/Extensions => FoundationExt}/URL+ImageCacheKey.swift (96%) create mode 100644 AppPackage/Sources/SwiftUINavigationExt/.swiftlint.yml create mode 100644 AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation_Extension.swift create mode 100644 AppPackage/Sources/Utilities/.swiftlint.yml diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index a1ec00e5f..32617eeba 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -68,7 +68,11 @@ let sharedSwiftSettings: [PackageDescription.SwiftSetting] = [ enum Module: String { case appFeature = "AppFeature" case appModels = "AppModels" + case composableArchitectureExt = "ComposableArchitectureExt" + case foundationExt = "FoundationExt" case resources = "Resources" + case swiftUINavigationExt = "SwiftUINavigationExt" + case utilities = "Utilities" // Test targets case appFeatureTests = "AppFeatureTests" @@ -206,7 +210,10 @@ let targets: [PackageDescription.Target] = [ module: .appFeature, dependencies: [ .module(.appModels), + .module(.composableArchitectureExt), + .module(.foundationExt), .module(.resources), + .module(.swiftUINavigationExt), .targetDependency(.alertKit), .targetDependency(.colorful), .targetDependency(.commonMark), @@ -249,6 +256,27 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .composableArchitectureExt, + dependencies: [ + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + .target( + module: .foundationExt, + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + .target( + module: .swiftUINavigationExt, + dependencies: [ + .targetDependency(.swiftUINavigation) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), // MARK: Tests .testTarget( @@ -256,6 +284,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appFeature), .module(.appModels), + .module(.foundationExt), .targetDependency(.composableArchitecture), .targetDependency(.kanna), .targetDependency(.kingfisher), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 1007c5c9d..4cb149d1e 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import ComposableArchitecture +import SwiftUINavigationExt @Reducer struct AppRouteReducer { diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift index acd45492c..7988f5b62 100644 --- a/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift +++ b/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift @@ -1,5 +1,6 @@ import CoreData import AppModels +import FoundationExt public class AppEnvMO: NSManagedObject {} diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift index edc3a0206..edd100b78 100644 --- a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift +++ b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift @@ -1,5 +1,6 @@ import CoreData import AppModels +import FoundationExt public class GalleryDetailMO: NSManagedObject {} diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift index 00ed894f0..96f576d7d 100644 --- a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift +++ b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift @@ -1,5 +1,6 @@ import CoreData import AppModels +import FoundationExt public class GalleryMO: NSManagedObject {} diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift index f9c54a4d0..ebeb5e6f5 100644 --- a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift +++ b/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import CoreData +import FoundationExt public class GalleryStateMO: NSManagedObject {} diff --git a/AppPackage/Sources/AppFeature/Network/Request+Account.swift b/AppPackage/Sources/AppFeature/Network/Request+Account.swift index 187769bdc..8fc5072a3 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Account.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Account.swift @@ -2,6 +2,7 @@ import Kanna import AppModels import Combine import Foundation +import FoundationExt // MARK: Account Ops struct LoginRequest: Request { diff --git a/AppPackage/Sources/AppFeature/Network/Request.swift b/AppPackage/Sources/AppFeature/Network/Request.swift index fb9b69f9f..78d21189c 100644 --- a/AppPackage/Sources/AppFeature/Network/Request.swift +++ b/AppPackage/Sources/AppFeature/Network/Request.swift @@ -3,6 +3,7 @@ import AppModels import Combine import Foundation import ComposableArchitecture +import FoundationExt protocol Request { associatedtype Response: Sendable diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift index 874708d44..c57c7c102 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import Resources import ComposableArchitecture +import FoundationExt #if DEBUG import Synchronization #endif diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift index e74ea52a2..e6ff8a768 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import CoreData +import FoundationExt // MARK: UpdateGalleryState extension DatabaseClient { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift index ae450f205..4e2b67ed0 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift @@ -3,6 +3,7 @@ import AppModels import Combine import CoreData import ComposableArchitecture +import FoundationExt struct DatabaseClient: Sendable { let prepareDatabase: @Sendable () async -> Result diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift index 2aedd9c62..2e43bb43f 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import FoundationExt // MARK: - Cache Operations extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift index 18a59ab26..578a22983 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift @@ -2,6 +2,7 @@ import Kanna import AppModels import Foundation import ImageIO +import FoundationExt // MARK: - Response Error Detection extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index fbd755f2d..c306a6249 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -2,6 +2,7 @@ import Kanna import AppModels import Foundation import ImageIO +import FoundationExt // MARK: - Response Inspection Helpers extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift index bac4b1cb6..81d96502d 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift @@ -3,6 +3,7 @@ import AppModels import SwiftUI import Combine import ComposableArchitecture +import FoundationExt struct ImageClient: Sendable { struct ImageAsset { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift index 3afe606fc..d4a06a158 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift @@ -1,6 +1,7 @@ import SwiftUI import Combine import ComposableArchitecture +import FoundationExt struct UIApplicationClient: Sendable { let openURL: @MainActor @Sendable (URL) -> Void diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift index 322daf9d0..d0509986c 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import ComposableArchitecture +import SwiftUINavigationExt extension Reducer { func haptics( @@ -31,24 +32,6 @@ extension Reducer { } } -// MARK: Recurse -struct RecurseReducer: Reducer -where State == Base.State, Action == Base.Action { - let base: (Reduce) -> Base - - public init(@ReducerBuilder base: @escaping (Reduce) -> Base) { - self.base = base - } - - public var body: some Reducer { - var `self`: Reduce! - self = Reduce { state, action in - base(self)._reduce(into: &state, action: action) - } - return self - } -} - // MARK: Logging struct LoggingReducer: Reducer where State == Base.State, Action == Base.Action { diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/SwiftUINavigation_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/SwiftUINavigation_Extension.swift index 83cceb609..ff2fcf752 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/SwiftUINavigation_Extension.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/SwiftUINavigation_Extension.swift @@ -1,77 +1,9 @@ import SwiftUI import TTProgressHUD import SwiftUINavigation - -extension NavigationLink { - init( - _ title: S, - unwrapping value: Binding, - @ViewBuilder destination: @escaping (Binding) -> WrappedDestination - ) where Destination == WrappedDestination?, Label == Text { - self.init( - title, - destination: Binding(unwrapping: value).map(destination), - isActive: .init(value) - ) - } - init( - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath, - @ViewBuilder destination: @escaping (Binding) -> WrappedDestination - ) where Destination == WrappedDestination?, Label == Text { - self.init( - "", unwrapping: `enum`.case(caseKeyPath), - destination: destination - ) - } -} +import SwiftUINavigationExt extension View { - func confirmationDialog( - message: String, - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath, - @ViewBuilder actions: @escaping (Case) -> A - ) -> some View { - self.confirmationDialog( - item: `enum`.case(caseKeyPath), - titleVisibility: .hidden, - title: { _ in Text("") }, - actions: actions, - message: { _ in Text(message) } - ) - } - func confirmationDialog( - message: String, - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath, - matching case: Case, - @ViewBuilder actions: @escaping (Case) -> A - ) -> some View { - self.confirmationDialog( - item: { - let unwrapping = `enum`.case(caseKeyPath) - let isMatched = `case` == unwrapping.wrappedValue - return isMatched ? unwrapping : .constant(nil) - }(), - titleVisibility: .hidden, - title: { _ in Text("") }, - actions: actions, - message: { _ in Text(message) } - ) - } - - func sheet( - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath, - @ViewBuilder content: @escaping (Case) -> Content - ) -> some View { - self.sheet( - isPresented: .constant(`enum`.case(caseKeyPath).wrappedValue != nil), - content: { `enum`.case(caseKeyPath).wrappedValue.map(content) } - ) - } - func progressHUD( config: ProgressHUDConfigState, unwrapping enum: Binding, @@ -86,29 +18,3 @@ extension View { } } } - -extension Binding { - func `case`( - _ caseKeyPath: CaseKeyPath - ) -> Binding where Value == Enum? { - let casePath = AnyCasePath(caseKeyPath) - return .init( - get: { self.wrappedValue.flatMap(casePath.extract(from:)) }, - set: { newValue, transaction in - self.transaction(transaction).wrappedValue = newValue.map(casePath.embed) - } - ) - } - - func isRemovedDuplicatesPresent() -> Binding where Value == Wrapped? { - .init( - get: { wrappedValue != nil }, - set: { isPresent, transaction in - guard self.transaction(transaction).wrappedValue != nil else { return } - if !isPresent { - self.transaction(transaction).wrappedValue = nil - } - } - ) - } -} diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift new file mode 100644 index 000000000..58123888a --- /dev/null +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift @@ -0,0 +1,16 @@ +import Foundation +import AppModels + +extension URL { + static let mock = Defaults.URL.ehentai + + func previewCacheCleanupURLs() -> [URL] { + guard let info = Parser.parsePreviewConfigs(url: self), + info.plainURL != self + else { + return [self] + } + + return [self, info.plainURL] + } +} diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift index c665709b0..4b0919dc3 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift @@ -1,5 +1,6 @@ import SwiftUI import Kingfisher +import FoundationExt extension View { func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift index 50c6a9303..ed00cfe9e 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift @@ -1,6 +1,7 @@ import Kanna import AppModels import Foundation +import FoundationExt extension Parser { static func parseGalleryURL(doc: HTMLDocument) throws -> URL { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift index 6005c6bcf..f1194ac4c 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import ComposableArchitecture +import SwiftUINavigationExt @Reducer struct CommentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift index 9fdb7d66e..74342ffc1 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import Kingfisher import ComposableArchitecture +import SwiftUINavigationExt struct CommentsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/Components/RatingView.swift b/AppPackage/Sources/AppFeature/View/Detail/Components/RatingView.swift index a82719064..0882a31f1 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Components/RatingView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Components/RatingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import FoundationExt struct RatingView: View { private let rawRating: Float diff --git a/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift index f24c6401d..291e78732 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import Kingfisher +import FoundationExt struct TagDetailView: View { private let detail: TagDetail diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift index a2321c67f..4cb8008e5 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import ComposableArchitecture +import FoundationExt // MARK: - Download Action Handlers extension DetailReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift index 432fa69f5..032568fa2 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift @@ -2,6 +2,8 @@ import SwiftUI import AppModels import Foundation import ComposableArchitecture +import ComposableArchitectureExt +import SwiftUINavigationExt @Reducer struct DetailReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift index 3559eba44..ac43e073b 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import AppModels +import SwiftUINavigationExt @Reducer struct DetailSearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift index 2a2e94c29..e40813fff 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import ComposableArchitecture +import SwiftUINavigationExt struct DetailSearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift index 75dfa4a7b..536f66a29 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift @@ -1,6 +1,7 @@ import SwiftUI import Resources import ComposableArchitecture +import SwiftUINavigationExt // MARK: NavigationLinks extension DetailView { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift index 00654b0b7..a81e14190 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import Kingfisher +import FoundationExt // MARK: DescriptionSection struct DescriptionSection: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift index 33b49e595..3ede0019e 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift @@ -1,6 +1,8 @@ import Foundation import AppModels import ComposableArchitecture +import FoundationExt +import SwiftUINavigationExt @Reducer struct PreviewsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift index cb41f2660..bb18d28fa 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import ComposableArchitecture +import SwiftUINavigationExt @Reducer struct TorrentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift index 6c140440d..9282a90bf 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import ComposableArchitecture +import FoundationExt @Reducer struct DownloadsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift index d6dcb873d..4fb06f61b 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import SFSafeSymbols import ComposableArchitecture +import SwiftUINavigationExt struct DownloadsView: View { private enum RowDialog: Identifiable { diff --git a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift index d44b6814a..92ca07576 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift @@ -2,6 +2,7 @@ import SwiftUI import Resources import SFSafeSymbols import ComposableArchitecture +import SwiftUINavigationExt struct FolderManagerView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift index 8c3c8cfef..e53200455 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import IdentifiedCollections import ComposableArchitecture +import SwiftUINavigationExt @Reducer struct FavoritesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift index 2ff2e4ae7..e8d000e0c 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import AlertKit import ComposableArchitecture +import SwiftUINavigationExt struct FavoritesView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift index db224c5b6..77f49cf7f 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift @@ -1,6 +1,8 @@ import ComposableArchitecture import AppModels import Foundation +import FoundationExt +import SwiftUINavigationExt @Reducer struct FrontpageReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift index efa68de39..5da47653d 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import AlertKit import ComposableArchitecture +import SwiftUINavigationExt struct FrontpageView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift index 0eee3761b..153a5acf0 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import ComposableArchitecture +import FoundationExt @Reducer struct HistoryReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift index 5140fd20c..9b4a55940 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import SwiftUINavigationExt struct HistoryView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift b/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift index b10a333a2..aaa618ace 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Kingfisher import ComposableArchitecture +import FoundationExt @Reducer struct HomeReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift index a3fd5d6e4..b05214bdb 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift @@ -4,6 +4,7 @@ import Resources import Kingfisher import SFSafeSymbols import ComposableArchitecture +import SwiftUINavigationExt struct HomeView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift index 8cc004b65..221a051cf 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift @@ -1,5 +1,7 @@ import ComposableArchitecture import AppModels +import FoundationExt +import SwiftUINavigationExt @Reducer struct PopularReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift index 1a42f2048..981108a5a 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import SwiftUINavigationExt struct PopularView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift index ed0d3ef9e..26c43907e 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import AppModels +import FoundationExt @Reducer struct ToplistsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift index 15f185c93..57d10b280 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import SwiftUINavigationExt struct ToplistsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift index a71c73662..a0779405b 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import AppModels +import SwiftUINavigationExt @Reducer struct WatchedReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift index eafca901e..78d25c5c2 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import SwiftUINavigationExt struct WatchedView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift b/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift index e11cc34c7..0041de43b 100644 --- a/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift +++ b/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift @@ -1,6 +1,7 @@ import SwiftUI import Resources import ComposableArchitecture +import SwiftUINavigationExt struct MigrationView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Body.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Body.swift index 513d3bf76..ed66f4172 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Body.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Body.swift @@ -2,6 +2,8 @@ import SwiftUI import Kingfisher import TTProgressHUD import ComposableArchitecture +import FoundationExt +import SwiftUINavigationExt // MARK: - CancelID enum ReadingCancelID: CaseIterable { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Database.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Database.swift index 709781c2d..bf85c6981 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Database.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Database.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import ComposableArchitecture +import FoundationExt // MARK: - Database & Download Actions extension ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+ImageFetch.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+ImageFetch.swift index 837099ba0..4848c6ced 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+ImageFetch.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+ImageFetch.swift @@ -1,5 +1,6 @@ import Foundation import ComposableArchitecture +import FoundationExt // MARK: - Image URL Fetch Actions extension ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift index 092f45472..01c0f9e49 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift @@ -3,6 +3,7 @@ import AppModels import Observation import SwiftUIPager import ComposableArchitecture +import FoundationExt struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift index 82975f488..a406ee7ef 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import AppModels import Foundation +import SwiftUINavigationExt @Reducer struct SearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift index e17093d9c..664279338 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift @@ -1,5 +1,7 @@ import ComposableArchitecture import AppModels +import FoundationExt +import SwiftUINavigationExt @Reducer struct SearchRootReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift index 2e4d353f2..cf00bf088 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift @@ -2,6 +2,8 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import FoundationExt +import SwiftUINavigationExt struct SearchRootView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift index dba5501e4..b6a5f10e8 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import ComposableArchitecture +import SwiftUINavigationExt struct SearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift index 7269e616c..793273082 100644 --- a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import SwiftUINavigationExt struct QuickSearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift index 8ce9f039a..053bcbb3b 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import ComposableArchitecture +import SwiftUINavigationExt @Reducer struct AccountSettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift index c7a907789..b47967d36 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import SwiftUINavigationExt struct AccountSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift index c563405b0..848b2e361 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import SwiftUINavigationExt struct AppearanceSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift index 4b4894e5b..e674305a4 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import ComposableArchitecture +import SwiftUINavigationExt @Reducer struct EhSettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift index 26b378f45..f7dbe0c54 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift @@ -2,6 +2,8 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import FoundationExt +import SwiftUINavigationExt extension EhSettingView { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift index 2def9ce31..41be2061b 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Resources +import FoundationExt extension EhSettingView { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift index f92e89508..66f1b9647 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import SwiftUINavigationExt struct EhSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift index 215b435bd..0b6414e28 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import FilePicker import ComposableArchitecture +import SwiftUINavigationExt struct GeneralSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift index 0431f849c..011a25ad9 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import ComposableArchitecture +import SwiftUINavigationExt @Reducer struct LoginReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift index df37a806b..012c3b17c 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift @@ -1,6 +1,7 @@ import SwiftUI import Resources import ComposableArchitecture +import SwiftUINavigationExt struct LogsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift index 1ef31e229..17585ae5e 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift @@ -2,6 +2,7 @@ import SwiftUI import Resources import SFSafeSymbols import ComposableArchitecture +import SwiftUINavigationExt struct SettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift index e34339300..bef2e005a 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import ImageIO import Kingfisher +import FoundationExt struct PreviewImageView: View { private let originalURL: URL? diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift index 6d5020660..b7c1d0d15 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift @@ -3,6 +3,7 @@ import SwiftUI import Kingfisher +import FoundationExt struct TagCloudView: View where TagCell: View, Element: Equatable & Identifiable, ID == Element.ID { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift index 726246f64..75e82a499 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import Kingfisher import Observation +import FoundationExt struct TagSuggestionView: View { @Binding private var keyword: String diff --git a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift index b9764ff13..a4907df57 100644 --- a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import SwiftUINavigationExt struct FiltersView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/ComposableArchitectureExt/.swiftlint.yml b/AppPackage/Sources/ComposableArchitectureExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/ComposableArchitectureExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/ComposableArchitectureExt/RecurseReducer.swift b/AppPackage/Sources/ComposableArchitectureExt/RecurseReducer.swift new file mode 100644 index 000000000..196d504c5 --- /dev/null +++ b/AppPackage/Sources/ComposableArchitectureExt/RecurseReducer.swift @@ -0,0 +1,19 @@ +import ComposableArchitecture + +// MARK: Recurse +public struct RecurseReducer: Reducer +where State == Base.State, Action == Base.Action { + let base: (Reduce) -> Base + + public init(@ReducerBuilder base: @escaping (Reduce) -> Base) { + self.base = base + } + + public var body: some Reducer { + var `self`: Reduce! + self = Reduce { state, action in + base(self)._reduce(into: &state, action: action) + } + return self + } +} diff --git a/AppPackage/Sources/FoundationExt/.swiftlint.yml b/AppPackage/Sources/FoundationExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/FoundationExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/Extensions.swift b/AppPackage/Sources/FoundationExt/Extensions.swift similarity index 77% rename from AppPackage/Sources/AppFeature/Tools/Extensions/Extensions.swift rename to AppPackage/Sources/FoundationExt/Extensions.swift index 4a40a5999..8fc72d209 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/Extensions.swift +++ b/AppPackage/Sources/FoundationExt/Extensions.swift @@ -1,10 +1,10 @@ import SwiftUI -import AppModels +import UIKit import Foundation // MARK: Encodable extension Encodable { - func toData() -> Data? { + public func toData() -> Data? { try? JSONEncoder().encode(self) } } @@ -12,17 +12,17 @@ extension Encodable { // MARK: UIApplication extension UIApplication { @MainActor - func endEditing() { + public func endEditing() { sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } } // MARK: Data extension Data { - func toObject() -> O? { + public func toObject() -> O? { try? JSONDecoder().decode(O.self, from: self) } - var utf8InvalidCharactersRipped: Data { + public var utf8InvalidCharactersRipped: Data { var data = self data.append(0) @@ -38,7 +38,7 @@ extension Data { // MARK: Float extension Float { - var halfRounded: Float { + public var halfRounded: Float { let lowerbound = Int(self) let upperbound = lowerbound + 1 let decimal: Float = self - Float(lowerbound) @@ -53,46 +53,30 @@ extension Float { } } -// MARK: URL -extension URL { - static let mock = Defaults.URL.ehentai - - func previewCacheCleanupURLs() -> [URL] { - guard let info = Parser.parsePreviewConfigs(url: self), - info.plainURL != self - else { - return [self] - } - - return [self, info.plainURL] - } - -} - // MARK: String extension String { - var isInteger: Bool { + public var isInteger: Bool { Int(self) != nil } - var isValidGID: Bool { + public var isValidGID: Bool { !isEmpty && isInteger } - var localizedKey: LocalizedStringKey { + public var localizedKey: LocalizedStringKey { .init(self) } - var emojisRipped: String { + public var emojisRipped: String { unicodeScalars .filter { !$0.properties.isEmojiPresentation } .reduce("") { $0 + .init($1) } } - var urlEncoded: String { + public var urlEncoded: String { addingPercentEncoding( withAllowedCharacters: .urlQueryAllowed ) ?? "" } - var isValidURL: Bool { + public var isValidURL: Bool { if let detector = try? NSDataDetector( types: NSTextCheckingResult.CheckingType.link.rawValue ) { @@ -104,17 +88,17 @@ extension String { } else { return false } } - func caseInsensitiveContains(_ other: String) -> Bool { + public func caseInsensitiveContains(_ other: String) -> Bool { range(of: other, options: .caseInsensitive) != nil } - func caseInsensitiveEqualsTo(_ other: String) -> Bool { + public func caseInsensitiveEqualsTo(_ other: String) -> Bool { caseInsensitiveContains(other) && count == other.count } } // MARK: UIImage extension UIImage { - func cropping(to rect: CGRect) -> UIImage? { + public func cropping(to rect: CGRect) -> UIImage? { let scaledRect = CGRect( x: rect.origin.x * scale, y: rect.origin.y * scale, @@ -126,13 +110,13 @@ extension UIImage { return UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation) } - func cropping(size: CGSize, offset: CGSize) -> UIImage? { + public func cropping(size: CGSize, offset: CGSize) -> UIImage? { let origin = CGPoint(x: offset.width, y: offset.height) let rect = CGRect(origin: origin, size: size) return cropping(to: rect) } - func withRoundedCorners(radius: CGFloat) -> UIImage? { + public func withRoundedCorners(radius: CGFloat) -> UIImage? { let maxRadius = min(size.width, size.height) / 2 let cornerRadius: CGFloat @@ -156,7 +140,7 @@ extension UIImage { // MARK: Color extension Color { - init(hex: String) { + public init(hex: String) { let hex = hex.trimmingCharacters( in: CharacterSet.alphanumerics.inverted ) @@ -184,30 +168,30 @@ extension Color { // MARK: Array extension Array { - func removeDuplicates(by predicate: (Element, Element) -> Bool) -> Self { + public func removeDuplicates(by predicate: (Element, Element) -> Bool) -> Self { var result = [Element]() for value in self where result.filter({ predicate($0, value) }).isEmpty { result.append(value) } return result } - func removeDuplicates(by keyPath: KeyPath) -> Self { + public func removeDuplicates(by keyPath: KeyPath) -> Self { removeDuplicates(by: { $0[keyPath: keyPath] == $1[keyPath: keyPath] }) } - func removeDuplicates() -> Self where Element: Equatable { + public func removeDuplicates() -> Self where Element: Equatable { removeDuplicates(by: ==) } } // MARK: Dictionary extension Dictionary { - var tuples: [(Key, Value)] { + public var tuples: [(Key, Value)] { map({ ($0.key, $0.value) }) } } // MARK: TimeInterval extension TimeInterval { - static let oneYear: Self = .init(60 * 60 * 24 * 365) - static let oneWeek: Self = .init(60 * 60 * 24 * 7) + public static let oneYear: Self = .init(60 * 60 * 24 * 365) + public static let oneWeek: Self = .init(60 * 60 * 24 * 7) } diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/URL+ImageCacheKey.swift b/AppPackage/Sources/FoundationExt/URL+ImageCacheKey.swift similarity index 96% rename from AppPackage/Sources/AppFeature/Tools/Extensions/URL+ImageCacheKey.swift rename to AppPackage/Sources/FoundationExt/URL+ImageCacheKey.swift index 15c7ed134..097f42522 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/URL+ImageCacheKey.swift +++ b/AppPackage/Sources/FoundationExt/URL+ImageCacheKey.swift @@ -8,7 +8,7 @@ extension URL { "gid", "page", "imgkey", "fileindex", "xres", "p", "key" ] - var stableImageCacheKey: String? { + public var stableImageCacheKey: String? { let normalizedPath = pathComponents .filter { $0 != "/" && !$0.isEmpty } .joined(separator: "/") @@ -29,7 +29,7 @@ extension URL { /// path yields one) so differing query/host variants of the same page collide, /// then the absolute URL as an exact-match fallback. Writers store under the /// primary key; readers check them in order. - var imageCacheKeys: [String] { + public var imageCacheKeys: [String] { var keys = [String]() if let stableImageCacheKey { keys.append(stableImageCacheKey) diff --git a/AppPackage/Sources/SwiftUINavigationExt/.swiftlint.yml b/AppPackage/Sources/SwiftUINavigationExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/SwiftUINavigationExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation_Extension.swift b/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation_Extension.swift new file mode 100644 index 000000000..b6d436298 --- /dev/null +++ b/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation_Extension.swift @@ -0,0 +1,99 @@ +import SwiftUI +import SwiftUINavigation + +extension NavigationLink { + public init( + _ title: S, + unwrapping value: Binding, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination + ) where Destination == WrappedDestination?, Label == Text { + self.init( + title, + destination: Binding(unwrapping: value).map(destination), + isActive: .init(value) + ) + } + public init( + unwrapping enum: Binding, + case caseKeyPath: CaseKeyPath, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination + ) where Destination == WrappedDestination?, Label == Text { + self.init( + "", unwrapping: `enum`.case(caseKeyPath), + destination: destination + ) + } +} + +extension View { + public func confirmationDialog( + message: String, + unwrapping enum: Binding, + case caseKeyPath: CaseKeyPath, + @ViewBuilder actions: @escaping (Case) -> A + ) -> some View { + self.confirmationDialog( + item: `enum`.case(caseKeyPath), + titleVisibility: .hidden, + title: { _ in Text("") }, + actions: actions, + message: { _ in Text(message) } + ) + } + public func confirmationDialog( + message: String, + unwrapping enum: Binding, + case caseKeyPath: CaseKeyPath, + matching case: Case, + @ViewBuilder actions: @escaping (Case) -> A + ) -> some View { + self.confirmationDialog( + item: { + let unwrapping = `enum`.case(caseKeyPath) + let isMatched = `case` == unwrapping.wrappedValue + return isMatched ? unwrapping : .constant(nil) + }(), + titleVisibility: .hidden, + title: { _ in Text("") }, + actions: actions, + message: { _ in Text(message) } + ) + } + + public func sheet( + unwrapping enum: Binding, + case caseKeyPath: CaseKeyPath, + @ViewBuilder content: @escaping (Case) -> Content + ) -> some View { + self.sheet( + isPresented: .constant(`enum`.case(caseKeyPath).wrappedValue != nil), + content: { `enum`.case(caseKeyPath).wrappedValue.map(content) } + ) + } +} + +extension Binding { + public func `case`( + _ caseKeyPath: CaseKeyPath + ) -> Binding where Value == Enum? { + let casePath = AnyCasePath(caseKeyPath) + return .init( + get: { self.wrappedValue.flatMap(casePath.extract(from:)) }, + set: { newValue, transaction in + self.transaction(transaction).wrappedValue = newValue.map(casePath.embed) + } + ) + } + + public func isRemovedDuplicatesPresent() -> Binding where Value == Wrapped? { + .init( + get: { wrappedValue != nil }, + set: { isPresent, transaction in + guard self.transaction(transaction).wrappedValue != nil else { return } + if !isPresent { + self.transaction(transaction).wrappedValue = nil + } + } + ) + } +} diff --git a/AppPackage/Sources/Utilities/.swiftlint.yml b/AppPackage/Sources/Utilities/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/Utilities/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift index df9925779..37685dd77 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift @@ -2,6 +2,7 @@ import UIKit import AppModels import Foundation import Testing +import FoundationExt @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift index 7972b2591..b600c6c9d 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -2,6 +2,7 @@ import CoreData import AppModels import Foundation import Testing +import FoundationExt @testable import AppFeature // MARK: - Sample Data Factories & CoreData Helpers diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift index 4f0b327e7..1da52d77f 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -4,6 +4,7 @@ import Kingfisher import UIKit import Foundation import Testing +import FoundationExt @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift index a55d6afdb..060bb7ca8 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift @@ -4,6 +4,7 @@ import Kingfisher import UIKit import Foundation import Testing +import FoundationExt @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift index 618aba423..329600f49 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift @@ -2,6 +2,7 @@ import UIKit import AppModels import Foundation import Testing +import FoundationExt @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift index b4b2c9efc..1638590ea 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import Testing import UIKit +import FoundationExt @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift index f54204fd6..2f8e41706 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Testing +import FoundationExt @testable import AppFeature struct SettingDownloadTests { From 175be1a1c527a2b7d3043644ae2be2e0a88b3e7a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 18:08:33 +0800 Subject: [PATCH 309/614] Extract Utilities module Move the foundational utilities out of App/Tools/Utilities into a new Utilities module (depends on AppModels): AppUtil, CookieUtil, DeviceUtil, FileUtil, URLUtil, UserDefaultsUtil. Defaults+Runtime moves here too: the host-derived Defaults.URL members and the device-derived FrameSize/ImageSize sizes depend on AppUtil/DeviceUtil, so they belong in the same layer (M3b had parked them in AppFeature only because the utilities still lived there). URLUtil consumes Defaults.URL.host et al., which is why it could not stay below them. Client-coupled helpers stay in AppFeature for M5 (DataCache, the DownloadStore family, HapticsUtil, ImagePlaceholderFingerprint, AppLaunchAutomation, UserDefaultsClient). Add the target to Package.swift, wire AppFeature and the test target, and add the import cascade. AppModels is unchanged. --- AppPackage/Package.swift | 10 +++++ .../DataFlow/AppDelegateReducer.swift | 1 + .../AppFeature/Network/Request+Account.swift | 1 + .../AppFeature/Network/Request+Detail.swift | 1 + .../AppFeature/Network/Request+Gallery.swift | 1 + .../AppFeature/Network/Request+Image.swift | 1 + .../Sources/AppFeature/Network/Request.swift | 1 + AppPackage/Sources/AppFeature/RootView.swift | 1 + .../AppFeature/Tools/CategoryColor.swift | 1 + .../Tools/Clients/AppDelegateClient.swift | 1 + .../Tools/Clients/DatabaseClient.swift | 1 + .../Tools/Clients/DeviceClient.swift | 1 + .../Tools/Clients/DownloadClient.swift | 1 + .../AppFeature/Tools/Clients/FileClient.swift | 1 + .../Tools/Clients/LibraryClient.swift | 1 + .../Tools/Clients/UIApplicationClient.swift | 1 + .../Tools/Clients/UserDefaultsClient.swift | 1 + .../AppFeature/Tools/Defaults+Runtime.swift | 43 ------------------- .../Tools/Parser/Parser+Preview.swift | 1 + .../Tools/Utilities/DownloadStore.swift | 1 + .../Tools/Utilities/UserDefaultsUtil.swift | 12 ------ .../View/Detail/Archives/ArchivesView.swift | 1 + .../View/Detail/Comments/CommentsView.swift | 1 + .../View/Detail/DetailReducer+Download.swift | 1 + .../DetailSearch/DetailSearchView.swift | 1 + .../Detail/DetailView+HeaderSection.swift | 1 + .../View/Detail/DetailView+Navigation.swift | 1 + .../View/Detail/DetailView+Subviews.swift | 1 + .../AppFeature/View/Detail/DetailView.swift | 1 + .../GalleryInfos/GalleryInfosView.swift | 1 + .../View/Detail/Previews/PreviewsView.swift | 1 + .../View/Downloads/DownloadsView.swift | 1 + .../View/Favorites/FavoritesView.swift | 1 + .../View/Home/Frontpage/FrontpageView.swift | 1 + .../View/Home/History/HistoryView.swift | 1 + .../View/Home/HomeView+Sections.swift | 1 + .../AppFeature/View/Home/HomeView.swift | 1 + .../View/Home/Popular/PopularView.swift | 1 + .../View/Home/Toplists/ToplistsView.swift | 1 + .../View/Home/Watched/WatchedView.swift | 1 + .../AppFeature/View/Reading/ReadingView.swift | 1 + .../View/Reading/ReadingViewComponents.swift | 1 + .../View/Reading/Support/ControlPanel.swift | 1 + .../View/Reading/Support/GestureHandler.swift | 1 + .../View/Reading/Support/PageHandler.swift | 1 + .../View/Search/SearchRootView+Keywords.swift | 1 + .../View/Search/SearchRootView.swift | 1 + .../AppFeature/View/Search/SearchView.swift | 1 + .../AccountSetting/AccountSettingView.swift | 1 + .../View/Setting/Components/AboutView.swift | 1 + .../Components/ReadingSettingView.swift | 1 + .../EhSetting/EhSettingView+Sections3.swift | 1 + .../Setting/EhSetting/EhSettingView.swift | 1 + .../View/Support/Components/AlertView.swift | 1 + .../Support/Components/CategoryView.swift | 1 + .../Components/Cells/GalleryCardCell.swift | 1 + .../View/Support/Components/GenericList.swift | 1 + .../View/Support/Components/Placeholder.swift | 1 + .../Components/TagSuggestionView.swift | 1 + .../AppFeature/View/Support/NewDawnView.swift | 1 + .../AppFeature/View/TabBar/TabBarView.swift | 1 + .../Tools => }/Utilities/AppUtil.swift | 10 ++--- .../Tools => }/Utilities/CookieUtil.swift | 6 +-- .../Sources/Utilities/Defaults+Runtime.swift | 43 +++++++++++++++++++ .../Tools => }/Utilities/DeviceUtil.swift | 34 +++++++-------- .../Tools => }/Utilities/FileUtil.swift | 6 +-- .../Tools => }/Utilities/URLUtil.swift | 40 ++++++++--------- .../Sources/Utilities/UserDefaultsUtil.swift | 12 ++++++ .../Download/DownloadBadgeSortTests.swift | 1 + .../DownloadFeatureTestFactories.swift | 1 + .../DownloadFilterAndBadgeTests.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + .../Parser/Other/SettingDownloadTests.swift | 1 + 74 files changed, 177 insertions(+), 103 deletions(-) delete mode 100644 AppPackage/Sources/AppFeature/Tools/Defaults+Runtime.swift delete mode 100644 AppPackage/Sources/AppFeature/Tools/Utilities/UserDefaultsUtil.swift rename AppPackage/Sources/{AppFeature/Tools => }/Utilities/AppUtil.swift (77%) rename AppPackage/Sources/{AppFeature/Tools => }/Utilities/CookieUtil.swift (88%) create mode 100644 AppPackage/Sources/Utilities/Defaults+Runtime.swift rename AppPackage/Sources/{AppFeature/Tools => }/Utilities/DeviceUtil.swift (66%) rename AppPackage/Sources/{AppFeature/Tools => }/Utilities/FileUtil.swift (67%) rename AppPackage/Sources/{AppFeature/Tools => }/Utilities/URLUtil.swift (85%) create mode 100644 AppPackage/Sources/Utilities/UserDefaultsUtil.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 32617eeba..8c2dd8510 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -214,6 +214,7 @@ let targets: [PackageDescription.Target] = [ .module(.foundationExt), .module(.resources), .module(.swiftUINavigationExt), + .module(.utilities), .targetDependency(.alertKit), .targetDependency(.colorful), .targetDependency(.commonMark), @@ -277,6 +278,14 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .utilities, + dependencies: [ + .module(.appModels) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), // MARK: Tests .testTarget( @@ -285,6 +294,7 @@ let targets: [PackageDescription.Target] = [ .module(.appFeature), .module(.appModels), .module(.foundationExt), + .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.kanna), .targetDependency(.kingfisher), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index d346ddca9..9fe876c1a 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -2,6 +2,7 @@ import SwiftUI import BackgroundTasks import SwiftyBeaver import ComposableArchitecture +import Utilities @Reducer struct AppDelegateReducer { diff --git a/AppPackage/Sources/AppFeature/Network/Request+Account.swift b/AppPackage/Sources/AppFeature/Network/Request+Account.swift index 8fc5072a3..918596a8d 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Account.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Account.swift @@ -3,6 +3,7 @@ import AppModels import Combine import Foundation import FoundationExt +import Utilities // MARK: Account Ops struct LoginRequest: Request { diff --git a/AppPackage/Sources/AppFeature/Network/Request+Detail.swift b/AppPackage/Sources/AppFeature/Network/Request+Detail.swift index 837414396..65388a1e3 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Detail.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Detail.swift @@ -2,6 +2,7 @@ import Kanna import AppModels import Combine import Foundation +import Utilities // MARK: Response Types struct GalleryDetailResponse { diff --git a/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift b/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift index 2a6ddfae5..b3a755806 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift @@ -2,6 +2,7 @@ import Kanna import AppModels import Combine import Foundation +import Utilities // MARK: Fetch ListItems struct SearchGalleriesRequest: Request { diff --git a/AppPackage/Sources/AppFeature/Network/Request+Image.swift b/AppPackage/Sources/AppFeature/Network/Request+Image.swift index 15107cf98..c2afd68e3 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Image.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Image.swift @@ -2,6 +2,7 @@ import Kanna import AppModels import Combine import Foundation +import Utilities // MARK: Response Types struct GalleryMPVImageURLResponse { diff --git a/AppPackage/Sources/AppFeature/Network/Request.swift b/AppPackage/Sources/AppFeature/Network/Request.swift index 78d21189c..329688a83 100644 --- a/AppPackage/Sources/AppFeature/Network/Request.swift +++ b/AppPackage/Sources/AppFeature/Network/Request.swift @@ -4,6 +4,7 @@ import Combine import Foundation import ComposableArchitecture import FoundationExt +import Utilities protocol Request { associatedtype Response: Sendable diff --git a/AppPackage/Sources/AppFeature/RootView.swift b/AppPackage/Sources/AppFeature/RootView.swift index 4bfd1f441..cb2415a23 100644 --- a/AppPackage/Sources/AppFeature/RootView.swift +++ b/AppPackage/Sources/AppFeature/RootView.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import SwiftUI import UIKit +import Utilities // MARK: RootView public struct RootView: View { diff --git a/AppPackage/Sources/AppFeature/Tools/CategoryColor.swift b/AppPackage/Sources/AppFeature/Tools/CategoryColor.swift index 55111d8af..57c441c96 100644 --- a/AppPackage/Sources/AppFeature/Tools/CategoryColor.swift +++ b/AppPackage/Sources/AppFeature/Tools/CategoryColor.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import Utilities // Binds the pure, host-parameterized color on the model types to the host the user is // currently browsing. The runtime lookup (UserDefaults via AppUtil) lives here in the app diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/AppDelegateClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/AppDelegateClient.swift index 109ac06c7..f3836c39c 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/AppDelegateClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/AppDelegateClient.swift @@ -1,5 +1,6 @@ import SwiftUI import ComposableArchitecture +import Utilities struct AppDelegateClient: Sendable { let setOrientation: @MainActor @Sendable (UIInterfaceOrientationMask) -> Void diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift index 4e2b67ed0..df7a68fba 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift @@ -4,6 +4,7 @@ import Combine import CoreData import ComposableArchitecture import FoundationExt +import Utilities struct DatabaseClient: Sendable { let prepareDatabase: @Sendable () async -> Result diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DeviceClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DeviceClient.swift index efe3bcd22..e260d61da 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DeviceClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DeviceClient.swift @@ -1,5 +1,6 @@ import SwiftUI import Dependencies +import Utilities struct DeviceClient: Sendable { let isPad: @Sendable () async -> Bool diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift index 37e12e6bc..e70485833 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import ComposableArchitecture +import Utilities @DependencyClient struct DownloadClient: Sendable { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift index b831b7407..e6139d606 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift @@ -2,6 +2,7 @@ import Combine import AppModels import Foundation import ComposableArchitecture +import Utilities struct FileClient: Sendable { let createFile: @Sendable (String, Data?) -> Bool diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift index 87b12480c..4d1cf3f45 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift @@ -8,6 +8,7 @@ import SDWebImageWebPCoder import SwiftyBeaver import UIImageColors import ComposableArchitecture +import Utilities struct LibraryClient: Sendable { let initializeLogger: @Sendable () -> Void diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift index d4a06a158..fc6b07e00 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift @@ -2,6 +2,7 @@ import SwiftUI import Combine import ComposableArchitecture import FoundationExt +import Utilities struct UIApplicationClient: Sendable { let openURL: @MainActor @Sendable (URL) -> Void diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/UserDefaultsClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/UserDefaultsClient.swift index 12cbfefb5..cb772a1df 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/UserDefaultsClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/UserDefaultsClient.swift @@ -1,5 +1,6 @@ import Foundation import ComposableArchitecture +import Utilities struct UserDefaultsClient: Sendable { let setValue: @Sendable (Any, AppUserDefaults) -> Void diff --git a/AppPackage/Sources/AppFeature/Tools/Defaults+Runtime.swift b/AppPackage/Sources/AppFeature/Tools/Defaults+Runtime.swift deleted file mode 100644 index 2046b2bb2..000000000 --- a/AppPackage/Sources/AppFeature/Tools/Defaults+Runtime.swift +++ /dev/null @@ -1,43 +0,0 @@ -import AppModels -import CoreGraphics -import Foundation - -// Runtime-derived defaults that depend on app utilities (device metrics, the active gallery host). -// They live in the app layer so AppModels.Defaults stays free of those dependencies. -extension Defaults { - @MainActor - struct FrameSize { - static var archiveGridWidth: CGFloat { - DeviceUtil.isPadWidth ? 175 : DeviceUtil.isSEWidth ? 125 : 150 - } - static var cardCellWidth: CGFloat { DeviceUtil.windowW * 0.8 } - static let cardCellHeight: CGFloat = Defaults.ImageSize.headerH + 20 * 2 - static var cardCellSize: CGSize { - .init(width: cardCellWidth, height: cardCellHeight) - } - static var rankingCellWidth: CGFloat { - (DeviceUtil.isPadWidth ? 0.4 : 0.7) * DeviceUtil.windowW - } - static var alertWidthFactor: Double { - DeviceUtil.isPadWidth ? 0.5 : 1.0 - } - } -} - -extension Defaults.ImageSize { - @MainActor static var previewMinW: CGFloat { DeviceUtil.isPadWidth ? 180 : 100 } - @MainActor static var previewMaxW: CGFloat { DeviceUtil.isPadWidth ? 220 : 120 } - @MainActor static var previewAvgW: CGFloat { (previewMinW + previewMaxW) / 2 } -} - -extension Defaults.URL { - static var host: Foundation.URL { AppUtil.galleryHost == .exhentai ? exhentai : ehentai } - static var api: Foundation.URL { host.appendingPathComponent("api.php") } - static var myTags: Foundation.URL { host.appendingPathComponent("mytags") } - static var uConfig: Foundation.URL { host.appendingPathComponent("uconfig.php") } - static var galleryPopups: Foundation.URL { host.appendingPathComponent("gallerypopups.php") } - static var galleryTorrents: Foundation.URL { host.appendingPathComponent("gallerytorrents.php") } - static var popular: Foundation.URL { host.appendingPathComponent("popular") } - static var watched: Foundation.URL { host.appendingPathComponent("watched") } - static var favorites: Foundation.URL { host.appendingPathComponent("favorites.php") } -} diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift index fe279dcd6..a18a1cb4d 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift +++ b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift @@ -1,6 +1,7 @@ import Kanna import AppModels import Foundation +import Utilities extension Parser { static func parsePreviewURLs(doc: HTMLDocument) throws -> [Int: URL] { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift index c530a79b9..40a0394c7 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift +++ b/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import Resources import CryptoKit +import Utilities enum DownloadValidationState: Equatable, Sendable { case valid diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/UserDefaultsUtil.swift b/AppPackage/Sources/AppFeature/Tools/Utilities/UserDefaultsUtil.swift deleted file mode 100644 index 502ed429d..000000000 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/UserDefaultsUtil.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -struct UserDefaultsUtil { - static func value(forKey key: AppUserDefaults) -> T? { - UserDefaults.standard.value(forKey: key.rawValue) as? T - } -} - -enum AppUserDefaults: String { - case galleryHost - case clipboardChangeCount -} diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift index e3099c4d3..d7fec7c49 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import Utilities struct ArchivesView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift index 74342ffc1..65a29030e 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift @@ -4,6 +4,7 @@ import Resources import Kingfisher import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct CommentsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift index 4cb8008e5..37e9d9b51 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import FoundationExt +import Utilities // MARK: - Download Action Handlers extension DetailReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift index e40813fff..d4f46d171 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct DetailSearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift index 91096b580..61d2f609d 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift @@ -3,6 +3,7 @@ import AppModels import Resources import Kingfisher import SFSafeSymbols +import Utilities // MARK: HeaderSection struct HeaderSection: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift index 536f66a29..a0c533ad7 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift @@ -2,6 +2,7 @@ import SwiftUI import Resources import ComposableArchitecture import SwiftUINavigationExt +import Utilities // MARK: NavigationLinks extension DetailView { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift index a81e14190..d63388363 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift @@ -3,6 +3,7 @@ import AppModels import Resources import Kingfisher import FoundationExt +import Utilities // MARK: DescriptionSection struct DescriptionSection: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift index 3b3da255a..ed9b0605a 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift @@ -4,6 +4,7 @@ import Resources import Kingfisher import ComposableArchitecture import CommonMark +import Utilities private enum DownloadDialog: Equatable { case delete(isActiveDownload: Bool) diff --git a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift index d48c4b2cc..549d8c308 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import Utilities struct GalleryInfosView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift index 5a84a02d9..1176562a4 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import Utilities struct PreviewsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift index 4fb06f61b..f9369a64d 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift @@ -4,6 +4,7 @@ import Resources import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct DownloadsView: View { private enum RowDialog: Identifiable { diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift index e8d000e0c..49457b46c 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift @@ -4,6 +4,7 @@ import Resources import AlertKit import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct FavoritesView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift index 5da47653d..8edd23baf 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift @@ -4,6 +4,7 @@ import Resources import AlertKit import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct FrontpageView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift index 9b4a55940..ec7eee9e2 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct HistoryView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift index 258a65ec7..2e82d57e2 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift @@ -4,6 +4,7 @@ import Resources import Kingfisher import SwiftUIPager import SFSafeSymbols +import Utilities // MARK: CardSlideSection struct CardSlideSection: View, Equatable { diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift index b05214bdb..f6f5d61b3 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift @@ -5,6 +5,7 @@ import Kingfisher import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct HomeView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift index 981108a5a..7d6246983 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct PopularView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift index 57d10b280..21549c451 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct ToplistsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift index 78d25c5c2..6859f026b 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct WatchedView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift index 01c0f9e49..9c88af31f 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift @@ -4,6 +4,7 @@ import Observation import SwiftUIPager import ComposableArchitecture import FoundationExt +import Utilities struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift index 8f4fa65af..fd9c19976 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift @@ -5,6 +5,7 @@ import Kingfisher import SDWebImage import SDWebImageSwiftUI import ComposableArchitecture +import Utilities // MARK: ImageStackConfig struct ImageStackConfig { diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift index dfbde95a5..c6599851a 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Resources +import Utilities // MARK: ControlPanel struct ControlPanel: View { diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/GestureHandler.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/GestureHandler.swift index d92a07397..9175ae8bb 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/GestureHandler.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/GestureHandler.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Observation +import Utilities @Observable @MainActor diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/PageHandler.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/PageHandler.swift index 6264b68a2..c23d5a411 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/PageHandler.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/PageHandler.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Observation +import Utilities @Observable @MainActor diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootView+Keywords.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootView+Keywords.swift index bec26f990..8557b45eb 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootView+Keywords.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootView+Keywords.swift @@ -1,4 +1,5 @@ import SwiftUI +import Utilities // MARK: DoubleVerticalKeywordsStack struct DoubleVerticalKeywordsStack: View { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift index cf00bf088..cff371729 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import FoundationExt import SwiftUINavigationExt +import Utilities struct SearchRootView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift index b6a5f10e8..f2131c4b3 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct SearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift index b47967d36..03a10d95c 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct AccountSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift index 6a318adc0..b59e4facc 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift @@ -1,5 +1,6 @@ import SwiftUI import Resources +import Utilities struct AboutView: View { private var version: String { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift index 79446377e..9a8a64559 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import Utilities struct ReadingSettingView: View { @Binding private var readingDirection: ReadingDirection diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift index 41be2061b..4cdd9a6aa 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import FoundationExt +import Utilities extension EhSettingView { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift index 66f1b9647..77f56d88e 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt +import Utilities struct EhSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift index e3614022e..1959a69d2 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import SFSafeSymbols +import Utilities struct LoadingView: View { private let title: String diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift index a8bae9288..ef0f25c0f 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import Utilities // MARK: CategoryLabel struct CategoryLabel: View { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift index 46914c323..a2e1acb4f 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift @@ -3,6 +3,7 @@ import AppModels import Colorful import Kingfisher import UIImageColors +import Utilities struct GalleryCardCell: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift b/AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift index d2ee7be5a..58acb72c5 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import WaterfallGrid import ComposableArchitecture +import Utilities struct GenericList: View { private let galleries: [Gallery] diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift index c2ce9f580..5c1e54c36 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import Utilities struct Placeholder: View { @Environment(\.inSheet) private var inSheet diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift index 75e82a499..43d312748 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift @@ -4,6 +4,7 @@ import Resources import Kingfisher import Observation import FoundationExt +import Utilities struct TagSuggestionView: View { @Binding private var keyword: String diff --git a/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift b/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift index ca9978359..42623272a 100644 --- a/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Resources +import Utilities struct NewDawnView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 7411f0cb3..a90446dd1 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import SFSafeSymbols import ComposableArchitecture +import Utilities struct TabBarView: View { @Environment(\.scenePhase) private var scenePhase diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/AppUtil.swift b/AppPackage/Sources/Utilities/AppUtil.swift similarity index 77% rename from AppPackage/Sources/AppFeature/Tools/Utilities/AppUtil.swift rename to AppPackage/Sources/Utilities/AppUtil.swift index a5b761ef0..badc1e585 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/AppUtil.swift +++ b/AppPackage/Sources/Utilities/AppUtil.swift @@ -1,11 +1,11 @@ import Foundation import AppModels -struct AppUtil { - static var version: String { +public struct AppUtil { + public static var version: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "null" } - static var build: String { + public static var build: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "null" } @@ -18,12 +18,12 @@ struct AppUtil { #endif } - static var galleryHost: GalleryHost { + public static var galleryHost: GalleryHost { let rawValue: String? = UserDefaultsUtil.value(forKey: .galleryHost) return GalleryHost(rawValue: rawValue ?? "") ?? .ehentai } - static func dispatchMainSync(execute work: () -> Void) { + public static func dispatchMainSync(execute work: () -> Void) { if Thread.isMainThread { work() } else { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/CookieUtil.swift b/AppPackage/Sources/Utilities/CookieUtil.swift similarity index 88% rename from AppPackage/Sources/AppFeature/Tools/Utilities/CookieUtil.swift rename to AppPackage/Sources/Utilities/CookieUtil.swift index 43ce99973..f6b91b795 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/CookieUtil.swift +++ b/AppPackage/Sources/Utilities/CookieUtil.swift @@ -2,13 +2,13 @@ import Foundation import AppModels // MARK: Cookie -struct CookieUtil { - static var didLogin: Bool { +public struct CookieUtil { + public static var didLogin: Bool { CookieUtil.verify(for: Defaults.URL.ehentai, isEx: false) || CookieUtil.verify(for: Defaults.URL.exhentai, isEx: true) } - static func verify(for url: URL, isEx: Bool) -> Bool { + public static func verify(for url: URL, isEx: Bool) -> Bool { guard let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty else { return false } var igneous, memberID, passHash: String? diff --git a/AppPackage/Sources/Utilities/Defaults+Runtime.swift b/AppPackage/Sources/Utilities/Defaults+Runtime.swift new file mode 100644 index 000000000..0dbfe98e2 --- /dev/null +++ b/AppPackage/Sources/Utilities/Defaults+Runtime.swift @@ -0,0 +1,43 @@ +import AppModels +import CoreGraphics +import Foundation + +// Runtime-derived defaults that depend on app utilities (device metrics, the active gallery host). +// They live in the utilities layer so AppModels.Defaults stays free of those dependencies. +extension Defaults { + @MainActor + public struct FrameSize { + public static var archiveGridWidth: CGFloat { + DeviceUtil.isPadWidth ? 175 : DeviceUtil.isSEWidth ? 125 : 150 + } + public static var cardCellWidth: CGFloat { DeviceUtil.windowW * 0.8 } + public static let cardCellHeight: CGFloat = Defaults.ImageSize.headerH + 20 * 2 + public static var cardCellSize: CGSize { + .init(width: cardCellWidth, height: cardCellHeight) + } + public static var rankingCellWidth: CGFloat { + (DeviceUtil.isPadWidth ? 0.4 : 0.7) * DeviceUtil.windowW + } + public static var alertWidthFactor: Double { + DeviceUtil.isPadWidth ? 0.5 : 1.0 + } + } +} + +extension Defaults.ImageSize { + @MainActor public static var previewMinW: CGFloat { DeviceUtil.isPadWidth ? 180 : 100 } + @MainActor public static var previewMaxW: CGFloat { DeviceUtil.isPadWidth ? 220 : 120 } + @MainActor public static var previewAvgW: CGFloat { (previewMinW + previewMaxW) / 2 } +} + +extension Defaults.URL { + public static var host: Foundation.URL { AppUtil.galleryHost == .exhentai ? exhentai : ehentai } + public static var api: Foundation.URL { host.appendingPathComponent("api.php") } + public static var myTags: Foundation.URL { host.appendingPathComponent("mytags") } + public static var uConfig: Foundation.URL { host.appendingPathComponent("uconfig.php") } + public static var galleryPopups: Foundation.URL { host.appendingPathComponent("gallerypopups.php") } + public static var galleryTorrents: Foundation.URL { host.appendingPathComponent("gallerytorrents.php") } + public static var popular: Foundation.URL { host.appendingPathComponent("popular") } + public static var watched: Foundation.URL { host.appendingPathComponent("watched") } + public static var favorites: Foundation.URL { host.appendingPathComponent("favorites.php") } +} diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DeviceUtil.swift b/AppPackage/Sources/Utilities/DeviceUtil.swift similarity index 66% rename from AppPackage/Sources/AppFeature/Tools/Utilities/DeviceUtil.swift rename to AppPackage/Sources/Utilities/DeviceUtil.swift index 2c63487b8..f21159102 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DeviceUtil.swift +++ b/AppPackage/Sources/Utilities/DeviceUtil.swift @@ -2,29 +2,29 @@ import SwiftUI import Foundation @MainActor -struct DeviceUtil { - static var isPad: Bool { +public struct DeviceUtil { + public static var isPad: Bool { UIDevice.current.userInterfaceIdiom == .pad } - static var isPhone: Bool { + public static var isPhone: Bool { UIDevice.current.userInterfaceIdiom == .phone } - static var isPadWidth: Bool { + public static var isPadWidth: Bool { windowW >= 744 } - static var isSEWidth: Bool { + public static var isSEWidth: Bool { windowW <= 320 } - static var keyWindow: UIWindow? { + public static var keyWindow: UIWindow? { UIApplication.shared.connectedScenes .filter({ $0.activationState == .foregroundActive }) .compactMap({ $0 as? UIWindowScene }).last? .windows.filter({ $0.isKeyWindow }).last } - static var anyWindow: UIWindow? { + public static var anyWindow: UIWindow? { UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }).last? .windows.last @@ -34,45 +34,45 @@ struct DeviceUtil { keyWindow?.windowScene?.screen ?? anyWindow?.windowScene?.screen } - static var isLandscape: Bool { + public static var isLandscape: Bool { [.landscapeLeft, .landscapeRight] .contains(keyWindow?.windowScene?.effectiveGeometry.interfaceOrientation) } - static var isPortrait: Bool { + public static var isPortrait: Bool { [.portrait, .portraitUpsideDown] .contains(keyWindow?.windowScene?.effectiveGeometry.interfaceOrientation) } - static var windowW: CGFloat { + public static var windowW: CGFloat { min(absWindowW, absWindowH) } - static var windowH: CGFloat { + public static var windowH: CGFloat { max(absWindowW, absWindowH) } - static var screenW: CGFloat { + public static var screenW: CGFloat { min(absScreenW, absScreenH) } - static var screenH: CGFloat { + public static var screenH: CGFloat { max(absScreenW, absScreenH) } - static var absWindowW: CGFloat { + public static var absWindowW: CGFloat { keyWindow?.frame.size.width ?? absScreenW } - static var absWindowH: CGFloat { + public static var absWindowH: CGFloat { keyWindow?.frame.size.height ?? absScreenH } - static var absScreenW: CGFloat { + public static var absScreenW: CGFloat { currentScreen?.bounds.size.width ?? 0 } - static var absScreenH: CGFloat { + public static var absScreenH: CGFloat { currentScreen?.bounds.size.height ?? 0 } } diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/FileUtil.swift b/AppPackage/Sources/Utilities/FileUtil.swift similarity index 67% rename from AppPackage/Sources/AppFeature/Tools/Utilities/FileUtil.swift rename to AppPackage/Sources/Utilities/FileUtil.swift index 245dd210d..e99b233ec 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/FileUtil.swift +++ b/AppPackage/Sources/Utilities/FileUtil.swift @@ -1,11 +1,11 @@ import Foundation import AppModels -struct FileUtil { - static var logsDirectoryURL: URL { +public struct FileUtil { + public static var logsDirectoryURL: URL { .documentsDirectory.appendingPathComponent(Defaults.FilePath.logs) } - static var downloadsDirectoryURL: URL { + public static var downloadsDirectoryURL: URL { .documentsDirectory.appendingPathComponent( Defaults.FilePath.downloads, isDirectory: true diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/URLUtil.swift b/AppPackage/Sources/Utilities/URLUtil.swift similarity index 85% rename from AppPackage/Sources/AppFeature/Tools/Utilities/URLUtil.swift rename to AppPackage/Sources/Utilities/URLUtil.swift index 949156e03..f183ed9b7 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/URLUtil.swift +++ b/AppPackage/Sources/Utilities/URLUtil.swift @@ -1,29 +1,29 @@ import Foundation import AppModels -struct URLUtil { +public struct URLUtil { // Fetch - static func searchList(keyword: String, filter: Filter) -> URL { + public static func searchList(keyword: String, filter: Filter) -> URL { Defaults.URL.host.appending(queryItems: [.fSearch: keyword]).applyingFilter(filter) } - static func moreSearchList(keyword: String, filter: Filter, lastID: String) -> URL { + public static func moreSearchList(keyword: String, filter: Filter, lastID: String) -> URL { Defaults.URL.host.appending(queryItems: [.fSearch: keyword, .next: lastID]).applyingFilter(filter) } - static func frontpageList(filter: Filter) -> URL { + public static func frontpageList(filter: Filter) -> URL { Defaults.URL.host.applyingFilter(filter) } - static func moreFrontpageList(filter: Filter, lastID: String) -> URL { + public static func moreFrontpageList(filter: Filter, lastID: String) -> URL { Defaults.URL.host.appending(queryItems: [.next: lastID]).applyingFilter(filter) } - static func popularList(filter: Filter) -> URL { + public static func popularList(filter: Filter) -> URL { Defaults.URL.popular.applyingFilter(filter) } - static func watchedList(filter: Filter, keyword: String = "") -> URL { + public static func watchedList(filter: Filter, keyword: String = "") -> URL { var url = Defaults.URL.watched if !keyword.isEmpty { url.append(queryItems: [.fSearch: keyword]) @@ -31,7 +31,7 @@ struct URLUtil { return url.applyingFilter(filter) } - static func moreWatchedList(filter: Filter, lastID: String, keyword: String = "") -> URL { + public static func moreWatchedList(filter: Filter, lastID: String, keyword: String = "") -> URL { var url = Defaults.URL.watched.appending(queryItems: [.next: lastID]) if !keyword.isEmpty { url.append(queryItems: [.fSearch: keyword]) @@ -39,7 +39,7 @@ struct URLUtil { return url.applyingFilter(filter) } - static func favoritesList( + public static func favoritesList( favIndex: Int, keyword: String = "", sortOrder: FavoritesSortOrder? = nil @@ -63,7 +63,7 @@ struct URLUtil { return url } - static func moreFavoritesList( + public static func moreFavoritesList( favIndex: Int, lastID: String, lastTimestamp: String, @@ -82,7 +82,7 @@ struct URLUtil { return url } - static func toplistsList(catIndex: Int, pageNum: Int? = nil) -> URL { + public static func toplistsList(catIndex: Int, pageNum: Int? = nil) -> URL { var url = Defaults.URL.toplist.appending(queryItems: [.topcat: String(catIndex)]) if let pageNum = pageNum { url.append(queryItems: [.letterP: String(pageNum)]) @@ -90,35 +90,35 @@ struct URLUtil { return url } - static func moreToplistsList(catIndex: Int, pageNum: Int) -> URL { + public static func moreToplistsList(catIndex: Int, pageNum: Int) -> URL { Defaults.URL.toplist.appending(queryItems: [.topcat: String(catIndex), .letterP: String(pageNum)]) } - static func galleryDetail(url: URL) -> URL { + public static func galleryDetail(url: URL) -> URL { url.appending(queryItems: [.showComments: .one]) } - static func galleryTorrents(gid: String, token: String) -> URL { + public static func galleryTorrents(gid: String, token: String) -> URL { Defaults.URL.galleryTorrents.appending(queryItems: [.gid: gid, .token: token]) } // Account Associated Operations - static func addFavorite(gid: String, token: String) -> URL { + public static func addFavorite(gid: String, token: String) -> URL { Defaults.URL.galleryPopups .appending(queryItems: [.gid: gid, .token: token]) .appending(queryItems: [.act: .addFavAct]) } - static func userInfo(uid: String) -> URL { + public static func userInfo(uid: String) -> URL { Defaults.URL.forum.appending(queryItems: [.showUser: uid]) } // Misc - static func detailPage(url: URL, pageNum: Int) -> URL { + public static func detailPage(url: URL, pageNum: Int) -> URL { url.appending(queryItems: [.letterP: String(pageNum)]) } - static func combinedPreviewURL(plainURL: URL, width: String, height: String, offset: String) -> URL { + public static func combinedPreviewURL(plainURL: URL, width: String, height: String, offset: String) -> URL { plainURL .appending(queryItems: [.ehpandaWidth: width]) .appending(queryItems: [.ehpandaHeight: height]) @@ -126,11 +126,11 @@ struct URLUtil { } // GitHub - static func githubAPI(repoName: String) -> URL { + public static func githubAPI(repoName: String) -> URL { Defaults.URL.githubAPI.appendingPathComponent("\(repoName)/releases/latest") } - static func githubDownload(repoName: String, fileName: String) -> URL { + public static func githubDownload(repoName: String, fileName: String) -> URL { Defaults.URL.github.appendingPathComponent("\(repoName)/releases/latest/download/\(fileName)") } } diff --git a/AppPackage/Sources/Utilities/UserDefaultsUtil.swift b/AppPackage/Sources/Utilities/UserDefaultsUtil.swift new file mode 100644 index 000000000..235f494d6 --- /dev/null +++ b/AppPackage/Sources/Utilities/UserDefaultsUtil.swift @@ -0,0 +1,12 @@ +import Foundation + +public struct UserDefaultsUtil { + public static func value(forKey key: AppUserDefaults) -> T? { + UserDefaults.standard.value(forKey: key.rawValue) as? T + } +} + +public enum AppUserDefaults: String { + case galleryHost + case clipboardChangeCount +} diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift index e0b73b40a..bc800cb2e 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift @@ -4,6 +4,7 @@ import Foundation import SFSafeSymbols import ComposableArchitecture import Testing +import Utilities @testable import AppFeature struct DownloadBadgeSortTests: DownloadFeatureTestCase { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift index b600c6c9d..a825e2874 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -3,6 +3,7 @@ import AppModels import Foundation import Testing import FoundationExt +import Utilities @testable import AppFeature // MARK: - Sample Data Factories & CoreData Helpers diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 7b7efec8c..8dd2d21c4 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -4,6 +4,7 @@ import Foundation import SFSafeSymbols import ComposableArchitecture import Testing +import Utilities @testable import AppFeature struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index 0111df0ac..db733b8fb 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import Utilities @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index 17b71a155..e767dedff 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import Utilities @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift index 2f8e41706..4a0ff3ec3 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Testing import FoundationExt +import Utilities @testable import AppFeature struct SettingDownloadTests { From 8a6566a3e7cb4ac819af3db86a22c533aa3094bd Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 18:29:43 +0800 Subject: [PATCH 310/614] Move shared DataCache and ImagePlaceholderFingerprint into Utilities --- .../Tools/Clients/DownloadClient+Cache.swift | 1 + .../DownloadClient+ResponseValidation.swift | 1 + ...loadClient+ResponseValidationHelpers.swift | 1 + .../Tools/Clients/ImageClient.swift | 1 + .../Tools => }/Utilities/DataCache.swift | 52 +++++++++---------- .../ImagePlaceholderFingerprint.swift | 16 +++--- .../Tests/Download/DataCacheTests.swift | 1 + .../DownloadCoordinatorCaptureTests.swift | 1 + .../Tests/Download/ReaderImageDataTests.swift | 1 + 9 files changed, 41 insertions(+), 34 deletions(-) rename AppPackage/Sources/{AppFeature/Tools => }/Utilities/DataCache.swift (91%) rename AppPackage/Sources/{AppFeature/Tools => }/Utilities/ImagePlaceholderFingerprint.swift (76%) diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift index 2e43bb43f..9d5ac6f92 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import FoundationExt +import Utilities // MARK: - Cache Operations extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift index 578a22983..eb7ead657 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift @@ -3,6 +3,7 @@ import AppModels import Foundation import ImageIO import FoundationExt +import Utilities // MARK: - Response Error Detection extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index c306a6249..3ac11c727 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -3,6 +3,7 @@ import AppModels import Foundation import ImageIO import FoundationExt +import Utilities // MARK: - Response Inspection Helpers extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift index 81d96502d..4418d00e2 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift @@ -4,6 +4,7 @@ import SwiftUI import Combine import ComposableArchitecture import FoundationExt +import Utilities struct ImageClient: Sendable { struct ImageAsset { diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DataCache.swift b/AppPackage/Sources/Utilities/DataCache.swift similarity index 91% rename from AppPackage/Sources/AppFeature/Tools/Utilities/DataCache.swift rename to AppPackage/Sources/Utilities/DataCache.swift index 1052379d9..5d0b40a75 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DataCache.swift +++ b/AppPackage/Sources/Utilities/DataCache.swift @@ -14,15 +14,15 @@ import UIKit /// data is then handed to whichever engine renders it (routed by `Data.isAnimatedImageData`). /// Scope is deliberately the reader and its exports only. Covers, previews, and cells stay /// on plain Kingfisher URL-mode caching. -actor DataCache { - struct Configuration: Equatable, Sendable { - var rootURL: URL - var memoryCostLimit: Int - var maxDiskAge: TimeInterval - var diskSizeLimit: UInt64 - var sweepByteInterval: UInt64 - - init( +public actor DataCache { + public struct Configuration: Equatable, Sendable { + public var rootURL: URL + public var memoryCostLimit: Int + public var maxDiskAge: TimeInterval + public var diskSizeLimit: UInt64 + public var sweepByteInterval: UInt64 + + public init( rootURL: URL = URL.cachesDirectory .appendingPathComponent("DataCache.reading", isDirectory: true), memoryCostLimit: Int = Int(ProcessInfo.processInfo.physicalMemory / 4), @@ -37,14 +37,14 @@ actor DataCache { } } - static let shared = DataCache() + public static let shared = DataCache() private let configuration: Configuration private let fileManager: FileManager private let memoryCache = NSCache() private var bytesWrittenSinceSweep: UInt64 = 0 - init( + public init( configuration: Configuration = .init(), fileManager: sending FileManager = FileManager() ) { @@ -53,13 +53,13 @@ actor DataCache { memoryCache.totalCostLimit = configuration.memoryCostLimit } - nonisolated static func installSystemPurgeObservers() { + public nonisolated static func installSystemPurgeObservers() { Task { @MainActor in _ = dataCacheSystemPurgeObserver } } - func data(forKey key: String) -> Data? { + public func data(forKey key: String) -> Data? { let filename = Self.filename(forKey: key) if let data = memoryCache.object(forKey: filename as NSString) { return Data(referencing: data) @@ -86,7 +86,7 @@ actor DataCache { return data } - func data(forKeys keys: [String]) -> Data? { + public func data(forKeys keys: [String]) -> Data? { for key in Self.uniqued(keys) { if let data = data(forKey: key) { return data @@ -95,7 +95,7 @@ actor DataCache { return nil } - func store(_ data: Data, forKey key: String) throws { + public func store(_ data: Data, forKey key: String) throws { let filename = Self.filename(forKey: key) memoryCache.setObject(data as NSData, forKey: filename as NSString, cost: data.count) let fileURL = configuration.rootURL.appendingPathComponent(filename) @@ -108,13 +108,13 @@ actor DataCache { } } - func store(_ data: Data, forKeys keys: [String]) throws { + public func store(_ data: Data, forKeys keys: [String]) throws { for key in Self.uniqued(keys) { try store(data, forKey: key) } } - func removeData(forKey key: String) throws { + public func removeData(forKey key: String) throws { let filename = Self.filename(forKey: key) memoryCache.removeObject(forKey: filename as NSString) let fileURL = configuration.rootURL.appendingPathComponent(filename) @@ -122,13 +122,13 @@ actor DataCache { try fileManager.removeItem(at: fileURL) } - func removeData(forKeys keys: [String]) throws { + public func removeData(forKeys keys: [String]) throws { for key in Self.uniqued(keys) { try removeData(forKey: key) } } - func removeAll() throws { + public func removeAll() throws { memoryCache.removeAllObjects() if fileManager.fileExists(atPath: configuration.rootURL.path) { try fileManager.removeItem(at: configuration.rootURL) @@ -137,11 +137,11 @@ actor DataCache { bytesWrittenSinceSweep = 0 } - func removeAllMemory() { + public func removeAllMemory() { memoryCache.removeAllObjects() } - func totalSize() throws -> UInt64 { + public func totalSize() throws -> UInt64 { guard fileManager.fileExists(atPath: configuration.rootURL.path) else { return 0 } guard let enumerator = fileManager.enumerator( at: configuration.rootURL, @@ -162,7 +162,7 @@ actor DataCache { return total } - func sweepDisk() throws { + public func sweepDisk() throws { guard fileManager.fileExists(atPath: configuration.rootURL.path) else { return } var entries = try diskEntries() let now = Date() @@ -297,7 +297,7 @@ private let dataCacheSystemPurgeObserver = DataCacheSystemPurgeObserver(cache: . private final class DataCacheSystemPurgeObserver { private let tokens: [NSObjectProtocol] - init(cache: DataCache) { + public init(cache: DataCache) { let center = NotificationCenter.default tokens = [ center.addObserver( @@ -324,7 +324,7 @@ private final class DataCacheSystemPurgeObserver { } private struct DiskEntry { - let url: URL - let size: UInt64 - let accessDate: Date + public let url: URL + public let size: UInt64 + public let accessDate: Date } diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/ImagePlaceholderFingerprint.swift b/AppPackage/Sources/Utilities/ImagePlaceholderFingerprint.swift similarity index 76% rename from AppPackage/Sources/AppFeature/Tools/Utilities/ImagePlaceholderFingerprint.swift rename to AppPackage/Sources/Utilities/ImagePlaceholderFingerprint.swift index 2ec4e7531..a583744d1 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/ImagePlaceholderFingerprint.swift +++ b/AppPackage/Sources/Utilities/ImagePlaceholderFingerprint.swift @@ -9,26 +9,26 @@ import Foundation /// identically — a placeholder that slips through poisons the shared image cache /// for its full expiry window and can be displayed or exported as if it were the /// real page. Centralising the fingerprints keeps the two paths in lockstep. -enum ImagePlaceholderFingerprint: Sendable { +public enum ImagePlaceholderFingerprint: Sendable { case authenticationRequired case quotaExceeded - var error: AppError { + public var error: AppError { switch self { case .authenticationRequired: .authenticationRequired case .quotaExceeded: .quotaExceeded } } - static let authenticationRequiredByteCount = 144_844 - static let authenticationRequiredSHA1 = "e48ed350e902a51581246d2a764fa7827e8e6988" - static let quotaExceededByteCount = 28_658 - static let quotaExceededSHA1 = "f54b887b017694dc25eb1a1404f71981885f8ed9" + public static let authenticationRequiredByteCount = 144_844 + public static let authenticationRequiredSHA1 = "e48ed350e902a51581246d2a764fa7827e8e6988" + public static let quotaExceededByteCount = 28_658 + public static let quotaExceededSHA1 = "f54b887b017694dc25eb1a1404f71981885f8ed9" /// Returns the matching placeholder when `data` is byte-for-byte one of the /// known fixtures (exact length plus SHA-1), otherwise `nil`. The length gate /// makes the common, non-placeholder case cost one comparison. - static func match(_ data: Data) -> Self? { + public static func match(_ data: Data) -> Self? { if matches(data, byteCount: authenticationRequiredByteCount, sha1: authenticationRequiredSHA1) { return .authenticationRequired } @@ -43,7 +43,7 @@ enum ImagePlaceholderFingerprint: Sendable { return sha1Hex(for: data) == sha1 } - static func sha1Hex(for data: Data) -> String { + public static func sha1Hex(for data: Data) -> String { Insecure.SHA1.hash(data: data) .map { String(format: "%02x", $0) } .joined() diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift index d8c785b26..af21e2d04 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import Utilities @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift index 37685dd77..b0066b4c8 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift @@ -3,6 +3,7 @@ import AppModels import Foundation import Testing import FoundationExt +import Utilities @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift index 1638590ea..1fc232dcf 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift @@ -3,6 +3,7 @@ import AppModels import Testing import UIKit import FoundationExt +import Utilities @testable import AppFeature @Suite(.serialized) From f3867d18caeb2b19d48359bdf566643e18cd1a23 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 18:40:57 +0800 Subject: [PATCH 311/614] Extract Authorization/Logger/URL/UserDefaults/UIApplication client modules Move the five leaf clients with no AppFeature back-references into their own modules. Relocate the pure URL component helpers (modifyComponent/replaceHost/ replaceScheme) out of the DF networking file into FoundationExt so URLClient can reach them without depending on the network layer. --- AppPackage/Package.swift | 60 +++++++++++++++++++ .../AppFeature/DataFlow/AppLockReducer.swift | 1 + .../AppFeature/DataFlow/AppReducer.swift | 1 + .../AppFeature/DataFlow/AppRouteReducer.swift | 2 + .../AppFeature/Network/DFExtensions.swift | 23 +------ .../DownloadClient+ExecutionSupport.swift | 1 + .../Tools/Clients/UserDefaultsClient.swift | 46 -------------- .../Detail/Archives/ArchivesReducer.swift | 1 + .../Detail/Comments/CommentsReducer.swift | 2 + .../View/Reading/ReadingReducer.swift | 1 + .../Setting/EhSetting/EhSettingReducer.swift | 1 + .../GeneralSettingReducer.swift | 2 + .../View/Setting/Logs/LogsReducer.swift | 1 + .../View/Setting/SettingReducer.swift | 3 + .../AuthorizationClient/.swiftlint.yml | 1 + .../AuthorizationClient.swift | 24 ++++---- .../FoundationExt/URL+Components.swift | 22 +++++++ .../Sources/LoggerClient/.swiftlint.yml | 1 + .../LoggerClient.swift | 24 ++++---- .../UIApplicationClient/.swiftlint.yml | 1 + .../UIApplicationClient.swift | 34 +++++------ AppPackage/Sources/URLClient/.swiftlint.yml | 1 + .../Clients => URLClient}/URLClient.swift | 51 +++++++++------- .../Sources/UserDefaultsClient/.swiftlint.yml | 1 + .../UserDefaultsClient.swift | 46 ++++++++++++++ .../Download/DownloadAutomationTests.swift | 4 ++ .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../ReadingReducerDownloadTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + .../Parser/Other/SettingDownloadTests.swift | 1 + 31 files changed, 231 insertions(+), 129 deletions(-) delete mode 100644 AppPackage/Sources/AppFeature/Tools/Clients/UserDefaultsClient.swift create mode 100644 AppPackage/Sources/AuthorizationClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => AuthorizationClient}/AuthorizationClient.swift (66%) create mode 100644 AppPackage/Sources/FoundationExt/URL+Components.swift create mode 100644 AppPackage/Sources/LoggerClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => LoggerClient}/LoggerClient.swift (57%) create mode 100644 AppPackage/Sources/UIApplicationClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => UIApplicationClient}/UIApplicationClient.swift (69%) create mode 100644 AppPackage/Sources/URLClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => URLClient}/URLClient.swift (65%) create mode 100644 AppPackage/Sources/UserDefaultsClient/.swiftlint.yml create mode 100644 AppPackage/Sources/UserDefaultsClient/UserDefaultsClient.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 8c2dd8510..a55b05dc0 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -68,10 +68,15 @@ let sharedSwiftSettings: [PackageDescription.SwiftSetting] = [ enum Module: String { case appFeature = "AppFeature" case appModels = "AppModels" + case authorizationClient = "AuthorizationClient" case composableArchitectureExt = "ComposableArchitectureExt" case foundationExt = "FoundationExt" + case loggerClient = "LoggerClient" case resources = "Resources" case swiftUINavigationExt = "SwiftUINavigationExt" + case uiApplicationClient = "UIApplicationClient" + case urlClient = "URLClient" + case userDefaultsClient = "UserDefaultsClient" case utilities = "Utilities" // Test targets @@ -210,10 +215,15 @@ let targets: [PackageDescription.Target] = [ module: .appFeature, dependencies: [ .module(.appModels), + .module(.authorizationClient), .module(.composableArchitectureExt), .module(.foundationExt), + .module(.loggerClient), .module(.resources), .module(.swiftUINavigationExt), + .module(.uiApplicationClient), + .module(.urlClient), + .module(.userDefaultsClient), .module(.utilities), .targetDependency(.alertKit), .targetDependency(.colorful), @@ -286,6 +296,52 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .authorizationClient, + dependencies: [ + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + .target( + module: .loggerClient, + dependencies: [ + .module(.appModels), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + .target( + module: .uiApplicationClient, + dependencies: [ + .module(.foundationExt), + .module(.utilities), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + .target( + module: .urlClient, + dependencies: [ + .module(.appModels), + .module(.foundationExt), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + .target( + module: .userDefaultsClient, + dependencies: [ + .module(.utilities), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), // MARK: Tests .testTarget( @@ -294,6 +350,10 @@ let targets: [PackageDescription.Target] = [ .module(.appFeature), .module(.appModels), .module(.foundationExt), + .module(.loggerClient), + .module(.uiApplicationClient), + .module(.urlClient), + .module(.userDefaultsClient), .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.kanna), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift index f0f9d3cba..8cf251073 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift @@ -1,6 +1,7 @@ import SwiftUI import Resources import ComposableArchitecture +import AuthorizationClient @Reducer struct AppLockReducer { diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 4706a08ba..99297fe67 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -1,5 +1,6 @@ import SwiftUI import ComposableArchitecture +import URLClient @Reducer struct AppReducer { diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 4cb149d1e..25173bb61 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -2,6 +2,8 @@ import SwiftUI import AppModels import ComposableArchitecture import SwiftUINavigationExt +import URLClient +import UserDefaultsClient @Reducer struct AppRouteReducer { diff --git a/AppPackage/Sources/AppFeature/Network/DFExtensions.swift b/AppPackage/Sources/AppFeature/Network/DFExtensions.swift index 71970c43e..ff14fa4c0 100644 --- a/AppPackage/Sources/AppFeature/Network/DFExtensions.swift +++ b/AppPackage/Sources/AppFeature/Network/DFExtensions.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import DeprecatedAPI +import FoundationExt // MARK: Global private func forceDowncast(object: Any) -> T! { @@ -16,28 +17,6 @@ private func forceDowncast(object: Any) -> T! { return nil } -// MARK: URL -extension URL { - func modifyComponent(for url: URL, commitChanges: (inout URLComponents) -> Void) -> URL? { - guard var components = URLComponents( - url: self, resolvingAgainstBaseURL: false - ) - else { return nil } - commitChanges(&components) - return components.url - } - func replaceHost(to newHost: String?) -> URL? { - modifyComponent(for: self) { components in - components.host = newHost - } - } - func replaceScheme(to newScheme: String?) -> URL? { - modifyComponent(for: self) { components in - components.scheme = newScheme - } - } -} - // MARK: URLRequest extension URLRequest { var urlContainsImageURL: Bool { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift index b6ea0fd65..b3667c118 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import URLClient // MARK: - Execution Support extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/UserDefaultsClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/UserDefaultsClient.swift deleted file mode 100644 index cb772a1df..000000000 --- a/AppPackage/Sources/AppFeature/Tools/Clients/UserDefaultsClient.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation -import ComposableArchitecture -import Utilities - -struct UserDefaultsClient: Sendable { - let setValue: @Sendable (Any, AppUserDefaults) -> Void -} - -extension UserDefaultsClient { - static let live: Self = .init( - setValue: { value, key in - UserDefaults.standard.set(value, forKey: key.rawValue) - } - ) - - func getValue(_ key: AppUserDefaults) -> T? { - UserDefaultsUtil.value(forKey: key) - } -} - -// MARK: API -enum UserDefaultsClientKey: DependencyKey { - static let liveValue = UserDefaultsClient.live - static let previewValue = UserDefaultsClient.noop - static let testValue = UserDefaultsClient.unimplemented -} - -extension DependencyValues { - var userDefaultsClient: UserDefaultsClient { - get { self[UserDefaultsClientKey.self] } - set { self[UserDefaultsClientKey.self] = newValue } - } -} - -// MARK: Test -extension UserDefaultsClient { - static let noop: Self = .init( - setValue: { _, _ in } - ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - setValue: IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift index 56733503e..36c9f2c97 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import Resources import ComposableArchitecture +import FoundationExt @Reducer struct ArchivesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift index f1194ac4c..f41e8b5e8 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift @@ -2,6 +2,8 @@ import Foundation import AppModels import ComposableArchitecture import SwiftUINavigationExt +import URLClient +import UIApplicationClient @Reducer struct CommentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift index 8195cea9e..8640b9adf 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import ComposableArchitecture +import URLClient @Reducer struct ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift index e674305a4..7ba2ac326 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import SwiftUINavigationExt +import UIApplicationClient @Reducer struct EhSettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift index cfe6216a7..9cb84a9ff 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift @@ -1,6 +1,8 @@ import LocalAuthentication import AppModels import ComposableArchitecture +import AuthorizationClient +import UIApplicationClient @Reducer struct GeneralSettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift index b357ac8f3..3d935c782 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import AppModels +import UIApplicationClient @Reducer struct LogsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift index 19d9481f6..556fb9b5d 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift @@ -1,6 +1,9 @@ import Foundation import AppModels import ComposableArchitecture +import LoggerClient +import UserDefaultsClient +import UIApplicationClient @Reducer struct SettingReducer { diff --git a/AppPackage/Sources/AuthorizationClient/.swiftlint.yml b/AppPackage/Sources/AuthorizationClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AuthorizationClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/AuthorizationClient.swift b/AppPackage/Sources/AuthorizationClient/AuthorizationClient.swift similarity index 66% rename from AppPackage/Sources/AppFeature/Tools/Clients/AuthorizationClient.swift rename to AppPackage/Sources/AuthorizationClient/AuthorizationClient.swift index b7f02e1ad..677a6dd1b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/AuthorizationClient.swift +++ b/AppPackage/Sources/AuthorizationClient/AuthorizationClient.swift @@ -2,13 +2,13 @@ import Combine import LocalAuthentication import ComposableArchitecture -struct AuthorizationClient: Sendable { - let passcodeNotSet: @Sendable () -> Bool - let localAuthroize: @Sendable (String) async -> Bool +public struct AuthorizationClient: Sendable { + public let passcodeNotSet: @Sendable () -> Bool + public let localAuthroize: @Sendable (String) async -> Bool } extension AuthorizationClient { - static let live: Self = .init( + public static let live: Self = .init( passcodeNotSet: { var error: NSError? return !LAContext().canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) @@ -31,14 +31,14 @@ extension AuthorizationClient { } // MARK: API -enum AuthorizationClientKey: DependencyKey { - static let liveValue = AuthorizationClient.live - static let previewValue = AuthorizationClient.noop - static let testValue = AuthorizationClient.unimplemented +public enum AuthorizationClientKey: DependencyKey { + public static let liveValue = AuthorizationClient.live + public static let previewValue = AuthorizationClient.noop + public static let testValue = AuthorizationClient.unimplemented } extension DependencyValues { - var authorizationClient: AuthorizationClient { + public var authorizationClient: AuthorizationClient { get { self[AuthorizationClientKey.self] } set { self[AuthorizationClientKey.self] = newValue } } @@ -46,14 +46,14 @@ extension DependencyValues { // MARK: Test extension AuthorizationClient { - static let noop: Self = .init( + public static let noop: Self = .init( passcodeNotSet: { false }, localAuthroize: { _ in false } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( passcodeNotSet: IssueReporting.unimplemented(placeholder: placeholder()), localAuthroize: IssueReporting.unimplemented(placeholder: placeholder()) ) diff --git a/AppPackage/Sources/FoundationExt/URL+Components.swift b/AppPackage/Sources/FoundationExt/URL+Components.swift new file mode 100644 index 000000000..3bea7cff4 --- /dev/null +++ b/AppPackage/Sources/FoundationExt/URL+Components.swift @@ -0,0 +1,22 @@ +import Foundation + +public extension URL { + func modifyComponent(for url: URL, commitChanges: (inout URLComponents) -> Void) -> URL? { + guard var components = URLComponents( + url: self, resolvingAgainstBaseURL: false + ) + else { return nil } + commitChanges(&components) + return components.url + } + func replaceHost(to newHost: String?) -> URL? { + modifyComponent(for: self) { components in + components.host = newHost + } + } + func replaceScheme(to newScheme: String?) -> URL? { + modifyComponent(for: self) { components in + components.scheme = newScheme + } + } +} diff --git a/AppPackage/Sources/LoggerClient/.swiftlint.yml b/AppPackage/Sources/LoggerClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/LoggerClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/LoggerClient.swift b/AppPackage/Sources/LoggerClient/LoggerClient.swift similarity index 57% rename from AppPackage/Sources/AppFeature/Tools/Clients/LoggerClient.swift rename to AppPackage/Sources/LoggerClient/LoggerClient.swift index 9359c9792..bf72f2690 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/LoggerClient.swift +++ b/AppPackage/Sources/LoggerClient/LoggerClient.swift @@ -1,13 +1,13 @@ import ComposableArchitecture import AppModels -struct LoggerClient: Sendable { - let info: @Sendable (Any, Any?) -> Void - let error: @Sendable (Any, Any?) -> Void +public struct LoggerClient: Sendable { + public let info: @Sendable (Any, Any?) -> Void + public let error: @Sendable (Any, Any?) -> Void } extension LoggerClient { - static let live: Self = .init( + public static let live: Self = .init( info: { message, context in Logger.info(message, context: context) }, @@ -18,14 +18,14 @@ extension LoggerClient { } // MARK: API -enum LoggerClientKey: DependencyKey { - static let liveValue = LoggerClient.live - static let previewValue = LoggerClient.noop - static let testValue = LoggerClient.unimplemented +public enum LoggerClientKey: DependencyKey { + public static let liveValue = LoggerClient.live + public static let previewValue = LoggerClient.noop + public static let testValue = LoggerClient.unimplemented } extension DependencyValues { - var loggerClient: LoggerClient { + public var loggerClient: LoggerClient { get { self[LoggerClientKey.self] } set { self[LoggerClientKey.self] = newValue } } @@ -33,14 +33,14 @@ extension DependencyValues { // MARK: Test extension LoggerClient { - static let noop: Self = .init( + public static let noop: Self = .init( info: { _, _ in }, error: { _, _ in } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( info: IssueReporting.unimplemented(placeholder: placeholder()), error: IssueReporting.unimplemented(placeholder: placeholder()) ) diff --git a/AppPackage/Sources/UIApplicationClient/.swiftlint.yml b/AppPackage/Sources/UIApplicationClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/UIApplicationClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift b/AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift similarity index 69% rename from AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift rename to AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift index fc6b07e00..7c15cba81 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/UIApplicationClient.swift +++ b/AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift @@ -4,16 +4,16 @@ import ComposableArchitecture import FoundationExt import Utilities -struct UIApplicationClient: Sendable { - let openURL: @MainActor @Sendable (URL) -> Void - let hideKeyboard: @Sendable () async -> Void - let alternateIconName: @MainActor @Sendable () -> String? - let setAlternateIconName: @MainActor @Sendable (String?) async -> Bool - let setUserInterfaceStyle: @MainActor @Sendable (UIUserInterfaceStyle) -> Void +public struct UIApplicationClient: Sendable { + public let openURL: @MainActor @Sendable (URL) -> Void + public let hideKeyboard: @Sendable () async -> Void + public let alternateIconName: @MainActor @Sendable () -> String? + public let setAlternateIconName: @MainActor @Sendable (String?) async -> Bool + public let setUserInterfaceStyle: @MainActor @Sendable (UIUserInterfaceStyle) -> Void } extension UIApplicationClient { - static let live: Self = .init( + public static let live: Self = .init( openURL: { url in UIApplication.shared.open(url, options: [:]) }, @@ -41,13 +41,13 @@ extension UIApplicationClient { } ) @MainActor - func openSettings() { + public func openSettings() { if let url = URL(string: UIApplication.openSettingsURLString) { return openURL(url) } } @MainActor - func openFileApp() { + public func openFileApp() { let dirPath = FileUtil.logsDirectoryURL.path if let dirURL = URL(string: "shareddocuments://" + dirPath) { return openURL(dirURL) @@ -56,14 +56,14 @@ extension UIApplicationClient { } // MARK: API -enum UIApplicationClientKey: DependencyKey { - static let liveValue = UIApplicationClient.live - static let previewValue = UIApplicationClient.noop - static let testValue = UIApplicationClient.unimplemented +public enum UIApplicationClientKey: DependencyKey { + public static let liveValue = UIApplicationClient.live + public static let previewValue = UIApplicationClient.noop + public static let testValue = UIApplicationClient.unimplemented } extension DependencyValues { - var uiApplicationClient: UIApplicationClient { + public var uiApplicationClient: UIApplicationClient { get { self[UIApplicationClientKey.self] } set { self[UIApplicationClientKey.self] = newValue } } @@ -71,7 +71,7 @@ extension DependencyValues { // MARK: Test extension UIApplicationClient { - static let noop: Self = .init( + public static let noop: Self = .init( openURL: { _ in}, hideKeyboard: {}, alternateIconName: { nil }, @@ -79,9 +79,9 @@ extension UIApplicationClient { setUserInterfaceStyle: { _ in } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( openURL: IssueReporting.unimplemented(placeholder: placeholder()), hideKeyboard: IssueReporting.unimplemented(placeholder: placeholder()), alternateIconName: IssueReporting.unimplemented(placeholder: placeholder()), diff --git a/AppPackage/Sources/URLClient/.swiftlint.yml b/AppPackage/Sources/URLClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/URLClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/URLClient.swift b/AppPackage/Sources/URLClient/URLClient.swift similarity index 65% rename from AppPackage/Sources/AppFeature/Tools/Clients/URLClient.swift rename to AppPackage/Sources/URLClient/URLClient.swift index 7da87147c..455edc7cb 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/URLClient.swift +++ b/AppPackage/Sources/URLClient/URLClient.swift @@ -1,26 +1,37 @@ import SwiftUI import AppModels import Dependencies +import FoundationExt -struct URLAnalysisResult { - let isGalleryImageURL: Bool - let pageIndex: Int? - let commentID: String? +public struct URLAnalysisResult: Sendable { + public let isGalleryImageURL: Bool + public let pageIndex: Int? + public let commentID: String? } -struct URLClient: Sendable { - let checkIfHandleable: @Sendable (URL) -> Bool - let checkIfMPVURL: @Sendable (URL?) -> Bool - let parseGalleryID: @Sendable (URL) -> String +public struct URLClient: Sendable { + public let checkIfHandleable: @Sendable (URL) -> Bool + public let checkIfMPVURL: @Sendable (URL?) -> Bool + public let parseGalleryID: @Sendable (URL) -> String + + public init( + checkIfHandleable: @escaping @Sendable (URL) -> Bool, + checkIfMPVURL: @escaping @Sendable (URL?) -> Bool, + parseGalleryID: @escaping @Sendable (URL) -> String + ) { + self.checkIfHandleable = checkIfHandleable + self.checkIfMPVURL = checkIfMPVURL + self.parseGalleryID = parseGalleryID + } } extension URLClient { - static func isMPVURL(_ url: URL?) -> Bool { + public static func isMPVURL(_ url: URL?) -> Bool { guard let url else { return false } return url.pathComponents.count >= 2 && url.pathComponents[1] == "mpv" } - static let live: Self = .init( + public static let live: Self = .init( checkIfHandleable: { url in (url.absoluteString.contains(Defaults.URL.ehentai.absoluteString) || url.absoluteString.contains(Defaults.URL.exhentai.absoluteString)) @@ -38,13 +49,13 @@ extension URLClient { } ) - func resolveAppSchemeURL(_ url: URL) -> URL? { + public func resolveAppSchemeURL(_ url: URL) -> URL? { guard url.scheme == "ehpanda", let newURL = url.replaceScheme(to: "https") else { return url } return newURL } - func analyzeURL(_ url: URL) -> URLAnalysisResult { + public func analyzeURL(_ url: URL) -> URLAnalysisResult { guard checkIfHandleable(url) else { return URLAnalysisResult(isGalleryImageURL: false, pageIndex: nil, commentID: nil) } @@ -71,14 +82,14 @@ extension URLClient { } // MARK: API -enum URLClientKey: DependencyKey { - static let liveValue = URLClient.live - static let previewValue = URLClient.noop - static let testValue = URLClient.unimplemented +public enum URLClientKey: DependencyKey { + public static let liveValue = URLClient.live + public static let previewValue = URLClient.noop + public static let testValue = URLClient.unimplemented } extension DependencyValues { - var urlClient: URLClient { + public var urlClient: URLClient { get { self[URLClientKey.self] } set { self[URLClientKey.self] = newValue } } @@ -86,15 +97,15 @@ extension DependencyValues { // MARK: Test extension URLClient { - static let noop: Self = .init( + public static let noop: Self = .init( checkIfHandleable: { _ in false }, checkIfMPVURL: { _ in false }, parseGalleryID: { _ in .init() } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( checkIfHandleable: IssueReporting.unimplemented(placeholder: placeholder()), checkIfMPVURL: IssueReporting.unimplemented(placeholder: placeholder()), parseGalleryID: IssueReporting.unimplemented(placeholder: placeholder()) diff --git a/AppPackage/Sources/UserDefaultsClient/.swiftlint.yml b/AppPackage/Sources/UserDefaultsClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/UserDefaultsClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/UserDefaultsClient/UserDefaultsClient.swift b/AppPackage/Sources/UserDefaultsClient/UserDefaultsClient.swift new file mode 100644 index 000000000..aa3b2ef40 --- /dev/null +++ b/AppPackage/Sources/UserDefaultsClient/UserDefaultsClient.swift @@ -0,0 +1,46 @@ +import Foundation +import ComposableArchitecture +import Utilities + +public struct UserDefaultsClient: Sendable { + public let setValue: @Sendable (Any, AppUserDefaults) -> Void +} + +extension UserDefaultsClient { + public static let live: Self = .init( + setValue: { value, key in + UserDefaults.standard.set(value, forKey: key.rawValue) + } + ) + + public func getValue(_ key: AppUserDefaults) -> T? { + UserDefaultsUtil.value(forKey: key) + } +} + +// MARK: API +public enum UserDefaultsClientKey: DependencyKey { + public static let liveValue = UserDefaultsClient.live + public static let previewValue = UserDefaultsClient.noop + public static let testValue = UserDefaultsClient.unimplemented +} + +extension DependencyValues { + public var userDefaultsClient: UserDefaultsClient { + get { self[UserDefaultsClientKey.self] } + set { self[UserDefaultsClientKey.self] = newValue } + } +} + +// MARK: Test +extension UserDefaultsClient { + public static let noop: Self = .init( + setValue: { _, _ in } + ) + + public static func placeholder() -> Result { fatalError() } + + public static let unimplemented: Self = .init( + setValue: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index 637c47541..a772287dc 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -3,6 +3,10 @@ import AppModels import SwiftUI import ComposableArchitecture import Testing +import LoggerClient +import URLClient +import UserDefaultsClient +import UIApplicationClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index db733b8fb..abe46d35a 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import Utilities +import URLClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index 2aadaaaa8..bc936987f 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import URLClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index 6ff4f6f22..b766c2c68 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import URLClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index e767dedff..b0b909210 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import Utilities +import URLClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift index 4a0ff3ec3..8b7fd3351 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -3,6 +3,7 @@ import AppModels import Testing import FoundationExt import Utilities +import URLClient @testable import AppFeature struct SettingDownloadTests { From 64ad66e54e72e80caae1684b232ddf0497f2b9c9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 18:47:09 +0800 Subject: [PATCH 312/614] Extract HapticsClient module; move HapticsUtil into Utilities HapticsClient becomes its own module. HapticsUtil is shared infrastructure (four views trigger feedback directly, not only through the client), so it moves into Utilities rather than being buried inside the client module. --- AppPackage/Package.swift | 12 +++++++++ .../AppFeature/DataFlow/AppReducer.swift | 1 + .../AppFeature/DataFlow/AppRouteReducer.swift | 1 + .../Tools/Extensions/Reducer_Extension.swift | 1 + .../Detail/Archives/ArchivesReducer.swift | 1 + .../Detail/Comments/CommentsReducer.swift | 1 + .../View/Detail/DetailReducer.swift | 1 + .../DetailSearch/DetailSearchReducer.swift | 1 + .../GalleryInfos/GalleryInfosReducer.swift | 1 + .../Detail/Previews/PreviewsReducer.swift | 1 + .../Detail/Torrents/TorrentsReducer.swift | 1 + .../View/Favorites/FavoritesReducer.swift | 1 + .../Home/Frontpage/FrontpageReducer.swift | 1 + .../View/Home/History/HistoryReducer.swift | 1 + .../View/Home/Popular/PopularReducer.swift | 1 + .../View/Home/Toplists/ToplistsReducer.swift | 1 + .../View/Home/Watched/WatchedReducer.swift | 1 + .../View/Reading/ReadingReducer.swift | 1 + .../View/Search/SearchReducer.swift | 1 + .../View/Search/SearchRootReducer.swift | 1 + .../AccountSettingReducer.swift | 1 + .../Setting/EhSetting/EhSettingReducer.swift | 1 + .../View/Setting/Login/LoginReducer.swift | 1 + .../View/Setting/SettingReducer.swift | 1 + .../View/Support/Components/SubSection.swift | 1 + .../View/Support/DateSeekReducer.swift | 1 + .../Sources/HapticsClient/.swiftlint.yml | 1 + .../HapticsClient.swift | 25 ++++++++++--------- .../Tools => }/Utilities/HapticsUtil.swift | 6 ++--- .../Download/DetailReducerDownloadTests.swift | 1 + .../Download/DetailReducerMetadataTests.swift | 1 + .../DetailReducerMetadataUpdateTests.swift | 1 + .../Download/DetailReducerObserveTests.swift | 1 + .../DetailReducerPauseAndGuardTests.swift | 1 + .../Download/DownloadAutomationTests.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../DownloadsReducerReadingDismissTests.swift | 1 + .../PreviewsReducerDownloadTests.swift | 1 + .../ReadingReducerDownloadTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + 41 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 AppPackage/Sources/HapticsClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => HapticsClient}/HapticsClient.swift (51%) rename AppPackage/Sources/{AppFeature/Tools => }/Utilities/HapticsUtil.swift (83%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index a55b05dc0..679b817d0 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -71,6 +71,7 @@ enum Module: String { case authorizationClient = "AuthorizationClient" case composableArchitectureExt = "ComposableArchitectureExt" case foundationExt = "FoundationExt" + case hapticsClient = "HapticsClient" case loggerClient = "LoggerClient" case resources = "Resources" case swiftUINavigationExt = "SwiftUINavigationExt" @@ -218,6 +219,7 @@ let targets: [PackageDescription.Target] = [ .module(.authorizationClient), .module(.composableArchitectureExt), .module(.foundationExt), + .module(.hapticsClient), .module(.loggerClient), .module(.resources), .module(.swiftUINavigationExt), @@ -304,6 +306,15 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .hapticsClient, + dependencies: [ + .module(.utilities), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .loggerClient, dependencies: [ @@ -350,6 +361,7 @@ let targets: [PackageDescription.Target] = [ .module(.appFeature), .module(.appModels), .module(.foundationExt), + .module(.hapticsClient), .module(.loggerClient), .module(.uiApplicationClient), .module(.urlClient), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 99297fe67..537ee995f 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -1,6 +1,7 @@ import SwiftUI import ComposableArchitecture import URLClient +import HapticsClient @Reducer struct AppReducer { diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 25173bb61..080d313b9 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import SwiftUINavigationExt import URLClient import UserDefaultsClient +import HapticsClient @Reducer struct AppRouteReducer { diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift index d0509986c..5fec89b18 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import ComposableArchitecture import SwiftUINavigationExt +import HapticsClient extension Reducer { func haptics( diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift index 36c9f2c97..3cefd38b1 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import FoundationExt +import HapticsClient @Reducer struct ArchivesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift index f41e8b5e8..3ac23b86a 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import SwiftUINavigationExt import URLClient import UIApplicationClient +import HapticsClient @Reducer struct CommentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift index 032568fa2..01ec66cd4 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift @@ -4,6 +4,7 @@ import Foundation import ComposableArchitecture import ComposableArchitectureExt import SwiftUINavigationExt +import HapticsClient @Reducer struct DetailReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift index ac43e073b..854429379 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import AppModels import SwiftUINavigationExt +import HapticsClient @Reducer struct DetailSearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift index fefc29b72..7cefeddc0 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import HapticsClient @Reducer struct GalleryInfosReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift index 3ede0019e..96eab3ec0 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import FoundationExt import SwiftUINavigationExt +import HapticsClient @Reducer struct PreviewsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift index bb18d28fa..9a2a0e504 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import SwiftUINavigationExt +import HapticsClient @Reducer struct TorrentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift index e53200455..f8466eb50 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift @@ -3,6 +3,7 @@ import AppModels import IdentifiedCollections import ComposableArchitecture import SwiftUINavigationExt +import HapticsClient @Reducer struct FavoritesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift index 77f49cf7f..c571f3887 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift @@ -3,6 +3,7 @@ import AppModels import Foundation import FoundationExt import SwiftUINavigationExt +import HapticsClient @Reducer struct FrontpageReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift index 153a5acf0..e5e03d4a4 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import FoundationExt +import HapticsClient @Reducer struct HistoryReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift index 221a051cf..de378ecad 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import AppModels import FoundationExt import SwiftUINavigationExt +import HapticsClient @Reducer struct PopularReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift index 26c43907e..0c5ef1aed 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import AppModels import FoundationExt +import HapticsClient @Reducer struct ToplistsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift index a0779405b..17dc480d5 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import AppModels import SwiftUINavigationExt +import HapticsClient @Reducer struct WatchedReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift index 8640b9adf..4bd3d1a89 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import ComposableArchitecture import URLClient +import HapticsClient @Reducer struct ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift index a406ee7ef..ebd7582a6 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import AppModels import Foundation import SwiftUINavigationExt +import HapticsClient @Reducer struct SearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift index 664279338..9df3a4b02 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import AppModels import FoundationExt import SwiftUINavigationExt +import HapticsClient @Reducer struct SearchRootReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift index 053bcbb3b..4ef79615b 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import SwiftUINavigationExt +import HapticsClient @Reducer struct AccountSettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift index 7ba2ac326..fe46a97b2 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import SwiftUINavigationExt import UIApplicationClient +import HapticsClient @Reducer struct EhSettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift index 011a25ad9..fbfbadc59 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import ComposableArchitecture import SwiftUINavigationExt +import HapticsClient @Reducer struct LoginReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift index 556fb9b5d..791657dd5 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import LoggerClient import UserDefaultsClient import UIApplicationClient +import HapticsClient @Reducer struct SettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift b/AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift index 7ce8574f3..dd87fd0b1 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift @@ -1,5 +1,6 @@ import SwiftUI import Resources +import Utilities struct SubSection: View { private let title: String diff --git a/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift b/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift index e6b9216af..5b4f6063f 100644 --- a/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import AppModels import Foundation +import HapticsClient /// A headless, reusable sub-reducer for the "Seek to date" control. /// diff --git a/AppPackage/Sources/HapticsClient/.swiftlint.yml b/AppPackage/Sources/HapticsClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/HapticsClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/HapticsClient.swift b/AppPackage/Sources/HapticsClient/HapticsClient.swift similarity index 51% rename from AppPackage/Sources/AppFeature/Tools/Clients/HapticsClient.swift rename to AppPackage/Sources/HapticsClient/HapticsClient.swift index 23bc698c1..2d33c2c15 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/HapticsClient.swift +++ b/AppPackage/Sources/HapticsClient/HapticsClient.swift @@ -1,13 +1,14 @@ import SwiftUI import ComposableArchitecture +import Utilities -struct HapticsClient: Sendable { - let generateFeedback: @MainActor @Sendable (UIImpactFeedbackGenerator.FeedbackStyle) -> Void - let generateNotificationFeedback: @MainActor @Sendable (UINotificationFeedbackGenerator.FeedbackType) -> Void +public struct HapticsClient: Sendable { + public let generateFeedback: @MainActor @Sendable (UIImpactFeedbackGenerator.FeedbackStyle) -> Void + public let generateNotificationFeedback: @MainActor @Sendable (UINotificationFeedbackGenerator.FeedbackType) -> Void } extension HapticsClient { - static let live: Self = .init( + public static let live: Self = .init( generateFeedback: { style in HapticsUtil.generateFeedback(style: style) }, @@ -18,14 +19,14 @@ extension HapticsClient { } // MARK: API -enum HapticsClientKey: DependencyKey { - static let liveValue = HapticsClient.live - static let previewValue = HapticsClient.noop - static let testValue = HapticsClient.unimplemented +public enum HapticsClientKey: DependencyKey { + public static let liveValue = HapticsClient.live + public static let previewValue = HapticsClient.noop + public static let testValue = HapticsClient.unimplemented } extension DependencyValues { - var hapticsClient: HapticsClient { + public var hapticsClient: HapticsClient { get { self[HapticsClientKey.self] } set { self[HapticsClientKey.self] = newValue } } @@ -33,14 +34,14 @@ extension DependencyValues { // MARK: Test extension HapticsClient { - static let noop: Self = .init( + public static let noop: Self = .init( generateFeedback: { _ in }, generateNotificationFeedback: { _ in } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( generateFeedback: IssueReporting.unimplemented(placeholder: placeholder()), generateNotificationFeedback: IssueReporting.unimplemented(placeholder: placeholder()) ) diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/HapticsUtil.swift b/AppPackage/Sources/Utilities/HapticsUtil.swift similarity index 83% rename from AppPackage/Sources/AppFeature/Tools/Utilities/HapticsUtil.swift rename to AppPackage/Sources/Utilities/HapticsUtil.swift index dd8d68c0b..e757d32de 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/HapticsUtil.swift +++ b/AppPackage/Sources/Utilities/HapticsUtil.swift @@ -2,8 +2,8 @@ import SwiftUI import AudioToolbox @MainActor -struct HapticsUtil { - static func generateFeedback(style: UIImpactFeedbackGenerator.FeedbackStyle) { +public struct HapticsUtil { + public static func generateFeedback(style: UIImpactFeedbackGenerator.FeedbackStyle) { guard !isLegacyTapticEngine else { generateLegacyFeedback() return @@ -11,7 +11,7 @@ struct HapticsUtil { UIImpactFeedbackGenerator(style: style).impactOccurred() } - static func generateNotificationFeedback(style: UINotificationFeedbackGenerator.FeedbackType) { + public static func generateNotificationFeedback(style: UINotificationFeedbackGenerator.FeedbackType) { guard !isLegacyTapticEngine else { generateLegacyFeedback() return diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift index 66f4b1937..0d0bc8240 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import HapticsClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift index 780c5906d..8488e32c6 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import HapticsClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index d527b8c40..fad2ba723 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import HapticsClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift index 398ff20c0..bb936c362 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import HapticsClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index 8e03cea22..dfbc3ab60 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import HapticsClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index a772287dc..1c4545256 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -7,6 +7,7 @@ import LoggerClient import URLClient import UserDefaultsClient import UIApplicationClient +import HapticsClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index abe46d35a..d3e4fdca2 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Testing import Utilities import URLClient +import HapticsClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index bc936987f..834aebd60 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import URLClient +import HapticsClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift index e07f3fe1b..e46fe9321 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import Testing +import HapticsClient @testable import AppFeature struct DownloadsReducerReadingDismissTests { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift index 52de7be2e..7cd8efafd 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import HapticsClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index b766c2c68..f6370d57f 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import URLClient +import HapticsClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index b0b909210..a6219bb15 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Testing import Utilities import URLClient +import HapticsClient @testable import AppFeature @Suite(.serialized) From 9bce4cd0c52c5b417d60983769530f4445b255cb Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 18:51:38 +0800 Subject: [PATCH 313/614] Extract SDWebImageExt module from AnimatedImage_Extension The animated-image byte detection and SDWebImage routing extensions are client-layer support (ImageClient, LibraryClient and ClipboardClient all consume them), not view-layer code. Pull them into their own SDWebImageExt module now rather than deferring with the other UI extensions, so the image clients can be extracted without a back-dependency on AppFeature. --- AppPackage/Package.swift | 11 +++++ .../Tools/Clients/ClipboardClient.swift | 1 + .../Clients/DownloadClient+Networking.swift | 1 + ...loadClient+ResponseValidationHelpers.swift | 1 + .../Tools/Clients/ImageClient.swift | 1 + .../Tools/Clients/LibraryClient.swift | 1 + .../AppFeature/View/Reading/ReadingView.swift | 1 + .../Sources/SDWebImageExt/.swiftlint.yml | 1 + .../AnimatedImage_Extension.swift | 44 +++++++++---------- .../Parser/Other/AnimatedImageDataTests.swift | 1 + 10 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 AppPackage/Sources/SDWebImageExt/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Extensions => SDWebImageExt}/AnimatedImage_Extension.swift (87%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 679b817d0..c382994ab 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -74,6 +74,7 @@ enum Module: String { case hapticsClient = "HapticsClient" case loggerClient = "LoggerClient" case resources = "Resources" + case sdWebImageExt = "SDWebImageExt" case swiftUINavigationExt = "SwiftUINavigationExt" case uiApplicationClient = "UIApplicationClient" case urlClient = "URLClient" @@ -222,6 +223,7 @@ let targets: [PackageDescription.Target] = [ .module(.hapticsClient), .module(.loggerClient), .module(.resources), + .module(.sdWebImageExt), .module(.swiftUINavigationExt), .module(.uiApplicationClient), .module(.urlClient), @@ -315,6 +317,14 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .sdWebImageExt, + dependencies: [ + .targetDependency(.sdWebImageSwiftUI) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .loggerClient, dependencies: [ @@ -363,6 +373,7 @@ let targets: [PackageDescription.Target] = [ .module(.foundationExt), .module(.hapticsClient), .module(.loggerClient), + .module(.sdWebImageExt), .module(.uiApplicationClient), .module(.urlClient), .module(.userDefaultsClient), diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/ClipboardClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/ClipboardClient.swift index c9768f44d..8bfbb137b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/ClipboardClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/ClipboardClient.swift @@ -1,5 +1,6 @@ import SwiftUI import ComposableArchitecture +import SDWebImageExt struct ClipboardClient: Sendable { let url: @Sendable () -> URL? diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift index e16f27b2b..265677af7 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import UniformTypeIdentifiers +import SDWebImageExt // MARK: - Network extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index 3ac11c727..0e647c883 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -4,6 +4,7 @@ import Foundation import ImageIO import FoundationExt import Utilities +import SDWebImageExt // MARK: - Response Inspection Helpers extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift index 4418d00e2..53dfa1cbe 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift @@ -5,6 +5,7 @@ import Combine import ComposableArchitecture import FoundationExt import Utilities +import SDWebImageExt struct ImageClient: Sendable { struct ImageAsset { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift index 4d1cf3f45..7fbf314a4 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift @@ -9,6 +9,7 @@ import SwiftyBeaver import UIImageColors import ComposableArchitecture import Utilities +import SDWebImageExt struct LibraryClient: Sendable { let initializeLogger: @Sendable () -> Void diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift index 9c88af31f..0bf7eebc6 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift @@ -5,6 +5,7 @@ import SwiftUIPager import ComposableArchitecture import FoundationExt import Utilities +import SDWebImageExt struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/SDWebImageExt/.swiftlint.yml b/AppPackage/Sources/SDWebImageExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/SDWebImageExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/AnimatedImage_Extension.swift b/AppPackage/Sources/SDWebImageExt/AnimatedImage_Extension.swift similarity index 87% rename from AppPackage/Sources/AppFeature/Tools/Extensions/AnimatedImage_Extension.swift rename to AppPackage/Sources/SDWebImageExt/AnimatedImage_Extension.swift index 33364acbf..895b644ff 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/AnimatedImage_Extension.swift +++ b/AppPackage/Sources/SDWebImageExt/AnimatedImage_Extension.swift @@ -3,20 +3,20 @@ import SDWebImage import UniformTypeIdentifiers private enum ImageDataSignature { - static let jpeg: [UInt8] = [0xFF, 0xD8, 0xFF] - static let png: [UInt8] = [0x89, 0x50, 0x4E, 0x47] - static let pngComplete: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] - static let gif = Array("GIF".utf8) - static let riff = Array("RIFF".utf8) - static let webp = Array("WEBP".utf8) - static let webPExtended = Array("VP8X".utf8) - static let webPAnimation = Array("ANIM".utf8) - static let apngAnimationControl = Array("acTL".utf8) - static let pngImageData = Array("IDAT".utf8) + public static let jpeg: [UInt8] = [0xFF, 0xD8, 0xFF] + public static let png: [UInt8] = [0x89, 0x50, 0x4E, 0x47] + public static let pngComplete: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + public static let gif = Array("GIF".utf8) + public static let riff = Array("RIFF".utf8) + public static let webp = Array("WEBP".utf8) + public static let webPExtended = Array("VP8X".utf8) + public static let webPAnimation = Array("ANIM".utf8) + public static let apngAnimationControl = Array("acTL".utf8) + public static let pngImageData = Array("IDAT".utf8) } extension Data { - var knownBinaryImageFileExtension: String? { + public var knownBinaryImageFileExtension: String? { if isJPEGFormat { return "jpg" } @@ -32,19 +32,19 @@ extension Data { return nil } - var isKnownBinaryImageFormat: Bool { + public var isKnownBinaryImageFormat: Bool { knownBinaryImageFileExtension != nil } - var isJPEGFormat: Bool { + public var isJPEGFormat: Bool { starts(with: ImageDataSignature.jpeg) } - var isPNGFormat: Bool { + public var isPNGFormat: Bool { starts(with: ImageDataSignature.png) } - var isAPNGFormat: Bool { + public var isAPNGFormat: Bool { guard starts(with: ImageDataSignature.pngComplete) else { return false } // Walk the chunk headers in place; a still PNG returns at the first `IDAT` // after reading only a few header bytes, so no full-image copy is needed. @@ -67,11 +67,11 @@ extension Data { } } - var isGIFFormat: Bool { + public var isGIFFormat: Bool { starts(with: ImageDataSignature.gif) } - var isWebPFormat: Bool { + public var isWebPFormat: Bool { starts(with: ImageDataSignature.riff) && hasBytes(ImageDataSignature.webp, at: 8) } @@ -82,11 +82,11 @@ extension Data { /// count, APNG `acTL` before `IDAT`, WebP VP8X animation bit), never the URL's file /// extension: the extension is known before any bytes exist and is wrong for mislabeled /// or content-negotiated images. - var isAnimatedImageData: Bool { + public var isAnimatedImageData: Bool { isAnimatedGIFFormat || isAPNGFormat || isAnimatedWebPFormat } - var animatedImagePasteboardType: String? { + public var animatedImagePasteboardType: String? { if isAnimatedWebPFormat { return UTType.webP.identifier } @@ -99,7 +99,7 @@ extension Data { return nil } - var decodedImage: UIImage? { + public var decodedImage: UIImage? { if isAnimatedImageData, let animatedImage = SDAnimatedImage(data: self) { return animatedImage } @@ -219,11 +219,11 @@ extension Data { } extension UIImage { - var hasAnimatedFrames: Bool { + public var hasAnimatedFrames: Bool { sd_isAnimated } - var animatedSourceData: Data? { + public var animatedSourceData: Data? { // Prefer the original downloaded bytes so GIF/APNG/WebP keep their source format. if let data = (self as? SDAnimatedImageProvider)?.animatedImageData { return data diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/AnimatedImageDataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/AnimatedImageDataTests.swift index 7f91fa929..a8f385594 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/AnimatedImageDataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/AnimatedImageDataTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import SDWebImageExt @testable import AppFeature struct AnimatedImageDataTests { From 3979e3033fe2a82f9ec45b6ca173be6dfbc823ae Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 18:56:28 +0800 Subject: [PATCH 314/614] Extract ImageClient and LibraryClient modules Both depend only on AppModels, Utilities (DataCache) and SDWebImageExt plus their own image libraries, so they extract cleanly now that the animated-image support lives in SDWebImageExt. --- AppPackage/Package.swift | 34 +++++++++++ .../DataFlow/AppDelegateReducer.swift | 1 + .../Clients/DownloadClient+Manager.swift | 1 + .../AppFeature/View/Home/HomeReducer.swift | 1 + .../View/Reading/ReadingReducer.swift | 1 + .../View/Reading/ReadingViewComponents.swift | 1 + .../GeneralSettingReducer.swift | 1 + .../View/Setting/SettingReducer.swift | 1 + AppPackage/Sources/ImageClient/.swiftlint.yml | 1 + .../Clients => ImageClient}/ImageClient.swift | 44 +++++++------- .../Sources/LibraryClient/.swiftlint.yml | 1 + .../LibraryClient.swift | 60 +++++++++++++------ .../Download/DownloadAutomationTests.swift | 1 + .../Download/DownloadFeatureTestHelpers.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../Download/DownloadProcessCacheTests.swift | 1 + .../Tests/Download/ReaderImageDataTests.swift | 1 + .../ReadingReducerDownloadTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + 20 files changed, 114 insertions(+), 41 deletions(-) create mode 100644 AppPackage/Sources/ImageClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => ImageClient}/ImageClient.swift (85%) create mode 100644 AppPackage/Sources/LibraryClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => LibraryClient}/LibraryClient.swift (80%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index c382994ab..2036ed6ce 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -72,6 +72,8 @@ enum Module: String { case composableArchitectureExt = "ComposableArchitectureExt" case foundationExt = "FoundationExt" case hapticsClient = "HapticsClient" + case imageClient = "ImageClient" + case libraryClient = "LibraryClient" case loggerClient = "LoggerClient" case resources = "Resources" case sdWebImageExt = "SDWebImageExt" @@ -221,6 +223,8 @@ let targets: [PackageDescription.Target] = [ .module(.composableArchitectureExt), .module(.foundationExt), .module(.hapticsClient), + .module(.imageClient), + .module(.libraryClient), .module(.loggerClient), .module(.resources), .module(.sdWebImageExt), @@ -325,6 +329,34 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .imageClient, + dependencies: [ + .module(.appModels), + .module(.foundationExt), + .module(.sdWebImageExt), + .module(.utilities), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + .target( + module: .libraryClient, + dependencies: [ + .module(.appModels), + .module(.sdWebImageExt), + .module(.utilities), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher), + .targetDependency(.sdWebImageSwiftUI), + .targetDependency(.sdWebImageWebPCoder), + .targetDependency(.swiftyBeaver), + .targetDependency(.uiImageColors) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .loggerClient, dependencies: [ @@ -372,6 +404,8 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.foundationExt), .module(.hapticsClient), + .module(.imageClient), + .module(.libraryClient), .module(.loggerClient), .module(.sdWebImageExt), .module(.uiApplicationClient), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index 9fe876c1a..d5ec162b8 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -3,6 +3,7 @@ import BackgroundTasks import SwiftyBeaver import ComposableArchitecture import Utilities +import LibraryClient @Reducer struct AppDelegateReducer { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift index 56730aeeb..46b629891 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import LibraryClient typealias ScheduledDownloadOperation = @Sendable () async -> Void diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift b/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift index aaa618ace..a29475af9 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift @@ -3,6 +3,7 @@ import AppModels import Kingfisher import ComposableArchitecture import FoundationExt +import LibraryClient @Reducer struct HomeReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift index 4bd3d1a89..85f6d4602 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import URLClient import HapticsClient +import ImageClient @Reducer struct ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift index fd9c19976..af41cb77f 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift @@ -6,6 +6,7 @@ import SDWebImage import SDWebImageSwiftUI import ComposableArchitecture import Utilities +import ImageClient // MARK: ImageStackConfig struct ImageStackConfig { diff --git a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift index 9cb84a9ff..531d665bc 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import AuthorizationClient import UIApplicationClient +import LibraryClient @Reducer struct GeneralSettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift index 791657dd5..89f6f2e65 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift @@ -5,6 +5,7 @@ import LoggerClient import UserDefaultsClient import UIApplicationClient import HapticsClient +import LibraryClient @Reducer struct SettingReducer { diff --git a/AppPackage/Sources/ImageClient/.swiftlint.yml b/AppPackage/Sources/ImageClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/ImageClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift b/AppPackage/Sources/ImageClient/ImageClient.swift similarity index 85% rename from AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift rename to AppPackage/Sources/ImageClient/ImageClient.swift index 53dfa1cbe..e397df30f 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/ImageClient.swift +++ b/AppPackage/Sources/ImageClient/ImageClient.swift @@ -7,27 +7,27 @@ import FoundationExt import Utilities import SDWebImageExt -struct ImageClient: Sendable { - struct ImageAsset { - let image: UIImage - let data: Data +public struct ImageClient: Sendable { + public struct ImageAsset: Sendable { + public let image: UIImage + public let data: Data /// Render / export routing for this asset: `true` → SDWebImage (animated), /// `false` → Kingfisher / `UIImage` (still). Decided from the actual bytes, not the /// request URL. See `Data.isAnimatedImageData`. - var isAnimated: Bool { + public var isAnimated: Bool { data.isAnimatedImageData || image.hasAnimatedFrames } } - let prefetchImages: @Sendable ([URL]) -> Void - let saveImageDataToPhotoLibrary: @Sendable (Data) async -> Bool - var dataCache: DataCache = .shared - var urlSession: URLSession = .shared + public let prefetchImages: @Sendable ([URL]) -> Void + public let saveImageDataToPhotoLibrary: @Sendable (Data) async -> Bool + public var dataCache: DataCache = .shared + public var urlSession: URLSession = .shared } extension ImageClient { - static let live: Self = .init( + public static let live: Self = .init( prefetchImages: { urls in Task { await withTaskGroup(of: Void.self) { group in @@ -46,7 +46,7 @@ extension ImageClient { } ) - static func saveImageDataToPhotoLibrary(_ data: Data) async -> Bool { + public static func saveImageDataToPhotoLibrary(_ data: Data) async -> Bool { await withCheckedContinuation { continuation in PHPhotoLibrary.shared().performChanges { let request = PHAssetCreationRequest.forAsset() @@ -59,7 +59,7 @@ extension ImageClient { // Exports read the same owned bytes the reader display caches, so save/share/copy // route by image content (DES-1) instead of the request URL's extension. - func fetchImageAsset(url: URL) async -> Result { + public func fetchImageAsset(url: URL) async -> Result { do { let data = try await Self.readerImageData( url: url, dataCache: dataCache, urlSession: urlSession @@ -73,7 +73,7 @@ extension ImageClient { } } - func fetchReaderImageAsset( + public func fetchReaderImageAsset( url: URL, onProgress: (@MainActor @Sendable (Double) -> Void)? = nil ) async -> ImageAsset? { @@ -85,7 +85,7 @@ extension ImageClient { return .init(image: image, data: data) } - static func readerImageData( + public static func readerImageData( url: URL, dataCache: DataCache, urlSession: URLSession, @@ -169,14 +169,14 @@ extension ImageClient { } // MARK: API -enum ImageClientKey: DependencyKey { - static let liveValue = ImageClient.live - static let previewValue = ImageClient.noop - static let testValue = ImageClient.unimplemented +public enum ImageClientKey: DependencyKey { + public static let liveValue = ImageClient.live + public static let previewValue = ImageClient.noop + public static let testValue = ImageClient.unimplemented } extension DependencyValues { - var imageClient: ImageClient { + public var imageClient: ImageClient { get { self[ImageClientKey.self] } set { self[ImageClientKey.self] = newValue } } @@ -184,14 +184,14 @@ extension DependencyValues { // MARK: Test extension ImageClient { - static let noop: Self = .init( + public static let noop: Self = .init( prefetchImages: { _ in }, saveImageDataToPhotoLibrary: { _ in false } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( prefetchImages: IssueReporting.unimplemented(placeholder: placeholder()), saveImageDataToPhotoLibrary: IssueReporting.unimplemented(placeholder: placeholder()) ) diff --git a/AppPackage/Sources/LibraryClient/.swiftlint.yml b/AppPackage/Sources/LibraryClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/LibraryClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift b/AppPackage/Sources/LibraryClient/LibraryClient.swift similarity index 80% rename from AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift rename to AppPackage/Sources/LibraryClient/LibraryClient.swift index 7fbf314a4..ce099382d 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/LibraryClient.swift +++ b/AppPackage/Sources/LibraryClient/LibraryClient.swift @@ -11,20 +11,42 @@ import ComposableArchitecture import Utilities import SDWebImageExt -struct LibraryClient: Sendable { - let initializeLogger: @Sendable () -> Void - let initializeWebImage: @Sendable () -> Void - let removeAllCachedImages: @Sendable () async -> Void - let cachedImage: @Sendable (String) async -> UIImage? - let cachedImageData: @Sendable (String) async -> Data? - let removeCachedImage: @Sendable (String) async -> Void - let isCached: @Sendable (String) -> Bool - let analyzeImageColors: @Sendable (UIImage) async -> [Color]? - let calculateWebImageDiskCacheSize: @Sendable () async -> UInt? +public struct LibraryClient: Sendable { + public let initializeLogger: @Sendable () -> Void + public let initializeWebImage: @Sendable () -> Void + public let removeAllCachedImages: @Sendable () async -> Void + public let cachedImage: @Sendable (String) async -> UIImage? + public let cachedImageData: @Sendable (String) async -> Data? + public let removeCachedImage: @Sendable (String) async -> Void + public let isCached: @Sendable (String) -> Bool + public let analyzeImageColors: @Sendable (UIImage) async -> [Color]? + public let calculateWebImageDiskCacheSize: @Sendable () async -> UInt? + + public init( + initializeLogger: @escaping @Sendable () -> Void, + initializeWebImage: @escaping @Sendable () -> Void, + removeAllCachedImages: @escaping @Sendable () async -> Void, + cachedImage: @escaping @Sendable (String) async -> UIImage?, + cachedImageData: @escaping @Sendable (String) async -> Data?, + removeCachedImage: @escaping @Sendable (String) async -> Void, + isCached: @escaping @Sendable (String) -> Bool, + analyzeImageColors: @escaping @Sendable (UIImage) async -> [Color]?, + calculateWebImageDiskCacheSize: @escaping @Sendable () async -> UInt? + ) { + self.initializeLogger = initializeLogger + self.initializeWebImage = initializeWebImage + self.removeAllCachedImages = removeAllCachedImages + self.cachedImage = cachedImage + self.cachedImageData = cachedImageData + self.removeCachedImage = removeCachedImage + self.isCached = isCached + self.analyzeImageColors = analyzeImageColors + self.calculateWebImageDiskCacheSize = calculateWebImageDiskCacheSize + } } extension LibraryClient { - static let live: Self = .init( + public static let live: Self = .init( initializeLogger: { // MARK: SwiftyBeaver let file = FileDestination() @@ -230,14 +252,14 @@ private func image(from data: Data) -> UIImage? { } // MARK: API -enum LibraryClientKey: DependencyKey { - static let liveValue = LibraryClient.live - static let previewValue = LibraryClient.noop - static let testValue = LibraryClient.unimplemented +public enum LibraryClientKey: DependencyKey { + public static let liveValue = LibraryClient.live + public static let previewValue = LibraryClient.noop + public static let testValue = LibraryClient.unimplemented } extension DependencyValues { - var libraryClient: LibraryClient { + public var libraryClient: LibraryClient { get { self[LibraryClientKey.self] } set { self[LibraryClientKey.self] = newValue } } @@ -245,7 +267,7 @@ extension DependencyValues { // MARK: Test extension LibraryClient { - static let noop: Self = .init( + public static let noop: Self = .init( initializeLogger: {}, initializeWebImage: {}, removeAllCachedImages: {}, @@ -257,9 +279,9 @@ extension LibraryClient { calculateWebImageDiskCacheSize: { .none } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( initializeLogger: IssueReporting.unimplemented(placeholder: placeholder()), initializeWebImage: IssueReporting.unimplemented(placeholder: placeholder()), removeAllCachedImages: IssueReporting.unimplemented(placeholder: placeholder()), diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index 1c4545256..7c7744d27 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -8,6 +8,7 @@ import URLClient import UserDefaultsClient import UIApplicationClient import HapticsClient +import LibraryClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift index 88995b05a..e584e3200 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import Kingfisher import UIKit import Testing +import LibraryClient @testable import AppFeature // MARK: - Shared Test Helper Protocol diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index d3e4fdca2..044450608 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -5,6 +5,7 @@ import Testing import Utilities import URLClient import HapticsClient +import ImageClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index 834aebd60..261d28255 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Testing import URLClient import HapticsClient +import ImageClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift index 329600f49..5fb4ec61a 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift @@ -3,6 +3,7 @@ import AppModels import Foundation import Testing import FoundationExt +import LibraryClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift index 1fc232dcf..9296844a7 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift @@ -4,6 +4,7 @@ import Testing import UIKit import FoundationExt import Utilities +import ImageClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index f6370d57f..cb3e58b01 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Testing import URLClient import HapticsClient +import ImageClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index a6219bb15..9c42db5f9 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -5,6 +5,7 @@ import Testing import Utilities import URLClient import HapticsClient +import ImageClient @testable import AppFeature @Suite(.serialized) From 9ce9a1b89fa2f7aeb71ad78b793ecaf15ddbaa81 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 19:07:05 +0800 Subject: [PATCH 315/614] Extract DatabaseClient module with the Core Data stack Move the Database/ tree (persistence, managed-object definitions, migration) and the DatabaseClient reducer-dependency into their own module, along with the Model.xcdatamodeld and the model-5-to-6 mapping model resources they load via Bundle.module. Update the model's representedClassName entries to the new module so Core Data resolves the managed-object subclasses (the class name is not part of the store version hash, so this is migration-safe). AppFeature no longer bundles any resources. Relocate the FilterRange domain enum out of FiltersView into AppModels next to Filter so the database layer can reference it without depending on the view. --- AppPackage/Package.swift | 16 +++- .../DataFlow/AppDelegateReducer.swift | 1 + .../AppFeature/DataFlow/AppRouteReducer.swift | 1 + .../Tools/Clients/DownloadClient.swift | 1 + .../Detail/Archives/ArchivesReducer.swift | 1 + .../Detail/Comments/CommentsReducer.swift | 1 + .../View/Detail/DetailReducer.swift | 1 + .../DetailSearch/DetailSearchReducer.swift | 1 + .../Detail/Previews/PreviewsReducer.swift | 1 + .../View/Favorites/FavoritesReducer.swift | 1 + .../Home/Frontpage/FrontpageReducer.swift | 1 + .../View/Home/History/HistoryReducer.swift | 1 + .../AppFeature/View/Home/HomeReducer.swift | 1 + .../View/Home/Popular/PopularReducer.swift | 1 + .../View/Home/Toplists/ToplistsReducer.swift | 1 + .../View/Home/Watched/WatchedReducer.swift | 1 + .../View/Migration/MigrationReducer.swift | 1 + .../View/Reading/ReadingReducer.swift | 1 + .../View/Search/SearchReducer.swift | 1 + .../View/Search/SearchRootReducer.swift | 1 + .../Search/Support/QuickSearchReducer.swift | 1 + .../GeneralSettingReducer.swift | 1 + .../View/Setting/SettingReducer.swift | 1 + .../View/Support/FiltersReducer.swift | 1 + .../AppFeature/View/Support/FiltersView.swift | 20 ----- .../Sources/AppModels/Persistent/Filter.swift | 21 ++++++ .../Sources/DatabaseClient/.swiftlint.yml | 1 + .../FileManager+ApplicationSupport.swift | 2 +- .../NSManagedObjectModel+Compatible.swift | 2 +- .../NSManagedObjectModel+Resource.swift | 2 +- .../NSPersistentStoreCoordinator+SQLite.swift | 8 +- .../MODefinition/AppEnvMO+CoreDataClass.swift | 4 +- .../AppEnvMO+CoreDataProperties.swift | 0 .../GalleryDetailMO+CoreDataClass.swift | 4 +- .../GalleryDetailMO+CoreDataProperties.swift | 0 .../GalleryMO+CoreDataClass.swift | 4 +- .../GalleryMO+CoreDataProperties.swift | 0 .../GalleryStateMO+CoreDataClass.swift | 4 +- .../GalleryStateMO+CoreDataProperties.swift | 0 .../Migration/CoreDataMigrationStep.swift | 10 +-- .../Migration/CoreDataMigrationVersion.swift | 6 +- .../Database/Migration/CoreDataMigrator.swift | 10 +-- .../Model5toModel6MigrationPolicy.swift | 0 .../Database/Persistence.swift | 18 ++--- .../DatabaseClient+Updates.swift | 44 +++++------ .../DatabaseClient.swift | 74 +++++++++---------- .../Model.xcdatamodeld/.xccurrentversion | 0 .../Model 2.xcdatamodel/contents | 8 +- .../Model 3.xcdatamodel/contents | 8 +- .../Model 4.xcdatamodel/contents | 8 +- .../Model 5.xcdatamodel/contents | 8 +- .../Model 6.xcdatamodel/contents | 8 +- .../Model 7.xcdatamodel/contents | 8 +- .../Model.xcdatamodel/contents | 8 +- .../xcmapping.xml | 0 .../Download/DatabaseClientUpdateTests.swift | 1 + .../Download/DetailReducerDownloadTests.swift | 1 + .../Download/DetailReducerMetadataTests.swift | 1 + .../DetailReducerMetadataUpdateTests.swift | 1 + .../Download/DetailReducerObserveTests.swift | 1 + .../DetailReducerPauseAndGuardTests.swift | 1 + .../Download/DownloadAutomationTests.swift | 1 + .../DownloadFeatureTestFactories.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../PreviewsReducerDownloadTests.swift | 1 + .../ReadingReducerDownloadTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + 68 files changed, 197 insertions(+), 145 deletions(-) create mode 100644 AppPackage/Sources/DatabaseClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift (90%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift (60%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift (86%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift (82%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/MODefinition/AppEnvMO+CoreDataClass.swift (89%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/MODefinition/AppEnvMO+CoreDataProperties.swift (100%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift (92%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift (100%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/MODefinition/GalleryMO+CoreDataClass.swift (89%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/MODefinition/GalleryMO+CoreDataProperties.swift (100%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/MODefinition/GalleryStateMO+CoreDataClass.swift (90%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift (100%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/Migration/CoreDataMigrationStep.swift (85%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/Migration/CoreDataMigrationVersion.swift (79%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/Migration/CoreDataMigrator.swift (91%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift (100%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Database/Persistence.swift (87%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DatabaseClient}/DatabaseClient+Updates.swift (68%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DatabaseClient}/DatabaseClient.swift (84%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Resources/Model.xcdatamodeld/.xccurrentversion (100%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents (92%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents (92%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents (92%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents (92%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents (93%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents (92%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents (92%) rename AppPackage/Sources/{AppFeature => DatabaseClient}/Resources/Model5toModel6.xcmappingmodel/xcmapping.xml (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 2036ed6ce..99e4dfded 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -70,6 +70,7 @@ enum Module: String { case appModels = "AppModels" case authorizationClient = "AuthorizationClient" case composableArchitectureExt = "ComposableArchitectureExt" + case databaseClient = "DatabaseClient" case foundationExt = "FoundationExt" case hapticsClient = "HapticsClient" case imageClient = "ImageClient" @@ -221,6 +222,7 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.authorizationClient), .module(.composableArchitectureExt), + .module(.databaseClient), .module(.foundationExt), .module(.hapticsClient), .module(.imageClient), @@ -252,7 +254,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.uiImageColors), .targetDependency(.waterfallGrid) ], - resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), @@ -312,6 +313,18 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .databaseClient, + dependencies: [ + .module(.appModels), + .module(.foundationExt), + .module(.utilities), + .targetDependency(.composableArchitecture) + ], + resources: [.process(.resources)], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .hapticsClient, dependencies: [ @@ -402,6 +415,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appFeature), .module(.appModels), + .module(.databaseClient), .module(.foundationExt), .module(.hapticsClient), .module(.imageClient), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index d5ec162b8..a4de74d8e 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -4,6 +4,7 @@ import SwiftyBeaver import ComposableArchitecture import Utilities import LibraryClient +import DatabaseClient @Reducer struct AppDelegateReducer { diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 080d313b9..fc46eb616 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -5,6 +5,7 @@ import SwiftUINavigationExt import URLClient import UserDefaultsClient import HapticsClient +import DatabaseClient @Reducer struct AppRouteReducer { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift index e70485833..68cc6636b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Utilities +import DatabaseClient @DependencyClient struct DownloadClient: Sendable { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift index 3cefd38b1..8101bce9d 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import FoundationExt import HapticsClient +import DatabaseClient @Reducer struct ArchivesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift index 3ac23b86a..4cc1fbacf 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift @@ -5,6 +5,7 @@ import SwiftUINavigationExt import URLClient import UIApplicationClient import HapticsClient +import DatabaseClient @Reducer struct CommentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift index 01ec66cd4..7fc7d3c31 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import ComposableArchitectureExt import SwiftUINavigationExt import HapticsClient +import DatabaseClient @Reducer struct DetailReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift index 854429379..bd08a1292 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import AppModels import SwiftUINavigationExt import HapticsClient +import DatabaseClient @Reducer struct DetailSearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift index 96eab3ec0..14c99f816 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import FoundationExt import SwiftUINavigationExt import HapticsClient +import DatabaseClient @Reducer struct PreviewsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift index f8466eb50..2cfa36610 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift @@ -4,6 +4,7 @@ import IdentifiedCollections import ComposableArchitecture import SwiftUINavigationExt import HapticsClient +import DatabaseClient @Reducer struct FavoritesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift index c571f3887..659d3a5a8 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift @@ -4,6 +4,7 @@ import Foundation import FoundationExt import SwiftUINavigationExt import HapticsClient +import DatabaseClient @Reducer struct FrontpageReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift index e5e03d4a4..78991ab0e 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import FoundationExt import HapticsClient +import DatabaseClient @Reducer struct HistoryReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift b/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift index a29475af9..7adfda0e4 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift @@ -4,6 +4,7 @@ import Kingfisher import ComposableArchitecture import FoundationExt import LibraryClient +import DatabaseClient @Reducer struct HomeReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift index de378ecad..9c8b99c86 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift @@ -3,6 +3,7 @@ import AppModels import FoundationExt import SwiftUINavigationExt import HapticsClient +import DatabaseClient @Reducer struct PopularReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift index 0c5ef1aed..3ffe6ee51 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import AppModels import FoundationExt import HapticsClient +import DatabaseClient @Reducer struct ToplistsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift index 17dc480d5..c48e26e93 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import AppModels import SwiftUINavigationExt import HapticsClient +import DatabaseClient @Reducer struct WatchedReducer { diff --git a/AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift b/AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift index e0f48f478..5620f7d67 100644 --- a/AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import ComposableArchitecture +import DatabaseClient @Reducer struct MigrationReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift index 85f6d4602..5a22317c0 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import URLClient import HapticsClient import ImageClient +import DatabaseClient @Reducer struct ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift index ebd7582a6..0e7eec572 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift @@ -3,6 +3,7 @@ import AppModels import Foundation import SwiftUINavigationExt import HapticsClient +import DatabaseClient @Reducer struct SearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift index 9df3a4b02..ab78b05b0 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift @@ -3,6 +3,7 @@ import AppModels import FoundationExt import SwiftUINavigationExt import HapticsClient +import DatabaseClient @Reducer struct SearchRootReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift index 2c364bdd3..e3d0b31a9 100644 --- a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import ComposableArchitecture +import DatabaseClient @Reducer struct QuickSearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift index 531d665bc..88cc361d3 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import AuthorizationClient import UIApplicationClient import LibraryClient +import DatabaseClient @Reducer struct GeneralSettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift index 89f6f2e65..31c85f429 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift @@ -6,6 +6,7 @@ import UserDefaultsClient import UIApplicationClient import HapticsClient import LibraryClient +import DatabaseClient @Reducer struct SettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift b/AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift index 765dcfe88..e5f37b7ca 100644 --- a/AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import AppModels +import DatabaseClient @Reducer struct FiltersReducer { diff --git a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift index a4907df57..1119f17f4 100644 --- a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift @@ -212,26 +212,6 @@ private struct TupleCategory: Identifiable { let category: AppModels.Category } -enum FilterRange: Int, CaseIterable, Identifiable { - var id: Int { rawValue } - - case search - case global - case watched -} -extension FilterRange { - var value: String { - switch self { - case .search: - return L10n.Localizable.Enum.FilterRange.Value.search - case .global: - return L10n.Localizable.Enum.FilterRange.Value.global - case .watched: - return L10n.Localizable.Enum.FilterRange.Value.watched - } - } -} - struct FiltersView_Previews: PreviewProvider { static var previews: some View { FiltersView(store: .init(initialState: .init(), reducer: FiltersReducer.init)) diff --git a/AppPackage/Sources/AppModels/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift index 33b2fe712..1d2fa52da 100644 --- a/AppPackage/Sources/AppModels/Persistent/Filter.swift +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -1,4 +1,5 @@ import SwiftUI +import Resources public struct Filter: Codable, Equatable, Sendable { public init( @@ -149,3 +150,23 @@ extension Filter { disableTags = (try? container?.decodeIfPresent(Bool.self, forKey: .disableTags)) ?? false } } + +public enum FilterRange: Int, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } + + case search + case global + case watched +} +public extension FilterRange { + var value: String { + switch self { + case .search: + return L10n.Localizable.Enum.FilterRange.Value.search + case .global: + return L10n.Localizable.Enum.FilterRange.Value.global + case .watched: + return L10n.Localizable.Enum.FilterRange.Value.watched + } + } +} diff --git a/AppPackage/Sources/DatabaseClient/.swiftlint.yml b/AppPackage/Sources/DatabaseClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DatabaseClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift b/AppPackage/Sources/DatabaseClient/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift similarity index 90% rename from AppPackage/Sources/AppFeature/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift rename to AppPackage/Sources/DatabaseClient/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift index a9cedc637..08e5e2ca5 100755 --- a/AppPackage/Sources/AppFeature/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift +++ b/AppPackage/Sources/DatabaseClient/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift @@ -1,7 +1,7 @@ import Foundation extension FileManager { - static func clearApplicationSupportDirectoryContents() { + public static func clearApplicationSupportDirectoryContents() { guard let applicationSupportURL = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask).first, let applicationSupportDirectoryContents = try? FileManager diff --git a/AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift b/AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift similarity index 60% rename from AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift rename to AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift index c2750dfe7..0f0274cd3 100755 --- a/AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift +++ b/AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift @@ -2,7 +2,7 @@ import Foundation import CoreData extension NSManagedObjectModel { - static func compatibleModelForStoreMetadata(_ metadata: [String: Any]) -> NSManagedObjectModel? { + public static func compatibleModelForStoreMetadata(_ metadata: [String: Any]) -> NSManagedObjectModel? { NSManagedObjectModel.mergedModel(from: [Bundle.module], forStoreMetadata: metadata) } } diff --git a/AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift b/AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift similarity index 86% rename from AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift rename to AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift index 2f76acfd8..512f474c2 100755 --- a/AppPackage/Sources/AppFeature/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift +++ b/AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift @@ -3,7 +3,7 @@ import AppModels import CoreData extension NSManagedObjectModel { - static func managedObjectModel(forResource resource: String) throws -> NSManagedObjectModel { + public static func managedObjectModel(forResource resource: String) throws -> NSManagedObjectModel { let subdirectory = "Model.momd" let omoURL = Bundle.module.url(forResource: resource, withExtension: "omo", subdirectory: subdirectory) let momURL = Bundle.module.url(forResource: resource, withExtension: "mom", subdirectory: subdirectory) diff --git a/AppPackage/Sources/AppFeature/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift b/AppPackage/Sources/DatabaseClient/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift similarity index 82% rename from AppPackage/Sources/AppFeature/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift rename to AppPackage/Sources/DatabaseClient/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift index d12ba95b3..7e3fc7e80 100755 --- a/AppPackage/Sources/AppFeature/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift +++ b/AppPackage/Sources/DatabaseClient/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift @@ -2,7 +2,7 @@ import CoreData import AppModels extension NSPersistentStoreCoordinator { - static func destroyStore(at storeURL: URL) throws { + public static func destroyStore(at storeURL: URL) throws { do { let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: NSManagedObjectModel()) try persistentStoreCoordinator.destroyPersistentStore(at: storeURL, ofType: NSSQLiteStoreType, options: nil) @@ -11,7 +11,7 @@ extension NSPersistentStoreCoordinator { throw AppError.databaseCorrupted(message) } } - static func replaceStore(at targetURL: URL, withStoreAt sourceURL: URL) throws { + public static func replaceStore(at targetURL: URL, withStoreAt sourceURL: URL) throws { do { let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: NSManagedObjectModel()) try persistentStoreCoordinator.replacePersistentStore( @@ -25,13 +25,13 @@ extension NSPersistentStoreCoordinator { } } - static func metadata(at storeURL: URL) -> [String: Any]? { + public static func metadata(at storeURL: URL) -> [String: Any]? { try? NSPersistentStoreCoordinator.metadataForPersistentStore( ofType: NSSQLiteStoreType, at: storeURL, options: nil ) } - func addPersistentStore(at storeURL: URL, options: [AnyHashable: Any]) throws -> NSPersistentStore { + public func addPersistentStore(at storeURL: URL, options: [AnyHashable: Any]) throws -> NSPersistentStore { do { return try addPersistentStore( ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, options: options diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift similarity index 89% rename from AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift rename to AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift index 7988f5b62..824599a87 100644 --- a/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataClass.swift +++ b/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift @@ -5,7 +5,7 @@ import FoundationExt public class AppEnvMO: NSManagedObject {} extension AppEnvMO: ManagedObjectProtocol { - func toEntity() -> AppEnv { + public func toEntity() -> AppEnv { AppEnv( user: user?.toObject() ?? User(), setting: setting?.toObject() ?? Setting(), @@ -20,7 +20,7 @@ extension AppEnvMO: ManagedObjectProtocol { } extension AppEnv: ManagedObjectConvertible { - @discardableResult func toManagedObject(in context: NSManagedObjectContext) -> AppEnvMO { + @discardableResult public func toManagedObject(in context: NSManagedObjectContext) -> AppEnvMO { let appEnvMO = AppEnvMO(context: context) appEnvMO.user = user.toData() diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataProperties.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataProperties.swift similarity index 100% rename from AppPackage/Sources/AppFeature/Database/MODefinition/AppEnvMO+CoreDataProperties.swift rename to AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataProperties.swift diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift similarity index 92% rename from AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift rename to AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift index edd100b78..2ff886192 100644 --- a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift +++ b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift @@ -5,7 +5,7 @@ import FoundationExt public class GalleryDetailMO: NSManagedObject {} extension GalleryDetailMO: ManagedObjectProtocol { - func toEntity() -> GalleryDetail { + public func toEntity() -> GalleryDetail { GalleryDetail( gid: gid, title: title, jpnTitle: jpnTitle, isFavorited: isFavorited, visibility: visibility?.toObject() ?? GalleryVisibility.yes, @@ -21,7 +21,7 @@ extension GalleryDetailMO: ManagedObjectProtocol { } } extension GalleryDetail: ManagedObjectConvertible { - @discardableResult func toManagedObject(in context: NSManagedObjectContext) -> GalleryDetailMO { + @discardableResult public func toManagedObject(in context: NSManagedObjectContext) -> GalleryDetailMO { let galleryDetailMO = GalleryDetailMO(context: context) galleryDetailMO.gid = gid diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift similarity index 100% rename from AppPackage/Sources/AppFeature/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift rename to AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift similarity index 89% rename from AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift rename to AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift index 96f576d7d..359399e30 100644 --- a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataClass.swift +++ b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift @@ -5,7 +5,7 @@ import FoundationExt public class GalleryMO: NSManagedObject {} extension GalleryMO: ManagedObjectProtocol { - func toEntity() -> Gallery { + public func toEntity() -> Gallery { Gallery( gid: gid, token: token, title: title, rating: rating, @@ -19,7 +19,7 @@ extension GalleryMO: ManagedObjectProtocol { } } extension Gallery: ManagedObjectConvertible { - @discardableResult func toManagedObject(in context: NSManagedObjectContext) -> GalleryMO { + @discardableResult public func toManagedObject(in context: NSManagedObjectContext) -> GalleryMO { let galleryMO = GalleryMO(context: context) galleryMO.gid = gid diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataProperties.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataProperties.swift similarity index 100% rename from AppPackage/Sources/AppFeature/Database/MODefinition/GalleryMO+CoreDataProperties.swift rename to AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataProperties.swift diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift similarity index 90% rename from AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift rename to AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift index ebeb5e6f5..7648c4544 100644 --- a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataClass.swift +++ b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift @@ -6,7 +6,7 @@ import FoundationExt public class GalleryStateMO: NSManagedObject {} extension GalleryStateMO: ManagedObjectProtocol { - func toEntity() -> GalleryState { + public func toEntity() -> GalleryState { GalleryState( gid: gid, tags: tags?.toObject() ?? [GalleryTag](), readingProgress: Int(readingProgress), @@ -21,7 +21,7 @@ extension GalleryStateMO: ManagedObjectProtocol { } extension GalleryState: ManagedObjectConvertible { - @discardableResult func toManagedObject(in context: NSManagedObjectContext) -> GalleryStateMO { + @discardableResult public func toManagedObject(in context: NSManagedObjectContext) -> GalleryStateMO { let galleryStateMO = GalleryStateMO(context: context) galleryStateMO.gid = gid diff --git a/AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift similarity index 100% rename from AppPackage/Sources/AppFeature/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift rename to AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift diff --git a/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationStep.swift b/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationStep.swift similarity index 85% rename from AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationStep.swift rename to AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationStep.swift index 81e8db4f9..27ac8562c 100755 --- a/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationStep.swift +++ b/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationStep.swift @@ -1,12 +1,12 @@ import CoreData import AppModels -struct CoreDataMigrationStep { - let sourceModel: NSManagedObjectModel - let destinationModel: NSManagedObjectModel - let mappingModel: NSMappingModel +public struct CoreDataMigrationStep { + public let sourceModel: NSManagedObjectModel + public let destinationModel: NSManagedObjectModel + public let mappingModel: NSMappingModel - init(sourceVersion: CoreDataMigrationVersion, destinationVersion: CoreDataMigrationVersion) throws { + public init(sourceVersion: CoreDataMigrationVersion, destinationVersion: CoreDataMigrationVersion) throws { let sourceModel = try NSManagedObjectModel.managedObjectModel(forResource: sourceVersion.rawValue) let destinationModel = try NSManagedObjectModel.managedObjectModel(forResource: destinationVersion.rawValue) diff --git a/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationVersion.swift b/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationVersion.swift similarity index 79% rename from AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationVersion.swift rename to AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationVersion.swift index 5e1cbc915..7722ebf0c 100755 --- a/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrationVersion.swift +++ b/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationVersion.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import CoreData -enum CoreDataMigrationVersion: String, CaseIterable { +public enum CoreDataMigrationVersion: String, CaseIterable { case version1 = "Model" case version2 = "Model 2" case version3 = "Model 3" @@ -11,14 +11,14 @@ enum CoreDataMigrationVersion: String, CaseIterable { case version6 = "Model 6" case version7 = "Model 7" - static func current() throws -> CoreDataMigrationVersion { + public static func current() throws -> CoreDataMigrationVersion { guard let latest = allCases.last else { throw AppError.databaseCorrupted("No model versions found.") } return latest } - func nextVersion() -> CoreDataMigrationVersion? { + public func nextVersion() -> CoreDataMigrationVersion? { switch self { case .version1: return .version2 case .version2: return .version3 diff --git a/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrator.swift b/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrator.swift similarity index 91% rename from AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrator.swift rename to AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrator.swift index 5614619a9..779de136f 100755 --- a/AppPackage/Sources/AppFeature/Database/Migration/CoreDataMigrator.swift +++ b/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrator.swift @@ -1,18 +1,18 @@ import CoreData import AppModels -protocol CoreDataMigratorProtocol { +public protocol CoreDataMigratorProtocol { func requiresMigration(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws -> Bool func migrateStore(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws } -final class CoreDataMigrator: CoreDataMigratorProtocol, Sendable { - func requiresMigration(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws -> Bool { +public final class CoreDataMigrator: CoreDataMigratorProtocol, Sendable { + public func requiresMigration(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws -> Bool { guard let metadata = NSPersistentStoreCoordinator.metadata(at: storeURL) else { return false } return (try CoreDataMigrationVersion.compatibleVersionForStoreMetadata(metadata) != version) } - func migrateStore(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws { + public func migrateStore(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws { try forceWALCheckpointingForStore(at: storeURL) var currentURL = storeURL @@ -78,7 +78,7 @@ final class CoreDataMigrator: CoreDataMigratorProtocol, Sendable { return migrationSteps } - func forceWALCheckpointingForStore(at storeURL: URL) throws { + public func forceWALCheckpointingForStore(at storeURL: URL) throws { guard let metadata = NSPersistentStoreCoordinator.metadata(at: storeURL), let currentModel = NSManagedObjectModel.compatibleModelForStoreMetadata(metadata) else { return } diff --git a/AppPackage/Sources/AppFeature/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift b/AppPackage/Sources/DatabaseClient/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift similarity index 100% rename from AppPackage/Sources/AppFeature/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift rename to AppPackage/Sources/DatabaseClient/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift diff --git a/AppPackage/Sources/AppFeature/Database/Persistence.swift b/AppPackage/Sources/DatabaseClient/Database/Persistence.swift similarity index 87% rename from AppPackage/Sources/AppFeature/Database/Persistence.swift rename to AppPackage/Sources/DatabaseClient/Database/Persistence.swift index 259ee7158..98cfed52e 100644 --- a/AppPackage/Sources/AppFeature/Database/Persistence.swift +++ b/AppPackage/Sources/DatabaseClient/Database/Persistence.swift @@ -1,11 +1,11 @@ import CoreData import AppModels -struct PersistenceController: Sendable { - static let shared = PersistenceController() - let migrator = CoreDataMigrator() +public struct PersistenceController: Sendable { + public static let shared = PersistenceController() + public let migrator = CoreDataMigrator() - let container: NSPersistentCloudKitContainer = { + public let container: NSPersistentCloudKitContainer = { guard let modelURL = Bundle.module.url(forResource: "Model", withExtension: "momd"), let model = NSManagedObjectModel(contentsOf: modelURL) else { fatalError("Failed to load the Core Data model from the module bundle.") @@ -20,14 +20,14 @@ struct PersistenceController: Sendable { // MARK: Preparation extension PersistenceController { - func prepare(completion: @escaping @Sendable (Result) -> Void) { + public func prepare(completion: @escaping @Sendable (Result) -> Void) { do { try loadPersistentStore(completion: completion) } catch { completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) } } - func rebuild(completion: @escaping @Sendable (Result) -> Void) { + public func rebuild(completion: @escaping @Sendable (Result) -> Void) { guard let storeURL = container.persistentStoreDescriptions.first?.url else { completion(.failure(.databaseCorrupted("PersistentContainer was not set up properly."))) return @@ -92,18 +92,18 @@ extension PersistenceController { } // MARK: Definition -protocol ManagedObjectProtocol { +public protocol ManagedObjectProtocol { associatedtype Entity func toEntity() -> Entity } -protocol ManagedObjectConvertible { +public protocol ManagedObjectConvertible { associatedtype ManagedObject: NSManagedObject, ManagedObjectProtocol @discardableResult func toManagedObject(in context: NSManagedObjectContext) -> ManagedObject } -protocol GalleryIdentifiable: NSManagedObject { +public protocol GalleryIdentifiable: NSManagedObject { var gid: String { get set } } diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift b/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift similarity index 68% rename from AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift rename to AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift index e6ff8a768..7d333f2d4 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient+Updates.swift +++ b/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift @@ -5,37 +5,37 @@ import FoundationExt // MARK: UpdateGalleryState extension DatabaseClient { - @MainActor func updateGalleryState(gid: String, commitChanges: @escaping (GalleryStateMO) -> Void) { + @MainActor public func updateGalleryState(gid: String, commitChanges: @escaping (GalleryStateMO) -> Void) { guard gid.isValidGID else { return } update( entityType: GalleryStateMO.self, gid: gid, createIfNil: true, commitChanges: commitChanges ) } - @MainActor func updateGalleryState(gid: String, key: String, value: Any?) { + @MainActor public func updateGalleryState(gid: String, key: String, value: Any?) { guard gid.isValidGID else { return } updateGalleryState(gid: gid) { stateMO in stateMO.setValue(value, forKeyPath: key) } } - @MainActor func updateGalleryTags(gid: String, tags: [GalleryTag]) { + @MainActor public func updateGalleryTags(gid: String, tags: [GalleryTag]) { guard gid.isValidGID else { return } updateGalleryState(gid: gid, key: "tags", value: tags.toData()) } - @MainActor func updatePreviewConfig(gid: String, config: PreviewConfig) { + @MainActor public func updatePreviewConfig(gid: String, config: PreviewConfig) { guard gid.isValidGID else { return } updateGalleryState(gid: gid, key: "previewConfig", value: config.toData()) } - @MainActor func updateReadingProgress(gid: String, progress: Int) { + @MainActor public func updateReadingProgress(gid: String, progress: Int) { guard gid.isValidGID else { return } updateGalleryState(gid: gid, key: "readingProgress", value: Int64(progress)) } - @MainActor func updateComments(gid: String, comments: [GalleryComment]) { + @MainActor public func updateComments(gid: String, comments: [GalleryComment]) { guard gid.isValidGID else { return } updateGalleryState(gid: gid, key: "comments", value: comments.toData()) } - @MainActor func removeImageURLs(gid: String) { + @MainActor public func removeImageURLs(gid: String) { guard gid.isValidGID else { return } updateGalleryState(gid: gid) { galleryStateMO in galleryStateMO.imageURLs = nil @@ -44,7 +44,7 @@ extension DatabaseClient { galleryStateMO.originalImageURLs = nil } } - @MainActor func removeImageURLs() { + @MainActor public func removeImageURLs() { batchUpdate(entityType: GalleryStateMO.self) { galleryStateMOs in galleryStateMOs.forEach { galleryStateMO in galleryStateMO.imageURLs = nil @@ -54,25 +54,25 @@ extension DatabaseClient { } } } - @MainActor func removeExpiredImageURLs() { + @MainActor public func removeExpiredImageURLs() { fetchHistoryGalleries() .filter { Date().timeIntervalSince($0.lastOpenDate ?? .distantPast) > .oneWeek } .forEach { removeImageURLs(gid: $0.id) } } - @MainActor func updateThumbnailURLs(gid: String, thumbnailURLs: [Int: URL]) { + @MainActor public func updateThumbnailURLs(gid: String, thumbnailURLs: [Int: URL]) { guard gid.isValidGID else { return } updateGalleryState(gid: gid) { galleryStateMO in update(gid: gid, storedData: &galleryStateMO.thumbnailURLs, new: thumbnailURLs) } } - @MainActor func updateImageURLs(gid: String, imageURLs: [Int: URL], originalImageURLs: [Int: URL]) { + @MainActor public func updateImageURLs(gid: String, imageURLs: [Int: URL], originalImageURLs: [Int: URL]) { guard gid.isValidGID else { return } updateGalleryState(gid: gid) { galleryStateMO in update(gid: gid, storedData: &galleryStateMO.imageURLs, new: imageURLs) update(gid: gid, storedData: &galleryStateMO.originalImageURLs, new: originalImageURLs) } } - @MainActor func updatePreviewURLs(gid: String, previewURLs: [Int: URL]) { + @MainActor public func updatePreviewURLs(gid: String, previewURLs: [Int: URL]) { guard gid.isValidGID else { return } updateGalleryState(gid: gid) { galleryStateMO in update(gid: gid, storedData: &galleryStateMO.previewURLs, new: previewURLs) @@ -82,16 +82,16 @@ extension DatabaseClient { // MARK: UpdateAppEnv extension DatabaseClient { - @MainActor func updateAppEnv(key: String, value: Any?) { + @MainActor public func updateAppEnv(key: String, value: Any?) { update( entityType: AppEnvMO.self, createIfNil: true, commitChanges: { $0.setValue(value, forKeyPath: key) } ) } - @MainActor func updateSetting(_ setting: Setting) { + @MainActor public func updateSetting(_ setting: Setting) { updateAppEnv(key: "setting", value: setting.toData()) } - @MainActor func updateFilter(_ filter: Filter, range: FilterRange) { + @MainActor public func updateFilter(_ filter: Filter, range: FilterRange) { let key: String switch range { case .search: @@ -103,31 +103,31 @@ extension DatabaseClient { } updateAppEnv(key: key, value: filter.toData()) } - @MainActor func updateTagTranslator(_ tagTranslator: TagTranslator) { + @MainActor public func updateTagTranslator(_ tagTranslator: TagTranslator) { updateAppEnv(key: "tagTranslator", value: tagTranslator.toData()) } - @MainActor func updateUser(_ user: User) { + @MainActor public func updateUser(_ user: User) { updateAppEnv(key: "user", value: user.toData()) } - @MainActor func updateHistoryKeywords(_ keywords: [String]) { + @MainActor public func updateHistoryKeywords(_ keywords: [String]) { updateAppEnv(key: "historyKeywords", value: keywords.toData()) } - @MainActor func updateQuickSearchWords(_ words: [QuickSearchWord]) { + @MainActor public func updateQuickSearchWords(_ words: [QuickSearchWord]) { updateAppEnv(key: "quickSearchWords", value: words.toData()) } // Update User - @MainActor func updateUserProperty(_ commitChanges: @escaping (inout User) -> Void) { + @MainActor public func updateUserProperty(_ commitChanges: @escaping (inout User) -> Void) { var user = fetchAppEnv().user commitChanges(&user) updateUser(user) } - @MainActor func updateGreeting(_ greeting: Greeting) { + @MainActor public func updateGreeting(_ greeting: Greeting) { updateUserProperty { user in user.greeting = greeting } } - @MainActor func updateGalleryFunds(galleryPoints: String, credits: String) { + @MainActor public func updateGalleryFunds(galleryPoints: String, credits: String) { updateUserProperty { user in user.credits = credits user.galleryPoints = galleryPoints diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift similarity index 84% rename from AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift rename to AppPackage/Sources/DatabaseClient/DatabaseClient.swift index df7a68fba..40917e3ae 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DatabaseClient.swift +++ b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift @@ -6,9 +6,9 @@ import ComposableArchitecture import FoundationExt import Utilities -struct DatabaseClient: Sendable { - let prepareDatabase: @Sendable () async -> Result - let dropDatabase: @Sendable () async -> Result +public struct DatabaseClient: Sendable { + public let prepareDatabase: @Sendable () async -> Result + public let dropDatabase: @Sendable () async -> Result private let viewContext: @Sendable () -> NSManagedObjectContext private let saveContext: @Sendable () -> Void private let materializedObjects: @@ -16,7 +16,7 @@ struct DatabaseClient: Sendable { } extension DatabaseClient { - static let live: Self = .init( + public static let live: Self = .init( prepareDatabase: { await withCheckedContinuation { continuation in PersistenceController.shared.prepare { result in @@ -49,7 +49,7 @@ extension DatabaseClient { materializedObjects: materializedObjectsFromContext ) - static func live(persistenceContainer container: NSPersistentContainer) -> Self { + public static func live(persistenceContainer container: NSPersistentContainer) -> Self { .init( prepareDatabase: { .success(()) }, dropDatabase: { .success(()) }, @@ -87,7 +87,7 @@ extension DatabaseClient { // MARK: Foundation extension DatabaseClient { - func batchFetch( + public func batchFetch( entityType: MO.Type, fetchLimit: Int = 0, predicate: NSPredicate? = nil, findBeforeFetch: Bool = true, sortDescriptors: [NSSortDescriptor]? = nil ) -> [MO] { @@ -111,7 +111,7 @@ extension DatabaseClient { return results } - func fetch( + public func fetch( entityType: MO.Type, predicate: NSPredicate? = nil, findBeforeFetch: Bool = true, commitChanges: ((MO?) -> Void)? = nil ) -> MO? { @@ -123,7 +123,7 @@ extension DatabaseClient { return managedObject } - func fetchOrCreate( + public func fetchOrCreate( entityType: MO.Type, predicate: NSPredicate? = nil, commitChanges: ((MO?) -> Void)? = nil ) -> MO { @@ -139,7 +139,7 @@ extension DatabaseClient { } } - func batchUpdate( + public func batchUpdate( entityType: MO.Type, predicate: NSPredicate? = nil, commitChanges: ([MO]) -> Void ) { commitChanges(batchFetch( @@ -149,7 +149,7 @@ extension DatabaseClient { )) saveContext() } - func update( + public func update( entityType: MO.Type, predicate: NSPredicate? = nil, createIfNil: Bool = false, commitChanges: (MO) -> Void ) { @@ -170,7 +170,7 @@ extension DatabaseClient { // MARK: GalleryIdentifiable extension DatabaseClient { - func fetch( + public func fetch( entityType: MO.Type, gid: String, findBeforeFetch: Bool = true, commitChanges: ((MO?) -> Void)? = nil @@ -180,14 +180,14 @@ extension DatabaseClient { findBeforeFetch: findBeforeFetch, commitChanges: commitChanges ) } - func fetchOrCreate(entityType: MO.Type, gid: String) -> MO { + public func fetchOrCreate(entityType: MO.Type, gid: String) -> MO { fetchOrCreate( entityType: entityType, predicate: NSPredicate(format: "gid == %@", gid), commitChanges: { $0?.gid = gid } ) } - func update( + public func update( entityType: MO.Type, gid: String, createIfNil: Bool = false, commitChanges: @escaping @Sendable ((MO) -> Void) @@ -209,7 +209,7 @@ extension DatabaseClient { // MARK: GalleryState Helpers extension DatabaseClient { - func update(gid: String, storedData: inout Data?, new: [Int: T]) { + public func update(gid: String, storedData: inout Data?, new: [Int: T]) { guard !new.isEmpty, gid.isValidGID else { return } storedData = ((storedData?.toObject() as [Int: T]?) ?? [:]) .merging(new, uniquingKeysWith: { _, new in new }) @@ -219,7 +219,7 @@ extension DatabaseClient { // MARK: Fetch extension DatabaseClient { - func fetchGallery(gid: String) -> Gallery? { + public func fetchGallery(gid: String) -> Gallery? { guard gid.isValidGID else { return nil } var entity: Gallery? AppUtil.dispatchMainSync { @@ -227,7 +227,7 @@ extension DatabaseClient { } return entity } - func fetchGalleryDetail(gid: String) -> GalleryDetail? { + public func fetchGalleryDetail(gid: String) -> GalleryDetail? { guard gid.isValidGID else { return nil } var entity: GalleryDetail? AppUtil.dispatchMainSync { @@ -235,17 +235,17 @@ extension DatabaseClient { } return entity } - @MainActor func fetchAppEnv() -> AppEnv { + @MainActor public func fetchAppEnv() -> AppEnv { fetchOrCreate(entityType: AppEnvMO.self).toEntity() } - func fetchAppEnvSynchronously() -> AppEnv { + public func fetchAppEnvSynchronously() -> AppEnv { fetchOrCreate(entityType: AppEnvMO.self).toEntity() } - @MainActor func fetchGalleryState(gid: String) async -> GalleryState? { + @MainActor public func fetchGalleryState(gid: String) async -> GalleryState? { guard gid.isValidGID else { return nil } return fetchOrCreate(entityType: GalleryStateMO.self, gid: gid).toEntity() } - @MainActor func fetchHistoryGalleries(fetchLimit: Int = 0) -> [Gallery] { + @MainActor public func fetchHistoryGalleries(fetchLimit: Int = 0) -> [Gallery] { let predicate = NSPredicate(format: "lastOpenDate != nil") let sortDescriptor = NSSortDescriptor( keyPath: \GalleryMO.lastOpenDate, ascending: false @@ -260,7 +260,7 @@ extension DatabaseClient { } // MARK: FetchAccessor extension DatabaseClient { - func fetchFilterSynchronously(range: FilterRange) -> Filter { + public func fetchFilterSynchronously(range: FilterRange) -> Filter { switch range { case .search: return fetchAppEnvSynchronously().searchFilter @@ -270,13 +270,13 @@ extension DatabaseClient { return fetchAppEnvSynchronously().watchedFilter } } - @MainActor func fetchHistoryKeywords() -> [String] { + @MainActor public func fetchHistoryKeywords() -> [String] { fetchAppEnv().historyKeywords } - @MainActor func fetchQuickSearchWords() -> [QuickSearchWord] { + @MainActor public func fetchQuickSearchWords() -> [QuickSearchWord] { fetchAppEnv().quickSearchWords } - @MainActor func fetchGalleryPreviewURLs(gid: String) async -> [Int: URL]? { + @MainActor public func fetchGalleryPreviewURLs(gid: String) async -> [Int: URL]? { guard gid.isValidGID else { return nil } return await fetchGalleryState(gid: gid).map(\.previewURLs) } @@ -284,18 +284,18 @@ extension DatabaseClient { // MARK: UpdateGallery extension DatabaseClient { - @MainActor func updateGallery(gid: String, key: String, value: Any?) { + @MainActor public func updateGallery(gid: String, key: String, value: Any?) { guard gid.isValidGID else { return } update( entityType: GalleryMO.self, gid: gid, createIfNil: true, commitChanges: { $0.setValue(value, forKeyPath: key) } ) } - @MainActor func updateLastOpenDate(gid: String, date: Date = .now) { + @MainActor public func updateLastOpenDate(gid: String, date: Date = .now) { guard gid.isValidGID else { return } updateGallery(gid: gid, key: "lastOpenDate", value: date) } - @MainActor func clearHistoryGalleries() { + @MainActor public func clearHistoryGalleries() { let predicate = NSPredicate(format: "lastOpenDate != nil") batchUpdate(entityType: GalleryMO.self, predicate: predicate) { galleryMOs in galleryMOs.forEach { galleryMO in @@ -303,7 +303,7 @@ extension DatabaseClient { } } } - @MainActor func cacheGalleries(_ galleries: [Gallery]) { + @MainActor public func cacheGalleries(_ galleries: [Gallery]) { for gallery in galleries.filter({ $0.id.isValidGID }) { let storedMO = fetch( entityType: GalleryMO.self, gid: gallery.gid @@ -332,7 +332,7 @@ extension DatabaseClient { // MARK: UpdateGalleryDetail extension DatabaseClient { - @MainActor func cacheGalleryDetail(_ detail: GalleryDetail) { + @MainActor public func cacheGalleryDetail(_ detail: GalleryDetail) { guard detail.gid.isValidGID else { return } let storedMO = fetch( entityType: GalleryDetailMO.self, gid: detail.gid @@ -367,14 +367,14 @@ extension DatabaseClient { // UpdateGalleryState and UpdateAppEnv are in DatabaseClient+Updates.swift // MARK: API -enum DatabaseClientKey: DependencyKey { - static let liveValue = DatabaseClient.live - static let previewValue = DatabaseClient.noop - static let testValue = DatabaseClient.unimplemented +public enum DatabaseClientKey: DependencyKey { + public static let liveValue = DatabaseClient.live + public static let previewValue = DatabaseClient.noop + public static let testValue = DatabaseClient.unimplemented } extension DependencyValues { - var databaseClient: DatabaseClient { + public var databaseClient: DatabaseClient { get { self[DatabaseClientKey.self] } set { self[DatabaseClientKey.self] = newValue } } @@ -382,7 +382,7 @@ extension DependencyValues { // MARK: Test extension DatabaseClient { - static let noop: Self = .init( + public static let noop: Self = .init( prepareDatabase: { .success(()) }, dropDatabase: { .success(()) }, viewContext: { @@ -392,9 +392,9 @@ extension DatabaseClient { materializedObjects: { _, _ in .init() } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( prepareDatabase: IssueReporting.unimplemented(placeholder: placeholder()), dropDatabase: IssueReporting.unimplemented(placeholder: placeholder()), viewContext: IssueReporting.unimplemented(placeholder: placeholder()), diff --git a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/.xccurrentversion b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/.xccurrentversion similarity index 100% rename from AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/.xccurrentversion rename to AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/.xccurrentversion diff --git a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents similarity index 92% rename from AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents rename to AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents index 36fa4bee7..ede8a2693 100644 --- a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents +++ b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents @@ -1,13 +1,13 @@ - + - + @@ -29,7 +29,7 @@ - + @@ -43,7 +43,7 @@ - + diff --git a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents similarity index 92% rename from AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents rename to AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents index ae9a5622c..cea21fb25 100644 --- a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents +++ b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents @@ -1,13 +1,13 @@ - + - + @@ -29,7 +29,7 @@ - + @@ -43,7 +43,7 @@ - + diff --git a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents similarity index 92% rename from AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents rename to AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents index 9a5e0446a..67bbf5d10 100644 --- a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents +++ b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents @@ -1,6 +1,6 @@ - + @@ -8,7 +8,7 @@ - + @@ -30,7 +30,7 @@ - + @@ -44,7 +44,7 @@ - + diff --git a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents similarity index 92% rename from AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents rename to AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents index 094b739cb..01cdfa34f 100644 --- a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents +++ b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents @@ -1,6 +1,6 @@ - + @@ -9,7 +9,7 @@ - + @@ -31,7 +31,7 @@ - + @@ -45,7 +45,7 @@ - + diff --git a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents similarity index 93% rename from AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents rename to AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents index 6aaf4ac80..94bdc98ad 100644 --- a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents +++ b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents @@ -1,6 +1,6 @@ - + @@ -10,7 +10,7 @@ - + @@ -32,7 +32,7 @@ - + @@ -47,7 +47,7 @@ - + diff --git a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents similarity index 92% rename from AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents rename to AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents index 8cf3007bc..d3327e779 100644 --- a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents +++ b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents @@ -1,6 +1,6 @@ - + @@ -10,7 +10,7 @@ - + @@ -32,7 +32,7 @@ - + @@ -46,7 +46,7 @@ - + diff --git a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents similarity index 92% rename from AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents rename to AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents index 3f1d9567c..01182bddb 100644 --- a/AppPackage/Sources/AppFeature/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents +++ b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents @@ -1,13 +1,13 @@ - + - + @@ -29,7 +29,7 @@ - + @@ -43,7 +43,7 @@ - + diff --git a/AppPackage/Sources/AppFeature/Resources/Model5toModel6.xcmappingmodel/xcmapping.xml b/AppPackage/Sources/DatabaseClient/Resources/Model5toModel6.xcmappingmodel/xcmapping.xml similarity index 100% rename from AppPackage/Sources/AppFeature/Resources/Model5toModel6.xcmappingmodel/xcmapping.xml rename to AppPackage/Sources/DatabaseClient/Resources/Model5toModel6.xcmappingmodel/xcmapping.xml diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DatabaseClientUpdateTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DatabaseClientUpdateTests.swift index 1d08823a9..8d6159050 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DatabaseClientUpdateTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DatabaseClientUpdateTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import DatabaseClient @testable import AppFeature struct DatabaseClientUpdateTests: DownloadFeatureTestCase { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift index 0d0bc8240..5f7770207 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import HapticsClient +import DatabaseClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift index 8488e32c6..75dd5cf2c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import HapticsClient +import DatabaseClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index fad2ba723..b9bac9016 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import HapticsClient +import DatabaseClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift index bb936c362..d9c0de838 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import HapticsClient +import DatabaseClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index dfbc3ab60..c1bebd841 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import HapticsClient +import DatabaseClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index 7c7744d27..1d2a0eedf 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -9,6 +9,7 @@ import UserDefaultsClient import UIApplicationClient import HapticsClient import LibraryClient +import DatabaseClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift index a825e2874..4ed5e70d4 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -4,6 +4,7 @@ import Foundation import Testing import FoundationExt import Utilities +import DatabaseClient @testable import AppFeature // MARK: - Sample Data Factories & CoreData Helpers diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index 044450608..ea432d47f 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -6,6 +6,7 @@ import Utilities import URLClient import HapticsClient import ImageClient +import DatabaseClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index 261d28255..793855bcd 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -5,6 +5,7 @@ import Testing import URLClient import HapticsClient import ImageClient +import DatabaseClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift index 7cd8efafd..29cc0db03 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import HapticsClient +import DatabaseClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index cb3e58b01..0c9b0fe45 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -5,6 +5,7 @@ import Testing import URLClient import HapticsClient import ImageClient +import DatabaseClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index 9c42db5f9..7b7c552e8 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -6,6 +6,7 @@ import Utilities import URLClient import HapticsClient import ImageClient +import DatabaseClient @testable import AppFeature @Suite(.serialized) From 5d9a502db733095066fb4a3037afd4ec6ac055f2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 19:17:10 +0800 Subject: [PATCH 316/614] Extract Parser module Move the HTML-parsing layer (Tools/Parser/*) into its own module. Only the parse* entry points and the value types they return are public; the parsing helpers and private extensions stay internal. Relocate the VerifyEhProfileResponse value type out of the Request layer into AppModels so the parser can produce it without depending on Network (the request that returns it stays in the network layer). --- AppPackage/Package.swift | 15 +++++++ .../AppFeature/Network/Request+Account.swift | 5 +-- .../AppFeature/Network/Request+Detail.swift | 1 + .../AppFeature/Network/Request+Gallery.swift | 1 + .../AppFeature/Network/Request+Image.swift | 1 + .../Sources/AppFeature/Network/Request.swift | 1 + .../DownloadClient+ResponseValidation.swift | 1 + ...loadClient+ResponseValidationHelpers.swift | 1 + .../AppFeature/Tools/Extensions/URL+App.swift | 1 + .../Tools/Extensions/ViewModifiers.swift | 1 + .../Tools/Parser/Parser+Types.swift | 43 ------------------- .../AppFeature/Tools/Parser/Parser.swift | 1 - .../Support/VerifyEhProfileResponse.swift | 9 ++++ AppPackage/Sources/Parser/.swiftlint.yml | 1 + .../Tools => }/Parser/Parser+Archive.swift | 4 +- .../Tools => }/Parser/Parser+Comment.swift | 0 .../Tools => }/Parser/Parser+Detail.swift | 4 +- .../Tools => }/Parser/Parser+Favorite.swift | 4 +- .../Tools => }/Parser/Parser+Greeting.swift | 2 +- .../Tools => }/Parser/Parser+Image.swift | 6 +-- .../Tools => }/Parser/Parser+List.swift | 2 +- .../Tools => }/Parser/Parser+Misc.swift | 6 +-- .../Tools => }/Parser/Parser+Preview.swift | 4 +- .../Tools => }/Parser/Parser+Profile.swift | 4 +- .../Parser/Parser+ResponseError.swift | 4 +- .../Tools => }/Parser/Parser+Shared.swift | 7 ++- .../Tools => }/Parser/Parser+Torrent.swift | 2 +- AppPackage/Sources/Parser/Parser+Types.swift | 43 +++++++++++++++++++ .../Tools => }/Parser/Parser+User.swift | 4 +- AppPackage/Sources/Parser/Parser.swift | 1 + .../Gallery/GalleryDetailParserTests.swift | 1 + .../Gallery/GalleryImageURLParserTests.swift | 1 + .../Gallery/GalleryMPVKeysParserTests.swift | 1 + .../Tests/Parser/List/ListParserTests.swift | 1 + .../Parser/Other/BanIntervalParserTests.swift | 1 + .../Other/DownloadPageErrorParserTests.swift | 1 + .../Parser/Other/EhSettingParserTests.swift | 1 + .../Parser/Other/GreetingParserTests.swift | 1 + 38 files changed, 114 insertions(+), 73 deletions(-) delete mode 100644 AppPackage/Sources/AppFeature/Tools/Parser/Parser+Types.swift delete mode 100644 AppPackage/Sources/AppFeature/Tools/Parser/Parser.swift create mode 100644 AppPackage/Sources/AppModels/Support/VerifyEhProfileResponse.swift create mode 100644 AppPackage/Sources/Parser/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+Archive.swift (95%) rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+Comment.swift (100%) rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+Detail.swift (98%) rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+Favorite.swift (85%) rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+Greeting.swift (97%) rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+Image.swift (90%) rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+List.swift (99%) rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+Misc.swift (91%) rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+Preview.swift (95%) rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+Profile.swift (98%) rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+ResponseError.swift (97%) rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+Shared.swift (97%) rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+Torrent.swift (97%) create mode 100644 AppPackage/Sources/Parser/Parser+Types.swift rename AppPackage/Sources/{AppFeature/Tools => }/Parser/Parser+User.swift (91%) create mode 100644 AppPackage/Sources/Parser/Parser.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 99e4dfded..23f654457 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -76,6 +76,7 @@ enum Module: String { case imageClient = "ImageClient" case libraryClient = "LibraryClient" case loggerClient = "LoggerClient" + case parser = "Parser" case resources = "Resources" case sdWebImageExt = "SDWebImageExt" case swiftUINavigationExt = "SwiftUINavigationExt" @@ -228,6 +229,7 @@ let targets: [PackageDescription.Target] = [ .module(.imageClient), .module(.libraryClient), .module(.loggerClient), + .module(.parser), .module(.resources), .module(.sdWebImageExt), .module(.swiftUINavigationExt), @@ -379,6 +381,18 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .parser, + dependencies: [ + .module(.appModels), + .module(.foundationExt), + .module(.resources), + .module(.utilities), + .targetDependency(.kanna) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .uiApplicationClient, dependencies: [ @@ -421,6 +435,7 @@ let targets: [PackageDescription.Target] = [ .module(.imageClient), .module(.libraryClient), .module(.loggerClient), + .module(.parser), .module(.sdWebImageExt), .module(.uiApplicationClient), .module(.urlClient), diff --git a/AppPackage/Sources/AppFeature/Network/Request+Account.swift b/AppPackage/Sources/AppFeature/Network/Request+Account.swift index 918596a8d..9bc8c52a0 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Account.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Account.swift @@ -4,6 +4,7 @@ import Combine import Foundation import FoundationExt import Utilities +import Parser // MARK: Account Ops struct LoginRequest: Request { @@ -43,10 +44,6 @@ struct IgneousRequest: Request { } } -struct VerifyEhProfileResponse: Equatable { - let profileValue: Int? - let isProfileNotFound: Bool -} struct VerifyEhProfileRequest: Request { var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) diff --git a/AppPackage/Sources/AppFeature/Network/Request+Detail.swift b/AppPackage/Sources/AppFeature/Network/Request+Detail.swift index 65388a1e3..42b493dac 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Detail.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Detail.swift @@ -3,6 +3,7 @@ import AppModels import Combine import Foundation import Utilities +import Parser // MARK: Response Types struct GalleryDetailResponse { diff --git a/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift b/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift index b3a755806..70caf46dc 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift @@ -3,6 +3,7 @@ import AppModels import Combine import Foundation import Utilities +import Parser // MARK: Fetch ListItems struct SearchGalleriesRequest: Request { diff --git a/AppPackage/Sources/AppFeature/Network/Request+Image.swift b/AppPackage/Sources/AppFeature/Network/Request+Image.swift index c2afd68e3..7daab3406 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Image.swift +++ b/AppPackage/Sources/AppFeature/Network/Request+Image.swift @@ -3,6 +3,7 @@ import AppModels import Combine import Foundation import Utilities +import Parser // MARK: Response Types struct GalleryMPVImageURLResponse { diff --git a/AppPackage/Sources/AppFeature/Network/Request.swift b/AppPackage/Sources/AppFeature/Network/Request.swift index 329688a83..aa3e7e538 100644 --- a/AppPackage/Sources/AppFeature/Network/Request.swift +++ b/AppPackage/Sources/AppFeature/Network/Request.swift @@ -5,6 +5,7 @@ import Foundation import ComposableArchitecture import FoundationExt import Utilities +import Parser protocol Request { associatedtype Response: Sendable diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift index eb7ead657..34216c31e 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift @@ -4,6 +4,7 @@ import Foundation import ImageIO import FoundationExt import Utilities +import Parser // MARK: - Response Error Detection extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift index 0e647c883..975995c1a 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -5,6 +5,7 @@ import ImageIO import FoundationExt import Utilities import SDWebImageExt +import Parser // MARK: - Response Inspection Helpers extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift index 58123888a..71a26501b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import Parser extension URL { static let mock = Defaults.URL.ehentai diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift index 4b0919dc3..7946be638 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift @@ -1,6 +1,7 @@ import SwiftUI import Kingfisher import FoundationExt +import Parser extension View { func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Types.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Types.swift deleted file mode 100644 index df2728590..000000000 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Types.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -import AppModels - -extension Parser { - struct ThumbnailPanelInfo { - let coverURL: URL - let category: AppModels.Category - let rating: Float - let publishedDate: Date - let pageCount: Int - let uploader: String? - } - - struct GalleryNormalImageInfo { - let index: Int - let imageURL: URL - let originalImageURL: URL? - } - - struct RatingResult { - let imgRating: Float - let textRating: Float? - let containsUserRating: Bool - } - - struct PreviewConfigInfo { - let plainURL: URL - let size: CGSize - let offset: CGSize - } - - struct SelectionOption { - let name: String - let value: String - let isSelected: Bool - } - - struct ThumbnailSizeOption { - let value: Int - let isEnabled: Bool - let isSelected: Bool - } -} diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser.swift b/AppPackage/Sources/AppFeature/Tools/Parser/Parser.swift deleted file mode 100644 index 62647d596..000000000 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser.swift +++ /dev/null @@ -1 +0,0 @@ -enum Parser {} diff --git a/AppPackage/Sources/AppModels/Support/VerifyEhProfileResponse.swift b/AppPackage/Sources/AppModels/Support/VerifyEhProfileResponse.swift new file mode 100644 index 000000000..4a3cc4de1 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/VerifyEhProfileResponse.swift @@ -0,0 +1,9 @@ +public struct VerifyEhProfileResponse: Equatable, Sendable { + public let profileValue: Int? + public let isProfileNotFound: Bool + + public init(profileValue: Int?, isProfileNotFound: Bool) { + self.profileValue = profileValue + self.isProfileNotFound = isProfileNotFound + } +} diff --git a/AppPackage/Sources/Parser/.swiftlint.yml b/AppPackage/Sources/Parser/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/Parser/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Archive.swift b/AppPackage/Sources/Parser/Parser+Archive.swift similarity index 95% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+Archive.swift rename to AppPackage/Sources/Parser/Parser+Archive.swift index e87ae59c4..fed44846b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Archive.swift +++ b/AppPackage/Sources/Parser/Parser+Archive.swift @@ -3,7 +3,7 @@ import AppModels import Foundation extension Parser { - static func parseGalleryArchive(doc: HTMLDocument) throws -> GalleryArchive { + public static func parseGalleryArchive(doc: HTMLDocument) throws -> GalleryArchive { guard let node = doc.at_xpath("//table") else { throw AppError.parseFailed } @@ -54,7 +54,7 @@ extension Parser { return GalleryArchive(hathArchives: hathArchives) } - static func parseDownloadCommandResponse(doc: HTMLDocument) throws -> String { + public static func parseDownloadCommandResponse(doc: HTMLDocument) throws -> String { guard let dbNode = doc.at_xpath("//div [@id='db']") else { throw AppError.parseFailed } diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Comment.swift b/AppPackage/Sources/Parser/Parser+Comment.swift similarity index 100% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+Comment.swift rename to AppPackage/Sources/Parser/Parser+Comment.swift diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift b/AppPackage/Sources/Parser/Parser+Detail.swift similarity index 98% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift rename to AppPackage/Sources/Parser/Parser+Detail.swift index ed00cfe9e..14eb7f996 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Detail.swift +++ b/AppPackage/Sources/Parser/Parser+Detail.swift @@ -4,14 +4,14 @@ import Foundation import FoundationExt extension Parser { - static func parseGalleryURL(doc: HTMLDocument) throws -> URL { + public static func parseGalleryURL(doc: HTMLDocument) throws -> URL { guard let galleryURLString = doc.at_xpath("//div [@class='sb']")?.at_xpath("//a")?["href"], let galleryURL = URL(string: galleryURLString) else { throw AppError.parseFailed } return galleryURL } // swiftlint:disable:next function_body_length - static func parseGalleryDetail(doc: HTMLDocument, gid: String) throws -> (GalleryDetail, GalleryState) { + public static func parseGalleryDetail(doc: HTMLDocument, gid: String) throws -> (GalleryDetail, GalleryState) { var tmpGalleryDetail: GalleryDetail? var tmpGalleryState: GalleryState? for link in doc.xpath("//div [@class='gm']") { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Favorite.swift b/AppPackage/Sources/Parser/Parser+Favorite.swift similarity index 85% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+Favorite.swift rename to AppPackage/Sources/Parser/Parser+Favorite.swift index f2b1eafd3..108e01365 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Favorite.swift +++ b/AppPackage/Sources/Parser/Parser+Favorite.swift @@ -2,7 +2,7 @@ import Kanna import AppModels extension Parser { - static func parseFavoritesSortOrder(doc: HTMLDocument) -> FavoritesSortOrder? { + public static func parseFavoritesSortOrder(doc: HTMLDocument) -> FavoritesSortOrder? { guard let idoNode = doc.at_xpath("//div [@class='ido']") else { return nil } for link in idoNode.xpath("//div") where link.className == nil { guard let aText = link.at_xpath("//div")?.at_xpath("//a")?.text else { continue } @@ -15,7 +15,7 @@ extension Parser { return nil } - static func parseFavoriteCategories(doc: HTMLDocument) throws -> [Int: String] { + public static func parseFavoriteCategories(doc: HTMLDocument) throws -> [Int: String] { var favoriteCategories = [Int: String]() for link in doc.xpath("//div [@id='favsel']") { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Greeting.swift b/AppPackage/Sources/Parser/Parser+Greeting.swift similarity index 97% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+Greeting.swift rename to AppPackage/Sources/Parser/Parser+Greeting.swift index 61c67dec5..4d31ab81d 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Greeting.swift +++ b/AppPackage/Sources/Parser/Parser+Greeting.swift @@ -4,7 +4,7 @@ import Foundation extension Parser { // swiftlint:disable:next cyclomatic_complexity - static func parseGreeting(doc: HTMLDocument) throws -> Greeting { + public static func parseGreeting(doc: HTMLDocument) throws -> Greeting { guard let node = doc.at_xpath("//div [@id='eventpane']") else { throw AppError.parseFailed } diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Image.swift b/AppPackage/Sources/Parser/Parser+Image.swift similarity index 90% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+Image.swift rename to AppPackage/Sources/Parser/Parser+Image.swift index 1f92ccb59..37f27fe11 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Image.swift +++ b/AppPackage/Sources/Parser/Parser+Image.swift @@ -4,7 +4,7 @@ import Foundation extension Parser { // MARK: ImageURL - static func parseThumbnailURLs(doc: HTMLDocument) throws -> [Int: URL] { + public static func parseThumbnailURLs(doc: HTMLDocument) throws -> [Int: URL] { var thumbnailURLs = [Int: URL]() guard let gdtNode = doc.at_xpath("//div [@id='gdt']") @@ -24,7 +24,7 @@ extension Parser { return thumbnailURLs } - static func parseGalleryNormalImageURL(doc: HTMLDocument, index: Int) throws -> GalleryNormalImageInfo { + public static func parseGalleryNormalImageURL(doc: HTMLDocument, index: Int) throws -> GalleryNormalImageInfo { guard let i3Node = doc.at_xpath("//div [@id='i3']"), let imageURLString = i3Node.at_css("img")?["src"], let imageURL = URL(string: imageURLString) @@ -48,7 +48,7 @@ extension Parser { ) } - static func parseMPVKeys(doc: HTMLDocument) throws -> (String, [Int: String]) { + public static func parseMPVKeys(doc: HTMLDocument) throws -> (String, [Int: String]) { var tmpMPVKey: String? var imgKeys = [Int: String]() diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+List.swift b/AppPackage/Sources/Parser/Parser+List.swift similarity index 99% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+List.swift rename to AppPackage/Sources/Parser/Parser+List.swift index b529d5933..d5e60064d 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+List.swift +++ b/AppPackage/Sources/Parser/Parser+List.swift @@ -3,7 +3,7 @@ import AppModels import SwiftUI extension Parser { - static func parseGalleries(doc: HTMLDocument) throws -> [Gallery] { + public static func parseGalleries(doc: HTMLDocument) throws -> [Gallery] { let galleries: [Gallery] switch try? parseDisplayMode(doc: doc) { case "Minimal": diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Misc.swift b/AppPackage/Sources/Parser/Parser+Misc.swift similarity index 91% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+Misc.swift rename to AppPackage/Sources/Parser/Parser+Misc.swift index 30686cf3c..a41c79636 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Misc.swift +++ b/AppPackage/Sources/Parser/Parser+Misc.swift @@ -3,14 +3,14 @@ import AppModels import Foundation extension Parser { - static func parseSkipServerIdentifier(doc: HTMLDocument) throws -> String { + public static func parseSkipServerIdentifier(doc: HTMLDocument) throws -> String { guard let text = doc.at_xpath("//div [@id='i6']")?.at_xpath("//a [@id='loadfail']")?["onclick"], let rangeA = text.range(of: "nl('"), let rangeB = text.range(of: "')") else { throw AppError.parseFailed } return .init(text[rangeA.upperBound.. String { + public static func parseAPIKey(doc: HTMLDocument) throws -> String { var tmpKey: String? for link in doc.xpath("//script [@type='text/javascript']") { @@ -29,7 +29,7 @@ extension Parser { } /// Parses the gallery-list pager. - static func parsePageNum(doc: HTMLDocument) -> PageNumber { + public static func parsePageNum(doc: HTMLDocument) -> PageNumber { var current = 0 var maximum = 0 diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift b/AppPackage/Sources/Parser/Parser+Preview.swift similarity index 95% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift rename to AppPackage/Sources/Parser/Parser+Preview.swift index a18a1cb4d..7ff3440b5 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Preview.swift +++ b/AppPackage/Sources/Parser/Parser+Preview.swift @@ -4,7 +4,7 @@ import Foundation import Utilities extension Parser { - static func parsePreviewURLs(doc: HTMLDocument) throws -> [Int: URL] { + public static func parsePreviewURLs(doc: HTMLDocument) throws -> [Int: URL] { guard let gdtNode = doc.at_xpath("//div [@id='gdt']") else { throw AppError.parseFailed } @@ -12,7 +12,7 @@ extension Parser { return combinedURLs.isEmpty ? parseStandalonePreviewURLs(node: gdtNode) : combinedURLs } - static func parsePreviewConfigs(url: URL) -> PreviewConfigInfo? { + public static func parsePreviewConfigs(url: URL) -> PreviewConfigInfo? { guard var components = URLComponents( url: url, resolvingAgainstBaseURL: false ), diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Profile.swift b/AppPackage/Sources/Parser/Parser+Profile.swift similarity index 98% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+Profile.swift rename to AppPackage/Sources/Parser/Parser+Profile.swift index baf15d10e..d7c6ef412 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Profile.swift +++ b/AppPackage/Sources/Parser/Parser+Profile.swift @@ -2,7 +2,7 @@ import Kanna import AppModels extension Parser { - static func parseProfileIndex(doc: HTMLDocument) throws -> VerifyEhProfileResponse { + public static func parseProfileIndex(doc: HTMLDocument) throws -> VerifyEhProfileResponse { var profileNotFound = true var profileValue: Int? @@ -21,7 +21,7 @@ extension Parser { } // swiftlint:disable:next cyclomatic_complexity function_body_length - static func parseEhSetting(doc: HTMLDocument) throws -> EhSetting { + public static func parseEhSetting(doc: HTMLDocument) throws -> EhSetting { var tmpForm: XMLElement? for link in doc.xpath("//form [@method='post']") where link["id"] == nil { diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift b/AppPackage/Sources/Parser/Parser+ResponseError.swift similarity index 97% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift rename to AppPackage/Sources/Parser/Parser+ResponseError.swift index 2bf81ff85..1b35974eb 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+ResponseError.swift +++ b/AppPackage/Sources/Parser/Parser+ResponseError.swift @@ -3,7 +3,7 @@ import AppModels import Resources extension Parser { - static func parseResponseError(doc: HTMLDocument) -> AppError? { + public static func parseResponseError(doc: HTMLDocument) -> AppError? { if let banInterval = parseBanInterval(doc: doc) { return .ipBanned(banInterval) } @@ -21,7 +21,7 @@ extension Parser { return nil } - static func parseResponseError(content: String) -> AppError? { + public static func parseResponseError(content: String) -> AppError? { let normalizedContent = content.lowercased() guard !normalizedContent.isEmpty else { return nil } diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Shared.swift b/AppPackage/Sources/Parser/Parser+Shared.swift similarity index 97% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+Shared.swift rename to AppPackage/Sources/Parser/Parser+Shared.swift index 53cbfa642..fbd628078 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Shared.swift +++ b/AppPackage/Sources/Parser/Parser+Shared.swift @@ -1,6 +1,7 @@ import Kanna import AppModels import Foundation +import Utilities extension Parser { static func parseGTX00IndexFromTitle(from title: String) -> Int? { @@ -87,7 +88,9 @@ extension Parser { return try? parseDate(time: value, format: "yyyy-MM-dd") } - static func parseDateSeekNavigation(doc: HTMLDocument, host: URL = Defaults.URL.host) -> DateSeekNavigation? { + public static func parseDateSeekNavigation( + doc: HTMLDocument, host: URL = Defaults.URL.host + ) -> DateSeekNavigation? { guard let minimumDate = parseScriptDate(name: "mindate", doc: doc), let maximumDate = parseScriptDate(name: "maxdate", doc: doc), let directions = DateSeekNavigation.Directions( @@ -146,7 +149,7 @@ extension Parser { } // swiftlint:enable cyclomatic_complexity - static func parseBanInterval(doc: HTMLDocument) -> BanInterval? { + public static func parseBanInterval(doc: HTMLDocument) -> BanInterval? { guard let text = doc.body?.text, let range = text.range(of: "The ban expires in ") else { return nil } diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Torrent.swift b/AppPackage/Sources/Parser/Parser+Torrent.swift similarity index 97% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+Torrent.swift rename to AppPackage/Sources/Parser/Parser+Torrent.swift index 46998a83f..2f7592fa1 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+Torrent.swift +++ b/AppPackage/Sources/Parser/Parser+Torrent.swift @@ -4,7 +4,7 @@ import Foundation extension Parser { // swiftlint:disable:next cyclomatic_complexity function_body_length - static func parseGalleryTorrents(doc: HTMLDocument) -> [GalleryTorrent] { + public static func parseGalleryTorrents(doc: HTMLDocument) -> [GalleryTorrent] { var torrents = [GalleryTorrent]() for link in doc.xpath("//form") { diff --git a/AppPackage/Sources/Parser/Parser+Types.swift b/AppPackage/Sources/Parser/Parser+Types.swift new file mode 100644 index 000000000..061ce2b86 --- /dev/null +++ b/AppPackage/Sources/Parser/Parser+Types.swift @@ -0,0 +1,43 @@ +import Foundation +import AppModels + +extension Parser { + public struct ThumbnailPanelInfo { + public let coverURL: URL + public let category: AppModels.Category + public let rating: Float + public let publishedDate: Date + public let pageCount: Int + public let uploader: String? + } + + public struct GalleryNormalImageInfo { + public let index: Int + public let imageURL: URL + public let originalImageURL: URL? + } + + public struct RatingResult { + public let imgRating: Float + public let textRating: Float? + public let containsUserRating: Bool + } + + public struct PreviewConfigInfo { + public let plainURL: URL + public let size: CGSize + public let offset: CGSize + } + + public struct SelectionOption { + public let name: String + public let value: String + public let isSelected: Bool + } + + public struct ThumbnailSizeOption { + public let value: Int + public let isEnabled: Bool + public let isSelected: Bool + } +} diff --git a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+User.swift b/AppPackage/Sources/Parser/Parser+User.swift similarity index 91% rename from AppPackage/Sources/AppFeature/Tools/Parser/Parser+User.swift rename to AppPackage/Sources/Parser/Parser+User.swift index 69d323009..5322805e9 100644 --- a/AppPackage/Sources/AppFeature/Tools/Parser/Parser+User.swift +++ b/AppPackage/Sources/Parser/Parser+User.swift @@ -3,7 +3,7 @@ import AppModels import Foundation extension Parser { - static func parseUserInfo(doc: HTMLDocument) throws -> User { + public static func parseUserInfo(doc: HTMLDocument) throws -> User { var displayName: String? var avatarURL: URL? @@ -29,7 +29,7 @@ extension Parser { } } - static func parseCurrentFunds(doc: HTMLDocument) throws -> (String, String) { + public static func parseCurrentFunds(doc: HTMLDocument) throws -> (String, String) { var tmpGP: String? var tmpCredits: String? diff --git a/AppPackage/Sources/Parser/Parser.swift b/AppPackage/Sources/Parser/Parser.swift new file mode 100644 index 000000000..18fdf0f17 --- /dev/null +++ b/AppPackage/Sources/Parser/Parser.swift @@ -0,0 +1 @@ +public enum Parser {} diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift index d5c47bf97..86e39b694 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift @@ -1,5 +1,6 @@ import Kanna import Testing +import Parser @testable import AppFeature struct GalleryDetailParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift index f48867ec7..97aa75fc0 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift @@ -1,5 +1,6 @@ import Kanna import Testing +import Parser @testable import AppFeature struct GalleryImageURLParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift index 4acb4ced7..36d3944a8 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift @@ -1,5 +1,6 @@ import Kanna import Testing +import Parser @testable import AppFeature struct GalleryMPVKeysParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift index 56f902805..b46630580 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import Kanna import Testing +import Parser @testable import AppFeature struct ListParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/BanIntervalParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/BanIntervalParserTests.swift index 438eb88b4..3c0f462a8 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/BanIntervalParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/BanIntervalParserTests.swift @@ -1,5 +1,6 @@ import Kanna import Testing +import Parser @testable import AppFeature struct BanIntervalParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift index b5b47bd6b..c5fb802a6 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift @@ -2,6 +2,7 @@ import Kanna import AppModels import Combine import Testing +import Parser @testable import AppFeature struct DownloadPageErrorParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift index fdc69d27f..20843c16b 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift @@ -1,6 +1,7 @@ import Kanna import AppModels import Testing +import Parser @testable import AppFeature struct EhSettingParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/GreetingParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/GreetingParserTests.swift index 3cf64d05b..621426924 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/GreetingParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/GreetingParserTests.swift @@ -1,5 +1,6 @@ import Kanna import Testing +import Parser @testable import AppFeature struct GreetingParserTests: TestHelper { From bbdbde2f00340b3cc3a8ae2787aaa6d4801bb180 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 19:28:25 +0800 Subject: [PATCH 317/614] Extract Networking module and DFClient Move the request layer (Request+*, the Request protocol, DF domain-fronting URL protocol/stream/resolver) into a Networking module, and DFClient into its own module depending on it. The module is named Networking rather than Network to avoid colliding with Apple's system Network framework (which produced a module dependency cycle). Add the public memberwise inits and Sendable conformances the request and response value types need across the module boundary. --- AppPackage/Package.swift | 30 +++ .../AppFeature/DataFlow/AppRouteReducer.swift | 1 + .../DownloadClient+ExecutionFetch.swift | 1 + .../DownloadClient+ExecutionSupport.swift | 1 + .../Detail/Archives/ArchivesReducer.swift | 1 + .../Detail/Comments/CommentsReducer.swift | 1 + .../View/Detail/DetailReducer+Fetch.swift | 1 + .../View/Detail/DetailReducer.swift | 1 + .../DetailSearch/DetailSearchReducer.swift | 1 + .../Detail/Previews/PreviewsReducer.swift | 1 + .../Detail/Torrents/TorrentsReducer.swift | 1 + .../View/Favorites/FavoritesReducer.swift | 1 + .../Home/Frontpage/FrontpageReducer.swift | 1 + .../View/Home/HomeReducer+Body.swift | 1 + .../View/Home/Popular/PopularReducer.swift | 1 + .../View/Home/Toplists/ToplistsReducer.swift | 1 + .../View/Home/Watched/WatchedReducer.swift | 1 + .../Reading/ReadingReducer+ImageFetch.swift | 1 + .../View/Reading/ReadingReducer.swift | 1 + .../View/Search/SearchReducer.swift | 1 + .../Setting/EhSetting/EhSettingReducer.swift | 1 + .../View/Setting/Login/LoginReducer.swift | 1 + .../View/Setting/SettingReducer+Body.swift | 1 + .../View/Setting/SettingReducer+Helpers.swift | 1 + .../View/Setting/SettingReducer.swift | 1 + AppPackage/Sources/DFClient/.swiftlint.yml | 1 + .../Tools/Clients => DFClient}/DFClient.swift | 23 +- AppPackage/Sources/Networking/.swiftlint.yml | 1 + .../Network => Networking}/DFExtensions.swift | 36 +-- .../Network => Networking}/DFRequest.swift | 12 +- .../DFStreamHandler.swift | 6 +- .../DFURLProtocol.swift | 22 +- .../DomainResolver.swift | 6 +- .../Request+Account.swift | 235 +++++++++++++----- .../Request+Detail.swift | 137 +++++++--- .../Request+Gallery.swift | 188 ++++++++++---- .../Request+Image.swift | 190 ++++++++++---- .../Network => Networking}/Request.swift | 119 ++++++--- .../Download/DetailReducerMetadataTests.swift | 1 + .../Download/DownloadAutomationTests.swift | 1 + .../Other/DownloadPageErrorParserTests.swift | 1 + 41 files changed, 739 insertions(+), 294 deletions(-) create mode 100644 AppPackage/Sources/DFClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => DFClient}/DFClient.swift (62%) create mode 100644 AppPackage/Sources/Networking/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Network => Networking}/DFExtensions.swift (87%) rename AppPackage/Sources/{AppFeature/Network => Networking}/DFRequest.swift (90%) rename AppPackage/Sources/{AppFeature/Network => Networking}/DFStreamHandler.swift (97%) rename AppPackage/Sources/{AppFeature/Network => Networking}/DFURLProtocol.swift (72%) rename AppPackage/Sources/{AppFeature/Network => Networking}/DomainResolver.swift (94%) rename AppPackage/Sources/{AppFeature/Network => Networking}/Request+Account.swift (69%) rename AppPackage/Sources/{AppFeature/Network => Networking}/Request+Detail.swift (70%) rename AppPackage/Sources/{AppFeature/Network => Networking}/Request+Gallery.swift (64%) rename AppPackage/Sources/{AppFeature/Network => Networking}/Request+Image.swift (62%) rename AppPackage/Sources/{AppFeature/Network => Networking}/Request.swift (73%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 23f654457..a3e12a8ac 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -70,12 +70,14 @@ enum Module: String { case appModels = "AppModels" case authorizationClient = "AuthorizationClient" case composableArchitectureExt = "ComposableArchitectureExt" + case dfClient = "DFClient" case databaseClient = "DatabaseClient" case foundationExt = "FoundationExt" case hapticsClient = "HapticsClient" case imageClient = "ImageClient" case libraryClient = "LibraryClient" case loggerClient = "LoggerClient" + case networking = "Networking" case parser = "Parser" case resources = "Resources" case sdWebImageExt = "SDWebImageExt" @@ -224,11 +226,13 @@ let targets: [PackageDescription.Target] = [ .module(.authorizationClient), .module(.composableArchitectureExt), .module(.databaseClient), + .module(.dfClient), .module(.foundationExt), .module(.hapticsClient), .module(.imageClient), .module(.libraryClient), .module(.loggerClient), + .module(.networking), .module(.parser), .module(.resources), .module(.sdWebImageExt), @@ -315,6 +319,30 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .dfClient, + dependencies: [ + .module(.networking), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + .target( + module: .networking, + dependencies: [ + .module(.appModels), + .module(.foundationExt), + .module(.parser), + .module(.utilities), + .targetDependency(.composableArchitecture), + .targetDependency(.deprecatedAPI), + .targetDependency(.kanna) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .databaseClient, dependencies: [ @@ -430,11 +458,13 @@ let targets: [PackageDescription.Target] = [ .module(.appFeature), .module(.appModels), .module(.databaseClient), + .module(.dfClient), .module(.foundationExt), .module(.hapticsClient), .module(.imageClient), .module(.libraryClient), .module(.loggerClient), + .module(.networking), .module(.parser), .module(.sdWebImageExt), .module(.uiApplicationClient), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index fc46eb616..a2e0efe5d 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -6,6 +6,7 @@ import URLClient import UserDefaultsClient import HapticsClient import DatabaseClient +import Networking @Reducer struct AppRouteReducer { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift index 13cf8216e..1ec9a2add 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import Networking // MARK: - Fetch & Normalize Payload extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift index b3667c118..6a84db2c3 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import URLClient +import Networking // MARK: - Execution Support extension DownloadCoordinator { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift index 8101bce9d..8f2d1dd35 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import FoundationExt import HapticsClient import DatabaseClient +import Networking @Reducer struct ArchivesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift index 4cc1fbacf..e2b49b1a2 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift @@ -6,6 +6,7 @@ import URLClient import UIApplicationClient import HapticsClient import DatabaseClient +import Networking @Reducer struct CommentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Fetch.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Fetch.swift index 8c13d935a..89e3e2c39 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Fetch.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Fetch.swift @@ -1,5 +1,6 @@ import Foundation import ComposableArchitecture +import Networking // MARK: - Fetch & Gallery Ops Action Handlers extension DetailReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift index 7fc7d3c31..6259d649e 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift @@ -6,6 +6,7 @@ import ComposableArchitectureExt import SwiftUINavigationExt import HapticsClient import DatabaseClient +import Networking @Reducer struct DetailReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift index bd08a1292..c1d636a26 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -3,6 +3,7 @@ import AppModels import SwiftUINavigationExt import HapticsClient import DatabaseClient +import Networking @Reducer struct DetailSearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift index 14c99f816..08fc61176 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift @@ -5,6 +5,7 @@ import FoundationExt import SwiftUINavigationExt import HapticsClient import DatabaseClient +import Networking @Reducer struct PreviewsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift index 9a2a0e504..b1790a839 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import SwiftUINavigationExt import HapticsClient +import Networking @Reducer struct TorrentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift index 2cfa36610..46ec557d8 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import HapticsClient import DatabaseClient +import Networking @Reducer struct FavoritesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift index 659d3a5a8..7379e6e79 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift @@ -5,6 +5,7 @@ import FoundationExt import SwiftUINavigationExt import HapticsClient import DatabaseClient +import Networking @Reducer struct FrontpageReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift b/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift index 4937cd152..2e6a891ec 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift @@ -1,6 +1,7 @@ import SwiftUI import Kingfisher import ComposableArchitecture +import Networking extension HomeReducer { @ReducerBuilder diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift index 9c8b99c86..29aa7159a 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift @@ -4,6 +4,7 @@ import FoundationExt import SwiftUINavigationExt import HapticsClient import DatabaseClient +import Networking @Reducer struct PopularReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift index 3ffe6ee51..72726b4a4 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift @@ -3,6 +3,7 @@ import AppModels import FoundationExt import HapticsClient import DatabaseClient +import Networking @Reducer struct ToplistsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift index c48e26e93..b60b0370d 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift @@ -3,6 +3,7 @@ import AppModels import SwiftUINavigationExt import HapticsClient import DatabaseClient +import Networking @Reducer struct WatchedReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+ImageFetch.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+ImageFetch.swift index 4848c6ced..39ebc9db0 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+ImageFetch.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+ImageFetch.swift @@ -1,6 +1,7 @@ import Foundation import ComposableArchitecture import FoundationExt +import Networking // MARK: - Image URL Fetch Actions extension ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift index 5a22317c0..021aebbf1 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift @@ -5,6 +5,7 @@ import URLClient import HapticsClient import ImageClient import DatabaseClient +import Networking @Reducer struct ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift index 0e7eec572..682e33833 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift @@ -4,6 +4,7 @@ import Foundation import SwiftUINavigationExt import HapticsClient import DatabaseClient +import Networking @Reducer struct SearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift index fe46a97b2..0067808e5 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import SwiftUINavigationExt import UIApplicationClient import HapticsClient +import Networking @Reducer struct EhSettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift index fbfbadc59..9cd244cb0 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import SwiftUINavigationExt import HapticsClient +import Networking @Reducer struct LoginReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Body.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Body.swift index b9f4c8365..7609e2062 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Body.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Body.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import ComposableArchitecture +import Networking extension SettingReducer { @ReducerBuilder diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Helpers.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Helpers.swift index 56af78d33..2f9e4efa0 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Helpers.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Helpers.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import ComposableArchitecture +import Networking extension SettingReducer { func handleLoadUserSettings( diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift index 31c85f429..7ce247b69 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift @@ -7,6 +7,7 @@ import UIApplicationClient import HapticsClient import LibraryClient import DatabaseClient +import DFClient @Reducer struct SettingReducer { diff --git a/AppPackage/Sources/DFClient/.swiftlint.yml b/AppPackage/Sources/DFClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DFClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DFClient.swift b/AppPackage/Sources/DFClient/DFClient.swift similarity index 62% rename from AppPackage/Sources/AppFeature/Tools/Clients/DFClient.swift rename to AppPackage/Sources/DFClient/DFClient.swift index e8f363586..d56f16d9b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DFClient.swift +++ b/AppPackage/Sources/DFClient/DFClient.swift @@ -1,13 +1,14 @@ import Foundation import Kingfisher import ComposableArchitecture +import Networking -struct DFClient: Sendable { - let setActive: @Sendable (Bool) -> Void +public struct DFClient: Sendable { + public let setActive: @Sendable (Bool) -> Void } extension DFClient { - static let live: Self = .init( + public static let live: Self = .init( setActive: { newValue in if newValue { URLProtocol.registerClass(DFURLProtocol.self) @@ -23,14 +24,14 @@ extension DFClient { } // MARK: API -enum DFClientKey: DependencyKey { - static let liveValue = DFClient.live - static let previewValue = DFClient.noop - static let testValue = DFClient.unimplemented +public enum DFClientKey: DependencyKey { + public static let liveValue = DFClient.live + public static let previewValue = DFClient.noop + public static let testValue = DFClient.unimplemented } extension DependencyValues { - var dfClient: DFClient { + public var dfClient: DFClient { get { self[DFClientKey.self] } set { self[DFClientKey.self] = newValue } } @@ -38,13 +39,13 @@ extension DependencyValues { // MARK: Test extension DFClient { - static let noop: Self = .init( + public static let noop: Self = .init( setActive: { _ in } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( setActive: IssueReporting.unimplemented(placeholder: placeholder()) ) } diff --git a/AppPackage/Sources/Networking/.swiftlint.yml b/AppPackage/Sources/Networking/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/Networking/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Network/DFExtensions.swift b/AppPackage/Sources/Networking/DFExtensions.swift similarity index 87% rename from AppPackage/Sources/AppFeature/Network/DFExtensions.swift rename to AppPackage/Sources/Networking/DFExtensions.swift index ff14fa4c0..7136e26e9 100644 --- a/AppPackage/Sources/AppFeature/Network/DFExtensions.swift +++ b/AppPackage/Sources/Networking/DFExtensions.swift @@ -19,7 +19,7 @@ private func forceDowncast(object: Any) -> T! { // MARK: URLRequest extension URLRequest { - var urlContainsImageURL: Bool { + public var urlContainsImageURL: Bool { var containsTarget = false ["jpg", "jpeg", "png", "gif", "bmp"].forEach { type in if url?.absoluteString.contains(type) == true { @@ -32,7 +32,7 @@ extension URLRequest { // MARK: URLSessionConfiguration extension URLSessionConfiguration { - static var domainFronting: URLSessionConfiguration { + public static var domainFronting: URLSessionConfiguration { let config = URLSessionConfiguration.default config.protocolClasses = [DFURLProtocol.self] return config @@ -41,18 +41,18 @@ extension URLSessionConfiguration { // MARK: CFHTTPMessage extension CFHTTPMessage { - var isCompleted: Bool { + public var isCompleted: Bool { CFHTTPMessageIsHeaderComplete(self) } - var url: URL? { + public var url: URL? { CFHTTPMessageCopyRequestURL(self)?.autorelease() .takeUnretainedValue() as URL? } - var allHeaderFields: [String: String] { + public var allHeaderFields: [String: String] { CFHTTPMessageCopyAllHeaderFields(self)?.autorelease() .takeUnretainedValue() as? [String: String] ?? [String: String]() } - func httpResponse() -> HTTPURLResponse? { + public func httpResponse() -> HTTPURLResponse? { guard let url = url as URL? else { return nil } let version = CFHTTPMessageCopyVersion(self) .autorelease().takeUnretainedValue() as String @@ -69,12 +69,12 @@ extension CFHTTPMessage { // MARK: URLRequest extension URLRequest { - var isHTTPS: Bool { url?.scheme == "https" } - var hasHostField: Bool { hostKey?.count ?? 0 > 0 } - var hostKey: Dictionary.Keys.Element? { + public var isHTTPS: Bool { url?.scheme == "https" } + public var hasHostField: Bool { hostKey?.count ?? 0 > 0 } + public var hostKey: Dictionary.Keys.Element? { allHTTPHeaderFields?.keys.first(where: { $0.lowercased() == "host" }) } - var domain: String? { + public var domain: String? { var domain: String? = url?.host if let allFields = allHTTPHeaderFields, let hostKey = hostKey { @@ -83,14 +83,14 @@ extension URLRequest { return domain } - var domainWithScheme: String? { + public var domainWithScheme: String? { if let scheme = url?.scheme, let domain = domain { return scheme + "://" + domain } else { return nil } } - func domainIPReplaced() -> URLRequest { + public func domainIPReplaced() -> URLRequest { var request: URLRequest = self guard let domain = domain, @@ -108,7 +108,7 @@ extension URLRequest { } return request } - func HTTPBody() -> Data? { + public func HTTPBody() -> Data? { if httpMethod != "POST" || httpBody != nil { return httpBody } @@ -148,18 +148,18 @@ extension URLRequest { // MARK: InputStream extension InputStream { - enum CreateStreamError: Error { + public enum CreateStreamError: Error { case methodNotFound(msg: String) case urlNotFound(msg: String) case createStream(msg: String) } - var trust: SecTrust? { + public var trust: SecTrust? { let key = Stream.PropertyKey(kCFStreamPropertySSLPeerTrust as String) guard let value = property(forKey: key) else { return nil } return forceDowncast(object: value) as SecTrust } - func invalidatesCertChain(for host: String) { + public func invalidatesCertChain(for host: String) { guard host.count > 0 else { return } let settings: [AnyHashable: Any] = [ kCFStreamSSLValidatesCertificateChain: kCFBooleanFalse as Any @@ -168,7 +168,7 @@ extension InputStream { let key = kCFStreamPropertySSLSettings as String setProperty(settings, forKey: Stream.PropertyKey(key)) } - func httpMessage() -> CFHTTPMessage? { + public func httpMessage() -> CFHTTPMessage? { let stream = self as CFReadStream let key = "kCFStreamPropertyHTTPResponseHeader" as CFString @@ -179,7 +179,7 @@ extension InputStream { return forceDowncast(object: value) as CFHTTPMessage } - static func create(from request: URLRequest) -> Result { + public static func create(from request: URLRequest) -> Result { guard let method = request.httpMethod as CFString? else { return .failure(.methodNotFound( msg: "HTTPMethod not found: \(request.httpMethod ?? "nil")." diff --git a/AppPackage/Sources/AppFeature/Network/DFRequest.swift b/AppPackage/Sources/Networking/DFRequest.swift similarity index 90% rename from AppPackage/Sources/AppFeature/Network/DFRequest.swift rename to AppPackage/Sources/Networking/DFRequest.swift index 58e06506f..4bbada11e 100644 --- a/AppPackage/Sources/AppFeature/Network/DFRequest.swift +++ b/AppPackage/Sources/Networking/DFRequest.swift @@ -1,14 +1,14 @@ import Foundation import AppModels -struct DFRequest { - var request: URLRequest +public struct DFRequest { + public var request: URLRequest private let stream: InputStream private(set) weak var delegate: DFRequestDelegate? private lazy var streamHandler: DFStreamEventHandler? = DFStreamEventHandler(request: self) - init?( + public init?( _ req: URLRequest, delegate: DFRequestDelegate? = nil ) { @@ -37,7 +37,7 @@ struct DFRequest { } } - mutating func resume() { + public mutating func resume() { if !request.urlContainsImageURL { Logger.verbose("Request from: \(request.url?.absoluteString ?? "")") } @@ -47,7 +47,7 @@ struct DFRequest { stream.open() } - mutating func stop() { + public mutating func stop() { stream.delegate = nil streamHandler = nil stream.close() @@ -56,7 +56,7 @@ struct DFRequest { } // MARK: DFRequestDelegate -protocol DFRequestDelegate: AnyObject { +public protocol DFRequestDelegate: AnyObject { func dfRequestDidFinishLoading(_ request: DFRequest) func dfRequest(_ request: DFRequest, didLoad data: Data) func dfRequest(_ request: URLRequest, didFailWithError error: Error) diff --git a/AppPackage/Sources/AppFeature/Network/DFStreamHandler.swift b/AppPackage/Sources/Networking/DFStreamHandler.swift similarity index 97% rename from AppPackage/Sources/AppFeature/Network/DFStreamHandler.swift rename to AppPackage/Sources/Networking/DFStreamHandler.swift index 6dce06fb2..0607c3a67 100644 --- a/AppPackage/Sources/AppFeature/Network/DFStreamHandler.swift +++ b/AppPackage/Sources/Networking/DFStreamHandler.swift @@ -1,12 +1,12 @@ import Foundation import AppModels -class DFStreamEventHandler: NSObject { +public class DFStreamEventHandler: NSObject { private var request: DFRequest private var receivedResponse = false private var hasEvaluated = false - init(request: DFRequest) { + public init(request: DFRequest) { self.request = request } } @@ -85,7 +85,7 @@ private extension DFStreamEventHandler { // MARK: StreamDelegate extension DFStreamEventHandler: StreamDelegate { - func stream(_ aStream: Stream, handle eventCode: Stream.Event) { + public func stream(_ aStream: Stream, handle eventCode: Stream.Event) { guard let input = aStream as? InputStream else { Logger.error("Unexpected stream, should be a InputStream, but \(aStream).") return diff --git a/AppPackage/Sources/AppFeature/Network/DFURLProtocol.swift b/AppPackage/Sources/Networking/DFURLProtocol.swift similarity index 72% rename from AppPackage/Sources/AppFeature/Network/DFURLProtocol.swift rename to AppPackage/Sources/Networking/DFURLProtocol.swift index b349f60e4..035d2e4aa 100644 --- a/AppPackage/Sources/AppFeature/Network/DFURLProtocol.swift +++ b/AppPackage/Sources/Networking/DFURLProtocol.swift @@ -1,13 +1,13 @@ import Foundation import AppModels -class DFURLProtocol: URLProtocol { +public class DFURLProtocol: URLProtocol { private var dfRequest: DFRequest? - static let requestIdentifier = "DomainFrontingRequest" + public static let requestIdentifier = "DomainFrontingRequest" - override class func canonicalRequest( + public override class func canonicalRequest( for request: URLRequest) -> URLRequest { request } - override class func canInit(with request: URLRequest) -> Bool { + public override class func canInit(with request: URLRequest) -> Bool { if property(forKey: requestIdentifier, in: request) != nil { Logger.error("URLRequest has been initialized.") return false @@ -20,7 +20,7 @@ class DFURLProtocol: URLProtocol { return true } - override func startLoading() { + public override func startLoading() { dfRequest = DFRequest(request, delegate: self) let request = request as? NSMutableURLRequest DFURLProtocol.setProperty( @@ -31,7 +31,7 @@ class DFURLProtocol: URLProtocol { dfRequest?.resume() } - override func stopLoading() { + public override func stopLoading() { dfRequest?.stop() dfRequest = nil } @@ -39,22 +39,22 @@ class DFURLProtocol: URLProtocol { // MARK: DFRequestDelegate extension DFURLProtocol: DFRequestDelegate { - func dfRequestDidFinishLoading(_ request: DFRequest) { + public func dfRequestDidFinishLoading(_ request: DFRequest) { client?.urlProtocolDidFinishLoading(self) } - func dfRequest(_ request: DFRequest, didLoad data: Data) { + public func dfRequest(_ request: DFRequest, didLoad data: Data) { client?.urlProtocol(self, didLoad: data) } - func dfRequest(_ request: URLRequest, didFailWithError error: Error) { + public func dfRequest(_ request: URLRequest, didFailWithError error: Error) { client?.urlProtocol(self, didFailWithError: error) } - func dfRequest( + public func dfRequest( _ request: DFRequest, wasRedirectedTo urlRequest: URLRequest, redirectResponse: URLResponse ) { client?.urlProtocol(self, wasRedirectedTo: urlRequest, redirectResponse: redirectResponse) } - func dfRequest( + public func dfRequest( _ request: DFRequest, didReceive response: URLResponse, cacheStoragePolicy policy: URLCache.StoragePolicy ) { diff --git a/AppPackage/Sources/AppFeature/Network/DomainResolver.swift b/AppPackage/Sources/Networking/DomainResolver.swift similarity index 94% rename from AppPackage/Sources/AppFeature/Network/DomainResolver.swift rename to AppPackage/Sources/Networking/DomainResolver.swift index 073c80ecf..ca80d891c 100644 --- a/AppPackage/Sources/AppFeature/Network/DomainResolver.swift +++ b/AppPackage/Sources/Networking/DomainResolver.swift @@ -1,10 +1,10 @@ -struct DomainResolver { - static func resolve(domain: String) -> String? { +public struct DomainResolver { + public static func resolve(domain: String) -> String? { ResolvableDomain(rawValue: domain)?.ipPool.randomElement() } } -enum ResolvableDomain: String { +public enum ResolvableDomain: String { case ehgt = "ehgt.org" case ehgt0 = "gt0.ehgt.org" case ehgt1 = "gt1.ehgt.org" diff --git a/AppPackage/Sources/AppFeature/Network/Request+Account.swift b/AppPackage/Sources/Networking/Request+Account.swift similarity index 69% rename from AppPackage/Sources/AppFeature/Network/Request+Account.swift rename to AppPackage/Sources/Networking/Request+Account.swift index 9bc8c52a0..235e04b42 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Account.swift +++ b/AppPackage/Sources/Networking/Request+Account.swift @@ -7,11 +7,18 @@ import Utilities import Parser // MARK: Account Ops -struct LoginRequest: Request { - let username: String - let password: String +public struct LoginRequest: Request { + public init( + username: String, + password: String + ) { + self.username = username + self.password = password + } + public let username: String + public let password: String - var publisher: AnyPublisher { + public var publisher: AnyPublisher { let params: [String: String] = [ "b": "d", "bt": "1-1", @@ -34,8 +41,10 @@ struct LoginRequest: Request { } } -struct IgneousRequest: Request { - var publisher: AnyPublisher { +public struct IgneousRequest: Request { + public init() {} + + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: Defaults.URL.exhentai) .genericRetry() .compactMap { $0.response as? HTTPURLResponse } @@ -44,8 +53,10 @@ struct IgneousRequest: Request { } } -struct VerifyEhProfileRequest: Request { - var publisher: AnyPublisher { +public struct VerifyEhProfileRequest: Request { + public init() {} + + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -55,12 +66,21 @@ struct VerifyEhProfileRequest: Request { } } -struct EhProfileRequest: Request { - var action: EhProfileAction? - var name: String? - var set: Int? +public struct EhProfileRequest: Request { + public init( + action: EhProfileAction? = nil, + name: String? = nil, + set: Int? = nil + ) { + self.action = action + self.name = name + self.set = set + } + public var action: EhProfileAction? + public var name: String? + public var set: Int? - var publisher: AnyPublisher { + public var publisher: AnyPublisher { var params = [String: String]() if let action = action { @@ -87,8 +107,10 @@ struct EhProfileRequest: Request { } } -struct EhSettingRequest: Request { - var publisher: AnyPublisher { +public struct EhSettingRequest: Request { + public init() {} + + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -98,10 +120,15 @@ struct EhSettingRequest: Request { } } -struct SubmitEhSettingChangesRequest: Request { - let ehSetting: EhSetting +public struct SubmitEhSettingChangesRequest: Request { + public init( + ehSetting: EhSetting + ) { + self.ehSetting = ehSetting + } + public let ehSetting: EhSetting - var publisher: AnyPublisher { + public var publisher: AnyPublisher { let url = Defaults.URL.uConfig var params: [String: String] = [ "uh": String(ehSetting.loadThroughHathSetting.rawValue), @@ -181,12 +208,21 @@ struct SubmitEhSettingChangesRequest: Request { } } -struct FavorGalleryRequest: Request { - let gid: String - let token: String - let favIndex: Int +public struct FavorGalleryRequest: Request { + public init( + gid: String, + token: String, + favIndex: Int + ) { + self.gid = gid + self.token = token + self.favIndex = favIndex + } + public let gid: String + public let token: String + public let favIndex: Int - var publisher: AnyPublisher { + public var publisher: AnyPublisher { let url = URLUtil.addFavorite(gid: gid, token: token) let params: [String: String] = [ "favcat": "\(favIndex)", @@ -208,10 +244,15 @@ struct FavorGalleryRequest: Request { } } -struct UnfavorGalleryRequest: Request { - let gid: String +public struct UnfavorGalleryRequest: Request { + public init( + gid: String + ) { + self.gid = gid + } + public let gid: String - var publisher: AnyPublisher { + public var publisher: AnyPublisher { let params: [String: String] = [ "ddact": "delete", "modifygids[]": gid, @@ -231,11 +272,18 @@ struct UnfavorGalleryRequest: Request { } } -struct SendDownloadCommandRequest: Request { - let archiveURL: URL - let resolution: String +public struct SendDownloadCommandRequest: Request { + public init( + archiveURL: URL, + resolution: String + ) { + self.archiveURL = archiveURL + self.resolution = resolution + } + public let archiveURL: URL + public let resolution: String - var publisher: AnyPublisher { + public var publisher: AnyPublisher { let params: [String: String] = [ "hathdl_xres": resolution ] @@ -256,14 +304,27 @@ struct SendDownloadCommandRequest: Request { } } -struct RateGalleryRequest: Request { - let apiuid: Int - let apikey: String - let gid: Int - let token: String - let rating: Int +public struct RateGalleryRequest: Request { + public init( + apiuid: Int, + apikey: String, + gid: Int, + token: String, + rating: Int + ) { + self.apiuid = apiuid + self.apikey = apikey + self.gid = gid + self.token = token + self.rating = rating + } + public let apiuid: Int + public let apikey: String + public let gid: Int + public let token: String + public let rating: Int - var publisher: AnyPublisher { + public var publisher: AnyPublisher { let params: [String: Any] = [ "method": "rategallery", "apiuid": apiuid, @@ -285,11 +346,18 @@ struct RateGalleryRequest: Request { } } -struct CommentGalleryRequest: Request { - let content: String - let galleryURL: URL +public struct CommentGalleryRequest: Request { + public init( + content: String, + galleryURL: URL + ) { + self.content = content + self.galleryURL = galleryURL + } + public let content: String + public let galleryURL: URL - var publisher: AnyPublisher { + public var publisher: AnyPublisher { let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") let params: [String: String] = [ "commenttext_new": fixedContent @@ -308,12 +376,21 @@ struct CommentGalleryRequest: Request { } } -struct EditGalleryCommentRequest: Request { - let commentID: String - let content: String - let galleryURL: URL +public struct EditGalleryCommentRequest: Request { + public init( + commentID: String, + content: String, + galleryURL: URL + ) { + self.commentID = commentID + self.content = content + self.galleryURL = galleryURL + } + public let commentID: String + public let content: String + public let galleryURL: URL - var publisher: AnyPublisher { + public var publisher: AnyPublisher { let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") let params: [String: String] = [ "edit_comment": commentID, @@ -333,15 +410,30 @@ struct EditGalleryCommentRequest: Request { } } -struct VoteGalleryCommentRequest: Request { - let apiuid: Int - let apikey: String - let gid: Int - let token: String - let commentID: Int - let commentVote: Int - - var publisher: AnyPublisher { +public struct VoteGalleryCommentRequest: Request { + public init( + apiuid: Int, + apikey: String, + gid: Int, + token: String, + commentID: Int, + commentVote: Int + ) { + self.apiuid = apiuid + self.apikey = apikey + self.gid = gid + self.token = token + self.commentID = commentID + self.commentVote = commentVote + } + public let apiuid: Int + public let apikey: String + public let gid: Int + public let token: String + public let commentID: Int + public let commentVote: Int + + public var publisher: AnyPublisher { let params: [String: Any] = [ "method": "votecomment", "apiuid": apiuid, @@ -364,15 +456,30 @@ struct VoteGalleryCommentRequest: Request { } } -struct VoteGalleryTagRequest: Request { - let apiuid: Int - let apikey: String - let gid: Int - let token: String - let tag: String - let vote: Int - - var publisher: AnyPublisher { +public struct VoteGalleryTagRequest: Request { + public init( + apiuid: Int, + apikey: String, + gid: Int, + token: String, + tag: String, + vote: Int + ) { + self.apiuid = apiuid + self.apikey = apikey + self.gid = gid + self.token = token + self.tag = tag + self.vote = vote + } + public let apiuid: Int + public let apikey: String + public let gid: Int + public let token: String + public let tag: String + public let vote: Int + + public var publisher: AnyPublisher { let params: [String: Any] = [ "method": "taggallery", "apiuid": apiuid, diff --git a/AppPackage/Sources/AppFeature/Network/Request+Detail.swift b/AppPackage/Sources/Networking/Request+Detail.swift similarity index 70% rename from AppPackage/Sources/AppFeature/Network/Request+Detail.swift rename to AppPackage/Sources/Networking/Request+Detail.swift index 42b493dac..909c8f997 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Detail.swift +++ b/AppPackage/Sources/Networking/Request+Detail.swift @@ -6,21 +6,43 @@ import Utilities import Parser // MARK: Response Types -struct GalleryDetailResponse { - let galleryDetail: GalleryDetail - let galleryState: GalleryState - let apiKey: String - let greeting: Greeting? +public struct GalleryDetailResponse: Sendable { + public init( + galleryDetail: GalleryDetail, + galleryState: GalleryState, + apiKey: String, + greeting: Greeting? = nil + ) { + self.galleryDetail = galleryDetail + self.galleryState = galleryState + self.apiKey = apiKey + self.greeting = greeting + } + public let galleryDetail: GalleryDetail + public let galleryState: GalleryState + public let apiKey: String + public let greeting: Greeting? } // MARK: Fetch others -struct GalleryDetailRequest: Request { - let gid: String - let galleryURL: URL - var urlSession: URLSession = .shared - var allowsCellular = true +public struct GalleryDetailRequest: Request { + public init( + gid: String, + galleryURL: URL, + urlSession: URLSession = .shared, + allowsCellular: Bool = true + ) { + self.gid = gid + self.galleryURL = galleryURL + self.urlSession = urlSession + self.allowsCellular = allowsCellular + } + public let gid: String + public let galleryURL: URL + public var urlSession: URLSession = .shared + public var allowsCellular = true - var publisher: AnyPublisher { + public var publisher: AnyPublisher { urlSession.dataTaskPublisher( for: urlRequest( url: URLUtil.galleryDetail(url: galleryURL), @@ -90,18 +112,18 @@ private struct GalleryVersionMetadataAPIResponse: Decodable { let gmetadata: [GalleryVersionMetadata] } -struct GalleryVersionMetadataRequest: Request { - let gid: String - let token: String - let urlSession: URLSession +public struct GalleryVersionMetadataRequest: Request { + public let gid: String + public let token: String + public let urlSession: URLSession - init(gid: String, token: String, urlSession: URLSession = .shared) { + public init(gid: String, token: String, urlSession: URLSession = .shared) { self.gid = gid self.token = token self.urlSession = urlSession } - var publisher: AnyPublisher { + public var publisher: AnyPublisher { guard let gid = Int(gid) else { return Fail(error: AppError.notFound) .eraseToAnyPublisher() @@ -135,11 +157,18 @@ struct GalleryVersionMetadataRequest: Request { } } -struct GalleryReverseRequest: Request { - let url: URL - let isGalleryImageURL: Bool +public struct GalleryReverseRequest: Request { + public init( + url: URL, + isGalleryImageURL: Bool + ) { + self.url = url + self.isGalleryImageURL = isGalleryImageURL + } + public let url: URL + public let isGalleryImageURL: Bool - func getGallery(from detail: GalleryDetail?, and url: URL) -> Gallery? { + public func getGallery(from detail: GalleryDetail?, and url: URL) -> Gallery? { if let detail = detail { return Gallery( gid: url.pathComponents[2], @@ -159,14 +188,14 @@ struct GalleryReverseRequest: Request { } } - var publisher: AnyPublisher { + public var publisher: AnyPublisher { galleryURL(url: url) .genericRetry() .flatMap(gallery) .eraseToAnyPublisher() } - func galleryURL(url: URL) -> AnyPublisher { + public func galleryURL(url: URL) -> AnyPublisher { switch isGalleryImageURL { case true: return URLSession.shared.dataTaskPublisher(for: url) @@ -182,7 +211,7 @@ struct GalleryReverseRequest: Request { } } - func gallery(url: URL) -> AnyPublisher { + public func gallery(url: URL) -> AnyPublisher { URLSession.shared.dataTaskPublisher(for: url) .tryMap { try htmlDocument(data: $0.data) } .tryMap { doc in @@ -201,10 +230,15 @@ struct GalleryReverseRequest: Request { } } -struct GalleryArchiveRequest: Request { - let archiveURL: URL +public struct GalleryArchiveRequest: Request { + public init( + archiveURL: URL + ) { + self.archiveURL = archiveURL + } + public let archiveURL: URL - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: archiveURL) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -224,18 +258,25 @@ struct GalleryArchiveRequest: Request { } } -struct GalleryArchiveFundsRequest: Request { - let gid: String - let galleryURL: URL +public struct GalleryArchiveFundsRequest: Request { + public init( + gid: String, + galleryURL: URL + ) { + self.gid = gid + self.galleryURL = galleryURL + } + public let gid: String + public let galleryURL: URL - var publisher: AnyPublisher<(String, String), AppError> { + public var publisher: AnyPublisher<(String, String), AppError> { archiveURL(url: galleryURL) .genericRetry() .flatMap(funds) .eraseToAnyPublisher() } - func archiveURL(url: URL) -> AnyPublisher { + public func archiveURL(url: URL) -> AnyPublisher { URLSession.shared.dataTaskPublisher(for: url) .tryMap { try htmlDocument(data: $0.data) } .tryMap { doc in @@ -252,7 +293,7 @@ struct GalleryArchiveFundsRequest: Request { .eraseToAnyPublisher() } - func funds(url: URL) -> AnyPublisher<(String, String), AppError> { + public func funds(url: URL) -> AnyPublisher<(String, String), AppError> { URLSession.shared.dataTaskPublisher(for: url) .tryMap { try htmlDocument(data: $0.data) } .tryMap { try parseResponse(doc: $0, Parser.parseCurrentFunds) } @@ -261,11 +302,18 @@ struct GalleryArchiveFundsRequest: Request { } } -struct GalleryTorrentsRequest: Request { - let gid: String - let token: String +public struct GalleryTorrentsRequest: Request { + public init( + gid: String, + token: String + ) { + self.gid = gid + self.token = token + } + public let gid: String + public let token: String - var publisher: AnyPublisher<[GalleryTorrent], AppError> { + public var publisher: AnyPublisher<[GalleryTorrent], AppError> { URLSession.shared.dataTaskPublisher(for: URLUtil.galleryTorrents(gid: gid, token: token)) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -275,11 +323,18 @@ struct GalleryTorrentsRequest: Request { } } -struct GalleryPreviewURLsRequest: Request { - let galleryURL: URL - let pageNum: Int +public struct GalleryPreviewURLsRequest: Request { + public init( + galleryURL: URL, + pageNum: Int + ) { + self.galleryURL = galleryURL + self.pageNum = pageNum + } + public let galleryURL: URL + public let pageNum: Int - var publisher: AnyPublisher<[Int: URL], AppError> { + public var publisher: AnyPublisher<[Int: URL], AppError> { URLSession.shared.dataTaskPublisher(for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum)) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } diff --git a/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift b/AppPackage/Sources/Networking/Request+Gallery.swift similarity index 64% rename from AppPackage/Sources/AppFeature/Network/Request+Gallery.swift rename to AppPackage/Sources/Networking/Request+Gallery.swift index 70caf46dc..42c1b2425 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Gallery.swift +++ b/AppPackage/Sources/Networking/Request+Gallery.swift @@ -6,11 +6,18 @@ import Utilities import Parser // MARK: Fetch ListItems -struct SearchGalleriesRequest: Request { - let keyword: String - let filter: Filter +public struct SearchGalleriesRequest: Request { + public init( + keyword: String, + filter: Filter + ) { + self.keyword = keyword + self.filter = filter + } + public let keyword: String + public let filter: Filter - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher( for: URLUtil.searchList(keyword: keyword, filter: filter) ) @@ -30,12 +37,21 @@ struct SearchGalleriesRequest: Request { } } -struct MoreSearchGalleriesRequest: Request { - let keyword: String - let filter: Filter - let lastID: String +public struct MoreSearchGalleriesRequest: Request { + public init( + keyword: String, + filter: Filter, + lastID: String + ) { + self.keyword = keyword + self.filter = filter + self.lastID = lastID + } + public let keyword: String + public let filter: Filter + public let lastID: String - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher( for: URLUtil.moreSearchList(keyword: keyword, filter: filter, lastID: lastID) ) @@ -55,10 +71,15 @@ struct MoreSearchGalleriesRequest: Request { } } -struct DateSeekGalleriesRequest: Request { - let url: URL +public struct DateSeekGalleriesRequest: Request { + public init( + url: URL + ) { + self.url = url + } + public let url: URL - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: url) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -76,10 +97,15 @@ struct DateSeekGalleriesRequest: Request { } } -struct FrontpageGalleriesRequest: Request { - let filter: Filter +public struct FrontpageGalleriesRequest: Request { + public init( + filter: Filter + ) { + self.filter = filter + } + public let filter: Filter - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: URLUtil.frontpageList(filter: filter)) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -97,11 +123,18 @@ struct FrontpageGalleriesRequest: Request { } } -struct MoreFrontpageGalleriesRequest: Request { - let filter: Filter - let lastID: String +public struct MoreFrontpageGalleriesRequest: Request { + public init( + filter: Filter, + lastID: String + ) { + self.filter = filter + self.lastID = lastID + } + public let filter: Filter + public let lastID: String - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: URLUtil.moreFrontpageList(filter: filter, lastID: lastID)) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -119,10 +152,15 @@ struct MoreFrontpageGalleriesRequest: Request { } } -struct PopularGalleriesRequest: Request { - let filter: Filter +public struct PopularGalleriesRequest: Request { + public init( + filter: Filter + ) { + self.filter = filter + } + public let filter: Filter - var publisher: AnyPublisher<[Gallery], AppError> { + public var publisher: AnyPublisher<[Gallery], AppError> { URLSession.shared.dataTaskPublisher(for: URLUtil.popularList(filter: filter)) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -132,11 +170,18 @@ struct PopularGalleriesRequest: Request { } } -struct WatchedGalleriesRequest: Request { - let filter: Filter - let keyword: String +public struct WatchedGalleriesRequest: Request { + public init( + filter: Filter, + keyword: String + ) { + self.filter = filter + self.keyword = keyword + } + public let filter: Filter + public let keyword: String - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: URLUtil.watchedList(filter: filter, keyword: keyword)) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -154,12 +199,21 @@ struct WatchedGalleriesRequest: Request { } } -struct MoreWatchedGalleriesRequest: Request { - let filter: Filter - let lastID: String - let keyword: String +public struct MoreWatchedGalleriesRequest: Request { + public init( + filter: Filter, + lastID: String, + keyword: String + ) { + self.filter = filter + self.lastID = lastID + self.keyword = keyword + } + public let filter: Filter + public let lastID: String + public let keyword: String - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher( for: URLUtil.moreWatchedList(filter: filter, lastID: lastID, keyword: keyword) ) @@ -179,12 +233,21 @@ struct MoreWatchedGalleriesRequest: Request { } } -struct FavoritesGalleriesRequest: Request { - let favIndex: Int - let keyword: String - var sortOrder: FavoritesSortOrder? +public struct FavoritesGalleriesRequest: Request { + public init( + favIndex: Int, + keyword: String, + sortOrder: FavoritesSortOrder? = nil + ) { + self.favIndex = favIndex + self.keyword = keyword + self.sortOrder = sortOrder + } + public let favIndex: Int + public let keyword: String + public var sortOrder: FavoritesSortOrder? - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher( for: URLUtil.favoritesList(favIndex: favIndex, keyword: keyword, sortOrder: sortOrder) ) @@ -205,13 +268,24 @@ struct FavoritesGalleriesRequest: Request { } } -struct MoreFavoritesGalleriesRequest: Request { - let favIndex: Int - let lastID: String - var lastTimestamp: String - let keyword: String +public struct MoreFavoritesGalleriesRequest: Request { + public init( + favIndex: Int, + lastID: String, + lastTimestamp: String, + keyword: String + ) { + self.favIndex = favIndex + self.lastID = lastID + self.lastTimestamp = lastTimestamp + self.keyword = keyword + } + public let favIndex: Int + public let lastID: String + public var lastTimestamp: String + public let keyword: String - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher( for: URLUtil.moreFavoritesList( favIndex: favIndex, lastID: lastID, lastTimestamp: lastTimestamp, keyword: keyword @@ -234,11 +308,18 @@ struct MoreFavoritesGalleriesRequest: Request { } } -struct ToplistsGalleriesRequest: Request { - let catIndex: Int - var pageNum: Int? +public struct ToplistsGalleriesRequest: Request { + public init( + catIndex: Int, + pageNum: Int? = nil + ) { + self.catIndex = catIndex + self.pageNum = pageNum + } + public let catIndex: Int + public var pageNum: Int? - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + public var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { URLSession.shared.dataTaskPublisher( for: URLUtil.toplistsList(catIndex: catIndex, pageNum: pageNum) ) @@ -254,11 +335,18 @@ struct ToplistsGalleriesRequest: Request { } } -struct MoreToplistsGalleriesRequest: Request { - let catIndex: Int - let pageNum: Int +public struct MoreToplistsGalleriesRequest: Request { + public init( + catIndex: Int, + pageNum: Int + ) { + self.catIndex = catIndex + self.pageNum = pageNum + } + public let catIndex: Int + public let pageNum: Int - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + public var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { URLSession.shared.dataTaskPublisher( for: URLUtil.moreToplistsList( catIndex: catIndex, pageNum: pageNum diff --git a/AppPackage/Sources/AppFeature/Network/Request+Image.swift b/AppPackage/Sources/Networking/Request+Image.swift similarity index 62% rename from AppPackage/Sources/AppFeature/Network/Request+Image.swift rename to AppPackage/Sources/Networking/Request+Image.swift index 7daab3406..7ee9ad2dc 100644 --- a/AppPackage/Sources/AppFeature/Network/Request+Image.swift +++ b/AppPackage/Sources/Networking/Request+Image.swift @@ -6,19 +6,37 @@ import Utilities import Parser // MARK: Response Types -struct GalleryMPVImageURLResponse { - let imageURL: URL - let originalImageURL: URL? - let skipServerIdentifier: String +public struct GalleryMPVImageURLResponse: Sendable { + public init( + imageURL: URL, + originalImageURL: URL? = nil, + skipServerIdentifier: String + ) { + self.imageURL = imageURL + self.originalImageURL = originalImageURL + self.skipServerIdentifier = skipServerIdentifier + } + public let imageURL: URL + public let originalImageURL: URL? + public let skipServerIdentifier: String } // MARK: Image Requests -struct MPVKeysRequest: Request { - let mpvURL: URL - var urlSession: URLSession = .shared - var allowsCellular = true +public struct MPVKeysRequest: Request { + public init( + mpvURL: URL, + urlSession: URLSession = .shared, + allowsCellular: Bool = true + ) { + self.mpvURL = mpvURL + self.urlSession = urlSession + self.allowsCellular = allowsCellular + } + public let mpvURL: URL + public var urlSession: URLSession = .shared + public var allowsCellular = true - var publisher: AnyPublisher<(String, [Int: String]), AppError> { + public var publisher: AnyPublisher<(String, [Int: String]), AppError> { urlSession.dataTaskPublisher( for: urlRequest(url: mpvURL, allowsCellular: allowsCellular) ) @@ -30,13 +48,24 @@ struct MPVKeysRequest: Request { } } -struct ThumbnailURLsRequest: Request { - let galleryURL: URL - let pageNum: Int - var urlSession: URLSession = .shared - var allowsCellular = true +public struct ThumbnailURLsRequest: Request { + public init( + galleryURL: URL, + pageNum: Int, + urlSession: URLSession = .shared, + allowsCellular: Bool = true + ) { + self.galleryURL = galleryURL + self.pageNum = pageNum + self.urlSession = urlSession + self.allowsCellular = allowsCellular + } + public let galleryURL: URL + public let pageNum: Int + public var urlSession: URLSession = .shared + public var allowsCellular = true - var publisher: AnyPublisher<[Int: URL], AppError> { + public var publisher: AnyPublisher<[Int: URL], AppError> { urlSession.dataTaskPublisher( for: urlRequest( url: URLUtil.detailPage(url: galleryURL, pageNum: pageNum), @@ -51,12 +80,21 @@ struct ThumbnailURLsRequest: Request { } } -struct GalleryNormalImageURLsRequest: Request { - let thumbnailURLs: [Int: URL] - var urlSession: URLSession = .shared - var allowsCellular = true +public struct GalleryNormalImageURLsRequest: Request { + public init( + thumbnailURLs: [Int: URL], + urlSession: URLSession = .shared, + allowsCellular: Bool = true + ) { + self.thumbnailURLs = thumbnailURLs + self.urlSession = urlSession + self.allowsCellular = allowsCellular + } + public let thumbnailURLs: [Int: URL] + public var urlSession: URLSession = .shared + public var allowsCellular = true - var publisher: AnyPublisher<([Int: URL], [Int: URL]), AppError> { + public var publisher: AnyPublisher<([Int: URL], [Int: URL]), AppError> { thumbnailURLs.publisher .flatMap { index, url in urlSession.dataTaskPublisher( @@ -91,22 +129,48 @@ struct GalleryNormalImageURLsRequest: Request { } } -struct ImageURLRefetchResult { - let imageURL: URL - let anotherImageURL: URL - let response: HTTPURLResponse? +public struct ImageURLRefetchResult: Sendable { + public init( + imageURL: URL, + anotherImageURL: URL, + response: HTTPURLResponse? = nil + ) { + self.imageURL = imageURL + self.anotherImageURL = anotherImageURL + self.response = response + } + public let imageURL: URL + public let anotherImageURL: URL + public let response: HTTPURLResponse? } -struct GalleryNormalImageURLRefetchRequest: Request { - let index: Int - let pageNum: Int - let galleryURL: URL - let thumbnailURL: URL? - let storedImageURL: URL - var urlSession: URLSession = .shared - var allowsCellular = true +public struct GalleryNormalImageURLRefetchRequest: Request { + public init( + index: Int, + pageNum: Int, + galleryURL: URL, + thumbnailURL: URL? = nil, + storedImageURL: URL, + urlSession: URLSession = .shared, + allowsCellular: Bool = true + ) { + self.index = index + self.pageNum = pageNum + self.galleryURL = galleryURL + self.thumbnailURL = thumbnailURL + self.storedImageURL = storedImageURL + self.urlSession = urlSession + self.allowsCellular = allowsCellular + } + public let index: Int + public let pageNum: Int + public let galleryURL: URL + public let thumbnailURL: URL? + public let storedImageURL: URL + public var urlSession: URLSession = .shared + public var allowsCellular = true - var publisher: AnyPublisher<([Int: URL], HTTPURLResponse?), AppError> { + public var publisher: AnyPublisher<([Int: URL], HTTPURLResponse?), AppError> { storedThumbnailURL() .flatMap(renewThumbnailURL) .flatMap(imageURL) @@ -121,7 +185,7 @@ struct GalleryNormalImageURLRefetchRequest: Request { .eraseToAnyPublisher() } - func storedThumbnailURL() -> AnyPublisher { + public func storedThumbnailURL() -> AnyPublisher { if let thumbnailURL = thumbnailURL { return Just(thumbnailURL) .setFailureType(to: AppError.self) @@ -141,7 +205,7 @@ struct GalleryNormalImageURLRefetchRequest: Request { } } - func renewThumbnailURL(stored: URL) + public func renewThumbnailURL(stored: URL) -> AnyPublisher<(URL, URL), AppError> { urlSession.dataTaskPublisher( for: urlRequest(url: stored, allowsCellular: allowsCellular) @@ -165,7 +229,7 @@ struct GalleryNormalImageURLRefetchRequest: Request { .eraseToAnyPublisher() } - func imageURL(thumbnailURL: URL, anotherImageURL: URL) + public func imageURL(thumbnailURL: URL, anotherImageURL: URL) -> AnyPublisher { urlSession.dataTaskPublisher( for: urlRequest(url: thumbnailURL, allowsCellular: allowsCellular) @@ -199,18 +263,39 @@ struct GalleryNormalImageURLRefetchRequest: Request { } } -struct GalleryMPVImageURLRequest: Request { - let gid: Int - let index: Int - let mpvKey: String - let mpvImageKey: String - let skipServerIdentifier: String? - var apiURL: URL = Defaults.URL.api - var urlSession: URLSession = .shared - var allowsCellular = true - var requiresSkipServerIdentifier = true +public struct GalleryMPVImageURLRequest: Request { + public init( + gid: Int, + index: Int, + mpvKey: String, + mpvImageKey: String, + skipServerIdentifier: String? = nil, + apiURL: URL = Defaults.URL.api, + urlSession: URLSession = .shared, + allowsCellular: Bool = true, + requiresSkipServerIdentifier: Bool = true + ) { + self.gid = gid + self.index = index + self.mpvKey = mpvKey + self.mpvImageKey = mpvImageKey + self.skipServerIdentifier = skipServerIdentifier + self.apiURL = apiURL + self.urlSession = urlSession + self.allowsCellular = allowsCellular + self.requiresSkipServerIdentifier = requiresSkipServerIdentifier + } + public let gid: Int + public let index: Int + public let mpvKey: String + public let mpvImageKey: String + public let skipServerIdentifier: String? + public var apiURL: URL = Defaults.URL.api + public var urlSession: URLSession = .shared + public var allowsCellular = true + public var requiresSkipServerIdentifier = true - var publisher: AnyPublisher { + public var publisher: AnyPublisher { var params: [String: Any] = [ "method": "imagedispatch", "gid": gid, @@ -278,10 +363,15 @@ struct GalleryMPVImageURLRequest: Request { } // MARK: Tool -struct DataRequest: Request { - let url: URL +public struct DataRequest: Request { + public init( + url: URL + ) { + self.url = url + } + public let url: URL - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: url) .genericRetry() .map(\.data) diff --git a/AppPackage/Sources/AppFeature/Network/Request.swift b/AppPackage/Sources/Networking/Request.swift similarity index 73% rename from AppPackage/Sources/AppFeature/Network/Request.swift rename to AppPackage/Sources/Networking/Request.swift index aa3e7e538..681f72412 100644 --- a/AppPackage/Sources/AppFeature/Network/Request.swift +++ b/AppPackage/Sources/Networking/Request.swift @@ -7,7 +7,7 @@ import FoundationExt import Utilities import Parser -protocol Request { +public protocol Request { associatedtype Response: Sendable var publisher: AnyPublisher { get } @@ -19,11 +19,11 @@ private struct ResponseParsingError: Error { } extension Request { - func response() async -> Result { + public func response() async -> Result { await publisher.receive(on: DispatchQueue.main).async() } - func urlRequest( + public func urlRequest( url: URL, allowsCellular: Bool ) -> URLRequest { @@ -32,7 +32,7 @@ extension Request { return request } - func htmlDocument(data: Data) throws -> HTMLDocument { + public func htmlDocument(data: Data) throws -> HTMLDocument { do { return try Kanna.HTML(html: data, encoding: .utf8) } catch { @@ -49,7 +49,7 @@ extension Request { } } - func htmlDocumentWithUTF8Fallback(data: Data) throws -> HTMLDocument { + public func htmlDocumentWithUTF8Fallback(data: Data) throws -> HTMLDocument { do { return try Kanna.HTML(html: data, encoding: .utf8) } catch { @@ -75,7 +75,7 @@ extension Request { } } - func parseResponse( + public func parseResponse( doc: HTMLDocument, _ parser: (HTMLDocument) throws -> T ) throws -> T { @@ -89,7 +89,7 @@ extension Request { } } - func parseResponse( + public func parseResponse( data: Data, _ parser: (Data) throws -> T ) throws -> T { @@ -109,7 +109,7 @@ extension Request { } } - func mapAppError(error: Error) -> AppError { + public func mapAppError(error: Error) -> AppError { if let responseParsingError = error as? ResponseParsingError { if let responseError = parsedResponseError( from: responseParsingError @@ -144,11 +144,11 @@ extension Request { } extension Publisher { - func genericRetry() -> Publishers.Retry { + public func genericRetry() -> Publishers.Retry { retry(3) } - func async() async -> Result where Output: Sendable, Failure == AppError { + public func async() async -> Result where Output: Sendable, Failure == AppError { do { let output = try await asyncOutput() return .success(output) @@ -180,12 +180,12 @@ extension Publisher { } } extension URLRequest { - mutating func setURLEncodedContentType() { + public mutating func setURLEncodedContentType() { setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") } } extension Dictionary where Key == String, Value == String { - func dictString() -> String { + public func dictString() -> String { var array = [String]() keys.forEach { key in array.append(key + "=" + self[key].forceUnwrapped) @@ -204,28 +204,59 @@ private extension URL { // MARK: - Response Types -struct GalleriesResult { - let pageNumber: PageNumber - let dateSeekNavigation: DateSeekNavigation? - let galleries: [Gallery] +public struct GalleriesResult: Sendable { + public init( + pageNumber: PageNumber, + dateSeekNavigation: DateSeekNavigation? = nil, + galleries: [Gallery] + ) { + self.pageNumber = pageNumber + self.dateSeekNavigation = dateSeekNavigation + self.galleries = galleries + } + public let pageNumber: PageNumber + public let dateSeekNavigation: DateSeekNavigation? + public let galleries: [Gallery] } -struct FavoritesGalleriesResult { - let pageNumber: PageNumber - let dateSeekNavigation: DateSeekNavigation? - let sortOrder: FavoritesSortOrder? - let galleries: [Gallery] +public struct FavoritesGalleriesResult: Sendable { + public init( + pageNumber: PageNumber, + dateSeekNavigation: DateSeekNavigation? = nil, + sortOrder: FavoritesSortOrder? = nil, + galleries: [Gallery] + ) { + self.pageNumber = pageNumber + self.dateSeekNavigation = dateSeekNavigation + self.sortOrder = sortOrder + self.galleries = galleries + } + public let pageNumber: PageNumber + public let dateSeekNavigation: DateSeekNavigation? + public let sortOrder: FavoritesSortOrder? + public let galleries: [Gallery] } -struct GalleryArchiveResponse { - let archive: GalleryArchive - let galleryPoints: String? - let credits: String? +public struct GalleryArchiveResponse: Sendable { + public init( + archive: GalleryArchive, + galleryPoints: String? = nil, + credits: String? = nil + ) { + self.archive = archive + self.galleryPoints = galleryPoints + self.credits = credits + } + public let archive: GalleryArchive + public let galleryPoints: String? + public let credits: String? } // MARK: Routine -struct GreetingRequest: Request { - var publisher: AnyPublisher { +public struct GreetingRequest: Request { + public init() {} + + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: Defaults.URL.news) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -235,10 +266,15 @@ struct GreetingRequest: Request { } } -struct UserInfoRequest: Request { - let uid: String +public struct UserInfoRequest: Request { + public init( + uid: String + ) { + self.uid = uid + } + public let uid: String - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: URLUtil.userInfo(uid: uid)) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -248,8 +284,10 @@ struct UserInfoRequest: Request { } } -struct FavoriteCategoriesRequest: Request { - var publisher: AnyPublisher<[Int: String], AppError> { +public struct FavoriteCategoriesRequest: Request { + public init() {} + + public var publisher: AnyPublisher<[Int: String], AppError> { URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) .genericRetry() .tryMap { try htmlDocument(data: $0.data) } @@ -259,11 +297,18 @@ struct FavoriteCategoriesRequest: Request { } } -struct TagTranslatorRequest: Request { - let language: TranslatableLanguage - let updatedDate: Date +public struct TagTranslatorRequest: Request { + public init( + language: TranslatableLanguage, + updatedDate: Date + ) { + self.language = language + self.updatedDate = updatedDate + } + public let language: TranslatableLanguage + public let updatedDate: Date - var dateFormatter: DateFormatter { + public var dateFormatter: DateFormatter { let formatter = DateFormatter() formatter.dateFormat = Defaults.DateFormat.github formatter.timeZone = TimeZone(secondsFromGMT: 0) @@ -271,7 +316,7 @@ struct TagTranslatorRequest: Request { return formatter } - var publisher: AnyPublisher { + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: URLUtil.githubAPI(repoName: language.repoName)) .genericRetry().tryMap { data, _ -> Date in guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any], diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift index 75dd5cf2c..fe208360a 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Testing import HapticsClient import DatabaseClient +import Networking @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index 1d2a0eedf..c66dd4869 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -10,6 +10,7 @@ import UIApplicationClient import HapticsClient import LibraryClient import DatabaseClient +import DFClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift index c5fb802a6..e73bd7612 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift @@ -3,6 +3,7 @@ import AppModels import Combine import Testing import Parser +import Networking @testable import AppFeature struct DownloadPageErrorParserTests: TestHelper { From ecfd389ba039efccd3863cfeb2daf51bcc0d42b7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 19:52:39 +0800 Subject: [PATCH 318/614] Extract DownloadClient module (grouped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the whole DownloadClient family — the coordinator, page downloader, the download/queue/file/background-task stores, and the directly-injected BackgroundTaskClient helper — into one module, since these types are tightly coupled by the download subsystem's invariant ownership. Relocate the previewCacheCleanupURLs URL helper here too (it parses preview configs for post-download cache cleanup, used only by this module). Add the public inits and Sendable conformances the white-box download tests need across the boundary. --- AppPackage/Package.swift | 22 + .../DataFlow/AppDelegateReducer.swift | 1 + .../AppFeature/DataFlow/AppReducer.swift | 1 + .../Clients/BackgroundProcessingClient.swift | 1 + .../Clients/DownloadClient+Manager.swift | 325 ------------- .../AppFeature/Tools/Extensions/URL+App.swift | 11 - .../View/Detail/DetailReducer.swift | 1 + .../Detail/Previews/PreviewsReducer.swift | 1 + .../Downloads/DownloadInspectorReducer.swift | 1 + .../View/Downloads/DownloadsReducer.swift | 1 + .../View/Downloads/FolderManagerReducer.swift | 1 + .../View/Favorites/FavoritesReducer.swift | 1 + .../View/Home/History/HistoryReducer.swift | 1 + .../View/Home/Watched/WatchedReducer.swift | 1 + .../View/Reading/ReadingReducer.swift | 1 + .../View/Search/SearchReducer.swift | 1 + .../Sources/DownloadClient/.swiftlint.yml | 1 + .../BackgroundTaskClient.swift | 26 +- .../DownloadBackgroundTaskStore.swift | 29 +- .../DownloadClient+BackgroundAssertion.swift | 4 +- .../DownloadClient+BackgroundDownloads.swift | 16 +- .../DownloadClient+BackgroundProcessing.swift | 2 +- .../DownloadClient+Cache.swift | 16 +- .../DownloadClient+Execution.swift | 10 +- .../DownloadClient+ExecutionFetch.swift | 6 +- .../DownloadClient+ExecutionPerform.swift | 15 +- .../DownloadClient+ExecutionSupport.swift | 18 +- .../DownloadClient+Folders.swift | 10 +- .../DownloadClient+Manager.swift | 457 ++++++++++++++++++ .../DownloadClient+Networking.swift | 28 +- .../DownloadClient+PageDownload.swift | 4 +- .../DownloadClient+PageDownloadHelpers.swift | 2 +- .../DownloadClient+Persistence.swift | 24 +- .../DownloadClient+PersistenceHelpers.swift | 4 +- .../DownloadClient+PersistenceNormalize.swift | 12 +- .../DownloadClient+PublicAPI.swift | 24 +- .../DownloadClient+PublicAPIHelpers.swift | 4 +- .../DownloadClient+ResponseValidation.swift | 6 +- ...loadClient+ResponseValidationHelpers.swift | 8 +- .../DownloadClient+RetryHelpers.swift | 8 +- .../DownloadClient+Scheduling.swift | 14 +- .../DownloadClient+SchedulingHelpers.swift | 6 +- .../DownloadClient+Testing.swift | 18 +- .../DownloadClient.swift | 66 +-- .../DownloadFileManager.swift | 6 +- .../DownloadPageDownloader.swift | 51 +- .../DownloadQueueStore.swift | 14 +- .../DownloadStore+JSONCoding.swift | 4 +- .../DownloadStore+Operations.swift | 22 +- .../DownloadStore.swift | 122 +++-- .../URL+PreviewCacheCleanup.swift | 15 + .../Download/DetailReducerDownloadTests.swift | 1 + .../Download/DetailReducerMetadataTests.swift | 1 + .../DetailReducerMetadataUpdateTests.swift | 1 + .../Download/DetailReducerObserveTests.swift | 1 + .../DetailReducerPauseAndGuardTests.swift | 1 + .../Download/DownloadAutomationTests.swift | 1 + .../DownloadBackgroundAssertionTests.swift | 1 + .../DownloadBackgroundCompletionTests.swift | 1 + .../DownloadBackgroundProcessingTests.swift | 1 + .../DownloadBackgroundTaskStoreTests.swift | 1 + .../DownloadCoordinatorCachedURLTests.swift | 1 + .../DownloadCoordinatorCaptureTests.swift | 1 + .../DownloadCoordinatorRepairSeedTests.swift | 1 + .../DownloadCoordinatorStorageTests.swift | 1 + .../DownloadEnqueueManifestTests.swift | 1 + .../DownloadFeatureTestFactories.swift | 1 + .../Download/DownloadFeatureTestHelpers.swift | 1 + .../DownloadFolderOperationTests.swift | 1 + .../Download/DownloadImageParsingTests.swift | 1 + .../Download/DownloadInspectorLoadTests.swift | 1 + .../DownloadInspectorRetryTests.swift | 1 + .../Download/DownloadInspectorSkipTests.swift | 1 + .../DownloadInterruptedResumeTests.swift | 1 + .../Tests/Download/DownloadIpBanTests.swift | 1 + .../Download/DownloadObserverBatchTests.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../DownloadPauseAndReconcileTests.swift | 1 + .../Download/DownloadProcessCacheTests.swift | 1 + .../Tests/Download/DownloadProcessTests.swift | 1 + .../Download/DownloadQueueStoreTests.swift | 1 + .../DownloadRetryMinimalSourceTests.swift | 1 + .../Download/DownloadRetryPagesTests.swift | 1 + .../DownloadRetryUpdateFallbackTests.swift | 1 + .../Download/DownloadSchedulingTests.swift | 1 + .../Download/DownloadStoreHashTests.swift | 1 + .../Download/DownloadStoreRepairTests.swift | 1 + .../Tests/Download/DownloadStoreTests.swift | 1 + .../DownloadVersionSignatureTests.swift | 1 + .../DownloadsReducerActionTests.swift | 1 + .../DownloadsReducerRefreshTests.swift | 1 + .../Download/FolderManagerReducerTests.swift | 1 + .../PreviewsReducerDownloadTests.swift | 1 + .../ReadingReducerDownloadTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + 96 files changed, 888 insertions(+), 600 deletions(-) delete mode 100644 AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift create mode 100644 AppPackage/Sources/DownloadClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/BackgroundTaskClient.swift (67%) rename AppPackage/Sources/{AppFeature/Tools/Utilities => DownloadClient}/DownloadBackgroundTaskStore.swift (75%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+BackgroundAssertion.swift (95%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+BackgroundDownloads.swift (95%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+BackgroundProcessing.swift (96%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+Cache.swift (91%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+Execution.swift (96%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+ExecutionFetch.swift (97%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+ExecutionPerform.swift (94%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+ExecutionSupport.swift (97%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+Folders.swift (97%) create mode 100644 AppPackage/Sources/DownloadClient/DownloadClient+Manager.swift rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+Networking.swift (94%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+PageDownload.swift (99%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+PageDownloadHelpers.swift (99%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+Persistence.swift (90%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+PersistenceHelpers.swift (95%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+PersistenceNormalize.swift (90%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+PublicAPI.swift (94%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+PublicAPIHelpers.swift (95%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+ResponseValidation.swift (98%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+ResponseValidationHelpers.swift (97%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+RetryHelpers.swift (96%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+Scheduling.swift (94%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+SchedulingHelpers.swift (96%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient+Testing.swift (63%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadClient.swift (74%) rename AppPackage/Sources/{AppFeature/Tools/Utilities => DownloadClient}/DownloadFileManager.swift (68%) rename AppPackage/Sources/{AppFeature/Tools/Clients => DownloadClient}/DownloadPageDownloader.swift (90%) rename AppPackage/Sources/{AppFeature/Tools/Utilities => DownloadClient}/DownloadQueueStore.swift (74%) rename AppPackage/Sources/{AppFeature/Tools/Utilities => DownloadClient}/DownloadStore+JSONCoding.swift (55%) rename AppPackage/Sources/{AppFeature/Tools/Utilities => DownloadClient}/DownloadStore+Operations.swift (94%) rename AppPackage/Sources/{AppFeature/Tools/Utilities => DownloadClient}/DownloadStore.swift (81%) create mode 100644 AppPackage/Sources/DownloadClient/URL+PreviewCacheCleanup.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index a3e12a8ac..2e0c11577 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -72,6 +72,7 @@ enum Module: String { case composableArchitectureExt = "ComposableArchitectureExt" case dfClient = "DFClient" case databaseClient = "DatabaseClient" + case downloadClient = "DownloadClient" case foundationExt = "FoundationExt" case hapticsClient = "HapticsClient" case imageClient = "ImageClient" @@ -227,6 +228,7 @@ let targets: [PackageDescription.Target] = [ .module(.composableArchitectureExt), .module(.databaseClient), .module(.dfClient), + .module(.downloadClient), .module(.foundationExt), .module(.hapticsClient), .module(.imageClient), @@ -290,6 +292,25 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .downloadClient, + dependencies: [ + .module(.appModels), + .module(.databaseClient), + .module(.foundationExt), + .module(.libraryClient), + .module(.networking), + .module(.parser), + .module(.resources), + .module(.sdWebImageExt), + .module(.urlClient), + .module(.utilities), + .targetDependency(.composableArchitecture), + .targetDependency(.kanna) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .foundationExt, swiftSettings: sharedSwiftSettings, @@ -459,6 +480,7 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.databaseClient), .module(.dfClient), + .module(.downloadClient), .module(.foundationExt), .module(.hapticsClient), .module(.imageClient), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index a4de74d8e..abfb65f2f 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import Utilities import LibraryClient import DatabaseClient +import DownloadClient @Reducer struct AppDelegateReducer { diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 537ee995f..63899909e 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -2,6 +2,7 @@ import SwiftUI import ComposableArchitecture import URLClient import HapticsClient +import DownloadClient @Reducer struct AppReducer { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift index 774aed042..8595f915b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift +++ b/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift @@ -1,6 +1,7 @@ import BackgroundTasks import AppModels import ComposableArchitecture +import DownloadClient enum BackgroundProcessing { /// Fixed task identifier, independent of the bundle id. Must stay in sync with the diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift b/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift deleted file mode 100644 index 46b629891..000000000 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Manager.swift +++ /dev/null @@ -1,325 +0,0 @@ -import Foundation -import AppModels -import LibraryClient - -typealias ScheduledDownloadOperation = @Sendable () async -> Void - -enum ScheduledDownloadRunResult: Equatable, Sendable { - case ranOperation - case skippedOperation -} - -struct DownloadTaskRunner: Sendable { - var beforeActiveTaskCheck: @Sendable () async -> Void - var recordScheduledGallery: @Sendable (String) async -> Void - var runScheduledDownload: @Sendable ( - String, - @escaping ScheduledDownloadOperation - ) async -> ScheduledDownloadRunResult - var beforeFailurePersistence: @Sendable () async -> Void - - init( - beforeActiveTaskCheck: @escaping @Sendable () async -> Void = {}, - recordScheduledGallery: @escaping @Sendable (String) async -> Void = { _ in }, - runScheduledDownload: @escaping @Sendable ( - String, - @escaping ScheduledDownloadOperation - ) async -> ScheduledDownloadRunResult = { _, operation in - await operation() - return .ranOperation - }, - beforeFailurePersistence: @escaping @Sendable () async -> Void = {} - ) { - self.beforeActiveTaskCheck = beforeActiveTaskCheck - self.recordScheduledGallery = recordScheduledGallery - self.runScheduledDownload = runScheduledDownload - self.beforeFailurePersistence = beforeFailurePersistence - } -} - -/// The brain of the download subsystem: the in-memory read model (`downloadIndex`, -/// `userFolders`) fused with scheduling (`activeGalleryID`, `activeTask`, queued -/// modes / selections). It is one of three types the old monolith was split into by -/// invariant ownership, alongside `DownloadStore` (pure disk I/O) and -/// `DownloadObserverHub` (observer fan-out), all behind the unchanged `DownloadClient` -/// facade. Read model and scheduling stay fused on purpose: only one gallery downloads at -/// a time (E-Hentai rate-limits gallery downloads, so concurrency is unwanted), and -/// scheduling reads and writes the index on every step, so splitting them would buy nothing -/// and reintroduce the cross-actor races this single actor exists to prevent. -actor DownloadCoordinator { - static let retryLimit = 3 - static let progressFlushPageInterval = 8 - static let progressFlushMinimumInterval: TimeInterval = 0.4 - static let responseInspectionPrefixLength = 4096 - static let kokomadeImageURLSuffixes = [ - "exhentai.org/img/kokomade.jpg" - ] - static let quotaExceededImageURLSuffixes = [ - "exhentai.org/img/509.gif", - "ehgt.org/g/509.gif" - ] - - struct PageResult: Sendable { - let index: Int - let relativePath: String - let imageURL: URL? - } - - struct PageFailure: Error, Sendable { - let index: Int - let relativePath: String? - let error: AppError - } - - struct DownloadBatchResult: Sendable { - let pages: [PageResult] - let failedPages: [PageFailure] - } - - enum PageTaskOutcome: Sendable { - case success(PageResult) - case failure(PageFailure) - case cancelled - } - - struct RepairSeed: Sendable { - let folderURL: URL - let manifest: DownloadManifest - } - - struct WorkingSeed: Sendable { - let folderURL: URL - let manifest: DownloadManifest - let existingPages: [Int: String] - let coverRelativePath: String? - } - - enum ResolvedSource: Sendable { - case normal([Int: URL]) - case mpv(String, [Int: String]) - } - - struct ResolvedImageSource: Sendable { - let imageURL: URL - var mpvSkipServerIdentifier: String? - } - - struct PartialDownloadError: Error, Sendable { - let failedPages: [PageFailure] - } - - struct IncompleteDownloadError: Error, Sendable { - let missingPageIndices: [Int] - } - - struct FailureContext: Sendable { - let gid: String - let originalDownload: DownloadedGallery - let mode: DownloadStartMode - } - - struct ProgressFlushContext: Sendable { - let gid: String - let folderURL: URL - } - - struct PageDownloadContext: Sendable { - let payload: DownloadRequestPayload - let options: DownloadRequestOptions - let source: ResolvedSource? - let folderURL: URL - } - - struct CacheRestoreSource: Sendable { - let gid: String - let token: String - let cacheURLs: [URL?] - let referenceURL: URL? - let imageURL: URL? - } - - struct CaptureTargetResult: Sendable { - let folderURL: URL - let preferredRelativePath: String? - } - - struct HTMLResponseContext { - let prefixData: Data - let fullData: Data? - let response: URLResponse - let requestURL: URL? - let mimeType: String? - } - - struct DownloadExecutionContext: Sendable { - let payload: DownloadRequestPayload - let options: DownloadRequestOptions - let existingDownload: DownloadedGallery - } - - struct FinalizeContext: Sendable { - let coverRelativePath: String? - let batchResult: DownloadBatchResult - let existingDownload: DownloadedGallery - } - - let storage: DownloadStore - let urlSession: URLSession - let pageDownloader: DownloadPageDownloader - let backgroundTaskStore: DownloadBackgroundTaskStore - let backgroundTaskClient: BackgroundTaskClient - let storedCookiesProvider: @Sendable (URL) -> [HTTPCookie] - let libraryClient: LibraryClient - /// Supplies the latest runtime settings immediately before a queued download starts. - /// - /// Options are not stored in manifests or request payloads so settings changed while - /// a gallery is queued apply to the eventual detail fetch and page workers. - let downloadOptionsProvider: @Sendable () async -> DownloadRequestOptions - let queueStore: DownloadQueueStore - let taskRunner: DownloadTaskRunner - let observerHub = DownloadObserverHub() - /// Write-through cache of the on-disk download tree and the read authority between the - /// explicit scan boundaries (see `indexedDownload(gid:)`). The filesystem stays the - /// source of truth, so this is rebuilt from disk only at those boundaries, never on a - /// hot lookup. - var downloadIndex = [String: DownloadFolderRecord]() - var hasLoadedIndex = false - var userFolders = [String]() - /// Transient, session-scoped status: deliberately in-memory only, never written to disk. - /// Download-level errors, per-page failures, validation results, and the update-available - /// set are status *about* a download, not durable properties of it; they are cheap to - /// re-derive and re-derivation yields the *current* truth (e.g. a lifted quota simply - /// succeeds on the next attempt). Durable facts (downloaded pages, hashes, metadata) - /// live in the manifest. The accepted cost is that after relaunch a failed download - /// surfaces as inactive ("Paused") until its error re-surfaces on the next manual retry. - var downloadErrors = [String: DownloadFailure]() - var validationErrors = [String: DownloadFailure]() - var failedPageErrors = [String: [Int: PageFailure]]() - var updatedGalleryIDs = Set() - var queuedModes = [String: DownloadStartMode]() - var queuedPageSelections = [String: [Int]]() - var activeGalleryID: String? - var activeTask: Task? - var activeTaskGeneration = 0 - var schedulingBlockedGalleryIDs = Set() - var backgroundAssertionToken: BackgroundTaskToken? - /// Set synchronously across the `begin` MainActor hop so a concurrent reconcile - /// cannot issue a second assertion before the first token is recorded. - var isBeginningBackgroundAssertion = false - - init( - storage: DownloadStore, - urlSession: URLSession, - pageDownloader: DownloadPageDownloader? = nil, - backgroundTaskStore: DownloadBackgroundTaskStore? = nil, - backgroundTaskClient: BackgroundTaskClient = .noop, - storedCookiesProvider: @escaping @Sendable (URL) -> [HTTPCookie] = { - HTTPCookieStorage.shared.cookies(for: $0) ?? [] - }, - libraryClient: LibraryClient = .live, - downloadOptionsProvider: @escaping @Sendable () async -> DownloadRequestOptions = { - DownloadRequestOptions() - }, - queueStore: DownloadQueueStore? = nil, - taskRunner: DownloadTaskRunner = .init() - ) { - self.storage = storage - self.urlSession = urlSession - self.pageDownloader = pageDownloader ?? .foreground(urlSession: urlSession) - self.backgroundTaskStore = backgroundTaskStore ?? DownloadBackgroundTaskStore( - fileURL: storage.backgroundTaskRegistryURL() - ) - self.backgroundTaskClient = backgroundTaskClient - self.storedCookiesProvider = storedCookiesProvider - self.libraryClient = libraryClient - self.downloadOptionsProvider = downloadOptionsProvider - self.queueStore = queueStore ?? DownloadQueueStore(fileURL: storage.queueURL()) - self.taskRunner = taskRunner - } - - var fileManager: DownloadFileManager { - storage.fileManager - } -} - -/// Owns the observer continuations and the last snapshot broadcast to them, kept apart from -/// the coordinator's state so notification can never interleave with a state mutation. The -/// coordinator computes a snapshot and hands it here to fan out; this type holds no download -/// state of its own. -actor DownloadObserverHub { - private var lastObservedDownloads = [DownloadedGallery]() - private var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() - private var notifyGeneration = 0 - - func observe( - snapshot: @Sendable () async -> [DownloadedGallery] - ) async -> AsyncStream<[DownloadedGallery]> { - let identifier = UUID() - let (stream, continuation) = AsyncStream.makeStream( - of: [DownloadedGallery].self - ) - // Register before the snapshot resolves so a `notify` landing while the - // snapshot is in flight reaches this observer instead of being missed. - observers[identifier] = continuation - continuation.onTermination = { [weak self] _ in - guard let self else { return } - Task { - await self.removeObserver(id: identifier) - } - } - - let generationBeforeSnapshot = notifyGeneration - let initialDownloads = await snapshot() - if notifyGeneration == generationBeforeSnapshot { - // No notify reached this observer during resolution; deliver the snapshot. - continuation.yield(initialDownloads) - } - // Otherwise a fresher value already arrived via notify; skipping the now-stale - // snapshot keeps emissions ordered newest-last. - return stream - } - - func notify(_ downloads: [DownloadedGallery]) { - guard downloads != lastObservedDownloads else { return } - lastObservedDownloads = downloads - notifyGeneration += 1 - observers.values.forEach { $0.yield(downloads) } - } - - private func removeObserver(id: UUID) { - observers[id] = nil - } -} - -extension DownloadCoordinator { - func clearDownloadFailureState( - gid: String, - includePageFailures: Bool = true - ) { - downloadErrors[gid] = nil - validationErrors[gid] = nil - if includePageFailures { - failedPageErrors[gid] = nil - } - } - - func clearDownloadQueueIntent(gid: String) { - queuedModes[gid] = nil - queuedPageSelections[gid] = nil - } - - func clearDownloadSessionState( - gid: String, - includePageFailures: Bool = true, - includeUpdateFlag: Bool = false - ) { - clearDownloadFailureState( - gid: gid, - includePageFailures: includePageFailures - ) - clearDownloadQueueIntent(gid: gid) - if includeUpdateFlag { - updatedGalleryIDs.remove(gid) - } - } -} diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift index 71a26501b..c0804515b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift @@ -1,17 +1,6 @@ import Foundation import AppModels -import Parser extension URL { static let mock = Defaults.URL.ehentai - - func previewCacheCleanupURLs() -> [URL] { - guard let info = Parser.parsePreviewConfigs(url: self), - info.plainURL != self - else { - return [self] - } - - return [self, info.plainURL] - } } diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift index 6259d649e..16c9dfd03 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift @@ -7,6 +7,7 @@ import SwiftUINavigationExt import HapticsClient import DatabaseClient import Networking +import DownloadClient @Reducer struct DetailReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift index 08fc61176..0919ee1ab 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift @@ -6,6 +6,7 @@ import SwiftUINavigationExt import HapticsClient import DatabaseClient import Networking +import DownloadClient @Reducer struct PreviewsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift index bd67e90f9..1bdbc8f8b 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import Resources import ComposableArchitecture +import DownloadClient @Reducer struct DownloadInspectorReducer { diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift index 9282a90bf..72bf1346a 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import FoundationExt +import DownloadClient @Reducer struct DownloadsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift index 3ddad0b5d..d6c13e420 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import DownloadClient @Reducer struct FolderManagerReducer { diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift index 46ec557d8..73213ae3e 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift @@ -6,6 +6,7 @@ import SwiftUINavigationExt import HapticsClient import DatabaseClient import Networking +import DownloadClient @Reducer struct FavoritesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift index 78991ab0e..df7cda0f9 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import FoundationExt import HapticsClient import DatabaseClient +import DownloadClient @Reducer struct HistoryReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift index b60b0370d..c1e9003e0 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift @@ -4,6 +4,7 @@ import SwiftUINavigationExt import HapticsClient import DatabaseClient import Networking +import DownloadClient @Reducer struct WatchedReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift index 021aebbf1..a3bbccf6f 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift @@ -6,6 +6,7 @@ import HapticsClient import ImageClient import DatabaseClient import Networking +import DownloadClient @Reducer struct ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift index 682e33833..54aed0324 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift @@ -5,6 +5,7 @@ import SwiftUINavigationExt import HapticsClient import DatabaseClient import Networking +import DownloadClient @Reducer struct SearchReducer { diff --git a/AppPackage/Sources/DownloadClient/.swiftlint.yml b/AppPackage/Sources/DownloadClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DownloadClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundTaskClient.swift b/AppPackage/Sources/DownloadClient/BackgroundTaskClient.swift similarity index 67% rename from AppPackage/Sources/AppFeature/Tools/Clients/BackgroundTaskClient.swift rename to AppPackage/Sources/DownloadClient/BackgroundTaskClient.swift index 4ea384191..d4f35f56a 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundTaskClient.swift +++ b/AppPackage/Sources/DownloadClient/BackgroundTaskClient.swift @@ -1,7 +1,7 @@ import UIKit import ComposableArchitecture -typealias BackgroundTaskToken = UIBackgroundTaskIdentifier +public typealias BackgroundTaskToken = UIBackgroundTaskIdentifier /// Wraps `UIApplication`'s background-task assertion API so the download coordinator /// can hold an OS execution assertion while a download is in flight, keeping the @@ -12,16 +12,26 @@ typealias BackgroundTaskToken = UIBackgroundTaskIdentifier /// rather than a `@DependencyClient`. It is injected straight into `DownloadCoordinator` /// (like `pageDownloader`) rather than being resolved through `DependencyValues`, so it /// has no place for the macro's auto-generated unimplemented `testValue` to live. -struct BackgroundTaskClient: Sendable { +public struct BackgroundTaskClient: Sendable { /// Begins a background-task assertion and returns its token. `expirationHandler` /// fires when the OS is about to reclaim the assertion; the caller must end it then. - let begin: @MainActor @Sendable (_ expirationHandler: @escaping @Sendable () -> Void) -> BackgroundTaskToken + public let begin: @MainActor @Sendable (_ expirationHandler: @escaping @Sendable () -> Void) -> BackgroundTaskToken /// Ends a previously begun assertion. A no-op for `.invalid` tokens. - let end: @MainActor @Sendable (BackgroundTaskToken) -> Void + public let end: @MainActor @Sendable (BackgroundTaskToken) -> Void + + public init( + begin: @escaping @MainActor @Sendable ( + _ expirationHandler: @escaping @Sendable () -> Void + ) -> BackgroundTaskToken, + end: @escaping @MainActor @Sendable (BackgroundTaskToken) -> Void + ) { + self.begin = begin + self.end = end + } } extension BackgroundTaskClient { - static let live = Self( + public static let live = Self( begin: { expirationHandler in UIApplication.shared.beginBackgroundTask( withName: "app.ehpanda.downloads.assertion", @@ -37,14 +47,14 @@ extension BackgroundTaskClient { // MARK: Test extension BackgroundTaskClient { - static let noop = Self( + public static let noop = Self( begin: { _ in .invalid }, end: { _ in } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented = Self( + public static let unimplemented = Self( begin: IssueReporting.unimplemented(placeholder: placeholder()), end: IssueReporting.unimplemented(placeholder: placeholder()) ) diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadBackgroundTaskStore.swift b/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift similarity index 75% rename from AppPackage/Sources/AppFeature/Tools/Utilities/DownloadBackgroundTaskStore.swift rename to AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift index 55287f2d3..bf08f4522 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadBackgroundTaskStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift @@ -1,17 +1,24 @@ import Foundation import AppModels -actor DownloadBackgroundTaskStore { - struct Record: Codable, Equatable, Sendable { - let gid: String - let pageIndex: Int +public actor DownloadBackgroundTaskStore { + public struct Record: Codable, Equatable, Sendable { + public let gid: String + public let pageIndex: Int + public init( + gid: String, + pageIndex: Int + ) { + self.gid = gid + self.pageIndex = pageIndex + } } private let fileURL: URL private let fileManager: DownloadFileManager private var records: [Int: Record] - init( + public init( fileURL: URL, fileManager: sending FileManager = FileManager() ) { @@ -23,7 +30,7 @@ actor DownloadBackgroundTaskStore { ) } - func record( + public func record( taskIdentifier: Int, gid: String, pageIndex: Int @@ -32,27 +39,27 @@ actor DownloadBackgroundTaskStore { await save() } - func record(taskIdentifier: Int) -> Record? { + public func record(taskIdentifier: Int) -> Record? { records[taskIdentifier] } - func records(for gid: String) -> [Int: Record] { + public func records(for gid: String) -> [Int: Record] { records.filter { $0.value.gid == gid } } @discardableResult - func remove(taskIdentifier: Int) async -> Record? { + public func remove(taskIdentifier: Int) async -> Record? { let record = records.removeValue(forKey: taskIdentifier) await save() return record } - func removeAll(for gid: String) async { + public func removeAll(for gid: String) async { records = records.filter { $0.value.gid != gid } await save() } - func removeAll() async { + public func removeAll() async { records.removeAll() await save() } diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundAssertion.swift b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundAssertion.swift similarity index 95% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundAssertion.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+BackgroundAssertion.swift index 14a59267f..7fe275829 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundAssertion.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundAssertion.swift @@ -6,7 +6,7 @@ extension DownloadCoordinator { /// /// Drives the background-task assertion and the `BGProcessingTask` drain loop, so /// it must agree with the scheduler about what counts as schedulable work. - func hasPendingWork() async -> Bool { + public func hasPendingWork() async -> Bool { // A running task is unambiguous work; skip the disk-backed index read. if activeTask != nil { return true } let queuedGIDs = queueStore.gids @@ -23,7 +23,7 @@ extension DownloadCoordinator { /// queue mutation converges on, so the assertion can never be leaked when the last /// active download is paused or deleted (those paths null `activeTask` directly but /// still reschedule afterward). - func reconcileBackgroundAssertion() async { + public func reconcileBackgroundAssertion() async { guard await hasPendingWork() else { await endBackgroundAssertion() return diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundDownloads.swift b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift similarity index 95% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundDownloads.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift index 75e6f7581..816ecffc4 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundDownloads.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift @@ -1,7 +1,7 @@ import Foundation import AppModels -actor BackgroundPageCompletionReceiver { +public actor BackgroundPageCompletionReceiver { private enum PendingEvent { case completion(taskIdentifier: Int, fileURL: URL, response: URLResponse) case failure(taskIdentifier: Int, error: AppError?) @@ -10,12 +10,14 @@ actor BackgroundPageCompletionReceiver { private var coordinator: DownloadCoordinator? private var pendingEvents = [PendingEvent]() + public init() {} + // The background URLSession is live the moment it's created, so iOS can replay a // stored completion before the coordinator is installed one task-hop later. Buffer // anything that arrives in that window and drain it here, or the event would no-op // against a nil coordinator — stranding the staged file and letting resumeQueue // re-download an already-finished page (defeats the offline-finish guarantee). - func setCoordinator(_ coordinator: DownloadCoordinator) async { + public func setCoordinator(_ coordinator: DownloadCoordinator) async { self.coordinator = coordinator let bufferedEvents = pendingEvents pendingEvents.removeAll() @@ -24,7 +26,7 @@ actor BackgroundPageCompletionReceiver { } } - func handleCompletion( + public func handleCompletion( taskIdentifier: Int, fileURL: URL, response: URLResponse @@ -42,7 +44,7 @@ actor BackgroundPageCompletionReceiver { ) } - func handleFailure( + public func handleFailure( taskIdentifier: Int, error: AppError? ) async { @@ -77,7 +79,7 @@ actor BackgroundPageCompletionReceiver { } extension DownloadCoordinator { - func handleBackgroundPageDownloadCompleted( + public func handleBackgroundPageDownloadCompleted( taskIdentifier: Int, fileURL: URL, response: URLResponse @@ -111,7 +113,7 @@ extension DownloadCoordinator { await scheduleNextIfNeeded() } - func handleBackgroundPageDownloadFailed( + public func handleBackgroundPageDownloadFailed( taskIdentifier: Int, error: AppError? ) async { @@ -225,7 +227,7 @@ extension DownloadCoordinator { ) } - func removeStagedBackgroundFile(_ fileURL: URL) { + public func removeStagedBackgroundFile(_ fileURL: URL) { try? fileManager.operate { guard $0.fileExists(atPath: fileURL.path) else { return } try $0.removeItem(at: fileURL) diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundProcessing.swift b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundProcessing.swift similarity index 96% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundProcessing.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+BackgroundProcessing.swift index e5979e0a5..535b1a2eb 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+BackgroundProcessing.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundProcessing.swift @@ -10,7 +10,7 @@ extension DownloadCoordinator { /// expiration), the in-flight download is cancelled so the loop can observe the /// cancellation and return promptly instead of waiting out a transfer that may not /// finish before the process is suspended. - func runQueueUntilIdle() async { + public func runQueueUntilIdle() async { while !Task.isCancelled { await scheduleNextIfNeeded() guard let task = activeTask else { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift similarity index 91% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift index 9d5ac6f92..68f32afc9 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Cache.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift @@ -5,7 +5,7 @@ import Utilities // MARK: - Cache Operations extension DownloadCoordinator { - func removeCachedImages( + public func removeCachedImages( for urls: [URL?] ) async { let keys = urls @@ -19,13 +19,13 @@ extension DownloadCoordinator { } } - func pageImageCacheURLs( + public func pageImageCacheURLs( imageURL: URL? ) -> [URL?] { [imageURL] } - func restorePageFromCache( + public func restorePageFromCache( index: Int, source: CacheRestoreSource, folderURL: URL, @@ -72,19 +72,19 @@ extension DownloadCoordinator { ) } - func preferredPageReferenceURL( + public func preferredPageReferenceURL( resolvedImageSource: ResolvedImageSource ) -> URL? { resolvedImageSource.imageURL } - func preferredPageReferenceURL( + public func preferredPageReferenceURL( imageURL: URL? ) -> URL? { imageURL } - func cachedImageData( + public func cachedImageData( for urls: [URL?] ) async -> Data? { let keys = urls @@ -93,7 +93,7 @@ extension DownloadCoordinator { return await DataCache.shared.data(forKeys: keys) } - func validatedCachedAssetData( + public func validatedCachedAssetData( for urls: [URL?] ) async -> Data? { guard let cachedData = await cachedImageData(for: urls) else { @@ -109,7 +109,7 @@ extension DownloadCoordinator { return cachedData } - func detectCachedAssetError( + public func detectCachedAssetError( data: Data, referenceURLs _: [URL?] ) -> AppError? { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Execution.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift similarity index 96% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Execution.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift index 5486657b6..8cace4938 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Execution.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift @@ -3,7 +3,7 @@ import AppModels // MARK: - Process Download extension DownloadCoordinator { - func processDownload( + public func processDownload( gid: String, generation: Int? = nil ) async { @@ -69,7 +69,7 @@ extension DownloadCoordinator { // (re-slot after a title change), and an interrupted session can leave // both behind; only the completed folder may survive, or the stale // duplicate resurfaces once the surviving record is deleted. - func removeSupersededFolders(gid: String, token: String, keeping folderURL: URL) { + public func removeSupersededFolders(gid: String, token: String, keeping folderURL: URL) { do { try removeGalleryFolders(gid: gid, token: token, keeping: folderURL) } catch { @@ -77,7 +77,7 @@ extension DownloadCoordinator { } } - func removeGalleryFolders(gid: String, token: String, keeping folderURL: URL? = nil) throws { + public func removeGalleryFolders(gid: String, token: String, keeping folderURL: URL? = nil) throws { let keptPath = folderURL?.standardizedFileURL.path for galleryFolderURL in storage.galleryFolderURLs(gid: gid, token: token) { guard galleryFolderURL.standardizedFileURL.path != keptPath else { @@ -231,13 +231,13 @@ extension DownloadCoordinator { await notifyObservers() } - func settleCompletedDownload(gid: String) async { + public func settleCompletedDownload(gid: String) async { clearDownloadSessionState(gid: gid, includeUpdateFlag: true) await queueStore.remove(gid) await backgroundTaskStore.removeAll(for: gid) } - func finishActiveTaskIfOwned( + public func finishActiveTaskIfOwned( gid: String, generation: Int?, schedulesNext: Bool diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionFetch.swift similarity index 97% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+ExecutionFetch.swift index 1ec9a2add..979a86c1d 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionFetch.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionFetch.swift @@ -4,7 +4,7 @@ import Networking // MARK: - Fetch & Normalize Payload extension DownloadCoordinator { - func fetchLatestPayload( + public func fetchLatestPayload( for download: DownloadedGallery, mode: DownloadStartMode, options: DownloadRequestOptions, @@ -105,7 +105,7 @@ extension DownloadCoordinator { ) } - func fetchVersionMetadata( + public func fetchVersionMetadata( gid: String, token: String ) async -> Result { @@ -131,7 +131,7 @@ extension DownloadCoordinator { } } - func normalizeFetchedPayload( + public func normalizeFetchedPayload( _ payload: DownloadRequestPayload, mode: DownloadStartMode, rawPageSelection: [Int]? diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionPerform.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionPerform.swift similarity index 94% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionPerform.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+ExecutionPerform.swift index da80c201b..6f9cd0879 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionPerform.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionPerform.swift @@ -3,12 +3,19 @@ import AppModels // MARK: - Perform Download extension DownloadCoordinator { - struct PerformDownloadResult { - let coverRelativePath: String? - let pages: [PageResult] + public struct PerformDownloadResult { + public let coverRelativePath: String? + public let pages: [PageResult] + public init( + coverRelativePath: String? = nil, + pages: [PageResult] + ) { + self.coverRelativePath = coverRelativePath + self.pages = pages + } } - func performDownload( + public func performDownload( payload: DownloadRequestPayload, options: DownloadRequestOptions, folderRelativePath: String, diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift similarity index 97% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift index 6a84db2c3..7533ca9b5 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ExecutionSupport.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift @@ -5,7 +5,7 @@ import Networking // MARK: - Execution Support extension DownloadCoordinator { - func makeInitialManifest(payload: DownloadRequestPayload) -> DownloadManifest { + public func makeInitialManifest(payload: DownloadRequestPayload) -> DownloadManifest { let pageCount = payload.galleryDetail.pageCount let pages = pageCount > 0 ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) @@ -28,7 +28,7 @@ extension DownloadCoordinator { ) } - func folderRelativePath( + public func folderRelativePath( for payload: DownloadRequestPayload, parentFolderName: String ) -> String { @@ -42,7 +42,7 @@ extension DownloadCoordinator { return "\(parentFolderName)/\(galleryFolderName)" } - func downloadCoverImage( + public func downloadCoverImage( payload: DownloadRequestPayload, options: DownloadRequestOptions, folderURL: URL, @@ -137,7 +137,7 @@ extension DownloadCoordinator { return relativePath } - func cleanupCachedRemoteAssetsAfterSuccessfulDownload( + public func cleanupCachedRemoteAssetsAfterSuccessfulDownload( payload: DownloadRequestPayload, pages: [PageResult], existingDownload: DownloadedGallery @@ -157,7 +157,7 @@ extension DownloadCoordinator { await removeCachedImages(for: urls) } - func resolveSource( + public func resolveSource( payload: DownloadRequestPayload, options: DownloadRequestOptions, requiredPageIndices: [Int] @@ -201,7 +201,7 @@ extension DownloadCoordinator { } } - func prepareWorkingSeed( + public func prepareWorkingSeed( payload: DownloadRequestPayload, existingDownload: DownloadedGallery, folderURL: URL @@ -313,7 +313,7 @@ extension DownloadCoordinator { } } - func resolvedImageSource( + public func resolvedImageSource( index: Int, payload: DownloadRequestPayload, options: DownloadRequestOptions, @@ -381,7 +381,7 @@ extension DownloadCoordinator { } } - func repairSeed( + public func repairSeed( for download: DownloadedGallery, payload: DownloadRequestPayload ) -> RepairSeed? { @@ -401,7 +401,7 @@ extension DownloadCoordinator { return .init(folderURL: folderURL, manifest: manifest) } - func pendingPageIndices( + public func pendingPageIndices( payload: DownloadRequestPayload, folderURL: URL, existingPageRelativePaths: [Int: String] diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift similarity index 97% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift index 007ef5ef6..f63bdb061 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Folders.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift @@ -4,11 +4,11 @@ import Resources // MARK: - User Folder Operations extension DownloadCoordinator { - func fetchFolders() async -> [String] { + public func fetchFolders() async -> [String] { return userFolders } - func createFolder(name: String) async -> Result { + public func createFolder(name: String) async -> Result { guard let normalizedName = storage.normalizedUserFolderName(name) else { return .failure( .fileOperationFailed( @@ -35,7 +35,7 @@ extension DownloadCoordinator { return .success(()) } - func renameFolder( + public func renameFolder( oldName: String, newName: String ) async -> Result { @@ -85,7 +85,7 @@ extension DownloadCoordinator { return .success(()) } - func deleteFolder(name: String) async -> Result { + public func deleteFolder(name: String) async -> Result { let folderURL = storage.userFolderURL(name: name) guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return .failure(.notFound) @@ -134,7 +134,7 @@ extension DownloadCoordinator { return .success(()) } - func moveDownload( + public func moveDownload( gid: String, toFolderName folderName: String ) async -> Result { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Manager.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Manager.swift new file mode 100644 index 000000000..2ceae94da --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Manager.swift @@ -0,0 +1,457 @@ +import Foundation +import AppModels +import LibraryClient + +public typealias ScheduledDownloadOperation = @Sendable () async -> Void + +public enum ScheduledDownloadRunResult: Equatable, Sendable { + case ranOperation + case skippedOperation +} + +public struct DownloadTaskRunner: Sendable { + public var beforeActiveTaskCheck: @Sendable () async -> Void + public var recordScheduledGallery: @Sendable (String) async -> Void + public var runScheduledDownload: @Sendable ( + String, + @escaping ScheduledDownloadOperation + ) async -> ScheduledDownloadRunResult + public var beforeFailurePersistence: @Sendable () async -> Void + + public init( + beforeActiveTaskCheck: @escaping @Sendable () async -> Void = {}, + recordScheduledGallery: @escaping @Sendable (String) async -> Void = { _ in }, + runScheduledDownload: @escaping @Sendable ( + String, + @escaping ScheduledDownloadOperation + ) async -> ScheduledDownloadRunResult = { _, operation in + await operation() + return .ranOperation + }, + beforeFailurePersistence: @escaping @Sendable () async -> Void = {} + ) { + self.beforeActiveTaskCheck = beforeActiveTaskCheck + self.recordScheduledGallery = recordScheduledGallery + self.runScheduledDownload = runScheduledDownload + self.beforeFailurePersistence = beforeFailurePersistence + } +} + +/// The brain of the download subsystem: the in-memory read model (`downloadIndex`, +/// `userFolders`) fused with scheduling (`activeGalleryID`, `activeTask`, queued +/// modes / selections). It is one of three types the old monolith was split into by +/// invariant ownership, alongside `DownloadStore` (pure disk I/O) and +/// `DownloadObserverHub` (observer fan-out), all behind the unchanged `DownloadClient` +/// facade. Read model and scheduling stay fused on purpose: only one gallery downloads at +/// a time (E-Hentai rate-limits gallery downloads, so concurrency is unwanted), and +/// scheduling reads and writes the index on every step, so splitting them would buy nothing +/// and reintroduce the cross-actor races this single actor exists to prevent. +public actor DownloadCoordinator { + public static let retryLimit = 3 + public static let progressFlushPageInterval = 8 + public static let progressFlushMinimumInterval: TimeInterval = 0.4 + public static let responseInspectionPrefixLength = 4096 + public static let kokomadeImageURLSuffixes = [ + "exhentai.org/img/kokomade.jpg" + ] + public static let quotaExceededImageURLSuffixes = [ + "exhentai.org/img/509.gif", + "ehgt.org/g/509.gif" + ] + + public struct PageResult: Sendable { + public let index: Int + public let relativePath: String + public let imageURL: URL? + + public init(index: Int, relativePath: String, imageURL: URL?) { + self.index = index + self.relativePath = relativePath + self.imageURL = imageURL + } + } + + public struct PageFailure: Error, Sendable { + public let index: Int + public let relativePath: String? + public let error: AppError + + public init(index: Int, relativePath: String?, error: AppError) { + self.index = index + self.relativePath = relativePath + self.error = error + } + } + + public struct DownloadBatchResult: Sendable { + public let pages: [PageResult] + public let failedPages: [PageFailure] + public init( + pages: [PageResult], + failedPages: [PageFailure] + ) { + self.pages = pages + self.failedPages = failedPages + } + } + + public enum PageTaskOutcome: Sendable { + case success(PageResult) + case failure(PageFailure) + case cancelled + } + + public struct RepairSeed: Sendable { + public let folderURL: URL + public let manifest: DownloadManifest + public init( + folderURL: URL, + manifest: DownloadManifest + ) { + self.folderURL = folderURL + self.manifest = manifest + } + } + + public struct WorkingSeed: Sendable { + public let folderURL: URL + public let manifest: DownloadManifest + public let existingPages: [Int: String] + public let coverRelativePath: String? + public init( + folderURL: URL, + manifest: DownloadManifest, + existingPages: [Int: String], + coverRelativePath: String? = nil + ) { + self.folderURL = folderURL + self.manifest = manifest + self.existingPages = existingPages + self.coverRelativePath = coverRelativePath + } + } + + public enum ResolvedSource: Sendable { + case normal([Int: URL]) + case mpv(String, [Int: String]) + } + + public struct ResolvedImageSource: Sendable { + public let imageURL: URL + public var mpvSkipServerIdentifier: String? + public init( + imageURL: URL, + mpvSkipServerIdentifier: String? = nil + ) { + self.imageURL = imageURL + self.mpvSkipServerIdentifier = mpvSkipServerIdentifier + } + } + + public struct PartialDownloadError: Error, Sendable { + public let failedPages: [PageFailure] + public init( + failedPages: [PageFailure] + ) { + self.failedPages = failedPages + } + } + + public struct IncompleteDownloadError: Error, Sendable { + public let missingPageIndices: [Int] + public init( + missingPageIndices: [Int] + ) { + self.missingPageIndices = missingPageIndices + } + } + + public struct FailureContext: Sendable { + public let gid: String + public let originalDownload: DownloadedGallery + public let mode: DownloadStartMode + public init( + gid: String, + originalDownload: DownloadedGallery, + mode: DownloadStartMode + ) { + self.gid = gid + self.originalDownload = originalDownload + self.mode = mode + } + } + + public struct ProgressFlushContext: Sendable { + public let gid: String + public let folderURL: URL + + public init(gid: String, folderURL: URL) { + self.gid = gid + self.folderURL = folderURL + } + } + + public struct PageDownloadContext: Sendable { + public let payload: DownloadRequestPayload + public let options: DownloadRequestOptions + public let source: ResolvedSource? + public let folderURL: URL + public init( + payload: DownloadRequestPayload, + options: DownloadRequestOptions, + source: ResolvedSource? = nil, + folderURL: URL + ) { + self.payload = payload + self.options = options + self.source = source + self.folderURL = folderURL + } + } + + public struct CacheRestoreSource: Sendable { + public let gid: String + public let token: String + public let cacheURLs: [URL?] + public let referenceURL: URL? + public let imageURL: URL? + public init( + gid: String, + token: String, + cacheURLs: [URL?], + referenceURL: URL? = nil, + imageURL: URL? = nil + ) { + self.gid = gid + self.token = token + self.cacheURLs = cacheURLs + self.referenceURL = referenceURL + self.imageURL = imageURL + } + } + + public struct CaptureTargetResult: Sendable { + public let folderURL: URL + public let preferredRelativePath: String? + public init( + folderURL: URL, + preferredRelativePath: String? = nil + ) { + self.folderURL = folderURL + self.preferredRelativePath = preferredRelativePath + } + } + + public struct HTMLResponseContext { + public let prefixData: Data + public let fullData: Data? + public let response: URLResponse + public let requestURL: URL? + public let mimeType: String? + public init( + prefixData: Data, + fullData: Data? = nil, + response: URLResponse, + requestURL: URL? = nil, + mimeType: String? = nil + ) { + self.prefixData = prefixData + self.fullData = fullData + self.response = response + self.requestURL = requestURL + self.mimeType = mimeType + } + } + + public struct DownloadExecutionContext: Sendable { + public let payload: DownloadRequestPayload + public let options: DownloadRequestOptions + public let existingDownload: DownloadedGallery + public init( + payload: DownloadRequestPayload, + options: DownloadRequestOptions, + existingDownload: DownloadedGallery + ) { + self.payload = payload + self.options = options + self.existingDownload = existingDownload + } + } + + public struct FinalizeContext: Sendable { + public let coverRelativePath: String? + public let batchResult: DownloadBatchResult + public let existingDownload: DownloadedGallery + public init( + coverRelativePath: String? = nil, + batchResult: DownloadBatchResult, + existingDownload: DownloadedGallery + ) { + self.coverRelativePath = coverRelativePath + self.batchResult = batchResult + self.existingDownload = existingDownload + } + } + + public let storage: DownloadStore + public let urlSession: URLSession + public let pageDownloader: DownloadPageDownloader + public let backgroundTaskStore: DownloadBackgroundTaskStore + public let backgroundTaskClient: BackgroundTaskClient + public let storedCookiesProvider: @Sendable (URL) -> [HTTPCookie] + public let libraryClient: LibraryClient + /// Supplies the latest runtime settings immediately before a queued download starts. + /// + /// Options are not stored in manifests or request payloads so settings changed while + /// a gallery is queued apply to the eventual detail fetch and page workers. + public let downloadOptionsProvider: @Sendable () async -> DownloadRequestOptions + public let queueStore: DownloadQueueStore + public let taskRunner: DownloadTaskRunner + public let observerHub = DownloadObserverHub() + /// Write-through cache of the on-disk download tree and the read authority between the + /// explicit scan boundaries (see `indexedDownload(gid:)`). The filesystem stays the + /// source of truth, so this is rebuilt from disk only at those boundaries, never on a + /// hot lookup. + public var downloadIndex = [String: DownloadFolderRecord]() + public var hasLoadedIndex = false + public var userFolders = [String]() + /// Transient, session-scoped status: deliberately in-memory only, never written to disk. + /// Download-level errors, per-page failures, validation results, and the update-available + /// set are status *about* a download, not durable properties of it; they are cheap to + /// re-derive and re-derivation yields the *current* truth (e.g. a lifted quota simply + /// succeeds on the next attempt). Durable facts (downloaded pages, hashes, metadata) + /// live in the manifest. The accepted cost is that after relaunch a failed download + /// surfaces as inactive ("Paused") until its error re-surfaces on the next manual retry. + public var downloadErrors = [String: DownloadFailure]() + public var validationErrors = [String: DownloadFailure]() + public var failedPageErrors = [String: [Int: PageFailure]]() + public var updatedGalleryIDs = Set() + public var queuedModes = [String: DownloadStartMode]() + public var queuedPageSelections = [String: [Int]]() + public var activeGalleryID: String? + public var activeTask: Task? + public var activeTaskGeneration = 0 + public var schedulingBlockedGalleryIDs = Set() + public var backgroundAssertionToken: BackgroundTaskToken? + /// Set synchronously across the `begin` MainActor hop so a concurrent reconcile + /// cannot issue a second assertion before the first token is recorded. + public var isBeginningBackgroundAssertion = false + + public init( + storage: DownloadStore, + urlSession: URLSession, + pageDownloader: DownloadPageDownloader? = nil, + backgroundTaskStore: DownloadBackgroundTaskStore? = nil, + backgroundTaskClient: BackgroundTaskClient = .noop, + storedCookiesProvider: @escaping @Sendable (URL) -> [HTTPCookie] = { + HTTPCookieStorage.shared.cookies(for: $0) ?? [] + }, + libraryClient: LibraryClient = .live, + downloadOptionsProvider: @escaping @Sendable () async -> DownloadRequestOptions = { + DownloadRequestOptions() + }, + queueStore: DownloadQueueStore? = nil, + taskRunner: DownloadTaskRunner = .init() + ) { + self.storage = storage + self.urlSession = urlSession + self.pageDownloader = pageDownloader ?? .foreground(urlSession: urlSession) + self.backgroundTaskStore = backgroundTaskStore ?? DownloadBackgroundTaskStore( + fileURL: storage.backgroundTaskRegistryURL() + ) + self.backgroundTaskClient = backgroundTaskClient + self.storedCookiesProvider = storedCookiesProvider + self.libraryClient = libraryClient + self.downloadOptionsProvider = downloadOptionsProvider + self.queueStore = queueStore ?? DownloadQueueStore(fileURL: storage.queueURL()) + self.taskRunner = taskRunner + } + + public var fileManager: DownloadFileManager { + storage.fileManager + } +} + +/// Owns the observer continuations and the last snapshot broadcast to them, kept apart from +/// the coordinator's state so notification can never interleave with a state mutation. The +/// coordinator computes a snapshot and hands it here to fan out; this type holds no download +/// state of its own. +public actor DownloadObserverHub { + private var lastObservedDownloads = [DownloadedGallery]() + private var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() + private var notifyGeneration = 0 + + public init() {} + + public func observe( + snapshot: @Sendable () async -> [DownloadedGallery] + ) async -> AsyncStream<[DownloadedGallery]> { + let identifier = UUID() + let (stream, continuation) = AsyncStream.makeStream( + of: [DownloadedGallery].self + ) + // Register before the snapshot resolves so a `notify` landing while the + // snapshot is in flight reaches this observer instead of being missed. + observers[identifier] = continuation + continuation.onTermination = { [weak self] _ in + guard let self else { return } + Task { + await self.removeObserver(id: identifier) + } + } + + let generationBeforeSnapshot = notifyGeneration + let initialDownloads = await snapshot() + if notifyGeneration == generationBeforeSnapshot { + // No notify reached this observer during resolution; deliver the snapshot. + continuation.yield(initialDownloads) + } + // Otherwise a fresher value already arrived via notify; skipping the now-stale + // snapshot keeps emissions ordered newest-last. + return stream + } + + public func notify(_ downloads: [DownloadedGallery]) { + guard downloads != lastObservedDownloads else { return } + lastObservedDownloads = downloads + notifyGeneration += 1 + observers.values.forEach { $0.yield(downloads) } + } + + private func removeObserver(id: UUID) { + observers[id] = nil + } +} + +extension DownloadCoordinator { + public func clearDownloadFailureState( + gid: String, + includePageFailures: Bool = true + ) { + downloadErrors[gid] = nil + validationErrors[gid] = nil + if includePageFailures { + failedPageErrors[gid] = nil + } + } + + public func clearDownloadQueueIntent(gid: String) { + queuedModes[gid] = nil + queuedPageSelections[gid] = nil + } + + public func clearDownloadSessionState( + gid: String, + includePageFailures: Bool = true, + includeUpdateFlag: Bool = false + ) { + clearDownloadFailureState( + gid: gid, + includePageFailures: includePageFailures + ) + clearDownloadQueueIntent(gid: gid) + if includeUpdateFlag { + updatedGalleryIDs.remove(gid) + } + } +} diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift similarity index 94% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift index 265677af7..31a38bf50 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Networking.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift @@ -5,7 +5,7 @@ import SDWebImageExt // MARK: - Network extension DownloadCoordinator { - func downloadResponse( + public func downloadResponse( url: URL, allowsCellular: Bool, retriesRequest: Bool = true @@ -18,7 +18,7 @@ extension DownloadCoordinator { ) } - func downloadResponse( + public func downloadResponse( for request: URLRequest, retriesRequest: Bool = true ) async throws -> (URL, URLResponse) { @@ -54,7 +54,7 @@ extension DownloadCoordinator { return response } - func dataResponse( + public func dataResponse( for request: URLRequest, retriesRequest: Bool = true ) async throws -> (Data, URLResponse) { @@ -71,7 +71,7 @@ extension DownloadCoordinator { return try await rawDataResponse(for: request) } - func rawDataResponse( + public func rawDataResponse( for request: URLRequest ) async throws -> (Data, URLResponse) { do { @@ -94,7 +94,7 @@ extension DownloadCoordinator { } } - func rawDownloadResponse( + public func rawDownloadResponse( for request: URLRequest ) async throws -> (URL, URLResponse) { do { @@ -117,7 +117,7 @@ extension DownloadCoordinator { } } - func pageDownloadResponse( + public func pageDownloadResponse( url: URL, allowsCellular: Bool, context: DownloadPageTaskContext, @@ -132,7 +132,7 @@ extension DownloadCoordinator { ) } - func pageDownloadResponse( + public func pageDownloadResponse( for request: URLRequest, context: DownloadPageTaskContext, retriesRequest: Bool = true @@ -175,7 +175,7 @@ extension DownloadCoordinator { return transfer } - func rawPageDownloadResponse( + public func rawPageDownloadResponse( for request: URLRequest, context: DownloadPageTaskContext ) async throws -> DownloadPageTransfer { @@ -199,7 +199,7 @@ extension DownloadCoordinator { } } - func withRetry( + public func withRetry( operation: String, context: [String: Any], maxAttempts: Int = retryLimit, @@ -246,7 +246,7 @@ extension DownloadCoordinator { // MARK: - File Operations extension DownloadCoordinator { - func fileExtension( + public func fileExtension( for url: URL, response: URLResponse?, prefixData: Data @@ -282,7 +282,7 @@ extension DownloadCoordinator { } } - func createDirectory(at url: URL) throws { + public func createDirectory(at url: URL) throws { try fileManager.operate { try $0.createDirectory( at: url, @@ -291,12 +291,12 @@ extension DownloadCoordinator { } } - func write(data: Data, to url: URL) throws { + public func write(data: Data, to url: URL) throws { try createDirectory(at: url.deletingLastPathComponent()) try data.write(to: url, options: .atomic) } - func moveDownloadedFile( + public func moveDownloadedFile( from sourceURL: URL, to destinationURL: URL ) throws { @@ -313,7 +313,7 @@ extension DownloadCoordinator { } } - func readResponsePrefixData(at fileURL: URL) throws -> Data { + public func readResponsePrefixData(at fileURL: URL) throws -> Data { let handle = try FileHandle(forReadingFrom: fileURL) defer { try? handle.close() } return try handle.read( diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownload.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PageDownload.swift similarity index 99% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownload.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+PageDownload.swift index dbdd423fa..ccdf36943 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownload.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PageDownload.swift @@ -16,7 +16,7 @@ extension DownloadCoordinator { var didAbortForFatalError = false } - func downloadPages( + public func downloadPages( context: PageDownloadContext, pendingPageIndices: [Int], existingManifest: DownloadManifest, @@ -309,7 +309,7 @@ extension DownloadCoordinator { /// Gallery-level errors (`.expunged`, `.copyrightClaim`) are deliberately *not* fatal here — they /// mean the gallery is gone, but they surface before per-page download and are handled upstream, /// so a per-page occurrence is treated like any other page failure rather than aborting the batch. - func isFatalAccountAppError(_ error: AppError) -> Bool { + public func isFatalAccountAppError(_ error: AppError) -> Bool { switch error { case .quotaExceeded, .authenticationRequired, .ipBanned: return true diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownloadHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PageDownloadHelpers.swift similarity index 99% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownloadHelpers.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+PageDownloadHelpers.swift index 96528ee75..10078eb3e 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PageDownloadHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PageDownloadHelpers.swift @@ -3,7 +3,7 @@ import AppModels // MARK: - Download Single Page extension DownloadCoordinator { - func downloadPage( + public func downloadPage( index: Int, context: PageDownloadContext, preferredRelativePath: String? diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Persistence.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift similarity index 90% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Persistence.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift index 2e1d535c6..70762a9f4 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Persistence.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift @@ -4,7 +4,7 @@ import AppModels // MARK: - Disk Index extension DownloadCoordinator { @discardableResult - func reloadDownloadIndex() async -> [DownloadedGallery] { + public func reloadDownloadIndex() async -> [DownloadedGallery] { do { let scanResult = try storage.scanDownloads() downloadIndex = deduplicatedDownloadIndex(from: scanResult.records) @@ -23,13 +23,13 @@ extension DownloadCoordinator { /// The filesystem is the durable source of truth, but this actor's index is the read authority /// between explicit sync points. Hot lookups must not walk download folders; app launch, /// foreground return, pull-to-refresh, and targeted surprise repair are the scan boundaries. - func indexedDownload(gid: String) async -> DownloadedGallery? { + public func indexedDownload(gid: String) async -> DownloadedGallery? { guard hasLoadedIndex else { return nil } guard let record = downloadIndex[gid] else { return nil } return downloadedGallery(from: record) } - func indexedDownloads() async -> [DownloadedGallery] { + public func indexedDownloads() async -> [DownloadedGallery] { guard hasLoadedIndex else { return [] } return await downloads(from: Array(downloadIndex.values)) } @@ -44,7 +44,7 @@ extension DownloadCoordinator { .sorted(by: sortDownloadsByDisplayStatus) } - func indexedDownloads(gids: [String]) async -> [DownloadedGallery] { + public func indexedDownloads(gids: [String]) async -> [DownloadedGallery] { guard hasLoadedIndex else { return [] } let gidSet = Set(gids) return await downloads( @@ -129,18 +129,18 @@ private extension DownloadFolderRecord { // MARK: - Store Operations extension DownloadCoordinator { - func fetchDownload( + public func fetchDownload( gid: String ) async -> DownloadedGallery? { return await indexedDownload(gid: gid) } - func fetchDownloadsFromStore() async -> [DownloadedGallery] { + public func fetchDownloadsFromStore() async -> [DownloadedGallery] { return await reloadDownloadIndex() } @discardableResult - func reloadDownloadRecord(gid: String, token: String) async -> DownloadedGallery? { + public func reloadDownloadRecord(gid: String, token: String) async -> DownloadedGallery? { let records = storage.galleryFolderRecords(gid: gid, token: token) guard let record = deduplicatedDownloadIndex(from: records).values.first else { downloadIndex[gid] = nil @@ -159,7 +159,7 @@ extension DownloadCoordinator { // MARK: - Persist Failure & Progress extension DownloadCoordinator { - func persistFailure( + public func persistFailure( error: AppError, context: FailureContext ) async { @@ -170,13 +170,13 @@ extension DownloadCoordinator { /// Surfaces a download-level failure and clears its queue intent so it does not /// auto-resume. Shared by the foreground `persistFailure` and the background/orphan /// fatal-error paths so a fatal 509/auth/ban settles identically either way. - func settleDownloadFailure(gid: String, error: AppError) async { + public func settleDownloadFailure(gid: String, error: AppError) async { downloadErrors[gid] = DownloadFailure(error: error) clearDownloadQueueIntent(gid: gid) await queueStore.remove(gid) } - func flushDownloadProgress( + public func flushDownloadProgress( context: ProgressFlushContext, pendingResolvedPages: inout [PageResult], lastFlushDate: inout Date, @@ -200,7 +200,7 @@ extension DownloadCoordinator { await notifyObservers() } - func flushManifestPageProgress( + public func flushManifestPageProgress( folderURL: URL, pages: [PageResult] ) throws { @@ -222,7 +222,7 @@ extension DownloadCoordinator { updateDownloadIndex(folderURL: folderURL, manifest: manifest) } - func updateDownloadIndex(folderURL: URL, manifest: DownloadManifest) { + public func updateDownloadIndex(folderURL: URL, manifest: DownloadManifest) { downloadIndex[manifest.gid] = storage.galleryFolderRecord( folderURL: folderURL, manifest: manifest, diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceHelpers.swift similarity index 95% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceHelpers.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+PersistenceHelpers.swift index 3d2e54420..89b8a883b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceHelpers.swift @@ -4,7 +4,7 @@ import AppModels // MARK: - Sanitization extension DownloadCoordinator { @discardableResult - func sanitizeLocalFilesIfNeeded( + public func sanitizeLocalFilesIfNeeded( gid: String, clearingLastError: Bool = false ) async -> DownloadedGallery? { @@ -39,7 +39,7 @@ extension DownloadCoordinator { ) } - func captureTarget( + public func captureTarget( for download: DownloadedGallery, index: Int ) -> CaptureTargetResult? { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceNormalize.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceNormalize.swift similarity index 90% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceNormalize.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+PersistenceNormalize.swift index a9837498d..58167319d 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PersistenceNormalize.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceNormalize.swift @@ -3,7 +3,7 @@ import AppModels // MARK: - Manifest, Folder & Normalize extension DownloadCoordinator { - func validatedManifest( + public func validatedManifest( at folderURL: URL, gid: String, pageCount: Int @@ -18,7 +18,7 @@ extension DownloadCoordinator { return manifest } - func activeInspectionFolderURL( + public func activeInspectionFolderURL( for download: DownloadedGallery ) -> URL? { let completedFolderURL = download.folderURL @@ -28,7 +28,7 @@ extension DownloadCoordinator { return completedFolderExists ? completedFolderURL : nil } - func normalizeNeedsAttentionDownloads( + public func normalizeNeedsAttentionDownloads( _ downloads: [DownloadedGallery] ) async { for download in downloads { @@ -46,7 +46,7 @@ extension DownloadCoordinator { } } - func normalizeInterruptedDownloads( + public func normalizeInterruptedDownloads( _ downloads: [DownloadedGallery] ) async { let hasActiveTask = activeTask != nil @@ -62,7 +62,7 @@ extension DownloadCoordinator { } } - func reconcileActiveDownloadState() async { + public func reconcileActiveDownloadState() async { guard activeTask != nil, let activeGalleryID, await fetchDownload(gid: activeGalleryID) != nil @@ -76,7 +76,7 @@ extension DownloadCoordinator { /// (`verifiesContentHashes: true`). Routine scans and opens check file *presence* only; /// automatic content re-validation was removed because it re-hashed whole galleries on /// hot paths. The result is session-scoped status (`validationErrors`), not persisted. - func validateImageData(gid: String) async -> DownloadValidationState? { + public func validateImageData(gid: String) async -> DownloadValidationState? { guard let download = await fetchDownload(gid: gid), download.canValidateImageData else { return nil } diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift similarity index 94% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift index 31b42fff6..9d887aac5 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPI.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift @@ -4,7 +4,7 @@ import Resources // MARK: - Public API extension DownloadCoordinator { - func observeDownloads() async -> AsyncStream<[DownloadedGallery]> { + public func observeDownloads() async -> AsyncStream<[DownloadedGallery]> { // Let the hub pull the snapshot inside its own registration so the // capture-then-register hop can't strand a new observer on a stale // initial when a notify lands in the window (BUG-16). @@ -13,23 +13,23 @@ extension DownloadCoordinator { } } - func fetchDownloads() async -> [DownloadedGallery] { + public func fetchDownloads() async -> [DownloadedGallery] { return await indexedDownloads() } - func reconcileDownloads() async { + public func reconcileDownloads() async { await syncDownloadsState(scheduleNext: false) } - func refreshDownloads() async { + public func refreshDownloads() async { await syncDownloadsState(scheduleNext: true) } - func resumeQueue() async { + public func resumeQueue() async { await scheduleNextIfNeeded() } - func updateRemoteVersion( + public func updateRemoteVersion( gid: String, metadata: DownloadVersionMetadata ) async -> DownloadedGallery? { @@ -56,7 +56,7 @@ extension DownloadCoordinator { return await fetchDownload(gid: gid) } - func enqueue( + public func enqueue( payload: DownloadRequestPayload ) async -> Result { do { @@ -134,7 +134,7 @@ extension DownloadCoordinator { return manifest } - func togglePause(gid: String) async -> Result { + public func togglePause(gid: String) async -> Result { guard let download = await fetchDownload(gid: gid) else { return .failure(.notFound) } @@ -157,7 +157,7 @@ extension DownloadCoordinator { } } - func delete(gid: String) async -> Result { + public func delete(gid: String) async -> Result { let taskToCancel: Task? schedulingBlockedGalleryIDs.insert(gid) defer { @@ -199,7 +199,7 @@ extension DownloadCoordinator { return .success(()) } - func loadManifest( + public func loadManifest( gid: String ) async -> Result<(DownloadedGallery, DownloadManifest), AppError> { guard let download = await sanitizeLocalFilesIfNeeded(gid: gid) else { @@ -222,7 +222,7 @@ extension DownloadCoordinator { } } - func captureCachedPage( + public func captureCachedPage( gid: String, index: Int, imageURL: URL? @@ -286,7 +286,7 @@ extension DownloadCoordinator { } } - func loadInspection( + public func loadInspection( gid: String ) async -> Result { guard let download = await fetchDownload(gid: gid) else { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPIHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPIHelpers.swift similarity index 95% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPIHelpers.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+PublicAPIHelpers.swift index 14af59db4..3ae402ca2 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+PublicAPIHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPIHelpers.swift @@ -3,7 +3,7 @@ import AppModels // MARK: - Private helpers for public API extension DownloadCoordinator { - func buildInspectionPages( + public func buildInspectionPages( download: DownloadedGallery, activeFolderURL: URL?, existingRelativePaths: [Int: String], @@ -45,7 +45,7 @@ extension DownloadCoordinator { } } - func clearSelectedFailedPages( + public func clearSelectedFailedPages( gid: String, selectedPageIndices: [Int], ) { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift similarity index 98% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift index 34216c31e..0695db39c 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidation.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift @@ -8,7 +8,7 @@ import Parser // MARK: - Response Error Detection extension DownloadCoordinator { - func detectResponseError( + public func detectResponseError( data: Data, response: URLResponse, requestURL: URL?, @@ -25,7 +25,7 @@ extension DownloadCoordinator { ) } - func detectResponseError( + public func detectResponseError( fileURL: URL, response: URLResponse, requestURL: URL? @@ -125,7 +125,7 @@ extension DownloadCoordinator { return placeholderData } - func detectResponseError( + public func detectResponseError( prefixData: Data, fullData: Data?, response: URLResponse, diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift similarity index 97% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift index 975995c1a..afffd5498 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift @@ -9,7 +9,7 @@ import Parser // MARK: - Response Inspection Helpers extension DownloadCoordinator { - func normalizedMimeType( + public func normalizedMimeType( _ response: URLResponse ) -> String? { if let mimeType = response.mimeType?.lowercased(), @@ -28,7 +28,7 @@ extension DownloadCoordinator { return nil } - func shouldInspectTextResponse( + public func shouldInspectTextResponse( mimeType: String?, prefixData: Data ) -> Bool { @@ -49,7 +49,7 @@ extension DownloadCoordinator { return true } - func prefixLooksLikeHTML(_ prefixData: Data) -> Bool { + public func prefixLooksLikeHTML(_ prefixData: Data) -> Bool { let prefix = String( bytes: prefixData, encoding: .utf8 @@ -67,7 +67,7 @@ extension DownloadCoordinator { return htmlMarkers.contains(where: prefix.contains) } - func prefixLooksLikeJSON(_ prefixData: Data) -> Bool { + public func prefixLooksLikeJSON(_ prefixData: Data) -> Bool { let prefix = String( bytes: prefixData, encoding: .utf8 diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+RetryHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift similarity index 96% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+RetryHelpers.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift index 9e3fde3b9..ff2a79727 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+RetryHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift @@ -3,7 +3,7 @@ import AppModels // MARK: - Retry & RetryPages extension DownloadCoordinator { - func retry( + public func retry( gid: String, mode: DownloadStartMode ) async -> Result { @@ -37,7 +37,7 @@ extension DownloadCoordinator { await scheduleNextIfNeeded() } - func retryPages( + public func retryPages( gid: String, pageIndices: [Int] ) async -> Result { @@ -87,7 +87,7 @@ extension DownloadCoordinator { await scheduleNextIfNeeded() } - func loadLocalPageURLs( + public func loadLocalPageURLs( gid: String ) async -> Result<[Int: URL], AppError> { guard let download = await fetchDownload(gid: gid) else { @@ -96,7 +96,7 @@ extension DownloadCoordinator { return .success(download.localPageURLs) } - func rescanLocalPageURLs( + public func rescanLocalPageURLs( gid: String ) async -> [Int: URL]? { guard let token = downloadIndex[gid]?.manifest.token else { return nil } diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Scheduling.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift similarity index 94% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Scheduling.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift index fb054772e..4e96a5b04 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Scheduling.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift @@ -3,12 +3,12 @@ import AppModels // MARK: - Observer Management & Scheduling extension DownloadCoordinator { - func notifyObservers() async { + public func notifyObservers() async { let downloads = await indexedDownloads() await observerHub.notify(downloads) } - func scheduleNextIfNeeded() async { + public func scheduleNextIfNeeded() async { await scheduleNextIfNeededCore() // Reconcile on every exit path of the core (both early-return guards and the // happy path), so the background-task assertion always matches queue state. @@ -100,7 +100,7 @@ extension DownloadCoordinator { && shouldSchedule(download: download) } - func shouldSchedule(download: DownloadedGallery) -> Bool { + public func shouldSchedule(download: DownloadedGallery) -> Bool { if download.displayStatus == .active || download.isQueuedWorkItem { return true } @@ -112,7 +112,7 @@ extension DownloadCoordinator { return queuedPageSelections[download.gid]?.isEmpty == false } - func syncDownloadsState(scheduleNext: Bool) async { + public func syncDownloadsState(scheduleNext: Bool) async { let downloads = await fetchDownloadsFromStore() await normalizeNeedsAttentionDownloads(downloads) await normalizeInterruptedDownloads(downloads) @@ -131,7 +131,7 @@ extension DownloadCoordinator { // MARK: - Pause & Resume extension DownloadCoordinator { - func pause(gid: String) async -> Result { + public func pause(gid: String) async -> Result { do { schedulingBlockedGalleryIDs.insert(gid) defer { @@ -195,7 +195,7 @@ extension DownloadCoordinator { await backgroundTaskStore.removeAll(for: gid) } - func cancelQueuedWorkItem( + public func cancelQueuedWorkItem( _ download: DownloadedGallery, mode: DownloadStartMode ) async -> Result { @@ -212,7 +212,7 @@ extension DownloadCoordinator { return .success(()) } - func resume(gid: String) async -> Result { + public func resume(gid: String) async -> Result { guard let download = await fetchDownload(gid: gid) else { return .failure(.notFound) } diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+SchedulingHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+SchedulingHelpers.swift similarity index 96% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+SchedulingHelpers.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+SchedulingHelpers.swift index eea0fb3d4..9d2766e99 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+SchedulingHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+SchedulingHelpers.swift @@ -3,7 +3,7 @@ import AppModels // MARK: - Mode Resolution extension DownloadCoordinator { - func queuedMode( + public func queuedMode( for download: DownloadedGallery ) -> DownloadStartMode { if let mode = queuedModes[download.gid] { @@ -35,7 +35,7 @@ extension DownloadCoordinator { } } - func resumeMode( + public func resumeMode( for download: DownloadedGallery ) -> DownloadStartMode { if download.hasUpdate { @@ -66,7 +66,7 @@ extension DownloadCoordinator { download.completedPageCount == 0 ? .initial : .repair } - func effectiveRetryMode( + public func effectiveRetryMode( for download: DownloadedGallery, requestedMode: DownloadStartMode ) -> DownloadStartMode { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Testing.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Testing.swift similarity index 63% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Testing.swift rename to AppPackage/Sources/DownloadClient/DownloadClient+Testing.swift index 3ceb7c37b..50044bdf8 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient+Testing.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Testing.swift @@ -3,7 +3,7 @@ import AppModels #if DEBUG extension DownloadCoordinator { - func testingInstallActiveTask( + public func testingInstallActiveTask( gid: String, task: Task ) { @@ -12,25 +12,25 @@ extension DownloadCoordinator { activeTask = task } - func testingSetActiveGalleryID(_ gid: String?) { + public func testingSetActiveGalleryID(_ gid: String?) { activeGalleryID = gid } - func testingSetQueuedGalleryIDs(_ gids: [String]) async { + public func testingSetQueuedGalleryIDs(_ gids: [String]) async { await queueStore.removeAll() for gid in gids { await queueStore.enqueue(gid) } } - func testingSetDownloadError( + public func testingSetDownloadError( _ failure: DownloadFailure?, gid: String ) { downloadErrors[gid] = failure } - func testingSetFailedPageErrors( + public func testingSetFailedPageErrors( _ failures: [PageFailure], gid: String ) { @@ -39,19 +39,19 @@ extension DownloadCoordinator { ) } - func testingSetUpdatedGalleryIDs(_ gids: Set) { + public func testingSetUpdatedGalleryIDs(_ gids: Set) { updatedGalleryIDs = gids } - func testingHasActiveTask() -> Bool { + public func testingHasActiveTask() -> Bool { activeTask != nil } - func testingActiveGalleryID() -> String? { + public func testingActiveGalleryID() -> String? { activeGalleryID } - func testingHasBackgroundAssertion() -> Bool { + public func testingHasBackgroundAssertion() -> Bool { backgroundAssertionToken != nil } } diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift b/AppPackage/Sources/DownloadClient/DownloadClient.swift similarity index 74% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift rename to AppPackage/Sources/DownloadClient/DownloadClient.swift index 68cc6636b..45ea66ab6 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadClient.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient.swift @@ -5,36 +5,36 @@ import Utilities import DatabaseClient @DependencyClient -struct DownloadClient: Sendable { - var observeDownloads: @Sendable () -> AsyncStream<[DownloadedGallery]> = { AsyncStream { $0.finish() } } - var fetchDownloads: @Sendable () async throws -> [DownloadedGallery] - var fetchDownload: @Sendable (String) async -> DownloadedGallery? - var reconcileDownloads: @Sendable () async -> Void - var refreshDownloads: @Sendable () async -> Void - var validateImageData: @Sendable (String) async -> DownloadValidationState? - var fetchVersionMetadata: @Sendable (String, String) async -> DownloadVersionMetadata? - var updateRemoteVersion: @Sendable (String, DownloadVersionMetadata) async -> DownloadedGallery? - var enqueue: @Sendable (DownloadRequestPayload) async throws -> Void - var togglePause: @Sendable (String) async throws -> Void - var retry: @Sendable (String, DownloadStartMode) async throws -> Void - var retryPages: @Sendable (String, [Int]) async throws -> Void - var delete: @Sendable (String) async throws -> Void - var loadManifest: @Sendable (String) async throws -> (DownloadedGallery, DownloadManifest) - var loadLocalPageURLs: @Sendable (String) async -> [Int: URL]? - var rescanLocalPageURLs: @Sendable (String) async -> [Int: URL]? - var captureCachedPage: @Sendable (String, Int, URL?) async -> Void - var loadInspection: @Sendable (String) async throws -> DownloadInspection - var fetchFolders: @Sendable () async throws -> [String] - var createFolder: @Sendable (String) async throws -> Void - var renameFolder: @Sendable (String, String) async throws -> Void - var deleteFolder: @Sendable (String) async throws -> Void - var moveDownload: @Sendable (String, String) async throws -> Void - var hasPendingWork: @Sendable () async -> Bool = { false } - var runBackgroundProcessing: @Sendable () async -> Void +public struct DownloadClient: Sendable { + public var observeDownloads: @Sendable () -> AsyncStream<[DownloadedGallery]> = { AsyncStream { $0.finish() } } + public var fetchDownloads: @Sendable () async throws -> [DownloadedGallery] + public var fetchDownload: @Sendable (String) async -> DownloadedGallery? + public var reconcileDownloads: @Sendable () async -> Void + public var refreshDownloads: @Sendable () async -> Void + public var validateImageData: @Sendable (String) async -> DownloadValidationState? + public var fetchVersionMetadata: @Sendable (String, String) async -> DownloadVersionMetadata? + public var updateRemoteVersion: @Sendable (String, DownloadVersionMetadata) async -> DownloadedGallery? + public var enqueue: @Sendable (DownloadRequestPayload) async throws -> Void + public var togglePause: @Sendable (String) async throws -> Void + public var retry: @Sendable (String, DownloadStartMode) async throws -> Void + public var retryPages: @Sendable (String, [Int]) async throws -> Void + public var delete: @Sendable (String) async throws -> Void + public var loadManifest: @Sendable (String) async throws -> (DownloadedGallery, DownloadManifest) + public var loadLocalPageURLs: @Sendable (String) async -> [Int: URL]? + public var rescanLocalPageURLs: @Sendable (String) async -> [Int: URL]? + public var captureCachedPage: @Sendable (String, Int, URL?) async -> Void + public var loadInspection: @Sendable (String) async throws -> DownloadInspection + public var fetchFolders: @Sendable () async throws -> [String] + public var createFolder: @Sendable (String) async throws -> Void + public var renameFolder: @Sendable (String, String) async throws -> Void + public var deleteFolder: @Sendable (String) async throws -> Void + public var moveDownload: @Sendable (String, String) async throws -> Void + public var hasPendingWork: @Sendable () async -> Bool = { false } + public var runBackgroundProcessing: @Sendable () async -> Void } extension DownloadClient { - static func live( + public static func live( rootURL: URL = FileUtil.downloadsDirectoryURL, urlSession: URLSession = .shared, fileManager: sending FileManager = FileManager() @@ -147,14 +147,14 @@ extension DownloadClient { } // MARK: API -enum DownloadClientKey: DependencyKey { - static let liveValue = DownloadClient.live() - static let previewValue = DownloadClient.noop - static let testValue = DownloadClient() +public enum DownloadClientKey: DependencyKey { + public static let liveValue = DownloadClient.live() + public static let previewValue = DownloadClient.noop + public static let testValue = DownloadClient() } extension DependencyValues { - var downloadClient: DownloadClient { + public var downloadClient: DownloadClient { get { self[DownloadClientKey.self] } set { self[DownloadClientKey.self] = newValue } } @@ -162,7 +162,7 @@ extension DependencyValues { // MARK: Preview extension DownloadClient { - static let noop = Self( + public static let noop = Self( observeDownloads: { AsyncStream { $0.finish() } }, fetchDownloads: { [] }, fetchDownload: { _ in nil }, diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadFileManager.swift b/AppPackage/Sources/DownloadClient/DownloadFileManager.swift similarity index 68% rename from AppPackage/Sources/AppFeature/Tools/Utilities/DownloadFileManager.swift rename to AppPackage/Sources/DownloadClient/DownloadFileManager.swift index 60c9ec2b9..f89458221 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadFileManager.swift +++ b/AppPackage/Sources/DownloadClient/DownloadFileManager.swift @@ -1,14 +1,14 @@ import Foundation import Synchronization -final class DownloadFileManager: Sendable { +public final class DownloadFileManager: Sendable { private let fileManager: Mutex - init(_ fileManager: sending FileManager) { + public init(_ fileManager: sending FileManager) { self.fileManager = Mutex(fileManager) } - func operate( + public func operate( _ body: (inout sending FileManager) throws -> sending T ) rethrows -> sending T { try fileManager.withLock(body) diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadPageDownloader.swift b/AppPackage/Sources/DownloadClient/DownloadPageDownloader.swift similarity index 90% rename from AppPackage/Sources/AppFeature/Tools/Clients/DownloadPageDownloader.swift rename to AppPackage/Sources/DownloadClient/DownloadPageDownloader.swift index e8f08bc34..951dcf2be 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DownloadPageDownloader.swift +++ b/AppPackage/Sources/DownloadClient/DownloadPageDownloader.swift @@ -1,21 +1,42 @@ import Foundation import AppModels -struct DownloadPageTaskContext: Equatable, Sendable { - let gid: String - let pageIndex: Int +public struct DownloadPageTaskContext: Equatable, Sendable { + public let gid: String + public let pageIndex: Int + public init( + gid: String, + pageIndex: Int + ) { + self.gid = gid + self.pageIndex = pageIndex + } } -struct DownloadPageTransfer: Sendable { - let fileURL: URL - let response: URLResponse - let taskIdentifier: Int? +public struct DownloadPageTransfer: Sendable { + public let fileURL: URL + public let response: URLResponse + public let taskIdentifier: Int? + public init( + fileURL: URL, + response: URLResponse, + taskIdentifier: Int? = nil + ) { + self.fileURL = fileURL + self.response = response + self.taskIdentifier = taskIdentifier + } } -struct DownloadPageDownloader: Sendable { - var download: @Sendable (URLRequest, DownloadPageTaskContext) async throws -> DownloadPageTransfer +public struct DownloadPageDownloader: Sendable { + public var download: @Sendable (URLRequest, DownloadPageTaskContext) async throws -> DownloadPageTransfer + public init( + download: @escaping @Sendable (URLRequest, DownloadPageTaskContext) async throws -> DownloadPageTransfer + ) { + self.download = download + } - static func foreground(urlSession: URLSession) -> Self { + public static func foreground(urlSession: URLSession) -> Self { .init { request, _ in let (fileURL, response) = try await urlSession.download(for: request) return .init( @@ -26,7 +47,7 @@ struct DownloadPageDownloader: Sendable { } } - static func background( + public static func background( identifier: String, taskStore: DownloadBackgroundTaskStore, holdingDirectory: URL, @@ -48,14 +69,14 @@ struct DownloadPageDownloader: Sendable { } } -enum DownloadBackgroundSessionEvents { - static let pageSessionIdentifier: String = "app.ehpanda.downloads.pages" +public enum DownloadBackgroundSessionEvents { + public static let pageSessionIdentifier: String = "app.ehpanda.downloads.pages" @MainActor private static var completionHandlers = [String: () -> Void]() @MainActor - static func setCompletionHandler( + public static func setCompletionHandler( _ completionHandler: @escaping () -> Void, for identifier: String ) { @@ -63,7 +84,7 @@ enum DownloadBackgroundSessionEvents { } @MainActor - static func finishEvents(for identifier: String?) { + public static func finishEvents(for identifier: String?) { guard let identifier, let completionHandler = completionHandlers.removeValue( forKey: identifier diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadQueueStore.swift b/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift similarity index 74% rename from AppPackage/Sources/AppFeature/Tools/Utilities/DownloadQueueStore.swift rename to AppPackage/Sources/DownloadClient/DownloadQueueStore.swift index ebff86d4f..66c59a6d1 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadQueueStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift @@ -2,22 +2,22 @@ import ComposableArchitecture import AppModels import Foundation -struct DownloadQueueStore: Sendable { +public struct DownloadQueueStore: Sendable { private let identifiers: Shared<[String]> - init(fileURL: URL) { + public init(fileURL: URL) { identifiers = Shared(wrappedValue: [], .fileStorage(fileURL)) } - var gids: [String] { + public var gids: [String] { identifiers.wrappedValue } - func contains(_ gid: String) -> Bool { + public func contains(_ gid: String) -> Bool { identifiers.wrappedValue.contains(gid) } - func enqueue(_ gid: String) async { + public func enqueue(_ gid: String) async { identifiers.withLock { gids in guard !gids.contains(gid) else { return } gids.append(gid) @@ -25,14 +25,14 @@ struct DownloadQueueStore: Sendable { await save() } - func remove(_ gid: String) async { + public func remove(_ gid: String) async { identifiers.withLock { gids in gids.removeAll { $0 == gid } } await save() } - func removeAll() async { + public func removeAll() async { identifiers.withLock { gids in gids.removeAll() } diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+JSONCoding.swift b/AppPackage/Sources/DownloadClient/DownloadStore+JSONCoding.swift similarity index 55% rename from AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+JSONCoding.swift rename to AppPackage/Sources/DownloadClient/DownloadStore+JSONCoding.swift index 208b5ac2f..b16b3a4d2 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+JSONCoding.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore+JSONCoding.swift @@ -1,11 +1,11 @@ import Foundation extension DownloadStore { - func writeJSON(_ value: T, to url: URL) throws { + public func writeJSON(_ value: T, to url: URL) throws { try JSONEncoder().encode(value).write(to: url, options: .atomic) } - func readJSON(_ type: T.Type, from url: URL) throws -> T { + public func readJSON(_ type: T.Type, from url: URL) throws -> T { try JSONDecoder().decode(type, from: Data(contentsOf: url)) } } diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift similarity index 94% rename from AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift rename to AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift index 6338332e7..d88d6c350 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore+Operations.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift @@ -3,7 +3,7 @@ import AppModels import Resources extension DownloadStore { - func linkOrCopyReadableAsset(at sourceURL: URL, to destinationURL: URL) throws { + public func linkOrCopyReadableAsset(at sourceURL: URL, to destinationURL: URL) throws { guard sanitizeAssetFileIfNeeded(at: sourceURL) else { throw AppError.fileOperationFailed( L10n.Localizable.DownloadStore.Error.assetUnreadable(sourceURL.lastPathComponent) @@ -31,7 +31,7 @@ extension DownloadStore { } } - func materializeRepairSeed( + public func materializeRepairSeed( from sourceFolderURL: URL, manifest: DownloadManifest, to destinationFolderURL: URL @@ -75,7 +75,7 @@ extension DownloadStore { /// *merges*: it hashes a page solely when its recorded hash is empty, never re-hashing /// the whole gallery. There is no automatic re-validation anywhere; verifying existing /// bytes against their hashes is a user-initiated action (`validateImageData(gid:)`). - func addingCurrentFileHashes( + public func addingCurrentFileHashes( to manifest: DownloadManifest, folderURL: URL ) throws -> DownloadManifest { @@ -104,7 +104,7 @@ extension DownloadStore { } @discardableResult - func refreshManifestPageFileHash( + public func refreshManifestPageFileHash( folderURL: URL, pageIndex: Int, relativePath: String? = nil @@ -134,7 +134,7 @@ extension DownloadStore { } @discardableResult - func refreshManifestPageFileHashes( + public func refreshManifestPageFileHashes( folderURL: URL, pageRelativePaths: [Int: String] ) throws -> DownloadManifest { @@ -166,7 +166,7 @@ extension DownloadStore { } @discardableResult - func refreshManifestFileHashes(folderURL: URL) throws -> DownloadManifest { + public func refreshManifestFileHashes(folderURL: URL) throws -> DownloadManifest { let manifest = try readManifest(folderURL: folderURL) let hashedManifest = try addingCurrentFileHashes( to: manifest, @@ -178,12 +178,12 @@ extension DownloadStore { return hashedManifest } - func removeFolder(relativePath: String) throws { + public func removeFolder(relativePath: String) throws { let targetURL = folderURL(relativePath: relativePath) try removeFolder(at: targetURL) } - func removeFolder(at folderURL: URL) throws { + public func removeFolder(at folderURL: URL) throws { let targetURL = folderURL.standardizedFileURL guard targetURL.path.hasPrefix(rootURL.standardizedFileURL.path + "/") else { throw AppError.fileOperationFailed(targetURL.path) @@ -194,7 +194,7 @@ extension DownloadStore { } } - func validate( + public func validate( download: DownloadedGallery, verifiesContentHashes: Bool ) -> DownloadValidationState { @@ -219,7 +219,7 @@ extension DownloadStore { return .valid } - func validPageCount(folderURL: URL, manifest: DownloadManifest) -> Int { + public func validPageCount(folderURL: URL, manifest: DownloadManifest) -> Int { let existingPages = existingPageRelativePaths( folderURL: folderURL, manifest: manifest @@ -234,7 +234,7 @@ extension DownloadStore { } } - func isReadableAssetFile(at url: URL) -> Bool { + public func isReadableAssetFile(at url: URL) -> Bool { sanitizeAssetFileIfNeeded(at: url) } diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift b/AppPackage/Sources/DownloadClient/DownloadStore.swift similarity index 81% rename from AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift rename to AppPackage/Sources/DownloadClient/DownloadStore.swift index 40a0394c7..ab4eb15a5 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/DownloadStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore.swift @@ -4,24 +4,48 @@ import Resources import CryptoKit import Utilities -enum DownloadValidationState: Equatable, Sendable { +public enum DownloadValidationState: Equatable, Sendable { case valid case missingFiles(String) } -struct DownloadFolderRecord: Equatable, Sendable { - let relativePath: String - let folderURL: URL - let manifest: DownloadManifest - let localCoverURL: URL? - let localPageURLs: [Int: URL] - let modificationDate: Date? - let parentFolderName: String +public struct DownloadFolderRecord: Equatable, Sendable { + public let relativePath: String + public let folderURL: URL + public let manifest: DownloadManifest + public let localCoverURL: URL? + public let localPageURLs: [Int: URL] + public let modificationDate: Date? + public let parentFolderName: String + public init( + relativePath: String, + folderURL: URL, + manifest: DownloadManifest, + localCoverURL: URL? = nil, + localPageURLs: [Int: URL], + modificationDate: Date? = nil, + parentFolderName: String + ) { + self.relativePath = relativePath + self.folderURL = folderURL + self.manifest = manifest + self.localCoverURL = localCoverURL + self.localPageURLs = localPageURLs + self.modificationDate = modificationDate + self.parentFolderName = parentFolderName + } } -struct DownloadScanResult: Equatable, Sendable { - let records: [DownloadFolderRecord] - let userFolders: [String] +public struct DownloadScanResult: Equatable, Sendable { + public let records: [DownloadFolderRecord] + public let userFolders: [String] + public init( + records: [DownloadFolderRecord], + userFolders: [String] + ) { + self.records = records + self.userFolders = userFolders + } } /// Pure filesystem / manifest / hash I/O for downloads. The filesystem is the source of @@ -29,13 +53,13 @@ struct DownloadScanResult: Equatable, Sendable { /// in-memory state and is race-free by construction: every method reads or writes disk and /// returns. It is the I/O half of the download subsystem split; the `DownloadCoordinator` /// actor owns the mutable read model and scheduling on top of it. -struct DownloadStore: Sendable { +public struct DownloadStore: Sendable { private static let maxFolderComponentByteCount = 255 - let rootURL: URL - let fileManager: DownloadFileManager + public let rootURL: URL + public let fileManager: DownloadFileManager - init( + public init( rootURL: URL = FileUtil.downloadsDirectoryURL, fileManager: sending FileManager = FileManager() ) { @@ -43,7 +67,7 @@ struct DownloadStore: Sendable { self.fileManager = DownloadFileManager(fileManager) } - func ensureRootDirectory() throws { + public func ensureRootDirectory() throws { try fileManager.operate { try $0.createDirectory(at: rootURL, withIntermediateDirectories: true) } @@ -53,29 +77,29 @@ struct DownloadStore: Sendable { try? mutableRootURL.setResourceValues(resourceValues) } - func folderURL(relativePath: String) -> URL { + public func folderURL(relativePath: String) -> URL { rootURL.appendingPathComponent(relativePath, isDirectory: true) } - func userFolderURL(name: String) -> URL { + public func userFolderURL(name: String) -> URL { rootURL.appendingPathComponent(name, isDirectory: true) } - func rootRelativePath(forFolderURL url: URL) -> String? { + public func rootRelativePath(forFolderURL url: URL) -> String? { let rootPath = rootURL.standardizedFileURL.path + "/" let path = url.standardizedFileURL.path guard path.hasPrefix(rootPath) else { return nil } return String(path.dropFirst(rootPath.count)) } - func parentFolderName(forFolderURL url: URL) -> String? { + public func parentFolderName(forFolderURL url: URL) -> String? { guard let relativePath = rootRelativePath(forFolderURL: url) else { return nil } let components = relativePath.split(separator: "/") guard components.count >= 2 else { return nil } return String(components[0]) } - func validatedChildURL( + public func validatedChildURL( root: URL, relativePath: String ) -> URL? { let resolved = root @@ -87,20 +111,20 @@ struct DownloadStore: Sendable { return resolved } - func manifestURL(relativePath: String) -> URL { + public func manifestURL(relativePath: String) -> URL { folderURL(relativePath: relativePath) .appendingPathComponent(Defaults.FilePath.downloadManifest) } - func queueURL() -> URL { + public func queueURL() -> URL { rootURL.appendingPathComponent(".queue.json") } - func backgroundTaskRegistryURL() -> URL { + public func backgroundTaskRegistryURL() -> URL { rootURL.appendingPathComponent(".background-tasks.json") } - func backgroundTransferHoldingDirectoryURL() -> URL { + public func backgroundTransferHoldingDirectoryURL() -> URL { rootURL.appendingPathComponent(".background-downloads", isDirectory: true) } @@ -111,7 +135,7 @@ struct DownloadStore: Sendable { /// stranded. Call this only while no background session exists (e.g. at `.live` /// construction) so anything present is definitionally an orphan; the downloader /// recreates the directory on the next stage. - func purgeBackgroundTransferHoldingDirectory() { + public func purgeBackgroundTransferHoldingDirectory() { let holdingDirectory = backgroundTransferHoldingDirectoryURL() try? fileManager.operate { guard $0.fileExists(atPath: holdingDirectory.path) else { return } @@ -119,7 +143,7 @@ struct DownloadStore: Sendable { } } - func existingPageRelativePaths(folderURL: URL, manifest: DownloadManifest) -> [Int: String] { + public func existingPageRelativePaths(folderURL: URL, manifest: DownloadManifest) -> [Int: String] { let pageIndices = Set(manifest.pages.keys) guard !pageIndices.isEmpty else { return [:] } @@ -143,14 +167,14 @@ struct DownloadStore: Sendable { } } - func imageURLs(folderURL: URL, manifest: DownloadManifest) -> [Int: URL] { + public func imageURLs(folderURL: URL, manifest: DownloadManifest) -> [Int: URL] { existingPageRelativePaths(folderURL: folderURL, manifest: manifest) .reduce(into: [Int: URL]()) { result, entry in result[entry.key] = folderURL.appendingPathComponent(entry.value) } } - func localCoverURL(folderURL: URL, manifest: DownloadManifest) -> URL? { + public func localCoverURL(folderURL: URL, manifest: DownloadManifest) -> URL? { existingCoverFileURL( folderURL: folderURL, gid: manifest.gid, @@ -158,7 +182,7 @@ struct DownloadStore: Sendable { ) } - func existingCoverRelativePath(folderURL: URL, manifest: DownloadManifest) -> String? { + public func existingCoverRelativePath(folderURL: URL, manifest: DownloadManifest) -> String? { localCoverURL(folderURL: folderURL, manifest: manifest)? .lastPathComponent } @@ -168,13 +192,13 @@ struct DownloadStore: Sendable { /// `LSSupportsOpeningDocumentsInPlace`), and the `[gid_token]` prefix keeps identity /// resolvable from the name alone. The title is truncated to keep the whole component /// within the filesystem's per-name byte limit. - func makeFolderRelativePath(gid: String, token: String, title: String) -> String { + public func makeFolderRelativePath(gid: String, token: String, title: String) -> String { let prefix = galleryFolderNamePrefix(gid: gid, token: token) let titleByteCount = max(Self.maxFolderComponentByteCount - prefix.utf8.count, 0) return "\(prefix)\(normalizedFolderTitle(title, maximumUTF8ByteCount: titleByteCount))" } - func galleryFolderNamePrefix(gid: String, token: String) -> String { + public func galleryFolderNamePrefix(gid: String, token: String) -> String { "[\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))] " } @@ -182,7 +206,7 @@ struct DownloadStore: Sendable { /// filesystem location, not a stored list. A folder the user moved in the Files app is /// still found. The `[gid_token]` name prefix is the fast path; any folder without it is /// confirmed by reading its manifest's `gid` / `token`, so a renamed folder still matches. - func galleryFolderURLs(gid: String, token: String) -> [URL] { + public func galleryFolderURLs(gid: String, token: String) -> [URL] { guard fileManager.operate({ $0.fileExists(atPath: rootURL.path) }) else { return [] } @@ -200,7 +224,7 @@ struct DownloadStore: Sendable { } } - func galleryFolderRecords(gid: String, token: String) -> [DownloadFolderRecord] { + public func galleryFolderRecords(gid: String, token: String) -> [DownloadFolderRecord] { galleryFolderURLs(gid: gid, token: token).compactMap { folderURL in guard let manifest = try? readManifest(folderURL: folderURL), manifest.gid == gid, @@ -216,11 +240,11 @@ struct DownloadStore: Sendable { } } - static func isGalleryFolderLikeName(_ name: String) -> Bool { + public static func isGalleryFolderLikeName(_ name: String) -> Bool { name.range(of: #"^\[[^\]]*_[^\]]*\] "#, options: .regularExpression) != nil } - static func normalizedUserFolderName(_ name: String) -> String? { + public static func normalizedUserFolderName(_ name: String) -> String? { guard let limitedName = normalizedFolderName( name, trimsLeadingDots: true, @@ -235,7 +259,7 @@ struct DownloadStore: Sendable { return limitedName } - func normalizedUserFolderName(_ name: String) -> String? { + public func normalizedUserFolderName(_ name: String) -> String? { Self.normalizedUserFolderName(name) } @@ -304,22 +328,22 @@ struct DownloadStore: Sendable { "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_" } - func makePageRelativePath(gid: String, token: String, index: Int, fileExtension: String) -> String { + public func makePageRelativePath(gid: String, token: String, index: Int, fileExtension: String) -> String { "\(identityPrefix(gid: gid, token: token))\(index).\(fileExtension.lowercased())" } - func makeCoverRelativePath(gid: String, token: String, fileExtension: String) -> String { + public func makeCoverRelativePath(gid: String, token: String, fileExtension: String) -> String { "\(identityPrefix(gid: gid, token: token))cover.\(fileExtension.lowercased())" } - func existingPageFileURL(folderURL: URL, gid: String, token: String, index: Int) -> URL? { + public func existingPageFileURL(folderURL: URL, gid: String, token: String, index: Int) -> URL? { existingAssetFileURL( folderURL: folderURL, prefix: pageFilePrefix(gid: gid, token: token, index: index) ) } - func existingCoverFileURL(folderURL: URL, gid: String, token: String) -> URL? { + public func existingCoverFileURL(folderURL: URL, gid: String, token: String) -> URL? { existingAssetFileURL( folderURL: folderURL, prefix: coverFilePrefix(gid: gid, token: token) @@ -362,11 +386,11 @@ struct DownloadStore: Sendable { "\(identityPrefix(gid: gid, token: token))cover." } - func writeManifest(_ manifest: DownloadManifest, folderURL: URL) throws { + public func writeManifest(_ manifest: DownloadManifest, folderURL: URL) throws { try writeJSON(manifest, to: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest)) } - func readManifest(folderURL: URL) throws -> DownloadManifest { + public func readManifest(folderURL: URL) throws -> DownloadManifest { let manifest = try readJSON( DownloadManifest.self, from: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) @@ -388,11 +412,11 @@ struct DownloadStore: Sendable { .fileOperationFailed(L10n.Localizable.DownloadStore.Validation.manifestCorrupted) } - func scanDownloadFolders() throws -> [DownloadFolderRecord] { + public func scanDownloadFolders() throws -> [DownloadFolderRecord] { try scanDownloads().records } - func scanDownloads() throws -> DownloadScanResult { + public func scanDownloads() throws -> DownloadScanResult { guard fileManager.operate({ $0.fileExists(atPath: rootURL.path) }) else { return .init(records: [], userFolders: []) } @@ -442,7 +466,7 @@ struct DownloadStore: Sendable { } } - func galleryFolderRecord( + public func galleryFolderRecord( folderURL: URL, manifest: DownloadManifest, parentFolderName: String @@ -461,7 +485,7 @@ struct DownloadStore: Sendable { ) } - func fileHash(at url: URL) throws -> String { + public func fileHash(at url: URL) throws -> String { let handle = try FileHandle(forReadingFrom: url) defer { try? handle.close() } @@ -478,7 +502,7 @@ struct DownloadStore: Sendable { } @discardableResult - func sanitizeAssetFileIfNeeded(at url: URL) -> Bool { + public func sanitizeAssetFileIfNeeded(at url: URL) -> Bool { guard fileManager.operate({ $0.fileExists(atPath: url.path) }) else { return false } let attributes: [FileAttributeKey: Any] diff --git a/AppPackage/Sources/DownloadClient/URL+PreviewCacheCleanup.swift b/AppPackage/Sources/DownloadClient/URL+PreviewCacheCleanup.swift new file mode 100644 index 000000000..405a327dc --- /dev/null +++ b/AppPackage/Sources/DownloadClient/URL+PreviewCacheCleanup.swift @@ -0,0 +1,15 @@ +import Foundation +import AppModels +import Parser + +extension URL { + public func previewCacheCleanupURLs() -> [URL] { + guard let info = Parser.parsePreviewConfigs(url: self), + info.plainURL != self + else { + return [self] + } + + return [self, info.plainURL] + } +} diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift index 5f7770207..59cbef22f 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Testing import HapticsClient import DatabaseClient +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift index fe208360a..432c6632e 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift @@ -5,6 +5,7 @@ import Testing import HapticsClient import DatabaseClient import Networking +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index b9bac9016..db73c1ad7 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Testing import HapticsClient import DatabaseClient +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift index d9c0de838..f858ae381 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Testing import HapticsClient import DatabaseClient +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index c1bebd841..abca28303 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Testing import HapticsClient import DatabaseClient +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index c66dd4869..84a194770 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -11,6 +11,7 @@ import HapticsClient import LibraryClient import DatabaseClient import DFClient +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundAssertionTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundAssertionTests.swift index a04c4e9ec..abebfab23 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundAssertionTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundAssertionTests.swift @@ -2,6 +2,7 @@ import Foundation import Synchronization import UIKit import Testing +import DownloadClient @testable import AppFeature @Suite diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundCompletionTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundCompletionTests.swift index a176dc36b..d38f26a88 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundCompletionTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundCompletionTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift index c13ba6db6..e5e9dba3e 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift @@ -1,6 +1,7 @@ import Foundation import ComposableArchitecture import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift index c2392cc6d..af9a9ab66 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import DownloadClient @testable import AppFeature struct DownloadBackgroundTaskStoreTests { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift index a0d8a84b3..aed55daaa 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift index b0066b4c8..e79782f95 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift @@ -4,6 +4,7 @@ import Foundation import Testing import FoundationExt import Utilities +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift index 4f4e7558b..5efdebfdd 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift index 97297a7e8..d5dae2b44 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift @@ -4,6 +4,7 @@ import Resources import UIKit import Foundation import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift index ec2a72cb9..b418dfdd3 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift index 4ed5e70d4..8c44a2fd1 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -5,6 +5,7 @@ import Testing import FoundationExt import Utilities import DatabaseClient +import DownloadClient @testable import AppFeature // MARK: - Sample Data Factories & CoreData Helpers diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift index e584e3200..a45c4d076 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -6,6 +6,7 @@ import Kingfisher import UIKit import Testing import LibraryClient +import DownloadClient @testable import AppFeature // MARK: - Shared Test Helper Protocol diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift index 2c27afffb..cf66481d5 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift index 060bb7ca8..020ac1e73 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift @@ -5,6 +5,7 @@ import UIKit import Foundation import Testing import FoundationExt +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift index d2d6be6bb..85cc59157 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift index 936d318cf..610ee7f85 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift index 5db3b2ec4..106de60c6 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift index 09d83a987..4b7b3f2f4 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature @Suite diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift index 0e67383d0..feb3fcd8b 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift index 26b75f952..320ae2d6b 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index ea432d47f..f41f6aba7 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -7,6 +7,7 @@ import URLClient import HapticsClient import ImageClient import DatabaseClient +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index 793855bcd..b92b529e5 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -6,6 +6,7 @@ import URLClient import HapticsClient import ImageClient import DatabaseClient +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift index 2acb106e4..b95d9452c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Kingfisher import UIKit import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift index 5fb4ec61a..e521a3a7b 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift @@ -4,6 +4,7 @@ import Foundation import Testing import FoundationExt import LibraryClient +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift index 12789eca2..74991c49c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadQueueStoreTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadQueueStoreTests.swift index 7288792da..d8f386169 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadQueueStoreTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadQueueStoreTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import DownloadClient @testable import AppFeature struct DownloadQueueStoreTests { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift index bf7cf7245..c54631415 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift index 0b0ed882c..b6eaf53c2 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift index 1e3902d68..a39042aca 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift index 083792662..b4308ddfe 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature @Suite diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift index d69363789..be1bf08ad 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import Resources import Testing +import DownloadClient @testable import AppFeature struct DownloadStoreHashTests { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift index 610288036..eb29b55dc 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature struct DownloadStoreRepairTests { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift index e57f3c248..f60aec13c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import Resources import Testing +import DownloadClient @testable import AppFeature struct DownloadStoreTests { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift index 8a1c9e68f..7a7fd3ba9 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -1,6 +1,7 @@ import Foundation import AppModels import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift index b35a31c75..d2441b727 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift index 762ba13c1..699e87e02 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift index 25da28723..d902e88c4 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift index 29cc0db03..2a2011f8c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import Testing import HapticsClient import DatabaseClient +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index 0c9b0fe45..d9cfa9b31 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -6,6 +6,7 @@ import URLClient import HapticsClient import ImageClient import DatabaseClient +import DownloadClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index 7b7c552e8..1735121de 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -7,6 +7,7 @@ import URLClient import HapticsClient import ImageClient import DatabaseClient +import DownloadClient @testable import AppFeature @Suite(.serialized) From 053e019e5e297ac41100c48b87bd077cf6a1c768 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 19:57:58 +0800 Subject: [PATCH 319/614] Extract ClipboardClient and BackgroundProcessingClient modules Both are clean leaves now: ClipboardClient reaches the animated-image pasteboard helpers through SDWebImageExt, and BackgroundProcessingClient only referenced the AppDelegate/AppReducer in a doc comment. Drop a stray DownloadClient import the download extraction's cascade had added to BackgroundProcessingClient. --- AppPackage/Package.swift | 24 +++++++++++++++ .../DataFlow/AppDelegateReducer.swift | 1 + .../AppFeature/DataFlow/AppReducer.swift | 1 + .../AppFeature/DataFlow/AppRouteReducer.swift | 1 + .../GalleryInfos/GalleryInfosReducer.swift | 1 + .../Detail/Torrents/TorrentsReducer.swift | 1 + .../View/Reading/ReadingReducer.swift | 1 + .../AccountSettingReducer.swift | 1 + .../BackgroundProcessingClient/.swiftlint.yml | 1 + .../BackgroundProcessingClient.swift | 27 ++++++++--------- .../Sources/ClipboardClient/.swiftlint.yml | 1 + .../ClipboardClient.swift | 30 +++++++++---------- .../DownloadBackgroundProcessingTests.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../ReadingReducerDownloadTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + 17 files changed, 66 insertions(+), 29 deletions(-) create mode 100644 AppPackage/Sources/BackgroundProcessingClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => BackgroundProcessingClient}/BackgroundProcessingClient.swift (76%) create mode 100644 AppPackage/Sources/ClipboardClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => ClipboardClient}/ClipboardClient.swift (74%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 2e0c11577..004784715 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -69,6 +69,8 @@ enum Module: String { case appFeature = "AppFeature" case appModels = "AppModels" case authorizationClient = "AuthorizationClient" + case backgroundProcessingClient = "BackgroundProcessingClient" + case clipboardClient = "ClipboardClient" case composableArchitectureExt = "ComposableArchitectureExt" case dfClient = "DFClient" case databaseClient = "DatabaseClient" @@ -225,6 +227,8 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.authorizationClient), + .module(.backgroundProcessingClient), + .module(.clipboardClient), .module(.composableArchitectureExt), .module(.databaseClient), .module(.dfClient), @@ -340,6 +344,24 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .backgroundProcessingClient, + dependencies: [ + .module(.appModels), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + .target( + module: .clipboardClient, + dependencies: [ + .module(.sdWebImageExt), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .dfClient, dependencies: [ @@ -478,6 +500,8 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appFeature), .module(.appModels), + .module(.backgroundProcessingClient), + .module(.clipboardClient), .module(.databaseClient), .module(.dfClient), .module(.downloadClient), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index abfb65f2f..276f4c997 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -6,6 +6,7 @@ import Utilities import LibraryClient import DatabaseClient import DownloadClient +import BackgroundProcessingClient @Reducer struct AppDelegateReducer { diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 63899909e..79961fd95 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -3,6 +3,7 @@ import ComposableArchitecture import URLClient import HapticsClient import DownloadClient +import BackgroundProcessingClient @Reducer struct AppReducer { diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index a2e0efe5d..2805e2e4b 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -7,6 +7,7 @@ import UserDefaultsClient import HapticsClient import DatabaseClient import Networking +import ClipboardClient @Reducer struct AppRouteReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift index 7cefeddc0..732a17eb5 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import HapticsClient +import ClipboardClient @Reducer struct GalleryInfosReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift index b1790a839..0617cfa72 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import SwiftUINavigationExt import HapticsClient import Networking +import ClipboardClient @Reducer struct TorrentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift index a3bbccf6f..d522ed5fe 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift @@ -7,6 +7,7 @@ import ImageClient import DatabaseClient import Networking import DownloadClient +import ClipboardClient @Reducer struct ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift index 4ef79615b..983b27e54 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import SwiftUINavigationExt import HapticsClient +import ClipboardClient @Reducer struct AccountSettingReducer { diff --git a/AppPackage/Sources/BackgroundProcessingClient/.swiftlint.yml b/AppPackage/Sources/BackgroundProcessingClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/BackgroundProcessingClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift b/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift similarity index 76% rename from AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift rename to AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift index 8595f915b..89d954d4c 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/BackgroundProcessingClient.swift +++ b/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift @@ -1,12 +1,11 @@ import BackgroundTasks import AppModels import ComposableArchitecture -import DownloadClient -enum BackgroundProcessing { +public enum BackgroundProcessing { /// Fixed task identifier, independent of the bundle id. Must stay in sync with the /// `BGTaskSchedulerPermittedIdentifiers` entry in Info.plist. - static let downloadTaskIdentifier = "app.ehpanda.downloads.processing" + public static let downloadTaskIdentifier = "app.ehpanda.downloads.processing" } /// Wraps `BGTaskScheduler` so the app can ask iOS to relaunch it in a discretionary, @@ -15,20 +14,20 @@ enum BackgroundProcessing { /// `DependencyValues` because both the AppDelegate (registration) and `AppReducer` /// (scheduling) need it. @DependencyClient -struct BackgroundProcessingClient: Sendable { +public struct BackgroundProcessingClient: Sendable { /// Registers the launch handler for the download processing task. Must be called /// before the app finishes launching. - var register: @MainActor @Sendable (@escaping @MainActor @Sendable (BGProcessingTask) -> Void) -> Void + public var register: @MainActor @Sendable (@escaping @MainActor @Sendable (BGProcessingTask) -> Void) -> Void /// Submits a processing-task request. Best-effort and fire-and-forget: the system may /// refuse it (Background App Refresh disabled, identifier not permitted), which the /// live implementation logs and tolerates. - var schedule: @Sendable () -> Void + public var schedule: @Sendable () -> Void /// Cancels any pending download processing-task request. - var cancel: @Sendable () -> Void + public var cancel: @Sendable () -> Void } extension BackgroundProcessingClient { - static let live = Self( + public static let live = Self( register: { handler in _ = BGTaskScheduler.shared.register( forTaskWithIdentifier: BackgroundProcessing.downloadTaskIdentifier, @@ -63,14 +62,14 @@ extension BackgroundProcessingClient { } // MARK: API -enum BackgroundProcessingClientKey: DependencyKey { - static let liveValue = BackgroundProcessingClient.live - static let previewValue = BackgroundProcessingClient.noop - static let testValue = BackgroundProcessingClient() +public enum BackgroundProcessingClientKey: DependencyKey { + public static let liveValue = BackgroundProcessingClient.live + public static let previewValue = BackgroundProcessingClient.noop + public static let testValue = BackgroundProcessingClient() } extension DependencyValues { - var backgroundProcessingClient: BackgroundProcessingClient { + public var backgroundProcessingClient: BackgroundProcessingClient { get { self[BackgroundProcessingClientKey.self] } set { self[BackgroundProcessingClientKey.self] = newValue } } @@ -78,7 +77,7 @@ extension DependencyValues { // MARK: Test extension BackgroundProcessingClient { - static let noop = Self( + public static let noop = Self( register: { _ in }, schedule: {}, cancel: {} diff --git a/AppPackage/Sources/ClipboardClient/.swiftlint.yml b/AppPackage/Sources/ClipboardClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/ClipboardClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/ClipboardClient.swift b/AppPackage/Sources/ClipboardClient/ClipboardClient.swift similarity index 74% rename from AppPackage/Sources/AppFeature/Tools/Clients/ClipboardClient.swift rename to AppPackage/Sources/ClipboardClient/ClipboardClient.swift index 8bfbb137b..9c05df87c 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/ClipboardClient.swift +++ b/AppPackage/Sources/ClipboardClient/ClipboardClient.swift @@ -2,16 +2,16 @@ import SwiftUI import ComposableArchitecture import SDWebImageExt -struct ClipboardClient: Sendable { - let url: @Sendable () -> URL? - let changeCount: @Sendable () -> Int - let saveText: @Sendable (String) -> Void - let saveImage: @Sendable (UIImage, Bool) -> Void - let saveImageData: @Sendable (Data) -> Bool +public struct ClipboardClient: Sendable { + public let url: @Sendable () -> URL? + public let changeCount: @Sendable () -> Int + public let saveText: @Sendable (String) -> Void + public let saveImage: @Sendable (UIImage, Bool) -> Void + public let saveImageData: @Sendable (Data) -> Bool } extension ClipboardClient { - static let live: Self = .init( + public static let live: Self = .init( url: { if UIPasteboard.general.hasURLs { return UIPasteboard.general.url @@ -54,14 +54,14 @@ extension ClipboardClient { } // MARK: API -enum ClipboardClientKey: DependencyKey { - static let liveValue = ClipboardClient.live - static let previewValue = ClipboardClient.noop - static let testValue = ClipboardClient.unimplemented +public enum ClipboardClientKey: DependencyKey { + public static let liveValue = ClipboardClient.live + public static let previewValue = ClipboardClient.noop + public static let testValue = ClipboardClient.unimplemented } extension DependencyValues { - var clipboardClient: ClipboardClient { + public var clipboardClient: ClipboardClient { get { self[ClipboardClientKey.self] } set { self[ClipboardClientKey.self] = newValue } } @@ -69,7 +69,7 @@ extension DependencyValues { // MARK: Test extension ClipboardClient { - static let noop: Self = .init( + public static let noop: Self = .init( url: { nil }, changeCount: { 0 }, saveText: { _ in }, @@ -77,9 +77,9 @@ extension ClipboardClient { saveImageData: { _ in false } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( url: IssueReporting.unimplemented(placeholder: placeholder()), changeCount: IssueReporting.unimplemented(placeholder: placeholder()), saveText: IssueReporting.unimplemented(placeholder: placeholder()), diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift index e5e9dba3e..8e102a259 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift @@ -2,6 +2,7 @@ import Foundation import ComposableArchitecture import Testing import DownloadClient +import BackgroundProcessingClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index f41f6aba7..b28d254b3 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -8,6 +8,7 @@ import HapticsClient import ImageClient import DatabaseClient import DownloadClient +import ClipboardClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index b92b529e5..bc6990ae5 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -7,6 +7,7 @@ import HapticsClient import ImageClient import DatabaseClient import DownloadClient +import ClipboardClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index d9cfa9b31..2179d0d64 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -7,6 +7,7 @@ import HapticsClient import ImageClient import DatabaseClient import DownloadClient +import ClipboardClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index 1735121de..baddf749c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -8,6 +8,7 @@ import HapticsClient import ImageClient import DatabaseClient import DownloadClient +import ClipboardClient @testable import AppFeature @Suite(.serialized) From a440593fa6635278abab895192353b5f7bef905c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 20:03:03 +0800 Subject: [PATCH 320/614] Extract FileClient module; move Log model into AppModels FileClient.fetchLogs returns [Log], but the Log value type lived in LogsView. Relocate it (with its CustomStringConvertible behavior) into AppModels so the file client can produce it without depending on the view layer. --- AppPackage/Package.swift | 13 ++++++++ .../Detail/Torrents/TorrentsReducer.swift | 1 + .../View/Setting/Logs/LogsReducer.swift | 1 + .../View/Setting/Logs/LogsView.swift | 24 +-------------- .../View/Setting/SettingReducer.swift | 1 + .../Sources/AppModels/Support/Log.swift | 27 +++++++++++++++++ AppPackage/Sources/FileClient/.swiftlint.yml | 1 + .../Clients => FileClient}/FileClient.swift | 30 +++++++++---------- .../Download/DownloadAutomationTests.swift | 1 + 9 files changed, 61 insertions(+), 38 deletions(-) create mode 100644 AppPackage/Sources/AppModels/Support/Log.swift create mode 100644 AppPackage/Sources/FileClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => FileClient}/FileClient.swift (80%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 004784715..f518cbae2 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -75,6 +75,7 @@ enum Module: String { case dfClient = "DFClient" case databaseClient = "DatabaseClient" case downloadClient = "DownloadClient" + case fileClient = "FileClient" case foundationExt = "FoundationExt" case hapticsClient = "HapticsClient" case imageClient = "ImageClient" @@ -233,6 +234,7 @@ let targets: [PackageDescription.Target] = [ .module(.databaseClient), .module(.dfClient), .module(.downloadClient), + .module(.fileClient), .module(.foundationExt), .module(.hapticsClient), .module(.imageClient), @@ -315,6 +317,16 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .fileClient, + dependencies: [ + .module(.appModels), + .module(.utilities), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .foundationExt, swiftSettings: sharedSwiftSettings, @@ -505,6 +517,7 @@ let targets: [PackageDescription.Target] = [ .module(.databaseClient), .module(.dfClient), .module(.downloadClient), + .module(.fileClient), .module(.foundationExt), .module(.hapticsClient), .module(.imageClient), diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift index 0617cfa72..7c3e57ff7 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift @@ -5,6 +5,7 @@ import SwiftUINavigationExt import HapticsClient import Networking import ClipboardClient +import FileClient @Reducer struct TorrentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift index 3d935c782..873a1452e 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import AppModels import UIApplicationClient +import FileClient @Reducer struct LogsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift index 012c3b17c..b3bd6f015 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift @@ -2,6 +2,7 @@ import SwiftUI import Resources import ComposableArchitecture import SwiftUINavigationExt +import AppModels struct LogsView: View { @Bindable private var store: StoreOf @@ -143,29 +144,6 @@ private struct LogView: View { } } -// MARK: Definition -struct Log: Identifiable, Comparable { - static func < (lhs: Log, rhs: Log) -> Bool { - lhs.fileName < rhs.fileName - } - - var id: String { fileName } - let fileName: String - let contents: [String] -} -extension Log: CustomStringConvertible { - var description: String { - let params = String( - describing: [ - "fileName": fileName, - "contentsCount": contents.count - ] - as [String: Any] - ) - return "Log(\(params))" - } -} - struct LogsView_Previews: PreviewProvider { static var previews: some View { NavigationView { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift index 7ce247b69..28b7029b3 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift @@ -8,6 +8,7 @@ import HapticsClient import LibraryClient import DatabaseClient import DFClient +import FileClient @Reducer struct SettingReducer { diff --git a/AppPackage/Sources/AppModels/Support/Log.swift b/AppPackage/Sources/AppModels/Support/Log.swift new file mode 100644 index 000000000..6a3f20ec8 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/Log.swift @@ -0,0 +1,27 @@ +public struct Log: Identifiable, Comparable, Sendable { + public static func < (lhs: Log, rhs: Log) -> Bool { + lhs.fileName < rhs.fileName + } + + public var id: String { fileName } + public let fileName: String + public let contents: [String] + + public init(fileName: String, contents: [String]) { + self.fileName = fileName + self.contents = contents + } +} + +extension Log: CustomStringConvertible { + public var description: String { + let params = String( + describing: [ + "fileName": fileName, + "contentsCount": contents.count + ] + as [String: Any] + ) + return "Log(\(params))" + } +} diff --git a/AppPackage/Sources/FileClient/.swiftlint.yml b/AppPackage/Sources/FileClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/FileClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift b/AppPackage/Sources/FileClient/FileClient.swift similarity index 80% rename from AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift rename to AppPackage/Sources/FileClient/FileClient.swift index e6139d606..de76ac474 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/FileClient.swift +++ b/AppPackage/Sources/FileClient/FileClient.swift @@ -4,15 +4,15 @@ import Foundation import ComposableArchitecture import Utilities -struct FileClient: Sendable { - let createFile: @Sendable (String, Data?) -> Bool - let fetchLogs: @Sendable () async -> Result<[Log], AppError> - let deleteLog: @Sendable (String) async -> Result - let importTagTranslator: @Sendable (URL) async -> Result +public struct FileClient: Sendable { + public let createFile: @Sendable (String, Data?) -> Bool + public let fetchLogs: @Sendable () async -> Result<[Log], AppError> + public let deleteLog: @Sendable (String) async -> Result + public let importTagTranslator: @Sendable (URL) async -> Result } extension FileClient { - static let live: Self = .init( + public static let live: Self = .init( createFile: { path, data in FileManager.default.createFile(atPath: path, contents: data, attributes: nil) }, @@ -72,21 +72,21 @@ extension FileClient { } ) - func saveTorrent(hash: String, data: Data) -> URL? { + public func saveTorrent(hash: String, data: Data) -> URL? { let torrentDirectory = URL.cachesDirectory.appendingPathComponent("\(hash).torrent") return createFile(torrentDirectory.path, data) ? torrentDirectory : nil } } // MARK: API -enum FileClientKey: DependencyKey { - static let liveValue = FileClient.live - static let previewValue = FileClient.noop - static let testValue = FileClient.unimplemented +public enum FileClientKey: DependencyKey { + public static let liveValue = FileClient.live + public static let previewValue = FileClient.noop + public static let testValue = FileClient.unimplemented } extension DependencyValues { - var fileClient: FileClient { + public var fileClient: FileClient { get { self[FileClientKey.self] } set { self[FileClientKey.self] = newValue } } @@ -94,16 +94,16 @@ extension DependencyValues { // MARK: Test extension FileClient { - static let noop: Self = .init( + public static let noop: Self = .init( createFile: { _, _ in false }, fetchLogs: { .success([]) }, deleteLog: { _ in .success("") }, importTagTranslator: { _ in .success(.init()) } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( createFile: IssueReporting.unimplemented(placeholder: placeholder()), fetchLogs: IssueReporting.unimplemented(placeholder: placeholder()), deleteLog: IssueReporting.unimplemented(placeholder: placeholder()), diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index 84a194770..4bdcdacbf 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -12,6 +12,7 @@ import LibraryClient import DatabaseClient import DFClient import DownloadClient +import FileClient @testable import AppFeature @Suite(.serialized) From ba5579af325aa9e802cb56ffc9dcf3e1622ce361 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 20:09:28 +0800 Subject: [PATCH 321/614] Extract CookieClient module; move cookie value types into AppModels CookieClient produces and consumes CookieValue, CookieState and CookiesState, which lived in AccountSettingReducer. Relocate those value types into AppModels so the cookie client no longer depends on the account-setting view. CookieClient also gains a Utilities dependency for the runtime Defaults.URL.host. --- AppPackage/Package.swift | 15 +++ .../DataFlow/AppDelegateReducer.swift | 1 + .../AppFeature/DataFlow/AppReducer.swift | 1 + .../Detail/Archives/ArchivesReducer.swift | 1 + .../Detail/Comments/CommentsReducer.swift | 1 + .../View/Detail/DetailReducer.swift | 1 + .../View/Reading/ReadingReducer.swift | 1 + .../AccountSettingReducer.swift | 49 +--------- .../Setting/EhSetting/EhSettingReducer.swift | 1 + .../View/Setting/Login/LoginReducer.swift | 1 + .../View/Setting/SettingReducer.swift | 1 + .../AppModels/Support/CookieState.swift | 48 +++++++++ .../AppModels/Support/CookieValue.swift | 20 ++++ .../Sources/CookieClient/.swiftlint.yml | 1 + .../CookieClient.swift | 97 ++++++++++--------- .../Download/DetailReducerDownloadTests.swift | 1 + .../Download/DetailReducerMetadataTests.swift | 1 + .../DetailReducerMetadataUpdateTests.swift | 1 + .../Download/DetailReducerObserveTests.swift | 1 + .../DetailReducerPauseAndGuardTests.swift | 1 + .../Download/DownloadAutomationTests.swift | 1 + .../DownloadBackgroundProcessingTests.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../ReadingReducerDownloadTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + 26 files changed, 154 insertions(+), 96 deletions(-) create mode 100644 AppPackage/Sources/AppModels/Support/CookieState.swift create mode 100644 AppPackage/Sources/AppModels/Support/CookieValue.swift create mode 100644 AppPackage/Sources/CookieClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => CookieClient}/CookieClient.swift (88%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index f518cbae2..8d2102a21 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -72,6 +72,7 @@ enum Module: String { case backgroundProcessingClient = "BackgroundProcessingClient" case clipboardClient = "ClipboardClient" case composableArchitectureExt = "ComposableArchitectureExt" + case cookieClient = "CookieClient" case dfClient = "DFClient" case databaseClient = "DatabaseClient" case downloadClient = "DownloadClient" @@ -231,6 +232,7 @@ let targets: [PackageDescription.Target] = [ .module(.backgroundProcessingClient), .module(.clipboardClient), .module(.composableArchitectureExt), + .module(.cookieClient), .module(.databaseClient), .module(.dfClient), .module(.downloadClient), @@ -374,6 +376,18 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .cookieClient, + dependencies: [ + .module(.appModels), + .module(.foundationExt), + .module(.resources), + .module(.utilities), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .dfClient, dependencies: [ @@ -514,6 +528,7 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.backgroundProcessingClient), .module(.clipboardClient), + .module(.cookieClient), .module(.databaseClient), .module(.dfClient), .module(.downloadClient), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index 276f4c997..a0193730a 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -7,6 +7,7 @@ import LibraryClient import DatabaseClient import DownloadClient import BackgroundProcessingClient +import CookieClient @Reducer struct AppDelegateReducer { diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 79961fd95..932b0c939 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -4,6 +4,7 @@ import URLClient import HapticsClient import DownloadClient import BackgroundProcessingClient +import CookieClient @Reducer struct AppReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift index 8f2d1dd35..ce1e0cfaf 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift @@ -6,6 +6,7 @@ import FoundationExt import HapticsClient import DatabaseClient import Networking +import CookieClient @Reducer struct ArchivesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift index e2b49b1a2..d814ce5a1 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift @@ -7,6 +7,7 @@ import UIApplicationClient import HapticsClient import DatabaseClient import Networking +import CookieClient @Reducer struct CommentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift index 16c9dfd03..eff411e23 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift @@ -8,6 +8,7 @@ import HapticsClient import DatabaseClient import Networking import DownloadClient +import CookieClient @Reducer struct DetailReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift index d522ed5fe..422d364a8 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift @@ -8,6 +8,7 @@ import DatabaseClient import Networking import DownloadClient import ClipboardClient +import CookieClient @Reducer struct ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift index 983b27e54..6ee04af73 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import SwiftUINavigationExt import HapticsClient import ClipboardClient +import CookieClient @Reducer struct AccountSettingReducer { @@ -107,51 +108,3 @@ struct AccountSettingReducer { Scope(state: \.ehSettingState, action: \.ehSetting, child: EhSettingReducer.init) } } - -// MARK: Models -struct CookieValue: Equatable { - static let empty: Self = .init( - rawValue: .init(), localizedString: .init() - ) - - let rawValue: String - let localizedString: String - - var isInvalid: Bool { - !localizedString.isEmpty && !rawValue.isEmpty - } - var placeholder: String { - localizedString.isEmpty ? rawValue : localizedString - } -} - -struct CookiesState: Equatable { - static func empty(_ host: GalleryHost) -> Self { - .init( - host: host, - igneous: .empty, - memberID: .empty, - passHash: .empty - ) - } - var allCases: [CookieState] {[ - igneous, memberID, passHash - ]} - - let host: GalleryHost - var igneous: CookieState - var memberID: CookieState - var passHash: CookieState -} - -struct CookieState: Equatable { - static let empty: Self = .init( - key: "", value: .init( - rawValue: "", localizedString: "" - ) - ) - - let key: String - var value: CookieValue - var editingText = "" -} diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift index 0067808e5..d32a86767 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift @@ -5,6 +5,7 @@ import SwiftUINavigationExt import UIApplicationClient import HapticsClient import Networking +import CookieClient @Reducer struct EhSettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift index 9cd244cb0..c23e9148f 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import SwiftUINavigationExt import HapticsClient import Networking +import CookieClient @Reducer struct LoginReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift index 28b7029b3..1c1f5e050 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift @@ -9,6 +9,7 @@ import LibraryClient import DatabaseClient import DFClient import FileClient +import CookieClient @Reducer struct SettingReducer { diff --git a/AppPackage/Sources/AppModels/Support/CookieState.swift b/AppPackage/Sources/AppModels/Support/CookieState.swift new file mode 100644 index 000000000..667d2d630 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/CookieState.swift @@ -0,0 +1,48 @@ +public struct CookiesState: Equatable, Sendable { + public static func empty(_ host: GalleryHost) -> Self { + .init( + host: host, + igneous: .empty, + memberID: .empty, + passHash: .empty + ) + } + public var allCases: [CookieState] {[ + igneous, memberID, passHash + ]} + + public let host: GalleryHost + public var igneous: CookieState + public var memberID: CookieState + public var passHash: CookieState + + public init( + host: GalleryHost, + igneous: CookieState, + memberID: CookieState, + passHash: CookieState + ) { + self.host = host + self.igneous = igneous + self.memberID = memberID + self.passHash = passHash + } +} + +public struct CookieState: Equatable, Sendable { + public static let empty: Self = .init( + key: "", value: .init( + rawValue: "", localizedString: "" + ) + ) + + public let key: String + public var value: CookieValue + public var editingText = "" + + public init(key: String, value: CookieValue, editingText: String = "") { + self.key = key + self.value = value + self.editingText = editingText + } +} diff --git a/AppPackage/Sources/AppModels/Support/CookieValue.swift b/AppPackage/Sources/AppModels/Support/CookieValue.swift new file mode 100644 index 000000000..7d881995c --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/CookieValue.swift @@ -0,0 +1,20 @@ +public struct CookieValue: Equatable, Sendable { + public static let empty: Self = .init( + rawValue: .init(), localizedString: .init() + ) + + public let rawValue: String + public let localizedString: String + + public init(rawValue: String, localizedString: String) { + self.rawValue = rawValue + self.localizedString = localizedString + } + + public var isInvalid: Bool { + !localizedString.isEmpty && !rawValue.isEmpty + } + public var placeholder: String { + localizedString.isEmpty ? rawValue : localizedString + } +} diff --git a/AppPackage/Sources/CookieClient/.swiftlint.yml b/AppPackage/Sources/CookieClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/CookieClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift b/AppPackage/Sources/CookieClient/CookieClient.swift similarity index 88% rename from AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift rename to AppPackage/Sources/CookieClient/CookieClient.swift index c57c7c102..4d1a3954a 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/CookieClient.swift +++ b/AppPackage/Sources/CookieClient/CookieClient.swift @@ -3,13 +3,14 @@ import AppModels import Resources import ComposableArchitecture import FoundationExt +import Utilities #if DEBUG import Synchronization #endif -struct CookieClient: Sendable { - let clearAll: @Sendable () -> Void - let getCookie: @Sendable (URL, String) -> CookieValue +public struct CookieClient: Sendable { + public let clearAll: @Sendable () -> Void + public let getCookie: @Sendable (URL, String) -> CookieValue private let cookiesForURL: @Sendable (URL) -> [HTTPCookie] private let removeCookie: @Sendable (URL, String) -> Void private let checkExistence: @Sendable (URL, String) -> Bool @@ -19,9 +20,9 @@ struct CookieClient: Sendable { } extension CookieClient { - static let live: Self = live(cookieStorage: .shared) + public static let live: Self = live(cookieStorage: .shared) - static func live(cookieStorage: HTTPCookieStorage) -> Self { + public static func live(cookieStorage: HTTPCookieStorage) -> Self { .init( clearAll: { if let historyCookies = cookieStorage.cookies { @@ -113,7 +114,7 @@ extension CookieClient { // MARK: Foundation extension CookieClient { - func importAutomationCookies(memberID: String, passHash: String, igneous: String?) { + public func importAutomationCookies(memberID: String, passHash: String, igneous: String?) { let urls = [Defaults.URL.ehentai, Defaults.URL.exhentai, Defaults.URL.sexhentai] let authKeys = [Defaults.Cookie.ipbMemberId, Defaults.Cookie.ipbPassHash] @@ -163,7 +164,7 @@ extension CookieClient { ) { setCookieValue(url, key, value, path, expiresTime, sessionOnly) } - func editCookie(for url: URL, key: String, value: String) { + public func editCookie(for url: URL, key: String, value: String) { var newCookie: HTTPCookie? cookiesForURL(url).forEach { cookie in guard cookie.name == key else { return } @@ -173,21 +174,21 @@ extension CookieClient { guard let cookie = newCookie else { return } storeCookie(cookie) } - func setOrEditCookie(for url: URL, key: String, value: String) { + public func setOrEditCookie(for url: URL, key: String, value: String) { if checkExistence(url, key) { editCookie(for: url, key: key, value: value) } else { setCookie(for: url, key: key, value: value) } } - func cookies(for url: URL) -> [HTTPCookie] { + public func cookies(for url: URL) -> [HTTPCookie] { cookiesForURL(url) } } // MARK: Accessor extension CookieClient { - var didLogin: Bool { + public var didLogin: Bool { let ehHasAuth = !getCookie(Defaults.URL.ehentai, Defaults.Cookie.ipbMemberId).rawValue.isEmpty && !getCookie(Defaults.URL.ehentai, Defaults.Cookie.ipbPassHash).rawValue.isEmpty let exIgneous = getCookie(Defaults.URL.exhentai, Defaults.Cookie.igneous).rawValue @@ -197,25 +198,25 @@ extension CookieClient { && exIgneous != Defaults.Cookie.mystery return ehHasAuth || exHasAuth } - var apiuid: String { + public var apiuid: String { getCookie(Defaults.URL.host, Defaults.Cookie.ipbMemberId).rawValue } - var isSameAccount: Bool { + public var isSameAccount: Bool { let ehUID = getCookie(Defaults.URL.ehentai, Defaults.Cookie.ipbMemberId).rawValue let exUID = getCookie(Defaults.URL.exhentai, Defaults.Cookie.ipbMemberId).rawValue if !ehUID.isEmpty && !exUID.isEmpty { return ehUID == exUID } else { return false } } - var shouldFetchIgneous: Bool { + public var shouldFetchIgneous: Bool { let url = Defaults.URL.exhentai return !getCookie(url, Defaults.Cookie.ipbMemberId).rawValue.isEmpty && !getCookie(url, Defaults.Cookie.ipbPassHash).rawValue.isEmpty && getCookie(url, Defaults.Cookie.igneous).rawValue.isEmpty } - func removeYay() { + public func removeYay() { removeCookie(Defaults.URL.exhentai, Defaults.Cookie.yay) removeCookie(Defaults.URL.sexhentai, Defaults.Cookie.yay) } - func syncExCookies() { + public func syncExCookies() { let cookies = [ Defaults.Cookie.ipbMemberId, Defaults.Cookie.ipbPassHash, @@ -229,11 +230,11 @@ extension CookieClient { ) } } - func ignoreOffensive() { + public func ignoreOffensive() { setOrEditCookie(for: Defaults.URL.ehentai, key: Defaults.Cookie.ignoreOffensive, value: "1") setOrEditCookie(for: Defaults.URL.exhentai, key: Defaults.Cookie.ignoreOffensive, value: "1") } - func fulfillAnotherHostField() { + public func fulfillAnotherHostField() { let ehURL = Defaults.URL.ehentai let exURL = Defaults.URL.exhentai let memberIdKey = Defaults.Cookie.ipbMemberId @@ -251,7 +252,7 @@ extension CookieClient { setOrEditCookie(for: ehURL, key: passHashKey, value: exPassHash) } } - func loadCookiesState(host: GalleryHost) -> CookiesState { + public func loadCookiesState(host: GalleryHost) -> CookiesState { let igneousKey = Defaults.Cookie.igneous let memberIDKey = Defaults.Cookie.ipbMemberId let passHashKey = Defaults.Cookie.ipbPassHash @@ -265,7 +266,7 @@ extension CookieClient { passHash: .init(key: passHashKey, value: passHash, editingText: passHash.rawValue) ) } - func getCookiesDescription(host: GalleryHost) -> String { + public func getCookiesDescription(host: GalleryHost) -> String { var dictionary = [String: String]() [Defaults.Cookie.igneous, Defaults.Cookie.ipbMemberId, Defaults.Cookie.ipbPassHash].forEach { key in let cookieValue = getCookie(host.url, key) @@ -279,7 +280,7 @@ extension CookieClient { // MARK: SetCookies extension CookieClient { - func setCookies(state: CookiesState, trimsSpaces: Bool = true) { + public func setCookies(state: CookiesState, trimsSpaces: Bool = true) { for subState in state.allCases { for cookie in state.host.cookieURLs { setOrEditCookie( @@ -292,7 +293,7 @@ extension CookieClient { } } - func setCredentials(response: HTTPURLResponse) { + public func setCredentials(response: HTTPURLResponse) { guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } setString.components(separatedBy: ", ") .flatMap { $0.components(separatedBy: "; ") }.forEach { value in @@ -309,7 +310,7 @@ extension CookieClient { } } } - func setSkipServer(response: HTTPURLResponse) { + public func setSkipServer(response: HTTPURLResponse) { guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } setString.components(separatedBy: ", ") .flatMap { $0.components(separatedBy: "; ") } @@ -326,14 +327,14 @@ extension CookieClient { } // MARK: API -enum CookieClientKey: DependencyKey { - static let liveValue = CookieClient.live - static let previewValue = CookieClient.noop - static let testValue = CookieClient.unimplemented +public enum CookieClientKey: DependencyKey { + public static let liveValue = CookieClient.live + public static let previewValue = CookieClient.noop + public static let testValue = CookieClient.unimplemented } extension DependencyValues { - var cookieClient: CookieClient { + public var cookieClient: CookieClient { get { self[CookieClientKey.self] } set { self[CookieClientKey.self] = newValue } } @@ -341,7 +342,7 @@ extension DependencyValues { // MARK: Test extension CookieClient { - static let noop: Self = .init( + public static let noop: Self = .init( clearAll: {}, getCookie: { _, _ in .empty }, cookiesForURL: { _ in [] }, @@ -352,9 +353,9 @@ extension CookieClient { setCookieValue: { _, _, _, _, _, _ in } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( clearAll: IssueReporting.unimplemented(placeholder: placeholder()), getCookie: IssueReporting.unimplemented(placeholder: placeholder()), cookiesForURL: IssueReporting.unimplemented(placeholder: placeholder()), @@ -368,14 +369,14 @@ extension CookieClient { #if DEBUG private struct CookieClientTestingCookie: Sendable { - var domain: String - var path: String - var name: String - var value: String - var expiresDate: Date? - var isSessionOnly: Bool - - func matches(url: URL, key: String? = nil) -> Bool { + public var domain: String + public var path: String + public var name: String + public var value: String + public var expiresDate: Date? + public var isSessionOnly: Bool + + public func matches(url: URL, key: String? = nil) -> Bool { guard let host = url.host?.lowercased() else { return false } let normalizedDomain = domain.lowercased() .trimmingCharacters(in: CharacterSet(charactersIn: ".")) @@ -385,7 +386,7 @@ private struct CookieClientTestingCookie: Sendable { return domainMatches && keyMatches } - func httpCookie() -> HTTPCookie? { + public func httpCookie() -> HTTPCookie? { var properties: [HTTPCookiePropertyKey: Any] = [ .domain: domain, .path: path, @@ -404,15 +405,15 @@ private struct CookieClientTestingCookie: Sendable { private final class CookieClientTestingStore: Sendable { private let cookies: Mutex<[String: CookieClientTestingCookie]> - init(cookies: [String: CookieClientTestingCookie]) { + public init(cookies: [String: CookieClientTestingCookie]) { self.cookies = Mutex(cookies) } - func value(for url: URL, key: String) -> String { + public func value(for url: URL, key: String) -> String { cookie(for: url, key: key)?.value ?? "" } - func setValue( + public func setValue( _ value: String, for url: URL, key: String, @@ -432,17 +433,17 @@ private final class CookieClientTestingStore: Sendable { cookies.withLock { $0[storageKey(domain: domain, key: key)] = cookie } } - func removeValue(for url: URL, key: String) { + public func removeValue(for url: URL, key: String) { cookies.withLock { storage in storage = storage.filter { !$0.value.matches(url: url, key: key) } } } - func containsValue(for url: URL, key: String) -> Bool { + public func containsValue(for url: URL, key: String) -> Bool { cookie(for: url, key: key) != nil } - func cookies(for url: URL) -> [HTTPCookie] { + public func cookies(for url: URL) -> [HTTPCookie] { cookies.withLock { storage in storage.values .filter { $0.matches(url: url) } @@ -450,7 +451,7 @@ private final class CookieClientTestingStore: Sendable { } } - func store(_ cookie: HTTPCookie) { + public func store(_ cookie: HTTPCookie) { let testingCookie = CookieClientTestingCookie( domain: cookie.domain, path: cookie.path, @@ -464,7 +465,7 @@ private final class CookieClientTestingStore: Sendable { } } - func removeAll() { + public func removeAll() { cookies.withLock { $0.removeAll() } } @@ -480,7 +481,7 @@ private final class CookieClientTestingStore: Sendable { } extension CookieClient { - static func testing( + public static func testing( memberID: String = "", passHash: String = "", igneous: String? = nil diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift index 59cbef22f..65641b899 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift @@ -5,6 +5,7 @@ import Testing import HapticsClient import DatabaseClient import DownloadClient +import CookieClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift index 432c6632e..ad0225c17 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift @@ -6,6 +6,7 @@ import HapticsClient import DatabaseClient import Networking import DownloadClient +import CookieClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index db73c1ad7..ee644cf44 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -5,6 +5,7 @@ import Testing import HapticsClient import DatabaseClient import DownloadClient +import CookieClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift index f858ae381..edaae039a 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift @@ -5,6 +5,7 @@ import Testing import HapticsClient import DatabaseClient import DownloadClient +import CookieClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index abca28303..d1a6a8226 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -5,6 +5,7 @@ import Testing import HapticsClient import DatabaseClient import DownloadClient +import CookieClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index 4bdcdacbf..8610c632d 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -13,6 +13,7 @@ import DatabaseClient import DFClient import DownloadClient import FileClient +import CookieClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift index 8e102a259..99246b0f0 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift @@ -3,6 +3,7 @@ import ComposableArchitecture import Testing import DownloadClient import BackgroundProcessingClient +import CookieClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index b28d254b3..4d7c0c279 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -9,6 +9,7 @@ import ImageClient import DatabaseClient import DownloadClient import ClipboardClient +import CookieClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index bc6990ae5..2c9fe3c7e 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -8,6 +8,7 @@ import ImageClient import DatabaseClient import DownloadClient import ClipboardClient +import CookieClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index 2179d0d64..52a4e315d 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -8,6 +8,7 @@ import ImageClient import DatabaseClient import DownloadClient import ClipboardClient +import CookieClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index baddf749c..50389ecd9 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -9,6 +9,7 @@ import ImageClient import DatabaseClient import DownloadClient import ClipboardClient +import CookieClient @testable import AppFeature @Suite(.serialized) From e28675ad7028ef0babee2aa6998090382e5c9666 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 20:13:50 +0800 Subject: [PATCH 322/614] Extract AppLaunchAutomationClient module; move TabBarItemType into AppModels The launch-automation model resolves an initial TabBarItemType, but that enum lived in TabBarView. Move the pure enum into AppModels (its title/symbol/label presentation extension stays in the view) so the client and its automation model can be extracted without depending on the tab-bar view. --- AppPackage/Package.swift | 12 +++++ .../AppFeature/DataFlow/AppReducer.swift | 1 + .../Clients/AppLaunchAutomationClient.swift | 33 ------------- .../View/Detail/DetailReducer.swift | 1 + .../View/TabBar/TabBarReducer.swift | 1 + .../AppFeature/View/TabBar/TabBarView.swift | 10 ---- .../AppLaunchAutomationClient/.swiftlint.yml | 1 + .../AppLaunchAutomation.swift | 46 ++++++++++++++----- .../AppLaunchAutomationClient.swift | 33 +++++++++++++ .../AppModels/Support/TabBarItemType.swift | 9 ++++ .../Download/DetailReducerDownloadTests.swift | 1 + .../DetailReducerPauseAndGuardTests.swift | 1 + .../Download/DownloadAutomationTests.swift | 1 + .../DownloadBackgroundProcessingTests.swift | 1 + .../DownloadFeatureTestFactories.swift | 1 + 15 files changed, 97 insertions(+), 55 deletions(-) delete mode 100644 AppPackage/Sources/AppFeature/Tools/Clients/AppLaunchAutomationClient.swift create mode 100644 AppPackage/Sources/AppLaunchAutomationClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Utilities => AppLaunchAutomationClient}/AppLaunchAutomation.swift (69%) create mode 100644 AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomationClient.swift create mode 100644 AppPackage/Sources/AppModels/Support/TabBarItemType.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 8d2102a21..6adb9fa8c 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -67,6 +67,7 @@ let sharedSwiftSettings: [PackageDescription.SwiftSetting] = [ // MARK: Module enum Module: String { case appFeature = "AppFeature" + case appLaunchAutomationClient = "AppLaunchAutomationClient" case appModels = "AppModels" case authorizationClient = "AuthorizationClient" case backgroundProcessingClient = "BackgroundProcessingClient" @@ -227,6 +228,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .appFeature, dependencies: [ + .module(.appLaunchAutomationClient), .module(.appModels), .module(.authorizationClient), .module(.backgroundProcessingClient), @@ -350,6 +352,15 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .appLaunchAutomationClient, + dependencies: [ + .module(.appModels), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .authorizationClient, dependencies: [ @@ -525,6 +536,7 @@ let targets: [PackageDescription.Target] = [ module: .appFeatureTests, dependencies: [ .module(.appFeature), + .module(.appLaunchAutomationClient), .module(.appModels), .module(.backgroundProcessingClient), .module(.clipboardClient), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 932b0c939..a26c478b1 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -5,6 +5,7 @@ import HapticsClient import DownloadClient import BackgroundProcessingClient import CookieClient +import AppLaunchAutomationClient @Reducer struct AppReducer { diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/AppLaunchAutomationClient.swift b/AppPackage/Sources/AppFeature/Tools/Clients/AppLaunchAutomationClient.swift deleted file mode 100644 index a3b9fa6b5..000000000 --- a/AppPackage/Sources/AppFeature/Tools/Clients/AppLaunchAutomationClient.swift +++ /dev/null @@ -1,33 +0,0 @@ -import ComposableArchitecture - -@DependencyClient -struct AppLaunchAutomationClient: Sendable { - var current: @Sendable () -> AppLaunchAutomation? -} - -extension AppLaunchAutomationClient { - static let live: Self = .init( - current: { - AppLaunchAutomation.current - } - ) -} - -enum AppLaunchAutomationClientKey: DependencyKey { - static let liveValue = AppLaunchAutomationClient.live - static let previewValue = AppLaunchAutomationClient.none - static let testValue = AppLaunchAutomationClient() -} - -extension DependencyValues { - var appLaunchAutomationClient: AppLaunchAutomationClient { - get { self[AppLaunchAutomationClientKey.self] } - set { self[AppLaunchAutomationClientKey.self] = newValue } - } -} - -extension AppLaunchAutomationClient { - static let none: Self = .init( - current: { nil } - ) -} diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift index eff411e23..09ef2b610 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift @@ -9,6 +9,7 @@ import DatabaseClient import Networking import DownloadClient import CookieClient +import AppLaunchAutomationClient @Reducer struct DetailReducer { diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarReducer.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarReducer.swift index 6b321e80b..faaba4def 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarReducer.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarReducer.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import AppModels @Reducer struct TabBarReducer { diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index a90446dd1..1fc69620f 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -122,16 +122,6 @@ struct TabBarView: View { } // MARK: TabType -enum TabBarItemType: Int, CaseIterable, Identifiable, Sendable { - var id: Int { rawValue } - - case home - case favorites - case search - case downloads - case setting -} - extension TabBarItemType { var title: String { switch self { diff --git a/AppPackage/Sources/AppLaunchAutomationClient/.swiftlint.yml b/AppPackage/Sources/AppLaunchAutomationClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AppLaunchAutomationClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Utilities/AppLaunchAutomation.swift b/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomation.swift similarity index 69% rename from AppPackage/Sources/AppFeature/Tools/Utilities/AppLaunchAutomation.swift rename to AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomation.swift index b01c7d945..0e4ff1d6c 100644 --- a/AppPackage/Sources/AppFeature/Tools/Utilities/AppLaunchAutomation.swift +++ b/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomation.swift @@ -1,20 +1,42 @@ import Foundation import AppModels -struct AppLaunchAutomation: Sendable { - struct LoginCookies: Sendable { - let memberID: String - let passHash: String - let igneous: String? +public struct AppLaunchAutomation: Sendable { + public init( + initialTab: TabBarItemType? = nil, + autoDownloadGID: String? = nil, + downloadFolderName: String? = nil, + loginCookies: LoginCookies? = nil, + galleryURL: URL? = nil + ) { + self.initialTab = initialTab + self.autoDownloadGID = autoDownloadGID + self.downloadFolderName = downloadFolderName + self.loginCookies = loginCookies + self.galleryURL = galleryURL + } + public struct LoginCookies: Sendable { + public let memberID: String + public let passHash: String + public let igneous: String? + public init( + memberID: String, + passHash: String, + igneous: String? = nil + ) { + self.memberID = memberID + self.passHash = passHash + self.igneous = igneous + } } - let initialTab: TabBarItemType? - let autoDownloadGID: String? - let downloadFolderName: String? - let loginCookies: LoginCookies? - let galleryURL: URL? + public let initialTab: TabBarItemType? + public let autoDownloadGID: String? + public let downloadFolderName: String? + public let loginCookies: LoginCookies? + public let galleryURL: URL? - static var current: Self? { + public static var current: Self? { #if DEBUG resolve(environment: ProcessInfo.processInfo.environment) #else @@ -22,7 +44,7 @@ struct AppLaunchAutomation: Sendable { #endif } - static func resolve(environment: [String: String]) -> Self? { + public static func resolve(environment: [String: String]) -> Self? { #if DEBUG let initialTab = environment["EHPANDA_AUTOMATION_TAB"] .flatMap(parseTab(rawValue:)) diff --git a/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomationClient.swift b/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomationClient.swift new file mode 100644 index 000000000..bcfc2e808 --- /dev/null +++ b/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomationClient.swift @@ -0,0 +1,33 @@ +import ComposableArchitecture + +@DependencyClient +public struct AppLaunchAutomationClient: Sendable { + public var current: @Sendable () -> AppLaunchAutomation? +} + +extension AppLaunchAutomationClient { + public static let live: Self = .init( + current: { + AppLaunchAutomation.current + } + ) +} + +public enum AppLaunchAutomationClientKey: DependencyKey { + public static let liveValue = AppLaunchAutomationClient.live + public static let previewValue = AppLaunchAutomationClient.none + public static let testValue = AppLaunchAutomationClient() +} + +extension DependencyValues { + public var appLaunchAutomationClient: AppLaunchAutomationClient { + get { self[AppLaunchAutomationClientKey.self] } + set { self[AppLaunchAutomationClientKey.self] = newValue } + } +} + +extension AppLaunchAutomationClient { + public static let none: Self = .init( + current: { nil } + ) +} diff --git a/AppPackage/Sources/AppModels/Support/TabBarItemType.swift b/AppPackage/Sources/AppModels/Support/TabBarItemType.swift new file mode 100644 index 000000000..cec85edba --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/TabBarItemType.swift @@ -0,0 +1,9 @@ +public enum TabBarItemType: Int, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } + + case home + case favorites + case search + case downloads + case setting +} diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift index 65641b899..f2defbf2e 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift @@ -6,6 +6,7 @@ import HapticsClient import DatabaseClient import DownloadClient import CookieClient +import AppLaunchAutomationClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index d1a6a8226..db250228a 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -6,6 +6,7 @@ import HapticsClient import DatabaseClient import DownloadClient import CookieClient +import AppLaunchAutomationClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index 8610c632d..8fdd720e9 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -14,6 +14,7 @@ import DFClient import DownloadClient import FileClient import CookieClient +import AppLaunchAutomationClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift index 99246b0f0..55cc8abe5 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift @@ -4,6 +4,7 @@ import Testing import DownloadClient import BackgroundProcessingClient import CookieClient +import AppLaunchAutomationClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift index 8c44a2fd1..1df3eb0b1 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -6,6 +6,7 @@ import FoundationExt import Utilities import DatabaseClient import DownloadClient +import AppLaunchAutomationClient @testable import AppFeature // MARK: - Sample Data Factories & CoreData Helpers From 333ce9e6f514b3adea67e8bc26491b6afd6e2650 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 20:17:58 +0800 Subject: [PATCH 323/614] Extract DeviceClient module; move TouchHandler into Utilities DeviceClient reads the current touch point from TouchHandler, a gesture-delegate singleton that lived in RootView. Move it into Utilities as shared touch-tracking infrastructure (the reader views and the root view reach it there) so DeviceClient no longer depends on the app shell. --- AppPackage/Package.swift | 12 ++++++++ .../AppFeature/DataFlow/AppReducer.swift | 1 + AppPackage/Sources/AppFeature/RootView.swift | 14 ---------- .../View/Reading/ReadingReducer.swift | 1 + .../View/Setting/SettingReducer.swift | 1 + .../Sources/DeviceClient/.swiftlint.yml | 1 + .../DeviceClient.swift | 28 +++++++++---------- .../Sources/Utilities/TouchHandler.swift | 14 ++++++++++ .../Download/DownloadAutomationTests.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../DownloadsReducerReadingDismissTests.swift | 1 + .../ReadingReducerDownloadTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + 14 files changed, 50 insertions(+), 28 deletions(-) create mode 100644 AppPackage/Sources/DeviceClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => DeviceClient}/DeviceClient.swift (58%) create mode 100644 AppPackage/Sources/Utilities/TouchHandler.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 6adb9fa8c..1a1cc8407 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -76,6 +76,7 @@ enum Module: String { case cookieClient = "CookieClient" case dfClient = "DFClient" case databaseClient = "DatabaseClient" + case deviceClient = "DeviceClient" case downloadClient = "DownloadClient" case fileClient = "FileClient" case foundationExt = "FoundationExt" @@ -237,6 +238,7 @@ let targets: [PackageDescription.Target] = [ .module(.cookieClient), .module(.databaseClient), .module(.dfClient), + .module(.deviceClient), .module(.downloadClient), .module(.fileClient), .module(.foundationExt), @@ -302,6 +304,15 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .deviceClient, + dependencies: [ + .module(.utilities), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .downloadClient, dependencies: [ @@ -543,6 +554,7 @@ let targets: [PackageDescription.Target] = [ .module(.cookieClient), .module(.databaseClient), .module(.dfClient), + .module(.deviceClient), .module(.downloadClient), .module(.fileClient), .module(.foundationExt), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index a26c478b1..17f481c99 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -6,6 +6,7 @@ import DownloadClient import BackgroundProcessingClient import CookieClient import AppLaunchAutomationClient +import DeviceClient @Reducer struct AppReducer { diff --git a/AppPackage/Sources/AppFeature/RootView.swift b/AppPackage/Sources/AppFeature/RootView.swift index cb2415a23..d2d29d45a 100644 --- a/AppPackage/Sources/AppFeature/RootView.swift +++ b/AppPackage/Sources/AppFeature/RootView.swift @@ -38,17 +38,3 @@ public struct RootView: View { } } } - -// MARK: TouchHandler -final class TouchHandler: NSObject, UIGestureRecognizerDelegate { - static let shared = TouchHandler() - var currentPoint: CGPoint? - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldReceive touch: UITouch - ) -> Bool { - currentPoint = touch.location(in: touch.window) - return false - } -} diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift index 422d364a8..a69d35825 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift @@ -9,6 +9,7 @@ import Networking import DownloadClient import ClipboardClient import CookieClient +import DeviceClient @Reducer struct ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift index 1c1f5e050..560176b89 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift @@ -10,6 +10,7 @@ import DatabaseClient import DFClient import FileClient import CookieClient +import DeviceClient @Reducer struct SettingReducer { diff --git a/AppPackage/Sources/DeviceClient/.swiftlint.yml b/AppPackage/Sources/DeviceClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DeviceClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/DeviceClient.swift b/AppPackage/Sources/DeviceClient/DeviceClient.swift similarity index 58% rename from AppPackage/Sources/AppFeature/Tools/Clients/DeviceClient.swift rename to AppPackage/Sources/DeviceClient/DeviceClient.swift index e260d61da..faf99ab22 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/DeviceClient.swift +++ b/AppPackage/Sources/DeviceClient/DeviceClient.swift @@ -2,15 +2,15 @@ import SwiftUI import Dependencies import Utilities -struct DeviceClient: Sendable { - let isPad: @Sendable () async -> Bool - let absWindowW: @MainActor @Sendable () -> Double - let absWindowH: @MainActor @Sendable () -> Double - let touchPoint: @MainActor @Sendable () -> CGPoint? +public struct DeviceClient: Sendable { + public let isPad: @Sendable () async -> Bool + public let absWindowW: @MainActor @Sendable () -> Double + public let absWindowH: @MainActor @Sendable () -> Double + public let touchPoint: @MainActor @Sendable () -> CGPoint? } extension DeviceClient { - static let live: Self = .init( + public static let live: Self = .init( isPad: { await MainActor.run { DeviceUtil.isPad @@ -29,14 +29,14 @@ extension DeviceClient { } // MARK: API -enum DeviceClientKey: DependencyKey { - static let liveValue = DeviceClient.live - static let previewValue = DeviceClient.noop - static let testValue = DeviceClient.unimplemented +public enum DeviceClientKey: DependencyKey { + public static let liveValue = DeviceClient.live + public static let previewValue = DeviceClient.noop + public static let testValue = DeviceClient.unimplemented } extension DependencyValues { - var deviceClient: DeviceClient { + public var deviceClient: DeviceClient { get { self[DeviceClientKey.self] } set { self[DeviceClientKey.self] = newValue } } @@ -44,16 +44,16 @@ extension DependencyValues { // MARK: Test extension DeviceClient { - static let noop: Self = .init( + public static let noop: Self = .init( isPad: { false }, absWindowW: { .zero }, absWindowH: { .zero }, touchPoint: { .zero } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( isPad: IssueReporting.unimplemented(placeholder: placeholder()), absWindowW: IssueReporting.unimplemented(placeholder: placeholder()), absWindowH: IssueReporting.unimplemented(placeholder: placeholder()), diff --git a/AppPackage/Sources/Utilities/TouchHandler.swift b/AppPackage/Sources/Utilities/TouchHandler.swift new file mode 100644 index 000000000..15f25d3a2 --- /dev/null +++ b/AppPackage/Sources/Utilities/TouchHandler.swift @@ -0,0 +1,14 @@ +import UIKit + +public final class TouchHandler: NSObject, UIGestureRecognizerDelegate { + public static let shared = TouchHandler() + public var currentPoint: CGPoint? + + public func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + currentPoint = touch.location(in: touch.window) + return false + } +} diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index 8fdd720e9..83db8ba2d 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -15,6 +15,7 @@ import DownloadClient import FileClient import CookieClient import AppLaunchAutomationClient +import DeviceClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index 4d7c0c279..6d5f0a68f 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -10,6 +10,7 @@ import DatabaseClient import DownloadClient import ClipboardClient import CookieClient +import DeviceClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index 2c9fe3c7e..c3f635596 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -9,6 +9,7 @@ import DatabaseClient import DownloadClient import ClipboardClient import CookieClient +import DeviceClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift index e46fe9321..25287e11d 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import Testing import HapticsClient +import DeviceClient @testable import AppFeature struct DownloadsReducerReadingDismissTests { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index 52a4e315d..1d9341486 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -9,6 +9,7 @@ import DatabaseClient import DownloadClient import ClipboardClient import CookieClient +import DeviceClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index 50389ecd9..5180445bf 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -10,6 +10,7 @@ import DatabaseClient import DownloadClient import ClipboardClient import CookieClient +import DeviceClient @testable import AppFeature @Suite(.serialized) From f83444983d0feb0ac11018fa1e5fe5ac3b8ac41f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 20:22:49 +0800 Subject: [PATCH 324/614] Extract AppDelegateClient module; move the orientation-mask state into it AppDelegateClient sets the app's supported-orientation mask, which it wrote to a static on the AppDelegate composition root. Move that shared state into the client module as AppOrientationMask (main-actor isolated), so the client owns it and the app delegate reads it back without the client depending on the app shell. This is the last client out of AppFeature/Tools/Clients. --- AppPackage/Package.swift | 12 +++++++ .../Sources/AppDelegateClient/.swiftlint.yml | 1 + .../AppDelegateClient.swift | 32 +++++++++---------- .../AppOrientationMask.swift | 11 +++++++ .../DataFlow/AppDelegateReducer.swift | 5 ++- .../View/Reading/ReadingReducer.swift | 1 + .../View/Setting/SettingReducer.swift | 1 + .../Download/DownloadAutomationTests.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../DownloadsReducerReadingDismissTests.swift | 1 + .../ReadingReducerDownloadTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + 13 files changed, 50 insertions(+), 19 deletions(-) create mode 100644 AppPackage/Sources/AppDelegateClient/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools/Clients => AppDelegateClient}/AppDelegateClient.swift (51%) create mode 100644 AppPackage/Sources/AppDelegateClient/AppOrientationMask.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 1a1cc8407..5ef2e0ca3 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -66,6 +66,7 @@ let sharedSwiftSettings: [PackageDescription.SwiftSetting] = [ // MARK: Module enum Module: String { + case appDelegateClient = "AppDelegateClient" case appFeature = "AppFeature" case appLaunchAutomationClient = "AppLaunchAutomationClient" case appModels = "AppModels" @@ -229,6 +230,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .appFeature, dependencies: [ + .module(.appDelegateClient), .module(.appLaunchAutomationClient), .module(.appModels), .module(.authorizationClient), @@ -363,6 +365,15 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .appDelegateClient, + dependencies: [ + .module(.utilities), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .appLaunchAutomationClient, dependencies: [ @@ -546,6 +557,7 @@ let targets: [PackageDescription.Target] = [ .testTarget( module: .appFeatureTests, dependencies: [ + .module(.appDelegateClient), .module(.appFeature), .module(.appLaunchAutomationClient), .module(.appModels), diff --git a/AppPackage/Sources/AppDelegateClient/.swiftlint.yml b/AppPackage/Sources/AppDelegateClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AppDelegateClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/Clients/AppDelegateClient.swift b/AppPackage/Sources/AppDelegateClient/AppDelegateClient.swift similarity index 51% rename from AppPackage/Sources/AppFeature/Tools/Clients/AppDelegateClient.swift rename to AppPackage/Sources/AppDelegateClient/AppDelegateClient.swift index f3836c39c..4ae7ebf47 100644 --- a/AppPackage/Sources/AppFeature/Tools/Clients/AppDelegateClient.swift +++ b/AppPackage/Sources/AppDelegateClient/AppDelegateClient.swift @@ -2,44 +2,44 @@ import SwiftUI import ComposableArchitecture import Utilities -struct AppDelegateClient: Sendable { - let setOrientation: @MainActor @Sendable (UIInterfaceOrientationMask) -> Void - let setOrientationMask: @MainActor @Sendable (UIInterfaceOrientationMask) -> Void +public struct AppDelegateClient: Sendable { + public let setOrientation: @MainActor @Sendable (UIInterfaceOrientationMask) -> Void + public let setOrientationMask: @MainActor @Sendable (UIInterfaceOrientationMask) -> Void } extension AppDelegateClient { - static let live: Self = .init( + public static let live: Self = .init( setOrientation: { mask in DeviceUtil.keyWindow?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) }, setOrientationMask: { mask in - AppDelegate.orientationMask = mask + AppOrientationMask.current = mask } ) @MainActor - func setPortraitOrientation() { + public func setPortraitOrientation() { setOrientation(.portrait) } @MainActor - func setAllOrientationMask() { + public func setAllOrientationMask() { setOrientationMask([.all]) } @MainActor - func setPortraitOrientationMask() { + public func setPortraitOrientationMask() { setOrientationMask([.portrait, .portraitUpsideDown]) } } // MARK: API -enum AppDelegateClientKey: DependencyKey { - static let liveValue = AppDelegateClient.live - static let previewValue = AppDelegateClient.noop - static let testValue = AppDelegateClient.unimplemented +public enum AppDelegateClientKey: DependencyKey { + public static let liveValue = AppDelegateClient.live + public static let previewValue = AppDelegateClient.noop + public static let testValue = AppDelegateClient.unimplemented } extension DependencyValues { - var appDelegateClient: AppDelegateClient { + public var appDelegateClient: AppDelegateClient { get { self[AppDelegateClientKey.self] } set { self[AppDelegateClientKey.self] = newValue } } @@ -47,14 +47,14 @@ extension DependencyValues { // MARK: Test extension AppDelegateClient { - static let noop: Self = .init( + public static let noop: Self = .init( setOrientation: { _ in }, setOrientationMask: { _ in } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( setOrientation: IssueReporting.unimplemented(placeholder: placeholder()), setOrientationMask: IssueReporting.unimplemented(placeholder: placeholder()) ) diff --git a/AppPackage/Sources/AppDelegateClient/AppOrientationMask.swift b/AppPackage/Sources/AppDelegateClient/AppOrientationMask.swift new file mode 100644 index 000000000..6d7e38f76 --- /dev/null +++ b/AppPackage/Sources/AppDelegateClient/AppOrientationMask.swift @@ -0,0 +1,11 @@ +import UIKit +import Utilities + +/// The app's current supported-interface-orientation mask. The orientation lock is +/// owned by the orientation-setting client (written through `AppDelegateClient`), and +/// the app delegate reads this back from `application(_:supportedInterfaceOrientationsFor:)`. +@MainActor +public enum AppOrientationMask { + public static var current: UIInterfaceOrientationMask = + DeviceUtil.isPad ? .all : [.portrait, .portraitUpsideDown] +} diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index a0193730a..be21e87e3 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -3,6 +3,7 @@ import BackgroundTasks import SwiftyBeaver import ComposableArchitecture import Utilities +import AppDelegateClient import LibraryClient import DatabaseClient import DownloadClient @@ -61,11 +62,9 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { super.init() } - static var orientationMask: UIInterfaceOrientationMask = DeviceUtil.isPad ? .all : [.portrait, .portraitUpsideDown] - public func application( _ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow? - ) -> UIInterfaceOrientationMask { AppDelegate.orientationMask } + ) -> UIInterfaceOrientationMask { AppOrientationMask.current } public func application( _ application: UIApplication, didFinishLaunchingWithOptions diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift index a69d35825..a81c8215c 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift @@ -10,6 +10,7 @@ import DownloadClient import ClipboardClient import CookieClient import DeviceClient +import AppDelegateClient @Reducer struct ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift index 560176b89..0d16211e8 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift @@ -11,6 +11,7 @@ import DFClient import FileClient import CookieClient import DeviceClient +import AppDelegateClient @Reducer struct SettingReducer { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index 83db8ba2d..9802e7f82 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -16,6 +16,7 @@ import FileClient import CookieClient import AppLaunchAutomationClient import DeviceClient +import AppDelegateClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index 6d5f0a68f..b0f543931 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -11,6 +11,7 @@ import DownloadClient import ClipboardClient import CookieClient import DeviceClient +import AppDelegateClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index c3f635596..277e1c349 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -10,6 +10,7 @@ import DownloadClient import ClipboardClient import CookieClient import DeviceClient +import AppDelegateClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift index 25287e11d..1620a22a3 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import Testing import HapticsClient import DeviceClient +import AppDelegateClient @testable import AppFeature struct DownloadsReducerReadingDismissTests { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index 1d9341486..5f44f1b74 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -10,6 +10,7 @@ import DownloadClient import ClipboardClient import CookieClient import DeviceClient +import AppDelegateClient @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index 5180445bf..36a4f8e7d 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -11,6 +11,7 @@ import DownloadClient import ClipboardClient import CookieClient import DeviceClient +import AppDelegateClient @testable import AppFeature @Suite(.serialized) From 951ee5508d4c40ceca4e75c63b354f4cd3090619 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 20:58:02 +0800 Subject: [PATCH 325/614] Extract DesignSystem module; move UI-framework view extensions + CategoryColor --- AppPackage/Package.swift | 19 ++++++ .../AppFeature/DataFlow/AppRouteReducer.swift | 1 + .../Tools/Extensions/AlertKit_Extension.swift | 1 + .../Detail/Archives/ArchivesReducer.swift | 1 + .../View/Detail/Archives/ArchivesView.swift | 1 + .../Detail/Comments/CommentsReducer.swift | 1 + .../View/Detail/Comments/CommentsView.swift | 1 + .../Detail/Components/PostCommentView.swift | 1 + .../Detail/Components/TagDetailView.swift | 1 + .../DetailSearch/DetailSearchView.swift | 1 + .../View/Detail/DetailView+CommentCells.swift | 1 + .../Detail/DetailView+HeaderSection.swift | 1 + .../View/Detail/DetailView+Subviews.swift | 1 + .../AppFeature/View/Detail/DetailView.swift | 1 + .../GalleryInfos/GalleryInfosReducer.swift | 1 + .../GalleryInfos/GalleryInfosView.swift | 1 + .../View/Detail/Previews/PreviewsView.swift | 1 + .../Detail/Torrents/TorrentsReducer.swift | 1 + .../View/Detail/Torrents/TorrentsView.swift | 1 + .../Downloads/DownloadInspectorReducer.swift | 1 + .../Downloads/DownloadsView+Subviews.swift | 1 + .../View/Downloads/DownloadsView.swift | 1 + .../View/Downloads/FolderManagerView.swift | 1 + .../View/Favorites/FavoritesView.swift | 1 + .../View/Home/Frontpage/FrontpageView.swift | 1 + .../View/Home/History/HistoryView.swift | 1 + .../View/Home/HomeView+Sections.swift | 1 + .../AppFeature/View/Home/HomeView.swift | 1 + .../View/Home/Popular/PopularView.swift | 1 + .../View/Home/Toplists/ToplistsView.swift | 1 + .../View/Home/Watched/WatchedView.swift | 1 + .../View/Reading/ReadingReducer.swift | 1 + .../AppFeature/View/Reading/ReadingView.swift | 1 + .../View/Reading/Support/ControlPanel.swift | 1 + .../View/Reading/Support/LiveTextView.swift | 1 + .../View/Search/SearchRootView.swift | 1 + .../AppFeature/View/Search/SearchView.swift | 1 + .../View/Search/Support/QuickSearchView.swift | 1 + .../AccountSettingReducer.swift | 1 + .../AccountSetting/AccountSettingView.swift | 1 + .../AppearanceSettingView.swift | 1 + .../View/Setting/Components/AboutView.swift | 1 + .../Components/LaboratorySettingView.swift | 1 + .../Setting/EhSetting/EhSettingView.swift | 1 + .../GeneralSetting/GeneralSettingView.swift | 1 + .../View/Setting/Login/LoginView.swift | 1 + .../AppFeature/View/Setting/SettingView.swift | 1 + .../View/Support/Components/AlertView.swift | 1 + .../Support/Components/CategoryView.swift | 1 + .../Components/Cells/GalleryCardCell.swift | 1 + .../Components/Cells/GalleryDetailCell.swift | 1 + .../Components/Cells/GalleryHistoryCell.swift | 1 + .../Components/Cells/GalleryRankingCell.swift | 1 + .../Cells/GalleryThumbnailCell.swift | 1 + .../View/Support/Components/Placeholder.swift | 1 + .../Support/Components/PreviewImageView.swift | 1 + .../Support/Components/SettingTextField.swift | 1 + .../Support/Components/TagCloudView.swift | 1 + .../AppFeature/View/Support/FiltersView.swift | 1 + .../AppFeature/View/Support/NewDawnView.swift | 1 + .../AppFeature/View/TabBar/TabBarView.swift | 1 + .../Sources/DesignSystem/.swiftlint.yml | 1 + .../CategoryColor.swift | 4 +- .../SwiftUINavigation_Extension.swift | 2 +- .../TTProgressHUD_Extension.swift | 26 ++++---- .../ViewModifiers.swift | 60 +++++++++++-------- 66 files changed, 132 insertions(+), 40 deletions(-) create mode 100644 AppPackage/Sources/DesignSystem/.swiftlint.yml rename AppPackage/Sources/{AppFeature/Tools => DesignSystem}/CategoryColor.swift (87%) rename AppPackage/Sources/{AppFeature/Tools/Extensions => DesignSystem}/SwiftUINavigation_Extension.swift (86%) rename AppPackage/Sources/{AppFeature/Tools/Extensions => DesignSystem}/TTProgressHUD_Extension.swift (59%) rename AppPackage/Sources/{AppFeature/Tools/Extensions => DesignSystem}/ViewModifiers.swift (69%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 5ef2e0ca3..bd4de2d55 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -77,6 +77,7 @@ enum Module: String { case cookieClient = "CookieClient" case dfClient = "DFClient" case databaseClient = "DatabaseClient" + case designSystem = "DesignSystem" case deviceClient = "DeviceClient" case downloadClient = "DownloadClient" case fileClient = "FileClient" @@ -239,6 +240,7 @@ let targets: [PackageDescription.Target] = [ .module(.composableArchitectureExt), .module(.cookieClient), .module(.databaseClient), + .module(.designSystem), .module(.dfClient), .module(.deviceClient), .module(.downloadClient), @@ -466,6 +468,23 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .designSystem, + dependencies: [ + .module(.appModels), + .module(.foundationExt), + .module(.parser), + .module(.resources), + .module(.swiftUINavigationExt), + .module(.utilities), + .targetDependency(.kingfisher), + .targetDependency(.sfSafeSymbols), + .targetDependency(.swiftUINavigation), + .targetDependency(.ttProgressHUD) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .sdWebImageExt, dependencies: [ diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 2805e2e4b..a500ae097 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -8,6 +8,7 @@ import HapticsClient import DatabaseClient import Networking import ClipboardClient +import DesignSystem @Reducer struct AppRouteReducer { diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift index 1a47e4ac1..47cdef711 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import AlertKit +import DesignSystem extension View { func jumpPageAlert( diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift index ce1e0cfaf..b09fb4ff1 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift @@ -7,6 +7,7 @@ import HapticsClient import DatabaseClient import Networking import CookieClient +import DesignSystem @Reducer struct ArchivesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift index d7fec7c49..2cc896777 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import Utilities +import DesignSystem struct ArchivesView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift index d814ce5a1..26abcaa21 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift @@ -8,6 +8,7 @@ import HapticsClient import DatabaseClient import Networking import CookieClient +import DesignSystem @Reducer struct CommentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift index 65a29030e..6cb781edd 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift @@ -5,6 +5,7 @@ import Kingfisher import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct CommentsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/Components/PostCommentView.swift b/AppPackage/Sources/AppFeature/View/Detail/Components/PostCommentView.swift index e14249f2d..643872e89 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Components/PostCommentView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Components/PostCommentView.swift @@ -1,4 +1,5 @@ import SwiftUI +import DesignSystem struct PostCommentView: View { private let title: String diff --git a/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift index 291e78732..b11ffed56 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import Kingfisher import FoundationExt +import DesignSystem struct TagDetailView: View { private let detail: TagDetail diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift index d4f46d171..6167f149f 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct DetailSearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift index 5613539c4..adbaf93e6 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Resources +import DesignSystem extension DetailView { struct CommentCell: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift index 61d2f609d..d3eafebcf 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift @@ -4,6 +4,7 @@ import Resources import Kingfisher import SFSafeSymbols import Utilities +import DesignSystem // MARK: HeaderSection struct HeaderSection: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift index d63388363..43befee6d 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift @@ -4,6 +4,7 @@ import Resources import Kingfisher import FoundationExt import Utilities +import DesignSystem // MARK: DescriptionSection struct DescriptionSection: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift index ed9b0605a..0d45086fa 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift @@ -5,6 +5,7 @@ import Kingfisher import ComposableArchitecture import CommonMark import Utilities +import DesignSystem private enum DownloadDialog: Equatable { case delete(isActiveDownload: Bool) diff --git a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift index 732a17eb5..ff1da020b 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import HapticsClient import ClipboardClient +import DesignSystem @Reducer struct GalleryInfosReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift index 549d8c308..cca3098ac 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import Utilities +import DesignSystem struct GalleryInfosView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift index 1176562a4..06a3d4ba5 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import Utilities +import DesignSystem struct PreviewsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift index 7c3e57ff7..810980cd8 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift @@ -6,6 +6,7 @@ import HapticsClient import Networking import ClipboardClient import FileClient +import DesignSystem @Reducer struct TorrentsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift index 3cfab872d..ff32a07a0 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import DesignSystem struct TorrentsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift index 1bdbc8f8b..35c699d68 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import DownloadClient +import DesignSystem @Reducer struct DownloadInspectorReducer { diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift index b2b89f0eb..a5d0c36eb 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift @@ -3,6 +3,7 @@ import AppModels import Resources import SFSafeSymbols import ComposableArchitecture +import DesignSystem struct DownloadInspectorView: View { @Environment(\.dismiss) private var dismiss diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift index f9369a64d..9804bda53 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift @@ -5,6 +5,7 @@ import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct DownloadsView: View { private enum RowDialog: Identifiable { diff --git a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift index 92ca07576..8bdadd5e6 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift @@ -3,6 +3,7 @@ import Resources import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt +import DesignSystem struct FolderManagerView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift index 49457b46c..93b909e7a 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift @@ -5,6 +5,7 @@ import AlertKit import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct FavoritesView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift index 8edd23baf..3cfe82f30 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift @@ -5,6 +5,7 @@ import AlertKit import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct FrontpageView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift index ec7eee9e2..44fead7ce 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct HistoryView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift index 2e82d57e2..a44cee1e7 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift @@ -5,6 +5,7 @@ import Kingfisher import SwiftUIPager import SFSafeSymbols import Utilities +import DesignSystem // MARK: CardSlideSection struct CardSlideSection: View, Equatable { diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift index f6f5d61b3..47772bcb3 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift @@ -6,6 +6,7 @@ import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct HomeView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift index 7d6246983..e60347011 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct PopularView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift index 21549c451..cd3f7768f 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct ToplistsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift index 6859f026b..39b07ee68 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct WatchedView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift index a81c8215c..3348c33a3 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift @@ -11,6 +11,7 @@ import ClipboardClient import CookieClient import DeviceClient import AppDelegateClient +import DesignSystem @Reducer struct ReadingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift index 0bf7eebc6..1240b6a42 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift @@ -6,6 +6,7 @@ import ComposableArchitecture import FoundationExt import Utilities import SDWebImageExt +import DesignSystem struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift index c6599851a..dcd43bdbb 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import Utilities +import DesignSystem // MARK: ControlPanel struct ControlPanel: View { diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextView.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextView.swift index bff25541d..0d3fe4528 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextView.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import DesignSystem struct LiveTextView: View { private let liveTextGroups: [LiveTextGroup] diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift index cff371729..185c42e40 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import FoundationExt import SwiftUINavigationExt import Utilities +import DesignSystem struct SearchRootView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift index f2131c4b3..24fa901a7 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct SearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift index 793273082..4b0cad56f 100644 --- a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt +import DesignSystem struct QuickSearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift index 6ee04af73..747d534e6 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift @@ -5,6 +5,7 @@ import SwiftUINavigationExt import HapticsClient import ClipboardClient import CookieClient +import DesignSystem @Reducer struct AccountSettingReducer { diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift index 03a10d95c..a2f71a413 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct AccountSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift index 848b2e361..4003298ec 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt +import DesignSystem struct AppearanceSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift index b59e4facc..9778c3cba 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift @@ -1,6 +1,7 @@ import SwiftUI import Resources import Utilities +import DesignSystem struct AboutView: View { private var version: String { diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/LaboratorySettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/Components/LaboratorySettingView.swift index 7826f20a2..188682a53 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Components/LaboratorySettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Components/LaboratorySettingView.swift @@ -1,6 +1,7 @@ import SwiftUI import Resources import SFSafeSymbols +import DesignSystem struct LaboratorySettingView: View { @Binding private var bypassesSNIFiltering: Bool diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift index 77f56d88e..bb12b8c3e 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities +import DesignSystem struct EhSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift index 0b6414e28..274e5010b 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift @@ -4,6 +4,7 @@ import Resources import FilePicker import ComposableArchitecture import SwiftUINavigationExt +import DesignSystem struct GeneralSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift index 4d092191f..b4bf94ba1 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture +import DesignSystem struct LoginView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift index 17585ae5e..c94c0d235 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift @@ -3,6 +3,7 @@ import Resources import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt +import DesignSystem struct SettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift index 1959a69d2..a1a976220 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import SFSafeSymbols import Utilities +import DesignSystem struct LoadingView: View { private let title: String diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift index ef0f25c0f..08d3d1ce5 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Utilities +import DesignSystem // MARK: CategoryLabel struct CategoryLabel: View { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift index a2e1acb4f..3bedfe366 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift @@ -4,6 +4,7 @@ import Colorful import Kingfisher import UIImageColors import Utilities +import DesignSystem struct GalleryCardCell: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift index 154586a00..c7dbd7857 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Kingfisher +import DesignSystem struct GalleryDetailCell: View { enum CoverSource { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift index 2a6666211..ccbaa95b9 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Kingfisher +import DesignSystem struct GalleryHistoryCell: View { private let gallery: Gallery diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift index 588665fe1..f1f6796fc 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Kingfisher +import DesignSystem struct GalleryRankingCell: View { private let gallery: Gallery diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift index 9c492fa61..e25040446 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Kingfisher +import DesignSystem struct GalleryThumbnailCell: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift b/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift index 5c1e54c36..f51cf643f 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Utilities +import DesignSystem struct Placeholder: View { @Environment(\.inSheet) private var inSheet diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift index bef2e005a..5f5fc11ae 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift @@ -3,6 +3,7 @@ import AppModels import ImageIO import Kingfisher import FoundationExt +import DesignSystem struct PreviewImageView: View { private let originalURL: URL? diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/SettingTextField.swift b/AppPackage/Sources/AppFeature/View/Support/Components/SettingTextField.swift index 7b3d5627d..d257b5597 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/SettingTextField.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/SettingTextField.swift @@ -1,4 +1,5 @@ import SwiftUI +import DesignSystem struct SettingTextField: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift index b7c1d0d15..e8e6d5516 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift @@ -4,6 +4,7 @@ import SwiftUI import Kingfisher import FoundationExt +import DesignSystem struct TagCloudView: View where TagCell: View, Element: Equatable & Identifiable, ID == Element.ID { diff --git a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift index 1119f17f4..bbaffa4cf 100644 --- a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt +import DesignSystem struct FiltersView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift b/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift index 42623272a..80f2562bf 100644 --- a/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppModels import Resources import Utilities +import DesignSystem struct NewDawnView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 1fc69620f..bfa6bd06b 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -4,6 +4,7 @@ import Resources import SFSafeSymbols import ComposableArchitecture import Utilities +import DesignSystem struct TabBarView: View { @Environment(\.scenePhase) private var scenePhase diff --git a/AppPackage/Sources/DesignSystem/.swiftlint.yml b/AppPackage/Sources/DesignSystem/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DesignSystem/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/Tools/CategoryColor.swift b/AppPackage/Sources/DesignSystem/CategoryColor.swift similarity index 87% rename from AppPackage/Sources/AppFeature/Tools/CategoryColor.swift rename to AppPackage/Sources/DesignSystem/CategoryColor.swift index 57c441c96..ab011679a 100644 --- a/AppPackage/Sources/AppFeature/Tools/CategoryColor.swift +++ b/AppPackage/Sources/DesignSystem/CategoryColor.swift @@ -6,13 +6,13 @@ import Utilities // currently browsing. The runtime lookup (UserDefaults via AppUtil) lives here in the app // layer so the model types stay free of that dependency. extension AppModels.Category { - var color: Color { + public var color: Color { color(host: AppUtil.galleryHost) } } extension Gallery { - var color: Color { + public var color: Color { category.color } } diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/SwiftUINavigation_Extension.swift b/AppPackage/Sources/DesignSystem/SwiftUINavigation_Extension.swift similarity index 86% rename from AppPackage/Sources/AppFeature/Tools/Extensions/SwiftUINavigation_Extension.swift rename to AppPackage/Sources/DesignSystem/SwiftUINavigation_Extension.swift index ff2fcf752..b46fbf3ff 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/SwiftUINavigation_Extension.swift +++ b/AppPackage/Sources/DesignSystem/SwiftUINavigation_Extension.swift @@ -4,7 +4,7 @@ import SwiftUINavigation import SwiftUINavigationExt extension View { - func progressHUD( + public func progressHUD( config: ProgressHUDConfigState, unwrapping enum: Binding, case caseKeyPath: CaseKeyPath diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/TTProgressHUD_Extension.swift b/AppPackage/Sources/DesignSystem/TTProgressHUD_Extension.swift similarity index 59% rename from AppPackage/Sources/AppFeature/Tools/Extensions/TTProgressHUD_Extension.swift rename to AppPackage/Sources/DesignSystem/TTProgressHUD_Extension.swift index 2303398aa..e7483176b 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/TTProgressHUD_Extension.swift +++ b/AppPackage/Sources/DesignSystem/TTProgressHUD_Extension.swift @@ -1,7 +1,7 @@ import TTProgressHUD import Resources -enum ProgressHUDConfigState: Equatable, Sendable { +public enum ProgressHUDConfigState: Equatable, Sendable { case loading(title: String? = nil) case communicating case error(caption: String? = nil) @@ -10,7 +10,7 @@ enum ProgressHUDConfigState: Equatable, Sendable { case copiedToClipboardSucceeded @MainActor - var progressHUDConfig: TTProgressHUDConfig { + public var progressHUDConfig: TTProgressHUDConfig { switch self { case .loading(let title): return .loading(title: title) @@ -30,26 +30,30 @@ enum ProgressHUDConfigState: Equatable, Sendable { extension TTProgressHUDConfig { @MainActor - static var error: Self { error(caption: nil) } + public static var error: Self { error(caption: nil) } @MainActor - static var loading: Self { loading(title: L10n.Localizable.Hud.Title.loading) } + public static var loading: Self { loading(title: L10n.Localizable.Hud.Title.loading) } @MainActor - static var communicating: Self { loading(title: L10n.Localizable.Hud.Title.communicating) } + public static var communicating: Self { loading(title: L10n.Localizable.Hud.Title.communicating) } @MainActor - static var savedToPhotoLibrary: Self { success(caption: L10n.Localizable.Hud.Caption.savedToPhotoLibrary) } + public static var savedToPhotoLibrary: Self { + success(caption: L10n.Localizable.Hud.Caption.savedToPhotoLibrary) + } @MainActor - static var copiedToClipboardSucceeded: Self { success(caption: L10n.Localizable.Hud.Caption.copiedToClipboard) } + public static var copiedToClipboardSucceeded: Self { + success(caption: L10n.Localizable.Hud.Caption.copiedToClipboard) + } - static func loading(title: String? = nil) -> Self { + public static func loading(title: String? = nil) -> Self { .init(type: .loading, title: title) } - static func error(caption: String? = nil) -> Self { + public static func error(caption: String? = nil) -> Self { autoHide(type: .error, title: L10n.Localizable.Hud.Title.error, caption: caption) } - static func success(caption: String? = nil) -> Self { + public static func success(caption: String? = nil) -> Self { autoHide(type: .success, title: L10n.Localizable.Hud.Title.success, caption: caption) } - static func autoHide(type: TTProgressHUDType, title: String? = nil, caption: String? = nil) -> Self { + public static func autoHide(type: TTProgressHUDType, title: String? = nil, caption: String? = nil) -> Self { .init(type: type, title: title, caption: caption, shouldAutoHide: true, autoHideInterval: 1) } } diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift b/AppPackage/Sources/DesignSystem/ViewModifiers.swift similarity index 69% rename from AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift rename to AppPackage/Sources/DesignSystem/ViewModifiers.swift index 7946be638..aaebe0e41 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/ViewModifiers.swift +++ b/AppPackage/Sources/DesignSystem/ViewModifiers.swift @@ -1,20 +1,21 @@ import SwiftUI import Kingfisher +import SFSafeSymbols import FoundationExt import Parser extension View { - func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + public func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { clipShape(RoundedCorner(radius: radius, corners: corners)) } - @ViewBuilder func withHorizontalSpacing(width: CGFloat = 8, height: CGFloat? = nil) -> some View { + @ViewBuilder public func withHorizontalSpacing(width: CGFloat = 8, height: CGFloat? = nil) -> some View { Color.clear.frame(width: width, height: height) self Color.clear.frame(width: width, height: height) } - func withArrow(isVisible: Bool = true) -> some View { + public func withArrow(isVisible: Bool = true) -> some View { HStack { self Spacer() @@ -25,13 +26,13 @@ extension View { } } - func autoBlur(radius: Double) -> some View { + public func autoBlur(radius: Double) -> some View { blur(radius: radius) .allowsHitTesting(radius < 1) .animation(.linear(duration: 0.1), value: radius) } - func synchronize( + public func synchronize( _ first: Binding, _ second: Binding, initial: (first: Bool, second: Bool) = (false, false) @@ -45,7 +46,7 @@ extension View { } } - func synchronize( + public func synchronize( _ first: Binding, _ second: FocusState.Binding, initial: (first: Bool, second: Bool) = (false, false) @@ -60,26 +61,28 @@ extension View { } } -struct PlainLinearProgressViewStyle: ProgressViewStyle { - func makeBody(configuration: ProgressViewStyleConfiguration) -> some View { +public struct PlainLinearProgressViewStyle: ProgressViewStyle { + public init() {} + + public func makeBody(configuration: ProgressViewStyleConfiguration) -> some View { ProgressView(value: CGFloat(configuration.fractionCompleted ?? 0), total: 1) } } extension ProgressViewStyle where Self == PlainLinearProgressViewStyle { - static var plainLinear: PlainLinearProgressViewStyle { + public static var plainLinear: PlainLinearProgressViewStyle { PlainLinearProgressViewStyle() } } // MARK: Image Modifier -struct CornersModifier: ImageModifier { +public struct CornersModifier: ImageModifier { let radius: CGFloat? - init(radius: CGFloat? = nil) { + public init(radius: CGFloat? = nil) { self.radius = radius } - func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { + public func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { if let radius = radius { return image.withRoundedCorners(radius: radius) ?? image } else { @@ -88,16 +91,16 @@ struct CornersModifier: ImageModifier { } } -struct OffsetModifier: ImageModifier { +public struct OffsetModifier: ImageModifier { private let size: CGSize? private let offset: CGSize? - init(size: CGSize?, offset: CGSize?) { + public init(size: CGSize?, offset: CGSize?) { self.size = size self.offset = offset } - func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { + public func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { guard let size = size, let offset = offset else { return image } @@ -105,16 +108,16 @@ struct OffsetModifier: ImageModifier { } } -struct RoundedOffsetModifier: ImageModifier { +public struct RoundedOffsetModifier: ImageModifier { private let size: CGSize? private let offset: CGSize? - init(size: CGSize?, offset: CGSize?) { + public init(size: CGSize?, offset: CGSize?) { self.size = size self.offset = offset } - func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { + public func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { guard let size = size, let offset = offset, let croppedImg = image.cropping(size: size, offset: offset), let roundedCroppedImg = croppedImg.withRoundedCorners(radius: 5) @@ -124,16 +127,16 @@ struct RoundedOffsetModifier: ImageModifier { } } -struct WebtoonModifier: ImageModifier { +public struct WebtoonModifier: ImageModifier { private let minAspect: CGFloat private let idealAspect: CGFloat - init(minAspect: CGFloat, idealAspect: CGFloat) { + public init(minAspect: CGFloat, idealAspect: CGFloat) { self.minAspect = minAspect self.idealAspect = idealAspect } - func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { + public func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { let width = image.size.width let height = image.size.height let idealHeight = width / idealAspect @@ -143,7 +146,7 @@ struct WebtoonModifier: ImageModifier { } extension KFImage { - func defaultModifier(withRoundedCorners: Bool = true) -> KFImage { + public func defaultModifier(withRoundedCorners: Bool = true) -> KFImage { self .imageModifier(CornersModifier( radius: withRoundedCorners ? 5 : nil @@ -153,11 +156,16 @@ extension KFImage { } } -struct RoundedCorner: Shape { +public struct RoundedCorner: Shape { var radius: CGFloat = .infinity var corners: UIRectCorner = .allCorners - func path(in rect: CGRect) -> Path { + public init(radius: CGFloat = .infinity, corners: UIRectCorner = .allCorners) { + self.radius = radius + self.corners = corners + } + + public func path(in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, byRoundingCorners: corners, @@ -170,8 +178,8 @@ struct RoundedCorner: Shape { } } -struct PreviewResolver { - static func getPreviewConfigs(originalURL: URL?) -> (URL?, ImageModifier) { +public struct PreviewResolver { + public static func getPreviewConfigs(originalURL: URL?) -> (URL?, ImageModifier) { guard let url = originalURL, let info = Parser.parsePreviewConfigs(url: url) else { From 08977be9aa08ca9a357ea48c95a5e0346f09ec0f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 21:14:41 +0800 Subject: [PATCH 326/614] Extract AppComponents module; move shared views + relocate ToplistsType to AppModels --- AppPackage/Package.swift | 20 +++++++ .../Sources/AppComponents/.swiftlint.yml | 1 + .../Sources/AppComponents/ActivityView.swift | 14 +++++ .../AlertKit_Extension.swift | 2 +- .../AlertView.swift | 42 +++++++------- .../CategoryView.swift | 12 ++-- .../Cells/GalleryCardCell.swift | 6 +- .../Cells/GalleryDetailCell.swift | 9 +-- .../Cells/GalleryHistoryCell.swift | 6 +- .../Cells/GalleryRankingCell.swift | 6 +- .../Cells/GalleryThumbnailCell.swift | 7 ++- .../DateSeekPickerView.swift | 14 ++++- .../DownloadBadgeLabel.swift | 7 ++- .../GenericList.swift | 8 +-- .../Placeholder.swift | 8 +-- .../PreviewImageView.swift | 8 +-- .../RatingView.swift | 7 ++- .../SettingTextField.swift | 6 +- .../SubSection.swift | 6 +- .../TagCloudView.swift | 13 +++-- .../TagSuggestionView.swift | 7 ++- .../ToolbarItems.swift | 55 ++++++++++--------- .../WaveForm.swift | 6 +- .../View/Detail/Archives/ArchivesView.swift | 1 + .../View/Detail/Comments/CommentsView.swift | 1 + .../Detail/Components/TagDetailView.swift | 1 + .../DetailSearch/DetailSearchView.swift | 1 + .../Detail/DetailView+HeaderSection.swift | 1 + .../View/Detail/DetailView+Navigation.swift | 1 + .../View/Detail/DetailView+Subviews.swift | 1 + .../AppFeature/View/Detail/DetailView.swift | 1 + .../View/Detail/Previews/PreviewsView.swift | 1 + .../View/Detail/Torrents/TorrentsView.swift | 1 + .../Downloads/DownloadsView+Subviews.swift | 1 + .../View/Downloads/DownloadsView.swift | 1 + .../View/Downloads/FolderManagerView.swift | 1 + .../View/Favorites/FavoritesView.swift | 1 + .../View/Home/Frontpage/FrontpageView.swift | 1 + .../View/Home/History/HistoryView.swift | 1 + .../View/Home/HomeReducer+Body.swift | 1 + .../View/Home/HomeView+Sections.swift | 1 + .../AppFeature/View/Home/HomeView.swift | 1 + .../View/Home/Popular/PopularView.swift | 1 + .../View/Home/Toplists/ToplistsView.swift | 38 +------------ .../View/Home/Watched/WatchedView.swift | 1 + .../View/Migration/MigrationView.swift | 1 + .../AppFeature/View/Reading/ReadingView.swift | 1 + .../View/Reading/ReadingViewComponents.swift | 1 + .../View/Reading/Support/ControlPanel.swift | 1 + .../View/Search/SearchRootView.swift | 1 + .../AppFeature/View/Search/SearchView.swift | 1 + .../View/Search/Support/QuickSearchView.swift | 1 + .../EhSetting/EhSettingView+Sections1.swift | 1 + .../EhSetting/EhSettingView+Sections2.swift | 1 + .../Setting/EhSetting/EhSettingView.swift | 1 + .../View/Setting/Login/LoginView.swift | 1 + .../View/Setting/Logs/LogsView.swift | 1 + .../Support/Components/ActivityView.swift | 14 ----- .../View/Support/DateSeekReducer.swift | 1 + .../AppFeature/View/Support/FiltersView.swift | 1 + .../AppModels/Support/ToplistsType.swift | 37 +++++++++++++ 61 files changed, 234 insertions(+), 160 deletions(-) create mode 100644 AppPackage/Sources/AppComponents/.swiftlint.yml create mode 100644 AppPackage/Sources/AppComponents/ActivityView.swift rename AppPackage/Sources/{AppFeature/Tools/Extensions => AppComponents}/AlertKit_Extension.swift (98%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/AlertView.swift (79%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/CategoryView.swift (92%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/Cells/GalleryCardCell.swift (96%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/Cells/GalleryDetailCell.swift (97%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/Cells/GalleryHistoryCell.swift (91%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/Cells/GalleryRankingCell.swift (91%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/Cells/GalleryThumbnailCell.swift (97%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/DateSeekPickerView.swift (93%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/DownloadBadgeLabel.swift (91%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/GenericList.swift (98%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/Placeholder.swift (90%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/PreviewImageView.swift (96%) rename AppPackage/Sources/{AppFeature/View/Detail/Components => AppComponents}/RatingView.swift (95%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/SettingTextField.swift (92%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/SubSection.swift (95%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/TagCloudView.swift (94%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/TagSuggestionView.swift (96%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/ToolbarItems.swift (75%) rename AppPackage/Sources/{AppFeature/View/Support/Components => AppComponents}/WaveForm.swift (92%) delete mode 100644 AppPackage/Sources/AppFeature/View/Support/Components/ActivityView.swift create mode 100644 AppPackage/Sources/AppModels/Support/ToplistsType.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index bd4de2d55..0c97535b5 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -66,6 +66,7 @@ let sharedSwiftSettings: [PackageDescription.SwiftSetting] = [ // MARK: Module enum Module: String { + case appComponents = "AppComponents" case appDelegateClient = "AppDelegateClient" case appFeature = "AppFeature" case appLaunchAutomationClient = "AppLaunchAutomationClient" @@ -231,6 +232,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .appFeature, dependencies: [ + .module(.appComponents), .module(.appDelegateClient), .module(.appLaunchAutomationClient), .module(.appModels), @@ -485,6 +487,24 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .appComponents, + dependencies: [ + .module(.appModels), + .module(.designSystem), + .module(.foundationExt), + .module(.resources), + .module(.utilities), + .targetDependency(.alertKit), + .targetDependency(.colorful), + .targetDependency(.kingfisher), + .targetDependency(.sfSafeSymbols), + .targetDependency(.uiImageColors), + .targetDependency(.waterfallGrid) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .sdWebImageExt, dependencies: [ diff --git a/AppPackage/Sources/AppComponents/.swiftlint.yml b/AppPackage/Sources/AppComponents/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AppComponents/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppComponents/ActivityView.swift b/AppPackage/Sources/AppComponents/ActivityView.swift new file mode 100644 index 000000000..0618747c4 --- /dev/null +++ b/AppPackage/Sources/AppComponents/ActivityView.swift @@ -0,0 +1,14 @@ +import SwiftUI + +public struct ActivityView: UIViewControllerRepresentable { + private var activityItems: [Any] + + public init(activityItems: [Any]) { + self.activityItems = activityItems + } + + public func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + public func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift b/AppPackage/Sources/AppComponents/AlertKit_Extension.swift similarity index 98% rename from AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift rename to AppPackage/Sources/AppComponents/AlertKit_Extension.swift index 47cdef711..38d075494 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/AlertKit_Extension.swift +++ b/AppPackage/Sources/AppComponents/AlertKit_Extension.swift @@ -5,7 +5,7 @@ import AlertKit import DesignSystem extension View { - func jumpPageAlert( + public func jumpPageAlert( index: Binding, isPresented: Binding, isFocused: Binding, pageNumber: PageNumber, jumpAction: @escaping () -> Void ) -> some View { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift b/AppPackage/Sources/AppComponents/AlertView.swift similarity index 79% rename from AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift rename to AppPackage/Sources/AppComponents/AlertView.swift index a1a976220..3080d5d32 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/AlertView.swift +++ b/AppPackage/Sources/AppComponents/AlertView.swift @@ -5,28 +5,28 @@ import SFSafeSymbols import Utilities import DesignSystem -struct LoadingView: View { +public struct LoadingView: View { private let title: String - init(title: String = L10n.Localizable.LoadingView.Title.loading) { + public init(title: String = L10n.Localizable.LoadingView.Title.loading) { self.title = title } - var body: some View { + public var body: some View { ProgressView(title) } } -struct FetchMoreFooter: View { +public struct FetchMoreFooter: View { private let loadingState: LoadingState private let retryAction: (() -> Void)? - init(loadingState: LoadingState, retryAction: (() -> Void)?) { + public init(loadingState: LoadingState, retryAction: (() -> Void)?) { self.loadingState = loadingState self.retryAction = retryAction } - var body: some View { + public var body: some View { HStack(alignment: .center) { Spacer() ZStack { @@ -45,14 +45,14 @@ struct FetchMoreFooter: View { } } -struct NotLoginView: View { +public struct NotLoginView: View { private let action: () -> Void - init(action: @escaping () -> Void) { + public init(action: @escaping () -> Void) { self.action = action } - var body: some View { + public var body: some View { AlertView( symbol: .personCropCircleBadgeQuestionmarkFill, message: L10n.Localizable.NotLoginView.Title.needLogin @@ -62,12 +62,12 @@ struct NotLoginView: View { } } -struct ErrorView: View { +public struct ErrorView: View { private let error: AppError private let buttonTitle: String private let action: (() -> Void)? - init( + public init( error: AppError, buttonTitle: String = L10n.Localizable.ErrorView.Button.retry, action: (() -> Void)? = nil @@ -77,7 +77,7 @@ struct ErrorView: View { self.action = action } - var body: some View { + public var body: some View { AlertView(symbol: error.symbol, message: error.alertText) { if let action = action { AlertViewButton(title: buttonTitle, action: action) @@ -86,19 +86,19 @@ struct ErrorView: View { } } -struct AlertView: View { +public struct AlertView: View { @Environment(\.colorScheme) private var colorScheme private let symbol: SFSymbol private let message: String private let actions: Content - init(symbol: SFSymbol, message: String, @ViewBuilder actions: () -> Content) { + public init(symbol: SFSymbol, message: String, @ViewBuilder actions: () -> Content) { self.symbol = symbol self.message = message self.actions = actions() } - var body: some View { + public var body: some View { VStack { Image(systemSymbol: symbol).font(.system(size: 50)).padding(.bottom, 15) Text(message).multilineTextAlignment(.center).foregroundStyle(.gray) @@ -109,16 +109,16 @@ struct AlertView: View { } } -struct AlertViewButton: View { +public struct AlertViewButton: View { private let title: String private let action: () -> Void - init(title: String, action: @escaping () -> Void) { + public init(title: String, action: @escaping () -> Void) { self.title = title self.action = action } - var body: some View { + public var body: some View { Button(action: action) { Text(title) .foregroundColor(.primary.opacity(0.7)) @@ -129,19 +129,19 @@ struct AlertViewButton: View { } } -struct PageJumpView: View { +public struct PageJumpView: View { @Environment(\.colorScheme) private var colorScheme @Binding private var inputText: String private var isFocused: FocusState.Binding private let pageNumber: PageNumber - init(inputText: Binding, isFocused: FocusState.Binding, pageNumber: PageNumber) { + public init(inputText: Binding, isFocused: FocusState.Binding, pageNumber: PageNumber) { _inputText = inputText self.isFocused = isFocused self.pageNumber = pageNumber } - var body: some View { + public var body: some View { VStack { Text(L10n.Localizable.JumpPageView.Title.jumpPage).bold() HStack { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift b/AppPackage/Sources/AppComponents/CategoryView.swift similarity index 92% rename from AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift rename to AppPackage/Sources/AppComponents/CategoryView.swift index 08d3d1ce5..5ed2c2adf 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/CategoryView.swift +++ b/AppPackage/Sources/AppComponents/CategoryView.swift @@ -4,7 +4,7 @@ import Utilities import DesignSystem // MARK: CategoryLabel -struct CategoryLabel: View { +public struct CategoryLabel: View { private let text: String private let color: Color private let font: Font @@ -12,7 +12,7 @@ struct CategoryLabel: View { private let cornerRadius: CGFloat private let corners: UIRectCorner - init( + public init( text: String, color: Color, font: Font = .footnote, insets: EdgeInsets = .init(top: 1, leading: 3, bottom: 1, trailing: 3), cornerRadius: CGFloat = 2, corners: UIRectCorner = .allCorners @@ -25,7 +25,7 @@ struct CategoryLabel: View { self.corners = corners } - var body: some View { + public var body: some View { Text(text).font(font.bold()).lineLimit(1).foregroundStyle(.white) .padding(insets).background( Rectangle().foregroundStyle(color).cornerRadius(cornerRadius, corners: corners) @@ -34,7 +34,7 @@ struct CategoryLabel: View { } // MARK: CategoryView -struct CategoryView: View { +public struct CategoryView: View { private let bindings: [Binding] private let gridItems = [ @@ -46,12 +46,12 @@ struct CategoryView: View { } } - init?(bindings: [Binding]) { + public init?(bindings: [Binding]) { guard bindings.count == 10 else { return nil } self.bindings = bindings } - var body: some View { + public var body: some View { LazyVGrid(columns: gridItems) { ForEach(tuples, id: \.1) { isFiltered, category in CategoryCell(isFiltered: isFiltered, category: category) diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift b/AppPackage/Sources/AppComponents/Cells/GalleryCardCell.swift similarity index 96% rename from AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift rename to AppPackage/Sources/AppComponents/Cells/GalleryCardCell.swift index 3bedfe366..c9d5df266 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryCardCell.swift +++ b/AppPackage/Sources/AppComponents/Cells/GalleryCardCell.swift @@ -6,7 +6,7 @@ import UIImageColors import Utilities import DesignSystem -struct GalleryCardCell: View { +public struct GalleryCardCell: View { @Environment(\.colorScheme) private var colorScheme private let currentID: String @@ -18,7 +18,7 @@ struct GalleryCardCell: View { private let animation: Animation = .interpolatingSpring(stiffness: 50, damping: 1).speed(0.2) - init( + public init( gallery: Gallery, currentID: String, colors: [Color], webImageSuccessAction: @escaping (RetrieveImageResult) -> Void ) { @@ -40,7 +40,7 @@ struct GalleryCardCell: View { return trimmedTitle } - var body: some View { + public var body: some View { ZStack { Color.gray.opacity(0.2) ColorfulView(animated: animated, animation: animation, colors: colors) diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift b/AppPackage/Sources/AppComponents/Cells/GalleryDetailCell.swift similarity index 97% rename from AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift rename to AppPackage/Sources/AppComponents/Cells/GalleryDetailCell.swift index c7dbd7857..fe50bd5cb 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/AppPackage/Sources/AppComponents/Cells/GalleryDetailCell.swift @@ -1,10 +1,11 @@ import SwiftUI +import SFSafeSymbols import AppModels import Kingfisher import DesignSystem -struct GalleryDetailCell: View { - enum CoverSource { +public struct GalleryDetailCell: View { + public enum CoverSource { case dynamic case `static`(URL?) } @@ -17,7 +18,7 @@ struct GalleryDetailCell: View { private let translateAction: ((String) -> (String, TagTranslation?))? private let downloadBadge: DownloadBadge? - init( + public init( gallery: Gallery, coverSource: CoverSource = .dynamic, setting: Setting, @@ -40,7 +41,7 @@ struct GalleryDetailCell: View { } } - var body: some View { + public var body: some View { GalleryDetailCellContent( gallery: gallery, resolvedCoverURL: resolvedCoverURL, diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift b/AppPackage/Sources/AppComponents/Cells/GalleryHistoryCell.swift similarity index 91% rename from AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift rename to AppPackage/Sources/AppComponents/Cells/GalleryHistoryCell.swift index ccbaa95b9..07b6b1a52 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryHistoryCell.swift +++ b/AppPackage/Sources/AppComponents/Cells/GalleryHistoryCell.swift @@ -3,14 +3,14 @@ import AppModels import Kingfisher import DesignSystem -struct GalleryHistoryCell: View { +public struct GalleryHistoryCell: View { private let gallery: Gallery - init(gallery: Gallery) { + public init(gallery: Gallery) { self.gallery = gallery } - var body: some View { + public var body: some View { HStack(spacing: 20) { KFImage(gallery.coverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }.defaultModifier() diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift b/AppPackage/Sources/AppComponents/Cells/GalleryRankingCell.swift similarity index 91% rename from AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift rename to AppPackage/Sources/AppComponents/Cells/GalleryRankingCell.swift index f1f6796fc..c53841ec0 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/AppPackage/Sources/AppComponents/Cells/GalleryRankingCell.swift @@ -3,16 +3,16 @@ import AppModels import Kingfisher import DesignSystem -struct GalleryRankingCell: View { +public struct GalleryRankingCell: View { private let gallery: Gallery private let ranking: Int - init(gallery: Gallery, ranking: Int) { + public init(gallery: Gallery, ranking: Int) { self.gallery = gallery self.ranking = ranking } - var body: some View { + public var body: some View { HStack { KFImage(gallery.coverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }.defaultModifier() diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift b/AppPackage/Sources/AppComponents/Cells/GalleryThumbnailCell.swift similarity index 97% rename from AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift rename to AppPackage/Sources/AppComponents/Cells/GalleryThumbnailCell.swift index e25040446..bd14f4d37 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/AppPackage/Sources/AppComponents/Cells/GalleryThumbnailCell.swift @@ -1,9 +1,10 @@ import SwiftUI +import SFSafeSymbols import AppModels import Kingfisher import DesignSystem -struct GalleryThumbnailCell: View { +public struct GalleryThumbnailCell: View { @Environment(\.colorScheme) private var colorScheme private let gallery: Gallery @@ -11,7 +12,7 @@ struct GalleryThumbnailCell: View { private let translateAction: ((String) -> (String, TagTranslation?))? private let downloadBadge: DownloadBadge? - init( + public init( gallery: Gallery, setting: Setting, translateAction: ((String) -> (String, TagTranslation?))? = nil, @@ -30,7 +31,7 @@ struct GalleryThumbnailCell: View { colorScheme == .light ? Color(.systemGray5) : Color(.systemGray4) } - var body: some View { + public var body: some View { VStack(alignment: .leading, spacing: 0) { KFImage(gallery.coverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.rowAspect)) } diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift b/AppPackage/Sources/AppComponents/DateSeekPickerView.swift similarity index 93% rename from AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift rename to AppPackage/Sources/AppComponents/DateSeekPickerView.swift index 05d5a40e2..51d4e2fbf 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/DateSeekPickerView.swift +++ b/AppPackage/Sources/AppComponents/DateSeekPickerView.swift @@ -12,12 +12,22 @@ import SwiftUI /// - Precondition: `selectedDate` lies within `navigation.dateRange`. The picker renders the /// binding as-is and does not clamp it; keeping the date in range is the responsibility of /// whoever owns the date state (the embedded `DateSeekReducer` does so in its `present` action). -struct DateSeekPickerView: View { +public struct DateSeekPickerView: View { @Binding var selectedDate: Date let navigation: DateSeekNavigation let seekAction: (DateSeekDirection) -> Void - var body: some View { + public init( + selectedDate: Binding, + navigation: DateSeekNavigation, + seekAction: @escaping (DateSeekDirection) -> Void + ) { + _selectedDate = selectedDate + self.navigation = navigation + self.seekAction = seekAction + } + + public var body: some View { NavigationView { Form { Section { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift b/AppPackage/Sources/AppComponents/DownloadBadgeLabel.swift similarity index 91% rename from AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift rename to AppPackage/Sources/AppComponents/DownloadBadgeLabel.swift index 8162c820d..b618b7af0 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/DownloadBadgeLabel.swift +++ b/AppPackage/Sources/AppComponents/DownloadBadgeLabel.swift @@ -1,15 +1,16 @@ import SwiftUI +import SFSafeSymbols import AppModels import Resources -struct DownloadBadgeLabel: View { +public struct DownloadBadgeLabel: View { private let badge: DownloadBadge - init(badge: DownloadBadge) { + public init(badge: DownloadBadge) { self.badge = badge } - var body: some View { + public var body: some View { HStack(spacing: 4) { Image(systemSymbol: badge.symbol) .font(.caption.bold()) diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift b/AppPackage/Sources/AppComponents/GenericList.swift similarity index 98% rename from AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift rename to AppPackage/Sources/AppComponents/GenericList.swift index 58acb72c5..9f3767f89 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/GenericList.swift +++ b/AppPackage/Sources/AppComponents/GenericList.swift @@ -1,10 +1,10 @@ import SwiftUI +import SFSafeSymbols import AppModels import WaterfallGrid -import ComposableArchitecture import Utilities -struct GenericList: View { +public struct GenericList: View { private let galleries: [Gallery] private let setting: Setting private let downloadBadges: [String: DownloadBadge] @@ -16,7 +16,7 @@ struct GenericList: View { private let navigateAction: ((String) -> Void)? private let translateAction: ((String) -> (String, TagTranslation?))? - init( + public init( galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, loadingState: LoadingState, footerLoadingState: LoadingState, fetchAction: (() -> Void)? = nil, @@ -37,7 +37,7 @@ struct GenericList: View { self.translateAction = translateAction } - var body: some View { + public var body: some View { ZStack { VStack(spacing: 0) { switch setting.listDisplayMode { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift b/AppPackage/Sources/AppComponents/Placeholder.swift similarity index 90% rename from AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift rename to AppPackage/Sources/AppComponents/Placeholder.swift index f51cf643f..f801d8a75 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/Placeholder.swift +++ b/AppPackage/Sources/AppComponents/Placeholder.swift @@ -3,15 +3,15 @@ import AppModels import Utilities import DesignSystem -struct Placeholder: View { +public struct Placeholder: View { @Environment(\.inSheet) private var inSheet private let style: PlaceholderStyle - init(style: PlaceholderStyle) { + public init(style: PlaceholderStyle) { self.style = style } - var body: some View { + public var body: some View { switch style { case .activity(let ratio, let cornerRadius): ZStack { @@ -44,7 +44,7 @@ struct Placeholder: View { } } -enum PlaceholderStyle { +public enum PlaceholderStyle { case activity(ratio: CGFloat, cornerRadius: CGFloat = 5) case progress(pageNumber: Int, progress: Progress?, isDualPage: Bool = false, backgroundColor: Color) } diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift b/AppPackage/Sources/AppComponents/PreviewImageView.swift similarity index 96% rename from AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift rename to AppPackage/Sources/AppComponents/PreviewImageView.swift index 5f5fc11ae..b328a32cb 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/PreviewImageView.swift +++ b/AppPackage/Sources/AppComponents/PreviewImageView.swift @@ -5,12 +5,12 @@ import Kingfisher import FoundationExt import DesignSystem -struct PreviewImageView: View { +public struct PreviewImageView: View { private let originalURL: URL? private let maxPixelSize: CGFloat - private static let defaultMaxPixelSize = Defaults.ImageSize.previewMaxW * 3 + public static let defaultMaxPixelSize = Defaults.ImageSize.previewMaxW * 3 - init( + public init( originalURL: URL?, maxPixelSize: CGFloat = PreviewImageView.defaultMaxPixelSize ) { @@ -18,7 +18,7 @@ struct PreviewImageView: View { self.maxPixelSize = maxPixelSize } - var body: some View { + public var body: some View { if let originalURL, originalURL.isFileURL { LocalPreviewImageView(fileURL: originalURL, maxPixelSize: maxPixelSize) { Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) diff --git a/AppPackage/Sources/AppFeature/View/Detail/Components/RatingView.swift b/AppPackage/Sources/AppComponents/RatingView.swift similarity index 95% rename from AppPackage/Sources/AppFeature/View/Detail/Components/RatingView.swift rename to AppPackage/Sources/AppComponents/RatingView.swift index 0882a31f1..d6d036b48 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Components/RatingView.swift +++ b/AppPackage/Sources/AppComponents/RatingView.swift @@ -1,14 +1,15 @@ import SwiftUI +import SFSafeSymbols import FoundationExt -struct RatingView: View { +public struct RatingView: View { private let rawRating: Float - init(rating: Float) { + public init(rating: Float) { self.rawRating = rating } - var body: some View { + public var body: some View { HStack(spacing: 0) { if rating == 0.0 { ForEach(0..<5) { _ in NotFilledStar() } diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/SettingTextField.swift b/AppPackage/Sources/AppComponents/SettingTextField.swift similarity index 92% rename from AppPackage/Sources/AppFeature/View/Support/Components/SettingTextField.swift rename to AppPackage/Sources/AppComponents/SettingTextField.swift index d257b5597..4eb77dca3 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/SettingTextField.swift +++ b/AppPackage/Sources/AppComponents/SettingTextField.swift @@ -1,7 +1,7 @@ import SwiftUI import DesignSystem -struct SettingTextField: View { +public struct SettingTextField: View { @Environment(\.colorScheme) private var colorScheme @Binding private var text: String @@ -19,7 +19,7 @@ struct SettingTextField: View { return Text(text) } - init( + public init( text: Binding, promptText: String? = nil, width: CGFloat? = 50, alignment: TextAlignment = .center, background: Color? = nil ) { @@ -30,7 +30,7 @@ struct SettingTextField: View { self.background = background } - var body: some View { + public var body: some View { TextField("", text: $text, prompt: prompt).keyboardType(.numbersAndPunctuation) .textInputAutocapitalization(.none).multilineTextAlignment(alignment) .disableAutocorrection(true).background(color).frame(width: width).cornerRadius(5) diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift b/AppPackage/Sources/AppComponents/SubSection.swift similarity index 95% rename from AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift rename to AppPackage/Sources/AppComponents/SubSection.swift index dd87fd0b1..113811172 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/SubSection.swift +++ b/AppPackage/Sources/AppComponents/SubSection.swift @@ -2,7 +2,7 @@ import SwiftUI import Resources import Utilities -struct SubSection: View { +public struct SubSection: View { private let title: String private let showAll: Bool private let tint: Color? @@ -11,7 +11,7 @@ struct SubSection: View { private let showAllAction: () -> Void private let content: Content - init( + public init( title: String, showAll: Bool = true, tint: Color? = nil, isLoading: Bool? = nil, reloadAction: (() -> Void)? = nil, @@ -27,7 +27,7 @@ struct SubSection: View { self.content = content() } - var body: some View { + public var body: some View { VStack(alignment: .leading) { HStack { Button { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift b/AppPackage/Sources/AppComponents/TagCloudView.swift similarity index 94% rename from AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift rename to AppPackage/Sources/AppComponents/TagCloudView.swift index e8e6d5516..2569a2f73 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/TagCloudView.swift +++ b/AppPackage/Sources/AppComponents/TagCloudView.swift @@ -2,18 +2,19 @@ // import SwiftUI +import SFSafeSymbols import Kingfisher import FoundationExt import DesignSystem -struct TagCloudView: View +public struct TagCloudView: View where TagCell: View, Element: Equatable & Identifiable, ID == Element.ID { private let data: [Element] private let id: KeyPath private let spacing: Double private let content: (Element) -> TagCell - init( + public init( data: Data, id: KeyPath = \Element.id, spacing: Double = 4, @ViewBuilder content: @escaping (Element) -> TagCell ) where Data.Index == Int, Data.Element == Element { @@ -23,7 +24,7 @@ where TagCell: View, Element: Equatable & Identifiable, ID == Element.ID { self.content = content } - var body: some View { + public var body: some View { FlowLayout(spacing: spacing) { ForEach(data, id: id) { element in content(element) @@ -94,7 +95,7 @@ private struct FlowLayout: Layout { } } -struct TagCloudCell: View { +public struct TagCloudCell: View { private let text: String private let imageURL: URL? private let showsImages: Bool @@ -103,7 +104,7 @@ struct TagCloudCell: View { private let textColor: Color private let backgroundColor: Color - init( + public init( text: String, imageURL: URL?, showsImages: Bool, font: Font, padding: EdgeInsets, textColor: Color, backgroundColor: Color ) { @@ -116,7 +117,7 @@ struct TagCloudCell: View { self.backgroundColor = backgroundColor } - var body: some View { + public var body: some View { HStack(spacing: 2) { Text(showsImages ? text : text.emojisRipped) if let imageURL = imageURL, showsImages { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift b/AppPackage/Sources/AppComponents/TagSuggestionView.swift similarity index 96% rename from AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift rename to AppPackage/Sources/AppComponents/TagSuggestionView.swift index 43d312748..de0e208fa 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/TagSuggestionView.swift +++ b/AppPackage/Sources/AppComponents/TagSuggestionView.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols import AppModels import Resources import Kingfisher @@ -6,7 +7,7 @@ import Observation import FoundationExt import Utilities -struct TagSuggestionView: View { +public struct TagSuggestionView: View { @Binding private var keyword: String private let translations: [String: TagTranslation] private let showsImages: Bool @@ -14,14 +15,14 @@ struct TagSuggestionView: View { @State private var translationHandler = TagTranslationHandler() - init(keyword: Binding, translations: [String: TagTranslation], showsImages: Bool, isEnabled: Bool) { + public init(keyword: Binding, translations: [String: TagTranslation], showsImages: Bool, isEnabled: Bool) { _keyword = keyword self.translations = translations self.showsImages = showsImages self.isEnabled = isEnabled } - var body: some View { + public var body: some View { if isEnabled { if DeviceUtil.isPhone { Text(L10n.Localizable.Searchable.Title.matchesCount(translationHandler.suggestions.count)) diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift b/AppPackage/Sources/AppComponents/ToolbarItems.swift similarity index 75% rename from AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift rename to AppPackage/Sources/AppComponents/ToolbarItems.swift index 1bba15518..947b1807a 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/ToolbarItems.swift +++ b/AppPackage/Sources/AppComponents/ToolbarItems.swift @@ -1,14 +1,15 @@ import SwiftUI +import SFSafeSymbols import AppModels import Resources -struct CustomToolbarItem: ToolbarContent { +public struct CustomToolbarItem: ToolbarContent { private let placement: ToolbarItemPlacement private let tint: Color? private let disabled: Bool private let content: Content - init(placement: ToolbarItemPlacement = .navigationBarTrailing, + public init(placement: ToolbarItemPlacement = .navigationBarTrailing, tint: Color? = nil, disabled: Bool = false, @ViewBuilder content: () -> Content ) { @@ -18,7 +19,7 @@ struct CustomToolbarItem: ToolbarContent { self.content = content() } - var body: some ToolbarContent { + public var body: some ToolbarContent { ToolbarItem(placement: placement) { HStack(spacing: 14) { content @@ -29,16 +30,16 @@ struct CustomToolbarItem: ToolbarContent { } } -struct ToolbarFeaturesMenu: View { +public struct ToolbarFeaturesMenu: View { private let content: Content private let symbolRenderingMode: SymbolRenderingMode - init(symbolRenderingMode: SymbolRenderingMode = .monochrome, @ViewBuilder content: () -> Content) { + public init(symbolRenderingMode: SymbolRenderingMode = .monochrome, @ViewBuilder content: () -> Content) { self.content = content() self.symbolRenderingMode = symbolRenderingMode } - var body: some View { + public var body: some View { Menu { content } label: { @@ -48,16 +49,16 @@ struct ToolbarFeaturesMenu: View { } } -struct FiltersButton: View { +public struct FiltersButton: View { private let hideText: Bool private let action: () -> Void - init(hideText: Bool = false, action: @escaping () -> Void) { + public init(hideText: Bool = false, action: @escaping () -> Void) { self.hideText = hideText self.action = action } - var body: some View { + public var body: some View { Button(action: action) { Image(systemSymbol: .line3HorizontalDecrease) if !hideText { @@ -67,16 +68,16 @@ struct FiltersButton: View { } } -struct QuickSearchButton: View { +public struct QuickSearchButton: View { private let hideText: Bool private let action: () -> Void - init(hideText: Bool = false, action: @escaping () -> Void) { + public init(hideText: Bool = false, action: @escaping () -> Void) { self.hideText = hideText self.action = action } - var body: some View { + public var body: some View { Button(action: action) { Image(systemSymbol: .magnifyingglass) if !hideText { @@ -86,18 +87,18 @@ struct QuickSearchButton: View { } } -struct JumpPageButton: View { +public struct JumpPageButton: View { private let pageNumber: PageNumber private let hideText: Bool private let action: () -> Void - init(pageNumber: PageNumber, hideText: Bool = false, action: @escaping () -> Void) { + public init(pageNumber: PageNumber, hideText: Bool = false, action: @escaping () -> Void) { self.pageNumber = pageNumber self.hideText = hideText self.action = action } - var body: some View { + public var body: some View { Button(action: action) { Image(systemSymbol: .arrowshapeBounceForward) if !hideText { @@ -108,16 +109,16 @@ struct JumpPageButton: View { } } -struct DateSeekButton: View { +public struct DateSeekButton: View { private let navigation: DateSeekNavigation? private let action: (DateSeekNavigation) -> Void - init(navigation: DateSeekNavigation?, action: @escaping (DateSeekNavigation) -> Void) { + public init(navigation: DateSeekNavigation?, action: @escaping (DateSeekNavigation) -> Void) { self.navigation = navigation self.action = action } - var body: some View { + public var body: some View { Button { navigation.map(action) } label: { @@ -127,18 +128,18 @@ struct DateSeekButton: View { } } -struct FavoritesIndexMenu: View { +public struct FavoritesIndexMenu: View { private let user: User private let index: Int private let action: (Int) -> Void - init(user: User, index: Int, action: @escaping (Int) -> Void) { + public init(user: User, index: Int, action: @escaping (Int) -> Void) { self.user = user self.index = index self.action = action } - var body: some View { + public var body: some View { Menu { ForEach(-1..<10) { index in Button { @@ -157,16 +158,16 @@ struct FavoritesIndexMenu: View { } } -struct ToplistsTypeMenu: View { +public struct ToplistsTypeMenu: View { private let type: ToplistsType private let action: (ToplistsType) -> Void - init(type: ToplistsType, action: @escaping (ToplistsType) -> Void) { + public init(type: ToplistsType, action: @escaping (ToplistsType) -> Void) { self.type = type self.action = action } - var body: some View { + public var body: some View { Menu { ForEach(ToplistsType.allCases) { type in Button { @@ -185,16 +186,16 @@ struct ToplistsTypeMenu: View { } } -struct SortOrderMenu: View { +public struct SortOrderMenu: View { private let sortOrder: FavoritesSortOrder? private let action: (FavoritesSortOrder) -> Void - init(sortOrder: FavoritesSortOrder?, action: @escaping (FavoritesSortOrder) -> Void) { + public init(sortOrder: FavoritesSortOrder?, action: @escaping (FavoritesSortOrder) -> Void) { self.sortOrder = sortOrder self.action = action } - var body: some View { + public var body: some View { Menu { ForEach(FavoritesSortOrder.allCases) { order in Button { diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/WaveForm.swift b/AppPackage/Sources/AppComponents/WaveForm.swift similarity index 92% rename from AppPackage/Sources/AppFeature/View/Support/Components/WaveForm.swift rename to AppPackage/Sources/AppComponents/WaveForm.swift index 43aea4972..59e2f67be 100644 --- a/AppPackage/Sources/AppFeature/View/Support/Components/WaveForm.swift +++ b/AppPackage/Sources/AppComponents/WaveForm.swift @@ -3,18 +3,18 @@ import SwiftUI -struct WaveForm: View { +public struct WaveForm: View { private let color: Color private let amplify: CGFloat private let isReversed: Bool - init(color: Color, amplify: CGFloat, isReversed: Bool) { + public init(color: Color, amplify: CGFloat, isReversed: Bool) { self.color = color self.amplify = amplify self.isReversed = isReversed } - var body: some View { + public var body: some View { TimelineView(.animation) { timeLine in Canvas { context, size in let timeNow = timeLine.date.timeIntervalSinceReferenceDate diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift index 2cc896777..a39d87eb2 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import Utilities import DesignSystem +import AppComponents struct ArchivesView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift index 6cb781edd..6c28e5072 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift @@ -6,6 +6,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct CommentsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift index b11ffed56..cb0c09437 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift @@ -4,6 +4,7 @@ import Resources import Kingfisher import FoundationExt import DesignSystem +import AppComponents struct TagDetailView: View { private let detail: TagDetail diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift index 6167f149f..0e0da4651 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct DetailSearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift index d3eafebcf..061d660b6 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift @@ -5,6 +5,7 @@ import Kingfisher import SFSafeSymbols import Utilities import DesignSystem +import AppComponents // MARK: HeaderSection struct HeaderSection: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift index a0c533ad7..d70651b21 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift @@ -3,6 +3,7 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities +import AppComponents // MARK: NavigationLinks extension DetailView { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift index 43befee6d..eeafa7f19 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift @@ -5,6 +5,7 @@ import Kingfisher import FoundationExt import Utilities import DesignSystem +import AppComponents // MARK: DescriptionSection struct DescriptionSection: View { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift index 0d45086fa..3e8559c9c 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift @@ -6,6 +6,7 @@ import ComposableArchitecture import CommonMark import Utilities import DesignSystem +import AppComponents private enum DownloadDialog: Equatable { case delete(isActiveDownload: Bool) diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift index 06a3d4ba5..2386e1e22 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import Utilities import DesignSystem +import AppComponents struct PreviewsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift index ff32a07a0..8a4318775 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import DesignSystem +import AppComponents struct TorrentsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift index a5d0c36eb..68ec1c132 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift @@ -4,6 +4,7 @@ import Resources import SFSafeSymbols import ComposableArchitecture import DesignSystem +import AppComponents struct DownloadInspectorView: View { @Environment(\.dismiss) private var dismiss diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift index 9804bda53..fc8bc9561 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift @@ -6,6 +6,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct DownloadsView: View { private enum RowDialog: Identifiable { diff --git a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift index 8bdadd5e6..79c0c867b 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift @@ -4,6 +4,7 @@ import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt import DesignSystem +import AppComponents struct FolderManagerView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift index 93b909e7a..366275eab 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift @@ -6,6 +6,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct FavoritesView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift index 3cfe82f30..fa64c6512 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift @@ -6,6 +6,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct FrontpageView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift index 44fead7ce..0529ae8a9 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct HistoryView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift b/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift index 2e6a891ec..5d3e8cf89 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift @@ -2,6 +2,7 @@ import SwiftUI import Kingfisher import ComposableArchitecture import Networking +import AppModels extension HomeReducer { @ReducerBuilder diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift index a44cee1e7..ea091eb6f 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift @@ -6,6 +6,7 @@ import SwiftUIPager import SFSafeSymbols import Utilities import DesignSystem +import AppComponents // MARK: CardSlideSection struct CardSlideSection: View, Equatable { diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift index 47772bcb3..59d22eb6f 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift @@ -7,6 +7,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct HomeView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift index e60347011..a923552de 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct PopularView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift index cd3f7768f..53e77c780 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct ToplistsView: View { @Bindable private var store: StoreOf @@ -111,43 +112,6 @@ struct ToplistsView: View { } } -// MARK: Definition -enum ToplistsType: Int, Codable, CaseIterable, Identifiable { - var id: Int { rawValue } - - case yesterday - case pastMonth - case pastYear - case allTime -} - -extension ToplistsType { - var value: String { - switch self { - case .yesterday: - return L10n.Localizable.Enum.ToplistsType.Value.yesterday - case .pastMonth: - return L10n.Localizable.Enum.ToplistsType.Value.pastMonth - case .pastYear: - return L10n.Localizable.Enum.ToplistsType.Value.pastYear - case .allTime: - return L10n.Localizable.Enum.ToplistsType.Value.allTime - } - } - var categoryIndex: Int { - switch self { - case .yesterday: - return 15 - case .pastMonth: - return 13 - case .pastYear: - return 12 - case .allTime: - return 11 - } - } -} - struct ToplistsView_Previews: PreviewProvider { static var previews: some View { NavigationView { diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift index 39b07ee68..d16983012 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct WatchedView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift b/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift index 0041de43b..d7fcee7dc 100644 --- a/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift +++ b/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift @@ -2,6 +2,7 @@ import SwiftUI import Resources import ComposableArchitecture import SwiftUINavigationExt +import AppComponents struct MigrationView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift index 1240b6a42..399e491ff 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift @@ -7,6 +7,7 @@ import FoundationExt import Utilities import SDWebImageExt import DesignSystem +import AppComponents struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift b/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift index af41cb77f..3bfa154dd 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift @@ -7,6 +7,7 @@ import SDWebImageSwiftUI import ComposableArchitecture import Utilities import ImageClient +import AppComponents // MARK: ImageStackConfig struct ImageStackConfig { diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift b/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift index dcd43bdbb..531ef0b8c 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift +++ b/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift @@ -3,6 +3,7 @@ import AppModels import Resources import Utilities import DesignSystem +import AppComponents // MARK: ControlPanel struct ControlPanel: View { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift index 185c42e40..81d0781ce 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift @@ -6,6 +6,7 @@ import FoundationExt import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct SearchRootView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift index 24fa901a7..c64aa5912 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct SearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift index 4b0cad56f..9c71a4e89 100644 --- a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import DesignSystem +import AppComponents struct QuickSearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift index f7dbe0c54..edd4bba53 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import FoundationExt import SwiftUINavigationExt +import AppComponents extension EhSettingView { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift index 09ef7be47..6c1eddd2b 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Resources +import AppComponents extension EhSettingView { diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift index bb12b8c3e..af427473e 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import DesignSystem +import AppComponents struct EhSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift index b4bf94ba1..acde0cbda 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import DesignSystem +import AppComponents struct LoginView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift index b3bd6f015..9bd0ee107 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift @@ -3,6 +3,7 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import AppModels +import AppComponents struct LogsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Support/Components/ActivityView.swift b/AppPackage/Sources/AppFeature/View/Support/Components/ActivityView.swift deleted file mode 100644 index 6215324c5..000000000 --- a/AppPackage/Sources/AppFeature/View/Support/Components/ActivityView.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SwiftUI - -struct ActivityView: UIViewControllerRepresentable { - private var activityItems: [Any] - - init(activityItems: [Any]) { - self.activityItems = activityItems - } - - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: activityItems, applicationActivities: nil) - } - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} -} diff --git a/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift b/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift index 5b4f6063f..06be696d2 100644 --- a/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import AppModels import Foundation import HapticsClient +import AppComponents /// A headless, reusable sub-reducer for the "Seek to date" control. /// diff --git a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift index bbaffa4cf..00465ccdf 100644 --- a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift +++ b/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import DesignSystem +import AppComponents struct FiltersView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppModels/Support/ToplistsType.swift b/AppPackage/Sources/AppModels/Support/ToplistsType.swift new file mode 100644 index 000000000..5120687f2 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/ToplistsType.swift @@ -0,0 +1,37 @@ +import Resources + +public enum ToplistsType: Int, Codable, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } + + case yesterday + case pastMonth + case pastYear + case allTime +} + +extension ToplistsType { + public var value: String { + switch self { + case .yesterday: + return L10n.Localizable.Enum.ToplistsType.Value.yesterday + case .pastMonth: + return L10n.Localizable.Enum.ToplistsType.Value.pastMonth + case .pastYear: + return L10n.Localizable.Enum.ToplistsType.Value.pastYear + case .allTime: + return L10n.Localizable.Enum.ToplistsType.Value.allTime + } + } + public var categoryIndex: Int { + switch self { + case .yesterday: + return 15 + case .pastMonth: + return 13 + case .pastYear: + return 12 + case .allTime: + return 11 + } + } +} From 035ae20dff1690f74b6a970cfd3526be620c7496 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 22:10:01 +0800 Subject: [PATCH 327/614] Extract MigrationFeature module; publicize MigrationReducer + MigrationView First per-feature module of M7. The reducer becomes a public Sendable struct (public State/Action/init/body); making the reducer public drops its implicit Sendable, so the .run dependency self-capture needs an explicit Sendable conformance. RootView + AppDelegateReducer import the new module. --- AppPackage/Package.swift | 15 +++++++++++++++ .../DataFlow/AppDelegateReducer.swift | 1 + AppPackage/Sources/AppFeature/RootView.swift | 1 + .../Sources/MigrationFeature/.swiftlint.yml | 1 + .../MigrationReducer.swift | 18 +++++++++++------- .../MigrationView.swift | 7 ++++--- 6 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 AppPackage/Sources/MigrationFeature/.swiftlint.yml rename AppPackage/Sources/{AppFeature/View/Migration => MigrationFeature}/MigrationReducer.swift (86%) rename AppPackage/Sources/{AppFeature/View/Migration => MigrationFeature}/MigrationView.swift (92%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 0c97535b5..f89aa4da3 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -87,6 +87,7 @@ enum Module: String { case imageClient = "ImageClient" case libraryClient = "LibraryClient" case loggerClient = "LoggerClient" + case migrationFeature = "MigrationFeature" case networking = "Networking" case parser = "Parser" case resources = "Resources" @@ -252,6 +253,7 @@ let targets: [PackageDescription.Target] = [ .module(.imageClient), .module(.libraryClient), .module(.loggerClient), + .module(.migrationFeature), .module(.networking), .module(.parser), .module(.resources), @@ -513,6 +515,19 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .migrationFeature, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.databaseClient), + .module(.resources), + .module(.swiftUINavigationExt), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .imageClient, dependencies: [ diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index be21e87e3..4ff009a95 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -9,6 +9,7 @@ import DatabaseClient import DownloadClient import BackgroundProcessingClient import CookieClient +import MigrationFeature @Reducer struct AppDelegateReducer { diff --git a/AppPackage/Sources/AppFeature/RootView.swift b/AppPackage/Sources/AppFeature/RootView.swift index d2d29d45a..8ccbcfb30 100644 --- a/AppPackage/Sources/AppFeature/RootView.swift +++ b/AppPackage/Sources/AppFeature/RootView.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import SwiftUI import UIKit import Utilities +import MigrationFeature // MARK: RootView public struct RootView: View { diff --git a/AppPackage/Sources/MigrationFeature/.swiftlint.yml b/AppPackage/Sources/MigrationFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/MigrationFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift similarity index 86% rename from AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift rename to AppPackage/Sources/MigrationFeature/MigrationReducer.swift index 5620f7d67..9321d76a8 100644 --- a/AppPackage/Sources/AppFeature/View/Migration/MigrationReducer.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift @@ -4,19 +4,21 @@ import ComposableArchitecture import DatabaseClient @Reducer -struct MigrationReducer { +public struct MigrationReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable { case dropDialog } @ObservableState - struct State: Equatable { - var route: Route? - var databaseState: LoadingState = .loading + public struct State: Equatable { + public var route: Route? + public var databaseState: LoadingState = .loading + + public init() {} } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) case onDatabasePreparationSuccess @@ -29,7 +31,9 @@ struct MigrationReducer { @Dependency(\.databaseClient) private var databaseClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() Reduce { state, action in diff --git a/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift b/AppPackage/Sources/MigrationFeature/MigrationView.swift similarity index 92% rename from AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift rename to AppPackage/Sources/MigrationFeature/MigrationView.swift index d7fcee7dc..d41445cf9 100644 --- a/AppPackage/Sources/AppFeature/View/Migration/MigrationView.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationView.swift @@ -1,10 +1,11 @@ import SwiftUI +import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt import AppComponents -struct MigrationView: View { +public struct MigrationView: View { @Environment(\.colorScheme) private var colorScheme @Bindable private var store: StoreOf @@ -12,11 +13,11 @@ struct MigrationView: View { colorScheme == .light ? .white : .black } - init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } - var body: some View { + public var body: some View { NavigationView { ZStack { reversedPrimary.ignoresSafeArea() From 0f1dfc77c7bd0680783315a50532fa3f6d967369 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 22:14:12 +0800 Subject: [PATCH 328/614] Extract FiltersFeature module; publicize FiltersReducer + FiltersView Leaf support feature shared by Detail/Home/Search. Reducer made a public Sendable struct; nested Route/FocusedBound enums are publicized too since public State exposes them. 12 AppFeature consumers import the new module. --- AppPackage/Package.swift | 16 ++++++++++ .../DetailSearch/DetailSearchReducer.swift | 1 + .../DetailSearch/DetailSearchView.swift | 1 + .../Home/Frontpage/FrontpageReducer.swift | 1 + .../View/Home/Frontpage/FrontpageView.swift | 1 + .../View/Home/Popular/PopularReducer.swift | 1 + .../View/Home/Popular/PopularView.swift | 1 + .../View/Home/Watched/WatchedReducer.swift | 1 + .../View/Home/Watched/WatchedView.swift | 1 + .../View/Search/SearchReducer.swift | 1 + .../View/Search/SearchRootReducer.swift | 1 + .../View/Search/SearchRootView.swift | 1 + .../AppFeature/View/Search/SearchView.swift | 1 + .../Sources/FiltersFeature/.swiftlint.yml | 1 + .../FiltersReducer.swift | 30 +++++++++++-------- .../FiltersView.swift | 6 ++-- 16 files changed, 49 insertions(+), 16 deletions(-) create mode 100644 AppPackage/Sources/FiltersFeature/.swiftlint.yml rename AppPackage/Sources/{AppFeature/View/Support => FiltersFeature}/FiltersReducer.swift (84%) rename AppPackage/Sources/{AppFeature/View/Support => FiltersFeature}/FiltersView.swift (98%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index f89aa4da3..56c5550a7 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -82,6 +82,7 @@ enum Module: String { case deviceClient = "DeviceClient" case downloadClient = "DownloadClient" case fileClient = "FileClient" + case filtersFeature = "FiltersFeature" case foundationExt = "FoundationExt" case hapticsClient = "HapticsClient" case imageClient = "ImageClient" @@ -248,6 +249,7 @@ let targets: [PackageDescription.Target] = [ .module(.deviceClient), .module(.downloadClient), .module(.fileClient), + .module(.filtersFeature), .module(.foundationExt), .module(.hapticsClient), .module(.imageClient), @@ -528,6 +530,20 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .filtersFeature, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.databaseClient), + .module(.designSystem), + .module(.resources), + .module(.swiftUINavigationExt), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .imageClient, dependencies: [ diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift index c1d636a26..c9e448d4f 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -4,6 +4,7 @@ import SwiftUINavigationExt import HapticsClient import DatabaseClient import Networking +import FiltersFeature @Reducer struct DetailSearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift index 0e0da4651..bd12ffc79 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift @@ -5,6 +5,7 @@ import SwiftUINavigationExt import Utilities import DesignSystem import AppComponents +import FiltersFeature struct DetailSearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift index 7379e6e79..42eaf6fb0 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift @@ -6,6 +6,7 @@ import SwiftUINavigationExt import HapticsClient import DatabaseClient import Networking +import FiltersFeature @Reducer struct FrontpageReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift index fa64c6512..ead55c879 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift @@ -7,6 +7,7 @@ import SwiftUINavigationExt import Utilities import DesignSystem import AppComponents +import FiltersFeature struct FrontpageView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift index 29aa7159a..4bd0916bd 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift @@ -5,6 +5,7 @@ import SwiftUINavigationExt import HapticsClient import DatabaseClient import Networking +import FiltersFeature @Reducer struct PopularReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift index a923552de..dc308691f 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift @@ -6,6 +6,7 @@ import SwiftUINavigationExt import Utilities import DesignSystem import AppComponents +import FiltersFeature struct PopularView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift index c1e9003e0..bc69c7f9d 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift @@ -5,6 +5,7 @@ import HapticsClient import DatabaseClient import Networking import DownloadClient +import FiltersFeature @Reducer struct WatchedReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift index d16983012..5edb39ef6 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift @@ -6,6 +6,7 @@ import SwiftUINavigationExt import Utilities import DesignSystem import AppComponents +import FiltersFeature struct WatchedView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift index 54aed0324..96f5bb188 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift @@ -6,6 +6,7 @@ import HapticsClient import DatabaseClient import Networking import DownloadClient +import FiltersFeature @Reducer struct SearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift index ab78b05b0..9d9ec7b90 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift @@ -4,6 +4,7 @@ import FoundationExt import SwiftUINavigationExt import HapticsClient import DatabaseClient +import FiltersFeature @Reducer struct SearchRootReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift index 81d0781ce..fb7be00d8 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift @@ -7,6 +7,7 @@ import SwiftUINavigationExt import Utilities import DesignSystem import AppComponents +import FiltersFeature struct SearchRootView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift index c64aa5912..4a5aa6db2 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift @@ -5,6 +5,7 @@ import SwiftUINavigationExt import Utilities import DesignSystem import AppComponents +import FiltersFeature struct SearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/FiltersFeature/.swiftlint.yml b/AppPackage/Sources/FiltersFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/FiltersFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift similarity index 84% rename from AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift rename to AppPackage/Sources/FiltersFeature/FiltersReducer.swift index e5f37b7ca..5d93e556a 100644 --- a/AppPackage/Sources/AppFeature/View/Support/FiltersReducer.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift @@ -3,29 +3,31 @@ import AppModels import DatabaseClient @Reducer -struct FiltersReducer { +public struct FiltersReducer: Sendable { @CasePathable - enum Route { + public enum Route { case resetFilters } - enum FocusedBound { + public enum FocusedBound { case lower case upper } @ObservableState - struct State: Equatable { - var route: Route? - var filterRange: FilterRange = .search - var focusedBound: FocusedBound? - - var searchFilter = Filter() - var globalFilter = Filter() - var watchedFilter = Filter() + public struct State: Equatable { + public var route: Route? + public var filterRange: FilterRange = .search + public var focusedBound: FocusedBound? + + public var searchFilter = Filter() + public var globalFilter = Filter() + public var watchedFilter = Filter() + + public init() {} } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) case onTextFieldSubmitted @@ -38,7 +40,9 @@ struct FiltersReducer { @Dependency(\.databaseClient) private var databaseClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.searchFilter) { _, state in state.searchFilter.fixInvalidData() diff --git a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift similarity index 98% rename from AppPackage/Sources/AppFeature/View/Support/FiltersView.swift rename to AppPackage/Sources/FiltersFeature/FiltersView.swift index 00465ccdf..ad3d8ff6d 100644 --- a/AppPackage/Sources/AppFeature/View/Support/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -6,12 +6,12 @@ import SwiftUINavigationExt import DesignSystem import AppComponents -struct FiltersView: View { +public struct FiltersView: View { @Bindable private var store: StoreOf @FocusState private var focusedBound: FiltersReducer.FocusedBound? - init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } @@ -27,7 +27,7 @@ struct FiltersView: View { } // MARK: FilterView - var body: some View { + public var body: some View { NavigationView { Form { BasicSection( From d6d42524aca997b336200aa77e7d0c1a2a590e0b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 22:17:25 +0800 Subject: [PATCH 329/614] Extract DateSeekFeature module; publicize DateSeekReducer Headless seek-to-date sub-reducer (its picker/button views already live in AppComponents). Public Sendable reducer; dropped a stray AppComponents import that only appeared in a doc comment. 4 host reducers import it. --- AppPackage/Package.swift | 12 ++++++++++++ .../View/Favorites/FavoritesReducer.swift | 1 + .../Home/Frontpage/FrontpageReducer.swift | 1 + .../View/Home/Watched/WatchedReducer.swift | 1 + .../View/Search/SearchReducer.swift | 1 + .../Sources/DateSeekFeature/.swiftlint.yml | 1 + .../DateSeekReducer.swift | 19 +++++++++++-------- 7 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 AppPackage/Sources/DateSeekFeature/.swiftlint.yml rename AppPackage/Sources/{AppFeature/View/Support => DateSeekFeature}/DateSeekReducer.swift (87%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 56c5550a7..7d5678a16 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -78,6 +78,7 @@ enum Module: String { case cookieClient = "CookieClient" case dfClient = "DFClient" case databaseClient = "DatabaseClient" + case dateSeekFeature = "DateSeekFeature" case designSystem = "DesignSystem" case deviceClient = "DeviceClient" case downloadClient = "DownloadClient" @@ -244,6 +245,7 @@ let targets: [PackageDescription.Target] = [ .module(.composableArchitectureExt), .module(.cookieClient), .module(.databaseClient), + .module(.dateSeekFeature), .module(.designSystem), .module(.dfClient), .module(.deviceClient), @@ -544,6 +546,16 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .dateSeekFeature, + dependencies: [ + .module(.appModels), + .module(.hapticsClient), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .imageClient, dependencies: [ diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift index 73213ae3e..36c688b41 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift @@ -7,6 +7,7 @@ import HapticsClient import DatabaseClient import Networking import DownloadClient +import DateSeekFeature @Reducer struct FavoritesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift index 42eaf6fb0..12807fd7e 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift @@ -7,6 +7,7 @@ import HapticsClient import DatabaseClient import Networking import FiltersFeature +import DateSeekFeature @Reducer struct FrontpageReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift index bc69c7f9d..b0382e2f5 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift @@ -6,6 +6,7 @@ import DatabaseClient import Networking import DownloadClient import FiltersFeature +import DateSeekFeature @Reducer struct WatchedReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift index 96f5bb188..3904d756c 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift @@ -7,6 +7,7 @@ import DatabaseClient import Networking import DownloadClient import FiltersFeature +import DateSeekFeature @Reducer struct SearchReducer { diff --git a/AppPackage/Sources/DateSeekFeature/.swiftlint.yml b/AppPackage/Sources/DateSeekFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DateSeekFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift b/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift similarity index 87% rename from AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift rename to AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift index 06be696d2..2b2dfde2b 100644 --- a/AppPackage/Sources/AppFeature/View/Support/DateSeekReducer.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift @@ -2,7 +2,6 @@ import ComposableArchitecture import AppModels import Foundation import HapticsClient -import AppComponents /// A headless, reusable sub-reducer for the "Seek to date" control. /// @@ -16,15 +15,17 @@ import AppComponents /// The host performs the request and stores the result, because the gallery list and its loading /// state belong to the host — not to this control. @Reducer -struct DateSeekReducer { +public struct DateSeekReducer: Sendable { @ObservableState - struct State: Equatable { - var date = Date() + public struct State: Equatable { + public var date = Date() /// The navigation whose picker is presented; `nil` while the sheet is dismissed. - var navigation: DateSeekNavigation? + public var navigation: DateSeekNavigation? + + public init() {} } - enum Action { + public enum Action { case present(DateSeekNavigation) case setNavigation(DateSeekNavigation?) case performSeek(DateSeekDirection) @@ -32,13 +33,15 @@ struct DateSeekReducer { } @CasePathable - enum Delegate: Equatable { + public enum Delegate: Equatable { case performSeek(URL) } @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { Reduce { state, action in switch action { case .present(let navigation): From d4a6fe7ae59f2f8c0abb2d4e14f794fafcba7381 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 22:22:19 +0800 Subject: [PATCH 330/614] Extract QuickSearchFeature module; publicize QuickSearchReducer + QuickSearchView Deviation from the plan, which grouped QuickSearch under Search: the DetailSearch reducer composes it, so it must sit below DetailFeature and cannot live in SearchFeature. Made it its own leaf module instead. State/Route/FocusField regain explicit Sendable (lost when the types became public); the view needs an explicit SFSafeSymbols import. 10 AppFeature consumers import the new module. --- AppPackage/Package.swift | 17 +++++++++++ .../DetailSearch/DetailSearchReducer.swift | 1 + .../DetailSearch/DetailSearchView.swift | 1 + .../View/Favorites/FavoritesReducer.swift | 1 + .../View/Favorites/FavoritesView.swift | 1 + .../View/Home/Watched/WatchedReducer.swift | 1 + .../View/Home/Watched/WatchedView.swift | 1 + .../View/Search/SearchReducer.swift | 1 + .../View/Search/SearchRootReducer.swift | 1 + .../View/Search/SearchRootView.swift | 1 + .../AppFeature/View/Search/SearchView.swift | 1 + .../Sources/QuickSearchFeature/.swiftlint.yml | 1 + .../QuickSearchReducer.swift | 30 +++++++++++-------- .../QuickSearchView.swift | 7 +++-- 14 files changed, 49 insertions(+), 16 deletions(-) create mode 100644 AppPackage/Sources/QuickSearchFeature/.swiftlint.yml rename AppPackage/Sources/{AppFeature/View/Search/Support => QuickSearchFeature}/QuickSearchReducer.swift (84%) rename AppPackage/Sources/{AppFeature/View/Search/Support => QuickSearchFeature}/QuickSearchView.swift (97%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 7d5678a16..9938a2300 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -92,6 +92,7 @@ enum Module: String { case migrationFeature = "MigrationFeature" case networking = "Networking" case parser = "Parser" + case quickSearchFeature = "QuickSearchFeature" case resources = "Resources" case sdWebImageExt = "SDWebImageExt" case swiftUINavigationExt = "SwiftUINavigationExt" @@ -260,6 +261,7 @@ let targets: [PackageDescription.Target] = [ .module(.migrationFeature), .module(.networking), .module(.parser), + .module(.quickSearchFeature), .module(.resources), .module(.sdWebImageExt), .module(.swiftUINavigationExt), @@ -556,6 +558,21 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .quickSearchFeature, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.databaseClient), + .module(.designSystem), + .module(.resources), + .module(.swiftUINavigationExt), + .targetDependency(.composableArchitecture), + .targetDependency(.sfSafeSymbols) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .imageClient, dependencies: [ diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift index c9e448d4f..7f2b372c9 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -5,6 +5,7 @@ import HapticsClient import DatabaseClient import Networking import FiltersFeature +import QuickSearchFeature @Reducer struct DetailSearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift index bd12ffc79..9a3bfaf93 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift @@ -6,6 +6,7 @@ import Utilities import DesignSystem import AppComponents import FiltersFeature +import QuickSearchFeature struct DetailSearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift index 36c688b41..6710d9946 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift @@ -8,6 +8,7 @@ import DatabaseClient import Networking import DownloadClient import DateSeekFeature +import QuickSearchFeature @Reducer struct FavoritesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift index 366275eab..4aa2434df 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift @@ -7,6 +7,7 @@ import SwiftUINavigationExt import Utilities import DesignSystem import AppComponents +import QuickSearchFeature struct FavoritesView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift index b0382e2f5..1070fb548 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift @@ -7,6 +7,7 @@ import Networking import DownloadClient import FiltersFeature import DateSeekFeature +import QuickSearchFeature @Reducer struct WatchedReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift index 5edb39ef6..d814249d8 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift @@ -7,6 +7,7 @@ import Utilities import DesignSystem import AppComponents import FiltersFeature +import QuickSearchFeature struct WatchedView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift index 3904d756c..bc7e9e6d4 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift @@ -8,6 +8,7 @@ import Networking import DownloadClient import FiltersFeature import DateSeekFeature +import QuickSearchFeature @Reducer struct SearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift index 9d9ec7b90..ac296b589 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift @@ -5,6 +5,7 @@ import SwiftUINavigationExt import HapticsClient import DatabaseClient import FiltersFeature +import QuickSearchFeature @Reducer struct SearchRootReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift index fb7be00d8..b1df123a8 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift @@ -8,6 +8,7 @@ import Utilities import DesignSystem import AppComponents import FiltersFeature +import QuickSearchFeature struct SearchRootView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift index 4a5aa6db2..5c20bac00 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift @@ -6,6 +6,7 @@ import Utilities import DesignSystem import AppComponents import FiltersFeature +import QuickSearchFeature struct SearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/QuickSearchFeature/.swiftlint.yml b/AppPackage/Sources/QuickSearchFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/QuickSearchFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift similarity index 84% rename from AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift rename to AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift index e3d0b31a9..9b8935417 100644 --- a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchReducer.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift @@ -4,15 +4,15 @@ import ComposableArchitecture import DatabaseClient @Reducer -struct QuickSearchReducer { +public struct QuickSearchReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case newWord case editWord case deleteWord(QuickSearchWord) } - enum FocusField { + public enum FocusField: Sendable { case name case content } @@ -22,21 +22,23 @@ struct QuickSearchReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var focusedField: FocusField? - var editingWord: QuickSearchWord = .empty - var listEditMode: EditMode = .inactive - var isListEditing: Bool { + public struct State: Equatable, Sendable { + public var route: Route? + public var focusedField: FocusField? + public var editingWord: QuickSearchWord = .empty + public var listEditMode: EditMode = .inactive + public var isListEditing: Bool { get { listEditMode == .active } set { listEditMode = newValue ? .active : .inactive } } - var loadingState: LoadingState = .idle - var quickSearchWords = [QuickSearchWord]() + public var loadingState: LoadingState = .idle + public var quickSearchWords = [QuickSearchWord]() + + public init() {} } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) case clearSubStates @@ -59,7 +61,9 @@ struct QuickSearchReducer { @Dependency(\.databaseClient) private var databaseClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift similarity index 97% rename from AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift rename to AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index 9c71a4e89..8dff63793 100644 --- a/AppPackage/Sources/AppFeature/View/Search/Support/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -1,23 +1,24 @@ import SwiftUI import AppModels import Resources +import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt import DesignSystem import AppComponents -struct QuickSearchView: View { +public struct QuickSearchView: View { @Bindable private var store: StoreOf private let searchAction: (String) -> Void @FocusState private var focusedField: QuickSearchReducer.FocusField? - init(store: StoreOf, searchAction: @escaping (String) -> Void) { + public init(store: StoreOf, searchAction: @escaping (String) -> Void) { self.store = store self.searchAction = searchAction } - var body: some View { + public var body: some View { NavigationView { ZStack { List { From bd5214ef57e96a4b45ff57844bd3acaf5785fc55 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 22:25:45 +0800 Subject: [PATCH 331/614] Move NewDawnView into AppComponents; publicize it Deviation from the plan, which listed a standalone NewDawnFeature module: NewDawnView is a logic-less decorative view with no reducer, shared by Detail/Setting/TabBar, and all its dependencies are already AppComponents deps. A reusable view belongs in AppComponents (its purpose) rather than a degenerate single-view feature module. TabBarView gains the import. --- .../View/Support => AppComponents}/NewDawnView.swift | 6 +++--- AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) rename AppPackage/Sources/{AppFeature/View/Support => AppComponents}/NewDawnView.swift (97%) diff --git a/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift b/AppPackage/Sources/AppComponents/NewDawnView.swift similarity index 97% rename from AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift rename to AppPackage/Sources/AppComponents/NewDawnView.swift index 80f2562bf..e85f4bc32 100644 --- a/AppPackage/Sources/AppFeature/View/Support/NewDawnView.swift +++ b/AppPackage/Sources/AppComponents/NewDawnView.swift @@ -4,7 +4,7 @@ import Resources import Utilities import DesignSystem -struct NewDawnView: View { +public struct NewDawnView: View { @Environment(\.colorScheme) private var colorScheme private let greeting: Greeting @@ -23,12 +23,12 @@ struct NewDawnView: View { } } - init(greeting: Greeting) { + public init(greeting: Greeting) { self.greeting = greeting } // MARK: NewDawnView - var body: some View { + public var body: some View { ZStack { LinearGradient( gradient: Gradient(colors: gradientColors), diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index bfa6bd06b..708e8d9b1 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -5,6 +5,7 @@ import SFSafeSymbols import ComposableArchitecture import Utilities import DesignSystem +import AppComponents struct TabBarView: View { @Environment(\.scenePhase) private var scenePhase From 0c03d4b9e91d57866a5428694467b714604826d7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 22:43:06 +0800 Subject: [PATCH 332/614] Extract ReadingFeature module; relocate the haptics reducer operator + ReadingSettingView Reading is a leaf feature (reducer + view + support handlers). Public Sendable reducer; State/Route/ShareItem/ImageAction regain explicit Sendable (lost when public; UIImage is Sendable so the chain holds), and three views need an explicit SFSafeSymbols import. Two shared pieces had to move out of AppFeature so the feature module can compile: - Reducer.haptics (+ onBecomeNonNil) -> HapticsClient, where its HapticsClient parameter naturally lives; LoggingReducer stays in AppFeature (only AppReducer uses it). - ReadingSettingView -> AppComponents: a logic-less binding form shared by both ReadingView and SettingView (dropped a stray CA import). Detail/Previews/Downloads consumers and the Reading test files import the new module; appFeatureTests gains the ReadingFeature dep. --- AppPackage/Package.swift | 35 ++++++++++ .../ReadingSettingView.swift | 7 +- .../Tools/Extensions/Reducer_Extension.swift | 32 --------- .../View/Detail/DetailReducer.swift | 1 + .../AppFeature/View/Detail/DetailView.swift | 1 + .../Detail/Previews/PreviewsReducer.swift | 1 + .../View/Detail/Previews/PreviewsView.swift | 1 + .../View/Downloads/DownloadsReducer.swift | 1 + .../View/Downloads/DownloadsView.swift | 1 + .../AppFeature/View/Setting/SettingView.swift | 1 + .../HapticsClient/Reducer+Haptics.swift | 32 +++++++++ .../Sources/ReadingFeature/.swiftlint.yml | 1 + .../ReadingReducer+Body.swift | 0 .../ReadingReducer+Database.swift | 0 .../ReadingReducer+ImageFetch.swift | 0 .../ReadingReducer.swift | 70 ++++++++++--------- .../ReadingView+Gestures.swift | 0 .../ReadingView.swift | 7 +- .../ReadingViewComponents.swift | 1 + .../Support/AdvancedList.swift | 0 .../Support/AutoPlayHandler.swift | 0 .../Support/ControlPanel.swift | 1 + .../Support/GestureHandler.swift | 0 .../Support/LiveTextHandler.swift | 0 .../Support/LiveTextView.swift | 0 .../Support/PageHandler.swift | 0 .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../ReadingReducerDownloadTests.swift | 1 + .../Download/ReadingReducerLocalTests.swift | 1 + 30 files changed, 124 insertions(+), 73 deletions(-) rename AppPackage/Sources/{AppFeature/View/Setting/Components => AppComponents}/ReadingSettingView.swift (97%) create mode 100644 AppPackage/Sources/HapticsClient/Reducer+Haptics.swift create mode 100644 AppPackage/Sources/ReadingFeature/.swiftlint.yml rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/ReadingReducer+Body.swift (100%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/ReadingReducer+Database.swift (100%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/ReadingReducer+ImageFetch.swift (100%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/ReadingReducer.swift (79%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/ReadingView+Gestures.swift (100%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/ReadingView.swift (99%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/ReadingViewComponents.swift (99%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/Support/AdvancedList.swift (100%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/Support/AutoPlayHandler.swift (100%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/Support/ControlPanel.swift (99%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/Support/GestureHandler.swift (100%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/Support/LiveTextHandler.swift (100%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/Support/LiveTextView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Reading => ReadingFeature}/Support/PageHandler.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 9938a2300..837f0e6f3 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -93,6 +93,7 @@ enum Module: String { case networking = "Networking" case parser = "Parser" case quickSearchFeature = "QuickSearchFeature" + case readingFeature = "ReadingFeature" case resources = "Resources" case sdWebImageExt = "SDWebImageExt" case swiftUINavigationExt = "SwiftUINavigationExt" @@ -262,6 +263,7 @@ let targets: [PackageDescription.Target] = [ .module(.networking), .module(.parser), .module(.quickSearchFeature), + .module(.readingFeature), .module(.resources), .module(.sdWebImageExt), .module(.swiftUINavigationExt), @@ -472,6 +474,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .hapticsClient, dependencies: [ + .module(.swiftUINavigationExt), .module(.utilities), .targetDependency(.composableArchitecture) ], @@ -573,6 +576,37 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .readingFeature, + dependencies: [ + .module(.appComponents), + .module(.appDelegateClient), + .module(.appModels), + .module(.clipboardClient), + .module(.cookieClient), + .module(.databaseClient), + .module(.designSystem), + .module(.deviceClient), + .module(.downloadClient), + .module(.foundationExt), + .module(.hapticsClient), + .module(.imageClient), + .module(.networking), + .module(.resources), + .module(.sdWebImageExt), + .module(.swiftUINavigationExt), + .module(.urlClient), + .module(.utilities), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher), + .targetDependency(.sdWebImageSwiftUI), + .targetDependency(.sfSafeSymbols), + .targetDependency(.swiftUIPager), + .targetDependency(.ttProgressHUD) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .imageClient, dependencies: [ @@ -675,6 +709,7 @@ let targets: [PackageDescription.Target] = [ .module(.loggerClient), .module(.networking), .module(.parser), + .module(.readingFeature), .module(.sdWebImageExt), .module(.uiApplicationClient), .module(.urlClient), diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift b/AppPackage/Sources/AppComponents/ReadingSettingView.swift similarity index 97% rename from AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift rename to AppPackage/Sources/AppComponents/ReadingSettingView.swift index 9a8a64559..633be88a8 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Components/ReadingSettingView.swift +++ b/AppPackage/Sources/AppComponents/ReadingSettingView.swift @@ -1,10 +1,9 @@ import SwiftUI import AppModels import Resources -import ComposableArchitecture import Utilities -struct ReadingSettingView: View { +public struct ReadingSettingView: View { @Binding private var readingDirection: ReadingDirection @Binding private var prefetchLimit: Int @Binding private var enablesLandscape: Bool @@ -12,7 +11,7 @@ struct ReadingSettingView: View { @Binding private var maximumScaleFactor: Double @Binding private var doubleTapScaleFactor: Double - init( + public init( readingDirection: Binding, prefetchLimit: Binding, enablesLandscape: Binding, contentDividerHeight: Binding, maximumScaleFactor: Binding, doubleTapScaleFactor: Binding @@ -25,7 +24,7 @@ struct ReadingSettingView: View { _doubleTapScaleFactor = doubleTapScaleFactor } - var body: some View { + public var body: some View { Form { Section { Picker(L10n.Localizable.ReadingSettingView.Title.direction, selection: $readingDirection) { diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift index 5fec89b18..bade53bb4 100644 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift +++ b/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift @@ -1,37 +1,5 @@ -import SwiftUI import AppModels import ComposableArchitecture -import SwiftUINavigationExt -import HapticsClient - -extension Reducer { - func haptics( - unwrapping enum: @escaping (State) -> Enum?, - case caseKeyPath: CaseKeyPath, - hapticsClient: HapticsClient, - style: UIImpactFeedbackGenerator.FeedbackStyle = .light - ) -> some Reducer { - onBecomeNonNil(unwrapping: `enum`, case: caseKeyPath) { _, _ in - .run(operation: { _ in await hapticsClient.generateFeedback(style) }) - } - } - - private func onBecomeNonNil( - unwrapping enum: @escaping (State) -> Enum?, - case caseKeyPath: CaseKeyPath, - perform additionalEffects: @escaping (inout State, Action) -> Effect - ) -> some Reducer { - Reduce { state, action in - let previousCase = Binding.constant(`enum`(state)).case(caseKeyPath).wrappedValue - let effects = _reduce(into: &state, action: action) - let currentCase = Binding.constant(`enum`(state)).case(caseKeyPath).wrappedValue - - return previousCase == nil && currentCase != nil - ? .merge(effects, additionalEffects(&state, action)) - : effects - } - } -} // MARK: Logging struct LoggingReducer: Reducer diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift index 09ef2b610..611363e5b 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift @@ -10,6 +10,7 @@ import Networking import DownloadClient import CookieClient import AppLaunchAutomationClient +import ReadingFeature @Reducer struct DetailReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift index 3e8559c9c..da8ed6618 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift @@ -7,6 +7,7 @@ import CommonMark import Utilities import DesignSystem import AppComponents +import ReadingFeature private enum DownloadDialog: Equatable { case delete(isActiveDownload: Bool) diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift index 0919ee1ab..3d145c7a7 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift @@ -7,6 +7,7 @@ import HapticsClient import DatabaseClient import Networking import DownloadClient +import ReadingFeature @Reducer struct PreviewsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift index 2386e1e22..30c72dbd9 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift +++ b/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import Utilities import DesignSystem import AppComponents +import ReadingFeature struct PreviewsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift index 72bf1346a..9d2968165 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import FoundationExt import DownloadClient +import ReadingFeature @Reducer struct DownloadsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift index fc8bc9561..f77e3278a 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift @@ -7,6 +7,7 @@ import SwiftUINavigationExt import Utilities import DesignSystem import AppComponents +import ReadingFeature struct DownloadsView: View { private enum RowDialog: Identifiable { diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift b/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift index c94c0d235..8d8875d63 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift +++ b/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift @@ -4,6 +4,7 @@ import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt import DesignSystem +import AppComponents struct SettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/HapticsClient/Reducer+Haptics.swift b/AppPackage/Sources/HapticsClient/Reducer+Haptics.swift new file mode 100644 index 000000000..cdaaf3197 --- /dev/null +++ b/AppPackage/Sources/HapticsClient/Reducer+Haptics.swift @@ -0,0 +1,32 @@ +import SwiftUI +import ComposableArchitecture +import SwiftUINavigationExt + +extension Reducer { + public func haptics( + unwrapping enum: @escaping (State) -> Enum?, + case caseKeyPath: CaseKeyPath, + hapticsClient: HapticsClient, + style: UIImpactFeedbackGenerator.FeedbackStyle = .light + ) -> some Reducer { + onBecomeNonNil(unwrapping: `enum`, case: caseKeyPath) { _, _ in + .run(operation: { _ in await hapticsClient.generateFeedback(style) }) + } + } + + private func onBecomeNonNil( + unwrapping enum: @escaping (State) -> Enum?, + case caseKeyPath: CaseKeyPath, + perform additionalEffects: @escaping (inout State, Action) -> Effect + ) -> some Reducer { + Reduce { state, action in + let previousCase = Binding.constant(`enum`(state)).case(caseKeyPath).wrappedValue + let effects = _reduce(into: &state, action: action) + let currentCase = Binding.constant(`enum`(state)).case(caseKeyPath).wrappedValue + + return previousCase == nil && currentCase != nil + ? .merge(effects, additionalEffects(&state, action)) + : effects + } + } +} diff --git a/AppPackage/Sources/ReadingFeature/.swiftlint.yml b/AppPackage/Sources/ReadingFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Body.swift rename to AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Database.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+Database.swift rename to AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+ImageFetch.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Reading/ReadingReducer+ImageFetch.swift rename to AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift similarity index 79% rename from AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift rename to AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 3348c33a3..023d9fb1b 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -14,15 +14,15 @@ import AppDelegateClient import DesignSystem @Reducer -struct ReadingReducer { +public struct ReadingReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case hud case share(IdentifiableBox) case readingSetting(EquatableVoid = .init()) } - enum ShareItem: Equatable { + public enum ShareItem: Equatable, Sendable { var associatedValue: Any { switch self { case .data(let data): @@ -35,46 +35,46 @@ struct ReadingReducer { case image(UIImage) } - enum ImageAction { + public enum ImageAction: Sendable { case copy, save, share } @ObservableState - struct State: Equatable { - var route: Route? - var contentSource: ReadingContentSource = .remote - var gallery: Gallery = .empty - var language: Language? - - var readingProgress: Int = .zero - var forceRefreshID: UUID = .init() - var hudConfig: ProgressHUDConfigState = .loading() - - var webImageLoadSuccessIndices = Set() - var imageURLLoadingStates = [Int: LoadingState]() - var previewLoadingStates = [Int: LoadingState]() - var databaseLoadingState: LoadingState = .loading - var previewConfig: PreviewConfig = .normal(rows: 4) - - var previewURLs = [Int: URL]() + public struct State: Equatable, Sendable { + public var route: Route? + public var contentSource: ReadingContentSource = .remote + public var gallery: Gallery = .empty + public var language: Language? + + public var readingProgress: Int = .zero + public var forceRefreshID: UUID = .init() + public var hudConfig: ProgressHUDConfigState = .loading() + + public var webImageLoadSuccessIndices = Set() + public var imageURLLoadingStates = [Int: LoadingState]() + public var previewLoadingStates = [Int: LoadingState]() + public var databaseLoadingState: LoadingState = .loading + public var previewConfig: PreviewConfig = .normal(rows: 4) + + public var previewURLs = [Int: URL]() /// The single source of truth for downloaded page files. It is not copied into the /// other URL maps; both offline reads and the opportunistic "use the downloaded file /// if present" check in remote mode resolve a page through this map alone. - var localPageURLs = [Int: URL]() - var localPageRequestID = UUID() + public var localPageURLs = [Int: URL]() + public var localPageRequestID = UUID() - var thumbnailURLs = [Int: URL]() - var imageURLs = [Int: URL]() - var originalImageURLs = [Int: URL]() + public var thumbnailURLs = [Int: URL]() + public var imageURLs = [Int: URL]() + public var originalImageURLs = [Int: URL]() - var mpvKey: String? - var mpvImageKeys = [Int: String]() - var mpvSkipServerIdentifiers = [Int: String]() + public var mpvKey: String? + public var mpvImageKeys = [Int: String]() + public var mpvSkipServerIdentifiers = [Int: String]() - var showsPanel = false - var showsSliderPreview = false + public var showsPanel = false + public var showsSliderPreview = false - init(contentSource: ReadingContentSource = .remote) { + public init(contentSource: ReadingContentSource = .remote) { self.contentSource = contentSource } @@ -132,7 +132,7 @@ struct ReadingReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) @@ -198,5 +198,7 @@ struct ReadingReducer { @Dependency(\.imageClient) var imageClient @Dependency(\.urlClient) var urlClient - var body: some Reducer { makeBody() } + public init() {} + + public var body: some Reducer { makeBody() } } diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingView+Gestures.swift b/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Reading/ReadingView+Gestures.swift rename to AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift similarity index 99% rename from AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift rename to AppPackage/Sources/ReadingFeature/ReadingView.swift index 399e491ff..8809cddc0 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Observation +import SFSafeSymbols import SwiftUIPager import ComposableArchitecture import FoundationExt @@ -9,7 +10,7 @@ import SDWebImageExt import DesignSystem import AppComponents -struct ReadingView: View { +public struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme @Bindable var store: StoreOf @@ -23,7 +24,7 @@ struct ReadingView: View { @State private var pageHandler = PageHandler() @StateObject var page: Page = .first() - init( + public init( store: StoreOf, gid: String, setting: Binding, blurRadius: Double ) { @@ -52,7 +53,7 @@ struct ReadingView: View { return store.localPageURLs.merging(store.originalImageURLs, uniquingKeysWith: { local, _ in local }) } - var body: some View { + public var body: some View { @Bindable var bindableLiveTextHandler = liveTextHandler @Bindable var bindablePageHandler = pageHandler diff --git a/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift similarity index 99% rename from AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift rename to AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift index 3bfa154dd..f580fe3a7 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/ReadingViewComponents.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift @@ -8,6 +8,7 @@ import ComposableArchitecture import Utilities import ImageClient import AppComponents +import SFSafeSymbols // MARK: ImageStackConfig struct ImageStackConfig { diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/AdvancedList.swift b/AppPackage/Sources/ReadingFeature/Support/AdvancedList.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Reading/Support/AdvancedList.swift rename to AppPackage/Sources/ReadingFeature/Support/AdvancedList.swift diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/AutoPlayHandler.swift b/AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Reading/Support/AutoPlayHandler.swift rename to AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift similarity index 99% rename from AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift rename to AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift index 531ef0b8c..3c89d109d 100644 --- a/AppPackage/Sources/AppFeature/View/Reading/Support/ControlPanel.swift +++ b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift @@ -4,6 +4,7 @@ import Resources import Utilities import DesignSystem import AppComponents +import SFSafeSymbols // MARK: ControlPanel struct ControlPanel: View { diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/GestureHandler.swift b/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Reading/Support/GestureHandler.swift rename to AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextHandler.swift b/AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextHandler.swift rename to AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextView.swift b/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Reading/Support/LiveTextView.swift rename to AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift diff --git a/AppPackage/Sources/AppFeature/View/Reading/Support/PageHandler.swift b/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Reading/Support/PageHandler.swift rename to AppPackage/Sources/ReadingFeature/Support/PageHandler.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index b0f543931..78281ae6c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -12,6 +12,7 @@ import ClipboardClient import CookieClient import DeviceClient import AppDelegateClient +@testable import ReadingFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index 277e1c349..69ab12f65 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -11,6 +11,7 @@ import ClipboardClient import CookieClient import DeviceClient import AppDelegateClient +@testable import ReadingFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index 5f44f1b74..1535848fa 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -11,6 +11,7 @@ import ClipboardClient import CookieClient import DeviceClient import AppDelegateClient +@testable import ReadingFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index 36a4f8e7d..5c94b4d78 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -12,6 +12,7 @@ import ClipboardClient import CookieClient import DeviceClient import AppDelegateClient +@testable import ReadingFeature @testable import AppFeature @Suite(.serialized) From dbec9084691441d04acb9de35152b2901cbb9589 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 23:03:11 +0800 Subject: [PATCH 333/614] Extract DetailFeature module; the recursive Detail cluster + FolderManager Largest M7 extraction. Moves the whole recursive Detail cluster (Detail + Comments/Torrents/GalleryInfos/Archives/DetailSearch/Previews + the Detail-specific Components) into one module, since Detail<->Comments and Detail<->DetailSearch are mutually recursive. Absorbs FolderManager (Detail and Downloads both compose it; Detail is the lowest common module, and Downloads depends on Detail). Deps Reading/Filters/QuickSearch. Whole cluster publicized (every child State/Action is exposed through DetailReducer's public State/Action). Public reducers are Sendable; Route/State/CancelID regain explicit Sendable where captured. Three shared pieces relocated out of AppFeature so the module can compile: - Heap (recursive-state property-wrapper box) -> ComposableArchitectureExt - URL.mock -> AppModels (next to its Defaults.URL source and other .mock factories) These leave only LoggingReducer in AppFeature/Tools/Extensions. ~23 AppFeature consumers (Home/Search/Favorites/Downloads/TabBar/AppRoute) and the Detail/Download tests import the new module; appFeatureTests gains the DetailFeature dep. --- AppPackage/Package.swift | 35 ++++++++ .../AppFeature/DataFlow/AppRouteReducer.swift | 2 + .../AppFeature/Tools/Extensions/URL+App.swift | 6 -- .../View/Downloads/DownloadsReducer.swift | 2 + .../View/Downloads/DownloadsView.swift | 1 + .../View/Favorites/FavoritesReducer.swift | 2 + .../View/Favorites/FavoritesView.swift | 1 + .../Home/Frontpage/FrontpageReducer.swift | 2 + .../View/Home/Frontpage/FrontpageView.swift | 1 + .../View/Home/History/HistoryReducer.swift | 2 + .../View/Home/History/HistoryView.swift | 1 + .../View/Home/HomeReducer+Body.swift | 1 + .../AppFeature/View/Home/HomeReducer.swift | 2 + .../AppFeature/View/Home/HomeView.swift | 1 + .../View/Home/Popular/PopularReducer.swift | 2 + .../View/Home/Popular/PopularView.swift | 1 + .../View/Home/Toplists/ToplistsReducer.swift | 2 + .../View/Home/Toplists/ToplistsView.swift | 1 + .../View/Home/Watched/WatchedReducer.swift | 2 + .../View/Home/Watched/WatchedView.swift | 1 + .../View/Search/SearchReducer.swift | 2 + .../View/Search/SearchRootReducer.swift | 2 + .../View/Search/SearchRootView.swift | 1 + .../AppFeature/View/Search/SearchView.swift | 1 + .../AppFeature/View/TabBar/TabBarView.swift | 1 + .../Sources/AppModels/Support/URL+Mock.swift | 5 ++ .../Heap.swift | 8 +- .../Sources/DetailFeature/.swiftlint.yml | 1 + .../Archives/ArchivesReducer.swift | 24 ++--- .../Archives/ArchivesView.swift | 0 .../Comments/CommentsReducer.swift | 29 +++--- .../Comments/CommentsView.swift | 0 .../Components/LinkedText.swift | 0 .../Components/PostCommentView.swift | 0 .../Components/TagDetailView.swift | 0 .../DetailReducer+Actions.swift | 0 .../DetailReducer+Download.swift | 0 .../DetailReducer+Fetch.swift | 0 .../DetailReducer.swift | 88 ++++++++++--------- .../DetailSearch/DetailSearchReducer.swift | 35 ++++---- .../DetailSearch/DetailSearchView.swift | 0 .../DetailView+CommentCells.swift | 0 .../DetailView+HeaderSection.swift | 0 .../DetailView+Navigation.swift | 0 .../DetailView+Subviews.swift | 0 .../Detail => DetailFeature}/DetailView.swift | 6 +- .../FolderManager}/FolderManagerReducer.swift | 26 +++--- .../FolderManager}/FolderManagerView.swift | 6 +- .../GalleryInfos/GalleryInfosReducer.swift | 16 ++-- .../GalleryInfos/GalleryInfosView.swift | 0 .../Previews/PreviewsReducer.swift | 30 ++++--- .../Previews/PreviewsView.swift | 0 .../Torrents/TorrentsReducer.swift | 20 +++-- .../Torrents/TorrentsView.swift | 0 .../Download/DetailReducerDownloadTests.swift | 1 + .../Download/DetailReducerMetadataTests.swift | 1 + .../DetailReducerMetadataUpdateTests.swift | 1 + .../Download/DetailReducerObserveTests.swift | 1 + .../DetailReducerPauseAndGuardTests.swift | 1 + .../Download/DownloadFeatureTestHelpers.swift | 1 + .../DownloadObserverReadingTests.swift | 1 + .../DownloadObserverRefreshTests.swift | 1 + .../Download/FolderManagerReducerTests.swift | 1 + .../PreviewsReducerDownloadTests.swift | 1 + .../ReadingReducerDownloadTests.swift | 1 + 65 files changed, 240 insertions(+), 140 deletions(-) delete mode 100644 AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift create mode 100644 AppPackage/Sources/AppModels/Support/URL+Mock.swift rename AppPackage/Sources/{AppFeature/DataFlow => ComposableArchitectureExt}/Heap.swift (77%) create mode 100644 AppPackage/Sources/DetailFeature/.swiftlint.yml rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/Archives/ArchivesReducer.swift (91%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/Archives/ArchivesView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/Comments/CommentsReducer.swift (93%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/Comments/CommentsView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/Components/LinkedText.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/Components/PostCommentView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/Components/TagDetailView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/DetailReducer+Actions.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/DetailReducer+Download.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/DetailReducer+Fetch.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/DetailReducer.swift (79%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/DetailSearch/DetailSearchReducer.swift (89%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/DetailSearch/DetailSearchView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/DetailView+CommentCells.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/DetailView+HeaderSection.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/DetailView+Navigation.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/DetailView+Subviews.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/DetailView.swift (99%) rename AppPackage/Sources/{AppFeature/View/Downloads => DetailFeature/FolderManager}/FolderManagerReducer.swift (91%) rename AppPackage/Sources/{AppFeature/View/Downloads => DetailFeature/FolderManager}/FolderManagerView.swift (97%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/GalleryInfos/GalleryInfosReducer.swift (70%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/GalleryInfos/GalleryInfosView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/Previews/PreviewsReducer.swift (92%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/Previews/PreviewsView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/Torrents/TorrentsReducer.swift (88%) rename AppPackage/Sources/{AppFeature/View/Detail => DetailFeature}/Torrents/TorrentsView.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 837f0e6f3..edb0c9ff6 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -80,6 +80,7 @@ enum Module: String { case databaseClient = "DatabaseClient" case dateSeekFeature = "DateSeekFeature" case designSystem = "DesignSystem" + case detailFeature = "DetailFeature" case deviceClient = "DeviceClient" case downloadClient = "DownloadClient" case fileClient = "FileClient" @@ -249,6 +250,7 @@ let targets: [PackageDescription.Target] = [ .module(.databaseClient), .module(.dateSeekFeature), .module(.designSystem), + .module(.detailFeature), .module(.dfClient), .module(.deviceClient), .module(.downloadClient), @@ -576,6 +578,38 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .detailFeature, + dependencies: [ + .module(.appComponents), + .module(.appLaunchAutomationClient), + .module(.appModels), + .module(.clipboardClient), + .module(.composableArchitectureExt), + .module(.cookieClient), + .module(.databaseClient), + .module(.designSystem), + .module(.downloadClient), + .module(.fileClient), + .module(.filtersFeature), + .module(.foundationExt), + .module(.hapticsClient), + .module(.networking), + .module(.quickSearchFeature), + .module(.readingFeature), + .module(.resources), + .module(.swiftUINavigationExt), + .module(.uiApplicationClient), + .module(.urlClient), + .module(.utilities), + .targetDependency(.commonMark), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher), + .targetDependency(.sfSafeSymbols) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .readingFeature, dependencies: [ @@ -698,6 +732,7 @@ let targets: [PackageDescription.Target] = [ .module(.clipboardClient), .module(.cookieClient), .module(.databaseClient), + .module(.detailFeature), .module(.dfClient), .module(.deviceClient), .module(.downloadClient), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index a500ae097..23dd81b1b 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -9,6 +9,8 @@ import DatabaseClient import Networking import ClipboardClient import DesignSystem +import DetailFeature +import ComposableArchitectureExt @Reducer struct AppRouteReducer { diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift b/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift deleted file mode 100644 index c0804515b..000000000 --- a/AppPackage/Sources/AppFeature/Tools/Extensions/URL+App.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation -import AppModels - -extension URL { - static let mock = Defaults.URL.ehentai -} diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift index 9d2968165..b4d9712ce 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift @@ -4,6 +4,8 @@ import ComposableArchitecture import FoundationExt import DownloadClient import ReadingFeature +import DetailFeature +import ComposableArchitectureExt @Reducer struct DownloadsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift index f77e3278a..b67a33a5f 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift +++ b/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift @@ -8,6 +8,7 @@ import Utilities import DesignSystem import AppComponents import ReadingFeature +import DetailFeature struct DownloadsView: View { private enum RowDialog: Identifiable { diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift index 6710d9946..36fbd2082 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift @@ -9,6 +9,8 @@ import Networking import DownloadClient import DateSeekFeature import QuickSearchFeature +import DetailFeature +import ComposableArchitectureExt @Reducer struct FavoritesReducer { diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift index 4aa2434df..3a59f04e8 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift +++ b/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift @@ -8,6 +8,7 @@ import Utilities import DesignSystem import AppComponents import QuickSearchFeature +import DetailFeature struct FavoritesView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift index 12807fd7e..a3b4fb274 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift @@ -8,6 +8,8 @@ import DatabaseClient import Networking import FiltersFeature import DateSeekFeature +import DetailFeature +import ComposableArchitectureExt @Reducer struct FrontpageReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift index ead55c879..46a29b73d 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift @@ -8,6 +8,7 @@ import Utilities import DesignSystem import AppComponents import FiltersFeature +import DetailFeature struct FrontpageView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift index df7cda0f9..f017f3355 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift @@ -5,6 +5,8 @@ import FoundationExt import HapticsClient import DatabaseClient import DownloadClient +import DetailFeature +import ComposableArchitectureExt @Reducer struct HistoryReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift index 0529ae8a9..dba12c0ef 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift @@ -6,6 +6,7 @@ import SwiftUINavigationExt import Utilities import DesignSystem import AppComponents +import DetailFeature struct HistoryView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift b/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift index 5d3e8cf89..db6752f84 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift @@ -3,6 +3,7 @@ import Kingfisher import ComposableArchitecture import Networking import AppModels +import DetailFeature extension HomeReducer { @ReducerBuilder diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift b/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift index 7adfda0e4..5490a36da 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift @@ -5,6 +5,8 @@ import ComposableArchitecture import FoundationExt import LibraryClient import DatabaseClient +import DetailFeature +import ComposableArchitectureExt @Reducer struct HomeReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift index 59d22eb6f..9ef3feaa0 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/HomeView.swift @@ -8,6 +8,7 @@ import SwiftUINavigationExt import Utilities import DesignSystem import AppComponents +import DetailFeature struct HomeView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift index 4bd0916bd..3831f822e 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift @@ -6,6 +6,8 @@ import HapticsClient import DatabaseClient import Networking import FiltersFeature +import DetailFeature +import ComposableArchitectureExt @Reducer struct PopularReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift index dc308691f..83dbbe96f 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift @@ -7,6 +7,7 @@ import Utilities import DesignSystem import AppComponents import FiltersFeature +import DetailFeature struct PopularView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift index 72726b4a4..2a3f03b46 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift @@ -4,6 +4,8 @@ import FoundationExt import HapticsClient import DatabaseClient import Networking +import DetailFeature +import ComposableArchitectureExt @Reducer struct ToplistsReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift index 53e77c780..8f25f6d2f 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift @@ -6,6 +6,7 @@ import SwiftUINavigationExt import Utilities import DesignSystem import AppComponents +import DetailFeature struct ToplistsView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift index 1070fb548..e86b5c8c2 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift @@ -8,6 +8,8 @@ import DownloadClient import FiltersFeature import DateSeekFeature import QuickSearchFeature +import DetailFeature +import ComposableArchitectureExt @Reducer struct WatchedReducer { diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift index d814249d8..bc0c40906 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift +++ b/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift @@ -8,6 +8,7 @@ import DesignSystem import AppComponents import FiltersFeature import QuickSearchFeature +import DetailFeature struct WatchedView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift index bc7e9e6d4..48442f11b 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift @@ -9,6 +9,8 @@ import DownloadClient import FiltersFeature import DateSeekFeature import QuickSearchFeature +import DetailFeature +import ComposableArchitectureExt @Reducer struct SearchReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift index ac296b589..f146a66f8 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift @@ -6,6 +6,8 @@ import HapticsClient import DatabaseClient import FiltersFeature import QuickSearchFeature +import DetailFeature +import ComposableArchitectureExt @Reducer struct SearchRootReducer { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift index b1df123a8..ca4dda855 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift @@ -9,6 +9,7 @@ import DesignSystem import AppComponents import FiltersFeature import QuickSearchFeature +import DetailFeature struct SearchRootView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift index 5c20bac00..08b6f632c 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift +++ b/AppPackage/Sources/AppFeature/View/Search/SearchView.swift @@ -7,6 +7,7 @@ import DesignSystem import AppComponents import FiltersFeature import QuickSearchFeature +import DetailFeature struct SearchView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 708e8d9b1..510bc06e0 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -6,6 +6,7 @@ import ComposableArchitecture import Utilities import DesignSystem import AppComponents +import DetailFeature struct TabBarView: View { @Environment(\.scenePhase) private var scenePhase diff --git a/AppPackage/Sources/AppModels/Support/URL+Mock.swift b/AppPackage/Sources/AppModels/Support/URL+Mock.swift new file mode 100644 index 000000000..46873de87 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/URL+Mock.swift @@ -0,0 +1,5 @@ +import Foundation + +extension URL { + public static let mock = Defaults.URL.ehentai +} diff --git a/AppPackage/Sources/AppFeature/DataFlow/Heap.swift b/AppPackage/Sources/ComposableArchitectureExt/Heap.swift similarity index 77% rename from AppPackage/Sources/AppFeature/DataFlow/Heap.swift rename to AppPackage/Sources/ComposableArchitectureExt/Heap.swift index 6454ac5a9..81ebb9540 100755 --- a/AppPackage/Sources/AppFeature/DataFlow/Heap.swift +++ b/AppPackage/Sources/ComposableArchitectureExt/Heap.swift @@ -8,14 +8,14 @@ private final class Reference: Equatable { } } -@propertyWrapper struct Heap: Equatable { +@propertyWrapper public struct Heap: Equatable { private var reference: Reference - init(_ value: T) { + public init(_ value: T) { reference = .init(value) } - var wrappedValue: T { + public var wrappedValue: T { get { reference.value } set { if !isKnownUniquelyReferenced(&reference) { @@ -25,7 +25,7 @@ private final class Reference: Equatable { reference.value = newValue } } - var projectedValue: Heap { + public var projectedValue: Heap { self } } diff --git a/AppPackage/Sources/DetailFeature/.swiftlint.yml b/AppPackage/Sources/DetailFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DetailFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift similarity index 91% rename from AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift rename to AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index b09fb4ff1..849dc607f 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -10,9 +10,9 @@ import CookieClient import DesignSystem @Reducer -struct ArchivesReducer { +public struct ArchivesReducer: Sendable { @CasePathable - enum Route { + public enum Route: Sendable { case messageHUD case communicatingHUD } @@ -22,18 +22,18 @@ struct ArchivesReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var selectedArchive: GalleryArchive.HathArchive? + public struct State: Equatable { + public var route: Route? + public var selectedArchive: GalleryArchive.HathArchive? - var loadingState: LoadingState = .idle - var hathArchives = [GalleryArchive.HathArchive]() + public var loadingState: LoadingState = .idle + public var hathArchives = [GalleryArchive.HathArchive]() - var messageHUDConfig: ProgressHUDConfigState = .loading() - var communicatingHUDConfig: ProgressHUDConfigState = .communicating + public var messageHUDConfig: ProgressHUDConfigState = .loading() + public var communicatingHUDConfig: ProgressHUDConfigState = .communicating } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) @@ -52,7 +52,9 @@ struct ArchivesReducer { @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() Reduce { state, action in diff --git a/AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/Archives/ArchivesView.swift rename to AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift similarity index 93% rename from AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift rename to AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index 26abcaa21..03c46f253 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -9,11 +9,12 @@ import DatabaseClient import Networking import CookieClient import DesignSystem +import ComposableArchitectureExt @Reducer -struct CommentsReducer { +public struct CommentsReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case hud case detail(String) case postComment(String) @@ -24,23 +25,23 @@ struct CommentsReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var commentContent = "" - var postCommentFocused = false + public struct State: Equatable { + public var route: Route? + public var commentContent = "" + public var postCommentFocused = false - var hudConfig: ProgressHUDConfigState = .loading() - var scrollCommentID: String? - var scrollRowOpacity: Double = 1 + public var hudConfig: ProgressHUDConfigState = .loading() + public var scrollCommentID: String? + public var scrollRowOpacity: Double = 1 - var detailState: Heap + public var detailState: Heap - init() { + public init() { detailState = .init(.init()) } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) case clearSubStates @@ -74,7 +75,9 @@ struct CommentsReducer { @Dependency(\.cookieClient) private var cookieClient @Dependency(\.urlClient) private var urlClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/Comments/CommentsView.swift rename to AppPackage/Sources/DetailFeature/Comments/CommentsView.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/Components/LinkedText.swift b/AppPackage/Sources/DetailFeature/Components/LinkedText.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/Components/LinkedText.swift rename to AppPackage/Sources/DetailFeature/Components/LinkedText.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/Components/PostCommentView.swift b/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/Components/PostCommentView.swift rename to AppPackage/Sources/DetailFeature/Components/PostCommentView.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/Components/TagDetailView.swift rename to AppPackage/Sources/DetailFeature/Components/TagDetailView.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Actions.swift rename to AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Download.swift rename to AppPackage/Sources/DetailFeature/DetailReducer+Download.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Fetch.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/DetailReducer+Fetch.swift rename to AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift similarity index 79% rename from AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift rename to AppPackage/Sources/DetailFeature/DetailReducer.swift index 611363e5b..e818b0aa8 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -13,9 +13,9 @@ import AppLaunchAutomationClient import ReadingFeature @Reducer -struct DetailReducer { +public struct DetailReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case reading(EquatableVoid = .init()) case archives(URL, URL) case torrents(EquatableVoid = .init()) @@ -30,7 +30,7 @@ struct DetailReducer { case folderManager(EquatableVoid = .init()) } - enum CancelID: Hashable { + public enum CancelID: Hashable, Sendable { case fetchDatabaseInfos(String) case fetchGalleryDetail(String) case fetchVersionMetadata(String) @@ -65,30 +65,30 @@ struct DetailReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var commentContent = "" - var postCommentFocused = false - var showsNewDawnGreeting = false - var showsUserRating = false - var showsFullTitle = false - var userRating = 0 - var apiKey = "" - var gid = "" - var loadingState: LoadingState = .idle - var gallery: Gallery = .empty - var galleryDetail: GalleryDetail? - var galleryVersionMetadata: DownloadVersionMetadata? - var galleryTags = [GalleryTag]() - var galleryPreviewURLs = [Int: URL]() - var localPreviewURLs = [Int: URL]() - var galleryComments = [GalleryComment]() - var previewConfig: PreviewConfig = .normal(rows: 4) - var downloadBadge: DownloadBadge? - var downloadFailureCode: DownloadFailureCode? - var downloadFolders = [String]() - var isPreparingDownload = false - var hasLoadedDownloadBadge = false + public struct State: Equatable { + public var route: Route? + public var commentContent = "" + public var postCommentFocused = false + public var showsNewDawnGreeting = false + public var showsUserRating = false + public var showsFullTitle = false + public var userRating = 0 + public var apiKey = "" + public var gid = "" + public var loadingState: LoadingState = .idle + public var gallery: Gallery = .empty + public var galleryDetail: GalleryDetail? + public var galleryVersionMetadata: DownloadVersionMetadata? + public var galleryTags = [GalleryTag]() + public var galleryPreviewURLs = [Int: URL]() + public var localPreviewURLs = [Int: URL]() + public var galleryComments = [GalleryComment]() + public var previewConfig: PreviewConfig = .normal(rows: 4) + public var downloadBadge: DownloadBadge? + public var downloadFailureCode: DownloadFailureCode? + public var downloadFolders = [String]() + public var isPreparingDownload = false + public var hasLoadedDownloadBadge = false var cancellationGalleryID: String { gid.isEmpty ? gallery.id : gid @@ -99,20 +99,20 @@ struct DetailReducer { return badge.progress.completedPageCount == 0 && downloadFailureCode == .fileOperationFailed } - var didRunLaunchAutomation = false - var shouldCheckForRemoteUpdates = false - var didRequestVersionMetadata = false - var localPreviewRequestID = UUID() - var readingState = ReadingReducer.State() - var archivesState = ArchivesReducer.State() - var torrentsState = TorrentsReducer.State() - var previewsState = PreviewsReducer.State() - var commentsState: Heap - var galleryInfosState = GalleryInfosReducer.State() - var folderManagerState = FolderManagerReducer.State() - var detailSearchState: Heap + public var didRunLaunchAutomation = false + public var shouldCheckForRemoteUpdates = false + public var didRequestVersionMetadata = false + public var localPreviewRequestID = UUID() + public var readingState = ReadingReducer.State() + public var archivesState = ArchivesReducer.State() + public var torrentsState = TorrentsReducer.State() + public var previewsState = PreviewsReducer.State() + public var commentsState: Heap + public var galleryInfosState = GalleryInfosReducer.State() + public var folderManagerState = FolderManagerReducer.State() + public var detailSearchState: Heap - init() { + public init() { commentsState = .init(nil) detailSearchState = .init(nil) } @@ -123,7 +123,7 @@ struct DetailReducer { } } - indirect enum Action: BindableAction { + public indirect enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) case clearSubStates @@ -194,7 +194,9 @@ struct DetailReducer { @Dependency(\.cookieClient) var cookieClient @Dependency(\.appLaunchAutomationClient) var appLaunchAutomationClient - var body: some Reducer { detailBody } + public init() {} + + public var body: some Reducer { detailBody } } // MARK: - Reducer Body @@ -247,7 +249,7 @@ extension DetailReducer { // MARK: - Helpers extension DetailReducer { - func applyDownload(_ download: DownloadedGallery?, state: inout State) -> Bool { + public func applyDownload(_ download: DownloadedGallery?, state: inout State) -> Bool { let badge = download?.badge let didChangeBadge = badge != state.downloadBadge || !state.hasLoadedDownloadBadge state.downloadBadge = badge diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift similarity index 89% rename from AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift rename to AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index 7f2b372c9..b2ca02214 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -6,11 +6,12 @@ import DatabaseClient import Networking import FiltersFeature import QuickSearchFeature +import ComposableArchitectureExt @Reducer -struct DetailSearchReducer { +public struct DetailSearchReducer: Sendable { @dynamicMemberLookup @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case filters(EquatableVoid = .unique) case quickSearch(EquatableVoid = .unique) case detail(String) @@ -21,21 +22,21 @@ struct DetailSearchReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - var lastKeyword = "" + public struct State: Equatable { + public var route: Route? + public var keyword = "" + public var lastKeyword = "" - var galleries = [Gallery]() - var pageNumber = PageNumber() - var loadingState: LoadingState = .idle - var footerLoadingState: LoadingState = .idle + public var galleries = [Gallery]() + public var pageNumber = PageNumber() + public var loadingState: LoadingState = .idle + public var footerLoadingState: LoadingState = .idle - var detailState: Heap - var filtersState = FiltersReducer.State() - var quickDetailSearchState = QuickSearchReducer.State() + public var detailState: Heap + public var filtersState = FiltersReducer.State() + public var quickDetailSearchState = QuickSearchReducer.State() - init() { + public init() { detailState = .init(.init()) } @@ -48,7 +49,7 @@ struct DetailSearchReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) case clearSubStates @@ -67,7 +68,9 @@ struct DetailSearchReducer { @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/DetailSearch/DetailSearchView.swift rename to AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift b/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/DetailView+CommentCells.swift rename to AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/DetailView+HeaderSection.swift rename to AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/DetailView+Navigation.swift rename to AppPackage/Sources/DetailFeature/DetailView+Navigation.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/DetailView+Subviews.swift rename to AppPackage/Sources/DetailFeature/DetailView+Subviews.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift similarity index 99% rename from AppPackage/Sources/AppFeature/View/Detail/DetailView.swift rename to AppPackage/Sources/DetailFeature/DetailView.swift index da8ed6618..2ac9206c1 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -73,7 +73,7 @@ private enum DownloadDialog: Equatable { } } -struct DetailView: View { +public struct DetailView: View { @Bindable var store: StoreOf @State private var downloadDialog: DownloadDialog? let gid: String @@ -82,7 +82,7 @@ struct DetailView: View { let blurRadius: Double let tagTranslator: TagTranslator - init( + public init( store: StoreOf, gid: String, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { @@ -94,7 +94,7 @@ struct DetailView: View { self.tagTranslator = tagTranslator } - var body: some View { + public var body: some View { modalModifiers(content: { content }) .animation(.default, value: store.showsUserRating) .animation(.default, value: store.showsFullTitle) diff --git a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift similarity index 91% rename from AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift rename to AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift index d6c13e420..30387ef18 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerReducer.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift @@ -5,13 +5,13 @@ import ComposableArchitecture import DownloadClient @Reducer -struct FolderManagerReducer { +public struct FolderManagerReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case deleteFolder(String) } - enum EditingField: Equatable, Hashable { + public enum EditingField: Equatable, Hashable { case newFolder case renameFolder(String) } @@ -27,12 +27,14 @@ struct FolderManagerReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var editingField: EditingField? - var editingFolderName = "" - var loadingState: LoadingState = .idle - var folders = [String]() + public struct State: Equatable { + public var route: Route? + public var editingField: EditingField? + public var editingFolderName = "" + public var loadingState: LoadingState = .idle + public var folders = [String]() + + public init() {} var normalizedEditingFolderName: String? { DownloadStore.normalizedUserFolderName(editingFolderName) @@ -51,7 +53,7 @@ struct FolderManagerReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) case setEditingField(EditingField?) @@ -71,7 +73,9 @@ struct FolderManagerReducer { @Dependency(\.downloadClient) private var downloadClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() Reduce { state, action in diff --git a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift similarity index 97% rename from AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift rename to AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift index 79c0c867b..48b63a9c4 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/FolderManagerView.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift @@ -6,16 +6,16 @@ import SwiftUINavigationExt import DesignSystem import AppComponents -struct FolderManagerView: View { +public struct FolderManagerView: View { @Bindable private var store: StoreOf @FocusState private var focusedField: FolderManagerReducer.EditingField? @Environment(\.dismiss) private var dismiss - init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } - var body: some View { + public var body: some View { NavigationView { ZStack { List { diff --git a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift similarity index 70% rename from AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift rename to AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift index ff1da020b..6482851ff 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosReducer.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift @@ -4,19 +4,19 @@ import ClipboardClient import DesignSystem @Reducer -struct GalleryInfosReducer { +public struct GalleryInfosReducer: Sendable { @CasePathable - enum Route { + public enum Route: Sendable { case hud } @ObservableState - struct State: Equatable { - var route: Route? - var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded + public struct State: Equatable { + public var route: Route? + public var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) case copyText(String) } @@ -24,7 +24,9 @@ struct GalleryInfosReducer { @Dependency(\.clipboardClient) private var clipboardClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() Reduce { state, action in diff --git a/AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/GalleryInfos/GalleryInfosView.swift rename to AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift similarity index 92% rename from AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift rename to AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index 3d145c7a7..4abeeaf9e 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -10,9 +10,9 @@ import DownloadClient import ReadingFeature @Reducer -struct PreviewsReducer { +public struct PreviewsReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case reading(EquatableVoid = .init()) } @@ -24,19 +24,19 @@ struct PreviewsReducer { } @ObservableState - struct State: Equatable { - var route: Route? + public struct State: Equatable, Sendable { + public var route: Route? - var gallery: Gallery = .empty - var loadingState: LoadingState = .idle - var databaseLoadingState: LoadingState = .loading + public var gallery: Gallery = .empty + public var loadingState: LoadingState = .idle + public var databaseLoadingState: LoadingState = .loading - var previewURLs = [Int: URL]() - var localPreviewURLs = [Int: URL]() - var previewConfig: PreviewConfig = .normal(rows: 4) - var localPreviewRequestID = UUID() + public var previewURLs = [Int: URL]() + public var localPreviewURLs = [Int: URL]() + public var previewConfig: PreviewConfig = .normal(rows: 4) + public var localPreviewRequestID = UUID() - var readingState = ReadingReducer.State() + public var readingState = ReadingReducer.State() mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { self.previewURLs = self.previewURLs.merging( @@ -45,7 +45,7 @@ struct PreviewsReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) case clearSubStates @@ -72,7 +72,9 @@ struct PreviewsReducer { @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/Previews/PreviewsView.swift rename to AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift similarity index 88% rename from AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift rename to AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift index 810980cd8..fec557976 100644 --- a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift @@ -9,9 +9,9 @@ import FileClient import DesignSystem @Reducer -struct TorrentsReducer { +public struct TorrentsReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case hud case share(URL) } @@ -21,14 +21,14 @@ struct TorrentsReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var torrents = [GalleryTorrent]() - var loadingState: LoadingState = .idle - var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded + public struct State: Equatable { + public var route: Route? + public var torrents = [GalleryTorrent]() + public var loadingState: LoadingState = .idle + public var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) @@ -46,7 +46,9 @@ struct TorrentsReducer { @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.fileClient) private var fileClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() Reduce { state, action in diff --git a/AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Detail/Torrents/TorrentsView.swift rename to AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift index f2defbf2e..3ab83b2a7 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift @@ -7,6 +7,7 @@ import DatabaseClient import DownloadClient import CookieClient import AppLaunchAutomationClient +@testable import DetailFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift index ad0225c17..3b84d3084 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift @@ -7,6 +7,7 @@ import DatabaseClient import Networking import DownloadClient import CookieClient +@testable import DetailFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift index ee644cf44..42b352566 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -6,6 +6,7 @@ import HapticsClient import DatabaseClient import DownloadClient import CookieClient +@testable import DetailFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift index edaae039a..4a451bb3c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift @@ -6,6 +6,7 @@ import HapticsClient import DatabaseClient import DownloadClient import CookieClient +@testable import DetailFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift index db250228a..f184dcd2b 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -7,6 +7,7 @@ import DatabaseClient import DownloadClient import CookieClient import AppLaunchAutomationClient +@testable import DetailFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift index a45c4d076..62438b497 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -7,6 +7,7 @@ import UIKit import Testing import LibraryClient import DownloadClient +@testable import DetailFeature @testable import AppFeature // MARK: - Shared Test Helper Protocol diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index 78281ae6c..8f9a62625 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -13,6 +13,7 @@ import CookieClient import DeviceClient import AppDelegateClient @testable import ReadingFeature +@testable import DetailFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift index 69ab12f65..d11a141a8 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -12,6 +12,7 @@ import CookieClient import DeviceClient import AppDelegateClient @testable import ReadingFeature +@testable import DetailFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift index d902e88c4..80447ef70 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import DownloadClient +@testable import DetailFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift index 2a2011f8c..f465d4254 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -5,6 +5,7 @@ import Testing import HapticsClient import DatabaseClient import DownloadClient +@testable import DetailFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift index 1535848fa..03b838d87 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -12,6 +12,7 @@ import CookieClient import DeviceClient import AppDelegateClient @testable import ReadingFeature +@testable import DetailFeature @testable import AppFeature @Suite(.serialized) From 2d2bceacab2f6a079c36e09e1eefee0e698a945b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 23:10:05 +0800 Subject: [PATCH 334/614] Extract HomeFeature module; Home + its five gallery-list children Home + Frontpage/Popular/Watched/History/Toplists in one module (the children are composed only by HomeReducer). Depends on Detail/Filters/ DateSeek/QuickSearch. Whole cluster publicized; HomeReducer.Route plus the HomeMiscGridType/HomeSectionType it carries regain Sendable and public Identifiable.id. AppReducer + TabBarView import the new module. --- AppPackage/Package.swift | 31 ++++++++++++ .../AppFeature/DataFlow/AppReducer.swift | 1 + .../AppFeature/View/TabBar/TabBarView.swift | 1 + AppPackage/Sources/HomeFeature/.swiftlint.yml | 1 + .../Frontpage/FrontpageReducer.swift | 34 ++++++------- .../Frontpage/FrontpageView.swift | 0 .../History/HistoryReducer.swift | 28 ++++++----- .../History/HistoryView.swift | 0 .../HomeReducer+Body.swift | 0 .../Home => HomeFeature}/HomeReducer.swift | 48 ++++++++++--------- .../HomeView+Sections.swift | 0 .../View/Home => HomeFeature}/HomeView.swift | 14 +++--- .../Popular/PopularReducer.swift | 26 +++++----- .../Popular/PopularView.swift | 0 .../Toplists/ToplistsReducer.swift | 36 +++++++------- .../Toplists/ToplistsView.swift | 0 .../Watched/WatchedReducer.swift | 44 +++++++++-------- .../Watched/WatchedView.swift | 0 18 files changed, 155 insertions(+), 109 deletions(-) create mode 100644 AppPackage/Sources/HomeFeature/.swiftlint.yml rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/Frontpage/FrontpageReducer.swift (90%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/Frontpage/FrontpageView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/History/HistoryReducer.swift (86%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/History/HistoryView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/HomeReducer+Body.swift (100%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/HomeReducer.swift (67%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/HomeView+Sections.swift (100%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/HomeView.swift (96%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/Popular/PopularReducer.swift (86%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/Popular/PopularView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/Toplists/ToplistsReducer.swift (89%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/Toplists/ToplistsView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/Watched/WatchedReducer.swift (91%) rename AppPackage/Sources/{AppFeature/View/Home => HomeFeature}/Watched/WatchedView.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index edb0c9ff6..105cc2bdb 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -87,6 +87,7 @@ enum Module: String { case filtersFeature = "FiltersFeature" case foundationExt = "FoundationExt" case hapticsClient = "HapticsClient" + case homeFeature = "HomeFeature" case imageClient = "ImageClient" case libraryClient = "LibraryClient" case loggerClient = "LoggerClient" @@ -258,6 +259,7 @@ let targets: [PackageDescription.Target] = [ .module(.filtersFeature), .module(.foundationExt), .module(.hapticsClient), + .module(.homeFeature), .module(.imageClient), .module(.libraryClient), .module(.loggerClient), @@ -578,6 +580,35 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .homeFeature, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.composableArchitectureExt), + .module(.databaseClient), + .module(.dateSeekFeature), + .module(.designSystem), + .module(.detailFeature), + .module(.downloadClient), + .module(.filtersFeature), + .module(.foundationExt), + .module(.hapticsClient), + .module(.libraryClient), + .module(.networking), + .module(.quickSearchFeature), + .module(.resources), + .module(.swiftUINavigationExt), + .module(.utilities), + .targetDependency(.alertKit), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher), + .targetDependency(.sfSafeSymbols), + .targetDependency(.swiftUIPager) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .detailFeature, dependencies: [ diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 17f481c99..2132a2514 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -7,6 +7,7 @@ import BackgroundProcessingClient import CookieClient import AppLaunchAutomationClient import DeviceClient +import HomeFeature @Reducer struct AppReducer { diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 510bc06e0..12b014131 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -7,6 +7,7 @@ import Utilities import DesignSystem import AppComponents import DetailFeature +import HomeFeature struct TabBarView: View { @Environment(\.scenePhase) private var scenePhase diff --git a/AppPackage/Sources/HomeFeature/.swiftlint.yml b/AppPackage/Sources/HomeFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/HomeFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift similarity index 90% rename from AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift rename to AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index a3b4fb274..a46607520 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -12,9 +12,9 @@ import DetailFeature import ComposableArchitectureExt @Reducer -struct FrontpageReducer { +public struct FrontpageReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case filters(EquatableVoid = .init()) case detail(String) } @@ -24,25 +24,25 @@ struct FrontpageReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" + public struct State: Equatable { + public var route: Route? + public var keyword = "" var filteredGalleries: [Gallery] { guard !keyword.isEmpty else { return galleries } return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) } - var galleries = [Gallery]() - var pageNumber = PageNumber() - var dateSeekNavigation: DateSeekNavigation? - var loadingState: LoadingState = .idle - var footerLoadingState: LoadingState = .idle + public var galleries = [Gallery]() + public var pageNumber = PageNumber() + public var dateSeekNavigation: DateSeekNavigation? + public var loadingState: LoadingState = .idle + public var footerLoadingState: LoadingState = .idle - var dateSeek = DateSeekReducer.State() - var filtersState = FiltersReducer.State() - var detailState: Heap + public var dateSeek = DateSeekReducer.State() + public var filtersState = FiltersReducer.State() + public var detailState: Heap - init() { + public init() { detailState = .init(.init()) } @@ -55,7 +55,7 @@ struct FrontpageReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) case clearSubStates @@ -75,7 +75,9 @@ struct FrontpageReducer { @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Home/Frontpage/FrontpageView.swift rename to AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift similarity index 86% rename from AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift rename to AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index f017f3355..03155626f 100644 --- a/AppPackage/Sources/AppFeature/View/Home/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -9,39 +9,39 @@ import DetailFeature import ComposableArchitectureExt @Reducer -struct HistoryReducer { +public struct HistoryReducer: Sendable { private enum CancelID { case observeDownloads } @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case detail(String) case clearHistory } @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - var clearDialogPresented = false - var downloadBadges = [String: DownloadBadge]() + public struct State: Equatable { + public var route: Route? + public var keyword = "" + public var clearDialogPresented = false + public var downloadBadges = [String: DownloadBadge]() var filteredGalleries: [Gallery] { guard !keyword.isEmpty else { return galleries } return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) } - var galleries = [Gallery]() - var loadingState: LoadingState = .idle + public var galleries = [Gallery]() + public var loadingState: LoadingState = .idle - var detailState: Heap + public var detailState: Heap - init() { + public init() { detailState = .init(.init()) } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case onAppear case setNavigation(Route?) @@ -60,7 +60,9 @@ struct HistoryReducer { @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Home/History/HistoryView.swift rename to AppPackage/Sources/HomeFeature/History/HistoryView.swift diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Home/HomeReducer+Body.swift rename to AppPackage/Sources/HomeFeature/HomeReducer+Body.swift diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift b/AppPackage/Sources/HomeFeature/HomeReducer.swift similarity index 67% rename from AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift rename to AppPackage/Sources/HomeFeature/HomeReducer.swift index 5490a36da..e26e94d11 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeReducer.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer.swift @@ -9,40 +9,40 @@ import DetailFeature import ComposableArchitectureExt @Reducer -struct HomeReducer { +public struct HomeReducer: Sendable { @CasePathable - enum Route: Equatable, Hashable { + public enum Route: Equatable, Hashable, Sendable { case detail(String) case misc(HomeMiscGridType) case section(HomeSectionType) } @ObservableState - struct State: Equatable { - var route: Route? - var cardPageIndex = 1 - var currentCardID = "" - var allowsCardHitTesting = true - var rawCardColors = [String: [Color]]() + public struct State: Equatable { + public var route: Route? + public var cardPageIndex = 1 + public var currentCardID = "" + public var allowsCardHitTesting = true + public var rawCardColors = [String: [Color]]() var cardColors: [Color] { rawCardColors[currentCardID] ?? [.clear] } - var popularGalleries = [Gallery]() - var popularLoadingState: LoadingState = .idle - var frontpageGalleries = [Gallery]() - var frontpageLoadingState: LoadingState = .idle - var toplistsGalleries = [Int: [Gallery]]() - var toplistsLoadingState = [Int: LoadingState]() + public var popularGalleries = [Gallery]() + public var popularLoadingState: LoadingState = .idle + public var frontpageGalleries = [Gallery]() + public var frontpageLoadingState: LoadingState = .idle + public var toplistsGalleries = [Int: [Gallery]]() + public var toplistsLoadingState = [Int: LoadingState]() - var frontpageState = FrontpageReducer.State() - var toplistsState = ToplistsReducer.State() - var popularState = PopularReducer.State() - var watchedState = WatchedReducer.State() - var historyState = HistoryReducer.State() - var detailState: Heap + public var frontpageState = FrontpageReducer.State() + public var toplistsState = ToplistsReducer.State() + public var popularState = PopularReducer.State() + public var watchedState = WatchedReducer.State() + public var historyState = HistoryReducer.State() + public var detailState: Heap - init() { + public init() { detailState = .init(.init()) } @@ -67,7 +67,7 @@ struct HomeReducer { } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) case clearSubStates @@ -95,5 +95,7 @@ struct HomeReducer { @Dependency(\.databaseClient) var databaseClient @Dependency(\.libraryClient) var libraryClient - var body: some Reducer { reducerBody } + public init() {} + + public var body: some Reducer { reducerBody } } diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Home/HomeView+Sections.swift rename to AppPackage/Sources/HomeFeature/HomeView+Sections.swift diff --git a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift similarity index 96% rename from AppPackage/Sources/AppFeature/View/Home/HomeView.swift rename to AppPackage/Sources/HomeFeature/HomeView.swift index 9ef3feaa0..c076069f0 100644 --- a/AppPackage/Sources/AppFeature/View/Home/HomeView.swift +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -10,14 +10,14 @@ import DesignSystem import AppComponents import DetailFeature -struct HomeView: View { +public struct HomeView: View { @Bindable private var store: StoreOf private let user: User @Binding private var setting: Setting private let blurRadius: Double private let tagTranslator: TagTranslator - init( + public init( store: StoreOf, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { @@ -29,7 +29,7 @@ struct HomeView: View { } // MARK: HomeView - var body: some View { + public var body: some View { NavigationView { let content = ZStack { @@ -192,8 +192,8 @@ private extension HomeView { } // MARK: Definition -enum HomeMiscGridType: CaseIterable, Identifiable { - var id: String { title } +public enum HomeMiscGridType: CaseIterable, Identifiable, Sendable { + public var id: String { title } case popular case watched @@ -223,8 +223,8 @@ extension HomeMiscGridType { } } -enum HomeSectionType: String, CaseIterable, Identifiable { - var id: String { rawValue } +public enum HomeSectionType: String, CaseIterable, Identifiable, Sendable { + public var id: String { rawValue } case frontpage case toplists diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift similarity index 86% rename from AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift rename to AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index 3831f822e..b7e4e905f 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -10,9 +10,9 @@ import DetailFeature import ComposableArchitectureExt @Reducer -struct PopularReducer { +public struct PopularReducer: Sendable { @dynamicMemberLookup @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case filters(EquatableVoid = .unique) case detail(String) } @@ -22,26 +22,26 @@ struct PopularReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" + public struct State: Equatable { + public var route: Route? + public var keyword = "" var filteredGalleries: [Gallery] { guard !keyword.isEmpty else { return galleries } return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) } - var galleries = [Gallery]() - var loadingState: LoadingState = .idle + public var galleries = [Gallery]() + public var loadingState: LoadingState = .idle - var filtersState = FiltersReducer.State() - var detailState: Heap + public var filtersState = FiltersReducer.State() + public var detailState: Heap - init() { + public init() { detailState = .init(.init()) } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) case clearSubStates @@ -57,7 +57,9 @@ struct PopularReducer { @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Home/Popular/PopularView.swift rename to AppPackage/Sources/HomeFeature/Popular/PopularView.swift diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift similarity index 89% rename from AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift rename to AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index 2a3f03b46..259909d09 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -8,9 +8,9 @@ import DetailFeature import ComposableArchitectureExt @Reducer -struct ToplistsReducer { +public struct ToplistsReducer: Sendable { @dynamicMemberLookup @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case detail(String) } @@ -19,24 +19,24 @@ struct ToplistsReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - var jumpPageIndex = "" - var jumpPageAlertFocused = false - var jumpPageAlertPresented = false + public struct State: Equatable { + public var route: Route? + public var keyword = "" + public var jumpPageIndex = "" + public var jumpPageAlertFocused = false + public var jumpPageAlertPresented = false - var type: ToplistsType = .yesterday + public var type: ToplistsType = .yesterday var filteredGalleries: [Gallery]? { guard !keyword.isEmpty else { return galleries } return galleries?.filter({ $0.title.caseInsensitiveContains(keyword) }) } - var rawGalleries = [ToplistsType: [Gallery]]() - var rawPageNumber = [ToplistsType: PageNumber]() - var rawLoadingState = [ToplistsType: LoadingState]() - var rawFooterLoadingState = [ToplistsType: LoadingState]() + public var rawGalleries = [ToplistsType: [Gallery]]() + public var rawPageNumber = [ToplistsType: PageNumber]() + public var rawLoadingState = [ToplistsType: LoadingState]() + public var rawFooterLoadingState = [ToplistsType: LoadingState]() var galleries: [Gallery]? { rawGalleries[type] @@ -51,9 +51,9 @@ struct ToplistsReducer { rawFooterLoadingState[type] } - var detailState: Heap + public var detailState: Heap - init() { + public init() { detailState = .init(.init()) } @@ -66,7 +66,7 @@ struct ToplistsReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) case setToplistsType(ToplistsType) @@ -88,7 +88,9 @@ struct ToplistsReducer { @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Home/Toplists/ToplistsView.swift rename to AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift similarity index 91% rename from AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift rename to AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index e86b5c8c2..de3d6809d 100644 --- a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -12,9 +12,9 @@ import DetailFeature import ComposableArchitectureExt @Reducer -struct WatchedReducer { +public struct WatchedReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case filters(EquatableVoid = .init()) case quickSearch(EquatableVoid = .init()) case detail(String) @@ -25,23 +25,23 @@ struct WatchedReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - - var galleries = [Gallery]() - var pageNumber = PageNumber() - var dateSeekNavigation: DateSeekNavigation? - var loadingState: LoadingState = .idle - var footerLoadingState: LoadingState = .idle - var downloadBadges = [String: DownloadBadge]() - - var dateSeek = DateSeekReducer.State() - var filtersState = FiltersReducer.State() - var quickSearchState = QuickSearchReducer.State() - var detailState: Heap - - init() { + public struct State: Equatable { + public var route: Route? + public var keyword = "" + + public var galleries = [Gallery]() + public var pageNumber = PageNumber() + public var dateSeekNavigation: DateSeekNavigation? + public var loadingState: LoadingState = .idle + public var footerLoadingState: LoadingState = .idle + public var downloadBadges = [String: DownloadBadge]() + + public var dateSeek = DateSeekReducer.State() + public var filtersState = FiltersReducer.State() + public var quickSearchState = QuickSearchReducer.State() + public var detailState: Heap + + public init() { detailState = .init(.init()) } @@ -54,7 +54,7 @@ struct WatchedReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case onAppear case setNavigation(Route?) @@ -80,7 +80,9 @@ struct WatchedReducer { @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Home/Watched/WatchedView.swift rename to AppPackage/Sources/HomeFeature/Watched/WatchedView.swift From 7f7b240e1b4d092931c4ae0b2755d89fcf399189 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 23:13:53 +0800 Subject: [PATCH 335/614] Extract SearchFeature module; SearchRoot + Search SearchRoot composes Search; both move together. Depends on Detail/Filters/ DateSeek/QuickSearch. Reducers publicized (Route regains Sendable); SearchRootView is the only public view; the keywords view needs an explicit SFSafeSymbols import. AppReducer + TabBarView import the module. --- AppPackage/Package.swift | 27 +++++++++++ .../AppFeature/DataFlow/AppReducer.swift | 1 + .../AppFeature/View/TabBar/TabBarView.swift | 1 + .../Sources/SearchFeature/.swiftlint.yml | 1 + .../SearchReducer.swift | 46 ++++++++++--------- .../SearchRootReducer.swift | 32 +++++++------ .../SearchRootView+Keywords.swift | 1 + .../SearchRootView.swift | 6 +-- .../Search => SearchFeature}/SearchView.swift | 0 9 files changed, 75 insertions(+), 40 deletions(-) create mode 100644 AppPackage/Sources/SearchFeature/.swiftlint.yml rename AppPackage/Sources/{AppFeature/View/Search => SearchFeature}/SearchReducer.swift (91%) rename AppPackage/Sources/{AppFeature/View/Search => SearchFeature}/SearchRootReducer.swift (89%) rename AppPackage/Sources/{AppFeature/View/Search => SearchFeature}/SearchRootView+Keywords.swift (99%) rename AppPackage/Sources/{AppFeature/View/Search => SearchFeature}/SearchRootView.swift (99%) rename AppPackage/Sources/{AppFeature/View/Search => SearchFeature}/SearchView.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 105cc2bdb..88c87537d 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -97,6 +97,7 @@ enum Module: String { case quickSearchFeature = "QuickSearchFeature" case readingFeature = "ReadingFeature" case resources = "Resources" + case searchFeature = "SearchFeature" case sdWebImageExt = "SDWebImageExt" case swiftUINavigationExt = "SwiftUINavigationExt" case uiApplicationClient = "UIApplicationClient" @@ -269,6 +270,7 @@ let targets: [PackageDescription.Target] = [ .module(.quickSearchFeature), .module(.readingFeature), .module(.resources), + .module(.searchFeature), .module(.sdWebImageExt), .module(.swiftUINavigationExt), .module(.uiApplicationClient), @@ -580,6 +582,31 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .searchFeature, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.composableArchitectureExt), + .module(.databaseClient), + .module(.dateSeekFeature), + .module(.designSystem), + .module(.detailFeature), + .module(.downloadClient), + .module(.filtersFeature), + .module(.foundationExt), + .module(.hapticsClient), + .module(.networking), + .module(.quickSearchFeature), + .module(.resources), + .module(.swiftUINavigationExt), + .module(.utilities), + .targetDependency(.composableArchitecture), + .targetDependency(.sfSafeSymbols) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .homeFeature, dependencies: [ diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 2132a2514..4925622bd 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -8,6 +8,7 @@ import CookieClient import AppLaunchAutomationClient import DeviceClient import HomeFeature +import SearchFeature @Reducer struct AppReducer { diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 12b014131..ede046fd7 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -8,6 +8,7 @@ import DesignSystem import AppComponents import DetailFeature import HomeFeature +import SearchFeature struct TabBarView: View { @Environment(\.scenePhase) private var scenePhase diff --git a/AppPackage/Sources/SearchFeature/.swiftlint.yml b/AppPackage/Sources/SearchFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/SearchFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift similarity index 91% rename from AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift rename to AppPackage/Sources/SearchFeature/SearchReducer.swift index 48442f11b..0508ea150 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -13,9 +13,9 @@ import DetailFeature import ComposableArchitectureExt @Reducer -struct SearchReducer { +public struct SearchReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case filters(EquatableVoid = .init()) case quickSearch(EquatableVoid = .init()) case detail(String) @@ -26,24 +26,24 @@ struct SearchReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - var lastKeyword = "" - - var galleries = [Gallery]() - var pageNumber = PageNumber() - var dateSeekNavigation: DateSeekNavigation? - var loadingState: LoadingState = .idle - var footerLoadingState: LoadingState = .idle - var downloadBadges = [String: DownloadBadge]() - - var dateSeek = DateSeekReducer.State() - var filtersState = FiltersReducer.State() - var detailState: Heap - var quickSearchState = QuickSearchReducer.State() - - init() { + public struct State: Equatable { + public var route: Route? + public var keyword = "" + public var lastKeyword = "" + + public var galleries = [Gallery]() + public var pageNumber = PageNumber() + public var dateSeekNavigation: DateSeekNavigation? + public var loadingState: LoadingState = .idle + public var footerLoadingState: LoadingState = .idle + public var downloadBadges = [String: DownloadBadge]() + + public var dateSeek = DateSeekReducer.State() + public var filtersState = FiltersReducer.State() + public var detailState: Heap + public var quickSearchState = QuickSearchReducer.State() + + public init() { detailState = .init(.init()) } @@ -56,7 +56,7 @@ struct SearchReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case onAppear case setNavigation(Route?) @@ -81,7 +81,9 @@ struct SearchReducer { @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift similarity index 89% rename from AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift rename to AppPackage/Sources/SearchFeature/SearchRootReducer.swift index f146a66f8..39b88eed6 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -10,9 +10,9 @@ import DetailFeature import ComposableArchitectureExt @Reducer -struct SearchRootReducer { +public struct SearchRootReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case search case filters(EquatableVoid = .init()) case quickSearch(EquatableVoid = .init()) @@ -20,20 +20,20 @@ struct SearchRootReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - var historyGalleries = [Gallery]() + public struct State: Equatable { + public var route: Route? + public var keyword = "" + public var historyGalleries = [Gallery]() - var historyKeywords = [String]() - var quickSearchWords = [QuickSearchWord]() + public var historyKeywords = [String]() + public var quickSearchWords = [QuickSearchWord]() - var searchState = SearchReducer.State() - var filtersState = FiltersReducer.State() - var quickSearchState = QuickSearchReducer.State() - var detailState: Heap + public var searchState = SearchReducer.State() + public var filtersState = FiltersReducer.State() + public var quickSearchState = QuickSearchReducer.State() + public var detailState: Heap - init() { + public init() { detailState = .init(.init()) } @@ -68,7 +68,7 @@ struct SearchRootReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) case setKeyword(String) @@ -91,7 +91,9 @@ struct SearchRootReducer { @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootView+Keywords.swift b/AppPackage/Sources/SearchFeature/SearchRootView+Keywords.swift similarity index 99% rename from AppPackage/Sources/AppFeature/View/Search/SearchRootView+Keywords.swift rename to AppPackage/Sources/SearchFeature/SearchRootView+Keywords.swift index 8557b45eb..8f21c6a82 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootView+Keywords.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView+Keywords.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols import Utilities // MARK: DoubleVerticalKeywordsStack diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift similarity index 99% rename from AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift rename to AppPackage/Sources/SearchFeature/SearchRootView.swift index ca4dda855..9da7e3c26 100644 --- a/AppPackage/Sources/AppFeature/View/Search/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -11,14 +11,14 @@ import FiltersFeature import QuickSearchFeature import DetailFeature -struct SearchRootView: View { +public struct SearchRootView: View { @Bindable private var store: StoreOf private let user: User @Binding private var setting: Setting private let blurRadius: Double private let tagTranslator: TagTranslator - init( + public init( store: StoreOf, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { @@ -29,7 +29,7 @@ struct SearchRootView: View { self.tagTranslator = tagTranslator } - var body: some View { + public var body: some View { NavigationView { let content = ScrollView(showsIndicators: false) { diff --git a/AppPackage/Sources/AppFeature/View/Search/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Search/SearchView.swift rename to AppPackage/Sources/SearchFeature/SearchView.swift From 2c28970d702897cdbe037cd7ac021831593801f8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 23:17:41 +0800 Subject: [PATCH 336/614] Extract FavoritesFeature module FavoritesReducer + FavoritesView; depends on Detail/DateSeek/QuickSearch. Reducer publicized (Route regains Sendable). AppReducer + TabBarView import the module. --- AppPackage/Package.swift | 25 ++++++++++++ .../AppFeature/DataFlow/AppReducer.swift | 1 + .../AppFeature/View/TabBar/TabBarView.swift | 1 + .../Sources/FavoritesFeature/.swiftlint.yml | 1 + .../FavoritesReducer.swift | 40 ++++++++++--------- .../FavoritesView.swift | 6 +-- 6 files changed, 52 insertions(+), 22 deletions(-) create mode 100644 AppPackage/Sources/FavoritesFeature/.swiftlint.yml rename AppPackage/Sources/{AppFeature/View/Favorites => FavoritesFeature}/FavoritesReducer.swift (91%) rename AppPackage/Sources/{AppFeature/View/Favorites => FavoritesFeature}/FavoritesView.swift (98%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 88c87537d..b45d53b2b 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -83,6 +83,7 @@ enum Module: String { case detailFeature = "DetailFeature" case deviceClient = "DeviceClient" case downloadClient = "DownloadClient" + case favoritesFeature = "FavoritesFeature" case fileClient = "FileClient" case filtersFeature = "FiltersFeature" case foundationExt = "FoundationExt" @@ -256,6 +257,7 @@ let targets: [PackageDescription.Target] = [ .module(.dfClient), .module(.deviceClient), .module(.downloadClient), + .module(.favoritesFeature), .module(.fileClient), .module(.filtersFeature), .module(.foundationExt), @@ -582,6 +584,29 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .favoritesFeature, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.composableArchitectureExt), + .module(.databaseClient), + .module(.dateSeekFeature), + .module(.designSystem), + .module(.detailFeature), + .module(.downloadClient), + .module(.hapticsClient), + .module(.networking), + .module(.quickSearchFeature), + .module(.resources), + .module(.swiftUINavigationExt), + .module(.utilities), + .targetDependency(.alertKit), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .searchFeature, dependencies: [ diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 4925622bd..b5915aaa2 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -9,6 +9,7 @@ import AppLaunchAutomationClient import DeviceClient import HomeFeature import SearchFeature +import FavoritesFeature @Reducer struct AppReducer { diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index ede046fd7..cdddcbc7b 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -9,6 +9,7 @@ import AppComponents import DetailFeature import HomeFeature import SearchFeature +import FavoritesFeature struct TabBarView: View { @Environment(\.scenePhase) private var scenePhase diff --git a/AppPackage/Sources/FavoritesFeature/.swiftlint.yml b/AppPackage/Sources/FavoritesFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/FavoritesFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift similarity index 91% rename from AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift rename to AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index 36fbd2082..914576468 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -13,31 +13,31 @@ import DetailFeature import ComposableArchitectureExt @Reducer -struct FavoritesReducer { +public struct FavoritesReducer: Sendable { private enum CancelID { case observeDownloads } @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case quickSearch(EquatableVoid = .init()) case detail(String) } @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" + public struct State: Equatable { + public var route: Route? + public var keyword = "" - var index = -1 - var sortOrder: FavoritesSortOrder? + public var index = -1 + public var sortOrder: FavoritesSortOrder? - var rawGalleries = [Int: [Gallery]]() - var rawPageNumber = [Int: PageNumber]() - var rawDateSeekNavigation = [Int: DateSeekNavigation]() - var rawLoadingState = [Int: LoadingState]() - var rawFooterLoadingState = [Int: LoadingState]() - var downloadBadges = [String: DownloadBadge]() + public var rawGalleries = [Int: [Gallery]]() + public var rawPageNumber = [Int: PageNumber]() + public var rawDateSeekNavigation = [Int: DateSeekNavigation]() + public var rawLoadingState = [Int: LoadingState]() + public var rawFooterLoadingState = [Int: LoadingState]() + public var downloadBadges = [String: DownloadBadge]() var galleries: [Gallery]? { rawGalleries[index] @@ -55,11 +55,11 @@ struct FavoritesReducer { rawFooterLoadingState[index] } - var dateSeek = DateSeekReducer.State() - var detailState: Heap - var quickSearchState = QuickSearchReducer.State() + public var dateSeek = DateSeekReducer.State() + public var detailState: Heap + public var quickSearchState = QuickSearchReducer.State() - init() { + public init() { detailState = .init(.init()) } @@ -72,7 +72,7 @@ struct FavoritesReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case onAppear case setNavigation(Route?) @@ -97,7 +97,9 @@ struct FavoritesReducer { @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift similarity index 98% rename from AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift rename to AppPackage/Sources/FavoritesFeature/FavoritesView.swift index 3a59f04e8..db3b1da04 100644 --- a/AppPackage/Sources/AppFeature/View/Favorites/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -10,14 +10,14 @@ import AppComponents import QuickSearchFeature import DetailFeature -struct FavoritesView: View { +public struct FavoritesView: View { @Bindable private var store: StoreOf private let user: User @Binding private var setting: Setting private let blurRadius: Double private let tagTranslator: TagTranslator - init( + public init( store: StoreOf, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { @@ -33,7 +33,7 @@ struct FavoritesView: View { return (store.index == -1 ? L10n.Localizable.FavoritesView.Title.favorites : favoriteCategory) } - var body: some View { + public var body: some View { NavigationView { let content = ZStack { From 34a0de1560636d6db24fc4f9ba1be4b5f136eef9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 23:23:11 +0800 Subject: [PATCH 337/614] Extract DownloadsFeature module; Downloads + DownloadInspector DownloadsReducer + DownloadInspectorReducer + views; depends on Detail (which now owns FolderManager) and Reading. Reducers publicized; DownloadInspectorReducer.State regains explicit Sendable (its tests capture it in a @Sendable closure). AppReducer + TabBarView and the eight Download* test files import the module; appFeatureTests gains the dep. --- AppPackage/Package.swift | 23 ++++++++++ .../AppFeature/DataFlow/AppReducer.swift | 1 + .../AppFeature/View/TabBar/TabBarView.swift | 1 + .../Sources/DownloadsFeature/.swiftlint.yml | 1 + .../DownloadInspectorReducer.swift | 32 +++++++------- .../DownloadsReducer.swift | 42 ++++++++++--------- .../DownloadsView+Subviews.swift | 0 .../DownloadsView.swift | 6 +-- .../DownloadFilterAndBadgeTests.swift | 1 + .../Download/DownloadInspectorLoadTests.swift | 1 + .../DownloadInspectorRetryTests.swift | 1 + .../Download/DownloadInspectorSkipTests.swift | 1 + .../Download/DownloadObserverBatchTests.swift | 1 + .../DownloadsReducerActionTests.swift | 1 + .../DownloadsReducerReadingDismissTests.swift | 1 + .../DownloadsReducerRefreshTests.swift | 1 + 16 files changed, 76 insertions(+), 38 deletions(-) create mode 100644 AppPackage/Sources/DownloadsFeature/.swiftlint.yml rename AppPackage/Sources/{AppFeature/View/Downloads => DownloadsFeature}/DownloadInspectorReducer.swift (93%) rename AppPackage/Sources/{AppFeature/View/Downloads => DownloadsFeature}/DownloadsReducer.swift (92%) rename AppPackage/Sources/{AppFeature/View/Downloads => DownloadsFeature}/DownloadsView+Subviews.swift (100%) rename AppPackage/Sources/{AppFeature/View/Downloads => DownloadsFeature}/DownloadsView.swift (99%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index b45d53b2b..a2b0c6172 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -83,6 +83,7 @@ enum Module: String { case detailFeature = "DetailFeature" case deviceClient = "DeviceClient" case downloadClient = "DownloadClient" + case downloadsFeature = "DownloadsFeature" case favoritesFeature = "FavoritesFeature" case fileClient = "FileClient" case filtersFeature = "FiltersFeature" @@ -257,6 +258,7 @@ let targets: [PackageDescription.Target] = [ .module(.dfClient), .module(.deviceClient), .module(.downloadClient), + .module(.downloadsFeature), .module(.favoritesFeature), .module(.fileClient), .module(.filtersFeature), @@ -584,6 +586,26 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .downloadsFeature, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.composableArchitectureExt), + .module(.designSystem), + .module(.detailFeature), + .module(.downloadClient), + .module(.foundationExt), + .module(.readingFeature), + .module(.resources), + .module(.swiftUINavigationExt), + .module(.utilities), + .targetDependency(.composableArchitecture), + .targetDependency(.sfSafeSymbols) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .favoritesFeature, dependencies: [ @@ -819,6 +841,7 @@ let targets: [PackageDescription.Target] = [ .module(.dfClient), .module(.deviceClient), .module(.downloadClient), + .module(.downloadsFeature), .module(.fileClient), .module(.foundationExt), .module(.hapticsClient), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index b5915aaa2..d4b017943 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -10,6 +10,7 @@ import DeviceClient import HomeFeature import SearchFeature import FavoritesFeature +import DownloadsFeature @Reducer struct AppReducer { diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index cdddcbc7b..4bff4031f 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -10,6 +10,7 @@ import DetailFeature import HomeFeature import SearchFeature import FavoritesFeature +import DownloadsFeature struct TabBarView: View { @Environment(\.scenePhase) private var scenePhase diff --git a/AppPackage/Sources/DownloadsFeature/.swiftlint.yml b/AppPackage/Sources/DownloadsFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DownloadsFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift similarity index 93% rename from AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift rename to AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift index 35c699d68..7550bfb7f 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift @@ -6,9 +6,9 @@ import DownloadClient import DesignSystem @Reducer -struct DownloadInspectorReducer { +public struct DownloadInspectorReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case hud } @@ -18,24 +18,24 @@ struct DownloadInspectorReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var gid = "" - var inspection: DownloadInspection? - var stableInspection: DownloadInspection? - var loadingState: LoadingState = .loading - var hudConfig: ProgressHUDConfigState = .loading() - var inspectionRequestID = UUID() - var retryingPageIndices = Set() - var isValidatingImageData = false + public struct State: Equatable, Sendable { + public var route: Route? + public var gid = "" + public var inspection: DownloadInspection? + public var stableInspection: DownloadInspection? + public var loadingState: LoadingState = .loading + public var hudConfig: ProgressHUDConfigState = .loading() + public var inspectionRequestID = UUID() + public var retryingPageIndices = Set() + public var isValidatingImageData = false - init(gid: String = "") { + public init(gid: String = "") { self.gid = gid loadingState = gid.isEmpty ? .idle : .loading } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case onAppear case teardown @@ -53,7 +53,9 @@ struct DownloadInspectorReducer { @Dependency(\.downloadClient) private var downloadClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() Reduce { state, action in diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift similarity index 92% rename from AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift rename to AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index b4d9712ce..cf27d4490 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -8,9 +8,9 @@ import DetailFeature import ComposableArchitectureExt @Reducer -struct DownloadsReducer { +public struct DownloadsReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case inspector(String) case detail(String) case reading(String) @@ -23,22 +23,22 @@ struct DownloadsReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - var folderFilter: DownloadFolderFilter = .all - var folders = [String]() - var downloads = [DownloadedGallery]() - var loadingState: LoadingState = .loading - var hasLoadedInitialDownloads = false - - var detailState: Heap - var readingState = ReadingReducer.State() - var inspectorState = DownloadInspectorReducer.State() - var folderManagerState = FolderManagerReducer.State() - var readingRequestID = UUID() - - init() { + public struct State: Equatable { + public var route: Route? + public var keyword = "" + public var folderFilter: DownloadFolderFilter = .all + public var folders = [String]() + public var downloads = [DownloadedGallery]() + public var loadingState: LoadingState = .loading + public var hasLoadedInitialDownloads = false + + public var detailState: Heap + public var readingState = ReadingReducer.State() + public var inspectorState = DownloadInspectorReducer.State() + public var folderManagerState = FolderManagerReducer.State() + public var readingRequestID = UUID() + + public init() { detailState = .init(.init()) } @@ -53,7 +53,7 @@ struct DownloadsReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) case clearSubStates @@ -87,7 +87,9 @@ struct DownloadsReducer { @Dependency(\.downloadClient) private var downloadClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Downloads/DownloadsView+Subviews.swift rename to AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift diff --git a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift similarity index 99% rename from AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift rename to AppPackage/Sources/DownloadsFeature/DownloadsView.swift index b67a33a5f..65d4077a8 100644 --- a/AppPackage/Sources/AppFeature/View/Downloads/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -10,7 +10,7 @@ import AppComponents import ReadingFeature import DetailFeature -struct DownloadsView: View { +public struct DownloadsView: View { private enum RowDialog: Identifiable { case delete(DownloadedGallery) @@ -30,7 +30,7 @@ struct DownloadsView: View { private let blurRadius: Double private let tagTranslator: TagTranslator - init( + public init( store: StoreOf, user: User, setting: Binding, @@ -44,7 +44,7 @@ struct DownloadsView: View { self.tagTranslator = tagTranslator } - var body: some View { + public var body: some View { NavigationView { if DeviceUtil.isPad { contentView diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 8dd2d21c4..8f2aee468 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -5,6 +5,7 @@ import SFSafeSymbols import ComposableArchitecture import Testing import Utilities +@testable import DownloadsFeature @testable import AppFeature struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift index 85cc59157..de9776629 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -4,6 +4,7 @@ import Resources import ComposableArchitecture import Testing import DownloadClient +@testable import DownloadsFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift index 610ee7f85..9284869f2 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import DownloadClient +@testable import DownloadsFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift index 106de60c6..3471f8c03 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import DownloadClient +@testable import DownloadsFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift index 320ae2d6b..e31d793d2 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import DownloadClient +@testable import DownloadsFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift index d2441b727..8530e9355 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import DownloadClient +@testable import DownloadsFeature @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift index 1620a22a3..fca5c8c32 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift @@ -3,6 +3,7 @@ import Testing import HapticsClient import DeviceClient import AppDelegateClient +@testable import DownloadsFeature @testable import AppFeature struct DownloadsReducerReadingDismissTests { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift index 699e87e02..6f34796a0 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift @@ -3,6 +3,7 @@ import AppModels import ComposableArchitecture import Testing import DownloadClient +@testable import DownloadsFeature @testable import AppFeature @Suite(.serialized) From 84a43ea60674b0995196937db4ccd22dbf63bab9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 23:30:50 +0800 Subject: [PATCH 338/614] Extract SettingFeature module; Setting + all its sub-screens Final per-feature extraction. Setting + Account/Appearance/EhSetting/ General/Login/Logs sub-reducers and the Setting Components (About/Download/ Laboratory/Web) move as one module; the sub-reducers compose only each other, so SettingFeature has no other feature-module dependency. Whole cluster publicized; State/Route/FocusedField regain Sendable, Route keeps public Identifiable.id, SettingReducer.State gets a public init for cross-module construction. AppReducer + TabBarView import the module. AppFeature/View now holds only TabBar (app-level composition). --- AppPackage/Package.swift | 33 ++++++++++++++++++ .../AppFeature/DataFlow/AppReducer.swift | 1 + .../AppFeature/View/TabBar/TabBarView.swift | 1 + .../Sources/SettingFeature/.swiftlint.yml | 1 + .../AccountSettingReducer.swift | 26 +++++++------- .../AccountSetting/AccountSettingView.swift | 0 .../AppearanceSettingReducer.swift | 14 ++++---- .../AppearanceSettingView.swift | 0 .../Components/AboutView.swift | 0 .../Components/DownloadSettingView.swift | 0 .../Components/LaboratorySettingView.swift | 0 .../Components/WebView.swift | 0 .../EhSetting/EhSettingReducer.swift | 22 ++++++------ .../EhSetting/EhSettingView+Sections1.swift | 0 .../EhSetting/EhSettingView+Sections2.swift | 0 .../EhSetting/EhSettingView+Sections3.swift | 0 .../EhSetting/EhSettingView.swift | 0 .../GeneralSettingReducer.swift | 22 ++++++------ .../GeneralSetting/GeneralSettingView.swift | 0 .../Login/LoginReducer.swift | 24 +++++++------ .../Login/LoginView.swift | 0 .../Logs/LogsReducer.swift | 18 +++++----- .../Logs/LogsView.swift | 0 .../SettingReducer+Body.swift | 0 .../SettingReducer+Helpers.swift | 0 .../SettingReducer.swift | 34 +++++++++++-------- .../SettingView.swift | 6 ++-- 27 files changed, 127 insertions(+), 75 deletions(-) create mode 100644 AppPackage/Sources/SettingFeature/.swiftlint.yml rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/AccountSetting/AccountSettingReducer.swift (83%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/AccountSetting/AccountSettingView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/AppearanceSetting/AppearanceSettingReducer.swift (62%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/AppearanceSetting/AppearanceSettingView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/Components/AboutView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/Components/DownloadSettingView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/Components/LaboratorySettingView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/Components/WebView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/EhSetting/EhSettingReducer.swift (91%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/EhSetting/EhSettingView+Sections1.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/EhSetting/EhSettingView+Sections2.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/EhSetting/EhSettingView+Sections3.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/EhSetting/EhSettingView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/GeneralSetting/GeneralSettingReducer.swift (86%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/GeneralSetting/GeneralSettingView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/Login/LoginReducer.swift (85%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/Login/LoginView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/Logs/LogsReducer.swift (86%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/Logs/LogsView.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/SettingReducer+Body.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/SettingReducer+Helpers.swift (100%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/SettingReducer.swift (78%) rename AppPackage/Sources/{AppFeature/View/Setting => SettingFeature}/SettingView.swift (98%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index a2b0c6172..12d03f609 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -101,6 +101,7 @@ enum Module: String { case resources = "Resources" case searchFeature = "SearchFeature" case sdWebImageExt = "SDWebImageExt" + case settingFeature = "SettingFeature" case swiftUINavigationExt = "SwiftUINavigationExt" case uiApplicationClient = "UIApplicationClient" case urlClient = "URLClient" @@ -276,6 +277,7 @@ let targets: [PackageDescription.Target] = [ .module(.resources), .module(.searchFeature), .module(.sdWebImageExt), + .module(.settingFeature), .module(.swiftUINavigationExt), .module(.uiApplicationClient), .module(.urlClient), @@ -629,6 +631,37 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .settingFeature, + dependencies: [ + .module(.appComponents), + .module(.appDelegateClient), + .module(.appModels), + .module(.authorizationClient), + .module(.clipboardClient), + .module(.cookieClient), + .module(.databaseClient), + .module(.designSystem), + .module(.deviceClient), + .module(.dfClient), + .module(.fileClient), + .module(.foundationExt), + .module(.hapticsClient), + .module(.libraryClient), + .module(.loggerClient), + .module(.networking), + .module(.resources), + .module(.swiftUINavigationExt), + .module(.uiApplicationClient), + .module(.userDefaultsClient), + .module(.utilities), + .targetDependency(.composableArchitecture), + .targetDependency(.filePicker), + .targetDependency(.sfSafeSymbols) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .searchFeature, dependencies: [ diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index d4b017943..30e09b228 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -11,6 +11,7 @@ import HomeFeature import SearchFeature import FavoritesFeature import DownloadsFeature +import SettingFeature @Reducer struct AppReducer { diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 4bff4031f..e9504f4a8 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -11,6 +11,7 @@ import HomeFeature import SearchFeature import FavoritesFeature import DownloadsFeature +import SettingFeature struct TabBarView: View { @Environment(\.scenePhase) private var scenePhase diff --git a/AppPackage/Sources/SettingFeature/.swiftlint.yml b/AppPackage/Sources/SettingFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/SettingFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift similarity index 83% rename from AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift rename to AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index 747d534e6..f9a683be0 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -8,9 +8,9 @@ import CookieClient import DesignSystem @Reducer -struct AccountSettingReducer { +public struct AccountSettingReducer: Sendable { @dynamicMemberLookup @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case hud case login case logout @@ -19,17 +19,17 @@ struct AccountSettingReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var ehCookiesState: CookiesState = .empty(.ehentai) - var exCookiesState: CookiesState = .empty(.exhentai) - var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded - - var loginState = LoginReducer.State() - var ehSettingState = EhSettingReducer.State() + public struct State: Equatable, Sendable { + public var route: Route? + public var ehCookiesState: CookiesState = .empty(.ehentai) + public var exCookiesState: CookiesState = .empty(.exhentai) + public var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded + + public var loginState = LoginReducer.State() + public var ehSettingState = EhSettingReducer.State() } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) case onLogoutConfirmButtonTapped @@ -44,7 +44,9 @@ struct AccountSettingReducer { @Dependency(\.cookieClient) private var cookieClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/AccountSetting/AccountSettingView.swift rename to AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift similarity index 62% rename from AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift rename to AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift index 4e7e40f8a..412734a22 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift @@ -1,23 +1,25 @@ import ComposableArchitecture @Reducer -struct AppearanceSettingReducer { +public struct AppearanceSettingReducer: Sendable { @CasePathable - enum Route { + public enum Route: Sendable { case appIcon } @ObservableState - struct State: Equatable { - var route: Route? + public struct State: Equatable, Sendable { + public var route: Route? } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) } - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() Reduce { state, action in diff --git a/AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/AppearanceSetting/AppearanceSettingView.swift rename to AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/Components/AboutView.swift rename to AppPackage/Sources/SettingFeature/Components/AboutView.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/DownloadSettingView.swift b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/Components/DownloadSettingView.swift rename to AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/LaboratorySettingView.swift b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/Components/LaboratorySettingView.swift rename to AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/Components/WebView.swift b/AppPackage/Sources/SettingFeature/Components/WebView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/Components/WebView.swift rename to AppPackage/Sources/SettingFeature/Components/WebView.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift similarity index 91% rename from AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift rename to AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index d32a86767..0045a3b14 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -8,9 +8,9 @@ import Networking import CookieClient @Reducer -struct EhSettingReducer { +public struct EhSettingReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case webView(URL) case deleteProfile } @@ -20,13 +20,13 @@ struct EhSettingReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var editingProfileName = "" - var ehSetting: EhSetting? - var ehProfile: EhProfile? - var loadingState: LoadingState = .idle - var submittingState: LoadingState = .idle + public struct State: Equatable, Sendable { + public var route: Route? + public var editingProfileName = "" + public var ehSetting: EhSetting? + public var ehProfile: EhProfile? + public var loadingState: LoadingState = .idle + public var submittingState: LoadingState = .idle mutating func setEhSetting(_ ehSetting: EhSetting) { let ehProfile: EhProfile = ehSetting.ehProfiles @@ -37,7 +37,7 @@ struct EhSettingReducer { } } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) case setKeyboardHidden @@ -56,6 +56,8 @@ struct EhSettingReducer { @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient + public init() {} + public var body: some Reducer { BindingReducer() diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections1.swift rename to AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections2.swift rename to AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView+Sections3.swift rename to AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/EhSetting/EhSettingView.swift rename to AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift similarity index 86% rename from AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift rename to AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index 88cc361d3..0e3c5fb96 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -7,26 +7,26 @@ import LibraryClient import DatabaseClient @Reducer -struct GeneralSettingReducer { +public struct GeneralSettingReducer: Sendable { @CasePathable - enum Route { + public enum Route: Sendable { case logs case clearCache case removeCustomTranslations } @ObservableState - struct State: Equatable { - var route: Route? + public struct State: Equatable, Sendable { + public var route: Route? - var loadingState: LoadingState = .idle - var diskImageCacheSize = "0 KB" - var passcodeNotSet = false + public var loadingState: LoadingState = .idle + public var diskImageCacheSize = "0 KB" + public var passcodeNotSet = false - var logsState = LogsReducer.State() + public var logsState = LogsReducer.State() } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) case clearSubStates @@ -47,7 +47,9 @@ struct GeneralSettingReducer { @Dependency(\.databaseClient) private var databaseClient @Dependency(\.libraryClient) private var libraryClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() .onChange(of: \.route) { _, state in state.route == nil ? .send(.clearSubStates) : .none diff --git a/AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/GeneralSetting/GeneralSettingView.swift rename to AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift similarity index 85% rename from AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift rename to AppPackage/Sources/SettingFeature/Login/LoginReducer.swift index c23e9148f..e2937d82e 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginReducer.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift @@ -7,28 +7,28 @@ import Networking import CookieClient @Reducer -struct LoginReducer { +public struct LoginReducer: Sendable { private enum CancelID: Hashable { case login } @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case webView(URL) } - enum FocusedField { + public enum FocusedField: Sendable { case username case password } @ObservableState - struct State: Equatable { - var route: Route? - var focusedField: FocusedField? - var username = "" - var password = "" - var loginState: LoadingState = .idle + public struct State: Equatable, Sendable { + public var route: Route? + public var focusedField: FocusedField? + public var username = "" + public var password = "" + public var loginState: LoadingState = .idle var loginButtonDisabled: Bool { username.isEmpty || password.isEmpty @@ -39,7 +39,7 @@ struct LoginReducer { } } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) @@ -51,7 +51,9 @@ struct LoginReducer { @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() Reduce { state, action in diff --git a/AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/Login/LoginView.swift rename to AppPackage/Sources/SettingFeature/Login/LoginView.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift b/AppPackage/Sources/SettingFeature/Logs/LogsReducer.swift similarity index 86% rename from AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift rename to AppPackage/Sources/SettingFeature/Logs/LogsReducer.swift index 873a1452e..eb6ddb550 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsReducer.swift +++ b/AppPackage/Sources/SettingFeature/Logs/LogsReducer.swift @@ -4,9 +4,9 @@ import UIApplicationClient import FileClient @Reducer -struct LogsReducer { +public struct LogsReducer: Sendable { @CasePathable - enum Route: Equatable { + public enum Route: Equatable, Sendable { case log(Log) } @@ -15,13 +15,13 @@ struct LogsReducer { } @ObservableState - struct State: Equatable { - var route: Route? - var loadingState: LoadingState = .idle - var logs = [Log]() + public struct State: Equatable, Sendable { + public var route: Route? + public var loadingState: LoadingState = .idle + public var logs = [Log]() } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) case navigateToFileApp @@ -36,7 +36,9 @@ struct LogsReducer { @Dependency(\.uiApplicationClient) private var uiApplicationClient @Dependency(\.fileClient) private var fileClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() Reduce { state, action in diff --git a/AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift b/AppPackage/Sources/SettingFeature/Logs/LogsView.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/Logs/LogsView.swift rename to AppPackage/Sources/SettingFeature/Logs/LogsView.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Body.swift rename to AppPackage/Sources/SettingFeature/SettingReducer+Body.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Helpers.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift similarity index 100% rename from AppPackage/Sources/AppFeature/View/Setting/SettingReducer+Helpers.swift rename to AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift similarity index 78% rename from AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift rename to AppPackage/Sources/SettingFeature/SettingReducer.swift index 0d16211e8..0cba7c2c4 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -14,10 +14,10 @@ import DeviceClient import AppDelegateClient @Reducer -struct SettingReducer { +public struct SettingReducer: Sendable { @CasePathable - enum Route: Int, Equatable, Hashable, Identifiable, CaseIterable { - var id: Int { rawValue } + public enum Route: Int, Equatable, Hashable, Identifiable, CaseIterable, Sendable { + public var id: Int { rawValue } case account case general @@ -29,20 +29,22 @@ struct SettingReducer { } @ObservableState - struct State: Equatable { + public struct State: Equatable, Sendable { // AppEnvStorage - var setting = Setting() - var tagTranslator = TagTranslator() - var user = User() + public var setting = Setting() + public var tagTranslator = TagTranslator() + public var user = User() - var hasLoadedInitialSetting = false + public var hasLoadedInitialSetting = false - var route: Route? - var tagTranslatorLoadingState: LoadingState = .idle + public var route: Route? + public var tagTranslatorLoadingState: LoadingState = .idle - var accountSettingState = AccountSettingReducer.State() - var generalSettingState = GeneralSettingReducer.State() - var appearanceSettingState = AppearanceSettingReducer.State() + public var accountSettingState = AccountSettingReducer.State() + public var generalSettingState = GeneralSettingReducer.State() + public var appearanceSettingState = AppearanceSettingReducer.State() + + public init() {} mutating func setGreeting(_ greeting: Greeting) { guard let currDate = greeting.updateTime else { return } @@ -71,7 +73,7 @@ struct SettingReducer { } } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) case clearSubStates @@ -117,5 +119,7 @@ struct SettingReducer { @Dependency(\.fileClient) var fileClient @Dependency(\.dfClient) var dfClient - var body: some Reducer { reducerBody } + public init() {} + + public var body: some Reducer { reducerBody } } diff --git a/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift similarity index 98% rename from AppPackage/Sources/AppFeature/View/Setting/SettingView.swift rename to AppPackage/Sources/SettingFeature/SettingView.swift index 8d8875d63..534cf349d 100644 --- a/AppPackage/Sources/AppFeature/View/Setting/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -6,17 +6,17 @@ import SwiftUINavigationExt import DesignSystem import AppComponents -struct SettingView: View { +public struct SettingView: View { @Bindable private var store: StoreOf private let blurRadius: Double - init(store: StoreOf, blurRadius: Double) { + public init(store: StoreOf, blurRadius: Double) { self.store = store self.blurRadius = blurRadius } // MARK: SettingView - var body: some View { + public var body: some View { NavigationView { ScrollView { VStack(spacing: 0) { From 8bc349e32cb5444655dcca4c0f69d34577697b27 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 27 Jun 2026 23:52:04 +0800 Subject: [PATCH 339/614] Slim AppFeature to top-level composition AppFeature now holds only the top-level app composition: AppReducer, AppDelegateReducer, AppLockReducer, AppRouteReducer, RootView, the TabBar (view + reducer), and LoggingReducer. - Relocate the lone surviving Tools/Extensions/Reducer_Extension.swift to DataFlow/LoggingReducer.swift and remove the now-empty Tools tree. The file held only the LoggingReducer struct (used solely by AppReducer), so the old "Reducer_Extension" name was a misnomer and the deep Tools/Extensions path was vestigial. LoggingReducer is app-composition glue, so it stays in AppFeature. - Fix a vertical_parameter_alignment lint warning in AppComponents CustomToolbarItem.init (continuation parameters now align under the first parameter); this regression slipped past the DesignSystem/ AppComponents extraction due to build caching. App shell still imports only AppFeature. Package build, app build, and all 286 tests in 62 suites green. --- AppPackage/Sources/AppComponents/ToolbarItems.swift | 4 ++-- .../Reducer_Extension.swift => DataFlow/LoggingReducer.swift} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename AppPackage/Sources/AppFeature/{Tools/Extensions/Reducer_Extension.swift => DataFlow/LoggingReducer.swift} (100%) diff --git a/AppPackage/Sources/AppComponents/ToolbarItems.swift b/AppPackage/Sources/AppComponents/ToolbarItems.swift index 947b1807a..2f3cff879 100644 --- a/AppPackage/Sources/AppComponents/ToolbarItems.swift +++ b/AppPackage/Sources/AppComponents/ToolbarItems.swift @@ -10,8 +10,8 @@ public struct CustomToolbarItem: ToolbarContent { private let content: Content public init(placement: ToolbarItemPlacement = .navigationBarTrailing, - tint: Color? = nil, disabled: Bool = false, - @ViewBuilder content: () -> Content + tint: Color? = nil, disabled: Bool = false, + @ViewBuilder content: () -> Content ) { self.placement = placement self.tint = tint diff --git a/AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift b/AppPackage/Sources/AppFeature/DataFlow/LoggingReducer.swift similarity index 100% rename from AppPackage/Sources/AppFeature/Tools/Extensions/Reducer_Extension.swift rename to AppPackage/Sources/AppFeature/DataFlow/LoggingReducer.swift From b0252db8a33d12f082324aebe3c258a46bdebf24 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 08:20:03 +0800 Subject: [PATCH 340/614] Wire package tests into the app scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make `xcodebuild -scheme EhPanda test` (and ⌘U in Xcode) run the 286-test AppFeatureTests suite, which previously only ran via the AppPackage-Package scheme. - Add AppPackage/Tests/FeatureTests.xctestplan referencing the AppFeatureTests target in container:AppPackage. The plan lives inside the package (mirroring BitRemoteDev) rather than in the synchronized App/ folder, so it is never copied into the app bundle and needs no membership-exception machinery. - Wire the plan into the EhPanda scheme's TestAction via TestPlans, replacing the empty block. - Reference AppPackage as a folder wrapper in the project navigator and drop it from the project's packageReferences (matching PandoraDev). A bare XCLocalSwiftPackageReference only vends products, so Xcode never put the package's test targets in the app scheme's build graph (xctestrun TestConfigurations came out empty -> "no test bundles"). Indexing the package folder exposes its full target graph; the XCLocalSwiftPackageReference object is kept for the AppFeature product dependency. Finishing-touch audit: all 41 Sources modules + AppFeatureTests carry the parent_config .swiftlint.yml; ShareExtension is already thin (imports only AppIntents/UIKit, shares no app code); no LicensePlist / Settings.bundle has ever existed in this app, so that optional step is N/A. Package.resolved is current. Package build, app build, and 286 tests in 62 suites all green via both the package scheme and the app scheme. --- AppPackage/Tests/FeatureTests.xctestplan | 24 +++++++++++++++++++ EhPanda.xcodeproj/project.pbxproj | 3 ++- .../xcshareddata/xcschemes/EhPanda.xcscheme | 20 ++++++---------- 3 files changed, 33 insertions(+), 14 deletions(-) create mode 100644 AppPackage/Tests/FeatureTests.xctestplan diff --git a/AppPackage/Tests/FeatureTests.xctestplan b/AppPackage/Tests/FeatureTests.xctestplan new file mode 100644 index 000000000..3394f3626 --- /dev/null +++ b/AppPackage/Tests/FeatureTests.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "C0DEC0DE-FEED-4A11-BEEF-FEA7011E5751", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "AppFeatureTests", + "name" : "AppFeatureTests" + } + } + ], + "version" : 1 +} diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index bc236b25b..41eb8066a 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ /* Begin PBXFileReference section */ AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; ABC3C7542593696C00E0C11B /* EhPanda.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EhPanda.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A0F00000000000000000F004 /* AppPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AppPackage; sourceTree = ""; }; EA0C92482C3EB45E00D211F6 /* AltStore.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = AltStore.json; sourceTree = ""; }; EA0C92492C3EB45E00D211F6 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; EA0C924A2C3EB45E00D211F6 /* .gitattributes */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = .gitattributes; sourceTree = ""; }; @@ -121,6 +122,7 @@ ABC3C74B2593696C00E0C11B = { isa = PBXGroup; children = ( + A0F00000000000000000F004 /* AppPackage */, A6844B3D2F780C8600BBF6E5 /* App */, A6844C272F780C8B00BBF6E5 /* ShareExtension */, EA0C92472C3EB44300D211F6 /* Config */, @@ -254,7 +256,6 @@ ); mainGroup = ABC3C74B2593696C00E0C11B; packageReferences = ( - A0F00000000000000000F003 /* XCLocalSwiftPackageReference "AppPackage" */, A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */, ); preferredProjectObjectVersion = 100; diff --git a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme index ba2fd7137..e32bba493 100644 --- a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme +++ b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme @@ -26,19 +26,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - onlyGenerateCoverageForSpecifiedTargets = "YES"> - - - - - - + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + Date: Sun, 28 Jun 2026 09:17:24 +0800 Subject: [PATCH 341/614] Resolve compiler warnings --- AppPackage/Sources/FiltersFeature/FiltersReducer.swift | 2 +- AppPackage/Sources/MigrationFeature/MigrationReducer.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift index 5d93e556a..85cf97f47 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift @@ -5,7 +5,7 @@ import DatabaseClient @Reducer public struct FiltersReducer: Sendable { @CasePathable - public enum Route { + public enum Route: Sendable { case resetFilters } diff --git a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift index 9321d76a8..74b31425f 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift @@ -6,7 +6,7 @@ import DatabaseClient @Reducer public struct MigrationReducer: Sendable { @CasePathable - public enum Route: Equatable { + public enum Route: Equatable, Sendable { case dropDialog } From 9913f7d40ecb22ef0358e05ad21b067a5dfbb9f9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 13:39:52 +0800 Subject: [PATCH 342/614] Rename Parser module to ParserFeature --- AppPackage/Package.swift | 14 +++++++------- .../Sources/DesignSystem/ViewModifiers.swift | 2 +- .../DownloadClient+ResponseValidation.swift | 2 +- .../DownloadClient+ResponseValidationHelpers.swift | 2 +- .../DownloadClient/URL+PreviewCacheCleanup.swift | 2 +- .../Sources/Networking/Request+Account.swift | 2 +- AppPackage/Sources/Networking/Request+Detail.swift | 2 +- .../Sources/Networking/Request+Gallery.swift | 2 +- AppPackage/Sources/Networking/Request+Image.swift | 2 +- AppPackage/Sources/Networking/Request.swift | 2 +- .../{Parser => ParserFeature}/.swiftlint.yml | 0 .../{Parser => ParserFeature}/Parser+Archive.swift | 0 .../{Parser => ParserFeature}/Parser+Comment.swift | 0 .../{Parser => ParserFeature}/Parser+Detail.swift | 0 .../Parser+Favorite.swift | 0 .../Parser+Greeting.swift | 0 .../{Parser => ParserFeature}/Parser+Image.swift | 0 .../{Parser => ParserFeature}/Parser+List.swift | 0 .../{Parser => ParserFeature}/Parser+Misc.swift | 0 .../{Parser => ParserFeature}/Parser+Preview.swift | 0 .../{Parser => ParserFeature}/Parser+Profile.swift | 0 .../Parser+ResponseError.swift | 0 .../{Parser => ParserFeature}/Parser+Shared.swift | 0 .../{Parser => ParserFeature}/Parser+Torrent.swift | 0 .../{Parser => ParserFeature}/Parser+Types.swift | 0 .../{Parser => ParserFeature}/Parser+User.swift | 0 .../Sources/{Parser => ParserFeature}/Parser.swift | 0 .../Gallery/GalleryDetailParserTests.swift | 2 +- .../Gallery/GalleryImageURLParserTests.swift | 2 +- .../Gallery/GalleryMPVKeysParserTests.swift | 2 +- .../List/ListParserTests.swift | 2 +- .../Other/AnimatedImageDataTests.swift | 0 .../Other/BanIntervalParserTests.swift | 2 +- .../Other/DownloadPageErrorParserTests.swift | 2 +- .../Other/EhSettingParserTests.swift | 2 +- .../Other/GreetingParserTests.swift | 2 +- .../Other/SettingDownloadTests.swift | 0 37 files changed, 24 insertions(+), 24 deletions(-) rename AppPackage/Sources/{Parser => ParserFeature}/.swiftlint.yml (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+Archive.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+Comment.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+Detail.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+Favorite.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+Greeting.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+Image.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+List.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+Misc.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+Preview.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+Profile.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+ResponseError.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+Shared.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+Torrent.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+Types.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser+User.swift (100%) rename AppPackage/Sources/{Parser => ParserFeature}/Parser.swift (100%) rename AppPackage/Tests/AppFeatureTests/Tests/{Parser => ParserFeature}/Gallery/GalleryDetailParserTests.swift (98%) rename AppPackage/Tests/AppFeatureTests/Tests/{Parser => ParserFeature}/Gallery/GalleryImageURLParserTests.swift (98%) rename AppPackage/Tests/AppFeatureTests/Tests/{Parser => ParserFeature}/Gallery/GalleryMPVKeysParserTests.swift (94%) rename AppPackage/Tests/AppFeatureTests/Tests/{Parser => ParserFeature}/List/ListParserTests.swift (99%) rename AppPackage/Tests/AppFeatureTests/Tests/{Parser => ParserFeature}/Other/AnimatedImageDataTests.swift (100%) rename AppPackage/Tests/AppFeatureTests/Tests/{Parser => ParserFeature}/Other/BanIntervalParserTests.swift (94%) rename AppPackage/Tests/AppFeatureTests/Tests/{Parser => ParserFeature}/Other/DownloadPageErrorParserTests.swift (99%) rename AppPackage/Tests/AppFeatureTests/Tests/{Parser => ParserFeature}/Other/EhSettingParserTests.swift (99%) rename AppPackage/Tests/AppFeatureTests/Tests/{Parser => ParserFeature}/Other/GreetingParserTests.swift (96%) rename AppPackage/Tests/AppFeatureTests/Tests/{Parser => ParserFeature}/Other/SettingDownloadTests.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 12d03f609..754638bd1 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -95,7 +95,7 @@ enum Module: String { case loggerClient = "LoggerClient" case migrationFeature = "MigrationFeature" case networking = "Networking" - case parser = "Parser" + case parserFeature = "ParserFeature" case quickSearchFeature = "QuickSearchFeature" case readingFeature = "ReadingFeature" case resources = "Resources" @@ -271,7 +271,7 @@ let targets: [PackageDescription.Target] = [ .module(.loggerClient), .module(.migrationFeature), .module(.networking), - .module(.parser), + .module(.parserFeature), .module(.quickSearchFeature), .module(.readingFeature), .module(.resources), @@ -349,7 +349,7 @@ let targets: [PackageDescription.Target] = [ .module(.foundationExt), .module(.libraryClient), .module(.networking), - .module(.parser), + .module(.parserFeature), .module(.resources), .module(.sdWebImageExt), .module(.urlClient), @@ -462,7 +462,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.foundationExt), - .module(.parser), + .module(.parserFeature), .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.deprecatedAPI), @@ -498,7 +498,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.foundationExt), - .module(.parser), + .module(.parserFeature), .module(.resources), .module(.swiftUINavigationExt), .module(.utilities), @@ -817,7 +817,7 @@ let targets: [PackageDescription.Target] = [ plugins: swiftLintPlugins ), .target( - module: .parser, + module: .parserFeature, dependencies: [ .module(.appModels), .module(.foundationExt), @@ -882,7 +882,7 @@ let targets: [PackageDescription.Target] = [ .module(.libraryClient), .module(.loggerClient), .module(.networking), - .module(.parser), + .module(.parserFeature), .module(.readingFeature), .module(.sdWebImageExt), .module(.uiApplicationClient), diff --git a/AppPackage/Sources/DesignSystem/ViewModifiers.swift b/AppPackage/Sources/DesignSystem/ViewModifiers.swift index aaebe0e41..7fc33c302 100644 --- a/AppPackage/Sources/DesignSystem/ViewModifiers.swift +++ b/AppPackage/Sources/DesignSystem/ViewModifiers.swift @@ -2,7 +2,7 @@ import SwiftUI import Kingfisher import SFSafeSymbols import FoundationExt -import Parser +import ParserFeature extension View { public func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift index 0695db39c..6469d7049 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift @@ -4,7 +4,7 @@ import Foundation import ImageIO import FoundationExt import Utilities -import Parser +import ParserFeature // MARK: - Response Error Detection extension DownloadCoordinator { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift index afffd5498..7cc8b787f 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift @@ -5,7 +5,7 @@ import ImageIO import FoundationExt import Utilities import SDWebImageExt -import Parser +import ParserFeature // MARK: - Response Inspection Helpers extension DownloadCoordinator { diff --git a/AppPackage/Sources/DownloadClient/URL+PreviewCacheCleanup.swift b/AppPackage/Sources/DownloadClient/URL+PreviewCacheCleanup.swift index 405a327dc..0994927f5 100644 --- a/AppPackage/Sources/DownloadClient/URL+PreviewCacheCleanup.swift +++ b/AppPackage/Sources/DownloadClient/URL+PreviewCacheCleanup.swift @@ -1,6 +1,6 @@ import Foundation import AppModels -import Parser +import ParserFeature extension URL { public func previewCacheCleanupURLs() -> [URL] { diff --git a/AppPackage/Sources/Networking/Request+Account.swift b/AppPackage/Sources/Networking/Request+Account.swift index 235e04b42..f89f4b591 100644 --- a/AppPackage/Sources/Networking/Request+Account.swift +++ b/AppPackage/Sources/Networking/Request+Account.swift @@ -4,7 +4,7 @@ import Combine import Foundation import FoundationExt import Utilities -import Parser +import ParserFeature // MARK: Account Ops public struct LoginRequest: Request { diff --git a/AppPackage/Sources/Networking/Request+Detail.swift b/AppPackage/Sources/Networking/Request+Detail.swift index 909c8f997..107545d17 100644 --- a/AppPackage/Sources/Networking/Request+Detail.swift +++ b/AppPackage/Sources/Networking/Request+Detail.swift @@ -3,7 +3,7 @@ import AppModels import Combine import Foundation import Utilities -import Parser +import ParserFeature // MARK: Response Types public struct GalleryDetailResponse: Sendable { diff --git a/AppPackage/Sources/Networking/Request+Gallery.swift b/AppPackage/Sources/Networking/Request+Gallery.swift index 42c1b2425..484e6b6c8 100644 --- a/AppPackage/Sources/Networking/Request+Gallery.swift +++ b/AppPackage/Sources/Networking/Request+Gallery.swift @@ -3,7 +3,7 @@ import AppModels import Combine import Foundation import Utilities -import Parser +import ParserFeature // MARK: Fetch ListItems public struct SearchGalleriesRequest: Request { diff --git a/AppPackage/Sources/Networking/Request+Image.swift b/AppPackage/Sources/Networking/Request+Image.swift index 7ee9ad2dc..65643c00b 100644 --- a/AppPackage/Sources/Networking/Request+Image.swift +++ b/AppPackage/Sources/Networking/Request+Image.swift @@ -3,7 +3,7 @@ import AppModels import Combine import Foundation import Utilities -import Parser +import ParserFeature // MARK: Response Types public struct GalleryMPVImageURLResponse: Sendable { diff --git a/AppPackage/Sources/Networking/Request.swift b/AppPackage/Sources/Networking/Request.swift index 681f72412..cf8b0776f 100644 --- a/AppPackage/Sources/Networking/Request.swift +++ b/AppPackage/Sources/Networking/Request.swift @@ -5,7 +5,7 @@ import Foundation import ComposableArchitecture import FoundationExt import Utilities -import Parser +import ParserFeature public protocol Request { associatedtype Response: Sendable diff --git a/AppPackage/Sources/Parser/.swiftlint.yml b/AppPackage/Sources/ParserFeature/.swiftlint.yml similarity index 100% rename from AppPackage/Sources/Parser/.swiftlint.yml rename to AppPackage/Sources/ParserFeature/.swiftlint.yml diff --git a/AppPackage/Sources/Parser/Parser+Archive.swift b/AppPackage/Sources/ParserFeature/Parser+Archive.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+Archive.swift rename to AppPackage/Sources/ParserFeature/Parser+Archive.swift diff --git a/AppPackage/Sources/Parser/Parser+Comment.swift b/AppPackage/Sources/ParserFeature/Parser+Comment.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+Comment.swift rename to AppPackage/Sources/ParserFeature/Parser+Comment.swift diff --git a/AppPackage/Sources/Parser/Parser+Detail.swift b/AppPackage/Sources/ParserFeature/Parser+Detail.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+Detail.swift rename to AppPackage/Sources/ParserFeature/Parser+Detail.swift diff --git a/AppPackage/Sources/Parser/Parser+Favorite.swift b/AppPackage/Sources/ParserFeature/Parser+Favorite.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+Favorite.swift rename to AppPackage/Sources/ParserFeature/Parser+Favorite.swift diff --git a/AppPackage/Sources/Parser/Parser+Greeting.swift b/AppPackage/Sources/ParserFeature/Parser+Greeting.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+Greeting.swift rename to AppPackage/Sources/ParserFeature/Parser+Greeting.swift diff --git a/AppPackage/Sources/Parser/Parser+Image.swift b/AppPackage/Sources/ParserFeature/Parser+Image.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+Image.swift rename to AppPackage/Sources/ParserFeature/Parser+Image.swift diff --git a/AppPackage/Sources/Parser/Parser+List.swift b/AppPackage/Sources/ParserFeature/Parser+List.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+List.swift rename to AppPackage/Sources/ParserFeature/Parser+List.swift diff --git a/AppPackage/Sources/Parser/Parser+Misc.swift b/AppPackage/Sources/ParserFeature/Parser+Misc.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+Misc.swift rename to AppPackage/Sources/ParserFeature/Parser+Misc.swift diff --git a/AppPackage/Sources/Parser/Parser+Preview.swift b/AppPackage/Sources/ParserFeature/Parser+Preview.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+Preview.swift rename to AppPackage/Sources/ParserFeature/Parser+Preview.swift diff --git a/AppPackage/Sources/Parser/Parser+Profile.swift b/AppPackage/Sources/ParserFeature/Parser+Profile.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+Profile.swift rename to AppPackage/Sources/ParserFeature/Parser+Profile.swift diff --git a/AppPackage/Sources/Parser/Parser+ResponseError.swift b/AppPackage/Sources/ParserFeature/Parser+ResponseError.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+ResponseError.swift rename to AppPackage/Sources/ParserFeature/Parser+ResponseError.swift diff --git a/AppPackage/Sources/Parser/Parser+Shared.swift b/AppPackage/Sources/ParserFeature/Parser+Shared.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+Shared.swift rename to AppPackage/Sources/ParserFeature/Parser+Shared.swift diff --git a/AppPackage/Sources/Parser/Parser+Torrent.swift b/AppPackage/Sources/ParserFeature/Parser+Torrent.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+Torrent.swift rename to AppPackage/Sources/ParserFeature/Parser+Torrent.swift diff --git a/AppPackage/Sources/Parser/Parser+Types.swift b/AppPackage/Sources/ParserFeature/Parser+Types.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+Types.swift rename to AppPackage/Sources/ParserFeature/Parser+Types.swift diff --git a/AppPackage/Sources/Parser/Parser+User.swift b/AppPackage/Sources/ParserFeature/Parser+User.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser+User.swift rename to AppPackage/Sources/ParserFeature/Parser+User.swift diff --git a/AppPackage/Sources/Parser/Parser.swift b/AppPackage/Sources/ParserFeature/Parser.swift similarity index 100% rename from AppPackage/Sources/Parser/Parser.swift rename to AppPackage/Sources/ParserFeature/Parser.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryDetailParserTests.swift similarity index 98% rename from AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryDetailParserTests.swift index 86e39b694..285c472c2 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryDetailParserTests.swift @@ -1,6 +1,6 @@ import Kanna import Testing -import Parser +import ParserFeature @testable import AppFeature struct GalleryDetailParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryImageURLParserTests.swift similarity index 98% rename from AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryImageURLParserTests.swift index 97aa75fc0..4bd928f8e 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryImageURLParserTests.swift @@ -1,6 +1,6 @@ import Kanna import Testing -import Parser +import ParserFeature @testable import AppFeature struct GalleryImageURLParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryMPVKeysParserTests.swift similarity index 94% rename from AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryMPVKeysParserTests.swift index 36d3944a8..e59b20e27 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryMPVKeysParserTests.swift @@ -1,6 +1,6 @@ import Kanna import Testing -import Parser +import ParserFeature @testable import AppFeature struct GalleryMPVKeysParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/List/ListParserTests.swift similarity index 99% rename from AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/List/ListParserTests.swift index b46630580..67821ab15 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/List/ListParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/List/ListParserTests.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import Kanna import Testing -import Parser +import ParserFeature @testable import AppFeature struct ListParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/AnimatedImageDataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/AnimatedImageDataTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/AnimatedImageDataTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/AnimatedImageDataTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/BanIntervalParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/BanIntervalParserTests.swift similarity index 94% rename from AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/BanIntervalParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/BanIntervalParserTests.swift index 3c0f462a8..8547bb379 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/BanIntervalParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/BanIntervalParserTests.swift @@ -1,6 +1,6 @@ import Kanna import Testing -import Parser +import ParserFeature @testable import AppFeature struct BanIntervalParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/DownloadPageErrorParserTests.swift similarity index 99% rename from AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/DownloadPageErrorParserTests.swift index e73bd7612..0f0055a0a 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/DownloadPageErrorParserTests.swift @@ -2,7 +2,7 @@ import Kanna import AppModels import Combine import Testing -import Parser +import ParserFeature import Networking @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/EhSettingParserTests.swift similarity index 99% rename from AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/EhSettingParserTests.swift index 20843c16b..4fde3ab8a 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/EhSettingParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/EhSettingParserTests.swift @@ -1,7 +1,7 @@ import Kanna import AppModels import Testing -import Parser +import ParserFeature @testable import AppFeature struct EhSettingParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/GreetingParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/GreetingParserTests.swift similarity index 96% rename from AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/GreetingParserTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/GreetingParserTests.swift index 621426924..04cc989ad 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/GreetingParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/GreetingParserTests.swift @@ -1,6 +1,6 @@ import Kanna import Testing -import Parser +import ParserFeature @testable import AppFeature struct GreetingParserTests: TestHelper { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/SettingDownloadTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Parser/Other/SettingDownloadTests.swift rename to AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/SettingDownloadTests.swift From 5c8b3c427c9654ebf3553053b7e44cc6af4f03ae Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 13:42:11 +0800 Subject: [PATCH 343/614] Rename Networking module to NetworkingFeature --- AppPackage/Package.swift | 24 +++++++++---------- .../AppFeature/DataFlow/AppRouteReducer.swift | 2 +- AppPackage/Sources/DFClient/DFClient.swift | 2 +- .../Archives/ArchivesReducer.swift | 2 +- .../Comments/CommentsReducer.swift | 2 +- .../DetailFeature/DetailReducer+Fetch.swift | 2 +- .../Sources/DetailFeature/DetailReducer.swift | 2 +- .../DetailSearch/DetailSearchReducer.swift | 2 +- .../Previews/PreviewsReducer.swift | 2 +- .../Torrents/TorrentsReducer.swift | 2 +- .../DownloadClient+ExecutionFetch.swift | 2 +- .../DownloadClient+ExecutionSupport.swift | 2 +- .../FavoritesFeature/FavoritesReducer.swift | 2 +- .../Frontpage/FrontpageReducer.swift | 2 +- .../HomeFeature/HomeReducer+Body.swift | 2 +- .../HomeFeature/Popular/PopularReducer.swift | 2 +- .../Toplists/ToplistsReducer.swift | 2 +- .../HomeFeature/Watched/WatchedReducer.swift | 2 +- .../.swiftlint.yml | 0 .../DFExtensions.swift | 0 .../DFRequest.swift | 0 .../DFStreamHandler.swift | 0 .../DFURLProtocol.swift | 0 .../DomainResolver.swift | 0 .../Request+Account.swift | 0 .../Request+Detail.swift | 0 .../Request+Gallery.swift | 0 .../Request+Image.swift | 0 .../Request.swift | 0 .../ReadingReducer+ImageFetch.swift | 2 +- .../ReadingFeature/ReadingReducer.swift | 2 +- .../Sources/SearchFeature/SearchReducer.swift | 2 +- .../EhSetting/EhSettingReducer.swift | 2 +- .../SettingFeature/Login/LoginReducer.swift | 2 +- .../SettingFeature/SettingReducer+Body.swift | 2 +- .../SettingReducer+Helpers.swift | 2 +- .../Download/DetailReducerMetadataTests.swift | 2 +- .../Other/DownloadPageErrorParserTests.swift | 2 +- 38 files changed, 38 insertions(+), 38 deletions(-) rename AppPackage/Sources/{Networking => NetworkingFeature}/.swiftlint.yml (100%) rename AppPackage/Sources/{Networking => NetworkingFeature}/DFExtensions.swift (100%) rename AppPackage/Sources/{Networking => NetworkingFeature}/DFRequest.swift (100%) rename AppPackage/Sources/{Networking => NetworkingFeature}/DFStreamHandler.swift (100%) rename AppPackage/Sources/{Networking => NetworkingFeature}/DFURLProtocol.swift (100%) rename AppPackage/Sources/{Networking => NetworkingFeature}/DomainResolver.swift (100%) rename AppPackage/Sources/{Networking => NetworkingFeature}/Request+Account.swift (100%) rename AppPackage/Sources/{Networking => NetworkingFeature}/Request+Detail.swift (100%) rename AppPackage/Sources/{Networking => NetworkingFeature}/Request+Gallery.swift (100%) rename AppPackage/Sources/{Networking => NetworkingFeature}/Request+Image.swift (100%) rename AppPackage/Sources/{Networking => NetworkingFeature}/Request.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 754638bd1..6cccc9571 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -94,7 +94,7 @@ enum Module: String { case libraryClient = "LibraryClient" case loggerClient = "LoggerClient" case migrationFeature = "MigrationFeature" - case networking = "Networking" + case networkingFeature = "NetworkingFeature" case parserFeature = "ParserFeature" case quickSearchFeature = "QuickSearchFeature" case readingFeature = "ReadingFeature" @@ -270,7 +270,7 @@ let targets: [PackageDescription.Target] = [ .module(.libraryClient), .module(.loggerClient), .module(.migrationFeature), - .module(.networking), + .module(.networkingFeature), .module(.parserFeature), .module(.quickSearchFeature), .module(.readingFeature), @@ -348,7 +348,7 @@ let targets: [PackageDescription.Target] = [ .module(.databaseClient), .module(.foundationExt), .module(.libraryClient), - .module(.networking), + .module(.networkingFeature), .module(.parserFeature), .module(.resources), .module(.sdWebImageExt), @@ -450,7 +450,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .dfClient, dependencies: [ - .module(.networking), + .module(.networkingFeature), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher) ], @@ -458,7 +458,7 @@ let targets: [PackageDescription.Target] = [ plugins: swiftLintPlugins ), .target( - module: .networking, + module: .networkingFeature, dependencies: [ .module(.appModels), .module(.foundationExt), @@ -620,7 +620,7 @@ let targets: [PackageDescription.Target] = [ .module(.detailFeature), .module(.downloadClient), .module(.hapticsClient), - .module(.networking), + .module(.networkingFeature), .module(.quickSearchFeature), .module(.resources), .module(.swiftUINavigationExt), @@ -649,7 +649,7 @@ let targets: [PackageDescription.Target] = [ .module(.hapticsClient), .module(.libraryClient), .module(.loggerClient), - .module(.networking), + .module(.networkingFeature), .module(.resources), .module(.swiftUINavigationExt), .module(.uiApplicationClient), @@ -676,7 +676,7 @@ let targets: [PackageDescription.Target] = [ .module(.filtersFeature), .module(.foundationExt), .module(.hapticsClient), - .module(.networking), + .module(.networkingFeature), .module(.quickSearchFeature), .module(.resources), .module(.swiftUINavigationExt), @@ -702,7 +702,7 @@ let targets: [PackageDescription.Target] = [ .module(.foundationExt), .module(.hapticsClient), .module(.libraryClient), - .module(.networking), + .module(.networkingFeature), .module(.quickSearchFeature), .module(.resources), .module(.swiftUINavigationExt), @@ -732,7 +732,7 @@ let targets: [PackageDescription.Target] = [ .module(.filtersFeature), .module(.foundationExt), .module(.hapticsClient), - .module(.networking), + .module(.networkingFeature), .module(.quickSearchFeature), .module(.readingFeature), .module(.resources), @@ -763,7 +763,7 @@ let targets: [PackageDescription.Target] = [ .module(.foundationExt), .module(.hapticsClient), .module(.imageClient), - .module(.networking), + .module(.networkingFeature), .module(.resources), .module(.sdWebImageExt), .module(.swiftUINavigationExt), @@ -881,7 +881,7 @@ let targets: [PackageDescription.Target] = [ .module(.imageClient), .module(.libraryClient), .module(.loggerClient), - .module(.networking), + .module(.networkingFeature), .module(.parserFeature), .module(.readingFeature), .module(.sdWebImageExt), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 23dd81b1b..a45cfa118 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -6,7 +6,7 @@ import URLClient import UserDefaultsClient import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import ClipboardClient import DesignSystem import DetailFeature diff --git a/AppPackage/Sources/DFClient/DFClient.swift b/AppPackage/Sources/DFClient/DFClient.swift index d56f16d9b..ff384207a 100644 --- a/AppPackage/Sources/DFClient/DFClient.swift +++ b/AppPackage/Sources/DFClient/DFClient.swift @@ -1,7 +1,7 @@ import Foundation import Kingfisher import ComposableArchitecture -import Networking +import NetworkingFeature public struct DFClient: Sendable { public let setActive: @Sendable (Bool) -> Void diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index 849dc607f..f34d399b3 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -5,7 +5,7 @@ import ComposableArchitecture import FoundationExt import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import CookieClient import DesignSystem diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index 03c46f253..441ed8841 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -6,7 +6,7 @@ import URLClient import UIApplicationClient import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import CookieClient import DesignSystem import ComposableArchitectureExt diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift index 89e3e2c39..0a0ae000b 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift @@ -1,6 +1,6 @@ import Foundation import ComposableArchitecture -import Networking +import NetworkingFeature // MARK: - Fetch & Gallery Ops Action Handlers extension DetailReducer { diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index e818b0aa8..bdbac2caa 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -6,7 +6,7 @@ import ComposableArchitectureExt import SwiftUINavigationExt import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import DownloadClient import CookieClient import AppLaunchAutomationClient diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index b2ca02214..179d542be 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -3,7 +3,7 @@ import AppModels import SwiftUINavigationExt import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import FiltersFeature import QuickSearchFeature import ComposableArchitectureExt diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index 4abeeaf9e..7eceac82f 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -5,7 +5,7 @@ import FoundationExt import SwiftUINavigationExt import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import DownloadClient import ReadingFeature diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift index fec557976..3fd4ee51d 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift @@ -3,7 +3,7 @@ import AppModels import ComposableArchitecture import SwiftUINavigationExt import HapticsClient -import Networking +import NetworkingFeature import ClipboardClient import FileClient import DesignSystem diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionFetch.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionFetch.swift index 979a86c1d..fa8b9a476 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionFetch.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionFetch.swift @@ -1,6 +1,6 @@ import Foundation import AppModels -import Networking +import NetworkingFeature // MARK: - Fetch & Normalize Payload extension DownloadCoordinator { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift index 7533ca9b5..3f8cf5f00 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift @@ -1,7 +1,7 @@ import Foundation import AppModels import URLClient -import Networking +import NetworkingFeature // MARK: - Execution Support extension DownloadCoordinator { diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index 914576468..5938dbfde 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -5,7 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import DownloadClient import DateSeekFeature import QuickSearchFeature diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index a46607520..1eacd99b7 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -5,7 +5,7 @@ import FoundationExt import SwiftUINavigationExt import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import FiltersFeature import DateSeekFeature import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift index db6752f84..961c60780 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift @@ -1,7 +1,7 @@ import SwiftUI import Kingfisher import ComposableArchitecture -import Networking +import NetworkingFeature import AppModels import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index b7e4e905f..1d5cae767 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -4,7 +4,7 @@ import FoundationExt import SwiftUINavigationExt import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import FiltersFeature import DetailFeature import ComposableArchitectureExt diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index 259909d09..503a0dfe4 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -3,7 +3,7 @@ import AppModels import FoundationExt import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import DetailFeature import ComposableArchitectureExt diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index de3d6809d..25b22af19 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -3,7 +3,7 @@ import AppModels import SwiftUINavigationExt import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import DownloadClient import FiltersFeature import DateSeekFeature diff --git a/AppPackage/Sources/Networking/.swiftlint.yml b/AppPackage/Sources/NetworkingFeature/.swiftlint.yml similarity index 100% rename from AppPackage/Sources/Networking/.swiftlint.yml rename to AppPackage/Sources/NetworkingFeature/.swiftlint.yml diff --git a/AppPackage/Sources/Networking/DFExtensions.swift b/AppPackage/Sources/NetworkingFeature/DFExtensions.swift similarity index 100% rename from AppPackage/Sources/Networking/DFExtensions.swift rename to AppPackage/Sources/NetworkingFeature/DFExtensions.swift diff --git a/AppPackage/Sources/Networking/DFRequest.swift b/AppPackage/Sources/NetworkingFeature/DFRequest.swift similarity index 100% rename from AppPackage/Sources/Networking/DFRequest.swift rename to AppPackage/Sources/NetworkingFeature/DFRequest.swift diff --git a/AppPackage/Sources/Networking/DFStreamHandler.swift b/AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift similarity index 100% rename from AppPackage/Sources/Networking/DFStreamHandler.swift rename to AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift diff --git a/AppPackage/Sources/Networking/DFURLProtocol.swift b/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift similarity index 100% rename from AppPackage/Sources/Networking/DFURLProtocol.swift rename to AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift diff --git a/AppPackage/Sources/Networking/DomainResolver.swift b/AppPackage/Sources/NetworkingFeature/DomainResolver.swift similarity index 100% rename from AppPackage/Sources/Networking/DomainResolver.swift rename to AppPackage/Sources/NetworkingFeature/DomainResolver.swift diff --git a/AppPackage/Sources/Networking/Request+Account.swift b/AppPackage/Sources/NetworkingFeature/Request+Account.swift similarity index 100% rename from AppPackage/Sources/Networking/Request+Account.swift rename to AppPackage/Sources/NetworkingFeature/Request+Account.swift diff --git a/AppPackage/Sources/Networking/Request+Detail.swift b/AppPackage/Sources/NetworkingFeature/Request+Detail.swift similarity index 100% rename from AppPackage/Sources/Networking/Request+Detail.swift rename to AppPackage/Sources/NetworkingFeature/Request+Detail.swift diff --git a/AppPackage/Sources/Networking/Request+Gallery.swift b/AppPackage/Sources/NetworkingFeature/Request+Gallery.swift similarity index 100% rename from AppPackage/Sources/Networking/Request+Gallery.swift rename to AppPackage/Sources/NetworkingFeature/Request+Gallery.swift diff --git a/AppPackage/Sources/Networking/Request+Image.swift b/AppPackage/Sources/NetworkingFeature/Request+Image.swift similarity index 100% rename from AppPackage/Sources/Networking/Request+Image.swift rename to AppPackage/Sources/NetworkingFeature/Request+Image.swift diff --git a/AppPackage/Sources/Networking/Request.swift b/AppPackage/Sources/NetworkingFeature/Request.swift similarity index 100% rename from AppPackage/Sources/Networking/Request.swift rename to AppPackage/Sources/NetworkingFeature/Request.swift diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift index 39ebc9db0..a4cf21669 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift @@ -1,7 +1,7 @@ import Foundation import ComposableArchitecture import FoundationExt -import Networking +import NetworkingFeature // MARK: - Image URL Fetch Actions extension ReadingReducer { diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 023d9fb1b..5c11ab287 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -5,7 +5,7 @@ import URLClient import HapticsClient import ImageClient import DatabaseClient -import Networking +import NetworkingFeature import DownloadClient import ClipboardClient import CookieClient diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index 0508ea150..2c961bde3 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -4,7 +4,7 @@ import Foundation import SwiftUINavigationExt import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import DownloadClient import FiltersFeature import DateSeekFeature diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index 0045a3b14..1b1342f67 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -4,7 +4,7 @@ import ComposableArchitecture import SwiftUINavigationExt import UIApplicationClient import HapticsClient -import Networking +import NetworkingFeature import CookieClient @Reducer diff --git a/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift index e2937d82e..18dbee4ba 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift @@ -3,7 +3,7 @@ import AppModels import ComposableArchitecture import SwiftUINavigationExt import HapticsClient -import Networking +import NetworkingFeature import CookieClient @Reducer diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 7609e2062..a966330b7 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -1,7 +1,7 @@ import Foundation import AppModels import ComposableArchitecture -import Networking +import NetworkingFeature extension SettingReducer { @ReducerBuilder diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift index 2f9e4efa0..e7a5eae6f 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift @@ -1,7 +1,7 @@ import Foundation import AppModels import ComposableArchitecture -import Networking +import NetworkingFeature extension SettingReducer { func handleLoadUserSettings( diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift index 3b84d3084..34e65f167 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift @@ -4,7 +4,7 @@ import ComposableArchitecture import Testing import HapticsClient import DatabaseClient -import Networking +import NetworkingFeature import DownloadClient import CookieClient @testable import DetailFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/DownloadPageErrorParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/DownloadPageErrorParserTests.swift index 0f0055a0a..46de24ebb 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/DownloadPageErrorParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/DownloadPageErrorParserTests.swift @@ -3,7 +3,7 @@ import AppModels import Combine import Testing import ParserFeature -import Networking +import NetworkingFeature @testable import AppFeature struct DownloadPageErrorParserTests: TestHelper { From 0a9a8e579819c7d5bf6ff4c9d3bee53bc089d1e3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 13:57:11 +0800 Subject: [PATCH 344/614] Rename SDWebImageExt module to AnimatedImageFeature --- AppPackage/Package.swift | 18 +++++++++--------- .../.swiftlint.yml | 0 .../AnimatedImage+.swift} | 0 .../ClipboardClient/ClipboardClient.swift | 2 +- .../DownloadClient+Networking.swift | 2 +- ...nloadClient+ResponseValidationHelpers.swift | 2 +- .../Sources/ImageClient/ImageClient.swift | 2 +- .../Sources/LibraryClient/LibraryClient.swift | 2 +- .../Sources/ReadingFeature/ReadingView.swift | 2 +- .../Other/AnimatedImageDataTests.swift | 2 +- 10 files changed, 16 insertions(+), 16 deletions(-) rename AppPackage/Sources/{SDWebImageExt => AnimatedImageFeature}/.swiftlint.yml (100%) rename AppPackage/Sources/{SDWebImageExt/AnimatedImage_Extension.swift => AnimatedImageFeature/AnimatedImage+.swift} (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 6cccc9571..192c6b3e6 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -66,6 +66,7 @@ let sharedSwiftSettings: [PackageDescription.SwiftSetting] = [ // MARK: Module enum Module: String { + case animatedImageFeature = "AnimatedImageFeature" case appComponents = "AppComponents" case appDelegateClient = "AppDelegateClient" case appFeature = "AppFeature" @@ -100,7 +101,6 @@ enum Module: String { case readingFeature = "ReadingFeature" case resources = "Resources" case searchFeature = "SearchFeature" - case sdWebImageExt = "SDWebImageExt" case settingFeature = "SettingFeature" case swiftUINavigationExt = "SwiftUINavigationExt" case uiApplicationClient = "UIApplicationClient" @@ -276,7 +276,7 @@ let targets: [PackageDescription.Target] = [ .module(.readingFeature), .module(.resources), .module(.searchFeature), - .module(.sdWebImageExt), + .module(.animatedImageFeature), .module(.settingFeature), .module(.swiftUINavigationExt), .module(.uiApplicationClient), @@ -351,7 +351,7 @@ let targets: [PackageDescription.Target] = [ .module(.networkingFeature), .module(.parserFeature), .module(.resources), - .module(.sdWebImageExt), + .module(.animatedImageFeature), .module(.urlClient), .module(.utilities), .targetDependency(.composableArchitecture), @@ -429,7 +429,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .clipboardClient, dependencies: [ - .module(.sdWebImageExt), + .module(.animatedImageFeature), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -529,7 +529,7 @@ let targets: [PackageDescription.Target] = [ plugins: swiftLintPlugins ), .target( - module: .sdWebImageExt, + module: .animatedImageFeature, dependencies: [ .targetDependency(.sdWebImageSwiftUI) ], @@ -765,7 +765,7 @@ let targets: [PackageDescription.Target] = [ .module(.imageClient), .module(.networkingFeature), .module(.resources), - .module(.sdWebImageExt), + .module(.animatedImageFeature), .module(.swiftUINavigationExt), .module(.urlClient), .module(.utilities), @@ -784,7 +784,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.foundationExt), - .module(.sdWebImageExt), + .module(.animatedImageFeature), .module(.utilities), .targetDependency(.composableArchitecture) ], @@ -795,7 +795,7 @@ let targets: [PackageDescription.Target] = [ module: .libraryClient, dependencies: [ .module(.appModels), - .module(.sdWebImageExt), + .module(.animatedImageFeature), .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), @@ -884,7 +884,7 @@ let targets: [PackageDescription.Target] = [ .module(.networkingFeature), .module(.parserFeature), .module(.readingFeature), - .module(.sdWebImageExt), + .module(.animatedImageFeature), .module(.uiApplicationClient), .module(.urlClient), .module(.userDefaultsClient), diff --git a/AppPackage/Sources/SDWebImageExt/.swiftlint.yml b/AppPackage/Sources/AnimatedImageFeature/.swiftlint.yml similarity index 100% rename from AppPackage/Sources/SDWebImageExt/.swiftlint.yml rename to AppPackage/Sources/AnimatedImageFeature/.swiftlint.yml diff --git a/AppPackage/Sources/SDWebImageExt/AnimatedImage_Extension.swift b/AppPackage/Sources/AnimatedImageFeature/AnimatedImage+.swift similarity index 100% rename from AppPackage/Sources/SDWebImageExt/AnimatedImage_Extension.swift rename to AppPackage/Sources/AnimatedImageFeature/AnimatedImage+.swift diff --git a/AppPackage/Sources/ClipboardClient/ClipboardClient.swift b/AppPackage/Sources/ClipboardClient/ClipboardClient.swift index 9c05df87c..420c6a8af 100644 --- a/AppPackage/Sources/ClipboardClient/ClipboardClient.swift +++ b/AppPackage/Sources/ClipboardClient/ClipboardClient.swift @@ -1,6 +1,6 @@ import SwiftUI import ComposableArchitecture -import SDWebImageExt +import AnimatedImageFeature public struct ClipboardClient: Sendable { public let url: @Sendable () -> URL? diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift index 31a38bf50..487e1805c 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift @@ -1,7 +1,7 @@ import Foundation import AppModels import UniformTypeIdentifiers -import SDWebImageExt +import AnimatedImageFeature // MARK: - Network extension DownloadCoordinator { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift index 7cc8b787f..820c90580 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift @@ -4,7 +4,7 @@ import Foundation import ImageIO import FoundationExt import Utilities -import SDWebImageExt +import AnimatedImageFeature import ParserFeature // MARK: - Response Inspection Helpers diff --git a/AppPackage/Sources/ImageClient/ImageClient.swift b/AppPackage/Sources/ImageClient/ImageClient.swift index e397df30f..9b2753b0e 100644 --- a/AppPackage/Sources/ImageClient/ImageClient.swift +++ b/AppPackage/Sources/ImageClient/ImageClient.swift @@ -5,7 +5,7 @@ import Combine import ComposableArchitecture import FoundationExt import Utilities -import SDWebImageExt +import AnimatedImageFeature public struct ImageClient: Sendable { public struct ImageAsset: Sendable { diff --git a/AppPackage/Sources/LibraryClient/LibraryClient.swift b/AppPackage/Sources/LibraryClient/LibraryClient.swift index ce099382d..bba2b811e 100644 --- a/AppPackage/Sources/LibraryClient/LibraryClient.swift +++ b/AppPackage/Sources/LibraryClient/LibraryClient.swift @@ -9,7 +9,7 @@ import SwiftyBeaver import UIImageColors import ComposableArchitecture import Utilities -import SDWebImageExt +import AnimatedImageFeature public struct LibraryClient: Sendable { public let initializeLogger: @Sendable () -> Void diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 8809cddc0..d88dae587 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -6,7 +6,7 @@ import SwiftUIPager import ComposableArchitecture import FoundationExt import Utilities -import SDWebImageExt +import AnimatedImageFeature import DesignSystem import AppComponents diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/AnimatedImageDataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/AnimatedImageDataTests.swift index a8f385594..e60abc142 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/AnimatedImageDataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/AnimatedImageDataTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -import SDWebImageExt +import AnimatedImageFeature @testable import AppFeature struct AnimatedImageDataTests { From 0d7acdddfc92d3eac946ee0b12d183cd57ff54c7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 14:03:05 +0800 Subject: [PATCH 345/614] Move ViewModifiers into AppComponents --- AppPackage/Package.swift | 1 + AppPackage/Sources/AppComponents/AlertKit_Extension.swift | 1 - AppPackage/Sources/AppComponents/AlertView.swift | 1 - AppPackage/Sources/AppComponents/CategoryView.swift | 1 - AppPackage/Sources/AppComponents/Cells/GalleryCardCell.swift | 1 - AppPackage/Sources/AppComponents/Cells/GalleryHistoryCell.swift | 1 - AppPackage/Sources/AppComponents/Cells/GalleryRankingCell.swift | 1 - AppPackage/Sources/AppComponents/NewDawnView.swift | 1 - AppPackage/Sources/AppComponents/Placeholder.swift | 1 - AppPackage/Sources/AppComponents/PreviewImageView.swift | 1 - AppPackage/Sources/AppComponents/SettingTextField.swift | 1 - AppPackage/Sources/AppComponents/TagCloudView.swift | 1 - .../Sources/{DesignSystem => AppComponents}/ViewModifiers.swift | 0 .../Sources/DetailFeature/Components/PostCommentView.swift | 2 +- AppPackage/Sources/DetailFeature/Components/TagDetailView.swift | 1 - .../Sources/DetailFeature/DetailSearch/DetailSearchView.swift | 1 - AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift | 2 +- AppPackage/Sources/DetailFeature/DetailView+Subviews.swift | 1 - AppPackage/Sources/DetailFeature/DetailView.swift | 1 - .../Sources/DetailFeature/FolderManager/FolderManagerView.swift | 1 - AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift | 1 - AppPackage/Sources/DownloadsFeature/DownloadsView.swift | 1 - AppPackage/Sources/FavoritesFeature/FavoritesView.swift | 1 - AppPackage/Sources/FiltersFeature/FiltersView.swift | 1 - AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift | 1 - AppPackage/Sources/HomeFeature/History/HistoryView.swift | 1 - AppPackage/Sources/HomeFeature/HomeView+Sections.swift | 1 - AppPackage/Sources/HomeFeature/HomeView.swift | 1 - AppPackage/Sources/HomeFeature/Popular/PopularView.swift | 1 - AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift | 1 - AppPackage/Sources/HomeFeature/Watched/WatchedView.swift | 1 - AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift | 1 - AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift | 1 - AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift | 1 - AppPackage/Sources/SearchFeature/SearchRootView.swift | 1 - AppPackage/Sources/SearchFeature/SearchView.swift | 1 - .../SettingFeature/AccountSetting/AccountSettingView.swift | 1 + .../AppearanceSetting/AppearanceSettingView.swift | 2 +- AppPackage/Sources/SettingFeature/Components/AboutView.swift | 2 +- .../SettingFeature/Components/LaboratorySettingView.swift | 2 +- AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift | 1 - .../SettingFeature/GeneralSetting/GeneralSettingView.swift | 2 +- AppPackage/Sources/SettingFeature/Login/LoginView.swift | 1 - AppPackage/Sources/SettingFeature/SettingView.swift | 1 - 44 files changed, 8 insertions(+), 41 deletions(-) rename AppPackage/Sources/{DesignSystem => AppComponents}/ViewModifiers.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 192c6b3e6..118a694ce 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -516,6 +516,7 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.designSystem), .module(.foundationExt), + .module(.parserFeature), .module(.resources), .module(.utilities), .targetDependency(.alertKit), diff --git a/AppPackage/Sources/AppComponents/AlertKit_Extension.swift b/AppPackage/Sources/AppComponents/AlertKit_Extension.swift index 38d075494..89853b9f2 100644 --- a/AppPackage/Sources/AppComponents/AlertKit_Extension.swift +++ b/AppPackage/Sources/AppComponents/AlertKit_Extension.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import Resources import AlertKit -import DesignSystem extension View { public func jumpPageAlert( diff --git a/AppPackage/Sources/AppComponents/AlertView.swift b/AppPackage/Sources/AppComponents/AlertView.swift index 3080d5d32..a8627c202 100644 --- a/AppPackage/Sources/AppComponents/AlertView.swift +++ b/AppPackage/Sources/AppComponents/AlertView.swift @@ -3,7 +3,6 @@ import AppModels import Resources import SFSafeSymbols import Utilities -import DesignSystem public struct LoadingView: View { private let title: String diff --git a/AppPackage/Sources/AppComponents/CategoryView.swift b/AppPackage/Sources/AppComponents/CategoryView.swift index 5ed2c2adf..e2d75d1a1 100644 --- a/AppPackage/Sources/AppComponents/CategoryView.swift +++ b/AppPackage/Sources/AppComponents/CategoryView.swift @@ -1,7 +1,6 @@ import SwiftUI import AppModels import Utilities -import DesignSystem // MARK: CategoryLabel public struct CategoryLabel: View { diff --git a/AppPackage/Sources/AppComponents/Cells/GalleryCardCell.swift b/AppPackage/Sources/AppComponents/Cells/GalleryCardCell.swift index c9d5df266..a4c77a85f 100644 --- a/AppPackage/Sources/AppComponents/Cells/GalleryCardCell.swift +++ b/AppPackage/Sources/AppComponents/Cells/GalleryCardCell.swift @@ -4,7 +4,6 @@ import Colorful import Kingfisher import UIImageColors import Utilities -import DesignSystem public struct GalleryCardCell: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppComponents/Cells/GalleryHistoryCell.swift b/AppPackage/Sources/AppComponents/Cells/GalleryHistoryCell.swift index 07b6b1a52..a6042235c 100644 --- a/AppPackage/Sources/AppComponents/Cells/GalleryHistoryCell.swift +++ b/AppPackage/Sources/AppComponents/Cells/GalleryHistoryCell.swift @@ -1,7 +1,6 @@ import SwiftUI import AppModels import Kingfisher -import DesignSystem public struct GalleryHistoryCell: View { private let gallery: Gallery diff --git a/AppPackage/Sources/AppComponents/Cells/GalleryRankingCell.swift b/AppPackage/Sources/AppComponents/Cells/GalleryRankingCell.swift index c53841ec0..e9f726749 100644 --- a/AppPackage/Sources/AppComponents/Cells/GalleryRankingCell.swift +++ b/AppPackage/Sources/AppComponents/Cells/GalleryRankingCell.swift @@ -1,7 +1,6 @@ import SwiftUI import AppModels import Kingfisher -import DesignSystem public struct GalleryRankingCell: View { private let gallery: Gallery diff --git a/AppPackage/Sources/AppComponents/NewDawnView.swift b/AppPackage/Sources/AppComponents/NewDawnView.swift index e85f4bc32..4cc20f93c 100644 --- a/AppPackage/Sources/AppComponents/NewDawnView.swift +++ b/AppPackage/Sources/AppComponents/NewDawnView.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import Resources import Utilities -import DesignSystem public struct NewDawnView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppComponents/Placeholder.swift b/AppPackage/Sources/AppComponents/Placeholder.swift index f801d8a75..47e0e4fbe 100644 --- a/AppPackage/Sources/AppComponents/Placeholder.swift +++ b/AppPackage/Sources/AppComponents/Placeholder.swift @@ -1,7 +1,6 @@ import SwiftUI import AppModels import Utilities -import DesignSystem public struct Placeholder: View { @Environment(\.inSheet) private var inSheet diff --git a/AppPackage/Sources/AppComponents/PreviewImageView.swift b/AppPackage/Sources/AppComponents/PreviewImageView.swift index b328a32cb..b04ae3156 100644 --- a/AppPackage/Sources/AppComponents/PreviewImageView.swift +++ b/AppPackage/Sources/AppComponents/PreviewImageView.swift @@ -3,7 +3,6 @@ import AppModels import ImageIO import Kingfisher import FoundationExt -import DesignSystem public struct PreviewImageView: View { private let originalURL: URL? diff --git a/AppPackage/Sources/AppComponents/SettingTextField.swift b/AppPackage/Sources/AppComponents/SettingTextField.swift index 4eb77dca3..766985413 100644 --- a/AppPackage/Sources/AppComponents/SettingTextField.swift +++ b/AppPackage/Sources/AppComponents/SettingTextField.swift @@ -1,5 +1,4 @@ import SwiftUI -import DesignSystem public struct SettingTextField: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppComponents/TagCloudView.swift b/AppPackage/Sources/AppComponents/TagCloudView.swift index 2569a2f73..a3a8fa1a4 100644 --- a/AppPackage/Sources/AppComponents/TagCloudView.swift +++ b/AppPackage/Sources/AppComponents/TagCloudView.swift @@ -5,7 +5,6 @@ import SwiftUI import SFSafeSymbols import Kingfisher import FoundationExt -import DesignSystem public struct TagCloudView: View where TagCell: View, Element: Equatable & Identifiable, ID == Element.ID { diff --git a/AppPackage/Sources/DesignSystem/ViewModifiers.swift b/AppPackage/Sources/AppComponents/ViewModifiers.swift similarity index 100% rename from AppPackage/Sources/DesignSystem/ViewModifiers.swift rename to AppPackage/Sources/AppComponents/ViewModifiers.swift diff --git a/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift b/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift index 643872e89..338afef65 100644 --- a/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift +++ b/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift @@ -1,5 +1,5 @@ import SwiftUI -import DesignSystem +import AppComponents struct PostCommentView: View { private let title: String diff --git a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift index cb0c09437..5b605a8e9 100644 --- a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift +++ b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift @@ -3,7 +3,6 @@ import AppModels import Resources import Kingfisher import FoundationExt -import DesignSystem import AppComponents struct TagDetailView: View { diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift index 9a3bfaf93..a12e9a87c 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift @@ -3,7 +3,6 @@ import AppModels import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem import AppComponents import FiltersFeature import QuickSearchFeature diff --git a/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift b/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift index adbaf93e6..285fd4916 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import Resources -import DesignSystem +import AppComponents extension DetailView { struct CommentCell: View { diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index eeafa7f19..9bf7e98b1 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -4,7 +4,6 @@ import Resources import Kingfisher import FoundationExt import Utilities -import DesignSystem import AppComponents // MARK: DescriptionSection diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 2ac9206c1..b25932778 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -5,7 +5,6 @@ import Kingfisher import ComposableArchitecture import CommonMark import Utilities -import DesignSystem import AppComponents import ReadingFeature diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift index 48b63a9c4..79b96924b 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift @@ -3,7 +3,6 @@ import Resources import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt -import DesignSystem import AppComponents public struct FolderManagerView: View { diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift index 30c72dbd9..60da805d9 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift @@ -3,7 +3,6 @@ import AppModels import Resources import ComposableArchitecture import Utilities -import DesignSystem import AppComponents import ReadingFeature diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index 65d4077a8..464029e31 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -5,7 +5,6 @@ import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem import AppComponents import ReadingFeature import DetailFeature diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index db3b1da04..1011e6a0e 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -5,7 +5,6 @@ import AlertKit import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem import AppComponents import QuickSearchFeature import DetailFeature diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index ad3d8ff6d..89fb92683 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -3,7 +3,6 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt -import DesignSystem import AppComponents public struct FiltersView: View { diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 46a29b73d..5fb1d85c2 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -5,7 +5,6 @@ import AlertKit import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem import AppComponents import FiltersFeature import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index dba12c0ef..369020f64 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -4,7 +4,6 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem import AppComponents import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift index ea091eb6f..aaafcc238 100644 --- a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift +++ b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift @@ -5,7 +5,6 @@ import Kingfisher import SwiftUIPager import SFSafeSymbols import Utilities -import DesignSystem import AppComponents // MARK: CardSlideSection diff --git a/AppPackage/Sources/HomeFeature/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift index c076069f0..5ba2277ca 100644 --- a/AppPackage/Sources/HomeFeature/HomeView.swift +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -6,7 +6,6 @@ import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem import AppComponents import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index 83dbbe96f..42c03a9ce 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -4,7 +4,6 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem import AppComponents import FiltersFeature import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index 8f25f6d2f..e1e86a96c 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -4,7 +4,6 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem import AppComponents import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index bc0c40906..a7554b1bf 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -4,7 +4,6 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem import AppComponents import FiltersFeature import QuickSearchFeature diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index 8dff63793..ec2add10e 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -4,7 +4,6 @@ import Resources import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt -import DesignSystem import AppComponents public struct QuickSearchView: View { diff --git a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift index 3c89d109d..f87a3c190 100644 --- a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift +++ b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import Resources import Utilities -import DesignSystem import AppComponents import SFSafeSymbols diff --git a/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift b/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift index 0d3fe4528..bff25541d 100644 --- a/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift +++ b/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift @@ -1,6 +1,5 @@ import SwiftUI import AppModels -import DesignSystem struct LiveTextView: View { private let liveTextGroups: [LiveTextGroup] diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index 9da7e3c26..f3dc07063 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -5,7 +5,6 @@ import ComposableArchitecture import FoundationExt import SwiftUINavigationExt import Utilities -import DesignSystem import AppComponents import FiltersFeature import QuickSearchFeature diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift index 08b6f632c..aa21afcff 100644 --- a/AppPackage/Sources/SearchFeature/SearchView.swift +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -3,7 +3,6 @@ import AppModels import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem import AppComponents import FiltersFeature import QuickSearchFeature diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index a2f71a413..ec09f95e8 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import AppComponents import Resources import ComposableArchitecture import SwiftUINavigationExt diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift index 4003298ec..4894ae762 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt -import DesignSystem +import AppComponents struct AppearanceSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index 9778c3cba..427622245 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -1,7 +1,7 @@ import SwiftUI import Resources import Utilities -import DesignSystem +import AppComponents struct AboutView: View { private var version: String { diff --git a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift index 188682a53..296fdcda6 100644 --- a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift @@ -1,7 +1,7 @@ import SwiftUI import Resources import SFSafeSymbols -import DesignSystem +import AppComponents struct LaboratorySettingView: View { @Binding private var bypassesSNIFiltering: Bool diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift index af427473e..7c6ae6f57 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift @@ -4,7 +4,6 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem import AppComponents struct EhSettingView: View { diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index 274e5010b..cea3f0ebb 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -4,7 +4,7 @@ import Resources import FilePicker import ComposableArchitecture import SwiftUINavigationExt -import DesignSystem +import AppComponents struct GeneralSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/SettingFeature/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift index acde0cbda..987ecbc6a 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginView.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginView.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import DesignSystem import AppComponents struct LoginView: View { diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 534cf349d..997528fac 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -3,7 +3,6 @@ import Resources import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt -import DesignSystem import AppComponents public struct SettingView: View { From 3e28bd02287b1a5192070a46aeef9cc49c1f4029 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 14:06:55 +0800 Subject: [PATCH 346/614] Extract TTProgressHUDExt module from DesignSystem --- AppPackage/Package.swift | 17 +++++++++++++++++ .../AppFeature/DataFlow/AppRouteReducer.swift | 2 +- .../AppFeature/View/TabBar/TabBarView.swift | 2 +- .../Archives/ArchivesReducer.swift | 2 +- .../DetailFeature/Archives/ArchivesView.swift | 2 +- .../Comments/CommentsReducer.swift | 2 +- .../DetailFeature/Comments/CommentsView.swift | 2 +- .../GalleryInfos/GalleryInfosReducer.swift | 2 +- .../GalleryInfos/GalleryInfosView.swift | 2 +- .../Torrents/TorrentsReducer.swift | 2 +- .../DetailFeature/Torrents/TorrentsView.swift | 2 +- .../DownloadInspectorReducer.swift | 2 +- .../DownloadsView+Subviews.swift | 2 +- .../Sources/ReadingFeature/ReadingReducer.swift | 2 +- .../Sources/ReadingFeature/ReadingView.swift | 2 +- .../AccountSetting/AccountSettingReducer.swift | 2 +- .../AccountSetting/AccountSettingView.swift | 2 +- .../Sources/TTProgressHUDExt/.swiftlint.yml | 1 + .../TTProgressHUD+.swift} | 0 .../View+ProgressHUD.swift} | 0 20 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 AppPackage/Sources/TTProgressHUDExt/.swiftlint.yml rename AppPackage/Sources/{DesignSystem/TTProgressHUD_Extension.swift => TTProgressHUDExt/TTProgressHUD+.swift} (100%) rename AppPackage/Sources/{DesignSystem/SwiftUINavigation_Extension.swift => TTProgressHUDExt/View+ProgressHUD.swift} (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 118a694ce..74d6f4c09 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -103,6 +103,7 @@ enum Module: String { case searchFeature = "SearchFeature" case settingFeature = "SettingFeature" case swiftUINavigationExt = "SwiftUINavigationExt" + case ttProgressHUDExt = "TTProgressHUDExt" case uiApplicationClient = "UIApplicationClient" case urlClient = "URLClient" case userDefaultsClient = "UserDefaultsClient" @@ -279,6 +280,7 @@ let targets: [PackageDescription.Target] = [ .module(.animatedImageFeature), .module(.settingFeature), .module(.swiftUINavigationExt), + .module(.ttProgressHUDExt), .module(.uiApplicationClient), .module(.urlClient), .module(.userDefaultsClient), @@ -383,6 +385,17 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .ttProgressHUDExt, + dependencies: [ + .module(.resources), + .module(.swiftUINavigationExt), + .targetDependency(.swiftUINavigation), + .targetDependency(.ttProgressHUD) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .utilities, dependencies: [ @@ -602,6 +615,7 @@ let targets: [PackageDescription.Target] = [ .module(.readingFeature), .module(.resources), .module(.swiftUINavigationExt), + .module(.ttProgressHUDExt), .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.sfSafeSymbols) @@ -653,6 +667,7 @@ let targets: [PackageDescription.Target] = [ .module(.networkingFeature), .module(.resources), .module(.swiftUINavigationExt), + .module(.ttProgressHUDExt), .module(.uiApplicationClient), .module(.userDefaultsClient), .module(.utilities), @@ -738,6 +753,7 @@ let targets: [PackageDescription.Target] = [ .module(.readingFeature), .module(.resources), .module(.swiftUINavigationExt), + .module(.ttProgressHUDExt), .module(.uiApplicationClient), .module(.urlClient), .module(.utilities), @@ -768,6 +784,7 @@ let targets: [PackageDescription.Target] = [ .module(.resources), .module(.animatedImageFeature), .module(.swiftUINavigationExt), + .module(.ttProgressHUDExt), .module(.urlClient), .module(.utilities), .targetDependency(.composableArchitecture), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index a45cfa118..789ee88f6 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -8,7 +8,7 @@ import HapticsClient import DatabaseClient import NetworkingFeature import ClipboardClient -import DesignSystem +import TTProgressHUDExt import DetailFeature import ComposableArchitectureExt diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index e9504f4a8..75a591686 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -4,7 +4,7 @@ import Resources import SFSafeSymbols import ComposableArchitecture import Utilities -import DesignSystem +import TTProgressHUDExt import AppComponents import DetailFeature import HomeFeature diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index f34d399b3..ff1f0fb29 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -7,7 +7,7 @@ import HapticsClient import DatabaseClient import NetworkingFeature import CookieClient -import DesignSystem +import TTProgressHUDExt @Reducer public struct ArchivesReducer: Sendable { diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift index a39d87eb2..80c4634a9 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import Utilities -import DesignSystem +import TTProgressHUDExt import AppComponents struct ArchivesView: View { diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index 441ed8841..00f289f00 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -8,7 +8,7 @@ import HapticsClient import DatabaseClient import NetworkingFeature import CookieClient -import DesignSystem +import TTProgressHUDExt import ComposableArchitectureExt @Reducer diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index 6c28e5072..dac238ceb 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -5,7 +5,7 @@ import Kingfisher import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem +import TTProgressHUDExt import AppComponents struct CommentsView: View { diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift index 6482851ff..9d7e7dd8b 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift @@ -1,7 +1,7 @@ import ComposableArchitecture import HapticsClient import ClipboardClient -import DesignSystem +import TTProgressHUDExt @Reducer public struct GalleryInfosReducer: Sendable { diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index cca3098ac..b41e084ed 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import Utilities -import DesignSystem +import TTProgressHUDExt struct GalleryInfosView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift index 3fd4ee51d..716ed262a 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift @@ -6,7 +6,7 @@ import HapticsClient import NetworkingFeature import ClipboardClient import FileClient -import DesignSystem +import TTProgressHUDExt @Reducer public struct TorrentsReducer: Sendable { diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift index 8a4318775..8d1b6c19d 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import DesignSystem +import TTProgressHUDExt import AppComponents struct TorrentsView: View { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift index 7550bfb7f..060bd021c 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import DownloadClient -import DesignSystem +import TTProgressHUDExt @Reducer public struct DownloadInspectorReducer: Sendable { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index 68ec1c132..ebc1e825b 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -3,7 +3,7 @@ import AppModels import Resources import SFSafeSymbols import ComposableArchitecture -import DesignSystem +import TTProgressHUDExt import AppComponents struct DownloadInspectorView: View { diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 5c11ab287..6c9a93823 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -11,7 +11,7 @@ import ClipboardClient import CookieClient import DeviceClient import AppDelegateClient -import DesignSystem +import TTProgressHUDExt @Reducer public struct ReadingReducer: Sendable { diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index d88dae587..627440f75 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -7,7 +7,7 @@ import ComposableArchitecture import FoundationExt import Utilities import AnimatedImageFeature -import DesignSystem +import TTProgressHUDExt import AppComponents public struct ReadingView: View { diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index f9a683be0..175484581 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -5,7 +5,7 @@ import SwiftUINavigationExt import HapticsClient import ClipboardClient import CookieClient -import DesignSystem +import TTProgressHUDExt @Reducer public struct AccountSettingReducer: Sendable { diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index ec09f95e8..12afc9c19 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -5,7 +5,7 @@ import Resources import ComposableArchitecture import SwiftUINavigationExt import Utilities -import DesignSystem +import TTProgressHUDExt struct AccountSettingView: View { @Bindable private var store: StoreOf diff --git a/AppPackage/Sources/TTProgressHUDExt/.swiftlint.yml b/AppPackage/Sources/TTProgressHUDExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/TTProgressHUDExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/DesignSystem/TTProgressHUD_Extension.swift b/AppPackage/Sources/TTProgressHUDExt/TTProgressHUD+.swift similarity index 100% rename from AppPackage/Sources/DesignSystem/TTProgressHUD_Extension.swift rename to AppPackage/Sources/TTProgressHUDExt/TTProgressHUD+.swift diff --git a/AppPackage/Sources/DesignSystem/SwiftUINavigation_Extension.swift b/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift similarity index 100% rename from AppPackage/Sources/DesignSystem/SwiftUINavigation_Extension.swift rename to AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift From 494d19b4920a9e10985d23ce146b05eb84503d08 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 14:10:55 +0800 Subject: [PATCH 347/614] Extract AlertKitExt module from AppComponents --- AppPackage/Package.swift | 14 +++++++++++++- AppPackage/Sources/AlertKitExt/.swiftlint.yml | 1 + .../AlertKit+.swift} | 1 + .../HomeFeature/Toplists/ToplistsView.swift | 1 + 4 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 AppPackage/Sources/AlertKitExt/.swiftlint.yml rename AppPackage/Sources/{AppComponents/AlertKit_Extension.swift => AlertKitExt/AlertKit+.swift} (99%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 74d6f4c09..5ed7b56f4 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -66,6 +66,7 @@ let sharedSwiftSettings: [PackageDescription.SwiftSetting] = [ // MARK: Module enum Module: String { + case alertKitExt = "AlertKitExt" case animatedImageFeature = "AnimatedImageFeature" case appComponents = "AppComponents" case appDelegateClient = "AppDelegateClient" @@ -532,7 +533,6 @@ let targets: [PackageDescription.Target] = [ .module(.parserFeature), .module(.resources), .module(.utilities), - .targetDependency(.alertKit), .targetDependency(.colorful), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols), @@ -550,6 +550,17 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .alertKitExt, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.resources), + .targetDependency(.alertKit) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .migrationFeature, dependencies: [ @@ -706,6 +717,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .homeFeature, dependencies: [ + .module(.alertKitExt), .module(.appComponents), .module(.appModels), .module(.composableArchitectureExt), diff --git a/AppPackage/Sources/AlertKitExt/.swiftlint.yml b/AppPackage/Sources/AlertKitExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AlertKitExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppComponents/AlertKit_Extension.swift b/AppPackage/Sources/AlertKitExt/AlertKit+.swift similarity index 99% rename from AppPackage/Sources/AppComponents/AlertKit_Extension.swift rename to AppPackage/Sources/AlertKitExt/AlertKit+.swift index 89853b9f2..a862e3994 100644 --- a/AppPackage/Sources/AppComponents/AlertKit_Extension.swift +++ b/AppPackage/Sources/AlertKitExt/AlertKit+.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import AppComponents import Resources import AlertKit diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index e1e86a96c..a153ccef5 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import AlertKitExt import DetailFeature struct ToplistsView: View { From cdf68d3aa8591eab002b008419636b80acdfdff6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 14:19:00 +0800 Subject: [PATCH 348/614] Dissolve DesignSystem module Remove CategoryColor (call sites pass gallery host explicitly), delete the now-empty DesignSystem target, and rename SwiftUINavigation_Extension.swift to SwiftUINavigation+.swift. --- AppPackage/Package.swift | 29 ------------------- .../Sources/AppComponents/CategoryView.swift | 3 +- .../Cells/GalleryDetailCell.swift | 4 +-- .../Cells/GalleryThumbnailCell.swift | 4 +-- .../Sources/DesignSystem/.swiftlint.yml | 1 - .../Sources/DesignSystem/CategoryColor.swift | 18 ------------ .../DetailView+HeaderSection.swift | 3 +- .../EhSetting/EhSettingView+Sections2.swift | 3 +- ...tension.swift => SwiftUINavigation+.swift} | 0 9 files changed, 9 insertions(+), 56 deletions(-) delete mode 100644 AppPackage/Sources/DesignSystem/.swiftlint.yml delete mode 100644 AppPackage/Sources/DesignSystem/CategoryColor.swift rename AppPackage/Sources/SwiftUINavigationExt/{SwiftUINavigation_Extension.swift => SwiftUINavigation+.swift} (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 5ed7b56f4..529f227c0 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -81,7 +81,6 @@ enum Module: String { case dfClient = "DFClient" case databaseClient = "DatabaseClient" case dateSeekFeature = "DateSeekFeature" - case designSystem = "DesignSystem" case detailFeature = "DetailFeature" case deviceClient = "DeviceClient" case downloadClient = "DownloadClient" @@ -256,7 +255,6 @@ let targets: [PackageDescription.Target] = [ .module(.cookieClient), .module(.databaseClient), .module(.dateSeekFeature), - .module(.designSystem), .module(.detailFeature), .module(.dfClient), .module(.deviceClient), @@ -507,28 +505,10 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), - .target( - module: .designSystem, - dependencies: [ - .module(.appModels), - .module(.foundationExt), - .module(.parserFeature), - .module(.resources), - .module(.swiftUINavigationExt), - .module(.utilities), - .targetDependency(.kingfisher), - .targetDependency(.sfSafeSymbols), - .targetDependency(.swiftUINavigation), - .targetDependency(.ttProgressHUD) - ], - swiftSettings: sharedSwiftSettings, - plugins: swiftLintPlugins - ), .target( module: .appComponents, dependencies: [ .module(.appModels), - .module(.designSystem), .module(.foundationExt), .module(.parserFeature), .module(.resources), @@ -580,7 +560,6 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appModels), .module(.databaseClient), - .module(.designSystem), .module(.resources), .module(.swiftUINavigationExt), .targetDependency(.composableArchitecture) @@ -604,7 +583,6 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appModels), .module(.databaseClient), - .module(.designSystem), .module(.resources), .module(.swiftUINavigationExt), .targetDependency(.composableArchitecture), @@ -619,7 +597,6 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appModels), .module(.composableArchitectureExt), - .module(.designSystem), .module(.detailFeature), .module(.downloadClient), .module(.foundationExt), @@ -642,7 +619,6 @@ let targets: [PackageDescription.Target] = [ .module(.composableArchitectureExt), .module(.databaseClient), .module(.dateSeekFeature), - .module(.designSystem), .module(.detailFeature), .module(.downloadClient), .module(.hapticsClient), @@ -667,7 +643,6 @@ let targets: [PackageDescription.Target] = [ .module(.clipboardClient), .module(.cookieClient), .module(.databaseClient), - .module(.designSystem), .module(.deviceClient), .module(.dfClient), .module(.fileClient), @@ -697,7 +672,6 @@ let targets: [PackageDescription.Target] = [ .module(.composableArchitectureExt), .module(.databaseClient), .module(.dateSeekFeature), - .module(.designSystem), .module(.detailFeature), .module(.downloadClient), .module(.filtersFeature), @@ -723,7 +697,6 @@ let targets: [PackageDescription.Target] = [ .module(.composableArchitectureExt), .module(.databaseClient), .module(.dateSeekFeature), - .module(.designSystem), .module(.detailFeature), .module(.downloadClient), .module(.filtersFeature), @@ -754,7 +727,6 @@ let targets: [PackageDescription.Target] = [ .module(.composableArchitectureExt), .module(.cookieClient), .module(.databaseClient), - .module(.designSystem), .module(.downloadClient), .module(.fileClient), .module(.filtersFeature), @@ -786,7 +758,6 @@ let targets: [PackageDescription.Target] = [ .module(.clipboardClient), .module(.cookieClient), .module(.databaseClient), - .module(.designSystem), .module(.deviceClient), .module(.downloadClient), .module(.foundationExt), diff --git a/AppPackage/Sources/AppComponents/CategoryView.swift b/AppPackage/Sources/AppComponents/CategoryView.swift index e2d75d1a1..4251e4302 100644 --- a/AppPackage/Sources/AppComponents/CategoryView.swift +++ b/AppPackage/Sources/AppComponents/CategoryView.swift @@ -71,9 +71,10 @@ private struct CategoryCell: View { } var body: some View { + let color = category.color(host: AppUtil.galleryHost) ZStack { Rectangle() - .foregroundColor(isFiltered ? category.color.opacity(0.3) : category.color) + .foregroundColor(isFiltered ? color.opacity(0.3) : color) Text(category.value).bold().foregroundStyle(.white) .padding(.vertical, 5).lineLimit(1) } diff --git a/AppPackage/Sources/AppComponents/Cells/GalleryDetailCell.swift b/AppPackage/Sources/AppComponents/Cells/GalleryDetailCell.swift index fe50bd5cb..2c5376d4f 100644 --- a/AppPackage/Sources/AppComponents/Cells/GalleryDetailCell.swift +++ b/AppPackage/Sources/AppComponents/Cells/GalleryDetailCell.swift @@ -2,7 +2,7 @@ import SwiftUI import SFSafeSymbols import AppModels import Kingfisher -import DesignSystem +import Utilities public struct GalleryDetailCell: View { public enum CoverSource { @@ -133,7 +133,7 @@ private struct GalleryDetailCellContent: View { } } HStack(alignment: .bottom) { - CategoryLabel(text: gallery.category.value, color: gallery.color) + CategoryLabel(text: gallery.category.value, color: gallery.color(host: AppUtil.galleryHost)) Spacer() Text(gallery.formattedDateString).lineLimit(1).font(.footnote) .foregroundStyle(.secondary).minimumScaleFactor(0.75) diff --git a/AppPackage/Sources/AppComponents/Cells/GalleryThumbnailCell.swift b/AppPackage/Sources/AppComponents/Cells/GalleryThumbnailCell.swift index bd14f4d37..899c40699 100644 --- a/AppPackage/Sources/AppComponents/Cells/GalleryThumbnailCell.swift +++ b/AppPackage/Sources/AppComponents/Cells/GalleryThumbnailCell.swift @@ -2,7 +2,7 @@ import SwiftUI import SFSafeSymbols import AppModels import Kingfisher -import DesignSystem +import Utilities public struct GalleryThumbnailCell: View { @Environment(\.colorScheme) private var colorScheme @@ -44,7 +44,7 @@ public struct GalleryThumbnailCell: View { .scaledToFit() .overlay { CategoryLabel( - text: gallery.category.value, color: gallery.color, + text: gallery.category.value, color: gallery.color(host: AppUtil.galleryHost), insets: .init(top: 3, leading: 6, bottom: 3, trailing: 6), cornerRadius: 15, corners: .bottomLeft ) diff --git a/AppPackage/Sources/DesignSystem/.swiftlint.yml b/AppPackage/Sources/DesignSystem/.swiftlint.yml deleted file mode 100644 index 1242ffcaa..000000000 --- a/AppPackage/Sources/DesignSystem/.swiftlint.yml +++ /dev/null @@ -1 +0,0 @@ -parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/DesignSystem/CategoryColor.swift b/AppPackage/Sources/DesignSystem/CategoryColor.swift deleted file mode 100644 index ab011679a..000000000 --- a/AppPackage/Sources/DesignSystem/CategoryColor.swift +++ /dev/null @@ -1,18 +0,0 @@ -import SwiftUI -import AppModels -import Utilities - -// Binds the pure, host-parameterized color on the model types to the host the user is -// currently browsing. The runtime lookup (UserDefaults via AppUtil) lives here in the app -// layer so the model types stay free of that dependency. -extension AppModels.Category { - public var color: Color { - color(host: AppUtil.galleryHost) - } -} - -extension Gallery { - public var color: Color { - category.color - } -} diff --git a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift index 061d660b6..71b0eb3a4 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift @@ -4,7 +4,6 @@ import Resources import Kingfisher import SFSafeSymbols import Utilities -import DesignSystem import AppComponents // MARK: HeaderSection @@ -56,7 +55,7 @@ struct HeaderSection: View { } private var categoryLabel: some View { CategoryLabel( - text: gallery.category.value, color: gallery.color, font: .headline, + text: gallery.category.value, color: gallery.color(host: AppUtil.galleryHost), font: .headline, insets: .init(top: 2, leading: 4, bottom: 2, trailing: 4), cornerRadius: 3 ) .lineLimit(1) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift index 6c1eddd2b..fd00619ed 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Resources +import Utilities import AppComponents extension EhSettingView { @@ -40,7 +41,7 @@ struct FavoritesSection: View { ForEach(tuples, id: \.0) { category, nameBinding in HStack(spacing: 30) { Circle() - .foregroundColor(category.color) + .foregroundColor(category.color(host: AppUtil.galleryHost)) .frame(width: 10) SettingTextField(text: nameBinding, width: nil, alignment: .leading, background: .clear) diff --git a/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation_Extension.swift b/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift similarity index 100% rename from AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation_Extension.swift rename to AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift From 88deb75657563d8195172fdd41c658296f014403 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 14:23:07 +0800 Subject: [PATCH 349/614] Extract GalleryListComponents module from AppComponents --- AppPackage/Package.swift | 21 ++++++++++++++++++- .../DetailSearch/DetailSearchView.swift | 1 + .../DownloadsView+Subviews.swift | 1 + .../FavoritesFeature/FavoritesView.swift | 1 + .../GalleryListComponents/.swiftlint.yml | 1 + .../Cells/GalleryDetailCell.swift | 1 + .../Cells/GalleryThumbnailCell.swift | 1 + .../DownloadBadgeLabel.swift | 0 .../GenericList.swift | 1 + .../HomeFeature/Frontpage/FrontpageView.swift | 1 + .../HomeFeature/History/HistoryView.swift | 1 + .../HomeFeature/Popular/PopularView.swift | 1 + .../HomeFeature/Toplists/ToplistsView.swift | 1 + .../HomeFeature/Watched/WatchedView.swift | 1 + .../Sources/SearchFeature/SearchView.swift | 1 + 15 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 AppPackage/Sources/GalleryListComponents/.swiftlint.yml rename AppPackage/Sources/{AppComponents => GalleryListComponents}/Cells/GalleryDetailCell.swift (99%) rename AppPackage/Sources/{AppComponents => GalleryListComponents}/Cells/GalleryThumbnailCell.swift (99%) rename AppPackage/Sources/{AppComponents => GalleryListComponents}/DownloadBadgeLabel.swift (100%) rename AppPackage/Sources/{AppComponents => GalleryListComponents}/GenericList.swift (99%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 529f227c0..a51c0494e 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -89,6 +89,7 @@ enum Module: String { case fileClient = "FileClient" case filtersFeature = "FiltersFeature" case foundationExt = "FoundationExt" + case galleryListComponents = "GalleryListComponents" case hapticsClient = "HapticsClient" case homeFeature = "HomeFeature" case imageClient = "ImageClient" @@ -516,7 +517,20 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.colorful), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols), - .targetDependency(.uiImageColors), + .targetDependency(.uiImageColors) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + .target( + module: .galleryListComponents, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.resources), + .module(.utilities), + .targetDependency(.kingfisher), + .targetDependency(.sfSafeSymbols), .targetDependency(.waterfallGrid) ], swiftSettings: sharedSwiftSettings, @@ -600,6 +614,7 @@ let targets: [PackageDescription.Target] = [ .module(.detailFeature), .module(.downloadClient), .module(.foundationExt), + .module(.galleryListComponents), .module(.readingFeature), .module(.resources), .module(.swiftUINavigationExt), @@ -621,6 +636,7 @@ let targets: [PackageDescription.Target] = [ .module(.dateSeekFeature), .module(.detailFeature), .module(.downloadClient), + .module(.galleryListComponents), .module(.hapticsClient), .module(.networkingFeature), .module(.quickSearchFeature), @@ -676,6 +692,7 @@ let targets: [PackageDescription.Target] = [ .module(.downloadClient), .module(.filtersFeature), .module(.foundationExt), + .module(.galleryListComponents), .module(.hapticsClient), .module(.networkingFeature), .module(.quickSearchFeature), @@ -701,6 +718,7 @@ let targets: [PackageDescription.Target] = [ .module(.downloadClient), .module(.filtersFeature), .module(.foundationExt), + .module(.galleryListComponents), .module(.hapticsClient), .module(.libraryClient), .module(.networkingFeature), @@ -731,6 +749,7 @@ let targets: [PackageDescription.Target] = [ .module(.fileClient), .module(.filtersFeature), .module(.foundationExt), + .module(.galleryListComponents), .module(.hapticsClient), .module(.networkingFeature), .module(.quickSearchFeature), diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift index a12e9a87c..d007b30e6 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import GalleryListComponents import FiltersFeature import QuickSearchFeature diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index ebc1e825b..ba000fd83 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -5,6 +5,7 @@ import SFSafeSymbols import ComposableArchitecture import TTProgressHUDExt import AppComponents +import GalleryListComponents struct DownloadInspectorView: View { @Environment(\.dismiss) private var dismiss diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index 1011e6a0e..9da370fe5 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -6,6 +6,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import GalleryListComponents import QuickSearchFeature import DetailFeature diff --git a/AppPackage/Sources/GalleryListComponents/.swiftlint.yml b/AppPackage/Sources/GalleryListComponents/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/GalleryListComponents/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppComponents/Cells/GalleryDetailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift similarity index 99% rename from AppPackage/Sources/AppComponents/Cells/GalleryDetailCell.swift rename to AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift index 2c5376d4f..0cdb3ee70 100644 --- a/AppPackage/Sources/AppComponents/Cells/GalleryDetailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift @@ -1,6 +1,7 @@ import SwiftUI import SFSafeSymbols import AppModels +import AppComponents import Kingfisher import Utilities diff --git a/AppPackage/Sources/AppComponents/Cells/GalleryThumbnailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift similarity index 99% rename from AppPackage/Sources/AppComponents/Cells/GalleryThumbnailCell.swift rename to AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift index 899c40699..56a902633 100644 --- a/AppPackage/Sources/AppComponents/Cells/GalleryThumbnailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift @@ -1,6 +1,7 @@ import SwiftUI import SFSafeSymbols import AppModels +import AppComponents import Kingfisher import Utilities diff --git a/AppPackage/Sources/AppComponents/DownloadBadgeLabel.swift b/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift similarity index 100% rename from AppPackage/Sources/AppComponents/DownloadBadgeLabel.swift rename to AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift diff --git a/AppPackage/Sources/AppComponents/GenericList.swift b/AppPackage/Sources/GalleryListComponents/GenericList.swift similarity index 99% rename from AppPackage/Sources/AppComponents/GenericList.swift rename to AppPackage/Sources/GalleryListComponents/GenericList.swift index 9f3767f89..f82f2812e 100644 --- a/AppPackage/Sources/AppComponents/GenericList.swift +++ b/AppPackage/Sources/GalleryListComponents/GenericList.swift @@ -1,6 +1,7 @@ import SwiftUI import SFSafeSymbols import AppModels +import AppComponents import WaterfallGrid import Utilities diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 5fb1d85c2..64b2b6a20 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -6,6 +6,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import GalleryListComponents import FiltersFeature import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index 369020f64..2da6e14aa 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import GalleryListComponents import DetailFeature struct HistoryView: View { diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index 42c03a9ce..21ec89359 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import GalleryListComponents import FiltersFeature import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index a153ccef5..8bc045997 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import GalleryListComponents import AlertKitExt import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index a7554b1bf..98154f154 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import GalleryListComponents import FiltersFeature import QuickSearchFeature import DetailFeature diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift index aa21afcff..9eadfc27f 100644 --- a/AppPackage/Sources/SearchFeature/SearchView.swift +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import GalleryListComponents import FiltersFeature import QuickSearchFeature import DetailFeature From 5b2ca1b037640a7de37f7c1db6d47be95531d185 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 14:26:52 +0800 Subject: [PATCH 350/614] Move single-consumer cells into their feature modules GalleryCardCell/GalleryRankingCell to HomeFeature, GalleryHistoryCell to SearchFeature, WaveForm to SettingFeature. --- AppPackage/Package.swift | 9 +++++---- .../Cells => HomeFeature}/GalleryCardCell.swift | 1 + .../Cells => HomeFeature}/GalleryRankingCell.swift | 1 + .../Cells => SearchFeature}/GalleryHistoryCell.swift | 1 + .../{AppComponents => SettingFeature}/WaveForm.swift | 0 5 files changed, 8 insertions(+), 4 deletions(-) rename AppPackage/Sources/{AppComponents/Cells => HomeFeature}/GalleryCardCell.swift (99%) rename AppPackage/Sources/{AppComponents/Cells => HomeFeature}/GalleryRankingCell.swift (98%) rename AppPackage/Sources/{AppComponents/Cells => SearchFeature}/GalleryHistoryCell.swift (98%) rename AppPackage/Sources/{AppComponents => SettingFeature}/WaveForm.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index a51c0494e..23d52686b 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -514,10 +514,8 @@ let targets: [PackageDescription.Target] = [ .module(.parserFeature), .module(.resources), .module(.utilities), - .targetDependency(.colorful), .targetDependency(.kingfisher), - .targetDependency(.sfSafeSymbols), - .targetDependency(.uiImageColors) + .targetDependency(.sfSafeSymbols) ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins @@ -700,6 +698,7 @@ let targets: [PackageDescription.Target] = [ .module(.swiftUINavigationExt), .module(.utilities), .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols) ], swiftSettings: sharedSwiftSettings, @@ -727,10 +726,12 @@ let targets: [PackageDescription.Target] = [ .module(.swiftUINavigationExt), .module(.utilities), .targetDependency(.alertKit), + .targetDependency(.colorful), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols), - .targetDependency(.swiftUIPager) + .targetDependency(.swiftUIPager), + .targetDependency(.uiImageColors) ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins diff --git a/AppPackage/Sources/AppComponents/Cells/GalleryCardCell.swift b/AppPackage/Sources/HomeFeature/GalleryCardCell.swift similarity index 99% rename from AppPackage/Sources/AppComponents/Cells/GalleryCardCell.swift rename to AppPackage/Sources/HomeFeature/GalleryCardCell.swift index a4c77a85f..ccdac1b0f 100644 --- a/AppPackage/Sources/AppComponents/Cells/GalleryCardCell.swift +++ b/AppPackage/Sources/HomeFeature/GalleryCardCell.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import AppComponents import Colorful import Kingfisher import UIImageColors diff --git a/AppPackage/Sources/AppComponents/Cells/GalleryRankingCell.swift b/AppPackage/Sources/HomeFeature/GalleryRankingCell.swift similarity index 98% rename from AppPackage/Sources/AppComponents/Cells/GalleryRankingCell.swift rename to AppPackage/Sources/HomeFeature/GalleryRankingCell.swift index e9f726749..be72ee8b7 100644 --- a/AppPackage/Sources/AppComponents/Cells/GalleryRankingCell.swift +++ b/AppPackage/Sources/HomeFeature/GalleryRankingCell.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import AppComponents import Kingfisher public struct GalleryRankingCell: View { diff --git a/AppPackage/Sources/AppComponents/Cells/GalleryHistoryCell.swift b/AppPackage/Sources/SearchFeature/GalleryHistoryCell.swift similarity index 98% rename from AppPackage/Sources/AppComponents/Cells/GalleryHistoryCell.swift rename to AppPackage/Sources/SearchFeature/GalleryHistoryCell.swift index a6042235c..d398e0e59 100644 --- a/AppPackage/Sources/AppComponents/Cells/GalleryHistoryCell.swift +++ b/AppPackage/Sources/SearchFeature/GalleryHistoryCell.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import AppComponents import Kingfisher public struct GalleryHistoryCell: View { diff --git a/AppPackage/Sources/AppComponents/WaveForm.swift b/AppPackage/Sources/SettingFeature/WaveForm.swift similarity index 100% rename from AppPackage/Sources/AppComponents/WaveForm.swift rename to AppPackage/Sources/SettingFeature/WaveForm.swift From c3fdc87eefc45542575f1d1c86b86e7e9658e581 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 14:29:55 +0800 Subject: [PATCH 351/614] Extract ReadingSettingFeature module from AppComponents --- AppPackage/Package.swift | 13 +++++++++ .../Sources/ReadingFeature/ReadingView.swift | 1 + .../ReadingSettingFeature/.swiftlint.yml | 1 + .../ReadingSettingView.swift | 0 .../Sources/SettingFeature/SettingView.swift | 1 + .../build-request.json | 27 ++++++++++++++++++ .../description.msgpack | Bin 0 -> 246 bytes .../manifest.json | 1 + .../target-graph.txt | 1 + .../task-store.msgpack | Bin 0 -> 80 bytes .../build-request.json | 27 ++++++++++++++++++ .../description.msgpack | Bin 0 -> 246 bytes .../manifest.json | 1 + .../target-graph.txt | 1 + .../task-store.msgpack | Bin 0 -> 80 bytes 15 files changed, 74 insertions(+) create mode 100644 AppPackage/Sources/ReadingSettingFeature/.swiftlint.yml rename AppPackage/Sources/{AppComponents => ReadingSettingFeature}/ReadingSettingView.swift (100%) create mode 100644 build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/build-request.json create mode 100644 build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/description.msgpack create mode 100644 build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/manifest.json create mode 100644 build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/target-graph.txt create mode 100644 build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/task-store.msgpack create mode 100644 build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/build-request.json create mode 100644 build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/description.msgpack create mode 100644 build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/manifest.json create mode 100644 build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/target-graph.txt create mode 100644 build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/task-store.msgpack diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 23d52686b..82e98e102 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -100,6 +100,7 @@ enum Module: String { case parserFeature = "ParserFeature" case quickSearchFeature = "QuickSearchFeature" case readingFeature = "ReadingFeature" + case readingSettingFeature = "ReadingSettingFeature" case resources = "Resources" case searchFeature = "SearchFeature" case settingFeature = "SettingFeature" @@ -589,6 +590,16 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .readingSettingFeature, + dependencies: [ + .module(.appModels), + .module(.resources), + .module(.utilities) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .quickSearchFeature, dependencies: [ @@ -665,6 +676,7 @@ let targets: [PackageDescription.Target] = [ .module(.libraryClient), .module(.loggerClient), .module(.networkingFeature), + .module(.readingSettingFeature), .module(.resources), .module(.swiftUINavigationExt), .module(.ttProgressHUDExt), @@ -784,6 +796,7 @@ let targets: [PackageDescription.Target] = [ .module(.hapticsClient), .module(.imageClient), .module(.networkingFeature), + .module(.readingSettingFeature), .module(.resources), .module(.animatedImageFeature), .module(.swiftUINavigationExt), diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 627440f75..d8b8e0b88 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -9,6 +9,7 @@ import Utilities import AnimatedImageFeature import TTProgressHUDExt import AppComponents +import ReadingSettingFeature public struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/ReadingSettingFeature/.swiftlint.yml b/AppPackage/Sources/ReadingSettingFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/ReadingSettingFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppComponents/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift similarity index 100% rename from AppPackage/Sources/AppComponents/ReadingSettingView.swift rename to AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 997528fac..e4b164a2d 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -4,6 +4,7 @@ import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt import AppComponents +import ReadingSettingFeature public struct SettingView: View { @Bindable private var store: StoreOf diff --git a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/build-request.json b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/build-request.json new file mode 100644 index 000000000..38eab09c8 --- /dev/null +++ b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/build-request.json @@ -0,0 +1,27 @@ +{ + "buildCommand" : { + "command" : "build", + "skipDependencies" : false, + "style" : "buildOnly" + }, + "configuredTargets" : [ + + ], + "continueBuildingAfterErrors" : false, + "dependencyScope" : "workspace", + "enableIndexBuildArena" : false, + "hideShellScriptEnvironment" : false, + "parameters" : { + "action" : "build", + "overrides" : { + + } + }, + "qos" : "utility", + "schemeCommand" : "launch", + "showNonLoggedProgress" : true, + "useDryRun" : false, + "useImplicitDependencies" : false, + "useLegacyBuildLocations" : false, + "useParallelTargets" : true +} \ No newline at end of file diff --git a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/description.msgpack b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/description.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..78976160716b0a571fc7bd92471da94137b5f9c5 GIT binary patch literal 246 zcmW-bK~BRk6hyfM`(B{=PHH#b+lI13LPAJve{9E%aP2B~Qp74p2shv?rI)D0VMutJ zk!GZ)`MtZpt>HDAE<&Od`_BN6p?in@;hDT9sQcDwc&m0xK2W0TiVDnSiNrT0Bd!pY zFero)Tx=z6im&f+W*Tn}2I`;*PA~eC2hY!^Xn8KIm8Gif_KZVn8n$3PR1i9wIX4_L z<;Yuj3c7cuB6TpC53Sec171vwZSdLge;dfPn$ld1({-hJu2E@;v$8H2Hxe0>5{+t* N7p0lzSC^m5^ar-7SDF9- literal 0 HcmV?d00001 diff --git a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/manifest.json b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/manifest.json new file mode 100644 index 000000000..7391713b6 --- /dev/null +++ b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/manifest.json @@ -0,0 +1 @@ +{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":[""]},"commands":{"":{"tool":"phony","inputs":[""],"outputs":[""]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":[""]}}} \ No newline at end of file diff --git a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/target-graph.txt b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/target-graph.txt new file mode 100644 index 000000000..b83b1580f --- /dev/null +++ b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/target-graph.txt @@ -0,0 +1 @@ +Target dependency graph (0 target) \ No newline at end of file diff --git a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/task-store.msgpack b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/task-store.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..e599b6946d83b77c1ac76fc98ae226d544432d32 GIT binary patch literal 80 zcmbPuhe7fH5KLO)o>-E4Q!zZhD7&~IF*(&EH8CZ%$TzVd%q`e0Gbgn;yePAzBsFir fgb9--OjxLY_`rd~hbA1JFyX+V!$6tT(?!VNe}=_M+07!uxQ zq#5aHes6BCckr61OOP?A{xia3?B3yF|BS&PsJ`_E-kRN#_n7grBE6=K(ukHyu1Z-~ ztgdUV*jh=dnEv`6XQuJ$VZaV-^k&f?0|b6LCdbQ-b8@Sdt50Ic*0Kc`pn=%A!u#P+ zXph0cQ#8G&2KAnb5Zhqr170YlHiqKxzYXYHgLy8->9R7sG(sCGW~HhaCn*?{QjoTS NS6iCqSLdJe^aqhWSjhkY literal 0 HcmV?d00001 diff --git a/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/manifest.json b/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/manifest.json new file mode 100644 index 000000000..7391713b6 --- /dev/null +++ b/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/manifest.json @@ -0,0 +1 @@ +{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":[""]},"commands":{"":{"tool":"phony","inputs":[""],"outputs":[""]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":[""]}}} \ No newline at end of file diff --git a/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/target-graph.txt b/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/target-graph.txt new file mode 100644 index 000000000..b83b1580f --- /dev/null +++ b/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/target-graph.txt @@ -0,0 +1 @@ +Target dependency graph (0 target) \ No newline at end of file diff --git a/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/task-store.msgpack b/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/task-store.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..e599b6946d83b77c1ac76fc98ae226d544432d32 GIT binary patch literal 80 zcmbPuhe7fH5KLO)o>-E4Q!zZhD7&~IF*(&EH8CZ%$TzVd%q`e0Gbgn;yePAzBsFir fgb9--OjxLY_`rd~hbA1JFyX+V! Date: Sun, 28 Jun 2026 14:34:25 +0800 Subject: [PATCH 352/614] Move DateSeekPickerView into DateSeekFeature --- AppPackage/Package.swift | 4 +++- .../DateSeekPickerView.swift | 0 AppPackage/Sources/FavoritesFeature/FavoritesView.swift | 1 + AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift | 1 + AppPackage/Sources/HomeFeature/Watched/WatchedView.swift | 1 + AppPackage/Sources/SearchFeature/SearchView.swift | 1 + 6 files changed, 7 insertions(+), 1 deletion(-) rename AppPackage/Sources/{AppComponents => DateSeekFeature}/DateSeekPickerView.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 82e98e102..0ea138a6c 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -585,7 +585,9 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.hapticsClient), - .targetDependency(.composableArchitecture) + .module(.resources), + .targetDependency(.composableArchitecture), + .targetDependency(.sfSafeSymbols) ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins diff --git a/AppPackage/Sources/AppComponents/DateSeekPickerView.swift b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift similarity index 100% rename from AppPackage/Sources/AppComponents/DateSeekPickerView.swift rename to AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index 9da370fe5..db5f3a669 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -6,6 +6,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import DateSeekFeature import GalleryListComponents import QuickSearchFeature import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 64b2b6a20..74f788b5a 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -6,6 +6,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import DateSeekFeature import GalleryListComponents import FiltersFeature import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index 98154f154..446b4edc5 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -5,6 +5,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import DateSeekFeature import GalleryListComponents import FiltersFeature import QuickSearchFeature diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift index 9eadfc27f..1e1729644 100644 --- a/AppPackage/Sources/SearchFeature/SearchView.swift +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -4,6 +4,7 @@ import ComposableArchitecture import SwiftUINavigationExt import Utilities import AppComponents +import DateSeekFeature import GalleryListComponents import FiltersFeature import QuickSearchFeature From 9dff4e39cea0a0f8335a6c654e67c022691d84df Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 16:00:09 +0800 Subject: [PATCH 353/614] Drop unused CA from AppModels; declare CasePaths explicitly --- AppPackage/Package.swift | 4 +++- AppPackage/Sources/AppModels/Persistent/Setting.swift | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 0ea138a6c..e76cd9ab3 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -21,6 +21,7 @@ var dependencies: [PackageDescription.Package.Dependency] = [ .package(url: "https://github.com/markrenaud/FilePicker", from: "1.0.0"), .package(url: "https://github.com/onevcat/Kingfisher", from: "8.0.0"), .package(url: "https://github.com/paololeonardi/WaterfallGrid", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.7.0"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.25.0" @@ -31,6 +32,7 @@ var dependencies: [PackageDescription.Package.Dependency] = [ extension PackageDescription.Target.Dependency { static let alertKit: Self = .product(name: "AlertKit", package: "AlertKit") + static let casePaths: Self = .product(name: "CasePaths", package: "swift-case-paths") static let colorful: Self = .product(name: "Colorful", package: "Colorful") static let commonMark: Self = .product(name: "CommonMark", package: "SwiftCommonMark") static let composableArchitecture: Self = .product( @@ -312,8 +314,8 @@ let targets: [PackageDescription.Target] = [ module: .appModels, dependencies: [ .module(.resources), + .targetDependency(.casePaths), .targetDependency(.commonMark), - .targetDependency(.composableArchitecture), .targetDependency(.openCC), .targetDependency(.sfSafeSymbols), .targetDependency(.swiftyBeaver) diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index fef56432f..af571ca90 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -1,7 +1,6 @@ import SwiftUI import Resources import Foundation -import ComposableArchitecture public struct Setting: Codable, Equatable, Sendable { public init( From 05c186626afbe4a9e0cf02e42bf7b1399a5038bf Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 16:03:59 +0800 Subject: [PATCH 354/614] Move SFSymbol props out of AppModels into AppComponents --- AppPackage/Package.swift | 1 - .../AppComponents/AppError+Symbol.swift | 27 +++++++++++++++++++ .../AppComponents/DownloadBadge+Symbol.swift | 15 +++++++++++ .../DownloadedGallery+Extensions.swift | 12 --------- .../Sources/AppModels/Support/AppError.swift | 23 ---------------- 5 files changed, 42 insertions(+), 36 deletions(-) create mode 100644 AppPackage/Sources/AppComponents/AppError+Symbol.swift create mode 100644 AppPackage/Sources/AppComponents/DownloadBadge+Symbol.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index e76cd9ab3..98b7b7c92 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -317,7 +317,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.casePaths), .targetDependency(.commonMark), .targetDependency(.openCC), - .targetDependency(.sfSafeSymbols), .targetDependency(.swiftyBeaver) ], swiftSettings: sharedSwiftSettings, diff --git a/AppPackage/Sources/AppComponents/AppError+Symbol.swift b/AppPackage/Sources/AppComponents/AppError+Symbol.swift new file mode 100644 index 000000000..c5cd1ace4 --- /dev/null +++ b/AppPackage/Sources/AppComponents/AppError+Symbol.swift @@ -0,0 +1,27 @@ +import AppModels +import SFSafeSymbols + +extension AppError { + public var symbol: SFSymbol { + switch self { + case .databaseCorrupted: + return .exclamationmarkTriangleFill + case .ipBanned: + return .networkBadgeShieldHalfFilled + case .copyrightClaim, .expunged: + return .trashCircleFill + case .networkingFailed: + return .wifiExclamationmark + case .parseFailed: + return .rectangleAndTextMagnifyingglass + case .quotaExceeded: + return .gaugeWithDotsNeedle67percent + case .authenticationRequired: + return .lockCircleFill + case .fileOperationFailed: + return .folderFill + case .notFound, .unknown, .noUpdates, .webImageFailed: + return .questionmarkCircleFill + } + } +} diff --git a/AppPackage/Sources/AppComponents/DownloadBadge+Symbol.swift b/AppPackage/Sources/AppComponents/DownloadBadge+Symbol.swift new file mode 100644 index 000000000..730eb544a --- /dev/null +++ b/AppPackage/Sources/AppComponents/DownloadBadge+Symbol.swift @@ -0,0 +1,15 @@ +import AppModels +import SFSafeSymbols + +extension DownloadBadge { + public var symbol: SFSymbol { + switch status { + case .active: .playFill + case .queued: .listDash + case .inactive: .pauseFill + case .completed: .checkmarkCircleFill + case .updateAvailable: .arrowUpCircleFill + case .error: .exclamationmarkTriangleFill + } + } +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift index b2e712541..715355ab5 100644 --- a/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift @@ -1,19 +1,7 @@ import SwiftUI -import SFSafeSymbols // MARK: - DownloadBadge extension DownloadBadge { - public var symbol: SFSymbol { - switch status { - case .active: .playFill - case .queued: .listDash - case .inactive: .pauseFill - case .completed: .checkmarkCircleFill - case .updateAvailable: .arrowUpCircleFill - case .error: .exclamationmarkTriangleFill - } - } - public var color: Color { switch status { case .active, .queued: .green diff --git a/AppPackage/Sources/AppModels/Support/AppError.swift b/AppPackage/Sources/AppModels/Support/AppError.swift index 17649c784..f0ff360e6 100644 --- a/AppPackage/Sources/AppModels/Support/AppError.swift +++ b/AppPackage/Sources/AppModels/Support/AppError.swift @@ -1,6 +1,5 @@ import Foundation import Resources -import SFSafeSymbols public enum AppError: Error, Identifiable, Equatable, Hashable, Sendable { public var id: String { localizedDescription } @@ -65,28 +64,6 @@ extension AppError { return L10n.Localizable.AppError.LocalizedDescription.unknownError } } - public var symbol: SFSymbol { - switch self { - case .databaseCorrupted: - return .exclamationmarkTriangleFill - case .ipBanned: - return .networkBadgeShieldHalfFilled - case .copyrightClaim, .expunged: - return .trashCircleFill - case .networkingFailed: - return .wifiExclamationmark - case .parseFailed: - return .rectangleAndTextMagnifyingglass - case .quotaExceeded: - return .gaugeWithDotsNeedle67percent - case .authenticationRequired: - return .lockCircleFill - case .fileOperationFailed: - return .folderFill - case .notFound, .unknown, .noUpdates, .webImageFailed: - return .questionmarkCircleFill - } - } public var alertText: String { let tryLater = L10n.Localizable.ErrorView.Title.tryLater switch self { From 18a5166f2ffef50489b1b7f19f3fcdc204097c73 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 16:08:47 +0800 Subject: [PATCH 355/614] Extract chtConverted into OpenCCExt module --- AppPackage/Package.swift | 12 ++++++- .../AppModels/Tags/TagTranslation.swift | 34 ------------------ .../Sources/NetworkingFeature/Request.swift | 1 + AppPackage/Sources/OpenCCExt/.swiftlint.yml | 1 + .../TagTranslation+ChtConverted.swift | 36 +++++++++++++++++++ 5 files changed, 49 insertions(+), 35 deletions(-) create mode 100644 AppPackage/Sources/OpenCCExt/.swiftlint.yml create mode 100644 AppPackage/Sources/OpenCCExt/TagTranslation+ChtConverted.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 98b7b7c92..ac822c1c7 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -99,6 +99,7 @@ enum Module: String { case loggerClient = "LoggerClient" case migrationFeature = "MigrationFeature" case networkingFeature = "NetworkingFeature" + case openCCExt = "OpenCCExt" case parserFeature = "ParserFeature" case quickSearchFeature = "QuickSearchFeature" case readingFeature = "ReadingFeature" @@ -316,7 +317,6 @@ let targets: [PackageDescription.Target] = [ .module(.resources), .targetDependency(.casePaths), .targetDependency(.commonMark), - .targetDependency(.openCC), .targetDependency(.swiftyBeaver) ], swiftSettings: sharedSwiftSettings, @@ -477,6 +477,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.foundationExt), + .module(.openCCExt), .module(.parserFeature), .module(.utilities), .targetDependency(.composableArchitecture), @@ -555,6 +556,15 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .openCCExt, + dependencies: [ + .module(.appModels), + .targetDependency(.openCC) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .migrationFeature, dependencies: [ diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslation.swift b/AppPackage/Sources/AppModels/Tags/TagTranslation.swift index a93dc1efb..fd4304f02 100644 --- a/AppPackage/Sources/AppModels/Tags/TagTranslation.swift +++ b/AppPackage/Sources/AppModels/Tags/TagTranslation.swift @@ -1,4 +1,3 @@ -import OpenCC import Foundation public struct TagTranslation: Codable, Equatable, Hashable, Sendable { @@ -72,36 +71,3 @@ public struct TagTranslation: Codable, Equatable, Hashable, Sendable { ) } } - -extension Dictionary where Value == TagTranslation { - public var chtConverted: Self { - func customConversion(text: String) -> String { - switch text { - case "full color": - return "全彩" - default: - return text - } - } - - guard let preferredLanguage = Locale.preferredLanguages.first else { return self } - - var options: ChineseConverter.Options = [.traditionalize] - if preferredLanguage.contains("HK") { - options = [.traditionalize, .hkStandard] - } else if preferredLanguage.contains("TW") { - options = [.traditionalize, .twStandard, .twIdiom] - } - - guard let converter = try? ChineseConverter(options: options) else { return self } - var dictionary = self - dictionary.forEach { (key, value) in - dictionary[key] = TagTranslation( - namespace: value.namespace, key: value.key, - value: customConversion(text: converter.convert(value.value)), - description: value.description, linksString: value.linksString - ) - } - return dictionary - } -} diff --git a/AppPackage/Sources/NetworkingFeature/Request.swift b/AppPackage/Sources/NetworkingFeature/Request.swift index cf8b0776f..108956442 100644 --- a/AppPackage/Sources/NetworkingFeature/Request.swift +++ b/AppPackage/Sources/NetworkingFeature/Request.swift @@ -4,6 +4,7 @@ import Combine import Foundation import ComposableArchitecture import FoundationExt +import OpenCCExt import Utilities import ParserFeature diff --git a/AppPackage/Sources/OpenCCExt/.swiftlint.yml b/AppPackage/Sources/OpenCCExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/OpenCCExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/OpenCCExt/TagTranslation+ChtConverted.swift b/AppPackage/Sources/OpenCCExt/TagTranslation+ChtConverted.swift new file mode 100644 index 000000000..5fc83b5e2 --- /dev/null +++ b/AppPackage/Sources/OpenCCExt/TagTranslation+ChtConverted.swift @@ -0,0 +1,36 @@ +import AppModels +import Foundation +import OpenCC + +extension Dictionary where Value == TagTranslation { + public var chtConverted: Self { + func customConversion(text: String) -> String { + switch text { + case "full color": + return "全彩" + default: + return text + } + } + + guard let preferredLanguage = Locale.preferredLanguages.first else { return self } + + var options: ChineseConverter.Options = [.traditionalize] + if preferredLanguage.contains("HK") { + options = [.traditionalize, .hkStandard] + } else if preferredLanguage.contains("TW") { + options = [.traditionalize, .twStandard, .twIdiom] + } + + guard let converter = try? ChineseConverter(options: options) else { return self } + var dictionary = self + dictionary.forEach { (key, value) in + dictionary[key] = TagTranslation( + namespace: value.namespace, key: value.key, + value: customConversion(text: converter.convert(value.value)), + description: value.description, linksString: value.linksString + ) + } + return dictionary + } +} From a40acc3fe791e81f9964ae9ae93b8c9d9d9657bb Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 16:14:32 +0800 Subject: [PATCH 356/614] Extract MarkdownUtil into CommonMarkExt; AppModels depends on it instead of CommonMark directly --- AppPackage/Package.swift | 12 +++++++++++- .../Sources/AppModels/Tags/TagTranslation.swift | 1 + AppPackage/Sources/CommonMarkExt/.swiftlint.yml | 1 + .../Utilities => CommonMarkExt}/MarkdownUtil.swift | 8 ++++---- 4 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 AppPackage/Sources/CommonMarkExt/.swiftlint.yml rename AppPackage/Sources/{AppModels/Utilities => CommonMarkExt}/MarkdownUtil.swift (96%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index ac822c1c7..822a5b970 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -78,6 +78,7 @@ enum Module: String { case authorizationClient = "AuthorizationClient" case backgroundProcessingClient = "BackgroundProcessingClient" case clipboardClient = "ClipboardClient" + case commonMarkExt = "CommonMarkExt" case composableArchitectureExt = "ComposableArchitectureExt" case cookieClient = "CookieClient" case dfClient = "DFClient" @@ -314,9 +315,9 @@ let targets: [PackageDescription.Target] = [ .target( module: .appModels, dependencies: [ + .module(.commonMarkExt), .module(.resources), .targetDependency(.casePaths), - .targetDependency(.commonMark), .targetDependency(.swiftyBeaver) ], swiftSettings: sharedSwiftSettings, @@ -556,6 +557,15 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .commonMarkExt, + dependencies: [ + .targetDependency(.casePaths), + .targetDependency(.commonMark) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .openCCExt, dependencies: [ diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslation.swift b/AppPackage/Sources/AppModels/Tags/TagTranslation.swift index fd4304f02..43e65d75b 100644 --- a/AppPackage/Sources/AppModels/Tags/TagTranslation.swift +++ b/AppPackage/Sources/AppModels/Tags/TagTranslation.swift @@ -1,3 +1,4 @@ +import CommonMarkExt import Foundation public struct TagTranslation: Codable, Equatable, Hashable, Sendable { diff --git a/AppPackage/Sources/CommonMarkExt/.swiftlint.yml b/AppPackage/Sources/CommonMarkExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/CommonMarkExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppModels/Utilities/MarkdownUtil.swift b/AppPackage/Sources/CommonMarkExt/MarkdownUtil.swift similarity index 96% rename from AppPackage/Sources/AppModels/Utilities/MarkdownUtil.swift rename to AppPackage/Sources/CommonMarkExt/MarkdownUtil.swift index e4b18999c..2be748bec 100644 --- a/AppPackage/Sources/AppModels/Utilities/MarkdownUtil.swift +++ b/AppPackage/Sources/CommonMarkExt/MarkdownUtil.swift @@ -2,15 +2,15 @@ import CasePaths import CommonMark import Foundation -struct MarkdownUtil { - static func parseTexts(markdown: String) -> [String] { +public struct MarkdownUtil { + public static func parseTexts(markdown: String) -> [String] { (try? Document(markdown: markdown))?.blocks .compactMap({ $0[case: \.paragraph] }) .flatMap(\.text) .compactMap({ $0[case: \.text] }) ?? [] } - static func parseLinks(markdown: String) -> [URL] { + public static func parseLinks(markdown: String) -> [URL] { (try? Document(markdown: markdown))?.blocks .compactMap({ $0[case: \.paragraph] }) .flatMap(\.text) @@ -18,7 +18,7 @@ struct MarkdownUtil { .compactMap(\.url) ?? [] } - static func parseImages(markdown: String) -> [URL] { + public static func parseImages(markdown: String) -> [URL] { (try? Document(markdown: markdown))?.blocks .compactMap({ $0[case: \.paragraph] }) .flatMap(\.text) From c0e5e4d6dfa2625518afee7cc9c68689a5c1a32d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 16:23:00 +0800 Subject: [PATCH 357/614] Extract Logger typealias into SwiftyBeaverExt module --- AppPackage/Package.swift | 22 +++++++++++++++++-- .../AppFeature/DataFlow/LoggingReducer.swift | 1 + .../Sources/AppModels/Gallery/Category.swift | 1 + .../Sources/AppModels/Support/Misc.swift | 2 -- .../ValueTypes/Optional+ForceUnwrapped.swift | 1 + .../BackgroundProcessingClient.swift | 1 + .../DatabaseClient/DatabaseClient.swift | 1 + .../DownloadBackgroundTaskStore.swift | 1 + .../DownloadClient+BackgroundDownloads.swift | 1 + .../DownloadClient+Execution.swift | 1 + .../DownloadClient+Folders.swift | 1 + .../DownloadClient+Networking.swift | 1 + .../DownloadClient+Persistence.swift | 1 + .../DownloadClient+PublicAPI.swift | 1 + .../DownloadClient+ResponseValidation.swift | 1 + .../DownloadClient+RetryHelpers.swift | 1 + .../DownloadClient+Scheduling.swift | 1 + .../DownloadClient/DownloadQueueStore.swift | 1 + .../Sources/LoggerClient/LoggerClient.swift | 1 + .../NetworkingFeature/DFExtensions.swift | 1 + .../Sources/NetworkingFeature/DFRequest.swift | 1 + .../NetworkingFeature/DFStreamHandler.swift | 1 + .../NetworkingFeature/DFURLProtocol.swift | 1 + .../Sources/ParserFeature/Parser+Shared.swift | 1 + .../ReadingFeature/ReadingView+Gestures.swift | 1 + .../Sources/ReadingFeature/ReadingView.swift | 1 + .../Support/AutoPlayHandler.swift | 1 + .../Support/GestureHandler.swift | 1 + .../Support/LiveTextHandler.swift | 1 + .../ReadingFeature/Support/LiveTextView.swift | 1 + .../ReadingFeature/Support/PageHandler.swift | 1 + .../SettingFeature/Components/WebView.swift | 1 + .../Sources/SwiftyBeaverExt/.swiftlint.yml | 1 + .../Sources/SwiftyBeaverExt/Logger.swift | 3 +++ 34 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 AppPackage/Sources/SwiftyBeaverExt/.swiftlint.yml create mode 100644 AppPackage/Sources/SwiftyBeaverExt/Logger.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 822a5b970..418c1888a 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -109,6 +109,7 @@ enum Module: String { case searchFeature = "SearchFeature" case settingFeature = "SettingFeature" case swiftUINavigationExt = "SwiftUINavigationExt" + case swiftyBeaverExt = "SwiftyBeaverExt" case ttProgressHUDExt = "TTProgressHUDExt" case uiApplicationClient = "UIApplicationClient" case urlClient = "URLClient" @@ -285,6 +286,7 @@ let targets: [PackageDescription.Target] = [ .module(.animatedImageFeature), .module(.settingFeature), .module(.swiftUINavigationExt), + .module(.swiftyBeaverExt), .module(.ttProgressHUDExt), .module(.uiApplicationClient), .module(.urlClient), @@ -317,8 +319,8 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.commonMarkExt), .module(.resources), - .targetDependency(.casePaths), - .targetDependency(.swiftyBeaver) + .module(.swiftyBeaverExt), + .targetDependency(.casePaths) ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins @@ -357,6 +359,7 @@ let targets: [PackageDescription.Target] = [ .module(.parserFeature), .module(.resources), .module(.animatedImageFeature), + .module(.swiftyBeaverExt), .module(.urlClient), .module(.utilities), .targetDependency(.composableArchitecture), @@ -437,6 +440,7 @@ let targets: [PackageDescription.Target] = [ module: .backgroundProcessingClient, dependencies: [ .module(.appModels), + .module(.swiftyBeaverExt), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -480,6 +484,7 @@ let targets: [PackageDescription.Target] = [ .module(.foundationExt), .module(.openCCExt), .module(.parserFeature), + .module(.swiftyBeaverExt), .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.deprecatedAPI), @@ -493,6 +498,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.foundationExt), + .module(.swiftyBeaverExt), .module(.utilities), .targetDependency(.composableArchitecture) ], @@ -575,6 +581,14 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .swiftyBeaverExt, + dependencies: [ + .targetDependency(.swiftyBeaver) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .migrationFeature, dependencies: [ @@ -702,6 +716,7 @@ let targets: [PackageDescription.Target] = [ .module(.readingSettingFeature), .module(.resources), .module(.swiftUINavigationExt), + .module(.swiftyBeaverExt), .module(.ttProgressHUDExt), .module(.uiApplicationClient), .module(.userDefaultsClient), @@ -823,6 +838,7 @@ let targets: [PackageDescription.Target] = [ .module(.resources), .module(.animatedImageFeature), .module(.swiftUINavigationExt), + .module(.swiftyBeaverExt), .module(.ttProgressHUDExt), .module(.urlClient), .module(.utilities), @@ -868,6 +884,7 @@ let targets: [PackageDescription.Target] = [ module: .loggerClient, dependencies: [ .module(.appModels), + .module(.swiftyBeaverExt), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -879,6 +896,7 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.foundationExt), .module(.resources), + .module(.swiftyBeaverExt), .module(.utilities), .targetDependency(.kanna) ], diff --git a/AppPackage/Sources/AppFeature/DataFlow/LoggingReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/LoggingReducer.swift index bade53bb4..0d37f2253 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/LoggingReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/LoggingReducer.swift @@ -1,4 +1,5 @@ import AppModels +import SwiftyBeaverExt import ComposableArchitecture // MARK: Logging diff --git a/AppPackage/Sources/AppModels/Gallery/Category.swift b/AppPackage/Sources/AppModels/Gallery/Category.swift index d0e218e2e..dd380d49f 100644 --- a/AppPackage/Sources/AppModels/Gallery/Category.swift +++ b/AppPackage/Sources/AppModels/Gallery/Category.swift @@ -1,4 +1,5 @@ import SwiftUI +import SwiftyBeaverExt import Resources public enum Category: String, Codable, CaseIterable, Identifiable, Sendable { diff --git a/AppPackage/Sources/AppModels/Support/Misc.swift b/AppPackage/Sources/AppModels/Support/Misc.swift index 104b8165a..10c0e30c9 100644 --- a/AppPackage/Sources/AppModels/Support/Misc.swift +++ b/AppPackage/Sources/AppModels/Support/Misc.swift @@ -1,8 +1,6 @@ import CasePaths import Foundation -import SwiftyBeaver -public typealias Logger = SwiftyBeaver public typealias FavoritesSortOrder = EhSetting.FavoritesSortOrder public enum DateSeekDirection: Equatable, Sendable { diff --git a/AppPackage/Sources/AppModels/ValueTypes/Optional+ForceUnwrapped.swift b/AppPackage/Sources/AppModels/ValueTypes/Optional+ForceUnwrapped.swift index 7e578ad0a..69b8e41c4 100644 --- a/AppPackage/Sources/AppModels/ValueTypes/Optional+ForceUnwrapped.swift +++ b/AppPackage/Sources/AppModels/ValueTypes/Optional+ForceUnwrapped.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftyBeaverExt extension Optional { public var forceUnwrapped: Wrapped! { diff --git a/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift b/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift index 89d954d4c..549105d81 100644 --- a/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift +++ b/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift @@ -1,5 +1,6 @@ import BackgroundTasks import AppModels +import SwiftyBeaverExt import ComposableArchitecture public enum BackgroundProcessing { diff --git a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift index 40917e3ae..9e4e715a9 100644 --- a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift +++ b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import SwiftyBeaverExt import Combine import CoreData import ComposableArchitecture diff --git a/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift b/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift index bf08f4522..aecc03495 100644 --- a/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt public actor DownloadBackgroundTaskStore { public struct Record: Codable, Equatable, Sendable { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift index 816ecffc4..9d0c48746 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt public actor BackgroundPageCompletionReceiver { private enum PendingEvent { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift index 8cace4938..a64c7d16f 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt // MARK: - Process Download extension DownloadCoordinator { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift index f63bdb061..247c56bf4 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt import Resources // MARK: - User Folder Operations diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift index 487e1805c..139ce6bde 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt import UniformTypeIdentifiers import AnimatedImageFeature diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift index 70762a9f4..acf48ee57 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt // MARK: - Disk Index extension DownloadCoordinator { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift index 9d887aac5..9bc7ba7de 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt import Resources // MARK: - Public API diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift index 6469d7049..d9d1d82a1 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift @@ -1,5 +1,6 @@ import Kanna import AppModels +import SwiftyBeaverExt import Foundation import ImageIO import FoundationExt diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift index ff2a79727..2ad45072b 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt // MARK: - Retry & RetryPages extension DownloadCoordinator { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift index 4e96a5b04..743b8ffb1 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt // MARK: - Observer Management & Scheduling extension DownloadCoordinator { diff --git a/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift b/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift index 66c59a6d1..30c896946 100644 --- a/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import AppModels +import SwiftyBeaverExt import Foundation public struct DownloadQueueStore: Sendable { diff --git a/AppPackage/Sources/LoggerClient/LoggerClient.swift b/AppPackage/Sources/LoggerClient/LoggerClient.swift index bf72f2690..e85ad6c73 100644 --- a/AppPackage/Sources/LoggerClient/LoggerClient.swift +++ b/AppPackage/Sources/LoggerClient/LoggerClient.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import AppModels +import SwiftyBeaverExt public struct LoggerClient: Sendable { public let info: @Sendable (Any, Any?) -> Void diff --git a/AppPackage/Sources/NetworkingFeature/DFExtensions.swift b/AppPackage/Sources/NetworkingFeature/DFExtensions.swift index 7136e26e9..e2815d13e 100644 --- a/AppPackage/Sources/NetworkingFeature/DFExtensions.swift +++ b/AppPackage/Sources/NetworkingFeature/DFExtensions.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt import DeprecatedAPI import FoundationExt diff --git a/AppPackage/Sources/NetworkingFeature/DFRequest.swift b/AppPackage/Sources/NetworkingFeature/DFRequest.swift index 4bbada11e..207936572 100644 --- a/AppPackage/Sources/NetworkingFeature/DFRequest.swift +++ b/AppPackage/Sources/NetworkingFeature/DFRequest.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt public struct DFRequest { public var request: URLRequest diff --git a/AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift b/AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift index 0607c3a67..15e061a57 100644 --- a/AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift +++ b/AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt public class DFStreamEventHandler: NSObject { private var request: DFRequest diff --git a/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift b/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift index 035d2e4aa..c29ddbd61 100644 --- a/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift +++ b/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import SwiftyBeaverExt public class DFURLProtocol: URLProtocol { private var dfRequest: DFRequest? diff --git a/AppPackage/Sources/ParserFeature/Parser+Shared.swift b/AppPackage/Sources/ParserFeature/Parser+Shared.swift index fbd628078..05e9892e9 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Shared.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Shared.swift @@ -1,5 +1,6 @@ import Kanna import AppModels +import SwiftyBeaverExt import Foundation import Utilities diff --git a/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift b/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift index f0dd24cd7..26e72c4fe 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import SwiftyBeaverExt // MARK: Gesture extension ReadingView { diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index d8b8e0b88..00e444584 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import SwiftyBeaverExt import Observation import SFSafeSymbols import SwiftUIPager diff --git a/AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift b/AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift index 095913bb4..a4100edbd 100644 --- a/AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift +++ b/AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import SwiftyBeaverExt import Observation @Observable diff --git a/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift b/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift index 9175ae8bb..b73bfde99 100644 --- a/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift +++ b/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import SwiftyBeaverExt import Observation import Utilities diff --git a/AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift b/AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift index 8140fd05d..2759e7817 100644 --- a/AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift +++ b/AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift @@ -10,6 +10,7 @@ import Vision import AppModels +import SwiftyBeaverExt import SwiftUI import Foundation import Observation diff --git a/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift b/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift index bff25541d..899175a68 100644 --- a/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift +++ b/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import SwiftyBeaverExt struct LiveTextView: View { private let liveTextGroups: [LiveTextGroup] diff --git a/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift b/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift index c23d5a411..4bfdadd0b 100644 --- a/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift +++ b/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import SwiftyBeaverExt import Observation import Utilities diff --git a/AppPackage/Sources/SettingFeature/Components/WebView.swift b/AppPackage/Sources/SettingFeature/Components/WebView.swift index 9186d605a..8b619682b 100644 --- a/AppPackage/Sources/SettingFeature/Components/WebView.swift +++ b/AppPackage/Sources/SettingFeature/Components/WebView.swift @@ -1,5 +1,6 @@ import WebKit import AppModels +import SwiftyBeaverExt import SwiftUI struct WebView: UIViewControllerRepresentable { diff --git a/AppPackage/Sources/SwiftyBeaverExt/.swiftlint.yml b/AppPackage/Sources/SwiftyBeaverExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/SwiftyBeaverExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/SwiftyBeaverExt/Logger.swift b/AppPackage/Sources/SwiftyBeaverExt/Logger.swift new file mode 100644 index 000000000..c896ab405 --- /dev/null +++ b/AppPackage/Sources/SwiftyBeaverExt/Logger.swift @@ -0,0 +1,3 @@ +import SwiftyBeaver + +public typealias Logger = SwiftyBeaver From a17b1d11d6b76f998d39d1b803c2adbd397c1da7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 16:28:15 +0800 Subject: [PATCH 358/614] Merge FoundationExt module into Utilities --- AppPackage/Package.swift | 24 +------------------ .../AppComponents/PreviewImageView.swift | 2 +- .../Sources/AppComponents/RatingView.swift | 2 +- .../Sources/AppComponents/TagCloudView.swift | 2 +- .../AppComponents/TagSuggestionView.swift | 1 - .../Sources/AppComponents/ViewModifiers.swift | 2 +- .../Sources/CookieClient/CookieClient.swift | 1 - .../MODefinition/AppEnvMO+CoreDataClass.swift | 2 +- .../GalleryDetailMO+CoreDataClass.swift | 2 +- .../GalleryMO+CoreDataClass.swift | 2 +- .../GalleryStateMO+CoreDataClass.swift | 2 +- .../DatabaseClient+Updates.swift | 2 +- .../DatabaseClient/DatabaseClient.swift | 1 - .../Archives/ArchivesReducer.swift | 2 +- .../Components/TagDetailView.swift | 2 +- .../DetailReducer+Download.swift | 1 - .../DetailFeature/DetailView+Subviews.swift | 1 - .../Previews/PreviewsReducer.swift | 2 +- .../DownloadClient/DownloadClient+Cache.swift | 1 - .../DownloadClient+ResponseValidation.swift | 1 - ...loadClient+ResponseValidationHelpers.swift | 1 - .../DownloadsFeature/DownloadsReducer.swift | 2 +- .../Sources/FoundationExt/.swiftlint.yml | 1 - .../Frontpage/FrontpageReducer.swift | 2 +- .../HomeFeature/History/HistoryReducer.swift | 2 +- .../Sources/HomeFeature/HomeReducer.swift | 2 +- .../HomeFeature/Popular/PopularReducer.swift | 2 +- .../Toplists/ToplistsReducer.swift | 2 +- .../Sources/ImageClient/ImageClient.swift | 1 - .../NetworkingFeature/DFExtensions.swift | 2 +- .../NetworkingFeature/Request+Account.swift | 1 - .../Sources/NetworkingFeature/Request.swift | 1 - .../Sources/ParserFeature/Parser+Detail.swift | 2 +- .../ReadingFeature/ReadingReducer+Body.swift | 2 +- .../ReadingReducer+Database.swift | 2 +- .../ReadingReducer+ImageFetch.swift | 2 +- .../Sources/ReadingFeature/ReadingView.swift | 1 - .../SearchFeature/SearchRootReducer.swift | 2 +- .../SearchFeature/SearchRootView.swift | 1 - .../EhSetting/EhSettingView+Sections1.swift | 2 +- .../EhSetting/EhSettingView+Sections3.swift | 1 - .../UIApplicationClient.swift | 1 - AppPackage/Sources/URLClient/URLClient.swift | 2 +- .../Extensions.swift | 0 .../URL+Components.swift | 0 .../URL+ImageCacheKey.swift | 0 .../DownloadCoordinatorCaptureTests.swift | 1 - .../DownloadFeatureTestFactories.swift | 1 - .../DownloadImageParsingCacheTests.swift | 2 +- .../Download/DownloadImageParsingTests.swift | 2 +- .../Download/DownloadProcessCacheTests.swift | 2 +- .../Tests/Download/ReaderImageDataTests.swift | 1 - .../Other/SettingDownloadTests.swift | 1 - 53 files changed, 30 insertions(+), 72 deletions(-) delete mode 100644 AppPackage/Sources/FoundationExt/.swiftlint.yml rename AppPackage/Sources/{FoundationExt => Utilities}/Extensions.swift (100%) rename AppPackage/Sources/{FoundationExt => Utilities}/URL+Components.swift (100%) rename AppPackage/Sources/{FoundationExt => Utilities}/URL+ImageCacheKey.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 418c1888a..806fd8eb6 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -91,7 +91,6 @@ enum Module: String { case favoritesFeature = "FavoritesFeature" case fileClient = "FileClient" case filtersFeature = "FiltersFeature" - case foundationExt = "FoundationExt" case galleryListComponents = "GalleryListComponents" case hapticsClient = "HapticsClient" case homeFeature = "HomeFeature" @@ -270,7 +269,6 @@ let targets: [PackageDescription.Target] = [ .module(.favoritesFeature), .module(.fileClient), .module(.filtersFeature), - .module(.foundationExt), .module(.hapticsClient), .module(.homeFeature), .module(.imageClient), @@ -353,7 +351,6 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.databaseClient), - .module(.foundationExt), .module(.libraryClient), .module(.networkingFeature), .module(.parserFeature), @@ -378,11 +375,6 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), - .target( - module: .foundationExt, - swiftSettings: sharedSwiftSettings, - plugins: swiftLintPlugins - ), .target( module: .swiftUINavigationExt, dependencies: [ @@ -459,7 +451,6 @@ let targets: [PackageDescription.Target] = [ module: .cookieClient, dependencies: [ .module(.appModels), - .module(.foundationExt), .module(.resources), .module(.utilities), .targetDependency(.composableArchitecture) @@ -481,7 +472,6 @@ let targets: [PackageDescription.Target] = [ module: .networkingFeature, dependencies: [ .module(.appModels), - .module(.foundationExt), .module(.openCCExt), .module(.parserFeature), .module(.swiftyBeaverExt), @@ -497,7 +487,6 @@ let targets: [PackageDescription.Target] = [ module: .databaseClient, dependencies: [ .module(.appModels), - .module(.foundationExt), .module(.swiftyBeaverExt), .module(.utilities), .targetDependency(.composableArchitecture) @@ -520,7 +509,6 @@ let targets: [PackageDescription.Target] = [ module: .appComponents, dependencies: [ .module(.appModels), - .module(.foundationExt), .module(.parserFeature), .module(.resources), .module(.utilities), @@ -659,7 +647,6 @@ let targets: [PackageDescription.Target] = [ .module(.composableArchitectureExt), .module(.detailFeature), .module(.downloadClient), - .module(.foundationExt), .module(.galleryListComponents), .module(.readingFeature), .module(.resources), @@ -708,7 +695,6 @@ let targets: [PackageDescription.Target] = [ .module(.deviceClient), .module(.dfClient), .module(.fileClient), - .module(.foundationExt), .module(.hapticsClient), .module(.libraryClient), .module(.loggerClient), @@ -739,7 +725,6 @@ let targets: [PackageDescription.Target] = [ .module(.detailFeature), .module(.downloadClient), .module(.filtersFeature), - .module(.foundationExt), .module(.galleryListComponents), .module(.hapticsClient), .module(.networkingFeature), @@ -766,7 +751,6 @@ let targets: [PackageDescription.Target] = [ .module(.detailFeature), .module(.downloadClient), .module(.filtersFeature), - .module(.foundationExt), .module(.galleryListComponents), .module(.hapticsClient), .module(.libraryClient), @@ -799,7 +783,6 @@ let targets: [PackageDescription.Target] = [ .module(.downloadClient), .module(.fileClient), .module(.filtersFeature), - .module(.foundationExt), .module(.galleryListComponents), .module(.hapticsClient), .module(.networkingFeature), @@ -830,7 +813,6 @@ let targets: [PackageDescription.Target] = [ .module(.databaseClient), .module(.deviceClient), .module(.downloadClient), - .module(.foundationExt), .module(.hapticsClient), .module(.imageClient), .module(.networkingFeature), @@ -856,7 +838,6 @@ let targets: [PackageDescription.Target] = [ module: .imageClient, dependencies: [ .module(.appModels), - .module(.foundationExt), .module(.animatedImageFeature), .module(.utilities), .targetDependency(.composableArchitecture) @@ -894,7 +875,6 @@ let targets: [PackageDescription.Target] = [ module: .parserFeature, dependencies: [ .module(.appModels), - .module(.foundationExt), .module(.resources), .module(.swiftyBeaverExt), .module(.utilities), @@ -906,7 +886,6 @@ let targets: [PackageDescription.Target] = [ .target( module: .uiApplicationClient, dependencies: [ - .module(.foundationExt), .module(.utilities), .targetDependency(.composableArchitecture) ], @@ -917,7 +896,7 @@ let targets: [PackageDescription.Target] = [ module: .urlClient, dependencies: [ .module(.appModels), - .module(.foundationExt), + .module(.utilities), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -951,7 +930,6 @@ let targets: [PackageDescription.Target] = [ .module(.downloadClient), .module(.downloadsFeature), .module(.fileClient), - .module(.foundationExt), .module(.hapticsClient), .module(.imageClient), .module(.libraryClient), diff --git a/AppPackage/Sources/AppComponents/PreviewImageView.swift b/AppPackage/Sources/AppComponents/PreviewImageView.swift index b04ae3156..6d8d77ef8 100644 --- a/AppPackage/Sources/AppComponents/PreviewImageView.swift +++ b/AppPackage/Sources/AppComponents/PreviewImageView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import ImageIO import Kingfisher -import FoundationExt +import Utilities public struct PreviewImageView: View { private let originalURL: URL? diff --git a/AppPackage/Sources/AppComponents/RatingView.swift b/AppPackage/Sources/AppComponents/RatingView.swift index d6d036b48..c1777ff83 100644 --- a/AppPackage/Sources/AppComponents/RatingView.swift +++ b/AppPackage/Sources/AppComponents/RatingView.swift @@ -1,6 +1,6 @@ import SwiftUI import SFSafeSymbols -import FoundationExt +import Utilities public struct RatingView: View { private let rawRating: Float diff --git a/AppPackage/Sources/AppComponents/TagCloudView.swift b/AppPackage/Sources/AppComponents/TagCloudView.swift index a3a8fa1a4..e8365c38c 100644 --- a/AppPackage/Sources/AppComponents/TagCloudView.swift +++ b/AppPackage/Sources/AppComponents/TagCloudView.swift @@ -4,7 +4,7 @@ import SwiftUI import SFSafeSymbols import Kingfisher -import FoundationExt +import Utilities public struct TagCloudView: View where TagCell: View, Element: Equatable & Identifiable, ID == Element.ID { diff --git a/AppPackage/Sources/AppComponents/TagSuggestionView.swift b/AppPackage/Sources/AppComponents/TagSuggestionView.swift index de0e208fa..d0eb712ef 100644 --- a/AppPackage/Sources/AppComponents/TagSuggestionView.swift +++ b/AppPackage/Sources/AppComponents/TagSuggestionView.swift @@ -4,7 +4,6 @@ import AppModels import Resources import Kingfisher import Observation -import FoundationExt import Utilities public struct TagSuggestionView: View { diff --git a/AppPackage/Sources/AppComponents/ViewModifiers.swift b/AppPackage/Sources/AppComponents/ViewModifiers.swift index 7fc33c302..436e483f7 100644 --- a/AppPackage/Sources/AppComponents/ViewModifiers.swift +++ b/AppPackage/Sources/AppComponents/ViewModifiers.swift @@ -1,7 +1,7 @@ import SwiftUI import Kingfisher import SFSafeSymbols -import FoundationExt +import Utilities import ParserFeature extension View { diff --git a/AppPackage/Sources/CookieClient/CookieClient.swift b/AppPackage/Sources/CookieClient/CookieClient.swift index 4d1a3954a..cca952fe8 100644 --- a/AppPackage/Sources/CookieClient/CookieClient.swift +++ b/AppPackage/Sources/CookieClient/CookieClient.swift @@ -2,7 +2,6 @@ import Foundation import AppModels import Resources import ComposableArchitecture -import FoundationExt import Utilities #if DEBUG import Synchronization diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift index 824599a87..0532a6f93 100644 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift +++ b/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift @@ -1,6 +1,6 @@ import CoreData import AppModels -import FoundationExt +import Utilities public class AppEnvMO: NSManagedObject {} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift index 2ff886192..4db76c76c 100644 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift +++ b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift @@ -1,6 +1,6 @@ import CoreData import AppModels -import FoundationExt +import Utilities public class GalleryDetailMO: NSManagedObject {} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift index 359399e30..4610e81d7 100644 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift +++ b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift @@ -1,6 +1,6 @@ import CoreData import AppModels -import FoundationExt +import Utilities public class GalleryMO: NSManagedObject {} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift index 7648c4544..4fa40b06e 100644 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift +++ b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import CoreData -import FoundationExt +import Utilities public class GalleryStateMO: NSManagedObject {} diff --git a/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift b/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift index 7d333f2d4..a0da3bd08 100644 --- a/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift +++ b/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import CoreData -import FoundationExt +import Utilities // MARK: UpdateGalleryState extension DatabaseClient { diff --git a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift index 9e4e715a9..2cac27a74 100644 --- a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift +++ b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift @@ -4,7 +4,6 @@ import SwiftyBeaverExt import Combine import CoreData import ComposableArchitecture -import FoundationExt import Utilities public struct DatabaseClient: Sendable { diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index ff1f0fb29..1d79aa3ac 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import Resources import ComposableArchitecture -import FoundationExt +import Utilities import HapticsClient import DatabaseClient import NetworkingFeature diff --git a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift index 5b605a8e9..bb12c197d 100644 --- a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift +++ b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Resources import Kingfisher -import FoundationExt +import Utilities import AppComponents struct TagDetailView: View { diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift index 37e9d9b51..753b5edf3 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift @@ -1,7 +1,6 @@ import Foundation import AppModels import ComposableArchitecture -import FoundationExt import Utilities // MARK: - Download Action Handlers diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index 9bf7e98b1..216467d26 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import Resources import Kingfisher -import FoundationExt import Utilities import AppComponents diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index 7eceac82f..1b7a6e899 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -1,7 +1,7 @@ import Foundation import AppModels import ComposableArchitecture -import FoundationExt +import Utilities import SwiftUINavigationExt import HapticsClient import DatabaseClient diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift index 68f32afc9..cd18d7418 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift @@ -1,6 +1,5 @@ import Foundation import AppModels -import FoundationExt import Utilities // MARK: - Cache Operations diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift index d9d1d82a1..f1d487809 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift @@ -3,7 +3,6 @@ import AppModels import SwiftyBeaverExt import Foundation import ImageIO -import FoundationExt import Utilities import ParserFeature diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift index 820c90580..21186deeb 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift @@ -2,7 +2,6 @@ import Kanna import AppModels import Foundation import ImageIO -import FoundationExt import Utilities import AnimatedImageFeature import ParserFeature diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index cf27d4490..8640720ae 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -1,7 +1,7 @@ import Foundation import AppModels import ComposableArchitecture -import FoundationExt +import Utilities import DownloadClient import ReadingFeature import DetailFeature diff --git a/AppPackage/Sources/FoundationExt/.swiftlint.yml b/AppPackage/Sources/FoundationExt/.swiftlint.yml deleted file mode 100644 index 1242ffcaa..000000000 --- a/AppPackage/Sources/FoundationExt/.swiftlint.yml +++ /dev/null @@ -1 +0,0 @@ -parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index 1eacd99b7..13c53beae 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -1,7 +1,7 @@ import ComposableArchitecture import AppModels import Foundation -import FoundationExt +import Utilities import SwiftUINavigationExt import HapticsClient import DatabaseClient diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index 03155626f..91bc74e44 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -1,7 +1,7 @@ import Foundation import AppModels import ComposableArchitecture -import FoundationExt +import Utilities import HapticsClient import DatabaseClient import DownloadClient diff --git a/AppPackage/Sources/HomeFeature/HomeReducer.swift b/AppPackage/Sources/HomeFeature/HomeReducer.swift index e26e94d11..dc23f8ef4 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Kingfisher import ComposableArchitecture -import FoundationExt +import Utilities import LibraryClient import DatabaseClient import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index 1d5cae767..f79d360d6 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import AppModels -import FoundationExt +import Utilities import SwiftUINavigationExt import HapticsClient import DatabaseClient diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index 503a0dfe4..65eb7026d 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import AppModels -import FoundationExt +import Utilities import HapticsClient import DatabaseClient import NetworkingFeature diff --git a/AppPackage/Sources/ImageClient/ImageClient.swift b/AppPackage/Sources/ImageClient/ImageClient.swift index 9b2753b0e..1bafe5a46 100644 --- a/AppPackage/Sources/ImageClient/ImageClient.swift +++ b/AppPackage/Sources/ImageClient/ImageClient.swift @@ -3,7 +3,6 @@ import AppModels import SwiftUI import Combine import ComposableArchitecture -import FoundationExt import Utilities import AnimatedImageFeature diff --git a/AppPackage/Sources/NetworkingFeature/DFExtensions.swift b/AppPackage/Sources/NetworkingFeature/DFExtensions.swift index e2815d13e..0842a79f3 100644 --- a/AppPackage/Sources/NetworkingFeature/DFExtensions.swift +++ b/AppPackage/Sources/NetworkingFeature/DFExtensions.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import SwiftyBeaverExt import DeprecatedAPI -import FoundationExt +import Utilities // MARK: Global private func forceDowncast(object: Any) -> T! { diff --git a/AppPackage/Sources/NetworkingFeature/Request+Account.swift b/AppPackage/Sources/NetworkingFeature/Request+Account.swift index f89f4b591..2fc269412 100644 --- a/AppPackage/Sources/NetworkingFeature/Request+Account.swift +++ b/AppPackage/Sources/NetworkingFeature/Request+Account.swift @@ -2,7 +2,6 @@ import Kanna import AppModels import Combine import Foundation -import FoundationExt import Utilities import ParserFeature diff --git a/AppPackage/Sources/NetworkingFeature/Request.swift b/AppPackage/Sources/NetworkingFeature/Request.swift index 108956442..eb004e63e 100644 --- a/AppPackage/Sources/NetworkingFeature/Request.swift +++ b/AppPackage/Sources/NetworkingFeature/Request.swift @@ -3,7 +3,6 @@ import AppModels import Combine import Foundation import ComposableArchitecture -import FoundationExt import OpenCCExt import Utilities import ParserFeature diff --git a/AppPackage/Sources/ParserFeature/Parser+Detail.swift b/AppPackage/Sources/ParserFeature/Parser+Detail.swift index 14eb7f996..09eb946fb 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Detail.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Detail.swift @@ -1,7 +1,7 @@ import Kanna import AppModels import Foundation -import FoundationExt +import Utilities extension Parser { public static func parseGalleryURL(doc: HTMLDocument) throws -> URL { diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index ed66f4172..8caacfc23 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -2,7 +2,7 @@ import SwiftUI import Kingfisher import TTProgressHUD import ComposableArchitecture -import FoundationExt +import Utilities import SwiftUINavigationExt // MARK: - CancelID diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift index bf85c6981..fcc80b6d9 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import ComposableArchitecture -import FoundationExt +import Utilities // MARK: - Database & Download Actions extension ReadingReducer { diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift index a4cf21669..603b1d308 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift @@ -1,6 +1,6 @@ import Foundation import ComposableArchitecture -import FoundationExt +import Utilities import NetworkingFeature // MARK: - Image URL Fetch Actions diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 00e444584..6a676fea3 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -5,7 +5,6 @@ import Observation import SFSafeSymbols import SwiftUIPager import ComposableArchitecture -import FoundationExt import Utilities import AnimatedImageFeature import TTProgressHUDExt diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift index 39b88eed6..6823c1826 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import AppModels -import FoundationExt +import Utilities import SwiftUINavigationExt import HapticsClient import DatabaseClient diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index f3dc07063..eb1059674 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import FoundationExt import SwiftUINavigationExt import Utilities import AppComponents diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift index edd4bba53..514015d6b 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import FoundationExt +import Utilities import SwiftUINavigationExt import AppComponents diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift index 4cdd9a6aa..da04c2c5d 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -1,7 +1,6 @@ import SwiftUI import AppModels import Resources -import FoundationExt import Utilities extension EhSettingView { diff --git a/AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift b/AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift index 7c15cba81..0ceee0708 100644 --- a/AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift +++ b/AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift @@ -1,7 +1,6 @@ import SwiftUI import Combine import ComposableArchitecture -import FoundationExt import Utilities public struct UIApplicationClient: Sendable { diff --git a/AppPackage/Sources/URLClient/URLClient.swift b/AppPackage/Sources/URLClient/URLClient.swift index 455edc7cb..adbfcc4f3 100644 --- a/AppPackage/Sources/URLClient/URLClient.swift +++ b/AppPackage/Sources/URLClient/URLClient.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import Dependencies -import FoundationExt +import Utilities public struct URLAnalysisResult: Sendable { public let isGalleryImageURL: Bool diff --git a/AppPackage/Sources/FoundationExt/Extensions.swift b/AppPackage/Sources/Utilities/Extensions.swift similarity index 100% rename from AppPackage/Sources/FoundationExt/Extensions.swift rename to AppPackage/Sources/Utilities/Extensions.swift diff --git a/AppPackage/Sources/FoundationExt/URL+Components.swift b/AppPackage/Sources/Utilities/URL+Components.swift similarity index 100% rename from AppPackage/Sources/FoundationExt/URL+Components.swift rename to AppPackage/Sources/Utilities/URL+Components.swift diff --git a/AppPackage/Sources/FoundationExt/URL+ImageCacheKey.swift b/AppPackage/Sources/Utilities/URL+ImageCacheKey.swift similarity index 100% rename from AppPackage/Sources/FoundationExt/URL+ImageCacheKey.swift rename to AppPackage/Sources/Utilities/URL+ImageCacheKey.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift index e79782f95..69bdeb076 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift @@ -2,7 +2,6 @@ import UIKit import AppModels import Foundation import Testing -import FoundationExt import Utilities import DownloadClient @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift index 1df3eb0b1..89d15e2ff 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -2,7 +2,6 @@ import CoreData import AppModels import Foundation import Testing -import FoundationExt import Utilities import DatabaseClient import DownloadClient diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift index 1da52d77f..73bfaa61d 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -4,7 +4,7 @@ import Kingfisher import UIKit import Foundation import Testing -import FoundationExt +import Utilities @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift index 020ac1e73..2a7eef2fb 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift @@ -4,7 +4,7 @@ import Kingfisher import UIKit import Foundation import Testing -import FoundationExt +import Utilities import DownloadClient @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift index e521a3a7b..7d217341e 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift @@ -2,7 +2,7 @@ import UIKit import AppModels import Foundation import Testing -import FoundationExt +import Utilities import LibraryClient import DownloadClient @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift index 9296844a7..b0076dc03 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift @@ -2,7 +2,6 @@ import Foundation import AppModels import Testing import UIKit -import FoundationExt import Utilities import ImageClient @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/SettingDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/SettingDownloadTests.swift index 8b7fd3351..53c22ff95 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/SettingDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/SettingDownloadTests.swift @@ -1,7 +1,6 @@ import SwiftUI import AppModels import Testing -import FoundationExt import Utilities import URLClient @testable import AppFeature From 04a65fd0e554c358e37ea2be7035b91b6c2fc86c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 16:32:56 +0800 Subject: [PATCH 359/614] Rename Utilities module to AppTools --- AppPackage/Package.swift | 58 +++++++++---------- .../Sources/AppComponents/AlertView.swift | 2 +- .../Sources/AppComponents/CategoryView.swift | 2 +- .../Sources/AppComponents/NewDawnView.swift | 2 +- .../Sources/AppComponents/Placeholder.swift | 2 +- .../AppComponents/PreviewImageView.swift | 2 +- .../Sources/AppComponents/RatingView.swift | 2 +- .../Sources/AppComponents/SubSection.swift | 2 +- .../Sources/AppComponents/TagCloudView.swift | 2 +- .../AppComponents/TagSuggestionView.swift | 2 +- .../Sources/AppComponents/ViewModifiers.swift | 2 +- .../AppDelegateClient/AppDelegateClient.swift | 2 +- .../AppOrientationMask.swift | 2 +- .../DataFlow/AppDelegateReducer.swift | 2 +- AppPackage/Sources/AppFeature/RootView.swift | 2 +- .../AppFeature/View/TabBar/TabBarView.swift | 2 +- .../{Utilities => AppTools}/.swiftlint.yml | 0 .../{Utilities => AppTools}/AppUtil.swift | 0 .../{Utilities => AppTools}/CookieUtil.swift | 0 .../{Utilities => AppTools}/DataCache.swift | 0 .../Defaults+Runtime.swift | 0 .../{Utilities => AppTools}/DeviceUtil.swift | 0 .../{Utilities => AppTools}/Extensions.swift | 0 .../{Utilities => AppTools}/FileUtil.swift | 0 .../{Utilities => AppTools}/HapticsUtil.swift | 0 .../ImagePlaceholderFingerprint.swift | 0 .../TouchHandler.swift | 0 .../URL+Components.swift | 0 .../URL+ImageCacheKey.swift | 0 .../{Utilities => AppTools}/URLUtil.swift | 0 .../UserDefaultsUtil.swift | 0 .../Sources/CookieClient/CookieClient.swift | 2 +- .../MODefinition/AppEnvMO+CoreDataClass.swift | 2 +- .../GalleryDetailMO+CoreDataClass.swift | 2 +- .../GalleryMO+CoreDataClass.swift | 2 +- .../GalleryStateMO+CoreDataClass.swift | 2 +- .../DatabaseClient+Updates.swift | 2 +- .../DatabaseClient/DatabaseClient.swift | 2 +- .../Archives/ArchivesReducer.swift | 2 +- .../DetailFeature/Archives/ArchivesView.swift | 2 +- .../DetailFeature/Comments/CommentsView.swift | 2 +- .../Components/TagDetailView.swift | 2 +- .../DetailReducer+Download.swift | 2 +- .../DetailSearch/DetailSearchView.swift | 2 +- .../DetailView+HeaderSection.swift | 2 +- .../DetailFeature/DetailView+Navigation.swift | 2 +- .../DetailFeature/DetailView+Subviews.swift | 2 +- .../Sources/DetailFeature/DetailView.swift | 2 +- .../GalleryInfos/GalleryInfosView.swift | 2 +- .../Previews/PreviewsReducer.swift | 2 +- .../DetailFeature/Previews/PreviewsView.swift | 2 +- .../Sources/DeviceClient/DeviceClient.swift | 2 +- .../DownloadClient/DownloadClient+Cache.swift | 2 +- .../DownloadClient+ResponseValidation.swift | 2 +- ...loadClient+ResponseValidationHelpers.swift | 2 +- .../DownloadClient/DownloadClient.swift | 2 +- .../DownloadClient/DownloadStore.swift | 2 +- .../DownloadsFeature/DownloadsReducer.swift | 2 +- .../DownloadsFeature/DownloadsView.swift | 2 +- .../FavoritesFeature/FavoritesView.swift | 2 +- .../Sources/FileClient/FileClient.swift | 2 +- .../Cells/GalleryDetailCell.swift | 2 +- .../Cells/GalleryThumbnailCell.swift | 2 +- .../GalleryListComponents/GenericList.swift | 2 +- .../Sources/HapticsClient/HapticsClient.swift | 2 +- .../Frontpage/FrontpageReducer.swift | 2 +- .../HomeFeature/Frontpage/FrontpageView.swift | 2 +- .../Sources/HomeFeature/GalleryCardCell.swift | 2 +- .../HomeFeature/History/HistoryReducer.swift | 2 +- .../HomeFeature/History/HistoryView.swift | 2 +- .../Sources/HomeFeature/HomeReducer.swift | 2 +- .../HomeFeature/HomeView+Sections.swift | 2 +- AppPackage/Sources/HomeFeature/HomeView.swift | 2 +- .../HomeFeature/Popular/PopularReducer.swift | 2 +- .../HomeFeature/Popular/PopularView.swift | 2 +- .../Toplists/ToplistsReducer.swift | 2 +- .../HomeFeature/Toplists/ToplistsView.swift | 2 +- .../HomeFeature/Watched/WatchedView.swift | 2 +- .../Sources/ImageClient/ImageClient.swift | 2 +- .../Sources/LibraryClient/LibraryClient.swift | 2 +- .../NetworkingFeature/DFExtensions.swift | 2 +- .../NetworkingFeature/Request+Account.swift | 2 +- .../NetworkingFeature/Request+Detail.swift | 2 +- .../NetworkingFeature/Request+Gallery.swift | 2 +- .../NetworkingFeature/Request+Image.swift | 2 +- .../Sources/NetworkingFeature/Request.swift | 2 +- .../Sources/ParserFeature/Parser+Detail.swift | 2 +- .../ParserFeature/Parser+Preview.swift | 2 +- .../Sources/ParserFeature/Parser+Shared.swift | 2 +- .../ReadingFeature/ReadingReducer+Body.swift | 2 +- .../ReadingReducer+Database.swift | 2 +- .../ReadingReducer+ImageFetch.swift | 2 +- .../Sources/ReadingFeature/ReadingView.swift | 2 +- .../ReadingViewComponents.swift | 2 +- .../ReadingFeature/Support/ControlPanel.swift | 2 +- .../Support/GestureHandler.swift | 2 +- .../ReadingFeature/Support/PageHandler.swift | 2 +- .../ReadingSettingView.swift | 2 +- .../SearchFeature/SearchRootReducer.swift | 2 +- .../SearchRootView+Keywords.swift | 2 +- .../SearchFeature/SearchRootView.swift | 2 +- .../Sources/SearchFeature/SearchView.swift | 2 +- .../AccountSetting/AccountSettingView.swift | 2 +- .../SettingFeature/Components/AboutView.swift | 2 +- .../EhSetting/EhSettingView+Sections1.swift | 2 +- .../EhSetting/EhSettingView+Sections2.swift | 2 +- .../EhSetting/EhSettingView+Sections3.swift | 2 +- .../EhSetting/EhSettingView.swift | 2 +- .../UIApplicationClient.swift | 2 +- AppPackage/Sources/URLClient/URLClient.swift | 2 +- .../UserDefaultsClient.swift | 2 +- .../Tests/Download/DataCacheTests.swift | 2 +- .../Download/DownloadBadgeSortTests.swift | 2 +- .../DownloadCoordinatorCaptureTests.swift | 2 +- .../DownloadFeatureTestFactories.swift | 2 +- .../DownloadFilterAndBadgeTests.swift | 2 +- .../DownloadImageParsingCacheTests.swift | 2 +- .../Download/DownloadImageParsingTests.swift | 2 +- .../DownloadObserverReadingTests.swift | 2 +- .../Download/DownloadProcessCacheTests.swift | 2 +- .../Tests/Download/ReaderImageDataTests.swift | 2 +- .../Download/ReadingReducerLocalTests.swift | 2 +- .../Other/SettingDownloadTests.swift | 2 +- 123 files changed, 136 insertions(+), 136 deletions(-) rename AppPackage/Sources/{Utilities => AppTools}/.swiftlint.yml (100%) rename AppPackage/Sources/{Utilities => AppTools}/AppUtil.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/CookieUtil.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/DataCache.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/Defaults+Runtime.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/DeviceUtil.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/Extensions.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/FileUtil.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/HapticsUtil.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/ImagePlaceholderFingerprint.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/TouchHandler.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/URL+Components.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/URL+ImageCacheKey.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/URLUtil.swift (100%) rename AppPackage/Sources/{Utilities => AppTools}/UserDefaultsUtil.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 806fd8eb6..8605f2419 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -75,6 +75,7 @@ enum Module: String { case appFeature = "AppFeature" case appLaunchAutomationClient = "AppLaunchAutomationClient" case appModels = "AppModels" + case appTools = "AppTools" case authorizationClient = "AuthorizationClient" case backgroundProcessingClient = "BackgroundProcessingClient" case clipboardClient = "ClipboardClient" @@ -113,7 +114,6 @@ enum Module: String { case uiApplicationClient = "UIApplicationClient" case urlClient = "URLClient" case userDefaultsClient = "UserDefaultsClient" - case utilities = "Utilities" // Test targets case appFeatureTests = "AppFeatureTests" @@ -254,6 +254,7 @@ let targets: [PackageDescription.Target] = [ .module(.appDelegateClient), .module(.appLaunchAutomationClient), .module(.appModels), + .module(.appTools), .module(.authorizationClient), .module(.backgroundProcessingClient), .module(.clipboardClient), @@ -289,7 +290,6 @@ let targets: [PackageDescription.Target] = [ .module(.uiApplicationClient), .module(.urlClient), .module(.userDefaultsClient), - .module(.utilities), .targetDependency(.alertKit), .targetDependency(.colorful), .targetDependency(.commonMark), @@ -340,7 +340,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .deviceClient, dependencies: [ - .module(.utilities), + .module(.appTools), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -350,6 +350,7 @@ let targets: [PackageDescription.Target] = [ module: .downloadClient, dependencies: [ .module(.appModels), + .module(.appTools), .module(.databaseClient), .module(.libraryClient), .module(.networkingFeature), @@ -358,7 +359,6 @@ let targets: [PackageDescription.Target] = [ .module(.animatedImageFeature), .module(.swiftyBeaverExt), .module(.urlClient), - .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.kanna) ], @@ -369,7 +369,7 @@ let targets: [PackageDescription.Target] = [ module: .fileClient, dependencies: [ .module(.appModels), - .module(.utilities), + .module(.appTools), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -395,7 +395,7 @@ let targets: [PackageDescription.Target] = [ plugins: swiftLintPlugins ), .target( - module: .utilities, + module: .appTools, dependencies: [ .module(.appModels) ], @@ -405,7 +405,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .appDelegateClient, dependencies: [ - .module(.utilities), + .module(.appTools), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -451,8 +451,8 @@ let targets: [PackageDescription.Target] = [ module: .cookieClient, dependencies: [ .module(.appModels), + .module(.appTools), .module(.resources), - .module(.utilities), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -472,10 +472,10 @@ let targets: [PackageDescription.Target] = [ module: .networkingFeature, dependencies: [ .module(.appModels), + .module(.appTools), .module(.openCCExt), .module(.parserFeature), .module(.swiftyBeaverExt), - .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.deprecatedAPI), .targetDependency(.kanna) @@ -487,8 +487,8 @@ let targets: [PackageDescription.Target] = [ module: .databaseClient, dependencies: [ .module(.appModels), + .module(.appTools), .module(.swiftyBeaverExt), - .module(.utilities), .targetDependency(.composableArchitecture) ], resources: [.process(.resources)], @@ -498,8 +498,8 @@ let targets: [PackageDescription.Target] = [ .target( module: .hapticsClient, dependencies: [ + .module(.appTools), .module(.swiftUINavigationExt), - .module(.utilities), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -509,9 +509,9 @@ let targets: [PackageDescription.Target] = [ module: .appComponents, dependencies: [ .module(.appModels), + .module(.appTools), .module(.parserFeature), .module(.resources), - .module(.utilities), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols) ], @@ -523,8 +523,8 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appComponents), .module(.appModels), + .module(.appTools), .module(.resources), - .module(.utilities), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols), .targetDependency(.waterfallGrid) @@ -619,8 +619,8 @@ let targets: [PackageDescription.Target] = [ module: .readingSettingFeature, dependencies: [ .module(.appModels), - .module(.resources), - .module(.utilities) + .module(.appTools), + .module(.resources) ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins @@ -644,6 +644,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appComponents), .module(.appModels), + .module(.appTools), .module(.composableArchitectureExt), .module(.detailFeature), .module(.downloadClient), @@ -652,7 +653,6 @@ let targets: [PackageDescription.Target] = [ .module(.resources), .module(.swiftUINavigationExt), .module(.ttProgressHUDExt), - .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.sfSafeSymbols) ], @@ -664,6 +664,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appComponents), .module(.appModels), + .module(.appTools), .module(.composableArchitectureExt), .module(.databaseClient), .module(.dateSeekFeature), @@ -675,7 +676,6 @@ let targets: [PackageDescription.Target] = [ .module(.quickSearchFeature), .module(.resources), .module(.swiftUINavigationExt), - .module(.utilities), .targetDependency(.alertKit), .targetDependency(.composableArchitecture) ], @@ -688,6 +688,7 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appDelegateClient), .module(.appModels), + .module(.appTools), .module(.authorizationClient), .module(.clipboardClient), .module(.cookieClient), @@ -706,7 +707,6 @@ let targets: [PackageDescription.Target] = [ .module(.ttProgressHUDExt), .module(.uiApplicationClient), .module(.userDefaultsClient), - .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.filePicker), .targetDependency(.sfSafeSymbols) @@ -719,6 +719,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appComponents), .module(.appModels), + .module(.appTools), .module(.composableArchitectureExt), .module(.databaseClient), .module(.dateSeekFeature), @@ -731,7 +732,6 @@ let targets: [PackageDescription.Target] = [ .module(.quickSearchFeature), .module(.resources), .module(.swiftUINavigationExt), - .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols) @@ -745,6 +745,7 @@ let targets: [PackageDescription.Target] = [ .module(.alertKitExt), .module(.appComponents), .module(.appModels), + .module(.appTools), .module(.composableArchitectureExt), .module(.databaseClient), .module(.dateSeekFeature), @@ -758,7 +759,6 @@ let targets: [PackageDescription.Target] = [ .module(.quickSearchFeature), .module(.resources), .module(.swiftUINavigationExt), - .module(.utilities), .targetDependency(.alertKit), .targetDependency(.colorful), .targetDependency(.composableArchitecture), @@ -776,6 +776,7 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appLaunchAutomationClient), .module(.appModels), + .module(.appTools), .module(.clipboardClient), .module(.composableArchitectureExt), .module(.cookieClient), @@ -793,7 +794,6 @@ let targets: [PackageDescription.Target] = [ .module(.ttProgressHUDExt), .module(.uiApplicationClient), .module(.urlClient), - .module(.utilities), .targetDependency(.commonMark), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), @@ -808,6 +808,7 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appDelegateClient), .module(.appModels), + .module(.appTools), .module(.clipboardClient), .module(.cookieClient), .module(.databaseClient), @@ -823,7 +824,6 @@ let targets: [PackageDescription.Target] = [ .module(.swiftyBeaverExt), .module(.ttProgressHUDExt), .module(.urlClient), - .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), .targetDependency(.sdWebImageSwiftUI), @@ -839,7 +839,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.animatedImageFeature), - .module(.utilities), + .module(.appTools), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -850,7 +850,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.animatedImageFeature), - .module(.utilities), + .module(.appTools), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), .targetDependency(.sdWebImageSwiftUI), @@ -875,9 +875,9 @@ let targets: [PackageDescription.Target] = [ module: .parserFeature, dependencies: [ .module(.appModels), + .module(.appTools), .module(.resources), .module(.swiftyBeaverExt), - .module(.utilities), .targetDependency(.kanna) ], swiftSettings: sharedSwiftSettings, @@ -886,7 +886,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .uiApplicationClient, dependencies: [ - .module(.utilities), + .module(.appTools), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -896,7 +896,7 @@ let targets: [PackageDescription.Target] = [ module: .urlClient, dependencies: [ .module(.appModels), - .module(.utilities), + .module(.appTools), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -905,7 +905,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .userDefaultsClient, dependencies: [ - .module(.utilities), + .module(.appTools), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -920,6 +920,7 @@ let targets: [PackageDescription.Target] = [ .module(.appFeature), .module(.appLaunchAutomationClient), .module(.appModels), + .module(.appTools), .module(.backgroundProcessingClient), .module(.clipboardClient), .module(.cookieClient), @@ -941,7 +942,6 @@ let targets: [PackageDescription.Target] = [ .module(.uiApplicationClient), .module(.urlClient), .module(.userDefaultsClient), - .module(.utilities), .targetDependency(.composableArchitecture), .targetDependency(.kanna), .targetDependency(.kingfisher), diff --git a/AppPackage/Sources/AppComponents/AlertView.swift b/AppPackage/Sources/AppComponents/AlertView.swift index a8627c202..430f2091f 100644 --- a/AppPackage/Sources/AppComponents/AlertView.swift +++ b/AppPackage/Sources/AppComponents/AlertView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Resources import SFSafeSymbols -import Utilities +import AppTools public struct LoadingView: View { private let title: String diff --git a/AppPackage/Sources/AppComponents/CategoryView.swift b/AppPackage/Sources/AppComponents/CategoryView.swift index 4251e4302..afcddd05d 100644 --- a/AppPackage/Sources/AppComponents/CategoryView.swift +++ b/AppPackage/Sources/AppComponents/CategoryView.swift @@ -1,6 +1,6 @@ import SwiftUI import AppModels -import Utilities +import AppTools // MARK: CategoryLabel public struct CategoryLabel: View { diff --git a/AppPackage/Sources/AppComponents/NewDawnView.swift b/AppPackage/Sources/AppComponents/NewDawnView.swift index 4cc20f93c..bc1e890be 100644 --- a/AppPackage/Sources/AppComponents/NewDawnView.swift +++ b/AppPackage/Sources/AppComponents/NewDawnView.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import Resources -import Utilities +import AppTools public struct NewDawnView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/AppComponents/Placeholder.swift b/AppPackage/Sources/AppComponents/Placeholder.swift index 47e0e4fbe..61015c953 100644 --- a/AppPackage/Sources/AppComponents/Placeholder.swift +++ b/AppPackage/Sources/AppComponents/Placeholder.swift @@ -1,6 +1,6 @@ import SwiftUI import AppModels -import Utilities +import AppTools public struct Placeholder: View { @Environment(\.inSheet) private var inSheet diff --git a/AppPackage/Sources/AppComponents/PreviewImageView.swift b/AppPackage/Sources/AppComponents/PreviewImageView.swift index 6d8d77ef8..80a57a66d 100644 --- a/AppPackage/Sources/AppComponents/PreviewImageView.swift +++ b/AppPackage/Sources/AppComponents/PreviewImageView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import ImageIO import Kingfisher -import Utilities +import AppTools public struct PreviewImageView: View { private let originalURL: URL? diff --git a/AppPackage/Sources/AppComponents/RatingView.swift b/AppPackage/Sources/AppComponents/RatingView.swift index c1777ff83..c70882316 100644 --- a/AppPackage/Sources/AppComponents/RatingView.swift +++ b/AppPackage/Sources/AppComponents/RatingView.swift @@ -1,6 +1,6 @@ import SwiftUI import SFSafeSymbols -import Utilities +import AppTools public struct RatingView: View { private let rawRating: Float diff --git a/AppPackage/Sources/AppComponents/SubSection.swift b/AppPackage/Sources/AppComponents/SubSection.swift index 113811172..879b41a73 100644 --- a/AppPackage/Sources/AppComponents/SubSection.swift +++ b/AppPackage/Sources/AppComponents/SubSection.swift @@ -1,6 +1,6 @@ import SwiftUI import Resources -import Utilities +import AppTools public struct SubSection: View { private let title: String diff --git a/AppPackage/Sources/AppComponents/TagCloudView.swift b/AppPackage/Sources/AppComponents/TagCloudView.swift index e8365c38c..b0b5cb4f5 100644 --- a/AppPackage/Sources/AppComponents/TagCloudView.swift +++ b/AppPackage/Sources/AppComponents/TagCloudView.swift @@ -4,7 +4,7 @@ import SwiftUI import SFSafeSymbols import Kingfisher -import Utilities +import AppTools public struct TagCloudView: View where TagCell: View, Element: Equatable & Identifiable, ID == Element.ID { diff --git a/AppPackage/Sources/AppComponents/TagSuggestionView.swift b/AppPackage/Sources/AppComponents/TagSuggestionView.swift index d0eb712ef..ed01c2565 100644 --- a/AppPackage/Sources/AppComponents/TagSuggestionView.swift +++ b/AppPackage/Sources/AppComponents/TagSuggestionView.swift @@ -4,7 +4,7 @@ import AppModels import Resources import Kingfisher import Observation -import Utilities +import AppTools public struct TagSuggestionView: View { @Binding private var keyword: String diff --git a/AppPackage/Sources/AppComponents/ViewModifiers.swift b/AppPackage/Sources/AppComponents/ViewModifiers.swift index 436e483f7..26a76605a 100644 --- a/AppPackage/Sources/AppComponents/ViewModifiers.swift +++ b/AppPackage/Sources/AppComponents/ViewModifiers.swift @@ -1,7 +1,7 @@ import SwiftUI import Kingfisher import SFSafeSymbols -import Utilities +import AppTools import ParserFeature extension View { diff --git a/AppPackage/Sources/AppDelegateClient/AppDelegateClient.swift b/AppPackage/Sources/AppDelegateClient/AppDelegateClient.swift index 4ae7ebf47..d9c405e4d 100644 --- a/AppPackage/Sources/AppDelegateClient/AppDelegateClient.swift +++ b/AppPackage/Sources/AppDelegateClient/AppDelegateClient.swift @@ -1,6 +1,6 @@ import SwiftUI import ComposableArchitecture -import Utilities +import AppTools public struct AppDelegateClient: Sendable { public let setOrientation: @MainActor @Sendable (UIInterfaceOrientationMask) -> Void diff --git a/AppPackage/Sources/AppDelegateClient/AppOrientationMask.swift b/AppPackage/Sources/AppDelegateClient/AppOrientationMask.swift index 6d7e38f76..352bda4b1 100644 --- a/AppPackage/Sources/AppDelegateClient/AppOrientationMask.swift +++ b/AppPackage/Sources/AppDelegateClient/AppOrientationMask.swift @@ -1,5 +1,5 @@ import UIKit -import Utilities +import AppTools /// The app's current supported-interface-orientation mask. The orientation lock is /// owned by the orientation-setting client (written through `AppDelegateClient`), and diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index 4ff009a95..0e6d4e198 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -2,7 +2,7 @@ import SwiftUI import BackgroundTasks import SwiftyBeaver import ComposableArchitecture -import Utilities +import AppTools import AppDelegateClient import LibraryClient import DatabaseClient diff --git a/AppPackage/Sources/AppFeature/RootView.swift b/AppPackage/Sources/AppFeature/RootView.swift index 8ccbcfb30..db2e654e3 100644 --- a/AppPackage/Sources/AppFeature/RootView.swift +++ b/AppPackage/Sources/AppFeature/RootView.swift @@ -1,7 +1,7 @@ import ComposableArchitecture import SwiftUI import UIKit -import Utilities +import AppTools import MigrationFeature // MARK: RootView diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 75a591686..0d3c9fddc 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -3,7 +3,7 @@ import AppModels import Resources import SFSafeSymbols import ComposableArchitecture -import Utilities +import AppTools import TTProgressHUDExt import AppComponents import DetailFeature diff --git a/AppPackage/Sources/Utilities/.swiftlint.yml b/AppPackage/Sources/AppTools/.swiftlint.yml similarity index 100% rename from AppPackage/Sources/Utilities/.swiftlint.yml rename to AppPackage/Sources/AppTools/.swiftlint.yml diff --git a/AppPackage/Sources/Utilities/AppUtil.swift b/AppPackage/Sources/AppTools/AppUtil.swift similarity index 100% rename from AppPackage/Sources/Utilities/AppUtil.swift rename to AppPackage/Sources/AppTools/AppUtil.swift diff --git a/AppPackage/Sources/Utilities/CookieUtil.swift b/AppPackage/Sources/AppTools/CookieUtil.swift similarity index 100% rename from AppPackage/Sources/Utilities/CookieUtil.swift rename to AppPackage/Sources/AppTools/CookieUtil.swift diff --git a/AppPackage/Sources/Utilities/DataCache.swift b/AppPackage/Sources/AppTools/DataCache.swift similarity index 100% rename from AppPackage/Sources/Utilities/DataCache.swift rename to AppPackage/Sources/AppTools/DataCache.swift diff --git a/AppPackage/Sources/Utilities/Defaults+Runtime.swift b/AppPackage/Sources/AppTools/Defaults+Runtime.swift similarity index 100% rename from AppPackage/Sources/Utilities/Defaults+Runtime.swift rename to AppPackage/Sources/AppTools/Defaults+Runtime.swift diff --git a/AppPackage/Sources/Utilities/DeviceUtil.swift b/AppPackage/Sources/AppTools/DeviceUtil.swift similarity index 100% rename from AppPackage/Sources/Utilities/DeviceUtil.swift rename to AppPackage/Sources/AppTools/DeviceUtil.swift diff --git a/AppPackage/Sources/Utilities/Extensions.swift b/AppPackage/Sources/AppTools/Extensions.swift similarity index 100% rename from AppPackage/Sources/Utilities/Extensions.swift rename to AppPackage/Sources/AppTools/Extensions.swift diff --git a/AppPackage/Sources/Utilities/FileUtil.swift b/AppPackage/Sources/AppTools/FileUtil.swift similarity index 100% rename from AppPackage/Sources/Utilities/FileUtil.swift rename to AppPackage/Sources/AppTools/FileUtil.swift diff --git a/AppPackage/Sources/Utilities/HapticsUtil.swift b/AppPackage/Sources/AppTools/HapticsUtil.swift similarity index 100% rename from AppPackage/Sources/Utilities/HapticsUtil.swift rename to AppPackage/Sources/AppTools/HapticsUtil.swift diff --git a/AppPackage/Sources/Utilities/ImagePlaceholderFingerprint.swift b/AppPackage/Sources/AppTools/ImagePlaceholderFingerprint.swift similarity index 100% rename from AppPackage/Sources/Utilities/ImagePlaceholderFingerprint.swift rename to AppPackage/Sources/AppTools/ImagePlaceholderFingerprint.swift diff --git a/AppPackage/Sources/Utilities/TouchHandler.swift b/AppPackage/Sources/AppTools/TouchHandler.swift similarity index 100% rename from AppPackage/Sources/Utilities/TouchHandler.swift rename to AppPackage/Sources/AppTools/TouchHandler.swift diff --git a/AppPackage/Sources/Utilities/URL+Components.swift b/AppPackage/Sources/AppTools/URL+Components.swift similarity index 100% rename from AppPackage/Sources/Utilities/URL+Components.swift rename to AppPackage/Sources/AppTools/URL+Components.swift diff --git a/AppPackage/Sources/Utilities/URL+ImageCacheKey.swift b/AppPackage/Sources/AppTools/URL+ImageCacheKey.swift similarity index 100% rename from AppPackage/Sources/Utilities/URL+ImageCacheKey.swift rename to AppPackage/Sources/AppTools/URL+ImageCacheKey.swift diff --git a/AppPackage/Sources/Utilities/URLUtil.swift b/AppPackage/Sources/AppTools/URLUtil.swift similarity index 100% rename from AppPackage/Sources/Utilities/URLUtil.swift rename to AppPackage/Sources/AppTools/URLUtil.swift diff --git a/AppPackage/Sources/Utilities/UserDefaultsUtil.swift b/AppPackage/Sources/AppTools/UserDefaultsUtil.swift similarity index 100% rename from AppPackage/Sources/Utilities/UserDefaultsUtil.swift rename to AppPackage/Sources/AppTools/UserDefaultsUtil.swift diff --git a/AppPackage/Sources/CookieClient/CookieClient.swift b/AppPackage/Sources/CookieClient/CookieClient.swift index cca952fe8..5b89da7f4 100644 --- a/AppPackage/Sources/CookieClient/CookieClient.swift +++ b/AppPackage/Sources/CookieClient/CookieClient.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import Resources import ComposableArchitecture -import Utilities +import AppTools #if DEBUG import Synchronization #endif diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift index 0532a6f93..0bb9eade9 100644 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift +++ b/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift @@ -1,6 +1,6 @@ import CoreData import AppModels -import Utilities +import AppTools public class AppEnvMO: NSManagedObject {} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift index 4db76c76c..2dd77b12e 100644 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift +++ b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift @@ -1,6 +1,6 @@ import CoreData import AppModels -import Utilities +import AppTools public class GalleryDetailMO: NSManagedObject {} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift index 4610e81d7..18e2b8a4d 100644 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift +++ b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift @@ -1,6 +1,6 @@ import CoreData import AppModels -import Utilities +import AppTools public class GalleryMO: NSManagedObject {} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift index 4fa40b06e..dc147ddcc 100644 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift +++ b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import CoreData -import Utilities +import AppTools public class GalleryStateMO: NSManagedObject {} diff --git a/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift b/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift index a0da3bd08..8c2d8ace1 100644 --- a/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift +++ b/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import CoreData -import Utilities +import AppTools // MARK: UpdateGalleryState extension DatabaseClient { diff --git a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift index 2cac27a74..451a327a8 100644 --- a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift +++ b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift @@ -4,7 +4,7 @@ import SwiftyBeaverExt import Combine import CoreData import ComposableArchitecture -import Utilities +import AppTools public struct DatabaseClient: Sendable { public let prepareDatabase: @Sendable () async -> Result diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index 1d79aa3ac..20613ca56 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import Resources import ComposableArchitecture -import Utilities +import AppTools import HapticsClient import DatabaseClient import NetworkingFeature diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift index 80c4634a9..5d7b8a0f0 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import Utilities +import AppTools import TTProgressHUDExt import AppComponents diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index dac238ceb..c9097e32f 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -4,7 +4,7 @@ import Resources import Kingfisher import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import TTProgressHUDExt import AppComponents diff --git a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift index bb12c197d..0c18d59fe 100644 --- a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift +++ b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Resources import Kingfisher -import Utilities +import AppTools import AppComponents struct TagDetailView: View { diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift index 753b5edf3..c02bbb6b3 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift @@ -1,7 +1,7 @@ import Foundation import AppModels import ComposableArchitecture -import Utilities +import AppTools // MARK: - Download Action Handlers extension DetailReducer { diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift index d007b30e6..c5b0e12d8 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents import GalleryListComponents import FiltersFeature diff --git a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift index 71b0eb3a4..63a61808f 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift @@ -3,7 +3,7 @@ import AppModels import Resources import Kingfisher import SFSafeSymbols -import Utilities +import AppTools import AppComponents // MARK: HeaderSection diff --git a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift index d70651b21..f9c7a0107 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift @@ -2,7 +2,7 @@ import SwiftUI import Resources import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents // MARK: NavigationLinks diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index 216467d26..72dcaa72e 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Resources import Kingfisher -import Utilities +import AppTools import AppComponents // MARK: DescriptionSection diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index b25932778..8a1c6c1fe 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -4,7 +4,7 @@ import Resources import Kingfisher import ComposableArchitecture import CommonMark -import Utilities +import AppTools import AppComponents import ReadingFeature diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index b41e084ed..a1aa7d88b 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import Utilities +import AppTools import TTProgressHUDExt struct GalleryInfosView: View { diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index 1b7a6e899..258dfcab8 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -1,7 +1,7 @@ import Foundation import AppModels import ComposableArchitecture -import Utilities +import AppTools import SwiftUINavigationExt import HapticsClient import DatabaseClient diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift index 60da805d9..cfe65d517 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import Utilities +import AppTools import AppComponents import ReadingFeature diff --git a/AppPackage/Sources/DeviceClient/DeviceClient.swift b/AppPackage/Sources/DeviceClient/DeviceClient.swift index faf99ab22..44171eacd 100644 --- a/AppPackage/Sources/DeviceClient/DeviceClient.swift +++ b/AppPackage/Sources/DeviceClient/DeviceClient.swift @@ -1,6 +1,6 @@ import SwiftUI import Dependencies -import Utilities +import AppTools public struct DeviceClient: Sendable { public let isPad: @Sendable () async -> Bool diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift index cd18d7418..2cf234d3c 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift @@ -1,6 +1,6 @@ import Foundation import AppModels -import Utilities +import AppTools // MARK: - Cache Operations extension DownloadCoordinator { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift index f1d487809..1c357add7 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift @@ -3,7 +3,7 @@ import AppModels import SwiftyBeaverExt import Foundation import ImageIO -import Utilities +import AppTools import ParserFeature // MARK: - Response Error Detection diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift index 21186deeb..35f06cc53 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift @@ -2,7 +2,7 @@ import Kanna import AppModels import Foundation import ImageIO -import Utilities +import AppTools import AnimatedImageFeature import ParserFeature diff --git a/AppPackage/Sources/DownloadClient/DownloadClient.swift b/AppPackage/Sources/DownloadClient/DownloadClient.swift index 45ea66ab6..90dc65d67 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient.swift @@ -1,7 +1,7 @@ import Foundation import AppModels import ComposableArchitecture -import Utilities +import AppTools import DatabaseClient @DependencyClient diff --git a/AppPackage/Sources/DownloadClient/DownloadStore.swift b/AppPackage/Sources/DownloadClient/DownloadStore.swift index ab4eb15a5..72c1b1fb2 100644 --- a/AppPackage/Sources/DownloadClient/DownloadStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import Resources import CryptoKit -import Utilities +import AppTools public enum DownloadValidationState: Equatable, Sendable { case valid diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 8640720ae..c4e617b1a 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -1,7 +1,7 @@ import Foundation import AppModels import ComposableArchitecture -import Utilities +import AppTools import DownloadClient import ReadingFeature import DetailFeature diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index 464029e31..8549ee92c 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -4,7 +4,7 @@ import Resources import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents import ReadingFeature import DetailFeature diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index db5f3a669..b3b3d75d6 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -4,7 +4,7 @@ import Resources import AlertKit import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents import DateSeekFeature import GalleryListComponents diff --git a/AppPackage/Sources/FileClient/FileClient.swift b/AppPackage/Sources/FileClient/FileClient.swift index de76ac474..b2a72698a 100644 --- a/AppPackage/Sources/FileClient/FileClient.swift +++ b/AppPackage/Sources/FileClient/FileClient.swift @@ -2,7 +2,7 @@ import Combine import AppModels import Foundation import ComposableArchitecture -import Utilities +import AppTools public struct FileClient: Sendable { public let createFile: @Sendable (String, Data?) -> Bool diff --git a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift index 0cdb3ee70..71c7bc18d 100644 --- a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift @@ -3,7 +3,7 @@ import SFSafeSymbols import AppModels import AppComponents import Kingfisher -import Utilities +import AppTools public struct GalleryDetailCell: View { public enum CoverSource { diff --git a/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift index 56a902633..4b12cfb5e 100644 --- a/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift @@ -3,7 +3,7 @@ import SFSafeSymbols import AppModels import AppComponents import Kingfisher -import Utilities +import AppTools public struct GalleryThumbnailCell: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/GalleryListComponents/GenericList.swift b/AppPackage/Sources/GalleryListComponents/GenericList.swift index f82f2812e..b3a7c9132 100644 --- a/AppPackage/Sources/GalleryListComponents/GenericList.swift +++ b/AppPackage/Sources/GalleryListComponents/GenericList.swift @@ -3,7 +3,7 @@ import SFSafeSymbols import AppModels import AppComponents import WaterfallGrid -import Utilities +import AppTools public struct GenericList: View { private let galleries: [Gallery] diff --git a/AppPackage/Sources/HapticsClient/HapticsClient.swift b/AppPackage/Sources/HapticsClient/HapticsClient.swift index 2d33c2c15..a60f08025 100644 --- a/AppPackage/Sources/HapticsClient/HapticsClient.swift +++ b/AppPackage/Sources/HapticsClient/HapticsClient.swift @@ -1,6 +1,6 @@ import SwiftUI import ComposableArchitecture -import Utilities +import AppTools public struct HapticsClient: Sendable { public let generateFeedback: @MainActor @Sendable (UIImpactFeedbackGenerator.FeedbackStyle) -> Void diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index 13c53beae..d75b4cc3b 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -1,7 +1,7 @@ import ComposableArchitecture import AppModels import Foundation -import Utilities +import AppTools import SwiftUINavigationExt import HapticsClient import DatabaseClient diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 74f788b5a..b423c1648 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -4,7 +4,7 @@ import Resources import AlertKit import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents import DateSeekFeature import GalleryListComponents diff --git a/AppPackage/Sources/HomeFeature/GalleryCardCell.swift b/AppPackage/Sources/HomeFeature/GalleryCardCell.swift index ccdac1b0f..b92003770 100644 --- a/AppPackage/Sources/HomeFeature/GalleryCardCell.swift +++ b/AppPackage/Sources/HomeFeature/GalleryCardCell.swift @@ -4,7 +4,7 @@ import AppComponents import Colorful import Kingfisher import UIImageColors -import Utilities +import AppTools public struct GalleryCardCell: View { @Environment(\.colorScheme) private var colorScheme diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index 91bc74e44..ba06d1086 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -1,7 +1,7 @@ import Foundation import AppModels import ComposableArchitecture -import Utilities +import AppTools import HapticsClient import DatabaseClient import DownloadClient diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index 2da6e14aa..bf164662e 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents import GalleryListComponents import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/HomeReducer.swift b/AppPackage/Sources/HomeFeature/HomeReducer.swift index dc23f8ef4..b401d3c38 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Kingfisher import ComposableArchitecture -import Utilities +import AppTools import LibraryClient import DatabaseClient import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift index aaafcc238..f57f24a4a 100644 --- a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift +++ b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift @@ -4,7 +4,7 @@ import Resources import Kingfisher import SwiftUIPager import SFSafeSymbols -import Utilities +import AppTools import AppComponents // MARK: CardSlideSection diff --git a/AppPackage/Sources/HomeFeature/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift index 5ba2277ca..293a22e9b 100644 --- a/AppPackage/Sources/HomeFeature/HomeView.swift +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -5,7 +5,7 @@ import Kingfisher import SFSafeSymbols import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents import DetailFeature diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index f79d360d6..7fd7164d0 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import AppModels -import Utilities +import AppTools import SwiftUINavigationExt import HapticsClient import DatabaseClient diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index 21ec89359..bb6bf1822 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents import GalleryListComponents import FiltersFeature diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index 65eb7026d..c8a8535d6 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import AppModels -import Utilities +import AppTools import HapticsClient import DatabaseClient import NetworkingFeature diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index 8bc045997..0a7d453e2 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents import GalleryListComponents import AlertKitExt diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index 446b4edc5..7beabb709 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents import DateSeekFeature import GalleryListComponents diff --git a/AppPackage/Sources/ImageClient/ImageClient.swift b/AppPackage/Sources/ImageClient/ImageClient.swift index 1bafe5a46..6a708c490 100644 --- a/AppPackage/Sources/ImageClient/ImageClient.swift +++ b/AppPackage/Sources/ImageClient/ImageClient.swift @@ -3,7 +3,7 @@ import AppModels import SwiftUI import Combine import ComposableArchitecture -import Utilities +import AppTools import AnimatedImageFeature public struct ImageClient: Sendable { diff --git a/AppPackage/Sources/LibraryClient/LibraryClient.swift b/AppPackage/Sources/LibraryClient/LibraryClient.swift index bba2b811e..df94e39ec 100644 --- a/AppPackage/Sources/LibraryClient/LibraryClient.swift +++ b/AppPackage/Sources/LibraryClient/LibraryClient.swift @@ -8,7 +8,7 @@ import SDWebImageWebPCoder import SwiftyBeaver import UIImageColors import ComposableArchitecture -import Utilities +import AppTools import AnimatedImageFeature public struct LibraryClient: Sendable { diff --git a/AppPackage/Sources/NetworkingFeature/DFExtensions.swift b/AppPackage/Sources/NetworkingFeature/DFExtensions.swift index 0842a79f3..01e8211ec 100644 --- a/AppPackage/Sources/NetworkingFeature/DFExtensions.swift +++ b/AppPackage/Sources/NetworkingFeature/DFExtensions.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import SwiftyBeaverExt import DeprecatedAPI -import Utilities +import AppTools // MARK: Global private func forceDowncast(object: Any) -> T! { diff --git a/AppPackage/Sources/NetworkingFeature/Request+Account.swift b/AppPackage/Sources/NetworkingFeature/Request+Account.swift index 2fc269412..162535062 100644 --- a/AppPackage/Sources/NetworkingFeature/Request+Account.swift +++ b/AppPackage/Sources/NetworkingFeature/Request+Account.swift @@ -2,7 +2,7 @@ import Kanna import AppModels import Combine import Foundation -import Utilities +import AppTools import ParserFeature // MARK: Account Ops diff --git a/AppPackage/Sources/NetworkingFeature/Request+Detail.swift b/AppPackage/Sources/NetworkingFeature/Request+Detail.swift index 107545d17..442e3129a 100644 --- a/AppPackage/Sources/NetworkingFeature/Request+Detail.swift +++ b/AppPackage/Sources/NetworkingFeature/Request+Detail.swift @@ -2,7 +2,7 @@ import Kanna import AppModels import Combine import Foundation -import Utilities +import AppTools import ParserFeature // MARK: Response Types diff --git a/AppPackage/Sources/NetworkingFeature/Request+Gallery.swift b/AppPackage/Sources/NetworkingFeature/Request+Gallery.swift index 484e6b6c8..937cd17a5 100644 --- a/AppPackage/Sources/NetworkingFeature/Request+Gallery.swift +++ b/AppPackage/Sources/NetworkingFeature/Request+Gallery.swift @@ -2,7 +2,7 @@ import Kanna import AppModels import Combine import Foundation -import Utilities +import AppTools import ParserFeature // MARK: Fetch ListItems diff --git a/AppPackage/Sources/NetworkingFeature/Request+Image.swift b/AppPackage/Sources/NetworkingFeature/Request+Image.swift index 65643c00b..e6fc85893 100644 --- a/AppPackage/Sources/NetworkingFeature/Request+Image.swift +++ b/AppPackage/Sources/NetworkingFeature/Request+Image.swift @@ -2,7 +2,7 @@ import Kanna import AppModels import Combine import Foundation -import Utilities +import AppTools import ParserFeature // MARK: Response Types diff --git a/AppPackage/Sources/NetworkingFeature/Request.swift b/AppPackage/Sources/NetworkingFeature/Request.swift index eb004e63e..b878e3080 100644 --- a/AppPackage/Sources/NetworkingFeature/Request.swift +++ b/AppPackage/Sources/NetworkingFeature/Request.swift @@ -4,7 +4,7 @@ import Combine import Foundation import ComposableArchitecture import OpenCCExt -import Utilities +import AppTools import ParserFeature public protocol Request { diff --git a/AppPackage/Sources/ParserFeature/Parser+Detail.swift b/AppPackage/Sources/ParserFeature/Parser+Detail.swift index 09eb946fb..a5e6e7454 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Detail.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Detail.swift @@ -1,7 +1,7 @@ import Kanna import AppModels import Foundation -import Utilities +import AppTools extension Parser { public static func parseGalleryURL(doc: HTMLDocument) throws -> URL { diff --git a/AppPackage/Sources/ParserFeature/Parser+Preview.swift b/AppPackage/Sources/ParserFeature/Parser+Preview.swift index 7ff3440b5..aa38fde54 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Preview.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Preview.swift @@ -1,7 +1,7 @@ import Kanna import AppModels import Foundation -import Utilities +import AppTools extension Parser { public static func parsePreviewURLs(doc: HTMLDocument) throws -> [Int: URL] { diff --git a/AppPackage/Sources/ParserFeature/Parser+Shared.swift b/AppPackage/Sources/ParserFeature/Parser+Shared.swift index 05e9892e9..0c28eeac2 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Shared.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Shared.swift @@ -2,7 +2,7 @@ import Kanna import AppModels import SwiftyBeaverExt import Foundation -import Utilities +import AppTools extension Parser { static func parseGTX00IndexFromTitle(from title: String) -> Int? { diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index 8caacfc23..2ad2782d1 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -2,7 +2,7 @@ import SwiftUI import Kingfisher import TTProgressHUD import ComposableArchitecture -import Utilities +import AppTools import SwiftUINavigationExt // MARK: - CancelID diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift index fcc80b6d9..c1e0943b7 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import ComposableArchitecture -import Utilities +import AppTools // MARK: - Database & Download Actions extension ReadingReducer { diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift index 603b1d308..d0e1dcf93 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift @@ -1,6 +1,6 @@ import Foundation import ComposableArchitecture -import Utilities +import AppTools import NetworkingFeature // MARK: - Image URL Fetch Actions diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 6a676fea3..207c3d59b 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -5,7 +5,7 @@ import Observation import SFSafeSymbols import SwiftUIPager import ComposableArchitecture -import Utilities +import AppTools import AnimatedImageFeature import TTProgressHUDExt import AppComponents diff --git a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift index f580fe3a7..23a7c7893 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift @@ -5,7 +5,7 @@ import Kingfisher import SDWebImage import SDWebImageSwiftUI import ComposableArchitecture -import Utilities +import AppTools import ImageClient import AppComponents import SFSafeSymbols diff --git a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift index f87a3c190..3c7e34c17 100644 --- a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift +++ b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import Resources -import Utilities +import AppTools import AppComponents import SFSafeSymbols diff --git a/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift b/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift index b73bfde99..ecbcd2eba 100644 --- a/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift +++ b/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import SwiftyBeaverExt import Observation -import Utilities +import AppTools @Observable @MainActor diff --git a/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift b/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift index 4bfdadd0b..fc1ec985a 100644 --- a/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift +++ b/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import SwiftyBeaverExt import Observation -import Utilities +import AppTools @Observable @MainActor diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index 633be88a8..0e23813ef 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import Resources -import Utilities +import AppTools public struct ReadingSettingView: View { @Binding private var readingDirection: ReadingDirection diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift index 6823c1826..e4740d07c 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import AppModels -import Utilities +import AppTools import SwiftUINavigationExt import HapticsClient import DatabaseClient diff --git a/AppPackage/Sources/SearchFeature/SearchRootView+Keywords.swift b/AppPackage/Sources/SearchFeature/SearchRootView+Keywords.swift index 8f21c6a82..010d25816 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView+Keywords.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView+Keywords.swift @@ -1,6 +1,6 @@ import SwiftUI import SFSafeSymbols -import Utilities +import AppTools // MARK: DoubleVerticalKeywordsStack struct DoubleVerticalKeywordsStack: View { diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index eb1059674..79e9328b8 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents import FiltersFeature import QuickSearchFeature diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift index 1e1729644..4a30c8524 100644 --- a/AppPackage/Sources/SearchFeature/SearchView.swift +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents import DateSeekFeature import GalleryListComponents diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index 12afc9c19..aae5ad1df 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -4,7 +4,7 @@ import AppComponents import Resources import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import TTProgressHUDExt struct AccountSettingView: View { diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index 427622245..81e4d29a2 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -1,6 +1,6 @@ import SwiftUI import Resources -import Utilities +import AppTools import AppComponents struct AboutView: View { diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift index 514015d6b..458ca609b 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import Utilities +import AppTools import SwiftUINavigationExt import AppComponents diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift index fd00619ed..7ebcfe23f 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import Resources -import Utilities +import AppTools import AppComponents extension EhSettingView { diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift index da04c2c5d..522f0eb49 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import Resources -import Utilities +import AppTools extension EhSettingView { diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift index 7c6ae6f57..014cd1f30 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import SwiftUINavigationExt -import Utilities +import AppTools import AppComponents struct EhSettingView: View { diff --git a/AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift b/AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift index 0ceee0708..0c078f196 100644 --- a/AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift +++ b/AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift @@ -1,7 +1,7 @@ import SwiftUI import Combine import ComposableArchitecture -import Utilities +import AppTools public struct UIApplicationClient: Sendable { public let openURL: @MainActor @Sendable (URL) -> Void diff --git a/AppPackage/Sources/URLClient/URLClient.swift b/AppPackage/Sources/URLClient/URLClient.swift index adbfcc4f3..ebf82bf7f 100644 --- a/AppPackage/Sources/URLClient/URLClient.swift +++ b/AppPackage/Sources/URLClient/URLClient.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import Dependencies -import Utilities +import AppTools public struct URLAnalysisResult: Sendable { public let isGalleryImageURL: Bool diff --git a/AppPackage/Sources/UserDefaultsClient/UserDefaultsClient.swift b/AppPackage/Sources/UserDefaultsClient/UserDefaultsClient.swift index aa3b2ef40..ac15a8062 100644 --- a/AppPackage/Sources/UserDefaultsClient/UserDefaultsClient.swift +++ b/AppPackage/Sources/UserDefaultsClient/UserDefaultsClient.swift @@ -1,6 +1,6 @@ import Foundation import ComposableArchitecture -import Utilities +import AppTools public struct UserDefaultsClient: Sendable { public let setValue: @Sendable (Any, AppUserDefaults) -> Void diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift index af21e2d04..d2239371e 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -import Utilities +import AppTools @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift index bc800cb2e..686d7bb9d 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift @@ -4,7 +4,7 @@ import Foundation import SFSafeSymbols import ComposableArchitecture import Testing -import Utilities +import AppTools @testable import AppFeature struct DownloadBadgeSortTests: DownloadFeatureTestCase { diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift index 69bdeb076..bf144496c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift @@ -2,7 +2,7 @@ import UIKit import AppModels import Foundation import Testing -import Utilities +import AppTools import DownloadClient @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift index 89d15e2ff..e69bacec1 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -2,7 +2,7 @@ import CoreData import AppModels import Foundation import Testing -import Utilities +import AppTools import DatabaseClient import DownloadClient import AppLaunchAutomationClient diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift index 8f2aee468..5eac3a8ba 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift @@ -4,7 +4,7 @@ import Foundation import SFSafeSymbols import ComposableArchitecture import Testing -import Utilities +import AppTools @testable import DownloadsFeature @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift index 73bfaa61d..e9551fdf2 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift @@ -4,7 +4,7 @@ import Kingfisher import UIKit import Foundation import Testing -import Utilities +import AppTools @testable import AppFeature @Suite(.serialized) diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift index 2a7eef2fb..8e9dcc232 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift @@ -4,7 +4,7 @@ import Kingfisher import UIKit import Foundation import Testing -import Utilities +import AppTools import DownloadClient @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift index 8f9a62625..8770b6d73 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing -import Utilities +import AppTools import URLClient import HapticsClient import ImageClient diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift index 7d217341e..b938c78d6 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift @@ -2,7 +2,7 @@ import UIKit import AppModels import Foundation import Testing -import Utilities +import AppTools import LibraryClient import DownloadClient @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift index b0076dc03..68fce904b 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import Testing import UIKit -import Utilities +import AppTools import ImageClient @testable import AppFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift index 5c94b4d78..eece044ad 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing -import Utilities +import AppTools import URLClient import HapticsClient import ImageClient diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/SettingDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/SettingDownloadTests.swift index 53c22ff95..dd057d6c5 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/SettingDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/SettingDownloadTests.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import Testing -import Utilities +import AppTools import URLClient @testable import AppFeature From efbf479c2975d82baa4c407d5c8b3da4623e0223 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 16:36:43 +0800 Subject: [PATCH 360/614] Rename UIApplicationClient to ApplicationClient --- AppPackage/Package.swift | 12 +++++------ .../.swiftlint.yml | 0 .../ApplicationClient.swift} | 20 +++++++++---------- .../Comments/CommentsReducer.swift | 6 +++--- .../EhSetting/EhSettingReducer.swift | 6 +++--- .../GeneralSettingReducer.swift | 6 +++--- .../SettingFeature/Logs/LogsReducer.swift | 6 +++--- .../SettingFeature/SettingReducer+Body.swift | 6 +++--- .../SettingFeature/SettingReducer.swift | 4 ++-- .../Download/DownloadAutomationTests.swift | 8 ++++---- 10 files changed, 37 insertions(+), 37 deletions(-) rename AppPackage/Sources/{UIApplicationClient => ApplicationClient}/.swiftlint.yml (100%) rename AppPackage/Sources/{UIApplicationClient/UIApplicationClient.swift => ApplicationClient/ApplicationClient.swift} (83%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 8605f2419..d2290166c 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -76,6 +76,7 @@ enum Module: String { case appLaunchAutomationClient = "AppLaunchAutomationClient" case appModels = "AppModels" case appTools = "AppTools" + case applicationClient = "ApplicationClient" case authorizationClient = "AuthorizationClient" case backgroundProcessingClient = "BackgroundProcessingClient" case clipboardClient = "ClipboardClient" @@ -111,7 +112,6 @@ enum Module: String { case swiftUINavigationExt = "SwiftUINavigationExt" case swiftyBeaverExt = "SwiftyBeaverExt" case ttProgressHUDExt = "TTProgressHUDExt" - case uiApplicationClient = "UIApplicationClient" case urlClient = "URLClient" case userDefaultsClient = "UserDefaultsClient" @@ -255,6 +255,7 @@ let targets: [PackageDescription.Target] = [ .module(.appLaunchAutomationClient), .module(.appModels), .module(.appTools), + .module(.applicationClient), .module(.authorizationClient), .module(.backgroundProcessingClient), .module(.clipboardClient), @@ -287,7 +288,6 @@ let targets: [PackageDescription.Target] = [ .module(.swiftUINavigationExt), .module(.swiftyBeaverExt), .module(.ttProgressHUDExt), - .module(.uiApplicationClient), .module(.urlClient), .module(.userDefaultsClient), .targetDependency(.alertKit), @@ -689,6 +689,7 @@ let targets: [PackageDescription.Target] = [ .module(.appDelegateClient), .module(.appModels), .module(.appTools), + .module(.applicationClient), .module(.authorizationClient), .module(.clipboardClient), .module(.cookieClient), @@ -705,7 +706,6 @@ let targets: [PackageDescription.Target] = [ .module(.swiftUINavigationExt), .module(.swiftyBeaverExt), .module(.ttProgressHUDExt), - .module(.uiApplicationClient), .module(.userDefaultsClient), .targetDependency(.composableArchitecture), .targetDependency(.filePicker), @@ -777,6 +777,7 @@ let targets: [PackageDescription.Target] = [ .module(.appLaunchAutomationClient), .module(.appModels), .module(.appTools), + .module(.applicationClient), .module(.clipboardClient), .module(.composableArchitectureExt), .module(.cookieClient), @@ -792,7 +793,6 @@ let targets: [PackageDescription.Target] = [ .module(.resources), .module(.swiftUINavigationExt), .module(.ttProgressHUDExt), - .module(.uiApplicationClient), .module(.urlClient), .targetDependency(.commonMark), .targetDependency(.composableArchitecture), @@ -884,7 +884,7 @@ let targets: [PackageDescription.Target] = [ plugins: swiftLintPlugins ), .target( - module: .uiApplicationClient, + module: .applicationClient, dependencies: [ .module(.appTools), .targetDependency(.composableArchitecture) @@ -921,6 +921,7 @@ let targets: [PackageDescription.Target] = [ .module(.appLaunchAutomationClient), .module(.appModels), .module(.appTools), + .module(.applicationClient), .module(.backgroundProcessingClient), .module(.clipboardClient), .module(.cookieClient), @@ -939,7 +940,6 @@ let targets: [PackageDescription.Target] = [ .module(.parserFeature), .module(.readingFeature), .module(.animatedImageFeature), - .module(.uiApplicationClient), .module(.urlClient), .module(.userDefaultsClient), .targetDependency(.composableArchitecture), diff --git a/AppPackage/Sources/UIApplicationClient/.swiftlint.yml b/AppPackage/Sources/ApplicationClient/.swiftlint.yml similarity index 100% rename from AppPackage/Sources/UIApplicationClient/.swiftlint.yml rename to AppPackage/Sources/ApplicationClient/.swiftlint.yml diff --git a/AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift b/AppPackage/Sources/ApplicationClient/ApplicationClient.swift similarity index 83% rename from AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift rename to AppPackage/Sources/ApplicationClient/ApplicationClient.swift index 0c078f196..02f62463e 100644 --- a/AppPackage/Sources/UIApplicationClient/UIApplicationClient.swift +++ b/AppPackage/Sources/ApplicationClient/ApplicationClient.swift @@ -3,7 +3,7 @@ import Combine import ComposableArchitecture import AppTools -public struct UIApplicationClient: Sendable { +public struct ApplicationClient: Sendable { public let openURL: @MainActor @Sendable (URL) -> Void public let hideKeyboard: @Sendable () async -> Void public let alternateIconName: @MainActor @Sendable () -> String? @@ -11,7 +11,7 @@ public struct UIApplicationClient: Sendable { public let setUserInterfaceStyle: @MainActor @Sendable (UIUserInterfaceStyle) -> Void } -extension UIApplicationClient { +extension ApplicationClient { public static let live: Self = .init( openURL: { url in UIApplication.shared.open(url, options: [:]) @@ -55,21 +55,21 @@ extension UIApplicationClient { } // MARK: API -public enum UIApplicationClientKey: DependencyKey { - public static let liveValue = UIApplicationClient.live - public static let previewValue = UIApplicationClient.noop - public static let testValue = UIApplicationClient.unimplemented +public enum ApplicationClientKey: DependencyKey { + public static let liveValue = ApplicationClient.live + public static let previewValue = ApplicationClient.noop + public static let testValue = ApplicationClient.unimplemented } extension DependencyValues { - public var uiApplicationClient: UIApplicationClient { - get { self[UIApplicationClientKey.self] } - set { self[UIApplicationClientKey.self] = newValue } + public var applicationClient: ApplicationClient { + get { self[ApplicationClientKey.self] } + set { self[ApplicationClientKey.self] = newValue } } } // MARK: Test -extension UIApplicationClient { +extension ApplicationClient { public static let noop: Self = .init( openURL: { _ in}, hideKeyboard: {}, diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index 00f289f00..cfa0b72ef 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -3,7 +3,7 @@ import AppModels import ComposableArchitecture import SwiftUINavigationExt import URLClient -import UIApplicationClient +import ApplicationClient import HapticsClient import DatabaseClient import NetworkingFeature @@ -69,7 +69,7 @@ public struct CommentsReducer: Sendable { case detail(DetailReducer.Action) } - @Dependency(\.uiApplicationClient) private var uiApplicationClient + @Dependency(\.applicationClient) private var applicationClient @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient @@ -136,7 +136,7 @@ public struct CommentsReducer: Sendable { case .handleCommentLink(let url): guard urlClient.checkIfHandleable(url) else { - return .run(operation: { _ in await uiApplicationClient.openURL(url) }) + return .run(operation: { _ in await applicationClient.openURL(url) }) } let analysis = urlClient.analyzeURL(url) let gid = urlClient.parseGalleryID(url) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index 1b1342f67..c1133b483 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import SwiftUINavigationExt -import UIApplicationClient +import ApplicationClient import HapticsClient import NetworkingFeature import CookieClient @@ -52,7 +52,7 @@ public struct EhSettingReducer: Sendable { case performActionDone(Result) } - @Dependency(\.uiApplicationClient) private var uiApplicationClient + @Dependency(\.applicationClient) private var applicationClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient @@ -71,7 +71,7 @@ public struct EhSettingReducer: Sendable { return .none case .setKeyboardHidden: - return .run(operation: { _ in await uiApplicationClient.hideKeyboard() }) + return .run(operation: { _ in await applicationClient.hideKeyboard() }) case .setDefaultProfile(let profileSet): return .run { _ in diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index 0e3c5fb96..adb9263f4 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -2,7 +2,7 @@ import LocalAuthentication import AppModels import ComposableArchitecture import AuthorizationClient -import UIApplicationClient +import ApplicationClient import LibraryClient import DatabaseClient @@ -43,7 +43,7 @@ public struct GeneralSettingReducer: Sendable { } @Dependency(\.authorizationClient) private var authorizationClient - @Dependency(\.uiApplicationClient) private var uiApplicationClient + @Dependency(\.applicationClient) private var applicationClient @Dependency(\.databaseClient) private var databaseClient @Dependency(\.libraryClient) private var libraryClient @@ -89,7 +89,7 @@ public struct GeneralSettingReducer: Sendable { return .none case .navigateToSystemSetting: - return .run(operation: { _ in await uiApplicationClient.openSettings() }) + return .run(operation: { _ in await applicationClient.openSettings() }) case .calculateWebImageDiskCache: return .run { send in diff --git a/AppPackage/Sources/SettingFeature/Logs/LogsReducer.swift b/AppPackage/Sources/SettingFeature/Logs/LogsReducer.swift index eb6ddb550..237f89dd6 100644 --- a/AppPackage/Sources/SettingFeature/Logs/LogsReducer.swift +++ b/AppPackage/Sources/SettingFeature/Logs/LogsReducer.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import AppModels -import UIApplicationClient +import ApplicationClient import FileClient @Reducer @@ -33,7 +33,7 @@ public struct LogsReducer: Sendable { case deleteLogDone(Result) } - @Dependency(\.uiApplicationClient) private var uiApplicationClient + @Dependency(\.applicationClient) private var applicationClient @Dependency(\.fileClient) private var fileClient public init() {} @@ -51,7 +51,7 @@ public struct LogsReducer: Sendable { return .none case .navigateToFileApp: - return .run(operation: { _ in await uiApplicationClient.openFileApp() }) + return .run(operation: { _ in await applicationClient.openFileApp() }) case .teardown: return .cancel(id: CancelID.fetchLogs) diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index a966330b7..b24c2a267 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -37,7 +37,7 @@ extension SettingReducer { .merge( .send(.syncSetting), .run { [value = state.setting.appIconType.filename] send in - _ = await uiApplicationClient.setAlternateIconName(value) + _ = await applicationClient.setAlternateIconName(value) await send(.syncAppIconType) } ) @@ -109,7 +109,7 @@ extension SettingReducer { case .syncAppIconType: return .run { send in - await send(.syncAppIconTypeDone(await uiApplicationClient.alternateIconName())) + await send(.syncAppIconTypeDone(await applicationClient.alternateIconName())) } case .syncAppIconTypeDone(let iconName): @@ -122,7 +122,7 @@ extension SettingReducer { case .syncUserInterfaceStyle: let style = state.setting.preferredColorScheme.userInterfaceStyle - return .run(operation: { _ in await uiApplicationClient.setUserInterfaceStyle(style) }) + return .run(operation: { _ in await applicationClient.setUserInterfaceStyle(style) }) case .syncSetting: return .run { [state] _ in diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index 0cba7c2c4..4a2bd1e3b 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -3,7 +3,7 @@ import AppModels import ComposableArchitecture import LoggerClient import UserDefaultsClient -import UIApplicationClient +import ApplicationClient import HapticsClient import LibraryClient import DatabaseClient @@ -107,7 +107,7 @@ public struct SettingReducer: Sendable { case appearance(AppearanceSettingReducer.Action) } - @Dependency(\.uiApplicationClient) var uiApplicationClient + @Dependency(\.applicationClient) var applicationClient @Dependency(\.userDefaultsClient) var userDefaultsClient @Dependency(\.appDelegateClient) var appDelegateClient @Dependency(\.databaseClient) var databaseClient diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index 9802e7f82..702f22a5c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -6,7 +6,7 @@ import Testing import LoggerClient import URLClient import UserDefaultsClient -import UIApplicationClient +import ApplicationClient import HapticsClient import LibraryClient import DatabaseClient @@ -189,7 +189,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { $0.databaseClient = .noop $0.deviceClient = .noop $0.hapticsClient = .noop - $0.uiApplicationClient = .noop + $0.applicationClient = .noop $0.userDefaultsClient = .noop $0.appDelegateClient = .noop $0.libraryClient = .noop @@ -232,7 +232,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { $0.databaseClient = .noop $0.deviceClient = .noop $0.hapticsClient = .noop - $0.uiApplicationClient = .noop + $0.applicationClient = .noop $0.userDefaultsClient = .noop $0.appDelegateClient = .noop $0.libraryClient = .noop @@ -292,7 +292,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { $0.databaseClient = .noop $0.deviceClient = .noop $0.hapticsClient = .noop - $0.uiApplicationClient = .noop + $0.applicationClient = .noop $0.userDefaultsClient = .noop $0.appDelegateClient = .noop $0.libraryClient = .noop From f4abf6bed294f3d929143bff82fb6f9f284c05eb Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 20:30:14 +0800 Subject: [PATCH 361/614] Extract TagTranslationFeature; drop CommonMarkExt from AppModels --- .gitignore | 1 + AppPackage/Package.swift | 18 ++++++++- .../AppComponents/TagSuggestionView.swift | 1 + .../AppModels/Tags/TagSuggestion.swift | 36 +---------------- .../AppModels/Tags/TagTranslation.swift | 30 -------------- .../AppModels/Tags/TagTranslator.swift | 17 -------- .../DetailSearch/DetailSearchView.swift | 1 + .../DetailFeature/DetailView+Subviews.swift | 1 + .../Sources/DetailFeature/DetailView.swift | 1 + .../DownloadsView+Subviews.swift | 1 + .../FavoritesFeature/FavoritesView.swift | 1 + .../Cells/GalleryDetailCell.swift | 1 + .../Cells/GalleryThumbnailCell.swift | 1 + .../HomeFeature/Frontpage/FrontpageView.swift | 1 + .../HomeFeature/History/HistoryView.swift | 1 + .../HomeFeature/Popular/PopularView.swift | 1 + .../HomeFeature/Toplists/ToplistsView.swift | 1 + .../HomeFeature/Watched/WatchedView.swift | 1 + .../Sources/SearchFeature/SearchView.swift | 1 + .../TagTranslationFeature/.swiftlint.yml | 1 + .../TagSuggestion+Display.swift | 38 ++++++++++++++++++ .../TagTranslation+Markdown.swift | 34 ++++++++++++++++ .../TagTranslator+Lookup.swift | 21 ++++++++++ .../build-request.json | 27 ------------- .../description.msgpack | Bin 246 -> 0 bytes .../manifest.json | 1 - .../target-graph.txt | 1 - .../task-store.msgpack | Bin 80 -> 0 bytes .../build-request.json | 27 ------------- .../description.msgpack | Bin 246 -> 0 bytes .../manifest.json | 1 - .../target-graph.txt | 1 - .../task-store.msgpack | Bin 80 -> 0 bytes 33 files changed, 127 insertions(+), 141 deletions(-) create mode 100644 AppPackage/Sources/TagTranslationFeature/.swiftlint.yml create mode 100644 AppPackage/Sources/TagTranslationFeature/TagSuggestion+Display.swift create mode 100644 AppPackage/Sources/TagTranslationFeature/TagTranslation+Markdown.swift create mode 100644 AppPackage/Sources/TagTranslationFeature/TagTranslator+Lookup.swift delete mode 100644 build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/build-request.json delete mode 100644 build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/description.msgpack delete mode 100644 build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/manifest.json delete mode 100644 build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/target-graph.txt delete mode 100644 build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/task-store.msgpack delete mode 100644 build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/build-request.json delete mode 100644 build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/description.msgpack delete mode 100644 build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/manifest.json delete mode 100644 build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/target-graph.txt delete mode 100644 build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/task-store.msgpack diff --git a/.gitignore b/.gitignore index de898f207..9fdcea43e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +build .build .DS_Store .xcode-home diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index d2290166c..206a4a8cc 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -112,6 +112,7 @@ enum Module: String { case swiftUINavigationExt = "SwiftUINavigationExt" case swiftyBeaverExt = "SwiftyBeaverExt" case ttProgressHUDExt = "TTProgressHUDExt" + case tagTranslationFeature = "TagTranslationFeature" case urlClient = "URLClient" case userDefaultsClient = "UserDefaultsClient" @@ -315,7 +316,6 @@ let targets: [PackageDescription.Target] = [ .target( module: .appModels, dependencies: [ - .module(.commonMarkExt), .module(.resources), .module(.swiftyBeaverExt), .targetDependency(.casePaths) @@ -512,6 +512,7 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .module(.parserFeature), .module(.resources), + .module(.tagTranslationFeature), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols) ], @@ -525,6 +526,7 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.appTools), .module(.resources), + .module(.tagTranslationFeature), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols), .targetDependency(.waterfallGrid) @@ -577,6 +579,15 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .tagTranslationFeature, + dependencies: [ + .module(.appModels), + .module(.commonMarkExt) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .migrationFeature, dependencies: [ @@ -653,6 +664,7 @@ let targets: [PackageDescription.Target] = [ .module(.resources), .module(.swiftUINavigationExt), .module(.ttProgressHUDExt), + .module(.tagTranslationFeature), .targetDependency(.composableArchitecture), .targetDependency(.sfSafeSymbols) ], @@ -676,6 +688,7 @@ let targets: [PackageDescription.Target] = [ .module(.quickSearchFeature), .module(.resources), .module(.swiftUINavigationExt), + .module(.tagTranslationFeature), .targetDependency(.alertKit), .targetDependency(.composableArchitecture) ], @@ -732,6 +745,7 @@ let targets: [PackageDescription.Target] = [ .module(.quickSearchFeature), .module(.resources), .module(.swiftUINavigationExt), + .module(.tagTranslationFeature), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols) @@ -759,6 +773,7 @@ let targets: [PackageDescription.Target] = [ .module(.quickSearchFeature), .module(.resources), .module(.swiftUINavigationExt), + .module(.tagTranslationFeature), .targetDependency(.alertKit), .targetDependency(.colorful), .targetDependency(.composableArchitecture), @@ -793,6 +808,7 @@ let targets: [PackageDescription.Target] = [ .module(.resources), .module(.swiftUINavigationExt), .module(.ttProgressHUDExt), + .module(.tagTranslationFeature), .module(.urlClient), .targetDependency(.commonMark), .targetDependency(.composableArchitecture), diff --git a/AppPackage/Sources/AppComponents/TagSuggestionView.swift b/AppPackage/Sources/AppComponents/TagSuggestionView.swift index ed01c2565..7a5fee5b6 100644 --- a/AppPackage/Sources/AppComponents/TagSuggestionView.swift +++ b/AppPackage/Sources/AppComponents/TagSuggestionView.swift @@ -1,6 +1,7 @@ import SwiftUI import SFSafeSymbols import AppModels +import TagTranslationFeature import Resources import Kingfisher import Observation diff --git a/AppPackage/Sources/AppModels/Tags/TagSuggestion.swift b/AppPackage/Sources/AppModels/Tags/TagSuggestion.swift index d7485b7a3..2b4682953 100644 --- a/AppPackage/Sources/AppModels/Tags/TagSuggestion.swift +++ b/AppPackage/Sources/AppModels/Tags/TagSuggestion.swift @@ -1,4 +1,4 @@ -import SwiftUI +import Foundation public struct TagSuggestion: Equatable, Hashable, Identifiable, Sendable { public init( @@ -23,38 +23,4 @@ public struct TagSuggestion: Equatable, Hashable, Identifiable, Sendable { public let valueRange: Range? public let originalKeyword: String public let matchesNamespace: Bool - - public var displayKey: String { - var namespace = tag.namespace.rawValue - let leftSideString = leftSideString(of: keyRange, string: tag.key) - var middleString = middleString(of: keyRange, string: tag.key) - let rightSideString = rightSideString(of: keyRange, string: tag.key) - middleString = middleString.isEmpty ? middleString : middleString.linkStyled - namespace = matchesNamespace ? namespace.linkStyled : namespace - return [namespace, ":", leftSideString, middleString, rightSideString].joined() - } - public var displayValue: String { - let text = tag.displayValue - let leftSideString = leftSideString(of: valueRange, string: text) - var middleString = middleString(of: valueRange, string: text) - let rightSideString = rightSideString(of: valueRange, string: text) - middleString = middleString.isEmpty ? middleString : middleString.linkStyled - return [leftSideString, middleString, rightSideString].joined() - } - - private func leftSideString(of range: Range?, string: String) -> String { - guard let range = range, string.endIndex >= range.lowerBound else { return string } - return .init(string[string.startIndex..?, string: String) -> String { - guard let range = range, - range.upperBound <= string.endIndex, - range.lowerBound >= string.startIndex - else { return .init() } - return .init(string[range]) - } - private func rightSideString(of range: Range?, string: String) -> String { - guard let range = range, range.upperBound < string.endIndex else { return .init() } - return .init(string[range.upperBound.. (String, TagTranslation?) { - guard !returnOriginal else { return (word, nil) } - let (lhs, rhs) = word.stringsBesideColon - - var key = rhs - if let lhs = lhs { - key = lhs + rhs - } - guard let translation = translations[key] else { return (word, nil) } - - var result = translation.displayValue - if let lhs = lhs { - result = [lhs, ":", result].joined() - } - return (result, translation) - } } extension TagTranslator: CustomStringConvertible { diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift index c5b0e12d8..1c08d528a 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import TagTranslationFeature import ComposableArchitecture import SwiftUINavigationExt import AppTools diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index 72dcaa72e..3071c2e32 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import TagTranslationFeature import Resources import Kingfisher import AppTools diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 8a1c6c1fe..b988997e3 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import TagTranslationFeature import Resources import Kingfisher import ComposableArchitecture diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index ba000fd83..46b7bbd18 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import TagTranslationFeature import Resources import SFSafeSymbols import ComposableArchitecture diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index b3b3d75d6..e2cb5d403 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import TagTranslationFeature import Resources import AlertKit import ComposableArchitecture diff --git a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift index 71c7bc18d..fda1a7635 100644 --- a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift @@ -1,6 +1,7 @@ import SwiftUI import SFSafeSymbols import AppModels +import TagTranslationFeature import AppComponents import Kingfisher import AppTools diff --git a/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift index 4b12cfb5e..2d0eb42d2 100644 --- a/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift @@ -1,6 +1,7 @@ import SwiftUI import SFSafeSymbols import AppModels +import TagTranslationFeature import AppComponents import Kingfisher import AppTools diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index b423c1648..357a8f438 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import TagTranslationFeature import Resources import AlertKit import ComposableArchitecture diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index bf164662e..ce5d1a0e3 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import TagTranslationFeature import Resources import ComposableArchitecture import SwiftUINavigationExt diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index bb6bf1822..f8de9d966 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import TagTranslationFeature import Resources import ComposableArchitecture import SwiftUINavigationExt diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index 0a7d453e2..bb80c6093 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import TagTranslationFeature import Resources import ComposableArchitecture import SwiftUINavigationExt diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index 7beabb709..e4695ca9c 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import TagTranslationFeature import Resources import ComposableArchitecture import SwiftUINavigationExt diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift index 4a30c8524..f6cc9e6b4 100644 --- a/AppPackage/Sources/SearchFeature/SearchView.swift +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import TagTranslationFeature import ComposableArchitecture import SwiftUINavigationExt import AppTools diff --git a/AppPackage/Sources/TagTranslationFeature/.swiftlint.yml b/AppPackage/Sources/TagTranslationFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/TagTranslationFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/TagTranslationFeature/TagSuggestion+Display.swift b/AppPackage/Sources/TagTranslationFeature/TagSuggestion+Display.swift new file mode 100644 index 000000000..c44517309 --- /dev/null +++ b/AppPackage/Sources/TagTranslationFeature/TagSuggestion+Display.swift @@ -0,0 +1,38 @@ +import AppModels +import Foundation + +extension TagSuggestion { + public var displayKey: String { + var namespace = tag.namespace.rawValue + let leftSideString = leftSideString(of: keyRange, string: tag.key) + var middleString = middleString(of: keyRange, string: tag.key) + let rightSideString = rightSideString(of: keyRange, string: tag.key) + middleString = middleString.isEmpty ? middleString : middleString.linkStyled + namespace = matchesNamespace ? namespace.linkStyled : namespace + return [namespace, ":", leftSideString, middleString, rightSideString].joined() + } + public var displayValue: String { + let text = tag.displayValue + let leftSideString = leftSideString(of: valueRange, string: text) + var middleString = middleString(of: valueRange, string: text) + let rightSideString = rightSideString(of: valueRange, string: text) + middleString = middleString.isEmpty ? middleString : middleString.linkStyled + return [leftSideString, middleString, rightSideString].joined() + } + + private func leftSideString(of range: Range?, string: String) -> String { + guard let range = range, string.endIndex >= range.lowerBound else { return string } + return .init(string[string.startIndex..?, string: String) -> String { + guard let range = range, + range.upperBound <= string.endIndex, + range.lowerBound >= string.startIndex + else { return .init() } + return .init(string[range]) + } + private func rightSideString(of range: Range?, string: String) -> String { + guard let range = range, range.upperBound < string.endIndex else { return .init() } + return .init(string[range.upperBound.. (String, TagTranslation?) { + guard !returnOriginal else { return (word, nil) } + let (lhs, rhs) = word.stringsBesideColon + + var key = rhs + if let lhs = lhs { + key = lhs + rhs + } + guard let translation = translations[key] else { return (word, nil) } + + var result = translation.displayValue + if let lhs = lhs { + result = [lhs, ":", result].joined() + } + return (result, translation) + } +} diff --git a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/build-request.json b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/build-request.json deleted file mode 100644 index 38eab09c8..000000000 --- a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/build-request.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "buildCommand" : { - "command" : "build", - "skipDependencies" : false, - "style" : "buildOnly" - }, - "configuredTargets" : [ - - ], - "continueBuildingAfterErrors" : false, - "dependencyScope" : "workspace", - "enableIndexBuildArena" : false, - "hideShellScriptEnvironment" : false, - "parameters" : { - "action" : "build", - "overrides" : { - - } - }, - "qos" : "utility", - "schemeCommand" : "launch", - "showNonLoggedProgress" : true, - "useDryRun" : false, - "useImplicitDependencies" : false, - "useLegacyBuildLocations" : false, - "useParallelTargets" : true -} \ No newline at end of file diff --git a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/description.msgpack b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/description.msgpack deleted file mode 100644 index 78976160716b0a571fc7bd92471da94137b5f9c5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 246 zcmW-bK~BRk6hyfM`(B{=PHH#b+lI13LPAJve{9E%aP2B~Qp74p2shv?rI)D0VMutJ zk!GZ)`MtZpt>HDAE<&Od`_BN6p?in@;hDT9sQcDwc&m0xK2W0TiVDnSiNrT0Bd!pY zFero)Tx=z6im&f+W*Tn}2I`;*PA~eC2hY!^Xn8KIm8Gif_KZVn8n$3PR1i9wIX4_L z<;Yuj3c7cuB6TpC53Sec171vwZSdLge;dfPn$ld1({-hJu2E@;v$8H2Hxe0>5{+t* N7p0lzSC^m5^ar-7SDF9- diff --git a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/manifest.json b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/manifest.json deleted file mode 100644 index 7391713b6..000000000 --- a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/manifest.json +++ /dev/null @@ -1 +0,0 @@ -{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":[""]},"commands":{"":{"tool":"phony","inputs":[""],"outputs":[""]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":[""]}}} \ No newline at end of file diff --git a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/target-graph.txt b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/target-graph.txt deleted file mode 100644 index b83b1580f..000000000 --- a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/target-graph.txt +++ /dev/null @@ -1 +0,0 @@ -Target dependency graph (0 target) \ No newline at end of file diff --git a/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/task-store.msgpack b/build/XCBuildData/a281978a4370a4c88c5e855c54569a32.xcbuilddata/task-store.msgpack deleted file mode 100644 index e599b6946d83b77c1ac76fc98ae226d544432d32..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80 zcmbPuhe7fH5KLO)o>-E4Q!zZhD7&~IF*(&EH8CZ%$TzVd%q`e0Gbgn;yePAzBsFir fgb9--OjxLY_`rd~hbA1JFyX+V!$6tT(?!VNe}=_M+07!uxQ zq#5aHes6BCckr61OOP?A{xia3?B3yF|BS&PsJ`_E-kRN#_n7grBE6=K(ukHyu1Z-~ ztgdUV*jh=dnEv`6XQuJ$VZaV-^k&f?0|b6LCdbQ-b8@Sdt50Ic*0Kc`pn=%A!u#P+ zXph0cQ#8G&2KAnb5Zhqr170YlHiqKxzYXYHgLy8->9R7sG(sCGW~HhaCn*?{QjoTS NS6iCqSLdJe^aqhWSjhkY diff --git a/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/manifest.json b/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/manifest.json deleted file mode 100644 index 7391713b6..000000000 --- a/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/manifest.json +++ /dev/null @@ -1 +0,0 @@ -{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":[""]},"commands":{"":{"tool":"phony","inputs":[""],"outputs":[""]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":[""]}}} \ No newline at end of file diff --git a/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/target-graph.txt b/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/target-graph.txt deleted file mode 100644 index b83b1580f..000000000 --- a/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/target-graph.txt +++ /dev/null @@ -1 +0,0 @@ -Target dependency graph (0 target) \ No newline at end of file diff --git a/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/task-store.msgpack b/build/XCBuildData/ec3e5bcaafe14b19620666cb03b9e20c.xcbuilddata/task-store.msgpack deleted file mode 100644 index e599b6946d83b77c1ac76fc98ae226d544432d32..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80 zcmbPuhe7fH5KLO)o>-E4Q!zZhD7&~IF*(&EH8CZ%$TzVd%q`e0Gbgn;yePAzBsFir fgb9--OjxLY_`rd~hbA1JFyX+V! Date: Sun, 28 Jun 2026 21:54:29 +0800 Subject: [PATCH 362/614] Move ImagePlaceholderFingerprint into AppModels --- .../Support}/ImagePlaceholderFingerprint.swift | 1 - 1 file changed, 1 deletion(-) rename AppPackage/Sources/{AppTools => AppModels/Support}/ImagePlaceholderFingerprint.swift (99%) diff --git a/AppPackage/Sources/AppTools/ImagePlaceholderFingerprint.swift b/AppPackage/Sources/AppModels/Support/ImagePlaceholderFingerprint.swift similarity index 99% rename from AppPackage/Sources/AppTools/ImagePlaceholderFingerprint.swift rename to AppPackage/Sources/AppModels/Support/ImagePlaceholderFingerprint.swift index a583744d1..e1e539b8a 100644 --- a/AppPackage/Sources/AppTools/ImagePlaceholderFingerprint.swift +++ b/AppPackage/Sources/AppModels/Support/ImagePlaceholderFingerprint.swift @@ -1,5 +1,4 @@ import CryptoKit -import AppModels import Foundation /// A known E-H asset placeholder that decodes as a valid image but is *not* page From 425d2985d3026500378eae21a107fa124aa8e8de Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 22:17:23 +0800 Subject: [PATCH 363/614] Invert AppModels/AppTools layering: value types to AppTools, app-runtime utils to AppModels --- AppPackage/Package.swift | 7 ++++++- AppPackage/Sources/AlertKitExt/AlertKit+.swift | 1 + .../Sources/AppFeature/DataFlow/AppDelegateReducer.swift | 1 + .../Sources/AppFeature/DataFlow/AppRouteReducer.swift | 1 + .../AppLaunchAutomationClient/AppLaunchAutomation.swift | 1 + .../AppModels/Download/DownloadedGallery+Extensions.swift | 1 + .../Download/DownloadedGallery+SupportTypes.swift | 1 + AppPackage/Sources/AppModels/Gallery/Gallery.swift | 1 + AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift | 1 + AppPackage/Sources/AppModels/Gallery/GalleryState.swift | 1 + AppPackage/Sources/AppModels/Persistent/Setting.swift | 1 + AppPackage/Sources/AppModels/Support/Misc.swift | 1 + AppPackage/Sources/AppModels/Support/URL+Mock.swift | 1 + .../{AppTools => AppModels/Utilities}/AppUtil.swift | 2 +- .../Utilities}/Defaults+Runtime.swift | 2 +- .../{AppTools => AppModels/Utilities}/URLUtil.swift | 2 +- .../{AppModels/ValueTypes => AppTools}/ColorCodable.swift | 0 AppPackage/Sources/AppTools/CookieUtil.swift | 1 - .../{AppModels/ValueTypes => AppTools}/Defaults.swift | 0 .../ValueTypes => AppTools}/EnvironmentKeys.swift | 0 .../{AppModels/ValueTypes => AppTools}/EquatableVoid.swift | 0 AppPackage/Sources/AppTools/FileUtil.swift | 1 - .../ValueTypes => AppTools}/IdentifiableBox.swift | 0 .../ValueTypes => AppTools}/Optional+ForceUnwrapped.swift | 0 .../ValueTypes => AppTools}/String+Helpers.swift | 0 .../ValueTypes => AppTools}/URL+QueryItems.swift | 0 .../Sources/DateSeekFeature/DateSeekPickerView.swift | 1 + AppPackage/Sources/DetailFeature/DetailReducer.swift | 1 + .../DetailFeature/DetailSearch/DetailSearchReducer.swift | 1 + .../DownloadClient/DownloadClient+ExecutionSupport.swift | 1 + .../DownloadClient/DownloadClient+Persistence.swift | 1 + .../Sources/DownloadClient/DownloadStore+Operations.swift | 1 + AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift | 1 + AppPackage/Sources/HomeFeature/GalleryRankingCell.swift | 1 + .../Sources/HomeFeature/Watched/WatchedReducer.swift | 1 + AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift | 1 + AppPackage/Sources/ParserFeature/Parser+Archive.swift | 1 + AppPackage/Sources/ParserFeature/Parser+Comment.swift | 1 + AppPackage/Sources/ParserFeature/Parser+List.swift | 1 + AppPackage/Sources/ParserFeature/Parser+Torrent.swift | 1 + AppPackage/Sources/ReadingFeature/ReadingReducer.swift | 1 + AppPackage/Sources/SearchFeature/GalleryHistoryCell.swift | 1 + AppPackage/Sources/SearchFeature/SearchReducer.swift | 1 + .../Sources/SettingFeature/Components/AboutView.swift | 1 + AppPackage/Sources/SettingFeature/Components/WebView.swift | 1 + .../SettingFeature/EhSetting/EhSettingReducer.swift | 1 + AppPackage/Sources/SettingFeature/Login/LoginView.swift | 1 + .../Sources/SettingFeature/SettingReducer+Body.swift | 1 + .../Sources/SettingFeature/SettingReducer+Helpers.swift | 1 + .../TagTranslationFeature/TagSuggestion+Display.swift | 1 + .../TagTranslationFeature/TagTranslator+Lookup.swift | 1 + .../Tests/Download/DetailReducerDownloadTests.swift | 1 + .../Tests/Download/DownloadAutomationTests.swift | 1 + .../Download/DownloadCoordinatorRepairSeedTests.swift | 1 + .../Tests/Download/DownloadCoordinatorStorageTests.swift | 1 + .../Tests/Download/DownloadEnqueueManifestTests.swift | 1 + .../Tests/Download/DownloadFeatureTestHelpers.swift | 1 + .../Tests/Download/DownloadStoreRepairTests.swift | 1 + .../Tests/ParserFeature/List/ListParserTests.swift | 1 + 59 files changed, 54 insertions(+), 6 deletions(-) rename AppPackage/Sources/{AppTools => AppModels/Utilities}/AppUtil.swift (98%) rename AppPackage/Sources/{AppTools => AppModels/Utilities}/Defaults+Runtime.swift (99%) rename AppPackage/Sources/{AppTools => AppModels/Utilities}/URLUtil.swift (99%) rename AppPackage/Sources/{AppModels/ValueTypes => AppTools}/ColorCodable.swift (100%) rename AppPackage/Sources/{AppModels/ValueTypes => AppTools}/Defaults.swift (100%) rename AppPackage/Sources/{AppModels/ValueTypes => AppTools}/EnvironmentKeys.swift (100%) rename AppPackage/Sources/{AppModels/ValueTypes => AppTools}/EquatableVoid.swift (100%) rename AppPackage/Sources/{AppModels/ValueTypes => AppTools}/IdentifiableBox.swift (100%) rename AppPackage/Sources/{AppModels/ValueTypes => AppTools}/Optional+ForceUnwrapped.swift (100%) rename AppPackage/Sources/{AppModels/ValueTypes => AppTools}/String+Helpers.swift (100%) rename AppPackage/Sources/{AppModels/ValueTypes => AppTools}/URL+QueryItems.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 206a4a8cc..3ffb11d45 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -316,6 +316,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .appModels, dependencies: [ + .module(.appTools), .module(.resources), .module(.swiftyBeaverExt), .targetDependency(.casePaths) @@ -397,7 +398,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .appTools, dependencies: [ - .module(.appModels) + .module(.swiftyBeaverExt) ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins @@ -415,6 +416,7 @@ let targets: [PackageDescription.Target] = [ module: .appLaunchAutomationClient, dependencies: [ .module(.appModels), + .module(.appTools), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -547,6 +549,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appComponents), .module(.appModels), + .module(.appTools), .module(.resources), .targetDependency(.alertKit) ], @@ -583,6 +586,7 @@ let targets: [PackageDescription.Target] = [ module: .tagTranslationFeature, dependencies: [ .module(.appModels), + .module(.appTools), .module(.commonMarkExt) ], swiftSettings: sharedSwiftSettings, @@ -618,6 +622,7 @@ let targets: [PackageDescription.Target] = [ module: .dateSeekFeature, dependencies: [ .module(.appModels), + .module(.appTools), .module(.hapticsClient), .module(.resources), .targetDependency(.composableArchitecture), diff --git a/AppPackage/Sources/AlertKitExt/AlertKit+.swift b/AppPackage/Sources/AlertKitExt/AlertKit+.swift index a862e3994..540062234 100644 --- a/AppPackage/Sources/AlertKitExt/AlertKit+.swift +++ b/AppPackage/Sources/AlertKitExt/AlertKit+.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI import AppModels import AppComponents diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index 0e6d4e198..b65e570da 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -1,3 +1,4 @@ +import AppModels import SwiftUI import BackgroundTasks import SwiftyBeaver diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 789ee88f6..1a43b4bfa 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI import AppModels import ComposableArchitecture diff --git a/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomation.swift b/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomation.swift index 0e4ff1d6c..fb9115a76 100644 --- a/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomation.swift +++ b/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomation.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels diff --git a/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift index 715355ab5..003277c04 100644 --- a/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI // MARK: - DownloadBadge diff --git a/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift index ff1b7c6f9..01d2355c0 100644 --- a/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI // MARK: DownloadedGallery Computed Properties diff --git a/AppPackage/Sources/AppModels/Gallery/Gallery.swift b/AppPackage/Sources/AppModels/Gallery/Gallery.swift index a7272604e..e1932ef42 100644 --- a/AppPackage/Sources/AppModels/Gallery/Gallery.swift +++ b/AppPackage/Sources/AppModels/Gallery/Gallery.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI public struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift b/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift index ea3dda8be..d57a243d1 100644 --- a/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift +++ b/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import Resources diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryState.swift b/AppPackage/Sources/AppModels/Gallery/GalleryState.swift index b7ac886d9..e4eea7bc7 100644 --- a/AppPackage/Sources/AppModels/Gallery/GalleryState.swift +++ b/AppPackage/Sources/AppModels/Gallery/GalleryState.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI import Foundation diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index af571ca90..1ac571850 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI import Resources import Foundation diff --git a/AppPackage/Sources/AppModels/Support/Misc.swift b/AppPackage/Sources/AppModels/Support/Misc.swift index 10c0e30c9..d75b5badb 100644 --- a/AppPackage/Sources/AppModels/Support/Misc.swift +++ b/AppPackage/Sources/AppModels/Support/Misc.swift @@ -1,3 +1,4 @@ +import AppTools import CasePaths import Foundation diff --git a/AppPackage/Sources/AppModels/Support/URL+Mock.swift b/AppPackage/Sources/AppModels/Support/URL+Mock.swift index 46873de87..e1293450f 100644 --- a/AppPackage/Sources/AppModels/Support/URL+Mock.swift +++ b/AppPackage/Sources/AppModels/Support/URL+Mock.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation extension URL { diff --git a/AppPackage/Sources/AppTools/AppUtil.swift b/AppPackage/Sources/AppModels/Utilities/AppUtil.swift similarity index 98% rename from AppPackage/Sources/AppTools/AppUtil.swift rename to AppPackage/Sources/AppModels/Utilities/AppUtil.swift index badc1e585..f2d4a6014 100644 --- a/AppPackage/Sources/AppTools/AppUtil.swift +++ b/AppPackage/Sources/AppModels/Utilities/AppUtil.swift @@ -1,5 +1,5 @@ +import AppTools import Foundation -import AppModels public struct AppUtil { public static var version: String { diff --git a/AppPackage/Sources/AppTools/Defaults+Runtime.swift b/AppPackage/Sources/AppModels/Utilities/Defaults+Runtime.swift similarity index 99% rename from AppPackage/Sources/AppTools/Defaults+Runtime.swift rename to AppPackage/Sources/AppModels/Utilities/Defaults+Runtime.swift index 0dbfe98e2..0d7e7ffb2 100644 --- a/AppPackage/Sources/AppTools/Defaults+Runtime.swift +++ b/AppPackage/Sources/AppModels/Utilities/Defaults+Runtime.swift @@ -1,4 +1,4 @@ -import AppModels +import AppTools import CoreGraphics import Foundation diff --git a/AppPackage/Sources/AppTools/URLUtil.swift b/AppPackage/Sources/AppModels/Utilities/URLUtil.swift similarity index 99% rename from AppPackage/Sources/AppTools/URLUtil.swift rename to AppPackage/Sources/AppModels/Utilities/URLUtil.swift index f183ed9b7..f7e21fa74 100644 --- a/AppPackage/Sources/AppTools/URLUtil.swift +++ b/AppPackage/Sources/AppModels/Utilities/URLUtil.swift @@ -1,5 +1,5 @@ +import AppTools import Foundation -import AppModels public struct URLUtil { // Fetch diff --git a/AppPackage/Sources/AppModels/ValueTypes/ColorCodable.swift b/AppPackage/Sources/AppTools/ColorCodable.swift similarity index 100% rename from AppPackage/Sources/AppModels/ValueTypes/ColorCodable.swift rename to AppPackage/Sources/AppTools/ColorCodable.swift diff --git a/AppPackage/Sources/AppTools/CookieUtil.swift b/AppPackage/Sources/AppTools/CookieUtil.swift index f6b91b795..a47b305bc 100644 --- a/AppPackage/Sources/AppTools/CookieUtil.swift +++ b/AppPackage/Sources/AppTools/CookieUtil.swift @@ -1,5 +1,4 @@ import Foundation -import AppModels // MARK: Cookie public struct CookieUtil { diff --git a/AppPackage/Sources/AppModels/ValueTypes/Defaults.swift b/AppPackage/Sources/AppTools/Defaults.swift similarity index 100% rename from AppPackage/Sources/AppModels/ValueTypes/Defaults.swift rename to AppPackage/Sources/AppTools/Defaults.swift diff --git a/AppPackage/Sources/AppModels/ValueTypes/EnvironmentKeys.swift b/AppPackage/Sources/AppTools/EnvironmentKeys.swift similarity index 100% rename from AppPackage/Sources/AppModels/ValueTypes/EnvironmentKeys.swift rename to AppPackage/Sources/AppTools/EnvironmentKeys.swift diff --git a/AppPackage/Sources/AppModels/ValueTypes/EquatableVoid.swift b/AppPackage/Sources/AppTools/EquatableVoid.swift similarity index 100% rename from AppPackage/Sources/AppModels/ValueTypes/EquatableVoid.swift rename to AppPackage/Sources/AppTools/EquatableVoid.swift diff --git a/AppPackage/Sources/AppTools/FileUtil.swift b/AppPackage/Sources/AppTools/FileUtil.swift index e99b233ec..eca5c9aa2 100644 --- a/AppPackage/Sources/AppTools/FileUtil.swift +++ b/AppPackage/Sources/AppTools/FileUtil.swift @@ -1,5 +1,4 @@ import Foundation -import AppModels public struct FileUtil { public static var logsDirectoryURL: URL { diff --git a/AppPackage/Sources/AppModels/ValueTypes/IdentifiableBox.swift b/AppPackage/Sources/AppTools/IdentifiableBox.swift similarity index 100% rename from AppPackage/Sources/AppModels/ValueTypes/IdentifiableBox.swift rename to AppPackage/Sources/AppTools/IdentifiableBox.swift diff --git a/AppPackage/Sources/AppModels/ValueTypes/Optional+ForceUnwrapped.swift b/AppPackage/Sources/AppTools/Optional+ForceUnwrapped.swift similarity index 100% rename from AppPackage/Sources/AppModels/ValueTypes/Optional+ForceUnwrapped.swift rename to AppPackage/Sources/AppTools/Optional+ForceUnwrapped.swift diff --git a/AppPackage/Sources/AppModels/ValueTypes/String+Helpers.swift b/AppPackage/Sources/AppTools/String+Helpers.swift similarity index 100% rename from AppPackage/Sources/AppModels/ValueTypes/String+Helpers.swift rename to AppPackage/Sources/AppTools/String+Helpers.swift diff --git a/AppPackage/Sources/AppModels/ValueTypes/URL+QueryItems.swift b/AppPackage/Sources/AppTools/URL+QueryItems.swift similarity index 100% rename from AppPackage/Sources/AppModels/ValueTypes/URL+QueryItems.swift rename to AppPackage/Sources/AppTools/URL+QueryItems.swift diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift index 51d4e2fbf..cd0f35bf0 100644 --- a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift @@ -1,3 +1,4 @@ +import AppTools import SFSafeSymbols import AppModels import Resources diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index bdbac2caa..a48dc64dc 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI import AppModels import Foundation diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index 179d542be..13d823682 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -1,3 +1,4 @@ +import AppTools import ComposableArchitecture import AppModels import SwiftUINavigationExt diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift index 3f8cf5f00..9807fcc39 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import URLClient diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift index acf48ee57..c8173b9c3 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import SwiftyBeaverExt diff --git a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift index d88d6c350..f393205a2 100644 --- a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import Resources diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index 5938dbfde..9f55a1728 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI import AppModels import IdentifiedCollections diff --git a/AppPackage/Sources/HomeFeature/GalleryRankingCell.swift b/AppPackage/Sources/HomeFeature/GalleryRankingCell.swift index be72ee8b7..0dc680a0a 100644 --- a/AppPackage/Sources/HomeFeature/GalleryRankingCell.swift +++ b/AppPackage/Sources/HomeFeature/GalleryRankingCell.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI import AppModels import AppComponents diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index 25b22af19..e881d6b14 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -1,3 +1,4 @@ +import AppTools import ComposableArchitecture import AppModels import SwiftUINavigationExt diff --git a/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift b/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift index c29ddbd61..82e67e1b5 100644 --- a/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift +++ b/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import SwiftyBeaverExt diff --git a/AppPackage/Sources/ParserFeature/Parser+Archive.swift b/AppPackage/Sources/ParserFeature/Parser+Archive.swift index fed44846b..d7cbf5a88 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Archive.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Archive.swift @@ -1,3 +1,4 @@ +import AppTools import Kanna import AppModels import Foundation diff --git a/AppPackage/Sources/ParserFeature/Parser+Comment.swift b/AppPackage/Sources/ParserFeature/Parser+Comment.swift index 1ec937d31..19f84d9c8 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Comment.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Comment.swift @@ -1,3 +1,4 @@ +import AppTools import Kanna import AppModels import Foundation diff --git a/AppPackage/Sources/ParserFeature/Parser+List.swift b/AppPackage/Sources/ParserFeature/Parser+List.swift index d5e60064d..be68903cf 100644 --- a/AppPackage/Sources/ParserFeature/Parser+List.swift +++ b/AppPackage/Sources/ParserFeature/Parser+List.swift @@ -1,3 +1,4 @@ +import AppTools import Kanna import AppModels import SwiftUI diff --git a/AppPackage/Sources/ParserFeature/Parser+Torrent.swift b/AppPackage/Sources/ParserFeature/Parser+Torrent.swift index 2f7592fa1..28d209648 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Torrent.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Torrent.swift @@ -1,3 +1,4 @@ +import AppTools import Kanna import AppModels import Foundation diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 6c9a93823..b0eaf0462 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI import AppModels import ComposableArchitecture diff --git a/AppPackage/Sources/SearchFeature/GalleryHistoryCell.swift b/AppPackage/Sources/SearchFeature/GalleryHistoryCell.swift index d398e0e59..9081f0fae 100644 --- a/AppPackage/Sources/SearchFeature/GalleryHistoryCell.swift +++ b/AppPackage/Sources/SearchFeature/GalleryHistoryCell.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI import AppModels import AppComponents diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index 2c961bde3..cc4872cc4 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -1,3 +1,4 @@ +import AppTools import ComposableArchitecture import AppModels import Foundation diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index 81e4d29a2..4b4dd5d29 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -1,3 +1,4 @@ +import AppModels import SwiftUI import Resources import AppTools diff --git a/AppPackage/Sources/SettingFeature/Components/WebView.swift b/AppPackage/Sources/SettingFeature/Components/WebView.swift index 8b619682b..21b4dd7fe 100644 --- a/AppPackage/Sources/SettingFeature/Components/WebView.swift +++ b/AppPackage/Sources/SettingFeature/Components/WebView.swift @@ -1,3 +1,4 @@ +import AppTools import WebKit import AppModels import SwiftyBeaverExt diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index c1133b483..bc5c3fbce 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import ComposableArchitecture diff --git a/AppPackage/Sources/SettingFeature/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift index 987ecbc6a..d2ab0e995 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginView.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginView.swift @@ -1,3 +1,4 @@ +import AppTools import SwiftUI import AppModels import Resources diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index b24c2a267..52bc64a35 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import ComposableArchitecture diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift index e7a5eae6f..bb75a3803 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import ComposableArchitecture diff --git a/AppPackage/Sources/TagTranslationFeature/TagSuggestion+Display.swift b/AppPackage/Sources/TagTranslationFeature/TagSuggestion+Display.swift index c44517309..bced014ef 100644 --- a/AppPackage/Sources/TagTranslationFeature/TagSuggestion+Display.swift +++ b/AppPackage/Sources/TagTranslationFeature/TagSuggestion+Display.swift @@ -1,3 +1,4 @@ +import AppTools import AppModels import Foundation diff --git a/AppPackage/Sources/TagTranslationFeature/TagTranslator+Lookup.swift b/AppPackage/Sources/TagTranslationFeature/TagTranslator+Lookup.swift index e03e56752..dc6c8a7fb 100644 --- a/AppPackage/Sources/TagTranslationFeature/TagTranslator+Lookup.swift +++ b/AppPackage/Sources/TagTranslationFeature/TagTranslator+Lookup.swift @@ -1,3 +1,4 @@ +import AppTools import AppModels import Foundation diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift index 3ab83b2a7..bfcf35e23 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import ComposableArchitecture diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift index 702f22a5c..1d0eb2804 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import SwiftUI diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift index 5efdebfdd..4a02f5b51 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import Testing diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift index d5dae2b44..5063671bb 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift @@ -1,3 +1,4 @@ +import AppTools import Kingfisher import AppModels import Resources diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift index b418dfdd3..2d071f1b0 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import Testing diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift index 62438b497..07e17b7e4 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import CoreData diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift index eb29b55dc..2e988fe6c 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import Testing diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/List/ListParserTests.swift b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/List/ListParserTests.swift index 67821ab15..adf7489ee 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/List/ListParserTests.swift +++ b/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/List/ListParserTests.swift @@ -1,3 +1,4 @@ +import AppTools import Foundation import AppModels import Kanna From e34f339de83d96d29ecf934b197a8653e7eed95e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 22:40:43 +0800 Subject: [PATCH 364/614] Update AGENTS.md --- AGENTS.md | 35 +++++++++++------------------------ 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0342ba9ae..fb7e57a1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,34 +2,21 @@ This file gives coding agents a reliable working guide for this repository. +## Programming Instructions + +**Reducer naming convention**: Name reducers with a `Feature` suffix, for example `SettingFeature`. This is a project preference that overrides TCA's standard naming convention and any conflicting guidance from skills, training data, or search results. Follow it unless the user directly instructs otherwise. + +**SwiftLint coverage for new modules**: When adding a new module, create a `.swiftlint.yml` file at that module's root. Configure it to reference the appropriate parent SwiftLint config with `parent_config` (`parent_config: ../../../.swiftlint.yml` for a module under `AppPackage/Sources`) so the project's SwiftLint rules cover the new module. + +**Read SwiftLint rules**: Before writing or changing Swift code, read the root `.swiftlint.yml` to learn the project's lint rules, including the custom regex rules and banned APIs it defines. Write code that conforms to those rules from the start, and resolve every violation at its root. Suppressing a rule, disabling it, adding a `// swiftlint:disable`, or otherwise removing it, is forbidden without the user's explicit permission. + ## Project structure EhPanda is being modularized to match the App-shell + local-package layout: -- `App/` — the thin app-shell target. No business logic; it imports `AppFeature` and renders - the root view. -- `AppPackage/` — a local Swift package that holds all logic. Each module is a directory under - `AppPackage/Sources/`, with tests under `AppPackage/Tests/Tests`. +- `App/` — the thin app-shell target. No business logic; it imports `AppFeature` and renders the root view. +- `AppPackage/` — a local Swift package that holds all logic. Each module is a directory under `AppPackage/Sources/`, with tests under `AppPackage/Tests/Tests`. - `ShareExtension/` — the share extension target. -- `EhPanda.xcodeproj` — references `AppPackage` as a local Swift package - (`XCLocalSwiftPackageReference`); the app target links the `AppFeature` product. +- `EhPanda.xcodeproj` — references `AppPackage` as a local Swift package (`XCLocalSwiftPackageReference`); the app target links the `AppFeature` product. All third-party dependencies are declared in `AppPackage/Package.swift`, not in the Xcode project. - -## Programming Instructions - -**Reducer naming convention**: Name reducers with a `Feature` suffix, for example `SettingFeature`. -This is a project preference that overrides TCA's standard naming convention and any conflicting -guidance from skills, training data, or search results. Follow it unless the user directly -instructs otherwise. - -**SwiftLint coverage for new modules**: When adding a new module, create a `.swiftlint.yml` file at -that module's root. Configure it to reference the appropriate parent SwiftLint config with -`parent_config` (`parent_config: ../../../.swiftlint.yml` for a module under `AppPackage/Sources`) -so the project's SwiftLint rules cover the new module. - -**Read SwiftLint rules**: Before writing or changing Swift code, read the root `.swiftlint.yml` to -learn the project's lint rules, including the custom regex rules and banned APIs it defines. Write -code that conforms to those rules from the start, and resolve every violation at its root. -Suppressing a rule, disabling it, adding a `// swiftlint:disable`, or otherwise removing it, is -forbidden without the user's explicit permission. From 41f2a13742701eb08d92f55397d585f9eef0593c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 28 Jun 2026 23:11:14 +0800 Subject: [PATCH 365/614] Split AppFeatureTests into ParserFeatureTests + DownloadsFeatureTests Extract shared fixture-loading scaffolding (TestHelper, HTMLFilename, TestError) and the HTML/jpg fixtures into a new TestingSupport library target, kept out of the package's public products via an isTestSupport flag on Module. Both new test targets depend on it; download tests reach the fixtures through the public TestFixtures accessor since their own Bundle.module no longer carries the resources. Drop dead TestBundleLocator and unused imports from the moved helpers. Repoint the FeatureTests.xctestplan at the two new targets. --- AppPackage/Package.swift | 42 +++++++++++++++--- .../TestingSupport}/.swiftlint.yml | 0 .../TestingSupport}/HTMLFilename.swift | 4 +- .../Parser/Gallery/GalleryDetail.html | 0 .../Parser/Gallery/GalleryMPVKeys.html | 0 .../Parser/Gallery/GalleryNormalImageURL.html | 0 .../Parser/List/FavoritesCompactList.html | 0 .../Parser/List/FavoritesExtendedList.html | 0 .../Parser/List/FavoritesMinimalList.html | 0 .../Parser/List/FavoritesMinimalPlusList.html | 0 .../Parser/List/FavoritesThumbnailList.html | 0 .../Parser/List/FrontPageCompactList.html | 0 .../Parser/List/FrontPageExtendedList.html | 0 .../Parser/List/FrontPageMinimalList.html | 0 .../Parser/List/FrontPageMinimalPlusList.html | 0 .../Parser/List/FrontPageThumbnailList.html | 0 .../Parser/List/PopularCompactList.html | 0 .../Parser/List/PopularExtendedList.html | 0 .../Parser/List/PopularMinimalList.html | 0 .../Parser/List/PopularMinimalPlusList.html | 0 .../Parser/List/PopularThumbnailList.html | 0 .../Parser/List/ToplistsCompactList.html | 0 .../Parser/List/WatchedCompactList.html | 0 .../Parser/List/WatchedExtendedList.html | 0 .../Parser/List/WatchedMinimalList.html | 0 .../Parser/List/WatchedMinimalPlusList.html | 0 .../Parser/List/WatchedThumbnailList.html | 0 .../Parser/Other/BandwidthExceeded.html | Bin .../Resources/Parser/Other/EhSetting.html | 0 .../Parser/Other/ExLoginRequired.html | 0 .../Other/GalleryDetailWithGreeting.html | 0 .../Resources/Parser/Other/IPBanned.html | 0 .../Resources/Parser/Other/Kokomade.jpg | Bin .../TestingSupport}/TestError.swift | 0 .../Sources/TestingSupport/TestFixtures.swift | 12 +++++ .../TestingSupport}/TestHelper.swift | 7 +-- .../DownloadsFeatureTests/.swiftlint.yml | 1 + .../DataCacheTests.swift | 0 .../DatabaseClientUpdateTests.swift | 0 .../DetailReducerDownloadTests.swift | 0 .../DetailReducerMetadataTests.swift | 0 .../DetailReducerMetadataUpdateTests.swift | 0 .../DetailReducerObserveTests.swift | 0 .../DetailReducerPauseAndGuardTests.swift | 0 .../DownloadAutomationTests.swift | 0 .../DownloadBackgroundAssertionTests.swift | 0 .../DownloadBackgroundCompletionTests.swift | 0 .../DownloadBackgroundProcessingTests.swift | 0 .../DownloadBackgroundTaskStoreTests.swift | 0 .../DownloadBadgeSortTests.swift | 0 .../DownloadCoordinatorCachedURLTests.swift | 0 .../DownloadCoordinatorCaptureTests.swift | 0 .../DownloadCoordinatorRepairSeedTests.swift | 0 .../DownloadCoordinatorStorageTests.swift | 0 .../DownloadEnqueueManifestTests.swift | 0 .../DownloadFeatureTestFactories.swift | 0 .../DownloadFeatureTestHelpers.swift | 3 +- .../DownloadFeatureTestSupportTypes.swift | 0 .../DownloadFilterAndBadgeTests.swift | 0 .../DownloadFolderOperationTests.swift | 0 .../DownloadImageErrorTests.swift | 0 .../DownloadImageParsingCacheTests.swift | 0 .../DownloadImageParsingTests.swift | 0 .../DownloadInspectorLoadTests.swift | 0 .../DownloadInspectorRetryTests.swift | 0 .../DownloadInspectorSkipTests.swift | 0 .../DownloadInterruptedResumeTests.swift | 0 .../DownloadIpBanTests.swift | 1 + .../DownloadObserverBatchTests.swift | 0 .../DownloadObserverReadingTests.swift | 0 .../DownloadObserverRefreshTests.swift | 0 .../DownloadPauseAndReconcileTests.swift | 0 .../DownloadProcessCacheTests.swift | 0 .../DownloadProcessTests.swift | 0 .../DownloadQueueStoreTests.swift | 0 .../DownloadRetryMinimalSourceTests.swift | 0 .../DownloadRetryPagesTests.swift | 0 .../DownloadRetryUpdateFallbackTests.swift | 0 .../DownloadSchedulingTests.swift | 0 .../DownloadStoreHashTests.swift | 0 .../DownloadStoreRepairTests.swift | 0 .../DownloadStoreTests.swift | 0 .../DownloadVersionSignatureTests.swift | 0 .../DownloadedGalleryManifestModelTests.swift | 0 .../DownloadsReducerActionTests.swift | 0 .../DownloadsReducerReadingDismissTests.swift | 0 .../DownloadsReducerRefreshTests.swift | 0 .../FolderManagerReducerTests.swift | 0 .../PreviewsReducerDownloadTests.swift | 0 .../ReaderImageDataTests.swift | 3 +- .../ReadingReducerDownloadTests.swift | 0 .../ReadingReducerLocalTests.swift | 0 AppPackage/Tests/FeatureTests.xctestplan | 11 ++++- .../Tests/ParserFeatureTests/.swiftlint.yml | 1 + .../Gallery/GalleryDetailParserTests.swift | 1 + .../Gallery/GalleryImageURLParserTests.swift | 1 + .../Gallery/GalleryMPVKeysParserTests.swift | 1 + .../List/ListParserTests.swift | 1 + .../ListParserTestType.swift | 1 + .../Other/AnimatedImageDataTests.swift | 0 .../Other/BanIntervalParserTests.swift | 1 + .../Other/DownloadPageErrorParserTests.swift | 1 + .../Other/EhSettingParserTests.swift | 1 + .../Other/GreetingParserTests.swift | 1 + .../Other/SettingDownloadTests.swift | 0 .../xcshareddata/xcschemes/EhPanda.xcscheme | 2 +- 106 files changed, 76 insertions(+), 20 deletions(-) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/.swiftlint.yml (100%) rename AppPackage/{Tests/AppFeatureTests/Models => Sources/TestingSupport}/HTMLFilename.swift (97%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/Gallery/GalleryDetail.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/Gallery/GalleryMPVKeys.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/Gallery/GalleryNormalImageURL.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/FavoritesCompactList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/FavoritesExtendedList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/FavoritesMinimalList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/FavoritesMinimalPlusList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/FavoritesThumbnailList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/FrontPageCompactList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/FrontPageExtendedList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/FrontPageMinimalList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/FrontPageMinimalPlusList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/FrontPageThumbnailList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/PopularCompactList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/PopularExtendedList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/PopularMinimalList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/PopularMinimalPlusList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/PopularThumbnailList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/ToplistsCompactList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/WatchedCompactList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/WatchedExtendedList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/WatchedMinimalList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/WatchedMinimalPlusList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/List/WatchedThumbnailList.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/Other/BandwidthExceeded.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/Other/EhSetting.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/Other/ExLoginRequired.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/Other/GalleryDetailWithGreeting.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/Other/IPBanned.html (100%) rename AppPackage/{Tests/AppFeatureTests => Sources/TestingSupport}/Resources/Parser/Other/Kokomade.jpg (100%) rename AppPackage/{Tests/AppFeatureTests/Models => Sources/TestingSupport}/TestError.swift (100%) create mode 100644 AppPackage/Sources/TestingSupport/TestFixtures.swift rename AppPackage/{Tests/AppFeatureTests/Helpers => Sources/TestingSupport}/TestHelper.swift (69%) create mode 100644 AppPackage/Tests/DownloadsFeatureTests/.swiftlint.yml rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DataCacheTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DatabaseClientUpdateTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DetailReducerDownloadTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DetailReducerMetadataTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DetailReducerMetadataUpdateTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DetailReducerObserveTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DetailReducerPauseAndGuardTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadAutomationTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadBackgroundAssertionTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadBackgroundCompletionTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadBackgroundProcessingTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadBackgroundTaskStoreTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadBadgeSortTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadCoordinatorCachedURLTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadCoordinatorCaptureTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadCoordinatorRepairSeedTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadCoordinatorStorageTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadEnqueueManifestTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadFeatureTestFactories.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadFeatureTestHelpers.swift (98%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadFeatureTestSupportTypes.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadFilterAndBadgeTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadFolderOperationTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadImageErrorTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadImageParsingCacheTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadImageParsingTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadInspectorLoadTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadInspectorRetryTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadInspectorSkipTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadInterruptedResumeTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadIpBanTests.swift (99%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadObserverBatchTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadObserverReadingTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadObserverRefreshTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadPauseAndReconcileTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadProcessCacheTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadProcessTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadQueueStoreTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadRetryMinimalSourceTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadRetryPagesTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadRetryUpdateFallbackTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadSchedulingTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadStoreHashTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadStoreRepairTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadStoreTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadVersionSignatureTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadedGalleryManifestModelTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadsReducerActionTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadsReducerReadingDismissTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/DownloadsReducerRefreshTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/FolderManagerReducerTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/PreviewsReducerDownloadTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/ReaderImageDataTests.swift (99%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/ReadingReducerDownloadTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/Download => DownloadsFeatureTests}/ReadingReducerLocalTests.swift (100%) create mode 100644 AppPackage/Tests/ParserFeatureTests/.swiftlint.yml rename AppPackage/Tests/{AppFeatureTests/Tests/ParserFeature => ParserFeatureTests}/Gallery/GalleryDetailParserTests.swift (98%) rename AppPackage/Tests/{AppFeatureTests/Tests/ParserFeature => ParserFeatureTests}/Gallery/GalleryImageURLParserTests.swift (98%) rename AppPackage/Tests/{AppFeatureTests/Tests/ParserFeature => ParserFeatureTests}/Gallery/GalleryMPVKeysParserTests.swift (94%) rename AppPackage/Tests/{AppFeatureTests/Tests/ParserFeature => ParserFeatureTests}/List/ListParserTests.swift (99%) rename AppPackage/Tests/{AppFeatureTests/Models => ParserFeatureTests}/ListParserTestType.swift (99%) rename AppPackage/Tests/{AppFeatureTests/Tests/ParserFeature => ParserFeatureTests}/Other/AnimatedImageDataTests.swift (100%) rename AppPackage/Tests/{AppFeatureTests/Tests/ParserFeature => ParserFeatureTests}/Other/BanIntervalParserTests.swift (94%) rename AppPackage/Tests/{AppFeatureTests/Tests/ParserFeature => ParserFeatureTests}/Other/DownloadPageErrorParserTests.swift (99%) rename AppPackage/Tests/{AppFeatureTests/Tests/ParserFeature => ParserFeatureTests}/Other/EhSettingParserTests.swift (99%) rename AppPackage/Tests/{AppFeatureTests/Tests/ParserFeature => ParserFeatureTests}/Other/GreetingParserTests.swift (96%) rename AppPackage/Tests/{AppFeatureTests/Tests/ParserFeature => ParserFeatureTests}/Other/SettingDownloadTests.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 3ffb11d45..7930cb0f6 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -116,8 +116,12 @@ enum Module: String { case urlClient = "URLClient" case userDefaultsClient = "UserDefaultsClient" + // Test support + case testingSupport = "TestingSupport" + // Test targets - case appFeatureTests = "AppFeatureTests" + case parserFeatureTests = "ParserFeatureTests" + case downloadsFeatureTests = "DownloadsFeatureTests" } extension Module { @@ -933,10 +937,38 @@ let targets: [PackageDescription.Target] = [ plugins: swiftLintPlugins ), + // MARK: Test Support + .target( + module: .testingSupport, + dependencies: [ + .targetDependency(.kanna) + ], + resources: [.process(.resources)], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + // MARK: Tests .testTarget( - module: .appFeatureTests, + module: .parserFeatureTests, + dependencies: [ + .module(.testingSupport), + .module(.animatedImageFeature), + .module(.appFeature), + .module(.appModels), + .module(.appTools), + .module(.networkingFeature), + .module(.parserFeature), + .module(.urlClient), + .targetDependency(.kanna) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), + .testTarget( + module: .downloadsFeatureTests, dependencies: [ + .module(.testingSupport), .module(.appDelegateClient), .module(.appFeature), .module(.appLaunchAutomationClient), @@ -958,17 +990,13 @@ let targets: [PackageDescription.Target] = [ .module(.libraryClient), .module(.loggerClient), .module(.networkingFeature), - .module(.parserFeature), .module(.readingFeature), - .module(.animatedImageFeature), .module(.urlClient), .module(.userDefaultsClient), .targetDependency(.composableArchitecture), - .targetDependency(.kanna), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols) ], - resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ) @@ -980,7 +1008,7 @@ let package = Package( defaultLocalization: "en", platforms: [.iOS(.v26)], products: targets - .filter({ !$0.isTest }) + .filter({ !$0.isTest && $0.name != Module.testingSupport.rawValue }) .map(\.name) .map({ .library(name: $0, targets: [$0]) }), dependencies: dependencies, diff --git a/AppPackage/Tests/AppFeatureTests/.swiftlint.yml b/AppPackage/Sources/TestingSupport/.swiftlint.yml similarity index 100% rename from AppPackage/Tests/AppFeatureTests/.swiftlint.yml rename to AppPackage/Sources/TestingSupport/.swiftlint.yml diff --git a/AppPackage/Tests/AppFeatureTests/Models/HTMLFilename.swift b/AppPackage/Sources/TestingSupport/HTMLFilename.swift similarity index 97% rename from AppPackage/Tests/AppFeatureTests/Models/HTMLFilename.swift rename to AppPackage/Sources/TestingSupport/HTMLFilename.swift index 2bc59e6d2..76c198406 100644 --- a/AppPackage/Tests/AppFeatureTests/Models/HTMLFilename.swift +++ b/AppPackage/Sources/TestingSupport/HTMLFilename.swift @@ -1,6 +1,4 @@ -import AppModels - -enum HTMLFilename: String { +public enum HTMLFilename: String { // List // FrontPage case frontPageMinimalList = "FrontPageMinimalList" diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/Gallery/GalleryDetail.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Gallery/GalleryDetail.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/Gallery/GalleryDetail.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Gallery/GalleryDetail.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/Gallery/GalleryMPVKeys.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Gallery/GalleryMPVKeys.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/Gallery/GalleryMPVKeys.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Gallery/GalleryMPVKeys.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/Gallery/GalleryNormalImageURL.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Gallery/GalleryNormalImageURL.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/Gallery/GalleryNormalImageURL.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Gallery/GalleryNormalImageURL.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesCompactList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesCompactList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesCompactList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesCompactList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesExtendedList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesExtendedList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesExtendedList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesExtendedList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesMinimalList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesMinimalList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesMinimalList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesMinimalList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesMinimalPlusList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesMinimalPlusList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesMinimalPlusList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesMinimalPlusList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesThumbnailList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesThumbnailList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FavoritesThumbnailList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesThumbnailList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageCompactList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageCompactList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageCompactList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageCompactList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageExtendedList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageExtendedList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageExtendedList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageExtendedList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageMinimalList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageMinimalList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageMinimalList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageMinimalList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageMinimalPlusList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageMinimalPlusList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageMinimalPlusList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageMinimalPlusList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageThumbnailList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageThumbnailList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/FrontPageThumbnailList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageThumbnailList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularCompactList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularCompactList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularCompactList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularCompactList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularExtendedList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularExtendedList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularExtendedList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularExtendedList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularMinimalList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularMinimalList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularMinimalList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularMinimalList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularMinimalPlusList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularMinimalPlusList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularMinimalPlusList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularMinimalPlusList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularThumbnailList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularThumbnailList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/PopularThumbnailList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularThumbnailList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/ToplistsCompactList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/ToplistsCompactList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/ToplistsCompactList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/ToplistsCompactList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedCompactList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedCompactList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedCompactList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedCompactList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedExtendedList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedExtendedList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedExtendedList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedExtendedList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedMinimalList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedMinimalList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedMinimalList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedMinimalList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedMinimalPlusList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedMinimalPlusList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedMinimalPlusList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedMinimalPlusList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedThumbnailList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedThumbnailList.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/List/WatchedThumbnailList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedThumbnailList.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/BandwidthExceeded.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/BandwidthExceeded.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/BandwidthExceeded.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Other/BandwidthExceeded.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/EhSetting.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/EhSetting.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/EhSetting.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Other/EhSetting.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/ExLoginRequired.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/ExLoginRequired.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/ExLoginRequired.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Other/ExLoginRequired.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/GalleryDetailWithGreeting.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/GalleryDetailWithGreeting.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/GalleryDetailWithGreeting.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Other/GalleryDetailWithGreeting.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/IPBanned.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/IPBanned.html similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/IPBanned.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Other/IPBanned.html diff --git a/AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/Kokomade.jpg b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/Kokomade.jpg similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Resources/Parser/Other/Kokomade.jpg rename to AppPackage/Sources/TestingSupport/Resources/Parser/Other/Kokomade.jpg diff --git a/AppPackage/Tests/AppFeatureTests/Models/TestError.swift b/AppPackage/Sources/TestingSupport/TestError.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Models/TestError.swift rename to AppPackage/Sources/TestingSupport/TestError.swift diff --git a/AppPackage/Sources/TestingSupport/TestFixtures.swift b/AppPackage/Sources/TestingSupport/TestFixtures.swift new file mode 100644 index 000000000..98b61cba9 --- /dev/null +++ b/AppPackage/Sources/TestingSupport/TestFixtures.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Accessor for the test fixtures bundled with `TestingSupport`. +/// +/// Test targets that depend on `TestingSupport` cannot reach these resources through their own +/// `Bundle.module`, which resolves to the (resource-less) test bundle. Routing through this type +/// resolves `Bundle.module` inside `TestingSupport`, where the fixtures actually live. +public enum TestFixtures { + public static func url(forResource name: String, withExtension ext: String) -> URL? { + Bundle.module.url(forResource: name, withExtension: ext) + } +} diff --git a/AppPackage/Tests/AppFeatureTests/Helpers/TestHelper.swift b/AppPackage/Sources/TestingSupport/TestHelper.swift similarity index 69% rename from AppPackage/Tests/AppFeatureTests/Helpers/TestHelper.swift rename to AppPackage/Sources/TestingSupport/TestHelper.swift index 1ffda122c..588ec507f 100644 --- a/AppPackage/Tests/AppFeatureTests/Helpers/TestHelper.swift +++ b/AppPackage/Sources/TestingSupport/TestHelper.swift @@ -1,13 +1,10 @@ import Kanna -import Testing import Foundation -protocol TestHelper {} - -final class TestBundleLocator {} +public protocol TestHelper {} extension TestHelper { - func htmlDocument(filename: HTMLFilename) throws -> HTMLDocument { + public func htmlDocument(filename: HTMLFilename) throws -> HTMLDocument { guard let url = Bundle.module .url(forResource: filename.rawValue, withExtension: "html") else { diff --git a/AppPackage/Tests/DownloadsFeatureTests/.swiftlint.yml b/AppPackage/Tests/DownloadsFeatureTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DataCacheTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DataCacheTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DataCacheTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DatabaseClientUpdateTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DatabaseClientUpdateTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DatabaseClientUpdateTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DatabaseClientUpdateTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerDownloadTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerMetadataUpdateTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerObserveTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DetailReducerPauseAndGuardTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadAutomationTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundAssertionTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundAssertionTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundAssertionTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundAssertionTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundCompletionTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundCompletionTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundCompletionTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundCompletionTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundProcessingTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundProcessingTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundProcessingTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundTaskStoreTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBackgroundTaskStoreTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundTaskStoreTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadBadgeSortTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadBadgeSortTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadBadgeSortTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorCachedURLTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCachedURLTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorCachedURLTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorCaptureTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorCaptureTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorCaptureTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorRepairSeedTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorRepairSeedTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorRepairSeedTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadCoordinatorStorageTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadEnqueueManifestTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadEnqueueManifestTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadEnqueueManifestTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestFactories.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestFactories.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestFactories.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestHelpers.swift similarity index 98% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestHelpers.swift index 07e17b7e4..b034315bc 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestHelpers.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestHelpers.swift @@ -1,3 +1,4 @@ +import TestingSupport import AppTools import Foundation import AppModels @@ -207,7 +208,7 @@ extension DownloadFeatureTestCase { pathExtension: String ) throws -> Data { let fixtureURL = try #require( - Bundle.module.url(forResource: resource, withExtension: pathExtension) + TestFixtures.url(forResource: resource, withExtension: pathExtension) ) return try Data(contentsOf: fixtureURL) } diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestSupportTypes.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestSupportTypes.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFeatureTestSupportTypes.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestSupportTypes.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFilterAndBadgeTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFilterAndBadgeTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadFilterAndBadgeTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFolderOperationTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadFolderOperationTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadFolderOperationTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageErrorTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageErrorTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageErrorTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadImageErrorTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingCacheTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingCacheTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingCacheTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadImageParsingTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorLoadTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorRetryTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorRetryTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorRetryTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorSkipTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInspectorSkipTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorSkipTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInterruptedResumeTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadInterruptedResumeTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadInterruptedResumeTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadIpBanTests.swift similarity index 99% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadIpBanTests.swift index feb3fcd8b..53fa471b3 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadIpBanTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadIpBanTests.swift @@ -1,3 +1,4 @@ +import TestingSupport import Foundation import AppModels import Testing diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverBatchTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverBatchTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadObserverBatchTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverReadingTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadObserverRefreshTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadPauseAndReconcileTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadPauseAndReconcileTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadPauseAndReconcileTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadProcessCacheTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessCacheTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadProcessCacheTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadProcessTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadProcessTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadProcessTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadQueueStoreTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadQueueStoreTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadQueueStoreTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadQueueStoreTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadRetryMinimalSourceTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryMinimalSourceTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadRetryMinimalSourceTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadRetryPagesTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryPagesTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadRetryPagesTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadRetryUpdateFallbackTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadRetryUpdateFallbackTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadSchedulingTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadSchedulingTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadSchedulingTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreHashTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreRepairTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreRepairTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadStoreRepairTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadStoreTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadVersionSignatureTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadVersionSignatureTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadVersionSignatureTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadedGalleryManifestModelTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadedGalleryManifestModelTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadedGalleryManifestModelTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadedGalleryManifestModelTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerActionTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerReadingDismissTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerRefreshTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/DownloadsReducerRefreshTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerRefreshTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift b/AppPackage/Tests/DownloadsFeatureTests/FolderManagerReducerTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/FolderManagerReducerTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/FolderManagerReducerTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/PreviewsReducerDownloadTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReaderImageDataTests.swift similarity index 99% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/ReaderImageDataTests.swift index 68fce904b..2625acafd 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReaderImageDataTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/ReaderImageDataTests.swift @@ -1,3 +1,4 @@ +import TestingSupport import Foundation import AppModels import Testing @@ -300,7 +301,7 @@ struct ReaderImageDataTests { private func fixtureData(resource: String, pathExtension: String) throws -> Data { let fixtureURL = try #require( - Bundle.module.url(forResource: resource, withExtension: pathExtension) + TestFixtures.url(forResource: resource, withExtension: pathExtension) ) return try Data(contentsOf: fixtureURL) } diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerDownloadTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/Download/ReadingReducerLocalTests.swift rename to AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift diff --git a/AppPackage/Tests/FeatureTests.xctestplan b/AppPackage/Tests/FeatureTests.xctestplan index 3394f3626..b5170aa0f 100644 --- a/AppPackage/Tests/FeatureTests.xctestplan +++ b/AppPackage/Tests/FeatureTests.xctestplan @@ -15,8 +15,15 @@ { "target" : { "containerPath" : "container:AppPackage", - "identifier" : "AppFeatureTests", - "name" : "AppFeatureTests" + "identifier" : "DownloadsFeatureTests", + "name" : "DownloadsFeatureTests" + } + }, + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "ParserFeatureTests", + "name" : "ParserFeatureTests" } } ], diff --git a/AppPackage/Tests/ParserFeatureTests/.swiftlint.yml b/AppPackage/Tests/ParserFeatureTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/ParserFeatureTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryDetailParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryDetailParserTests.swift similarity index 98% rename from AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryDetailParserTests.swift rename to AppPackage/Tests/ParserFeatureTests/Gallery/GalleryDetailParserTests.swift index 285c472c2..ec034c07a 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryDetailParserTests.swift +++ b/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryDetailParserTests.swift @@ -1,3 +1,4 @@ +import TestingSupport import Kanna import Testing import ParserFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryImageURLParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryImageURLParserTests.swift similarity index 98% rename from AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryImageURLParserTests.swift rename to AppPackage/Tests/ParserFeatureTests/Gallery/GalleryImageURLParserTests.swift index 4bd928f8e..13499a94e 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryImageURLParserTests.swift +++ b/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryImageURLParserTests.swift @@ -1,3 +1,4 @@ +import TestingSupport import Kanna import Testing import ParserFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryMPVKeysParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryMPVKeysParserTests.swift similarity index 94% rename from AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryMPVKeysParserTests.swift rename to AppPackage/Tests/ParserFeatureTests/Gallery/GalleryMPVKeysParserTests.swift index e59b20e27..36bc8528b 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Gallery/GalleryMPVKeysParserTests.swift +++ b/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryMPVKeysParserTests.swift @@ -1,3 +1,4 @@ +import TestingSupport import Kanna import Testing import ParserFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/List/ListParserTests.swift b/AppPackage/Tests/ParserFeatureTests/List/ListParserTests.swift similarity index 99% rename from AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/List/ListParserTests.swift rename to AppPackage/Tests/ParserFeatureTests/List/ListParserTests.swift index adf7489ee..dc3d30e71 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/List/ListParserTests.swift +++ b/AppPackage/Tests/ParserFeatureTests/List/ListParserTests.swift @@ -1,3 +1,4 @@ +import TestingSupport import AppTools import Foundation import AppModels diff --git a/AppPackage/Tests/AppFeatureTests/Models/ListParserTestType.swift b/AppPackage/Tests/ParserFeatureTests/ListParserTestType.swift similarity index 99% rename from AppPackage/Tests/AppFeatureTests/Models/ListParserTestType.swift rename to AppPackage/Tests/ParserFeatureTests/ListParserTestType.swift index e5cca70f6..de4b22ce1 100644 --- a/AppPackage/Tests/AppFeatureTests/Models/ListParserTestType.swift +++ b/AppPackage/Tests/ParserFeatureTests/ListParserTestType.swift @@ -1,3 +1,4 @@ +import TestingSupport enum ListParserTestType: CaseIterable { // FrontPage case frontPageMinimalList diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/AnimatedImageDataTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/AnimatedImageDataTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/AnimatedImageDataTests.swift rename to AppPackage/Tests/ParserFeatureTests/Other/AnimatedImageDataTests.swift diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/BanIntervalParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/BanIntervalParserTests.swift similarity index 94% rename from AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/BanIntervalParserTests.swift rename to AppPackage/Tests/ParserFeatureTests/Other/BanIntervalParserTests.swift index 8547bb379..740f78918 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/BanIntervalParserTests.swift +++ b/AppPackage/Tests/ParserFeatureTests/Other/BanIntervalParserTests.swift @@ -1,3 +1,4 @@ +import TestingSupport import Kanna import Testing import ParserFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/DownloadPageErrorParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/DownloadPageErrorParserTests.swift similarity index 99% rename from AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/DownloadPageErrorParserTests.swift rename to AppPackage/Tests/ParserFeatureTests/Other/DownloadPageErrorParserTests.swift index 46de24ebb..046855ff5 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/DownloadPageErrorParserTests.swift +++ b/AppPackage/Tests/ParserFeatureTests/Other/DownloadPageErrorParserTests.swift @@ -1,3 +1,4 @@ +import TestingSupport import Kanna import AppModels import Combine diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/EhSettingParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/EhSettingParserTests.swift similarity index 99% rename from AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/EhSettingParserTests.swift rename to AppPackage/Tests/ParserFeatureTests/Other/EhSettingParserTests.swift index 4fde3ab8a..a131dec6e 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/EhSettingParserTests.swift +++ b/AppPackage/Tests/ParserFeatureTests/Other/EhSettingParserTests.swift @@ -1,3 +1,4 @@ +import TestingSupport import Kanna import AppModels import Testing diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/GreetingParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/GreetingParserTests.swift similarity index 96% rename from AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/GreetingParserTests.swift rename to AppPackage/Tests/ParserFeatureTests/Other/GreetingParserTests.swift index 04cc989ad..3292848e5 100644 --- a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/GreetingParserTests.swift +++ b/AppPackage/Tests/ParserFeatureTests/Other/GreetingParserTests.swift @@ -1,3 +1,4 @@ +import TestingSupport import Kanna import Testing import ParserFeature diff --git a/AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/SettingDownloadTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/SettingDownloadTests.swift similarity index 100% rename from AppPackage/Tests/AppFeatureTests/Tests/ParserFeature/Other/SettingDownloadTests.swift rename to AppPackage/Tests/ParserFeatureTests/Other/SettingDownloadTests.swift diff --git a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme index e32bba493..a9b13c51e 100644 --- a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme +++ b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> From 29b01d986edc34258fcad2e4fb7fc05f18294137 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 08:48:23 +0800 Subject: [PATCH 366/614] Make cancelled-cleanup scheduling test deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testCancelledProcessCleanupDoesNotClearNewerActiveTask waited for the paused task's active-slot clear by polling testingActiveGalleryID against a 1-second wall-clock deadline, which intermittently failed under parallel test load (executor starvation). Replace the poll with a cancellation-event handshake: the gate's first download now parks inside withTaskCancellationHandler and signals waitForFirstCancellation() the moment pause() cancels it. Actor mutual exclusion then guarantees the active slot is already cleared before the next scheduleNextIfNeeded() runs, with no timing dependency. The first download still stays parked past cancellation so its late cleanup fires only after the second task is active — preserving the generation-guard behaviour under test. Verified: 40 run-tests-until-failure iterations, all green. --- .../DownloadSchedulingTests.swift | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadSchedulingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadSchedulingTests.swift index b4308ddfe..4c081709c 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadSchedulingTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadSchedulingTests.swift @@ -124,7 +124,12 @@ struct DownloadSchedulingTests: DownloadFeatureTestCase { let pauseTask = Task { await manager.pause(gid: firstGID) } - try await waitForActiveGalleryID(manager, toEqual: nil) + // Pause cancels the first task and clears the active slot synchronously + // before suspending on the cancelled task's completion. Awaiting that + // cancellation event (instead of polling activeGalleryID against a + // wall-clock deadline) lets actor mutual exclusion guarantee the slot is + // already clear by the time the next scheduleNextIfNeeded() runs. + await gate.waitForFirstCancellation() await manager.scheduleNextIfNeeded() await gate.waitForSecondStart() @@ -164,24 +169,6 @@ private extension DownloadSchedulingTests { folderURL: folderURL ) } - - func waitForActiveGalleryID( - _ manager: DownloadCoordinator, - toEqual expected: String?, - timeout: Duration = .seconds(1) - ) async throws { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - - while await manager.testingActiveGalleryID() != expected, - clock.now < deadline { - try? await Task.sleep(for: .milliseconds(10)) - } - try #require( - await manager.testingActiveGalleryID() == expected, - "Timed out waiting for activeGalleryID to become \(String(describing: expected))." - ) - } } private actor ScheduleFetchGate { @@ -219,8 +206,10 @@ private actor ScheduleFetchGate { private actor ScheduledProcessCleanupGate { private let firstGID: String private var firstArrived = false + private var firstCancelled = false private var secondStarted = false private var firstArrivalContinuation: CheckedContinuation? + private var firstCancellationContinuation: CheckedContinuation? private var secondStartContinuation: CheckedContinuation? private var releaseFirstContinuation: CheckedContinuation? @@ -246,6 +235,13 @@ private actor ScheduledProcessCleanupGate { } } + func waitForFirstCancellation() async { + guard !firstCancelled else { return } + await withCheckedContinuation { continuation in + firstCancellationContinuation = continuation + } + } + func waitForSecondStart() async { guard !secondStarted else { return } await withCheckedContinuation { continuation in @@ -262,11 +258,26 @@ private actor ScheduledProcessCleanupGate { firstArrived = true firstArrivalContinuation?.resume() firstArrivalContinuation = nil - await withCheckedContinuation { continuation in - releaseFirstContinuation = continuation + // Stay parked past cancellation: the first task's cleanup must fire only + // after the second task becomes active. The cancellation handler merely + // reports that pause() has cancelled this task, which is the point at + // which it has cleared the active slot. + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + releaseFirstContinuation = continuation + } + } onCancel: { + Task { await self.markFirstCancelled() } } } + private func markFirstCancelled() { + guard !firstCancelled else { return } + firstCancelled = true + firstCancellationContinuation?.resume() + firstCancellationContinuation = nil + } + private func startSecond() { secondStarted = true secondStartContinuation?.resume() From fce7f023b40b0e7c7597016839c4a205d1b8bafb Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 11:50:39 +0800 Subject: [PATCH 367/614] Add OSLogExt module and app identifier --- AppPackage/Package.swift | 9 +++++++++ AppPackage/Sources/AppTools/Defaults.swift | 3 +++ AppPackage/Sources/OSLogExt/.swiftlint.yml | 1 + AppPackage/Sources/OSLogExt/Logger+.swift | 11 +++++++++++ 4 files changed, 24 insertions(+) create mode 100644 AppPackage/Sources/OSLogExt/.swiftlint.yml create mode 100644 AppPackage/Sources/OSLogExt/Logger+.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 7930cb0f6..cad8971eb 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -102,6 +102,7 @@ enum Module: String { case migrationFeature = "MigrationFeature" case networkingFeature = "NetworkingFeature" case openCCExt = "OpenCCExt" + case osLogExt = "OSLogExt" case parserFeature = "ParserFeature" case quickSearchFeature = "QuickSearchFeature" case readingFeature = "ReadingFeature" @@ -586,6 +587,14 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .osLogExt, + dependencies: [ + .module(.appTools) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .tagTranslationFeature, dependencies: [ diff --git a/AppPackage/Sources/AppTools/Defaults.swift b/AppPackage/Sources/AppTools/Defaults.swift index def590f3e..af3582a65 100644 --- a/AppPackage/Sources/AppTools/Defaults.swift +++ b/AppPackage/Sources/AppTools/Defaults.swift @@ -2,6 +2,9 @@ import CoreGraphics import Foundation public struct Defaults: Sendable { + public struct App: Sendable { + public static let identifier = "app.ehpanda" + } public struct ImageSize: Sendable { public static let rowAspect: CGFloat = 8/11 public static let headerAspect: CGFloat = 8/11 diff --git a/AppPackage/Sources/OSLogExt/.swiftlint.yml b/AppPackage/Sources/OSLogExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/OSLogExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/OSLogExt/Logger+.swift b/AppPackage/Sources/OSLogExt/Logger+.swift new file mode 100644 index 000000000..d4b9d7b84 --- /dev/null +++ b/AppPackage/Sources/OSLogExt/Logger+.swift @@ -0,0 +1,11 @@ +@_exported import OSLog +import AppTools + +public extension Logger { + init(moduleName: String, category: String) { + self.init( + subsystem: [Defaults.App.identifier, moduleName].joined(separator: "."), + category: category + ) + } +} From 181018b4d46e450055e0ac4e363654e55d5f4f80 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 12:08:48 +0800 Subject: [PATCH 368/614] Use OSLog in BackgroundProcessingClient --- AppPackage/Package.swift | 2 +- .../BackgroundProcessingClient.swift | 3 +-- AppPackage/Sources/BackgroundProcessingClient/Logger+.swift | 6 ++++++ 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 AppPackage/Sources/BackgroundProcessingClient/Logger+.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index cad8971eb..d9291169f 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -439,7 +439,7 @@ let targets: [PackageDescription.Target] = [ module: .backgroundProcessingClient, dependencies: [ .module(.appModels), - .module(.swiftyBeaverExt), + .module(.osLogExt), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, diff --git a/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift b/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift index 549105d81..912bd987f 100644 --- a/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift +++ b/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift @@ -1,6 +1,5 @@ import BackgroundTasks import AppModels -import SwiftyBeaverExt import ComposableArchitecture public enum BackgroundProcessing { @@ -51,7 +50,7 @@ extension BackgroundProcessingClient { do { try BGTaskScheduler.shared.submit(request) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") } }, cancel: { diff --git a/AppPackage/Sources/BackgroundProcessingClient/Logger+.swift b/AppPackage/Sources/BackgroundProcessingClient/Logger+.swift new file mode 100644 index 000000000..65b92ebf1 --- /dev/null +++ b/AppPackage/Sources/BackgroundProcessingClient/Logger+.swift @@ -0,0 +1,6 @@ +import OSLogExt + +let logger = Logger( + moduleName: "BackgroundProcessingClient", + category: .init(describing: BackgroundProcessingClient.self) +) From 1210a51a2b4cb1f1273a464229147b99950aaecd Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 12:10:13 +0800 Subject: [PATCH 369/614] Use OSLog in AppModels --- AppPackage/Package.swift | 2 +- AppPackage/Sources/AppModels/Gallery/Category.swift | 3 +-- AppPackage/Sources/AppModels/Logger+.swift | 6 ++++++ 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 AppPackage/Sources/AppModels/Logger+.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index d9291169f..00a722970 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -323,7 +323,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appTools), .module(.resources), - .module(.swiftyBeaverExt), + .module(.osLogExt), .targetDependency(.casePaths) ], swiftSettings: sharedSwiftSettings, diff --git a/AppPackage/Sources/AppModels/Gallery/Category.swift b/AppPackage/Sources/AppModels/Gallery/Category.swift index dd380d49f..7047e31fb 100644 --- a/AppPackage/Sources/AppModels/Gallery/Category.swift +++ b/AppPackage/Sources/AppModels/Gallery/Category.swift @@ -1,5 +1,4 @@ import SwiftUI -import SwiftyBeaverExt import Resources public enum Category: String, Codable, CaseIterable, Identifiable, Sendable { @@ -39,7 +38,7 @@ extension Category { case .misc: return 1 case .private: let message = "`Private` doesn't have a `filterValue`!" - Logger.error(message) + logger.error("\(message, privacy: .public)") fatalError(message) } } diff --git a/AppPackage/Sources/AppModels/Logger+.swift b/AppPackage/Sources/AppModels/Logger+.swift new file mode 100644 index 000000000..6867fffb6 --- /dev/null +++ b/AppPackage/Sources/AppModels/Logger+.swift @@ -0,0 +1,6 @@ +import OSLogExt + +let logger = Logger( + moduleName: "AppModels", + category: .init(describing: Category.self) +) From 0a780e2d38ced06275a02ff833f66440c03ab1e0 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 12:11:02 +0800 Subject: [PATCH 370/614] Use OSLog in ParserFeature --- AppPackage/Package.swift | 2 +- AppPackage/Sources/ParserFeature/Logger+.swift | 6 ++++++ AppPackage/Sources/ParserFeature/Parser+Shared.swift | 7 +------ 3 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 AppPackage/Sources/ParserFeature/Logger+.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 00a722970..c7cad7792 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -911,7 +911,7 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.appTools), .module(.resources), - .module(.swiftyBeaverExt), + .module(.osLogExt), .targetDependency(.kanna) ], swiftSettings: sharedSwiftSettings, diff --git a/AppPackage/Sources/ParserFeature/Logger+.swift b/AppPackage/Sources/ParserFeature/Logger+.swift new file mode 100644 index 000000000..fb51fc84e --- /dev/null +++ b/AppPackage/Sources/ParserFeature/Logger+.swift @@ -0,0 +1,6 @@ +import OSLogExt + +let logger = Logger( + moduleName: "ParserFeature", + category: .init(describing: Parser.self) +) diff --git a/AppPackage/Sources/ParserFeature/Parser+Shared.swift b/AppPackage/Sources/ParserFeature/Parser+Shared.swift index 0c28eeac2..c9856c722 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Shared.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Shared.swift @@ -1,6 +1,5 @@ import Kanna import AppModels -import SwiftyBeaverExt import Foundation import AppTools @@ -190,11 +189,7 @@ extension Parser { return .minutes(minutes, seconds: nil) } } else { - Logger.error( - "Unrecognized BanInterval format", context: [ - "expireDescription": expireDescription - ] - ) + logger.error("Unrecognized BanInterval format: \(expireDescription, privacy: .public)") return .unrecognized(content: expireDescription) } } From 5b9145954f7efb34ea5ba783725606ce9cd872fb Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 12:12:21 +0800 Subject: [PATCH 371/614] Use OSLog in DatabaseClient --- AppPackage/Package.swift | 2 +- .../DatabaseClient/Database/Extensions/Logger+.swift | 6 ++++++ AppPackage/Sources/DatabaseClient/DatabaseClient.swift | 5 ++--- 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 AppPackage/Sources/DatabaseClient/Database/Extensions/Logger+.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index c7cad7792..c6f657acb 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -495,7 +495,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.appTools), - .module(.swiftyBeaverExt), + .module(.osLogExt), .targetDependency(.composableArchitecture) ], resources: [.process(.resources)], diff --git a/AppPackage/Sources/DatabaseClient/Database/Extensions/Logger+.swift b/AppPackage/Sources/DatabaseClient/Database/Extensions/Logger+.swift new file mode 100644 index 000000000..d2a72fd43 --- /dev/null +++ b/AppPackage/Sources/DatabaseClient/Database/Extensions/Logger+.swift @@ -0,0 +1,6 @@ +import OSLogExt + +let logger = Logger( + moduleName: "DatabaseClient", + category: .init(describing: DatabaseClient.self) +) diff --git a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift index 451a327a8..219d65e34 100644 --- a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift +++ b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift @@ -1,6 +1,5 @@ import SwiftUI import AppModels -import SwiftyBeaverExt import Combine import CoreData import ComposableArchitecture @@ -41,7 +40,7 @@ extension DatabaseClient { do { try context.save() } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") fatalError("Unresolved error \(error)") } } @@ -63,7 +62,7 @@ extension DatabaseClient { do { try context.save() } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") fatalError("Unresolved error \(error)") } } From dd9ed764bcc7237f81fad2019112b64607581253 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 12:13:39 +0800 Subject: [PATCH 372/614] Use OSLog in SettingFeature WebView --- AppPackage/Package.swift | 2 +- AppPackage/Sources/SettingFeature/Components/WebView.swift | 3 +-- AppPackage/Sources/SettingFeature/Logger+.swift | 6 ++++++ 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 AppPackage/Sources/SettingFeature/Logger+.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index c6f657acb..8c8c3f590 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -732,10 +732,10 @@ let targets: [PackageDescription.Target] = [ .module(.libraryClient), .module(.loggerClient), .module(.networkingFeature), + .module(.osLogExt), .module(.readingSettingFeature), .module(.resources), .module(.swiftUINavigationExt), - .module(.swiftyBeaverExt), .module(.ttProgressHUDExt), .module(.userDefaultsClient), .targetDependency(.composableArchitecture), diff --git a/AppPackage/Sources/SettingFeature/Components/WebView.swift b/AppPackage/Sources/SettingFeature/Components/WebView.swift index 21b4dd7fe..72565e319 100644 --- a/AppPackage/Sources/SettingFeature/Components/WebView.swift +++ b/AppPackage/Sources/SettingFeature/Components/WebView.swift @@ -1,7 +1,6 @@ import AppTools import WebKit import AppModels -import SwiftyBeaverExt import SwiftUI struct WebView: UIViewControllerRepresentable { @@ -39,7 +38,7 @@ struct WebView: UIViewControllerRepresentable { } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - Logger.error(error) + logger.error("\(error, privacy: .public)") } } diff --git a/AppPackage/Sources/SettingFeature/Logger+.swift b/AppPackage/Sources/SettingFeature/Logger+.swift new file mode 100644 index 000000000..22a7eef37 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/Logger+.swift @@ -0,0 +1,6 @@ +import OSLogExt + +let logger = Logger( + moduleName: "SettingFeature", + category: .init(describing: WebView.self) +) From f78011f1381045b83c290304ac3ca93870f1fcbd Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 12:19:11 +0800 Subject: [PATCH 373/614] Use OSLog in NetworkingFeature --- AppPackage/Package.swift | 2 +- .../NetworkingFeature/DFExtensions.swift | 15 ++++++-------- .../Sources/NetworkingFeature/DFRequest.swift | 7 +++++-- .../NetworkingFeature/DFStreamHandler.swift | 20 ++++++++++--------- .../NetworkingFeature/DFURLProtocol.swift | 8 +++++--- .../Sources/NetworkingFeature/Logger+.swift | 7 +++++++ 6 files changed, 35 insertions(+), 24 deletions(-) create mode 100644 AppPackage/Sources/NetworkingFeature/Logger+.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 8c8c3f590..6e911950e 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -481,8 +481,8 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.appTools), .module(.openCCExt), + .module(.osLogExt), .module(.parserFeature), - .module(.swiftyBeaverExt), .targetDependency(.composableArchitecture), .targetDependency(.deprecatedAPI), .targetDependency(.kanna) diff --git a/AppPackage/Sources/NetworkingFeature/DFExtensions.swift b/AppPackage/Sources/NetworkingFeature/DFExtensions.swift index 01e8211ec..1cbf440ae 100644 --- a/AppPackage/Sources/NetworkingFeature/DFExtensions.swift +++ b/AppPackage/Sources/NetworkingFeature/DFExtensions.swift @@ -1,20 +1,17 @@ import Foundation import AppModels -import SwiftyBeaverExt +import OSLogExt import DeprecatedAPI import AppTools +private let logger = Logger(category: "DFExtensions") + // MARK: Global private func forceDowncast(object: Any) -> T! { if let downcastedValue = object as? T { return downcastedValue } - Logger.error( - "Failed in force downcasting...", - context: [ - "type": T.self - ] - ) + logger.error("Failed in force downcasting to type: \(String(describing: T.self), privacy: .public)") return nil } @@ -135,10 +132,10 @@ extension URLRequest { if readSize > 0 { body.append(buffer, count: readSize) } else if readSize == 0 { - Logger.verbose("HTTPBodyStream read EOF.") + logger.debug("HTTPBodyStream read EOF.") } else { if let error = stream.streamError as Error? { - Logger.error("HTTPBodyStream read Error: \(error).") + logger.error("HTTPBodyStream read Error: \(error, privacy: .public)") } } } while readSize > 0 diff --git a/AppPackage/Sources/NetworkingFeature/DFRequest.swift b/AppPackage/Sources/NetworkingFeature/DFRequest.swift index 207936572..fbc7d7c48 100644 --- a/AppPackage/Sources/NetworkingFeature/DFRequest.swift +++ b/AppPackage/Sources/NetworkingFeature/DFRequest.swift @@ -1,6 +1,8 @@ import Foundation import AppModels -import SwiftyBeaverExt +import OSLogExt + +private let logger = Logger(category: .init(describing: DFRequest.self)) public struct DFRequest { public var request: URLRequest @@ -40,7 +42,8 @@ public struct DFRequest { public mutating func resume() { if !request.urlContainsImageURL { - Logger.verbose("Request from: \(request.url?.absoluteString ?? "")") + let urlString = request.url?.absoluteString ?? "" + logger.debug("Request from: \(urlString)") } stream.schedule(in: RunLoop.current, forMode: .common) diff --git a/AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift b/AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift index 15e061a57..e1318589d 100644 --- a/AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift +++ b/AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift @@ -1,6 +1,8 @@ import Foundation import AppModels -import SwiftyBeaverExt +import OSLogExt + +private let logger = Logger(category: .init(describing: DFStreamEventHandler.self)) public class DFStreamEventHandler: NSObject { private var request: DFRequest @@ -78,7 +80,7 @@ private extension DFStreamEventHandler { if SecTrustEvaluateWithError(serverTrust, &error) { return true } else { - Logger.error(error as Any) + logger.error("\(String(describing: error), privacy: .public)") return false } } @@ -88,7 +90,7 @@ private extension DFStreamEventHandler { extension DFStreamEventHandler: StreamDelegate { public func stream(_ aStream: Stream, handle eventCode: Stream.Event) { guard let input = aStream as? InputStream else { - Logger.error("Unexpected stream, should be a InputStream, but \(aStream).") + logger.error("Unexpected stream, should be a InputStream, but \(aStream, privacy: .public).") return } @@ -113,14 +115,14 @@ private extension DFStreamEventHandler { func openCompleted() { if !request.request.urlContainsImageURL { let urlString = request.request.url?.absoluteString ?? "" - Logger.verbose("Stream open completed for: \(urlString).") + logger.debug("Stream open completed for: \(urlString).") } } func endEncountered(_ stream: InputStream) { if !request.request.urlContainsImageURL { let urlString = request.request.url?.absoluteString ?? "" - Logger.verbose("Stream end off for: \(urlString).") + logger.debug("Stream end off for: \(urlString).") } let message = stream.httpMessage() @@ -131,7 +133,7 @@ private extension DFStreamEventHandler { } else { if !self.request.request.urlContainsImageURL { let urlString = self.request.request.url?.absoluteString ?? "" - Logger.verbose("Request loading finished for: \(urlString).") + logger.debug("Request loading finished for: \(urlString).") } self.request.delegate?.dfRequestDidFinishLoading(self.request) } @@ -161,7 +163,7 @@ private extension DFStreamEventHandler { url = originalURL.appendingPathComponent(url.absoluteString) } - Logger.warning("Request redirected to: \(url.absoluteString).") + logger.warning("Request redirected to: \(url.absoluteString).") var req = URLRequest(url: url) req.httpMethod = "GET" @@ -180,7 +182,7 @@ private extension DFStreamEventHandler { if let err = stream.streamError as NSError? { if !request.request.urlContainsImageURL { let urlString = request.request.url?.absoluteString ?? "" - Logger.error("\(stream) Occurred error: \(err) for: \(urlString).") + logger.error("\(stream, privacy: .public) Occurred error: \(err, privacy: .public) for: \(urlString).") } request.delegate?.dfRequest( request.request, @@ -191,6 +193,6 @@ private extension DFStreamEventHandler { } func defaultHandle(event: Stream.Event) { - Logger.error("An unexpected Evnet: \(event) occurred.") + logger.error("An unexpected event: \(String(describing: event), privacy: .public) occurred.") } } diff --git a/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift b/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift index 82e67e1b5..9555bcc83 100644 --- a/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift +++ b/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift @@ -1,7 +1,9 @@ import AppTools import Foundation import AppModels -import SwiftyBeaverExt +import OSLogExt + +private let logger = Logger(category: .init(describing: DFURLProtocol.self)) public class DFURLProtocol: URLProtocol { private var dfRequest: DFRequest? @@ -11,12 +13,12 @@ public class DFURLProtocol: URLProtocol { for request: URLRequest) -> URLRequest { request } public override class func canInit(with request: URLRequest) -> Bool { if property(forKey: requestIdentifier, in: request) != nil { - Logger.error("URLRequest has been initialized.") + logger.error("URLRequest has been initialized.") return false } if !["http", "https"].contains(request.url?.scheme) { let scheme = request.url?.scheme ?? "nil" - Logger.error("URL scheme \"\(scheme)\" is not supported.") + logger.error("URL scheme \"\(scheme, privacy: .public)\" is not supported.") return false } return true diff --git a/AppPackage/Sources/NetworkingFeature/Logger+.swift b/AppPackage/Sources/NetworkingFeature/Logger+.swift new file mode 100644 index 000000000..e93b73183 --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "NetworkingFeature", category: category) + } +} From 3b5db5b83a013c6f671240cbbcdc3879af97be1c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 12:23:47 +0800 Subject: [PATCH 374/614] Use OSLog in ReadingFeature, drop UI noise --- AppPackage/Package.swift | 2 +- .../Sources/ReadingFeature/Logger+.swift | 7 ++++++ .../ReadingFeature/ReadingView+Gestures.swift | 2 -- .../Sources/ReadingFeature/ReadingView.swift | 24 +++++-------------- .../Support/AutoPlayHandler.swift | 3 --- .../Support/GestureHandler.swift | 14 ----------- .../Support/LiveTextHandler.swift | 16 ++++--------- .../ReadingFeature/Support/LiveTextView.swift | 2 -- .../ReadingFeature/Support/PageHandler.swift | 7 +----- 9 files changed, 19 insertions(+), 58 deletions(-) create mode 100644 AppPackage/Sources/ReadingFeature/Logger+.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 6e911950e..d9f1c30e7 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -851,11 +851,11 @@ let targets: [PackageDescription.Target] = [ .module(.hapticsClient), .module(.imageClient), .module(.networkingFeature), + .module(.osLogExt), .module(.readingSettingFeature), .module(.resources), .module(.animatedImageFeature), .module(.swiftUINavigationExt), - .module(.swiftyBeaverExt), .module(.ttProgressHUDExt), .module(.urlClient), .targetDependency(.composableArchitecture), diff --git a/AppPackage/Sources/ReadingFeature/Logger+.swift b/AppPackage/Sources/ReadingFeature/Logger+.swift new file mode 100644 index 000000000..3e9822c84 --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "ReadingFeature", category: category) + } +} diff --git a/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift b/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift index 26e72c4fe..9cb1bc883 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift @@ -1,6 +1,5 @@ import SwiftUI import AppModels -import SwiftyBeaverExt // MARK: Gesture extension ReadingView { @@ -12,7 +11,6 @@ extension ReadingView { setPageIndexOffsetAction: { let newValue = page.index + $0 page.update(.new(index: newValue)) - Logger.info("Pager.update", context: ["update": newValue]) }, toggleShowsPanelAction: { store.send(.toggleShowsPanel) } ) diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 207c3d59b..6be7742d1 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -1,6 +1,6 @@ import SwiftUI import AppModels -import SwiftyBeaverExt +import OSLogExt import Observation import SFSafeSymbols import SwiftUIPager @@ -11,6 +11,8 @@ import TTProgressHUDExt import AppComponents import ReadingSettingFeature +private let logger = Logger(category: .init(describing: ReadingView.self)) + public struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme @@ -181,20 +183,15 @@ public struct ReadingView: View { pageAndAutoPlayTriggers(content: content) // LiveText .onChange(of: liveTextHandler.enablesLiveText) { _, newValue in - Logger.info("liveTextHandler.enablesLiveText changed", context: ["isEnabled": newValue]) if newValue { store.webImageLoadSuccessIndices.forEach(analyzeImageForLiveText) } } .onChange(of: store.webImageLoadSuccessIndices) { _, newValue in - Logger.info("store.webImageLoadSuccessIndices changed", context: [ - "count": store.webImageLoadSuccessIndices.count - ]) if liveTextHandler.enablesLiveText { newValue.forEach(analyzeImageForLiveText) } } // Orientation .onChange(of: setting.enablesLandscape) { _, newValue in - Logger.info("setting.enablesLandscape changed", context: ["newValue": newValue]) store.send(.setOrientationPortrait(!newValue)) } } @@ -204,7 +201,6 @@ public struct ReadingView: View { content() // Page .onChange(of: page.index) { _, newValue in - Logger.info("page.index changed", context: ["pageIndex": newValue]) let newValue = pageHandler.mapFromPager( index: newValue, pageCount: store.gallery.pageCount, setting: setting ) @@ -214,23 +210,19 @@ public struct ReadingView: View { } } .onChange(of: pageHandler.sliderValue) { _, newValue in - Logger.info("pageHandler.sliderValue changed", context: ["sliderValue": newValue]) if !store.showsSliderPreview { setPageIndex(sliderValue: newValue) } } .onChange(of: store.showsSliderPreview) { _, newValue in - Logger.info("store.showsSliderPreview changed", context: ["isShown": newValue]) if !newValue { setPageIndex(sliderValue: pageHandler.sliderValue) } setAutoPlayPolocy(.off) } .onChange(of: store.readingProgress) { _, newValue in - Logger.info("store.readingProgress changed", context: ["readingProgress": newValue]) pageHandler.sliderValue = .init(newValue) } // AutoPlay .onChange(of: store.route) { _, newValue in - Logger.info("store.route changed", context: ["route": newValue]) if ![.hud, .none].contains(newValue) { setAutoPlayPolocy(.off) } @@ -281,23 +273,19 @@ extension ReadingView { ) if page.index != newValue { page.update(.new(index: newValue)) - Logger.info("Pager.update", context: ["update": newValue]) } } func setAutoPlayPolocy(_ policy: AutoPlayPolicy) { autoPlayHandler.setPolicy(policy, updatePageAction: { page.update(.next) - Logger.info("Pager.update", context: ["update": "next"]) }) } func analyzeImageForLiveText(index: Int) { - Logger.info("analyzeImageForLiveText", context: ["index": index]) guard liveTextHandler.liveTextGroups[index] == nil else { - Logger.info("analyzeImageForLiveText duplicated", context: ["index": index]) return } guard let imageURL = displayImageURLs[index] else { - Logger.info("analyzeImageForLiveText URL not found", context: ["index": index]) + logger.debug("analyzeImageForLiveText URL not found, index: \(index, privacy: .public)") return } if imageURL.isFileURL { @@ -321,7 +309,7 @@ extension ReadingView { let image = data.decodedImage, let cgImage = image.cgImage else { - Logger.info("analyzeImageForLiveText local image not found", context: ["index": index]) + logger.debug("analyzeImageForLiveText local image not found, index: \(index, privacy: .public)") return } @@ -340,7 +328,7 @@ extension ReadingView { let image = data.decodedImage, let cgImage = image.cgImage else { - Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) + logger.debug("analyzeImageForLiveText image not found, index: \(index, privacy: .public)") return } diff --git a/AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift b/AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift index a4100edbd..b9c48b0d3 100644 --- a/AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift +++ b/AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift @@ -1,6 +1,5 @@ import SwiftUI import AppModels -import SwiftyBeaverExt import Observation @Observable @@ -15,12 +14,10 @@ final class AutoPlayHandler { } func invalidate() { - Logger.info("invalidate") timer?.invalidate() } func setPolicy(_ policy: AutoPlayPolicy, updatePageAction: @MainActor @escaping () -> Void) { - Logger.info("setPolicy", context: ["policy": policy]) self.policy = policy timer?.invalidate() let timeInterval = TimeInterval(policy.rawValue) diff --git a/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift b/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift index ecbcd2eba..13bf7a6c4 100644 --- a/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift +++ b/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift @@ -1,6 +1,5 @@ import SwiftUI import AppModels -import SwiftyBeaverExt import Observation import AppTools @@ -51,7 +50,6 @@ final class GestureHandler { setPageIndexOffsetAction: @escaping (Int) -> Void, toggleShowsPanelAction: @escaping () -> Void ) { - Logger.info("onSingleTapGestureEnded", context: ["readingDirection": readingDirection]) guard readingDirection != .vertical, let pointX = TouchHandler.shared.currentPoint?.x else { @@ -69,9 +67,6 @@ final class GestureHandler { } func onDoubleTapGestureEnded(scaleMaximum: Double, doubleTapScale: Double) { - Logger.info("onDoubleTapGestureEnded", context: [ - "scaleMaximum": scaleMaximum, "doubleTapScale": doubleTapScale - ]) let newScale = scale == 1 ? doubleTapScale : 1 if let point = TouchHandler.shared.currentPoint { correctScaleAnchor(point: point) @@ -81,9 +76,6 @@ final class GestureHandler { } func onMagnificationGestureChanged(value: Double, scaleMaximum: Double) { - Logger.info("onMagnificationGestureChanged", context: [ - "value": value, "scaleMaximum": scaleMaximum - ]) if value == 1 { baseScale = scale } @@ -94,9 +86,6 @@ final class GestureHandler { } func onMagnificationGestureEnded(value: Double, scaleMaximum: Double) { - Logger.info("onMagnificationGestureEnded", context: [ - "value": value, "scaleMaximum": scaleMaximum - ]) onMagnificationGestureChanged(value: value, scaleMaximum: scaleMaximum) if value * baseScale - 1 < 0.01 { setScale(scale: 1, maximum: scaleMaximum) @@ -105,7 +94,6 @@ final class GestureHandler { } func onDragGestureChanged(value: DragGesture.Value) { - Logger.info("onDragGestureChanged", context: ["value": value]) guard scale > 1 else { return } let newX = value.translation.width + newOffset.width let newY = value.translation.height + newOffset.height @@ -115,7 +103,6 @@ final class GestureHandler { } func onDragGestureEnded(value: DragGesture.Value) { - Logger.info("onDragGestureEnded", context: ["value": value]) onDragGestureChanged(value: value) if scale > 1 { newOffset.width = offset.width @@ -124,7 +111,6 @@ final class GestureHandler { } func onControlPanelDismissGestureEnded(value: DragGesture.Value, dismissAction: @escaping () -> Void) { - Logger.info("onControlPanelDismissGestureEnded", context: ["value": value]) if value.predictedEndTranslation.height > 30 { dismissAction() } diff --git a/AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift b/AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift index 2759e7817..d67e8ce74 100644 --- a/AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift +++ b/AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift @@ -10,11 +10,13 @@ import Vision import AppModels -import SwiftyBeaverExt +import OSLogExt import SwiftUI import Foundation import Observation +private let logger = Logger(category: .init(describing: LiveTextHandler.self)) + @Observable @MainActor final class LiveTextHandler { @@ -30,9 +32,6 @@ final class LiveTextHandler { } func cancelRequests() { - Logger.info("cancelRequests", context: [ - "processingRequestsCount": analysisTasks.count - ]) analysisTasks.values.forEach { task in task.cancel() } @@ -40,15 +39,10 @@ final class LiveTextHandler { } func setFocusedLiveTextGroup(_ group: LiveTextGroup) { - Logger.info("setFocusedLiveTextGroup", context: ["group": group]) focusedLiveTextGroup = group } func analyzeImage(_ cgImage: CGImage, size: CGSize, index: Int, recognitionLanguages: [String]?) { - Logger.info("analyzeImage", context: [ - "index": index, "recognitionLanguages": recognitionLanguages as Any - ]) - analysisTasks[index]?.cancel() analysisTasks[index] = Task { [weak self] in do { @@ -61,9 +55,7 @@ final class LiveTextHandler { self?.liveTextGroups[index] = groups } catch is CancellationError { } catch { - Logger.info("Unable to perform the requests.", context: [ - "error": error, "index": index - ]) + logger.error("Live Text failed, index \(index, privacy: .public): \(error, privacy: .public)") } } } diff --git a/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift b/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift index 899175a68..4cdcca651 100644 --- a/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift +++ b/AppPackage/Sources/ReadingFeature/Support/LiveTextView.swift @@ -1,6 +1,5 @@ import SwiftUI import AppModels -import SwiftyBeaverExt struct LiveTextView: View { private let liveTextGroups: [LiveTextGroup] @@ -111,7 +110,6 @@ private struct HighlightView: UIViewRepresentable { @MainActor @objc func onTap(sender: UIView) { - Logger.info("onTap", context: ["tappedText": textView?.text]) guard let textView = textView else { return } let height = textView.contentSize.height diff --git a/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift b/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift index fc1ec985a..99e86d838 100644 --- a/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift +++ b/AppPackage/Sources/ReadingFeature/Support/PageHandler.swift @@ -1,17 +1,12 @@ import SwiftUI import AppModels -import SwiftyBeaverExt import Observation import AppTools @Observable @MainActor final class PageHandler { - var sliderValue: Float = 1 { - didSet { - Logger.info("sliderValue.didSet", context: ["sliderValue": sliderValue]) - } - } + var sliderValue: Float = 1 func mapFromPager(index: Int, pageCount: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Int { guard isLandscape && setting.enablesDualPageMode From fc56becdbae7b037fccf11eb9f0c0bbb79588d23 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 12:30:03 +0800 Subject: [PATCH 375/614] Use OSLog in DownloadClient --- AppPackage/Package.swift | 2 +- .../DownloadBackgroundTaskStore.swift | 5 +-- .../DownloadClient+BackgroundDownloads.swift | 3 +- .../DownloadClient+Execution.swift | 31 ++++++------- .../DownloadClient+Folders.swift | 9 ++-- .../DownloadClient+Networking.swift | 45 +++++++------------ .../DownloadClient+Persistence.swift | 3 +- .../DownloadClient+PublicAPI.swift | 7 ++- .../DownloadClient+ResponseValidation.swift | 13 +++--- .../DownloadClient+RetryHelpers.swift | 5 +-- .../DownloadClient+Scheduling.swift | 5 +-- .../DownloadClient/DownloadQueueStore.swift | 3 +- .../DownloadClient/Extensions/Logger+.swift | 6 +++ .../URL+PreviewCacheCleanup.swift | 0 14 files changed, 59 insertions(+), 78 deletions(-) create mode 100644 AppPackage/Sources/DownloadClient/Extensions/Logger+.swift rename AppPackage/Sources/DownloadClient/{ => Extensions}/URL+PreviewCacheCleanup.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index d9f1c30e7..87adfc6ef 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -360,10 +360,10 @@ let targets: [PackageDescription.Target] = [ .module(.databaseClient), .module(.libraryClient), .module(.networkingFeature), + .module(.osLogExt), .module(.parserFeature), .module(.resources), .module(.animatedImageFeature), - .module(.swiftyBeaverExt), .module(.urlClient), .targetDependency(.composableArchitecture), .targetDependency(.kanna) diff --git a/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift b/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift index aecc03495..0886b04ed 100644 --- a/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift @@ -1,6 +1,5 @@ import Foundation import AppModels -import SwiftyBeaverExt public actor DownloadBackgroundTaskStore { public struct Record: Codable, Equatable, Sendable { @@ -76,7 +75,7 @@ public actor DownloadBackgroundTaskStore { let data = try Data(contentsOf: fileURL) return try JSONDecoder().decode([Int: Record].self, from: data) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") return [:] } } @@ -92,7 +91,7 @@ public actor DownloadBackgroundTaskStore { let data = try JSONEncoder().encode(records) try data.write(to: fileURL, options: .atomic) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") } } } diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift index 9d0c48746..d2df0550c 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift @@ -1,6 +1,5 @@ import Foundation import AppModels -import SwiftyBeaverExt public actor BackgroundPageCompletionReceiver { private enum PendingEvent { @@ -99,7 +98,7 @@ extension DownloadCoordinator { response: response ) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") removeStagedBackgroundFile(fileURL) // A fatal account error (quota/auth/ban) detected on an orphaned page must // settle the whole download like the foreground does, so scheduleNextIfNeeded diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift index a64c7d16f..54d0e5aac 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift @@ -1,6 +1,5 @@ import Foundation import AppModels -import SwiftyBeaverExt // MARK: - Process Download extension DownloadCoordinator { @@ -74,7 +73,7 @@ extension DownloadCoordinator { do { try removeGalleryFolders(gid: gid, token: token, keeping: folderURL) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") } } @@ -160,13 +159,12 @@ extension DownloadCoordinator { guard !shouldSuppressFailurePersistence(for: context.gid) else { return } - Logger.error( - "Download failed.", - context: [ - "gid": context.gid, - "mode": context.mode.rawValue, - "error": error.localizedDescription - ] + logger.error( + """ + Download failed, gid: \(context.gid, privacy: .public), \ + mode: \(context.mode.rawValue, privacy: .public), \ + error: \(error.localizedDescription, privacy: .public) + """ ) await persistFailure(error: error, context: context) await notifyObservers() @@ -185,13 +183,12 @@ extension DownloadCoordinator { failedPageErrors[context.gid] = Dictionary( uniqueKeysWithValues: error.failedPages.map { ($0.index, $0) } ) - Logger.error( - "Download partially failed.", - context: [ - "gid": context.gid, - "mode": context.mode.rawValue, - "failedPages": error.failedPages.map(\.index) - ] + logger.error( + """ + Download partially failed, gid: \(context.gid, privacy: .public), \ + mode: \(context.mode.rawValue, privacy: .public), \ + failedPages: \(String(describing: error.failedPages.map(\.index)), privacy: .public) + """ ) await persistFailure(error: pageError, context: context) await notifyObservers() @@ -224,7 +221,7 @@ extension DownloadCoordinator { guard !shouldSuppressFailurePersistence(for: context.gid) else { return } - Logger.error(error) + logger.error("\(error, privacy: .public)") await persistFailure( error: appError, context: context diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift index 247c56bf4..ac7459aab 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift @@ -1,6 +1,5 @@ import Foundation import AppModels -import SwiftyBeaverExt import Resources // MARK: - User Folder Operations @@ -29,7 +28,7 @@ extension DownloadCoordinator { try storage.ensureRootDirectory() try createDirectory(at: folderURL) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") return .failure(.fileOperationFailed(error.localizedDescription)) } insertUserFolder(normalizedName) @@ -77,7 +76,7 @@ extension DownloadCoordinator { try $0.moveItem(at: sourceURL, to: destinationURL) } } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") await reloadDownloadRecordIfPossible(gidInFolder: oldName) return .failure(.fileOperationFailed(error.localizedDescription)) } @@ -116,7 +115,7 @@ extension DownloadCoordinator { await reloadDownloadRecords(containedRecords) return .failure(error) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") await reloadDownloadRecords(containedRecords) return .failure(.fileOperationFailed(error.localizedDescription)) } @@ -182,7 +181,7 @@ extension DownloadCoordinator { try $0.moveItem(at: download.folderURL, to: destinationURL) } } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") await reloadDownloadRecord(gid: download.gid, token: download.token) return .failure(.fileOperationFailed(error.localizedDescription)) } diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift index 139ce6bde..b2a0466b6 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift @@ -1,6 +1,5 @@ import Foundation import AppModels -import SwiftyBeaverExt import UniformTypeIdentifiers import AnimatedImageFeature @@ -30,10 +29,7 @@ extension DownloadCoordinator { let response: (URL, URLResponse) if retriesRequest { response = try await withRetry( - operation: "downloadResponse", - context: [ - "url": request.url?.absoluteString ?? "" - ] + operation: "downloadResponse" ) { try await performRequest() } @@ -61,10 +57,7 @@ extension DownloadCoordinator { ) async throws -> (Data, URLResponse) { if retriesRequest { return try await withRetry( - operation: "dataResponse", - context: [ - "url": request.url?.absoluteString ?? "" - ] + operation: "dataResponse" ) { try await rawDataResponse(for: request) } @@ -148,10 +141,7 @@ extension DownloadCoordinator { let transfer: DownloadPageTransfer if retriesRequest { transfer = try await withRetry( - operation: "pageDownloadResponse", - context: [ - "url": request.url?.absoluteString ?? "" - ] + operation: "pageDownloadResponse" ) { try await performRequest() } @@ -202,7 +192,6 @@ extension DownloadCoordinator { public func withRetry( operation: String, - context: [String: Any], maxAttempts: Int = retryLimit, body: () async throws -> T ) async throws -> T { @@ -217,27 +206,25 @@ extension DownloadCoordinator { attempt < maxAttempts else { throw error } - Logger.error( - "Download operation will retry.", - context: context.merging([ - "operation": operation, - "attempt": attempt, - "error": error.localizedDescription - ], uniquingKeysWith: { _, new in new }) + logger.warning( + """ + Download operation will retry, operation: \(operation, privacy: .public), \ + attempt: \(attempt, privacy: .public), \ + error: \(error.localizedDescription, privacy: .public) + """ ) attempt += 1 } catch { guard attempt < maxAttempts else { throw error } - Logger.error( - "Download operation will retry" - + " after unexpected error.", - context: context.merging([ - "operation": operation, - "attempt": attempt, - "error": error.localizedDescription - ], uniquingKeysWith: { _, new in new }) + logger.warning( + """ + Download operation will retry after unexpected error, \ + operation: \(operation, privacy: .public), \ + attempt: \(attempt, privacy: .public), \ + error: \(error.localizedDescription, privacy: .public) + """ ) attempt += 1 } diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift index c8173b9c3..b4267730e 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift @@ -1,7 +1,6 @@ import AppTools import Foundation import AppModels -import SwiftyBeaverExt // MARK: - Disk Index extension DownloadCoordinator { @@ -14,7 +13,7 @@ extension DownloadCoordinator { hasLoadedIndex = true return await downloads(from: scanResult.records) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") downloadIndex = [:] userFolders = [] hasLoadedIndex = true diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift index 9bc7ba7de..30a0e6e3b 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift @@ -1,6 +1,5 @@ import Foundation import AppModels -import SwiftyBeaverExt import Resources // MARK: - Public API @@ -92,7 +91,7 @@ extension DownloadCoordinator { } catch let error as AppError { return .failure(error) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") return .failure(.unknown) } } @@ -185,7 +184,7 @@ extension DownloadCoordinator { await reloadDownloadRecord(gid: download.gid, token: download.token) return .failure(error) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") await reloadDownloadRecord(gid: download.gid, token: download.token) return .failure(.fileOperationFailed(error.localizedDescription)) } @@ -283,7 +282,7 @@ extension DownloadCoordinator { updateDownloadIndex(folderURL: captureTarget.folderURL, manifest: manifest) _ = await sanitizeLocalFilesIfNeeded(gid: gid, clearingLastError: true) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") } } diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift index 1c357add7..dff4ae30d 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift @@ -1,6 +1,5 @@ import Kanna import AppModels -import SwiftyBeaverExt import Foundation import ImageIO import AppTools @@ -257,12 +256,12 @@ extension DownloadCoordinator { } return nil } - Logger.error( - "Download received unexpected HTML response.", - context: [ - "url": requestURL?.absoluteString ?? "", - "snippet": String(textPrefix.prefix(240)) - ] + logger.error( + """ + Download received unexpected HTML response, \ + url: \(requestURL?.absoluteString ?? ""), \ + snippet: \(String(textPrefix.prefix(240)), privacy: .public) + """ ) if statusCode(for: response) == 404 { return .notFound diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift index 2ad45072b..60d816deb 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift @@ -1,6 +1,5 @@ import Foundation import AppModels -import SwiftyBeaverExt // MARK: - Retry & RetryPages extension DownloadCoordinator { @@ -17,7 +16,7 @@ extension DownloadCoordinator { } catch let error as AppError { return .failure(error) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") return .failure(.unknown) } } @@ -67,7 +66,7 @@ extension DownloadCoordinator { } catch let error as AppError { return .failure(error) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") return .failure(.unknown) } } diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift index 743b8ffb1..a74d613f6 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift @@ -1,6 +1,5 @@ import Foundation import AppModels -import SwiftyBeaverExt // MARK: - Observer Management & Scheduling extension DownloadCoordinator { @@ -121,7 +120,7 @@ extension DownloadCoordinator { do { try storage.ensureRootDirectory() } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") } await reconcileActiveDownloadState() await notifyObservers() @@ -164,7 +163,7 @@ extension DownloadCoordinator { } catch let error as AppError { return .failure(error) } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") return .failure(.unknown) } } diff --git a/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift b/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift index 30c896946..dfc4024f9 100644 --- a/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift @@ -1,6 +1,5 @@ import ComposableArchitecture import AppModels -import SwiftyBeaverExt import Foundation public struct DownloadQueueStore: Sendable { @@ -44,7 +43,7 @@ public struct DownloadQueueStore: Sendable { do { try await identifiers.save() } catch { - Logger.error(error) + logger.error("\(error, privacy: .public)") } } } diff --git a/AppPackage/Sources/DownloadClient/Extensions/Logger+.swift b/AppPackage/Sources/DownloadClient/Extensions/Logger+.swift new file mode 100644 index 000000000..9568917ec --- /dev/null +++ b/AppPackage/Sources/DownloadClient/Extensions/Logger+.swift @@ -0,0 +1,6 @@ +import OSLogExt + +let logger = Logger( + moduleName: "DownloadClient", + category: .init(describing: DownloadClient.self) +) diff --git a/AppPackage/Sources/DownloadClient/URL+PreviewCacheCleanup.swift b/AppPackage/Sources/DownloadClient/Extensions/URL+PreviewCacheCleanup.swift similarity index 100% rename from AppPackage/Sources/DownloadClient/URL+PreviewCacheCleanup.swift rename to AppPackage/Sources/DownloadClient/Extensions/URL+PreviewCacheCleanup.swift From 34234a6cd87b46a111057799e3ba2eaf634b3702 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 12:31:54 +0800 Subject: [PATCH 376/614] Use OSLog in AppTools --- AppPackage/Package.swift | 4 +--- .../Sources/AppTools/Extensions/Logger+.swift | 14 ++++++++++++++ .../{ => Extensions}/Optional+ForceUnwrapped.swift | 6 +----- .../AppTools/{ => Extensions}/String+Helpers.swift | 0 .../AppTools/{ => Extensions}/URL+Components.swift | 0 .../{ => Extensions}/URL+ImageCacheKey.swift | 0 .../AppTools/{ => Extensions}/URL+QueryItems.swift | 0 7 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 AppPackage/Sources/AppTools/Extensions/Logger+.swift rename AppPackage/Sources/AppTools/{ => Extensions}/Optional+ForceUnwrapped.swift (54%) rename AppPackage/Sources/AppTools/{ => Extensions}/String+Helpers.swift (100%) rename AppPackage/Sources/AppTools/{ => Extensions}/URL+Components.swift (100%) rename AppPackage/Sources/AppTools/{ => Extensions}/URL+ImageCacheKey.swift (100%) rename AppPackage/Sources/AppTools/{ => Extensions}/URL+QueryItems.swift (100%) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 87adfc6ef..86f4fdc92 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -402,9 +402,7 @@ let targets: [PackageDescription.Target] = [ ), .target( module: .appTools, - dependencies: [ - .module(.swiftyBeaverExt) - ], + dependencies: [], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/AppTools/Extensions/Logger+.swift b/AppPackage/Sources/AppTools/Extensions/Logger+.swift new file mode 100644 index 000000000..abe293985 --- /dev/null +++ b/AppPackage/Sources/AppTools/Extensions/Logger+.swift @@ -0,0 +1,14 @@ +import OSLog + +// AppTools cannot import OSLogExt (OSLogExt depends on AppTools), so it composes +// the subsystem locally instead of using OSLogExt's `Logger(moduleName:category:)`. +extension Logger { + init(category: String) { + self.init( + subsystem: [Defaults.App.identifier, "AppTools"].joined(separator: "."), + category: category + ) + } +} + +let logger = Logger(category: "ForceUnwrap") diff --git a/AppPackage/Sources/AppTools/Optional+ForceUnwrapped.swift b/AppPackage/Sources/AppTools/Extensions/Optional+ForceUnwrapped.swift similarity index 54% rename from AppPackage/Sources/AppTools/Optional+ForceUnwrapped.swift rename to AppPackage/Sources/AppTools/Extensions/Optional+ForceUnwrapped.swift index 69b8e41c4..a4a402ec6 100644 --- a/AppPackage/Sources/AppTools/Optional+ForceUnwrapped.swift +++ b/AppPackage/Sources/AppTools/Extensions/Optional+ForceUnwrapped.swift @@ -1,15 +1,11 @@ import Foundation -import SwiftyBeaverExt extension Optional { public var forceUnwrapped: Wrapped! { if let value = self { return value } - Logger.error( - "Failed in force unwrapping...", - context: ["type": Wrapped.self] - ) + logger.error("Failed in force unwrapping type: \(String(describing: Wrapped.self), privacy: .public)") return nil } } diff --git a/AppPackage/Sources/AppTools/String+Helpers.swift b/AppPackage/Sources/AppTools/Extensions/String+Helpers.swift similarity index 100% rename from AppPackage/Sources/AppTools/String+Helpers.swift rename to AppPackage/Sources/AppTools/Extensions/String+Helpers.swift diff --git a/AppPackage/Sources/AppTools/URL+Components.swift b/AppPackage/Sources/AppTools/Extensions/URL+Components.swift similarity index 100% rename from AppPackage/Sources/AppTools/URL+Components.swift rename to AppPackage/Sources/AppTools/Extensions/URL+Components.swift diff --git a/AppPackage/Sources/AppTools/URL+ImageCacheKey.swift b/AppPackage/Sources/AppTools/Extensions/URL+ImageCacheKey.swift similarity index 100% rename from AppPackage/Sources/AppTools/URL+ImageCacheKey.swift rename to AppPackage/Sources/AppTools/Extensions/URL+ImageCacheKey.swift diff --git a/AppPackage/Sources/AppTools/URL+QueryItems.swift b/AppPackage/Sources/AppTools/Extensions/URL+QueryItems.swift similarity index 100% rename from AppPackage/Sources/AppTools/URL+QueryItems.swift rename to AppPackage/Sources/AppTools/Extensions/URL+QueryItems.swift From 1579a158f947a54a012fef3fdf5663d8d71230a3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 12:45:27 +0800 Subject: [PATCH 377/614] Remove SwiftyBeaver logging plumbing --- AppPackage/Package.swift | 17 - .../DataFlow/AppDelegateReducer.swift | 2 - .../AppFeature/DataFlow/AppReducer.swift | 412 +++++++++--------- .../AppFeature/DataFlow/LoggingReducer.swift | 21 - .../Sources/LibraryClient/LibraryClient.swift | 35 -- .../Sources/LoggerClient/.swiftlint.yml | 1 - .../Sources/LoggerClient/LoggerClient.swift | 48 -- .../SettingFeature/Components/WebView.swift | 3 + .../Sources/SettingFeature/Logger+.swift | 9 +- .../SettingReducer+Helpers.swift | 8 +- .../SettingFeature/SettingReducer.swift | 2 - .../DownloadAutomationTests.swift | 4 - .../DownloadProcessCacheTests.swift | 1 - 13 files changed, 219 insertions(+), 344 deletions(-) delete mode 100644 AppPackage/Sources/AppFeature/DataFlow/LoggingReducer.swift delete mode 100644 AppPackage/Sources/LoggerClient/.swiftlint.yml delete mode 100644 AppPackage/Sources/LoggerClient/LoggerClient.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 86f4fdc92..89b2e9ac2 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -98,7 +98,6 @@ enum Module: String { case homeFeature = "HomeFeature" case imageClient = "ImageClient" case libraryClient = "LibraryClient" - case loggerClient = "LoggerClient" case migrationFeature = "MigrationFeature" case networkingFeature = "NetworkingFeature" case openCCExt = "OpenCCExt" @@ -281,7 +280,6 @@ let targets: [PackageDescription.Target] = [ .module(.homeFeature), .module(.imageClient), .module(.libraryClient), - .module(.loggerClient), .module(.migrationFeature), .module(.networkingFeature), .module(.parserFeature), @@ -292,7 +290,6 @@ let targets: [PackageDescription.Target] = [ .module(.animatedImageFeature), .module(.settingFeature), .module(.swiftUINavigationExt), - .module(.swiftyBeaverExt), .module(.ttProgressHUDExt), .module(.urlClient), .module(.userDefaultsClient), @@ -310,7 +307,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sfSafeSymbols), .targetDependency(.swiftUINavigation), .targetDependency(.swiftUIPager), - .targetDependency(.swiftyBeaver), .targetDependency(.ttProgressHUD), .targetDependency(.uiImageColors), .targetDependency(.waterfallGrid) @@ -728,7 +724,6 @@ let targets: [PackageDescription.Target] = [ .module(.fileClient), .module(.hapticsClient), .module(.libraryClient), - .module(.loggerClient), .module(.networkingFeature), .module(.osLogExt), .module(.readingSettingFeature), @@ -887,22 +882,11 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.kingfisher), .targetDependency(.sdWebImageSwiftUI), .targetDependency(.sdWebImageWebPCoder), - .targetDependency(.swiftyBeaver), .targetDependency(.uiImageColors) ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), - .target( - module: .loggerClient, - dependencies: [ - .module(.appModels), - .module(.swiftyBeaverExt), - .targetDependency(.composableArchitecture) - ], - swiftSettings: sharedSwiftSettings, - plugins: swiftLintPlugins - ), .target( module: .parserFeature, dependencies: [ @@ -995,7 +979,6 @@ let targets: [PackageDescription.Target] = [ .module(.hapticsClient), .module(.imageClient), .module(.libraryClient), - .module(.loggerClient), .module(.networkingFeature), .module(.readingFeature), .module(.urlClient), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index b65e570da..5fd65bb11 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -1,7 +1,6 @@ import AppModels import SwiftUI import BackgroundTasks -import SwiftyBeaver import ComposableArchitecture import AppTools import AppDelegateClient @@ -35,7 +34,6 @@ struct AppDelegateReducer { switch action { case .onLaunchFinish: return .merge( - .run(operation: { _ in libraryClient.initializeLogger() }), .run(operation: { _ in libraryClient.initializeWebImage() }), .run(operation: { _ in cookieClient.removeYay() }), .run(operation: { _ in cookieClient.syncExCookies() }), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 30e09b228..ab84dbb4a 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -60,245 +60,243 @@ struct AppReducer { @Dependency(\.urlClient) private var urlClient var body: some Reducer { - LoggingReducer { - BindingReducer() - .onChange(of: \.appRouteState.route) { _, state in - state.appRouteState.route == nil ? .send(.appRoute(.clearSubStates)) : .none - } - .onChange(of: \.settingState.setting) { _, _ in - .send(.setting(.syncSetting)) - } - - Reduce { state, action in - switch action { - case .binding: - return .none + BindingReducer() + .onChange(of: \.appRouteState.route) { _, state in + state.appRouteState.route == nil ? .send(.appRoute(.clearSubStates)) : .none + } + .onChange(of: \.settingState.setting) { _, _ in + .send(.setting(.syncSetting)) + } - case .onScenePhaseChange(let scenePhase): - state.scenePhase = scenePhase - guard state.settingState.hasLoadedInitialSetting else { return .none } - - switch scenePhase { - case .active: - let threshold = state.settingState.setting.autoLockPolicy.rawValue - let blurRadius = state.settingState.setting.backgroundBlurRadius - var effects: [Effect] = [ - .send(.appLock(.onBecomeActive(threshold, blurRadius))) - ] - // iOS interposes .inactive on a foreground return - // (.background -> .inactive -> .active), so the previous - // phase is never .background here. Latch the background - // entry instead: reconcile once per cycle, never on a - // transient .inactive blip (Control Center, notifications). - if state.hasEnteredBackground { - state.hasEnteredBackground = false - effects.append( - .run { _ in - await downloadClient.reconcileDownloads() - } - ) - } - return .merge(effects) - - case .inactive: - let blurRadius = state.settingState.setting.backgroundBlurRadius - return .send(.appLock(.onBecomeInactive(blurRadius))) - - case .background: - state.hasEnteredBackground = true - // Ask iOS for a later background window to finish the queue; the - // beginBackgroundTask assertion only covers the brief grace - // period right after backgrounding. - return .run { _ in - if await downloadClient.hasPendingWork() { - backgroundProcessingClient.schedule() - } - } + Reduce { state, action in + switch action { + case .binding: + return .none - default: - return .none - } + case .onScenePhaseChange(let scenePhase): + state.scenePhase = scenePhase + guard state.settingState.hasLoadedInitialSetting else { return .none } - case .runLaunchAutomation: - guard !state.didRunLaunchAutomation, - let automation = appLaunchAutomationClient.current() - else { return .none } - - state.didRunLaunchAutomation = true - return .run { send in - if let galleryURL = automation.galleryURL, - urlClient.checkIfHandleable(galleryURL) { - await send(.appRoute(.handleDeepLink(galleryURL))) - } else if let initialTab = automation.initialTab { - await send(.tabBar(.setTabBarItemType(initialTab))) - } + switch scenePhase { + case .active: + let threshold = state.settingState.setting.autoLockPolicy.rawValue + let blurRadius = state.settingState.setting.backgroundBlurRadius + var effects: [Effect] = [ + .send(.appLock(.onBecomeActive(threshold, blurRadius))) + ] + // iOS interposes .inactive on a foreground return + // (.background -> .inactive -> .active), so the previous + // phase is never .background here. Latch the background + // entry instead: reconcile once per cycle, never on a + // transient .inactive blip (Control Center, notifications). + if state.hasEnteredBackground { + state.hasEnteredBackground = false + effects.append( + .run { _ in + await downloadClient.reconcileDownloads() + } + ) } + return .merge(effects) - case .appDelegate(.migration(.onDatabasePreparationSuccess)): - let loginCookies = appLaunchAutomationClient.current()?.loginCookies - return .run { send in - if let loginCookies { - cookieClient.importAutomationCookies( - memberID: loginCookies.memberID, - passHash: loginCookies.passHash, - igneous: loginCookies.igneous - ) + case .inactive: + let blurRadius = state.settingState.setting.backgroundBlurRadius + return .send(.appLock(.onBecomeInactive(blurRadius))) + + case .background: + state.hasEnteredBackground = true + // Ask iOS for a later background window to finish the queue; the + // beginBackgroundTask assertion only covers the brief grace + // period right after backgrounding. + return .run { _ in + if await downloadClient.hasPendingWork() { + backgroundProcessingClient.schedule() } - await send(.appDelegate(.removeExpiredImageURLs)) - await send(.setting(.loadUserSettings)) } - case .appDelegate: + default: return .none + } - case .appRoute(.clearSubStates): - return .run { send in - guard await deviceClient.isPad() else { return } - await send(.clearPadSettingSubstates) + case .runLaunchAutomation: + guard !state.didRunLaunchAutomation, + let automation = appLaunchAutomationClient.current() + else { return .none } + + state.didRunLaunchAutomation = true + return .run { send in + if let galleryURL = automation.galleryURL, + urlClient.checkIfHandleable(galleryURL) { + await send(.appRoute(.handleDeepLink(galleryURL))) + } else if let initialTab = automation.initialTab { + await send(.tabBar(.setTabBarItemType(initialTab))) } + } - case .clearPadSettingSubstates: - state.settingState.route = nil - return .send(.setting(.clearSubStates)) + case .appDelegate(.migration(.onDatabasePreparationSuccess)): + let loginCookies = appLaunchAutomationClient.current()?.loginCookies + return .run { send in + if let loginCookies { + cookieClient.importAutomationCookies( + memberID: loginCookies.memberID, + passHash: loginCookies.passHash, + igneous: loginCookies.igneous + ) + } + await send(.appDelegate(.removeExpiredImageURLs)) + await send(.setting(.loadUserSettings)) + } - case .appRoute: - return .none + case .appDelegate: + return .none - case .appLock(.unlockApp): - var effects: [Effect] = [ - .send(.setting(.fetchGreeting)) - ] - if state.settingState.setting.detectsLinksFromClipboard { - effects.append(.send(.appRoute(.detectClipboardURL))) - } - return .merge(effects) + case .appRoute(.clearSubStates): + return .run { send in + guard await deviceClient.isPad() else { return } + await send(.clearPadSettingSubstates) + } - case .appLock: - return .none + case .clearPadSettingSubstates: + state.settingState.route = nil + return .send(.setting(.clearSubStates)) - case .tabBar(.setTabBarItemType(let type)): - var effects = [Effect]() - let hapticEffect: Effect = .run { _ in - await hapticsClient.generateFeedback(.soft) - } - if type == state.tabBarState.tabBarItemType { - switch type { - case .home: - if state.homeState.route != nil { - effects.append(.send(.home(.setNavigation(nil)))) - } else { - effects.append(.send(.home(.fetchAllGalleries))) - } - case .favorites: - if state.favoritesState.route != nil { - effects.append(.send(.favorites(.setNavigation(nil)))) - effects.append(hapticEffect) - } else if cookieClient.didLogin { - effects.append(.send(.favorites(.fetchGalleries()))) - effects.append(hapticEffect) - } - case .search: - if state.searchRootState.route != nil { - effects.append(.send(.searchRoot(.setNavigation(nil)))) - } else { - effects.append(.send(.searchRoot(.fetchDatabaseInfos))) - } - case .downloads: - if state.downloadsState.route != nil { - effects.append(.send(.downloads(.setNavigation(nil)))) - } else { - effects.append(.send(.downloads(.fetchDownloads))) - } + case .appRoute: + return .none + + case .appLock(.unlockApp): + var effects: [Effect] = [ + .send(.setting(.fetchGreeting)) + ] + if state.settingState.setting.detectsLinksFromClipboard { + effects.append(.send(.appRoute(.detectClipboardURL))) + } + return .merge(effects) + + case .appLock: + return .none + + case .tabBar(.setTabBarItemType(let type)): + var effects = [Effect]() + let hapticEffect: Effect = .run { _ in + await hapticsClient.generateFeedback(.soft) + } + if type == state.tabBarState.tabBarItemType { + switch type { + case .home: + if state.homeState.route != nil { + effects.append(.send(.home(.setNavigation(nil)))) + } else { + effects.append(.send(.home(.fetchAllGalleries))) + } + case .favorites: + if state.favoritesState.route != nil { + effects.append(.send(.favorites(.setNavigation(nil)))) effects.append(hapticEffect) - case .setting: - if state.settingState.route != nil { - effects.append(.send(.setting(.setNavigation(nil)))) - effects.append(hapticEffect) - } + } else if cookieClient.didLogin { + effects.append(.send(.favorites(.fetchGalleries()))) + effects.append(hapticEffect) + } + case .search: + if state.searchRootState.route != nil { + effects.append(.send(.searchRoot(.setNavigation(nil)))) + } else { + effects.append(.send(.searchRoot(.fetchDatabaseInfos))) + } + case .downloads: + if state.downloadsState.route != nil { + effects.append(.send(.downloads(.setNavigation(nil)))) + } else { + effects.append(.send(.downloads(.fetchDownloads))) } - if [.home, .search].contains(type) { + effects.append(hapticEffect) + case .setting: + if state.settingState.route != nil { + effects.append(.send(.setting(.setNavigation(nil)))) effects.append(hapticEffect) } } - return effects.isEmpty ? .none : .merge(effects) - - case .tabBar: - return .none - - case .home(.watched(.onNotLoginViewButtonTapped)), .favorites(.onNotLoginViewButtonTapped): - var effects: [Effect] = [ - .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), - .send(.tabBar(.setTabBarItemType(.setting))) - ] - effects.append(.send(.setting(.setNavigation(.account)))) - if !cookieClient.didLogin { - effects.append( - .run { send in - let isPad = await deviceClient.isPad() - let delay = UInt64(isPad ? 1200 : 200) - try await Task.sleep(for: .milliseconds(delay)) - await send(.setting(.account(.setNavigation(.login)))) - } - ) + if [.home, .search].contains(type) { + effects.append(hapticEffect) } - return .merge(effects) + } + return effects.isEmpty ? .none : .merge(effects) + + case .tabBar: + return .none + + case .home(.watched(.onNotLoginViewButtonTapped)), .favorites(.onNotLoginViewButtonTapped): + var effects: [Effect] = [ + .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), + .send(.tabBar(.setTabBarItemType(.setting))) + ] + effects.append(.send(.setting(.setNavigation(.account)))) + if !cookieClient.didLogin { + effects.append( + .run { send in + let isPad = await deviceClient.isPad() + let delay = UInt64(isPad ? 1200 : 200) + try await Task.sleep(for: .milliseconds(delay)) + await send(.setting(.account(.setNavigation(.login)))) + } + ) + } + return .merge(effects) - case .home: - return .none + case .home: + return .none - case .favorites: - return .none + case .favorites: + return .none - case .searchRoot: - return .none + case .searchRoot: + return .none - case .downloads: - return .none + case .downloads: + return .none - case .setting(.loadUserSettingsDone): - var effects = [Effect]() - let threshold = state.settingState.setting.autoLockPolicy.rawValue - let blurRadius = state.settingState.setting.backgroundBlurRadius - if threshold >= 0 { - state.appLockState.becameInactiveDate = .distantPast - effects.append(.send(.appLock(.onBecomeActive(threshold, blurRadius)))) - } - if state.settingState.setting.detectsLinksFromClipboard { - effects.append(.send(.appRoute(.detectClipboardURL))) - } - state.isAwaitingIgneousForLaunchAutomation = shouldDelayLaunchAutomationUntilIgneous( - state: state - ) - if !state.isAwaitingIgneousForLaunchAutomation { - effects.append(.send(.runLaunchAutomation)) - } - return effects.isEmpty ? .none : .merge(effects) + case .setting(.loadUserSettingsDone): + var effects = [Effect]() + let threshold = state.settingState.setting.autoLockPolicy.rawValue + let blurRadius = state.settingState.setting.backgroundBlurRadius + if threshold >= 0 { + state.appLockState.becameInactiveDate = .distantPast + effects.append(.send(.appLock(.onBecomeActive(threshold, blurRadius)))) + } + if state.settingState.setting.detectsLinksFromClipboard { + effects.append(.send(.appRoute(.detectClipboardURL))) + } + state.isAwaitingIgneousForLaunchAutomation = shouldDelayLaunchAutomationUntilIgneous( + state: state + ) + if !state.isAwaitingIgneousForLaunchAutomation { + effects.append(.send(.runLaunchAutomation)) + } + return effects.isEmpty ? .none : .merge(effects) - case .setting(.account(.loadCookies)): - guard state.isAwaitingIgneousForLaunchAutomation, - !shouldDelayLaunchAutomationUntilIgneous(state: state) - else { return .none } - state.isAwaitingIgneousForLaunchAutomation = false - return .send(.runLaunchAutomation) + case .setting(.account(.loadCookies)): + guard state.isAwaitingIgneousForLaunchAutomation, + !shouldDelayLaunchAutomationUntilIgneous(state: state) + else { return .none } + state.isAwaitingIgneousForLaunchAutomation = false + return .send(.runLaunchAutomation) - case .setting(.fetchGreetingDone(let result)): - return .send(.appRoute(.fetchGreetingDone(result))) + case .setting(.fetchGreetingDone(let result)): + return .send(.appRoute(.fetchGreetingDone(result))) - case .setting: - return .none - } + case .setting: + return .none } - - Scope(state: \.appRouteState, action: \.appRoute, child: AppRouteReducer.init) - Scope(state: \.appLockState, action: \.appLock, child: AppLockReducer.init) - Scope(state: \.appDelegateState, action: \.appDelegate, child: AppDelegateReducer.init) - Scope(state: \.tabBarState, action: \.tabBar, child: TabBarReducer.init) - Scope(state: \.homeState, action: \.home, child: HomeReducer.init) - Scope(state: \.favoritesState, action: \.favorites, child: FavoritesReducer.init) - Scope(state: \.searchRootState, action: \.searchRoot, child: SearchRootReducer.init) - Scope(state: \.downloadsState, action: \.downloads, child: DownloadsReducer.init) - Scope(state: \.settingState, action: \.setting, child: SettingReducer.init) } + + Scope(state: \.appRouteState, action: \.appRoute, child: AppRouteReducer.init) + Scope(state: \.appLockState, action: \.appLock, child: AppLockReducer.init) + Scope(state: \.appDelegateState, action: \.appDelegate, child: AppDelegateReducer.init) + Scope(state: \.tabBarState, action: \.tabBar, child: TabBarReducer.init) + Scope(state: \.homeState, action: \.home, child: HomeReducer.init) + Scope(state: \.favoritesState, action: \.favorites, child: FavoritesReducer.init) + Scope(state: \.searchRootState, action: \.searchRoot, child: SearchRootReducer.init) + Scope(state: \.downloadsState, action: \.downloads, child: DownloadsReducer.init) + Scope(state: \.settingState, action: \.setting, child: SettingReducer.init) } } diff --git a/AppPackage/Sources/AppFeature/DataFlow/LoggingReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/LoggingReducer.swift deleted file mode 100644 index 0d37f2253..000000000 --- a/AppPackage/Sources/AppFeature/DataFlow/LoggingReducer.swift +++ /dev/null @@ -1,21 +0,0 @@ -import AppModels -import SwiftyBeaverExt -import ComposableArchitecture - -// MARK: Logging -struct LoggingReducer: Reducer -where State == Base.State, Action == Base.Action { - let base: Base - - init(@ReducerBuilder base: () -> Base) { - self.base = base() - } - - @ReducerBuilder - var body: some Reducer { - Reduce { state, action in - Logger.info(action) - return base._reduce(into: &state, action: action) - } - } -} diff --git a/AppPackage/Sources/LibraryClient/LibraryClient.swift b/AppPackage/Sources/LibraryClient/LibraryClient.swift index df94e39ec..f901f26b0 100644 --- a/AppPackage/Sources/LibraryClient/LibraryClient.swift +++ b/AppPackage/Sources/LibraryClient/LibraryClient.swift @@ -5,14 +5,12 @@ import Foundation import Kingfisher import SDWebImage import SDWebImageWebPCoder -import SwiftyBeaver import UIImageColors import ComposableArchitecture import AppTools import AnimatedImageFeature public struct LibraryClient: Sendable { - public let initializeLogger: @Sendable () -> Void public let initializeWebImage: @Sendable () -> Void public let removeAllCachedImages: @Sendable () async -> Void public let cachedImage: @Sendable (String) async -> UIImage? @@ -23,7 +21,6 @@ public struct LibraryClient: Sendable { public let calculateWebImageDiskCacheSize: @Sendable () async -> UInt? public init( - initializeLogger: @escaping @Sendable () -> Void, initializeWebImage: @escaping @Sendable () -> Void, removeAllCachedImages: @escaping @Sendable () async -> Void, cachedImage: @escaping @Sendable (String) async -> UIImage?, @@ -33,7 +30,6 @@ public struct LibraryClient: Sendable { analyzeImageColors: @escaping @Sendable (UIImage) async -> [Color]?, calculateWebImageDiskCacheSize: @escaping @Sendable () async -> UInt? ) { - self.initializeLogger = initializeLogger self.initializeWebImage = initializeWebImage self.removeAllCachedImages = removeAllCachedImages self.cachedImage = cachedImage @@ -47,35 +43,6 @@ public struct LibraryClient: Sendable { extension LibraryClient { public static let live: Self = .init( - initializeLogger: { - // MARK: SwiftyBeaver - let file = FileDestination() - let console = ConsoleDestination() - let format = [ - "$Dyyyy-MM-dd HH:mm:ss.SSS$d", - "$C$L$c $N.$F:$l - $M $X" - ].joined(separator: " ") - - file.format = format - file.logFileAmount = 10 - file.calendar = Calendar(identifier: .gregorian) - file.logFileURL = FileUtil.logsDirectoryURL - .appendingPathComponent(Defaults.FilePath.ehpandaLog) - - console.format = format - console.calendar = Calendar(identifier: .gregorian) - console.asynchronously = false - console.levelColor.verbose = "😪" - console.levelColor.warning = "⚠️" - console.levelColor.error = "‼️" - console.levelColor.debug = "🐛" - console.levelColor.info = "📖" - - SwiftyBeaver.addDestination(file) - #if DEBUG - SwiftyBeaver.addDestination(console) - #endif - }, initializeWebImage: { let config = KingfisherManager.shared.downloader.sessionConfiguration config.httpCookieStorage = HTTPCookieStorage.shared @@ -268,7 +235,6 @@ extension DependencyValues { // MARK: Test extension LibraryClient { public static let noop: Self = .init( - initializeLogger: {}, initializeWebImage: {}, removeAllCachedImages: {}, cachedImage: { _ in nil }, @@ -282,7 +248,6 @@ extension LibraryClient { public static func placeholder() -> Result { fatalError() } public static let unimplemented: Self = .init( - initializeLogger: IssueReporting.unimplemented(placeholder: placeholder()), initializeWebImage: IssueReporting.unimplemented(placeholder: placeholder()), removeAllCachedImages: IssueReporting.unimplemented(placeholder: placeholder()), cachedImage: IssueReporting.unimplemented(placeholder: placeholder()), diff --git a/AppPackage/Sources/LoggerClient/.swiftlint.yml b/AppPackage/Sources/LoggerClient/.swiftlint.yml deleted file mode 100644 index 1242ffcaa..000000000 --- a/AppPackage/Sources/LoggerClient/.swiftlint.yml +++ /dev/null @@ -1 +0,0 @@ -parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/LoggerClient/LoggerClient.swift b/AppPackage/Sources/LoggerClient/LoggerClient.swift deleted file mode 100644 index e85ad6c73..000000000 --- a/AppPackage/Sources/LoggerClient/LoggerClient.swift +++ /dev/null @@ -1,48 +0,0 @@ -import ComposableArchitecture -import AppModels -import SwiftyBeaverExt - -public struct LoggerClient: Sendable { - public let info: @Sendable (Any, Any?) -> Void - public let error: @Sendable (Any, Any?) -> Void -} - -extension LoggerClient { - public static let live: Self = .init( - info: { message, context in - Logger.info(message, context: context) - }, - error: { message, context in - Logger.error(message, context: context) - } - ) -} - -// MARK: API -public enum LoggerClientKey: DependencyKey { - public static let liveValue = LoggerClient.live - public static let previewValue = LoggerClient.noop - public static let testValue = LoggerClient.unimplemented -} - -extension DependencyValues { - public var loggerClient: LoggerClient { - get { self[LoggerClientKey.self] } - set { self[LoggerClientKey.self] = newValue } - } -} - -// MARK: Test -extension LoggerClient { - public static let noop: Self = .init( - info: { _, _ in }, - error: { _, _ in } - ) - - public static func placeholder() -> Result { fatalError() } - - public static let unimplemented: Self = .init( - info: IssueReporting.unimplemented(placeholder: placeholder()), - error: IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/AppPackage/Sources/SettingFeature/Components/WebView.swift b/AppPackage/Sources/SettingFeature/Components/WebView.swift index 72565e319..246c6aaf8 100644 --- a/AppPackage/Sources/SettingFeature/Components/WebView.swift +++ b/AppPackage/Sources/SettingFeature/Components/WebView.swift @@ -1,8 +1,11 @@ import AppTools import WebKit import AppModels +import OSLogExt import SwiftUI +private let logger = Logger(category: .init(describing: WebView.self)) + struct WebView: UIViewControllerRepresentable { private let url: URL private let loginDoneAction: (() -> Void)? diff --git a/AppPackage/Sources/SettingFeature/Logger+.swift b/AppPackage/Sources/SettingFeature/Logger+.swift index 22a7eef37..deff821cc 100644 --- a/AppPackage/Sources/SettingFeature/Logger+.swift +++ b/AppPackage/Sources/SettingFeature/Logger+.swift @@ -1,6 +1,7 @@ import OSLogExt -let logger = Logger( - moduleName: "SettingFeature", - category: .init(describing: WebView.self) -) +extension Logger { + init(category: String) { + self.init(moduleName: "SettingFeature", category: category) + } +} diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift index bb75a3803..83b844403 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift @@ -1,9 +1,12 @@ import AppTools import Foundation import AppModels +import OSLogExt import ComposableArchitecture import NetworkingFeature +private let logger = Logger(category: .init(describing: SettingReducer.self)) + extension SettingReducer { func handleLoadUserSettings( _ state: inout State, appEnv: AppEnv @@ -123,8 +126,9 @@ extension SettingReducer { } else if response.isProfileNotFound { effects.append(.send(.createDefaultEhProfile)) } else { - let message = "Found profile but failed in parsing value." - effects.append(.run(operation: { _ in loggerClient.error(message, nil) })) + effects.append(.run { _ in + logger.error("Found profile but failed in parsing value.") + }) } } return effects.isEmpty ? .none : .merge(effects) diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index 4a2bd1e3b..cadef38b3 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -1,7 +1,6 @@ import Foundation import AppModels import ComposableArchitecture -import LoggerClient import UserDefaultsClient import ApplicationClient import HapticsClient @@ -113,7 +112,6 @@ public struct SettingReducer: Sendable { @Dependency(\.databaseClient) var databaseClient @Dependency(\.libraryClient) var libraryClient @Dependency(\.hapticsClient) var hapticsClient - @Dependency(\.loggerClient) var loggerClient @Dependency(\.cookieClient) var cookieClient @Dependency(\.deviceClient) var deviceClient @Dependency(\.fileClient) var fileClient diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift index 1d0eb2804..bd763e1f0 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift @@ -4,7 +4,6 @@ import AppModels import SwiftUI import ComposableArchitecture import Testing -import LoggerClient import URLClient import UserDefaultsClient import ApplicationClient @@ -194,7 +193,6 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { $0.userDefaultsClient = .noop $0.appDelegateClient = .noop $0.libraryClient = .noop - $0.loggerClient = .noop $0.fileClient = .noop $0.dfClient = .noop $0.urlClient = .noop @@ -237,7 +235,6 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { $0.userDefaultsClient = .noop $0.appDelegateClient = .noop $0.libraryClient = .noop - $0.loggerClient = .noop $0.fileClient = .noop $0.dfClient = .noop $0.urlClient = .init( @@ -297,7 +294,6 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { $0.userDefaultsClient = .noop $0.appDelegateClient = .noop $0.libraryClient = .noop - $0.loggerClient = .noop $0.fileClient = .noop $0.dfClient = .noop $0.urlClient = .init( diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadProcessCacheTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadProcessCacheTests.swift index b938c78d6..ee7ace97c 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadProcessCacheTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadProcessCacheTests.swift @@ -293,7 +293,6 @@ private extension DownloadProcessCacheTests { } let cachedImageData = try #require(cachedImage.jpegData(compressionQuality: 1)) return .init( - initializeLogger: {}, initializeWebImage: {}, removeAllCachedImages: { cachedKeys.value = [] From 8a4e1e9b69547e2c4cdae329f5fa4c47154f89f5 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 14:29:11 +0800 Subject: [PATCH 378/614] Add AppActivityLog model and LogsClient module --- AppPackage/Package.swift | 13 ++ .../AppModels/Support/AppActivityLog.swift | 96 +++++++++++ .../AppModels/Support/LaunchLogFile.swift | 52 ++++++ AppPackage/Sources/AppTools/Defaults.swift | 2 + AppPackage/Sources/LogsClient/.swiftlint.yml | 1 + AppPackage/Sources/LogsClient/Logger+.swift | 7 + .../Sources/LogsClient/LogsClient.swift | 149 ++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 8 + AppPackage/Sources/Resources/Strings.swift | 16 ++ 9 files changed, 344 insertions(+) create mode 100644 AppPackage/Sources/AppModels/Support/AppActivityLog.swift create mode 100644 AppPackage/Sources/AppModels/Support/LaunchLogFile.swift create mode 100644 AppPackage/Sources/LogsClient/.swiftlint.yml create mode 100644 AppPackage/Sources/LogsClient/Logger+.swift create mode 100644 AppPackage/Sources/LogsClient/LogsClient.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 89b2e9ac2..230a4e7fc 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -98,6 +98,7 @@ enum Module: String { case homeFeature = "HomeFeature" case imageClient = "ImageClient" case libraryClient = "LibraryClient" + case logsClient = "LogsClient" case migrationFeature = "MigrationFeature" case networkingFeature = "NetworkingFeature" case openCCExt = "OpenCCExt" @@ -589,6 +590,17 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .logsClient, + dependencies: [ + .module(.appModels), + .module(.appTools), + .module(.osLogExt), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .tagTranslationFeature, dependencies: [ @@ -724,6 +736,7 @@ let targets: [PackageDescription.Target] = [ .module(.fileClient), .module(.hapticsClient), .module(.libraryClient), + .module(.logsClient), .module(.networkingFeature), .module(.osLogExt), .module(.readingSettingFeature), diff --git a/AppPackage/Sources/AppModels/Support/AppActivityLog.swift b/AppPackage/Sources/AppModels/Support/AppActivityLog.swift new file mode 100644 index 000000000..194be1297 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/AppActivityLog.swift @@ -0,0 +1,96 @@ +import OSLog +import SwiftUI +import Resources +import Foundation + +public struct AppActivityLog: Sendable, Equatable, Identifiable, Codable { + public let date: Date + public let category: String + public let level: OSLogEntryLog.Level + public let message: String + + public var id: String { + dateDescription + message + } + + public var dateDescription: String { + Self.logDateFormatter.string(from: date) + } + + private static let logDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MM-dd HH:mm:ss.SSS" + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US") + return formatter + }() + + public init( + date: Date, + category: String, + level: OSLogEntryLog.Level, + message: String + ) { + self.date = date + self.category = category + self.level = level + self.message = message + } + + public init(osLog: OSLogEntryLog) { + self.init( + date: osLog.date, + category: osLog.category, + level: osLog.level, + message: osLog.composedMessage + ) + } + + // `OSLogEntryLog.Level` is an `Int`-backed enum, so it is persisted as its raw value. + private enum CodingKeys: String, CodingKey { + case date, category, level, message + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decode(Date.self, forKey: .date) + category = try container.decode(String.self, forKey: .category) + let levelRawValue = try container.decode(Int.self, forKey: .level) + level = OSLogEntryLog.Level(rawValue: levelRawValue) ?? .undefined + message = try container.decode(String.self, forKey: .message) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(date, forKey: .date) + try container.encode(category, forKey: .category) + try container.encode(level.rawValue, forKey: .level) + try container.encode(message, forKey: .message) + } +} + +public extension OSLogEntryLog.Level { + var color: Color { + switch self { + case .debug: .indigo + case .info: .blue + case .notice: .gray + case .error: .orange + case .fault: .red + case .undefined: .primary + @unknown default: .primary + } + } + + var title: String { + switch self { + case .undefined: L10n.Localizable.AppActivityLogsView.Level.undefined + case .debug: L10n.Localizable.AppActivityLogsView.Level.debug + case .info: L10n.Localizable.AppActivityLogsView.Level.info + case .notice: L10n.Localizable.AppActivityLogsView.Level.notice + case .error: L10n.Localizable.AppActivityLogsView.Level.error + case .fault: L10n.Localizable.AppActivityLogsView.Level.fault + @unknown default: "" + } + } +} diff --git a/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift b/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift new file mode 100644 index 000000000..24f95640d --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift @@ -0,0 +1,52 @@ +import AppTools +import Foundation + +/// A persisted per-launch activity-log file, named `ehpanda--.jsonl`. +public struct LaunchLogFile: Identifiable, Equatable, Sendable { + public let url: URL + public let date: Date + public let launchCount: Int + + public var id: Int { launchCount } + + public init(url: URL, date: Date, launchCount: Int) { + self.url = url + self.date = date + self.launchCount = launchCount + } + + /// Parses a `LaunchLogFile` from a log-file URL, returning `nil` when the + /// file name does not match the `ehpanda--.jsonl` format. + public init?(fileURL: URL) { + let name = fileURL.lastPathComponent + let prefix = Defaults.FilePath.activityLogPrefix + let suffix = "." + Defaults.FilePath.activityLogExtension + guard name.hasPrefix(prefix), name.hasSuffix(suffix) else { return nil } + + let core = name.dropFirst(prefix.count).dropLast(suffix.count) + let components = core.split(separator: "-") + guard components.count == 2, + components[0].count == 8, + let date = Self.fileNameDateFormatter.date(from: String(components[0])), + let launchCount = Int(components[1]) + else { return nil } + + self.init(url: fileURL, date: date, launchCount: launchCount) + } + + /// The canonical `ehpanda--.jsonl` file name for a launch. + public static func fileName(date: Date, launchCount: Int) -> String { + let dateString = fileNameDateFormatter.string(from: date) + return "\(Defaults.FilePath.activityLogPrefix)\(dateString)-\(launchCount)" + + ".\(Defaults.FilePath.activityLogExtension)" + } + + private static let fileNameDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd" + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() +} diff --git a/AppPackage/Sources/AppTools/Defaults.swift b/AppPackage/Sources/AppTools/Defaults.swift index af3582a65..971a319de 100644 --- a/AppPackage/Sources/AppTools/Defaults.swift +++ b/AppPackage/Sources/AppTools/Defaults.swift @@ -41,6 +41,8 @@ public struct Defaults: Sendable { public struct FilePath: Sendable { public static let logs = "logs" public static let ehpandaLog = "EhPanda.log" + public static let activityLogPrefix = "ehpanda-" + public static let activityLogExtension = "jsonl" public static let downloads = "Downloads" public static let downloadPages = "pages" public static let downloadManifest = "manifest.json" diff --git a/AppPackage/Sources/LogsClient/.swiftlint.yml b/AppPackage/Sources/LogsClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/LogsClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/LogsClient/Logger+.swift b/AppPackage/Sources/LogsClient/Logger+.swift new file mode 100644 index 000000000..b65f8984d --- /dev/null +++ b/AppPackage/Sources/LogsClient/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "LogsClient", category: category) + } +} diff --git a/AppPackage/Sources/LogsClient/LogsClient.swift b/AppPackage/Sources/LogsClient/LogsClient.swift new file mode 100644 index 000000000..19cde6d77 --- /dev/null +++ b/AppPackage/Sources/LogsClient/LogsClient.swift @@ -0,0 +1,149 @@ +import OSLogExt +import AppTools +import AppModels +import Foundation +import ComposableArchitecture + +private let logger = Logger(category: .init(describing: LogsClient.self)) + +public struct LogsClient: Sendable { + /// Reads activity-log entries emitted by this process since `after` + /// (or since boot when `after` is `nil`), sorted oldest-first. + public var fetchNewEntries: @Sendable (_ after: Date?) async throws -> [AppActivityLog] + /// Appends entries to a per-launch jsonl file, creating it (and the logs directory) when needed. + public var appendToLaunchFile: @Sendable (_ logs: [AppActivityLog], _ url: URL) async throws -> Void + /// Reads back a previously written per-launch jsonl file. + public var readLaunchFile: @Sendable (_ url: URL) async throws -> [AppActivityLog] + /// Lists the persisted per-launch log files, newest launch first. + public var listLaunchFiles: @Sendable () async -> [LaunchLogFile] + /// Derives the next launch count from the existing log files (`max + 1`, or `1` when none exist). + public var nextLaunchCount: @Sendable () async -> Int + /// In-memory, case-insensitive keyword filter over already-loaded logs. + public var query: @Sendable (_ logs: [AppActivityLog], _ keyword: String) -> [AppActivityLog] + /// The jsonl file URL for a given launch. + public var currentLaunchFileURL: @Sendable (_ launchCount: Int, _ date: Date) -> URL +} + +extension LogsClient { + public static let live: Self = .init( + fetchNewEntries: { after in + let store = try OSLogStore(scope: .currentProcessIdentifier) + let position = after.map(store.position(date:)) + ?? store.position(timeIntervalSinceLatestBoot: .zero) + let predicate = NSPredicate(format: "subsystem BEGINSWITH %@", Defaults.App.identifier) + let entries = Array(try store.getEntries(at: position, matching: predicate)) + let logEntries = entries.compactMap { $0 as? OSLogEntryLog } + if logEntries.count != entries.count { + logger.warning(""" + Some log entries could not be read as OSLogEntryLog. \ + Read \(logEntries.count, privacy: .public) of \(entries.count, privacy: .public). + """) + } + let logs = logEntries + .filter { $0.subsystem.caseInsensitiveContains(Defaults.App.identifier) } + .map(AppActivityLog.init(osLog:)) + .sorted { $0.date < $1.date } + guard let after else { return logs } + return logs.filter { $0.date > after } + }, + appendToLaunchFile: { logs, url in + guard !logs.isEmpty else { return } + let directory = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let encoder = JSONEncoder() + var payload = Data() + for log in logs { + payload.append(try encoder.encode(log)) + payload.append(0x0A) + } + + if FileManager.default.fileExists(atPath: url.path) { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: payload) + } else { + try payload.write(to: url, options: .atomic) + } + }, + readLaunchFile: { url in + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + return data.split(separator: 0x0A).compactMap { line in + try? decoder.decode(AppActivityLog.self, from: Data(line)) + } + }, + listLaunchFiles: { + let directory = FileUtil.logsDirectoryURL + guard let names = try? FileManager.default.contentsOfDirectory(atPath: directory.path) else { + return [] + } + return names + .compactMap { LaunchLogFile(fileURL: directory.appendingPathComponent($0)) } + .sorted { $0.launchCount > $1.launchCount } + }, + nextLaunchCount: { + let directory = FileUtil.logsDirectoryURL + guard let names = try? FileManager.default.contentsOfDirectory(atPath: directory.path) else { + return 1 + } + let counts = names.compactMap { + LaunchLogFile(fileURL: directory.appendingPathComponent($0))?.launchCount + } + return (counts.max() ?? 0) + 1 + }, + query: { logs, keyword in + guard !keyword.isEmpty else { return logs } + return logs.filter { log in + [log.dateDescription, log.level.title, log.category, log.message] + .joined(separator: " ") + .caseInsensitiveContains(keyword) + } + }, + currentLaunchFileURL: { launchCount, date in + FileUtil.logsDirectoryURL.appendingPathComponent( + LaunchLogFile.fileName(date: date, launchCount: launchCount) + ) + } + ) +} + +// MARK: API +public enum LogsClientKey: DependencyKey { + public static let liveValue = LogsClient.live + public static let previewValue = LogsClient.noop + public static let testValue = LogsClient.unimplemented +} + +extension DependencyValues { + public var logsClient: LogsClient { + get { self[LogsClientKey.self] } + set { self[LogsClientKey.self] = newValue } + } +} + +// MARK: Test +extension LogsClient { + public static let noop: Self = .init( + fetchNewEntries: { _ in [] }, + appendToLaunchFile: { _, _ in }, + readLaunchFile: { _ in [] }, + listLaunchFiles: { [] }, + nextLaunchCount: { 1 }, + query: { logs, _ in logs }, + currentLaunchFileURL: { _, _ in FileUtil.logsDirectoryURL } + ) + + public static func placeholder() -> Result { fatalError() } + + public static let unimplemented: Self = .init( + fetchNewEntries: IssueReporting.unimplemented(placeholder: placeholder()), + appendToLaunchFile: IssueReporting.unimplemented(placeholder: placeholder()), + readLaunchFile: IssueReporting.unimplemented(placeholder: placeholder()), + listLaunchFiles: IssueReporting.unimplemented(placeholder: placeholder()), + nextLaunchCount: IssueReporting.unimplemented(placeholder: placeholder()), + query: IssueReporting.unimplemented(placeholder: placeholder()), + currentLaunchFileURL: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index a54afb184..71afe7f3a 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -234,6 +234,14 @@ "logs_view.title.logs" = "Logs"; "logs_view.title.latest" = "Latest"; +// MARK: AppActivityLogsView +"app_activity_logs_view.level.undefined" = "Undefined"; +"app_activity_logs_view.level.debug" = "Debug"; +"app_activity_logs_view.level.info" = "Info"; +"app_activity_logs_view.level.notice" = "Notice"; +"app_activity_logs_view.level.error" = "Error"; +"app_activity_logs_view.level.fault" = "Fault"; + // MARK: AppearanceSettingView "appearance_setting_view.title.appearance" = "Appearance"; "appearance_setting_view.title.theme" = "Theme"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 86c34a318..0a3771b17 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -242,6 +242,22 @@ public enum L10n { public static let showsNewDawnGreeting = L10n.tr("Localizable", "account_setting_view.title.shows_new_dawn_greeting", fallback: "Shows new dawn greeting") } } + public enum AppActivityLogsView { + public enum Level { + /// Debug + public static let debug = L10n.tr("Localizable", "app_activity_logs_view.level.debug", fallback: "Debug") + /// Error + public static let error = L10n.tr("Localizable", "app_activity_logs_view.level.error", fallback: "Error") + /// Fault + public static let fault = L10n.tr("Localizable", "app_activity_logs_view.level.fault", fallback: "Fault") + /// Info + public static let info = L10n.tr("Localizable", "app_activity_logs_view.level.info", fallback: "Info") + /// Notice + public static let notice = L10n.tr("Localizable", "app_activity_logs_view.level.notice", fallback: "Notice") + /// Undefined + public static let undefined = L10n.tr("Localizable", "app_activity_logs_view.level.undefined", fallback: "Undefined") + } + } public enum AppError { public enum Alert { /// Login required to access this download. From 8656f78067009b5d0e1128bd709cda7565b50de7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 14:44:47 +0800 Subject: [PATCH 379/614] Rebuild log viewer with live pump and picker --- .../AppFeature/DataFlow/AppReducer.swift | 17 +- .../Resources/en.lproj/Localizable.strings | 4 + AppPackage/Sources/Resources/Strings.swift | 14 ++ .../AppActivityLogsReducer.swift | 156 ++++++++++++++++++ .../AppActivityLogs/AppActivityLogsView.swift | 154 +++++++++++++++++ .../GeneralSettingReducer.swift | 14 +- .../GeneralSetting/GeneralSettingView.swift | 8 +- .../SettingFeature/Logs/LogsReducer.swift | 92 ----------- .../SettingFeature/Logs/LogsView.swift | 154 ----------------- .../DownloadAutomationTests.swift | 10 +- 10 files changed, 360 insertions(+), 263 deletions(-) create mode 100644 AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift create mode 100644 AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift delete mode 100644 AppPackage/Sources/SettingFeature/Logs/LogsReducer.swift delete mode 100644 AppPackage/Sources/SettingFeature/Logs/LogsView.swift diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index ab84dbb4a..9a57e89b5 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -82,7 +82,8 @@ struct AppReducer { let threshold = state.settingState.setting.autoLockPolicy.rawValue let blurRadius = state.settingState.setting.backgroundBlurRadius var effects: [Effect] = [ - .send(.appLock(.onBecomeActive(threshold, blurRadius))) + .send(.appLock(.onBecomeActive(threshold, blurRadius))), + .send(.setting(.general(.appActivityLogs(.startPump)))) ] // iOS interposes .inactive on a foreground return // (.background -> .inactive -> .active), so the previous @@ -108,11 +109,14 @@ struct AppReducer { // Ask iOS for a later background window to finish the queue; the // beginBackgroundTask assertion only covers the brief grace // period right after backgrounding. - return .run { _ in - if await downloadClient.hasPendingWork() { - backgroundProcessingClient.schedule() + return .merge( + .send(.setting(.general(.appActivityLogs(.pausePump)))), + .run { _ in + if await downloadClient.hasPendingWork() { + backgroundProcessingClient.schedule() + } } - } + ) default: return .none @@ -133,6 +137,9 @@ struct AppReducer { } } + case .appDelegate(.onLaunchFinish): + return .send(.setting(.general(.appActivityLogs(.startPump)))) + case .appDelegate(.migration(.onDatabasePreparationSuccess)): let loginCookies = appLaunchAutomationClient.current()?.loginCookies return .run { send in diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 71afe7f3a..9adc05093 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -235,6 +235,10 @@ "logs_view.title.latest" = "Latest"; // MARK: AppActivityLogsView +"app_activity_logs_view.title" = "App Activity Logs"; +"app_activity_logs_view.placeholder.no_logs" = "No logs found"; +"app_activity_logs_view.launch.current" = "Current launch"; +"app_activity_logs_view.launch" = "Launch %1$@ (%2$@)"; "app_activity_logs_view.level.undefined" = "Undefined"; "app_activity_logs_view.level.debug" = "Debug"; "app_activity_logs_view.level.info" = "Info"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 0a3771b17..b94a676ad 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -243,6 +243,16 @@ public enum L10n { } } public enum AppActivityLogsView { + /// Launch %1$@ (%2$@) + public static func launch(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "app_activity_logs_view.launch", String(describing: p1), String(describing: p2), fallback: "Launch %1$@ (%2$@)") + } + /// App Activity Logs + public static let title = L10n.tr("Localizable", "app_activity_logs_view.title", fallback: "App Activity Logs") + public enum Launch { + /// Current launch + public static let current = L10n.tr("Localizable", "app_activity_logs_view.launch.current", fallback: "Current launch") + } public enum Level { /// Debug public static let debug = L10n.tr("Localizable", "app_activity_logs_view.level.debug", fallback: "Debug") @@ -257,6 +267,10 @@ public enum L10n { /// Undefined public static let undefined = L10n.tr("Localizable", "app_activity_logs_view.level.undefined", fallback: "Undefined") } + public enum Placeholder { + /// No logs found + public static let noLogs = L10n.tr("Localizable", "app_activity_logs_view.placeholder.no_logs", fallback: "No logs found") + } } public enum AppError { public enum Alert { diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift new file mode 100644 index 000000000..c40e4152a --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift @@ -0,0 +1,156 @@ +import Foundation +import AppModels +import LogsClient +import ComposableArchitecture + +@Reducer +public struct AppActivityLogsReducer: Sendable { + private enum CancelID { + case pump + } + + @ObservableState + public struct State: Equatable, Sendable { + // Launch context, derived once per app launch and reused across pump pauses. + public var currentLaunchCount: Int? + public var launchDate: Date? + public var launchFileURL: URL? + public var lastCursorDate: Date? + + // Live, state-backed logs for the current launch. + public var currentLaunchLogs = [AppActivityLog]() + // File-backed log files for previous launches (excludes the current launch). + public var previousLaunches = [LaunchLogFile]() + // File-backed logs for the currently selected previous launch. + public var selectedLaunchLogs = [AppActivityLog]() + + // `nil` selects the current launch (state-backed); otherwise a previous launch count. + public var selectedLaunchCount: Int? + public var displayedLogs = [AppActivityLog]() + public var keyword = "" + public var loadingState: LoadingState = .idle + + public init() {} + } + + public enum Action: Equatable, Sendable { + case startPump + case pausePump + case setLaunchContext(launchCount: Int, date: Date, fileURL: URL) + case didReceiveNewEntries([AppActivityLog]) + case refreshAvailableLaunches + case availableLaunchesResponse([LaunchLogFile]) + case selectLaunch(Int?) + case launchFileResponse([AppActivityLog]) + case queryLogs(String) + } + + @Dependency(\.logsClient) private var logsClient + @Dependency(\.continuousClock) private var clock + @Dependency(\.date) private var date + + public init() {} + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .startPump: + return .run { [existingURL = state.launchFileURL, cursor0 = state.lastCursorDate] send in + let fileURL: URL + if let existingURL { + fileURL = existingURL + } else { + let launchCount = await logsClient.nextLaunchCount() + let now = date.now + let resolvedURL = logsClient.currentLaunchFileURL(launchCount, now) + await send(.setLaunchContext(launchCount: launchCount, date: now, fileURL: resolvedURL)) + fileURL = resolvedURL + } + await send(.refreshAvailableLaunches) + + var cursor = cursor0 + while !Task.isCancelled { + let newEntries = (try? await logsClient.fetchNewEntries(cursor)) ?? [] + if let lastDate = newEntries.last?.date { + cursor = lastDate + await send(.didReceiveNewEntries(newEntries)) + try? await logsClient.appendToLaunchFile(newEntries, fileURL) + } + try await clock.sleep(for: .seconds(5)) + } + } + .cancellable(id: CancelID.pump, cancelInFlight: true) + + case .pausePump: + return .merge( + .run { [cursor = state.lastCursorDate, fileURL = state.launchFileURL] send in + guard let fileURL else { return } + let newEntries = (try? await logsClient.fetchNewEntries(cursor)) ?? [] + guard !newEntries.isEmpty else { return } + await send(.didReceiveNewEntries(newEntries)) + try? await logsClient.appendToLaunchFile(newEntries, fileURL) + }, + .cancel(id: CancelID.pump) + ) + + case let .setLaunchContext(launchCount, date, fileURL): + state.currentLaunchCount = launchCount + state.launchDate = date + state.launchFileURL = fileURL + return .none + + case .didReceiveNewEntries(let entries): + state.currentLaunchLogs.append(contentsOf: entries) + state.lastCursorDate = entries.last?.date ?? state.lastCursorDate + if state.selectedLaunchCount == nil { + refreshDisplayedLogs(&state) + } + return .none + + case .refreshAvailableLaunches: + return .run { send in + await send(.availableLaunchesResponse(await logsClient.listLaunchFiles())) + } + + case .availableLaunchesResponse(let launches): + state.previousLaunches = launches.filter { $0.launchCount != state.currentLaunchCount } + return .none + + case .selectLaunch(let launchCount): + guard let launchCount, launchCount != state.currentLaunchCount, + let file = state.previousLaunches.first(where: { $0.launchCount == launchCount }) + else { + state.selectedLaunchCount = nil + state.selectedLaunchLogs = [] + refreshDisplayedLogs(&state) + return .none + } + state.selectedLaunchCount = launchCount + state.loadingState = .loading + return .run { send in + let logs = (try? await logsClient.readLaunchFile(file.url)) ?? [] + await send(.launchFileResponse(logs)) + } + + case .launchFileResponse(let logs): + state.loadingState = .idle + state.selectedLaunchLogs = logs + refreshDisplayedLogs(&state) + return .none + + case .queryLogs(let keyword): + state.keyword = keyword + refreshDisplayedLogs(&state) + return .none + } + } + } + + private func refreshDisplayedLogs(_ state: inout State) { + let source = state.selectedLaunchCount == nil + ? state.currentLaunchLogs + : state.selectedLaunchLogs + state.displayedLogs = logsClient.query(source, state.keyword) + .sorted { $0.date > $1.date } + } +} diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift new file mode 100644 index 000000000..b595ab13d --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -0,0 +1,154 @@ +import SwiftUI +import Resources +import AppModels +import AppComponents +import SFSafeSymbols +import ComposableArchitecture + +struct AppActivityLogsView: View { + @Bindable private var store: StoreOf + + @State private var keyword = "" + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + ZStack { + List(store.displayedLogs) { log in + AppActivityLogRow(log: log) + } + .listStyle(.plain) + .opacity(store.displayedLogs.isEmpty ? 0 : 1) + + LoadingView() + .opacity(store.loadingState == .loading && store.displayedLogs.isEmpty ? 1 : 0) + + Text(L10n.Localizable.AppActivityLogsView.Placeholder.noLogs) + .foregroundColor(.secondary) + .opacity(store.loadingState != .loading && store.displayedLogs.isEmpty ? 1 : 0) + } + .searchable(text: $keyword) + .onSubmit(of: .search) { + store.send(.queryLogs(keyword)) + } + .onChange(of: keyword) { oldValue, newValue in + if !oldValue.isEmpty, newValue.isEmpty { + store.send(.queryLogs(newValue)) + } + } + .onAppear { + store.send(.refreshAvailableLaunches) + } + .toolbar(content: toolbar) + .navigationTitle(L10n.Localizable.AppActivityLogsView.title) + } + + @ToolbarContentBuilder + private func toolbar() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + launchMenu + } label: { + Image(systemSymbol: .clock) + } + } + } + + @ViewBuilder + private var launchMenu: some View { + Button { + store.send(.selectLaunch(nil)) + } label: { + launchLabel(title: currentLaunchTitle, isSelected: store.selectedLaunchCount == nil) + } + + ForEach(groupedLaunches, id: \.date) { group in + Section(Self.dayFormatter.string(from: group.date)) { + ForEach(group.launches) { launch in + Button { + store.send(.selectLaunch(launch.launchCount)) + } label: { + launchLabel( + title: L10n.Localizable.AppActivityLogsView.launch( + "\(launch.launchCount)", Self.dayFormatter.string(from: launch.date) + ), + isSelected: store.selectedLaunchCount == launch.launchCount + ) + } + } + } + } + } + + @ViewBuilder + private func launchLabel(title: String, isSelected: Bool) -> some View { + if isSelected { + Label(title, systemSymbol: .checkmark) + } else { + Text(title) + } + } + + private var currentLaunchTitle: String { + guard let count = store.currentLaunchCount, let date = store.launchDate else { + return L10n.Localizable.AppActivityLogsView.Launch.current + } + return L10n.Localizable.AppActivityLogsView.launch("\(count)", Self.dayFormatter.string(from: date)) + } + + private var groupedLaunches: [(date: Date, launches: [LaunchLogFile])] { + Dictionary(grouping: store.previousLaunches, by: \.date) + .map { (date: $0.key, launches: $0.value.sorted { $0.launchCount > $1.launchCount }) } + .sorted { $0.date > $1.date } + } + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + }() +} + +// MARK: AppActivityLogRow +private struct AppActivityLogRow: View { + let log: AppActivityLog + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemSymbol: .circleFill) + .foregroundColor(log.level.color) + .font(.caption2) + Text(log.dateDescription) + if !log.category.isEmpty { + Text(log.category) + .foregroundColor(.primary) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background(Color(.systemGray5)) + .clipShape(.rect(cornerRadius: 4)) + .bold() + .lineLimit(1) + } + } + Text(log.message) + .lineLimit(30) + } + .font(.caption.monospaced()) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct AppActivityLogsView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + AppActivityLogsView( + store: .init(initialState: .init(), reducer: AppActivityLogsReducer.init) + ) + } + } +} diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index adb9263f4..b99b80156 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -10,7 +10,7 @@ import DatabaseClient public struct GeneralSettingReducer: Sendable { @CasePathable public enum Route: Sendable { - case logs + case appActivityLogs case clearCache case removeCustomTranslations } @@ -23,7 +23,7 @@ public struct GeneralSettingReducer: Sendable { public var diskImageCacheSize = "0 KB" public var passcodeNotSet = false - public var logsState = LogsReducer.State() + public var appActivityLogsState = AppActivityLogsReducer.State() } public enum Action: BindableAction, Equatable { @@ -39,7 +39,7 @@ public struct GeneralSettingReducer: Sendable { case calculateWebImageDiskCache case calculateWebImageDiskCacheDone(UInt?) - case logs(LogsReducer.Action) + case appActivityLogs(AppActivityLogsReducer.Action) } @Dependency(\.authorizationClient) private var authorizationClient @@ -65,8 +65,8 @@ public struct GeneralSettingReducer: Sendable { return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: - state.logsState = .init() - return .send(.logs(.teardown)) + // The activity-logs pump is app-wide and always alive; never reset it on navigation. + return .none case .onTranslationsFilePicked: return .none @@ -104,11 +104,11 @@ public struct GeneralSettingReducer: Sendable { state.diskImageCacheSize = formatter.string(fromByteCount: .init(bytes)) return .none - case .logs: + case .appActivityLogs: return .none } } - Scope(state: \.logsState, action: \.logs, child: LogsReducer.init) + Scope(state: \.appActivityLogsState, action: \.appActivityLogs, child: AppActivityLogsReducer.init) } } diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index cea3f0ebb..87ae42b28 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -60,7 +60,7 @@ struct GeneralSettingView: View { .foregroundStyle(.tint) } Button(L10n.Localizable.GeneralSettingView.Button.logs) { - store.send(.setNavigation(.logs)) + store.send(.setNavigation(.appActivityLogs)) } .foregroundColor(.primary).withArrow() } @@ -188,8 +188,10 @@ struct GeneralSettingView: View { } private var navigationLink: some View { - NavigationLink(unwrapping: $store.route, case: \.logs) { _ in - LogsView(store: store.scope(state: \.logsState, action: \.logs)) + NavigationLink(unwrapping: $store.route, case: \.appActivityLogs) { _ in + AppActivityLogsView( + store: store.scope(state: \.appActivityLogsState, action: \.appActivityLogs) + ) } } } diff --git a/AppPackage/Sources/SettingFeature/Logs/LogsReducer.swift b/AppPackage/Sources/SettingFeature/Logs/LogsReducer.swift deleted file mode 100644 index 237f89dd6..000000000 --- a/AppPackage/Sources/SettingFeature/Logs/LogsReducer.swift +++ /dev/null @@ -1,92 +0,0 @@ -import ComposableArchitecture -import AppModels -import ApplicationClient -import FileClient - -@Reducer -public struct LogsReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case log(Log) - } - - private enum CancelID { - case fetchLogs - } - - @ObservableState - public struct State: Equatable, Sendable { - public var route: Route? - public var loadingState: LoadingState = .idle - public var logs = [Log]() - } - - public enum Action: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(Route?) - case navigateToFileApp - - case teardown - case fetchLogs - case fetchLogsDone(Result<[Log], AppError>) - case deleteLog(String) - case deleteLogDone(Result) - } - - @Dependency(\.applicationClient) private var applicationClient - @Dependency(\.fileClient) private var fileClient - - public init() {} - - public var body: some Reducer { - BindingReducer() - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .navigateToFileApp: - return .run(operation: { _ in await applicationClient.openFileApp() }) - - case .teardown: - return .cancel(id: CancelID.fetchLogs) - - case .fetchLogs: - guard state.loadingState != .loading else { return .none } - state.loadingState = .loading - return .run { send in - let result = await fileClient.fetchLogs() - await send(.fetchLogsDone(result)) - } - .cancellable(id: CancelID.fetchLogs) - - case .fetchLogsDone(let result): - switch result { - case .success(let logs): - state.logs = logs - state.loadingState = .idle - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .deleteLog(let fileName): - return .run { send in - let result = await fileClient.deleteLog(fileName) - await send(.deleteLogDone(result)) - } - - case .deleteLogDone(let result): - if case .success(let fileName) = result { - state.logs = state.logs.filter({ $0.fileName != fileName }) - } - return .none - } - } - } -} diff --git a/AppPackage/Sources/SettingFeature/Logs/LogsView.swift b/AppPackage/Sources/SettingFeature/Logs/LogsView.swift deleted file mode 100644 index 9bd0ee107..000000000 --- a/AppPackage/Sources/SettingFeature/Logs/LogsView.swift +++ /dev/null @@ -1,154 +0,0 @@ -import SwiftUI -import Resources -import ComposableArchitecture -import SwiftUINavigationExt -import AppModels -import AppComponents - -struct LogsView: View { - @Bindable private var store: StoreOf - - init(store: StoreOf) { - self.store = store - } - - var body: some View { - ZStack { - List(store.logs) { log in - Button { - store.send(.setNavigation(.log(log))) - } label: { - LogCell(log: log, isLatest: log == store.logs.first) - } - .swipeActions { - Button { - store.send(.deleteLog(log.fileName)) - } label: { - Image(systemSymbol: .trash) - } - .tint(.red) - } - .foregroundColor(.primary) - } - .opacity(store.logs.isEmpty ? 0 : 1) - - LoadingView().opacity(store.loadingState == .loading && store.logs.isEmpty ? 1 : 0) - - let error = store.loadingState.failed - ErrorView(error: error ?? .notFound) { - store.send(.fetchLogs) - } - .opacity(error != nil && store.logs.isEmpty ? 1 : 0) - } - .onAppear { - if store.logs.isEmpty { - DispatchQueue.main.async { - store.send(.fetchLogs) - } - } - } - .toolbar(content: toolbar) - .background(navigationLink) - .navigationTitle(L10n.Localizable.LogsView.Title.logs) - } - - private var navigationLink: some View { - NavigationLink(unwrapping: $store.route, case: \.log) { route in - LogView(log: route.wrappedValue) - } - } - private func toolbar() -> some ToolbarContent { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - store.send(.navigateToFileApp) - } label: { - Image(systemSymbol: .folderBadgeGearshape) - } - } - } -} - -// MARK: LogCell -private struct LogCell: View { - private let log: Log - private let isLatest: Bool - - private var dateRangeString: String { - parseDate(string: log.contents.first) - + " - " + parseDate(string: log.contents.last) - } - - init(log: Log, isLatest: Bool) { - self.log = log - self.isLatest = isLatest - } - - var body: some View { - VStack(spacing: 5) { - HStack { - Text(log.fileName).font(.callout) - Spacer() - HStack(spacing: 2) { - Image(systemSymbol: .checkmarkCircle) - .foregroundColor(.green) - Text(L10n.Localizable.LogsView.Title.latest) - } - .opacity(isLatest ? 0.6 : 0) - .font(.caption) - } - HStack { - Text(dateRangeString).bold() - Spacer() - Text(L10n.Localizable.Common.Value.records("\(log.contents.count)")) - } - .foregroundColor(.secondary) - .font(.caption2).lineLimit(1) - } - .padding() - } - - private func parseDate(string: String?) -> String { - guard let string = string, - let range = string.range(of: " ") - else { return "" } - - return String(string[.. Date: Mon, 29 Jun 2026 14:52:09 +0800 Subject: [PATCH 380/614] Delete SwiftyBeaver module and dead log file APIs --- AppPackage/Package.resolved | 11 +---- AppPackage/Package.swift | 11 ----- .../Sources/AppModels/Support/Log.swift | 27 ----------- AppPackage/Sources/AppTools/Defaults.swift | 1 - .../ApplicationClient/ApplicationClient.swift | 7 --- .../Sources/FileClient/FileClient.swift | 45 ------------------- .../Resources/en.lproj/Constant.strings | 2 - .../Resources/en.lproj/Localizable.strings | 4 -- AppPackage/Sources/Resources/Strings.swift | 12 ----- .../SettingFeature/Components/AboutView.swift | 4 -- .../Sources/SwiftyBeaverExt/.swiftlint.yml | 1 - .../Sources/SwiftyBeaverExt/Logger.swift | 3 -- .../xcshareddata/swiftpm/Package.resolved | 11 +---- 13 files changed, 2 insertions(+), 137 deletions(-) delete mode 100644 AppPackage/Sources/AppModels/Support/Log.swift delete mode 100644 AppPackage/Sources/SwiftyBeaverExt/.swiftlint.yml delete mode 100644 AppPackage/Sources/SwiftyBeaverExt/Logger.swift diff --git a/AppPackage/Package.resolved b/AppPackage/Package.resolved index 5a5c3e52e..f46f2b379 100644 --- a/AppPackage/Package.resolved +++ b/AppPackage/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "d65854d8f89be6f47565ce46f0077cc8f97043049a286f7b9261c6fb4d807e0a", + "originHash" : "38195e57824c04b99b7f201f4c8de371fa209d236ec8e6cdd6dfe5d46edb2e8e", "pins" : [ { "identity" : "alertkit", @@ -244,15 +244,6 @@ "version" : "2.5.0" } }, - { - "identity" : "swiftybeaver", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SwiftyBeaver/SwiftyBeaver", - "state" : { - "revision" : "8cba041db09596183331d123f337d0eb2e6e8e91", - "version" : "2.1.1" - } - }, { "identity" : "swiftyopencc", "kind" : "remoteSourceControl", diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 230a4e7fc..72684de64 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -13,7 +13,6 @@ var dependencies: [PackageDescription.Package.Dependency] = [ .package(url: "https://github.com/SDWebImage/SDWebImageWebPCoder", from: "0.14.0"), .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "7.0.0"), .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.63.0"), - .package(url: "https://github.com/SwiftyBeaver/SwiftyBeaver", from: "2.0.0"), .package(url: "https://github.com/ddddxxx/SwiftyOpenCC", exact: "2.0.0-beta"), .package(url: "https://github.com/fermoya/SwiftUIPager", from: "2.5.0"), .package(url: "https://github.com/gonzalezreal/SwiftCommonMark", from: "1.0.0"), @@ -49,7 +48,6 @@ extension PackageDescription.Target.Dependency { static let sfSafeSymbols: Self = .product(name: "SFSafeSymbols", package: "SFSafeSymbols") static let swiftUINavigation: Self = .product(name: "SwiftUINavigation", package: "swift-navigation") static let swiftUIPager: Self = .product(name: "SwiftUIPager", package: "SwiftUIPager") - static let swiftyBeaver: Self = .product(name: "SwiftyBeaver", package: "SwiftyBeaver") static let ttProgressHUD: Self = .product(name: "TTProgressHUD", package: "TTProgressHUD") static let uiImageColors: Self = .product(name: "UIImageColors", package: "UIImageColors") static let waterfallGrid: Self = .product(name: "WaterfallGrid", package: "WaterfallGrid") @@ -111,7 +109,6 @@ enum Module: String { case searchFeature = "SearchFeature" case settingFeature = "SettingFeature" case swiftUINavigationExt = "SwiftUINavigationExt" - case swiftyBeaverExt = "SwiftyBeaverExt" case ttProgressHUDExt = "TTProgressHUDExt" case tagTranslationFeature = "TagTranslationFeature" case urlClient = "URLClient" @@ -574,14 +571,6 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), - .target( - module: .swiftyBeaverExt, - dependencies: [ - .targetDependency(.swiftyBeaver) - ], - swiftSettings: sharedSwiftSettings, - plugins: swiftLintPlugins - ), .target( module: .osLogExt, dependencies: [ diff --git a/AppPackage/Sources/AppModels/Support/Log.swift b/AppPackage/Sources/AppModels/Support/Log.swift deleted file mode 100644 index 6a3f20ec8..000000000 --- a/AppPackage/Sources/AppModels/Support/Log.swift +++ /dev/null @@ -1,27 +0,0 @@ -public struct Log: Identifiable, Comparable, Sendable { - public static func < (lhs: Log, rhs: Log) -> Bool { - lhs.fileName < rhs.fileName - } - - public var id: String { fileName } - public let fileName: String - public let contents: [String] - - public init(fileName: String, contents: [String]) { - self.fileName = fileName - self.contents = contents - } -} - -extension Log: CustomStringConvertible { - public var description: String { - let params = String( - describing: [ - "fileName": fileName, - "contentsCount": contents.count - ] - as [String: Any] - ) - return "Log(\(params))" - } -} diff --git a/AppPackage/Sources/AppTools/Defaults.swift b/AppPackage/Sources/AppTools/Defaults.swift index 971a319de..cf2f3c85e 100644 --- a/AppPackage/Sources/AppTools/Defaults.swift +++ b/AppPackage/Sources/AppTools/Defaults.swift @@ -40,7 +40,6 @@ public struct Defaults: Sendable { } public struct FilePath: Sendable { public static let logs = "logs" - public static let ehpandaLog = "EhPanda.log" public static let activityLogPrefix = "ehpanda-" public static let activityLogExtension = "jsonl" public static let downloads = "Downloads" diff --git a/AppPackage/Sources/ApplicationClient/ApplicationClient.swift b/AppPackage/Sources/ApplicationClient/ApplicationClient.swift index 02f62463e..c5dcf01d1 100644 --- a/AppPackage/Sources/ApplicationClient/ApplicationClient.swift +++ b/AppPackage/Sources/ApplicationClient/ApplicationClient.swift @@ -45,13 +45,6 @@ extension ApplicationClient { return openURL(url) } } - @MainActor - public func openFileApp() { - let dirPath = FileUtil.logsDirectoryURL.path - if let dirURL = URL(string: "shareddocuments://" + dirPath) { - return openURL(dirURL) - } - } } // MARK: API diff --git a/AppPackage/Sources/FileClient/FileClient.swift b/AppPackage/Sources/FileClient/FileClient.swift index b2a72698a..72570b961 100644 --- a/AppPackage/Sources/FileClient/FileClient.swift +++ b/AppPackage/Sources/FileClient/FileClient.swift @@ -1,13 +1,9 @@ -import Combine import AppModels import Foundation import ComposableArchitecture -import AppTools public struct FileClient: Sendable { public let createFile: @Sendable (String, Data?) -> Bool - public let fetchLogs: @Sendable () async -> Result<[Log], AppError> - public let deleteLog: @Sendable (String) async -> Result public let importTagTranslator: @Sendable (URL) async -> Result } @@ -16,43 +12,6 @@ extension FileClient { createFile: { path, data in FileManager.default.createFile(atPath: path, contents: data, attributes: nil) }, - fetchLogs: { - await withCheckedContinuation { continuation in - guard let enumerator = FileManager.default.enumerator(atPath: FileUtil.logsDirectoryURL.path), - let fileNames = (enumerator.allObjects as? [String])? - .filter({ $0.contains(Defaults.FilePath.ehpandaLog) }) - else { - continuation.resume(returning: .failure(.notFound)) - return - } - - let logs: [Log] = fileNames.compactMap { name in - let fileURL = FileUtil.logsDirectoryURL.appendingPathComponent(name) - guard let content = try? String(contentsOf: fileURL, encoding: .utf8) - else { return nil } - - return Log( - fileName: name, contents: content - .components(separatedBy: "\n") - .filter({ !$0.isEmpty }) - ) - } - .sorted() - continuation.resume(returning: .success(logs)) - } - }, - deleteLog: { fileName in - await withCheckedContinuation { continuation in - let fileURL = FileUtil.logsDirectoryURL.appendingPathComponent(fileName) - - try? FileManager.default.removeItem(at: fileURL) - - if FileManager.default.fileExists(atPath: fileURL.path) { - continuation.resume(returning: .failure(.unknown)) - } - continuation.resume(returning: .success(fileName)) - } - }, importTagTranslator: { url in await withCheckedContinuation { continuation in guard let data = try? Data(contentsOf: url), @@ -96,8 +55,6 @@ extension DependencyValues { extension FileClient { public static let noop: Self = .init( createFile: { _, _ in false }, - fetchLogs: { .success([]) }, - deleteLog: { _ in .success("") }, importTagTranslator: { _ in .success(.init()) } ) @@ -105,8 +62,6 @@ extension FileClient { public static let unimplemented: Self = .init( createFile: IssueReporting.unimplemented(placeholder: placeholder()), - fetchLogs: IssueReporting.unimplemented(placeholder: placeholder()), - deleteLog: IssueReporting.unimplemented(placeholder: placeholder()), importTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()) ) } diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings index c08edd416..48a8e7393 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings @@ -62,7 +62,6 @@ "app.acknowledgement.link.filePicker" = "https://github.com/markrenaud/FilePicker"; "app.acknowledgement.link.kingfisher" = "https://github.com/onevcat/Kingfisher"; "app.acknowledgement.link.swiftUIPager" = "https://github.com/fermoya/SwiftUIPager"; -"app.acknowledgement.link.swiftyBeaver" = "https://github.com/SwiftyBeaver/SwiftyBeaver"; "app.acknowledgement.link.waterfallGrid" = "https://github.com/paololeonardi/WaterfallGrid"; "app.acknowledgement.link.swiftyOpenCC" = "https://github.com/ddddxxx/SwiftyOpenCC"; "app.acknowledgement.link.uiImageColors" = "https://github.com/jathu/UIImageColors"; @@ -81,7 +80,6 @@ "app.acknowledgement.text.filePicker" = "FilePicker"; "app.acknowledgement.text.kingfisher" = "Kingfisher"; "app.acknowledgement.text.swiftUIPager" = "SwiftUIPager"; -"app.acknowledgement.text.swiftyBeaver" = "SwiftyBeaver"; "app.acknowledgement.text.waterfallGrid" = "WaterfallGrid"; "app.acknowledgement.text.swiftyOpenCC" = "SwiftyOpenCC"; "app.acknowledgement.text.uiImageColors" = "UIImageColors"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 9adc05093..65a566436 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -230,10 +230,6 @@ "enum.auto_lock_policy.value.never" = "Never"; "enum.auto_lock_policy.value.instantly" = "Instantly"; -// MARK: LogsView -"logs_view.title.logs" = "Logs"; -"logs_view.title.latest" = "Latest"; - // MARK: AppActivityLogsView "app_activity_logs_view.title" = "App Activity Logs"; "app_activity_logs_view.placeholder.no_logs" = "No logs found"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index b94a676ad..9fb705e09 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -38,8 +38,6 @@ public enum L10n { public static let swiftUINavigation = L10n.tr("Constant", "app.acknowledgement.link.swiftUINavigation", fallback: "https://github.com/pointfreeco/swiftui-navigation") /// https://github.com/fermoya/SwiftUIPager public static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.link.swiftUIPager", fallback: "https://github.com/fermoya/SwiftUIPager") - /// https://github.com/SwiftyBeaver/SwiftyBeaver - public static let swiftyBeaver = L10n.tr("Constant", "app.acknowledgement.link.swiftyBeaver", fallback: "https://github.com/SwiftyBeaver/SwiftyBeaver") /// https://github.com/ddddxxx/SwiftyOpenCC public static let swiftyOpenCC = L10n.tr("Constant", "app.acknowledgement.link.swiftyOpenCC", fallback: "https://github.com/ddddxxx/SwiftyOpenCC") /// https://github.com/pointfreeco/swift-composable-architecture @@ -74,8 +72,6 @@ public enum L10n { public static let swiftUINavigation = L10n.tr("Constant", "app.acknowledgement.text.swiftUINavigation", fallback: "SwiftUI Navigation") /// SwiftUIPager public static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.text.swiftUIPager", fallback: "SwiftUIPager") - /// SwiftyBeaver - public static let swiftyBeaver = L10n.tr("Constant", "app.acknowledgement.text.swiftyBeaver", fallback: "SwiftyBeaver") /// SwiftyOpenCC public static let swiftyOpenCC = L10n.tr("Constant", "app.acknowledgement.text.swiftyOpenCC", fallback: "SwiftyOpenCC") /// The Composable Architecture @@ -2308,14 +2304,6 @@ public enum L10n { public static let username = L10n.tr("Localizable", "login_view.title.username", fallback: "Username") } } - public enum LogsView { - public enum Title { - /// Latest - public static let latest = L10n.tr("Localizable", "logs_view.title.latest", fallback: "Latest") - /// Logs - public static let logs = L10n.tr("Localizable", "logs_view.title.logs", fallback: "Logs") - } - } public enum NewDawnView { public enum Title { /// It is the dawn of a new day! diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index 4b4dd5d29..4a29345f9 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -174,10 +174,6 @@ struct AboutView: View { urlString: L10n.Constant.App.Acknowledgement.Link.swiftUIPager, text: L10n.Constant.App.Acknowledgement.Text.swiftUIPager ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftyBeaver, - text: L10n.Constant.App.Acknowledgement.Text.swiftyBeaver - ), .init( urlString: L10n.Constant.App.Acknowledgement.Link.waterfallGrid, text: L10n.Constant.App.Acknowledgement.Text.waterfallGrid diff --git a/AppPackage/Sources/SwiftyBeaverExt/.swiftlint.yml b/AppPackage/Sources/SwiftyBeaverExt/.swiftlint.yml deleted file mode 100644 index 1242ffcaa..000000000 --- a/AppPackage/Sources/SwiftyBeaverExt/.swiftlint.yml +++ /dev/null @@ -1 +0,0 @@ -parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/SwiftyBeaverExt/Logger.swift b/AppPackage/Sources/SwiftyBeaverExt/Logger.swift deleted file mode 100644 index c896ab405..000000000 --- a/AppPackage/Sources/SwiftyBeaverExt/Logger.swift +++ /dev/null @@ -1,3 +0,0 @@ -import SwiftyBeaver - -public typealias Logger = SwiftyBeaver diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 43dd5856e..d402ae987 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0e56f677033694d39621023de83f46972d677fe0bbda56c25c4cc4d92f90b765", + "originHash" : "64c9c6c904db8a7d0f68dc02141fd024d418f3a230457e3f33353921cb7ba45e", "pins" : [ { "identity" : "alertkit", @@ -244,15 +244,6 @@ "version" : "2.5.0" } }, - { - "identity" : "swiftybeaver", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SwiftyBeaver/SwiftyBeaver", - "state" : { - "revision" : "8cba041db09596183331d123f337d0eb2e6e8e91", - "version" : "2.1.1" - } - }, { "identity" : "swiftyopencc", "kind" : "remoteSourceControl", From 7af25e0aa84fd388b0ecb1bb69a3beaf1b1b3b36 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 14:58:05 +0800 Subject: [PATCH 381/614] Rename General log entry to App Activity Logs --- .../Sources/Resources/Resources/en.lproj/Localizable.strings | 2 +- AppPackage/Sources/Resources/Strings.swift | 4 ++-- .../SettingFeature/GeneralSetting/GeneralSettingView.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 65a566436..276afcf62 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -217,7 +217,7 @@ "general_setting_view.title.redirects_links_to_the_selected_host" = "Redirects links to the selected host"; "general_setting_view.title.detects_links_from_clipboard" = "Detects links from the clipboard"; "general_setting_view.title.background_blur_radius" = "Background blur radius"; -"general_setting_view.button.logs" = "Logs"; +"general_setting_view.button.app_activity_logs" = "App Activity Logs"; "general_setting_view.button.import_custom_translations" = "Import custom translations"; "general_setting_view.button.remove_custom_translations" = "Remove custom translations"; "general_setting_view.button.clear_image_caches" = "Clear image caches"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 9fb705e09..c14115433 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -2176,12 +2176,12 @@ public enum L10n { } public enum GeneralSettingView { public enum Button { + /// App Activity Logs + public static let appActivityLogs = L10n.tr("Localizable", "general_setting_view.button.app_activity_logs", fallback: "App Activity Logs") /// Clear image caches public static let clearImageCaches = L10n.tr("Localizable", "general_setting_view.button.clear_image_caches", fallback: "Clear image caches") /// Import custom translations public static let importCustomTranslations = L10n.tr("Localizable", "general_setting_view.button.import_custom_translations", fallback: "Import custom translations") - /// Logs - public static let logs = L10n.tr("Localizable", "general_setting_view.button.logs", fallback: "Logs") /// Remove custom translations public static let removeCustomTranslations = L10n.tr("Localizable", "general_setting_view.button.remove_custom_translations", fallback: "Remove custom translations") } diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index 87ae42b28..efb32d320 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -59,7 +59,7 @@ struct GeneralSettingView: View { } .foregroundStyle(.tint) } - Button(L10n.Localizable.GeneralSettingView.Button.logs) { + Button(L10n.Localizable.GeneralSettingView.Button.appActivityLogs) { store.send(.setNavigation(.appActivityLogs)) } .foregroundColor(.primary).withArrow() From eab6663d9159cc2c67a10952f4071ba58640d24f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 15:04:06 +0800 Subject: [PATCH 382/614] Add SettingFeatureTests for log viewer pump --- AppPackage/Package.swift | 12 ++ AppPackage/Tests/FeatureTests.xctestplan | 7 + .../Tests/SettingFeatureTests/.swiftlint.yml | 1 + .../AppActivityLogsReducerTests.swift | 140 ++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 AppPackage/Tests/SettingFeatureTests/.swiftlint.yml create mode 100644 AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 72684de64..777f033fb 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -120,6 +120,7 @@ enum Module: String { // Test targets case parserFeatureTests = "ParserFeatureTests" case downloadsFeatureTests = "DownloadsFeatureTests" + case settingFeatureTests = "SettingFeatureTests" } extension Module { @@ -991,6 +992,17 @@ let targets: [PackageDescription.Target] = [ ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins + ), + .testTarget( + module: .settingFeatureTests, + dependencies: [ + .module(.appModels), + .module(.logsClient), + .module(.settingFeature), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins ) ] diff --git a/AppPackage/Tests/FeatureTests.xctestplan b/AppPackage/Tests/FeatureTests.xctestplan index b5170aa0f..a624167e0 100644 --- a/AppPackage/Tests/FeatureTests.xctestplan +++ b/AppPackage/Tests/FeatureTests.xctestplan @@ -25,6 +25,13 @@ "identifier" : "ParserFeatureTests", "name" : "ParserFeatureTests" } + }, + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "SettingFeatureTests", + "name" : "SettingFeatureTests" + } } ], "version" : 1 diff --git a/AppPackage/Tests/SettingFeatureTests/.swiftlint.yml b/AppPackage/Tests/SettingFeatureTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift new file mode 100644 index 000000000..8258d11d2 --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift @@ -0,0 +1,140 @@ +import OSLog +import Testing +import Foundation +import AppModels +import LogsClient +import SettingFeature +import ComposableArchitecture + +@Suite +struct AppActivityLogsReducerTests { + @MainActor + @Test + func testPumpAppendsNewEntriesToStateAndFile() async { + let entryA = makeLog("first", secondsSince1970: 10) + let entryB = makeLog("second", secondsSince1970: 20) + let fetchCount = LockIsolated(0) + let appended = LockIsolated([[AppActivityLog]]()) + let fileURL = URL(fileURLWithPath: "/tmp/ehpanda-20200101-3.jsonl") + + var client = LogsClient.noop + client.nextLaunchCount = { 3 } + client.currentLaunchFileURL = { _, _ in fileURL } + client.fetchNewEntries = { _ in + fetchCount.withValue { $0 += 1 } + return fetchCount.value == 1 ? [entryA, entryB] : [] + } + client.appendToLaunchFile = { logs, _ in + appended.withValue { $0.append(logs) } + } + + let store = TestStore(initialState: AppActivityLogsReducer.State(), reducer: AppActivityLogsReducer.init) { + $0.logsClient = client + $0.continuousClock = TestClock() + $0.date = .constant(.init(timeIntervalSince1970: 0)) + } + store.exhaustivity = .off + + await store.send(.startPump) + await store.receive(\.didReceiveNewEntries) + + #expect(store.state.currentLaunchLogs == [entryA, entryB]) + #expect(store.state.lastCursorDate == entryB.date) + // Newest entry is shown first. + #expect(store.state.displayedLogs == [entryB, entryA]) + + await store.send(.pausePump) + await store.finish() + + // The pump appended the batch to the per-launch jsonl file exactly once. + #expect(appended.value == [[entryA, entryB]]) + } + + @MainActor + @Test + func testSelectingPreviousLaunchLoadsFileBackedLogs() async { + let fileLog = makeLog("archived", secondsSince1970: 5) + let launch = LaunchLogFile( + url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-2.jsonl"), + date: .init(timeIntervalSince1970: 0), + launchCount: 2 + ) + var client = LogsClient.noop + client.readLaunchFile = { _ in [fileLog] } + + var initialState = AppActivityLogsReducer.State() + initialState.currentLaunchCount = 3 + initialState.previousLaunches = [launch] + initialState.currentLaunchLogs = [makeLog("live", secondsSince1970: 100)] + + let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { + $0.logsClient = client + } + + await store.send(.selectLaunch(2)) { + $0.selectedLaunchCount = 2 + $0.loadingState = .loading + } + await store.receive(\.launchFileResponse) { + $0.loadingState = .idle + $0.selectedLaunchLogs = [fileLog] + $0.displayedLogs = [fileLog] + } + } + + @MainActor + @Test + func testSelectingCurrentLaunchRestoresLiveLogs() async { + let live = makeLog("live", secondsSince1970: 100) + var initialState = AppActivityLogsReducer.State() + initialState.currentLaunchCount = 3 + initialState.currentLaunchLogs = [live] + initialState.selectedLaunchCount = 2 + initialState.selectedLaunchLogs = [makeLog("archived", secondsSince1970: 5)] + initialState.displayedLogs = initialState.selectedLaunchLogs + + let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { + $0.logsClient = .noop + } + + await store.send(.selectLaunch(nil)) { + $0.selectedLaunchCount = nil + $0.selectedLaunchLogs = [] + $0.displayedLogs = [live] + } + } + + @MainActor + @Test + func testQueryLogsFiltersDisplayedLogs() async { + let hello = makeLog("hello world", secondsSince1970: 10) + let goodbye = makeLog("goodbye", secondsSince1970: 20) + var client = LogsClient.noop + client.query = { logs, keyword in logs.filter { $0.message.contains(keyword) } } + + var initialState = AppActivityLogsReducer.State() + initialState.currentLaunchLogs = [hello, goodbye] + + let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { + $0.logsClient = client + } + + await store.send(.queryLogs("hello")) { + $0.keyword = "hello" + $0.displayedLogs = [hello] + } + } + + private func makeLog( + _ message: String, + secondsSince1970: TimeInterval, + level: OSLogEntryLog.Level = .info + ) -> AppActivityLog { + AppActivityLog( + date: .init(timeIntervalSince1970: secondsSince1970), + category: "Test", + level: level, + message: message + ) + } +} From 7cc93c503b83314c98214fbd0da85c6c17c495f2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 15:21:03 +0800 Subject: [PATCH 383/614] Move logger instances to files that use them --- AppPackage/Sources/AppModels/Gallery/Category.swift | 3 +++ AppPackage/Sources/AppModels/Logger+.swift | 9 +++++---- AppPackage/Sources/AppTools/Extensions/Logger+.swift | 2 -- .../AppTools/Extensions/Optional+ForceUnwrapped.swift | 3 +++ .../BackgroundProcessingClient.swift | 3 +++ .../Sources/BackgroundProcessingClient/Logger+.swift | 9 +++++---- .../DatabaseClient/Database/Extensions/Logger+.swift | 9 +++++---- AppPackage/Sources/DatabaseClient/DatabaseClient.swift | 3 +++ .../DownloadClient/DownloadBackgroundTaskStore.swift | 3 +++ .../DownloadClient+BackgroundDownloads.swift | 3 +++ .../DownloadClient/DownloadClient+Execution.swift | 3 +++ .../Sources/DownloadClient/DownloadClient+Folders.swift | 3 +++ .../DownloadClient/DownloadClient+Networking.swift | 3 +++ .../DownloadClient/DownloadClient+Persistence.swift | 3 +++ .../DownloadClient/DownloadClient+PublicAPI.swift | 3 +++ .../DownloadClient+ResponseValidation.swift | 3 +++ .../DownloadClient/DownloadClient+RetryHelpers.swift | 3 +++ .../DownloadClient/DownloadClient+Scheduling.swift | 3 +++ .../Sources/DownloadClient/DownloadQueueStore.swift | 3 +++ .../Sources/DownloadClient/Extensions/Logger+.swift | 9 +++++---- AppPackage/Sources/ParserFeature/Logger+.swift | 9 +++++---- AppPackage/Sources/ParserFeature/Parser+Shared.swift | 3 +++ 22 files changed, 73 insertions(+), 22 deletions(-) diff --git a/AppPackage/Sources/AppModels/Gallery/Category.swift b/AppPackage/Sources/AppModels/Gallery/Category.swift index 7047e31fb..1397e6f43 100644 --- a/AppPackage/Sources/AppModels/Gallery/Category.swift +++ b/AppPackage/Sources/AppModels/Gallery/Category.swift @@ -1,6 +1,9 @@ import SwiftUI +import OSLogExt import Resources +private let logger = Logger(category: .init(describing: Category.self)) + public enum Category: String, Codable, CaseIterable, Identifiable, Sendable { public var id: String { rawValue } diff --git a/AppPackage/Sources/AppModels/Logger+.swift b/AppPackage/Sources/AppModels/Logger+.swift index 6867fffb6..2f1d493d5 100644 --- a/AppPackage/Sources/AppModels/Logger+.swift +++ b/AppPackage/Sources/AppModels/Logger+.swift @@ -1,6 +1,7 @@ import OSLogExt -let logger = Logger( - moduleName: "AppModels", - category: .init(describing: Category.self) -) +extension Logger { + init(category: String) { + self.init(moduleName: "AppModels", category: category) + } +} diff --git a/AppPackage/Sources/AppTools/Extensions/Logger+.swift b/AppPackage/Sources/AppTools/Extensions/Logger+.swift index abe293985..2d21eb1e0 100644 --- a/AppPackage/Sources/AppTools/Extensions/Logger+.swift +++ b/AppPackage/Sources/AppTools/Extensions/Logger+.swift @@ -10,5 +10,3 @@ extension Logger { ) } } - -let logger = Logger(category: "ForceUnwrap") diff --git a/AppPackage/Sources/AppTools/Extensions/Optional+ForceUnwrapped.swift b/AppPackage/Sources/AppTools/Extensions/Optional+ForceUnwrapped.swift index a4a402ec6..8bd4b87a4 100644 --- a/AppPackage/Sources/AppTools/Extensions/Optional+ForceUnwrapped.swift +++ b/AppPackage/Sources/AppTools/Extensions/Optional+ForceUnwrapped.swift @@ -1,5 +1,8 @@ +import OSLog import Foundation +private let logger = Logger(category: "ForceUnwrap") + extension Optional { public var forceUnwrapped: Wrapped! { if let value = self { diff --git a/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift b/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift index 912bd987f..9fc00c70e 100644 --- a/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift +++ b/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift @@ -1,7 +1,10 @@ +import OSLogExt import BackgroundTasks import AppModels import ComposableArchitecture +private let logger = Logger(category: .init(describing: BackgroundProcessingClient.self)) + public enum BackgroundProcessing { /// Fixed task identifier, independent of the bundle id. Must stay in sync with the /// `BGTaskSchedulerPermittedIdentifiers` entry in Info.plist. diff --git a/AppPackage/Sources/BackgroundProcessingClient/Logger+.swift b/AppPackage/Sources/BackgroundProcessingClient/Logger+.swift index 65b92ebf1..1941618f1 100644 --- a/AppPackage/Sources/BackgroundProcessingClient/Logger+.swift +++ b/AppPackage/Sources/BackgroundProcessingClient/Logger+.swift @@ -1,6 +1,7 @@ import OSLogExt -let logger = Logger( - moduleName: "BackgroundProcessingClient", - category: .init(describing: BackgroundProcessingClient.self) -) +extension Logger { + init(category: String) { + self.init(moduleName: "BackgroundProcessingClient", category: category) + } +} diff --git a/AppPackage/Sources/DatabaseClient/Database/Extensions/Logger+.swift b/AppPackage/Sources/DatabaseClient/Database/Extensions/Logger+.swift index d2a72fd43..dc116c0b8 100644 --- a/AppPackage/Sources/DatabaseClient/Database/Extensions/Logger+.swift +++ b/AppPackage/Sources/DatabaseClient/Database/Extensions/Logger+.swift @@ -1,6 +1,7 @@ import OSLogExt -let logger = Logger( - moduleName: "DatabaseClient", - category: .init(describing: DatabaseClient.self) -) +extension Logger { + init(category: String) { + self.init(moduleName: "DatabaseClient", category: category) + } +} diff --git a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift index 219d65e34..52bc924e5 100644 --- a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift +++ b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift @@ -2,9 +2,12 @@ import SwiftUI import AppModels import Combine import CoreData +import OSLogExt import ComposableArchitecture import AppTools +private let logger = Logger(category: .init(describing: DatabaseClient.self)) + public struct DatabaseClient: Sendable { public let prepareDatabase: @Sendable () async -> Result public let dropDatabase: @Sendable () async -> Result diff --git a/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift b/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift index 0886b04ed..850d0c753 100644 --- a/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift @@ -1,6 +1,9 @@ +import OSLogExt import Foundation import AppModels +private let logger = Logger(category: .init(describing: DownloadBackgroundTaskStore.self)) + public actor DownloadBackgroundTaskStore { public struct Record: Codable, Equatable, Sendable { public let gid: String diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift index d2df0550c..870c78320 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift @@ -1,6 +1,9 @@ +import OSLogExt import Foundation import AppModels +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + public actor BackgroundPageCompletionReceiver { private enum PendingEvent { case completion(taskIdentifier: Int, fileURL: URL, response: URLResponse) diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift index 54d0e5aac..205c5ee45 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift @@ -1,6 +1,9 @@ +import OSLogExt import Foundation import AppModels +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + // MARK: - Process Download extension DownloadCoordinator { public func processDownload( diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift index ac7459aab..70eaccbbf 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift @@ -1,7 +1,10 @@ +import OSLogExt import Foundation import AppModels import Resources +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + // MARK: - User Folder Operations extension DownloadCoordinator { public func fetchFolders() async -> [String] { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift index b2a0466b6..a21cdcebe 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift @@ -1,8 +1,11 @@ +import OSLogExt import Foundation import AppModels import UniformTypeIdentifiers import AnimatedImageFeature +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + // MARK: - Network extension DownloadCoordinator { public func downloadResponse( diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift index b4267730e..0991791ae 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift @@ -1,7 +1,10 @@ +import OSLogExt import AppTools import Foundation import AppModels +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + // MARK: - Disk Index extension DownloadCoordinator { @discardableResult diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift index 30a0e6e3b..796db7f3b 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift @@ -1,7 +1,10 @@ +import OSLogExt import Foundation import AppModels import Resources +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + // MARK: - Public API extension DownloadCoordinator { public func observeDownloads() async -> AsyncStream<[DownloadedGallery]> { diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift index dff4ae30d..969b4227c 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift @@ -1,10 +1,13 @@ import Kanna +import OSLogExt import AppModels import Foundation import ImageIO import AppTools import ParserFeature +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + // MARK: - Response Error Detection extension DownloadCoordinator { public func detectResponseError( diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift index 60d816deb..8c5261cc8 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift @@ -1,6 +1,9 @@ +import OSLogExt import Foundation import AppModels +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + // MARK: - Retry & RetryPages extension DownloadCoordinator { public func retry( diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift index a74d613f6..a8384053e 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift @@ -1,6 +1,9 @@ +import OSLogExt import Foundation import AppModels +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + // MARK: - Observer Management & Scheduling extension DownloadCoordinator { public func notifyObservers() async { diff --git a/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift b/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift index dfc4024f9..341f16e72 100644 --- a/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift @@ -1,7 +1,10 @@ +import OSLogExt import ComposableArchitecture import AppModels import Foundation +private let logger = Logger(category: .init(describing: DownloadQueueStore.self)) + public struct DownloadQueueStore: Sendable { private let identifiers: Shared<[String]> diff --git a/AppPackage/Sources/DownloadClient/Extensions/Logger+.swift b/AppPackage/Sources/DownloadClient/Extensions/Logger+.swift index 9568917ec..b52977578 100644 --- a/AppPackage/Sources/DownloadClient/Extensions/Logger+.swift +++ b/AppPackage/Sources/DownloadClient/Extensions/Logger+.swift @@ -1,6 +1,7 @@ import OSLogExt -let logger = Logger( - moduleName: "DownloadClient", - category: .init(describing: DownloadClient.self) -) +extension Logger { + init(category: String) { + self.init(moduleName: "DownloadClient", category: category) + } +} diff --git a/AppPackage/Sources/ParserFeature/Logger+.swift b/AppPackage/Sources/ParserFeature/Logger+.swift index fb51fc84e..01a45773a 100644 --- a/AppPackage/Sources/ParserFeature/Logger+.swift +++ b/AppPackage/Sources/ParserFeature/Logger+.swift @@ -1,6 +1,7 @@ import OSLogExt -let logger = Logger( - moduleName: "ParserFeature", - category: .init(describing: Parser.self) -) +extension Logger { + init(category: String) { + self.init(moduleName: "ParserFeature", category: category) + } +} diff --git a/AppPackage/Sources/ParserFeature/Parser+Shared.swift b/AppPackage/Sources/ParserFeature/Parser+Shared.swift index c9856c722..f71a91b49 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Shared.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Shared.swift @@ -1,8 +1,11 @@ import Kanna import AppModels +import OSLogExt import Foundation import AppTools +private let logger = Logger(category: .init(describing: Parser.self)) + extension Parser { static func parseGTX00IndexFromTitle(from title: String) -> Int? { // The probable format of page title is "Page [Number]: filename" From 20b3e8019cda7ae74614df7fa587f1c4e53c92c2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 22:57:55 +0800 Subject: [PATCH 384/614] Fix activity log title and empty-page baseline --- .../AppActivityLogs/AppActivityLogsReducer.swift | 13 +++++++++++++ .../AppActivityLogs/AppActivityLogsView.swift | 1 + 2 files changed, 14 insertions(+) diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift index c40e4152a..4cfe09fb8 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift @@ -1,8 +1,11 @@ +import OSLogExt import Foundation import AppModels import LogsClient import ComposableArchitecture +private let logger = Logger(category: .init(describing: AppActivityLogsReducer.self)) + @Reducer public struct AppActivityLogsReducer: Sendable { private enum CancelID { @@ -65,6 +68,16 @@ public struct AppActivityLogsReducer: Sendable { let resolvedURL = logsClient.currentLaunchFileURL(launchCount, now) await send(.setLaunchContext(launchCount: launchCount, date: now, fileURL: resolvedURL)) fileURL = resolvedURL + // A persisted `.notice` so the log always has a baseline entry. Only + // `.notice`/`.error`/`.fault` survive in OSLogStore; `.debug`/`.info` do not. + let appVersion = Bundle.main + .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" + logger.notice(""" + App activity logging started. \ + Launch \(launchCount, privacy: .public), \ + version \(appVersion, privacy: .public), \ + \(ProcessInfo.processInfo.operatingSystemVersionString, privacy: .public). + """) } await send(.refreshAvailableLaunches) diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift index b595ab13d..1761ea163 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -43,6 +43,7 @@ struct AppActivityLogsView: View { } .toolbar(content: toolbar) .navigationTitle(L10n.Localizable.AppActivityLogsView.title) + .navigationBarTitleDisplayMode(.large) } @ToolbarContentBuilder From ab5a6531d3aa035edd66d377d01e87e3005fdf0b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 23:14:27 +0800 Subject: [PATCH 385/614] Reset activity log launch count per day --- .../AppModels/Support/LaunchLogFile.swift | 12 +++++++++--- .../Sources/LogsClient/LogsClient.swift | 19 +++++++++++-------- .../AppActivityLogsReducer.swift | 2 +- .../AppActivityLogsReducerTests.swift | 2 +- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift b/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift index 24f95640d..349b2d787 100644 --- a/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift +++ b/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift @@ -36,17 +36,23 @@ public struct LaunchLogFile: Identifiable, Equatable, Sendable { /// The canonical `ehpanda--.jsonl` file name for a launch. public static func fileName(date: Date, launchCount: Int) -> String { - let dateString = fileNameDateFormatter.string(from: date) - return "\(Defaults.FilePath.activityLogPrefix)\(dateString)-\(launchCount)" + "\(Defaults.FilePath.activityLogPrefix)\(dayString(for: date))-\(launchCount)" + ".\(Defaults.FilePath.activityLogExtension)" } + /// The `yyyyMMdd` day component used in log file names, in the device's local time zone. + /// Two launches share a day (and thus the same launch-count sequence) iff these match. + public static func dayString(for date: Date) -> String { + fileNameDateFormatter.string(from: date) + } + + // No explicit time zone: the day rolls over at the device's local midnight, matching how + // the picker groups launches and the user's notion of "a different day". private static let fileNameDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd" formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) return formatter }() } diff --git a/AppPackage/Sources/LogsClient/LogsClient.swift b/AppPackage/Sources/LogsClient/LogsClient.swift index 19cde6d77..d73065003 100644 --- a/AppPackage/Sources/LogsClient/LogsClient.swift +++ b/AppPackage/Sources/LogsClient/LogsClient.swift @@ -16,8 +16,9 @@ public struct LogsClient: Sendable { public var readLaunchFile: @Sendable (_ url: URL) async throws -> [AppActivityLog] /// Lists the persisted per-launch log files, newest launch first. public var listLaunchFiles: @Sendable () async -> [LaunchLogFile] - /// Derives the next launch count from the existing log files (`max + 1`, or `1` when none exist). - public var nextLaunchCount: @Sendable () async -> Int + /// Derives the next launch count for the given day from the existing log files + /// (`max + 1` among that day's files, or `1` — so the count resets each new day). + public var nextLaunchCount: @Sendable (_ date: Date) async -> Int /// In-memory, case-insensitive keyword filter over already-loaded logs. public var query: @Sendable (_ logs: [AppActivityLog], _ keyword: String) -> [AppActivityLog] /// The jsonl file URL for a given launch. @@ -83,15 +84,17 @@ extension LogsClient { .compactMap { LaunchLogFile(fileURL: directory.appendingPathComponent($0)) } .sorted { $0.launchCount > $1.launchCount } }, - nextLaunchCount: { + nextLaunchCount: { date in let directory = FileUtil.logsDirectoryURL guard let names = try? FileManager.default.contentsOfDirectory(atPath: directory.path) else { return 1 } - let counts = names.compactMap { - LaunchLogFile(fileURL: directory.appendingPathComponent($0))?.launchCount - } - return (counts.max() ?? 0) + 1 + let today = LaunchLogFile.dayString(for: date) + let todayCounts = names + .compactMap { LaunchLogFile(fileURL: directory.appendingPathComponent($0)) } + .filter { LaunchLogFile.dayString(for: $0.date) == today } + .map(\.launchCount) + return (todayCounts.max() ?? 0) + 1 }, query: { logs, keyword in guard !keyword.isEmpty else { return logs } @@ -130,7 +133,7 @@ extension LogsClient { appendToLaunchFile: { _, _ in }, readLaunchFile: { _ in [] }, listLaunchFiles: { [] }, - nextLaunchCount: { 1 }, + nextLaunchCount: { _ in 1 }, query: { logs, _ in logs }, currentLaunchFileURL: { _, _ in FileUtil.logsDirectoryURL } ) diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift index 4cfe09fb8..fabb7785d 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift @@ -63,8 +63,8 @@ public struct AppActivityLogsReducer: Sendable { if let existingURL { fileURL = existingURL } else { - let launchCount = await logsClient.nextLaunchCount() let now = date.now + let launchCount = await logsClient.nextLaunchCount(now) let resolvedURL = logsClient.currentLaunchFileURL(launchCount, now) await send(.setLaunchContext(launchCount: launchCount, date: now, fileURL: resolvedURL)) fileURL = resolvedURL diff --git a/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift index 8258d11d2..5288088dd 100644 --- a/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift @@ -18,7 +18,7 @@ struct AppActivityLogsReducerTests { let fileURL = URL(fileURLWithPath: "/tmp/ehpanda-20200101-3.jsonl") var client = LogsClient.noop - client.nextLaunchCount = { 3 } + client.nextLaunchCount = { _ in 3 } client.currentLaunchFileURL = { _, _ in fileURL } client.fetchNewEntries = { _ in fetchCount.withValue { $0 += 1 } From b59003e80a602b5a71e7ab0c77cfb22a85d764ec Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 29 Jun 2026 23:20:48 +0800 Subject: [PATCH 386/614] Build log file names with joined separators --- .../AppModels/Support/LaunchLogFile.swift | 35 ++++++++++++------- AppPackage/Sources/AppTools/Defaults.swift | 2 +- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift b/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift index 349b2d787..32cd6c51c 100644 --- a/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift +++ b/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift @@ -18,17 +18,17 @@ public struct LaunchLogFile: Identifiable, Equatable, Sendable { /// Parses a `LaunchLogFile` from a log-file URL, returning `nil` when the /// file name does not match the `ehpanda--.jsonl` format. public init?(fileURL: URL) { - let name = fileURL.lastPathComponent - let prefix = Defaults.FilePath.activityLogPrefix - let suffix = "." + Defaults.FilePath.activityLogExtension - guard name.hasPrefix(prefix), name.hasSuffix(suffix) else { return nil } - - let core = name.dropFirst(prefix.count).dropLast(suffix.count) - let components = core.split(separator: "-") - guard components.count == 2, - components[0].count == 8, - let date = Self.fileNameDateFormatter.date(from: String(components[0])), - let launchCount = Int(components[1]) + let nameComponents = fileURL.lastPathComponent.split(separator: ".") + guard nameComponents.count == 2, + String(nameComponents[1]) == Defaults.FilePath.activityLogExtension + else { return nil } + + let components = nameComponents[0].split(separator: "-") + guard components.count == 3, + String(components[0]) == Defaults.FilePath.activityLogPrefix, + components[1].count == 8, + let date = Self.fileNameDateFormatter.date(from: String(components[1])), + let launchCount = Int(components[2]) else { return nil } self.init(url: fileURL, date: date, launchCount: launchCount) @@ -36,8 +36,17 @@ public struct LaunchLogFile: Identifiable, Equatable, Sendable { /// The canonical `ehpanda--.jsonl` file name for a launch. public static func fileName(date: Date, launchCount: Int) -> String { - "\(Defaults.FilePath.activityLogPrefix)\(dayString(for: date))-\(launchCount)" - + ".\(Defaults.FilePath.activityLogExtension)" + [ + [ + Defaults.FilePath.activityLogPrefix, + dayString(for: date), + String(launchCount) + ] + .joined(separator: "-"), + + Defaults.FilePath.activityLogExtension + ] + .joined(separator: ".") } /// The `yyyyMMdd` day component used in log file names, in the device's local time zone. diff --git a/AppPackage/Sources/AppTools/Defaults.swift b/AppPackage/Sources/AppTools/Defaults.swift index cf2f3c85e..829939211 100644 --- a/AppPackage/Sources/AppTools/Defaults.swift +++ b/AppPackage/Sources/AppTools/Defaults.swift @@ -40,7 +40,7 @@ public struct Defaults: Sendable { } public struct FilePath: Sendable { public static let logs = "logs" - public static let activityLogPrefix = "ehpanda-" + public static let activityLogPrefix = "ehpanda" public static let activityLogExtension = "jsonl" public static let downloads = "Downloads" public static let downloadPages = "pages" From fe57affb6f66f77d6301d47abc4de78754084cc1 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 30 Jun 2026 00:08:43 +0800 Subject: [PATCH 387/614] Add Run picker with Current section and sheet --- .../Sources/LogsClient/LogsClient.swift | 3 +- .../Resources/en.lproj/Localizable.strings | 7 +- .../Resources/ja.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 3 + .../zh-Hant-HK.lproj/Localizable.strings | 3 + .../zh-Hant-TW.lproj/Localizable.strings | 3 + .../zh-Hant.lproj/Localizable.strings | 3 + AppPackage/Sources/Resources/Strings.swift | 22 ++- .../AppActivityLogsReducer.swift | 5 +- .../AppActivityLogs/AppActivityLogsView.swift | 140 +++++++++++++----- 10 files changed, 141 insertions(+), 51 deletions(-) diff --git a/AppPackage/Sources/LogsClient/LogsClient.swift b/AppPackage/Sources/LogsClient/LogsClient.swift index d73065003..1695de481 100644 --- a/AppPackage/Sources/LogsClient/LogsClient.swift +++ b/AppPackage/Sources/LogsClient/LogsClient.swift @@ -82,7 +82,8 @@ extension LogsClient { } return names .compactMap { LaunchLogFile(fileURL: directory.appendingPathComponent($0)) } - .sorted { $0.launchCount > $1.launchCount } + // Newest first across days: counts reset daily, so order by day then count. + .sorted { $0.date != $1.date ? $0.date > $1.date : $0.launchCount > $1.launchCount } }, nextLaunchCount: { date in let directory = FileUtil.logsDirectoryURL diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 276afcf62..7968191c3 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -233,8 +233,11 @@ // MARK: AppActivityLogsView "app_activity_logs_view.title" = "App Activity Logs"; "app_activity_logs_view.placeholder.no_logs" = "No logs found"; -"app_activity_logs_view.launch.current" = "Current launch"; -"app_activity_logs_view.launch" = "Launch %1$@ (%2$@)"; +"app_activity_logs_view.section.current" = "Current"; +"app_activity_logs_view.run" = "Run %@"; +"app_activity_logs_view.more_logs" = "More logs"; +"app_activity_logs_view.runs" = "Runs"; +"app_activity_logs_view.done" = "Done"; "app_activity_logs_view.level.undefined" = "Undefined"; "app_activity_logs_view.level.debug" = "Debug"; "app_activity_logs_view.level.info" = "Info"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 3e9afdb27..3502a5151 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -1032,3 +1032,6 @@ "download_store.validation.page_missing" = "ページ %d が見つかりません。"; "download_store.validation.cover_image_corrupted" = "表紙画像データが破損しています。"; "download_store.validation.page_image_corrupted" = "ページ %d の画像データが破損しています。"; + +// MARK: AppActivityLogsView +"app_activity_logs_view.run" = "起動 %@"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 70dffa7b8..25f397a85 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -1043,3 +1043,6 @@ "enum.browsing_country.name.yemen" = "也门"; "enum.browsing_country.name.zambia" = "赞比亚"; "enum.browsing_country.name.zimbabwe" = "津巴布韦"; + +// MARK: AppActivityLogsView +"app_activity_logs_view.run" = "运行 %@"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings index 08c8f97ca..f6a97bf3d 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings @@ -1029,3 +1029,6 @@ "download_store.validation.page_missing" = "第 %d 頁缺失。"; "download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; "download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; + +// MARK: AppActivityLogsView +"app_activity_logs_view.run" = "運行 %@"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings index 34cbc0aa9..b9d71f10d 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings @@ -1030,3 +1030,6 @@ "download_store.validation.page_missing" = "第 %d 頁缺失。"; "download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; "download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; + +// MARK: AppActivityLogsView +"app_activity_logs_view.run" = "運行 %@"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 8b5f26f58..770f24462 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -1030,3 +1030,6 @@ "download_store.validation.page_missing" = "第 %d 頁缺失。"; "download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; "download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; + +// MARK: AppActivityLogsView +"app_activity_logs_view.run" = "運行 %@"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index c14115433..235664592 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -239,16 +239,18 @@ public enum L10n { } } public enum AppActivityLogsView { - /// Launch %1$@ (%2$@) - public static func launch(_ p1: Any, _ p2: Any) -> String { - return L10n.tr("Localizable", "app_activity_logs_view.launch", String(describing: p1), String(describing: p2), fallback: "Launch %1$@ (%2$@)") - } + /// Done + public static let done = L10n.tr("Localizable", "app_activity_logs_view.done", fallback: "Done") + /// More logs + public static let moreLogs = L10n.tr("Localizable", "app_activity_logs_view.more_logs", fallback: "More logs") + /// Run %@ + public static func run(_ p1: Any) -> String { + return L10n.tr("Localizable", "app_activity_logs_view.run", String(describing: p1), fallback: "Run %@") + } + /// Runs + public static let runs = L10n.tr("Localizable", "app_activity_logs_view.runs", fallback: "Runs") /// App Activity Logs public static let title = L10n.tr("Localizable", "app_activity_logs_view.title", fallback: "App Activity Logs") - public enum Launch { - /// Current launch - public static let current = L10n.tr("Localizable", "app_activity_logs_view.launch.current", fallback: "Current launch") - } public enum Level { /// Debug public static let debug = L10n.tr("Localizable", "app_activity_logs_view.level.debug", fallback: "Debug") @@ -267,6 +269,10 @@ public enum L10n { /// No logs found public static let noLogs = L10n.tr("Localizable", "app_activity_logs_view.placeholder.no_logs", fallback: "No logs found") } + public enum Section { + /// Current + public static let current = L10n.tr("Localizable", "app_activity_logs_view.section.current", fallback: "Current") + } } public enum AppError { public enum Alert { diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift index fabb7785d..abb06dc08 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift @@ -74,7 +74,7 @@ public struct AppActivityLogsReducer: Sendable { .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" logger.notice(""" App activity logging started. \ - Launch \(launchCount, privacy: .public), \ + Run \(launchCount, privacy: .public), \ version \(appVersion, privacy: .public), \ \(ProcessInfo.processInfo.operatingSystemVersionString, privacy: .public). """) @@ -126,7 +126,8 @@ public struct AppActivityLogsReducer: Sendable { } case .availableLaunchesResponse(let launches): - state.previousLaunches = launches.filter { $0.launchCount != state.currentLaunchCount } + // Exclude the current run by file (its count can repeat on earlier days). + state.previousLaunches = launches.filter { $0.url != state.launchFileURL } return .none case .selectLaunch(let launchCount): diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift index 1761ea163..0ed58d687 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -9,6 +9,7 @@ struct AppActivityLogsView: View { @Bindable private var store: StoreOf @State private var keyword = "" + @State private var isRunPickerPresented = false init(store: StoreOf) { self.store = store @@ -44,13 +45,16 @@ struct AppActivityLogsView: View { .toolbar(content: toolbar) .navigationTitle(L10n.Localizable.AppActivityLogsView.title) .navigationBarTitleDisplayMode(.large) + .sheet(isPresented: $isRunPickerPresented) { + RunPickerSheet(store: store) { isRunPickerPresented = false } + } } @ToolbarContentBuilder private func toolbar() -> some ToolbarContent { ToolbarItem(placement: .navigationBarTrailing) { Menu { - launchMenu + runMenu } label: { Image(systemSymbol: .clock) } @@ -58,61 +62,121 @@ struct AppActivityLogsView: View { } @ViewBuilder - private var launchMenu: some View { - Button { - store.send(.selectLaunch(nil)) - } label: { - launchLabel(title: currentLaunchTitle, isSelected: store.selectedLaunchCount == nil) + private var runMenu: some View { + Section(L10n.Localizable.AppActivityLogsView.Section.current) { + RunButton( + launchCount: store.currentLaunchCount, + isSelected: store.selectedLaunchCount == nil + ) { + store.send(.selectLaunch(nil)) + } } - ForEach(groupedLaunches, id: \.date) { group in - Section(Self.dayFormatter.string(from: group.date)) { - ForEach(group.launches) { launch in - Button { - store.send(.selectLaunch(launch.launchCount)) - } label: { - launchLabel( - title: L10n.Localizable.AppActivityLogsView.launch( - "\(launch.launchCount)", Self.dayFormatter.string(from: launch.date) - ), - isSelected: store.selectedLaunchCount == launch.launchCount - ) + ForEach(groupedRuns(Array(store.previousLaunches.prefix(5))), id: \.date) { group in + Section(runDayFormatter.string(from: group.date)) { + ForEach(group.runs) { run in + RunButton( + launchCount: run.launchCount, + isSelected: store.selectedLaunchCount == run.launchCount + ) { + store.send(.selectLaunch(run.launchCount)) } } } } + + Section { + Button(L10n.Localizable.AppActivityLogsView.moreLogs) { + isRunPickerPresented = true + } + } } +} - @ViewBuilder - private func launchLabel(title: String, isSelected: Bool) -> some View { - if isSelected { - Label(title, systemSymbol: .checkmark) - } else { - Text(title) +// MARK: RunPickerSheet +private struct RunPickerSheet: View { + @Bindable var store: StoreOf + let onSelect: () -> Void + + var body: some View { + NavigationStack { + List { + Section(L10n.Localizable.AppActivityLogsView.Section.current) { + RunButton( + launchCount: store.currentLaunchCount, + isSelected: store.selectedLaunchCount == nil + ) { + store.send(.selectLaunch(nil)) + onSelect() + } + } + + ForEach(groupedRuns(store.previousLaunches), id: \.date) { group in + Section(runDayFormatter.string(from: group.date)) { + ForEach(group.runs) { run in + RunButton( + launchCount: run.launchCount, + isSelected: store.selectedLaunchCount == run.launchCount + ) { + store.send(.selectLaunch(run.launchCount)) + onSelect() + } + } + } + } + } + .navigationTitle(L10n.Localizable.AppActivityLogsView.runs) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Localizable.AppActivityLogsView.done) { + onSelect() + } + } + } } } +} - private var currentLaunchTitle: String { - guard let count = store.currentLaunchCount, let date = store.launchDate else { - return L10n.Localizable.AppActivityLogsView.Launch.current +// MARK: RunButton +private struct RunButton: View { + let launchCount: Int? + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + if isSelected { + Label(runTitle(launchCount), systemSymbol: .checkmark) + } else { + Text(runTitle(launchCount)) + } } - return L10n.Localizable.AppActivityLogsView.launch("\(count)", Self.dayFormatter.string(from: date)) + .foregroundStyle(.primary) } +} - private var groupedLaunches: [(date: Date, launches: [LaunchLogFile])] { - Dictionary(grouping: store.previousLaunches, by: \.date) - .map { (date: $0.key, launches: $0.value.sorted { $0.launchCount > $1.launchCount }) } - .sorted { $0.date > $1.date } +// A nil launch count is the current run before its count is resolved; fall back to "Current". +private func runTitle(_ launchCount: Int?) -> String { + guard let launchCount else { + return L10n.Localizable.AppActivityLogsView.Section.current } + return L10n.Localizable.AppActivityLogsView.run("\(launchCount)") +} - private static let dayFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .none - return formatter - }() +private func groupedRuns(_ runs: [LaunchLogFile]) -> [(date: Date, runs: [LaunchLogFile])] { + Dictionary(grouping: runs, by: \.date) + .map { (date: $0.key, runs: $0.value.sorted { $0.launchCount > $1.launchCount }) } + .sorted { $0.date > $1.date } } +private let runDayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter +}() + // MARK: AppActivityLogRow private struct AppActivityLogRow: View { let log: AppActivityLog From 656e406dfe1b2aab59d29a5c9c2f02d7a93c7069 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 30 Jun 2026 07:54:20 +0800 Subject: [PATCH 388/614] Rename launch log names to run, drop launchDate --- .../{LaunchLogFile.swift => RunLogFile.swift} | 30 ++--- .../Sources/LogsClient/LogsClient.swift | 64 +++++----- .../AppActivityLogsReducer.swift | 112 +++++++++--------- .../AppActivityLogs/AppActivityLogsView.swift | 48 ++++---- .../AppActivityLogsReducerTests.swift | 52 ++++---- 5 files changed, 152 insertions(+), 154 deletions(-) rename AppPackage/Sources/AppModels/Support/{LaunchLogFile.swift => RunLogFile.swift} (62%) diff --git a/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift b/AppPackage/Sources/AppModels/Support/RunLogFile.swift similarity index 62% rename from AppPackage/Sources/AppModels/Support/LaunchLogFile.swift rename to AppPackage/Sources/AppModels/Support/RunLogFile.swift index 32cd6c51c..dbb20cfb7 100644 --- a/AppPackage/Sources/AppModels/Support/LaunchLogFile.swift +++ b/AppPackage/Sources/AppModels/Support/RunLogFile.swift @@ -1,22 +1,22 @@ import AppTools import Foundation -/// A persisted per-launch activity-log file, named `ehpanda--.jsonl`. -public struct LaunchLogFile: Identifiable, Equatable, Sendable { +/// A persisted per-run activity-log file, named `ehpanda--.jsonl`. +public struct RunLogFile: Identifiable, Equatable, Sendable { public let url: URL public let date: Date - public let launchCount: Int + public let runCount: Int - public var id: Int { launchCount } + public var id: Int { runCount } - public init(url: URL, date: Date, launchCount: Int) { + public init(url: URL, date: Date, runCount: Int) { self.url = url self.date = date - self.launchCount = launchCount + self.runCount = runCount } - /// Parses a `LaunchLogFile` from a log-file URL, returning `nil` when the - /// file name does not match the `ehpanda--.jsonl` format. + /// Parses a `RunLogFile` from a log-file URL, returning `nil` when the + /// file name does not match the `ehpanda--.jsonl` format. public init?(fileURL: URL) { let nameComponents = fileURL.lastPathComponent.split(separator: ".") guard nameComponents.count == 2, @@ -28,19 +28,19 @@ public struct LaunchLogFile: Identifiable, Equatable, Sendable { String(components[0]) == Defaults.FilePath.activityLogPrefix, components[1].count == 8, let date = Self.fileNameDateFormatter.date(from: String(components[1])), - let launchCount = Int(components[2]) + let runCount = Int(components[2]) else { return nil } - self.init(url: fileURL, date: date, launchCount: launchCount) + self.init(url: fileURL, date: date, runCount: runCount) } - /// The canonical `ehpanda--.jsonl` file name for a launch. - public static func fileName(date: Date, launchCount: Int) -> String { + /// The canonical `ehpanda--.jsonl` file name for a run. + public static func fileName(date: Date, runCount: Int) -> String { [ [ Defaults.FilePath.activityLogPrefix, dayString(for: date), - String(launchCount) + String(runCount) ] .joined(separator: "-"), @@ -50,13 +50,13 @@ public struct LaunchLogFile: Identifiable, Equatable, Sendable { } /// The `yyyyMMdd` day component used in log file names, in the device's local time zone. - /// Two launches share a day (and thus the same launch-count sequence) iff these match. + /// Two runs share a day (and thus the same run-count sequence) iff these match. public static func dayString(for date: Date) -> String { fileNameDateFormatter.string(from: date) } // No explicit time zone: the day rolls over at the device's local midnight, matching how - // the picker groups launches and the user's notion of "a different day". + // the picker groups runs and the user's notion of "a different day". private static let fileNameDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd" diff --git a/AppPackage/Sources/LogsClient/LogsClient.swift b/AppPackage/Sources/LogsClient/LogsClient.swift index 1695de481..9edb8ad6e 100644 --- a/AppPackage/Sources/LogsClient/LogsClient.swift +++ b/AppPackage/Sources/LogsClient/LogsClient.swift @@ -10,19 +10,19 @@ public struct LogsClient: Sendable { /// Reads activity-log entries emitted by this process since `after` /// (or since boot when `after` is `nil`), sorted oldest-first. public var fetchNewEntries: @Sendable (_ after: Date?) async throws -> [AppActivityLog] - /// Appends entries to a per-launch jsonl file, creating it (and the logs directory) when needed. - public var appendToLaunchFile: @Sendable (_ logs: [AppActivityLog], _ url: URL) async throws -> Void - /// Reads back a previously written per-launch jsonl file. - public var readLaunchFile: @Sendable (_ url: URL) async throws -> [AppActivityLog] - /// Lists the persisted per-launch log files, newest launch first. - public var listLaunchFiles: @Sendable () async -> [LaunchLogFile] - /// Derives the next launch count for the given day from the existing log files + /// Appends entries to a per-run jsonl file, creating it (and the logs directory) when needed. + public var appendToRunFile: @Sendable (_ logs: [AppActivityLog], _ url: URL) async throws -> Void + /// Reads back a previously written per-run jsonl file. + public var readRunFile: @Sendable (_ url: URL) async throws -> [AppActivityLog] + /// Lists the persisted per-run log files, newest run first. + public var listRunFiles: @Sendable () async -> [RunLogFile] + /// Derives the next run count for the given day from the existing log files /// (`max + 1` among that day's files, or `1` — so the count resets each new day). - public var nextLaunchCount: @Sendable (_ date: Date) async -> Int + public var nextRunCount: @Sendable (_ date: Date) async -> Int /// In-memory, case-insensitive keyword filter over already-loaded logs. public var query: @Sendable (_ logs: [AppActivityLog], _ keyword: String) -> [AppActivityLog] - /// The jsonl file URL for a given launch. - public var currentLaunchFileURL: @Sendable (_ launchCount: Int, _ date: Date) -> URL + /// The jsonl file URL for a given run. + public var currentRunFileURL: @Sendable (_ runCount: Int, _ date: Date) -> URL } extension LogsClient { @@ -47,7 +47,7 @@ extension LogsClient { guard let after else { return logs } return logs.filter { $0.date > after } }, - appendToLaunchFile: { logs, url in + appendToRunFile: { logs, url in guard !logs.isEmpty else { return } let directory = url.deletingLastPathComponent() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -68,33 +68,33 @@ extension LogsClient { try payload.write(to: url, options: .atomic) } }, - readLaunchFile: { url in + readRunFile: { url in let data = try Data(contentsOf: url) let decoder = JSONDecoder() return data.split(separator: 0x0A).compactMap { line in try? decoder.decode(AppActivityLog.self, from: Data(line)) } }, - listLaunchFiles: { + listRunFiles: { let directory = FileUtil.logsDirectoryURL guard let names = try? FileManager.default.contentsOfDirectory(atPath: directory.path) else { return [] } return names - .compactMap { LaunchLogFile(fileURL: directory.appendingPathComponent($0)) } + .compactMap { RunLogFile(fileURL: directory.appendingPathComponent($0)) } // Newest first across days: counts reset daily, so order by day then count. - .sorted { $0.date != $1.date ? $0.date > $1.date : $0.launchCount > $1.launchCount } + .sorted { $0.date != $1.date ? $0.date > $1.date : $0.runCount > $1.runCount } }, - nextLaunchCount: { date in + nextRunCount: { date in let directory = FileUtil.logsDirectoryURL guard let names = try? FileManager.default.contentsOfDirectory(atPath: directory.path) else { return 1 } - let today = LaunchLogFile.dayString(for: date) + let today = RunLogFile.dayString(for: date) let todayCounts = names - .compactMap { LaunchLogFile(fileURL: directory.appendingPathComponent($0)) } - .filter { LaunchLogFile.dayString(for: $0.date) == today } - .map(\.launchCount) + .compactMap { RunLogFile(fileURL: directory.appendingPathComponent($0)) } + .filter { RunLogFile.dayString(for: $0.date) == today } + .map(\.runCount) return (todayCounts.max() ?? 0) + 1 }, query: { logs, keyword in @@ -105,9 +105,9 @@ extension LogsClient { .caseInsensitiveContains(keyword) } }, - currentLaunchFileURL: { launchCount, date in + currentRunFileURL: { runCount, date in FileUtil.logsDirectoryURL.appendingPathComponent( - LaunchLogFile.fileName(date: date, launchCount: launchCount) + RunLogFile.fileName(date: date, runCount: runCount) ) } ) @@ -131,23 +131,23 @@ extension DependencyValues { extension LogsClient { public static let noop: Self = .init( fetchNewEntries: { _ in [] }, - appendToLaunchFile: { _, _ in }, - readLaunchFile: { _ in [] }, - listLaunchFiles: { [] }, - nextLaunchCount: { _ in 1 }, + appendToRunFile: { _, _ in }, + readRunFile: { _ in [] }, + listRunFiles: { [] }, + nextRunCount: { _ in 1 }, query: { logs, _ in logs }, - currentLaunchFileURL: { _, _ in FileUtil.logsDirectoryURL } + currentRunFileURL: { _, _ in FileUtil.logsDirectoryURL } ) public static func placeholder() -> Result { fatalError() } public static let unimplemented: Self = .init( fetchNewEntries: IssueReporting.unimplemented(placeholder: placeholder()), - appendToLaunchFile: IssueReporting.unimplemented(placeholder: placeholder()), - readLaunchFile: IssueReporting.unimplemented(placeholder: placeholder()), - listLaunchFiles: IssueReporting.unimplemented(placeholder: placeholder()), - nextLaunchCount: IssueReporting.unimplemented(placeholder: placeholder()), + appendToRunFile: IssueReporting.unimplemented(placeholder: placeholder()), + readRunFile: IssueReporting.unimplemented(placeholder: placeholder()), + listRunFiles: IssueReporting.unimplemented(placeholder: placeholder()), + nextRunCount: IssueReporting.unimplemented(placeholder: placeholder()), query: IssueReporting.unimplemented(placeholder: placeholder()), - currentLaunchFileURL: IssueReporting.unimplemented(placeholder: placeholder()) + currentRunFileURL: IssueReporting.unimplemented(placeholder: placeholder()) ) } diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift index abb06dc08..557975e44 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift @@ -14,21 +14,20 @@ public struct AppActivityLogsReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - // Launch context, derived once per app launch and reused across pump pauses. - public var currentLaunchCount: Int? - public var launchDate: Date? - public var launchFileURL: URL? + // Run context, derived once per app run and reused across pump pauses. + public var currentRunCount: Int? + public var runFileURL: URL? public var lastCursorDate: Date? - // Live, state-backed logs for the current launch. - public var currentLaunchLogs = [AppActivityLog]() - // File-backed log files for previous launches (excludes the current launch). - public var previousLaunches = [LaunchLogFile]() - // File-backed logs for the currently selected previous launch. - public var selectedLaunchLogs = [AppActivityLog]() + // Live, state-backed logs for the current run. + public var currentRunLogs = [AppActivityLog]() + // File-backed log files for previous runs (excludes the current run). + public var previousRuns = [RunLogFile]() + // File-backed logs for the currently selected previous run. + public var selectedRunLogs = [AppActivityLog]() - // `nil` selects the current launch (state-backed); otherwise a previous launch count. - public var selectedLaunchCount: Int? + // `nil` selects the current run (state-backed); otherwise a previous run count. + public var selectedRunCount: Int? public var displayedLogs = [AppActivityLog]() public var keyword = "" public var loadingState: LoadingState = .idle @@ -39,12 +38,12 @@ public struct AppActivityLogsReducer: Sendable { public enum Action: Equatable, Sendable { case startPump case pausePump - case setLaunchContext(launchCount: Int, date: Date, fileURL: URL) + case setRunContext(runCount: Int, fileURL: URL) case didReceiveNewEntries([AppActivityLog]) - case refreshAvailableLaunches - case availableLaunchesResponse([LaunchLogFile]) - case selectLaunch(Int?) - case launchFileResponse([AppActivityLog]) + case refreshAvailableRuns + case availableRunsResponse([RunLogFile]) + case selectRun(Int?) + case runFileResponse([AppActivityLog]) case queryLogs(String) } @@ -58,28 +57,28 @@ public struct AppActivityLogsReducer: Sendable { Reduce { state, action in switch action { case .startPump: - return .run { [existingURL = state.launchFileURL, cursor0 = state.lastCursorDate] send in + return .run { [existingURL = state.runFileURL, cursor0 = state.lastCursorDate] send in let fileURL: URL if let existingURL { fileURL = existingURL } else { let now = date.now - let launchCount = await logsClient.nextLaunchCount(now) - let resolvedURL = logsClient.currentLaunchFileURL(launchCount, now) - await send(.setLaunchContext(launchCount: launchCount, date: now, fileURL: resolvedURL)) + let runCount = await logsClient.nextRunCount(now) + let resolvedURL = logsClient.currentRunFileURL(runCount, now) + await send(.setRunContext(runCount: runCount, fileURL: resolvedURL)) fileURL = resolvedURL - // A persisted `.notice` so the log always has a baseline entry. Only - // `.notice`/`.error`/`.fault` survive in OSLogStore; `.debug`/`.info` do not. let appVersion = Bundle.main - .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" - logger.notice(""" - App activity logging started. \ - Run \(launchCount, privacy: .public), \ - version \(appVersion, privacy: .public), \ - \(ProcessInfo.processInfo.operatingSystemVersionString, privacy: .public). - """) + .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "(null)" + logger.log( + """ + App activity logging started. + Run \(runCount, privacy: .public) + App Version \(appVersion, privacy: .public) + OS \(ProcessInfo.processInfo.operatingSystemVersionString, privacy: .public) + """ + ) } - await send(.refreshAvailableLaunches) + await send(.refreshAvailableRuns) var cursor = cursor0 while !Task.isCancelled { @@ -87,7 +86,7 @@ public struct AppActivityLogsReducer: Sendable { if let lastDate = newEntries.last?.date { cursor = lastDate await send(.didReceiveNewEntries(newEntries)) - try? await logsClient.appendToLaunchFile(newEntries, fileURL) + try? await logsClient.appendToRunFile(newEntries, fileURL) } try await clock.sleep(for: .seconds(5)) } @@ -96,59 +95,58 @@ public struct AppActivityLogsReducer: Sendable { case .pausePump: return .merge( - .run { [cursor = state.lastCursorDate, fileURL = state.launchFileURL] send in + .run { [cursor = state.lastCursorDate, fileURL = state.runFileURL] send in guard let fileURL else { return } let newEntries = (try? await logsClient.fetchNewEntries(cursor)) ?? [] guard !newEntries.isEmpty else { return } await send(.didReceiveNewEntries(newEntries)) - try? await logsClient.appendToLaunchFile(newEntries, fileURL) + try? await logsClient.appendToRunFile(newEntries, fileURL) }, .cancel(id: CancelID.pump) ) - case let .setLaunchContext(launchCount, date, fileURL): - state.currentLaunchCount = launchCount - state.launchDate = date - state.launchFileURL = fileURL + case let .setRunContext(runCount, fileURL): + state.currentRunCount = runCount + state.runFileURL = fileURL return .none case .didReceiveNewEntries(let entries): - state.currentLaunchLogs.append(contentsOf: entries) + state.currentRunLogs.append(contentsOf: entries) state.lastCursorDate = entries.last?.date ?? state.lastCursorDate - if state.selectedLaunchCount == nil { + if state.selectedRunCount == nil { refreshDisplayedLogs(&state) } return .none - case .refreshAvailableLaunches: + case .refreshAvailableRuns: return .run { send in - await send(.availableLaunchesResponse(await logsClient.listLaunchFiles())) + await send(.availableRunsResponse(await logsClient.listRunFiles())) } - case .availableLaunchesResponse(let launches): + case .availableRunsResponse(let runs): // Exclude the current run by file (its count can repeat on earlier days). - state.previousLaunches = launches.filter { $0.url != state.launchFileURL } + state.previousRuns = runs.filter { $0.url != state.runFileURL } return .none - case .selectLaunch(let launchCount): - guard let launchCount, launchCount != state.currentLaunchCount, - let file = state.previousLaunches.first(where: { $0.launchCount == launchCount }) + case .selectRun(let runCount): + guard let runCount, runCount != state.currentRunCount, + let file = state.previousRuns.first(where: { $0.runCount == runCount }) else { - state.selectedLaunchCount = nil - state.selectedLaunchLogs = [] + state.selectedRunCount = nil + state.selectedRunLogs = [] refreshDisplayedLogs(&state) return .none } - state.selectedLaunchCount = launchCount + state.selectedRunCount = runCount state.loadingState = .loading return .run { send in - let logs = (try? await logsClient.readLaunchFile(file.url)) ?? [] - await send(.launchFileResponse(logs)) + let logs = (try? await logsClient.readRunFile(file.url)) ?? [] + await send(.runFileResponse(logs)) } - case .launchFileResponse(let logs): + case .runFileResponse(let logs): state.loadingState = .idle - state.selectedLaunchLogs = logs + state.selectedRunLogs = logs refreshDisplayedLogs(&state) return .none @@ -161,9 +159,9 @@ public struct AppActivityLogsReducer: Sendable { } private func refreshDisplayedLogs(_ state: inout State) { - let source = state.selectedLaunchCount == nil - ? state.currentLaunchLogs - : state.selectedLaunchLogs + let source = state.selectedRunCount == nil + ? state.currentRunLogs + : state.selectedRunLogs state.displayedLogs = logsClient.query(source, state.keyword) .sorted { $0.date > $1.date } } diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift index 0ed58d687..fa7bcb874 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -40,7 +40,7 @@ struct AppActivityLogsView: View { } } .onAppear { - store.send(.refreshAvailableLaunches) + store.send(.refreshAvailableRuns) } .toolbar(content: toolbar) .navigationTitle(L10n.Localizable.AppActivityLogsView.title) @@ -65,21 +65,21 @@ struct AppActivityLogsView: View { private var runMenu: some View { Section(L10n.Localizable.AppActivityLogsView.Section.current) { RunButton( - launchCount: store.currentLaunchCount, - isSelected: store.selectedLaunchCount == nil + runCount: store.currentRunCount, + isSelected: store.selectedRunCount == nil ) { - store.send(.selectLaunch(nil)) + store.send(.selectRun(nil)) } } - ForEach(groupedRuns(Array(store.previousLaunches.prefix(5))), id: \.date) { group in + ForEach(groupedRuns(Array(store.previousRuns.prefix(5))), id: \.date) { group in Section(runDayFormatter.string(from: group.date)) { ForEach(group.runs) { run in RunButton( - launchCount: run.launchCount, - isSelected: store.selectedLaunchCount == run.launchCount + runCount: run.runCount, + isSelected: store.selectedRunCount == run.runCount ) { - store.send(.selectLaunch(run.launchCount)) + store.send(.selectRun(run.runCount)) } } } @@ -103,22 +103,22 @@ private struct RunPickerSheet: View { List { Section(L10n.Localizable.AppActivityLogsView.Section.current) { RunButton( - launchCount: store.currentLaunchCount, - isSelected: store.selectedLaunchCount == nil + runCount: store.currentRunCount, + isSelected: store.selectedRunCount == nil ) { - store.send(.selectLaunch(nil)) + store.send(.selectRun(nil)) onSelect() } } - ForEach(groupedRuns(store.previousLaunches), id: \.date) { group in + ForEach(groupedRuns(store.previousRuns), id: \.date) { group in Section(runDayFormatter.string(from: group.date)) { ForEach(group.runs) { run in RunButton( - launchCount: run.launchCount, - isSelected: store.selectedLaunchCount == run.launchCount + runCount: run.runCount, + isSelected: store.selectedRunCount == run.runCount ) { - store.send(.selectLaunch(run.launchCount)) + store.send(.selectRun(run.runCount)) onSelect() } } @@ -140,33 +140,33 @@ private struct RunPickerSheet: View { // MARK: RunButton private struct RunButton: View { - let launchCount: Int? + let runCount: Int? let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { if isSelected { - Label(runTitle(launchCount), systemSymbol: .checkmark) + Label(runTitle(runCount), systemSymbol: .checkmark) } else { - Text(runTitle(launchCount)) + Text(runTitle(runCount)) } } .foregroundStyle(.primary) } } -// A nil launch count is the current run before its count is resolved; fall back to "Current". -private func runTitle(_ launchCount: Int?) -> String { - guard let launchCount else { +// A nil run count is the current run before its count is resolved; fall back to "Current". +private func runTitle(_ runCount: Int?) -> String { + guard let runCount else { return L10n.Localizable.AppActivityLogsView.Section.current } - return L10n.Localizable.AppActivityLogsView.run("\(launchCount)") + return L10n.Localizable.AppActivityLogsView.run("\(runCount)") } -private func groupedRuns(_ runs: [LaunchLogFile]) -> [(date: Date, runs: [LaunchLogFile])] { +private func groupedRuns(_ runs: [RunLogFile]) -> [(date: Date, runs: [RunLogFile])] { Dictionary(grouping: runs, by: \.date) - .map { (date: $0.key, runs: $0.value.sorted { $0.launchCount > $1.launchCount }) } + .map { (date: $0.key, runs: $0.value.sorted { $0.runCount > $1.runCount }) } .sorted { $0.date > $1.date } } diff --git a/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift index 5288088dd..c42a7ac74 100644 --- a/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift @@ -18,13 +18,13 @@ struct AppActivityLogsReducerTests { let fileURL = URL(fileURLWithPath: "/tmp/ehpanda-20200101-3.jsonl") var client = LogsClient.noop - client.nextLaunchCount = { _ in 3 } - client.currentLaunchFileURL = { _, _ in fileURL } + client.nextRunCount = { _ in 3 } + client.currentRunFileURL = { _, _ in fileURL } client.fetchNewEntries = { _ in fetchCount.withValue { $0 += 1 } return fetchCount.value == 1 ? [entryA, entryB] : [] } - client.appendToLaunchFile = { logs, _ in + client.appendToRunFile = { logs, _ in appended.withValue { $0.append(logs) } } @@ -38,7 +38,7 @@ struct AppActivityLogsReducerTests { await store.send(.startPump) await store.receive(\.didReceiveNewEntries) - #expect(store.state.currentLaunchLogs == [entryA, entryB]) + #expect(store.state.currentRunLogs == [entryA, entryB]) #expect(store.state.lastCursorDate == entryB.date) // Newest entry is shown first. #expect(store.state.displayedLogs == [entryB, entryA]) @@ -46,60 +46,60 @@ struct AppActivityLogsReducerTests { await store.send(.pausePump) await store.finish() - // The pump appended the batch to the per-launch jsonl file exactly once. + // The pump appended the batch to the per-run jsonl file exactly once. #expect(appended.value == [[entryA, entryB]]) } @MainActor @Test - func testSelectingPreviousLaunchLoadsFileBackedLogs() async { + func testSelectingPreviousRunLoadsFileBackedLogs() async { let fileLog = makeLog("archived", secondsSince1970: 5) - let launch = LaunchLogFile( + let run = RunLogFile( url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-2.jsonl"), date: .init(timeIntervalSince1970: 0), - launchCount: 2 + runCount: 2 ) var client = LogsClient.noop - client.readLaunchFile = { _ in [fileLog] } + client.readRunFile = { _ in [fileLog] } var initialState = AppActivityLogsReducer.State() - initialState.currentLaunchCount = 3 - initialState.previousLaunches = [launch] - initialState.currentLaunchLogs = [makeLog("live", secondsSince1970: 100)] + initialState.currentRunCount = 3 + initialState.previousRuns = [run] + initialState.currentRunLogs = [makeLog("live", secondsSince1970: 100)] let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { $0.logsClient = client } - await store.send(.selectLaunch(2)) { - $0.selectedLaunchCount = 2 + await store.send(.selectRun(2)) { + $0.selectedRunCount = 2 $0.loadingState = .loading } - await store.receive(\.launchFileResponse) { + await store.receive(\.runFileResponse) { $0.loadingState = .idle - $0.selectedLaunchLogs = [fileLog] + $0.selectedRunLogs = [fileLog] $0.displayedLogs = [fileLog] } } @MainActor @Test - func testSelectingCurrentLaunchRestoresLiveLogs() async { + func testSelectingCurrentRunRestoresLiveLogs() async { let live = makeLog("live", secondsSince1970: 100) var initialState = AppActivityLogsReducer.State() - initialState.currentLaunchCount = 3 - initialState.currentLaunchLogs = [live] - initialState.selectedLaunchCount = 2 - initialState.selectedLaunchLogs = [makeLog("archived", secondsSince1970: 5)] - initialState.displayedLogs = initialState.selectedLaunchLogs + initialState.currentRunCount = 3 + initialState.currentRunLogs = [live] + initialState.selectedRunCount = 2 + initialState.selectedRunLogs = [makeLog("archived", secondsSince1970: 5)] + initialState.displayedLogs = initialState.selectedRunLogs let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { $0.logsClient = .noop } - await store.send(.selectLaunch(nil)) { - $0.selectedLaunchCount = nil - $0.selectedLaunchLogs = [] + await store.send(.selectRun(nil)) { + $0.selectedRunCount = nil + $0.selectedRunLogs = [] $0.displayedLogs = [live] } } @@ -113,7 +113,7 @@ struct AppActivityLogsReducerTests { client.query = { logs, keyword in logs.filter { $0.message.contains(keyword) } } var initialState = AppActivityLogsReducer.State() - initialState.currentLaunchLogs = [hello, goodbye] + initialState.currentRunLogs = [hello, goodbye] let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { $0.logsClient = client From addc6051b21bb1264ecc454c24d6bb542ebdd3fa Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 15:16:21 +0800 Subject: [PATCH 389/614] Show run time HHmm, cap picker at 5 incl current --- .../AppModels/Support/RunLogFile.swift | 29 +++++---- .../Resources/en.lproj/Localizable.strings | 1 - AppPackage/Sources/Resources/Strings.swift | 2 - .../AppActivityLogsReducer.swift | 22 +++---- .../AppActivityLogs/AppActivityLogsView.swift | 64 +++++++++++-------- .../AppActivityLogsReducerTests.swift | 16 +++-- 6 files changed, 77 insertions(+), 57 deletions(-) diff --git a/AppPackage/Sources/AppModels/Support/RunLogFile.swift b/AppPackage/Sources/AppModels/Support/RunLogFile.swift index dbb20cfb7..c195d6683 100644 --- a/AppPackage/Sources/AppModels/Support/RunLogFile.swift +++ b/AppPackage/Sources/AppModels/Support/RunLogFile.swift @@ -1,9 +1,10 @@ import AppTools import Foundation -/// A persisted per-run activity-log file, named `ehpanda--.jsonl`. +/// A persisted per-run activity-log file, named `ehpanda---.jsonl`. public struct RunLogFile: Identifiable, Equatable, Sendable { public let url: URL + /// The run's start time, decoded from the `-` file-name components. public let date: Date public let runCount: Int @@ -15,8 +16,8 @@ public struct RunLogFile: Identifiable, Equatable, Sendable { self.runCount = runCount } - /// Parses a `RunLogFile` from a log-file URL, returning `nil` when the - /// file name does not match the `ehpanda--.jsonl` format. + /// Parses a `RunLogFile` from a log-file URL, returning `nil` when the file name + /// does not match the `ehpanda---.jsonl` format. public init?(fileURL: URL) { let nameComponents = fileURL.lastPathComponent.split(separator: ".") guard nameComponents.count == 2, @@ -24,22 +25,24 @@ public struct RunLogFile: Identifiable, Equatable, Sendable { else { return nil } let components = nameComponents[0].split(separator: "-") - guard components.count == 3, + guard components.count == 4, String(components[0]) == Defaults.FilePath.activityLogPrefix, components[1].count == 8, - let date = Self.fileNameDateFormatter.date(from: String(components[1])), - let runCount = Int(components[2]) + components[2].count == 6, + let date = Self.dateTimeFormatter.date(from: String(components[1]) + String(components[2])), + let runCount = Int(components[3]) else { return nil } self.init(url: fileURL, date: date, runCount: runCount) } - /// The canonical `ehpanda--.jsonl` file name for a run. + /// The canonical `ehpanda---.jsonl` file name for a run. public static func fileName(date: Date, runCount: Int) -> String { [ [ Defaults.FilePath.activityLogPrefix, dayString(for: date), + timeFormatter.string(from: date), String(runCount) ] .joined(separator: "-"), @@ -52,16 +55,20 @@ public struct RunLogFile: Identifiable, Equatable, Sendable { /// The `yyyyMMdd` day component used in log file names, in the device's local time zone. /// Two runs share a day (and thus the same run-count sequence) iff these match. public static func dayString(for date: Date) -> String { - fileNameDateFormatter.string(from: date) + dayFormatter.string(from: date) } // No explicit time zone: the day rolls over at the device's local midnight, matching how // the picker groups runs and the user's notion of "a different day". - private static let fileNameDateFormatter: DateFormatter = { + private static let dayFormatter = fileNameFormatter(dateFormat: "yyyyMMdd") + private static let timeFormatter = fileNameFormatter(dateFormat: "HHmmss") + private static let dateTimeFormatter = fileNameFormatter(dateFormat: "yyyyMMddHHmmss") + + private static func fileNameFormatter(dateFormat: String) -> DateFormatter { let formatter = DateFormatter() - formatter.dateFormat = "yyyyMMdd" + formatter.dateFormat = dateFormat formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "en_US_POSIX") return formatter - }() + } } diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 7968191c3..039f1a66f 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -237,7 +237,6 @@ "app_activity_logs_view.run" = "Run %@"; "app_activity_logs_view.more_logs" = "More logs"; "app_activity_logs_view.runs" = "Runs"; -"app_activity_logs_view.done" = "Done"; "app_activity_logs_view.level.undefined" = "Undefined"; "app_activity_logs_view.level.debug" = "Debug"; "app_activity_logs_view.level.info" = "Info"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 235664592..a6a2d086e 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -239,8 +239,6 @@ public enum L10n { } } public enum AppActivityLogsView { - /// Done - public static let done = L10n.tr("Localizable", "app_activity_logs_view.done", fallback: "Done") /// More logs public static let moreLogs = L10n.tr("Localizable", "app_activity_logs_view.more_logs", fallback: "More logs") /// Run %@ diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift index 557975e44..ef45ef474 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift @@ -14,9 +14,8 @@ public struct AppActivityLogsReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - // Run context, derived once per app run and reused across pump pauses. - public var currentRunCount: Int? - public var runFileURL: URL? + // The current run, derived once per app run and reused across pump pauses. + public var currentRun: RunLogFile? public var lastCursorDate: Date? // Live, state-backed logs for the current run. @@ -38,7 +37,7 @@ public struct AppActivityLogsReducer: Sendable { public enum Action: Equatable, Sendable { case startPump case pausePump - case setRunContext(runCount: Int, fileURL: URL) + case setCurrentRun(RunLogFile) case didReceiveNewEntries([AppActivityLog]) case refreshAvailableRuns case availableRunsResponse([RunLogFile]) @@ -57,7 +56,7 @@ public struct AppActivityLogsReducer: Sendable { Reduce { state, action in switch action { case .startPump: - return .run { [existingURL = state.runFileURL, cursor0 = state.lastCursorDate] send in + return .run { [existingURL = state.currentRun?.url, cursor0 = state.lastCursorDate] send in let fileURL: URL if let existingURL { fileURL = existingURL @@ -65,7 +64,7 @@ public struct AppActivityLogsReducer: Sendable { let now = date.now let runCount = await logsClient.nextRunCount(now) let resolvedURL = logsClient.currentRunFileURL(runCount, now) - await send(.setRunContext(runCount: runCount, fileURL: resolvedURL)) + await send(.setCurrentRun(RunLogFile(url: resolvedURL, date: now, runCount: runCount))) fileURL = resolvedURL let appVersion = Bundle.main .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "(null)" @@ -95,7 +94,7 @@ public struct AppActivityLogsReducer: Sendable { case .pausePump: return .merge( - .run { [cursor = state.lastCursorDate, fileURL = state.runFileURL] send in + .run { [cursor = state.lastCursorDate, fileURL = state.currentRun?.url] send in guard let fileURL else { return } let newEntries = (try? await logsClient.fetchNewEntries(cursor)) ?? [] guard !newEntries.isEmpty else { return } @@ -105,9 +104,8 @@ public struct AppActivityLogsReducer: Sendable { .cancel(id: CancelID.pump) ) - case let .setRunContext(runCount, fileURL): - state.currentRunCount = runCount - state.runFileURL = fileURL + case let .setCurrentRun(run): + state.currentRun = run return .none case .didReceiveNewEntries(let entries): @@ -125,11 +123,11 @@ public struct AppActivityLogsReducer: Sendable { case .availableRunsResponse(let runs): // Exclude the current run by file (its count can repeat on earlier days). - state.previousRuns = runs.filter { $0.url != state.runFileURL } + state.previousRuns = runs.filter { $0.url != state.currentRun?.url } return .none case .selectRun(let runCount): - guard let runCount, runCount != state.currentRunCount, + guard let runCount, runCount != state.currentRun?.runCount, let file = state.previousRuns.first(where: { $0.runCount == runCount }) else { state.selectedRunCount = nil diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift index fa7bcb874..26caf0a5b 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -65,18 +65,19 @@ struct AppActivityLogsView: View { private var runMenu: some View { Section(L10n.Localizable.AppActivityLogsView.Section.current) { RunButton( - runCount: store.currentRunCount, + run: store.currentRun, isSelected: store.selectedRunCount == nil ) { store.send(.selectRun(nil)) } } - ForEach(groupedRuns(Array(store.previousRuns.prefix(5))), id: \.date) { group in - Section(runDayFormatter.string(from: group.date)) { + // Show the latest runs including the current one: current + 4 previous = 5 rows. + ForEach(groupedRuns(Array(store.previousRuns.prefix(4))), id: \.day) { group in + Section(runDayFormatter.string(from: group.day)) { ForEach(group.runs) { run in RunButton( - runCount: run.runCount, + run: run, isSelected: store.selectedRunCount == run.runCount ) { store.send(.selectRun(run.runCount)) @@ -86,8 +87,10 @@ struct AppActivityLogsView: View { } Section { - Button(L10n.Localizable.AppActivityLogsView.moreLogs) { + Button { isRunPickerPresented = true + } label: { + Label(L10n.Localizable.AppActivityLogsView.moreLogs, systemSymbol: .ellipsisCalendar) } } } @@ -96,30 +99,30 @@ struct AppActivityLogsView: View { // MARK: RunPickerSheet private struct RunPickerSheet: View { @Bindable var store: StoreOf - let onSelect: () -> Void + let dismissAction: () -> Void var body: some View { NavigationStack { List { Section(L10n.Localizable.AppActivityLogsView.Section.current) { RunButton( - runCount: store.currentRunCount, + run: store.currentRun, isSelected: store.selectedRunCount == nil ) { store.send(.selectRun(nil)) - onSelect() + dismissAction() } } - ForEach(groupedRuns(store.previousRuns), id: \.date) { group in - Section(runDayFormatter.string(from: group.date)) { + ForEach(groupedRuns(store.previousRuns), id: \.day) { group in + Section(runDayFormatter.string(from: group.day)) { ForEach(group.runs) { run in RunButton( - runCount: run.runCount, + run: run, isSelected: store.selectedRunCount == run.runCount ) { store.send(.selectRun(run.runCount)) - onSelect() + dismissAction() } } } @@ -128,10 +131,8 @@ private struct RunPickerSheet: View { .navigationTitle(L10n.Localizable.AppActivityLogsView.runs) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Localizable.AppActivityLogsView.done) { - onSelect() - } + ToolbarItem(placement: .cancellationAction) { + Button(role: .cancel, action: dismissAction) } } } @@ -140,34 +141,35 @@ private struct RunPickerSheet: View { // MARK: RunButton private struct RunButton: View { - let runCount: Int? + let run: RunLogFile? let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { if isSelected { - Label(runTitle(runCount), systemSymbol: .checkmark) + Label(runLabel(run), systemSymbol: .checkmark) } else { - Text(runTitle(runCount)) + Text(runLabel(run)) } } .foregroundStyle(.primary) } } -// A nil run count is the current run before its count is resolved; fall back to "Current". -private func runTitle(_ runCount: Int?) -> String { - guard let runCount else { +// A nil run is the current run before its count is resolved; fall back to "Current". +private func runLabel(_ run: RunLogFile?) -> String { + guard let run else { return L10n.Localizable.AppActivityLogsView.Section.current } - return L10n.Localizable.AppActivityLogsView.run("\(runCount)") + let title = L10n.Localizable.AppActivityLogsView.run("\(run.runCount)") + return "\(title) (\(runTimeFormatter.string(from: run.date)))" } -private func groupedRuns(_ runs: [RunLogFile]) -> [(date: Date, runs: [RunLogFile])] { - Dictionary(grouping: runs, by: \.date) - .map { (date: $0.key, runs: $0.value.sorted { $0.runCount > $1.runCount }) } - .sorted { $0.date > $1.date } +private func groupedRuns(_ runs: [RunLogFile]) -> [(day: Date, runs: [RunLogFile])] { + Dictionary(grouping: runs) { Calendar.current.startOfDay(for: $0.date) } + .map { (day: $0.key, runs: $0.value.sorted { $0.runCount > $1.runCount }) } + .sorted { $0.day > $1.day } } private let runDayFormatter: DateFormatter = { @@ -177,6 +179,14 @@ private let runDayFormatter: DateFormatter = { return formatter }() +// 24-hour HH:mm; the day is already shown by the section header. +private let runTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter +}() + // MARK: AppActivityLogRow private struct AppActivityLogRow: View { let log: AppActivityLog diff --git a/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift index c42a7ac74..231a29273 100644 --- a/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift @@ -15,7 +15,7 @@ struct AppActivityLogsReducerTests { let entryB = makeLog("second", secondsSince1970: 20) let fetchCount = LockIsolated(0) let appended = LockIsolated([[AppActivityLog]]()) - let fileURL = URL(fileURLWithPath: "/tmp/ehpanda-20200101-3.jsonl") + let fileURL = URL(fileURLWithPath: "/tmp/ehpanda-20200101-090000-3.jsonl") var client = LogsClient.noop client.nextRunCount = { _ in 3 } @@ -55,7 +55,7 @@ struct AppActivityLogsReducerTests { func testSelectingPreviousRunLoadsFileBackedLogs() async { let fileLog = makeLog("archived", secondsSince1970: 5) let run = RunLogFile( - url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-2.jsonl"), + url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-090000-2.jsonl"), date: .init(timeIntervalSince1970: 0), runCount: 2 ) @@ -63,7 +63,11 @@ struct AppActivityLogsReducerTests { client.readRunFile = { _ in [fileLog] } var initialState = AppActivityLogsReducer.State() - initialState.currentRunCount = 3 + initialState.currentRun = RunLogFile( + url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-100000-3.jsonl"), + date: .init(timeIntervalSince1970: 3600), + runCount: 3 + ) initialState.previousRuns = [run] initialState.currentRunLogs = [makeLog("live", secondsSince1970: 100)] @@ -87,7 +91,11 @@ struct AppActivityLogsReducerTests { func testSelectingCurrentRunRestoresLiveLogs() async { let live = makeLog("live", secondsSince1970: 100) var initialState = AppActivityLogsReducer.State() - initialState.currentRunCount = 3 + initialState.currentRun = RunLogFile( + url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-100000-3.jsonl"), + date: .init(timeIntervalSince1970: 3600), + runCount: 3 + ) initialState.currentRunLogs = [live] initialState.selectedRunCount = 2 initialState.selectedRunLogs = [makeLog("archived", secondsSince1970: 5)] From 346b62f52e3625cf72fb5380dcdaea1c5db5e9c8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 15:55:51 +0800 Subject: [PATCH 390/614] Log app activity events at notice level --- AppPackage/Package.swift | 1 + .../AppFeature/DataFlow/AppDelegateReducer.swift | 5 +++++ .../Sources/AppFeature/DataFlow/AppReducer.swift | 7 ++++++- AppPackage/Sources/AppFeature/Logger+.swift | 7 +++++++ .../BackgroundProcessingClient.swift | 1 + .../Sources/DatabaseClient/DatabaseClient.swift | 3 +++ .../DownloadClient/DownloadClient+Execution.swift | 7 +++++++ .../DownloadClient/DownloadClient+PublicAPI.swift | 7 +++++++ .../DownloadClient/DownloadClient+Scheduling.swift | 2 ++ .../GeneralSetting/GeneralSettingReducer.swift | 4 ++++ .../Sources/SettingFeature/Login/LoginReducer.swift | 13 +++++++++++-- .../SettingFeature/SettingReducer+Body.swift | 12 ++++++++++-- 12 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 AppPackage/Sources/AppFeature/Logger+.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 777f033fb..97d3c5ec8 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -281,6 +281,7 @@ let targets: [PackageDescription.Target] = [ .module(.libraryClient), .module(.migrationFeature), .module(.networkingFeature), + .module(.osLogExt), .module(.parserFeature), .module(.quickSearchFeature), .module(.readingFeature), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index 5fd65bb11..16293fe75 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -10,6 +10,9 @@ import DownloadClient import BackgroundProcessingClient import CookieClient import MigrationFeature +import OSLogExt + +private let logger = Logger(category: .init(describing: AppDelegateReducer.self)) @Reducer struct AppDelegateReducer { @@ -90,6 +93,7 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { @Dependency(\.backgroundProcessingClient) var backgroundProcessingClient let work = Task { @MainActor in + logger.notice("Background processing started.") await downloadClient.runBackgroundProcessing() // Reschedule only if we stopped on our own with work still pending; an // expiration cancels this task and reschedules from its own handler. @@ -97,6 +101,7 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { backgroundProcessingClient.schedule() } task.setTaskCompleted(success: !Task.isCancelled) + logger.notice("Background processing finished, cancelled: \(Task.isCancelled, privacy: .public).") } task.expirationHandler = { work.cancel() diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 9a57e89b5..6eee35b66 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -12,6 +12,9 @@ import SearchFeature import FavoritesFeature import DownloadsFeature import SettingFeature +import OSLogExt + +private let logger = Logger(category: .init(describing: AppReducer.self)) @Reducer struct AppReducer { @@ -83,7 +86,8 @@ struct AppReducer { let blurRadius = state.settingState.setting.backgroundBlurRadius var effects: [Effect] = [ .send(.appLock(.onBecomeActive(threshold, blurRadius))), - .send(.setting(.general(.appActivityLogs(.startPump)))) + .send(.setting(.general(.appActivityLogs(.startPump)))), + .run { _ in logger.notice("App entered foreground.") } ] // iOS interposes .inactive on a foreground return // (.background -> .inactive -> .active), so the previous @@ -112,6 +116,7 @@ struct AppReducer { return .merge( .send(.setting(.general(.appActivityLogs(.pausePump)))), .run { _ in + logger.notice("App entered background.") if await downloadClient.hasPendingWork() { backgroundProcessingClient.schedule() } diff --git a/AppPackage/Sources/AppFeature/Logger+.swift b/AppPackage/Sources/AppFeature/Logger+.swift new file mode 100644 index 000000000..ec1c9186c --- /dev/null +++ b/AppPackage/Sources/AppFeature/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "AppFeature", category: category) + } +} diff --git a/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift b/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift index 9fc00c70e..2d53f4e7f 100644 --- a/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift +++ b/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift @@ -52,6 +52,7 @@ extension BackgroundProcessingClient { request.earliestBeginDate = nil do { try BGTaskScheduler.shared.submit(request) + logger.notice("Scheduled background processing task.") } catch { logger.error("\(error, privacy: .public)") } diff --git a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift index 52bc924e5..0a7329b3a 100644 --- a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift +++ b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift @@ -29,6 +29,9 @@ extension DatabaseClient { dropDatabase: { await withCheckedContinuation { continuation in PersistenceController.shared.rebuild { result in + if case .success = result { + logger.notice("Database dropped.") + } continuation.resume(returning: result) } } diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift index 205c5ee45..9b856cbe9 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift @@ -56,6 +56,13 @@ extension DownloadCoordinator { download: DownloadedGallery, result: ProcessDownloadResult ) async { + logger.notice( + """ + Download completed, gid: \(gid, privacy: .public), \ + pages: \(download.pageCount, privacy: .public), \ + title: \(download.title, privacy: .public). + """ + ) await settleCompletedDownload(gid: gid) let completedFolderURL = storage.folderURL( relativePath: result.folderRelativePath diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift index 796db7f3b..62a539588 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift @@ -90,6 +90,12 @@ extension DownloadCoordinator { await queueStore.enqueue(payload.gallery.gid) await notifyObservers() await scheduleNextIfNeeded() + logger.notice( + """ + Download enqueued, gid: \(payload.gallery.gid, privacy: .public), \ + title: \(payload.gallery.title, privacy: .public). + """ + ) return .success(()) } catch let error as AppError { return .failure(error) @@ -199,6 +205,7 @@ extension DownloadCoordinator { downloadIndex[gid] = nil await notifyObservers() await scheduleNextIfNeeded() + logger.notice("Download deleted, gid: \(gid, privacy: .public).") return .success(()) } diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift index a8384053e..e5780a10c 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift @@ -162,6 +162,7 @@ extension DownloadCoordinator { ) await notifyObservers() await scheduleNextIfNeeded() + logger.notice("Download paused, gid: \(gid, privacy: .public).") return .success(()) } catch let error as AppError { return .failure(error) @@ -226,6 +227,7 @@ extension DownloadCoordinator { await queueStore.enqueue(gid) await notifyObservers() await scheduleNextIfNeeded() + logger.notice("Download resumed, gid: \(gid, privacy: .public).") return .success(()) } diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index b99b80156..764129dff 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -5,6 +5,9 @@ import AuthorizationClient import ApplicationClient import LibraryClient import DatabaseClient +import OSLogExt + +private let logger = Logger(category: .init(describing: GeneralSettingReducer.self)) @Reducer public struct GeneralSettingReducer: Sendable { @@ -81,6 +84,7 @@ public struct GeneralSettingReducer: Sendable { async let removeImageURLs: Void = databaseClient.removeImageURLs() _ = await (removeCachedImages, removeImageURLs) + logger.notice("Cleared image cache.") await send(.calculateWebImageDiskCache) } diff --git a/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift index 18dbee4ba..d4f66601e 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift @@ -5,6 +5,9 @@ import SwiftUINavigationExt import HapticsClient import NetworkingFeature import CookieClient +import OSLogExt + +private let logger = Logger(category: .init(describing: LoginReducer.self)) @Reducer public struct LoginReducer: Sendable { @@ -86,10 +89,16 @@ public struct LoginReducer: Sendable { var effects = [Effect]() if cookieClient.didLogin { state.loginState = .idle - effects.append(.run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) })) + effects.append(.run(operation: { _ in + logger.notice("Login succeeded.") + await hapticsClient.generateNotificationFeedback(.success) + })) } else { state.loginState = .failed(.unknown) - effects.append(.run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) })) + effects.append(.run(operation: { _ in + logger.notice("Login failed.") + await hapticsClient.generateNotificationFeedback(.error) + })) } if case .success(let response) = result, let response = response { effects.append(.run(operation: { _ in cookieClient.setCredentials(response: response) })) diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 52bc64a35..f73dd31ab 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -1,9 +1,12 @@ import AppTools import Foundation import AppModels +import OSLogExt import ComposableArchitecture import NetworkingFeature +private let logger = Logger(category: .init(describing: SettingReducer.self)) + extension SettingReducer { @ReducerBuilder var reducerBody: some Reducer { @@ -166,11 +169,15 @@ extension SettingReducer { case .fetchIgneousDone(let result): if case .success(let response) = result { return .run { send in + logger.notice("Igneous token refreshed.") cookieClient.setCredentials(response: response) await send(.account(.loadCookies)) } } - return .send(.account(.loadCookies)) + return .merge( + .run { _ in logger.notice("Igneous refresh failed.") }, + .send(.account(.loadCookies)) + ) case .fetchUserInfo: guard cookieClient.didLogin else { return .none } @@ -263,7 +270,8 @@ extension SettingReducer { .send(.syncUser), .run(operation: { _ in cookieClient.clearAll() }), .run(operation: { _ in await databaseClient.removeImageURLs() }), - .run(operation: { _ in await libraryClient.removeAllCachedImages() }) + .run(operation: { _ in await libraryClient.removeAllCachedImages() }), + .run { _ in logger.notice("Logged out.") } ) case .account: From ae39ed97139cae8dbc19cb3d12787ef09d147ed1 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 16:36:03 +0800 Subject: [PATCH 391/614] Use file URL as RunLogFile identity --- .../AppModels/Support/RunLogFile.swift | 4 +++- .../AppActivityLogsReducer.swift | 22 +++++++++---------- .../AppActivityLogs/AppActivityLogsView.swift | 12 +++++----- .../AppActivityLogsReducerTests.swift | 8 +++---- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/AppPackage/Sources/AppModels/Support/RunLogFile.swift b/AppPackage/Sources/AppModels/Support/RunLogFile.swift index c195d6683..f8c2ad33d 100644 --- a/AppPackage/Sources/AppModels/Support/RunLogFile.swift +++ b/AppPackage/Sources/AppModels/Support/RunLogFile.swift @@ -8,7 +8,9 @@ public struct RunLogFile: Identifiable, Equatable, Sendable { public let date: Date public let runCount: Int - public var id: Int { runCount } + // The file URL is the run's canonical unique identity: run counts reset daily, so they + // repeat across days, whereas the name (day + time + count) is unique per file. + public var id: URL { url } public init(url: URL, date: Date, runCount: Int) { self.url = url diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift index ef45ef474..d97746688 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift @@ -25,8 +25,8 @@ public struct AppActivityLogsReducer: Sendable { // File-backed logs for the currently selected previous run. public var selectedRunLogs = [AppActivityLog]() - // `nil` selects the current run (state-backed); otherwise a previous run count. - public var selectedRunCount: Int? + // `nil` selects the current run (state-backed); otherwise a previous run's file URL. + public var selectedRun: URL? public var displayedLogs = [AppActivityLog]() public var keyword = "" public var loadingState: LoadingState = .idle @@ -41,7 +41,7 @@ public struct AppActivityLogsReducer: Sendable { case didReceiveNewEntries([AppActivityLog]) case refreshAvailableRuns case availableRunsResponse([RunLogFile]) - case selectRun(Int?) + case selectRun(URL?) case runFileResponse([AppActivityLog]) case queryLogs(String) } @@ -111,7 +111,7 @@ public struct AppActivityLogsReducer: Sendable { case .didReceiveNewEntries(let entries): state.currentRunLogs.append(contentsOf: entries) state.lastCursorDate = entries.last?.date ?? state.lastCursorDate - if state.selectedRunCount == nil { + if state.selectedRun == nil { refreshDisplayedLogs(&state) } return .none @@ -126,19 +126,17 @@ public struct AppActivityLogsReducer: Sendable { state.previousRuns = runs.filter { $0.url != state.currentRun?.url } return .none - case .selectRun(let runCount): - guard let runCount, runCount != state.currentRun?.runCount, - let file = state.previousRuns.first(where: { $0.runCount == runCount }) - else { - state.selectedRunCount = nil + case .selectRun(let url): + guard let url, state.previousRuns.contains(where: { $0.url == url }) else { + state.selectedRun = nil state.selectedRunLogs = [] refreshDisplayedLogs(&state) return .none } - state.selectedRunCount = runCount + state.selectedRun = url state.loadingState = .loading return .run { send in - let logs = (try? await logsClient.readRunFile(file.url)) ?? [] + let logs = (try? await logsClient.readRunFile(url)) ?? [] await send(.runFileResponse(logs)) } @@ -157,7 +155,7 @@ public struct AppActivityLogsReducer: Sendable { } private func refreshDisplayedLogs(_ state: inout State) { - let source = state.selectedRunCount == nil + let source = state.selectedRun == nil ? state.currentRunLogs : state.selectedRunLogs state.displayedLogs = logsClient.query(source, state.keyword) diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift index 26caf0a5b..4b0d31d52 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -66,7 +66,7 @@ struct AppActivityLogsView: View { Section(L10n.Localizable.AppActivityLogsView.Section.current) { RunButton( run: store.currentRun, - isSelected: store.selectedRunCount == nil + isSelected: store.selectedRun == nil ) { store.send(.selectRun(nil)) } @@ -78,9 +78,9 @@ struct AppActivityLogsView: View { ForEach(group.runs) { run in RunButton( run: run, - isSelected: store.selectedRunCount == run.runCount + isSelected: store.selectedRun == run.url ) { - store.send(.selectRun(run.runCount)) + store.send(.selectRun(run.url)) } } } @@ -107,7 +107,7 @@ private struct RunPickerSheet: View { Section(L10n.Localizable.AppActivityLogsView.Section.current) { RunButton( run: store.currentRun, - isSelected: store.selectedRunCount == nil + isSelected: store.selectedRun == nil ) { store.send(.selectRun(nil)) dismissAction() @@ -119,9 +119,9 @@ private struct RunPickerSheet: View { ForEach(group.runs) { run in RunButton( run: run, - isSelected: store.selectedRunCount == run.runCount + isSelected: store.selectedRun == run.url ) { - store.send(.selectRun(run.runCount)) + store.send(.selectRun(run.url)) dismissAction() } } diff --git a/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift index 231a29273..0568511f9 100644 --- a/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift @@ -75,8 +75,8 @@ struct AppActivityLogsReducerTests { $0.logsClient = client } - await store.send(.selectRun(2)) { - $0.selectedRunCount = 2 + await store.send(.selectRun(run.url)) { + $0.selectedRun = run.url $0.loadingState = .loading } await store.receive(\.runFileResponse) { @@ -97,7 +97,7 @@ struct AppActivityLogsReducerTests { runCount: 3 ) initialState.currentRunLogs = [live] - initialState.selectedRunCount = 2 + initialState.selectedRun = URL(fileURLWithPath: "/tmp/ehpanda-20200101-090000-2.jsonl") initialState.selectedRunLogs = [makeLog("archived", secondsSince1970: 5)] initialState.displayedLogs = initialState.selectedRunLogs @@ -106,7 +106,7 @@ struct AppActivityLogsReducerTests { } await store.send(.selectRun(nil)) { - $0.selectedRunCount = nil + $0.selectedRun = nil $0.selectedRunLogs = [] $0.displayedLogs = [live] } From 8a8b9316804efb049706b943a5b3ddf5528ff2d1 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 17:20:35 +0800 Subject: [PATCH 392/614] Add Open in Files button to activity logs --- .../Sources/ApplicationClient/ApplicationClient.swift | 7 +++++++ .../Resources/Resources/en.lproj/Localizable.strings | 1 + AppPackage/Sources/Resources/Strings.swift | 2 ++ .../AppActivityLogs/AppActivityLogsReducer.swift | 6 ++++++ .../AppActivityLogs/AppActivityLogsView.swift | 7 +++++++ 5 files changed, 23 insertions(+) diff --git a/AppPackage/Sources/ApplicationClient/ApplicationClient.swift b/AppPackage/Sources/ApplicationClient/ApplicationClient.swift index c5dcf01d1..02f62463e 100644 --- a/AppPackage/Sources/ApplicationClient/ApplicationClient.swift +++ b/AppPackage/Sources/ApplicationClient/ApplicationClient.swift @@ -45,6 +45,13 @@ extension ApplicationClient { return openURL(url) } } + @MainActor + public func openFileApp() { + let dirPath = FileUtil.logsDirectoryURL.path + if let dirURL = URL(string: "shareddocuments://" + dirPath) { + return openURL(dirURL) + } + } } // MARK: API diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 039f1a66f..34635f48d 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -236,6 +236,7 @@ "app_activity_logs_view.section.current" = "Current"; "app_activity_logs_view.run" = "Run %@"; "app_activity_logs_view.more_logs" = "More logs"; +"app_activity_logs_view.open_in_files" = "Open in Files"; "app_activity_logs_view.runs" = "Runs"; "app_activity_logs_view.level.undefined" = "Undefined"; "app_activity_logs_view.level.debug" = "Debug"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index a6a2d086e..1f30f003c 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -241,6 +241,8 @@ public enum L10n { public enum AppActivityLogsView { /// More logs public static let moreLogs = L10n.tr("Localizable", "app_activity_logs_view.more_logs", fallback: "More logs") + /// Open in Files + public static let openInFiles = L10n.tr("Localizable", "app_activity_logs_view.open_in_files", fallback: "Open in Files") /// Run %@ public static func run(_ p1: Any) -> String { return L10n.tr("Localizable", "app_activity_logs_view.run", String(describing: p1), fallback: "Run %@") diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift index d97746688..56c7e8f4d 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift @@ -2,6 +2,7 @@ import OSLogExt import Foundation import AppModels import LogsClient +import ApplicationClient import ComposableArchitecture private let logger = Logger(category: .init(describing: AppActivityLogsReducer.self)) @@ -44,9 +45,11 @@ public struct AppActivityLogsReducer: Sendable { case selectRun(URL?) case runFileResponse([AppActivityLog]) case queryLogs(String) + case navigateToFileApp } @Dependency(\.logsClient) private var logsClient + @Dependency(\.applicationClient) private var applicationClient @Dependency(\.continuousClock) private var clock @Dependency(\.date) private var date @@ -150,6 +153,9 @@ public struct AppActivityLogsReducer: Sendable { state.keyword = keyword refreshDisplayedLogs(&state) return .none + + case .navigateToFileApp: + return .run { _ in await applicationClient.openFileApp() } } } } diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift index 4b0d31d52..d51a11a67 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -59,6 +59,13 @@ struct AppActivityLogsView: View { Image(systemSymbol: .clock) } } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + store.send(.navigateToFileApp) + } label: { + Label(L10n.Localizable.AppActivityLogsView.openInFiles, systemSymbol: .folderBadgeGearshape) + } + } } @ViewBuilder From 93528aa3bf160cf66110dff8e7ab198fcd62d575 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 17:20:58 +0800 Subject: [PATCH 393/614] Use sentence case for App activity logs label --- .../Resources/Resources/en.lproj/Localizable.strings | 4 ++-- AppPackage/Sources/Resources/Strings.swift | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 34635f48d..bf8b43e1b 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -217,7 +217,7 @@ "general_setting_view.title.redirects_links_to_the_selected_host" = "Redirects links to the selected host"; "general_setting_view.title.detects_links_from_clipboard" = "Detects links from the clipboard"; "general_setting_view.title.background_blur_radius" = "Background blur radius"; -"general_setting_view.button.app_activity_logs" = "App Activity Logs"; +"general_setting_view.button.app_activity_logs" = "App activity logs"; "general_setting_view.button.import_custom_translations" = "Import custom translations"; "general_setting_view.button.remove_custom_translations" = "Remove custom translations"; "general_setting_view.button.clear_image_caches" = "Clear image caches"; @@ -231,7 +231,7 @@ "enum.auto_lock_policy.value.instantly" = "Instantly"; // MARK: AppActivityLogsView -"app_activity_logs_view.title" = "App Activity Logs"; +"app_activity_logs_view.title" = "App activity logs"; "app_activity_logs_view.placeholder.no_logs" = "No logs found"; "app_activity_logs_view.section.current" = "Current"; "app_activity_logs_view.run" = "Run %@"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 1f30f003c..1b49e3cf5 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -249,8 +249,8 @@ public enum L10n { } /// Runs public static let runs = L10n.tr("Localizable", "app_activity_logs_view.runs", fallback: "Runs") - /// App Activity Logs - public static let title = L10n.tr("Localizable", "app_activity_logs_view.title", fallback: "App Activity Logs") + /// App activity logs + public static let title = L10n.tr("Localizable", "app_activity_logs_view.title", fallback: "App activity logs") public enum Level { /// Debug public static let debug = L10n.tr("Localizable", "app_activity_logs_view.level.debug", fallback: "Debug") @@ -2182,8 +2182,8 @@ public enum L10n { } public enum GeneralSettingView { public enum Button { - /// App Activity Logs - public static let appActivityLogs = L10n.tr("Localizable", "general_setting_view.button.app_activity_logs", fallback: "App Activity Logs") + /// App activity logs + public static let appActivityLogs = L10n.tr("Localizable", "general_setting_view.button.app_activity_logs", fallback: "App activity logs") /// Clear image caches public static let clearImageCaches = L10n.tr("Localizable", "general_setting_view.button.clear_image_caches", fallback: "Clear image caches") /// Import custom translations From 6985542cf0b17931a1677fb64d2b638fc5e64898 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 17:32:14 +0800 Subject: [PATCH 394/614] Resync localizations to match en, add logs keys --- .../Resources/de.lproj/Localizable.strings | 238 +++++++++-------- .../Resources/ja.lproj/Localizable.strings | 243 +++++++++-------- .../Resources/ko.lproj/Localizable.strings | 249 ++++++++++-------- .../zh-Hans.lproj/Localizable.strings | 24 +- .../zh-Hant-HK.lproj/Localizable.strings | 242 +++++++++-------- .../zh-Hant-TW.lproj/Localizable.strings | 239 +++++++++-------- .../zh-Hant.lproj/Localizable.strings | 241 +++++++++-------- 7 files changed, 805 insertions(+), 671 deletions(-) diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 0b52cd94a..61581d59f 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -42,10 +42,14 @@ "common.value.seconds" = "Nach %@ Sekunden"; "common.value.records" = "%@ Einträge"; +// MARK: Common button +"common.button.cancel" = "Abbrechen"; + // MARK: TabItem "tab_item.title.home" = "Home"; "tab_item.title.favorites" = "Favoriten"; "tab_item.title.search" = "Suche"; +"tab_item.title.downloads" = "Downloads"; "tab_item.title.setting" = "Einstellungen"; // MARK: ToolbarItem @@ -81,6 +85,24 @@ "error_view.title.copyright_claim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; "error_view.title.gallery_unavailable" = "This gallery has been removed or is unavailable."; +// MARK: AppError +"app_error.localized_description.database_corrupted" = "Datenbank beschädigt"; +"app_error.localized_description.copyright_claim" = "Urheberrechtsanspruch"; +"app_error.localized_description.ip_banned" = "IP-Adresse gesperrt"; +"app_error.localized_description.gallery_expunged" = "Galerie entfernt"; +"app_error.localized_description.network_error" = "Netzwerkfehler"; +"app_error.localized_description.web_image_loading_error" = "Fehler beim Laden des Webbilds"; +"app_error.localized_description.parse_error" = "Parserfehler"; +"app_error.localized_description.quota_exceeded" = "Kontingent überschritten"; +"app_error.localized_description.authentication_required" = "Authentifizierung erforderlich"; +"app_error.localized_description.file_operation_failed" = "Dateivorgang fehlgeschlagen"; +"app_error.localized_description.no_updates_available" = "Keine Updates verfügbar"; +"app_error.localized_description.not_found" = "Nicht gefunden"; +"app_error.localized_description.unknown_error" = "Unbekannter Fehler"; +"app_error.alert.quota_exceeded" = "Bildkontingent überschritten.\nBitte warte einen Moment und versuche es dann erneut."; +"app_error.alert.authentication_required" = "Für diesen Download ist eine Anmeldung erforderlich."; +"app_error.alert.local_file_operation_failed" = "Lokaler Dateivorgang fehlgeschlagen."; + // MARK: ConfirmationDialog "confirmation_dialog.title.drop_database" = "You will lose all your data in this app.\nAre you sure to drop the database?"; "confirmation_dialog.title.remove_custom_translations" = "Are you sure to remove your custom translations?"; @@ -195,7 +217,7 @@ "general_setting_view.title.redirects_links_to_the_selected_host" = "Links zum ausgewählten Host umleiten"; "general_setting_view.title.detects_links_from_clipboard" = "Übernimmt automatisch Links aus der Zwischenablage"; "general_setting_view.title.background_blur_radius" = "Background blur radius"; -"general_setting_view.button.logs" = "Logs"; +"general_setting_view.button.app_activity_logs" = "App-Aktivitätsprotokolle"; "general_setting_view.button.import_custom_translations" = "Import custom translations"; "general_setting_view.button.remove_custom_translations" = "Remove custom translations"; "general_setting_view.button.clear_image_caches" = "Zwischengespeicherte Bilder (Cache) löschen"; @@ -208,9 +230,20 @@ "enum.auto_lock_policy.value.never" = "Nie"; "enum.auto_lock_policy.value.instantly" = "Sofort"; -// MARK: LogsView -"logs_view.title.logs" = "Logs"; -"logs_view.title.latest" = "Neueste"; +// MARK: AppActivityLogsView +"app_activity_logs_view.title" = "App-Aktivitätsprotokolle"; +"app_activity_logs_view.placeholder.no_logs" = "Keine Protokolle gefunden"; +"app_activity_logs_view.section.current" = "Aktuell"; +"app_activity_logs_view.run" = "Ausführung %@"; +"app_activity_logs_view.more_logs" = "Weitere Protokolle"; +"app_activity_logs_view.open_in_files" = "In „Dateien“ öffnen"; +"app_activity_logs_view.runs" = "Ausführungen"; +"app_activity_logs_view.level.undefined" = "Undefiniert"; +"app_activity_logs_view.level.debug" = "Debug"; +"app_activity_logs_view.level.info" = "Info"; +"app_activity_logs_view.level.notice" = "Hinweis"; +"app_activity_logs_view.level.error" = "Fehler"; +"app_activity_logs_view.level.fault" = "Störung"; // MARK: AppearanceSettingView "appearance_setting_view.title.appearance" = "Oberfläche"; @@ -288,6 +321,9 @@ "detail_view.accessibility.download_button.retry" = "Download erneut versuchen"; "detail_view.accessibility.download_button.repair" = "Download reparieren"; "detail_view.accessibility.download_button.preparing" = "Download-Informationen werden geladen"; +"detail_view.accessibility.download_button.pause_action" = "Download pausieren"; +"detail_view.accessibility.download_button.paused" = "Download fortsetzen. Pausiert bei %d von %d"; +"detail_view.accessibility.download_button.partial" = "Download erneut versuchen. %d von %d Seiten sind bereits verfügbar."; "detail_view.toolbar_item.button.archives" = "Archiv"; "detail_view.toolbar_item.button.torrents" = "Torrents"; "detail_view.toolbar_item.button.share" = "Teilen"; @@ -306,6 +342,19 @@ "detail_view.action_section.button.similar_gallery" = "Ähnliche Galerien"; "detail_view.section.title.previews" = "Vorschau"; "detail_view.section.title.comments" = "Kommentar"; +"detail_view.dialog.title.delete_download" = "Download löschen?"; +"detail_view.dialog.title.repair_download" = "Download reparieren?"; +"detail_view.dialog.title.update_download" = "Download aktualisieren?"; +"detail_view.dialog.title.redownload_gallery" = "Galerie erneut herunterladen?"; +"detail_view.dialog.message.delete_active_download" = "Der aktuelle Download wird gestoppt und die Galerie von diesem Gerät entfernt."; +"detail_view.dialog.message.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; +"detail_view.dialog.message.repair_download" = "Die Offline-Dateien dieser Galerie jetzt reparieren?"; +"detail_view.dialog.message.update_download" = "Diese Galerie jetzt auf die neueste Online-Version aktualisieren?"; +"detail_view.dialog.message.redownload_gallery" = "Diese Galerie jetzt vollständig neu herunterladen?"; +"detail_view.dialog.button.repair" = "Reparieren"; +"detail_view.dialog.button.update" = "Aktualisieren"; +"detail_view.dialog.button.redownload" = "Erneut laden"; +"detail_view.offline_notice.saved_details" = "Online-Details konnten nicht aktualisiert werden. Stattdessen werden gespeicherte Details angezeigt."; // MARK: ArchivesView "archives_view.title.archives" = "Archiv"; @@ -355,6 +404,56 @@ "tag_detail_view.section.title.images" = "Images"; "tag_detail_view.section.title.links" = "Links"; +// MARK: DownloadsView +"enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; +"detail_view.menu.text.no_folders" = "No folders yet"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; +"downloads_view.title.downloads" = "Downloads"; +"downloads_view.search.prompt.downloads" = "Downloads durchsuchen"; +"downloads_view.dialog.title.delete_download" = "Download löschen?"; +"downloads_view.dialog.message.delete_active_download" = "Der aktuelle Download wird abgebrochen und von diesem Gerät entfernt."; +"downloads_view.dialog.message.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; +"downloads_view.swipe.button.pages" = "Seiten"; +"downloads_view.swipe.button.update" = "Aktualisieren"; +"downloads_view.swipe.button.resume" = "Fortsetzen"; +"downloads_view.swipe.button.pause" = "Pausieren"; +"downloads_view.empty_state.downloads" = "Heruntergeladene Galerien werden hier angezeigt."; +"downloads_view.empty_state.no_matching_filters" = "Keine Downloads entsprechen den aktuellen Filtern."; +"downloads_view.button.clear_filters" = "Filter löschen"; +"downloads_view.button.validate_image_data" = "Bilddaten validieren"; +"downloads_view.inspector.section.actions" = "Aktionen"; +"downloads_view.inspector.section.pages" = "Seiten"; +"downloads_view.inspector.button.retry_failed_pages" = "Fehlgeschlagene Seiten erneut versuchen"; +"downloads_view.inspector.button.validating_image_data" = "Bilddaten werden geprüft..."; +"downloads_view.inspector.button.update_download" = "Download aktualisieren"; +"downloads_view.inspector.hud.image_data_valid" = "Bilddaten sind gültig"; +"downloads_view.inspector.hud.image_data_unavailable" = "Bilddaten konnten nicht geprüft werden."; +"downloads_view.inspector.title.download_status" = "Downloadstatus"; +"downloads_view.inspector.page.pending" = "Ausstehend"; +"downloads_view.inspector.page.tap_to_retry" = "Tippen, um diese Seite erneut zu versuchen"; +"downloads_view.inspector.page.title" = "Seite %d"; +"downloads_view.inspector.page.none" = "Keine Seiten"; +"downloads_view.inspector.status.pending" = "Ausstehend"; +"downloads_view.inspector.status.downloaded" = "Heruntergeladen"; +"downloads_view.inspector.status.failed" = "Fehlgeschlagen"; + +// MARK: DownloadSettingView +"download_setting_view.title" = "Download"; +"download_setting_view.section.title.download_queue" = "Download-Warteschlange"; +"download_setting_view.section.title.network" = "Netzwerk"; +"download_setting_view.title.concurrent_image_downloads" = "Gleichzeitige Bilddownloads"; +"download_setting_view.title.retry_failed_pages_automatically" = "Fehlgeschlagene Seiten automatisch erneut versuchen"; +"download_setting_view.title.allow_cellular_downloads" = "Downloads über Mobilfunk erlauben"; +"download_setting_view.footer.network" = "Es wird immer nur eine Galerie gleichzeitig heruntergeladen. Mit dieser Einstellung steuerst du, wie viele Galerieseiten parallel geladen werden, ob Mobilfunk erlaubt ist und dass Dateien im Downloads-Ordner der App gespeichert werden."; + // MARK: CommentsView "comments_view.title.comments" = "Kommentar"; @@ -380,6 +479,33 @@ // AutoPlayPolicy "enum.auto_play_policy.value.off" = "Off"; + +// MARK: DownloadBadge +"struct.download_badge.text.queued" = "In Warteschlange"; +"struct.download_badge.text.downloading" = "Lädt herunter"; +"struct.download_badge.text.paused" = "Pausiert"; +"struct.download_badge.text.downloaded" = "Heruntergeladen"; +"struct.download_badge.text.needs_attention" = "Benötigt Aufmerksamkeit"; +"struct.download_badge.text.update_available" = "Update verfügbar"; +"struct.download_badge.text.needs_repair" = "Reparatur nötig"; +"struct.download_badge.progress" = "%d/%d"; + +// MARK: DownloadStore +"download_store.error.asset_unreadable" = "Asset-Datei ist nicht lesbar: %@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "Download-Ordner konnte nicht aufgelöst werden."; +"download_store.validation.download_folder_missing" = "Download-Ordner fehlt."; +"download_store.validation.manifest_missing" = "Manifest-Datei fehlt."; +"download_store.validation.manifest_corrupted" = "Manifest-Datei ist beschädigt."; +"download_store.validation.downloaded_pages_incomplete" = "Heruntergeladene Seiten sind unvollständig."; +"download_store.validation.cover_image_missing" = "Coverbild fehlt."; +"download_store.validation.page_missing" = "Seite %d fehlt."; +"download_store.validation.cover_image_corrupted" = "Coverbilddaten sind beschädigt."; +"download_store.validation.page_image_corrupted" = "Bilddaten von Seite %d sind beschädigt."; + // MARK: FiltersView "filters_view.title.filters" = "Filters"; "filters_view.title.advanced_settings" = "Erweiterte Einstellungen"; @@ -928,107 +1054,3 @@ "enum.browsing_country.name.yemen" = "Yemen"; "enum.browsing_country.name.zambia" = "Zambia"; "enum.browsing_country.name.zimbabwe" = "Zimbabwe"; - -// MARK: Download Localization Additions -"common.button.cancel" = "Abbrechen"; -"tab_item.title.downloads" = "Downloads"; -"app_error.localized_description.database_corrupted" = "Datenbank beschädigt"; -"app_error.localized_description.copyright_claim" = "Urheberrechtsanspruch"; -"app_error.localized_description.ip_banned" = "IP-Adresse gesperrt"; -"app_error.localized_description.gallery_expunged" = "Galerie entfernt"; -"app_error.localized_description.network_error" = "Netzwerkfehler"; -"app_error.localized_description.web_image_loading_error" = "Fehler beim Laden des Webbilds"; -"app_error.localized_description.parse_error" = "Parserfehler"; -"app_error.localized_description.quota_exceeded" = "Kontingent überschritten"; -"app_error.localized_description.authentication_required" = "Authentifizierung erforderlich"; -"app_error.localized_description.file_operation_failed" = "Dateivorgang fehlgeschlagen"; -"app_error.localized_description.no_updates_available" = "Keine Updates verfügbar"; -"app_error.localized_description.not_found" = "Nicht gefunden"; -"app_error.localized_description.unknown_error" = "Unbekannter Fehler"; -"app_error.alert.quota_exceeded" = "Bildkontingent überschritten.\nBitte warte einen Moment und versuche es dann erneut."; -"app_error.alert.authentication_required" = "Für diesen Download ist eine Anmeldung erforderlich."; -"app_error.alert.local_file_operation_failed" = "Lokaler Dateivorgang fehlgeschlagen."; -"detail_view.accessibility.download_button.pause_action" = "Download pausieren"; -"detail_view.accessibility.download_button.paused" = "Download fortsetzen. Pausiert bei %d von %d"; -"detail_view.accessibility.download_button.partial" = "Download erneut versuchen. %d von %d Seiten sind bereits verfügbar."; -"detail_view.dialog.title.delete_download" = "Download löschen?"; -"detail_view.dialog.title.repair_download" = "Download reparieren?"; -"detail_view.dialog.title.update_download" = "Download aktualisieren?"; -"detail_view.dialog.title.redownload_gallery" = "Galerie erneut herunterladen?"; -"detail_view.dialog.message.delete_active_download" = "Der aktuelle Download wird gestoppt und die Galerie von diesem Gerät entfernt."; -"detail_view.dialog.message.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; -"detail_view.dialog.message.repair_download" = "Die Offline-Dateien dieser Galerie jetzt reparieren?"; -"detail_view.dialog.message.update_download" = "Diese Galerie jetzt auf die neueste Online-Version aktualisieren?"; -"detail_view.dialog.message.redownload_gallery" = "Diese Galerie jetzt vollständig neu herunterladen?"; -"detail_view.dialog.button.repair" = "Reparieren"; -"detail_view.dialog.button.update" = "Aktualisieren"; -"detail_view.dialog.button.redownload" = "Erneut laden"; -"detail_view.offline_notice.saved_details" = "Online-Details konnten nicht aktualisiert werden. Stattdessen werden gespeicherte Details angezeigt."; -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "Downloads"; -"downloads_view.search.prompt.downloads" = "Downloads durchsuchen"; -"downloads_view.dialog.title.delete_download" = "Download löschen?"; -"downloads_view.dialog.message.delete_active_download" = "Der aktuelle Download wird abgebrochen und von diesem Gerät entfernt."; -"downloads_view.dialog.message.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; -"downloads_view.swipe.button.pages" = "Seiten"; -"downloads_view.swipe.button.update" = "Aktualisieren"; -"downloads_view.swipe.button.resume" = "Fortsetzen"; -"downloads_view.swipe.button.pause" = "Pausieren"; -"downloads_view.empty_state.downloads" = "Heruntergeladene Galerien werden hier angezeigt."; -"downloads_view.empty_state.no_matching_filters" = "Keine Downloads entsprechen den aktuellen Filtern."; -"downloads_view.button.clear_filters" = "Filter löschen"; -"downloads_view.button.validate_image_data" = "Bilddaten validieren"; -"downloads_view.inspector.section.actions" = "Aktionen"; -"downloads_view.inspector.section.pages" = "Seiten"; -"downloads_view.inspector.button.retry_failed_pages" = "Fehlgeschlagene Seiten erneut versuchen"; -"downloads_view.inspector.button.validating_image_data" = "Bilddaten werden geprüft..."; -"downloads_view.inspector.button.update_download" = "Download aktualisieren"; -"downloads_view.inspector.hud.image_data_valid" = "Bilddaten sind gültig"; -"downloads_view.inspector.hud.image_data_unavailable" = "Bilddaten konnten nicht geprüft werden."; -"downloads_view.inspector.title.download_status" = "Downloadstatus"; -"downloads_view.inspector.page.pending" = "Ausstehend"; -"downloads_view.inspector.page.tap_to_retry" = "Tippen, um diese Seite erneut zu versuchen"; -"downloads_view.inspector.page.title" = "Seite %d"; -"downloads_view.inspector.page.none" = "Keine Seiten"; -"downloads_view.inspector.status.pending" = "Ausstehend"; -"downloads_view.inspector.status.downloaded" = "Heruntergeladen"; -"downloads_view.inspector.status.failed" = "Fehlgeschlagen"; -"download_setting_view.title" = "Download"; -"download_setting_view.section.title.download_queue" = "Download-Warteschlange"; -"download_setting_view.section.title.network" = "Netzwerk"; -"download_setting_view.title.concurrent_image_downloads" = "Gleichzeitige Bilddownloads"; -"download_setting_view.title.retry_failed_pages_automatically" = "Fehlgeschlagene Seiten automatisch erneut versuchen"; -"download_setting_view.title.allow_cellular_downloads" = "Downloads über Mobilfunk erlauben"; -"download_setting_view.footer.network" = "Es wird immer nur eine Galerie gleichzeitig heruntergeladen. Mit dieser Einstellung steuerst du, wie viele Galerieseiten parallel geladen werden, ob Mobilfunk erlaubt ist und dass Dateien im Downloads-Ordner der App gespeichert werden."; -"struct.download_badge.text.queued" = "In Warteschlange"; -"struct.download_badge.text.downloading" = "Lädt herunter"; -"struct.download_badge.text.paused" = "Pausiert"; -"struct.download_badge.text.downloaded" = "Heruntergeladen"; -"struct.download_badge.text.needs_attention" = "Benötigt Aufmerksamkeit"; -"struct.download_badge.text.update_available" = "Update verfügbar"; -"struct.download_badge.text.needs_repair" = "Reparatur nötig"; -"struct.download_badge.progress" = "%d/%d"; -"download_store.error.asset_unreadable" = "Asset-Datei ist nicht lesbar: %@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "Download-Ordner konnte nicht aufgelöst werden."; -"download_store.validation.download_folder_missing" = "Download-Ordner fehlt."; -"download_store.validation.manifest_missing" = "Manifest-Datei fehlt."; -"download_store.validation.manifest_corrupted" = "Manifest-Datei ist beschädigt."; -"download_store.validation.downloaded_pages_incomplete" = "Heruntergeladene Seiten sind unvollständig."; -"download_store.validation.cover_image_missing" = "Coverbild fehlt."; -"download_store.validation.page_missing" = "Seite %d fehlt."; -"download_store.validation.cover_image_corrupted" = "Coverbilddaten sind beschädigt."; -"download_store.validation.page_image_corrupted" = "Bilddaten von Seite %d sind beschädigt."; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 3502a5151..b25790baa 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings EhPanda */ @@ -42,10 +42,14 @@ "common.value.seconds" = "%@ 秒"; "common.value.records" = "%@ 件のレコード"; +// MARK: Common button +"common.button.cancel" = "キャンセル"; + // MARK: TabItem "tab_item.title.home" = "ホーム"; "tab_item.title.favorites" = "お気に入り"; "tab_item.title.search" = "検索"; +"tab_item.title.downloads" = "ダウンロード"; "tab_item.title.setting" = "設定"; // MARK: ToolbarItem @@ -81,6 +85,24 @@ "error_view.title.copyright_claim" = "申し訳ありませんが、このギャラリーは %@ の著作権主張によってアクセス不可になっています。"; "error_view.title.gallery_unavailable" = "このギャラリーはすでに削除済みまたは無効です。"; +// MARK: AppError +"app_error.localized_description.database_corrupted" = "データベース破損"; +"app_error.localized_description.copyright_claim" = "著作権侵害の申し立て"; +"app_error.localized_description.ip_banned" = "IP アドレスがブロックされました"; +"app_error.localized_description.gallery_expunged" = "ギャラリー削除済み"; +"app_error.localized_description.network_error" = "ネットワークエラー"; +"app_error.localized_description.web_image_loading_error" = "Web 画像の読み込みエラー"; +"app_error.localized_description.parse_error" = "解析エラー"; +"app_error.localized_description.quota_exceeded" = "画像割り当て超過"; +"app_error.localized_description.authentication_required" = "認証が必要です"; +"app_error.localized_description.file_operation_failed" = "ファイル操作に失敗しました"; +"app_error.localized_description.no_updates_available" = "利用可能な更新はありません"; +"app_error.localized_description.not_found" = "見つかりません"; +"app_error.localized_description.unknown_error" = "不明なエラー"; +"app_error.alert.quota_exceeded" = "画像の帯域割り当てを使い切りました。\nしばらく待ってからもう一度お試しください。"; +"app_error.alert.authentication_required" = "このダウンロードにアクセスするにはログインが必要です。"; +"app_error.alert.local_file_operation_failed" = "ローカルファイルの操作に失敗しました。"; + // MARK: ConfirmationDialog "confirmation_dialog.title.drop_database" = "本アプリでのすべてのデータを失うことになります。\n本当にデータベースを削除してもよろしいですか?"; "confirmation_dialog.title.remove_custom_translations" = "本当にカスタム翻訳を削除してもよろしいですか?"; @@ -195,7 +217,7 @@ "general_setting_view.title.redirects_links_to_the_selected_host" = "リンクを選択されたホストへリダイレクト"; "general_setting_view.title.detects_links_from_clipboard" = "クリップボードからリンクを探知"; "general_setting_view.title.background_blur_radius" = "バッググラウンドぼかし度"; -"general_setting_view.button.logs" = "ログ"; +"general_setting_view.button.app_activity_logs" = "アプリアクティビティログ"; "general_setting_view.button.import_custom_translations" = "カスタム翻訳を取り込む"; "general_setting_view.button.remove_custom_translations" = "カスタム翻訳を削除"; "general_setting_view.button.clear_image_caches" = "画像キャッシュを削除"; @@ -208,9 +230,20 @@ "enum.auto_lock_policy.value.never" = "なし"; "enum.auto_lock_policy.value.instantly" = "すぐに"; -// MARK: LogsView -"logs_view.title.logs" = "ログ"; -"logs_view.title.latest" = "最新"; +// MARK: AppActivityLogsView +"app_activity_logs_view.title" = "アプリアクティビティログ"; +"app_activity_logs_view.placeholder.no_logs" = "ログが見つかりません"; +"app_activity_logs_view.section.current" = "現在"; +"app_activity_logs_view.run" = "起動 %@"; +"app_activity_logs_view.more_logs" = "他のログ"; +"app_activity_logs_view.open_in_files" = "ファイルで開く"; +"app_activity_logs_view.runs" = "起動"; +"app_activity_logs_view.level.undefined" = "未定義"; +"app_activity_logs_view.level.debug" = "デバッグ"; +"app_activity_logs_view.level.info" = "情報"; +"app_activity_logs_view.level.notice" = "通知"; +"app_activity_logs_view.level.error" = "エラー"; +"app_activity_logs_view.level.fault" = "障害"; // MARK: AppearanceSettingView "appearance_setting_view.title.appearance" = "外観"; @@ -288,6 +321,9 @@ "detail_view.accessibility.download_button.retry" = "ダウンロードを再試行"; "detail_view.accessibility.download_button.repair" = "ダウンロードを修復"; "detail_view.accessibility.download_button.preparing" = "ダウンロード情報を取得中"; +"detail_view.accessibility.download_button.pause_action" = "ダウンロードを一時停止"; +"detail_view.accessibility.download_button.paused" = "ダウンロードを再開。%d / %d ページで停止中"; +"detail_view.accessibility.download_button.partial" = "ダウンロードを再試行。すでに %d / %d ページが利用可能です。"; "detail_view.toolbar_item.button.archives" = "アーカイブ"; "detail_view.toolbar_item.button.torrents" = "トレント"; "detail_view.toolbar_item.button.share" = "共有"; @@ -306,6 +342,19 @@ "detail_view.action_section.button.similar_gallery" = "類似ギャラリー"; "detail_view.section.title.previews" = "プレビュー"; "detail_view.section.title.comments" = "コメント"; +"detail_view.dialog.title.delete_download" = "ダウンロードを削除しますか?"; +"detail_view.dialog.title.repair_download" = "ダウンロードを修復しますか?"; +"detail_view.dialog.title.update_download" = "ダウンロードを更新しますか?"; +"detail_view.dialog.title.redownload_gallery" = "ギャラリーを再ダウンロードしますか?"; +"detail_view.dialog.message.delete_active_download" = "現在のダウンロードを停止し、このデバイスからギャラリーを削除します。"; +"detail_view.dialog.message.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; +"detail_view.dialog.message.repair_download" = "このギャラリーのオフラインファイルを今すぐ修復しますか?"; +"detail_view.dialog.message.update_download" = "このギャラリーを今すぐオンラインの最新バージョンに更新しますか?"; +"detail_view.dialog.message.redownload_gallery" = "このギャラリーを今すぐ最初から再ダウンロードしますか?"; +"detail_view.dialog.button.repair" = "修復"; +"detail_view.dialog.button.update" = "更新"; +"detail_view.dialog.button.redownload" = "再ダウンロード"; +"detail_view.offline_notice.saved_details" = "オンラインの詳細を更新できなかったため、保存済みの詳細を表示しています。"; // MARK: ArchivesView "archives_view.title.archives" = "アーカイブ"; @@ -355,6 +404,56 @@ "tag_detail_view.section.title.images" = "画像"; "tag_detail_view.section.title.links" = "リンク"; +// MARK: DownloadsView +"enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; +"detail_view.menu.text.no_folders" = "No folders yet"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; +"downloads_view.title.downloads" = "ダウンロード"; +"downloads_view.search.prompt.downloads" = "ダウンロードを検索"; +"downloads_view.dialog.title.delete_download" = "ダウンロードを削除しますか?"; +"downloads_view.dialog.message.delete_active_download" = "現在のダウンロードをキャンセルし、このデバイスから削除します。"; +"downloads_view.dialog.message.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; +"downloads_view.swipe.button.pages" = "ページ"; +"downloads_view.swipe.button.update" = "更新"; +"downloads_view.swipe.button.resume" = "再開"; +"downloads_view.swipe.button.pause" = "一時停止"; +"downloads_view.empty_state.downloads" = "ダウンロードしたギャラリーはここに表示されます。"; +"downloads_view.empty_state.no_matching_filters" = "現在のフィルターに一致するダウンロードはありません。"; +"downloads_view.button.clear_filters" = "フィルターをクリア"; +"downloads_view.button.validate_image_data" = "画像データを検証"; +"downloads_view.inspector.section.actions" = "操作"; +"downloads_view.inspector.section.pages" = "ページ"; +"downloads_view.inspector.button.retry_failed_pages" = "失敗したページを再試行"; +"downloads_view.inspector.button.validating_image_data" = "画像データを検証中..."; +"downloads_view.inspector.button.update_download" = "ダウンロードを更新"; +"downloads_view.inspector.hud.image_data_valid" = "画像データは有効です"; +"downloads_view.inspector.hud.image_data_unavailable" = "画像データを検証できませんでした。"; +"downloads_view.inspector.title.download_status" = "ダウンロード状況"; +"downloads_view.inspector.page.pending" = "待機中"; +"downloads_view.inspector.page.tap_to_retry" = "タップしてこのページを再試行"; +"downloads_view.inspector.page.title" = "ページ %d"; +"downloads_view.inspector.page.none" = "ページなし"; +"downloads_view.inspector.status.pending" = "待機中"; +"downloads_view.inspector.status.downloaded" = "ダウンロード済み"; +"downloads_view.inspector.status.failed" = "失敗"; + +// MARK: DownloadSettingView +"download_setting_view.title" = "ダウンロード"; +"download_setting_view.section.title.download_queue" = "ダウンロードキュー"; +"download_setting_view.section.title.network" = "ネットワーク"; +"download_setting_view.title.concurrent_image_downloads" = "同時画像ダウンロード数"; +"download_setting_view.title.retry_failed_pages_automatically" = "失敗したページを自動で再試行"; +"download_setting_view.title.allow_cellular_downloads" = "モバイル通信でのダウンロードを許可"; +"download_setting_view.footer.network" = "一度にダウンロードされるギャラリーは 1 件だけです。この設定では、1 つのギャラリー内で同時にダウンロードするページ数、モバイル通信の許可または禁止、そしてファイルをアプリの Downloads フォルダに保存する動作を管理します。"; + // MARK: CommentsView "comments_view.title.comments" = "コメント"; @@ -380,6 +479,33 @@ // AutoPlayPolicy "enum.auto_play_policy.value.off" = "オフ"; + +// MARK: DownloadBadge +"struct.download_badge.text.queued" = "待機中"; +"struct.download_badge.text.downloading" = "ダウンロード中"; +"struct.download_badge.text.paused" = "一時停止"; +"struct.download_badge.text.downloaded" = "ダウンロード済み"; +"struct.download_badge.text.needs_attention" = "要対応"; +"struct.download_badge.text.update_available" = "更新あり"; +"struct.download_badge.text.needs_repair" = "要修復"; +"struct.download_badge.progress" = "%d/%d"; + +// MARK: DownloadStore +"download_store.error.asset_unreadable" = "アセットファイルを読み取れません: %@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "ダウンロードフォルダを解決できませんでした。"; +"download_store.validation.download_folder_missing" = "ダウンロードフォルダが見つかりません。"; +"download_store.validation.manifest_missing" = "マニフェストファイルが見つかりません。"; +"download_store.validation.manifest_corrupted" = "マニフェストファイルが破損しています。"; +"download_store.validation.downloaded_pages_incomplete" = "ダウンロード済みページが不完全です。"; +"download_store.validation.cover_image_missing" = "表紙画像が見つかりません。"; +"download_store.validation.page_missing" = "ページ %d が見つかりません。"; +"download_store.validation.cover_image_corrupted" = "表紙画像データが破損しています。"; +"download_store.validation.page_image_corrupted" = "ページ %d の画像データが破損しています。"; + // MARK: FiltersView "filters_view.title.filters" = "フィルター"; "filters_view.title.advanced_settings" = "高度な設定"; @@ -928,110 +1054,3 @@ "enum.browsing_country.name.yemen" = "イエメン"; "enum.browsing_country.name.zambia" = "ザンビア"; "enum.browsing_country.name.zimbabwe" = "ジンバブエ"; - -// MARK: Download Localization Additions -"common.button.cancel" = "キャンセル"; -"tab_item.title.downloads" = "ダウンロード"; -"app_error.localized_description.database_corrupted" = "データベース破損"; -"app_error.localized_description.copyright_claim" = "著作権侵害の申し立て"; -"app_error.localized_description.ip_banned" = "IP アドレスがブロックされました"; -"app_error.localized_description.gallery_expunged" = "ギャラリー削除済み"; -"app_error.localized_description.network_error" = "ネットワークエラー"; -"app_error.localized_description.web_image_loading_error" = "Web 画像の読み込みエラー"; -"app_error.localized_description.parse_error" = "解析エラー"; -"app_error.localized_description.quota_exceeded" = "画像割り当て超過"; -"app_error.localized_description.authentication_required" = "認証が必要です"; -"app_error.localized_description.file_operation_failed" = "ファイル操作に失敗しました"; -"app_error.localized_description.no_updates_available" = "利用可能な更新はありません"; -"app_error.localized_description.not_found" = "見つかりません"; -"app_error.localized_description.unknown_error" = "不明なエラー"; -"app_error.alert.quota_exceeded" = "画像の帯域割り当てを使い切りました。\nしばらく待ってからもう一度お試しください。"; -"app_error.alert.authentication_required" = "このダウンロードにアクセスするにはログインが必要です。"; -"app_error.alert.local_file_operation_failed" = "ローカルファイルの操作に失敗しました。"; -"detail_view.accessibility.download_button.pause_action" = "ダウンロードを一時停止"; -"detail_view.accessibility.download_button.paused" = "ダウンロードを再開。%d / %d ページで停止中"; -"detail_view.accessibility.download_button.partial" = "ダウンロードを再試行。すでに %d / %d ページが利用可能です。"; -"detail_view.dialog.title.delete_download" = "ダウンロードを削除しますか?"; -"detail_view.dialog.title.repair_download" = "ダウンロードを修復しますか?"; -"detail_view.dialog.title.update_download" = "ダウンロードを更新しますか?"; -"detail_view.dialog.title.redownload_gallery" = "ギャラリーを再ダウンロードしますか?"; -"detail_view.dialog.message.delete_active_download" = "現在のダウンロードを停止し、このデバイスからギャラリーを削除します。"; -"detail_view.dialog.message.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; -"detail_view.dialog.message.repair_download" = "このギャラリーのオフラインファイルを今すぐ修復しますか?"; -"detail_view.dialog.message.update_download" = "このギャラリーを今すぐオンラインの最新バージョンに更新しますか?"; -"detail_view.dialog.message.redownload_gallery" = "このギャラリーを今すぐ最初から再ダウンロードしますか?"; -"detail_view.dialog.button.repair" = "修復"; -"detail_view.dialog.button.update" = "更新"; -"detail_view.dialog.button.redownload" = "再ダウンロード"; -"detail_view.offline_notice.saved_details" = "オンラインの詳細を更新できなかったため、保存済みの詳細を表示しています。"; -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "ダウンロード"; -"downloads_view.search.prompt.downloads" = "ダウンロードを検索"; -"downloads_view.dialog.title.delete_download" = "ダウンロードを削除しますか?"; -"downloads_view.dialog.message.delete_active_download" = "現在のダウンロードをキャンセルし、このデバイスから削除します。"; -"downloads_view.dialog.message.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; -"downloads_view.swipe.button.pages" = "ページ"; -"downloads_view.swipe.button.update" = "更新"; -"downloads_view.swipe.button.resume" = "再開"; -"downloads_view.swipe.button.pause" = "一時停止"; -"downloads_view.empty_state.downloads" = "ダウンロードしたギャラリーはここに表示されます。"; -"downloads_view.empty_state.no_matching_filters" = "現在のフィルターに一致するダウンロードはありません。"; -"downloads_view.button.clear_filters" = "フィルターをクリア"; -"downloads_view.button.validate_image_data" = "画像データを検証"; -"downloads_view.inspector.section.actions" = "操作"; -"downloads_view.inspector.section.pages" = "ページ"; -"downloads_view.inspector.button.retry_failed_pages" = "失敗したページを再試行"; -"downloads_view.inspector.button.validating_image_data" = "画像データを検証中..."; -"downloads_view.inspector.button.update_download" = "ダウンロードを更新"; -"downloads_view.inspector.hud.image_data_valid" = "画像データは有効です"; -"downloads_view.inspector.hud.image_data_unavailable" = "画像データを検証できませんでした。"; -"downloads_view.inspector.title.download_status" = "ダウンロード状況"; -"downloads_view.inspector.page.pending" = "待機中"; -"downloads_view.inspector.page.tap_to_retry" = "タップしてこのページを再試行"; -"downloads_view.inspector.page.title" = "ページ %d"; -"downloads_view.inspector.page.none" = "ページなし"; -"downloads_view.inspector.status.pending" = "待機中"; -"downloads_view.inspector.status.downloaded" = "ダウンロード済み"; -"downloads_view.inspector.status.failed" = "失敗"; -"download_setting_view.title" = "ダウンロード"; -"download_setting_view.section.title.download_queue" = "ダウンロードキュー"; -"download_setting_view.section.title.network" = "ネットワーク"; -"download_setting_view.title.concurrent_image_downloads" = "同時画像ダウンロード数"; -"download_setting_view.title.retry_failed_pages_automatically" = "失敗したページを自動で再試行"; -"download_setting_view.title.allow_cellular_downloads" = "モバイル通信でのダウンロードを許可"; -"download_setting_view.footer.network" = "一度にダウンロードされるギャラリーは 1 件だけです。この設定では、1 つのギャラリー内で同時にダウンロードするページ数、モバイル通信の許可または禁止、そしてファイルをアプリの Downloads フォルダに保存する動作を管理します。"; -"struct.download_badge.text.queued" = "待機中"; -"struct.download_badge.text.downloading" = "ダウンロード中"; -"struct.download_badge.text.paused" = "一時停止"; -"struct.download_badge.text.downloaded" = "ダウンロード済み"; -"struct.download_badge.text.needs_attention" = "要対応"; -"struct.download_badge.text.update_available" = "更新あり"; -"struct.download_badge.text.needs_repair" = "要修復"; -"struct.download_badge.progress" = "%d/%d"; -"download_store.error.asset_unreadable" = "アセットファイルを読み取れません: %@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "ダウンロードフォルダを解決できませんでした。"; -"download_store.validation.download_folder_missing" = "ダウンロードフォルダが見つかりません。"; -"download_store.validation.manifest_missing" = "マニフェストファイルが見つかりません。"; -"download_store.validation.manifest_corrupted" = "マニフェストファイルが破損しています。"; -"download_store.validation.downloaded_pages_incomplete" = "ダウンロード済みページが不完全です。"; -"download_store.validation.cover_image_missing" = "表紙画像が見つかりません。"; -"download_store.validation.page_missing" = "ページ %d が見つかりません。"; -"download_store.validation.cover_image_corrupted" = "表紙画像データが破損しています。"; -"download_store.validation.page_image_corrupted" = "ページ %d の画像データが破損しています。"; - -// MARK: AppActivityLogsView -"app_activity_logs_view.run" = "起動 %@"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index 70aaa4bb5..3b85cf1dd 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings EhPanda */ @@ -42,21 +42,21 @@ "common.value.seconds" = "%@ 초"; "common.value.records" = "%@ 기록수"; +// MARK: Common button +"common.button.cancel" = "취소"; + // MARK: TabItem "tab_item.title.home" = "Home"; "tab_item.title.favorites" = "즐겨찾기"; "tab_item.title.search" = "검색"; +"tab_item.title.downloads" = "다운로드"; "tab_item.title.setting" = "설정"; // MARK: ToolbarItem "toolbar_item.button.filters" = "필터"; "toolbar_item.button.jump_page" = "페이지 이동"; -"toolbar_item.button.quick_search" = "빠른 검색"; "toolbar_item.button.date_seek" = "날짜로 이동"; - -// MARK: JumpPage -"jump_page_view.title.jump_page" = "페이지 이동"; -"jump_page_view.button.confirm" = "확인"; +"toolbar_item.button.quick_search" = "빠른 검색"; // MARK: DateSeek "date_seek_view.title.date_seek" = "날짜로 이동"; @@ -64,6 +64,9 @@ "date_seek_view.footer.seek_around_date" = "선택한 날짜 근처의 갤러리로 이동합니다."; "date_seek_view.button.seek_newer" = "새로운 쪽"; "date_seek_view.button.seek_older" = "오래된 쪽"; +// MARK: JumpPage +"jump_page_view.title.jump_page" = "페이지 이동"; +"jump_page_view.button.confirm" = "확인"; // MARK: AlertView "loading_view.title.loading" = "로딩 중..."; @@ -82,6 +85,24 @@ "error_view.title.copyright_claim" = "%@의 저작권 요청으로 인하여 이 갤러리를 사용할 수 없어요."; "error_view.title.gallery_unavailable" = "이 갤러리는 제거되었거나 사용할 수 없어요."; +// MARK: AppError +"app_error.localized_description.database_corrupted" = "데이터베이스 손상"; +"app_error.localized_description.copyright_claim" = "저작권 신고"; +"app_error.localized_description.ip_banned" = "IP 차단됨"; +"app_error.localized_description.gallery_expunged" = "갤러리 삭제됨"; +"app_error.localized_description.network_error" = "네트워크 오류"; +"app_error.localized_description.web_image_loading_error" = "웹 이미지 로드 오류"; +"app_error.localized_description.parse_error" = "파싱 오류"; +"app_error.localized_description.quota_exceeded" = "할당량 초과"; +"app_error.localized_description.authentication_required" = "인증 필요"; +"app_error.localized_description.file_operation_failed" = "파일 작업 실패"; +"app_error.localized_description.no_updates_available" = "사용 가능한 업데이트 없음"; +"app_error.localized_description.not_found" = "찾을 수 없음"; +"app_error.localized_description.unknown_error" = "알 수 없는 오류"; +"app_error.alert.quota_exceeded" = "이미지 할당량을 모두 사용했습니다.\n잠시 후 다시 시도해 주세요."; +"app_error.alert.authentication_required" = "이 다운로드에 접근하려면 로그인해야 합니다."; +"app_error.alert.local_file_operation_failed" = "로컬 파일 작업에 실패했습니다."; + // MARK: ConfirmationDialog "confirmation_dialog.title.drop_database" = "You will lose all your data in this app.\nAre you sure to drop the database?"; "confirmation_dialog.title.remove_custom_translations" = "Are you sure to remove your custom translations?"; @@ -196,7 +217,7 @@ "general_setting_view.title.redirects_links_to_the_selected_host" = "선택한 서버로 이동하기"; "general_setting_view.title.detects_links_from_clipboard" = "클립보드의 링크 인식하기"; "general_setting_view.title.background_blur_radius" = "Background blur radius"; -"general_setting_view.button.logs" = "로그"; +"general_setting_view.button.app_activity_logs" = "앱 활동 로그"; "general_setting_view.button.import_custom_translations" = "Import custom translations"; "general_setting_view.button.remove_custom_translations" = "Remove custom translations"; "general_setting_view.button.clear_image_caches" = "이미지 캐시 지우기"; @@ -209,9 +230,20 @@ "enum.auto_lock_policy.value.never" = "안 함"; "enum.auto_lock_policy.value.instantly" = "즉시"; -// MARK: LogsView -"logs_view.title.logs" = "로그"; -"logs_view.title.latest" = "마지막"; +// MARK: AppActivityLogsView +"app_activity_logs_view.title" = "앱 활동 로그"; +"app_activity_logs_view.placeholder.no_logs" = "로그가 없습니다"; +"app_activity_logs_view.section.current" = "현재"; +"app_activity_logs_view.run" = "실행 %@"; +"app_activity_logs_view.more_logs" = "더 많은 로그"; +"app_activity_logs_view.open_in_files" = "파일 앱에서 열기"; +"app_activity_logs_view.runs" = "실행"; +"app_activity_logs_view.level.undefined" = "정의되지 않음"; +"app_activity_logs_view.level.debug" = "디버그"; +"app_activity_logs_view.level.info" = "정보"; +"app_activity_logs_view.level.notice" = "알림"; +"app_activity_logs_view.level.error" = "오류"; +"app_activity_logs_view.level.fault" = "결함"; // MARK: AppearanceSettingView "appearance_setting_view.title.appearance" = "외관"; @@ -289,6 +321,9 @@ "detail_view.accessibility.download_button.retry" = "다운로드 다시 시도"; "detail_view.accessibility.download_button.repair" = "다운로드 복구"; "detail_view.accessibility.download_button.preparing" = "다운로드 정보를 불러오는 중"; +"detail_view.accessibility.download_button.pause_action" = "다운로드 일시 정지"; +"detail_view.accessibility.download_button.paused" = "다운로드 다시 시작. %d / %d 페이지에서 일시 정지됨"; +"detail_view.accessibility.download_button.partial" = "다운로드 다시 시도. 이미 %d / %d 페이지를 사용할 수 있습니다."; "detail_view.toolbar_item.button.archives" = "아카이브"; "detail_view.toolbar_item.button.torrents" = "토렌트"; "detail_view.toolbar_item.button.share" = "공유"; @@ -307,6 +342,19 @@ "detail_view.action_section.button.similar_gallery" = "비슷한 작품"; "detail_view.section.title.previews" = "미리보기"; "detail_view.section.title.comments" = "댓글"; +"detail_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; +"detail_view.dialog.title.repair_download" = "다운로드를 복구할까요?"; +"detail_view.dialog.title.update_download" = "다운로드를 업데이트할까요?"; +"detail_view.dialog.title.redownload_gallery" = "갤러리를 다시 다운로드할까요?"; +"detail_view.dialog.message.delete_active_download" = "현재 다운로드를 중지하고 이 기기에서 갤러리를 삭제합니다."; +"detail_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; +"detail_view.dialog.message.repair_download" = "이 갤러리의 오프라인 파일을 지금 복구할까요?"; +"detail_view.dialog.message.update_download" = "이 갤러리를 지금 온라인 최신 버전으로 업데이트할까요?"; +"detail_view.dialog.message.redownload_gallery" = "이 갤러리를 지금 처음부터 다시 다운로드할까요?"; +"detail_view.dialog.button.repair" = "복구"; +"detail_view.dialog.button.update" = "업데이트"; +"detail_view.dialog.button.redownload" = "다시 다운로드"; +"detail_view.offline_notice.saved_details" = "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다."; // MARK: ArchivesView "archives_view.title.archives" = "아카이브"; @@ -356,6 +404,56 @@ "tag_detail_view.section.title.images" = "Images"; "tag_detail_view.section.title.links" = "Links"; +// MARK: DownloadsView +"enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; +"detail_view.menu.text.no_folders" = "No folders yet"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; +"downloads_view.title.downloads" = "다운로드"; +"downloads_view.search.prompt.downloads" = "다운로드 검색"; +"downloads_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; +"downloads_view.dialog.message.delete_active_download" = "현재 다운로드를 취소하고 이 기기에서 삭제합니다."; +"downloads_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; +"downloads_view.swipe.button.pages" = "페이지"; +"downloads_view.swipe.button.update" = "업데이트"; +"downloads_view.swipe.button.resume" = "재개"; +"downloads_view.swipe.button.pause" = "일시 정지"; +"downloads_view.empty_state.downloads" = "다운로드한 갤러리가 여기에 표시됩니다."; +"downloads_view.empty_state.no_matching_filters" = "현재 필터와 일치하는 다운로드가 없습니다."; +"downloads_view.button.clear_filters" = "필터 지우기"; +"downloads_view.button.validate_image_data" = "이미지 데이터 검증"; +"downloads_view.inspector.section.actions" = "동작"; +"downloads_view.inspector.section.pages" = "페이지"; +"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도"; +"downloads_view.inspector.button.validating_image_data" = "이미지 데이터 검증 중..."; +"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; +"downloads_view.inspector.hud.image_data_valid" = "이미지 데이터가 유효합니다"; +"downloads_view.inspector.hud.image_data_unavailable" = "이미지 데이터를 검증할 수 없습니다."; +"downloads_view.inspector.title.download_status" = "다운로드 상태"; +"downloads_view.inspector.page.pending" = "대기 중"; +"downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; +"downloads_view.inspector.page.title" = "페이지 %d"; +"downloads_view.inspector.page.none" = "페이지 없음"; +"downloads_view.inspector.status.pending" = "대기 중"; +"downloads_view.inspector.status.downloaded" = "다운로드됨"; +"downloads_view.inspector.status.failed" = "실패"; + +// MARK: DownloadSettingView +"download_setting_view.title" = "다운로드"; +"download_setting_view.section.title.download_queue" = "다운로드 대기열"; +"download_setting_view.section.title.network" = "네트워크"; +"download_setting_view.title.concurrent_image_downloads" = "동시 이미지 다운로드 수"; +"download_setting_view.title.retry_failed_pages_automatically" = "실패한 페이지 자동 재시도"; +"download_setting_view.title.allow_cellular_downloads" = "셀룰러 다운로드 허용"; +"download_setting_view.footer.network" = "한 번에 하나의 갤러리만 다운로드됩니다. 이 설정으로 한 갤러리 안에서 동시에 다운로드할 페이지 수, 셀룰러 다운로드 허용 여부, 그리고 파일을 앱의 Downloads 폴더에 저장하는 방식을 제어합니다."; + // MARK: CommentsView "comments_view.title.comments" = "댓글"; @@ -381,6 +479,33 @@ // AutoPlayPolicy "enum.auto_play_policy.value.off" = "Off"; + +// MARK: DownloadBadge +"struct.download_badge.text.queued" = "대기 중"; +"struct.download_badge.text.downloading" = "다운로드 중"; +"struct.download_badge.text.paused" = "일시 정지"; +"struct.download_badge.text.downloaded" = "다운로드됨"; +"struct.download_badge.text.needs_attention" = "조치 필요"; +"struct.download_badge.text.update_available" = "업데이트 가능"; +"struct.download_badge.text.needs_repair" = "복구 필요"; +"struct.download_badge.progress" = "%d/%d"; + +// MARK: DownloadStore +"download_store.error.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "다운로드 폴더를 확인할 수 없습니다."; +"download_store.validation.download_folder_missing" = "다운로드 폴더가 없습니다."; +"download_store.validation.manifest_missing" = "매니페스트 파일이 없습니다."; +"download_store.validation.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; +"download_store.validation.downloaded_pages_incomplete" = "다운로드한 페이지가 불완전합니다."; +"download_store.validation.cover_image_missing" = "표지 이미지가 없습니다."; +"download_store.validation.page_missing" = "페이지 %d가 없습니다."; +"download_store.validation.cover_image_corrupted" = "표지 이미지 데이터가 손상되었습니다."; +"download_store.validation.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; + // MARK: FiltersView "filters_view.title.filters" = "필터"; "filters_view.title.advanced_settings" = "고급 설정"; @@ -929,107 +1054,3 @@ "enum.browsing_country.name.yemen" = "예멘"; "enum.browsing_country.name.zambia" = "잠비아"; "enum.browsing_country.name.zimbabwe" = "짐바브웨"; - -// MARK: Download Localization Additions -"common.button.cancel" = "취소"; -"tab_item.title.downloads" = "다운로드"; -"app_error.localized_description.database_corrupted" = "데이터베이스 손상"; -"app_error.localized_description.copyright_claim" = "저작권 신고"; -"app_error.localized_description.ip_banned" = "IP 차단됨"; -"app_error.localized_description.gallery_expunged" = "갤러리 삭제됨"; -"app_error.localized_description.network_error" = "네트워크 오류"; -"app_error.localized_description.web_image_loading_error" = "웹 이미지 로드 오류"; -"app_error.localized_description.parse_error" = "파싱 오류"; -"app_error.localized_description.quota_exceeded" = "할당량 초과"; -"app_error.localized_description.authentication_required" = "인증 필요"; -"app_error.localized_description.file_operation_failed" = "파일 작업 실패"; -"app_error.localized_description.no_updates_available" = "사용 가능한 업데이트 없음"; -"app_error.localized_description.not_found" = "찾을 수 없음"; -"app_error.localized_description.unknown_error" = "알 수 없는 오류"; -"app_error.alert.quota_exceeded" = "이미지 할당량을 모두 사용했습니다.\n잠시 후 다시 시도해 주세요."; -"app_error.alert.authentication_required" = "이 다운로드에 접근하려면 로그인해야 합니다."; -"app_error.alert.local_file_operation_failed" = "로컬 파일 작업에 실패했습니다."; -"detail_view.accessibility.download_button.pause_action" = "다운로드 일시 정지"; -"detail_view.accessibility.download_button.paused" = "다운로드 다시 시작. %d / %d 페이지에서 일시 정지됨"; -"detail_view.accessibility.download_button.partial" = "다운로드 다시 시도. 이미 %d / %d 페이지를 사용할 수 있습니다."; -"detail_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; -"detail_view.dialog.title.repair_download" = "다운로드를 복구할까요?"; -"detail_view.dialog.title.update_download" = "다운로드를 업데이트할까요?"; -"detail_view.dialog.title.redownload_gallery" = "갤러리를 다시 다운로드할까요?"; -"detail_view.dialog.message.delete_active_download" = "현재 다운로드를 중지하고 이 기기에서 갤러리를 삭제합니다."; -"detail_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; -"detail_view.dialog.message.repair_download" = "이 갤러리의 오프라인 파일을 지금 복구할까요?"; -"detail_view.dialog.message.update_download" = "이 갤러리를 지금 온라인 최신 버전으로 업데이트할까요?"; -"detail_view.dialog.message.redownload_gallery" = "이 갤러리를 지금 처음부터 다시 다운로드할까요?"; -"detail_view.dialog.button.repair" = "복구"; -"detail_view.dialog.button.update" = "업데이트"; -"detail_view.dialog.button.redownload" = "다시 다운로드"; -"detail_view.offline_notice.saved_details" = "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다."; -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "다운로드"; -"downloads_view.search.prompt.downloads" = "다운로드 검색"; -"downloads_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; -"downloads_view.dialog.message.delete_active_download" = "현재 다운로드를 취소하고 이 기기에서 삭제합니다."; -"downloads_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; -"downloads_view.swipe.button.pages" = "페이지"; -"downloads_view.swipe.button.update" = "업데이트"; -"downloads_view.swipe.button.resume" = "재개"; -"downloads_view.swipe.button.pause" = "일시 정지"; -"downloads_view.empty_state.downloads" = "다운로드한 갤러리가 여기에 표시됩니다."; -"downloads_view.empty_state.no_matching_filters" = "현재 필터와 일치하는 다운로드가 없습니다."; -"downloads_view.button.clear_filters" = "필터 지우기"; -"downloads_view.button.validate_image_data" = "이미지 데이터 검증"; -"downloads_view.inspector.section.actions" = "동작"; -"downloads_view.inspector.section.pages" = "페이지"; -"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도"; -"downloads_view.inspector.button.validating_image_data" = "이미지 데이터 검증 중..."; -"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; -"downloads_view.inspector.hud.image_data_valid" = "이미지 데이터가 유효합니다"; -"downloads_view.inspector.hud.image_data_unavailable" = "이미지 데이터를 검증할 수 없습니다."; -"downloads_view.inspector.title.download_status" = "다운로드 상태"; -"downloads_view.inspector.page.pending" = "대기 중"; -"downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; -"downloads_view.inspector.page.title" = "페이지 %d"; -"downloads_view.inspector.page.none" = "페이지 없음"; -"downloads_view.inspector.status.pending" = "대기 중"; -"downloads_view.inspector.status.downloaded" = "다운로드됨"; -"downloads_view.inspector.status.failed" = "실패"; -"download_setting_view.title" = "다운로드"; -"download_setting_view.section.title.download_queue" = "다운로드 대기열"; -"download_setting_view.section.title.network" = "네트워크"; -"download_setting_view.title.concurrent_image_downloads" = "동시 이미지 다운로드 수"; -"download_setting_view.title.retry_failed_pages_automatically" = "실패한 페이지 자동 재시도"; -"download_setting_view.title.allow_cellular_downloads" = "셀룰러 다운로드 허용"; -"download_setting_view.footer.network" = "한 번에 하나의 갤러리만 다운로드됩니다. 이 설정으로 한 갤러리 안에서 동시에 다운로드할 페이지 수, 셀룰러 다운로드 허용 여부, 그리고 파일을 앱의 Downloads 폴더에 저장하는 방식을 제어합니다."; -"struct.download_badge.text.queued" = "대기 중"; -"struct.download_badge.text.downloading" = "다운로드 중"; -"struct.download_badge.text.paused" = "일시 정지"; -"struct.download_badge.text.downloaded" = "다운로드됨"; -"struct.download_badge.text.needs_attention" = "조치 필요"; -"struct.download_badge.text.update_available" = "업데이트 가능"; -"struct.download_badge.text.needs_repair" = "복구 필요"; -"struct.download_badge.progress" = "%d/%d"; -"download_store.error.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "다운로드 폴더를 확인할 수 없습니다."; -"download_store.validation.download_folder_missing" = "다운로드 폴더가 없습니다."; -"download_store.validation.manifest_missing" = "매니페스트 파일이 없습니다."; -"download_store.validation.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; -"download_store.validation.downloaded_pages_incomplete" = "다운로드한 페이지가 불완전합니다."; -"download_store.validation.cover_image_missing" = "표지 이미지가 없습니다."; -"download_store.validation.page_missing" = "페이지 %d가 없습니다."; -"download_store.validation.cover_image_corrupted" = "표지 이미지 데이터가 손상되었습니다."; -"download_store.validation.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 25f397a85..c194074cf 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings EhPanda */ @@ -217,7 +217,7 @@ "general_setting_view.title.redirects_links_to_the_selected_host" = "重定向链接到选定的站点"; "general_setting_view.title.detects_links_from_clipboard" = "从剪切板检测链接"; "general_setting_view.title.background_blur_radius" = "后台模糊效果"; -"general_setting_view.button.logs" = "日志"; +"general_setting_view.button.app_activity_logs" = "应用活动日志"; "general_setting_view.button.import_custom_translations" = "导入自定义翻译"; "general_setting_view.button.remove_custom_translations" = "移除自定义翻译"; "general_setting_view.button.clear_image_caches" = "清空图片缓存"; @@ -230,9 +230,20 @@ "enum.auto_lock_policy.value.never" = "不锁定"; "enum.auto_lock_policy.value.instantly" = "立即"; -// MARK: LogsView -"logs_view.title.logs" = "日志"; -"logs_view.title.latest" = "最新"; +// MARK: AppActivityLogsView +"app_activity_logs_view.title" = "应用活动日志"; +"app_activity_logs_view.placeholder.no_logs" = "未找到日志"; +"app_activity_logs_view.section.current" = "当前"; +"app_activity_logs_view.run" = "运行 %@"; +"app_activity_logs_view.more_logs" = "更多日志"; +"app_activity_logs_view.open_in_files" = "在“文件”中打开"; +"app_activity_logs_view.runs" = "运行"; +"app_activity_logs_view.level.undefined" = "未定义"; +"app_activity_logs_view.level.debug" = "调试"; +"app_activity_logs_view.level.info" = "信息"; +"app_activity_logs_view.level.notice" = "通知"; +"app_activity_logs_view.level.error" = "错误"; +"app_activity_logs_view.level.fault" = "故障"; // MARK: AppearanceSettingView "appearance_setting_view.title.appearance" = "外观"; @@ -1043,6 +1054,3 @@ "enum.browsing_country.name.yemen" = "也门"; "enum.browsing_country.name.zambia" = "赞比亚"; "enum.browsing_country.name.zimbabwe" = "津巴布韦"; - -// MARK: AppActivityLogsView -"app_activity_logs_view.run" = "运行 %@"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings index f6a97bf3d..e6e7475a4 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings EhPanda */ @@ -42,10 +42,14 @@ "common.value.seconds" = "%@ 秒"; "common.value.records" = "%@ 筆紀錄"; +// MARK: Common button +"common.button.cancel" = "取消"; + // MARK: TabItem "tab_item.title.home" = "總覽"; "tab_item.title.favorites" = "收藏"; "tab_item.title.search" = "搜尋"; +"tab_item.title.downloads" = "下載"; "tab_item.title.setting" = "設定"; // MARK: ToolbarItem @@ -81,6 +85,24 @@ "error_view.title.copyright_claim" = "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取"; "error_view.title.gallery_unavailable" = "此畫廊已被刪除或你沒有權限存取"; +// MARK: AppError +"app_error.localized_description.database_corrupted" = "資料庫損壞"; +"app_error.localized_description.copyright_claim" = "版權聲明"; +"app_error.localized_description.ip_banned" = "IP 已封禁"; +"app_error.localized_description.gallery_expunged" = "畫廊已刪除"; +"app_error.localized_description.network_error" = "網絡錯誤"; +"app_error.localized_description.web_image_loading_error" = "網頁圖片載入錯誤"; +"app_error.localized_description.parse_error" = "解析錯誤"; +"app_error.localized_description.quota_exceeded" = "流量額度已用盡"; +"app_error.localized_description.authentication_required" = "需要登入"; +"app_error.localized_description.file_operation_failed" = "檔案操作失敗"; +"app_error.localized_description.no_updates_available" = "沒有可用更新"; +"app_error.localized_description.not_found" = "未找到"; +"app_error.localized_description.unknown_error" = "未知錯誤"; +"app_error.alert.quota_exceeded" = "圖片流量額度已用盡。\n請稍後再試。"; +"app_error.alert.authentication_required" = "存取此下載內容需要登入。"; +"app_error.alert.local_file_operation_failed" = "本機檔案操作失敗。"; + // MARK: ConfirmationDialog "confirmation_dialog.title.drop_database" = "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?"; "confirmation_dialog.title.remove_custom_translations" = "是否確定要刪除所有自訂翻譯?"; @@ -195,7 +217,7 @@ "general_setting_view.title.redirects_links_to_the_selected_host" = "將連結重新導向至選擇的網站"; "general_setting_view.title.detects_links_from_clipboard" = "偵測剪貼簿中的連結"; "general_setting_view.title.background_blur_radius" = "後台背景模糊"; -"general_setting_view.button.logs" = "日誌"; +"general_setting_view.button.app_activity_logs" = "應用程式活動日誌"; "general_setting_view.button.import_custom_translations" = "匯入自訂標籤翻譯"; "general_setting_view.button.remove_custom_translations" = "刪除自訂標籤翻譯"; "general_setting_view.button.clear_image_caches" = "清理圖片快取"; @@ -208,9 +230,20 @@ "enum.auto_lock_policy.value.never" = "永不自動鎖定"; "enum.auto_lock_policy.value.instantly" = "立刻"; -// MARK: LogsView -"logs_view.title.logs" = "日誌"; -"logs_view.title.latest" = "最新"; +// MARK: AppActivityLogsView +"app_activity_logs_view.title" = "應用程式活動日誌"; +"app_activity_logs_view.placeholder.no_logs" = "找不到日誌"; +"app_activity_logs_view.section.current" = "目前"; +"app_activity_logs_view.run" = "運行 %@"; +"app_activity_logs_view.more_logs" = "更多日誌"; +"app_activity_logs_view.open_in_files" = "在「檔案」中開啟"; +"app_activity_logs_view.runs" = "運行"; +"app_activity_logs_view.level.undefined" = "未定義"; +"app_activity_logs_view.level.debug" = "偵錯"; +"app_activity_logs_view.level.info" = "資訊"; +"app_activity_logs_view.level.notice" = "通知"; +"app_activity_logs_view.level.error" = "錯誤"; +"app_activity_logs_view.level.fault" = "故障"; // MARK: AppearanceSettingView "appearance_setting_view.title.appearance" = "外觀設定"; @@ -288,6 +321,9 @@ "detail_view.accessibility.download_button.retry" = "重新下載"; "detail_view.accessibility.download_button.repair" = "修復下載檔案"; "detail_view.accessibility.download_button.preparing" = "正在取得下載資訊"; +"detail_view.accessibility.download_button.pause_action" = "暫停下載"; +"detail_view.accessibility.download_button.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; +"detail_view.accessibility.download_button.partial" = "重新下載,已有 %d / %d 頁可用。"; "detail_view.toolbar_item.button.archives" = "存檔至 H@H 用戶端"; "detail_view.toolbar_item.button.torrents" = "種子"; "detail_view.toolbar_item.button.share" = "分享"; @@ -306,6 +342,19 @@ "detail_view.action_section.button.similar_gallery" = "類似畫廊"; "detail_view.section.title.previews" = "預覽"; "detail_view.section.title.comments" = "留言"; +"detail_view.dialog.title.delete_download" = "刪除下載?"; +"detail_view.dialog.title.repair_download" = "修復下載?"; +"detail_view.dialog.title.update_download" = "更新下載?"; +"detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; +"detail_view.dialog.message.delete_active_download" = "這將停止目前下載並從此裝置移除此畫廊。"; +"detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; +"detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; +"detail_view.dialog.message.redownload_gallery" = "現在重新完整下載此畫廊嗎?"; +"detail_view.dialog.button.repair" = "修復"; +"detail_view.dialog.button.update" = "更新"; +"detail_view.dialog.button.redownload" = "重新下載"; +"detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; // MARK: ArchivesView "archives_view.title.archives" = "存檔"; @@ -355,12 +404,63 @@ "tag_detail_view.section.title.images" = "圖片"; "tag_detail_view.section.title.links" = "連結"; +// MARK: DownloadsView +"enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; +"detail_view.menu.text.no_folders" = "No folders yet"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; +"downloads_view.title.downloads" = "下載"; +"downloads_view.search.prompt.downloads" = "搜尋下載"; +"downloads_view.dialog.title.delete_download" = "刪除下載?"; +"downloads_view.dialog.message.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; +"downloads_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"downloads_view.swipe.button.pages" = "頁面"; +"downloads_view.swipe.button.update" = "更新"; +"downloads_view.swipe.button.resume" = "繼續"; +"downloads_view.swipe.button.pause" = "暫停"; +"downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; +"downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; +"downloads_view.button.clear_filters" = "清除篩選"; +"downloads_view.button.validate_image_data" = "驗證圖片資料"; +"downloads_view.inspector.section.actions" = "操作"; +"downloads_view.inspector.section.pages" = "頁面"; +"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; +"downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; +"downloads_view.inspector.button.update_download" = "更新下載"; +"downloads_view.inspector.hud.image_data_valid" = "圖片資料有效"; +"downloads_view.inspector.hud.image_data_unavailable" = "無法驗證圖片資料。"; +"downloads_view.inspector.title.download_status" = "下載狀態"; +"downloads_view.inspector.page.pending" = "等待中"; +"downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; +"downloads_view.inspector.page.title" = "第 %d 頁"; +"downloads_view.inspector.page.none" = "沒有頁面"; +"downloads_view.inspector.status.pending" = "等待中"; +"downloads_view.inspector.status.downloaded" = "已下載"; +"downloads_view.inspector.status.failed" = "失敗"; + +// MARK: DownloadSettingView +"download_setting_view.title" = "下載"; +"download_setting_view.section.title.download_queue" = "下載佇列"; +"download_setting_view.section.title.network" = "網絡"; +"download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; +"download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; +"download_setting_view.title.allow_cellular_downloads" = "允許流動網絡下載"; +"download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許流動網絡下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; + // MARK: CommentsView "comments_view.title.comments" = "留言"; // MARK: PostCommentView "post_comment_view.title.post_comment" = "發表留言"; "post_comment_view.title.edit_comment" = "編輯留言"; + // MARK: PreviewsView "previews_view.title.previews" = "預覽"; @@ -379,6 +479,33 @@ // AutoPlayPolicy "enum.auto_play_policy.value.off" = "關閉"; + +// MARK: DownloadBadge +"struct.download_badge.text.queued" = "已排隊"; +"struct.download_badge.text.downloading" = "下載中"; +"struct.download_badge.text.paused" = "已暫停"; +"struct.download_badge.text.downloaded" = "已下載"; +"struct.download_badge.text.needs_attention" = "需處理"; +"struct.download_badge.text.update_available" = "有可更新"; +"struct.download_badge.text.needs_repair" = "需修復"; +"struct.download_badge.progress" = "%d/%d"; + +// MARK: DownloadStore +"download_store.error.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "無法解析下載資料夾。"; +"download_store.validation.download_folder_missing" = "下載資料夾缺失。"; +"download_store.validation.manifest_missing" = "Manifest 檔案缺失。"; +"download_store.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; +"download_store.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; +"download_store.validation.cover_image_missing" = "封面圖片缺失。"; +"download_store.validation.page_missing" = "第 %d 頁缺失。"; +"download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; +"download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; + // MARK: FiltersView "filters_view.title.filters" = "過濾"; "filters_view.title.advanced_settings" = "進階選項"; @@ -927,108 +1054,3 @@ "enum.browsing_country.name.yemen" = "也門"; "enum.browsing_country.name.zambia" = "贊比亞"; "enum.browsing_country.name.zimbabwe" = "津巴布韋"; -"common.button.cancel" = "取消"; -"tab_item.title.downloads" = "下載"; -"app_error.localized_description.database_corrupted" = "資料庫損壞"; -"app_error.localized_description.copyright_claim" = "版權聲明"; -"app_error.localized_description.ip_banned" = "IP 已封禁"; -"app_error.localized_description.gallery_expunged" = "畫廊已刪除"; -"app_error.localized_description.network_error" = "網絡錯誤"; -"app_error.localized_description.web_image_loading_error" = "網頁圖片載入錯誤"; -"app_error.localized_description.parse_error" = "解析錯誤"; -"app_error.localized_description.quota_exceeded" = "流量額度已用盡"; -"app_error.localized_description.authentication_required" = "需要登入"; -"app_error.localized_description.file_operation_failed" = "檔案操作失敗"; -"app_error.localized_description.no_updates_available" = "沒有可用更新"; -"app_error.localized_description.not_found" = "未找到"; -"app_error.localized_description.unknown_error" = "未知錯誤"; -"app_error.alert.quota_exceeded" = "圖片流量額度已用盡。\n請稍後再試。"; -"app_error.alert.authentication_required" = "存取此下載內容需要登入。"; -"app_error.alert.local_file_operation_failed" = "本機檔案操作失敗。"; -"detail_view.accessibility.download_button.pause_action" = "暫停下載"; -"detail_view.accessibility.download_button.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; -"detail_view.accessibility.download_button.partial" = "重新下載,已有 %d / %d 頁可用。"; -"detail_view.dialog.title.delete_download" = "刪除下載?"; -"detail_view.dialog.title.repair_download" = "修復下載?"; -"detail_view.dialog.title.update_download" = "更新下載?"; -"detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; -"detail_view.dialog.message.delete_active_download" = "這將停止目前下載並從此裝置移除此畫廊。"; -"detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; -"detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; -"detail_view.dialog.message.redownload_gallery" = "現在重新完整下載此畫廊嗎?"; -"detail_view.dialog.button.repair" = "修復"; -"detail_view.dialog.button.update" = "更新"; -"detail_view.dialog.button.redownload" = "重新下載"; -"detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "下載"; -"downloads_view.search.prompt.downloads" = "搜尋下載"; -"downloads_view.dialog.title.delete_download" = "刪除下載?"; -"downloads_view.dialog.message.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; -"downloads_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"downloads_view.swipe.button.pages" = "頁面"; -"downloads_view.swipe.button.update" = "更新"; -"downloads_view.swipe.button.resume" = "繼續"; -"downloads_view.swipe.button.pause" = "暫停"; -"downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; -"downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; -"downloads_view.button.clear_filters" = "清除篩選"; -"downloads_view.button.validate_image_data" = "驗證圖片資料"; -"downloads_view.inspector.section.actions" = "操作"; -"downloads_view.inspector.section.pages" = "頁面"; -"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; -"downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; -"downloads_view.inspector.button.update_download" = "更新下載"; -"downloads_view.inspector.hud.image_data_valid" = "圖片資料有效"; -"downloads_view.inspector.hud.image_data_unavailable" = "無法驗證圖片資料。"; -"downloads_view.inspector.title.download_status" = "下載狀態"; -"downloads_view.inspector.page.pending" = "等待中"; -"downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; -"downloads_view.inspector.page.title" = "第 %d 頁"; -"downloads_view.inspector.page.none" = "沒有頁面"; -"downloads_view.inspector.status.pending" = "等待中"; -"downloads_view.inspector.status.downloaded" = "已下載"; -"downloads_view.inspector.status.failed" = "失敗"; -"download_setting_view.title" = "下載"; -"download_setting_view.section.title.download_queue" = "下載佇列"; -"download_setting_view.section.title.network" = "網絡"; -"download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; -"download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; -"download_setting_view.title.allow_cellular_downloads" = "允許流動網絡下載"; -"download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許流動網絡下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; -"struct.download_badge.text.queued" = "已排隊"; -"struct.download_badge.text.downloading" = "下載中"; -"struct.download_badge.text.paused" = "已暫停"; -"struct.download_badge.text.downloaded" = "已下載"; -"struct.download_badge.text.needs_attention" = "需處理"; -"struct.download_badge.text.update_available" = "有可更新"; -"struct.download_badge.text.needs_repair" = "需修復"; -"struct.download_badge.progress" = "%d/%d"; -"download_store.error.asset_unreadable" = "資源檔案無法讀取:%@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "無法解析下載資料夾。"; -"download_store.validation.download_folder_missing" = "下載資料夾缺失。"; -"download_store.validation.manifest_missing" = "Manifest 檔案缺失。"; -"download_store.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; -"download_store.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; -"download_store.validation.cover_image_missing" = "封面圖片缺失。"; -"download_store.validation.page_missing" = "第 %d 頁缺失。"; -"download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; -"download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; - -// MARK: AppActivityLogsView -"app_activity_logs_view.run" = "運行 %@"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings index b9d71f10d..3ba1284f8 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings @@ -42,10 +42,14 @@ "common.value.seconds" = "%@ 秒"; "common.value.records" = "%@ 筆紀錄"; +// MARK: Common button +"common.button.cancel" = "取消"; + // MARK: TabItem "tab_item.title.home" = "總覽"; "tab_item.title.favorites" = "收藏"; "tab_item.title.search" = "搜尋"; +"tab_item.title.downloads" = "下載"; "tab_item.title.setting" = "設定"; // MARK: ToolbarItem @@ -81,6 +85,24 @@ "error_view.title.copyright_claim" = "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取"; "error_view.title.gallery_unavailable" = "此畫廊已被刪除或你沒有權限存取"; +// MARK: AppError +"app_error.localized_description.database_corrupted" = "資料庫損壞"; +"app_error.localized_description.copyright_claim" = "版權聲明"; +"app_error.localized_description.ip_banned" = "IP 已封禁"; +"app_error.localized_description.gallery_expunged" = "畫廊已刪除"; +"app_error.localized_description.network_error" = "網路錯誤"; +"app_error.localized_description.web_image_loading_error" = "網頁圖片載入錯誤"; +"app_error.localized_description.parse_error" = "解析錯誤"; +"app_error.localized_description.quota_exceeded" = "流量額度已用盡"; +"app_error.localized_description.authentication_required" = "需要登入"; +"app_error.localized_description.file_operation_failed" = "檔案操作失敗"; +"app_error.localized_description.no_updates_available" = "沒有可用更新"; +"app_error.localized_description.not_found" = "未找到"; +"app_error.localized_description.unknown_error" = "未知錯誤"; +"app_error.alert.quota_exceeded" = "圖片流量額度已用盡。\n請稍後再試。"; +"app_error.alert.authentication_required" = "存取此下載內容需要登入。"; +"app_error.alert.local_file_operation_failed" = "本機檔案操作失敗。"; + // MARK: ConfirmationDialog "confirmation_dialog.title.drop_database" = "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?"; "confirmation_dialog.title.remove_custom_translations" = "是否確定要刪除所有自訂翻譯?"; @@ -195,7 +217,7 @@ "general_setting_view.title.redirects_links_to_the_selected_host" = "將連結重新導向至選擇的網站"; "general_setting_view.title.detects_links_from_clipboard" = "偵測剪貼簿中的連結"; "general_setting_view.title.background_blur_radius" = "後台背景模糊"; -"general_setting_view.button.logs" = "日誌"; +"general_setting_view.button.app_activity_logs" = "應用程式活動日誌"; "general_setting_view.button.import_custom_translations" = "匯入自訂標籤翻譯"; "general_setting_view.button.remove_custom_translations" = "刪除自訂標籤翻譯"; "general_setting_view.button.clear_image_caches" = "清理圖片快取"; @@ -208,9 +230,20 @@ "enum.auto_lock_policy.value.never" = "永不自動鎖定"; "enum.auto_lock_policy.value.instantly" = "立刻"; -// MARK: LogsView -"logs_view.title.logs" = "日誌"; -"logs_view.title.latest" = "最新"; +// MARK: AppActivityLogsView +"app_activity_logs_view.title" = "應用程式活動日誌"; +"app_activity_logs_view.placeholder.no_logs" = "找不到日誌"; +"app_activity_logs_view.section.current" = "目前"; +"app_activity_logs_view.run" = "運行 %@"; +"app_activity_logs_view.more_logs" = "更多日誌"; +"app_activity_logs_view.open_in_files" = "在「檔案」中開啟"; +"app_activity_logs_view.runs" = "運行"; +"app_activity_logs_view.level.undefined" = "未定義"; +"app_activity_logs_view.level.debug" = "偵錯"; +"app_activity_logs_view.level.info" = "資訊"; +"app_activity_logs_view.level.notice" = "通知"; +"app_activity_logs_view.level.error" = "錯誤"; +"app_activity_logs_view.level.fault" = "故障"; // MARK: AppearanceSettingView "appearance_setting_view.title.appearance" = "外觀設定"; @@ -288,6 +321,9 @@ "detail_view.accessibility.download_button.retry" = "重新下載"; "detail_view.accessibility.download_button.repair" = "修復下載檔案"; "detail_view.accessibility.download_button.preparing" = "正在取得下載資訊"; +"detail_view.accessibility.download_button.pause_action" = "暫停下載"; +"detail_view.accessibility.download_button.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; +"detail_view.accessibility.download_button.partial" = "重新下載,已有 %d / %d 頁可用。"; "detail_view.toolbar_item.button.archives" = "存檔至 H@H 用戶端"; "detail_view.toolbar_item.button.torrents" = "種子"; "detail_view.toolbar_item.button.share" = "分享"; @@ -306,6 +342,19 @@ "detail_view.action_section.button.similar_gallery" = "類似畫廊"; "detail_view.section.title.previews" = "預覽"; "detail_view.section.title.comments" = "留言"; +"detail_view.dialog.title.delete_download" = "刪除下載?"; +"detail_view.dialog.title.repair_download" = "修復下載?"; +"detail_view.dialog.title.update_download" = "更新下載?"; +"detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; +"detail_view.dialog.message.delete_active_download" = "這將停止目前下載並從此裝置移除此畫廊。"; +"detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; +"detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; +"detail_view.dialog.message.redownload_gallery" = "現在重新完整下載此畫廊嗎?"; +"detail_view.dialog.button.repair" = "修復"; +"detail_view.dialog.button.update" = "更新"; +"detail_view.dialog.button.redownload" = "重新下載"; +"detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; // MARK: ArchivesView "archives_view.title.archives" = "存檔"; @@ -355,6 +404,56 @@ "tag_detail_view.section.title.images" = "圖片"; "tag_detail_view.section.title.links" = "連結"; +// MARK: DownloadsView +"enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; +"detail_view.menu.text.no_folders" = "No folders yet"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; +"downloads_view.title.downloads" = "下載"; +"downloads_view.search.prompt.downloads" = "搜尋下載"; +"downloads_view.dialog.title.delete_download" = "刪除下載?"; +"downloads_view.dialog.message.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; +"downloads_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"downloads_view.swipe.button.pages" = "頁面"; +"downloads_view.swipe.button.update" = "更新"; +"downloads_view.swipe.button.resume" = "繼續"; +"downloads_view.swipe.button.pause" = "暫停"; +"downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; +"downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; +"downloads_view.button.clear_filters" = "清除篩選"; +"downloads_view.button.validate_image_data" = "驗證圖片資料"; +"downloads_view.inspector.section.actions" = "操作"; +"downloads_view.inspector.section.pages" = "頁面"; +"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; +"downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; +"downloads_view.inspector.button.update_download" = "更新下載"; +"downloads_view.inspector.hud.image_data_valid" = "圖片資料有效"; +"downloads_view.inspector.hud.image_data_unavailable" = "無法驗證圖片資料。"; +"downloads_view.inspector.title.download_status" = "下載狀態"; +"downloads_view.inspector.page.pending" = "等待中"; +"downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; +"downloads_view.inspector.page.title" = "第 %d 頁"; +"downloads_view.inspector.page.none" = "沒有頁面"; +"downloads_view.inspector.status.pending" = "等待中"; +"downloads_view.inspector.status.downloaded" = "已下載"; +"downloads_view.inspector.status.failed" = "失敗"; + +// MARK: DownloadSettingView +"download_setting_view.title" = "下載"; +"download_setting_view.section.title.download_queue" = "下載佇列"; +"download_setting_view.section.title.network" = "網路"; +"download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; +"download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; +"download_setting_view.title.allow_cellular_downloads" = "允許行動網路下載"; +"download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許行動網路下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; + // MARK: CommentsView "comments_view.title.comments" = "留言"; @@ -380,6 +479,33 @@ // AutoPlayPolicy "enum.auto_play_policy.value.off" = "關閉"; + +// MARK: DownloadBadge +"struct.download_badge.text.queued" = "已排隊"; +"struct.download_badge.text.downloading" = "下載中"; +"struct.download_badge.text.paused" = "已暫停"; +"struct.download_badge.text.downloaded" = "已下載"; +"struct.download_badge.text.needs_attention" = "需處理"; +"struct.download_badge.text.update_available" = "有可更新"; +"struct.download_badge.text.needs_repair" = "需修復"; +"struct.download_badge.progress" = "%d/%d"; + +// MARK: DownloadStore +"download_store.error.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "無法解析下載資料夾。"; +"download_store.validation.download_folder_missing" = "下載資料夾缺失。"; +"download_store.validation.manifest_missing" = "Manifest 檔案缺失。"; +"download_store.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; +"download_store.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; +"download_store.validation.cover_image_missing" = "封面圖片缺失。"; +"download_store.validation.page_missing" = "第 %d 頁缺失。"; +"download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; +"download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; + // MARK: FiltersView "filters_view.title.filters" = "過濾"; "filters_view.title.advanced_settings" = "進階選項"; @@ -928,108 +1054,3 @@ "enum.browsing_country.name.yemen" = "葉門"; "enum.browsing_country.name.zambia" = "尚比亞"; "enum.browsing_country.name.zimbabwe" = "辛巴威"; -"common.button.cancel" = "取消"; -"tab_item.title.downloads" = "下載"; -"app_error.localized_description.database_corrupted" = "資料庫損壞"; -"app_error.localized_description.copyright_claim" = "版權聲明"; -"app_error.localized_description.ip_banned" = "IP 已封禁"; -"app_error.localized_description.gallery_expunged" = "畫廊已刪除"; -"app_error.localized_description.network_error" = "網路錯誤"; -"app_error.localized_description.web_image_loading_error" = "網頁圖片載入錯誤"; -"app_error.localized_description.parse_error" = "解析錯誤"; -"app_error.localized_description.quota_exceeded" = "流量額度已用盡"; -"app_error.localized_description.authentication_required" = "需要登入"; -"app_error.localized_description.file_operation_failed" = "檔案操作失敗"; -"app_error.localized_description.no_updates_available" = "沒有可用更新"; -"app_error.localized_description.not_found" = "未找到"; -"app_error.localized_description.unknown_error" = "未知錯誤"; -"app_error.alert.quota_exceeded" = "圖片流量額度已用盡。\n請稍後再試。"; -"app_error.alert.authentication_required" = "存取此下載內容需要登入。"; -"app_error.alert.local_file_operation_failed" = "本機檔案操作失敗。"; -"detail_view.accessibility.download_button.pause_action" = "暫停下載"; -"detail_view.accessibility.download_button.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; -"detail_view.accessibility.download_button.partial" = "重新下載,已有 %d / %d 頁可用。"; -"detail_view.dialog.title.delete_download" = "刪除下載?"; -"detail_view.dialog.title.repair_download" = "修復下載?"; -"detail_view.dialog.title.update_download" = "更新下載?"; -"detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; -"detail_view.dialog.message.delete_active_download" = "這將停止目前下載並從此裝置移除此畫廊。"; -"detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; -"detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; -"detail_view.dialog.message.redownload_gallery" = "現在重新完整下載此畫廊嗎?"; -"detail_view.dialog.button.repair" = "修復"; -"detail_view.dialog.button.update" = "更新"; -"detail_view.dialog.button.redownload" = "重新下載"; -"detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "下載"; -"downloads_view.search.prompt.downloads" = "搜尋下載"; -"downloads_view.dialog.title.delete_download" = "刪除下載?"; -"downloads_view.dialog.message.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; -"downloads_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"downloads_view.swipe.button.pages" = "頁面"; -"downloads_view.swipe.button.update" = "更新"; -"downloads_view.swipe.button.resume" = "繼續"; -"downloads_view.swipe.button.pause" = "暫停"; -"downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; -"downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; -"downloads_view.button.clear_filters" = "清除篩選"; -"downloads_view.button.validate_image_data" = "驗證圖片資料"; -"downloads_view.inspector.section.actions" = "操作"; -"downloads_view.inspector.section.pages" = "頁面"; -"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; -"downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; -"downloads_view.inspector.button.update_download" = "更新下載"; -"downloads_view.inspector.hud.image_data_valid" = "圖片資料有效"; -"downloads_view.inspector.hud.image_data_unavailable" = "無法驗證圖片資料。"; -"downloads_view.inspector.title.download_status" = "下載狀態"; -"downloads_view.inspector.page.pending" = "等待中"; -"downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; -"downloads_view.inspector.page.title" = "第 %d 頁"; -"downloads_view.inspector.page.none" = "沒有頁面"; -"downloads_view.inspector.status.pending" = "等待中"; -"downloads_view.inspector.status.downloaded" = "已下載"; -"downloads_view.inspector.status.failed" = "失敗"; -"download_setting_view.title" = "下載"; -"download_setting_view.section.title.download_queue" = "下載佇列"; -"download_setting_view.section.title.network" = "網路"; -"download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; -"download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; -"download_setting_view.title.allow_cellular_downloads" = "允許行動網路下載"; -"download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許行動網路下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; -"struct.download_badge.text.queued" = "已排隊"; -"struct.download_badge.text.downloading" = "下載中"; -"struct.download_badge.text.paused" = "已暫停"; -"struct.download_badge.text.downloaded" = "已下載"; -"struct.download_badge.text.needs_attention" = "需處理"; -"struct.download_badge.text.update_available" = "有可更新"; -"struct.download_badge.text.needs_repair" = "需修復"; -"struct.download_badge.progress" = "%d/%d"; -"download_store.error.asset_unreadable" = "資源檔案無法讀取:%@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "無法解析下載資料夾。"; -"download_store.validation.download_folder_missing" = "下載資料夾缺失。"; -"download_store.validation.manifest_missing" = "Manifest 檔案缺失。"; -"download_store.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; -"download_store.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; -"download_store.validation.cover_image_missing" = "封面圖片缺失。"; -"download_store.validation.page_missing" = "第 %d 頁缺失。"; -"download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; -"download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; - -// MARK: AppActivityLogsView -"app_activity_logs_view.run" = "運行 %@"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 770f24462..6eec2e673 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings EhPanda */ @@ -42,10 +42,14 @@ "common.value.seconds" = "%@ 秒"; "common.value.records" = "%@ 筆紀錄"; +// MARK: Common button +"common.button.cancel" = "取消"; + // MARK: TabItem "tab_item.title.home" = "總覽"; "tab_item.title.favorites" = "收藏"; "tab_item.title.search" = "搜尋"; +"tab_item.title.downloads" = "下載"; "tab_item.title.setting" = "設定"; // MARK: ToolbarItem @@ -81,6 +85,24 @@ "error_view.title.copyright_claim" = "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取"; "error_view.title.gallery_unavailable" = "此畫廊已被刪除或你沒有權限存取"; +// MARK: AppError +"app_error.localized_description.database_corrupted" = "資料庫損壞"; +"app_error.localized_description.copyright_claim" = "版權聲明"; +"app_error.localized_description.ip_banned" = "IP 已封禁"; +"app_error.localized_description.gallery_expunged" = "畫廊已刪除"; +"app_error.localized_description.network_error" = "網絡錯誤"; +"app_error.localized_description.web_image_loading_error" = "網頁圖片載入錯誤"; +"app_error.localized_description.parse_error" = "解析錯誤"; +"app_error.localized_description.quota_exceeded" = "流量額度已用盡"; +"app_error.localized_description.authentication_required" = "需要登入"; +"app_error.localized_description.file_operation_failed" = "檔案操作失敗"; +"app_error.localized_description.no_updates_available" = "沒有可用更新"; +"app_error.localized_description.not_found" = "未找到"; +"app_error.localized_description.unknown_error" = "未知錯誤"; +"app_error.alert.quota_exceeded" = "圖片流量額度已用盡。\n請稍後再試。"; +"app_error.alert.authentication_required" = "存取此下載內容需要登入。"; +"app_error.alert.local_file_operation_failed" = "本機檔案操作失敗。"; + // MARK: ConfirmationDialog "confirmation_dialog.title.drop_database" = "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?"; "confirmation_dialog.title.remove_custom_translations" = "是否確定要刪除所有自訂翻譯?"; @@ -195,7 +217,7 @@ "general_setting_view.title.redirects_links_to_the_selected_host" = "將連結重新導向至選擇的網站"; "general_setting_view.title.detects_links_from_clipboard" = "偵測剪貼簿中的連結"; "general_setting_view.title.background_blur_radius" = "後台背景模糊"; -"general_setting_view.button.logs" = "日誌"; +"general_setting_view.button.app_activity_logs" = "應用程式活動日誌"; "general_setting_view.button.import_custom_translations" = "匯入自訂標籤翻譯"; "general_setting_view.button.remove_custom_translations" = "刪除自訂標籤翻譯"; "general_setting_view.button.clear_image_caches" = "清理圖片快取"; @@ -208,9 +230,20 @@ "enum.auto_lock_policy.value.never" = "永不自動鎖定"; "enum.auto_lock_policy.value.instantly" = "立刻"; -// MARK: LogsView -"logs_view.title.logs" = "日誌"; -"logs_view.title.latest" = "最新"; +// MARK: AppActivityLogsView +"app_activity_logs_view.title" = "應用程式活動日誌"; +"app_activity_logs_view.placeholder.no_logs" = "找不到日誌"; +"app_activity_logs_view.section.current" = "目前"; +"app_activity_logs_view.run" = "運行 %@"; +"app_activity_logs_view.more_logs" = "更多日誌"; +"app_activity_logs_view.open_in_files" = "在「檔案」中開啟"; +"app_activity_logs_view.runs" = "運行"; +"app_activity_logs_view.level.undefined" = "未定義"; +"app_activity_logs_view.level.debug" = "偵錯"; +"app_activity_logs_view.level.info" = "資訊"; +"app_activity_logs_view.level.notice" = "通知"; +"app_activity_logs_view.level.error" = "錯誤"; +"app_activity_logs_view.level.fault" = "故障"; // MARK: AppearanceSettingView "appearance_setting_view.title.appearance" = "外觀設定"; @@ -288,6 +321,9 @@ "detail_view.accessibility.download_button.retry" = "重新下載"; "detail_view.accessibility.download_button.repair" = "修復下載檔案"; "detail_view.accessibility.download_button.preparing" = "正在取得下載資訊"; +"detail_view.accessibility.download_button.pause_action" = "暫停下載"; +"detail_view.accessibility.download_button.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; +"detail_view.accessibility.download_button.partial" = "重新下載,已有 %d / %d 頁可用。"; "detail_view.toolbar_item.button.archives" = "存檔至 H@H 用戶端"; "detail_view.toolbar_item.button.torrents" = "種子"; "detail_view.toolbar_item.button.share" = "分享"; @@ -306,6 +342,19 @@ "detail_view.action_section.button.similar_gallery" = "類似畫廊"; "detail_view.section.title.previews" = "預覽"; "detail_view.section.title.comments" = "留言"; +"detail_view.dialog.title.delete_download" = "刪除下載?"; +"detail_view.dialog.title.repair_download" = "修復下載?"; +"detail_view.dialog.title.update_download" = "更新下載?"; +"detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; +"detail_view.dialog.message.delete_active_download" = "這將停止目前下載並從此裝置移除此畫廊。"; +"detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; +"detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; +"detail_view.dialog.message.redownload_gallery" = "現在重新完整下載此畫廊嗎?"; +"detail_view.dialog.button.repair" = "修復"; +"detail_view.dialog.button.update" = "更新"; +"detail_view.dialog.button.redownload" = "重新下載"; +"detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; // MARK: ArchivesView "archives_view.title.archives" = "存檔"; @@ -355,6 +404,56 @@ "tag_detail_view.section.title.images" = "圖片"; "tag_detail_view.section.title.links" = "連結"; +// MARK: DownloadsView +"enum.download_folder_filter.title.all" = "All"; +"detail_view.menu.button.manage_folders" = "Manage Folders"; +"detail_view.menu.button.create_default_folder" = "Create Default Folder"; +"detail_view.menu.text.no_folders" = "No folders yet"; +"downloads_view.menu.button.manage_folders" = "Manage Folders"; +"downloads_view.menu.button.move_to_folder" = "Move to Folder"; +"downloads_view.swipe.button.move" = "Move"; +"folder_manager_view.title.folders" = "Folders"; +"folder_manager_view.placeholder.folder_name" = "Folder name"; +"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; +"downloads_view.title.downloads" = "下載"; +"downloads_view.search.prompt.downloads" = "搜尋下載"; +"downloads_view.dialog.title.delete_download" = "刪除下載?"; +"downloads_view.dialog.message.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; +"downloads_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"downloads_view.swipe.button.pages" = "頁面"; +"downloads_view.swipe.button.update" = "更新"; +"downloads_view.swipe.button.resume" = "繼續"; +"downloads_view.swipe.button.pause" = "暫停"; +"downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; +"downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; +"downloads_view.button.clear_filters" = "清除篩選"; +"downloads_view.button.validate_image_data" = "驗證圖片資料"; +"downloads_view.inspector.section.actions" = "操作"; +"downloads_view.inspector.section.pages" = "頁面"; +"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; +"downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; +"downloads_view.inspector.button.update_download" = "更新下載"; +"downloads_view.inspector.hud.image_data_valid" = "圖片資料有效"; +"downloads_view.inspector.hud.image_data_unavailable" = "無法驗證圖片資料。"; +"downloads_view.inspector.title.download_status" = "下載狀態"; +"downloads_view.inspector.page.pending" = "等待中"; +"downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; +"downloads_view.inspector.page.title" = "第 %d 頁"; +"downloads_view.inspector.page.none" = "沒有頁面"; +"downloads_view.inspector.status.pending" = "等待中"; +"downloads_view.inspector.status.downloaded" = "已下載"; +"downloads_view.inspector.status.failed" = "失敗"; + +// MARK: DownloadSettingView +"download_setting_view.title" = "下載"; +"download_setting_view.section.title.download_queue" = "下載佇列"; +"download_setting_view.section.title.network" = "網絡"; +"download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; +"download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; +"download_setting_view.title.allow_cellular_downloads" = "允許流動網絡下載"; +"download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許流動網絡下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; + // MARK: CommentsView "comments_view.title.comments" = "留言"; @@ -380,6 +479,33 @@ // AutoPlayPolicy "enum.auto_play_policy.value.off" = "關閉"; + +// MARK: DownloadBadge +"struct.download_badge.text.queued" = "已排隊"; +"struct.download_badge.text.downloading" = "下載中"; +"struct.download_badge.text.paused" = "已暫停"; +"struct.download_badge.text.downloaded" = "已下載"; +"struct.download_badge.text.needs_attention" = "需處理"; +"struct.download_badge.text.update_available" = "有可更新"; +"struct.download_badge.text.needs_repair" = "需修復"; +"struct.download_badge.progress" = "%d/%d"; + +// MARK: DownloadStore +"download_store.error.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_store.error.invalid_folder_name" = "The folder name is invalid."; +"download_store.error.folder_already_exists" = "A folder with this name already exists."; +"download_store.error.folder_busy_downloading" = "The folder contains an active download."; +"download_store.error.download_busy" = "The download is currently active."; +"download_store.validation.download_folder_unresolved" = "無法解析下載資料夾。"; +"download_store.validation.download_folder_missing" = "下載資料夾缺失。"; +"download_store.validation.manifest_missing" = "Manifest 檔案缺失。"; +"download_store.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; +"download_store.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; +"download_store.validation.cover_image_missing" = "封面圖片缺失。"; +"download_store.validation.page_missing" = "第 %d 頁缺失。"; +"download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; +"download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; + // MARK: FiltersView "filters_view.title.filters" = "過濾"; "filters_view.title.advanced_settings" = "進階選項"; @@ -928,108 +1054,3 @@ "enum.browsing_country.name.yemen" = "葉門"; "enum.browsing_country.name.zambia" = "尚比亞"; "enum.browsing_country.name.zimbabwe" = "辛巴威"; -"common.button.cancel" = "取消"; -"tab_item.title.downloads" = "下載"; -"app_error.localized_description.database_corrupted" = "資料庫損壞"; -"app_error.localized_description.copyright_claim" = "版權聲明"; -"app_error.localized_description.ip_banned" = "IP 已封禁"; -"app_error.localized_description.gallery_expunged" = "畫廊已刪除"; -"app_error.localized_description.network_error" = "網絡錯誤"; -"app_error.localized_description.web_image_loading_error" = "網頁圖片載入錯誤"; -"app_error.localized_description.parse_error" = "解析錯誤"; -"app_error.localized_description.quota_exceeded" = "流量額度已用盡"; -"app_error.localized_description.authentication_required" = "需要登入"; -"app_error.localized_description.file_operation_failed" = "檔案操作失敗"; -"app_error.localized_description.no_updates_available" = "沒有可用更新"; -"app_error.localized_description.not_found" = "未找到"; -"app_error.localized_description.unknown_error" = "未知錯誤"; -"app_error.alert.quota_exceeded" = "圖片流量額度已用盡。\n請稍後再試。"; -"app_error.alert.authentication_required" = "存取此下載內容需要登入。"; -"app_error.alert.local_file_operation_failed" = "本機檔案操作失敗。"; -"detail_view.accessibility.download_button.pause_action" = "暫停下載"; -"detail_view.accessibility.download_button.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; -"detail_view.accessibility.download_button.partial" = "重新下載,已有 %d / %d 頁可用。"; -"detail_view.dialog.title.delete_download" = "刪除下載?"; -"detail_view.dialog.title.repair_download" = "修復下載?"; -"detail_view.dialog.title.update_download" = "更新下載?"; -"detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; -"detail_view.dialog.message.delete_active_download" = "這將停止目前下載並從此裝置移除此畫廊。"; -"detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; -"detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; -"detail_view.dialog.message.redownload_gallery" = "現在重新完整下載此畫廊嗎?"; -"detail_view.dialog.button.repair" = "修復"; -"detail_view.dialog.button.update" = "更新"; -"detail_view.dialog.button.redownload" = "重新下載"; -"detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "下載"; -"downloads_view.search.prompt.downloads" = "搜尋下載"; -"downloads_view.dialog.title.delete_download" = "刪除下載?"; -"downloads_view.dialog.message.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; -"downloads_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"downloads_view.swipe.button.pages" = "頁面"; -"downloads_view.swipe.button.update" = "更新"; -"downloads_view.swipe.button.resume" = "繼續"; -"downloads_view.swipe.button.pause" = "暫停"; -"downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; -"downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; -"downloads_view.button.clear_filters" = "清除篩選"; -"downloads_view.button.validate_image_data" = "驗證圖片資料"; -"downloads_view.inspector.section.actions" = "操作"; -"downloads_view.inspector.section.pages" = "頁面"; -"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; -"downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; -"downloads_view.inspector.button.update_download" = "更新下載"; -"downloads_view.inspector.hud.image_data_valid" = "圖片資料有效"; -"downloads_view.inspector.hud.image_data_unavailable" = "無法驗證圖片資料。"; -"downloads_view.inspector.title.download_status" = "下載狀態"; -"downloads_view.inspector.page.pending" = "等待中"; -"downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; -"downloads_view.inspector.page.title" = "第 %d 頁"; -"downloads_view.inspector.page.none" = "沒有頁面"; -"downloads_view.inspector.status.pending" = "等待中"; -"downloads_view.inspector.status.downloaded" = "已下載"; -"downloads_view.inspector.status.failed" = "失敗"; -"download_setting_view.title" = "下載"; -"download_setting_view.section.title.download_queue" = "下載佇列"; -"download_setting_view.section.title.network" = "網絡"; -"download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; -"download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; -"download_setting_view.title.allow_cellular_downloads" = "允許流動網絡下載"; -"download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許流動網絡下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; -"struct.download_badge.text.queued" = "已排隊"; -"struct.download_badge.text.downloading" = "下載中"; -"struct.download_badge.text.paused" = "已暫停"; -"struct.download_badge.text.downloaded" = "已下載"; -"struct.download_badge.text.needs_attention" = "需處理"; -"struct.download_badge.text.update_available" = "有可更新"; -"struct.download_badge.text.needs_repair" = "需修復"; -"struct.download_badge.progress" = "%d/%d"; -"download_store.error.asset_unreadable" = "資源檔案無法讀取:%@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "無法解析下載資料夾。"; -"download_store.validation.download_folder_missing" = "下載資料夾缺失。"; -"download_store.validation.manifest_missing" = "Manifest 檔案缺失。"; -"download_store.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; -"download_store.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; -"download_store.validation.cover_image_missing" = "封面圖片缺失。"; -"download_store.validation.page_missing" = "第 %d 頁缺失。"; -"download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; -"download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; - -// MARK: AppActivityLogsView -"app_activity_logs_view.run" = "運行 %@"; From b5afaa905954c22ec9d6469520122610090a6d8d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 17:37:32 +0800 Subject: [PATCH 395/614] Drop Copied from attribution comments --- AppPackage/Sources/AppComponents/TagCloudView.swift | 3 --- AppPackage/Sources/AppTools/ColorCodable.swift | 3 --- AppPackage/Sources/DetailFeature/Components/LinkedText.swift | 3 --- AppPackage/Sources/SettingFeature/WaveForm.swift | 3 --- 4 files changed, 12 deletions(-) diff --git a/AppPackage/Sources/AppComponents/TagCloudView.swift b/AppPackage/Sources/AppComponents/TagCloudView.swift index b0b5cb4f5..2d7882450 100644 --- a/AppPackage/Sources/AppComponents/TagCloudView.swift +++ b/AppPackage/Sources/AppComponents/TagCloudView.swift @@ -1,6 +1,3 @@ -// Copied from https://stackoverflow.com/questions/62102647/ -// - import SwiftUI import SFSafeSymbols import Kingfisher diff --git a/AppPackage/Sources/AppTools/ColorCodable.swift b/AppPackage/Sources/AppTools/ColorCodable.swift index 27a4d735e..9ef6b0334 100644 --- a/AppPackage/Sources/AppTools/ColorCodable.swift +++ b/AppPackage/Sources/AppTools/ColorCodable.swift @@ -1,6 +1,3 @@ -// Copied from https://brunowernimont.me/howtos/make-swiftui-color-codable -// - import SwiftUI #if os(iOS) import UIKit diff --git a/AppPackage/Sources/DetailFeature/Components/LinkedText.swift b/AppPackage/Sources/DetailFeature/Components/LinkedText.swift index cba372e86..7428769b4 100644 --- a/AppPackage/Sources/DetailFeature/Components/LinkedText.swift +++ b/AppPackage/Sources/DetailFeature/Components/LinkedText.swift @@ -1,6 +1,3 @@ -// Copied from https://gist.github.com/mjm/0581781f85db45b05e8e2c5c33696f88 -// - import SwiftUI private let linkDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) diff --git a/AppPackage/Sources/SettingFeature/WaveForm.swift b/AppPackage/Sources/SettingFeature/WaveForm.swift index 59e2f67be..e1a6c7a63 100644 --- a/AppPackage/Sources/SettingFeature/WaveForm.swift +++ b/AppPackage/Sources/SettingFeature/WaveForm.swift @@ -1,6 +1,3 @@ -// Copied from Kavsoft -// - import SwiftUI public struct WaveForm: View { From 5da21f9c378b7407da5dfb3f891724ea248f9561 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 17:37:32 +0800 Subject: [PATCH 396/614] Drop Localizable.strings file headers --- .../Sources/Resources/Resources/de.lproj/Localizable.strings | 5 ----- .../Sources/Resources/Resources/en.lproj/Localizable.strings | 5 ----- .../Sources/Resources/Resources/ja.lproj/Localizable.strings | 5 ----- .../Sources/Resources/Resources/ko.lproj/Localizable.strings | 5 ----- .../Resources/Resources/zh-Hans.lproj/Localizable.strings | 5 ----- .../Resources/Resources/zh-Hant-HK.lproj/Localizable.strings | 5 ----- .../Resources/Resources/zh-Hant-TW.lproj/Localizable.strings | 5 ----- .../Resources/Resources/zh-Hant.lproj/Localizable.strings | 5 ----- 8 files changed, 40 deletions(-) diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 61581d59f..62a176db9 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -1,8 +1,3 @@ -/* - Localizable.strings - EhPanda -*/ - // MARK: BanInterval "enum.ban_interval.description.and" = "and"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index bf8b43e1b..6b76cbbca 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -1,8 +1,3 @@ -/* - Localizable.strings - EhPanda -*/ - // MARK: BanInterval "enum.ban_interval.description.and" = "and"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index b25790baa..963b1b762 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -1,8 +1,3 @@ -/* - Localizable.strings - EhPanda -*/ - // MARK: BanInterval "enum.ban_interval.description.and" = ""; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index 3b85cf1dd..0035ed864 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -1,8 +1,3 @@ -/* - Localizable.strings - EhPanda -*/ - // MARK: BanInterval "enum.ban_interval.description.and" = "and"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index c194074cf..28db31c49 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -1,8 +1,3 @@ -/* - Localizable.strings - EhPanda -*/ - // MARK: BanInterval "enum.ban_interval.description.and" = ""; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings index e6e7475a4..9fe624cb3 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings @@ -1,8 +1,3 @@ -/* - Localizable.strings - EhPanda -*/ - // MARK: BanInterval "enum.ban_interval.description.and" = ""; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings index 3ba1284f8..812ff230f 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings @@ -1,8 +1,3 @@ -/* - Localizable.strings - EhPanda -*/ - // MARK: BanInterval "enum.ban_interval.description.and" = ""; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 6eec2e673..c7142a10e 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -1,8 +1,3 @@ -/* - Localizable.strings - EhPanda -*/ - // MARK: BanInterval "enum.ban_interval.description.and" = ""; From c5b11f3f9fae681a89b60b506ef0eda2d0229c44 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 19:21:43 +0800 Subject: [PATCH 397/614] Adopt TCA AlertState/ConfirmationDialogState --- .../DetailReducer+Download.swift | 73 ++++++++++++++ .../Sources/DetailFeature/DetailReducer.swift | 10 ++ .../Sources/DetailFeature/DetailView.swift | 97 +------------------ .../FolderManager/FolderManagerReducer.swift | 32 ++++-- .../FolderManager/FolderManagerView.swift | 17 +--- .../DownloadsFeature/DownloadsReducer.swift | 64 ++++++++++++ .../DownloadsFeature/DownloadsView.swift | 68 ++----------- .../FiltersFeature/FiltersReducer.swift | 32 ++++-- .../Sources/FiltersFeature/FiltersView.swift | 25 ++--- .../HomeFeature/History/HistoryReducer.swift | 32 +++++- .../HomeFeature/History/HistoryView.swift | 14 +-- .../MigrationFeature/MigrationReducer.swift | 33 +++++-- .../MigrationFeature/MigrationView.swift | 14 +-- .../QuickSearchReducer.swift | 31 +++++- .../QuickSearchFeature/QuickSearchView.swift | 17 +--- .../AccountSettingReducer.swift | 31 +++++- .../AccountSetting/AccountSettingView.swift | 28 +----- .../EhSetting/EhSettingReducer.swift | 32 +++++- .../EhSetting/EhSettingView+Sections1.swift | 12 --- .../EhSetting/EhSettingView.swift | 13 +-- .../GeneralSettingReducer.swift | 52 +++++++++- .../GeneralSetting/GeneralSettingView.swift | 27 +----- .../SwiftUINavigation+.swift | 34 ------- 23 files changed, 440 insertions(+), 348 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift index c02bbb6b3..759fd8036 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import Resources import ComposableArchitecture import AppTools @@ -8,6 +9,45 @@ extension DetailReducer { var downloadReducer: some ReducerOf { Reduce { state, action in switch action { + case .deleteDownloadButtonTapped: + state.alert = AlertState { + TextState(L10n.Localizable.DetailView.Dialog.Title.deleteDownload) + } actions: { + ButtonState(role: .destructive, action: .confirmDeleteDownload) { + TextState(L10n.Localizable.ConfirmationDialog.Button.delete) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState(L10n.Localizable.DetailView.Dialog.Message.deleteDownloadedGallery) + } + return .none + + case .retryDownloadButtonTapped(let mode): + state.alert = AlertState { + TextState(Self.retryDownloadTitle(for: mode)) + } actions: { + ButtonState(action: .confirmRetryDownload(mode)) { + TextState(Self.retryDownloadConfirmTitle(for: mode)) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState(Self.retryDownloadMessage(for: mode)) + } + return .none + + case .alert(.presented(.confirmDeleteDownload)): + return .send(.deleteDownload) + + case .alert(.presented(.confirmRetryDownload(let mode))): + return .send(.retryDownload(mode)) + + case .alert: + return .none + case .fetchDownloadBadge: guard state.gid.isValidGID else { return .none } return .run { [galleryID = state.gid] send in @@ -259,4 +299,37 @@ extension DetailReducer { } } } + + private static func retryDownloadTitle(for mode: DownloadStartMode) -> String { + switch mode { + case .repair: + return L10n.Localizable.DetailView.Dialog.Title.repairDownload + case .update: + return L10n.Localizable.DetailView.Dialog.Title.updateDownload + case .initial, .redownload: + return L10n.Localizable.DetailView.Dialog.Title.redownloadGallery + } + } + + private static func retryDownloadMessage(for mode: DownloadStartMode) -> String { + switch mode { + case .repair: + return L10n.Localizable.DetailView.Dialog.Message.repairDownload + case .update: + return L10n.Localizable.DetailView.Dialog.Message.updateDownload + case .initial, .redownload: + return L10n.Localizable.DetailView.Dialog.Message.redownloadGallery + } + } + + private static func retryDownloadConfirmTitle(for mode: DownloadStartMode) -> String { + switch mode { + case .repair: + return L10n.Localizable.DetailView.Dialog.Button.repair + case .update: + return L10n.Localizable.DetailView.Dialog.Button.update + case .initial, .redownload: + return L10n.Localizable.DetailView.Dialog.Button.redownload + } + } } diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index a48dc64dc..b5a24d4c0 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -31,6 +31,11 @@ public struct DetailReducer: Sendable { case folderManager(EquatableVoid = .init()) } + public enum Alert: Equatable, Sendable { + case confirmDeleteDownload + case confirmRetryDownload(DownloadStartMode) + } + public enum CancelID: Hashable, Sendable { case fetchDatabaseInfos(String) case fetchGalleryDetail(String) @@ -68,6 +73,7 @@ public struct DetailReducer: Sendable { @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var alert: AlertState? public var commentContent = "" public var postCommentFocused = false public var showsNewDawnGreeting = false @@ -127,6 +133,9 @@ public struct DetailReducer: Sendable { public indirect enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) + case alert(PresentationAction) + case deleteDownloadButtonTapped + case retryDownloadButtonTapped(DownloadStartMode) case clearSubStates case onPostCommentAppear case onAppear(String, Bool) @@ -216,6 +225,7 @@ extension DetailReducer { galleryOpsReducer childReducer(self) optionalChildReducers + .ifLet(\.$alert, action: \.alert) Scope(state: \.readingState, action: \.reading, child: ReadingReducer.init) Scope(state: \.archivesState, action: \.archives, child: ArchivesReducer.init) Scope(state: \.torrentsState, action: \.torrents, child: TorrentsReducer.init) diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index b988997e3..c00e670bc 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -9,73 +9,8 @@ import AppTools import AppComponents import ReadingFeature -private enum DownloadDialog: Equatable { - case delete(isActiveDownload: Bool) - case retry(DownloadStartMode) - - var title: String { - switch self { - case .delete: - return L10n.Localizable.DetailView.Dialog.Title.deleteDownload - case .retry(let mode): - switch mode { - case .repair: - return L10n.Localizable.DetailView.Dialog.Title.repairDownload - case .update: - return L10n.Localizable.DetailView.Dialog.Title.updateDownload - case .initial, .redownload: - return L10n.Localizable.DetailView.Dialog.Title.redownloadGallery - } - } - } - - var message: String { - switch self { - case .delete(let isActiveDownload): - return isActiveDownload - ? L10n.Localizable.DetailView.Dialog.Message.deleteActiveDownload - : L10n.Localizable.DetailView.Dialog.Message.deleteDownloadedGallery - case .retry(let mode): - switch mode { - case .repair: - return L10n.Localizable.DetailView.Dialog.Message.repairDownload - case .update: - return L10n.Localizable.DetailView.Dialog.Message.updateDownload - case .initial, .redownload: - return L10n.Localizable.DetailView.Dialog.Message.redownloadGallery - } - } - } - - var confirmTitle: String { - switch self { - case .delete: - return L10n.Localizable.ConfirmationDialog.Button.delete - case .retry(let mode): - switch mode { - case .repair: - return L10n.Localizable.DetailView.Dialog.Button.repair - case .update: - return L10n.Localizable.DetailView.Dialog.Button.update - case .initial, .redownload: - return L10n.Localizable.DetailView.Dialog.Button.redownload - } - } - } - - var confirmRole: ButtonRole? { - switch self { - case .delete: - return .destructive - case .retry: - return nil - } - } -} - public struct DetailView: View { @Bindable var store: StoreOf - @State private var downloadDialog: DownloadDialog? let gid: String let user: User @Binding var setting: Setting @@ -110,29 +45,7 @@ public struct DetailView: View { .onChange(of: store.hasLoadedDownloadBadge) { _, _ in runLaunchAutomationIfNeeded() } - .alert( - downloadDialog?.title ?? "", - isPresented: Binding( - get: { downloadDialog != nil }, - set: { if !$0 { downloadDialog = nil } } - ), - presenting: downloadDialog - ) { dialog in - Button(dialog.confirmTitle, role: dialog.confirmRole) { - switch dialog { - case .delete: - store.send(.deleteDownload) - case .retry(let mode): - store.send(.retryDownload(mode)) - } - downloadDialog = nil - } - Button(L10n.Localizable.Common.Button.cancel, role: .cancel) { - downloadDialog = nil - } - } message: { dialog in - Text(dialog.message) - } + .alert($store.scope(state: \.alert, action: \.alert)) .background(navigationLinks) .toolbar(content: toolbar) } @@ -354,13 +267,11 @@ private extension DetailView { case .queued, .active, .inactive: store.send(.toggleDownloadPause) case .completed: - downloadDialog = .delete(isActiveDownload: false) + store.send(.deleteDownloadButtonTapped) case .error: - downloadDialog = store.downloadNeedsRepair - ? .retry(.repair) - : .retry(.redownload) + store.send(.retryDownloadButtonTapped(store.downloadNeedsRepair ? .repair : .redownload)) case .updateAvailable: - downloadDialog = .retry(.update) + store.send(.retryDownloadButtonTapped(.update)) } } diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift index 30387ef18..aebb6d786 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift @@ -6,9 +6,8 @@ import DownloadClient @Reducer public struct FolderManagerReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case deleteFolder(String) + public enum Dialog: Equatable, Sendable { + case confirmDelete(String) } public enum EditingField: Equatable, Hashable { @@ -28,7 +27,7 @@ public struct FolderManagerReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? + @Presents public var confirmationDialog: ConfirmationDialogState? public var editingField: EditingField? public var editingFolderName = "" public var loadingState: LoadingState = .idle @@ -55,7 +54,8 @@ public struct FolderManagerReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) + case confirmationDialog(PresentationAction) + case deleteButtonTapped(String) case setEditingField(EditingField?) case submitEditingField @@ -83,8 +83,25 @@ public struct FolderManagerReducer: Sendable { case .binding: return .none - case .setNavigation(let route): - state.route = route + case .deleteButtonTapped(let folder): + state.confirmationDialog = ConfirmationDialogState { + TextState("") + } actions: { + ButtonState(role: .destructive, action: .confirmDelete(folder)) { + TextState(L10n.Localizable.ConfirmationDialog.Button.delete) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState(L10n.Localizable.FolderManagerView.Dialog.Message.deleteFolder) + } + return .none + + case .confirmationDialog(.presented(.confirmDelete(let folder))): + return .send(.deleteFolder(folder)) + + case .confirmationDialog: return .none case .setEditingField(let editingField): @@ -182,5 +199,6 @@ public struct FolderManagerReducer: Sendable { return .none } } + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) } } diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift index 79b96924b..fe0f50cbc 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift @@ -27,7 +27,7 @@ public struct FolderManagerView: View { .padding(5) .swipeActions(edge: .trailing) { Button { - store.send(.setNavigation(.deleteFolder(folder))) + store.send(.deleteButtonTapped(folder)) } label: { Image(systemSymbol: .trash) } @@ -38,23 +38,14 @@ public struct FolderManagerView: View { Image(systemSymbol: .squareAndPencil) } } - .confirmationDialog( - message: L10n.Localizable.FolderManagerView.Dialog.Message.deleteFolder, - unwrapping: $store.route, - case: \.deleteFolder, - matching: folder - ) { route in - Button(L10n.Localizable.ConfirmationDialog.Button.delete, role: .destructive) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.send(.deleteFolder(route)) - } - } - } } } stateOverlay } + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) .animation(.default, value: store.folders) .animation(.default, value: store.editingField) .synchronize($store.editingField, $focusedField) diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index c4e617b1a..744014b13 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import Resources import ComposableArchitecture import AppTools import DownloadClient @@ -17,6 +18,14 @@ public struct DownloadsReducer: Sendable { case folderManager(EquatableVoid = .init()) } + public enum Alert: Equatable, Sendable { + case confirmDelete(String) + } + + public enum Dialog: Equatable, Sendable { + case move(String, String) + } + private enum CancelID { case observeDownloads case fetchFolders @@ -25,6 +34,8 @@ public struct DownloadsReducer: Sendable { @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var alert: AlertState? + @Presents public var confirmationDialog: ConfirmationDialogState? public var keyword = "" public var folderFilter: DownloadFolderFilter = .all public var folders = [String]() @@ -56,6 +67,10 @@ public struct DownloadsReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) + case alert(PresentationAction) + case confirmationDialog(PresentationAction) + case deleteDownloadButtonTapped(DownloadedGallery) + case moveButtonTapped(DownloadedGallery) case clearSubStates case onAppear @@ -114,6 +129,53 @@ public struct DownloadsReducer: Sendable { } return route == nil ? .send(.clearSubStates) : .none + case .deleteDownloadButtonTapped(let download): + state.alert = AlertState { + TextState(L10n.Localizable.DownloadsView.Dialog.Title.deleteDownload) + } actions: { + ButtonState(role: .destructive, action: .confirmDelete(download.gid)) { + TextState(L10n.Localizable.ConfirmationDialog.Button.delete) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState( + download.canTogglePause + ? L10n.Localizable.DownloadsView.Dialog.Message.deleteActiveDownload + : L10n.Localizable.DownloadsView.Dialog.Message.deleteDownloadedGallery + ) + } + return .none + + case .moveButtonTapped(let download): + let destinations = state.folders.filter { $0 != download.folderName } + state.confirmationDialog = ConfirmationDialogState { + TextState(L10n.Localizable.DownloadsView.Menu.Button.moveToFolder) + } actions: { + for folder in destinations { + ButtonState(action: .move(download.gid, folder)) { + TextState(folder) + } + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } + return .none + + case .alert(.presented(.confirmDelete(let gid))): + return .send(.deleteDownload(gid)) + + case .alert: + return .none + + case .confirmationDialog(.presented(.move(let gid, let folder))): + return .send(.moveDownload(gid, folder)) + + case .confirmationDialog: + return .none + case .clearSubStates: state.detailState.wrappedValue = .init() state.readingState = .init() @@ -289,6 +351,8 @@ public struct DownloadsReducer: Sendable { return .none } } + .ifLet(\.$alert, action: \.alert) + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) Scope(state: \.readingState, action: \.reading, child: ReadingReducer.init) diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index 8549ee92c..f6fb93586 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -10,20 +10,7 @@ import ReadingFeature import DetailFeature public struct DownloadsView: View { - private enum RowDialog: Identifiable { - case delete(DownloadedGallery) - - var id: String { - switch self { - case .delete(let download): - return "delete-\(download.gid)" - } - } - } - @Bindable private var store: StoreOf - @State private var rowDialog: RowDialog? - @State private var moveDialogDownload: DownloadedGallery? @Binding private var setting: Setting private let user: User private let blurRadius: Double @@ -123,53 +110,10 @@ public struct DownloadsView: View { .onAppear { store.send(.onAppear) } - .alert( - L10n.Localizable.DownloadsView.Dialog.Title.deleteDownload, - isPresented: Binding( - get: { rowDialog != nil }, - set: { if !$0 { rowDialog = nil } } - ), - presenting: rowDialog - ) { dialog in - switch dialog { - case .delete(let download): - Button(L10n.Localizable.ConfirmationDialog.Button.delete, role: .destructive) { - store.send(.deleteDownload(download.gid)) - rowDialog = nil - } - Button(L10n.Localizable.Common.Button.cancel, role: .cancel) { - rowDialog = nil - } - } - } message: { dialog in - switch dialog { - case .delete(let download): - Text( - download.canTogglePause - ? L10n.Localizable.DownloadsView.Dialog.Message.deleteActiveDownload - : L10n.Localizable.DownloadsView.Dialog.Message.deleteDownloadedGallery - ) - } - } + .alert($store.scope(state: \.alert, action: \.alert)) .confirmationDialog( - L10n.Localizable.DownloadsView.Menu.Button.moveToFolder, - isPresented: Binding( - get: { moveDialogDownload != nil }, - set: { if !$0 { moveDialogDownload = nil } } - ), - titleVisibility: .visible, - presenting: moveDialogDownload - ) { download in - ForEach(moveDestinations(for: download), id: \.self) { folder in - Button(folder) { - store.send(.moveDownload(download.gid, folder)) - moveDialogDownload = nil - } - } - Button(L10n.Localizable.Common.Button.cancel, role: .cancel) { - moveDialogDownload = nil - } - } + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) .background(navigationLink) .navigationTitle(L10n.Localizable.DownloadsView.Title.downloads) .navigationBarTitleDisplayMode(.large) @@ -214,7 +158,7 @@ private extension DownloadsView { if canMove(download) { Button { - moveDialogDownload = download + store.send(.moveButtonTapped(download)) } label: { Label( L10n.Localizable.DownloadsView.Swipe.Button.move, @@ -254,7 +198,7 @@ private extension DownloadsView { } Button(role: .destructive) { - rowDialog = .delete(download) + store.send(.deleteDownloadButtonTapped(download)) } label: { Label(L10n.Localizable.ConfirmationDialog.Button.delete, systemSymbol: .trash) } @@ -326,7 +270,7 @@ private extension DownloadsView { } Button(role: .destructive) { - rowDialog = .delete(download) + store.send(.deleteDownloadButtonTapped(download)) } label: { Label(L10n.Localizable.ConfirmationDialog.Button.delete, systemSymbol: .trash) } diff --git a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift index 85cf97f47..cd39363c9 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift @@ -1,12 +1,12 @@ import ComposableArchitecture import AppModels +import Resources import DatabaseClient @Reducer public struct FiltersReducer: Sendable { - @CasePathable - public enum Route: Sendable { - case resetFilters + public enum Dialog: Equatable, Sendable { + case confirmReset } public enum FocusedBound { @@ -16,7 +16,7 @@ public struct FiltersReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? + @Presents public var confirmationDialog: ConfirmationDialogState? public var filterRange: FilterRange = .search public var focusedBound: FocusedBound? @@ -29,7 +29,8 @@ public struct FiltersReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) - case setNavigation(Route?) + case confirmationDialog(PresentationAction) + case resetFiltersButtonTapped case onTextFieldSubmitted case syncFilter(FilterRange) @@ -62,8 +63,25 @@ public struct FiltersReducer: Sendable { case .binding: return .none - case .setNavigation(let route): - state.route = route + case .resetFiltersButtonTapped: + state.confirmationDialog = ConfirmationDialogState { + TextState("") + } actions: { + ButtonState(role: .destructive, action: .confirmReset) { + TextState(L10n.Localizable.ConfirmationDialog.Button.reset) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState(L10n.Localizable.ConfirmationDialog.Title.reset) + } + return .none + + case .confirmationDialog(.presented(.confirmReset)): + return .send(.resetFilters) + + case .confirmationDialog: return .none case .onTextFieldSubmitted: diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index 89fb92683..2b5ddcaae 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -30,10 +30,8 @@ public struct FiltersView: View { NavigationView { Form { BasicSection( - route: $store.route, filter: filter, filterRange: $store.filterRange, - resetFiltersAction: { store.send(.resetFilters) }, - resetFiltersDialogAction: { store.send(.setNavigation(.resetFilters)) } + resetFiltersDialogAction: { store.send(.resetFiltersButtonTapped) } ) AdvancedSection( filter: filter, focusedBound: $focusedBound, @@ -41,6 +39,9 @@ public struct FiltersView: View { ) } .synchronize($store.focusedBound, $focusedBound) + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) .navigationTitle(L10n.Localizable.FiltersView.Title.filters) .onAppear { store.send(.fetchFilters) } } @@ -49,10 +50,8 @@ public struct FiltersView: View { // MARK: BasicSection private struct BasicSection: View { - @Binding private var route: FiltersReducer.Route? @Binding private var filter: Filter @Binding private var filterRange: FilterRange - private let resetFiltersAction: () -> Void private let resetFiltersDialogAction: () -> Void private var categoryBindings: [Binding] { [ $filter.doujinshi, $filter.manga, $filter.artistCG, $filter.gameCG, $filter.western, @@ -60,13 +59,11 @@ private struct BasicSection: View { ] } init( - route: Binding, filter: Binding, filterRange: Binding, - resetFiltersAction: @escaping () -> Void, resetFiltersDialogAction: @escaping () -> Void + filter: Binding, filterRange: Binding, + resetFiltersDialogAction: @escaping () -> Void ) { - _route = route _filter = filter _filterRange = filterRange - self.resetFiltersAction = resetFiltersAction self.resetFiltersDialogAction = resetFiltersDialogAction } @@ -82,16 +79,6 @@ private struct BasicSection: View { Button(action: resetFiltersDialogAction) { Text(L10n.Localizable.FiltersView.Button.resetFilters).foregroundStyle(.red) } - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.reset, - unwrapping: $route, - case: \.resetFilters - ) { - Button( - L10n.Localizable.ConfirmationDialog.Button.reset, - role: .destructive, action: resetFiltersAction - ) - } Toggle(L10n.Localizable.FiltersView.Title.advancedSettings, isOn: $filter.advanced) } } diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index ba06d1086..de52454ef 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import Resources import ComposableArchitecture import AppTools import HapticsClient @@ -17,14 +18,17 @@ public struct HistoryReducer: Sendable { @CasePathable public enum Route: Equatable, Sendable { case detail(String) - case clearHistory + } + + public enum Dialog: Equatable, Sendable { + case confirmClearHistory } @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var confirmationDialog: ConfirmationDialogState? public var keyword = "" - public var clearDialogPresented = false public var downloadBadges = [String: DownloadBadge]() var filteredGalleries: [Gallery] { @@ -45,7 +49,9 @@ public struct HistoryReducer: Sendable { case binding(BindingAction) case onAppear case setNavigation(Route?) + case confirmationDialog(PresentationAction) case clearSubStates + case clearHistoryButtonTapped case clearHistoryGalleries case fetchGalleries @@ -80,6 +86,27 @@ public struct HistoryReducer: Sendable { state.route = route return route == nil ? .send(.clearSubStates) : .none + case .clearHistoryButtonTapped: + state.confirmationDialog = ConfirmationDialogState { + TextState("") + } actions: { + ButtonState(role: .destructive, action: .confirmClearHistory) { + TextState(L10n.Localizable.ConfirmationDialog.Button.clear) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState(L10n.Localizable.ConfirmationDialog.Title.clear) + } + return .none + + case .confirmationDialog(.presented(.confirmClearHistory)): + return .send(.clearHistoryGalleries) + + case .confirmationDialog: + return .none + case .clearSubStates: state.detailState.wrappedValue = .init() return .send(.detail(.teardown)) @@ -128,6 +155,7 @@ public struct HistoryReducer: Sendable { return .none } } + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index ce5d1a0e3..d5c497734 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -53,6 +53,9 @@ struct HistoryView: View { } .background(navigationLink) .toolbar(content: toolbar) + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) .navigationTitle(L10n.Localizable.HistoryView.Title.history) if DeviceUtil.isPad { @@ -86,20 +89,11 @@ struct HistoryView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { Button { - store.send(.setNavigation(.clearHistory)) + store.send(.clearHistoryButtonTapped) } label: { Image(systemSymbol: .trashCircle) } .disabled(store.loadingState != .idle || store.galleries.isEmpty) - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.clear, - unwrapping: $store.route, - case: \.clearHistory - ) { - Button(L10n.Localizable.ConfirmationDialog.Button.clear, role: .destructive) { - store.send(.clearHistoryGalleries) - } - } } } } diff --git a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift index 74b31425f..25528d93a 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift @@ -1,18 +1,18 @@ import Foundation import AppModels +import Resources import ComposableArchitecture import DatabaseClient @Reducer public struct MigrationReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case dropDialog + public enum Dialog: Equatable, Sendable { + case confirmDropDatabase } @ObservableState public struct State: Equatable { - public var route: Route? + @Presents public var confirmationDialog: ConfirmationDialogState? public var databaseState: LoadingState = .loading public init() {} @@ -20,7 +20,8 @@ public struct MigrationReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) - case setNavigation(Route?) + case confirmationDialog(PresentationAction) + case dropDatabaseButtonTapped case onDatabasePreparationSuccess case prepareDatabase @@ -41,8 +42,25 @@ public struct MigrationReducer: Sendable { case .binding: return .none - case .setNavigation(let route): - state.route = route + case .dropDatabaseButtonTapped: + state.confirmationDialog = ConfirmationDialogState { + TextState("") + } actions: { + ButtonState(role: .destructive, action: .confirmDropDatabase) { + TextState(L10n.Localizable.ConfirmationDialog.Button.dropDatabase) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState(L10n.Localizable.ConfirmationDialog.Title.dropDatabase) + } + return .none + + case .confirmationDialog(.presented(.confirmDropDatabase)): + return .send(.dropDatabase) + + case .confirmationDialog: return .none case .onDatabasePreparationSuccess: @@ -81,6 +99,7 @@ public struct MigrationReducer: Sendable { } } } + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) } } diff --git a/AppPackage/Sources/MigrationFeature/MigrationView.swift b/AppPackage/Sources/MigrationFeature/MigrationView.swift index d41445cf9..f83aa9b56 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationView.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationView.swift @@ -27,19 +27,13 @@ public struct MigrationView: View { let errorNonNil = error ?? .databaseCorrupted(nil) AlertView(symbol: errorNonNil.symbol, message: errorNonNil.localizedDescription) { AlertViewButton(title: L10n.Localizable.ErrorView.Button.dropDatabase) { - store.send(.setNavigation(.dropDialog)) - } - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.dropDatabase, - unwrapping: $store.route, - case: \.dropDialog - ) { - Button(L10n.Localizable.ConfirmationDialog.Button.dropDatabase, role: .destructive) { - store.send(.dropDatabase) - } + store.send(.dropDatabaseButtonTapped) } } .opacity(error != nil ? 1 : 0) + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) } .animation(.default, value: store.databaseState) } diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift index 9b8935417..49970d509 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import Resources import ComposableArchitecture import DatabaseClient @@ -9,7 +10,10 @@ public struct QuickSearchReducer: Sendable { public enum Route: Equatable, Sendable { case newWord case editWord - case deleteWord(QuickSearchWord) + } + + public enum Dialog: Equatable, Sendable { + case confirmDelete(QuickSearchWord) } public enum FocusField: Sendable { @@ -24,6 +28,7 @@ public struct QuickSearchReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { public var route: Route? + @Presents public var confirmationDialog: ConfirmationDialogState? public var focusedField: FocusField? public var editingWord: QuickSearchWord = .empty public var listEditMode: EditMode = .inactive @@ -41,6 +46,8 @@ public struct QuickSearchReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) + case confirmationDialog(PresentationAction) + case deleteWordButtonTapped(QuickSearchWord) case clearSubStates case syncQuickSearchWords @@ -78,6 +85,27 @@ public struct QuickSearchReducer: Sendable { state.route = route return route == nil ? .send(.clearSubStates) : .none + case .deleteWordButtonTapped(let word): + state.confirmationDialog = ConfirmationDialogState { + TextState("") + } actions: { + ButtonState(role: .destructive, action: .confirmDelete(word)) { + TextState(L10n.Localizable.ConfirmationDialog.Button.delete) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState(L10n.Localizable.ConfirmationDialog.Title.delete) + } + return .none + + case .confirmationDialog(.presented(.confirmDelete(let word))): + return .send(.deleteWord(word)) + + case .confirmationDialog: + return .none + case .clearSubStates: state.focusedField = nil state.editingWord = .empty @@ -136,5 +164,6 @@ public struct QuickSearchReducer: Sendable { return .none } } + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) } } diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index ec2add10e..99c614bc7 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -38,7 +38,7 @@ public struct QuickSearchView: View { } .swipeActions(edge: .trailing) { Button { - store.send(.setNavigation(.deleteWord(word))) + store.send(.deleteWordButtonTapped(word)) } label: { Image(systemSymbol: .trash) } @@ -51,18 +51,6 @@ public struct QuickSearchView: View { } } .withArrow(isVisible: !store.isListEditing).padding(5) - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.delete, - unwrapping: $store.route, - case: \.deleteWord, - matching: word - ) { route in - Button(L10n.Localizable.ConfirmationDialog.Button.delete, role: .destructive) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.send(.deleteWord(route)) - } - } - } } .onDelete { offsets in store.send(.deleteWordWithOffsets(offsets)) @@ -81,6 +69,9 @@ public struct QuickSearchView: View { && store.quickSearchWords.isEmpty ? 1 : 0 ) } + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) .synchronize($store.focusedField, $focusedField) .environment(\.editMode, $store.listEditMode) .animation(.default, value: store.quickSearchWords) diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index 175484581..9f9ce365a 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import Resources import ComposableArchitecture import SwiftUINavigationExt import HapticsClient @@ -13,14 +14,18 @@ public struct AccountSettingReducer: Sendable { public enum Route: Equatable, Sendable { case hud case login - case logout case ehSetting case webView(URL) } + public enum Dialog: Equatable, Sendable { + case confirmLogout + } + @ObservableState public struct State: Equatable, Sendable { public var route: Route? + @Presents public var confirmationDialog: ConfirmationDialogState? public var ehCookiesState: CookiesState = .empty(.ehentai) public var exCookiesState: CookiesState = .empty(.exhentai) public var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded @@ -32,6 +37,8 @@ public struct AccountSettingReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) + case confirmationDialog(PresentationAction) + case logoutButtonTapped case onLogoutConfirmButtonTapped case clearSubStates case loadCookies @@ -67,6 +74,27 @@ public struct AccountSettingReducer: Sendable { state.route = route return route == nil ? .send(.clearSubStates) : .none + case .logoutButtonTapped: + state.confirmationDialog = ConfirmationDialogState { + TextState("") + } actions: { + ButtonState(role: .destructive, action: .confirmLogout) { + TextState(L10n.Localizable.ConfirmationDialog.Button.logout) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState(L10n.Localizable.ConfirmationDialog.Title.logout) + } + return .none + + case .confirmationDialog(.presented(.confirmLogout)): + return .send(.onLogoutConfirmButtonTapped) + + case .confirmationDialog: + return .none + case .onLogoutConfirmButtonTapped: return .send(.loadCookies) @@ -106,6 +134,7 @@ public struct AccountSettingReducer: Sendable { case: \.webView, hapticsClient: hapticsClient ) + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) Scope(state: \.loginState, action: \.login, child: LoginReducer.init) Scope(state: \.ehSettingState, action: \.ehSetting, child: EhSettingReducer.init) diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index aae5ad1df..16ab3da09 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -37,16 +37,10 @@ struct AccountSettingView: View { } .pickerStyle(.segmented) AccountSection( - route: $store.route, showsNewDawnGreeting: $showsNewDawnGreeting, bypassesSNIFiltering: bypassesSNIFiltering, loginAction: { store.send(.setNavigation(.login)) }, - logoutAction: { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.send(.onLogoutConfirmButtonTapped) - } - }, - logoutDialogAction: { store.send(.setNavigation(.logout)) }, + logoutDialogAction: { store.send(.logoutButtonTapped) }, configureAccountAction: { store.send(.setNavigation(.ehSetting)) }, manageTagsAction: { store.send(.setNavigation(.webView(Defaults.URL.myTags))) } ) @@ -62,6 +56,9 @@ struct AccountSettingView: View { unwrapping: $store.route, case: \.hud ) + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) .sheet(item: $store.route.sending(\.setNavigation).webView, id: \.absoluteString) { url in WebView(url: url) .ignoresSafeArea(edges: .bottom) @@ -93,28 +90,23 @@ private extension AccountSettingView { // MARK: AccountSection private struct AccountSection: View { - @Binding private var route: AccountSettingReducer.Route? @Binding private var showsNewDawnGreeting: Bool private let bypassesSNIFiltering: Bool private let loginAction: () -> Void - private let logoutAction: () -> Void private let logoutDialogAction: () -> Void private let configureAccountAction: () -> Void private let manageTagsAction: () -> Void init( - route: Binding, showsNewDawnGreeting: Binding, bypassesSNIFiltering: Bool, - loginAction: @escaping () -> Void, logoutAction: @escaping () -> Void, + loginAction: @escaping () -> Void, logoutDialogAction: @escaping () -> Void, configureAccountAction: @escaping () -> Void, manageTagsAction: @escaping () -> Void ) { - _route = route _showsNewDawnGreeting = showsNewDawnGreeting self.bypassesSNIFiltering = bypassesSNIFiltering self.loginAction = loginAction - self.logoutAction = logoutAction self.logoutDialogAction = logoutDialogAction self.configureAccountAction = configureAccountAction self.manageTagsAction = manageTagsAction @@ -128,16 +120,6 @@ private struct AccountSection: View { L10n.Localizable.ConfirmationDialog.Button.logout, role: .destructive, action: logoutDialogAction ) - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.logout, - unwrapping: $route, - case: \.logout - ) { - Button( - L10n.Localizable.ConfirmationDialog.Button.logout, - role: .destructive, action: logoutAction - ) - } Group { Button( L10n.Localizable.AccountSettingView.Button.accountConfiguration, diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index bc5c3fbce..b97279d75 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -1,6 +1,7 @@ import AppTools import Foundation import AppModels +import Resources import ComposableArchitecture import SwiftUINavigationExt import ApplicationClient @@ -13,7 +14,10 @@ public struct EhSettingReducer: Sendable { @CasePathable public enum Route: Equatable, Sendable { case webView(URL) - case deleteProfile + } + + public enum Dialog: Equatable, Sendable { + case confirmDeleteProfile } private enum CancelID: CaseIterable { @@ -23,6 +27,7 @@ public struct EhSettingReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { public var route: Route? + @Presents public var confirmationDialog: ConfirmationDialogState? public var editingProfileName = "" public var ehSetting: EhSetting? public var ehProfile: EhProfile? @@ -41,6 +46,8 @@ public struct EhSettingReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) + case confirmationDialog(PresentationAction) + case deleteProfileButtonTapped case setKeyboardHidden case setDefaultProfile(Int) @@ -71,6 +78,28 @@ public struct EhSettingReducer: Sendable { state.route = route return .none + case .deleteProfileButtonTapped: + state.confirmationDialog = ConfirmationDialogState { + TextState("") + } actions: { + ButtonState(role: .destructive, action: .confirmDeleteProfile) { + TextState(L10n.Localizable.ConfirmationDialog.Button.delete) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState(L10n.Localizable.ConfirmationDialog.Title.delete) + } + return .none + + case .confirmationDialog(.presented(.confirmDeleteProfile)): + guard let value = state.ehProfile?.value else { return .none } + return .send(.performAction(action: .delete, name: nil, set: value)) + + case .confirmationDialog: + return .none + case .setKeyboardHidden: return .run(operation: { _ in await applicationClient.hideKeyboard() }) @@ -153,5 +182,6 @@ public struct EhSettingReducer: Sendable { case: \.webView, hapticsClient: hapticsClient ) + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift index 458ca609b..fdce97612 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -10,11 +10,9 @@ extension EhSettingView { // MARK: EhProfileSection struct EhProfileSection: View { - @Binding var route: EhSettingReducer.Route? @Binding var ehSetting: EhSetting @Binding var ehProfile: EhProfile @Binding var editingProfileName: String - let deleteAction: () -> Void let deleteDialogAction: () -> Void let performEhProfileAction: (EhProfileAction?, String?, Int) -> Void @@ -40,16 +38,6 @@ struct EhProfileSection: View { role: .destructive, action: deleteDialogAction ) - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.delete, - unwrapping: $route, - case: \.deleteProfile - ) { - Button( - L10n.Localizable.ConfirmationDialog.Button.delete, - role: .destructive, action: deleteAction - ) - } } } header: { Text(L10n.Localizable.EhSettingView.Section.Title.profileSettings) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift index 014cd1f30..50c880692 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift @@ -53,6 +53,9 @@ struct EhSettingView: View { .ignoresSafeArea(edges: .bottom) .autoBlur(radius: blurRadius) } + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) .toolbar(content: toolbar) .navigationTitle(L10n.Localizable.EhSettingView.Title.hostSettings(galleryHost.rawValue)) } @@ -61,18 +64,10 @@ struct EhSettingView: View { Form { Group { EhProfileSection( - route: $store.route, ehSetting: ehSetting, ehProfile: ehProfile, editingProfileName: $store.editingProfileName, - deleteAction: { - if let value = store.ehProfile?.value { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.send(.performAction(action: .delete, name: nil, set: value)) - } - } - }, - deleteDialogAction: { store.send(.setNavigation(.deleteProfile)) }, + deleteDialogAction: { store.send(.deleteProfileButtonTapped) }, performEhProfileAction: { store.send(.performAction(action: $0, name: $1, set: $2)) } ) diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index 764129dff..661206a7f 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -1,5 +1,6 @@ import LocalAuthentication import AppModels +import Resources import ComposableArchitecture import AuthorizationClient import ApplicationClient @@ -14,13 +15,17 @@ public struct GeneralSettingReducer: Sendable { @CasePathable public enum Route: Sendable { case appActivityLogs - case clearCache - case removeCustomTranslations + } + + public enum Dialog: Equatable, Sendable { + case confirmClearCache + case confirmRemoveCustomTranslations } @ObservableState public struct State: Equatable, Sendable { public var route: Route? + @Presents public var confirmationDialog: ConfirmationDialogState? public var loadingState: LoadingState = .idle public var diskImageCacheSize = "0 KB" @@ -32,10 +37,13 @@ public struct GeneralSettingReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) + case confirmationDialog(PresentationAction) case clearSubStates case onTranslationsFilePicked(URL) + case removeCustomTranslationsButtonTapped case onRemoveCustomTranslations + case clearImageCachesButtonTapped case clearWebImageCache case checkPasscodeSetting case navigateToSystemSetting @@ -67,6 +75,45 @@ public struct GeneralSettingReducer: Sendable { state.route = route return route == nil ? .send(.clearSubStates) : .none + case .removeCustomTranslationsButtonTapped: + state.confirmationDialog = ConfirmationDialogState { + TextState("") + } actions: { + ButtonState(role: .destructive, action: .confirmRemoveCustomTranslations) { + TextState(L10n.Localizable.ConfirmationDialog.Button.remove) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState(L10n.Localizable.ConfirmationDialog.Title.removeCustomTranslations) + } + return .none + + case .clearImageCachesButtonTapped: + state.confirmationDialog = ConfirmationDialogState { + TextState("") + } actions: { + ButtonState(role: .destructive, action: .confirmClearCache) { + TextState(L10n.Localizable.ConfirmationDialog.Button.clear) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState(L10n.Localizable.ConfirmationDialog.Title.clear) + } + return .none + + case .confirmationDialog(.presented(.confirmRemoveCustomTranslations)): + return .send(.onRemoveCustomTranslations) + + case .confirmationDialog(.presented(.confirmClearCache)): + return .send(.clearWebImageCache) + + case .confirmationDialog: + return .none + case .clearSubStates: // The activity-logs pump is app-wide and always alive; never reset it on navigation. return .none @@ -112,6 +159,7 @@ public struct GeneralSettingReducer: Sendable { return .none } } + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) Scope(state: \.appActivityLogsState, action: \.appActivityLogs, child: AppActivityLogsReducer.init) } diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index efb32d320..8ee3874dd 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -104,19 +104,8 @@ struct GeneralSettingView: View { if tagTranslatorHasCustomTranslations { Button( L10n.Localizable.GeneralSettingView.Button.removeCustomTranslations, - role: .destructive, action: { store.send(.setNavigation(.removeCustomTranslations)) } + role: .destructive, action: { store.send(.removeCustomTranslationsButtonTapped) } ) - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.removeCustomTranslations, - unwrapping: $store.route, - case: \.removeCustomTranslations - ) { - Button(L10n.Localizable.ConfirmationDialog.Button.remove, role: .destructive) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.send(.onRemoveCustomTranslations) - } - } - } } } Section(L10n.Localizable.GeneralSettingView.Section.Title.navigation) { @@ -155,7 +144,7 @@ struct GeneralSettingView: View { } Section(L10n.Localizable.GeneralSettingView.Section.Title.caches) { Button { - store.send(.setNavigation(.clearCache)) + store.send(.clearImageCachesButtonTapped) } label: { HStack { Text(L10n.Localizable.GeneralSettingView.Button.clearImageCaches) @@ -164,17 +153,11 @@ struct GeneralSettingView: View { } .foregroundColor(.primary) } - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.clear, - unwrapping: $store.route, - case: \.clearCache - ) { - Button(L10n.Localizable.ConfirmationDialog.Button.clear, role: .destructive) { - store.send(.clearWebImageCache) - } - } } } + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) .animation(.default, value: tagTranslatorHasCustomTranslations) .animation(.default, value: tagTranslatorLoadingState) .animation(.default, value: enablesTagsExtension) diff --git a/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift b/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift index b6d436298..69517f148 100644 --- a/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift +++ b/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift @@ -26,40 +26,6 @@ extension NavigationLink { } extension View { - public func confirmationDialog( - message: String, - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath, - @ViewBuilder actions: @escaping (Case) -> A - ) -> some View { - self.confirmationDialog( - item: `enum`.case(caseKeyPath), - titleVisibility: .hidden, - title: { _ in Text("") }, - actions: actions, - message: { _ in Text(message) } - ) - } - public func confirmationDialog( - message: String, - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath, - matching case: Case, - @ViewBuilder actions: @escaping (Case) -> A - ) -> some View { - self.confirmationDialog( - item: { - let unwrapping = `enum`.case(caseKeyPath) - let isMatched = `case` == unwrapping.wrappedValue - return isMatched ? unwrapping : .constant(nil) - }(), - titleVisibility: .hidden, - title: { _ in Text("") }, - actions: actions, - message: { _ in Text(message) } - ) - } - public func sheet( unwrapping enum: Binding, case caseKeyPath: CaseKeyPath, From 1bc48180f43e9d49d69a45ad2df570371ed3e2f4 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 20:01:05 +0800 Subject: [PATCH 398/614] Adopt `@Presents` for Search modal sheets --- .../FiltersFeature/FiltersReducer.swift | 4 +- .../Sources/FiltersFeature/FiltersView.swift | 2 +- .../QuickSearchFeature/QuickSearchView.swift | 2 +- .../Sources/SearchFeature/SearchReducer.swift | 49 ++++++++++--------- .../SearchFeature/SearchRootReducer.swift | 45 ++++++++++------- .../SearchFeature/SearchRootView.swift | 26 +++++----- .../Sources/SearchFeature/SearchView.swift | 22 +++++---- 7 files changed, 83 insertions(+), 67 deletions(-) diff --git a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift index cd39363c9..ee6678508 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift @@ -9,13 +9,13 @@ public struct FiltersReducer: Sendable { case confirmReset } - public enum FocusedBound { + public enum FocusedBound: Sendable { case lower case upper } @ObservableState - public struct State: Equatable { + public struct State: Equatable, Sendable { @Presents public var confirmationDialog: ConfirmationDialogState? public var filterRange: FilterRange = .search public var focusedBound: FocusedBound? diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index 2b5ddcaae..b317485d6 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -27,7 +27,7 @@ public struct FiltersView: View { // MARK: FilterView public var body: some View { - NavigationView { + NavigationStack { Form { BasicSection( filter: filter, filterRange: $store.filterRange, diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index 99c614bc7..ce0dbf3ff 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -18,7 +18,7 @@ public struct QuickSearchView: View { } public var body: some View { - NavigationView { + NavigationStack { ZStack { List { ForEach(store.quickSearchWords) { word in diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index cc4872cc4..aee0d2677 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -17,11 +17,15 @@ import ComposableArchitectureExt public struct SearchReducer: Sendable { @CasePathable public enum Route: Equatable, Sendable { - case filters(EquatableVoid = .init()) - case quickSearch(EquatableVoid = .init()) case detail(String) } + @Reducer + public enum Destination { + case filters(FiltersReducer) + case quickSearch(QuickSearchReducer) + } + private enum CancelID: CaseIterable { case fetchGalleries, fetchMoreGalleries, observeDownloads, fetchDateSeekGalleries } @@ -29,6 +33,7 @@ public struct SearchReducer: Sendable { @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var destination: Destination.State? public var keyword = "" public var lastKeyword = "" @@ -40,9 +45,7 @@ public struct SearchReducer: Sendable { public var downloadBadges = [String: DownloadBadge]() public var dateSeek = DateSeekReducer.State() - public var filtersState = FiltersReducer.State() public var detailState: Heap - public var quickSearchState = QuickSearchReducer.State() public init() { detailState = .init(.init()) @@ -62,6 +65,9 @@ public struct SearchReducer: Sendable { case onAppear case setNavigation(Route?) case clearSubStates + case filtersButtonTapped + case quickSearchButtonTapped + case destination(PresentationAction) case teardown case fetchGalleries(String? = nil) @@ -74,8 +80,6 @@ public struct SearchReducer: Sendable { case dateSeek(DateSeekReducer.Action) case detail(DetailReducer.Action) - case filters(FiltersReducer.Action) - case quickSearch(QuickSearchReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -110,12 +114,18 @@ public struct SearchReducer: Sendable { case .clearSubStates: state.detailState.wrappedValue = .init() - state.filtersState = .init() - state.quickSearchState = .init() - return .merge( - .send(.detail(.teardown)), - .send(.quickSearch(.teardown)) - ) + return .send(.detail(.teardown)) + + case .filtersButtonTapped: + state.destination = .filters(FiltersReducer.State()) + return .none + + case .quickSearchButtonTapped: + state.destination = .quickSearch(QuickSearchReducer.State()) + return .none + + case .destination: + return .none case .teardown: return .merge(CancelID.allCases.map(Effect.cancel(id:))) @@ -243,28 +253,23 @@ public struct SearchReducer: Sendable { case .detail: return .none - - case .filters: - return .none - - case .quickSearch: - return .none } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.quickSearch, hapticsClient: hapticsClient ) .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.filters, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) Scope(state: \.dateSeek, action: \.dateSeek, child: DateSeekReducer.init) - Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) - Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } + +extension SearchReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift index e4740d07c..e12087b26 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -14,14 +14,19 @@ public struct SearchRootReducer: Sendable { @CasePathable public enum Route: Equatable, Sendable { case search - case filters(EquatableVoid = .init()) - case quickSearch(EquatableVoid = .init()) case detail(String) } + @Reducer + public enum Destination { + case filters(FiltersReducer) + case quickSearch(QuickSearchReducer) + } + @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var destination: Destination.State? public var keyword = "" public var historyGalleries = [Gallery]() @@ -29,8 +34,6 @@ public struct SearchRootReducer: Sendable { public var quickSearchWords = [QuickSearchWord]() public var searchState = SearchReducer.State() - public var filtersState = FiltersReducer.State() - public var quickSearchState = QuickSearchReducer.State() public var detailState: Heap public init() { @@ -73,6 +76,9 @@ public struct SearchRootReducer: Sendable { case setNavigation(Route?) case setKeyword(String) case clearSubStates + case filtersButtonTapped + case quickSearchButtonTapped + case destination(PresentationAction) case syncHistoryKeywords case fetchDatabaseInfos @@ -83,8 +89,6 @@ public struct SearchRootReducer: Sendable { case fetchHistoryGalleriesDone([Gallery]) case search(SearchReducer.Action) - case filters(FiltersReducer.Action) - case quickSearch(QuickSearchReducer.Action) case detail(DetailReducer.Action) } @@ -125,14 +129,22 @@ public struct SearchRootReducer: Sendable { case .clearSubStates: state.searchState = .init() state.detailState.wrappedValue = .init() - state.filtersState = .init() - state.quickSearchState = .init() return .merge( .send(.search(.teardown)), - .send(.quickSearch(.teardown)), .send(.detail(.teardown)) ) + case .filtersButtonTapped: + state.destination = .filters(FiltersReducer.State()) + return .none + + case .quickSearchButtonTapped: + state.destination = .quickSearch(QuickSearchReducer.State()) + return .none + + case .destination: + return .none + case .syncHistoryKeywords: return .run { [historyKeywords = state.historyKeywords] _ in await databaseClient.updateHistoryKeywords(historyKeywords) @@ -178,30 +190,25 @@ public struct SearchRootReducer: Sendable { case .search: return .none - case .filters: - return .none - - case .quickSearch: - return .none - case .detail: return .none } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.quickSearch, hapticsClient: hapticsClient ) .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.filters, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) Scope(state: \.searchState, action: \.search, child: SearchReducer.init) - Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) - Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } + +extension SearchRootReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index 79e9328b8..aaf4e263e 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -36,7 +36,7 @@ public struct SearchRootView: View { historyGalleries: store.historyGalleries, quickSearchWords: store.quickSearchWords, navigateGalleryAction: { store.send(.setNavigation(.detail($0))) }, - navigateQuickSearchAction: { store.send(.setNavigation(.quickSearch())) }, + navigateQuickSearchAction: { store.send(.quickSearchButtonTapped) }, searchKeywordAction: { keyword in store.send(.setKeyword(keyword)) store.send(.setNavigation(.search)) @@ -44,18 +44,20 @@ public struct SearchRootView: View { removeKeywordAction: { store.send(.removeHistoryKeyword($0)) } ) } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.setKeyword(keyword)) + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.setKeyword(keyword)) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - store.send(.setNavigation(.search)) + self.store.send(.setNavigation(.search)) } } .accentColor(setting.accentColor) @@ -110,10 +112,10 @@ public struct SearchRootView: View { CustomToolbarItem(tint: .primary) { ToolbarFeaturesMenu(symbolRenderingMode: .hierarchical) { FiltersButton { - store.send(.setNavigation(.filters())) + store.send(.filtersButtonTapped) } QuickSearchButton { - store.send(.setNavigation(.quickSearch())) + store.send(.quickSearchButtonTapped) } } } diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift index f6cc9e6b4..60078c0ba 100644 --- a/AppPackage/Sources/SearchFeature/SearchView.swift +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -47,18 +47,20 @@ struct SearchView: View { }, downloadBadges: store.downloadBadges ) - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.fetchGalleries(keyword)) + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.fetchGalleries(keyword)) } .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in @@ -127,10 +129,10 @@ struct SearchView: View { store.send(.dateSeek(.present(navigation))) } FiltersButton { - store.send(.setNavigation(.filters())) + store.send(.filtersButtonTapped) } QuickSearchButton { - store.send(.setNavigation(.quickSearch())) + store.send(.quickSearchButtonTapped) } } } From 477e6360be4e3d314fa410c80c622a553eab8414 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 20:12:08 +0800 Subject: [PATCH 399/614] Adopt `@Presents` for Setting web-view sheets --- .../AccountSettingReducer.swift | 21 +++++++++++++++- .../AccountSetting/AccountSettingView.swift | 6 ++--- .../EhSetting/EhSettingReducer.swift | 23 +++++++++++------ .../EhSetting/EhSettingView.swift | 6 ++--- .../SettingFeature/Login/LoginReducer.swift | 25 +++++++++++++------ .../SettingFeature/Login/LoginView.swift | 6 ++--- 6 files changed, 62 insertions(+), 25 deletions(-) diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index 9f9ce365a..1d63666d9 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -15,6 +15,11 @@ public struct AccountSettingReducer: Sendable { case hud case login case ehSetting + } + + @Reducer + public enum Destination { + @ReducerCaseIgnored case webView(URL) } @@ -25,6 +30,7 @@ public struct AccountSettingReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { public var route: Route? + @Presents public var destination: Destination.State? @Presents public var confirmationDialog: ConfirmationDialogState? public var ehCookiesState: CookiesState = .empty(.ehentai) public var exCookiesState: CookiesState = .empty(.exhentai) @@ -37,6 +43,8 @@ public struct AccountSettingReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) + case destination(PresentationAction) + case presentWebView(URL) case confirmationDialog(PresentationAction) case logoutButtonTapped case onLogoutConfirmButtonTapped @@ -74,6 +82,13 @@ public struct AccountSettingReducer: Sendable { state.route = route return route == nil ? .send(.clearSubStates) : .none + case .destination: + return .none + + case .presentWebView(let url): + state.destination = .webView(url) + return .none + case .logoutButtonTapped: state.confirmationDialog = ConfirmationDialogState { TextState("") @@ -130,13 +145,17 @@ public struct AccountSettingReducer: Sendable { } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.webView, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) .ifLet(\.$confirmationDialog, action: \.confirmationDialog) Scope(state: \.loginState, action: \.login, child: LoginReducer.init) Scope(state: \.ehSettingState, action: \.ehSetting, child: EhSettingReducer.init) } } + +extension AccountSettingReducer.Destination.State: Equatable, Sendable {} +extension AccountSettingReducer.Destination.Action: Equatable, Sendable {} diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index 16ab3da09..4988b3f66 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -42,7 +42,7 @@ struct AccountSettingView: View { loginAction: { store.send(.setNavigation(.login)) }, logoutDialogAction: { store.send(.logoutButtonTapped) }, configureAccountAction: { store.send(.setNavigation(.ehSetting)) }, - manageTagsAction: { store.send(.setNavigation(.webView(Defaults.URL.myTags))) } + manageTagsAction: { store.send(.presentWebView(Defaults.URL.myTags)) } ) } CookieSection( @@ -59,8 +59,8 @@ struct AccountSettingView: View { .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) - .sheet(item: $store.route.sending(\.setNavigation).webView, id: \.absoluteString) { url in - WebView(url: url) + .sheet(item: $store.destination.webView, id: \.absoluteString) { url in + WebView(url: url.wrappedValue) .ignoresSafeArea(edges: .bottom) .autoBlur(radius: blurRadius) } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index b97279d75..5f0097b10 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -11,8 +11,9 @@ import CookieClient @Reducer public struct EhSettingReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { + @Reducer + public enum Destination { + @ReducerCaseIgnored case webView(URL) } @@ -26,7 +27,7 @@ public struct EhSettingReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - public var route: Route? + @Presents public var destination: Destination.State? @Presents public var confirmationDialog: ConfirmationDialogState? public var editingProfileName = "" public var ehSetting: EhSetting? @@ -45,7 +46,8 @@ public struct EhSettingReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) - case setNavigation(Route?) + case destination(PresentationAction) + case presentWebView(URL) case confirmationDialog(PresentationAction) case deleteProfileButtonTapped case setKeyboardHidden @@ -74,8 +76,11 @@ public struct EhSettingReducer: Sendable { case .binding: return .none - case .setNavigation(let route): - state.route = route + case .destination: + return .none + + case .presentWebView(let url): + state.destination = .webView(url) return .none case .deleteProfileButtonTapped: @@ -178,10 +183,14 @@ public struct EhSettingReducer: Sendable { } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.webView, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) .ifLet(\.$confirmationDialog, action: \.confirmationDialog) } } + +extension EhSettingReducer.Destination.State: Equatable, Sendable {} +extension EhSettingReducer.Destination.Action: Equatable, Sendable {} diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift index 50c880692..41ab1408d 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift @@ -48,8 +48,8 @@ struct EhSettingView: View { store.send(.setDefaultProfile(profileSet)) } } - .sheet(item: $store.route.sending(\.setNavigation).webView, id: \.absoluteString) { url in - WebView(url: url) + .sheet(item: $store.destination.webView, id: \.absoluteString) { url in + WebView(url: url.wrappedValue) .ignoresSafeArea(edges: .bottom) .autoBlur(radius: blurRadius) } @@ -104,7 +104,7 @@ struct EhSettingView: View { Group { ToolbarItem(placement: .navigationBarTrailing) { Button { - store.send(.setNavigation(.webView(Defaults.URL.uConfig))) + store.send(.presentWebView(Defaults.URL.uConfig)) } label: { Image(systemSymbol: .globe) } diff --git a/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift index d4f66601e..fa66d60f9 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift @@ -15,8 +15,9 @@ public struct LoginReducer: Sendable { case login } - @CasePathable - public enum Route: Equatable, Sendable { + @Reducer + public enum Destination { + @ReducerCaseIgnored case webView(URL) } @@ -27,7 +28,7 @@ public struct LoginReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - public var route: Route? + @Presents public var destination: Destination.State? public var focusedField: FocusedField? public var username = "" public var password = "" @@ -44,7 +45,8 @@ public struct LoginReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) - case setNavigation(Route?) + case destination(PresentationAction) + case presentWebView(URL) case teardown case login @@ -64,8 +66,11 @@ public struct LoginReducer: Sendable { case .binding: return .none - case .setNavigation(let route): - state.route = route + case .destination: + return .none + + case .presentWebView(let url): + state.destination = .webView(url) return .none case .teardown: @@ -85,7 +90,7 @@ public struct LoginReducer: Sendable { ) case .loginDone(let result): - state.route = nil + state.destination = nil var effects = [Effect]() if cookieClient.didLogin { state.loginState = .idle @@ -107,9 +112,13 @@ public struct LoginReducer: Sendable { } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.webView, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) } } + +extension LoginReducer.Destination.State: Equatable, Sendable {} +extension LoginReducer.Destination.Action: Equatable, Sendable {} diff --git a/AppPackage/Sources/SettingFeature/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift index d2ab0e995..96c18e61f 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginView.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginView.swift @@ -68,8 +68,8 @@ struct LoginView: View { } } .synchronize($store.focusedField, $focusedField) - .sheet(item: $store.route.sending(\.setNavigation).webView, id: \.absoluteString) { route in - WebView(url: route.wrappedValue) { + .sheet(item: $store.destination.webView, id: \.absoluteString) { url in + WebView(url: url.wrappedValue) { store.send(.loginDone(.success(nil))) } .ignoresSafeArea(edges: .bottom) @@ -93,7 +93,7 @@ struct LoginView: View { private func toolbar() -> some ToolbarContent { ToolbarItem(placement: .navigationBarTrailing) { Button { - store.send(.setNavigation(.webView(Defaults.URL.webLogin))) + store.send(.presentWebView(Defaults.URL.webLogin)) } label: { Image(systemSymbol: .globe) } From 2d65b2c729fd06b1ff78046a2bf88cbb20650293 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 20:23:01 +0800 Subject: [PATCH 400/614] Adopt `@Presents` for list-feature modal sheets --- .../FavoritesFeature/FavoritesReducer.swift | 27 +++++++--- .../FavoritesFeature/FavoritesView.swift | 14 +++--- .../Frontpage/FrontpageReducer.swift | 28 +++++++---- .../HomeFeature/Frontpage/FrontpageView.swift | 8 +-- .../HomeFeature/Popular/PopularReducer.swift | 28 +++++++---- .../HomeFeature/Popular/PopularView.swift | 8 +-- .../HomeFeature/Watched/WatchedReducer.swift | 49 ++++++++++--------- .../HomeFeature/Watched/WatchedView.swift | 22 +++++---- 8 files changed, 113 insertions(+), 71 deletions(-) diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index 9f55a1728..62aa4a5bb 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -21,13 +21,18 @@ public struct FavoritesReducer: Sendable { @CasePathable public enum Route: Equatable, Sendable { - case quickSearch(EquatableVoid = .init()) case detail(String) } + @Reducer + public enum Destination { + case quickSearch(QuickSearchReducer) + } + @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var destination: Destination.State? public var keyword = "" public var index = -1 @@ -58,7 +63,6 @@ public struct FavoritesReducer: Sendable { public var dateSeek = DateSeekReducer.State() public var detailState: Heap - public var quickSearchState = QuickSearchReducer.State() public init() { detailState = .init(.init()) @@ -79,6 +83,8 @@ public struct FavoritesReducer: Sendable { case setNavigation(Route?) case setFavoritesIndex(Int) case clearSubStates + case quickSearchButtonTapped + case destination(PresentationAction) case onNotLoginViewButtonTapped case fetchGalleries(String? = nil, FavoritesSortOrder? = nil) @@ -91,7 +97,6 @@ public struct FavoritesReducer: Sendable { case dateSeek(DateSeekReducer.Action) case detail(DetailReducer.Action) - case quickSearch(QuickSearchReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -127,6 +132,13 @@ public struct FavoritesReducer: Sendable { state.detailState.wrappedValue = .init() return .send(.detail(.teardown)) + case .quickSearchButtonTapped: + state.destination = .quickSearch(QuickSearchReducer.State()) + return .none + + case .destination: + return .none + case .onNotLoginViewButtonTapped: return .none @@ -262,19 +274,18 @@ public struct FavoritesReducer: Sendable { case .detail: return .none - - case .quickSearch: - return .none } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.quickSearch, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) Scope(state: \.dateSeek, action: \.dateSeek, child: DateSeekReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) - Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) } } + +extension FavoritesReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index e2cb5d403..dc5627307 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -58,12 +58,12 @@ public struct FavoritesView: View { NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) } } - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.fetchGalleries(keyword)) + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.fetchGalleries(keyword)) } .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) @@ -144,7 +144,7 @@ public struct FavoritesView: View { store.send(.dateSeek(.present(navigation))) } QuickSearchButton(hideText: true) { - store.send(.setNavigation(.quickSearch())) + store.send(.quickSearchButtonTapped) } } } diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index d75b4cc3b..3ad677ecf 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -15,10 +15,14 @@ import ComposableArchitectureExt public struct FrontpageReducer: Sendable { @CasePathable public enum Route: Equatable, Sendable { - case filters(EquatableVoid = .init()) case detail(String) } + @Reducer + public enum Destination { + case filters(FiltersReducer) + } + private enum CancelID: CaseIterable { case fetchGalleries, fetchMoreGalleries, fetchDateSeekGalleries } @@ -26,6 +30,7 @@ public struct FrontpageReducer: Sendable { @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var destination: Destination.State? public var keyword = "" var filteredGalleries: [Gallery] { @@ -39,7 +44,6 @@ public struct FrontpageReducer: Sendable { public var footerLoadingState: LoadingState = .idle public var dateSeek = DateSeekReducer.State() - public var filtersState = FiltersReducer.State() public var detailState: Heap public init() { @@ -59,6 +63,8 @@ public struct FrontpageReducer: Sendable { case binding(BindingAction) case setNavigation(Route?) case clearSubStates + case filtersButtonTapped + case destination(PresentationAction) case teardown case fetchGalleries @@ -68,7 +74,6 @@ public struct FrontpageReducer: Sendable { case performDateSeekDone(Result) case dateSeek(DateSeekReducer.Action) - case filters(FiltersReducer.Action) case detail(DetailReducer.Action) } @@ -94,9 +99,15 @@ public struct FrontpageReducer: Sendable { case .clearSubStates: state.detailState.wrappedValue = .init() - state.filtersState = .init() return .send(.detail(.teardown)) + case .filtersButtonTapped: + state.destination = .filters(FiltersReducer.State()) + return .none + + case .destination: + return .none + case .teardown: return .merge(CancelID.allCases.map(Effect.cancel(id:))) @@ -200,21 +211,20 @@ public struct FrontpageReducer: Sendable { case .dateSeek: return .none - case .filters: - return .none - case .detail: return .none } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.filters, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) Scope(state: \.dateSeek, action: \.dateSeek, child: DateSeekReducer.init) - Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } + +extension FrontpageReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 357a8f438..1ba4eec53 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -45,8 +45,10 @@ struct FrontpageView: View { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in @@ -104,7 +106,7 @@ struct FrontpageView: View { store.send(.dateSeek(.present(navigation))) } FiltersButton(hideText: true) { - store.send(.setNavigation(.filters())) + store.send(.filtersButtonTapped) } } } diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index 7fd7164d0..7c12f865b 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -13,10 +13,14 @@ import ComposableArchitectureExt public struct PopularReducer: Sendable { @dynamicMemberLookup @CasePathable public enum Route: Equatable, Sendable { - case filters(EquatableVoid = .unique) case detail(String) } + @Reducer + public enum Destination { + case filters(FiltersReducer) + } + private enum CancelID { case fetchGalleries } @@ -24,6 +28,7 @@ public struct PopularReducer: Sendable { @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var destination: Destination.State? public var keyword = "" var filteredGalleries: [Gallery] { @@ -33,7 +38,6 @@ public struct PopularReducer: Sendable { public var galleries = [Gallery]() public var loadingState: LoadingState = .idle - public var filtersState = FiltersReducer.State() public var detailState: Heap public init() { @@ -45,12 +49,13 @@ public struct PopularReducer: Sendable { case binding(BindingAction) case setNavigation(Route?) case clearSubStates + case filtersButtonTapped + case destination(PresentationAction) case teardown case fetchGalleries case fetchGalleriesDone(Result<[Gallery], AppError>) - case filters(FiltersReducer.Action) case detail(DetailReducer.Action) } @@ -76,9 +81,15 @@ public struct PopularReducer: Sendable { case .clearSubStates: state.detailState.wrappedValue = .init() - state.filtersState = .init() return .send(.detail(.teardown)) + case .filtersButtonTapped: + state.destination = .filters(FiltersReducer.State()) + return .none + + case .destination: + return .none + case .teardown: return .cancel(id: CancelID.fetchGalleries) @@ -107,20 +118,19 @@ public struct PopularReducer: Sendable { } return .none - case .filters: - return .none - case .detail: return .none } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.filters, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) - Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } + +extension PopularReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index f8de9d966..887f79f17 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -41,8 +41,10 @@ struct PopularView: View { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) @@ -88,7 +90,7 @@ struct PopularView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { FiltersButton(hideText: true) { - store.send(.setNavigation(.filters())) + store.send(.filtersButtonTapped) } } } diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index e881d6b14..5153ea649 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -16,11 +16,15 @@ import ComposableArchitectureExt public struct WatchedReducer: Sendable { @CasePathable public enum Route: Equatable, Sendable { - case filters(EquatableVoid = .init()) - case quickSearch(EquatableVoid = .init()) case detail(String) } + @Reducer + public enum Destination { + case filters(FiltersReducer) + case quickSearch(QuickSearchReducer) + } + private enum CancelID: CaseIterable { case fetchGalleries, fetchMoreGalleries, observeDownloads, fetchDateSeekGalleries } @@ -28,6 +32,7 @@ public struct WatchedReducer: Sendable { @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var destination: Destination.State? public var keyword = "" public var galleries = [Gallery]() @@ -38,8 +43,6 @@ public struct WatchedReducer: Sendable { public var downloadBadges = [String: DownloadBadge]() public var dateSeek = DateSeekReducer.State() - public var filtersState = FiltersReducer.State() - public var quickSearchState = QuickSearchReducer.State() public var detailState: Heap public init() { @@ -60,6 +63,9 @@ public struct WatchedReducer: Sendable { case onAppear case setNavigation(Route?) case clearSubStates + case filtersButtonTapped + case quickSearchButtonTapped + case destination(PresentationAction) case onNotLoginViewButtonTapped case teardown @@ -72,9 +78,7 @@ public struct WatchedReducer: Sendable { case performDateSeekDone(Result) case dateSeek(DateSeekReducer.Action) - case filters(FiltersReducer.Action) case detail(DetailReducer.Action) - case quickSearch(QuickSearchReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -103,12 +107,18 @@ public struct WatchedReducer: Sendable { case .clearSubStates: state.detailState.wrappedValue = .init() - state.filtersState = .init() - state.quickSearchState = .init() - return .merge( - .send(.detail(.teardown)), - .send(.quickSearch(.teardown)) - ) + return .send(.detail(.teardown)) + + case .filtersButtonTapped: + state.destination = .filters(FiltersReducer.State()) + return .none + + case .quickSearchButtonTapped: + state.destination = .quickSearch(QuickSearchReducer.State()) + return .none + + case .destination: + return .none case .onNotLoginViewButtonTapped: return .none @@ -236,30 +246,25 @@ public struct WatchedReducer: Sendable { case .dateSeek: return .none - case .quickSearch: - return .none - - case .filters: - return .none - case .detail: return .none } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.quickSearch, hapticsClient: hapticsClient ) .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.filters, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) Scope(state: \.dateSeek, action: \.dateSeek, child: DateSeekReducer.init) - Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) - Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } + +extension WatchedReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index e4695ca9c..a8b224ff3 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -52,18 +52,20 @@ struct WatchedView: View { NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) } } - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.fetchGalleries(keyword)) + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.fetchGalleries(keyword)) } .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in @@ -132,10 +134,10 @@ struct WatchedView: View { store.send(.dateSeek(.present(navigation))) } FiltersButton { - store.send(.setNavigation(.filters())) + store.send(.filtersButtonTapped) } QuickSearchButton { - store.send(.setNavigation(.quickSearch())) + store.send(.quickSearchButtonTapped) } } } From 896593de933a6004fb0eb3ac45c88c28da9bc407 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 20:53:57 +0800 Subject: [PATCH 401/614] Adopt `@Presents` for Reading/Downloads modals --- .../DownloadsFeature/DownloadsReducer.swift | 79 +++++++++---------- .../DownloadsFeature/DownloadsView.swift | 35 ++++---- .../ReadingFeature/ReadingReducer+Body.swift | 18 ++++- .../ReadingFeature/ReadingReducer.swift | 15 +++- .../Sources/ReadingFeature/ReadingView.swift | 15 ++-- .../DownloadsReducerActionTests.swift | 3 +- .../DownloadsReducerReadingDismissTests.swift | 11 ++- 7 files changed, 99 insertions(+), 77 deletions(-) diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 744014b13..0e87cc92d 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -12,10 +12,14 @@ import ComposableArchitectureExt public struct DownloadsReducer: Sendable { @CasePathable public enum Route: Equatable, Sendable { - case inspector(String) case detail(String) - case reading(String) - case folderManager(EquatableVoid = .init()) + } + + @Reducer + public enum Destination { + case inspector(DownloadInspectorReducer) + case reading(ReadingReducer) + case folderManager(FolderManagerReducer) } public enum Alert: Equatable, Sendable { @@ -34,6 +38,7 @@ public struct DownloadsReducer: Sendable { @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var destination: Destination.State? @Presents public var alert: AlertState? @Presents public var confirmationDialog: ConfirmationDialogState? public var keyword = "" @@ -44,9 +49,6 @@ public struct DownloadsReducer: Sendable { public var hasLoadedInitialDownloads = false public var detailState: Heap - public var readingState = ReadingReducer.State() - public var inspectorState = DownloadInspectorReducer.State() - public var folderManagerState = FolderManagerReducer.State() public var readingRequestID = UUID() public init() { @@ -67,6 +69,9 @@ public struct DownloadsReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) + case destination(PresentationAction) + case inspectorButtonTapped(String) + case folderManagerButtonTapped case alert(PresentationAction) case confirmationDialog(PresentationAction) case deleteDownloadButtonTapped(DownloadedGallery) @@ -95,9 +100,6 @@ public struct DownloadsReducer: Sendable { case deleteDownloadDone(Result) case detail(DetailReducer.Action) - case reading(ReadingReducer.Action) - case inspector(DownloadInspectorReducer.Action) - case folderManager(FolderManagerReducer.Action) } @Dependency(\.downloadClient) private var downloadClient @@ -124,11 +126,17 @@ public struct DownloadsReducer: Sendable { detailState.gallery = download.gallery _ = DetailReducer().applyDownload(download, state: &detailState) state.detailState.wrappedValue = detailState - } else if case .inspector(let gid) = route { - state.inspectorState = .init(gid: gid) } return route == nil ? .send(.clearSubStates) : .none + case .inspectorButtonTapped(let gid): + state.destination = .inspector(.init(gid: gid)) + return .none + + case .folderManagerButtonTapped: + state.destination = .folderManager(.init()) + return .none + case .deleteDownloadButtonTapped(let download): state.alert = AlertState { TextState(L10n.Localizable.DownloadsView.Dialog.Title.deleteDownload) @@ -178,15 +186,7 @@ public struct DownloadsReducer: Sendable { case .clearSubStates: state.detailState.wrappedValue = .init() - state.readingState = .init() - state.inspectorState = .init() - state.folderManagerState = .init() - return .merge( - .send(.detail(.teardown)), - .send(.reading(.teardown)), - .send(.inspector(.teardown)), - .send(.folderManager(.teardown)) - ) + return .send(.detail(.teardown)) case .onAppear: guard !state.hasLoadedInitialDownloads else { return .send(.fetchFolders) } @@ -265,10 +265,6 @@ public struct DownloadsReducer: Sendable { case .openReading(let gid): let requestID = UUID() state.readingRequestID = requestID - state.readingState = .init(contentSource: .remote) - if let download = state.downloads.first(where: { $0.gid == gid }) { - state.readingState.applyDownloadFallback(download) - } return .run { send in await send( .openReadingDone( @@ -283,10 +279,17 @@ public struct DownloadsReducer: Sendable { case .openReadingDone(let requestID, let gid, let result): guard state.readingRequestID == requestID else { return .none } + var readingState: ReadingReducer.State if case .success(let (download, manifest)) = result { - state.readingState = .init(contentSource: .local(download, manifest)) + readingState = .init(contentSource: .local(download, manifest)) + readingState.gallery = download.gallery + } else { + readingState = .init(contentSource: .remote) + if let download = state.downloads.first(where: { $0.gid == gid }) { + readingState.applyDownloadFallback(download) + } } - state.route = .reading(gid) + state.destination = .reading(readingState) return .none case .toggleDownloadPause(let gid): @@ -333,34 +336,28 @@ public struct DownloadsReducer: Sendable { case .detail: return .none - case .reading(.onPerformDismiss): - return .send(.setNavigation(nil)) - - case .reading: - return .none + case .destination(.presented(.reading(.onPerformDismiss))): + return .send(.destination(.dismiss)) - case .inspector: - return .none - - case .folderManager(.createFolderDone), - .folderManager(.renameFolderDone), - .folderManager(.deleteFolderDone): + case .destination(.presented(.folderManager(.createFolderDone))), + .destination(.presented(.folderManager(.renameFolderDone))), + .destination(.presented(.folderManager(.deleteFolderDone))): return .send(.fetchFolders) - case .folderManager: + case .destination: return .none } } + .ifLet(\.$destination, action: \.destination) .ifLet(\.$alert, action: \.alert) .ifLet(\.$confirmationDialog, action: \.confirmationDialog) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) - Scope(state: \.readingState, action: \.reading, child: ReadingReducer.init) - Scope(state: \.inspectorState, action: \.inspector, child: DownloadInspectorReducer.init) - Scope(state: \.folderManagerState, action: \.folderManager, child: FolderManagerReducer.init) } } +extension DownloadsReducer.Destination.State: Equatable {} + private extension ReadingReducer.State { mutating func applyDownloadFallback(_ download: DownloadedGallery) { gallery = download.gallery diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index f6fb93586..74d4cf673 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -78,29 +78,32 @@ public struct DownloadsView: View { placement: .navigationBarDrawer(displayMode: .automatic), prompt: L10n.Localizable.DownloadsView.Search.Prompt.downloads ) - .sheet(item: $store.route.sending(\.setNavigation).inspector, id: \.self) { _ in - NavigationView { + .sheet( + item: $store.scope(state: \.destination?.inspector, action: \.destination.inspector) + ) { store in + NavigationStack { DownloadInspectorView( - store: store.scope(state: \.inspectorState, action: \.inspector), + store: store, setting: setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } .autoBlur(radius: blurRadius) - .navigationViewStyle(.stack) } - .sheet(item: $store.route.sending(\.setNavigation).folderManager) { _ in - FolderManagerView( - store: store.scope(state: \.folderManagerState, action: \.folderManager) - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) + .sheet( + item: $store.scope(state: \.destination?.folderManager, action: \.destination.folderManager) + ) { store in + FolderManagerView(store: store) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) } - .fullScreenCover(item: $store.route.sending(\.setNavigation).reading, id: \.self) { route in + .fullScreenCover( + item: $store.scope(state: \.destination?.reading, action: \.destination.reading) + ) { store in ReadingView( - store: store.scope(state: \.readingState, action: \.reading), - gid: route.wrappedValue, + store: store, + gid: store.gallery.id, setting: $setting, blurRadius: blurRadius ) @@ -147,7 +150,7 @@ private extension DownloadsView { } .swipeActions(edge: .leading, allowsFullSwipe: false) { Button { - store.send(.setNavigation(.inspector(download.gid))) + store.send(.inspectorButtonTapped(download.gid)) } label: { Label( L10n.Localizable.DownloadsView.Swipe.Button.pages, @@ -220,7 +223,7 @@ private extension DownloadsView { } Button { - store.send(.setNavigation(.inspector(download.gid))) + store.send(.inspectorButtonTapped(download.gid)) } label: { Label( L10n.Localizable.DownloadsView.Swipe.Button.pages, @@ -325,7 +328,7 @@ private extension DownloadsView { Menu { Section { Button { - store.send(.setNavigation(.folderManager())) + store.send(.folderManagerButtonTapped) } label: { Label( L10n.Localizable.DownloadsView.Menu.Button.manageFolders, diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index 2ad2782d1..e7745b92a 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -37,15 +37,16 @@ extension ReadingReducer { imageFetchReducer } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.readingSetting, hapticsClient: hapticsClient ) .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.share, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) } var lifecycleReducer: some ReducerOf { @@ -58,6 +59,17 @@ extension ReadingReducer { state.route = route return .none + case .destination: + return .none + + case .presentShare(let shareItem): + state.destination = .share(shareItem) + return .none + + case .presentReadingSetting: + state.destination = .readingSetting(.init()) + return .none + case .toggleShowsPanel: state.showsPanel.toggle() return .none @@ -188,7 +200,7 @@ extension ReadingReducer { let shareItem: ShareItem = asset.isAnimated ? .data(asset.data) : .image(asset.image) - return .send(.setNavigation(.share(.init(value: shareItem)))) + return .send(.presentShare(.init(value: shareItem))) } } else { state.hudConfig = .error() diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index b0eaf0462..e474ab9a7 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -19,8 +19,14 @@ public struct ReadingReducer: Sendable { @CasePathable public enum Route: Equatable, Sendable { case hud + } + + @Reducer + public enum Destination { + @ReducerCaseIgnored case share(IdentifiableBox) - case readingSetting(EquatableVoid = .init()) + @ReducerCaseIgnored + case readingSetting(EquatableVoid) } public enum ShareItem: Equatable, Sendable { @@ -43,6 +49,7 @@ public struct ReadingReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { public var route: Route? + @Presents public var destination: Destination.State? public var contentSource: ReadingContentSource = .remote public var gallery: Gallery = .empty public var language: Language? @@ -136,6 +143,9 @@ public struct ReadingReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) + case destination(PresentationAction) + case presentShare(IdentifiableBox) + case presentReadingSetting case toggleShowsPanel case setOrientationPortrait(Bool) @@ -203,3 +213,6 @@ public struct ReadingReducer: Sendable { public var body: some Reducer { makeBody() } } + +extension ReadingReducer.Destination.State: Equatable, Sendable {} +extension ReadingReducer.Destination.Action: Equatable, Sendable {} diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 6be7742d1..627074271 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -61,8 +61,8 @@ public struct ReadingView: View { @Bindable var bindablePageHandler = pageHandler return changeTriggers(content: { content }) - .sheet(item: $store.route.sending(\.setNavigation).readingSetting) { _ in - NavigationView { + .sheet(item: $store.destination.readingSetting, id: \.id) { _ in + NavigationStack { ReadingSettingView( readingDirection: $setting.readingDirection, prefetchLimit: $setting.prefetchLimit, @@ -75,7 +75,7 @@ public struct ReadingView: View { if !DeviceUtil.isPad && DeviceUtil.isLandscape { CustomToolbarItem(placement: .cancellationAction) { Button { - store.send(.setNavigation(nil)) + store.send(.destination(.dismiss)) } label: { Image(systemSymbol: .chevronDown) } @@ -86,9 +86,8 @@ public struct ReadingView: View { .accentColor(setting.accentColor) .tint(setting.accentColor) .autoBlur(radius: blurRadius) - .navigationViewStyle(.stack) } - .sheet(item: $store.route.sending(\.setNavigation).share) { shareItemBox in + .sheet(item: $store.destination.share, id: \.id) { shareItemBox in ActivityView(activityItems: [shareItemBox.wrappedValue.associatedValue]) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) @@ -170,7 +169,7 @@ public struct ReadingView: View { previewURLs: displayPreviewURLs, dismissGesture: controlPanelDismissGesture, dismissAction: { store.send(.onPerformDismiss) }, - navigateSettingAction: { store.send(.setNavigation(.readingSetting())) }, + navigateSettingAction: { store.send(.presentReadingSetting) }, reloadAllImagesAction: { store.send(.reloadAllWebImages) }, retryAllFailedImagesAction: { store.send(.retryAllFailedWebImages) }, fetchPreviewURLsAction: { store.send(.fetchPreviewURLs($0)) } @@ -222,8 +221,8 @@ public struct ReadingView: View { pageHandler.sliderValue = .init(newValue) } // AutoPlay - .onChange(of: store.route) { _, newValue in - if ![.hud, .none].contains(newValue) { + .onChange(of: store.destination != nil) { _, isPresented in + if isPresented { setAutoPlayPolocy(.off) } } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift index 8530e9355..a04ac9d7c 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift @@ -254,8 +254,7 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { await store.send(.openReading(download.gid)) await store.receive(\.openReadingDone) - #expect(store.state.route == .reading(download.gid)) - #expect(store.state.readingState.contentSource == .local(download, manifest)) + #expect(store.state.destination?.reading?.contentSource == .local(download, manifest)) } @MainActor diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift index fca5c8c32..147c7f734 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift @@ -9,10 +9,9 @@ import AppDelegateClient struct DownloadsReducerReadingDismissTests { @MainActor @Test - func readingDismissClearsRoute() async { - let gid = "135790" + func readingDismissClearsDestination() async { var initialState = DownloadsReducer.State() - initialState.route = .reading(gid) + initialState.destination = .reading(.init(contentSource: .remote)) let store = TestStore( initialState: initialState, @@ -25,9 +24,9 @@ struct DownloadsReducerReadingDismissTests { ) store.exhaustivity = .off - await store.send(.reading(.onPerformDismiss)) - await store.receive(\.setNavigation) + await store.send(.destination(.presented(.reading(.onPerformDismiss)))) + await store.receive(\.destination.dismiss) - #expect(store.state.route == nil) + #expect(store.state.destination == nil) } } From 516d867c64e377889dc63a6eb205e2ae337db915 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 21:07:58 +0800 Subject: [PATCH 402/614] Adopt `@Presents` for Detail sub-view modals --- .../Comments/CommentsReducer.swift | 20 ++++++- .../DetailFeature/Comments/CommentsView.swift | 14 ++--- .../Previews/PreviewsReducer.swift | 52 +++++++------------ .../DetailFeature/Previews/PreviewsView.swift | 8 +-- .../Torrents/TorrentsReducer.swift | 23 +++++++- .../DetailFeature/Torrents/TorrentsView.swift | 4 +- .../PreviewsReducerDownloadTests.swift | 14 ++--- 7 files changed, 80 insertions(+), 55 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index cfa0b72ef..db2d72ac6 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -17,6 +17,11 @@ public struct CommentsReducer: Sendable { public enum Route: Equatable, Sendable { case hud case detail(String) + } + + @Reducer + public enum Destination { + @ReducerCaseIgnored case postComment(String) } @@ -27,6 +32,7 @@ public struct CommentsReducer: Sendable { @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var destination: Destination.State? public var commentContent = "" public var postCommentFocused = false @@ -44,6 +50,8 @@ public struct CommentsReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) + case destination(PresentationAction) + case presentPostComment(String) case clearSubStates case clearScrollCommentID @@ -92,6 +100,13 @@ public struct CommentsReducer: Sendable { state.route = route return route == nil ? .send(.clearSubStates) : .none + case .destination: + return .none + + case .presentPostComment(let commentID): + state.destination = .postComment(commentID) + return .none + case .clearSubStates: state.detailState.wrappedValue = .init() state.commentContent = .init() @@ -268,9 +283,12 @@ public struct CommentsReducer: Sendable { } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.postComment, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) } } + +extension CommentsReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index c9097e32f..2bf17ee0b 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -72,7 +72,7 @@ struct CommentsView: View { if comment.editable { Button { store.send(.setCommentContent(comment.plainTextContent)) - store.send(.setNavigation(.postComment(comment.commentID))) + store.send(.presentPostComment(comment.commentID)) } label: { Image(systemSymbol: .squareAndPencil) } @@ -89,8 +89,8 @@ struct CommentsView: View { } } } - .sheet(item: $store.route.sending(\.setNavigation).postComment, id: \.self) { route in - let hasCommentID = !route.wrappedValue.isEmpty + .sheet(item: $store.destination.postComment, id: \.self) { commentID in + let hasCommentID = !commentID.wrappedValue.isEmpty PostCommentView( title: hasCommentID ? L10n.Localizable.PostCommentView.Title.editComment @@ -99,13 +99,13 @@ struct CommentsView: View { isFocused: $store.postCommentFocused, postAction: { if hasCommentID { - store.send(.postComment(galleryURL, route.wrappedValue)) + store.send(.postComment(galleryURL, commentID.wrappedValue)) } else { store.send(.postComment(galleryURL)) } - store.send(.setNavigation(nil)) + store.send(.destination(.dismiss)) }, - cancelAction: { store.send(.setNavigation(nil)) }, + cancelAction: { store.send(.destination(.dismiss)) }, onAppearAction: { store.send(.onPostCommentAppear) } ) .accentColor(setting.accentColor) @@ -128,7 +128,7 @@ struct CommentsView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { Button { - store.send(.setNavigation(.postComment(""))) + store.send(.presentPostComment("")) } label: { Image(systemSymbol: .squareAndPencil) } diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index 258dfcab8..d64136d8b 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -11,9 +11,9 @@ import ReadingFeature @Reducer public struct PreviewsReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case reading(EquatableVoid = .init()) + @Reducer + public enum Destination { + case reading(ReadingReducer) } private enum CancelID: CaseIterable { @@ -25,7 +25,7 @@ public struct PreviewsReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - public var route: Route? + @Presents public var destination: Destination.State? public var gallery: Gallery = .empty public var loadingState: LoadingState = .idle @@ -36,8 +36,6 @@ public struct PreviewsReducer: Sendable { public var previewConfig: PreviewConfig = .normal(rows: 4) public var localPreviewRequestID = UUID() - public var readingState = ReadingReducer.State() - mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { self.previewURLs = self.previewURLs.merging( previewURLs, uniquingKeysWith: { stored, _ in stored } @@ -47,8 +45,7 @@ public struct PreviewsReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates + case destination(PresentationAction) case syncPreviewURLs([Int: URL]) case updateReadingProgress(Int) @@ -64,8 +61,6 @@ public struct PreviewsReducer: Sendable { case openReadingDone(Result<(DownloadedGallery, DownloadManifest), AppError>) case fetchPreviewURLs(Int) case fetchPreviewURLsDone(Result<[Int: URL], AppError>) - - case reading(ReadingReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -76,22 +71,17 @@ public struct PreviewsReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } Reduce { state, action in switch action { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none + case .destination(.presented(.reading(.onPerformDismiss))): + return .send(.destination(.dismiss)) - case .clearSubStates: - state.readingState = .init() - return .send(.reading(.teardown)) + case .destination: + return .none case .syncPreviewURLs(let previewURLs): return .run { [state] _ in @@ -173,7 +163,6 @@ public struct PreviewsReducer: Sendable { return .none case .openReading: - state.readingState = .init(contentSource: .remote) return .run { [galleryID = state.gallery.id] send in guard galleryID.isValidGID else { await send(.openReadingDone(.failure(.notFound))) @@ -185,13 +174,15 @@ public struct PreviewsReducer: Sendable { } case .openReadingDone(let result): + var readingState: ReadingReducer.State if case .success(let (download, manifest)) = result { - state.readingState = .init(contentSource: .local(download, manifest)) + readingState = .init(contentSource: .local(download, manifest)) } else { - state.readingState.contentSource = .remote - state.readingState.localPageURLs = state.localPreviewURLs + readingState = .init(contentSource: .remote) + readingState.localPageURLs = state.localPreviewURLs } - state.route = .reading() + readingState.gallery = state.gallery + state.destination = .reading(readingState) return .none case .fetchPreviewURLs(let index): @@ -221,20 +212,15 @@ public struct PreviewsReducer: Sendable { state.loadingState = .failed(error) } return .none - - case .reading(.onPerformDismiss): - return .send(.setNavigation(nil)) - - case .reading: - return .none } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.reading, hapticsClient: hapticsClient ) - - Scope(state: \.readingState, action: \.reading, child: ReadingReducer.init) + .ifLet(\.$destination, action: \.destination) } } + +extension PreviewsReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift index cfe65d517..28d7b1bbd 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift @@ -63,10 +63,12 @@ struct PreviewsView: View { .padding(.bottom) .id(store.databaseLoadingState) } - .fullScreenCover(item: $store.route.sending(\.setNavigation).reading) { _ in + .fullScreenCover( + item: $store.scope(state: \.destination?.reading, action: \.destination.reading) + ) { store in ReadingView( - store: store.scope(state: \.readingState, action: \.reading), - gid: gid, setting: $setting, blurRadius: blurRadius + store: store, + gid: store.gallery.id, setting: $setting, blurRadius: blurRadius ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift index 716ed262a..dbb928f5c 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift @@ -13,6 +13,11 @@ public struct TorrentsReducer: Sendable { @CasePathable public enum Route: Equatable, Sendable { case hud + } + + @Reducer + public enum Destination { + @ReducerCaseIgnored case share(URL) } @@ -23,6 +28,7 @@ public struct TorrentsReducer: Sendable { @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var destination: Destination.State? public var torrents = [GalleryTorrent]() public var loadingState: LoadingState = .idle public var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded @@ -31,6 +37,8 @@ public struct TorrentsReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) case setNavigation(Route?) + case destination(PresentationAction) + case presentShare(URL) case copyText(String) case presentTorrentActivity(String, Data) @@ -60,6 +68,13 @@ public struct TorrentsReducer: Sendable { state.route = route return .none + case .destination: + return .none + + case .presentShare(let url): + state.destination = .share(url) + return .none + case .copyText(let magnetURL): state.route = .hud return .merge( @@ -69,7 +84,7 @@ public struct TorrentsReducer: Sendable { case .presentTorrentActivity(let hash, let data): if let url = fileClient.saveTorrent(hash: hash, data: data) { - return .send(.setNavigation(.share(url))) + return .send(.presentShare(url)) } return .none @@ -114,9 +129,13 @@ public struct TorrentsReducer: Sendable { } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.share, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) } } + +extension TorrentsReducer.Destination.State: Equatable, Sendable {} +extension TorrentsReducer.Destination.Action: Equatable, Sendable {} diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift index 8d1b6c19d..b9d9b5cac 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift @@ -43,8 +43,8 @@ struct TorrentsView: View { } .opacity(error != nil && store.torrents.isEmpty ? 1 : 0) } - .sheet(item: $store.route.sending(\.setNavigation).share, id: \.absoluteString) { route in - ActivityView(activityItems: [route.wrappedValue]) + .sheet(item: $store.destination.share, id: \.absoluteString) { url in + ActivityView(activityItems: [url.wrappedValue]) .autoBlur(radius: blurRadius) } .progressHUD( diff --git a/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift index f465d4254..5916bee37 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift @@ -26,15 +26,15 @@ struct PreviewsReducerDownloadTests: DownloadFeatureTestCase { await store.send(.openReading(1)) await store.skipReceivedActions(strict: false) - if case .local(let actualDownload, let actualManifest) = store.state.readingState.contentSource { + if case .local(let actualDownload, let actualManifest) = store.state.destination?.reading?.contentSource { #expect(actualDownload == download) #expect(actualManifest == manifest) } else { Issue.record("Expected previews to open local reading content.") } - if case .reading = store.state.route { + if case .reading = store.state.destination { } else { - Issue.record("Expected reading route to be active.") + Issue.record("Expected reading destination to be active.") } } @@ -71,12 +71,12 @@ struct PreviewsReducerDownloadTests: DownloadFeatureTestCase { await store.send(.openReading(1)) await store.receive(\.openReadingDone) - guard case .reading = store.state.route else { - Issue.record("Expected previews route to enter reading") + guard case .reading = store.state.destination else { + Issue.record("Expected previews destination to enter reading") return } - #expect(store.state.readingState.contentSource == .remote) - #expect(store.state.readingState.localPageURLs == [1: localURL]) + #expect(store.state.destination?.reading?.contentSource == .remote) + #expect(store.state.destination?.reading?.localPageURLs == [1: localURL]) } } From a0d4b44ed6fd5f9426ac014d9ef829be667a8145 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 21:14:46 +0800 Subject: [PATCH 403/614] Adopt `@Presents` for AppRoute setting/newDawn --- .../AppFeature/DataFlow/AppRouteReducer.swift | 31 +++++++++++++++++-- .../AppFeature/View/TabBar/TabBarView.swift | 8 ++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 1a43b4bfa..342c245e7 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -18,14 +18,21 @@ struct AppRouteReducer { @CasePathable enum Route: Equatable, Hashable { case hud - case setting(EquatableVoid = .init()) case detail(String) + } + + @Reducer + enum Destination { + @ReducerCaseIgnored + case setting(EquatableVoid) + @ReducerCaseIgnored case newDawn(Greeting) } @ObservableState struct State: Equatable { var route: Route? + @Presents var destination: Destination.State? var hudConfig: ProgressHUDConfigState = .loading() var detailState: Heap @@ -38,6 +45,9 @@ struct AppRouteReducer { enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) + case destination(PresentationAction) + case presentSetting + case presentNewDawn(Greeting) case setHUDConfig(ProgressHUDConfigState) case clearSubStates @@ -75,6 +85,17 @@ struct AppRouteReducer { state.route = route return route == nil ? .send(.clearSubStates) : .none + case .destination: + return .none + + case .presentSetting: + state.destination = .setting(.init()) + return .none + + case .presentNewDawn(let greeting): + state.destination = .newDawn(greeting) + return .none + case .setHUDConfig(let config): state.hudConfig = config return .none @@ -178,7 +199,7 @@ struct AppRouteReducer { case .fetchGreetingDone(let result): if case .success(let greeting) = result, !greeting.gainedNothing { - return .send(.setNavigation(.newDawn(greeting))) + return .send(.presentNewDawn(greeting)) } return .none @@ -187,7 +208,7 @@ struct AppRouteReducer { } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.newDawn, hapticsClient: hapticsClient ) @@ -196,7 +217,11 @@ struct AppRouteReducer { case: \.detail, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } + +extension AppRouteReducer.Destination.State: Equatable, Sendable {} +extension AppRouteReducer.Destination.Action: Equatable, Sendable {} diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 0d3c9fddc..ce384cd8c 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -28,7 +28,7 @@ struct TabBarView: View { get: { store.tabBarState.tabBarItemType }, set: { tab in if tab == .setting, DeviceUtil.isPad { - store.send(.appRoute(.setNavigation(.setting()))) + store.send(.appRoute(.presentSetting)) } else { store.send(.tabBar(.setTabBarItemType(tab))) } @@ -89,11 +89,11 @@ struct TabBarView: View { } .font(.system(size: 80)).opacity(store.appLockState.isAppLocked ? 1 : 0) } - .sheet(item: $store.appRouteState.route.sending(\.appRoute.setNavigation).newDawn) { greeting in - NewDawnView(greeting: greeting) + .sheet(item: $store.appRouteState.destination.newDawn) { greeting in + NewDawnView(greeting: greeting.wrappedValue) .autoBlur(radius: store.appLockState.blurRadius) } - .sheet(item: $store.appRouteState.route.sending(\.appRoute.setNavigation).setting) { _ in + .sheet(item: $store.appRouteState.destination.setting) { _ in SettingView( store: store.scope(state: \.settingState, action: \.setting), blurRadius: store.appLockState.blurRadius From e5d2f619dc0c8f476ab1b8b8265b9fbd0539f3cb Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 21:31:43 +0800 Subject: [PATCH 404/614] Adopt `@Presents` for DateSeek picker sheet --- .../DateSeekFeature/DateSeekPickerView.swift | 4 +- .../DateSeekFeature/DateSeekReducer.swift | 54 +++++++++---------- .../FavoritesFeature/FavoritesReducer.swift | 17 +++--- .../FavoritesFeature/FavoritesView.swift | 13 +++-- .../Frontpage/FrontpageReducer.swift | 17 +++--- .../HomeFeature/Frontpage/FrontpageView.swift | 13 +++-- .../HomeFeature/Watched/WatchedReducer.swift | 17 +++--- .../HomeFeature/Watched/WatchedView.swift | 13 +++-- .../Sources/SearchFeature/SearchReducer.swift | 17 +++--- .../Sources/SearchFeature/SearchView.swift | 13 +++-- 10 files changed, 105 insertions(+), 73 deletions(-) diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift index cd0f35bf0..d8818920a 100644 --- a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift @@ -7,12 +7,12 @@ import SwiftUI /// The "Seek to date" sheet content: a graphical date picker plus newer/older direction buttons. /// /// This is a store-agnostic, reusable component — it is driven entirely by the values passed in, -/// not by a dedicated reducer. Hosts typically wire it to an embedded `DateSeekReducer`, but it +/// not by a dedicated reducer. Hosts typically wire it to a presented `DateSeekReducer`, but it /// has no dependency on one. /// /// - Precondition: `selectedDate` lies within `navigation.dateRange`. The picker renders the /// binding as-is and does not clamp it; keeping the date in range is the responsibility of -/// whoever owns the date state (the embedded `DateSeekReducer` does so in its `present` action). +/// whoever owns the date state (the presented `DateSeekReducer` clamps it in its initializer). public struct DateSeekPickerView: View { @Binding var selectedDate: Date let navigation: DateSeekNavigation diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift b/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift index 2b2dfde2b..278f117da 100644 --- a/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift @@ -3,31 +3,34 @@ import AppModels import Foundation import HapticsClient -/// A headless, reusable sub-reducer for the "Seek to date" control. +/// A headless, reusable feature for the "Seek to date" control. /// /// Despite the matching name, this reducer is **not** the companion of `DateSeekPickerView`: it /// owns no view, and the picker owns no reducer. `DateSeekPickerView` is a store-agnostic -/// presentation component, while `DateSeekReducer` is logic-only, designed to be embedded — via -/// `Scope` — into any gallery-list reducer that exposes a `DateSeekNavigation`. +/// presentation component, while `DateSeekReducer` is logic-only, designed to be presented — as a +/// `@Presents` destination — by any gallery-list reducer that exposes a `DateSeekNavigation`. /// -/// It owns the picker's UI state (selected date, presented navigation), validates and clamps the -/// date, and resolves a seek `URL`, which it hands back to its host through `delegate(.performSeek)`. -/// The host performs the request and stores the result, because the gallery list and its loading -/// state belong to the host — not to this control. +/// It is a self-contained sheet feature: it owns the picker's UI state (selected date, the +/// navigation being seeked), validates and clamps the date, and resolves a seek `URL`. It reports +/// that URL back to its host through `delegate(.performSeek)` and then dismisses itself. The host +/// performs the request and stores the result, because the gallery list and its loading state +/// belong to the host — not to this control. @Reducer public struct DateSeekReducer: Sendable { @ObservableState - public struct State: Equatable { - public var date = Date() - /// The navigation whose picker is presented; `nil` while the sheet is dismissed. - public var navigation: DateSeekNavigation? + public struct State: Equatable, Sendable { + public var date: Date + /// The navigation whose picker is presented. + public var navigation: DateSeekNavigation - public init() {} + public init(navigation: DateSeekNavigation) { + self.navigation = navigation + self.date = navigation.clampedDate(Date()) + } } - public enum Action { - case present(DateSeekNavigation) - case setNavigation(DateSeekNavigation?) + public enum Action: BindableAction { + case binding(BindingAction) case performSeek(DateSeekDirection) case delegate(Delegate) } @@ -38,29 +41,26 @@ public struct DateSeekReducer: Sendable { } @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.dismiss) private var dismiss public init() {} public var body: some Reducer { + BindingReducer() + Reduce { state, action in switch action { - case .present(let navigation): - state.date = navigation.clampedDate(state.date) - state.navigation = navigation - return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) - - case .setNavigation(let navigation): - state.navigation = navigation + case .binding: return .none case .performSeek(let direction): - guard let navigation = state.navigation, - let url = navigation.seekURL(date: state.date, direction: direction) - else { + guard let url = state.navigation.seekURL(date: state.date, direction: direction) else { return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } - state.navigation = nil - return .send(.delegate(.performSeek(url))) + return .merge( + .send(.delegate(.performSeek(url))), + .run(operation: { _ in await dismiss() }) + ) case .delegate: return .none diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index 62aa4a5bb..b239acfb4 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -27,6 +27,7 @@ public struct FavoritesReducer: Sendable { @Reducer public enum Destination { case quickSearch(QuickSearchReducer) + case dateSeek(DateSeekReducer) } @ObservableState @@ -61,7 +62,6 @@ public struct FavoritesReducer: Sendable { rawFooterLoadingState[index] } - public var dateSeek = DateSeekReducer.State() public var detailState: Heap public init() { @@ -84,6 +84,7 @@ public struct FavoritesReducer: Sendable { case setFavoritesIndex(Int) case clearSubStates case quickSearchButtonTapped + case dateSeekButtonTapped(DateSeekNavigation) case destination(PresentationAction) case onNotLoginViewButtonTapped @@ -95,7 +96,6 @@ public struct FavoritesReducer: Sendable { case observeDownloadsDone([DownloadedGallery]) case performDateSeekDone(Int, Result) - case dateSeek(DateSeekReducer.Action) case detail(DetailReducer.Action) } @@ -136,7 +136,8 @@ public struct FavoritesReducer: Sendable { state.destination = .quickSearch(QuickSearchReducer.State()) return .none - case .destination: + case .dateSeekButtonTapped(let navigation): + state.destination = .dateSeek(.init(navigation: navigation)) return .none case .onNotLoginViewButtonTapped: @@ -241,7 +242,7 @@ public struct FavoritesReducer: Sendable { ) return .none - case .dateSeek(.delegate(.performSeek(let url))): + case .destination(.presented(.dateSeek(.delegate(.performSeek(let url))))): guard state.loadingState != .loading else { return .none } state.rawLoadingState[state.index] = .loading state.rawFooterLoadingState[state.index] = .idle @@ -269,7 +270,7 @@ public struct FavoritesReducer: Sendable { } return .none - case .dateSeek: + case .destination: return .none case .detail: @@ -281,9 +282,13 @@ public struct FavoritesReducer: Sendable { case: \.quickSearch, hapticsClient: hapticsClient ) + .haptics( + unwrapping: \.destination, + case: \.dateSeek, + hapticsClient: hapticsClient + ) .ifLet(\.$destination, action: \.destination) - Scope(state: \.dateSeek, action: \.dateSeek, child: DateSeekReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index dc5627307..aeaf76704 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -68,11 +68,14 @@ public struct FavoritesView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in + .sheet( + item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) + ) { store in + @Bindable var store = store DateSeekPickerView( - selectedDate: $store.dateSeek.date, - navigation: navigation.wrappedValue, - seekAction: { store.send(.dateSeek(.performSeek($0))) } + selectedDate: $store.date, + navigation: store.navigation, + seekAction: { store.send(.performSeek($0)) } ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) @@ -141,7 +144,7 @@ public struct FavoritesView: View { } } DateSeekButton(navigation: store.dateSeekNavigation) { navigation in - store.send(.dateSeek(.present(navigation))) + store.send(.dateSeekButtonTapped(navigation)) } QuickSearchButton(hideText: true) { store.send(.quickSearchButtonTapped) diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index 3ad677ecf..ddccf66b0 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -21,6 +21,7 @@ public struct FrontpageReducer: Sendable { @Reducer public enum Destination { case filters(FiltersReducer) + case dateSeek(DateSeekReducer) } private enum CancelID: CaseIterable { @@ -43,7 +44,6 @@ public struct FrontpageReducer: Sendable { public var loadingState: LoadingState = .idle public var footerLoadingState: LoadingState = .idle - public var dateSeek = DateSeekReducer.State() public var detailState: Heap public init() { @@ -64,6 +64,7 @@ public struct FrontpageReducer: Sendable { case setNavigation(Route?) case clearSubStates case filtersButtonTapped + case dateSeekButtonTapped(DateSeekNavigation) case destination(PresentationAction) case teardown @@ -73,7 +74,6 @@ public struct FrontpageReducer: Sendable { case fetchMoreGalleriesDone(Result) case performDateSeekDone(Result) - case dateSeek(DateSeekReducer.Action) case detail(DetailReducer.Action) } @@ -105,7 +105,8 @@ public struct FrontpageReducer: Sendable { state.destination = .filters(FiltersReducer.State()) return .none - case .destination: + case .dateSeekButtonTapped(let navigation): + state.destination = .dateSeek(.init(navigation: navigation)) return .none case .teardown: @@ -179,7 +180,7 @@ public struct FrontpageReducer: Sendable { } return .none - case .dateSeek(.delegate(.performSeek(let url))): + case .destination(.presented(.dateSeek(.delegate(.performSeek(let url))))): guard state.loadingState != .loading else { return .none } state.loadingState = .loading state.footerLoadingState = .idle @@ -208,7 +209,7 @@ public struct FrontpageReducer: Sendable { } return .none - case .dateSeek: + case .destination: return .none case .detail: @@ -220,9 +221,13 @@ public struct FrontpageReducer: Sendable { case: \.filters, hapticsClient: hapticsClient ) + .haptics( + unwrapping: \.destination, + case: \.dateSeek, + hapticsClient: hapticsClient + ) .ifLet(\.$destination, action: \.destination) - Scope(state: \.dateSeek, action: \.dateSeek, child: DateSeekReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 1ba4eec53..8f98e261c 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -51,11 +51,14 @@ struct FrontpageView: View { FiltersView(store: store) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in + .sheet( + item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) + ) { store in + @Bindable var store = store DateSeekPickerView( - selectedDate: $store.dateSeek.date, - navigation: navigation.wrappedValue, - seekAction: { store.send(.dateSeek(.performSeek($0))) } + selectedDate: $store.date, + navigation: store.navigation, + seekAction: { store.send(.performSeek($0)) } ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) @@ -103,7 +106,7 @@ struct FrontpageView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { DateSeekButton(navigation: store.dateSeekNavigation) { navigation in - store.send(.dateSeek(.present(navigation))) + store.send(.dateSeekButtonTapped(navigation)) } FiltersButton(hideText: true) { store.send(.filtersButtonTapped) diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index 5153ea649..f19554611 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -23,6 +23,7 @@ public struct WatchedReducer: Sendable { public enum Destination { case filters(FiltersReducer) case quickSearch(QuickSearchReducer) + case dateSeek(DateSeekReducer) } private enum CancelID: CaseIterable { @@ -42,7 +43,6 @@ public struct WatchedReducer: Sendable { public var footerLoadingState: LoadingState = .idle public var downloadBadges = [String: DownloadBadge]() - public var dateSeek = DateSeekReducer.State() public var detailState: Heap public init() { @@ -65,6 +65,7 @@ public struct WatchedReducer: Sendable { case clearSubStates case filtersButtonTapped case quickSearchButtonTapped + case dateSeekButtonTapped(DateSeekNavigation) case destination(PresentationAction) case onNotLoginViewButtonTapped @@ -77,7 +78,6 @@ public struct WatchedReducer: Sendable { case observeDownloadsDone([DownloadedGallery]) case performDateSeekDone(Result) - case dateSeek(DateSeekReducer.Action) case detail(DetailReducer.Action) } @@ -117,7 +117,8 @@ public struct WatchedReducer: Sendable { state.destination = .quickSearch(QuickSearchReducer.State()) return .none - case .destination: + case .dateSeekButtonTapped(let navigation): + state.destination = .dateSeek(.init(navigation: navigation)) return .none case .onNotLoginViewButtonTapped: @@ -214,7 +215,7 @@ public struct WatchedReducer: Sendable { ) return .none - case .dateSeek(.delegate(.performSeek(let url))): + case .destination(.presented(.dateSeek(.delegate(.performSeek(let url))))): guard state.loadingState != .loading else { return .none } state.loadingState = .loading state.footerLoadingState = .idle @@ -243,7 +244,7 @@ public struct WatchedReducer: Sendable { } return .none - case .dateSeek: + case .destination: return .none case .detail: @@ -260,9 +261,13 @@ public struct WatchedReducer: Sendable { case: \.filters, hapticsClient: hapticsClient ) + .haptics( + unwrapping: \.destination, + case: \.dateSeek, + hapticsClient: hapticsClient + ) .ifLet(\.$destination, action: \.destination) - Scope(state: \.dateSeek, action: \.dateSeek, child: DateSeekReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index a8b224ff3..abdcd4f86 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -68,11 +68,14 @@ struct WatchedView: View { FiltersView(store: store) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in + .sheet( + item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) + ) { store in + @Bindable var store = store DateSeekPickerView( - selectedDate: $store.dateSeek.date, - navigation: navigation.wrappedValue, - seekAction: { store.send(.dateSeek(.performSeek($0))) } + selectedDate: $store.date, + navigation: store.navigation, + seekAction: { store.send(.performSeek($0)) } ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) @@ -131,7 +134,7 @@ struct WatchedView: View { CustomToolbarItem { ToolbarFeaturesMenu { DateSeekButton(navigation: store.dateSeekNavigation) { navigation in - store.send(.dateSeek(.present(navigation))) + store.send(.dateSeekButtonTapped(navigation)) } FiltersButton { store.send(.filtersButtonTapped) diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index aee0d2677..eb21a8ce9 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -24,6 +24,7 @@ public struct SearchReducer: Sendable { public enum Destination { case filters(FiltersReducer) case quickSearch(QuickSearchReducer) + case dateSeek(DateSeekReducer) } private enum CancelID: CaseIterable { @@ -44,7 +45,6 @@ public struct SearchReducer: Sendable { public var footerLoadingState: LoadingState = .idle public var downloadBadges = [String: DownloadBadge]() - public var dateSeek = DateSeekReducer.State() public var detailState: Heap public init() { @@ -67,6 +67,7 @@ public struct SearchReducer: Sendable { case clearSubStates case filtersButtonTapped case quickSearchButtonTapped + case dateSeekButtonTapped(DateSeekNavigation) case destination(PresentationAction) case teardown @@ -78,7 +79,6 @@ public struct SearchReducer: Sendable { case observeDownloadsDone([DownloadedGallery]) case performDateSeekDone(Result) - case dateSeek(DateSeekReducer.Action) case detail(DetailReducer.Action) } @@ -124,7 +124,8 @@ public struct SearchReducer: Sendable { state.destination = .quickSearch(QuickSearchReducer.State()) return .none - case .destination: + case .dateSeekButtonTapped(let navigation): + state.destination = .dateSeek(.init(navigation: navigation)) return .none case .teardown: @@ -219,7 +220,7 @@ public struct SearchReducer: Sendable { ) return .none - case .dateSeek(.delegate(.performSeek(let url))): + case .destination(.presented(.dateSeek(.delegate(.performSeek(let url))))): guard state.loadingState != .loading else { return .none } state.loadingState = .loading state.footerLoadingState = .idle @@ -248,7 +249,7 @@ public struct SearchReducer: Sendable { } return .none - case .dateSeek: + case .destination: return .none case .detail: @@ -265,9 +266,13 @@ public struct SearchReducer: Sendable { case: \.filters, hapticsClient: hapticsClient ) + .haptics( + unwrapping: \.destination, + case: \.dateSeek, + hapticsClient: hapticsClient + ) .ifLet(\.$destination, action: \.destination) - Scope(state: \.dateSeek, action: \.dateSeek, child: DateSeekReducer.init) Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift index 60078c0ba..57a602350 100644 --- a/AppPackage/Sources/SearchFeature/SearchView.swift +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -63,11 +63,14 @@ struct SearchView: View { FiltersView(store: store) .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } - .sheet(item: $store.dateSeek.navigation.sending(\.dateSeek.setNavigation), id: \.self) { navigation in + .sheet( + item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) + ) { store in + @Bindable var store = store DateSeekPickerView( - selectedDate: $store.dateSeek.date, - navigation: navigation.wrappedValue, - seekAction: { store.send(.dateSeek(.performSeek($0))) } + selectedDate: $store.date, + navigation: store.navigation, + seekAction: { store.send(.performSeek($0)) } ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) @@ -126,7 +129,7 @@ struct SearchView: View { CustomToolbarItem { ToolbarFeaturesMenu { DateSeekButton(navigation: store.dateSeekNavigation) { navigation in - store.send(.dateSeek(.present(navigation))) + store.send(.dateSeekButtonTapped(navigation)) } FiltersButton { store.send(.filtersButtonTapped) From 943e28453da8d6b89c389adc980eb0b0c0921ee6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 21:38:41 +0800 Subject: [PATCH 405/614] Adopt `@Presents` for DetailSearch modal sheets --- .../DetailSearch/DetailSearchReducer.swift | 50 ++++++++++--------- .../DetailSearch/DetailSearchView.swift | 22 ++++---- 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index 13d823682..2440a5b69 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -13,11 +13,15 @@ import ComposableArchitectureExt public struct DetailSearchReducer: Sendable { @dynamicMemberLookup @CasePathable public enum Route: Equatable, Sendable { - case filters(EquatableVoid = .unique) - case quickSearch(EquatableVoid = .unique) case detail(String) } + @Reducer + public enum Destination { + case filters(FiltersReducer) + case quickSearch(QuickSearchReducer) + } + private enum CancelID: CaseIterable { case fetchGalleries, fetchMoreGalleries } @@ -25,6 +29,7 @@ public struct DetailSearchReducer: Sendable { @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var destination: Destination.State? public var keyword = "" public var lastKeyword = "" @@ -34,8 +39,6 @@ public struct DetailSearchReducer: Sendable { public var footerLoadingState: LoadingState = .idle public var detailState: Heap - public var filtersState = FiltersReducer.State() - public var quickDetailSearchState = QuickSearchReducer.State() public init() { detailState = .init(.init()) @@ -54,6 +57,9 @@ public struct DetailSearchReducer: Sendable { case binding(BindingAction) case setNavigation(Route?) case clearSubStates + case filtersButtonTapped + case quickSearchButtonTapped + case destination(PresentationAction) case teardown case fetchGalleries(String? = nil) @@ -62,8 +68,6 @@ public struct DetailSearchReducer: Sendable { case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case detail(DetailReducer.Action) - case filters(FiltersReducer.Action) - case quickSearch(QuickSearchReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -94,12 +98,18 @@ public struct DetailSearchReducer: Sendable { case .clearSubStates: state.detailState.wrappedValue = .init() - state.filtersState = .init() - state.quickDetailSearchState = .init() - return .merge( - .send(.detail(.teardown)), - .send(.quickSearch(.teardown)) - ) + return .send(.detail(.teardown)) + + case .filtersButtonTapped: + state.destination = .filters(FiltersReducer.State()) + return .none + + case .quickSearchButtonTapped: + state.destination = .quickSearch(QuickSearchReducer.State()) + return .none + + case .destination: + return .none case .teardown: return .merge(CancelID.allCases.map(Effect.cancel(id:))) @@ -177,26 +187,20 @@ public struct DetailSearchReducer: Sendable { case .detail: return .none - - case .filters: - return .none - - case .quickSearch: - return .none } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.quickSearch, hapticsClient: hapticsClient ) .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.filters, hapticsClient: hapticsClient ) - - Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) - Scope(state: \.quickDetailSearchState, action: \.quickSearch, child: QuickSearchReducer.init) + .ifLet(\.$destination, action: \.destination) } } + +extension DetailSearchReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift index 1c08d528a..13d725551 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift @@ -44,18 +44,20 @@ struct DetailSearchView: View { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickDetailSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.fetchGalleries(keyword)) + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.fetchGalleries(keyword)) } .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } .searchable(text: $store.keyword) @@ -111,10 +113,10 @@ struct DetailSearchView: View { CustomToolbarItem { ToolbarFeaturesMenu { FiltersButton { - store.send(.setNavigation(.filters())) + store.send(.filtersButtonTapped) } QuickSearchButton { - store.send(.setNavigation(.quickSearch())) + store.send(.quickSearchButtonTapped) } } } From 2547e78630e7aa1c93e32db4c1a89514a66866de Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 22:03:17 +0800 Subject: [PATCH 406/614] Adopt `@Presents` for Detail modal sheets --- .../AppFeature/DataFlow/AppRouteReducer.swift | 2 +- .../DetailFeature/Archives/ArchivesView.swift | 2 +- .../Comments/CommentsReducer.swift | 2 +- .../Components/PostCommentView.swift | 2 +- .../Components/TagDetailView.swift | 2 +- .../DetailFeature/DetailReducer+Actions.swift | 57 +++++++++++--- .../DetailReducer+Download.swift | 17 +++-- .../DetailFeature/DetailReducer+Fetch.swift | 2 +- .../Sources/DetailFeature/DetailReducer.swift | 61 +++++++-------- .../DetailFeature/DetailView+Navigation.swift | 9 +-- .../Sources/DetailFeature/DetailView.swift | 75 ++++++++++--------- .../FolderManager/FolderManagerView.swift | 2 +- .../DetailFeature/Torrents/TorrentsView.swift | 2 +- .../DownloadsFeature/DownloadsReducer.swift | 6 +- .../DetailReducerDownloadTests.swift | 3 +- .../DetailReducerObserveTests.swift | 12 +-- 16 files changed, 139 insertions(+), 117 deletions(-) diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 342c245e7..c59678dde 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -151,7 +151,7 @@ struct AppRouteReducer { effects.append( .run { send in try await Task.sleep(for: .milliseconds(500)) - await send(.detail(.setNavigation(.reading()))) + await send(.detail(.presentReading)) } ) } else if let commentID = commentID { diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift index 5d7b8a0f0..90e313110 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift @@ -26,7 +26,7 @@ struct ArchivesView: View { // MARK: ArchiveView var body: some View { - NavigationView { + NavigationStack { ZStack { VStack { HathArchivesView(archives: store.hathArchives, selection: $store.selectedArchive) diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index db2d72ac6..9c21da087 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -171,7 +171,7 @@ public struct CommentsReducer: Sendable { effects.append( .run { send in try await Task.sleep(for: .milliseconds(750)) - await send(.detail(.setNavigation(.reading()))) + await send(.detail(.presentReading)) } ) } else if let commentID = commentID { diff --git a/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift b/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift index 338afef65..a2bb7b744 100644 --- a/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift +++ b/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift @@ -28,7 +28,7 @@ struct PostCommentView: View { } var body: some View { - NavigationView { + NavigationStack { VStack { TextEditor(text: $content) .focused($isTextEditorFocused) diff --git a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift index 0c18d59fe..f3d51d826 100644 --- a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift +++ b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift @@ -13,7 +13,7 @@ struct TagDetailView: View { } var body: some View { - NavigationView { + NavigationStack { ScrollView(showsIndicators: false) { VStack { TagDescriptionSection(description: detail.description) diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index cc71a4b16..24fea4995 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -1,5 +1,6 @@ import Foundation import ComposableArchitecture +import ReadingFeature // MARK: - Navigation & UI Action Handlers extension DetailReducer { @@ -13,24 +14,58 @@ extension DetailReducer { state.route = route return route == nil ? .send(.clearSubStates) : .none + case .destination(.dismiss): + if case .postComment = state.destination { + state.commentContent = .init() + state.postCommentFocused = false + } + return .none + + case .destination: + return .none + + case .presentReading: + state.destination = .reading(ReadingReducer.State()) + return .none + + case .archivesButtonTapped: + state.destination = .archives(ArchivesReducer.State()) + return .none + + case .torrentsButtonTapped: + state.destination = .torrents(TorrentsReducer.State()) + return .none + + case .folderManagerButtonTapped: + state.destination = .folderManager(FolderManagerReducer.State()) + return .none + + case .shareButtonTapped(let url): + state.destination = .share(url) + return .none + + case .postCommentButtonTapped: + state.destination = .postComment(.init()) + return .none + + case .presentNewDawn(let greeting): + state.destination = .newDawn(greeting) + return .none + + case .tagDetailButtonTapped(let tagDetail): + state.destination = .tagDetail(tagDetail) + return .none + case .clearSubStates: - state.readingState = .init() - state.archivesState = .init() - state.torrentsState = .init() state.previewsState = .init() state.commentsState.wrappedValue = .init() state.commentContent = .init() state.postCommentFocused = false state.galleryInfosState = .init() - state.folderManagerState = .init() state.detailSearchState.wrappedValue = .init() return .merge( - .send(.reading(.teardown)), - .send(.archives(.teardown)), - .send(.torrents(.teardown)), .send(.previews(.teardown)), .send(.comments(.teardown)), - .send(.folderManager(.teardown)), .send(.detailSearch(.teardown)) ) @@ -169,10 +204,10 @@ extension DetailReducer { func childReducer(_ reducer: Reduce) -> some ReducerOf { Reduce { state, action in switch action { - case .reading(.onPerformDismiss): - return .send(.setNavigation(nil)) + case .destination(.presented(.reading(.onPerformDismiss))): + return .send(.destination(.dismiss)) - case .reading, .archives, .torrents, .previews, .galleryInfos: + case .previews, .galleryInfos: return .none case .comments(.performCommentActionDone(let result)): diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift index 759fd8036..c918c92b3 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import AppTools +import ReadingFeature // MARK: - Download Action Handlers extension DetailReducer { @@ -92,9 +93,9 @@ extension DetailReducer { } return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) - case .folderManager(.createFolderDone), - .folderManager(.renameFolderDone), - .folderManager(.deleteFolderDone): + case .destination(.presented(.folderManager(.createFolderDone))), + .destination(.presented(.folderManager(.renameFolderDone))), + .destination(.presented(.folderManager(.deleteFolderDone))): return .send(.fetchDownloadFolders) case .observeDownload: @@ -137,7 +138,6 @@ extension DetailReducer { return .none case .openReading: - state.readingState = .init(contentSource: .remote) return .run { [galleryID = state.gallery.id] send in guard galleryID.isValidGID else { await send(.openReadingDone(.failure(.notFound))) @@ -149,13 +149,14 @@ extension DetailReducer { } case .openReadingDone(let result): + var readingState: ReadingReducer.State if case .success(let (download, manifest)) = result { - state.readingState = .init(contentSource: .local(download, manifest)) + readingState = .init(contentSource: .local(download, manifest)) } else { - state.readingState.contentSource = .remote - state.readingState.localPageURLs = state.localPreviewURLs + readingState = .init(contentSource: .remote) + readingState.localPageURLs = state.localPreviewURLs } - state.route = .reading() + state.destination = .reading(readingState) return .none case .runLaunchAutomationIfNeeded: diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift index 0a0ae000b..6dee6c46b 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift @@ -82,7 +82,7 @@ extension DetailReducer { if let greeting = response.greeting { effects.append(.send(.syncGreeting(greeting))) if !greeting.gainedNothing && state.showsNewDawnGreeting { - effects.append(.send(.setNavigation(.newDawn(greeting)))) + effects.append(.send(.presentNewDawn(greeting))) } } if let config = response.galleryState.previewConfig { diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index b5a24d4c0..e5872f58b 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -17,18 +17,22 @@ import ReadingFeature public struct DetailReducer: Sendable { @CasePathable public enum Route: Equatable, Sendable { - case reading(EquatableVoid = .init()) - case archives(URL, URL) - case torrents(EquatableVoid = .init()) case previews case comments(URL) - case share(URL) - case postComment(EquatableVoid = .init()) - case newDawn(Greeting) case detailSearch(String) - case tagDetail(TagDetail) case galleryInfos(Gallery, GalleryDetail) - case folderManager(EquatableVoid = .init()) + } + + @Reducer + public enum Destination { + case reading(ReadingReducer) + case archives(ArchivesReducer) + case torrents(TorrentsReducer) + case folderManager(FolderManagerReducer) + @ReducerCaseIgnored case share(URL) + @ReducerCaseIgnored case postComment(EquatableVoid) + @ReducerCaseIgnored case newDawn(Greeting) + @ReducerCaseIgnored case tagDetail(TagDetail) } public enum Alert: Equatable, Sendable { @@ -73,6 +77,7 @@ public struct DetailReducer: Sendable { @ObservableState public struct State: Equatable { public var route: Route? + @Presents public var destination: Destination.State? @Presents public var alert: AlertState? public var commentContent = "" public var postCommentFocused = false @@ -110,13 +115,9 @@ public struct DetailReducer: Sendable { public var shouldCheckForRemoteUpdates = false public var didRequestVersionMetadata = false public var localPreviewRequestID = UUID() - public var readingState = ReadingReducer.State() - public var archivesState = ArchivesReducer.State() - public var torrentsState = TorrentsReducer.State() public var previewsState = PreviewsReducer.State() public var commentsState: Heap public var galleryInfosState = GalleryInfosReducer.State() - public var folderManagerState = FolderManagerReducer.State() public var detailSearchState: Heap public init() { @@ -133,6 +134,15 @@ public struct DetailReducer: Sendable { public indirect enum Action: BindableAction { case binding(BindingAction) case setNavigation(Route?) + case destination(PresentationAction) + case presentReading + case archivesButtonTapped + case torrentsButtonTapped + case folderManagerButtonTapped + case shareButtonTapped(URL) + case postCommentButtonTapped + case presentNewDawn(Greeting) + case tagDetailButtonTapped(TagDetail) case alert(PresentationAction) case deleteDownloadButtonTapped case retryDownloadButtonTapped(DownloadStartMode) @@ -188,13 +198,9 @@ public struct DetailReducer: Sendable { case postComment(URL) case voteTag(String, Int) case anyGalleryOpsDone(Result) - case reading(ReadingReducer.Action) - case archives(ArchivesReducer.Action) - case torrents(TorrentsReducer.Action) case previews(PreviewsReducer.Action) case comments(CommentsReducer.Action) case galleryInfos(GalleryInfosReducer.Action) - case folderManager(FolderManagerReducer.Action) case detailSearch(DetailSearchReducer.Action) } @@ -225,13 +231,10 @@ extension DetailReducer { galleryOpsReducer childReducer(self) optionalChildReducers + .ifLet(\.$destination, action: \.destination) .ifLet(\.$alert, action: \.alert) - Scope(state: \.readingState, action: \.reading, child: ReadingReducer.init) - Scope(state: \.archivesState, action: \.archives, child: ArchivesReducer.init) - Scope(state: \.torrentsState, action: \.torrents, child: TorrentsReducer.init) Scope(state: \.previewsState, action: \.previews, child: PreviewsReducer.init) Scope(state: \.galleryInfosState, action: \.galleryInfos, child: GalleryInfosReducer.init) - Scope(state: \.folderManagerState, action: \.folderManager, child: FolderManagerReducer.init) } } @@ -242,22 +245,6 @@ extension DetailReducer { } } -// MARK: - Haptics -extension DetailReducer { - func hapticsReducer( - @ReducerBuilder reducer: () -> some Reducer - ) -> some Reducer { - reducer() - .haptics(unwrapping: \.route, case: \.detailSearch, hapticsClient: hapticsClient, style: .soft) - .haptics(unwrapping: \.route, case: \.postComment, hapticsClient: hapticsClient) - .haptics(unwrapping: \.route, case: \.tagDetail, hapticsClient: hapticsClient) - .haptics(unwrapping: \.route, case: \.torrents, hapticsClient: hapticsClient) - .haptics(unwrapping: \.route, case: \.archives, hapticsClient: hapticsClient) - .haptics(unwrapping: \.route, case: \.reading, hapticsClient: hapticsClient) - .haptics(unwrapping: \.route, case: \.share, hapticsClient: hapticsClient) - } -} - // MARK: - Helpers extension DetailReducer { public func applyDownload(_ download: DownloadedGallery?, state: inout State) -> Bool { @@ -281,3 +268,5 @@ extension DetailReducer { && !state.didRequestVersionMetadata } } + +extension DetailReducer.Destination.State: Equatable {} diff --git a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift index f9c7a0107..70f36dfe6 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift @@ -48,16 +48,13 @@ extension DetailView { CustomToolbarItem { ToolbarFeaturesMenu { Button { - if let galleryURL = store.gallery.galleryURL, - let archiveURL = store.galleryDetail?.archiveURL { - store.send(.setNavigation(.archives(galleryURL, archiveURL))) - } + store.send(.archivesButtonTapped) } label: { Label(L10n.Localizable.DetailView.ToolbarItem.Button.archives, systemSymbol: .zipperPage) } .disabled(store.galleryDetail?.archiveURL == nil || !CookieUtil.didLogin) Button { - store.send(.setNavigation(.torrents())) + store.send(.torrentsButtonTapped) } label: { let base = L10n.Localizable.DetailView.ToolbarItem.Button.torrents let torrentCount = store.galleryDetail?.torrentCount ?? 0 @@ -67,7 +64,7 @@ extension DetailView { .disabled((store.galleryDetail?.torrentCount ?? 0 > 0) != true) Button { if let galleryURL = store.gallery.galleryURL { - store.send(.setNavigation(.share(galleryURL))) + store.send(.shareButtonTapped(galleryURL)) } } label: { Label(L10n.Localizable.DetailView.ToolbarItem.Button.share, systemSymbol: .squareAndArrowUp) diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index c00e670bc..0dab90c07 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -81,7 +81,7 @@ private extension DetailView { downloadToFolderAction: { store.send(.startDownload($0)) }, - manageFoldersAction: { store.send(.setNavigation(.folderManager())) }, + manageFoldersAction: { store.send(.folderManagerButtonTapped) }, createDefaultFolderAction: { store.send(.createDefaultFolder) }, favorAction: { store.send(.favorGallery($0)) }, unfavorAction: { store.send(.unfavorGallery) }, @@ -121,7 +121,7 @@ private extension DetailView { tags: store.galleryTags, showsImages: setting.showsImagesInTags, voteTagAction: { store.send(.voteTag($0, $1)) }, navigateSearchAction: { store.send(.setNavigation(.detailSearch($0))) }, - navigateTagDetailAction: { store.send(.setNavigation(.tagDetail($0))) }, + navigateTagDetailAction: { store.send(.tagDetailButtonTapped($0)) }, translateAction: { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } @@ -150,7 +150,7 @@ private extension DetailView { store.send(.setNavigation(.comments(galleryURL))) } }, - navigatePostCommentAction: { store.send(.setNavigation(.postComment())) } + navigatePostCommentAction: { store.send(.postCommentButtonTapped) } ) } .padding(.bottom, 20) @@ -180,7 +180,7 @@ private extension DetailView { func modalModifiers(@ViewBuilder content: () -> Content) -> some View { primaryModalModifiers(content: content) - .sheet(item: $store.route.sending(\.setNavigation).postComment) { _ in + .sheet(item: $store.destination.postComment, id: \.id) { _ in PostCommentView( title: L10n.Localizable.PostCommentView.Title.postComment, content: $store.commentContent, @@ -189,29 +189,31 @@ private extension DetailView { if let galleryURL = store.gallery.galleryURL { store.send(.postComment(galleryURL)) } - store.send(.setNavigation(nil)) + store.send(.destination(.dismiss)) }, - cancelAction: { store.send(.setNavigation(nil)) }, + cancelAction: { store.send(.destination(.dismiss)) }, onAppearAction: { store.send(.onPostCommentAppear) } ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(item: $store.route.sending(\.setNavigation).newDawn) { greeting in - NewDawnView(greeting: greeting) + .sheet(item: $store.destination.newDawn) { greeting in + NewDawnView(greeting: greeting.wrappedValue) .autoBlur(radius: blurRadius) } - .sheet(item: $store.route.sending(\.setNavigation).tagDetail, id: \.title) { detail in - TagDetailView(detail: detail) + .sheet(item: $store.destination.tagDetail, id: \.title) { detail in + TagDetailView(detail: detail.wrappedValue) .autoBlur(radius: blurRadius) } } private func primaryModalModifiers(@ViewBuilder content: () -> Content) -> some View { content() - .fullScreenCover(item: $store.route.sending(\.setNavigation).reading) { _ in + .fullScreenCover( + item: $store.scope(state: \.destination?.reading, action: \.destination.reading) + ) { store in ReadingView( - store: store.scope(state: \.readingState, action: \.reading), + store: store, gid: gid, setting: $setting, blurRadius: blurRadius @@ -219,37 +221,42 @@ private extension DetailView { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(item: $store.route.sending(\.setNavigation).archives, id: \.0.absoluteString) { urls in - let (galleryURL, archiveURL) = urls - ArchivesView( - store: store.scope(state: \.archivesState, action: \.archives), - gid: gid, - user: user, - galleryURL: galleryURL, - archiveURL: archiveURL - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) + .sheet( + item: $store.scope(state: \.destination?.archives, action: \.destination.archives) + ) { archivesStore in + if let galleryURL = store.gallery.galleryURL, let archiveURL = store.galleryDetail?.archiveURL { + ArchivesView( + store: archivesStore, + gid: gid, + user: user, + galleryURL: galleryURL, + archiveURL: archiveURL + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } } - .sheet(item: $store.route.sending(\.setNavigation).torrents) { _ in + .sheet( + item: $store.scope(state: \.destination?.torrents, action: \.destination.torrents) + ) { store in TorrentsView( - store: store.scope(state: \.torrentsState, action: \.torrents), + store: store, gid: gid, - token: store.gallery.token, + token: self.store.gallery.token, blurRadius: blurRadius ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(item: $store.route.sending(\.setNavigation).folderManager) { _ in - FolderManagerView( - store: store.scope(state: \.folderManagerState, action: \.folderManager) - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) + .sheet( + item: $store.scope(state: \.destination?.folderManager, action: \.destination.folderManager) + ) { store in + FolderManagerView(store: store) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) } - .sheet(item: $store.route.sending(\.setNavigation).share, id: \.absoluteString) { url in - ActivityView(activityItems: [url]) + .sheet(item: $store.destination.share, id: \.absoluteString) { url in + ActivityView(activityItems: [url.wrappedValue]) .autoBlur(radius: blurRadius) } } diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift index fe0f50cbc..5f7dd4df9 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift @@ -15,7 +15,7 @@ public struct FolderManagerView: View { } public var body: some View { - NavigationView { + NavigationStack { ZStack { List { if store.editingField == .newFolder { diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift index b9d9b5cac..aeae83ece 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift @@ -19,7 +19,7 @@ struct TorrentsView: View { } var body: some View { - NavigationView { + NavigationStack { ZStack { List(store.torrents) { torrent in TorrentRow(torrent: torrent) { magnetURL in diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 0e87cc92d..64d959d4a 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -328,9 +328,9 @@ public struct DownloadsReducer: Sendable { case .deleteDownloadDone: return .none - case .detail(.folderManager(.createFolderDone)), - .detail(.folderManager(.renameFolderDone)), - .detail(.folderManager(.deleteFolderDone)): + case .detail(.destination(.presented(.folderManager(.createFolderDone)))), + .detail(.destination(.presented(.folderManager(.renameFolderDone)))), + .detail(.destination(.presented(.folderManager(.deleteFolderDone)))): return .send(.fetchFolders) case .detail: diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift index bfcf35e23..fad7f0ed1 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift @@ -88,6 +88,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { gallery: gallery, detail: detail, downloadValue: nil, folders: { ["Library"] }, + configure: { $0.destination = .folderManager(.init()) }, enqueue: { _ in } ) store.exhaustivity = .off @@ -97,7 +98,7 @@ struct DetailReducerDownloadTests: DownloadFeatureTestCase { $0.downloadFolders = ["Library"] } - await store.send(.folderManager(.createFolderDone(.success(())))) + await store.send(.destination(.presented(.folderManager(.createFolderDone(.success(())))))) await store.receive(\.fetchDownloadFolders) await store.skipReceivedActions(strict: false) } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift index 4a451bb3c..dd3e9b45b 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift @@ -108,11 +108,7 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { await store.send(.openReading) await store.skipReceivedActions(strict: false) - #expect(store.state.readingState.contentSource == .local(download, manifest)) - if case .reading = store.state.route { - } else { - Issue.record("Expected reading route to be active.") - } + #expect(store.state.destination?.reading?.contentSource == .local(download, manifest)) } @MainActor @@ -140,11 +136,7 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { await store.send(.openReading) await store.skipReceivedActions(strict: false) - #expect(store.state.readingState.contentSource == .remote) - if case .reading = store.state.route { - } else { - Issue.record("Expected reading route to be active.") - } + #expect(store.state.destination?.reading?.contentSource == .remote) } } From ac96e4843afd4a2659031e4f9e5b7871bfb93985 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 22:03:17 +0800 Subject: [PATCH 407/614] Remove unused sheet(unwrapping:case:) helper --- .../SwiftUINavigationExt/SwiftUINavigation+.swift | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift b/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift index 69517f148..aebef4a0c 100644 --- a/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift +++ b/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift @@ -25,19 +25,6 @@ extension NavigationLink { } } -extension View { - public func sheet( - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath, - @ViewBuilder content: @escaping (Case) -> Content - ) -> some View { - self.sheet( - isPresented: .constant(`enum`.case(caseKeyPath).wrappedValue != nil), - content: { `enum`.case(caseKeyPath).wrappedValue.map(content) } - ) - } -} - extension Binding { public func `case`( _ caseKeyPath: CaseKeyPath From b011ff0fceb6138b778532675aaab5259b33dbe9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 23:00:50 +0800 Subject: [PATCH 408/614] Extract activity-logs pump behind shared state --- AppPackage/Package.swift | 8 +- .../AppFeature/DataFlow/AppReducer.swift | 12 +- .../Sources/LogsClient/LogsClient.swift | 12 -- .../AppActivityLogsPumpReducer.swift | 104 ++++++++++ .../AppActivityLogsReducer.swift | 110 ++--------- .../AppActivityLogsSharedKeys.swift | 17 ++ .../AppActivityLogsReducerTests.swift | 179 +++++++++++------- 7 files changed, 262 insertions(+), 180 deletions(-) create mode 100644 AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsPumpReducer.swift create mode 100644 AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsSharedKeys.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 97d3c5ec8..2cd804d5e 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -26,6 +26,7 @@ var dependencies: [PackageDescription.Package.Dependency] = [ from: "1.25.0" ), .package(url: "https://github.com/pointfreeco/swift-navigation", from: "2.8.0"), + .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.0.0"), .package(url: "https://github.com/tid-kijyun/Kanna", from: "6.0.0") ] @@ -46,6 +47,7 @@ extension PackageDescription.Target.Dependency { static let sdWebImageSwiftUI: Self = .product(name: "SDWebImageSwiftUI", package: "SDWebImageSwiftUI") static let sdWebImageWebPCoder: Self = .product(name: "SDWebImageWebPCoder", package: "SDWebImageWebPCoder") static let sfSafeSymbols: Self = .product(name: "SFSafeSymbols", package: "SFSafeSymbols") + static let sharing: Self = .product(name: "Sharing", package: "swift-sharing") static let swiftUINavigation: Self = .product(name: "SwiftUINavigation", package: "swift-navigation") static let swiftUIPager: Self = .product(name: "SwiftUIPager", package: "SwiftUIPager") static let ttProgressHUD: Self = .product(name: "TTProgressHUD", package: "TTProgressHUD") @@ -737,7 +739,8 @@ let targets: [PackageDescription.Target] = [ .module(.userDefaultsClient), .targetDependency(.composableArchitecture), .targetDependency(.filePicker), - .targetDependency(.sfSafeSymbols) + .targetDependency(.sfSafeSymbols), + .targetDependency(.sharing) ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins @@ -1000,7 +1003,8 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.logsClient), .module(.settingFeature), - .targetDependency(.composableArchitecture) + .targetDependency(.composableArchitecture), + .targetDependency(.sharing) ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 6eee35b66..ecd8c33ef 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -29,6 +29,7 @@ struct AppReducer { var searchRootState = SearchRootReducer.State() var downloadsState = DownloadsReducer.State() var settingState = SettingReducer.State() + var appLogsPumpState = AppActivityLogsPumpReducer.State() var scenePhase = ScenePhase.active var hasEnteredBackground = false var didRunLaunchAutomation = false @@ -52,6 +53,7 @@ struct AppReducer { case searchRoot(SearchRootReducer.Action) case downloads(DownloadsReducer.Action) case setting(SettingReducer.Action) + case appLogsPump(AppActivityLogsPumpReducer.Action) } @Dependency(\.hapticsClient) private var hapticsClient @@ -86,7 +88,7 @@ struct AppReducer { let blurRadius = state.settingState.setting.backgroundBlurRadius var effects: [Effect] = [ .send(.appLock(.onBecomeActive(threshold, blurRadius))), - .send(.setting(.general(.appActivityLogs(.startPump)))), + .send(.appLogsPump(.startPump)), .run { _ in logger.notice("App entered foreground.") } ] // iOS interposes .inactive on a foreground return @@ -114,7 +116,7 @@ struct AppReducer { // beginBackgroundTask assertion only covers the brief grace // period right after backgrounding. return .merge( - .send(.setting(.general(.appActivityLogs(.pausePump)))), + .send(.appLogsPump(.pausePump)), .run { _ in logger.notice("App entered background.") if await downloadClient.hasPendingWork() { @@ -143,7 +145,7 @@ struct AppReducer { } case .appDelegate(.onLaunchFinish): - return .send(.setting(.general(.appActivityLogs(.startPump)))) + return .send(.appLogsPump(.startPump)) case .appDelegate(.migration(.onDatabasePreparationSuccess)): let loginCookies = appLaunchAutomationClient.current()?.loginCookies @@ -297,6 +299,9 @@ struct AppReducer { case .setting: return .none + + case .appLogsPump: + return .none } } @@ -309,6 +314,7 @@ struct AppReducer { Scope(state: \.searchRootState, action: \.searchRoot, child: SearchRootReducer.init) Scope(state: \.downloadsState, action: \.downloads, child: DownloadsReducer.init) Scope(state: \.settingState, action: \.setting, child: SettingReducer.init) + Scope(state: \.appLogsPumpState, action: \.appLogsPump, child: AppActivityLogsPumpReducer.init) } } diff --git a/AppPackage/Sources/LogsClient/LogsClient.swift b/AppPackage/Sources/LogsClient/LogsClient.swift index 9edb8ad6e..600c94c29 100644 --- a/AppPackage/Sources/LogsClient/LogsClient.swift +++ b/AppPackage/Sources/LogsClient/LogsClient.swift @@ -19,8 +19,6 @@ public struct LogsClient: Sendable { /// Derives the next run count for the given day from the existing log files /// (`max + 1` among that day's files, or `1` — so the count resets each new day). public var nextRunCount: @Sendable (_ date: Date) async -> Int - /// In-memory, case-insensitive keyword filter over already-loaded logs. - public var query: @Sendable (_ logs: [AppActivityLog], _ keyword: String) -> [AppActivityLog] /// The jsonl file URL for a given run. public var currentRunFileURL: @Sendable (_ runCount: Int, _ date: Date) -> URL } @@ -97,14 +95,6 @@ extension LogsClient { .map(\.runCount) return (todayCounts.max() ?? 0) + 1 }, - query: { logs, keyword in - guard !keyword.isEmpty else { return logs } - return logs.filter { log in - [log.dateDescription, log.level.title, log.category, log.message] - .joined(separator: " ") - .caseInsensitiveContains(keyword) - } - }, currentRunFileURL: { runCount, date in FileUtil.logsDirectoryURL.appendingPathComponent( RunLogFile.fileName(date: date, runCount: runCount) @@ -135,7 +125,6 @@ extension LogsClient { readRunFile: { _ in [] }, listRunFiles: { [] }, nextRunCount: { _ in 1 }, - query: { logs, _ in logs }, currentRunFileURL: { _, _ in FileUtil.logsDirectoryURL } ) @@ -147,7 +136,6 @@ extension LogsClient { readRunFile: IssueReporting.unimplemented(placeholder: placeholder()), listRunFiles: IssueReporting.unimplemented(placeholder: placeholder()), nextRunCount: IssueReporting.unimplemented(placeholder: placeholder()), - query: IssueReporting.unimplemented(placeholder: placeholder()), currentRunFileURL: IssueReporting.unimplemented(placeholder: placeholder()) ) } diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsPumpReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsPumpReducer.swift new file mode 100644 index 000000000..31b78d84d --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsPumpReducer.swift @@ -0,0 +1,104 @@ +import OSLogExt +import Foundation +import AppModels +import Sharing +import LogsClient +import ComposableArchitecture + +private let logger = Logger(category: .init(describing: AppActivityLogsPumpReducer.self)) + +// The always-alive, view-less activity-logs pump. Owned by AppReducer so it outlives Setting +// navigation, it derives the current run once per app run, appends new OS log entries to that run's +// jsonl file every few seconds, and publishes the live current run + its logs via in-memory shared +// state to the (navigation-scoped, read-only) `AppActivityLogsReducer` screen. +@Reducer +public struct AppActivityLogsPumpReducer: Sendable { + private enum CancelID { + case pump + } + + @ObservableState + public struct State: Equatable, Sendable { + @Shared(.appActivityLogsCurrentRun) public var currentRun: RunLogFile? + @Shared(.appActivityLogsCurrentRunLogs) public var currentRunLogs: [AppActivityLog] + public var lastCursorDate: Date? + + public init() {} + } + + public enum Action: Equatable, Sendable { + case startPump + case pausePump + case setCurrentRun(RunLogFile) + case didReceiveNewEntries([AppActivityLog]) + } + + @Dependency(\.logsClient) private var logsClient + @Dependency(\.continuousClock) private var clock + @Dependency(\.date) private var date + + public init() {} + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .startPump: + return .run { [existingURL = state.currentRun?.url, cursor0 = state.lastCursorDate] send in + let fileURL: URL + if let existingURL { + fileURL = existingURL + } else { + let now = date.now + let runCount = await logsClient.nextRunCount(now) + let resolvedURL = logsClient.currentRunFileURL(runCount, now) + await send(.setCurrentRun(RunLogFile(url: resolvedURL, date: now, runCount: runCount))) + fileURL = resolvedURL + let appVersion = Bundle.main + .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "(null)" + logger.log( + """ + App activity logging started. + Run \(runCount, privacy: .public) + App Version \(appVersion, privacy: .public) + OS \(ProcessInfo.processInfo.operatingSystemVersionString, privacy: .public) + """ + ) + } + + var cursor = cursor0 + while !Task.isCancelled { + let newEntries = (try? await logsClient.fetchNewEntries(cursor)) ?? [] + if let lastDate = newEntries.last?.date { + cursor = lastDate + await send(.didReceiveNewEntries(newEntries)) + try? await logsClient.appendToRunFile(newEntries, fileURL) + } + try await clock.sleep(for: .seconds(5)) + } + } + .cancellable(id: CancelID.pump, cancelInFlight: true) + + case .pausePump: + return .merge( + .run { [cursor = state.lastCursorDate, fileURL = state.currentRun?.url] send in + guard let fileURL else { return } + let newEntries = (try? await logsClient.fetchNewEntries(cursor)) ?? [] + guard !newEntries.isEmpty else { return } + await send(.didReceiveNewEntries(newEntries)) + try? await logsClient.appendToRunFile(newEntries, fileURL) + }, + .cancel(id: CancelID.pump) + ) + + case let .setCurrentRun(run): + state.$currentRun.withLock { $0 = run } + return .none + + case .didReceiveNewEntries(let entries): + state.$currentRunLogs.withLock { $0.append(contentsOf: entries) } + state.lastCursorDate = entries.last?.date ?? state.lastCursorDate + return .none + } + } + } +} diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift index 56c7e8f4d..66a298bfe 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift @@ -1,45 +1,45 @@ -import OSLogExt import Foundation import AppModels +import Sharing import LogsClient import ApplicationClient import ComposableArchitecture -private let logger = Logger(category: .init(describing: AppActivityLogsReducer.self)) - @Reducer public struct AppActivityLogsReducer: Sendable { - private enum CancelID { - case pump - } - @ObservableState public struct State: Equatable, Sendable { - // The current run, derived once per app run and reused across pump pauses. - public var currentRun: RunLogFile? - public var lastCursorDate: Date? + // Live, read-only view of the current run + its logs, published by the always-alive + // `AppActivityLogsPumpReducer`. Read-only so the screen can never write stale data back. + @SharedReader(.appActivityLogsCurrentRun) public var currentRun: RunLogFile? + @SharedReader(.appActivityLogsCurrentRunLogs) public var currentRunLogs: [AppActivityLog] - // Live, state-backed logs for the current run. - public var currentRunLogs = [AppActivityLog]() // File-backed log files for previous runs (excludes the current run). public var previousRuns = [RunLogFile]() // File-backed logs for the currently selected previous run. public var selectedRunLogs = [AppActivityLog]() - // `nil` selects the current run (state-backed); otherwise a previous run's file URL. + // `nil` selects the current run (live/shared); otherwise a previous run's file URL. public var selectedRun: URL? - public var displayedLogs = [AppActivityLog]() public var keyword = "" public var loadingState: LoadingState = .idle + // Derived live from the shared current-run buffer (or the selected previous run), newest + // first. Computed rather than stored so it tracks the pump's shared writes without an action. + public var displayedLogs: [AppActivityLog] { + let source = selectedRun == nil ? currentRunLogs : selectedRunLogs + let filtered = keyword.isEmpty ? source : source.filter { log in + [log.dateDescription, log.level.title, log.category, log.message] + .joined(separator: " ") + .caseInsensitiveContains(keyword) + } + return filtered.sorted { $0.date > $1.date } + } + public init() {} } public enum Action: Equatable, Sendable { - case startPump - case pausePump - case setCurrentRun(RunLogFile) - case didReceiveNewEntries([AppActivityLog]) case refreshAvailableRuns case availableRunsResponse([RunLogFile]) case selectRun(URL?) @@ -50,75 +50,12 @@ public struct AppActivityLogsReducer: Sendable { @Dependency(\.logsClient) private var logsClient @Dependency(\.applicationClient) private var applicationClient - @Dependency(\.continuousClock) private var clock - @Dependency(\.date) private var date public init() {} public var body: some Reducer { Reduce { state, action in switch action { - case .startPump: - return .run { [existingURL = state.currentRun?.url, cursor0 = state.lastCursorDate] send in - let fileURL: URL - if let existingURL { - fileURL = existingURL - } else { - let now = date.now - let runCount = await logsClient.nextRunCount(now) - let resolvedURL = logsClient.currentRunFileURL(runCount, now) - await send(.setCurrentRun(RunLogFile(url: resolvedURL, date: now, runCount: runCount))) - fileURL = resolvedURL - let appVersion = Bundle.main - .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "(null)" - logger.log( - """ - App activity logging started. - Run \(runCount, privacy: .public) - App Version \(appVersion, privacy: .public) - OS \(ProcessInfo.processInfo.operatingSystemVersionString, privacy: .public) - """ - ) - } - await send(.refreshAvailableRuns) - - var cursor = cursor0 - while !Task.isCancelled { - let newEntries = (try? await logsClient.fetchNewEntries(cursor)) ?? [] - if let lastDate = newEntries.last?.date { - cursor = lastDate - await send(.didReceiveNewEntries(newEntries)) - try? await logsClient.appendToRunFile(newEntries, fileURL) - } - try await clock.sleep(for: .seconds(5)) - } - } - .cancellable(id: CancelID.pump, cancelInFlight: true) - - case .pausePump: - return .merge( - .run { [cursor = state.lastCursorDate, fileURL = state.currentRun?.url] send in - guard let fileURL else { return } - let newEntries = (try? await logsClient.fetchNewEntries(cursor)) ?? [] - guard !newEntries.isEmpty else { return } - await send(.didReceiveNewEntries(newEntries)) - try? await logsClient.appendToRunFile(newEntries, fileURL) - }, - .cancel(id: CancelID.pump) - ) - - case let .setCurrentRun(run): - state.currentRun = run - return .none - - case .didReceiveNewEntries(let entries): - state.currentRunLogs.append(contentsOf: entries) - state.lastCursorDate = entries.last?.date ?? state.lastCursorDate - if state.selectedRun == nil { - refreshDisplayedLogs(&state) - } - return .none - case .refreshAvailableRuns: return .run { send in await send(.availableRunsResponse(await logsClient.listRunFiles())) @@ -133,7 +70,6 @@ public struct AppActivityLogsReducer: Sendable { guard let url, state.previousRuns.contains(where: { $0.url == url }) else { state.selectedRun = nil state.selectedRunLogs = [] - refreshDisplayedLogs(&state) return .none } state.selectedRun = url @@ -146,12 +82,10 @@ public struct AppActivityLogsReducer: Sendable { case .runFileResponse(let logs): state.loadingState = .idle state.selectedRunLogs = logs - refreshDisplayedLogs(&state) return .none case .queryLogs(let keyword): state.keyword = keyword - refreshDisplayedLogs(&state) return .none case .navigateToFileApp: @@ -159,12 +93,4 @@ public struct AppActivityLogsReducer: Sendable { } } } - - private func refreshDisplayedLogs(_ state: inout State) { - let source = state.selectedRun == nil - ? state.currentRunLogs - : state.selectedRunLogs - state.displayedLogs = logsClient.query(source, state.keyword) - .sorted { $0.date > $1.date } - } } diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsSharedKeys.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsSharedKeys.swift new file mode 100644 index 000000000..424a28d9e --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsSharedKeys.swift @@ -0,0 +1,17 @@ +import Sharing +import AppModels + +// In-memory shared keys bridging the always-alive activity-logs pump (writer, owned by AppReducer) +// to the read-only activity-logs screen (a Setting-stack path element). Domain-typed keys live in +// their owning feature rather than a generic keys module, per the shared-keys design. +extension SharedReaderKey where Self == InMemoryKey.Default { + static var appActivityLogsCurrentRun: Self { + Self[.inMemory("appActivityLogs.currentRun"), default: nil] + } +} + +extension SharedReaderKey where Self == InMemoryKey<[AppActivityLog]>.Default { + static var appActivityLogsCurrentRunLogs: Self { + Self[.inMemory("appActivityLogs.currentRunLogs"), default: []] + } +} diff --git a/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift index 0568511f9..3b22a4305 100644 --- a/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift @@ -1,9 +1,10 @@ import OSLog import Testing +import Sharing import Foundation import AppModels import LogsClient -import SettingFeature +@testable import SettingFeature import ComposableArchitecture @Suite @@ -28,26 +29,33 @@ struct AppActivityLogsReducerTests { appended.withValue { $0.append(logs) } } - let store = TestStore(initialState: AppActivityLogsReducer.State(), reducer: AppActivityLogsReducer.init) { - $0.logsClient = client - $0.continuousClock = TestClock() - $0.date = .constant(.init(timeIntervalSince1970: 0)) + let storage = InMemoryStorage() + await withDependencies { + $0.defaultInMemoryStorage = storage + } operation: { + let store = TestStore( + initialState: AppActivityLogsPumpReducer.State(), + reducer: AppActivityLogsPumpReducer.init + ) { + $0.logsClient = client + $0.continuousClock = TestClock() + $0.date = .constant(.init(timeIntervalSince1970: 0)) + $0.defaultInMemoryStorage = storage + } + store.exhaustivity = .off + + await store.send(.startPump) + await store.receive(\.didReceiveNewEntries) + + #expect(store.state.currentRunLogs == [entryA, entryB]) + #expect(store.state.lastCursorDate == entryB.date) + + await store.send(.pausePump) + await store.finish() + + // The pump appended the batch to the per-run jsonl file exactly once. + #expect(appended.value == [[entryA, entryB]]) } - store.exhaustivity = .off - - await store.send(.startPump) - await store.receive(\.didReceiveNewEntries) - - #expect(store.state.currentRunLogs == [entryA, entryB]) - #expect(store.state.lastCursorDate == entryB.date) - // Newest entry is shown first. - #expect(store.state.displayedLogs == [entryB, entryA]) - - await store.send(.pausePump) - await store.finish() - - // The pump appended the batch to the per-run jsonl file exactly once. - #expect(appended.value == [[entryA, entryB]]) } @MainActor @@ -62,27 +70,38 @@ struct AppActivityLogsReducerTests { var client = LogsClient.noop client.readRunFile = { _ in [fileLog] } - var initialState = AppActivityLogsReducer.State() - initialState.currentRun = RunLogFile( - url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-100000-3.jsonl"), - date: .init(timeIntervalSince1970: 3600), - runCount: 3 - ) - initialState.previousRuns = [run] - initialState.currentRunLogs = [makeLog("live", secondsSince1970: 100)] - - let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { - $0.logsClient = client - } - - await store.send(.selectRun(run.url)) { - $0.selectedRun = run.url - $0.loadingState = .loading - } - await store.receive(\.runFileResponse) { - $0.loadingState = .idle - $0.selectedRunLogs = [fileLog] - $0.displayedLogs = [fileLog] + let storage = InMemoryStorage() + await withDependencies { + $0.defaultInMemoryStorage = storage + } operation: { + @Shared(.appActivityLogsCurrentRun) var currentRun: RunLogFile? + @Shared(.appActivityLogsCurrentRunLogs) var currentRunLogs: [AppActivityLog] + $currentRun.withLock { + $0 = RunLogFile( + url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-100000-3.jsonl"), + date: .init(timeIntervalSince1970: 3600), + runCount: 3 + ) + } + $currentRunLogs.withLock { $0 = [makeLog("live", secondsSince1970: 100)] } + + var initialState = AppActivityLogsReducer.State() + initialState.previousRuns = [run] + + let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { + $0.logsClient = client + $0.defaultInMemoryStorage = storage + } + + await store.send(.selectRun(run.url)) { + $0.selectedRun = run.url + $0.loadingState = .loading + } + await store.receive(\.runFileResponse) { + $0.loadingState = .idle + $0.selectedRunLogs = [fileLog] + } + #expect(store.state.displayedLogs == [fileLog]) } } @@ -90,25 +109,36 @@ struct AppActivityLogsReducerTests { @Test func testSelectingCurrentRunRestoresLiveLogs() async { let live = makeLog("live", secondsSince1970: 100) - var initialState = AppActivityLogsReducer.State() - initialState.currentRun = RunLogFile( - url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-100000-3.jsonl"), - date: .init(timeIntervalSince1970: 3600), - runCount: 3 - ) - initialState.currentRunLogs = [live] - initialState.selectedRun = URL(fileURLWithPath: "/tmp/ehpanda-20200101-090000-2.jsonl") - initialState.selectedRunLogs = [makeLog("archived", secondsSince1970: 5)] - initialState.displayedLogs = initialState.selectedRunLogs - - let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { - $0.logsClient = .noop - } - await store.send(.selectRun(nil)) { - $0.selectedRun = nil - $0.selectedRunLogs = [] - $0.displayedLogs = [live] + let storage = InMemoryStorage() + await withDependencies { + $0.defaultInMemoryStorage = storage + } operation: { + @Shared(.appActivityLogsCurrentRun) var currentRun: RunLogFile? + @Shared(.appActivityLogsCurrentRunLogs) var currentRunLogs: [AppActivityLog] + $currentRun.withLock { + $0 = RunLogFile( + url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-100000-3.jsonl"), + date: .init(timeIntervalSince1970: 3600), + runCount: 3 + ) + } + $currentRunLogs.withLock { $0 = [live] } + + var initialState = AppActivityLogsReducer.State() + initialState.selectedRun = URL(fileURLWithPath: "/tmp/ehpanda-20200101-090000-2.jsonl") + initialState.selectedRunLogs = [makeLog("archived", secondsSince1970: 5)] + + let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { + $0.logsClient = .noop + $0.defaultInMemoryStorage = storage + } + + await store.send(.selectRun(nil)) { + $0.selectedRun = nil + $0.selectedRunLogs = [] + } + #expect(store.state.displayedLogs == [live]) } } @@ -117,19 +147,26 @@ struct AppActivityLogsReducerTests { func testQueryLogsFiltersDisplayedLogs() async { let hello = makeLog("hello world", secondsSince1970: 10) let goodbye = makeLog("goodbye", secondsSince1970: 20) - var client = LogsClient.noop - client.query = { logs, keyword in logs.filter { $0.message.contains(keyword) } } - - var initialState = AppActivityLogsReducer.State() - initialState.currentRunLogs = [hello, goodbye] - - let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { - $0.logsClient = client - } - await store.send(.queryLogs("hello")) { - $0.keyword = "hello" - $0.displayedLogs = [hello] + let storage = InMemoryStorage() + await withDependencies { + $0.defaultInMemoryStorage = storage + } operation: { + @Shared(.appActivityLogsCurrentRunLogs) var currentRunLogs: [AppActivityLog] + $currentRunLogs.withLock { $0 = [hello, goodbye] } + + let store = TestStore( + initialState: AppActivityLogsReducer.State(), + reducer: AppActivityLogsReducer.init + ) { + $0.logsClient = .noop + $0.defaultInMemoryStorage = storage + } + + await store.send(.queryLogs("hello")) { + $0.keyword = "hello" + } + #expect(store.state.displayedLogs == [hello]) } } From 9080a6b894edf54a0f265f16ae093ba5fa6abb77 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 1 Jul 2026 23:19:50 +0800 Subject: [PATCH 409/614] Convert Setting navigation to StackState --- .../AppFeature/DataFlow/AppReducer.swift | 14 +-- .../AccountSettingReducer.swift | 57 ++++------- .../AccountSetting/AccountSettingView.swift | 27 +---- .../AppearanceSettingReducer.swift | 24 ++--- .../AppearanceSettingView.swift | 14 +-- .../GeneralSettingReducer.swift | 35 ++----- .../GeneralSetting/GeneralSettingView.swift | 12 +-- .../SettingFeature/Login/LoginReducer.swift | 3 + .../Sources/SettingFeature/SettingPath.swift | 41 ++++++++ .../SettingFeature/SettingReducer+Body.swift | 53 ++++++---- .../SettingFeature/SettingReducer.swift | 42 +++++--- .../Sources/SettingFeature/SettingView.swift | 99 +++++++++++-------- .../DownloadAutomationTests.swift | 2 +- 13 files changed, 212 insertions(+), 211 deletions(-) create mode 100644 AppPackage/Sources/SettingFeature/SettingPath.swift diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index ecd8c33ef..dbb44e40d 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -171,8 +171,8 @@ struct AppReducer { } case .clearPadSettingSubstates: - state.settingState.route = nil - return .send(.setting(.clearSubStates)) + state.settingState.path.removeAll() + return .none case .appRoute: return .none @@ -224,8 +224,8 @@ struct AppReducer { } effects.append(hapticEffect) case .setting: - if state.settingState.route != nil { - effects.append(.send(.setting(.setNavigation(nil)))) + if !state.settingState.path.isEmpty { + state.settingState.path.removeAll() effects.append(hapticEffect) } } @@ -243,14 +243,14 @@ struct AppReducer { .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), .send(.tabBar(.setTabBarItemType(.setting))) ] - effects.append(.send(.setting(.setNavigation(.account)))) + effects.append(.send(.setting(.settingRowTapped(.account)))) if !cookieClient.didLogin { effects.append( .run { send in let isPad = await deviceClient.isPad() let delay = UInt64(isPad ? 1200 : 200) try await Task.sleep(for: .milliseconds(delay)) - await send(.setting(.account(.setNavigation(.login)))) + await send(.setting(.pushLogin)) } ) } @@ -287,7 +287,7 @@ struct AppReducer { } return effects.isEmpty ? .none : .merge(effects) - case .setting(.account(.loadCookies)): + case .setting(.igneousRefreshed): guard state.isAwaitingIgneousForLaunchAutomation, !shouldDelayLaunchAutomationUntilIgneous(state: state) else { return .none } diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index 1d63666d9..3093714b6 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -10,11 +10,10 @@ import TTProgressHUDExt @Reducer public struct AccountSettingReducer: Sendable { - @dynamicMemberLookup @CasePathable - public enum Route: Equatable, Sendable { - case hud - case login - case ehSetting + // Transient copied-to-clipboard toast. Not navigation — drives the progressHUD overlay only. + @CasePathable + public enum HUD: Equatable, Sendable { + case copiedToClipboard } @Reducer @@ -27,32 +26,34 @@ public struct AccountSettingReducer: Sendable { case confirmLogout } + // Pushes handled by SettingReducer, which owns the Setting navigation stack. + public enum Delegate: Equatable, Sendable { + case pushLogin + case pushEhSetting + } + @ObservableState public struct State: Equatable, Sendable { - public var route: Route? @Presents public var destination: Destination.State? @Presents public var confirmationDialog: ConfirmationDialogState? + public var hud: HUD? public var ehCookiesState: CookiesState = .empty(.ehentai) public var exCookiesState: CookiesState = .empty(.exhentai) public var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded - public var loginState = LoginReducer.State() - public var ehSettingState = EhSettingReducer.State() + public init() {} } public enum Action: BindableAction, Equatable { case binding(BindingAction) - case setNavigation(Route?) case destination(PresentationAction) case presentWebView(URL) case confirmationDialog(PresentationAction) + case delegate(Delegate) case logoutButtonTapped case onLogoutConfirmButtonTapped - case clearSubStates case loadCookies case copyCookies(GalleryHost) - case login(LoginReducer.Action) - case ehSetting(EhSettingReducer.Action) } @Dependency(\.clipboardClient) private var clipboardClient @@ -63,9 +64,6 @@ public struct AccountSettingReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } .onChange(of: \.ehCookiesState) { _, state in .run(operation: { [value = state.ehCookiesState] _ in cookieClient.setCookies(state: value) }) } @@ -78,10 +76,6 @@ public struct AccountSettingReducer: Sendable { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - case .destination: return .none @@ -89,6 +83,9 @@ public struct AccountSettingReducer: Sendable { state.destination = .webView(url) return .none + case .delegate: + return .none + case .logoutButtonTapped: state.confirmationDialog = ConfirmationDialogState { TextState("") @@ -113,35 +110,18 @@ public struct AccountSettingReducer: Sendable { case .onLogoutConfirmButtonTapped: return .send(.loadCookies) - case .clearSubStates: - state.loginState = .init() - state.ehSettingState = .init() - return .merge( - .send(.login(.teardown)), - .send(.ehSetting(.teardown)) - ) - case .loadCookies: state.ehCookiesState = cookieClient.loadCookiesState(host: .ehentai) state.exCookiesState = cookieClient.loadCookiesState(host: .exhentai) return .none case .copyCookies(let host): + state.hud = .copiedToClipboard let cookiesDescription = cookieClient.getCookiesDescription(host: host) return .merge( - .send(.setNavigation(.hud)), .run(operation: { _ in clipboardClient.saveText(cookiesDescription) }), .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) ) - - case .login(.loginDone): - return cookieClient.didLogin ? .send(.setNavigation(nil)) : .none - - case .login: - return .none - - case .ehSetting: - return .none } } .haptics( @@ -151,9 +131,6 @@ public struct AccountSettingReducer: Sendable { ) .ifLet(\.$destination, action: \.destination) .ifLet(\.$confirmationDialog, action: \.confirmationDialog) - - Scope(state: \.loginState, action: \.login, child: LoginReducer.init) - Scope(state: \.ehSettingState, action: \.ehSetting, child: EhSettingReducer.init) } } diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index 4988b3f66..cc88b6453 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -39,9 +39,9 @@ struct AccountSettingView: View { AccountSection( showsNewDawnGreeting: $showsNewDawnGreeting, bypassesSNIFiltering: bypassesSNIFiltering, - loginAction: { store.send(.setNavigation(.login)) }, + loginAction: { store.send(.delegate(.pushLogin)) }, logoutDialogAction: { store.send(.logoutButtonTapped) }, - configureAccountAction: { store.send(.setNavigation(.ehSetting)) }, + configureAccountAction: { store.send(.delegate(.pushEhSetting)) }, manageTagsAction: { store.send(.presentWebView(Defaults.URL.myTags)) } ) } @@ -53,8 +53,8 @@ struct AccountSettingView: View { } .progressHUD( config: store.hudConfig, - unwrapping: $store.route, - case: \.hud + unwrapping: $store.hud, + case: \.copiedToClipboard ) .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) @@ -65,29 +65,10 @@ struct AccountSettingView: View { .autoBlur(radius: blurRadius) } .onAppear { store.send(.loadCookies) } - .background(navigationLinks) .navigationTitle(L10n.Localizable.AccountSettingView.Title.account) } } -// MARK: NavigationLinks -private extension AccountSettingView { - @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: $store.route, case: \.login) { _ in - LoginView( - store: store.scope(state: \.loginState, action: \.login), - bypassesSNIFiltering: bypassesSNIFiltering, blurRadius: blurRadius - ) - } - NavigationLink(unwrapping: $store.route, case: \.ehSetting) { _ in - EhSettingView( - store: store.scope(state: \.ehSettingState, action: \.ehSetting), - bypassesSNIFiltering: bypassesSNIFiltering, blurRadius: blurRadius - ) - } - } -} - // MARK: AccountSection private struct AccountSection: View { @Binding private var showsNewDawnGreeting: Bool diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift index 412734a22..ead871c5e 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift @@ -2,33 +2,27 @@ import ComposableArchitecture @Reducer public struct AppearanceSettingReducer: Sendable { - @CasePathable - public enum Route: Sendable { - case appIcon + // Pushes handled by SettingReducer, which owns the Setting navigation stack. The screen itself is + // stateless — its controls bind directly into `SettingReducer.State.setting` from the root. + public enum Delegate: Equatable, Sendable { + case pushAppIcon } @ObservableState public struct State: Equatable, Sendable { - public var route: Route? + public init() {} } - public enum Action: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(Route?) + public enum Action: Equatable, Sendable { + case delegate(Delegate) } public init() {} public var body: some Reducer { - BindingReducer() - - Reduce { state, action in + Reduce { _, action in switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route + case .delegate: return .none } } diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift index 4894ae762..4f4f0bdf9 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -2,11 +2,10 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import SwiftUINavigationExt import AppComponents struct AppearanceSettingView: View { - @Bindable private var store: StoreOf + private let store: StoreOf @Binding private var preferredColorScheme: PreferredColorScheme @Binding private var accentColor: Color @@ -53,7 +52,7 @@ struct AppearanceSettingView: View { ColorPicker(L10n.Localizable.AppearanceSettingView.Title.tintColor, selection: $accentColor) Button(L10n.Localizable.AppearanceSettingView.Button.appIcon) { - store.send(.setNavigation(.appIcon)) + store.send(.delegate(.pushAppIcon)) } .foregroundStyle(.primary) .withArrow() @@ -97,19 +96,12 @@ struct AppearanceSettingView: View { ) } } - .background(navigationLink) .navigationTitle(L10n.Localizable.AppearanceSettingView.Title.appearance) } - - private var navigationLink: some View { - NavigationLink(unwrapping: $store.route, case: \.appIcon) { _ in - AppIconView(appIconType: $appIconType) - } - } } // MARK: SelectAppIconView -private struct AppIconView: View { +struct AppIconView: View { @Binding private var appIconType: AppIconType init(appIconType: Binding) { diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index 661206a7f..6eadb73a9 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -12,33 +12,31 @@ private let logger = Logger(category: .init(describing: GeneralSettingReducer.se @Reducer public struct GeneralSettingReducer: Sendable { - @CasePathable - public enum Route: Sendable { - case appActivityLogs - } - public enum Dialog: Equatable, Sendable { case confirmClearCache case confirmRemoveCustomTranslations } + // Pushes handled by SettingReducer, which owns the Setting navigation stack. + public enum Delegate: Equatable, Sendable { + case pushAppActivityLogs + } + @ObservableState public struct State: Equatable, Sendable { - public var route: Route? @Presents public var confirmationDialog: ConfirmationDialogState? public var loadingState: LoadingState = .idle public var diskImageCacheSize = "0 KB" public var passcodeNotSet = false - public var appActivityLogsState = AppActivityLogsReducer.State() + public init() {} } public enum Action: BindableAction, Equatable { case binding(BindingAction) - case setNavigation(Route?) case confirmationDialog(PresentationAction) - case clearSubStates + case delegate(Delegate) case onTranslationsFilePicked(URL) case removeCustomTranslationsButtonTapped case onRemoveCustomTranslations @@ -49,8 +47,6 @@ public struct GeneralSettingReducer: Sendable { case navigateToSystemSetting case calculateWebImageDiskCache case calculateWebImageDiskCacheDone(UInt?) - - case appActivityLogs(AppActivityLogsReducer.Action) } @Dependency(\.authorizationClient) private var authorizationClient @@ -62,18 +58,14 @@ public struct GeneralSettingReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } Reduce { state, action in switch action { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none + case .delegate: + return .none case .removeCustomTranslationsButtonTapped: state.confirmationDialog = ConfirmationDialogState { @@ -114,10 +106,6 @@ public struct GeneralSettingReducer: Sendable { case .confirmationDialog: return .none - case .clearSubStates: - // The activity-logs pump is app-wide and always alive; never reset it on navigation. - return .none - case .onTranslationsFilePicked: return .none @@ -154,13 +142,8 @@ public struct GeneralSettingReducer: Sendable { formatter.allowedUnits = .useAll state.diskImageCacheSize = formatter.string(fromByteCount: .init(bytes)) return .none - - case .appActivityLogs: - return .none } } .ifLet(\.$confirmationDialog, action: \.confirmationDialog) - - Scope(state: \.appActivityLogsState, action: \.appActivityLogs, child: AppActivityLogsReducer.init) } } diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index 8ee3874dd..cc4b284ef 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -3,7 +3,6 @@ import AppModels import Resources import FilePicker import ComposableArchitecture -import SwiftUINavigationExt import AppComponents struct GeneralSettingView: View { @@ -60,7 +59,7 @@ struct GeneralSettingView: View { .foregroundStyle(.tint) } Button(L10n.Localizable.GeneralSettingView.Button.appActivityLogs) { - store.send(.setNavigation(.appActivityLogs)) + store.send(.delegate(.pushAppActivityLogs)) } .foregroundColor(.primary).withArrow() } @@ -166,17 +165,8 @@ struct GeneralSettingView: View { store.send(.checkPasscodeSetting) store.send(.calculateWebImageDiskCache) } - .background(navigationLink) .navigationTitle(L10n.Localizable.GeneralSettingView.Title.general) } - - private var navigationLink: some View { - NavigationLink(unwrapping: $store.route, case: \.appActivityLogs) { _ in - AppActivityLogsView( - store: store.scope(state: \.appActivityLogsState, action: \.appActivityLogs) - ) - } - } } struct GeneralSettingView_Previews: PreviewProvider { diff --git a/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift index fa66d60f9..1e2af7bd6 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift @@ -55,6 +55,7 @@ public struct LoginReducer: Sendable { @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient + @Dependency(\.dismiss) private var dismiss public init() {} @@ -98,6 +99,8 @@ public struct LoginReducer: Sendable { logger.notice("Login succeeded.") await hapticsClient.generateNotificationFeedback(.success) })) + // Pop this login screen off the Setting stack now that we're signed in. + effects.append(.run { _ in await dismiss() }) } else { state.loginState = .failed(.unknown) effects.append(.run(operation: { _ in diff --git a/AppPackage/Sources/SettingFeature/SettingPath.swift b/AppPackage/Sources/SettingFeature/SettingPath.swift new file mode 100644 index 000000000..0bb1c89a9 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/SettingPath.swift @@ -0,0 +1,41 @@ +import ComposableArchitecture + +// The single flat navigation stack for the Setting tab, owned by `SettingReducer`. Every drill-down +// screen is a path element; child screens never push directly — they emit `delegate` actions that +// `SettingReducer` observes and appends to `path`. State-free screens (driven purely by bindings into +// `SettingReducer.State.setting`) are backed by `StaticSettingScreenReducer` and built from those +// root bindings in `SettingView`'s destination switch. +@Reducer +public enum SettingPath { + case account(AccountSettingReducer) + case general(GeneralSettingReducer) + case appearance(AppearanceSettingReducer) + case login(LoginReducer) + case ehSetting(EhSettingReducer) + case appActivityLogs(AppActivityLogsReducer) + case download(StaticSettingScreenReducer) + case reading(StaticSettingScreenReducer) + case laboratory(StaticSettingScreenReducer) + case about(StaticSettingScreenReducer) + case appIcon(StaticSettingScreenReducer) +} + +extension SettingPath.State: Equatable, Sendable {} + +// A placeholder reducer for Setting screens that hold no state and run no logic (their views are +// driven entirely by bindings into `SettingReducer.State.setting`). Shared across every such leaf. +@Reducer +public struct StaticSettingScreenReducer: Sendable { + @ObservableState + public struct State: Equatable, Sendable { + public init() {} + } + + public enum Action: Equatable, Sendable {} + + public init() {} + + public var body: some Reducer { + EmptyReducer() + } +} diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index f73dd31ab..8d92de532 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -101,14 +101,29 @@ extension SettingReducer { .send(.syncTagTranslator) ) - case .setNavigation(let route): - state.route = route + case .settingRowTapped(let screen): + state.path.append(screen.pathElement) return .none - case .clearSubStates: - state.accountSettingState = .init() - state.generalSettingState = .init() - state.appearanceSettingState = .init() + case .pushLogin: + state.path.append(.login(.init())) + return .none + + // Account emits a delegate to push its children onto the shared stack. + case .path(.element(id: _, action: .account(.delegate(.pushLogin)))): + state.path.append(.login(.init())) + return .none + + case .path(.element(id: _, action: .account(.delegate(.pushEhSetting)))): + state.path.append(.ehSetting(.init())) + return .none + + case .path(.element(id: _, action: .general(.delegate(.pushAppActivityLogs)))): + state.path.append(.appActivityLogs(.init())) + return .none + + case .path(.element(id: _, action: .appearance(.delegate(.pushAppIcon)))): + state.path.append(.appIcon(.init())) return .none case .syncAppIconType: @@ -171,12 +186,12 @@ extension SettingReducer { return .run { send in logger.notice("Igneous token refreshed.") cookieClient.setCredentials(response: response) - await send(.account(.loadCookies)) + await send(.igneousRefreshed) } } return .merge( .run { _ in logger.notice("Igneous refresh failed.") }, - .send(.account(.loadCookies)) + .send(.igneousRefreshed) ) case .fetchUserInfo: @@ -253,7 +268,9 @@ extension SettingReducer { } return .none - case .account(.login(.loginDone)): + // Login is a top-level stack element; it self-dismisses on success while Setting runs + // the post-login setup. + case .path(.element(id: _, action: .login(.loginDone))): return .merge( .run(operation: { _ in cookieClient.removeYay() }), .run(operation: { _ in cookieClient.syncExCookies() }), @@ -264,7 +281,7 @@ extension SettingReducer { .send(.fetchEhProfileIndex) ) - case .account(.onLogoutConfirmButtonTapped): + case .path(.element(id: _, action: .account(.onLogoutConfirmButtonTapped))): state.user = User() return .merge( .send(.syncUser), @@ -274,31 +291,25 @@ extension SettingReducer { .run { _ in logger.notice("Logged out.") } ) - case .account: - return .none - - case .general(.onTranslationsFilePicked(let url)): + case .path(.element(id: _, action: .general(.onTranslationsFilePicked(let url)))): return .run { send in let result = await fileClient.importTagTranslator(url) await send(.fetchTagTranslatorDone(result)) } - case .general(.onRemoveCustomTranslations): + case .path(.element(id: _, action: .general(.onRemoveCustomTranslations))): state.tagTranslator.hasCustomTranslations = false state.tagTranslator.translations = .init() return .send(.syncTagTranslator) - case .general: + case .igneousRefreshed: return .none - case .appearance: + case .path: return .none } } - - Scope(state: \.accountSettingState, action: \.account, child: AccountSettingReducer.init) - Scope(state: \.generalSettingState, action: \.general, child: GeneralSettingReducer.init) - Scope(state: \.appearanceSettingState, action: \.appearance, child: AppearanceSettingReducer.init) + .forEach(\.path, action: \.path) } } diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index cadef38b3..eb6dfc65b 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -14,8 +14,9 @@ import AppDelegateClient @Reducer public struct SettingReducer: Sendable { - @CasePathable - public enum Route: Int, Equatable, Hashable, Identifiable, CaseIterable, Sendable { + // The top-level Setting screens listed in the root menu. Each maps 1:1 to a `SettingPath` + // element that `settingRowTapped` appends when its row is tapped. + public enum RootScreen: Int, Equatable, Hashable, Identifiable, CaseIterable, Sendable { public var id: Int { rawValue } case account @@ -25,6 +26,25 @@ public struct SettingReducer: Sendable { case reading case laboratory case about + + var pathElement: SettingPath.State { + switch self { + case .account: + return .account(.init()) + case .general: + return .general(.init()) + case .appearance: + return .appearance(.init()) + case .download: + return .download(.init()) + case .reading: + return .reading(.init()) + case .laboratory: + return .laboratory(.init()) + case .about: + return .about(.init()) + } + } } @ObservableState @@ -36,13 +56,9 @@ public struct SettingReducer: Sendable { public var hasLoadedInitialSetting = false - public var route: Route? + public var path = StackState() public var tagTranslatorLoadingState: LoadingState = .idle - public var accountSettingState = AccountSettingReducer.State() - public var generalSettingState = GeneralSettingReducer.State() - public var appearanceSettingState = AppearanceSettingReducer.State() - public init() {} mutating func setGreeting(_ greeting: Greeting) { @@ -72,10 +88,11 @@ public struct SettingReducer: Sendable { } } - public enum Action: BindableAction, Equatable { + public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates + case path(StackActionOf) + case settingRowTapped(RootScreen) + case pushLogin case syncAppIconType case syncAppIconTypeDone(String?) @@ -100,10 +117,7 @@ public struct SettingReducer: Sendable { case fetchEhProfileIndexDone(Result) case fetchFavoriteCategories case fetchFavoriteCategoriesDone(Result<[Int: String], AppError>) - - case account(AccountSettingReducer.Action) - case general(GeneralSettingReducer.Action) - case appearance(AppearanceSettingReducer.Action) + case igneousRefreshed } @Dependency(\.applicationClient) var applicationClient diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index e4b164a2d..82fdc0859 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -2,7 +2,6 @@ import SwiftUI import Resources import SFSafeSymbols import ComposableArchitecture -import SwiftUINavigationExt import AppComponents import ReadingSettingFeature @@ -17,39 +16,40 @@ public struct SettingView: View { // MARK: SettingView public var body: some View { - NavigationView { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { ScrollView { VStack(spacing: 0) { - ForEach(SettingReducer.Route.allCases) { route in - SettingRow(rowType: route) { - store.send(.setNavigation($0)) + ForEach(SettingReducer.RootScreen.allCases) { screen in + SettingRow(rowType: screen) { + store.send(.settingRowTapped($0)) } } } .padding(.vertical, 40).padding(.horizontal) } - .background(navigationLinks) .navigationTitle(L10n.Localizable.SettingView.Title.setting) + } destination: { pathStore in + destination(pathStore) + .tint(store.setting.accentColor) } } -} -// MARK: NavigationLinks -private extension SettingView { - @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: $store.route, case: \.account) { _ in + // MARK: Destinations + @ViewBuilder + private func destination(_ pathStore: StoreOf) -> some View { + switch pathStore.case { + case .account(let accountStore): AccountSettingView( - store: store.scope(state: \.accountSettingState, action: \.account), + store: accountStore, galleryHost: $store.setting.galleryHost, showsNewDawnGreeting: $store.setting.showsNewDawnGreeting, bypassesSNIFiltering: store.setting.bypassesSNIFiltering, blurRadius: blurRadius ) - .tint(store.setting.accentColor) - } - NavigationLink(unwrapping: $store.route, case: \.general) { _ in + + case .general(let generalStore): GeneralSettingView( - store: store.scope(state: \.generalSettingState, action: \.general), + store: generalStore, tagTranslatorLoadingState: store.tagTranslatorLoadingState, tagTranslatorEmpty: store.tagTranslator.translations.isEmpty, tagTranslatorHasCustomTranslations: store.tagTranslator.hasCustomTranslations, @@ -62,11 +62,10 @@ private extension SettingView { backgroundBlurRadius: $store.setting.backgroundBlurRadius, autoLockPolicy: $store.setting.autoLockPolicy ) - .tint(store.setting.accentColor) - } - NavigationLink(unwrapping: $store.route, case: \.appearance) { _ in + + case .appearance(let appearanceStore): AppearanceSettingView( - store: store.scope(state: \.appearanceSettingState, action: \.appearance), + store: appearanceStore, preferredColorScheme: $store.setting.preferredColorScheme, accentColor: $store.setting.accentColor, appIconType: $store.setting.appIconType, @@ -75,9 +74,32 @@ private extension SettingView { listTagsNumberMaximum: $store.setting.listTagsNumberMaximum, displaysJapaneseTitle: $store.setting.displaysJapaneseTitle ) - .tint(store.setting.accentColor) - } - NavigationLink(unwrapping: $store.route, case: \.reading) { _ in + + case .login(let loginStore): + LoginView( + store: loginStore, + bypassesSNIFiltering: store.setting.bypassesSNIFiltering, + blurRadius: blurRadius + ) + + case .ehSetting(let ehSettingStore): + EhSettingView( + store: ehSettingStore, + bypassesSNIFiltering: store.setting.bypassesSNIFiltering, + blurRadius: blurRadius + ) + + case .appActivityLogs(let logsStore): + AppActivityLogsView(store: logsStore) + + case .download: + DownloadSettingView( + downloadThreadLimit: $store.setting.downloadThreadLimit, + downloadAllowCellular: $store.setting.downloadAllowCellular, + downloadAutoRetryFailedPages: $store.setting.downloadAutoRetryFailedPages + ) + + case .reading: ReadingSettingView( readingDirection: $store.setting.readingDirection, prefetchLimit: $store.setting.prefetchLimit, @@ -86,24 +108,17 @@ private extension SettingView { maximumScaleFactor: $store.setting.maximumScaleFactor, doubleTapScaleFactor: $store.setting.doubleTapScaleFactor ) - .tint(store.setting.accentColor) - } - NavigationLink(unwrapping: $store.route, case: \.download) { _ in - DownloadSettingView( - downloadThreadLimit: $store.setting.downloadThreadLimit, - downloadAllowCellular: $store.setting.downloadAllowCellular, - downloadAutoRetryFailedPages: $store.setting.downloadAutoRetryFailedPages - ) - .tint(store.setting.accentColor) - } - NavigationLink(unwrapping: $store.route, case: \.laboratory) { _ in + + case .laboratory: LaboratorySettingView( bypassesSNIFiltering: $store.setting.bypassesSNIFiltering ) - .tint(store.setting.accentColor) - } - NavigationLink(unwrapping: $store.route, case: \.about) { _ in - AboutView().tint(store.setting.accentColor) + + case .about: + AboutView() + + case .appIcon: + AppIconView(appIconType: $store.setting.appIconType) } } } @@ -113,8 +128,8 @@ private struct SettingRow: View { @Environment(\.colorScheme) private var colorScheme @State private var isPressing = false - private let rowType: SettingReducer.Route - private let tapAction: (SettingReducer.Route) -> Void + private let rowType: SettingReducer.RootScreen + private let tapAction: (SettingReducer.RootScreen) -> Void private var color: Color { colorScheme == .light ? Color(.darkGray) : Color(.lightGray) @@ -123,7 +138,7 @@ private struct SettingRow: View { isPressing ? color.opacity(0.1) : .clear } - init(rowType: SettingReducer.Route, tapAction: @escaping (SettingReducer.Route) -> Void) { + init(rowType: SettingReducer.RootScreen, tapAction: @escaping (SettingReducer.RootScreen) -> Void) { self.rowType = rowType self.tapAction = tapAction } @@ -148,7 +163,7 @@ private struct SettingRow: View { } // MARK: Definition -extension SettingReducer.Route { +extension SettingReducer.RootScreen { var value: String { switch self { case .account: diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift index ea7f3f9fa..f4c6d9514 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift @@ -316,7 +316,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { #expect(store.state.isAwaitingIgneousForLaunchAutomation) await store.send(.setting(.fetchIgneousDone(.failure(.networkingFailed)))) - await store.receive(\.setting.account.loadCookies) + await store.receive(\.setting.igneousRefreshed) #expect(store.state.didRunLaunchAutomation == false) #expect(store.state.isAwaitingIgneousForLaunchAutomation) } From 40e95b1e43cb7111daf09526abb6d800660340e8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 00:58:55 +0800 Subject: [PATCH 410/614] Convert gallery drill-down to StackState --- .../AppFeature/DataFlow/AppReducer.swift | 39 ++-- .../AppFeature/DataFlow/AppRouteReducer.swift | 98 ++++---- .../AppFeature/View/TabBar/TabBarView.swift | 24 +- .../Comments/CommentsReducer.swift | 88 ++++--- .../DetailFeature/Comments/CommentsView.swift | 17 +- .../DetailFeature/DetailReducer+Actions.swift | 66 +----- .../DetailFeature/DetailReducer+Fetch.swift | 24 +- .../Sources/DetailFeature/DetailReducer.swift | 74 +++--- .../DetailSearch/DetailSearchReducer.swift | 39 +--- .../DetailSearch/DetailSearchView.swift | 116 ++++------ .../DetailFeature/DetailView+Navigation.swift | 38 --- .../Sources/DetailFeature/DetailView.swift | 17 +- .../DetailFeature/GalleryDeepLink.swift | 10 + .../DetailFeature/GalleryDestination.swift | 90 ++++++++ .../GalleryInfos/GalleryInfosReducer.swift | 9 + .../DetailFeature/GalleryNavigation.swift | 53 +++++ .../Sources/DetailFeature/GalleryPath.swift | 17 ++ .../Previews/PreviewsReducer.swift | 7 + .../DownloadsFeature/DownloadsReducer.swift | 66 +++--- .../DownloadsFeature/DownloadsView.swift | 50 +--- .../FavoritesFeature/FavoritesReducer.swift | 53 ++--- .../FavoritesFeature/FavoritesView.swift | 152 ++++++------ .../Frontpage/FrontpageReducer.swift | 37 +-- .../HomeFeature/Frontpage/FrontpageView.swift | 105 +++------ .../HomeFeature/History/HistoryReducer.swift | 36 +-- .../HomeFeature/History/HistoryView.swift | 79 ++----- AppPackage/Sources/HomeFeature/HomePath.swift | 30 +++ .../HomeFeature/HomeReducer+Body.swift | 91 ++++---- .../Sources/HomeFeature/HomeReducer.swift | 35 +-- AppPackage/Sources/HomeFeature/HomeView.swift | 217 +++++++----------- .../HomeFeature/Popular/PopularReducer.swift | 37 +-- .../HomeFeature/Popular/PopularView.swift | 77 ++----- .../Toplists/ToplistsReducer.swift | 36 +-- .../HomeFeature/Toplists/ToplistsView.swift | 89 +++---- .../HomeFeature/Watched/WatchedReducer.swift | 37 +-- .../HomeFeature/Watched/WatchedView.swift | 155 +++++-------- .../Sources/SearchFeature/SearchPath.swift | 26 +++ .../Sources/SearchFeature/SearchReducer.swift | 58 ++--- .../SearchFeature/SearchRootReducer.swift | 101 ++++---- .../SearchFeature/SearchRootView.swift | 77 ++----- .../Sources/SearchFeature/SearchView.swift | 152 +++++------- .../DownloadsReducerActionTests.swift | 16 +- 42 files changed, 1106 insertions(+), 1532 deletions(-) create mode 100644 AppPackage/Sources/DetailFeature/GalleryDeepLink.swift create mode 100644 AppPackage/Sources/DetailFeature/GalleryDestination.swift create mode 100644 AppPackage/Sources/DetailFeature/GalleryNavigation.swift create mode 100644 AppPackage/Sources/DetailFeature/GalleryPath.swift create mode 100644 AppPackage/Sources/HomeFeature/HomePath.swift create mode 100644 AppPackage/Sources/SearchFeature/SearchPath.swift diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index dbb44e40d..6779e5028 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -40,7 +40,6 @@ struct AppReducer { case binding(BindingAction) case onScenePhaseChange(ScenePhase) case runLaunchAutomation - case clearPadSettingSubstates case appDelegate(AppDelegateReducer.Action) case appRoute(AppRouteReducer.Action) @@ -66,8 +65,13 @@ struct AppReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.appRouteState.route) { _, state in - state.appRouteState.route == nil ? .send(.appRoute(.clearSubStates)) : .none + .onChange(of: \.appRouteState.destination) { oldValue, state in + // iPad presents Setting as a modal sheet; when it's dismissed, reset its navigation + // stack so reopening starts at the root. + if oldValue?.setting != nil, state.appRouteState.destination == nil { + state.settingState.path.removeAll() + } + return .none } .onChange(of: \.settingState.setting) { _, _ in .send(.setting(.syncSetting)) @@ -164,16 +168,6 @@ struct AppReducer { case .appDelegate: return .none - case .appRoute(.clearSubStates): - return .run { send in - guard await deviceClient.isPad() else { return } - await send(.clearPadSettingSubstates) - } - - case .clearPadSettingSubstates: - state.settingState.path.removeAll() - return .none - case .appRoute: return .none @@ -197,28 +191,28 @@ struct AppReducer { if type == state.tabBarState.tabBarItemType { switch type { case .home: - if state.homeState.route != nil { - effects.append(.send(.home(.setNavigation(nil)))) + if !state.homeState.path.isEmpty { + state.homeState.path.removeAll() } else { effects.append(.send(.home(.fetchAllGalleries))) } case .favorites: - if state.favoritesState.route != nil { - effects.append(.send(.favorites(.setNavigation(nil)))) + if !state.favoritesState.path.isEmpty { + state.favoritesState.path.removeAll() effects.append(hapticEffect) } else if cookieClient.didLogin { effects.append(.send(.favorites(.fetchGalleries()))) effects.append(hapticEffect) } case .search: - if state.searchRootState.route != nil { - effects.append(.send(.searchRoot(.setNavigation(nil)))) + if !state.searchRootState.path.isEmpty { + state.searchRootState.path.removeAll() } else { effects.append(.send(.searchRoot(.fetchDatabaseInfos))) } case .downloads: - if state.downloadsState.route != nil { - effects.append(.send(.downloads(.setNavigation(nil)))) + if !state.downloadsState.path.isEmpty { + state.downloadsState.path.removeAll() } else { effects.append(.send(.downloads(.fetchDownloads))) } @@ -238,7 +232,8 @@ struct AppReducer { case .tabBar: return .none - case .home(.watched(.onNotLoginViewButtonTapped)), .favorites(.onNotLoginViewButtonTapped): + case .home(.path(.element(id: _, action: .watched(.onNotLoginViewButtonTapped)))), + .favorites(.onNotLoginViewButtonTapped): var effects: [Effect] = [ .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), .send(.tabBar(.setTabBarItemType(.setting))) diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index c59678dde..fc9aac964 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -2,7 +2,6 @@ import AppTools import SwiftUI import AppModels import ComposableArchitecture -import SwiftUINavigationExt import URLClient import UserDefaultsClient import HapticsClient @@ -11,14 +10,12 @@ import NetworkingFeature import ClipboardClient import TTProgressHUDExt import DetailFeature -import ComposableArchitectureExt @Reducer struct AppRouteReducer { @CasePathable enum Route: Equatable, Hashable { case hud - case detail(String) } @Reducer @@ -32,24 +29,23 @@ struct AppRouteReducer { @ObservableState struct State: Equatable { var route: Route? + // The deep-link/clipboard gallery, presented modally as the root of its own gallery stack. + @Presents var detail: DetailReducer.State? + var path = StackState() @Presents var destination: Destination.State? var hudConfig: ProgressHUDConfigState = .loading() - var detailState: Heap - - init() { - detailState = .init(.init()) - } + init() {} } enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) case destination(PresentationAction) + case detail(PresentationAction) + case path(StackActionOf) case presentSetting case presentNewDawn(Greeting) case setHUDConfig(ProgressHUDConfigState) - case clearSubStates case detectClipboardURL case handleDeepLink(URL) @@ -60,8 +56,6 @@ struct AppRouteReducer { case fetchGallery(URL, Bool) case fetchGalleryDone(URL, Result) case fetchGreetingDone(Result) - - case detail(DetailReducer.Action) } @Dependency(\.userDefaultsClient) private var userDefaultsClient @@ -72,22 +66,44 @@ struct AppRouteReducer { var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } Reduce { state, action in switch action { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - case .destination: return .none + case .detail(.dismiss): + state.path.removeAll() + return .none + + case let .detail(.presented(.delegate(delegate))): + if let next = GalleryNavigation.nextScreen(for: .detail(.delegate(delegate))) { + state.path.append(next) + } + return .none + + case .detail: + return .none + + case let .path(.element(id: _, action: .comments(.delegate(.performedCommentAction(gid))))): + if state.detail?.gid == gid { + return .send(.detail(.presented(.fetchGalleryDetail))) + } + guard let id = state.path.detailID(forGID: gid) else { return .none } + return .send(.path(.element(id: id, action: .detail(.fetchGalleryDetail)))) + + case let .path(.element(id: _, action: elementAction)): + if let next = GalleryNavigation.nextScreen(for: elementAction) { + state.path.append(next) + } + return .none + + case .path: + return .none + case .presentSetting: state.destination = .setting(.init()) return .none @@ -100,10 +116,6 @@ struct AppRouteReducer { state.hudConfig = config return .none - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) - case .detectClipboardURL: let currentChangeCount = clipboardClient.changeCount() guard currentChangeCount != userDefaultsClient @@ -120,10 +132,10 @@ struct AppRouteReducer { let url = urlClient.resolveAppSchemeURL(url) ?? url guard urlClient.checkIfHandleable(url) else { return .none } var delay = 0 - if case .detail = state.route { + if state.detail != nil { delay = 1000 - state.route = nil - state.detailState.wrappedValue = .init() + state.detail = nil + state.path.removeAll() } let analysis = urlClient.analyzeURL(url) let gid = urlClient.parseGalleryID(url) @@ -143,27 +155,17 @@ struct AppRouteReducer { let pageIndex = analysis.pageIndex let commentID = analysis.commentID let gid = urlClient.parseGalleryID(url) + var deepLink: GalleryDeepLink? var effects = [Effect]() - state.detailState.wrappedValue = .init() - effects.append(.send(.detail(.fetchDatabaseInfos(gid)))) if let pageIndex = pageIndex { effects.append(.send(.updateReadingProgress(gid, pageIndex))) - effects.append( - .run { send in - try await Task.sleep(for: .milliseconds(500)) - await send(.detail(.presentReading)) - } - ) + deepLink = .reading(page: pageIndex) } else if let commentID = commentID { - state.detailState.wrappedValue?.commentsState.wrappedValue?.scrollCommentID = commentID - effects.append( - .run { send in - try await Task.sleep(for: .milliseconds(500)) - await send(.detail(.setNavigation(.comments(url)))) - } - ) + deepLink = .comments(commentID: commentID) } - effects.append(.send(.setNavigation(.detail(gid)))) + state.path.removeAll() + state.detail = DetailReducer.State(gid: gid, pendingDeepLink: deepLink) + effects.append(.run(operation: { _ in await hapticsClient.generateFeedback(.light) })) return .merge(effects) case .updateReadingProgress(let gid, let progress): @@ -202,9 +204,6 @@ struct AppRouteReducer { return .send(.presentNewDawn(greeting)) } return .none - - case .detail: - return .none } } .haptics( @@ -212,14 +211,9 @@ struct AppRouteReducer { case: \.newDawn, hapticsClient: hapticsClient ) - .haptics( - unwrapping: \.route, - case: \.detail, - hapticsClient: hapticsClient - ) .ifLet(\.$destination, action: \.destination) - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) + .ifLet(\.$detail, action: \.detail) { DetailReducer() } + .forEach(\.path, action: \.path) } } diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index ce384cd8c..a0e48b0f4 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -101,14 +101,22 @@ struct TabBarView: View { .accentColor(store.settingState.setting.accentColor) .autoBlur(radius: store.appLockState.blurRadius) } - .sheet(item: $store.appRouteState.route.sending(\.appRoute.setNavigation).detail, id: \.self) { route in - NavigationView { + .sheet(item: $store.scope(state: \.appRouteState.detail, action: \.appRoute.detail)) { detailStore in + NavigationStack( + path: $store.scope(state: \.appRouteState.path, action: \.appRoute.path) + ) { DetailView( - store: store.scope( - state: \.appRouteState.detailState.wrappedValue!, - action: \.appRoute.detail - ), - gid: route.wrappedValue, user: store.settingState.user, + store: detailStore, + gid: detailStore.gid, + user: store.settingState.user, + setting: $store.settingState.setting, + blurRadius: store.appLockState.blurRadius, + tagTranslator: store.settingState.tagTranslator + ) + } destination: { elementStore in + galleryDestination( + elementStore, + user: store.settingState.user, setting: $store.settingState.setting, blurRadius: store.appLockState.blurRadius, tagTranslator: store.settingState.tagTranslator @@ -116,8 +124,6 @@ struct TabBarView: View { } .accentColor(store.settingState.setting.accentColor) .autoBlur(radius: store.appLockState.blurRadius) - .environment(\.inSheet, true) - .navigationViewStyle(.stack) } .progressHUD( config: store.appRouteState.hudConfig, diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index 9c21da087..2bd7118da 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -1,7 +1,6 @@ import Foundation import AppModels import ComposableArchitecture -import SwiftUINavigationExt import URLClient import ApplicationClient import HapticsClient @@ -9,14 +8,12 @@ import DatabaseClient import NetworkingFeature import CookieClient import TTProgressHUDExt -import ComposableArchitectureExt @Reducer public struct CommentsReducer: Sendable { @CasePathable public enum Route: Equatable, Sendable { case hud - case detail(String) } @Reducer @@ -25,6 +22,13 @@ public struct CommentsReducer: Sendable { case postComment(String) } + public enum Delegate: Equatable, Sendable { + // Open the linked gallery (optionally deep-linking to a page or comment) as a new stack element. + case pushDetail(String, GalleryDeepLink?) + // A comment was voted/edited; ask the host to refresh the detail with this gid so it stays in sync. + case performedCommentAction(String) + } + private enum CancelID: CaseIterable { case postComment, voteComment, fetchGallery } @@ -40,20 +44,32 @@ public struct CommentsReducer: Sendable { public var scrollCommentID: String? public var scrollRowOpacity: Double = 1 - public var detailState: Heap - - public init() { - detailState = .init(.init()) + // Display data captured when this screen is pushed onto the host's gallery stack. + public var gid = "" + public var token = "" + public var apiKey = "" + public var galleryURL: URL + public var comments = [GalleryComment]() + + public init( + gid: String = "", token: String = "", apiKey: String = "", + galleryURL: URL, comments: [GalleryComment] = [], scrollCommentID: String? = nil + ) { + self.gid = gid + self.token = token + self.apiKey = apiKey + self.galleryURL = galleryURL + self.comments = comments + self.scrollCommentID = scrollCommentID } } public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) case destination(PresentationAction) case presentPostComment(String) - case clearSubStates case clearScrollCommentID + case delegate(Delegate) case setHUDConfig(ProgressHUDConfigState) case setPostCommentFocused(Bool) @@ -73,8 +89,6 @@ public struct CommentsReducer: Sendable { case performCommentActionDone(Result) case fetchGallery(URL, Bool) case fetchGalleryDone(URL, Result) - - case detail(DetailReducer.Action) } @Dependency(\.applicationClient) private var applicationClient @@ -87,19 +101,12 @@ public struct CommentsReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } Reduce { state, action in switch action { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - case .destination: return .none @@ -107,16 +114,13 @@ public struct CommentsReducer: Sendable { state.destination = .postComment(commentID) return .none - case .clearSubStates: - state.detailState.wrappedValue = .init() - state.commentContent = .init() - state.postCommentFocused = false - return .send(.detail(.teardown)) - case .clearScrollCommentID: state.scrollCommentID = nil return .none + case .delegate: + return .none + case .setHUDConfig(let config): state.hudConfig = config return .none @@ -165,25 +169,15 @@ public struct CommentsReducer: Sendable { let pageIndex = analysis.pageIndex let commentID = analysis.commentID let gid = urlClient.parseGalleryID(url) + var deepLink: GalleryDeepLink? var effects = [Effect]() if let pageIndex = pageIndex { effects.append(.send(.updateReadingProgress(gid, pageIndex))) - effects.append( - .run { send in - try await Task.sleep(for: .milliseconds(750)) - await send(.detail(.presentReading)) - } - ) + deepLink = .reading(page: pageIndex) } else if let commentID = commentID { - state.detailState.wrappedValue?.commentsState.wrappedValue?.scrollCommentID = commentID - effects.append( - .run { send in - try await Task.sleep(for: .milliseconds(750)) - await send(.detail(.setNavigation(.comments(url)))) - } - ) + deepLink = .comments(commentID: commentID) } - effects.append(.send(.setNavigation(.detail(gid)))) + effects.append(.send(.delegate(.pushDetail(gid, deepLink)))) return .merge(effects) case .onPostCommentAppear: @@ -193,9 +187,6 @@ public struct CommentsReducer: Sendable { } case .onAppear: - if state.detailState.wrappedValue == nil { - state.detailState.wrappedValue = .init() - } return state.scrollCommentID != nil ? .send(.performScrollOpacityEffect) : .none case .updateReadingProgress(let gid, let progress): @@ -249,8 +240,16 @@ public struct CommentsReducer: Sendable { } .cancellable(id: CancelID.voteComment) - case .performCommentActionDone: - return .none + case .performCommentActionDone(let result): + switch result { + case .success: + return .merge( + .send(.delegate(.performedCommentAction(state.gid))), + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) + ) + case .failure: + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + } case .fetchGallery(let url, let isGalleryImageURL): state.route = .hud @@ -277,9 +276,6 @@ public struct CommentsReducer: Sendable { await send(.setHUDConfig(.error())) } } - - case .detail: - return .none } } .haptics( diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index 2bf17ee0b..b3f4460a2 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -3,7 +3,6 @@ import AppModels import Resources import Kingfisher import ComposableArchitecture -import SwiftUINavigationExt import AppTools import TTProgressHUDExt import AppComponents @@ -120,7 +119,6 @@ struct CommentsView: View { .onAppear { store.send(.onAppear) } - .background(navigationLink) .toolbar(content: toolbar) .navigationTitle(L10n.Localizable.CommentsView.Title.comments) } @@ -137,19 +135,6 @@ struct CommentsView: View { } } -// MARK: NavigationLinks -private extension CommentsView { - @ViewBuilder var navigationLink: some View { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } -} - extension CommentsView { struct CommentCell: View { private let gid: String @@ -270,7 +255,7 @@ struct CommentsView_Previews: PreviewProvider { static var previews: some View { NavigationView { CommentsView( - store: .init(initialState: .init(), reducer: CommentsReducer.init), + store: .init(initialState: .init(galleryURL: .mock), reducer: CommentsReducer.init), gid: .init(), token: .init(), apiKey: .init(), diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index 24fea4995..22e211ac2 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -10,9 +10,8 @@ extension DetailReducer { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none + case .delegate: + return .none case .destination(.dismiss): if case .postComment = state.destination { @@ -21,6 +20,9 @@ extension DetailReducer { } return .none + case .destination(.presented(.reading(.onPerformDismiss))): + return .send(.destination(.dismiss)) + case .destination: return .none @@ -56,19 +58,6 @@ extension DetailReducer { state.destination = .tagDetail(tagDetail) return .none - case .clearSubStates: - state.previewsState = .init() - state.commentsState.wrappedValue = .init() - state.commentContent = .init() - state.postCommentFocused = false - state.galleryInfosState = .init() - state.detailSearchState.wrappedValue = .init() - return .merge( - .send(.previews(.teardown)), - .send(.comments(.teardown)), - .send(.detailSearch(.teardown)) - ) - case .onPostCommentAppear: return .run { send in try await Task.sleep(for: .milliseconds(750)) @@ -95,12 +84,6 @@ extension DetailReducer { state.hasLoadedDownloadBadge = false state.didRunLaunchAutomation = false state.localPreviewURLs = .init() - if state.detailSearchState.wrappedValue == nil { - state.detailSearchState.wrappedValue = .init() - } - if state.commentsState.wrappedValue == nil { - state.commentsState.wrappedValue = .init() - } return .merge( .send(.fetchDatabaseInfos(gid)), .send(.fetchDownloadBadge), @@ -201,43 +184,4 @@ extension DetailReducer { } } - func childReducer(_ reducer: Reduce) -> some ReducerOf { - Reduce { state, action in - switch action { - case .destination(.presented(.reading(.onPerformDismiss))): - return .send(.destination(.dismiss)) - - case .previews, .galleryInfos: - return .none - - case .comments(.performCommentActionDone(let result)): - return .send(.anyGalleryOpsDone(result)) - - case .comments(.detail(let recursiveAction)): - guard state.commentsState.wrappedValue != nil else { return .none } - let effect = reducer._reduce( - // swiftlint:disable:next force_unwrapping - into: &state.commentsState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction - ) - return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) - - case .comments: - return .none - - case .detailSearch(.detail(let recursiveAction)): - guard state.detailSearchState.wrappedValue != nil else { return .none } - let effect = reducer._reduce( - // swiftlint:disable:next force_unwrapping - into: &state.detailSearchState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction - ) - return .publisher({ _EffectPublisher(effect).map({ Action.comments(.detail($0)) }) }) - - case .detailSearch: - return .none - - default: - return .none - } - } - } } diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift index 6dee6c46b..abb0429f7 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift @@ -4,7 +4,7 @@ import NetworkingFeature // MARK: - Fetch & Gallery Ops Action Handlers extension DetailReducer { - func fetchReducer(_ reducer: Reduce) -> some ReducerOf { + var fetchReducer: some ReducerOf { Reduce { state, action in switch action { case .teardown: @@ -88,6 +88,28 @@ extension DetailReducer { if let config = response.galleryState.previewConfig { effects.append(.send(.syncPreviewConfig(config))) } + if let deepLink = state.pendingDeepLink { + state.pendingDeepLink = nil + switch deepLink { + case .reading: + // The linking comment already wrote the reading progress; open the reader + // after a short beat so that write has landed before ReadingView appears. + effects.append( + .run { send in + try await Task.sleep(for: .milliseconds(750)) + await send(.presentReading) + } + ) + case .comments(let commentID): + if let galleryURL = state.gallery.galleryURL { + effects.append(.send(.delegate(.pushComments( + gid: state.gallery.id, token: state.gallery.token, apiKey: state.apiKey, + galleryURL: galleryURL, comments: state.galleryComments, + scrollCommentID: commentID + )))) + } + } + } return .merge(effects) case .failure(let error): state.loadingState = .failed(error) diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index e5872f58b..2aeff0392 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -3,8 +3,6 @@ import SwiftUI import AppModels import Foundation import ComposableArchitecture -import ComposableArchitectureExt -import SwiftUINavigationExt import HapticsClient import DatabaseClient import NetworkingFeature @@ -15,12 +13,16 @@ import ReadingFeature @Reducer public struct DetailReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case previews - case comments(URL) - case detailSearch(String) - case galleryInfos(Gallery, GalleryDetail) + // The gallery sub-screens are now standalone elements on the host's navigation stack. Detail asks + // the host to push them via these delegate actions instead of owning nested child state itself. + public enum Delegate: Equatable, Sendable { + case pushPreviews(String) + case pushComments( + gid: String, token: String, apiKey: String, + galleryURL: URL, comments: [GalleryComment], scrollCommentID: String? + ) + case pushDetailSearch(String) + case pushGalleryInfos(Gallery, GalleryDetail) } @Reducer @@ -76,7 +78,6 @@ public struct DetailReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? @Presents public var destination: Destination.State? @Presents public var alert: AlertState? public var commentContent = "" @@ -115,14 +116,12 @@ public struct DetailReducer: Sendable { public var shouldCheckForRemoteUpdates = false public var didRequestVersionMetadata = false public var localPreviewRequestID = UUID() - public var previewsState = PreviewsReducer.State() - public var commentsState: Heap - public var galleryInfosState = GalleryInfosReducer.State() - public var detailSearchState: Heap + // A deep-link intent to act on once this detail finishes loading (see GalleryDeepLink). + public var pendingDeepLink: GalleryDeepLink? - public init() { - commentsState = .init(nil) - detailSearchState = .init(nil) + public init(gid: String = "", pendingDeepLink: GalleryDeepLink? = nil) { + self.gid = gid + self.pendingDeepLink = pendingDeepLink } mutating func updateRating(value: DragGesture.Value) { @@ -131,9 +130,9 @@ public struct DetailReducer: Sendable { } } - public indirect enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) + case delegate(Delegate) case destination(PresentationAction) case presentReading case archivesButtonTapped @@ -146,7 +145,6 @@ public struct DetailReducer: Sendable { case alert(PresentationAction) case deleteDownloadButtonTapped case retryDownloadButtonTapped(DownloadStartMode) - case clearSubStates case onPostCommentAppear case onAppear(String, Bool) case toggleShowFullTitle @@ -198,10 +196,6 @@ public struct DetailReducer: Sendable { case postComment(URL) case voteTag(String, Int) case anyGalleryOpsDone(Result) - case previews(PreviewsReducer.Action) - case comments(CommentsReducer.Action) - case galleryInfos(GalleryInfosReducer.Action) - case detailSearch(DetailSearchReducer.Action) } @Dependency(\.databaseClient) var databaseClient @@ -217,31 +211,17 @@ public struct DetailReducer: Sendable { // MARK: - Reducer Body extension DetailReducer { + @ReducerBuilder var detailBody: some Reducer { - RecurseReducer { (self) in - BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } - navigationReducer - uiReducer - syncReducer - downloadReducer - fetchReducer(self) - galleryOpsReducer - childReducer(self) - optionalChildReducers - .ifLet(\.$destination, action: \.destination) - .ifLet(\.$alert, action: \.alert) - Scope(state: \.previewsState, action: \.previews, child: PreviewsReducer.init) - Scope(state: \.galleryInfosState, action: \.galleryInfos, child: GalleryInfosReducer.init) - } - } - - var optionalChildReducers: some ReducerOf { - Reduce { _, _ in .none } - .ifLet(\.commentsState.wrappedValue, action: \.comments, then: CommentsReducer.init) - .ifLet(\.detailSearchState.wrappedValue, action: \.detailSearch, then: DetailSearchReducer.init) + BindingReducer() + navigationReducer + uiReducer + syncReducer + downloadReducer + fetchReducer + galleryOpsReducer + .ifLet(\.$destination, action: \.destination) + .ifLet(\.$alert, action: \.alert) } } diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index 2440a5b69..433b44c59 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -1,34 +1,30 @@ import AppTools import ComposableArchitecture import AppModels -import SwiftUINavigationExt import HapticsClient import DatabaseClient import NetworkingFeature import FiltersFeature import QuickSearchFeature -import ComposableArchitectureExt @Reducer public struct DetailSearchReducer: Sendable { - @dynamicMemberLookup @CasePathable - public enum Route: Equatable, Sendable { - case detail(String) - } - @Reducer public enum Destination { case filters(FiltersReducer) case quickSearch(QuickSearchReducer) } + public enum Delegate: Equatable, Sendable { + case pushDetail(String) + } + private enum CancelID: CaseIterable { case fetchGalleries, fetchMoreGalleries } @ObservableState public struct State: Equatable { - public var route: Route? @Presents public var destination: Destination.State? public var keyword = "" public var lastKeyword = "" @@ -38,10 +34,9 @@ public struct DetailSearchReducer: Sendable { public var loadingState: LoadingState = .idle public var footerLoadingState: LoadingState = .idle - public var detailState: Heap - - public init() { - detailState = .init(.init()) + public init(keyword: String = "") { + self.keyword = keyword + self.lastKeyword = keyword } mutating func insertGalleries(_ galleries: [Gallery]) { @@ -55,8 +50,7 @@ public struct DetailSearchReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates + case delegate(Delegate) case filtersButtonTapped case quickSearchButtonTapped case destination(PresentationAction) @@ -66,8 +60,6 @@ public struct DetailSearchReducer: Sendable { case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - - case detail(DetailReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -77,9 +69,6 @@ public struct DetailSearchReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } .onChange(of: \.keyword) { _, state in if !state.keyword.isEmpty { state.lastKeyword = state.keyword @@ -92,13 +81,8 @@ public struct DetailSearchReducer: Sendable { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) + case .delegate: + return .none case .filtersButtonTapped: state.destination = .filters(FiltersReducer.State()) @@ -184,9 +168,6 @@ public struct DetailSearchReducer: Sendable { state.footerLoadingState = .failed(error) } return .none - - case .detail: - return .none } } .haptics( diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift index 13d725551..ec9205e22 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import TagTranslationFeature import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents import GalleryListComponents @@ -30,85 +29,56 @@ struct DetailSearchView: View { } var body: some View { - let content = - GenericList( - galleries: store.galleries, - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState, - footerLoadingState: store.footerLoadingState, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - .sheet( - item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) - ) { store in - QuickSearchView(store: store) { keyword in - self.store.send(.destination(.dismiss)) - self.store.send(.fetchGalleries(keyword)) - } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet( - item: $store.scope(state: \.destination?.filters, action: \.destination.filters) - ) { store in - FiltersView(store: store) - .accentColor(setting.accentColor).autoBlur(radius: blurRadius) + GenericList( + galleries: store.galleries, + setting: setting, + pageNumber: store.pageNumber, + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion - ) - } - .onSubmit(of: .search) { - store.send(.fetchGalleries()) + ) + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.fetchGalleries(keyword)) } - .onAppear { - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries(keyword)) - } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) + .accentColor(setting.accentColor).autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: tagTranslator.translations, + showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + ) + } + .onSubmit(of: .search) { + store.send(.fetchGalleries()) + } + .onAppear { + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries(keyword)) } } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(store.lastKeyword) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content } + .toolbar(content: toolbar) + .navigationTitle(store.lastKeyword) } - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } private func toolbar() -> some ToolbarContent { CustomToolbarItem { ToolbarFeaturesMenu { diff --git a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift index 70f36dfe6..783855d6e 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift @@ -1,47 +1,9 @@ import SwiftUI import Resources import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents -// MARK: NavigationLinks -extension DetailView { - @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: $store.route, case: \.previews) { _ in - PreviewsView( - store: store.scope(state: \.previewsState, action: \.previews), - gid: gid, setting: $setting, blurRadius: blurRadius - ) - } - NavigationLink(unwrapping: $store.route, case: \.comments) { route in - if let commentStore = store.scope(state: \.commentsState.wrappedValue, action: \.comments) { - CommentsView( - store: commentStore, gid: gid, token: store.gallery.token, apiKey: store.apiKey, - galleryURL: route.wrappedValue, comments: store.galleryComments, user: user, - setting: $setting, blurRadius: blurRadius, - tagTranslator: tagTranslator - ) - } - } - NavigationLink(unwrapping: $store.route, case: \.detailSearch) { route in - if let detailSearchStore = store.scope(state: \.detailSearchState.wrappedValue, action: \.detailSearch) { - DetailSearchView( - store: detailSearchStore, keyword: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - NavigationLink(unwrapping: $store.route, case: \.galleryInfos) { route in - let (gallery, galleryDetail) = route.wrappedValue - GalleryInfosView( - store: store.scope(state: \.galleryInfosState, action: \.galleryInfos), - gallery: gallery, galleryDetail: galleryDetail - ) - } - } -} - // MARK: ToolBar extension DetailView { func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 0dab90c07..95e9778c3 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -46,7 +46,6 @@ public struct DetailView: View { runLaunchAutomationIfNeeded() } .alert($store.scope(state: \.alert, action: \.alert)) - .background(navigationLinks) .toolbar(content: toolbar) } @@ -89,7 +88,7 @@ private extension DetailView { navigateUploaderAction: { if let uploader = store.galleryDetail?.uploader { let keyword = "uploader:" + "\"\(uploader)\"" - store.send(.setNavigation(.detailSearch(keyword))) + store.send(.delegate(.pushDetailSearch(keyword))) } } ) @@ -99,7 +98,7 @@ private extension DetailView { galleryDetail: store.galleryDetail ?? .empty, navigateGalleryInfosAction: { if let galleryDetail = store.galleryDetail { - store.send(.setNavigation(.galleryInfos(store.gallery, galleryDetail))) + store.send(.delegate(.pushGalleryInfos(store.gallery, galleryDetail))) } } ) @@ -112,7 +111,7 @@ private extension DetailView { confirmRatingAction: { store.send(.confirmRating($0)) }, navigateSimilarGalleryAction: { if let trimmedTitle = store.galleryDetail?.trimmedTitle { - store.send(.setNavigation(.detailSearch(trimmedTitle))) + store.send(.delegate(.pushDetailSearch(trimmedTitle))) } } ) @@ -120,7 +119,7 @@ private extension DetailView { TagsSection( tags: store.galleryTags, showsImages: setting.showsImagesInTags, voteTagAction: { store.send(.voteTag($0, $1)) }, - navigateSearchAction: { store.send(.setNavigation(.detailSearch($0))) }, + navigateSearchAction: { store.send(.delegate(.pushDetailSearch($0))) }, navigateTagDetailAction: { store.send(.tagDetailButtonTapped($0)) }, translateAction: { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) @@ -136,7 +135,7 @@ private extension DetailView { PreviewsSection( pageCount: store.galleryDetail?.pageCount ?? 0, previewURLs: displayPreviewURLs, - navigatePreviewsAction: { store.send(.setNavigation(.previews)) }, + navigatePreviewsAction: { store.send(.delegate(.pushPreviews(gid))) }, navigateReadingAction: { store.send(.updateReadingProgress($0)) store.send(.openReading) @@ -147,7 +146,11 @@ private extension DetailView { comments: store.galleryComments, navigateCommentAction: { if let galleryURL = store.gallery.galleryURL { - store.send(.setNavigation(.comments(galleryURL))) + store.send(.delegate(.pushComments( + gid: gid, token: store.gallery.token, apiKey: store.apiKey, + galleryURL: galleryURL, comments: store.galleryComments, + scrollCommentID: nil + ))) } }, navigatePostCommentAction: { store.send(.postCommentButtonTapped) } diff --git a/AppPackage/Sources/DetailFeature/GalleryDeepLink.swift b/AppPackage/Sources/DetailFeature/GalleryDeepLink.swift new file mode 100644 index 000000000..c338e1c3e --- /dev/null +++ b/AppPackage/Sources/DetailFeature/GalleryDeepLink.swift @@ -0,0 +1,10 @@ +import Foundation + +// A deferred navigation intent carried onto a freshly-pushed Detail screen. It lets a gallery link +// tapped inside a comment open the linked gallery and then, once that detail finishes loading, jump +// to a specific reading page or scroll its comment list to a specific comment — behavior that the old +// recursive detail achieved by mutating the embedded child before pushing. +public enum GalleryDeepLink: Equatable, Sendable { + case reading(page: Int) + case comments(commentID: String) +} diff --git a/AppPackage/Sources/DetailFeature/GalleryDestination.swift b/AppPackage/Sources/DetailFeature/GalleryDestination.swift new file mode 100644 index 000000000..4d1707bcd --- /dev/null +++ b/AppPackage/Sources/DetailFeature/GalleryDestination.swift @@ -0,0 +1,90 @@ +import SwiftUI +import AppModels +import TagTranslationFeature +import ComposableArchitecture + +// Builds the view for a single gallery stack element. Shared by every gallery host (and reused by the +// nested `.gallery` case of Home's and SearchRoot's paths) so the screen wiring lives in one place. +@MainActor +@ViewBuilder +public func galleryDestination( + _ store: StoreOf, + user: User, + setting: Binding, + blurRadius: Double, + tagTranslator: TagTranslator +) -> some View { + switch store.case { + case .detail(let detailStore): + DetailView( + store: detailStore, gid: detailStore.gid, user: user, + setting: setting, blurRadius: blurRadius, tagTranslator: tagTranslator + ) + case .previews(let previewsStore): + PreviewsView( + store: previewsStore, gid: previewsStore.gid, + setting: setting, blurRadius: blurRadius + ) + case .comments(let commentsStore): + CommentsView( + store: commentsStore, gid: commentsStore.gid, token: commentsStore.token, + apiKey: commentsStore.apiKey, galleryURL: commentsStore.galleryURL, + comments: commentsStore.comments, user: user, setting: setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + case .detailSearch(let searchStore): + DetailSearchView( + store: searchStore, keyword: searchStore.keyword, user: user, + setting: setting, blurRadius: blurRadius, tagTranslator: tagTranslator + ) + case .galleryInfos(let infosStore): + GalleryInfosView( + store: infosStore, gallery: infosStore.gallery, galleryDetail: infosStore.galleryDetail + ) + } +} + +// A `NavigationStack` whose drill-down is the shared `GalleryPath`. Hosts that stack only gallery +// screens (Favorites, Downloads, deep-link detail) build their root list here; the iPhone/iPad +// navigation container decision is centralized in this one type. +public struct GalleryNavigationContainer: View { + @Bindable private var store: Store + private let statePath: KeyPath> + private let actionPath: CaseKeyPath> + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + private let root: Root + + public init( + store: Store, + state statePath: KeyPath>, + action actionPath: CaseKeyPath>, + user: User, + setting: Binding, + blurRadius: Double, + tagTranslator: TagTranslator, + @ViewBuilder root: () -> Root + ) { + self.store = store + self.statePath = statePath + self.actionPath = actionPath + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + self.root = root() + } + + public var body: some View { + NavigationStack(path: $store.scope(state: statePath, action: actionPath)) { + root + } destination: { elementStore in + galleryDestination( + elementStore, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } +} diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift index 9d7e7dd8b..68cd4fc38 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift @@ -1,3 +1,4 @@ +import AppModels import ComposableArchitecture import HapticsClient import ClipboardClient @@ -14,6 +15,14 @@ public struct GalleryInfosReducer: Sendable { public struct State: Equatable { public var route: Route? public var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded + // Display data captured when this screen is pushed onto the host's gallery stack. + public var gallery: Gallery = .empty + public var galleryDetail: GalleryDetail = .empty + + public init(gallery: Gallery = .empty, galleryDetail: GalleryDetail = .empty) { + self.gallery = gallery + self.galleryDetail = galleryDetail + } } public enum Action: BindableAction, Equatable { diff --git a/AppPackage/Sources/DetailFeature/GalleryNavigation.swift b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift new file mode 100644 index 000000000..5e60dca06 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift @@ -0,0 +1,53 @@ +import ComposableArchitecture + +// Shared routing for gallery stacks. `nextScreen` maps a gallery element's delegate action to the +// screen that should be pushed next, so every host appends new elements the same way regardless of +// whether it stacks `GalleryPath` directly or nested under a `.gallery` case. +public enum GalleryNavigation { + public static func nextScreen(for action: GalleryPath.Action) -> GalleryPath.State? { + switch action { + case let .detail(.delegate(delegate)): + switch delegate { + case .pushPreviews(let gid): + return .previews(.init(gid: gid)) + case let .pushComments(gid, token, apiKey, galleryURL, comments, scrollCommentID): + return .comments(.init( + gid: gid, token: token, apiKey: apiKey, + galleryURL: galleryURL, comments: comments, scrollCommentID: scrollCommentID + )) + case .pushDetailSearch(let keyword): + return .detailSearch(.init(keyword: keyword)) + case let .pushGalleryInfos(gallery, galleryDetail): + return .galleryInfos(.init(gallery: gallery, galleryDetail: galleryDetail)) + } + + case let .comments(.delegate(delegate)): + switch delegate { + case let .pushDetail(gid, deepLink): + return .detail(.init(gid: gid, pendingDeepLink: deepLink)) + case .performedCommentAction: + return nil + } + + case let .detailSearch(.delegate(.pushDetail(gid))): + return .detail(.init(gid: gid)) + + default: + return nil + } + } +} + +extension StackState where Element == GalleryPath.State { + // The id of the pushed `.detail` element for `gid`, so a comment action performed on a deeper + // `.comments` screen can refresh the detail it belongs to. + public func detailID(forGID gid: String) -> StackElementID? { + for id in ids { + guard let element = self[id: id] else { continue } + if case .detail(let state) = element, state.gid == gid { + return id + } + } + return nil + } +} diff --git a/AppPackage/Sources/DetailFeature/GalleryPath.swift b/AppPackage/Sources/DetailFeature/GalleryPath.swift new file mode 100644 index 000000000..978f8a4c7 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/GalleryPath.swift @@ -0,0 +1,17 @@ +import ComposableArchitecture + +// The shared set of gallery drill-down screens. Every gallery host drives a flat `StackState` of these +// elements: tapping a gallery pushes `.detail`, which in turn asks the host (via its `Delegate` actions) +// to push `.previews`/`.comments`/`.detailSearch`/`.galleryInfos`, and `.comments`/`.detailSearch` can +// push another `.detail`. Hosts that also stack their own list screens (Home, SearchRoot) nest this enum +// as a `.gallery(GalleryPath)` case so the gallery routing stays defined in one place. +@Reducer +public enum GalleryPath { + case detail(DetailReducer) + case previews(PreviewsReducer) + case comments(CommentsReducer) + case detailSearch(DetailSearchReducer) + case galleryInfos(GalleryInfosReducer) +} + +extension GalleryPath.State: Equatable {} diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index d64136d8b..4d211a676 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -27,6 +27,8 @@ public struct PreviewsReducer: Sendable { public struct State: Equatable, Sendable { @Presents public var destination: Destination.State? + // The gallery id this screen fetches; captured when pushed onto the host's gallery stack. + public var gid = "" public var gallery: Gallery = .empty public var loadingState: LoadingState = .idle public var databaseLoadingState: LoadingState = .loading @@ -36,6 +38,11 @@ public struct PreviewsReducer: Sendable { public var previewConfig: PreviewConfig = .normal(rows: 4) public var localPreviewRequestID = UUID() + public init(gid: String = "", gallery: Gallery = .empty) { + self.gid = gid + self.gallery = gallery + } + mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { self.previewURLs = self.previewURLs.merging( previewURLs, uniquingKeysWith: { stored, _ in stored } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 64d959d4a..c81537f78 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -6,15 +6,9 @@ import AppTools import DownloadClient import ReadingFeature import DetailFeature -import ComposableArchitectureExt @Reducer public struct DownloadsReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case detail(String) - } - @Reducer public enum Destination { case inspector(DownloadInspectorReducer) @@ -37,7 +31,7 @@ public struct DownloadsReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? + public var path = StackState() @Presents public var destination: Destination.State? @Presents public var alert: AlertState? @Presents public var confirmationDialog: ConfirmationDialogState? @@ -48,12 +42,9 @@ public struct DownloadsReducer: Sendable { public var loadingState: LoadingState = .loading public var hasLoadedInitialDownloads = false - public var detailState: Heap public var readingRequestID = UUID() - public init() { - detailState = .init(.init()) - } + public init() {} var filteredDownloads: [DownloadedGallery] { downloads.filter { @@ -68,7 +59,8 @@ public struct DownloadsReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) + case galleryTapped(String) + case path(StackActionOf) case destination(PresentationAction) case inspectorButtonTapped(String) case folderManagerButtonTapped @@ -76,7 +68,6 @@ public struct DownloadsReducer: Sendable { case confirmationDialog(PresentationAction) case deleteDownloadButtonTapped(DownloadedGallery) case moveButtonTapped(DownloadedGallery) - case clearSubStates case onAppear case teardown @@ -98,8 +89,6 @@ public struct DownloadsReducer: Sendable { case updateDownloadDone(Result) case deleteDownload(String) case deleteDownloadDone(Result) - - case detail(DetailReducer.Action) } @Dependency(\.downloadClient) private var downloadClient @@ -108,26 +97,21 @@ public struct DownloadsReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } Reduce { state, action in switch action { case .binding: return .none - case .setNavigation(let route): - state.route = route - if case .detail(let gid) = route, - let download = state.downloads.first(where: { $0.gid == gid }) { - var detailState = DetailReducer.State() - detailState.gid = download.gid + case .galleryTapped(let gid): + // Seed the detail with the locally downloaded gallery/badge so it renders offline. + var detailState = DetailReducer.State(gid: gid) + if let download = state.downloads.first(where: { $0.gid == gid }) { detailState.gallery = download.gallery _ = DetailReducer().applyDownload(download, state: &detailState) - state.detailState.wrappedValue = detailState } - return route == nil ? .send(.clearSubStates) : .none + state.path.append(.detail(detailState)) + return .none case .inspectorButtonTapped(let gid): state.destination = .inspector(.init(gid: gid)) @@ -184,10 +168,6 @@ public struct DownloadsReducer: Sendable { case .confirmationDialog: return .none - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) - case .onAppear: guard !state.hasLoadedInitialDownloads else { return .send(.fetchFolders) } state.hasLoadedInitialDownloads = true @@ -328,12 +308,25 @@ public struct DownloadsReducer: Sendable { case .deleteDownloadDone: return .none - case .detail(.destination(.presented(.folderManager(.createFolderDone)))), - .detail(.destination(.presented(.folderManager(.renameFolderDone)))), - .detail(.destination(.presented(.folderManager(.deleteFolderDone)))): - return .send(.fetchFolders) + case let .path(.element(id: _, action: .detail(.destination(.presented(.folderManager(action)))))): + switch action { + case .createFolderDone, .renameFolderDone, .deleteFolderDone: + return .send(.fetchFolders) + default: + return .none + } + + case let .path(.element(id: _, action: .comments(.delegate(.performedCommentAction(gid))))): + guard let id = state.path.detailID(forGID: gid) else { return .none } + return .send(.path(.element(id: id, action: .detail(.fetchGalleryDetail)))) - case .detail: + case let .path(.element(id: _, action: elementAction)): + if let next = GalleryNavigation.nextScreen(for: elementAction) { + state.path.append(next) + } + return .none + + case .path: return .none case .destination(.presented(.reading(.onPerformDismiss))): @@ -351,8 +344,7 @@ public struct DownloadsReducer: Sendable { .ifLet(\.$destination, action: \.destination) .ifLet(\.$alert, action: \.alert) .ifLet(\.$confirmationDialog, action: \.confirmationDialog) - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) + .forEach(\.path, action: \.path) } } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index 74d4cf673..8c40546f4 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -3,7 +3,6 @@ import AppModels import Resources import SFSafeSymbols import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents import ReadingFeature @@ -31,27 +30,16 @@ public struct DownloadsView: View { } public var body: some View { - NavigationView { - if DeviceUtil.isPad { - contentView - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, - user: user, - setting: $setting, - blurRadius: blurRadius, - tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius) - .environment(\.inSheet, true) - .navigationViewStyle(.stack) - } - } else { - contentView - } + GalleryNavigationContainer( + store: store, + state: \.path, + action: \.path, + user: user, + setting: $setting, + blurRadius: blurRadius, + tagTranslator: tagTranslator + ) { + contentView } } @@ -117,7 +105,6 @@ public struct DownloadsView: View { .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) - .background(navigationLink) .navigationTitle(L10n.Localizable.DownloadsView.Title.downloads) .navigationBarTitleDisplayMode(.large) .toolbar(content: toolbar) @@ -214,7 +201,7 @@ private extension DownloadsView { @ViewBuilder private func downloadContextMenu(_ download: DownloadedGallery) -> some View { Button { - store.send(.setNavigation(.detail(download.gid))) + store.send(.galleryTapped(download.gid)) } label: { Label( L10n.Localizable.DetailView.ContextMenu.Button.detail, @@ -279,21 +266,6 @@ private extension DownloadsView { } } - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, - user: user, - setting: $setting, - blurRadius: blurRadius, - tagTranslator: tagTranslator - ) - } - } - } - @ViewBuilder private var emptyStateView: some View { if store.downloads.isEmpty { AlertView( diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index b239acfb4..de726e9ac 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -3,7 +3,6 @@ import SwiftUI import AppModels import IdentifiedCollections import ComposableArchitecture -import SwiftUINavigationExt import HapticsClient import DatabaseClient import NetworkingFeature @@ -11,7 +10,6 @@ import DownloadClient import DateSeekFeature import QuickSearchFeature import DetailFeature -import ComposableArchitectureExt @Reducer public struct FavoritesReducer: Sendable { @@ -19,11 +17,6 @@ public struct FavoritesReducer: Sendable { case observeDownloads } - @CasePathable - public enum Route: Equatable, Sendable { - case detail(String) - } - @Reducer public enum Destination { case quickSearch(QuickSearchReducer) @@ -32,7 +25,7 @@ public struct FavoritesReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? + public var path = StackState() @Presents public var destination: Destination.State? public var keyword = "" @@ -62,11 +55,7 @@ public struct FavoritesReducer: Sendable { rawFooterLoadingState[index] } - public var detailState: Heap - - public init() { - detailState = .init(.init()) - } + public init() {} mutating func insertGalleries(index: Int, galleries: [Gallery]) { galleries.forEach { gallery in @@ -80,9 +69,9 @@ public struct FavoritesReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) case onAppear - case setNavigation(Route?) + case galleryTapped(String) + case path(StackActionOf) case setFavoritesIndex(Int) - case clearSubStates case quickSearchButtonTapped case dateSeekButtonTapped(DateSeekNavigation) case destination(PresentationAction) @@ -95,8 +84,6 @@ public struct FavoritesReducer: Sendable { case observeDownloads case observeDownloadsDone([DownloadedGallery]) case performDateSeekDone(Int, Result) - - case detail(DetailReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -107,9 +94,6 @@ public struct FavoritesReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } Reduce { state, action in switch action { @@ -119,19 +103,28 @@ public struct FavoritesReducer: Sendable { case .onAppear: return .send(.observeDownloads) - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none + case .galleryTapped(let gid): + state.path.append(.detail(.init(gid: gid))) + return .none + + case let .path(.element(id: _, action: .comments(.delegate(.performedCommentAction(gid))))): + guard let id = state.path.detailID(forGID: gid) else { return .none } + return .send(.path(.element(id: id, action: .detail(.fetchGalleryDetail)))) + + case let .path(.element(id: _, action: elementAction)): + if let next = GalleryNavigation.nextScreen(for: elementAction) { + state.path.append(next) + } + return .none + + case .path: + return .none case .setFavoritesIndex(let index): state.index = index guard state.galleries?.isEmpty != false else { return .none } return .send(.fetchGalleries()) - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) - case .quickSearchButtonTapped: state.destination = .quickSearch(QuickSearchReducer.State()) return .none @@ -272,9 +265,6 @@ public struct FavoritesReducer: Sendable { case .destination: return .none - - case .detail: - return .none } } .haptics( @@ -288,8 +278,7 @@ public struct FavoritesReducer: Sendable { hapticsClient: hapticsClient ) .ifLet(\.$destination, action: \.destination) - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) + .forEach(\.path, action: \.path) } } diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index aeaf76704..dc50c969f 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -4,7 +4,6 @@ import TagTranslationFeature import Resources import AlertKit import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents import DateSeekFeature @@ -36,101 +35,80 @@ public struct FavoritesView: View { } public var body: some View { - NavigationView { - let content = - ZStack { - if CookieUtil.didLogin { - GenericList( - galleries: store.galleries ?? [], - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState ?? .idle, - footerLoadingState: store.footerLoadingState ?? .idle, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - }, - downloadBadges: store.downloadBadges - ) - } else { - NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) - } - } - .sheet( - item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) - ) { store in - QuickSearchView(store: store) { keyword in - self.store.send(.destination(.dismiss)) - self.store.send(.fetchGalleries(keyword)) - } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet( - item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) - ) { store in - @Bindable var store = store - DateSeekPickerView( - selectedDate: $store.date, - navigation: store.navigation, - seekAction: { store.send(.performSeek($0)) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + GalleryNavigationContainer( + store: store, + state: \.path, + action: \.path, + user: user, + setting: $setting, + blurRadius: blurRadius, + tagTranslator: tagTranslator + ) { + ZStack { + if CookieUtil.didLogin { + GenericList( + galleries: store.galleries ?? [], + setting: setting, + pageNumber: store.pageNumber, + loadingState: store.loadingState ?? .idle, + footerLoadingState: store.footerLoadingState ?? .idle, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.galleryTapped($0)) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + }, + downloadBadges: store.downloadBadges ) + } else { + NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) } - .onSubmit(of: .search) { - store.send(.fetchGalleries()) + } + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.fetchGalleries(keyword)) } - .onAppear { - store.send(.onAppear) - if store.galleries?.isEmpty != false && CookieUtil.didLogin { - DispatchQueue.main.async { - store.send(.fetchGalleries()) - } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) + ) { store in + @Bindable var store = store + DateSeekPickerView( + selectedDate: $store.date, + navigation: store.navigation, + seekAction: { store.send(.performSeek($0)) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: tagTranslator.translations, + showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + ) + } + .onSubmit(of: .search) { + store.send(.fetchGalleries()) + } + .onAppear { + store.send(.onAppear) + if store.galleries?.isEmpty != false && CookieUtil.didLogin { + DispatchQueue.main.async { + store.send(.fetchGalleries()) } } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(navigationTitle) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content } + .toolbar(content: toolbar) + .navigationTitle(navigationTitle) } } - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } private func toolbar() -> some ToolbarContent { CustomToolbarItem(tint: .primary) { FavoritesIndexMenu(user: user, index: store.index) { index in diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index ddccf66b0..2760f679b 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -2,20 +2,16 @@ import ComposableArchitecture import AppModels import Foundation import AppTools -import SwiftUINavigationExt import HapticsClient import DatabaseClient import NetworkingFeature import FiltersFeature import DateSeekFeature -import DetailFeature -import ComposableArchitectureExt @Reducer public struct FrontpageReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case detail(String) + public enum Delegate: Equatable, Sendable { + case pushDetail(String) } @Reducer @@ -30,7 +26,6 @@ public struct FrontpageReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? @Presents public var destination: Destination.State? public var keyword = "" @@ -44,11 +39,7 @@ public struct FrontpageReducer: Sendable { public var loadingState: LoadingState = .idle public var footerLoadingState: LoadingState = .idle - public var detailState: Heap - - public init() { - detailState = .init(.init()) - } + public init() {} mutating func insertGalleries(_ galleries: [Gallery]) { galleries.forEach { gallery in @@ -61,8 +52,7 @@ public struct FrontpageReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates + case delegate(Delegate) case filtersButtonTapped case dateSeekButtonTapped(DateSeekNavigation) case destination(PresentationAction) @@ -73,8 +63,6 @@ public struct FrontpageReducer: Sendable { case fetchMoreGalleries case fetchMoreGalleriesDone(Result) case performDateSeekDone(Result) - - case detail(DetailReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -84,22 +72,14 @@ public struct FrontpageReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } Reduce { state, action in switch action { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) + case .delegate: + return .none case .filtersButtonTapped: state.destination = .filters(FiltersReducer.State()) @@ -211,9 +191,6 @@ public struct FrontpageReducer: Sendable { case .destination: return .none - - case .detail: - return .none } } .haptics( @@ -227,8 +204,6 @@ public struct FrontpageReducer: Sendable { hapticsClient: hapticsClient ) .ifLet(\.$destination, action: \.destination) - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 8f98e261c..4befaedc3 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -4,13 +4,11 @@ import TagTranslationFeature import Resources import AlertKit import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents import DateSeekFeature import GalleryListComponents import FiltersFeature -import DetailFeature struct FrontpageView: View { @Bindable private var store: StoreOf @@ -31,78 +29,49 @@ struct FrontpageView: View { } var body: some View { - let content = - GenericList( - galleries: store.filteredGalleries, - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState, - footerLoadingState: store.footerLoadingState, - fetchAction: { store.send(.fetchGalleries) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - .sheet( - item: $store.scope(state: \.destination?.filters, action: \.destination.filters) - ) { store in - FiltersView(store: store) - .autoBlur(radius: blurRadius).environment(\.inSheet, true) - } - .sheet( - item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) - ) { store in - @Bindable var store = store - DateSeekPickerView( - selectedDate: $store.date, - navigation: store.navigation, - seekAction: { store.send(.performSeek($0)) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) + GenericList( + galleries: store.filteredGalleries, + setting: setting, + pageNumber: store.pageNumber, + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + fetchAction: { store.send(.fetchGalleries) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .onAppear { - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries) - } + ) + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .sheet( + item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) + ) { store in + @Bindable var store = store + DateSeekPickerView( + selectedDate: $store.date, + navigation: store.navigation, + seekAction: { store.send(.performSeek($0)) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) + .onAppear { + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries) } } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.FrontpageView.Title.frontpage) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content } + .toolbar(content: toolbar) + .navigationTitle(L10n.Localizable.FrontpageView.Title.frontpage) } - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } private func toolbar() -> some ToolbarContent { CustomToolbarItem { DateSeekButton(navigation: store.dateSeekNavigation) { navigation in diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index de52454ef..709e7eef8 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -6,8 +6,6 @@ import AppTools import HapticsClient import DatabaseClient import DownloadClient -import DetailFeature -import ComposableArchitectureExt @Reducer public struct HistoryReducer: Sendable { @@ -15,9 +13,8 @@ public struct HistoryReducer: Sendable { case observeDownloads } - @CasePathable - public enum Route: Equatable, Sendable { - case detail(String) + public enum Delegate: Equatable, Sendable { + case pushDetail(String) } public enum Dialog: Equatable, Sendable { @@ -26,7 +23,6 @@ public struct HistoryReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? @Presents public var confirmationDialog: ConfirmationDialogState? public var keyword = "" public var downloadBadges = [String: DownloadBadge]() @@ -38,19 +34,14 @@ public struct HistoryReducer: Sendable { public var galleries = [Gallery]() public var loadingState: LoadingState = .idle - public var detailState: Heap - - public init() { - detailState = .init(.init()) - } + public init() {} } public enum Action: BindableAction { case binding(BindingAction) case onAppear - case setNavigation(Route?) + case delegate(Delegate) case confirmationDialog(PresentationAction) - case clearSubStates case clearHistoryButtonTapped case clearHistoryGalleries @@ -58,8 +49,6 @@ public struct HistoryReducer: Sendable { case fetchGalleriesDone([Gallery]) case observeDownloads case observeDownloadsDone([DownloadedGallery]) - - case detail(DetailReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -70,9 +59,6 @@ public struct HistoryReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } Reduce { state, action in switch action { @@ -82,9 +68,8 @@ public struct HistoryReducer: Sendable { case .onAppear: return .send(.observeDownloads) - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none + case .delegate: + return .none case .clearHistoryButtonTapped: state.confirmationDialog = ConfirmationDialogState { @@ -107,10 +92,6 @@ public struct HistoryReducer: Sendable { case .confirmationDialog: return .none - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) - case .clearHistoryGalleries: return .merge( .run(operation: { _ in await databaseClient.clearHistoryGalleries() }), @@ -150,13 +131,8 @@ public struct HistoryReducer: Sendable { uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) } ) return .none - - case .detail: - return .none } } .ifLet(\.$confirmationDialog, action: \.confirmationDialog) - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index d5c497734..ad3acb0a0 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -3,11 +3,9 @@ import AppModels import TagTranslationFeature import Resources import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents import GalleryListComponents -import DetailFeature struct HistoryView: View { @Bindable private var store: StoreOf @@ -28,64 +26,35 @@ struct HistoryView: View { } var body: some View { - let content = - GenericList( - galleries: store.filteredGalleries, - setting: setting, - pageNumber: nil, - loadingState: store.loadingState, - footerLoadingState: .idle, - fetchAction: { store.send(.fetchGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - }, - downloadBadges: store.downloadBadges - ) - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .onAppear { - store.send(.onAppear) - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries) - } + GenericList( + galleries: store.filteredGalleries, + setting: setting, + pageNumber: nil, + loadingState: store.loadingState, + footerLoadingState: .idle, + fetchAction: { store.send(.fetchGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + }, + downloadBadges: store.downloadBadges + ) + .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) + .onAppear { + store.send(.onAppear) + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries) } } - .background(navigationLink) - .toolbar(content: toolbar) - .confirmationDialog( - $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) - ) - .navigationTitle(L10n.Localizable.HistoryView.Title.history) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content } + .toolbar(content: toolbar) + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) + .navigationTitle(L10n.Localizable.HistoryView.Title.history) } - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } private func toolbar() -> some ToolbarContent { CustomToolbarItem { Button { diff --git a/AppPackage/Sources/HomeFeature/HomePath.swift b/AppPackage/Sources/HomeFeature/HomePath.swift new file mode 100644 index 000000000..10da0f92e --- /dev/null +++ b/AppPackage/Sources/HomeFeature/HomePath.swift @@ -0,0 +1,30 @@ +import ComposableArchitecture +import DetailFeature + +// The Home tab's navigation stack: its five full-list screens plus the shared gallery drill-down, +// nested as a `.gallery` case so the gallery routing stays defined once in `GalleryPath`. +@Reducer +public enum HomePath { + case frontpage(FrontpageReducer) + case popular(PopularReducer) + case toplists(ToplistsReducer) + case watched(WatchedReducer) + case history(HistoryReducer) + case gallery(GalleryPath.Body = GalleryPath.body) +} + +extension HomePath.State: Equatable {} + +extension StackState where Element == HomePath.State { + // Locate the pushed `.gallery(.detail)` element for `gid` so a comment action performed on a + // deeper `.comments` screen can refresh the detail it belongs to. + func galleryDetailID(forGID gid: String) -> StackElementID? { + for id in ids { + guard let element = self[id: id] else { continue } + if case .gallery(.detail(let state)) = element, state.gid == gid { + return id + } + } + return nil + } +} diff --git a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift index 961c60780..f047ae933 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift @@ -9,9 +9,6 @@ extension HomeReducer { @ReducerBuilder var reducerBody: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } .onChange(of: \.cardPageIndex) { _, state in guard state.cardPageIndex < state.popularGalleries.count else { return .none } state.currentCardID = state.popularGalleries[state.cardPageIndex].gid @@ -27,24 +24,50 @@ extension HomeReducer { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.frontpageState = .init() - state.toplistsState = .init() - state.popularState = .init() - state.watchedState = .init() - state.historyState = .init() - state.detailState.wrappedValue = .init() - return .merge( - .send(.frontpage(.teardown)), - .send(.toplists(.teardown)), - .send(.popular(.teardown)), - .send(.watched(.teardown)), - .send(.detail(.teardown)) - ) + case .galleryTapped(let gid): + state.path.append(.gallery(.detail(.init(gid: gid)))) + return .none + + case .sectionTapped(let type): + switch type { + case .frontpage: + state.path.append(.frontpage(.init())) + case .toplists: + state.path.append(.toplists(.init())) + } + return .none + + case .miscTapped(let type): + switch type { + case .popular: + state.path.append(.popular(.init())) + case .watched: + state.path.append(.watched(.init())) + case .history: + state.path.append(.history(.init())) + } + return .none + + case let .path(.element(id: _, action: .frontpage(.delegate(.pushDetail(gid))))), + let .path(.element(id: _, action: .popular(.delegate(.pushDetail(gid))))), + let .path(.element(id: _, action: .toplists(.delegate(.pushDetail(gid))))), + let .path(.element(id: _, action: .watched(.delegate(.pushDetail(gid))))), + let .path(.element(id: _, action: .history(.delegate(.pushDetail(gid))))): + state.path.append(.gallery(.detail(.init(gid: gid)))) + return .none + + case let .path(.element(id: _, action: .gallery(.comments(.delegate(.performedCommentAction(gid)))))): + guard let id = state.path.galleryDetailID(forGID: gid) else { return .none } + return .send(.path(.element(id: id, action: .gallery(.detail(.fetchGalleryDetail))))) + + case let .path(.element(id: _, action: .gallery(galleryAction))): + if let next = GalleryNavigation.nextScreen(for: galleryAction) { + state.path.append(.gallery(next)) + } + return .none + + case .path: + return .none case .setAllowsCardHitTesting(let isAllowed): state.allowsCardHitTesting = isAllowed @@ -146,32 +169,8 @@ extension HomeReducer { case .analyzeImageColorsDone(let gid, let colors): state.rawCardColors[gid] = colors return .none - - case .frontpage: - return .none - - case .toplists: - return .none - - case .popular: - return .none - - case .watched: - return .none - - case .history: - return .none - - case .detail: - return .none } } - - Scope(state: \.frontpageState, action: \.frontpage, child: FrontpageReducer.init) - Scope(state: \.toplistsState, action: \.toplists, child: ToplistsReducer.init) - Scope(state: \.popularState, action: \.popular, child: PopularReducer.init) - Scope(state: \.watchedState, action: \.watched, child: WatchedReducer.init) - Scope(state: \.historyState, action: \.history, child: HistoryReducer.init) - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) + .forEach(\.path, action: \.path) } } diff --git a/AppPackage/Sources/HomeFeature/HomeReducer.swift b/AppPackage/Sources/HomeFeature/HomeReducer.swift index b401d3c38..4fa81cef2 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer.swift @@ -5,21 +5,12 @@ import ComposableArchitecture import AppTools import LibraryClient import DatabaseClient -import DetailFeature -import ComposableArchitectureExt @Reducer public struct HomeReducer: Sendable { - @CasePathable - public enum Route: Equatable, Hashable, Sendable { - case detail(String) - case misc(HomeMiscGridType) - case section(HomeSectionType) - } - @ObservableState public struct State: Equatable { - public var route: Route? + public var path = StackState() public var cardPageIndex = 1 public var currentCardID = "" public var allowsCardHitTesting = true @@ -35,16 +26,7 @@ public struct HomeReducer: Sendable { public var toplistsGalleries = [Int: [Gallery]]() public var toplistsLoadingState = [Int: LoadingState]() - public var frontpageState = FrontpageReducer.State() - public var toplistsState = ToplistsReducer.State() - public var popularState = PopularReducer.State() - public var watchedState = WatchedReducer.State() - public var historyState = HistoryReducer.State() - public var detailState: Heap - - public init() { - detailState = .init(.init()) - } + public init() {} mutating func setPopularGalleries(_ galleries: [Gallery]) { let sortedGalleries = galleries.sorted { lhs, rhs in @@ -69,8 +51,10 @@ public struct HomeReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates + case galleryTapped(String) + case sectionTapped(HomeSectionType) + case miscTapped(HomeMiscGridType) + case path(StackActionOf) case setAllowsCardHitTesting(Bool) case analyzeImageColors(String, RetrieveImageResult) case analyzeImageColorsDone(String, [Color]?) @@ -83,13 +67,6 @@ public struct HomeReducer: Sendable { case fetchFrontpageGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchToplistsGalleries(Int, Int? = nil) case fetchToplistsGalleriesDone(Int, Result<(PageNumber, [Gallery]), AppError>) - - case frontpage(FrontpageReducer.Action) - case toplists(ToplistsReducer.Action) - case popular(PopularReducer.Action) - case watched(WatchedReducer.Action) - case history(HistoryReducer.Action) - case detail(DetailReducer.Action) } @Dependency(\.databaseClient) var databaseClient diff --git a/AppPackage/Sources/HomeFeature/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift index 293a22e9b..34b5f2a04 100644 --- a/AppPackage/Sources/HomeFeature/HomeView.swift +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -4,7 +4,6 @@ import Resources import Kingfisher import SFSafeSymbols import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents import DetailFeature @@ -29,87 +28,102 @@ public struct HomeView: View { // MARK: HomeView public var body: some View { - NavigationView { - let content = - ZStack { - ScrollView(showsIndicators: false) { - VStack { - if !store.popularGalleries.isEmpty { - CardSlideSection( - galleries: store.popularGalleries, - pageIndex: $store.cardPageIndex, - currentID: store.currentCardID, - colors: store.cardColors, - navigateAction: navigateTo(gid:), - webImageSuccessAction: { gid, result in - store.send(.analyzeImageColors(gid, result)) - } - ) - .equatable().allowsHitTesting(store.allowsCardHitTesting) - } - Group { - if store.frontpageGalleries.count > 1 { - CoverWallSection( - galleries: store.frontpageGalleries, - isLoading: store.frontpageLoadingState == .loading, - navigateAction: navigateTo(gid:), - showAllAction: { store.send(.setNavigation(.section(.frontpage))) }, - reloadAction: { store.send(.fetchFrontpageGalleries) } - ) + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + ZStack { + ScrollView(showsIndicators: false) { + VStack { + if !store.popularGalleries.isEmpty { + CardSlideSection( + galleries: store.popularGalleries, + pageIndex: $store.cardPageIndex, + currentID: store.currentCardID, + colors: store.cardColors, + navigateAction: navigateTo(gid:), + webImageSuccessAction: { gid, result in + store.send(.analyzeImageColors(gid, result)) } - ToplistsSection( - galleries: store.toplistsGalleries, - isLoading: !store.toplistsLoadingState - .values.allSatisfy({ $0 != .loading }), + ) + .equatable().allowsHitTesting(store.allowsCardHitTesting) + } + Group { + if store.frontpageGalleries.count > 1 { + CoverWallSection( + galleries: store.frontpageGalleries, + isLoading: store.frontpageLoadingState == .loading, navigateAction: navigateTo(gid:), - showAllAction: { store.send(.setNavigation(.section(.toplists))) }, - reloadAction: { store.send(.fetchAllToplistsGalleries) } + showAllAction: { store.send(.sectionTapped(.frontpage)) }, + reloadAction: { store.send(.fetchFrontpageGalleries) } ) - MiscGridSection(navigateAction: navigateTo(type:)) } - .padding(.vertical) + ToplistsSection( + galleries: store.toplistsGalleries, + isLoading: !store.toplistsLoadingState + .values.allSatisfy({ $0 != .loading }), + navigateAction: navigateTo(gid:), + showAllAction: { store.send(.sectionTapped(.toplists)) }, + reloadAction: { store.send(.fetchAllToplistsGalleries) } + ) + MiscGridSection(navigateAction: navigateTo(type:)) } + .padding(.vertical) } - .opacity(store.popularGalleries.isEmpty ? 0 : 1).zIndex(2) + } + .opacity(store.popularGalleries.isEmpty ? 0 : 1).zIndex(2) - LoadingView() - .opacity( - store.popularLoadingState == .loading - && store.popularGalleries.isEmpty ? 1 : 0 - ) - .zIndex(0) + LoadingView() + .opacity( + store.popularLoadingState == .loading + && store.popularGalleries.isEmpty ? 1 : 0 + ) + .zIndex(0) - let error = store.popularLoadingState.failed - ErrorView(error: error ?? .unknown) { - store.send(.fetchAllGalleries) - } - .opacity(store.popularGalleries.isEmpty && error != nil ? 1 : 0) - .zIndex(1) + let error = store.popularLoadingState.failed + ErrorView(error: error ?? .unknown) { + store.send(.fetchAllGalleries) } - .animation(.default, value: store.popularLoadingState) - .onAppear { - if store.popularGalleries.isEmpty { - store.send(.fetchAllGalleries) - } + .opacity(store.popularGalleries.isEmpty && error != nil ? 1 : 0) + .zIndex(1) + } + .animation(.default, value: store.popularLoadingState) + .onAppear { + if store.popularGalleries.isEmpty { + store.send(.fetchAllGalleries) } - .background(navigationLinks) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.HomeView.Title.home) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content + } + .toolbar(content: toolbar) + .navigationTitle(L10n.Localizable.HomeView.Title.home) + } destination: { store in + switch store.case { + case .frontpage(let store): + FrontpageView( + store: store, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + case .popular(let store): + PopularView( + store: store, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + case .toplists(let store): + ToplistsView( + store: store, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + case .watched(let store): + WatchedView( + store: store, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + case .history(let store): + HistoryView( + store: store, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + case .gallery(let store): + galleryDestination( + store, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) } } } @@ -127,66 +141,13 @@ public struct HomeView: View { } } -// MARK: NavigationLinks +// MARK: Navigation private extension HomeView { - @ViewBuilder var navigationLinks: some View { - if DeviceUtil.isPhone { - detailViewLink - } - miscGridLink - sectionLink - } - var detailViewLink: some View { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - var miscGridLink: some View { - NavigationLink(unwrapping: $store.route, case: \.misc) { route in - switch route.wrappedValue { - case .popular: - PopularView( - store: store.scope(state: \.popularState, action: \.popular), - user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator - ) - case .watched: - WatchedView( - store: store.scope(state: \.watchedState, action: \.watched), - user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator - ) - case .history: - HistoryView( - store: store.scope(state: \.historyState, action: \.history), - user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } - var sectionLink: some View { - NavigationLink(unwrapping: $store.route, case: \.section) { route in - switch route.wrappedValue { - case .frontpage: - FrontpageView( - store: store.scope(state: \.frontpageState, action: \.frontpage), - user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator - ) - case .toplists: - ToplistsView( - store: store.scope(state: \.toplistsState, action: \.toplists), - user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } func navigateTo(gid: String) { - store.send(.setNavigation(.detail(gid))) + store.send(.galleryTapped(gid)) } func navigateTo(type: HomeMiscGridType) { - store.send(.setNavigation(.misc(type))) + store.send(.miscTapped(type)) } } diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index 7c12f865b..165ea53b5 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -1,19 +1,15 @@ import ComposableArchitecture import AppModels import AppTools -import SwiftUINavigationExt import HapticsClient import DatabaseClient import NetworkingFeature import FiltersFeature -import DetailFeature -import ComposableArchitectureExt @Reducer public struct PopularReducer: Sendable { - @dynamicMemberLookup @CasePathable - public enum Route: Equatable, Sendable { - case detail(String) + public enum Delegate: Equatable, Sendable { + case pushDetail(String) } @Reducer @@ -27,7 +23,6 @@ public struct PopularReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? @Presents public var destination: Destination.State? public var keyword = "" @@ -38,25 +33,18 @@ public struct PopularReducer: Sendable { public var galleries = [Gallery]() public var loadingState: LoadingState = .idle - public var detailState: Heap - - public init() { - detailState = .init(.init()) - } + public init() {} } public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates + case delegate(Delegate) case filtersButtonTapped case destination(PresentationAction) case teardown case fetchGalleries case fetchGalleriesDone(Result<[Gallery], AppError>) - - case detail(DetailReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -66,22 +54,14 @@ public struct PopularReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } Reduce { state, action in switch action { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) + case .delegate: + return .none case .filtersButtonTapped: state.destination = .filters(FiltersReducer.State()) @@ -117,9 +97,6 @@ public struct PopularReducer: Sendable { state.loadingState = .failed(error) } return .none - - case .detail: - return .none } } .haptics( @@ -128,8 +105,6 @@ public struct PopularReducer: Sendable { hapticsClient: hapticsClient ) .ifLet(\.$destination, action: \.destination) - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index 887f79f17..3761438aa 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -3,12 +3,10 @@ import AppModels import TagTranslationFeature import Resources import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents import GalleryListComponents import FiltersFeature -import DetailFeature struct PopularView: View { @Bindable private var store: StoreOf @@ -29,64 +27,35 @@ struct PopularView: View { } var body: some View { - let content = - GenericList( - galleries: store.filteredGalleries, - setting: setting, pageNumber: nil, - loadingState: store.loadingState, - footerLoadingState: .idle, - fetchAction: { store.send(.fetchGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - .sheet( - item: $store.scope(state: \.destination?.filters, action: \.destination.filters) - ) { store in - FiltersView(store: store) - .autoBlur(radius: blurRadius).environment(\.inSheet, true) + GenericList( + galleries: store.filteredGalleries, + setting: setting, pageNumber: nil, + loadingState: store.loadingState, + footerLoadingState: .idle, + fetchAction: { store.send(.fetchGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .onAppear { - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries) - } + ) + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) + .onAppear { + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries) } } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.PopularView.Title.popular) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content } + .toolbar(content: toolbar) + .navigationTitle(L10n.Localizable.PopularView.Title.popular) } - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } private func toolbar() -> some ToolbarContent { CustomToolbarItem { FiltersButton(hideText: true) { diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index c8a8535d6..9e0ff49a4 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -4,14 +4,11 @@ import AppTools import HapticsClient import DatabaseClient import NetworkingFeature -import DetailFeature -import ComposableArchitectureExt @Reducer public struct ToplistsReducer: Sendable { - @dynamicMemberLookup @CasePathable - public enum Route: Equatable, Sendable { - case detail(String) + public enum Delegate: Equatable, Sendable { + case pushDetail(String) } private enum CancelID: CaseIterable { @@ -20,7 +17,6 @@ public struct ToplistsReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? public var keyword = "" public var jumpPageIndex = "" public var jumpPageAlertFocused = false @@ -51,11 +47,7 @@ public struct ToplistsReducer: Sendable { rawFooterLoadingState[type] } - public var detailState: Heap - - public init() { - detailState = .init(.init()) - } + public init() {} mutating func insertGalleries(type: ToplistsType, galleries: [Gallery]) { galleries.forEach { gallery in @@ -68,9 +60,8 @@ public struct ToplistsReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) + case delegate(Delegate) case setToplistsType(ToplistsType) - case clearSubStates case performJumpPage case presentJumpPageAlert @@ -81,8 +72,6 @@ public struct ToplistsReducer: Sendable { case fetchGalleriesDone(ToplistsType, Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(ToplistsType, Result<(PageNumber, [Gallery]), AppError>) - - case detail(DetailReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -92,9 +81,6 @@ public struct ToplistsReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } .onChange(of: \.jumpPageAlertPresented) { _, state in if !state.jumpPageAlertPresented { state.jumpPageAlertFocused = false @@ -107,19 +93,14 @@ public struct ToplistsReducer: Sendable { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none + case .delegate: + return .none case .setToplistsType(let type): state.type = type guard state.galleries?.isEmpty != false else { return .none } return .send(.fetchGalleries()) - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) - case .performJumpPage: guard let index = Int(state.jumpPageIndex), let pageNumber = state.pageNumber, @@ -210,12 +191,7 @@ public struct ToplistsReducer: Sendable { state.rawFooterLoadingState[type] = .failed(error) } return .none - - case .detail: - return .none } } - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index bb80c6093..bd8a91bf5 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -3,12 +3,10 @@ import AppModels import TagTranslationFeature import Resources import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents import GalleryListComponents import AlertKitExt -import DetailFeature struct ToplistsView: View { @Bindable private var store: StoreOf @@ -33,69 +31,40 @@ struct ToplistsView: View { } var body: some View { - let content = - GenericList( - galleries: store.filteredGalleries ?? [], - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState ?? .idle, - footerLoadingState: store.footerLoadingState ?? .idle, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - .jumpPageAlert( - index: $store.jumpPageIndex, - isPresented: $store.jumpPageAlertPresented, - isFocused: $store.jumpPageAlertFocused, - pageNumber: store.pageNumber ?? .init(), - jumpAction: { store.send(.performJumpPage) } - ) - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .navigationBarBackButtonHidden(store.jumpPageAlertPresented) - .animation(.default, value: store.jumpPageAlertPresented) - .onAppear { - if store.galleries?.isEmpty != false { - DispatchQueue.main.async { - store.send(.fetchGalleries()) - } - } + GenericList( + galleries: store.filteredGalleries ?? [], + setting: setting, + pageNumber: store.pageNumber, + loadingState: store.loadingState ?? .idle, + footerLoadingState: store.footerLoadingState ?? .idle, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(navigationTitle) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) + ) + .jumpPageAlert( + index: $store.jumpPageIndex, + isPresented: $store.jumpPageAlertPresented, + isFocused: $store.jumpPageAlertFocused, + pageNumber: store.pageNumber ?? .init(), + jumpAction: { store.send(.performJumpPage) } + ) + .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) + .navigationBarBackButtonHidden(store.jumpPageAlertPresented) + .animation(.default, value: store.jumpPageAlertPresented) + .onAppear { + if store.galleries?.isEmpty != false { + DispatchQueue.main.async { + store.send(.fetchGalleries()) } - } else { - content - } - } - - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) } } + .toolbar(content: toolbar) + .navigationTitle(navigationTitle) } + private func toolbar() -> some ToolbarContent { CustomToolbarItem(disabled: store.jumpPageAlertPresented) { ToplistsTypeMenu(type: store.type) { type in diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index f19554611..f1910a2e7 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -1,7 +1,6 @@ import AppTools import ComposableArchitecture import AppModels -import SwiftUINavigationExt import HapticsClient import DatabaseClient import NetworkingFeature @@ -9,14 +8,11 @@ import DownloadClient import FiltersFeature import DateSeekFeature import QuickSearchFeature -import DetailFeature -import ComposableArchitectureExt @Reducer public struct WatchedReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case detail(String) + public enum Delegate: Equatable, Sendable { + case pushDetail(String) } @Reducer @@ -32,7 +28,6 @@ public struct WatchedReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? @Presents public var destination: Destination.State? public var keyword = "" @@ -43,11 +38,7 @@ public struct WatchedReducer: Sendable { public var footerLoadingState: LoadingState = .idle public var downloadBadges = [String: DownloadBadge]() - public var detailState: Heap - - public init() { - detailState = .init(.init()) - } + public init() {} mutating func insertGalleries(_ galleries: [Gallery]) { galleries.forEach { gallery in @@ -61,8 +52,7 @@ public struct WatchedReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) case onAppear - case setNavigation(Route?) - case clearSubStates + case delegate(Delegate) case filtersButtonTapped case quickSearchButtonTapped case dateSeekButtonTapped(DateSeekNavigation) @@ -77,8 +67,6 @@ public struct WatchedReducer: Sendable { case observeDownloads case observeDownloadsDone([DownloadedGallery]) case performDateSeekDone(Result) - - case detail(DetailReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -89,9 +77,6 @@ public struct WatchedReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } Reduce { state, action in switch action { @@ -101,13 +86,8 @@ public struct WatchedReducer: Sendable { case .onAppear: return .send(.observeDownloads) - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) + case .delegate: + return .none case .filtersButtonTapped: state.destination = .filters(FiltersReducer.State()) @@ -246,9 +226,6 @@ public struct WatchedReducer: Sendable { case .destination: return .none - - case .detail: - return .none } } .haptics( @@ -267,8 +244,6 @@ public struct WatchedReducer: Sendable { hapticsClient: hapticsClient ) .ifLet(\.$destination, action: \.destination) - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index abdcd4f86..01f524a44 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -3,14 +3,12 @@ import AppModels import TagTranslationFeature import Resources import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents import DateSeekFeature import GalleryListComponents import FiltersFeature import QuickSearchFeature -import DetailFeature struct WatchedView: View { @Bindable private var store: StoreOf @@ -31,105 +29,76 @@ struct WatchedView: View { } var body: some View { - let content = - ZStack { - if CookieUtil.didLogin { - GenericList( - galleries: store.galleries, - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState, - footerLoadingState: store.footerLoadingState, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - }, - downloadBadges: store.downloadBadges - ) - } else { - NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) - } - } - .sheet( - item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) - ) { store in - QuickSearchView(store: store) { keyword in - self.store.send(.destination(.dismiss)) - self.store.send(.fetchGalleries(keyword)) - } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet( - item: $store.scope(state: \.destination?.filters, action: \.destination.filters) - ) { store in - FiltersView(store: store) - .autoBlur(radius: blurRadius).environment(\.inSheet, true) - } - .sheet( - item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) - ) { store in - @Bindable var store = store - DateSeekPickerView( - selectedDate: $store.date, - navigation: store.navigation, - seekAction: { store.send(.performSeek($0)) } + ZStack { + if CookieUtil.didLogin { + GenericList( + galleries: store.galleries, + setting: setting, + pageNumber: store.pageNumber, + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + }, + downloadBadges: store.downloadBadges ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) + } else { + NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion - ) - } - .onSubmit(of: .search) { - store.send(.fetchGalleries()) + } + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.fetchGalleries(keyword)) } - .onAppear { - store.send(.onAppear) - if store.galleries.isEmpty && CookieUtil.didLogin { - DispatchQueue.main.async { - store.send(.fetchGalleries()) - } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .sheet( + item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) + ) { store in + @Bindable var store = store + DateSeekPickerView( + selectedDate: $store.date, + navigation: store.navigation, + seekAction: { store.send(.performSeek($0)) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: tagTranslator.translations, + showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + ) + } + .onSubmit(of: .search) { + store.send(.fetchGalleries()) + } + .onAppear { + store.send(.onAppear) + if store.galleries.isEmpty && CookieUtil.didLogin { + DispatchQueue.main.async { + store.send(.fetchGalleries()) } } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.WatchedView.Title.watched) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content } + .toolbar(content: toolbar) + .navigationTitle(L10n.Localizable.WatchedView.Title.watched) } - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } private func toolbar() -> some ToolbarContent { CustomToolbarItem { ToolbarFeaturesMenu { diff --git a/AppPackage/Sources/SearchFeature/SearchPath.swift b/AppPackage/Sources/SearchFeature/SearchPath.swift new file mode 100644 index 000000000..a274cb966 --- /dev/null +++ b/AppPackage/Sources/SearchFeature/SearchPath.swift @@ -0,0 +1,26 @@ +import ComposableArchitecture +import DetailFeature + +// The Search tab's navigation stack: the search-results screen plus the shared gallery drill-down, +// nested as a `.gallery` case so the gallery routing stays defined once in `GalleryPath`. +@Reducer +public enum SearchPath { + case search(SearchReducer) + case gallery(GalleryPath.Body = GalleryPath.body) +} + +extension SearchPath.State: Equatable {} + +extension StackState where Element == SearchPath.State { + // Locate the pushed `.gallery(.detail)` element for `gid` so a comment action performed on a + // deeper `.comments` screen can refresh the detail it belongs to. + func galleryDetailID(forGID gid: String) -> StackElementID? { + for id in ids { + guard let element = self[id: id] else { continue } + if case .gallery(.detail(let state)) = element, state.gid == gid { + return id + } + } + return nil + } +} diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index eb21a8ce9..76f79d68d 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -2,7 +2,6 @@ import AppTools import ComposableArchitecture import AppModels import Foundation -import SwiftUINavigationExt import HapticsClient import DatabaseClient import NetworkingFeature @@ -10,14 +9,12 @@ import DownloadClient import FiltersFeature import DateSeekFeature import QuickSearchFeature -import DetailFeature -import ComposableArchitectureExt @Reducer public struct SearchReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case detail(String) + public enum Delegate: Equatable, Sendable { + case pushDetail(String) + case searchPerformed(String) } @Reducer @@ -33,7 +30,6 @@ public struct SearchReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? @Presents public var destination: Destination.State? public var keyword = "" public var lastKeyword = "" @@ -45,10 +41,9 @@ public struct SearchReducer: Sendable { public var footerLoadingState: LoadingState = .idle public var downloadBadges = [String: DownloadBadge]() - public var detailState: Heap - - public init() { - detailState = .init(.init()) + public init(keyword: String = "") { + self.keyword = keyword + lastKeyword = keyword } mutating func insertGalleries(_ galleries: [Gallery]) { @@ -63,8 +58,7 @@ public struct SearchReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) case onAppear - case setNavigation(Route?) - case clearSubStates + case delegate(Delegate) case filtersButtonTapped case quickSearchButtonTapped case dateSeekButtonTapped(DateSeekNavigation) @@ -78,8 +72,6 @@ public struct SearchReducer: Sendable { case observeDownloads case observeDownloadsDone([DownloadedGallery]) case performDateSeekDone(Result) - - case detail(DetailReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -90,9 +82,6 @@ public struct SearchReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } .onChange(of: \.keyword) { _, state in if !state.keyword.isEmpty { state.lastKeyword = state.keyword @@ -108,13 +97,8 @@ public struct SearchReducer: Sendable { case .onAppear: return .send(.observeDownloads) - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) + case .delegate: + return .none case .filtersButtonTapped: state.destination = .filters(FiltersReducer.State()) @@ -132,7 +116,11 @@ public struct SearchReducer: Sendable { return .merge(CancelID.allCases.map(Effect.cancel(id:))) case .fetchGalleries(let keyword): - guard state.loadingState != .loading else { return .none } + // The performed keyword is what the host records into search history: an explicit + // keyword when provided, otherwise the current `lastKeyword`. Emit it even when a + // fetch is already in flight, matching the previous host-observes-every-fetch behavior. + let historyEffect: Effect = .send(.delegate(.searchPerformed(keyword ?? state.lastKeyword))) + guard state.loadingState != .loading else { return historyEffect } if let keyword = keyword { state.keyword = keyword state.lastKeyword = keyword @@ -140,11 +128,14 @@ public struct SearchReducer: Sendable { state.loadingState = .loading state.pageNumber.resetPages() let filter = databaseClient.fetchFilterSynchronously(range: .search) - return .run { [lastKeyword = state.lastKeyword] send in - let response = await SearchGalleriesRequest(keyword: lastKeyword, filter: filter).response() - await send(.fetchGalleriesDone(response)) - } - .cancellable(id: CancelID.fetchGalleries) + return .merge( + historyEffect, + .run { [lastKeyword = state.lastKeyword] send in + let response = await SearchGalleriesRequest(keyword: lastKeyword, filter: filter).response() + await send(.fetchGalleriesDone(response)) + } + .cancellable(id: CancelID.fetchGalleries) + ) case .fetchGalleriesDone(let result): state.loadingState = .idle @@ -251,9 +242,6 @@ public struct SearchReducer: Sendable { case .destination: return .none - - case .detail: - return .none } } .haptics( @@ -272,8 +260,6 @@ public struct SearchReducer: Sendable { hapticsClient: hapticsClient ) .ifLet(\.$destination, action: \.destination) - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) } } diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift index e12087b26..95237617c 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -1,22 +1,14 @@ import ComposableArchitecture import AppModels import AppTools -import SwiftUINavigationExt import HapticsClient import DatabaseClient import FiltersFeature import QuickSearchFeature import DetailFeature -import ComposableArchitectureExt @Reducer public struct SearchRootReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case search - case detail(String) - } - @Reducer public enum Destination { case filters(FiltersReducer) @@ -25,7 +17,7 @@ public struct SearchRootReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? + public var path = StackState() @Presents public var destination: Destination.State? public var keyword = "" public var historyGalleries = [Gallery]() @@ -33,12 +25,7 @@ public struct SearchRootReducer: Sendable { public var historyKeywords = [String]() public var quickSearchWords = [QuickSearchWord]() - public var searchState = SearchReducer.State() - public var detailState: Heap - - public init() { - detailState = .init(.init()) - } + public init() {} mutating func appendHistoryKeywords(_ keywords: [String]) { guard !keywords.isEmpty else { return } @@ -73,9 +60,10 @@ public struct SearchRootReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) + case pushSearch + case galleryTapped(String) + case path(StackActionOf) case setKeyword(String) - case clearSubStates case filtersButtonTapped case quickSearchButtonTapped case destination(PresentationAction) @@ -87,9 +75,6 @@ public struct SearchRootReducer: Sendable { case removeHistoryKeyword(String) case fetchHistoryGalleries case fetchHistoryGalleriesDone([Gallery]) - - case search(SearchReducer.Action) - case detail(DetailReducer.Action) } @Dependency(\.databaseClient) private var databaseClient @@ -99,13 +84,13 @@ public struct SearchRootReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil - ? .merge( - .send(.clearSubStates), - .send(.fetchDatabaseInfos) - ) - : .none + .onChange(of: \.path) { oldValue, state in + // Returning to the root refreshes history keywords / quick-search words that a + // pushed Search screen (or the QuickSearch editor) may have changed. + if !oldValue.isEmpty, state.path.isEmpty { + return .send(.fetchDatabaseInfos) + } + return .none } Reduce { state, action in @@ -113,27 +98,39 @@ public struct SearchRootReducer: Sendable { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil - ? .merge( - .send(.clearSubStates), - .send(.fetchDatabaseInfos) - ) - : .none + case .pushSearch: + state.path.append(.search(.init(keyword: state.keyword))) + return .none + + case .galleryTapped(let gid): + state.path.append(.gallery(.detail(.init(gid: gid)))) + return .none + + case let .path(.element(id: _, action: .search(.delegate(.pushDetail(gid))))): + state.path.append(.gallery(.detail(.init(gid: gid)))) + return .none + + case let .path(.element(id: _, action: .search(.delegate(.searchPerformed(keyword))))): + state.appendHistoryKeywords([keyword]) + return .send(.syncHistoryKeywords) + + case let .path(.element(id: _, action: .gallery(.comments(.delegate(.performedCommentAction(gid)))))): + guard let id = state.path.galleryDetailID(forGID: gid) else { return .none } + return .send(.path(.element(id: id, action: .gallery(.detail(.fetchGalleryDetail))))) + + case let .path(.element(id: _, action: .gallery(galleryAction))): + if let next = GalleryNavigation.nextScreen(for: galleryAction) { + state.path.append(.gallery(next)) + } + return .none + + case .path: + return .none case .setKeyword(let keyword): state.keyword = keyword return .none - case .clearSubStates: - state.searchState = .init() - state.detailState.wrappedValue = .init() - return .merge( - .send(.search(.teardown)), - .send(.detail(.teardown)) - ) - case .filtersButtonTapped: state.destination = .filters(FiltersReducer.State()) return .none @@ -178,20 +175,6 @@ public struct SearchRootReducer: Sendable { case .fetchHistoryGalleriesDone(let galleries): state.historyGalleries = Array(galleries.prefix(min(galleries.count, 10))) return .none - - case .search(.fetchGalleries(let keyword)): - if let keyword = keyword { - state.appendHistoryKeywords([keyword]) - } else { - state.appendHistoryKeywords([state.searchState.lastKeyword]) - } - return .send(.syncHistoryKeywords) - - case .search: - return .none - - case .detail: - return .none } } .haptics( @@ -205,9 +188,7 @@ public struct SearchRootReducer: Sendable { hapticsClient: hapticsClient ) .ifLet(\.$destination, action: \.destination) - - Scope(state: \.searchState, action: \.search, child: SearchReducer.init) - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) + .forEach(\.path, action: \.path) } } diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index aaf4e263e..99604cf99 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents import FiltersFeature @@ -28,18 +27,18 @@ public struct SearchRootView: View { } public var body: some View { - NavigationView { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { let content = ScrollView(showsIndicators: false) { SuggestionsPanel( historyKeywords: store.historyKeywords.reversed(), historyGalleries: store.historyGalleries, quickSearchWords: store.quickSearchWords, - navigateGalleryAction: { store.send(.setNavigation(.detail($0))) }, + navigateGalleryAction: { store.send(.galleryTapped($0)) }, navigateQuickSearchAction: { store.send(.quickSearchButtonTapped) }, searchKeywordAction: { keyword in store.send(.setKeyword(keyword)) - store.send(.setNavigation(.search)) + store.send(.pushSearch) }, removeKeywordAction: { store.send(.removeHistoryKeyword($0)) } ) @@ -57,7 +56,7 @@ public struct SearchRootView: View { self.store.send(.destination(.dismiss)) self.store.send(.setKeyword(keyword)) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.store.send(.setNavigation(.search)) + self.store.send(.pushSearch) } } .accentColor(setting.accentColor) @@ -71,39 +70,34 @@ public struct SearchRootView: View { ) } .onSubmit(of: .search) { - store.send(.setNavigation(.search)) + store.send(.pushSearch) } .onAppear { store.send(.fetchHistoryGalleries) store.send(.fetchDatabaseInfos) } - .background(navigationLinks) .toolbar(content: toolbar) .navigationTitle(L10n.Localizable.SearchView.Title.search) - if DeviceUtil.isPad { + // Workaround: Prevent the title disappearing issue. + if store.historyKeywords.isEmpty && store.historyGalleries.isEmpty { content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { gid in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: gid, - user: user, - setting: $setting, - blurRadius: blurRadius, - tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } + .navigationSubtitle(Text(" ")) } else { - // Workaround: Prevent the title disappearing issue. - if store.historyKeywords.isEmpty && store.historyGalleries.isEmpty { - content - .navigationSubtitle(Text(" ")) - } else { - content - } + content + } + } destination: { store in + switch store.case { + case .search(let store): + SearchView( + store: store, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + case .gallery(let store): + galleryDestination( + store, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) } } } @@ -122,33 +116,6 @@ public struct SearchRootView: View { } } -private extension SearchRootView { - @ViewBuilder var navigationLinks: some View { - if DeviceUtil.isPhone { - detailViewLink - } - searchViewLink - } - var detailViewLink: some View { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - var searchViewLink: some View { - NavigationLink(unwrapping: $store.route, case: \.search) { _ in - SearchView( - store: store.scope(state: \.searchState, action: \.search), - keyword: store.keyword, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } -} - // MARK: SuggestionsPanel private struct SuggestionsPanel: View { private let historyKeywords: [String] diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift index 57a602350..0cd4d805c 100644 --- a/AppPackage/Sources/SearchFeature/SearchView.swift +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -2,18 +2,15 @@ import SwiftUI import AppModels import TagTranslationFeature import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents import DateSeekFeature import GalleryListComponents import FiltersFeature import QuickSearchFeature -import DetailFeature struct SearchView: View { @Bindable private var store: StoreOf - private let keyword: String private let user: User @Binding private var setting: Setting private let blurRadius: Double @@ -21,10 +18,9 @@ struct SearchView: View { init( store: StoreOf, - keyword: String, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - self.keyword = keyword self.user = user _setting = setting self.blurRadius = blurRadius @@ -32,99 +28,70 @@ struct SearchView: View { } var body: some View { - let content = - GenericList( - galleries: store.galleries, - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState, - footerLoadingState: store.footerLoadingState, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - }, - downloadBadges: store.downloadBadges - ) - .sheet( - item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) - ) { store in - QuickSearchView(store: store) { keyword in - self.store.send(.destination(.dismiss)) - self.store.send(.fetchGalleries(keyword)) - } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet( - item: $store.scope(state: \.destination?.filters, action: \.destination.filters) - ) { store in - FiltersView(store: store) - .accentColor(setting.accentColor).autoBlur(radius: blurRadius) - } - .sheet( - item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) - ) { store in - @Bindable var store = store - DateSeekPickerView( - selectedDate: $store.date, - navigation: store.navigation, - seekAction: { store.send(.performSeek($0)) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion - ) - } - .onSubmit(of: .search) { - store.send(.fetchGalleries()) + GenericList( + galleries: store.galleries, + setting: setting, + pageNumber: store.pageNumber, + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + }, + downloadBadges: store.downloadBadges + ) + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.fetchGalleries(keyword)) } - .onAppear { - store.send(.onAppear) - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries(keyword)) - } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) + .accentColor(setting.accentColor).autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) + ) { store in + @Bindable var store = store + DateSeekPickerView( + selectedDate: $store.date, + navigation: store.navigation, + seekAction: { store.send(.performSeek($0)) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: tagTranslator.translations, + showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + ) + } + .onSubmit(of: .search) { + store.send(.fetchGalleries()) + } + .onAppear { + store.send(.onAppear) + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries()) } } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(store.lastKeyword) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content } + .toolbar(content: toolbar) + .navigationTitle(store.lastKeyword) } - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } private func toolbar() -> some ToolbarContent { CustomToolbarItem { ToolbarFeaturesMenu { @@ -146,7 +113,6 @@ struct SearchView_Previews: PreviewProvider { static var previews: some View { SearchView( store: .init(initialState: .init(), reducer: SearchReducer.init), - keyword: .init(), user: .init(), setting: .constant(.init()), blurRadius: 0, diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift index a04ac9d7c..5a7b7cf29 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift @@ -34,13 +34,17 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { let store = TestStore(initialState: initialState, reducer: DownloadsReducer.init) store.exhaustivity = .off - await store.send(.setNavigation(.detail(download.gid))) + await store.send(.galleryTapped(download.gid)) - #expect(store.state.route == .detail(download.gid)) - #expect(store.state.detailState.wrappedValue?.gid == download.gid) - #expect(store.state.detailState.wrappedValue?.gallery.id == download.gid) - #expect(store.state.detailState.wrappedValue?.downloadBadge?.status == .completed) - #expect(store.state.detailState.wrappedValue?.shouldCheckForRemoteUpdates == true) + #expect(store.state.path.count == 1) + guard let element = store.state.path.first, case .detail(let detailState) = element else { + Issue.record("Expected a pushed detail element") + return + } + #expect(detailState.gid == download.gid) + #expect(detailState.gallery.id == download.gid) + #expect(detailState.downloadBadge?.status == .completed) + #expect(detailState.shouldCheckForRemoteUpdates == true) } @MainActor From 7793073593aab196556e56db1449514c95113ae6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 01:09:05 +0800 Subject: [PATCH 411/614] Replace NavigationView with NavigationStack --- AppPackage/Sources/AppFeature/RootView.swift | 1 - AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift | 2 +- AppPackage/Sources/DetailFeature/Comments/CommentsView.swift | 2 +- AppPackage/Sources/DetailFeature/DetailView.swift | 2 +- .../Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift | 2 +- AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift | 2 +- AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift | 2 +- AppPackage/Sources/HomeFeature/History/HistoryView.swift | 2 +- AppPackage/Sources/HomeFeature/Popular/PopularView.swift | 2 +- AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift | 2 +- AppPackage/Sources/HomeFeature/Watched/WatchedView.swift | 2 +- AppPackage/Sources/MigrationFeature/MigrationView.swift | 3 +-- AppPackage/Sources/ReadingFeature/ReadingView.swift | 2 +- .../Sources/ReadingSettingFeature/ReadingSettingView.swift | 2 +- .../SettingFeature/AccountSetting/AccountSettingView.swift | 2 +- .../AppearanceSetting/AppearanceSettingView.swift | 2 +- AppPackage/Sources/SettingFeature/Components/AboutView.swift | 2 +- .../SettingFeature/Components/DownloadSettingView.swift | 2 +- .../SettingFeature/Components/LaboratorySettingView.swift | 2 +- .../Sources/SettingFeature/EhSetting/EhSettingView.swift | 2 +- .../SettingFeature/GeneralSetting/GeneralSettingView.swift | 2 +- AppPackage/Sources/SettingFeature/Login/LoginView.swift | 2 +- 22 files changed, 21 insertions(+), 23 deletions(-) diff --git a/AppPackage/Sources/AppFeature/RootView.swift b/AppPackage/Sources/AppFeature/RootView.swift index db2e654e3..be1b488bb 100644 --- a/AppPackage/Sources/AppFeature/RootView.swift +++ b/AppPackage/Sources/AppFeature/RootView.swift @@ -28,7 +28,6 @@ public struct RootView: View { .opacity(databaseState != .idle ? 1 : 0) .animation(.linear(duration: 0.5), value: databaseState) } - .navigationViewStyle(.stack) } private func addTouchHandler() { diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift index d8818920a..33854d9b9 100644 --- a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift @@ -29,7 +29,7 @@ public struct DateSeekPickerView: View { } public var body: some View { - NavigationView { + NavigationStack { Form { Section { DatePicker( diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index b3f4460a2..47e05e06b 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -253,7 +253,7 @@ private extension KFImage { struct CommentsView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { CommentsView( store: .init(initialState: .init(galleryURL: .mock), reducer: CommentsReducer.init), gid: .init(), diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 95e9778c3..65ae3a8d2 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -313,7 +313,7 @@ private extension DetailView { struct DetailView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { DetailView( store: .init(initialState: .init(), reducer: DetailReducer.init), gid: .init(), diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index a1aa7d88b..d9f7028ef 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -128,7 +128,7 @@ private struct Info: Identifiable { struct GalleryInfosView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { GalleryInfosView( store: .init(initialState: .init(), reducer: GalleryInfosReducer.init), gallery: .preview, diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift index 28d7b1bbd..599df96cc 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift @@ -82,7 +82,7 @@ struct PreviewsView: View { struct PreviewsView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { PreviewsView( store: .init(initialState: .init(gallery: .preview), reducer: PreviewsReducer.init), gid: .init(), diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 4befaedc3..3c351b68b 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -86,7 +86,7 @@ struct FrontpageView: View { struct FrontpageView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { FrontpageView( store: .init(initialState: .init(), reducer: FrontpageReducer.init), user: .init(), diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index ad3acb0a0..d6257dbff 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -69,7 +69,7 @@ struct HistoryView: View { struct HistoryView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { HistoryView( store: .init(initialState: .init(), reducer: HistoryReducer.init), user: .init(), diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index 3761438aa..65f10a4cb 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -67,7 +67,7 @@ struct PopularView: View { struct PopularView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { PopularView( store: .init(initialState: .init(), reducer: PopularReducer.init), user: .init(), diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index bd8a91bf5..1a57dacb0 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -86,7 +86,7 @@ struct ToplistsView: View { struct ToplistsView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { ToplistsView( store: .init(initialState: .init(), reducer: ToplistsReducer.init), user: .init(), diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index 01f524a44..975a9a6bb 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -118,7 +118,7 @@ struct WatchedView: View { struct WatchedView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { WatchedView( store: .init(initialState: .init(), reducer: WatchedReducer.init), user: .init(), diff --git a/AppPackage/Sources/MigrationFeature/MigrationView.swift b/AppPackage/Sources/MigrationFeature/MigrationView.swift index f83aa9b56..785161bee 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationView.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationView.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import SwiftUINavigationExt import AppComponents public struct MigrationView: View { @@ -18,7 +17,7 @@ public struct MigrationView: View { } public var body: some View { - NavigationView { + NavigationStack { ZStack { reversedPrimary.ignoresSafeArea() LoadingView(title: L10n.Localizable.LoadingView.Title.preparingDatabase) diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 627074271..f33c9233d 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -340,7 +340,7 @@ extension ReadingView { struct ReadingView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { Text("") .fullScreenCover(isPresented: .constant(true)) { ReadingView( diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index 0e23813ef..0b0e173f2 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -118,7 +118,7 @@ private extension Double { struct ReadingSettingView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { ReadingSettingView( readingDirection: .constant(.vertical), prefetchLimit: .constant(10), diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index cc88b6453..4c748f7fe 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -182,7 +182,7 @@ private struct CookieRow: View { struct AccountSettingView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { AccountSettingView( store: .init(initialState: .init(), reducer: AccountSettingReducer.init), galleryHost: .constant(.ehentai), diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift index 4f4f0bdf9..f1321a679 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -162,7 +162,7 @@ private struct AppIconRow: View { struct AppearanceSettingView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { AppearanceSettingView( store: .init(initialState: .init(), reducer: AppearanceSettingReducer.init), preferredColorScheme: .constant(.automatic), diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index 4a29345f9..bc49685de 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -248,7 +248,7 @@ private struct Info: Identifiable { struct EhPandaView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { AboutView() } } diff --git a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift index e46a72cde..7664c339b 100644 --- a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift @@ -56,7 +56,7 @@ struct DownloadSettingView: View { struct DownloadSettingView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { DownloadSettingView( downloadThreadLimit: .constant(1), downloadAllowCellular: .constant(true), diff --git a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift index 296fdcda6..bec390b4f 100644 --- a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift @@ -72,7 +72,7 @@ struct LaboratoryCell: View { struct LaboratorySettingView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { LaboratorySettingView( bypassesSNIFiltering: .constant(false) ) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift index 41ab1408d..8f3859d89 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift @@ -132,7 +132,7 @@ struct EhSettingView: View { struct EhSettingView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { EhSettingView( store: .init( initialState: .init(ehSetting: .empty, ehProfile: .empty, loadingState: .idle), diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index cc4b284ef..b28a4ebe8 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -171,7 +171,7 @@ struct GeneralSettingView: View { struct GeneralSettingView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { GeneralSettingView( store: .init(initialState: .init(), reducer: GeneralSettingReducer.init), tagTranslatorLoadingState: .idle, diff --git a/AppPackage/Sources/SettingFeature/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift index 96c18e61f..561d55946 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginView.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginView.swift @@ -147,7 +147,7 @@ private struct LoginTextField: View { struct LoginView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { LoginView( store: .init(initialState: .init(), reducer: LoginReducer.init), bypassesSNIFiltering: false, From f9a257867f3b27755a6b25934d13bba53291ddca Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 01:15:23 +0800 Subject: [PATCH 412/614] Modernize QuickSearch word editor navigation --- .../QuickSearchReducer.swift | 174 +++++++++--------- .../QuickSearchFeature/QuickSearchView.swift | 46 ++--- 2 files changed, 105 insertions(+), 115 deletions(-) diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift index 49970d509..397381189 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift @@ -6,10 +6,10 @@ import DatabaseClient @Reducer public struct QuickSearchReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case newWord - case editWord + // Which flavour of the word editor is pushed onto the stack; drives `.navigationDestination(item:)`. + public enum WordEditKind: Hashable, Sendable { + case new + case edit } public enum Dialog: Equatable, Sendable { @@ -27,7 +27,7 @@ public struct QuickSearchReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - public var route: Route? + public var editKind: WordEditKind? @Presents public var confirmationDialog: ConfirmationDialogState? public var focusedField: FocusField? public var editingWord: QuickSearchWord = .empty @@ -45,15 +45,14 @@ public struct QuickSearchReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) - case setNavigation(Route?) case confirmationDialog(PresentationAction) case deleteWordButtonTapped(QuickSearchWord) - case clearSubStates + case newWordButtonTapped + case editWordButtonTapped(QuickSearchWord) case syncQuickSearchWords case toggleListEditing - case setEditingWord(QuickSearchWord) case appendWord case editWord @@ -71,99 +70,106 @@ public struct QuickSearchReducer: Sendable { public init() {} public var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, state in - state.route == nil ? .send(.clearSubStates) : .none - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .deleteWordButtonTapped(let word): - state.confirmationDialog = ConfirmationDialogState { - TextState("") - } actions: { - ButtonState(role: .destructive, action: .confirmDelete(word)) { - TextState(L10n.Localizable.ConfirmationDialog.Button.delete) + CombineReducers { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .newWordButtonTapped: + state.editingWord = .empty + state.editKind = .new + return .none + + case .editWordButtonTapped(let word): + state.editingWord = word + state.editKind = .edit + return .none + + case .deleteWordButtonTapped(let word): + state.confirmationDialog = ConfirmationDialogState { + TextState("") + } actions: { + ButtonState(role: .destructive, action: .confirmDelete(word)) { + TextState(L10n.Localizable.ConfirmationDialog.Button.delete) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + } message: { + TextState(L10n.Localizable.ConfirmationDialog.Title.delete) } - ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) - } - } message: { - TextState(L10n.Localizable.ConfirmationDialog.Title.delete) - } - return .none + return .none - case .confirmationDialog(.presented(.confirmDelete(let word))): - return .send(.deleteWord(word)) + case .confirmationDialog(.presented(.confirmDelete(let word))): + return .send(.deleteWord(word)) - case .confirmationDialog: - return .none + case .confirmationDialog: + return .none - case .clearSubStates: - state.focusedField = nil - state.editingWord = .empty - return .none - - case .syncQuickSearchWords: - return .run { [state] _ in - await databaseClient.updateQuickSearchWords(state.quickSearchWords) - } + case .syncQuickSearchWords: + return .run { [state] _ in + await databaseClient.updateQuickSearchWords(state.quickSearchWords) + } - case .toggleListEditing: - state.isListEditing.toggle() - return .none + case .toggleListEditing: + state.isListEditing.toggle() + return .none - case .setEditingWord(let word): - state.editingWord = word - return .none + case .appendWord: + state.quickSearchWords.append(state.editingWord) + state.editKind = nil + return .send(.syncQuickSearchWords) - case .appendWord: - state.quickSearchWords.append(state.editingWord) - return .send(.syncQuickSearchWords) + case .editWord: + if let index = state.quickSearchWords.firstIndex(where: { $0.id == state.editingWord.id }) { + state.quickSearchWords[index] = state.editingWord + state.editKind = nil + return .send(.syncQuickSearchWords) + } + state.editKind = nil + return .none - case .editWord: - if let index = state.quickSearchWords.firstIndex(where: { $0.id == state.editingWord.id }) { - state.quickSearchWords[index] = state.editingWord + case .deleteWord(let word): + state.quickSearchWords = state.quickSearchWords.filter({ $0 != word }) return .send(.syncQuickSearchWords) - } - return .none - case .deleteWord(let word): - state.quickSearchWords = state.quickSearchWords.filter({ $0 != word }) - return .send(.syncQuickSearchWords) + case .deleteWordWithOffsets(let offsets): + state.quickSearchWords.remove(atOffsets: offsets) + return .send(.syncQuickSearchWords) - case .deleteWordWithOffsets(let offsets): - state.quickSearchWords.remove(atOffsets: offsets) - return .send(.syncQuickSearchWords) + case .moveWord(let source, let destination): + state.quickSearchWords.move(fromOffsets: source, toOffset: destination) + return .send(.syncQuickSearchWords) - case .moveWord(let source, let destination): - state.quickSearchWords.move(fromOffsets: source, toOffset: destination) - return .send(.syncQuickSearchWords) + case .teardown: + return .cancel(id: CancelID.fetchQuickSearchWords) - case .teardown: - return .cancel(id: CancelID.fetchQuickSearchWords) + case .fetchQuickSearchWords: + state.loadingState = .loading + return .run { send in + let quickSearchWords = await databaseClient.fetchQuickSearchWords() + await send(.fetchQuickSearchWordsDone(quickSearchWords)) + } + .cancellable(id: CancelID.fetchQuickSearchWords) - case .fetchQuickSearchWords: - state.loadingState = .loading - return .run { send in - let quickSearchWords = await databaseClient.fetchQuickSearchWords() - await send(.fetchQuickSearchWordsDone(quickSearchWords)) + case .fetchQuickSearchWordsDone(let words): + state.loadingState = .idle + state.quickSearchWords = words + return .none } - .cancellable(id: CancelID.fetchQuickSearchWords) - - case .fetchQuickSearchWordsDone(let words): - state.loadingState = .idle - state.quickSearchWords = words - return .none } } + // Dismissing the editor (back-swipe or a confirmed save) resets the scratch word and focus. + .onChange(of: \.editKind) { _, state in + if state.editKind == nil { + state.focusedField = nil + state.editingWord = .empty + } + return .none + } .ifLet(\.$confirmationDialog, action: \.confirmationDialog) } } diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index ce0dbf3ff..ea18df606 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -3,7 +3,6 @@ import AppModels import Resources import SFSafeSymbols import ComposableArchitecture -import SwiftUINavigationExt import AppComponents public struct QuickSearchView: View { @@ -44,8 +43,7 @@ public struct QuickSearchView: View { } .tint(.red) Button { - store.send(.setEditingWord(word)) - store.send(.setNavigation(.editWord)) + store.send(.editWordButtonTapped(word)) } label: { Image(systemSymbol: .squareAndPencil) } @@ -82,7 +80,7 @@ public struct QuickSearchView: View { } } .toolbar(content: toolbar) - .background(navigationLinks) + .navigationDestination(item: $store.editKind) { editWordView(for: $0) } .navigationTitle(L10n.Localizable.QuickSearchView.Title.quickSearch) } } @@ -99,8 +97,7 @@ public struct QuickSearchView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { Button { - store.send(.setEditingWord(.empty)) - store.send(.setNavigation(.newWord)) + store.send(.newWordButtonTapped) } label: { Image(systemSymbol: .plus) } @@ -112,31 +109,18 @@ public struct QuickSearchView: View { } } } - @ViewBuilder private var navigationLinks: some View { - NavigationLink(unwrapping: $store.route, case: \.newWord) { _ in - EditWordView( - title: L10n.Localizable.QuickSearchView.Title.newWord, - word: $store.editingWord, - focusedField: $focusedField, - submitAction: onTextFieldSubmitted, - confirmAction: { - store.send(.appendWord) - store.send(.setNavigation(nil)) - } - ) - } - NavigationLink(unwrapping: $store.route, case: \.editWord) { _ in - EditWordView( - title: L10n.Localizable.QuickSearchView.Title.editWord, - word: $store.editingWord, - focusedField: $focusedField, - submitAction: onTextFieldSubmitted, - confirmAction: { - store.send(.editWord) - store.send(.setNavigation(nil)) - } - ) - } + @ViewBuilder private func editWordView(for kind: QuickSearchReducer.WordEditKind) -> some View { + EditWordView( + title: kind == .new + ? L10n.Localizable.QuickSearchView.Title.newWord + : L10n.Localizable.QuickSearchView.Title.editWord, + word: $store.editingWord, + focusedField: $focusedField, + submitAction: onTextFieldSubmitted, + confirmAction: { + store.send(kind == .new ? .appendWord : .editWord) + } + ) } } From 2d309674cb6968e770dfaa44fe04981bfd57398b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 01:30:12 +0800 Subject: [PATCH 413/614] Remove RecurseReducer and deprecated nav helpers --- AppPackage/Package.swift | 28 +---------------- .../ComposableArchitectureExt/.swiftlint.yml | 1 - .../ComposableArchitectureExt/Heap.swift | 31 ------------------- .../RecurseReducer.swift | 19 ------------ .../FolderManager/FolderManagerView.swift | 1 - .../Previews/PreviewsReducer.swift | 1 - .../Torrents/TorrentsReducer.swift | 1 - .../Sources/FiltersFeature/FiltersView.swift | 1 - .../ReadingFeature/ReadingReducer+Body.swift | 1 - .../AccountSettingReducer.swift | 1 - .../AccountSetting/AccountSettingView.swift | 1 - .../EhSetting/EhSettingReducer.swift | 1 - .../EhSetting/EhSettingView+Sections1.swift | 1 - .../EhSetting/EhSettingView.swift | 1 - .../SettingFeature/Login/LoginReducer.swift | 1 - .../SwiftUINavigation+.swift | 26 +--------------- 16 files changed, 2 insertions(+), 114 deletions(-) delete mode 100644 AppPackage/Sources/ComposableArchitectureExt/.swiftlint.yml delete mode 100755 AppPackage/Sources/ComposableArchitectureExt/Heap.swift delete mode 100644 AppPackage/Sources/ComposableArchitectureExt/RecurseReducer.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 2cd804d5e..ee2eb9871 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -81,7 +81,6 @@ enum Module: String { case backgroundProcessingClient = "BackgroundProcessingClient" case clipboardClient = "ClipboardClient" case commonMarkExt = "CommonMarkExt" - case composableArchitectureExt = "ComposableArchitectureExt" case cookieClient = "CookieClient" case dfClient = "DFClient" case databaseClient = "DatabaseClient" @@ -265,7 +264,6 @@ let targets: [PackageDescription.Target] = [ .module(.authorizationClient), .module(.backgroundProcessingClient), .module(.clipboardClient), - .module(.composableArchitectureExt), .module(.cookieClient), .module(.databaseClient), .module(.dateSeekFeature), @@ -291,7 +289,6 @@ let targets: [PackageDescription.Target] = [ .module(.searchFeature), .module(.animatedImageFeature), .module(.settingFeature), - .module(.swiftUINavigationExt), .module(.ttProgressHUDExt), .module(.urlClient), .module(.userDefaultsClient), @@ -333,14 +330,6 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), - .target( - module: .composableArchitectureExt, - dependencies: [ - .targetDependency(.composableArchitecture) - ], - swiftSettings: sharedSwiftSettings, - plugins: swiftLintPlugins - ), .target( module: .deviceClient, dependencies: [ @@ -382,7 +371,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .swiftUINavigationExt, dependencies: [ - .targetDependency(.swiftUINavigation) + .targetDependency(.casePaths) ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins @@ -611,7 +600,6 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.databaseClient), .module(.resources), - .module(.swiftUINavigationExt), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -624,7 +612,6 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.databaseClient), .module(.resources), - .module(.swiftUINavigationExt), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -660,7 +647,6 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.databaseClient), .module(.resources), - .module(.swiftUINavigationExt), .targetDependency(.composableArchitecture), .targetDependency(.sfSafeSymbols) ], @@ -673,13 +659,11 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appModels), .module(.appTools), - .module(.composableArchitectureExt), .module(.detailFeature), .module(.downloadClient), .module(.galleryListComponents), .module(.readingFeature), .module(.resources), - .module(.swiftUINavigationExt), .module(.ttProgressHUDExt), .module(.tagTranslationFeature), .targetDependency(.composableArchitecture), @@ -694,7 +678,6 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appModels), .module(.appTools), - .module(.composableArchitectureExt), .module(.databaseClient), .module(.dateSeekFeature), .module(.detailFeature), @@ -704,7 +687,6 @@ let targets: [PackageDescription.Target] = [ .module(.networkingFeature), .module(.quickSearchFeature), .module(.resources), - .module(.swiftUINavigationExt), .module(.tagTranslationFeature), .targetDependency(.alertKit), .targetDependency(.composableArchitecture) @@ -734,7 +716,6 @@ let targets: [PackageDescription.Target] = [ .module(.osLogExt), .module(.readingSettingFeature), .module(.resources), - .module(.swiftUINavigationExt), .module(.ttProgressHUDExt), .module(.userDefaultsClient), .targetDependency(.composableArchitecture), @@ -751,7 +732,6 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appModels), .module(.appTools), - .module(.composableArchitectureExt), .module(.databaseClient), .module(.dateSeekFeature), .module(.detailFeature), @@ -762,7 +742,6 @@ let targets: [PackageDescription.Target] = [ .module(.networkingFeature), .module(.quickSearchFeature), .module(.resources), - .module(.swiftUINavigationExt), .module(.tagTranslationFeature), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), @@ -778,7 +757,6 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appModels), .module(.appTools), - .module(.composableArchitectureExt), .module(.databaseClient), .module(.dateSeekFeature), .module(.detailFeature), @@ -790,7 +768,6 @@ let targets: [PackageDescription.Target] = [ .module(.networkingFeature), .module(.quickSearchFeature), .module(.resources), - .module(.swiftUINavigationExt), .module(.tagTranslationFeature), .targetDependency(.alertKit), .targetDependency(.colorful), @@ -812,7 +789,6 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .module(.applicationClient), .module(.clipboardClient), - .module(.composableArchitectureExt), .module(.cookieClient), .module(.databaseClient), .module(.downloadClient), @@ -824,7 +800,6 @@ let targets: [PackageDescription.Target] = [ .module(.quickSearchFeature), .module(.readingFeature), .module(.resources), - .module(.swiftUINavigationExt), .module(.ttProgressHUDExt), .module(.tagTranslationFeature), .module(.urlClient), @@ -855,7 +830,6 @@ let targets: [PackageDescription.Target] = [ .module(.readingSettingFeature), .module(.resources), .module(.animatedImageFeature), - .module(.swiftUINavigationExt), .module(.ttProgressHUDExt), .module(.urlClient), .targetDependency(.composableArchitecture), diff --git a/AppPackage/Sources/ComposableArchitectureExt/.swiftlint.yml b/AppPackage/Sources/ComposableArchitectureExt/.swiftlint.yml deleted file mode 100644 index 1242ffcaa..000000000 --- a/AppPackage/Sources/ComposableArchitectureExt/.swiftlint.yml +++ /dev/null @@ -1 +0,0 @@ -parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/ComposableArchitectureExt/Heap.swift b/AppPackage/Sources/ComposableArchitectureExt/Heap.swift deleted file mode 100755 index 81ebb9540..000000000 --- a/AppPackage/Sources/ComposableArchitectureExt/Heap.swift +++ /dev/null @@ -1,31 +0,0 @@ -private final class Reference: Equatable { - var value: T - init(_ value: T) { - self.value = value - } - static func == (lhs: Reference, rhs: Reference) -> Bool { - lhs.value == rhs.value - } -} - -@propertyWrapper public struct Heap: Equatable { - private var reference: Reference - - public init(_ value: T) { - reference = .init(value) - } - - public var wrappedValue: T { - get { reference.value } - set { - if !isKnownUniquelyReferenced(&reference) { - reference = .init(newValue) - return - } - reference.value = newValue - } - } - public var projectedValue: Heap { - self - } -} diff --git a/AppPackage/Sources/ComposableArchitectureExt/RecurseReducer.swift b/AppPackage/Sources/ComposableArchitectureExt/RecurseReducer.swift deleted file mode 100644 index 196d504c5..000000000 --- a/AppPackage/Sources/ComposableArchitectureExt/RecurseReducer.swift +++ /dev/null @@ -1,19 +0,0 @@ -import ComposableArchitecture - -// MARK: Recurse -public struct RecurseReducer: Reducer -where State == Base.State, Action == Base.Action { - let base: (Reduce) -> Base - - public init(@ReducerBuilder base: @escaping (Reduce) -> Base) { - self.base = base - } - - public var body: some Reducer { - var `self`: Reduce! - self = Reduce { state, action in - base(self)._reduce(into: &state, action: action) - } - return self - } -} diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift index 5f7dd4df9..c209860f2 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift @@ -2,7 +2,6 @@ import SwiftUI import Resources import SFSafeSymbols import ComposableArchitecture -import SwiftUINavigationExt import AppComponents public struct FolderManagerView: View { diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index 4d211a676..1361700f8 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -2,7 +2,6 @@ import Foundation import AppModels import ComposableArchitecture import AppTools -import SwiftUINavigationExt import HapticsClient import DatabaseClient import NetworkingFeature diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift index dbb928f5c..fcf7ddef4 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift @@ -1,7 +1,6 @@ import Foundation import AppModels import ComposableArchitecture -import SwiftUINavigationExt import HapticsClient import NetworkingFeature import ClipboardClient diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index b317485d6..4b8eeee8f 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import SwiftUINavigationExt import AppComponents public struct FiltersView: View { diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index e7745b92a..279f73bc2 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -3,7 +3,6 @@ import Kingfisher import TTProgressHUD import ComposableArchitecture import AppTools -import SwiftUINavigationExt // MARK: - CancelID enum ReadingCancelID: CaseIterable { diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index 3093714b6..630be6ce3 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -2,7 +2,6 @@ import Foundation import AppModels import Resources import ComposableArchitecture -import SwiftUINavigationExt import HapticsClient import ClipboardClient import CookieClient diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index 4c748f7fe..de2409b61 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -3,7 +3,6 @@ import AppModels import AppComponents import Resources import ComposableArchitecture -import SwiftUINavigationExt import AppTools import TTProgressHUDExt diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index 5f0097b10..ab7cd22b8 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -3,7 +3,6 @@ import Foundation import AppModels import Resources import ComposableArchitecture -import SwiftUINavigationExt import ApplicationClient import HapticsClient import NetworkingFeature diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift index fdce97612..9b6b969b0 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -3,7 +3,6 @@ import AppModels import Resources import ComposableArchitecture import AppTools -import SwiftUINavigationExt import AppComponents extension EhSettingView { diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift index 8f3859d89..eba1a5461 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import SwiftUINavigationExt import AppTools import AppComponents diff --git a/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift index 1e2af7bd6..71c5c45ab 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift @@ -1,7 +1,6 @@ import SwiftUI import AppModels import ComposableArchitecture -import SwiftUINavigationExt import HapticsClient import NetworkingFeature import CookieClient diff --git a/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift b/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift index aebef4a0c..eb928f2f8 100644 --- a/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift +++ b/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift @@ -1,29 +1,5 @@ import SwiftUI -import SwiftUINavigation - -extension NavigationLink { - public init( - _ title: S, - unwrapping value: Binding, - @ViewBuilder destination: @escaping (Binding) -> WrappedDestination - ) where Destination == WrappedDestination?, Label == Text { - self.init( - title, - destination: Binding(unwrapping: value).map(destination), - isActive: .init(value) - ) - } - public init( - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath, - @ViewBuilder destination: @escaping (Binding) -> WrappedDestination - ) where Destination == WrappedDestination?, Label == Text { - self.init( - "", unwrapping: `enum`.case(caseKeyPath), - destination: destination - ) - } -} +import CasePaths extension Binding { public func `case`( From 5b204baa23ab571ed326919fd0d9e2a16fcd57a1 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 07:17:17 +0800 Subject: [PATCH 414/614] Present gallery detail modally on iPad --- AppPackage/Package.swift | 4 ++ .../AppFeature/DataFlow/AppReducer.swift | 10 +++++ .../AppFeature/DataFlow/AppRouteReducer.swift | 8 ++++ .../AppFeature/View/TabBar/TabBarView.swift | 1 + .../Sources/DetailFeature/DetailReducer.swift | 13 ++++++ .../Sources/DeviceClient/DeviceClient.swift | 12 ++++++ .../DownloadsFeature/DownloadsReducer.swift | 33 ++++++++++++--- .../FavoritesFeature/FavoritesReducer.swift | 22 ++++++++++ .../HomeFeature/HomeReducer+Body.swift | 29 +++++++++----- .../Sources/HomeFeature/HomeReducer.swift | 8 ++++ .../SearchFeature/SearchRootReducer.swift | 25 ++++++++++-- .../DownloadsReducerActionTests.swift | 40 ++++++++++++++++++- 12 files changed, 186 insertions(+), 19 deletions(-) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index ee2eb9871..5421e266a 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -660,6 +660,7 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.appTools), .module(.detailFeature), + .module(.deviceClient), .module(.downloadClient), .module(.galleryListComponents), .module(.readingFeature), @@ -681,6 +682,7 @@ let targets: [PackageDescription.Target] = [ .module(.databaseClient), .module(.dateSeekFeature), .module(.detailFeature), + .module(.deviceClient), .module(.downloadClient), .module(.galleryListComponents), .module(.hapticsClient), @@ -735,6 +737,7 @@ let targets: [PackageDescription.Target] = [ .module(.databaseClient), .module(.dateSeekFeature), .module(.detailFeature), + .module(.deviceClient), .module(.downloadClient), .module(.filtersFeature), .module(.galleryListComponents), @@ -760,6 +763,7 @@ let targets: [PackageDescription.Target] = [ .module(.databaseClient), .module(.dateSeekFeature), .module(.detailFeature), + .module(.deviceClient), .module(.downloadClient), .module(.filtersFeature), .module(.galleryListComponents), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 6779e5028..5208e956e 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -251,6 +251,16 @@ struct AppReducer { } return .merge(effects) + // A gallery tapped on iPad presents modally (hosted by AppRoute) instead of pushing + // inline; the tab hosts delegate that presentation up here. + case let .home(.delegate(.presentGalleryDetail(gid))), + let .searchRoot(.delegate(.presentGalleryDetail(gid))), + let .favorites(.delegate(.presentGalleryDetail(gid))): + return .send(.appRoute(.presentGalleryDetail(gid, nil))) + + case let .downloads(.delegate(.presentGalleryDetail(gid, download))): + return .send(.appRoute(.presentGalleryDetail(gid, download))) + case .home: return .none diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index fc9aac964..4cdbe812e 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -45,6 +45,7 @@ struct AppRouteReducer { case path(StackActionOf) case presentSetting case presentNewDawn(Greeting) + case presentGalleryDetail(String, DownloadedGallery?) case setHUDConfig(ProgressHUDConfigState) case detectClipboardURL @@ -112,6 +113,13 @@ struct AppRouteReducer { state.destination = .newDawn(greeting) return .none + case .presentGalleryDetail(let gid, let download): + // A gallery opened from a tab on iPad: modal detail rooting its own gallery stack, + // seeded from the local download when one exists so it renders offline. + state.path.removeAll() + state.detail = .init(gid: gid, seededFrom: download) + return .none + case .setHUDConfig(let config): state.hudConfig = config return .none diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index a0e48b0f4..ed3d266a6 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -124,6 +124,7 @@ struct TabBarView: View { } .accentColor(store.settingState.setting.accentColor) .autoBlur(radius: store.appLockState.blurRadius) + .environment(\.inSheet, true) } .progressHUD( config: store.appRouteState.hudConfig, diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index 2aeff0392..7dd271d32 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -249,4 +249,17 @@ extension DetailReducer { } } +extension DetailReducer.State { + // Pre-populated from a local download so a downloaded gallery renders instantly and offline; + // the live download observation keeps the state in sync afterwards. Shared by the Downloads + // tab's inline push and the app-level modal presentation (iPad / deep link). + public init(gid: String, seededFrom download: DownloadedGallery?) { + self.init(gid: gid) + if let download { + gallery = download.gallery + _ = DetailReducer().applyDownload(download, state: &self) + } + } +} + extension DetailReducer.Destination.State: Equatable {} diff --git a/AppPackage/Sources/DeviceClient/DeviceClient.swift b/AppPackage/Sources/DeviceClient/DeviceClient.swift index 44171eacd..76970950b 100644 --- a/AppPackage/Sources/DeviceClient/DeviceClient.swift +++ b/AppPackage/Sources/DeviceClient/DeviceClient.swift @@ -7,6 +7,18 @@ public struct DeviceClient: Sendable { public let absWindowW: @MainActor @Sendable () -> Double public let absWindowH: @MainActor @Sendable () -> Double public let touchPoint: @MainActor @Sendable () -> CGPoint? + + public init( + isPad: @escaping @Sendable () async -> Bool, + absWindowW: @escaping @MainActor @Sendable () -> Double, + absWindowH: @escaping @MainActor @Sendable () -> Double, + touchPoint: @escaping @MainActor @Sendable () -> CGPoint? + ) { + self.isPad = isPad + self.absWindowW = absWindowW + self.absWindowH = absWindowH + self.touchPoint = touchPoint + } } extension DeviceClient { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index c81537f78..d518e3c78 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -3,12 +3,17 @@ import AppModels import Resources import ComposableArchitecture import AppTools +import DeviceClient import DownloadClient import ReadingFeature import DetailFeature @Reducer public struct DownloadsReducer: Sendable { + public enum Delegate: Equatable, Sendable { + case presentGalleryDetail(String, DownloadedGallery?) + } + @Reducer public enum Destination { case inspector(DownloadInspectorReducer) @@ -59,7 +64,9 @@ public struct DownloadsReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) + case delegate(Delegate) case galleryTapped(String) + case pushGalleryDetail(String) case path(StackActionOf) case destination(PresentationAction) case inspectorButtonTapped(String) @@ -92,6 +99,7 @@ public struct DownloadsReducer: Sendable { } @Dependency(\.downloadClient) private var downloadClient + @Dependency(\.deviceClient) private var deviceClient public init() {} @@ -104,13 +112,26 @@ public struct DownloadsReducer: Sendable { return .none case .galleryTapped(let gid): - // Seed the detail with the locally downloaded gallery/badge so it renders offline. - var detailState = DetailReducer.State(gid: gid) - if let download = state.downloads.first(where: { $0.gid == gid }) { - detailState.gallery = download.gallery - _ = DetailReducer().applyDownload(download, state: &detailState) + // iPhone pushes the detail inline; iPad delegates up so it presents as a modal + // sheet hosted by AppRoute, matching the pre-StackState behavior. + let download = state.downloads.first(where: { $0.gid == gid }) + return .run { send in + if await deviceClient.isPad() { + await send(.delegate(.presentGalleryDetail(gid, download))) + } else { + await send(.pushGalleryDetail(gid)) + } } - state.path.append(.detail(detailState)) + + case .pushGalleryDetail(let gid): + // Seed the detail with the locally downloaded gallery/badge so it renders offline. + state.path.append(.detail(.init( + gid: gid, + seededFrom: state.downloads.first(where: { $0.gid == gid }) + ))) + return .none + + case .delegate: return .none case .inspectorButtonTapped(let gid): diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index de726e9ac..f4fa44ad5 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -7,12 +7,17 @@ import HapticsClient import DatabaseClient import NetworkingFeature import DownloadClient +import DeviceClient import DateSeekFeature import QuickSearchFeature import DetailFeature @Reducer public struct FavoritesReducer: Sendable { + public enum Delegate: Equatable, Sendable { + case presentGalleryDetail(String) + } + private enum CancelID { case observeDownloads } @@ -69,7 +74,9 @@ public struct FavoritesReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) case onAppear + case delegate(Delegate) case galleryTapped(String) + case pushGalleryDetail(String) case path(StackActionOf) case setFavoritesIndex(Int) case quickSearchButtonTapped @@ -87,6 +94,7 @@ public struct FavoritesReducer: Sendable { } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.deviceClient) private var deviceClient @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient @@ -104,9 +112,23 @@ public struct FavoritesReducer: Sendable { return .send(.observeDownloads) case .galleryTapped(let gid): + // iPhone pushes the detail inline; iPad delegates up so it presents as a modal + // sheet hosted by AppRoute, matching the pre-StackState behavior. + return .run { send in + if await deviceClient.isPad() { + await send(.delegate(.presentGalleryDetail(gid))) + } else { + await send(.pushGalleryDetail(gid)) + } + } + + case .pushGalleryDetail(let gid): state.path.append(.detail(.init(gid: gid))) return .none + case .delegate: + return .none + case let .path(.element(id: _, action: .comments(.delegate(.performedCommentAction(gid))))): guard let id = state.path.detailID(forGID: gid) else { return .none } return .send(.path(.element(id: id, action: .detail(.fetchGalleryDetail)))) diff --git a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift index f047ae933..c5a2d056e 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift @@ -24,10 +24,29 @@ extension HomeReducer { case .binding: return .none - case .galleryTapped(let gid): + case .galleryTapped(let gid), + let .path(.element(id: _, action: .frontpage(.delegate(.pushDetail(gid))))), + let .path(.element(id: _, action: .popular(.delegate(.pushDetail(gid))))), + let .path(.element(id: _, action: .toplists(.delegate(.pushDetail(gid))))), + let .path(.element(id: _, action: .watched(.delegate(.pushDetail(gid))))), + let .path(.element(id: _, action: .history(.delegate(.pushDetail(gid))))): + // iPhone pushes the detail inline; iPad delegates up so it presents as a modal + // sheet hosted by AppRoute, matching the pre-StackState behavior. + return .run { send in + if await deviceClient.isPad() { + await send(.delegate(.presentGalleryDetail(gid))) + } else { + await send(.pushGalleryDetail(gid)) + } + } + + case .pushGalleryDetail(let gid): state.path.append(.gallery(.detail(.init(gid: gid)))) return .none + case .delegate: + return .none + case .sectionTapped(let type): switch type { case .frontpage: @@ -48,14 +67,6 @@ extension HomeReducer { } return .none - case let .path(.element(id: _, action: .frontpage(.delegate(.pushDetail(gid))))), - let .path(.element(id: _, action: .popular(.delegate(.pushDetail(gid))))), - let .path(.element(id: _, action: .toplists(.delegate(.pushDetail(gid))))), - let .path(.element(id: _, action: .watched(.delegate(.pushDetail(gid))))), - let .path(.element(id: _, action: .history(.delegate(.pushDetail(gid))))): - state.path.append(.gallery(.detail(.init(gid: gid)))) - return .none - case let .path(.element(id: _, action: .gallery(.comments(.delegate(.performedCommentAction(gid)))))): guard let id = state.path.galleryDetailID(forGID: gid) else { return .none } return .send(.path(.element(id: id, action: .gallery(.detail(.fetchGalleryDetail))))) diff --git a/AppPackage/Sources/HomeFeature/HomeReducer.swift b/AppPackage/Sources/HomeFeature/HomeReducer.swift index 4fa81cef2..a9701a497 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer.swift @@ -5,9 +5,14 @@ import ComposableArchitecture import AppTools import LibraryClient import DatabaseClient +import DeviceClient @Reducer public struct HomeReducer: Sendable { + public enum Delegate: Equatable, Sendable { + case presentGalleryDetail(String) + } + @ObservableState public struct State: Equatable { public var path = StackState() @@ -51,7 +56,9 @@ public struct HomeReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) + case delegate(Delegate) case galleryTapped(String) + case pushGalleryDetail(String) case sectionTapped(HomeSectionType) case miscTapped(HomeMiscGridType) case path(StackActionOf) @@ -70,6 +77,7 @@ public struct HomeReducer: Sendable { } @Dependency(\.databaseClient) var databaseClient + @Dependency(\.deviceClient) var deviceClient @Dependency(\.libraryClient) var libraryClient public init() {} diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift index 95237617c..ed437e22d 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -3,12 +3,17 @@ import AppModels import AppTools import HapticsClient import DatabaseClient +import DeviceClient import FiltersFeature import QuickSearchFeature import DetailFeature @Reducer public struct SearchRootReducer: Sendable { + public enum Delegate: Equatable, Sendable { + case presentGalleryDetail(String) + } + @Reducer public enum Destination { case filters(FiltersReducer) @@ -60,8 +65,10 @@ public struct SearchRootReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) + case delegate(Delegate) case pushSearch case galleryTapped(String) + case pushGalleryDetail(String) case path(StackActionOf) case setKeyword(String) case filtersButtonTapped @@ -78,6 +85,7 @@ public struct SearchRootReducer: Sendable { } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.deviceClient) private var deviceClient @Dependency(\.hapticsClient) private var hapticsClient public init() {} @@ -102,12 +110,23 @@ public struct SearchRootReducer: Sendable { state.path.append(.search(.init(keyword: state.keyword))) return .none - case .galleryTapped(let gid): + case .galleryTapped(let gid), + let .path(.element(id: _, action: .search(.delegate(.pushDetail(gid))))): + // iPhone pushes the detail inline; iPad delegates up so it presents as a modal + // sheet hosted by AppRoute, matching the pre-StackState behavior. + return .run { send in + if await deviceClient.isPad() { + await send(.delegate(.presentGalleryDetail(gid))) + } else { + await send(.pushGalleryDetail(gid)) + } + } + + case .pushGalleryDetail(let gid): state.path.append(.gallery(.detail(.init(gid: gid)))) return .none - case let .path(.element(id: _, action: .search(.delegate(.pushDetail(gid))))): - state.path.append(.gallery(.detail(.init(gid: gid)))) + case .delegate: return .none case let .path(.element(id: _, action: .search(.delegate(.searchPerformed(keyword))))): diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift index 5a7b7cf29..80517d2fb 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import Testing +import DeviceClient import DownloadClient @testable import DownloadsFeature @testable import AppFeature @@ -31,10 +32,15 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { var initialState = DownloadsReducer.State() initialState.downloads = [download] - let store = TestStore(initialState: initialState, reducer: DownloadsReducer.init) + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { $0.deviceClient = .noop } + ) store.exhaustivity = .off await store.send(.galleryTapped(download.gid)) + await store.receive(\.pushGalleryDetail) #expect(store.state.path.count == 1) guard let element = store.state.path.first, case .detail(let detailState) = element else { @@ -47,6 +53,38 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { #expect(detailState.shouldCheckForRemoteUpdates == true) } + @MainActor + @Test + func testDownloadsReducerDelegatesModalDetailOnPad() async { + let download = sampleDownload( + gid: "123456", + title: "Completed Gallery", + status: .completed + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.deviceClient = DeviceClient( + isPad: { true }, + absWindowW: { .zero }, + absWindowH: { .zero }, + touchPoint: { nil } + ) + } + ) + store.exhaustivity = .off + + await store.send(.galleryTapped(download.gid)) + await store.receive(\.delegate) + + // The host must not push inline on iPad; AppReducer presents the modal instead. + #expect(store.state.path.isEmpty) + } + @MainActor @Test func testDownloadsReducerFolderFilterNarrowsDownloads() async { From 96d4ea7f732de29bdfadb4361fe7b42b873dd367 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 07:31:17 +0800 Subject: [PATCH 415/614] Drive progress HUDs from optional state --- .../AppFeature/DataFlow/AppRouteReducer.swift | 21 ++++----- .../AppFeature/View/TabBar/TabBarView.swift | 6 +-- .../Archives/ArchivesReducer.swift | 31 ++++--------- .../DetailFeature/Archives/ArchivesView.swift | 11 +---- .../Comments/CommentsReducer.swift | 22 ++++------ .../DetailFeature/Comments/CommentsView.swift | 6 +-- .../GalleryInfos/GalleryInfosReducer.swift | 10 +---- .../GalleryInfos/GalleryInfosView.swift | 6 +-- .../Torrents/TorrentsReducer.swift | 15 +------ .../DetailFeature/Torrents/TorrentsView.swift | 6 +-- .../DownloadInspectorReducer.swift | 11 +---- .../DownloadsView+Subviews.swift | 6 +-- .../ReadingFeature/ReadingReducer+Body.swift | 19 +++----- .../ReadingFeature/ReadingReducer.swift | 9 +--- .../Sources/ReadingFeature/ReadingView.swift | 6 +-- .../AccountSettingReducer.swift | 11 +---- .../AccountSetting/AccountSettingView.swift | 6 +-- .../TTProgressHUDExt/View+ProgressHUD.swift | 43 +++++++++++++------ .../DownloadInspectorLoadTests.swift | 6 +-- 19 files changed, 80 insertions(+), 171 deletions(-) diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 4cdbe812e..59dfab5a5 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -13,11 +13,6 @@ import DetailFeature @Reducer struct AppRouteReducer { - @CasePathable - enum Route: Equatable, Hashable { - case hud - } - @Reducer enum Destination { @ReducerCaseIgnored @@ -28,12 +23,11 @@ struct AppRouteReducer { @ObservableState struct State: Equatable { - var route: Route? + var hud: ProgressHUDConfigState? // The deep-link/clipboard gallery, presented modally as the root of its own gallery stack. @Presents var detail: DetailReducer.State? var path = StackState() @Presents var destination: Destination.State? - var hudConfig: ProgressHUDConfigState = .loading() init() {} } @@ -46,7 +40,7 @@ struct AppRouteReducer { case presentSetting case presentNewDawn(Greeting) case presentGalleryDetail(String, DownloadedGallery?) - case setHUDConfig(ProgressHUDConfigState) + case setHUD(ProgressHUDConfigState) case detectClipboardURL case handleDeepLink(URL) @@ -120,8 +114,8 @@ struct AppRouteReducer { state.detail = .init(gid: gid, seededFrom: download) return .none - case .setHUDConfig(let config): - state.hudConfig = config + case .setHUD(let config): + state.hud = config return .none case .detectClipboardURL: @@ -183,7 +177,7 @@ struct AppRouteReducer { } case .fetchGallery(let url, let isGalleryImageURL): - state.route = .hud + state.hud = .loading() return .run { send in let response = await GalleryReverseRequest( url: url, isGalleryImageURL: isGalleryImageURL @@ -193,7 +187,7 @@ struct AppRouteReducer { } case .fetchGalleryDone(let url, let result): - state.route = nil + state.hud = nil switch result { case .success(let gallery): return .run { send in @@ -201,9 +195,10 @@ struct AppRouteReducer { await send(.handleGalleryLink(url)) } case .failure: + // Let the loading HUD animate out before showing the error toast. return .run { send in try await Task.sleep(for: .milliseconds(500)) - await send(.setHUDConfig(.error())) + await send(.setHUD(.error())) } } diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index ed3d266a6..e4f85a648 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -126,11 +126,7 @@ struct TabBarView: View { .autoBlur(radius: store.appLockState.blurRadius) .environment(\.inSheet, true) } - .progressHUD( - config: store.appRouteState.hudConfig, - unwrapping: $store.appRouteState.route, - case: \.hud - ) + .progressHUD($store.appRouteState.hud) .onChange(of: scenePhase) { _, newValue in store.send(.onScenePhaseChange(newValue)) } .onOpenURL { store.send(.appRoute(.handleDeepLink($0))) } } diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index 20613ca56..bddd27010 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -11,31 +11,21 @@ import TTProgressHUDExt @Reducer public struct ArchivesReducer: Sendable { - @CasePathable - public enum Route: Sendable { - case messageHUD - case communicatingHUD - } - private enum CancelID: CaseIterable { case fetchArchive, fetchArchiveFunds, fetchDownloadResponse } @ObservableState public struct State: Equatable { - public var route: Route? + public var hud: ProgressHUDConfigState? public var selectedArchive: GalleryArchive.HathArchive? public var loadingState: LoadingState = .idle public var hathArchives = [GalleryArchive.HathArchive]() - - public var messageHUDConfig: ProgressHUDConfigState = .loading() - public var communicatingHUDConfig: ProgressHUDConfigState = .communicating } public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) case syncGalleryFunds(String, String) @@ -62,10 +52,6 @@ public struct ArchivesReducer: Sendable { case .binding: return .none - case .setNavigation(let route): - state.route = route - return .none - case .syncGalleryFunds(let galleryPoints, let credits): return .run { _ in await databaseClient.updateGalleryFunds(galleryPoints: galleryPoints, credits: credits) @@ -120,9 +106,9 @@ public struct ArchivesReducer: Sendable { case .fetchDownloadResponse(let archiveURL): guard let selectedArchive = state.selectedArchive, - state.route != .communicatingHUD + state.hud != .communicating else { return .none } - state.route = .communicatingHUD + state.hud = .communicating return .run {send in let response = await SendDownloadCommandRequest( archiveURL: archiveURL, @@ -134,26 +120,25 @@ public struct ArchivesReducer: Sendable { .cancellable(id: CancelID.fetchDownloadResponse) case .fetchDownloadResponseDone(let result): - state.route = .messageHUD let isSuccess: Bool switch result { case .success(let response): switch response { case L10n.Constant.Website.Response.hathClientNotFound: - state.messageHUDConfig = .error(caption: L10n.Localizable.Website.Response.hathClientNotFound) + state.hud = .error(caption: L10n.Localizable.Website.Response.hathClientNotFound) isSuccess = false case L10n.Constant.Website.Response.hathClientNotOnline: - state.messageHUDConfig = .error(caption: L10n.Localizable.Website.Response.hathClientNotOnline) + state.hud = .error(caption: L10n.Localizable.Website.Response.hathClientNotOnline) isSuccess = false case L10n.Constant.Website.Response.invalidResolution: - state.messageHUDConfig = .error(caption: L10n.Localizable.Website.Response.invalidResolution) + state.hud = .error(caption: L10n.Localizable.Website.Response.invalidResolution) isSuccess = false default: - state.messageHUDConfig = .success(caption: response) + state.hud = .success(caption: response) isSuccess = true } case .failure: - state.messageHUDConfig = .error() + state.hud = .error() isSuccess = false } return .run { _ in diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift index 90e313110..5ab831739 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift @@ -56,16 +56,7 @@ struct ArchivesView: View { } .opacity(error != nil && store.hathArchives.isEmpty ? 1 : 0) } - .progressHUD( - config: store.communicatingHUDConfig, - unwrapping: $store.route, - case: \.communicatingHUD - ) - .progressHUD( - config: store.messageHUDConfig, - unwrapping: $store.route, - case: \.messageHUD - ) + .progressHUD($store.hud) .animation(.default, value: store.hathArchives) .animation(.default, value: user.galleryPoints) .animation(.default, value: user.credits) diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index 2bd7118da..41b17ba22 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -11,11 +11,6 @@ import TTProgressHUDExt @Reducer public struct CommentsReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case hud - } - @Reducer public enum Destination { @ReducerCaseIgnored @@ -35,12 +30,10 @@ public struct CommentsReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? + public var hud: ProgressHUDConfigState? @Presents public var destination: Destination.State? public var commentContent = "" public var postCommentFocused = false - - public var hudConfig: ProgressHUDConfigState = .loading() public var scrollCommentID: String? public var scrollRowOpacity: Double = 1 @@ -71,7 +64,7 @@ public struct CommentsReducer: Sendable { case clearScrollCommentID case delegate(Delegate) - case setHUDConfig(ProgressHUDConfigState) + case setHUD(ProgressHUDConfigState) case setPostCommentFocused(Bool) case setScrollRowOpacity(Double) case setCommentContent(String) @@ -121,8 +114,8 @@ public struct CommentsReducer: Sendable { case .delegate: return .none - case .setHUDConfig(let config): - state.hudConfig = config + case .setHUD(let config): + state.hud = config return .none case .setPostCommentFocused(let isFocused): @@ -252,7 +245,7 @@ public struct CommentsReducer: Sendable { } case .fetchGallery(let url, let isGalleryImageURL): - state.route = .hud + state.hud = .loading() return .run { send in let response = await GalleryReverseRequest( url: url, isGalleryImageURL: isGalleryImageURL @@ -263,7 +256,7 @@ public struct CommentsReducer: Sendable { .cancellable(id: CancelID.fetchGallery) case .fetchGalleryDone(let url, let result): - state.route = nil + state.hud = nil switch result { case .success(let gallery): return .merge( @@ -271,9 +264,10 @@ public struct CommentsReducer: Sendable { .send(.handleGalleryLink(url)) ) case .failure: + // Let the loading HUD animate out before showing the error toast. return .run { send in try await Task.sleep(for: .milliseconds(500)) - await send(.setHUDConfig(.error())) + await send(.setHUD(.error())) } } } diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index 47e05e06b..d468ace9a 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -110,11 +110,7 @@ struct CommentsView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .progressHUD( - config: store.hudConfig, - unwrapping: $store.route, - case: \.hud - ) + .progressHUD($store.hud) .animation(.default, value: store.scrollRowOpacity) .onAppear { store.send(.onAppear) diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift index 68cd4fc38..14d537e9b 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift @@ -6,15 +6,9 @@ import TTProgressHUDExt @Reducer public struct GalleryInfosReducer: Sendable { - @CasePathable - public enum Route: Sendable { - case hud - } - @ObservableState public struct State: Equatable { - public var route: Route? - public var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded + public var hud: ProgressHUDConfigState? // Display data captured when this screen is pushed onto the host's gallery stack. public var gallery: Gallery = .empty public var galleryDetail: GalleryDetail = .empty @@ -44,7 +38,7 @@ public struct GalleryInfosReducer: Sendable { return .none case .copyText(let text): - state.route = .hud + state.hud = .copiedToClipboardSucceeded return .merge( .run(operation: { _ in clipboardClient.saveText(text) }), .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index d9f7028ef..0fd88401b 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -111,11 +111,7 @@ struct GalleryInfosView: View { } } } - .progressHUD( - config: store.hudConfig, - unwrapping: $store.route, - case: \.hud - ) + .progressHUD($store.hud) .navigationTitle(L10n.Localizable.GalleryInfosView.Title.galleryInfos) } } diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift index fcf7ddef4..1c35bd497 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift @@ -9,11 +9,6 @@ import TTProgressHUDExt @Reducer public struct TorrentsReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case hud - } - @Reducer public enum Destination { @ReducerCaseIgnored @@ -26,16 +21,14 @@ public struct TorrentsReducer: Sendable { @ObservableState public struct State: Equatable { - public var route: Route? + public var hud: ProgressHUDConfigState? @Presents public var destination: Destination.State? public var torrents = [GalleryTorrent]() public var loadingState: LoadingState = .idle - public var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded } public enum Action: BindableAction, Equatable { case binding(BindingAction) - case setNavigation(Route?) case destination(PresentationAction) case presentShare(URL) @@ -63,10 +56,6 @@ public struct TorrentsReducer: Sendable { case .binding: return .none - case .setNavigation(let route): - state.route = route - return .none - case .destination: return .none @@ -75,7 +64,7 @@ public struct TorrentsReducer: Sendable { return .none case .copyText(let magnetURL): - state.route = .hud + state.hud = .copiedToClipboardSucceeded return .merge( .run(operation: { _ in clipboardClient.saveText(magnetURL) }), .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift index aeae83ece..001f71b5d 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift @@ -47,11 +47,7 @@ struct TorrentsView: View { ActivityView(activityItems: [url.wrappedValue]) .autoBlur(radius: blurRadius) } - .progressHUD( - config: store.hudConfig, - unwrapping: $store.route, - case: \.hud - ) + .progressHUD($store.hud) .animation(.default, value: store.torrents) .onAppear { store.send(.fetchGalleryTorrents(gid, token)) diff --git a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift index 060bd021c..904f7177e 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift @@ -7,11 +7,6 @@ import TTProgressHUDExt @Reducer public struct DownloadInspectorReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case hud - } - private enum CancelID { case observeDownloads case loadInspection @@ -19,12 +14,11 @@ public struct DownloadInspectorReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - public var route: Route? + public var hud: ProgressHUDConfigState? public var gid = "" public var inspection: DownloadInspection? public var stableInspection: DownloadInspection? public var loadingState: LoadingState = .loading - public var hudConfig: ProgressHUDConfigState = .loading() public var inspectionRequestID = UUID() public var retryingPageIndices = Set() public var isValidatingImageData = false @@ -214,8 +208,7 @@ public struct DownloadInspectorReducer: Sendable { case .validateImageDataDone(let validation): state.isValidatingImageData = false - state.hudConfig = validation.hudConfig - state.route = .hud + state.hud = validation.hudConfig return .send(.loadInspection) } } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index 46b7bbd18..8cf808aa6 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -112,11 +112,7 @@ struct DownloadInspectorView: View { } } .autoBlur(radius: blurRadius) - .progressHUD( - config: store.hudConfig, - unwrapping: $store.route, - case: \.hud - ) + .progressHUD($store.hud) .navigationTitle(L10n.Localizable.DownloadsView.Inspector.Title.downloadStatus) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index 279f73bc2..73a3e1a75 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -54,10 +54,6 @@ extension ReadingReducer { case .binding: return .none - case .setNavigation(let route): - state.route = route - return .none - case .destination: return .none @@ -168,8 +164,8 @@ extension ReadingReducer { return .send(.fetchImage(.save, imageURL)) case .saveImageDone(let isSucceeded): - state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error() - return .send(.setNavigation(.hud)) + state.hud = isSucceeded ? .savedToPhotoLibrary : .error() + return .none case .shareImage(let imageURL): return .send(.fetchImage(.share, imageURL)) @@ -185,11 +181,8 @@ extension ReadingReducer { if case .success(let asset) = result { switch action { case .copy: - state.hudConfig = .copiedToClipboardSucceeded - return .merge( - .send(.setNavigation(.hud)), - .run(operation: { _ in _ = clipboardClient.saveImageData(asset.data) }) - ) + state.hud = .copiedToClipboardSucceeded + return .run(operation: { _ in _ = clipboardClient.saveImageData(asset.data) }) case .save: return .run { send in let success = await imageClient.saveImageDataToPhotoLibrary(asset.data) @@ -202,8 +195,8 @@ extension ReadingReducer { return .send(.presentShare(.init(value: shareItem))) } } else { - state.hudConfig = .error() - return .send(.setNavigation(.hud)) + state.hud = .error() + return .none } case .teardown: diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index e474ab9a7..26beb81e6 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -16,11 +16,6 @@ import TTProgressHUDExt @Reducer public struct ReadingReducer: Sendable { - @CasePathable - public enum Route: Equatable, Sendable { - case hud - } - @Reducer public enum Destination { @ReducerCaseIgnored @@ -48,7 +43,7 @@ public struct ReadingReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - public var route: Route? + public var hud: ProgressHUDConfigState? @Presents public var destination: Destination.State? public var contentSource: ReadingContentSource = .remote public var gallery: Gallery = .empty @@ -56,7 +51,6 @@ public struct ReadingReducer: Sendable { public var readingProgress: Int = .zero public var forceRefreshID: UUID = .init() - public var hudConfig: ProgressHUDConfigState = .loading() public var webImageLoadSuccessIndices = Set() public var imageURLLoadingStates = [Int: LoadingState]() @@ -142,7 +136,6 @@ public struct ReadingReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) case destination(PresentationAction) case presentShare(IdentifiableBox) case presentReadingSetting diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index f33c9233d..5c9ebb2fd 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -92,11 +92,7 @@ public struct ReadingView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .progressHUD( - config: store.hudConfig, - unwrapping: $store.route, - case: \.hud - ) + .progressHUD($store.hud) .animation(.linear(duration: 0.1), value: gestureHandler.offset) .animation(.default, value: liveTextHandler.enablesLiveText) diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index 630be6ce3..49c4a4402 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -9,12 +9,6 @@ import TTProgressHUDExt @Reducer public struct AccountSettingReducer: Sendable { - // Transient copied-to-clipboard toast. Not navigation — drives the progressHUD overlay only. - @CasePathable - public enum HUD: Equatable, Sendable { - case copiedToClipboard - } - @Reducer public enum Destination { @ReducerCaseIgnored @@ -35,10 +29,9 @@ public struct AccountSettingReducer: Sendable { public struct State: Equatable, Sendable { @Presents public var destination: Destination.State? @Presents public var confirmationDialog: ConfirmationDialogState? - public var hud: HUD? + public var hud: ProgressHUDConfigState? public var ehCookiesState: CookiesState = .empty(.ehentai) public var exCookiesState: CookiesState = .empty(.exhentai) - public var hudConfig: ProgressHUDConfigState = .copiedToClipboardSucceeded public init() {} } @@ -115,7 +108,7 @@ public struct AccountSettingReducer: Sendable { return .none case .copyCookies(let host): - state.hud = .copiedToClipboard + state.hud = .copiedToClipboardSucceeded let cookiesDescription = cookieClient.getCookiesDescription(host: host) return .merge( .run(operation: { _ in clipboardClient.saveText(cookiesDescription) }), diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index de2409b61..d8e5f3c1c 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -50,11 +50,7 @@ struct AccountSettingView: View { copyAction: { store.send(.copyCookies($0)) } ) } - .progressHUD( - config: store.hudConfig, - unwrapping: $store.hud, - case: \.copiedToClipboard - ) + .progressHUD($store.hud) .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) diff --git a/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift b/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift index b46fbf3ff..20af23e37 100644 --- a/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift +++ b/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift @@ -1,20 +1,39 @@ import SwiftUI import TTProgressHUD -import SwiftUINavigation -import SwiftUINavigationExt extension View { - public func progressHUD( - config: ProgressHUDConfigState, - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath - ) -> some View { + /// Overlays a progress HUD driven by optional state: a non-`nil` value shows the HUD with that + /// configuration and `nil` hides it. Auto-hiding HUDs write `nil` back through the binding. + public func progressHUD(_ config: Binding) -> some View { + modifier(ProgressHUDModifier(config: config)) + } +} + +private struct ProgressHUDModifier: ViewModifier { + @Binding var config: ProgressHUDConfigState? + // Keeps the last shown configuration alive so the HUD's hide transition doesn't fall back + // to a default look the moment the state is reset to `nil`. + @State private var lastConfig: ProgressHUDConfigState = .loading() + + func body(content: Content) -> some View { ZStack { - self - TTProgressHUD( - `enum`.case(caseKeyPath).isRemovedDuplicatesPresent(), - config: config.progressHUDConfig - ) + content + TTProgressHUD(isVisible, config: (config ?? lastConfig).progressHUDConfig) } + .onChange(of: config) { _, newValue in + if let newValue { + lastConfig = newValue + } + } + } + + private var isVisible: Binding { + .init( + get: { config != nil }, + set: { isPresented, transaction in + guard !isPresented, config != nil else { return } + $config.transaction(transaction).wrappedValue = nil + } + ) } } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift index de9776629..e27a9d60a 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift @@ -155,10 +155,9 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { } await store.receive(\.validateImageDataDone) { $0.isValidatingImageData = false - $0.hudConfig = .success( + $0.hud = .success( caption: L10n.Localizable.DownloadsView.Inspector.Hud.imageDataValid ) - $0.route = .hud } await store.receive(\.loadInspection) await store.receive(\.loadInspectionDone) { @@ -310,8 +309,7 @@ extension DownloadInspectorLoadTests { } await store.receive(\.validateImageDataDone) { $0.isValidatingImageData = false - $0.hudConfig = .error(caption: "Page 2 image data is corrupted.") - $0.route = .hud + $0.hud = .error(caption: "Page 2 image data is corrupted.") } await store.receive(\.loadInspection) await store.receive(\.loadInspectionDone) { From 88620c0ad5dabf9c733d678cb9893b97acbdb7ee Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 07:36:19 +0800 Subject: [PATCH 416/614] Delete SwiftUINavigationExt and direct dep --- AppPackage/Package.swift | 15 ---------- .../HapticsClient/Reducer+Haptics.swift | 10 +++---- .../SwiftUINavigationExt/.swiftlint.yml | 1 - .../SwiftUINavigation+.swift | 28 ------------------- 4 files changed, 5 insertions(+), 49 deletions(-) delete mode 100644 AppPackage/Sources/SwiftUINavigationExt/.swiftlint.yml delete mode 100644 AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 5421e266a..0253036a5 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -25,7 +25,6 @@ var dependencies: [PackageDescription.Package.Dependency] = [ url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.25.0" ), - .package(url: "https://github.com/pointfreeco/swift-navigation", from: "2.8.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.0.0"), .package(url: "https://github.com/tid-kijyun/Kanna", from: "6.0.0") ] @@ -48,7 +47,6 @@ extension PackageDescription.Target.Dependency { static let sdWebImageWebPCoder: Self = .product(name: "SDWebImageWebPCoder", package: "SDWebImageWebPCoder") static let sfSafeSymbols: Self = .product(name: "SFSafeSymbols", package: "SFSafeSymbols") static let sharing: Self = .product(name: "Sharing", package: "swift-sharing") - static let swiftUINavigation: Self = .product(name: "SwiftUINavigation", package: "swift-navigation") static let swiftUIPager: Self = .product(name: "SwiftUIPager", package: "SwiftUIPager") static let ttProgressHUD: Self = .product(name: "TTProgressHUD", package: "TTProgressHUD") static let uiImageColors: Self = .product(name: "UIImageColors", package: "UIImageColors") @@ -109,7 +107,6 @@ enum Module: String { case resources = "Resources" case searchFeature = "SearchFeature" case settingFeature = "SettingFeature" - case swiftUINavigationExt = "SwiftUINavigationExt" case ttProgressHUDExt = "TTProgressHUDExt" case tagTranslationFeature = "TagTranslationFeature" case urlClient = "URLClient" @@ -304,7 +301,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sdWebImageSwiftUI), .targetDependency(.sdWebImageWebPCoder), .targetDependency(.sfSafeSymbols), - .targetDependency(.swiftUINavigation), .targetDependency(.swiftUIPager), .targetDependency(.ttProgressHUD), .targetDependency(.uiImageColors), @@ -368,20 +364,10 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), - .target( - module: .swiftUINavigationExt, - dependencies: [ - .targetDependency(.casePaths) - ], - swiftSettings: sharedSwiftSettings, - plugins: swiftLintPlugins - ), .target( module: .ttProgressHUDExt, dependencies: [ .module(.resources), - .module(.swiftUINavigationExt), - .targetDependency(.swiftUINavigation), .targetDependency(.ttProgressHUD) ], swiftSettings: sharedSwiftSettings, @@ -491,7 +477,6 @@ let targets: [PackageDescription.Target] = [ module: .hapticsClient, dependencies: [ .module(.appTools), - .module(.swiftUINavigationExt), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, diff --git a/AppPackage/Sources/HapticsClient/Reducer+Haptics.swift b/AppPackage/Sources/HapticsClient/Reducer+Haptics.swift index cdaaf3197..c55b6c1c4 100644 --- a/AppPackage/Sources/HapticsClient/Reducer+Haptics.swift +++ b/AppPackage/Sources/HapticsClient/Reducer+Haptics.swift @@ -1,9 +1,8 @@ import SwiftUI import ComposableArchitecture -import SwiftUINavigationExt extension Reducer { - public func haptics( + public func haptics( unwrapping enum: @escaping (State) -> Enum?, case caseKeyPath: CaseKeyPath, hapticsClient: HapticsClient, @@ -14,15 +13,16 @@ extension Reducer { } } - private func onBecomeNonNil( + private func onBecomeNonNil( unwrapping enum: @escaping (State) -> Enum?, case caseKeyPath: CaseKeyPath, perform additionalEffects: @escaping (inout State, Action) -> Effect ) -> some Reducer { Reduce { state, action in - let previousCase = Binding.constant(`enum`(state)).case(caseKeyPath).wrappedValue + let casePath = AnyCasePath(caseKeyPath) + let previousCase = `enum`(state).flatMap(casePath.extract(from:)) let effects = _reduce(into: &state, action: action) - let currentCase = Binding.constant(`enum`(state)).case(caseKeyPath).wrappedValue + let currentCase = `enum`(state).flatMap(casePath.extract(from:)) return previousCase == nil && currentCase != nil ? .merge(effects, additionalEffects(&state, action)) diff --git a/AppPackage/Sources/SwiftUINavigationExt/.swiftlint.yml b/AppPackage/Sources/SwiftUINavigationExt/.swiftlint.yml deleted file mode 100644 index 1242ffcaa..000000000 --- a/AppPackage/Sources/SwiftUINavigationExt/.swiftlint.yml +++ /dev/null @@ -1 +0,0 @@ -parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift b/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift deleted file mode 100644 index eb928f2f8..000000000 --- a/AppPackage/Sources/SwiftUINavigationExt/SwiftUINavigation+.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SwiftUI -import CasePaths - -extension Binding { - public func `case`( - _ caseKeyPath: CaseKeyPath - ) -> Binding where Value == Enum? { - let casePath = AnyCasePath(caseKeyPath) - return .init( - get: { self.wrappedValue.flatMap(casePath.extract(from:)) }, - set: { newValue, transaction in - self.transaction(transaction).wrappedValue = newValue.map(casePath.embed) - } - ) - } - - public func isRemovedDuplicatesPresent() -> Binding where Value == Wrapped? { - .init( - get: { wrappedValue != nil }, - set: { isPresent, transaction in - guard self.transaction(transaction).wrappedValue != nil else { return } - if !isPresent { - self.transaction(transaction).wrappedValue = nil - } - } - ) - } -} From c8417a7239cf0c686261fdd74e45b520b88ea2cf Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 07:45:40 +0800 Subject: [PATCH 417/614] Replace AlertState with custom AppAlertState --- AppPackage/Package.swift | 1 + .../Sources/AppComponents/AppAlertCard.swift | 88 +++++++ .../Sources/AppComponents/AppAlertState.swift | 239 ++++++++++++++++++ .../DetailReducer+Download.swift | 5 +- .../Sources/DetailFeature/DetailReducer.swift | 3 +- .../Sources/DetailFeature/DetailView.swift | 2 +- .../DownloadsFeature/DownloadsReducer.swift | 5 +- .../DownloadsFeature/DownloadsView.swift | 2 +- .../Resources/de.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + .../Resources/ko.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant-HK.lproj/Localizable.strings | 1 + .../zh-Hant-TW.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + AppPackage/Sources/Resources/Strings.swift | 5 +- 17 files changed, 349 insertions(+), 9 deletions(-) create mode 100644 AppPackage/Sources/AppComponents/AppAlertCard.swift create mode 100644 AppPackage/Sources/AppComponents/AppAlertState.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 0253036a5..b18b62d51 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -490,6 +490,7 @@ let targets: [PackageDescription.Target] = [ .module(.parserFeature), .module(.resources), .module(.tagTranslationFeature), + .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols) ], diff --git a/AppPackage/Sources/AppComponents/AppAlertCard.swift b/AppPackage/Sources/AppComponents/AppAlertCard.swift new file mode 100644 index 000000000..5c1b4e1f2 --- /dev/null +++ b/AppPackage/Sources/AppComponents/AppAlertCard.swift @@ -0,0 +1,88 @@ +import SwiftUI + +/// The shared chrome behind every app alert: a dimmed backdrop, a centered scrollable glass card, +/// a header title, an optional message, an optional accessory slot (e.g. a toggle), and a +/// caller-provided actions row. The `appAlert(_:accessory:)` modifier (see ``AppAlertState``) +/// builds on it to present a state-driven alert from a reducer. +struct AppAlertCard: View { + private let title: Text + private let message: Text? + private let titleFocus: AccessibilityFocusState.Binding + private let onEscape: () -> Void + private let accessory: Accessory + private let actions: Actions + + @State private var availableHeight: CGFloat = 0 + + init( + title: Text, + message: Text?, + titleFocus: AccessibilityFocusState.Binding, + onEscape: @escaping () -> Void, + @ViewBuilder accessory: () -> Accessory = { EmptyView() }, + @ViewBuilder actions: () -> Actions + ) { + self.title = title + self.message = message + self.titleFocus = titleFocus + self.onEscape = onEscape + self.accessory = accessory() + self.actions = actions() + } + + var body: some View { + ZStack { + Color.black + .opacity(0.42) + .ignoresSafeArea() + .accessibilityHidden(true) + + // Center the card while still letting it scroll at the largest accessibility + // text sizes, where the content can otherwise exceed the screen height. + ScrollView { + card + .frame(maxWidth: 380) + .padding(.horizontal, 24) + .padding(.vertical, 24) + .frame(maxWidth: .infinity, minHeight: availableHeight) + } + .scrollBounceBehavior(.basedOnSize) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + availableHeight = height + } + } + // Custom overlays aren't real modals, so wire up the VoiceOver / Full Keyboard + // Access escape gesture to dismiss the notice the same way the primary action does. + .accessibilityAction(.escape) { + onEscape() + } + } + + private var card: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 8) { + title + .font(.title3.weight(.bold)) + .foregroundStyle(.primary) + .accessibilityAddTraits(.isHeader) + .accessibilityFocused(titleFocus) + + if let message { + message + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + accessory + + actions + } + .padding(20) + .glassEffect(.regular, in: .rect(cornerRadius: 24)) + .accessibilityElement(children: .contain) + } +} diff --git a/AppPackage/Sources/AppComponents/AppAlertState.swift b/AppPackage/Sources/AppComponents/AppAlertState.swift new file mode 100644 index 000000000..6cafbdd14 --- /dev/null +++ b/AppPackage/Sources/AppComponents/AppAlertState.swift @@ -0,0 +1,239 @@ +import SwiftUI +import Resources +import ComposableArchitecture + +/// Describes an app alert to present, modeled directly on TCA's `AlertState`: the dialog's content +/// lives in your feature's state, so presentation and the buttons' actions stay in the reducer and +/// remain testable. Its initializer mirrors `AlertState` exactly — a `title`, a `ButtonStateBuilder` +/// of `actions`, and an optional `message` — so migrating a call site is a pure rename. +/// +/// It reuses TCA's own `TextState` and `ButtonState` for content, which means runtime strings, +/// localized keys, and button roles (`.cancel`, `.destructive`) all work the same way they do with +/// `AlertState`. The only reason it isn't `AlertState` itself: this state is rendered by a consumer +/// view (``AppAlertCard``) rather than internally by the framework, so it must be `@ObservableState` +/// for the presentation store to expose its content without the deprecated `Store.withState`. +@ObservableState +public struct AppAlertState: Identifiable { + public let id: UUID + public var title: TextState + public var message: TextState? + public var buttons: [ButtonState] + + public init( + title: () -> TextState, + @ButtonStateBuilder actions: () -> [ButtonState] = { [] }, + message: (() -> TextState)? = nil + ) { + self.id = UUID() + self.title = title() + self.buttons = actions() + self.message = message?() + } +} + +extension AppAlertState: Equatable where Action: Equatable { + // Mirrors `AlertState`: identity is excluded so two states with equal content compare equal, + // which keeps reducer tests asserting on freshly-constructed states straightforward. + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.title == rhs.title + && lhs.message == rhs.message + && lhs.buttons == rhs.buttons + } +} + +extension AppAlertState: Hashable where Action: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(title) + hasher.combine(message) + hasher.combine(buttons) + } +} + +extension AppAlertState: Sendable where Action: Sendable {} + +// Marks the dialog as ephemeral, exactly as `AlertState`/`ConfirmationDialogState` do, so a plain +// `.ifLet(_:action:)` (no child reducer) drives it and it auto-dismisses when a button is tapped. +extension AppAlertState: _EphemeralState {} + +extension View { + /// Presents an ``AppAlertCard``-style glass card when presentation state held in a store becomes + /// non-`nil`, mirroring TCA's `.alert(_:)`. Drive it with a presented store scope: + /// + /// ```swift + /// .appAlert($store.scope(state: \.alert, action: \.alert)) + /// ``` + /// + /// Tapping a button sends that button's action through the presentation store and dismisses the + /// dialog automatically — ``AppAlertState`` is ephemeral, just like a system alert. + /// + /// Pass an `accessory` view builder for dialogs that need richer content than buttons — such as a + /// "Don't show again" toggle bound to your feature's state — rendered between the message and the + /// buttons: + /// + /// ```swift + /// .appAlert($store.scope(state: \.alert, action: \.alert)) { + /// Toggle("Don't show again", isOn: $store.suppress) + /// } + /// ``` + @MainActor + public func appAlert( + _ item: Binding, Action>?>, + @ViewBuilder accessory: @escaping () -> Accessory = { EmptyView() } + ) -> some View { + modifier(AppAlertModifier(item: item, accessory: accessory)) + } +} + +private struct AppAlertModifier: ViewModifier { + @Binding var item: Store, Action>? + let accessory: () -> Accessory + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @AccessibilityFocusState private var isTitleFocused: Bool + + func body(content: Content) -> some View { + ZStack { + content + .accessibilityHidden(item != nil) + + if let store = item { + AppAlertCard( + title: Text(store.title), + message: store.message.map(Text.init), + titleFocus: $isTitleFocused, + onEscape: dismiss, + accessory: accessory + ) { + actions(for: store) + } + .transition(reduceMotion ? .opacity : .scale(scale: 0.96).combined(with: .opacity)) + .zIndex(1) + } + } + .onChange(of: item != nil) { _, isPresented in + // Move VoiceOver focus onto the dialog when it appears; the background is hidden. + if isPresented { + isTitleFocused = true + } + } + .animation( + reduceMotion ? .easeInOut(duration: 0.12) : .smooth(duration: 0.2), + value: item != nil + ) + } + + @ViewBuilder + private func actions(for store: Store, Action>) -> some View { + let buttons = resolvedButtons(store.buttons) + let primaryID = buttons.last(where: { $0.role != .cancel })?.id + + if buttons.count > 2 { + VStack(spacing: 10) { + ForEach(buttons) { button in + alertButton(button, store: store, isProminent: button.id == primaryID, fillWidth: true) + } + } + } else { + HStack(spacing: 10) { + ForEach(buttons) { button in + let isProminent = button.id == primaryID + let fillWidth = !(buttons.count == 2 && isProminent) + alertButton(button, store: store, isProminent: isProminent, fillWidth: fillWidth) + } + } + } + } + + @ViewBuilder + private func alertButton( + _ button: ResolvedButton, + store: Store, Action>, + isProminent: Bool, + fillWidth: Bool + ) -> some View { + let action = Button(role: button.role.map(ButtonRole.init)) { + if let state = button.state { + state.withAction { sentAction in + if let sentAction { + store.send(sentAction) + } else { + dismiss() + } + } + } else { + dismiss() + } + } label: { + if fillWidth { + Text(button.label) + .frame(maxWidth: .infinity) + } else { + Text(button.label) + .frame(minWidth: 72) + } + } + + if isProminent { + // A prominent destructive button is tinted red by SwiftUI from its role; otherwise + // the button inherits the app's accent color from the environment. + action.buttonStyle(.borderedProminent) + } else { + action.buttonStyle(.bordered) + } + } + + private func dismiss() { + item = nil + } + + /// Resolves the buttons to render, reproducing SwiftUI's `.alert` semantics: with no buttons the + /// alert offers a single "OK", and an alert that declares no `.cancel`-role button gets one added + /// so it stays dismissable. A `nil` ``ResolvedButton/state`` is such a synthesized button — it + /// carries no action and only dismisses. + private func resolvedButtons(_ buttons: [ButtonState]) -> [ResolvedButton] { + var resolved = [ResolvedButton]() + + if buttons.isEmpty { + resolved.append( + ResolvedButton(id: 0, label: TextState(L10n.Localizable.Common.Button.ok), role: nil, state: nil) + ) + } else { + for (index, button) in buttons.enumerated() { + resolved.append( + ResolvedButton(id: index, label: button.label, role: button.role, state: button) + ) + } + if !buttons.contains(where: { $0.role == .cancel }) { + resolved.append( + ResolvedButton( + id: buttons.count, + label: TextState(L10n.Localizable.Common.Button.cancel), + role: .cancel, + state: nil + ) + ) + } + } + + return ordered(resolved) + } + + /// Moves the prominent (primary) button to the trailing position, matching the + /// bordered-leading / prominent-trailing layout the app uses elsewhere. + private func ordered(_ buttons: [ResolvedButton]) -> [ResolvedButton] { + guard let primaryIndex = buttons.lastIndex(where: { $0.role != .cancel }) else { + return buttons + } + var result = buttons + let primary = result.remove(at: primaryIndex) + result.append(primary) + return result + } + + private struct ResolvedButton: Identifiable { + let id: Int + let label: TextState + let role: ButtonStateRole? + // The originating button state, or `nil` for a synthesized OK / Cancel that only dismisses. + let state: ButtonState? + } +} diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift index c918c92b3..7dde022d8 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import Resources import ComposableArchitecture +import AppComponents import AppTools import ReadingFeature @@ -11,7 +12,7 @@ extension DetailReducer { Reduce { state, action in switch action { case .deleteDownloadButtonTapped: - state.alert = AlertState { + state.alert = AppAlertState { TextState(L10n.Localizable.DetailView.Dialog.Title.deleteDownload) } actions: { ButtonState(role: .destructive, action: .confirmDeleteDownload) { @@ -26,7 +27,7 @@ extension DetailReducer { return .none case .retryDownloadButtonTapped(let mode): - state.alert = AlertState { + state.alert = AppAlertState { TextState(Self.retryDownloadTitle(for: mode)) } actions: { ButtonState(action: .confirmRetryDownload(mode)) { diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index 7dd271d32..57ca4806d 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -3,6 +3,7 @@ import SwiftUI import AppModels import Foundation import ComposableArchitecture +import AppComponents import HapticsClient import DatabaseClient import NetworkingFeature @@ -79,7 +80,7 @@ public struct DetailReducer: Sendable { @ObservableState public struct State: Equatable { @Presents public var destination: Destination.State? - @Presents public var alert: AlertState? + @Presents public var alert: AppAlertState? public var commentContent = "" public var postCommentFocused = false public var showsNewDawnGreeting = false diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 65ae3a8d2..112afc70f 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -45,7 +45,7 @@ public struct DetailView: View { .onChange(of: store.hasLoadedDownloadBadge) { _, _ in runLaunchAutomationIfNeeded() } - .alert($store.scope(state: \.alert, action: \.alert)) + .appAlert($store.scope(state: \.alert, action: \.alert)) .toolbar(content: toolbar) } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index d518e3c78..92c6923da 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -2,6 +2,7 @@ import Foundation import AppModels import Resources import ComposableArchitecture +import AppComponents import AppTools import DeviceClient import DownloadClient @@ -38,7 +39,7 @@ public struct DownloadsReducer: Sendable { public struct State: Equatable { public var path = StackState() @Presents public var destination: Destination.State? - @Presents public var alert: AlertState? + @Presents public var alert: AppAlertState? @Presents public var confirmationDialog: ConfirmationDialogState? public var keyword = "" public var folderFilter: DownloadFolderFilter = .all @@ -143,7 +144,7 @@ public struct DownloadsReducer: Sendable { return .none case .deleteDownloadButtonTapped(let download): - state.alert = AlertState { + state.alert = AppAlertState { TextState(L10n.Localizable.DownloadsView.Dialog.Title.deleteDownload) } actions: { ButtonState(role: .destructive, action: .confirmDelete(download.gid)) { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index 8c40546f4..bfe837df5 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -101,7 +101,7 @@ public struct DownloadsView: View { .onAppear { store.send(.onAppear) } - .alert($store.scope(state: \.alert, action: \.alert)) + .appAlert($store.scope(state: \.alert, action: \.alert)) .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 62a176db9..14ccdc728 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -39,6 +39,7 @@ // MARK: Common button "common.button.cancel" = "Abbrechen"; +"common.button.ok" = "OK"; // MARK: TabItem "tab_item.title.home" = "Home"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 6b76cbbca..d47c931f3 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -39,6 +39,7 @@ // MARK: Common button "common.button.cancel" = "Cancel"; +"common.button.ok" = "OK"; // MARK: TabItem "tab_item.title.home" = "Home"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 963b1b762..eea8e3d51 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -39,6 +39,7 @@ // MARK: Common button "common.button.cancel" = "キャンセル"; +"common.button.ok" = "OK"; // MARK: TabItem "tab_item.title.home" = "ホーム"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index 0035ed864..c9f13b059 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -39,6 +39,7 @@ // MARK: Common button "common.button.cancel" = "취소"; +"common.button.ok" = "확인"; // MARK: TabItem "tab_item.title.home" = "Home"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 28db31c49..8b3cf15a3 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -39,6 +39,7 @@ // MARK: Common button "common.button.cancel" = "取消"; +"common.button.ok" = "好"; // MARK: TabItem "tab_item.title.home" = "主页"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings index 9fe624cb3..1962313af 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings @@ -39,6 +39,7 @@ // MARK: Common button "common.button.cancel" = "取消"; +"common.button.ok" = "好"; // MARK: TabItem "tab_item.title.home" = "總覽"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings index 812ff230f..dbf217643 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings @@ -39,6 +39,7 @@ // MARK: Common button "common.button.cancel" = "取消"; +"common.button.ok" = "好"; // MARK: TabItem "tab_item.title.home" = "總覽"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index c7142a10e..4acae8b34 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -39,6 +39,7 @@ // MARK: Common button "common.button.cancel" = "取消"; +"common.button.ok" = "好"; // MARK: TabItem "tab_item.title.home" = "總覽"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 1b49e3cf5..698ca408d 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -375,6 +375,8 @@ public enum L10n { public enum Button { /// Cancel public static let cancel = L10n.tr("Localizable", "common.button.cancel", fallback: "Cancel") + /// OK + public static let ok = L10n.tr("Localizable", "common.button.ok", fallback: "OK") } public enum Value { /// %@ day @@ -1045,8 +1047,7 @@ public enum L10n { } public enum BanInterval { public enum Description { - /// Localizable.strings - /// EhPanda + /// and public static let and = L10n.tr("Localizable", "enum.ban_interval.description.and", fallback: "and") } } From 8da2826191a53e9e5ab8e07700df05dba1bcde70 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 08:46:21 +0800 Subject: [PATCH 418/614] Render AppAlertState alerts with native alert --- .../Sources/AppComponents/AppAlertCard.swift | 88 ------ .../Sources/AppComponents/AppAlertState.swift | 278 +++++++----------- .../Resources/de.lproj/Localizable.strings | 1 - .../Resources/en.lproj/Localizable.strings | 1 - .../Resources/ja.lproj/Localizable.strings | 1 - .../Resources/ko.lproj/Localizable.strings | 1 - .../zh-Hans.lproj/Localizable.strings | 1 - .../zh-Hant-HK.lproj/Localizable.strings | 1 - .../zh-Hant-TW.lproj/Localizable.strings | 1 - .../zh-Hant.lproj/Localizable.strings | 1 - AppPackage/Sources/Resources/Strings.swift | 2 - 11 files changed, 107 insertions(+), 269 deletions(-) delete mode 100644 AppPackage/Sources/AppComponents/AppAlertCard.swift diff --git a/AppPackage/Sources/AppComponents/AppAlertCard.swift b/AppPackage/Sources/AppComponents/AppAlertCard.swift deleted file mode 100644 index 5c1b4e1f2..000000000 --- a/AppPackage/Sources/AppComponents/AppAlertCard.swift +++ /dev/null @@ -1,88 +0,0 @@ -import SwiftUI - -/// The shared chrome behind every app alert: a dimmed backdrop, a centered scrollable glass card, -/// a header title, an optional message, an optional accessory slot (e.g. a toggle), and a -/// caller-provided actions row. The `appAlert(_:accessory:)` modifier (see ``AppAlertState``) -/// builds on it to present a state-driven alert from a reducer. -struct AppAlertCard: View { - private let title: Text - private let message: Text? - private let titleFocus: AccessibilityFocusState.Binding - private let onEscape: () -> Void - private let accessory: Accessory - private let actions: Actions - - @State private var availableHeight: CGFloat = 0 - - init( - title: Text, - message: Text?, - titleFocus: AccessibilityFocusState.Binding, - onEscape: @escaping () -> Void, - @ViewBuilder accessory: () -> Accessory = { EmptyView() }, - @ViewBuilder actions: () -> Actions - ) { - self.title = title - self.message = message - self.titleFocus = titleFocus - self.onEscape = onEscape - self.accessory = accessory() - self.actions = actions() - } - - var body: some View { - ZStack { - Color.black - .opacity(0.42) - .ignoresSafeArea() - .accessibilityHidden(true) - - // Center the card while still letting it scroll at the largest accessibility - // text sizes, where the content can otherwise exceed the screen height. - ScrollView { - card - .frame(maxWidth: 380) - .padding(.horizontal, 24) - .padding(.vertical, 24) - .frame(maxWidth: .infinity, minHeight: availableHeight) - } - .scrollBounceBehavior(.basedOnSize) - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.height - } action: { height in - availableHeight = height - } - } - // Custom overlays aren't real modals, so wire up the VoiceOver / Full Keyboard - // Access escape gesture to dismiss the notice the same way the primary action does. - .accessibilityAction(.escape) { - onEscape() - } - } - - private var card: some View { - VStack(alignment: .leading, spacing: 18) { - VStack(alignment: .leading, spacing: 8) { - title - .font(.title3.weight(.bold)) - .foregroundStyle(.primary) - .accessibilityAddTraits(.isHeader) - .accessibilityFocused(titleFocus) - - if let message { - message - .font(.body) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - accessory - - actions - } - .padding(20) - .glassEffect(.regular, in: .rect(cornerRadius: 24)) - .accessibilityElement(children: .contain) - } -} diff --git a/AppPackage/Sources/AppComponents/AppAlertState.swift b/AppPackage/Sources/AppComponents/AppAlertState.swift index 6cafbdd14..ab5d1fabd 100644 --- a/AppPackage/Sources/AppComponents/AppAlertState.swift +++ b/AppPackage/Sources/AppComponents/AppAlertState.swift @@ -2,19 +2,34 @@ import SwiftUI import Resources import ComposableArchitecture -/// Describes an app alert to present, modeled directly on TCA's `AlertState`: the dialog's content -/// lives in your feature's state, so presentation and the buttons' actions stay in the reducer and -/// remain testable. Its initializer mirrors `AlertState` exactly — a `title`, a `ButtonStateBuilder` -/// of `actions`, and an optional `message` — so migrating a call site is a pure rename. +/// The app's single presentation-state type. It backs both button dialogs and progress HUDs, so a +/// feature models any transient presentation with one `@ObservableState` value: /// -/// It reuses TCA's own `TextState` and `ButtonState` for content, which means runtime strings, -/// localized keys, and button roles (`.cancel`, `.destructive`) all work the same way they do with -/// `AlertState`. The only reason it isn't `AlertState` itself: this state is rendered by a consumer -/// view (``AppAlertCard``) rather than internally by the framework, so it must be `@ObservableState` -/// for the presentation store to expose its content without the deprecated `Store.withState`. +/// - ``Style/alert`` renders through ``SwiftUICore/View/appAlert(_:)`` as a **native** system alert, +/// with `buttons` wired to your reducer's actions (mirroring `AlertState`'s ergonomics). +/// - ``Style/hud(icon:autoHide:)`` renders through `View.progressHUD(_:)` (in `TTProgressHUDExt`) as +/// a `TTProgressHUD` toast; the button-less HUD factories live on `AppAlertState`. +/// +/// Its alert initializer mirrors `AlertState` exactly — a `title`, a `ButtonStateBuilder` of +/// `actions`, and an optional `message` — so migrating an alert call site is a pure rename. It reuses +/// TCA's `TextState`/`ButtonState`, so localized keys and button roles behave identically. It isn't +/// `AlertState` itself because it is rendered by a consumer view rather than the framework, so it +/// must be `@ObservableState` for the presentation store to expose its content. @ObservableState public struct AppAlertState: Identifiable { + /// How a value is presented. See ``AppAlertState`` for which modifier renders each style. + public enum Style: Equatable, Hashable, Sendable { + case alert + case hud(icon: HUDIcon, autoHide: Bool) + } + + /// The glyph a ``Style/hud(icon:autoHide:)`` presentation shows; maps to a `TTProgressHUDType`. + public enum HUDIcon: Equatable, Hashable, Sendable { + case loading, success, error + } + public let id: UUID + public var style: Style public var title: TextState public var message: TextState? public var buttons: [ButtonState] @@ -25,17 +40,28 @@ public struct AppAlertState: Identifiable { message: (() -> TextState)? = nil ) { self.id = UUID() + self.style = .alert self.title = title() self.buttons = actions() self.message = message?() } + + // Builds a button-less HUD presentation; used by the `Action == Never` factories below. + init(style: Style, title: TextState, message: TextState? = nil) { + self.id = UUID() + self.style = style + self.title = title + self.message = message + self.buttons = [] + } } extension AppAlertState: Equatable where Action: Equatable { // Mirrors `AlertState`: identity is excluded so two states with equal content compare equal, // which keeps reducer tests asserting on freshly-constructed states straightforward. public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.title == rhs.title + lhs.style == rhs.style + && lhs.title == rhs.title && lhs.message == rhs.message && lhs.buttons == rhs.buttons } @@ -43,6 +69,7 @@ extension AppAlertState: Equatable where Action: Equatable { extension AppAlertState: Hashable where Action: Hashable { public func hash(into hasher: inout Hasher) { + hasher.combine(style) hasher.combine(title) hasher.combine(message) hasher.combine(buttons) @@ -55,185 +82,94 @@ extension AppAlertState: Sendable where Action: Sendable {} // `.ifLet(_:action:)` (no child reducer) drives it and it auto-dismisses when a button is tapped. extension AppAlertState: _EphemeralState {} +// MARK: - HUD presentations +// These mirror the old `ProgressHUDConfigState` cases one-for-one, so migrating a HUD assignment is a +// pure type change; `TTProgressHUDExt` maps `HUDIcon` + `title`/`message` onto a `TTProgressHUDConfig`. +extension AppAlertState where Action == Never { + public static func loading(title: String? = nil) -> Self { + .init( + style: .hud(icon: .loading, autoHide: false), + title: TextState(title ?? L10n.Localizable.Hud.Title.loading) + ) + } + public static var communicating: Self { + .init( + style: .hud(icon: .loading, autoHide: false), + title: TextState(L10n.Localizable.Hud.Title.communicating) + ) + } + public static func error(caption: String? = nil) -> Self { + .init( + style: .hud(icon: .error, autoHide: true), + title: TextState(L10n.Localizable.Hud.Title.error), + message: caption.map { TextState($0) } + ) + } + public static func success(caption: String? = nil) -> Self { + .init( + style: .hud(icon: .success, autoHide: true), + title: TextState(L10n.Localizable.Hud.Title.success), + message: caption.map { TextState($0) } + ) + } + public static var savedToPhotoLibrary: Self { + .success(caption: L10n.Localizable.Hud.Caption.savedToPhotoLibrary) + } + public static var copiedToClipboardSucceeded: Self { + .success(caption: L10n.Localizable.Hud.Caption.copiedToClipboard) + } +} + +// MARK: - Native alert presentation extension View { - /// Presents an ``AppAlertCard``-style glass card when presentation state held in a store becomes - /// non-`nil`, mirroring TCA's `.alert(_:)`. Drive it with a presented store scope: + /// Presents a **native** system alert when presentation state held in a store becomes non-`nil`, + /// mirroring TCA's `.alert(_:)`. Drive it with a presented store scope: /// /// ```swift /// .appAlert($store.scope(state: \.alert, action: \.alert)) /// ``` /// /// Tapping a button sends that button's action through the presentation store and dismisses the - /// dialog automatically — ``AppAlertState`` is ephemeral, just like a system alert. - /// - /// Pass an `accessory` view builder for dialogs that need richer content than buttons — such as a - /// "Don't show again" toggle bound to your feature's state — rendered between the message and the - /// buttons: - /// - /// ```swift - /// .appAlert($store.scope(state: \.alert, action: \.alert)) { - /// Toggle("Don't show again", isOn: $store.suppress) - /// } - /// ``` + /// alert automatically — ``AppAlertState`` is ephemeral, just like a system alert. With no + /// buttons, SwiftUI adds its own system-localized "OK". @MainActor - public func appAlert( - _ item: Binding, Action>?>, - @ViewBuilder accessory: @escaping () -> Accessory = { EmptyView() } + public func appAlert( + _ item: Binding, Action>?> ) -> some View { - modifier(AppAlertModifier(item: item, accessory: accessory)) + modifier(AppAlertViewModifier(item: item)) } } -private struct AppAlertModifier: ViewModifier { +private struct AppAlertViewModifier: ViewModifier { @Binding var item: Store, Action>? - let accessory: () -> Accessory - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @AccessibilityFocusState private var isTitleFocused: Bool func body(content: Content) -> some View { - ZStack { - content - .accessibilityHidden(item != nil) - - if let store = item { - AppAlertCard( - title: Text(store.title), - message: store.message.map(Text.init), - titleFocus: $isTitleFocused, - onEscape: dismiss, - accessory: accessory - ) { - actions(for: store) - } - .transition(reduceMotion ? .opacity : .scale(scale: 0.96).combined(with: .opacity)) - .zIndex(1) - } - } - .onChange(of: item != nil) { _, isPresented in - // Move VoiceOver focus onto the dialog when it appears; the background is hidden. - if isPresented { - isTitleFocused = true - } - } - .animation( - reduceMotion ? .easeInOut(duration: 0.12) : .smooth(duration: 0.2), - value: item != nil - ) - } - - @ViewBuilder - private func actions(for store: Store, Action>) -> some View { - let buttons = resolvedButtons(store.buttons) - let primaryID = buttons.last(where: { $0.role != .cancel })?.id - - if buttons.count > 2 { - VStack(spacing: 10) { - ForEach(buttons) { button in - alertButton(button, store: store, isProminent: button.id == primaryID, fillWidth: true) - } - } - } else { - HStack(spacing: 10) { - ForEach(buttons) { button in - let isProminent = button.id == primaryID - let fillWidth = !(buttons.count == 2 && isProminent) - alertButton(button, store: store, isProminent: isProminent, fillWidth: fillWidth) + content.alert( + item.map { Text($0.title) } ?? Text(verbatim: ""), + isPresented: Binding( + get: { item != nil }, + set: { isPresented, transaction in + guard !isPresented, item != nil else { return } + $item.transaction(transaction).wrappedValue = nil } - } - } - } - - @ViewBuilder - private func alertButton( - _ button: ResolvedButton, - store: Store, Action>, - isProminent: Bool, - fillWidth: Bool - ) -> some View { - let action = Button(role: button.role.map(ButtonRole.init)) { - if let state = button.state { - state.withAction { sentAction in - if let sentAction { - store.send(sentAction) - } else { - dismiss() + ), + presenting: item, + actions: { store in + ForEach(store.buttons) { button in + Button(role: button.role.map(ButtonRole.init)) { + button.withAction { action in + if let action { store.send(action) } + } + } label: { + Text(button.label) } } - } else { - dismiss() - } - } label: { - if fillWidth { - Text(button.label) - .frame(maxWidth: .infinity) - } else { - Text(button.label) - .frame(minWidth: 72) - } - } - - if isProminent { - // A prominent destructive button is tinted red by SwiftUI from its role; otherwise - // the button inherits the app's accent color from the environment. - action.buttonStyle(.borderedProminent) - } else { - action.buttonStyle(.bordered) - } - } - - private func dismiss() { - item = nil - } - - /// Resolves the buttons to render, reproducing SwiftUI's `.alert` semantics: with no buttons the - /// alert offers a single "OK", and an alert that declares no `.cancel`-role button gets one added - /// so it stays dismissable. A `nil` ``ResolvedButton/state`` is such a synthesized button — it - /// carries no action and only dismisses. - private func resolvedButtons(_ buttons: [ButtonState]) -> [ResolvedButton] { - var resolved = [ResolvedButton]() - - if buttons.isEmpty { - resolved.append( - ResolvedButton(id: 0, label: TextState(L10n.Localizable.Common.Button.ok), role: nil, state: nil) - ) - } else { - for (index, button) in buttons.enumerated() { - resolved.append( - ResolvedButton(id: index, label: button.label, role: button.role, state: button) - ) - } - if !buttons.contains(where: { $0.role == .cancel }) { - resolved.append( - ResolvedButton( - id: buttons.count, - label: TextState(L10n.Localizable.Common.Button.cancel), - role: .cancel, - state: nil - ) - ) + }, + message: { store in + if let message = store.message { + Text(message) + } } - } - - return ordered(resolved) - } - - /// Moves the prominent (primary) button to the trailing position, matching the - /// bordered-leading / prominent-trailing layout the app uses elsewhere. - private func ordered(_ buttons: [ResolvedButton]) -> [ResolvedButton] { - guard let primaryIndex = buttons.lastIndex(where: { $0.role != .cancel }) else { - return buttons - } - var result = buttons - let primary = result.remove(at: primaryIndex) - result.append(primary) - return result - } - - private struct ResolvedButton: Identifiable { - let id: Int - let label: TextState - let role: ButtonStateRole? - // The originating button state, or `nil` for a synthesized OK / Cancel that only dismisses. - let state: ButtonState? + ) } } diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 14ccdc728..62a176db9 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -39,7 +39,6 @@ // MARK: Common button "common.button.cancel" = "Abbrechen"; -"common.button.ok" = "OK"; // MARK: TabItem "tab_item.title.home" = "Home"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index d47c931f3..6b76cbbca 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -39,7 +39,6 @@ // MARK: Common button "common.button.cancel" = "Cancel"; -"common.button.ok" = "OK"; // MARK: TabItem "tab_item.title.home" = "Home"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index eea8e3d51..963b1b762 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -39,7 +39,6 @@ // MARK: Common button "common.button.cancel" = "キャンセル"; -"common.button.ok" = "OK"; // MARK: TabItem "tab_item.title.home" = "ホーム"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index c9f13b059..0035ed864 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -39,7 +39,6 @@ // MARK: Common button "common.button.cancel" = "취소"; -"common.button.ok" = "확인"; // MARK: TabItem "tab_item.title.home" = "Home"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 8b3cf15a3..28db31c49 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -39,7 +39,6 @@ // MARK: Common button "common.button.cancel" = "取消"; -"common.button.ok" = "好"; // MARK: TabItem "tab_item.title.home" = "主页"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings index 1962313af..9fe624cb3 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings @@ -39,7 +39,6 @@ // MARK: Common button "common.button.cancel" = "取消"; -"common.button.ok" = "好"; // MARK: TabItem "tab_item.title.home" = "總覽"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings index dbf217643..812ff230f 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings @@ -39,7 +39,6 @@ // MARK: Common button "common.button.cancel" = "取消"; -"common.button.ok" = "好"; // MARK: TabItem "tab_item.title.home" = "總覽"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 4acae8b34..c7142a10e 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -39,7 +39,6 @@ // MARK: Common button "common.button.cancel" = "取消"; -"common.button.ok" = "好"; // MARK: TabItem "tab_item.title.home" = "總覽"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 698ca408d..ac3367ed2 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -375,8 +375,6 @@ public enum L10n { public enum Button { /// Cancel public static let cancel = L10n.tr("Localizable", "common.button.cancel", fallback: "Cancel") - /// OK - public static let ok = L10n.tr("Localizable", "common.button.ok", fallback: "OK") } public enum Value { /// %@ day From 4c5095d1d288e71f0557240dafd0850edec8a770 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 08:56:07 +0800 Subject: [PATCH 419/614] Fold progress HUD state into AppAlertState --- AppPackage/Package.swift | 2 + .../AppFeature/DataFlow/AppRouteReducer.swift | 6 +- .../Archives/ArchivesReducer.swift | 4 +- .../Comments/CommentsReducer.swift | 6 +- .../GalleryInfos/GalleryInfosReducer.swift | 4 +- .../Torrents/TorrentsReducer.swift | 4 +- .../DownloadInspectorReducer.swift | 6 +- .../ReadingFeature/ReadingReducer+Body.swift | 2 +- .../ReadingFeature/ReadingReducer.swift | 4 +- .../AccountSettingReducer.swift | 4 +- .../TTProgressHUDExt/TTProgressHUD+.swift | 59 ------------------- .../TTProgressHUDExt/View+ProgressHUD.swift | 44 ++++++++++++-- 12 files changed, 60 insertions(+), 85 deletions(-) delete mode 100644 AppPackage/Sources/TTProgressHUDExt/TTProgressHUD+.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index b18b62d51..01910c14a 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -367,7 +367,9 @@ let targets: [PackageDescription.Target] = [ .target( module: .ttProgressHUDExt, dependencies: [ + .module(.appComponents), .module(.resources), + .targetDependency(.composableArchitecture), .targetDependency(.ttProgressHUD) ], swiftSettings: sharedSwiftSettings, diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 59dfab5a5..d00c1f272 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -8,7 +8,7 @@ import HapticsClient import DatabaseClient import NetworkingFeature import ClipboardClient -import TTProgressHUDExt +import AppComponents import DetailFeature @Reducer @@ -23,7 +23,7 @@ struct AppRouteReducer { @ObservableState struct State: Equatable { - var hud: ProgressHUDConfigState? + var hud: AppAlertState? // The deep-link/clipboard gallery, presented modally as the root of its own gallery stack. @Presents var detail: DetailReducer.State? var path = StackState() @@ -40,7 +40,7 @@ struct AppRouteReducer { case presentSetting case presentNewDawn(Greeting) case presentGalleryDetail(String, DownloadedGallery?) - case setHUD(ProgressHUDConfigState) + case setHUD(AppAlertState) case detectClipboardURL case handleDeepLink(URL) diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index bddd27010..30cdb19d3 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -7,7 +7,7 @@ import HapticsClient import DatabaseClient import NetworkingFeature import CookieClient -import TTProgressHUDExt +import AppComponents @Reducer public struct ArchivesReducer: Sendable { @@ -17,7 +17,7 @@ public struct ArchivesReducer: Sendable { @ObservableState public struct State: Equatable { - public var hud: ProgressHUDConfigState? + public var hud: AppAlertState? public var selectedArchive: GalleryArchive.HathArchive? public var loadingState: LoadingState = .idle diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index 41b17ba22..c3d6a4b0d 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -7,7 +7,7 @@ import HapticsClient import DatabaseClient import NetworkingFeature import CookieClient -import TTProgressHUDExt +import AppComponents @Reducer public struct CommentsReducer: Sendable { @@ -30,7 +30,7 @@ public struct CommentsReducer: Sendable { @ObservableState public struct State: Equatable { - public var hud: ProgressHUDConfigState? + public var hud: AppAlertState? @Presents public var destination: Destination.State? public var commentContent = "" public var postCommentFocused = false @@ -64,7 +64,7 @@ public struct CommentsReducer: Sendable { case clearScrollCommentID case delegate(Delegate) - case setHUD(ProgressHUDConfigState) + case setHUD(AppAlertState) case setPostCommentFocused(Bool) case setScrollRowOpacity(Double) case setCommentContent(String) diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift index 14d537e9b..f99e4685d 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift @@ -2,13 +2,13 @@ import AppModels import ComposableArchitecture import HapticsClient import ClipboardClient -import TTProgressHUDExt +import AppComponents @Reducer public struct GalleryInfosReducer: Sendable { @ObservableState public struct State: Equatable { - public var hud: ProgressHUDConfigState? + public var hud: AppAlertState? // Display data captured when this screen is pushed onto the host's gallery stack. public var gallery: Gallery = .empty public var galleryDetail: GalleryDetail = .empty diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift index 1c35bd497..7820e5060 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift @@ -5,7 +5,7 @@ import HapticsClient import NetworkingFeature import ClipboardClient import FileClient -import TTProgressHUDExt +import AppComponents @Reducer public struct TorrentsReducer: Sendable { @@ -21,7 +21,7 @@ public struct TorrentsReducer: Sendable { @ObservableState public struct State: Equatable { - public var hud: ProgressHUDConfigState? + public var hud: AppAlertState? @Presents public var destination: Destination.State? public var torrents = [GalleryTorrent]() public var loadingState: LoadingState = .idle diff --git a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift index 904f7177e..b87328b83 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import DownloadClient -import TTProgressHUDExt +import AppComponents @Reducer public struct DownloadInspectorReducer: Sendable { @@ -14,7 +14,7 @@ public struct DownloadInspectorReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - public var hud: ProgressHUDConfigState? + public var hud: AppAlertState? public var gid = "" public var inspection: DownloadInspection? public var stableInspection: DownloadInspection? @@ -216,7 +216,7 @@ public struct DownloadInspectorReducer: Sendable { } private extension Optional where Wrapped == DownloadValidationState { - var hudConfig: ProgressHUDConfigState { + var hudConfig: AppAlertState { switch self { case .some(.valid): return .success( diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index 73a3e1a75..587faa683 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -1,6 +1,6 @@ import SwiftUI import Kingfisher -import TTProgressHUD +import AppComponents import ComposableArchitecture import AppTools diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 26beb81e6..56343f8e1 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -12,7 +12,7 @@ import ClipboardClient import CookieClient import DeviceClient import AppDelegateClient -import TTProgressHUDExt +import AppComponents @Reducer public struct ReadingReducer: Sendable { @@ -43,7 +43,7 @@ public struct ReadingReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - public var hud: ProgressHUDConfigState? + public var hud: AppAlertState? @Presents public var destination: Destination.State? public var contentSource: ReadingContentSource = .remote public var gallery: Gallery = .empty diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index 49c4a4402..54c05d8e6 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -5,7 +5,7 @@ import ComposableArchitecture import HapticsClient import ClipboardClient import CookieClient -import TTProgressHUDExt +import AppComponents @Reducer public struct AccountSettingReducer: Sendable { @@ -29,7 +29,7 @@ public struct AccountSettingReducer: Sendable { public struct State: Equatable, Sendable { @Presents public var destination: Destination.State? @Presents public var confirmationDialog: ConfirmationDialogState? - public var hud: ProgressHUDConfigState? + public var hud: AppAlertState? public var ehCookiesState: CookiesState = .empty(.ehentai) public var exCookiesState: CookiesState = .empty(.exhentai) diff --git a/AppPackage/Sources/TTProgressHUDExt/TTProgressHUD+.swift b/AppPackage/Sources/TTProgressHUDExt/TTProgressHUD+.swift deleted file mode 100644 index e7483176b..000000000 --- a/AppPackage/Sources/TTProgressHUDExt/TTProgressHUD+.swift +++ /dev/null @@ -1,59 +0,0 @@ -import TTProgressHUD -import Resources - -public enum ProgressHUDConfigState: Equatable, Sendable { - case loading(title: String? = nil) - case communicating - case error(caption: String? = nil) - case success(caption: String? = nil) - case savedToPhotoLibrary - case copiedToClipboardSucceeded - - @MainActor - public var progressHUDConfig: TTProgressHUDConfig { - switch self { - case .loading(let title): - return .loading(title: title) - case .communicating: - return .loading(title: L10n.Localizable.Hud.Title.communicating) - case .error(let caption): - return .error(caption: caption) - case .success(let caption): - return .success(caption: caption) - case .savedToPhotoLibrary: - return .success(caption: L10n.Localizable.Hud.Caption.savedToPhotoLibrary) - case .copiedToClipboardSucceeded: - return .success(caption: L10n.Localizable.Hud.Caption.copiedToClipboard) - } - } -} - -extension TTProgressHUDConfig { - @MainActor - public static var error: Self { error(caption: nil) } - @MainActor - public static var loading: Self { loading(title: L10n.Localizable.Hud.Title.loading) } - @MainActor - public static var communicating: Self { loading(title: L10n.Localizable.Hud.Title.communicating) } - @MainActor - public static var savedToPhotoLibrary: Self { - success(caption: L10n.Localizable.Hud.Caption.savedToPhotoLibrary) - } - @MainActor - public static var copiedToClipboardSucceeded: Self { - success(caption: L10n.Localizable.Hud.Caption.copiedToClipboard) - } - - public static func loading(title: String? = nil) -> Self { - .init(type: .loading, title: title) - } - public static func error(caption: String? = nil) -> Self { - autoHide(type: .error, title: L10n.Localizable.Hud.Title.error, caption: caption) - } - public static func success(caption: String? = nil) -> Self { - autoHide(type: .success, title: L10n.Localizable.Hud.Title.success, caption: caption) - } - public static func autoHide(type: TTProgressHUDType, title: String? = nil, caption: String? = nil) -> Self { - .init(type: type, title: title, caption: caption, shouldAutoHide: true, autoHideInterval: 1) - } -} diff --git a/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift b/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift index 20af23e37..0c07ec6c6 100644 --- a/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift +++ b/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift @@ -1,24 +1,28 @@ import SwiftUI +import AppComponents +import ComposableArchitecture import TTProgressHUD extension View { - /// Overlays a progress HUD driven by optional state: a non-`nil` value shows the HUD with that - /// configuration and `nil` hides it. Auto-hiding HUDs write `nil` back through the binding. - public func progressHUD(_ config: Binding) -> some View { + /// Overlays a progress HUD driven by optional ``AppAlertState`` HUD state: a non-`nil` value shows + /// the HUD with that configuration and `nil` hides it. Auto-hiding HUDs write `nil` back through + /// the binding. Build the state with the `.hud`-style factories on `AppAlertState` + /// (`.loading()`, `.success(caption:)`, …). + public func progressHUD(_ config: Binding?>) -> some View { modifier(ProgressHUDModifier(config: config)) } } private struct ProgressHUDModifier: ViewModifier { - @Binding var config: ProgressHUDConfigState? + @Binding var config: AppAlertState? // Keeps the last shown configuration alive so the HUD's hide transition doesn't fall back // to a default look the moment the state is reset to `nil`. - @State private var lastConfig: ProgressHUDConfigState = .loading() + @State private var lastConfig: AppAlertState = .loading() func body(content: Content) -> some View { ZStack { content - TTProgressHUD(isVisible, config: (config ?? lastConfig).progressHUDConfig) + TTProgressHUD(isVisible, config: (config ?? lastConfig).ttProgressHUDConfig) } .onChange(of: config) { _, newValue in if let newValue { @@ -37,3 +41,31 @@ private struct ProgressHUDModifier: ViewModifier { ) } } + +private extension AppAlertState where Action == Never { + // Maps the unified HUD state onto the underlying TTProgressHUD library config. The `.alert` style + // never reaches a `progressHUD` binding, so it degrades to a plain loading spinner defensively. + var ttProgressHUDConfig: TTProgressHUDConfig { + let type: TTProgressHUDType + let autoHide: Bool + switch style { + case .alert: + type = .loading + autoHide = false + case let .hud(icon, shouldAutoHide): + autoHide = shouldAutoHide + switch icon { + case .loading: type = .loading + case .success: type = .success + case .error: type = .error + } + } + return .init( + type: type, + title: String(state: title), + caption: message.map { String(state: $0) }, + shouldAutoHide: autoHide, + autoHideInterval: 1 + ) + } +} From 3bb38a7da577d1e513e2ca55906beaab6a4d74e8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 09:04:23 +0800 Subject: [PATCH 420/614] Replace AlertKit page-jump with native alert --- AppPackage/Package.swift | 19 ----- AppPackage/Sources/AlertKitExt/.swiftlint.yml | 1 - .../Sources/AlertKitExt/AlertKit+.swift | 78 ------------------- .../Sources/AppComponents/AlertView.swift | 28 ------- .../FavoritesFeature/FavoritesView.swift | 1 - .../HomeFeature/Frontpage/FrontpageView.swift | 1 - .../Toplists/ToplistsReducer.swift | 12 --- .../HomeFeature/Toplists/ToplistsView.swift | 26 +++---- .../Resources/en.lproj/Constant.strings | 2 - AppPackage/Sources/Resources/Strings.swift | 4 - .../SettingFeature/Components/AboutView.swift | 4 - .../xcshareddata/swiftpm/Package.resolved | 11 +-- 12 files changed, 14 insertions(+), 173 deletions(-) delete mode 100644 AppPackage/Sources/AlertKitExt/.swiftlint.yml delete mode 100644 AppPackage/Sources/AlertKitExt/AlertKit+.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 01910c14a..2b6740c5d 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -6,7 +6,6 @@ import PackageDescription var dependencies: [PackageDescription.Package.Dependency] = [ // Pinned to match the app's resolved version; 1.1.x deprecates ColorfulView. .package(url: "https://github.com/Co2333/Colorful", .upToNextMinor(from: "1.0.1")), - .package(url: "https://github.com/EhPanda-Team/AlertKit", branch: "custom"), .package(url: "https://github.com/EhPanda-Team/DeprecatedAPI", branch: "main"), .package(url: "https://github.com/EhPanda-Team/TTProgressHUD", branch: "custom"), .package(url: "https://github.com/SDWebImage/SDWebImageSwiftUI", from: "3.0.0"), @@ -30,7 +29,6 @@ var dependencies: [PackageDescription.Package.Dependency] = [ ] extension PackageDescription.Target.Dependency { - static let alertKit: Self = .product(name: "AlertKit", package: "AlertKit") static let casePaths: Self = .product(name: "CasePaths", package: "swift-case-paths") static let colorful: Self = .product(name: "Colorful", package: "Colorful") static let commonMark: Self = .product(name: "CommonMark", package: "SwiftCommonMark") @@ -66,7 +64,6 @@ let sharedSwiftSettings: [PackageDescription.SwiftSetting] = [ // MARK: Module enum Module: String { - case alertKitExt = "AlertKitExt" case animatedImageFeature = "AnimatedImageFeature" case appComponents = "AppComponents" case appDelegateClient = "AppDelegateClient" @@ -289,7 +286,6 @@ let targets: [PackageDescription.Target] = [ .module(.ttProgressHUDExt), .module(.urlClient), .module(.userDefaultsClient), - .targetDependency(.alertKit), .targetDependency(.colorful), .targetDependency(.commonMark), .targetDependency(.composableArchitecture), @@ -522,18 +518,6 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), - .target( - module: .alertKitExt, - dependencies: [ - .module(.appComponents), - .module(.appModels), - .module(.appTools), - .module(.resources), - .targetDependency(.alertKit) - ], - swiftSettings: sharedSwiftSettings, - plugins: swiftLintPlugins - ), .target( module: .commonMarkExt, dependencies: [ @@ -678,7 +662,6 @@ let targets: [PackageDescription.Target] = [ .module(.quickSearchFeature), .module(.resources), .module(.tagTranslationFeature), - .targetDependency(.alertKit), .targetDependency(.composableArchitecture) ], swiftSettings: sharedSwiftSettings, @@ -744,7 +727,6 @@ let targets: [PackageDescription.Target] = [ .target( module: .homeFeature, dependencies: [ - .module(.alertKitExt), .module(.appComponents), .module(.appModels), .module(.appTools), @@ -761,7 +743,6 @@ let targets: [PackageDescription.Target] = [ .module(.quickSearchFeature), .module(.resources), .module(.tagTranslationFeature), - .targetDependency(.alertKit), .targetDependency(.colorful), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), diff --git a/AppPackage/Sources/AlertKitExt/.swiftlint.yml b/AppPackage/Sources/AlertKitExt/.swiftlint.yml deleted file mode 100644 index 1242ffcaa..000000000 --- a/AppPackage/Sources/AlertKitExt/.swiftlint.yml +++ /dev/null @@ -1 +0,0 @@ -parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AlertKitExt/AlertKit+.swift b/AppPackage/Sources/AlertKitExt/AlertKit+.swift deleted file mode 100644 index 540062234..000000000 --- a/AppPackage/Sources/AlertKitExt/AlertKit+.swift +++ /dev/null @@ -1,78 +0,0 @@ -import AppTools -import SwiftUI -import AppModels -import AppComponents -import Resources -import AlertKit - -extension View { - public func jumpPageAlert( - index: Binding, isPresented: Binding, isFocused: Binding, - pageNumber: PageNumber, jumpAction: @escaping () -> Void - ) -> some View { - JumpPageAlert( - content: self, index: index, isPresented: isPresented, - isFocused: isFocused, pageNumber: pageNumber, jumpAction: jumpAction - ) - } -} - -private struct JumpPageAlert: View { - @Environment(\.colorScheme) private var colorScheme - - private let content: Content - @Binding private var index: String - @Binding private var isPresented: Bool - @Binding private var isFocused: Bool - private let pageNumber: PageNumber - private let jumpAction: () -> Void - - @FocusState private var focused - @StateObject private var manager = CustomAlertManager() - - init( - content: Content, - index: Binding, - isPresented: Binding, - isFocused: Binding, - pageNumber: PageNumber, - jumpAction: @escaping () -> Void - ) { - self.content = content - _index = index - _isPresented = isPresented - _isFocused = isFocused - self.pageNumber = pageNumber - self.jumpAction = jumpAction - } - - private var widthFactor: Double { - Defaults.FrameSize.alertWidthFactor - } - private var backgroundOpacity: Double { - colorScheme == .light ? 0.2 : 0.5 - } - - var body: some View { - content.customAlert( - manager: manager, - widthFactor: widthFactor, - backgroundOpacity: backgroundOpacity, - content: { - PageJumpView( - inputText: $index, - isFocused: $focused, - pageNumber: pageNumber - ) - }, - buttons: [ - .regular( - content: { Text(L10n.Localizable.JumpPageView.Button.confirm) }, - action: jumpAction - ) - ] - ) - .synchronize($isFocused, $focused) - .synchronize($isPresented, $manager.isPresented) - } -} diff --git a/AppPackage/Sources/AppComponents/AlertView.swift b/AppPackage/Sources/AppComponents/AlertView.swift index 430f2091f..9a9a4b33b 100644 --- a/AppPackage/Sources/AppComponents/AlertView.swift +++ b/AppPackage/Sources/AppComponents/AlertView.swift @@ -127,31 +127,3 @@ public struct AlertViewButton: View { .buttonStyle(.glass) } } - -public struct PageJumpView: View { - @Environment(\.colorScheme) private var colorScheme - @Binding private var inputText: String - private var isFocused: FocusState.Binding - private let pageNumber: PageNumber - - public init(inputText: Binding, isFocused: FocusState.Binding, pageNumber: PageNumber) { - _inputText = inputText - self.isFocused = isFocused - self.pageNumber = pageNumber - } - - public var body: some View { - VStack { - Text(L10n.Localizable.JumpPageView.Title.jumpPage).bold() - HStack { - let opacity = colorScheme == .light ? 0.15 : 0.1 - TextField(inputText, text: $inputText).multilineTextAlignment(.center).keyboardType(.numberPad) - .padding(.horizontal, 10).padding(.vertical, 5).background(Color.gray.opacity(opacity)) - .cornerRadius(5).frame(width: 75).focused(isFocused.projectedValue) - Text("-") - Text("\(pageNumber.maximum + 1)") - } - .lineLimit(1) - } - } -} diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index dc50c969f..e727d6f9b 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import TagTranslationFeature import Resources -import AlertKit import ComposableArchitecture import AppTools import AppComponents diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 3c351b68b..997a50366 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -2,7 +2,6 @@ import SwiftUI import AppModels import TagTranslationFeature import Resources -import AlertKit import ComposableArchitecture import AppTools import AppComponents diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index 9e0ff49a4..0713ce073 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -19,7 +19,6 @@ public struct ToplistsReducer: Sendable { public struct State: Equatable { public var keyword = "" public var jumpPageIndex = "" - public var jumpPageAlertFocused = false public var jumpPageAlertPresented = false public var type: ToplistsType = .yesterday @@ -65,7 +64,6 @@ public struct ToplistsReducer: Sendable { case performJumpPage case presentJumpPageAlert - case setJumpPageAlertFocused(Bool) case teardown case fetchGalleries(Int? = nil) @@ -81,12 +79,6 @@ public struct ToplistsReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.jumpPageAlertPresented) { _, state in - if !state.jumpPageAlertPresented { - state.jumpPageAlertFocused = false - } - return .none - } Reduce { state, action in switch action { @@ -113,10 +105,6 @@ public struct ToplistsReducer: Sendable { state.jumpPageAlertPresented = true return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) - case .setJumpPageAlertFocused(let isFocused): - state.jumpPageAlertFocused = isFocused - return .none - case .teardown: return .merge(CancelID.allCases.map(Effect.cancel(id:))) diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index 1a57dacb0..109265c97 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -6,7 +6,6 @@ import ComposableArchitecture import AppTools import AppComponents import GalleryListComponents -import AlertKitExt struct ToplistsView: View { @Bindable private var store: StoreOf @@ -44,16 +43,20 @@ struct ToplistsView: View { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) - .jumpPageAlert( - index: $store.jumpPageIndex, - isPresented: $store.jumpPageAlertPresented, - isFocused: $store.jumpPageAlertFocused, - pageNumber: store.pageNumber ?? .init(), - jumpAction: { store.send(.performJumpPage) } - ) .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .navigationBarBackButtonHidden(store.jumpPageAlertPresented) - .animation(.default, value: store.jumpPageAlertPresented) + .alert( + L10n.Localizable.JumpPageView.Title.jumpPage, + isPresented: $store.jumpPageAlertPresented + ) { + TextField(L10n.Localizable.JumpPageView.Title.jumpPage, text: $store.jumpPageIndex) + .keyboardType(.numberPad) + Button(L10n.Localizable.JumpPageView.Button.confirm) { + store.send(.performJumpPage) + } + Button(L10n.Localizable.Common.Button.cancel, role: .cancel) {} + } message: { + Text(verbatim: "1 - \((store.pageNumber?.maximum ?? 0) + 1)") + } .onAppear { if store.galleries?.isEmpty != false { DispatchQueue.main.async { @@ -75,9 +78,6 @@ struct ToplistsView: View { if AppUtil.galleryHost == .ehentai { JumpPageButton(pageNumber: store.pageNumber ?? .init(), hideText: true) { store.send(.presentJumpPageAlert) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.send(.setJumpPageAlertFocused(true)) - } } } } diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings index 48a8e7393..e03e2962e 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings @@ -57,7 +57,6 @@ // Acknowledgement link "app.acknowledgement.link.kanna" = "https://github.com/tid-kijyun/Kanna"; "app.acknowledgement.link.swiftGen" = "https://github.com/SwiftGen/SwiftGen"; -"app.acknowledgement.link.alertKit" = "https://github.com/rebeloper/AlertKit"; "app.acknowledgement.link.colorful" = "https://github.com/Co2333/Colorful"; "app.acknowledgement.link.filePicker" = "https://github.com/markrenaud/FilePicker"; "app.acknowledgement.link.kingfisher" = "https://github.com/onevcat/Kingfisher"; @@ -75,7 +74,6 @@ // Acknowledgement text "app.acknowledgement.text.kanna" = "Kanna"; "app.acknowledgement.text.swiftGen" = "SwiftGen"; -"app.acknowledgement.text.alertKit" = "AlertKit"; "app.acknowledgement.text.colorful" = "Colorful"; "app.acknowledgement.text.filePicker" = "FilePicker"; "app.acknowledgement.text.kingfisher" = "Kingfisher"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index ac3367ed2..252967004 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -16,8 +16,6 @@ public enum L10n { public static let copyright = L10n.tr("Constant", "app.copyright", fallback: "Copyright © 2025 EhPanda Team") public enum Acknowledgement { public enum Link { - /// https://github.com/rebeloper/AlertKit - public static let alertKit = L10n.tr("Constant", "app.acknowledgement.link.alertKit", fallback: "https://github.com/rebeloper/AlertKit") /// https://github.com/Co2333/Colorful public static let colorful = L10n.tr("Constant", "app.acknowledgement.link.colorful", fallback: "https://github.com/Co2333/Colorful") /// https://github.com/EhTagTranslation/Database @@ -50,8 +48,6 @@ public enum L10n { public static let waterfallGrid = L10n.tr("Constant", "app.acknowledgement.link.waterfallGrid", fallback: "https://github.com/paololeonardi/WaterfallGrid") } public enum Text { - /// AlertKit - public static let alertKit = L10n.tr("Constant", "app.acknowledgement.text.alertKit", fallback: "AlertKit") /// Colorful public static let colorful = L10n.tr("Constant", "app.acknowledgement.text.colorful", fallback: "Colorful") /// EhTagTranslation/Database diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index bc49685de..a633aaa24 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -154,10 +154,6 @@ struct AboutView: View { urlString: L10n.Constant.App.Acknowledgement.Link.swiftGen, text: L10n.Constant.App.Acknowledgement.Text.swiftGen ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.alertKit, - text: L10n.Constant.App.Acknowledgement.Text.alertKit - ), .init( urlString: L10n.Constant.App.Acknowledgement.Link.colorful, text: L10n.Constant.App.Acknowledgement.Text.colorful diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d402ae987..1f3aaa8cc 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "64c9c6c904db8a7d0f68dc02141fd024d418f3a230457e3f33353921cb7ba45e", + "originHash" : "88f6a7be23b9d1a44065375d99bfe3ebda31826a9447b3348b6adaa656bcea09", "pins" : [ - { - "identity" : "alertkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/EhPanda-Team/AlertKit", - "state" : { - "branch" : "custom", - "revision" : "39b01c53ffadf3dab9871dd4c960cd81af5246b6" - } - }, { "identity" : "colorful", "kind" : "remoteSourceControl", From 06700b063626d984b300e5f755f8f0d570b902ed Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 09:31:24 +0800 Subject: [PATCH 421/614] Enrich page jump alert message --- AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift | 2 +- .../Resources/Resources/de.lproj/Localizable.strings | 1 + .../Resources/Resources/en.lproj/Localizable.strings | 1 + .../Resources/Resources/ja.lproj/Localizable.strings | 1 + .../Resources/Resources/ko.lproj/Localizable.strings | 1 + .../Resources/Resources/zh-Hans.lproj/Localizable.strings | 1 + .../Resources/zh-Hant-HK.lproj/Localizable.strings | 1 + .../Resources/zh-Hant-TW.lproj/Localizable.strings | 1 + .../Resources/Resources/zh-Hant.lproj/Localizable.strings | 1 + AppPackage/Sources/Resources/Strings.swift | 6 ++++++ 10 files changed, 15 insertions(+), 1 deletion(-) diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index 109265c97..c69f089a5 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -55,7 +55,7 @@ struct ToplistsView: View { } Button(L10n.Localizable.Common.Button.cancel, role: .cancel) {} } message: { - Text(verbatim: "1 - \((store.pageNumber?.maximum ?? 0) + 1)") + Text(L10n.Localizable.JumpPageView.Description.jumpPage((store.pageNumber?.maximum ?? 0) + 1)) } .onAppear { if store.galleries?.isEmpty != false { diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 62a176db9..d29e24bbe 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -61,6 +61,7 @@ "date_seek_view.button.seek_older" = "Ältere"; // MARK: JumpPage "jump_page_view.title.jump_page" = "Jump page"; +"jump_page_view.description.jump_page" = "Geben Sie eine Seitenzahl zwischen 1 und %d ein."; "jump_page_view.button.confirm" = "Confirm"; // MARK: AlertView diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 6b76cbbca..3adf815f8 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -61,6 +61,7 @@ "date_seek_view.button.seek_older" = "Older"; // MARK: JumpPage "jump_page_view.title.jump_page" = "Jump page"; +"jump_page_view.description.jump_page" = "Enter a page number between 1 and %d to jump to."; "jump_page_view.button.confirm" = "Confirm"; // MARK: AlertView diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 963b1b762..35aa61b83 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -61,6 +61,7 @@ "date_seek_view.button.seek_older" = "古い方"; // MARK: JumpPage "jump_page_view.title.jump_page" = "ページジャンプ"; +"jump_page_view.description.jump_page" = "1 ~ %d のページ番号を入力してください。"; "jump_page_view.button.confirm" = "確認"; // MARK: AlertView diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index 0035ed864..df32fa8c7 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -61,6 +61,7 @@ "date_seek_view.button.seek_older" = "오래된 쪽"; // MARK: JumpPage "jump_page_view.title.jump_page" = "페이지 이동"; +"jump_page_view.description.jump_page" = "1에서 %d 사이의 페이지 번호를 입력하세요."; "jump_page_view.button.confirm" = "확인"; // MARK: AlertView diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 28db31c49..082fa06d0 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -61,6 +61,7 @@ "date_seek_view.button.seek_older" = "较旧"; // MARK: JumpPage "jump_page_view.title.jump_page" = "页码跳转"; +"jump_page_view.description.jump_page" = "请输入 1 到 %d 之间的页码。"; "jump_page_view.button.confirm" = "确认"; // MARK: AlertView diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings index 9fe624cb3..936c41893 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings @@ -61,6 +61,7 @@ "date_seek_view.button.seek_older" = "較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; +"jump_page_view.description.jump_page" = "請輸入 1 到 %d 之間的頁碼。"; "jump_page_view.button.confirm" = "確定"; // MARK: AlertView diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings index 812ff230f..c7cca7736 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings @@ -61,6 +61,7 @@ "date_seek_view.button.seek_older" = "較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; +"jump_page_view.description.jump_page" = "請輸入 1 到 %d 之間的頁碼。"; "jump_page_view.button.confirm" = "確定"; // MARK: AlertView diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index c7142a10e..244bb2bd6 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -61,6 +61,7 @@ "date_seek_view.button.seek_older" = "較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; +"jump_page_view.description.jump_page" = "請輸入 1 到 %d 之間的頁碼。"; "jump_page_view.button.confirm" = "確定"; // MARK: AlertView diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 252967004..180b4b757 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -2270,6 +2270,12 @@ public enum L10n { /// Confirm public static let confirm = L10n.tr("Localizable", "jump_page_view.button.confirm", fallback: "Confirm") } + public enum Description { + /// Enter a page number between 1 and %d to jump to. + public static func jumpPage(_ p1: Int) -> String { + return L10n.tr("Localizable", "jump_page_view.description.jump_page", p1, fallback: "Enter a page number between 1 and %d to jump to.") + } + } public enum Title { /// Jump page public static let jumpPage = L10n.tr("Localizable", "jump_page_view.title.jump_page", fallback: "Jump page") From 5ed262f0b8aa91c934525935e078cb8967b4ff1f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 11:17:36 +0800 Subject: [PATCH 422/614] Present page jump via AppAlertState, autofocus --- .../Sources/AppComponents/AppAlertState.swift | 62 ++++++++++++++++++- .../Toplists/ToplistsReducer.swift | 38 ++++++++++-- .../HomeFeature/Toplists/ToplistsView.swift | 16 +---- 3 files changed, 97 insertions(+), 19 deletions(-) diff --git a/AppPackage/Sources/AppComponents/AppAlertState.swift b/AppPackage/Sources/AppComponents/AppAlertState.swift index ab5d1fabd..a4ef71b94 100644 --- a/AppPackage/Sources/AppComponents/AppAlertState.swift +++ b/AppPackage/Sources/AppComponents/AppAlertState.swift @@ -32,6 +32,7 @@ public struct AppAlertState: Identifiable { public var style: Style public var title: TextState public var message: TextState? + public var textField: AppAlertTextFieldState? public var buttons: [ButtonState] public init( @@ -46,6 +47,18 @@ public struct AppAlertState: Identifiable { self.message = message?() } + /// Builds an alert that includes a single text field. `textField` sits between `title` and + /// `actions` so the trailing `actions`/`message` closures read exactly like the plain alert init. + public init( + title: () -> TextState, + textField: AppAlertTextFieldState, + @ButtonStateBuilder actions: () -> [ButtonState] = { [] }, + message: (() -> TextState)? = nil + ) { + self.init(title: title, actions: actions, message: message) + self.textField = textField + } + // Builds a button-less HUD presentation; used by the `Action == Never` factories below. init(style: Style, title: TextState, message: TextState? = nil) { self.id = UUID() @@ -63,6 +76,7 @@ extension AppAlertState: Equatable where Action: Equatable { lhs.style == rhs.style && lhs.title == rhs.title && lhs.message == rhs.message + && lhs.textField == rhs.textField && lhs.buttons == rhs.buttons } } @@ -72,6 +86,7 @@ extension AppAlertState: Hashable where Action: Hashable { hasher.combine(style) hasher.combine(title) hasher.combine(message) + hasher.combine(textField) hasher.combine(buttons) } } @@ -82,6 +97,27 @@ extension AppAlertState: Sendable where Action: Sendable {} // `.ifLet(_:action:)` (no child reducer) drives it and it auto-dismisses when a button is tapped. extension AppAlertState: _EphemeralState {} +/// Describes a single text field shown inside an ``AppAlertState`` alert. The field's *value* is not +/// stored here: it binds to the host reducer's own state, supplied at the call site through +/// ``SwiftUICore/View/appAlert(_:text:)``. That keeps the alert ephemeral — keystrokes never have to +/// round-trip through a reducer — while still letting a feature declare the field declaratively. It's a +/// top-level type (not nested in ``AppAlertState``) because it's independent of the alert's `Action`. +public struct AppAlertTextFieldState: Equatable, Hashable, Sendable { + /// The kind of keyboard the field raises. Kept host-agnostic (no `UIKit` type) so the state stays + /// `Sendable`; ``SwiftUICore/View/appAlert(_:text:)`` maps it to a `UIKeyboardType`. + public enum Keyboard: Equatable, Hashable, Sendable { + case `default`, numberPad + } + + public var placeholder: TextState + public var keyboard: Keyboard + + public init(placeholder: TextState, keyboard: Keyboard = .default) { + self.placeholder = placeholder + self.keyboard = keyboard + } +} + // MARK: - HUD presentations // These mirror the old `ProgressHUDConfigState` cases one-for-one, so migrating a HUD assignment is a // pure type change; `TTProgressHUDExt` maps `HUDIcon` + `title`/`message` onto a `TTProgressHUDConfig`. @@ -136,12 +172,26 @@ extension View { public func appAlert( _ item: Binding, Action>?> ) -> some View { - modifier(AppAlertViewModifier(item: item)) + modifier(AppAlertViewModifier(item: item, text: nil)) + } + + /// Same as ``appAlert(_:)`` but also renders the alert's ``AppAlertState/TextFieldState`` (if any), + /// binding it to `text` and auto-focusing it so the keyboard is up the moment the alert appears — + /// native `.alert` no longer focuses its first field on its own. `text` lives in the host reducer's + /// state, e.g. `.appAlert($store.scope(state: \.alert, action: \.alert), text: $store.pageIndex)`. + @MainActor + public func appAlert( + _ item: Binding, Action>?>, + text: Binding + ) -> some View { + modifier(AppAlertViewModifier(item: item, text: text)) } } private struct AppAlertViewModifier: ViewModifier { @Binding var item: Store, Action>? + let text: Binding? + @FocusState private var isFieldFocused: Bool func body(content: Content) -> some View { content.alert( @@ -155,6 +205,16 @@ private struct AppAlertViewModifier: ViewModifier { ), presenting: item, actions: { store in + if let textField = store.textField, let text { + TextField(String(state: textField.placeholder), text: text) + .keyboardType(textField.keyboard == .numberPad ? .numberPad : .default) + .focused($isFieldFocused) + .onAppear { + // The field isn't in the responder chain the instant it appears, so a + // synchronous focus is dropped; hop to the next runloop to make it stick. + DispatchQueue.main.async { isFieldFocused = true } + } + } ForEach(store.buttons) { button in Button(role: button.role.map(ButtonRole.init)) { button.withAction { action in diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index 0713ce073..b7f5cdfb5 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -1,6 +1,8 @@ import ComposableArchitecture import AppModels import AppTools +import AppComponents +import Resources import HapticsClient import DatabaseClient import NetworkingFeature @@ -11,6 +13,10 @@ public struct ToplistsReducer: Sendable { case pushDetail(String) } + public enum Alert: Equatable, Sendable { + case performJumpPage + } + private enum CancelID: CaseIterable { case fetchGalleries, fetchMoreGalleries } @@ -19,7 +25,7 @@ public struct ToplistsReducer: Sendable { public struct State: Equatable { public var keyword = "" public var jumpPageIndex = "" - public var jumpPageAlertPresented = false + @Presents public var alert: AppAlertState? public var type: ToplistsType = .yesterday @@ -62,7 +68,7 @@ public struct ToplistsReducer: Sendable { case delegate(Delegate) case setToplistsType(ToplistsType) - case performJumpPage + case alert(PresentationAction) case presentJumpPageAlert case teardown @@ -93,7 +99,7 @@ public struct ToplistsReducer: Sendable { guard state.galleries?.isEmpty != false else { return .none } return .send(.fetchGalleries()) - case .performJumpPage: + case .alert(.presented(.performJumpPage)): guard let index = Int(state.jumpPageIndex), let pageNumber = state.pageNumber, index > 0, index <= pageNumber.maximum + 1 else { @@ -101,8 +107,31 @@ public struct ToplistsReducer: Sendable { } return .send(.fetchGalleries(index - 1)) + case .alert: + return .none + case .presentJumpPageAlert: - state.jumpPageAlertPresented = true + let maximumPage = (state.pageNumber?.maximum ?? 0) + 1 + state.alert = AppAlertState( + title: { + TextState(L10n.Localizable.JumpPageView.Title.jumpPage) + }, + textField: .init( + placeholder: TextState(L10n.Localizable.JumpPageView.Title.jumpPage), + keyboard: .numberPad + ), + actions: { + ButtonState(action: .performJumpPage) { + TextState(L10n.Localizable.JumpPageView.Button.confirm) + } + ButtonState(role: .cancel) { + TextState(L10n.Localizable.Common.Button.cancel) + } + }, + message: { + TextState(L10n.Localizable.JumpPageView.Description.jumpPage(maximumPage)) + } + ) return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) case .teardown: @@ -181,5 +210,6 @@ public struct ToplistsReducer: Sendable { return .none } } + .ifLet(\.$alert, action: \.alert) } } diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index c69f089a5..601c5e073 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -44,19 +44,7 @@ struct ToplistsView: View { } ) .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .alert( - L10n.Localizable.JumpPageView.Title.jumpPage, - isPresented: $store.jumpPageAlertPresented - ) { - TextField(L10n.Localizable.JumpPageView.Title.jumpPage, text: $store.jumpPageIndex) - .keyboardType(.numberPad) - Button(L10n.Localizable.JumpPageView.Button.confirm) { - store.send(.performJumpPage) - } - Button(L10n.Localizable.Common.Button.cancel, role: .cancel) {} - } message: { - Text(L10n.Localizable.JumpPageView.Description.jumpPage((store.pageNumber?.maximum ?? 0) + 1)) - } + .appAlert($store.scope(state: \.alert, action: \.alert), text: $store.jumpPageIndex) .onAppear { if store.galleries?.isEmpty != false { DispatchQueue.main.async { @@ -69,7 +57,7 @@ struct ToplistsView: View { } private func toolbar() -> some ToolbarContent { - CustomToolbarItem(disabled: store.jumpPageAlertPresented) { + CustomToolbarItem(disabled: store.alert != nil) { ToplistsTypeMenu(type: store.type) { type in if type != store.type { store.send(.setToplistsType(type)) From 40af5b7450bfd54807d905c148876203b54ecc9c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 18:15:03 +0800 Subject: [PATCH 423/614] Anchor confirm dialogs to trigger controls --- AGENTS.md | 2 ++ .../Sources/FiltersFeature/FiltersView.swift | 14 ++++++++----- .../HomeFeature/History/HistoryView.swift | 6 +++--- .../MigrationFeature/MigrationView.swift | 6 +++--- .../AccountSetting/AccountSettingView.swift | 12 ++++++++--- .../EhSetting/EhSettingView+Sections1.swift | 3 +++ .../EhSetting/EhSettingView.swift | 6 +++--- .../GeneralSettingReducer.swift | 21 ++++++++++++------- .../GeneralSetting/GeneralSettingView.swift | 9 +++++--- 9 files changed, 51 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb7e57a1f..2f50b8fea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,8 @@ This file gives coding agents a reliable working guide for this repository. **Read SwiftLint rules**: Before writing or changing Swift code, read the root `.swiftlint.yml` to learn the project's lint rules, including the custom regex rules and banned APIs it defines. Write code that conforms to those rules from the start, and resolve every violation at its root. Suppressing a rule, disabling it, adding a `// swiftlint:disable`, or otherwise removing it, is forbidden without the user's explicit permission. +**Confirmation dialog / alert placement**: Attach a `.confirmationDialog`/`.alert` modifier to a UI element that is both **stable** (stays in the hierarchy until the dialog is dismissed — being `.disabled` is fine, being removed or `.opacity`-hidden is not) and the **action source** (the control that triggers it). On iPad these render as popovers anchored to the view the modifier is attached to, so the anchor must be the triggering control for the arrow to point at the right place; and if that view leaves the hierarchy while the dialog is up, the dialog is torn down with it. Do not move such a modifier onto a transient or unrelated container (a whole `Form`/`List`, or a view gated by a condition) for convenience — keep it on the triggering button/row. When the trigger lives inside a subview, thread the store-scoped dialog binding into that subview and attach it there rather than hoisting the modifier to an ancestor. Exception: for a per-row destructive action whose row can scroll out of view, the stable action-source is the enclosing list container, so attach it there. + ## Project structure EhPanda is being modularized to match the App-shell + local-package layout: diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index 4b8eeee8f..d0bba651c 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -30,7 +30,8 @@ public struct FiltersView: View { Form { BasicSection( filter: filter, filterRange: $store.filterRange, - resetFiltersDialogAction: { store.send(.resetFiltersButtonTapped) } + resetFiltersDialogAction: { store.send(.resetFiltersButtonTapped) }, + confirmationDialog: $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) AdvancedSection( filter: filter, focusedBound: $focusedBound, @@ -38,9 +39,6 @@ public struct FiltersView: View { ) } .synchronize($store.focusedBound, $focusedBound) - .confirmationDialog( - $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) - ) .navigationTitle(L10n.Localizable.FiltersView.Title.filters) .onAppear { store.send(.fetchFilters) } } @@ -52,6 +50,8 @@ private struct BasicSection: View { @Binding private var filter: Filter @Binding private var filterRange: FilterRange private let resetFiltersDialogAction: () -> Void + private let confirmationDialog: + Binding, FiltersReducer.Dialog>?> private var categoryBindings: [Binding] { [ $filter.doujinshi, $filter.manga, $filter.artistCG, $filter.gameCG, $filter.western, $filter.nonH, $filter.imageSet, $filter.cosplay, $filter.asianPorn, $filter.misc @@ -59,11 +59,14 @@ private struct BasicSection: View { init( filter: Binding, filterRange: Binding, - resetFiltersDialogAction: @escaping () -> Void + resetFiltersDialogAction: @escaping () -> Void, + confirmationDialog: + Binding, FiltersReducer.Dialog>?> ) { _filter = filter _filterRange = filterRange self.resetFiltersDialogAction = resetFiltersDialogAction + self.confirmationDialog = confirmationDialog } var body: some View { @@ -78,6 +81,7 @@ private struct BasicSection: View { Button(action: resetFiltersDialogAction) { Text(L10n.Localizable.FiltersView.Button.resetFilters).foregroundStyle(.red) } + .confirmationDialog(confirmationDialog) Toggle(L10n.Localizable.FiltersView.Title.advancedSettings, isOn: $filter.advanced) } } diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index d6257dbff..57e0a4097 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -49,9 +49,6 @@ struct HistoryView: View { } } .toolbar(content: toolbar) - .confirmationDialog( - $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) - ) .navigationTitle(L10n.Localizable.HistoryView.Title.history) } @@ -63,6 +60,9 @@ struct HistoryView: View { Image(systemSymbol: .trashCircle) } .disabled(store.loadingState != .idle || store.galleries.isEmpty) + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) } } } diff --git a/AppPackage/Sources/MigrationFeature/MigrationView.swift b/AppPackage/Sources/MigrationFeature/MigrationView.swift index 785161bee..7984c0301 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationView.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationView.swift @@ -28,11 +28,11 @@ public struct MigrationView: View { AlertViewButton(title: L10n.Localizable.ErrorView.Button.dropDatabase) { store.send(.dropDatabaseButtonTapped) } + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) } .opacity(error != nil ? 1 : 0) - .confirmationDialog( - $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) - ) } .animation(.default, value: store.databaseState) } diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index d8e5f3c1c..38ba8dc1c 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -40,6 +40,9 @@ struct AccountSettingView: View { bypassesSNIFiltering: bypassesSNIFiltering, loginAction: { store.send(.delegate(.pushLogin)) }, logoutDialogAction: { store.send(.logoutButtonTapped) }, + logoutConfirmationDialog: $store.scope( + state: \.confirmationDialog, action: \.confirmationDialog + ), configureAccountAction: { store.send(.delegate(.pushEhSetting)) }, manageTagsAction: { store.send(.presentWebView(Defaults.URL.myTags)) } ) @@ -51,9 +54,6 @@ struct AccountSettingView: View { ) } .progressHUD($store.hud) - .confirmationDialog( - $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) - ) .sheet(item: $store.destination.webView, id: \.absoluteString) { url in WebView(url: url.wrappedValue) .ignoresSafeArea(edges: .bottom) @@ -70,6 +70,8 @@ private struct AccountSection: View { private let bypassesSNIFiltering: Bool private let loginAction: () -> Void private let logoutDialogAction: () -> Void + private let logoutConfirmationDialog: + Binding, AccountSettingReducer.Dialog>?> private let configureAccountAction: () -> Void private let manageTagsAction: () -> Void @@ -77,6 +79,8 @@ private struct AccountSection: View { showsNewDawnGreeting: Binding, bypassesSNIFiltering: Bool, loginAction: @escaping () -> Void, logoutDialogAction: @escaping () -> Void, + logoutConfirmationDialog: + Binding, AccountSettingReducer.Dialog>?>, configureAccountAction: @escaping () -> Void, manageTagsAction: @escaping () -> Void ) { @@ -84,6 +88,7 @@ private struct AccountSection: View { self.bypassesSNIFiltering = bypassesSNIFiltering self.loginAction = loginAction self.logoutDialogAction = logoutDialogAction + self.logoutConfirmationDialog = logoutConfirmationDialog self.configureAccountAction = configureAccountAction self.manageTagsAction = manageTagsAction } @@ -96,6 +101,7 @@ private struct AccountSection: View { L10n.Localizable.ConfirmationDialog.Button.logout, role: .destructive, action: logoutDialogAction ) + .confirmationDialog(logoutConfirmationDialog) Group { Button( L10n.Localizable.AccountSettingView.Button.accountConfiguration, diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift index 9b6b969b0..0f74753f3 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -13,6 +13,8 @@ struct EhProfileSection: View { @Binding var ehProfile: EhProfile @Binding var editingProfileName: String let deleteDialogAction: () -> Void + let deleteConfirmationDialog: + Binding, EhSettingReducer.Dialog>?> let performEhProfileAction: (EhProfileAction?, String?, Int) -> Void @FocusState private var isFocused @@ -37,6 +39,7 @@ struct EhProfileSection: View { role: .destructive, action: deleteDialogAction ) + .confirmationDialog(deleteConfirmationDialog) } } header: { Text(L10n.Localizable.EhSettingView.Section.Title.profileSettings) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift index eba1a5461..d43a9b7f5 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift @@ -52,9 +52,6 @@ struct EhSettingView: View { .ignoresSafeArea(edges: .bottom) .autoBlur(radius: blurRadius) } - .confirmationDialog( - $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) - ) .toolbar(content: toolbar) .navigationTitle(L10n.Localizable.EhSettingView.Title.hostSettings(galleryHost.rawValue)) } @@ -67,6 +64,9 @@ struct EhSettingView: View { ehProfile: ehProfile, editingProfileName: $store.editingProfileName, deleteDialogAction: { store.send(.deleteProfileButtonTapped) }, + deleteConfirmationDialog: $store.scope( + state: \.confirmationDialog, action: \.confirmationDialog + ), performEhProfileAction: { store.send(.performAction(action: $0, name: $1, set: $2)) } ) diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index 6eadb73a9..cef8c6902 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -24,7 +24,10 @@ public struct GeneralSettingReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - @Presents public var confirmationDialog: ConfirmationDialogState? + // Two separate dialogs so each anchors to its own trigger button on iPad (the clear-cache + // and remove-translations buttons live in different sections). + @Presents public var clearCacheDialog: ConfirmationDialogState? + @Presents public var removeTranslationsDialog: ConfirmationDialogState? public var loadingState: LoadingState = .idle public var diskImageCacheSize = "0 KB" @@ -35,7 +38,8 @@ public struct GeneralSettingReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) - case confirmationDialog(PresentationAction) + case clearCacheDialog(PresentationAction) + case removeTranslationsDialog(PresentationAction) case delegate(Delegate) case onTranslationsFilePicked(URL) case removeCustomTranslationsButtonTapped @@ -68,7 +72,7 @@ public struct GeneralSettingReducer: Sendable { return .none case .removeCustomTranslationsButtonTapped: - state.confirmationDialog = ConfirmationDialogState { + state.removeTranslationsDialog = ConfirmationDialogState { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmRemoveCustomTranslations) { @@ -83,7 +87,7 @@ public struct GeneralSettingReducer: Sendable { return .none case .clearImageCachesButtonTapped: - state.confirmationDialog = ConfirmationDialogState { + state.clearCacheDialog = ConfirmationDialogState { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmClearCache) { @@ -97,13 +101,13 @@ public struct GeneralSettingReducer: Sendable { } return .none - case .confirmationDialog(.presented(.confirmRemoveCustomTranslations)): + case .removeTranslationsDialog(.presented(.confirmRemoveCustomTranslations)): return .send(.onRemoveCustomTranslations) - case .confirmationDialog(.presented(.confirmClearCache)): + case .clearCacheDialog(.presented(.confirmClearCache)): return .send(.clearWebImageCache) - case .confirmationDialog: + case .removeTranslationsDialog, .clearCacheDialog: return .none case .onTranslationsFilePicked: @@ -144,6 +148,7 @@ public struct GeneralSettingReducer: Sendable { return .none } } - .ifLet(\.$confirmationDialog, action: \.confirmationDialog) + .ifLet(\.$clearCacheDialog, action: \.clearCacheDialog) + .ifLet(\.$removeTranslationsDialog, action: \.removeTranslationsDialog) } } diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index b28a4ebe8..fa40a8e09 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -105,6 +105,9 @@ struct GeneralSettingView: View { L10n.Localizable.GeneralSettingView.Button.removeCustomTranslations, role: .destructive, action: { store.send(.removeCustomTranslationsButtonTapped) } ) + .confirmationDialog( + $store.scope(state: \.removeTranslationsDialog, action: \.removeTranslationsDialog) + ) } } Section(L10n.Localizable.GeneralSettingView.Section.Title.navigation) { @@ -152,11 +155,11 @@ struct GeneralSettingView: View { } .foregroundColor(.primary) } + .confirmationDialog( + $store.scope(state: \.clearCacheDialog, action: \.clearCacheDialog) + ) } } - .confirmationDialog( - $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) - ) .animation(.default, value: tagTranslatorHasCustomTranslations) .animation(.default, value: tagTranslatorLoadingState) .animation(.default, value: enablesTagsExtension) From 4bf4201c046621eec9b26cfaaf0567f5dbc9ccf9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 20:29:33 +0800 Subject: [PATCH 424/614] Reset compose state when presenting comment sheet --- AppPackage/Package.swift | 12 +++++ .../Comments/CommentsReducer.swift | 14 +++--- .../DetailFeature/Comments/CommentsView.swift | 7 +-- .../DetailFeature/DetailReducer+Actions.swift | 12 ++--- .../Tests/DetailFeatureTests/.swiftlint.yml | 1 + .../CommentsReducerTests.swift | 46 +++++++++++++++++++ AppPackage/Tests/FeatureTests.xctestplan | 7 +++ 7 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 AppPackage/Tests/DetailFeatureTests/.swiftlint.yml create mode 100644 AppPackage/Tests/DetailFeatureTests/CommentsReducerTests.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 2b6740c5d..b66d9d9cb 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -116,6 +116,7 @@ enum Module: String { case parserFeatureTests = "ParserFeatureTests" case downloadsFeatureTests = "DownloadsFeatureTests" case settingFeatureTests = "SettingFeatureTests" + case detailFeatureTests = "DetailFeatureTests" } extension Module { @@ -955,6 +956,17 @@ let targets: [PackageDescription.Target] = [ ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins + ), + .testTarget( + module: .detailFeatureTests, + dependencies: [ + .module(.appModels), + .module(.detailFeature), + .module(.hapticsClient), + .targetDependency(.composableArchitecture) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins ) ] diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index c3d6a4b0d..cff36f8fc 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -60,14 +60,13 @@ public struct CommentsReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) case destination(PresentationAction) - case presentPostComment(String) + case presentPostComment(commentID: String, content: String? = nil) case clearScrollCommentID case delegate(Delegate) case setHUD(AppAlertState) case setPostCommentFocused(Bool) case setScrollRowOpacity(Double) - case setCommentContent(String) case performScrollOpacityEffect case handleCommentLink(URL) case handleGalleryLink(URL) @@ -103,7 +102,12 @@ public struct CommentsReducer: Sendable { case .destination: return .none - case .presentPostComment(let commentID): + case let .presentPostComment(commentID, content): + // Reset on present (not on dismiss): the sheet is a raw case binding, so an + // interactive swipe-down never sends `.destination(.dismiss)`. Editing passes the + // comment's text as `content`; the new-comment button passes nil to clear it. + state.commentContent = content ?? "" + state.postCommentFocused = false state.destination = .postComment(commentID) return .none @@ -126,10 +130,6 @@ public struct CommentsReducer: Sendable { state.scrollRowOpacity = opacity return .none - case .setCommentContent(let content): - state.commentContent = content - return .none - case .performScrollOpacityEffect: return .merge( .run { send in diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index d468ace9a..42430b20d 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -70,8 +70,9 @@ struct CommentsView: View { } if comment.editable { Button { - store.send(.setCommentContent(comment.plainTextContent)) - store.send(.presentPostComment(comment.commentID)) + store.send(.presentPostComment( + commentID: comment.commentID, content: comment.plainTextContent + )) } label: { Image(systemSymbol: .squareAndPencil) } @@ -122,7 +123,7 @@ struct CommentsView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { Button { - store.send(.presentPostComment("")) + store.send(.presentPostComment(commentID: "")) } label: { Image(systemSymbol: .squareAndPencil) } diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index 22e211ac2..6180d3ea9 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -13,13 +13,6 @@ extension DetailReducer { case .delegate: return .none - case .destination(.dismiss): - if case .postComment = state.destination { - state.commentContent = .init() - state.postCommentFocused = false - } - return .none - case .destination(.presented(.reading(.onPerformDismiss))): return .send(.destination(.dismiss)) @@ -47,6 +40,11 @@ extension DetailReducer { return .none case .postCommentButtonTapped: + // Reset on present (not on dismiss): the sheet is a raw case binding, so a swipe-down + // never sends `.destination(.dismiss)`. This is the new-comment flow only, so clearing + // is always correct. + state.commentContent = .init() + state.postCommentFocused = false state.destination = .postComment(.init()) return .none diff --git a/AppPackage/Tests/DetailFeatureTests/.swiftlint.yml b/AppPackage/Tests/DetailFeatureTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/DetailFeatureTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/DetailFeatureTests/CommentsReducerTests.swift b/AppPackage/Tests/DetailFeatureTests/CommentsReducerTests.swift new file mode 100644 index 000000000..041930e93 --- /dev/null +++ b/AppPackage/Tests/DetailFeatureTests/CommentsReducerTests.swift @@ -0,0 +1,46 @@ +import Testing +import Foundation +import AppModels +import HapticsClient +@testable import DetailFeature +import ComposableArchitecture + +@Suite +struct CommentsReducerTests { + // Regression: editing a comment then opening a new one used to leak the edited text, because the + // compose state was reset only on dismiss (which a swipe-down never triggers). The reset now + // happens on present, so a fresh compose always starts empty regardless of how the sheet closed. + @MainActor + @Test + func presentingPostCommentResetsStaleComposeState() async { + let store = TestStore( + initialState: CommentsReducer.State(galleryURL: .mock), + reducer: CommentsReducer.init + ) { + $0.hapticsClient = .noop + } + + // Editing carries the prefill through the present action. + await store.send(.presentPostComment(commentID: "42", content: "existing text")) { + $0.commentContent = "existing text" + $0.destination = .postComment("42") + } + + // Dismissing (Cancel or swipe-down) intentionally leaves the compose state untouched. + await store.send(.destination(.dismiss)) { + $0.destination = nil + } + + // Mimic the focus the editor's onAppear would have set. + await store.send(.setPostCommentFocused(true)) { + $0.postCommentFocused = true + } + + // Opening a new comment clears the stale text and focus on present. + await store.send(.presentPostComment(commentID: "")) { + $0.commentContent = "" + $0.postCommentFocused = false + $0.destination = .postComment("") + } + } +} diff --git a/AppPackage/Tests/FeatureTests.xctestplan b/AppPackage/Tests/FeatureTests.xctestplan index a624167e0..b25e6b434 100644 --- a/AppPackage/Tests/FeatureTests.xctestplan +++ b/AppPackage/Tests/FeatureTests.xctestplan @@ -12,6 +12,13 @@ "testTimeoutsEnabled" : true }, "testTargets" : [ + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "DetailFeatureTests", + "name" : "DetailFeatureTests" + } + }, { "target" : { "containerPath" : "container:AppPackage", From b39bf62b2d021ccf58ed84c1edc114934f9f9849 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 20:44:22 +0800 Subject: [PATCH 425/614] Share gallery push helper, guard duplicates --- .../AppFeature/DataFlow/AppRouteReducer.swift | 4 +- .../DetailFeature/GalleryNavigation.swift | 47 +++++++++++++++++++ .../DownloadsFeature/DownloadsReducer.swift | 18 +++---- .../FavoritesFeature/FavoritesReducer.swift | 18 +++---- AppPackage/Sources/HomeFeature/HomePath.swift | 19 ++++++++ .../HomeFeature/HomeReducer+Body.swift | 18 +++---- .../Sources/SearchFeature/SearchPath.swift | 11 +++++ .../SearchFeature/SearchRootReducer.swift | 18 +++---- .../GalleryNavigationTests.swift | 29 ++++++++++++ .../DownloadsReducerActionTests.swift | 28 +++++++++++ 10 files changed, 164 insertions(+), 46 deletions(-) create mode 100644 AppPackage/Tests/DetailFeatureTests/GalleryNavigationTests.swift diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index d00c1f272..174d9fd79 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -76,7 +76,7 @@ struct AppRouteReducer { case let .detail(.presented(.delegate(delegate))): if let next = GalleryNavigation.nextScreen(for: .detail(.delegate(delegate))) { - state.path.append(next) + state.path.appendGuardingDuplicate(next) } return .none @@ -92,7 +92,7 @@ struct AppRouteReducer { case let .path(.element(id: _, action: elementAction)): if let next = GalleryNavigation.nextScreen(for: elementAction) { - state.path.append(next) + state.path.appendGuardingDuplicate(next) } return .none diff --git a/AppPackage/Sources/DetailFeature/GalleryNavigation.swift b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift index 5e60dca06..05e068bec 100644 --- a/AppPackage/Sources/DetailFeature/GalleryNavigation.swift +++ b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift @@ -4,6 +4,19 @@ import ComposableArchitecture // screen that should be pushed next, so every host appends new elements the same way regardless of // whether it stacks `GalleryPath` directly or nested under a `.gallery` case. public enum GalleryNavigation { + // Centralizes the device branch every gallery host shares: iPad presents the detail as a modal + // sheet via the host's `present` delegate; iPhone pushes it inline via the host's `push` action. + // Actions are supplied as closures because host `Action` types are not `Sendable`. + public static func routeGalleryDetail( + isPad: @escaping @Sendable () async -> Bool, + present: @escaping @Sendable () -> Action, + push: @escaping @Sendable () -> Action + ) -> Effect { + .run { send in + await send(await isPad() ? present() : push()) + } + } + public static func nextScreen(for action: GalleryPath.Action) -> GalleryPath.State? { switch action { case let .detail(.delegate(delegate)): @@ -38,6 +51,40 @@ public enum GalleryNavigation { } } +// A stable per-screen identity for suppressing duplicate adjacent pushes. Screens for the same +// destination share a key even when volatile per-init state differs (e.g. the `localPreviewRequestID` +// UUID on detail/previews states), which plain `Equatable` would treat as distinct. +public protocol GalleryRouteIdentifiable { + var routeKey: String { get } +} + +extension StackState where Element: GalleryRouteIdentifiable { + // Append a screen unless it has the same route key as the current top of the stack, so a rapid + // double-activation can't push the same detail twice. Only the adjacent element is compared, so + // legitimate same-gid re-pushes through a deeper screen (Detail → Comments → same Detail) work. + public mutating func appendGuardingDuplicate(_ element: Element) { + guard last?.routeKey != element.routeKey else { return } + append(element) + } +} + +extension GalleryPath.State: GalleryRouteIdentifiable { + public var routeKey: String { + switch self { + case .detail(let state): + return "detail:\(state.gid)" + case .previews(let state): + return "previews:\(state.gid)" + case .comments(let state): + return "comments:\(state.gid)" + case .detailSearch(let state): + return "detailSearch:\(state.keyword)" + case .galleryInfos(let state): + return "galleryInfos:\(state.gallery.id)" + } + } +} + extension StackState where Element == GalleryPath.State { // The id of the pushed `.detail` element for `gid`, so a comment action performed on a deeper // `.comments` screen can refresh the detail it belongs to. diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 92c6923da..db1b2a3e6 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -113,20 +113,16 @@ public struct DownloadsReducer: Sendable { return .none case .galleryTapped(let gid): - // iPhone pushes the detail inline; iPad delegates up so it presents as a modal - // sheet hosted by AppRoute, matching the pre-StackState behavior. let download = state.downloads.first(where: { $0.gid == gid }) - return .run { send in - if await deviceClient.isPad() { - await send(.delegate(.presentGalleryDetail(gid, download))) - } else { - await send(.pushGalleryDetail(gid)) - } - } + return GalleryNavigation.routeGalleryDetail( + isPad: deviceClient.isPad, + present: { .delegate(.presentGalleryDetail(gid, download)) }, + push: { .pushGalleryDetail(gid) } + ) case .pushGalleryDetail(let gid): // Seed the detail with the locally downloaded gallery/badge so it renders offline. - state.path.append(.detail(.init( + state.path.appendGuardingDuplicate(.detail(.init( gid: gid, seededFrom: state.downloads.first(where: { $0.gid == gid }) ))) @@ -344,7 +340,7 @@ public struct DownloadsReducer: Sendable { case let .path(.element(id: _, action: elementAction)): if let next = GalleryNavigation.nextScreen(for: elementAction) { - state.path.append(next) + state.path.appendGuardingDuplicate(next) } return .none diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index f4fa44ad5..7ce7dc41d 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -112,18 +112,14 @@ public struct FavoritesReducer: Sendable { return .send(.observeDownloads) case .galleryTapped(let gid): - // iPhone pushes the detail inline; iPad delegates up so it presents as a modal - // sheet hosted by AppRoute, matching the pre-StackState behavior. - return .run { send in - if await deviceClient.isPad() { - await send(.delegate(.presentGalleryDetail(gid))) - } else { - await send(.pushGalleryDetail(gid)) - } - } + return GalleryNavigation.routeGalleryDetail( + isPad: deviceClient.isPad, + present: { .delegate(.presentGalleryDetail(gid)) }, + push: { .pushGalleryDetail(gid) } + ) case .pushGalleryDetail(let gid): - state.path.append(.detail(.init(gid: gid))) + state.path.appendGuardingDuplicate(.detail(.init(gid: gid))) return .none case .delegate: @@ -135,7 +131,7 @@ public struct FavoritesReducer: Sendable { case let .path(.element(id: _, action: elementAction)): if let next = GalleryNavigation.nextScreen(for: elementAction) { - state.path.append(next) + state.path.appendGuardingDuplicate(next) } return .none diff --git a/AppPackage/Sources/HomeFeature/HomePath.swift b/AppPackage/Sources/HomeFeature/HomePath.swift index 10da0f92e..bdb2f31af 100644 --- a/AppPackage/Sources/HomeFeature/HomePath.swift +++ b/AppPackage/Sources/HomeFeature/HomePath.swift @@ -15,6 +15,25 @@ public enum HomePath { extension HomePath.State: Equatable {} +extension HomePath.State: GalleryRouteIdentifiable { + public var routeKey: String { + switch self { + case .frontpage: + return "frontpage" + case .popular: + return "popular" + case .toplists: + return "toplists" + case .watched: + return "watched" + case .history: + return "history" + case .gallery(let state): + return "gallery/\(state.routeKey)" + } + } +} + extension StackState where Element == HomePath.State { // Locate the pushed `.gallery(.detail)` element for `gid` so a comment action performed on a // deeper `.comments` screen can refresh the detail it belongs to. diff --git a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift index c5a2d056e..2b709998e 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift @@ -30,18 +30,14 @@ extension HomeReducer { let .path(.element(id: _, action: .toplists(.delegate(.pushDetail(gid))))), let .path(.element(id: _, action: .watched(.delegate(.pushDetail(gid))))), let .path(.element(id: _, action: .history(.delegate(.pushDetail(gid))))): - // iPhone pushes the detail inline; iPad delegates up so it presents as a modal - // sheet hosted by AppRoute, matching the pre-StackState behavior. - return .run { send in - if await deviceClient.isPad() { - await send(.delegate(.presentGalleryDetail(gid))) - } else { - await send(.pushGalleryDetail(gid)) - } - } + return GalleryNavigation.routeGalleryDetail( + isPad: deviceClient.isPad, + present: { .delegate(.presentGalleryDetail(gid)) }, + push: { .pushGalleryDetail(gid) } + ) case .pushGalleryDetail(let gid): - state.path.append(.gallery(.detail(.init(gid: gid)))) + state.path.appendGuardingDuplicate(.gallery(.detail(.init(gid: gid)))) return .none case .delegate: @@ -73,7 +69,7 @@ extension HomeReducer { case let .path(.element(id: _, action: .gallery(galleryAction))): if let next = GalleryNavigation.nextScreen(for: galleryAction) { - state.path.append(.gallery(next)) + state.path.appendGuardingDuplicate(.gallery(next)) } return .none diff --git a/AppPackage/Sources/SearchFeature/SearchPath.swift b/AppPackage/Sources/SearchFeature/SearchPath.swift index a274cb966..8a7a2dcd8 100644 --- a/AppPackage/Sources/SearchFeature/SearchPath.swift +++ b/AppPackage/Sources/SearchFeature/SearchPath.swift @@ -11,6 +11,17 @@ public enum SearchPath { extension SearchPath.State: Equatable {} +extension SearchPath.State: GalleryRouteIdentifiable { + public var routeKey: String { + switch self { + case .search: + return "search" + case .gallery(let state): + return "gallery/\(state.routeKey)" + } + } +} + extension StackState where Element == SearchPath.State { // Locate the pushed `.gallery(.detail)` element for `gid` so a comment action performed on a // deeper `.comments` screen can refresh the detail it belongs to. diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift index ed437e22d..614d25930 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -112,18 +112,14 @@ public struct SearchRootReducer: Sendable { case .galleryTapped(let gid), let .path(.element(id: _, action: .search(.delegate(.pushDetail(gid))))): - // iPhone pushes the detail inline; iPad delegates up so it presents as a modal - // sheet hosted by AppRoute, matching the pre-StackState behavior. - return .run { send in - if await deviceClient.isPad() { - await send(.delegate(.presentGalleryDetail(gid))) - } else { - await send(.pushGalleryDetail(gid)) - } - } + return GalleryNavigation.routeGalleryDetail( + isPad: deviceClient.isPad, + present: { .delegate(.presentGalleryDetail(gid)) }, + push: { .pushGalleryDetail(gid) } + ) case .pushGalleryDetail(let gid): - state.path.append(.gallery(.detail(.init(gid: gid)))) + state.path.appendGuardingDuplicate(.gallery(.detail(.init(gid: gid)))) return .none case .delegate: @@ -139,7 +135,7 @@ public struct SearchRootReducer: Sendable { case let .path(.element(id: _, action: .gallery(galleryAction))): if let next = GalleryNavigation.nextScreen(for: galleryAction) { - state.path.append(.gallery(next)) + state.path.appendGuardingDuplicate(.gallery(next)) } return .none diff --git a/AppPackage/Tests/DetailFeatureTests/GalleryNavigationTests.swift b/AppPackage/Tests/DetailFeatureTests/GalleryNavigationTests.swift new file mode 100644 index 000000000..2d1c5ef32 --- /dev/null +++ b/AppPackage/Tests/DetailFeatureTests/GalleryNavigationTests.swift @@ -0,0 +1,29 @@ +import Testing +import AppModels +@testable import DetailFeature +import ComposableArchitecture + +@Suite +struct GalleryNavigationTests { + // appendGuardingDuplicate skips only an adjacent identical element, so a rapid double-activation + // pushes one screen while a legitimate same-gid re-push through a deeper screen still appends. + @Test + func appendGuardingDuplicateSkipsOnlyAdjacentDuplicates() { + var path = StackState() + + path.appendGuardingDuplicate(.detail(.init(gid: "1"))) + #expect(path.count == 1) + + // A second identical push (double-tap) is skipped. + path.appendGuardingDuplicate(.detail(.init(gid: "1"))) + #expect(path.count == 1) + + // A different screen is appended. + path.appendGuardingDuplicate(.comments(.init(galleryURL: .mock))) + #expect(path.count == 2) + + // The same detail after a non-adjacent screen is appended (only the top is compared). + path.appendGuardingDuplicate(.detail(.init(gid: "1"))) + #expect(path.count == 3) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift index 80517d2fb..1be8db91e 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift @@ -53,6 +53,34 @@ struct DownloadsReducerActionTests: DownloadFeatureTestCase { #expect(detailState.shouldCheckForRemoteUpdates == true) } + @MainActor + @Test + func testDownloadsReducerDoubleTapPushesDetailOnce() async { + let download = sampleDownload( + gid: "123456", + title: "Completed Gallery", + status: .completed + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { $0.deviceClient = .noop } + ) + store.exhaustivity = .off + + await store.send(.galleryTapped(download.gid)) + await store.receive(\.pushGalleryDetail) + #expect(store.state.path.count == 1) + + // A rapid second activation must not push a duplicate adjacent detail. + await store.send(.galleryTapped(download.gid)) + await store.receive(\.pushGalleryDetail) + #expect(store.state.path.count == 1) + } + @MainActor @Test func testDownloadsReducerDelegatesModalDetailOnPad() async { From 400f8933ed0458b3b3b161f8e69462e7a58e451e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 20:59:52 +0800 Subject: [PATCH 426/614] Delete dead teardown action cases --- .../Archives/ArchivesReducer.swift | 6 +----- .../Comments/CommentsReducer.swift | 6 +----- .../DetailFeature/DetailReducer+Fetch.swift | 7 ------- .../Sources/DetailFeature/DetailReducer.swift | 20 ------------------- .../DetailSearch/DetailSearchReducer.swift | 6 +----- .../FolderManager/FolderManagerReducer.swift | 4 ---- .../Previews/PreviewsReducer.swift | 6 +----- .../Torrents/TorrentsReducer.swift | 6 +----- .../DownloadInspectorReducer.swift | 7 ------- .../DownloadsFeature/DownloadsReducer.swift | 7 ------- .../Frontpage/FrontpageReducer.swift | 6 +----- .../HomeFeature/Popular/PopularReducer.swift | 4 ---- .../Toplists/ToplistsReducer.swift | 6 +----- .../HomeFeature/Watched/WatchedReducer.swift | 6 +----- .../QuickSearchReducer.swift | 4 ---- .../ReadingFeature/ReadingReducer+Body.swift | 5 +---- .../ReadingReducer+Database.swift | 13 ------------ .../ReadingFeature/ReadingReducer.swift | 1 - .../Sources/SearchFeature/SearchReducer.swift | 6 +----- .../EhSetting/EhSettingReducer.swift | 6 +----- .../SettingFeature/Login/LoginReducer.swift | 4 ---- .../DetailReducerObserveTests.swift | 10 ---------- 22 files changed, 11 insertions(+), 135 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index 30cdb19d3..10db0653e 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -11,7 +11,7 @@ import AppComponents @Reducer public struct ArchivesReducer: Sendable { - private enum CancelID: CaseIterable { + private enum CancelID { case fetchArchive, fetchArchiveFunds, fetchDownloadResponse } @@ -29,7 +29,6 @@ public struct ArchivesReducer: Sendable { case syncGalleryFunds(String, String) - case teardown case fetchArchive(String, URL, URL) case fetchArchiveDone(String, URL, Result) case fetchArchiveFunds(String, URL) @@ -57,9 +56,6 @@ public struct ArchivesReducer: Sendable { await databaseClient.updateGalleryFunds(galleryPoints: galleryPoints, credits: credits) } - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .fetchArchive(let gid, let galleryURL, let archiveURL): guard state.loadingState != .loading else { return .none } state.loadingState = .loading diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index cff36f8fc..80638bacc 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -24,7 +24,7 @@ public struct CommentsReducer: Sendable { case performedCommentAction(String) } - private enum CancelID: CaseIterable { + private enum CancelID { case postComment, voteComment, fetchGallery } @@ -75,7 +75,6 @@ public struct CommentsReducer: Sendable { case updateReadingProgress(String, Int) - case teardown case postComment(URL, String? = nil) case voteComment(String, String, String, String, Int) case performCommentActionDone(Result) @@ -188,9 +187,6 @@ public struct CommentsReducer: Sendable { await databaseClient.updateReadingProgress(gid: gid, progress: progress) } - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .postComment(let galleryURL, let commentID): guard !state.commentContent.isEmpty else { return .none } if let commentID = commentID { diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift index abb0429f7..5c8dd8e9d 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift @@ -7,13 +7,6 @@ extension DetailReducer { var fetchReducer: some ReducerOf { Reduce { state, action in switch action { - case .teardown: - return .merge( - CancelID - .all(for: state.cancellationGalleryID) - .map(Effect.cancel(id:)) - ) - case .fetchDatabaseInfos(let gid): if let gallery = databaseClient.fetchGallery(gid: gid) { state.gallery = gallery diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index 57ca4806d..b01b4eb0f 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -56,25 +56,6 @@ public struct DetailReducer: Sendable { case unfavorGallery(String) case postComment(String) case voteTag(String) - - // Teardown cancels this whole set; keep it in sync with the cases above. - // Dropping `CaseIterable` (associated values) means the compiler can't check the list for us. - static func all(for gid: String) -> [Self] { - [ - .fetchDatabaseInfos(gid), - .fetchGalleryDetail(gid), - .fetchVersionMetadata(gid), - .fetchDownloadBadge(gid), - .fetchDownloadFolders(gid), - .observeDownload(gid), - .loadLocalPreviewURLs(gid), - .rateGallery(gid), - .favorGallery(gid), - .unfavorGallery(gid), - .postComment(gid), - .voteTag(gid) - ] - } } @ObservableState @@ -184,7 +165,6 @@ public struct DetailReducer: Sendable { case retryDownloadDone(Result) case deleteDownload case deleteDownloadDone(Result) - case teardown case fetchDatabaseInfos(String) case fetchDatabaseInfosDone(GalleryState) case fetchGalleryDetail diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index 433b44c59..8195620ee 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -19,7 +19,7 @@ public struct DetailSearchReducer: Sendable { case pushDetail(String) } - private enum CancelID: CaseIterable { + private enum CancelID { case fetchGalleries, fetchMoreGalleries } @@ -55,7 +55,6 @@ public struct DetailSearchReducer: Sendable { case quickSearchButtonTapped case destination(PresentationAction) - case teardown case fetchGalleries(String? = nil) case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries @@ -95,9 +94,6 @@ public struct DetailSearchReducer: Sendable { case .destination: return .none - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .fetchGalleries(let keyword): guard state.loadingState != .loading else { return .none } if let keyword = keyword { diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift index aebb6d786..80dd20d26 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift @@ -66,7 +66,6 @@ public struct FolderManagerReducer: Sendable { case deleteFolder(String) case deleteFolderDone(Result) - case teardown case fetchFolders case fetchFoldersDone([String]) } @@ -183,9 +182,6 @@ public struct FolderManagerReducer: Sendable { state.loadingState = .failed(error) return .none - case .teardown: - return .cancel(id: CancelID.fetchFolders) - case .fetchFolders: state.loadingState = .loading return .run { send in diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index 1361700f8..709e326ad 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -15,7 +15,7 @@ public struct PreviewsReducer: Sendable { case reading(ReadingReducer) } - private enum CancelID: CaseIterable { + private enum CancelID { case fetchDatabaseInfos case observeDownloads case loadLocalPreviewURLs @@ -56,7 +56,6 @@ public struct PreviewsReducer: Sendable { case syncPreviewURLs([Int: URL]) case updateReadingProgress(Int) - case teardown case fetchDatabaseInfos(String) case fetchDatabaseInfosDone(GalleryState) case observeDownloads(String) @@ -99,9 +98,6 @@ public struct PreviewsReducer: Sendable { await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) } - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .fetchDatabaseInfos(let gid): guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } state.gallery = gallery diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift index 7820e5060..42c3cf614 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift @@ -15,7 +15,7 @@ public struct TorrentsReducer: Sendable { case share(URL) } - private enum CancelID: CaseIterable { + private enum CancelID { case fetchTorrent, fetchGalleryTorrents } @@ -35,7 +35,6 @@ public struct TorrentsReducer: Sendable { case copyText(String) case presentTorrentActivity(String, Data) - case teardown case fetchTorrent(String, URL) case fetchTorrentDone(String, Result) case fetchGalleryTorrents(String, String) @@ -83,9 +82,6 @@ public struct TorrentsReducer: Sendable { } .cancellable(id: CancelID.fetchTorrent) - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .fetchTorrentDone(let hash, let result): if case .success(let data) = result, !data.isEmpty { return .send(.presentTorrentActivity(hash, data)) diff --git a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift index b87328b83..5bb9d528a 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift @@ -32,7 +32,6 @@ public struct DownloadInspectorReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) case onAppear - case teardown case loadInspection case loadInspectionDone(UUID, Result) case observeDownloads @@ -64,12 +63,6 @@ public struct DownloadInspectorReducer: Sendable { .send(.observeDownloads) ) - case .teardown: - return .merge( - .cancel(id: CancelID.observeDownloads), - .cancel(id: CancelID.loadInspection) - ) - case .loadInspection: guard !state.gid.isEmpty else { return .none } if state.inspection == nil { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index db1b2a3e6..b4ac2cd4d 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -78,7 +78,6 @@ public struct DownloadsReducer: Sendable { case moveButtonTapped(DownloadedGallery) case onAppear - case teardown case fetchDownloads case fetchDownloadsDone([DownloadedGallery]) case observeDownloads @@ -195,12 +194,6 @@ public struct DownloadsReducer: Sendable { .send(.fetchFolders) ) - case .teardown: - return .merge( - .cancel(id: CancelID.observeDownloads), - .cancel(id: CancelID.fetchFolders) - ) - case .fetchDownloads: state.loadingState = .loading return .run { send in diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index 2760f679b..f1875c8ac 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -20,7 +20,7 @@ public struct FrontpageReducer: Sendable { case dateSeek(DateSeekReducer) } - private enum CancelID: CaseIterable { + private enum CancelID { case fetchGalleries, fetchMoreGalleries, fetchDateSeekGalleries } @@ -57,7 +57,6 @@ public struct FrontpageReducer: Sendable { case dateSeekButtonTapped(DateSeekNavigation) case destination(PresentationAction) - case teardown case fetchGalleries case fetchGalleriesDone(Result) case fetchMoreGalleries @@ -89,9 +88,6 @@ public struct FrontpageReducer: Sendable { state.destination = .dateSeek(.init(navigation: navigation)) return .none - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .fetchGalleries: guard state.loadingState != .loading else { return .none } state.loadingState = .loading diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index 165ea53b5..29b07eb6e 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -42,7 +42,6 @@ public struct PopularReducer: Sendable { case filtersButtonTapped case destination(PresentationAction) - case teardown case fetchGalleries case fetchGalleriesDone(Result<[Gallery], AppError>) } @@ -70,9 +69,6 @@ public struct PopularReducer: Sendable { case .destination: return .none - case .teardown: - return .cancel(id: CancelID.fetchGalleries) - case .fetchGalleries: guard state.loadingState != .loading else { return .none } state.loadingState = .loading diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index b7f5cdfb5..40e07d49b 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -17,7 +17,7 @@ public struct ToplistsReducer: Sendable { case performJumpPage } - private enum CancelID: CaseIterable { + private enum CancelID { case fetchGalleries, fetchMoreGalleries } @@ -71,7 +71,6 @@ public struct ToplistsReducer: Sendable { case alert(PresentationAction) case presentJumpPageAlert - case teardown case fetchGalleries(Int? = nil) case fetchGalleriesDone(ToplistsType, Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries @@ -134,9 +133,6 @@ public struct ToplistsReducer: Sendable { ) return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .fetchGalleries(let pageNum): guard state.loadingState != .loading else { return .none } state.rawLoadingState[state.type] = .loading diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index f1910a2e7..ef67ec9a5 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -22,7 +22,7 @@ public struct WatchedReducer: Sendable { case dateSeek(DateSeekReducer) } - private enum CancelID: CaseIterable { + private enum CancelID { case fetchGalleries, fetchMoreGalleries, observeDownloads, fetchDateSeekGalleries } @@ -59,7 +59,6 @@ public struct WatchedReducer: Sendable { case destination(PresentationAction) case onNotLoginViewButtonTapped - case teardown case fetchGalleries(String? = nil) case fetchGalleriesDone(Result) case fetchMoreGalleries @@ -104,9 +103,6 @@ public struct WatchedReducer: Sendable { case .onNotLoginViewButtonTapped: return .none - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .fetchGalleries(let keyword): guard state.loadingState != .loading else { return .none } if let keyword = keyword { diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift index 397381189..3e7fcd7dd 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift @@ -60,7 +60,6 @@ public struct QuickSearchReducer: Sendable { case deleteWordWithOffsets(IndexSet) case moveWord(IndexSet, Int) - case teardown case fetchQuickSearchWords case fetchQuickSearchWordsDone([QuickSearchWord]) } @@ -144,9 +143,6 @@ public struct QuickSearchReducer: Sendable { state.quickSearchWords.move(fromOffsets: source, toOffset: destination) return .send(.syncQuickSearchWords) - case .teardown: - return .cancel(id: CancelID.fetchQuickSearchWords) - case .fetchQuickSearchWords: state.loadingState = .loading return .run { send in diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index 587faa683..2cf80255b 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -5,7 +5,7 @@ import ComposableArchitecture import AppTools // MARK: - CancelID -enum ReadingCancelID: CaseIterable { +enum ReadingCancelID { case fetchImage case fetchDatabaseInfos case observeDownloads @@ -199,9 +199,6 @@ extension ReadingReducer { return .none } - case .teardown: - return reduceTeardown() - default: return .none } diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift index c1e0943b7..faf802f1e 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift @@ -62,19 +62,6 @@ extension ReadingReducer { } } - func reduceTeardown() -> Effect { - var effects: [Effect] = [ - .merge(ReadingCancelID.allCases.map(Effect.cancel(id:))) - ] - effects.append( - .run { send in - guard await !deviceClient.isPad() else { return } - await send(.setOrientationPortrait(true)) - } - ) - return .merge(effects) - } - func reduceFetchDatabaseInfos(state: inout State, gid: String) -> Effect { if case .local(let download, let manifest) = state.contentSource { applyLocalSource(state: &state, download: download, manifest: manifest) diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 56343f8e1..440346bc5 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -163,7 +163,6 @@ public struct ReadingReducer: Sendable { case syncThumbnailURLs([Int: URL]) case syncImageURLs([Int: URL], [Int: URL]) - case teardown case fetchDatabaseInfos(String) case fetchDatabaseInfosDone(GalleryState) case observeDownloads(String) diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index 76f79d68d..6f2d8ceaa 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -24,7 +24,7 @@ public struct SearchReducer: Sendable { case dateSeek(DateSeekReducer) } - private enum CancelID: CaseIterable { + private enum CancelID { case fetchGalleries, fetchMoreGalleries, observeDownloads, fetchDateSeekGalleries } @@ -64,7 +64,6 @@ public struct SearchReducer: Sendable { case dateSeekButtonTapped(DateSeekNavigation) case destination(PresentationAction) - case teardown case fetchGalleries(String? = nil) case fetchGalleriesDone(Result) case fetchMoreGalleries @@ -112,9 +111,6 @@ public struct SearchReducer: Sendable { state.destination = .dateSeek(.init(navigation: navigation)) return .none - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .fetchGalleries(let keyword): // The performed keyword is what the host records into search history: an explicit // keyword when provided, otherwise the current `lastKeyword`. Emit it even when a diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index ab7cd22b8..7c348a4c3 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -20,7 +20,7 @@ public struct EhSettingReducer: Sendable { case confirmDeleteProfile } - private enum CancelID: CaseIterable { + private enum CancelID { case fetchEhSetting, submitChanges, performAction } @@ -52,7 +52,6 @@ public struct EhSettingReducer: Sendable { case setKeyboardHidden case setDefaultProfile(Int) - case teardown case fetchEhSetting case fetchEhSettingDone(Result) case submitChanges @@ -114,9 +113,6 @@ public struct EhSettingReducer: Sendable { ) } - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .fetchEhSetting: guard state.loadingState != .loading else { return .none } state.loadingState = .loading diff --git a/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift index 71c5c45ab..e118049cb 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift @@ -47,7 +47,6 @@ public struct LoginReducer: Sendable { case destination(PresentationAction) case presentWebView(URL) - case teardown case login case loginDone(Result) } @@ -73,9 +72,6 @@ public struct LoginReducer: Sendable { state.destination = .webView(url) return .none - case .teardown: - return .cancel(id: CancelID.login) - case .login: guard !state.loginButtonDisabled || state.loginState == .loading else { return .none } state.focusedField = nil diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift index dd3e9b45b..19da213e0 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift @@ -12,16 +12,6 @@ import CookieClient @Suite(.serialized) @MainActor struct DetailReducerObserveTests: DownloadFeatureTestCase { - @Test - func testDetailCancellationIDsAreScopedByGallery() { - let firstGalleryIDs = DetailReducer.CancelID.all(for: "100") - let secondGalleryIDs = DetailReducer.CancelID.all(for: "200") - - #expect(firstGalleryIDs.count == 12) - #expect(secondGalleryIDs.count == 12) - #expect(Set(firstGalleryIDs).isDisjoint(with: Set(secondGalleryIDs))) - } - @MainActor @Test func testDetailReducerObservesDownloadBadgeTransitions() async { From 3b33ad2c2745b2cee5360f44fd7d2bf86809bea6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 21:11:34 +0800 Subject: [PATCH 427/614] Add Setting stack navigation tests --- AppPackage/Package.swift | 2 + .../SettingReducerNavigationTests.swift | 148 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index b66d9d9cb..016a8a550 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -949,6 +949,8 @@ let targets: [PackageDescription.Target] = [ module: .settingFeatureTests, dependencies: [ .module(.appModels), + .module(.cookieClient), + .module(.hapticsClient), .module(.logsClient), .module(.settingFeature), .targetDependency(.composableArchitecture), diff --git a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift new file mode 100644 index 000000000..4ebddaf3d --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift @@ -0,0 +1,148 @@ +import Testing +import Foundation +import AppModels +import Sharing +import CookieClient +import HapticsClient +@testable import SettingFeature +import ComposableArchitecture + +// Covers the Setting tab's single flat navigation stack: root-row taps, child `delegate`-driven +// pushes, and the post-login effect cascade that `SettingReducer` runs while the login screen +// self-dismisses. +@Suite +@MainActor +struct SettingReducerNavigationTests { + // MARK: Root menu + + @Test + func settingRowTappedAppendsMatchingScreen() async throws { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + + // Each root row appends exactly its mapped `SettingPath` element, in order. + for screen in SettingReducer.RootScreen.allCases { + await store.send(.settingRowTapped(screen)) { + $0.path.append(screen.pathElement) + } + } + + #expect(store.state.path.count == SettingReducer.RootScreen.allCases.count) + } + + @Test + func pushLoginAppendsLoginScreen() async { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + + await store.send(.pushLogin) { + $0.path.append(.login(.init())) + } + } + + // MARK: Child delegate → parent push + + @Test + func accountDelegatePushLoginAppendsLogin() async throws { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + + await store.send(.settingRowTapped(.account)) { + $0.path.append(.account(.init())) + } + let id = try #require(store.state.path.ids.last) + await store.send(.path(.element(id: id, action: .account(.delegate(.pushLogin))))) { + $0.path.append(.login(.init())) + } + } + + @Test + func accountDelegatePushEhSettingAppendsEhSetting() async throws { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + + await store.send(.settingRowTapped(.account)) { + $0.path.append(.account(.init())) + } + let id = try #require(store.state.path.ids.last) + await store.send(.path(.element(id: id, action: .account(.delegate(.pushEhSetting))))) { + $0.path.append(.ehSetting(.init())) + } + } + + @Test + func appearanceDelegatePushAppIconAppendsAppIcon() async throws { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + + await store.send(.settingRowTapped(.appearance)) { + $0.path.append(.appearance(.init())) + } + let id = try #require(store.state.path.ids.last) + await store.send(.path(.element(id: id, action: .appearance(.delegate(.pushAppIcon))))) { + $0.path.append(.appIcon(.init())) + } + } + + @Test + func generalDelegatePushAppActivityLogsAppendsLogs() async throws { + // The logs screen reads in-memory `@SharedReader` keys; isolate them so the read can't see + // another test's pump state. + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { + $0.defaultInMemoryStorage = InMemoryStorage() + } + store.exhaustivity = .off + + await store.send(.settingRowTapped(.general)) + let id = try #require(store.state.path.ids.last) + await store.send(.path(.element(id: id, action: .general(.delegate(.pushAppActivityLogs))))) + + #expect(store.state.path.count == 2) + guard case .appActivityLogs = store.state.path.last else { + Issue.record("Expected .appActivityLogs on top of the Setting stack") + return + } + } + + // MARK: Post-login cascade + + @Test + func loginDoneRunsPostLoginFetchCascade() async throws { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { + $0.cookieClient = .noop + $0.hapticsClient = .noop + } + store.exhaustivity = .off + + await store.send(.pushLogin) + let id = try #require(store.state.path.ids.last) + + // Finishing login fans out to the four signed-in fetches; each guards on `didLogin` (false + // under the noop cookie client) so no network effects run. + await store.send(.path(.element(id: id, action: .login(.loginDone(.success(nil)))))) + await store.receive(\.fetchIgneous) + await store.receive(\.fetchUserInfo) + await store.receive(\.fetchFavoriteCategories) + await store.receive(\.fetchEhProfileIndex) + } + + // MARK: Igneous refresh signalling + + @Test + func fetchIgneousDoneSuccessSignalsRefreshed() async throws { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { + $0.cookieClient = .noop + } + store.exhaustivity = .off + + let response = try #require( + HTTPURLResponse(url: .mock, statusCode: 200, httpVersion: nil, headerFields: nil) + ) + await store.send(.fetchIgneousDone(.success(response))) + await store.receive(\.igneousRefreshed) + } + + @Test + func fetchIgneousDoneFailureStillSignalsRefreshed() async { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + store.exhaustivity = .off + + await store.send(.fetchIgneousDone(.failure(.notFound))) + await store.receive(\.igneousRefreshed) + } +} From 19b9cbb8c9552ae048e8203e691544429c1f3ad1 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 21:14:17 +0800 Subject: [PATCH 428/614] Drop stale AlertKit pin from Package.resolved --- AppPackage/Package.resolved | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/AppPackage/Package.resolved b/AppPackage/Package.resolved index f46f2b379..727263225 100644 --- a/AppPackage/Package.resolved +++ b/AppPackage/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "38195e57824c04b99b7f201f4c8de371fa209d236ec8e6cdd6dfe5d46edb2e8e", + "originHash" : "0822a765dddc189f0968f604438ceeabadae6a4cfac36efee836cdfbe30699dd", "pins" : [ - { - "identity" : "alertkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/EhPanda-Team/AlertKit", - "state" : { - "branch" : "custom", - "revision" : "39b01c53ffadf3dab9871dd4c960cd81af5246b6" - } - }, { "identity" : "colorful", "kind" : "remoteSourceControl", From 6d63f6581a693d4499737b6c51d7962e9985ff48 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 21:17:04 +0800 Subject: [PATCH 429/614] Remove orphaned detail delete-download string --- .../Sources/Resources/Resources/de.lproj/Localizable.strings | 1 - .../Sources/Resources/Resources/en.lproj/Localizable.strings | 1 - .../Sources/Resources/Resources/ja.lproj/Localizable.strings | 1 - .../Sources/Resources/Resources/ko.lproj/Localizable.strings | 1 - .../Resources/Resources/zh-Hans.lproj/Localizable.strings | 1 - .../Resources/Resources/zh-Hant-HK.lproj/Localizable.strings | 1 - .../Resources/Resources/zh-Hant-TW.lproj/Localizable.strings | 1 - .../Resources/Resources/zh-Hant.lproj/Localizable.strings | 1 - AppPackage/Sources/Resources/Strings.swift | 2 -- 9 files changed, 10 deletions(-) diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index d29e24bbe..b146ca120 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -342,7 +342,6 @@ "detail_view.dialog.title.repair_download" = "Download reparieren?"; "detail_view.dialog.title.update_download" = "Download aktualisieren?"; "detail_view.dialog.title.redownload_gallery" = "Galerie erneut herunterladen?"; -"detail_view.dialog.message.delete_active_download" = "Der aktuelle Download wird gestoppt und die Galerie von diesem Gerät entfernt."; "detail_view.dialog.message.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; "detail_view.dialog.message.repair_download" = "Die Offline-Dateien dieser Galerie jetzt reparieren?"; "detail_view.dialog.message.update_download" = "Diese Galerie jetzt auf die neueste Online-Version aktualisieren?"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 3adf815f8..56b5fda1c 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -342,7 +342,6 @@ "detail_view.dialog.title.repair_download" = "Repair Download?"; "detail_view.dialog.title.update_download" = "Update Download?"; "detail_view.dialog.title.redownload_gallery" = "Redownload Gallery?"; -"detail_view.dialog.message.delete_active_download" = "This will stop the current download and remove the gallery from this device."; "detail_view.dialog.message.delete_downloaded_gallery" = "This will remove the downloaded gallery from this device."; "detail_view.dialog.message.repair_download" = "Repair the offline files for this gallery now?"; "detail_view.dialog.message.update_download" = "Update this gallery to the newest online version now?"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 35aa61b83..e9359406a 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -342,7 +342,6 @@ "detail_view.dialog.title.repair_download" = "ダウンロードを修復しますか?"; "detail_view.dialog.title.update_download" = "ダウンロードを更新しますか?"; "detail_view.dialog.title.redownload_gallery" = "ギャラリーを再ダウンロードしますか?"; -"detail_view.dialog.message.delete_active_download" = "現在のダウンロードを停止し、このデバイスからギャラリーを削除します。"; "detail_view.dialog.message.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; "detail_view.dialog.message.repair_download" = "このギャラリーのオフラインファイルを今すぐ修復しますか?"; "detail_view.dialog.message.update_download" = "このギャラリーを今すぐオンラインの最新バージョンに更新しますか?"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index df32fa8c7..1398f3c72 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -342,7 +342,6 @@ "detail_view.dialog.title.repair_download" = "다운로드를 복구할까요?"; "detail_view.dialog.title.update_download" = "다운로드를 업데이트할까요?"; "detail_view.dialog.title.redownload_gallery" = "갤러리를 다시 다운로드할까요?"; -"detail_view.dialog.message.delete_active_download" = "현재 다운로드를 중지하고 이 기기에서 갤러리를 삭제합니다."; "detail_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; "detail_view.dialog.message.repair_download" = "이 갤러리의 오프라인 파일을 지금 복구할까요?"; "detail_view.dialog.message.update_download" = "이 갤러리를 지금 온라인 최신 버전으로 업데이트할까요?"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 082fa06d0..0265c3b1c 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -342,7 +342,6 @@ "detail_view.dialog.title.repair_download" = "修复下载?"; "detail_view.dialog.title.update_download" = "更新下载?"; "detail_view.dialog.title.redownload_gallery" = "重新下载画廊?"; -"detail_view.dialog.message.delete_active_download" = "这将停止当前下载并从此设备移除该画廊。"; "detail_view.dialog.message.delete_downloaded_gallery" = "这将从此设备移除已下载的画廊。"; "detail_view.dialog.message.repair_download" = "现在修复此画廊的离线文件吗?"; "detail_view.dialog.message.update_download" = "现在将此画廊更新到线上最新版本吗?"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings index 936c41893..5127cda45 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings @@ -342,7 +342,6 @@ "detail_view.dialog.title.repair_download" = "修復下載?"; "detail_view.dialog.title.update_download" = "更新下載?"; "detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; -"detail_view.dialog.message.delete_active_download" = "這將停止目前下載並從此裝置移除此畫廊。"; "detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; "detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; "detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings index c7cca7736..8b8e12079 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings @@ -342,7 +342,6 @@ "detail_view.dialog.title.repair_download" = "修復下載?"; "detail_view.dialog.title.update_download" = "更新下載?"; "detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; -"detail_view.dialog.message.delete_active_download" = "這將停止目前下載並從此裝置移除此畫廊。"; "detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; "detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; "detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 244bb2bd6..1ff341466 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -342,7 +342,6 @@ "detail_view.dialog.title.repair_download" = "修復下載?"; "detail_view.dialog.title.update_download" = "更新下載?"; "detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; -"detail_view.dialog.message.delete_active_download" = "這將停止目前下載並從此裝置移除此畫廊。"; "detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; "detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; "detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 180b4b757..714d6a562 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -579,8 +579,6 @@ public enum L10n { public static let update = L10n.tr("Localizable", "detail_view.dialog.button.update", fallback: "Update") } public enum Message { - /// This will stop the current download and remove the gallery from this device. - public static let deleteActiveDownload = L10n.tr("Localizable", "detail_view.dialog.message.delete_active_download", fallback: "This will stop the current download and remove the gallery from this device.") /// This will remove the downloaded gallery from this device. public static let deleteDownloadedGallery = L10n.tr("Localizable", "detail_view.dialog.message.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") /// Start a fresh download for this gallery now? From 171ce503b36c2ea9086129dfcd6ac5826fadc1cc Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 22:11:45 +0800 Subject: [PATCH 430/614] Guard duplicate pushes on non-gallery routes --- .../Sources/HomeFeature/HomeReducer+Body.swift | 10 +++++----- .../SearchFeature/SearchRootReducer.swift | 2 +- .../Sources/SettingFeature/SettingPath.swift | 11 +++++++++++ .../SettingFeature/SettingReducer+Body.swift | 12 ++++++------ .../SettingReducerNavigationTests.swift | 17 +++++++++++++++++ 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift index 2b709998e..7d9580676 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift @@ -46,20 +46,20 @@ extension HomeReducer { case .sectionTapped(let type): switch type { case .frontpage: - state.path.append(.frontpage(.init())) + state.path.appendGuardingDuplicate(.frontpage(.init())) case .toplists: - state.path.append(.toplists(.init())) + state.path.appendGuardingDuplicate(.toplists(.init())) } return .none case .miscTapped(let type): switch type { case .popular: - state.path.append(.popular(.init())) + state.path.appendGuardingDuplicate(.popular(.init())) case .watched: - state.path.append(.watched(.init())) + state.path.appendGuardingDuplicate(.watched(.init())) case .history: - state.path.append(.history(.init())) + state.path.appendGuardingDuplicate(.history(.init())) } return .none diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift index 614d25930..521593c2b 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -107,7 +107,7 @@ public struct SearchRootReducer: Sendable { return .none case .pushSearch: - state.path.append(.search(.init(keyword: state.keyword))) + state.path.appendGuardingDuplicate(.search(.init(keyword: state.keyword))) return .none case .galleryTapped(let gid), diff --git a/AppPackage/Sources/SettingFeature/SettingPath.swift b/AppPackage/Sources/SettingFeature/SettingPath.swift index 0bb1c89a9..32aa89e93 100644 --- a/AppPackage/Sources/SettingFeature/SettingPath.swift +++ b/AppPackage/Sources/SettingFeature/SettingPath.swift @@ -22,6 +22,17 @@ public enum SettingPath { extension SettingPath.State: Equatable, Sendable {} +extension StackState where Element == SettingPath.State { + // Skip appending a screen identical to the current top, so a rapid double-activation of a Setting + // row — or a child re-emitting the same `delegate` — can't stack the same screen twice. Setting + // path states are cleanly `Equatable` (no volatile per-init fields), so a plain value comparison + // suffices here; only the adjacent element is checked, mirroring the gallery stacks' guard. + mutating func appendGuardingDuplicate(_ element: SettingPath.State) { + guard last != element else { return } + append(element) + } +} + // A placeholder reducer for Setting screens that hold no state and run no logic (their views are // driven entirely by bindings into `SettingReducer.State.setting`). Shared across every such leaf. @Reducer diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 8d92de532..5112a960f 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -102,28 +102,28 @@ extension SettingReducer { ) case .settingRowTapped(let screen): - state.path.append(screen.pathElement) + state.path.appendGuardingDuplicate(screen.pathElement) return .none case .pushLogin: - state.path.append(.login(.init())) + state.path.appendGuardingDuplicate(.login(.init())) return .none // Account emits a delegate to push its children onto the shared stack. case .path(.element(id: _, action: .account(.delegate(.pushLogin)))): - state.path.append(.login(.init())) + state.path.appendGuardingDuplicate(.login(.init())) return .none case .path(.element(id: _, action: .account(.delegate(.pushEhSetting)))): - state.path.append(.ehSetting(.init())) + state.path.appendGuardingDuplicate(.ehSetting(.init())) return .none case .path(.element(id: _, action: .general(.delegate(.pushAppActivityLogs)))): - state.path.append(.appActivityLogs(.init())) + state.path.appendGuardingDuplicate(.appActivityLogs(.init())) return .none case .path(.element(id: _, action: .appearance(.delegate(.pushAppIcon)))): - state.path.append(.appIcon(.init())) + state.path.appendGuardingDuplicate(.appIcon(.init())) return .none case .syncAppIconType: diff --git a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift index 4ebddaf3d..f5ad3784c 100644 --- a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift @@ -38,6 +38,23 @@ struct SettingReducerNavigationTests { } } + @Test + func settingRowTappedGuardsAgainstAdjacentDuplicate() async { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + store.exhaustivity = .off + + await store.send(.settingRowTapped(.account)) + #expect(store.state.path.count == 1) + + // A rapid second identical tap is skipped — only the adjacent top is compared. + await store.send(.settingRowTapped(.account)) + #expect(store.state.path.count == 1) + + // A different row still appends. + await store.send(.settingRowTapped(.general)) + #expect(store.state.path.count == 2) + } + // MARK: Child delegate → parent push @Test From a73e04319dbf5b3491d235c89c1c9edcb3f2468d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 22:13:55 +0800 Subject: [PATCH 431/614] Delete dead setCommentContent action --- AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift | 4 ---- AppPackage/Sources/DetailFeature/DetailReducer.swift | 1 - 2 files changed, 5 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index 6180d3ea9..4386decc1 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -102,10 +102,6 @@ extension DetailReducer { state.showsUserRating.toggle() return .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }) - case .setCommentContent(let content): - state.commentContent = content - return .none - case .setPostCommentFocused(let isFocused): state.postCommentFocused = isFocused return .none diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index b01b4eb0f..92d2f56e1 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -131,7 +131,6 @@ public struct DetailReducer: Sendable { case onAppear(String, Bool) case toggleShowFullTitle case toggleShowUserRating - case setCommentContent(String) case setPostCommentFocused(Bool) case updateRating(DragGesture.Value) case confirmRating(DragGesture.Value) From a7b14634075f355c76ad9e4fd51c6685910ec629 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 22:52:49 +0800 Subject: [PATCH 432/614] Replace FilePicker with native fileImporter --- AppPackage/Package.resolved | 11 +------ AppPackage/Package.swift | 4 --- .../Sources/FileClient/FileClient.swift | 4 +++ .../GeneralSettingReducer.swift | 21 ++++++++++++ .../GeneralSetting/GeneralSettingView.swift | 15 +++++---- .../GeneralSettingReducerTests.swift | 32 +++++++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 11 +------ 7 files changed, 68 insertions(+), 30 deletions(-) create mode 100644 AppPackage/Tests/SettingFeatureTests/GeneralSettingReducerTests.swift diff --git a/AppPackage/Package.resolved b/AppPackage/Package.resolved index 727263225..01d5cd333 100644 --- a/AppPackage/Package.resolved +++ b/AppPackage/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0822a765dddc189f0968f604438ceeabadae6a4cfac36efee836cdfbe30699dd", + "originHash" : "950b583b60e12ba632ae0241b643765c995f9f7e5953a45213ffe2de456c02ba", "pins" : [ { "identity" : "colorful", @@ -28,15 +28,6 @@ "revision" : "021e29675457a9b4b7859a46afbb5d0e37574e84" } }, - { - "identity" : "filepicker", - "kind" : "remoteSourceControl", - "location" : "https://github.com/markrenaud/FilePicker", - "state" : { - "revision" : "720f8cb5ca0c0efc982ed381afc84ba3e8b3214e", - "version" : "1.0.1" - } - }, { "identity" : "kanna", "kind" : "remoteSourceControl", diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 016a8a550..d4ad2844d 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -16,7 +16,6 @@ var dependencies: [PackageDescription.Package.Dependency] = [ .package(url: "https://github.com/fermoya/SwiftUIPager", from: "2.5.0"), .package(url: "https://github.com/gonzalezreal/SwiftCommonMark", from: "1.0.0"), .package(url: "https://github.com/jathu/UIImageColors", from: "2.2.0"), - .package(url: "https://github.com/markrenaud/FilePicker", from: "1.0.0"), .package(url: "https://github.com/onevcat/Kingfisher", from: "8.0.0"), .package(url: "https://github.com/paololeonardi/WaterfallGrid", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.7.0"), @@ -37,7 +36,6 @@ extension PackageDescription.Target.Dependency { package: "swift-composable-architecture" ) static let deprecatedAPI: Self = .product(name: "DeprecatedAPI", package: "DeprecatedAPI") - static let filePicker: Self = .product(name: "FilePicker", package: "FilePicker") static let kanna: Self = .product(name: "Kanna", package: "Kanna") static let kingfisher: Self = .product(name: "Kingfisher", package: "Kingfisher") static let openCC: Self = .product(name: "OpenCC", package: "SwiftyOpenCC") @@ -291,7 +289,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.commonMark), .targetDependency(.composableArchitecture), .targetDependency(.deprecatedAPI), - .targetDependency(.filePicker), .targetDependency(.kanna), .targetDependency(.kingfisher), .targetDependency(.openCC), @@ -693,7 +690,6 @@ let targets: [PackageDescription.Target] = [ .module(.ttProgressHUDExt), .module(.userDefaultsClient), .targetDependency(.composableArchitecture), - .targetDependency(.filePicker), .targetDependency(.sfSafeSymbols), .targetDependency(.sharing) ], diff --git a/AppPackage/Sources/FileClient/FileClient.swift b/AppPackage/Sources/FileClient/FileClient.swift index 72570b961..06ade1004 100644 --- a/AppPackage/Sources/FileClient/FileClient.swift +++ b/AppPackage/Sources/FileClient/FileClient.swift @@ -14,6 +14,10 @@ extension FileClient { }, importTagTranslator: { url in await withCheckedContinuation { continuation in + // `.fileImporter` returns a security-scoped URL to the original file; access must be + // claimed before reading and released afterwards, unlike a copied-in temp file. + let didAccess = url.startAccessingSecurityScopedResource() + defer { if didAccess { url.stopAccessingSecurityScopedResource() } } guard let data = try? Data(contentsOf: url), let translations = try? JSONDecoder().decode( EhTagTranslationDatabaseResponse.self, from: data diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index cef8c6902..aa810d824 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -12,6 +12,12 @@ private let logger = Logger(category: .init(describing: GeneralSettingReducer.se @Reducer public struct GeneralSettingReducer: Sendable { + @Reducer + public enum Destination { + @ReducerCaseIgnored + case importTranslations + } + public enum Dialog: Equatable, Sendable { case confirmClearCache case confirmRemoveCustomTranslations @@ -24,6 +30,8 @@ public struct GeneralSettingReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { + // Drives the native `.fileImporter` presentation for importing custom tag translations. + @Presents public var destination: Destination.State? // Two separate dialogs so each anchors to its own trigger button on iPad (the clear-cache // and remove-translations buttons live in different sections). @Presents public var clearCacheDialog: ConfirmationDialogState? @@ -38,9 +46,11 @@ public struct GeneralSettingReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) + case destination(PresentationAction) case clearCacheDialog(PresentationAction) case removeTranslationsDialog(PresentationAction) case delegate(Delegate) + case importCustomTranslationsButtonTapped case onTranslationsFilePicked(URL) case removeCustomTranslationsButtonTapped case onRemoveCustomTranslations @@ -68,9 +78,16 @@ public struct GeneralSettingReducer: Sendable { case .binding: return .none + case .destination: + return .none + case .delegate: return .none + case .importCustomTranslationsButtonTapped: + state.destination = .importTranslations + return .none + case .removeCustomTranslationsButtonTapped: state.removeTranslationsDialog = ConfirmationDialogState { TextState("") @@ -148,7 +165,11 @@ public struct GeneralSettingReducer: Sendable { return .none } } + .ifLet(\.$destination, action: \.destination) .ifLet(\.$clearCacheDialog, action: \.clearCacheDialog) .ifLet(\.$removeTranslationsDialog, action: \.removeTranslationsDialog) } } + +extension GeneralSettingReducer.Destination.State: Equatable, Sendable {} +extension GeneralSettingReducer.Destination.Action: Equatable, Sendable {} diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index fa40a8e09..af3537adb 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -1,7 +1,7 @@ import SwiftUI import AppModels import Resources -import FilePicker +import UniformTypeIdentifiers import ComposableArchitecture import AppComponents @@ -92,11 +92,14 @@ struct GeneralSettingView: View { ) Toggle(L10n.Localizable.GeneralSettingView.Title.showsImagesInTags, isOn: $showsImagesInTags) } - FilePicker( - types: [.json], allowMultiple: false, - title: L10n.Localizable.GeneralSettingView.Button.importCustomTranslations - ) { urls in - if let url = urls.first { + Button(L10n.Localizable.GeneralSettingView.Button.importCustomTranslations) { + store.send(.importCustomTranslationsButtonTapped) + } + .fileImporter( + isPresented: $store.destination.importTranslations, + allowedContentTypes: [.json] + ) { result in + if case .success(let url) = result { store.send(.onTranslationsFilePicked(url)) } } diff --git a/AppPackage/Tests/SettingFeatureTests/GeneralSettingReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/GeneralSettingReducerTests.swift new file mode 100644 index 000000000..070857f43 --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/GeneralSettingReducerTests.swift @@ -0,0 +1,32 @@ +import Testing +import Foundation +@testable import SettingFeature +import ComposableArchitecture + +// Covers the tag-translation file-import flow: the button drives a native `.fileImporter` through a +// `@Presents` destination, so present/dismiss are exhaustively assertable in the reducer. +@Suite +@MainActor +struct GeneralSettingReducerTests { + @Test + func importButtonPresentsFileImporter() async { + let store = TestStore(initialState: .init(), reducer: GeneralSettingReducer.init) + + await store.send(.importCustomTranslationsButtonTapped) { + $0.destination = .importTranslations + } + + // Cancelling or picking flips the `isPresented` binding back to false → dismiss. + await store.send(.destination(.dismiss)) { + $0.destination = nil + } + } + + @Test + func filePickedIsForwardedToParentWithoutLocalStateChange() async { + let store = TestStore(initialState: .init(), reducer: GeneralSettingReducer.init) + + // The import itself is handled by `SettingReducer`; the child only relays the URL. + await store.send(.onTranslationsFilePicked(URL(filePath: "/tmp/tags.json"))) + } +} diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1f3aaa8cc..e000364b2 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "88f6a7be23b9d1a44065375d99bfe3ebda31826a9447b3348b6adaa656bcea09", + "originHash" : "53b7494b1e4cf7044dac935df78229a3bbcfdd0047ba2987a18d98334560747c", "pins" : [ { "identity" : "colorful", @@ -28,15 +28,6 @@ "revision" : "021e29675457a9b4b7859a46afbb5d0e37574e84" } }, - { - "identity" : "filepicker", - "kind" : "remoteSourceControl", - "location" : "https://github.com/markrenaud/FilePicker", - "state" : { - "revision" : "720f8cb5ca0c0efc982ed381afc84ba3e8b3214e", - "version" : "1.0.1" - } - }, { "identity" : "kanna", "kind" : "remoteSourceControl", From c51614f20e36c34a776bed10a74c44cc51d73e6a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 22:55:15 +0800 Subject: [PATCH 433/614] Remove FilePicker acknowledgement entry --- .../Sources/Resources/Resources/en.lproj/Constant.strings | 2 -- AppPackage/Sources/Resources/Strings.swift | 4 ---- AppPackage/Sources/SettingFeature/Components/AboutView.swift | 4 ---- 3 files changed, 10 deletions(-) diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings index e03e2962e..e532f72c6 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings @@ -58,7 +58,6 @@ "app.acknowledgement.link.kanna" = "https://github.com/tid-kijyun/Kanna"; "app.acknowledgement.link.swiftGen" = "https://github.com/SwiftGen/SwiftGen"; "app.acknowledgement.link.colorful" = "https://github.com/Co2333/Colorful"; -"app.acknowledgement.link.filePicker" = "https://github.com/markrenaud/FilePicker"; "app.acknowledgement.link.kingfisher" = "https://github.com/onevcat/Kingfisher"; "app.acknowledgement.link.swiftUIPager" = "https://github.com/fermoya/SwiftUIPager"; "app.acknowledgement.link.waterfallGrid" = "https://github.com/paololeonardi/WaterfallGrid"; @@ -75,7 +74,6 @@ "app.acknowledgement.text.kanna" = "Kanna"; "app.acknowledgement.text.swiftGen" = "SwiftGen"; "app.acknowledgement.text.colorful" = "Colorful"; -"app.acknowledgement.text.filePicker" = "FilePicker"; "app.acknowledgement.text.kingfisher" = "Kingfisher"; "app.acknowledgement.text.swiftUIPager" = "SwiftUIPager"; "app.acknowledgement.text.waterfallGrid" = "WaterfallGrid"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 714d6a562..419a73b32 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -20,8 +20,6 @@ public enum L10n { public static let colorful = L10n.tr("Constant", "app.acknowledgement.link.colorful", fallback: "https://github.com/Co2333/Colorful") /// https://github.com/EhTagTranslation/Database public static let ehTagTranslationDatabase = L10n.tr("Constant", "app.acknowledgement.link.ehTagTranslationDatabase", fallback: "https://github.com/EhTagTranslation/Database") - /// https://github.com/markrenaud/FilePicker - public static let filePicker = L10n.tr("Constant", "app.acknowledgement.link.filePicker", fallback: "https://github.com/markrenaud/FilePicker") /// https://github.com/tid-kijyun/Kanna public static let kanna = L10n.tr("Constant", "app.acknowledgement.link.kanna", fallback: "https://github.com/tid-kijyun/Kanna") /// https://github.com/onevcat/Kingfisher @@ -52,8 +50,6 @@ public enum L10n { public static let colorful = L10n.tr("Constant", "app.acknowledgement.text.colorful", fallback: "Colorful") /// EhTagTranslation/Database public static let ehTagTranslationDatabase = L10n.tr("Constant", "app.acknowledgement.text.ehTagTranslationDatabase", fallback: "EhTagTranslation/Database") - /// FilePicker - public static let filePicker = L10n.tr("Constant", "app.acknowledgement.text.filePicker", fallback: "FilePicker") /// Kanna public static let kanna = L10n.tr("Constant", "app.acknowledgement.text.kanna", fallback: "Kanna") /// Kingfisher diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index a633aaa24..814fa3b81 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -158,10 +158,6 @@ struct AboutView: View { urlString: L10n.Constant.App.Acknowledgement.Link.colorful, text: L10n.Constant.App.Acknowledgement.Text.colorful ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.filePicker, - text: L10n.Constant.App.Acknowledgement.Text.filePicker - ), .init( urlString: L10n.Constant.App.Acknowledgement.Link.kingfisher, text: L10n.Constant.App.Acknowledgement.Text.kingfisher From 9d55284bc08d7223cb6242bc518d4abe730c9c4e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 2 Jul 2026 23:59:53 +0800 Subject: [PATCH 434/614] Coordinate fileImporter read for iCloud files --- AppPackage/Package.swift | 10 +++++ .../Sources/FileClient/FileClient.swift | 36 ++++++++++-------- AppPackage/Tests/FeatureTests.xctestplan | 7 ++++ .../Tests/FileClientTests/.swiftlint.yml | 1 + .../FileClientTests/FileClientTests.swift | 38 +++++++++++++++++++ 5 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 AppPackage/Tests/FileClientTests/.swiftlint.yml create mode 100644 AppPackage/Tests/FileClientTests/FileClientTests.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index d4ad2844d..d4cf8d6ba 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -113,6 +113,7 @@ enum Module: String { // Test targets case parserFeatureTests = "ParserFeatureTests" case downloadsFeatureTests = "DownloadsFeatureTests" + case fileClientTests = "FileClientTests" case settingFeatureTests = "SettingFeatureTests" case detailFeatureTests = "DetailFeatureTests" } @@ -941,6 +942,15 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .testTarget( + module: .fileClientTests, + dependencies: [ + .module(.appModels), + .module(.fileClient) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .testTarget( module: .settingFeatureTests, dependencies: [ diff --git a/AppPackage/Sources/FileClient/FileClient.swift b/AppPackage/Sources/FileClient/FileClient.swift index 06ade1004..69071ff49 100644 --- a/AppPackage/Sources/FileClient/FileClient.swift +++ b/AppPackage/Sources/FileClient/FileClient.swift @@ -14,23 +14,29 @@ extension FileClient { }, importTagTranslator: { url in await withCheckedContinuation { continuation in - // `.fileImporter` returns a security-scoped URL to the original file; access must be - // claimed before reading and released afterwards, unlike a copied-in temp file. + // `.fileImporter` returns a security-scoped URL to the original file, which for an + // iCloud item may not be downloaded yet. A coordinated read triggers the download and + // runs the accessor only once the bytes are local. The security scope is released + // inside the accessor: `coordinate(with:queue:)` returns immediately, so a `defer` + // in this outer closure would drop the scope before the accessor ever reads. let didAccess = url.startAccessingSecurityScopedResource() - defer { if didAccess { url.stopAccessingSecurityScopedResource() } } - guard let data = try? Data(contentsOf: url), - let translations = try? JSONDecoder().decode( - EhTagTranslationDatabaseResponse.self, from: data - ).tagTranslations - else { - continuation.resume(returning: .failure(.parseFailed)) - return + let intent = NSFileAccessIntent.readingIntent(with: url, options: .withoutChanges) + NSFileCoordinator().coordinate(with: [intent], queue: .init()) { error in + defer { if didAccess { url.stopAccessingSecurityScopedResource() } } + guard error == nil, + let data = try? Data(contentsOf: intent.url), + let translations = try? JSONDecoder().decode( + EhTagTranslationDatabaseResponse.self, from: data + ).tagTranslations, + !translations.isEmpty + else { + continuation.resume(returning: .failure(.parseFailed)) + return + } + continuation.resume( + returning: .success(.init(hasCustomTranslations: true, translations: translations)) + ) } - guard !translations.isEmpty else { - continuation.resume(returning: .failure(.parseFailed)) - return - } - continuation.resume(returning: .success(.init(hasCustomTranslations: true, translations: translations))) } } ) diff --git a/AppPackage/Tests/FeatureTests.xctestplan b/AppPackage/Tests/FeatureTests.xctestplan index b25e6b434..77339d3a2 100644 --- a/AppPackage/Tests/FeatureTests.xctestplan +++ b/AppPackage/Tests/FeatureTests.xctestplan @@ -26,6 +26,13 @@ "name" : "DownloadsFeatureTests" } }, + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "FileClientTests", + "name" : "FileClientTests" + } + }, { "target" : { "containerPath" : "container:AppPackage", diff --git a/AppPackage/Tests/FileClientTests/.swiftlint.yml b/AppPackage/Tests/FileClientTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/FileClientTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/FileClientTests/FileClientTests.swift b/AppPackage/Tests/FileClientTests/FileClientTests.swift new file mode 100644 index 000000000..b1fde0a3c --- /dev/null +++ b/AppPackage/Tests/FileClientTests/FileClientTests.swift @@ -0,0 +1,38 @@ +import Testing +import Foundation +import AppModels +import FileClient + +// Exercises the live importer's coordinated, security-scoped read (REV-1) against local files; +// the iCloud download that coordination triggers is system behavior, smoke-tested manually. +@Suite +struct FileClientTests { + private func writeTemporaryFile(_ data: Data) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appending(path: "tags-\(UUID().uuidString).json") + try data.write(to: url) + return url + } + + @Test + func importsValidTranslationFileViaCoordinatedRead() async throws { + let response = EhTagTranslationDatabaseResponse( + data: [.init(namespace: "female", data: ["tag": .init(name: "translated")])] + ) + let url = try writeTemporaryFile(JSONEncoder().encode(response)) + defer { try? FileManager.default.removeItem(at: url) } + + let translator = try await FileClient.live.importTagTranslator(url).get() + #expect(translator.hasCustomTranslations) + #expect(translator.translations.count == 1) + } + + @Test + func undecodableFileFailsWithParseFailed() async throws { + let url = try writeTemporaryFile(Data("not json".utf8)) + defer { try? FileManager.default.removeItem(at: url) } + + let result = await FileClient.live.importTagTranslator(url) + #expect(result == .failure(.parseFailed)) + } +} From 353544665e76a548a8124c6003930ed773bf0e5b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 00:03:43 +0800 Subject: [PATCH 435/614] Cover parent tag-translation import intercept --- AppPackage/Package.swift | 1 + .../Sources/FileClient/FileClient.swift | 4 +-- .../GeneralSettingReducerTests.swift | 5 ++-- .../SettingReducerNavigationTests.swift | 25 +++++++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index d4cf8d6ba..31bf63370 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -956,6 +956,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.cookieClient), + .module(.fileClient), .module(.hapticsClient), .module(.logsClient), .module(.settingFeature), diff --git a/AppPackage/Sources/FileClient/FileClient.swift b/AppPackage/Sources/FileClient/FileClient.swift index 69071ff49..c1eb4ec44 100644 --- a/AppPackage/Sources/FileClient/FileClient.swift +++ b/AppPackage/Sources/FileClient/FileClient.swift @@ -3,8 +3,8 @@ import Foundation import ComposableArchitecture public struct FileClient: Sendable { - public let createFile: @Sendable (String, Data?) -> Bool - public let importTagTranslator: @Sendable (URL) async -> Result + public var createFile: @Sendable (String, Data?) -> Bool + public var importTagTranslator: @Sendable (URL) async -> Result } extension FileClient { diff --git a/AppPackage/Tests/SettingFeatureTests/GeneralSettingReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/GeneralSettingReducerTests.swift index 070857f43..4ab9cee4a 100644 --- a/AppPackage/Tests/SettingFeatureTests/GeneralSettingReducerTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/GeneralSettingReducerTests.swift @@ -23,10 +23,11 @@ struct GeneralSettingReducerTests { } @Test - func filePickedIsForwardedToParentWithoutLocalStateChange() async { + func filePickedCausesNoLocalStateChange() async { let store = TestStore(initialState: .init(), reducer: GeneralSettingReducer.init) - // The import itself is handled by `SettingReducer`; the child only relays the URL. + // The child only relays the URL and mutates no local state; the import itself is handled by + // `SettingReducer` (covered by `generalFilePickedImportsAndStoresTagTranslator`). await store.send(.onTranslationsFilePicked(URL(filePath: "/tmp/tags.json"))) } } diff --git a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift index f5ad3784c..5ea2c687d 100644 --- a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift @@ -3,6 +3,7 @@ import Foundation import AppModels import Sharing import CookieClient +import FileClient import HapticsClient @testable import SettingFeature import ComposableArchitecture @@ -116,6 +117,30 @@ struct SettingReducerNavigationTests { } } + // MARK: Child intercepts + + @Test + func generalFilePickedImportsAndStoresTagTranslator() async throws { + let imported = TagTranslator(hasCustomTranslations: true) + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { + $0.fileClient.importTagTranslator = { _ in .success(imported) } + $0.databaseClient = .noop + } + + await store.send(.settingRowTapped(.general)) { + $0.path.append(SettingReducer.RootScreen.general.pathElement) + } + let id = try #require(store.state.path.ids.last) + let url = URL(filePath: "/tmp/tags.json") + await store.send(.path(.element(id: id, action: .general(.onTranslationsFilePicked(url))))) + + // The parent intercept runs `fileClient.importTagTranslator` and stores the result. + await store.receive(\.fetchTagTranslatorDone) { + $0.tagTranslator = imported + } + await store.receive(\.syncTagTranslator) + } + // MARK: Post-login cascade @Test From 9f45036421ab3c3297c2506867c4ec95c4d09426 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 01:01:50 +0800 Subject: [PATCH 436/614] Add SystemNotificationExt glass toast module --- AppPackage/Package.swift | 11 ++ .../SystemNotificationExt/.swiftlint.yml | 1 + .../ToastMessageView.swift | 100 ++++++++++++++++++ .../SystemNotificationExt/View+Toast.swift | 72 +++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 AppPackage/Sources/SystemNotificationExt/.swiftlint.yml create mode 100644 AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift create mode 100644 AppPackage/Sources/SystemNotificationExt/View+Toast.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 31bf63370..44bda555e 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -102,6 +102,7 @@ enum Module: String { case resources = "Resources" case searchFeature = "SearchFeature" case settingFeature = "SettingFeature" + case systemNotificationExt = "SystemNotificationExt" case ttProgressHUDExt = "TTProgressHUDExt" case tagTranslationFeature = "TagTranslationFeature" case urlClient = "URLClient" @@ -370,6 +371,16 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .systemNotificationExt, + dependencies: [ + .module(.appComponents), + .targetDependency(.composableArchitecture), + .targetDependency(.sfSafeSymbols) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .appTools, dependencies: [], diff --git a/AppPackage/Sources/SystemNotificationExt/.swiftlint.yml b/AppPackage/Sources/SystemNotificationExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/SystemNotificationExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift b/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift new file mode 100644 index 000000000..a80aae8ff --- /dev/null +++ b/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift @@ -0,0 +1,100 @@ +// +// ToastMessageView.swift +// SystemNotificationExt +// +// The Liquid Glass capsule shown by `View.toast(_:)`. The layout adapts SystemNotificationMessage +// (MIT, https://github.com/danielsaidi/SystemNotification): a leading symbol, a one-line bold title +// over an optional one-line subtitle, and a hidden trailing symbol that mirrors the leading one so +// the text stays optically centered. The capsule is pure Liquid Glass with nothing behind it — +// layering glass over a Material would render it opaque. +// + +import SwiftUI +import SFSafeSymbols +import AppComponents +import ComposableArchitecture + +/// The rendered content of a toast, mapped from ``AppAlertState`` by ``AppAlertState/toastContent``. +struct ToastContent: Equatable { + enum Icon: Equatable { + case loading, success, error + } + + var icon: Icon + var title: String + var subtitle: String? + var autoHide: Bool +} + +struct ToastMessageView: View { + let content: ToastContent + + var body: some View { + HStack(spacing: 16) { + icon + text + icon.hidden() + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .glassEffect(.regular, in: .capsule) + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var icon: some View { + switch content.icon { + case .loading: + ProgressView() + case .success: + Image(systemSymbol: .checkmarkCircle) + .font(.title3) + .foregroundStyle(.green) + case .error: + Image(systemSymbol: .exclamationmarkTriangle) + .font(.title3) + .foregroundStyle(.red) + } + } + + private var text: some View { + VStack(spacing: 2) { + Text(content.title) + .font(.footnote.bold()) + .foregroundStyle(.primary) + if let subtitle = content.subtitle { + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .lineLimit(1) + } +} + +extension AppAlertState where Action == Never { + /// Maps the unified presentation state onto renderable toast content. The `.alert` style never + /// reaches a `toast` binding, so it degrades to a plain loading spinner defensively. + var toastContent: ToastContent { + let icon: ToastContent.Icon + let autoHide: Bool + switch style { + case .alert: + icon = .loading + autoHide = false + case let .hud(hudIcon, shouldAutoHide): + autoHide = shouldAutoHide + switch hudIcon { + case .loading: icon = .loading + case .success: icon = .success + case .error: icon = .error + } + } + return .init( + icon: icon, + title: String(state: title), + subtitle: message.map { String(state: $0) }, + autoHide: autoHide + ) + } +} diff --git a/AppPackage/Sources/SystemNotificationExt/View+Toast.swift b/AppPackage/Sources/SystemNotificationExt/View+Toast.swift new file mode 100644 index 000000000..1922c88b9 --- /dev/null +++ b/AppPackage/Sources/SystemNotificationExt/View+Toast.swift @@ -0,0 +1,72 @@ +// +// View+Toast.swift +// SystemNotificationExt +// +// Presents a bottom-anchored Liquid Glass toast, driven by `AppAlertState` presentation state. +// The presentation model is adapted from Daniel Saidi's SystemNotification (MIT-licensed): +// https://github.com/danielsaidi/SystemNotification — reduced to a single bottom edge and rebuilt +// on TCA presentation state and SwiftUI's Liquid Glass (`glassEffect`) instead of a Material chrome. +// + +import SwiftUI +import AppComponents +import ComposableArchitecture + +extension View { + /// Overlays a bottom-anchored Liquid Glass toast driven by presentation state, mirroring + /// ``SwiftUICore/View/appAlert(_:)``. A non-`nil` store presents the toast; auto-hiding toasts + /// dismiss themselves after a short delay, and a downward swipe dismisses an auto-hiding toast + /// early. Both paths clear the presentation binding, sending `.dismiss` through the store. + /// + /// Drive it with a presented store scope, exactly like `appAlert`: + /// + /// ```swift + /// .toast($store.scope(state: \.toast, action: \.toast)) + /// ``` + @MainActor + public func toast( + _ item: Binding, Never>?> + ) -> some View { + modifier(ToastViewModifier(item: item)) + } +} + +private struct ToastViewModifier: ViewModifier { + @Binding var item: Store, Never>? + + func body(content: Content) -> some View { + content.overlay(alignment: .bottom) { + if let store = item { + let toast = store.toastContent + // SwiftUI keeps this conditional child alive through its removal transition, so the + // last content stays visible while the toast slides back off-screen — no manual hold. + ToastMessageView(content: toast) + .padding(.horizontal) + .padding(.bottom) + .gesture(dismissGesture(autoHide: toast.autoHide)) + .task(id: store.id) { await autoDismiss(toast) } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.bouncy, value: item != nil) + } + + // Only auto-hiding toasts (success / error) can be flicked away; a loading toast stays until + // its reducer clears the state, so a downward drag on it is ignored. + private func dismissGesture(autoHide: Bool) -> some Gesture { + DragGesture(minimumDistance: 20) + .onEnded { value in + guard autoHide, value.translation.height > 0 else { return } + item = nil + } + } + + private func autoDismiss(_ toast: ToastContent) async { + guard toast.autoHide else { return } + try? await Task.sleep(for: .seconds(3)) + // The task is cancelled when the toast is replaced or dismissed; only a timer that ran to + // completion should clear the state, so bail out on cancellation. + guard !Task.isCancelled else { return } + item = nil + } +} From 140ab25c790f4843ad047aac0bdf7f456f9e3ed1 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 01:10:59 +0800 Subject: [PATCH 437/614] Rename HUD style and L10n keys to toast --- .../Sources/AppComponents/AppAlertState.swift | 40 ++++++++-------- .../DownloadInspectorReducer.swift | 8 ++-- .../Resources/de.lproj/Localizable.strings | 16 +++---- .../Resources/en.lproj/Localizable.strings | 16 +++---- .../Resources/ja.lproj/Localizable.strings | 16 +++---- .../Resources/ko.lproj/Localizable.strings | 16 +++---- .../zh-Hans.lproj/Localizable.strings | 16 +++---- .../zh-Hant-HK.lproj/Localizable.strings | 16 +++---- .../zh-Hant-TW.lproj/Localizable.strings | 16 +++---- .../zh-Hant.lproj/Localizable.strings | 16 +++---- AppPackage/Sources/Resources/Strings.swift | 48 +++++++++---------- .../ToastMessageView.swift | 4 +- .../TTProgressHUDExt/View+ProgressHUD.swift | 2 +- .../DownloadInspectorLoadTests.swift | 2 +- 14 files changed, 116 insertions(+), 116 deletions(-) diff --git a/AppPackage/Sources/AppComponents/AppAlertState.swift b/AppPackage/Sources/AppComponents/AppAlertState.swift index a4ef71b94..e212bd1ea 100644 --- a/AppPackage/Sources/AppComponents/AppAlertState.swift +++ b/AppPackage/Sources/AppComponents/AppAlertState.swift @@ -2,13 +2,13 @@ import SwiftUI import Resources import ComposableArchitecture -/// The app's single presentation-state type. It backs both button dialogs and progress HUDs, so a +/// The app's single presentation-state type. It backs both button dialogs and toasts, so a /// feature models any transient presentation with one `@ObservableState` value: /// /// - ``Style/alert`` renders through ``SwiftUICore/View/appAlert(_:)`` as a **native** system alert, /// with `buttons` wired to your reducer's actions (mirroring `AlertState`'s ergonomics). -/// - ``Style/hud(icon:autoHide:)`` renders through `View.progressHUD(_:)` (in `TTProgressHUDExt`) as -/// a `TTProgressHUD` toast; the button-less HUD factories live on `AppAlertState`. +/// - ``Style/toast(icon:autoHide:)`` renders through `View.toast(_:)` (in `SystemNotificationExt`) +/// as a bottom Liquid Glass toast; the button-less toast factories live on `AppAlertState`. /// /// Its alert initializer mirrors `AlertState` exactly — a `title`, a `ButtonStateBuilder` of /// `actions`, and an optional `message` — so migrating an alert call site is a pure rename. It reuses @@ -20,11 +20,11 @@ public struct AppAlertState: Identifiable { /// How a value is presented. See ``AppAlertState`` for which modifier renders each style. public enum Style: Equatable, Hashable, Sendable { case alert - case hud(icon: HUDIcon, autoHide: Bool) + case toast(icon: ToastIcon, autoHide: Bool) } - /// The glyph a ``Style/hud(icon:autoHide:)`` presentation shows; maps to a `TTProgressHUDType`. - public enum HUDIcon: Equatable, Hashable, Sendable { + /// The glyph a ``Style/toast(icon:autoHide:)`` presentation shows. + public enum ToastIcon: Equatable, Hashable, Sendable { case loading, success, error } @@ -59,7 +59,7 @@ public struct AppAlertState: Identifiable { self.textField = textField } - // Builds a button-less HUD presentation; used by the `Action == Never` factories below. + // Builds a button-less toast presentation; used by the `Action == Never` factories below. init(style: Style, title: TextState, message: TextState? = nil) { self.id = UUID() self.style = style @@ -118,41 +118,41 @@ public struct AppAlertTextFieldState: Equatable, Hashable, Sendable { } } -// MARK: - HUD presentations -// These mirror the old `ProgressHUDConfigState` cases one-for-one, so migrating a HUD assignment is a -// pure type change; `TTProgressHUDExt` maps `HUDIcon` + `title`/`message` onto a `TTProgressHUDConfig`. +// MARK: - Toast presentations +// Button-less toast presentations. `SystemNotificationExt` maps `ToastIcon` + `title`/`message` +// onto the rendered Liquid Glass toast content. extension AppAlertState where Action == Never { public static func loading(title: String? = nil) -> Self { .init( - style: .hud(icon: .loading, autoHide: false), - title: TextState(title ?? L10n.Localizable.Hud.Title.loading) + style: .toast(icon: .loading, autoHide: false), + title: TextState(title ?? L10n.Localizable.Toast.Title.loading) ) } public static var communicating: Self { .init( - style: .hud(icon: .loading, autoHide: false), - title: TextState(L10n.Localizable.Hud.Title.communicating) + style: .toast(icon: .loading, autoHide: false), + title: TextState(L10n.Localizable.Toast.Title.communicating) ) } public static func error(caption: String? = nil) -> Self { .init( - style: .hud(icon: .error, autoHide: true), - title: TextState(L10n.Localizable.Hud.Title.error), + style: .toast(icon: .error, autoHide: true), + title: TextState(L10n.Localizable.Toast.Title.error), message: caption.map { TextState($0) } ) } public static func success(caption: String? = nil) -> Self { .init( - style: .hud(icon: .success, autoHide: true), - title: TextState(L10n.Localizable.Hud.Title.success), + style: .toast(icon: .success, autoHide: true), + title: TextState(L10n.Localizable.Toast.Title.success), message: caption.map { TextState($0) } ) } public static var savedToPhotoLibrary: Self { - .success(caption: L10n.Localizable.Hud.Caption.savedToPhotoLibrary) + .success(caption: L10n.Localizable.Toast.Caption.savedToPhotoLibrary) } public static var copiedToClipboardSucceeded: Self { - .success(caption: L10n.Localizable.Hud.Caption.copiedToClipboard) + .success(caption: L10n.Localizable.Toast.Caption.copiedToClipboard) } } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift index 5bb9d528a..e3a36ee44 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift @@ -201,7 +201,7 @@ public struct DownloadInspectorReducer: Sendable { case .validateImageDataDone(let validation): state.isValidatingImageData = false - state.hud = validation.hudConfig + state.hud = validation.toastConfig return .send(.loadInspection) } } @@ -209,11 +209,11 @@ public struct DownloadInspectorReducer: Sendable { } private extension Optional where Wrapped == DownloadValidationState { - var hudConfig: AppAlertState { + var toastConfig: AppAlertState { switch self { case .some(.valid): return .success( - caption: L10n.Localizable.DownloadsView.Inspector.Hud.imageDataValid + caption: L10n.Localizable.DownloadsView.Inspector.Toast.imageDataValid ) case .some(.missingFiles(let message)): @@ -221,7 +221,7 @@ private extension Optional where Wrapped == DownloadValidationState { case nil: return .error( - caption: L10n.Localizable.DownloadsView.Inspector.Hud.imageDataUnavailable + caption: L10n.Localizable.DownloadsView.Inspector.Toast.imageDataUnavailable ) } } diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index b146ca120..e04eaefcb 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -13,12 +13,12 @@ "website.response.invalid_resolution" = "Die gewünschte Galerie kann nicht in der gewählten Auflösung heruntergeladen werden"; // MARK: HUD -"hud.title.error" = "Fehler"; -"hud.title.success" = "Erfolg"; -"hud.title.loading" = "Wird geladen..."; -"hud.title.communicating" = "Verbinde..."; -"hud.caption.copied_to_clipboard" = "In Zwischenablage kopiert"; -"hud.caption.saved_to_photo_library" = "Saved to photo library"; +"toast.title.error" = "Fehler"; +"toast.title.success" = "Erfolg"; +"toast.title.loading" = "Wird geladen..."; +"toast.title.communicating" = "Verbinde..."; +"toast.caption.copied_to_clipboard" = "In Zwischenablage kopiert"; +"toast.caption.saved_to_photo_library" = "Saved to photo library"; // MARK: AutoLock "local_authorization.reason" = "Die App hat sich selbst gesperrt, da der auto-lock Zeitraum abgelaufen ist."; @@ -429,8 +429,8 @@ "downloads_view.inspector.button.retry_failed_pages" = "Fehlgeschlagene Seiten erneut versuchen"; "downloads_view.inspector.button.validating_image_data" = "Bilddaten werden geprüft..."; "downloads_view.inspector.button.update_download" = "Download aktualisieren"; -"downloads_view.inspector.hud.image_data_valid" = "Bilddaten sind gültig"; -"downloads_view.inspector.hud.image_data_unavailable" = "Bilddaten konnten nicht geprüft werden."; +"downloads_view.inspector.toast.image_data_valid" = "Bilddaten sind gültig"; +"downloads_view.inspector.toast.image_data_unavailable" = "Bilddaten konnten nicht geprüft werden."; "downloads_view.inspector.title.download_status" = "Downloadstatus"; "downloads_view.inspector.page.pending" = "Ausstehend"; "downloads_view.inspector.page.tap_to_retry" = "Tippen, um diese Seite erneut zu versuchen"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 56b5fda1c..6a4a08a1a 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -13,12 +13,12 @@ "website.response.invalid_resolution" = "The requested gallery cannot be downloaded with the selected resolution."; // MARK: HUD -"hud.title.error" = "Error"; -"hud.title.success" = "Success"; -"hud.title.loading" = "Loading..."; -"hud.title.communicating" = "Communicating..."; -"hud.caption.copied_to_clipboard" = "Copied to clipboard"; -"hud.caption.saved_to_photo_library" = "Saved to photo library"; +"toast.title.error" = "Error"; +"toast.title.success" = "Success"; +"toast.title.loading" = "Loading..."; +"toast.title.communicating" = "Communicating..."; +"toast.caption.copied_to_clipboard" = "Copied to clipboard"; +"toast.caption.saved_to_photo_library" = "Saved to photo library"; // MARK: AutoLock "local_authorization.reason" = "The App has been locked due to the Auto-Lock expiration."; @@ -429,8 +429,8 @@ "downloads_view.inspector.button.retry_failed_pages" = "Retry Failed Pages"; "downloads_view.inspector.button.validating_image_data" = "Validating Image Data..."; "downloads_view.inspector.button.update_download" = "Update Download"; -"downloads_view.inspector.hud.image_data_valid" = "Image data is valid"; -"downloads_view.inspector.hud.image_data_unavailable" = "Image data could not be validated."; +"downloads_view.inspector.toast.image_data_valid" = "Image data is valid"; +"downloads_view.inspector.toast.image_data_unavailable" = "Image data could not be validated."; "downloads_view.inspector.title.download_status" = "Download Status"; "downloads_view.inspector.page.pending" = "Pending"; "downloads_view.inspector.page.tap_to_retry" = "Tap to retry this page"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index e9359406a..bada45be1 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -13,12 +13,12 @@ "website.response.invalid_resolution" = "このギャラリーは選択された解像度ではダウンロードできません"; // MARK: HUD -"hud.title.error" = "エラー"; -"hud.title.success" = "成功"; -"hud.title.loading" = "読み込み中..."; -"hud.title.communicating" = "通信中..."; -"hud.caption.copied_to_clipboard" = "クリップボードにコピーしました"; -"hud.caption.saved_to_photo_library" = "ライブラリに保存しました"; +"toast.title.error" = "エラー"; +"toast.title.success" = "成功"; +"toast.title.loading" = "読み込み中..."; +"toast.title.communicating" = "通信中..."; +"toast.caption.copied_to_clipboard" = "クリップボードにコピーしました"; +"toast.caption.saved_to_photo_library" = "ライブラリに保存しました"; // MARK: AutoLock "local_authorization.reason" = "自動ロック期限が切れたため、アプリがロックされています"; @@ -429,8 +429,8 @@ "downloads_view.inspector.button.retry_failed_pages" = "失敗したページを再試行"; "downloads_view.inspector.button.validating_image_data" = "画像データを検証中..."; "downloads_view.inspector.button.update_download" = "ダウンロードを更新"; -"downloads_view.inspector.hud.image_data_valid" = "画像データは有効です"; -"downloads_view.inspector.hud.image_data_unavailable" = "画像データを検証できませんでした。"; +"downloads_view.inspector.toast.image_data_valid" = "画像データは有効です"; +"downloads_view.inspector.toast.image_data_unavailable" = "画像データを検証できませんでした。"; "downloads_view.inspector.title.download_status" = "ダウンロード状況"; "downloads_view.inspector.page.pending" = "待機中"; "downloads_view.inspector.page.tap_to_retry" = "タップしてこのページを再試行"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index 1398f3c72..f395992d3 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -13,12 +13,12 @@ "website.response.invalid_resolution" = "이 콘텐츠는 선택한 해상도로 다운로드할 수 없어요."; // MARK: HUD -"hud.title.error" = "실패"; -"hud.title.success" = "성공"; -"hud.title.loading" = "로딩 중..."; -"hud.title.communicating" = "접속 중..."; -"hud.caption.copied_to_clipboard" = "클립보드에 복사되었어요"; -"hud.caption.saved_to_photo_library" = "이미지 저장"; +"toast.title.error" = "실패"; +"toast.title.success" = "성공"; +"toast.title.loading" = "로딩 중..."; +"toast.title.communicating" = "접속 중..."; +"toast.caption.copied_to_clipboard" = "클립보드에 복사되었어요"; +"toast.caption.saved_to_photo_library" = "이미지 저장"; // MARK: AutoLock "local_authorization.reason" = "자동 잠금으로 앱이 잠겼어요."; @@ -429,8 +429,8 @@ "downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도"; "downloads_view.inspector.button.validating_image_data" = "이미지 데이터 검증 중..."; "downloads_view.inspector.button.update_download" = "다운로드 업데이트"; -"downloads_view.inspector.hud.image_data_valid" = "이미지 데이터가 유효합니다"; -"downloads_view.inspector.hud.image_data_unavailable" = "이미지 데이터를 검증할 수 없습니다."; +"downloads_view.inspector.toast.image_data_valid" = "이미지 데이터가 유효합니다"; +"downloads_view.inspector.toast.image_data_unavailable" = "이미지 데이터를 검증할 수 없습니다."; "downloads_view.inspector.title.download_status" = "다운로드 상태"; "downloads_view.inspector.page.pending" = "대기 중"; "downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 0265c3b1c..7cc1a94d4 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -13,12 +13,12 @@ "website.response.invalid_resolution" = "该画廊不能以选中的分辨率下载"; // MARK: HUD -"hud.title.error" = "错误"; -"hud.title.success" = "成功"; -"hud.title.loading" = "加载中..."; -"hud.title.communicating" = "通信中..."; -"hud.caption.copied_to_clipboard" = "已复制到剪切板"; -"hud.caption.saved_to_photo_library" = "已保存到图库"; +"toast.title.error" = "错误"; +"toast.title.success" = "成功"; +"toast.title.loading" = "加载中..."; +"toast.title.communicating" = "通信中..."; +"toast.caption.copied_to_clipboard" = "已复制到剪切板"; +"toast.caption.saved_to_photo_library" = "已保存到图库"; // MARK: AutoLock "local_authorization.reason" = "因超过设置的自动锁定期限,App 已被锁定"; @@ -429,8 +429,8 @@ "downloads_view.inspector.button.retry_failed_pages" = "重试失败页面"; "downloads_view.inspector.button.validating_image_data" = "正在验证图像数据..."; "downloads_view.inspector.button.update_download" = "更新下载"; -"downloads_view.inspector.hud.image_data_valid" = "图像数据有效"; -"downloads_view.inspector.hud.image_data_unavailable" = "无法验证图像数据。"; +"downloads_view.inspector.toast.image_data_valid" = "图像数据有效"; +"downloads_view.inspector.toast.image_data_unavailable" = "无法验证图像数据。"; "downloads_view.inspector.title.download_status" = "下载状态"; "downloads_view.inspector.page.pending" = "等待中"; "downloads_view.inspector.page.tap_to_retry" = "点按以重试此页"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings index 5127cda45..2768202e3 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings @@ -13,12 +13,12 @@ "website.response.invalid_resolution" = "該畫廊不能以目前選擇的解像度下載"; // MARK: HUD -"hud.title.error" = "錯誤"; -"hud.title.success" = "成功"; -"hud.title.loading" = "載入中..."; -"hud.title.communicating" = "連線中..."; -"hud.caption.copied_to_clipboard" = "已複製到剪貼簿"; -"hud.caption.saved_to_photo_library" = "已儲存到照片"; +"toast.title.error" = "錯誤"; +"toast.title.success" = "成功"; +"toast.title.loading" = "載入中..."; +"toast.title.communicating" = "連線中..."; +"toast.caption.copied_to_clipboard" = "已複製到剪貼簿"; +"toast.caption.saved_to_photo_library" = "已儲存到照片"; // MARK: AutoLock "local_authorization.reason" = "由於超過 APP 自動鎖定期限,APP 已被鎖定,請重新解鎖"; @@ -429,8 +429,8 @@ "downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; "downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; "downloads_view.inspector.button.update_download" = "更新下載"; -"downloads_view.inspector.hud.image_data_valid" = "圖片資料有效"; -"downloads_view.inspector.hud.image_data_unavailable" = "無法驗證圖片資料。"; +"downloads_view.inspector.toast.image_data_valid" = "圖片資料有效"; +"downloads_view.inspector.toast.image_data_unavailable" = "無法驗證圖片資料。"; "downloads_view.inspector.title.download_status" = "下載狀態"; "downloads_view.inspector.page.pending" = "等待中"; "downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings index 8b8e12079..71edb4167 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings @@ -13,12 +13,12 @@ "website.response.invalid_resolution" = "該畫廊不能以目前選擇的解析度下載"; // MARK: HUD -"hud.title.error" = "錯誤"; -"hud.title.success" = "成功"; -"hud.title.loading" = "載入中..."; -"hud.title.communicating" = "連線中..."; -"hud.caption.copied_to_clipboard" = "已複製到剪貼簿"; -"hud.caption.saved_to_photo_library" = "已儲存到照片"; +"toast.title.error" = "錯誤"; +"toast.title.success" = "成功"; +"toast.title.loading" = "載入中..."; +"toast.title.communicating" = "連線中..."; +"toast.caption.copied_to_clipboard" = "已複製到剪貼簿"; +"toast.caption.saved_to_photo_library" = "已儲存到照片"; // MARK: AutoLock "local_authorization.reason" = "由於超過 APP 自動鎖定期限,APP 已被鎖定,請重新解鎖"; @@ -429,8 +429,8 @@ "downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; "downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; "downloads_view.inspector.button.update_download" = "更新下載"; -"downloads_view.inspector.hud.image_data_valid" = "圖片資料有效"; -"downloads_view.inspector.hud.image_data_unavailable" = "無法驗證圖片資料。"; +"downloads_view.inspector.toast.image_data_valid" = "圖片資料有效"; +"downloads_view.inspector.toast.image_data_unavailable" = "無法驗證圖片資料。"; "downloads_view.inspector.title.download_status" = "下載狀態"; "downloads_view.inspector.page.pending" = "等待中"; "downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 1ff341466..b62e9116f 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -13,12 +13,12 @@ "website.response.invalid_resolution" = "該畫廊不能以目前選擇的解析度下載"; // MARK: HUD -"hud.title.error" = "錯誤"; -"hud.title.success" = "成功"; -"hud.title.loading" = "載入中..."; -"hud.title.communicating" = "連線中..."; -"hud.caption.copied_to_clipboard" = "已複製到剪貼簿"; -"hud.caption.saved_to_photo_library" = "已儲存到照片"; +"toast.title.error" = "錯誤"; +"toast.title.success" = "成功"; +"toast.title.loading" = "載入中..."; +"toast.title.communicating" = "連線中..."; +"toast.caption.copied_to_clipboard" = "已複製到剪貼簿"; +"toast.caption.saved_to_photo_library" = "已儲存到照片"; // MARK: AutoLock "local_authorization.reason" = "由於超過 APP 自動鎖定期限,APP 已被鎖定,請重新解鎖"; @@ -429,8 +429,8 @@ "downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; "downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; "downloads_view.inspector.button.update_download" = "更新下載"; -"downloads_view.inspector.hud.image_data_valid" = "圖片資料有效"; -"downloads_view.inspector.hud.image_data_unavailable" = "無法驗證圖片資料。"; +"downloads_view.inspector.toast.image_data_valid" = "圖片資料有效"; +"downloads_view.inspector.toast.image_data_unavailable" = "無法驗證圖片資料。"; "downloads_view.inspector.title.download_status" = "下載狀態"; "downloads_view.inspector.page.pending" = "等待中"; "downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 419a73b32..c38baae9c 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -728,12 +728,6 @@ public enum L10n { /// Validating Image Data... public static let validatingImageData = L10n.tr("Localizable", "downloads_view.inspector.button.validating_image_data", fallback: "Validating Image Data...") } - public enum Hud { - /// Image data could not be validated. - public static let imageDataUnavailable = L10n.tr("Localizable", "downloads_view.inspector.hud.image_data_unavailable", fallback: "Image data could not be validated.") - /// Image data is valid - public static let imageDataValid = L10n.tr("Localizable", "downloads_view.inspector.hud.image_data_valid", fallback: "Image data is valid") - } public enum Page { /// No pages public static let `none` = L10n.tr("Localizable", "downloads_view.inspector.page.none", fallback: "No pages") @@ -764,6 +758,12 @@ public enum L10n { /// Download Status public static let downloadStatus = L10n.tr("Localizable", "downloads_view.inspector.title.download_status", fallback: "Download Status") } + public enum Toast { + /// Image data could not be validated. + public static let imageDataUnavailable = L10n.tr("Localizable", "downloads_view.inspector.toast.image_data_unavailable", fallback: "Image data could not be validated.") + /// Image data is valid + public static let imageDataValid = L10n.tr("Localizable", "downloads_view.inspector.toast.image_data_valid", fallback: "Image data is valid") + } } public enum Menu { public enum Button { @@ -2241,24 +2241,6 @@ public enum L10n { public static let home = L10n.tr("Localizable", "home_view.title.home", fallback: "Home") } } - public enum Hud { - public enum Caption { - /// Copied to clipboard - public static let copiedToClipboard = L10n.tr("Localizable", "hud.caption.copied_to_clipboard", fallback: "Copied to clipboard") - /// Saved to photo library - public static let savedToPhotoLibrary = L10n.tr("Localizable", "hud.caption.saved_to_photo_library", fallback: "Saved to photo library") - } - public enum Title { - /// Communicating... - public static let communicating = L10n.tr("Localizable", "hud.title.communicating", fallback: "Communicating...") - /// Error - public static let error = L10n.tr("Localizable", "hud.title.error", fallback: "Error") - /// Loading... - public static let loading = L10n.tr("Localizable", "hud.title.loading", fallback: "Loading...") - /// Success - public static let success = L10n.tr("Localizable", "hud.title.success", fallback: "Success") - } - } public enum JumpPageView { public enum Button { /// Confirm @@ -2547,6 +2529,24 @@ public enum L10n { } } } + public enum Toast { + public enum Caption { + /// Copied to clipboard + public static let copiedToClipboard = L10n.tr("Localizable", "toast.caption.copied_to_clipboard", fallback: "Copied to clipboard") + /// Saved to photo library + public static let savedToPhotoLibrary = L10n.tr("Localizable", "toast.caption.saved_to_photo_library", fallback: "Saved to photo library") + } + public enum Title { + /// Communicating... + public static let communicating = L10n.tr("Localizable", "toast.title.communicating", fallback: "Communicating...") + /// Error + public static let error = L10n.tr("Localizable", "toast.title.error", fallback: "Error") + /// Loading... + public static let loading = L10n.tr("Localizable", "toast.title.loading", fallback: "Loading...") + /// Success + public static let success = L10n.tr("Localizable", "toast.title.success", fallback: "Success") + } + } public enum ToolbarItem { public enum Button { /// Seek to date diff --git a/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift b/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift index a80aae8ff..c1ec02010 100644 --- a/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift +++ b/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift @@ -82,9 +82,9 @@ extension AppAlertState where Action == Never { case .alert: icon = .loading autoHide = false - case let .hud(hudIcon, shouldAutoHide): + case let .toast(toastIcon, shouldAutoHide): autoHide = shouldAutoHide - switch hudIcon { + switch toastIcon { case .loading: icon = .loading case .success: icon = .success case .error: icon = .error diff --git a/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift b/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift index 0c07ec6c6..f490587f8 100644 --- a/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift +++ b/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift @@ -52,7 +52,7 @@ private extension AppAlertState where Action == Never { case .alert: type = .loading autoHide = false - case let .hud(icon, shouldAutoHide): + case let .toast(icon, shouldAutoHide): autoHide = shouldAutoHide switch icon { case .loading: type = .loading diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift index e27a9d60a..00426f1df 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift @@ -156,7 +156,7 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { await store.receive(\.validateImageDataDone) { $0.isValidatingImageData = false $0.hud = .success( - caption: L10n.Localizable.DownloadsView.Inspector.Hud.imageDataValid + caption: L10n.Localizable.DownloadsView.Inspector.Toast.imageDataValid ) } await store.receive(\.loadInspection) From 75fedcfe057a7b956c76de2d506d243dc9efb1a8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 01:25:52 +0800 Subject: [PATCH 438/614] Adopt .toast presentation, remove TTProgressHUD --- AppPackage/Package.swift | 28 ++------ .../AppFeature/DataFlow/AppRouteReducer.swift | 21 +++--- .../AppFeature/View/TabBar/TabBarView.swift | 4 +- .../Archives/ArchivesReducer.swift | 21 +++--- .../DetailFeature/Archives/ArchivesView.swift | 4 +- .../Comments/CommentsReducer.swift | 21 +++--- .../DetailFeature/Comments/CommentsView.swift | 4 +- .../GalleryInfos/GalleryInfosReducer.swift | 9 ++- .../GalleryInfos/GalleryInfosView.swift | 4 +- .../Torrents/TorrentsReducer.swift | 9 ++- .../DetailFeature/Torrents/TorrentsView.swift | 4 +- .../DownloadInspectorReducer.swift | 9 ++- .../DownloadsFeature/DownloadsReducer.swift | 2 +- .../DownloadsView+Subviews.swift | 4 +- .../ReadingFeature/ReadingReducer+Body.swift | 7 +- .../ReadingFeature/ReadingReducer.swift | 3 +- .../Sources/ReadingFeature/ReadingView.swift | 4 +- .../AccountSettingReducer.swift | 9 ++- .../AccountSetting/AccountSettingView.swift | 4 +- .../Sources/TTProgressHUDExt/.swiftlint.yml | 1 - .../TTProgressHUDExt/View+ProgressHUD.swift | 71 ------------------- .../DownloadInspectorLoadTests.swift | 6 +- .../xcshareddata/swiftpm/Package.resolved | 11 +-- 23 files changed, 100 insertions(+), 160 deletions(-) delete mode 100644 AppPackage/Sources/TTProgressHUDExt/.swiftlint.yml delete mode 100644 AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 44bda555e..2b65c747d 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -7,7 +7,6 @@ var dependencies: [PackageDescription.Package.Dependency] = [ // Pinned to match the app's resolved version; 1.1.x deprecates ColorfulView. .package(url: "https://github.com/Co2333/Colorful", .upToNextMinor(from: "1.0.1")), .package(url: "https://github.com/EhPanda-Team/DeprecatedAPI", branch: "main"), - .package(url: "https://github.com/EhPanda-Team/TTProgressHUD", branch: "custom"), .package(url: "https://github.com/SDWebImage/SDWebImageSwiftUI", from: "3.0.0"), .package(url: "https://github.com/SDWebImage/SDWebImageWebPCoder", from: "0.14.0"), .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "7.0.0"), @@ -44,7 +43,6 @@ extension PackageDescription.Target.Dependency { static let sfSafeSymbols: Self = .product(name: "SFSafeSymbols", package: "SFSafeSymbols") static let sharing: Self = .product(name: "Sharing", package: "swift-sharing") static let swiftUIPager: Self = .product(name: "SwiftUIPager", package: "SwiftUIPager") - static let ttProgressHUD: Self = .product(name: "TTProgressHUD", package: "TTProgressHUD") static let uiImageColors: Self = .product(name: "UIImageColors", package: "UIImageColors") static let waterfallGrid: Self = .product(name: "WaterfallGrid", package: "WaterfallGrid") } @@ -103,7 +101,6 @@ enum Module: String { case searchFeature = "SearchFeature" case settingFeature = "SettingFeature" case systemNotificationExt = "SystemNotificationExt" - case ttProgressHUDExt = "TTProgressHUDExt" case tagTranslationFeature = "TagTranslationFeature" case urlClient = "URLClient" case userDefaultsClient = "UserDefaultsClient" @@ -284,7 +281,7 @@ let targets: [PackageDescription.Target] = [ .module(.searchFeature), .module(.animatedImageFeature), .module(.settingFeature), - .module(.ttProgressHUDExt), + .module(.systemNotificationExt), .module(.urlClient), .module(.userDefaultsClient), .targetDependency(.colorful), @@ -298,7 +295,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sdWebImageWebPCoder), .targetDependency(.sfSafeSymbols), .targetDependency(.swiftUIPager), - .targetDependency(.ttProgressHUD), .targetDependency(.uiImageColors), .targetDependency(.waterfallGrid) ], @@ -360,17 +356,6 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), - .target( - module: .ttProgressHUDExt, - dependencies: [ - .module(.appComponents), - .module(.resources), - .targetDependency(.composableArchitecture), - .targetDependency(.ttProgressHUD) - ], - swiftSettings: sharedSwiftSettings, - plugins: swiftLintPlugins - ), .target( module: .systemNotificationExt, dependencies: [ @@ -647,7 +632,7 @@ let targets: [PackageDescription.Target] = [ .module(.galleryListComponents), .module(.readingFeature), .module(.resources), - .module(.ttProgressHUDExt), + .module(.systemNotificationExt), .module(.tagTranslationFeature), .targetDependency(.composableArchitecture), .targetDependency(.sfSafeSymbols) @@ -699,7 +684,7 @@ let targets: [PackageDescription.Target] = [ .module(.osLogExt), .module(.readingSettingFeature), .module(.resources), - .module(.ttProgressHUDExt), + .module(.systemNotificationExt), .module(.userDefaultsClient), .targetDependency(.composableArchitecture), .targetDependency(.sfSafeSymbols), @@ -782,7 +767,7 @@ let targets: [PackageDescription.Target] = [ .module(.quickSearchFeature), .module(.readingFeature), .module(.resources), - .module(.ttProgressHUDExt), + .module(.systemNotificationExt), .module(.tagTranslationFeature), .module(.urlClient), .targetDependency(.commonMark), @@ -812,14 +797,13 @@ let targets: [PackageDescription.Target] = [ .module(.readingSettingFeature), .module(.resources), .module(.animatedImageFeature), - .module(.ttProgressHUDExt), + .module(.systemNotificationExt), .module(.urlClient), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), .targetDependency(.sdWebImageSwiftUI), .targetDependency(.sfSafeSymbols), - .targetDependency(.swiftUIPager), - .targetDependency(.ttProgressHUD) + .targetDependency(.swiftUIPager) ], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 174d9fd79..6a72e2d72 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -23,7 +23,7 @@ struct AppRouteReducer { @ObservableState struct State: Equatable { - var hud: AppAlertState? + @Presents var toast: AppAlertState? // The deep-link/clipboard gallery, presented modally as the root of its own gallery stack. @Presents var detail: DetailReducer.State? var path = StackState() @@ -34,13 +34,14 @@ struct AppRouteReducer { enum Action: BindableAction { case binding(BindingAction) + case toast(PresentationAction) case destination(PresentationAction) case detail(PresentationAction) case path(StackActionOf) case presentSetting case presentNewDawn(Greeting) case presentGalleryDetail(String, DownloadedGallery?) - case setHUD(AppAlertState) + case setToast(AppAlertState) case detectClipboardURL case handleDeepLink(URL) @@ -67,6 +68,9 @@ struct AppRouteReducer { case .binding: return .none + case .toast: + return .none + case .destination: return .none @@ -114,8 +118,8 @@ struct AppRouteReducer { state.detail = .init(gid: gid, seededFrom: download) return .none - case .setHUD(let config): - state.hud = config + case .setToast(let config): + state.toast = config return .none case .detectClipboardURL: @@ -177,7 +181,7 @@ struct AppRouteReducer { } case .fetchGallery(let url, let isGalleryImageURL): - state.hud = .loading() + state.toast = .loading() return .run { send in let response = await GalleryReverseRequest( url: url, isGalleryImageURL: isGalleryImageURL @@ -187,7 +191,7 @@ struct AppRouteReducer { } case .fetchGalleryDone(let url, let result): - state.hud = nil + state.toast = nil switch result { case .success(let gallery): return .run { send in @@ -195,10 +199,10 @@ struct AppRouteReducer { await send(.handleGalleryLink(url)) } case .failure: - // Let the loading HUD animate out before showing the error toast. + // Let the loading toast animate out before showing the error toast. return .run { send in try await Task.sleep(for: .milliseconds(500)) - await send(.setHUD(.error())) + await send(.setToast(.error())) } } @@ -216,6 +220,7 @@ struct AppRouteReducer { ) .ifLet(\.$destination, action: \.destination) .ifLet(\.$detail, action: \.detail) { DetailReducer() } + .ifLet(\.$toast, action: \.toast) .forEach(\.path, action: \.path) } } diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index e4f85a648..8a8e21ef4 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -4,7 +4,7 @@ import Resources import SFSafeSymbols import ComposableArchitecture import AppTools -import TTProgressHUDExt +import SystemNotificationExt import AppComponents import DetailFeature import HomeFeature @@ -126,7 +126,7 @@ struct TabBarView: View { .autoBlur(radius: store.appLockState.blurRadius) .environment(\.inSheet, true) } - .progressHUD($store.appRouteState.hud) + .toast($store.scope(state: \.appRouteState.toast, action: \.appRoute.toast)) .onChange(of: scenePhase) { _, newValue in store.send(.onScenePhaseChange(newValue)) } .onOpenURL { store.send(.appRoute(.handleDeepLink($0))) } } diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index 10db0653e..c12ecf841 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -17,7 +17,7 @@ public struct ArchivesReducer: Sendable { @ObservableState public struct State: Equatable { - public var hud: AppAlertState? + @Presents public var toast: AppAlertState? public var selectedArchive: GalleryArchive.HathArchive? public var loadingState: LoadingState = .idle @@ -26,6 +26,7 @@ public struct ArchivesReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) + case toast(PresentationAction) case syncGalleryFunds(String, String) @@ -51,6 +52,9 @@ public struct ArchivesReducer: Sendable { case .binding: return .none + case .toast: + return .none + case .syncGalleryFunds(let galleryPoints, let credits): return .run { _ in await databaseClient.updateGalleryFunds(galleryPoints: galleryPoints, credits: credits) @@ -102,9 +106,9 @@ public struct ArchivesReducer: Sendable { case .fetchDownloadResponse(let archiveURL): guard let selectedArchive = state.selectedArchive, - state.hud != .communicating + state.toast != .communicating else { return .none } - state.hud = .communicating + state.toast = .communicating return .run {send in let response = await SendDownloadCommandRequest( archiveURL: archiveURL, @@ -121,20 +125,20 @@ public struct ArchivesReducer: Sendable { case .success(let response): switch response { case L10n.Constant.Website.Response.hathClientNotFound: - state.hud = .error(caption: L10n.Localizable.Website.Response.hathClientNotFound) + state.toast = .error(caption: L10n.Localizable.Website.Response.hathClientNotFound) isSuccess = false case L10n.Constant.Website.Response.hathClientNotOnline: - state.hud = .error(caption: L10n.Localizable.Website.Response.hathClientNotOnline) + state.toast = .error(caption: L10n.Localizable.Website.Response.hathClientNotOnline) isSuccess = false case L10n.Constant.Website.Response.invalidResolution: - state.hud = .error(caption: L10n.Localizable.Website.Response.invalidResolution) + state.toast = .error(caption: L10n.Localizable.Website.Response.invalidResolution) isSuccess = false default: - state.hud = .success(caption: response) + state.toast = .success(caption: response) isSuccess = true } case .failure: - state.hud = .error() + state.toast = .error() isSuccess = false } return .run { _ in @@ -142,5 +146,6 @@ public struct ArchivesReducer: Sendable { } } } + .ifLet(\.$toast, action: \.toast) } } diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift index 5ab831739..a15d83f55 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import AppTools -import TTProgressHUDExt +import SystemNotificationExt import AppComponents struct ArchivesView: View { @@ -56,7 +56,7 @@ struct ArchivesView: View { } .opacity(error != nil && store.hathArchives.isEmpty ? 1 : 0) } - .progressHUD($store.hud) + .toast($store.scope(state: \.toast, action: \.toast)) .animation(.default, value: store.hathArchives) .animation(.default, value: user.galleryPoints) .animation(.default, value: user.credits) diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index 80638bacc..6760134ed 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -30,7 +30,7 @@ public struct CommentsReducer: Sendable { @ObservableState public struct State: Equatable { - public var hud: AppAlertState? + @Presents public var toast: AppAlertState? @Presents public var destination: Destination.State? public var commentContent = "" public var postCommentFocused = false @@ -59,12 +59,13 @@ public struct CommentsReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) + case toast(PresentationAction) case destination(PresentationAction) case presentPostComment(commentID: String, content: String? = nil) case clearScrollCommentID case delegate(Delegate) - case setHUD(AppAlertState) + case setToast(AppAlertState) case setPostCommentFocused(Bool) case setScrollRowOpacity(Double) case performScrollOpacityEffect @@ -98,6 +99,9 @@ public struct CommentsReducer: Sendable { case .binding: return .none + case .toast: + return .none + case .destination: return .none @@ -117,8 +121,8 @@ public struct CommentsReducer: Sendable { case .delegate: return .none - case .setHUD(let config): - state.hud = config + case .setToast(let config): + state.toast = config return .none case .setPostCommentFocused(let isFocused): @@ -241,7 +245,7 @@ public struct CommentsReducer: Sendable { } case .fetchGallery(let url, let isGalleryImageURL): - state.hud = .loading() + state.toast = .loading() return .run { send in let response = await GalleryReverseRequest( url: url, isGalleryImageURL: isGalleryImageURL @@ -252,7 +256,7 @@ public struct CommentsReducer: Sendable { .cancellable(id: CancelID.fetchGallery) case .fetchGalleryDone(let url, let result): - state.hud = nil + state.toast = nil switch result { case .success(let gallery): return .merge( @@ -260,10 +264,10 @@ public struct CommentsReducer: Sendable { .send(.handleGalleryLink(url)) ) case .failure: - // Let the loading HUD animate out before showing the error toast. + // Let the loading toast animate out before showing the error toast. return .run { send in try await Task.sleep(for: .milliseconds(500)) - await send(.setHUD(.error())) + await send(.setToast(.error())) } } } @@ -274,6 +278,7 @@ public struct CommentsReducer: Sendable { hapticsClient: hapticsClient ) .ifLet(\.$destination, action: \.destination) + .ifLet(\.$toast, action: \.toast) } } diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index 42430b20d..05ffc425c 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -4,7 +4,7 @@ import Resources import Kingfisher import ComposableArchitecture import AppTools -import TTProgressHUDExt +import SystemNotificationExt import AppComponents struct CommentsView: View { @@ -111,7 +111,7 @@ struct CommentsView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .progressHUD($store.hud) + .toast($store.scope(state: \.toast, action: \.toast)) .animation(.default, value: store.scrollRowOpacity) .onAppear { store.send(.onAppear) diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift index f99e4685d..72a6ac092 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift @@ -8,7 +8,7 @@ import AppComponents public struct GalleryInfosReducer: Sendable { @ObservableState public struct State: Equatable { - public var hud: AppAlertState? + @Presents public var toast: AppAlertState? // Display data captured when this screen is pushed onto the host's gallery stack. public var gallery: Gallery = .empty public var galleryDetail: GalleryDetail = .empty @@ -21,6 +21,7 @@ public struct GalleryInfosReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) + case toast(PresentationAction) case copyText(String) } @@ -37,13 +38,17 @@ public struct GalleryInfosReducer: Sendable { case .binding: return .none + case .toast: + return .none + case .copyText(let text): - state.hud = .copiedToClipboardSucceeded + state.toast = .copiedToClipboardSucceeded return .merge( .run(operation: { _ in clipboardClient.saveText(text) }), .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) ) } } + .ifLet(\.$toast, action: \.toast) } } diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index 0fd88401b..afe302495 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -3,7 +3,7 @@ import AppModels import Resources import ComposableArchitecture import AppTools -import TTProgressHUDExt +import SystemNotificationExt struct GalleryInfosView: View { @Bindable private var store: StoreOf @@ -111,7 +111,7 @@ struct GalleryInfosView: View { } } } - .progressHUD($store.hud) + .toast($store.scope(state: \.toast, action: \.toast)) .navigationTitle(L10n.Localizable.GalleryInfosView.Title.galleryInfos) } } diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift index 42c3cf614..aba64d2e1 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsReducer.swift @@ -21,7 +21,7 @@ public struct TorrentsReducer: Sendable { @ObservableState public struct State: Equatable { - public var hud: AppAlertState? + @Presents public var toast: AppAlertState? @Presents public var destination: Destination.State? public var torrents = [GalleryTorrent]() public var loadingState: LoadingState = .idle @@ -29,6 +29,7 @@ public struct TorrentsReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) + case toast(PresentationAction) case destination(PresentationAction) case presentShare(URL) @@ -55,6 +56,9 @@ public struct TorrentsReducer: Sendable { case .binding: return .none + case .toast: + return .none + case .destination: return .none @@ -63,7 +67,7 @@ public struct TorrentsReducer: Sendable { return .none case .copyText(let magnetURL): - state.hud = .copiedToClipboardSucceeded + state.toast = .copiedToClipboardSucceeded return .merge( .run(operation: { _ in clipboardClient.saveText(magnetURL) }), .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) @@ -118,6 +122,7 @@ public struct TorrentsReducer: Sendable { hapticsClient: hapticsClient ) .ifLet(\.$destination, action: \.destination) + .ifLet(\.$toast, action: \.toast) } } diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift index 001f71b5d..26a5f8f50 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppModels import Resources import ComposableArchitecture -import TTProgressHUDExt +import SystemNotificationExt import AppComponents struct TorrentsView: View { @@ -47,7 +47,7 @@ struct TorrentsView: View { ActivityView(activityItems: [url.wrappedValue]) .autoBlur(radius: blurRadius) } - .progressHUD($store.hud) + .toast($store.scope(state: \.toast, action: \.toast)) .animation(.default, value: store.torrents) .onAppear { store.send(.fetchGalleryTorrents(gid, token)) diff --git a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift index e3a36ee44..555b4771c 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift @@ -14,7 +14,7 @@ public struct DownloadInspectorReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - public var hud: AppAlertState? + @Presents public var toast: AppAlertState? public var gid = "" public var inspection: DownloadInspection? public var stableInspection: DownloadInspection? @@ -31,6 +31,7 @@ public struct DownloadInspectorReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) + case toast(PresentationAction) case onAppear case loadInspection case loadInspectionDone(UUID, Result) @@ -56,6 +57,9 @@ public struct DownloadInspectorReducer: Sendable { case .binding: return .none + case .toast: + return .none + case .onAppear: guard !state.gid.isEmpty else { return .none } return .merge( @@ -201,10 +205,11 @@ public struct DownloadInspectorReducer: Sendable { case .validateImageDataDone(let validation): state.isValidatingImageData = false - state.hud = validation.toastConfig + state.toast = validation.toastConfig return .send(.loadInspection) } } + .ifLet(\.$toast, action: \.toast) } } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index b4ac2cd4d..06826e11d 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -302,7 +302,7 @@ public struct DownloadsReducer: Sendable { await send(.updateDownloadDone(.failure(AppError(error)))) } - // List-level mutations don't surface a per-op HUD: the `observeDownloads` stream is the + // List-level mutations don't surface a per-op toast: the `observeDownloads` stream is the // user-facing feedback from the DES-3 write-through index. Failures leave the current // observed state in place; the download client performs any targeted surprise repair. case .updateDownloadDone: diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index 8cf808aa6..6e0232bfa 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -4,7 +4,7 @@ import TagTranslationFeature import Resources import SFSafeSymbols import ComposableArchitecture -import TTProgressHUDExt +import SystemNotificationExt import AppComponents import GalleryListComponents @@ -112,7 +112,7 @@ struct DownloadInspectorView: View { } } .autoBlur(radius: blurRadius) - .progressHUD($store.hud) + .toast($store.scope(state: \.toast, action: \.toast)) .navigationTitle(L10n.Localizable.DownloadsView.Inspector.Title.downloadStatus) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index 2cf80255b..ab21afbff 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -46,6 +46,7 @@ extension ReadingReducer { hapticsClient: hapticsClient ) .ifLet(\.$destination, action: \.destination) + .ifLet(\.$toast, action: \.toast) } var lifecycleReducer: some ReducerOf { @@ -164,7 +165,7 @@ extension ReadingReducer { return .send(.fetchImage(.save, imageURL)) case .saveImageDone(let isSucceeded): - state.hud = isSucceeded ? .savedToPhotoLibrary : .error() + state.toast = isSucceeded ? .savedToPhotoLibrary : .error() return .none case .shareImage(let imageURL): @@ -181,7 +182,7 @@ extension ReadingReducer { if case .success(let asset) = result { switch action { case .copy: - state.hud = .copiedToClipboardSucceeded + state.toast = .copiedToClipboardSucceeded return .run(operation: { _ in _ = clipboardClient.saveImageData(asset.data) }) case .save: return .run { send in @@ -195,7 +196,7 @@ extension ReadingReducer { return .send(.presentShare(.init(value: shareItem))) } } else { - state.hud = .error() + state.toast = .error() return .none } diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 440346bc5..779014a52 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -43,7 +43,7 @@ public struct ReadingReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - public var hud: AppAlertState? + @Presents public var toast: AppAlertState? @Presents public var destination: Destination.State? public var contentSource: ReadingContentSource = .remote public var gallery: Gallery = .empty @@ -136,6 +136,7 @@ public struct ReadingReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) + case toast(PresentationAction) case destination(PresentationAction) case presentShare(IdentifiableBox) case presentReadingSetting diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 5c9ebb2fd..adf5185a2 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -7,7 +7,7 @@ import SwiftUIPager import ComposableArchitecture import AppTools import AnimatedImageFeature -import TTProgressHUDExt +import SystemNotificationExt import AppComponents import ReadingSettingFeature @@ -92,7 +92,7 @@ public struct ReadingView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .progressHUD($store.hud) + .toast($store.scope(state: \.toast, action: \.toast)) .animation(.linear(duration: 0.1), value: gestureHandler.offset) .animation(.default, value: liveTextHandler.enablesLiveText) diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index 54c05d8e6..b797a107e 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -29,7 +29,7 @@ public struct AccountSettingReducer: Sendable { public struct State: Equatable, Sendable { @Presents public var destination: Destination.State? @Presents public var confirmationDialog: ConfirmationDialogState? - public var hud: AppAlertState? + @Presents public var toast: AppAlertState? public var ehCookiesState: CookiesState = .empty(.ehentai) public var exCookiesState: CookiesState = .empty(.exhentai) @@ -38,6 +38,7 @@ public struct AccountSettingReducer: Sendable { public enum Action: BindableAction, Equatable { case binding(BindingAction) + case toast(PresentationAction) case destination(PresentationAction) case presentWebView(URL) case confirmationDialog(PresentationAction) @@ -68,6 +69,9 @@ public struct AccountSettingReducer: Sendable { case .binding: return .none + case .toast: + return .none + case .destination: return .none @@ -108,7 +112,7 @@ public struct AccountSettingReducer: Sendable { return .none case .copyCookies(let host): - state.hud = .copiedToClipboardSucceeded + state.toast = .copiedToClipboardSucceeded let cookiesDescription = cookieClient.getCookiesDescription(host: host) return .merge( .run(operation: { _ in clipboardClient.saveText(cookiesDescription) }), @@ -123,6 +127,7 @@ public struct AccountSettingReducer: Sendable { ) .ifLet(\.$destination, action: \.destination) .ifLet(\.$confirmationDialog, action: \.confirmationDialog) + .ifLet(\.$toast, action: \.toast) } } diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index 38ba8dc1c..accc16a60 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -4,7 +4,7 @@ import AppComponents import Resources import ComposableArchitecture import AppTools -import TTProgressHUDExt +import SystemNotificationExt struct AccountSettingView: View { @Bindable private var store: StoreOf @@ -53,7 +53,7 @@ struct AccountSettingView: View { copyAction: { store.send(.copyCookies($0)) } ) } - .progressHUD($store.hud) + .toast($store.scope(state: \.toast, action: \.toast)) .sheet(item: $store.destination.webView, id: \.absoluteString) { url in WebView(url: url.wrappedValue) .ignoresSafeArea(edges: .bottom) diff --git a/AppPackage/Sources/TTProgressHUDExt/.swiftlint.yml b/AppPackage/Sources/TTProgressHUDExt/.swiftlint.yml deleted file mode 100644 index 1242ffcaa..000000000 --- a/AppPackage/Sources/TTProgressHUDExt/.swiftlint.yml +++ /dev/null @@ -1 +0,0 @@ -parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift b/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift deleted file mode 100644 index f490587f8..000000000 --- a/AppPackage/Sources/TTProgressHUDExt/View+ProgressHUD.swift +++ /dev/null @@ -1,71 +0,0 @@ -import SwiftUI -import AppComponents -import ComposableArchitecture -import TTProgressHUD - -extension View { - /// Overlays a progress HUD driven by optional ``AppAlertState`` HUD state: a non-`nil` value shows - /// the HUD with that configuration and `nil` hides it. Auto-hiding HUDs write `nil` back through - /// the binding. Build the state with the `.hud`-style factories on `AppAlertState` - /// (`.loading()`, `.success(caption:)`, …). - public func progressHUD(_ config: Binding?>) -> some View { - modifier(ProgressHUDModifier(config: config)) - } -} - -private struct ProgressHUDModifier: ViewModifier { - @Binding var config: AppAlertState? - // Keeps the last shown configuration alive so the HUD's hide transition doesn't fall back - // to a default look the moment the state is reset to `nil`. - @State private var lastConfig: AppAlertState = .loading() - - func body(content: Content) -> some View { - ZStack { - content - TTProgressHUD(isVisible, config: (config ?? lastConfig).ttProgressHUDConfig) - } - .onChange(of: config) { _, newValue in - if let newValue { - lastConfig = newValue - } - } - } - - private var isVisible: Binding { - .init( - get: { config != nil }, - set: { isPresented, transaction in - guard !isPresented, config != nil else { return } - $config.transaction(transaction).wrappedValue = nil - } - ) - } -} - -private extension AppAlertState where Action == Never { - // Maps the unified HUD state onto the underlying TTProgressHUD library config. The `.alert` style - // never reaches a `progressHUD` binding, so it degrades to a plain loading spinner defensively. - var ttProgressHUDConfig: TTProgressHUDConfig { - let type: TTProgressHUDType - let autoHide: Bool - switch style { - case .alert: - type = .loading - autoHide = false - case let .toast(icon, shouldAutoHide): - autoHide = shouldAutoHide - switch icon { - case .loading: type = .loading - case .success: type = .success - case .error: type = .error - } - } - return .init( - type: type, - title: String(state: title), - caption: message.map { String(state: $0) }, - shouldAutoHide: autoHide, - autoHideInterval: 1 - ) - } -} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift index 00426f1df..f3ca8e838 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift @@ -155,7 +155,7 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { } await store.receive(\.validateImageDataDone) { $0.isValidatingImageData = false - $0.hud = .success( + $0.toast = .success( caption: L10n.Localizable.DownloadsView.Inspector.Toast.imageDataValid ) } @@ -288,7 +288,7 @@ extension DownloadInspectorLoadTests { @MainActor @Test - func testDownloadInspectorReducerValidateImageDataShowsMissingFilesHUD() async { + func testDownloadInspectorReducerValidateImageDataShowsMissingFilesToast() async { let download = sampleDownload( gid: "112241", title: "Missing Image Data Gallery", status: .completed, pageCount: 2 @@ -309,7 +309,7 @@ extension DownloadInspectorLoadTests { } await store.receive(\.validateImageDataDone) { $0.isValidatingImageData = false - $0.hud = .error(caption: "Page 2 image data is corrupted.") + $0.toast = .error(caption: "Page 2 image data is corrupted.") } await store.receive(\.loadInspection) await store.receive(\.loadInspectionDone) { diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e000364b2..035f30fd1 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "53b7494b1e4cf7044dac935df78229a3bbcfdd0047ba2987a18d98334560747c", + "originHash" : "5bac2b3bb2714aa40e3cafdac8915686b3cb750f4525ebe3b0126cfe0f635990", "pins" : [ { "identity" : "colorful", @@ -235,15 +235,6 @@ "version" : "2.0.0-beta" } }, - { - "identity" : "ttprogresshud", - "kind" : "remoteSourceControl", - "location" : "https://github.com/EhPanda-Team/TTProgressHUD", - "state" : { - "branch" : "custom", - "revision" : "349b595c4f0ff86e8d3c8d65be206a02642fd525" - } - }, { "identity" : "uiimagecolors", "kind" : "remoteSourceControl", From 423ef2f44be545843dcd5290c380708f978c99d0 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 01:28:05 +0800 Subject: [PATCH 439/614] Swap acknowledgement to SystemNotification --- .../Resources/Resources/de.lproj/Localizable.strings | 2 +- .../Sources/Resources/Resources/en.lproj/Constant.strings | 4 ++-- .../Resources/Resources/en.lproj/Localizable.strings | 2 +- .../Resources/Resources/ja.lproj/Localizable.strings | 2 +- .../Resources/Resources/ko.lproj/Localizable.strings | 2 +- .../Resources/Resources/zh-Hans.lproj/Localizable.strings | 2 +- .../Resources/zh-Hant-HK.lproj/Localizable.strings | 2 +- .../Resources/zh-Hant-TW.lproj/Localizable.strings | 2 +- .../Resources/Resources/zh-Hant.lproj/Localizable.strings | 2 +- AppPackage/Sources/Resources/Strings.swift | 8 ++++---- .../Sources/SettingFeature/Components/AboutView.swift | 4 ++-- 11 files changed, 16 insertions(+), 16 deletions(-) diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index e04eaefcb..48d68c689 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -12,7 +12,7 @@ "website.response.hath_client_not_online" = "Dein H@H Client scheint offline zu sein. Sieh nach, ob er läuft und probier's nochmal"; "website.response.invalid_resolution" = "Die gewünschte Galerie kann nicht in der gewählten Auflösung heruntergeladen werden"; -// MARK: HUD +// MARK: Toast "toast.title.error" = "Fehler"; "toast.title.success" = "Erfolg"; "toast.title.loading" = "Wird geladen..."; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings index e532f72c6..675f44193 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings @@ -64,7 +64,7 @@ "app.acknowledgement.link.swiftyOpenCC" = "https://github.com/ddddxxx/SwiftyOpenCC"; "app.acknowledgement.link.uiImageColors" = "https://github.com/jathu/UIImageColors"; "app.acknowledgement.link.sfSafeSymbols" = "https://github.com/SFSafeSymbols/SFSafeSymbols"; -"app.acknowledgement.link.ttProgressHUD" = "https://github.com/honkmaster/TTProgressHUD"; +"app.acknowledgement.link.systemNotification" = "https://github.com/danielsaidi/SystemNotification"; "app.acknowledgement.link.swiftUINavigation" = "https://github.com/pointfreeco/swiftui-navigation"; "app.acknowledgement.link.swiftCommonMark" = "https://github.com/gonzalezreal/SwiftCommonMark"; "app.acknowledgement.link.ehTagTranslationDatabase" = "https://github.com/EhTagTranslation/Database"; @@ -80,7 +80,7 @@ "app.acknowledgement.text.swiftyOpenCC" = "SwiftyOpenCC"; "app.acknowledgement.text.uiImageColors" = "UIImageColors"; "app.acknowledgement.text.sfSafeSymbols" = "SFSafeSymbols"; -"app.acknowledgement.text.ttProgressHUD" = "TTProgressHUD"; +"app.acknowledgement.text.systemNotification" = "SystemNotification"; "app.acknowledgement.text.swiftUINavigation" = "SwiftUI Navigation"; "app.acknowledgement.text.swiftCommonMark" = "SwiftCommonMark"; "app.acknowledgement.text.ehTagTranslationDatabase" = "EhTagTranslation/Database"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 6a4a08a1a..0ccd153ee 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -12,7 +12,7 @@ "website.response.hath_client_not_online" = "Your H@H client appears to be offline. Turn it on, then try again."; "website.response.invalid_resolution" = "The requested gallery cannot be downloaded with the selected resolution."; -// MARK: HUD +// MARK: Toast "toast.title.error" = "Error"; "toast.title.success" = "Success"; "toast.title.loading" = "Loading..."; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index bada45be1..5119c86a3 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -12,7 +12,7 @@ "website.response.hath_client_not_online" = "H@H クライアントは現在オフラインのようです、起動してからもう一度お試しください"; "website.response.invalid_resolution" = "このギャラリーは選択された解像度ではダウンロードできません"; -// MARK: HUD +// MARK: Toast "toast.title.error" = "エラー"; "toast.title.success" = "成功"; "toast.title.loading" = "読み込み中..."; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index f395992d3..b46c60dd6 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -12,7 +12,7 @@ "website.response.hath_client_not_online" = "H@H 클라이언트가 오프라인인 것 같네요. 클라이언트를 켜고 다시 시도해주세요."; "website.response.invalid_resolution" = "이 콘텐츠는 선택한 해상도로 다운로드할 수 없어요."; -// MARK: HUD +// MARK: Toast "toast.title.error" = "실패"; "toast.title.success" = "성공"; "toast.title.loading" = "로딩 중..."; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 7cc1a94d4..a8fc25a52 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -12,7 +12,7 @@ "website.response.hath_client_not_online" = "你的 H@H 客户端似乎处于离线状态,请启动它后再试"; "website.response.invalid_resolution" = "该画廊不能以选中的分辨率下载"; -// MARK: HUD +// MARK: Toast "toast.title.error" = "错误"; "toast.title.success" = "成功"; "toast.title.loading" = "加载中..."; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings index 2768202e3..81a98df3a 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings @@ -12,7 +12,7 @@ "website.response.hath_client_not_online" = "你的 H@H 用戶端為離線狀態,請啟動後再試"; "website.response.invalid_resolution" = "該畫廊不能以目前選擇的解像度下載"; -// MARK: HUD +// MARK: Toast "toast.title.error" = "錯誤"; "toast.title.success" = "成功"; "toast.title.loading" = "載入中..."; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings index 71edb4167..76b5f7fe7 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings @@ -12,7 +12,7 @@ "website.response.hath_client_not_online" = "你的 H@H 用戶端為離線狀態,請啟動後再試"; "website.response.invalid_resolution" = "該畫廊不能以目前選擇的解析度下載"; -// MARK: HUD +// MARK: Toast "toast.title.error" = "錯誤"; "toast.title.success" = "成功"; "toast.title.loading" = "載入中..."; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index b62e9116f..94249b5cd 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -12,7 +12,7 @@ "website.response.hath_client_not_online" = "你的 H@H 用戶端為離線狀態,請啟動後再試"; "website.response.invalid_resolution" = "該畫廊不能以目前選擇的解析度下載"; -// MARK: HUD +// MARK: Toast "toast.title.error" = "錯誤"; "toast.title.success" = "成功"; "toast.title.loading" = "載入中..."; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index c38baae9c..299320097 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -36,10 +36,10 @@ public enum L10n { public static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.link.swiftUIPager", fallback: "https://github.com/fermoya/SwiftUIPager") /// https://github.com/ddddxxx/SwiftyOpenCC public static let swiftyOpenCC = L10n.tr("Constant", "app.acknowledgement.link.swiftyOpenCC", fallback: "https://github.com/ddddxxx/SwiftyOpenCC") + /// https://github.com/danielsaidi/SystemNotification + public static let systemNotification = L10n.tr("Constant", "app.acknowledgement.link.systemNotification", fallback: "https://github.com/danielsaidi/SystemNotification") /// https://github.com/pointfreeco/swift-composable-architecture public static let tca = L10n.tr("Constant", "app.acknowledgement.link.tca", fallback: "https://github.com/pointfreeco/swift-composable-architecture") - /// https://github.com/honkmaster/TTProgressHUD - public static let ttProgressHUD = L10n.tr("Constant", "app.acknowledgement.link.ttProgressHUD", fallback: "https://github.com/honkmaster/TTProgressHUD") /// https://github.com/jathu/UIImageColors public static let uiImageColors = L10n.tr("Constant", "app.acknowledgement.link.uiImageColors", fallback: "https://github.com/jathu/UIImageColors") /// https://github.com/paololeonardi/WaterfallGrid @@ -66,10 +66,10 @@ public enum L10n { public static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.text.swiftUIPager", fallback: "SwiftUIPager") /// SwiftyOpenCC public static let swiftyOpenCC = L10n.tr("Constant", "app.acknowledgement.text.swiftyOpenCC", fallback: "SwiftyOpenCC") + /// SystemNotification + public static let systemNotification = L10n.tr("Constant", "app.acknowledgement.text.systemNotification", fallback: "SystemNotification") /// The Composable Architecture public static let tca = L10n.tr("Constant", "app.acknowledgement.text.tca", fallback: "The Composable Architecture") - /// TTProgressHUD - public static let ttProgressHUD = L10n.tr("Constant", "app.acknowledgement.text.ttProgressHUD", fallback: "TTProgressHUD") /// UIImageColors public static let uiImageColors = L10n.tr("Constant", "app.acknowledgement.text.uiImageColors", fallback: "UIImageColors") /// WaterfallGrid diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index 814fa3b81..90c285bff 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -183,8 +183,8 @@ struct AboutView: View { text: L10n.Constant.App.Acknowledgement.Text.sfSafeSymbols ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.ttProgressHUD, - text: L10n.Constant.App.Acknowledgement.Text.ttProgressHUD + urlString: L10n.Constant.App.Acknowledgement.Link.systemNotification, + text: L10n.Constant.App.Acknowledgement.Text.systemNotification ), .init( urlString: L10n.Constant.App.Acknowledgement.Link.swiftUINavigation, From 9777d2afbe3271e3c262987d4cc403ebb4c9c5b5 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 07:45:23 +0800 Subject: [PATCH 440/614] Fix toast dismiss identity and animation scope --- .../SystemNotificationExt/View+Toast.swift | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/AppPackage/Sources/SystemNotificationExt/View+Toast.swift b/AppPackage/Sources/SystemNotificationExt/View+Toast.swift index 1922c88b9..859ae2067 100644 --- a/AppPackage/Sources/SystemNotificationExt/View+Toast.swift +++ b/AppPackage/Sources/SystemNotificationExt/View+Toast.swift @@ -36,19 +36,27 @@ private struct ToastViewModifier: ViewModifier { func body(content: Content) -> some View { content.overlay(alignment: .bottom) { - if let store = item { - let toast = store.toastContent - // SwiftUI keeps this conditional child alive through its removal transition, so the - // last content stays visible while the toast slides back off-screen — no manual hold. - ToastMessageView(content: toast) - .padding(.horizontal) - .padding(.bottom) - .gesture(dismissGesture(autoHide: toast.autoHide)) - .task(id: store.id) { await autoDismiss(toast) } - .transition(.move(edge: .bottom).combined(with: .opacity)) + ZStack { + if let store = item { + let toast = store.toastContent + // SwiftUI keeps this conditional child alive through its removal transition, so + // the last content stays visible while the toast slides back off-screen — no + // manual hold. + ToastMessageView(content: toast) + .padding(.horizontal) + .padding(.bottom) + .gesture(dismissGesture(autoHide: toast.autoHide)) + // The timer must restart whenever the presented state is replaced. That id + // is `store.state.id`; `store.id` is the Store object's own identity (TCA + // declares `Store: Identifiable`), which shadows the state's UUID. + .task(id: store.state.id) { await autoDismiss(toast) } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } } + // Scoped inside the overlay: the host view can mutate in the same transaction that + // presents or clears the toast, and must not inherit this animation. + .animation(.bouncy, value: item != nil) } - .animation(.bouncy, value: item != nil) } // Only auto-hiding toasts (success / error) can be flicked away; a loading toast stays until From 3fb7270afe9e5ebf70bee5cca99993589ff349a0 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 07:45:23 +0800 Subject: [PATCH 441/614] Drop stale TTProgressHUD pin from Package.resolved --- AppPackage/Package.resolved | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/AppPackage/Package.resolved b/AppPackage/Package.resolved index 01d5cd333..ce2650547 100644 --- a/AppPackage/Package.resolved +++ b/AppPackage/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "950b583b60e12ba632ae0241b643765c995f9f7e5953a45213ffe2de456c02ba", + "originHash" : "12e80fee1d23108c4dbbe92f4d843fe86b02b3f0908b8d5efd03651504be2337", "pins" : [ { "identity" : "colorful", @@ -235,15 +235,6 @@ "version" : "2.0.0-beta" } }, - { - "identity" : "ttprogresshud", - "kind" : "remoteSourceControl", - "location" : "https://github.com/EhPanda-Team/TTProgressHUD", - "state" : { - "branch" : "custom", - "revision" : "349b595c4f0ff86e8d3c8d65be206a02642fd525" - } - }, { "identity" : "uiimagecolors", "kind" : "remoteSourceControl", From 9055c5af7f0d806f048b0c86f618ffaff0b4b589 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 07:59:32 +0800 Subject: [PATCH 442/614] Tidy SystemNotificationExt header comments --- .../Sources/SystemNotificationExt/ToastMessageView.swift | 5 +---- AppPackage/Sources/SystemNotificationExt/View+Toast.swift | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift b/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift index c1ec02010..57845970f 100644 --- a/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift +++ b/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift @@ -1,11 +1,8 @@ // -// ToastMessageView.swift -// SystemNotificationExt -// // The Liquid Glass capsule shown by `View.toast(_:)`. The layout adapts SystemNotificationMessage // (MIT, https://github.com/danielsaidi/SystemNotification): a leading symbol, a one-line bold title // over an optional one-line subtitle, and a hidden trailing symbol that mirrors the leading one so -// the text stays optically centered. The capsule is pure Liquid Glass with nothing behind it — +// the text stays optically centered. The capsule is pure Liquid Glass with nothing behind it, // layering glass over a Material would render it opaque. // diff --git a/AppPackage/Sources/SystemNotificationExt/View+Toast.swift b/AppPackage/Sources/SystemNotificationExt/View+Toast.swift index 859ae2067..53b3738f4 100644 --- a/AppPackage/Sources/SystemNotificationExt/View+Toast.swift +++ b/AppPackage/Sources/SystemNotificationExt/View+Toast.swift @@ -1,10 +1,7 @@ // -// View+Toast.swift -// SystemNotificationExt -// // Presents a bottom-anchored Liquid Glass toast, driven by `AppAlertState` presentation state. // The presentation model is adapted from Daniel Saidi's SystemNotification (MIT-licensed): -// https://github.com/danielsaidi/SystemNotification — reduced to a single bottom edge and rebuilt +// https://github.com/danielsaidi/SystemNotification, reduced to a single bottom edge and rebuilt // on TCA presentation state and SwiftUI's Liquid Glass (`glassEffect`) instead of a Material chrome. // From 2c411de3af34aca398060bddedad609aaecbd5af Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 08:19:06 +0800 Subject: [PATCH 443/614] Restore swipe axis guard, fix dismiss race --- .../SystemNotificationExt/View+Toast.swift | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/AppPackage/Sources/SystemNotificationExt/View+Toast.swift b/AppPackage/Sources/SystemNotificationExt/View+Toast.swift index 53b3738f4..32e45d320 100644 --- a/AppPackage/Sources/SystemNotificationExt/View+Toast.swift +++ b/AppPackage/Sources/SystemNotificationExt/View+Toast.swift @@ -36,6 +36,10 @@ private struct ToastViewModifier: ViewModifier { ZStack { if let store = item { let toast = store.toastContent + // The dismiss timer keys off the state's own UUID. Not `store.id`: TCA declares + // `Store: Identifiable`, so that is the Store object's identity, which shadows + // the state's UUID and only coincidentally tracks replacement. + let id = store.state.id // SwiftUI keeps this conditional child alive through its removal transition, so // the last content stays visible while the toast slides back off-screen — no // manual hold. @@ -43,10 +47,7 @@ private struct ToastViewModifier: ViewModifier { .padding(.horizontal) .padding(.bottom) .gesture(dismissGesture(autoHide: toast.autoHide)) - // The timer must restart whenever the presented state is replaced. That id - // is `store.state.id`; `store.id` is the Store object's own identity (TCA - // declares `Store: Identifiable`), which shadows the state's UUID. - .task(id: store.state.id) { await autoDismiss(toast) } + .task(id: id) { await autoDismiss(toast, presentedID: id) } .transition(.move(edge: .bottom).combined(with: .opacity)) } } @@ -57,21 +58,27 @@ private struct ToastViewModifier: ViewModifier { } // Only auto-hiding toasts (success / error) can be flicked away; a loading toast stays until - // its reducer clears the state, so a downward drag on it is ignored. + // its reducer clears the state, so a downward drag on it is ignored. As in the ported design, + // the drag must also be predominantly vertical — a sideways flick is not a dismissal. private func dismissGesture(autoHide: Bool) -> some Gesture { DragGesture(minimumDistance: 20) .onEnded { value in - guard autoHide, value.translation.height > 0 else { return } + let translation = value.translation + guard autoHide, + abs(translation.height) > abs(translation.width), + translation.height > 0 + else { return } item = nil } } - private func autoDismiss(_ toast: ToastContent) async { + private func autoDismiss(_ toast: ToastContent, presentedID: UUID) async { guard toast.autoHide else { return } try? await Task.sleep(for: .seconds(3)) - // The task is cancelled when the toast is replaced or dismissed; only a timer that ran to - // completion should clear the state, so bail out on cancellation. - guard !Task.isCancelled else { return } + // The task is cancelled when the toast is replaced or dismissed, but a continuation already + // enqueued when the replacement lands can still run before SwiftUI restarts the task. Only + // a completed timer whose state is still presented may clear it. + guard !Task.isCancelled, item?.state.id == presentedID else { return } item = nil } } From 3dd9877944d4915103c6b03cb9abbce08d4f5c2f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 09:04:22 +0800 Subject: [PATCH 444/614] Drop SwiftUINavigation acknowledgement entry --- .../Resources/Resources/en.lproj/Constant.strings | 1 - .../SettingFeature/Components/AboutView.swift | 12 ++++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings index 675f44193..ffb6ec460 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings @@ -65,7 +65,6 @@ "app.acknowledgement.link.uiImageColors" = "https://github.com/jathu/UIImageColors"; "app.acknowledgement.link.sfSafeSymbols" = "https://github.com/SFSafeSymbols/SFSafeSymbols"; "app.acknowledgement.link.systemNotification" = "https://github.com/danielsaidi/SystemNotification"; -"app.acknowledgement.link.swiftUINavigation" = "https://github.com/pointfreeco/swiftui-navigation"; "app.acknowledgement.link.swiftCommonMark" = "https://github.com/gonzalezreal/SwiftCommonMark"; "app.acknowledgement.link.ehTagTranslationDatabase" = "https://github.com/EhTagTranslation/Database"; "app.acknowledgement.link.tca" = "https://github.com/pointfreeco/swift-composable-architecture"; diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index 90c285bff..a27617521 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -150,14 +150,14 @@ struct AboutView: View { urlString: L10n.Constant.App.Acknowledgement.Link.kanna, text: L10n.Constant.App.Acknowledgement.Text.kanna ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftGen, - text: L10n.Constant.App.Acknowledgement.Text.swiftGen - ), .init( urlString: L10n.Constant.App.Acknowledgement.Link.colorful, text: L10n.Constant.App.Acknowledgement.Text.colorful ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.swiftGen, + text: L10n.Constant.App.Acknowledgement.Text.swiftGen + ), .init( urlString: L10n.Constant.App.Acknowledgement.Link.kingfisher, text: L10n.Constant.App.Acknowledgement.Text.kingfisher @@ -186,10 +186,6 @@ struct AboutView: View { urlString: L10n.Constant.App.Acknowledgement.Link.systemNotification, text: L10n.Constant.App.Acknowledgement.Text.systemNotification ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftUINavigation, - text: L10n.Constant.App.Acknowledgement.Text.swiftUINavigation - ), .init( urlString: L10n.Constant.App.Acknowledgement.Link.swiftCommonMark, text: L10n.Constant.App.Acknowledgement.Text.swiftCommonMark From f0e2f7f58e4be685f497827b77e51922661fef6c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 22:21:42 +0800 Subject: [PATCH 445/614] Update copyright statement --- .../Sources/Resources/Resources/en.lproj/Constant.strings | 2 +- AppPackage/Sources/Resources/Strings.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings index ffb6ec460..6722c6ed5 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings @@ -10,7 +10,7 @@ "website.response.gallery_unavailable" = "This gallery has been removed or is unavailable."; // MARK: App -"app.copyright" = "Copyright © 2025 EhPanda Team"; +"app.copyright" = "Copyright © 2026 EhPanda Team"; // Contact "app.contact.link.website" = "https://ehpanda.app"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 299320097..4bc9bb0d9 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -12,8 +12,8 @@ import Foundation public enum L10n { public enum Constant { public enum App { - /// Copyright © 2025 EhPanda Team - public static let copyright = L10n.tr("Constant", "app.copyright", fallback: "Copyright © 2025 EhPanda Team") + /// Copyright © 2026 EhPanda Team + public static let copyright = L10n.tr("Constant", "app.copyright", fallback: "Copyright © 2026 EhPanda Team") public enum Acknowledgement { public enum Link { /// https://github.com/Co2333/Colorful From 05bd3c8033c6a564d5d0b7ea8d32d44a76e1320e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 22:30:38 +0800 Subject: [PATCH 446/614] Update contributors list --- .../Resources/Resources/en.lproj/Constant.strings | 8 ++++---- AppPackage/Sources/Resources/Strings.swift | 10 ++++------ .../SettingFeature/Components/AboutView.swift | 12 ++++++------ 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings index 6722c6ed5..f5e787f8c 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings @@ -33,16 +33,16 @@ "app.special_thanks.text.honjow" = "honjow"; // Code level contributor -"app.code_level_contributor.link.chihchy" = "https://github.com/chihchy"; +"app.code_level_contributor.link.vvbbnn00" = "https://github.com/vvbbnn00"; +"app.code_level_contributor.link.Kaed3mi" = "https://github.com/Kaed3mi"; "app.code_level_contributor.link.aalberrty" = "https://github.com/aalberrty"; "app.code_level_contributor.link.Jimmy-Prime" = "https://github.com/Jimmy-Prime"; "app.code_level_contributor.link.xioxin" = "https://github.com/xioxin"; -"app.code_level_contributor.link.vvbbnn00" = "https://github.com/vvbbnn00"; -"app.code_level_contributor.text.chihchy" = "Chihchy"; +"app.code_level_contributor.text.vvbbnn00" = "vvbbnn00"; +"app.code_level_contributor.text.Kaed3mi" = "Kaed3mi"; "app.code_level_contributor.text.aalberrty" = "Zack Asahina"; "app.code_level_contributor.text.Jimmy-Prime" = "Jimmy Prime"; "app.code_level_contributor.text.xioxin" = "xioxin"; -"app.code_level_contributor.text.vvbbnn00" = "vvbbnn00"; // Translation contributor "app.translation_contributor.link.nebulosa-cat" = "https://github.com/Nebulosa-Cat"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 4bc9bb0d9..46c579a82 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -30,8 +30,6 @@ public enum L10n { public static let swiftCommonMark = L10n.tr("Constant", "app.acknowledgement.link.swiftCommonMark", fallback: "https://github.com/gonzalezreal/SwiftCommonMark") /// https://github.com/SwiftGen/SwiftGen public static let swiftGen = L10n.tr("Constant", "app.acknowledgement.link.swiftGen", fallback: "https://github.com/SwiftGen/SwiftGen") - /// https://github.com/pointfreeco/swiftui-navigation - public static let swiftUINavigation = L10n.tr("Constant", "app.acknowledgement.link.swiftUINavigation", fallback: "https://github.com/pointfreeco/swiftui-navigation") /// https://github.com/fermoya/SwiftUIPager public static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.link.swiftUIPager", fallback: "https://github.com/fermoya/SwiftUIPager") /// https://github.com/ddddxxx/SwiftyOpenCC @@ -80,10 +78,10 @@ public enum L10n { public enum Link { /// https://github.com/aalberrty public static let aalberrty = L10n.tr("Constant", "app.code_level_contributor.link.aalberrty", fallback: "https://github.com/aalberrty") - /// https://github.com/chihchy - public static let chihchy = L10n.tr("Constant", "app.code_level_contributor.link.chihchy", fallback: "https://github.com/chihchy") /// https://github.com/Jimmy-Prime public static let jimmyPrime = L10n.tr("Constant", "app.code_level_contributor.link.Jimmy-Prime", fallback: "https://github.com/Jimmy-Prime") + /// https://github.com/Kaed3mi + public static let kaed3mi = L10n.tr("Constant", "app.code_level_contributor.link.Kaed3mi", fallback: "https://github.com/Kaed3mi") /// https://github.com/vvbbnn00 public static let vvbbnn00 = L10n.tr("Constant", "app.code_level_contributor.link.vvbbnn00", fallback: "https://github.com/vvbbnn00") /// https://github.com/xioxin @@ -92,10 +90,10 @@ public enum L10n { public enum Text { /// Zack Asahina public static let aalberrty = L10n.tr("Constant", "app.code_level_contributor.text.aalberrty", fallback: "Zack Asahina") - /// Chihchy - public static let chihchy = L10n.tr("Constant", "app.code_level_contributor.text.chihchy", fallback: "Chihchy") /// Jimmy Prime public static let jimmyPrime = L10n.tr("Constant", "app.code_level_contributor.text.Jimmy-Prime", fallback: "Jimmy Prime") + /// Kaed3mi + public static let kaed3mi = L10n.tr("Constant", "app.code_level_contributor.text.Kaed3mi", fallback: "Kaed3mi") /// vvbbnn00 public static let vvbbnn00 = L10n.tr("Constant", "app.code_level_contributor.text.vvbbnn00", fallback: "vvbbnn00") /// xioxin diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index a27617521..ec0fa6311 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -103,8 +103,12 @@ struct AboutView: View { // MARK: Code level contributors private let codeLevelContributors: [Info] = {[ .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.chihchy, - text: L10n.Constant.App.CodeLevelContributor.Text.chihchy + urlString: L10n.Constant.App.CodeLevelContributor.Link.vvbbnn00, + text: L10n.Constant.App.CodeLevelContributor.Text.vvbbnn00 + ), + .init( + urlString: L10n.Constant.App.CodeLevelContributor.Link.kaed3mi, + text: L10n.Constant.App.CodeLevelContributor.Text.kaed3mi ), .init( urlString: L10n.Constant.App.CodeLevelContributor.Link.aalberrty, @@ -117,10 +121,6 @@ struct AboutView: View { .init( urlString: L10n.Constant.App.CodeLevelContributor.Link.xioxin, text: L10n.Constant.App.CodeLevelContributor.Text.xioxin - ), - .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.vvbbnn00, - text: L10n.Constant.App.CodeLevelContributor.Text.vvbbnn00 ) ]}() From e3452afb8af6c91a2239db5d3872e501c980b69a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 22:24:40 +0800 Subject: [PATCH 447/614] Remove translations wanted section from READMEs --- README.md | 9 --------- READMEs/README.chs.md | 9 --------- READMEs/README.cht.md | 9 --------- READMEs/README.de.md | 9 --------- READMEs/README.jpn.md | 9 --------- READMEs/README.ko.md | 9 --------- 6 files changed, 54 deletions(-) diff --git a/README.md b/README.md index ca0249a8e..cf217f38b 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,6 @@ 简体中文

-## 📢 Translations Wanted 📢 -Please submit a pull request if you want to help with translation. - -App Strings: [{lang}.lproj](/EhPanda/App) - -GitHub Readme: [README.{lang}.md](/READMEs) - -https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/blob/main/src/main.js) - ## Installation 1. Get the ipa file from [Releases](https://github.com/EhPanda-Team/EhPanda/releases). 2. Use some software like [AltStore](https://altstore.io) to install the ipa file on your device. diff --git a/READMEs/README.chs.md b/READMEs/README.chs.md index 298fee041..fe9e2a9e5 100644 --- a/READMEs/README.chs.md +++ b/READMEs/README.chs.md @@ -15,15 +15,6 @@ 简体中文

-## 📢 募集翻译 📢 -如果你想帮助翻译这个应用程序,请提交一个 Pull Request。 - -App Strings: [{lang}.lproj](/EhPanda/App) - -GitHub Readme: [README.{lang}.md](/READMEs) - -https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/blob/main/src/main.js) - ## 安装步骤 1. 在 [Releases](https://github.com/EhPanda-Team/EhPanda/releases) 取得 ipa 文件。 2. 使用 [AltStore](https://altstore.io) 这类软件将 ipa 文件安装到你的设备。 diff --git a/READMEs/README.cht.md b/READMEs/README.cht.md index d9101b2df..3eaad77bd 100644 --- a/READMEs/README.cht.md +++ b/READMEs/README.cht.md @@ -15,15 +15,6 @@ 简体中文

-## 📢 徵集翻譯 📢 -如果你想幫助翻譯這個應用程式,請提交一個 Pull Request。 - -App Strings: [{lang}.lproj](/EhPanda/App) - -GitHub Readme: [README.{lang}.md](/READMEs) - -https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/blob/main/src/main.js) - ## 安裝步驟 1. 在 [Releases](https://github.com/EhPanda-Team/EhPanda/releases) 取得 ipa 文件。 2. 使用 [AltStore](https://altstore.io) 這類軟件將 ipa 文件安裝到你的裝置。 diff --git a/READMEs/README.de.md b/READMEs/README.de.md index e337f0e95..8d142f7bb 100644 --- a/READMEs/README.de.md +++ b/READMEs/README.de.md @@ -15,15 +15,6 @@ 简体中文

-## 📢 Übersetzer gesucht 📢 -Stelle eine Pull-Request wenn du bei der Übersetzung mithelfen möchtest. - -App Strings: [{lang}.lproj](/EhPanda/App) - -GitHub Readme: [README.{lang}.md](/READMEs) - -https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/blob/main/src/main.js) - ## Installation 1. Lade die IPA-Datei hier herunter: [Releases](https://github.com/EhPanda-Team/EhPanda/releases). 2. Nutze eine Programm zur Installation von nicht im Appstore gelisteten Dateien wie z.B. [AltStore](https://altstore.io) um die IPA-Datei zu installieren. diff --git a/READMEs/README.jpn.md b/READMEs/README.jpn.md index 64d6a06a4..12162d952 100644 --- a/READMEs/README.jpn.md +++ b/READMEs/README.jpn.md @@ -15,15 +15,6 @@ 简体中文

-## 📢 翻訳募集中 📢 -このアプリの翻訳に協力したい場合は、Pull Request を提出してください。 - -App Strings: [{lang}.lproj](/EhPanda/App) - -GitHub Readme: [README.{lang}.md](/READMEs) - -https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/blob/main/src/main.js) - ## インストール手順 1. [Releases](https://github.com/EhPanda-Team/EhPanda/releases) から ipa ファイルを取得。 2. [AltStore](https://altstore.io) とかで ipa ファイルをデバイスにインストール。 diff --git a/READMEs/README.ko.md b/READMEs/README.ko.md index 540177a5d..c7e28a617 100644 --- a/READMEs/README.ko.md +++ b/READMEs/README.ko.md @@ -15,15 +15,6 @@ 简体中文

-## 📢 번역 수요 📢 -번역을 돕고 싶으면 Pull Request를 제출해주세요. - -App Strings: [{lang}.lproj](/EhPanda/App) - -GitHub Readme: [README.{lang}.md](/READMEs) - -https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/blob/main/src/main.js) - ## 다운로드 1. [Releases](https://github.com/EhPanda-Team/EhPanda/releases)에서 ipa 파일을 다운로드 받으세요. 2. [AltStore](https://altstore.io)를 사용해서 ipa 파일을 설치할 수 있습니다. From 389a874a3e8ee88bfa81d91b7532fd50cb0ff35f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Fri, 3 Jul 2026 23:10:23 +0800 Subject: [PATCH 448/614] Update L10n keys --- App/zh-Hant-HK.lproj/InfoPlist.strings | 7 - App/zh-Hant-TW.lproj/InfoPlist.strings | 7 - .../Sources/AppComponents/AlertView.swift | 8 +- .../Sources/AppComponents/AppAlertState.swift | 12 +- .../Sources/AppComponents/NewDawnView.swift | 4 +- .../Sources/AppComponents/SubSection.swift | 2 +- .../AppComponents/TagSuggestionView.swift | 2 +- .../Sources/AppComponents/ToolbarItems.swift | 8 +- .../AppFeature/View/TabBar/TabBarView.swift | 10 +- .../Download/DownloadFolderFilter.swift | 2 +- .../Sources/AppModels/Gallery/Category.swift | 22 +- .../AppModels/Gallery/GalleryArchive.swift | 4 +- .../AppModels/Gallery/GalleryDetail.swift | 6 +- .../Sources/AppModels/Gallery/Language.swift | 132 +- .../AppModels/Persistent/AppIconType.swift | 10 +- .../Sources/AppModels/Persistent/Filter.swift | 6 +- .../AppModels/Persistent/Greeting.swift | 8 +- .../AppModels/Persistent/Setting.swift | 26 +- .../Sources/AppModels/Persistent/User.swift | 4 +- .../Sources/AppModels/Support/AppError.swift | 70 +- .../AppModels/Support/BrowsingCountry.swift | 504 +- .../AppModels/Support/EhSetting+Enums.swift | 26 +- .../Support/EhSetting+Extensions.swift | 16 +- .../Sources/AppModels/Support/EhSetting.swift | 54 +- .../AppModels/Support/ToplistsType.swift | 8 +- .../Sources/AppModels/Tags/TagNamespace.swift | 24 +- .../Sources/CookieClient/CookieClient.swift | 6 +- .../DateSeekFeature/DateSeekPickerView.swift | 10 +- .../Archives/ArchivesReducer.swift | 12 +- .../DetailFeature/Archives/ArchivesView.swift | 4 +- .../DetailFeature/Comments/CommentsView.swift | 6 +- .../Components/TagDetailView.swift | 4 +- .../DetailReducer+Download.swift | 28 +- .../DetailView+CommentCells.swift | 2 +- .../DetailView+HeaderSection.swift | 32 +- .../DetailFeature/DetailView+Navigation.swift | 6 +- .../DetailFeature/DetailView+Subviews.swift | 30 +- .../Sources/DetailFeature/DetailView.swift | 6 +- .../FolderManager/FolderManagerReducer.swift | 8 +- .../FolderManager/FolderManagerView.swift | 6 +- .../GalleryInfos/GalleryInfosView.swift | 52 +- .../DetailFeature/Previews/PreviewsView.swift | 2 +- .../DetailFeature/Torrents/TorrentsView.swift | 2 +- .../DownloadClient+Folders.swift | 16 +- .../DownloadClient+PublicAPI.swift | 2 +- .../DownloadStore+Operations.swift | 18 +- .../DownloadClient/DownloadStore.swift | 2 +- .../DownloadInspectorReducer.swift | 4 +- .../DownloadsFeature/DownloadsReducer.swift | 14 +- .../DownloadsView+Subviews.swift | 22 +- .../DownloadsFeature/DownloadsView.swift | 38 +- .../FavoritesFeature/FavoritesView.swift | 2 +- .../FiltersFeature/FiltersReducer.swift | 6 +- .../Sources/FiltersFeature/FiltersView.swift | 42 +- .../DownloadBadgeLabel.swift | 4 +- .../HomeFeature/Frontpage/FrontpageView.swift | 4 +- .../HomeFeature/History/HistoryReducer.swift | 6 +- .../HomeFeature/History/HistoryView.swift | 4 +- .../HomeFeature/HomeView+Sections.swift | 6 +- AppPackage/Sources/HomeFeature/HomeView.swift | 8 +- .../HomeFeature/Popular/PopularView.swift | 4 +- .../Toplists/ToplistsReducer.swift | 10 +- .../HomeFeature/Toplists/ToplistsView.swift | 4 +- .../HomeFeature/Watched/WatchedView.swift | 2 +- .../MigrationFeature/MigrationReducer.swift | 6 +- .../MigrationFeature/MigrationView.swift | 4 +- .../ParserFeature/Parser+ResponseError.swift | 2 +- .../QuickSearchReducer.swift | 6 +- .../QuickSearchFeature/QuickSearchView.swift | 12 +- .../ReadingViewComponents.swift | 14 +- .../ReadingFeature/Support/ControlPanel.swift | 12 +- .../ReadingSettingView.swift | 18 +- .../Resources/de.lproj/Localizable.strings | 1751 ++++--- .../Resources/en.lproj/Constant.strings | 131 +- .../Resources/en.lproj/Localizable.strings | 1753 ++++--- .../Resources/ja.lproj/Localizable.strings | 1751 ++++--- .../Resources/ko.lproj/Localizable.strings | 1751 ++++--- .../zh-Hans.lproj/Localizable.strings | 1751 ++++--- .../zh-Hant-HK.lproj/Localizable.strings | 1051 ---- .../zh-Hant-TW.lproj/Localizable.strings | 1051 ---- .../zh-Hant.lproj/Localizable.strings | 1751 ++++--- AppPackage/Sources/Resources/Strings.swift | 4454 ++++++++--------- .../SearchFeature/SearchRootView.swift | 8 +- .../AccountSettingReducer.swift | 6 +- .../AccountSetting/AccountSettingView.swift | 16 +- .../AppActivityLogs/AppActivityLogsView.swift | 8 +- .../AppearanceSettingView.swift | 24 +- .../SettingFeature/Components/AboutView.swift | 138 +- .../Components/DownloadSettingView.swift | 10 +- .../Components/LaboratorySettingView.swift | 4 +- .../EhSetting/EhSettingReducer.swift | 6 +- .../EhSetting/EhSettingView+Sections1.swift | 62 +- .../EhSetting/EhSettingView+Sections2.swift | 40 +- .../EhSetting/EhSettingView+Sections3.swift | 62 +- .../EhSetting/EhSettingView.swift | 4 +- .../GeneralSettingReducer.swift | 12 +- .../GeneralSetting/GeneralSettingView.swift | 38 +- .../SettingFeature/Login/LoginView.swift | 6 +- .../Sources/SettingFeature/SettingView.swift | 16 +- .../DownloadCoordinatorStorageTests.swift | 2 +- .../DownloadInspectorLoadTests.swift | 2 +- .../DownloadStoreHashTests.swift | 2 +- .../DownloadStoreTests.swift | 8 +- EhPanda.xcodeproj/project.pbxproj | 7 +- 104 files changed, 8238 insertions(+), 10928 deletions(-) delete mode 100644 App/zh-Hant-HK.lproj/InfoPlist.strings delete mode 100644 App/zh-Hant-TW.lproj/InfoPlist.strings delete mode 100644 AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings delete mode 100644 AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings diff --git a/App/zh-Hant-HK.lproj/InfoPlist.strings b/App/zh-Hant-HK.lproj/InfoPlist.strings deleted file mode 100644 index c19da4373..000000000 --- a/App/zh-Hant-HK.lproj/InfoPlist.strings +++ /dev/null @@ -1,7 +0,0 @@ -/* - InfoPlist.strings - EhPanda -*/ - -"NSFaceIDUsageDescription" = "我們需要您提供 Face ID 權限來使用 Face ID 解鎖"; -"NSPhotoLibraryAddUsageDescription" = "我們需要您提供照片權限來保存圖片到照片"; diff --git a/App/zh-Hant-TW.lproj/InfoPlist.strings b/App/zh-Hant-TW.lproj/InfoPlist.strings deleted file mode 100644 index af7729065..000000000 --- a/App/zh-Hant-TW.lproj/InfoPlist.strings +++ /dev/null @@ -1,7 +0,0 @@ -/* - InfoPlist.strings - EhPanda -*/ - -"NSFaceIDUsageDescription" = "我們需要您提供 Face ID 權限來使用 Face ID 解鎖"; -"NSPhotoLibraryAddUsageDescription" = "我們需要您提供照片權限來儲存圖片到照片"; diff --git a/AppPackage/Sources/AppComponents/AlertView.swift b/AppPackage/Sources/AppComponents/AlertView.swift index 9a9a4b33b..37dfd6390 100644 --- a/AppPackage/Sources/AppComponents/AlertView.swift +++ b/AppPackage/Sources/AppComponents/AlertView.swift @@ -7,7 +7,7 @@ import AppTools public struct LoadingView: View { private let title: String - public init(title: String = L10n.Localizable.LoadingView.Title.loading) { + public init(title: String = L10n.Localizable.LoadingView.loading) { self.title = title } @@ -54,9 +54,9 @@ public struct NotLoginView: View { public var body: some View { AlertView( symbol: .personCropCircleBadgeQuestionmarkFill, - message: L10n.Localizable.NotLoginView.Title.needLogin + message: L10n.Localizable.NotLoginView.needLogin ) { - AlertViewButton(title: L10n.Localizable.NotLoginView.Button.login, action: action) + AlertViewButton(title: L10n.Localizable.notLoginViewlogin, action: action) } } } @@ -68,7 +68,7 @@ public struct ErrorView: View { public init( error: AppError, - buttonTitle: String = L10n.Localizable.ErrorView.Button.retry, + buttonTitle: String = L10n.Localizable.ErrorView.retry, action: (() -> Void)? = nil ) { self.error = error diff --git a/AppPackage/Sources/AppComponents/AppAlertState.swift b/AppPackage/Sources/AppComponents/AppAlertState.swift index e212bd1ea..2d9473dc4 100644 --- a/AppPackage/Sources/AppComponents/AppAlertState.swift +++ b/AppPackage/Sources/AppComponents/AppAlertState.swift @@ -125,34 +125,34 @@ extension AppAlertState where Action == Never { public static func loading(title: String? = nil) -> Self { .init( style: .toast(icon: .loading, autoHide: false), - title: TextState(title ?? L10n.Localizable.Toast.Title.loading) + title: TextState(title ?? L10n.Localizable.Toast.loading) ) } public static var communicating: Self { .init( style: .toast(icon: .loading, autoHide: false), - title: TextState(L10n.Localizable.Toast.Title.communicating) + title: TextState(L10n.Localizable.Toast.communicating) ) } public static func error(caption: String? = nil) -> Self { .init( style: .toast(icon: .error, autoHide: true), - title: TextState(L10n.Localizable.Toast.Title.error), + title: TextState(L10n.Localizable.Toast.error), message: caption.map { TextState($0) } ) } public static func success(caption: String? = nil) -> Self { .init( style: .toast(icon: .success, autoHide: true), - title: TextState(L10n.Localizable.Toast.Title.success), + title: TextState(L10n.Localizable.Toast.success), message: caption.map { TextState($0) } ) } public static var savedToPhotoLibrary: Self { - .success(caption: L10n.Localizable.Toast.Caption.savedToPhotoLibrary) + .success(caption: L10n.Localizable.Toast.savedToPhotoLibrary) } public static var copiedToClipboardSucceeded: Self { - .success(caption: L10n.Localizable.Toast.Caption.copiedToClipboard) + .success(caption: L10n.Localizable.Toast.copiedToClipboard) } } diff --git a/AppPackage/Sources/AppComponents/NewDawnView.swift b/AppPackage/Sources/AppComponents/NewDawnView.swift index bc1e890be..f77ad1a4c 100644 --- a/AppPackage/Sources/AppComponents/NewDawnView.swift +++ b/AppPackage/Sources/AppComponents/NewDawnView.swift @@ -47,8 +47,8 @@ public struct NewDawnView: View { } VStack(spacing: 50) { VStack(spacing: 10) { - TextView(text: L10n.Localizable.NewDawnView.Title.first, font: .largeTitle) - TextView(text: L10n.Localizable.NewDawnView.Title.second, font: .title2) + TextView(text: L10n.Localizable.NewDawnView.first, font: .largeTitle) + TextView(text: L10n.Localizable.NewDawnView.second, font: .title2) } TextView(text: greeting.gainContent ?? "", font: .title3, fontWeight: .bold) } diff --git a/AppPackage/Sources/AppComponents/SubSection.swift b/AppPackage/Sources/AppComponents/SubSection.swift index 879b41a73..34ee497a1 100644 --- a/AppPackage/Sources/AppComponents/SubSection.swift +++ b/AppPackage/Sources/AppComponents/SubSection.swift @@ -45,7 +45,7 @@ public struct SubSection: View { .foregroundColor(.primary) Spacer() Button(action: showAllAction) { - Text(L10n.Localizable.SubSection.Button.showAll).font(.subheadline) + Text(L10n.Localizable.SubSection.showAll).font(.subheadline) } .tint(tint).opacity(showAll ? 1 : 0) } diff --git a/AppPackage/Sources/AppComponents/TagSuggestionView.swift b/AppPackage/Sources/AppComponents/TagSuggestionView.swift index 7a5fee5b6..f21753347 100644 --- a/AppPackage/Sources/AppComponents/TagSuggestionView.swift +++ b/AppPackage/Sources/AppComponents/TagSuggestionView.swift @@ -25,7 +25,7 @@ public struct TagSuggestionView: View { public var body: some View { if isEnabled { if DeviceUtil.isPhone { - Text(L10n.Localizable.Searchable.Title.matchesCount(translationHandler.suggestions.count)) + Text(L10n.Localizable.Searchable.matchesCount(translationHandler.suggestions.count)) .foregroundColor(.secondary) .font(.subheadline) } diff --git a/AppPackage/Sources/AppComponents/ToolbarItems.swift b/AppPackage/Sources/AppComponents/ToolbarItems.swift index 2f3cff879..3a461db35 100644 --- a/AppPackage/Sources/AppComponents/ToolbarItems.swift +++ b/AppPackage/Sources/AppComponents/ToolbarItems.swift @@ -62,7 +62,7 @@ public struct FiltersButton: View { Button(action: action) { Image(systemSymbol: .line3HorizontalDecrease) if !hideText { - Text(L10n.Localizable.ToolbarItem.Button.filters) + Text(L10n.Localizable.ToolbarItem.filters) } } } @@ -81,7 +81,7 @@ public struct QuickSearchButton: View { Button(action: action) { Image(systemSymbol: .magnifyingglass) if !hideText { - Text(L10n.Localizable.ToolbarItem.Button.quickSearch) + Text(L10n.Localizable.ToolbarItem.quickSearch) } } } @@ -102,7 +102,7 @@ public struct JumpPageButton: View { Button(action: action) { Image(systemSymbol: .arrowshapeBounceForward) if !hideText { - Text(L10n.Localizable.ToolbarItem.Button.jumpPage) + Text(L10n.Localizable.ToolbarItem.jumpPage) } } .disabled(pageNumber.isSinglePage) @@ -122,7 +122,7 @@ public struct DateSeekButton: View { Button { navigation.map(action) } label: { - Label(L10n.Localizable.ToolbarItem.Button.dateSeek, systemSymbol: .calendar) + Label(L10n.Localizable.ToolbarItem.dateSeek, systemSymbol: .calendar) } .disabled(navigation == nil) } diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 8a8e21ef4..33584e413 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -137,15 +137,15 @@ extension TabBarItemType { var title: String { switch self { case .home: - return L10n.Localizable.TabItem.Title.home + return L10n.Localizable.TabItem.home case .favorites: - return L10n.Localizable.TabItem.Title.favorites + return L10n.Localizable.TabItem.favorites case .search: - return L10n.Localizable.TabItem.Title.search + return L10n.Localizable.TabItem.search case .downloads: - return L10n.Localizable.TabItem.Title.downloads + return L10n.Localizable.TabItem.downloads case .setting: - return L10n.Localizable.TabItem.Title.setting + return L10n.Localizable.TabItem.setting } } var symbol: SFSymbol { diff --git a/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift b/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift index 02056e2e3..2d76d6d61 100644 --- a/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift @@ -7,7 +7,7 @@ public enum DownloadFolderFilter: Equatable, Sendable { public var title: String { switch self { case .all: - return L10n.Localizable.Enum.DownloadFolderFilter.Title.all + return L10n.Localizable.DownloadFolderFilter.all case .folder(let name): return name } diff --git a/AppPackage/Sources/AppModels/Gallery/Category.swift b/AppPackage/Sources/AppModels/Gallery/Category.swift index 1397e6f43..ff555f947 100644 --- a/AppPackage/Sources/AppModels/Gallery/Category.swift +++ b/AppPackage/Sources/AppModels/Gallery/Category.swift @@ -47,17 +47,17 @@ extension Category { } public var value: String { switch self { - case .doujinshi: return L10n.Localizable.Enum.Category.Value.doujinshi - case .manga: return L10n.Localizable.Enum.Category.Value.manga - case .artistCG: return L10n.Localizable.Enum.Category.Value.artistCG - case .gameCG: return L10n.Localizable.Enum.Category.Value.gameCG - case .western: return L10n.Localizable.Enum.Category.Value.western - case .nonH: return L10n.Localizable.Enum.Category.Value.nonH - case .imageSet: return L10n.Localizable.Enum.Category.Value.imageSet - case .cosplay: return L10n.Localizable.Enum.Category.Value.cosplay - case .asianPorn: return L10n.Localizable.Enum.Category.Value.asianPorn - case .misc: return L10n.Localizable.Enum.Category.Value.misc - case .private: return L10n.Localizable.Enum.Category.Value.private + case .doujinshi: return L10n.Localizable.Category.doujinshi + case .manga: return L10n.Localizable.Category.manga + case .artistCG: return L10n.Localizable.Category.artistCG + case .gameCG: return L10n.Localizable.Category.gameCG + case .western: return L10n.Localizable.Category.western + case .nonH: return L10n.Localizable.Category.nonH + case .imageSet: return L10n.Localizable.Category.imageSet + case .cosplay: return L10n.Localizable.Category.cosplay + case .asianPorn: return L10n.Localizable.Category.asianPorn + case .misc: return L10n.Localizable.Category.misc + case .private: return L10n.Localizable.Category.private } } } diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift b/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift index 5e766522d..a37555d49 100644 --- a/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift +++ b/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift @@ -26,7 +26,7 @@ public struct GalleryArchive: Codable, Equatable, Sendable { public var price: String { switch gpPrice { case "Free": - return L10n.Localizable.Struct.HathArchive.Price.free + return L10n.Localizable.HathArchive.free default: return gpPrice } @@ -51,7 +51,7 @@ extension ArchiveResolution { case .x780, .x980, .x1280, .x1600, .x2400: return rawValue case .original: - return L10n.Localizable.Enum.ArchiveResolution.Value.original + return L10n.Localizable.ArchiveResolution.original } } public var parameter: String { diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift b/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift index d57a243d1..2f645f3bb 100644 --- a/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift +++ b/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift @@ -128,16 +128,16 @@ extension GalleryVisibility { public var value: String { switch self { case .yes: - return L10n.Localizable.Enum.GalleryVisibility.Value.yes + return L10n.Localizable.GalleryVisibility.yes case .no(let reason): let localizedReason: String switch reason { case "Expunged": - localizedReason = L10n.Localizable.Enum.GalleryVisibility.Value.No.Reason.expunged + localizedReason = L10n.Localizable.GalleryVisibility.expunged default: localizedReason = reason } - return L10n.Localizable.Enum.GalleryVisibility.Value.no(localizedReason) + return L10n.Localizable.GalleryVisibility.no(localizedReason) } } } diff --git a/AppPackage/Sources/AppModels/Gallery/Language.swift b/AppPackage/Sources/AppModels/Gallery/Language.swift index 674668092..1788f1365 100644 --- a/AppPackage/Sources/AppModels/Gallery/Language.swift +++ b/AppPackage/Sources/AppModels/Gallery/Language.swift @@ -32,72 +32,72 @@ extension Language { } public var value: String { switch self { - case .invalid: return L10n.Localizable.Enum.Language.Value.invalid - case .other: return L10n.Localizable.Enum.Language.Value.other - case .afrikaans: return L10n.Localizable.Enum.Language.Value.afrikaans - case .albanian: return L10n.Localizable.Enum.Language.Value.albanian - case .arabic: return L10n.Localizable.Enum.Language.Value.arabic - case .bengali: return L10n.Localizable.Enum.Language.Value.bengali - case .bosnian: return L10n.Localizable.Enum.Language.Value.bosnian - case .bulgarian: return L10n.Localizable.Enum.Language.Value.bulgarian - case .burmese: return L10n.Localizable.Enum.Language.Value.burmese - case .catalan: return L10n.Localizable.Enum.Language.Value.catalan - case .cebuano: return L10n.Localizable.Enum.Language.Value.cebuano - case .chinese: return L10n.Localizable.Enum.Language.Value.chinese - case .croatian: return L10n.Localizable.Enum.Language.Value.croatian - case .czech: return L10n.Localizable.Enum.Language.Value.czech - case .danish: return L10n.Localizable.Enum.Language.Value.danish - case .dutch: return L10n.Localizable.Enum.Language.Value.dutch - case .english: return L10n.Localizable.Enum.Language.Value.english - case .esperanto: return L10n.Localizable.Enum.Language.Value.esperanto - case .estonian: return L10n.Localizable.Enum.Language.Value.estonian - case .finnish: return L10n.Localizable.Enum.Language.Value.finnish - case .french: return L10n.Localizable.Enum.Language.Value.french - case .georgian: return L10n.Localizable.Enum.Language.Value.georgian - case .german: return L10n.Localizable.Enum.Language.Value.german - case .greek: return L10n.Localizable.Enum.Language.Value.greek - case .hebrew: return L10n.Localizable.Enum.Language.Value.hebrew - case .hindi: return L10n.Localizable.Enum.Language.Value.hindi - case .hmong: return L10n.Localizable.Enum.Language.Value.hmong - case .hungarian: return L10n.Localizable.Enum.Language.Value.hungarian - case .indonesian: return L10n.Localizable.Enum.Language.Value.indonesian - case .italian: return L10n.Localizable.Enum.Language.Value.italian - case .japanese: return L10n.Localizable.Enum.Language.Value.japanese - case .kazakh: return L10n.Localizable.Enum.Language.Value.kazakh - case .khmer: return L10n.Localizable.Enum.Language.Value.khmer - case .korean: return L10n.Localizable.Enum.Language.Value.korean - case .kurdish: return L10n.Localizable.Enum.Language.Value.kurdish - case .lao: return L10n.Localizable.Enum.Language.Value.lao - case .latin: return L10n.Localizable.Enum.Language.Value.latin - case .mongolian: return L10n.Localizable.Enum.Language.Value.mongolian - case .ndebele: return L10n.Localizable.Enum.Language.Value.ndebele - case .nepali: return L10n.Localizable.Enum.Language.Value.nepali - case .norwegian: return L10n.Localizable.Enum.Language.Value.norwegian - case .oromo: return L10n.Localizable.Enum.Language.Value.oromo - case .pashto: return L10n.Localizable.Enum.Language.Value.pashto - case .persian: return L10n.Localizable.Enum.Language.Value.persian - case .polish: return L10n.Localizable.Enum.Language.Value.polish - case .portuguese: return L10n.Localizable.Enum.Language.Value.portuguese - case .punjabi: return L10n.Localizable.Enum.Language.Value.punjabi - case .romanian: return L10n.Localizable.Enum.Language.Value.romanian - case .russian: return L10n.Localizable.Enum.Language.Value.russian - case .sango: return L10n.Localizable.Enum.Language.Value.sango - case .serbian: return L10n.Localizable.Enum.Language.Value.serbian - case .shona: return L10n.Localizable.Enum.Language.Value.shona - case .slovak: return L10n.Localizable.Enum.Language.Value.slovak - case .slovenian: return L10n.Localizable.Enum.Language.Value.slovenian - case .somali: return L10n.Localizable.Enum.Language.Value.somali - case .spanish: return L10n.Localizable.Enum.Language.Value.spanish - case .swahili: return L10n.Localizable.Enum.Language.Value.swahili - case .swedish: return L10n.Localizable.Enum.Language.Value.swedish - case .tagalog: return L10n.Localizable.Enum.Language.Value.tagalog - case .thai: return L10n.Localizable.Enum.Language.Value.thai - case .tigrinya: return L10n.Localizable.Enum.Language.Value.tigrinya - case .turkish: return L10n.Localizable.Enum.Language.Value.turkish - case .ukrainian: return L10n.Localizable.Enum.Language.Value.ukrainian - case .urdu: return L10n.Localizable.Enum.Language.Value.urdu - case .vietnamese: return L10n.Localizable.Enum.Language.Value.vietnamese - case .zulu: return L10n.Localizable.Enum.Language.Value.zulu + case .invalid: return L10n.Localizable.Language.invalid + case .other: return L10n.Localizable.Language.other + case .afrikaans: return L10n.Localizable.Language.afrikaans + case .albanian: return L10n.Localizable.Language.albanian + case .arabic: return L10n.Localizable.Language.arabic + case .bengali: return L10n.Localizable.Language.bengali + case .bosnian: return L10n.Localizable.Language.bosnian + case .bulgarian: return L10n.Localizable.Language.bulgarian + case .burmese: return L10n.Localizable.Language.burmese + case .catalan: return L10n.Localizable.Language.catalan + case .cebuano: return L10n.Localizable.Language.cebuano + case .chinese: return L10n.Localizable.Language.chinese + case .croatian: return L10n.Localizable.Language.croatian + case .czech: return L10n.Localizable.Language.czech + case .danish: return L10n.Localizable.Language.danish + case .dutch: return L10n.Localizable.Language.dutch + case .english: return L10n.Localizable.Language.english + case .esperanto: return L10n.Localizable.Language.esperanto + case .estonian: return L10n.Localizable.Language.estonian + case .finnish: return L10n.Localizable.Language.finnish + case .french: return L10n.Localizable.Language.french + case .georgian: return L10n.Localizable.Language.georgian + case .german: return L10n.Localizable.Language.german + case .greek: return L10n.Localizable.Language.greek + case .hebrew: return L10n.Localizable.Language.hebrew + case .hindi: return L10n.Localizable.Language.hindi + case .hmong: return L10n.Localizable.Language.hmong + case .hungarian: return L10n.Localizable.Language.hungarian + case .indonesian: return L10n.Localizable.Language.indonesian + case .italian: return L10n.Localizable.Language.italian + case .japanese: return L10n.Localizable.Language.japanese + case .kazakh: return L10n.Localizable.Language.kazakh + case .khmer: return L10n.Localizable.Language.khmer + case .korean: return L10n.Localizable.Language.korean + case .kurdish: return L10n.Localizable.Language.kurdish + case .lao: return L10n.Localizable.Language.lao + case .latin: return L10n.Localizable.Language.latin + case .mongolian: return L10n.Localizable.Language.mongolian + case .ndebele: return L10n.Localizable.Language.ndebele + case .nepali: return L10n.Localizable.Language.nepali + case .norwegian: return L10n.Localizable.Language.norwegian + case .oromo: return L10n.Localizable.Language.oromo + case .pashto: return L10n.Localizable.Language.pashto + case .persian: return L10n.Localizable.Language.persian + case .polish: return L10n.Localizable.Language.polish + case .portuguese: return L10n.Localizable.Language.portuguese + case .punjabi: return L10n.Localizable.Language.punjabi + case .romanian: return L10n.Localizable.Language.romanian + case .russian: return L10n.Localizable.Language.russian + case .sango: return L10n.Localizable.Language.sango + case .serbian: return L10n.Localizable.Language.serbian + case .shona: return L10n.Localizable.Language.shona + case .slovak: return L10n.Localizable.Language.slovak + case .slovenian: return L10n.Localizable.Language.slovenian + case .somali: return L10n.Localizable.Language.somali + case .spanish: return L10n.Localizable.Language.spanish + case .swahili: return L10n.Localizable.Language.swahili + case .swedish: return L10n.Localizable.Language.swedish + case .tagalog: return L10n.Localizable.Language.tagalog + case .thai: return L10n.Localizable.Language.thai + case .tigrinya: return L10n.Localizable.Language.tigrinya + case .turkish: return L10n.Localizable.Language.turkish + case .ukrainian: return L10n.Localizable.Language.ukrainian + case .urdu: return L10n.Localizable.Language.urdu + case .vietnamese: return L10n.Localizable.Language.vietnamese + case .zulu: return L10n.Localizable.Language.zulu } } } diff --git a/AppPackage/Sources/AppModels/Persistent/AppIconType.swift b/AppPackage/Sources/AppModels/Persistent/AppIconType.swift index 9bff7c66b..4a366c33b 100644 --- a/AppPackage/Sources/AppModels/Persistent/AppIconType.swift +++ b/AppPackage/Sources/AppModels/Persistent/AppIconType.swift @@ -14,19 +14,19 @@ extension AppIconType { public var name: String { switch self { case .default: - return L10n.Localizable.Enum.AppIconType.Value.default + return L10n.Localizable.AppIconType.default case .ukiyoe: - return L10n.Localizable.Enum.AppIconType.Value.ukiyoe + return L10n.Localizable.AppIconType.ukiyoe case .developer: - return L10n.Localizable.Enum.AppIconType.Value.developer + return L10n.Localizable.AppIconType.developer case .standWithUkraine2022: - return L10n.Localizable.Enum.AppIconType.Value.standWithUkraine2022 + return L10n.Localizable.AppIconType.standWithUkraine2022 case .notMyPresidnet: - return L10n.Localizable.Enum.AppIconType.Value.notMyPresident + return L10n.Localizable.AppIconType.notMyPresident } } diff --git a/AppPackage/Sources/AppModels/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift index 1d2fa52da..b5788eb81 100644 --- a/AppPackage/Sources/AppModels/Persistent/Filter.swift +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -162,11 +162,11 @@ public extension FilterRange { var value: String { switch self { case .search: - return L10n.Localizable.Enum.FilterRange.Value.search + return L10n.Localizable.FilterRange.search case .global: - return L10n.Localizable.Enum.FilterRange.Value.global + return L10n.Localizable.FilterRange.global case .watched: - return L10n.Localizable.Enum.FilterRange.Value.watched + return L10n.Localizable.FilterRange.watched } } } diff --git a/AppPackage/Sources/AppModels/Persistent/Greeting.swift b/AppPackage/Sources/AppModels/Persistent/Greeting.swift index 730918655..bb7d78f6b 100644 --- a/AppPackage/Sources/AppModels/Persistent/Greeting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Greeting.swift @@ -61,10 +61,10 @@ public struct Greeting: Codable, Equatable, Hashable, Identifiable, Sendable { public var gainContent: String? { let rewards = rewards guard !rewards.isEmpty else { return nil } - let and = L10n.Localizable.Struct.Greeting.Mark.and - let end = L10n.Localizable.Struct.Greeting.Mark.end - let start = L10n.Localizable.Struct.Greeting.Mark.start - let separator = L10n.Localizable.Struct.Greeting.Mark.separator + let and = L10n.Localizable.Greeting.and + let end = L10n.Localizable.Greeting.end + let start = L10n.Localizable.Greeting.start + let separator = L10n.Localizable.Greeting.separator let rewardDescription = rewards.enumerated().map { (offset, element) in if offset == 0 { return element diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index 1ac571850..d44aabac3 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -169,15 +169,15 @@ extension AutoLockPolicy { public var value: String { switch self { case .never: - return L10n.Localizable.Enum.AutoLockPolicy.Value.never + return L10n.Localizable.AutoLockPolicy.never case .instantly: - return L10n.Localizable.Enum.AutoLockPolicy.Value.instantly + return L10n.Localizable.AutoLockPolicy.instantly case .sec15: - return L10n.Localizable.Common.Value.seconds("\(rawValue)") + return L10n.Localizable.Common.seconds("\(rawValue)") case .min1: - return L10n.Localizable.Common.Value.minute("\(rawValue / 60)") + return L10n.Localizable.Common.minute("\(rawValue / 60)") case .min5, .min10, .min30: - return L10n.Localizable.Common.Value.minutes("\(rawValue / 60)") + return L10n.Localizable.Common.minutes("\(rawValue / 60)") } } } @@ -193,11 +193,11 @@ extension PreferredColorScheme { public var value: String { switch self { case .automatic: - return L10n.Localizable.Enum.PreferredColorScheme.Value.automatic + return L10n.Localizable.PreferredColorScheme.automatic case .light: - return L10n.Localizable.Enum.PreferredColorScheme.Value.light + return L10n.Localizable.PreferredColorScheme.light case .dark: - return L10n.Localizable.Enum.PreferredColorScheme.Value.dark + return L10n.Localizable.PreferredColorScheme.dark } } public var userInterfaceStyle: UIUserInterfaceStyle { @@ -223,11 +223,11 @@ extension ReadingDirection { public var value: String { switch self { case .vertical: - return L10n.Localizable.Enum.ReadingDirection.Value.vertical + return L10n.Localizable.ReadingDirection.vertical case .rightToLeft: - return L10n.Localizable.Enum.ReadingDirection.Value.rightToLeft + return L10n.Localizable.ReadingDirection.rightToLeft case .leftToRight: - return L10n.Localizable.Enum.ReadingDirection.Value.leftToRight + return L10n.Localizable.ReadingDirection.leftToRight } } } @@ -242,9 +242,9 @@ extension ListDisplayMode { public var value: String { switch self { case .detail: - return L10n.Localizable.Enum.ListDisplayMode.Value.detail + return L10n.Localizable.ListDisplayMode.detail case .thumbnail: - return L10n.Localizable.Enum.ListDisplayMode.Value.thumbnail + return L10n.Localizable.ListDisplayMode.thumbnail } } } diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index 6d75b8b3f..ab8328e86 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -33,8 +33,8 @@ public struct User: Codable, Equatable, Sendable { public var favoriteCategories: [Int: String]? public func getFavoriteCategory(index: Int) -> String { - guard index != -1 else { return L10n.Localizable.Struct.User.FavoriteCategory.all } - let defaultCategory = L10n.Localizable.Struct.User.FavoriteCategory.default("\(index)") + guard index != -1 else { return L10n.Localizable.FavoriteCategory.all } + let defaultCategory = L10n.Localizable.FavoriteCategory.default("\(index)") let category = favoriteCategories?[index] ?? defaultCategory let isDefault = category == "Favorites \(index)" return isDefault ? defaultCategory : category diff --git a/AppPackage/Sources/AppModels/Support/AppError.swift b/AppPackage/Sources/AppModels/Support/AppError.swift index f0ff360e6..52d09384e 100644 --- a/AppPackage/Sources/AppModels/Support/AppError.swift +++ b/AppPackage/Sources/AppModels/Support/AppError.swift @@ -37,71 +37,71 @@ extension AppError { public var localizedDescription: String { switch self { case .databaseCorrupted: - return L10n.Localizable.AppError.LocalizedDescription.databaseCorrupted + return L10n.Localizable.AppError.databaseCorrupted case .copyrightClaim: - return L10n.Localizable.AppError.LocalizedDescription.copyrightClaim + return L10n.Localizable.AppError.copyrightClaim case .ipBanned: - return L10n.Localizable.AppError.LocalizedDescription.ipBanned + return L10n.Localizable.AppError.ipBanned case .expunged: - return L10n.Localizable.AppError.LocalizedDescription.galleryExpunged + return L10n.Localizable.AppError.galleryExpunged case .networkingFailed: - return L10n.Localizable.AppError.LocalizedDescription.networkError + return L10n.Localizable.AppError.networkError case .webImageFailed: - return L10n.Localizable.AppError.LocalizedDescription.webImageLoadingError + return L10n.Localizable.AppError.webImageLoadingError case .parseFailed: - return L10n.Localizable.AppError.LocalizedDescription.parseError + return L10n.Localizable.AppError.parseError case .quotaExceeded: - return L10n.Localizable.AppError.LocalizedDescription.quotaExceeded + return L10n.Localizable.AppError.quotaExceeded case .authenticationRequired: - return L10n.Localizable.AppError.LocalizedDescription.authenticationRequired + return L10n.Localizable.AppError.authenticationRequired case .fileOperationFailed: - return L10n.Localizable.AppError.LocalizedDescription.fileOperationFailed + return L10n.Localizable.AppError.fileOperationFailed case .noUpdates: - return L10n.Localizable.AppError.LocalizedDescription.noUpdatesAvailable + return L10n.Localizable.AppError.noUpdatesAvailable case .notFound: - return L10n.Localizable.AppError.LocalizedDescription.notFound + return L10n.Localizable.AppError.notFound case .unknown: - return L10n.Localizable.AppError.LocalizedDescription.unknownError + return L10n.Localizable.AppError.unknownError } } public var alertText: String { - let tryLater = L10n.Localizable.ErrorView.Title.tryLater + let tryLater = L10n.Localizable.ErrorView.tryLater switch self { case .databaseCorrupted(let reason): - var lines = [L10n.Localizable.ErrorView.Title.databaseCorrupted] + var lines = [L10n.Localizable.ErrorView.databaseCorrupted] if let reason = reason { lines.append("(\(reason))") } return lines.joined(separator: "\n") case .copyrightClaim(let owner): - return L10n.Localizable.ErrorView.Title.copyrightClaim(owner) + return L10n.Localizable.ErrorView.copyrightClaim(owner) case .ipBanned(let interval): - return L10n.Localizable.ErrorView.Title.ipBanned(interval.description) + return L10n.Localizable.ErrorView.ipBanned(interval.description) case .expunged(let reason): switch reason { - case L10n.Constant.Website.Response.galleryUnavailable: - return L10n.Localizable.ErrorView.Title.galleryUnavailable + case L10n.Constant.galleryUnavailable: + return L10n.Localizable.ErrorView.galleryUnavailable default: return reason } case .networkingFailed: - return [L10n.Localizable.ErrorView.Title.network, tryLater].joined(separator: "\n") + return [L10n.Localizable.ErrorView.network, tryLater].joined(separator: "\n") case .parseFailed: - return [L10n.Localizable.ErrorView.Title.parsing, tryLater].joined(separator: "\n") + return [L10n.Localizable.ErrorView.parsing, tryLater].joined(separator: "\n") case .quotaExceeded: - return L10n.Localizable.AppError.Alert.quotaExceeded + return L10n.Localizable.AppError.quotaExceededDescription case .authenticationRequired: - return L10n.Localizable.AppError.Alert.authenticationRequired + return L10n.Localizable.AppError.authenticationRequiredDescription case .fileOperationFailed(let reason): - return [L10n.Localizable.AppError.Alert.localFileOperationFailed, reason] + return [L10n.Localizable.AppError.localFileOperationFailed, reason] .filter { !$0.isEmpty } .joined(separator: "\n") case .noUpdates, .webImageFailed: return "" case .notFound: - return L10n.Localizable.ErrorView.Title.notFound + return L10n.Localizable.ErrorView.notFound case .unknown: - return [L10n.Localizable.ErrorView.Title.unknown, tryLater].joined(separator: "\n") + return [L10n.Localizable.ErrorView.unknown, tryLater].joined(separator: "\n") } } } @@ -116,7 +116,7 @@ public enum BanInterval: Equatable, Hashable, Sendable { extension BanInterval { public var description: String { var params: [String] - let and = L10n.Localizable.Enum.BanInterval.Description.and + let and = L10n.Localizable.BanInterval.and switch self { case .days(let days, let hours): @@ -141,19 +141,19 @@ extension BanInterval { } private func daysWithUnit(_ days: Int) -> String { - days > 1 ? L10n.Localizable.Common.Value.days("\(days)") - : L10n.Localizable.Common.Value.day("\(days)") + days > 1 ? L10n.Localizable.Common.days("\(days)") + : L10n.Localizable.Common.day("\(days)") } private func hoursWithUnit(_ hours: Int) -> String { - hours > 1 ? L10n.Localizable.Common.Value.hours("\(hours)") - : L10n.Localizable.Common.Value.hour("\(hours)") + hours > 1 ? L10n.Localizable.Common.hours("\(hours)") + : L10n.Localizable.Common.hour("\(hours)") } private func minutesWithUnit(_ minutes: Int) -> String { - minutes > 1 ? L10n.Localizable.Common.Value.minutes("\(minutes)") - : L10n.Localizable.Common.Value.minute("\(minutes)") + minutes > 1 ? L10n.Localizable.Common.minutes("\(minutes)") + : L10n.Localizable.Common.minute("\(minutes)") } private func secondsWithUnit(_ seconds: Int) -> String { - seconds > 1 ? L10n.Localizable.Common.Value.seconds("\(seconds)") - : L10n.Localizable.Common.Value.second("\(seconds)") + seconds > 1 ? L10n.Localizable.Common.seconds("\(seconds)") + : L10n.Localizable.Common.second("\(seconds)") } } diff --git a/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift b/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift index bc0741c2a..d9748a2dd 100644 --- a/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift +++ b/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift @@ -12,258 +12,258 @@ extension EhSetting.BrowsingCountry { public var id: Int { hashValue } public var name: String { switch self { - case .autoDetect: return L10n.Localizable.Enum.BrowsingCountry.Name.autoDetect - case .afghanistan: return L10n.Localizable.Enum.BrowsingCountry.Name.afghanistan - case .alandIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.alandIslands - case .albania: return L10n.Localizable.Enum.BrowsingCountry.Name.albania - case .algeria: return L10n.Localizable.Enum.BrowsingCountry.Name.algeria - case .americanSamoa: return L10n.Localizable.Enum.BrowsingCountry.Name.americanSamoa - case .andorra: return L10n.Localizable.Enum.BrowsingCountry.Name.andorra - case .angola: return L10n.Localizable.Enum.BrowsingCountry.Name.angola - case .anguilla: return L10n.Localizable.Enum.BrowsingCountry.Name.anguilla - case .antarctica: return L10n.Localizable.Enum.BrowsingCountry.Name.antarctica - case .antiguaAndBarbuda: return L10n.Localizable.Enum.BrowsingCountry.Name.antiguaAndBarbuda - case .argentina: return L10n.Localizable.Enum.BrowsingCountry.Name.argentina - case .armenia: return L10n.Localizable.Enum.BrowsingCountry.Name.armenia - case .aruba: return L10n.Localizable.Enum.BrowsingCountry.Name.aruba - case .asiaPacificRegion: return L10n.Localizable.Enum.BrowsingCountry.Name.asiaPacificRegion - case .australia: return L10n.Localizable.Enum.BrowsingCountry.Name.australia - case .austria: return L10n.Localizable.Enum.BrowsingCountry.Name.austria - case .azerbaijan: return L10n.Localizable.Enum.BrowsingCountry.Name.azerbaijan - case .bahamas: return L10n.Localizable.Enum.BrowsingCountry.Name.bahamas - case .bahrain: return L10n.Localizable.Enum.BrowsingCountry.Name.bahrain - case .bangladesh: return L10n.Localizable.Enum.BrowsingCountry.Name.bangladesh - case .barbados: return L10n.Localizable.Enum.BrowsingCountry.Name.barbados - case .belarus: return L10n.Localizable.Enum.BrowsingCountry.Name.belarus - case .belgium: return L10n.Localizable.Enum.BrowsingCountry.Name.belgium - case .belize: return L10n.Localizable.Enum.BrowsingCountry.Name.belize - case .benin: return L10n.Localizable.Enum.BrowsingCountry.Name.benin - case .bermuda: return L10n.Localizable.Enum.BrowsingCountry.Name.bermuda - case .bhutan: return L10n.Localizable.Enum.BrowsingCountry.Name.bhutan - case .bolivia: return L10n.Localizable.Enum.BrowsingCountry.Name.bolivia - case .bonaireSaintEustatiusAndSaba: return L10n.Localizable.Enum.BrowsingCountry.Name.bonaireSaintEustatiusAndSaba - case .bosniaAndHerzegovina: return L10n.Localizable.Enum.BrowsingCountry.Name.bosniaAndHerzegovina - case .botswana: return L10n.Localizable.Enum.BrowsingCountry.Name.botswana - case .bouvetIsland: return L10n.Localizable.Enum.BrowsingCountry.Name.bouvetIsland - case .brazil: return L10n.Localizable.Enum.BrowsingCountry.Name.brazil - case .britishIndianOceanTerritory: return L10n.Localizable.Enum.BrowsingCountry.Name.britishIndianOceanTerritory - case .bruneiDarussalam: return L10n.Localizable.Enum.BrowsingCountry.Name.bruneiDarussalam - case .bulgaria: return L10n.Localizable.Enum.BrowsingCountry.Name.bulgaria - case .burkinaFaso: return L10n.Localizable.Enum.BrowsingCountry.Name.burkinaFaso - case .burundi: return L10n.Localizable.Enum.BrowsingCountry.Name.burundi - case .cambodia: return L10n.Localizable.Enum.BrowsingCountry.Name.cambodia - case .cameroon: return L10n.Localizable.Enum.BrowsingCountry.Name.cameroon - case .canada: return L10n.Localizable.Enum.BrowsingCountry.Name.canada - case .capeVerde: return L10n.Localizable.Enum.BrowsingCountry.Name.capeVerde - case .caymanIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.caymanIslands - case .centralAfricanRepublic: return L10n.Localizable.Enum.BrowsingCountry.Name.centralAfricanRepublic - case .chad: return L10n.Localizable.Enum.BrowsingCountry.Name.chad - case .chile: return L10n.Localizable.Enum.BrowsingCountry.Name.chile - case .china: return L10n.Localizable.Enum.BrowsingCountry.Name.china - case .christmasIsland: return L10n.Localizable.Enum.BrowsingCountry.Name.christmasIsland - case .cocosIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.cocosIslands - case .colombia: return L10n.Localizable.Enum.BrowsingCountry.Name.colombia - case .comoros: return L10n.Localizable.Enum.BrowsingCountry.Name.comoros - case .congo: return L10n.Localizable.Enum.BrowsingCountry.Name.congo - case .theDemocraticRepublicOfTheCongo: return L10n.Localizable.Enum.BrowsingCountry.Name.theDemocraticRepublicOfTheCongo - case .cookIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.cookIslands - case .costaRica: return L10n.Localizable.Enum.BrowsingCountry.Name.costaRica - case .coteDIvoire: return L10n.Localizable.Enum.BrowsingCountry.Name.coteDIvoire - case .croatia: return L10n.Localizable.Enum.BrowsingCountry.Name.croatia - case .cuba: return L10n.Localizable.Enum.BrowsingCountry.Name.cuba - case .curacao: return L10n.Localizable.Enum.BrowsingCountry.Name.curacao - case .cyprus: return L10n.Localizable.Enum.BrowsingCountry.Name.cyprus - case .czechRepublic: return L10n.Localizable.Enum.BrowsingCountry.Name.czechRepublic - case .denmark: return L10n.Localizable.Enum.BrowsingCountry.Name.denmark - case .djibouti: return L10n.Localizable.Enum.BrowsingCountry.Name.djibouti - case .dominica: return L10n.Localizable.Enum.BrowsingCountry.Name.dominica - case .dominicanRepublic: return L10n.Localizable.Enum.BrowsingCountry.Name.dominicanRepublic - case .ecuador: return L10n.Localizable.Enum.BrowsingCountry.Name.ecuador - case .egypt: return L10n.Localizable.Enum.BrowsingCountry.Name.egypt - case .elSalvador: return L10n.Localizable.Enum.BrowsingCountry.Name.elSalvador - case .equatorialGuinea: return L10n.Localizable.Enum.BrowsingCountry.Name.equatorialGuinea - case .eritrea: return L10n.Localizable.Enum.BrowsingCountry.Name.eritrea - case .estonia: return L10n.Localizable.Enum.BrowsingCountry.Name.estonia - case .ethiopia: return L10n.Localizable.Enum.BrowsingCountry.Name.ethiopia - case .europe: return L10n.Localizable.Enum.BrowsingCountry.Name.europe - case .falklandIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.falklandIslands - case .faroeIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.faroeIslands - case .fiji: return L10n.Localizable.Enum.BrowsingCountry.Name.fiji - case .finland: return L10n.Localizable.Enum.BrowsingCountry.Name.finland - case .france: return L10n.Localizable.Enum.BrowsingCountry.Name.france - case .frenchGuiana: return L10n.Localizable.Enum.BrowsingCountry.Name.frenchGuiana - case .frenchPolynesia: return L10n.Localizable.Enum.BrowsingCountry.Name.frenchPolynesia - case .frenchSouthernTerritories: return L10n.Localizable.Enum.BrowsingCountry.Name.frenchSouthernTerritories - case .gabon: return L10n.Localizable.Enum.BrowsingCountry.Name.gabon - case .gambia: return L10n.Localizable.Enum.BrowsingCountry.Name.gambia - case .georgia: return L10n.Localizable.Enum.BrowsingCountry.Name.georgia - case .germany: return L10n.Localizable.Enum.BrowsingCountry.Name.germany - case .ghana: return L10n.Localizable.Enum.BrowsingCountry.Name.ghana - case .gibraltar: return L10n.Localizable.Enum.BrowsingCountry.Name.gibraltar - case .greece: return L10n.Localizable.Enum.BrowsingCountry.Name.greece - case .greenland: return L10n.Localizable.Enum.BrowsingCountry.Name.greenland - case .grenada: return L10n.Localizable.Enum.BrowsingCountry.Name.grenada - case .guadeloupe: return L10n.Localizable.Enum.BrowsingCountry.Name.guadeloupe - case .guam: return L10n.Localizable.Enum.BrowsingCountry.Name.guam - case .guatemala: return L10n.Localizable.Enum.BrowsingCountry.Name.guatemala - case .guernsey: return L10n.Localizable.Enum.BrowsingCountry.Name.guernsey - case .guinea: return L10n.Localizable.Enum.BrowsingCountry.Name.guinea - case .guineaBissau: return L10n.Localizable.Enum.BrowsingCountry.Name.guineaBissau - case .guyana: return L10n.Localizable.Enum.BrowsingCountry.Name.guyana - case .haiti: return L10n.Localizable.Enum.BrowsingCountry.Name.haiti - case .heardIslandAndMcDonaldIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.heardIslandAndMcDonaldIslands - case .vaticanCityState: return L10n.Localizable.Enum.BrowsingCountry.Name.vaticanCityState - case .honduras: return L10n.Localizable.Enum.BrowsingCountry.Name.honduras - case .hongKong: return L10n.Localizable.Enum.BrowsingCountry.Name.hongKong - case .hungary: return L10n.Localizable.Enum.BrowsingCountry.Name.hungary - case .iceland: return L10n.Localizable.Enum.BrowsingCountry.Name.iceland - case .india: return L10n.Localizable.Enum.BrowsingCountry.Name.india - case .indonesia: return L10n.Localizable.Enum.BrowsingCountry.Name.indonesia - case .iran: return L10n.Localizable.Enum.BrowsingCountry.Name.iran - case .iraq: return L10n.Localizable.Enum.BrowsingCountry.Name.iraq - case .ireland: return L10n.Localizable.Enum.BrowsingCountry.Name.ireland - case .isleOfMan: return L10n.Localizable.Enum.BrowsingCountry.Name.isleOfMan - case .israel: return L10n.Localizable.Enum.BrowsingCountry.Name.israel - case .italy: return L10n.Localizable.Enum.BrowsingCountry.Name.italy - case .jamaica: return L10n.Localizable.Enum.BrowsingCountry.Name.jamaica - case .japan: return L10n.Localizable.Enum.BrowsingCountry.Name.japan - case .jersey: return L10n.Localizable.Enum.BrowsingCountry.Name.jersey - case .jordan: return L10n.Localizable.Enum.BrowsingCountry.Name.jordan - case .kazakhstan: return L10n.Localizable.Enum.BrowsingCountry.Name.kazakhstan - case .kenya: return L10n.Localizable.Enum.BrowsingCountry.Name.kenya - case .kiribati: return L10n.Localizable.Enum.BrowsingCountry.Name.kiribati - case .kuwait: return L10n.Localizable.Enum.BrowsingCountry.Name.kuwait - case .kyrgyzstan: return L10n.Localizable.Enum.BrowsingCountry.Name.kyrgyzstan - case .laoPeoplesDemocraticRepublic: return L10n.Localizable.Enum.BrowsingCountry.Name.laoPeoplesDemocraticRepublic - case .latvia: return L10n.Localizable.Enum.BrowsingCountry.Name.latvia - case .lebanon: return L10n.Localizable.Enum.BrowsingCountry.Name.lebanon - case .lesotho: return L10n.Localizable.Enum.BrowsingCountry.Name.lesotho - case .liberia: return L10n.Localizable.Enum.BrowsingCountry.Name.liberia - case .libya: return L10n.Localizable.Enum.BrowsingCountry.Name.libya - case .liechtenstein: return L10n.Localizable.Enum.BrowsingCountry.Name.liechtenstein - case .lithuania: return L10n.Localizable.Enum.BrowsingCountry.Name.lithuania - case .luxembourg: return L10n.Localizable.Enum.BrowsingCountry.Name.luxembourg - case .macau: return L10n.Localizable.Enum.BrowsingCountry.Name.macau - case .macedonia: return L10n.Localizable.Enum.BrowsingCountry.Name.macedonia - case .madagascar: return L10n.Localizable.Enum.BrowsingCountry.Name.madagascar - case .malawi: return L10n.Localizable.Enum.BrowsingCountry.Name.malawi - case .malaysia: return L10n.Localizable.Enum.BrowsingCountry.Name.malaysia - case .maldives: return L10n.Localizable.Enum.BrowsingCountry.Name.maldives - case .mali: return L10n.Localizable.Enum.BrowsingCountry.Name.mali - case .malta: return L10n.Localizable.Enum.BrowsingCountry.Name.malta - case .marshallIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.marshallIslands - case .martinique: return L10n.Localizable.Enum.BrowsingCountry.Name.martinique - case .mauritania: return L10n.Localizable.Enum.BrowsingCountry.Name.mauritania - case .mauritius: return L10n.Localizable.Enum.BrowsingCountry.Name.mauritius - case .mayotte: return L10n.Localizable.Enum.BrowsingCountry.Name.mayotte - case .mexico: return L10n.Localizable.Enum.BrowsingCountry.Name.mexico - case .micronesia: return L10n.Localizable.Enum.BrowsingCountry.Name.micronesia - case .moldova: return L10n.Localizable.Enum.BrowsingCountry.Name.moldova - case .monaco: return L10n.Localizable.Enum.BrowsingCountry.Name.monaco - case .mongolia: return L10n.Localizable.Enum.BrowsingCountry.Name.mongolia - case .montenegro: return L10n.Localizable.Enum.BrowsingCountry.Name.montenegro - case .montserrat: return L10n.Localizable.Enum.BrowsingCountry.Name.montserrat - case .morocco: return L10n.Localizable.Enum.BrowsingCountry.Name.morocco - case .mozambique: return L10n.Localizable.Enum.BrowsingCountry.Name.mozambique - case .myanmar: return L10n.Localizable.Enum.BrowsingCountry.Name.myanmar - case .namibia: return L10n.Localizable.Enum.BrowsingCountry.Name.namibia - case .nauru: return L10n.Localizable.Enum.BrowsingCountry.Name.nauru - case .nepal: return L10n.Localizable.Enum.BrowsingCountry.Name.nepal - case .netherlands: return L10n.Localizable.Enum.BrowsingCountry.Name.netherlands - case .newCaledonia: return L10n.Localizable.Enum.BrowsingCountry.Name.newCaledonia - case .newZealand: return L10n.Localizable.Enum.BrowsingCountry.Name.newZealand - case .nicaragua: return L10n.Localizable.Enum.BrowsingCountry.Name.nicaragua - case .niger: return L10n.Localizable.Enum.BrowsingCountry.Name.niger - case .nigeria: return L10n.Localizable.Enum.BrowsingCountry.Name.nigeria - case .niue: return L10n.Localizable.Enum.BrowsingCountry.Name.niue - case .norfolkIsland: return L10n.Localizable.Enum.BrowsingCountry.Name.norfolkIsland - case .northKorea: return L10n.Localizable.Enum.BrowsingCountry.Name.northKorea - case .northernMarianaIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.northernMarianaIslands - case .norway: return L10n.Localizable.Enum.BrowsingCountry.Name.norway - case .oman: return L10n.Localizable.Enum.BrowsingCountry.Name.oman - case .pakistan: return L10n.Localizable.Enum.BrowsingCountry.Name.pakistan - case .palau: return L10n.Localizable.Enum.BrowsingCountry.Name.palau - case .palestinianTerritory: return L10n.Localizable.Enum.BrowsingCountry.Name.palestinianTerritory - case .panama: return L10n.Localizable.Enum.BrowsingCountry.Name.panama - case .papuaNewGuinea: return L10n.Localizable.Enum.BrowsingCountry.Name.papuaNewGuinea - case .paraguay: return L10n.Localizable.Enum.BrowsingCountry.Name.paraguay - case .peru: return L10n.Localizable.Enum.BrowsingCountry.Name.peru - case .philippines: return L10n.Localizable.Enum.BrowsingCountry.Name.philippines - case .pitcairnIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.pitcairnIslands - case .poland: return L10n.Localizable.Enum.BrowsingCountry.Name.poland - case .portugal: return L10n.Localizable.Enum.BrowsingCountry.Name.portugal - case .puertoRico: return L10n.Localizable.Enum.BrowsingCountry.Name.puertoRico - case .qatar: return L10n.Localizable.Enum.BrowsingCountry.Name.qatar - case .reunion: return L10n.Localizable.Enum.BrowsingCountry.Name.reunion - case .romania: return L10n.Localizable.Enum.BrowsingCountry.Name.romania - case .russianFederation: return L10n.Localizable.Enum.BrowsingCountry.Name.russianFederation - case .rwanda: return L10n.Localizable.Enum.BrowsingCountry.Name.rwanda - case .saintBarthelemy: return L10n.Localizable.Enum.BrowsingCountry.Name.saintBarthelemy - case .saintHelena: return L10n.Localizable.Enum.BrowsingCountry.Name.saintHelena - case .saintKittsAndNevis: return L10n.Localizable.Enum.BrowsingCountry.Name.saintKittsAndNevis - case .saintLucia: return L10n.Localizable.Enum.BrowsingCountry.Name.saintLucia - case .saintMartin: return L10n.Localizable.Enum.BrowsingCountry.Name.saintMartin - case .saintPierreAndMiquelon: return L10n.Localizable.Enum.BrowsingCountry.Name.saintPierreAndMiquelon - case .saintVincentAndTheGrenadines: return L10n.Localizable.Enum.BrowsingCountry.Name.saintVincentAndTheGrenadines - case .samoa: return L10n.Localizable.Enum.BrowsingCountry.Name.samoa - case .sanMarino: return L10n.Localizable.Enum.BrowsingCountry.Name.sanMarino - case .saoTomeAndPrincipe: return L10n.Localizable.Enum.BrowsingCountry.Name.saoTomeAndPrincipe - case .saudiArabia: return L10n.Localizable.Enum.BrowsingCountry.Name.saudiArabia - case .senegal: return L10n.Localizable.Enum.BrowsingCountry.Name.senegal - case .serbia: return L10n.Localizable.Enum.BrowsingCountry.Name.serbia - case .seychelles: return L10n.Localizable.Enum.BrowsingCountry.Name.seychelles - case .sierraLeone: return L10n.Localizable.Enum.BrowsingCountry.Name.sierraLeone - case .singapore: return L10n.Localizable.Enum.BrowsingCountry.Name.singapore - case .sintMaarten: return L10n.Localizable.Enum.BrowsingCountry.Name.sintMaarten - case .slovakia: return L10n.Localizable.Enum.BrowsingCountry.Name.slovakia - case .slovenia: return L10n.Localizable.Enum.BrowsingCountry.Name.slovenia - case .solomonIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.solomonIslands - case .somalia: return L10n.Localizable.Enum.BrowsingCountry.Name.somalia - case .southAfrica: return L10n.Localizable.Enum.BrowsingCountry.Name.southAfrica - case .southGeorgiaAndTheSouthSandwichIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.southGeorgiaAndTheSouthSandwichIslands - case .southKorea: return L10n.Localizable.Enum.BrowsingCountry.Name.southKorea - case .southSudan: return L10n.Localizable.Enum.BrowsingCountry.Name.southSudan - case .spain: return L10n.Localizable.Enum.BrowsingCountry.Name.spain - case .sriLanka: return L10n.Localizable.Enum.BrowsingCountry.Name.sriLanka - case .sudan: return L10n.Localizable.Enum.BrowsingCountry.Name.sudan - case .suriname: return L10n.Localizable.Enum.BrowsingCountry.Name.suriname - case .svalbardAndJanMayen: return L10n.Localizable.Enum.BrowsingCountry.Name.svalbardAndJanMayen - case .swaziland: return L10n.Localizable.Enum.BrowsingCountry.Name.swaziland - case .sweden: return L10n.Localizable.Enum.BrowsingCountry.Name.sweden - case .switzerland: return L10n.Localizable.Enum.BrowsingCountry.Name.switzerland - case .syrianArabRepublic: return L10n.Localizable.Enum.BrowsingCountry.Name.syrianArabRepublic - case .taiwan: return L10n.Localizable.Enum.BrowsingCountry.Name.taiwan - case .tajikistan: return L10n.Localizable.Enum.BrowsingCountry.Name.tajikistan - case .tanzania: return L10n.Localizable.Enum.BrowsingCountry.Name.tanzania - case .thailand: return L10n.Localizable.Enum.BrowsingCountry.Name.thailand - case .timorLeste: return L10n.Localizable.Enum.BrowsingCountry.Name.timorLeste - case .togo: return L10n.Localizable.Enum.BrowsingCountry.Name.togo - case .tokelau: return L10n.Localizable.Enum.BrowsingCountry.Name.tokelau - case .tonga: return L10n.Localizable.Enum.BrowsingCountry.Name.tonga - case .trinidadAndTobago: return L10n.Localizable.Enum.BrowsingCountry.Name.trinidadAndTobago - case .tunisia: return L10n.Localizable.Enum.BrowsingCountry.Name.tunisia - case .turkey: return L10n.Localizable.Enum.BrowsingCountry.Name.turkey - case .turkmenistan: return L10n.Localizable.Enum.BrowsingCountry.Name.turkmenistan - case .turksAndCaicosIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.turksAndCaicosIslands - case .tuvalu: return L10n.Localizable.Enum.BrowsingCountry.Name.tuvalu - case .uganda: return L10n.Localizable.Enum.BrowsingCountry.Name.uganda - case .ukraine: return L10n.Localizable.Enum.BrowsingCountry.Name.ukraine - case .unitedArabEmirates: return L10n.Localizable.Enum.BrowsingCountry.Name.unitedArabEmirates - case .unitedKingdom: return L10n.Localizable.Enum.BrowsingCountry.Name.unitedKingdom - case .unitedStates: return L10n.Localizable.Enum.BrowsingCountry.Name.unitedStates - case .unitedStatesMinorOutlyingIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.unitedStatesMinorOutlyingIslands - case .uruguay: return L10n.Localizable.Enum.BrowsingCountry.Name.uruguay - case .uzbekistan: return L10n.Localizable.Enum.BrowsingCountry.Name.uzbekistan - case .vanuatu: return L10n.Localizable.Enum.BrowsingCountry.Name.vanuatu - case .venezuela: return L10n.Localizable.Enum.BrowsingCountry.Name.venezuela - case .vietnam: return L10n.Localizable.Enum.BrowsingCountry.Name.vietnam - case .virginIslandsBritish: return L10n.Localizable.Enum.BrowsingCountry.Name.virginIslandsBritish - case .virginIslandsUS: return L10n.Localizable.Enum.BrowsingCountry.Name.virginIslandsUS - case .wallisAndFutuna: return L10n.Localizable.Enum.BrowsingCountry.Name.wallisAndFutuna - case .westernSahara: return L10n.Localizable.Enum.BrowsingCountry.Name.westernSahara - case .yemen: return L10n.Localizable.Enum.BrowsingCountry.Name.yemen - case .zambia: return L10n.Localizable.Enum.BrowsingCountry.Name.zambia - case .zimbabwe: return L10n.Localizable.Enum.BrowsingCountry.Name.zimbabwe + case .autoDetect: return L10n.Localizable.BrowsingCountry.autoDetect + case .afghanistan: return L10n.Localizable.BrowsingCountry.afghanistan + case .alandIslands: return L10n.Localizable.BrowsingCountry.alandIslands + case .albania: return L10n.Localizable.BrowsingCountry.albania + case .algeria: return L10n.Localizable.BrowsingCountry.algeria + case .americanSamoa: return L10n.Localizable.BrowsingCountry.americanSamoa + case .andorra: return L10n.Localizable.BrowsingCountry.andorra + case .angola: return L10n.Localizable.BrowsingCountry.angola + case .anguilla: return L10n.Localizable.BrowsingCountry.anguilla + case .antarctica: return L10n.Localizable.BrowsingCountry.antarctica + case .antiguaAndBarbuda: return L10n.Localizable.BrowsingCountry.antiguaAndBarbuda + case .argentina: return L10n.Localizable.BrowsingCountry.argentina + case .armenia: return L10n.Localizable.BrowsingCountry.armenia + case .aruba: return L10n.Localizable.BrowsingCountry.aruba + case .asiaPacificRegion: return L10n.Localizable.BrowsingCountry.asiaPacificRegion + case .australia: return L10n.Localizable.BrowsingCountry.australia + case .austria: return L10n.Localizable.BrowsingCountry.austria + case .azerbaijan: return L10n.Localizable.BrowsingCountry.azerbaijan + case .bahamas: return L10n.Localizable.BrowsingCountry.bahamas + case .bahrain: return L10n.Localizable.BrowsingCountry.bahrain + case .bangladesh: return L10n.Localizable.BrowsingCountry.bangladesh + case .barbados: return L10n.Localizable.BrowsingCountry.barbados + case .belarus: return L10n.Localizable.BrowsingCountry.belarus + case .belgium: return L10n.Localizable.BrowsingCountry.belgium + case .belize: return L10n.Localizable.BrowsingCountry.belize + case .benin: return L10n.Localizable.BrowsingCountry.benin + case .bermuda: return L10n.Localizable.BrowsingCountry.bermuda + case .bhutan: return L10n.Localizable.BrowsingCountry.bhutan + case .bolivia: return L10n.Localizable.BrowsingCountry.bolivia + case .bonaireSaintEustatiusAndSaba: return L10n.Localizable.BrowsingCountry.bonaireSaintEustatiusAndSaba + case .bosniaAndHerzegovina: return L10n.Localizable.BrowsingCountry.bosniaAndHerzegovina + case .botswana: return L10n.Localizable.BrowsingCountry.botswana + case .bouvetIsland: return L10n.Localizable.BrowsingCountry.bouvetIsland + case .brazil: return L10n.Localizable.BrowsingCountry.brazil + case .britishIndianOceanTerritory: return L10n.Localizable.BrowsingCountry.britishIndianOceanTerritory + case .bruneiDarussalam: return L10n.Localizable.BrowsingCountry.bruneiDarussalam + case .bulgaria: return L10n.Localizable.BrowsingCountry.bulgaria + case .burkinaFaso: return L10n.Localizable.BrowsingCountry.burkinaFaso + case .burundi: return L10n.Localizable.BrowsingCountry.burundi + case .cambodia: return L10n.Localizable.BrowsingCountry.cambodia + case .cameroon: return L10n.Localizable.BrowsingCountry.cameroon + case .canada: return L10n.Localizable.BrowsingCountry.canada + case .capeVerde: return L10n.Localizable.BrowsingCountry.capeVerde + case .caymanIslands: return L10n.Localizable.BrowsingCountry.caymanIslands + case .centralAfricanRepublic: return L10n.Localizable.BrowsingCountry.centralAfricanRepublic + case .chad: return L10n.Localizable.BrowsingCountry.chad + case .chile: return L10n.Localizable.BrowsingCountry.chile + case .china: return L10n.Localizable.BrowsingCountry.china + case .christmasIsland: return L10n.Localizable.BrowsingCountry.christmasIsland + case .cocosIslands: return L10n.Localizable.BrowsingCountry.cocosIslands + case .colombia: return L10n.Localizable.BrowsingCountry.colombia + case .comoros: return L10n.Localizable.BrowsingCountry.comoros + case .congo: return L10n.Localizable.BrowsingCountry.congo + case .theDemocraticRepublicOfTheCongo: return L10n.Localizable.BrowsingCountry.theDemocraticRepublicOfTheCongo + case .cookIslands: return L10n.Localizable.BrowsingCountry.cookIslands + case .costaRica: return L10n.Localizable.BrowsingCountry.costaRica + case .coteDIvoire: return L10n.Localizable.BrowsingCountry.coteDIvoire + case .croatia: return L10n.Localizable.BrowsingCountry.croatia + case .cuba: return L10n.Localizable.BrowsingCountry.cuba + case .curacao: return L10n.Localizable.BrowsingCountry.curacao + case .cyprus: return L10n.Localizable.BrowsingCountry.cyprus + case .czechRepublic: return L10n.Localizable.BrowsingCountry.czechRepublic + case .denmark: return L10n.Localizable.BrowsingCountry.denmark + case .djibouti: return L10n.Localizable.BrowsingCountry.djibouti + case .dominica: return L10n.Localizable.BrowsingCountry.dominica + case .dominicanRepublic: return L10n.Localizable.BrowsingCountry.dominicanRepublic + case .ecuador: return L10n.Localizable.BrowsingCountry.ecuador + case .egypt: return L10n.Localizable.BrowsingCountry.egypt + case .elSalvador: return L10n.Localizable.BrowsingCountry.elSalvador + case .equatorialGuinea: return L10n.Localizable.BrowsingCountry.equatorialGuinea + case .eritrea: return L10n.Localizable.BrowsingCountry.eritrea + case .estonia: return L10n.Localizable.BrowsingCountry.estonia + case .ethiopia: return L10n.Localizable.BrowsingCountry.ethiopia + case .europe: return L10n.Localizable.BrowsingCountry.europe + case .falklandIslands: return L10n.Localizable.BrowsingCountry.falklandIslands + case .faroeIslands: return L10n.Localizable.BrowsingCountry.faroeIslands + case .fiji: return L10n.Localizable.BrowsingCountry.fiji + case .finland: return L10n.Localizable.BrowsingCountry.finland + case .france: return L10n.Localizable.BrowsingCountry.france + case .frenchGuiana: return L10n.Localizable.BrowsingCountry.frenchGuiana + case .frenchPolynesia: return L10n.Localizable.BrowsingCountry.frenchPolynesia + case .frenchSouthernTerritories: return L10n.Localizable.BrowsingCountry.frenchSouthernTerritories + case .gabon: return L10n.Localizable.BrowsingCountry.gabon + case .gambia: return L10n.Localizable.BrowsingCountry.gambia + case .georgia: return L10n.Localizable.BrowsingCountry.georgia + case .germany: return L10n.Localizable.BrowsingCountry.germany + case .ghana: return L10n.Localizable.BrowsingCountry.ghana + case .gibraltar: return L10n.Localizable.BrowsingCountry.gibraltar + case .greece: return L10n.Localizable.BrowsingCountry.greece + case .greenland: return L10n.Localizable.BrowsingCountry.greenland + case .grenada: return L10n.Localizable.BrowsingCountry.grenada + case .guadeloupe: return L10n.Localizable.BrowsingCountry.guadeloupe + case .guam: return L10n.Localizable.BrowsingCountry.guam + case .guatemala: return L10n.Localizable.BrowsingCountry.guatemala + case .guernsey: return L10n.Localizable.BrowsingCountry.guernsey + case .guinea: return L10n.Localizable.BrowsingCountry.guinea + case .guineaBissau: return L10n.Localizable.BrowsingCountry.guineaBissau + case .guyana: return L10n.Localizable.BrowsingCountry.guyana + case .haiti: return L10n.Localizable.BrowsingCountry.haiti + case .heardIslandAndMcDonaldIslands: return L10n.Localizable.BrowsingCountry.heardIslandAndMcDonaldIslands + case .vaticanCityState: return L10n.Localizable.BrowsingCountry.vaticanCityState + case .honduras: return L10n.Localizable.BrowsingCountry.honduras + case .hongKong: return L10n.Localizable.BrowsingCountry.hongKong + case .hungary: return L10n.Localizable.BrowsingCountry.hungary + case .iceland: return L10n.Localizable.BrowsingCountry.iceland + case .india: return L10n.Localizable.BrowsingCountry.india + case .indonesia: return L10n.Localizable.BrowsingCountry.indonesia + case .iran: return L10n.Localizable.BrowsingCountry.iran + case .iraq: return L10n.Localizable.BrowsingCountry.iraq + case .ireland: return L10n.Localizable.BrowsingCountry.ireland + case .isleOfMan: return L10n.Localizable.BrowsingCountry.isleOfMan + case .israel: return L10n.Localizable.BrowsingCountry.israel + case .italy: return L10n.Localizable.BrowsingCountry.italy + case .jamaica: return L10n.Localizable.BrowsingCountry.jamaica + case .japan: return L10n.Localizable.BrowsingCountry.japan + case .jersey: return L10n.Localizable.BrowsingCountry.jersey + case .jordan: return L10n.Localizable.BrowsingCountry.jordan + case .kazakhstan: return L10n.Localizable.BrowsingCountry.kazakhstan + case .kenya: return L10n.Localizable.BrowsingCountry.kenya + case .kiribati: return L10n.Localizable.BrowsingCountry.kiribati + case .kuwait: return L10n.Localizable.BrowsingCountry.kuwait + case .kyrgyzstan: return L10n.Localizable.BrowsingCountry.kyrgyzstan + case .laoPeoplesDemocraticRepublic: return L10n.Localizable.BrowsingCountry.laoPeoplesDemocraticRepublic + case .latvia: return L10n.Localizable.BrowsingCountry.latvia + case .lebanon: return L10n.Localizable.BrowsingCountry.lebanon + case .lesotho: return L10n.Localizable.BrowsingCountry.lesotho + case .liberia: return L10n.Localizable.BrowsingCountry.liberia + case .libya: return L10n.Localizable.BrowsingCountry.libya + case .liechtenstein: return L10n.Localizable.BrowsingCountry.liechtenstein + case .lithuania: return L10n.Localizable.BrowsingCountry.lithuania + case .luxembourg: return L10n.Localizable.BrowsingCountry.luxembourg + case .macau: return L10n.Localizable.BrowsingCountry.macau + case .macedonia: return L10n.Localizable.BrowsingCountry.macedonia + case .madagascar: return L10n.Localizable.BrowsingCountry.madagascar + case .malawi: return L10n.Localizable.BrowsingCountry.malawi + case .malaysia: return L10n.Localizable.BrowsingCountry.malaysia + case .maldives: return L10n.Localizable.BrowsingCountry.maldives + case .mali: return L10n.Localizable.BrowsingCountry.mali + case .malta: return L10n.Localizable.BrowsingCountry.malta + case .marshallIslands: return L10n.Localizable.BrowsingCountry.marshallIslands + case .martinique: return L10n.Localizable.BrowsingCountry.martinique + case .mauritania: return L10n.Localizable.BrowsingCountry.mauritania + case .mauritius: return L10n.Localizable.BrowsingCountry.mauritius + case .mayotte: return L10n.Localizable.BrowsingCountry.mayotte + case .mexico: return L10n.Localizable.BrowsingCountry.mexico + case .micronesia: return L10n.Localizable.BrowsingCountry.micronesia + case .moldova: return L10n.Localizable.BrowsingCountry.moldova + case .monaco: return L10n.Localizable.BrowsingCountry.monaco + case .mongolia: return L10n.Localizable.BrowsingCountry.mongolia + case .montenegro: return L10n.Localizable.BrowsingCountry.montenegro + case .montserrat: return L10n.Localizable.BrowsingCountry.montserrat + case .morocco: return L10n.Localizable.BrowsingCountry.morocco + case .mozambique: return L10n.Localizable.BrowsingCountry.mozambique + case .myanmar: return L10n.Localizable.BrowsingCountry.myanmar + case .namibia: return L10n.Localizable.BrowsingCountry.namibia + case .nauru: return L10n.Localizable.BrowsingCountry.nauru + case .nepal: return L10n.Localizable.BrowsingCountry.nepal + case .netherlands: return L10n.Localizable.BrowsingCountry.netherlands + case .newCaledonia: return L10n.Localizable.BrowsingCountry.newCaledonia + case .newZealand: return L10n.Localizable.BrowsingCountry.newZealand + case .nicaragua: return L10n.Localizable.BrowsingCountry.nicaragua + case .niger: return L10n.Localizable.BrowsingCountry.niger + case .nigeria: return L10n.Localizable.BrowsingCountry.nigeria + case .niue: return L10n.Localizable.BrowsingCountry.niue + case .norfolkIsland: return L10n.Localizable.BrowsingCountry.norfolkIsland + case .northKorea: return L10n.Localizable.BrowsingCountry.northKorea + case .northernMarianaIslands: return L10n.Localizable.BrowsingCountry.northernMarianaIslands + case .norway: return L10n.Localizable.BrowsingCountry.norway + case .oman: return L10n.Localizable.BrowsingCountry.oman + case .pakistan: return L10n.Localizable.BrowsingCountry.pakistan + case .palau: return L10n.Localizable.BrowsingCountry.palau + case .palestinianTerritory: return L10n.Localizable.BrowsingCountry.palestinianTerritory + case .panama: return L10n.Localizable.BrowsingCountry.panama + case .papuaNewGuinea: return L10n.Localizable.BrowsingCountry.papuaNewGuinea + case .paraguay: return L10n.Localizable.BrowsingCountry.paraguay + case .peru: return L10n.Localizable.BrowsingCountry.peru + case .philippines: return L10n.Localizable.BrowsingCountry.philippines + case .pitcairnIslands: return L10n.Localizable.BrowsingCountry.pitcairnIslands + case .poland: return L10n.Localizable.BrowsingCountry.poland + case .portugal: return L10n.Localizable.BrowsingCountry.portugal + case .puertoRico: return L10n.Localizable.BrowsingCountry.puertoRico + case .qatar: return L10n.Localizable.BrowsingCountry.qatar + case .reunion: return L10n.Localizable.BrowsingCountry.reunion + case .romania: return L10n.Localizable.BrowsingCountry.romania + case .russianFederation: return L10n.Localizable.BrowsingCountry.russianFederation + case .rwanda: return L10n.Localizable.BrowsingCountry.rwanda + case .saintBarthelemy: return L10n.Localizable.BrowsingCountry.saintBarthelemy + case .saintHelena: return L10n.Localizable.BrowsingCountry.saintHelena + case .saintKittsAndNevis: return L10n.Localizable.BrowsingCountry.saintKittsAndNevis + case .saintLucia: return L10n.Localizable.BrowsingCountry.saintLucia + case .saintMartin: return L10n.Localizable.BrowsingCountry.saintMartin + case .saintPierreAndMiquelon: return L10n.Localizable.BrowsingCountry.saintPierreAndMiquelon + case .saintVincentAndTheGrenadines: return L10n.Localizable.BrowsingCountry.saintVincentAndTheGrenadines + case .samoa: return L10n.Localizable.BrowsingCountry.samoa + case .sanMarino: return L10n.Localizable.BrowsingCountry.sanMarino + case .saoTomeAndPrincipe: return L10n.Localizable.BrowsingCountry.saoTomeAndPrincipe + case .saudiArabia: return L10n.Localizable.BrowsingCountry.saudiArabia + case .senegal: return L10n.Localizable.BrowsingCountry.senegal + case .serbia: return L10n.Localizable.BrowsingCountry.serbia + case .seychelles: return L10n.Localizable.BrowsingCountry.seychelles + case .sierraLeone: return L10n.Localizable.BrowsingCountry.sierraLeone + case .singapore: return L10n.Localizable.BrowsingCountry.singapore + case .sintMaarten: return L10n.Localizable.BrowsingCountry.sintMaarten + case .slovakia: return L10n.Localizable.BrowsingCountry.slovakia + case .slovenia: return L10n.Localizable.BrowsingCountry.slovenia + case .solomonIslands: return L10n.Localizable.BrowsingCountry.solomonIslands + case .somalia: return L10n.Localizable.BrowsingCountry.somalia + case .southAfrica: return L10n.Localizable.BrowsingCountry.southAfrica + case .southGeorgiaAndTheSouthSandwichIslands: return L10n.Localizable.BrowsingCountry.southGeorgiaAndTheSouthSandwichIslands + case .southKorea: return L10n.Localizable.BrowsingCountry.southKorea + case .southSudan: return L10n.Localizable.BrowsingCountry.southSudan + case .spain: return L10n.Localizable.BrowsingCountry.spain + case .sriLanka: return L10n.Localizable.BrowsingCountry.sriLanka + case .sudan: return L10n.Localizable.BrowsingCountry.sudan + case .suriname: return L10n.Localizable.BrowsingCountry.suriname + case .svalbardAndJanMayen: return L10n.Localizable.BrowsingCountry.svalbardAndJanMayen + case .swaziland: return L10n.Localizable.BrowsingCountry.swaziland + case .sweden: return L10n.Localizable.BrowsingCountry.sweden + case .switzerland: return L10n.Localizable.BrowsingCountry.switzerland + case .syrianArabRepublic: return L10n.Localizable.BrowsingCountry.syrianArabRepublic + case .taiwan: return L10n.Localizable.BrowsingCountry.taiwan + case .tajikistan: return L10n.Localizable.BrowsingCountry.tajikistan + case .tanzania: return L10n.Localizable.BrowsingCountry.tanzania + case .thailand: return L10n.Localizable.BrowsingCountry.thailand + case .timorLeste: return L10n.Localizable.BrowsingCountry.timorLeste + case .togo: return L10n.Localizable.BrowsingCountry.togo + case .tokelau: return L10n.Localizable.BrowsingCountry.tokelau + case .tonga: return L10n.Localizable.BrowsingCountry.tonga + case .trinidadAndTobago: return L10n.Localizable.BrowsingCountry.trinidadAndTobago + case .tunisia: return L10n.Localizable.BrowsingCountry.tunisia + case .turkey: return L10n.Localizable.BrowsingCountry.turkey + case .turkmenistan: return L10n.Localizable.BrowsingCountry.turkmenistan + case .turksAndCaicosIslands: return L10n.Localizable.BrowsingCountry.turksAndCaicosIslands + case .tuvalu: return L10n.Localizable.BrowsingCountry.tuvalu + case .uganda: return L10n.Localizable.BrowsingCountry.uganda + case .ukraine: return L10n.Localizable.BrowsingCountry.ukraine + case .unitedArabEmirates: return L10n.Localizable.BrowsingCountry.unitedArabEmirates + case .unitedKingdom: return L10n.Localizable.BrowsingCountry.unitedKingdom + case .unitedStates: return L10n.Localizable.BrowsingCountry.unitedStates + case .unitedStatesMinorOutlyingIslands: return L10n.Localizable.BrowsingCountry.unitedStatesMinorOutlyingIslands + case .uruguay: return L10n.Localizable.BrowsingCountry.uruguay + case .uzbekistan: return L10n.Localizable.BrowsingCountry.uzbekistan + case .vanuatu: return L10n.Localizable.BrowsingCountry.vanuatu + case .venezuela: return L10n.Localizable.BrowsingCountry.venezuela + case .vietnam: return L10n.Localizable.BrowsingCountry.vietnam + case .virginIslandsBritish: return L10n.Localizable.BrowsingCountry.virginIslandsBritish + case .virginIslandsUS: return L10n.Localizable.BrowsingCountry.virginIslandsUS + case .wallisAndFutuna: return L10n.Localizable.BrowsingCountry.wallisAndFutuna + case .westernSahara: return L10n.Localizable.BrowsingCountry.westernSahara + case .yemen: return L10n.Localizable.BrowsingCountry.yemen + case .zambia: return L10n.Localizable.BrowsingCountry.zambia + case .zimbabwe: return L10n.Localizable.BrowsingCountry.zimbabwe } } } diff --git a/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift b/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift index 5ce6a494e..65cb6a11a 100644 --- a/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift @@ -14,11 +14,11 @@ extension EhSetting.CommentsSortOrder { public var value: String { switch self { case .oldest: - return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.oldest + return L10n.Localizable.CommentsSortOrder.oldest case .recent: - return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.recent + return L10n.Localizable.CommentsSortOrder.recent case .highestScore: - return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.highestScore + return L10n.Localizable.CommentsSortOrder.highestScore } } } @@ -36,9 +36,9 @@ extension EhSetting.CommentVotesShowTiming { public var value: String { switch self { case .onHoverOrClick: - return L10n.Localizable.Enum.EhSetting.CommentsVotesShowTiming.Value.onHoverOrClick + return L10n.Localizable.CommentsVotesShowTiming.onHoverOrClick case .always: - return L10n.Localizable.Enum.EhSetting.CommentsVotesShowTiming.Value.always + return L10n.Localizable.CommentsVotesShowTiming.always } } } @@ -56,9 +56,9 @@ extension EhSetting.TagsSortOrder { public var value: String { switch self { case .alphabetical: - return L10n.Localizable.Enum.EhSetting.TagsSortOrder.Value.alphabetical + return L10n.Localizable.TagsSortOrder.alphabetical case .tagPower: - return L10n.Localizable.Enum.EhSetting.TagsSortOrder.Value.tagPower + return L10n.Localizable.TagsSortOrder.tagPower } } } @@ -77,11 +77,11 @@ extension EhSetting.MultiplePageViewerStyle { public var value: String { switch self { case .alignLeftScaleIfOverWidth: - return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignLeftScaleIfOverWidth + return L10n.Localizable.MultiplePageViewerStyle.alignLeftScaleIfOverWidth case .alignCenterScaleIfOverWidth: - return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignCenterScaleIfOverWidth + return L10n.Localizable.MultiplePageViewerStyle.alignCenterScaleIfOverWidth case .alignCenterAlwaysScale: - return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignCenterAlwaysScale + return L10n.Localizable.MultiplePageViewerStyle.alignCenterAlwaysScale } } } @@ -99,9 +99,9 @@ extension EhSetting.GalleryPageNumbering { public var value: String { switch self { - case .none: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.none - case .pageNumberOnly: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.pageNumberOnly - case .pageNumberAndName: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.pageNumberAndName + case .none: L10n.Localizable.GalleryPageNumbering.none + case .pageNumberOnly: L10n.Localizable.GalleryPageNumbering.pageNumberOnly + case .pageNumberAndName: L10n.Localizable.GalleryPageNumbering.pageNumberAndName } } } diff --git a/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift b/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift index 5723fa4bf..d51d4af45 100644 --- a/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift @@ -13,17 +13,17 @@ extension EhSetting.ThumbnailLoadTiming { public var value: String { switch self { case .onMouseOver: - return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Value.onMouseOver + return L10n.Localizable.ThumbnailLoadTiming.onMouseOver case .onPageLoad: - return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Value.onPageLoad + return L10n.Localizable.ThumbnailLoadTiming.onPageLoad } } public var description: String { switch self { case .onMouseOver: - return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Description.onMouseOver + return L10n.Localizable.ThumbnailLoadTiming.onMouseOverDescription case .onPageLoad: - return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Description.onPageLoad + return L10n.Localizable.ThumbnailLoadTiming.onPageLoadDescription } } } @@ -47,13 +47,13 @@ extension EhSetting.ThumbnailSize { public var value: String { switch self { case .normal: - return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.normal + return L10n.Localizable.ThumbnailSize.normal case .large: - return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.large + return L10n.Localizable.ThumbnailSize.large case .small: - return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.small + return L10n.Localizable.ThumbnailSize.small case .auto: - return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.auto + return L10n.Localizable.ThumbnailSize.auto } } } diff --git a/AppPackage/Sources/AppModels/Support/EhSetting.swift b/AppPackage/Sources/AppModels/Support/EhSetting.swift index 2cdc0664b..4f19ba9cb 100644 --- a/AppPackage/Sources/AppModels/Support/EhSetting.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting.swift @@ -232,25 +232,25 @@ extension EhSetting.LoadThroughHathSetting { public var value: String { switch self { case .anyClient: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.anyClient + return L10n.Localizable.LoadThroughHathSetting.anyClient case .defaultPortOnly: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.defaultPortOnly + return L10n.Localizable.LoadThroughHathSetting.defaultPortOnly case .modernNo: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.modernNo + return L10n.Localizable.LoadThroughHathSetting.modernNo case .legacyNo: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.legacyNo + return L10n.Localizable.LoadThroughHathSetting.legacyNo } } public var description: String { switch self { case .anyClient: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.anyClient + return L10n.Localizable.LoadThroughHathSetting.anyClientDescription case .defaultPortOnly: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.defaultPortOnly + return L10n.Localizable.LoadThroughHathSetting.defaultPortOnlyDescription case .modernNo: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.modernNo + return L10n.Localizable.LoadThroughHathSetting.modernNoDescription case .legacyNo: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.legacyNo + return L10n.Localizable.LoadThroughHathSetting.legacyNoDescription } } } @@ -276,7 +276,7 @@ extension EhSetting.ImageResolution { public var value: String { switch self { case .auto: - return L10n.Localizable.Enum.EhSetting.ImageResolution.Value.auto + return L10n.Localizable.ImageResolution.auto case .x780: return "780x" case .x980: @@ -304,9 +304,9 @@ extension EhSetting.GalleryName { public var value: String { switch self { case .default: - return L10n.Localizable.Enum.EhSetting.GalleryName.Value.default + return L10n.Localizable.GalleryName.default case .japanese: - return L10n.Localizable.Enum.EhSetting.GalleryName.Value.japanese + return L10n.Localizable.GalleryName.japanese } } } @@ -328,17 +328,17 @@ extension EhSetting.ArchiverBehavior { public var value: String { switch self { case .manualSelectManualStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.manualSelectManualStart + return L10n.Localizable.EhSetting.ArchiverBehavior.manualSelectManualStart case .manualSelectAutoStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.manualSelectAutoStart + return L10n.Localizable.EhSetting.ArchiverBehavior.manualSelectAutoStart case .autoSelectOriginalManualStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectOriginalManualStart + return L10n.Localizable.EhSetting.ArchiverBehavior.autoSelectOriginalManualStart case .autoSelectOriginalAutoStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectOriginalAutoStart + return L10n.Localizable.EhSetting.ArchiverBehavior.autoSelectOriginalAutoStart case .autoSelectResampleManualStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectResampleManualStart + return L10n.Localizable.EhSetting.ArchiverBehavior.autoSelectResampleManualStart case .autoSelectResampleAutoStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectResampleAutoStart + return L10n.Localizable.EhSetting.ArchiverBehavior.autoSelectResampleAutoStart } } } @@ -359,15 +359,15 @@ extension EhSetting.DisplayMode { public var value: String { switch self { case .compact: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.compact + return L10n.Localizable.DisplayMode.compact case .thumbnail: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.thumbnail + return L10n.Localizable.DisplayMode.thumbnail case .extended: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.extended + return L10n.Localizable.DisplayMode.extended case .minimal: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.minimal + return L10n.Localizable.DisplayMode.minimal case .minimalPlus: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.minimalPlus + return L10n.Localizable.DisplayMode.minimalPlus } } } @@ -385,9 +385,9 @@ extension EhSetting.FavoritesSortOrder { public var value: String { switch self { case .lastUpdateTime: - return L10n.Localizable.Enum.EhSetting.FavoritesSortOrder.Value.lastUpdateTime + return L10n.Localizable.FavoritesSortOrder.lastUpdateTime case .favoritedTime: - return L10n.Localizable.Enum.EhSetting.FavoritesSortOrder.Value.favoritedTime + return L10n.Localizable.FavoritesSortOrder.favoritedTime } } } @@ -406,11 +406,11 @@ extension EhSetting.ExcludedLanguagesCategory { public var value: String { switch self { case .original: - return L10n.Localizable.Enum.EhSetting.ExcludedLanguagesCategory.Value.original + return L10n.Localizable.ExcludedLanguagesCategory.original case .translated: - return L10n.Localizable.Enum.EhSetting.ExcludedLanguagesCategory.Value.translated + return L10n.Localizable.ExcludedLanguagesCategory.translated case .rewrite: - return L10n.Localizable.Enum.EhSetting.ExcludedLanguagesCategory.Value.rewrite + return L10n.Localizable.ExcludedLanguagesCategory.rewrite } } } diff --git a/AppPackage/Sources/AppModels/Support/ToplistsType.swift b/AppPackage/Sources/AppModels/Support/ToplistsType.swift index 5120687f2..6c2f0f77a 100644 --- a/AppPackage/Sources/AppModels/Support/ToplistsType.swift +++ b/AppPackage/Sources/AppModels/Support/ToplistsType.swift @@ -13,13 +13,13 @@ extension ToplistsType { public var value: String { switch self { case .yesterday: - return L10n.Localizable.Enum.ToplistsType.Value.yesterday + return L10n.Localizable.ToplistsType.yesterday case .pastMonth: - return L10n.Localizable.Enum.ToplistsType.Value.pastMonth + return L10n.Localizable.ToplistsType.pastMonth case .pastYear: - return L10n.Localizable.Enum.ToplistsType.Value.pastYear + return L10n.Localizable.ToplistsType.pastYear case .allTime: - return L10n.Localizable.Enum.ToplistsType.Value.allTime + return L10n.Localizable.ToplistsType.allTime } } public var categoryIndex: Int { diff --git a/AppPackage/Sources/AppModels/Tags/TagNamespace.swift b/AppPackage/Sources/AppModels/Tags/TagNamespace.swift index 122611359..b6e4fb3cc 100644 --- a/AppPackage/Sources/AppModels/Tags/TagNamespace.swift +++ b/AppPackage/Sources/AppModels/Tags/TagNamespace.swift @@ -61,18 +61,18 @@ extension TagNamespace { } public var value: String { switch self { - case .reclass: return L10n.Localizable.Enum.TagNamespace.Value.reclass - case .language: return L10n.Localizable.Enum.TagNamespace.Value.language - case .parody: return L10n.Localizable.Enum.TagNamespace.Value.parody - case .character: return L10n.Localizable.Enum.TagNamespace.Value.character - case .group: return L10n.Localizable.Enum.TagNamespace.Value.group - case .artist: return L10n.Localizable.Enum.TagNamespace.Value.artist - case .male: return L10n.Localizable.Enum.TagNamespace.Value.male - case .female: return L10n.Localizable.Enum.TagNamespace.Value.female - case .mixed: return L10n.Localizable.Enum.TagNamespace.Value.mixed - case .cosplayer: return L10n.Localizable.Enum.TagNamespace.Value.cosplayer - case .other: return L10n.Localizable.Enum.TagNamespace.Value.other - case .temp: return L10n.Localizable.Enum.TagNamespace.Value.temp + case .reclass: return L10n.Localizable.TagNamespace.reclass + case .language: return L10n.Localizable.TagNamespace.language + case .parody: return L10n.Localizable.TagNamespace.parody + case .character: return L10n.Localizable.TagNamespace.character + case .group: return L10n.Localizable.TagNamespace.group + case .artist: return L10n.Localizable.TagNamespace.artist + case .male: return L10n.Localizable.TagNamespace.male + case .female: return L10n.Localizable.TagNamespace.female + case .mixed: return L10n.Localizable.TagNamespace.mixed + case .cosplayer: return L10n.Localizable.TagNamespace.cosplayer + case .other: return L10n.Localizable.TagNamespace.other + case .temp: return L10n.Localizable.TagNamespace.temp } } } diff --git a/AppPackage/Sources/CookieClient/CookieClient.swift b/AppPackage/Sources/CookieClient/CookieClient.swift index 5b89da7f4..0229f4d86 100644 --- a/AppPackage/Sources/CookieClient/CookieClient.swift +++ b/AppPackage/Sources/CookieClient/CookieClient.swift @@ -32,7 +32,7 @@ extension CookieClient { }, getCookie: { url, key in var value = CookieValue( - rawValue: "", localizedString: L10n.Localizable.Struct.CookieValue.LocalizedString.none + rawValue: "", localizedString: L10n.Localizable.CookieValue.none ) guard let cookies = cookieStorage.cookies(for: url), !cookies.isEmpty else { return value } @@ -42,14 +42,14 @@ extension CookieClient { expiresDate <= .now { value = CookieValue( rawValue: "", - localizedString: L10n.Localizable.Struct.CookieValue.LocalizedString.expired + localizedString: L10n.Localizable.CookieValue.expired ) return } guard cookie.value != Defaults.Cookie.mystery else { value = CookieValue( rawValue: cookie.value, localizedString: - L10n.Localizable.Struct.CookieValue.LocalizedString.mystery + L10n.Localizable.CookieValue.mystery ) return } diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift index 33854d9b9..92ea3fef4 100644 --- a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift @@ -33,21 +33,21 @@ public struct DateSeekPickerView: View { Form { Section { DatePicker( - L10n.Localizable.DateSeekView.Title.date, + L10n.Localizable.DateSeekView.date, selection: $selectedDate, in: navigation.dateRange, displayedComponents: .date ) .datePickerStyle(.graphical) } footer: { - Text(L10n.Localizable.DateSeekView.Footer.seekAroundDate) + Text(L10n.Localizable.DateSeekView.seekAroundDate) } Section { let seekOlderButton = SeekButton( symbol: .chevronLeftChevronLeftDotted, - title: L10n.Localizable.DateSeekView.Button.seekOlder, + title: L10n.Localizable.DateSeekView.seekOlder, reversedIconTitlePosition: false, action: { seekAction(.older) } ) @@ -56,7 +56,7 @@ public struct DateSeekPickerView: View { let seekNewerButton = SeekButton( symbol: .chevronRightDottedChevronRight, - title: L10n.Localizable.DateSeekView.Button.seekNewer, + title: L10n.Localizable.DateSeekView.seekNewer, reversedIconTitlePosition: true, action: { seekAction(.newer) } ) @@ -80,7 +80,7 @@ public struct DateSeekPickerView: View { .listRowInsets(.init()) } } - .navigationTitle(L10n.Localizable.DateSeekView.Title.dateSeek) + .navigationTitle(L10n.Localizable.DateSeekView.dateSeek) .navigationBarTitleDisplayMode(.large) } } diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index c12ecf841..4d86bb2fc 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -124,14 +124,14 @@ public struct ArchivesReducer: Sendable { switch result { case .success(let response): switch response { - case L10n.Constant.Website.Response.hathClientNotFound: - state.toast = .error(caption: L10n.Localizable.Website.Response.hathClientNotFound) + case L10n.Constant.hathClientNotFound: + state.toast = .error(caption: L10n.Localizable.hathClientNotFound) isSuccess = false - case L10n.Constant.Website.Response.hathClientNotOnline: - state.toast = .error(caption: L10n.Localizable.Website.Response.hathClientNotOnline) + case L10n.Constant.hathClientNotOnline: + state.toast = .error(caption: L10n.Localizable.hathClientNotOnline) isSuccess = false - case L10n.Constant.Website.Response.invalidResolution: - state.toast = .error(caption: L10n.Localizable.Website.Response.invalidResolution) + case L10n.Constant.invalidResolution: + state.toast = .error(caption: L10n.Localizable.invalidResolution) isSuccess = false default: state.toast = .success(caption: response) diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift index a15d83f55..852922420 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift @@ -63,7 +63,7 @@ struct ArchivesView: View { .onAppear { store.send(.fetchArchive(gid, galleryURL, archiveURL)) } - .navigationTitle(L10n.Localizable.ArchivesView.Title.archives) + .navigationTitle(L10n.Localizable.ArchivesView.archives) } } } @@ -205,7 +205,7 @@ private struct DownloadButton: View { } var body: some View { - Text(L10n.Localizable.ArchivesView.Button.downloadToHathClient) + Text(L10n.Localizable.ArchivesView.downloadToHathClient) .font(.headline) .foregroundStyle(textColor) .frame(maxWidth: .infinity) diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index 05ffc425c..0735581cc 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -93,8 +93,8 @@ struct CommentsView: View { let hasCommentID = !commentID.wrappedValue.isEmpty PostCommentView( title: hasCommentID - ? L10n.Localizable.PostCommentView.Title.editComment - : L10n.Localizable.PostCommentView.Title.postComment, + ? L10n.Localizable.PostCommentView.editComment + : L10n.Localizable.PostCommentView.postComment, content: $store.commentContent, isFocused: $store.postCommentFocused, postAction: { @@ -117,7 +117,7 @@ struct CommentsView: View { store.send(.onAppear) } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.CommentsView.Title.comments) + .navigationTitle(L10n.Localizable.CommentsView.comments) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift index f3d51d826..865a89962 100644 --- a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift +++ b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift @@ -57,7 +57,7 @@ private struct ImagesSection: View { } var body: some View { - SubSection(title: L10n.Localizable.TagDetailView.Section.Title.images, showAll: false) { + SubSection(title: L10n.Localizable.TagDetailView.images, showAll: false) { VStack { if !imageURLs.isEmpty { ScrollView(.horizontal, showsIndicators: false) { @@ -92,7 +92,7 @@ private struct LinksSection: View { } var body: some View { - SubSection(title: L10n.Localizable.TagDetailView.Section.Title.links, showAll: false) { + SubSection(title: L10n.Localizable.TagDetailView.links, showAll: false) { HStack { if !links.isEmpty { VStack(alignment: .leading) { diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift index 7dde022d8..a87d5cd48 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift @@ -13,16 +13,16 @@ extension DetailReducer { switch action { case .deleteDownloadButtonTapped: state.alert = AppAlertState { - TextState(L10n.Localizable.DetailView.Dialog.Title.deleteDownload) + TextState(L10n.Localizable.DetailView.deleteDownload) } actions: { ButtonState(role: .destructive, action: .confirmDeleteDownload) { - TextState(L10n.Localizable.ConfirmationDialog.Button.delete) + TextState(L10n.Localizable.ConfirmationDialog.delete) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.DetailView.Dialog.Message.deleteDownloadedGallery) + TextState(L10n.Localizable.DetailView.deleteDownloadedGallery) } return .none @@ -34,7 +34,7 @@ extension DetailReducer { TextState(Self.retryDownloadConfirmTitle(for: mode)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } message: { TextState(Self.retryDownloadMessage(for: mode)) @@ -305,33 +305,33 @@ extension DetailReducer { private static func retryDownloadTitle(for mode: DownloadStartMode) -> String { switch mode { case .repair: - return L10n.Localizable.DetailView.Dialog.Title.repairDownload + return L10n.Localizable.DetailView.repairDownload case .update: - return L10n.Localizable.DetailView.Dialog.Title.updateDownload + return L10n.Localizable.DetailView.updateDownload case .initial, .redownload: - return L10n.Localizable.DetailView.Dialog.Title.redownloadGallery + return L10n.Localizable.DetailView.redownloadGallery } } private static func retryDownloadMessage(for mode: DownloadStartMode) -> String { switch mode { case .repair: - return L10n.Localizable.DetailView.Dialog.Message.repairDownload + return L10n.Localizable.DetailView.repairDownloadDescription case .update: - return L10n.Localizable.DetailView.Dialog.Message.updateDownload + return L10n.Localizable.DetailView.updateDownloadDescription case .initial, .redownload: - return L10n.Localizable.DetailView.Dialog.Message.redownloadGallery + return L10n.Localizable.DetailView.redownloadGalleryDescription } } private static func retryDownloadConfirmTitle(for mode: DownloadStartMode) -> String { switch mode { case .repair: - return L10n.Localizable.DetailView.Dialog.Button.repair + return L10n.Localizable.DetailView.repair case .update: - return L10n.Localizable.DetailView.Dialog.Button.update + return L10n.Localizable.DetailView.update case .initial, .redownload: - return L10n.Localizable.DetailView.Dialog.Button.redownload + return L10n.Localizable.DetailView.redownload } } } diff --git a/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift b/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift index 285fd4916..962a8b7b2 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift @@ -52,7 +52,7 @@ struct CommentButton: View { Button(action: action) { HStack { Image(systemSymbol: .squareAndPencil) - Text(L10n.Localizable.DetailView.Button.postComment) + Text(L10n.Localizable.DetailView.postComment) .bold() } .padding() diff --git a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift index 63a61808f..19cf053a9 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift @@ -88,7 +88,7 @@ struct HeaderSection: View { Section { Button(action: manageFoldersAction) { Label( - L10n.Localizable.DetailView.Menu.Button.manageFolders, + L10n.Localizable.DetailView.manageFolders, systemSymbol: .folderBadgeGearshape ) } @@ -98,7 +98,7 @@ struct HeaderSection: View { if downloadFolders.isEmpty { Button(action: createDefaultFolderAction) { Label( - L10n.Localizable.DetailView.Menu.Button.createDefaultFolder, + L10n.Localizable.DetailView.createDefaultFolder, systemSymbol: .folderBadgePlus ) } @@ -107,7 +107,7 @@ struct HeaderSection: View { } Section { if downloadFolders.isEmpty { - Text(L10n.Localizable.DetailView.Menu.Text.noFolders) + Text(L10n.Localizable.DetailView.noFolders) } else { ForEach(downloadFolders, id: \.self) { folder in Button { @@ -181,7 +181,7 @@ struct HeaderSection: View { } .buttonStyle(.glassProminent) .buttonBorderShape(.circle) - .accessibilityLabel(L10n.Localizable.DetailView.Button.read) + .accessibilityLabel(L10n.Localizable.DetailView.read) } private func progressIndicator( progress: Double, isDeterminate: Bool, centerSymbol: SFSymbol @@ -293,43 +293,43 @@ struct HeaderSection: View { // MARK: HeaderSection Accessibility extension HeaderSection { var downloadButtonAccessibilityLabel: String { - guard canDownload else { return L10n.Localizable.DetailView.Accessibility.DownloadButton.login } + guard canDownload else { return L10n.Localizable.DetailView.Accessibility.login } guard !showsMetadataPreparation else { - return L10n.Localizable.DetailView.Accessibility.DownloadButton.preparing + return L10n.Localizable.DetailView.Accessibility.preparing } return downloadBadgeAccessibilityLabel } var downloadBadgeAccessibilityLabel: String { guard let badge = downloadBadge else { - return L10n.Localizable.DetailView.Accessibility.DownloadButton.download + return L10n.Localizable.DetailView.Accessibility.download } let progress = badge.progress switch badge.status { case .queued: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.queued + return L10n.Localizable.DetailView.Accessibility.queued case .active: - let downloading = L10n.Localizable.DetailView.Accessibility.DownloadButton.downloading( + let downloading = L10n.Localizable.DetailView.Accessibility.downloading( progress.completedPageCount, progress.displayPageCount ) - return [downloading, L10n.Localizable.DetailView.Accessibility.DownloadButton.pauseAction] + return [downloading, L10n.Localizable.DetailView.Accessibility.pauseAction] .joined(separator: ". ") case .inactive: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.paused( + return L10n.Localizable.DetailView.Accessibility.paused( progress.completedPageCount, progress.displayPageCount ) case .completed: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.downloaded + return L10n.Localizable.DetailView.Accessibility.downloaded case .updateAvailable: - return L10n.Localizable.DetailView.Accessibility.DownloadButton.update + return L10n.Localizable.DetailView.Accessibility.update case .error: if isPartialDownloadError { - return L10n.Localizable.DetailView.Accessibility.DownloadButton.partial( + return L10n.Localizable.DetailView.Accessibility.partial( progress.completedPageCount, progress.displayPageCount ) } return downloadNeedsRepair - ? L10n.Localizable.DetailView.Accessibility.DownloadButton.repair - : L10n.Localizable.DetailView.Accessibility.DownloadButton.retry + ? L10n.Localizable.DetailView.Accessibility.repair + : L10n.Localizable.DetailView.Accessibility.retry } } } diff --git a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift index 783855d6e..46daf1af4 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift @@ -12,13 +12,13 @@ extension DetailView { Button { store.send(.archivesButtonTapped) } label: { - Label(L10n.Localizable.DetailView.ToolbarItem.Button.archives, systemSymbol: .zipperPage) + Label(L10n.Localizable.DetailView.archives, systemSymbol: .zipperPage) } .disabled(store.galleryDetail?.archiveURL == nil || !CookieUtil.didLogin) Button { store.send(.torrentsButtonTapped) } label: { - let base = L10n.Localizable.DetailView.ToolbarItem.Button.torrents + let base = L10n.Localizable.DetailView.torrents let torrentCount = store.galleryDetail?.torrentCount ?? 0 let baseWithCount = [base, "(\(torrentCount))"].joined(separator: " ") Label(torrentCount > 0 ? baseWithCount : base, systemSymbol: .leaf) @@ -29,7 +29,7 @@ extension DetailView { store.send(.shareButtonTapped(galleryURL)) } } label: { - Label(L10n.Localizable.DetailView.ToolbarItem.Button.share, systemSymbol: .squareAndArrowUp) + Label(L10n.Localizable.DetailView.share, systemSymbol: .squareAndArrowUp) } } .disabled(store.galleryDetail == nil || store.loadingState == .loading) diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index 3071c2e32..369f6c9b9 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -14,26 +14,26 @@ struct DescriptionSection: View { private var infos: [DescScrollInfo] {[ DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.favorited, - description: L10n.Localizable.DetailView.DescriptionSection.Description.favorited, + title: L10n.Localizable.DetailView.favorited, + description: L10n.Localizable.DetailView.favoritedUnit, value: .init(galleryDetail.favoritedCount) ), DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.language, + title: L10n.Localizable.DetailView.language, description: galleryDetail.language.value, value: galleryDetail.language.abbreviation ), DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.ratings("\(galleryDetail.ratingCount)"), + title: L10n.Localizable.DetailView.ratings("\(galleryDetail.ratingCount)"), description: .init(), value: .init(), rating: galleryDetail.rating, isRating: true ), DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.pageCount, - description: L10n.Localizable.DetailView.DescriptionSection.Description.pageCount, + title: L10n.Localizable.DetailView.pageCount, + description: L10n.Localizable.DetailView.pageCountUnit, value: .init(galleryDetail.pageCount) ), DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.fileSize, + title: L10n.Localizable.DetailView.fileSize, description: galleryDetail.sizeType, value: .init(galleryDetail.sizeCount) ) ]} @@ -122,14 +122,14 @@ struct ActionSection: View { Button(action: showUserRatingAction) { Spacer() Image(systemSymbol: .squareAndPencil) - Text(L10n.Localizable.DetailView.ActionSection.Button.giveARating).bold() + Text(L10n.Localizable.DetailView.giveARating).bold() Spacer() } .disabled(!CookieUtil.didLogin) Button(action: navigateSimilarGalleryAction) { Spacer() Image(systemSymbol: .photoOnRectangleAngled) - Text(L10n.Localizable.DetailView.ActionSection.Button.similarGallery).bold() + Text(L10n.Localizable.DetailView.similarGallery).bold() Spacer() } } @@ -242,7 +242,7 @@ extension TagsSection { )) } label: { Image(systemSymbol: .richtextPage) - Text(L10n.Localizable.DetailView.ContextMenu.Button.detail) + Text(L10n.Localizable.DetailView.detail) } } if CookieUtil.didLogin { @@ -258,20 +258,20 @@ extension TagsSection { } label: { Image(systemSymbol: content.isVotedUp ? .handThumbsup : .handThumbsdown) .symbolVariant(.fill) - Text(L10n.Localizable.DetailView.ContextMenu.Button.withdrawVote) + Text(L10n.Localizable.DetailView.withdrawVote) } } else { Button { voteTagAction(content.voteKeyword(tag: tag), 1) } label: { Image(systemSymbol: .handThumbsup) - Text(L10n.Localizable.DetailView.ContextMenu.Button.voteUp) + Text(L10n.Localizable.DetailView.voteUp) } Button { voteTagAction(content.voteKeyword(tag: tag), -1) } label: { Image(systemSymbol: .handThumbsdown) - Text(L10n.Localizable.DetailView.ContextMenu.Button.voteDown) + Text(L10n.Localizable.DetailView.voteDown) } } } @@ -290,7 +290,7 @@ struct PreviewsSection: View { var body: some View { SubSection( - title: L10n.Localizable.DetailView.Section.Title.previews, + title: L10n.Localizable.DetailView.previews, showAll: pageCount > 20, showAllAction: navigatePreviewsAction ) { ScrollView(.horizontal, showsIndicators: false) { @@ -325,7 +325,7 @@ struct CommentsSection: View { var body: some View { SubSection( - title: L10n.Localizable.DetailView.Section.Title.comments, + title: L10n.Localizable.DetailView.comments, showAll: !comments.isEmpty, showAllAction: navigateCommentAction ) { ScrollView(.horizontal, showsIndicators: false) { diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 112afc70f..136fba6b0 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -185,7 +185,7 @@ private extension DetailView { primaryModalModifiers(content: content) .sheet(item: $store.destination.postComment, id: \.id) { _ in PostCommentView( - title: L10n.Localizable.PostCommentView.Title.postComment, + title: L10n.Localizable.PostCommentView.postComment, content: $store.commentContent, isFocused: $store.postCommentFocused, postAction: { @@ -292,13 +292,13 @@ private extension DetailView { @ViewBuilder private func offlineFallbackNotice(error: AppError) -> some View { VStack(alignment: .leading, spacing: 10) { Label( - L10n.Localizable.DetailView.OfflineNotice.savedDetails, + L10n.Localizable.DetailView.savedDetails, systemSymbol: .wifiExclamationmark ) .font(.subheadline.weight(.semibold)) .foregroundStyle(.orange) if error.isRetryable != false { - Button(L10n.Localizable.ErrorView.Button.retry) { + Button(L10n.Localizable.ErrorView.retry) { store.send(.fetchGalleryDetail) } .buttonStyle(.glass) diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift index 80dd20d26..9a85fda11 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift @@ -21,7 +21,7 @@ public struct FolderManagerReducer: Sendable { private static var invalidFolderNameError: AppError { .fileOperationFailed( - L10n.Localizable.DownloadStore.Error.invalidFolderName + L10n.Localizable.DownloadStore.invalidFolderName ) } @@ -87,13 +87,13 @@ public struct FolderManagerReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmDelete(folder)) { - TextState(L10n.Localizable.ConfirmationDialog.Button.delete) + TextState(L10n.Localizable.ConfirmationDialog.delete) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.FolderManagerView.Dialog.Message.deleteFolder) + TextState(L10n.Localizable.FolderManagerView.deleteFolder) } return .none diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift index c209860f2..9d67735fc 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift @@ -52,7 +52,7 @@ public struct FolderManagerView: View { store.send(.fetchFolders) } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.FolderManagerView.Title.folders) + .navigationTitle(L10n.Localizable.FolderManagerView.folders) .navigationBarTitleDisplayMode(.inline) } } @@ -71,7 +71,7 @@ public struct FolderManagerView: View { if store.folders.isEmpty && store.editingField != .newFolder { AlertView( symbol: .folder, - message: L10n.Localizable.FolderManagerView.EmptyState.folders + message: L10n.Localizable.FolderManagerView.emptyFolders ) { EmptyView() } @@ -101,7 +101,7 @@ public struct FolderManagerView: View { private func editingTextField(_ field: FolderManagerReducer.EditingField) -> some View { TextField( - L10n.Localizable.FolderManagerView.Placeholder.folderName, + L10n.Localizable.FolderManagerView.folderName, text: $store.editingFolderName ) .disableAutocorrection(true) diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index afe302495..9c1cdb66a 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -18,72 +18,72 @@ struct GalleryInfosView: View { private var infos: [Info] { [ - Info(title: L10n.Localizable.GalleryInfosView.Title.id, value: galleryDetail.gid), - Info(title: L10n.Localizable.GalleryInfosView.Title.token, value: gallery.token), - Info(title: L10n.Localizable.GalleryInfosView.Title.title, value: galleryDetail.title), - Info(title: L10n.Localizable.GalleryInfosView.Title.japaneseTitle, value: galleryDetail.jpnTitle), + Info(title: L10n.Localizable.GalleryInfosView.id, value: galleryDetail.gid), + Info(title: L10n.Localizable.GalleryInfosView.token, value: gallery.token), + Info(title: L10n.Localizable.GalleryInfosView.title, value: galleryDetail.title), + Info(title: L10n.Localizable.GalleryInfosView.japaneseTitle, value: galleryDetail.jpnTitle), Info( - title: L10n.Localizable.GalleryInfosView.Title.galleryURL, + title: L10n.Localizable.GalleryInfosView.galleryURL, value: gallery.galleryURL?.absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.Title.coverURL, + title: L10n.Localizable.GalleryInfosView.coverURL, value: galleryDetail.coverURL?.absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.Title.archiveURL, + title: L10n.Localizable.GalleryInfosView.archiveURL, value: galleryDetail.archiveURL?.absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.Title.torrentURL, + title: L10n.Localizable.GalleryInfosView.torrentURL, value: URLUtil.galleryTorrents(gid: gallery.gid, token: gallery.token).absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.Title.parentURL, + title: L10n.Localizable.GalleryInfosView.parentURL, value: galleryDetail.parentURL?.absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.Title.category, + title: L10n.Localizable.GalleryInfosView.category, value: galleryDetail.category.value ), - Info(title: L10n.Localizable.GalleryInfosView.Title.uploader, value: galleryDetail.uploader), + Info(title: L10n.Localizable.GalleryInfosView.uploader, value: galleryDetail.uploader), Info( - title: L10n.Localizable.GalleryInfosView.Title.postedDate, + title: L10n.Localizable.GalleryInfosView.postedDate, value: galleryDetail.formattedDateString ), Info( - title: L10n.Localizable.GalleryInfosView.Title.visibility, + title: L10n.Localizable.GalleryInfosView.visibility, value: galleryDetail.visibility.value ), - Info(title: L10n.Localizable.GalleryInfosView.Title.language, value: galleryDetail.language.value), - Info(title: L10n.Localizable.GalleryInfosView.Title.pageCount, value: String(galleryDetail.pageCount)), + Info(title: L10n.Localizable.GalleryInfosView.language, value: galleryDetail.language.value), + Info(title: L10n.Localizable.GalleryInfosView.pageCount, value: String(galleryDetail.pageCount)), Info( - title: L10n.Localizable.GalleryInfosView.Title.fileSize, + title: L10n.Localizable.GalleryInfosView.fileSize, value: String(Int(galleryDetail.sizeCount)) + galleryDetail.sizeType ), Info( - title: L10n.Localizable.GalleryInfosView.Title.favoritedTimes, + title: L10n.Localizable.GalleryInfosView.favoritedTimes, value: String(galleryDetail.favoritedCount) ), Info( - title: L10n.Localizable.GalleryInfosView.Title.favorited, - value: galleryDetail.isFavorited ? L10n.Localizable.GalleryInfosView.Value.yes - : L10n.Localizable.GalleryInfosView.Value.no + title: L10n.Localizable.GalleryInfosView.favorited, + value: galleryDetail.isFavorited ? L10n.Localizable.GalleryInfosView.yes + : L10n.Localizable.GalleryInfosView.no ), Info( - title: L10n.Localizable.GalleryInfosView.Title.ratingCount, + title: L10n.Localizable.GalleryInfosView.ratingCount, value: String(galleryDetail.ratingCount) ), Info( - title: L10n.Localizable.GalleryInfosView.Title.averageRating, + title: L10n.Localizable.GalleryInfosView.averageRating, value: String(Int(galleryDetail.rating)) ), Info( - title: L10n.Localizable.GalleryInfosView.Title.myRating, + title: L10n.Localizable.GalleryInfosView.myRating, value: galleryDetail.userRating == 0 ? nil : String(Int(galleryDetail.userRating)) ), Info( - title: L10n.Localizable.GalleryInfosView.Title.torrentCount, + title: L10n.Localizable.GalleryInfosView.torrentCount, value: String(galleryDetail.torrentCount) ) ] @@ -104,7 +104,7 @@ struct GalleryInfosView: View { store.send(.copyText(text)) } } label: { - Text(info.value ?? L10n.Localizable.GalleryInfosView.Value.none) + Text(info.value ?? L10n.Localizable.GalleryInfosView.none) .lineLimit(3).font(.caption) .foregroundStyle(.tint) } @@ -112,7 +112,7 @@ struct GalleryInfosView: View { } } .toast($store.scope(state: \.toast, action: \.toast)) - .navigationTitle(L10n.Localizable.GalleryInfosView.Title.galleryInfos) + .navigationTitle(L10n.Localizable.GalleryInfosView.galleryInfos) } } diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift index 599df96cc..54202aec7 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift @@ -76,7 +76,7 @@ struct PreviewsView: View { .onAppear { store.send(.fetchDatabaseInfos(gid)) } - .navigationTitle(L10n.Localizable.PreviewsView.Title.previews) + .navigationTitle(L10n.Localizable.PreviewsView.previews) } } diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift index 26a5f8f50..4bed1aac1 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift @@ -52,7 +52,7 @@ struct TorrentsView: View { .onAppear { store.send(.fetchGalleryTorrents(gid, token)) } - .navigationTitle(L10n.Localizable.TorrentsView.Title.torrents) + .navigationTitle(L10n.Localizable.TorrentsView.torrents) } } } diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift index 70eaccbbf..302a5bacd 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift @@ -15,7 +15,7 @@ extension DownloadCoordinator { guard let normalizedName = storage.normalizedUserFolderName(name) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.Error.invalidFolderName + L10n.Localizable.DownloadStore.invalidFolderName ) ) } @@ -23,7 +23,7 @@ extension DownloadCoordinator { guard !fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.Error.folderAlreadyExists + L10n.Localizable.DownloadStore.folderAlreadyExists ) ) } @@ -45,7 +45,7 @@ extension DownloadCoordinator { guard let normalizedName = storage.normalizedUserFolderName(newName) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.Error.invalidFolderName + L10n.Localizable.DownloadStore.invalidFolderName ) ) } @@ -60,7 +60,7 @@ extension DownloadCoordinator { guard !fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.Error.folderAlreadyExists + L10n.Localizable.DownloadStore.folderAlreadyExists ) ) } @@ -70,7 +70,7 @@ extension DownloadCoordinator { downloadIndex[activeGalleryID]?.parentFolderName == oldName { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.Error.folderBusyDownloading + L10n.Localizable.DownloadStore.folderBusyDownloading ) ) } @@ -144,7 +144,7 @@ extension DownloadCoordinator { guard let normalizedName = storage.normalizedUserFolderName(folderName) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.Error.invalidFolderName + L10n.Localizable.DownloadStore.invalidFolderName ) ) } @@ -158,7 +158,7 @@ extension DownloadCoordinator { guard activeGalleryID != gid else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.Error.downloadBusy + L10n.Localizable.DownloadStore.downloadBusy ) ) } @@ -173,7 +173,7 @@ extension DownloadCoordinator { guard !fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.Error.folderAlreadyExists + L10n.Localizable.DownloadStore.folderAlreadyExists ) ) } diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift index 62a539588..5539278b7 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift @@ -74,7 +74,7 @@ extension DownloadCoordinator { } else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.Error.invalidFolderName + L10n.Localizable.DownloadStore.invalidFolderName ) ) } diff --git a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift index f393205a2..5aa73eaa0 100644 --- a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift @@ -7,7 +7,7 @@ extension DownloadStore { public func linkOrCopyReadableAsset(at sourceURL: URL, to destinationURL: URL) throws { guard sanitizeAssetFileIfNeeded(at: sourceURL) else { throw AppError.fileOperationFailed( - L10n.Localizable.DownloadStore.Error.assetUnreadable(sourceURL.lastPathComponent) + L10n.Localizable.DownloadStore.assetUnreadable(sourceURL.lastPathComponent) ) } @@ -91,13 +91,13 @@ extension DownloadStore { } guard let relativePath = existingPages[index] else { throw AppError.fileOperationFailed( - L10n.Localizable.DownloadStore.Validation.pageMissing(index) + L10n.Localizable.DownloadStore.pageMissing(index) ) } pages[index] = try hashReadableAsset( folderURL: folderURL, relativePath: relativePath, - missingMessage: L10n.Localizable.DownloadStore.Validation.pageMissing(index) + missingMessage: L10n.Localizable.DownloadStore.pageMissing(index) ) } @@ -152,7 +152,7 @@ extension DownloadStore { pages[index] = try hashReadableAsset( folderURL: folderURL, relativePath: refreshedRelativePath, - missingMessage: L10n.Localizable.DownloadStore.Validation.pageMissing(index) + missingMessage: L10n.Localizable.DownloadStore.pageMissing(index) ) didUpdate = true } @@ -201,14 +201,14 @@ extension DownloadStore { ) -> DownloadValidationState { let folderURL = download.folderURL guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { - return .missingFiles(L10n.Localizable.DownloadStore.Validation.downloadFolderMissing) + return .missingFiles(L10n.Localizable.DownloadStore.downloadFolderMissing) } let manifestURL = download.manifestURL guard fileManager.operate({ $0.fileExists(atPath: manifestURL.path) }) else { - return .missingFiles(L10n.Localizable.DownloadStore.Validation.manifestMissing) + return .missingFiles(L10n.Localizable.DownloadStore.manifestMissing) } guard let manifest = try? readManifest(folderURL: folderURL) else { - return .missingFiles(L10n.Localizable.DownloadStore.Validation.manifestCorrupted) + return .missingFiles(L10n.Localizable.DownloadStore.manifestCorrupted) } if let pageValidationFailure = validatePages( folderURL: folderURL, @@ -290,12 +290,12 @@ extension DownloadStore { let pageURL = validatedChildURL(root: folderURL, relativePath: relativePath), sanitizeAssetFileIfNeeded(at: pageURL) else { - return .missingFiles(L10n.Localizable.DownloadStore.Validation.pageMissing(index)) + return .missingFiles(L10n.Localizable.DownloadStore.pageMissing(index)) } if verifiesContentHash, (try? fileHash(at: pageURL)) != expectedHash { return .missingFiles( - L10n.Localizable.DownloadStore.Validation.pageImageCorrupted(index) + L10n.Localizable.DownloadStore.pageImageCorrupted(index) ) } diff --git a/AppPackage/Sources/DownloadClient/DownloadStore.swift b/AppPackage/Sources/DownloadClient/DownloadStore.swift index 72c1b1fb2..9a2d98d3f 100644 --- a/AppPackage/Sources/DownloadClient/DownloadStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore.swift @@ -409,7 +409,7 @@ public struct DownloadStore: Sendable { } private func manifestCorruptedError() -> AppError { - .fileOperationFailed(L10n.Localizable.DownloadStore.Validation.manifestCorrupted) + .fileOperationFailed(L10n.Localizable.DownloadStore.manifestCorrupted) } public func scanDownloadFolders() throws -> [DownloadFolderRecord] { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift index 555b4771c..d09c059e7 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift @@ -218,7 +218,7 @@ private extension Optional where Wrapped == DownloadValidationState { switch self { case .some(.valid): return .success( - caption: L10n.Localizable.DownloadsView.Inspector.Toast.imageDataValid + caption: L10n.Localizable.DownloadInspectorView.imageDataValid ) case .some(.missingFiles(let message)): @@ -226,7 +226,7 @@ private extension Optional where Wrapped == DownloadValidationState { case nil: return .error( - caption: L10n.Localizable.DownloadsView.Inspector.Toast.imageDataUnavailable + caption: L10n.Localizable.DownloadInspectorView.imageDataUnavailable ) } } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 06826e11d..1c7c1bcc2 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -140,19 +140,19 @@ public struct DownloadsReducer: Sendable { case .deleteDownloadButtonTapped(let download): state.alert = AppAlertState { - TextState(L10n.Localizable.DownloadsView.Dialog.Title.deleteDownload) + TextState(L10n.Localizable.DownloadsView.deleteDownload) } actions: { ButtonState(role: .destructive, action: .confirmDelete(download.gid)) { - TextState(L10n.Localizable.ConfirmationDialog.Button.delete) + TextState(L10n.Localizable.ConfirmationDialog.delete) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } message: { TextState( download.canTogglePause - ? L10n.Localizable.DownloadsView.Dialog.Message.deleteActiveDownload - : L10n.Localizable.DownloadsView.Dialog.Message.deleteDownloadedGallery + ? L10n.Localizable.DownloadsView.deleteActiveDownload + : L10n.Localizable.DownloadsView.deleteDownloadedGallery ) } return .none @@ -160,7 +160,7 @@ public struct DownloadsReducer: Sendable { case .moveButtonTapped(let download): let destinations = state.folders.filter { $0 != download.folderName } state.confirmationDialog = ConfirmationDialogState { - TextState(L10n.Localizable.DownloadsView.Menu.Button.moveToFolder) + TextState(L10n.Localizable.DownloadsView.moveToFolder) } actions: { for folder in destinations { ButtonState(action: .move(download.gid, folder)) { @@ -168,7 +168,7 @@ public struct DownloadsReducer: Sendable { } } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } return .none diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index 6e0232bfa..3779d8196 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -72,7 +72,7 @@ struct DownloadInspectorView: View { let isRetryFailedPagesDisabled = !inspection.canRetryFailedPages let isValidateImageDataDisabled = !inspection.canValidateImageData || store.isValidatingImageData - Section(L10n.Localizable.DownloadsView.Inspector.Section.actions) { + Section(L10n.Localizable.DownloadInspectorView.actions) { Button { store.send(.toggleDownloadPause) } label: { @@ -88,7 +88,7 @@ struct DownloadInspectorView: View { store.send(.retryPages(inspection.failedPageIndices)) } label: { Label( - L10n.Localizable.DownloadsView.Inspector.Button.retryFailedPages, + L10n.Localizable.DownloadInspectorView.retryFailedPages, systemSymbol: .arrowClockwise ) .disabledActionForegroundStyle(isRetryFailedPagesDisabled) @@ -113,7 +113,7 @@ struct DownloadInspectorView: View { } .autoBlur(radius: blurRadius) .toast($store.scope(state: \.toast, action: \.toast)) - .navigationTitle(L10n.Localizable.DownloadsView.Inspector.Title.downloadStatus) + .navigationTitle(L10n.Localizable.DownloadInspectorView.downloadStatus) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -133,8 +133,8 @@ private struct DownloadInspectorValidationActionLabel: View { private var title: String { isValidating - ? L10n.Localizable.DownloadsView.Inspector.Button.validatingImageData - : L10n.Localizable.DownloadsView.Button.validateImageData + ? L10n.Localizable.DownloadInspectorView.validatingImageData + : L10n.Localizable.DownloadsView.validateImageData } private var progressAnimation: Animation? { @@ -174,7 +174,7 @@ struct DownloadInspectorPageGroupRow: View { private var pageNumbersText: String { let indices = pages.map(\.index).sorted() guard !indices.isEmpty else { - return L10n.Localizable.DownloadsView.Inspector.Page.none + return L10n.Localizable.DownloadInspectorView.none } return Self.formattedPageRanges(indices) } @@ -244,11 +244,11 @@ private extension DownloadPageStatus { var title: String { switch self { case .pending: - return L10n.Localizable.DownloadsView.Inspector.Status.pending + return L10n.Localizable.DownloadInspectorView.pending case .downloaded: - return L10n.Localizable.DownloadsView.Inspector.Status.downloaded + return L10n.Localizable.DownloadInspectorView.downloaded case .failed: - return L10n.Localizable.DownloadsView.Inspector.Status.failed + return L10n.Localizable.DownloadInspectorView.failed } } @@ -276,8 +276,8 @@ private extension DownloadPageStatus { private extension DownloadedGallery { var inspectorPauseResumeTitle: String { displayStatus == .inactive - ? L10n.Localizable.DownloadsView.Swipe.Button.resume - : L10n.Localizable.DownloadsView.Swipe.Button.pause + ? L10n.Localizable.DownloadsView.resume + : L10n.Localizable.DownloadsView.pause } var inspectorPauseResumeSymbol: SFSymbol { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index bfe837df5..5fdd69682 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -64,7 +64,7 @@ public struct DownloadsView: View { .searchable( text: $store.keyword, placement: .navigationBarDrawer(displayMode: .automatic), - prompt: L10n.Localizable.DownloadsView.Search.Prompt.downloads + prompt: L10n.Localizable.DownloadsView.searchDownloads ) .sheet( item: $store.scope(state: \.destination?.inspector, action: \.destination.inspector) @@ -105,7 +105,7 @@ public struct DownloadsView: View { .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) - .navigationTitle(L10n.Localizable.DownloadsView.Title.downloads) + .navigationTitle(L10n.Localizable.DownloadsView.downloads) .navigationBarTitleDisplayMode(.large) .toolbar(content: toolbar) } @@ -140,7 +140,7 @@ private extension DownloadsView { store.send(.inspectorButtonTapped(download.gid)) } label: { Label( - L10n.Localizable.DownloadsView.Swipe.Button.pages, + L10n.Localizable.DownloadsView.pages, systemSymbol: .listBulletRectanglePortrait ) } @@ -151,7 +151,7 @@ private extension DownloadsView { store.send(.moveButtonTapped(download)) } label: { Label( - L10n.Localizable.DownloadsView.Swipe.Button.move, + L10n.Localizable.DownloadsView.move, systemSymbol: .folder ) } @@ -164,7 +164,7 @@ private extension DownloadsView { store.send(.updateDownload(download.gid)) } label: { Label( - L10n.Localizable.DownloadsView.Swipe.Button.update, + L10n.Localizable.DownloadsView.update, systemSymbol: .arrowTrianglehead2ClockwiseRotate90 ) } @@ -177,8 +177,8 @@ private extension DownloadsView { } label: { Label( download.displayStatus == .inactive - ? L10n.Localizable.DownloadsView.Swipe.Button.resume - : L10n.Localizable.DownloadsView.Swipe.Button.pause, + ? L10n.Localizable.DownloadsView.resume + : L10n.Localizable.DownloadsView.pause, systemSymbol: download.displayStatus == .inactive ? .playFill : .pauseFill @@ -190,7 +190,7 @@ private extension DownloadsView { Button(role: .destructive) { store.send(.deleteDownloadButtonTapped(download)) } label: { - Label(L10n.Localizable.ConfirmationDialog.Button.delete, systemSymbol: .trash) + Label(L10n.Localizable.ConfirmationDialog.delete, systemSymbol: .trash) } } } @@ -204,7 +204,7 @@ private extension DownloadsView { store.send(.galleryTapped(download.gid)) } label: { Label( - L10n.Localizable.DetailView.ContextMenu.Button.detail, + L10n.Localizable.DetailView.detail, systemSymbol: .infoCircle ) } @@ -213,7 +213,7 @@ private extension DownloadsView { store.send(.inspectorButtonTapped(download.gid)) } label: { Label( - L10n.Localizable.DownloadsView.Swipe.Button.pages, + L10n.Localizable.DownloadsView.pages, systemSymbol: .listBulletRectanglePortrait ) } @@ -227,7 +227,7 @@ private extension DownloadsView { } } label: { Label( - L10n.Localizable.DownloadsView.Menu.Button.moveToFolder, + L10n.Localizable.DownloadsView.moveToFolder, systemSymbol: .folder ) } @@ -238,7 +238,7 @@ private extension DownloadsView { store.send(.updateDownload(download.gid)) } label: { Label( - L10n.Localizable.DownloadsView.Swipe.Button.update, + L10n.Localizable.DownloadsView.update, systemSymbol: .arrowTrianglehead2ClockwiseRotate90 ) } @@ -250,8 +250,8 @@ private extension DownloadsView { } label: { Label( download.displayStatus == .inactive - ? L10n.Localizable.DownloadsView.Swipe.Button.resume - : L10n.Localizable.DownloadsView.Swipe.Button.pause, + ? L10n.Localizable.DownloadsView.resume + : L10n.Localizable.DownloadsView.pause, systemSymbol: download.displayStatus == .inactive ? .playFill : .pauseFill @@ -262,7 +262,7 @@ private extension DownloadsView { Button(role: .destructive) { store.send(.deleteDownloadButtonTapped(download)) } label: { - Label(L10n.Localizable.ConfirmationDialog.Button.delete, systemSymbol: .trash) + Label(L10n.Localizable.ConfirmationDialog.delete, systemSymbol: .trash) } } @@ -270,16 +270,16 @@ private extension DownloadsView { if store.downloads.isEmpty { AlertView( symbol: .squareAndArrowDown, - message: L10n.Localizable.DownloadsView.EmptyState.downloads + message: L10n.Localizable.DownloadsView.emptyDownloads ) { EmptyView() } } else { AlertView( symbol: .line3HorizontalDecreaseCircle, - message: L10n.Localizable.DownloadsView.EmptyState.noMatchingFilters + message: L10n.Localizable.DownloadsView.noMatchingFilters ) { - AlertViewButton(title: L10n.Localizable.DownloadsView.Button.clearFilters) { + AlertViewButton(title: L10n.Localizable.DownloadsView.clearFilters) { store.keyword = "" store.folderFilter = .all } @@ -303,7 +303,7 @@ private extension DownloadsView { store.send(.folderManagerButtonTapped) } label: { Label( - L10n.Localizable.DownloadsView.Menu.Button.manageFolders, + L10n.Localizable.DownloadsView.manageFolders, systemSymbol: .folderBadgeGearshape ) } diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index e727d6f9b..cc0d73352 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -30,7 +30,7 @@ public struct FavoritesView: View { private var navigationTitle: String { let favoriteCategory = user.getFavoriteCategory(index: store.index) - return (store.index == -1 ? L10n.Localizable.FavoritesView.Title.favorites : favoriteCategory) + return (store.index == -1 ? L10n.Localizable.FavoritesView.favorites : favoriteCategory) } public var body: some View { diff --git a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift index ee6678508..97c7d305b 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift @@ -68,13 +68,13 @@ public struct FiltersReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmReset) { - TextState(L10n.Localizable.ConfirmationDialog.Button.reset) + TextState(L10n.Localizable.ConfirmationDialog.reset) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.Title.reset) + TextState(L10n.Localizable.ConfirmationDialog.resetDescription) } return .none diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index d0bba651c..2592e4864 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -39,7 +39,7 @@ public struct FiltersView: View { ) } .synchronize($store.focusedBound, $focusedBound) - .navigationTitle(L10n.Localizable.FiltersView.Title.filters) + .navigationTitle(L10n.Localizable.FiltersView.filters) .onAppear { store.send(.fetchFilters) } } } @@ -79,10 +79,10 @@ private struct BasicSection: View { .pickerStyle(.segmented) CategoryView(bindings: categoryBindings) Button(action: resetFiltersDialogAction) { - Text(L10n.Localizable.FiltersView.Button.resetFilters).foregroundStyle(.red) + Text(L10n.Localizable.FiltersView.resetFilters).foregroundStyle(.red) } .confirmationDialog(confirmationDialog) - Toggle(L10n.Localizable.FiltersView.Title.advancedSettings, isOn: $filter.advanced) + Toggle(L10n.Localizable.FiltersView.advancedSettings, isOn: $filter.advanced) } } } @@ -105,24 +105,24 @@ private struct AdvancedSection: View { var body: some View { Group { - Section(L10n.Localizable.FiltersView.Section.Title.advanced) { - Toggle(L10n.Localizable.FiltersView.Title.searchGalleryName, isOn: $filter.galleryName) - Toggle(L10n.Localizable.FiltersView.Title.searchGalleryTags, isOn: $filter.galleryTags) - Toggle(L10n.Localizable.FiltersView.Title.searchGalleryDescription, isOn: $filter.galleryDesc) - Toggle(L10n.Localizable.FiltersView.Title.searchTorrentFilenames, isOn: $filter.torrentFilenames) + Section(L10n.Localizable.FiltersView.advanced) { + Toggle(L10n.Localizable.FiltersView.searchGalleryName, isOn: $filter.galleryName) + Toggle(L10n.Localizable.FiltersView.searchGalleryTags, isOn: $filter.galleryTags) + Toggle(L10n.Localizable.FiltersView.searchGalleryDescription, isOn: $filter.galleryDesc) + Toggle(L10n.Localizable.FiltersView.searchTorrentFilenames, isOn: $filter.torrentFilenames) Toggle( - L10n.Localizable.FiltersView.Title.onlyShowGalleriesWithTorrents, + L10n.Localizable.FiltersView.onlyShowGalleriesWithTorrents, isOn: $filter.onlyWithTorrents ) - Toggle(L10n.Localizable.FiltersView.Title.searchLowPowerTags, isOn: $filter.lowPowerTags) - Toggle(L10n.Localizable.FiltersView.Title.searchDownvotedTags, isOn: $filter.downvotedTags) - Toggle(L10n.Localizable.FiltersView.Title.searchExpungedGalleries, isOn: $filter.expungedGalleries) + Toggle(L10n.Localizable.FiltersView.searchLowPowerTags, isOn: $filter.lowPowerTags) + Toggle(L10n.Localizable.FiltersView.searchDownvotedTags, isOn: $filter.downvotedTags) + Toggle(L10n.Localizable.FiltersView.searchExpungedGalleries, isOn: $filter.expungedGalleries) } Section { - Toggle(L10n.Localizable.FiltersView.Title.setMinimumRating, isOn: $filter.minRatingActivated) + Toggle(L10n.Localizable.FiltersView.setMinimumRating, isOn: $filter.minRatingActivated) MinimumRatingSetter(minimum: $filter.minRating) .disabled(!filter.minRatingActivated) - Toggle(L10n.Localizable.FiltersView.Title.setPagesRange, isOn: $filter.pageRangeActivated) + Toggle(L10n.Localizable.FiltersView.setPagesRange, isOn: $filter.pageRangeActivated) .disabled(focusedBound.wrappedValue != nil) PagesRangeSetter( lowerBound: $filter.pageLowerBound, @@ -132,10 +132,10 @@ private struct AdvancedSection: View { ) .disabled(!filter.pageRangeActivated) } - Section(L10n.Localizable.FiltersView.Section.Title.defaultFilter) { - Toggle(L10n.Localizable.FiltersView.Title.disableLanguageFilter, isOn: $filter.disableLanguage) - Toggle(L10n.Localizable.FiltersView.Title.disableUploaderFilter, isOn: $filter.disableUploader) - Toggle(L10n.Localizable.FiltersView.Title.disableTagsFilter, isOn: $filter.disableTags) + Section(L10n.Localizable.FiltersView.defaultFilter) { + Toggle(L10n.Localizable.FiltersView.disableLanguageFilter, isOn: $filter.disableLanguage) + Toggle(L10n.Localizable.FiltersView.disableUploaderFilter, isOn: $filter.disableUploader) + Toggle(L10n.Localizable.FiltersView.disableTagsFilter, isOn: $filter.disableTags) } } .disabled(!filter.advanced) @@ -151,9 +151,9 @@ private struct MinimumRatingSetter: View { } var body: some View { - Picker(L10n.Localizable.FiltersView.Title.minimumRating, selection: $minimum) { + Picker(L10n.Localizable.FiltersView.minimumRating, selection: $minimum) { ForEach(Array(2...5), id: \.self) { number in - Text(L10n.Localizable.Common.Value.stars("\(number)")).tag(number) + Text(L10n.Localizable.Common.stars("\(number)")).tag(number) } } .pickerStyle(.menu) @@ -181,7 +181,7 @@ private struct PagesRangeSetter: View { var body: some View { HStack { - Text(L10n.Localizable.FiltersView.Title.pagesRange) + Text(L10n.Localizable.FiltersView.pagesRange) Spacer() SettingTextField(text: $lowerBound) .focused(focusedBound, equals: .lower) diff --git a/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift b/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift index b618b7af0..9f318328c 100644 --- a/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift +++ b/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift @@ -28,14 +28,14 @@ public struct DownloadBadgeLabel: View { } private var progressText: String { - L10n.Localizable.Struct.DownloadBadge.progress( + L10n.Localizable.DownloadBadge.progress( badge.progress.displayCompletedPageCount, badge.progress.displayPageCount ) } private var statusText: String { - typealias BadgeText = L10n.Localizable.Struct.DownloadBadge.Text + typealias BadgeText = L10n.Localizable.DownloadBadge switch badge.status { case .queued: return BadgeText.queued diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 997a50366..3ce03adfa 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -59,7 +59,7 @@ struct FrontpageView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) + .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.filter) .onAppear { if store.galleries.isEmpty { DispatchQueue.main.async { @@ -68,7 +68,7 @@ struct FrontpageView: View { } } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.FrontpageView.Title.frontpage) + .navigationTitle(L10n.Localizable.FrontpageView.frontpage) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index 709e7eef8..cc44f0531 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -76,13 +76,13 @@ public struct HistoryReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmClearHistory) { - TextState(L10n.Localizable.ConfirmationDialog.Button.clear) + TextState(L10n.Localizable.ConfirmationDialog.clear) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.Title.clear) + TextState(L10n.Localizable.ConfirmationDialog.clearDescription) } return .none diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index 57e0a4097..23a3bfb41 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -39,7 +39,7 @@ struct HistoryView: View { }, downloadBadges: store.downloadBadges ) - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) + .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.filter) .onAppear { store.send(.onAppear) if store.galleries.isEmpty { @@ -49,7 +49,7 @@ struct HistoryView: View { } } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.HistoryView.Title.history) + .navigationTitle(L10n.Localizable.HistoryView.history) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift index f57f24a4a..79b9a1a0b 100644 --- a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift +++ b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift @@ -97,7 +97,7 @@ struct CoverWallSection: View { var body: some View { SubSection( - title: L10n.Localizable.HomeView.Section.Title.frontpage, + title: L10n.Localizable.HomeView.frontpage, tint: .secondary, isLoading: isLoading, reloadAction: reloadAction, showAllAction: showAllAction @@ -190,7 +190,7 @@ struct ToplistsSection: View { var body: some View { SubSection( - title: L10n.Localizable.HomeView.Section.Title.toplists, + title: L10n.Localizable.HomeView.toplists, tint: .secondary, isLoading: isLoading, reloadAction: reloadAction, showAllAction: showAllAction @@ -264,7 +264,7 @@ struct MiscGridSection: View { } var body: some View { - SubSection(title: L10n.Localizable.HomeView.Section.Title.other, showAll: false) { + SubSection(title: L10n.Localizable.HomeView.other, showAll: false) { ScrollView(.horizontal, showsIndicators: false) { HStack { let types = HomeMiscGridType.allCases diff --git a/AppPackage/Sources/HomeFeature/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift index 34b5f2a04..b69ea69b0 100644 --- a/AppPackage/Sources/HomeFeature/HomeView.swift +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -91,7 +91,7 @@ public struct HomeView: View { } } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.HomeView.Title.home) + .navigationTitle(L10n.Localizable.HomeView.home) } destination: { store in switch store.case { case .frontpage(let store): @@ -164,11 +164,11 @@ extension HomeMiscGridType { var title: String { switch self { case .popular: - return L10n.Localizable.Enum.HomeMiscGridType.Title.popular + return L10n.Localizable.HomeMiscGridType.popular case .watched: - return L10n.Localizable.Enum.HomeMiscGridType.Title.watched + return L10n.Localizable.HomeMiscGridType.watched case .history: - return L10n.Localizable.Enum.HomeMiscGridType.Title.history + return L10n.Localizable.HomeMiscGridType.history } } var symbol: SFSymbol { diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index 65f10a4cb..0770c29f4 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -44,7 +44,7 @@ struct PopularView: View { FiltersView(store: store) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) + .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.filter) .onAppear { if store.galleries.isEmpty { DispatchQueue.main.async { @@ -53,7 +53,7 @@ struct PopularView: View { } } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.PopularView.Title.popular) + .navigationTitle(L10n.Localizable.PopularView.popular) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index 40e07d49b..fa65f5aed 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -113,22 +113,22 @@ public struct ToplistsReducer: Sendable { let maximumPage = (state.pageNumber?.maximum ?? 0) + 1 state.alert = AppAlertState( title: { - TextState(L10n.Localizable.JumpPageView.Title.jumpPage) + TextState(L10n.Localizable.JumpPageView.jumpPage) }, textField: .init( - placeholder: TextState(L10n.Localizable.JumpPageView.Title.jumpPage), + placeholder: TextState(L10n.Localizable.JumpPageView.jumpPage), keyboard: .numberPad ), actions: { ButtonState(action: .performJumpPage) { - TextState(L10n.Localizable.JumpPageView.Button.confirm) + TextState(L10n.Localizable.JumpPageView.confirm) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } }, message: { - TextState(L10n.Localizable.JumpPageView.Description.jumpPage(maximumPage)) + TextState(L10n.Localizable.JumpPageView.jumpPageDescription(maximumPage)) } ) return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index 601c5e073..9a620e81a 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -26,7 +26,7 @@ struct ToplistsView: View { } private var navigationTitle: String { - [L10n.Localizable.ToplistsView.Title.toplists, store.type.value].joined(separator: " - ") + [L10n.Localizable.ToplistsView.toplists, store.type.value].joined(separator: " - ") } var body: some View { @@ -43,7 +43,7 @@ struct ToplistsView: View { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) + .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.filter) .appAlert($store.scope(state: \.alert, action: \.alert), text: $store.jumpPageIndex) .onAppear { if store.galleries?.isEmpty != false { diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index 975a9a6bb..e4b3bac0a 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -96,7 +96,7 @@ struct WatchedView: View { } } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.WatchedView.Title.watched) + .navigationTitle(L10n.Localizable.WatchedView.watched) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift index 25528d93a..a18957760 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift @@ -47,13 +47,13 @@ public struct MigrationReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmDropDatabase) { - TextState(L10n.Localizable.ConfirmationDialog.Button.dropDatabase) + TextState(L10n.Localizable.ConfirmationDialog.dropDatabase) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.Title.dropDatabase) + TextState(L10n.Localizable.ConfirmationDialog.dropDatabaseDescription) } return .none diff --git a/AppPackage/Sources/MigrationFeature/MigrationView.swift b/AppPackage/Sources/MigrationFeature/MigrationView.swift index 7984c0301..d54209479 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationView.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationView.swift @@ -20,12 +20,12 @@ public struct MigrationView: View { NavigationStack { ZStack { reversedPrimary.ignoresSafeArea() - LoadingView(title: L10n.Localizable.LoadingView.Title.preparingDatabase) + LoadingView(title: L10n.Localizable.LoadingView.preparingDatabase) .opacity(store.databaseState == .loading ? 1 : 0) let error = store.databaseState.failed let errorNonNil = error ?? .databaseCorrupted(nil) AlertView(symbol: errorNonNil.symbol, message: errorNonNil.localizedDescription) { - AlertViewButton(title: L10n.Localizable.ErrorView.Button.dropDatabase) { + AlertViewButton(title: L10n.Localizable.ErrorView.dropDatabase) { store.send(.dropDatabaseButtonTapped) } .confirmationDialog( diff --git a/AppPackage/Sources/ParserFeature/Parser+ResponseError.swift b/AppPackage/Sources/ParserFeature/Parser+ResponseError.swift index 1b35974eb..43afc73b1 100644 --- a/AppPackage/Sources/ParserFeature/Parser+ResponseError.swift +++ b/AppPackage/Sources/ParserFeature/Parser+ResponseError.swift @@ -43,7 +43,7 @@ extension Parser { // gallery-dl treats `404 + Gallery Not Available` as an authorization-like unavailable state: // https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py if normalizedContent.contains("gallery not available") - || normalizedContent.contains(L10n.Constant.Website.Response.galleryUnavailable.lowercased()) { + || normalizedContent.contains(L10n.Constant.galleryUnavailable.lowercased()) { return nil } // JDownloader treats `bounce_login.php` as an account / re-login required signal for EH/EX. diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift index 3e7fcd7dd..2901a4f8b 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift @@ -92,13 +92,13 @@ public struct QuickSearchReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmDelete(word)) { - TextState(L10n.Localizable.ConfirmationDialog.Button.delete) + TextState(L10n.Localizable.ConfirmationDialog.delete) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.Title.delete) + TextState(L10n.Localizable.ConfirmationDialog.deleteDescription) } return .none diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index ea18df606..86ca01009 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -81,7 +81,7 @@ public struct QuickSearchView: View { } .toolbar(content: toolbar) .navigationDestination(item: $store.editKind) { editWordView(for: $0) } - .navigationTitle(L10n.Localizable.QuickSearchView.Title.quickSearch) + .navigationTitle(L10n.Localizable.QuickSearchView.quickSearch) } } @@ -112,8 +112,8 @@ public struct QuickSearchView: View { @ViewBuilder private func editWordView(for kind: QuickSearchReducer.WordEditKind) -> some View { EditWordView( title: kind == .new - ? L10n.Localizable.QuickSearchView.Title.newWord - : L10n.Localizable.QuickSearchView.Title.editWord, + ? L10n.Localizable.QuickSearchView.newWord + : L10n.Localizable.QuickSearchView.editWord, word: $store.editingWord, focusedField: $focusedField, submitAction: onTextFieldSubmitted, @@ -147,11 +147,11 @@ extension QuickSearchView { var body: some View { Form { - Section(L10n.Localizable.QuickSearchView.Title.name) { - TextField(L10n.Localizable.QuickSearchView.Placeholder.optional, text: $word.name) + Section(L10n.Localizable.QuickSearchView.name) { + TextField(L10n.Localizable.QuickSearchView.optional, text: $word.name) .submitLabel(.next).focused(focusedField, equals: .name) } - Section(L10n.Localizable.QuickSearchView.Title.content) { + Section(L10n.Localizable.QuickSearchView.content) { TextEditor(text: $word.content) .disableAutocorrection(true) .textInputAutocapitalization(.never) diff --git a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift index 23a7c7893..bdea0e0a3 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift @@ -34,9 +34,9 @@ extension AutoPlayPolicy { var value: String { switch self { case .off: - return L10n.Localizable.Enum.AutoPlayPolicy.Value.off + return L10n.Localizable.AutoPlayPolicy.off default: - return L10n.Localizable.Common.Value.seconds("\(rawValue)") + return L10n.Localizable.Common.seconds("\(rawValue)") } } } @@ -144,25 +144,25 @@ struct HorizontalImageStack: View { Button { refetchAction(index) } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.reload, systemSymbol: .arrowCounterclockwise) + Label(L10n.Localizable.ReadingView.reload, systemSymbol: .arrowCounterclockwise) } if let imageURL = imageURLs[index] { Button { copyImageAction(imageURL) } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.copy, systemSymbol: .plusSquareOnSquare) + Label(L10n.Localizable.ReadingView.copy, systemSymbol: .plusSquareOnSquare) } Button { saveImageAction(imageURL) } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.save, systemSymbol: .squareAndArrowDown) + Label(L10n.Localizable.ReadingView.save, systemSymbol: .squareAndArrowDown) } if let originalImageURL = originalImageURLs[index] { Button { saveImageAction(originalImageURL) } label: { Label( - L10n.Localizable.ReadingView.ContextMenu.Button.saveOriginal, + L10n.Localizable.ReadingView.saveOriginal, systemSymbol: .squareAndArrowDownOnSquare ) } @@ -170,7 +170,7 @@ struct HorizontalImageStack: View { Button { shareImageAction(imageURL) } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.share, systemSymbol: .squareAndArrowUp) + Label(L10n.Localizable.ReadingView.share, systemSymbol: .squareAndArrowUp) } } } diff --git a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift index 3c7e34c17..63b9030ac 100644 --- a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift +++ b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift @@ -151,7 +151,7 @@ private struct UpperPanel: View { Button { setting.enablesDualPageMode.toggle() } label: { - Text(L10n.Localizable.ReadingView.ToolbarItem.Title.dualPageMode) + Text(L10n.Localizable.ReadingView.dualPageMode) if setting.enablesDualPageMode { Image(systemSymbol: .checkmark) } @@ -159,7 +159,7 @@ private struct UpperPanel: View { Button { setting.exceptCover.toggle() } label: { - Text(L10n.Localizable.ReadingView.ToolbarItem.Title.exceptTheCover) + Text(L10n.Localizable.ReadingView.exceptTheCover) if setting.exceptCover { Image(systemSymbol: .checkmark) } @@ -173,7 +173,7 @@ private struct UpperPanel: View { } Menu { - Text(L10n.Localizable.ReadingView.ToolbarItem.Title.autoPlay).foregroundColor(.secondary) + Text(L10n.Localizable.ReadingView.autoPlay).foregroundColor(.secondary) ForEach(AutoPlayPolicy.allCases) { policy in Button { autoPlayPolicy = policy @@ -193,15 +193,15 @@ private struct UpperPanel: View { ToolbarFeaturesMenu { Button(action: retryAllFailedImagesAction) { Image(systemSymbol: .exclamationmarkArrowTrianglehead2ClockwiseRotate90) - Text(L10n.Localizable.ReadingView.ToolbarItem.Button.retryAllFailedImages) + Text(L10n.Localizable.ReadingView.retryAllFailedImages) } Button(action: reloadAllImagesAction) { Image(systemSymbol: .arrowCounterclockwise) - Text(L10n.Localizable.ReadingView.ToolbarItem.Button.reloadAllImages) + Text(L10n.Localizable.ReadingView.reloadAllImages) } Button(action: navigateSettingAction) { Image(systemSymbol: .gear) - Text(L10n.Localizable.ReadingView.ToolbarItem.Button.readingSetting) + Text(L10n.Localizable.ReadingView.readingSetting) } } .buttonStyle(.borderless) diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index 0b0e173f2..8233a7adf 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -27,25 +27,25 @@ public struct ReadingSettingView: View { public var body: some View { Form { Section { - Picker(L10n.Localizable.ReadingSettingView.Title.direction, selection: $readingDirection) { + Picker(L10n.Localizable.ReadingSettingView.direction, selection: $readingDirection) { ForEach(ReadingDirection.allCases) { Text($0.value).tag($0) } } .pickerStyle(.menu) - Picker(L10n.Localizable.ReadingSettingView.Title.preloadLimit, selection: $prefetchLimit) { + Picker(L10n.Localizable.ReadingSettingView.preloadLimit, selection: $prefetchLimit) { ForEach(Array(stride(from: 6, through: 18, by: 4)), id: \.self) { value in - Text(L10n.Localizable.Common.Value.pages("\(value)")).tag(value) + Text(L10n.Localizable.Common.pages("\(value)")).tag(value) } } .pickerStyle(.menu) if !DeviceUtil.isPad { - Toggle(L10n.Localizable.ReadingSettingView.Title.enablesLandscape, isOn: $enablesLandscape) + Toggle(L10n.Localizable.ReadingSettingView.enablesLandscape, isOn: $enablesLandscape) } } - Section(L10n.Localizable.ReadingSettingView.Section.Title.appearance) { + Section(L10n.Localizable.ReadingSettingView.appearance) { Picker( - L10n.Localizable.ReadingSettingView.Title.separatorHeight, + L10n.Localizable.ReadingSettingView.separatorHeight, selection: $contentDividerHeight ) { ForEach(Array(stride(from: 0, through: 20, by: 5)), id: \.self) { value in @@ -56,17 +56,17 @@ public struct ReadingSettingView: View { .disabled(readingDirection != .vertical) ScaleFactorRow( scaleFactor: $maximumScaleFactor, - labelContent: L10n.Localizable.ReadingSettingView.Title.maximumScaleFactor, + labelContent: L10n.Localizable.ReadingSettingView.maximumScaleFactor, minFactor: 1.5, maxFactor: 10 ) ScaleFactorRow( scaleFactor: $doubleTapScaleFactor, - labelContent: L10n.Localizable.ReadingSettingView.Title.doubleTapScaleFactor, + labelContent: L10n.Localizable.ReadingSettingView.doubleTapScaleFactor, minFactor: 1.5, maxFactor: 5 ) } } - .navigationTitle(L10n.Localizable.ReadingSettingView.Title.reading) + .navigationTitle(L10n.Localizable.ReadingSettingView.reading) } } diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 48d68c689..b2380e4ef 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -1,235 +1,232 @@ // MARK: BanInterval -"enum.ban_interval.description.and" = "and"; +"ban_interval.and" = "and"; // MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "Yesterday"; -"enum.toplists_type.value.past_month" = "Past month"; -"enum.toplists_type.value.past_year" = "Past year"; -"enum.toplists_type.value.all_time" = "All time"; +"toplists_type.yesterday" = "Yesterday"; +"toplists_type.past_month" = "Past month"; +"toplists_type.past_year" = "Past year"; +"toplists_type.all_time" = "All time"; // MARK: Response -"website.response.hath_client_not_found" = "Du benötigst einen deinem Konto zugehörigen H@H Client um diese Funktion nutzen zu können"; -"website.response.hath_client_not_online" = "Dein H@H Client scheint offline zu sein. Sieh nach, ob er läuft und probier's nochmal"; -"website.response.invalid_resolution" = "Die gewünschte Galerie kann nicht in der gewählten Auflösung heruntergeladen werden"; +"hath_client_not_found" = "Du benötigst einen deinem Konto zugehörigen H@H Client um diese Funktion nutzen zu können"; +"hath_client_not_online" = "Dein H@H Client scheint offline zu sein. Sieh nach, ob er läuft und probier's nochmal"; +"invalid_resolution" = "Die gewünschte Galerie kann nicht in der gewählten Auflösung heruntergeladen werden"; // MARK: Toast -"toast.title.error" = "Fehler"; -"toast.title.success" = "Erfolg"; -"toast.title.loading" = "Wird geladen..."; -"toast.title.communicating" = "Verbinde..."; -"toast.caption.copied_to_clipboard" = "In Zwischenablage kopiert"; -"toast.caption.saved_to_photo_library" = "Saved to photo library"; +"toast.error" = "Fehler"; +"toast.success" = "Erfolg"; +"toast.loading" = "Wird geladen..."; +"toast.communicating" = "Verbinde..."; +"toast.copied_to_clipboard" = "In Zwischenablage kopiert"; +"toast.saved_to_photo_library" = "Saved to photo library"; // MARK: AutoLock "local_authorization.reason" = "Die App hat sich selbst gesperrt, da der auto-lock Zeitraum abgelaufen ist."; // MARK: Common value -"common.value.stars" = "%@ Sterne"; -"common.value.pages" = "%@ pages"; -"common.value.times" = "%@ mal"; -"common.value.day" = "%@ day"; -"common.value.days" = "%@ days"; -"common.value.hour" = "%@ hour"; -"common.value.hours" = "%@ hours"; -"common.value.minute" = "Nach %@ Minute"; -"common.value.minutes" = "Nach %@ Minuten"; -"common.value.second" = "%@ second"; -"common.value.seconds" = "Nach %@ Sekunden"; -"common.value.records" = "%@ Einträge"; +"common.stars" = "%@ Sterne"; +"common.pages" = "%@ pages"; +"common.day" = "%@ day"; +"common.days" = "%@ days"; +"common.hour" = "%@ hour"; +"common.hours" = "%@ hours"; +"common.minute" = "Nach %@ Minute"; +"common.minutes" = "Nach %@ Minuten"; +"common.second" = "%@ second"; +"common.seconds" = "Nach %@ Sekunden"; // MARK: Common button -"common.button.cancel" = "Abbrechen"; +"common.cancel" = "Abbrechen"; // MARK: TabItem -"tab_item.title.home" = "Home"; -"tab_item.title.favorites" = "Favoriten"; -"tab_item.title.search" = "Suche"; -"tab_item.title.downloads" = "Downloads"; -"tab_item.title.setting" = "Einstellungen"; +"tab_item.home" = "Home"; +"tab_item.favorites" = "Favoriten"; +"tab_item.search" = "Suche"; +"tab_item.downloads" = "Downloads"; +"tab_item.setting" = "Einstellungen"; // MARK: ToolbarItem -"toolbar_item.button.filters" = "Filters"; -"toolbar_item.button.jump_page" = "Jump page"; -"toolbar_item.button.date_seek" = "Datum aufsuchen"; -"toolbar_item.button.quick_search" = "Quick search"; +"toolbar_item.filters" = "Filters"; +"toolbar_item.jump_page" = "Jump page"; +"toolbar_item.date_seek" = "Datum aufsuchen"; +"toolbar_item.quick_search" = "Quick search"; // MARK: DateSeek -"date_seek_view.title.date_seek" = "Datum aufsuchen"; -"date_seek_view.title.date" = "Datum"; -"date_seek_view.footer.seek_around_date" = "Galerien rund um das gewählte Datum aufsuchen."; -"date_seek_view.button.seek_newer" = "Neuere"; -"date_seek_view.button.seek_older" = "Ältere"; +"date_seek_view.date_seek" = "Datum aufsuchen"; +"date_seek_view.date" = "Datum"; +"date_seek_view.seek_around_date" = "Galerien rund um das gewählte Datum aufsuchen."; +"date_seek_view.seek_newer" = "Neuere"; +"date_seek_view.seek_older" = "Ältere"; // MARK: JumpPage -"jump_page_view.title.jump_page" = "Jump page"; -"jump_page_view.description.jump_page" = "Geben Sie eine Seitenzahl zwischen 1 und %d ein."; -"jump_page_view.button.confirm" = "Confirm"; +"jump_page_view.jump_page" = "Jump page"; +"jump_page_view.jump_page_description" = "Geben Sie eine Seitenzahl zwischen 1 und %d ein."; +"jump_page_view.confirm" = "Confirm"; // MARK: AlertView -"loading_view.title.loading" = "Wird geladen..."; -"loading_view.title.preparing_database" = "Preparing the database..."; -"not_login_view.title.need_login" = "You need to login to access this feature."; -"not_login_view.button.login" = "Login"; -"error_view.button.retry" = "Erneut versuchen"; -"error_view.button.drop_database" = "Drop the database"; -"error_view.title.try_later" = "Please try again later."; -"error_view.title.network" = "A network error occurred."; -"error_view.title.parsing" = "A parsing error occurred."; -"error_view.title.unknown" = "An unknown error occurred."; -"error_view.title.not_found" = "There seems to be nothing here."; -"error_view.title.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; -"error_view.title.ip_banned" = "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@."; -"error_view.title.copyright_claim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; -"error_view.title.gallery_unavailable" = "This gallery has been removed or is unavailable."; +"loading_view.loading" = "Wird geladen..."; +"loading_view.preparing_database" = "Preparing the database..."; +"not_login_view.need_login" = "You need to login to access this feature."; +"not_login_viewlogin" = "Login"; +"error_view.retry" = "Erneut versuchen"; +"error_view.drop_database" = "Drop the database"; +"error_view.try_later" = "Please try again later."; +"error_view.network" = "A network error occurred."; +"error_view.parsing" = "A parsing error occurred."; +"error_view.unknown" = "An unknown error occurred."; +"error_view.not_found" = "There seems to be nothing here."; +"error_view.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; +"error_view.ip_banned" = "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@."; +"error_view.copyright_claim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; +"error_view.gallery_unavailable" = "This gallery has been removed or is unavailable."; // MARK: AppError -"app_error.localized_description.database_corrupted" = "Datenbank beschädigt"; -"app_error.localized_description.copyright_claim" = "Urheberrechtsanspruch"; -"app_error.localized_description.ip_banned" = "IP-Adresse gesperrt"; -"app_error.localized_description.gallery_expunged" = "Galerie entfernt"; -"app_error.localized_description.network_error" = "Netzwerkfehler"; -"app_error.localized_description.web_image_loading_error" = "Fehler beim Laden des Webbilds"; -"app_error.localized_description.parse_error" = "Parserfehler"; -"app_error.localized_description.quota_exceeded" = "Kontingent überschritten"; -"app_error.localized_description.authentication_required" = "Authentifizierung erforderlich"; -"app_error.localized_description.file_operation_failed" = "Dateivorgang fehlgeschlagen"; -"app_error.localized_description.no_updates_available" = "Keine Updates verfügbar"; -"app_error.localized_description.not_found" = "Nicht gefunden"; -"app_error.localized_description.unknown_error" = "Unbekannter Fehler"; -"app_error.alert.quota_exceeded" = "Bildkontingent überschritten.\nBitte warte einen Moment und versuche es dann erneut."; -"app_error.alert.authentication_required" = "Für diesen Download ist eine Anmeldung erforderlich."; -"app_error.alert.local_file_operation_failed" = "Lokaler Dateivorgang fehlgeschlagen."; +"app_error.database_corrupted" = "Datenbank beschädigt"; +"app_error.copyright_claim" = "Urheberrechtsanspruch"; +"app_error.ip_banned" = "IP-Adresse gesperrt"; +"app_error.gallery_expunged" = "Galerie entfernt"; +"app_error.network_error" = "Netzwerkfehler"; +"app_error.web_image_loading_error" = "Fehler beim Laden des Webbilds"; +"app_error.parse_error" = "Parserfehler"; +"app_error.quota_exceeded" = "Kontingent überschritten"; +"app_error.authentication_required" = "Authentifizierung erforderlich"; +"app_error.file_operation_failed" = "Dateivorgang fehlgeschlagen"; +"app_error.no_updates_available" = "Keine Updates verfügbar"; +"app_error.not_found" = "Nicht gefunden"; +"app_error.unknown_error" = "Unbekannter Fehler"; +"app_error.quota_exceeded_description" = "Bildkontingent überschritten.\nBitte warte einen Moment und versuche es dann erneut."; +"app_error.authentication_required_description" = "Für diesen Download ist eine Anmeldung erforderlich."; +"app_error.local_file_operation_failed" = "Lokaler Dateivorgang fehlgeschlagen."; // MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "You will lose all your data in this app.\nAre you sure to drop the database?"; -"confirmation_dialog.title.remove_custom_translations" = "Are you sure to remove your custom translations?"; -"confirmation_dialog.title.logout" = "Bist du sicher das du dich ausloggen möchtest?"; -"confirmation_dialog.title.delete" = "Are you sure to delete this item?"; -"confirmation_dialog.title.clear" = "Bist du sicher das du das löschen möchtest?"; -"confirmation_dialog.title.reset" = "Bist du sicher?"; -"confirmation_dialog.button.drop_database" = "Drop the database"; -"confirmation_dialog.button.remove" = "Remove"; -"confirmation_dialog.button.logout" = "Ausloggen"; -"confirmation_dialog.button.delete" = "Delete"; -"confirmation_dialog.button.clear" = "Löschen"; -"confirmation_dialog.button.reset" = "Zurücksetzen"; +"confirmation_dialog.drop_database_description" = "You will lose all your data in this app.\nAre you sure to drop the database?"; +"confirmation_dialog.remove_custom_translations" = "Are you sure to remove your custom translations?"; +"confirmation_dialog.logout_description" = "Bist du sicher das du dich ausloggen möchtest?"; +"confirmation_dialog.delete_description" = "Are you sure to delete this item?"; +"confirmation_dialog.clear_description" = "Bist du sicher das du das löschen möchtest?"; +"confirmation_dialog.reset_description" = "Bist du sicher?"; +"confirmation_dialog.drop_database" = "Drop the database"; +"confirmation_dialog.remove" = "Remove"; +"confirmation_dialog.logout" = "Ausloggen"; +"confirmation_dialog.delete" = "Delete"; +"confirmation_dialog.clear" = "Löschen"; +"confirmation_dialog.reset" = "Zurücksetzen"; // MARK: SubSection -"sub_section.button.show_all" = "Alle anzeigen"; +"sub_section.show_all" = "Alle anzeigen"; // MARK: NewDawnView -"new_dawn_view.title.first" = "Es ist der Beginn eines neuen Tages!"; -"new_dawn_view.title.second" = "Als du auf deine bisherige Reise zurückblickst wirst du ein kleines bisschen weiser."; +"new_dawn_view.first" = "Es ist der Beginn eines neuen Tages!"; +"new_dawn_view.second" = "Als du auf deine bisherige Reise zurückblickst wirst du ein kleines bisschen weiser."; // Greeting -"struct.greeting.mark.start" = "Du erhälst "; -"struct.greeting.mark.separator" = ", "; -"struct.greeting.mark.and" = " und "; -"struct.greeting.mark.end" = "!"; +"greeting.start" = "Du erhälst "; +"greeting.separator" = ", "; +"greeting.and" = " und "; +"greeting.end" = "!"; // MARK: HomeView -"home_view.title.home" = "Home"; -"home_view.section.title.frontpage" = "Frontpage"; -"home_view.section.title.toplists" = "Toplists"; -"home_view.section.title.other" = "Other"; +"home_view.home" = "Home"; +"home_view.frontpage" = "Frontpage"; +"home_view.toplists" = "Toplists"; +"home_view.other" = "Other"; // HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "Beliebt"; -"enum.home_misc_grid_type.title.watched" = "Meine Tags"; -"enum.home_misc_grid_type.title.history" = "Verlauf"; +"home_misc_grid_type.popular" = "Beliebt"; +"home_misc_grid_type.watched" = "Meine Tags"; +"home_misc_grid_type.history" = "Verlauf"; // MARK: FrontpageView -"frontpage_view.title.frontpage" = "Frontpage"; +"frontpage_view.frontpage" = "Frontpage"; // MARK: ToplistsView -"toplists_view.title.toplists" = "Toplists"; +"toplists_view.toplists" = "Toplists"; // MARK: PopularView -"popular_view.title.popular" = "Beliebt"; +"popular_view.popular" = "Beliebt"; // MARK: WatchedView -"watched_view.title.watched" = "Meine Tags"; +"watched_view.watched" = "Meine Tags"; // MARK: HistoryView -"history_view.title.history" = "Verlauf"; +"history_view.history" = "Verlauf"; // MARK: FavoritesView -"favorites_view.title.favorites" = "Favoriten"; +"favorites_view.favorites" = "Favoriten"; // FavoriteCategory -"struct.user.favorite_category.default" = "Favoriten %@"; -"struct.user.favorite_category.all" = "Alle"; +"favorite_category.default" = "Favoriten %@"; +"favorite_category.all" = "Alle"; // MARK: SearchView -"search_view.title.search" = "Suche"; -"search_view.section.title.recently_searched" = "Recently searched"; -"search_view.section.title.recently_seen" = "Recently seen"; -"search_view.section.title.quick_search" = "Quick search"; +"search_view.search" = "Suche"; +"search_view.recently_searched" = "Recently searched"; +"search_view.recently_seen" = "Recently seen"; +"search_view.quick_search" = "Quick search"; // Searchable -"searchable.prompt.filter" = "Filter"; -"searchable.title.matches_count" = "Found %d matches."; +"searchable.filter" = "Filter"; +"searchable.matches_count" = "Found %d matches."; // MARK: QuickSearchView -"quick_search_view.title.quick_search" = "Quick search"; -"quick_search_view.title.edit_word" = "Edit word"; -"quick_search_view.title.new_word" = "New word"; -"quick_search_view.title.content" = "Content"; -"quick_search_view.title.name" = "Name"; -"quick_search_view.placeholder.optional" = "Optional"; +"quick_search_view.quick_search" = "Quick search"; +"quick_search_view.edit_word" = "Edit word"; +"quick_search_view.new_word" = "New word"; +"quick_search_view.content" = "Content"; +"quick_search_view.name" = "Name"; +"quick_search_view.optional" = "Optional"; // MARK: SettingView -"setting_view.title.setting" = "Einstellungen"; +"setting_view.setting" = "Einstellungen"; // SettingStateRoute -"enum.setting_state_route.value.account" = "Konto"; -"enum.setting_state_route.value.general" = "Allgemein"; -"enum.setting_state_route.value.appearance" = "Oberfläche"; -"enum.setting_state_route.value.reading" = "Am Lesen"; -"enum.setting_state_route.value.download" = "Download"; -"enum.setting_state_route.value.laboratory" = "Experimentelles"; -"enum.setting_state_route.value.about" = "About"; +"setting_state_route.account" = "Konto"; +"setting_state_route.general" = "Allgemein"; +"setting_state_route.appearance" = "Oberfläche"; +"setting_state_route.reading" = "Am Lesen"; +"setting_state_route.download" = "Download"; +"setting_state_route.laboratory" = "Experimentelles"; +"setting_state_route.about" = "About"; // MARK: AccountSettingView -"account_setting_view.title.account" = "Konto"; -"account_setting_view.title.shows_new_dawn_greeting" = "Neuer-Tag-Meldung anzeigen"; -"account_setting_view.button.login" = "Einloggen"; -"account_setting_view.button.logout" = "Ausloggen"; -"account_setting_view.button.account_configuration" = "Kontoeinstellungen"; -"account_setting_view.button.tags_management" = "Meine Tags bearbeiten"; -"account_setting_view.button.copy_cookies" = "Cookies kopieren"; +"account_setting_view.account" = "Konto"; +"account_setting_view.shows_new_dawn_greeting" = "Neuer-Tag-Meldung anzeigen"; +"account_setting_view.login" = "Einloggen"; +"account_setting_view.account_configuration" = "Kontoeinstellungen"; +"account_setting_view.tags_management" = "Meine Tags bearbeiten"; +"account_setting_view.copy_cookies" = "Cookies kopieren"; // CookieValue -"struct.cookie_value.localized_string.expired" = "Abgelaufen"; -"struct.cookie_value.localized_string.mystery" = "Abgelehnt"; -"struct.cookie_value.localized_string.none" = "None"; +"cookie_value.expired" = "Abgelaufen"; +"cookie_value.mystery" = "Abgelehnt"; +"cookie_value.none" = "None"; // MARK: LoginView -"login_view.title.login" = "Einloggen"; -"login_view.title.username" = "Username"; -"login_view.title.password" = "Password"; +"login_view.login" = "Einloggen"; +"login_view.username" = "Username"; +"login_view.password" = "Password"; // MARK: GeneralSettingView -"general_setting_view.title.general" = "Allgemein"; -"general_setting_view.title.language" = "Sprache"; -"general_setting_view.title.auto_lock" = "Auto-Lock"; -"general_setting_view.title.enables_tags_extension" = "Enables tags extension"; -"general_setting_view.title.translates_tags" = "Translates tags"; -"general_setting_view.title.shows_tags_search_suggestion" = "Shows tags search suggestion"; -"general_setting_view.title.shows_images_in_tags" = "Shows images in tags"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "Links zum ausgewählten Host umleiten"; -"general_setting_view.title.detects_links_from_clipboard" = "Übernimmt automatisch Links aus der Zwischenablage"; -"general_setting_view.title.background_blur_radius" = "Background blur radius"; -"general_setting_view.button.app_activity_logs" = "App-Aktivitätsprotokolle"; -"general_setting_view.button.import_custom_translations" = "Import custom translations"; -"general_setting_view.button.remove_custom_translations" = "Remove custom translations"; -"general_setting_view.button.clear_image_caches" = "Zwischengespeicherte Bilder (Cache) löschen"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "Tags"; -"general_setting_view.section.title.navigation" = "Navigation"; -"general_setting_view.section.title.security" = "Sicherheit"; -"general_setting_view.section.title.caches" = "Caches"; +"general_setting_view.general" = "Allgemein"; +"general_setting_view.language" = "Sprache"; +"general_setting_view.auto_lock" = "Auto-Lock"; +"general_setting_view.enables_tags_extension" = "Enables tags extension"; +"general_setting_view.translates_tags" = "Translates tags"; +"general_setting_view.shows_tags_search_suggestion" = "Shows tags search suggestion"; +"general_setting_view.shows_images_in_tags" = "Shows images in tags"; +"general_setting_view.redirects_links_to_the_selected_host" = "Links zum ausgewählten Host umleiten"; +"general_setting_view.detects_links_from_clipboard" = "Übernimmt automatisch Links aus der Zwischenablage"; +"general_setting_view.background_blur_radius" = "Background blur radius"; +"general_setting_view.app_activity_logs" = "App-Aktivitätsprotokolle"; +"general_setting_view.import_custom_translations" = "Import custom translations"; +"general_setting_view.remove_custom_translations" = "Remove custom translations"; +"general_setting_view.clear_image_caches" = "Zwischengespeicherte Bilder (Cache) löschen"; +"general_setting_view.default_language_description" = "N/A"; +"general_setting_view.tags" = "Tags"; +"general_setting_view.navigation" = "Navigation"; +"general_setting_view.security" = "Sicherheit"; +"general_setting_view.caches" = "Caches"; // AutoLockPolicy -"enum.auto_lock_policy.value.never" = "Nie"; -"enum.auto_lock_policy.value.instantly" = "Sofort"; +"auto_lock_policy.never" = "Nie"; +"auto_lock_policy.instantly" = "Sofort"; // MARK: AppActivityLogsView "app_activity_logs_view.title" = "App-Aktivitätsprotokolle"; -"app_activity_logs_view.placeholder.no_logs" = "Keine Protokolle gefunden"; -"app_activity_logs_view.section.current" = "Aktuell"; +"app_activity_logs_view.no_logs" = "Keine Protokolle gefunden"; +"app_activity_logs_view.current" = "Aktuell"; "app_activity_logs_view.run" = "Ausführung %@"; "app_activity_logs_view.more_logs" = "Weitere Protokolle"; "app_activity_logs_view.open_in_files" = "In „Dateien“ öffnen"; @@ -242,810 +239,786 @@ "app_activity_logs_view.level.fault" = "Störung"; // MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "Oberfläche"; -"appearance_setting_view.title.theme" = "Theme"; -"appearance_setting_view.title.tint_color" = "Farbe"; -"appearance_setting_view.title.display_mode" = "Display mode"; -"appearance_setting_view.title.shows_tags_in_list" = "Tags als Liste anzeigen"; -"appearance_setting_view.title.maximum_number_of_tags" = "Maximale Anzahl an Tags"; -"appearance_setting_view.title.displays_japanese_title" = "Displays Japanese title"; -"appearance_setting_view.button.app_icon" = "App icon"; -"appearance_setting_view.menu.title.infite" = "Infite"; -"appearance_setting_view.section.title.list" = "List"; -"appearance_setting_view.section.title.gallery" = "Gallery"; +"appearance_setting_view.appearance" = "Oberfläche"; +"appearance_setting_view.theme" = "Theme"; +"appearance_setting_view.tint_color" = "Farbe"; +"appearance_setting_view.display_mode" = "Display mode"; +"appearance_setting_view.shows_tags_in_list" = "Tags als Liste anzeigen"; +"appearance_setting_view.maximum_number_of_tags" = "Maximale Anzahl an Tags"; +"appearance_setting_view.displays_japanese_title" = "Displays Japanese title"; +"appearance_setting_view.app_icon" = "App icon"; +"appearance_setting_view.infite" = "Infite"; +"appearance_setting_view.list" = "List"; +"appearance_setting_view.gallery" = "Gallery"; // PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "Automatisch"; -"enum.preferred_color_scheme.value.light" = "Hell"; -"enum.preferred_color_scheme.value.dark" = "Dunkel"; +"preferred_color_scheme.automatic" = "Automatisch"; +"preferred_color_scheme.light" = "Hell"; +"preferred_color_scheme.dark" = "Dunkel"; // AppIconType -"enum.app_icon_type.value.default" = "Standard"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; +"app_icon_type.default" = "Standard"; +"app_icon_type.ukiyoe" = "Ukiyo-e"; +"app_icon_type.developer" = "Developer"; +"app_icon_type.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; +"app_icon_type.not_my_president" = "NOT MY PRESIDENT"; // ListDisplayMode -"enum.list_display_mode.value.detail" = "Detail"; -"enum.list_display_mode.value.thumbnail" = "Thumbnail"; +"list_display_mode.detail" = "Detail"; +"list_display_mode.thumbnail" = "Thumbnail"; // MARK: AppIconView -"app_icon_view.title.app_icon" = "App icon"; +"app_icon_view.app_icon" = "App icon"; // MARK: reading_settingView -"reading_setting_view.title.reading" = "Am Lesen"; -"reading_setting_view.title.direction" = "Direction"; -"reading_setting_view.title.preload_limit" = "Preload limit"; -"reading_setting_view.title.enables_landscape" = "Enables landscape"; -"reading_setting_view.title.separator_height" = "Höhe der Teilung"; -"reading_setting_view.title.maximum_scale_factor" = "Maximaler Skalierungsfaktor"; -"reading_setting_view.title.double_tap_scale_factor" = "Doppel-Tap Skalierungsfaktor"; -"reading_setting_view.section.title.appearance" = "Oberfläche"; +"reading_setting_view.reading" = "Am Lesen"; +"reading_setting_view.direction" = "Direction"; +"reading_setting_view.preload_limit" = "Preload limit"; +"reading_setting_view.enables_landscape" = "Enables landscape"; +"reading_setting_view.separator_height" = "Höhe der Teilung"; +"reading_setting_view.maximum_scale_factor" = "Maximaler Skalierungsfaktor"; +"reading_setting_view.double_tap_scale_factor" = "Doppel-Tap Skalierungsfaktor"; +"reading_setting_view.appearance" = "Oberfläche"; // ReadingDirection -"enum.reading_direction.value.vertical" = "Vertikal"; -"enum.reading_direction.value.right_to_left" = "Von rechts nach links"; -"enum.reading_direction.value.left_to_right" = "Von links nach rechts"; +"reading_direction.vertical" = "Vertikal"; +"reading_direction.right_to_left" = "Von rechts nach links"; +"reading_direction.left_to_right" = "Von links nach rechts"; // MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "Experimentelles"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "SNI Filter umgehen"; +"laboratory_setting_view.laboratory" = "Experimentelles"; +"laboratory_setting_view.bypasses_SNI_filtering" = "SNI Filter umgehen"; // MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "Website"; -"about_view.button.altStore_source" = "AltStore Quelle"; -"about_view.title.version" = "Version"; -"about_view.section.title.special_thanks" = "Special thanks"; -"about_view.section.title.code_level_contributors" = "Code-level contributors"; -"about_view.section.title.translation_contributors" = "Translation contributors"; -"about_view.section.title.acknowledgements" = "OK"; +"about_view.ehPanda" = "EhPanda"; +"about_view.website" = "Website"; +"about_view.altStore_source" = "AltStore Quelle"; +"about_view.version" = "Version"; +"about_view.special_thanks" = "Special thanks"; +"about_view.code_level_contributors" = "Code-level contributors"; +"about_view.translation_contributors" = "Translation contributors"; +"about_view.acknowledgements" = "OK"; // MARK: DetailView -"detail_view.button.download_login" = "LOGIN"; -"detail_view.button.download_get" = "HOLEN"; -"detail_view.button.download_wait" = "WARTEN"; -"detail_view.button.download_done" = "FERTIG"; -"detail_view.button.download_update" = "UPDATE"; -"detail_view.button.download_retry" = "ERNEUT"; -"detail_view.button.download_repair" = "REPAR."; -"detail_view.button.read" = "Lesen"; -"detail_view.button.post_comment" = "Kommentar abgeben"; -"detail_view.accessibility.download_button.login" = "Zum Herunterladen anmelden"; -"detail_view.accessibility.download_button.download" = "Herunterladen"; -"detail_view.accessibility.download_button.queued" = "In Warteschlange"; -"detail_view.accessibility.download_button.downloading" = "Lädt %d von %d herunter"; -"detail_view.accessibility.download_button.downloaded" = "Heruntergeladene Galerie löschen"; -"detail_view.accessibility.download_button.update" = "Download aktualisieren"; -"detail_view.accessibility.download_button.retry" = "Download erneut versuchen"; -"detail_view.accessibility.download_button.repair" = "Download reparieren"; -"detail_view.accessibility.download_button.preparing" = "Download-Informationen werden geladen"; -"detail_view.accessibility.download_button.pause_action" = "Download pausieren"; -"detail_view.accessibility.download_button.paused" = "Download fortsetzen. Pausiert bei %d von %d"; -"detail_view.accessibility.download_button.partial" = "Download erneut versuchen. %d von %d Seiten sind bereits verfügbar."; -"detail_view.toolbar_item.button.archives" = "Archiv"; -"detail_view.toolbar_item.button.torrents" = "Torrents"; -"detail_view.toolbar_item.button.share" = "Teilen"; -"detail_view.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "Withdraw vote"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "Favorisiert"; -"detail_view.description_section.title.language" = "Sprache"; -"detail_view.description_section.title.ratings" = "%@ Bewertungen"; -"detail_view.description_section.title.page_count" = "Seitenzahl"; -"detail_view.description_section.title.file_size" = "Dateigröße"; -"detail_view.description_section.description.favorited" = "mal"; -"detail_view.description_section.description.page_count" = "Seiten"; -"detail_view.action_section.button.give_a_rating" = "Bewertung abgeben"; -"detail_view.action_section.button.similar_gallery" = "Ähnliche Galerien"; -"detail_view.section.title.previews" = "Vorschau"; -"detail_view.section.title.comments" = "Kommentar"; -"detail_view.dialog.title.delete_download" = "Download löschen?"; -"detail_view.dialog.title.repair_download" = "Download reparieren?"; -"detail_view.dialog.title.update_download" = "Download aktualisieren?"; -"detail_view.dialog.title.redownload_gallery" = "Galerie erneut herunterladen?"; -"detail_view.dialog.message.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; -"detail_view.dialog.message.repair_download" = "Die Offline-Dateien dieser Galerie jetzt reparieren?"; -"detail_view.dialog.message.update_download" = "Diese Galerie jetzt auf die neueste Online-Version aktualisieren?"; -"detail_view.dialog.message.redownload_gallery" = "Diese Galerie jetzt vollständig neu herunterladen?"; -"detail_view.dialog.button.repair" = "Reparieren"; -"detail_view.dialog.button.update" = "Aktualisieren"; -"detail_view.dialog.button.redownload" = "Erneut laden"; -"detail_view.offline_notice.saved_details" = "Online-Details konnten nicht aktualisiert werden. Stattdessen werden gespeicherte Details angezeigt."; +"detail_view.read" = "Lesen"; +"detail_view.post_comment" = "Kommentar abgeben"; +"detail_view.accessibility.login" = "Zum Herunterladen anmelden"; +"detail_view.accessibility.download" = "Herunterladen"; +"detail_view.accessibility.queued" = "In Warteschlange"; +"detail_view.accessibility.downloading" = "Lädt %d von %d herunter"; +"detail_view.accessibility.downloaded" = "Heruntergeladene Galerie löschen"; +"detail_view.accessibility.update" = "Download aktualisieren"; +"detail_view.accessibility.retry" = "Download erneut versuchen"; +"detail_view.accessibility.repair" = "Download reparieren"; +"detail_view.accessibility.preparing" = "Download-Informationen werden geladen"; +"detail_view.accessibility.pause_action" = "Download pausieren"; +"detail_view.accessibility.paused" = "Download fortsetzen. Pausiert bei %d von %d"; +"detail_view.accessibility.partial" = "Download erneut versuchen. %d von %d Seiten sind bereits verfügbar."; +"detail_view.archives" = "Archiv"; +"detail_view.torrents" = "Torrents"; +"detail_view.share" = "Teilen"; +"detail_view.detail" = "Detail"; +"detail_view.withdraw_vote" = "Withdraw vote"; +"detail_view.vote_up" = "Vote up"; +"detail_view.vote_down" = "Vote down"; +"detail_view.favorited" = "Favorisiert"; +"detail_view.language" = "Sprache"; +"detail_view.ratings" = "%@ Bewertungen"; +"detail_view.page_count" = "Seitenzahl"; +"detail_view.file_size" = "Dateigröße"; +"detail_view.favorited_unit" = "mal"; +"detail_view.page_count_unit" = "Seiten"; +"detail_view.give_a_rating" = "Bewertung abgeben"; +"detail_view.similar_gallery" = "Ähnliche Galerien"; +"detail_view.previews" = "Vorschau"; +"detail_view.comments" = "Kommentar"; +"detail_view.delete_download" = "Download löschen?"; +"detail_view.repair_download" = "Download reparieren?"; +"detail_view.update_download" = "Download aktualisieren?"; +"detail_view.redownload_gallery" = "Galerie erneut herunterladen?"; +"detail_view.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; +"detail_view.repair_download_description" = "Die Offline-Dateien dieser Galerie jetzt reparieren?"; +"detail_view.update_download_description" = "Diese Galerie jetzt auf die neueste Online-Version aktualisieren?"; +"detail_view.redownload_gallery_description" = "Diese Galerie jetzt vollständig neu herunterladen?"; +"detail_view.repair" = "Reparieren"; +"detail_view.update" = "Aktualisieren"; +"detail_view.redownload" = "Erneut laden"; +"detail_view.saved_details" = "Online-Details konnten nicht aktualisiert werden. Stattdessen werden gespeicherte Details angezeigt."; // MARK: ArchivesView -"archives_view.title.archives" = "Archiv"; -"archives_view.button.download_to_hath_client" = "Mit H@H Client herunterladen"; +"archives_view.archives" = "Archiv"; +"archives_view.download_to_hath_client" = "Mit H@H Client herunterladen"; // HathArchive -"struct.hath_archive.price.free" = "Frei"; -"struct.hath_archive.price.not_available" = "./."; +"hath_archive.free" = "Frei"; // ArchiveResolution -"enum.archive_resolution.value.original" = "Original"; +"archive_resolution.original" = "Original"; // MARK: TorrentsView -"torrents_view.title.torrents" = "Torrents"; +"torrents_view.torrents" = "Torrents"; // MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "Gallery infos"; -"gallery_infos_view.title.id" = "ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "Title"; -"gallery_infos_view.title.japanese_title" = "Japanese title"; -"gallery_infos_view.title.gallery_URL" = "Gallery URL"; -"gallery_infos_view.title.cover_URL" = "Cover URL"; -"gallery_infos_view.title.archive_URL" = "Archive URL"; -"gallery_infos_view.title.torrent_URL" = "Torrent URL"; -"gallery_infos_view.title.parent_URL" = "Parent URL"; -"gallery_infos_view.title.category" = "Category"; -"gallery_infos_view.title.uploader" = "Uploader"; -"gallery_infos_view.title.posted_date" = "Posted date"; -"gallery_infos_view.title.visibility" = "Visibility"; -"gallery_infos_view.title.language" = "Language"; -"gallery_infos_view.title.page_count" = "Page count"; -"gallery_infos_view.title.file_size" = "File size"; -"gallery_infos_view.title.favorited_times" = "Favorited times"; -"gallery_infos_view.title.favorited" = "Favorited"; -"gallery_infos_view.title.rating_count" = "Rating count"; -"gallery_infos_view.title.average_rating" = "Average rating"; -"gallery_infos_view.title.my_rating" = "My rating"; -"gallery_infos_view.title.torrent_count" = "Torrent count"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "Yes"; -"gallery_infos_view.value.no" = "No"; +"gallery_infos_view.gallery_infos" = "Gallery infos"; +"gallery_infos_view.id" = "ID"; +"gallery_infos_view.token" = "Token"; +"gallery_infos_view.title" = "Title"; +"gallery_infos_view.japanese_title" = "Japanese title"; +"gallery_infos_view.gallery_URL" = "Gallery URL"; +"gallery_infos_view.cover_URL" = "Cover URL"; +"gallery_infos_view.archive_URL" = "Archive URL"; +"gallery_infos_view.torrent_URL" = "Torrent URL"; +"gallery_infos_view.parent_URL" = "Parent URL"; +"gallery_infos_view.category" = "Category"; +"gallery_infos_view.uploader" = "Uploader"; +"gallery_infos_view.posted_date" = "Posted date"; +"gallery_infos_view.visibility" = "Visibility"; +"gallery_infos_view.language" = "Language"; +"gallery_infos_view.page_count" = "Page count"; +"gallery_infos_view.file_size" = "File size"; +"gallery_infos_view.favorited_times" = "Favorited times"; +"gallery_infos_view.favorited" = "Favorited"; +"gallery_infos_view.rating_count" = "Rating count"; +"gallery_infos_view.average_rating" = "Average rating"; +"gallery_infos_view.my_rating" = "My rating"; +"gallery_infos_view.torrent_count" = "Torrent count"; +"gallery_infos_view.none" = "None"; +"gallery_infos_view.yes" = "Yes"; +"gallery_infos_view.no" = "No"; // GalleryVisibility -"enum.gallery_visibility.value.yes" = "Yes"; -"enum.gallery_visibility.value.no" = "No (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "Expunged"; +"gallery_visibility.yes" = "Yes"; +"gallery_visibility.no" = "No (%@)"; +"gallery_visibility.expunged" = "Expunged"; // MARK: TagDetailView -"tag_detail_view.section.title.images" = "Images"; -"tag_detail_view.section.title.links" = "Links"; +"tag_detail_view.images" = "Images"; +"tag_detail_view.links" = "Links"; // MARK: DownloadsView -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "Downloads"; -"downloads_view.search.prompt.downloads" = "Downloads durchsuchen"; -"downloads_view.dialog.title.delete_download" = "Download löschen?"; -"downloads_view.dialog.message.delete_active_download" = "Der aktuelle Download wird abgebrochen und von diesem Gerät entfernt."; -"downloads_view.dialog.message.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; -"downloads_view.swipe.button.pages" = "Seiten"; -"downloads_view.swipe.button.update" = "Aktualisieren"; -"downloads_view.swipe.button.resume" = "Fortsetzen"; -"downloads_view.swipe.button.pause" = "Pausieren"; -"downloads_view.empty_state.downloads" = "Heruntergeladene Galerien werden hier angezeigt."; -"downloads_view.empty_state.no_matching_filters" = "Keine Downloads entsprechen den aktuellen Filtern."; -"downloads_view.button.clear_filters" = "Filter löschen"; -"downloads_view.button.validate_image_data" = "Bilddaten validieren"; -"downloads_view.inspector.section.actions" = "Aktionen"; -"downloads_view.inspector.section.pages" = "Seiten"; -"downloads_view.inspector.button.retry_failed_pages" = "Fehlgeschlagene Seiten erneut versuchen"; -"downloads_view.inspector.button.validating_image_data" = "Bilddaten werden geprüft..."; -"downloads_view.inspector.button.update_download" = "Download aktualisieren"; -"downloads_view.inspector.toast.image_data_valid" = "Bilddaten sind gültig"; -"downloads_view.inspector.toast.image_data_unavailable" = "Bilddaten konnten nicht geprüft werden."; -"downloads_view.inspector.title.download_status" = "Downloadstatus"; -"downloads_view.inspector.page.pending" = "Ausstehend"; -"downloads_view.inspector.page.tap_to_retry" = "Tippen, um diese Seite erneut zu versuchen"; -"downloads_view.inspector.page.title" = "Seite %d"; -"downloads_view.inspector.page.none" = "Keine Seiten"; -"downloads_view.inspector.status.pending" = "Ausstehend"; -"downloads_view.inspector.status.downloaded" = "Heruntergeladen"; -"downloads_view.inspector.status.failed" = "Fehlgeschlagen"; +"download_folder_filter.all" = "All"; +"detail_view.manage_folders" = "Manage Folders"; +"detail_view.create_default_folder" = "Create Default Folder"; +"detail_view.no_folders" = "No folders yet"; +"downloads_view.manage_folders" = "Manage Folders"; +"downloads_view.move_to_folder" = "Move to Folder"; +"downloads_view.move" = "Move"; +"folder_manager_view.folders" = "Folders"; +"folder_manager_view.folder_name" = "Folder name"; +"folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_folders" = "Folders you create will appear here."; +"downloads_view.downloads" = "Downloads"; +"downloads_view.search_downloads" = "Downloads durchsuchen"; +"downloads_view.delete_download" = "Download löschen?"; +"downloads_view.delete_active_download" = "Der aktuelle Download wird abgebrochen und von diesem Gerät entfernt."; +"downloads_view.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; +"downloads_view.pages" = "Seiten"; +"downloads_view.update" = "Aktualisieren"; +"downloads_view.resume" = "Fortsetzen"; +"downloads_view.pause" = "Pausieren"; +"downloads_view.empty_downloads" = "Heruntergeladene Galerien werden hier angezeigt."; +"downloads_view.no_matching_filters" = "Keine Downloads entsprechen den aktuellen Filtern."; +"downloads_view.clear_filters" = "Filter löschen"; +"downloads_view.validate_image_data" = "Bilddaten validieren"; +"download_inspector_view.actions" = "Aktionen"; +"download_inspector_view.retry_failed_pages" = "Fehlgeschlagene Seiten erneut versuchen"; +"download_inspector_view.validating_image_data" = "Bilddaten werden geprüft..."; +"download_inspector_view.image_data_valid" = "Bilddaten sind gültig"; +"download_inspector_view.image_data_unavailable" = "Bilddaten konnten nicht geprüft werden."; +"download_inspector_view.download_status" = "Downloadstatus"; +"download_inspector_view.pending" = "Ausstehend"; +"download_inspector_view.none" = "Keine Seiten"; +"download_inspector_view.downloaded" = "Heruntergeladen"; +"download_inspector_view.failed" = "Fehlgeschlagen"; // MARK: DownloadSettingView "download_setting_view.title" = "Download"; -"download_setting_view.section.title.download_queue" = "Download-Warteschlange"; -"download_setting_view.section.title.network" = "Netzwerk"; -"download_setting_view.title.concurrent_image_downloads" = "Gleichzeitige Bilddownloads"; -"download_setting_view.title.retry_failed_pages_automatically" = "Fehlgeschlagene Seiten automatisch erneut versuchen"; -"download_setting_view.title.allow_cellular_downloads" = "Downloads über Mobilfunk erlauben"; -"download_setting_view.footer.network" = "Es wird immer nur eine Galerie gleichzeitig heruntergeladen. Mit dieser Einstellung steuerst du, wie viele Galerieseiten parallel geladen werden, ob Mobilfunk erlaubt ist und dass Dateien im Downloads-Ordner der App gespeichert werden."; +"download_setting_view.network" = "Netzwerk"; +"download_setting_view.concurrent_image_downloads" = "Gleichzeitige Bilddownloads"; +"download_setting_view.retry_failed_pages_automatically" = "Fehlgeschlagene Seiten automatisch erneut versuchen"; +"download_setting_view.allow_cellular_downloads" = "Downloads über Mobilfunk erlauben"; +"download_setting_view.network_description" = "Es wird immer nur eine Galerie gleichzeitig heruntergeladen. Mit dieser Einstellung steuerst du, wie viele Galerieseiten parallel geladen werden, ob Mobilfunk erlaubt ist und dass Dateien im Downloads-Ordner der App gespeichert werden."; // MARK: CommentsView -"comments_view.title.comments" = "Kommentar"; +"comments_view.comments" = "Kommentar"; // MARK: PostCommentView -"post_comment_view.title.post_comment" = "Kommentar abgeben"; -"post_comment_view.title.edit_comment" = "Kommentar bearbeiten"; +"post_comment_view.post_comment" = "Kommentar abgeben"; +"post_comment_view.edit_comment" = "Kommentar bearbeiten"; // MARK: PreviewsView -"previews_view.title.previews" = "Vorschau"; +"previews_view.previews" = "Vorschau"; // MARK: ReadingView -"reading_view.context_menu.button.reload" = "Reload"; -"reading_view.context_menu.button.copy" = "Copy"; -"reading_view.context_menu.button.save" = "Save"; -"reading_view.context_menu.button.save_original" = "Save original"; -"reading_view.context_menu.button.share" = "Teilen"; -"reading_view.toolbar_item.title.auto_play" = "Auto-Play"; -"reading_view.toolbar_item.title.dual_page_mode" = "Dual-Page mode"; -"reading_view.toolbar_item.title.except_the_cover" = "Except the cover"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "Retry failed images"; -"reading_view.toolbar_item.button.reload_all_images" = "Reload all images"; -"reading_view.toolbar_item.button.reading_setting" = "Reading setting"; +"reading_view.reload" = "Reload"; +"reading_view.copy" = "Copy"; +"reading_view.save" = "Save"; +"reading_view.save_original" = "Save original"; +"reading_view.share" = "Teilen"; +"reading_view.auto_play" = "Auto-Play"; +"reading_view.dual_page_mode" = "Dual-Page mode"; +"reading_view.except_the_cover" = "Except the cover"; +"reading_view.retry_all_failed_images" = "Retry failed images"; +"reading_view.reload_all_images" = "Reload all images"; +"reading_view.reading_setting" = "Reading setting"; // AutoPlayPolicy -"enum.auto_play_policy.value.off" = "Off"; +"auto_play_policy.off" = "Off"; // MARK: DownloadBadge -"struct.download_badge.text.queued" = "In Warteschlange"; -"struct.download_badge.text.downloading" = "Lädt herunter"; -"struct.download_badge.text.paused" = "Pausiert"; -"struct.download_badge.text.downloaded" = "Heruntergeladen"; -"struct.download_badge.text.needs_attention" = "Benötigt Aufmerksamkeit"; -"struct.download_badge.text.update_available" = "Update verfügbar"; -"struct.download_badge.text.needs_repair" = "Reparatur nötig"; -"struct.download_badge.progress" = "%d/%d"; +"download_badge.queued" = "In Warteschlange"; +"download_badge.downloading" = "Lädt herunter"; +"download_badge.paused" = "Pausiert"; +"download_badge.downloaded" = "Heruntergeladen"; +"download_badge.needs_attention" = "Benötigt Aufmerksamkeit"; +"download_badge.update_available" = "Update verfügbar"; +"download_badge.progress" = "%d/%d"; // MARK: DownloadStore -"download_store.error.asset_unreadable" = "Asset-Datei ist nicht lesbar: %@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "Download-Ordner konnte nicht aufgelöst werden."; -"download_store.validation.download_folder_missing" = "Download-Ordner fehlt."; -"download_store.validation.manifest_missing" = "Manifest-Datei fehlt."; -"download_store.validation.manifest_corrupted" = "Manifest-Datei ist beschädigt."; -"download_store.validation.downloaded_pages_incomplete" = "Heruntergeladene Seiten sind unvollständig."; -"download_store.validation.cover_image_missing" = "Coverbild fehlt."; -"download_store.validation.page_missing" = "Seite %d fehlt."; -"download_store.validation.cover_image_corrupted" = "Coverbilddaten sind beschädigt."; -"download_store.validation.page_image_corrupted" = "Bilddaten von Seite %d sind beschädigt."; +"download_store.asset_unreadable" = "Asset-Datei ist nicht lesbar: %@"; +"download_store.invalid_folder_name" = "The folder name is invalid."; +"download_store.folder_already_exists" = "A folder with this name already exists."; +"download_store.folder_busy_downloading" = "The folder contains an active download."; +"download_store.download_busy" = "The download is currently active."; +"download_store.download_folder_missing" = "Download-Ordner fehlt."; +"download_store.manifest_missing" = "Manifest-Datei fehlt."; +"download_store.manifest_corrupted" = "Manifest-Datei ist beschädigt."; +"download_store.page_missing" = "Seite %d fehlt."; +"download_store.page_image_corrupted" = "Bilddaten von Seite %d sind beschädigt."; // MARK: FiltersView -"filters_view.title.filters" = "Filters"; -"filters_view.title.advanced_settings" = "Erweiterte Einstellungen"; -"filters_view.title.search_gallery_name" = "Galerienamen durchsuchen"; -"filters_view.title.search_gallery_tags" = "Galerietags durchsuchen"; -"filters_view.title.search_gallery_description" = "Galeriebeschreibung durchsuchen"; -"filters_view.title.search_torrent_filenames" = "Torrents durchsuchen"; -"filters_view.title.only_show_galleries_with_torrents" = "Nur Galerien mit Torrents zeigen"; -"filters_view.title.search_low_power_tags" = "Low-Power Tags miteinbeziehen"; -"filters_view.title.search_downvoted_tags" = "Negativ bewertete Tags miteinbeziehen"; -"filters_view.title.search_expunged_galleries" = "Gelöschte Galerien zeigen"; -"filters_view.title.set_minimum_rating" = "Minimaleste Bewertung festlegen"; -"filters_view.title.minimum_rating" = "Minimale Bewertung"; -"filters_view.title.set_pages_range" = "Seitenzahl-Bereich festlegen"; -"filters_view.title.pages_range" = "Seitenzahl-Bereich"; -"filters_view.title.disable_language_filter" = "Gefilterte Sprachen miteinbeziehen"; -"filters_view.title.disable_uploader_filter" = "Gefilterte Uploader miteinbeziehen"; -"filters_view.title.disable_tags_filter" = "Gefilterte Tags miteinbeziehen"; -"filters_view.button.reset_filters" = "Filter zurücksetzen"; -"filters_view.section.title.advanced" = "Erweitert"; -"filters_view.section.title.default_filter" = "Standardfilter"; +"filters_view.filters" = "Filters"; +"filters_view.advanced_settings" = "Erweiterte Einstellungen"; +"filters_view.search_gallery_name" = "Galerienamen durchsuchen"; +"filters_view.search_gallery_tags" = "Galerietags durchsuchen"; +"filters_view.search_gallery_description" = "Galeriebeschreibung durchsuchen"; +"filters_view.search_torrent_filenames" = "Torrents durchsuchen"; +"filters_view.only_show_galleries_with_torrents" = "Nur Galerien mit Torrents zeigen"; +"filters_view.search_low_power_tags" = "Low-Power Tags miteinbeziehen"; +"filters_view.search_downvoted_tags" = "Negativ bewertete Tags miteinbeziehen"; +"filters_view.search_expunged_galleries" = "Gelöschte Galerien zeigen"; +"filters_view.set_minimum_rating" = "Minimaleste Bewertung festlegen"; +"filters_view.minimum_rating" = "Minimale Bewertung"; +"filters_view.set_pages_range" = "Seitenzahl-Bereich festlegen"; +"filters_view.pages_range" = "Seitenzahl-Bereich"; +"filters_view.disable_language_filter" = "Gefilterte Sprachen miteinbeziehen"; +"filters_view.disable_uploader_filter" = "Gefilterte Uploader miteinbeziehen"; +"filters_view.disable_tags_filter" = "Gefilterte Tags miteinbeziehen"; +"filters_view.reset_filters" = "Filter zurücksetzen"; +"filters_view.advanced" = "Erweitert"; +"filters_view.default_filter" = "Standardfilter"; // FilterRange -"enum.filter_range.value.search" = "Suche"; -"enum.filter_range.value.global" = "Global"; -"enum.filter_range.value.watched" = "Meine Tags"; +"filter_range.search" = "Suche"; +"filter_range.global" = "Global"; +"filter_range.watched" = "Meine Tags"; // MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ settings"; -"eh_setting_view.section.title.profile_settings" = "Profile Settings"; -"eh_setting_view.title.selected_profile" = "Selected profile"; -"eh_setting_view.button.set_as_default" = "Set as default"; -"eh_setting_view.button.delete_profile" = "Delete profile"; -"eh_setting_view.button.rename" = "Rename"; -"eh_setting_view.button.create_new" = "Create new"; -"eh_setting_view.toolbar_item.button.done" = "Done"; - -"eh_setting_view.section.title.image_load_settings" = "Image Load Settings"; -"eh_setting_view.title.load_images_through_the_hath_network" = "Load images through the Hath network"; -"eh_setting_view.title.browsing_country" = "Browsing country"; -"eh_setting_view.description.browsing_country" = "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below."; +"eh_setting_view.host_settings" = "%@ settings"; +"eh_setting_view.profile_settings" = "Profile Settings"; +"eh_setting_view.selected_profile" = "Selected profile"; +"eh_setting_view.set_as_default" = "Set as default"; +"eh_setting_view.delete_profile" = "Delete profile"; +"eh_setting_view.rename" = "Rename"; +"eh_setting_view.create_new" = "Create new"; +"eh_setting_view.done" = "Done"; + +"eh_setting_view.image_load_settings" = "Image Load Settings"; +"eh_setting_view.load_images_through_the_hath_network" = "Load images through the Hath network"; +"eh_setting_view.browsing_country" = "Browsing country"; +"eh_setting_view.browsing_country_description" = "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below."; // EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "Any client"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "Default port clients only"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "No [Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "No [Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "Recommended."; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports."; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems."; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; - -"eh_setting_view.section.title.image_size_settings" = "Image Size Settings"; -"eh_setting_view.title.image_resolution" = "Image resolution"; -"eh_setting_view.description.image_resolution" = "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000."; -"eh_setting_view.title.image_size" = "Image size"; -"eh_setting_view.description.image_size" = "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)"; -"eh_setting_view.title.horizontal" = "Horizontal"; -"eh_setting_view.title.vertical" = "Vertical"; +"load_through_hath_setting.any_client" = "Any client"; +"load_through_hath_setting.default_port_only" = "Default port clients only"; +"load_through_hath_setting.modern_no" = "No [Modern/HTTPS]"; +"load_through_hath_setting.legacy_no" = "No [Legacy/HTTP]"; +"load_through_hath_setting.any_client_description" = "Recommended."; +"load_through_hath_setting.default_port_only_description" = "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports."; +"load_through_hath_setting.modern_no_description" = "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems."; +"load_through_hath_setting.legacy_no_description" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; + +"eh_setting_view.image_size_settings" = "Image Size Settings"; +"eh_setting_view.image_resolution" = "Image resolution"; +"eh_setting_view.image_resolution_description" = "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000."; +"eh_setting_view.image_size" = "Image size"; +"eh_setting_view.image_size_description" = "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)"; +"eh_setting_view.horizontal" = "Horizontal"; +"eh_setting_view.vertical" = "Vertical"; // EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "Auto"; +"image_resolution.auto" = "Auto"; -"eh_setting_view.section.title.gallery_name_display" = "Gallery Name Display"; -"eh_setting_view.title.gallery_name" = "Gallery name"; -"eh_setting_view.description.gallery_name" = "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?"; +"eh_setting_view.gallery_name_display" = "Gallery Name Display"; +"eh_setting_view.gallery_name" = "Gallery name"; +"eh_setting_view.gallery_name_description" = "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?"; // EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "Default Title"; -"enum.eh_setting.gallery_name.value.japanese" = "Japanese Title (if available)"; +"gallery_name.default" = "Default Title"; +"gallery_name.japanese" = "Japanese Title (if available)"; -"eh_setting_view.section.title.archiver_settings" = "Archiver Settings"; -"eh_setting_view.title.archiver_behavior" = "Archiver behavior"; -"eh_setting_view.description.archiver_behavior" = "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here."; +"eh_setting_view.archiver_settings" = "Archiver Settings"; +"eh_setting_view.archiver_behavior" = "Archiver behavior"; +"eh_setting_view.archiver_behavior_description" = "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here."; // EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "Manual Select, Manual Start (Default)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "Manual Select, Auto Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "Auto Select Original, Manual Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "Auto Select Original, Auto Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "Auto Select Resample, Manual Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "Auto Select Resample, Auto Start"; - -"eh_setting_view.section.title.front_page_settings" = "Front Page Settings"; -"eh_setting_view.title.display_mode" = "Display mode"; -"eh_setting_view.description.display_mode" = "Which display mode would you like to use on the front and search pages?"; -"eh_setting_view.section.title.show_search_range_indicator" = "Search Range Indicator"; -"eh_setting_view.title.show_search_range_indicator" = "Show search range indicator"; -"eh_setting_view.description.gallery_category" = "What categories would you like to show by default on the front page and in searches?"; +"eh_setting.archiver_behavior.manual_select_manual_start" = "Manual Select, Manual Start (Default)"; +"eh_setting.archiver_behavior.manual_select_auto_start" = "Manual Select, Auto Start"; +"eh_setting.archiver_behavior.auto_select_original_manual_start" = "Auto Select Original, Manual Start"; +"eh_setting.archiver_behavior.auto_select_original_auto_start" = "Auto Select Original, Auto Start"; +"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "Auto Select Resample, Manual Start"; +"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "Auto Select Resample, Auto Start"; + +"eh_setting_view.front_page_settings" = "Front Page Settings"; +"eh_setting_view.display_mode" = "Display mode"; +"eh_setting_view.display_mode_description" = "Which display mode would you like to use on the front and search pages?"; +"eh_setting_view.show_search_range_indicator" = "Search Range Indicator"; +"eh_setting_view.show_search_range_indicator_description" = "Show search range indicator"; +"eh_setting_view.gallery_category" = "What categories would you like to show by default on the front page and in searches?"; // EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "Compact"; -"enum.eh_setting.display_mode.value.thumbnail" = "Thumbnail"; -"enum.eh_setting.display_mode.value.extended" = "Extended"; -"enum.eh_setting.display_mode.value.minimal" = "Minimal"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "Optional UI Elements"; -"eh_setting_view.description.optional_UI_elements" = "Some historic UI elements are now disabled by default. You can enable those here."; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; - -"eh_setting_view.section.title.favorites" = "Favorites"; -"eh_setting_view.description.favorite_categories" = "Here you can choose and rename your favorite categories."; -"eh_setting_view.title.favorites_sort_order" = "Favorites sort order"; -"eh_setting_view.description.favorites_sort_order" = "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting."; +"display_mode.compact" = "Compact"; +"display_mode.thumbnail" = "Thumbnail"; +"display_mode.extended" = "Extended"; +"display_mode.minimal" = "Minimal"; +"display_mode.minimalPlus" = "Minimal+"; + +"eh_setting_view.optional_UI_elements" = "Optional UI Elements"; +"eh_setting_view.optional_UI_elements_description" = "Some historic UI elements are now disabled by default. You can enable those here."; +"eh_setting_view.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; + +"eh_setting_view.favorites" = "Favorites"; +"eh_setting_view.favorite_categories" = "Here you can choose and rename your favorite categories."; +"eh_setting_view.favorites_sort_order" = "Favorites sort order"; +"eh_setting_view.favorites_sort_order_description" = "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting."; // EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "By last gallery update time"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "By favorited time"; +"favorites_sort_order.last_update_time" = "By last gallery update time"; +"favorites_sort_order.favorited_time" = "By favorited time"; -"eh_setting_view.section.title.ratings" = "Ratings"; -"eh_setting_view.title.ratings_color" = "Ratings color"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; +"eh_setting_view.ratings" = "Ratings"; +"eh_setting_view.ratings_color" = "Ratings color"; +"eh_setting_view.ratings_color_prompt" = "RRGGB"; +"eh_setting_view.ratings_color_description" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; -"eh_setting_view.section.title.tag_filtering_threshold" = "Tag Filtering Threshold"; -"eh_setting_view.title.tag_filtering_threshold" = "Tag Filtering Threshold"; -"eh_setting_view.description.tag_filtering_threshold" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; +"eh_setting_view.tag_filtering_threshold" = "Tag Filtering Threshold"; +"eh_setting_view.tag_filtering_threshold_description" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; -"eh_setting_view.section.title.tag_watching_threshold" = "Tag Watching Threshold"; -"eh_setting_view.title.tag_watching_threshold" = "Tag Watching Threshold"; -"eh_setting_view.description.tag_watching_threshold" = "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999."; +"eh_setting_view.tag_watching_threshold" = "Tag Watching Threshold"; +"eh_setting_view.tag_watching_threshold_description" = "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999."; -"eh_setting_view.section.title.filtered_removal_count" = "Show Filtered Removal Count"; -"eh_setting_view.description.filtered_removal_count" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; -"eh_setting_view.title.show_filtered_removal_count" = "Show filtered removal count"; +"eh_setting_viewfiltered_removal_count" = "Show Filtered Removal Count"; +"eh_setting_view.filtered_removal_count_description" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; +"eh_setting_view.show_filtered_removal_count" = "Show filtered removal count"; -"eh_setting_view.section.title.excluded_languages" = "Excluded Languages"; -"eh_setting_view.description.excluded_languages" = "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query."; +"eh_setting_view.excluded_languages" = "Excluded Languages"; +"eh_setting_view.excluded_languages_description" = "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query."; // EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "Original"; -"enum.eh_setting.excluded_languages_category.value.translated" = "Translated"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "Rewrite"; - -"eh_setting_view.section.title.excluded_uploaders" = "Excluded Uploaders"; -"eh_setting_view.description.excluded_uploaders" = "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query."; -"eh_setting_view.description.excluded_uploaders_count" = "You are currently using **%@ / %@** exclusion slots."; - -"eh_setting_view.section.title.search_result_count" = "Search Result Count"; -"eh_setting_view.title.result_count" = "Result count"; -"eh_setting_view.description.result_count" = "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)"; - -"eh_setting_view.section.title.thumbnail_settings" = "Thumbnail Settings"; -"eh_setting_view.title.thumbnail_load_timing" = "Thumbnail load timing"; -"eh_setting_view.description.thumbnail_load_timing" = "How would you like the mouse-over thumbnails on the front page to load when using List Mode?"; -"eh_setting_view.description.thumbnail_configuration" = "You can set a default thumbnail configuration for all galleries you visit."; -"eh_setting_view.title.thumbnail_size" = "Size"; -"eh_setting_view.title.thumbnail_row_count" = "Rows"; +"excluded_languages_category.original" = "Original"; +"excluded_languages_category.translated" = "Translated"; +"excluded_languages_category.rewrite" = "Rewrite"; + +"eh_setting_view.excluded_uploaders" = "Excluded Uploaders"; +"eh_setting_view.excluded_uploaders_description" = "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query."; +"eh_setting_view.excluded_uploaders_count" = "You are currently using **%@ / %@** exclusion slots."; + +"eh_setting_view.search_result_count" = "Search Result Count"; +"eh_setting_view.result_count" = "Result count"; +"eh_setting_view.result_count_description" = "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)"; + +"eh_setting_view.thumbnail_settings" = "Thumbnail Settings"; +"eh_setting_view.thumbnail_load_timing" = "Thumbnail load timing"; +"eh_setting_view.thumbnail_load_timing_description" = "How would you like the mouse-over thumbnails on the front page to load when using List Mode?"; +"eh_setting_view.thumbnail_configuration" = "You can set a default thumbnail configuration for all galleries you visit."; +"eh_setting_view.thumbnail_size" = "Size"; +"eh_setting_view.thumbnail_row_count" = "Rows"; // EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "On mouse-over"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "On page load"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "Pages load faster, but there may be a slight delay before a thumb appears."; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded."; +"thumbnail_load_timing.on_mouse_over" = "On mouse-over"; +"thumbnail_load_timing.on_page_load" = "On page load"; +"thumbnail_load_timing.on_mouse_over_description" = "Pages load faster, but there may be a slight delay before a thumb appears."; +"thumbnail_load_timing.on_page_load_description" = "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded."; // EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "Normal"; -"enum.eh_setting.thumbnail_size.value.large" = "Large"; -"enum.eh_setting.thumbnail_size.value.small" = "Small"; -"enum.eh_setting.thumbnail_size.value.auto" = "Auto"; - -"eh_setting_view.section.title.cover_scaling" = "Cover Scaling"; -"eh_setting_view.title.scale_factor" = "Scale factor"; -"eh_setting_view.description.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; - -"eh_setting_view.section.title.viewport_override" = "Viewport Override"; -"eh_setting_view.title.virtual_width" = "Virtual width"; -"eh_setting_view.description.virtual_width" = "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400."; - -"eh_setting_view.section.title.gallery_comments" = "Gallery Comments"; -"eh_setting_view.title.comments_sort_order" = "Comments sort order"; -"eh_setting_view.title.comments_votes_show_timing" = "Comment votes show timing"; +"thumbnail_size.normal" = "Normal"; +"thumbnail_size.large" = "Large"; +"thumbnail_size.small" = "Small"; +"thumbnail_size.auto" = "Auto"; + +"eh_setting_view.cover_scaling" = "Cover Scaling"; +"eh_setting_view.scale_factor" = "Scale factor"; +"eh_setting_view.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; + +"eh_setting_view.viewport_override" = "Viewport Override"; +"eh_setting_view.virtual_width" = "Virtual width"; +"eh_setting_view.virtual_width_description" = "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400."; + +"eh_setting_view.gallery_comments" = "Gallery Comments"; +"eh_setting_view.comments_sort_order" = "Comments sort order"; +"eh_setting_view.comments_votes_show_timing" = "Comment votes show timing"; // EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "Oldest comments first"; -"enum.eh_setting.comments_sort_order.value.recent" = "Recent comments first"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "By highest score"; +"comments_sort_order.oldest" = "Oldest comments first"; +"comments_sort_order.recent" = "Recent comments first"; +"comments_sort_order.highest_score" = "By highest score"; // EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "On score hover or click"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "Always"; +"comments_votes_show_timing.on_hover_or_click" = "On score hover or click"; +"comments_votes_show_timing.always" = "Always"; -"eh_setting_view.section.title.gallery_tags" = "Gallery Tags"; -"eh_setting_view.title.tags_sort_order" = "Tags sort order"; +"eh_setting_view.gallery_tags" = "Gallery Tags"; +"eh_setting_view.tags_sort_order" = "Tags sort order"; // EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "Alphabetical"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "By tag power"; +"tags_sort_order.alphabetical" = "Alphabetical"; +"tags_sort_order.tag_power" = "By tag power"; -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; +"eh_setting_view.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; +"eh_setting_view.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; -"eh_setting_view.section.title.hath_local_network_host" = "Hath Local Network Host"; -"eh_setting_view.title.ip_address_port" = "IP address:Port"; -"eh_setting_view.description.ip_address_port" = "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work."; -"eh_setting_view.section.title.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; -"eh_setting_view.title.use_original_images" = "Use original images"; +"eh_setting_view.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; +"eh_setting_view.use_original_images" = "Use original images"; -"eh_setting_view.section.title.multi_page_viewer" = "Multi-Page Viewer"; -"eh_setting_view.title.use_multi_page_viewer" = "Use Multi-Page Viewer"; -"eh_setting_view.title.display_style" = "Display style"; -"eh_setting_view.title.show_thumbnail_pane" = "Show thumbnail pane"; +"eh_setting_view.multi_page_viewer" = "Multi-Page Viewer"; +"eh_setting_view.use_multi_page_viewer" = "Use Multi-Page Viewer"; +"eh_setting_view.display_style" = "Display style"; +"eh_setting_view.show_thumbnail_pane" = "Show thumbnail pane"; // EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "Align left, scale if overwidth"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "Align center, scale if overwidth"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "Align center, always scale"; +"multiple_page_viewer_style.align_left_scale_if_over_width" = "Align left, scale if overwidth"; +"multiple_page_viewer_style.align_center_scale_if_over_width" = "Align center, scale if overwidth"; +"multiple_page_viewer_style.align_center_always_scale" = "Align center, always scale"; // EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "None"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "Page Number Only"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "Page Number + Name"; +"gallery_page_numbering.none" = "None"; +"gallery_page_numbering.page_number_only" = "Page Number Only"; +"gallery_page_numbering.page_number_and_name" = "Page Number + Name"; // MARK: Category -"enum.category.value.doujinshi" = "Doujinshi"; -"enum.category.value.manga" = "Manga"; -"enum.category.value.artist_CG" = "Artist CG"; -"enum.category.value.game_CG" = "Game CG"; -"enum.category.value.western" = "Western"; -"enum.category.value.non_h" = "Non-H"; -"enum.category.value.image_set" = "Image Set"; -"enum.category.value.cosplay" = "Cosplay"; -"enum.category.value.asian_porn" = "Asian Porn"; -"enum.category.value.misc" = "Misc"; -"enum.category.value.private" = "Private"; +"category.doujinshi" = "Doujinshi"; +"category.manga" = "Manga"; +"category.artist_CG" = "Artist CG"; +"category.game_CG" = "Game CG"; +"category.western" = "Western"; +"category.non_h" = "Non-H"; +"category.image_set" = "Image Set"; +"category.cosplay" = "Cosplay"; +"category.asian_porn" = "Asian Porn"; +"category.misc" = "Misc"; +"category.private" = "Private"; // MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "Reclass"; -"enum.tag_namespace.value.language" = "Sprache"; -"enum.tag_namespace.value.parody" = "Parodie"; -"enum.tag_namespace.value.character" = "Charakter"; -"enum.tag_namespace.value.group" = "Gruppe"; -"enum.tag_namespace.value.artist" = "Künstler"; -"enum.tag_namespace.value.male" = "Männlich"; -"enum.tag_namespace.value.female" = "Weiblich"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "Other"; -"enum.tag_namespace.value.temp" = "Temp"; +"tag_namespace.reclass" = "Reclass"; +"tag_namespace.language" = "Sprache"; +"tag_namespace.parody" = "Parodie"; +"tag_namespace.character" = "Charakter"; +"tag_namespace.group" = "Gruppe"; +"tag_namespace.artist" = "Künstler"; +"tag_namespace.male" = "Männlich"; +"tag_namespace.female" = "Weiblich"; +"tag_namespace.mixed" = "Mixed"; +"tag_namespace.cosplayer" = "Cosplayer"; +"tag_namespace.other" = "Other"; +"tag_namespace.temp" = "Temp"; // MARK: Language -"enum.language.value.invalid" = "./."; -"enum.language.value.other" = "Other"; -"enum.language.value.afrikaans" = "Afrikaan"; -"enum.language.value.albanian" = "Albanisch"; -"enum.language.value.arabic" = "Arabisch"; -"enum.language.value.bengali" = "Bengali"; -"enum.language.value.bosnian" = "Bosnisch"; -"enum.language.value.bulgarian" = "Bulgarisch"; -"enum.language.value.burmese" = "Birmanisch"; -"enum.language.value.catalan" = "Katalanisch"; -"enum.language.value.cebuano" = "Cebuano"; -"enum.language.value.chinese" = "Chinesisch"; -"enum.language.value.croatian" = "Kroatisch"; -"enum.language.value.czech" = "Tschechisch"; -"enum.language.value.danish" = "Dänisch"; -"enum.language.value.dutch" = "Niederländisch"; -"enum.language.value.english" = "Englisch"; -"enum.language.value.esperanto" = "Esperanto"; -"enum.language.value.estonian" = "Estländisch"; -"enum.language.value.finnish" = "Finnisch"; -"enum.language.value.french" = "Französisch"; -"enum.language.value.georgian" = "Georgisch"; -"enum.language.value.german" = "Deutsch"; -"enum.language.value.greek" = "Griechisch"; -"enum.language.value.hebrew" = "Hebräisch"; -"enum.language.value.hindi" = "Hindi"; -"enum.language.value.hmong" = "Hmong"; -"enum.language.value.hungarian" = "Ungarisch"; -"enum.language.value.indonesian" = "Indonesisch"; -"enum.language.value.italian" = "Italian"; -"enum.language.value.japanese" = "Japanisch"; -"enum.language.value.kazakh" = "Kazakhstanisch"; -"enum.language.value.khmer" = "Khmer"; -"enum.language.value.korean" = "Koreanisch"; -"enum.language.value.kurdish" = "Kurdisch"; -"enum.language.value.lao" = "Lao"; -"enum.language.value.latin" = "Latein"; -"enum.language.value.mongolian" = "Mongolisch"; -"enum.language.value.ndebele" = "Ndebele"; -"enum.language.value.nepali" = "Nepali"; -"enum.language.value.norwegian" = "Norwegisch"; -"enum.language.value.oromo" = "Oromo"; -"enum.language.value.pashto" = "Pashto"; -"enum.language.value.persian" = "Persisch"; -"enum.language.value.polish" = "Polnisch"; -"enum.language.value.portuguese" = "Portugiesisch"; -"enum.language.value.punjabi" = "Punjabi"; -"enum.language.value.romanian" = "Rumänisch"; -"enum.language.value.russian" = "Russisch"; -"enum.language.value.sango" = "Sango"; -"enum.language.value.serbian" = "Serbisch"; -"enum.language.value.shona" = "Shona"; -"enum.language.value.slovak" = "Slovakisch"; -"enum.language.value.slovenian" = "Slovenisch"; -"enum.language.value.somali" = "Somali"; -"enum.language.value.spanish" = "Spanisch"; -"enum.language.value.swahili" = "Swahili"; -"enum.language.value.swedish" = "Schwedisch"; -"enum.language.value.tagalog" = "Tagalog"; -"enum.language.value.thai" = "Thai"; -"enum.language.value.tigrinya" = "Tigrinya"; -"enum.language.value.turkish" = "Türkisch"; -"enum.language.value.ukrainian" = "Ukrainisch"; -"enum.language.value.urdu" = "Urdu"; -"enum.language.value.vietnamese" = "Vietnamesisch"; -"enum.language.value.zulu" = "Zulu"; +"language.invalid" = "./."; +"language.other" = "Other"; +"language.afrikaans" = "Afrikaan"; +"language.albanian" = "Albanisch"; +"language.arabic" = "Arabisch"; +"language.bengali" = "Bengali"; +"language.bosnian" = "Bosnisch"; +"language.bulgarian" = "Bulgarisch"; +"language.burmese" = "Birmanisch"; +"language.catalan" = "Katalanisch"; +"language.cebuano" = "Cebuano"; +"language.chinese" = "Chinesisch"; +"language.croatian" = "Kroatisch"; +"language.czech" = "Tschechisch"; +"language.danish" = "Dänisch"; +"language.dutch" = "Niederländisch"; +"language.english" = "Englisch"; +"language.esperanto" = "Esperanto"; +"language.estonian" = "Estländisch"; +"language.finnish" = "Finnisch"; +"language.french" = "Französisch"; +"language.georgian" = "Georgisch"; +"language.german" = "Deutsch"; +"language.greek" = "Griechisch"; +"language.hebrew" = "Hebräisch"; +"language.hindi" = "Hindi"; +"language.hmong" = "Hmong"; +"language.hungarian" = "Ungarisch"; +"language.indonesian" = "Indonesisch"; +"language.italian" = "Italian"; +"language.japanese" = "Japanisch"; +"language.kazakh" = "Kazakhstanisch"; +"language.khmer" = "Khmer"; +"language.korean" = "Koreanisch"; +"language.kurdish" = "Kurdisch"; +"language.lao" = "Lao"; +"language.latin" = "Latein"; +"language.mongolian" = "Mongolisch"; +"language.ndebele" = "Ndebele"; +"language.nepali" = "Nepali"; +"language.norwegian" = "Norwegisch"; +"language.oromo" = "Oromo"; +"language.pashto" = "Pashto"; +"language.persian" = "Persisch"; +"language.polish" = "Polnisch"; +"language.portuguese" = "Portugiesisch"; +"language.punjabi" = "Punjabi"; +"language.romanian" = "Rumänisch"; +"language.russian" = "Russisch"; +"language.sango" = "Sango"; +"language.serbian" = "Serbisch"; +"language.shona" = "Shona"; +"language.slovak" = "Slovakisch"; +"language.slovenian" = "Slovenisch"; +"language.somali" = "Somali"; +"language.spanish" = "Spanisch"; +"language.swahili" = "Swahili"; +"language.swedish" = "Schwedisch"; +"language.tagalog" = "Tagalog"; +"language.thai" = "Thai"; +"language.tigrinya" = "Tigrinya"; +"language.turkish" = "Türkisch"; +"language.ukrainian" = "Ukrainisch"; +"language.urdu" = "Urdu"; +"language.vietnamese" = "Vietnamesisch"; +"language.zulu" = "Zulu"; // MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "Auto-Detect"; -"enum.browsing_country.name.afghanistan" = "Afghanistan"; -"enum.browsing_country.name.aland_islands" = "Aland Islands"; -"enum.browsing_country.name.albania" = "Albania"; -"enum.browsing_country.name.algeria" = "Algeria"; -"enum.browsing_country.name.american_samoa" = "American Samoa"; -"enum.browsing_country.name.andorra" = "Andorra"; -"enum.browsing_country.name.angola" = "Angola"; -"enum.browsing_country.name.anguilla" = "Anguilla"; -"enum.browsing_country.name.antarctica" = "Antarctica"; -"enum.browsing_country.name.antigua_and_barbuda" = "Antigua and Barbuda"; -"enum.browsing_country.name.argentina" = "Argentina"; -"enum.browsing_country.name.armenia" = "Armenia"; -"enum.browsing_country.name.aruba" = "Aruba"; -"enum.browsing_country.name.asia_pacific_region" = "Asia-Pacific Region"; -"enum.browsing_country.name.australia" = "Australia"; -"enum.browsing_country.name.austria" = "Austria"; -"enum.browsing_country.name.azerbaijan" = "Azerbaijan"; -"enum.browsing_country.name.bahamas" = "Bahamas"; -"enum.browsing_country.name.bahrain" = "Bahrain"; -"enum.browsing_country.name.bangladesh" = "Bangladesh"; -"enum.browsing_country.name.barbados" = "Barbados"; -"enum.browsing_country.name.belarus" = "Belarus"; -"enum.browsing_country.name.belgium" = "Belgium"; -"enum.browsing_country.name.belize" = "Belize"; -"enum.browsing_country.name.benin" = "Benin"; -"enum.browsing_country.name.bermuda" = "Bermuda"; -"enum.browsing_country.name.bhutan" = "Bhutan"; -"enum.browsing_country.name.bolivia" = "Bolivia"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "Bonaire Saint Eustatius and Saba"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "Bosnia and Herzegovina"; -"enum.browsing_country.name.botswana" = "Botswana"; -"enum.browsing_country.name.bouvet_island" = "Bouvet Island"; -"enum.browsing_country.name.brazil" = "Brazil"; -"enum.browsing_country.name.british_indian_ocean_territory" = "British Indian Ocean Territory"; -"enum.browsing_country.name.brunei_darussalam" = "Brunei Darussalam"; -"enum.browsing_country.name.bulgaria" = "Bulgaria"; -"enum.browsing_country.name.burkina_faso" = "Burkina Faso"; -"enum.browsing_country.name.burundi" = "Burundi"; -"enum.browsing_country.name.cambodia" = "Cambodia"; -"enum.browsing_country.name.cameroon" = "Cameroon"; -"enum.browsing_country.name.canada" = "Canada"; -"enum.browsing_country.name.cape_verde" = "Cape Verde"; -"enum.browsing_country.name.cayman_islands" = "Cayman Islands"; -"enum.browsing_country.name.central_african_republic" = "Central African Republic"; -"enum.browsing_country.name.chad" = "Chad"; -"enum.browsing_country.name.chile" = "Chile"; -"enum.browsing_country.name.china" = "China"; -"enum.browsing_country.name.christmas_island" = "Christmas Island"; -"enum.browsing_country.name.cocos_islands" = "Cocos Islands"; -"enum.browsing_country.name.colombia" = "Colombia"; -"enum.browsing_country.name.comoros" = "Comoros"; -"enum.browsing_country.name.congo" = "Congo"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "The Democratic Republic of the Congo"; -"enum.browsing_country.name.cook_islands" = "Cook Islands"; -"enum.browsing_country.name.costa_rica" = "Costa Rica"; -"enum.browsing_country.name.cote_d_ivoire" = "Cote D'Ivoire"; -"enum.browsing_country.name.croatia" = "Croatia"; -"enum.browsing_country.name.cuba" = "Cuba"; -"enum.browsing_country.name.curacao" = "Curacao"; -"enum.browsing_country.name.cyprus" = "Cyprus"; -"enum.browsing_country.name.czech_republic" = "Czech Republic"; -"enum.browsing_country.name.denmark" = "Denmark"; -"enum.browsing_country.name.djibouti" = "Djibouti"; -"enum.browsing_country.name.dominica" = "Dominica"; -"enum.browsing_country.name.dominican_republic" = "Dominican Republic"; -"enum.browsing_country.name.ecuador" = "Ecuador"; -"enum.browsing_country.name.egypt" = "Egypt"; -"enum.browsing_country.name.el_salvador" = "El Salvador"; -"enum.browsing_country.name.equatorial_guinea" = "Equatorial Guinea"; -"enum.browsing_country.name.eritrea" = "Eritrea"; -"enum.browsing_country.name.estonia" = "Estonia"; -"enum.browsing_country.name.ethiopia" = "Ethiopia"; -"enum.browsing_country.name.europe" = "Europe"; -"enum.browsing_country.name.falkland_islands" = "Falkland Islands"; -"enum.browsing_country.name.faroe_islands" = "Faroe Islands"; -"enum.browsing_country.name.fiji" = "Fiji"; -"enum.browsing_country.name.finland" = "Finland"; -"enum.browsing_country.name.france" = "France"; -"enum.browsing_country.name.french_guiana" = "French Guiana"; -"enum.browsing_country.name.french_polynesia" = "French Polynesia"; -"enum.browsing_country.name.french_southern_territories" = "French Southern Territories"; -"enum.browsing_country.name.gabon" = "Gabon"; -"enum.browsing_country.name.gambia" = "Gambia"; -"enum.browsing_country.name.georgia" = "Georgia"; -"enum.browsing_country.name.germany" = "Germany"; -"enum.browsing_country.name.ghana" = "Ghana"; -"enum.browsing_country.name.gibraltar" = "Gibraltar"; -"enum.browsing_country.name.greece" = "Greece"; -"enum.browsing_country.name.greenland" = "Greenland"; -"enum.browsing_country.name.grenada" = "Grenada"; -"enum.browsing_country.name.guadeloupe" = "Guadeloupe"; -"enum.browsing_country.name.guam" = "Guam"; -"enum.browsing_country.name.guatemala" = "Guatemala"; -"enum.browsing_country.name.guernsey" = "Guernsey"; -"enum.browsing_country.name.guinea" = "Guinea"; -"enum.browsing_country.name.guinea_bissau" = "Guinea-Bissau"; -"enum.browsing_country.name.guyana" = "Guyana"; -"enum.browsing_country.name.haiti" = "Haiti"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "Heard Island and McDonald Islands"; -"enum.browsing_country.name.vatican_city_state" = "Vatican City State"; -"enum.browsing_country.name.honduras" = "Honduras"; -"enum.browsing_country.name.hong_kong" = "Hong Kong"; -"enum.browsing_country.name.hungary" = "Hungary"; -"enum.browsing_country.name.iceland" = "Iceland"; -"enum.browsing_country.name.india" = "India"; -"enum.browsing_country.name.indonesia" = "Indonesia"; -"enum.browsing_country.name.iran" = "Iran"; -"enum.browsing_country.name.iraq" = "Iraq"; -"enum.browsing_country.name.ireland" = "Ireland"; -"enum.browsing_country.name.isle_of_man" = "Isle of Man"; -"enum.browsing_country.name.israel" = "Israel"; -"enum.browsing_country.name.italy" = "Italy"; -"enum.browsing_country.name.jamaica" = "Jamaica"; -"enum.browsing_country.name.japan" = "Japan"; -"enum.browsing_country.name.jersey" = "Jersey"; -"enum.browsing_country.name.jordan" = "Jordan"; -"enum.browsing_country.name.kazakhstan" = "Kazakhstan"; -"enum.browsing_country.name.kenya" = "Kenya"; -"enum.browsing_country.name.kiribati" = "Kiribati"; -"enum.browsing_country.name.kuwait" = "Kuwait"; -"enum.browsing_country.name.kyrgyzstan" = "Kyrgyzstan"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "Lao People's Democratic Republic"; -"enum.browsing_country.name.latvia" = "Latvia"; -"enum.browsing_country.name.lebanon" = "Lebanon"; -"enum.browsing_country.name.lesotho" = "Lesotho"; -"enum.browsing_country.name.liberia" = "Liberia"; -"enum.browsing_country.name.libya" = "Libya"; -"enum.browsing_country.name.liechtenstein" = "Liechtenstein"; -"enum.browsing_country.name.lithuania" = "Lithuania"; -"enum.browsing_country.name.luxembourg" = "Luxembourg"; -"enum.browsing_country.name.macau" = "Macau"; -"enum.browsing_country.name.macedonia" = "Macedonia"; -"enum.browsing_country.name.madagascar" = "Madagascar"; -"enum.browsing_country.name.malawi" = "Malawi"; -"enum.browsing_country.name.malaysia" = "Malaysia"; -"enum.browsing_country.name.maldives" = "Maldives"; -"enum.browsing_country.name.mali" = "Mali"; -"enum.browsing_country.name.malta" = "Malta"; -"enum.browsing_country.name.marshall_islands" = "Marshall Islands"; -"enum.browsing_country.name.martinique" = "Martinique"; -"enum.browsing_country.name.mauritania" = "Mauritania"; -"enum.browsing_country.name.mauritius" = "Mauritius"; -"enum.browsing_country.name.mayotte" = "Mayotte"; -"enum.browsing_country.name.mexico" = "Mexico"; -"enum.browsing_country.name.micronesia" = "Micronesia"; -"enum.browsing_country.name.moldova" = "Moldova"; -"enum.browsing_country.name.monaco" = "Monaco"; -"enum.browsing_country.name.mongolia" = "Mongolia"; -"enum.browsing_country.name.montenegro" = "Montenegro"; -"enum.browsing_country.name.montserrat" = "Montserrat"; -"enum.browsing_country.name.morocco" = "Morocco"; -"enum.browsing_country.name.mozambique" = "Mozambique"; -"enum.browsing_country.name.myanmar" = "Myanmar"; -"enum.browsing_country.name.namibia" = "Namibia"; -"enum.browsing_country.name.nauru" = "Nauru"; -"enum.browsing_country.name.nepal" = "Nepal"; -"enum.browsing_country.name.netherlands" = "Netherlands"; -"enum.browsing_country.name.new_caledonia" = "New Caledonia"; -"enum.browsing_country.name.new_zealand" = "New Zealand"; -"enum.browsing_country.name.nicaragua" = "Nicaragua"; -"enum.browsing_country.name.niger" = "Niger"; -"enum.browsing_country.name.nigeria" = "Nigeria"; -"enum.browsing_country.name.niue" = "Niue"; -"enum.browsing_country.name.norfolk_island" = "Norfolk Island"; -"enum.browsing_country.name.north_korea" = "North Korea"; -"enum.browsing_country.name.northern_mariana_islands" = "Northern Mariana Islands"; -"enum.browsing_country.name.norway" = "Norway"; -"enum.browsing_country.name.oman" = "Oman"; -"enum.browsing_country.name.pakistan" = "Pakistan"; -"enum.browsing_country.name.palau" = "Palau"; -"enum.browsing_country.name.palestinian_territory" = "Palestinian Territory"; -"enum.browsing_country.name.panama" = "Panama"; -"enum.browsing_country.name.papua_new_guinea" = "Papua New Guinea"; -"enum.browsing_country.name.paraguay" = "Paraguay"; -"enum.browsing_country.name.peru" = "Peru"; -"enum.browsing_country.name.philippines" = "Philippines"; -"enum.browsing_country.name.pitcairn_islands" = "Pitcairn Islands"; -"enum.browsing_country.name.poland" = "Poland"; -"enum.browsing_country.name.portugal" = "Portugal"; -"enum.browsing_country.name.puerto_rico" = "Puerto Rico"; -"enum.browsing_country.name.qatar" = "Qatar"; -"enum.browsing_country.name.reunion" = "Reunion"; -"enum.browsing_country.name.romania" = "Romania"; -"enum.browsing_country.name.russian_federation" = "Russian Federation"; -"enum.browsing_country.name.rwanda" = "Rwanda"; -"enum.browsing_country.name.saint_barthelemy" = "Saint Barthelemy"; -"enum.browsing_country.name.saint_helena" = "Saint Helena"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "Saint Kitts and Nevis"; -"enum.browsing_country.name.saint_lucia" = "Saint Lucia"; -"enum.browsing_country.name.saint_martin" = "Saint Martin"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "Saint Pierre and Miquelon"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "Saint Vincent and the Grenadines"; -"enum.browsing_country.name.samoa" = "Samoa"; -"enum.browsing_country.name.san_marino" = "San Marino"; -"enum.browsing_country.name.sao_tome_and_principe" = "Sao Tome and Principe"; -"enum.browsing_country.name.saudi_arabia" = "Saudi Arabia"; -"enum.browsing_country.name.senegal" = "Senegal"; -"enum.browsing_country.name.serbia" = "Serbia"; -"enum.browsing_country.name.seychelles" = "Seychelles"; -"enum.browsing_country.name.sierra_leone" = "Sierra Leone"; -"enum.browsing_country.name.singapore" = "Singapore"; -"enum.browsing_country.name.sint_maarten" = "Sint Maarten"; -"enum.browsing_country.name.slovakia" = "Slovakia"; -"enum.browsing_country.name.slovenia" = "Slovenia"; -"enum.browsing_country.name.solomon_islands" = "Solomon Islands"; -"enum.browsing_country.name.somalia" = "Somalia"; -"enum.browsing_country.name.south_africa" = "South Africa"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "South Georgia and the South Sandwich Islands"; -"enum.browsing_country.name.south_korea" = "South Korea"; -"enum.browsing_country.name.south_sudan" = "South Sudan"; -"enum.browsing_country.name.spain" = "Spain"; -"enum.browsing_country.name.sri_lanka" = "Sri Lanka"; -"enum.browsing_country.name.sudan" = "Sudan"; -"enum.browsing_country.name.suriname" = "Suriname"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "Svalbard and Jan Mayen"; -"enum.browsing_country.name.swaziland" = "Swaziland"; -"enum.browsing_country.name.sweden" = "Sweden"; -"enum.browsing_country.name.switzerland" = "Switzerland"; -"enum.browsing_country.name.syrian_arab_republic" = "Syrian Arab Republic"; -"enum.browsing_country.name.taiwan" = "Taiwan"; -"enum.browsing_country.name.tajikistan" = "Tajikistan"; -"enum.browsing_country.name.tanzania" = "Tanzania"; -"enum.browsing_country.name.thailand" = "Thailand"; -"enum.browsing_country.name.timor_leste" = "Timor-Leste"; -"enum.browsing_country.name.togo" = "Togo"; -"enum.browsing_country.name.tokelau" = "Tokelau"; -"enum.browsing_country.name.tonga" = "Tonga"; -"enum.browsing_country.name.trinidad_and_tobago" = "Trinidad and Tobago"; -"enum.browsing_country.name.tunisia" = "Tunisia"; -"enum.browsing_country.name.turkey" = "Turkey"; -"enum.browsing_country.name.turkmenistan" = "Turkmenistan"; -"enum.browsing_country.name.turks_and_caicos_islands" = "Turks and Caicos Islands"; -"enum.browsing_country.name.tuvalu" = "Tuvalu"; -"enum.browsing_country.name.uganda" = "Uganda"; -"enum.browsing_country.name.ukraine" = "Ukraine"; -"enum.browsing_country.name.united_arab_emirates" = "United Arab Emirates"; -"enum.browsing_country.name.united_kingdom" = "United Kingdom"; -"enum.browsing_country.name.united_states" = "United States"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "United States Minor Outlying Islands"; -"enum.browsing_country.name.uruguay" = "Uruguay"; -"enum.browsing_country.name.uzbekistan" = "Uzbekistan"; -"enum.browsing_country.name.vanuatu" = "Vanuatu"; -"enum.browsing_country.name.venezuela" = "Venezuela"; -"enum.browsing_country.name.vietnam" = "Vietnam"; -"enum.browsing_country.name.virgin_islands_british" = "British Virgin Islands"; -"enum.browsing_country.name.virgin_islands_US" = "U.S. Virgin Islands"; -"enum.browsing_country.name.wallis_and_futuna" = "Wallis and Futuna"; -"enum.browsing_country.name.western_sahara" = "Western Sahara"; -"enum.browsing_country.name.yemen" = "Yemen"; -"enum.browsing_country.name.zambia" = "Zambia"; -"enum.browsing_country.name.zimbabwe" = "Zimbabwe"; +"browsing_country.auto_detect" = "Auto-Detect"; +"browsing_country.afghanistan" = "Afghanistan"; +"browsing_country.aland_islands" = "Aland Islands"; +"browsing_country.albania" = "Albania"; +"browsing_country.algeria" = "Algeria"; +"browsing_country.american_samoa" = "American Samoa"; +"browsing_country.andorra" = "Andorra"; +"browsing_country.angola" = "Angola"; +"browsing_country.anguilla" = "Anguilla"; +"browsing_country.antarctica" = "Antarctica"; +"browsing_country.antigua_and_barbuda" = "Antigua and Barbuda"; +"browsing_country.argentina" = "Argentina"; +"browsing_country.armenia" = "Armenia"; +"browsing_country.aruba" = "Aruba"; +"browsing_country.asia_pacific_region" = "Asia-Pacific Region"; +"browsing_country.australia" = "Australia"; +"browsing_country.austria" = "Austria"; +"browsing_country.azerbaijan" = "Azerbaijan"; +"browsing_country.bahamas" = "Bahamas"; +"browsing_country.bahrain" = "Bahrain"; +"browsing_country.bangladesh" = "Bangladesh"; +"browsing_country.barbados" = "Barbados"; +"browsing_country.belarus" = "Belarus"; +"browsing_country.belgium" = "Belgium"; +"browsing_country.belize" = "Belize"; +"browsing_country.benin" = "Benin"; +"browsing_country.bermuda" = "Bermuda"; +"browsing_country.bhutan" = "Bhutan"; +"browsing_country.bolivia" = "Bolivia"; +"browsing_country.bonaire_saint_eustatius_and_saba" = "Bonaire Saint Eustatius and Saba"; +"browsing_country.bosnia_and_herzegovina" = "Bosnia and Herzegovina"; +"browsing_country.botswana" = "Botswana"; +"browsing_country.bouvet_island" = "Bouvet Island"; +"browsing_country.brazil" = "Brazil"; +"browsing_country.british_indian_ocean_territory" = "British Indian Ocean Territory"; +"browsing_country.brunei_darussalam" = "Brunei Darussalam"; +"browsing_country.bulgaria" = "Bulgaria"; +"browsing_country.burkina_faso" = "Burkina Faso"; +"browsing_country.burundi" = "Burundi"; +"browsing_country.cambodia" = "Cambodia"; +"browsing_country.cameroon" = "Cameroon"; +"browsing_country.canada" = "Canada"; +"browsing_country.cape_verde" = "Cape Verde"; +"browsing_country.cayman_islands" = "Cayman Islands"; +"browsing_country.central_african_republic" = "Central African Republic"; +"browsing_country.chad" = "Chad"; +"browsing_country.chile" = "Chile"; +"browsing_country.china" = "China"; +"browsing_country.christmas_island" = "Christmas Island"; +"browsing_country.cocos_islands" = "Cocos Islands"; +"browsing_country.colombia" = "Colombia"; +"browsing_country.comoros" = "Comoros"; +"browsing_country.congo" = "Congo"; +"browsing_country.the_democratic_republic_of_the_congo" = "The Democratic Republic of the Congo"; +"browsing_country.cook_islands" = "Cook Islands"; +"browsing_country.costa_rica" = "Costa Rica"; +"browsing_country.cote_d_ivoire" = "Cote D'Ivoire"; +"browsing_country.croatia" = "Croatia"; +"browsing_country.cuba" = "Cuba"; +"browsing_country.curacao" = "Curacao"; +"browsing_country.cyprus" = "Cyprus"; +"browsing_country.czech_republic" = "Czech Republic"; +"browsing_country.denmark" = "Denmark"; +"browsing_country.djibouti" = "Djibouti"; +"browsing_country.dominica" = "Dominica"; +"browsing_country.dominican_republic" = "Dominican Republic"; +"browsing_country.ecuador" = "Ecuador"; +"browsing_country.egypt" = "Egypt"; +"browsing_country.el_salvador" = "El Salvador"; +"browsing_country.equatorial_guinea" = "Equatorial Guinea"; +"browsing_country.eritrea" = "Eritrea"; +"browsing_country.estonia" = "Estonia"; +"browsing_country.ethiopia" = "Ethiopia"; +"browsing_country.europe" = "Europe"; +"browsing_country.falkland_islands" = "Falkland Islands"; +"browsing_country.faroe_islands" = "Faroe Islands"; +"browsing_country.fiji" = "Fiji"; +"browsing_country.finland" = "Finland"; +"browsing_country.france" = "France"; +"browsing_country.french_guiana" = "French Guiana"; +"browsing_country.french_polynesia" = "French Polynesia"; +"browsing_country.french_southern_territories" = "French Southern Territories"; +"browsing_country.gabon" = "Gabon"; +"browsing_country.gambia" = "Gambia"; +"browsing_country.georgia" = "Georgia"; +"browsing_country.germany" = "Germany"; +"browsing_country.ghana" = "Ghana"; +"browsing_country.gibraltar" = "Gibraltar"; +"browsing_country.greece" = "Greece"; +"browsing_country.greenland" = "Greenland"; +"browsing_country.grenada" = "Grenada"; +"browsing_country.guadeloupe" = "Guadeloupe"; +"browsing_country.guam" = "Guam"; +"browsing_country.guatemala" = "Guatemala"; +"browsing_country.guernsey" = "Guernsey"; +"browsing_country.guinea" = "Guinea"; +"browsing_country.guinea_bissau" = "Guinea-Bissau"; +"browsing_country.guyana" = "Guyana"; +"browsing_country.haiti" = "Haiti"; +"browsing_country.heard_island_and_mc_donald_islands" = "Heard Island and McDonald Islands"; +"browsing_country.vatican_city_state" = "Vatican City State"; +"browsing_country.honduras" = "Honduras"; +"browsing_country.hong_kong" = "Hong Kong"; +"browsing_country.hungary" = "Hungary"; +"browsing_country.iceland" = "Iceland"; +"browsing_country.india" = "India"; +"browsing_country.indonesia" = "Indonesia"; +"browsing_country.iran" = "Iran"; +"browsing_country.iraq" = "Iraq"; +"browsing_country.ireland" = "Ireland"; +"browsing_country.isle_of_man" = "Isle of Man"; +"browsing_country.israel" = "Israel"; +"browsing_country.italy" = "Italy"; +"browsing_country.jamaica" = "Jamaica"; +"browsing_country.japan" = "Japan"; +"browsing_country.jersey" = "Jersey"; +"browsing_country.jordan" = "Jordan"; +"browsing_country.kazakhstan" = "Kazakhstan"; +"browsing_country.kenya" = "Kenya"; +"browsing_country.kiribati" = "Kiribati"; +"browsing_country.kuwait" = "Kuwait"; +"browsing_country.kyrgyzstan" = "Kyrgyzstan"; +"browsing_country.lao_peoples_democratic_republic" = "Lao People's Democratic Republic"; +"browsing_country.latvia" = "Latvia"; +"browsing_country.lebanon" = "Lebanon"; +"browsing_country.lesotho" = "Lesotho"; +"browsing_country.liberia" = "Liberia"; +"browsing_country.libya" = "Libya"; +"browsing_country.liechtenstein" = "Liechtenstein"; +"browsing_country.lithuania" = "Lithuania"; +"browsing_country.luxembourg" = "Luxembourg"; +"browsing_country.macau" = "Macau"; +"browsing_country.macedonia" = "Macedonia"; +"browsing_country.madagascar" = "Madagascar"; +"browsing_country.malawi" = "Malawi"; +"browsing_country.malaysia" = "Malaysia"; +"browsing_country.maldives" = "Maldives"; +"browsing_country.mali" = "Mali"; +"browsing_country.malta" = "Malta"; +"browsing_country.marshall_islands" = "Marshall Islands"; +"browsing_country.martinique" = "Martinique"; +"browsing_country.mauritania" = "Mauritania"; +"browsing_country.mauritius" = "Mauritius"; +"browsing_country.mayotte" = "Mayotte"; +"browsing_country.mexico" = "Mexico"; +"browsing_country.micronesia" = "Micronesia"; +"browsing_country.moldova" = "Moldova"; +"browsing_country.monaco" = "Monaco"; +"browsing_country.mongolia" = "Mongolia"; +"browsing_country.montenegro" = "Montenegro"; +"browsing_country.montserrat" = "Montserrat"; +"browsing_country.morocco" = "Morocco"; +"browsing_country.mozambique" = "Mozambique"; +"browsing_country.myanmar" = "Myanmar"; +"browsing_country.namibia" = "Namibia"; +"browsing_country.nauru" = "Nauru"; +"browsing_country.nepal" = "Nepal"; +"browsing_country.netherlands" = "Netherlands"; +"browsing_country.new_caledonia" = "New Caledonia"; +"browsing_country.new_zealand" = "New Zealand"; +"browsing_country.nicaragua" = "Nicaragua"; +"browsing_country.niger" = "Niger"; +"browsing_country.nigeria" = "Nigeria"; +"browsing_country.niue" = "Niue"; +"browsing_country.norfolk_island" = "Norfolk Island"; +"browsing_country.north_korea" = "North Korea"; +"browsing_country.northern_mariana_islands" = "Northern Mariana Islands"; +"browsing_country.norway" = "Norway"; +"browsing_country.oman" = "Oman"; +"browsing_country.pakistan" = "Pakistan"; +"browsing_country.palau" = "Palau"; +"browsing_country.palestinian_territory" = "Palestinian Territory"; +"browsing_country.panama" = "Panama"; +"browsing_country.papua_new_guinea" = "Papua New Guinea"; +"browsing_country.paraguay" = "Paraguay"; +"browsing_country.peru" = "Peru"; +"browsing_country.philippines" = "Philippines"; +"browsing_country.pitcairn_islands" = "Pitcairn Islands"; +"browsing_country.poland" = "Poland"; +"browsing_country.portugal" = "Portugal"; +"browsing_country.puerto_rico" = "Puerto Rico"; +"browsing_country.qatar" = "Qatar"; +"browsing_country.reunion" = "Reunion"; +"browsing_country.romania" = "Romania"; +"browsing_country.russian_federation" = "Russian Federation"; +"browsing_country.rwanda" = "Rwanda"; +"browsing_country.saint_barthelemy" = "Saint Barthelemy"; +"browsing_country.saint_helena" = "Saint Helena"; +"browsing_country.saint_kitts_and_nevis" = "Saint Kitts and Nevis"; +"browsing_country.saint_lucia" = "Saint Lucia"; +"browsing_country.saint_martin" = "Saint Martin"; +"browsing_country.saint_pierre_and_miquelon" = "Saint Pierre and Miquelon"; +"browsing_country.saint_vincent_and_the_grenadines" = "Saint Vincent and the Grenadines"; +"browsing_country.samoa" = "Samoa"; +"browsing_country.san_marino" = "San Marino"; +"browsing_country.sao_tome_and_principe" = "Sao Tome and Principe"; +"browsing_country.saudi_arabia" = "Saudi Arabia"; +"browsing_country.senegal" = "Senegal"; +"browsing_country.serbia" = "Serbia"; +"browsing_country.seychelles" = "Seychelles"; +"browsing_country.sierra_leone" = "Sierra Leone"; +"browsing_country.singapore" = "Singapore"; +"browsing_country.sint_maarten" = "Sint Maarten"; +"browsing_country.slovakia" = "Slovakia"; +"browsing_country.slovenia" = "Slovenia"; +"browsing_country.solomon_islands" = "Solomon Islands"; +"browsing_country.somalia" = "Somalia"; +"browsing_country.south_africa" = "South Africa"; +"browsing_country.south_georgia_and_the_south_sandwich_islands" = "South Georgia and the South Sandwich Islands"; +"browsing_country.south_korea" = "South Korea"; +"browsing_country.south_sudan" = "South Sudan"; +"browsing_country.spain" = "Spain"; +"browsing_country.sri_lanka" = "Sri Lanka"; +"browsing_country.sudan" = "Sudan"; +"browsing_country.suriname" = "Suriname"; +"browsing_country.svalbard_and_jan_mayen" = "Svalbard and Jan Mayen"; +"browsing_country.swaziland" = "Swaziland"; +"browsing_country.sweden" = "Sweden"; +"browsing_country.switzerland" = "Switzerland"; +"browsing_country.syrian_arab_republic" = "Syrian Arab Republic"; +"browsing_country.taiwan" = "Taiwan"; +"browsing_country.tajikistan" = "Tajikistan"; +"browsing_country.tanzania" = "Tanzania"; +"browsing_country.thailand" = "Thailand"; +"browsing_country.timor_leste" = "Timor-Leste"; +"browsing_country.togo" = "Togo"; +"browsing_country.tokelau" = "Tokelau"; +"browsing_country.tonga" = "Tonga"; +"browsing_country.trinidad_and_tobago" = "Trinidad and Tobago"; +"browsing_country.tunisia" = "Tunisia"; +"browsing_country.turkey" = "Turkey"; +"browsing_country.turkmenistan" = "Turkmenistan"; +"browsing_country.turks_and_caicos_islands" = "Turks and Caicos Islands"; +"browsing_country.tuvalu" = "Tuvalu"; +"browsing_country.uganda" = "Uganda"; +"browsing_country.ukraine" = "Ukraine"; +"browsing_country.united_arab_emirates" = "United Arab Emirates"; +"browsing_country.united_kingdom" = "United Kingdom"; +"browsing_country.united_states" = "United States"; +"browsing_country.united_states_minor_outlying_islands" = "United States Minor Outlying Islands"; +"browsing_country.uruguay" = "Uruguay"; +"browsing_country.uzbekistan" = "Uzbekistan"; +"browsing_country.vanuatu" = "Vanuatu"; +"browsing_country.venezuela" = "Venezuela"; +"browsing_country.vietnam" = "Vietnam"; +"browsing_country.virgin_islands_british" = "British Virgin Islands"; +"browsing_country.virgin_islands_US" = "U.S. Virgin Islands"; +"browsing_country.wallis_and_futuna" = "Wallis and Futuna"; +"browsing_country.western_sahara" = "Western Sahara"; +"browsing_country.yemen" = "Yemen"; +"browsing_country.zambia" = "Zambia"; +"browsing_country.zimbabwe" = "Zimbabwe"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings index f5e787f8c..4a4994a3c 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings @@ -4,83 +4,82 @@ */ // MARK: Website response -"website.response.hath_client_not_found" = "You must have a H@H client assigned to your account to use this feature."; -"website.response.hath_client_not_online" = "Your H@H client appears to be offline. Turn it on, then try again."; -"website.response.invalid_resolution" = "The requested gallery cannot be downloaded with the selected resolution."; -"website.response.gallery_unavailable" = "This gallery has been removed or is unavailable."; +"hath_client_not_found" = "You must have a H@H client assigned to your account to use this feature."; +"hath_client_not_online" = "Your H@H client appears to be offline. Turn it on, then try again."; +"invalid_resolution" = "The requested gallery cannot be downloaded with the selected resolution."; +"gallery_unavailable" = "This gallery has been removed or is unavailable."; // MARK: App -"app.copyright" = "Copyright © 2026 EhPanda Team"; +"copyright" = "Copyright © 2026 EhPanda Team"; // Contact -"app.contact.link.website" = "https://ehpanda.app"; -"app.contact.link.gitHub" = "https://github.com/EhPanda-Team/EhPanda"; -"app.contact.link.discord" = "https://discord.gg/BSBE9FCBTq"; -"app.contact.link.telegram" = "https://t.me/ehpanda"; -"app.contact.link.altStore" = "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json"; -"app.contact.text.gitHub" = "GitHub"; -"app.contact.text.discord" = "Discord"; -"app.contact.text.telegram" = "Telegram"; +"contact.website" = "https://ehpanda.app"; +"contact.gitHub" = "https://github.com/EhPanda-Team/EhPanda"; +"contact.discord" = "https://discord.gg/BSBE9FCBTq"; +"contact.telegram" = "https://t.me/ehpanda"; +"contact.altStore_link" = "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json"; +"contact.gitHub_link" = "GitHub"; +"contact.discord_link" = "Discord"; +"contact.telegram_link" = "Telegram"; // Special thanks -"app.special_thanks.link.taylorlannister" = "https://github.com/taylorlannister"; -"app.special_thanks.link.luminescent_yq" = ""; -"app.special_thanks.link.caxerx" = "https://github.com/caxerx"; -"app.special_thanks.link.honjow" = "https://github.com/honjow"; -"app.special_thanks.text.taylorlannister" = "taylorlannister"; -"app.special_thanks.text.luminescent_yq" = "Luminescent_yq"; -"app.special_thanks.text.caxerx" = "caxerx"; -"app.special_thanks.text.honjow" = "honjow"; +"special_thanks.taylorlannister_link" = "https://github.com/taylorlannister"; +"special_thanks.luminescent_yq_link" = ""; +"special_thanks.caxerx_link" = "https://github.com/caxerx"; +"special_thanks.honjow_link" = "https://github.com/honjow"; +"special_thanks.taylorlannister" = "taylorlannister"; +"special_thanks.luminescent_yq" = "Luminescent_yq"; +"special_thanks.caxerx" = "caxerx"; +"special_thanks.honjow" = "honjow"; // Code level contributor -"app.code_level_contributor.link.vvbbnn00" = "https://github.com/vvbbnn00"; -"app.code_level_contributor.link.Kaed3mi" = "https://github.com/Kaed3mi"; -"app.code_level_contributor.link.aalberrty" = "https://github.com/aalberrty"; -"app.code_level_contributor.link.Jimmy-Prime" = "https://github.com/Jimmy-Prime"; -"app.code_level_contributor.link.xioxin" = "https://github.com/xioxin"; -"app.code_level_contributor.text.vvbbnn00" = "vvbbnn00"; -"app.code_level_contributor.text.Kaed3mi" = "Kaed3mi"; -"app.code_level_contributor.text.aalberrty" = "Zack Asahina"; -"app.code_level_contributor.text.Jimmy-Prime" = "Jimmy Prime"; -"app.code_level_contributor.text.xioxin" = "xioxin"; +"code_level_contributor.vvbbnn00_link" = "https://github.com/vvbbnn00"; +"code_level_contributor.Kaed3mi_link" = "https://github.com/Kaed3mi"; +"code_level_contributor.aalberrty_link" = "https://github.com/aalberrty"; +"code_level_contributor.Jimmy-Prime_link" = "https://github.com/Jimmy-Prime"; +"code_level_contributor.xioxin_link" = "https://github.com/xioxin"; +"code_level_contributor.vvbbnn00" = "vvbbnn00"; +"code_level_contributor.Kaed3mi" = "Kaed3mi"; +"code_level_contributor.aalberrty" = "Zack Asahina"; +"code_level_contributor.Jimmy-Prime" = "Jimmy Prime"; +"code_level_contributor.xioxin" = "xioxin"; // Translation contributor -"app.translation_contributor.link.nebulosa-cat" = "https://github.com/Nebulosa-Cat"; -"app.translation_contributor.link.paulHaeussler" = "https://github.com/PaulHaeussler"; -"app.translation_contributor.link.caxerx" = "https://github.com/caxerx"; -"app.translation_contributor.link.NeKoOuO" = "https://github.com/NeKoOuO"; -"app.translation_contributor.text.nebulosa-cat" = "雲豹 ΦωΦ"; -"app.translation_contributor.text.paulHaeussler" = "PaulHaeussler"; -"app.translation_contributor.text.caxerx" = "caxerx"; -"app.translation_contributor.text.NeKoOuO" = "ɴᴇᴋᴏ"; +"translation_contributor.nebulosa-cat_link" = "https://github.com/Nebulosa-Cat"; +"translation_contributor.paulHaeussler_link" = "https://github.com/PaulHaeussler"; +"translation_contributor.caxerx_link" = "https://github.com/caxerx"; +"translation_contributor.NeKoOuO_link" = "https://github.com/NeKoOuO"; +"translation_contributor.nebulosa-cat" = "雲豹 ΦωΦ"; +"translation_contributor.paulHaeussler" = "PaulHaeussler"; +"translation_contributor.caxerx" = "caxerx"; +"translation_contributor.NeKoOuO" = "ɴᴇᴋᴏ"; // Acknowledgement link -"app.acknowledgement.link.kanna" = "https://github.com/tid-kijyun/Kanna"; -"app.acknowledgement.link.swiftGen" = "https://github.com/SwiftGen/SwiftGen"; -"app.acknowledgement.link.colorful" = "https://github.com/Co2333/Colorful"; -"app.acknowledgement.link.kingfisher" = "https://github.com/onevcat/Kingfisher"; -"app.acknowledgement.link.swiftUIPager" = "https://github.com/fermoya/SwiftUIPager"; -"app.acknowledgement.link.waterfallGrid" = "https://github.com/paololeonardi/WaterfallGrid"; -"app.acknowledgement.link.swiftyOpenCC" = "https://github.com/ddddxxx/SwiftyOpenCC"; -"app.acknowledgement.link.uiImageColors" = "https://github.com/jathu/UIImageColors"; -"app.acknowledgement.link.sfSafeSymbols" = "https://github.com/SFSafeSymbols/SFSafeSymbols"; -"app.acknowledgement.link.systemNotification" = "https://github.com/danielsaidi/SystemNotification"; -"app.acknowledgement.link.swiftCommonMark" = "https://github.com/gonzalezreal/SwiftCommonMark"; -"app.acknowledgement.link.ehTagTranslationDatabase" = "https://github.com/EhTagTranslation/Database"; -"app.acknowledgement.link.tca" = "https://github.com/pointfreeco/swift-composable-architecture"; +"acknowledgement.kanna_link" = "https://github.com/tid-kijyun/Kanna"; +"acknowledgement.swiftGen_link" = "https://github.com/SwiftGen/SwiftGen"; +"acknowledgement.colorful_link" = "https://github.com/Co2333/Colorful"; +"acknowledgement.kingfisher_link" = "https://github.com/onevcat/Kingfisher"; +"acknowledgement.swiftUIPager_link" = "https://github.com/fermoya/SwiftUIPager"; +"acknowledgement.waterfallGrid_link" = "https://github.com/paololeonardi/WaterfallGrid"; +"acknowledgement.swiftyOpenCC_link" = "https://github.com/ddddxxx/SwiftyOpenCC"; +"acknowledgement.uiImageColors_link" = "https://github.com/jathu/UIImageColors"; +"acknowledgement.sfSafeSymbols_link" = "https://github.com/SFSafeSymbols/SFSafeSymbols"; +"acknowledgement.systemNotification_link" = "https://github.com/danielsaidi/SystemNotification"; +"acknowledgement.swiftCommonMark_link" = "https://github.com/gonzalezreal/SwiftCommonMark"; +"acknowledgement.ehTagTranslationDatabase_link" = "https://github.com/EhTagTranslation/Database"; +"acknowledgement.tca_link" = "https://github.com/pointfreeco/swift-composable-architecture"; // Acknowledgement text -"app.acknowledgement.text.kanna" = "Kanna"; -"app.acknowledgement.text.swiftGen" = "SwiftGen"; -"app.acknowledgement.text.colorful" = "Colorful"; -"app.acknowledgement.text.kingfisher" = "Kingfisher"; -"app.acknowledgement.text.swiftUIPager" = "SwiftUIPager"; -"app.acknowledgement.text.waterfallGrid" = "WaterfallGrid"; -"app.acknowledgement.text.swiftyOpenCC" = "SwiftyOpenCC"; -"app.acknowledgement.text.uiImageColors" = "UIImageColors"; -"app.acknowledgement.text.sfSafeSymbols" = "SFSafeSymbols"; -"app.acknowledgement.text.systemNotification" = "SystemNotification"; -"app.acknowledgement.text.swiftUINavigation" = "SwiftUI Navigation"; -"app.acknowledgement.text.swiftCommonMark" = "SwiftCommonMark"; -"app.acknowledgement.text.ehTagTranslationDatabase" = "EhTagTranslation/Database"; -"app.acknowledgement.text.tca" = "The Composable Architecture"; +"acknowledgement.kanna" = "Kanna"; +"acknowledgement.swiftGen" = "SwiftGen"; +"acknowledgement.colorful" = "Colorful"; +"acknowledgement.kingfisher" = "Kingfisher"; +"acknowledgement.swiftUIPager" = "SwiftUIPager"; +"acknowledgement.waterfallGrid" = "WaterfallGrid"; +"acknowledgement.swiftyOpenCC" = "SwiftyOpenCC"; +"acknowledgement.uiImageColors" = "UIImageColors"; +"acknowledgement.sfSafeSymbols" = "SFSafeSymbols"; +"acknowledgement.systemNotification" = "SystemNotification"; +"acknowledgement.swiftCommonMark" = "SwiftCommonMark"; +"acknowledgement.ehTagTranslationDatabase" = "EhTagTranslation/Database"; +"acknowledgement.tca" = "The Composable Architecture"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 0ccd153ee..57f8e0ebf 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -1,235 +1,232 @@ // MARK: BanInterval -"enum.ban_interval.description.and" = "and"; +"ban_interval.and" = "and"; // MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "Yesterday"; -"enum.toplists_type.value.past_month" = "Past month"; -"enum.toplists_type.value.past_year" = "Past year"; -"enum.toplists_type.value.all_time" = "All time"; +"toplists_type.yesterday" = "Yesterday"; +"toplists_type.past_month" = "Past month"; +"toplists_type.past_year" = "Past year"; +"toplists_type.all_time" = "All time"; // MARK: Response -"website.response.hath_client_not_found" = "You must have a H@H client assigned to your account to use this feature."; -"website.response.hath_client_not_online" = "Your H@H client appears to be offline. Turn it on, then try again."; -"website.response.invalid_resolution" = "The requested gallery cannot be downloaded with the selected resolution."; +"hath_client_not_found" = "You must have a H@H client assigned to your account to use this feature."; +"hath_client_not_online" = "Your H@H client appears to be offline. Turn it on, then try again."; +"invalid_resolution" = "The requested gallery cannot be downloaded with the selected resolution."; // MARK: Toast -"toast.title.error" = "Error"; -"toast.title.success" = "Success"; -"toast.title.loading" = "Loading..."; -"toast.title.communicating" = "Communicating..."; -"toast.caption.copied_to_clipboard" = "Copied to clipboard"; -"toast.caption.saved_to_photo_library" = "Saved to photo library"; +"toast.error" = "Error"; +"toast.success" = "Success"; +"toast.loading" = "Loading..."; +"toast.communicating" = "Communicating..."; +"toast.copied_to_clipboard" = "Copied to clipboard"; +"toast.saved_to_photo_library" = "Saved to photo library"; // MARK: AutoLock "local_authorization.reason" = "The App has been locked due to the Auto-Lock expiration."; // MARK: Common value -"common.value.stars" = "%@ stars"; -"common.value.pages" = "%@ pages"; -"common.value.times" = "%@ times"; -"common.value.day" = "%@ day"; -"common.value.days" = "%@ days"; -"common.value.hour" = "%@ hour"; -"common.value.hours" = "%@ hours"; -"common.value.minute" = "%@ minute"; -"common.value.minutes" = "%@ minutes"; -"common.value.second" = "%@ second"; -"common.value.seconds" = "%@ seconds"; -"common.value.records" = "%@ records"; +"common.stars" = "%@ stars"; +"common.pages" = "%@ pages"; +"common.day" = "%@ day"; +"common.days" = "%@ days"; +"common.hour" = "%@ hour"; +"common.hours" = "%@ hours"; +"common.minute" = "%@ minute"; +"common.minutes" = "%@ minutes"; +"common.second" = "%@ second"; +"common.seconds" = "%@ seconds"; // MARK: Common button -"common.button.cancel" = "Cancel"; +"common.cancel" = "Cancel"; // MARK: TabItem -"tab_item.title.home" = "Home"; -"tab_item.title.favorites" = "Favorites"; -"tab_item.title.search" = "Search"; -"tab_item.title.downloads" = "Downloads"; -"tab_item.title.setting" = "Setting"; +"tab_item.home" = "Home"; +"tab_item.favorites" = "Favorites"; +"tab_item.search" = "Search"; +"tab_item.downloads" = "Downloads"; +"tab_item.setting" = "Setting"; // MARK: ToolbarItem -"toolbar_item.button.filters" = "Filters"; -"toolbar_item.button.jump_page" = "Jump page"; -"toolbar_item.button.date_seek" = "Seek to date"; -"toolbar_item.button.quick_search" = "Quick search"; +"toolbar_item.filters" = "Filters"; +"toolbar_item.jump_page" = "Jump page"; +"toolbar_item.date_seek" = "Seek to date"; +"toolbar_item.quick_search" = "Quick search"; // MARK: DateSeek -"date_seek_view.title.date_seek" = "Seek to date"; -"date_seek_view.title.date" = "Date"; -"date_seek_view.footer.seek_around_date" = "Seek to galleries around the selected date."; -"date_seek_view.button.seek_newer" = "Newer"; -"date_seek_view.button.seek_older" = "Older"; +"date_seek_view.date_seek" = "Seek to date"; +"date_seek_view.date" = "Date"; +"date_seek_view.seek_around_date" = "Seek to galleries around the selected date."; +"date_seek_view.seek_newer" = "Newer"; +"date_seek_view.seek_older" = "Older"; // MARK: JumpPage -"jump_page_view.title.jump_page" = "Jump page"; -"jump_page_view.description.jump_page" = "Enter a page number between 1 and %d to jump to."; -"jump_page_view.button.confirm" = "Confirm"; +"jump_page_view.jump_page" = "Jump page"; +"jump_page_view.jump_page_description" = "Enter a page number between 1 and %d to jump to."; +"jump_page_view.confirm" = "Confirm"; // MARK: AlertView -"loading_view.title.loading" = "Loading..."; -"loading_view.title.preparing_database" = "Preparing the database..."; -"not_login_view.title.need_login" = "You need to login to access this feature."; -"not_login_view.button.login" = "Login"; -"error_view.button.retry" = "Retry"; -"error_view.button.drop_database" = "Drop the database"; -"error_view.title.try_later" = "Please try again later."; -"error_view.title.network" = "A network error occurred."; -"error_view.title.parsing" = "A parsing error occurred."; -"error_view.title.unknown" = "An unknown error occurred."; -"error_view.title.not_found" = "There seems to be nothing here."; -"error_view.title.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; -"error_view.title.ip_banned" = "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@."; -"error_view.title.copyright_claim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; -"error_view.title.gallery_unavailable" = "This gallery has been removed or is unavailable."; +"loading_view.loading" = "Loading..."; +"loading_view.preparing_database" = "Preparing the database..."; +"not_login_view.need_login" = "You need to login to access this feature."; +"not_login_viewlogin" = "Login"; +"error_view.retry" = "Retry"; +"error_view.drop_database" = "Drop the database"; +"error_view.try_later" = "Please try again later."; +"error_view.network" = "A network error occurred."; +"error_view.parsing" = "A parsing error occurred."; +"error_view.unknown" = "An unknown error occurred."; +"error_view.not_found" = "There seems to be nothing here."; +"error_view.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; +"error_view.ip_banned" = "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@."; +"error_view.copyright_claim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; +"error_view.gallery_unavailable" = "This gallery has been removed or is unavailable."; // MARK: AppError -"app_error.localized_description.database_corrupted" = "Database Corrupted"; -"app_error.localized_description.copyright_claim" = "Copyright Claim"; -"app_error.localized_description.ip_banned" = "IP Banned"; -"app_error.localized_description.gallery_expunged" = "Gallery Expunged"; -"app_error.localized_description.network_error" = "Network Error"; -"app_error.localized_description.web_image_loading_error" = "Web image loading error"; -"app_error.localized_description.parse_error" = "Parse Error"; -"app_error.localized_description.quota_exceeded" = "Quota Exceeded"; -"app_error.localized_description.authentication_required" = "Authentication Required"; -"app_error.localized_description.file_operation_failed" = "File Operation Failed"; -"app_error.localized_description.no_updates_available" = "No updates available"; -"app_error.localized_description.not_found" = "Not found"; -"app_error.localized_description.unknown_error" = "Unknown Error"; -"app_error.alert.quota_exceeded" = "Image quota exceeded.\nPlease wait and try again later."; -"app_error.alert.authentication_required" = "Login required to access this download."; -"app_error.alert.local_file_operation_failed" = "Local file operation failed."; +"app_error.database_corrupted" = "Database Corrupted"; +"app_error.copyright_claim" = "Copyright Claim"; +"app_error.ip_banned" = "IP Banned"; +"app_error.gallery_expunged" = "Gallery Expunged"; +"app_error.network_error" = "Network Error"; +"app_error.web_image_loading_error" = "Web image loading error"; +"app_error.parse_error" = "Parse Error"; +"app_error.quota_exceeded" = "Quota Exceeded"; +"app_error.authentication_required" = "Authentication Required"; +"app_error.file_operation_failed" = "File Operation Failed"; +"app_error.no_updates_available" = "No updates available"; +"app_error.not_found" = "Not found"; +"app_error.unknown_error" = "Unknown Error"; +"app_error.quota_exceeded_description" = "Image quota exceeded.\nPlease wait and try again later."; +"app_error.authentication_required_description" = "Login required to access this download."; +"app_error.local_file_operation_failed" = "Local file operation failed."; // MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "You will lose all your data in this app.\nAre you sure to drop the database?"; -"confirmation_dialog.title.remove_custom_translations" = "Are you sure to remove your custom translations?"; -"confirmation_dialog.title.logout" = "Are you sure to logout?"; -"confirmation_dialog.title.delete" = "Are you sure to delete this item?"; -"confirmation_dialog.title.clear" = "Are you sure to clear?"; -"confirmation_dialog.title.reset" = "Are you sure to reset?"; -"confirmation_dialog.button.drop_database" = "Drop the database"; -"confirmation_dialog.button.remove" = "Remove"; -"confirmation_dialog.button.logout" = "Logout"; -"confirmation_dialog.button.delete" = "Delete"; -"confirmation_dialog.button.clear" = "Clear"; -"confirmation_dialog.button.reset" = "Reset"; +"confirmation_dialog.drop_database_description" = "You will lose all your data in this app.\nAre you sure to drop the database?"; +"confirmation_dialog.remove_custom_translations" = "Are you sure to remove your custom translations?"; +"confirmation_dialog.logout_description" = "Are you sure to logout?"; +"confirmation_dialog.delete_description" = "Are you sure to delete this item?"; +"confirmation_dialog.clear_description" = "Are you sure to clear?"; +"confirmation_dialog.reset_description" = "Are you sure to reset?"; +"confirmation_dialog.drop_database" = "Drop the database"; +"confirmation_dialog.remove" = "Remove"; +"confirmation_dialog.logout" = "Logout"; +"confirmation_dialog.delete" = "Delete"; +"confirmation_dialog.clear" = "Clear"; +"confirmation_dialog.reset" = "Reset"; // MARK: SubSection -"sub_section.button.show_all" = "Show all"; +"sub_section.show_all" = "Show all"; // MARK: NewDawnView -"new_dawn_view.title.first" = "It is the dawn of a new day!"; -"new_dawn_view.title.second" = "Reflecting on your journey so far, you find that you are a little wiser."; +"new_dawn_view.first" = "It is the dawn of a new day!"; +"new_dawn_view.second" = "Reflecting on your journey so far, you find that you are a little wiser."; // Greeting -"struct.greeting.mark.start" = "You gain "; -"struct.greeting.mark.separator" = ", "; -"struct.greeting.mark.and" = " and "; -"struct.greeting.mark.end" = "!"; +"greeting.start" = "You gain "; +"greeting.separator" = ", "; +"greeting.and" = " and "; +"greeting.end" = "!"; // MARK: HomeView -"home_view.title.home" = "Home"; -"home_view.section.title.frontpage" = "Frontpage"; -"home_view.section.title.toplists" = "Toplists"; -"home_view.section.title.other" = "Other"; +"home_view.home" = "Home"; +"home_view.frontpage" = "Frontpage"; +"home_view.toplists" = "Toplists"; +"home_view.other" = "Other"; // HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "Popular"; -"enum.home_misc_grid_type.title.watched" = "Watched"; -"enum.home_misc_grid_type.title.history" = "History"; +"home_misc_grid_type.popular" = "Popular"; +"home_misc_grid_type.watched" = "Watched"; +"home_misc_grid_type.history" = "History"; // MARK: FrontpageView -"frontpage_view.title.frontpage" = "Frontpage"; +"frontpage_view.frontpage" = "Frontpage"; // MARK: ToplistsView -"toplists_view.title.toplists" = "Toplists"; +"toplists_view.toplists" = "Toplists"; // MARK: PopularView -"popular_view.title.popular" = "Popular"; +"popular_view.popular" = "Popular"; // MARK: WatchedView -"watched_view.title.watched" = "Watched"; +"watched_view.watched" = "Watched"; // MARK: HistoryView -"history_view.title.history" = "History"; +"history_view.history" = "History"; // MARK: FavoritesView -"favorites_view.title.favorites" = "Favorites"; +"favorites_view.favorites" = "Favorites"; // FavoriteCategory -"struct.user.favorite_category.default" = "Favorites %@"; -"struct.user.favorite_category.all" = "All"; +"favorite_category.default" = "Favorites %@"; +"favorite_category.all" = "All"; // MARK: SearchView -"search_view.title.search" = "Search"; -"search_view.section.title.recently_searched" = "Recently searched"; -"search_view.section.title.recently_seen" = "Recently seen"; -"search_view.section.title.quick_search" = "Quick search"; +"search_view.search" = "Search"; +"search_view.recently_searched" = "Recently searched"; +"search_view.recently_seen" = "Recently seen"; +"search_view.quick_search" = "Quick search"; // Searchable -"searchable.prompt.filter" = "Filter"; -"searchable.title.matches_count" = "Found %d matches."; +"searchable.filter" = "Filter"; +"searchable.matches_count" = "Found %d matches."; // MARK: QuickSearchView -"quick_search_view.title.quick_search" = "Quick search"; -"quick_search_view.title.edit_word" = "Edit word"; -"quick_search_view.title.new_word" = "New word"; -"quick_search_view.title.content" = "Content"; -"quick_search_view.title.name" = "Name"; -"quick_search_view.placeholder.optional" = "Optional"; +"quick_search_view.quick_search" = "Quick search"; +"quick_search_view.edit_word" = "Edit word"; +"quick_search_view.new_word" = "New word"; +"quick_search_view.content" = "Content"; +"quick_search_view.name" = "Name"; +"quick_search_view.optional" = "Optional"; // MARK: SettingView -"setting_view.title.setting" = "Setting"; +"setting_view.setting" = "Setting"; // SettingStateRoute -"enum.setting_state_route.value.account" = "Account"; -"enum.setting_state_route.value.general" = "General"; -"enum.setting_state_route.value.appearance" = "Appearance"; -"enum.setting_state_route.value.reading" = "Reading"; -"enum.setting_state_route.value.download" = "Download"; -"enum.setting_state_route.value.laboratory" = "Laboratory"; -"enum.setting_state_route.value.about" = "About"; +"setting_state_route.account" = "Account"; +"setting_state_route.general" = "General"; +"setting_state_route.appearance" = "Appearance"; +"setting_state_route.reading" = "Reading"; +"setting_state_route.download" = "Download"; +"setting_state_route.laboratory" = "Laboratory"; +"setting_state_route.about" = "About"; // MARK: AccountSettingView -"account_setting_view.title.account" = "Account"; -"account_setting_view.title.shows_new_dawn_greeting" = "Shows new dawn greeting"; -"account_setting_view.button.login" = "Login"; -"account_setting_view.button.logout" = "Logout"; -"account_setting_view.button.account_configuration" = "Account configuration"; -"account_setting_view.button.tags_management" = "Manage tags subscription"; -"account_setting_view.button.copy_cookies" = "Copy cookies"; +"account_setting_view.account" = "Account"; +"account_setting_view.shows_new_dawn_greeting" = "Shows new dawn greeting"; +"account_setting_view.login" = "Login"; +"account_setting_view.account_configuration" = "Account configuration"; +"account_setting_view.tags_management" = "Manage tags subscription"; +"account_setting_view.copy_cookies" = "Copy cookies"; // CookieValue -"struct.cookie_value.localized_string.expired" = "Expired"; -"struct.cookie_value.localized_string.mystery" = "Rejected"; -"struct.cookie_value.localized_string.none" = "None"; +"cookie_value.expired" = "Expired"; +"cookie_value.mystery" = "Rejected"; +"cookie_value.none" = "None"; // MARK: LoginView -"login_view.title.login" = "Login"; -"login_view.title.username" = "Username"; -"login_view.title.password" = "Password"; +"login_view.login" = "Login"; +"login_view.username" = "Username"; +"login_view.password" = "Password"; // MARK: GeneralSettingView -"general_setting_view.title.general" = "General"; -"general_setting_view.title.language" = "Language"; -"general_setting_view.title.auto_lock" = "Auto-Lock"; -"general_setting_view.title.enables_tags_extension" = "Enables tags extension"; -"general_setting_view.title.translates_tags" = "Translates tags"; -"general_setting_view.title.shows_tags_search_suggestion" = "Shows tags search suggestion"; -"general_setting_view.title.shows_images_in_tags" = "Shows images in tags"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "Redirects links to the selected host"; -"general_setting_view.title.detects_links_from_clipboard" = "Detects links from the clipboard"; -"general_setting_view.title.background_blur_radius" = "Background blur radius"; -"general_setting_view.button.app_activity_logs" = "App activity logs"; -"general_setting_view.button.import_custom_translations" = "Import custom translations"; -"general_setting_view.button.remove_custom_translations" = "Remove custom translations"; -"general_setting_view.button.clear_image_caches" = "Clear image caches"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "Tags"; -"general_setting_view.section.title.navigation" = "Navigation"; -"general_setting_view.section.title.security" = "Security"; -"general_setting_view.section.title.caches" = "Caches"; +"general_setting_view.general" = "General"; +"general_setting_view.language" = "Language"; +"general_setting_view.auto_lock" = "Auto-Lock"; +"general_setting_view.enables_tags_extension" = "Enables tags extension"; +"general_setting_view.translates_tags" = "Translates tags"; +"general_setting_view.shows_tags_search_suggestion" = "Shows tags search suggestion"; +"general_setting_view.shows_images_in_tags" = "Shows images in tags"; +"general_setting_view.redirects_links_to_the_selected_host" = "Redirects links to the selected host"; +"general_setting_view.detects_links_from_clipboard" = "Detects links from the clipboard"; +"general_setting_view.background_blur_radius" = "Background blur radius"; +"general_setting_view.app_activity_logs" = "App activity logs"; +"general_setting_view.import_custom_translations" = "Import custom translations"; +"general_setting_view.remove_custom_translations" = "Remove custom translations"; +"general_setting_view.clear_image_caches" = "Clear image caches"; +"general_setting_view.default_language_description" = "N/A"; +"general_setting_view.tags" = "Tags"; +"general_setting_view.navigation" = "Navigation"; +"general_setting_view.security" = "Security"; +"general_setting_view.caches" = "Caches"; // AutoLockPolicy -"enum.auto_lock_policy.value.never" = "Never"; -"enum.auto_lock_policy.value.instantly" = "Instantly"; +"auto_lock_policy.never" = "Never"; +"auto_lock_policy.instantly" = "Instantly"; // MARK: AppActivityLogsView "app_activity_logs_view.title" = "App activity logs"; -"app_activity_logs_view.placeholder.no_logs" = "No logs found"; -"app_activity_logs_view.section.current" = "Current"; +"app_activity_logs_view.no_logs" = "No logs found"; +"app_activity_logs_view.current" = "Current"; "app_activity_logs_view.run" = "Run %@"; "app_activity_logs_view.more_logs" = "More logs"; "app_activity_logs_view.open_in_files" = "Open in Files"; @@ -242,810 +239,786 @@ "app_activity_logs_view.level.fault" = "Fault"; // MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "Appearance"; -"appearance_setting_view.title.theme" = "Theme"; -"appearance_setting_view.title.tint_color" = "Tint color"; -"appearance_setting_view.title.display_mode" = "Display mode"; -"appearance_setting_view.title.shows_tags_in_list" = "Shows tags in list"; -"appearance_setting_view.title.maximum_number_of_tags" = "Maximum number of tags"; -"appearance_setting_view.title.displays_japanese_title" = "Displays Japanese title"; -"appearance_setting_view.button.app_icon" = "App icon"; -"appearance_setting_view.menu.title.infite" = "Infite"; -"appearance_setting_view.section.title.list" = "List"; -"appearance_setting_view.section.title.gallery" = "Gallery"; +"appearance_setting_view.appearance" = "Appearance"; +"appearance_setting_view.theme" = "Theme"; +"appearance_setting_view.tint_color" = "Tint color"; +"appearance_setting_view.display_mode" = "Display mode"; +"appearance_setting_view.shows_tags_in_list" = "Shows tags in list"; +"appearance_setting_view.maximum_number_of_tags" = "Maximum number of tags"; +"appearance_setting_view.displays_japanese_title" = "Displays Japanese title"; +"appearance_setting_view.app_icon" = "App icon"; +"appearance_setting_view.infite" = "Infite"; +"appearance_setting_view.list" = "List"; +"appearance_setting_view.gallery" = "Gallery"; // PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "Automatic"; -"enum.preferred_color_scheme.value.light" = "Light"; -"enum.preferred_color_scheme.value.dark" = "Dark"; +"preferred_color_scheme.automatic" = "Automatic"; +"preferred_color_scheme.light" = "Light"; +"preferred_color_scheme.dark" = "Dark"; // AppIconType -"enum.app_icon_type.value.default" = "Default"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; +"app_icon_type.default" = "Default"; +"app_icon_type.ukiyoe" = "Ukiyo-e"; +"app_icon_type.developer" = "Developer"; +"app_icon_type.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; +"app_icon_type.not_my_president" = "NOT MY PRESIDENT"; // ListDisplayMode -"enum.list_display_mode.value.detail" = "Detail"; -"enum.list_display_mode.value.thumbnail" = "Thumbnail"; +"list_display_mode.detail" = "Detail"; +"list_display_mode.thumbnail" = "Thumbnail"; // MARK: AppIconView -"app_icon_view.title.app_icon" = "App icon"; +"app_icon_view.app_icon" = "App icon"; // MARK: reading_settingView -"reading_setting_view.title.reading" = "Reading"; -"reading_setting_view.title.direction" = "Direction"; -"reading_setting_view.title.preload_limit" = "Preload limit"; -"reading_setting_view.title.enables_landscape" = "Enables landscape"; -"reading_setting_view.title.separator_height" = "Separator height"; -"reading_setting_view.title.maximum_scale_factor" = "Maximum scale factor"; -"reading_setting_view.title.double_tap_scale_factor" = "Double tap scale factor"; -"reading_setting_view.section.title.appearance" = "Appearance"; +"reading_setting_view.reading" = "Reading"; +"reading_setting_view.direction" = "Direction"; +"reading_setting_view.preload_limit" = "Preload limit"; +"reading_setting_view.enables_landscape" = "Enables landscape"; +"reading_setting_view.separator_height" = "Separator height"; +"reading_setting_view.maximum_scale_factor" = "Maximum scale factor"; +"reading_setting_view.double_tap_scale_factor" = "Double tap scale factor"; +"reading_setting_view.appearance" = "Appearance"; // ReadingDirection -"enum.reading_direction.value.vertical" = "Vertical"; -"enum.reading_direction.value.right_to_left" = "Right-to-left"; -"enum.reading_direction.value.left_to_right" = "Left-to-right"; +"reading_direction.vertical" = "Vertical"; +"reading_direction.right_to_left" = "Right-to-left"; +"reading_direction.left_to_right" = "Left-to-right"; // MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "Laboratory"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "Bypasses SNI Filtering"; +"laboratory_setting_view.laboratory" = "Laboratory"; +"laboratory_setting_view.bypasses_SNI_filtering" = "Bypasses SNI Filtering"; // MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "Website"; -"about_view.button.altStore_source" = "AltStore source"; -"about_view.title.version" = "Version"; -"about_view.section.title.special_thanks" = "Special thanks"; -"about_view.section.title.code_level_contributors" = "Code-level contributors"; -"about_view.section.title.translation_contributors" = "Translation contributors"; -"about_view.section.title.acknowledgements" = "Acknowledgements"; +"about_view.ehPanda" = "EhPanda"; +"about_view.website" = "Website"; +"about_view.altStore_source" = "AltStore source"; +"about_view.version" = "Version"; +"about_view.special_thanks" = "Special thanks"; +"about_view.code_level_contributors" = "Code-level contributors"; +"about_view.translation_contributors" = "Translation contributors"; +"about_view.acknowledgements" = "Acknowledgements"; // MARK: DetailView -"detail_view.button.download_login" = "LOG IN"; -"detail_view.button.download_get" = "GET"; -"detail_view.button.download_wait" = "WAIT"; -"detail_view.button.download_done" = "DONE"; -"detail_view.button.download_update" = "UPDATE"; -"detail_view.button.download_retry" = "RETRY"; -"detail_view.button.download_repair" = "REPAIR"; -"detail_view.button.read" = "Read"; -"detail_view.button.post_comment" = "Post comment"; -"detail_view.accessibility.download_button.login" = "Log in to download"; -"detail_view.accessibility.download_button.download" = "Download"; -"detail_view.accessibility.download_button.queued" = "Queued"; -"detail_view.accessibility.download_button.downloading" = "Downloading %d of %d"; -"detail_view.accessibility.download_button.downloaded" = "Delete downloaded gallery"; -"detail_view.accessibility.download_button.update" = "Update download"; -"detail_view.accessibility.download_button.retry" = "Retry download"; -"detail_view.accessibility.download_button.repair" = "Repair download"; -"detail_view.accessibility.download_button.preparing" = "Preparing download"; -"detail_view.accessibility.download_button.pause_action" = "Pause download"; -"detail_view.accessibility.download_button.paused" = "Resume download. Paused at %d of %d"; -"detail_view.accessibility.download_button.partial" = "Retry download. %d of %d pages are already available."; -"detail_view.toolbar_item.button.archives" = "Archives"; -"detail_view.toolbar_item.button.torrents" = "Torrents"; -"detail_view.toolbar_item.button.share" = "Share"; -"detail_view.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "Withdraw vote"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "Favorited"; -"detail_view.description_section.title.language" = "Language"; -"detail_view.description_section.title.ratings" = "%@ Ratings"; -"detail_view.description_section.title.page_count" = "Page Count"; -"detail_view.description_section.title.file_size" = "File Size"; -"detail_view.description_section.description.favorited" = "Times"; -"detail_view.description_section.description.page_count" = "Pages"; -"detail_view.action_section.button.give_a_rating" = "Give a Rating"; -"detail_view.action_section.button.similar_gallery" = "Similar Gallery"; -"detail_view.section.title.previews" = "Previews"; -"detail_view.section.title.comments" = "Comments"; -"detail_view.dialog.title.delete_download" = "Delete Download?"; -"detail_view.dialog.title.repair_download" = "Repair Download?"; -"detail_view.dialog.title.update_download" = "Update Download?"; -"detail_view.dialog.title.redownload_gallery" = "Redownload Gallery?"; -"detail_view.dialog.message.delete_downloaded_gallery" = "This will remove the downloaded gallery from this device."; -"detail_view.dialog.message.repair_download" = "Repair the offline files for this gallery now?"; -"detail_view.dialog.message.update_download" = "Update this gallery to the newest online version now?"; -"detail_view.dialog.message.redownload_gallery" = "Start a fresh download for this gallery now?"; -"detail_view.dialog.button.repair" = "Repair"; -"detail_view.dialog.button.update" = "Update"; -"detail_view.dialog.button.redownload" = "Redownload"; -"detail_view.offline_notice.saved_details" = "Couldn't refresh online details. Showing saved details instead."; +"detail_view.read" = "Read"; +"detail_view.post_comment" = "Post comment"; +"detail_view.accessibility.login" = "Log in to download"; +"detail_view.accessibility.download" = "Download"; +"detail_view.accessibility.queued" = "Queued"; +"detail_view.accessibility.downloading" = "Downloading %d of %d"; +"detail_view.accessibility.downloaded" = "Delete downloaded gallery"; +"detail_view.accessibility.update" = "Update download"; +"detail_view.accessibility.retry" = "Retry download"; +"detail_view.accessibility.repair" = "Repair download"; +"detail_view.accessibility.preparing" = "Preparing download"; +"detail_view.accessibility.pause_action" = "Pause download"; +"detail_view.accessibility.paused" = "Resume download. Paused at %d of %d"; +"detail_view.accessibility.partial" = "Retry download. %d of %d pages are already available."; +"detail_view.archives" = "Archives"; +"detail_view.torrents" = "Torrents"; +"detail_view.share" = "Share"; +"detail_view.detail" = "Detail"; +"detail_view.withdraw_vote" = "Withdraw vote"; +"detail_view.vote_up" = "Vote up"; +"detail_view.vote_down" = "Vote down"; +"detail_view.favorited" = "Favorited"; +"detail_view.language" = "Language"; +"detail_view.ratings" = "%@ Ratings"; +"detail_view.page_count" = "Page Count"; +"detail_view.file_size" = "File Size"; +"detail_view.favorited_unit" = "Times"; +"detail_view.page_count_unit" = "Pages"; +"detail_view.give_a_rating" = "Give a Rating"; +"detail_view.similar_gallery" = "Similar Gallery"; +"detail_view.previews" = "Previews"; +"detail_view.comments" = "Comments"; +"detail_view.delete_download" = "Delete Download?"; +"detail_view.repair_download" = "Repair Download?"; +"detail_view.update_download" = "Update Download?"; +"detail_view.redownload_gallery" = "Redownload Gallery?"; +"detail_view.delete_downloaded_gallery" = "This will remove the downloaded gallery from this device."; +"detail_view.repair_download_description" = "Repair the offline files for this gallery now?"; +"detail_view.update_download_description" = "Update this gallery to the newest online version now?"; +"detail_view.redownload_gallery_description" = "Start a fresh download for this gallery now?"; +"detail_view.repair" = "Repair"; +"detail_view.update" = "Update"; +"detail_view.redownload" = "Redownload"; +"detail_view.saved_details" = "Couldn't refresh online details. Showing saved details instead."; // MARK: ArchivesView -"archives_view.title.archives" = "Archives"; -"archives_view.button.download_to_hath_client" = "Download To H@H Client"; +"archives_view.archives" = "Archives"; +"archives_view.download_to_hath_client" = "Download To H@H Client"; // HathArchive -"struct.hath_archive.price.free" = "Free"; -"struct.hath_archive.price.not_available" = "N/A"; +"hath_archive.free" = "Free"; // ArchiveResolution -"enum.archive_resolution.value.original" = "Original"; +"archive_resolution.original" = "Original"; // MARK: TorrentsView -"torrents_view.title.torrents" = "Torrents"; +"torrents_view.torrents" = "Torrents"; // MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "Gallery infos"; -"gallery_infos_view.title.id" = "ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "Title"; -"gallery_infos_view.title.japanese_title" = "Japanese title"; -"gallery_infos_view.title.gallery_URL" = "Gallery URL"; -"gallery_infos_view.title.cover_URL" = "Cover URL"; -"gallery_infos_view.title.archive_URL" = "Archive URL"; -"gallery_infos_view.title.torrent_URL" = "Torrent URL"; -"gallery_infos_view.title.parent_URL" = "Parent URL"; -"gallery_infos_view.title.category" = "Category"; -"gallery_infos_view.title.uploader" = "Uploader"; -"gallery_infos_view.title.posted_date" = "Posted date"; -"gallery_infos_view.title.visibility" = "Visibility"; -"gallery_infos_view.title.language" = "Language"; -"gallery_infos_view.title.page_count" = "Page count"; -"gallery_infos_view.title.file_size" = "File size"; -"gallery_infos_view.title.favorited_times" = "Favorited times"; -"gallery_infos_view.title.favorited" = "Favorited"; -"gallery_infos_view.title.rating_count" = "Rating count"; -"gallery_infos_view.title.average_rating" = "Average rating"; -"gallery_infos_view.title.my_rating" = "My rating"; -"gallery_infos_view.title.torrent_count" = "Torrent count"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "Yes"; -"gallery_infos_view.value.no" = "No"; +"gallery_infos_view.gallery_infos" = "Gallery infos"; +"gallery_infos_view.id" = "ID"; +"gallery_infos_view.token" = "Token"; +"gallery_infos_view.title" = "Title"; +"gallery_infos_view.japanese_title" = "Japanese title"; +"gallery_infos_view.gallery_URL" = "Gallery URL"; +"gallery_infos_view.cover_URL" = "Cover URL"; +"gallery_infos_view.archive_URL" = "Archive URL"; +"gallery_infos_view.torrent_URL" = "Torrent URL"; +"gallery_infos_view.parent_URL" = "Parent URL"; +"gallery_infos_view.category" = "Category"; +"gallery_infos_view.uploader" = "Uploader"; +"gallery_infos_view.posted_date" = "Posted date"; +"gallery_infos_view.visibility" = "Visibility"; +"gallery_infos_view.language" = "Language"; +"gallery_infos_view.page_count" = "Page count"; +"gallery_infos_view.file_size" = "File size"; +"gallery_infos_view.favorited_times" = "Favorited times"; +"gallery_infos_view.favorited" = "Favorited"; +"gallery_infos_view.rating_count" = "Rating count"; +"gallery_infos_view.average_rating" = "Average rating"; +"gallery_infos_view.my_rating" = "My rating"; +"gallery_infos_view.torrent_count" = "Torrent count"; +"gallery_infos_view.none" = "None"; +"gallery_infos_view.yes" = "Yes"; +"gallery_infos_view.no" = "No"; // GalleryVisibility -"enum.gallery_visibility.value.yes" = "Yes"; -"enum.gallery_visibility.value.no" = "No (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "Expunged"; +"gallery_visibility.yes" = "Yes"; +"gallery_visibility.no" = "No (%@)"; +"gallery_visibility.expunged" = "Expunged"; // MARK: TagDetailView -"tag_detail_view.section.title.images" = "Images"; -"tag_detail_view.section.title.links" = "Links"; +"tag_detail_view.images" = "Images"; +"tag_detail_view.links" = "Links"; // MARK: DownloadsView -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "Downloads"; -"downloads_view.search.prompt.downloads" = "Search downloads"; -"downloads_view.dialog.title.delete_download" = "Delete Download?"; -"downloads_view.dialog.message.delete_active_download" = "This will cancel the current download and remove it from this device."; -"downloads_view.dialog.message.delete_downloaded_gallery" = "This will remove the downloaded gallery from this device."; -"downloads_view.swipe.button.pages" = "Pages"; -"downloads_view.swipe.button.update" = "Update"; -"downloads_view.swipe.button.resume" = "Resume"; -"downloads_view.swipe.button.pause" = "Pause"; -"downloads_view.empty_state.downloads" = "Downloaded galleries will appear here."; -"downloads_view.empty_state.no_matching_filters" = "No downloads match the current filters."; -"downloads_view.button.clear_filters" = "Clear Filters"; -"downloads_view.button.validate_image_data" = "Validate Image Data"; -"downloads_view.inspector.section.actions" = "Actions"; -"downloads_view.inspector.section.pages" = "Pages"; -"downloads_view.inspector.button.retry_failed_pages" = "Retry Failed Pages"; -"downloads_view.inspector.button.validating_image_data" = "Validating Image Data..."; -"downloads_view.inspector.button.update_download" = "Update Download"; -"downloads_view.inspector.toast.image_data_valid" = "Image data is valid"; -"downloads_view.inspector.toast.image_data_unavailable" = "Image data could not be validated."; -"downloads_view.inspector.title.download_status" = "Download Status"; -"downloads_view.inspector.page.pending" = "Pending"; -"downloads_view.inspector.page.tap_to_retry" = "Tap to retry this page"; -"downloads_view.inspector.page.title" = "Page %d"; -"downloads_view.inspector.page.none" = "No pages"; -"downloads_view.inspector.status.pending" = "Pending"; -"downloads_view.inspector.status.downloaded" = "Downloaded"; -"downloads_view.inspector.status.failed" = "Failed"; +"download_folder_filter.all" = "All"; +"detail_view.manage_folders" = "Manage Folders"; +"detail_view.create_default_folder" = "Create Default Folder"; +"detail_view.no_folders" = "No folders yet"; +"downloads_view.manage_folders" = "Manage Folders"; +"downloads_view.move_to_folder" = "Move to Folder"; +"downloads_view.move" = "Move"; +"folder_manager_view.folders" = "Folders"; +"folder_manager_view.folder_name" = "Folder name"; +"folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_folders" = "Folders you create will appear here."; +"downloads_view.downloads" = "Downloads"; +"downloads_view.search_downloads" = "Search downloads"; +"downloads_view.delete_download" = "Delete Download?"; +"downloads_view.delete_active_download" = "This will cancel the current download and remove it from this device."; +"downloads_view.delete_downloaded_gallery" = "This will remove the downloaded gallery from this device."; +"downloads_view.pages" = "Pages"; +"downloads_view.update" = "Update"; +"downloads_view.resume" = "Resume"; +"downloads_view.pause" = "Pause"; +"downloads_view.empty_downloads" = "Downloaded galleries will appear here."; +"downloads_view.no_matching_filters" = "No downloads match the current filters."; +"downloads_view.clear_filters" = "Clear Filters"; +"downloads_view.validate_image_data" = "Validate Image Data"; +"download_inspector_view.actions" = "Actions"; +"download_inspector_view.retry_failed_pages" = "Retry Failed Pages"; +"download_inspector_view.validating_image_data" = "Validating Image Data..."; +"download_inspector_view.image_data_valid" = "Image data is valid"; +"download_inspector_view.image_data_unavailable" = "Image data could not be validated."; +"download_inspector_view.download_status" = "Download Status"; +"download_inspector_view.pending" = "Pending"; +"download_inspector_view.none" = "No pages"; +"download_inspector_view.downloaded" = "Downloaded"; +"download_inspector_view.failed" = "Failed"; // MARK: DownloadSettingView "download_setting_view.title" = "Download"; -"download_setting_view.section.title.download_queue" = "Download Queue"; -"download_setting_view.section.title.network" = "Network"; -"download_setting_view.title.concurrent_image_downloads" = "Concurrent image downloads"; -"download_setting_view.title.retry_failed_pages_automatically" = "Retry failed pages automatically"; -"download_setting_view.title.allow_cellular_downloads" = "Allow cellular downloads"; -"download_setting_view.footer.network" = "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder."; +"download_setting_view.network" = "Network"; +"download_setting_view.concurrent_image_downloads" = "Concurrent image downloads"; +"download_setting_view.retry_failed_pages_automatically" = "Retry failed pages automatically"; +"download_setting_view.allow_cellular_downloads" = "Allow cellular downloads"; +"download_setting_view.network_description" = "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder."; // MARK: CommentsView -"comments_view.title.comments" = "Comments"; +"comments_view.comments" = "Comments"; // MARK: PostCommentView -"post_comment_view.title.post_comment" = "Post comment"; -"post_comment_view.title.edit_comment" = "Edit comment"; +"post_comment_view.post_comment" = "Post comment"; +"post_comment_view.edit_comment" = "Edit comment"; // MARK: PreviewsView -"previews_view.title.previews" = "Previews"; +"previews_view.previews" = "Previews"; // MARK: ReadingView -"reading_view.context_menu.button.reload" = "Reload"; -"reading_view.context_menu.button.copy" = "Copy"; -"reading_view.context_menu.button.save" = "Save"; -"reading_view.context_menu.button.save_original" = "Save original"; -"reading_view.context_menu.button.share" = "Share"; -"reading_view.toolbar_item.title.auto_play" = "Auto-Play"; -"reading_view.toolbar_item.title.dual_page_mode" = "Dual-Page mode"; -"reading_view.toolbar_item.title.except_the_cover" = "Except the cover"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "Retry all failed images"; -"reading_view.toolbar_item.button.reload_all_images" = "Reload all images"; -"reading_view.toolbar_item.button.reading_setting" = "Reading setting"; +"reading_view.reload" = "Reload"; +"reading_view.copy" = "Copy"; +"reading_view.save" = "Save"; +"reading_view.save_original" = "Save original"; +"reading_view.share" = "Share"; +"reading_view.auto_play" = "Auto-Play"; +"reading_view.dual_page_mode" = "Dual-Page mode"; +"reading_view.except_the_cover" = "Except the cover"; +"reading_view.retry_all_failed_images" = "Retry all failed images"; +"reading_view.reload_all_images" = "Reload all images"; +"reading_view.reading_setting" = "Reading setting"; // AutoPlayPolicy -"enum.auto_play_policy.value.off" = "Off"; +"auto_play_policy.off" = "Off"; // MARK: DownloadBadge -"struct.download_badge.text.queued" = "Queued"; -"struct.download_badge.text.downloading" = "Downloading"; -"struct.download_badge.text.paused" = "Paused"; -"struct.download_badge.text.downloaded" = "Downloaded"; -"struct.download_badge.text.needs_attention" = "Needs Attention"; -"struct.download_badge.text.update_available" = "Update Available"; -"struct.download_badge.text.needs_repair" = "Needs Repair"; -"struct.download_badge.progress" = "%d/%d"; +"download_badge.queued" = "Queued"; +"download_badge.downloading" = "Downloading"; +"download_badge.paused" = "Paused"; +"download_badge.downloaded" = "Downloaded"; +"download_badge.needs_attention" = "Needs Attention"; +"download_badge.update_available" = "Update Available"; +"download_badge.progress" = "%d/%d"; // MARK: DownloadStore -"download_store.error.asset_unreadable" = "Asset file is unreadable: %@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "Download folder could not be resolved."; -"download_store.validation.download_folder_missing" = "Download folder is missing."; -"download_store.validation.manifest_missing" = "Manifest file is missing."; -"download_store.validation.manifest_corrupted" = "Manifest file is corrupted."; -"download_store.validation.downloaded_pages_incomplete" = "Downloaded pages are incomplete."; -"download_store.validation.cover_image_missing" = "Cover image is missing."; -"download_store.validation.page_missing" = "Page %d is missing."; -"download_store.validation.cover_image_corrupted" = "Cover image data is corrupted."; -"download_store.validation.page_image_corrupted" = "Page %d image data is corrupted."; +"download_store.asset_unreadable" = "Asset file is unreadable: %@"; +"download_store.invalid_folder_name" = "The folder name is invalid."; +"download_store.folder_already_exists" = "A folder with this name already exists."; +"download_store.folder_busy_downloading" = "The folder contains an active download."; +"download_store.download_busy" = "The download is currently active."; +"download_store.download_folder_missing" = "Download folder is missing."; +"download_store.manifest_missing" = "Manifest file is missing."; +"download_store.manifest_corrupted" = "Manifest file is corrupted."; +"download_store.cover_image_missing" = "Cover image is missing."; +"download_store.page_missing" = "Page %d is missing."; +"download_store.page_image_corrupted" = "Page %d image data is corrupted."; // MARK: FiltersView -"filters_view.title.filters" = "Filters"; -"filters_view.title.advanced_settings" = "Advanced settings"; -"filters_view.title.search_gallery_name" = "Search gallery name"; -"filters_view.title.search_gallery_tags" = "Search gallery tags"; -"filters_view.title.search_gallery_description" = "Search gallery description"; -"filters_view.title.search_torrent_filenames" = "Search torrent filenames"; -"filters_view.title.only_show_galleries_with_torrents" = "Only show galleries with torrents"; -"filters_view.title.search_low_power_tags" = "Search Low-Power tags"; -"filters_view.title.search_downvoted_tags" = "Search downvoted tags"; -"filters_view.title.search_expunged_galleries" = "Search expunged galleries"; -"filters_view.title.set_minimum_rating" = "Set minimum rating"; -"filters_view.title.minimum_rating" = "Minimum rating"; -"filters_view.title.set_pages_range" = "Set pages range"; -"filters_view.title.pages_range" = "Pages range"; -"filters_view.title.disable_language_filter" = "Disable language filter"; -"filters_view.title.disable_uploader_filter" = "Disable uploader filter"; -"filters_view.title.disable_tags_filter" = "Disable tags filter"; -"filters_view.button.reset_filters" = "Reset filters"; -"filters_view.section.title.advanced" = "Advanced"; -"filters_view.section.title.default_filter" = "Default filter"; +"filters_view.filters" = "Filters"; +"filters_view.advanced_settings" = "Advanced settings"; +"filters_view.search_gallery_name" = "Search gallery name"; +"filters_view.search_gallery_tags" = "Search gallery tags"; +"filters_view.search_gallery_description" = "Search gallery description"; +"filters_view.search_torrent_filenames" = "Search torrent filenames"; +"filters_view.only_show_galleries_with_torrents" = "Only show galleries with torrents"; +"filters_view.search_low_power_tags" = "Search Low-Power tags"; +"filters_view.search_downvoted_tags" = "Search downvoted tags"; +"filters_view.search_expunged_galleries" = "Search expunged galleries"; +"filters_view.set_minimum_rating" = "Set minimum rating"; +"filters_view.minimum_rating" = "Minimum rating"; +"filters_view.set_pages_range" = "Set pages range"; +"filters_view.pages_range" = "Pages range"; +"filters_view.disable_language_filter" = "Disable language filter"; +"filters_view.disable_uploader_filter" = "Disable uploader filter"; +"filters_view.disable_tags_filter" = "Disable tags filter"; +"filters_view.reset_filters" = "Reset filters"; +"filters_view.advanced" = "Advanced"; +"filters_view.default_filter" = "Default filter"; // FilterRange -"enum.filter_range.value.search" = "Search"; -"enum.filter_range.value.global" = "Global"; -"enum.filter_range.value.watched" = "Watched"; +"filter_range.search" = "Search"; +"filter_range.global" = "Global"; +"filter_range.watched" = "Watched"; // MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ settings"; -"eh_setting_view.section.title.profile_settings" = "Profile Settings"; -"eh_setting_view.title.selected_profile" = "Selected profile"; -"eh_setting_view.button.set_as_default" = "Set as default"; -"eh_setting_view.button.delete_profile" = "Delete profile"; -"eh_setting_view.button.rename" = "Rename"; -"eh_setting_view.button.create_new" = "Create new"; -"eh_setting_view.toolbar_item.button.done" = "Done"; - -"eh_setting_view.section.title.image_load_settings" = "Image Load Settings"; -"eh_setting_view.title.load_images_through_the_hath_network" = "Load images through the Hath network"; -"eh_setting_view.title.browsing_country" = "Browsing country"; -"eh_setting_view.description.browsing_country" = "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below."; +"eh_setting_view.host_settings" = "%@ settings"; +"eh_setting_view.profile_settings" = "Profile Settings"; +"eh_setting_view.selected_profile" = "Selected profile"; +"eh_setting_view.set_as_default" = "Set as default"; +"eh_setting_view.delete_profile" = "Delete profile"; +"eh_setting_view.rename" = "Rename"; +"eh_setting_view.create_new" = "Create new"; +"eh_setting_view.done" = "Done"; + +"eh_setting_view.image_load_settings" = "Image Load Settings"; +"eh_setting_view.load_images_through_the_hath_network" = "Load images through the Hath network"; +"eh_setting_view.browsing_country" = "Browsing country"; +"eh_setting_view.browsing_country_description" = "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below."; // EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "Any client"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "Default port clients only"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "No [Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "No [Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "Recommended."; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports."; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems."; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; - -"eh_setting_view.section.title.image_size_settings" = "Image Size Settings"; -"eh_setting_view.title.image_resolution" = "Image resolution"; -"eh_setting_view.description.image_resolution" = "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000."; -"eh_setting_view.title.image_size" = "Image size"; -"eh_setting_view.description.image_size" = "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)"; -"eh_setting_view.title.horizontal" = "Horizontal"; -"eh_setting_view.title.vertical" = "Vertical"; +"load_through_hath_setting.any_client" = "Any client"; +"load_through_hath_setting.default_port_only" = "Default port clients only"; +"load_through_hath_setting.modern_no" = "No [Modern/HTTPS]"; +"load_through_hath_setting.legacy_no" = "No [Legacy/HTTP]"; +"load_through_hath_setting.any_client_description" = "Recommended."; +"load_through_hath_setting.default_port_only_description" = "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports."; +"load_through_hath_setting.modern_no_description" = "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems."; +"load_through_hath_setting.legacy_no_description" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; + +"eh_setting_view.image_size_settings" = "Image Size Settings"; +"eh_setting_view.image_resolution" = "Image resolution"; +"eh_setting_view.image_resolution_description" = "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000."; +"eh_setting_view.image_size" = "Image size"; +"eh_setting_view.image_size_description" = "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)"; +"eh_setting_view.horizontal" = "Horizontal"; +"eh_setting_view.vertical" = "Vertical"; // EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "Auto"; +"image_resolution.auto" = "Auto"; -"eh_setting_view.section.title.gallery_name_display" = "Gallery Name Display"; -"eh_setting_view.title.gallery_name" = "Gallery name"; -"eh_setting_view.description.gallery_name" = "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?"; +"eh_setting_view.gallery_name_display" = "Gallery Name Display"; +"eh_setting_view.gallery_name" = "Gallery name"; +"eh_setting_view.gallery_name_description" = "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?"; // EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "Default Title"; -"enum.eh_setting.gallery_name.value.japanese" = "Japanese Title (if available)"; +"gallery_name.default" = "Default Title"; +"gallery_name.japanese" = "Japanese Title (if available)"; -"eh_setting_view.section.title.archiver_settings" = "Archiver Settings"; -"eh_setting_view.title.archiver_behavior" = "Archiver behavior"; -"eh_setting_view.description.archiver_behavior" = "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here."; +"eh_setting_view.archiver_settings" = "Archiver Settings"; +"eh_setting_view.archiver_behavior" = "Archiver behavior"; +"eh_setting_view.archiver_behavior_description" = "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here."; // EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "Manual Select, Manual Start (Default)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "Manual Select, Auto Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "Auto Select Original, Manual Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "Auto Select Original, Auto Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "Auto Select Resample, Manual Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "Auto Select Resample, Auto Start"; - -"eh_setting_view.section.title.front_page_settings" = "Front Page Settings"; -"eh_setting_view.title.display_mode" = "Display mode"; -"eh_setting_view.description.display_mode" = "Which display mode would you like to use on the front and search pages?"; -"eh_setting_view.section.title.show_search_range_indicator" = "Search Range Indicator"; -"eh_setting_view.title.show_search_range_indicator" = "Show search range indicator"; -"eh_setting_view.description.gallery_category" = "What categories would you like to show by default on the front page and in searches?"; +"eh_setting.archiver_behavior.manual_select_manual_start" = "Manual Select, Manual Start (Default)"; +"eh_setting.archiver_behavior.manual_select_auto_start" = "Manual Select, Auto Start"; +"eh_setting.archiver_behavior.auto_select_original_manual_start" = "Auto Select Original, Manual Start"; +"eh_setting.archiver_behavior.auto_select_original_auto_start" = "Auto Select Original, Auto Start"; +"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "Auto Select Resample, Manual Start"; +"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "Auto Select Resample, Auto Start"; + +"eh_setting_view.front_page_settings" = "Front Page Settings"; +"eh_setting_view.display_mode" = "Display mode"; +"eh_setting_view.display_mode_description" = "Which display mode would you like to use on the front and search pages?"; +"eh_setting_view.show_search_range_indicator" = "Search Range Indicator"; +"eh_setting_view.show_search_range_indicator_description" = "Show search range indicator"; +"eh_setting_view.gallery_category" = "What categories would you like to show by default on the front page and in searches?"; // EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "Compact"; -"enum.eh_setting.display_mode.value.thumbnail" = "Thumbnail"; -"enum.eh_setting.display_mode.value.extended" = "Extended"; -"enum.eh_setting.display_mode.value.minimal" = "Minimal"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "Optional UI Elements"; -"eh_setting_view.description.optional_UI_elements" = "Some historic UI elements are now disabled by default. You can enable those here."; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; - -"eh_setting_view.section.title.favorites" = "Favorites"; -"eh_setting_view.description.favorite_categories" = "Here you can choose and rename your favorite categories."; -"eh_setting_view.title.favorites_sort_order" = "Favorites sort order"; -"eh_setting_view.description.favorites_sort_order" = "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting."; +"display_mode.compact" = "Compact"; +"display_mode.thumbnail" = "Thumbnail"; +"display_mode.extended" = "Extended"; +"display_mode.minimal" = "Minimal"; +"display_mode.minimalPlus" = "Minimal+"; + +"eh_setting_view.optional_UI_elements" = "Optional UI Elements"; +"eh_setting_view.optional_UI_elements_description" = "Some historic UI elements are now disabled by default. You can enable those here."; +"eh_setting_view.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; + +"eh_setting_view.favorites" = "Favorites"; +"eh_setting_view.favorite_categories" = "Here you can choose and rename your favorite categories."; +"eh_setting_view.favorites_sort_order" = "Favorites sort order"; +"eh_setting_view.favorites_sort_order_description" = "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting."; // EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "By last gallery update time"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "By favorited time"; +"favorites_sort_order.last_update_time" = "By last gallery update time"; +"favorites_sort_order.favorited_time" = "By favorited time"; -"eh_setting_view.section.title.ratings" = "Ratings"; -"eh_setting_view.title.ratings_color" = "Ratings color"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; +"eh_setting_view.ratings" = "Ratings"; +"eh_setting_view.ratings_color" = "Ratings color"; +"eh_setting_view.ratings_color_prompt" = "RRGGB"; +"eh_setting_view.ratings_color_description" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; -"eh_setting_view.section.title.tag_filtering_threshold" = "Tag Filtering Threshold"; -"eh_setting_view.title.tag_filtering_threshold" = "Tag Filtering Threshold"; -"eh_setting_view.description.tag_filtering_threshold" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; +"eh_setting_view.tag_filtering_threshold" = "Tag Filtering Threshold"; +"eh_setting_view.tag_filtering_threshold_description" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; -"eh_setting_view.section.title.tag_watching_threshold" = "Tag Watching Threshold"; -"eh_setting_view.title.tag_watching_threshold" = "Tag Watching Threshold"; -"eh_setting_view.description.tag_watching_threshold" = "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999."; +"eh_setting_view.tag_watching_threshold" = "Tag Watching Threshold"; +"eh_setting_view.tag_watching_threshold_description" = "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999."; -"eh_setting_view.section.title.filtered_removal_count" = "Show Filtered Removal Count"; -"eh_setting_view.description.filtered_removal_count" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; -"eh_setting_view.title.show_filtered_removal_count" = "Show filtered removal count"; +"eh_setting_viewfiltered_removal_count" = "Show Filtered Removal Count"; +"eh_setting_view.filtered_removal_count_description" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; +"eh_setting_view.show_filtered_removal_count" = "Show filtered removal count"; -"eh_setting_view.section.title.excluded_languages" = "Excluded Languages"; -"eh_setting_view.description.excluded_languages" = "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query."; +"eh_setting_view.excluded_languages" = "Excluded Languages"; +"eh_setting_view.excluded_languages_description" = "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query."; // EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "Original"; -"enum.eh_setting.excluded_languages_category.value.translated" = "Translated"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "Rewrite"; - -"eh_setting_view.section.title.excluded_uploaders" = "Excluded Uploaders"; -"eh_setting_view.description.excluded_uploaders" = "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query."; -"eh_setting_view.description.excluded_uploaders_count" = "You are currently using **%@ / %@** exclusion slots."; - -"eh_setting_view.section.title.search_result_count" = "Search Result Count"; -"eh_setting_view.title.result_count" = "Result count"; -"eh_setting_view.description.result_count" = "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)"; - -"eh_setting_view.section.title.thumbnail_settings" = "Thumbnail Settings"; -"eh_setting_view.title.thumbnail_load_timing" = "Thumbnail load timing"; -"eh_setting_view.description.thumbnail_load_timing" = "How would you like the mouse-over thumbnails on the front page to load when using List Mode?"; -"eh_setting_view.description.thumbnail_configuration" = "You can set a default thumbnail configuration for all galleries you visit."; -"eh_setting_view.title.thumbnail_size" = "Size"; -"eh_setting_view.title.thumbnail_row_count" = "Rows"; +"excluded_languages_category.original" = "Original"; +"excluded_languages_category.translated" = "Translated"; +"excluded_languages_category.rewrite" = "Rewrite"; + +"eh_setting_view.excluded_uploaders" = "Excluded Uploaders"; +"eh_setting_view.excluded_uploaders_description" = "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query."; +"eh_setting_view.excluded_uploaders_count" = "You are currently using **%@ / %@** exclusion slots."; + +"eh_setting_view.search_result_count" = "Search Result Count"; +"eh_setting_view.result_count" = "Result count"; +"eh_setting_view.result_count_description" = "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)"; + +"eh_setting_view.thumbnail_settings" = "Thumbnail Settings"; +"eh_setting_view.thumbnail_load_timing" = "Thumbnail load timing"; +"eh_setting_view.thumbnail_load_timing_description" = "How would you like the mouse-over thumbnails on the front page to load when using List Mode?"; +"eh_setting_view.thumbnail_configuration" = "You can set a default thumbnail configuration for all galleries you visit."; +"eh_setting_view.thumbnail_size" = "Size"; +"eh_setting_view.thumbnail_row_count" = "Rows"; // EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "On mouse-over"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "On page load"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "Pages load faster, but there may be a slight delay before a thumb appears."; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded."; +"thumbnail_load_timing.on_mouse_over" = "On mouse-over"; +"thumbnail_load_timing.on_page_load" = "On page load"; +"thumbnail_load_timing.on_mouse_over_description" = "Pages load faster, but there may be a slight delay before a thumb appears."; +"thumbnail_load_timing.on_page_load_description" = "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded."; // EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "Normal"; -"enum.eh_setting.thumbnail_size.value.large" = "Large"; -"enum.eh_setting.thumbnail_size.value.small" = "Small"; -"enum.eh_setting.thumbnail_size.value.auto" = "Auto"; - -"eh_setting_view.section.title.cover_scaling" = "Cover Scaling"; -"eh_setting_view.title.scale_factor" = "Scale factor"; -"eh_setting_view.description.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; - -"eh_setting_view.section.title.viewport_override" = "Viewport Override"; -"eh_setting_view.title.virtual_width" = "Virtual width"; -"eh_setting_view.description.virtual_width" = "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400."; - -"eh_setting_view.section.title.gallery_comments" = "Gallery Comments"; -"eh_setting_view.title.comments_sort_order" = "Comments sort order"; -"eh_setting_view.title.comments_votes_show_timing" = "Comment votes show timing"; +"thumbnail_size.normal" = "Normal"; +"thumbnail_size.large" = "Large"; +"thumbnail_size.small" = "Small"; +"thumbnail_size.auto" = "Auto"; + +"eh_setting_view.cover_scaling" = "Cover Scaling"; +"eh_setting_view.scale_factor" = "Scale factor"; +"eh_setting_view.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; + +"eh_setting_view.viewport_override" = "Viewport Override"; +"eh_setting_view.virtual_width" = "Virtual width"; +"eh_setting_view.virtual_width_description" = "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400."; + +"eh_setting_view.gallery_comments" = "Gallery Comments"; +"eh_setting_view.comments_sort_order" = "Comments sort order"; +"eh_setting_view.comments_votes_show_timing" = "Comment votes show timing"; // EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "Oldest comments first"; -"enum.eh_setting.comments_sort_order.value.recent" = "Recent comments first"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "By highest score"; +"comments_sort_order.oldest" = "Oldest comments first"; +"comments_sort_order.recent" = "Recent comments first"; +"comments_sort_order.highest_score" = "By highest score"; // EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "On score hover or click"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "Always"; +"comments_votes_show_timing.on_hover_or_click" = "On score hover or click"; +"comments_votes_show_timing.always" = "Always"; -"eh_setting_view.section.title.gallery_tags" = "Gallery Tags"; -"eh_setting_view.title.tags_sort_order" = "Tags sort order"; +"eh_setting_view.gallery_tags" = "Gallery Tags"; +"eh_setting_view.tags_sort_order" = "Tags sort order"; // EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "Alphabetical"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "By tag power"; +"tags_sort_order.alphabetical" = "Alphabetical"; +"tags_sort_order.tag_power" = "By tag power"; -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; +"eh_setting_view.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; +"eh_setting_view.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; -"eh_setting_view.section.title.hath_local_network_host" = "Hath Local Network Host"; -"eh_setting_view.title.ip_address_port" = "IP address:Port"; -"eh_setting_view.description.ip_address_port" = "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work."; +"eh_setting_view.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; +"eh_setting_view.use_original_images" = "Use original images"; -"eh_setting_view.section.title.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; -"eh_setting_view.title.use_original_images" = "Use original images"; - -"eh_setting_view.section.title.multi_page_viewer" = "Multi-Page Viewer"; -"eh_setting_view.title.use_multi_page_viewer" = "Use Multi-Page Viewer"; -"eh_setting_view.title.display_style" = "Display style"; -"eh_setting_view.title.show_thumbnail_pane" = "Show thumbnail pane"; +"eh_setting_view.multi_page_viewer" = "Multi-Page Viewer"; +"eh_setting_view.use_multi_page_viewer" = "Use Multi-Page Viewer"; +"eh_setting_view.display_style" = "Display style"; +"eh_setting_view.show_thumbnail_pane" = "Show thumbnail pane"; // EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "Align left, scale if overwidth"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "Align center, scale if overwidth"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "Align center, always scale"; +"multiple_page_viewer_style.align_left_scale_if_over_width" = "Align left, scale if overwidth"; +"multiple_page_viewer_style.align_center_scale_if_over_width" = "Align center, scale if overwidth"; +"multiple_page_viewer_style.align_center_always_scale" = "Align center, always scale"; // EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "None"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "Page Number Only"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "Page Number + Name"; +"gallery_page_numbering.none" = "None"; +"gallery_page_numbering.page_number_only" = "Page Number Only"; +"gallery_page_numbering.page_number_and_name" = "Page Number + Name"; // MARK: Category -"enum.category.value.doujinshi" = "Doujinshi"; -"enum.category.value.manga" = "Manga"; -"enum.category.value.artist_CG" = "Artist CG"; -"enum.category.value.game_CG" = "Game CG"; -"enum.category.value.western" = "Western"; -"enum.category.value.non_h" = "Non-H"; -"enum.category.value.image_set" = "Image Set"; -"enum.category.value.cosplay" = "Cosplay"; -"enum.category.value.asian_porn" = "Asian Porn"; -"enum.category.value.misc" = "Misc"; -"enum.category.value.private" = "Private"; +"category.doujinshi" = "Doujinshi"; +"category.manga" = "Manga"; +"category.artist_CG" = "Artist CG"; +"category.game_CG" = "Game CG"; +"category.western" = "Western"; +"category.non_h" = "Non-H"; +"category.image_set" = "Image Set"; +"category.cosplay" = "Cosplay"; +"category.asian_porn" = "Asian Porn"; +"category.misc" = "Misc"; +"category.private" = "Private"; // MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "Reclass"; -"enum.tag_namespace.value.language" = "Language"; -"enum.tag_namespace.value.parody" = "Parody"; -"enum.tag_namespace.value.character" = "Character"; -"enum.tag_namespace.value.group" = "Group"; -"enum.tag_namespace.value.artist" = "Artist"; -"enum.tag_namespace.value.male" = "Male"; -"enum.tag_namespace.value.female" = "Female"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "Other"; -"enum.tag_namespace.value.temp" = "Temp"; +"tag_namespace.reclass" = "Reclass"; +"tag_namespace.language" = "Language"; +"tag_namespace.parody" = "Parody"; +"tag_namespace.character" = "Character"; +"tag_namespace.group" = "Group"; +"tag_namespace.artist" = "Artist"; +"tag_namespace.male" = "Male"; +"tag_namespace.female" = "Female"; +"tag_namespace.mixed" = "Mixed"; +"tag_namespace.cosplayer" = "Cosplayer"; +"tag_namespace.other" = "Other"; +"tag_namespace.temp" = "Temp"; // MARK: Language -"enum.language.value.invalid" = "N/A"; -"enum.language.value.other" = "Other"; -"enum.language.value.afrikaans" = "Afrikaans"; -"enum.language.value.albanian" = "Albanian"; -"enum.language.value.arabic" = "Arabic"; -"enum.language.value.bengali" = "Bengali"; -"enum.language.value.bosnian" = "Bosnian"; -"enum.language.value.bulgarian" = "Bulgarian"; -"enum.language.value.burmese" = "Burmese"; -"enum.language.value.catalan" = "Catalan"; -"enum.language.value.cebuano" = "Cebuano"; -"enum.language.value.chinese" = "Chinese"; -"enum.language.value.croatian" = "Croatian"; -"enum.language.value.czech" = "Czech"; -"enum.language.value.danish" = "Danish"; -"enum.language.value.dutch" = "Dutch"; -"enum.language.value.english" = "English"; -"enum.language.value.esperanto" = "Esperanto"; -"enum.language.value.estonian" = "Estonian"; -"enum.language.value.finnish" = "Finnish"; -"enum.language.value.french" = "French"; -"enum.language.value.georgian" = "Georgian"; -"enum.language.value.german" = "German"; -"enum.language.value.greek" = "Greek"; -"enum.language.value.hebrew" = "Hebrew"; -"enum.language.value.hindi" = "Hindi"; -"enum.language.value.hmong" = "Hmong"; -"enum.language.value.hungarian" = "Hungarian"; -"enum.language.value.indonesian" = "Indonesian"; -"enum.language.value.italian" = "Italian"; -"enum.language.value.japanese" = "Japanese"; -"enum.language.value.kazakh" = "Kazakh"; -"enum.language.value.khmer" = "Khmer"; -"enum.language.value.korean" = "Korean"; -"enum.language.value.kurdish" = "Kurdish"; -"enum.language.value.lao" = "Lao"; -"enum.language.value.latin" = "Latin"; -"enum.language.value.mongolian" = "Mongolian"; -"enum.language.value.ndebele" = "Ndebele"; -"enum.language.value.nepali" = "Nepali"; -"enum.language.value.norwegian" = "Norwegian"; -"enum.language.value.oromo" = "Oromo"; -"enum.language.value.pashto" = "Pashto"; -"enum.language.value.persian" = "Persian"; -"enum.language.value.polish" = "Polish"; -"enum.language.value.portuguese" = "Portuguese"; -"enum.language.value.punjabi" = "Punjabi"; -"enum.language.value.romanian" = "Romanian"; -"enum.language.value.russian" = "Russian"; -"enum.language.value.sango" = "Sango"; -"enum.language.value.serbian" = "Serbian"; -"enum.language.value.shona" = "Shona"; -"enum.language.value.slovak" = "Slovak"; -"enum.language.value.slovenian" = "Slovenian"; -"enum.language.value.somali" = "Somali"; -"enum.language.value.spanish" = "Spanish"; -"enum.language.value.swahili" = "Swahili"; -"enum.language.value.swedish" = "Swedish"; -"enum.language.value.tagalog" = "Tagalog"; -"enum.language.value.thai" = "Thai"; -"enum.language.value.tigrinya" = "Tigrinya"; -"enum.language.value.turkish" = "Turkish"; -"enum.language.value.ukrainian" = "Ukrainian"; -"enum.language.value.urdu" = "Urdu"; -"enum.language.value.vietnamese" = "Vietnamese"; -"enum.language.value.zulu" = "Zulu"; +"language.invalid" = "N/A"; +"language.other" = "Other"; +"language.afrikaans" = "Afrikaans"; +"language.albanian" = "Albanian"; +"language.arabic" = "Arabic"; +"language.bengali" = "Bengali"; +"language.bosnian" = "Bosnian"; +"language.bulgarian" = "Bulgarian"; +"language.burmese" = "Burmese"; +"language.catalan" = "Catalan"; +"language.cebuano" = "Cebuano"; +"language.chinese" = "Chinese"; +"language.croatian" = "Croatian"; +"language.czech" = "Czech"; +"language.danish" = "Danish"; +"language.dutch" = "Dutch"; +"language.english" = "English"; +"language.esperanto" = "Esperanto"; +"language.estonian" = "Estonian"; +"language.finnish" = "Finnish"; +"language.french" = "French"; +"language.georgian" = "Georgian"; +"language.german" = "German"; +"language.greek" = "Greek"; +"language.hebrew" = "Hebrew"; +"language.hindi" = "Hindi"; +"language.hmong" = "Hmong"; +"language.hungarian" = "Hungarian"; +"language.indonesian" = "Indonesian"; +"language.italian" = "Italian"; +"language.japanese" = "Japanese"; +"language.kazakh" = "Kazakh"; +"language.khmer" = "Khmer"; +"language.korean" = "Korean"; +"language.kurdish" = "Kurdish"; +"language.lao" = "Lao"; +"language.latin" = "Latin"; +"language.mongolian" = "Mongolian"; +"language.ndebele" = "Ndebele"; +"language.nepali" = "Nepali"; +"language.norwegian" = "Norwegian"; +"language.oromo" = "Oromo"; +"language.pashto" = "Pashto"; +"language.persian" = "Persian"; +"language.polish" = "Polish"; +"language.portuguese" = "Portuguese"; +"language.punjabi" = "Punjabi"; +"language.romanian" = "Romanian"; +"language.russian" = "Russian"; +"language.sango" = "Sango"; +"language.serbian" = "Serbian"; +"language.shona" = "Shona"; +"language.slovak" = "Slovak"; +"language.slovenian" = "Slovenian"; +"language.somali" = "Somali"; +"language.spanish" = "Spanish"; +"language.swahili" = "Swahili"; +"language.swedish" = "Swedish"; +"language.tagalog" = "Tagalog"; +"language.thai" = "Thai"; +"language.tigrinya" = "Tigrinya"; +"language.turkish" = "Turkish"; +"language.ukrainian" = "Ukrainian"; +"language.urdu" = "Urdu"; +"language.vietnamese" = "Vietnamese"; +"language.zulu" = "Zulu"; // MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "Auto-Detect"; -"enum.browsing_country.name.afghanistan" = "Afghanistan"; -"enum.browsing_country.name.aland_islands" = "Aland Islands"; -"enum.browsing_country.name.albania" = "Albania"; -"enum.browsing_country.name.algeria" = "Algeria"; -"enum.browsing_country.name.american_samoa" = "American Samoa"; -"enum.browsing_country.name.andorra" = "Andorra"; -"enum.browsing_country.name.angola" = "Angola"; -"enum.browsing_country.name.anguilla" = "Anguilla"; -"enum.browsing_country.name.antarctica" = "Antarctica"; -"enum.browsing_country.name.antigua_and_barbuda" = "Antigua and Barbuda"; -"enum.browsing_country.name.argentina" = "Argentina"; -"enum.browsing_country.name.armenia" = "Armenia"; -"enum.browsing_country.name.aruba" = "Aruba"; -"enum.browsing_country.name.asia_pacific_region" = "Asia-Pacific Region"; -"enum.browsing_country.name.australia" = "Australia"; -"enum.browsing_country.name.austria" = "Austria"; -"enum.browsing_country.name.azerbaijan" = "Azerbaijan"; -"enum.browsing_country.name.bahamas" = "Bahamas"; -"enum.browsing_country.name.bahrain" = "Bahrain"; -"enum.browsing_country.name.bangladesh" = "Bangladesh"; -"enum.browsing_country.name.barbados" = "Barbados"; -"enum.browsing_country.name.belarus" = "Belarus"; -"enum.browsing_country.name.belgium" = "Belgium"; -"enum.browsing_country.name.belize" = "Belize"; -"enum.browsing_country.name.benin" = "Benin"; -"enum.browsing_country.name.bermuda" = "Bermuda"; -"enum.browsing_country.name.bhutan" = "Bhutan"; -"enum.browsing_country.name.bolivia" = "Bolivia"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "Bonaire Saint Eustatius and Saba"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "Bosnia and Herzegovina"; -"enum.browsing_country.name.botswana" = "Botswana"; -"enum.browsing_country.name.bouvet_island" = "Bouvet Island"; -"enum.browsing_country.name.brazil" = "Brazil"; -"enum.browsing_country.name.british_indian_ocean_territory" = "British Indian Ocean Territory"; -"enum.browsing_country.name.brunei_darussalam" = "Brunei Darussalam"; -"enum.browsing_country.name.bulgaria" = "Bulgaria"; -"enum.browsing_country.name.burkina_faso" = "Burkina Faso"; -"enum.browsing_country.name.burundi" = "Burundi"; -"enum.browsing_country.name.cambodia" = "Cambodia"; -"enum.browsing_country.name.cameroon" = "Cameroon"; -"enum.browsing_country.name.canada" = "Canada"; -"enum.browsing_country.name.cape_verde" = "Cape Verde"; -"enum.browsing_country.name.cayman_islands" = "Cayman Islands"; -"enum.browsing_country.name.central_african_republic" = "Central African Republic"; -"enum.browsing_country.name.chad" = "Chad"; -"enum.browsing_country.name.chile" = "Chile"; -"enum.browsing_country.name.china" = "China"; -"enum.browsing_country.name.christmas_island" = "Christmas Island"; -"enum.browsing_country.name.cocos_islands" = "Cocos Islands"; -"enum.browsing_country.name.colombia" = "Colombia"; -"enum.browsing_country.name.comoros" = "Comoros"; -"enum.browsing_country.name.congo" = "Congo"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "The Democratic Republic of the Congo"; -"enum.browsing_country.name.cook_islands" = "Cook Islands"; -"enum.browsing_country.name.costa_rica" = "Costa Rica"; -"enum.browsing_country.name.cote_d_ivoire" = "Cote D'Ivoire"; -"enum.browsing_country.name.croatia" = "Croatia"; -"enum.browsing_country.name.cuba" = "Cuba"; -"enum.browsing_country.name.curacao" = "Curacao"; -"enum.browsing_country.name.cyprus" = "Cyprus"; -"enum.browsing_country.name.czech_republic" = "Czech Republic"; -"enum.browsing_country.name.denmark" = "Denmark"; -"enum.browsing_country.name.djibouti" = "Djibouti"; -"enum.browsing_country.name.dominica" = "Dominica"; -"enum.browsing_country.name.dominican_republic" = "Dominican Republic"; -"enum.browsing_country.name.ecuador" = "Ecuador"; -"enum.browsing_country.name.egypt" = "Egypt"; -"enum.browsing_country.name.el_salvador" = "El Salvador"; -"enum.browsing_country.name.equatorial_guinea" = "Equatorial Guinea"; -"enum.browsing_country.name.eritrea" = "Eritrea"; -"enum.browsing_country.name.estonia" = "Estonia"; -"enum.browsing_country.name.ethiopia" = "Ethiopia"; -"enum.browsing_country.name.europe" = "Europe"; -"enum.browsing_country.name.falkland_islands" = "Falkland Islands"; -"enum.browsing_country.name.faroe_islands" = "Faroe Islands"; -"enum.browsing_country.name.fiji" = "Fiji"; -"enum.browsing_country.name.finland" = "Finland"; -"enum.browsing_country.name.france" = "France"; -"enum.browsing_country.name.french_guiana" = "French Guiana"; -"enum.browsing_country.name.french_polynesia" = "French Polynesia"; -"enum.browsing_country.name.french_southern_territories" = "French Southern Territories"; -"enum.browsing_country.name.gabon" = "Gabon"; -"enum.browsing_country.name.gambia" = "Gambia"; -"enum.browsing_country.name.georgia" = "Georgia"; -"enum.browsing_country.name.germany" = "Germany"; -"enum.browsing_country.name.ghana" = "Ghana"; -"enum.browsing_country.name.gibraltar" = "Gibraltar"; -"enum.browsing_country.name.greece" = "Greece"; -"enum.browsing_country.name.greenland" = "Greenland"; -"enum.browsing_country.name.grenada" = "Grenada"; -"enum.browsing_country.name.guadeloupe" = "Guadeloupe"; -"enum.browsing_country.name.guam" = "Guam"; -"enum.browsing_country.name.guatemala" = "Guatemala"; -"enum.browsing_country.name.guernsey" = "Guernsey"; -"enum.browsing_country.name.guinea" = "Guinea"; -"enum.browsing_country.name.guinea_bissau" = "Guinea-Bissau"; -"enum.browsing_country.name.guyana" = "Guyana"; -"enum.browsing_country.name.haiti" = "Haiti"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "Heard Island and McDonald Islands"; -"enum.browsing_country.name.vatican_city_state" = "Vatican City State"; -"enum.browsing_country.name.honduras" = "Honduras"; -"enum.browsing_country.name.hong_kong" = "Hong Kong"; -"enum.browsing_country.name.hungary" = "Hungary"; -"enum.browsing_country.name.iceland" = "Iceland"; -"enum.browsing_country.name.india" = "India"; -"enum.browsing_country.name.indonesia" = "Indonesia"; -"enum.browsing_country.name.iran" = "Iran"; -"enum.browsing_country.name.iraq" = "Iraq"; -"enum.browsing_country.name.ireland" = "Ireland"; -"enum.browsing_country.name.isle_of_man" = "Isle of Man"; -"enum.browsing_country.name.israel" = "Israel"; -"enum.browsing_country.name.italy" = "Italy"; -"enum.browsing_country.name.jamaica" = "Jamaica"; -"enum.browsing_country.name.japan" = "Japan"; -"enum.browsing_country.name.jersey" = "Jersey"; -"enum.browsing_country.name.jordan" = "Jordan"; -"enum.browsing_country.name.kazakhstan" = "Kazakhstan"; -"enum.browsing_country.name.kenya" = "Kenya"; -"enum.browsing_country.name.kiribati" = "Kiribati"; -"enum.browsing_country.name.kuwait" = "Kuwait"; -"enum.browsing_country.name.kyrgyzstan" = "Kyrgyzstan"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "Lao People's Democratic Republic"; -"enum.browsing_country.name.latvia" = "Latvia"; -"enum.browsing_country.name.lebanon" = "Lebanon"; -"enum.browsing_country.name.lesotho" = "Lesotho"; -"enum.browsing_country.name.liberia" = "Liberia"; -"enum.browsing_country.name.libya" = "Libya"; -"enum.browsing_country.name.liechtenstein" = "Liechtenstein"; -"enum.browsing_country.name.lithuania" = "Lithuania"; -"enum.browsing_country.name.luxembourg" = "Luxembourg"; -"enum.browsing_country.name.macau" = "Macau"; -"enum.browsing_country.name.macedonia" = "Macedonia"; -"enum.browsing_country.name.madagascar" = "Madagascar"; -"enum.browsing_country.name.malawi" = "Malawi"; -"enum.browsing_country.name.malaysia" = "Malaysia"; -"enum.browsing_country.name.maldives" = "Maldives"; -"enum.browsing_country.name.mali" = "Mali"; -"enum.browsing_country.name.malta" = "Malta"; -"enum.browsing_country.name.marshall_islands" = "Marshall Islands"; -"enum.browsing_country.name.martinique" = "Martinique"; -"enum.browsing_country.name.mauritania" = "Mauritania"; -"enum.browsing_country.name.mauritius" = "Mauritius"; -"enum.browsing_country.name.mayotte" = "Mayotte"; -"enum.browsing_country.name.mexico" = "Mexico"; -"enum.browsing_country.name.micronesia" = "Micronesia"; -"enum.browsing_country.name.moldova" = "Moldova"; -"enum.browsing_country.name.monaco" = "Monaco"; -"enum.browsing_country.name.mongolia" = "Mongolia"; -"enum.browsing_country.name.montenegro" = "Montenegro"; -"enum.browsing_country.name.montserrat" = "Montserrat"; -"enum.browsing_country.name.morocco" = "Morocco"; -"enum.browsing_country.name.mozambique" = "Mozambique"; -"enum.browsing_country.name.myanmar" = "Myanmar"; -"enum.browsing_country.name.namibia" = "Namibia"; -"enum.browsing_country.name.nauru" = "Nauru"; -"enum.browsing_country.name.nepal" = "Nepal"; -"enum.browsing_country.name.netherlands" = "Netherlands"; -"enum.browsing_country.name.new_caledonia" = "New Caledonia"; -"enum.browsing_country.name.new_zealand" = "New Zealand"; -"enum.browsing_country.name.nicaragua" = "Nicaragua"; -"enum.browsing_country.name.niger" = "Niger"; -"enum.browsing_country.name.nigeria" = "Nigeria"; -"enum.browsing_country.name.niue" = "Niue"; -"enum.browsing_country.name.norfolk_island" = "Norfolk Island"; -"enum.browsing_country.name.north_korea" = "North Korea"; -"enum.browsing_country.name.northern_mariana_islands" = "Northern Mariana Islands"; -"enum.browsing_country.name.norway" = "Norway"; -"enum.browsing_country.name.oman" = "Oman"; -"enum.browsing_country.name.pakistan" = "Pakistan"; -"enum.browsing_country.name.palau" = "Palau"; -"enum.browsing_country.name.palestinian_territory" = "Palestinian Territory"; -"enum.browsing_country.name.panama" = "Panama"; -"enum.browsing_country.name.papua_new_guinea" = "Papua New Guinea"; -"enum.browsing_country.name.paraguay" = "Paraguay"; -"enum.browsing_country.name.peru" = "Peru"; -"enum.browsing_country.name.philippines" = "Philippines"; -"enum.browsing_country.name.pitcairn_islands" = "Pitcairn Islands"; -"enum.browsing_country.name.poland" = "Poland"; -"enum.browsing_country.name.portugal" = "Portugal"; -"enum.browsing_country.name.puerto_rico" = "Puerto Rico"; -"enum.browsing_country.name.qatar" = "Qatar"; -"enum.browsing_country.name.reunion" = "Reunion"; -"enum.browsing_country.name.romania" = "Romania"; -"enum.browsing_country.name.russian_federation" = "Russian Federation"; -"enum.browsing_country.name.rwanda" = "Rwanda"; -"enum.browsing_country.name.saint_barthelemy" = "Saint Barthelemy"; -"enum.browsing_country.name.saint_helena" = "Saint Helena"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "Saint Kitts and Nevis"; -"enum.browsing_country.name.saint_lucia" = "Saint Lucia"; -"enum.browsing_country.name.saint_martin" = "Saint Martin"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "Saint Pierre and Miquelon"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "Saint Vincent and the Grenadines"; -"enum.browsing_country.name.samoa" = "Samoa"; -"enum.browsing_country.name.san_marino" = "San Marino"; -"enum.browsing_country.name.sao_tome_and_principe" = "Sao Tome and Principe"; -"enum.browsing_country.name.saudi_arabia" = "Saudi Arabia"; -"enum.browsing_country.name.senegal" = "Senegal"; -"enum.browsing_country.name.serbia" = "Serbia"; -"enum.browsing_country.name.seychelles" = "Seychelles"; -"enum.browsing_country.name.sierra_leone" = "Sierra Leone"; -"enum.browsing_country.name.singapore" = "Singapore"; -"enum.browsing_country.name.sint_maarten" = "Sint Maarten"; -"enum.browsing_country.name.slovakia" = "Slovakia"; -"enum.browsing_country.name.slovenia" = "Slovenia"; -"enum.browsing_country.name.solomon_islands" = "Solomon Islands"; -"enum.browsing_country.name.somalia" = "Somalia"; -"enum.browsing_country.name.south_africa" = "South Africa"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "South Georgia and the South Sandwich Islands"; -"enum.browsing_country.name.south_korea" = "South Korea"; -"enum.browsing_country.name.south_sudan" = "South Sudan"; -"enum.browsing_country.name.spain" = "Spain"; -"enum.browsing_country.name.sri_lanka" = "Sri Lanka"; -"enum.browsing_country.name.sudan" = "Sudan"; -"enum.browsing_country.name.suriname" = "Suriname"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "Svalbard and Jan Mayen"; -"enum.browsing_country.name.swaziland" = "Swaziland"; -"enum.browsing_country.name.sweden" = "Sweden"; -"enum.browsing_country.name.switzerland" = "Switzerland"; -"enum.browsing_country.name.syrian_arab_republic" = "Syrian Arab Republic"; -"enum.browsing_country.name.taiwan" = "Taiwan"; -"enum.browsing_country.name.tajikistan" = "Tajikistan"; -"enum.browsing_country.name.tanzania" = "Tanzania"; -"enum.browsing_country.name.thailand" = "Thailand"; -"enum.browsing_country.name.timor_leste" = "Timor-Leste"; -"enum.browsing_country.name.togo" = "Togo"; -"enum.browsing_country.name.tokelau" = "Tokelau"; -"enum.browsing_country.name.tonga" = "Tonga"; -"enum.browsing_country.name.trinidad_and_tobago" = "Trinidad and Tobago"; -"enum.browsing_country.name.tunisia" = "Tunisia"; -"enum.browsing_country.name.turkey" = "Turkey"; -"enum.browsing_country.name.turkmenistan" = "Turkmenistan"; -"enum.browsing_country.name.turks_and_caicos_islands" = "Turks and Caicos Islands"; -"enum.browsing_country.name.tuvalu" = "Tuvalu"; -"enum.browsing_country.name.uganda" = "Uganda"; -"enum.browsing_country.name.ukraine" = "Ukraine"; -"enum.browsing_country.name.united_arab_emirates" = "United Arab Emirates"; -"enum.browsing_country.name.united_kingdom" = "United Kingdom"; -"enum.browsing_country.name.united_states" = "United States"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "United States Minor Outlying Islands"; -"enum.browsing_country.name.uruguay" = "Uruguay"; -"enum.browsing_country.name.uzbekistan" = "Uzbekistan"; -"enum.browsing_country.name.vanuatu" = "Vanuatu"; -"enum.browsing_country.name.venezuela" = "Venezuela"; -"enum.browsing_country.name.vietnam" = "Vietnam"; -"enum.browsing_country.name.virgin_islands_british" = "British Virgin Islands"; -"enum.browsing_country.name.virgin_islands_US" = "U.S. Virgin Islands"; -"enum.browsing_country.name.wallis_and_futuna" = "Wallis and Futuna"; -"enum.browsing_country.name.western_sahara" = "Western Sahara"; -"enum.browsing_country.name.yemen" = "Yemen"; -"enum.browsing_country.name.zambia" = "Zambia"; -"enum.browsing_country.name.zimbabwe" = "Zimbabwe"; +"browsing_country.auto_detect" = "Auto-Detect"; +"browsing_country.afghanistan" = "Afghanistan"; +"browsing_country.aland_islands" = "Aland Islands"; +"browsing_country.albania" = "Albania"; +"browsing_country.algeria" = "Algeria"; +"browsing_country.american_samoa" = "American Samoa"; +"browsing_country.andorra" = "Andorra"; +"browsing_country.angola" = "Angola"; +"browsing_country.anguilla" = "Anguilla"; +"browsing_country.antarctica" = "Antarctica"; +"browsing_country.antigua_and_barbuda" = "Antigua and Barbuda"; +"browsing_country.argentina" = "Argentina"; +"browsing_country.armenia" = "Armenia"; +"browsing_country.aruba" = "Aruba"; +"browsing_country.asia_pacific_region" = "Asia-Pacific Region"; +"browsing_country.australia" = "Australia"; +"browsing_country.austria" = "Austria"; +"browsing_country.azerbaijan" = "Azerbaijan"; +"browsing_country.bahamas" = "Bahamas"; +"browsing_country.bahrain" = "Bahrain"; +"browsing_country.bangladesh" = "Bangladesh"; +"browsing_country.barbados" = "Barbados"; +"browsing_country.belarus" = "Belarus"; +"browsing_country.belgium" = "Belgium"; +"browsing_country.belize" = "Belize"; +"browsing_country.benin" = "Benin"; +"browsing_country.bermuda" = "Bermuda"; +"browsing_country.bhutan" = "Bhutan"; +"browsing_country.bolivia" = "Bolivia"; +"browsing_country.bonaire_saint_eustatius_and_saba" = "Bonaire Saint Eustatius and Saba"; +"browsing_country.bosnia_and_herzegovina" = "Bosnia and Herzegovina"; +"browsing_country.botswana" = "Botswana"; +"browsing_country.bouvet_island" = "Bouvet Island"; +"browsing_country.brazil" = "Brazil"; +"browsing_country.british_indian_ocean_territory" = "British Indian Ocean Territory"; +"browsing_country.brunei_darussalam" = "Brunei Darussalam"; +"browsing_country.bulgaria" = "Bulgaria"; +"browsing_country.burkina_faso" = "Burkina Faso"; +"browsing_country.burundi" = "Burundi"; +"browsing_country.cambodia" = "Cambodia"; +"browsing_country.cameroon" = "Cameroon"; +"browsing_country.canada" = "Canada"; +"browsing_country.cape_verde" = "Cape Verde"; +"browsing_country.cayman_islands" = "Cayman Islands"; +"browsing_country.central_african_republic" = "Central African Republic"; +"browsing_country.chad" = "Chad"; +"browsing_country.chile" = "Chile"; +"browsing_country.china" = "China"; +"browsing_country.christmas_island" = "Christmas Island"; +"browsing_country.cocos_islands" = "Cocos Islands"; +"browsing_country.colombia" = "Colombia"; +"browsing_country.comoros" = "Comoros"; +"browsing_country.congo" = "Congo"; +"browsing_country.the_democratic_republic_of_the_congo" = "The Democratic Republic of the Congo"; +"browsing_country.cook_islands" = "Cook Islands"; +"browsing_country.costa_rica" = "Costa Rica"; +"browsing_country.cote_d_ivoire" = "Cote D'Ivoire"; +"browsing_country.croatia" = "Croatia"; +"browsing_country.cuba" = "Cuba"; +"browsing_country.curacao" = "Curacao"; +"browsing_country.cyprus" = "Cyprus"; +"browsing_country.czech_republic" = "Czech Republic"; +"browsing_country.denmark" = "Denmark"; +"browsing_country.djibouti" = "Djibouti"; +"browsing_country.dominica" = "Dominica"; +"browsing_country.dominican_republic" = "Dominican Republic"; +"browsing_country.ecuador" = "Ecuador"; +"browsing_country.egypt" = "Egypt"; +"browsing_country.el_salvador" = "El Salvador"; +"browsing_country.equatorial_guinea" = "Equatorial Guinea"; +"browsing_country.eritrea" = "Eritrea"; +"browsing_country.estonia" = "Estonia"; +"browsing_country.ethiopia" = "Ethiopia"; +"browsing_country.europe" = "Europe"; +"browsing_country.falkland_islands" = "Falkland Islands"; +"browsing_country.faroe_islands" = "Faroe Islands"; +"browsing_country.fiji" = "Fiji"; +"browsing_country.finland" = "Finland"; +"browsing_country.france" = "France"; +"browsing_country.french_guiana" = "French Guiana"; +"browsing_country.french_polynesia" = "French Polynesia"; +"browsing_country.french_southern_territories" = "French Southern Territories"; +"browsing_country.gabon" = "Gabon"; +"browsing_country.gambia" = "Gambia"; +"browsing_country.georgia" = "Georgia"; +"browsing_country.germany" = "Germany"; +"browsing_country.ghana" = "Ghana"; +"browsing_country.gibraltar" = "Gibraltar"; +"browsing_country.greece" = "Greece"; +"browsing_country.greenland" = "Greenland"; +"browsing_country.grenada" = "Grenada"; +"browsing_country.guadeloupe" = "Guadeloupe"; +"browsing_country.guam" = "Guam"; +"browsing_country.guatemala" = "Guatemala"; +"browsing_country.guernsey" = "Guernsey"; +"browsing_country.guinea" = "Guinea"; +"browsing_country.guinea_bissau" = "Guinea-Bissau"; +"browsing_country.guyana" = "Guyana"; +"browsing_country.haiti" = "Haiti"; +"browsing_country.heard_island_and_mc_donald_islands" = "Heard Island and McDonald Islands"; +"browsing_country.vatican_city_state" = "Vatican City State"; +"browsing_country.honduras" = "Honduras"; +"browsing_country.hong_kong" = "Hong Kong"; +"browsing_country.hungary" = "Hungary"; +"browsing_country.iceland" = "Iceland"; +"browsing_country.india" = "India"; +"browsing_country.indonesia" = "Indonesia"; +"browsing_country.iran" = "Iran"; +"browsing_country.iraq" = "Iraq"; +"browsing_country.ireland" = "Ireland"; +"browsing_country.isle_of_man" = "Isle of Man"; +"browsing_country.israel" = "Israel"; +"browsing_country.italy" = "Italy"; +"browsing_country.jamaica" = "Jamaica"; +"browsing_country.japan" = "Japan"; +"browsing_country.jersey" = "Jersey"; +"browsing_country.jordan" = "Jordan"; +"browsing_country.kazakhstan" = "Kazakhstan"; +"browsing_country.kenya" = "Kenya"; +"browsing_country.kiribati" = "Kiribati"; +"browsing_country.kuwait" = "Kuwait"; +"browsing_country.kyrgyzstan" = "Kyrgyzstan"; +"browsing_country.lao_peoples_democratic_republic" = "Lao People's Democratic Republic"; +"browsing_country.latvia" = "Latvia"; +"browsing_country.lebanon" = "Lebanon"; +"browsing_country.lesotho" = "Lesotho"; +"browsing_country.liberia" = "Liberia"; +"browsing_country.libya" = "Libya"; +"browsing_country.liechtenstein" = "Liechtenstein"; +"browsing_country.lithuania" = "Lithuania"; +"browsing_country.luxembourg" = "Luxembourg"; +"browsing_country.macau" = "Macau"; +"browsing_country.macedonia" = "Macedonia"; +"browsing_country.madagascar" = "Madagascar"; +"browsing_country.malawi" = "Malawi"; +"browsing_country.malaysia" = "Malaysia"; +"browsing_country.maldives" = "Maldives"; +"browsing_country.mali" = "Mali"; +"browsing_country.malta" = "Malta"; +"browsing_country.marshall_islands" = "Marshall Islands"; +"browsing_country.martinique" = "Martinique"; +"browsing_country.mauritania" = "Mauritania"; +"browsing_country.mauritius" = "Mauritius"; +"browsing_country.mayotte" = "Mayotte"; +"browsing_country.mexico" = "Mexico"; +"browsing_country.micronesia" = "Micronesia"; +"browsing_country.moldova" = "Moldova"; +"browsing_country.monaco" = "Monaco"; +"browsing_country.mongolia" = "Mongolia"; +"browsing_country.montenegro" = "Montenegro"; +"browsing_country.montserrat" = "Montserrat"; +"browsing_country.morocco" = "Morocco"; +"browsing_country.mozambique" = "Mozambique"; +"browsing_country.myanmar" = "Myanmar"; +"browsing_country.namibia" = "Namibia"; +"browsing_country.nauru" = "Nauru"; +"browsing_country.nepal" = "Nepal"; +"browsing_country.netherlands" = "Netherlands"; +"browsing_country.new_caledonia" = "New Caledonia"; +"browsing_country.new_zealand" = "New Zealand"; +"browsing_country.nicaragua" = "Nicaragua"; +"browsing_country.niger" = "Niger"; +"browsing_country.nigeria" = "Nigeria"; +"browsing_country.niue" = "Niue"; +"browsing_country.norfolk_island" = "Norfolk Island"; +"browsing_country.north_korea" = "North Korea"; +"browsing_country.northern_mariana_islands" = "Northern Mariana Islands"; +"browsing_country.norway" = "Norway"; +"browsing_country.oman" = "Oman"; +"browsing_country.pakistan" = "Pakistan"; +"browsing_country.palau" = "Palau"; +"browsing_country.palestinian_territory" = "Palestinian Territory"; +"browsing_country.panama" = "Panama"; +"browsing_country.papua_new_guinea" = "Papua New Guinea"; +"browsing_country.paraguay" = "Paraguay"; +"browsing_country.peru" = "Peru"; +"browsing_country.philippines" = "Philippines"; +"browsing_country.pitcairn_islands" = "Pitcairn Islands"; +"browsing_country.poland" = "Poland"; +"browsing_country.portugal" = "Portugal"; +"browsing_country.puerto_rico" = "Puerto Rico"; +"browsing_country.qatar" = "Qatar"; +"browsing_country.reunion" = "Reunion"; +"browsing_country.romania" = "Romania"; +"browsing_country.russian_federation" = "Russian Federation"; +"browsing_country.rwanda" = "Rwanda"; +"browsing_country.saint_barthelemy" = "Saint Barthelemy"; +"browsing_country.saint_helena" = "Saint Helena"; +"browsing_country.saint_kitts_and_nevis" = "Saint Kitts and Nevis"; +"browsing_country.saint_lucia" = "Saint Lucia"; +"browsing_country.saint_martin" = "Saint Martin"; +"browsing_country.saint_pierre_and_miquelon" = "Saint Pierre and Miquelon"; +"browsing_country.saint_vincent_and_the_grenadines" = "Saint Vincent and the Grenadines"; +"browsing_country.samoa" = "Samoa"; +"browsing_country.san_marino" = "San Marino"; +"browsing_country.sao_tome_and_principe" = "Sao Tome and Principe"; +"browsing_country.saudi_arabia" = "Saudi Arabia"; +"browsing_country.senegal" = "Senegal"; +"browsing_country.serbia" = "Serbia"; +"browsing_country.seychelles" = "Seychelles"; +"browsing_country.sierra_leone" = "Sierra Leone"; +"browsing_country.singapore" = "Singapore"; +"browsing_country.sint_maarten" = "Sint Maarten"; +"browsing_country.slovakia" = "Slovakia"; +"browsing_country.slovenia" = "Slovenia"; +"browsing_country.solomon_islands" = "Solomon Islands"; +"browsing_country.somalia" = "Somalia"; +"browsing_country.south_africa" = "South Africa"; +"browsing_country.south_georgia_and_the_south_sandwich_islands" = "South Georgia and the South Sandwich Islands"; +"browsing_country.south_korea" = "South Korea"; +"browsing_country.south_sudan" = "South Sudan"; +"browsing_country.spain" = "Spain"; +"browsing_country.sri_lanka" = "Sri Lanka"; +"browsing_country.sudan" = "Sudan"; +"browsing_country.suriname" = "Suriname"; +"browsing_country.svalbard_and_jan_mayen" = "Svalbard and Jan Mayen"; +"browsing_country.swaziland" = "Swaziland"; +"browsing_country.sweden" = "Sweden"; +"browsing_country.switzerland" = "Switzerland"; +"browsing_country.syrian_arab_republic" = "Syrian Arab Republic"; +"browsing_country.taiwan" = "Taiwan"; +"browsing_country.tajikistan" = "Tajikistan"; +"browsing_country.tanzania" = "Tanzania"; +"browsing_country.thailand" = "Thailand"; +"browsing_country.timor_leste" = "Timor-Leste"; +"browsing_country.togo" = "Togo"; +"browsing_country.tokelau" = "Tokelau"; +"browsing_country.tonga" = "Tonga"; +"browsing_country.trinidad_and_tobago" = "Trinidad and Tobago"; +"browsing_country.tunisia" = "Tunisia"; +"browsing_country.turkey" = "Turkey"; +"browsing_country.turkmenistan" = "Turkmenistan"; +"browsing_country.turks_and_caicos_islands" = "Turks and Caicos Islands"; +"browsing_country.tuvalu" = "Tuvalu"; +"browsing_country.uganda" = "Uganda"; +"browsing_country.ukraine" = "Ukraine"; +"browsing_country.united_arab_emirates" = "United Arab Emirates"; +"browsing_country.united_kingdom" = "United Kingdom"; +"browsing_country.united_states" = "United States"; +"browsing_country.united_states_minor_outlying_islands" = "United States Minor Outlying Islands"; +"browsing_country.uruguay" = "Uruguay"; +"browsing_country.uzbekistan" = "Uzbekistan"; +"browsing_country.vanuatu" = "Vanuatu"; +"browsing_country.venezuela" = "Venezuela"; +"browsing_country.vietnam" = "Vietnam"; +"browsing_country.virgin_islands_british" = "British Virgin Islands"; +"browsing_country.virgin_islands_US" = "U.S. Virgin Islands"; +"browsing_country.wallis_and_futuna" = "Wallis and Futuna"; +"browsing_country.western_sahara" = "Western Sahara"; +"browsing_country.yemen" = "Yemen"; +"browsing_country.zambia" = "Zambia"; +"browsing_country.zimbabwe" = "Zimbabwe"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 5119c86a3..a08e5c21a 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -1,235 +1,232 @@ // MARK: BanInterval -"enum.ban_interval.description.and" = ""; +"ban_interval.and" = ""; // MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "昨日"; -"enum.toplists_type.value.past_month" = "先月"; -"enum.toplists_type.value.past_year" = "去年"; -"enum.toplists_type.value.all_time" = "すべて"; +"toplists_type.yesterday" = "昨日"; +"toplists_type.past_month" = "先月"; +"toplists_type.past_year" = "去年"; +"toplists_type.all_time" = "すべて"; // MARK: Response -"website.response.hath_client_not_found" = "この機能を使うには、アカウントに関連付けられている H@H クライアントが必要です"; -"website.response.hath_client_not_online" = "H@H クライアントは現在オフラインのようです、起動してからもう一度お試しください"; -"website.response.invalid_resolution" = "このギャラリーは選択された解像度ではダウンロードできません"; +"hath_client_not_found" = "この機能を使うには、アカウントに関連付けられている H@H クライアントが必要です"; +"hath_client_not_online" = "H@H クライアントは現在オフラインのようです、起動してからもう一度お試しください"; +"invalid_resolution" = "このギャラリーは選択された解像度ではダウンロードできません"; // MARK: Toast -"toast.title.error" = "エラー"; -"toast.title.success" = "成功"; -"toast.title.loading" = "読み込み中..."; -"toast.title.communicating" = "通信中..."; -"toast.caption.copied_to_clipboard" = "クリップボードにコピーしました"; -"toast.caption.saved_to_photo_library" = "ライブラリに保存しました"; +"toast.error" = "エラー"; +"toast.success" = "成功"; +"toast.loading" = "読み込み中..."; +"toast.communicating" = "通信中..."; +"toast.copied_to_clipboard" = "クリップボードにコピーしました"; +"toast.saved_to_photo_library" = "ライブラリに保存しました"; // MARK: AutoLock "local_authorization.reason" = "自動ロック期限が切れたため、アプリがロックされています"; // MARK: Common value -"common.value.stars" = "%@ つ星"; -"common.value.pages" = "%@ ページ"; -"common.value.times" = "%@ 回"; -"common.value.day" = "%@ 日"; -"common.value.days" = "%@ 日"; -"common.value.hour" = "%@ 時間"; -"common.value.hours" = "%@ 時間"; -"common.value.minute" = "%@ 分"; -"common.value.minutes" = "%@ 分"; -"common.value.second" = "%@ 秒"; -"common.value.seconds" = "%@ 秒"; -"common.value.records" = "%@ 件のレコード"; +"common.stars" = "%@ つ星"; +"common.pages" = "%@ ページ"; +"common.day" = "%@ 日"; +"common.days" = "%@ 日"; +"common.hour" = "%@ 時間"; +"common.hours" = "%@ 時間"; +"common.minute" = "%@ 分"; +"common.minutes" = "%@ 分"; +"common.second" = "%@ 秒"; +"common.seconds" = "%@ 秒"; // MARK: Common button -"common.button.cancel" = "キャンセル"; +"common.cancel" = "キャンセル"; // MARK: TabItem -"tab_item.title.home" = "ホーム"; -"tab_item.title.favorites" = "お気に入り"; -"tab_item.title.search" = "検索"; -"tab_item.title.downloads" = "ダウンロード"; -"tab_item.title.setting" = "設定"; +"tab_item.home" = "ホーム"; +"tab_item.favorites" = "お気に入り"; +"tab_item.search" = "検索"; +"tab_item.downloads" = "ダウンロード"; +"tab_item.setting" = "設定"; // MARK: ToolbarItem -"toolbar_item.button.filters" = "フィルター"; -"toolbar_item.button.jump_page" = "ページジャンプ"; -"toolbar_item.button.date_seek" = "日付指定"; -"toolbar_item.button.quick_search" = "クイック検索"; +"toolbar_item.filters" = "フィルター"; +"toolbar_item.jump_page" = "ページジャンプ"; +"toolbar_item.date_seek" = "日付指定"; +"toolbar_item.quick_search" = "クイック検索"; // MARK: DateSeek -"date_seek_view.title.date_seek" = "日付指定"; -"date_seek_view.title.date" = "日付"; -"date_seek_view.footer.seek_around_date" = "選択した日付付近のギャラリーへ移動します。"; -"date_seek_view.button.seek_newer" = "新しい方"; -"date_seek_view.button.seek_older" = "古い方"; +"date_seek_view.date_seek" = "日付指定"; +"date_seek_view.date" = "日付"; +"date_seek_view.seek_around_date" = "選択した日付付近のギャラリーへ移動します。"; +"date_seek_view.seek_newer" = "新しい方"; +"date_seek_view.seek_older" = "古い方"; // MARK: JumpPage -"jump_page_view.title.jump_page" = "ページジャンプ"; -"jump_page_view.description.jump_page" = "1 ~ %d のページ番号を入力してください。"; -"jump_page_view.button.confirm" = "確認"; +"jump_page_view.jump_page" = "ページジャンプ"; +"jump_page_view.jump_page_description" = "1 ~ %d のページ番号を入力してください。"; +"jump_page_view.confirm" = "確認"; // MARK: AlertView -"loading_view.title.loading" = "読み込み中..."; -"loading_view.title.preparing_database" = "データベース準備中..."; -"not_login_view.title.need_login" = "本機能をご利用になるにはログインが必要です"; -"not_login_view.button.login" = "ログイン"; -"error_view.button.retry" = "リトライ"; -"error_view.button.drop_database" = "データベースを削除"; -"error_view.title.try_later" = "しばらくしてからもう一度お試しください"; -"error_view.title.network" = "ネットワーク障害が発生しました"; -"error_view.title.parsing" = "解析中に問題が発生しました"; -"error_view.title.unknown" = "不明なエラーが発生しました"; -"error_view.title.not_found" = "ここには何もないようです"; -"error_view.title.database_corrupted" = "データベースが破損しています。\nGitHub で Issue を作成していただくようお願いいたします。"; -"error_view.title.ip_banned" = "この IP アドレスを経由して過剰なページロードが行われました。クローラの疑いがあるため、この IP アドレスは一時的にブロックされました。ブロックは %@後に解除されます。"; -"error_view.title.copyright_claim" = "申し訳ありませんが、このギャラリーは %@ の著作権主張によってアクセス不可になっています。"; -"error_view.title.gallery_unavailable" = "このギャラリーはすでに削除済みまたは無効です。"; +"loading_view.loading" = "読み込み中..."; +"loading_view.preparing_database" = "データベース準備中..."; +"not_login_view.need_login" = "本機能をご利用になるにはログインが必要です"; +"not_login_viewlogin" = "ログイン"; +"error_view.retry" = "リトライ"; +"error_view.drop_database" = "データベースを削除"; +"error_view.try_later" = "しばらくしてからもう一度お試しください"; +"error_view.network" = "ネットワーク障害が発生しました"; +"error_view.parsing" = "解析中に問題が発生しました"; +"error_view.unknown" = "不明なエラーが発生しました"; +"error_view.not_found" = "ここには何もないようです"; +"error_view.database_corrupted" = "データベースが破損しています。\nGitHub で Issue を作成していただくようお願いいたします。"; +"error_view.ip_banned" = "この IP アドレスを経由して過剰なページロードが行われました。クローラの疑いがあるため、この IP アドレスは一時的にブロックされました。ブロックは %@後に解除されます。"; +"error_view.copyright_claim" = "申し訳ありませんが、このギャラリーは %@ の著作権主張によってアクセス不可になっています。"; +"error_view.gallery_unavailable" = "このギャラリーはすでに削除済みまたは無効です。"; // MARK: AppError -"app_error.localized_description.database_corrupted" = "データベース破損"; -"app_error.localized_description.copyright_claim" = "著作権侵害の申し立て"; -"app_error.localized_description.ip_banned" = "IP アドレスがブロックされました"; -"app_error.localized_description.gallery_expunged" = "ギャラリー削除済み"; -"app_error.localized_description.network_error" = "ネットワークエラー"; -"app_error.localized_description.web_image_loading_error" = "Web 画像の読み込みエラー"; -"app_error.localized_description.parse_error" = "解析エラー"; -"app_error.localized_description.quota_exceeded" = "画像割り当て超過"; -"app_error.localized_description.authentication_required" = "認証が必要です"; -"app_error.localized_description.file_operation_failed" = "ファイル操作に失敗しました"; -"app_error.localized_description.no_updates_available" = "利用可能な更新はありません"; -"app_error.localized_description.not_found" = "見つかりません"; -"app_error.localized_description.unknown_error" = "不明なエラー"; -"app_error.alert.quota_exceeded" = "画像の帯域割り当てを使い切りました。\nしばらく待ってからもう一度お試しください。"; -"app_error.alert.authentication_required" = "このダウンロードにアクセスするにはログインが必要です。"; -"app_error.alert.local_file_operation_failed" = "ローカルファイルの操作に失敗しました。"; +"app_error.database_corrupted" = "データベース破損"; +"app_error.copyright_claim" = "著作権侵害の申し立て"; +"app_error.ip_banned" = "IP アドレスがブロックされました"; +"app_error.gallery_expunged" = "ギャラリー削除済み"; +"app_error.network_error" = "ネットワークエラー"; +"app_error.web_image_loading_error" = "Web 画像の読み込みエラー"; +"app_error.parse_error" = "解析エラー"; +"app_error.quota_exceeded" = "画像割り当て超過"; +"app_error.authentication_required" = "認証が必要です"; +"app_error.file_operation_failed" = "ファイル操作に失敗しました"; +"app_error.no_updates_available" = "利用可能な更新はありません"; +"app_error.not_found" = "見つかりません"; +"app_error.unknown_error" = "不明なエラー"; +"app_error.quota_exceeded_description" = "画像の帯域割り当てを使い切りました。\nしばらく待ってからもう一度お試しください。"; +"app_error.authentication_required_description" = "このダウンロードにアクセスするにはログインが必要です。"; +"app_error.local_file_operation_failed" = "ローカルファイルの操作に失敗しました。"; // MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "本アプリでのすべてのデータを失うことになります。\n本当にデータベースを削除してもよろしいですか?"; -"confirmation_dialog.title.remove_custom_translations" = "本当にカスタム翻訳を削除してもよろしいですか?"; -"confirmation_dialog.title.logout" = "本当にログアウトしてもよろしいですか?"; -"confirmation_dialog.title.delete" = "本当にこれを削除してもよろしいですか?"; -"confirmation_dialog.title.clear" = "本当に削除してもよろしいですか?"; -"confirmation_dialog.title.reset" = "本当に戻してもよろしいですか?"; -"confirmation_dialog.button.drop_database" = "データベースを削除"; -"confirmation_dialog.button.remove" = "削除"; -"confirmation_dialog.button.logout" = "ログアウト"; -"confirmation_dialog.button.delete" = "削除"; -"confirmation_dialog.button.clear" = "削除"; -"confirmation_dialog.button.reset" = "戻す"; +"confirmation_dialog.drop_database_description" = "本アプリでのすべてのデータを失うことになります。\n本当にデータベースを削除してもよろしいですか?"; +"confirmation_dialog.remove_custom_translations" = "本当にカスタム翻訳を削除してもよろしいですか?"; +"confirmation_dialog.logout_description" = "本当にログアウトしてもよろしいですか?"; +"confirmation_dialog.delete_description" = "本当にこれを削除してもよろしいですか?"; +"confirmation_dialog.clear_description" = "本当に削除してもよろしいですか?"; +"confirmation_dialog.reset_description" = "本当に戻してもよろしいですか?"; +"confirmation_dialog.drop_database" = "データベースを削除"; +"confirmation_dialog.remove" = "削除"; +"confirmation_dialog.logout" = "ログアウト"; +"confirmation_dialog.delete" = "削除"; +"confirmation_dialog.clear" = "削除"; +"confirmation_dialog.reset" = "戻す"; // MARK: SubSection -"sub_section.button.show_all" = "すべて表示"; +"sub_section.show_all" = "すべて表示"; // MARK: NewDawnView -"new_dawn_view.title.first" = "新しい一日の夜明けです!"; -"new_dawn_view.title.second" = "今までの歩みを振り返り、少し賢くなった気がする。"; +"new_dawn_view.first" = "新しい一日の夜明けです!"; +"new_dawn_view.second" = "今までの歩みを振り返り、少し賢くなった気がする。"; // Greeting -"struct.greeting.mark.start" = ""; -"struct.greeting.mark.separator" = "、"; -"struct.greeting.mark.and" = " と "; -"struct.greeting.mark.end" = "を手に入れた!"; +"greeting.start" = ""; +"greeting.separator" = "、"; +"greeting.and" = " と "; +"greeting.end" = "を手に入れた!"; // MARK: HomeView -"home_view.title.home" = "ホーム"; -"home_view.section.title.frontpage" = "フロントページ"; -"home_view.section.title.toplists" = "ランキング"; -"home_view.section.title.other" = "その他"; +"home_view.home" = "ホーム"; +"home_view.frontpage" = "フロントページ"; +"home_view.toplists" = "ランキング"; +"home_view.other" = "その他"; // HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "人気"; -"enum.home_misc_grid_type.title.watched" = "タグの購読"; -"enum.home_misc_grid_type.title.history" = "閲覧履歴"; +"home_misc_grid_type.popular" = "人気"; +"home_misc_grid_type.watched" = "タグの購読"; +"home_misc_grid_type.history" = "閲覧履歴"; // MARK: FrontpageView -"frontpage_view.title.frontpage" = "フロントページ"; +"frontpage_view.frontpage" = "フロントページ"; // MARK: ToplistsView -"toplists_view.title.toplists" = "ランキング"; +"toplists_view.toplists" = "ランキング"; // MARK: PopularView -"popular_view.title.popular" = "人気"; +"popular_view.popular" = "人気"; // MARK: WatchedView -"watched_view.title.watched" = "タグの購読"; +"watched_view.watched" = "タグの購読"; // MARK: HistoryView -"history_view.title.history" = "閲覧履歴"; +"history_view.history" = "閲覧履歴"; // MARK: FavoritesView -"favorites_view.title.favorites" = "お気に入り"; +"favorites_view.favorites" = "お気に入り"; // FavoriteCategory -"struct.user.favorite_category.default" = "お気に入り %@"; -"struct.user.favorite_category.all" = "すべて"; +"favorite_category.default" = "お気に入り %@"; +"favorite_category.all" = "すべて"; // MARK: SearchView -"search_view.title.search" = "検索"; -"search_view.section.title.recently_searched" = "最近検索した項目"; -"search_view.section.title.recently_seen" = "最近閲覧した項目"; -"search_view.section.title.quick_search" = "クイック検索"; +"search_view.search" = "検索"; +"search_view.recently_searched" = "最近検索した項目"; +"search_view.recently_seen" = "最近閲覧した項目"; +"search_view.quick_search" = "クイック検索"; // Searchable -"searchable.prompt.filter" = "フィルター"; -"searchable.title.matches_count" = "%d 件の該当項目"; +"searchable.filter" = "フィルター"; +"searchable.matches_count" = "%d 件の該当項目"; // MARK: QuickSearchView -"quick_search_view.title.quick_search" = "クイック検索"; -"quick_search_view.title.edit_word" = "キーワードを編集"; -"quick_search_view.title.new_word" = "キーワードを追加"; -"quick_search_view.title.content" = "内容"; -"quick_search_view.title.name" = "名前"; -"quick_search_view.placeholder.optional" = "任意"; +"quick_search_view.quick_search" = "クイック検索"; +"quick_search_view.edit_word" = "キーワードを編集"; +"quick_search_view.new_word" = "キーワードを追加"; +"quick_search_view.content" = "内容"; +"quick_search_view.name" = "名前"; +"quick_search_view.optional" = "任意"; // MARK: SettingView -"setting_view.title.setting" = "設定"; +"setting_view.setting" = "設定"; // SettingStateRoute -"enum.setting_state_route.value.account" = "アカウント"; -"enum.setting_state_route.value.general" = "一般"; -"enum.setting_state_route.value.appearance" = "外観"; -"enum.setting_state_route.value.reading" = "閲覧"; -"enum.setting_state_route.value.download" = "ダウンロード"; -"enum.setting_state_route.value.laboratory" = "ラボ"; -"enum.setting_state_route.value.about" = "アプリについて"; +"setting_state_route.account" = "アカウント"; +"setting_state_route.general" = "一般"; +"setting_state_route.appearance" = "外観"; +"setting_state_route.reading" = "閲覧"; +"setting_state_route.download" = "ダウンロード"; +"setting_state_route.laboratory" = "ラボ"; +"setting_state_route.about" = "アプリについて"; // MARK: AccountSettingView -"account_setting_view.title.account" = "アカウント"; -"account_setting_view.title.shows_new_dawn_greeting" = "夜明けの挨拶を表示"; -"account_setting_view.button.login" = "ログイン"; -"account_setting_view.button.logout" = "ログアウト"; -"account_setting_view.button.account_configuration" = "アカウント設定"; -"account_setting_view.button.tags_management" = "タグの購読を管理"; -"account_setting_view.button.copy_cookies" = "クッキーをコピー"; +"account_setting_view.account" = "アカウント"; +"account_setting_view.shows_new_dawn_greeting" = "夜明けの挨拶を表示"; +"account_setting_view.login" = "ログイン"; +"account_setting_view.account_configuration" = "アカウント設定"; +"account_setting_view.tags_management" = "タグの購読を管理"; +"account_setting_view.copy_cookies" = "クッキーをコピー"; // CookieValue -"struct.cookie_value.localized_string.expired" = "期限切れ"; -"struct.cookie_value.localized_string.mystery" = "拒否"; -"struct.cookie_value.localized_string.none" = "なし"; +"cookie_value.expired" = "期限切れ"; +"cookie_value.mystery" = "拒否"; +"cookie_value.none" = "なし"; // MARK: LoginView -"login_view.title.login" = "ログイン"; -"login_view.title.username" = "ユーザー名"; -"login_view.title.password" = "パスワード"; +"login_view.login" = "ログイン"; +"login_view.username" = "ユーザー名"; +"login_view.password" = "パスワード"; // MARK: GeneralSettingView -"general_setting_view.title.general" = "一般"; -"general_setting_view.title.language" = "言語"; -"general_setting_view.title.auto_lock" = "自動ロック"; -"general_setting_view.title.enables_tags_extension" = "タグの拡張機能を有効"; -"general_setting_view.title.translates_tags" = "タグを訳す"; -"general_setting_view.title.shows_tags_search_suggestion" = "タグの検索提案を表示"; -"general_setting_view.title.shows_images_in_tags" = "タグの画像を表示"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "リンクを選択されたホストへリダイレクト"; -"general_setting_view.title.detects_links_from_clipboard" = "クリップボードからリンクを探知"; -"general_setting_view.title.background_blur_radius" = "バッググラウンドぼかし度"; -"general_setting_view.button.app_activity_logs" = "アプリアクティビティログ"; -"general_setting_view.button.import_custom_translations" = "カスタム翻訳を取り込む"; -"general_setting_view.button.remove_custom_translations" = "カスタム翻訳を削除"; -"general_setting_view.button.clear_image_caches" = "画像キャッシュを削除"; -"general_setting_view.value.default_language_description" = "無効"; -"general_setting_view.section.title.tags" = "タグ"; -"general_setting_view.section.title.navigation" = "ナビゲーション"; -"general_setting_view.section.title.security" = "セキュリティ"; -"general_setting_view.section.title.caches" = "キャッシュ"; +"general_setting_view.general" = "一般"; +"general_setting_view.language" = "言語"; +"general_setting_view.auto_lock" = "自動ロック"; +"general_setting_view.enables_tags_extension" = "タグの拡張機能を有効"; +"general_setting_view.translates_tags" = "タグを訳す"; +"general_setting_view.shows_tags_search_suggestion" = "タグの検索提案を表示"; +"general_setting_view.shows_images_in_tags" = "タグの画像を表示"; +"general_setting_view.redirects_links_to_the_selected_host" = "リンクを選択されたホストへリダイレクト"; +"general_setting_view.detects_links_from_clipboard" = "クリップボードからリンクを探知"; +"general_setting_view.background_blur_radius" = "バッググラウンドぼかし度"; +"general_setting_view.app_activity_logs" = "アプリアクティビティログ"; +"general_setting_view.import_custom_translations" = "カスタム翻訳を取り込む"; +"general_setting_view.remove_custom_translations" = "カスタム翻訳を削除"; +"general_setting_view.clear_image_caches" = "画像キャッシュを削除"; +"general_setting_view.default_language_description" = "無効"; +"general_setting_view.tags" = "タグ"; +"general_setting_view.navigation" = "ナビゲーション"; +"general_setting_view.security" = "セキュリティ"; +"general_setting_view.caches" = "キャッシュ"; // AutoLockPolicy -"enum.auto_lock_policy.value.never" = "なし"; -"enum.auto_lock_policy.value.instantly" = "すぐに"; +"auto_lock_policy.never" = "なし"; +"auto_lock_policy.instantly" = "すぐに"; // MARK: AppActivityLogsView "app_activity_logs_view.title" = "アプリアクティビティログ"; -"app_activity_logs_view.placeholder.no_logs" = "ログが見つかりません"; -"app_activity_logs_view.section.current" = "現在"; +"app_activity_logs_view.no_logs" = "ログが見つかりません"; +"app_activity_logs_view.current" = "現在"; "app_activity_logs_view.run" = "起動 %@"; "app_activity_logs_view.more_logs" = "他のログ"; "app_activity_logs_view.open_in_files" = "ファイルで開く"; @@ -242,810 +239,786 @@ "app_activity_logs_view.level.fault" = "障害"; // MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "外観"; -"appearance_setting_view.title.theme" = "テーマ"; -"appearance_setting_view.title.tint_color" = "テーマの色"; -"appearance_setting_view.title.display_mode" = "表示モード"; -"appearance_setting_view.title.shows_tags_in_list" = "リストでタグを表示"; -"appearance_setting_view.title.maximum_number_of_tags" = "タグ数上限"; -"appearance_setting_view.title.displays_japanese_title" = "日本語タイトルを表示"; -"appearance_setting_view.button.app_icon" = "アプリアイコン"; -"appearance_setting_view.menu.title.infite" = "無制限"; -"appearance_setting_view.section.title.list" = "リスト"; -"appearance_setting_view.section.title.gallery" = "ギャラリー"; +"appearance_setting_view.appearance" = "外観"; +"appearance_setting_view.theme" = "テーマ"; +"appearance_setting_view.tint_color" = "テーマの色"; +"appearance_setting_view.display_mode" = "表示モード"; +"appearance_setting_view.shows_tags_in_list" = "リストでタグを表示"; +"appearance_setting_view.maximum_number_of_tags" = "タグ数上限"; +"appearance_setting_view.displays_japanese_title" = "日本語タイトルを表示"; +"appearance_setting_view.app_icon" = "アプリアイコン"; +"appearance_setting_view.infite" = "無制限"; +"appearance_setting_view.list" = "リスト"; +"appearance_setting_view.gallery" = "ギャラリー"; // PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "自動"; -"enum.preferred_color_scheme.value.light" = "ライト"; -"enum.preferred_color_scheme.value.dark" = "ダーク"; +"preferred_color_scheme.automatic" = "自動"; +"preferred_color_scheme.light" = "ライト"; +"preferred_color_scheme.dark" = "ダーク"; // AppIconType -"enum.app_icon_type.value.default" = "デフォルト"; -"enum.app_icon_type.value.ukiyoe" = "浮世絵"; -"enum.app_icon_type.value.developer" = "デベロッパー"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "ウクライナと共に (2022)"; -"enum.app_icon_type.value.not_my_president" = "私の大統領ではない"; +"app_icon_type.default" = "デフォルト"; +"app_icon_type.ukiyoe" = "浮世絵"; +"app_icon_type.developer" = "デベロッパー"; +"app_icon_type.stand_with_ukraine_2022" = "ウクライナと共に (2022)"; +"app_icon_type.not_my_president" = "私の大統領ではない"; // ListDisplayMode -"enum.list_display_mode.value.detail" = "詳細"; -"enum.list_display_mode.value.thumbnail" = "サムネイル"; +"list_display_mode.detail" = "詳細"; +"list_display_mode.thumbnail" = "サムネイル"; // MARK: AppIconView -"app_icon_view.title.app_icon" = "アプリアイコン"; +"app_icon_view.app_icon" = "アプリアイコン"; // MARK: reading_settingView -"reading_setting_view.title.reading" = "閲覧"; -"reading_setting_view.title.direction" = "方向"; -"reading_setting_view.title.preload_limit" = "プリロード上限数"; -"reading_setting_view.title.enables_landscape" = "横向きを有効"; -"reading_setting_view.title.separator_height" = "仕切りの高さ"; -"reading_setting_view.title.maximum_scale_factor" = "最大スケール係数"; -"reading_setting_view.title.double_tap_scale_factor" = "ダブルタップスケール係数"; -"reading_setting_view.section.title.appearance" = "外観"; +"reading_setting_view.reading" = "閲覧"; +"reading_setting_view.direction" = "方向"; +"reading_setting_view.preload_limit" = "プリロード上限数"; +"reading_setting_view.enables_landscape" = "横向きを有効"; +"reading_setting_view.separator_height" = "仕切りの高さ"; +"reading_setting_view.maximum_scale_factor" = "最大スケール係数"; +"reading_setting_view.double_tap_scale_factor" = "ダブルタップスケール係数"; +"reading_setting_view.appearance" = "外観"; // ReadingDirection -"enum.reading_direction.value.vertical" = "縦読み"; -"enum.reading_direction.value.right_to_left" = "右開き"; -"enum.reading_direction.value.left_to_right" = "左開き"; +"reading_direction.vertical" = "縦読み"; +"reading_direction.right_to_left" = "右開き"; +"reading_direction.left_to_right" = "左開き"; // MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "ラボ"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "SNI フィルタリング回避"; +"laboratory_setting_view.laboratory" = "ラボ"; +"laboratory_setting_view.bypasses_SNI_filtering" = "SNI フィルタリング回避"; // MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "ウェブサイト"; -"about_view.button.altStore_source" = "AltStore ソース"; -"about_view.title.version" = "バージョン"; -"about_view.section.title.special_thanks" = "特別な感謝"; -"about_view.section.title.code_level_contributors" = "コードレベル貢献者"; -"about_view.section.title.translation_contributors" = "翻訳貢献者"; -"about_view.section.title.acknowledgements" = "謝辞"; +"about_view.ehPanda" = "EhPanda"; +"about_view.website" = "ウェブサイト"; +"about_view.altStore_source" = "AltStore ソース"; +"about_view.version" = "バージョン"; +"about_view.special_thanks" = "特別な感謝"; +"about_view.code_level_contributors" = "コードレベル貢献者"; +"about_view.translation_contributors" = "翻訳貢献者"; +"about_view.acknowledgements" = "謝辞"; // MARK: DetailView -"detail_view.button.download_login" = "ログイン"; -"detail_view.button.download_get" = "入手"; -"detail_view.button.download_wait" = "待機"; -"detail_view.button.download_done" = "完了"; -"detail_view.button.download_update" = "更新"; -"detail_view.button.download_retry" = "再試行"; -"detail_view.button.download_repair" = "修復"; -"detail_view.button.read" = "閲覧"; -"detail_view.button.post_comment" = "コメントを書く"; -"detail_view.accessibility.download_button.login" = "ダウンロードするにはログインが必要です"; -"detail_view.accessibility.download_button.download" = "ダウンロード"; -"detail_view.accessibility.download_button.queued" = "ダウンロード待ち"; -"detail_view.accessibility.download_button.downloading" = "%d / %d ページをダウンロード中"; -"detail_view.accessibility.download_button.downloaded" = "ダウンロード済みのギャラリーを削除"; -"detail_view.accessibility.download_button.update" = "ダウンロードを更新"; -"detail_view.accessibility.download_button.retry" = "ダウンロードを再試行"; -"detail_view.accessibility.download_button.repair" = "ダウンロードを修復"; -"detail_view.accessibility.download_button.preparing" = "ダウンロード情報を取得中"; -"detail_view.accessibility.download_button.pause_action" = "ダウンロードを一時停止"; -"detail_view.accessibility.download_button.paused" = "ダウンロードを再開。%d / %d ページで停止中"; -"detail_view.accessibility.download_button.partial" = "ダウンロードを再試行。すでに %d / %d ページが利用可能です。"; -"detail_view.toolbar_item.button.archives" = "アーカイブ"; -"detail_view.toolbar_item.button.torrents" = "トレント"; -"detail_view.toolbar_item.button.share" = "共有"; -"detail_view.context_menu.button.detail" = "詳細"; -"detail_view.context_menu.button.withdraw_vote" = "投票を取り消す"; -"detail_view.context_menu.button.vote_up" = "賛成票を投じる"; -"detail_view.context_menu.button.vote_down" = "反対票を投じる"; -"detail_view.description_section.title.favorited" = "気に入り"; -"detail_view.description_section.title.language" = "言語"; -"detail_view.description_section.title.ratings" = "%@ 件の評価"; -"detail_view.description_section.title.page_count" = "ページ数"; -"detail_view.description_section.title.file_size" = "ファイルサイズ"; -"detail_view.description_section.description.favorited" = "回"; -"detail_view.description_section.description.page_count" = "ページ"; -"detail_view.action_section.button.give_a_rating" = "評価する"; -"detail_view.action_section.button.similar_gallery" = "類似ギャラリー"; -"detail_view.section.title.previews" = "プレビュー"; -"detail_view.section.title.comments" = "コメント"; -"detail_view.dialog.title.delete_download" = "ダウンロードを削除しますか?"; -"detail_view.dialog.title.repair_download" = "ダウンロードを修復しますか?"; -"detail_view.dialog.title.update_download" = "ダウンロードを更新しますか?"; -"detail_view.dialog.title.redownload_gallery" = "ギャラリーを再ダウンロードしますか?"; -"detail_view.dialog.message.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; -"detail_view.dialog.message.repair_download" = "このギャラリーのオフラインファイルを今すぐ修復しますか?"; -"detail_view.dialog.message.update_download" = "このギャラリーを今すぐオンラインの最新バージョンに更新しますか?"; -"detail_view.dialog.message.redownload_gallery" = "このギャラリーを今すぐ最初から再ダウンロードしますか?"; -"detail_view.dialog.button.repair" = "修復"; -"detail_view.dialog.button.update" = "更新"; -"detail_view.dialog.button.redownload" = "再ダウンロード"; -"detail_view.offline_notice.saved_details" = "オンラインの詳細を更新できなかったため、保存済みの詳細を表示しています。"; +"detail_view.read" = "閲覧"; +"detail_view.post_comment" = "コメントを書く"; +"detail_view.accessibility.login" = "ダウンロードするにはログインが必要です"; +"detail_view.accessibility.download" = "ダウンロード"; +"detail_view.accessibility.queued" = "ダウンロード待ち"; +"detail_view.accessibility.downloading" = "%d / %d ページをダウンロード中"; +"detail_view.accessibility.downloaded" = "ダウンロード済みのギャラリーを削除"; +"detail_view.accessibility.update" = "ダウンロードを更新"; +"detail_view.accessibility.retry" = "ダウンロードを再試行"; +"detail_view.accessibility.repair" = "ダウンロードを修復"; +"detail_view.accessibility.preparing" = "ダウンロード情報を取得中"; +"detail_view.accessibility.pause_action" = "ダウンロードを一時停止"; +"detail_view.accessibility.paused" = "ダウンロードを再開。%d / %d ページで停止中"; +"detail_view.accessibility.partial" = "ダウンロードを再試行。すでに %d / %d ページが利用可能です。"; +"detail_view.archives" = "アーカイブ"; +"detail_view.torrents" = "トレント"; +"detail_view.share" = "共有"; +"detail_view.detail" = "詳細"; +"detail_view.withdraw_vote" = "投票を取り消す"; +"detail_view.vote_up" = "賛成票を投じる"; +"detail_view.vote_down" = "反対票を投じる"; +"detail_view.favorited" = "気に入り"; +"detail_view.language" = "言語"; +"detail_view.ratings" = "%@ 件の評価"; +"detail_view.page_count" = "ページ数"; +"detail_view.file_size" = "ファイルサイズ"; +"detail_view.favorited_unit" = "回"; +"detail_view.page_count_unit" = "ページ"; +"detail_view.give_a_rating" = "評価する"; +"detail_view.similar_gallery" = "類似ギャラリー"; +"detail_view.previews" = "プレビュー"; +"detail_view.comments" = "コメント"; +"detail_view.delete_download" = "ダウンロードを削除しますか?"; +"detail_view.repair_download" = "ダウンロードを修復しますか?"; +"detail_view.update_download" = "ダウンロードを更新しますか?"; +"detail_view.redownload_gallery" = "ギャラリーを再ダウンロードしますか?"; +"detail_view.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; +"detail_view.repair_download_description" = "このギャラリーのオフラインファイルを今すぐ修復しますか?"; +"detail_view.update_download_description" = "このギャラリーを今すぐオンラインの最新バージョンに更新しますか?"; +"detail_view.redownload_gallery_description" = "このギャラリーを今すぐ最初から再ダウンロードしますか?"; +"detail_view.repair" = "修復"; +"detail_view.update" = "更新"; +"detail_view.redownload" = "再ダウンロード"; +"detail_view.saved_details" = "オンラインの詳細を更新できなかったため、保存済みの詳細を表示しています。"; // MARK: ArchivesView -"archives_view.title.archives" = "アーカイブ"; -"archives_view.button.download_to_hath_client" = "H@H クライアントにダウンロード"; +"archives_view.archives" = "アーカイブ"; +"archives_view.download_to_hath_client" = "H@H クライアントにダウンロード"; // HathArchive -"struct.hath_archive.price.free" = "無料"; -"struct.hath_archive.price.not_available" = "無効"; +"hath_archive.free" = "無料"; // ArchiveResolution -"enum.archive_resolution.value.original" = "オリジナル"; +"archive_resolution.original" = "オリジナル"; // MARK: TorrentsView -"torrents_view.title.torrents" = "トレント"; +"torrents_view.torrents" = "トレント"; // MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "ギャラリー情報"; -"gallery_infos_view.title.id" = "ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "タイトル"; -"gallery_infos_view.title.japanese_title" = "日本語タイトル"; -"gallery_infos_view.title.gallery_URL" = "ギャラリーリンク"; -"gallery_infos_view.title.cover_URL" = "カバーリンク"; -"gallery_infos_view.title.archive_URL" = "アーカイブリンク"; -"gallery_infos_view.title.torrent_URL" = "トレントリンク"; -"gallery_infos_view.title.parent_URL" = "親ギャラリーリンク"; -"gallery_infos_view.title.category" = "カテゴリー"; -"gallery_infos_view.title.uploader" = "アップローダー"; -"gallery_infos_view.title.posted_date" = "投稿日付"; -"gallery_infos_view.title.visibility" = "可視"; -"gallery_infos_view.title.language" = "言語"; -"gallery_infos_view.title.page_count" = "ページ数"; -"gallery_infos_view.title.file_size" = "ファイルサイズ"; -"gallery_infos_view.title.favorited_times" = "気に入り数"; -"gallery_infos_view.title.favorited" = "お気に入り済み"; -"gallery_infos_view.title.rating_count" = "評価数"; -"gallery_infos_view.title.average_rating" = "平均評価"; -"gallery_infos_view.title.my_rating" = "自分の評価"; -"gallery_infos_view.title.torrent_count" = "トレント数"; -"gallery_infos_view.value.none" = "なし"; -"gallery_infos_view.value.yes" = "はい"; -"gallery_infos_view.value.no" = "いいえ"; +"gallery_infos_view.gallery_infos" = "ギャラリー情報"; +"gallery_infos_view.id" = "ID"; +"gallery_infos_view.token" = "Token"; +"gallery_infos_view.title" = "タイトル"; +"gallery_infos_view.japanese_title" = "日本語タイトル"; +"gallery_infos_view.gallery_URL" = "ギャラリーリンク"; +"gallery_infos_view.cover_URL" = "カバーリンク"; +"gallery_infos_view.archive_URL" = "アーカイブリンク"; +"gallery_infos_view.torrent_URL" = "トレントリンク"; +"gallery_infos_view.parent_URL" = "親ギャラリーリンク"; +"gallery_infos_view.category" = "カテゴリー"; +"gallery_infos_view.uploader" = "アップローダー"; +"gallery_infos_view.posted_date" = "投稿日付"; +"gallery_infos_view.visibility" = "可視"; +"gallery_infos_view.language" = "言語"; +"gallery_infos_view.page_count" = "ページ数"; +"gallery_infos_view.file_size" = "ファイルサイズ"; +"gallery_infos_view.favorited_times" = "気に入り数"; +"gallery_infos_view.favorited" = "お気に入り済み"; +"gallery_infos_view.rating_count" = "評価数"; +"gallery_infos_view.average_rating" = "平均評価"; +"gallery_infos_view.my_rating" = "自分の評価"; +"gallery_infos_view.torrent_count" = "トレント数"; +"gallery_infos_view.none" = "なし"; +"gallery_infos_view.yes" = "はい"; +"gallery_infos_view.no" = "いいえ"; // GalleryVisibility -"enum.gallery_visibility.value.yes" = "はい"; -"enum.gallery_visibility.value.no" = "いいえ (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "削除済み"; +"gallery_visibility.yes" = "はい"; +"gallery_visibility.no" = "いいえ (%@)"; +"gallery_visibility.expunged" = "削除済み"; // MARK: TagDetailView -"tag_detail_view.section.title.images" = "画像"; -"tag_detail_view.section.title.links" = "リンク"; +"tag_detail_view.images" = "画像"; +"tag_detail_view.links" = "リンク"; // MARK: DownloadsView -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "ダウンロード"; -"downloads_view.search.prompt.downloads" = "ダウンロードを検索"; -"downloads_view.dialog.title.delete_download" = "ダウンロードを削除しますか?"; -"downloads_view.dialog.message.delete_active_download" = "現在のダウンロードをキャンセルし、このデバイスから削除します。"; -"downloads_view.dialog.message.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; -"downloads_view.swipe.button.pages" = "ページ"; -"downloads_view.swipe.button.update" = "更新"; -"downloads_view.swipe.button.resume" = "再開"; -"downloads_view.swipe.button.pause" = "一時停止"; -"downloads_view.empty_state.downloads" = "ダウンロードしたギャラリーはここに表示されます。"; -"downloads_view.empty_state.no_matching_filters" = "現在のフィルターに一致するダウンロードはありません。"; -"downloads_view.button.clear_filters" = "フィルターをクリア"; -"downloads_view.button.validate_image_data" = "画像データを検証"; -"downloads_view.inspector.section.actions" = "操作"; -"downloads_view.inspector.section.pages" = "ページ"; -"downloads_view.inspector.button.retry_failed_pages" = "失敗したページを再試行"; -"downloads_view.inspector.button.validating_image_data" = "画像データを検証中..."; -"downloads_view.inspector.button.update_download" = "ダウンロードを更新"; -"downloads_view.inspector.toast.image_data_valid" = "画像データは有効です"; -"downloads_view.inspector.toast.image_data_unavailable" = "画像データを検証できませんでした。"; -"downloads_view.inspector.title.download_status" = "ダウンロード状況"; -"downloads_view.inspector.page.pending" = "待機中"; -"downloads_view.inspector.page.tap_to_retry" = "タップしてこのページを再試行"; -"downloads_view.inspector.page.title" = "ページ %d"; -"downloads_view.inspector.page.none" = "ページなし"; -"downloads_view.inspector.status.pending" = "待機中"; -"downloads_view.inspector.status.downloaded" = "ダウンロード済み"; -"downloads_view.inspector.status.failed" = "失敗"; +"download_folder_filter.all" = "All"; +"detail_view.manage_folders" = "Manage Folders"; +"detail_view.create_default_folder" = "Create Default Folder"; +"detail_view.no_folders" = "No folders yet"; +"downloads_view.manage_folders" = "Manage Folders"; +"downloads_view.move_to_folder" = "Move to Folder"; +"downloads_view.move" = "Move"; +"folder_manager_view.folders" = "Folders"; +"folder_manager_view.folder_name" = "Folder name"; +"folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_folders" = "Folders you create will appear here."; +"downloads_view.downloads" = "ダウンロード"; +"downloads_view.search_downloads" = "ダウンロードを検索"; +"downloads_view.delete_download" = "ダウンロードを削除しますか?"; +"downloads_view.delete_active_download" = "現在のダウンロードをキャンセルし、このデバイスから削除します。"; +"downloads_view.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; +"downloads_view.pages" = "ページ"; +"downloads_view.update" = "更新"; +"downloads_view.resume" = "再開"; +"downloads_view.pause" = "一時停止"; +"downloads_view.empty_downloads" = "ダウンロードしたギャラリーはここに表示されます。"; +"downloads_view.no_matching_filters" = "現在のフィルターに一致するダウンロードはありません。"; +"downloads_view.clear_filters" = "フィルターをクリア"; +"downloads_view.validate_image_data" = "画像データを検証"; +"download_inspector_view.actions" = "操作"; +"download_inspector_view.retry_failed_pages" = "失敗したページを再試行"; +"download_inspector_view.validating_image_data" = "画像データを検証中..."; +"download_inspector_view.image_data_valid" = "画像データは有効です"; +"download_inspector_view.image_data_unavailable" = "画像データを検証できませんでした。"; +"download_inspector_view.download_status" = "ダウンロード状況"; +"download_inspector_view.pending" = "待機中"; +"download_inspector_view.none" = "ページなし"; +"download_inspector_view.downloaded" = "ダウンロード済み"; +"download_inspector_view.failed" = "失敗"; // MARK: DownloadSettingView "download_setting_view.title" = "ダウンロード"; -"download_setting_view.section.title.download_queue" = "ダウンロードキュー"; -"download_setting_view.section.title.network" = "ネットワーク"; -"download_setting_view.title.concurrent_image_downloads" = "同時画像ダウンロード数"; -"download_setting_view.title.retry_failed_pages_automatically" = "失敗したページを自動で再試行"; -"download_setting_view.title.allow_cellular_downloads" = "モバイル通信でのダウンロードを許可"; -"download_setting_view.footer.network" = "一度にダウンロードされるギャラリーは 1 件だけです。この設定では、1 つのギャラリー内で同時にダウンロードするページ数、モバイル通信の許可または禁止、そしてファイルをアプリの Downloads フォルダに保存する動作を管理します。"; +"download_setting_view.network" = "ネットワーク"; +"download_setting_view.concurrent_image_downloads" = "同時画像ダウンロード数"; +"download_setting_view.retry_failed_pages_automatically" = "失敗したページを自動で再試行"; +"download_setting_view.allow_cellular_downloads" = "モバイル通信でのダウンロードを許可"; +"download_setting_view.network_description" = "一度にダウンロードされるギャラリーは 1 件だけです。この設定では、1 つのギャラリー内で同時にダウンロードするページ数、モバイル通信の許可または禁止、そしてファイルをアプリの Downloads フォルダに保存する動作を管理します。"; // MARK: CommentsView -"comments_view.title.comments" = "コメント"; +"comments_view.comments" = "コメント"; // MARK: PostCommentView -"post_comment_view.title.post_comment" = "コメントを書く"; -"post_comment_view.title.edit_comment" = "コメントを編集"; +"post_comment_view.post_comment" = "コメントを書く"; +"post_comment_view.edit_comment" = "コメントを編集"; // MARK: PreviewsView -"previews_view.title.previews" = "プレビュー"; +"previews_view.previews" = "プレビュー"; // MARK: ReadingView -"reading_view.context_menu.button.reload" = "再読み込み"; -"reading_view.context_menu.button.copy" = "コピー"; -"reading_view.context_menu.button.save" = "保存"; -"reading_view.context_menu.button.save_original" = "オリジナルを保存"; -"reading_view.context_menu.button.share" = "共有"; -"reading_view.toolbar_item.title.auto_play" = "自動再生"; -"reading_view.toolbar_item.title.dual_page_mode" = "デュアルページモード"; -"reading_view.toolbar_item.title.except_the_cover" = "カバーを除く"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "読み込み失敗した画像をすべてリトライ"; -"reading_view.toolbar_item.button.reload_all_images" = "画像をすべて再読み込み"; -"reading_view.toolbar_item.button.reading_setting" = "閲覧設定"; +"reading_view.reload" = "再読み込み"; +"reading_view.copy" = "コピー"; +"reading_view.save" = "保存"; +"reading_view.save_original" = "オリジナルを保存"; +"reading_view.share" = "共有"; +"reading_view.auto_play" = "自動再生"; +"reading_view.dual_page_mode" = "デュアルページモード"; +"reading_view.except_the_cover" = "カバーを除く"; +"reading_view.retry_all_failed_images" = "読み込み失敗した画像をすべてリトライ"; +"reading_view.reload_all_images" = "画像をすべて再読み込み"; +"reading_view.reading_setting" = "閲覧設定"; // AutoPlayPolicy -"enum.auto_play_policy.value.off" = "オフ"; +"auto_play_policy.off" = "オフ"; // MARK: DownloadBadge -"struct.download_badge.text.queued" = "待機中"; -"struct.download_badge.text.downloading" = "ダウンロード中"; -"struct.download_badge.text.paused" = "一時停止"; -"struct.download_badge.text.downloaded" = "ダウンロード済み"; -"struct.download_badge.text.needs_attention" = "要対応"; -"struct.download_badge.text.update_available" = "更新あり"; -"struct.download_badge.text.needs_repair" = "要修復"; -"struct.download_badge.progress" = "%d/%d"; +"download_badge.queued" = "待機中"; +"download_badge.downloading" = "ダウンロード中"; +"download_badge.paused" = "一時停止"; +"download_badge.downloaded" = "ダウンロード済み"; +"download_badge.needs_attention" = "要対応"; +"download_badge.update_available" = "更新あり"; +"download_badge.progress" = "%d/%d"; // MARK: DownloadStore -"download_store.error.asset_unreadable" = "アセットファイルを読み取れません: %@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "ダウンロードフォルダを解決できませんでした。"; -"download_store.validation.download_folder_missing" = "ダウンロードフォルダが見つかりません。"; -"download_store.validation.manifest_missing" = "マニフェストファイルが見つかりません。"; -"download_store.validation.manifest_corrupted" = "マニフェストファイルが破損しています。"; -"download_store.validation.downloaded_pages_incomplete" = "ダウンロード済みページが不完全です。"; -"download_store.validation.cover_image_missing" = "表紙画像が見つかりません。"; -"download_store.validation.page_missing" = "ページ %d が見つかりません。"; -"download_store.validation.cover_image_corrupted" = "表紙画像データが破損しています。"; -"download_store.validation.page_image_corrupted" = "ページ %d の画像データが破損しています。"; +"download_store.asset_unreadable" = "アセットファイルを読み取れません: %@"; +"download_store.invalid_folder_name" = "The folder name is invalid."; +"download_store.folder_already_exists" = "A folder with this name already exists."; +"download_store.folder_busy_downloading" = "The folder contains an active download."; +"download_store.download_busy" = "The download is currently active."; +"download_store.download_folder_missing" = "ダウンロードフォルダが見つかりません。"; +"download_store.manifest_missing" = "マニフェストファイルが見つかりません。"; +"download_store.manifest_corrupted" = "マニフェストファイルが破損しています。"; +"download_store.page_missing" = "ページ %d が見つかりません。"; +"download_store.page_image_corrupted" = "ページ %d の画像データが破損しています。"; // MARK: FiltersView -"filters_view.title.filters" = "フィルター"; -"filters_view.title.advanced_settings" = "高度な設定"; -"filters_view.title.search_gallery_name" = "ギャラリー名を検索"; -"filters_view.title.search_gallery_tags" = "ギャラリータグを検索"; -"filters_view.title.search_gallery_description" = "ギャラリー説明を検索"; -"filters_view.title.search_torrent_filenames" = "トレントファイル名を検索"; -"filters_view.title.only_show_galleries_with_torrents" = "トレントを含むもののみを表示"; -"filters_view.title.search_low_power_tags" = "低希望タグを検索"; -"filters_view.title.search_downvoted_tags" = "低評価タグを検索"; -"filters_view.title.search_expunged_galleries" = "削除済みのギャラリーを表示"; -"filters_view.title.set_minimum_rating" = "評価の下限を指定"; -"filters_view.title.minimum_rating" = "評価の下限"; -"filters_view.title.set_pages_range" = "ページ数範囲を指定"; -"filters_view.title.pages_range" = "ページ数範囲"; -"filters_view.title.disable_language_filter" = "言語フィルターを無効化"; -"filters_view.title.disable_uploader_filter" = "アップローダフィルターを無効化"; -"filters_view.title.disable_tags_filter" = "タグフィルターを無効化"; -"filters_view.button.reset_filters" = "既定値に戻す"; -"filters_view.section.title.advanced" = "高度"; -"filters_view.section.title.default_filter" = "既定フィルター"; +"filters_view.filters" = "フィルター"; +"filters_view.advanced_settings" = "高度な設定"; +"filters_view.search_gallery_name" = "ギャラリー名を検索"; +"filters_view.search_gallery_tags" = "ギャラリータグを検索"; +"filters_view.search_gallery_description" = "ギャラリー説明を検索"; +"filters_view.search_torrent_filenames" = "トレントファイル名を検索"; +"filters_view.only_show_galleries_with_torrents" = "トレントを含むもののみを表示"; +"filters_view.search_low_power_tags" = "低希望タグを検索"; +"filters_view.search_downvoted_tags" = "低評価タグを検索"; +"filters_view.search_expunged_galleries" = "削除済みのギャラリーを表示"; +"filters_view.set_minimum_rating" = "評価の下限を指定"; +"filters_view.minimum_rating" = "評価の下限"; +"filters_view.set_pages_range" = "ページ数範囲を指定"; +"filters_view.pages_range" = "ページ数範囲"; +"filters_view.disable_language_filter" = "言語フィルターを無効化"; +"filters_view.disable_uploader_filter" = "アップローダフィルターを無効化"; +"filters_view.disable_tags_filter" = "タグフィルターを無効化"; +"filters_view.reset_filters" = "既定値に戻す"; +"filters_view.advanced" = "高度"; +"filters_view.default_filter" = "既定フィルター"; // FilterRange -"enum.filter_range.value.search" = "検索"; -"enum.filter_range.value.global" = "全般"; -"enum.filter_range.value.watched" = "タグの購読"; +"filter_range.search" = "検索"; +"filter_range.global" = "全般"; +"filter_range.watched" = "タグの購読"; // MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 設定"; -"eh_setting_view.section.title.profile_settings" = "プロファイル設定"; -"eh_setting_view.title.selected_profile" = "選択されたプロファイル"; -"eh_setting_view.button.set_as_default" = "デフォルトに設定"; -"eh_setting_view.button.delete_profile" = "プロファイルを削除"; -"eh_setting_view.button.rename" = "名前を変更"; -"eh_setting_view.button.create_new" = "新規作成"; -"eh_setting_view.toolbar_item.button.done" = "完了"; - -"eh_setting_view.section.title.image_load_settings" = "画像読み込み設定"; -"eh_setting_view.title.load_images_through_the_hath_network" = "Hath ネットワーク経由で画像を読み込む"; -"eh_setting_view.title.browsing_country" = "閲覧国"; -"eh_setting_view.description.browsing_country" = "**%@** から本サイトを閲覧している、またはその国の VPN・プロキシを使用しているようです。本サイトはその地域の H@H クライアントから画像を読み込もうとしますが、もし自動検知の結果が誤っている、または特別な事情でほかの地域のクライアントを希望する場合(例えばスプリットトンネル VPN を使用している)は下に手動選択できます。"; +"eh_setting_view.host_settings" = "%@ 設定"; +"eh_setting_view.profile_settings" = "プロファイル設定"; +"eh_setting_view.selected_profile" = "選択されたプロファイル"; +"eh_setting_view.set_as_default" = "デフォルトに設定"; +"eh_setting_view.delete_profile" = "プロファイルを削除"; +"eh_setting_view.rename" = "名前を変更"; +"eh_setting_view.create_new" = "新規作成"; +"eh_setting_view.done" = "完了"; + +"eh_setting_view.image_load_settings" = "画像読み込み設定"; +"eh_setting_view.load_images_through_the_hath_network" = "Hath ネットワーク経由で画像を読み込む"; +"eh_setting_view.browsing_country" = "閲覧国"; +"eh_setting_view.browsing_country_description" = "**%@** から本サイトを閲覧している、またはその国の VPN・プロキシを使用しているようです。本サイトはその地域の H@H クライアントから画像を読み込もうとしますが、もし自動検知の結果が誤っている、または特別な事情でほかの地域のクライアントを希望する場合(例えばスプリットトンネル VPN を使用している)は下に手動選択できます。"; // EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "任意のクライアント"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "デフォルトポートのクライアントのみ"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "使わない [モダン / HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "使わない [レガシー / HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "推奨。"; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "遅くなることがあります。非標準発信ポートがファイヤーウォール・プロキシにブロックされた場合のみ有効にしてください。"; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "寄付者独占オプション。閲覧による割当額の消耗は激しくなります。厳重な問題が起こった場合以外おすすめしません。"; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "寄付者独占オプション。モダンブラウザでは機能しないこともあります。レガシー・旧型ブラウザの場合以外おすすめしません。"; - -"eh_setting_view.section.title.image_size_settings" = "画像サイズ設定"; -"eh_setting_view.title.image_resolution" = "画像解像度"; -"eh_setting_view.description.image_resolution" = "一般的に、オンライン閲覧の画像は 1280x までにリサンプリングされます。下のいずれかのリサンプリング解像度に変更できます。サーバー負荷軽減のため、1280x 以上の解像度は現時点で寄付者、Hath Perks 利用者または UID が 3,000,000 以下の者に限定されます。"; -"eh_setting_view.title.image_size" = "画像サイズ"; -"eh_setting_view.description.image_size" = "サイト側は画像を自動的にスクリーンに適したサイズにスケールしますが、手動的にその画像の表示サイズ最大値を指定することも可能です。ブラウザが処理を実行するため、画像のリサンプリングは行われません。(ゼロは無制限を意味します)"; -"eh_setting_view.title.horizontal" = "幅"; -"eh_setting_view.title.vertical" = "高さ"; +"load_through_hath_setting.any_client" = "任意のクライアント"; +"load_through_hath_setting.default_port_only" = "デフォルトポートのクライアントのみ"; +"load_through_hath_setting.modern_no" = "使わない [モダン / HTTPS]"; +"load_through_hath_setting.legacy_no" = "使わない [レガシー / HTTP]"; +"load_through_hath_setting.any_client_description" = "推奨。"; +"load_through_hath_setting.default_port_only_description" = "遅くなることがあります。非標準発信ポートがファイヤーウォール・プロキシにブロックされた場合のみ有効にしてください。"; +"load_through_hath_setting.modern_no_description" = "寄付者独占オプション。閲覧による割当額の消耗は激しくなります。厳重な問題が起こった場合以外おすすめしません。"; +"load_through_hath_setting.legacy_no_description" = "寄付者独占オプション。モダンブラウザでは機能しないこともあります。レガシー・旧型ブラウザの場合以外おすすめしません。"; + +"eh_setting_view.image_size_settings" = "画像サイズ設定"; +"eh_setting_view.image_resolution" = "画像解像度"; +"eh_setting_view.image_resolution_description" = "一般的に、オンライン閲覧の画像は 1280x までにリサンプリングされます。下のいずれかのリサンプリング解像度に変更できます。サーバー負荷軽減のため、1280x 以上の解像度は現時点で寄付者、Hath Perks 利用者または UID が 3,000,000 以下の者に限定されます。"; +"eh_setting_view.image_size" = "画像サイズ"; +"eh_setting_view.image_size_description" = "サイト側は画像を自動的にスクリーンに適したサイズにスケールしますが、手動的にその画像の表示サイズ最大値を指定することも可能です。ブラウザが処理を実行するため、画像のリサンプリングは行われません。(ゼロは無制限を意味します)"; +"eh_setting_view.horizontal" = "幅"; +"eh_setting_view.vertical" = "高さ"; // EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "自動"; +"image_resolution.auto" = "自動"; -"eh_setting_view.section.title.gallery_name_display" = "ギャラリー名表示"; -"eh_setting_view.title.gallery_name" = "ギャラリー名"; -"eh_setting_view.description.gallery_name" = "英語・ローマ字と日本語両方のタイトルを持つギャラリーはたくさんあります。どちらをデフォルトにしますか?"; +"eh_setting_view.gallery_name_display" = "ギャラリー名表示"; +"eh_setting_view.gallery_name" = "ギャラリー名"; +"eh_setting_view.gallery_name_description" = "英語・ローマ字と日本語両方のタイトルを持つギャラリーはたくさんあります。どちらをデフォルトにしますか?"; // EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "デフォルトタイトル"; -"enum.eh_setting.gallery_name.value.japanese" = "日本語タイトル(可能なら)"; +"gallery_name.default" = "デフォルトタイトル"; +"gallery_name.japanese" = "日本語タイトル(可能なら)"; -"eh_setting_view.section.title.archiver_settings" = "アーカイバー設定"; -"eh_setting_view.title.archiver_behavior" = "アーカイバー動作"; -"eh_setting_view.description.archiver_behavior" = "アーカイバーのデフォルト動作はオリジナルとリサンプルのアーカイブのコストと選択を確認してからリンクを提供し、それからそのリンクをクリックしたりどこかにペーストしたりすることも可能です。そのデフォルト動作はここで変更できます。"; +"eh_setting_view.archiver_settings" = "アーカイバー設定"; +"eh_setting_view.archiver_behavior" = "アーカイバー動作"; +"eh_setting_view.archiver_behavior_description" = "アーカイバーのデフォルト動作はオリジナルとリサンプルのアーカイブのコストと選択を確認してからリンクを提供し、それからそのリンクをクリックしたりどこかにペーストしたりすることも可能です。そのデフォルト動作はここで変更できます。"; // EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "手動で選択、手動で開始(デフォルト)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "手動で選択、自動で開始"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "自動でオリジナルを選択、手動で開始"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "自動でオリジナルを選択、自動で開始"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "自動でリサンプルを選択、手動で開始"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "自動でリサンプルを選択、自動で開始"; - -"eh_setting_view.section.title.front_page_settings" = "フロントページ設定"; -"eh_setting_view.title.display_mode" = "表示モード"; -"eh_setting_view.description.display_mode" = "フロント・検索ページで使う表示モードはどれにしますか?"; -"eh_setting_view.section.title.show_search_range_indicator" = "検索範囲インジケーター"; -"eh_setting_view.title.show_search_range_indicator" = "検索範囲インジケーターを表示"; -"eh_setting_view.description.gallery_category" = "フロント・検索ページでどれらのカテゴリーのギャラリーを表示しますか?"; +"eh_setting.archiver_behavior.manual_select_manual_start" = "手動で選択、手動で開始(デフォルト)"; +"eh_setting.archiver_behavior.manual_select_auto_start" = "手動で選択、自動で開始"; +"eh_setting.archiver_behavior.auto_select_original_manual_start" = "自動でオリジナルを選択、手動で開始"; +"eh_setting.archiver_behavior.auto_select_original_auto_start" = "自動でオリジナルを選択、自動で開始"; +"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "自動でリサンプルを選択、手動で開始"; +"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "自動でリサンプルを選択、自動で開始"; + +"eh_setting_view.front_page_settings" = "フロントページ設定"; +"eh_setting_view.display_mode" = "表示モード"; +"eh_setting_view.display_mode_description" = "フロント・検索ページで使う表示モードはどれにしますか?"; +"eh_setting_view.show_search_range_indicator" = "検索範囲インジケーター"; +"eh_setting_view.show_search_range_indicator_description" = "検索範囲インジケーターを表示"; +"eh_setting_view.gallery_category" = "フロント・検索ページでどれらのカテゴリーのギャラリーを表示しますか?"; // EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "コンパクト"; -"enum.eh_setting.display_mode.value.thumbnail" = "サムネイル"; -"enum.eh_setting.display_mode.value.extended" = "拡張"; -"enum.eh_setting.display_mode.value.minimal" = "最小化"; -"enum.eh_setting.display_mode.value.minimalPlus" = "最小化+"; - -"eh_setting_view.section.title.optional_UI_elements" = "UI の表示制御"; -"eh_setting_view.description.optional_UI_elements" = "一部の従来の UI はデフォルトで無効になっています。ここで有効にすることができます。"; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "ギャラリーのサムネイルセレクタ"; - -"eh_setting_view.section.title.favorites" = "お気に入り"; -"eh_setting_view.description.favorite_categories" = "ここではお気に入りカテゴリー名の変更ができます。"; -"eh_setting_view.title.favorites_sort_order" = "お気に入りの並び替え"; -"eh_setting_view.description.favorites_sort_order" = "お気に入りページのデフォルト並び替え順序も変更可能です。注意:平成28年3月の改修前にお気に入りに追加した項目はタイムスタンプが含まれていないため、この設定を無視して代わりにギャラリーの投稿時間を使います。"; +"display_mode.compact" = "コンパクト"; +"display_mode.thumbnail" = "サムネイル"; +"display_mode.extended" = "拡張"; +"display_mode.minimal" = "最小化"; +"display_mode.minimalPlus" = "最小化+"; + +"eh_setting_view.optional_UI_elements" = "UI の表示制御"; +"eh_setting_view.optional_UI_elements_description" = "一部の従来の UI はデフォルトで無効になっています。ここで有効にすることができます。"; +"eh_setting_view.enable_gallery_thumbnail_selector" = "ギャラリーのサムネイルセレクタ"; + +"eh_setting_view.favorites" = "お気に入り"; +"eh_setting_view.favorite_categories" = "ここではお気に入りカテゴリー名の変更ができます。"; +"eh_setting_view.favorites_sort_order" = "お気に入りの並び替え"; +"eh_setting_view.favorites_sort_order_description" = "お気に入りページのデフォルト並び替え順序も変更可能です。注意:平成28年3月の改修前にお気に入りに追加した項目はタイムスタンプが含まれていないため、この設定を無視して代わりにギャラリーの投稿時間を使います。"; // EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "更新時間の新しい順"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "気に入った時間の新しい順"; +"favorites_sort_order.last_update_time" = "更新時間の新しい順"; +"favorites_sort_order.favorited_time" = "気に入った時間の新しい順"; -"eh_setting_view.section.title.ratings" = "評価"; -"eh_setting_view.title.ratings_color" = "評価の色"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "デフォルトでは、評価済みのギャラリーは 2 以下の評価に赤い星を使う、2.5 ~ 4 には緑、4.5 以上には青。下に色の組み合わせを入れることでこのルールをカスタマイズできます。一つの星の色は一つの文字で指定します。デフォルトの「RRGGB」は「一番目と二番目の星は赤(Red)、三番と四番は緑(Green)、五番は青(Blue)」を意味します。黄色(Yellow)も使用可能です。R・G・B・Yで組み合わせた五文字はどれも機能します。"; +"eh_setting_view.ratings" = "評価"; +"eh_setting_view.ratings_color" = "評価の色"; +"eh_setting_view.ratings_color_prompt" = "RRGGB"; +"eh_setting_view.ratings_color_description" = "デフォルトでは、評価済みのギャラリーは 2 以下の評価に赤い星を使う、2.5 ~ 4 には緑、4.5 以上には青。下に色の組み合わせを入れることでこのルールをカスタマイズできます。一つの星の色は一つの文字で指定します。デフォルトの「RRGGB」は「一番目と二番目の星は赤(Red)、三番と四番は緑(Green)、五番は青(Blue)」を意味します。黄色(Yellow)も使用可能です。R・G・B・Yで組み合わせた五文字はどれも機能します。"; -"eh_setting_view.section.title.tag_filtering_threshold" = "タグフィルタリングしきい値"; -"eh_setting_view.title.tag_filtering_threshold" = "タグフィルタリングしきい値"; -"eh_setting_view.description.tag_filtering_threshold" = "負の重み付きでマイタグに追加することでタグをソフトフィルタリングすることができます。もしあるギャラリーが持つタグの重み総和がこのしきい値より低ければ、そのギャラリーはフィルタリングされます。このしきい値はゼロから -9999 まで設定できます。"; +"eh_setting_view.tag_filtering_threshold" = "タグフィルタリングしきい値"; +"eh_setting_view.tag_filtering_threshold_description" = "負の重み付きでマイタグに追加することでタグをソフトフィルタリングすることができます。もしあるギャラリーが持つタグの重み総和がこのしきい値より低ければ、そのギャラリーはフィルタリングされます。このしきい値はゼロから -9999 まで設定できます。"; -"eh_setting_view.section.title.tag_watching_threshold" = "タグ購読しきい値"; -"eh_setting_view.title.tag_watching_threshold" = "タグ購読しきい値"; -"eh_setting_view.description.tag_watching_threshold" = "もしあるギャラリーは最近投稿されたもので、少なくても一つの正の重みの購読タグを持っていて、購読タグの重み総和がこのしきい値と同じまたはより高ければ、そのギャラリーは購読画面で表示されます。このしきい値はゼロから 9999 まで設定できます。"; +"eh_setting_view.tag_watching_threshold" = "タグ購読しきい値"; +"eh_setting_view.tag_watching_threshold_description" = "もしあるギャラリーは最近投稿されたもので、少なくても一つの正の重みの購読タグを持っていて、購読タグの重み総和がこのしきい値と同じまたはより高ければ、そのギャラリーは購読画面で表示されます。このしきい値はゼロから 9999 まで設定できます。"; -"eh_setting_view.section.title.filtered_removal_count" = "フィルター除去数"; -"eh_setting_view.description.filtered_removal_count" = "「既定フィルターにより本ページから XX 個のギャラリーが除去されました」を表示しますか?"; -"eh_setting_view.title.show_filtered_removal_count" = "フィルター除去数を表示"; +"eh_setting_viewfiltered_removal_count" = "フィルター除去数"; +"eh_setting_view.filtered_removal_count_description" = "「既定フィルターにより本ページから XX 個のギャラリーが除去されました」を表示しますか?"; +"eh_setting_view.show_filtered_removal_count" = "フィルター除去数を表示"; -"eh_setting_view.section.title.excluded_languages" = "排除された言語"; -"eh_setting_view.description.excluded_languages" = "特定の言語のギャラリーをリストと検索結果から隠したい場合、下に選択してください。注意:どんな検索クエリーを使ってもこれらの言語のギャラリーは表示されません。"; +"eh_setting_view.excluded_languages" = "排除された言語"; +"eh_setting_view.excluded_languages_description" = "特定の言語のギャラリーをリストと検索結果から隠したい場合、下に選択してください。注意:どんな検索クエリーを使ってもこれらの言語のギャラリーは表示されません。"; // EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "オリジナル"; -"enum.eh_setting.excluded_languages_category.value.translated" = "翻訳版"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "書き換え版"; - -"eh_setting_view.section.title.excluded_uploaders" = "排除された投稿者"; -"eh_setting_view.description.excluded_uploaders" = "特定の投稿者のギャラリーをリストと検索結果から隠したい場合、下に名前を記入してください。一行に一つのユーザー名で。注意:どんな検索クエリーを使ってもこの投稿者たちのギャラリーは表示されません。"; -"eh_setting_view.description.excluded_uploaders_count" = "現時点で **%@ / %@** の排除スロットが使用済みです。"; - -"eh_setting_view.section.title.search_result_count" = "検索結果数"; -"eh_setting_view.title.result_count" = "結果数"; -"eh_setting_view.description.result_count" = "インデックス・トレントの検索ページで、各ページにどれくらいの結果数がお望みですか?\n(「Hath Perk:ページング拡張」が必要)"; - -"eh_setting_view.section.title.thumbnail_settings" = "サムネイル設定"; -"eh_setting_view.title.thumbnail_load_timing" = "サムネイル読み込みタイミング"; -"eh_setting_view.description.thumbnail_load_timing" = "リストでは、どんなタイミングでホームページのマウスオーバーサムネイルを読み込みますか?"; -"eh_setting_view.description.thumbnail_configuration" = "すべてのギャラリーに適応するデフォルトのサムネイル構成を設定できます。"; -"eh_setting_view.title.thumbnail_size" = "サイズ"; -"eh_setting_view.title.thumbnail_row_count" = "行数"; +"excluded_languages_category.original" = "オリジナル"; +"excluded_languages_category.translated" = "翻訳版"; +"excluded_languages_category.rewrite" = "書き換え版"; + +"eh_setting_view.excluded_uploaders" = "排除された投稿者"; +"eh_setting_view.excluded_uploaders_description" = "特定の投稿者のギャラリーをリストと検索結果から隠したい場合、下に名前を記入してください。一行に一つのユーザー名で。注意:どんな検索クエリーを使ってもこの投稿者たちのギャラリーは表示されません。"; +"eh_setting_view.excluded_uploaders_count" = "現時点で **%@ / %@** の排除スロットが使用済みです。"; + +"eh_setting_view.search_result_count" = "検索結果数"; +"eh_setting_view.result_count" = "結果数"; +"eh_setting_view.result_count_description" = "インデックス・トレントの検索ページで、各ページにどれくらいの結果数がお望みですか?\n(「Hath Perk:ページング拡張」が必要)"; + +"eh_setting_view.thumbnail_settings" = "サムネイル設定"; +"eh_setting_view.thumbnail_load_timing" = "サムネイル読み込みタイミング"; +"eh_setting_view.thumbnail_load_timing_description" = "リストでは、どんなタイミングでホームページのマウスオーバーサムネイルを読み込みますか?"; +"eh_setting_view.thumbnail_configuration" = "すべてのギャラリーに適応するデフォルトのサムネイル構成を設定できます。"; +"eh_setting_view.thumbnail_size" = "サイズ"; +"eh_setting_view.thumbnail_row_count" = "行数"; // EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "マウス経過時"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "ページ読み込み時"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "ページの読み込みが速くなりますが、サムネイルの表示はちょっぴり遅れてきます。"; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "ページの読み込み時間が増えますが、サムネイルはすぐに表示できます。"; +"thumbnail_load_timing.on_mouse_over" = "マウス経過時"; +"thumbnail_load_timing.on_page_load" = "ページ読み込み時"; +"thumbnail_load_timing.on_mouse_over_description" = "ページの読み込みが速くなりますが、サムネイルの表示はちょっぴり遅れてきます。"; +"thumbnail_load_timing.on_page_load_description" = "ページの読み込み時間が増えますが、サムネイルはすぐに表示できます。"; // EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "普通"; -"enum.eh_setting.thumbnail_size.value.large" = "大きめ"; -"enum.eh_setting.thumbnail_size.value.small" = "小さめ"; -"enum.eh_setting.thumbnail_size.value.auto" = "自動"; - -"eh_setting_view.section.title.cover_scaling" = "カバースケーリング"; -"eh_setting_view.title.scale_factor" = "スケール係数"; -"eh_setting_view.description.cover_scale_factor" = "サムネイル・拡張表示モードでのカバーを 75%% ~ 150%% にスケールすることができます。"; - -"eh_setting_view.section.title.viewport_override" = "表示領域オーバーライド"; -"eh_setting_view.title.virtual_width" = "仮想幅"; -"eh_setting_view.description.virtual_width" = "モバイルデバイスの仮想幅をオーバーライドすることができます。一般的にはデバイスの DPI に基づいて自動的に決定されます。例えばサムネイルスケール係数が 100%% の場合、640 ~ 1400 の幅が合理的です。"; - -"eh_setting_view.section.title.gallery_comments" = "ギャラリーコメント"; -"eh_setting_view.title.comments_sort_order" = "コメントの並び替え"; -"eh_setting_view.title.comments_votes_show_timing" = "コメントスコア表示タイミング"; +"thumbnail_size.normal" = "普通"; +"thumbnail_size.large" = "大きめ"; +"thumbnail_size.small" = "小さめ"; +"thumbnail_size.auto" = "自動"; + +"eh_setting_view.cover_scaling" = "カバースケーリング"; +"eh_setting_view.scale_factor" = "スケール係数"; +"eh_setting_view.cover_scale_factor" = "サムネイル・拡張表示モードでのカバーを 75%% ~ 150%% にスケールすることができます。"; + +"eh_setting_view.viewport_override" = "表示領域オーバーライド"; +"eh_setting_view.virtual_width" = "仮想幅"; +"eh_setting_view.virtual_width_description" = "モバイルデバイスの仮想幅をオーバーライドすることができます。一般的にはデバイスの DPI に基づいて自動的に決定されます。例えばサムネイルスケール係数が 100%% の場合、640 ~ 1400 の幅が合理的です。"; + +"eh_setting_view.gallery_comments" = "ギャラリーコメント"; +"eh_setting_view.comments_sort_order" = "コメントの並び替え"; +"eh_setting_view.comments_votes_show_timing" = "コメントスコア表示タイミング"; // EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "コメントの古い順"; -"enum.eh_setting.comments_sort_order.value.recent" = "コメントの新しい順"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "スコアの高い順"; +"comments_sort_order.oldest" = "コメントの古い順"; +"comments_sort_order.recent" = "コメントの新しい順"; +"comments_sort_order.highest_score" = "スコアの高い順"; // EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "スコアに経過・クリック時"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "常時"; +"comments_votes_show_timing.on_hover_or_click" = "スコアに経過・クリック時"; +"comments_votes_show_timing.always" = "常時"; -"eh_setting_view.section.title.gallery_tags" = "ギャラリータグ"; -"eh_setting_view.title.tags_sort_order" = "タグの並び替え"; +"eh_setting_view.gallery_tags" = "ギャラリータグ"; +"eh_setting_view.tags_sort_order" = "タグの並び替え"; // EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "アルファベット順"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "タグパワーの高い順"; +"tags_sort_order.alphabetical" = "アルファベット順"; +"tags_sort_order.tag_power" = "タグパワーの高い順"; -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "ギャラリーサムネイルのラベル"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "ギャラリーサムネイルの下にラベルを表示"; +"eh_setting_view.gallery_page_thumbnail_labeling" = "ギャラリーサムネイルのラベル"; +"eh_setting_view.show_label_below_gallery_thumbnails" = "ギャラリーサムネイルの下にラベルを表示"; -"eh_setting_view.section.title.hath_local_network_host" = "Hath ローカルネットワークホスト"; -"eh_setting_view.title.ip_address_port" = "IP アドレス:ポート"; -"eh_setting_view.description.ip_address_port" = "ローカルネットワークで今と同じパブリック IP を使う H@H クライアントがお持ちの場合、この設定が役立ちます。ルーターがバグが多くてリクエストを自分の IP にルートすることができないこともあります、それをこの設定で回避できます。\nH@H クライアントが今と同じデバイスで運行している場合はループバックアドレス(127.0.0.1:ポート)を使ってください。別のデバイスの場合はそのローカル IP を使ってください。かなりのブラウザの構成では外部サイトがローカル IP にアクセスすることをブロックしています、この設定を有効にするには本サイトをホワイトリストに入れてください。"; -"eh_setting_view.section.title.original_images" = "オリジナル画像を使いますか?リサンプリングされた画像は、上記の解像度で「自動」以外を選択し、該当する画像の方が幅が広い場合、またはオリジナル画像が 10 MiB(一年以上前のギャラリーの場合は 4 MiB)より大きい場合に使用されます。"; -"eh_setting_view.title.use_original_images" = "オリジナル画像を使う"; +"eh_setting_view.original_images" = "オリジナル画像を使いますか?リサンプリングされた画像は、上記の解像度で「自動」以外を選択し、該当する画像の方が幅が広い場合、またはオリジナル画像が 10 MiB(一年以上前のギャラリーの場合は 4 MiB)より大きい場合に使用されます。"; +"eh_setting_view.use_original_images" = "オリジナル画像を使う"; -"eh_setting_view.section.title.multi_page_viewer" = "マルチページビューア"; -"eh_setting_view.title.use_multi_page_viewer" = "マルチページビューアを使う"; -"eh_setting_view.title.display_style" = "表示仕様"; -"eh_setting_view.title.show_thumbnail_pane" = "サムネイルパネルを表示"; +"eh_setting_view.multi_page_viewer" = "マルチページビューア"; +"eh_setting_view.use_multi_page_viewer" = "マルチページビューアを使う"; +"eh_setting_view.display_style" = "表示仕様"; +"eh_setting_view.show_thumbnail_pane" = "サムネイルパネルを表示"; // EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "左寄せ、幅によってスケール"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "中央揃え、幅によってスケール"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "中央揃え、常時スケール"; +"multiple_page_viewer_style.align_left_scale_if_over_width" = "左寄せ、幅によってスケール"; +"multiple_page_viewer_style.align_center_scale_if_over_width" = "中央揃え、幅によってスケール"; +"multiple_page_viewer_style.align_center_always_scale" = "中央揃え、常時スケール"; // EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "表示しない"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "ページ番号のみ表示"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "ページ番号と名前を表示"; +"gallery_page_numbering.none" = "表示しない"; +"gallery_page_numbering.page_number_only" = "ページ番号のみ表示"; +"gallery_page_numbering.page_number_and_name" = "ページ番号と名前を表示"; // MARK: Category -"enum.category.value.doujinshi" = "同人誌"; -"enum.category.value.manga" = "漫画"; -"enum.category.value.artist_CG" = "イラスト"; -"enum.category.value.game_CG" = "ゲーム CG"; -"enum.category.value.western" = "西洋"; -"enum.category.value.non_h" = "健全"; -"enum.category.value.image_set" = "画像集"; -"enum.category.value.cosplay" = "コスプレ"; -"enum.category.value.asian_porn" = "アジア"; -"enum.category.value.misc" = "その他"; -"enum.category.value.private" = "プライベート"; +"category.doujinshi" = "同人誌"; +"category.manga" = "漫画"; +"category.artist_CG" = "イラスト"; +"category.game_CG" = "ゲーム CG"; +"category.western" = "西洋"; +"category.non_h" = "健全"; +"category.image_set" = "画像集"; +"category.cosplay" = "コスプレ"; +"category.asian_porn" = "アジア"; +"category.misc" = "その他"; +"category.private" = "プライベート"; // MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "再分類"; -"enum.tag_namespace.value.language" = "言語"; -"enum.tag_namespace.value.parody" = "原作"; -"enum.tag_namespace.value.character" = "キャラ"; -"enum.tag_namespace.value.group" = "団体"; -"enum.tag_namespace.value.artist" = "作者"; -"enum.tag_namespace.value.male" = "男性"; -"enum.tag_namespace.value.female" = "女性"; -"enum.tag_namespace.value.mixed" = "混在性別"; -"enum.tag_namespace.value.cosplayer" = "レイヤー"; -"enum.tag_namespace.value.other" = "その他"; -"enum.tag_namespace.value.temp" = "一時的"; +"tag_namespace.reclass" = "再分類"; +"tag_namespace.language" = "言語"; +"tag_namespace.parody" = "原作"; +"tag_namespace.character" = "キャラ"; +"tag_namespace.group" = "団体"; +"tag_namespace.artist" = "作者"; +"tag_namespace.male" = "男性"; +"tag_namespace.female" = "女性"; +"tag_namespace.mixed" = "混在性別"; +"tag_namespace.cosplayer" = "レイヤー"; +"tag_namespace.other" = "その他"; +"tag_namespace.temp" = "一時的"; // MARK: Language -"enum.language.value.invalid" = "無効"; -"enum.language.value.other" = "その他"; -"enum.language.value.afrikaans" = "アフリカーンス語"; -"enum.language.value.albanian" = "アルバニア語"; -"enum.language.value.arabic" = "アラビア語"; -"enum.language.value.bengali" = "ベンガル語"; -"enum.language.value.bosnian" = "ボスニア語"; -"enum.language.value.bulgarian" = "ブルガリア語"; -"enum.language.value.burmese" = "ビルマ語"; -"enum.language.value.catalan" = "カタルーニャ語"; -"enum.language.value.cebuano" = "セブアノ語"; -"enum.language.value.chinese" = "中国語"; -"enum.language.value.croatian" = "クロアチア語"; -"enum.language.value.czech" = "チェコ語"; -"enum.language.value.danish" = "デンマーク語"; -"enum.language.value.dutch" = "オランダ語"; -"enum.language.value.english" = "英語"; -"enum.language.value.esperanto" = "国際語"; -"enum.language.value.estonian" = "エストニア語"; -"enum.language.value.finnish" = "フィンランド語"; -"enum.language.value.french" = "フランス語"; -"enum.language.value.georgian" = "グルジア語"; -"enum.language.value.german" = "ドイツ語"; -"enum.language.value.greek" = "ギリシア語"; -"enum.language.value.hebrew" = "ヘブライ語"; -"enum.language.value.hindi" = "ヒンディー語"; -"enum.language.value.hmong" = "ミャオ語"; -"enum.language.value.hungarian" = "ハンガリー語"; -"enum.language.value.indonesian" = "インドネシア語"; -"enum.language.value.italian" = "イタリア語"; -"enum.language.value.japanese" = "日本語"; -"enum.language.value.kazakh" = "カザフ語"; -"enum.language.value.khmer" = "クメール語"; -"enum.language.value.korean" = "韓国語"; -"enum.language.value.kurdish" = "クルド語"; -"enum.language.value.lao" = "ラーオ語"; -"enum.language.value.latin" = "ラテン語"; -"enum.language.value.mongolian" = "モンゴル語"; -"enum.language.value.ndebele" = "ンデベレ語"; -"enum.language.value.nepali" = "ネパール語"; -"enum.language.value.norwegian" = "ノルウェー語"; -"enum.language.value.oromo" = "オロモ語"; -"enum.language.value.pashto" = "パシュトー語"; -"enum.language.value.persian" = "ペルシア語"; -"enum.language.value.polish" = "ポーランド語"; -"enum.language.value.portuguese" = "ポルトガル語"; -"enum.language.value.punjabi" = "パンジャーブ語"; -"enum.language.value.romanian" = "ルーマニア語"; -"enum.language.value.russian" = "ロシア語"; -"enum.language.value.sango" = "サンゴ語"; -"enum.language.value.serbian" = "セルビア語"; -"enum.language.value.shona" = "ショナ語"; -"enum.language.value.slovak" = "スロバキア語"; -"enum.language.value.slovenian" = "スロベニア語"; -"enum.language.value.somali" = "ソマリ語"; -"enum.language.value.spanish" = "スペイン語"; -"enum.language.value.swahili" = "スワヒリ語"; -"enum.language.value.swedish" = "スウェーデン語"; -"enum.language.value.tagalog" = "タガログ語"; -"enum.language.value.thai" = "タイ語"; -"enum.language.value.tigrinya" = "ティグリニャ語"; -"enum.language.value.turkish" = "トルコ語"; -"enum.language.value.ukrainian" = "ウクライナ語"; -"enum.language.value.urdu" = "ウルドゥー語"; -"enum.language.value.vietnamese" = "ベトナム語"; -"enum.language.value.zulu" = "ズールー語"; +"language.invalid" = "無効"; +"language.other" = "その他"; +"language.afrikaans" = "アフリカーンス語"; +"language.albanian" = "アルバニア語"; +"language.arabic" = "アラビア語"; +"language.bengali" = "ベンガル語"; +"language.bosnian" = "ボスニア語"; +"language.bulgarian" = "ブルガリア語"; +"language.burmese" = "ビルマ語"; +"language.catalan" = "カタルーニャ語"; +"language.cebuano" = "セブアノ語"; +"language.chinese" = "中国語"; +"language.croatian" = "クロアチア語"; +"language.czech" = "チェコ語"; +"language.danish" = "デンマーク語"; +"language.dutch" = "オランダ語"; +"language.english" = "英語"; +"language.esperanto" = "国際語"; +"language.estonian" = "エストニア語"; +"language.finnish" = "フィンランド語"; +"language.french" = "フランス語"; +"language.georgian" = "グルジア語"; +"language.german" = "ドイツ語"; +"language.greek" = "ギリシア語"; +"language.hebrew" = "ヘブライ語"; +"language.hindi" = "ヒンディー語"; +"language.hmong" = "ミャオ語"; +"language.hungarian" = "ハンガリー語"; +"language.indonesian" = "インドネシア語"; +"language.italian" = "イタリア語"; +"language.japanese" = "日本語"; +"language.kazakh" = "カザフ語"; +"language.khmer" = "クメール語"; +"language.korean" = "韓国語"; +"language.kurdish" = "クルド語"; +"language.lao" = "ラーオ語"; +"language.latin" = "ラテン語"; +"language.mongolian" = "モンゴル語"; +"language.ndebele" = "ンデベレ語"; +"language.nepali" = "ネパール語"; +"language.norwegian" = "ノルウェー語"; +"language.oromo" = "オロモ語"; +"language.pashto" = "パシュトー語"; +"language.persian" = "ペルシア語"; +"language.polish" = "ポーランド語"; +"language.portuguese" = "ポルトガル語"; +"language.punjabi" = "パンジャーブ語"; +"language.romanian" = "ルーマニア語"; +"language.russian" = "ロシア語"; +"language.sango" = "サンゴ語"; +"language.serbian" = "セルビア語"; +"language.shona" = "ショナ語"; +"language.slovak" = "スロバキア語"; +"language.slovenian" = "スロベニア語"; +"language.somali" = "ソマリ語"; +"language.spanish" = "スペイン語"; +"language.swahili" = "スワヒリ語"; +"language.swedish" = "スウェーデン語"; +"language.tagalog" = "タガログ語"; +"language.thai" = "タイ語"; +"language.tigrinya" = "ティグリニャ語"; +"language.turkish" = "トルコ語"; +"language.ukrainian" = "ウクライナ語"; +"language.urdu" = "ウルドゥー語"; +"language.vietnamese" = "ベトナム語"; +"language.zulu" = "ズールー語"; // MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "自動検出"; -"enum.browsing_country.name.afghanistan" = "アフガニスタン"; -"enum.browsing_country.name.aland_islands" = "オーランド諸島"; -"enum.browsing_country.name.albania" = "アルバニア"; -"enum.browsing_country.name.algeria" = "アルジェリア"; -"enum.browsing_country.name.american_samoa" = "アメリカ領サモア"; -"enum.browsing_country.name.andorra" = "アンドラ"; -"enum.browsing_country.name.angola" = "アンゴラ"; -"enum.browsing_country.name.anguilla" = "アンギラ"; -"enum.browsing_country.name.antarctica" = "南極大陸"; -"enum.browsing_country.name.antigua_and_barbuda" = "アンティグア・バーブーダ"; -"enum.browsing_country.name.argentina" = "アルゼンチン"; -"enum.browsing_country.name.armenia" = "アルメニア"; -"enum.browsing_country.name.aruba" = "アルバ"; -"enum.browsing_country.name.asia_pacific_region" = "アジア太平洋地域"; -"enum.browsing_country.name.australia" = "オーストラリア"; -"enum.browsing_country.name.austria" = "オーストリア"; -"enum.browsing_country.name.azerbaijan" = "アゼルバイジャン"; -"enum.browsing_country.name.bahamas" = "バハマ"; -"enum.browsing_country.name.bahrain" = "バーレーン"; -"enum.browsing_country.name.bangladesh" = "バングラデシュ"; -"enum.browsing_country.name.barbados" = "バルバドス"; -"enum.browsing_country.name.belarus" = "ベラルーシ"; -"enum.browsing_country.name.belgium" = "ベルギー"; -"enum.browsing_country.name.belize" = "ベリーズ"; -"enum.browsing_country.name.benin" = "ベナン"; -"enum.browsing_country.name.bermuda" = "バミューダ諸島"; -"enum.browsing_country.name.bhutan" = "ブータン"; -"enum.browsing_country.name.bolivia" = "ボリビア"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "ボネール、シント・ユースタティウスおよびサバ"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "ボスニア・ヘルツェゴビナ"; -"enum.browsing_country.name.botswana" = "ボツワナ"; -"enum.browsing_country.name.bouvet_island" = "ブーベ島"; -"enum.browsing_country.name.brazil" = "ブラジル"; -"enum.browsing_country.name.british_indian_ocean_territory" = "イギリス領インド洋地域"; -"enum.browsing_country.name.brunei_darussalam" = "ブルネイ・ダルサラーム"; -"enum.browsing_country.name.bulgaria" = "ブルガリア"; -"enum.browsing_country.name.burkina_faso" = "ブルキナファソ"; -"enum.browsing_country.name.burundi" = "ブルンジ"; -"enum.browsing_country.name.cambodia" = "カンボジア"; -"enum.browsing_country.name.cameroon" = "カメルーン"; -"enum.browsing_country.name.canada" = "カナダ"; -"enum.browsing_country.name.cape_verde" = "カーボベルデ"; -"enum.browsing_country.name.cayman_islands" = "ケイマン諸島"; -"enum.browsing_country.name.central_african_republic" = "中央アフリカ共和国"; -"enum.browsing_country.name.chad" = "チャド"; -"enum.browsing_country.name.chile" = "チリ"; -"enum.browsing_country.name.china" = "中華人民共和国"; -"enum.browsing_country.name.christmas_island" = "クリスマス島"; -"enum.browsing_country.name.cocos_islands" = "ココス諸島"; -"enum.browsing_country.name.colombia" = "コロンビア"; -"enum.browsing_country.name.comoros" = "コモロ"; -"enum.browsing_country.name.congo" = "コンゴ共和国"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "コンゴ民主共和国"; -"enum.browsing_country.name.cook_islands" = "クック諸島"; -"enum.browsing_country.name.costa_rica" = "コスタリカ"; -"enum.browsing_country.name.cote_d_ivoire" = "コートジボワール"; -"enum.browsing_country.name.croatia" = "クロアチア"; -"enum.browsing_country.name.cuba" = "キューバ"; -"enum.browsing_country.name.curacao" = "キュラソー島"; -"enum.browsing_country.name.cyprus" = "キプロス"; -"enum.browsing_country.name.czech_republic" = "チェコ"; -"enum.browsing_country.name.denmark" = "デンマーク"; -"enum.browsing_country.name.djibouti" = "ジブチ"; -"enum.browsing_country.name.dominica" = "ドミニカ"; -"enum.browsing_country.name.dominican_republic" = "ドミニカ共和国"; -"enum.browsing_country.name.ecuador" = "エクアドル"; -"enum.browsing_country.name.egypt" = "エジプト"; -"enum.browsing_country.name.el_salvador" = "エルサルバドル"; -"enum.browsing_country.name.equatorial_guinea" = "赤道ギニア"; -"enum.browsing_country.name.eritrea" = "エリトリア"; -"enum.browsing_country.name.estonia" = "エストニア"; -"enum.browsing_country.name.ethiopia" = "エチオピア"; -"enum.browsing_country.name.europe" = "ヨーロッパ"; -"enum.browsing_country.name.falkland_islands" = "フォークランド諸島"; -"enum.browsing_country.name.faroe_islands" = "フェロー諸島"; -"enum.browsing_country.name.fiji" = "フィジー"; -"enum.browsing_country.name.finland" = "フィンランド"; -"enum.browsing_country.name.france" = "フランス"; -"enum.browsing_country.name.french_guiana" = "フランス領ギアナ"; -"enum.browsing_country.name.french_polynesia" = "フランス領ポリネシア"; -"enum.browsing_country.name.french_southern_territories" = "フランス領南方・南極地域"; -"enum.browsing_country.name.gabon" = "ガボン"; -"enum.browsing_country.name.gambia" = "ガンビア"; -"enum.browsing_country.name.georgia" = "ジョージア"; -"enum.browsing_country.name.germany" = "ドイツ"; -"enum.browsing_country.name.ghana" = "ガーナ"; -"enum.browsing_country.name.gibraltar" = "ジブラルタル"; -"enum.browsing_country.name.greece" = "ギリシャ"; -"enum.browsing_country.name.greenland" = "グリーンランド"; -"enum.browsing_country.name.grenada" = "グレナダ"; -"enum.browsing_country.name.guadeloupe" = "グアドループ"; -"enum.browsing_country.name.guam" = "グアム"; -"enum.browsing_country.name.guatemala" = "グアテマラ"; -"enum.browsing_country.name.guernsey" = "ガーンジー"; -"enum.browsing_country.name.guinea" = "ギニア"; -"enum.browsing_country.name.guinea_bissau" = "ギニアビサウ"; -"enum.browsing_country.name.guyana" = "ガイアナ"; -"enum.browsing_country.name.haiti" = "ハイチ"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "ハード島とマクドナルド諸島"; -"enum.browsing_country.name.vatican_city_state" = "バチカン市国"; -"enum.browsing_country.name.honduras" = "ホンジュラス"; -"enum.browsing_country.name.hong_kong" = "香港"; -"enum.browsing_country.name.hungary" = "ハンガリー"; -"enum.browsing_country.name.iceland" = "アイスランド"; -"enum.browsing_country.name.india" = "インド"; -"enum.browsing_country.name.indonesia" = "インドネシア"; -"enum.browsing_country.name.iran" = "イラン"; -"enum.browsing_country.name.iraq" = "イラク"; -"enum.browsing_country.name.ireland" = "アイルランド"; -"enum.browsing_country.name.isle_of_man" = "マン島"; -"enum.browsing_country.name.israel" = "イスラエル"; -"enum.browsing_country.name.italy" = "イタリア"; -"enum.browsing_country.name.jamaica" = "ジャマイカ"; -"enum.browsing_country.name.japan" = "日本"; -"enum.browsing_country.name.jersey" = "ジャージー"; -"enum.browsing_country.name.jordan" = "ヨルダン"; -"enum.browsing_country.name.kazakhstan" = "カザフスタン"; -"enum.browsing_country.name.kenya" = "ケニア"; -"enum.browsing_country.name.kiribati" = "キリバス"; -"enum.browsing_country.name.kuwait" = "クウェート"; -"enum.browsing_country.name.kyrgyzstan" = "キルギス"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "ラオス"; -"enum.browsing_country.name.latvia" = "ラトビア"; -"enum.browsing_country.name.lebanon" = "レバノン"; -"enum.browsing_country.name.lesotho" = "レソト"; -"enum.browsing_country.name.liberia" = "リベリア"; -"enum.browsing_country.name.libya" = "リビア"; -"enum.browsing_country.name.liechtenstein" = "リヒテンシュタイン"; -"enum.browsing_country.name.lithuania" = "リトアニア"; -"enum.browsing_country.name.luxembourg" = "ルクセンブルク"; -"enum.browsing_country.name.macau" = "マカオ"; -"enum.browsing_country.name.macedonia" = "マケドニア"; -"enum.browsing_country.name.madagascar" = "マダガスカル"; -"enum.browsing_country.name.malawi" = "マラウイ"; -"enum.browsing_country.name.malaysia" = "マレーシア"; -"enum.browsing_country.name.maldives" = "モルディブ"; -"enum.browsing_country.name.mali" = "マリ"; -"enum.browsing_country.name.malta" = "マルタ"; -"enum.browsing_country.name.marshall_islands" = "マーシャル諸島"; -"enum.browsing_country.name.martinique" = "マルティニーク"; -"enum.browsing_country.name.mauritania" = "モーリタニア"; -"enum.browsing_country.name.mauritius" = "モーリシャス"; -"enum.browsing_country.name.mayotte" = "マヨット"; -"enum.browsing_country.name.mexico" = "メキシコ"; -"enum.browsing_country.name.micronesia" = "ミクロネシア"; -"enum.browsing_country.name.moldova" = "モルドバ"; -"enum.browsing_country.name.monaco" = "モナコ"; -"enum.browsing_country.name.mongolia" = "モンゴル"; -"enum.browsing_country.name.montenegro" = "モンテネグロ"; -"enum.browsing_country.name.montserrat" = "モントセラト"; -"enum.browsing_country.name.morocco" = "モロッコ"; -"enum.browsing_country.name.mozambique" = "モザンビーク"; -"enum.browsing_country.name.myanmar" = "ミャンマー"; -"enum.browsing_country.name.namibia" = "ナミビア"; -"enum.browsing_country.name.nauru" = "ナウル"; -"enum.browsing_country.name.nepal" = "ネパール"; -"enum.browsing_country.name.netherlands" = "オランダ"; -"enum.browsing_country.name.new_caledonia" = "ニューカレドニア"; -"enum.browsing_country.name.new_zealand" = "ニュージーランド"; -"enum.browsing_country.name.nicaragua" = "ニカラグア"; -"enum.browsing_country.name.niger" = "ニジェール"; -"enum.browsing_country.name.nigeria" = "ナイジェリア"; -"enum.browsing_country.name.niue" = "ニウエ"; -"enum.browsing_country.name.norfolk_island" = "ノーフォーク島"; -"enum.browsing_country.name.north_korea" = "朝鮮"; -"enum.browsing_country.name.northern_mariana_islands" = "北マリアナ諸島"; -"enum.browsing_country.name.norway" = "ノルウェー"; -"enum.browsing_country.name.oman" = "オマーン"; -"enum.browsing_country.name.pakistan" = "パキスタン"; -"enum.browsing_country.name.palau" = "パラオ"; -"enum.browsing_country.name.palestinian_territory" = "パレスチナ"; -"enum.browsing_country.name.panama" = "パナマ"; -"enum.browsing_country.name.papua_new_guinea" = "パプアニューギニア"; -"enum.browsing_country.name.paraguay" = "パラグアイ"; -"enum.browsing_country.name.peru" = "ペルー"; -"enum.browsing_country.name.philippines" = "フィリピン"; -"enum.browsing_country.name.pitcairn_islands" = "ピトケアン諸島"; -"enum.browsing_country.name.poland" = "ポーランド"; -"enum.browsing_country.name.portugal" = "ポルトガル"; -"enum.browsing_country.name.puerto_rico" = "プエルトリコ"; -"enum.browsing_country.name.qatar" = "カタール"; -"enum.browsing_country.name.reunion" = "ユニオン"; -"enum.browsing_country.name.romania" = "ルーマニア"; -"enum.browsing_country.name.russian_federation" = "ロシア"; -"enum.browsing_country.name.rwanda" = "ルワンダ"; -"enum.browsing_country.name.saint_barthelemy" = "サン・バルテルミー島"; -"enum.browsing_country.name.saint_helena" = "セントヘレナ"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "セントクリストファー・ネービス"; -"enum.browsing_country.name.saint_lucia" = "セントルシア"; -"enum.browsing_country.name.saint_martin" = "サン・マルタン島"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "サンピエール島・ミクロン島"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "セントビンセントおよびグレナディーン諸島"; -"enum.browsing_country.name.samoa" = "サモア"; -"enum.browsing_country.name.san_marino" = "サンマリノ"; -"enum.browsing_country.name.sao_tome_and_principe" = "サントメ・プリンシペ"; -"enum.browsing_country.name.saudi_arabia" = "サウジアラビア"; -"enum.browsing_country.name.senegal" = "セネガル"; -"enum.browsing_country.name.serbia" = "セルビア"; -"enum.browsing_country.name.seychelles" = "セーシェル"; -"enum.browsing_country.name.sierra_leone" = "シエラレオネ"; -"enum.browsing_country.name.singapore" = "シンガポール"; -"enum.browsing_country.name.sint_maarten" = "シント・マールテン"; -"enum.browsing_country.name.slovakia" = "スロバキア"; -"enum.browsing_country.name.slovenia" = "スロベニア"; -"enum.browsing_country.name.solomon_islands" = "ソロモン諸島"; -"enum.browsing_country.name.somalia" = "ソマリア"; -"enum.browsing_country.name.south_africa" = "南アフリカ"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "サウスジョージア・サウスサンドウィッチ諸島"; -"enum.browsing_country.name.south_korea" = "韓国"; -"enum.browsing_country.name.south_sudan" = "南スーダン"; -"enum.browsing_country.name.spain" = "スペイン"; -"enum.browsing_country.name.sri_lanka" = "スリランカ"; -"enum.browsing_country.name.sudan" = "スーダン"; -"enum.browsing_country.name.suriname" = "スリナム"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "スヴァールバル諸島およびヤンマイエン島"; -"enum.browsing_country.name.swaziland" = "エスワティニ"; -"enum.browsing_country.name.sweden" = "スウェーデン"; -"enum.browsing_country.name.switzerland" = "スイス"; -"enum.browsing_country.name.syrian_arab_republic" = "シリア"; -"enum.browsing_country.name.taiwan" = "台湾"; -"enum.browsing_country.name.tajikistan" = "タジキスタン"; -"enum.browsing_country.name.tanzania" = "タンザニア"; -"enum.browsing_country.name.thailand" = "タイ"; -"enum.browsing_country.name.timor_leste" = "東ティモール"; -"enum.browsing_country.name.togo" = "トーゴ"; -"enum.browsing_country.name.tokelau" = "トケラウ"; -"enum.browsing_country.name.tonga" = "トンガ"; -"enum.browsing_country.name.trinidad_and_tobago" = "トリニダード・トバゴ"; -"enum.browsing_country.name.tunisia" = "チュニジア"; -"enum.browsing_country.name.turkey" = "トルコ"; -"enum.browsing_country.name.turkmenistan" = "トルクメニスタン"; -"enum.browsing_country.name.turks_and_caicos_islands" = "タークス・カイコス諸島"; -"enum.browsing_country.name.tuvalu" = "ツバル"; -"enum.browsing_country.name.uganda" = "ウガンダ"; -"enum.browsing_country.name.ukraine" = "ウクライナ"; -"enum.browsing_country.name.united_arab_emirates" = "アラブ首長国連邦"; -"enum.browsing_country.name.united_kingdom" = "イギリス"; -"enum.browsing_country.name.united_states" = "アメリカ"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "合衆国領有小離島"; -"enum.browsing_country.name.uruguay" = "ウルグアイ"; -"enum.browsing_country.name.uzbekistan" = "ウズベキスタン"; -"enum.browsing_country.name.vanuatu" = "バヌアツ"; -"enum.browsing_country.name.venezuela" = "ベネズエラ"; -"enum.browsing_country.name.vietnam" = "ベトナム"; -"enum.browsing_country.name.virgin_islands_british" = "イギリス領バージン諸島"; -"enum.browsing_country.name.virgin_islands_US" = "アメリカ領ヴァージン諸島"; -"enum.browsing_country.name.wallis_and_futuna" = "ウォリス・フツナ"; -"enum.browsing_country.name.western_sahara" = "西サハラ"; -"enum.browsing_country.name.yemen" = "イエメン"; -"enum.browsing_country.name.zambia" = "ザンビア"; -"enum.browsing_country.name.zimbabwe" = "ジンバブエ"; +"browsing_country.auto_detect" = "自動検出"; +"browsing_country.afghanistan" = "アフガニスタン"; +"browsing_country.aland_islands" = "オーランド諸島"; +"browsing_country.albania" = "アルバニア"; +"browsing_country.algeria" = "アルジェリア"; +"browsing_country.american_samoa" = "アメリカ領サモア"; +"browsing_country.andorra" = "アンドラ"; +"browsing_country.angola" = "アンゴラ"; +"browsing_country.anguilla" = "アンギラ"; +"browsing_country.antarctica" = "南極大陸"; +"browsing_country.antigua_and_barbuda" = "アンティグア・バーブーダ"; +"browsing_country.argentina" = "アルゼンチン"; +"browsing_country.armenia" = "アルメニア"; +"browsing_country.aruba" = "アルバ"; +"browsing_country.asia_pacific_region" = "アジア太平洋地域"; +"browsing_country.australia" = "オーストラリア"; +"browsing_country.austria" = "オーストリア"; +"browsing_country.azerbaijan" = "アゼルバイジャン"; +"browsing_country.bahamas" = "バハマ"; +"browsing_country.bahrain" = "バーレーン"; +"browsing_country.bangladesh" = "バングラデシュ"; +"browsing_country.barbados" = "バルバドス"; +"browsing_country.belarus" = "ベラルーシ"; +"browsing_country.belgium" = "ベルギー"; +"browsing_country.belize" = "ベリーズ"; +"browsing_country.benin" = "ベナン"; +"browsing_country.bermuda" = "バミューダ諸島"; +"browsing_country.bhutan" = "ブータン"; +"browsing_country.bolivia" = "ボリビア"; +"browsing_country.bonaire_saint_eustatius_and_saba" = "ボネール、シント・ユースタティウスおよびサバ"; +"browsing_country.bosnia_and_herzegovina" = "ボスニア・ヘルツェゴビナ"; +"browsing_country.botswana" = "ボツワナ"; +"browsing_country.bouvet_island" = "ブーベ島"; +"browsing_country.brazil" = "ブラジル"; +"browsing_country.british_indian_ocean_territory" = "イギリス領インド洋地域"; +"browsing_country.brunei_darussalam" = "ブルネイ・ダルサラーム"; +"browsing_country.bulgaria" = "ブルガリア"; +"browsing_country.burkina_faso" = "ブルキナファソ"; +"browsing_country.burundi" = "ブルンジ"; +"browsing_country.cambodia" = "カンボジア"; +"browsing_country.cameroon" = "カメルーン"; +"browsing_country.canada" = "カナダ"; +"browsing_country.cape_verde" = "カーボベルデ"; +"browsing_country.cayman_islands" = "ケイマン諸島"; +"browsing_country.central_african_republic" = "中央アフリカ共和国"; +"browsing_country.chad" = "チャド"; +"browsing_country.chile" = "チリ"; +"browsing_country.china" = "中華人民共和国"; +"browsing_country.christmas_island" = "クリスマス島"; +"browsing_country.cocos_islands" = "ココス諸島"; +"browsing_country.colombia" = "コロンビア"; +"browsing_country.comoros" = "コモロ"; +"browsing_country.congo" = "コンゴ共和国"; +"browsing_country.the_democratic_republic_of_the_congo" = "コンゴ民主共和国"; +"browsing_country.cook_islands" = "クック諸島"; +"browsing_country.costa_rica" = "コスタリカ"; +"browsing_country.cote_d_ivoire" = "コートジボワール"; +"browsing_country.croatia" = "クロアチア"; +"browsing_country.cuba" = "キューバ"; +"browsing_country.curacao" = "キュラソー島"; +"browsing_country.cyprus" = "キプロス"; +"browsing_country.czech_republic" = "チェコ"; +"browsing_country.denmark" = "デンマーク"; +"browsing_country.djibouti" = "ジブチ"; +"browsing_country.dominica" = "ドミニカ"; +"browsing_country.dominican_republic" = "ドミニカ共和国"; +"browsing_country.ecuador" = "エクアドル"; +"browsing_country.egypt" = "エジプト"; +"browsing_country.el_salvador" = "エルサルバドル"; +"browsing_country.equatorial_guinea" = "赤道ギニア"; +"browsing_country.eritrea" = "エリトリア"; +"browsing_country.estonia" = "エストニア"; +"browsing_country.ethiopia" = "エチオピア"; +"browsing_country.europe" = "ヨーロッパ"; +"browsing_country.falkland_islands" = "フォークランド諸島"; +"browsing_country.faroe_islands" = "フェロー諸島"; +"browsing_country.fiji" = "フィジー"; +"browsing_country.finland" = "フィンランド"; +"browsing_country.france" = "フランス"; +"browsing_country.french_guiana" = "フランス領ギアナ"; +"browsing_country.french_polynesia" = "フランス領ポリネシア"; +"browsing_country.french_southern_territories" = "フランス領南方・南極地域"; +"browsing_country.gabon" = "ガボン"; +"browsing_country.gambia" = "ガンビア"; +"browsing_country.georgia" = "ジョージア"; +"browsing_country.germany" = "ドイツ"; +"browsing_country.ghana" = "ガーナ"; +"browsing_country.gibraltar" = "ジブラルタル"; +"browsing_country.greece" = "ギリシャ"; +"browsing_country.greenland" = "グリーンランド"; +"browsing_country.grenada" = "グレナダ"; +"browsing_country.guadeloupe" = "グアドループ"; +"browsing_country.guam" = "グアム"; +"browsing_country.guatemala" = "グアテマラ"; +"browsing_country.guernsey" = "ガーンジー"; +"browsing_country.guinea" = "ギニア"; +"browsing_country.guinea_bissau" = "ギニアビサウ"; +"browsing_country.guyana" = "ガイアナ"; +"browsing_country.haiti" = "ハイチ"; +"browsing_country.heard_island_and_mc_donald_islands" = "ハード島とマクドナルド諸島"; +"browsing_country.vatican_city_state" = "バチカン市国"; +"browsing_country.honduras" = "ホンジュラス"; +"browsing_country.hong_kong" = "香港"; +"browsing_country.hungary" = "ハンガリー"; +"browsing_country.iceland" = "アイスランド"; +"browsing_country.india" = "インド"; +"browsing_country.indonesia" = "インドネシア"; +"browsing_country.iran" = "イラン"; +"browsing_country.iraq" = "イラク"; +"browsing_country.ireland" = "アイルランド"; +"browsing_country.isle_of_man" = "マン島"; +"browsing_country.israel" = "イスラエル"; +"browsing_country.italy" = "イタリア"; +"browsing_country.jamaica" = "ジャマイカ"; +"browsing_country.japan" = "日本"; +"browsing_country.jersey" = "ジャージー"; +"browsing_country.jordan" = "ヨルダン"; +"browsing_country.kazakhstan" = "カザフスタン"; +"browsing_country.kenya" = "ケニア"; +"browsing_country.kiribati" = "キリバス"; +"browsing_country.kuwait" = "クウェート"; +"browsing_country.kyrgyzstan" = "キルギス"; +"browsing_country.lao_peoples_democratic_republic" = "ラオス"; +"browsing_country.latvia" = "ラトビア"; +"browsing_country.lebanon" = "レバノン"; +"browsing_country.lesotho" = "レソト"; +"browsing_country.liberia" = "リベリア"; +"browsing_country.libya" = "リビア"; +"browsing_country.liechtenstein" = "リヒテンシュタイン"; +"browsing_country.lithuania" = "リトアニア"; +"browsing_country.luxembourg" = "ルクセンブルク"; +"browsing_country.macau" = "マカオ"; +"browsing_country.macedonia" = "マケドニア"; +"browsing_country.madagascar" = "マダガスカル"; +"browsing_country.malawi" = "マラウイ"; +"browsing_country.malaysia" = "マレーシア"; +"browsing_country.maldives" = "モルディブ"; +"browsing_country.mali" = "マリ"; +"browsing_country.malta" = "マルタ"; +"browsing_country.marshall_islands" = "マーシャル諸島"; +"browsing_country.martinique" = "マルティニーク"; +"browsing_country.mauritania" = "モーリタニア"; +"browsing_country.mauritius" = "モーリシャス"; +"browsing_country.mayotte" = "マヨット"; +"browsing_country.mexico" = "メキシコ"; +"browsing_country.micronesia" = "ミクロネシア"; +"browsing_country.moldova" = "モルドバ"; +"browsing_country.monaco" = "モナコ"; +"browsing_country.mongolia" = "モンゴル"; +"browsing_country.montenegro" = "モンテネグロ"; +"browsing_country.montserrat" = "モントセラト"; +"browsing_country.morocco" = "モロッコ"; +"browsing_country.mozambique" = "モザンビーク"; +"browsing_country.myanmar" = "ミャンマー"; +"browsing_country.namibia" = "ナミビア"; +"browsing_country.nauru" = "ナウル"; +"browsing_country.nepal" = "ネパール"; +"browsing_country.netherlands" = "オランダ"; +"browsing_country.new_caledonia" = "ニューカレドニア"; +"browsing_country.new_zealand" = "ニュージーランド"; +"browsing_country.nicaragua" = "ニカラグア"; +"browsing_country.niger" = "ニジェール"; +"browsing_country.nigeria" = "ナイジェリア"; +"browsing_country.niue" = "ニウエ"; +"browsing_country.norfolk_island" = "ノーフォーク島"; +"browsing_country.north_korea" = "朝鮮"; +"browsing_country.northern_mariana_islands" = "北マリアナ諸島"; +"browsing_country.norway" = "ノルウェー"; +"browsing_country.oman" = "オマーン"; +"browsing_country.pakistan" = "パキスタン"; +"browsing_country.palau" = "パラオ"; +"browsing_country.palestinian_territory" = "パレスチナ"; +"browsing_country.panama" = "パナマ"; +"browsing_country.papua_new_guinea" = "パプアニューギニア"; +"browsing_country.paraguay" = "パラグアイ"; +"browsing_country.peru" = "ペルー"; +"browsing_country.philippines" = "フィリピン"; +"browsing_country.pitcairn_islands" = "ピトケアン諸島"; +"browsing_country.poland" = "ポーランド"; +"browsing_country.portugal" = "ポルトガル"; +"browsing_country.puerto_rico" = "プエルトリコ"; +"browsing_country.qatar" = "カタール"; +"browsing_country.reunion" = "ユニオン"; +"browsing_country.romania" = "ルーマニア"; +"browsing_country.russian_federation" = "ロシア"; +"browsing_country.rwanda" = "ルワンダ"; +"browsing_country.saint_barthelemy" = "サン・バルテルミー島"; +"browsing_country.saint_helena" = "セントヘレナ"; +"browsing_country.saint_kitts_and_nevis" = "セントクリストファー・ネービス"; +"browsing_country.saint_lucia" = "セントルシア"; +"browsing_country.saint_martin" = "サン・マルタン島"; +"browsing_country.saint_pierre_and_miquelon" = "サンピエール島・ミクロン島"; +"browsing_country.saint_vincent_and_the_grenadines" = "セントビンセントおよびグレナディーン諸島"; +"browsing_country.samoa" = "サモア"; +"browsing_country.san_marino" = "サンマリノ"; +"browsing_country.sao_tome_and_principe" = "サントメ・プリンシペ"; +"browsing_country.saudi_arabia" = "サウジアラビア"; +"browsing_country.senegal" = "セネガル"; +"browsing_country.serbia" = "セルビア"; +"browsing_country.seychelles" = "セーシェル"; +"browsing_country.sierra_leone" = "シエラレオネ"; +"browsing_country.singapore" = "シンガポール"; +"browsing_country.sint_maarten" = "シント・マールテン"; +"browsing_country.slovakia" = "スロバキア"; +"browsing_country.slovenia" = "スロベニア"; +"browsing_country.solomon_islands" = "ソロモン諸島"; +"browsing_country.somalia" = "ソマリア"; +"browsing_country.south_africa" = "南アフリカ"; +"browsing_country.south_georgia_and_the_south_sandwich_islands" = "サウスジョージア・サウスサンドウィッチ諸島"; +"browsing_country.south_korea" = "韓国"; +"browsing_country.south_sudan" = "南スーダン"; +"browsing_country.spain" = "スペイン"; +"browsing_country.sri_lanka" = "スリランカ"; +"browsing_country.sudan" = "スーダン"; +"browsing_country.suriname" = "スリナム"; +"browsing_country.svalbard_and_jan_mayen" = "スヴァールバル諸島およびヤンマイエン島"; +"browsing_country.swaziland" = "エスワティニ"; +"browsing_country.sweden" = "スウェーデン"; +"browsing_country.switzerland" = "スイス"; +"browsing_country.syrian_arab_republic" = "シリア"; +"browsing_country.taiwan" = "台湾"; +"browsing_country.tajikistan" = "タジキスタン"; +"browsing_country.tanzania" = "タンザニア"; +"browsing_country.thailand" = "タイ"; +"browsing_country.timor_leste" = "東ティモール"; +"browsing_country.togo" = "トーゴ"; +"browsing_country.tokelau" = "トケラウ"; +"browsing_country.tonga" = "トンガ"; +"browsing_country.trinidad_and_tobago" = "トリニダード・トバゴ"; +"browsing_country.tunisia" = "チュニジア"; +"browsing_country.turkey" = "トルコ"; +"browsing_country.turkmenistan" = "トルクメニスタン"; +"browsing_country.turks_and_caicos_islands" = "タークス・カイコス諸島"; +"browsing_country.tuvalu" = "ツバル"; +"browsing_country.uganda" = "ウガンダ"; +"browsing_country.ukraine" = "ウクライナ"; +"browsing_country.united_arab_emirates" = "アラブ首長国連邦"; +"browsing_country.united_kingdom" = "イギリス"; +"browsing_country.united_states" = "アメリカ"; +"browsing_country.united_states_minor_outlying_islands" = "合衆国領有小離島"; +"browsing_country.uruguay" = "ウルグアイ"; +"browsing_country.uzbekistan" = "ウズベキスタン"; +"browsing_country.vanuatu" = "バヌアツ"; +"browsing_country.venezuela" = "ベネズエラ"; +"browsing_country.vietnam" = "ベトナム"; +"browsing_country.virgin_islands_british" = "イギリス領バージン諸島"; +"browsing_country.virgin_islands_US" = "アメリカ領ヴァージン諸島"; +"browsing_country.wallis_and_futuna" = "ウォリス・フツナ"; +"browsing_country.western_sahara" = "西サハラ"; +"browsing_country.yemen" = "イエメン"; +"browsing_country.zambia" = "ザンビア"; +"browsing_country.zimbabwe" = "ジンバブエ"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index b46c60dd6..fa5b7848e 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -1,235 +1,232 @@ // MARK: BanInterval -"enum.ban_interval.description.and" = "and"; +"ban_interval.and" = "and"; // MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "어제"; -"enum.toplists_type.value.past_month" = "지난 달"; -"enum.toplists_type.value.past_year" = "지난 해"; -"enum.toplists_type.value.all_time" = "전체"; +"toplists_type.yesterday" = "어제"; +"toplists_type.past_month" = "지난 달"; +"toplists_type.past_year" = "지난 해"; +"toplists_type.all_time" = "전체"; // MARK: Response -"website.response.hath_client_not_found" = "H@H 클라이언트를 아이디에 연동시킨 후 사용해주세요."; -"website.response.hath_client_not_online" = "H@H 클라이언트가 오프라인인 것 같네요. 클라이언트를 켜고 다시 시도해주세요."; -"website.response.invalid_resolution" = "이 콘텐츠는 선택한 해상도로 다운로드할 수 없어요."; +"hath_client_not_found" = "H@H 클라이언트를 아이디에 연동시킨 후 사용해주세요."; +"hath_client_not_online" = "H@H 클라이언트가 오프라인인 것 같네요. 클라이언트를 켜고 다시 시도해주세요."; +"invalid_resolution" = "이 콘텐츠는 선택한 해상도로 다운로드할 수 없어요."; // MARK: Toast -"toast.title.error" = "실패"; -"toast.title.success" = "성공"; -"toast.title.loading" = "로딩 중..."; -"toast.title.communicating" = "접속 중..."; -"toast.caption.copied_to_clipboard" = "클립보드에 복사되었어요"; -"toast.caption.saved_to_photo_library" = "이미지 저장"; +"toast.error" = "실패"; +"toast.success" = "성공"; +"toast.loading" = "로딩 중..."; +"toast.communicating" = "접속 중..."; +"toast.copied_to_clipboard" = "클립보드에 복사되었어요"; +"toast.saved_to_photo_library" = "이미지 저장"; // MARK: AutoLock "local_authorization.reason" = "자동 잠금으로 앱이 잠겼어요."; // MARK: Common value -"common.value.stars" = "%@별"; -"common.value.pages" = "%@페이지"; -"common.value.times" = "%@번"; -"common.value.day" = "%@ day"; -"common.value.days" = "%@ days"; -"common.value.hour" = "%@ hour"; -"common.value.hours" = "%@ hours"; -"common.value.minute" = "%@ 분"; -"common.value.minutes" = "%@ 분"; -"common.value.second" = "%@ 초"; -"common.value.seconds" = "%@ 초"; -"common.value.records" = "%@ 기록수"; +"common.stars" = "%@별"; +"common.pages" = "%@페이지"; +"common.day" = "%@ day"; +"common.days" = "%@ days"; +"common.hour" = "%@ hour"; +"common.hours" = "%@ hours"; +"common.minute" = "%@ 분"; +"common.minutes" = "%@ 분"; +"common.second" = "%@ 초"; +"common.seconds" = "%@ 초"; // MARK: Common button -"common.button.cancel" = "취소"; +"common.cancel" = "취소"; // MARK: TabItem -"tab_item.title.home" = "Home"; -"tab_item.title.favorites" = "즐겨찾기"; -"tab_item.title.search" = "검색"; -"tab_item.title.downloads" = "다운로드"; -"tab_item.title.setting" = "설정"; +"tab_item.home" = "Home"; +"tab_item.favorites" = "즐겨찾기"; +"tab_item.search" = "검색"; +"tab_item.downloads" = "다운로드"; +"tab_item.setting" = "설정"; // MARK: ToolbarItem -"toolbar_item.button.filters" = "필터"; -"toolbar_item.button.jump_page" = "페이지 이동"; -"toolbar_item.button.date_seek" = "날짜로 이동"; -"toolbar_item.button.quick_search" = "빠른 검색"; +"toolbar_item.filters" = "필터"; +"toolbar_item.jump_page" = "페이지 이동"; +"toolbar_item.date_seek" = "날짜로 이동"; +"toolbar_item.quick_search" = "빠른 검색"; // MARK: DateSeek -"date_seek_view.title.date_seek" = "날짜로 이동"; -"date_seek_view.title.date" = "날짜"; -"date_seek_view.footer.seek_around_date" = "선택한 날짜 근처의 갤러리로 이동합니다."; -"date_seek_view.button.seek_newer" = "새로운 쪽"; -"date_seek_view.button.seek_older" = "오래된 쪽"; +"date_seek_view.date_seek" = "날짜로 이동"; +"date_seek_view.date" = "날짜"; +"date_seek_view.seek_around_date" = "선택한 날짜 근처의 갤러리로 이동합니다."; +"date_seek_view.seek_newer" = "새로운 쪽"; +"date_seek_view.seek_older" = "오래된 쪽"; // MARK: JumpPage -"jump_page_view.title.jump_page" = "페이지 이동"; -"jump_page_view.description.jump_page" = "1에서 %d 사이의 페이지 번호를 입력하세요."; -"jump_page_view.button.confirm" = "확인"; +"jump_page_view.jump_page" = "페이지 이동"; +"jump_page_view.jump_page_description" = "1에서 %d 사이의 페이지 번호를 입력하세요."; +"jump_page_view.confirm" = "확인"; // MARK: AlertView -"loading_view.title.loading" = "로딩 중..."; -"loading_view.title.preparing_database" = "Preparing the database..."; -"not_login_view.title.need_login" = "You need to login to access this feature."; -"not_login_view.button.login" = "Login"; -"error_view.button.retry" = "재시도"; -"error_view.button.drop_database" = "Drop the database"; -"error_view.title.try_later" = "잠시 후 다시 시도해 주세요."; -"error_view.title.network" = "인터넷 접속 오류가 발생했어요."; -"error_view.title.parsing" = "구분 분석 오류가 발생했어요."; -"error_view.title.unknown" = "알 수 없는 오류가 발생했어요."; -"error_view.title.not_found" = "여기가 아무도 없는 것 같습니다."; -"error_view.title.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; -"error_view.title.ip_banned" = "자동화된 미러링/수집 소프트웨어를 사용 중임을 나타내는 과도한 페이지 로드로 인해 IP 주소가 일시적으로 금지되었습니다. 금지효과는 %@ 에서 만료되었습니다."; -"error_view.title.copyright_claim" = "%@의 저작권 요청으로 인하여 이 갤러리를 사용할 수 없어요."; -"error_view.title.gallery_unavailable" = "이 갤러리는 제거되었거나 사용할 수 없어요."; +"loading_view.loading" = "로딩 중..."; +"loading_view.preparing_database" = "Preparing the database..."; +"not_login_view.need_login" = "You need to login to access this feature."; +"not_login_viewlogin" = "Login"; +"error_view.retry" = "재시도"; +"error_view.drop_database" = "Drop the database"; +"error_view.try_later" = "잠시 후 다시 시도해 주세요."; +"error_view.network" = "인터넷 접속 오류가 발생했어요."; +"error_view.parsing" = "구분 분석 오류가 발생했어요."; +"error_view.unknown" = "알 수 없는 오류가 발생했어요."; +"error_view.not_found" = "여기가 아무도 없는 것 같습니다."; +"error_view.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; +"error_view.ip_banned" = "자동화된 미러링/수집 소프트웨어를 사용 중임을 나타내는 과도한 페이지 로드로 인해 IP 주소가 일시적으로 금지되었습니다. 금지효과는 %@ 에서 만료되었습니다."; +"error_view.copyright_claim" = "%@의 저작권 요청으로 인하여 이 갤러리를 사용할 수 없어요."; +"error_view.gallery_unavailable" = "이 갤러리는 제거되었거나 사용할 수 없어요."; // MARK: AppError -"app_error.localized_description.database_corrupted" = "데이터베이스 손상"; -"app_error.localized_description.copyright_claim" = "저작권 신고"; -"app_error.localized_description.ip_banned" = "IP 차단됨"; -"app_error.localized_description.gallery_expunged" = "갤러리 삭제됨"; -"app_error.localized_description.network_error" = "네트워크 오류"; -"app_error.localized_description.web_image_loading_error" = "웹 이미지 로드 오류"; -"app_error.localized_description.parse_error" = "파싱 오류"; -"app_error.localized_description.quota_exceeded" = "할당량 초과"; -"app_error.localized_description.authentication_required" = "인증 필요"; -"app_error.localized_description.file_operation_failed" = "파일 작업 실패"; -"app_error.localized_description.no_updates_available" = "사용 가능한 업데이트 없음"; -"app_error.localized_description.not_found" = "찾을 수 없음"; -"app_error.localized_description.unknown_error" = "알 수 없는 오류"; -"app_error.alert.quota_exceeded" = "이미지 할당량을 모두 사용했습니다.\n잠시 후 다시 시도해 주세요."; -"app_error.alert.authentication_required" = "이 다운로드에 접근하려면 로그인해야 합니다."; -"app_error.alert.local_file_operation_failed" = "로컬 파일 작업에 실패했습니다."; +"app_error.database_corrupted" = "데이터베이스 손상"; +"app_error.copyright_claim" = "저작권 신고"; +"app_error.ip_banned" = "IP 차단됨"; +"app_error.gallery_expunged" = "갤러리 삭제됨"; +"app_error.network_error" = "네트워크 오류"; +"app_error.web_image_loading_error" = "웹 이미지 로드 오류"; +"app_error.parse_error" = "파싱 오류"; +"app_error.quota_exceeded" = "할당량 초과"; +"app_error.authentication_required" = "인증 필요"; +"app_error.file_operation_failed" = "파일 작업 실패"; +"app_error.no_updates_available" = "사용 가능한 업데이트 없음"; +"app_error.not_found" = "찾을 수 없음"; +"app_error.unknown_error" = "알 수 없는 오류"; +"app_error.quota_exceeded_description" = "이미지 할당량을 모두 사용했습니다.\n잠시 후 다시 시도해 주세요."; +"app_error.authentication_required_description" = "이 다운로드에 접근하려면 로그인해야 합니다."; +"app_error.local_file_operation_failed" = "로컬 파일 작업에 실패했습니다."; // MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "You will lose all your data in this app.\nAre you sure to drop the database?"; -"confirmation_dialog.title.remove_custom_translations" = "Are you sure to remove your custom translations?"; -"confirmation_dialog.title.logout" = "로그아웃 하시겠어요?"; -"confirmation_dialog.title.delete" = "Are you sure to delete this item?"; -"confirmation_dialog.title.clear" = "삭제하시겠어요?"; -"confirmation_dialog.title.reset" = "초기화하시겠어요?"; -"confirmation_dialog.button.drop_database" = "Drop the database"; -"confirmation_dialog.button.remove" = "Remove"; -"confirmation_dialog.button.logout" = "로그아웃"; -"confirmation_dialog.button.delete" = "삭제"; -"confirmation_dialog.button.clear" = "삭제"; -"confirmation_dialog.button.reset" = "초기화"; +"confirmation_dialog.drop_database_description" = "You will lose all your data in this app.\nAre you sure to drop the database?"; +"confirmation_dialog.remove_custom_translations" = "Are you sure to remove your custom translations?"; +"confirmation_dialog.logout_description" = "로그아웃 하시겠어요?"; +"confirmation_dialog.delete_description" = "Are you sure to delete this item?"; +"confirmation_dialog.clear_description" = "삭제하시겠어요?"; +"confirmation_dialog.reset_description" = "초기화하시겠어요?"; +"confirmation_dialog.drop_database" = "Drop the database"; +"confirmation_dialog.remove" = "Remove"; +"confirmation_dialog.logout" = "로그아웃"; +"confirmation_dialog.delete" = "삭제"; +"confirmation_dialog.clear" = "삭제"; +"confirmation_dialog.reset" = "초기화"; // MARK: SubSection -"sub_section.button.show_all" = "모두 보기"; +"sub_section.show_all" = "모두 보기"; // MARK: NewDawnView -"new_dawn_view.title.first" = "새로운 하루가 시작되었어요!"; -"new_dawn_view.title.second" = "지금까지의 여정을 돌이켜보면, 당신은 조금 더 현명해진 것 같죠?"; +"new_dawn_view.first" = "새로운 하루가 시작되었어요!"; +"new_dawn_view.second" = "지금까지의 여정을 돌이켜보면, 당신은 조금 더 현명해진 것 같죠?"; // Greeting -"struct.greeting.mark.start" = ""; -"struct.greeting.mark.separator" = ", "; -"struct.greeting.mark.and" = " 과 "; -"struct.greeting.mark.end" = "획득했어요!"; +"greeting.start" = ""; +"greeting.separator" = ", "; +"greeting.and" = " 과 "; +"greeting.end" = "획득했어요!"; // MARK: HomeView -"home_view.title.home" = "홈"; -"home_view.section.title.frontpage" = "프론트 페이지"; -"home_view.section.title.toplists" = "상위 목록"; -"home_view.section.title.other" = "Other"; +"home_view.home" = "홈"; +"home_view.frontpage" = "프론트 페이지"; +"home_view.toplists" = "상위 목록"; +"home_view.other" = "Other"; // HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "인기 작품"; -"enum.home_misc_grid_type.title.watched" = "주시 태그"; -"enum.home_misc_grid_type.title.history" = "읽은 목록"; +"home_misc_grid_type.popular" = "인기 작품"; +"home_misc_grid_type.watched" = "주시 태그"; +"home_misc_grid_type.history" = "읽은 목록"; // MARK: FrontpageView -"frontpage_view.title.frontpage" = "프론트 페이지"; +"frontpage_view.frontpage" = "프론트 페이지"; // MARK: ToplistsView -"toplists_view.title.toplists" = "상위 목록"; +"toplists_view.toplists" = "상위 목록"; // MARK: PopularView -"popular_view.title.popular" = "인기 작품"; +"popular_view.popular" = "인기 작품"; // MARK: WatchedView -"watched_view.title.watched" = "주시 태그"; +"watched_view.watched" = "주시 태그"; // MARK: HistoryView -"history_view.title.history" = "읽은 목록"; +"history_view.history" = "읽은 목록"; // MARK: FavoritesView -"favorites_view.title.favorites" = "즐겨찾기"; +"favorites_view.favorites" = "즐겨찾기"; // FavoriteCategory -"struct.user.favorite_category.default" = "즐겨찾기 %@"; -"struct.user.favorite_category.all" = "모두"; +"favorite_category.default" = "즐겨찾기 %@"; +"favorite_category.all" = "모두"; // MARK: SearchView -"search_view.title.search" = "검색"; -"search_view.section.title.recently_searched" = "Recently searched"; -"search_view.section.title.recently_seen" = "Recently seen"; -"search_view.section.title.quick_search" = "빠른 검색"; +"search_view.search" = "검색"; +"search_view.recently_searched" = "Recently searched"; +"search_view.recently_seen" = "Recently seen"; +"search_view.quick_search" = "빠른 검색"; // Searchable -"searchable.prompt.filter" = "Filter"; -"searchable.title.matches_count" = "Found %d matches."; +"searchable.filter" = "Filter"; +"searchable.matches_count" = "Found %d matches."; // MARK: QuickSearchView -"quick_search_view.title.quick_search" = "빠른 검색"; -"quick_search_view.title.edit_word" = "Edit word"; -"quick_search_view.title.new_word" = "New word"; -"quick_search_view.title.content" = "Content"; -"quick_search_view.title.name" = "Name"; -"quick_search_view.placeholder.optional" = "Optional"; +"quick_search_view.quick_search" = "빠른 검색"; +"quick_search_view.edit_word" = "Edit word"; +"quick_search_view.new_word" = "New word"; +"quick_search_view.content" = "Content"; +"quick_search_view.name" = "Name"; +"quick_search_view.optional" = "Optional"; // MARK: SettingView -"setting_view.title.setting" = "설정"; +"setting_view.setting" = "설정"; // SettingStateRoute -"enum.setting_state_route.value.account" = "계정"; -"enum.setting_state_route.value.general" = "일반"; -"enum.setting_state_route.value.appearance" = "외관"; -"enum.setting_state_route.value.reading" = "읽기"; -"enum.setting_state_route.value.download" = "다운로드"; -"enum.setting_state_route.value.laboratory" = "실험실"; -"enum.setting_state_route.value.about" = "About"; +"setting_state_route.account" = "계정"; +"setting_state_route.general" = "일반"; +"setting_state_route.appearance" = "외관"; +"setting_state_route.reading" = "읽기"; +"setting_state_route.download" = "다운로드"; +"setting_state_route.laboratory" = "실험실"; +"setting_state_route.about" = "About"; // MARK: AccountSettingView -"account_setting_view.title.account" = "계정"; -"account_setting_view.title.shows_new_dawn_greeting" = "새벽 인사 구독하기"; -"account_setting_view.button.login" = "로그인"; -"account_setting_view.button.logout" = "로그아웃"; -"account_setting_view.button.account_configuration" = "계정 설정"; -"account_setting_view.button.tags_management" = "태그 구독 관리"; -"account_setting_view.button.copy_cookies" = "쿠키 복사하기"; +"account_setting_view.account" = "계정"; +"account_setting_view.shows_new_dawn_greeting" = "새벽 인사 구독하기"; +"account_setting_view.login" = "로그인"; +"account_setting_view.account_configuration" = "계정 설정"; +"account_setting_view.tags_management" = "태그 구독 관리"; +"account_setting_view.copy_cookies" = "쿠키 복사하기"; // CookieValue -"struct.cookie_value.localized_string.expired" = "만료됨"; -"struct.cookie_value.localized_string.mystery" = "거절됨"; -"struct.cookie_value.localized_string.none" = "None"; +"cookie_value.expired" = "만료됨"; +"cookie_value.mystery" = "거절됨"; +"cookie_value.none" = "None"; // MARK: LoginView -"login_view.title.login" = "로그인"; -"login_view.title.username" = "이름"; -"login_view.title.password" = "비밀번호"; +"login_view.login" = "로그인"; +"login_view.username" = "이름"; +"login_view.password" = "비밀번호"; // MARK: GeneralSettingView -"general_setting_view.title.general" = "일반"; -"general_setting_view.title.language" = "언어"; -"general_setting_view.title.auto_lock" = "앱 자동 잠금"; -"general_setting_view.title.enables_tags_extension" = "Enables tags extension"; -"general_setting_view.title.translates_tags" = "태그 번역하기"; -"general_setting_view.title.shows_tags_search_suggestion" = "Shows tags search suggestion"; -"general_setting_view.title.shows_images_in_tags" = "Shows images in tags"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "선택한 서버로 이동하기"; -"general_setting_view.title.detects_links_from_clipboard" = "클립보드의 링크 인식하기"; -"general_setting_view.title.background_blur_radius" = "Background blur radius"; -"general_setting_view.button.app_activity_logs" = "앱 활동 로그"; -"general_setting_view.button.import_custom_translations" = "Import custom translations"; -"general_setting_view.button.remove_custom_translations" = "Remove custom translations"; -"general_setting_view.button.clear_image_caches" = "이미지 캐시 지우기"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "Tags"; -"general_setting_view.section.title.navigation" = "내비게이션"; -"general_setting_view.section.title.security" = "개인 정보 보호"; -"general_setting_view.section.title.caches" = "캐시"; +"general_setting_view.general" = "일반"; +"general_setting_view.language" = "언어"; +"general_setting_view.auto_lock" = "앱 자동 잠금"; +"general_setting_view.enables_tags_extension" = "Enables tags extension"; +"general_setting_view.translates_tags" = "태그 번역하기"; +"general_setting_view.shows_tags_search_suggestion" = "Shows tags search suggestion"; +"general_setting_view.shows_images_in_tags" = "Shows images in tags"; +"general_setting_view.redirects_links_to_the_selected_host" = "선택한 서버로 이동하기"; +"general_setting_view.detects_links_from_clipboard" = "클립보드의 링크 인식하기"; +"general_setting_view.background_blur_radius" = "Background blur radius"; +"general_setting_view.app_activity_logs" = "앱 활동 로그"; +"general_setting_view.import_custom_translations" = "Import custom translations"; +"general_setting_view.remove_custom_translations" = "Remove custom translations"; +"general_setting_view.clear_image_caches" = "이미지 캐시 지우기"; +"general_setting_view.default_language_description" = "N/A"; +"general_setting_view.tags" = "Tags"; +"general_setting_view.navigation" = "내비게이션"; +"general_setting_view.security" = "개인 정보 보호"; +"general_setting_view.caches" = "캐시"; // AutoLockPolicy -"enum.auto_lock_policy.value.never" = "안 함"; -"enum.auto_lock_policy.value.instantly" = "즉시"; +"auto_lock_policy.never" = "안 함"; +"auto_lock_policy.instantly" = "즉시"; // MARK: AppActivityLogsView "app_activity_logs_view.title" = "앱 활동 로그"; -"app_activity_logs_view.placeholder.no_logs" = "로그가 없습니다"; -"app_activity_logs_view.section.current" = "현재"; +"app_activity_logs_view.no_logs" = "로그가 없습니다"; +"app_activity_logs_view.current" = "현재"; "app_activity_logs_view.run" = "실행 %@"; "app_activity_logs_view.more_logs" = "더 많은 로그"; "app_activity_logs_view.open_in_files" = "파일 앱에서 열기"; @@ -242,810 +239,786 @@ "app_activity_logs_view.level.fault" = "결함"; // MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "외관"; -"appearance_setting_view.title.theme" = "테마"; -"appearance_setting_view.title.tint_color" = "액센트 색상"; -"appearance_setting_view.title.display_mode" = "표시방식"; -"appearance_setting_view.title.shows_tags_in_list" = "리스트에서 태그 보여주기"; -"appearance_setting_view.title.maximum_number_of_tags" = "태그 갯수"; -"appearance_setting_view.title.displays_japanese_title" = "Displays Japanese title"; -"appearance_setting_view.button.app_icon" = "앱 아이콘"; -"appearance_setting_view.menu.title.infite" = "제한 없음"; -"appearance_setting_view.section.title.list" = "리스트"; -"appearance_setting_view.section.title.gallery" = "Gallery"; +"appearance_setting_view.appearance" = "외관"; +"appearance_setting_view.theme" = "테마"; +"appearance_setting_view.tint_color" = "액센트 색상"; +"appearance_setting_view.display_mode" = "표시방식"; +"appearance_setting_view.shows_tags_in_list" = "리스트에서 태그 보여주기"; +"appearance_setting_view.maximum_number_of_tags" = "태그 갯수"; +"appearance_setting_view.displays_japanese_title" = "Displays Japanese title"; +"appearance_setting_view.app_icon" = "앱 아이콘"; +"appearance_setting_view.infite" = "제한 없음"; +"appearance_setting_view.list" = "리스트"; +"appearance_setting_view.gallery" = "Gallery"; // PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "자동"; -"enum.preferred_color_scheme.value.light" = "라이트"; -"enum.preferred_color_scheme.value.dark" = "다크"; +"preferred_color_scheme.automatic" = "자동"; +"preferred_color_scheme.light" = "라이트"; +"preferred_color_scheme.dark" = "다크"; // AppIconType -"enum.app_icon_type.value.default" = "기본"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; +"app_icon_type.default" = "기본"; +"app_icon_type.ukiyoe" = "Ukiyo-e"; +"app_icon_type.developer" = "Developer"; +"app_icon_type.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; +"app_icon_type.not_my_president" = "NOT MY PRESIDENT"; // ListDisplayMode -"enum.list_display_mode.value.detail" = "자세히"; -"enum.list_display_mode.value.thumbnail" = "썸네일"; +"list_display_mode.detail" = "자세히"; +"list_display_mode.thumbnail" = "썸네일"; // MARK: AppIconView -"app_icon_view.title.app_icon" = "앱 아이콘"; +"app_icon_view.app_icon" = "앱 아이콘"; // MARK: reading_settingView -"reading_setting_view.title.reading" = "읽기"; -"reading_setting_view.title.direction" = "방향"; -"reading_setting_view.title.preload_limit" = "페이지 미리 로딩"; -"reading_setting_view.title.enables_landscape" = "Enables landscape"; -"reading_setting_view.title.separator_height" = "페이지 간 여백 두께"; -"reading_setting_view.title.maximum_scale_factor" = "최대 확대 비율"; -"reading_setting_view.title.double_tap_scale_factor" = "더블 탭 확대 비율"; -"reading_setting_view.section.title.appearance" = "외관"; +"reading_setting_view.reading" = "읽기"; +"reading_setting_view.direction" = "방향"; +"reading_setting_view.preload_limit" = "페이지 미리 로딩"; +"reading_setting_view.enables_landscape" = "Enables landscape"; +"reading_setting_view.separator_height" = "페이지 간 여백 두께"; +"reading_setting_view.maximum_scale_factor" = "최대 확대 비율"; +"reading_setting_view.double_tap_scale_factor" = "더블 탭 확대 비율"; +"reading_setting_view.appearance" = "외관"; // ReadingDirection -"enum.reading_direction.value.vertical" = "위에서 아래로"; -"enum.reading_direction.value.right_to_left" = "오른쪽에서 왼쪽으로"; -"enum.reading_direction.value.left_to_right" = "왼쪽에서 오른쪽으로"; +"reading_direction.vertical" = "위에서 아래로"; +"reading_direction.right_to_left" = "오른쪽에서 왼쪽으로"; +"reading_direction.left_to_right" = "왼쪽에서 오른쪽으로"; // MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "실험실"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "SNI 차단 우회"; +"laboratory_setting_view.laboratory" = "실험실"; +"laboratory_setting_view.bypasses_SNI_filtering" = "SNI 차단 우회"; // MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "웹사이트"; -"about_view.button.altStore_source" = "AltStore 소스"; -"about_view.title.version" = "버전"; -"about_view.section.title.special_thanks" = "Special thanks"; -"about_view.section.title.code_level_contributors" = "Code-level contributors"; -"about_view.section.title.translation_contributors" = "Translation contributors"; -"about_view.section.title.acknowledgements" = "도움을 주신 분들"; +"about_view.ehPanda" = "EhPanda"; +"about_view.website" = "웹사이트"; +"about_view.altStore_source" = "AltStore 소스"; +"about_view.version" = "버전"; +"about_view.special_thanks" = "Special thanks"; +"about_view.code_level_contributors" = "Code-level contributors"; +"about_view.translation_contributors" = "Translation contributors"; +"about_view.acknowledgements" = "도움을 주신 분들"; // MARK: DetailView -"detail_view.button.download_login" = "로그인"; -"detail_view.button.download_get" = "받기"; -"detail_view.button.download_wait" = "대기"; -"detail_view.button.download_done" = "완료"; -"detail_view.button.download_update" = "업데이트"; -"detail_view.button.download_retry" = "재시도"; -"detail_view.button.download_repair" = "복구"; -"detail_view.button.read" = "읽기"; -"detail_view.button.post_comment" = "평가 남기기"; -"detail_view.accessibility.download_button.login" = "다운로드하려면 로그인해야 합니다"; -"detail_view.accessibility.download_button.download" = "다운로드"; -"detail_view.accessibility.download_button.queued" = "다운로드 대기 중"; -"detail_view.accessibility.download_button.downloading" = "%d / %d 페이지 다운로드 중"; -"detail_view.accessibility.download_button.downloaded" = "다운로드한 갤러리 삭제"; -"detail_view.accessibility.download_button.update" = "다운로드 업데이트"; -"detail_view.accessibility.download_button.retry" = "다운로드 다시 시도"; -"detail_view.accessibility.download_button.repair" = "다운로드 복구"; -"detail_view.accessibility.download_button.preparing" = "다운로드 정보를 불러오는 중"; -"detail_view.accessibility.download_button.pause_action" = "다운로드 일시 정지"; -"detail_view.accessibility.download_button.paused" = "다운로드 다시 시작. %d / %d 페이지에서 일시 정지됨"; -"detail_view.accessibility.download_button.partial" = "다운로드 다시 시도. 이미 %d / %d 페이지를 사용할 수 있습니다."; -"detail_view.toolbar_item.button.archives" = "아카이브"; -"detail_view.toolbar_item.button.torrents" = "토렌트"; -"detail_view.toolbar_item.button.share" = "공유"; -"detail_view.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "Withdraw vote"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "즐겨찾기"; -"detail_view.description_section.title.language" = "언어"; -"detail_view.description_section.title.ratings" = "%@명의 별점"; -"detail_view.description_section.title.page_count" = "페이지 수"; -"detail_view.description_section.title.file_size" = "파일 크기"; -"detail_view.description_section.description.favorited" = "번"; -"detail_view.description_section.description.page_count" = "페이지"; -"detail_view.action_section.button.give_a_rating" = "별점 주기"; -"detail_view.action_section.button.similar_gallery" = "비슷한 작품"; -"detail_view.section.title.previews" = "미리보기"; -"detail_view.section.title.comments" = "댓글"; -"detail_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; -"detail_view.dialog.title.repair_download" = "다운로드를 복구할까요?"; -"detail_view.dialog.title.update_download" = "다운로드를 업데이트할까요?"; -"detail_view.dialog.title.redownload_gallery" = "갤러리를 다시 다운로드할까요?"; -"detail_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; -"detail_view.dialog.message.repair_download" = "이 갤러리의 오프라인 파일을 지금 복구할까요?"; -"detail_view.dialog.message.update_download" = "이 갤러리를 지금 온라인 최신 버전으로 업데이트할까요?"; -"detail_view.dialog.message.redownload_gallery" = "이 갤러리를 지금 처음부터 다시 다운로드할까요?"; -"detail_view.dialog.button.repair" = "복구"; -"detail_view.dialog.button.update" = "업데이트"; -"detail_view.dialog.button.redownload" = "다시 다운로드"; -"detail_view.offline_notice.saved_details" = "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다."; +"detail_view.read" = "읽기"; +"detail_view.post_comment" = "평가 남기기"; +"detail_view.accessibility.login" = "다운로드하려면 로그인해야 합니다"; +"detail_view.accessibility.download" = "다운로드"; +"detail_view.accessibility.queued" = "다운로드 대기 중"; +"detail_view.accessibility.downloading" = "%d / %d 페이지 다운로드 중"; +"detail_view.accessibility.downloaded" = "다운로드한 갤러리 삭제"; +"detail_view.accessibility.update" = "다운로드 업데이트"; +"detail_view.accessibility.retry" = "다운로드 다시 시도"; +"detail_view.accessibility.repair" = "다운로드 복구"; +"detail_view.accessibility.preparing" = "다운로드 정보를 불러오는 중"; +"detail_view.accessibility.pause_action" = "다운로드 일시 정지"; +"detail_view.accessibility.paused" = "다운로드 다시 시작. %d / %d 페이지에서 일시 정지됨"; +"detail_view.accessibility.partial" = "다운로드 다시 시도. 이미 %d / %d 페이지를 사용할 수 있습니다."; +"detail_view.archives" = "아카이브"; +"detail_view.torrents" = "토렌트"; +"detail_view.share" = "공유"; +"detail_view.detail" = "Detail"; +"detail_view.withdraw_vote" = "Withdraw vote"; +"detail_view.vote_up" = "Vote up"; +"detail_view.vote_down" = "Vote down"; +"detail_view.favorited" = "즐겨찾기"; +"detail_view.language" = "언어"; +"detail_view.ratings" = "%@명의 별점"; +"detail_view.page_count" = "페이지 수"; +"detail_view.file_size" = "파일 크기"; +"detail_view.favorited_unit" = "번"; +"detail_view.page_count_unit" = "페이지"; +"detail_view.give_a_rating" = "별점 주기"; +"detail_view.similar_gallery" = "비슷한 작품"; +"detail_view.previews" = "미리보기"; +"detail_view.comments" = "댓글"; +"detail_view.delete_download" = "다운로드를 삭제할까요?"; +"detail_view.repair_download" = "다운로드를 복구할까요?"; +"detail_view.update_download" = "다운로드를 업데이트할까요?"; +"detail_view.redownload_gallery" = "갤러리를 다시 다운로드할까요?"; +"detail_view.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; +"detail_view.repair_download_description" = "이 갤러리의 오프라인 파일을 지금 복구할까요?"; +"detail_view.update_download_description" = "이 갤러리를 지금 온라인 최신 버전으로 업데이트할까요?"; +"detail_view.redownload_gallery_description" = "이 갤러리를 지금 처음부터 다시 다운로드할까요?"; +"detail_view.repair" = "복구"; +"detail_view.update" = "업데이트"; +"detail_view.redownload" = "다시 다운로드"; +"detail_view.saved_details" = "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다."; // MARK: ArchivesView -"archives_view.title.archives" = "아카이브"; -"archives_view.button.download_to_hath_client" = "H@H 클라이언트로 저장"; +"archives_view.archives" = "아카이브"; +"archives_view.download_to_hath_client" = "H@H 클라이언트로 저장"; // HathArchive -"struct.hath_archive.price.free" = "무료"; -"struct.hath_archive.price.not_available" = "무효"; +"hath_archive.free" = "무료"; // ArchiveResolution -"enum.archive_resolution.value.original" = "원본"; +"archive_resolution.original" = "원본"; // MARK: TorrentsView -"torrents_view.title.torrents" = "토렌트"; +"torrents_view.torrents" = "토렌트"; // MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "갤러리 정보"; -"gallery_infos_view.title.id" = "ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "제목"; -"gallery_infos_view.title.japanese_title" = "일본어 제목"; -"gallery_infos_view.title.gallery_URL" = "갤러리 주소"; -"gallery_infos_view.title.cover_URL" = "표지 주소"; -"gallery_infos_view.title.archive_URL" = "아카이브 주소"; -"gallery_infos_view.title.torrent_URL" = "토렌트 주소"; -"gallery_infos_view.title.parent_URL" = "부모 갤러리 링크"; -"gallery_infos_view.title.category" = "장르"; -"gallery_infos_view.title.uploader" = "업로드"; -"gallery_infos_view.title.posted_date" = "업로드된 날짜"; -"gallery_infos_view.title.visibility" = "가시성"; -"gallery_infos_view.title.language" = "언어"; -"gallery_infos_view.title.page_count" = "페이지 수"; -"gallery_infos_view.title.file_size" = "파일 크기"; -"gallery_infos_view.title.favorited_times" = "즐겨찾기된 수"; -"gallery_infos_view.title.favorited" = "즐겨찾기에 저장 됨"; -"gallery_infos_view.title.rating_count" = "별점 갯수"; -"gallery_infos_view.title.average_rating" = "평균 별점"; -"gallery_infos_view.title.my_rating" = "My rating"; -"gallery_infos_view.title.torrent_count" = "토렌트 수"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "네"; -"gallery_infos_view.value.no" = "아니요"; +"gallery_infos_view.gallery_infos" = "갤러리 정보"; +"gallery_infos_view.id" = "ID"; +"gallery_infos_view.token" = "Token"; +"gallery_infos_view.title" = "제목"; +"gallery_infos_view.japanese_title" = "일본어 제목"; +"gallery_infos_view.gallery_URL" = "갤러리 주소"; +"gallery_infos_view.cover_URL" = "표지 주소"; +"gallery_infos_view.archive_URL" = "아카이브 주소"; +"gallery_infos_view.torrent_URL" = "토렌트 주소"; +"gallery_infos_view.parent_URL" = "부모 갤러리 링크"; +"gallery_infos_view.category" = "장르"; +"gallery_infos_view.uploader" = "업로드"; +"gallery_infos_view.posted_date" = "업로드된 날짜"; +"gallery_infos_view.visibility" = "가시성"; +"gallery_infos_view.language" = "언어"; +"gallery_infos_view.page_count" = "페이지 수"; +"gallery_infos_view.file_size" = "파일 크기"; +"gallery_infos_view.favorited_times" = "즐겨찾기된 수"; +"gallery_infos_view.favorited" = "즐겨찾기에 저장 됨"; +"gallery_infos_view.rating_count" = "별점 갯수"; +"gallery_infos_view.average_rating" = "평균 별점"; +"gallery_infos_view.my_rating" = "My rating"; +"gallery_infos_view.torrent_count" = "토렌트 수"; +"gallery_infos_view.none" = "None"; +"gallery_infos_view.yes" = "네"; +"gallery_infos_view.no" = "아니요"; // GalleryVisibility -"enum.gallery_visibility.value.yes" = "네"; -"enum.gallery_visibility.value.no" = "아니요 (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "삭제됨"; +"gallery_visibility.yes" = "네"; +"gallery_visibility.no" = "아니요 (%@)"; +"gallery_visibility.expunged" = "삭제됨"; // MARK: TagDetailView -"tag_detail_view.section.title.images" = "Images"; -"tag_detail_view.section.title.links" = "Links"; +"tag_detail_view.images" = "Images"; +"tag_detail_view.links" = "Links"; // MARK: DownloadsView -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "다운로드"; -"downloads_view.search.prompt.downloads" = "다운로드 검색"; -"downloads_view.dialog.title.delete_download" = "다운로드를 삭제할까요?"; -"downloads_view.dialog.message.delete_active_download" = "현재 다운로드를 취소하고 이 기기에서 삭제합니다."; -"downloads_view.dialog.message.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; -"downloads_view.swipe.button.pages" = "페이지"; -"downloads_view.swipe.button.update" = "업데이트"; -"downloads_view.swipe.button.resume" = "재개"; -"downloads_view.swipe.button.pause" = "일시 정지"; -"downloads_view.empty_state.downloads" = "다운로드한 갤러리가 여기에 표시됩니다."; -"downloads_view.empty_state.no_matching_filters" = "현재 필터와 일치하는 다운로드가 없습니다."; -"downloads_view.button.clear_filters" = "필터 지우기"; -"downloads_view.button.validate_image_data" = "이미지 데이터 검증"; -"downloads_view.inspector.section.actions" = "동작"; -"downloads_view.inspector.section.pages" = "페이지"; -"downloads_view.inspector.button.retry_failed_pages" = "실패한 페이지 다시 시도"; -"downloads_view.inspector.button.validating_image_data" = "이미지 데이터 검증 중..."; -"downloads_view.inspector.button.update_download" = "다운로드 업데이트"; -"downloads_view.inspector.toast.image_data_valid" = "이미지 데이터가 유효합니다"; -"downloads_view.inspector.toast.image_data_unavailable" = "이미지 데이터를 검증할 수 없습니다."; -"downloads_view.inspector.title.download_status" = "다운로드 상태"; -"downloads_view.inspector.page.pending" = "대기 중"; -"downloads_view.inspector.page.tap_to_retry" = "탭하여 이 페이지를 다시 시도"; -"downloads_view.inspector.page.title" = "페이지 %d"; -"downloads_view.inspector.page.none" = "페이지 없음"; -"downloads_view.inspector.status.pending" = "대기 중"; -"downloads_view.inspector.status.downloaded" = "다운로드됨"; -"downloads_view.inspector.status.failed" = "실패"; +"download_folder_filter.all" = "All"; +"detail_view.manage_folders" = "Manage Folders"; +"detail_view.create_default_folder" = "Create Default Folder"; +"detail_view.no_folders" = "No folders yet"; +"downloads_view.manage_folders" = "Manage Folders"; +"downloads_view.move_to_folder" = "Move to Folder"; +"downloads_view.move" = "Move"; +"folder_manager_view.folders" = "Folders"; +"folder_manager_view.folder_name" = "Folder name"; +"folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_folders" = "Folders you create will appear here."; +"downloads_view.downloads" = "다운로드"; +"downloads_view.search_downloads" = "다운로드 검색"; +"downloads_view.delete_download" = "다운로드를 삭제할까요?"; +"downloads_view.delete_active_download" = "현재 다운로드를 취소하고 이 기기에서 삭제합니다."; +"downloads_view.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; +"downloads_view.pages" = "페이지"; +"downloads_view.update" = "업데이트"; +"downloads_view.resume" = "재개"; +"downloads_view.pause" = "일시 정지"; +"downloads_view.empty_downloads" = "다운로드한 갤러리가 여기에 표시됩니다."; +"downloads_view.no_matching_filters" = "현재 필터와 일치하는 다운로드가 없습니다."; +"downloads_view.clear_filters" = "필터 지우기"; +"downloads_view.validate_image_data" = "이미지 데이터 검증"; +"download_inspector_view.actions" = "동작"; +"download_inspector_view.retry_failed_pages" = "실패한 페이지 다시 시도"; +"download_inspector_view.validating_image_data" = "이미지 데이터 검증 중..."; +"download_inspector_view.image_data_valid" = "이미지 데이터가 유효합니다"; +"download_inspector_view.image_data_unavailable" = "이미지 데이터를 검증할 수 없습니다."; +"download_inspector_view.download_status" = "다운로드 상태"; +"download_inspector_view.pending" = "대기 중"; +"download_inspector_view.none" = "페이지 없음"; +"download_inspector_view.downloaded" = "다운로드됨"; +"download_inspector_view.failed" = "실패"; // MARK: DownloadSettingView "download_setting_view.title" = "다운로드"; -"download_setting_view.section.title.download_queue" = "다운로드 대기열"; -"download_setting_view.section.title.network" = "네트워크"; -"download_setting_view.title.concurrent_image_downloads" = "동시 이미지 다운로드 수"; -"download_setting_view.title.retry_failed_pages_automatically" = "실패한 페이지 자동 재시도"; -"download_setting_view.title.allow_cellular_downloads" = "셀룰러 다운로드 허용"; -"download_setting_view.footer.network" = "한 번에 하나의 갤러리만 다운로드됩니다. 이 설정으로 한 갤러리 안에서 동시에 다운로드할 페이지 수, 셀룰러 다운로드 허용 여부, 그리고 파일을 앱의 Downloads 폴더에 저장하는 방식을 제어합니다."; +"download_setting_view.network" = "네트워크"; +"download_setting_view.concurrent_image_downloads" = "동시 이미지 다운로드 수"; +"download_setting_view.retry_failed_pages_automatically" = "실패한 페이지 자동 재시도"; +"download_setting_view.allow_cellular_downloads" = "셀룰러 다운로드 허용"; +"download_setting_view.network_description" = "한 번에 하나의 갤러리만 다운로드됩니다. 이 설정으로 한 갤러리 안에서 동시에 다운로드할 페이지 수, 셀룰러 다운로드 허용 여부, 그리고 파일을 앱의 Downloads 폴더에 저장하는 방식을 제어합니다."; // MARK: CommentsView -"comments_view.title.comments" = "댓글"; +"comments_view.comments" = "댓글"; // MARK: PostCommentView -"post_comment_view.title.post_comment" = "평가 남기기"; -"post_comment_view.title.edit_comment" = "평가 수정"; +"post_comment_view.post_comment" = "평가 남기기"; +"post_comment_view.edit_comment" = "평가 수정"; // MARK: PreviewsView -"previews_view.title.previews" = "미리보기"; +"previews_view.previews" = "미리보기"; // MARK: ReadingView -"reading_view.context_menu.button.reload" = "재시도"; -"reading_view.context_menu.button.copy" = "복사"; -"reading_view.context_menu.button.save" = "저장"; -"reading_view.context_menu.button.save_original" = "Save original"; -"reading_view.context_menu.button.share" = "공유"; -"reading_view.toolbar_item.title.auto_play" = "자동 재생"; -"reading_view.toolbar_item.title.dual_page_mode" = "두 장을 한 화면으로 보기"; -"reading_view.toolbar_item.title.except_the_cover" = "표지 제외하기"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "Retry failed images"; -"reading_view.toolbar_item.button.reload_all_images" = "Reload all images"; -"reading_view.toolbar_item.button.reading_setting" = "Reading setting"; +"reading_view.reload" = "재시도"; +"reading_view.copy" = "복사"; +"reading_view.save" = "저장"; +"reading_view.save_original" = "Save original"; +"reading_view.share" = "공유"; +"reading_view.auto_play" = "자동 재생"; +"reading_view.dual_page_mode" = "두 장을 한 화면으로 보기"; +"reading_view.except_the_cover" = "표지 제외하기"; +"reading_view.retry_all_failed_images" = "Retry failed images"; +"reading_view.reload_all_images" = "Reload all images"; +"reading_view.reading_setting" = "Reading setting"; // AutoPlayPolicy -"enum.auto_play_policy.value.off" = "Off"; +"auto_play_policy.off" = "Off"; // MARK: DownloadBadge -"struct.download_badge.text.queued" = "대기 중"; -"struct.download_badge.text.downloading" = "다운로드 중"; -"struct.download_badge.text.paused" = "일시 정지"; -"struct.download_badge.text.downloaded" = "다운로드됨"; -"struct.download_badge.text.needs_attention" = "조치 필요"; -"struct.download_badge.text.update_available" = "업데이트 가능"; -"struct.download_badge.text.needs_repair" = "복구 필요"; -"struct.download_badge.progress" = "%d/%d"; +"download_badge.queued" = "대기 중"; +"download_badge.downloading" = "다운로드 중"; +"download_badge.paused" = "일시 정지"; +"download_badge.downloaded" = "다운로드됨"; +"download_badge.needs_attention" = "조치 필요"; +"download_badge.update_available" = "업데이트 가능"; +"download_badge.progress" = "%d/%d"; // MARK: DownloadStore -"download_store.error.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "다운로드 폴더를 확인할 수 없습니다."; -"download_store.validation.download_folder_missing" = "다운로드 폴더가 없습니다."; -"download_store.validation.manifest_missing" = "매니페스트 파일이 없습니다."; -"download_store.validation.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; -"download_store.validation.downloaded_pages_incomplete" = "다운로드한 페이지가 불완전합니다."; -"download_store.validation.cover_image_missing" = "표지 이미지가 없습니다."; -"download_store.validation.page_missing" = "페이지 %d가 없습니다."; -"download_store.validation.cover_image_corrupted" = "표지 이미지 데이터가 손상되었습니다."; -"download_store.validation.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; +"download_store.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; +"download_store.invalid_folder_name" = "The folder name is invalid."; +"download_store.folder_already_exists" = "A folder with this name already exists."; +"download_store.folder_busy_downloading" = "The folder contains an active download."; +"download_store.download_busy" = "The download is currently active."; +"download_store.download_folder_missing" = "다운로드 폴더가 없습니다."; +"download_store.manifest_missing" = "매니페스트 파일이 없습니다."; +"download_store.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; +"download_store.page_missing" = "페이지 %d가 없습니다."; +"download_store.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; // MARK: FiltersView -"filters_view.title.filters" = "필터"; -"filters_view.title.advanced_settings" = "고급 설정"; -"filters_view.title.search_gallery_name" = "갤러리 이름을 찾아보기"; -"filters_view.title.search_gallery_tags" = "갤러리 태그를 찾아보기"; -"filters_view.title.search_gallery_description" = "갤러리 설명을 찾아보기"; -"filters_view.title.search_torrent_filenames" = "토렌트 파일 이름을 찾아보기"; -"filters_view.title.only_show_galleries_with_torrents" = "토렌트 있는 갤러리만 보이기"; -"filters_view.title.search_low_power_tags" = "인기가 없는 태그를 찾아보기"; -"filters_view.title.search_downvoted_tags" = "낮은 평가의 태그를 찾아보기"; -"filters_view.title.search_expunged_galleries" = "삭제된 갤러리를 보여주기"; -"filters_view.title.set_minimum_rating" = "최소 별점 설정하기"; -"filters_view.title.minimum_rating" = "최소 별점"; -"filters_view.title.set_pages_range" = "페이지 범위 설정"; -"filters_view.title.pages_range" = "페이지 범위"; -"filters_view.title.disable_language_filter" = "언어 필터 끄기"; -"filters_view.title.disable_uploader_filter" = "업로더 필터 끄기"; -"filters_view.title.disable_tags_filter" = "태그 필터 끄기"; -"filters_view.button.reset_filters" = "모든 필터 초기화"; -"filters_view.section.title.advanced" = "고급"; -"filters_view.section.title.default_filter" = "기본 옵션"; +"filters_view.filters" = "필터"; +"filters_view.advanced_settings" = "고급 설정"; +"filters_view.search_gallery_name" = "갤러리 이름을 찾아보기"; +"filters_view.search_gallery_tags" = "갤러리 태그를 찾아보기"; +"filters_view.search_gallery_description" = "갤러리 설명을 찾아보기"; +"filters_view.search_torrent_filenames" = "토렌트 파일 이름을 찾아보기"; +"filters_view.only_show_galleries_with_torrents" = "토렌트 있는 갤러리만 보이기"; +"filters_view.search_low_power_tags" = "인기가 없는 태그를 찾아보기"; +"filters_view.search_downvoted_tags" = "낮은 평가의 태그를 찾아보기"; +"filters_view.search_expunged_galleries" = "삭제된 갤러리를 보여주기"; +"filters_view.set_minimum_rating" = "최소 별점 설정하기"; +"filters_view.minimum_rating" = "최소 별점"; +"filters_view.set_pages_range" = "페이지 범위 설정"; +"filters_view.pages_range" = "페이지 범위"; +"filters_view.disable_language_filter" = "언어 필터 끄기"; +"filters_view.disable_uploader_filter" = "업로더 필터 끄기"; +"filters_view.disable_tags_filter" = "태그 필터 끄기"; +"filters_view.reset_filters" = "모든 필터 초기화"; +"filters_view.advanced" = "고급"; +"filters_view.default_filter" = "기본 옵션"; // FilterRange -"enum.filter_range.value.search" = "검색"; -"enum.filter_range.value.global" = "전체"; -"enum.filter_range.value.watched" = "주시 태그"; +"filter_range.search" = "검색"; +"filter_range.global" = "전체"; +"filter_range.watched" = "주시 태그"; // MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 설정"; -"eh_setting_view.section.title.profile_settings" = "프로필 설정"; -"eh_setting_view.title.selected_profile" = "선택한 프로필"; -"eh_setting_view.button.set_as_default" = "기본으로 설정"; -"eh_setting_view.button.delete_profile" = "프로필 삭제"; -"eh_setting_view.button.rename" = "이름 변경"; -"eh_setting_view.button.create_new" = "추가"; -"eh_setting_view.toolbar_item.button.done" = "Done"; - -"eh_setting_view.section.title.image_load_settings" = "이미지 로드 설정"; -"eh_setting_view.title.load_images_through_the_hath_network" = "Hath 네트워크를 통하여 이미지 로드"; -"eh_setting_view.title.browsing_country" = "브라우징하는 나라"; -"eh_setting_view.description.browsing_country" = "**%@**에서 사이트를 탐색하거나 이 나라에서 VPN이나 프록시를 사용하려고 하는 것 같네요. 이런 경우엔 사이트에서 이 지역의 H@H 클라이언트의 이미지를 로드하려고 시도할 거에요. 만약에 이 나라가 잘못되었거나 분할 터널링 VPN을 사용하는 경우와 같이 어떤 이유로든 다른 지역을 사용하려는 경우라면, 아래에서 다른 나라를 선택할 수 있어요."; +"eh_setting_view.host_settings" = "%@ 설정"; +"eh_setting_view.profile_settings" = "프로필 설정"; +"eh_setting_view.selected_profile" = "선택한 프로필"; +"eh_setting_view.set_as_default" = "기본으로 설정"; +"eh_setting_view.delete_profile" = "프로필 삭제"; +"eh_setting_view.rename" = "이름 변경"; +"eh_setting_view.create_new" = "추가"; +"eh_setting_view.done" = "Done"; + +"eh_setting_view.image_load_settings" = "이미지 로드 설정"; +"eh_setting_view.load_images_through_the_hath_network" = "Hath 네트워크를 통하여 이미지 로드"; +"eh_setting_view.browsing_country" = "브라우징하는 나라"; +"eh_setting_view.browsing_country_description" = "**%@**에서 사이트를 탐색하거나 이 나라에서 VPN이나 프록시를 사용하려고 하는 것 같네요. 이런 경우엔 사이트에서 이 지역의 H@H 클라이언트의 이미지를 로드하려고 시도할 거에요. 만약에 이 나라가 잘못되었거나 분할 터널링 VPN을 사용하는 경우와 같이 어떤 이유로든 다른 지역을 사용하려는 경우라면, 아래에서 다른 나라를 선택할 수 있어요."; // EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "어떤 클라이언트에서든"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "기본 포트 클라이언트만"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "아닙니다 [Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "아닙니다 [Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "추천."; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "더 느려질 수 있어요. 나가는 비표준 포트를 차단하는 방화벽/프록시가 있는 경우 사용하세요."; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "기부자 전용 기능이에요. 심각한 문제가 있는 경우를 제외하고는 사용하지 말아주세요."; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; - -"eh_setting_view.section.title.image_size_settings" = "이미지 사이즈 설정"; -"eh_setting_view.title.image_resolution" = "이미지 해상도"; -"eh_setting_view.description.image_resolution" = "일반적으로 이미지는 온라인 뷰어를 위해 1280 픽셀의 수평 해상도로 작아져요. 아래의 압축된 해상도 중 하나를 선택할 수 있어요. 서버 과부하를 막기 위해, 1280 이상의 해상도는 도네이션을 한 사람, hath perk를 가진 사람, 그리고 UID가 300만 이하인 사람들로 일시적으로 제한되어요."; -"eh_setting_view.title.image_size" = "이미지 사이즈"; -"eh_setting_view.description.image_size" = "사이트가 사용자의 화면 너비에 맞게 이미지를 자동으로 축소시키지만, 수동으로 크기를 정할 수도 있어요. 크기 조정은 브라우저 측에서 수행되므로 이미지가 다시 샘플링되지 않아요. (0 = no limit)"; -"eh_setting_view.title.horizontal" = "가로"; -"eh_setting_view.title.vertical" = "세로"; +"load_through_hath_setting.any_client" = "어떤 클라이언트에서든"; +"load_through_hath_setting.default_port_only" = "기본 포트 클라이언트만"; +"load_through_hath_setting.modern_no" = "아닙니다 [Modern/HTTPS]"; +"load_through_hath_setting.legacy_no" = "아닙니다 [Legacy/HTTP]"; +"load_through_hath_setting.any_client_description" = "추천."; +"load_through_hath_setting.default_port_only_description" = "더 느려질 수 있어요. 나가는 비표준 포트를 차단하는 방화벽/프록시가 있는 경우 사용하세요."; +"load_through_hath_setting.modern_no_description" = "기부자 전용 기능이에요. 심각한 문제가 있는 경우를 제외하고는 사용하지 말아주세요."; +"load_through_hath_setting.legacy_no_description" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; + +"eh_setting_view.image_size_settings" = "이미지 사이즈 설정"; +"eh_setting_view.image_resolution" = "이미지 해상도"; +"eh_setting_view.image_resolution_description" = "일반적으로 이미지는 온라인 뷰어를 위해 1280 픽셀의 수평 해상도로 작아져요. 아래의 압축된 해상도 중 하나를 선택할 수 있어요. 서버 과부하를 막기 위해, 1280 이상의 해상도는 도네이션을 한 사람, hath perk를 가진 사람, 그리고 UID가 300만 이하인 사람들로 일시적으로 제한되어요."; +"eh_setting_view.image_size" = "이미지 사이즈"; +"eh_setting_view.image_size_description" = "사이트가 사용자의 화면 너비에 맞게 이미지를 자동으로 축소시키지만, 수동으로 크기를 정할 수도 있어요. 크기 조정은 브라우저 측에서 수행되므로 이미지가 다시 샘플링되지 않아요. (0 = no limit)"; +"eh_setting_view.horizontal" = "가로"; +"eh_setting_view.vertical" = "세로"; // EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "자동"; +"image_resolution.auto" = "자동"; -"eh_setting_view.section.title.gallery_name_display" = "갤러리 이름 보이기"; -"eh_setting_view.title.gallery_name" = "갤러리 이름"; -"eh_setting_view.description.gallery_name" = "영어 제목과 일본어 제목 중 기본값으로 보일 언어를 선택해주세요."; +"eh_setting_view.gallery_name_display" = "갤러리 이름 보이기"; +"eh_setting_view.gallery_name" = "갤러리 이름"; +"eh_setting_view.gallery_name_description" = "영어 제목과 일본어 제목 중 기본값으로 보일 언어를 선택해주세요."; // EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "영어 제목"; -"enum.eh_setting.gallery_name.value.japanese" = "일본어 제목(가능하면)"; +"gallery_name.default" = "영어 제목"; +"gallery_name.japanese" = "일본어 제목(가능하면)"; -"eh_setting_view.section.title.archiver_settings" = "아카이버"; -"eh_setting_view.title.archiver_behavior" = "아카이버 동작 방법 설정"; -"eh_setting_view.description.archiver_behavior" = "아카이버의 기본 동작은 원본 또는 저화질 갤러리 저장에 대한 비용과 선택을 확인한 다음 다른 곳에서 클릭하거나 복사할 수 있는 링크를 표시하는 것입니다. 여기서 이 동작을 변경할 수 있습니다."; +"eh_setting_view.archiver_settings" = "아카이버"; +"eh_setting_view.archiver_behavior" = "아카이버 동작 방법 설정"; +"eh_setting_view.archiver_behavior_description" = "아카이버의 기본 동작은 원본 또는 저화질 갤러리 저장에 대한 비용과 선택을 확인한 다음 다른 곳에서 클릭하거나 복사할 수 있는 링크를 표시하는 것입니다. 여기서 이 동작을 변경할 수 있습니다."; // EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "수동 선택, 수동 시작 (기본)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "수동 선택, 자동 시작"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "자동으로 원본을 선택, 수동 시작"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "자동으로 원본을 선택, 자동 시작"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "자동으로 저화질을 선택, 수동 시작"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "자동으로 저화질을 선택, 자동 시작"; - -"eh_setting_view.section.title.front_page_settings" = "프론트 페이지 설정"; -"eh_setting_view.title.display_mode" = "표시방식"; -"eh_setting_view.description.display_mode" = "프론트와 검색 페이지에서 사용할 디스플레이 모드를 선택하세요."; -"eh_setting_view.section.title.show_search_range_indicator" = "Search Range Indicator"; -"eh_setting_view.title.show_search_range_indicator" = "Show search range indicator"; -"eh_setting_view.description.gallery_category" = "프론트와 검색 페이지에서 어떤 카테고리가 보여지도록 할까요?"; +"eh_setting.archiver_behavior.manual_select_manual_start" = "수동 선택, 수동 시작 (기본)"; +"eh_setting.archiver_behavior.manual_select_auto_start" = "수동 선택, 자동 시작"; +"eh_setting.archiver_behavior.auto_select_original_manual_start" = "자동으로 원본을 선택, 수동 시작"; +"eh_setting.archiver_behavior.auto_select_original_auto_start" = "자동으로 원본을 선택, 자동 시작"; +"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "자동으로 저화질을 선택, 수동 시작"; +"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "자동으로 저화질을 선택, 자동 시작"; + +"eh_setting_view.front_page_settings" = "프론트 페이지 설정"; +"eh_setting_view.display_mode" = "표시방식"; +"eh_setting_view.display_mode_description" = "프론트와 검색 페이지에서 사용할 디스플레이 모드를 선택하세요."; +"eh_setting_view.show_search_range_indicator" = "Search Range Indicator"; +"eh_setting_view.show_search_range_indicator_description" = "Show search range indicator"; +"eh_setting_view.gallery_category" = "프론트와 검색 페이지에서 어떤 카테고리가 보여지도록 할까요?"; // EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "Compact"; -"enum.eh_setting.display_mode.value.thumbnail" = "Thumbnail"; -"enum.eh_setting.display_mode.value.extended" = "Extended"; -"enum.eh_setting.display_mode.value.minimal" = "Minimal"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "Optional UI Elements"; -"eh_setting_view.description.optional_UI_elements" = "Some historic UI elements are now disabled by default. You can enable those here."; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; - -"eh_setting_view.section.title.favorites" = "즐겨찾기"; -"eh_setting_view.description.favorite_categories" = "여기서 좋아하는 장르들을 선택하고 이름을 바꿀 수 있어요."; -"eh_setting_view.title.favorites_sort_order" = "관심 순서를 배열"; -"eh_setting_view.description.favorites_sort_order" = "당신의 관심 페이지의 기본 정렬 방식을 선택할 수 있어요. 2016년 3월 개정 전에 추가된 즐겨찾기는 타임스탬프가 저장되지 않아 이 설정에 관계없이 갤러리가 게시된 시간으로 정렬되어요."; +"display_mode.compact" = "Compact"; +"display_mode.thumbnail" = "Thumbnail"; +"display_mode.extended" = "Extended"; +"display_mode.minimal" = "Minimal"; +"display_mode.minimalPlus" = "Minimal+"; + +"eh_setting_view.optional_UI_elements" = "Optional UI Elements"; +"eh_setting_view.optional_UI_elements_description" = "Some historic UI elements are now disabled by default. You can enable those here."; +"eh_setting_view.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; + +"eh_setting_view.favorites" = "즐겨찾기"; +"eh_setting_view.favorite_categories" = "여기서 좋아하는 장르들을 선택하고 이름을 바꿀 수 있어요."; +"eh_setting_view.favorites_sort_order" = "관심 순서를 배열"; +"eh_setting_view.favorites_sort_order_description" = "당신의 관심 페이지의 기본 정렬 방식을 선택할 수 있어요. 2016년 3월 개정 전에 추가된 즐겨찾기는 타임스탬프가 저장되지 않아 이 설정에 관계없이 갤러리가 게시된 시간으로 정렬되어요."; // EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "마지막 업데이트 시간으로"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "별점 시간으로"; +"favorites_sort_order.last_update_time" = "마지막 업데이트 시간으로"; +"favorites_sort_order.favorited_time" = "별점 시간으로"; -"eh_setting_view.section.title.ratings" = "별점"; -"eh_setting_view.title.ratings_color" = "별점 색깔"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "기본적으로 등급을 매긴 갤러리는 별 2개 이하의 등급에 대해 빨간색, 2.5~4개의 등급에 대해 녹색, 4.5~5개의 등급에 대해 파란색 별로 표시되어요. 아래에 원하는 색상 조합을 입력하여 사용자 정의할 수 있어요. 각 문자는 별 하나를 표현해요. 기본 RRGGB는 첫 번째와 두 번째 별의 경우 R(ed), 세 번째와 네 번째 별의 경우 G(reen), 다섯 번째 별의 경우 B(lue)를 의미해요. 일반 별에 (Y)ellow를 사용할 수도 있어요. 모든 5글자의 R/G/B/Y 콤보가 작동해요."; +"eh_setting_view.ratings" = "별점"; +"eh_setting_view.ratings_color" = "별점 색깔"; +"eh_setting_view.ratings_color_prompt" = "RRGGB"; +"eh_setting_view.ratings_color_description" = "기본적으로 등급을 매긴 갤러리는 별 2개 이하의 등급에 대해 빨간색, 2.5~4개의 등급에 대해 녹색, 4.5~5개의 등급에 대해 파란색 별로 표시되어요. 아래에 원하는 색상 조합을 입력하여 사용자 정의할 수 있어요. 각 문자는 별 하나를 표현해요. 기본 RRGGB는 첫 번째와 두 번째 별의 경우 R(ed), 세 번째와 네 번째 별의 경우 G(reen), 다섯 번째 별의 경우 B(lue)를 의미해요. 일반 별에 (Y)ellow를 사용할 수도 있어요. 모든 5글자의 R/G/B/Y 콤보가 작동해요."; -"eh_setting_view.section.title.tag_filtering_threshold" = "태그 필터링 임계값"; -"eh_setting_view.title.tag_filtering_threshold" = "태그 필터링 임계값"; -"eh_setting_view.description.tag_filtering_threshold" = "마이너스 가중치로 My Tags에 추가하여 태그를 소프트 필터할 수 있어요. 갤러리에 이 값 이하의 가중치를 추가하는 태그가 있으면 보기에서 필터링되어요. 이 임계값은 0과 -9999 사이에서 설정할 수 있어요."; +"eh_setting_view.tag_filtering_threshold" = "태그 필터링 임계값"; +"eh_setting_view.tag_filtering_threshold_description" = "마이너스 가중치로 My Tags에 추가하여 태그를 소프트 필터할 수 있어요. 갤러리에 이 값 이하의 가중치를 추가하는 태그가 있으면 보기에서 필터링되어요. 이 임계값은 0과 -9999 사이에서 설정할 수 있어요."; -"eh_setting_view.section.title.tag_watching_threshold" = "태그 보여주기 임계값"; -"eh_setting_view.title.tag_watching_threshold" = "태그 보여주기 임계값"; -"eh_setting_view.description.tag_watching_threshold" = "최근에 업로드된 갤러리는 최소 1개의 Watched 태그가 있고 Watched 태그의 가중치의 합이 이 값 이상이 될 경우 Watched 화면에 포함되어요. 이 임계값은 0과 9999 사이에서 설정할 수 있어요."; +"eh_setting_view.tag_watching_threshold" = "태그 보여주기 임계값"; +"eh_setting_view.tag_watching_threshold_description" = "최근에 업로드된 갤러리는 최소 1개의 Watched 태그가 있고 Watched 태그의 가중치의 합이 이 값 이상이 될 경우 Watched 화면에 포함되어요. 이 임계값은 0과 9999 사이에서 설정할 수 있어요."; -"eh_setting_view.section.title.filtered_removal_count" = "Show Filtered Removal Count"; -"eh_setting_view.description.filtered_removal_count" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; -"eh_setting_view.title.show_filtered_removal_count" = "Show filtered removal count"; +"eh_setting_viewfiltered_removal_count" = "Show Filtered Removal Count"; +"eh_setting_view.filtered_removal_count_description" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; +"eh_setting_view.show_filtered_removal_count" = "Show filtered removal count"; -"eh_setting_view.section.title.excluded_languages" = "제외된 언어"; -"eh_setting_view.description.excluded_languages" = "갤러리 목록에서 특정 언어로 된 갤러리를 숨기고 검색하려면 아래 목록에서 해당 갤러리를 선택해주세요. 검색어에 관계없이 일치하는 갤러리는 나타나지 않아요."; +"eh_setting_view.excluded_languages" = "제외된 언어"; +"eh_setting_view.excluded_languages_description" = "갤러리 목록에서 특정 언어로 된 갤러리를 숨기고 검색하려면 아래 목록에서 해당 갤러리를 선택해주세요. 검색어에 관계없이 일치하는 갤러리는 나타나지 않아요."; // EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "원본"; -"enum.eh_setting.excluded_languages_category.value.translated" = "번역됨"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "다시 쓰기"; - -"eh_setting_view.section.title.excluded_uploaders" = "제외된 업로드"; -"eh_setting_view.description.excluded_uploaders" = "갤러리 목록 및 검색에서 특정 업로더의 갤러리를 숨기려면 아래에 해당 갤러리를 추가해주세요. 한 줄에 하나의 사용자 이름을 입력해주세요. 이러한 업로더의 갤러리는 검색 쿼리에 관계없이 나타나지 않아요."; -"eh_setting_view.description.excluded_uploaders_count" = "**%@ / %@** 개의 슬롯을 사용하고 있어요."; - -"eh_setting_view.section.title.search_result_count" = "검색 결과 수"; -"eh_setting_view.title.result_count" = "결과 수"; -"eh_setting_view.description.result_count" = "인덱스 / 검색 / 토렌트 검색 페이지에 대해 페이지당 몇 개의 결과를 원하시나요?\n(Hath Perk: 페이징 확장 필요)"; - -"eh_setting_view.section.title.thumbnail_settings" = "썸네일 설정"; -"eh_setting_view.title.thumbnail_load_timing" = "썸네일 로드 시간"; -"eh_setting_view.description.thumbnail_load_timing" = "목록 모드를 사용할 때 앞 페이지의 마우스 오버 미리 보기를 어떻게 로드할까요?"; -"eh_setting_view.description.thumbnail_configuration" = "모든 방문한 갤러리에 대하여 기본 썸네일을 설정할 수 있어요."; -"eh_setting_view.title.thumbnail_size" = "사이즈"; -"eh_setting_view.title.thumbnail_row_count" = "줄"; +"excluded_languages_category.original" = "원본"; +"excluded_languages_category.translated" = "번역됨"; +"excluded_languages_category.rewrite" = "다시 쓰기"; + +"eh_setting_view.excluded_uploaders" = "제외된 업로드"; +"eh_setting_view.excluded_uploaders_description" = "갤러리 목록 및 검색에서 특정 업로더의 갤러리를 숨기려면 아래에 해당 갤러리를 추가해주세요. 한 줄에 하나의 사용자 이름을 입력해주세요. 이러한 업로더의 갤러리는 검색 쿼리에 관계없이 나타나지 않아요."; +"eh_setting_view.excluded_uploaders_count" = "**%@ / %@** 개의 슬롯을 사용하고 있어요."; + +"eh_setting_view.search_result_count" = "검색 결과 수"; +"eh_setting_view.result_count" = "결과 수"; +"eh_setting_view.result_count_description" = "인덱스 / 검색 / 토렌트 검색 페이지에 대해 페이지당 몇 개의 결과를 원하시나요?\n(Hath Perk: 페이징 확장 필요)"; + +"eh_setting_view.thumbnail_settings" = "썸네일 설정"; +"eh_setting_view.thumbnail_load_timing" = "썸네일 로드 시간"; +"eh_setting_view.thumbnail_load_timing_description" = "목록 모드를 사용할 때 앞 페이지의 마우스 오버 미리 보기를 어떻게 로드할까요?"; +"eh_setting_view.thumbnail_configuration" = "모든 방문한 갤러리에 대하여 기본 썸네일을 설정할 수 있어요."; +"eh_setting_view.thumbnail_size" = "사이즈"; +"eh_setting_view.thumbnail_row_count" = "줄"; // EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "마우스를 올릴 때"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "페이지 로드될 때"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "페이지가 더 빨리 로드되지만 엄지손가락이 나타나기 전까지 약간의 지연이 있을 수 있어요."; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "페이지 로드에 시간이 더 오래 걸리지만, 페이지가 로드된 후 썸네일을 로드하는데 지연이 없어요."; +"thumbnail_load_timing.on_mouse_over" = "마우스를 올릴 때"; +"thumbnail_load_timing.on_page_load" = "페이지 로드될 때"; +"thumbnail_load_timing.on_mouse_over_description" = "페이지가 더 빨리 로드되지만 엄지손가락이 나타나기 전까지 약간의 지연이 있을 수 있어요."; +"thumbnail_load_timing.on_page_load_description" = "페이지 로드에 시간이 더 오래 걸리지만, 페이지가 로드된 후 썸네일을 로드하는데 지연이 없어요."; // EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "보통"; -"enum.eh_setting.thumbnail_size.value.large" = "크게"; -"enum.eh_setting.thumbnail_size.value.small" = "Small"; -"enum.eh_setting.thumbnail_size.value.auto" = "Auto"; - -"eh_setting_view.section.title.cover_scaling" = "Cover Scaling"; -"eh_setting_view.title.scale_factor" = "크기 비율"; -"eh_setting_view.description.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; - -"eh_setting_view.section.title.viewport_override" = "뷰포트 조정"; -"eh_setting_view.title.virtual_width" = "가상 너비"; -"eh_setting_view.description.virtual_width" = "모바일 장치의 사이트 가상 너비를 설정할 수 있어요. 일반적으로 DPI에 따라 장치에 의해 자동으로 결정되어요. 100%% 썸네일 스케일의 추천 값은 640에서 1400 사이에요."; - -"eh_setting_view.section.title.gallery_comments" = "갤러리 댓글"; -"eh_setting_view.title.comments_sort_order" = "댓글 순서"; -"eh_setting_view.title.comments_votes_show_timing" = "평가의 시간을 보이기"; +"thumbnail_size.normal" = "보통"; +"thumbnail_size.large" = "크게"; +"thumbnail_size.small" = "Small"; +"thumbnail_size.auto" = "Auto"; + +"eh_setting_view.cover_scaling" = "Cover Scaling"; +"eh_setting_view.scale_factor" = "크기 비율"; +"eh_setting_view.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; + +"eh_setting_view.viewport_override" = "뷰포트 조정"; +"eh_setting_view.virtual_width" = "가상 너비"; +"eh_setting_view.virtual_width_description" = "모바일 장치의 사이트 가상 너비를 설정할 수 있어요. 일반적으로 DPI에 따라 장치에 의해 자동으로 결정되어요. 100%% 썸네일 스케일의 추천 값은 640에서 1400 사이에요."; + +"eh_setting_view.gallery_comments" = "갤러리 댓글"; +"eh_setting_view.comments_sort_order" = "댓글 순서"; +"eh_setting_view.comments_votes_show_timing" = "평가의 시간을 보이기"; // EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "가장 이른 순서"; -"enum.eh_setting.comments_sort_order.value.recent" = "최신순"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "평가가 가장 높은 순서"; +"comments_sort_order.oldest" = "가장 이른 순서"; +"comments_sort_order.recent" = "최신순"; +"comments_sort_order.highest_score" = "평가가 가장 높은 순서"; // EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "점수를 가리키커나 클리하기"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "항상"; +"comments_votes_show_timing.on_hover_or_click" = "점수를 가리키커나 클리하기"; +"comments_votes_show_timing.always" = "항상"; -"eh_setting_view.section.title.gallery_tags" = "갤러리 태그"; -"eh_setting_view.title.tags_sort_order" = "태그 순서를 배열"; +"eh_setting_view.gallery_tags" = "갤러리 태그"; +"eh_setting_view.tags_sort_order" = "태그 순서를 배열"; // EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "알파벳순으로"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "태크 가중치로"; +"tags_sort_order.alphabetical" = "알파벳순으로"; +"tags_sort_order.tag_power" = "태크 가중치로"; -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; +"eh_setting_view.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; +"eh_setting_view.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; -"eh_setting_view.section.title.hath_local_network_host" = "Hath 로컬 네트워크 호스트"; -"eh_setting_view.title.ip_address_port" = "IP주소:포트"; -"eh_setting_view.description.ip_address_port" = "이 설정은 사이트를 검색하는 것과 동일한 공용 IP로 로컬 네트워크에서 H@H 클라이언트를 실행하는 경우 사용할 수 있습니다. 일부 라우터는 버그가 있어 요청을 자신의 IP로 다시 라우팅할 수 없기에, 아래를 따라서 이 문제를 해결할 수 있습니다.\n찾아보는 동일한 장치에서 클라이언트를 실행하는 경우 루프백 주소(127.0.0.1:port)를 사용할 수 있습니다. 클라이언트가 네트워크의 다른 장치에서 실행 중인 경우 로컬 네트워크 IP를 사용할 수 있습니다. 일부 브라우저 구성에서는 외부 웹 사이트가 로컬 네트워크 IP가 있는 URL에 액세스할 수 없도록 합니다. 그런 다음 사이트가 작동하려면 사이트를 화이트리스트에 추가해야 합니다."; -"eh_setting_view.section.title.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; -"eh_setting_view.title.use_original_images" = "원본 뷰어 적용"; +"eh_setting_view.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; +"eh_setting_view.use_original_images" = "원본 뷰어 적용"; -"eh_setting_view.section.title.multi_page_viewer" = "멀티 페이지 뷰어"; -"eh_setting_view.title.use_multi_page_viewer" = "다중 페이지 뷰어 적용"; -"eh_setting_view.title.display_style" = "보여주기 스타일"; -"eh_setting_view.title.show_thumbnail_pane" = "썸네일 창 표시"; +"eh_setting_view.multi_page_viewer" = "멀티 페이지 뷰어"; +"eh_setting_view.use_multi_page_viewer" = "다중 페이지 뷰어 적용"; +"eh_setting_view.display_style" = "보여주기 스타일"; +"eh_setting_view.show_thumbnail_pane" = "썸네일 창 표시"; // EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "왼쪽 정렬, 너비 초과할 때 크기 맞추기"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "가운데 정렬, 너비 초과할 때 크기 맞추기"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "가운데 정렬, 항상 크기 맞추기"; +"multiple_page_viewer_style.align_left_scale_if_over_width" = "왼쪽 정렬, 너비 초과할 때 크기 맞추기"; +"multiple_page_viewer_style.align_center_scale_if_over_width" = "가운데 정렬, 너비 초과할 때 크기 맞추기"; +"multiple_page_viewer_style.align_center_always_scale" = "가운데 정렬, 항상 크기 맞추기"; // EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "None"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "Page Number Only"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "Page Number + Name"; +"gallery_page_numbering.none" = "None"; +"gallery_page_numbering.page_number_only" = "Page Number Only"; +"gallery_page_numbering.page_number_and_name" = "Page Number + Name"; // MARK: Category -"enum.category.value.doujinshi" = "동인지"; -"enum.category.value.manga" = "만화"; -"enum.category.value.artist_CG" = "일러스트"; -"enum.category.value.game_CG" = "게임 CG"; -"enum.category.value.western" = "서양"; -"enum.category.value.non_h" = "Non-H"; -"enum.category.value.image_set" = "포토북"; -"enum.category.value.cosplay" = "코스프레"; -"enum.category.value.asian_porn" = "Asian Porn"; -"enum.category.value.misc" = "기타"; -"enum.category.value.private" = "Private"; +"category.doujinshi" = "동인지"; +"category.manga" = "만화"; +"category.artist_CG" = "일러스트"; +"category.game_CG" = "게임 CG"; +"category.western" = "서양"; +"category.non_h" = "Non-H"; +"category.image_set" = "포토북"; +"category.cosplay" = "코스프레"; +"category.asian_porn" = "Asian Porn"; +"category.misc" = "기타"; +"category.private" = "Private"; // MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "재분류"; -"enum.tag_namespace.value.language" = "언어"; -"enum.tag_namespace.value.parody" = "원작"; -"enum.tag_namespace.value.character" = "캐릭터"; -"enum.tag_namespace.value.group" = "그룹"; -"enum.tag_namespace.value.artist" = "작가"; -"enum.tag_namespace.value.male" = "남성"; -"enum.tag_namespace.value.female" = "여성"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "Other"; -"enum.tag_namespace.value.temp" = "Temp"; +"tag_namespace.reclass" = "재분류"; +"tag_namespace.language" = "언어"; +"tag_namespace.parody" = "원작"; +"tag_namespace.character" = "캐릭터"; +"tag_namespace.group" = "그룹"; +"tag_namespace.artist" = "작가"; +"tag_namespace.male" = "남성"; +"tag_namespace.female" = "여성"; +"tag_namespace.mixed" = "Mixed"; +"tag_namespace.cosplayer" = "Cosplayer"; +"tag_namespace.other" = "Other"; +"tag_namespace.temp" = "Temp"; // MARK: Language -"enum.language.value.invalid" = "무효"; -"enum.language.value.other" = "Other"; -"enum.language.value.afrikaans" = "아프리칸스어"; -"enum.language.value.albanian" = "알바니아어"; -"enum.language.value.arabic" = "아랍어"; -"enum.language.value.bengali" = "벵갈어"; -"enum.language.value.bosnian" = "보스니아어"; -"enum.language.value.bulgarian" = "불가리아어"; -"enum.language.value.burmese" = "버마어"; -"enum.language.value.catalan" = "카탈루냐어"; -"enum.language.value.cebuano" = "세부어"; -"enum.language.value.chinese" = "중국어"; -"enum.language.value.croatian" = "크로아티아어"; -"enum.language.value.czech" = "체코어"; -"enum.language.value.danish" = "덴마크어"; -"enum.language.value.dutch" = "네덜란드어"; -"enum.language.value.english" = "영어"; -"enum.language.value.esperanto" = "국제어"; -"enum.language.value.estonian" = "에스토니아어"; -"enum.language.value.finnish" = "핀란드어"; -"enum.language.value.french" = "프랑스어"; -"enum.language.value.georgian" = "그루지야어"; -"enum.language.value.german" = "독일어"; -"enum.language.value.greek" = "그리스어"; -"enum.language.value.hebrew" = "히브리어"; -"enum.language.value.hindi" = "힌디어"; -"enum.language.value.hmong" = "묘어"; -"enum.language.value.hungarian" = "헝가리어"; -"enum.language.value.indonesian" = "인도네시아어"; -"enum.language.value.italian" = "이탈리아어"; -"enum.language.value.japanese" = "일본어"; -"enum.language.value.kazakh" = "카자흐어"; -"enum.language.value.khmer" = "크메르원"; -"enum.language.value.korean" = "한국어"; -"enum.language.value.kurdish" = "쿠르드어"; -"enum.language.value.lao" = "라오스어"; -"enum.language.value.latin" = "라틴어"; -"enum.language.value.mongolian" = "몽골어"; -"enum.language.value.ndebele" = "은데벨리어"; -"enum.language.value.nepali" = "네팔어"; -"enum.language.value.norwegian" = "노르웨이어로"; -"enum.language.value.oromo" = "오로모어"; -"enum.language.value.pashto" = "파슈토어"; -"enum.language.value.persian" = "페르시아어"; -"enum.language.value.polish" = "폴란드어"; -"enum.language.value.portuguese" = "포르투갈어"; -"enum.language.value.punjabi" = "펀자브어"; -"enum.language.value.romanian" = "루마니아어"; -"enum.language.value.russian" = "러시아어"; -"enum.language.value.sango" = "쌍고어"; -"enum.language.value.serbian" = "세르비아어"; -"enum.language.value.shona" = "쇼나어"; -"enum.language.value.slovak" = "슬로바키아어"; -"enum.language.value.slovenian" = "슬로베니아어"; -"enum.language.value.somali" = "소말리아어"; -"enum.language.value.spanish" = "스페인어"; -"enum.language.value.swahili" = "스와히리어로"; -"enum.language.value.swedish" = "스웨덴어"; -"enum.language.value.tagalog" = "타갈로어"; -"enum.language.value.thai" = "타이어"; -"enum.language.value.tigrinya" = "티글리니아어"; -"enum.language.value.turkish" = "터키어"; -"enum.language.value.ukrainian" = "우크라이나어"; -"enum.language.value.urdu" = "우르두어"; -"enum.language.value.vietnamese" = "베트남어"; -"enum.language.value.zulu" = "줄루어"; +"language.invalid" = "무효"; +"language.other" = "Other"; +"language.afrikaans" = "아프리칸스어"; +"language.albanian" = "알바니아어"; +"language.arabic" = "아랍어"; +"language.bengali" = "벵갈어"; +"language.bosnian" = "보스니아어"; +"language.bulgarian" = "불가리아어"; +"language.burmese" = "버마어"; +"language.catalan" = "카탈루냐어"; +"language.cebuano" = "세부어"; +"language.chinese" = "중국어"; +"language.croatian" = "크로아티아어"; +"language.czech" = "체코어"; +"language.danish" = "덴마크어"; +"language.dutch" = "네덜란드어"; +"language.english" = "영어"; +"language.esperanto" = "국제어"; +"language.estonian" = "에스토니아어"; +"language.finnish" = "핀란드어"; +"language.french" = "프랑스어"; +"language.georgian" = "그루지야어"; +"language.german" = "독일어"; +"language.greek" = "그리스어"; +"language.hebrew" = "히브리어"; +"language.hindi" = "힌디어"; +"language.hmong" = "묘어"; +"language.hungarian" = "헝가리어"; +"language.indonesian" = "인도네시아어"; +"language.italian" = "이탈리아어"; +"language.japanese" = "일본어"; +"language.kazakh" = "카자흐어"; +"language.khmer" = "크메르원"; +"language.korean" = "한국어"; +"language.kurdish" = "쿠르드어"; +"language.lao" = "라오스어"; +"language.latin" = "라틴어"; +"language.mongolian" = "몽골어"; +"language.ndebele" = "은데벨리어"; +"language.nepali" = "네팔어"; +"language.norwegian" = "노르웨이어로"; +"language.oromo" = "오로모어"; +"language.pashto" = "파슈토어"; +"language.persian" = "페르시아어"; +"language.polish" = "폴란드어"; +"language.portuguese" = "포르투갈어"; +"language.punjabi" = "펀자브어"; +"language.romanian" = "루마니아어"; +"language.russian" = "러시아어"; +"language.sango" = "쌍고어"; +"language.serbian" = "세르비아어"; +"language.shona" = "쇼나어"; +"language.slovak" = "슬로바키아어"; +"language.slovenian" = "슬로베니아어"; +"language.somali" = "소말리아어"; +"language.spanish" = "스페인어"; +"language.swahili" = "스와히리어로"; +"language.swedish" = "스웨덴어"; +"language.tagalog" = "타갈로어"; +"language.thai" = "타이어"; +"language.tigrinya" = "티글리니아어"; +"language.turkish" = "터키어"; +"language.ukrainian" = "우크라이나어"; +"language.urdu" = "우르두어"; +"language.vietnamese" = "베트남어"; +"language.zulu" = "줄루어"; // MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "자동으로 설정"; -"enum.browsing_country.name.afghanistan" = "아프가니스탄"; -"enum.browsing_country.name.aland_islands" = "알란드 제도"; -"enum.browsing_country.name.albania" = "알바니아"; -"enum.browsing_country.name.algeria" = "알제리아"; -"enum.browsing_country.name.american_samoa" = "아메리칸 사모아"; -"enum.browsing_country.name.andorra" = "안도라"; -"enum.browsing_country.name.angola" = "앙골라"; -"enum.browsing_country.name.anguilla" = "안젤라"; -"enum.browsing_country.name.antarctica" = "남극"; -"enum.browsing_country.name.antigua_and_barbuda" = "앤티가 바부다"; -"enum.browsing_country.name.argentina" = "아르헨티나"; -"enum.browsing_country.name.armenia" = "아르메니아"; -"enum.browsing_country.name.aruba" = "아루바 섬"; -"enum.browsing_country.name.asia_pacific_region" = "아시아 태평양 영역"; -"enum.browsing_country.name.australia" = "호주"; -"enum.browsing_country.name.austria" = "오스트리아"; -"enum.browsing_country.name.azerbaijan" = "아제르바이잔"; -"enum.browsing_country.name.bahamas" = "바하마스"; -"enum.browsing_country.name.bahrain" = "바레인"; -"enum.browsing_country.name.bangladesh" = "방글라데시"; -"enum.browsing_country.name.barbados" = "바베이도스"; -"enum.browsing_country.name.belarus" = "벨라루스"; -"enum.browsing_country.name.belgium" = "벨기에"; -"enum.browsing_country.name.belize" = "벨리즈"; -"enum.browsing_country.name.benin" = "베냉"; -"enum.browsing_country.name.bermuda" = "버뮤다"; -"enum.browsing_country.name.bhutan" = "부탄"; -"enum.browsing_country.name.bolivia" = "볼리비아"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "보네르 성 유스타티우스와 사바"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "보스니아 헤르체코비나 "; -"enum.browsing_country.name.botswana" = "보츠와나"; -"enum.browsing_country.name.bouvet_island" = "부베섬"; -"enum.browsing_country.name.brazil" = "브라질"; -"enum.browsing_country.name.british_indian_ocean_territory" = "영국령 인도양 식민지"; -"enum.browsing_country.name.brunei_darussalam" = "브루나이 다루살람"; -"enum.browsing_country.name.bulgaria" = "불가리아"; -"enum.browsing_country.name.burkina_faso" = "부르키나 파소"; -"enum.browsing_country.name.burundi" = "부룬디"; -"enum.browsing_country.name.cambodia" = "캄보디아"; -"enum.browsing_country.name.cameroon" = "카메룬"; -"enum.browsing_country.name.canada" = "캐나다"; -"enum.browsing_country.name.cape_verde" = "포르투갈어"; -"enum.browsing_country.name.cayman_islands" = "케이맨 제도"; -"enum.browsing_country.name.central_african_republic" = "중앙아프리카 공화국"; -"enum.browsing_country.name.chad" = "차드"; -"enum.browsing_country.name.chile" = "칠레"; -"enum.browsing_country.name.china" = "중국"; -"enum.browsing_country.name.christmas_island" = "크리스마스 섬"; -"enum.browsing_country.name.cocos_islands" = "코코스 제도"; -"enum.browsing_country.name.colombia" = "콜롬비아"; -"enum.browsing_country.name.comoros" = "코모로"; -"enum.browsing_country.name.congo" = "콩고"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "콩고민주공화국"; -"enum.browsing_country.name.cook_islands" = "쿡제도"; -"enum.browsing_country.name.costa_rica" = "코스타리카"; -"enum.browsing_country.name.cote_d_ivoire" = "코트디부아르"; -"enum.browsing_country.name.croatia" = "크로아티아"; -"enum.browsing_country.name.cuba" = "쿠바"; -"enum.browsing_country.name.curacao" = "큐라소"; -"enum.browsing_country.name.cyprus" = "키프로스"; -"enum.browsing_country.name.czech_republic" = "체코 공화국"; -"enum.browsing_country.name.denmark" = "덴마크"; -"enum.browsing_country.name.djibouti" = "지부티"; -"enum.browsing_country.name.dominica" = "도미니카"; -"enum.browsing_country.name.dominican_republic" = "도미니카 공화국"; -"enum.browsing_country.name.ecuador" = "에콰도르"; -"enum.browsing_country.name.egypt" = "이집트"; -"enum.browsing_country.name.el_salvador" = "엘살바도르"; -"enum.browsing_country.name.equatorial_guinea" = "적도 기니"; -"enum.browsing_country.name.eritrea" = "에리트레아"; -"enum.browsing_country.name.estonia" = "에스토니아"; -"enum.browsing_country.name.ethiopia" = "에티오피아"; -"enum.browsing_country.name.europe" = "유럽"; -"enum.browsing_country.name.falkland_islands" = "포클랜드 제도"; -"enum.browsing_country.name.faroe_islands" = "페로스 제도"; -"enum.browsing_country.name.fiji" = "피지"; -"enum.browsing_country.name.finland" = "핀란드"; -"enum.browsing_country.name.france" = "프랑스"; -"enum.browsing_country.name.french_guiana" = "프랑스령 기아나"; -"enum.browsing_country.name.french_polynesia" = "프랑스령 폴리네시아"; -"enum.browsing_country.name.french_southern_territories" = "프랑스령 남부와 남극지역"; -"enum.browsing_country.name.gabon" = "가봉"; -"enum.browsing_country.name.gambia" = "감비아"; -"enum.browsing_country.name.georgia" = "그루지야"; -"enum.browsing_country.name.germany" = "독일"; -"enum.browsing_country.name.ghana" = "가나"; -"enum.browsing_country.name.gibraltar" = "지브롤터"; -"enum.browsing_country.name.greece" = "희랍"; -"enum.browsing_country.name.greenland" = "그린란드"; -"enum.browsing_country.name.grenada" = "그레나다"; -"enum.browsing_country.name.guadeloupe" = "과들루프 섬"; -"enum.browsing_country.name.guam" = "괌"; -"enum.browsing_country.name.guatemala" = "과테말라"; -"enum.browsing_country.name.guernsey" = "건지종 젖소"; -"enum.browsing_country.name.guinea" = "기니"; -"enum.browsing_country.name.guinea_bissau" = "기니비사우"; -"enum.browsing_country.name.guyana" = "가이아나"; -"enum.browsing_country.name.haiti" = "아이티"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "허드 맥도널드 제도"; -"enum.browsing_country.name.vatican_city_state" = "바티칸 시국"; -"enum.browsing_country.name.honduras" = "온두라스"; -"enum.browsing_country.name.hong_kong" = "홍콩"; -"enum.browsing_country.name.hungary" = "헝가리"; -"enum.browsing_country.name.iceland" = "Iceland"; -"enum.browsing_country.name.india" = "인도"; -"enum.browsing_country.name.indonesia" = "인도네시아"; -"enum.browsing_country.name.iran" = "이란"; -"enum.browsing_country.name.iraq" = "이라크"; -"enum.browsing_country.name.ireland" = "아일랜드"; -"enum.browsing_country.name.isle_of_man" = "맨 섬"; -"enum.browsing_country.name.israel" = "이스라엘"; -"enum.browsing_country.name.italy" = "이탈리아"; -"enum.browsing_country.name.jamaica" = "자마이카"; -"enum.browsing_country.name.japan" = "일본"; -"enum.browsing_country.name.jersey" = "저시"; -"enum.browsing_country.name.jordan" = "요단"; -"enum.browsing_country.name.kazakhstan" = "카자흐스탄"; -"enum.browsing_country.name.kenya" = "케냐"; -"enum.browsing_country.name.kiribati" = "키리바시"; -"enum.browsing_country.name.kuwait" = "쿠웨이트"; -"enum.browsing_country.name.kyrgyzstan" = "키르기스스탄"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "라오 인민민주공화국"; -"enum.browsing_country.name.latvia" = "라트비아"; -"enum.browsing_country.name.lebanon" = "레바논"; -"enum.browsing_country.name.lesotho" = "레소토"; -"enum.browsing_country.name.liberia" = "리베리아"; -"enum.browsing_country.name.libya" = "리비아"; -"enum.browsing_country.name.liechtenstein" = "리히텐슈타인"; -"enum.browsing_country.name.lithuania" = "리투아니아"; -"enum.browsing_country.name.luxembourg" = "룩셈부르크"; -"enum.browsing_country.name.macau" = "마카오"; -"enum.browsing_country.name.macedonia" = "마케도니아"; -"enum.browsing_country.name.madagascar" = "마다스카르"; -"enum.browsing_country.name.malawi" = "말라위"; -"enum.browsing_country.name.malaysia" = "말레이시아"; -"enum.browsing_country.name.maldives" = "말디브"; -"enum.browsing_country.name.mali" = "말리"; -"enum.browsing_country.name.malta" = "말타"; -"enum.browsing_country.name.marshall_islands" = "마샬군도"; -"enum.browsing_country.name.martinique" = "마르티니크"; -"enum.browsing_country.name.mauritania" = "모리타니아"; -"enum.browsing_country.name.mauritius" = "모리셔스"; -"enum.browsing_country.name.mayotte" = "마요트 섬"; -"enum.browsing_country.name.mexico" = "맥시코"; -"enum.browsing_country.name.micronesia" = "마크로네시아"; -"enum.browsing_country.name.moldova" = "몰도바"; -"enum.browsing_country.name.monaco" = "모나코"; -"enum.browsing_country.name.mongolia" = "몽콜"; -"enum.browsing_country.name.montenegro" = "몬테네그로"; -"enum.browsing_country.name.montserrat" = "몬트세라트섬"; -"enum.browsing_country.name.morocco" = "모로코가족"; -"enum.browsing_country.name.mozambique" = "모잠비크"; -"enum.browsing_country.name.myanmar" = "미얀마"; -"enum.browsing_country.name.namibia" = "나미비아"; -"enum.browsing_country.name.nauru" = "나우루"; -"enum.browsing_country.name.nepal" = "네팔"; -"enum.browsing_country.name.netherlands" = "네덜란드"; -"enum.browsing_country.name.new_caledonia" = "뉴칼레도니아"; -"enum.browsing_country.name.new_zealand" = "뉴질랜드"; -"enum.browsing_country.name.nicaragua" = "나카라과"; -"enum.browsing_country.name.niger" = "니제르"; -"enum.browsing_country.name.nigeria" = "나이지리아"; -"enum.browsing_country.name.niue" = "니우에 섬"; -"enum.browsing_country.name.norfolk_island" = "노퍽섬"; -"enum.browsing_country.name.north_korea" = "북한"; -"enum.browsing_country.name.northern_mariana_islands" = "북마리아나제도"; -"enum.browsing_country.name.norway" = "노르웨이"; -"enum.browsing_country.name.oman" = "오만"; -"enum.browsing_country.name.pakistan" = "파키스탄"; -"enum.browsing_country.name.palau" = "팔라우"; -"enum.browsing_country.name.palestinian_territory" = "팔레스타인의 지역"; -"enum.browsing_country.name.panama" = "파나마모자"; -"enum.browsing_country.name.papua_new_guinea" = "파푸아뉴기니"; -"enum.browsing_country.name.paraguay" = "파라과이"; -"enum.browsing_country.name.peru" = "페루"; -"enum.browsing_country.name.philippines" = "필리핀"; -"enum.browsing_country.name.pitcairn_islands" = "핏케언 제도"; -"enum.browsing_country.name.poland" = "폴란드"; -"enum.browsing_country.name.portugal" = "포르투갈"; -"enum.browsing_country.name.puerto_rico" = "푸에르토리코"; -"enum.browsing_country.name.qatar" = "카타로"; -"enum.browsing_country.name.reunion" = "레워니옹"; -"enum.browsing_country.name.romania" = "루마니아"; -"enum.browsing_country.name.russian_federation" = "러시아 연방"; -"enum.browsing_country.name.rwanda" = "르완다"; -"enum.browsing_country.name.saint_barthelemy" = "생바르텔레미"; -"enum.browsing_country.name.saint_helena" = "세인츠헬레나 섬"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "세인트키츠네비스"; -"enum.browsing_country.name.saint_lucia" = "세인트루시아"; -"enum.browsing_country.name.saint_martin" = "세인트 마틴"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "생피에르 미글롱"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "세인트빈센트 그레나딘"; -"enum.browsing_country.name.samoa" = "사모아"; -"enum.browsing_country.name.san_marino" = "산마리노"; -"enum.browsing_country.name.sao_tome_and_principe" = "상투메 프린시페 도브라"; -"enum.browsing_country.name.saudi_arabia" = "사우디 아라비아"; -"enum.browsing_country.name.senegal" = "세네갈"; -"enum.browsing_country.name.serbia" = "세르비아"; -"enum.browsing_country.name.seychelles" = "세이셸"; -"enum.browsing_country.name.sierra_leone" = "시에라리온"; -"enum.browsing_country.name.singapore" = "싱가포르"; -"enum.browsing_country.name.sint_maarten" = "신트마르턴"; -"enum.browsing_country.name.slovakia" = "슬로바키아"; -"enum.browsing_country.name.slovenia" = "슬로베니아"; -"enum.browsing_country.name.solomon_islands" = "솔로몬 제도"; -"enum.browsing_country.name.somalia" = "소말리아"; -"enum.browsing_country.name.south_africa" = "남아프리카"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "사우스조지아 사우스샌드위치 제도"; -"enum.browsing_country.name.south_korea" = "한국"; -"enum.browsing_country.name.south_sudan" = "남수단"; -"enum.browsing_country.name.spain" = "스페인"; -"enum.browsing_country.name.sri_lanka" = "스리랑카"; -"enum.browsing_country.name.sudan" = "수단"; -"enum.browsing_country.name.suriname" = "수리남"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "스발바르 얀마옌 제도"; -"enum.browsing_country.name.swaziland" = "스와질란드"; -"enum.browsing_country.name.sweden" = "스웨덴"; -"enum.browsing_country.name.switzerland" = "스위스"; -"enum.browsing_country.name.syrian_arab_republic" = "시리아"; -"enum.browsing_country.name.taiwan" = "대만"; -"enum.browsing_country.name.tajikistan" = "타지키스탄"; -"enum.browsing_country.name.tanzania" = "탄지니아"; -"enum.browsing_country.name.thailand" = "태국"; -"enum.browsing_country.name.timor_leste" = "동티모르"; -"enum.browsing_country.name.togo" = "토고"; -"enum.browsing_country.name.tokelau" = "토켈라우"; -"enum.browsing_country.name.tonga" = "통가"; -"enum.browsing_country.name.trinidad_and_tobago" = "트리니다드토바고"; -"enum.browsing_country.name.tunisia" = "튀니지"; -"enum.browsing_country.name.turkey" = "터키"; -"enum.browsing_country.name.turkmenistan" = "투르크메니스탄"; -"enum.browsing_country.name.turks_and_caicos_islands" = "터크스카이코스 제도"; -"enum.browsing_country.name.tuvalu" = "투발루"; -"enum.browsing_country.name.uganda" = "우간다"; -"enum.browsing_country.name.ukraine" = "우크라이나"; -"enum.browsing_country.name.united_arab_emirates" = "아랍 에미리트 연합국"; -"enum.browsing_country.name.united_kingdom" = "영국"; -"enum.browsing_country.name.united_states" = "미국"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "미국령 군소 제도"; -"enum.browsing_country.name.uruguay" = "우루과이"; -"enum.browsing_country.name.uzbekistan" = "우즈베키스탄"; -"enum.browsing_country.name.vanuatu" = "바누어투"; -"enum.browsing_country.name.venezuela" = "베네수엘라"; -"enum.browsing_country.name.vietnam" = "베트남"; -"enum.browsing_country.name.virgin_islands_british" = "영국령 버진 제도"; -"enum.browsing_country.name.virgin_islands_US" = "세인트존 섬"; -"enum.browsing_country.name.wallis_and_futuna" = "월리스 푸투나제도"; -"enum.browsing_country.name.western_sahara" = "서사하라"; -"enum.browsing_country.name.yemen" = "예멘"; -"enum.browsing_country.name.zambia" = "잠비아"; -"enum.browsing_country.name.zimbabwe" = "짐바브웨"; +"browsing_country.auto_detect" = "자동으로 설정"; +"browsing_country.afghanistan" = "아프가니스탄"; +"browsing_country.aland_islands" = "알란드 제도"; +"browsing_country.albania" = "알바니아"; +"browsing_country.algeria" = "알제리아"; +"browsing_country.american_samoa" = "아메리칸 사모아"; +"browsing_country.andorra" = "안도라"; +"browsing_country.angola" = "앙골라"; +"browsing_country.anguilla" = "안젤라"; +"browsing_country.antarctica" = "남극"; +"browsing_country.antigua_and_barbuda" = "앤티가 바부다"; +"browsing_country.argentina" = "아르헨티나"; +"browsing_country.armenia" = "아르메니아"; +"browsing_country.aruba" = "아루바 섬"; +"browsing_country.asia_pacific_region" = "아시아 태평양 영역"; +"browsing_country.australia" = "호주"; +"browsing_country.austria" = "오스트리아"; +"browsing_country.azerbaijan" = "아제르바이잔"; +"browsing_country.bahamas" = "바하마스"; +"browsing_country.bahrain" = "바레인"; +"browsing_country.bangladesh" = "방글라데시"; +"browsing_country.barbados" = "바베이도스"; +"browsing_country.belarus" = "벨라루스"; +"browsing_country.belgium" = "벨기에"; +"browsing_country.belize" = "벨리즈"; +"browsing_country.benin" = "베냉"; +"browsing_country.bermuda" = "버뮤다"; +"browsing_country.bhutan" = "부탄"; +"browsing_country.bolivia" = "볼리비아"; +"browsing_country.bonaire_saint_eustatius_and_saba" = "보네르 성 유스타티우스와 사바"; +"browsing_country.bosnia_and_herzegovina" = "보스니아 헤르체코비나 "; +"browsing_country.botswana" = "보츠와나"; +"browsing_country.bouvet_island" = "부베섬"; +"browsing_country.brazil" = "브라질"; +"browsing_country.british_indian_ocean_territory" = "영국령 인도양 식민지"; +"browsing_country.brunei_darussalam" = "브루나이 다루살람"; +"browsing_country.bulgaria" = "불가리아"; +"browsing_country.burkina_faso" = "부르키나 파소"; +"browsing_country.burundi" = "부룬디"; +"browsing_country.cambodia" = "캄보디아"; +"browsing_country.cameroon" = "카메룬"; +"browsing_country.canada" = "캐나다"; +"browsing_country.cape_verde" = "포르투갈어"; +"browsing_country.cayman_islands" = "케이맨 제도"; +"browsing_country.central_african_republic" = "중앙아프리카 공화국"; +"browsing_country.chad" = "차드"; +"browsing_country.chile" = "칠레"; +"browsing_country.china" = "중국"; +"browsing_country.christmas_island" = "크리스마스 섬"; +"browsing_country.cocos_islands" = "코코스 제도"; +"browsing_country.colombia" = "콜롬비아"; +"browsing_country.comoros" = "코모로"; +"browsing_country.congo" = "콩고"; +"browsing_country.the_democratic_republic_of_the_congo" = "콩고민주공화국"; +"browsing_country.cook_islands" = "쿡제도"; +"browsing_country.costa_rica" = "코스타리카"; +"browsing_country.cote_d_ivoire" = "코트디부아르"; +"browsing_country.croatia" = "크로아티아"; +"browsing_country.cuba" = "쿠바"; +"browsing_country.curacao" = "큐라소"; +"browsing_country.cyprus" = "키프로스"; +"browsing_country.czech_republic" = "체코 공화국"; +"browsing_country.denmark" = "덴마크"; +"browsing_country.djibouti" = "지부티"; +"browsing_country.dominica" = "도미니카"; +"browsing_country.dominican_republic" = "도미니카 공화국"; +"browsing_country.ecuador" = "에콰도르"; +"browsing_country.egypt" = "이집트"; +"browsing_country.el_salvador" = "엘살바도르"; +"browsing_country.equatorial_guinea" = "적도 기니"; +"browsing_country.eritrea" = "에리트레아"; +"browsing_country.estonia" = "에스토니아"; +"browsing_country.ethiopia" = "에티오피아"; +"browsing_country.europe" = "유럽"; +"browsing_country.falkland_islands" = "포클랜드 제도"; +"browsing_country.faroe_islands" = "페로스 제도"; +"browsing_country.fiji" = "피지"; +"browsing_country.finland" = "핀란드"; +"browsing_country.france" = "프랑스"; +"browsing_country.french_guiana" = "프랑스령 기아나"; +"browsing_country.french_polynesia" = "프랑스령 폴리네시아"; +"browsing_country.french_southern_territories" = "프랑스령 남부와 남극지역"; +"browsing_country.gabon" = "가봉"; +"browsing_country.gambia" = "감비아"; +"browsing_country.georgia" = "그루지야"; +"browsing_country.germany" = "독일"; +"browsing_country.ghana" = "가나"; +"browsing_country.gibraltar" = "지브롤터"; +"browsing_country.greece" = "희랍"; +"browsing_country.greenland" = "그린란드"; +"browsing_country.grenada" = "그레나다"; +"browsing_country.guadeloupe" = "과들루프 섬"; +"browsing_country.guam" = "괌"; +"browsing_country.guatemala" = "과테말라"; +"browsing_country.guernsey" = "건지종 젖소"; +"browsing_country.guinea" = "기니"; +"browsing_country.guinea_bissau" = "기니비사우"; +"browsing_country.guyana" = "가이아나"; +"browsing_country.haiti" = "아이티"; +"browsing_country.heard_island_and_mc_donald_islands" = "허드 맥도널드 제도"; +"browsing_country.vatican_city_state" = "바티칸 시국"; +"browsing_country.honduras" = "온두라스"; +"browsing_country.hong_kong" = "홍콩"; +"browsing_country.hungary" = "헝가리"; +"browsing_country.iceland" = "Iceland"; +"browsing_country.india" = "인도"; +"browsing_country.indonesia" = "인도네시아"; +"browsing_country.iran" = "이란"; +"browsing_country.iraq" = "이라크"; +"browsing_country.ireland" = "아일랜드"; +"browsing_country.isle_of_man" = "맨 섬"; +"browsing_country.israel" = "이스라엘"; +"browsing_country.italy" = "이탈리아"; +"browsing_country.jamaica" = "자마이카"; +"browsing_country.japan" = "일본"; +"browsing_country.jersey" = "저시"; +"browsing_country.jordan" = "요단"; +"browsing_country.kazakhstan" = "카자흐스탄"; +"browsing_country.kenya" = "케냐"; +"browsing_country.kiribati" = "키리바시"; +"browsing_country.kuwait" = "쿠웨이트"; +"browsing_country.kyrgyzstan" = "키르기스스탄"; +"browsing_country.lao_peoples_democratic_republic" = "라오 인민민주공화국"; +"browsing_country.latvia" = "라트비아"; +"browsing_country.lebanon" = "레바논"; +"browsing_country.lesotho" = "레소토"; +"browsing_country.liberia" = "리베리아"; +"browsing_country.libya" = "리비아"; +"browsing_country.liechtenstein" = "리히텐슈타인"; +"browsing_country.lithuania" = "리투아니아"; +"browsing_country.luxembourg" = "룩셈부르크"; +"browsing_country.macau" = "마카오"; +"browsing_country.macedonia" = "마케도니아"; +"browsing_country.madagascar" = "마다스카르"; +"browsing_country.malawi" = "말라위"; +"browsing_country.malaysia" = "말레이시아"; +"browsing_country.maldives" = "말디브"; +"browsing_country.mali" = "말리"; +"browsing_country.malta" = "말타"; +"browsing_country.marshall_islands" = "마샬군도"; +"browsing_country.martinique" = "마르티니크"; +"browsing_country.mauritania" = "모리타니아"; +"browsing_country.mauritius" = "모리셔스"; +"browsing_country.mayotte" = "마요트 섬"; +"browsing_country.mexico" = "맥시코"; +"browsing_country.micronesia" = "마크로네시아"; +"browsing_country.moldova" = "몰도바"; +"browsing_country.monaco" = "모나코"; +"browsing_country.mongolia" = "몽콜"; +"browsing_country.montenegro" = "몬테네그로"; +"browsing_country.montserrat" = "몬트세라트섬"; +"browsing_country.morocco" = "모로코가족"; +"browsing_country.mozambique" = "모잠비크"; +"browsing_country.myanmar" = "미얀마"; +"browsing_country.namibia" = "나미비아"; +"browsing_country.nauru" = "나우루"; +"browsing_country.nepal" = "네팔"; +"browsing_country.netherlands" = "네덜란드"; +"browsing_country.new_caledonia" = "뉴칼레도니아"; +"browsing_country.new_zealand" = "뉴질랜드"; +"browsing_country.nicaragua" = "나카라과"; +"browsing_country.niger" = "니제르"; +"browsing_country.nigeria" = "나이지리아"; +"browsing_country.niue" = "니우에 섬"; +"browsing_country.norfolk_island" = "노퍽섬"; +"browsing_country.north_korea" = "북한"; +"browsing_country.northern_mariana_islands" = "북마리아나제도"; +"browsing_country.norway" = "노르웨이"; +"browsing_country.oman" = "오만"; +"browsing_country.pakistan" = "파키스탄"; +"browsing_country.palau" = "팔라우"; +"browsing_country.palestinian_territory" = "팔레스타인의 지역"; +"browsing_country.panama" = "파나마모자"; +"browsing_country.papua_new_guinea" = "파푸아뉴기니"; +"browsing_country.paraguay" = "파라과이"; +"browsing_country.peru" = "페루"; +"browsing_country.philippines" = "필리핀"; +"browsing_country.pitcairn_islands" = "핏케언 제도"; +"browsing_country.poland" = "폴란드"; +"browsing_country.portugal" = "포르투갈"; +"browsing_country.puerto_rico" = "푸에르토리코"; +"browsing_country.qatar" = "카타로"; +"browsing_country.reunion" = "레워니옹"; +"browsing_country.romania" = "루마니아"; +"browsing_country.russian_federation" = "러시아 연방"; +"browsing_country.rwanda" = "르완다"; +"browsing_country.saint_barthelemy" = "생바르텔레미"; +"browsing_country.saint_helena" = "세인츠헬레나 섬"; +"browsing_country.saint_kitts_and_nevis" = "세인트키츠네비스"; +"browsing_country.saint_lucia" = "세인트루시아"; +"browsing_country.saint_martin" = "세인트 마틴"; +"browsing_country.saint_pierre_and_miquelon" = "생피에르 미글롱"; +"browsing_country.saint_vincent_and_the_grenadines" = "세인트빈센트 그레나딘"; +"browsing_country.samoa" = "사모아"; +"browsing_country.san_marino" = "산마리노"; +"browsing_country.sao_tome_and_principe" = "상투메 프린시페 도브라"; +"browsing_country.saudi_arabia" = "사우디 아라비아"; +"browsing_country.senegal" = "세네갈"; +"browsing_country.serbia" = "세르비아"; +"browsing_country.seychelles" = "세이셸"; +"browsing_country.sierra_leone" = "시에라리온"; +"browsing_country.singapore" = "싱가포르"; +"browsing_country.sint_maarten" = "신트마르턴"; +"browsing_country.slovakia" = "슬로바키아"; +"browsing_country.slovenia" = "슬로베니아"; +"browsing_country.solomon_islands" = "솔로몬 제도"; +"browsing_country.somalia" = "소말리아"; +"browsing_country.south_africa" = "남아프리카"; +"browsing_country.south_georgia_and_the_south_sandwich_islands" = "사우스조지아 사우스샌드위치 제도"; +"browsing_country.south_korea" = "한국"; +"browsing_country.south_sudan" = "남수단"; +"browsing_country.spain" = "스페인"; +"browsing_country.sri_lanka" = "스리랑카"; +"browsing_country.sudan" = "수단"; +"browsing_country.suriname" = "수리남"; +"browsing_country.svalbard_and_jan_mayen" = "스발바르 얀마옌 제도"; +"browsing_country.swaziland" = "스와질란드"; +"browsing_country.sweden" = "스웨덴"; +"browsing_country.switzerland" = "스위스"; +"browsing_country.syrian_arab_republic" = "시리아"; +"browsing_country.taiwan" = "대만"; +"browsing_country.tajikistan" = "타지키스탄"; +"browsing_country.tanzania" = "탄지니아"; +"browsing_country.thailand" = "태국"; +"browsing_country.timor_leste" = "동티모르"; +"browsing_country.togo" = "토고"; +"browsing_country.tokelau" = "토켈라우"; +"browsing_country.tonga" = "통가"; +"browsing_country.trinidad_and_tobago" = "트리니다드토바고"; +"browsing_country.tunisia" = "튀니지"; +"browsing_country.turkey" = "터키"; +"browsing_country.turkmenistan" = "투르크메니스탄"; +"browsing_country.turks_and_caicos_islands" = "터크스카이코스 제도"; +"browsing_country.tuvalu" = "투발루"; +"browsing_country.uganda" = "우간다"; +"browsing_country.ukraine" = "우크라이나"; +"browsing_country.united_arab_emirates" = "아랍 에미리트 연합국"; +"browsing_country.united_kingdom" = "영국"; +"browsing_country.united_states" = "미국"; +"browsing_country.united_states_minor_outlying_islands" = "미국령 군소 제도"; +"browsing_country.uruguay" = "우루과이"; +"browsing_country.uzbekistan" = "우즈베키스탄"; +"browsing_country.vanuatu" = "바누어투"; +"browsing_country.venezuela" = "베네수엘라"; +"browsing_country.vietnam" = "베트남"; +"browsing_country.virgin_islands_british" = "영국령 버진 제도"; +"browsing_country.virgin_islands_US" = "세인트존 섬"; +"browsing_country.wallis_and_futuna" = "월리스 푸투나제도"; +"browsing_country.western_sahara" = "서사하라"; +"browsing_country.yemen" = "예멘"; +"browsing_country.zambia" = "잠비아"; +"browsing_country.zimbabwe" = "짐바브웨"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index a8fc25a52..c233e860b 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -1,235 +1,232 @@ // MARK: BanInterval -"enum.ban_interval.description.and" = ""; +"ban_interval.and" = ""; // MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "昨日"; -"enum.toplists_type.value.past_month" = "上月"; -"enum.toplists_type.value.past_year" = "去年"; -"enum.toplists_type.value.all_time" = "全部"; +"toplists_type.yesterday" = "昨日"; +"toplists_type.past_month" = "上月"; +"toplists_type.past_year" = "去年"; +"toplists_type.all_time" = "全部"; // MARK: Response -"website.response.hath_client_not_found" = "你需要一个关联到账户的 H@H 客户端才能使用这个功能"; -"website.response.hath_client_not_online" = "你的 H@H 客户端似乎处于离线状态,请启动它后再试"; -"website.response.invalid_resolution" = "该画廊不能以选中的分辨率下载"; +"hath_client_not_found" = "你需要一个关联到账户的 H@H 客户端才能使用这个功能"; +"hath_client_not_online" = "你的 H@H 客户端似乎处于离线状态,请启动它后再试"; +"invalid_resolution" = "该画廊不能以选中的分辨率下载"; // MARK: Toast -"toast.title.error" = "错误"; -"toast.title.success" = "成功"; -"toast.title.loading" = "加载中..."; -"toast.title.communicating" = "通信中..."; -"toast.caption.copied_to_clipboard" = "已复制到剪切板"; -"toast.caption.saved_to_photo_library" = "已保存到图库"; +"toast.error" = "错误"; +"toast.success" = "成功"; +"toast.loading" = "加载中..."; +"toast.communicating" = "通信中..."; +"toast.copied_to_clipboard" = "已复制到剪切板"; +"toast.saved_to_photo_library" = "已保存到图库"; // MARK: AutoLock "local_authorization.reason" = "因超过设置的自动锁定期限,App 已被锁定"; // MARK: Common value -"common.value.stars" = "%@ 星"; -"common.value.pages" = "%@ 页"; -"common.value.times" = "%@ 次"; -"common.value.day" = "%@ 天"; -"common.value.days" = "%@ 天"; -"common.value.hour" = "%@ 小时"; -"common.value.hours" = "%@ 小时"; -"common.value.minute" = "%@ 分"; -"common.value.minutes" = "%@ 分"; -"common.value.second" = "%@ 秒"; -"common.value.seconds" = "%@ 秒"; -"common.value.records" = "%@ 条记录"; +"common.stars" = "%@ 星"; +"common.pages" = "%@ 页"; +"common.day" = "%@ 天"; +"common.days" = "%@ 天"; +"common.hour" = "%@ 小时"; +"common.hours" = "%@ 小时"; +"common.minute" = "%@ 分"; +"common.minutes" = "%@ 分"; +"common.second" = "%@ 秒"; +"common.seconds" = "%@ 秒"; // MARK: Common button -"common.button.cancel" = "取消"; +"common.cancel" = "取消"; // MARK: TabItem -"tab_item.title.home" = "主页"; -"tab_item.title.favorites" = "收藏"; -"tab_item.title.search" = "搜索"; -"tab_item.title.downloads" = "下载"; -"tab_item.title.setting" = "设置"; +"tab_item.home" = "主页"; +"tab_item.favorites" = "收藏"; +"tab_item.search" = "搜索"; +"tab_item.downloads" = "下载"; +"tab_item.setting" = "设置"; // MARK: ToolbarItem -"toolbar_item.button.filters" = "筛选"; -"toolbar_item.button.jump_page" = "页码跳转"; -"toolbar_item.button.date_seek" = "日期定位"; -"toolbar_item.button.quick_search" = "快速搜索"; +"toolbar_item.filters" = "筛选"; +"toolbar_item.jump_page" = "页码跳转"; +"toolbar_item.date_seek" = "日期定位"; +"toolbar_item.quick_search" = "快速搜索"; // MARK: DateSeek -"date_seek_view.title.date_seek" = "日期定位"; -"date_seek_view.title.date" = "日期"; -"date_seek_view.footer.seek_around_date" = "前往所选日期附近的画廊。"; -"date_seek_view.button.seek_newer" = "较新"; -"date_seek_view.button.seek_older" = "较旧"; +"date_seek_view.date_seek" = "日期定位"; +"date_seek_view.date" = "日期"; +"date_seek_view.seek_around_date" = "前往所选日期附近的画廊。"; +"date_seek_view.seek_newer" = "较新"; +"date_seek_view.seek_older" = "较旧"; // MARK: JumpPage -"jump_page_view.title.jump_page" = "页码跳转"; -"jump_page_view.description.jump_page" = "请输入 1 到 %d 之间的页码。"; -"jump_page_view.button.confirm" = "确认"; +"jump_page_view.jump_page" = "页码跳转"; +"jump_page_view.jump_page_description" = "请输入 1 到 %d 之间的页码。"; +"jump_page_view.confirm" = "确认"; // MARK: AlertView -"loading_view.title.loading" = "加载中..."; -"loading_view.title.preparing_database" = "正在准备数据库..."; -"not_login_view.title.need_login" = "你需要登录才能使用该功能"; -"not_login_view.button.login" = "登录"; -"error_view.button.retry" = "重试"; -"error_view.button.drop_database" = "丢弃数据库"; -"error_view.title.try_later" = "请稍后再试"; -"error_view.title.network" = "发生了网络故障"; -"error_view.title.parsing" = "发生了解析错误"; -"error_view.title.unknown" = "发生了未知错误"; -"error_view.title.not_found" = "这里似乎什么也没有"; -"error_view.title.database_corrupted" = "数据库已损毁。\n请到 GitHub 提起 Issue 反馈。"; -"error_view.title.ip_banned" = "当前的 IP 地址发生了过量的页面加载,因有使用爬虫程序的嫌疑已被暂时封禁。封禁将在 %@后解除。"; -"error_view.title.copyright_claim" = "抱歉,该画廊因 %@ 的版权主张已无法访问。"; -"error_view.title.gallery_unavailable" = "该画廊已被移除或不可用。"; +"loading_view.loading" = "加载中..."; +"loading_view.preparing_database" = "正在准备数据库..."; +"not_login_view.need_login" = "你需要登录才能使用该功能"; +"not_login_viewlogin" = "登录"; +"error_view.retry" = "重试"; +"error_view.drop_database" = "丢弃数据库"; +"error_view.try_later" = "请稍后再试"; +"error_view.network" = "发生了网络故障"; +"error_view.parsing" = "发生了解析错误"; +"error_view.unknown" = "发生了未知错误"; +"error_view.not_found" = "这里似乎什么也没有"; +"error_view.database_corrupted" = "数据库已损毁。\n请到 GitHub 提起 Issue 反馈。"; +"error_view.ip_banned" = "当前的 IP 地址发生了过量的页面加载,因有使用爬虫程序的嫌疑已被暂时封禁。封禁将在 %@后解除。"; +"error_view.copyright_claim" = "抱歉,该画廊因 %@ 的版权主张已无法访问。"; +"error_view.gallery_unavailable" = "该画廊已被移除或不可用。"; // MARK: AppError -"app_error.localized_description.database_corrupted" = "数据库损坏"; -"app_error.localized_description.copyright_claim" = "版权声明"; -"app_error.localized_description.ip_banned" = "IP 已封禁"; -"app_error.localized_description.gallery_expunged" = "画廊已删除"; -"app_error.localized_description.network_error" = "网络错误"; -"app_error.localized_description.web_image_loading_error" = "网页图片加载错误"; -"app_error.localized_description.parse_error" = "解析错误"; -"app_error.localized_description.quota_exceeded" = "流量额度已用尽"; -"app_error.localized_description.authentication_required" = "需要登录"; -"app_error.localized_description.file_operation_failed" = "文件操作失败"; -"app_error.localized_description.no_updates_available" = "没有可用更新"; -"app_error.localized_description.not_found" = "未找到"; -"app_error.localized_description.unknown_error" = "未知错误"; -"app_error.alert.quota_exceeded" = "图片流量额度已用尽。\n请稍后再试。"; -"app_error.alert.authentication_required" = "访问此下载内容需要登录。"; -"app_error.alert.local_file_operation_failed" = "本地文件操作失败。"; +"app_error.database_corrupted" = "数据库损坏"; +"app_error.copyright_claim" = "版权声明"; +"app_error.ip_banned" = "IP 已封禁"; +"app_error.gallery_expunged" = "画廊已删除"; +"app_error.network_error" = "网络错误"; +"app_error.web_image_loading_error" = "网页图片加载错误"; +"app_error.parse_error" = "解析错误"; +"app_error.quota_exceeded" = "流量额度已用尽"; +"app_error.authentication_required" = "需要登录"; +"app_error.file_operation_failed" = "文件操作失败"; +"app_error.no_updates_available" = "没有可用更新"; +"app_error.not_found" = "未找到"; +"app_error.unknown_error" = "未知错误"; +"app_error.quota_exceeded_description" = "图片流量额度已用尽。\n请稍后再试。"; +"app_error.authentication_required_description" = "访问此下载内容需要登录。"; +"app_error.local_file_operation_failed" = "本地文件操作失败。"; // MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "你将失去这个 App 中所有的数据。\n确定要丢弃数据库吗?"; -"confirmation_dialog.title.remove_custom_translations" = "确定要移除自定义翻译吗?"; -"confirmation_dialog.title.logout" = "确定要退出登录吗?"; -"confirmation_dialog.title.delete" = "确定要删除吗?"; -"confirmation_dialog.title.clear" = "确定要清空吗?"; -"confirmation_dialog.title.reset" = "确定要重置吗?"; -"confirmation_dialog.button.drop_database" = "丢弃数据库"; -"confirmation_dialog.button.remove" = "移除"; -"confirmation_dialog.button.logout" = "退出登录"; -"confirmation_dialog.button.delete" = "删除"; -"confirmation_dialog.button.clear" = "清空"; -"confirmation_dialog.button.reset" = "重置"; +"confirmation_dialog.drop_database_description" = "你将失去这个 App 中所有的数据。\n确定要丢弃数据库吗?"; +"confirmation_dialog.remove_custom_translations" = "确定要移除自定义翻译吗?"; +"confirmation_dialog.logout_description" = "确定要退出登录吗?"; +"confirmation_dialog.delete_description" = "确定要删除吗?"; +"confirmation_dialog.clear_description" = "确定要清空吗?"; +"confirmation_dialog.reset_description" = "确定要重置吗?"; +"confirmation_dialog.drop_database" = "丢弃数据库"; +"confirmation_dialog.remove" = "移除"; +"confirmation_dialog.logout" = "退出登录"; +"confirmation_dialog.delete" = "删除"; +"confirmation_dialog.clear" = "清空"; +"confirmation_dialog.reset" = "重置"; // MARK: SubSection -"sub_section.button.show_all" = "显示全部"; +"sub_section.show_all" = "显示全部"; // MARK: NewDawnView -"new_dawn_view.title.first" = "又是全新的一天!"; -"new_dawn_view.title.second" = "回顾至今的历程,发觉自己更睿智了一些。"; +"new_dawn_view.first" = "又是全新的一天!"; +"new_dawn_view.second" = "回顾至今的历程,发觉自己更睿智了一些。"; // Greeting -"struct.greeting.mark.start" = "你获得了 "; -"struct.greeting.mark.separator" = "、"; -"struct.greeting.mark.and" = " 和 "; -"struct.greeting.mark.end" = "!"; +"greeting.start" = "你获得了 "; +"greeting.separator" = "、"; +"greeting.and" = " 和 "; +"greeting.end" = "!"; // MARK: HomeView -"home_view.title.home" = "主页"; -"home_view.section.title.frontpage" = "扉页"; -"home_view.section.title.toplists" = "排行"; -"home_view.section.title.other" = "其它"; +"home_view.home" = "主页"; +"home_view.frontpage" = "扉页"; +"home_view.toplists" = "排行"; +"home_view.other" = "其它"; // HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "热门"; -"enum.home_misc_grid_type.title.watched" = "标签"; -"enum.home_misc_grid_type.title.history" = "历史"; +"home_misc_grid_type.popular" = "热门"; +"home_misc_grid_type.watched" = "标签"; +"home_misc_grid_type.history" = "历史"; // MARK: FrontpageView -"frontpage_view.title.frontpage" = "扉页"; +"frontpage_view.frontpage" = "扉页"; // MARK: ToplistsView -"toplists_view.title.toplists" = "排行"; +"toplists_view.toplists" = "排行"; // MARK: PopularView -"popular_view.title.popular" = "热门"; +"popular_view.popular" = "热门"; // MARK: WatchedView -"watched_view.title.watched" = "标签"; +"watched_view.watched" = "标签"; // MARK: HistoryView -"history_view.title.history" = "历史"; +"history_view.history" = "历史"; // MARK: FavoritesView -"favorites_view.title.favorites" = "收藏"; +"favorites_view.favorites" = "收藏"; // FavoriteCategory -"struct.user.favorite_category.default" = "收藏夹 %@"; -"struct.user.favorite_category.all" = "全部"; +"favorite_category.default" = "收藏夹 %@"; +"favorite_category.all" = "全部"; // MARK: SearchView -"search_view.title.search" = "搜索"; -"search_view.section.title.recently_searched" = "最近搜索"; -"search_view.section.title.recently_seen" = "最近看过"; -"search_view.section.title.quick_search" = "快速搜索"; +"search_view.search" = "搜索"; +"search_view.recently_searched" = "最近搜索"; +"search_view.recently_seen" = "最近看过"; +"search_view.quick_search" = "快速搜索"; // Searchable -"searchable.prompt.filter" = "筛选"; -"searchable.title.matches_count" = "找到 %d 项结果"; +"searchable.filter" = "筛选"; +"searchable.matches_count" = "找到 %d 项结果"; // MARK: QuickSearchView -"quick_search_view.title.quick_search" = "快速搜索"; -"quick_search_view.title.edit_word" = "编辑关键词"; -"quick_search_view.title.new_word" = "添加关键词"; -"quick_search_view.title.content" = "内容"; -"quick_search_view.title.name" = "名称"; -"quick_search_view.placeholder.optional" = "可选"; +"quick_search_view.quick_search" = "快速搜索"; +"quick_search_view.edit_word" = "编辑关键词"; +"quick_search_view.new_word" = "添加关键词"; +"quick_search_view.content" = "内容"; +"quick_search_view.name" = "名称"; +"quick_search_view.optional" = "可选"; // MARK: SettingView -"setting_view.title.setting" = "设置"; +"setting_view.setting" = "设置"; // SettingStateRoute -"enum.setting_state_route.value.account" = "账户"; -"enum.setting_state_route.value.general" = "一般"; -"enum.setting_state_route.value.appearance" = "外观"; -"enum.setting_state_route.value.reading" = "阅读"; -"enum.setting_state_route.value.download" = "下载"; -"enum.setting_state_route.value.laboratory" = "实验室"; -"enum.setting_state_route.value.about" = "关于"; +"setting_state_route.account" = "账户"; +"setting_state_route.general" = "一般"; +"setting_state_route.appearance" = "外观"; +"setting_state_route.reading" = "阅读"; +"setting_state_route.download" = "下载"; +"setting_state_route.laboratory" = "实验室"; +"setting_state_route.about" = "关于"; // MARK: AccountSettingView -"account_setting_view.title.account" = "账户"; -"account_setting_view.title.shows_new_dawn_greeting" = "显示黎明问候"; -"account_setting_view.button.login" = "登录"; -"account_setting_view.button.logout" = "退出登录"; -"account_setting_view.button.account_configuration" = "账户设置"; -"account_setting_view.button.tags_management" = "管理标签订阅"; -"account_setting_view.button.copy_cookies" = "复制 Cookies"; +"account_setting_view.account" = "账户"; +"account_setting_view.shows_new_dawn_greeting" = "显示黎明问候"; +"account_setting_view.login" = "登录"; +"account_setting_view.account_configuration" = "账户设置"; +"account_setting_view.tags_management" = "管理标签订阅"; +"account_setting_view.copy_cookies" = "复制 Cookies"; // CookieValue -"struct.cookie_value.localized_string.expired" = "已过期"; -"struct.cookie_value.localized_string.mystery" = "被拒绝"; -"struct.cookie_value.localized_string.none" = "无内容"; +"cookie_value.expired" = "已过期"; +"cookie_value.mystery" = "被拒绝"; +"cookie_value.none" = "无内容"; // MARK: LoginView -"login_view.title.login" = "登录"; -"login_view.title.username" = "用户名"; -"login_view.title.password" = "密码"; +"login_view.login" = "登录"; +"login_view.username" = "用户名"; +"login_view.password" = "密码"; // MARK: GeneralSettingView -"general_setting_view.title.general" = "一般"; -"general_setting_view.title.language" = "语言"; -"general_setting_view.title.auto_lock" = "自动锁定"; -"general_setting_view.title.enables_tags_extension" = "启用标签扩展"; -"general_setting_view.title.translates_tags" = "翻译标签"; -"general_setting_view.title.shows_tags_search_suggestion" = "显示标签搜索建议"; -"general_setting_view.title.shows_images_in_tags" = "显示标签中的图像"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "重定向链接到选定的站点"; -"general_setting_view.title.detects_links_from_clipboard" = "从剪切板检测链接"; -"general_setting_view.title.background_blur_radius" = "后台模糊效果"; -"general_setting_view.button.app_activity_logs" = "应用活动日志"; -"general_setting_view.button.import_custom_translations" = "导入自定义翻译"; -"general_setting_view.button.remove_custom_translations" = "移除自定义翻译"; -"general_setting_view.button.clear_image_caches" = "清空图片缓存"; -"general_setting_view.value.default_language_description" = "无效"; -"general_setting_view.section.title.tags" = "标签"; -"general_setting_view.section.title.navigation" = "导航"; -"general_setting_view.section.title.security" = "安全"; -"general_setting_view.section.title.caches" = "缓存"; +"general_setting_view.general" = "一般"; +"general_setting_view.language" = "语言"; +"general_setting_view.auto_lock" = "自动锁定"; +"general_setting_view.enables_tags_extension" = "启用标签扩展"; +"general_setting_view.translates_tags" = "翻译标签"; +"general_setting_view.shows_tags_search_suggestion" = "显示标签搜索建议"; +"general_setting_view.shows_images_in_tags" = "显示标签中的图像"; +"general_setting_view.redirects_links_to_the_selected_host" = "重定向链接到选定的站点"; +"general_setting_view.detects_links_from_clipboard" = "从剪切板检测链接"; +"general_setting_view.background_blur_radius" = "后台模糊效果"; +"general_setting_view.app_activity_logs" = "应用活动日志"; +"general_setting_view.import_custom_translations" = "导入自定义翻译"; +"general_setting_view.remove_custom_translations" = "移除自定义翻译"; +"general_setting_view.clear_image_caches" = "清空图片缓存"; +"general_setting_view.default_language_description" = "无效"; +"general_setting_view.tags" = "标签"; +"general_setting_view.navigation" = "导航"; +"general_setting_view.security" = "安全"; +"general_setting_view.caches" = "缓存"; // AutoLockPolicy -"enum.auto_lock_policy.value.never" = "不锁定"; -"enum.auto_lock_policy.value.instantly" = "立即"; +"auto_lock_policy.never" = "不锁定"; +"auto_lock_policy.instantly" = "立即"; // MARK: AppActivityLogsView "app_activity_logs_view.title" = "应用活动日志"; -"app_activity_logs_view.placeholder.no_logs" = "未找到日志"; -"app_activity_logs_view.section.current" = "当前"; +"app_activity_logs_view.no_logs" = "未找到日志"; +"app_activity_logs_view.current" = "当前"; "app_activity_logs_view.run" = "运行 %@"; "app_activity_logs_view.more_logs" = "更多日志"; "app_activity_logs_view.open_in_files" = "在“文件”中打开"; @@ -242,810 +239,786 @@ "app_activity_logs_view.level.fault" = "故障"; // MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "外观"; -"appearance_setting_view.title.theme" = "主题"; -"appearance_setting_view.title.tint_color" = "主题色"; -"appearance_setting_view.title.display_mode" = "显示样式"; -"appearance_setting_view.title.shows_tags_in_list" = "在列表中显示标签"; -"appearance_setting_view.title.maximum_number_of_tags" = "标签数量上限"; -"appearance_setting_view.title.displays_japanese_title" = "显示日文标题"; -"appearance_setting_view.button.app_icon" = "应用图标"; -"appearance_setting_view.menu.title.infite" = "无限"; -"appearance_setting_view.section.title.list" = "列表"; -"appearance_setting_view.section.title.gallery" = "画廊"; +"appearance_setting_view.appearance" = "外观"; +"appearance_setting_view.theme" = "主题"; +"appearance_setting_view.tint_color" = "主题色"; +"appearance_setting_view.display_mode" = "显示样式"; +"appearance_setting_view.shows_tags_in_list" = "在列表中显示标签"; +"appearance_setting_view.maximum_number_of_tags" = "标签数量上限"; +"appearance_setting_view.displays_japanese_title" = "显示日文标题"; +"appearance_setting_view.app_icon" = "应用图标"; +"appearance_setting_view.infite" = "无限"; +"appearance_setting_view.list" = "列表"; +"appearance_setting_view.gallery" = "画廊"; // PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "自动"; -"enum.preferred_color_scheme.value.light" = "浅色"; -"enum.preferred_color_scheme.value.dark" = "深色"; +"preferred_color_scheme.automatic" = "自动"; +"preferred_color_scheme.light" = "浅色"; +"preferred_color_scheme.dark" = "深色"; // AppIconType -"enum.app_icon_type.value.default" = "默认"; -"enum.app_icon_type.value.ukiyoe" = "浮世绘"; -"enum.app_icon_type.value.developer" = "开发者"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "与乌克兰同在 (2022)"; -"enum.app_icon_type.value.not_my_president" = "他不是我的主席"; +"app_icon_type.default" = "默认"; +"app_icon_type.ukiyoe" = "浮世绘"; +"app_icon_type.developer" = "开发者"; +"app_icon_type.stand_with_ukraine_2022" = "与乌克兰同在 (2022)"; +"app_icon_type.not_my_president" = "他不是我的主席"; // ListDisplayMode -"enum.list_display_mode.value.detail" = "详情"; -"enum.list_display_mode.value.thumbnail" = "缩略图"; +"list_display_mode.detail" = "详情"; +"list_display_mode.thumbnail" = "缩略图"; // MARK: AppIconView -"app_icon_view.title.app_icon" = "应用图标"; +"app_icon_view.app_icon" = "应用图标"; // MARK: reading_settingView -"reading_setting_view.title.reading" = "阅读"; -"reading_setting_view.title.direction" = "方向"; -"reading_setting_view.title.preload_limit" = "预加载数量上限"; -"reading_setting_view.title.enables_landscape" = "启用横屏"; -"reading_setting_view.title.separator_height" = "分隔线高度"; -"reading_setting_view.title.maximum_scale_factor" = "最大缩放系数"; -"reading_setting_view.title.double_tap_scale_factor" = "双击缩放系数"; -"reading_setting_view.section.title.appearance" = "外观"; +"reading_setting_view.reading" = "阅读"; +"reading_setting_view.direction" = "方向"; +"reading_setting_view.preload_limit" = "预加载数量上限"; +"reading_setting_view.enables_landscape" = "启用横屏"; +"reading_setting_view.separator_height" = "分隔线高度"; +"reading_setting_view.maximum_scale_factor" = "最大缩放系数"; +"reading_setting_view.double_tap_scale_factor" = "双击缩放系数"; +"reading_setting_view.appearance" = "外观"; // ReadingDirection -"enum.reading_direction.value.vertical" = "垂直"; -"enum.reading_direction.value.right_to_left" = "右至左"; -"enum.reading_direction.value.left_to_right" = "左至右"; +"reading_direction.vertical" = "垂直"; +"reading_direction.right_to_left" = "右至左"; +"reading_direction.left_to_right" = "左至右"; // MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "实验室"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "域前置绕过 SNI 阻断"; +"laboratory_setting_view.laboratory" = "实验室"; +"laboratory_setting_view.bypasses_SNI_filtering" = "域前置绕过 SNI 阻断"; // MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "网站"; -"about_view.button.altStore_source" = "AltStore 源"; -"about_view.title.version" = "版本"; -"about_view.section.title.special_thanks" = "特别致谢"; -"about_view.section.title.code_level_contributors" = "代码级贡献者"; -"about_view.section.title.translation_contributors" = "翻译贡献者"; -"about_view.section.title.acknowledgements" = "致谢"; +"about_view.ehPanda" = "EhPanda"; +"about_view.website" = "网站"; +"about_view.altStore_source" = "AltStore 源"; +"about_view.version" = "版本"; +"about_view.special_thanks" = "特别致谢"; +"about_view.code_level_contributors" = "代码级贡献者"; +"about_view.translation_contributors" = "翻译贡献者"; +"about_view.acknowledgements" = "致谢"; // MARK: DetailView -"detail_view.button.download_login" = "登录"; -"detail_view.button.download_get" = "获取"; -"detail_view.button.download_wait" = "等待"; -"detail_view.button.download_done" = "完成"; -"detail_view.button.download_update" = "更新"; -"detail_view.button.download_retry" = "重试"; -"detail_view.button.download_repair" = "修复"; -"detail_view.button.read" = "阅读"; -"detail_view.button.post_comment" = "发布评论"; -"detail_view.accessibility.download_button.login" = "登录后即可下载"; -"detail_view.accessibility.download_button.download" = "下载"; -"detail_view.accessibility.download_button.queued" = "已加入下载队列"; -"detail_view.accessibility.download_button.downloading" = "正在下载第 %d / %d 页"; -"detail_view.accessibility.download_button.downloaded" = "删除已下载画廊"; -"detail_view.accessibility.download_button.update" = "更新下载内容"; -"detail_view.accessibility.download_button.retry" = "重新下载"; -"detail_view.accessibility.download_button.repair" = "修复下载文件"; -"detail_view.accessibility.download_button.preparing" = "正在获取下载信息"; -"detail_view.accessibility.download_button.pause_action" = "暂停下载"; -"detail_view.accessibility.download_button.paused" = "继续下载,当前暂停在第 %d / %d 页"; -"detail_view.accessibility.download_button.partial" = "重新下载,已有 %d / %d 页可用。"; -"detail_view.toolbar_item.button.archives" = "归档"; -"detail_view.toolbar_item.button.torrents" = "种子"; -"detail_view.toolbar_item.button.share" = "分享"; -"detail_view.context_menu.button.detail" = "详情"; -"detail_view.context_menu.button.withdraw_vote" = "撤销投票"; -"detail_view.context_menu.button.vote_up" = "投票赞成"; -"detail_view.context_menu.button.vote_down" = "投票反对"; -"detail_view.description_section.title.favorited" = "收藏"; -"detail_view.description_section.title.language" = "语言"; -"detail_view.description_section.title.ratings" = "%@ 个评分"; -"detail_view.description_section.title.page_count" = "页数"; -"detail_view.description_section.title.file_size" = "文件大小"; -"detail_view.description_section.description.favorited" = "次"; -"detail_view.description_section.description.page_count" = "页"; -"detail_view.action_section.button.give_a_rating" = "给予评分"; -"detail_view.action_section.button.similar_gallery" = "相似画廊"; -"detail_view.section.title.previews" = "预览"; -"detail_view.section.title.comments" = "评论"; -"detail_view.dialog.title.delete_download" = "删除下载?"; -"detail_view.dialog.title.repair_download" = "修复下载?"; -"detail_view.dialog.title.update_download" = "更新下载?"; -"detail_view.dialog.title.redownload_gallery" = "重新下载画廊?"; -"detail_view.dialog.message.delete_downloaded_gallery" = "这将从此设备移除已下载的画廊。"; -"detail_view.dialog.message.repair_download" = "现在修复此画廊的离线文件吗?"; -"detail_view.dialog.message.update_download" = "现在将此画廊更新到线上最新版本吗?"; -"detail_view.dialog.message.redownload_gallery" = "现在重新完整下载此画廊吗?"; -"detail_view.dialog.button.repair" = "修复"; -"detail_view.dialog.button.update" = "更新"; -"detail_view.dialog.button.redownload" = "重新下载"; -"detail_view.offline_notice.saved_details" = "无法刷新在线详情,现显示已保存的详情。"; +"detail_view.read" = "阅读"; +"detail_view.post_comment" = "发布评论"; +"detail_view.accessibility.login" = "登录后即可下载"; +"detail_view.accessibility.download" = "下载"; +"detail_view.accessibility.queued" = "已加入下载队列"; +"detail_view.accessibility.downloading" = "正在下载第 %d / %d 页"; +"detail_view.accessibility.downloaded" = "删除已下载画廊"; +"detail_view.accessibility.update" = "更新下载内容"; +"detail_view.accessibility.retry" = "重新下载"; +"detail_view.accessibility.repair" = "修复下载文件"; +"detail_view.accessibility.preparing" = "正在获取下载信息"; +"detail_view.accessibility.pause_action" = "暂停下载"; +"detail_view.accessibility.paused" = "继续下载,当前暂停在第 %d / %d 页"; +"detail_view.accessibility.partial" = "重新下载,已有 %d / %d 页可用。"; +"detail_view.archives" = "归档"; +"detail_view.torrents" = "种子"; +"detail_view.share" = "分享"; +"detail_view.detail" = "详情"; +"detail_view.withdraw_vote" = "撤销投票"; +"detail_view.vote_up" = "投票赞成"; +"detail_view.vote_down" = "投票反对"; +"detail_view.favorited" = "收藏"; +"detail_view.language" = "语言"; +"detail_view.ratings" = "%@ 个评分"; +"detail_view.page_count" = "页数"; +"detail_view.file_size" = "文件大小"; +"detail_view.favorited_unit" = "次"; +"detail_view.page_count_unit" = "页"; +"detail_view.give_a_rating" = "给予评分"; +"detail_view.similar_gallery" = "相似画廊"; +"detail_view.previews" = "预览"; +"detail_view.comments" = "评论"; +"detail_view.delete_download" = "删除下载?"; +"detail_view.repair_download" = "修复下载?"; +"detail_view.update_download" = "更新下载?"; +"detail_view.redownload_gallery" = "重新下载画廊?"; +"detail_view.delete_downloaded_gallery" = "这将从此设备移除已下载的画廊。"; +"detail_view.repair_download_description" = "现在修复此画廊的离线文件吗?"; +"detail_view.update_download_description" = "现在将此画廊更新到线上最新版本吗?"; +"detail_view.redownload_gallery_description" = "现在重新完整下载此画廊吗?"; +"detail_view.repair" = "修复"; +"detail_view.update" = "更新"; +"detail_view.redownload" = "重新下载"; +"detail_view.saved_details" = "无法刷新在线详情,现显示已保存的详情。"; // MARK: ArchivesView -"archives_view.title.archives" = "归档"; -"archives_view.button.download_to_hath_client" = "下载到 H@H 客户端"; +"archives_view.archives" = "归档"; +"archives_view.download_to_hath_client" = "下载到 H@H 客户端"; // HathArchive -"struct.hath_archive.price.free" = "免费"; -"struct.hath_archive.price.not_available" = "无效"; +"hath_archive.free" = "免费"; // ArchiveResolution -"enum.archive_resolution.value.original" = "原始分辨率"; +"archive_resolution.original" = "原始分辨率"; // MARK: TorrentsView -"torrents_view.title.torrents" = "种子"; +"torrents_view.torrents" = "种子"; // MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "画廊信息"; -"gallery_infos_view.title.id" = "ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "标题"; -"gallery_infos_view.title.japanese_title" = "日文标题"; -"gallery_infos_view.title.gallery_URL" = "画廊链接"; -"gallery_infos_view.title.cover_URL" = "封面链接"; -"gallery_infos_view.title.archive_URL" = "归档链接"; -"gallery_infos_view.title.torrent_URL" = "种子链接"; -"gallery_infos_view.title.parent_URL" = "上游画廊链接"; -"gallery_infos_view.title.category" = "分类"; -"gallery_infos_view.title.uploader" = "上传者"; -"gallery_infos_view.title.posted_date" = "发布日期"; -"gallery_infos_view.title.visibility" = "可见"; -"gallery_infos_view.title.language" = "语言"; -"gallery_infos_view.title.page_count" = "页数"; -"gallery_infos_view.title.file_size" = "文件大小"; -"gallery_infos_view.title.favorited_times" = "收藏次数"; -"gallery_infos_view.title.favorited" = "已收藏"; -"gallery_infos_view.title.rating_count" = "评分次数"; -"gallery_infos_view.title.average_rating" = "平均评分"; -"gallery_infos_view.title.my_rating" = "我的评分"; -"gallery_infos_view.title.torrent_count" = "种子个数"; -"gallery_infos_view.value.none" = "无"; -"gallery_infos_view.value.yes" = "是"; -"gallery_infos_view.value.no" = "否"; +"gallery_infos_view.gallery_infos" = "画廊信息"; +"gallery_infos_view.id" = "ID"; +"gallery_infos_view.token" = "Token"; +"gallery_infos_view.title" = "标题"; +"gallery_infos_view.japanese_title" = "日文标题"; +"gallery_infos_view.gallery_URL" = "画廊链接"; +"gallery_infos_view.cover_URL" = "封面链接"; +"gallery_infos_view.archive_URL" = "归档链接"; +"gallery_infos_view.torrent_URL" = "种子链接"; +"gallery_infos_view.parent_URL" = "上游画廊链接"; +"gallery_infos_view.category" = "分类"; +"gallery_infos_view.uploader" = "上传者"; +"gallery_infos_view.posted_date" = "发布日期"; +"gallery_infos_view.visibility" = "可见"; +"gallery_infos_view.language" = "语言"; +"gallery_infos_view.page_count" = "页数"; +"gallery_infos_view.file_size" = "文件大小"; +"gallery_infos_view.favorited_times" = "收藏次数"; +"gallery_infos_view.favorited" = "已收藏"; +"gallery_infos_view.rating_count" = "评分次数"; +"gallery_infos_view.average_rating" = "平均评分"; +"gallery_infos_view.my_rating" = "我的评分"; +"gallery_infos_view.torrent_count" = "种子个数"; +"gallery_infos_view.none" = "无"; +"gallery_infos_view.yes" = "是"; +"gallery_infos_view.no" = "否"; // GalleryVisibility -"enum.gallery_visibility.value.yes" = "是"; -"enum.gallery_visibility.value.no" = "否 (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "已删除"; +"gallery_visibility.yes" = "是"; +"gallery_visibility.no" = "否 (%@)"; +"gallery_visibility.expunged" = "已删除"; // MARK: TagDetailView -"tag_detail_view.section.title.images" = "图片"; -"tag_detail_view.section.title.links" = "链接"; +"tag_detail_view.images" = "图片"; +"tag_detail_view.links" = "链接"; // MARK: DownloadsView -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "下载"; -"downloads_view.search.prompt.downloads" = "搜索下载"; -"downloads_view.dialog.title.delete_download" = "删除下载?"; -"downloads_view.dialog.message.delete_active_download" = "这将取消当前下载并从此设备移除它。"; -"downloads_view.dialog.message.delete_downloaded_gallery" = "这将从此设备移除已下载的画廊。"; -"downloads_view.swipe.button.pages" = "页面"; -"downloads_view.swipe.button.update" = "更新"; -"downloads_view.swipe.button.resume" = "继续"; -"downloads_view.swipe.button.pause" = "暂停"; -"downloads_view.empty_state.downloads" = "已下载的画廊会显示在这里。"; -"downloads_view.empty_state.no_matching_filters" = "没有下载项符合当前筛选条件。"; -"downloads_view.button.clear_filters" = "清除筛选"; -"downloads_view.button.validate_image_data" = "验证图片数据"; -"downloads_view.inspector.section.actions" = "操作"; -"downloads_view.inspector.section.pages" = "页面"; -"downloads_view.inspector.button.retry_failed_pages" = "重试失败页面"; -"downloads_view.inspector.button.validating_image_data" = "正在验证图像数据..."; -"downloads_view.inspector.button.update_download" = "更新下载"; -"downloads_view.inspector.toast.image_data_valid" = "图像数据有效"; -"downloads_view.inspector.toast.image_data_unavailable" = "无法验证图像数据。"; -"downloads_view.inspector.title.download_status" = "下载状态"; -"downloads_view.inspector.page.pending" = "等待中"; -"downloads_view.inspector.page.tap_to_retry" = "点按以重试此页"; -"downloads_view.inspector.page.title" = "第 %d 页"; -"downloads_view.inspector.page.none" = "无页面"; -"downloads_view.inspector.status.pending" = "等待中"; -"downloads_view.inspector.status.downloaded" = "已下载"; -"downloads_view.inspector.status.failed" = "失败"; +"download_folder_filter.all" = "All"; +"detail_view.manage_folders" = "Manage Folders"; +"detail_view.create_default_folder" = "Create Default Folder"; +"detail_view.no_folders" = "No folders yet"; +"downloads_view.manage_folders" = "Manage Folders"; +"downloads_view.move_to_folder" = "Move to Folder"; +"downloads_view.move" = "Move"; +"folder_manager_view.folders" = "Folders"; +"folder_manager_view.folder_name" = "Folder name"; +"folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_folders" = "Folders you create will appear here."; +"downloads_view.downloads" = "下载"; +"downloads_view.search_downloads" = "搜索下载"; +"downloads_view.delete_download" = "删除下载?"; +"downloads_view.delete_active_download" = "这将取消当前下载并从此设备移除它。"; +"downloads_view.delete_downloaded_gallery" = "这将从此设备移除已下载的画廊。"; +"downloads_view.pages" = "页面"; +"downloads_view.update" = "更新"; +"downloads_view.resume" = "继续"; +"downloads_view.pause" = "暂停"; +"downloads_view.empty_downloads" = "已下载的画廊会显示在这里。"; +"downloads_view.no_matching_filters" = "没有下载项符合当前筛选条件。"; +"downloads_view.clear_filters" = "清除筛选"; +"downloads_view.validate_image_data" = "验证图片数据"; +"download_inspector_view.actions" = "操作"; +"download_inspector_view.retry_failed_pages" = "重试失败页面"; +"download_inspector_view.validating_image_data" = "正在验证图像数据..."; +"download_inspector_view.image_data_valid" = "图像数据有效"; +"download_inspector_view.image_data_unavailable" = "无法验证图像数据。"; +"download_inspector_view.download_status" = "下载状态"; +"download_inspector_view.pending" = "等待中"; +"download_inspector_view.none" = "无页面"; +"download_inspector_view.downloaded" = "已下载"; +"download_inspector_view.failed" = "失败"; // MARK: DownloadSettingView "download_setting_view.title" = "下载"; -"download_setting_view.section.title.download_queue" = "下载队列"; -"download_setting_view.section.title.network" = "网络"; -"download_setting_view.title.concurrent_image_downloads" = "并发图片下载"; -"download_setting_view.title.retry_failed_pages_automatically" = "自动重试失败页面"; -"download_setting_view.title.allow_cellular_downloads" = "允许蜂窝网络下载"; -"download_setting_view.footer.network" = "每次只会下载一个画廊。这个设置用于控制单个画廊内页面的并行下载数量、是否允许蜂窝网络下载,以及文件在应用 Downloads 文件夹中的存储方式。"; +"download_setting_view.network" = "网络"; +"download_setting_view.concurrent_image_downloads" = "并发图片下载"; +"download_setting_view.retry_failed_pages_automatically" = "自动重试失败页面"; +"download_setting_view.allow_cellular_downloads" = "允许蜂窝网络下载"; +"download_setting_view.network_description" = "每次只会下载一个画廊。这个设置用于控制单个画廊内页面的并行下载数量、是否允许蜂窝网络下载,以及文件在应用 Downloads 文件夹中的存储方式。"; // MARK: CommentsView -"comments_view.title.comments" = "评论"; +"comments_view.comments" = "评论"; // MARK: PostCommentView -"post_comment_view.title.post_comment" = "发布评论"; -"post_comment_view.title.edit_comment" = "编辑评论"; +"post_comment_view.post_comment" = "发布评论"; +"post_comment_view.edit_comment" = "编辑评论"; // MARK: PreviewsView -"previews_view.title.previews" = "预览"; +"previews_view.previews" = "预览"; // MARK: ReadingView -"reading_view.context_menu.button.reload" = "重新加载"; -"reading_view.context_menu.button.copy" = "复制"; -"reading_view.context_menu.button.save" = "保存"; -"reading_view.context_menu.button.save_original" = "保存原图"; -"reading_view.context_menu.button.share" = "分享"; -"reading_view.toolbar_item.title.auto_play" = "自动播放"; -"reading_view.toolbar_item.title.dual_page_mode" = "双页模式"; -"reading_view.toolbar_item.title.except_the_cover" = "封面除外"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "重试所有读取失败图片"; -"reading_view.toolbar_item.button.reload_all_images" = "重新加载所有图片"; -"reading_view.toolbar_item.button.reading_setting" = "阅读设置"; +"reading_view.reload" = "重新加载"; +"reading_view.copy" = "复制"; +"reading_view.save" = "保存"; +"reading_view.save_original" = "保存原图"; +"reading_view.share" = "分享"; +"reading_view.auto_play" = "自动播放"; +"reading_view.dual_page_mode" = "双页模式"; +"reading_view.except_the_cover" = "封面除外"; +"reading_view.retry_all_failed_images" = "重试所有读取失败图片"; +"reading_view.reload_all_images" = "重新加载所有图片"; +"reading_view.reading_setting" = "阅读设置"; // AutoPlayPolicy -"enum.auto_play_policy.value.off" = "不启用"; +"auto_play_policy.off" = "不启用"; // MARK: DownloadBadge -"struct.download_badge.text.queued" = "已排队"; -"struct.download_badge.text.downloading" = "下载中"; -"struct.download_badge.text.paused" = "已暂停"; -"struct.download_badge.text.downloaded" = "已下载"; -"struct.download_badge.text.needs_attention" = "需处理"; -"struct.download_badge.text.update_available" = "有可更新"; -"struct.download_badge.text.needs_repair" = "需修复"; -"struct.download_badge.progress" = "%d/%d"; +"download_badge.queued" = "已排队"; +"download_badge.downloading" = "下载中"; +"download_badge.paused" = "已暂停"; +"download_badge.downloaded" = "已下载"; +"download_badge.needs_attention" = "需处理"; +"download_badge.update_available" = "有可更新"; +"download_badge.progress" = "%d/%d"; // MARK: DownloadStore -"download_store.error.asset_unreadable" = "资源文件无法读取:%@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "无法解析下载文件夹。"; -"download_store.validation.download_folder_missing" = "下载文件夹缺失。"; -"download_store.validation.manifest_missing" = "Manifest 文件缺失。"; -"download_store.validation.manifest_corrupted" = "Manifest 文件已损坏。"; -"download_store.validation.downloaded_pages_incomplete" = "下载页面不完整。"; -"download_store.validation.cover_image_missing" = "封面图片缺失。"; -"download_store.validation.page_missing" = "第 %d 页缺失。"; -"download_store.validation.cover_image_corrupted" = "封面图片数据已损坏。"; -"download_store.validation.page_image_corrupted" = "第 %d 页图片数据已损坏。"; +"download_store.asset_unreadable" = "资源文件无法读取:%@"; +"download_store.invalid_folder_name" = "The folder name is invalid."; +"download_store.folder_already_exists" = "A folder with this name already exists."; +"download_store.folder_busy_downloading" = "The folder contains an active download."; +"download_store.download_busy" = "The download is currently active."; +"download_store.download_folder_missing" = "下载文件夹缺失。"; +"download_store.manifest_missing" = "Manifest 文件缺失。"; +"download_store.manifest_corrupted" = "Manifest 文件已损坏。"; +"download_store.page_missing" = "第 %d 页缺失。"; +"download_store.page_image_corrupted" = "第 %d 页图片数据已损坏。"; // MARK: FiltersView -"filters_view.title.filters" = "筛选"; -"filters_view.title.advanced_settings" = "高级选项"; -"filters_view.title.search_gallery_name" = "搜索画廊名称"; -"filters_view.title.search_gallery_tags" = "搜索画廊标签"; -"filters_view.title.search_gallery_description" = "搜索画廊描述"; -"filters_view.title.search_torrent_filenames" = "搜索种子文件名"; -"filters_view.title.only_show_galleries_with_torrents" = "只显示带有种子的画廊"; -"filters_view.title.search_low_power_tags" = "搜索低期望标签"; -"filters_view.title.search_downvoted_tags" = "搜索低评价标签"; -"filters_view.title.search_expunged_galleries" = "显示已被删除的画廊"; -"filters_view.title.set_minimum_rating" = "设置评分下限"; -"filters_view.title.minimum_rating" = "评分下限"; -"filters_view.title.set_pages_range" = "设置页数范围"; -"filters_view.title.pages_range" = "页数范围"; -"filters_view.title.disable_language_filter" = "禁用语言筛选"; -"filters_view.title.disable_uploader_filter" = "禁用上传者筛选"; -"filters_view.title.disable_tags_filter" = "禁用标签筛选"; -"filters_view.button.reset_filters" = "重置所有选项"; -"filters_view.section.title.advanced" = "高级"; -"filters_view.section.title.default_filter" = "默认筛选"; +"filters_view.filters" = "筛选"; +"filters_view.advanced_settings" = "高级选项"; +"filters_view.search_gallery_name" = "搜索画廊名称"; +"filters_view.search_gallery_tags" = "搜索画廊标签"; +"filters_view.search_gallery_description" = "搜索画廊描述"; +"filters_view.search_torrent_filenames" = "搜索种子文件名"; +"filters_view.only_show_galleries_with_torrents" = "只显示带有种子的画廊"; +"filters_view.search_low_power_tags" = "搜索低期望标签"; +"filters_view.search_downvoted_tags" = "搜索低评价标签"; +"filters_view.search_expunged_galleries" = "显示已被删除的画廊"; +"filters_view.set_minimum_rating" = "设置评分下限"; +"filters_view.minimum_rating" = "评分下限"; +"filters_view.set_pages_range" = "设置页数范围"; +"filters_view.pages_range" = "页数范围"; +"filters_view.disable_language_filter" = "禁用语言筛选"; +"filters_view.disable_uploader_filter" = "禁用上传者筛选"; +"filters_view.disable_tags_filter" = "禁用标签筛选"; +"filters_view.reset_filters" = "重置所有选项"; +"filters_view.advanced" = "高级"; +"filters_view.default_filter" = "默认筛选"; // FilterRange -"enum.filter_range.value.search" = "搜索"; -"enum.filter_range.value.global" = "全局"; -"enum.filter_range.value.watched" = "标签"; +"filter_range.search" = "搜索"; +"filter_range.global" = "全局"; +"filter_range.watched" = "标签"; // MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 设置"; -"eh_setting_view.section.title.profile_settings" = "档案设置"; -"eh_setting_view.title.selected_profile" = "当前选定档案"; -"eh_setting_view.button.set_as_default" = "设为默认"; -"eh_setting_view.button.delete_profile" = "删除档案"; -"eh_setting_view.button.rename" = "重命名"; -"eh_setting_view.button.create_new" = "创建新档案"; -"eh_setting_view.toolbar_item.button.done" = "完成"; - -"eh_setting_view.section.title.image_load_settings" = "图片加载设置"; -"eh_setting_view.title.load_images_through_the_hath_network" = "通过 Hath 网络加载图像"; -"eh_setting_view.title.browsing_country" = "浏览国家"; -"eh_setting_view.description.browsing_country" = "你似乎正在 **%@** 浏览此网页,或是使用了一个来自这个国家的 VPN 或代理,这意味着网站将尝试通过在此区域的 H@H 客户端加载图片。如果该结果不正确,或你想通过其它地区的 H@H 客户端加载图片(例如你正在使用分割隧道 VPN),你可以在下方选择另一个国家。"; +"eh_setting_view.host_settings" = "%@ 设置"; +"eh_setting_view.profile_settings" = "档案设置"; +"eh_setting_view.selected_profile" = "当前选定档案"; +"eh_setting_view.set_as_default" = "设为默认"; +"eh_setting_view.delete_profile" = "删除档案"; +"eh_setting_view.rename" = "重命名"; +"eh_setting_view.create_new" = "创建新档案"; +"eh_setting_view.done" = "完成"; + +"eh_setting_view.image_load_settings" = "图片加载设置"; +"eh_setting_view.load_images_through_the_hath_network" = "通过 Hath 网络加载图像"; +"eh_setting_view.browsing_country" = "浏览国家"; +"eh_setting_view.browsing_country_description" = "你似乎正在 **%@** 浏览此网页,或是使用了一个来自这个国家的 VPN 或代理,这意味着网站将尝试通过在此区域的 H@H 客户端加载图片。如果该结果不正确,或你想通过其它地区的 H@H 客户端加载图片(例如你正在使用分割隧道 VPN),你可以在下方选择另一个国家。"; // EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "所有客户端"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "仅使用默认端口的客户端"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "不通过 [现代 / HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "不通过 [旧式 / HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "推荐。"; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "可能稍慢。当防火墙或代理阻止非标准接口的流量时启用此项。"; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "仅限赞助者。配额消耗会加快。只建议在遇到严重问题时使用。"; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "仅限赞助者。在现代浏览器可能不可用。只建议在旧式 / 过时的浏览器使用。"; - -"eh_setting_view.section.title.image_size_settings" = "图像尺寸设置"; -"eh_setting_view.title.image_resolution" = "图像分辨率"; -"eh_setting_view.description.image_resolution" = "通常情况,图像将重采样到 1280 像素宽度以用于在线浏览,你也可以选择以下重新采样分辨率。但是为了避免负载过高,高于 1280 像素将只供给于赞助者、特殊贡献者,以及 UID 小于 3,000,000 的用户。"; -"eh_setting_view.title.image_size" = "图像尺寸"; -"eh_setting_view.description.image_size" = "虽然图片会自动根据窗口缩小,你也可以手动设置最大大小,图片并没有重新采样。(0 为不限制)"; -"eh_setting_view.title.horizontal" = "宽度"; -"eh_setting_view.title.vertical" = "高度"; +"load_through_hath_setting.any_client" = "所有客户端"; +"load_through_hath_setting.default_port_only" = "仅使用默认端口的客户端"; +"load_through_hath_setting.modern_no" = "不通过 [现代 / HTTPS]"; +"load_through_hath_setting.legacy_no" = "不通过 [旧式 / HTTP]"; +"load_through_hath_setting.any_client_description" = "推荐。"; +"load_through_hath_setting.default_port_only_description" = "可能稍慢。当防火墙或代理阻止非标准接口的流量时启用此项。"; +"load_through_hath_setting.modern_no_description" = "仅限赞助者。配额消耗会加快。只建议在遇到严重问题时使用。"; +"load_through_hath_setting.legacy_no_description" = "仅限赞助者。在现代浏览器可能不可用。只建议在旧式 / 过时的浏览器使用。"; + +"eh_setting_view.image_size_settings" = "图像尺寸设置"; +"eh_setting_view.image_resolution" = "图像分辨率"; +"eh_setting_view.image_resolution_description" = "通常情况,图像将重采样到 1280 像素宽度以用于在线浏览,你也可以选择以下重新采样分辨率。但是为了避免负载过高,高于 1280 像素将只供给于赞助者、特殊贡献者,以及 UID 小于 3,000,000 的用户。"; +"eh_setting_view.image_size" = "图像尺寸"; +"eh_setting_view.image_size_description" = "虽然图片会自动根据窗口缩小,你也可以手动设置最大大小,图片并没有重新采样。(0 为不限制)"; +"eh_setting_view.horizontal" = "宽度"; +"eh_setting_view.vertical" = "高度"; // EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "自动"; +"image_resolution.auto" = "自动"; -"eh_setting_view.section.title.gallery_name_display" = "画廊名称显示"; -"eh_setting_view.title.gallery_name" = "画廊名称"; -"eh_setting_view.description.gallery_name" = "很多画廊都同时拥有英文或者日文标题,你想默认显示哪一个?"; +"eh_setting_view.gallery_name_display" = "画廊名称显示"; +"eh_setting_view.gallery_name" = "画廊名称"; +"eh_setting_view.gallery_name_description" = "很多画廊都同时拥有英文或者日文标题,你想默认显示哪一个?"; // EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "默认标题"; -"enum.eh_setting.gallery_name.value.japanese" = "日文标题(如果有)"; +"gallery_name.default" = "默认标题"; +"gallery_name.japanese" = "日文标题(如果有)"; -"eh_setting_view.section.title.archiver_settings" = "归档设置"; -"eh_setting_view.title.archiver_behavior" = "归档下载方式"; -"eh_setting_view.description.archiver_behavior" = "默认归档下载方式为手动选择(原画质或压缩画质),然后手动复制或点击下载链接。你可以修改归档下载方式。"; +"eh_setting_view.archiver_settings" = "归档设置"; +"eh_setting_view.archiver_behavior" = "归档下载方式"; +"eh_setting_view.archiver_behavior_description" = "默认归档下载方式为手动选择(原画质或压缩画质),然后手动复制或点击下载链接。你可以修改归档下载方式。"; // EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "手动选择,手动下载(默认)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "手动选择,自动下载"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "自动选择原始画质,手动下载"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "自动选择原始画质,自动下载"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "自动选择压缩画质,手动下载"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "自动选择压缩画质,自动下载"; - -"eh_setting_view.section.title.front_page_settings" = "扉页设置"; -"eh_setting_view.title.display_mode" = "显示样式"; -"eh_setting_view.description.display_mode" = "你希望在扉页和搜索页显示哪种样式?"; -"eh_setting_view.section.title.show_search_range_indicator" = "搜索范围指示器"; -"eh_setting_view.title.show_search_range_indicator" = "显示搜索范围指示器"; -"eh_setting_view.description.gallery_category" = "你希望在扉页和搜索页看到哪些类别?"; +"eh_setting.archiver_behavior.manual_select_manual_start" = "手动选择,手动下载(默认)"; +"eh_setting.archiver_behavior.manual_select_auto_start" = "手动选择,自动下载"; +"eh_setting.archiver_behavior.auto_select_original_manual_start" = "自动选择原始画质,手动下载"; +"eh_setting.archiver_behavior.auto_select_original_auto_start" = "自动选择原始画质,自动下载"; +"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "自动选择压缩画质,手动下载"; +"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "自动选择压缩画质,自动下载"; + +"eh_setting_view.front_page_settings" = "扉页设置"; +"eh_setting_view.display_mode" = "显示样式"; +"eh_setting_view.display_mode_description" = "你希望在扉页和搜索页显示哪种样式?"; +"eh_setting_view.show_search_range_indicator" = "搜索范围指示器"; +"eh_setting_view.show_search_range_indicator_description" = "显示搜索范围指示器"; +"eh_setting_view.gallery_category" = "你希望在扉页和搜索页看到哪些类别?"; // EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "紧凑"; -"enum.eh_setting.display_mode.value.thumbnail" = "缩略图"; -"enum.eh_setting.display_mode.value.extended" = "扩展"; -"enum.eh_setting.display_mode.value.minimal" = "最小化"; -"enum.eh_setting.display_mode.value.minimalPlus" = "最小化 +"; - -"eh_setting_view.section.title.optional_UI_elements" = "可选的 UI 组件"; -"eh_setting_view.description.optional_UI_elements" = "一些旧版 UI 组件现已默认禁用。您可以在此启用这些组件。"; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "在画廊页面启用缩图选择器"; - -"eh_setting_view.section.title.favorites" = "收藏"; -"eh_setting_view.description.favorite_categories" = "在这里你可以重命名你的收藏夹。"; -"eh_setting_view.title.favorites_sort_order" = "收藏排序方式"; -"eh_setting_view.description.favorites_sort_order" = "你也可以选择收藏夹中默认排序。注意:2016 年 3 月改版之前加入收藏夹的画廊并未保存收藏时间,会以画廊发布时间代替。"; +"display_mode.compact" = "紧凑"; +"display_mode.thumbnail" = "缩略图"; +"display_mode.extended" = "扩展"; +"display_mode.minimal" = "最小化"; +"display_mode.minimalPlus" = "最小化 +"; + +"eh_setting_view.optional_UI_elements" = "可选的 UI 组件"; +"eh_setting_view.optional_UI_elements_description" = "一些旧版 UI 组件现已默认禁用。您可以在此启用这些组件。"; +"eh_setting_view.enable_gallery_thumbnail_selector" = "在画廊页面启用缩图选择器"; + +"eh_setting_view.favorites" = "收藏"; +"eh_setting_view.favorite_categories" = "在这里你可以重命名你的收藏夹。"; +"eh_setting_view.favorites_sort_order" = "收藏排序方式"; +"eh_setting_view.favorites_sort_order_description" = "你也可以选择收藏夹中默认排序。注意:2016 年 3 月改版之前加入收藏夹的画廊并未保存收藏时间,会以画廊发布时间代替。"; // EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "按更新时间"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "按收藏时间"; +"favorites_sort_order.last_update_time" = "按更新时间"; +"favorites_sort_order.favorited_time" = "按收藏时间"; -"eh_setting_view.section.title.ratings" = "评分"; -"eh_setting_view.title.ratings_color" = "评分颜色"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "默认设置下,你评为 2 星及以下的画廊显示为红星,2.5 ~ 4 星显示为绿星,4.5 ~ 5 星显示为蓝星。你可以将其设定为其它颜色组合。每一个字幕代表一颗星, 默认的 RRGGB 表示第一第二颗星显示为红色 R(ed),第三第四颗星显示是绿色 G(reen),第五颗星显示为蓝色 B(lue)。你也可以使用黄色 (Y)ellow,R/G/B/Y 任何五个组合都是有效的。"; +"eh_setting_view.ratings" = "评分"; +"eh_setting_view.ratings_color" = "评分颜色"; +"eh_setting_view.ratings_color_prompt" = "RRGGB"; +"eh_setting_view.ratings_color_description" = "默认设置下,你评为 2 星及以下的画廊显示为红星,2.5 ~ 4 星显示为绿星,4.5 ~ 5 星显示为蓝星。你可以将其设定为其它颜色组合。每一个字幕代表一颗星, 默认的 RRGGB 表示第一第二颗星显示为红色 R(ed),第三第四颗星显示是绿色 G(reen),第五颗星显示为蓝色 B(lue)。你也可以使用黄色 (Y)ellow,R/G/B/Y 任何五个组合都是有效的。"; -"eh_setting_view.section.title.tag_filtering_threshold" = "标签筛选阈值"; -"eh_setting_view.title.tag_filtering_threshold" = "标签筛选阈值"; -"eh_setting_view.description.tag_filtering_threshold" = "你可以通过将标签加入“我的标签”并设置一个负权重来软过滤它们。如果一个作品所有的标签权重之和低于设定值,此作品将从视图中被过滤。这个值可以设定为 0 ~ -9999。"; +"eh_setting_view.tag_filtering_threshold" = "标签筛选阈值"; +"eh_setting_view.tag_filtering_threshold_description" = "你可以通过将标签加入“我的标签”并设置一个负权重来软过滤它们。如果一个作品所有的标签权重之和低于设定值,此作品将从视图中被过滤。这个值可以设定为 0 ~ -9999。"; -"eh_setting_view.section.title.tag_watching_threshold" = "标签订阅阈值"; -"eh_setting_view.title.tag_watching_threshold" = "标签订阅阈值"; -"eh_setting_view.description.tag_watching_threshold" = "你可以通过将标签加入“我的标签”并设置一个正权重来关注它们。如果一个最近上传的作品所有标签的权重之和高于设定值,则它将会被包含在“关注”里。这个值可以设定为 0 ~ 9999。"; +"eh_setting_view.tag_watching_threshold" = "标签订阅阈值"; +"eh_setting_view.tag_watching_threshold_description" = "你可以通过将标签加入“我的标签”并设置一个正权重来关注它们。如果一个最近上传的作品所有标签的权重之和高于设定值,则它将会被包含在“关注”里。这个值可以设定为 0 ~ 9999。"; -"eh_setting_view.section.title.filtered_removal_count" = "筛选器移除数"; -"eh_setting_view.description.filtered_removal_count" = "要显示“你的默认筛选器从本页移除了 XX 个画廊”提示吗?"; -"eh_setting_view.title.show_filtered_removal_count" = "显示筛选器移除数"; +"eh_setting_viewfiltered_removal_count" = "筛选器移除数"; +"eh_setting_view.filtered_removal_count_description" = "要显示“你的默认筛选器从本页移除了 XX 个画廊”提示吗?"; +"eh_setting_view.show_filtered_removal_count" = "显示筛选器移除数"; -"eh_setting_view.section.title.excluded_languages" = "屏蔽的语言"; -"eh_setting_view.description.excluded_languages" = "如果你希望以从列表或搜索结果中隐藏特定语言的画廊,请从下面的列表中选择。注意:无论搜索条件为何,这些画廊都不会出现。"; +"eh_setting_view.excluded_languages" = "屏蔽的语言"; +"eh_setting_view.excluded_languages_description" = "如果你希望以从列表或搜索结果中隐藏特定语言的画廊,请从下面的列表中选择。注意:无论搜索条件为何,这些画廊都不会出现。"; // EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "原始版本"; -"enum.eh_setting.excluded_languages_category.value.translated" = "翻译版本"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "改编版本"; - -"eh_setting_view.section.title.excluded_uploaders" = "屏蔽的上传者"; -"eh_setting_view.description.excluded_uploaders" = "如果你希望在画廊中和搜索中隐藏某个上传者的话,请把他们的用户名填写在下方,每行一个。注意:无论搜索条件为何,这些上传者都不会出现。"; -"eh_setting_view.description.excluded_uploaders_count" = "已使用 **%@ / %@** 个屏蔽槽位。"; - -"eh_setting_view.section.title.search_result_count" = "搜索结果数"; -"eh_setting_view.title.result_count" = "结果数"; -"eh_setting_view.description.result_count" = "搜索页面每页显示多少条数据?\n(需要“Hath Perk:页面扩大”)"; - -"eh_setting_view.section.title.thumbnail_settings" = "缩略图设置"; -"eh_setting_view.title.thumbnail_load_timing" = "缩略图加载时机"; -"eh_setting_view.description.thumbnail_load_timing" = "你希望列表中的鼠标悬停缩略图何时加载?"; -"eh_setting_view.description.thumbnail_configuration" = "你可以设定一个对所有画廊生效的默认缩略图配置。"; -"eh_setting_view.title.thumbnail_size" = "尺寸"; -"eh_setting_view.title.thumbnail_row_count" = "行数"; +"excluded_languages_category.original" = "原始版本"; +"excluded_languages_category.translated" = "翻译版本"; +"excluded_languages_category.rewrite" = "改编版本"; + +"eh_setting_view.excluded_uploaders" = "屏蔽的上传者"; +"eh_setting_view.excluded_uploaders_description" = "如果你希望在画廊中和搜索中隐藏某个上传者的话,请把他们的用户名填写在下方,每行一个。注意:无论搜索条件为何,这些上传者都不会出现。"; +"eh_setting_view.excluded_uploaders_count" = "已使用 **%@ / %@** 个屏蔽槽位。"; + +"eh_setting_view.search_result_count" = "搜索结果数"; +"eh_setting_view.result_count" = "结果数"; +"eh_setting_view.result_count_description" = "搜索页面每页显示多少条数据?\n(需要“Hath Perk:页面扩大”)"; + +"eh_setting_view.thumbnail_settings" = "缩略图设置"; +"eh_setting_view.thumbnail_load_timing" = "缩略图加载时机"; +"eh_setting_view.thumbnail_load_timing_description" = "你希望列表中的鼠标悬停缩略图何时加载?"; +"eh_setting_view.thumbnail_configuration" = "你可以设定一个对所有画廊生效的默认缩略图配置。"; +"eh_setting_view.thumbnail_size" = "尺寸"; +"eh_setting_view.thumbnail_row_count" = "行数"; // EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "鼠标悬停时"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "页面加载时"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "页面加载快,缩略图加载有延迟。"; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "页面加载时间更长,显示缩略图无需等待。"; +"thumbnail_load_timing.on_mouse_over" = "鼠标悬停时"; +"thumbnail_load_timing.on_page_load" = "页面加载时"; +"thumbnail_load_timing.on_mouse_over_description" = "页面加载快,缩略图加载有延迟。"; +"thumbnail_load_timing.on_page_load_description" = "页面加载时间更长,显示缩略图无需等待。"; // EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "普通"; -"enum.eh_setting.thumbnail_size.value.large" = "较大"; -"enum.eh_setting.thumbnail_size.value.small" = "较小"; -"enum.eh_setting.thumbnail_size.value.auto" = "自动"; - -"eh_setting_view.section.title.cover_scaling" = "封面缩放"; -"eh_setting_view.title.scale_factor" = "缩放比例"; -"eh_setting_view.description.cover_scale_factor" = "缩略图和扩展模式下的画廊列表封面可以缩放为 75%% 到 150%% 之间的值。"; - -"eh_setting_view.section.title.viewport_override" = "覆写可视区域"; -"eh_setting_view.title.virtual_width" = "虚拟宽度"; -"eh_setting_view.description.virtual_width" = "允许你覆写移动设备的可视区域,默认是根据 DPI 自动计算的,100%% 缩略图比例下的合理值在 640 到 1400 之间。"; - -"eh_setting_view.section.title.gallery_comments" = "画廊评论"; -"eh_setting_view.title.comments_sort_order" = "评论排序方式"; -"eh_setting_view.title.comments_votes_show_timing" = "显示评论分数时机"; +"thumbnail_size.normal" = "普通"; +"thumbnail_size.large" = "较大"; +"thumbnail_size.small" = "较小"; +"thumbnail_size.auto" = "自动"; + +"eh_setting_view.cover_scaling" = "封面缩放"; +"eh_setting_view.scale_factor" = "缩放比例"; +"eh_setting_view.cover_scale_factor" = "缩略图和扩展模式下的画廊列表封面可以缩放为 75%% 到 150%% 之间的值。"; + +"eh_setting_view.viewport_override" = "覆写可视区域"; +"eh_setting_view.virtual_width" = "虚拟宽度"; +"eh_setting_view.virtual_width_description" = "允许你覆写移动设备的可视区域,默认是根据 DPI 自动计算的,100%% 缩略图比例下的合理值在 640 到 1400 之间。"; + +"eh_setting_view.gallery_comments" = "画廊评论"; +"eh_setting_view.comments_sort_order" = "评论排序方式"; +"eh_setting_view.comments_votes_show_timing" = "显示评论分数时机"; // EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "按最早的评论"; -"enum.eh_setting.comments_sort_order.value.recent" = "按最新的评论"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "按最高分的评论"; +"comments_sort_order.oldest" = "按最早的评论"; +"comments_sort_order.recent" = "按最新的评论"; +"comments_sort_order.highest_score" = "按最高分的评论"; // EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "悬停或点击时"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "始终显示"; +"comments_votes_show_timing.on_hover_or_click" = "悬停或点击时"; +"comments_votes_show_timing.always" = "始终显示"; -"eh_setting_view.section.title.gallery_tags" = "画廊标签"; -"eh_setting_view.title.tags_sort_order" = "标签排序方式"; +"eh_setting_view.gallery_tags" = "画廊标签"; +"eh_setting_view.tags_sort_order" = "标签排序方式"; // EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "按字母排序"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "按标签权重"; +"tags_sort_order.alphabetical" = "按字母排序"; +"tags_sort_order.tag_power" = "按标签权重"; -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "画廊页面缩略图标签"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "在画廊缩略图下方显示标签"; +"eh_setting_view.gallery_page_thumbnail_labeling" = "画廊页面缩略图标签"; +"eh_setting_view.show_label_below_gallery_thumbnails" = "在画廊缩略图下方显示标签"; -"eh_setting_view.section.title.hath_local_network_host" = "Hath 本地网络服务器"; -"eh_setting_view.title.ip_address_port" = "IP 地址:端口"; -"eh_setting_view.description.ip_address_port" = "如果你本地安装了 H@H 客户端,本地 IP 与浏览网站的公共 IP 相同,一些路由器不支持回流导致无法访问到自己,你可以设置这里来解决。\n如果在同一台设备上访问网站和运行客户端,请使用本地回环地址 (127.0.0.1:端口号)。如果客户端在网络上的其它设备运行,请使用那台机器的内网 IP。某些浏览器的配置可能阻止外部网站访问本地网络,你必须将网站列入白名单才能工作。"; -"eh_setting_view.section.title.original_images" = "是否使用原始图像而非重新采样的版本?如果您在上方选择的水平分辨率不是“自动”,并且所查看的图像更宽,或者原始图像大于 10 MiB(对于超过一年的图库,则为 4 MiB),那么仍将使用重新采样的图像。"; -"eh_setting_view.title.use_original_images" = "显示原图"; +"eh_setting_view.original_images" = "是否使用原始图像而非重新采样的版本?如果您在上方选择的水平分辨率不是“自动”,并且所查看的图像更宽,或者原始图像大于 10 MiB(对于超过一年的图库,则为 4 MiB),那么仍将使用重新采样的图像。"; +"eh_setting_view.use_original_images" = "显示原图"; -"eh_setting_view.section.title.multi_page_viewer" = "多页查看器"; -"eh_setting_view.title.use_multi_page_viewer" = "使用多页查看器"; -"eh_setting_view.title.display_style" = "显示样式"; -"eh_setting_view.title.show_thumbnail_pane" = "显示缩略图侧栏"; +"eh_setting_view.multi_page_viewer" = "多页查看器"; +"eh_setting_view.use_multi_page_viewer" = "使用多页查看器"; +"eh_setting_view.display_style" = "显示样式"; +"eh_setting_view.show_thumbnail_pane" = "显示缩略图侧栏"; // EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "左对齐,图像过宽时缩放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "居中对齐,图像过宽时缩放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "居中对齐,图像始终缩放"; +"multiple_page_viewer_style.align_left_scale_if_over_width" = "左对齐,图像过宽时缩放"; +"multiple_page_viewer_style.align_center_scale_if_over_width" = "居中对齐,图像过宽时缩放"; +"multiple_page_viewer_style.align_center_always_scale" = "居中对齐,图像始终缩放"; // EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "不显示"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "仅显示页码"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "显示页码和名称"; +"gallery_page_numbering.none" = "不显示"; +"gallery_page_numbering.page_number_only" = "仅显示页码"; +"gallery_page_numbering.page_number_and_name" = "显示页码和名称"; // MARK: Category -"enum.category.value.doujinshi" = "同人志"; -"enum.category.value.manga" = "漫画"; -"enum.category.value.artist_CG" = "插画"; -"enum.category.value.game_CG" = "游戏 CG"; -"enum.category.value.western" = "西方"; -"enum.category.value.non_h" = "健康"; -"enum.category.value.image_set" = "照片集"; -"enum.category.value.cosplay" = "角色扮演"; -"enum.category.value.asian_porn" = "亚洲"; -"enum.category.value.misc" = "其它"; -"enum.category.value.private" = "非公开"; +"category.doujinshi" = "同人志"; +"category.manga" = "漫画"; +"category.artist_CG" = "插画"; +"category.game_CG" = "游戏 CG"; +"category.western" = "西方"; +"category.non_h" = "健康"; +"category.image_set" = "照片集"; +"category.cosplay" = "角色扮演"; +"category.asian_porn" = "亚洲"; +"category.misc" = "其它"; +"category.private" = "非公开"; // MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "重归类"; -"enum.tag_namespace.value.language" = "语言"; -"enum.tag_namespace.value.parody" = "原作"; -"enum.tag_namespace.value.character" = "角色"; -"enum.tag_namespace.value.group" = "团体"; -"enum.tag_namespace.value.artist" = "作者"; -"enum.tag_namespace.value.male" = "男性"; -"enum.tag_namespace.value.female" = "女性"; -"enum.tag_namespace.value.mixed" = "混合性别"; -"enum.tag_namespace.value.cosplayer" = "扮装者"; -"enum.tag_namespace.value.other" = "其它"; -"enum.tag_namespace.value.temp" = "临时"; +"tag_namespace.reclass" = "重归类"; +"tag_namespace.language" = "语言"; +"tag_namespace.parody" = "原作"; +"tag_namespace.character" = "角色"; +"tag_namespace.group" = "团体"; +"tag_namespace.artist" = "作者"; +"tag_namespace.male" = "男性"; +"tag_namespace.female" = "女性"; +"tag_namespace.mixed" = "混合性别"; +"tag_namespace.cosplayer" = "扮装者"; +"tag_namespace.other" = "其它"; +"tag_namespace.temp" = "临时"; // MARK: Language -"enum.language.value.invalid" = "无效"; -"enum.language.value.other" = "其它"; -"enum.language.value.afrikaans" = "南非语"; -"enum.language.value.albanian" = "阿尔巴尼亚语"; -"enum.language.value.arabic" = "阿拉伯语"; -"enum.language.value.bengali" = "孟加拉语"; -"enum.language.value.bosnian" = "波斯尼亚语"; -"enum.language.value.bulgarian" = "保加利亚语"; -"enum.language.value.burmese" = "缅甸语"; -"enum.language.value.catalan" = "加泰罗尼亚语"; -"enum.language.value.cebuano" = "宿雾語"; -"enum.language.value.chinese" = "汉语"; -"enum.language.value.croatian" = "克罗地亚语"; -"enum.language.value.czech" = "捷克语"; -"enum.language.value.danish" = "丹麦语"; -"enum.language.value.dutch" = "荷兰语"; -"enum.language.value.english" = "英语"; -"enum.language.value.esperanto" = "国际语"; -"enum.language.value.estonian" = "爱沙尼亚语"; -"enum.language.value.finnish" = "芬兰语"; -"enum.language.value.french" = "法语"; -"enum.language.value.georgian" = "格鲁吉亚语"; -"enum.language.value.german" = "德语"; -"enum.language.value.greek" = "希腊语"; -"enum.language.value.hebrew" = "希伯来语"; -"enum.language.value.hindi" = "印地语"; -"enum.language.value.hmong" = "苗语"; -"enum.language.value.hungarian" = "匈牙利语"; -"enum.language.value.indonesian" = "印度尼西亚语"; -"enum.language.value.italian" = "意大利语"; -"enum.language.value.japanese" = "日语"; -"enum.language.value.kazakh" = "哈萨克语"; -"enum.language.value.khmer" = "高棉文"; -"enum.language.value.korean" = "韩语"; -"enum.language.value.kurdish" = "库尔德语"; -"enum.language.value.lao" = "老挝语"; -"enum.language.value.latin" = "拉丁语"; -"enum.language.value.mongolian" = "蒙古语"; -"enum.language.value.ndebele" = "恩德贝莱语"; -"enum.language.value.nepali" = "尼泊尔语"; -"enum.language.value.norwegian" = "挪威语"; -"enum.language.value.oromo" = "奥罗莫语"; -"enum.language.value.pashto" = "普什图语"; -"enum.language.value.persian" = "波斯语"; -"enum.language.value.polish" = "波兰语"; -"enum.language.value.portuguese" = "葡萄牙语"; -"enum.language.value.punjabi" = "旁遮普语"; -"enum.language.value.romanian" = "罗马尼亚语"; -"enum.language.value.russian" = "俄语"; -"enum.language.value.sango" = "桑戈语"; -"enum.language.value.serbian" = "塞尔维亚语"; -"enum.language.value.shona" = "绍纳语"; -"enum.language.value.slovak" = "斯洛伐克语"; -"enum.language.value.slovenian" = "斯洛文尼亚语"; -"enum.language.value.somali" = "索马里语"; -"enum.language.value.spanish" = "西班牙语"; -"enum.language.value.swahili" = "斯瓦希里语"; -"enum.language.value.swedish" = "瑞典语"; -"enum.language.value.tagalog" = "他加洛语"; -"enum.language.value.thai" = "泰语"; -"enum.language.value.tigrinya" = "提格利尼亚语"; -"enum.language.value.turkish" = "土耳其语"; -"enum.language.value.ukrainian" = "乌克兰语"; -"enum.language.value.urdu" = "乌尔都语"; -"enum.language.value.vietnamese" = "越南语"; -"enum.language.value.zulu" = "祖鲁语"; +"language.invalid" = "无效"; +"language.other" = "其它"; +"language.afrikaans" = "南非语"; +"language.albanian" = "阿尔巴尼亚语"; +"language.arabic" = "阿拉伯语"; +"language.bengali" = "孟加拉语"; +"language.bosnian" = "波斯尼亚语"; +"language.bulgarian" = "保加利亚语"; +"language.burmese" = "缅甸语"; +"language.catalan" = "加泰罗尼亚语"; +"language.cebuano" = "宿雾語"; +"language.chinese" = "汉语"; +"language.croatian" = "克罗地亚语"; +"language.czech" = "捷克语"; +"language.danish" = "丹麦语"; +"language.dutch" = "荷兰语"; +"language.english" = "英语"; +"language.esperanto" = "国际语"; +"language.estonian" = "爱沙尼亚语"; +"language.finnish" = "芬兰语"; +"language.french" = "法语"; +"language.georgian" = "格鲁吉亚语"; +"language.german" = "德语"; +"language.greek" = "希腊语"; +"language.hebrew" = "希伯来语"; +"language.hindi" = "印地语"; +"language.hmong" = "苗语"; +"language.hungarian" = "匈牙利语"; +"language.indonesian" = "印度尼西亚语"; +"language.italian" = "意大利语"; +"language.japanese" = "日语"; +"language.kazakh" = "哈萨克语"; +"language.khmer" = "高棉文"; +"language.korean" = "韩语"; +"language.kurdish" = "库尔德语"; +"language.lao" = "老挝语"; +"language.latin" = "拉丁语"; +"language.mongolian" = "蒙古语"; +"language.ndebele" = "恩德贝莱语"; +"language.nepali" = "尼泊尔语"; +"language.norwegian" = "挪威语"; +"language.oromo" = "奥罗莫语"; +"language.pashto" = "普什图语"; +"language.persian" = "波斯语"; +"language.polish" = "波兰语"; +"language.portuguese" = "葡萄牙语"; +"language.punjabi" = "旁遮普语"; +"language.romanian" = "罗马尼亚语"; +"language.russian" = "俄语"; +"language.sango" = "桑戈语"; +"language.serbian" = "塞尔维亚语"; +"language.shona" = "绍纳语"; +"language.slovak" = "斯洛伐克语"; +"language.slovenian" = "斯洛文尼亚语"; +"language.somali" = "索马里语"; +"language.spanish" = "西班牙语"; +"language.swahili" = "斯瓦希里语"; +"language.swedish" = "瑞典语"; +"language.tagalog" = "他加洛语"; +"language.thai" = "泰语"; +"language.tigrinya" = "提格利尼亚语"; +"language.turkish" = "土耳其语"; +"language.ukrainian" = "乌克兰语"; +"language.urdu" = "乌尔都语"; +"language.vietnamese" = "越南语"; +"language.zulu" = "祖鲁语"; // MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "自动检测"; -"enum.browsing_country.name.afghanistan" = "阿富汗"; -"enum.browsing_country.name.aland_islands" = "奥兰群岛"; -"enum.browsing_country.name.albania" = "阿尔巴尼亚"; -"enum.browsing_country.name.algeria" = "阿尔及利亚"; -"enum.browsing_country.name.american_samoa" = "美属萨摩亚"; -"enum.browsing_country.name.andorra" = "安道尔"; -"enum.browsing_country.name.angola" = "安哥拉"; -"enum.browsing_country.name.anguilla" = "安圭拉"; -"enum.browsing_country.name.antarctica" = "南极洲"; -"enum.browsing_country.name.antigua_and_barbuda" = "安提瓜和巴布达"; -"enum.browsing_country.name.argentina" = "阿根廷"; -"enum.browsing_country.name.armenia" = "亚美尼亚"; -"enum.browsing_country.name.aruba" = "阿鲁巴"; -"enum.browsing_country.name.asia_pacific_region" = "亚太地区"; -"enum.browsing_country.name.australia" = "澳大利亚"; -"enum.browsing_country.name.austria" = "奥地利"; -"enum.browsing_country.name.azerbaijan" = "阿塞拜疆"; -"enum.browsing_country.name.bahamas" = "巴哈马"; -"enum.browsing_country.name.bahrain" = "巴林"; -"enum.browsing_country.name.bangladesh" = "孟加拉国"; -"enum.browsing_country.name.barbados" = "巴巴多斯"; -"enum.browsing_country.name.belarus" = "白俄罗斯"; -"enum.browsing_country.name.belgium" = "比利时"; -"enum.browsing_country.name.belize" = "伯利兹"; -"enum.browsing_country.name.benin" = "贝宁"; -"enum.browsing_country.name.bermuda" = "百慕大"; -"enum.browsing_country.name.bhutan" = "不丹"; -"enum.browsing_country.name.bolivia" = "玻利维亚"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "博奈尔、圣尤斯特歇斯与萨巴"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "波斯尼亚和黑塞哥维那"; -"enum.browsing_country.name.botswana" = "博茨瓦纳"; -"enum.browsing_country.name.bouvet_island" = "布韦岛"; -"enum.browsing_country.name.brazil" = "巴西"; -"enum.browsing_country.name.british_indian_ocean_territory" = "英属印度洋领地"; -"enum.browsing_country.name.brunei_darussalam" = "文莱"; -"enum.browsing_country.name.bulgaria" = "保加利亚"; -"enum.browsing_country.name.burkina_faso" = "布基纳法索"; -"enum.browsing_country.name.burundi" = "蒲隆地"; -"enum.browsing_country.name.cambodia" = "柬埔寨"; -"enum.browsing_country.name.cameroon" = "喀麦隆"; -"enum.browsing_country.name.canada" = "加拿大"; -"enum.browsing_country.name.cape_verde" = "佛得角"; -"enum.browsing_country.name.cayman_islands" = "开曼群岛"; -"enum.browsing_country.name.central_african_republic" = "中非"; -"enum.browsing_country.name.chad" = "乍得"; -"enum.browsing_country.name.chile" = "智利"; -"enum.browsing_country.name.china" = "中华人民共和国"; -"enum.browsing_country.name.christmas_island" = "圣诞岛"; -"enum.browsing_country.name.cocos_islands" = "科科斯岛"; -"enum.browsing_country.name.colombia" = "哥伦比亚"; -"enum.browsing_country.name.comoros" = "科摩罗"; -"enum.browsing_country.name.congo" = "刚果共和国"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "刚果民主共和国"; -"enum.browsing_country.name.cook_islands" = "库克群岛"; -"enum.browsing_country.name.costa_rica" = "哥斯达黎加"; -"enum.browsing_country.name.cote_d_ivoire" = "科特迪瓦"; -"enum.browsing_country.name.croatia" = "克罗地亚"; -"enum.browsing_country.name.cuba" = "古巴"; -"enum.browsing_country.name.curacao" = "库拉索"; -"enum.browsing_country.name.cyprus" = "塞浦路斯"; -"enum.browsing_country.name.czech_republic" = "捷克"; -"enum.browsing_country.name.denmark" = "丹麦"; -"enum.browsing_country.name.djibouti" = "吉布提"; -"enum.browsing_country.name.dominica" = "多米尼克"; -"enum.browsing_country.name.dominican_republic" = "多米尼加"; -"enum.browsing_country.name.ecuador" = "厄瓜多尔"; -"enum.browsing_country.name.egypt" = "埃及"; -"enum.browsing_country.name.el_salvador" = "萨尔瓦多"; -"enum.browsing_country.name.equatorial_guinea" = "赤道几内亚"; -"enum.browsing_country.name.eritrea" = "厄立特里亚"; -"enum.browsing_country.name.estonia" = "爱沙尼亚"; -"enum.browsing_country.name.ethiopia" = "埃塞俄比亚"; -"enum.browsing_country.name.europe" = "欧洲"; -"enum.browsing_country.name.falkland_islands" = "福克兰群岛"; -"enum.browsing_country.name.faroe_islands" = "法罗群岛"; -"enum.browsing_country.name.fiji" = "斐济"; -"enum.browsing_country.name.finland" = "芬兰"; -"enum.browsing_country.name.france" = "法国"; -"enum.browsing_country.name.french_guiana" = "法属圭亚那"; -"enum.browsing_country.name.french_polynesia" = "法属波利尼西亚"; -"enum.browsing_country.name.french_southern_territories" = "法属南部和南极领地"; -"enum.browsing_country.name.gabon" = "加蓬"; -"enum.browsing_country.name.gambia" = "冈比亚"; -"enum.browsing_country.name.georgia" = "格鲁吉亚"; -"enum.browsing_country.name.germany" = "德国"; -"enum.browsing_country.name.ghana" = "加纳"; -"enum.browsing_country.name.gibraltar" = "直布罗陀"; -"enum.browsing_country.name.greece" = "希腊"; -"enum.browsing_country.name.greenland" = "格陵兰"; -"enum.browsing_country.name.grenada" = "格林纳达"; -"enum.browsing_country.name.guadeloupe" = "瓜德罗普"; -"enum.browsing_country.name.guam" = "关岛"; -"enum.browsing_country.name.guatemala" = "危地马拉"; -"enum.browsing_country.name.guernsey" = "根西"; -"enum.browsing_country.name.guinea" = "几内亚"; -"enum.browsing_country.name.guinea_bissau" = "几内亚比绍"; -"enum.browsing_country.name.guyana" = "圭亚那"; -"enum.browsing_country.name.haiti" = "海地"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "赫德岛和麦克唐纳群岛"; -"enum.browsing_country.name.vatican_city_state" = "梵蒂冈城国"; -"enum.browsing_country.name.honduras" = "洪都拉斯"; -"enum.browsing_country.name.hong_kong" = "香港"; -"enum.browsing_country.name.hungary" = "匈牙利"; -"enum.browsing_country.name.iceland" = "冰岛"; -"enum.browsing_country.name.india" = "印度"; -"enum.browsing_country.name.indonesia" = "印度尼西亚"; -"enum.browsing_country.name.iran" = "伊朗"; -"enum.browsing_country.name.iraq" = "伊拉克"; -"enum.browsing_country.name.ireland" = "爱尔兰"; -"enum.browsing_country.name.isle_of_man" = "曼岛"; -"enum.browsing_country.name.israel" = "以色列"; -"enum.browsing_country.name.italy" = "意大利"; -"enum.browsing_country.name.jamaica" = "牙买加"; -"enum.browsing_country.name.japan" = "日本"; -"enum.browsing_country.name.jersey" = "泽西"; -"enum.browsing_country.name.jordan" = "约旦"; -"enum.browsing_country.name.kazakhstan" = "哈萨克斯坦"; -"enum.browsing_country.name.kenya" = "肯尼亚"; -"enum.browsing_country.name.kiribati" = "基里巴斯"; -"enum.browsing_country.name.kuwait" = "科威特"; -"enum.browsing_country.name.kyrgyzstan" = "吉尔吉斯斯坦"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "老挝"; -"enum.browsing_country.name.latvia" = "拉脱维亚"; -"enum.browsing_country.name.lebanon" = "黎巴嫩"; -"enum.browsing_country.name.lesotho" = "莱索托"; -"enum.browsing_country.name.liberia" = "利比里亚"; -"enum.browsing_country.name.libya" = "利比亚"; -"enum.browsing_country.name.liechtenstein" = "列支敦士登"; -"enum.browsing_country.name.lithuania" = "立陶宛"; -"enum.browsing_country.name.luxembourg" = "卢森堡"; -"enum.browsing_country.name.macau" = "澳门"; -"enum.browsing_country.name.macedonia" = "马其顿"; -"enum.browsing_country.name.madagascar" = "马达加斯加"; -"enum.browsing_country.name.malawi" = "马拉维"; -"enum.browsing_country.name.malaysia" = "马来西亚"; -"enum.browsing_country.name.maldives" = "马尔代夫"; -"enum.browsing_country.name.mali" = "马里"; -"enum.browsing_country.name.malta" = "马耳他"; -"enum.browsing_country.name.marshall_islands" = "马绍尔群岛"; -"enum.browsing_country.name.martinique" = "马提尼克"; -"enum.browsing_country.name.mauritania" = "毛里塔尼亚"; -"enum.browsing_country.name.mauritius" = "模里西斯"; -"enum.browsing_country.name.mayotte" = "马约特"; -"enum.browsing_country.name.mexico" = "墨西哥"; -"enum.browsing_country.name.micronesia" = "密克罗尼西亚"; -"enum.browsing_country.name.moldova" = "摩尔多瓦"; -"enum.browsing_country.name.monaco" = "摩纳哥"; -"enum.browsing_country.name.mongolia" = "蒙古"; -"enum.browsing_country.name.montenegro" = "黑山"; -"enum.browsing_country.name.montserrat" = "蒙塞拉特岛"; -"enum.browsing_country.name.morocco" = "摩洛哥"; -"enum.browsing_country.name.mozambique" = "莫桑比克"; -"enum.browsing_country.name.myanmar" = "缅甸"; -"enum.browsing_country.name.namibia" = "纳米比亚"; -"enum.browsing_country.name.nauru" = "诺鲁"; -"enum.browsing_country.name.nepal" = "尼泊尔"; -"enum.browsing_country.name.netherlands" = "荷兰"; -"enum.browsing_country.name.new_caledonia" = "新喀里多尼亚"; -"enum.browsing_country.name.new_zealand" = "新西兰"; -"enum.browsing_country.name.nicaragua" = "尼加拉瓜"; -"enum.browsing_country.name.niger" = "尼日尔"; -"enum.browsing_country.name.nigeria" = "尼日利亚"; -"enum.browsing_country.name.niue" = "纽埃"; -"enum.browsing_country.name.norfolk_island" = "诺福克岛"; -"enum.browsing_country.name.north_korea" = "朝鲜"; -"enum.browsing_country.name.northern_mariana_islands" = "北马里亚纳群岛"; -"enum.browsing_country.name.norway" = "挪威"; -"enum.browsing_country.name.oman" = "阿曼"; -"enum.browsing_country.name.pakistan" = "巴基斯坦"; -"enum.browsing_country.name.palau" = "帛琉"; -"enum.browsing_country.name.palestinian_territory" = "巴勒斯坦"; -"enum.browsing_country.name.panama" = "巴拿马"; -"enum.browsing_country.name.papua_new_guinea" = "巴布亚新几内亚"; -"enum.browsing_country.name.paraguay" = "巴拉圭"; -"enum.browsing_country.name.peru" = "秘鲁"; -"enum.browsing_country.name.philippines" = "菲律宾"; -"enum.browsing_country.name.pitcairn_islands" = "皮特凯恩群岛"; -"enum.browsing_country.name.poland" = "波兰"; -"enum.browsing_country.name.portugal" = "葡萄牙"; -"enum.browsing_country.name.puerto_rico" = "波多黎各"; -"enum.browsing_country.name.qatar" = "卡塔尔"; -"enum.browsing_country.name.reunion" = "留尼汪"; -"enum.browsing_country.name.romania" = ""; -"enum.browsing_country.name.russian_federation" = "俄罗斯"; -"enum.browsing_country.name.rwanda" = "卢旺达"; -"enum.browsing_country.name.saint_barthelemy" = "圣巴泰勒米"; -"enum.browsing_country.name.saint_helena" = "圣赫勒拿"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "圣基茨岛"; -"enum.browsing_country.name.saint_lucia" = "圣卢西亚"; -"enum.browsing_country.name.saint_martin" = "圣马丁岛"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "圣皮埃尔和密克隆"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "圣文森特和格林纳丁斯"; -"enum.browsing_country.name.samoa" = "萨摩亚"; -"enum.browsing_country.name.san_marino" = "圣马力诺"; -"enum.browsing_country.name.sao_tome_and_principe" = "圣多美和普林西比"; -"enum.browsing_country.name.saudi_arabia" = "沙地阿拉伯"; -"enum.browsing_country.name.senegal" = "塞内加尔"; -"enum.browsing_country.name.serbia" = "塞尔维亚"; -"enum.browsing_country.name.seychelles" = "塞舌尔"; -"enum.browsing_country.name.sierra_leone" = "塞拉利昂"; -"enum.browsing_country.name.singapore" = "新加坡"; -"enum.browsing_country.name.sint_maarten" = "圣马丁岛"; -"enum.browsing_country.name.slovakia" = "斯洛伐克"; -"enum.browsing_country.name.slovenia" = "斯洛文尼亚"; -"enum.browsing_country.name.solomon_islands" = "所罗门群岛"; -"enum.browsing_country.name.somalia" = "索马里"; -"enum.browsing_country.name.south_africa" = "南非"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "南乔治亚和南桑威奇群岛"; -"enum.browsing_country.name.south_korea" = "韩国"; -"enum.browsing_country.name.south_sudan" = "南苏丹"; -"enum.browsing_country.name.spain" = "西班牙"; -"enum.browsing_country.name.sri_lanka" = "斯里兰卡"; -"enum.browsing_country.name.sudan" = "苏丹"; -"enum.browsing_country.name.suriname" = "苏里南"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "斯瓦尔巴和扬马延"; -"enum.browsing_country.name.swaziland" = "史瓦帝尼"; -"enum.browsing_country.name.sweden" = "瑞典"; -"enum.browsing_country.name.switzerland" = "瑞士"; -"enum.browsing_country.name.syrian_arab_republic" = "叙利亚"; -"enum.browsing_country.name.taiwan" = "台湾"; -"enum.browsing_country.name.tajikistan" = "塔吉克斯坦"; -"enum.browsing_country.name.tanzania" = "坦桑尼亚"; -"enum.browsing_country.name.thailand" = "泰国"; -"enum.browsing_country.name.timor_leste" = "东帝汶"; -"enum.browsing_country.name.togo" = "多哥"; -"enum.browsing_country.name.tokelau" = "托克劳"; -"enum.browsing_country.name.tonga" = "汤加"; -"enum.browsing_country.name.trinidad_and_tobago" = "特立尼达和多巴哥"; -"enum.browsing_country.name.tunisia" = "突尼斯"; -"enum.browsing_country.name.turkey" = "土耳其"; -"enum.browsing_country.name.turkmenistan" = "土库曼斯坦"; -"enum.browsing_country.name.turks_and_caicos_islands" = "特克斯和凯科斯群岛"; -"enum.browsing_country.name.tuvalu" = "图瓦卢"; -"enum.browsing_country.name.uganda" = "乌干达"; -"enum.browsing_country.name.ukraine" = "乌克兰"; -"enum.browsing_country.name.united_arab_emirates" = "阿拉伯联合酋长国"; -"enum.browsing_country.name.united_kingdom" = "英国"; -"enum.browsing_country.name.united_states" = "美国"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "美国本土外小岛屿"; -"enum.browsing_country.name.uruguay" = "乌拉圭"; -"enum.browsing_country.name.uzbekistan" = "乌兹别克斯坦"; -"enum.browsing_country.name.vanuatu" = "瓦努阿图"; -"enum.browsing_country.name.venezuela" = "委內瑞拉"; -"enum.browsing_country.name.vietnam" = "越南"; -"enum.browsing_country.name.virgin_islands_british" = "英属维尔京群岛"; -"enum.browsing_country.name.virgin_islands_US" = "美属维尔京群岛"; -"enum.browsing_country.name.wallis_and_futuna" = "瓦利斯和富图纳"; -"enum.browsing_country.name.western_sahara" = "西撒哈拉"; -"enum.browsing_country.name.yemen" = "也门"; -"enum.browsing_country.name.zambia" = "赞比亚"; -"enum.browsing_country.name.zimbabwe" = "津巴布韦"; +"browsing_country.auto_detect" = "自动检测"; +"browsing_country.afghanistan" = "阿富汗"; +"browsing_country.aland_islands" = "奥兰群岛"; +"browsing_country.albania" = "阿尔巴尼亚"; +"browsing_country.algeria" = "阿尔及利亚"; +"browsing_country.american_samoa" = "美属萨摩亚"; +"browsing_country.andorra" = "安道尔"; +"browsing_country.angola" = "安哥拉"; +"browsing_country.anguilla" = "安圭拉"; +"browsing_country.antarctica" = "南极洲"; +"browsing_country.antigua_and_barbuda" = "安提瓜和巴布达"; +"browsing_country.argentina" = "阿根廷"; +"browsing_country.armenia" = "亚美尼亚"; +"browsing_country.aruba" = "阿鲁巴"; +"browsing_country.asia_pacific_region" = "亚太地区"; +"browsing_country.australia" = "澳大利亚"; +"browsing_country.austria" = "奥地利"; +"browsing_country.azerbaijan" = "阿塞拜疆"; +"browsing_country.bahamas" = "巴哈马"; +"browsing_country.bahrain" = "巴林"; +"browsing_country.bangladesh" = "孟加拉国"; +"browsing_country.barbados" = "巴巴多斯"; +"browsing_country.belarus" = "白俄罗斯"; +"browsing_country.belgium" = "比利时"; +"browsing_country.belize" = "伯利兹"; +"browsing_country.benin" = "贝宁"; +"browsing_country.bermuda" = "百慕大"; +"browsing_country.bhutan" = "不丹"; +"browsing_country.bolivia" = "玻利维亚"; +"browsing_country.bonaire_saint_eustatius_and_saba" = "博奈尔、圣尤斯特歇斯与萨巴"; +"browsing_country.bosnia_and_herzegovina" = "波斯尼亚和黑塞哥维那"; +"browsing_country.botswana" = "博茨瓦纳"; +"browsing_country.bouvet_island" = "布韦岛"; +"browsing_country.brazil" = "巴西"; +"browsing_country.british_indian_ocean_territory" = "英属印度洋领地"; +"browsing_country.brunei_darussalam" = "文莱"; +"browsing_country.bulgaria" = "保加利亚"; +"browsing_country.burkina_faso" = "布基纳法索"; +"browsing_country.burundi" = "蒲隆地"; +"browsing_country.cambodia" = "柬埔寨"; +"browsing_country.cameroon" = "喀麦隆"; +"browsing_country.canada" = "加拿大"; +"browsing_country.cape_verde" = "佛得角"; +"browsing_country.cayman_islands" = "开曼群岛"; +"browsing_country.central_african_republic" = "中非"; +"browsing_country.chad" = "乍得"; +"browsing_country.chile" = "智利"; +"browsing_country.china" = "中华人民共和国"; +"browsing_country.christmas_island" = "圣诞岛"; +"browsing_country.cocos_islands" = "科科斯岛"; +"browsing_country.colombia" = "哥伦比亚"; +"browsing_country.comoros" = "科摩罗"; +"browsing_country.congo" = "刚果共和国"; +"browsing_country.the_democratic_republic_of_the_congo" = "刚果民主共和国"; +"browsing_country.cook_islands" = "库克群岛"; +"browsing_country.costa_rica" = "哥斯达黎加"; +"browsing_country.cote_d_ivoire" = "科特迪瓦"; +"browsing_country.croatia" = "克罗地亚"; +"browsing_country.cuba" = "古巴"; +"browsing_country.curacao" = "库拉索"; +"browsing_country.cyprus" = "塞浦路斯"; +"browsing_country.czech_republic" = "捷克"; +"browsing_country.denmark" = "丹麦"; +"browsing_country.djibouti" = "吉布提"; +"browsing_country.dominica" = "多米尼克"; +"browsing_country.dominican_republic" = "多米尼加"; +"browsing_country.ecuador" = "厄瓜多尔"; +"browsing_country.egypt" = "埃及"; +"browsing_country.el_salvador" = "萨尔瓦多"; +"browsing_country.equatorial_guinea" = "赤道几内亚"; +"browsing_country.eritrea" = "厄立特里亚"; +"browsing_country.estonia" = "爱沙尼亚"; +"browsing_country.ethiopia" = "埃塞俄比亚"; +"browsing_country.europe" = "欧洲"; +"browsing_country.falkland_islands" = "福克兰群岛"; +"browsing_country.faroe_islands" = "法罗群岛"; +"browsing_country.fiji" = "斐济"; +"browsing_country.finland" = "芬兰"; +"browsing_country.france" = "法国"; +"browsing_country.french_guiana" = "法属圭亚那"; +"browsing_country.french_polynesia" = "法属波利尼西亚"; +"browsing_country.french_southern_territories" = "法属南部和南极领地"; +"browsing_country.gabon" = "加蓬"; +"browsing_country.gambia" = "冈比亚"; +"browsing_country.georgia" = "格鲁吉亚"; +"browsing_country.germany" = "德国"; +"browsing_country.ghana" = "加纳"; +"browsing_country.gibraltar" = "直布罗陀"; +"browsing_country.greece" = "希腊"; +"browsing_country.greenland" = "格陵兰"; +"browsing_country.grenada" = "格林纳达"; +"browsing_country.guadeloupe" = "瓜德罗普"; +"browsing_country.guam" = "关岛"; +"browsing_country.guatemala" = "危地马拉"; +"browsing_country.guernsey" = "根西"; +"browsing_country.guinea" = "几内亚"; +"browsing_country.guinea_bissau" = "几内亚比绍"; +"browsing_country.guyana" = "圭亚那"; +"browsing_country.haiti" = "海地"; +"browsing_country.heard_island_and_mc_donald_islands" = "赫德岛和麦克唐纳群岛"; +"browsing_country.vatican_city_state" = "梵蒂冈城国"; +"browsing_country.honduras" = "洪都拉斯"; +"browsing_country.hong_kong" = "香港"; +"browsing_country.hungary" = "匈牙利"; +"browsing_country.iceland" = "冰岛"; +"browsing_country.india" = "印度"; +"browsing_country.indonesia" = "印度尼西亚"; +"browsing_country.iran" = "伊朗"; +"browsing_country.iraq" = "伊拉克"; +"browsing_country.ireland" = "爱尔兰"; +"browsing_country.isle_of_man" = "曼岛"; +"browsing_country.israel" = "以色列"; +"browsing_country.italy" = "意大利"; +"browsing_country.jamaica" = "牙买加"; +"browsing_country.japan" = "日本"; +"browsing_country.jersey" = "泽西"; +"browsing_country.jordan" = "约旦"; +"browsing_country.kazakhstan" = "哈萨克斯坦"; +"browsing_country.kenya" = "肯尼亚"; +"browsing_country.kiribati" = "基里巴斯"; +"browsing_country.kuwait" = "科威特"; +"browsing_country.kyrgyzstan" = "吉尔吉斯斯坦"; +"browsing_country.lao_peoples_democratic_republic" = "老挝"; +"browsing_country.latvia" = "拉脱维亚"; +"browsing_country.lebanon" = "黎巴嫩"; +"browsing_country.lesotho" = "莱索托"; +"browsing_country.liberia" = "利比里亚"; +"browsing_country.libya" = "利比亚"; +"browsing_country.liechtenstein" = "列支敦士登"; +"browsing_country.lithuania" = "立陶宛"; +"browsing_country.luxembourg" = "卢森堡"; +"browsing_country.macau" = "澳门"; +"browsing_country.macedonia" = "马其顿"; +"browsing_country.madagascar" = "马达加斯加"; +"browsing_country.malawi" = "马拉维"; +"browsing_country.malaysia" = "马来西亚"; +"browsing_country.maldives" = "马尔代夫"; +"browsing_country.mali" = "马里"; +"browsing_country.malta" = "马耳他"; +"browsing_country.marshall_islands" = "马绍尔群岛"; +"browsing_country.martinique" = "马提尼克"; +"browsing_country.mauritania" = "毛里塔尼亚"; +"browsing_country.mauritius" = "模里西斯"; +"browsing_country.mayotte" = "马约特"; +"browsing_country.mexico" = "墨西哥"; +"browsing_country.micronesia" = "密克罗尼西亚"; +"browsing_country.moldova" = "摩尔多瓦"; +"browsing_country.monaco" = "摩纳哥"; +"browsing_country.mongolia" = "蒙古"; +"browsing_country.montenegro" = "黑山"; +"browsing_country.montserrat" = "蒙塞拉特岛"; +"browsing_country.morocco" = "摩洛哥"; +"browsing_country.mozambique" = "莫桑比克"; +"browsing_country.myanmar" = "缅甸"; +"browsing_country.namibia" = "纳米比亚"; +"browsing_country.nauru" = "诺鲁"; +"browsing_country.nepal" = "尼泊尔"; +"browsing_country.netherlands" = "荷兰"; +"browsing_country.new_caledonia" = "新喀里多尼亚"; +"browsing_country.new_zealand" = "新西兰"; +"browsing_country.nicaragua" = "尼加拉瓜"; +"browsing_country.niger" = "尼日尔"; +"browsing_country.nigeria" = "尼日利亚"; +"browsing_country.niue" = "纽埃"; +"browsing_country.norfolk_island" = "诺福克岛"; +"browsing_country.north_korea" = "朝鲜"; +"browsing_country.northern_mariana_islands" = "北马里亚纳群岛"; +"browsing_country.norway" = "挪威"; +"browsing_country.oman" = "阿曼"; +"browsing_country.pakistan" = "巴基斯坦"; +"browsing_country.palau" = "帛琉"; +"browsing_country.palestinian_territory" = "巴勒斯坦"; +"browsing_country.panama" = "巴拿马"; +"browsing_country.papua_new_guinea" = "巴布亚新几内亚"; +"browsing_country.paraguay" = "巴拉圭"; +"browsing_country.peru" = "秘鲁"; +"browsing_country.philippines" = "菲律宾"; +"browsing_country.pitcairn_islands" = "皮特凯恩群岛"; +"browsing_country.poland" = "波兰"; +"browsing_country.portugal" = "葡萄牙"; +"browsing_country.puerto_rico" = "波多黎各"; +"browsing_country.qatar" = "卡塔尔"; +"browsing_country.reunion" = "留尼汪"; +"browsing_country.romania" = ""; +"browsing_country.russian_federation" = "俄罗斯"; +"browsing_country.rwanda" = "卢旺达"; +"browsing_country.saint_barthelemy" = "圣巴泰勒米"; +"browsing_country.saint_helena" = "圣赫勒拿"; +"browsing_country.saint_kitts_and_nevis" = "圣基茨岛"; +"browsing_country.saint_lucia" = "圣卢西亚"; +"browsing_country.saint_martin" = "圣马丁岛"; +"browsing_country.saint_pierre_and_miquelon" = "圣皮埃尔和密克隆"; +"browsing_country.saint_vincent_and_the_grenadines" = "圣文森特和格林纳丁斯"; +"browsing_country.samoa" = "萨摩亚"; +"browsing_country.san_marino" = "圣马力诺"; +"browsing_country.sao_tome_and_principe" = "圣多美和普林西比"; +"browsing_country.saudi_arabia" = "沙地阿拉伯"; +"browsing_country.senegal" = "塞内加尔"; +"browsing_country.serbia" = "塞尔维亚"; +"browsing_country.seychelles" = "塞舌尔"; +"browsing_country.sierra_leone" = "塞拉利昂"; +"browsing_country.singapore" = "新加坡"; +"browsing_country.sint_maarten" = "圣马丁岛"; +"browsing_country.slovakia" = "斯洛伐克"; +"browsing_country.slovenia" = "斯洛文尼亚"; +"browsing_country.solomon_islands" = "所罗门群岛"; +"browsing_country.somalia" = "索马里"; +"browsing_country.south_africa" = "南非"; +"browsing_country.south_georgia_and_the_south_sandwich_islands" = "南乔治亚和南桑威奇群岛"; +"browsing_country.south_korea" = "韩国"; +"browsing_country.south_sudan" = "南苏丹"; +"browsing_country.spain" = "西班牙"; +"browsing_country.sri_lanka" = "斯里兰卡"; +"browsing_country.sudan" = "苏丹"; +"browsing_country.suriname" = "苏里南"; +"browsing_country.svalbard_and_jan_mayen" = "斯瓦尔巴和扬马延"; +"browsing_country.swaziland" = "史瓦帝尼"; +"browsing_country.sweden" = "瑞典"; +"browsing_country.switzerland" = "瑞士"; +"browsing_country.syrian_arab_republic" = "叙利亚"; +"browsing_country.taiwan" = "台湾"; +"browsing_country.tajikistan" = "塔吉克斯坦"; +"browsing_country.tanzania" = "坦桑尼亚"; +"browsing_country.thailand" = "泰国"; +"browsing_country.timor_leste" = "东帝汶"; +"browsing_country.togo" = "多哥"; +"browsing_country.tokelau" = "托克劳"; +"browsing_country.tonga" = "汤加"; +"browsing_country.trinidad_and_tobago" = "特立尼达和多巴哥"; +"browsing_country.tunisia" = "突尼斯"; +"browsing_country.turkey" = "土耳其"; +"browsing_country.turkmenistan" = "土库曼斯坦"; +"browsing_country.turks_and_caicos_islands" = "特克斯和凯科斯群岛"; +"browsing_country.tuvalu" = "图瓦卢"; +"browsing_country.uganda" = "乌干达"; +"browsing_country.ukraine" = "乌克兰"; +"browsing_country.united_arab_emirates" = "阿拉伯联合酋长国"; +"browsing_country.united_kingdom" = "英国"; +"browsing_country.united_states" = "美国"; +"browsing_country.united_states_minor_outlying_islands" = "美国本土外小岛屿"; +"browsing_country.uruguay" = "乌拉圭"; +"browsing_country.uzbekistan" = "乌兹别克斯坦"; +"browsing_country.vanuatu" = "瓦努阿图"; +"browsing_country.venezuela" = "委內瑞拉"; +"browsing_country.vietnam" = "越南"; +"browsing_country.virgin_islands_british" = "英属维尔京群岛"; +"browsing_country.virgin_islands_US" = "美属维尔京群岛"; +"browsing_country.wallis_and_futuna" = "瓦利斯和富图纳"; +"browsing_country.western_sahara" = "西撒哈拉"; +"browsing_country.yemen" = "也门"; +"browsing_country.zambia" = "赞比亚"; +"browsing_country.zimbabwe" = "津巴布韦"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings deleted file mode 100644 index 81a98df3a..000000000 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-HK.lproj/Localizable.strings +++ /dev/null @@ -1,1051 +0,0 @@ -// MARK: BanInterval -"enum.ban_interval.description.and" = ""; - -// MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "昨天"; -"enum.toplists_type.value.past_month" = "上個月"; -"enum.toplists_type.value.past_year" = "去年"; -"enum.toplists_type.value.all_time" = "所有時間"; - -// MARK: Response -"website.response.hath_client_not_found" = "你需要一個連結到帳號的 H@H 用戶端才能使用這個功能"; -"website.response.hath_client_not_online" = "你的 H@H 用戶端為離線狀態,請啟動後再試"; -"website.response.invalid_resolution" = "該畫廊不能以目前選擇的解像度下載"; - -// MARK: Toast -"toast.title.error" = "錯誤"; -"toast.title.success" = "成功"; -"toast.title.loading" = "載入中..."; -"toast.title.communicating" = "連線中..."; -"toast.caption.copied_to_clipboard" = "已複製到剪貼簿"; -"toast.caption.saved_to_photo_library" = "已儲存到照片"; - -// MARK: AutoLock -"local_authorization.reason" = "由於超過 APP 自動鎖定期限,APP 已被鎖定,請重新解鎖"; - -// MARK: Common value -"common.value.stars" = "%@ 星"; -"common.value.pages" = "%@ 頁"; -"common.value.times" = "%@ 次"; -"common.value.day" = "%@ 天"; -"common.value.days" = "%@ 天"; -"common.value.hour" = "%@ 小時"; -"common.value.hours" = "%@ 小時"; -"common.value.minute" = "%@ 分"; -"common.value.minutes" = "%@ 分"; -"common.value.second" = "%@ 秒"; -"common.value.seconds" = "%@ 秒"; -"common.value.records" = "%@ 筆紀錄"; - -// MARK: Common button -"common.button.cancel" = "取消"; - -// MARK: TabItem -"tab_item.title.home" = "總覽"; -"tab_item.title.favorites" = "收藏"; -"tab_item.title.search" = "搜尋"; -"tab_item.title.downloads" = "下載"; -"tab_item.title.setting" = "設定"; - -// MARK: ToolbarItem -"toolbar_item.button.filters" = "過濾"; -"toolbar_item.button.jump_page" = "跳到..."; -"toolbar_item.button.date_seek" = "日期定位"; -"toolbar_item.button.quick_search" = "快速搜尋"; - -// MARK: DateSeek -"date_seek_view.title.date_seek" = "日期定位"; -"date_seek_view.title.date" = "日期"; -"date_seek_view.footer.seek_around_date" = "前往所選日期附近的畫廊。"; -"date_seek_view.button.seek_newer" = "較新"; -"date_seek_view.button.seek_older" = "較舊"; -// MARK: JumpPage -"jump_page_view.title.jump_page" = "跳到..."; -"jump_page_view.description.jump_page" = "請輸入 1 到 %d 之間的頁碼。"; -"jump_page_view.button.confirm" = "確定"; - -// MARK: AlertView -"loading_view.title.loading" = "載入中..."; -"loading_view.title.preparing_database" = "正在準備..."; -"not_login_view.title.need_login" = "你需要登入才能完成這個動作"; -"not_login_view.button.login" = "登入"; -"error_view.button.retry" = "重試"; -"error_view.button.drop_database" = "刪除資料庫"; -"error_view.title.try_later" = "請稍後再試"; -"error_view.title.network" = "網絡發生故障,請檢查網絡狀態"; -"error_view.title.parsing" = "網頁解析器發生故障"; -"error_view.title.unknown" = "發生不明錯誤"; -"error_view.title.not_found" = "這邊看起來空空如也"; -"error_view.title.database_corrupted" = "資料庫已經損壞\n請提交 issue 至 GitHub."; -"error_view.title.ip_banned" = "你的 IP 因為頁面載入次數過於頻繁而被暫時禁止存取,這可能是因為你正在使用爬蟲/鏡像軟件,禁止存取將在 %@ 後解除"; -"error_view.title.copyright_claim" = "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取"; -"error_view.title.gallery_unavailable" = "此畫廊已被刪除或你沒有權限存取"; - -// MARK: AppError -"app_error.localized_description.database_corrupted" = "資料庫損壞"; -"app_error.localized_description.copyright_claim" = "版權聲明"; -"app_error.localized_description.ip_banned" = "IP 已封禁"; -"app_error.localized_description.gallery_expunged" = "畫廊已刪除"; -"app_error.localized_description.network_error" = "網絡錯誤"; -"app_error.localized_description.web_image_loading_error" = "網頁圖片載入錯誤"; -"app_error.localized_description.parse_error" = "解析錯誤"; -"app_error.localized_description.quota_exceeded" = "流量額度已用盡"; -"app_error.localized_description.authentication_required" = "需要登入"; -"app_error.localized_description.file_operation_failed" = "檔案操作失敗"; -"app_error.localized_description.no_updates_available" = "沒有可用更新"; -"app_error.localized_description.not_found" = "未找到"; -"app_error.localized_description.unknown_error" = "未知錯誤"; -"app_error.alert.quota_exceeded" = "圖片流量額度已用盡。\n請稍後再試。"; -"app_error.alert.authentication_required" = "存取此下載內容需要登入。"; -"app_error.alert.local_file_operation_failed" = "本機檔案操作失敗。"; - -// MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?"; -"confirmation_dialog.title.remove_custom_translations" = "是否確定要刪除所有自訂翻譯?"; -"confirmation_dialog.title.logout" = "確定要登出嗎?"; -"confirmation_dialog.title.delete" = "確定要刪除?"; -"confirmation_dialog.title.clear" = "確定要清空嗎?"; -"confirmation_dialog.title.reset" = "確定要重設嗎?"; -"confirmation_dialog.button.drop_database" = "刪除資料庫"; -"confirmation_dialog.button.remove" = "移除"; -"confirmation_dialog.button.logout" = "登出"; -"confirmation_dialog.button.delete" = "刪除"; -"confirmation_dialog.button.clear" = "清空"; -"confirmation_dialog.button.reset" = "重設"; - -// MARK: SubSection -"sub_section.button.show_all" = "顯示全部"; - -// MARK: NewDawnView -"new_dawn_view.title.first" = "現在是嶄新的一天!"; -"new_dawn_view.title.second" = "回顧到目前為止的旅程,你發現自己睿智了一點。"; -// Greeting -"struct.greeting.mark.start" = "你獲得了 "; -"struct.greeting.mark.separator" = "、"; -"struct.greeting.mark.and" = " 和 "; -"struct.greeting.mark.end" = "!"; - -// MARK: HomeView -"home_view.title.home" = "總覽"; -"home_view.section.title.frontpage" = "首頁"; -"home_view.section.title.toplists" = "排行"; -"home_view.section.title.other" = "其他"; -// HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "熱門"; -"enum.home_misc_grid_type.title.watched" = "關注"; -"enum.home_misc_grid_type.title.history" = "歷程"; - -// MARK: FrontpageView -"frontpage_view.title.frontpage" = "首頁"; - -// MARK: ToplistsView -"toplists_view.title.toplists" = "排行"; - -// MARK: PopularView -"popular_view.title.popular" = "熱門"; - -// MARK: WatchedView -"watched_view.title.watched" = "關注"; - -// MARK: HistoryView -"history_view.title.history" = "歷程"; - -// MARK: FavoritesView -"favorites_view.title.favorites" = "收藏"; -// FavoriteCategory -"struct.user.favorite_category.default" = "收藏匣 %@"; -"struct.user.favorite_category.all" = "全部"; - -// MARK: SearchView -"search_view.title.search" = "搜尋"; -"search_view.section.title.recently_searched" = "最近搜尋"; -"search_view.section.title.recently_seen" = "最近閱讀"; -"search_view.section.title.quick_search" = "快速搜尋"; -// Searchable -"searchable.prompt.filter" = "過濾"; -"searchable.title.matches_count" = "找到 %d 項結果"; - -// MARK: QuickSearchView -"quick_search_view.title.quick_search" = "快速搜尋"; -"quick_search_view.title.edit_word" = "編輯關鍵字"; -"quick_search_view.title.new_word" = "新關鍵字"; -"quick_search_view.title.content" = "搜尋內容"; -"quick_search_view.title.name" = "名稱"; -"quick_search_view.placeholder.optional" = "(可選)"; - -// MARK: SettingView -"setting_view.title.setting" = "設定"; -// SettingStateRoute -"enum.setting_state_route.value.account" = "帳號"; -"enum.setting_state_route.value.general" = "一般"; -"enum.setting_state_route.value.appearance" = "外觀"; -"enum.setting_state_route.value.reading" = "閱讀"; -"enum.setting_state_route.value.download" = "下載"; -"enum.setting_state_route.value.laboratory" = "實驗性功能"; -"enum.setting_state_route.value.about" = "關於"; - -// MARK: AccountSettingView -"account_setting_view.title.account" = "帳號設定"; -"account_setting_view.title.shows_new_dawn_greeting" = "顯示黎明問候"; -"account_setting_view.button.login" = "登入"; -"account_setting_view.button.logout" = "登出"; -"account_setting_view.button.account_configuration" = "帳號設定"; -"account_setting_view.button.tags_management" = "管理訂閱標籤"; -"account_setting_view.button.copy_cookies" = "複製 Cookies"; -// CookieValue -"struct.cookie_value.localized_string.expired" = "已過期"; -"struct.cookie_value.localized_string.mystery" = "被拒絕"; -"struct.cookie_value.localized_string.none" = "None"; - -// MARK: LoginView -"login_view.title.login" = "登入"; -"login_view.title.username" = "Username"; -"login_view.title.password" = "Password"; - -// MARK: GeneralSettingView -"general_setting_view.title.general" = "一般設定"; -"general_setting_view.title.language" = "語言"; -"general_setting_view.title.auto_lock" = "自動鎖定"; -"general_setting_view.title.enables_tags_extension" = "啟用自訂標籤擴充功能"; -"general_setting_view.title.translates_tags" = "標籤翻譯"; -"general_setting_view.title.shows_tags_search_suggestion" = "搜尋時顯示標籤建議"; -"general_setting_view.title.shows_images_in_tags" = "在標籤顯示圖片"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "將連結重新導向至選擇的網站"; -"general_setting_view.title.detects_links_from_clipboard" = "偵測剪貼簿中的連結"; -"general_setting_view.title.background_blur_radius" = "後台背景模糊"; -"general_setting_view.button.app_activity_logs" = "應用程式活動日誌"; -"general_setting_view.button.import_custom_translations" = "匯入自訂標籤翻譯"; -"general_setting_view.button.remove_custom_translations" = "刪除自訂標籤翻譯"; -"general_setting_view.button.clear_image_caches" = "清理圖片快取"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "標籤"; -"general_setting_view.section.title.navigation" = "導覽"; -"general_setting_view.section.title.security" = "安全"; -"general_setting_view.section.title.caches" = "快取"; -// AutoLockPolicy -"enum.auto_lock_policy.value.never" = "永不自動鎖定"; -"enum.auto_lock_policy.value.instantly" = "立刻"; - -// MARK: AppActivityLogsView -"app_activity_logs_view.title" = "應用程式活動日誌"; -"app_activity_logs_view.placeholder.no_logs" = "找不到日誌"; -"app_activity_logs_view.section.current" = "目前"; -"app_activity_logs_view.run" = "運行 %@"; -"app_activity_logs_view.more_logs" = "更多日誌"; -"app_activity_logs_view.open_in_files" = "在「檔案」中開啟"; -"app_activity_logs_view.runs" = "運行"; -"app_activity_logs_view.level.undefined" = "未定義"; -"app_activity_logs_view.level.debug" = "偵錯"; -"app_activity_logs_view.level.info" = "資訊"; -"app_activity_logs_view.level.notice" = "通知"; -"app_activity_logs_view.level.error" = "錯誤"; -"app_activity_logs_view.level.fault" = "故障"; - -// MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "外觀設定"; -"appearance_setting_view.title.theme" = "主題"; -"appearance_setting_view.title.tint_color" = "強調色"; -"appearance_setting_view.title.display_mode" = "顯示模式"; -"appearance_setting_view.title.shows_tags_in_list" = "在列表中顯示標籤"; -"appearance_setting_view.title.maximum_number_of_tags" = "標籤最大顯示數量"; -"appearance_setting_view.title.displays_japanese_title" = "以日文顯示標籤"; -"appearance_setting_view.button.app_icon" = "App 圖案"; -"appearance_setting_view.menu.title.infite" = "無限"; -"appearance_setting_view.section.title.list" = "列表"; -"appearance_setting_view.section.title.gallery" = "畫廊"; -// PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "自動"; -"enum.preferred_color_scheme.value.light" = "淺色"; -"enum.preferred_color_scheme.value.dark" = "深色"; -// AppIconType -"enum.app_icon_type.value.default" = "預設"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; -// ListDisplayMode -"enum.list_display_mode.value.detail" = "詳細"; -"enum.list_display_mode.value.thumbnail" = "縮圖"; - -// MARK: AppIconView -"app_icon_view.title.app_icon" = "App 圖案"; - -// MARK: reading_settingView -"reading_setting_view.title.reading" = "閱讀設定"; -"reading_setting_view.title.direction" = "翻頁方向"; -"reading_setting_view.title.preload_limit" = "預先載入頁數"; -"reading_setting_view.title.enables_landscape" = "啟用橫向顯示"; -"reading_setting_view.title.separator_height" = "頁間分隔高度"; -"reading_setting_view.title.maximum_scale_factor" = "縮放上限"; -"reading_setting_view.title.double_tap_scale_factor" = "雙擊縮放值"; -"reading_setting_view.section.title.appearance" = "外觀"; -// ReadingDirection -"enum.reading_direction.value.vertical" = "垂直"; -"enum.reading_direction.value.right_to_left" = "由右至左滑"; -"enum.reading_direction.value.left_to_right" = "由左至右滑"; - -// MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "實驗性功能"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "繞過 SNI 過濾"; - -// MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "官方網站"; -"about_view.button.altStore_source" = "AltStore source"; -"about_view.title.version" = "版本"; -"about_view.section.title.special_thanks" = "特別銘謝"; -"about_view.section.title.code_level_contributors" = "程式碼貢獻者"; -"about_view.section.title.translation_contributors" = "翻譯貢獻者"; -"about_view.section.title.acknowledgements" = "致謝"; - -// MARK: DetailView -"detail_view.button.download_login" = "登入"; -"detail_view.button.download_get" = "取得"; -"detail_view.button.download_wait" = "等待"; -"detail_view.button.download_done" = "完成"; -"detail_view.button.download_update" = "更新"; -"detail_view.button.download_retry" = "重試"; -"detail_view.button.download_repair" = "修復"; -"detail_view.button.read" = "閱讀"; -"detail_view.button.post_comment" = "發表留言"; -"detail_view.accessibility.download_button.login" = "登入後即可下載"; -"detail_view.accessibility.download_button.download" = "下載"; -"detail_view.accessibility.download_button.queued" = "已加入下載佇列"; -"detail_view.accessibility.download_button.downloading" = "正在下載第 %d / %d 頁"; -"detail_view.accessibility.download_button.downloaded" = "刪除已下載畫廊"; -"detail_view.accessibility.download_button.update" = "更新下載內容"; -"detail_view.accessibility.download_button.retry" = "重新下載"; -"detail_view.accessibility.download_button.repair" = "修復下載檔案"; -"detail_view.accessibility.download_button.preparing" = "正在取得下載資訊"; -"detail_view.accessibility.download_button.pause_action" = "暫停下載"; -"detail_view.accessibility.download_button.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; -"detail_view.accessibility.download_button.partial" = "重新下載,已有 %d / %d 頁可用。"; -"detail_view.toolbar_item.button.archives" = "存檔至 H@H 用戶端"; -"detail_view.toolbar_item.button.torrents" = "種子"; -"detail_view.toolbar_item.button.share" = "分享"; -"detail_view.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "收回評分"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "收藏"; -"detail_view.description_section.title.language" = "語言"; -"detail_view.description_section.title.ratings" = "%@ 個評分"; -"detail_view.description_section.title.page_count" = "頁數"; -"detail_view.description_section.title.file_size" = "檔案大小"; -"detail_view.description_section.description.favorited" = "次"; -"detail_view.description_section.description.page_count" = "頁"; -"detail_view.action_section.button.give_a_rating" = "給予評分"; -"detail_view.action_section.button.similar_gallery" = "類似畫廊"; -"detail_view.section.title.previews" = "預覽"; -"detail_view.section.title.comments" = "留言"; -"detail_view.dialog.title.delete_download" = "刪除下載?"; -"detail_view.dialog.title.repair_download" = "修復下載?"; -"detail_view.dialog.title.update_download" = "更新下載?"; -"detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; -"detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; -"detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; -"detail_view.dialog.message.redownload_gallery" = "現在重新完整下載此畫廊嗎?"; -"detail_view.dialog.button.repair" = "修復"; -"detail_view.dialog.button.update" = "更新"; -"detail_view.dialog.button.redownload" = "重新下載"; -"detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; - -// MARK: ArchivesView -"archives_view.title.archives" = "存檔"; -"archives_view.button.download_to_hath_client" = "下載至 H@H 用戶端"; -// HathArchive -"struct.hath_archive.price.free" = "免費"; -"struct.hath_archive.price.not_available" = "N/A"; -// ArchiveResolution -"enum.archive_resolution.value.original" = "原始畫質"; - -// MARK: TorrentsView -"torrents_view.title.torrents" = "種子"; - -// MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "畫廊資訊"; -"gallery_infos_view.title.id" = "畫廊 ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "標題"; -"gallery_infos_view.title.japanese_title" = "日文標題"; -"gallery_infos_view.title.gallery_URL" = "畫廊 URL"; -"gallery_infos_view.title.cover_URL" = "封面 URL"; -"gallery_infos_view.title.archive_URL" = "存檔 URL"; -"gallery_infos_view.title.torrent_URL" = "種子 URL"; -"gallery_infos_view.title.parent_URL" = "Parent URL"; -"gallery_infos_view.title.category" = "類別"; -"gallery_infos_view.title.uploader" = "上傳者"; -"gallery_infos_view.title.posted_date" = "發佈日期"; -"gallery_infos_view.title.visibility" = "能見度"; -"gallery_infos_view.title.language" = "語言"; -"gallery_infos_view.title.page_count" = "頁數"; -"gallery_infos_view.title.file_size" = "檔案大小"; -"gallery_infos_view.title.favorited_times" = "被收藏次數"; -"gallery_infos_view.title.favorited" = "已收藏"; -"gallery_infos_view.title.rating_count" = "被評分次數"; -"gallery_infos_view.title.average_rating" = "平均評分"; -"gallery_infos_view.title.my_rating" = "我的評分"; -"gallery_infos_view.title.torrent_count" = "種子數量"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "Yes"; -"gallery_infos_view.value.no" = "No"; -// GalleryVisibility -"enum.gallery_visibility.value.yes" = "Yes"; -"enum.gallery_visibility.value.no" = "No (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "已被刪除"; - -// MARK: TagDetailView -"tag_detail_view.section.title.images" = "圖片"; -"tag_detail_view.section.title.links" = "連結"; - -// MARK: DownloadsView -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "下載"; -"downloads_view.search.prompt.downloads" = "搜尋下載"; -"downloads_view.dialog.title.delete_download" = "刪除下載?"; -"downloads_view.dialog.message.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; -"downloads_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"downloads_view.swipe.button.pages" = "頁面"; -"downloads_view.swipe.button.update" = "更新"; -"downloads_view.swipe.button.resume" = "繼續"; -"downloads_view.swipe.button.pause" = "暫停"; -"downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; -"downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; -"downloads_view.button.clear_filters" = "清除篩選"; -"downloads_view.button.validate_image_data" = "驗證圖片資料"; -"downloads_view.inspector.section.actions" = "操作"; -"downloads_view.inspector.section.pages" = "頁面"; -"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; -"downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; -"downloads_view.inspector.button.update_download" = "更新下載"; -"downloads_view.inspector.toast.image_data_valid" = "圖片資料有效"; -"downloads_view.inspector.toast.image_data_unavailable" = "無法驗證圖片資料。"; -"downloads_view.inspector.title.download_status" = "下載狀態"; -"downloads_view.inspector.page.pending" = "等待中"; -"downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; -"downloads_view.inspector.page.title" = "第 %d 頁"; -"downloads_view.inspector.page.none" = "沒有頁面"; -"downloads_view.inspector.status.pending" = "等待中"; -"downloads_view.inspector.status.downloaded" = "已下載"; -"downloads_view.inspector.status.failed" = "失敗"; - -// MARK: DownloadSettingView -"download_setting_view.title" = "下載"; -"download_setting_view.section.title.download_queue" = "下載佇列"; -"download_setting_view.section.title.network" = "網絡"; -"download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; -"download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; -"download_setting_view.title.allow_cellular_downloads" = "允許流動網絡下載"; -"download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許流動網絡下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; - -// MARK: CommentsView -"comments_view.title.comments" = "留言"; - -// MARK: PostCommentView -"post_comment_view.title.post_comment" = "發表留言"; -"post_comment_view.title.edit_comment" = "編輯留言"; - -// MARK: PreviewsView -"previews_view.title.previews" = "預覽"; - -// MARK: ReadingView -"reading_view.context_menu.button.reload" = "重新整理"; -"reading_view.context_menu.button.copy" = "複製"; -"reading_view.context_menu.button.save" = "儲存圖片"; -"reading_view.context_menu.button.save_original" = "儲存原始圖片"; -"reading_view.context_menu.button.share" = "分享"; -"reading_view.toolbar_item.title.auto_play" = "自動播放"; -"reading_view.toolbar_item.title.dual_page_mode" = "雙頁模式"; -"reading_view.toolbar_item.title.except_the_cover" = "封面除外"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "重新載入失敗圖片"; -"reading_view.toolbar_item.button.reload_all_images" = "重新載入所有圖片"; -"reading_view.toolbar_item.button.reading_setting" = "閱讀設定"; -// AutoPlayPolicy -"enum.auto_play_policy.value.off" = "關閉"; - - -// MARK: DownloadBadge -"struct.download_badge.text.queued" = "已排隊"; -"struct.download_badge.text.downloading" = "下載中"; -"struct.download_badge.text.paused" = "已暫停"; -"struct.download_badge.text.downloaded" = "已下載"; -"struct.download_badge.text.needs_attention" = "需處理"; -"struct.download_badge.text.update_available" = "有可更新"; -"struct.download_badge.text.needs_repair" = "需修復"; -"struct.download_badge.progress" = "%d/%d"; - -// MARK: DownloadStore -"download_store.error.asset_unreadable" = "資源檔案無法讀取:%@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "無法解析下載資料夾。"; -"download_store.validation.download_folder_missing" = "下載資料夾缺失。"; -"download_store.validation.manifest_missing" = "Manifest 檔案缺失。"; -"download_store.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; -"download_store.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; -"download_store.validation.cover_image_missing" = "封面圖片缺失。"; -"download_store.validation.page_missing" = "第 %d 頁缺失。"; -"download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; -"download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; - -// MARK: FiltersView -"filters_view.title.filters" = "過濾"; -"filters_view.title.advanced_settings" = "進階選項"; -"filters_view.title.search_gallery_name" = "搜尋畫廊名稱"; -"filters_view.title.search_gallery_tags" = "搜尋畫廊標籤"; -"filters_view.title.search_gallery_description" = "搜尋畫廊描述"; -"filters_view.title.search_torrent_filenames" = "搜尋種子檔案名"; -"filters_view.title.only_show_galleries_with_torrents" = "只顯示有種子的畫廊"; -"filters_view.title.search_low_power_tags" = "搜尋低期望標籤"; -"filters_view.title.search_downvoted_tags" = "搜尋低評價標籤"; -"filters_view.title.search_expunged_galleries" = "顯示被刪除的畫廊"; -"filters_view.title.set_minimum_rating" = "設定評分下限"; -"filters_view.title.minimum_rating" = "評分下限"; -"filters_view.title.set_pages_range" = "設定頁數範圍"; -"filters_view.title.pages_range" = "頁數範圍"; -"filters_view.title.disable_language_filter" = "停用語言篩選"; -"filters_view.title.disable_uploader_filter" = "停用上傳者篩選"; -"filters_view.title.disable_tags_filter" = "停用標籤篩選"; -"filters_view.button.reset_filters" = "重設所有選項"; -"filters_view.section.title.advanced" = "進階"; -"filters_view.section.title.default_filter" = "預設篩選"; -// FilterRange -"enum.filter_range.value.search" = "搜尋"; -"enum.filter_range.value.global" = "全域"; -"enum.filter_range.value.watched" = "標籤"; - -// MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 設定"; -"eh_setting_view.section.title.profile_settings" = "設定檔設定"; -"eh_setting_view.title.selected_profile" = "選擇設定檔"; -"eh_setting_view.button.set_as_default" = "設為預設"; -"eh_setting_view.button.delete_profile" = "刪除設定檔"; -"eh_setting_view.button.rename" = "重新命名"; -"eh_setting_view.button.create_new" = "新增設定檔"; -"eh_setting_view.toolbar_item.button.done" = "完成"; - -"eh_setting_view.section.title.image_load_settings" = "圖片來源設定"; -"eh_setting_view.title.load_images_through_the_hath_network" = "透過 Hath Network 載入圖片"; -"eh_setting_view.title.browsing_country" = "所在國家"; -"eh_setting_view.description.browsing_country" = "你似乎是從 **%@** 瀏覽這個網站或在使用當地的 VPN,這意味着網站將嘗試從這個地理區域的 H@H 用戶端載入圖片。如果這不正確或你出於任何原因想要使用不同的區域(例如你正在透過 VPN 連線),您可以在下面選擇不同的國家/地區。"; -// EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "任何用戶端"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "只使用預設連接埠的用戶端"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "No [現代/Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "No [傳統/Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "建議選項(預設)"; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "如果網絡防火牆會阻擋任何非預設傳出連接埠則使用這個選項(可能較慢)"; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "E-Hentai 贊助者功能: 你將無法同時瀏覽多個頁面,僅在出現重大錯誤時才啟用這個選項"; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "E-Hentai 贊助者功能: 在現代瀏覽器上預設設定可能無法正常工作,僅推薦用於傳統瀏覽器"; - -"eh_setting_view.section.title.image_size_settings" = "圖片尺寸設定"; -"eh_setting_view.title.image_resolution" = "圖片解像度"; -"eh_setting_view.description.image_resolution" = "一般情況下圖片會被縮放成 1280 px 水平解像度以供線上瀏覽,你也可以選擇下列解像度之一。為了避免破壞伺服器正常運作,高於 1280x 的解像度暫時僅提供下列使用者使用: 贊助者、具有任何 Hath Perk 的使用者以及 UID 低於 3,000,000 的使用者"; -"eh_setting_view.title.image_size" = "圖片尺寸"; -"eh_setting_view.description.image_size" = "雖然網站會自動縮小圖片以適應瀏覽裝置的螢幕寬度,但您也可以手動限製圖片的最大顯示尺寸。 像自動縮放一樣,這不會重新採樣圖像,因為調整大小是在瀏覽器完成的 (0 = 沒有限制)"; -"eh_setting_view.title.horizontal" = "水平尺寸(寬)"; -"eh_setting_view.title.vertical" = "垂直尺寸(長)"; -// EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "自動"; - -"eh_setting_view.section.title.gallery_name_display" = "畫廊顯示名稱"; -"eh_setting_view.title.gallery_name" = "畫廊名稱"; -"eh_setting_view.description.gallery_name" = "許多畫廊同時提供了英文/預設標題與日文標題,你想優先顯示哪種畫廊名稱?"; -// EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "預設標題"; -"enum.eh_setting.gallery_name.value.japanese" = "日文標題(若該畫廊支援)"; - -"eh_setting_view.section.title.archiver_settings" = "存檔設定"; -"eh_setting_view.title.archiver_behavior" = "存檔邏輯設定"; -"eh_setting_view.description.archiver_behavior" = "存檔的預設邏輯是確認原始圖片或重新採樣後進行存檔的成本差異與選擇,然後顯示一個可以在其他地方點擊、複製的連結,你可以在此處更改他的運作方式。"; -// EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "手動選擇,手動開始下載(預設)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "手動選擇,自動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "自動選擇原始畫質,,手動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "自動選擇原始畫質並開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "自動選擇重新採樣,手動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "自動選擇重新採樣並開始下載"; - -"eh_setting_view.section.title.front_page_settings" = "首頁設定"; -"eh_setting_view.title.display_mode" = "顯示模式"; -"eh_setting_view.description.display_mode" = "你想在首頁和搜尋結果中使用哪一種顯示方式?"; -"eh_setting_view.section.title.show_search_range_indicator" = "搜尋範圍指示器"; -"eh_setting_view.title.show_search_range_indicator" = "顯示搜尋範圍指示器"; -"eh_setting_view.description.gallery_category" = "預設情況下你希望在首頁和搜尋結果中顯示哪些類別的結果?"; -// EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "緊湊(Compact)"; -"enum.eh_setting.display_mode.value.thumbnail" = "縮圖(Thumbnail)"; -"enum.eh_setting.display_mode.value.extended" = "放大(Extended)"; -"enum.eh_setting.display_mode.value.minimal" = "最小(Minimal)"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "可選用的 UI 元件"; -"eh_setting_view.description.optional_UI_elements" = "一些舊版 UI 元件現已預設停用。您可以在此啟用這些元件。"; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "在畫廊頁面啟用縮圖選擇器"; - -"eh_setting_view.section.title.favorites" = "收藏匣"; -"eh_setting_view.description.favorite_categories" = "在這裏你可以選擇並重新命名收藏匣"; -"eh_setting_view.title.favorites_sort_order" = "收藏匣排序"; -"eh_setting_view.description.favorites_sort_order" = "你還可以在收藏匣頁面上為畫廊選擇預設排列順序。 請注意,在 2016 年 3 月網站改版之前新增的畫廊並不儲存時間印記,並且無論使用何種設定,都將使用畫廊發佈時間作為排序參考。"; -// EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "透過最後更新時間排序"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "透過收藏順序排序"; - -"eh_setting_view.section.title.ratings" = "評分"; -"eh_setting_view.title.ratings_color" = "評分顏色"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "預設情況下,你評分的畫廊將 2 星及以下的評分顯示為紅色星,2.5 ~ 4 顆星的評分為綠色,4.5 ~ 5 顆星的評分為藍色。 透過在下面輸入顏色組合你可以自訂想顯示的顏色。 每個字母各代表一顆星(1~5),預設的 RRGGB 表示第一顆和第二顆星的 R(ed),第三顆和第四顆的 G(reen),第五顆的 B(lue)。 你也可以將 (Y)ellow 用於普通星星。 任何五個字母的 R/G/B/Y 組合都有效"; - -"eh_setting_view.section.title.tag_filtering_threshold" = "過濾標籤閾值"; -"eh_setting_view.title.tag_filtering_threshold" = "閾值"; -"eh_setting_view.description.tag_filtering_threshold" = "你可以透過將標籤新增到具有負數權重的“我的標籤”清單中來過濾標籤。 如果畫廊的標籤加起來的權重低於此值,則會被從列表中過濾掉,此閾值可以設定在 0 ~ -9999 之間"; - -"eh_setting_view.section.title.tag_watching_threshold" = "關注標籤閾值"; -"eh_setting_view.title.tag_watching_threshold" = "關注標籤生效的閾值"; -"eh_setting_view.description.tag_watching_threshold" = "如果最近上傳的畫廊中至少有一個具有正權重的你正在關注/追蹤的標籤,並且這些標籤所具有的權重高於此設定中的數值,那這個畫廊將會出現在「追蹤標籤」頁面中,此閾值可以設定在 0 ~ -9999 之間"; - -"eh_setting_view.section.title.filtered_removal_count" = "顯示過濾結果計數器"; -"eh_setting_view.description.filtered_removal_count" = "顯示 \"Your default filters removed XX galleries from this page\" ?"; -"eh_setting_view.title.show_filtered_removal_count" = "是否顯示過濾結果計數器"; - -"eh_setting_view.section.title.excluded_languages" = "排除語言"; -"eh_setting_view.description.excluded_languages" = "如果你希望從畫廊列表和搜尋中隱藏掉某些語言的畫廊,請從下面的清單中選取它們。請注意,無論你的搜尋查詢如何,相符於篩除規則的畫廊都不會出現。"; -// EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "原始語言"; -"enum.eh_setting.excluded_languages_category.value.translated" = "翻譯語言"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "覆寫"; - -"eh_setting_view.section.title.excluded_uploaders" = "排除的上傳者"; -"eh_setting_view.description.excluded_uploaders" = "如果你希望從畫廊列表和搜尋結果中隱藏某些上傳者的畫廊,請將它們新增到下方,每行輸入一個使用者名稱。請注意,無論你的搜尋結果如何,這些上傳者的畫廊都不會出現。"; -"eh_setting_view.description.excluded_uploaders_count" = "你正在使用 **%@ / %@** 排除欄位"; - -"eh_setting_view.section.title.search_result_count" = "搜尋結果數量上限"; -"eh_setting_view.title.result_count" = "數量上限"; -"eh_setting_view.description.result_count" = "你希望每頁顯示幾個搜尋結果?\n(該功能需要有 Hath Perk: Paging Enlargement)"; - -"eh_setting_view.section.title.thumbnail_settings" = "縮圖設定"; -"eh_setting_view.title.thumbnail_load_timing" = "縮圖載入時機設定"; -"eh_setting_view.description.thumbnail_load_timing" = "使用列表模式時,你希望如何載入以滑鼠位置顯示的縮圖?"; -"eh_setting_view.description.thumbnail_configuration" = "你可以為存取的所有畫廊設定預設的縮圖配置。"; -"eh_setting_view.title.thumbnail_size" = "尺寸"; -"eh_setting_view.title.thumbnail_row_count" = "行數"; -// EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "滑鼠位置"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "網頁載入位置"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "網頁的載入速度更快,但縮圖出現的時間可能稍有延遲"; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "網頁需要更多的載入時間,但是在網頁完全載入後縮圖顯示不會有任何延遲"; -// EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "正常"; -"enum.eh_setting.thumbnail_size.value.large" = "大型"; -"enum.eh_setting.thumbnail_size.value.small" = "小型"; -"enum.eh_setting.thumbnail_size.value.auto" = "自動"; - -"eh_setting_view.section.title.cover_scaling" = "封面縮放"; -"eh_setting_view.title.scale_factor" = "縮放比例"; -"eh_setting_view.description.cover_scale_factor" = "在縮圖與放大檢視這兩種檢視模式下,封面的重新採樣比率介於 75%% 至 150%%."; - -"eh_setting_view.section.title.viewport_override" = "視窗覆蓋"; -"eh_setting_view.title.virtual_width" = "虛擬寬度"; -"eh_setting_view.description.virtual_width" = "允許你覆蓋流動裝置網站的虛擬寬度。 這通常由你的裝置根據其 DPI 自動確定。 100%% 縮圖比例的合理值介於 640 和 1400 之間。"; - -"eh_setting_view.section.title.gallery_comments" = "畫廊留言"; -"eh_setting_view.title.comments_sort_order" = "留言排序方式"; -"eh_setting_view.title.comments_votes_show_timing" = "留言投票數顯示時機"; -// EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "最舊留言優先"; -"enum.eh_setting.comments_sort_order.value.recent" = "最新留言優先"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "最相關留言優先"; -// EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "滑鼠在分數上停留或點擊時"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "總是顯示"; - -"eh_setting_view.section.title.gallery_tags" = "畫廊標籤"; -"eh_setting_view.title.tags_sort_order" = "標籤顯示順序"; -// EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "字母順序"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "標籤權重"; - -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "畫廊頁面縮圖標籤"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "在畫廊縮圖下方顯示標籤"; - -"eh_setting_view.section.title.hath_local_network_host" = "內網 H@H 服務 (Hath Local Network Host)"; -"eh_setting_view.title.ip_address_port" = "IP 位址:連接埠號"; -"eh_setting_view.description.ip_address_port" = "如果你在內網絡上使用與瀏覽站點相同的公共 IP 架設 H@H 用戶端,有些路由器會因此發生問題,無法將請求傳回自己的 IP,透過啟用這項設定可以解決此問題。\n如果您在瀏覽的同一裝置上執行用戶端,請使用回環地址 (127.0.0.1:port)。 如果用戶端在您網絡上的另一台裝置上執行,請使用其本機網絡 IP。 某些瀏覽器配置會阻止外部網站存取具有本機網絡 IP 的 URL,你必須將站點列入白名單才能使其正常工作。"; - -"eh_setting_view.section.title.original_images" = "要使用原始圖片而非重新取樣的版本嗎? 若您在上方選擇「自動」以外的水準解像度且圖片較寬,或原始圖片大於 10 MiB(一年以上的圖庫則為 4 MiB),系統仍會使用重新取樣的圖片。"; -"eh_setting_view.title.use_original_images" = "使用原始圖片(原解像度)"; - -"eh_setting_view.section.title.multi_page_viewer" = "多頁瀏覽"; -"eh_setting_view.title.use_multi_page_viewer" = "使用多頁瀏覽"; -"eh_setting_view.title.display_style" = "顯示方式"; -"eh_setting_view.title.show_thumbnail_pane" = "顯示縮圖窗格"; -// EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "向左對齊,若寬度超出頁面則進行縮放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "置中對齊,若寬度超出頁面則進行縮放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "置中對齊且總是縮放"; -// EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "不顯示"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "只顯示頁碼"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "顯示頁碼和名稱"; - -// MARK: Category -"enum.category.value.doujinshi" = "同人誌"; -"enum.category.value.manga" = "漫畫"; -"enum.category.value.artist_CG" = "插畫"; -"enum.category.value.game_CG" = "遊戲 CG"; -"enum.category.value.western" = "西方"; -"enum.category.value.non_h" = "健康"; -"enum.category.value.image_set" = "圖片集"; -"enum.category.value.cosplay" = "角色扮演"; -"enum.category.value.asian_porn" = "亞洲"; -"enum.category.value.misc" = "其他"; -"enum.category.value.private" = "私人"; - -// MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "重新分類"; -"enum.tag_namespace.value.language" = "語言"; -"enum.tag_namespace.value.parody" = "原作"; -"enum.tag_namespace.value.character" = "角色"; -"enum.tag_namespace.value.group" = "團體"; -"enum.tag_namespace.value.artist" = "作者"; -"enum.tag_namespace.value.male" = "男性"; -"enum.tag_namespace.value.female" = "女性"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "其他"; -"enum.tag_namespace.value.temp" = "Temp"; - -// MARK: Language -"enum.language.value.invalid" = "無效"; -"enum.language.value.other" = "其他"; -"enum.language.value.afrikaans" = "南非語"; -"enum.language.value.albanian" = "阿爾巴尼亞語"; -"enum.language.value.arabic" = "阿拉伯語"; -"enum.language.value.bengali" = "孟加拉語"; -"enum.language.value.bosnian" = "波斯尼亞語"; -"enum.language.value.bulgarian" = "保加利亞語"; -"enum.language.value.burmese" = "緬甸語"; -"enum.language.value.catalan" = "加泰隆尼亞語"; -"enum.language.value.cebuano" = "宿霧語"; -"enum.language.value.chinese" = "漢語"; -"enum.language.value.croatian" = "克羅地亞語"; -"enum.language.value.czech" = "捷克語"; -"enum.language.value.danish" = "丹麥語"; -"enum.language.value.dutch" = "荷蘭語"; -"enum.language.value.english" = "英語"; -"enum.language.value.esperanto" = "國際語"; -"enum.language.value.estonian" = "愛沙尼亞語"; -"enum.language.value.finnish" = "芬蘭語"; -"enum.language.value.french" = "法語"; -"enum.language.value.georgian" = "格魯吉亞語"; -"enum.language.value.german" = "德語"; -"enum.language.value.greek" = "希臘語"; -"enum.language.value.hebrew" = "希伯來語"; -"enum.language.value.hindi" = "印度語"; -"enum.language.value.hmong" = "苗語"; -"enum.language.value.hungarian" = "匈牙利語"; -"enum.language.value.indonesian" = "印尼語"; -"enum.language.value.italian" = "意大利語"; -"enum.language.value.japanese" = "日語"; -"enum.language.value.kazakh" = "哈薩克語"; -"enum.language.value.khmer" = "高棉語"; -"enum.language.value.korean" = "韓語"; -"enum.language.value.kurdish" = "庫爾德語"; -"enum.language.value.lao" = "老撾語"; -"enum.language.value.latin" = "拉丁語"; -"enum.language.value.mongolian" = "蒙古語"; -"enum.language.value.ndebele" = "恩德貝萊語"; -"enum.language.value.nepali" = "尼泊爾語"; -"enum.language.value.norwegian" = "挪威語"; -"enum.language.value.oromo" = "奧羅莫語"; -"enum.language.value.pashto" = "普什圖語"; -"enum.language.value.persian" = "波斯語"; -"enum.language.value.polish" = "波蘭語"; -"enum.language.value.portuguese" = "葡萄牙語"; -"enum.language.value.punjabi" = "旁遮普語"; -"enum.language.value.romanian" = "羅馬尼亞語"; -"enum.language.value.russian" = "俄語"; -"enum.language.value.sango" = "桑戈語"; -"enum.language.value.serbian" = "塞爾維亞語"; -"enum.language.value.shona" = "紹納語"; -"enum.language.value.slovak" = "斯洛伐克語"; -"enum.language.value.slovenian" = "斯洛文尼亞語"; -"enum.language.value.somali" = "索馬利語"; -"enum.language.value.spanish" = "西班牙語"; -"enum.language.value.swahili" = "斯瓦希里語"; -"enum.language.value.swedish" = "瑞典語"; -"enum.language.value.tagalog" = "他加祿語"; -"enum.language.value.thai" = "泰語"; -"enum.language.value.tigrinya" = "提格利尼亞語"; -"enum.language.value.turkish" = "土耳其語"; -"enum.language.value.ukrainian" = "烏克蘭語"; -"enum.language.value.urdu" = "烏爾都語"; -"enum.language.value.vietnamese" = "越南語"; -"enum.language.value.zulu" = "祖魯語"; - -// MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "自動偵測"; -"enum.browsing_country.name.afghanistan" = "阿富汗"; -"enum.browsing_country.name.aland_islands" = "奧蘭群島"; -"enum.browsing_country.name.albania" = "阿爾巴尼亞"; -"enum.browsing_country.name.algeria" = "阿爾及利亞"; -"enum.browsing_country.name.american_samoa" = "美屬薩摩亞"; -"enum.browsing_country.name.andorra" = "安道爾"; -"enum.browsing_country.name.angola" = "安哥拉"; -"enum.browsing_country.name.anguilla" = "安圭拉"; -"enum.browsing_country.name.antarctica" = "南極洲"; -"enum.browsing_country.name.antigua_and_barbuda" = "安提瓜和巴布達"; -"enum.browsing_country.name.argentina" = "阿根廷"; -"enum.browsing_country.name.armenia" = "亞美尼亞"; -"enum.browsing_country.name.aruba" = "阿魯巴"; -"enum.browsing_country.name.asia_pacific_region" = "亞太地區"; -"enum.browsing_country.name.australia" = "澳洲"; -"enum.browsing_country.name.austria" = "奧地利"; -"enum.browsing_country.name.azerbaijan" = "阿塞拜疆"; -"enum.browsing_country.name.bahamas" = "巴哈馬"; -"enum.browsing_country.name.bahrain" = "巴林"; -"enum.browsing_country.name.bangladesh" = "孟加拉"; -"enum.browsing_country.name.barbados" = "巴巴多斯"; -"enum.browsing_country.name.belarus" = "白俄羅斯"; -"enum.browsing_country.name.belgium" = "比利時"; -"enum.browsing_country.name.belize" = "伯利茲"; -"enum.browsing_country.name.benin" = "貝南"; -"enum.browsing_country.name.bermuda" = "百慕達"; -"enum.browsing_country.name.bhutan" = "不丹"; -"enum.browsing_country.name.bolivia" = "玻利維亞"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "博奈爾、聖尤斯特歇斯和薩巴"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "波斯尼亞"; -"enum.browsing_country.name.botswana" = "博茨瓦納"; -"enum.browsing_country.name.bouvet_island" = "布威島"; -"enum.browsing_country.name.brazil" = "巴西"; -"enum.browsing_country.name.british_indian_ocean_territory" = "英屬印度洋領地"; -"enum.browsing_country.name.brunei_darussalam" = "汶萊"; -"enum.browsing_country.name.bulgaria" = "保加利亞"; -"enum.browsing_country.name.burkina_faso" = "布基納法索"; -"enum.browsing_country.name.burundi" = "布隆迪"; -"enum.browsing_country.name.cambodia" = "柬埔寨"; -"enum.browsing_country.name.cameroon" = "喀麥隆"; -"enum.browsing_country.name.canada" = "加拿大"; -"enum.browsing_country.name.cape_verde" = "佛得角"; -"enum.browsing_country.name.cayman_islands" = "開曼群島"; -"enum.browsing_country.name.central_african_republic" = "中非"; -"enum.browsing_country.name.chad" = "乍德"; -"enum.browsing_country.name.chile" = "智利"; -"enum.browsing_country.name.china" = "中國"; -"enum.browsing_country.name.christmas_island" = "聖誕島"; -"enum.browsing_country.name.cocos_islands" = "科科斯(基林)群島"; -"enum.browsing_country.name.colombia" = "哥倫比亞"; -"enum.browsing_country.name.comoros" = "科摩羅"; -"enum.browsing_country.name.congo" = "剛果共和國"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "剛果民主共和國"; -"enum.browsing_country.name.cook_islands" = "庫克群島"; -"enum.browsing_country.name.costa_rica" = "哥斯達黎加"; -"enum.browsing_country.name.cote_d_ivoire" = "象牙海岸"; -"enum.browsing_country.name.croatia" = "克羅地亞"; -"enum.browsing_country.name.cuba" = "古巴"; -"enum.browsing_country.name.curacao" = "古拉索"; -"enum.browsing_country.name.cyprus" = "塞浦路斯"; -"enum.browsing_country.name.czech_republic" = "捷克共和國"; -"enum.browsing_country.name.denmark" = "丹麥"; -"enum.browsing_country.name.djibouti" = "吉布堤"; -"enum.browsing_country.name.dominica" = "多米尼克"; -"enum.browsing_country.name.dominican_republic" = "多明尼加"; -"enum.browsing_country.name.ecuador" = "厄瓜多爾"; -"enum.browsing_country.name.egypt" = "埃及"; -"enum.browsing_country.name.el_salvador" = "薩爾瓦多"; -"enum.browsing_country.name.equatorial_guinea" = "赤道幾內亞"; -"enum.browsing_country.name.eritrea" = "厄立特里亞"; -"enum.browsing_country.name.estonia" = "愛沙尼亞"; -"enum.browsing_country.name.ethiopia" = "埃塞俄比亞"; -"enum.browsing_country.name.europe" = "歐洲"; -"enum.browsing_country.name.falkland_islands" = "福克蘭群島"; -"enum.browsing_country.name.faroe_islands" = "法羅群島"; -"enum.browsing_country.name.fiji" = "斐濟"; -"enum.browsing_country.name.finland" = "芬蘭"; -"enum.browsing_country.name.france" = "法國"; -"enum.browsing_country.name.french_guiana" = "法屬圭亞那"; -"enum.browsing_country.name.french_polynesia" = "法屬波利尼西亞"; -"enum.browsing_country.name.french_southern_territories" = "法屬南部領土"; -"enum.browsing_country.name.gabon" = "加蓬"; -"enum.browsing_country.name.gambia" = "岡比亞"; -"enum.browsing_country.name.georgia" = "格魯吉亞"; -"enum.browsing_country.name.germany" = "德國"; -"enum.browsing_country.name.ghana" = "加納"; -"enum.browsing_country.name.gibraltar" = "直布羅陀"; -"enum.browsing_country.name.greece" = "希臘"; -"enum.browsing_country.name.greenland" = "格陵蘭"; -"enum.browsing_country.name.grenada" = "格林納達"; -"enum.browsing_country.name.guadeloupe" = "瓜地洛普"; -"enum.browsing_country.name.guam" = "關島"; -"enum.browsing_country.name.guatemala" = "危地馬拉"; -"enum.browsing_country.name.guernsey" = "耿西"; -"enum.browsing_country.name.guinea" = "幾內亞"; -"enum.browsing_country.name.guinea_bissau" = "幾內亞比紹"; -"enum.browsing_country.name.guyana" = "圭亞那"; -"enum.browsing_country.name.haiti" = "海地"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "赫德島和麥克唐納群島"; -"enum.browsing_country.name.vatican_city_state" = "梵蒂岡"; -"enum.browsing_country.name.honduras" = "洪都拉斯"; -"enum.browsing_country.name.hong_kong" = "香港"; -"enum.browsing_country.name.hungary" = "匈牙利"; -"enum.browsing_country.name.iceland" = "冰島"; -"enum.browsing_country.name.india" = "印度"; -"enum.browsing_country.name.indonesia" = "印度尼西亞(印尼)"; -"enum.browsing_country.name.iran" = "伊朗"; -"enum.browsing_country.name.iraq" = "伊拉克"; -"enum.browsing_country.name.ireland" = "愛爾蘭"; -"enum.browsing_country.name.isle_of_man" = "曼島"; -"enum.browsing_country.name.israel" = "以色列"; -"enum.browsing_country.name.italy" = "意大利"; -"enum.browsing_country.name.jamaica" = "牙買加"; -"enum.browsing_country.name.japan" = "日本"; -"enum.browsing_country.name.jersey" = "澤西島"; -"enum.browsing_country.name.jordan" = "約旦"; -"enum.browsing_country.name.kazakhstan" = "哈薩克共和國"; -"enum.browsing_country.name.kenya" = "肯亞"; -"enum.browsing_country.name.kiribati" = "基里巴斯"; -"enum.browsing_country.name.kuwait" = "科威特"; -"enum.browsing_country.name.kyrgyzstan" = "吉爾吉斯"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "老撾"; -"enum.browsing_country.name.latvia" = "拉脫維亞"; -"enum.browsing_country.name.lebanon" = "黎巴嫩"; -"enum.browsing_country.name.lesotho" = "萊索托"; -"enum.browsing_country.name.liberia" = "利比里亞"; -"enum.browsing_country.name.libya" = "利比亞"; -"enum.browsing_country.name.liechtenstein" = "列支敦士登"; -"enum.browsing_country.name.lithuania" = "立陶宛"; -"enum.browsing_country.name.luxembourg" = "盧森堡"; -"enum.browsing_country.name.macau" = "澳門"; -"enum.browsing_country.name.macedonia" = "北馬其頓"; -"enum.browsing_country.name.madagascar" = "馬達加斯加"; -"enum.browsing_country.name.malawi" = "馬拉維"; -"enum.browsing_country.name.malaysia" = "馬來西亞"; -"enum.browsing_country.name.maldives" = "馬爾代夫"; -"enum.browsing_country.name.mali" = "馬利"; -"enum.browsing_country.name.malta" = "馬爾他"; -"enum.browsing_country.name.marshall_islands" = "馬紹爾群島"; -"enum.browsing_country.name.martinique" = "馬丁尼克"; -"enum.browsing_country.name.mauritania" = "毛里塔尼亞"; -"enum.browsing_country.name.mauritius" = "毛里裘斯"; -"enum.browsing_country.name.mayotte" = "馬約特"; -"enum.browsing_country.name.mexico" = "墨西哥"; -"enum.browsing_country.name.micronesia" = "密克羅尼西亞"; -"enum.browsing_country.name.moldova" = "摩爾多瓦"; -"enum.browsing_country.name.monaco" = "摩納哥"; -"enum.browsing_country.name.mongolia" = "蒙古"; -"enum.browsing_country.name.montenegro" = "黑山"; -"enum.browsing_country.name.montserrat" = "蒙特塞拉特"; -"enum.browsing_country.name.morocco" = "摩洛哥"; -"enum.browsing_country.name.mozambique" = "莫桑比克"; -"enum.browsing_country.name.myanmar" = "緬甸"; -"enum.browsing_country.name.namibia" = "納米比亞"; -"enum.browsing_country.name.nauru" = "諾魯"; -"enum.browsing_country.name.nepal" = "尼泊爾"; -"enum.browsing_country.name.netherlands" = "荷蘭"; -"enum.browsing_country.name.new_caledonia" = "新喀里多尼亞"; -"enum.browsing_country.name.new_zealand" = "新西蘭"; -"enum.browsing_country.name.nicaragua" = "尼加拉瓜"; -"enum.browsing_country.name.niger" = "尼日爾"; -"enum.browsing_country.name.nigeria" = "尼日利亞"; -"enum.browsing_country.name.niue" = "紐埃"; -"enum.browsing_country.name.norfolk_island" = "諾福克島"; -"enum.browsing_country.name.north_korea" = "朝鮮"; -"enum.browsing_country.name.northern_mariana_islands" = "北馬里亞納群島"; -"enum.browsing_country.name.norway" = "挪威"; -"enum.browsing_country.name.oman" = "阿曼"; -"enum.browsing_country.name.pakistan" = "巴基斯坦"; -"enum.browsing_country.name.palau" = "帕勞"; -"enum.browsing_country.name.palestinian_territory" = "巴勒斯坦領土"; -"enum.browsing_country.name.panama" = "巴拿馬"; -"enum.browsing_country.name.papua_new_guinea" = "巴布亞新幾內亞"; -"enum.browsing_country.name.paraguay" = "巴拉圭"; -"enum.browsing_country.name.peru" = "秘魯"; -"enum.browsing_country.name.philippines" = "菲律賓"; -"enum.browsing_country.name.pitcairn_islands" = "皮特凱恩群島"; -"enum.browsing_country.name.poland" = "波蘭"; -"enum.browsing_country.name.portugal" = "葡萄牙"; -"enum.browsing_country.name.puerto_rico" = "波多黎各"; -"enum.browsing_country.name.qatar" = "卡達"; -"enum.browsing_country.name.reunion" = "留尼旺"; -"enum.browsing_country.name.romania" = "羅馬尼亞"; -"enum.browsing_country.name.russian_federation" = "俄羅斯"; -"enum.browsing_country.name.rwanda" = "盧旺達"; -"enum.browsing_country.name.saint_barthelemy" = "聖巴瑟米"; -"enum.browsing_country.name.saint_helena" = "聖赫勒拿"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "聖吉斯納域斯"; -"enum.browsing_country.name.saint_lucia" = "聖盧西亞"; -"enum.browsing_country.name.saint_martin" = "聖馬丁"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "聖皮耶與密克隆"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "聖文森特和格林納丁斯"; -"enum.browsing_country.name.samoa" = "薩摩亞"; -"enum.browsing_country.name.san_marino" = "聖馬力諾"; -"enum.browsing_country.name.sao_tome_and_principe" = "聖多美和普林西比"; -"enum.browsing_country.name.saudi_arabia" = "沙特阿拉伯"; -"enum.browsing_country.name.senegal" = "塞內加爾"; -"enum.browsing_country.name.serbia" = "塞爾維亞"; -"enum.browsing_country.name.seychelles" = "塞舌爾"; -"enum.browsing_country.name.sierra_leone" = "獅子山"; -"enum.browsing_country.name.singapore" = "新加坡"; -"enum.browsing_country.name.sint_maarten" = "聖馬丁"; -"enum.browsing_country.name.slovakia" = "斯洛伐克"; -"enum.browsing_country.name.slovenia" = "斯洛文尼亞"; -"enum.browsing_country.name.solomon_islands" = "所羅門群島"; -"enum.browsing_country.name.somalia" = "索馬里"; -"enum.browsing_country.name.south_africa" = "南非"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "南格魯吉亞和南桑威奇群島"; -"enum.browsing_country.name.south_korea" = "韓國"; -"enum.browsing_country.name.south_sudan" = "南蘇丹"; -"enum.browsing_country.name.spain" = "西班牙"; -"enum.browsing_country.name.sri_lanka" = "斯里蘭卡"; -"enum.browsing_country.name.sudan" = "蘇丹"; -"enum.browsing_country.name.suriname" = "蘇里南"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "斯瓦巴和揚馬延"; -"enum.browsing_country.name.swaziland" = "斯威士蘭"; -"enum.browsing_country.name.sweden" = "瑞典"; -"enum.browsing_country.name.switzerland" = "瑞士"; -"enum.browsing_country.name.syrian_arab_republic" = "敘利亞"; -"enum.browsing_country.name.taiwan" = "臺灣"; -"enum.browsing_country.name.tajikistan" = "塔吉克"; -"enum.browsing_country.name.tanzania" = "坦桑尼亞"; -"enum.browsing_country.name.thailand" = "泰國"; -"enum.browsing_country.name.timor_leste" = "東帝汶"; -"enum.browsing_country.name.togo" = "多哥"; -"enum.browsing_country.name.tokelau" = "托克勞"; -"enum.browsing_country.name.tonga" = "湯加"; -"enum.browsing_country.name.trinidad_and_tobago" = "千里達和托巴哥"; -"enum.browsing_country.name.tunisia" = "突尼斯"; -"enum.browsing_country.name.turkey" = "土耳其"; -"enum.browsing_country.name.turkmenistan" = "土庫曼"; -"enum.browsing_country.name.turks_and_caicos_islands" = "土克斯及開科斯群島"; -"enum.browsing_country.name.tuvalu" = "圖瓦盧"; -"enum.browsing_country.name.uganda" = "烏干達"; -"enum.browsing_country.name.ukraine" = "烏克蘭"; -"enum.browsing_country.name.united_arab_emirates" = "阿拉伯聯合酋長國"; -"enum.browsing_country.name.united_kingdom" = "英國"; -"enum.browsing_country.name.united_states" = "美國"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "美國外圍小島嶼"; -"enum.browsing_country.name.uruguay" = "烏拉圭"; -"enum.browsing_country.name.uzbekistan" = "烏茲別克"; -"enum.browsing_country.name.vanuatu" = "瓦努阿圖"; -"enum.browsing_country.name.venezuela" = "委內瑞拉"; -"enum.browsing_country.name.vietnam" = "越南"; -"enum.browsing_country.name.virgin_islands_british" = "英屬維京群島"; -"enum.browsing_country.name.virgin_islands_US" = "美屬維京群島"; -"enum.browsing_country.name.wallis_and_futuna" = "瓦利斯和富圖納"; -"enum.browsing_country.name.western_sahara" = "西撒哈拉"; -"enum.browsing_country.name.yemen" = "也門"; -"enum.browsing_country.name.zambia" = "贊比亞"; -"enum.browsing_country.name.zimbabwe" = "津巴布韋"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings deleted file mode 100644 index 76b5f7fe7..000000000 --- a/AppPackage/Sources/Resources/Resources/zh-Hant-TW.lproj/Localizable.strings +++ /dev/null @@ -1,1051 +0,0 @@ -// MARK: BanInterval -"enum.ban_interval.description.and" = ""; - -// MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "昨天"; -"enum.toplists_type.value.past_month" = "上個月"; -"enum.toplists_type.value.past_year" = "去年"; -"enum.toplists_type.value.all_time" = "所有時間"; - -// MARK: Response -"website.response.hath_client_not_found" = "你需要一個連結到帳號的 H@H 用戶端才能使用這個功能"; -"website.response.hath_client_not_online" = "你的 H@H 用戶端為離線狀態,請啟動後再試"; -"website.response.invalid_resolution" = "該畫廊不能以目前選擇的解析度下載"; - -// MARK: Toast -"toast.title.error" = "錯誤"; -"toast.title.success" = "成功"; -"toast.title.loading" = "載入中..."; -"toast.title.communicating" = "連線中..."; -"toast.caption.copied_to_clipboard" = "已複製到剪貼簿"; -"toast.caption.saved_to_photo_library" = "已儲存到照片"; - -// MARK: AutoLock -"local_authorization.reason" = "由於超過 APP 自動鎖定期限,APP 已被鎖定,請重新解鎖"; - -// MARK: Common value -"common.value.stars" = "%@ 星"; -"common.value.pages" = "%@ 頁"; -"common.value.times" = "%@ 次"; -"common.value.day" = "%@ 天"; -"common.value.days" = "%@ 天"; -"common.value.hour" = "%@ 小時"; -"common.value.hours" = "%@ 小時"; -"common.value.minute" = "%@ 分"; -"common.value.minutes" = "%@ 分"; -"common.value.second" = "%@ 秒"; -"common.value.seconds" = "%@ 秒"; -"common.value.records" = "%@ 筆紀錄"; - -// MARK: Common button -"common.button.cancel" = "取消"; - -// MARK: TabItem -"tab_item.title.home" = "總覽"; -"tab_item.title.favorites" = "收藏"; -"tab_item.title.search" = "搜尋"; -"tab_item.title.downloads" = "下載"; -"tab_item.title.setting" = "設定"; - -// MARK: ToolbarItem -"toolbar_item.button.filters" = "過濾"; -"toolbar_item.button.jump_page" = "跳到..."; -"toolbar_item.button.date_seek" = "日期定位"; -"toolbar_item.button.quick_search" = "快速搜尋"; - -// MARK: DateSeek -"date_seek_view.title.date_seek" = "日期定位"; -"date_seek_view.title.date" = "日期"; -"date_seek_view.footer.seek_around_date" = "前往所選日期附近的畫廊。"; -"date_seek_view.button.seek_newer" = "較新"; -"date_seek_view.button.seek_older" = "較舊"; -// MARK: JumpPage -"jump_page_view.title.jump_page" = "跳到..."; -"jump_page_view.description.jump_page" = "請輸入 1 到 %d 之間的頁碼。"; -"jump_page_view.button.confirm" = "確定"; - -// MARK: AlertView -"loading_view.title.loading" = "載入中..."; -"loading_view.title.preparing_database" = "正在準備..."; -"not_login_view.title.need_login" = "你需要登入才能完成這個動作"; -"not_login_view.button.login" = "登入"; -"error_view.button.retry" = "重試"; -"error_view.button.drop_database" = "刪除資料庫"; -"error_view.title.try_later" = "請稍後再試"; -"error_view.title.network" = "網路發生故障,請檢查網路狀態"; -"error_view.title.parsing" = "網頁解析器發生故障"; -"error_view.title.unknown" = "發生不明錯誤"; -"error_view.title.not_found" = "這邊看起來空空如也"; -"error_view.title.database_corrupted" = "資料庫已經損壞\n請提交 issue 至 GitHub."; -"error_view.title.ip_banned" = "你的 IP 因為頁面載入次數過於頻繁而被暫時禁止存取,這可能是因為你正在使用爬蟲/鏡像軟體,禁止存取將在 %@ 後解除"; -"error_view.title.copyright_claim" = "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取"; -"error_view.title.gallery_unavailable" = "此畫廊已被刪除或你沒有權限存取"; - -// MARK: AppError -"app_error.localized_description.database_corrupted" = "資料庫損壞"; -"app_error.localized_description.copyright_claim" = "版權聲明"; -"app_error.localized_description.ip_banned" = "IP 已封禁"; -"app_error.localized_description.gallery_expunged" = "畫廊已刪除"; -"app_error.localized_description.network_error" = "網路錯誤"; -"app_error.localized_description.web_image_loading_error" = "網頁圖片載入錯誤"; -"app_error.localized_description.parse_error" = "解析錯誤"; -"app_error.localized_description.quota_exceeded" = "流量額度已用盡"; -"app_error.localized_description.authentication_required" = "需要登入"; -"app_error.localized_description.file_operation_failed" = "檔案操作失敗"; -"app_error.localized_description.no_updates_available" = "沒有可用更新"; -"app_error.localized_description.not_found" = "未找到"; -"app_error.localized_description.unknown_error" = "未知錯誤"; -"app_error.alert.quota_exceeded" = "圖片流量額度已用盡。\n請稍後再試。"; -"app_error.alert.authentication_required" = "存取此下載內容需要登入。"; -"app_error.alert.local_file_operation_failed" = "本機檔案操作失敗。"; - -// MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?"; -"confirmation_dialog.title.remove_custom_translations" = "是否確定要刪除所有自訂翻譯?"; -"confirmation_dialog.title.logout" = "確定要登出嗎?"; -"confirmation_dialog.title.delete" = "確定要刪除?"; -"confirmation_dialog.title.clear" = "確定要清空嗎?"; -"confirmation_dialog.title.reset" = "確定要重設嗎?"; -"confirmation_dialog.button.drop_database" = "刪除資料庫"; -"confirmation_dialog.button.remove" = "移除"; -"confirmation_dialog.button.logout" = "登出"; -"confirmation_dialog.button.delete" = "刪除"; -"confirmation_dialog.button.clear" = "清空"; -"confirmation_dialog.button.reset" = "重設"; - -// MARK: SubSection -"sub_section.button.show_all" = "顯示全部"; - -// MARK: NewDawnView -"new_dawn_view.title.first" = "現在是嶄新的一天!"; -"new_dawn_view.title.second" = "回顧到目前為止的旅程,你發現自己睿智了一點。"; -// Greeting -"struct.greeting.mark.start" = "你獲得了 "; -"struct.greeting.mark.separator" = "、"; -"struct.greeting.mark.and" = " 和 "; -"struct.greeting.mark.end" = "!"; - -// MARK: HomeView -"home_view.title.home" = "總覽"; -"home_view.section.title.frontpage" = "首頁"; -"home_view.section.title.toplists" = "排行"; -"home_view.section.title.other" = "其他"; -// HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "熱門"; -"enum.home_misc_grid_type.title.watched" = "關注"; -"enum.home_misc_grid_type.title.history" = "歷程"; - -// MARK: FrontpageView -"frontpage_view.title.frontpage" = "首頁"; - -// MARK: ToplistsView -"toplists_view.title.toplists" = "排行"; - -// MARK: PopularView -"popular_view.title.popular" = "熱門"; - -// MARK: WatchedView -"watched_view.title.watched" = "關注"; - -// MARK: HistoryView -"history_view.title.history" = "歷程"; - -// MARK: FavoritesView -"favorites_view.title.favorites" = "收藏"; -// FavoriteCategory -"struct.user.favorite_category.default" = "收藏匣 %@"; -"struct.user.favorite_category.all" = "全部"; - -// MARK: SearchView -"search_view.title.search" = "搜尋"; -"search_view.section.title.recently_searched" = "最近搜尋"; -"search_view.section.title.recently_seen" = "最近閱讀"; -"search_view.section.title.quick_search" = "快速搜尋"; -// Searchable -"searchable.prompt.filter" = "過濾"; -"searchable.title.matches_count" = "找到 %d 項結果"; - -// MARK: QuickSearchView -"quick_search_view.title.quick_search" = "快速搜尋"; -"quick_search_view.title.edit_word" = "編輯關鍵字"; -"quick_search_view.title.new_word" = "新關鍵字"; -"quick_search_view.title.content" = "搜尋內容"; -"quick_search_view.title.name" = "名稱"; -"quick_search_view.placeholder.optional" = "(可選)"; - -// MARK: SettingView -"setting_view.title.setting" = "設定"; -// SettingStateRoute -"enum.setting_state_route.value.account" = "帳號"; -"enum.setting_state_route.value.general" = "一般"; -"enum.setting_state_route.value.appearance" = "外觀"; -"enum.setting_state_route.value.reading" = "閱讀"; -"enum.setting_state_route.value.download" = "下載"; -"enum.setting_state_route.value.laboratory" = "實驗性功能"; -"enum.setting_state_route.value.about" = "關於"; - -// MARK: AccountSettingView -"account_setting_view.title.account" = "帳號設定"; -"account_setting_view.title.shows_new_dawn_greeting" = "顯示黎明問候"; -"account_setting_view.button.login" = "登入"; -"account_setting_view.button.logout" = "登出"; -"account_setting_view.button.account_configuration" = "帳號設定"; -"account_setting_view.button.tags_management" = "管理訂閱標籤"; -"account_setting_view.button.copy_cookies" = "複製 Cookies"; -// CookieValue -"struct.cookie_value.localized_string.expired" = "已過期"; -"struct.cookie_value.localized_string.mystery" = "被拒絕"; -"struct.cookie_value.localized_string.none" = "None"; - -// MARK: LoginView -"login_view.title.login" = "登入"; -"login_view.title.username" = "Username"; -"login_view.title.password" = "Password"; - -// MARK: GeneralSettingView -"general_setting_view.title.general" = "一般設定"; -"general_setting_view.title.language" = "語言"; -"general_setting_view.title.auto_lock" = "自動鎖定"; -"general_setting_view.title.enables_tags_extension" = "啟用自訂標籤擴充功能"; -"general_setting_view.title.translates_tags" = "標籤翻譯"; -"general_setting_view.title.shows_tags_search_suggestion" = "搜尋時顯示標籤建議"; -"general_setting_view.title.shows_images_in_tags" = "在標籤顯示圖片"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "將連結重新導向至選擇的網站"; -"general_setting_view.title.detects_links_from_clipboard" = "偵測剪貼簿中的連結"; -"general_setting_view.title.background_blur_radius" = "後台背景模糊"; -"general_setting_view.button.app_activity_logs" = "應用程式活動日誌"; -"general_setting_view.button.import_custom_translations" = "匯入自訂標籤翻譯"; -"general_setting_view.button.remove_custom_translations" = "刪除自訂標籤翻譯"; -"general_setting_view.button.clear_image_caches" = "清理圖片快取"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "標籤"; -"general_setting_view.section.title.navigation" = "導覽"; -"general_setting_view.section.title.security" = "安全"; -"general_setting_view.section.title.caches" = "快取"; -// AutoLockPolicy -"enum.auto_lock_policy.value.never" = "永不自動鎖定"; -"enum.auto_lock_policy.value.instantly" = "立刻"; - -// MARK: AppActivityLogsView -"app_activity_logs_view.title" = "應用程式活動日誌"; -"app_activity_logs_view.placeholder.no_logs" = "找不到日誌"; -"app_activity_logs_view.section.current" = "目前"; -"app_activity_logs_view.run" = "運行 %@"; -"app_activity_logs_view.more_logs" = "更多日誌"; -"app_activity_logs_view.open_in_files" = "在「檔案」中開啟"; -"app_activity_logs_view.runs" = "運行"; -"app_activity_logs_view.level.undefined" = "未定義"; -"app_activity_logs_view.level.debug" = "偵錯"; -"app_activity_logs_view.level.info" = "資訊"; -"app_activity_logs_view.level.notice" = "通知"; -"app_activity_logs_view.level.error" = "錯誤"; -"app_activity_logs_view.level.fault" = "故障"; - -// MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "外觀設定"; -"appearance_setting_view.title.theme" = "主題"; -"appearance_setting_view.title.tint_color" = "強調色"; -"appearance_setting_view.title.display_mode" = "顯示模式"; -"appearance_setting_view.title.shows_tags_in_list" = "在列表中顯示標籤"; -"appearance_setting_view.title.maximum_number_of_tags" = "標籤最大顯示數量"; -"appearance_setting_view.title.displays_japanese_title" = "以日文顯示標籤"; -"appearance_setting_view.button.app_icon" = "App 圖案"; -"appearance_setting_view.menu.title.infite" = "無限"; -"appearance_setting_view.section.title.list" = "列表"; -"appearance_setting_view.section.title.gallery" = "畫廊"; -// PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "自動"; -"enum.preferred_color_scheme.value.light" = "淺色"; -"enum.preferred_color_scheme.value.dark" = "深色"; -// AppIconType -"enum.app_icon_type.value.default" = "預設"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; -// ListDisplayMode -"enum.list_display_mode.value.detail" = "詳細"; -"enum.list_display_mode.value.thumbnail" = "縮圖"; - -// MARK: AppIconView -"app_icon_view.title.app_icon" = "App 圖案"; - -// MARK: reading_settingView -"reading_setting_view.title.reading" = "閱讀設定"; -"reading_setting_view.title.direction" = "翻頁方向"; -"reading_setting_view.title.preload_limit" = "預先載入頁數"; -"reading_setting_view.title.enables_landscape" = "啟用橫向顯示"; -"reading_setting_view.title.separator_height" = "頁間分隔高度"; -"reading_setting_view.title.maximum_scale_factor" = "縮放上限"; -"reading_setting_view.title.double_tap_scale_factor" = "雙擊縮放值"; -"reading_setting_view.section.title.appearance" = "外觀"; -// ReadingDirection -"enum.reading_direction.value.vertical" = "垂直"; -"enum.reading_direction.value.right_to_left" = "由右至左滑"; -"enum.reading_direction.value.left_to_right" = "由左至右滑"; - -// MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "實驗性功能"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "繞過 SNI 過濾"; - -// MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "官方網站"; -"about_view.button.altStore_source" = "AltStore source"; -"about_view.title.version" = "版本"; -"about_view.section.title.special_thanks" = "特別銘謝"; -"about_view.section.title.code_level_contributors" = "程式碼貢獻者"; -"about_view.section.title.translation_contributors" = "翻譯貢獻者"; -"about_view.section.title.acknowledgements" = "致謝"; - -// MARK: DetailView -"detail_view.button.download_login" = "登入"; -"detail_view.button.download_get" = "取得"; -"detail_view.button.download_wait" = "等待"; -"detail_view.button.download_done" = "完成"; -"detail_view.button.download_update" = "更新"; -"detail_view.button.download_retry" = "重試"; -"detail_view.button.download_repair" = "修復"; -"detail_view.button.read" = "閱讀"; -"detail_view.button.post_comment" = "發表留言"; -"detail_view.accessibility.download_button.login" = "登入後即可下載"; -"detail_view.accessibility.download_button.download" = "下載"; -"detail_view.accessibility.download_button.queued" = "已加入下載佇列"; -"detail_view.accessibility.download_button.downloading" = "正在下載第 %d / %d 頁"; -"detail_view.accessibility.download_button.downloaded" = "刪除已下載畫廊"; -"detail_view.accessibility.download_button.update" = "更新下載內容"; -"detail_view.accessibility.download_button.retry" = "重新下載"; -"detail_view.accessibility.download_button.repair" = "修復下載檔案"; -"detail_view.accessibility.download_button.preparing" = "正在取得下載資訊"; -"detail_view.accessibility.download_button.pause_action" = "暫停下載"; -"detail_view.accessibility.download_button.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; -"detail_view.accessibility.download_button.partial" = "重新下載,已有 %d / %d 頁可用。"; -"detail_view.toolbar_item.button.archives" = "存檔至 H@H 用戶端"; -"detail_view.toolbar_item.button.torrents" = "種子"; -"detail_view.toolbar_item.button.share" = "分享"; -"detail_view.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "收回評分"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "收藏"; -"detail_view.description_section.title.language" = "語言"; -"detail_view.description_section.title.ratings" = "%@ 個評分"; -"detail_view.description_section.title.page_count" = "頁數"; -"detail_view.description_section.title.file_size" = "檔案大小"; -"detail_view.description_section.description.favorited" = "次"; -"detail_view.description_section.description.page_count" = "頁"; -"detail_view.action_section.button.give_a_rating" = "給予評分"; -"detail_view.action_section.button.similar_gallery" = "類似畫廊"; -"detail_view.section.title.previews" = "預覽"; -"detail_view.section.title.comments" = "留言"; -"detail_view.dialog.title.delete_download" = "刪除下載?"; -"detail_view.dialog.title.repair_download" = "修復下載?"; -"detail_view.dialog.title.update_download" = "更新下載?"; -"detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; -"detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; -"detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; -"detail_view.dialog.message.redownload_gallery" = "現在重新完整下載此畫廊嗎?"; -"detail_view.dialog.button.repair" = "修復"; -"detail_view.dialog.button.update" = "更新"; -"detail_view.dialog.button.redownload" = "重新下載"; -"detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; - -// MARK: ArchivesView -"archives_view.title.archives" = "存檔"; -"archives_view.button.download_to_hath_client" = "下載至 H@H 用戶端"; -// HathArchive -"struct.hath_archive.price.free" = "免費"; -"struct.hath_archive.price.not_available" = "N/A"; -// ArchiveResolution -"enum.archive_resolution.value.original" = "原始畫質"; - -// MARK: TorrentsView -"torrents_view.title.torrents" = "種子"; - -// MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "畫廊資訊"; -"gallery_infos_view.title.id" = "畫廊 ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "標題"; -"gallery_infos_view.title.japanese_title" = "日文標題"; -"gallery_infos_view.title.gallery_URL" = "畫廊 URL"; -"gallery_infos_view.title.cover_URL" = "封面 URL"; -"gallery_infos_view.title.archive_URL" = "存檔 URL"; -"gallery_infos_view.title.torrent_URL" = "種子 URL"; -"gallery_infos_view.title.parent_URL" = "Parent URL"; -"gallery_infos_view.title.category" = "類別"; -"gallery_infos_view.title.uploader" = "上傳者"; -"gallery_infos_view.title.posted_date" = "發布日期"; -"gallery_infos_view.title.visibility" = "能見度"; -"gallery_infos_view.title.language" = "語言"; -"gallery_infos_view.title.page_count" = "頁數"; -"gallery_infos_view.title.file_size" = "檔案大小"; -"gallery_infos_view.title.favorited_times" = "被收藏次數"; -"gallery_infos_view.title.favorited" = "已收藏"; -"gallery_infos_view.title.rating_count" = "被評分次數"; -"gallery_infos_view.title.average_rating" = "平均評分"; -"gallery_infos_view.title.my_rating" = "我的評分"; -"gallery_infos_view.title.torrent_count" = "種子數量"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "Yes"; -"gallery_infos_view.value.no" = "No"; -// GalleryVisibility -"enum.gallery_visibility.value.yes" = "Yes"; -"enum.gallery_visibility.value.no" = "No (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "已被刪除"; - -// MARK: TagDetailView -"tag_detail_view.section.title.images" = "圖片"; -"tag_detail_view.section.title.links" = "連結"; - -// MARK: DownloadsView -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "下載"; -"downloads_view.search.prompt.downloads" = "搜尋下載"; -"downloads_view.dialog.title.delete_download" = "刪除下載?"; -"downloads_view.dialog.message.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; -"downloads_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"downloads_view.swipe.button.pages" = "頁面"; -"downloads_view.swipe.button.update" = "更新"; -"downloads_view.swipe.button.resume" = "繼續"; -"downloads_view.swipe.button.pause" = "暫停"; -"downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; -"downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; -"downloads_view.button.clear_filters" = "清除篩選"; -"downloads_view.button.validate_image_data" = "驗證圖片資料"; -"downloads_view.inspector.section.actions" = "操作"; -"downloads_view.inspector.section.pages" = "頁面"; -"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; -"downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; -"downloads_view.inspector.button.update_download" = "更新下載"; -"downloads_view.inspector.toast.image_data_valid" = "圖片資料有效"; -"downloads_view.inspector.toast.image_data_unavailable" = "無法驗證圖片資料。"; -"downloads_view.inspector.title.download_status" = "下載狀態"; -"downloads_view.inspector.page.pending" = "等待中"; -"downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; -"downloads_view.inspector.page.title" = "第 %d 頁"; -"downloads_view.inspector.page.none" = "沒有頁面"; -"downloads_view.inspector.status.pending" = "等待中"; -"downloads_view.inspector.status.downloaded" = "已下載"; -"downloads_view.inspector.status.failed" = "失敗"; - -// MARK: DownloadSettingView -"download_setting_view.title" = "下載"; -"download_setting_view.section.title.download_queue" = "下載佇列"; -"download_setting_view.section.title.network" = "網路"; -"download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; -"download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; -"download_setting_view.title.allow_cellular_downloads" = "允許行動網路下載"; -"download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許行動網路下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; - -// MARK: CommentsView -"comments_view.title.comments" = "留言"; - -// MARK: PostCommentView -"post_comment_view.title.post_comment" = "發表留言"; -"post_comment_view.title.edit_comment" = "編輯留言"; - -// MARK: PreviewsView -"previews_view.title.previews" = "預覽"; - -// MARK: ReadingView -"reading_view.context_menu.button.reload" = "重新整理"; -"reading_view.context_menu.button.copy" = "複製"; -"reading_view.context_menu.button.save" = "儲存圖片"; -"reading_view.context_menu.button.save_original" = "儲存原始圖片"; -"reading_view.context_menu.button.share" = "分享"; -"reading_view.toolbar_item.title.auto_play" = "自動播放"; -"reading_view.toolbar_item.title.dual_page_mode" = "雙頁模式"; -"reading_view.toolbar_item.title.except_the_cover" = "封面除外"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "重新載入失敗圖片"; -"reading_view.toolbar_item.button.reload_all_images" = "重新載入所有圖片"; -"reading_view.toolbar_item.button.reading_setting" = "閱讀設定"; -// AutoPlayPolicy -"enum.auto_play_policy.value.off" = "關閉"; - - -// MARK: DownloadBadge -"struct.download_badge.text.queued" = "已排隊"; -"struct.download_badge.text.downloading" = "下載中"; -"struct.download_badge.text.paused" = "已暫停"; -"struct.download_badge.text.downloaded" = "已下載"; -"struct.download_badge.text.needs_attention" = "需處理"; -"struct.download_badge.text.update_available" = "有可更新"; -"struct.download_badge.text.needs_repair" = "需修復"; -"struct.download_badge.progress" = "%d/%d"; - -// MARK: DownloadStore -"download_store.error.asset_unreadable" = "資源檔案無法讀取:%@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "無法解析下載資料夾。"; -"download_store.validation.download_folder_missing" = "下載資料夾缺失。"; -"download_store.validation.manifest_missing" = "Manifest 檔案缺失。"; -"download_store.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; -"download_store.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; -"download_store.validation.cover_image_missing" = "封面圖片缺失。"; -"download_store.validation.page_missing" = "第 %d 頁缺失。"; -"download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; -"download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; - -// MARK: FiltersView -"filters_view.title.filters" = "過濾"; -"filters_view.title.advanced_settings" = "進階選項"; -"filters_view.title.search_gallery_name" = "搜尋畫廊名稱"; -"filters_view.title.search_gallery_tags" = "搜尋畫廊標籤"; -"filters_view.title.search_gallery_description" = "搜尋畫廊描述"; -"filters_view.title.search_torrent_filenames" = "搜尋種子檔案名"; -"filters_view.title.only_show_galleries_with_torrents" = "只顯示有種子的畫廊"; -"filters_view.title.search_low_power_tags" = "搜尋低期望標籤"; -"filters_view.title.search_downvoted_tags" = "搜尋低評價標籤"; -"filters_view.title.search_expunged_galleries" = "顯示被刪除的畫廊"; -"filters_view.title.set_minimum_rating" = "設定評分下限"; -"filters_view.title.minimum_rating" = "評分下限"; -"filters_view.title.set_pages_range" = "設定頁數範圍"; -"filters_view.title.pages_range" = "頁數範圍"; -"filters_view.title.disable_language_filter" = "停用語言篩選"; -"filters_view.title.disable_uploader_filter" = "停用上傳者篩選"; -"filters_view.title.disable_tags_filter" = "停用標籤篩選"; -"filters_view.button.reset_filters" = "重設所有選項"; -"filters_view.section.title.advanced" = "進階"; -"filters_view.section.title.default_filter" = "預設篩選"; -// FilterRange -"enum.filter_range.value.search" = "搜尋"; -"enum.filter_range.value.global" = "全域"; -"enum.filter_range.value.watched" = "標籤"; - -// MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 設定"; -"eh_setting_view.section.title.profile_settings" = "設定檔設定"; -"eh_setting_view.title.selected_profile" = "選擇設定檔"; -"eh_setting_view.button.set_as_default" = "設為預設"; -"eh_setting_view.button.delete_profile" = "刪除設定檔"; -"eh_setting_view.button.rename" = "重新命名"; -"eh_setting_view.button.create_new" = "新增設定檔"; -"eh_setting_view.toolbar_item.button.done" = "完成"; - -"eh_setting_view.section.title.image_load_settings" = "圖片來源設定"; -"eh_setting_view.title.load_images_through_the_hath_network" = "透過 Hath Network 載入圖片"; -"eh_setting_view.title.browsing_country" = "所在國家"; -"eh_setting_view.description.browsing_country" = "你似乎是從 **%@** 瀏覽這個網站或在使用當地的 VPN,這意味著網站將嘗試從這個地理區域的 H@H 用戶端載入圖片。如果這不正確或你出於任何原因想要使用不同的區域(例如你正在透過 VPN 連線),您可以在下面選擇不同的國家/地區。"; -// EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "任何用戶端"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "只使用預設連接埠的用戶端"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "No [現代/Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "No [傳統/Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "建議選項(預設)"; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "如果網路防火牆會阻擋任何非預設傳出連接埠則使用這個選項(可能較慢)"; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "E-Hentai 贊助者功能: 你將無法同時瀏覽多個頁面,僅在出現重大錯誤時才啟用這個選項"; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "E-Hentai 贊助者功能: 在現代瀏覽器上預設設定可能無法正常工作,僅推薦用於傳統瀏覽器"; - -"eh_setting_view.section.title.image_size_settings" = "圖片尺寸設定"; -"eh_setting_view.title.image_resolution" = "圖片解析度"; -"eh_setting_view.description.image_resolution" = "一般情況下圖片會被縮放成 1280 px 水平解析度以供線上瀏覽,你也可以選擇下列解析度之一。為了避免破壞伺服器正常運作,高於 1280x 的解析度暫時僅提供下列使用者使用: 贊助者、具有任何 Hath Perk 的使用者以及 UID 低於 3,000,000 的使用者"; -"eh_setting_view.title.image_size" = "圖片尺寸"; -"eh_setting_view.description.image_size" = "雖然網站會自動縮小圖片以適應瀏覽裝置的螢幕寬度,但您也可以手動限製圖片的最大顯示尺寸。 像自動縮放一樣,這不會重新採樣圖像,因為調整大小是在瀏覽器完成的 (0 = 沒有限制)"; -"eh_setting_view.title.horizontal" = "水平尺寸(寬)"; -"eh_setting_view.title.vertical" = "垂直尺寸(長)"; -// EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "自動"; - -"eh_setting_view.section.title.gallery_name_display" = "畫廊顯示名稱"; -"eh_setting_view.title.gallery_name" = "畫廊名稱"; -"eh_setting_view.description.gallery_name" = "許多畫廊同時提供了英文/預設標題與日文標題,你想優先顯示哪種畫廊名稱?"; -// EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "預設標題"; -"enum.eh_setting.gallery_name.value.japanese" = "日文標題(若該畫廊支援)"; - -"eh_setting_view.section.title.archiver_settings" = "存檔設定"; -"eh_setting_view.title.archiver_behavior" = "存檔邏輯設定"; -"eh_setting_view.description.archiver_behavior" = "存檔的預設邏輯是確認原始圖片或重新採樣後進行存檔的成本差異與選擇,然後顯示一個可以在其他地方點擊、複製的連結,你可以在此處更改他的運作方式。"; -// EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "手動選擇,手動開始下載(預設)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "手動選擇,自動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "自動選擇原始畫質,,手動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "自動選擇原始畫質並開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "自動選擇重新採樣,手動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "自動選擇重新採樣並開始下載"; - -"eh_setting_view.section.title.front_page_settings" = "首頁設定"; -"eh_setting_view.title.display_mode" = "顯示模式"; -"eh_setting_view.description.display_mode" = "你想在首頁和搜尋結果中使用哪一種顯示方式?"; -"eh_setting_view.section.title.show_search_range_indicator" = "搜尋範圍指示器"; -"eh_setting_view.title.show_search_range_indicator" = "顯示搜尋範圍指示器"; -"eh_setting_view.description.gallery_category" = "預設情況下你希望在首頁和搜尋結果中顯示哪些類別的結果?"; -// EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "緊湊(Compact)"; -"enum.eh_setting.display_mode.value.thumbnail" = "縮圖(Thumbnail)"; -"enum.eh_setting.display_mode.value.extended" = "放大(Extended)"; -"enum.eh_setting.display_mode.value.minimal" = "最小(Minimal)"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "可選用的 UI 元件"; -"eh_setting_view.description.optional_UI_elements" = "一些舊版 UI 元件現已預設停用。您可以在此啟用這些元件。"; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "在畫廊頁面啟用縮圖選擇器"; - -"eh_setting_view.section.title.favorites" = "收藏匣"; -"eh_setting_view.description.favorite_categories" = "在這裡你可以選擇並重新命名收藏匣"; -"eh_setting_view.title.favorites_sort_order" = "收藏匣排序"; -"eh_setting_view.description.favorites_sort_order" = "你還可以在收藏匣頁面上為畫廊選擇預設排列順序。 請注意,在 2016 年 3 月網站改版之前新增的畫廊並不儲存時間印記,並且無論使用何種設定,都將使用畫廊發佈時間作為排序參考。"; -// EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "透過最後更新時間排序"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "透過收藏順序排序"; - -"eh_setting_view.section.title.ratings" = "評分"; -"eh_setting_view.title.ratings_color" = "評分顏色"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "預設情況下,你評分的畫廊將 2 星及以下的評分顯示為紅色星,2.5 ~ 4 顆星的評分為綠色,4.5 ~ 5 顆星的評分為藍色。 透過在下面輸入顏色組合你可以自訂想顯示的顏色。 每個字母各代表一顆星(1~5),預設的 RRGGB 表示第一顆和第二顆星的 R(ed),第三顆和第四顆的 G(reen),第五顆的 B(lue)。 你也可以將 (Y)ellow 用於普通星星。 任何五個字母的 R/G/B/Y 組合都有效"; - -"eh_setting_view.section.title.tag_filtering_threshold" = "過濾標籤閾值"; -"eh_setting_view.title.tag_filtering_threshold" = "閾值"; -"eh_setting_view.description.tag_filtering_threshold" = "你可以透過將標籤新增到具有負數權重的“我的標籤”清單中來過濾標籤。 如果畫廊的標籤加起來的權重低於此值,則會被從列表中過濾掉,此閾值可以設定在 0 ~ -9999 之間"; - -"eh_setting_view.section.title.tag_watching_threshold" = "關注標籤閾值"; -"eh_setting_view.title.tag_watching_threshold" = "關注標籤生效的閾值"; -"eh_setting_view.description.tag_watching_threshold" = "如果最近上傳的畫廊中至少有一個具有正權重的你正在關注/追蹤的標籤,並且這些標籤所具有的權重高於此設定中的數值,那這個畫廊將會出現在「追蹤標籤」頁面中,此閾值可以設定在 0 ~ -9999 之間"; - -"eh_setting_view.section.title.filtered_removal_count" = "顯示過濾結果計數器"; -"eh_setting_view.description.filtered_removal_count" = "顯示 \"Your default filters removed XX galleries from this page\" ?"; -"eh_setting_view.title.show_filtered_removal_count" = "是否顯示過濾結果計數器"; - -"eh_setting_view.section.title.excluded_languages" = "排除語言"; -"eh_setting_view.description.excluded_languages" = "如果你希望從畫廊列表和搜尋中隱藏掉某些語言的畫廊,請從下面的清單中選取它們。請注意,無論你的搜尋查詢如何,相符於篩除規則的畫廊都不會出現。"; -// EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "原始語言"; -"enum.eh_setting.excluded_languages_category.value.translated" = "翻譯語言"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "覆寫"; - -"eh_setting_view.section.title.excluded_uploaders" = "排除的上傳者"; -"eh_setting_view.description.excluded_uploaders" = "如果你希望從畫廊列表和搜尋結果中隱藏某些上傳者的畫廊,請將它們新增到下方,每行輸入一個使用者名稱。請注意,無論你的搜尋結果如何,這些上傳者的畫廊都不會出現。"; -"eh_setting_view.description.excluded_uploaders_count" = "你正在使用 **%@ / %@** 排除欄位"; - -"eh_setting_view.section.title.search_result_count" = "搜尋結果數量上限"; -"eh_setting_view.title.result_count" = "數量上限"; -"eh_setting_view.description.result_count" = "你希望每頁顯示幾個搜尋結果?\n(該功能需要有 Hath Perk: Paging Enlargement)"; - -"eh_setting_view.section.title.thumbnail_settings" = "縮圖設定"; -"eh_setting_view.title.thumbnail_load_timing" = "縮圖載入時機設定"; -"eh_setting_view.description.thumbnail_load_timing" = "使用列表模式時,你希望如何載入以滑鼠位置顯示的縮圖?"; -"eh_setting_view.description.thumbnail_configuration" = "你可以為存取的所有畫廊設定預設的縮圖配置。"; -"eh_setting_view.title.thumbnail_size" = "尺寸"; -"eh_setting_view.title.thumbnail_row_count" = "行數"; -// EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "滑鼠位置"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "網頁載入位置"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "網頁的載入速度更快,但縮圖出現的時間可能稍有延遲"; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "網頁需要更多的載入時間,但是在網頁完全載入後縮圖顯示不會有任何延遲"; -// EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "正常"; -"enum.eh_setting.thumbnail_size.value.large" = "大型"; -"enum.eh_setting.thumbnail_size.value.small" = "小型"; -"enum.eh_setting.thumbnail_size.value.auto" = "自動"; - -"eh_setting_view.section.title.cover_scaling" = "封面縮放"; -"eh_setting_view.title.scale_factor" = "縮放比例"; -"eh_setting_view.description.cover_scale_factor" = "在縮圖與放大檢視這兩種檢視模式下,封面的重新採樣比率介於 75%% 至 150%%."; - -"eh_setting_view.section.title.viewport_override" = "視窗覆蓋"; -"eh_setting_view.title.virtual_width" = "虛擬寬度"; -"eh_setting_view.description.virtual_width" = "允許你覆蓋行動裝置網站的虛擬寬度。 這通常由你的裝置根據其 DPI 自動確定。 100%% 縮圖比例的合理值介於 640 和 1400 之間。"; - -"eh_setting_view.section.title.gallery_comments" = "畫廊留言"; -"eh_setting_view.title.comments_sort_order" = "留言排序方式"; -"eh_setting_view.title.comments_votes_show_timing" = "留言投票數顯示時機"; -// EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "最舊留言優先"; -"enum.eh_setting.comments_sort_order.value.recent" = "最新留言優先"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "最相關留言優先"; -// EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "滑鼠在分數上停留或點擊時"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "總是顯示"; - -"eh_setting_view.section.title.gallery_tags" = "畫廊標籤"; -"eh_setting_view.title.tags_sort_order" = "標籤顯示順序"; -// EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "字母順序"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "標籤權重"; - -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "畫廊頁面縮圖標籤"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "在畫廊縮圖下方顯示標籤"; - -"eh_setting_view.section.title.hath_local_network_host" = "內網 H@H 服務 (Hath Local Network Host)"; -"eh_setting_view.title.ip_address_port" = "IP 位址:連接埠號"; -"eh_setting_view.description.ip_address_port" = "如果你在內網路上使用與瀏覽站點相同的公共 IP 架設 H@H 用戶端,有些路由器會因此發生問題,無法將請求傳回自己的 IP,透過啟用這項設定可以解決此問題。\n如果您在瀏覽的同一裝置上執行用戶端,請使用回環地址 (127.0.0.1:port)。 如果用戶端在您網路上的另一台裝置上執行,請使用其本機網路 IP。 某些瀏覽器配置會阻止外部網站存取具有本機網路 IP 的 URL,你必須將站點列入白名單才能使其正常工作。"; - -"eh_setting_view.section.title.original_images" = "要使用原始圖片而非重新取樣的版本嗎? 若您在上方選擇「自動」以外的水準解析度且圖片較寬,或原始圖片大於 10 MiB(一年以上的圖庫則為 4 MiB),系統仍會使用重新取樣的圖片。"; -"eh_setting_view.title.use_original_images" = "使用原始圖片(原解析度)"; - -"eh_setting_view.section.title.multi_page_viewer" = "多頁瀏覽"; -"eh_setting_view.title.use_multi_page_viewer" = "使用多頁瀏覽"; -"eh_setting_view.title.display_style" = "顯示方式"; -"eh_setting_view.title.show_thumbnail_pane" = "顯示縮圖窗格"; -// EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "向左對齊,若寬度超出頁面則進行縮放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "置中對齊,若寬度超出頁面則進行縮放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "置中對齊且總是縮放"; -// EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "不顯示"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "只顯示頁碼"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "顯示頁碼和名稱"; - -// MARK: Category -"enum.category.value.doujinshi" = "同人誌"; -"enum.category.value.manga" = "漫畫"; -"enum.category.value.artist_CG" = "插畫"; -"enum.category.value.game_CG" = "遊戲 CG"; -"enum.category.value.western" = "西方"; -"enum.category.value.non_h" = "健康"; -"enum.category.value.image_set" = "圖片集"; -"enum.category.value.cosplay" = "角色扮演"; -"enum.category.value.asian_porn" = "亞洲"; -"enum.category.value.misc" = "其它"; -"enum.category.value.private" = "私人"; - -// MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "重新分類"; -"enum.tag_namespace.value.language" = "語言"; -"enum.tag_namespace.value.parody" = "原作"; -"enum.tag_namespace.value.character" = "角色"; -"enum.tag_namespace.value.group" = "團體"; -"enum.tag_namespace.value.artist" = "作者"; -"enum.tag_namespace.value.male" = "男性"; -"enum.tag_namespace.value.female" = "女性"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "其他"; -"enum.tag_namespace.value.temp" = "Temp"; - -// MARK: Language -"enum.language.value.invalid" = "無效"; -"enum.language.value.other" = "其它"; -"enum.language.value.afrikaans" = "南非語"; -"enum.language.value.albanian" = "阿爾巴尼亞語"; -"enum.language.value.arabic" = "阿拉伯語"; -"enum.language.value.bengali" = "孟加拉語"; -"enum.language.value.bosnian" = "波士尼亞語"; -"enum.language.value.bulgarian" = "保加利亞語"; -"enum.language.value.burmese" = "緬甸語"; -"enum.language.value.catalan" = "加泰隆尼亞語"; -"enum.language.value.cebuano" = "宿霧語"; -"enum.language.value.chinese" = "漢語"; -"enum.language.value.croatian" = "克羅埃西亞語"; -"enum.language.value.czech" = "捷克語"; -"enum.language.value.danish" = "丹麥語"; -"enum.language.value.dutch" = "荷蘭語"; -"enum.language.value.english" = "英語"; -"enum.language.value.esperanto" = "國際語"; -"enum.language.value.estonian" = "愛沙尼亞語"; -"enum.language.value.finnish" = "芬蘭語"; -"enum.language.value.french" = "法語"; -"enum.language.value.georgian" = "喬治亞語"; -"enum.language.value.german" = "德語"; -"enum.language.value.greek" = "希臘語"; -"enum.language.value.hebrew" = "希伯來語"; -"enum.language.value.hindi" = "印度語"; -"enum.language.value.hmong" = "苗語"; -"enum.language.value.hungarian" = "匈牙利語"; -"enum.language.value.indonesian" = "印尼語"; -"enum.language.value.italian" = "義大利語"; -"enum.language.value.japanese" = "日語"; -"enum.language.value.kazakh" = "哈薩克語"; -"enum.language.value.khmer" = "高棉語"; -"enum.language.value.korean" = "韓語"; -"enum.language.value.kurdish" = "庫德語"; -"enum.language.value.lao" = "寮語"; -"enum.language.value.latin" = "拉丁語"; -"enum.language.value.mongolian" = "蒙古語"; -"enum.language.value.ndebele" = "恩德貝萊語"; -"enum.language.value.nepali" = "尼泊爾語"; -"enum.language.value.norwegian" = "挪威語"; -"enum.language.value.oromo" = "奧羅莫語"; -"enum.language.value.pashto" = "普什圖語"; -"enum.language.value.persian" = "波斯語"; -"enum.language.value.polish" = "波蘭語"; -"enum.language.value.portuguese" = "葡萄牙語"; -"enum.language.value.punjabi" = "旁遮普語"; -"enum.language.value.romanian" = "羅馬尼亞語"; -"enum.language.value.russian" = "俄語"; -"enum.language.value.sango" = "桑戈語"; -"enum.language.value.serbian" = "塞爾維亞語"; -"enum.language.value.shona" = "紹納語"; -"enum.language.value.slovak" = "斯洛伐克語"; -"enum.language.value.slovenian" = "斯洛維尼亞語"; -"enum.language.value.somali" = "索馬利語"; -"enum.language.value.spanish" = "西班牙語"; -"enum.language.value.swahili" = "斯瓦希里語"; -"enum.language.value.swedish" = "瑞典語"; -"enum.language.value.tagalog" = "他加祿語"; -"enum.language.value.thai" = "泰語"; -"enum.language.value.tigrinya" = "提格利尼亞語"; -"enum.language.value.turkish" = "土耳其語"; -"enum.language.value.ukrainian" = "烏克蘭語"; -"enum.language.value.urdu" = "烏爾都語"; -"enum.language.value.vietnamese" = "越南語"; -"enum.language.value.zulu" = "祖魯語"; - -// MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "自動偵測"; -"enum.browsing_country.name.afghanistan" = "阿富汗"; -"enum.browsing_country.name.aland_islands" = "奧蘭群島"; -"enum.browsing_country.name.albania" = "阿爾巴尼亞"; -"enum.browsing_country.name.algeria" = "阿爾及利亞"; -"enum.browsing_country.name.american_samoa" = "美屬薩摩亞"; -"enum.browsing_country.name.andorra" = "安道爾"; -"enum.browsing_country.name.angola" = "安哥拉"; -"enum.browsing_country.name.anguilla" = "安圭拉"; -"enum.browsing_country.name.antarctica" = "南極洲"; -"enum.browsing_country.name.antigua_and_barbuda" = "安地卡及巴布達"; -"enum.browsing_country.name.argentina" = "阿根廷"; -"enum.browsing_country.name.armenia" = "亞美尼亞"; -"enum.browsing_country.name.aruba" = "阿魯巴"; -"enum.browsing_country.name.asia_pacific_region" = "亞太地區"; -"enum.browsing_country.name.australia" = "澳洲"; -"enum.browsing_country.name.austria" = "奧地利"; -"enum.browsing_country.name.azerbaijan" = "亞塞拜然"; -"enum.browsing_country.name.bahamas" = "巴哈馬"; -"enum.browsing_country.name.bahrain" = "巴林"; -"enum.browsing_country.name.bangladesh" = "孟加拉"; -"enum.browsing_country.name.barbados" = "巴貝多"; -"enum.browsing_country.name.belarus" = "白俄羅斯"; -"enum.browsing_country.name.belgium" = "比利時"; -"enum.browsing_country.name.belize" = "貝里斯"; -"enum.browsing_country.name.benin" = "貝南"; -"enum.browsing_country.name.bermuda" = "百慕達"; -"enum.browsing_country.name.bhutan" = "不丹"; -"enum.browsing_country.name.bolivia" = "玻利維亞"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "博奈爾、聖尤斯特歇斯和薩巴"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "波士尼亞"; -"enum.browsing_country.name.botswana" = "波札那"; -"enum.browsing_country.name.bouvet_island" = "布威島"; -"enum.browsing_country.name.brazil" = "巴西"; -"enum.browsing_country.name.british_indian_ocean_territory" = "英屬印度洋領地"; -"enum.browsing_country.name.brunei_darussalam" = "汶萊"; -"enum.browsing_country.name.bulgaria" = "保加利亞"; -"enum.browsing_country.name.burkina_faso" = "布吉納法索"; -"enum.browsing_country.name.burundi" = "蒲隆地"; -"enum.browsing_country.name.cambodia" = "柬埔寨"; -"enum.browsing_country.name.cameroon" = "喀麥隆"; -"enum.browsing_country.name.canada" = "加拿大"; -"enum.browsing_country.name.cape_verde" = "維德角"; -"enum.browsing_country.name.cayman_islands" = "開曼群島"; -"enum.browsing_country.name.central_african_republic" = "中非"; -"enum.browsing_country.name.chad" = "查德"; -"enum.browsing_country.name.chile" = "智利"; -"enum.browsing_country.name.china" = "中國"; -"enum.browsing_country.name.christmas_island" = "聖誕島"; -"enum.browsing_country.name.cocos_islands" = "科科斯(基林)群島"; -"enum.browsing_country.name.colombia" = "哥倫比亞"; -"enum.browsing_country.name.comoros" = "葛摩"; -"enum.browsing_country.name.congo" = "剛果共和國"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "剛果民主共和國"; -"enum.browsing_country.name.cook_islands" = "庫克群島"; -"enum.browsing_country.name.costa_rica" = "哥斯大黎加"; -"enum.browsing_country.name.cote_d_ivoire" = "象牙海岸"; -"enum.browsing_country.name.croatia" = "克羅埃西亞"; -"enum.browsing_country.name.cuba" = "古巴"; -"enum.browsing_country.name.curacao" = "古拉索"; -"enum.browsing_country.name.cyprus" = "賽普勒斯"; -"enum.browsing_country.name.czech_republic" = "捷克共和國"; -"enum.browsing_country.name.denmark" = "丹麥"; -"enum.browsing_country.name.djibouti" = "吉布地"; -"enum.browsing_country.name.dominica" = "多米尼克"; -"enum.browsing_country.name.dominican_republic" = "多明尼加"; -"enum.browsing_country.name.ecuador" = "厄瓜多"; -"enum.browsing_country.name.egypt" = "埃及"; -"enum.browsing_country.name.el_salvador" = "薩爾瓦多"; -"enum.browsing_country.name.equatorial_guinea" = "赤道幾內亞"; -"enum.browsing_country.name.eritrea" = "厄利垂亞"; -"enum.browsing_country.name.estonia" = "愛沙尼亞"; -"enum.browsing_country.name.ethiopia" = "衣索比亞"; -"enum.browsing_country.name.europe" = "歐洲"; -"enum.browsing_country.name.falkland_islands" = "福克蘭群島"; -"enum.browsing_country.name.faroe_islands" = "法羅群島"; -"enum.browsing_country.name.fiji" = "斐濟"; -"enum.browsing_country.name.finland" = "芬蘭"; -"enum.browsing_country.name.france" = "法國"; -"enum.browsing_country.name.french_guiana" = "法屬圭亞那"; -"enum.browsing_country.name.french_polynesia" = "法屬玻里尼西亞"; -"enum.browsing_country.name.french_southern_territories" = "法屬南部領土"; -"enum.browsing_country.name.gabon" = "加彭"; -"enum.browsing_country.name.gambia" = "甘比亞"; -"enum.browsing_country.name.georgia" = "喬治亞"; -"enum.browsing_country.name.germany" = "德國"; -"enum.browsing_country.name.ghana" = "迦納"; -"enum.browsing_country.name.gibraltar" = "直布羅陀"; -"enum.browsing_country.name.greece" = "希臘"; -"enum.browsing_country.name.greenland" = "格陵蘭"; -"enum.browsing_country.name.grenada" = "格瑞那達"; -"enum.browsing_country.name.guadeloupe" = "瓜地洛普"; -"enum.browsing_country.name.guam" = "關島"; -"enum.browsing_country.name.guatemala" = "瓜地馬拉"; -"enum.browsing_country.name.guernsey" = "耿西"; -"enum.browsing_country.name.guinea" = "幾內亞"; -"enum.browsing_country.name.guinea_bissau" = "幾內亞比索"; -"enum.browsing_country.name.guyana" = "蓋亞那"; -"enum.browsing_country.name.haiti" = "海地"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "赫德島和麥克唐納群島"; -"enum.browsing_country.name.vatican_city_state" = "梵蒂岡"; -"enum.browsing_country.name.honduras" = "宏都拉斯"; -"enum.browsing_country.name.hong_kong" = "香港"; -"enum.browsing_country.name.hungary" = "匈牙利"; -"enum.browsing_country.name.iceland" = "冰島"; -"enum.browsing_country.name.india" = "印度"; -"enum.browsing_country.name.indonesia" = "印度尼西亞(印尼)"; -"enum.browsing_country.name.iran" = "伊朗"; -"enum.browsing_country.name.iraq" = "伊拉克"; -"enum.browsing_country.name.ireland" = "愛爾蘭"; -"enum.browsing_country.name.isle_of_man" = "曼島"; -"enum.browsing_country.name.israel" = "以色列"; -"enum.browsing_country.name.italy" = "義大利"; -"enum.browsing_country.name.jamaica" = "牙買加"; -"enum.browsing_country.name.japan" = "日本"; -"enum.browsing_country.name.jersey" = "澤西島"; -"enum.browsing_country.name.jordan" = "約旦"; -"enum.browsing_country.name.kazakhstan" = "哈薩克共和國"; -"enum.browsing_country.name.kenya" = "肯亞"; -"enum.browsing_country.name.kiribati" = "吉里巴斯"; -"enum.browsing_country.name.kuwait" = "科威特"; -"enum.browsing_country.name.kyrgyzstan" = "吉爾吉斯"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "寮國"; -"enum.browsing_country.name.latvia" = "拉脫維亞"; -"enum.browsing_country.name.lebanon" = "黎巴嫩"; -"enum.browsing_country.name.lesotho" = "賴索托"; -"enum.browsing_country.name.liberia" = "賴比瑞亞"; -"enum.browsing_country.name.libya" = "利比亞"; -"enum.browsing_country.name.liechtenstein" = "列支敦斯登"; -"enum.browsing_country.name.lithuania" = "立陶宛"; -"enum.browsing_country.name.luxembourg" = "盧森堡"; -"enum.browsing_country.name.macau" = "澳門"; -"enum.browsing_country.name.macedonia" = "北馬其頓"; -"enum.browsing_country.name.madagascar" = "馬達加斯加"; -"enum.browsing_country.name.malawi" = "馬拉威"; -"enum.browsing_country.name.malaysia" = "馬來西亞"; -"enum.browsing_country.name.maldives" = "馬爾地夫"; -"enum.browsing_country.name.mali" = "馬利"; -"enum.browsing_country.name.malta" = "馬爾他"; -"enum.browsing_country.name.marshall_islands" = "馬紹爾群島"; -"enum.browsing_country.name.martinique" = "馬丁尼克"; -"enum.browsing_country.name.mauritania" = "茅利塔尼亞"; -"enum.browsing_country.name.mauritius" = "模里西斯"; -"enum.browsing_country.name.mayotte" = "馬約特"; -"enum.browsing_country.name.mexico" = "墨西哥"; -"enum.browsing_country.name.micronesia" = "密克羅尼西亞"; -"enum.browsing_country.name.moldova" = "摩爾多瓦"; -"enum.browsing_country.name.monaco" = "摩納哥"; -"enum.browsing_country.name.mongolia" = "蒙古"; -"enum.browsing_country.name.montenegro" = "蒙特內哥羅"; -"enum.browsing_country.name.montserrat" = "蒙特塞拉特"; -"enum.browsing_country.name.morocco" = "摩洛哥"; -"enum.browsing_country.name.mozambique" = "莫三比克"; -"enum.browsing_country.name.myanmar" = "緬甸"; -"enum.browsing_country.name.namibia" = "納米比亞"; -"enum.browsing_country.name.nauru" = "諾魯"; -"enum.browsing_country.name.nepal" = "尼泊爾"; -"enum.browsing_country.name.netherlands" = "荷蘭"; -"enum.browsing_country.name.new_caledonia" = "新喀里多尼亞"; -"enum.browsing_country.name.new_zealand" = "紐西蘭"; -"enum.browsing_country.name.nicaragua" = "尼加拉瓜"; -"enum.browsing_country.name.niger" = "尼日"; -"enum.browsing_country.name.nigeria" = "奈及利亞"; -"enum.browsing_country.name.niue" = "紐埃"; -"enum.browsing_country.name.norfolk_island" = "諾福克島"; -"enum.browsing_country.name.north_korea" = "朝鮮"; -"enum.browsing_country.name.northern_mariana_islands" = "北馬里亞納群島"; -"enum.browsing_country.name.norway" = "挪威"; -"enum.browsing_country.name.oman" = "阿曼"; -"enum.browsing_country.name.pakistan" = "巴基斯坦"; -"enum.browsing_country.name.palau" = "帛琉"; -"enum.browsing_country.name.palestinian_territory" = "巴勒斯坦領土"; -"enum.browsing_country.name.panama" = "巴拿馬"; -"enum.browsing_country.name.papua_new_guinea" = "巴布亞紐幾內亞"; -"enum.browsing_country.name.paraguay" = "巴拉圭"; -"enum.browsing_country.name.peru" = "秘魯"; -"enum.browsing_country.name.philippines" = "菲律賓"; -"enum.browsing_country.name.pitcairn_islands" = "皮特凱恩群島"; -"enum.browsing_country.name.poland" = "波蘭"; -"enum.browsing_country.name.portugal" = "葡萄牙"; -"enum.browsing_country.name.puerto_rico" = "波多黎各"; -"enum.browsing_country.name.qatar" = "卡達"; -"enum.browsing_country.name.reunion" = "留尼旺"; -"enum.browsing_country.name.romania" = "羅馬尼亞"; -"enum.browsing_country.name.russian_federation" = "俄羅斯"; -"enum.browsing_country.name.rwanda" = "盧安達"; -"enum.browsing_country.name.saint_barthelemy" = "聖巴瑟米"; -"enum.browsing_country.name.saint_helena" = "聖赫勒拿"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "聖克里斯多福及尼維斯"; -"enum.browsing_country.name.saint_lucia" = "聖露西亞"; -"enum.browsing_country.name.saint_martin" = "聖馬丁"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "聖皮耶與密克隆"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "聖文森及格瑞那丁"; -"enum.browsing_country.name.samoa" = "薩摩亞"; -"enum.browsing_country.name.san_marino" = "聖馬利諾"; -"enum.browsing_country.name.sao_tome_and_principe" = "聖多美普林西比"; -"enum.browsing_country.name.saudi_arabia" = "沙烏地阿拉伯"; -"enum.browsing_country.name.senegal" = "塞內加爾"; -"enum.browsing_country.name.serbia" = "塞爾維亞"; -"enum.browsing_country.name.seychelles" = "塞席爾"; -"enum.browsing_country.name.sierra_leone" = "獅子山"; -"enum.browsing_country.name.singapore" = "新加坡"; -"enum.browsing_country.name.sint_maarten" = "聖馬丁"; -"enum.browsing_country.name.slovakia" = "斯洛伐克"; -"enum.browsing_country.name.slovenia" = "斯洛維尼亞"; -"enum.browsing_country.name.solomon_islands" = "索羅門群島"; -"enum.browsing_country.name.somalia" = "索馬利亞"; -"enum.browsing_country.name.south_africa" = "南非"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "南喬治亞和南桑威奇群島"; -"enum.browsing_country.name.south_korea" = "韓國"; -"enum.browsing_country.name.south_sudan" = "南蘇丹"; -"enum.browsing_country.name.spain" = "西班牙"; -"enum.browsing_country.name.sri_lanka" = "斯里蘭卡"; -"enum.browsing_country.name.sudan" = "蘇丹"; -"enum.browsing_country.name.suriname" = "蘇利南"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "斯瓦巴和揚馬延"; -"enum.browsing_country.name.swaziland" = "史瓦帝尼"; -"enum.browsing_country.name.sweden" = "瑞典"; -"enum.browsing_country.name.switzerland" = "瑞士"; -"enum.browsing_country.name.syrian_arab_republic" = "敘利亞"; -"enum.browsing_country.name.taiwan" = "臺灣"; -"enum.browsing_country.name.tajikistan" = "塔吉克"; -"enum.browsing_country.name.tanzania" = "坦尚尼亞"; -"enum.browsing_country.name.thailand" = "泰國"; -"enum.browsing_country.name.timor_leste" = "東帝汶"; -"enum.browsing_country.name.togo" = "多哥"; -"enum.browsing_country.name.tokelau" = "托克勞"; -"enum.browsing_country.name.tonga" = "東加"; -"enum.browsing_country.name.trinidad_and_tobago" = "千里達和托巴哥"; -"enum.browsing_country.name.tunisia" = "突尼西亞"; -"enum.browsing_country.name.turkey" = "土耳其"; -"enum.browsing_country.name.turkmenistan" = "土庫曼"; -"enum.browsing_country.name.turks_and_caicos_islands" = "土克斯及開科斯群島"; -"enum.browsing_country.name.tuvalu" = "吐瓦魯"; -"enum.browsing_country.name.uganda" = "烏干達"; -"enum.browsing_country.name.ukraine" = "烏克蘭"; -"enum.browsing_country.name.united_arab_emirates" = "阿拉伯聯合大公國"; -"enum.browsing_country.name.united_kingdom" = "英國"; -"enum.browsing_country.name.united_states" = "美國"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "美國外圍小島嶼"; -"enum.browsing_country.name.uruguay" = "烏拉圭"; -"enum.browsing_country.name.uzbekistan" = "烏茲別克"; -"enum.browsing_country.name.vanuatu" = "萬那杜"; -"enum.browsing_country.name.venezuela" = "委內瑞拉"; -"enum.browsing_country.name.vietnam" = "越南"; -"enum.browsing_country.name.virgin_islands_british" = "英屬維京群島"; -"enum.browsing_country.name.virgin_islands_US" = "美屬維京群島"; -"enum.browsing_country.name.wallis_and_futuna" = "瓦利斯和富圖納"; -"enum.browsing_country.name.western_sahara" = "西撒哈拉"; -"enum.browsing_country.name.yemen" = "葉門"; -"enum.browsing_country.name.zambia" = "尚比亞"; -"enum.browsing_country.name.zimbabwe" = "辛巴威"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 94249b5cd..ffb0c2017 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -1,235 +1,232 @@ // MARK: BanInterval -"enum.ban_interval.description.and" = ""; +"ban_interval.and" = ""; // MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "昨天"; -"enum.toplists_type.value.past_month" = "上個月"; -"enum.toplists_type.value.past_year" = "去年"; -"enum.toplists_type.value.all_time" = "所有時間"; +"toplists_type.yesterday" = "昨天"; +"toplists_type.past_month" = "上個月"; +"toplists_type.past_year" = "去年"; +"toplists_type.all_time" = "所有時間"; // MARK: Response -"website.response.hath_client_not_found" = "你需要一個連結到帳號的 H@H 用戶端才能使用這個功能"; -"website.response.hath_client_not_online" = "你的 H@H 用戶端為離線狀態,請啟動後再試"; -"website.response.invalid_resolution" = "該畫廊不能以目前選擇的解析度下載"; +"hath_client_not_found" = "你需要一個連結到帳號的 H@H 用戶端才能使用這個功能"; +"hath_client_not_online" = "你的 H@H 用戶端為離線狀態,請啟動後再試"; +"invalid_resolution" = "該畫廊不能以目前選擇的解析度下載"; // MARK: Toast -"toast.title.error" = "錯誤"; -"toast.title.success" = "成功"; -"toast.title.loading" = "載入中..."; -"toast.title.communicating" = "連線中..."; -"toast.caption.copied_to_clipboard" = "已複製到剪貼簿"; -"toast.caption.saved_to_photo_library" = "已儲存到照片"; +"toast.error" = "錯誤"; +"toast.success" = "成功"; +"toast.loading" = "載入中..."; +"toast.communicating" = "連線中..."; +"toast.copied_to_clipboard" = "已複製到剪貼簿"; +"toast.saved_to_photo_library" = "已儲存到照片"; // MARK: AutoLock "local_authorization.reason" = "由於超過 APP 自動鎖定期限,APP 已被鎖定,請重新解鎖"; // MARK: Common value -"common.value.stars" = "%@ 星"; -"common.value.pages" = "%@ 頁"; -"common.value.times" = "%@ 次"; -"common.value.day" = "%@ 天"; -"common.value.days" = "%@ 天"; -"common.value.hour" = "%@ 小時"; -"common.value.hours" = "%@ 小時"; -"common.value.minute" = "%@ 分"; -"common.value.minutes" = "%@ 分"; -"common.value.second" = "%@ 秒"; -"common.value.seconds" = "%@ 秒"; -"common.value.records" = "%@ 筆紀錄"; +"common.stars" = "%@ 星"; +"common.pages" = "%@ 頁"; +"common.day" = "%@ 天"; +"common.days" = "%@ 天"; +"common.hour" = "%@ 小時"; +"common.hours" = "%@ 小時"; +"common.minute" = "%@ 分"; +"common.minutes" = "%@ 分"; +"common.second" = "%@ 秒"; +"common.seconds" = "%@ 秒"; // MARK: Common button -"common.button.cancel" = "取消"; +"common.cancel" = "取消"; // MARK: TabItem -"tab_item.title.home" = "總覽"; -"tab_item.title.favorites" = "收藏"; -"tab_item.title.search" = "搜尋"; -"tab_item.title.downloads" = "下載"; -"tab_item.title.setting" = "設定"; +"tab_item.home" = "總覽"; +"tab_item.favorites" = "收藏"; +"tab_item.search" = "搜尋"; +"tab_item.downloads" = "下載"; +"tab_item.setting" = "設定"; // MARK: ToolbarItem -"toolbar_item.button.filters" = "過濾"; -"toolbar_item.button.jump_page" = "跳到..."; -"toolbar_item.button.date_seek" = "日期定位"; -"toolbar_item.button.quick_search" = "快速搜尋"; +"toolbar_item.filters" = "過濾"; +"toolbar_item.jump_page" = "跳到..."; +"toolbar_item.date_seek" = "日期定位"; +"toolbar_item.quick_search" = "快速搜尋"; // MARK: DateSeek -"date_seek_view.title.date_seek" = "日期定位"; -"date_seek_view.title.date" = "日期"; -"date_seek_view.footer.seek_around_date" = "前往所選日期附近的畫廊。"; -"date_seek_view.button.seek_newer" = "較新"; -"date_seek_view.button.seek_older" = "較舊"; +"date_seek_view.date_seek" = "日期定位"; +"date_seek_view.date" = "日期"; +"date_seek_view.seek_around_date" = "前往所選日期附近的畫廊。"; +"date_seek_view.seek_newer" = "較新"; +"date_seek_view.seek_older" = "較舊"; // MARK: JumpPage -"jump_page_view.title.jump_page" = "跳到..."; -"jump_page_view.description.jump_page" = "請輸入 1 到 %d 之間的頁碼。"; -"jump_page_view.button.confirm" = "確定"; +"jump_page_view.jump_page" = "跳到..."; +"jump_page_view.jump_page_description" = "請輸入 1 到 %d 之間的頁碼。"; +"jump_page_view.confirm" = "確定"; // MARK: AlertView -"loading_view.title.loading" = "載入中..."; -"loading_view.title.preparing_database" = "正在準備..."; -"not_login_view.title.need_login" = "你需要登入才能完成這個動作"; -"not_login_view.button.login" = "登入"; -"error_view.button.retry" = "重試"; -"error_view.button.drop_database" = "刪除資料庫"; -"error_view.title.try_later" = "請稍後再試"; -"error_view.title.network" = "網路發生故障,請檢查網路狀態"; -"error_view.title.parsing" = "網頁解析器發生故障"; -"error_view.title.unknown" = "發生不明錯誤"; -"error_view.title.not_found" = "這邊看起來空空如也"; -"error_view.title.database_corrupted" = "資料庫已經損壞\n請提交 issue 至 GitHub."; -"error_view.title.ip_banned" = "你的 IP 因為頁面載入次數過於頻繁而被暫時禁止存取,這可能是因為你正在使用爬蟲/鏡像軟體,禁止存取將在 %@ 後解除"; -"error_view.title.copyright_claim" = "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取"; -"error_view.title.gallery_unavailable" = "此畫廊已被刪除或你沒有權限存取"; +"loading_view.loading" = "載入中..."; +"loading_view.preparing_database" = "正在準備..."; +"not_login_view.need_login" = "你需要登入才能完成這個動作"; +"not_login_viewlogin" = "登入"; +"error_view.retry" = "重試"; +"error_view.drop_database" = "刪除資料庫"; +"error_view.try_later" = "請稍後再試"; +"error_view.network" = "網路發生故障,請檢查網路狀態"; +"error_view.parsing" = "網頁解析器發生故障"; +"error_view.unknown" = "發生不明錯誤"; +"error_view.not_found" = "這邊看起來空空如也"; +"error_view.database_corrupted" = "資料庫已經損壞\n請提交 issue 至 GitHub."; +"error_view.ip_banned" = "你的 IP 因為頁面載入次數過於頻繁而被暫時禁止存取,這可能是因為你正在使用爬蟲/鏡像軟體,禁止存取將在 %@ 後解除"; +"error_view.copyright_claim" = "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取"; +"error_view.gallery_unavailable" = "此畫廊已被刪除或你沒有權限存取"; // MARK: AppError -"app_error.localized_description.database_corrupted" = "資料庫損壞"; -"app_error.localized_description.copyright_claim" = "版權聲明"; -"app_error.localized_description.ip_banned" = "IP 已封禁"; -"app_error.localized_description.gallery_expunged" = "畫廊已刪除"; -"app_error.localized_description.network_error" = "網絡錯誤"; -"app_error.localized_description.web_image_loading_error" = "網頁圖片載入錯誤"; -"app_error.localized_description.parse_error" = "解析錯誤"; -"app_error.localized_description.quota_exceeded" = "流量額度已用盡"; -"app_error.localized_description.authentication_required" = "需要登入"; -"app_error.localized_description.file_operation_failed" = "檔案操作失敗"; -"app_error.localized_description.no_updates_available" = "沒有可用更新"; -"app_error.localized_description.not_found" = "未找到"; -"app_error.localized_description.unknown_error" = "未知錯誤"; -"app_error.alert.quota_exceeded" = "圖片流量額度已用盡。\n請稍後再試。"; -"app_error.alert.authentication_required" = "存取此下載內容需要登入。"; -"app_error.alert.local_file_operation_failed" = "本機檔案操作失敗。"; +"app_error.database_corrupted" = "資料庫損壞"; +"app_error.copyright_claim" = "版權聲明"; +"app_error.ip_banned" = "IP 已封禁"; +"app_error.gallery_expunged" = "畫廊已刪除"; +"app_error.network_error" = "網路錯誤"; +"app_error.web_image_loading_error" = "網頁圖片載入錯誤"; +"app_error.parse_error" = "解析錯誤"; +"app_error.quota_exceeded" = "流量額度已用盡"; +"app_error.authentication_required" = "需要登入"; +"app_error.file_operation_failed" = "檔案操作失敗"; +"app_error.no_updates_available" = "沒有可用更新"; +"app_error.not_found" = "未找到"; +"app_error.unknown_error" = "未知錯誤"; +"app_error.quota_exceeded_description" = "圖片流量額度已用盡。\n請稍後再試。"; +"app_error.authentication_required_description" = "存取此下載內容需要登入。"; +"app_error.local_file_operation_failed" = "本機檔案操作失敗。"; // MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?"; -"confirmation_dialog.title.remove_custom_translations" = "是否確定要刪除所有自訂翻譯?"; -"confirmation_dialog.title.logout" = "確定要登出嗎?"; -"confirmation_dialog.title.delete" = "確定要刪除?"; -"confirmation_dialog.title.clear" = "確定要清空嗎?"; -"confirmation_dialog.title.reset" = "確定要重設嗎?"; -"confirmation_dialog.button.drop_database" = "刪除資料庫"; -"confirmation_dialog.button.remove" = "移除"; -"confirmation_dialog.button.logout" = "登出"; -"confirmation_dialog.button.delete" = "刪除"; -"confirmation_dialog.button.clear" = "清空"; -"confirmation_dialog.button.reset" = "重設"; +"confirmation_dialog.drop_database_description" = "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?"; +"confirmation_dialog.remove_custom_translations" = "是否確定要刪除所有自訂翻譯?"; +"confirmation_dialog.logout_description" = "確定要登出嗎?"; +"confirmation_dialog.delete_description" = "確定要刪除?"; +"confirmation_dialog.clear_description" = "確定要清空嗎?"; +"confirmation_dialog.reset_description" = "確定要重設嗎?"; +"confirmation_dialog.drop_database" = "刪除資料庫"; +"confirmation_dialog.remove" = "移除"; +"confirmation_dialog.logout" = "登出"; +"confirmation_dialog.delete" = "刪除"; +"confirmation_dialog.clear" = "清空"; +"confirmation_dialog.reset" = "重設"; // MARK: SubSection -"sub_section.button.show_all" = "顯示全部"; +"sub_section.show_all" = "顯示全部"; // MARK: NewDawnView -"new_dawn_view.title.first" = "現在是嶄新的一天!"; -"new_dawn_view.title.second" = "回顧到目前為止的旅程,你發現自己睿智了一點。"; +"new_dawn_view.first" = "現在是嶄新的一天!"; +"new_dawn_view.second" = "回顧到目前為止的旅程,你發現自己睿智了一點。"; // Greeting -"struct.greeting.mark.start" = "你獲得了 "; -"struct.greeting.mark.separator" = "、"; -"struct.greeting.mark.and" = " 和 "; -"struct.greeting.mark.end" = "!"; +"greeting.start" = "你獲得了 "; +"greeting.separator" = "、"; +"greeting.and" = " 和 "; +"greeting.end" = "!"; // MARK: HomeView -"home_view.title.home" = "總覽"; -"home_view.section.title.frontpage" = "首頁"; -"home_view.section.title.toplists" = "排行"; -"home_view.section.title.other" = "其他"; +"home_view.home" = "總覽"; +"home_view.frontpage" = "首頁"; +"home_view.toplists" = "排行"; +"home_view.other" = "其他"; // HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "熱門"; -"enum.home_misc_grid_type.title.watched" = "關注"; -"enum.home_misc_grid_type.title.history" = "歷程"; +"home_misc_grid_type.popular" = "熱門"; +"home_misc_grid_type.watched" = "關注"; +"home_misc_grid_type.history" = "歷程"; // MARK: FrontpageView -"frontpage_view.title.frontpage" = "首頁"; +"frontpage_view.frontpage" = "首頁"; // MARK: ToplistsView -"toplists_view.title.toplists" = "排行"; +"toplists_view.toplists" = "排行"; // MARK: PopularView -"popular_view.title.popular" = "熱門"; +"popular_view.popular" = "熱門"; // MARK: WatchedView -"watched_view.title.watched" = "關注"; +"watched_view.watched" = "關注"; // MARK: HistoryView -"history_view.title.history" = "歷程"; +"history_view.history" = "歷程"; // MARK: FavoritesView -"favorites_view.title.favorites" = "收藏"; +"favorites_view.favorites" = "收藏"; // FavoriteCategory -"struct.user.favorite_category.default" = "收藏匣 %@"; -"struct.user.favorite_category.all" = "全部"; +"favorite_category.default" = "收藏匣 %@"; +"favorite_category.all" = "全部"; // MARK: SearchView -"search_view.title.search" = "搜尋"; -"search_view.section.title.recently_searched" = "最近搜尋"; -"search_view.section.title.recently_seen" = "最近閱讀"; -"search_view.section.title.quick_search" = "快速搜尋"; +"search_view.search" = "搜尋"; +"search_view.recently_searched" = "最近搜尋"; +"search_view.recently_seen" = "最近閱讀"; +"search_view.quick_search" = "快速搜尋"; // Searchable -"searchable.prompt.filter" = "過濾"; -"searchable.title.matches_count" = "找到 %d 項結果"; +"searchable.filter" = "過濾"; +"searchable.matches_count" = "找到 %d 項結果"; // MARK: QuickSearchView -"quick_search_view.title.quick_search" = "快速搜尋"; -"quick_search_view.title.edit_word" = "編輯關鍵字"; -"quick_search_view.title.new_word" = "新關鍵字"; -"quick_search_view.title.content" = "搜尋內容"; -"quick_search_view.title.name" = "名稱"; -"quick_search_view.placeholder.optional" = "(可選)"; +"quick_search_view.quick_search" = "快速搜尋"; +"quick_search_view.edit_word" = "編輯關鍵字"; +"quick_search_view.new_word" = "新關鍵字"; +"quick_search_view.content" = "搜尋內容"; +"quick_search_view.name" = "名稱"; +"quick_search_view.optional" = "(可選)"; // MARK: SettingView -"setting_view.title.setting" = "設定"; +"setting_view.setting" = "設定"; // SettingStateRoute -"enum.setting_state_route.value.account" = "帳號"; -"enum.setting_state_route.value.general" = "一般"; -"enum.setting_state_route.value.appearance" = "外觀"; -"enum.setting_state_route.value.reading" = "閱讀"; -"enum.setting_state_route.value.download" = "下載"; -"enum.setting_state_route.value.laboratory" = "實驗性功能"; -"enum.setting_state_route.value.about" = "關於"; +"setting_state_route.account" = "帳號"; +"setting_state_route.general" = "一般"; +"setting_state_route.appearance" = "外觀"; +"setting_state_route.reading" = "閱讀"; +"setting_state_route.download" = "下載"; +"setting_state_route.laboratory" = "實驗性功能"; +"setting_state_route.about" = "關於"; // MARK: AccountSettingView -"account_setting_view.title.account" = "帳號設定"; -"account_setting_view.title.shows_new_dawn_greeting" = "顯示黎明問候"; -"account_setting_view.button.login" = "登入"; -"account_setting_view.button.logout" = "登出"; -"account_setting_view.button.account_configuration" = "帳號設定"; -"account_setting_view.button.tags_management" = "管理訂閱標籤"; -"account_setting_view.button.copy_cookies" = "複製 Cookies"; +"account_setting_view.account" = "帳號設定"; +"account_setting_view.shows_new_dawn_greeting" = "顯示黎明問候"; +"account_setting_view.login" = "登入"; +"account_setting_view.account_configuration" = "帳號設定"; +"account_setting_view.tags_management" = "管理訂閱標籤"; +"account_setting_view.copy_cookies" = "複製 Cookies"; // CookieValue -"struct.cookie_value.localized_string.expired" = "已過期"; -"struct.cookie_value.localized_string.mystery" = "被拒絕"; -"struct.cookie_value.localized_string.none" = "None"; +"cookie_value.expired" = "已過期"; +"cookie_value.mystery" = "被拒絕"; +"cookie_value.none" = "None"; // MARK: LoginView -"login_view.title.login" = "登入"; -"login_view.title.username" = "Username"; -"login_view.title.password" = "Password"; +"login_view.login" = "登入"; +"login_view.username" = "Username"; +"login_view.password" = "Password"; // MARK: GeneralSettingView -"general_setting_view.title.general" = "一般設定"; -"general_setting_view.title.language" = "語言"; -"general_setting_view.title.auto_lock" = "自動鎖定"; -"general_setting_view.title.enables_tags_extension" = "啟用自訂標籤擴充功能"; -"general_setting_view.title.translates_tags" = "標籤翻譯"; -"general_setting_view.title.shows_tags_search_suggestion" = "搜尋時顯示標籤建議"; -"general_setting_view.title.shows_images_in_tags" = "在標籤顯示圖片"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "將連結重新導向至選擇的網站"; -"general_setting_view.title.detects_links_from_clipboard" = "偵測剪貼簿中的連結"; -"general_setting_view.title.background_blur_radius" = "後台背景模糊"; -"general_setting_view.button.app_activity_logs" = "應用程式活動日誌"; -"general_setting_view.button.import_custom_translations" = "匯入自訂標籤翻譯"; -"general_setting_view.button.remove_custom_translations" = "刪除自訂標籤翻譯"; -"general_setting_view.button.clear_image_caches" = "清理圖片快取"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "標籤"; -"general_setting_view.section.title.navigation" = "導覽"; -"general_setting_view.section.title.security" = "安全"; -"general_setting_view.section.title.caches" = "快取"; +"general_setting_view.general" = "一般設定"; +"general_setting_view.language" = "語言"; +"general_setting_view.auto_lock" = "自動鎖定"; +"general_setting_view.enables_tags_extension" = "啟用自訂標籤擴充功能"; +"general_setting_view.translates_tags" = "標籤翻譯"; +"general_setting_view.shows_tags_search_suggestion" = "搜尋時顯示標籤建議"; +"general_setting_view.shows_images_in_tags" = "在標籤顯示圖片"; +"general_setting_view.redirects_links_to_the_selected_host" = "將連結重新導向至選擇的網站"; +"general_setting_view.detects_links_from_clipboard" = "偵測剪貼簿中的連結"; +"general_setting_view.background_blur_radius" = "後台背景模糊"; +"general_setting_view.app_activity_logs" = "應用程式活動日誌"; +"general_setting_view.import_custom_translations" = "匯入自訂標籤翻譯"; +"general_setting_view.remove_custom_translations" = "刪除自訂標籤翻譯"; +"general_setting_view.clear_image_caches" = "清理圖片快取"; +"general_setting_view.default_language_description" = "N/A"; +"general_setting_view.tags" = "標籤"; +"general_setting_view.navigation" = "導覽"; +"general_setting_view.security" = "安全"; +"general_setting_view.caches" = "快取"; // AutoLockPolicy -"enum.auto_lock_policy.value.never" = "永不自動鎖定"; -"enum.auto_lock_policy.value.instantly" = "立刻"; +"auto_lock_policy.never" = "永不自動鎖定"; +"auto_lock_policy.instantly" = "立刻"; // MARK: AppActivityLogsView "app_activity_logs_view.title" = "應用程式活動日誌"; -"app_activity_logs_view.placeholder.no_logs" = "找不到日誌"; -"app_activity_logs_view.section.current" = "目前"; +"app_activity_logs_view.no_logs" = "找不到日誌"; +"app_activity_logs_view.current" = "目前"; "app_activity_logs_view.run" = "運行 %@"; "app_activity_logs_view.more_logs" = "更多日誌"; "app_activity_logs_view.open_in_files" = "在「檔案」中開啟"; @@ -242,810 +239,786 @@ "app_activity_logs_view.level.fault" = "故障"; // MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "外觀設定"; -"appearance_setting_view.title.theme" = "主題"; -"appearance_setting_view.title.tint_color" = "強調色"; -"appearance_setting_view.title.display_mode" = "顯示模式"; -"appearance_setting_view.title.shows_tags_in_list" = "在列表中顯示標籤"; -"appearance_setting_view.title.maximum_number_of_tags" = "標籤最大顯示數量"; -"appearance_setting_view.title.displays_japanese_title" = "以日文顯示標籤"; -"appearance_setting_view.button.app_icon" = "App 圖案"; -"appearance_setting_view.menu.title.infite" = "無限"; -"appearance_setting_view.section.title.list" = "列表"; -"appearance_setting_view.section.title.gallery" = "畫廊"; +"appearance_setting_view.appearance" = "外觀設定"; +"appearance_setting_view.theme" = "主題"; +"appearance_setting_view.tint_color" = "強調色"; +"appearance_setting_view.display_mode" = "顯示模式"; +"appearance_setting_view.shows_tags_in_list" = "在列表中顯示標籤"; +"appearance_setting_view.maximum_number_of_tags" = "標籤最大顯示數量"; +"appearance_setting_view.displays_japanese_title" = "以日文顯示標籤"; +"appearance_setting_view.app_icon" = "App 圖案"; +"appearance_setting_view.infite" = "無限"; +"appearance_setting_view.list" = "列表"; +"appearance_setting_view.gallery" = "畫廊"; // PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "自動"; -"enum.preferred_color_scheme.value.light" = "淺色"; -"enum.preferred_color_scheme.value.dark" = "深色"; +"preferred_color_scheme.automatic" = "自動"; +"preferred_color_scheme.light" = "淺色"; +"preferred_color_scheme.dark" = "深色"; // AppIconType -"enum.app_icon_type.value.default" = "預設"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; +"app_icon_type.default" = "預設"; +"app_icon_type.ukiyoe" = "Ukiyo-e"; +"app_icon_type.developer" = "Developer"; +"app_icon_type.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; +"app_icon_type.not_my_president" = "NOT MY PRESIDENT"; // ListDisplayMode -"enum.list_display_mode.value.detail" = "詳細"; -"enum.list_display_mode.value.thumbnail" = "縮圖"; +"list_display_mode.detail" = "詳細"; +"list_display_mode.thumbnail" = "縮圖"; // MARK: AppIconView -"app_icon_view.title.app_icon" = "App 圖案"; +"app_icon_view.app_icon" = "App 圖案"; // MARK: reading_settingView -"reading_setting_view.title.reading" = "閱讀設定"; -"reading_setting_view.title.direction" = "翻頁方向"; -"reading_setting_view.title.preload_limit" = "預先載入頁數"; -"reading_setting_view.title.enables_landscape" = "啟用橫向顯示"; -"reading_setting_view.title.separator_height" = "頁間分隔高度"; -"reading_setting_view.title.maximum_scale_factor" = "縮放上限"; -"reading_setting_view.title.double_tap_scale_factor" = "雙擊縮放值"; -"reading_setting_view.section.title.appearance" = "外觀"; +"reading_setting_view.reading" = "閱讀設定"; +"reading_setting_view.direction" = "翻頁方向"; +"reading_setting_view.preload_limit" = "預先載入頁數"; +"reading_setting_view.enables_landscape" = "啟用橫向顯示"; +"reading_setting_view.separator_height" = "頁間分隔高度"; +"reading_setting_view.maximum_scale_factor" = "縮放上限"; +"reading_setting_view.double_tap_scale_factor" = "雙擊縮放值"; +"reading_setting_view.appearance" = "外觀"; // ReadingDirection -"enum.reading_direction.value.vertical" = "垂直"; -"enum.reading_direction.value.right_to_left" = "由右至左滑"; -"enum.reading_direction.value.left_to_right" = "由左至右滑"; +"reading_direction.vertical" = "垂直"; +"reading_direction.right_to_left" = "由右至左滑"; +"reading_direction.left_to_right" = "由左至右滑"; // MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "實驗性功能"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "繞過 SNI 過濾"; +"laboratory_setting_view.laboratory" = "實驗性功能"; +"laboratory_setting_view.bypasses_SNI_filtering" = "繞過 SNI 過濾"; // MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "官方網站"; -"about_view.button.altStore_source" = "AltStore source"; -"about_view.title.version" = "版本"; -"about_view.section.title.special_thanks" = "特別銘謝"; -"about_view.section.title.code_level_contributors" = "程式碼貢獻者"; -"about_view.section.title.translation_contributors" = "翻譯貢獻者"; -"about_view.section.title.acknowledgements" = "致謝"; +"about_view.ehPanda" = "EhPanda"; +"about_view.website" = "官方網站"; +"about_view.altStore_source" = "AltStore source"; +"about_view.version" = "版本"; +"about_view.special_thanks" = "特別銘謝"; +"about_view.code_level_contributors" = "程式碼貢獻者"; +"about_view.translation_contributors" = "翻譯貢獻者"; +"about_view.acknowledgements" = "致謝"; // MARK: DetailView -"detail_view.button.download_login" = "登入"; -"detail_view.button.download_get" = "取得"; -"detail_view.button.download_wait" = "等待"; -"detail_view.button.download_done" = "完成"; -"detail_view.button.download_update" = "更新"; -"detail_view.button.download_retry" = "重試"; -"detail_view.button.download_repair" = "修復"; -"detail_view.button.read" = "閱讀"; -"detail_view.button.post_comment" = "發表留言"; -"detail_view.accessibility.download_button.login" = "登入後即可下載"; -"detail_view.accessibility.download_button.download" = "下載"; -"detail_view.accessibility.download_button.queued" = "已加入下載佇列"; -"detail_view.accessibility.download_button.downloading" = "正在下載第 %d / %d 頁"; -"detail_view.accessibility.download_button.downloaded" = "刪除已下載畫廊"; -"detail_view.accessibility.download_button.update" = "更新下載內容"; -"detail_view.accessibility.download_button.retry" = "重新下載"; -"detail_view.accessibility.download_button.repair" = "修復下載檔案"; -"detail_view.accessibility.download_button.preparing" = "正在取得下載資訊"; -"detail_view.accessibility.download_button.pause_action" = "暫停下載"; -"detail_view.accessibility.download_button.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; -"detail_view.accessibility.download_button.partial" = "重新下載,已有 %d / %d 頁可用。"; -"detail_view.toolbar_item.button.archives" = "存檔至 H@H 用戶端"; -"detail_view.toolbar_item.button.torrents" = "種子"; -"detail_view.toolbar_item.button.share" = "分享"; -"detail_view.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "收回評分"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "收藏"; -"detail_view.description_section.title.language" = "語言"; -"detail_view.description_section.title.ratings" = "%@ 個評分"; -"detail_view.description_section.title.page_count" = "頁數"; -"detail_view.description_section.title.file_size" = "檔案大小"; -"detail_view.description_section.description.favorited" = "次"; -"detail_view.description_section.description.page_count" = "頁"; -"detail_view.action_section.button.give_a_rating" = "給予評分"; -"detail_view.action_section.button.similar_gallery" = "類似畫廊"; -"detail_view.section.title.previews" = "預覽"; -"detail_view.section.title.comments" = "留言"; -"detail_view.dialog.title.delete_download" = "刪除下載?"; -"detail_view.dialog.title.repair_download" = "修復下載?"; -"detail_view.dialog.title.update_download" = "更新下載?"; -"detail_view.dialog.title.redownload_gallery" = "重新下載畫廊?"; -"detail_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"detail_view.dialog.message.repair_download" = "現在修復此畫廊的離線檔案嗎?"; -"detail_view.dialog.message.update_download" = "現在將此畫廊更新到線上最新版本嗎?"; -"detail_view.dialog.message.redownload_gallery" = "現在重新完整下載此畫廊嗎?"; -"detail_view.dialog.button.repair" = "修復"; -"detail_view.dialog.button.update" = "更新"; -"detail_view.dialog.button.redownload" = "重新下載"; -"detail_view.offline_notice.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; +"detail_view.read" = "閱讀"; +"detail_view.post_comment" = "發表留言"; +"detail_view.accessibility.login" = "登入後即可下載"; +"detail_view.accessibility.download" = "下載"; +"detail_view.accessibility.queued" = "已加入下載佇列"; +"detail_view.accessibility.downloading" = "正在下載第 %d / %d 頁"; +"detail_view.accessibility.downloaded" = "刪除已下載畫廊"; +"detail_view.accessibility.update" = "更新下載內容"; +"detail_view.accessibility.retry" = "重新下載"; +"detail_view.accessibility.repair" = "修復下載檔案"; +"detail_view.accessibility.preparing" = "正在取得下載資訊"; +"detail_view.accessibility.pause_action" = "暫停下載"; +"detail_view.accessibility.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; +"detail_view.accessibility.partial" = "重新下載,已有 %d / %d 頁可用。"; +"detail_view.archives" = "存檔至 H@H 用戶端"; +"detail_view.torrents" = "種子"; +"detail_view.share" = "分享"; +"detail_view.detail" = "Detail"; +"detail_view.withdraw_vote" = "收回評分"; +"detail_view.vote_up" = "Vote up"; +"detail_view.vote_down" = "Vote down"; +"detail_view.favorited" = "收藏"; +"detail_view.language" = "語言"; +"detail_view.ratings" = "%@ 個評分"; +"detail_view.page_count" = "頁數"; +"detail_view.file_size" = "檔案大小"; +"detail_view.favorited_unit" = "次"; +"detail_view.page_count_unit" = "頁"; +"detail_view.give_a_rating" = "給予評分"; +"detail_view.similar_gallery" = "類似畫廊"; +"detail_view.previews" = "預覽"; +"detail_view.comments" = "留言"; +"detail_view.delete_download" = "刪除下載?"; +"detail_view.repair_download" = "修復下載?"; +"detail_view.update_download" = "更新下載?"; +"detail_view.redownload_gallery" = "重新下載畫廊?"; +"detail_view.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"detail_view.repair_download_description" = "現在修復此畫廊的離線檔案嗎?"; +"detail_view.update_download_description" = "現在將此畫廊更新到線上最新版本嗎?"; +"detail_view.redownload_gallery_description" = "現在重新完整下載此畫廊嗎?"; +"detail_view.repair" = "修復"; +"detail_view.update" = "更新"; +"detail_view.redownload" = "重新下載"; +"detail_view.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; // MARK: ArchivesView -"archives_view.title.archives" = "存檔"; -"archives_view.button.download_to_hath_client" = "下載至 H@H 用戶端"; +"archives_view.archives" = "存檔"; +"archives_view.download_to_hath_client" = "下載至 H@H 用戶端"; // HathArchive -"struct.hath_archive.price.free" = "免費"; -"struct.hath_archive.price.not_available" = "N/A"; +"hath_archive.free" = "免費"; // ArchiveResolution -"enum.archive_resolution.value.original" = "原始畫質"; +"archive_resolution.original" = "原始畫質"; // MARK: TorrentsView -"torrents_view.title.torrents" = "種子"; +"torrents_view.torrents" = "種子"; // MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "畫廊資訊"; -"gallery_infos_view.title.id" = "畫廊 ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "標題"; -"gallery_infos_view.title.japanese_title" = "日文標題"; -"gallery_infos_view.title.gallery_URL" = "畫廊 URL"; -"gallery_infos_view.title.cover_URL" = "封面 URL"; -"gallery_infos_view.title.archive_URL" = "存檔 URL"; -"gallery_infos_view.title.torrent_URL" = "種子 URL"; -"gallery_infos_view.title.parent_URL" = "Parent URL"; -"gallery_infos_view.title.category" = "類別"; -"gallery_infos_view.title.uploader" = "上傳者"; -"gallery_infos_view.title.posted_date" = "發布日期"; -"gallery_infos_view.title.visibility" = "能見度"; -"gallery_infos_view.title.language" = "語言"; -"gallery_infos_view.title.page_count" = "頁數"; -"gallery_infos_view.title.file_size" = "檔案大小"; -"gallery_infos_view.title.favorited_times" = "被收藏次數"; -"gallery_infos_view.title.favorited" = "已收藏"; -"gallery_infos_view.title.rating_count" = "被評分次數"; -"gallery_infos_view.title.average_rating" = "平均評分"; -"gallery_infos_view.title.my_rating" = "我的評分"; -"gallery_infos_view.title.torrent_count" = "種子數量"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "Yes"; -"gallery_infos_view.value.no" = "No"; +"gallery_infos_view.gallery_infos" = "畫廊資訊"; +"gallery_infos_view.id" = "畫廊 ID"; +"gallery_infos_view.token" = "Token"; +"gallery_infos_view.title" = "標題"; +"gallery_infos_view.japanese_title" = "日文標題"; +"gallery_infos_view.gallery_URL" = "畫廊 URL"; +"gallery_infos_view.cover_URL" = "封面 URL"; +"gallery_infos_view.archive_URL" = "存檔 URL"; +"gallery_infos_view.torrent_URL" = "種子 URL"; +"gallery_infos_view.parent_URL" = "Parent URL"; +"gallery_infos_view.category" = "類別"; +"gallery_infos_view.uploader" = "上傳者"; +"gallery_infos_view.posted_date" = "發布日期"; +"gallery_infos_view.visibility" = "能見度"; +"gallery_infos_view.language" = "語言"; +"gallery_infos_view.page_count" = "頁數"; +"gallery_infos_view.file_size" = "檔案大小"; +"gallery_infos_view.favorited_times" = "被收藏次數"; +"gallery_infos_view.favorited" = "已收藏"; +"gallery_infos_view.rating_count" = "被評分次數"; +"gallery_infos_view.average_rating" = "平均評分"; +"gallery_infos_view.my_rating" = "我的評分"; +"gallery_infos_view.torrent_count" = "種子數量"; +"gallery_infos_view.none" = "None"; +"gallery_infos_view.yes" = "Yes"; +"gallery_infos_view.no" = "No"; // GalleryVisibility -"enum.gallery_visibility.value.yes" = "Yes"; -"enum.gallery_visibility.value.no" = "No (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "已被刪除"; +"gallery_visibility.yes" = "Yes"; +"gallery_visibility.no" = "No (%@)"; +"gallery_visibility.expunged" = "已被刪除"; // MARK: TagDetailView -"tag_detail_view.section.title.images" = "圖片"; -"tag_detail_view.section.title.links" = "連結"; +"tag_detail_view.images" = "圖片"; +"tag_detail_view.links" = "連結"; // MARK: DownloadsView -"enum.download_folder_filter.title.all" = "All"; -"detail_view.menu.button.manage_folders" = "Manage Folders"; -"detail_view.menu.button.create_default_folder" = "Create Default Folder"; -"detail_view.menu.text.no_folders" = "No folders yet"; -"downloads_view.menu.button.manage_folders" = "Manage Folders"; -"downloads_view.menu.button.move_to_folder" = "Move to Folder"; -"downloads_view.swipe.button.move" = "Move"; -"folder_manager_view.title.folders" = "Folders"; -"folder_manager_view.placeholder.folder_name" = "Folder name"; -"folder_manager_view.dialog.message.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_state.folders" = "Folders you create will appear here."; -"downloads_view.title.downloads" = "下載"; -"downloads_view.search.prompt.downloads" = "搜尋下載"; -"downloads_view.dialog.title.delete_download" = "刪除下載?"; -"downloads_view.dialog.message.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; -"downloads_view.dialog.message.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"downloads_view.swipe.button.pages" = "頁面"; -"downloads_view.swipe.button.update" = "更新"; -"downloads_view.swipe.button.resume" = "繼續"; -"downloads_view.swipe.button.pause" = "暫停"; -"downloads_view.empty_state.downloads" = "已下載的畫廊會顯示在這裡。"; -"downloads_view.empty_state.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; -"downloads_view.button.clear_filters" = "清除篩選"; -"downloads_view.button.validate_image_data" = "驗證圖片資料"; -"downloads_view.inspector.section.actions" = "操作"; -"downloads_view.inspector.section.pages" = "頁面"; -"downloads_view.inspector.button.retry_failed_pages" = "重試失敗頁面"; -"downloads_view.inspector.button.validating_image_data" = "正在驗證圖片資料..."; -"downloads_view.inspector.button.update_download" = "更新下載"; -"downloads_view.inspector.toast.image_data_valid" = "圖片資料有效"; -"downloads_view.inspector.toast.image_data_unavailable" = "無法驗證圖片資料。"; -"downloads_view.inspector.title.download_status" = "下載狀態"; -"downloads_view.inspector.page.pending" = "等待中"; -"downloads_view.inspector.page.tap_to_retry" = "點按以重試此頁"; -"downloads_view.inspector.page.title" = "第 %d 頁"; -"downloads_view.inspector.page.none" = "沒有頁面"; -"downloads_view.inspector.status.pending" = "等待中"; -"downloads_view.inspector.status.downloaded" = "已下載"; -"downloads_view.inspector.status.failed" = "失敗"; +"download_folder_filter.all" = "All"; +"detail_view.manage_folders" = "Manage Folders"; +"detail_view.create_default_folder" = "Create Default Folder"; +"detail_view.no_folders" = "No folders yet"; +"downloads_view.manage_folders" = "Manage Folders"; +"downloads_view.move_to_folder" = "Move to Folder"; +"downloads_view.move" = "Move"; +"folder_manager_view.folders" = "Folders"; +"folder_manager_view.folder_name" = "Folder name"; +"folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; +"folder_manager_view.empty_folders" = "Folders you create will appear here."; +"downloads_view.downloads" = "下載"; +"downloads_view.search_downloads" = "搜尋下載"; +"downloads_view.delete_download" = "刪除下載?"; +"downloads_view.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; +"downloads_view.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; +"downloads_view.pages" = "頁面"; +"downloads_view.update" = "更新"; +"downloads_view.resume" = "繼續"; +"downloads_view.pause" = "暫停"; +"downloads_view.empty_downloads" = "已下載的畫廊會顯示在這裡。"; +"downloads_view.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; +"downloads_view.clear_filters" = "清除篩選"; +"downloads_view.validate_image_data" = "驗證圖片資料"; +"download_inspector_view.actions" = "操作"; +"download_inspector_view.retry_failed_pages" = "重試失敗頁面"; +"download_inspector_view.validating_image_data" = "正在驗證圖片資料..."; +"download_inspector_view.image_data_valid" = "圖片資料有效"; +"download_inspector_view.image_data_unavailable" = "無法驗證圖片資料。"; +"download_inspector_view.download_status" = "下載狀態"; +"download_inspector_view.pending" = "等待中"; +"download_inspector_view.none" = "沒有頁面"; +"download_inspector_view.downloaded" = "已下載"; +"download_inspector_view.failed" = "失敗"; // MARK: DownloadSettingView "download_setting_view.title" = "下載"; -"download_setting_view.section.title.download_queue" = "下載佇列"; -"download_setting_view.section.title.network" = "網絡"; -"download_setting_view.title.concurrent_image_downloads" = "並行圖片下載"; -"download_setting_view.title.retry_failed_pages_automatically" = "自動重試失敗頁面"; -"download_setting_view.title.allow_cellular_downloads" = "允許流動網絡下載"; -"download_setting_view.footer.network" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許流動網絡下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; +"download_setting_view.network" = "網路"; +"download_setting_view.concurrent_image_downloads" = "並行圖片下載"; +"download_setting_view.retry_failed_pages_automatically" = "自動重試失敗頁面"; +"download_setting_view.allow_cellular_downloads" = "允許行動網路下載"; +"download_setting_view.network_description" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許行動網路下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; // MARK: CommentsView -"comments_view.title.comments" = "留言"; +"comments_view.comments" = "留言"; // MARK: PostCommentView -"post_comment_view.title.post_comment" = "發表留言"; -"post_comment_view.title.edit_comment" = "編輯留言"; +"post_comment_view.post_comment" = "發表留言"; +"post_comment_view.edit_comment" = "編輯留言"; // MARK: PreviewsView -"previews_view.title.previews" = "預覽"; +"previews_view.previews" = "預覽"; // MARK: ReadingView -"reading_view.context_menu.button.reload" = "重新整理"; -"reading_view.context_menu.button.copy" = "複製"; -"reading_view.context_menu.button.save" = "儲存圖片"; -"reading_view.context_menu.button.save_original" = "儲存原始圖片"; -"reading_view.context_menu.button.share" = "分享"; -"reading_view.toolbar_item.title.auto_play" = "自動播放"; -"reading_view.toolbar_item.title.dual_page_mode" = "雙頁模式"; -"reading_view.toolbar_item.title.except_the_cover" = "封面除外"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "重新載入失敗圖片"; -"reading_view.toolbar_item.button.reload_all_images" = "重新載入所有圖片"; -"reading_view.toolbar_item.button.reading_setting" = "閱讀設定"; +"reading_view.reload" = "重新整理"; +"reading_view.copy" = "複製"; +"reading_view.save" = "儲存圖片"; +"reading_view.save_original" = "儲存原始圖片"; +"reading_view.share" = "分享"; +"reading_view.auto_play" = "自動播放"; +"reading_view.dual_page_mode" = "雙頁模式"; +"reading_view.except_the_cover" = "封面除外"; +"reading_view.retry_all_failed_images" = "重新載入失敗圖片"; +"reading_view.reload_all_images" = "重新載入所有圖片"; +"reading_view.reading_setting" = "閱讀設定"; // AutoPlayPolicy -"enum.auto_play_policy.value.off" = "關閉"; +"auto_play_policy.off" = "關閉"; // MARK: DownloadBadge -"struct.download_badge.text.queued" = "已排隊"; -"struct.download_badge.text.downloading" = "下載中"; -"struct.download_badge.text.paused" = "已暫停"; -"struct.download_badge.text.downloaded" = "已下載"; -"struct.download_badge.text.needs_attention" = "需處理"; -"struct.download_badge.text.update_available" = "有可更新"; -"struct.download_badge.text.needs_repair" = "需修復"; -"struct.download_badge.progress" = "%d/%d"; +"download_badge.queued" = "已排隊"; +"download_badge.downloading" = "下載中"; +"download_badge.paused" = "已暫停"; +"download_badge.downloaded" = "已下載"; +"download_badge.needs_attention" = "需處理"; +"download_badge.update_available" = "有可更新"; +"download_badge.progress" = "%d/%d"; // MARK: DownloadStore -"download_store.error.asset_unreadable" = "資源檔案無法讀取:%@"; -"download_store.error.invalid_folder_name" = "The folder name is invalid."; -"download_store.error.folder_already_exists" = "A folder with this name already exists."; -"download_store.error.folder_busy_downloading" = "The folder contains an active download."; -"download_store.error.download_busy" = "The download is currently active."; -"download_store.validation.download_folder_unresolved" = "無法解析下載資料夾。"; -"download_store.validation.download_folder_missing" = "下載資料夾缺失。"; -"download_store.validation.manifest_missing" = "Manifest 檔案缺失。"; -"download_store.validation.manifest_corrupted" = "Manifest 檔案已損壞。"; -"download_store.validation.downloaded_pages_incomplete" = "下載頁面不完整。"; -"download_store.validation.cover_image_missing" = "封面圖片缺失。"; -"download_store.validation.page_missing" = "第 %d 頁缺失。"; -"download_store.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; -"download_store.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; +"download_store.asset_unreadable" = "資源檔案無法讀取:%@"; +"download_store.invalid_folder_name" = "The folder name is invalid."; +"download_store.folder_already_exists" = "A folder with this name already exists."; +"download_store.folder_busy_downloading" = "The folder contains an active download."; +"download_store.download_busy" = "The download is currently active."; +"download_store.download_folder_missing" = "下載資料夾缺失。"; +"download_store.manifest_missing" = "Manifest 檔案缺失。"; +"download_store.manifest_corrupted" = "Manifest 檔案已損壞。"; +"download_store.page_missing" = "第 %d 頁缺失。"; +"download_store.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; // MARK: FiltersView -"filters_view.title.filters" = "過濾"; -"filters_view.title.advanced_settings" = "進階選項"; -"filters_view.title.search_gallery_name" = "搜尋畫廊名稱"; -"filters_view.title.search_gallery_tags" = "搜尋畫廊標籤"; -"filters_view.title.search_gallery_description" = "搜尋畫廊描述"; -"filters_view.title.search_torrent_filenames" = "搜尋種子檔案名"; -"filters_view.title.only_show_galleries_with_torrents" = "只顯示有種子的畫廊"; -"filters_view.title.search_low_power_tags" = "搜尋低期望標籤"; -"filters_view.title.search_downvoted_tags" = "搜尋低評價標籤"; -"filters_view.title.search_expunged_galleries" = "顯示被刪除的畫廊"; -"filters_view.title.set_minimum_rating" = "設定評分下限"; -"filters_view.title.minimum_rating" = "評分下限"; -"filters_view.title.set_pages_range" = "設定頁數範圍"; -"filters_view.title.pages_range" = "頁數範圍"; -"filters_view.title.disable_language_filter" = "停用語言篩選"; -"filters_view.title.disable_uploader_filter" = "停用上傳者篩選"; -"filters_view.title.disable_tags_filter" = "停用標籤篩選"; -"filters_view.button.reset_filters" = "重設所有選項"; -"filters_view.section.title.advanced" = "進階"; -"filters_view.section.title.default_filter" = "預設篩選"; +"filters_view.filters" = "過濾"; +"filters_view.advanced_settings" = "進階選項"; +"filters_view.search_gallery_name" = "搜尋畫廊名稱"; +"filters_view.search_gallery_tags" = "搜尋畫廊標籤"; +"filters_view.search_gallery_description" = "搜尋畫廊描述"; +"filters_view.search_torrent_filenames" = "搜尋種子檔案名"; +"filters_view.only_show_galleries_with_torrents" = "只顯示有種子的畫廊"; +"filters_view.search_low_power_tags" = "搜尋低期望標籤"; +"filters_view.search_downvoted_tags" = "搜尋低評價標籤"; +"filters_view.search_expunged_galleries" = "顯示被刪除的畫廊"; +"filters_view.set_minimum_rating" = "設定評分下限"; +"filters_view.minimum_rating" = "評分下限"; +"filters_view.set_pages_range" = "設定頁數範圍"; +"filters_view.pages_range" = "頁數範圍"; +"filters_view.disable_language_filter" = "停用語言篩選"; +"filters_view.disable_uploader_filter" = "停用上傳者篩選"; +"filters_view.disable_tags_filter" = "停用標籤篩選"; +"filters_view.reset_filters" = "重設所有選項"; +"filters_view.advanced" = "進階"; +"filters_view.default_filter" = "預設篩選"; // FilterRange -"enum.filter_range.value.search" = "搜尋"; -"enum.filter_range.value.global" = "全域"; -"enum.filter_range.value.watched" = "標籤"; +"filter_range.search" = "搜尋"; +"filter_range.global" = "全域"; +"filter_range.watched" = "標籤"; // MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 設定"; -"eh_setting_view.section.title.profile_settings" = "設定檔設定"; -"eh_setting_view.title.selected_profile" = "選擇設定檔"; -"eh_setting_view.button.set_as_default" = "設為預設"; -"eh_setting_view.button.delete_profile" = "刪除設定檔"; -"eh_setting_view.button.rename" = "重新命名"; -"eh_setting_view.button.create_new" = "新增設定檔"; -"eh_setting_view.toolbar_item.button.done" = "完成"; - -"eh_setting_view.section.title.image_load_settings" = "圖片來源設定"; -"eh_setting_view.title.load_images_through_the_hath_network" = "透過 Hath Network 載入圖片"; -"eh_setting_view.title.browsing_country" = "所在國家"; -"eh_setting_view.description.browsing_country" = "你似乎是從 **%@** 瀏覽這個網站或在使用當地的 VPN,這意味著網站將嘗試從這個地理區域的 H@H 用戶端載入圖片。如果這不正確或你出於任何原因想要使用不同的區域(例如你正在透過 VPN 連線),您可以在下面選擇不同的國家/地區。"; +"eh_setting_view.host_settings" = "%@ 設定"; +"eh_setting_view.profile_settings" = "設定檔設定"; +"eh_setting_view.selected_profile" = "選擇設定檔"; +"eh_setting_view.set_as_default" = "設為預設"; +"eh_setting_view.delete_profile" = "刪除設定檔"; +"eh_setting_view.rename" = "重新命名"; +"eh_setting_view.create_new" = "新增設定檔"; +"eh_setting_view.done" = "完成"; + +"eh_setting_view.image_load_settings" = "圖片來源設定"; +"eh_setting_view.load_images_through_the_hath_network" = "透過 Hath Network 載入圖片"; +"eh_setting_view.browsing_country" = "所在國家"; +"eh_setting_view.browsing_country_description" = "你似乎是從 **%@** 瀏覽這個網站或在使用當地的 VPN,這意味著網站將嘗試從這個地理區域的 H@H 用戶端載入圖片。如果這不正確或你出於任何原因想要使用不同的區域(例如你正在透過 VPN 連線),您可以在下面選擇不同的國家/地區。"; // EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "任何用戶端"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "只使用預設連接埠的用戶端"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "No [現代/Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "No [傳統/Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "建議選項(預設)"; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "如果網路防火牆會阻擋任何非預設傳出連接埠則使用這個選項(可能較慢)"; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "E-Hentai 贊助者功能: 你將無法同時瀏覽多個頁面,僅在出現重大錯誤時才啟用這個選項"; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "E-Hentai 贊助者功能: 在現代瀏覽器上預設設定可能無法正常工作,僅推薦用於傳統瀏覽器"; - -"eh_setting_view.section.title.image_size_settings" = "圖片尺寸設定"; -"eh_setting_view.title.image_resolution" = "圖片解析度"; -"eh_setting_view.description.image_resolution" = "一般情況下圖片會被縮放成 1280 px 水平解析度以供線上瀏覽,你也可以選擇下列解析度之一。為了避免破壞伺服器正常運作,高於 1280x 的解析度暫時僅提供下列使用者使用: 贊助者、具有任何 Hath Perk 的使用者以及 UID 低於 3,000,000 的使用者"; -"eh_setting_view.title.image_size" = "圖片尺寸"; -"eh_setting_view.description.image_size" = "雖然網站會自動縮小圖片以適應瀏覽裝置的螢幕寬度,但您也可以手動限製圖片的最大顯示尺寸。 像自動縮放一樣,這不會重新採樣圖像,因為調整大小是在瀏覽器完成的 (0 = 沒有限制)"; -"eh_setting_view.title.horizontal" = "水平尺寸(寬)"; -"eh_setting_view.title.vertical" = "垂直尺寸(長)"; +"load_through_hath_setting.any_client" = "任何用戶端"; +"load_through_hath_setting.default_port_only" = "只使用預設連接埠的用戶端"; +"load_through_hath_setting.modern_no" = "No [現代/Modern/HTTPS]"; +"load_through_hath_setting.legacy_no" = "No [傳統/Legacy/HTTP]"; +"load_through_hath_setting.any_client_description" = "建議選項(預設)"; +"load_through_hath_setting.default_port_only_description" = "如果網路防火牆會阻擋任何非預設傳出連接埠則使用這個選項(可能較慢)"; +"load_through_hath_setting.modern_no_description" = "E-Hentai 贊助者功能: 你將無法同時瀏覽多個頁面,僅在出現重大錯誤時才啟用這個選項"; +"load_through_hath_setting.legacy_no_description" = "E-Hentai 贊助者功能: 在現代瀏覽器上預設設定可能無法正常工作,僅推薦用於傳統瀏覽器"; + +"eh_setting_view.image_size_settings" = "圖片尺寸設定"; +"eh_setting_view.image_resolution" = "圖片解析度"; +"eh_setting_view.image_resolution_description" = "一般情況下圖片會被縮放成 1280 px 水平解析度以供線上瀏覽,你也可以選擇下列解析度之一。為了避免破壞伺服器正常運作,高於 1280x 的解析度暫時僅提供下列使用者使用: 贊助者、具有任何 Hath Perk 的使用者以及 UID 低於 3,000,000 的使用者"; +"eh_setting_view.image_size" = "圖片尺寸"; +"eh_setting_view.image_size_description" = "雖然網站會自動縮小圖片以適應瀏覽裝置的螢幕寬度,但您也可以手動限製圖片的最大顯示尺寸。 像自動縮放一樣,這不會重新採樣圖像,因為調整大小是在瀏覽器完成的 (0 = 沒有限制)"; +"eh_setting_view.horizontal" = "水平尺寸(寬)"; +"eh_setting_view.vertical" = "垂直尺寸(長)"; // EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "自動"; +"image_resolution.auto" = "自動"; -"eh_setting_view.section.title.gallery_name_display" = "畫廊顯示名稱"; -"eh_setting_view.title.gallery_name" = "畫廊名稱"; -"eh_setting_view.description.gallery_name" = "許多畫廊同時提供了英文/預設標題與日文標題,你想優先顯示哪種畫廊名稱?"; +"eh_setting_view.gallery_name_display" = "畫廊顯示名稱"; +"eh_setting_view.gallery_name" = "畫廊名稱"; +"eh_setting_view.gallery_name_description" = "許多畫廊同時提供了英文/預設標題與日文標題,你想優先顯示哪種畫廊名稱?"; // EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "預設標題"; -"enum.eh_setting.gallery_name.value.japanese" = "日文標題(若該畫廊支援)"; +"gallery_name.default" = "預設標題"; +"gallery_name.japanese" = "日文標題(若該畫廊支援)"; -"eh_setting_view.section.title.archiver_settings" = "存檔設定"; -"eh_setting_view.title.archiver_behavior" = "存檔邏輯設定"; -"eh_setting_view.description.archiver_behavior" = "存檔的預設邏輯是確認原始圖片或重新採樣後進行存檔的成本差異與選擇,然後顯示一個可以在其他地方點擊、複製的連結,你可以在此處更改他的運作方式。"; +"eh_setting_view.archiver_settings" = "存檔設定"; +"eh_setting_view.archiver_behavior" = "存檔邏輯設定"; +"eh_setting_view.archiver_behavior_description" = "存檔的預設邏輯是確認原始圖片或重新採樣後進行存檔的成本差異與選擇,然後顯示一個可以在其他地方點擊、複製的連結,你可以在此處更改他的運作方式。"; // EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "手動選擇,手動開始下載(預設)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "手動選擇,自動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "自動選擇原始畫質,,手動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "自動選擇原始畫質並開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "自動選擇重新採樣,手動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "自動選擇重新採樣並開始下載"; - -"eh_setting_view.section.title.front_page_settings" = "首頁設定"; -"eh_setting_view.title.display_mode" = "顯示模式"; -"eh_setting_view.description.display_mode" = "你想在首頁和搜尋結果中使用哪一種顯示方式?"; -"eh_setting_view.section.title.show_search_range_indicator" = "搜尋範圍指示器"; -"eh_setting_view.title.show_search_range_indicator" = "顯示搜尋範圍指示器"; -"eh_setting_view.description.gallery_category" = "預設情況下你希望在首頁和搜尋結果中顯示哪些類別的結果?"; +"eh_setting.archiver_behavior.manual_select_manual_start" = "手動選擇,手動開始下載(預設)"; +"eh_setting.archiver_behavior.manual_select_auto_start" = "手動選擇,自動開始下載"; +"eh_setting.archiver_behavior.auto_select_original_manual_start" = "自動選擇原始畫質,,手動開始下載"; +"eh_setting.archiver_behavior.auto_select_original_auto_start" = "自動選擇原始畫質並開始下載"; +"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "自動選擇重新採樣,手動開始下載"; +"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "自動選擇重新採樣並開始下載"; + +"eh_setting_view.front_page_settings" = "首頁設定"; +"eh_setting_view.display_mode" = "顯示模式"; +"eh_setting_view.display_mode_description" = "你想在首頁和搜尋結果中使用哪一種顯示方式?"; +"eh_setting_view.show_search_range_indicator" = "搜尋範圍指示器"; +"eh_setting_view.show_search_range_indicator_description" = "顯示搜尋範圍指示器"; +"eh_setting_view.gallery_category" = "預設情況下你希望在首頁和搜尋結果中顯示哪些類別的結果?"; // EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "緊湊(Compact)"; -"enum.eh_setting.display_mode.value.thumbnail" = "縮圖(Thumbnail)"; -"enum.eh_setting.display_mode.value.extended" = "放大(Extended)"; -"enum.eh_setting.display_mode.value.minimal" = "最小(Minimal)"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "可選用的 UI 元件"; -"eh_setting_view.description.optional_UI_elements" = "一些舊版 UI 元件現已預設停用。您可以在此啟用這些元件。"; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "在畫廊頁面啟用縮圖選擇器"; - -"eh_setting_view.section.title.favorites" = "收藏匣"; -"eh_setting_view.description.favorite_categories" = "在這裡你可以選擇並重新命名收藏匣"; -"eh_setting_view.title.favorites_sort_order" = "收藏匣排序"; -"eh_setting_view.description.favorites_sort_order" = "你還可以在收藏匣頁面上為畫廊選擇預設排列順序。 請注意,在 2016 年 3 月網站改版之前新增的畫廊並不儲存時間印記,並且無論使用何種設定,都將使用畫廊發佈時間作為排序參考。"; +"display_mode.compact" = "緊湊(Compact)"; +"display_mode.thumbnail" = "縮圖(Thumbnail)"; +"display_mode.extended" = "放大(Extended)"; +"display_mode.minimal" = "最小(Minimal)"; +"display_mode.minimalPlus" = "Minimal+"; + +"eh_setting_view.optional_UI_elements" = "可選用的 UI 元件"; +"eh_setting_view.optional_UI_elements_description" = "一些舊版 UI 元件現已預設停用。您可以在此啟用這些元件。"; +"eh_setting_view.enable_gallery_thumbnail_selector" = "在畫廊頁面啟用縮圖選擇器"; + +"eh_setting_view.favorites" = "收藏匣"; +"eh_setting_view.favorite_categories" = "在這裡你可以選擇並重新命名收藏匣"; +"eh_setting_view.favorites_sort_order" = "收藏匣排序"; +"eh_setting_view.favorites_sort_order_description" = "你還可以在收藏匣頁面上為畫廊選擇預設排列順序。 請注意,在 2016 年 3 月網站改版之前新增的畫廊並不儲存時間印記,並且無論使用何種設定,都將使用畫廊發佈時間作為排序參考。"; // EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "透過最後更新時間排序"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "透過收藏順序排序"; +"favorites_sort_order.last_update_time" = "透過最後更新時間排序"; +"favorites_sort_order.favorited_time" = "透過收藏順序排序"; -"eh_setting_view.section.title.ratings" = "評分"; -"eh_setting_view.title.ratings_color" = "評分顏色"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "預設情況下,你評分的畫廊將 2 星及以下的評分顯示為紅色星,2.5 ~ 4 顆星的評分為綠色,4.5 ~ 5 顆星的評分為藍色。 透過在下面輸入顏色組合你可以自訂想顯示的顏色。 每個字母各代表一顆星(1~5),預設的 RRGGB 表示第一顆和第二顆星的 R(ed),第三顆和第四顆的 G(reen),第五顆的 B(lue)。 你也可以將 (Y)ellow 用於普通星星。 任何五個字母的 R/G/B/Y 組合都有效"; +"eh_setting_view.ratings" = "評分"; +"eh_setting_view.ratings_color" = "評分顏色"; +"eh_setting_view.ratings_color_prompt" = "RRGGB"; +"eh_setting_view.ratings_color_description" = "預設情況下,你評分的畫廊將 2 星及以下的評分顯示為紅色星,2.5 ~ 4 顆星的評分為綠色,4.5 ~ 5 顆星的評分為藍色。 透過在下面輸入顏色組合你可以自訂想顯示的顏色。 每個字母各代表一顆星(1~5),預設的 RRGGB 表示第一顆和第二顆星的 R(ed),第三顆和第四顆的 G(reen),第五顆的 B(lue)。 你也可以將 (Y)ellow 用於普通星星。 任何五個字母的 R/G/B/Y 組合都有效"; -"eh_setting_view.section.title.tag_filtering_threshold" = "過濾標籤閾值"; -"eh_setting_view.title.tag_filtering_threshold" = "閾值"; -"eh_setting_view.description.tag_filtering_threshold" = "你可以透過將標籤新增到具有負數權重的“我的標籤”清單中來過濾標籤。 如果畫廊的標籤加起來的權重低於此值,則會被從列表中過濾掉,此閾值可以設定在 0 ~ -9999 之間"; +"eh_setting_view.tag_filtering_threshold" = "過濾標籤閾值"; +"eh_setting_view.tag_filtering_threshold_description" = "你可以透過將標籤新增到具有負數權重的“我的標籤”清單中來過濾標籤。 如果畫廊的標籤加起來的權重低於此值,則會被從列表中過濾掉,此閾值可以設定在 0 ~ -9999 之間"; -"eh_setting_view.section.title.tag_watching_threshold" = "關注標籤閾值"; -"eh_setting_view.title.tag_watching_threshold" = "關注標籤生效的閾值"; -"eh_setting_view.description.tag_watching_threshold" = "如果最近上傳的畫廊中至少有一個具有正權重的你正在關注/追蹤的標籤,並且這些標籤所具有的權重高於此設定中的數值,那這個畫廊將會出現在「追蹤標籤」頁面中,此閾值可以設定在 0 ~ -9999 之間"; +"eh_setting_view.tag_watching_threshold" = "關注標籤閾值"; +"eh_setting_view.tag_watching_threshold_description" = "如果最近上傳的畫廊中至少有一個具有正權重的你正在關注/追蹤的標籤,並且這些標籤所具有的權重高於此設定中的數值,那這個畫廊將會出現在「追蹤標籤」頁面中,此閾值可以設定在 0 ~ -9999 之間"; -"eh_setting_view.section.title.filtered_removal_count" = "顯示過濾結果計數器"; -"eh_setting_view.description.filtered_removal_count" = "顯示 \"Your default filters removed XX galleries from this page\" ?"; -"eh_setting_view.title.show_filtered_removal_count" = "是否顯示過濾結果計數器"; +"eh_setting_viewfiltered_removal_count" = "顯示過濾結果計數器"; +"eh_setting_view.filtered_removal_count_description" = "顯示 \"Your default filters removed XX galleries from this page\" ?"; +"eh_setting_view.show_filtered_removal_count" = "是否顯示過濾結果計數器"; -"eh_setting_view.section.title.excluded_languages" = "排除語言"; -"eh_setting_view.description.excluded_languages" = "如果你希望從畫廊列表和搜尋中隱藏掉某些語言的畫廊,請從下面的清單中選取它們。請注意,無論你的搜尋查詢如何,相符於篩除規則的畫廊都不會出現。"; +"eh_setting_view.excluded_languages" = "排除語言"; +"eh_setting_view.excluded_languages_description" = "如果你希望從畫廊列表和搜尋中隱藏掉某些語言的畫廊,請從下面的清單中選取它們。請注意,無論你的搜尋查詢如何,相符於篩除規則的畫廊都不會出現。"; // EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "原始語言"; -"enum.eh_setting.excluded_languages_category.value.translated" = "翻譯語言"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "覆寫"; - -"eh_setting_view.section.title.excluded_uploaders" = "排除的上傳者"; -"eh_setting_view.description.excluded_uploaders" = "如果你希望從畫廊列表和搜尋結果中隱藏某些上傳者的畫廊,請將它們新增到下方,每行輸入一個使用者名稱。請注意,無論你的搜尋結果如何,這些上傳者的畫廊都不會出現。"; -"eh_setting_view.description.excluded_uploaders_count" = "你正在使用 **%@ / %@** 排除欄位"; - -"eh_setting_view.section.title.search_result_count" = "搜尋結果數量上限"; -"eh_setting_view.title.result_count" = "數量上限"; -"eh_setting_view.description.result_count" = "你希望每頁顯示幾個搜尋結果?\n(該功能需要有 Hath Perk: Paging Enlargement)"; - -"eh_setting_view.section.title.thumbnail_settings" = "縮圖設定"; -"eh_setting_view.title.thumbnail_load_timing" = "縮圖載入時機設定"; -"eh_setting_view.description.thumbnail_load_timing" = "使用列表模式時,你希望如何載入以滑鼠位置顯示的縮圖?"; -"eh_setting_view.description.thumbnail_configuration" = "你可以為存取的所有畫廊設定預設的縮圖配置。"; -"eh_setting_view.title.thumbnail_size" = "尺寸"; -"eh_setting_view.title.thumbnail_row_count" = "行數"; +"excluded_languages_category.original" = "原始語言"; +"excluded_languages_category.translated" = "翻譯語言"; +"excluded_languages_category.rewrite" = "覆寫"; + +"eh_setting_view.excluded_uploaders" = "排除的上傳者"; +"eh_setting_view.excluded_uploaders_description" = "如果你希望從畫廊列表和搜尋結果中隱藏某些上傳者的畫廊,請將它們新增到下方,每行輸入一個使用者名稱。請注意,無論你的搜尋結果如何,這些上傳者的畫廊都不會出現。"; +"eh_setting_view.excluded_uploaders_count" = "你正在使用 **%@ / %@** 排除欄位"; + +"eh_setting_view.search_result_count" = "搜尋結果數量上限"; +"eh_setting_view.result_count" = "數量上限"; +"eh_setting_view.result_count_description" = "你希望每頁顯示幾個搜尋結果?\n(該功能需要有 Hath Perk: Paging Enlargement)"; + +"eh_setting_view.thumbnail_settings" = "縮圖設定"; +"eh_setting_view.thumbnail_load_timing" = "縮圖載入時機設定"; +"eh_setting_view.thumbnail_load_timing_description" = "使用列表模式時,你希望如何載入以滑鼠位置顯示的縮圖?"; +"eh_setting_view.thumbnail_configuration" = "你可以為存取的所有畫廊設定預設的縮圖配置。"; +"eh_setting_view.thumbnail_size" = "尺寸"; +"eh_setting_view.thumbnail_row_count" = "行數"; // EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "滑鼠位置"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "網頁載入位置"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "網頁的載入速度更快,但縮圖出現的時間可能稍有延遲"; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "網頁需要更多的載入時間,但是在網頁完全載入後縮圖顯示不會有任何延遲"; +"thumbnail_load_timing.on_mouse_over" = "滑鼠位置"; +"thumbnail_load_timing.on_page_load" = "網頁載入位置"; +"thumbnail_load_timing.on_mouse_over_description" = "網頁的載入速度更快,但縮圖出現的時間可能稍有延遲"; +"thumbnail_load_timing.on_page_load_description" = "網頁需要更多的載入時間,但是在網頁完全載入後縮圖顯示不會有任何延遲"; // EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "正常"; -"enum.eh_setting.thumbnail_size.value.large" = "大型"; -"enum.eh_setting.thumbnail_size.value.small" = "小型"; -"enum.eh_setting.thumbnail_size.value.auto" = "自動"; - -"eh_setting_view.section.title.cover_scaling" = "封面縮放"; -"eh_setting_view.title.scale_factor" = "縮放比例"; -"eh_setting_view.description.cover_scale_factor" = "在縮圖與放大檢視這兩種檢視模式下,封面的重新採樣比率介於 75%% 至 150%%."; - -"eh_setting_view.section.title.viewport_override" = "視窗覆蓋"; -"eh_setting_view.title.virtual_width" = "虛擬寬度"; -"eh_setting_view.description.virtual_width" = "允許你覆蓋行動裝置網站的虛擬寬度。 這通常由你的裝置根據其 DPI 自動確定。 100%% 縮圖比例的合理值介於 640 和 1400 之間。"; - -"eh_setting_view.section.title.gallery_comments" = "畫廊留言"; -"eh_setting_view.title.comments_sort_order" = "留言排序方式"; -"eh_setting_view.title.comments_votes_show_timing" = "留言投票數顯示時機"; +"thumbnail_size.normal" = "正常"; +"thumbnail_size.large" = "大型"; +"thumbnail_size.small" = "小型"; +"thumbnail_size.auto" = "自動"; + +"eh_setting_view.cover_scaling" = "封面縮放"; +"eh_setting_view.scale_factor" = "縮放比例"; +"eh_setting_view.cover_scale_factor" = "在縮圖與放大檢視這兩種檢視模式下,封面的重新採樣比率介於 75%% 至 150%%."; + +"eh_setting_view.viewport_override" = "視窗覆蓋"; +"eh_setting_view.virtual_width" = "虛擬寬度"; +"eh_setting_view.virtual_width_description" = "允許你覆蓋行動裝置網站的虛擬寬度。 這通常由你的裝置根據其 DPI 自動確定。 100%% 縮圖比例的合理值介於 640 和 1400 之間。"; + +"eh_setting_view.gallery_comments" = "畫廊留言"; +"eh_setting_view.comments_sort_order" = "留言排序方式"; +"eh_setting_view.comments_votes_show_timing" = "留言投票數顯示時機"; // EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "最舊留言優先"; -"enum.eh_setting.comments_sort_order.value.recent" = "最新留言優先"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "最相關留言優先"; +"comments_sort_order.oldest" = "最舊留言優先"; +"comments_sort_order.recent" = "最新留言優先"; +"comments_sort_order.highest_score" = "最相關留言優先"; // EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "滑鼠在分數上停留或點擊時"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "總是顯示"; +"comments_votes_show_timing.on_hover_or_click" = "滑鼠在分數上停留或點擊時"; +"comments_votes_show_timing.always" = "總是顯示"; -"eh_setting_view.section.title.gallery_tags" = "畫廊標籤"; -"eh_setting_view.title.tags_sort_order" = "標籤顯示順序"; +"eh_setting_view.gallery_tags" = "畫廊標籤"; +"eh_setting_view.tags_sort_order" = "標籤顯示順序"; // EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "字母順序"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "標籤權重"; +"tags_sort_order.alphabetical" = "字母順序"; +"tags_sort_order.tag_power" = "標籤權重"; -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "畫廊頁面縮圖標籤"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "在畫廊縮圖下方顯示標籤"; +"eh_setting_view.gallery_page_thumbnail_labeling" = "畫廊頁面縮圖標籤"; +"eh_setting_view.show_label_below_gallery_thumbnails" = "在畫廊縮圖下方顯示標籤"; -"eh_setting_view.section.title.hath_local_network_host" = "內網 H@H 服務 (Hath Local Network Host)"; -"eh_setting_view.title.ip_address_port" = "IP 位址:連接埠號"; -"eh_setting_view.description.ip_address_port" = "如果你在內網路上使用與瀏覽站點相同的公共 IP 架設 H@H 用戶端,有些路由器會因此發生問題,無法將請求傳回自己的 IP,透過啟用這項設定可以解決此問題。\n如果您在瀏覽的同一裝置上執行用戶端,請使用回環地址 (127.0.0.1:port)。 如果用戶端在您網路上的另一台裝置上執行,請使用其本機網路 IP。 某些瀏覽器配置會阻止外部網站存取具有本機網路 IP 的 URL,你必須將站點列入白名單才能使其正常工作。"; -"eh_setting_view.section.title.original_images" = "要使用原始圖片而非重新取樣的版本嗎? 若您在上方選擇「自動」以外的水準解析度且圖片較寬,或原始圖片大於 10 MiB(一年以上的圖庫則為 4 MiB),系統仍會使用重新取樣的圖片。"; -"eh_setting_view.title.use_original_images" = "使用原始圖片(原解析度)"; +"eh_setting_view.original_images" = "要使用原始圖片而非重新取樣的版本嗎? 若您在上方選擇「自動」以外的水準解析度且圖片較寬,或原始圖片大於 10 MiB(一年以上的圖庫則為 4 MiB),系統仍會使用重新取樣的圖片。"; +"eh_setting_view.use_original_images" = "使用原始圖片(原解析度)"; -"eh_setting_view.section.title.multi_page_viewer" = "多頁瀏覽"; -"eh_setting_view.title.use_multi_page_viewer" = "使用多頁瀏覽"; -"eh_setting_view.title.display_style" = "顯示方式"; -"eh_setting_view.title.show_thumbnail_pane" = "顯示縮圖窗格"; +"eh_setting_view.multi_page_viewer" = "多頁瀏覽"; +"eh_setting_view.use_multi_page_viewer" = "使用多頁瀏覽"; +"eh_setting_view.display_style" = "顯示方式"; +"eh_setting_view.show_thumbnail_pane" = "顯示縮圖窗格"; // EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "向左對齊,若寬度超出頁面則進行縮放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "置中對齊,若寬度超出頁面則進行縮放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "置中對齊且總是縮放"; +"multiple_page_viewer_style.align_left_scale_if_over_width" = "向左對齊,若寬度超出頁面則進行縮放"; +"multiple_page_viewer_style.align_center_scale_if_over_width" = "置中對齊,若寬度超出頁面則進行縮放"; +"multiple_page_viewer_style.align_center_always_scale" = "置中對齊且總是縮放"; // EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "不顯示"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "只顯示頁碼"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "顯示頁碼和名稱"; +"gallery_page_numbering.none" = "不顯示"; +"gallery_page_numbering.page_number_only" = "只顯示頁碼"; +"gallery_page_numbering.page_number_and_name" = "顯示頁碼和名稱"; // MARK: Category -"enum.category.value.doujinshi" = "同人誌"; -"enum.category.value.manga" = "漫畫"; -"enum.category.value.artist_CG" = "插畫"; -"enum.category.value.game_CG" = "遊戲 CG"; -"enum.category.value.western" = "西方"; -"enum.category.value.non_h" = "健康"; -"enum.category.value.image_set" = "圖片集"; -"enum.category.value.cosplay" = "角色扮演"; -"enum.category.value.asian_porn" = "亞洲"; -"enum.category.value.misc" = "其它"; -"enum.category.value.private" = "私人"; +"category.doujinshi" = "同人誌"; +"category.manga" = "漫畫"; +"category.artist_CG" = "插畫"; +"category.game_CG" = "遊戲 CG"; +"category.western" = "西方"; +"category.non_h" = "健康"; +"category.image_set" = "圖片集"; +"category.cosplay" = "角色扮演"; +"category.asian_porn" = "亞洲"; +"category.misc" = "其它"; +"category.private" = "私人"; // MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "重新分類"; -"enum.tag_namespace.value.language" = "語言"; -"enum.tag_namespace.value.parody" = "原作"; -"enum.tag_namespace.value.character" = "角色"; -"enum.tag_namespace.value.group" = "團體"; -"enum.tag_namespace.value.artist" = "作者"; -"enum.tag_namespace.value.male" = "男性"; -"enum.tag_namespace.value.female" = "女性"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "其他"; -"enum.tag_namespace.value.temp" = "Temp"; +"tag_namespace.reclass" = "重新分類"; +"tag_namespace.language" = "語言"; +"tag_namespace.parody" = "原作"; +"tag_namespace.character" = "角色"; +"tag_namespace.group" = "團體"; +"tag_namespace.artist" = "作者"; +"tag_namespace.male" = "男性"; +"tag_namespace.female" = "女性"; +"tag_namespace.mixed" = "Mixed"; +"tag_namespace.cosplayer" = "Cosplayer"; +"tag_namespace.other" = "其他"; +"tag_namespace.temp" = "Temp"; // MARK: Language -"enum.language.value.invalid" = "無效"; -"enum.language.value.other" = "其它"; -"enum.language.value.afrikaans" = "南非語"; -"enum.language.value.albanian" = "阿爾巴尼亞語"; -"enum.language.value.arabic" = "阿拉伯語"; -"enum.language.value.bengali" = "孟加拉語"; -"enum.language.value.bosnian" = "波士尼亞語"; -"enum.language.value.bulgarian" = "保加利亞語"; -"enum.language.value.burmese" = "緬甸語"; -"enum.language.value.catalan" = "加泰隆尼亞語"; -"enum.language.value.cebuano" = "宿霧語"; -"enum.language.value.chinese" = "漢語"; -"enum.language.value.croatian" = "克羅埃西亞語"; -"enum.language.value.czech" = "捷克語"; -"enum.language.value.danish" = "丹麥語"; -"enum.language.value.dutch" = "荷蘭語"; -"enum.language.value.english" = "英語"; -"enum.language.value.esperanto" = "國際語"; -"enum.language.value.estonian" = "愛沙尼亞語"; -"enum.language.value.finnish" = "芬蘭語"; -"enum.language.value.french" = "法語"; -"enum.language.value.georgian" = "喬治亞語"; -"enum.language.value.german" = "德語"; -"enum.language.value.greek" = "希臘語"; -"enum.language.value.hebrew" = "希伯來語"; -"enum.language.value.hindi" = "印度語"; -"enum.language.value.hmong" = "苗語"; -"enum.language.value.hungarian" = "匈牙利語"; -"enum.language.value.indonesian" = "印尼語"; -"enum.language.value.italian" = "義大利語"; -"enum.language.value.japanese" = "日語"; -"enum.language.value.kazakh" = "哈薩克語"; -"enum.language.value.khmer" = "高棉語"; -"enum.language.value.korean" = "韓語"; -"enum.language.value.kurdish" = "庫德語"; -"enum.language.value.lao" = "寮語"; -"enum.language.value.latin" = "拉丁語"; -"enum.language.value.mongolian" = "蒙古語"; -"enum.language.value.ndebele" = "恩德貝萊語"; -"enum.language.value.nepali" = "尼泊爾語"; -"enum.language.value.norwegian" = "挪威語"; -"enum.language.value.oromo" = "奧羅莫語"; -"enum.language.value.pashto" = "普什圖語"; -"enum.language.value.persian" = "波斯語"; -"enum.language.value.polish" = "波蘭語"; -"enum.language.value.portuguese" = "葡萄牙語"; -"enum.language.value.punjabi" = "旁遮普語"; -"enum.language.value.romanian" = "羅馬尼亞語"; -"enum.language.value.russian" = "俄語"; -"enum.language.value.sango" = "桑戈語"; -"enum.language.value.serbian" = "塞爾維亞語"; -"enum.language.value.shona" = "紹納語"; -"enum.language.value.slovak" = "斯洛伐克語"; -"enum.language.value.slovenian" = "斯洛維尼亞語"; -"enum.language.value.somali" = "索馬利語"; -"enum.language.value.spanish" = "西班牙語"; -"enum.language.value.swahili" = "斯瓦希里語"; -"enum.language.value.swedish" = "瑞典語"; -"enum.language.value.tagalog" = "他加祿語"; -"enum.language.value.thai" = "泰語"; -"enum.language.value.tigrinya" = "提格利尼亞語"; -"enum.language.value.turkish" = "土耳其語"; -"enum.language.value.ukrainian" = "烏克蘭語"; -"enum.language.value.urdu" = "烏爾都語"; -"enum.language.value.vietnamese" = "越南語"; -"enum.language.value.zulu" = "祖魯語"; +"language.invalid" = "無效"; +"language.other" = "其它"; +"language.afrikaans" = "南非語"; +"language.albanian" = "阿爾巴尼亞語"; +"language.arabic" = "阿拉伯語"; +"language.bengali" = "孟加拉語"; +"language.bosnian" = "波士尼亞語"; +"language.bulgarian" = "保加利亞語"; +"language.burmese" = "緬甸語"; +"language.catalan" = "加泰隆尼亞語"; +"language.cebuano" = "宿霧語"; +"language.chinese" = "漢語"; +"language.croatian" = "克羅埃西亞語"; +"language.czech" = "捷克語"; +"language.danish" = "丹麥語"; +"language.dutch" = "荷蘭語"; +"language.english" = "英語"; +"language.esperanto" = "國際語"; +"language.estonian" = "愛沙尼亞語"; +"language.finnish" = "芬蘭語"; +"language.french" = "法語"; +"language.georgian" = "喬治亞語"; +"language.german" = "德語"; +"language.greek" = "希臘語"; +"language.hebrew" = "希伯來語"; +"language.hindi" = "印度語"; +"language.hmong" = "苗語"; +"language.hungarian" = "匈牙利語"; +"language.indonesian" = "印尼語"; +"language.italian" = "義大利語"; +"language.japanese" = "日語"; +"language.kazakh" = "哈薩克語"; +"language.khmer" = "高棉語"; +"language.korean" = "韓語"; +"language.kurdish" = "庫德語"; +"language.lao" = "寮語"; +"language.latin" = "拉丁語"; +"language.mongolian" = "蒙古語"; +"language.ndebele" = "恩德貝萊語"; +"language.nepali" = "尼泊爾語"; +"language.norwegian" = "挪威語"; +"language.oromo" = "奧羅莫語"; +"language.pashto" = "普什圖語"; +"language.persian" = "波斯語"; +"language.polish" = "波蘭語"; +"language.portuguese" = "葡萄牙語"; +"language.punjabi" = "旁遮普語"; +"language.romanian" = "羅馬尼亞語"; +"language.russian" = "俄語"; +"language.sango" = "桑戈語"; +"language.serbian" = "塞爾維亞語"; +"language.shona" = "紹納語"; +"language.slovak" = "斯洛伐克語"; +"language.slovenian" = "斯洛維尼亞語"; +"language.somali" = "索馬利語"; +"language.spanish" = "西班牙語"; +"language.swahili" = "斯瓦希里語"; +"language.swedish" = "瑞典語"; +"language.tagalog" = "他加祿語"; +"language.thai" = "泰語"; +"language.tigrinya" = "提格利尼亞語"; +"language.turkish" = "土耳其語"; +"language.ukrainian" = "烏克蘭語"; +"language.urdu" = "烏爾都語"; +"language.vietnamese" = "越南語"; +"language.zulu" = "祖魯語"; // MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "自動偵測"; -"enum.browsing_country.name.afghanistan" = "阿富汗"; -"enum.browsing_country.name.aland_islands" = "奧蘭群島"; -"enum.browsing_country.name.albania" = "阿爾巴尼亞"; -"enum.browsing_country.name.algeria" = "阿爾及利亞"; -"enum.browsing_country.name.american_samoa" = "美屬薩摩亞"; -"enum.browsing_country.name.andorra" = "安道爾"; -"enum.browsing_country.name.angola" = "安哥拉"; -"enum.browsing_country.name.anguilla" = "安圭拉"; -"enum.browsing_country.name.antarctica" = "南極洲"; -"enum.browsing_country.name.antigua_and_barbuda" = "安地卡及巴布達"; -"enum.browsing_country.name.argentina" = "阿根廷"; -"enum.browsing_country.name.armenia" = "亞美尼亞"; -"enum.browsing_country.name.aruba" = "阿魯巴"; -"enum.browsing_country.name.asia_pacific_region" = "亞太地區"; -"enum.browsing_country.name.australia" = "澳洲"; -"enum.browsing_country.name.austria" = "奧地利"; -"enum.browsing_country.name.azerbaijan" = "亞塞拜然"; -"enum.browsing_country.name.bahamas" = "巴哈馬"; -"enum.browsing_country.name.bahrain" = "巴林"; -"enum.browsing_country.name.bangladesh" = "孟加拉"; -"enum.browsing_country.name.barbados" = "巴貝多"; -"enum.browsing_country.name.belarus" = "白俄羅斯"; -"enum.browsing_country.name.belgium" = "比利時"; -"enum.browsing_country.name.belize" = "貝里斯"; -"enum.browsing_country.name.benin" = "貝南"; -"enum.browsing_country.name.bermuda" = "百慕達"; -"enum.browsing_country.name.bhutan" = "不丹"; -"enum.browsing_country.name.bolivia" = "玻利維亞"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "博奈爾、聖尤斯特歇斯和薩巴"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "波士尼亞"; -"enum.browsing_country.name.botswana" = "波札那"; -"enum.browsing_country.name.bouvet_island" = "布威島"; -"enum.browsing_country.name.brazil" = "巴西"; -"enum.browsing_country.name.british_indian_ocean_territory" = "英屬印度洋領地"; -"enum.browsing_country.name.brunei_darussalam" = "汶萊"; -"enum.browsing_country.name.bulgaria" = "保加利亞"; -"enum.browsing_country.name.burkina_faso" = "布吉納法索"; -"enum.browsing_country.name.burundi" = "蒲隆地"; -"enum.browsing_country.name.cambodia" = "柬埔寨"; -"enum.browsing_country.name.cameroon" = "喀麥隆"; -"enum.browsing_country.name.canada" = "加拿大"; -"enum.browsing_country.name.cape_verde" = "維德角"; -"enum.browsing_country.name.cayman_islands" = "開曼群島"; -"enum.browsing_country.name.central_african_republic" = "中非"; -"enum.browsing_country.name.chad" = "查德"; -"enum.browsing_country.name.chile" = "智利"; -"enum.browsing_country.name.china" = "中國"; -"enum.browsing_country.name.christmas_island" = "聖誕島"; -"enum.browsing_country.name.cocos_islands" = "科科斯(基林)群島"; -"enum.browsing_country.name.colombia" = "哥倫比亞"; -"enum.browsing_country.name.comoros" = "葛摩"; -"enum.browsing_country.name.congo" = "剛果共和國"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "剛果民主共和國"; -"enum.browsing_country.name.cook_islands" = "庫克群島"; -"enum.browsing_country.name.costa_rica" = "哥斯大黎加"; -"enum.browsing_country.name.cote_d_ivoire" = "象牙海岸"; -"enum.browsing_country.name.croatia" = "克羅埃西亞"; -"enum.browsing_country.name.cuba" = "古巴"; -"enum.browsing_country.name.curacao" = "古拉索"; -"enum.browsing_country.name.cyprus" = "賽普勒斯"; -"enum.browsing_country.name.czech_republic" = "捷克共和國"; -"enum.browsing_country.name.denmark" = "丹麥"; -"enum.browsing_country.name.djibouti" = "吉布地"; -"enum.browsing_country.name.dominica" = "多米尼克"; -"enum.browsing_country.name.dominican_republic" = "多明尼加"; -"enum.browsing_country.name.ecuador" = "厄瓜多"; -"enum.browsing_country.name.egypt" = "埃及"; -"enum.browsing_country.name.el_salvador" = "薩爾瓦多"; -"enum.browsing_country.name.equatorial_guinea" = "赤道幾內亞"; -"enum.browsing_country.name.eritrea" = "厄利垂亞"; -"enum.browsing_country.name.estonia" = "愛沙尼亞"; -"enum.browsing_country.name.ethiopia" = "衣索比亞"; -"enum.browsing_country.name.europe" = "歐洲"; -"enum.browsing_country.name.falkland_islands" = "福克蘭群島"; -"enum.browsing_country.name.faroe_islands" = "法羅群島"; -"enum.browsing_country.name.fiji" = "斐濟"; -"enum.browsing_country.name.finland" = "芬蘭"; -"enum.browsing_country.name.france" = "法國"; -"enum.browsing_country.name.french_guiana" = "法屬圭亞那"; -"enum.browsing_country.name.french_polynesia" = "法屬玻里尼西亞"; -"enum.browsing_country.name.french_southern_territories" = "法屬南部領土"; -"enum.browsing_country.name.gabon" = "加彭"; -"enum.browsing_country.name.gambia" = "甘比亞"; -"enum.browsing_country.name.georgia" = "喬治亞"; -"enum.browsing_country.name.germany" = "德國"; -"enum.browsing_country.name.ghana" = "迦納"; -"enum.browsing_country.name.gibraltar" = "直布羅陀"; -"enum.browsing_country.name.greece" = "希臘"; -"enum.browsing_country.name.greenland" = "格陵蘭"; -"enum.browsing_country.name.grenada" = "格瑞那達"; -"enum.browsing_country.name.guadeloupe" = "瓜地洛普"; -"enum.browsing_country.name.guam" = "關島"; -"enum.browsing_country.name.guatemala" = "瓜地馬拉"; -"enum.browsing_country.name.guernsey" = "耿西"; -"enum.browsing_country.name.guinea" = "幾內亞"; -"enum.browsing_country.name.guinea_bissau" = "幾內亞比索"; -"enum.browsing_country.name.guyana" = "蓋亞那"; -"enum.browsing_country.name.haiti" = "海地"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "赫德島和麥克唐納群島"; -"enum.browsing_country.name.vatican_city_state" = "梵蒂岡"; -"enum.browsing_country.name.honduras" = "宏都拉斯"; -"enum.browsing_country.name.hong_kong" = "香港"; -"enum.browsing_country.name.hungary" = "匈牙利"; -"enum.browsing_country.name.iceland" = "冰島"; -"enum.browsing_country.name.india" = "印度"; -"enum.browsing_country.name.indonesia" = "印度尼西亞(印尼)"; -"enum.browsing_country.name.iran" = "伊朗"; -"enum.browsing_country.name.iraq" = "伊拉克"; -"enum.browsing_country.name.ireland" = "愛爾蘭"; -"enum.browsing_country.name.isle_of_man" = "曼島"; -"enum.browsing_country.name.israel" = "以色列"; -"enum.browsing_country.name.italy" = "義大利"; -"enum.browsing_country.name.jamaica" = "牙買加"; -"enum.browsing_country.name.japan" = "日本"; -"enum.browsing_country.name.jersey" = "澤西島"; -"enum.browsing_country.name.jordan" = "約旦"; -"enum.browsing_country.name.kazakhstan" = "哈薩克共和國"; -"enum.browsing_country.name.kenya" = "肯亞"; -"enum.browsing_country.name.kiribati" = "吉里巴斯"; -"enum.browsing_country.name.kuwait" = "科威特"; -"enum.browsing_country.name.kyrgyzstan" = "吉爾吉斯"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "寮國"; -"enum.browsing_country.name.latvia" = "拉脫維亞"; -"enum.browsing_country.name.lebanon" = "黎巴嫩"; -"enum.browsing_country.name.lesotho" = "賴索托"; -"enum.browsing_country.name.liberia" = "賴比瑞亞"; -"enum.browsing_country.name.libya" = "利比亞"; -"enum.browsing_country.name.liechtenstein" = "列支敦斯登"; -"enum.browsing_country.name.lithuania" = "立陶宛"; -"enum.browsing_country.name.luxembourg" = "盧森堡"; -"enum.browsing_country.name.macau" = "澳門"; -"enum.browsing_country.name.macedonia" = "北馬其頓"; -"enum.browsing_country.name.madagascar" = "馬達加斯加"; -"enum.browsing_country.name.malawi" = "馬拉威"; -"enum.browsing_country.name.malaysia" = "馬來西亞"; -"enum.browsing_country.name.maldives" = "馬爾地夫"; -"enum.browsing_country.name.mali" = "馬利"; -"enum.browsing_country.name.malta" = "馬爾他"; -"enum.browsing_country.name.marshall_islands" = "馬紹爾群島"; -"enum.browsing_country.name.martinique" = "馬丁尼克"; -"enum.browsing_country.name.mauritania" = "茅利塔尼亞"; -"enum.browsing_country.name.mauritius" = "模里西斯"; -"enum.browsing_country.name.mayotte" = "馬約特"; -"enum.browsing_country.name.mexico" = "墨西哥"; -"enum.browsing_country.name.micronesia" = "密克羅尼西亞"; -"enum.browsing_country.name.moldova" = "摩爾多瓦"; -"enum.browsing_country.name.monaco" = "摩納哥"; -"enum.browsing_country.name.mongolia" = "蒙古"; -"enum.browsing_country.name.montenegro" = "蒙特內哥羅"; -"enum.browsing_country.name.montserrat" = "蒙特塞拉特"; -"enum.browsing_country.name.morocco" = "摩洛哥"; -"enum.browsing_country.name.mozambique" = "莫三比克"; -"enum.browsing_country.name.myanmar" = "緬甸"; -"enum.browsing_country.name.namibia" = "納米比亞"; -"enum.browsing_country.name.nauru" = "諾魯"; -"enum.browsing_country.name.nepal" = "尼泊爾"; -"enum.browsing_country.name.netherlands" = "荷蘭"; -"enum.browsing_country.name.new_caledonia" = "新喀里多尼亞"; -"enum.browsing_country.name.new_zealand" = "紐西蘭"; -"enum.browsing_country.name.nicaragua" = "尼加拉瓜"; -"enum.browsing_country.name.niger" = "尼日"; -"enum.browsing_country.name.nigeria" = "奈及利亞"; -"enum.browsing_country.name.niue" = "紐埃"; -"enum.browsing_country.name.norfolk_island" = "諾福克島"; -"enum.browsing_country.name.north_korea" = "朝鮮"; -"enum.browsing_country.name.northern_mariana_islands" = "北馬里亞納群島"; -"enum.browsing_country.name.norway" = "挪威"; -"enum.browsing_country.name.oman" = "阿曼"; -"enum.browsing_country.name.pakistan" = "巴基斯坦"; -"enum.browsing_country.name.palau" = "帛琉"; -"enum.browsing_country.name.palestinian_territory" = "巴勒斯坦領土"; -"enum.browsing_country.name.panama" = "巴拿馬"; -"enum.browsing_country.name.papua_new_guinea" = "巴布亞紐幾內亞"; -"enum.browsing_country.name.paraguay" = "巴拉圭"; -"enum.browsing_country.name.peru" = "秘魯"; -"enum.browsing_country.name.philippines" = "菲律賓"; -"enum.browsing_country.name.pitcairn_islands" = "皮特凱恩群島"; -"enum.browsing_country.name.poland" = "波蘭"; -"enum.browsing_country.name.portugal" = "葡萄牙"; -"enum.browsing_country.name.puerto_rico" = "波多黎各"; -"enum.browsing_country.name.qatar" = "卡達"; -"enum.browsing_country.name.reunion" = "留尼旺"; -"enum.browsing_country.name.romania" = "羅馬尼亞"; -"enum.browsing_country.name.russian_federation" = "俄羅斯"; -"enum.browsing_country.name.rwanda" = "盧安達"; -"enum.browsing_country.name.saint_barthelemy" = "聖巴瑟米"; -"enum.browsing_country.name.saint_helena" = "聖赫勒拿"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "聖克里斯多福及尼維斯"; -"enum.browsing_country.name.saint_lucia" = "聖露西亞"; -"enum.browsing_country.name.saint_martin" = "聖馬丁"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "聖皮耶與密克隆"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "聖文森及格瑞那丁"; -"enum.browsing_country.name.samoa" = "薩摩亞"; -"enum.browsing_country.name.san_marino" = "聖馬利諾"; -"enum.browsing_country.name.sao_tome_and_principe" = "聖多美普林西比"; -"enum.browsing_country.name.saudi_arabia" = "沙烏地阿拉伯"; -"enum.browsing_country.name.senegal" = "塞內加爾"; -"enum.browsing_country.name.serbia" = "塞爾維亞"; -"enum.browsing_country.name.seychelles" = "塞席爾"; -"enum.browsing_country.name.sierra_leone" = "獅子山"; -"enum.browsing_country.name.singapore" = "新加坡"; -"enum.browsing_country.name.sint_maarten" = "聖馬丁"; -"enum.browsing_country.name.slovakia" = "斯洛伐克"; -"enum.browsing_country.name.slovenia" = "斯洛維尼亞"; -"enum.browsing_country.name.solomon_islands" = "索羅門群島"; -"enum.browsing_country.name.somalia" = "索馬利亞"; -"enum.browsing_country.name.south_africa" = "南非"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "南喬治亞和南桑威奇群島"; -"enum.browsing_country.name.south_korea" = "韓國"; -"enum.browsing_country.name.south_sudan" = "南蘇丹"; -"enum.browsing_country.name.spain" = "西班牙"; -"enum.browsing_country.name.sri_lanka" = "斯里蘭卡"; -"enum.browsing_country.name.sudan" = "蘇丹"; -"enum.browsing_country.name.suriname" = "蘇利南"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "斯瓦巴和揚馬延"; -"enum.browsing_country.name.swaziland" = "史瓦帝尼"; -"enum.browsing_country.name.sweden" = "瑞典"; -"enum.browsing_country.name.switzerland" = "瑞士"; -"enum.browsing_country.name.syrian_arab_republic" = "敘利亞"; -"enum.browsing_country.name.taiwan" = "臺灣"; -"enum.browsing_country.name.tajikistan" = "塔吉克"; -"enum.browsing_country.name.tanzania" = "坦尚尼亞"; -"enum.browsing_country.name.thailand" = "泰國"; -"enum.browsing_country.name.timor_leste" = "東帝汶"; -"enum.browsing_country.name.togo" = "多哥"; -"enum.browsing_country.name.tokelau" = "托克勞"; -"enum.browsing_country.name.tonga" = "東加"; -"enum.browsing_country.name.trinidad_and_tobago" = "千里達和托巴哥"; -"enum.browsing_country.name.tunisia" = "突尼西亞"; -"enum.browsing_country.name.turkey" = "土耳其"; -"enum.browsing_country.name.turkmenistan" = "土庫曼"; -"enum.browsing_country.name.turks_and_caicos_islands" = "土克斯及開科斯群島"; -"enum.browsing_country.name.tuvalu" = "吐瓦魯"; -"enum.browsing_country.name.uganda" = "烏干達"; -"enum.browsing_country.name.ukraine" = "烏克蘭"; -"enum.browsing_country.name.united_arab_emirates" = "阿拉伯聯合大公國"; -"enum.browsing_country.name.united_kingdom" = "英國"; -"enum.browsing_country.name.united_states" = "美國"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "美國外圍小島嶼"; -"enum.browsing_country.name.uruguay" = "烏拉圭"; -"enum.browsing_country.name.uzbekistan" = "烏茲別克"; -"enum.browsing_country.name.vanuatu" = "萬那杜"; -"enum.browsing_country.name.venezuela" = "委內瑞拉"; -"enum.browsing_country.name.vietnam" = "越南"; -"enum.browsing_country.name.virgin_islands_british" = "英屬維京群島"; -"enum.browsing_country.name.virgin_islands_US" = "美屬維京群島"; -"enum.browsing_country.name.wallis_and_futuna" = "瓦利斯和富圖納"; -"enum.browsing_country.name.western_sahara" = "西撒哈拉"; -"enum.browsing_country.name.yemen" = "葉門"; -"enum.browsing_country.name.zambia" = "尚比亞"; -"enum.browsing_country.name.zimbabwe" = "辛巴威"; +"browsing_country.auto_detect" = "自動偵測"; +"browsing_country.afghanistan" = "阿富汗"; +"browsing_country.aland_islands" = "奧蘭群島"; +"browsing_country.albania" = "阿爾巴尼亞"; +"browsing_country.algeria" = "阿爾及利亞"; +"browsing_country.american_samoa" = "美屬薩摩亞"; +"browsing_country.andorra" = "安道爾"; +"browsing_country.angola" = "安哥拉"; +"browsing_country.anguilla" = "安圭拉"; +"browsing_country.antarctica" = "南極洲"; +"browsing_country.antigua_and_barbuda" = "安地卡及巴布達"; +"browsing_country.argentina" = "阿根廷"; +"browsing_country.armenia" = "亞美尼亞"; +"browsing_country.aruba" = "阿魯巴"; +"browsing_country.asia_pacific_region" = "亞太地區"; +"browsing_country.australia" = "澳洲"; +"browsing_country.austria" = "奧地利"; +"browsing_country.azerbaijan" = "亞塞拜然"; +"browsing_country.bahamas" = "巴哈馬"; +"browsing_country.bahrain" = "巴林"; +"browsing_country.bangladesh" = "孟加拉"; +"browsing_country.barbados" = "巴貝多"; +"browsing_country.belarus" = "白俄羅斯"; +"browsing_country.belgium" = "比利時"; +"browsing_country.belize" = "貝里斯"; +"browsing_country.benin" = "貝南"; +"browsing_country.bermuda" = "百慕達"; +"browsing_country.bhutan" = "不丹"; +"browsing_country.bolivia" = "玻利維亞"; +"browsing_country.bonaire_saint_eustatius_and_saba" = "博奈爾、聖尤斯特歇斯和薩巴"; +"browsing_country.bosnia_and_herzegovina" = "波士尼亞"; +"browsing_country.botswana" = "波札那"; +"browsing_country.bouvet_island" = "布威島"; +"browsing_country.brazil" = "巴西"; +"browsing_country.british_indian_ocean_territory" = "英屬印度洋領地"; +"browsing_country.brunei_darussalam" = "汶萊"; +"browsing_country.bulgaria" = "保加利亞"; +"browsing_country.burkina_faso" = "布吉納法索"; +"browsing_country.burundi" = "蒲隆地"; +"browsing_country.cambodia" = "柬埔寨"; +"browsing_country.cameroon" = "喀麥隆"; +"browsing_country.canada" = "加拿大"; +"browsing_country.cape_verde" = "維德角"; +"browsing_country.cayman_islands" = "開曼群島"; +"browsing_country.central_african_republic" = "中非"; +"browsing_country.chad" = "查德"; +"browsing_country.chile" = "智利"; +"browsing_country.china" = "中國"; +"browsing_country.christmas_island" = "聖誕島"; +"browsing_country.cocos_islands" = "科科斯(基林)群島"; +"browsing_country.colombia" = "哥倫比亞"; +"browsing_country.comoros" = "葛摩"; +"browsing_country.congo" = "剛果共和國"; +"browsing_country.the_democratic_republic_of_the_congo" = "剛果民主共和國"; +"browsing_country.cook_islands" = "庫克群島"; +"browsing_country.costa_rica" = "哥斯大黎加"; +"browsing_country.cote_d_ivoire" = "象牙海岸"; +"browsing_country.croatia" = "克羅埃西亞"; +"browsing_country.cuba" = "古巴"; +"browsing_country.curacao" = "古拉索"; +"browsing_country.cyprus" = "賽普勒斯"; +"browsing_country.czech_republic" = "捷克共和國"; +"browsing_country.denmark" = "丹麥"; +"browsing_country.djibouti" = "吉布地"; +"browsing_country.dominica" = "多米尼克"; +"browsing_country.dominican_republic" = "多明尼加"; +"browsing_country.ecuador" = "厄瓜多"; +"browsing_country.egypt" = "埃及"; +"browsing_country.el_salvador" = "薩爾瓦多"; +"browsing_country.equatorial_guinea" = "赤道幾內亞"; +"browsing_country.eritrea" = "厄利垂亞"; +"browsing_country.estonia" = "愛沙尼亞"; +"browsing_country.ethiopia" = "衣索比亞"; +"browsing_country.europe" = "歐洲"; +"browsing_country.falkland_islands" = "福克蘭群島"; +"browsing_country.faroe_islands" = "法羅群島"; +"browsing_country.fiji" = "斐濟"; +"browsing_country.finland" = "芬蘭"; +"browsing_country.france" = "法國"; +"browsing_country.french_guiana" = "法屬圭亞那"; +"browsing_country.french_polynesia" = "法屬玻里尼西亞"; +"browsing_country.french_southern_territories" = "法屬南部領土"; +"browsing_country.gabon" = "加彭"; +"browsing_country.gambia" = "甘比亞"; +"browsing_country.georgia" = "喬治亞"; +"browsing_country.germany" = "德國"; +"browsing_country.ghana" = "迦納"; +"browsing_country.gibraltar" = "直布羅陀"; +"browsing_country.greece" = "希臘"; +"browsing_country.greenland" = "格陵蘭"; +"browsing_country.grenada" = "格瑞那達"; +"browsing_country.guadeloupe" = "瓜地洛普"; +"browsing_country.guam" = "關島"; +"browsing_country.guatemala" = "瓜地馬拉"; +"browsing_country.guernsey" = "耿西"; +"browsing_country.guinea" = "幾內亞"; +"browsing_country.guinea_bissau" = "幾內亞比索"; +"browsing_country.guyana" = "蓋亞那"; +"browsing_country.haiti" = "海地"; +"browsing_country.heard_island_and_mc_donald_islands" = "赫德島和麥克唐納群島"; +"browsing_country.vatican_city_state" = "梵蒂岡"; +"browsing_country.honduras" = "宏都拉斯"; +"browsing_country.hong_kong" = "香港"; +"browsing_country.hungary" = "匈牙利"; +"browsing_country.iceland" = "冰島"; +"browsing_country.india" = "印度"; +"browsing_country.indonesia" = "印度尼西亞(印尼)"; +"browsing_country.iran" = "伊朗"; +"browsing_country.iraq" = "伊拉克"; +"browsing_country.ireland" = "愛爾蘭"; +"browsing_country.isle_of_man" = "曼島"; +"browsing_country.israel" = "以色列"; +"browsing_country.italy" = "義大利"; +"browsing_country.jamaica" = "牙買加"; +"browsing_country.japan" = "日本"; +"browsing_country.jersey" = "澤西島"; +"browsing_country.jordan" = "約旦"; +"browsing_country.kazakhstan" = "哈薩克共和國"; +"browsing_country.kenya" = "肯亞"; +"browsing_country.kiribati" = "吉里巴斯"; +"browsing_country.kuwait" = "科威特"; +"browsing_country.kyrgyzstan" = "吉爾吉斯"; +"browsing_country.lao_peoples_democratic_republic" = "寮國"; +"browsing_country.latvia" = "拉脫維亞"; +"browsing_country.lebanon" = "黎巴嫩"; +"browsing_country.lesotho" = "賴索托"; +"browsing_country.liberia" = "賴比瑞亞"; +"browsing_country.libya" = "利比亞"; +"browsing_country.liechtenstein" = "列支敦斯登"; +"browsing_country.lithuania" = "立陶宛"; +"browsing_country.luxembourg" = "盧森堡"; +"browsing_country.macau" = "澳門"; +"browsing_country.macedonia" = "北馬其頓"; +"browsing_country.madagascar" = "馬達加斯加"; +"browsing_country.malawi" = "馬拉威"; +"browsing_country.malaysia" = "馬來西亞"; +"browsing_country.maldives" = "馬爾地夫"; +"browsing_country.mali" = "馬利"; +"browsing_country.malta" = "馬爾他"; +"browsing_country.marshall_islands" = "馬紹爾群島"; +"browsing_country.martinique" = "馬丁尼克"; +"browsing_country.mauritania" = "茅利塔尼亞"; +"browsing_country.mauritius" = "模里西斯"; +"browsing_country.mayotte" = "馬約特"; +"browsing_country.mexico" = "墨西哥"; +"browsing_country.micronesia" = "密克羅尼西亞"; +"browsing_country.moldova" = "摩爾多瓦"; +"browsing_country.monaco" = "摩納哥"; +"browsing_country.mongolia" = "蒙古"; +"browsing_country.montenegro" = "蒙特內哥羅"; +"browsing_country.montserrat" = "蒙特塞拉特"; +"browsing_country.morocco" = "摩洛哥"; +"browsing_country.mozambique" = "莫三比克"; +"browsing_country.myanmar" = "緬甸"; +"browsing_country.namibia" = "納米比亞"; +"browsing_country.nauru" = "諾魯"; +"browsing_country.nepal" = "尼泊爾"; +"browsing_country.netherlands" = "荷蘭"; +"browsing_country.new_caledonia" = "新喀里多尼亞"; +"browsing_country.new_zealand" = "紐西蘭"; +"browsing_country.nicaragua" = "尼加拉瓜"; +"browsing_country.niger" = "尼日"; +"browsing_country.nigeria" = "奈及利亞"; +"browsing_country.niue" = "紐埃"; +"browsing_country.norfolk_island" = "諾福克島"; +"browsing_country.north_korea" = "朝鮮"; +"browsing_country.northern_mariana_islands" = "北馬里亞納群島"; +"browsing_country.norway" = "挪威"; +"browsing_country.oman" = "阿曼"; +"browsing_country.pakistan" = "巴基斯坦"; +"browsing_country.palau" = "帛琉"; +"browsing_country.palestinian_territory" = "巴勒斯坦領土"; +"browsing_country.panama" = "巴拿馬"; +"browsing_country.papua_new_guinea" = "巴布亞紐幾內亞"; +"browsing_country.paraguay" = "巴拉圭"; +"browsing_country.peru" = "秘魯"; +"browsing_country.philippines" = "菲律賓"; +"browsing_country.pitcairn_islands" = "皮特凱恩群島"; +"browsing_country.poland" = "波蘭"; +"browsing_country.portugal" = "葡萄牙"; +"browsing_country.puerto_rico" = "波多黎各"; +"browsing_country.qatar" = "卡達"; +"browsing_country.reunion" = "留尼旺"; +"browsing_country.romania" = "羅馬尼亞"; +"browsing_country.russian_federation" = "俄羅斯"; +"browsing_country.rwanda" = "盧安達"; +"browsing_country.saint_barthelemy" = "聖巴瑟米"; +"browsing_country.saint_helena" = "聖赫勒拿"; +"browsing_country.saint_kitts_and_nevis" = "聖克里斯多福及尼維斯"; +"browsing_country.saint_lucia" = "聖露西亞"; +"browsing_country.saint_martin" = "聖馬丁"; +"browsing_country.saint_pierre_and_miquelon" = "聖皮耶與密克隆"; +"browsing_country.saint_vincent_and_the_grenadines" = "聖文森及格瑞那丁"; +"browsing_country.samoa" = "薩摩亞"; +"browsing_country.san_marino" = "聖馬利諾"; +"browsing_country.sao_tome_and_principe" = "聖多美普林西比"; +"browsing_country.saudi_arabia" = "沙烏地阿拉伯"; +"browsing_country.senegal" = "塞內加爾"; +"browsing_country.serbia" = "塞爾維亞"; +"browsing_country.seychelles" = "塞席爾"; +"browsing_country.sierra_leone" = "獅子山"; +"browsing_country.singapore" = "新加坡"; +"browsing_country.sint_maarten" = "聖馬丁"; +"browsing_country.slovakia" = "斯洛伐克"; +"browsing_country.slovenia" = "斯洛維尼亞"; +"browsing_country.solomon_islands" = "索羅門群島"; +"browsing_country.somalia" = "索馬利亞"; +"browsing_country.south_africa" = "南非"; +"browsing_country.south_georgia_and_the_south_sandwich_islands" = "南喬治亞和南桑威奇群島"; +"browsing_country.south_korea" = "韓國"; +"browsing_country.south_sudan" = "南蘇丹"; +"browsing_country.spain" = "西班牙"; +"browsing_country.sri_lanka" = "斯里蘭卡"; +"browsing_country.sudan" = "蘇丹"; +"browsing_country.suriname" = "蘇利南"; +"browsing_country.svalbard_and_jan_mayen" = "斯瓦巴和揚馬延"; +"browsing_country.swaziland" = "史瓦帝尼"; +"browsing_country.sweden" = "瑞典"; +"browsing_country.switzerland" = "瑞士"; +"browsing_country.syrian_arab_republic" = "敘利亞"; +"browsing_country.taiwan" = "臺灣"; +"browsing_country.tajikistan" = "塔吉克"; +"browsing_country.tanzania" = "坦尚尼亞"; +"browsing_country.thailand" = "泰國"; +"browsing_country.timor_leste" = "東帝汶"; +"browsing_country.togo" = "多哥"; +"browsing_country.tokelau" = "托克勞"; +"browsing_country.tonga" = "東加"; +"browsing_country.trinidad_and_tobago" = "千里達和托巴哥"; +"browsing_country.tunisia" = "突尼西亞"; +"browsing_country.turkey" = "土耳其"; +"browsing_country.turkmenistan" = "土庫曼"; +"browsing_country.turks_and_caicos_islands" = "土克斯及開科斯群島"; +"browsing_country.tuvalu" = "吐瓦魯"; +"browsing_country.uganda" = "烏干達"; +"browsing_country.ukraine" = "烏克蘭"; +"browsing_country.united_arab_emirates" = "阿拉伯聯合大公國"; +"browsing_country.united_kingdom" = "英國"; +"browsing_country.united_states" = "美國"; +"browsing_country.united_states_minor_outlying_islands" = "美國外圍小島嶼"; +"browsing_country.uruguay" = "烏拉圭"; +"browsing_country.uzbekistan" = "烏茲別克"; +"browsing_country.vanuatu" = "萬那杜"; +"browsing_country.venezuela" = "委內瑞拉"; +"browsing_country.vietnam" = "越南"; +"browsing_country.virgin_islands_british" = "英屬維京群島"; +"browsing_country.virgin_islands_US" = "美屬維京群島"; +"browsing_country.wallis_and_futuna" = "瓦利斯和富圖納"; +"browsing_country.western_sahara" = "西撒哈拉"; +"browsing_country.yemen" = "葉門"; +"browsing_country.zambia" = "尚比亞"; +"browsing_country.zimbabwe" = "辛巴威"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 46c579a82..e669fa0b0 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -11,226 +11,202 @@ import Foundation // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum L10n { public enum Constant { - public enum App { - /// Copyright © 2026 EhPanda Team - public static let copyright = L10n.tr("Constant", "app.copyright", fallback: "Copyright © 2026 EhPanda Team") - public enum Acknowledgement { - public enum Link { - /// https://github.com/Co2333/Colorful - public static let colorful = L10n.tr("Constant", "app.acknowledgement.link.colorful", fallback: "https://github.com/Co2333/Colorful") - /// https://github.com/EhTagTranslation/Database - public static let ehTagTranslationDatabase = L10n.tr("Constant", "app.acknowledgement.link.ehTagTranslationDatabase", fallback: "https://github.com/EhTagTranslation/Database") - /// https://github.com/tid-kijyun/Kanna - public static let kanna = L10n.tr("Constant", "app.acknowledgement.link.kanna", fallback: "https://github.com/tid-kijyun/Kanna") - /// https://github.com/onevcat/Kingfisher - public static let kingfisher = L10n.tr("Constant", "app.acknowledgement.link.kingfisher", fallback: "https://github.com/onevcat/Kingfisher") - /// https://github.com/SFSafeSymbols/SFSafeSymbols - public static let sfSafeSymbols = L10n.tr("Constant", "app.acknowledgement.link.sfSafeSymbols", fallback: "https://github.com/SFSafeSymbols/SFSafeSymbols") - /// https://github.com/gonzalezreal/SwiftCommonMark - public static let swiftCommonMark = L10n.tr("Constant", "app.acknowledgement.link.swiftCommonMark", fallback: "https://github.com/gonzalezreal/SwiftCommonMark") - /// https://github.com/SwiftGen/SwiftGen - public static let swiftGen = L10n.tr("Constant", "app.acknowledgement.link.swiftGen", fallback: "https://github.com/SwiftGen/SwiftGen") - /// https://github.com/fermoya/SwiftUIPager - public static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.link.swiftUIPager", fallback: "https://github.com/fermoya/SwiftUIPager") - /// https://github.com/ddddxxx/SwiftyOpenCC - public static let swiftyOpenCC = L10n.tr("Constant", "app.acknowledgement.link.swiftyOpenCC", fallback: "https://github.com/ddddxxx/SwiftyOpenCC") - /// https://github.com/danielsaidi/SystemNotification - public static let systemNotification = L10n.tr("Constant", "app.acknowledgement.link.systemNotification", fallback: "https://github.com/danielsaidi/SystemNotification") - /// https://github.com/pointfreeco/swift-composable-architecture - public static let tca = L10n.tr("Constant", "app.acknowledgement.link.tca", fallback: "https://github.com/pointfreeco/swift-composable-architecture") - /// https://github.com/jathu/UIImageColors - public static let uiImageColors = L10n.tr("Constant", "app.acknowledgement.link.uiImageColors", fallback: "https://github.com/jathu/UIImageColors") - /// https://github.com/paololeonardi/WaterfallGrid - public static let waterfallGrid = L10n.tr("Constant", "app.acknowledgement.link.waterfallGrid", fallback: "https://github.com/paololeonardi/WaterfallGrid") - } - public enum Text { - /// Colorful - public static let colorful = L10n.tr("Constant", "app.acknowledgement.text.colorful", fallback: "Colorful") - /// EhTagTranslation/Database - public static let ehTagTranslationDatabase = L10n.tr("Constant", "app.acknowledgement.text.ehTagTranslationDatabase", fallback: "EhTagTranslation/Database") - /// Kanna - public static let kanna = L10n.tr("Constant", "app.acknowledgement.text.kanna", fallback: "Kanna") - /// Kingfisher - public static let kingfisher = L10n.tr("Constant", "app.acknowledgement.text.kingfisher", fallback: "Kingfisher") - /// SFSafeSymbols - public static let sfSafeSymbols = L10n.tr("Constant", "app.acknowledgement.text.sfSafeSymbols", fallback: "SFSafeSymbols") - /// SwiftCommonMark - public static let swiftCommonMark = L10n.tr("Constant", "app.acknowledgement.text.swiftCommonMark", fallback: "SwiftCommonMark") - /// SwiftGen - public static let swiftGen = L10n.tr("Constant", "app.acknowledgement.text.swiftGen", fallback: "SwiftGen") - /// SwiftUI Navigation - public static let swiftUINavigation = L10n.tr("Constant", "app.acknowledgement.text.swiftUINavigation", fallback: "SwiftUI Navigation") - /// SwiftUIPager - public static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.text.swiftUIPager", fallback: "SwiftUIPager") - /// SwiftyOpenCC - public static let swiftyOpenCC = L10n.tr("Constant", "app.acknowledgement.text.swiftyOpenCC", fallback: "SwiftyOpenCC") - /// SystemNotification - public static let systemNotification = L10n.tr("Constant", "app.acknowledgement.text.systemNotification", fallback: "SystemNotification") - /// The Composable Architecture - public static let tca = L10n.tr("Constant", "app.acknowledgement.text.tca", fallback: "The Composable Architecture") - /// UIImageColors - public static let uiImageColors = L10n.tr("Constant", "app.acknowledgement.text.uiImageColors", fallback: "UIImageColors") - /// WaterfallGrid - public static let waterfallGrid = L10n.tr("Constant", "app.acknowledgement.text.waterfallGrid", fallback: "WaterfallGrid") - } - } - public enum CodeLevelContributor { - public enum Link { - /// https://github.com/aalberrty - public static let aalberrty = L10n.tr("Constant", "app.code_level_contributor.link.aalberrty", fallback: "https://github.com/aalberrty") - /// https://github.com/Jimmy-Prime - public static let jimmyPrime = L10n.tr("Constant", "app.code_level_contributor.link.Jimmy-Prime", fallback: "https://github.com/Jimmy-Prime") - /// https://github.com/Kaed3mi - public static let kaed3mi = L10n.tr("Constant", "app.code_level_contributor.link.Kaed3mi", fallback: "https://github.com/Kaed3mi") - /// https://github.com/vvbbnn00 - public static let vvbbnn00 = L10n.tr("Constant", "app.code_level_contributor.link.vvbbnn00", fallback: "https://github.com/vvbbnn00") - /// https://github.com/xioxin - public static let xioxin = L10n.tr("Constant", "app.code_level_contributor.link.xioxin", fallback: "https://github.com/xioxin") - } - public enum Text { - /// Zack Asahina - public static let aalberrty = L10n.tr("Constant", "app.code_level_contributor.text.aalberrty", fallback: "Zack Asahina") - /// Jimmy Prime - public static let jimmyPrime = L10n.tr("Constant", "app.code_level_contributor.text.Jimmy-Prime", fallback: "Jimmy Prime") - /// Kaed3mi - public static let kaed3mi = L10n.tr("Constant", "app.code_level_contributor.text.Kaed3mi", fallback: "Kaed3mi") - /// vvbbnn00 - public static let vvbbnn00 = L10n.tr("Constant", "app.code_level_contributor.text.vvbbnn00", fallback: "vvbbnn00") - /// xioxin - public static let xioxin = L10n.tr("Constant", "app.code_level_contributor.text.xioxin", fallback: "xioxin") - } - } - public enum Contact { - public enum Link { - /// altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json - public static let altStore = L10n.tr("Constant", "app.contact.link.altStore", fallback: "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json") - /// https://discord.gg/BSBE9FCBTq - public static let discord = L10n.tr("Constant", "app.contact.link.discord", fallback: "https://discord.gg/BSBE9FCBTq") - /// https://github.com/EhPanda-Team/EhPanda - public static let gitHub = L10n.tr("Constant", "app.contact.link.gitHub", fallback: "https://github.com/EhPanda-Team/EhPanda") - /// https://t.me/ehpanda - public static let telegram = L10n.tr("Constant", "app.contact.link.telegram", fallback: "https://t.me/ehpanda") - /// https://ehpanda.app - public static let website = L10n.tr("Constant", "app.contact.link.website", fallback: "https://ehpanda.app") - } - public enum Text { - /// Discord - public static let discord = L10n.tr("Constant", "app.contact.text.discord", fallback: "Discord") - /// GitHub - public static let gitHub = L10n.tr("Constant", "app.contact.text.gitHub", fallback: "GitHub") - /// Telegram - public static let telegram = L10n.tr("Constant", "app.contact.text.telegram", fallback: "Telegram") - } - } - public enum SpecialThanks { - public enum Link { - /// https://github.com/caxerx - public static let caxerx = L10n.tr("Constant", "app.special_thanks.link.caxerx", fallback: "https://github.com/caxerx") - /// https://github.com/honjow - public static let honjow = L10n.tr("Constant", "app.special_thanks.link.honjow", fallback: "https://github.com/honjow") - /// - public static let luminescentYq = L10n.tr("Constant", "app.special_thanks.link.luminescent_yq", fallback: "") - /// https://github.com/taylorlannister - public static let taylorlannister = L10n.tr("Constant", "app.special_thanks.link.taylorlannister", fallback: "https://github.com/taylorlannister") - } - public enum Text { - /// caxerx - public static let caxerx = L10n.tr("Constant", "app.special_thanks.text.caxerx", fallback: "caxerx") - /// honjow - public static let honjow = L10n.tr("Constant", "app.special_thanks.text.honjow", fallback: "honjow") - /// Luminescent_yq - public static let luminescentYq = L10n.tr("Constant", "app.special_thanks.text.luminescent_yq", fallback: "Luminescent_yq") - /// taylorlannister - public static let taylorlannister = L10n.tr("Constant", "app.special_thanks.text.taylorlannister", fallback: "taylorlannister") - } - } - public enum TranslationContributor { - public enum Link { - /// https://github.com/caxerx - public static let caxerx = L10n.tr("Constant", "app.translation_contributor.link.caxerx", fallback: "https://github.com/caxerx") - /// https://github.com/Nebulosa-Cat - public static let nebulosaCat = L10n.tr("Constant", "app.translation_contributor.link.nebulosa-cat", fallback: "https://github.com/Nebulosa-Cat") - /// https://github.com/NeKoOuO - public static let neKoOuO = L10n.tr("Constant", "app.translation_contributor.link.NeKoOuO", fallback: "https://github.com/NeKoOuO") - /// https://github.com/PaulHaeussler - public static let paulHaeussler = L10n.tr("Constant", "app.translation_contributor.link.paulHaeussler", fallback: "https://github.com/PaulHaeussler") - } - public enum Text { - /// caxerx - public static let caxerx = L10n.tr("Constant", "app.translation_contributor.text.caxerx", fallback: "caxerx") - /// 雲豹 ΦωΦ - public static let nebulosaCat = L10n.tr("Constant", "app.translation_contributor.text.nebulosa-cat", fallback: "雲豹 ΦωΦ") - /// ɴᴇᴋᴏ - public static let neKoOuO = L10n.tr("Constant", "app.translation_contributor.text.NeKoOuO", fallback: "ɴᴇᴋᴏ") - /// PaulHaeussler - public static let paulHaeussler = L10n.tr("Constant", "app.translation_contributor.text.paulHaeussler", fallback: "PaulHaeussler") - } - } + /// Copyright © 2026 EhPanda Team + public static let copyright = L10n.tr("Constant", "copyright", fallback: "Copyright © 2026 EhPanda Team") + /// This gallery has been removed or is unavailable. + public static let galleryUnavailable = L10n.tr("Constant", "gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") + /// Constant.strings + /// EhPanda + public static let hathClientNotFound = L10n.tr("Constant", "hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") + /// Your H@H client appears to be offline. Turn it on, then try again. + public static let hathClientNotOnline = L10n.tr("Constant", "hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") + /// The requested gallery cannot be downloaded with the selected resolution. + public static let invalidResolution = L10n.tr("Constant", "invalid_resolution", fallback: "The requested gallery cannot be downloaded with the selected resolution.") + public enum Acknowledgement { + /// Colorful + public static let colorful = L10n.tr("Constant", "acknowledgement.colorful", fallback: "Colorful") + /// https://github.com/Co2333/Colorful + public static let colorfulLink = L10n.tr("Constant", "acknowledgement.colorful_link", fallback: "https://github.com/Co2333/Colorful") + /// EhTagTranslation/Database + public static let ehTagTranslationDatabase = L10n.tr("Constant", "acknowledgement.ehTagTranslationDatabase", fallback: "EhTagTranslation/Database") + /// https://github.com/EhTagTranslation/Database + public static let ehTagTranslationDatabaseLink = L10n.tr("Constant", "acknowledgement.ehTagTranslationDatabase_link", fallback: "https://github.com/EhTagTranslation/Database") + /// Kanna + public static let kanna = L10n.tr("Constant", "acknowledgement.kanna", fallback: "Kanna") + /// https://github.com/tid-kijyun/Kanna + public static let kannaLink = L10n.tr("Constant", "acknowledgement.kanna_link", fallback: "https://github.com/tid-kijyun/Kanna") + /// Kingfisher + public static let kingfisher = L10n.tr("Constant", "acknowledgement.kingfisher", fallback: "Kingfisher") + /// https://github.com/onevcat/Kingfisher + public static let kingfisherLink = L10n.tr("Constant", "acknowledgement.kingfisher_link", fallback: "https://github.com/onevcat/Kingfisher") + /// SFSafeSymbols + public static let sfSafeSymbols = L10n.tr("Constant", "acknowledgement.sfSafeSymbols", fallback: "SFSafeSymbols") + /// https://github.com/SFSafeSymbols/SFSafeSymbols + public static let sfSafeSymbolsLink = L10n.tr("Constant", "acknowledgement.sfSafeSymbols_link", fallback: "https://github.com/SFSafeSymbols/SFSafeSymbols") + /// SwiftCommonMark + public static let swiftCommonMark = L10n.tr("Constant", "acknowledgement.swiftCommonMark", fallback: "SwiftCommonMark") + /// https://github.com/gonzalezreal/SwiftCommonMark + public static let swiftCommonMarkLink = L10n.tr("Constant", "acknowledgement.swiftCommonMark_link", fallback: "https://github.com/gonzalezreal/SwiftCommonMark") + /// SwiftGen + public static let swiftGen = L10n.tr("Constant", "acknowledgement.swiftGen", fallback: "SwiftGen") + /// https://github.com/SwiftGen/SwiftGen + public static let swiftGenLink = L10n.tr("Constant", "acknowledgement.swiftGen_link", fallback: "https://github.com/SwiftGen/SwiftGen") + /// SwiftUI Navigation + public static let swiftUINavigation = L10n.tr("Constant", "acknowledgement.swiftUINavigation", fallback: "SwiftUI Navigation") + /// SwiftUIPager + public static let swiftUIPager = L10n.tr("Constant", "acknowledgement.swiftUIPager", fallback: "SwiftUIPager") + /// https://github.com/fermoya/SwiftUIPager + public static let swiftUIPagerLink = L10n.tr("Constant", "acknowledgement.swiftUIPager_link", fallback: "https://github.com/fermoya/SwiftUIPager") + /// SwiftyOpenCC + public static let swiftyOpenCC = L10n.tr("Constant", "acknowledgement.swiftyOpenCC", fallback: "SwiftyOpenCC") + /// https://github.com/ddddxxx/SwiftyOpenCC + public static let swiftyOpenCCLink = L10n.tr("Constant", "acknowledgement.swiftyOpenCC_link", fallback: "https://github.com/ddddxxx/SwiftyOpenCC") + /// SystemNotification + public static let systemNotification = L10n.tr("Constant", "acknowledgement.systemNotification", fallback: "SystemNotification") + /// https://github.com/danielsaidi/SystemNotification + public static let systemNotificationLink = L10n.tr("Constant", "acknowledgement.systemNotification_link", fallback: "https://github.com/danielsaidi/SystemNotification") + /// The Composable Architecture + public static let tca = L10n.tr("Constant", "acknowledgement.tca", fallback: "The Composable Architecture") + /// https://github.com/pointfreeco/swift-composable-architecture + public static let tcaLink = L10n.tr("Constant", "acknowledgement.tca_link", fallback: "https://github.com/pointfreeco/swift-composable-architecture") + /// UIImageColors + public static let uiImageColors = L10n.tr("Constant", "acknowledgement.uiImageColors", fallback: "UIImageColors") + /// https://github.com/jathu/UIImageColors + public static let uiImageColorsLink = L10n.tr("Constant", "acknowledgement.uiImageColors_link", fallback: "https://github.com/jathu/UIImageColors") + /// WaterfallGrid + public static let waterfallGrid = L10n.tr("Constant", "acknowledgement.waterfallGrid", fallback: "WaterfallGrid") + /// https://github.com/paololeonardi/WaterfallGrid + public static let waterfallGridLink = L10n.tr("Constant", "acknowledgement.waterfallGrid_link", fallback: "https://github.com/paololeonardi/WaterfallGrid") } - public enum Website { - public enum Response { - /// This gallery has been removed or is unavailable. - public static let galleryUnavailable = L10n.tr("Constant", "website.response.gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") - /// Constant.strings - /// EhPanda - public static let hathClientNotFound = L10n.tr("Constant", "website.response.hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") - /// Your H@H client appears to be offline. Turn it on, then try again. - public static let hathClientNotOnline = L10n.tr("Constant", "website.response.hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") - /// The requested gallery cannot be downloaded with the selected resolution. - public static let invalidResolution = L10n.tr("Constant", "website.response.invalid_resolution", fallback: "The requested gallery cannot be downloaded with the selected resolution.") - } + public enum CodeLevelContributor { + /// Zack Asahina + public static let aalberrty = L10n.tr("Constant", "code_level_contributor.aalberrty", fallback: "Zack Asahina") + /// https://github.com/aalberrty + public static let aalberrtyLink = L10n.tr("Constant", "code_level_contributor.aalberrty_link", fallback: "https://github.com/aalberrty") + /// Jimmy Prime + public static let jimmyPrime = L10n.tr("Constant", "code_level_contributor.Jimmy-Prime", fallback: "Jimmy Prime") + /// https://github.com/Jimmy-Prime + public static let jimmyPrimeLink = L10n.tr("Constant", "code_level_contributor.Jimmy-Prime_link", fallback: "https://github.com/Jimmy-Prime") + /// Kaed3mi + public static let kaed3mi = L10n.tr("Constant", "code_level_contributor.Kaed3mi", fallback: "Kaed3mi") + /// https://github.com/Kaed3mi + public static let kaed3miLink = L10n.tr("Constant", "code_level_contributor.Kaed3mi_link", fallback: "https://github.com/Kaed3mi") + /// vvbbnn00 + public static let vvbbnn00 = L10n.tr("Constant", "code_level_contributor.vvbbnn00", fallback: "vvbbnn00") + /// https://github.com/vvbbnn00 + public static let vvbbnn00Link = L10n.tr("Constant", "code_level_contributor.vvbbnn00_link", fallback: "https://github.com/vvbbnn00") + /// xioxin + public static let xioxin = L10n.tr("Constant", "code_level_contributor.xioxin", fallback: "xioxin") + /// https://github.com/xioxin + public static let xioxinLink = L10n.tr("Constant", "code_level_contributor.xioxin_link", fallback: "https://github.com/xioxin") + } + public enum Contact { + /// altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json + public static let altStoreLink = L10n.tr("Constant", "contact.altStore_link", fallback: "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json") + /// https://discord.gg/BSBE9FCBTq + public static let discord = L10n.tr("Constant", "contact.discord", fallback: "https://discord.gg/BSBE9FCBTq") + /// Discord + public static let discordLink = L10n.tr("Constant", "contact.discord_link", fallback: "Discord") + /// https://github.com/EhPanda-Team/EhPanda + public static let gitHub = L10n.tr("Constant", "contact.gitHub", fallback: "https://github.com/EhPanda-Team/EhPanda") + /// GitHub + public static let gitHubLink = L10n.tr("Constant", "contact.gitHub_link", fallback: "GitHub") + /// https://t.me/ehpanda + public static let telegram = L10n.tr("Constant", "contact.telegram", fallback: "https://t.me/ehpanda") + /// Telegram + public static let telegramLink = L10n.tr("Constant", "contact.telegram_link", fallback: "Telegram") + /// https://ehpanda.app + public static let website = L10n.tr("Constant", "contact.website", fallback: "https://ehpanda.app") + } + public enum SpecialThanks { + /// caxerx + public static let caxerx = L10n.tr("Constant", "special_thanks.caxerx", fallback: "caxerx") + /// https://github.com/caxerx + public static let caxerxLink = L10n.tr("Constant", "special_thanks.caxerx_link", fallback: "https://github.com/caxerx") + /// honjow + public static let honjow = L10n.tr("Constant", "special_thanks.honjow", fallback: "honjow") + /// https://github.com/honjow + public static let honjowLink = L10n.tr("Constant", "special_thanks.honjow_link", fallback: "https://github.com/honjow") + /// Luminescent_yq + public static let luminescentYq = L10n.tr("Constant", "special_thanks.luminescent_yq", fallback: "Luminescent_yq") + /// + public static let luminescentYqLink = L10n.tr("Constant", "special_thanks.luminescent_yq_link", fallback: "") + /// taylorlannister + public static let taylorlannister = L10n.tr("Constant", "special_thanks.taylorlannister", fallback: "taylorlannister") + /// https://github.com/taylorlannister + public static let taylorlannisterLink = L10n.tr("Constant", "special_thanks.taylorlannister_link", fallback: "https://github.com/taylorlannister") + } + public enum TranslationContributor { + /// caxerx + public static let caxerx = L10n.tr("Constant", "translation_contributor.caxerx", fallback: "caxerx") + /// https://github.com/caxerx + public static let caxerxLink = L10n.tr("Constant", "translation_contributor.caxerx_link", fallback: "https://github.com/caxerx") + /// 雲豹 ΦωΦ + public static let nebulosaCat = L10n.tr("Constant", "translation_contributor.nebulosa-cat", fallback: "雲豹 ΦωΦ") + /// https://github.com/Nebulosa-Cat + public static let nebulosaCatLink = L10n.tr("Constant", "translation_contributor.nebulosa-cat_link", fallback: "https://github.com/Nebulosa-Cat") + /// ɴᴇᴋᴏ + public static let neKoOuO = L10n.tr("Constant", "translation_contributor.NeKoOuO", fallback: "ɴᴇᴋᴏ") + /// https://github.com/NeKoOuO + public static let neKoOuOLink = L10n.tr("Constant", "translation_contributor.NeKoOuO_link", fallback: "https://github.com/NeKoOuO") + /// PaulHaeussler + public static let paulHaeussler = L10n.tr("Constant", "translation_contributor.paulHaeussler", fallback: "PaulHaeussler") + /// https://github.com/PaulHaeussler + public static let paulHaeusslerLink = L10n.tr("Constant", "translation_contributor.paulHaeussler_link", fallback: "https://github.com/PaulHaeussler") } } public enum Localizable { + /// Show Filtered Removal Count + public static let ehSettingViewfilteredRemovalCount = L10n.tr("Localizable", "eh_setting_viewfiltered_removal_count", fallback: "Show Filtered Removal Count") + /// You must have a H@H client assigned to your account to use this feature. + public static let hathClientNotFound = L10n.tr("Localizable", "hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") + /// Your H@H client appears to be offline. Turn it on, then try again. + public static let hathClientNotOnline = L10n.tr("Localizable", "hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") + /// The requested gallery cannot be downloaded with the selected resolution. + public static let invalidResolution = L10n.tr("Localizable", "invalid_resolution", fallback: "The requested gallery cannot be downloaded with the selected resolution.") + /// Login + public static let notLoginViewlogin = L10n.tr("Localizable", "not_login_viewlogin", fallback: "Login") public enum AboutView { - public enum Button { - /// AltStore source - public static let altStoreSource = L10n.tr("Localizable", "about_view.button.altStore_source", fallback: "AltStore source") - /// Website - public static let website = L10n.tr("Localizable", "about_view.button.website", fallback: "Website") - } - public enum Section { - public enum Title { - /// Acknowledgements - public static let acknowledgements = L10n.tr("Localizable", "about_view.section.title.acknowledgements", fallback: "Acknowledgements") - /// Code-level contributors - public static let codeLevelContributors = L10n.tr("Localizable", "about_view.section.title.code_level_contributors", fallback: "Code-level contributors") - /// Special thanks - public static let specialThanks = L10n.tr("Localizable", "about_view.section.title.special_thanks", fallback: "Special thanks") - /// Translation contributors - public static let translationContributors = L10n.tr("Localizable", "about_view.section.title.translation_contributors", fallback: "Translation contributors") - } - } - public enum Title { - /// EhPanda - public static let ehPanda = L10n.tr("Localizable", "about_view.title.ehPanda", fallback: "EhPanda") - /// Version - public static let version = L10n.tr("Localizable", "about_view.title.version", fallback: "Version") - } + /// Acknowledgements + public static let acknowledgements = L10n.tr("Localizable", "about_view.acknowledgements", fallback: "Acknowledgements") + /// AltStore source + public static let altStoreSource = L10n.tr("Localizable", "about_view.altStore_source", fallback: "AltStore source") + /// Code-level contributors + public static let codeLevelContributors = L10n.tr("Localizable", "about_view.code_level_contributors", fallback: "Code-level contributors") + /// EhPanda + public static let ehPanda = L10n.tr("Localizable", "about_view.ehPanda", fallback: "EhPanda") + /// Special thanks + public static let specialThanks = L10n.tr("Localizable", "about_view.special_thanks", fallback: "Special thanks") + /// Translation contributors + public static let translationContributors = L10n.tr("Localizable", "about_view.translation_contributors", fallback: "Translation contributors") + /// Version + public static let version = L10n.tr("Localizable", "about_view.version", fallback: "Version") + /// Website + public static let website = L10n.tr("Localizable", "about_view.website", fallback: "Website") } public enum AccountSettingView { - public enum Button { - /// Account configuration - public static let accountConfiguration = L10n.tr("Localizable", "account_setting_view.button.account_configuration", fallback: "Account configuration") - /// Copy cookies - public static let copyCookies = L10n.tr("Localizable", "account_setting_view.button.copy_cookies", fallback: "Copy cookies") - /// Login - public static let login = L10n.tr("Localizable", "account_setting_view.button.login", fallback: "Login") - /// Logout - public static let logout = L10n.tr("Localizable", "account_setting_view.button.logout", fallback: "Logout") - /// Manage tags subscription - public static let tagsManagement = L10n.tr("Localizable", "account_setting_view.button.tags_management", fallback: "Manage tags subscription") - } - public enum Title { - /// Account - public static let account = L10n.tr("Localizable", "account_setting_view.title.account", fallback: "Account") - /// Shows new dawn greeting - public static let showsNewDawnGreeting = L10n.tr("Localizable", "account_setting_view.title.shows_new_dawn_greeting", fallback: "Shows new dawn greeting") - } + /// Account + public static let account = L10n.tr("Localizable", "account_setting_view.account", fallback: "Account") + /// Account configuration + public static let accountConfiguration = L10n.tr("Localizable", "account_setting_view.account_configuration", fallback: "Account configuration") + /// Copy cookies + public static let copyCookies = L10n.tr("Localizable", "account_setting_view.copy_cookies", fallback: "Copy cookies") + /// Login + public static let login = L10n.tr("Localizable", "account_setting_view.login", fallback: "Login") + /// Logout + public static let logout = L10n.tr("Localizable", "account_setting_view.logout", fallback: "Logout") + /// Shows new dawn greeting + public static let showsNewDawnGreeting = L10n.tr("Localizable", "account_setting_view.shows_new_dawn_greeting", fallback: "Shows new dawn greeting") + /// Manage tags subscription + public static let tagsManagement = L10n.tr("Localizable", "account_setting_view.tags_management", fallback: "Manage tags subscription") } public enum AppActivityLogsView { + /// Current + public static let current = L10n.tr("Localizable", "app_activity_logs_view.current", fallback: "Current") /// More logs public static let moreLogs = L10n.tr("Localizable", "app_activity_logs_view.more_logs", fallback: "More logs") + /// No logs found + public static let noLogs = L10n.tr("Localizable", "app_activity_logs_view.no_logs", fallback: "No logs found") /// Open in Files public static let openInFiles = L10n.tr("Localizable", "app_activity_logs_view.open_in_files", fallback: "Open in Files") /// Run %@ @@ -255,2335 +231,1953 @@ public enum L10n { /// Undefined public static let undefined = L10n.tr("Localizable", "app_activity_logs_view.level.undefined", fallback: "Undefined") } - public enum Placeholder { - /// No logs found - public static let noLogs = L10n.tr("Localizable", "app_activity_logs_view.placeholder.no_logs", fallback: "No logs found") - } - public enum Section { - /// Current - public static let current = L10n.tr("Localizable", "app_activity_logs_view.section.current", fallback: "Current") - } } public enum AppError { - public enum Alert { - /// Login required to access this download. - public static let authenticationRequired = L10n.tr("Localizable", "app_error.alert.authentication_required", fallback: "Login required to access this download.") - /// Local file operation failed. - public static let localFileOperationFailed = L10n.tr("Localizable", "app_error.alert.local_file_operation_failed", fallback: "Local file operation failed.") - /// Image quota exceeded. - /// Please wait and try again later. - public static let quotaExceeded = L10n.tr("Localizable", "app_error.alert.quota_exceeded", fallback: "Image quota exceeded.\nPlease wait and try again later.") - } - public enum LocalizedDescription { - /// Authentication Required - public static let authenticationRequired = L10n.tr("Localizable", "app_error.localized_description.authentication_required", fallback: "Authentication Required") - /// Copyright Claim - public static let copyrightClaim = L10n.tr("Localizable", "app_error.localized_description.copyright_claim", fallback: "Copyright Claim") - /// Database Corrupted - public static let databaseCorrupted = L10n.tr("Localizable", "app_error.localized_description.database_corrupted", fallback: "Database Corrupted") - /// File Operation Failed - public static let fileOperationFailed = L10n.tr("Localizable", "app_error.localized_description.file_operation_failed", fallback: "File Operation Failed") - /// Gallery Expunged - public static let galleryExpunged = L10n.tr("Localizable", "app_error.localized_description.gallery_expunged", fallback: "Gallery Expunged") - /// IP Banned - public static let ipBanned = L10n.tr("Localizable", "app_error.localized_description.ip_banned", fallback: "IP Banned") - /// Network Error - public static let networkError = L10n.tr("Localizable", "app_error.localized_description.network_error", fallback: "Network Error") - /// No updates available - public static let noUpdatesAvailable = L10n.tr("Localizable", "app_error.localized_description.no_updates_available", fallback: "No updates available") - /// Not found - public static let notFound = L10n.tr("Localizable", "app_error.localized_description.not_found", fallback: "Not found") - /// Parse Error - public static let parseError = L10n.tr("Localizable", "app_error.localized_description.parse_error", fallback: "Parse Error") - /// Quota Exceeded - public static let quotaExceeded = L10n.tr("Localizable", "app_error.localized_description.quota_exceeded", fallback: "Quota Exceeded") - /// Unknown Error - public static let unknownError = L10n.tr("Localizable", "app_error.localized_description.unknown_error", fallback: "Unknown Error") - /// Web image loading error - public static let webImageLoadingError = L10n.tr("Localizable", "app_error.localized_description.web_image_loading_error", fallback: "Web image loading error") - } + /// Authentication Required + public static let authenticationRequired = L10n.tr("Localizable", "app_error.authentication_required", fallback: "Authentication Required") + /// Login required to access this download. + public static let authenticationRequiredDescription = L10n.tr("Localizable", "app_error.authentication_required_description", fallback: "Login required to access this download.") + /// Copyright Claim + public static let copyrightClaim = L10n.tr("Localizable", "app_error.copyright_claim", fallback: "Copyright Claim") + /// Database Corrupted + public static let databaseCorrupted = L10n.tr("Localizable", "app_error.database_corrupted", fallback: "Database Corrupted") + /// File Operation Failed + public static let fileOperationFailed = L10n.tr("Localizable", "app_error.file_operation_failed", fallback: "File Operation Failed") + /// Gallery Expunged + public static let galleryExpunged = L10n.tr("Localizable", "app_error.gallery_expunged", fallback: "Gallery Expunged") + /// IP Banned + public static let ipBanned = L10n.tr("Localizable", "app_error.ip_banned", fallback: "IP Banned") + /// Local file operation failed. + public static let localFileOperationFailed = L10n.tr("Localizable", "app_error.local_file_operation_failed", fallback: "Local file operation failed.") + /// Network Error + public static let networkError = L10n.tr("Localizable", "app_error.network_error", fallback: "Network Error") + /// No updates available + public static let noUpdatesAvailable = L10n.tr("Localizable", "app_error.no_updates_available", fallback: "No updates available") + /// Not found + public static let notFound = L10n.tr("Localizable", "app_error.not_found", fallback: "Not found") + /// Parse Error + public static let parseError = L10n.tr("Localizable", "app_error.parse_error", fallback: "Parse Error") + /// Quota Exceeded + public static let quotaExceeded = L10n.tr("Localizable", "app_error.quota_exceeded", fallback: "Quota Exceeded") + /// Image quota exceeded. + /// Please wait and try again later. + public static let quotaExceededDescription = L10n.tr("Localizable", "app_error.quota_exceeded_description", fallback: "Image quota exceeded.\nPlease wait and try again later.") + /// Unknown Error + public static let unknownError = L10n.tr("Localizable", "app_error.unknown_error", fallback: "Unknown Error") + /// Web image loading error + public static let webImageLoadingError = L10n.tr("Localizable", "app_error.web_image_loading_error", fallback: "Web image loading error") + } + public enum AppIconType { + /// Default + public static let `default` = L10n.tr("Localizable", "app_icon_type.default", fallback: "Default") + /// Developer + public static let developer = L10n.tr("Localizable", "app_icon_type.developer", fallback: "Developer") + /// NOT MY PRESIDENT + public static let notMyPresident = L10n.tr("Localizable", "app_icon_type.not_my_president", fallback: "NOT MY PRESIDENT") + /// Stand With Ukraine (2022) + public static let standWithUkraine2022 = L10n.tr("Localizable", "app_icon_type.stand_with_ukraine_2022", fallback: "Stand With Ukraine (2022)") + /// Ukiyo-e + public static let ukiyoe = L10n.tr("Localizable", "app_icon_type.ukiyoe", fallback: "Ukiyo-e") } public enum AppIconView { - public enum Title { - /// App icon - public static let appIcon = L10n.tr("Localizable", "app_icon_view.title.app_icon", fallback: "App icon") - } + /// App icon + public static let appIcon = L10n.tr("Localizable", "app_icon_view.app_icon", fallback: "App icon") } public enum AppearanceSettingView { - public enum Button { - /// App icon - public static let appIcon = L10n.tr("Localizable", "appearance_setting_view.button.app_icon", fallback: "App icon") - } - public enum Menu { - public enum Title { - /// Infite - public static let infite = L10n.tr("Localizable", "appearance_setting_view.menu.title.infite", fallback: "Infite") - } - } - public enum Section { - public enum Title { - /// Gallery - public static let gallery = L10n.tr("Localizable", "appearance_setting_view.section.title.gallery", fallback: "Gallery") - /// List - public static let list = L10n.tr("Localizable", "appearance_setting_view.section.title.list", fallback: "List") - } - } - public enum Title { - /// Appearance - public static let appearance = L10n.tr("Localizable", "appearance_setting_view.title.appearance", fallback: "Appearance") - /// Display mode - public static let displayMode = L10n.tr("Localizable", "appearance_setting_view.title.display_mode", fallback: "Display mode") - /// Displays Japanese title - public static let displaysJapaneseTitle = L10n.tr("Localizable", "appearance_setting_view.title.displays_japanese_title", fallback: "Displays Japanese title") - /// Maximum number of tags - public static let maximumNumberOfTags = L10n.tr("Localizable", "appearance_setting_view.title.maximum_number_of_tags", fallback: "Maximum number of tags") - /// Shows tags in list - public static let showsTagsInList = L10n.tr("Localizable", "appearance_setting_view.title.shows_tags_in_list", fallback: "Shows tags in list") - /// Theme - public static let theme = L10n.tr("Localizable", "appearance_setting_view.title.theme", fallback: "Theme") - /// Tint color - public static let tintColor = L10n.tr("Localizable", "appearance_setting_view.title.tint_color", fallback: "Tint color") - } + /// App icon + public static let appIcon = L10n.tr("Localizable", "appearance_setting_view.app_icon", fallback: "App icon") + /// Appearance + public static let appearance = L10n.tr("Localizable", "appearance_setting_view.appearance", fallback: "Appearance") + /// Display mode + public static let displayMode = L10n.tr("Localizable", "appearance_setting_view.display_mode", fallback: "Display mode") + /// Displays Japanese title + public static let displaysJapaneseTitle = L10n.tr("Localizable", "appearance_setting_view.displays_japanese_title", fallback: "Displays Japanese title") + /// Gallery + public static let gallery = L10n.tr("Localizable", "appearance_setting_view.gallery", fallback: "Gallery") + /// Infite + public static let infite = L10n.tr("Localizable", "appearance_setting_view.infite", fallback: "Infite") + /// List + public static let list = L10n.tr("Localizable", "appearance_setting_view.list", fallback: "List") + /// Maximum number of tags + public static let maximumNumberOfTags = L10n.tr("Localizable", "appearance_setting_view.maximum_number_of_tags", fallback: "Maximum number of tags") + /// Shows tags in list + public static let showsTagsInList = L10n.tr("Localizable", "appearance_setting_view.shows_tags_in_list", fallback: "Shows tags in list") + /// Theme + public static let theme = L10n.tr("Localizable", "appearance_setting_view.theme", fallback: "Theme") + /// Tint color + public static let tintColor = L10n.tr("Localizable", "appearance_setting_view.tint_color", fallback: "Tint color") + } + public enum ArchiveResolution { + /// Original + public static let original = L10n.tr("Localizable", "archive_resolution.original", fallback: "Original") } public enum ArchivesView { - public enum Button { - /// Download To H@H Client - public static let downloadToHathClient = L10n.tr("Localizable", "archives_view.button.download_to_hath_client", fallback: "Download To H@H Client") - } - public enum Title { - /// Archives - public static let archives = L10n.tr("Localizable", "archives_view.title.archives", fallback: "Archives") - } + /// Archives + public static let archives = L10n.tr("Localizable", "archives_view.archives", fallback: "Archives") + /// Download To H@H Client + public static let downloadToHathClient = L10n.tr("Localizable", "archives_view.download_to_hath_client", fallback: "Download To H@H Client") } - public enum CommentsView { - public enum Title { - /// Comments - public static let comments = L10n.tr("Localizable", "comments_view.title.comments", fallback: "Comments") - } + public enum AutoLockPolicy { + /// Instantly + public static let instantly = L10n.tr("Localizable", "auto_lock_policy.instantly", fallback: "Instantly") + /// Never + public static let never = L10n.tr("Localizable", "auto_lock_policy.never", fallback: "Never") } - public enum Common { - public enum Button { - /// Cancel - public static let cancel = L10n.tr("Localizable", "common.button.cancel", fallback: "Cancel") - } - public enum Value { - /// %@ day - public static func day(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.day", String(describing: p1), fallback: "%@ day") - } - /// %@ days - public static func days(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.days", String(describing: p1), fallback: "%@ days") - } - /// %@ hour - public static func hour(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.hour", String(describing: p1), fallback: "%@ hour") - } - /// %@ hours - public static func hours(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.hours", String(describing: p1), fallback: "%@ hours") - } - /// %@ minute - public static func minute(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.minute", String(describing: p1), fallback: "%@ minute") - } - /// %@ minutes - public static func minutes(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.minutes", String(describing: p1), fallback: "%@ minutes") - } - /// %@ pages - public static func pages(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.pages", String(describing: p1), fallback: "%@ pages") - } - /// %@ records - public static func records(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.records", String(describing: p1), fallback: "%@ records") - } - /// %@ second - public static func second(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.second", String(describing: p1), fallback: "%@ second") - } - /// %@ seconds - public static func seconds(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.seconds", String(describing: p1), fallback: "%@ seconds") - } - /// %@ stars - public static func stars(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.stars", String(describing: p1), fallback: "%@ stars") - } - /// %@ times - public static func times(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.times", String(describing: p1), fallback: "%@ times") - } - } + public enum AutoPlayPolicy { + /// Off + public static let off = L10n.tr("Localizable", "auto_play_policy.off", fallback: "Off") } - public enum ConfirmationDialog { - public enum Button { - /// Clear - public static let clear = L10n.tr("Localizable", "confirmation_dialog.button.clear", fallback: "Clear") - /// Delete - public static let delete = L10n.tr("Localizable", "confirmation_dialog.button.delete", fallback: "Delete") - /// Drop the database - public static let dropDatabase = L10n.tr("Localizable", "confirmation_dialog.button.drop_database", fallback: "Drop the database") - /// Logout - public static let logout = L10n.tr("Localizable", "confirmation_dialog.button.logout", fallback: "Logout") - /// Remove - public static let remove = L10n.tr("Localizable", "confirmation_dialog.button.remove", fallback: "Remove") - /// Reset - public static let reset = L10n.tr("Localizable", "confirmation_dialog.button.reset", fallback: "Reset") - } - public enum Title { - /// Are you sure to clear? - public static let clear = L10n.tr("Localizable", "confirmation_dialog.title.clear", fallback: "Are you sure to clear?") - /// Are you sure to delete this item? - public static let delete = L10n.tr("Localizable", "confirmation_dialog.title.delete", fallback: "Are you sure to delete this item?") - /// You will lose all your data in this app. - /// Are you sure to drop the database? - public static let dropDatabase = L10n.tr("Localizable", "confirmation_dialog.title.drop_database", fallback: "You will lose all your data in this app.\nAre you sure to drop the database?") - /// Are you sure to logout? - public static let logout = L10n.tr("Localizable", "confirmation_dialog.title.logout", fallback: "Are you sure to logout?") - /// Are you sure to remove your custom translations? - public static let removeCustomTranslations = L10n.tr("Localizable", "confirmation_dialog.title.remove_custom_translations", fallback: "Are you sure to remove your custom translations?") - /// Are you sure to reset? - public static let reset = L10n.tr("Localizable", "confirmation_dialog.title.reset", fallback: "Are you sure to reset?") - } + public enum BanInterval { + /// and + public static let and = L10n.tr("Localizable", "ban_interval.and", fallback: "and") } - public enum DateSeekView { - public enum Button { - /// Newer - public static let seekNewer = L10n.tr("Localizable", "date_seek_view.button.seek_newer", fallback: "Newer") - /// Older - public static let seekOlder = L10n.tr("Localizable", "date_seek_view.button.seek_older", fallback: "Older") + public enum BrowsingCountry { + /// Afghanistan + public static let afghanistan = L10n.tr("Localizable", "browsing_country.afghanistan", fallback: "Afghanistan") + /// Aland Islands + public static let alandIslands = L10n.tr("Localizable", "browsing_country.aland_islands", fallback: "Aland Islands") + /// Albania + public static let albania = L10n.tr("Localizable", "browsing_country.albania", fallback: "Albania") + /// Algeria + public static let algeria = L10n.tr("Localizable", "browsing_country.algeria", fallback: "Algeria") + /// American Samoa + public static let americanSamoa = L10n.tr("Localizable", "browsing_country.american_samoa", fallback: "American Samoa") + /// Andorra + public static let andorra = L10n.tr("Localizable", "browsing_country.andorra", fallback: "Andorra") + /// Angola + public static let angola = L10n.tr("Localizable", "browsing_country.angola", fallback: "Angola") + /// Anguilla + public static let anguilla = L10n.tr("Localizable", "browsing_country.anguilla", fallback: "Anguilla") + /// Antarctica + public static let antarctica = L10n.tr("Localizable", "browsing_country.antarctica", fallback: "Antarctica") + /// Antigua and Barbuda + public static let antiguaAndBarbuda = L10n.tr("Localizable", "browsing_country.antigua_and_barbuda", fallback: "Antigua and Barbuda") + /// Argentina + public static let argentina = L10n.tr("Localizable", "browsing_country.argentina", fallback: "Argentina") + /// Armenia + public static let armenia = L10n.tr("Localizable", "browsing_country.armenia", fallback: "Armenia") + /// Aruba + public static let aruba = L10n.tr("Localizable", "browsing_country.aruba", fallback: "Aruba") + /// Asia-Pacific Region + public static let asiaPacificRegion = L10n.tr("Localizable", "browsing_country.asia_pacific_region", fallback: "Asia-Pacific Region") + /// Australia + public static let australia = L10n.tr("Localizable", "browsing_country.australia", fallback: "Australia") + /// Austria + public static let austria = L10n.tr("Localizable", "browsing_country.austria", fallback: "Austria") + /// Auto-Detect + public static let autoDetect = L10n.tr("Localizable", "browsing_country.auto_detect", fallback: "Auto-Detect") + /// Azerbaijan + public static let azerbaijan = L10n.tr("Localizable", "browsing_country.azerbaijan", fallback: "Azerbaijan") + /// Bahamas + public static let bahamas = L10n.tr("Localizable", "browsing_country.bahamas", fallback: "Bahamas") + /// Bahrain + public static let bahrain = L10n.tr("Localizable", "browsing_country.bahrain", fallback: "Bahrain") + /// Bangladesh + public static let bangladesh = L10n.tr("Localizable", "browsing_country.bangladesh", fallback: "Bangladesh") + /// Barbados + public static let barbados = L10n.tr("Localizable", "browsing_country.barbados", fallback: "Barbados") + /// Belarus + public static let belarus = L10n.tr("Localizable", "browsing_country.belarus", fallback: "Belarus") + /// Belgium + public static let belgium = L10n.tr("Localizable", "browsing_country.belgium", fallback: "Belgium") + /// Belize + public static let belize = L10n.tr("Localizable", "browsing_country.belize", fallback: "Belize") + /// Benin + public static let benin = L10n.tr("Localizable", "browsing_country.benin", fallback: "Benin") + /// Bermuda + public static let bermuda = L10n.tr("Localizable", "browsing_country.bermuda", fallback: "Bermuda") + /// Bhutan + public static let bhutan = L10n.tr("Localizable", "browsing_country.bhutan", fallback: "Bhutan") + /// Bolivia + public static let bolivia = L10n.tr("Localizable", "browsing_country.bolivia", fallback: "Bolivia") + /// Bonaire Saint Eustatius and Saba + public static let bonaireSaintEustatiusAndSaba = L10n.tr("Localizable", "browsing_country.bonaire_saint_eustatius_and_saba", fallback: "Bonaire Saint Eustatius and Saba") + /// Bosnia and Herzegovina + public static let bosniaAndHerzegovina = L10n.tr("Localizable", "browsing_country.bosnia_and_herzegovina", fallback: "Bosnia and Herzegovina") + /// Botswana + public static let botswana = L10n.tr("Localizable", "browsing_country.botswana", fallback: "Botswana") + /// Bouvet Island + public static let bouvetIsland = L10n.tr("Localizable", "browsing_country.bouvet_island", fallback: "Bouvet Island") + /// Brazil + public static let brazil = L10n.tr("Localizable", "browsing_country.brazil", fallback: "Brazil") + /// British Indian Ocean Territory + public static let britishIndianOceanTerritory = L10n.tr("Localizable", "browsing_country.british_indian_ocean_territory", fallback: "British Indian Ocean Territory") + /// Brunei Darussalam + public static let bruneiDarussalam = L10n.tr("Localizable", "browsing_country.brunei_darussalam", fallback: "Brunei Darussalam") + /// Bulgaria + public static let bulgaria = L10n.tr("Localizable", "browsing_country.bulgaria", fallback: "Bulgaria") + /// Burkina Faso + public static let burkinaFaso = L10n.tr("Localizable", "browsing_country.burkina_faso", fallback: "Burkina Faso") + /// Burundi + public static let burundi = L10n.tr("Localizable", "browsing_country.burundi", fallback: "Burundi") + /// Cambodia + public static let cambodia = L10n.tr("Localizable", "browsing_country.cambodia", fallback: "Cambodia") + /// Cameroon + public static let cameroon = L10n.tr("Localizable", "browsing_country.cameroon", fallback: "Cameroon") + /// Canada + public static let canada = L10n.tr("Localizable", "browsing_country.canada", fallback: "Canada") + /// Cape Verde + public static let capeVerde = L10n.tr("Localizable", "browsing_country.cape_verde", fallback: "Cape Verde") + /// Cayman Islands + public static let caymanIslands = L10n.tr("Localizable", "browsing_country.cayman_islands", fallback: "Cayman Islands") + /// Central African Republic + public static let centralAfricanRepublic = L10n.tr("Localizable", "browsing_country.central_african_republic", fallback: "Central African Republic") + /// Chad + public static let chad = L10n.tr("Localizable", "browsing_country.chad", fallback: "Chad") + /// Chile + public static let chile = L10n.tr("Localizable", "browsing_country.chile", fallback: "Chile") + /// China + public static let china = L10n.tr("Localizable", "browsing_country.china", fallback: "China") + /// Christmas Island + public static let christmasIsland = L10n.tr("Localizable", "browsing_country.christmas_island", fallback: "Christmas Island") + /// Cocos Islands + public static let cocosIslands = L10n.tr("Localizable", "browsing_country.cocos_islands", fallback: "Cocos Islands") + /// Colombia + public static let colombia = L10n.tr("Localizable", "browsing_country.colombia", fallback: "Colombia") + /// Comoros + public static let comoros = L10n.tr("Localizable", "browsing_country.comoros", fallback: "Comoros") + /// Congo + public static let congo = L10n.tr("Localizable", "browsing_country.congo", fallback: "Congo") + /// Cook Islands + public static let cookIslands = L10n.tr("Localizable", "browsing_country.cook_islands", fallback: "Cook Islands") + /// Costa Rica + public static let costaRica = L10n.tr("Localizable", "browsing_country.costa_rica", fallback: "Costa Rica") + /// Cote D'Ivoire + public static let coteDIvoire = L10n.tr("Localizable", "browsing_country.cote_d_ivoire", fallback: "Cote D'Ivoire") + /// Croatia + public static let croatia = L10n.tr("Localizable", "browsing_country.croatia", fallback: "Croatia") + /// Cuba + public static let cuba = L10n.tr("Localizable", "browsing_country.cuba", fallback: "Cuba") + /// Curacao + public static let curacao = L10n.tr("Localizable", "browsing_country.curacao", fallback: "Curacao") + /// Cyprus + public static let cyprus = L10n.tr("Localizable", "browsing_country.cyprus", fallback: "Cyprus") + /// Czech Republic + public static let czechRepublic = L10n.tr("Localizable", "browsing_country.czech_republic", fallback: "Czech Republic") + /// Denmark + public static let denmark = L10n.tr("Localizable", "browsing_country.denmark", fallback: "Denmark") + /// Djibouti + public static let djibouti = L10n.tr("Localizable", "browsing_country.djibouti", fallback: "Djibouti") + /// Dominica + public static let dominica = L10n.tr("Localizable", "browsing_country.dominica", fallback: "Dominica") + /// Dominican Republic + public static let dominicanRepublic = L10n.tr("Localizable", "browsing_country.dominican_republic", fallback: "Dominican Republic") + /// Ecuador + public static let ecuador = L10n.tr("Localizable", "browsing_country.ecuador", fallback: "Ecuador") + /// Egypt + public static let egypt = L10n.tr("Localizable", "browsing_country.egypt", fallback: "Egypt") + /// El Salvador + public static let elSalvador = L10n.tr("Localizable", "browsing_country.el_salvador", fallback: "El Salvador") + /// Equatorial Guinea + public static let equatorialGuinea = L10n.tr("Localizable", "browsing_country.equatorial_guinea", fallback: "Equatorial Guinea") + /// Eritrea + public static let eritrea = L10n.tr("Localizable", "browsing_country.eritrea", fallback: "Eritrea") + /// Estonia + public static let estonia = L10n.tr("Localizable", "browsing_country.estonia", fallback: "Estonia") + /// Ethiopia + public static let ethiopia = L10n.tr("Localizable", "browsing_country.ethiopia", fallback: "Ethiopia") + /// Europe + public static let europe = L10n.tr("Localizable", "browsing_country.europe", fallback: "Europe") + /// Falkland Islands + public static let falklandIslands = L10n.tr("Localizable", "browsing_country.falkland_islands", fallback: "Falkland Islands") + /// Faroe Islands + public static let faroeIslands = L10n.tr("Localizable", "browsing_country.faroe_islands", fallback: "Faroe Islands") + /// Fiji + public static let fiji = L10n.tr("Localizable", "browsing_country.fiji", fallback: "Fiji") + /// Finland + public static let finland = L10n.tr("Localizable", "browsing_country.finland", fallback: "Finland") + /// France + public static let france = L10n.tr("Localizable", "browsing_country.france", fallback: "France") + /// French Guiana + public static let frenchGuiana = L10n.tr("Localizable", "browsing_country.french_guiana", fallback: "French Guiana") + /// French Polynesia + public static let frenchPolynesia = L10n.tr("Localizable", "browsing_country.french_polynesia", fallback: "French Polynesia") + /// French Southern Territories + public static let frenchSouthernTerritories = L10n.tr("Localizable", "browsing_country.french_southern_territories", fallback: "French Southern Territories") + /// Gabon + public static let gabon = L10n.tr("Localizable", "browsing_country.gabon", fallback: "Gabon") + /// Gambia + public static let gambia = L10n.tr("Localizable", "browsing_country.gambia", fallback: "Gambia") + /// Georgia + public static let georgia = L10n.tr("Localizable", "browsing_country.georgia", fallback: "Georgia") + /// Germany + public static let germany = L10n.tr("Localizable", "browsing_country.germany", fallback: "Germany") + /// Ghana + public static let ghana = L10n.tr("Localizable", "browsing_country.ghana", fallback: "Ghana") + /// Gibraltar + public static let gibraltar = L10n.tr("Localizable", "browsing_country.gibraltar", fallback: "Gibraltar") + /// Greece + public static let greece = L10n.tr("Localizable", "browsing_country.greece", fallback: "Greece") + /// Greenland + public static let greenland = L10n.tr("Localizable", "browsing_country.greenland", fallback: "Greenland") + /// Grenada + public static let grenada = L10n.tr("Localizable", "browsing_country.grenada", fallback: "Grenada") + /// Guadeloupe + public static let guadeloupe = L10n.tr("Localizable", "browsing_country.guadeloupe", fallback: "Guadeloupe") + /// Guam + public static let guam = L10n.tr("Localizable", "browsing_country.guam", fallback: "Guam") + /// Guatemala + public static let guatemala = L10n.tr("Localizable", "browsing_country.guatemala", fallback: "Guatemala") + /// Guernsey + public static let guernsey = L10n.tr("Localizable", "browsing_country.guernsey", fallback: "Guernsey") + /// Guinea + public static let guinea = L10n.tr("Localizable", "browsing_country.guinea", fallback: "Guinea") + /// Guinea-Bissau + public static let guineaBissau = L10n.tr("Localizable", "browsing_country.guinea_bissau", fallback: "Guinea-Bissau") + /// Guyana + public static let guyana = L10n.tr("Localizable", "browsing_country.guyana", fallback: "Guyana") + /// Haiti + public static let haiti = L10n.tr("Localizable", "browsing_country.haiti", fallback: "Haiti") + /// Heard Island and McDonald Islands + public static let heardIslandAndMcDonaldIslands = L10n.tr("Localizable", "browsing_country.heard_island_and_mc_donald_islands", fallback: "Heard Island and McDonald Islands") + /// Honduras + public static let honduras = L10n.tr("Localizable", "browsing_country.honduras", fallback: "Honduras") + /// Hong Kong + public static let hongKong = L10n.tr("Localizable", "browsing_country.hong_kong", fallback: "Hong Kong") + /// Hungary + public static let hungary = L10n.tr("Localizable", "browsing_country.hungary", fallback: "Hungary") + /// Iceland + public static let iceland = L10n.tr("Localizable", "browsing_country.iceland", fallback: "Iceland") + /// India + public static let india = L10n.tr("Localizable", "browsing_country.india", fallback: "India") + /// Indonesia + public static let indonesia = L10n.tr("Localizable", "browsing_country.indonesia", fallback: "Indonesia") + /// Iran + public static let iran = L10n.tr("Localizable", "browsing_country.iran", fallback: "Iran") + /// Iraq + public static let iraq = L10n.tr("Localizable", "browsing_country.iraq", fallback: "Iraq") + /// Ireland + public static let ireland = L10n.tr("Localizable", "browsing_country.ireland", fallback: "Ireland") + /// Isle of Man + public static let isleOfMan = L10n.tr("Localizable", "browsing_country.isle_of_man", fallback: "Isle of Man") + /// Israel + public static let israel = L10n.tr("Localizable", "browsing_country.israel", fallback: "Israel") + /// Italy + public static let italy = L10n.tr("Localizable", "browsing_country.italy", fallback: "Italy") + /// Jamaica + public static let jamaica = L10n.tr("Localizable", "browsing_country.jamaica", fallback: "Jamaica") + /// Japan + public static let japan = L10n.tr("Localizable", "browsing_country.japan", fallback: "Japan") + /// Jersey + public static let jersey = L10n.tr("Localizable", "browsing_country.jersey", fallback: "Jersey") + /// Jordan + public static let jordan = L10n.tr("Localizable", "browsing_country.jordan", fallback: "Jordan") + /// Kazakhstan + public static let kazakhstan = L10n.tr("Localizable", "browsing_country.kazakhstan", fallback: "Kazakhstan") + /// Kenya + public static let kenya = L10n.tr("Localizable", "browsing_country.kenya", fallback: "Kenya") + /// Kiribati + public static let kiribati = L10n.tr("Localizable", "browsing_country.kiribati", fallback: "Kiribati") + /// Kuwait + public static let kuwait = L10n.tr("Localizable", "browsing_country.kuwait", fallback: "Kuwait") + /// Kyrgyzstan + public static let kyrgyzstan = L10n.tr("Localizable", "browsing_country.kyrgyzstan", fallback: "Kyrgyzstan") + /// Lao People's Democratic Republic + public static let laoPeoplesDemocraticRepublic = L10n.tr("Localizable", "browsing_country.lao_peoples_democratic_republic", fallback: "Lao People's Democratic Republic") + /// Latvia + public static let latvia = L10n.tr("Localizable", "browsing_country.latvia", fallback: "Latvia") + /// Lebanon + public static let lebanon = L10n.tr("Localizable", "browsing_country.lebanon", fallback: "Lebanon") + /// Lesotho + public static let lesotho = L10n.tr("Localizable", "browsing_country.lesotho", fallback: "Lesotho") + /// Liberia + public static let liberia = L10n.tr("Localizable", "browsing_country.liberia", fallback: "Liberia") + /// Libya + public static let libya = L10n.tr("Localizable", "browsing_country.libya", fallback: "Libya") + /// Liechtenstein + public static let liechtenstein = L10n.tr("Localizable", "browsing_country.liechtenstein", fallback: "Liechtenstein") + /// Lithuania + public static let lithuania = L10n.tr("Localizable", "browsing_country.lithuania", fallback: "Lithuania") + /// Luxembourg + public static let luxembourg = L10n.tr("Localizable", "browsing_country.luxembourg", fallback: "Luxembourg") + /// Macau + public static let macau = L10n.tr("Localizable", "browsing_country.macau", fallback: "Macau") + /// Macedonia + public static let macedonia = L10n.tr("Localizable", "browsing_country.macedonia", fallback: "Macedonia") + /// Madagascar + public static let madagascar = L10n.tr("Localizable", "browsing_country.madagascar", fallback: "Madagascar") + /// Malawi + public static let malawi = L10n.tr("Localizable", "browsing_country.malawi", fallback: "Malawi") + /// Malaysia + public static let malaysia = L10n.tr("Localizable", "browsing_country.malaysia", fallback: "Malaysia") + /// Maldives + public static let maldives = L10n.tr("Localizable", "browsing_country.maldives", fallback: "Maldives") + /// Mali + public static let mali = L10n.tr("Localizable", "browsing_country.mali", fallback: "Mali") + /// Malta + public static let malta = L10n.tr("Localizable", "browsing_country.malta", fallback: "Malta") + /// Marshall Islands + public static let marshallIslands = L10n.tr("Localizable", "browsing_country.marshall_islands", fallback: "Marshall Islands") + /// Martinique + public static let martinique = L10n.tr("Localizable", "browsing_country.martinique", fallback: "Martinique") + /// Mauritania + public static let mauritania = L10n.tr("Localizable", "browsing_country.mauritania", fallback: "Mauritania") + /// Mauritius + public static let mauritius = L10n.tr("Localizable", "browsing_country.mauritius", fallback: "Mauritius") + /// Mayotte + public static let mayotte = L10n.tr("Localizable", "browsing_country.mayotte", fallback: "Mayotte") + /// Mexico + public static let mexico = L10n.tr("Localizable", "browsing_country.mexico", fallback: "Mexico") + /// Micronesia + public static let micronesia = L10n.tr("Localizable", "browsing_country.micronesia", fallback: "Micronesia") + /// Moldova + public static let moldova = L10n.tr("Localizable", "browsing_country.moldova", fallback: "Moldova") + /// Monaco + public static let monaco = L10n.tr("Localizable", "browsing_country.monaco", fallback: "Monaco") + /// Mongolia + public static let mongolia = L10n.tr("Localizable", "browsing_country.mongolia", fallback: "Mongolia") + /// Montenegro + public static let montenegro = L10n.tr("Localizable", "browsing_country.montenegro", fallback: "Montenegro") + /// Montserrat + public static let montserrat = L10n.tr("Localizable", "browsing_country.montserrat", fallback: "Montserrat") + /// Morocco + public static let morocco = L10n.tr("Localizable", "browsing_country.morocco", fallback: "Morocco") + /// Mozambique + public static let mozambique = L10n.tr("Localizable", "browsing_country.mozambique", fallback: "Mozambique") + /// Myanmar + public static let myanmar = L10n.tr("Localizable", "browsing_country.myanmar", fallback: "Myanmar") + /// Namibia + public static let namibia = L10n.tr("Localizable", "browsing_country.namibia", fallback: "Namibia") + /// Nauru + public static let nauru = L10n.tr("Localizable", "browsing_country.nauru", fallback: "Nauru") + /// Nepal + public static let nepal = L10n.tr("Localizable", "browsing_country.nepal", fallback: "Nepal") + /// Netherlands + public static let netherlands = L10n.tr("Localizable", "browsing_country.netherlands", fallback: "Netherlands") + /// New Caledonia + public static let newCaledonia = L10n.tr("Localizable", "browsing_country.new_caledonia", fallback: "New Caledonia") + /// New Zealand + public static let newZealand = L10n.tr("Localizable", "browsing_country.new_zealand", fallback: "New Zealand") + /// Nicaragua + public static let nicaragua = L10n.tr("Localizable", "browsing_country.nicaragua", fallback: "Nicaragua") + /// Niger + public static let niger = L10n.tr("Localizable", "browsing_country.niger", fallback: "Niger") + /// Nigeria + public static let nigeria = L10n.tr("Localizable", "browsing_country.nigeria", fallback: "Nigeria") + /// Niue + public static let niue = L10n.tr("Localizable", "browsing_country.niue", fallback: "Niue") + /// Norfolk Island + public static let norfolkIsland = L10n.tr("Localizable", "browsing_country.norfolk_island", fallback: "Norfolk Island") + /// North Korea + public static let northKorea = L10n.tr("Localizable", "browsing_country.north_korea", fallback: "North Korea") + /// Northern Mariana Islands + public static let northernMarianaIslands = L10n.tr("Localizable", "browsing_country.northern_mariana_islands", fallback: "Northern Mariana Islands") + /// Norway + public static let norway = L10n.tr("Localizable", "browsing_country.norway", fallback: "Norway") + /// Oman + public static let oman = L10n.tr("Localizable", "browsing_country.oman", fallback: "Oman") + /// Pakistan + public static let pakistan = L10n.tr("Localizable", "browsing_country.pakistan", fallback: "Pakistan") + /// Palau + public static let palau = L10n.tr("Localizable", "browsing_country.palau", fallback: "Palau") + /// Palestinian Territory + public static let palestinianTerritory = L10n.tr("Localizable", "browsing_country.palestinian_territory", fallback: "Palestinian Territory") + /// Panama + public static let panama = L10n.tr("Localizable", "browsing_country.panama", fallback: "Panama") + /// Papua New Guinea + public static let papuaNewGuinea = L10n.tr("Localizable", "browsing_country.papua_new_guinea", fallback: "Papua New Guinea") + /// Paraguay + public static let paraguay = L10n.tr("Localizable", "browsing_country.paraguay", fallback: "Paraguay") + /// Peru + public static let peru = L10n.tr("Localizable", "browsing_country.peru", fallback: "Peru") + /// Philippines + public static let philippines = L10n.tr("Localizable", "browsing_country.philippines", fallback: "Philippines") + /// Pitcairn Islands + public static let pitcairnIslands = L10n.tr("Localizable", "browsing_country.pitcairn_islands", fallback: "Pitcairn Islands") + /// Poland + public static let poland = L10n.tr("Localizable", "browsing_country.poland", fallback: "Poland") + /// Portugal + public static let portugal = L10n.tr("Localizable", "browsing_country.portugal", fallback: "Portugal") + /// Puerto Rico + public static let puertoRico = L10n.tr("Localizable", "browsing_country.puerto_rico", fallback: "Puerto Rico") + /// Qatar + public static let qatar = L10n.tr("Localizable", "browsing_country.qatar", fallback: "Qatar") + /// Reunion + public static let reunion = L10n.tr("Localizable", "browsing_country.reunion", fallback: "Reunion") + /// Romania + public static let romania = L10n.tr("Localizable", "browsing_country.romania", fallback: "Romania") + /// Russian Federation + public static let russianFederation = L10n.tr("Localizable", "browsing_country.russian_federation", fallback: "Russian Federation") + /// Rwanda + public static let rwanda = L10n.tr("Localizable", "browsing_country.rwanda", fallback: "Rwanda") + /// Saint Barthelemy + public static let saintBarthelemy = L10n.tr("Localizable", "browsing_country.saint_barthelemy", fallback: "Saint Barthelemy") + /// Saint Helena + public static let saintHelena = L10n.tr("Localizable", "browsing_country.saint_helena", fallback: "Saint Helena") + /// Saint Kitts and Nevis + public static let saintKittsAndNevis = L10n.tr("Localizable", "browsing_country.saint_kitts_and_nevis", fallback: "Saint Kitts and Nevis") + /// Saint Lucia + public static let saintLucia = L10n.tr("Localizable", "browsing_country.saint_lucia", fallback: "Saint Lucia") + /// Saint Martin + public static let saintMartin = L10n.tr("Localizable", "browsing_country.saint_martin", fallback: "Saint Martin") + /// Saint Pierre and Miquelon + public static let saintPierreAndMiquelon = L10n.tr("Localizable", "browsing_country.saint_pierre_and_miquelon", fallback: "Saint Pierre and Miquelon") + /// Saint Vincent and the Grenadines + public static let saintVincentAndTheGrenadines = L10n.tr("Localizable", "browsing_country.saint_vincent_and_the_grenadines", fallback: "Saint Vincent and the Grenadines") + /// Samoa + public static let samoa = L10n.tr("Localizable", "browsing_country.samoa", fallback: "Samoa") + /// San Marino + public static let sanMarino = L10n.tr("Localizable", "browsing_country.san_marino", fallback: "San Marino") + /// Sao Tome and Principe + public static let saoTomeAndPrincipe = L10n.tr("Localizable", "browsing_country.sao_tome_and_principe", fallback: "Sao Tome and Principe") + /// Saudi Arabia + public static let saudiArabia = L10n.tr("Localizable", "browsing_country.saudi_arabia", fallback: "Saudi Arabia") + /// Senegal + public static let senegal = L10n.tr("Localizable", "browsing_country.senegal", fallback: "Senegal") + /// Serbia + public static let serbia = L10n.tr("Localizable", "browsing_country.serbia", fallback: "Serbia") + /// Seychelles + public static let seychelles = L10n.tr("Localizable", "browsing_country.seychelles", fallback: "Seychelles") + /// Sierra Leone + public static let sierraLeone = L10n.tr("Localizable", "browsing_country.sierra_leone", fallback: "Sierra Leone") + /// Singapore + public static let singapore = L10n.tr("Localizable", "browsing_country.singapore", fallback: "Singapore") + /// Sint Maarten + public static let sintMaarten = L10n.tr("Localizable", "browsing_country.sint_maarten", fallback: "Sint Maarten") + /// Slovakia + public static let slovakia = L10n.tr("Localizable", "browsing_country.slovakia", fallback: "Slovakia") + /// Slovenia + public static let slovenia = L10n.tr("Localizable", "browsing_country.slovenia", fallback: "Slovenia") + /// Solomon Islands + public static let solomonIslands = L10n.tr("Localizable", "browsing_country.solomon_islands", fallback: "Solomon Islands") + /// Somalia + public static let somalia = L10n.tr("Localizable", "browsing_country.somalia", fallback: "Somalia") + /// South Africa + public static let southAfrica = L10n.tr("Localizable", "browsing_country.south_africa", fallback: "South Africa") + /// South Georgia and the South Sandwich Islands + public static let southGeorgiaAndTheSouthSandwichIslands = L10n.tr("Localizable", "browsing_country.south_georgia_and_the_south_sandwich_islands", fallback: "South Georgia and the South Sandwich Islands") + /// South Korea + public static let southKorea = L10n.tr("Localizable", "browsing_country.south_korea", fallback: "South Korea") + /// South Sudan + public static let southSudan = L10n.tr("Localizable", "browsing_country.south_sudan", fallback: "South Sudan") + /// Spain + public static let spain = L10n.tr("Localizable", "browsing_country.spain", fallback: "Spain") + /// Sri Lanka + public static let sriLanka = L10n.tr("Localizable", "browsing_country.sri_lanka", fallback: "Sri Lanka") + /// Sudan + public static let sudan = L10n.tr("Localizable", "browsing_country.sudan", fallback: "Sudan") + /// Suriname + public static let suriname = L10n.tr("Localizable", "browsing_country.suriname", fallback: "Suriname") + /// Svalbard and Jan Mayen + public static let svalbardAndJanMayen = L10n.tr("Localizable", "browsing_country.svalbard_and_jan_mayen", fallback: "Svalbard and Jan Mayen") + /// Swaziland + public static let swaziland = L10n.tr("Localizable", "browsing_country.swaziland", fallback: "Swaziland") + /// Sweden + public static let sweden = L10n.tr("Localizable", "browsing_country.sweden", fallback: "Sweden") + /// Switzerland + public static let switzerland = L10n.tr("Localizable", "browsing_country.switzerland", fallback: "Switzerland") + /// Syrian Arab Republic + public static let syrianArabRepublic = L10n.tr("Localizable", "browsing_country.syrian_arab_republic", fallback: "Syrian Arab Republic") + /// Taiwan + public static let taiwan = L10n.tr("Localizable", "browsing_country.taiwan", fallback: "Taiwan") + /// Tajikistan + public static let tajikistan = L10n.tr("Localizable", "browsing_country.tajikistan", fallback: "Tajikistan") + /// Tanzania + public static let tanzania = L10n.tr("Localizable", "browsing_country.tanzania", fallback: "Tanzania") + /// Thailand + public static let thailand = L10n.tr("Localizable", "browsing_country.thailand", fallback: "Thailand") + /// The Democratic Republic of the Congo + public static let theDemocraticRepublicOfTheCongo = L10n.tr("Localizable", "browsing_country.the_democratic_republic_of_the_congo", fallback: "The Democratic Republic of the Congo") + /// Timor-Leste + public static let timorLeste = L10n.tr("Localizable", "browsing_country.timor_leste", fallback: "Timor-Leste") + /// Togo + public static let togo = L10n.tr("Localizable", "browsing_country.togo", fallback: "Togo") + /// Tokelau + public static let tokelau = L10n.tr("Localizable", "browsing_country.tokelau", fallback: "Tokelau") + /// Tonga + public static let tonga = L10n.tr("Localizable", "browsing_country.tonga", fallback: "Tonga") + /// Trinidad and Tobago + public static let trinidadAndTobago = L10n.tr("Localizable", "browsing_country.trinidad_and_tobago", fallback: "Trinidad and Tobago") + /// Tunisia + public static let tunisia = L10n.tr("Localizable", "browsing_country.tunisia", fallback: "Tunisia") + /// Turkey + public static let turkey = L10n.tr("Localizable", "browsing_country.turkey", fallback: "Turkey") + /// Turkmenistan + public static let turkmenistan = L10n.tr("Localizable", "browsing_country.turkmenistan", fallback: "Turkmenistan") + /// Turks and Caicos Islands + public static let turksAndCaicosIslands = L10n.tr("Localizable", "browsing_country.turks_and_caicos_islands", fallback: "Turks and Caicos Islands") + /// Tuvalu + public static let tuvalu = L10n.tr("Localizable", "browsing_country.tuvalu", fallback: "Tuvalu") + /// Uganda + public static let uganda = L10n.tr("Localizable", "browsing_country.uganda", fallback: "Uganda") + /// Ukraine + public static let ukraine = L10n.tr("Localizable", "browsing_country.ukraine", fallback: "Ukraine") + /// United Arab Emirates + public static let unitedArabEmirates = L10n.tr("Localizable", "browsing_country.united_arab_emirates", fallback: "United Arab Emirates") + /// United Kingdom + public static let unitedKingdom = L10n.tr("Localizable", "browsing_country.united_kingdom", fallback: "United Kingdom") + /// United States + public static let unitedStates = L10n.tr("Localizable", "browsing_country.united_states", fallback: "United States") + /// United States Minor Outlying Islands + public static let unitedStatesMinorOutlyingIslands = L10n.tr("Localizable", "browsing_country.united_states_minor_outlying_islands", fallback: "United States Minor Outlying Islands") + /// Uruguay + public static let uruguay = L10n.tr("Localizable", "browsing_country.uruguay", fallback: "Uruguay") + /// Uzbekistan + public static let uzbekistan = L10n.tr("Localizable", "browsing_country.uzbekistan", fallback: "Uzbekistan") + /// Vanuatu + public static let vanuatu = L10n.tr("Localizable", "browsing_country.vanuatu", fallback: "Vanuatu") + /// Vatican City State + public static let vaticanCityState = L10n.tr("Localizable", "browsing_country.vatican_city_state", fallback: "Vatican City State") + /// Venezuela + public static let venezuela = L10n.tr("Localizable", "browsing_country.venezuela", fallback: "Venezuela") + /// Vietnam + public static let vietnam = L10n.tr("Localizable", "browsing_country.vietnam", fallback: "Vietnam") + /// British Virgin Islands + public static let virginIslandsBritish = L10n.tr("Localizable", "browsing_country.virgin_islands_british", fallback: "British Virgin Islands") + /// U.S. Virgin Islands + public static let virginIslandsUS = L10n.tr("Localizable", "browsing_country.virgin_islands_US", fallback: "U.S. Virgin Islands") + /// Wallis and Futuna + public static let wallisAndFutuna = L10n.tr("Localizable", "browsing_country.wallis_and_futuna", fallback: "Wallis and Futuna") + /// Western Sahara + public static let westernSahara = L10n.tr("Localizable", "browsing_country.western_sahara", fallback: "Western Sahara") + /// Yemen + public static let yemen = L10n.tr("Localizable", "browsing_country.yemen", fallback: "Yemen") + /// Zambia + public static let zambia = L10n.tr("Localizable", "browsing_country.zambia", fallback: "Zambia") + /// Zimbabwe + public static let zimbabwe = L10n.tr("Localizable", "browsing_country.zimbabwe", fallback: "Zimbabwe") + } + public enum Category { + /// Artist CG + public static let artistCG = L10n.tr("Localizable", "category.artist_CG", fallback: "Artist CG") + /// Asian Porn + public static let asianPorn = L10n.tr("Localizable", "category.asian_porn", fallback: "Asian Porn") + /// Cosplay + public static let cosplay = L10n.tr("Localizable", "category.cosplay", fallback: "Cosplay") + /// Doujinshi + public static let doujinshi = L10n.tr("Localizable", "category.doujinshi", fallback: "Doujinshi") + /// Game CG + public static let gameCG = L10n.tr("Localizable", "category.game_CG", fallback: "Game CG") + /// Image Set + public static let imageSet = L10n.tr("Localizable", "category.image_set", fallback: "Image Set") + /// Manga + public static let manga = L10n.tr("Localizable", "category.manga", fallback: "Manga") + /// Misc + public static let misc = L10n.tr("Localizable", "category.misc", fallback: "Misc") + /// Non-H + public static let nonH = L10n.tr("Localizable", "category.non_h", fallback: "Non-H") + /// Private + public static let `private` = L10n.tr("Localizable", "category.private", fallback: "Private") + /// Western + public static let western = L10n.tr("Localizable", "category.western", fallback: "Western") + } + public enum CommentsSortOrder { + /// By highest score + public static let highestScore = L10n.tr("Localizable", "comments_sort_order.highest_score", fallback: "By highest score") + /// Oldest comments first + public static let oldest = L10n.tr("Localizable", "comments_sort_order.oldest", fallback: "Oldest comments first") + /// Recent comments first + public static let recent = L10n.tr("Localizable", "comments_sort_order.recent", fallback: "Recent comments first") + } + public enum CommentsView { + /// Comments + public static let comments = L10n.tr("Localizable", "comments_view.comments", fallback: "Comments") + } + public enum CommentsVotesShowTiming { + /// Always + public static let always = L10n.tr("Localizable", "comments_votes_show_timing.always", fallback: "Always") + /// On score hover or click + public static let onHoverOrClick = L10n.tr("Localizable", "comments_votes_show_timing.on_hover_or_click", fallback: "On score hover or click") + } + public enum Common { + /// Cancel + public static let cancel = L10n.tr("Localizable", "common.cancel", fallback: "Cancel") + /// %@ day + public static func day(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.day", String(describing: p1), fallback: "%@ day") } - public enum Footer { - /// Seek to galleries around the selected date. - public static let seekAroundDate = L10n.tr("Localizable", "date_seek_view.footer.seek_around_date", fallback: "Seek to galleries around the selected date.") + /// %@ days + public static func days(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.days", String(describing: p1), fallback: "%@ days") } - public enum Title { - /// Date - public static let date = L10n.tr("Localizable", "date_seek_view.title.date", fallback: "Date") - /// Seek to date - public static let dateSeek = L10n.tr("Localizable", "date_seek_view.title.date_seek", fallback: "Seek to date") + /// %@ hour + public static func hour(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.hour", String(describing: p1), fallback: "%@ hour") } - } - public enum DetailView { - public enum Accessibility { - public enum DownloadButton { - /// Download - public static let download = L10n.tr("Localizable", "detail_view.accessibility.download_button.download", fallback: "Download") - /// Delete downloaded gallery - public static let downloaded = L10n.tr("Localizable", "detail_view.accessibility.download_button.downloaded", fallback: "Delete downloaded gallery") - /// Downloading %d of %d - public static func downloading(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "detail_view.accessibility.download_button.downloading", p1, p2, fallback: "Downloading %d of %d") - } - /// Log in to download - public static let login = L10n.tr("Localizable", "detail_view.accessibility.download_button.login", fallback: "Log in to download") - /// Retry download. %d of %d pages are already available. - public static func partial(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "detail_view.accessibility.download_button.partial", p1, p2, fallback: "Retry download. %d of %d pages are already available.") - } - /// Pause download - public static let pauseAction = L10n.tr("Localizable", "detail_view.accessibility.download_button.pause_action", fallback: "Pause download") - /// Resume download. Paused at %d of %d - public static func paused(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "detail_view.accessibility.download_button.paused", p1, p2, fallback: "Resume download. Paused at %d of %d") - } - /// Preparing download - public static let preparing = L10n.tr("Localizable", "detail_view.accessibility.download_button.preparing", fallback: "Preparing download") - /// Queued - public static let queued = L10n.tr("Localizable", "detail_view.accessibility.download_button.queued", fallback: "Queued") - /// Repair download - public static let repair = L10n.tr("Localizable", "detail_view.accessibility.download_button.repair", fallback: "Repair download") - /// Retry download - public static let retry = L10n.tr("Localizable", "detail_view.accessibility.download_button.retry", fallback: "Retry download") - /// Update download - public static let update = L10n.tr("Localizable", "detail_view.accessibility.download_button.update", fallback: "Update download") - } + /// %@ hours + public static func hours(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.hours", String(describing: p1), fallback: "%@ hours") } - public enum ActionSection { - public enum Button { - /// Give a Rating - public static let giveARating = L10n.tr("Localizable", "detail_view.action_section.button.give_a_rating", fallback: "Give a Rating") - /// Similar Gallery - public static let similarGallery = L10n.tr("Localizable", "detail_view.action_section.button.similar_gallery", fallback: "Similar Gallery") - } + /// %@ minute + public static func minute(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.minute", String(describing: p1), fallback: "%@ minute") } - public enum Button { - /// DONE - public static let downloadDone = L10n.tr("Localizable", "detail_view.button.download_done", fallback: "DONE") - /// GET - public static let downloadGet = L10n.tr("Localizable", "detail_view.button.download_get", fallback: "GET") - /// LOG IN - public static let downloadLogin = L10n.tr("Localizable", "detail_view.button.download_login", fallback: "LOG IN") - /// REPAIR - public static let downloadRepair = L10n.tr("Localizable", "detail_view.button.download_repair", fallback: "REPAIR") - /// RETRY - public static let downloadRetry = L10n.tr("Localizable", "detail_view.button.download_retry", fallback: "RETRY") - /// UPDATE - public static let downloadUpdate = L10n.tr("Localizable", "detail_view.button.download_update", fallback: "UPDATE") - /// WAIT - public static let downloadWait = L10n.tr("Localizable", "detail_view.button.download_wait", fallback: "WAIT") - /// Post comment - public static let postComment = L10n.tr("Localizable", "detail_view.button.post_comment", fallback: "Post comment") - /// Read - public static let read = L10n.tr("Localizable", "detail_view.button.read", fallback: "Read") + /// %@ minutes + public static func minutes(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.minutes", String(describing: p1), fallback: "%@ minutes") } - public enum ContextMenu { - public enum Button { - /// Detail - public static let detail = L10n.tr("Localizable", "detail_view.context_menu.button.detail", fallback: "Detail") - /// Vote down - public static let voteDown = L10n.tr("Localizable", "detail_view.context_menu.button.vote_down", fallback: "Vote down") - /// Vote up - public static let voteUp = L10n.tr("Localizable", "detail_view.context_menu.button.vote_up", fallback: "Vote up") - /// Withdraw vote - public static let withdrawVote = L10n.tr("Localizable", "detail_view.context_menu.button.withdraw_vote", fallback: "Withdraw vote") - } + /// %@ pages + public static func pages(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.pages", String(describing: p1), fallback: "%@ pages") } - public enum DescriptionSection { - public enum Description { - /// Times - public static let favorited = L10n.tr("Localizable", "detail_view.description_section.description.favorited", fallback: "Times") - /// Pages - public static let pageCount = L10n.tr("Localizable", "detail_view.description_section.description.page_count", fallback: "Pages") - } - public enum Title { - /// Favorited - public static let favorited = L10n.tr("Localizable", "detail_view.description_section.title.favorited", fallback: "Favorited") - /// File Size - public static let fileSize = L10n.tr("Localizable", "detail_view.description_section.title.file_size", fallback: "File Size") - /// Language - public static let language = L10n.tr("Localizable", "detail_view.description_section.title.language", fallback: "Language") - /// Page Count - public static let pageCount = L10n.tr("Localizable", "detail_view.description_section.title.page_count", fallback: "Page Count") - /// %@ Ratings - public static func ratings(_ p1: Any) -> String { - return L10n.tr("Localizable", "detail_view.description_section.title.ratings", String(describing: p1), fallback: "%@ Ratings") - } - } + /// %@ records + public static func records(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.records", String(describing: p1), fallback: "%@ records") } - public enum Dialog { - public enum Button { - /// Redownload - public static let redownload = L10n.tr("Localizable", "detail_view.dialog.button.redownload", fallback: "Redownload") - /// Repair - public static let repair = L10n.tr("Localizable", "detail_view.dialog.button.repair", fallback: "Repair") - /// Update - public static let update = L10n.tr("Localizable", "detail_view.dialog.button.update", fallback: "Update") - } - public enum Message { - /// This will remove the downloaded gallery from this device. - public static let deleteDownloadedGallery = L10n.tr("Localizable", "detail_view.dialog.message.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") - /// Start a fresh download for this gallery now? - public static let redownloadGallery = L10n.tr("Localizable", "detail_view.dialog.message.redownload_gallery", fallback: "Start a fresh download for this gallery now?") - /// Repair the offline files for this gallery now? - public static let repairDownload = L10n.tr("Localizable", "detail_view.dialog.message.repair_download", fallback: "Repair the offline files for this gallery now?") - /// Update this gallery to the newest online version now? - public static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.message.update_download", fallback: "Update this gallery to the newest online version now?") - } - public enum Title { - /// Delete Download? - public static let deleteDownload = L10n.tr("Localizable", "detail_view.dialog.title.delete_download", fallback: "Delete Download?") - /// Redownload Gallery? - public static let redownloadGallery = L10n.tr("Localizable", "detail_view.dialog.title.redownload_gallery", fallback: "Redownload Gallery?") - /// Repair Download? - public static let repairDownload = L10n.tr("Localizable", "detail_view.dialog.title.repair_download", fallback: "Repair Download?") - /// Update Download? - public static let updateDownload = L10n.tr("Localizable", "detail_view.dialog.title.update_download", fallback: "Update Download?") - } + /// %@ second + public static func second(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.second", String(describing: p1), fallback: "%@ second") } - public enum Menu { - public enum Button { - /// Create Default Folder - public static let createDefaultFolder = L10n.tr("Localizable", "detail_view.menu.button.create_default_folder", fallback: "Create Default Folder") - /// Manage Folders - public static let manageFolders = L10n.tr("Localizable", "detail_view.menu.button.manage_folders", fallback: "Manage Folders") - } - public enum Text { - /// No folders yet - public static let noFolders = L10n.tr("Localizable", "detail_view.menu.text.no_folders", fallback: "No folders yet") - } + /// %@ seconds + public static func seconds(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.seconds", String(describing: p1), fallback: "%@ seconds") } - public enum OfflineNotice { - /// Couldn't refresh online details. Showing saved details instead. - public static let savedDetails = L10n.tr("Localizable", "detail_view.offline_notice.saved_details", fallback: "Couldn't refresh online details. Showing saved details instead.") + /// %@ stars + public static func stars(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.stars", String(describing: p1), fallback: "%@ stars") } - public enum Section { - public enum Title { - /// Comments - public static let comments = L10n.tr("Localizable", "detail_view.section.title.comments", fallback: "Comments") - /// Previews - public static let previews = L10n.tr("Localizable", "detail_view.section.title.previews", fallback: "Previews") - } + /// %@ times + public static func times(_ p1: Any) -> String { + return L10n.tr("Localizable", "common.times", String(describing: p1), fallback: "%@ times") } - public enum ToolbarItem { - public enum Button { - /// Archives - public static let archives = L10n.tr("Localizable", "detail_view.toolbar_item.button.archives", fallback: "Archives") - /// Share - public static let share = L10n.tr("Localizable", "detail_view.toolbar_item.button.share", fallback: "Share") - /// Torrents - public static let torrents = L10n.tr("Localizable", "detail_view.toolbar_item.button.torrents", fallback: "Torrents") - } + } + public enum ConfirmationDialog { + /// Clear + public static let clear = L10n.tr("Localizable", "confirmation_dialog.clear", fallback: "Clear") + /// Are you sure to clear? + public static let clearDescription = L10n.tr("Localizable", "confirmation_dialog.clear_description", fallback: "Are you sure to clear?") + /// Delete + public static let delete = L10n.tr("Localizable", "confirmation_dialog.delete", fallback: "Delete") + /// Are you sure to delete this item? + public static let deleteDescription = L10n.tr("Localizable", "confirmation_dialog.delete_description", fallback: "Are you sure to delete this item?") + /// Drop the database + public static let dropDatabase = L10n.tr("Localizable", "confirmation_dialog.drop_database", fallback: "Drop the database") + /// You will lose all your data in this app. + /// Are you sure to drop the database? + public static let dropDatabaseDescription = L10n.tr("Localizable", "confirmation_dialog.drop_database_description", fallback: "You will lose all your data in this app.\nAre you sure to drop the database?") + /// Logout + public static let logout = L10n.tr("Localizable", "confirmation_dialog.logout", fallback: "Logout") + /// Are you sure to logout? + public static let logoutDescription = L10n.tr("Localizable", "confirmation_dialog.logout_description", fallback: "Are you sure to logout?") + /// Remove + public static let remove = L10n.tr("Localizable", "confirmation_dialog.remove", fallback: "Remove") + /// Are you sure to remove your custom translations? + public static let removeCustomTranslations = L10n.tr("Localizable", "confirmation_dialog.remove_custom_translations", fallback: "Are you sure to remove your custom translations?") + /// Reset + public static let reset = L10n.tr("Localizable", "confirmation_dialog.reset", fallback: "Reset") + /// Are you sure to reset? + public static let resetDescription = L10n.tr("Localizable", "confirmation_dialog.reset_description", fallback: "Are you sure to reset?") + } + public enum CookieValue { + /// Expired + public static let expired = L10n.tr("Localizable", "cookie_value.expired", fallback: "Expired") + /// Rejected + public static let mystery = L10n.tr("Localizable", "cookie_value.mystery", fallback: "Rejected") + /// None + public static let `none` = L10n.tr("Localizable", "cookie_value.none", fallback: "None") + } + public enum DateSeekView { + /// Date + public static let date = L10n.tr("Localizable", "date_seek_view.date", fallback: "Date") + /// Seek to date + public static let dateSeek = L10n.tr("Localizable", "date_seek_view.date_seek", fallback: "Seek to date") + /// Seek to galleries around the selected date. + public static let seekAroundDate = L10n.tr("Localizable", "date_seek_view.seek_around_date", fallback: "Seek to galleries around the selected date.") + /// Newer + public static let seekNewer = L10n.tr("Localizable", "date_seek_view.seek_newer", fallback: "Newer") + /// Older + public static let seekOlder = L10n.tr("Localizable", "date_seek_view.seek_older", fallback: "Older") + } + public enum DetailView { + /// Archives + public static let archives = L10n.tr("Localizable", "detail_view.archives", fallback: "Archives") + /// Comments + public static let comments = L10n.tr("Localizable", "detail_view.comments", fallback: "Comments") + /// Create Default Folder + public static let createDefaultFolder = L10n.tr("Localizable", "detail_view.create_default_folder", fallback: "Create Default Folder") + /// Delete Download? + public static let deleteDownload = L10n.tr("Localizable", "detail_view.delete_download", fallback: "Delete Download?") + /// This will remove the downloaded gallery from this device. + public static let deleteDownloadedGallery = L10n.tr("Localizable", "detail_view.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") + /// Detail + public static let detail = L10n.tr("Localizable", "detail_view.detail", fallback: "Detail") + /// DONE + public static let downloadDone = L10n.tr("Localizable", "detail_view.download_done", fallback: "DONE") + /// GET + public static let downloadGet = L10n.tr("Localizable", "detail_view.download_get", fallback: "GET") + /// LOG IN + public static let downloadLogin = L10n.tr("Localizable", "detail_view.download_login", fallback: "LOG IN") + /// REPAIR + public static let downloadRepair = L10n.tr("Localizable", "detail_view.download_repair", fallback: "REPAIR") + /// RETRY + public static let downloadRetry = L10n.tr("Localizable", "detail_view.download_retry", fallback: "RETRY") + /// UPDATE + public static let downloadUpdate = L10n.tr("Localizable", "detail_view.download_update", fallback: "UPDATE") + /// WAIT + public static let downloadWait = L10n.tr("Localizable", "detail_view.download_wait", fallback: "WAIT") + /// Favorited + public static let favorited = L10n.tr("Localizable", "detail_view.favorited", fallback: "Favorited") + /// Times + public static let favoritedUnit = L10n.tr("Localizable", "detail_view.favorited_unit", fallback: "Times") + /// File Size + public static let fileSize = L10n.tr("Localizable", "detail_view.file_size", fallback: "File Size") + /// Give a Rating + public static let giveARating = L10n.tr("Localizable", "detail_view.give_a_rating", fallback: "Give a Rating") + /// Language + public static let language = L10n.tr("Localizable", "detail_view.language", fallback: "Language") + /// Manage Folders + public static let manageFolders = L10n.tr("Localizable", "detail_view.manage_folders", fallback: "Manage Folders") + /// No folders yet + public static let noFolders = L10n.tr("Localizable", "detail_view.no_folders", fallback: "No folders yet") + /// Page Count + public static let pageCount = L10n.tr("Localizable", "detail_view.page_count", fallback: "Page Count") + /// Pages + public static let pageCountUnit = L10n.tr("Localizable", "detail_view.page_count_unit", fallback: "Pages") + /// Post comment + public static let postComment = L10n.tr("Localizable", "detail_view.post_comment", fallback: "Post comment") + /// Previews + public static let previews = L10n.tr("Localizable", "detail_view.previews", fallback: "Previews") + /// %@ Ratings + public static func ratings(_ p1: Any) -> String { + return L10n.tr("Localizable", "detail_view.ratings", String(describing: p1), fallback: "%@ Ratings") + } + /// Read + public static let read = L10n.tr("Localizable", "detail_view.read", fallback: "Read") + /// Redownload + public static let redownload = L10n.tr("Localizable", "detail_view.redownload", fallback: "Redownload") + /// Redownload Gallery? + public static let redownloadGallery = L10n.tr("Localizable", "detail_view.redownload_gallery", fallback: "Redownload Gallery?") + /// Start a fresh download for this gallery now? + public static let redownloadGalleryDescription = L10n.tr("Localizable", "detail_view.redownload_gallery_description", fallback: "Start a fresh download for this gallery now?") + /// Repair + public static let repair = L10n.tr("Localizable", "detail_view.repair", fallback: "Repair") + /// Repair Download? + public static let repairDownload = L10n.tr("Localizable", "detail_view.repair_download", fallback: "Repair Download?") + /// Repair the offline files for this gallery now? + public static let repairDownloadDescription = L10n.tr("Localizable", "detail_view.repair_download_description", fallback: "Repair the offline files for this gallery now?") + /// Couldn't refresh online details. Showing saved details instead. + public static let savedDetails = L10n.tr("Localizable", "detail_view.saved_details", fallback: "Couldn't refresh online details. Showing saved details instead.") + /// Share + public static let share = L10n.tr("Localizable", "detail_view.share", fallback: "Share") + /// Similar Gallery + public static let similarGallery = L10n.tr("Localizable", "detail_view.similar_gallery", fallback: "Similar Gallery") + /// Torrents + public static let torrents = L10n.tr("Localizable", "detail_view.torrents", fallback: "Torrents") + /// Update + public static let update = L10n.tr("Localizable", "detail_view.update", fallback: "Update") + /// Update Download? + public static let updateDownload = L10n.tr("Localizable", "detail_view.update_download", fallback: "Update Download?") + /// Update this gallery to the newest online version now? + public static let updateDownloadDescription = L10n.tr("Localizable", "detail_view.update_download_description", fallback: "Update this gallery to the newest online version now?") + /// Vote down + public static let voteDown = L10n.tr("Localizable", "detail_view.vote_down", fallback: "Vote down") + /// Vote up + public static let voteUp = L10n.tr("Localizable", "detail_view.vote_up", fallback: "Vote up") + /// Withdraw vote + public static let withdrawVote = L10n.tr("Localizable", "detail_view.withdraw_vote", fallback: "Withdraw vote") + public enum Accessibility { + /// Download + public static let download = L10n.tr("Localizable", "detail_view.accessibility.download", fallback: "Download") + /// Delete downloaded gallery + public static let downloaded = L10n.tr("Localizable", "detail_view.accessibility.downloaded", fallback: "Delete downloaded gallery") + /// Downloading %d of %d + public static func downloading(_ p1: Int, _ p2: Int) -> String { + return L10n.tr("Localizable", "detail_view.accessibility.downloading", p1, p2, fallback: "Downloading %d of %d") + } + /// Log in to download + public static let login = L10n.tr("Localizable", "detail_view.accessibility.login", fallback: "Log in to download") + /// Retry download. %d of %d pages are already available. + public static func partial(_ p1: Int, _ p2: Int) -> String { + return L10n.tr("Localizable", "detail_view.accessibility.partial", p1, p2, fallback: "Retry download. %d of %d pages are already available.") + } + /// Pause download + public static let pauseAction = L10n.tr("Localizable", "detail_view.accessibility.pause_action", fallback: "Pause download") + /// Resume download. Paused at %d of %d + public static func paused(_ p1: Int, _ p2: Int) -> String { + return L10n.tr("Localizable", "detail_view.accessibility.paused", p1, p2, fallback: "Resume download. Paused at %d of %d") + } + /// Preparing download + public static let preparing = L10n.tr("Localizable", "detail_view.accessibility.preparing", fallback: "Preparing download") + /// Queued + public static let queued = L10n.tr("Localizable", "detail_view.accessibility.queued", fallback: "Queued") + /// Repair download + public static let repair = L10n.tr("Localizable", "detail_view.accessibility.repair", fallback: "Repair download") + /// Retry download + public static let retry = L10n.tr("Localizable", "detail_view.accessibility.retry", fallback: "Retry download") + /// Update download + public static let update = L10n.tr("Localizable", "detail_view.accessibility.update", fallback: "Update download") } } + public enum DisplayMode { + /// Compact + public static let compact = L10n.tr("Localizable", "display_mode.compact", fallback: "Compact") + /// Extended + public static let extended = L10n.tr("Localizable", "display_mode.extended", fallback: "Extended") + /// Minimal + public static let minimal = L10n.tr("Localizable", "display_mode.minimal", fallback: "Minimal") + /// Minimal+ + public static let minimalPlus = L10n.tr("Localizable", "display_mode.minimalPlus", fallback: "Minimal+") + /// Thumbnail + public static let thumbnail = L10n.tr("Localizable", "display_mode.thumbnail", fallback: "Thumbnail") + } + public enum DownloadBadge { + /// Downloaded + public static let downloaded = L10n.tr("Localizable", "download_badge.downloaded", fallback: "Downloaded") + /// Downloading + public static let downloading = L10n.tr("Localizable", "download_badge.downloading", fallback: "Downloading") + /// Needs Attention + public static let needsAttention = L10n.tr("Localizable", "download_badge.needs_attention", fallback: "Needs Attention") + /// Needs Repair + public static let needsRepair = L10n.tr("Localizable", "download_badge.needs_repair", fallback: "Needs Repair") + /// Paused + public static let paused = L10n.tr("Localizable", "download_badge.paused", fallback: "Paused") + /// %d/%d + public static func progress(_ p1: Int, _ p2: Int) -> String { + return L10n.tr("Localizable", "download_badge.progress", p1, p2, fallback: "%d/%d") + } + /// Queued + public static let queued = L10n.tr("Localizable", "download_badge.queued", fallback: "Queued") + /// Update Available + public static let updateAvailable = L10n.tr("Localizable", "download_badge.update_available", fallback: "Update Available") + } + public enum DownloadFolderFilter { + /// All + public static let all = L10n.tr("Localizable", "download_folder_filter.all", fallback: "All") + } + public enum DownloadInspectorView { + /// Actions + public static let actions = L10n.tr("Localizable", "download_inspector_view.actions", fallback: "Actions") + /// Download Status + public static let downloadStatus = L10n.tr("Localizable", "download_inspector_view.download_status", fallback: "Download Status") + /// Downloaded + public static let downloaded = L10n.tr("Localizable", "download_inspector_view.downloaded", fallback: "Downloaded") + /// Failed + public static let failed = L10n.tr("Localizable", "download_inspector_view.failed", fallback: "Failed") + /// Image data could not be validated. + public static let imageDataUnavailable = L10n.tr("Localizable", "download_inspector_view.image_data_unavailable", fallback: "Image data could not be validated.") + /// Image data is valid + public static let imageDataValid = L10n.tr("Localizable", "download_inspector_view.image_data_valid", fallback: "Image data is valid") + /// No pages + public static let `none` = L10n.tr("Localizable", "download_inspector_view.none", fallback: "No pages") + /// Pages + public static let pages = L10n.tr("Localizable", "download_inspector_view.pages", fallback: "Pages") + /// Pending + public static let pending = L10n.tr("Localizable", "download_inspector_view.pending", fallback: "Pending") + /// Retry Failed Pages + public static let retryFailedPages = L10n.tr("Localizable", "download_inspector_view.retry_failed_pages", fallback: "Retry Failed Pages") + /// Tap to retry this page + public static let tapToRetry = L10n.tr("Localizable", "download_inspector_view.tap_to_retry", fallback: "Tap to retry this page") + /// Page %d + public static func title(_ p1: Int) -> String { + return L10n.tr("Localizable", "download_inspector_view.title", p1, fallback: "Page %d") + } + /// Update Download + public static let updateDownload = L10n.tr("Localizable", "download_inspector_view.update_download", fallback: "Update Download") + /// Validating Image Data... + public static let validatingImageData = L10n.tr("Localizable", "download_inspector_view.validating_image_data", fallback: "Validating Image Data...") + } public enum DownloadSettingView { + /// Allow cellular downloads + public static let allowCellularDownloads = L10n.tr("Localizable", "download_setting_view.allow_cellular_downloads", fallback: "Allow cellular downloads") + /// Concurrent image downloads + public static let concurrentImageDownloads = L10n.tr("Localizable", "download_setting_view.concurrent_image_downloads", fallback: "Concurrent image downloads") + /// Download Queue + public static let downloadQueue = L10n.tr("Localizable", "download_setting_view.download_queue", fallback: "Download Queue") + /// Network + public static let network = L10n.tr("Localizable", "download_setting_view.network", fallback: "Network") + /// Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder. + public static let networkDescription = L10n.tr("Localizable", "download_setting_view.network_description", fallback: "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder.") + /// Retry failed pages automatically + public static let retryFailedPagesAutomatically = L10n.tr("Localizable", "download_setting_view.retry_failed_pages_automatically", fallback: "Retry failed pages automatically") /// Download public static let title = L10n.tr("Localizable", "download_setting_view.title", fallback: "Download") - public enum Footer { - /// Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder. - public static let network = L10n.tr("Localizable", "download_setting_view.footer.network", fallback: "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder.") - } - public enum Section { - public enum Title { - /// Download Queue - public static let downloadQueue = L10n.tr("Localizable", "download_setting_view.section.title.download_queue", fallback: "Download Queue") - /// Network - public static let network = L10n.tr("Localizable", "download_setting_view.section.title.network", fallback: "Network") - } - } - public enum Title { - /// Allow cellular downloads - public static let allowCellularDownloads = L10n.tr("Localizable", "download_setting_view.title.allow_cellular_downloads", fallback: "Allow cellular downloads") - /// Concurrent image downloads - public static let concurrentImageDownloads = L10n.tr("Localizable", "download_setting_view.title.concurrent_image_downloads", fallback: "Concurrent image downloads") - /// Retry failed pages automatically - public static let retryFailedPagesAutomatically = L10n.tr("Localizable", "download_setting_view.title.retry_failed_pages_automatically", fallback: "Retry failed pages automatically") - } } public enum DownloadStore { - public enum Error { - /// Asset file is unreadable: %@ - public static func assetUnreadable(_ p1: Any) -> String { - return L10n.tr("Localizable", "download_store.error.asset_unreadable", String(describing: p1), fallback: "Asset file is unreadable: %@") - } - /// The download is currently active. - public static let downloadBusy = L10n.tr("Localizable", "download_store.error.download_busy", fallback: "The download is currently active.") - /// A folder with this name already exists. - public static let folderAlreadyExists = L10n.tr("Localizable", "download_store.error.folder_already_exists", fallback: "A folder with this name already exists.") - /// The folder contains an active download. - public static let folderBusyDownloading = L10n.tr("Localizable", "download_store.error.folder_busy_downloading", fallback: "The folder contains an active download.") - /// The folder name is invalid. - public static let invalidFolderName = L10n.tr("Localizable", "download_store.error.invalid_folder_name", fallback: "The folder name is invalid.") - } - public enum Validation { - /// Cover image data is corrupted. - public static let coverImageCorrupted = L10n.tr("Localizable", "download_store.validation.cover_image_corrupted", fallback: "Cover image data is corrupted.") - /// Cover image is missing. - public static let coverImageMissing = L10n.tr("Localizable", "download_store.validation.cover_image_missing", fallback: "Cover image is missing.") - /// Download folder is missing. - public static let downloadFolderMissing = L10n.tr("Localizable", "download_store.validation.download_folder_missing", fallback: "Download folder is missing.") - /// Download folder could not be resolved. - public static let downloadFolderUnresolved = L10n.tr("Localizable", "download_store.validation.download_folder_unresolved", fallback: "Download folder could not be resolved.") - /// Downloaded pages are incomplete. - public static let downloadedPagesIncomplete = L10n.tr("Localizable", "download_store.validation.downloaded_pages_incomplete", fallback: "Downloaded pages are incomplete.") - /// Manifest file is corrupted. - public static let manifestCorrupted = L10n.tr("Localizable", "download_store.validation.manifest_corrupted", fallback: "Manifest file is corrupted.") - /// Manifest file is missing. - public static let manifestMissing = L10n.tr("Localizable", "download_store.validation.manifest_missing", fallback: "Manifest file is missing.") - /// Page %d image data is corrupted. - public static func pageImageCorrupted(_ p1: Int) -> String { - return L10n.tr("Localizable", "download_store.validation.page_image_corrupted", p1, fallback: "Page %d image data is corrupted.") - } - /// Page %d is missing. - public static func pageMissing(_ p1: Int) -> String { - return L10n.tr("Localizable", "download_store.validation.page_missing", p1, fallback: "Page %d is missing.") - } + /// Asset file is unreadable: %@ + public static func assetUnreadable(_ p1: Any) -> String { + return L10n.tr("Localizable", "download_store.asset_unreadable", String(describing: p1), fallback: "Asset file is unreadable: %@") + } + /// Cover image data is corrupted. + public static let coverImageCorrupted = L10n.tr("Localizable", "download_store.cover_image_corrupted", fallback: "Cover image data is corrupted.") + /// Cover image is missing. + public static let coverImageMissing = L10n.tr("Localizable", "download_store.cover_image_missing", fallback: "Cover image is missing.") + /// The download is currently active. + public static let downloadBusy = L10n.tr("Localizable", "download_store.download_busy", fallback: "The download is currently active.") + /// Download folder is missing. + public static let downloadFolderMissing = L10n.tr("Localizable", "download_store.download_folder_missing", fallback: "Download folder is missing.") + /// Download folder could not be resolved. + public static let downloadFolderUnresolved = L10n.tr("Localizable", "download_store.download_folder_unresolved", fallback: "Download folder could not be resolved.") + /// Downloaded pages are incomplete. + public static let downloadedPagesIncomplete = L10n.tr("Localizable", "download_store.downloaded_pages_incomplete", fallback: "Downloaded pages are incomplete.") + /// A folder with this name already exists. + public static let folderAlreadyExists = L10n.tr("Localizable", "download_store.folder_already_exists", fallback: "A folder with this name already exists.") + /// The folder contains an active download. + public static let folderBusyDownloading = L10n.tr("Localizable", "download_store.folder_busy_downloading", fallback: "The folder contains an active download.") + /// The folder name is invalid. + public static let invalidFolderName = L10n.tr("Localizable", "download_store.invalid_folder_name", fallback: "The folder name is invalid.") + /// Manifest file is corrupted. + public static let manifestCorrupted = L10n.tr("Localizable", "download_store.manifest_corrupted", fallback: "Manifest file is corrupted.") + /// Manifest file is missing. + public static let manifestMissing = L10n.tr("Localizable", "download_store.manifest_missing", fallback: "Manifest file is missing.") + /// Page %d image data is corrupted. + public static func pageImageCorrupted(_ p1: Int) -> String { + return L10n.tr("Localizable", "download_store.page_image_corrupted", p1, fallback: "Page %d image data is corrupted.") + } + /// Page %d is missing. + public static func pageMissing(_ p1: Int) -> String { + return L10n.tr("Localizable", "download_store.page_missing", p1, fallback: "Page %d is missing.") } } public enum DownloadsView { - public enum Button { - /// Clear Filters - public static let clearFilters = L10n.tr("Localizable", "downloads_view.button.clear_filters", fallback: "Clear Filters") - /// Validate Image Data - public static let validateImageData = L10n.tr("Localizable", "downloads_view.button.validate_image_data", fallback: "Validate Image Data") - } - public enum Dialog { - public enum Message { - /// This will cancel the current download and remove it from this device. - public static let deleteActiveDownload = L10n.tr("Localizable", "downloads_view.dialog.message.delete_active_download", fallback: "This will cancel the current download and remove it from this device.") - /// This will remove the downloaded gallery from this device. - public static let deleteDownloadedGallery = L10n.tr("Localizable", "downloads_view.dialog.message.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") - } - public enum Title { - /// Delete Download? - public static let deleteDownload = L10n.tr("Localizable", "downloads_view.dialog.title.delete_download", fallback: "Delete Download?") - } - } - public enum EmptyState { - /// Downloaded galleries will appear here. - public static let downloads = L10n.tr("Localizable", "downloads_view.empty_state.downloads", fallback: "Downloaded galleries will appear here.") - /// No downloads match the current filters. - public static let noMatchingFilters = L10n.tr("Localizable", "downloads_view.empty_state.no_matching_filters", fallback: "No downloads match the current filters.") - } - public enum Inspector { - public enum Button { - /// Retry Failed Pages - public static let retryFailedPages = L10n.tr("Localizable", "downloads_view.inspector.button.retry_failed_pages", fallback: "Retry Failed Pages") - /// Update Download - public static let updateDownload = L10n.tr("Localizable", "downloads_view.inspector.button.update_download", fallback: "Update Download") - /// Validating Image Data... - public static let validatingImageData = L10n.tr("Localizable", "downloads_view.inspector.button.validating_image_data", fallback: "Validating Image Data...") - } - public enum Page { - /// No pages - public static let `none` = L10n.tr("Localizable", "downloads_view.inspector.page.none", fallback: "No pages") - /// Pending - public static let pending = L10n.tr("Localizable", "downloads_view.inspector.page.pending", fallback: "Pending") - /// Tap to retry this page - public static let tapToRetry = L10n.tr("Localizable", "downloads_view.inspector.page.tap_to_retry", fallback: "Tap to retry this page") - /// Page %d - public static func title(_ p1: Int) -> String { - return L10n.tr("Localizable", "downloads_view.inspector.page.title", p1, fallback: "Page %d") - } - } - public enum Section { - /// Actions - public static let actions = L10n.tr("Localizable", "downloads_view.inspector.section.actions", fallback: "Actions") - /// Pages - public static let pages = L10n.tr("Localizable", "downloads_view.inspector.section.pages", fallback: "Pages") - } - public enum Status { - /// Downloaded - public static let downloaded = L10n.tr("Localizable", "downloads_view.inspector.status.downloaded", fallback: "Downloaded") - /// Failed - public static let failed = L10n.tr("Localizable", "downloads_view.inspector.status.failed", fallback: "Failed") - /// Pending - public static let pending = L10n.tr("Localizable", "downloads_view.inspector.status.pending", fallback: "Pending") - } - public enum Title { - /// Download Status - public static let downloadStatus = L10n.tr("Localizable", "downloads_view.inspector.title.download_status", fallback: "Download Status") - } - public enum Toast { - /// Image data could not be validated. - public static let imageDataUnavailable = L10n.tr("Localizable", "downloads_view.inspector.toast.image_data_unavailable", fallback: "Image data could not be validated.") - /// Image data is valid - public static let imageDataValid = L10n.tr("Localizable", "downloads_view.inspector.toast.image_data_valid", fallback: "Image data is valid") - } - } - public enum Menu { - public enum Button { - /// Manage Folders - public static let manageFolders = L10n.tr("Localizable", "downloads_view.menu.button.manage_folders", fallback: "Manage Folders") - /// Move to Folder - public static let moveToFolder = L10n.tr("Localizable", "downloads_view.menu.button.move_to_folder", fallback: "Move to Folder") - } - } - public enum Search { - public enum Prompt { - /// Search downloads - public static let downloads = L10n.tr("Localizable", "downloads_view.search.prompt.downloads", fallback: "Search downloads") - } - } - public enum Swipe { - public enum Button { - /// Move - public static let move = L10n.tr("Localizable", "downloads_view.swipe.button.move", fallback: "Move") - /// Pages - public static let pages = L10n.tr("Localizable", "downloads_view.swipe.button.pages", fallback: "Pages") - /// Pause - public static let pause = L10n.tr("Localizable", "downloads_view.swipe.button.pause", fallback: "Pause") - /// Resume - public static let resume = L10n.tr("Localizable", "downloads_view.swipe.button.resume", fallback: "Resume") - /// Update - public static let update = L10n.tr("Localizable", "downloads_view.swipe.button.update", fallback: "Update") - } - } - public enum Title { - /// Downloads - public static let downloads = L10n.tr("Localizable", "downloads_view.title.downloads", fallback: "Downloads") - } + /// Clear Filters + public static let clearFilters = L10n.tr("Localizable", "downloads_view.clear_filters", fallback: "Clear Filters") + /// This will cancel the current download and remove it from this device. + public static let deleteActiveDownload = L10n.tr("Localizable", "downloads_view.delete_active_download", fallback: "This will cancel the current download and remove it from this device.") + /// Delete Download? + public static let deleteDownload = L10n.tr("Localizable", "downloads_view.delete_download", fallback: "Delete Download?") + /// This will remove the downloaded gallery from this device. + public static let deleteDownloadedGallery = L10n.tr("Localizable", "downloads_view.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") + /// Downloads + public static let downloads = L10n.tr("Localizable", "downloads_view.downloads", fallback: "Downloads") + /// Downloaded galleries will appear here. + public static let emptyDownloads = L10n.tr("Localizable", "downloads_view.empty_downloads", fallback: "Downloaded galleries will appear here.") + /// Manage Folders + public static let manageFolders = L10n.tr("Localizable", "downloads_view.manage_folders", fallback: "Manage Folders") + /// Move + public static let move = L10n.tr("Localizable", "downloads_view.move", fallback: "Move") + /// Move to Folder + public static let moveToFolder = L10n.tr("Localizable", "downloads_view.move_to_folder", fallback: "Move to Folder") + /// No downloads match the current filters. + public static let noMatchingFilters = L10n.tr("Localizable", "downloads_view.no_matching_filters", fallback: "No downloads match the current filters.") + /// Pages + public static let pages = L10n.tr("Localizable", "downloads_view.pages", fallback: "Pages") + /// Pause + public static let pause = L10n.tr("Localizable", "downloads_view.pause", fallback: "Pause") + /// Resume + public static let resume = L10n.tr("Localizable", "downloads_view.resume", fallback: "Resume") + /// Search downloads + public static let searchDownloads = L10n.tr("Localizable", "downloads_view.search_downloads", fallback: "Search downloads") + /// Update + public static let update = L10n.tr("Localizable", "downloads_view.update", fallback: "Update") + /// Validate Image Data + public static let validateImageData = L10n.tr("Localizable", "downloads_view.validate_image_data", fallback: "Validate Image Data") } - public enum EhSettingView { - public enum Button { - /// Create new - public static let createNew = L10n.tr("Localizable", "eh_setting_view.button.create_new", fallback: "Create new") - /// Delete profile - public static let deleteProfile = L10n.tr("Localizable", "eh_setting_view.button.delete_profile", fallback: "Delete profile") - /// Rename - public static let rename = L10n.tr("Localizable", "eh_setting_view.button.rename", fallback: "Rename") - /// Set as default - public static let setAsDefault = L10n.tr("Localizable", "eh_setting_view.button.set_as_default", fallback: "Set as default") - } - public enum Description { - /// The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here. - public static let archiverBehavior = L10n.tr("Localizable", "eh_setting_view.description.archiver_behavior", fallback: "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here.") - /// You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below. - public static func browsingCountry(_ p1: Any) -> String { - return L10n.tr("Localizable", "eh_setting_view.description.browsing_country", String(describing: p1), fallback: "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below.") - } - /// The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes. - public static let coverScaleFactor = L10n.tr("Localizable", "eh_setting_view.description.cover_scale_factor", fallback: "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes.") - /// Which display mode would you like to use on the front and search pages? - public static let displayMode = L10n.tr("Localizable", "eh_setting_view.description.display_mode", fallback: "Which display mode would you like to use on the front and search pages?") - /// If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query. - public static let excludedLanguages = L10n.tr("Localizable", "eh_setting_view.description.excluded_languages", fallback: "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query.") - /// If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query. - public static let excludedUploaders = L10n.tr("Localizable", "eh_setting_view.description.excluded_uploaders", fallback: "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query.") - /// You are currently using **%@ / %@** exclusion slots. - public static func excludedUploadersCount(_ p1: Any, _ p2: Any) -> String { - return L10n.tr("Localizable", "eh_setting_view.description.excluded_uploaders_count", String(describing: p1), String(describing: p2), fallback: "You are currently using **%@ / %@** exclusion slots.") - } - /// Here you can choose and rename your favorite categories. - public static let favoriteCategories = L10n.tr("Localizable", "eh_setting_view.description.favorite_categories", fallback: "Here you can choose and rename your favorite categories.") - /// You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting. - public static let favoritesSortOrder = L10n.tr("Localizable", "eh_setting_view.description.favorites_sort_order", fallback: "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting.") - /// Show the "Your default filters removed XX galleries from this page" readout? - public static let filteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.description.filtered_removal_count", fallback: "Show the \"Your default filters removed XX galleries from this page\" readout?") - /// What categories would you like to show by default on the front page and in searches? - public static let galleryCategory = L10n.tr("Localizable", "eh_setting_view.description.gallery_category", fallback: "What categories would you like to show by default on the front page and in searches?") - /// Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default? - public static let galleryName = L10n.tr("Localizable", "eh_setting_view.description.gallery_name", fallback: "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?") - /// Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000. - public static let imageResolution = L10n.tr("Localizable", "eh_setting_view.description.image_resolution", fallback: "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000.") - /// While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit) - public static let imageSize = L10n.tr("Localizable", "eh_setting_view.description.image_size", fallback: "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)") - /// This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem. - /// If you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work. - public static let ipAddressPort = L10n.tr("Localizable", "eh_setting_view.description.ip_address_port", fallback: "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work.") - /// Some historic UI elements are now disabled by default. You can enable those here. - public static let optionalUIElements = L10n.tr("Localizable", "eh_setting_view.description.optional_UI_elements", fallback: "Some historic UI elements are now disabled by default. You can enable those here.") - /// By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works. - public static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.description.ratings_color", fallback: "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works.") - /// How many results would you like per page for the index/search page and torrent search pages? - /// (Hath Perk: Paging Enlargement Required) - public static let resultCount = L10n.tr("Localizable", "eh_setting_view.description.result_count", fallback: "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)") - /// You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999. - public static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.description.tag_filtering_threshold", fallback: "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999.") - /// Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999. - public static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.description.tag_watching_threshold", fallback: "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999.") - /// You can set a default thumbnail configuration for all galleries you visit. - public static let thumbnailConfiguration = L10n.tr("Localizable", "eh_setting_view.description.thumbnail_configuration", fallback: "You can set a default thumbnail configuration for all galleries you visit.") - /// How would you like the mouse-over thumbnails on the front page to load when using List Mode? - public static let thumbnailLoadTiming = L10n.tr("Localizable", "eh_setting_view.description.thumbnail_load_timing", fallback: "How would you like the mouse-over thumbnails on the front page to load when using List Mode?") - /// Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400. - public static let virtualWidth = L10n.tr("Localizable", "eh_setting_view.description.virtual_width", fallback: "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400.") - } - public enum Promt { - /// RRGGB - public static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.promt.ratings_color", fallback: "RRGGB") - } - public enum Section { - public enum Title { - /// Archiver Settings - public static let archiverSettings = L10n.tr("Localizable", "eh_setting_view.section.title.archiver_settings", fallback: "Archiver Settings") - /// Cover Scaling - public static let coverScaling = L10n.tr("Localizable", "eh_setting_view.section.title.cover_scaling", fallback: "Cover Scaling") - /// Excluded Languages - public static let excludedLanguages = L10n.tr("Localizable", "eh_setting_view.section.title.excluded_languages", fallback: "Excluded Languages") - /// Excluded Uploaders - public static let excludedUploaders = L10n.tr("Localizable", "eh_setting_view.section.title.excluded_uploaders", fallback: "Excluded Uploaders") - /// Favorites - public static let favorites = L10n.tr("Localizable", "eh_setting_view.section.title.favorites", fallback: "Favorites") - /// Show Filtered Removal Count - public static let filteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.section.title.filtered_removal_count", fallback: "Show Filtered Removal Count") - /// Front Page Settings - public static let frontPageSettings = L10n.tr("Localizable", "eh_setting_view.section.title.front_page_settings", fallback: "Front Page Settings") - /// Gallery Comments - public static let galleryComments = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_comments", fallback: "Gallery Comments") - /// Gallery Name Display - public static let galleryNameDisplay = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_name_display", fallback: "Gallery Name Display") - /// Gallery Page Thumbnail Labeling - public static let galleryPageThumbnailLabeling = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_page_thumbnail_labeling", fallback: "Gallery Page Thumbnail Labeling") - /// Gallery Tags - public static let galleryTags = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_tags", fallback: "Gallery Tags") - /// Hath Local Network Host - public static let hathLocalNetworkHost = L10n.tr("Localizable", "eh_setting_view.section.title.hath_local_network_host", fallback: "Hath Local Network Host") - /// Image Load Settings - public static let imageLoadSettings = L10n.tr("Localizable", "eh_setting_view.section.title.image_load_settings", fallback: "Image Load Settings") - /// Image Size Settings - public static let imageSizeSettings = L10n.tr("Localizable", "eh_setting_view.section.title.image_size_settings", fallback: "Image Size Settings") - /// Multi-Page Viewer - public static let multiPageViewer = L10n.tr("Localizable", "eh_setting_view.section.title.multi_page_viewer", fallback: "Multi-Page Viewer") - /// Optional UI Elements - public static let optionalUIElements = L10n.tr("Localizable", "eh_setting_view.section.title.optional_UI_elements", fallback: "Optional UI Elements") - /// Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than "Auto" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year). - public static let originalImages = L10n.tr("Localizable", "eh_setting_view.section.title.original_images", fallback: "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year).") - /// Profile Settings - public static let profileSettings = L10n.tr("Localizable", "eh_setting_view.section.title.profile_settings", fallback: "Profile Settings") - /// Ratings - public static let ratings = L10n.tr("Localizable", "eh_setting_view.section.title.ratings", fallback: "Ratings") - /// Search Result Count - public static let searchResultCount = L10n.tr("Localizable", "eh_setting_view.section.title.search_result_count", fallback: "Search Result Count") - /// Search Range Indicator - public static let showSearchRangeIndicator = L10n.tr("Localizable", "eh_setting_view.section.title.show_search_range_indicator", fallback: "Search Range Indicator") - /// Tag Filtering Threshold - public static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.section.title.tag_filtering_threshold", fallback: "Tag Filtering Threshold") - /// Tag Watching Threshold - public static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.section.title.tag_watching_threshold", fallback: "Tag Watching Threshold") - /// Thumbnail Settings - public static let thumbnailSettings = L10n.tr("Localizable", "eh_setting_view.section.title.thumbnail_settings", fallback: "Thumbnail Settings") - /// Viewport Override - public static let viewportOverride = L10n.tr("Localizable", "eh_setting_view.section.title.viewport_override", fallback: "Viewport Override") - } - } - public enum Title { - /// Archiver behavior - public static let archiverBehavior = L10n.tr("Localizable", "eh_setting_view.title.archiver_behavior", fallback: "Archiver behavior") - /// Browsing country - public static let browsingCountry = L10n.tr("Localizable", "eh_setting_view.title.browsing_country", fallback: "Browsing country") - /// Comments sort order - public static let commentsSortOrder = L10n.tr("Localizable", "eh_setting_view.title.comments_sort_order", fallback: "Comments sort order") - /// Comment votes show timing - public static let commentsVotesShowTiming = L10n.tr("Localizable", "eh_setting_view.title.comments_votes_show_timing", fallback: "Comment votes show timing") - /// Display mode - public static let displayMode = L10n.tr("Localizable", "eh_setting_view.title.display_mode", fallback: "Display mode") - /// Display style - public static let displayStyle = L10n.tr("Localizable", "eh_setting_view.title.display_style", fallback: "Display style") - /// Enable thumbnail selector on gallery screen - public static let enableGalleryThumbnailSelector = L10n.tr("Localizable", "eh_setting_view.title.enable_gallery_thumbnail_selector", fallback: "Enable thumbnail selector on gallery screen") - /// Favorites sort order - public static let favoritesSortOrder = L10n.tr("Localizable", "eh_setting_view.title.favorites_sort_order", fallback: "Favorites sort order") - /// Gallery name - public static let galleryName = L10n.tr("Localizable", "eh_setting_view.title.gallery_name", fallback: "Gallery name") - /// Horizontal - public static let horizontal = L10n.tr("Localizable", "eh_setting_view.title.horizontal", fallback: "Horizontal") - /// %@ settings - public static func hostSettings(_ p1: Any) -> String { - return L10n.tr("Localizable", "eh_setting_view.title.host_settings", String(describing: p1), fallback: "%@ settings") - } - /// Image resolution - public static let imageResolution = L10n.tr("Localizable", "eh_setting_view.title.image_resolution", fallback: "Image resolution") - /// Image size - public static let imageSize = L10n.tr("Localizable", "eh_setting_view.title.image_size", fallback: "Image size") - /// IP address:Port - public static let ipAddressPort = L10n.tr("Localizable", "eh_setting_view.title.ip_address_port", fallback: "IP address:Port") - /// Load images through the Hath network - public static let loadImagesThroughTheHathNetwork = L10n.tr("Localizable", "eh_setting_view.title.load_images_through_the_hath_network", fallback: "Load images through the Hath network") - /// Ratings color - public static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.title.ratings_color", fallback: "Ratings color") - /// Result count - public static let resultCount = L10n.tr("Localizable", "eh_setting_view.title.result_count", fallback: "Result count") - /// Scale factor - public static let scaleFactor = L10n.tr("Localizable", "eh_setting_view.title.scale_factor", fallback: "Scale factor") - /// Selected profile - public static let selectedProfile = L10n.tr("Localizable", "eh_setting_view.title.selected_profile", fallback: "Selected profile") - /// Show filtered removal count - public static let showFilteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.title.show_filtered_removal_count", fallback: "Show filtered removal count") - /// Show label below gallery thumbnails - public static let showLabelBelowGalleryThumbnails = L10n.tr("Localizable", "eh_setting_view.title.show_label_below_gallery_thumbnails", fallback: "Show label below gallery thumbnails") - /// Show search range indicator - public static let showSearchRangeIndicator = L10n.tr("Localizable", "eh_setting_view.title.show_search_range_indicator", fallback: "Show search range indicator") - /// Show thumbnail pane - public static let showThumbnailPane = L10n.tr("Localizable", "eh_setting_view.title.show_thumbnail_pane", fallback: "Show thumbnail pane") - /// Tag Filtering Threshold - public static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.title.tag_filtering_threshold", fallback: "Tag Filtering Threshold") - /// Tag Watching Threshold - public static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.title.tag_watching_threshold", fallback: "Tag Watching Threshold") - /// Tags sort order - public static let tagsSortOrder = L10n.tr("Localizable", "eh_setting_view.title.tags_sort_order", fallback: "Tags sort order") - /// Thumbnail load timing - public static let thumbnailLoadTiming = L10n.tr("Localizable", "eh_setting_view.title.thumbnail_load_timing", fallback: "Thumbnail load timing") - /// Rows - public static let thumbnailRowCount = L10n.tr("Localizable", "eh_setting_view.title.thumbnail_row_count", fallback: "Rows") - /// Size - public static let thumbnailSize = L10n.tr("Localizable", "eh_setting_view.title.thumbnail_size", fallback: "Size") - /// Use Multi-Page Viewer - public static let useMultiPageViewer = L10n.tr("Localizable", "eh_setting_view.title.use_multi_page_viewer", fallback: "Use Multi-Page Viewer") - /// Use original images - public static let useOriginalImages = L10n.tr("Localizable", "eh_setting_view.title.use_original_images", fallback: "Use original images") - /// Vertical - public static let vertical = L10n.tr("Localizable", "eh_setting_view.title.vertical", fallback: "Vertical") - /// Virtual width - public static let virtualWidth = L10n.tr("Localizable", "eh_setting_view.title.virtual_width", fallback: "Virtual width") - } - public enum ToolbarItem { - public enum Button { - /// Done - public static let done = L10n.tr("Localizable", "eh_setting_view.toolbar_item.button.done", fallback: "Done") - } + public enum EhSetting { + public enum ArchiverBehavior { + /// Auto Select Original, Auto Start + public static let autoSelectOriginalAutoStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.auto_select_original_auto_start", fallback: "Auto Select Original, Auto Start") + /// Auto Select Original, Manual Start + public static let autoSelectOriginalManualStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.auto_select_original_manual_start", fallback: "Auto Select Original, Manual Start") + /// Auto Select Resample, Auto Start + public static let autoSelectResampleAutoStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.auto_select_resample_auto_start", fallback: "Auto Select Resample, Auto Start") + /// Auto Select Resample, Manual Start + public static let autoSelectResampleManualStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.auto_select_resample_manual_start", fallback: "Auto Select Resample, Manual Start") + /// Manual Select, Auto Start + public static let manualSelectAutoStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.manual_select_auto_start", fallback: "Manual Select, Auto Start") + /// Manual Select, Manual Start (Default) + public static let manualSelectManualStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.manual_select_manual_start", fallback: "Manual Select, Manual Start (Default)") } } - public enum Enum { - public enum AppIconType { - public enum Value { - /// Default - public static let `default` = L10n.tr("Localizable", "enum.app_icon_type.value.default", fallback: "Default") - /// Developer - public static let developer = L10n.tr("Localizable", "enum.app_icon_type.value.developer", fallback: "Developer") - /// NOT MY PRESIDENT - public static let notMyPresident = L10n.tr("Localizable", "enum.app_icon_type.value.not_my_president", fallback: "NOT MY PRESIDENT") - /// Stand With Ukraine (2022) - public static let standWithUkraine2022 = L10n.tr("Localizable", "enum.app_icon_type.value.stand_with_ukraine_2022", fallback: "Stand With Ukraine (2022)") - /// Ukiyo-e - public static let ukiyoe = L10n.tr("Localizable", "enum.app_icon_type.value.ukiyoe", fallback: "Ukiyo-e") - } - } - public enum ArchiveResolution { - public enum Value { - /// Original - public static let original = L10n.tr("Localizable", "enum.archive_resolution.value.original", fallback: "Original") - } - } - public enum AutoLockPolicy { - public enum Value { - /// Instantly - public static let instantly = L10n.tr("Localizable", "enum.auto_lock_policy.value.instantly", fallback: "Instantly") - /// Never - public static let never = L10n.tr("Localizable", "enum.auto_lock_policy.value.never", fallback: "Never") - } - } - public enum AutoPlayPolicy { - public enum Value { - /// Off - public static let off = L10n.tr("Localizable", "enum.auto_play_policy.value.off", fallback: "Off") - } - } - public enum BanInterval { - public enum Description { - /// and - public static let and = L10n.tr("Localizable", "enum.ban_interval.description.and", fallback: "and") - } - } - public enum BrowsingCountry { - public enum Name { - /// Afghanistan - public static let afghanistan = L10n.tr("Localizable", "enum.browsing_country.name.afghanistan", fallback: "Afghanistan") - /// Aland Islands - public static let alandIslands = L10n.tr("Localizable", "enum.browsing_country.name.aland_islands", fallback: "Aland Islands") - /// Albania - public static let albania = L10n.tr("Localizable", "enum.browsing_country.name.albania", fallback: "Albania") - /// Algeria - public static let algeria = L10n.tr("Localizable", "enum.browsing_country.name.algeria", fallback: "Algeria") - /// American Samoa - public static let americanSamoa = L10n.tr("Localizable", "enum.browsing_country.name.american_samoa", fallback: "American Samoa") - /// Andorra - public static let andorra = L10n.tr("Localizable", "enum.browsing_country.name.andorra", fallback: "Andorra") - /// Angola - public static let angola = L10n.tr("Localizable", "enum.browsing_country.name.angola", fallback: "Angola") - /// Anguilla - public static let anguilla = L10n.tr("Localizable", "enum.browsing_country.name.anguilla", fallback: "Anguilla") - /// Antarctica - public static let antarctica = L10n.tr("Localizable", "enum.browsing_country.name.antarctica", fallback: "Antarctica") - /// Antigua and Barbuda - public static let antiguaAndBarbuda = L10n.tr("Localizable", "enum.browsing_country.name.antigua_and_barbuda", fallback: "Antigua and Barbuda") - /// Argentina - public static let argentina = L10n.tr("Localizable", "enum.browsing_country.name.argentina", fallback: "Argentina") - /// Armenia - public static let armenia = L10n.tr("Localizable", "enum.browsing_country.name.armenia", fallback: "Armenia") - /// Aruba - public static let aruba = L10n.tr("Localizable", "enum.browsing_country.name.aruba", fallback: "Aruba") - /// Asia-Pacific Region - public static let asiaPacificRegion = L10n.tr("Localizable", "enum.browsing_country.name.asia_pacific_region", fallback: "Asia-Pacific Region") - /// Australia - public static let australia = L10n.tr("Localizable", "enum.browsing_country.name.australia", fallback: "Australia") - /// Austria - public static let austria = L10n.tr("Localizable", "enum.browsing_country.name.austria", fallback: "Austria") - /// Auto-Detect - public static let autoDetect = L10n.tr("Localizable", "enum.browsing_country.name.auto_detect", fallback: "Auto-Detect") - /// Azerbaijan - public static let azerbaijan = L10n.tr("Localizable", "enum.browsing_country.name.azerbaijan", fallback: "Azerbaijan") - /// Bahamas - public static let bahamas = L10n.tr("Localizable", "enum.browsing_country.name.bahamas", fallback: "Bahamas") - /// Bahrain - public static let bahrain = L10n.tr("Localizable", "enum.browsing_country.name.bahrain", fallback: "Bahrain") - /// Bangladesh - public static let bangladesh = L10n.tr("Localizable", "enum.browsing_country.name.bangladesh", fallback: "Bangladesh") - /// Barbados - public static let barbados = L10n.tr("Localizable", "enum.browsing_country.name.barbados", fallback: "Barbados") - /// Belarus - public static let belarus = L10n.tr("Localizable", "enum.browsing_country.name.belarus", fallback: "Belarus") - /// Belgium - public static let belgium = L10n.tr("Localizable", "enum.browsing_country.name.belgium", fallback: "Belgium") - /// Belize - public static let belize = L10n.tr("Localizable", "enum.browsing_country.name.belize", fallback: "Belize") - /// Benin - public static let benin = L10n.tr("Localizable", "enum.browsing_country.name.benin", fallback: "Benin") - /// Bermuda - public static let bermuda = L10n.tr("Localizable", "enum.browsing_country.name.bermuda", fallback: "Bermuda") - /// Bhutan - public static let bhutan = L10n.tr("Localizable", "enum.browsing_country.name.bhutan", fallback: "Bhutan") - /// Bolivia - public static let bolivia = L10n.tr("Localizable", "enum.browsing_country.name.bolivia", fallback: "Bolivia") - /// Bonaire Saint Eustatius and Saba - public static let bonaireSaintEustatiusAndSaba = L10n.tr("Localizable", "enum.browsing_country.name.bonaire_saint_eustatius_and_saba", fallback: "Bonaire Saint Eustatius and Saba") - /// Bosnia and Herzegovina - public static let bosniaAndHerzegovina = L10n.tr("Localizable", "enum.browsing_country.name.bosnia_and_herzegovina", fallback: "Bosnia and Herzegovina") - /// Botswana - public static let botswana = L10n.tr("Localizable", "enum.browsing_country.name.botswana", fallback: "Botswana") - /// Bouvet Island - public static let bouvetIsland = L10n.tr("Localizable", "enum.browsing_country.name.bouvet_island", fallback: "Bouvet Island") - /// Brazil - public static let brazil = L10n.tr("Localizable", "enum.browsing_country.name.brazil", fallback: "Brazil") - /// British Indian Ocean Territory - public static let britishIndianOceanTerritory = L10n.tr("Localizable", "enum.browsing_country.name.british_indian_ocean_territory", fallback: "British Indian Ocean Territory") - /// Brunei Darussalam - public static let bruneiDarussalam = L10n.tr("Localizable", "enum.browsing_country.name.brunei_darussalam", fallback: "Brunei Darussalam") - /// Bulgaria - public static let bulgaria = L10n.tr("Localizable", "enum.browsing_country.name.bulgaria", fallback: "Bulgaria") - /// Burkina Faso - public static let burkinaFaso = L10n.tr("Localizable", "enum.browsing_country.name.burkina_faso", fallback: "Burkina Faso") - /// Burundi - public static let burundi = L10n.tr("Localizable", "enum.browsing_country.name.burundi", fallback: "Burundi") - /// Cambodia - public static let cambodia = L10n.tr("Localizable", "enum.browsing_country.name.cambodia", fallback: "Cambodia") - /// Cameroon - public static let cameroon = L10n.tr("Localizable", "enum.browsing_country.name.cameroon", fallback: "Cameroon") - /// Canada - public static let canada = L10n.tr("Localizable", "enum.browsing_country.name.canada", fallback: "Canada") - /// Cape Verde - public static let capeVerde = L10n.tr("Localizable", "enum.browsing_country.name.cape_verde", fallback: "Cape Verde") - /// Cayman Islands - public static let caymanIslands = L10n.tr("Localizable", "enum.browsing_country.name.cayman_islands", fallback: "Cayman Islands") - /// Central African Republic - public static let centralAfricanRepublic = L10n.tr("Localizable", "enum.browsing_country.name.central_african_republic", fallback: "Central African Republic") - /// Chad - public static let chad = L10n.tr("Localizable", "enum.browsing_country.name.chad", fallback: "Chad") - /// Chile - public static let chile = L10n.tr("Localizable", "enum.browsing_country.name.chile", fallback: "Chile") - /// China - public static let china = L10n.tr("Localizable", "enum.browsing_country.name.china", fallback: "China") - /// Christmas Island - public static let christmasIsland = L10n.tr("Localizable", "enum.browsing_country.name.christmas_island", fallback: "Christmas Island") - /// Cocos Islands - public static let cocosIslands = L10n.tr("Localizable", "enum.browsing_country.name.cocos_islands", fallback: "Cocos Islands") - /// Colombia - public static let colombia = L10n.tr("Localizable", "enum.browsing_country.name.colombia", fallback: "Colombia") - /// Comoros - public static let comoros = L10n.tr("Localizable", "enum.browsing_country.name.comoros", fallback: "Comoros") - /// Congo - public static let congo = L10n.tr("Localizable", "enum.browsing_country.name.congo", fallback: "Congo") - /// Cook Islands - public static let cookIslands = L10n.tr("Localizable", "enum.browsing_country.name.cook_islands", fallback: "Cook Islands") - /// Costa Rica - public static let costaRica = L10n.tr("Localizable", "enum.browsing_country.name.costa_rica", fallback: "Costa Rica") - /// Cote D'Ivoire - public static let coteDIvoire = L10n.tr("Localizable", "enum.browsing_country.name.cote_d_ivoire", fallback: "Cote D'Ivoire") - /// Croatia - public static let croatia = L10n.tr("Localizable", "enum.browsing_country.name.croatia", fallback: "Croatia") - /// Cuba - public static let cuba = L10n.tr("Localizable", "enum.browsing_country.name.cuba", fallback: "Cuba") - /// Curacao - public static let curacao = L10n.tr("Localizable", "enum.browsing_country.name.curacao", fallback: "Curacao") - /// Cyprus - public static let cyprus = L10n.tr("Localizable", "enum.browsing_country.name.cyprus", fallback: "Cyprus") - /// Czech Republic - public static let czechRepublic = L10n.tr("Localizable", "enum.browsing_country.name.czech_republic", fallback: "Czech Republic") - /// Denmark - public static let denmark = L10n.tr("Localizable", "enum.browsing_country.name.denmark", fallback: "Denmark") - /// Djibouti - public static let djibouti = L10n.tr("Localizable", "enum.browsing_country.name.djibouti", fallback: "Djibouti") - /// Dominica - public static let dominica = L10n.tr("Localizable", "enum.browsing_country.name.dominica", fallback: "Dominica") - /// Dominican Republic - public static let dominicanRepublic = L10n.tr("Localizable", "enum.browsing_country.name.dominican_republic", fallback: "Dominican Republic") - /// Ecuador - public static let ecuador = L10n.tr("Localizable", "enum.browsing_country.name.ecuador", fallback: "Ecuador") - /// Egypt - public static let egypt = L10n.tr("Localizable", "enum.browsing_country.name.egypt", fallback: "Egypt") - /// El Salvador - public static let elSalvador = L10n.tr("Localizable", "enum.browsing_country.name.el_salvador", fallback: "El Salvador") - /// Equatorial Guinea - public static let equatorialGuinea = L10n.tr("Localizable", "enum.browsing_country.name.equatorial_guinea", fallback: "Equatorial Guinea") - /// Eritrea - public static let eritrea = L10n.tr("Localizable", "enum.browsing_country.name.eritrea", fallback: "Eritrea") - /// Estonia - public static let estonia = L10n.tr("Localizable", "enum.browsing_country.name.estonia", fallback: "Estonia") - /// Ethiopia - public static let ethiopia = L10n.tr("Localizable", "enum.browsing_country.name.ethiopia", fallback: "Ethiopia") - /// Europe - public static let europe = L10n.tr("Localizable", "enum.browsing_country.name.europe", fallback: "Europe") - /// Falkland Islands - public static let falklandIslands = L10n.tr("Localizable", "enum.browsing_country.name.falkland_islands", fallback: "Falkland Islands") - /// Faroe Islands - public static let faroeIslands = L10n.tr("Localizable", "enum.browsing_country.name.faroe_islands", fallback: "Faroe Islands") - /// Fiji - public static let fiji = L10n.tr("Localizable", "enum.browsing_country.name.fiji", fallback: "Fiji") - /// Finland - public static let finland = L10n.tr("Localizable", "enum.browsing_country.name.finland", fallback: "Finland") - /// France - public static let france = L10n.tr("Localizable", "enum.browsing_country.name.france", fallback: "France") - /// French Guiana - public static let frenchGuiana = L10n.tr("Localizable", "enum.browsing_country.name.french_guiana", fallback: "French Guiana") - /// French Polynesia - public static let frenchPolynesia = L10n.tr("Localizable", "enum.browsing_country.name.french_polynesia", fallback: "French Polynesia") - /// French Southern Territories - public static let frenchSouthernTerritories = L10n.tr("Localizable", "enum.browsing_country.name.french_southern_territories", fallback: "French Southern Territories") - /// Gabon - public static let gabon = L10n.tr("Localizable", "enum.browsing_country.name.gabon", fallback: "Gabon") - /// Gambia - public static let gambia = L10n.tr("Localizable", "enum.browsing_country.name.gambia", fallback: "Gambia") - /// Georgia - public static let georgia = L10n.tr("Localizable", "enum.browsing_country.name.georgia", fallback: "Georgia") - /// Germany - public static let germany = L10n.tr("Localizable", "enum.browsing_country.name.germany", fallback: "Germany") - /// Ghana - public static let ghana = L10n.tr("Localizable", "enum.browsing_country.name.ghana", fallback: "Ghana") - /// Gibraltar - public static let gibraltar = L10n.tr("Localizable", "enum.browsing_country.name.gibraltar", fallback: "Gibraltar") - /// Greece - public static let greece = L10n.tr("Localizable", "enum.browsing_country.name.greece", fallback: "Greece") - /// Greenland - public static let greenland = L10n.tr("Localizable", "enum.browsing_country.name.greenland", fallback: "Greenland") - /// Grenada - public static let grenada = L10n.tr("Localizable", "enum.browsing_country.name.grenada", fallback: "Grenada") - /// Guadeloupe - public static let guadeloupe = L10n.tr("Localizable", "enum.browsing_country.name.guadeloupe", fallback: "Guadeloupe") - /// Guam - public static let guam = L10n.tr("Localizable", "enum.browsing_country.name.guam", fallback: "Guam") - /// Guatemala - public static let guatemala = L10n.tr("Localizable", "enum.browsing_country.name.guatemala", fallback: "Guatemala") - /// Guernsey - public static let guernsey = L10n.tr("Localizable", "enum.browsing_country.name.guernsey", fallback: "Guernsey") - /// Guinea - public static let guinea = L10n.tr("Localizable", "enum.browsing_country.name.guinea", fallback: "Guinea") - /// Guinea-Bissau - public static let guineaBissau = L10n.tr("Localizable", "enum.browsing_country.name.guinea_bissau", fallback: "Guinea-Bissau") - /// Guyana - public static let guyana = L10n.tr("Localizable", "enum.browsing_country.name.guyana", fallback: "Guyana") - /// Haiti - public static let haiti = L10n.tr("Localizable", "enum.browsing_country.name.haiti", fallback: "Haiti") - /// Heard Island and McDonald Islands - public static let heardIslandAndMcDonaldIslands = L10n.tr("Localizable", "enum.browsing_country.name.heard_island_and_mc_donald_islands", fallback: "Heard Island and McDonald Islands") - /// Honduras - public static let honduras = L10n.tr("Localizable", "enum.browsing_country.name.honduras", fallback: "Honduras") - /// Hong Kong - public static let hongKong = L10n.tr("Localizable", "enum.browsing_country.name.hong_kong", fallback: "Hong Kong") - /// Hungary - public static let hungary = L10n.tr("Localizable", "enum.browsing_country.name.hungary", fallback: "Hungary") - /// Iceland - public static let iceland = L10n.tr("Localizable", "enum.browsing_country.name.iceland", fallback: "Iceland") - /// India - public static let india = L10n.tr("Localizable", "enum.browsing_country.name.india", fallback: "India") - /// Indonesia - public static let indonesia = L10n.tr("Localizable", "enum.browsing_country.name.indonesia", fallback: "Indonesia") - /// Iran - public static let iran = L10n.tr("Localizable", "enum.browsing_country.name.iran", fallback: "Iran") - /// Iraq - public static let iraq = L10n.tr("Localizable", "enum.browsing_country.name.iraq", fallback: "Iraq") - /// Ireland - public static let ireland = L10n.tr("Localizable", "enum.browsing_country.name.ireland", fallback: "Ireland") - /// Isle of Man - public static let isleOfMan = L10n.tr("Localizable", "enum.browsing_country.name.isle_of_man", fallback: "Isle of Man") - /// Israel - public static let israel = L10n.tr("Localizable", "enum.browsing_country.name.israel", fallback: "Israel") - /// Italy - public static let italy = L10n.tr("Localizable", "enum.browsing_country.name.italy", fallback: "Italy") - /// Jamaica - public static let jamaica = L10n.tr("Localizable", "enum.browsing_country.name.jamaica", fallback: "Jamaica") - /// Japan - public static let japan = L10n.tr("Localizable", "enum.browsing_country.name.japan", fallback: "Japan") - /// Jersey - public static let jersey = L10n.tr("Localizable", "enum.browsing_country.name.jersey", fallback: "Jersey") - /// Jordan - public static let jordan = L10n.tr("Localizable", "enum.browsing_country.name.jordan", fallback: "Jordan") - /// Kazakhstan - public static let kazakhstan = L10n.tr("Localizable", "enum.browsing_country.name.kazakhstan", fallback: "Kazakhstan") - /// Kenya - public static let kenya = L10n.tr("Localizable", "enum.browsing_country.name.kenya", fallback: "Kenya") - /// Kiribati - public static let kiribati = L10n.tr("Localizable", "enum.browsing_country.name.kiribati", fallback: "Kiribati") - /// Kuwait - public static let kuwait = L10n.tr("Localizable", "enum.browsing_country.name.kuwait", fallback: "Kuwait") - /// Kyrgyzstan - public static let kyrgyzstan = L10n.tr("Localizable", "enum.browsing_country.name.kyrgyzstan", fallback: "Kyrgyzstan") - /// Lao People's Democratic Republic - public static let laoPeoplesDemocraticRepublic = L10n.tr("Localizable", "enum.browsing_country.name.lao_peoples_democratic_republic", fallback: "Lao People's Democratic Republic") - /// Latvia - public static let latvia = L10n.tr("Localizable", "enum.browsing_country.name.latvia", fallback: "Latvia") - /// Lebanon - public static let lebanon = L10n.tr("Localizable", "enum.browsing_country.name.lebanon", fallback: "Lebanon") - /// Lesotho - public static let lesotho = L10n.tr("Localizable", "enum.browsing_country.name.lesotho", fallback: "Lesotho") - /// Liberia - public static let liberia = L10n.tr("Localizable", "enum.browsing_country.name.liberia", fallback: "Liberia") - /// Libya - public static let libya = L10n.tr("Localizable", "enum.browsing_country.name.libya", fallback: "Libya") - /// Liechtenstein - public static let liechtenstein = L10n.tr("Localizable", "enum.browsing_country.name.liechtenstein", fallback: "Liechtenstein") - /// Lithuania - public static let lithuania = L10n.tr("Localizable", "enum.browsing_country.name.lithuania", fallback: "Lithuania") - /// Luxembourg - public static let luxembourg = L10n.tr("Localizable", "enum.browsing_country.name.luxembourg", fallback: "Luxembourg") - /// Macau - public static let macau = L10n.tr("Localizable", "enum.browsing_country.name.macau", fallback: "Macau") - /// Macedonia - public static let macedonia = L10n.tr("Localizable", "enum.browsing_country.name.macedonia", fallback: "Macedonia") - /// Madagascar - public static let madagascar = L10n.tr("Localizable", "enum.browsing_country.name.madagascar", fallback: "Madagascar") - /// Malawi - public static let malawi = L10n.tr("Localizable", "enum.browsing_country.name.malawi", fallback: "Malawi") - /// Malaysia - public static let malaysia = L10n.tr("Localizable", "enum.browsing_country.name.malaysia", fallback: "Malaysia") - /// Maldives - public static let maldives = L10n.tr("Localizable", "enum.browsing_country.name.maldives", fallback: "Maldives") - /// Mali - public static let mali = L10n.tr("Localizable", "enum.browsing_country.name.mali", fallback: "Mali") - /// Malta - public static let malta = L10n.tr("Localizable", "enum.browsing_country.name.malta", fallback: "Malta") - /// Marshall Islands - public static let marshallIslands = L10n.tr("Localizable", "enum.browsing_country.name.marshall_islands", fallback: "Marshall Islands") - /// Martinique - public static let martinique = L10n.tr("Localizable", "enum.browsing_country.name.martinique", fallback: "Martinique") - /// Mauritania - public static let mauritania = L10n.tr("Localizable", "enum.browsing_country.name.mauritania", fallback: "Mauritania") - /// Mauritius - public static let mauritius = L10n.tr("Localizable", "enum.browsing_country.name.mauritius", fallback: "Mauritius") - /// Mayotte - public static let mayotte = L10n.tr("Localizable", "enum.browsing_country.name.mayotte", fallback: "Mayotte") - /// Mexico - public static let mexico = L10n.tr("Localizable", "enum.browsing_country.name.mexico", fallback: "Mexico") - /// Micronesia - public static let micronesia = L10n.tr("Localizable", "enum.browsing_country.name.micronesia", fallback: "Micronesia") - /// Moldova - public static let moldova = L10n.tr("Localizable", "enum.browsing_country.name.moldova", fallback: "Moldova") - /// Monaco - public static let monaco = L10n.tr("Localizable", "enum.browsing_country.name.monaco", fallback: "Monaco") - /// Mongolia - public static let mongolia = L10n.tr("Localizable", "enum.browsing_country.name.mongolia", fallback: "Mongolia") - /// Montenegro - public static let montenegro = L10n.tr("Localizable", "enum.browsing_country.name.montenegro", fallback: "Montenegro") - /// Montserrat - public static let montserrat = L10n.tr("Localizable", "enum.browsing_country.name.montserrat", fallback: "Montserrat") - /// Morocco - public static let morocco = L10n.tr("Localizable", "enum.browsing_country.name.morocco", fallback: "Morocco") - /// Mozambique - public static let mozambique = L10n.tr("Localizable", "enum.browsing_country.name.mozambique", fallback: "Mozambique") - /// Myanmar - public static let myanmar = L10n.tr("Localizable", "enum.browsing_country.name.myanmar", fallback: "Myanmar") - /// Namibia - public static let namibia = L10n.tr("Localizable", "enum.browsing_country.name.namibia", fallback: "Namibia") - /// Nauru - public static let nauru = L10n.tr("Localizable", "enum.browsing_country.name.nauru", fallback: "Nauru") - /// Nepal - public static let nepal = L10n.tr("Localizable", "enum.browsing_country.name.nepal", fallback: "Nepal") - /// Netherlands - public static let netherlands = L10n.tr("Localizable", "enum.browsing_country.name.netherlands", fallback: "Netherlands") - /// New Caledonia - public static let newCaledonia = L10n.tr("Localizable", "enum.browsing_country.name.new_caledonia", fallback: "New Caledonia") - /// New Zealand - public static let newZealand = L10n.tr("Localizable", "enum.browsing_country.name.new_zealand", fallback: "New Zealand") - /// Nicaragua - public static let nicaragua = L10n.tr("Localizable", "enum.browsing_country.name.nicaragua", fallback: "Nicaragua") - /// Niger - public static let niger = L10n.tr("Localizable", "enum.browsing_country.name.niger", fallback: "Niger") - /// Nigeria - public static let nigeria = L10n.tr("Localizable", "enum.browsing_country.name.nigeria", fallback: "Nigeria") - /// Niue - public static let niue = L10n.tr("Localizable", "enum.browsing_country.name.niue", fallback: "Niue") - /// Norfolk Island - public static let norfolkIsland = L10n.tr("Localizable", "enum.browsing_country.name.norfolk_island", fallback: "Norfolk Island") - /// North Korea - public static let northKorea = L10n.tr("Localizable", "enum.browsing_country.name.north_korea", fallback: "North Korea") - /// Northern Mariana Islands - public static let northernMarianaIslands = L10n.tr("Localizable", "enum.browsing_country.name.northern_mariana_islands", fallback: "Northern Mariana Islands") - /// Norway - public static let norway = L10n.tr("Localizable", "enum.browsing_country.name.norway", fallback: "Norway") - /// Oman - public static let oman = L10n.tr("Localizable", "enum.browsing_country.name.oman", fallback: "Oman") - /// Pakistan - public static let pakistan = L10n.tr("Localizable", "enum.browsing_country.name.pakistan", fallback: "Pakistan") - /// Palau - public static let palau = L10n.tr("Localizable", "enum.browsing_country.name.palau", fallback: "Palau") - /// Palestinian Territory - public static let palestinianTerritory = L10n.tr("Localizable", "enum.browsing_country.name.palestinian_territory", fallback: "Palestinian Territory") - /// Panama - public static let panama = L10n.tr("Localizable", "enum.browsing_country.name.panama", fallback: "Panama") - /// Papua New Guinea - public static let papuaNewGuinea = L10n.tr("Localizable", "enum.browsing_country.name.papua_new_guinea", fallback: "Papua New Guinea") - /// Paraguay - public static let paraguay = L10n.tr("Localizable", "enum.browsing_country.name.paraguay", fallback: "Paraguay") - /// Peru - public static let peru = L10n.tr("Localizable", "enum.browsing_country.name.peru", fallback: "Peru") - /// Philippines - public static let philippines = L10n.tr("Localizable", "enum.browsing_country.name.philippines", fallback: "Philippines") - /// Pitcairn Islands - public static let pitcairnIslands = L10n.tr("Localizable", "enum.browsing_country.name.pitcairn_islands", fallback: "Pitcairn Islands") - /// Poland - public static let poland = L10n.tr("Localizable", "enum.browsing_country.name.poland", fallback: "Poland") - /// Portugal - public static let portugal = L10n.tr("Localizable", "enum.browsing_country.name.portugal", fallback: "Portugal") - /// Puerto Rico - public static let puertoRico = L10n.tr("Localizable", "enum.browsing_country.name.puerto_rico", fallback: "Puerto Rico") - /// Qatar - public static let qatar = L10n.tr("Localizable", "enum.browsing_country.name.qatar", fallback: "Qatar") - /// Reunion - public static let reunion = L10n.tr("Localizable", "enum.browsing_country.name.reunion", fallback: "Reunion") - /// Romania - public static let romania = L10n.tr("Localizable", "enum.browsing_country.name.romania", fallback: "Romania") - /// Russian Federation - public static let russianFederation = L10n.tr("Localizable", "enum.browsing_country.name.russian_federation", fallback: "Russian Federation") - /// Rwanda - public static let rwanda = L10n.tr("Localizable", "enum.browsing_country.name.rwanda", fallback: "Rwanda") - /// Saint Barthelemy - public static let saintBarthelemy = L10n.tr("Localizable", "enum.browsing_country.name.saint_barthelemy", fallback: "Saint Barthelemy") - /// Saint Helena - public static let saintHelena = L10n.tr("Localizable", "enum.browsing_country.name.saint_helena", fallback: "Saint Helena") - /// Saint Kitts and Nevis - public static let saintKittsAndNevis = L10n.tr("Localizable", "enum.browsing_country.name.saint_kitts_and_nevis", fallback: "Saint Kitts and Nevis") - /// Saint Lucia - public static let saintLucia = L10n.tr("Localizable", "enum.browsing_country.name.saint_lucia", fallback: "Saint Lucia") - /// Saint Martin - public static let saintMartin = L10n.tr("Localizable", "enum.browsing_country.name.saint_martin", fallback: "Saint Martin") - /// Saint Pierre and Miquelon - public static let saintPierreAndMiquelon = L10n.tr("Localizable", "enum.browsing_country.name.saint_pierre_and_miquelon", fallback: "Saint Pierre and Miquelon") - /// Saint Vincent and the Grenadines - public static let saintVincentAndTheGrenadines = L10n.tr("Localizable", "enum.browsing_country.name.saint_vincent_and_the_grenadines", fallback: "Saint Vincent and the Grenadines") - /// Samoa - public static let samoa = L10n.tr("Localizable", "enum.browsing_country.name.samoa", fallback: "Samoa") - /// San Marino - public static let sanMarino = L10n.tr("Localizable", "enum.browsing_country.name.san_marino", fallback: "San Marino") - /// Sao Tome and Principe - public static let saoTomeAndPrincipe = L10n.tr("Localizable", "enum.browsing_country.name.sao_tome_and_principe", fallback: "Sao Tome and Principe") - /// Saudi Arabia - public static let saudiArabia = L10n.tr("Localizable", "enum.browsing_country.name.saudi_arabia", fallback: "Saudi Arabia") - /// Senegal - public static let senegal = L10n.tr("Localizable", "enum.browsing_country.name.senegal", fallback: "Senegal") - /// Serbia - public static let serbia = L10n.tr("Localizable", "enum.browsing_country.name.serbia", fallback: "Serbia") - /// Seychelles - public static let seychelles = L10n.tr("Localizable", "enum.browsing_country.name.seychelles", fallback: "Seychelles") - /// Sierra Leone - public static let sierraLeone = L10n.tr("Localizable", "enum.browsing_country.name.sierra_leone", fallback: "Sierra Leone") - /// Singapore - public static let singapore = L10n.tr("Localizable", "enum.browsing_country.name.singapore", fallback: "Singapore") - /// Sint Maarten - public static let sintMaarten = L10n.tr("Localizable", "enum.browsing_country.name.sint_maarten", fallback: "Sint Maarten") - /// Slovakia - public static let slovakia = L10n.tr("Localizable", "enum.browsing_country.name.slovakia", fallback: "Slovakia") - /// Slovenia - public static let slovenia = L10n.tr("Localizable", "enum.browsing_country.name.slovenia", fallback: "Slovenia") - /// Solomon Islands - public static let solomonIslands = L10n.tr("Localizable", "enum.browsing_country.name.solomon_islands", fallback: "Solomon Islands") - /// Somalia - public static let somalia = L10n.tr("Localizable", "enum.browsing_country.name.somalia", fallback: "Somalia") - /// South Africa - public static let southAfrica = L10n.tr("Localizable", "enum.browsing_country.name.south_africa", fallback: "South Africa") - /// South Georgia and the South Sandwich Islands - public static let southGeorgiaAndTheSouthSandwichIslands = L10n.tr("Localizable", "enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands", fallback: "South Georgia and the South Sandwich Islands") - /// South Korea - public static let southKorea = L10n.tr("Localizable", "enum.browsing_country.name.south_korea", fallback: "South Korea") - /// South Sudan - public static let southSudan = L10n.tr("Localizable", "enum.browsing_country.name.south_sudan", fallback: "South Sudan") - /// Spain - public static let spain = L10n.tr("Localizable", "enum.browsing_country.name.spain", fallback: "Spain") - /// Sri Lanka - public static let sriLanka = L10n.tr("Localizable", "enum.browsing_country.name.sri_lanka", fallback: "Sri Lanka") - /// Sudan - public static let sudan = L10n.tr("Localizable", "enum.browsing_country.name.sudan", fallback: "Sudan") - /// Suriname - public static let suriname = L10n.tr("Localizable", "enum.browsing_country.name.suriname", fallback: "Suriname") - /// Svalbard and Jan Mayen - public static let svalbardAndJanMayen = L10n.tr("Localizable", "enum.browsing_country.name.svalbard_and_jan_mayen", fallback: "Svalbard and Jan Mayen") - /// Swaziland - public static let swaziland = L10n.tr("Localizable", "enum.browsing_country.name.swaziland", fallback: "Swaziland") - /// Sweden - public static let sweden = L10n.tr("Localizable", "enum.browsing_country.name.sweden", fallback: "Sweden") - /// Switzerland - public static let switzerland = L10n.tr("Localizable", "enum.browsing_country.name.switzerland", fallback: "Switzerland") - /// Syrian Arab Republic - public static let syrianArabRepublic = L10n.tr("Localizable", "enum.browsing_country.name.syrian_arab_republic", fallback: "Syrian Arab Republic") - /// Taiwan - public static let taiwan = L10n.tr("Localizable", "enum.browsing_country.name.taiwan", fallback: "Taiwan") - /// Tajikistan - public static let tajikistan = L10n.tr("Localizable", "enum.browsing_country.name.tajikistan", fallback: "Tajikistan") - /// Tanzania - public static let tanzania = L10n.tr("Localizable", "enum.browsing_country.name.tanzania", fallback: "Tanzania") - /// Thailand - public static let thailand = L10n.tr("Localizable", "enum.browsing_country.name.thailand", fallback: "Thailand") - /// The Democratic Republic of the Congo - public static let theDemocraticRepublicOfTheCongo = L10n.tr("Localizable", "enum.browsing_country.name.the_democratic_republic_of_the_congo", fallback: "The Democratic Republic of the Congo") - /// Timor-Leste - public static let timorLeste = L10n.tr("Localizable", "enum.browsing_country.name.timor_leste", fallback: "Timor-Leste") - /// Togo - public static let togo = L10n.tr("Localizable", "enum.browsing_country.name.togo", fallback: "Togo") - /// Tokelau - public static let tokelau = L10n.tr("Localizable", "enum.browsing_country.name.tokelau", fallback: "Tokelau") - /// Tonga - public static let tonga = L10n.tr("Localizable", "enum.browsing_country.name.tonga", fallback: "Tonga") - /// Trinidad and Tobago - public static let trinidadAndTobago = L10n.tr("Localizable", "enum.browsing_country.name.trinidad_and_tobago", fallback: "Trinidad and Tobago") - /// Tunisia - public static let tunisia = L10n.tr("Localizable", "enum.browsing_country.name.tunisia", fallback: "Tunisia") - /// Turkey - public static let turkey = L10n.tr("Localizable", "enum.browsing_country.name.turkey", fallback: "Turkey") - /// Turkmenistan - public static let turkmenistan = L10n.tr("Localizable", "enum.browsing_country.name.turkmenistan", fallback: "Turkmenistan") - /// Turks and Caicos Islands - public static let turksAndCaicosIslands = L10n.tr("Localizable", "enum.browsing_country.name.turks_and_caicos_islands", fallback: "Turks and Caicos Islands") - /// Tuvalu - public static let tuvalu = L10n.tr("Localizable", "enum.browsing_country.name.tuvalu", fallback: "Tuvalu") - /// Uganda - public static let uganda = L10n.tr("Localizable", "enum.browsing_country.name.uganda", fallback: "Uganda") - /// Ukraine - public static let ukraine = L10n.tr("Localizable", "enum.browsing_country.name.ukraine", fallback: "Ukraine") - /// United Arab Emirates - public static let unitedArabEmirates = L10n.tr("Localizable", "enum.browsing_country.name.united_arab_emirates", fallback: "United Arab Emirates") - /// United Kingdom - public static let unitedKingdom = L10n.tr("Localizable", "enum.browsing_country.name.united_kingdom", fallback: "United Kingdom") - /// United States - public static let unitedStates = L10n.tr("Localizable", "enum.browsing_country.name.united_states", fallback: "United States") - /// United States Minor Outlying Islands - public static let unitedStatesMinorOutlyingIslands = L10n.tr("Localizable", "enum.browsing_country.name.united_states_minor_outlying_islands", fallback: "United States Minor Outlying Islands") - /// Uruguay - public static let uruguay = L10n.tr("Localizable", "enum.browsing_country.name.uruguay", fallback: "Uruguay") - /// Uzbekistan - public static let uzbekistan = L10n.tr("Localizable", "enum.browsing_country.name.uzbekistan", fallback: "Uzbekistan") - /// Vanuatu - public static let vanuatu = L10n.tr("Localizable", "enum.browsing_country.name.vanuatu", fallback: "Vanuatu") - /// Vatican City State - public static let vaticanCityState = L10n.tr("Localizable", "enum.browsing_country.name.vatican_city_state", fallback: "Vatican City State") - /// Venezuela - public static let venezuela = L10n.tr("Localizable", "enum.browsing_country.name.venezuela", fallback: "Venezuela") - /// Vietnam - public static let vietnam = L10n.tr("Localizable", "enum.browsing_country.name.vietnam", fallback: "Vietnam") - /// British Virgin Islands - public static let virginIslandsBritish = L10n.tr("Localizable", "enum.browsing_country.name.virgin_islands_british", fallback: "British Virgin Islands") - /// U.S. Virgin Islands - public static let virginIslandsUS = L10n.tr("Localizable", "enum.browsing_country.name.virgin_islands_US", fallback: "U.S. Virgin Islands") - /// Wallis and Futuna - public static let wallisAndFutuna = L10n.tr("Localizable", "enum.browsing_country.name.wallis_and_futuna", fallback: "Wallis and Futuna") - /// Western Sahara - public static let westernSahara = L10n.tr("Localizable", "enum.browsing_country.name.western_sahara", fallback: "Western Sahara") - /// Yemen - public static let yemen = L10n.tr("Localizable", "enum.browsing_country.name.yemen", fallback: "Yemen") - /// Zambia - public static let zambia = L10n.tr("Localizable", "enum.browsing_country.name.zambia", fallback: "Zambia") - /// Zimbabwe - public static let zimbabwe = L10n.tr("Localizable", "enum.browsing_country.name.zimbabwe", fallback: "Zimbabwe") - } - } - public enum Category { - public enum Value { - /// Artist CG - public static let artistCG = L10n.tr("Localizable", "enum.category.value.artist_CG", fallback: "Artist CG") - /// Asian Porn - public static let asianPorn = L10n.tr("Localizable", "enum.category.value.asian_porn", fallback: "Asian Porn") - /// Cosplay - public static let cosplay = L10n.tr("Localizable", "enum.category.value.cosplay", fallback: "Cosplay") - /// Doujinshi - public static let doujinshi = L10n.tr("Localizable", "enum.category.value.doujinshi", fallback: "Doujinshi") - /// Game CG - public static let gameCG = L10n.tr("Localizable", "enum.category.value.game_CG", fallback: "Game CG") - /// Image Set - public static let imageSet = L10n.tr("Localizable", "enum.category.value.image_set", fallback: "Image Set") - /// Manga - public static let manga = L10n.tr("Localizable", "enum.category.value.manga", fallback: "Manga") - /// Misc - public static let misc = L10n.tr("Localizable", "enum.category.value.misc", fallback: "Misc") - /// Non-H - public static let nonH = L10n.tr("Localizable", "enum.category.value.non_h", fallback: "Non-H") - /// Private - public static let `private` = L10n.tr("Localizable", "enum.category.value.private", fallback: "Private") - /// Western - public static let western = L10n.tr("Localizable", "enum.category.value.western", fallback: "Western") - } - } - public enum DownloadFolderFilter { - public enum Title { - /// All - public static let all = L10n.tr("Localizable", "enum.download_folder_filter.title.all", fallback: "All") - } - } - public enum EhSetting { - public enum ArchiverBehavior { - public enum Value { - /// Auto Select Original, Auto Start - public static let autoSelectOriginalAutoStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start", fallback: "Auto Select Original, Auto Start") - /// Auto Select Original, Manual Start - public static let autoSelectOriginalManualStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start", fallback: "Auto Select Original, Manual Start") - /// Auto Select Resample, Auto Start - public static let autoSelectResampleAutoStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start", fallback: "Auto Select Resample, Auto Start") - /// Auto Select Resample, Manual Start - public static let autoSelectResampleManualStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start", fallback: "Auto Select Resample, Manual Start") - /// Manual Select, Auto Start - public static let manualSelectAutoStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.manual_select_auto_start", fallback: "Manual Select, Auto Start") - /// Manual Select, Manual Start (Default) - public static let manualSelectManualStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.manual_select_manual_start", fallback: "Manual Select, Manual Start (Default)") - } - } - public enum CommentsSortOrder { - public enum Value { - /// By highest score - public static let highestScore = L10n.tr("Localizable", "enum.eh_setting.comments_sort_order.value.highest_score", fallback: "By highest score") - /// Oldest comments first - public static let oldest = L10n.tr("Localizable", "enum.eh_setting.comments_sort_order.value.oldest", fallback: "Oldest comments first") - /// Recent comments first - public static let recent = L10n.tr("Localizable", "enum.eh_setting.comments_sort_order.value.recent", fallback: "Recent comments first") - } - } - public enum CommentsVotesShowTiming { - public enum Value { - /// Always - public static let always = L10n.tr("Localizable", "enum.eh_setting.comments_votes_show_timing.value.always", fallback: "Always") - /// On score hover or click - public static let onHoverOrClick = L10n.tr("Localizable", "enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click", fallback: "On score hover or click") - } - } - public enum DisplayMode { - public enum Value { - /// Compact - public static let compact = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.compact", fallback: "Compact") - /// Extended - public static let extended = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.extended", fallback: "Extended") - /// Minimal - public static let minimal = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.minimal", fallback: "Minimal") - /// Minimal+ - public static let minimalPlus = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.minimalPlus", fallback: "Minimal+") - /// Thumbnail - public static let thumbnail = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.thumbnail", fallback: "Thumbnail") - } - } - public enum ExcludedLanguagesCategory { - public enum Value { - /// Original - public static let original = L10n.tr("Localizable", "enum.eh_setting.excluded_languages_category.value.original", fallback: "Original") - /// Rewrite - public static let rewrite = L10n.tr("Localizable", "enum.eh_setting.excluded_languages_category.value.rewrite", fallback: "Rewrite") - /// Translated - public static let translated = L10n.tr("Localizable", "enum.eh_setting.excluded_languages_category.value.translated", fallback: "Translated") - } - } - public enum FavoritesSortOrder { - public enum Value { - /// By favorited time - public static let favoritedTime = L10n.tr("Localizable", "enum.eh_setting.favorites_sort_order.value.favorited_time", fallback: "By favorited time") - /// By last gallery update time - public static let lastUpdateTime = L10n.tr("Localizable", "enum.eh_setting.favorites_sort_order.value.last_update_time", fallback: "By last gallery update time") - } - } - public enum GalleryName { - public enum Value { - /// Default Title - public static let `default` = L10n.tr("Localizable", "enum.eh_setting.gallery_name.value.default", fallback: "Default Title") - /// Japanese Title (if available) - public static let japanese = L10n.tr("Localizable", "enum.eh_setting.gallery_name.value.japanese", fallback: "Japanese Title (if available)") - } - } - public enum GalleryPageNumbering { - public enum Value { - /// None - public static let `none` = L10n.tr("Localizable", "enum.eh_setting.gallery_page_numbering.value.none", fallback: "None") - /// Page Number + Name - public static let pageNumberAndName = L10n.tr("Localizable", "enum.eh_setting.gallery_page_numbering.value.page_number_and_name", fallback: "Page Number + Name") - /// Page Number Only - public static let pageNumberOnly = L10n.tr("Localizable", "enum.eh_setting.gallery_page_numbering.value.page_number_only", fallback: "Page Number Only") - } - } - public enum ImageResolution { - public enum Value { - /// Auto - public static let auto = L10n.tr("Localizable", "enum.eh_setting.image_resolution.value.auto", fallback: "Auto") - } - } - public enum LoadThroughHathSetting { - public enum Description { - /// Recommended. - public static let anyClient = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.any_client", fallback: "Recommended.") - /// Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports. - public static let defaultPortOnly = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.default_port_only", fallback: "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports.") - /// Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only. - public static let legacyNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.legacy_no", fallback: "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only.") - /// Donator only. You will not be able to browse as many pages. Recommended only if having severe problems. - public static let modernNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.modern_no", fallback: "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems.") - } - public enum Value { - /// Any client - public static let anyClient = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.any_client", fallback: "Any client") - /// Default port clients only - public static let defaultPortOnly = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.default_port_only", fallback: "Default port clients only") - /// No [Legacy/HTTP] - public static let legacyNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.legacy_no", fallback: "No [Legacy/HTTP]") - /// No [Modern/HTTPS] - public static let modernNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.modern_no", fallback: "No [Modern/HTTPS]") - } - } - public enum MultiplePageViewerStyle { - public enum Value { - /// Align center, always scale - public static let alignCenterAlwaysScale = L10n.tr("Localizable", "enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale", fallback: "Align center, always scale") - /// Align center, scale if overwidth - public static let alignCenterScaleIfOverWidth = L10n.tr("Localizable", "enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width", fallback: "Align center, scale if overwidth") - /// Align left, scale if overwidth - public static let alignLeftScaleIfOverWidth = L10n.tr("Localizable", "enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width", fallback: "Align left, scale if overwidth") - } - } - public enum TagsSortOrder { - public enum Value { - /// Alphabetical - public static let alphabetical = L10n.tr("Localizable", "enum.eh_setting.tags_sort_order.value.alphabetical", fallback: "Alphabetical") - /// By tag power - public static let tagPower = L10n.tr("Localizable", "enum.eh_setting.tags_sort_order.value.tag_power", fallback: "By tag power") - } - } - public enum ThumbnailLoadTiming { - public enum Description { - /// Pages load faster, but there may be a slight delay before a thumb appears. - public static let onMouseOver = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.description.on_mouse_over", fallback: "Pages load faster, but there may be a slight delay before a thumb appears.") - /// Pages take longer to load, but there is no delay for loading a thumb after the page has loaded. - public static let onPageLoad = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.description.on_page_load", fallback: "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded.") - } - public enum Value { - /// On mouse-over - public static let onMouseOver = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.value.on_mouse_over", fallback: "On mouse-over") - /// On page load - public static let onPageLoad = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.value.on_page_load", fallback: "On page load") - } - } - public enum ThumbnailSize { - public enum Value { - /// Auto - public static let auto = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.auto", fallback: "Auto") - /// Large - public static let large = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.large", fallback: "Large") - /// Normal - public static let normal = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.normal", fallback: "Normal") - /// Small - public static let small = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.small", fallback: "Small") - } - } - } - public enum FilterRange { - public enum Value { - /// Global - public static let global = L10n.tr("Localizable", "enum.filter_range.value.global", fallback: "Global") - /// Search - public static let search = L10n.tr("Localizable", "enum.filter_range.value.search", fallback: "Search") - /// Watched - public static let watched = L10n.tr("Localizable", "enum.filter_range.value.watched", fallback: "Watched") - } - } - public enum GalleryVisibility { - public enum Value { - /// No (%@) - public static func no(_ p1: Any) -> String { - return L10n.tr("Localizable", "enum.gallery_visibility.value.no", String(describing: p1), fallback: "No (%@)") - } - /// Yes - public static let yes = L10n.tr("Localizable", "enum.gallery_visibility.value.yes", fallback: "Yes") - public enum No { - public enum Reason { - /// Expunged - public static let expunged = L10n.tr("Localizable", "enum.gallery_visibility.value.no.reason.expunged", fallback: "Expunged") - } - } - } - } - public enum HomeMiscGridType { - public enum Title { - /// History - public static let history = L10n.tr("Localizable", "enum.home_misc_grid_type.title.history", fallback: "History") - /// Popular - public static let popular = L10n.tr("Localizable", "enum.home_misc_grid_type.title.popular", fallback: "Popular") - /// Watched - public static let watched = L10n.tr("Localizable", "enum.home_misc_grid_type.title.watched", fallback: "Watched") - } - } - public enum Language { - public enum Value { - /// Afrikaans - public static let afrikaans = L10n.tr("Localizable", "enum.language.value.afrikaans", fallback: "Afrikaans") - /// Albanian - public static let albanian = L10n.tr("Localizable", "enum.language.value.albanian", fallback: "Albanian") - /// Arabic - public static let arabic = L10n.tr("Localizable", "enum.language.value.arabic", fallback: "Arabic") - /// Bengali - public static let bengali = L10n.tr("Localizable", "enum.language.value.bengali", fallback: "Bengali") - /// Bosnian - public static let bosnian = L10n.tr("Localizable", "enum.language.value.bosnian", fallback: "Bosnian") - /// Bulgarian - public static let bulgarian = L10n.tr("Localizable", "enum.language.value.bulgarian", fallback: "Bulgarian") - /// Burmese - public static let burmese = L10n.tr("Localizable", "enum.language.value.burmese", fallback: "Burmese") - /// Catalan - public static let catalan = L10n.tr("Localizable", "enum.language.value.catalan", fallback: "Catalan") - /// Cebuano - public static let cebuano = L10n.tr("Localizable", "enum.language.value.cebuano", fallback: "Cebuano") - /// Chinese - public static let chinese = L10n.tr("Localizable", "enum.language.value.chinese", fallback: "Chinese") - /// Croatian - public static let croatian = L10n.tr("Localizable", "enum.language.value.croatian", fallback: "Croatian") - /// Czech - public static let czech = L10n.tr("Localizable", "enum.language.value.czech", fallback: "Czech") - /// Danish - public static let danish = L10n.tr("Localizable", "enum.language.value.danish", fallback: "Danish") - /// Dutch - public static let dutch = L10n.tr("Localizable", "enum.language.value.dutch", fallback: "Dutch") - /// English - public static let english = L10n.tr("Localizable", "enum.language.value.english", fallback: "English") - /// Esperanto - public static let esperanto = L10n.tr("Localizable", "enum.language.value.esperanto", fallback: "Esperanto") - /// Estonian - public static let estonian = L10n.tr("Localizable", "enum.language.value.estonian", fallback: "Estonian") - /// Finnish - public static let finnish = L10n.tr("Localizable", "enum.language.value.finnish", fallback: "Finnish") - /// French - public static let french = L10n.tr("Localizable", "enum.language.value.french", fallback: "French") - /// Georgian - public static let georgian = L10n.tr("Localizable", "enum.language.value.georgian", fallback: "Georgian") - /// German - public static let german = L10n.tr("Localizable", "enum.language.value.german", fallback: "German") - /// Greek - public static let greek = L10n.tr("Localizable", "enum.language.value.greek", fallback: "Greek") - /// Hebrew - public static let hebrew = L10n.tr("Localizable", "enum.language.value.hebrew", fallback: "Hebrew") - /// Hindi - public static let hindi = L10n.tr("Localizable", "enum.language.value.hindi", fallback: "Hindi") - /// Hmong - public static let hmong = L10n.tr("Localizable", "enum.language.value.hmong", fallback: "Hmong") - /// Hungarian - public static let hungarian = L10n.tr("Localizable", "enum.language.value.hungarian", fallback: "Hungarian") - /// Indonesian - public static let indonesian = L10n.tr("Localizable", "enum.language.value.indonesian", fallback: "Indonesian") - /// N/A - public static let invalid = L10n.tr("Localizable", "enum.language.value.invalid", fallback: "N/A") - /// Italian - public static let italian = L10n.tr("Localizable", "enum.language.value.italian", fallback: "Italian") - /// Japanese - public static let japanese = L10n.tr("Localizable", "enum.language.value.japanese", fallback: "Japanese") - /// Kazakh - public static let kazakh = L10n.tr("Localizable", "enum.language.value.kazakh", fallback: "Kazakh") - /// Khmer - public static let khmer = L10n.tr("Localizable", "enum.language.value.khmer", fallback: "Khmer") - /// Korean - public static let korean = L10n.tr("Localizable", "enum.language.value.korean", fallback: "Korean") - /// Kurdish - public static let kurdish = L10n.tr("Localizable", "enum.language.value.kurdish", fallback: "Kurdish") - /// Lao - public static let lao = L10n.tr("Localizable", "enum.language.value.lao", fallback: "Lao") - /// Latin - public static let latin = L10n.tr("Localizable", "enum.language.value.latin", fallback: "Latin") - /// Mongolian - public static let mongolian = L10n.tr("Localizable", "enum.language.value.mongolian", fallback: "Mongolian") - /// Ndebele - public static let ndebele = L10n.tr("Localizable", "enum.language.value.ndebele", fallback: "Ndebele") - /// Nepali - public static let nepali = L10n.tr("Localizable", "enum.language.value.nepali", fallback: "Nepali") - /// Norwegian - public static let norwegian = L10n.tr("Localizable", "enum.language.value.norwegian", fallback: "Norwegian") - /// Oromo - public static let oromo = L10n.tr("Localizable", "enum.language.value.oromo", fallback: "Oromo") - /// Other - public static let other = L10n.tr("Localizable", "enum.language.value.other", fallback: "Other") - /// Pashto - public static let pashto = L10n.tr("Localizable", "enum.language.value.pashto", fallback: "Pashto") - /// Persian - public static let persian = L10n.tr("Localizable", "enum.language.value.persian", fallback: "Persian") - /// Polish - public static let polish = L10n.tr("Localizable", "enum.language.value.polish", fallback: "Polish") - /// Portuguese - public static let portuguese = L10n.tr("Localizable", "enum.language.value.portuguese", fallback: "Portuguese") - /// Punjabi - public static let punjabi = L10n.tr("Localizable", "enum.language.value.punjabi", fallback: "Punjabi") - /// Romanian - public static let romanian = L10n.tr("Localizable", "enum.language.value.romanian", fallback: "Romanian") - /// Russian - public static let russian = L10n.tr("Localizable", "enum.language.value.russian", fallback: "Russian") - /// Sango - public static let sango = L10n.tr("Localizable", "enum.language.value.sango", fallback: "Sango") - /// Serbian - public static let serbian = L10n.tr("Localizable", "enum.language.value.serbian", fallback: "Serbian") - /// Shona - public static let shona = L10n.tr("Localizable", "enum.language.value.shona", fallback: "Shona") - /// Slovak - public static let slovak = L10n.tr("Localizable", "enum.language.value.slovak", fallback: "Slovak") - /// Slovenian - public static let slovenian = L10n.tr("Localizable", "enum.language.value.slovenian", fallback: "Slovenian") - /// Somali - public static let somali = L10n.tr("Localizable", "enum.language.value.somali", fallback: "Somali") - /// Spanish - public static let spanish = L10n.tr("Localizable", "enum.language.value.spanish", fallback: "Spanish") - /// Swahili - public static let swahili = L10n.tr("Localizable", "enum.language.value.swahili", fallback: "Swahili") - /// Swedish - public static let swedish = L10n.tr("Localizable", "enum.language.value.swedish", fallback: "Swedish") - /// Tagalog - public static let tagalog = L10n.tr("Localizable", "enum.language.value.tagalog", fallback: "Tagalog") - /// Thai - public static let thai = L10n.tr("Localizable", "enum.language.value.thai", fallback: "Thai") - /// Tigrinya - public static let tigrinya = L10n.tr("Localizable", "enum.language.value.tigrinya", fallback: "Tigrinya") - /// Turkish - public static let turkish = L10n.tr("Localizable", "enum.language.value.turkish", fallback: "Turkish") - /// Ukrainian - public static let ukrainian = L10n.tr("Localizable", "enum.language.value.ukrainian", fallback: "Ukrainian") - /// Urdu - public static let urdu = L10n.tr("Localizable", "enum.language.value.urdu", fallback: "Urdu") - /// Vietnamese - public static let vietnamese = L10n.tr("Localizable", "enum.language.value.vietnamese", fallback: "Vietnamese") - /// Zulu - public static let zulu = L10n.tr("Localizable", "enum.language.value.zulu", fallback: "Zulu") - } - } - public enum ListDisplayMode { - public enum Value { - /// Detail - public static let detail = L10n.tr("Localizable", "enum.list_display_mode.value.detail", fallback: "Detail") - /// Thumbnail - public static let thumbnail = L10n.tr("Localizable", "enum.list_display_mode.value.thumbnail", fallback: "Thumbnail") - } - } - public enum PreferredColorScheme { - public enum Value { - /// Automatic - public static let automatic = L10n.tr("Localizable", "enum.preferred_color_scheme.value.automatic", fallback: "Automatic") - /// Dark - public static let dark = L10n.tr("Localizable", "enum.preferred_color_scheme.value.dark", fallback: "Dark") - /// Light - public static let light = L10n.tr("Localizable", "enum.preferred_color_scheme.value.light", fallback: "Light") - } - } - public enum ReadingDirection { - public enum Value { - /// Left-to-right - public static let leftToRight = L10n.tr("Localizable", "enum.reading_direction.value.left_to_right", fallback: "Left-to-right") - /// Right-to-left - public static let rightToLeft = L10n.tr("Localizable", "enum.reading_direction.value.right_to_left", fallback: "Right-to-left") - /// Vertical - public static let vertical = L10n.tr("Localizable", "enum.reading_direction.value.vertical", fallback: "Vertical") - } - } - public enum SettingStateRoute { - public enum Value { - /// About - public static let about = L10n.tr("Localizable", "enum.setting_state_route.value.about", fallback: "About") - /// Account - public static let account = L10n.tr("Localizable", "enum.setting_state_route.value.account", fallback: "Account") - /// Appearance - public static let appearance = L10n.tr("Localizable", "enum.setting_state_route.value.appearance", fallback: "Appearance") - /// Download - public static let download = L10n.tr("Localizable", "enum.setting_state_route.value.download", fallback: "Download") - /// General - public static let general = L10n.tr("Localizable", "enum.setting_state_route.value.general", fallback: "General") - /// Laboratory - public static let laboratory = L10n.tr("Localizable", "enum.setting_state_route.value.laboratory", fallback: "Laboratory") - /// Reading - public static let reading = L10n.tr("Localizable", "enum.setting_state_route.value.reading", fallback: "Reading") - } - } - public enum TagNamespace { - public enum Value { - /// Artist - public static let artist = L10n.tr("Localizable", "enum.tag_namespace.value.artist", fallback: "Artist") - /// Character - public static let character = L10n.tr("Localizable", "enum.tag_namespace.value.character", fallback: "Character") - /// Cosplayer - public static let cosplayer = L10n.tr("Localizable", "enum.tag_namespace.value.cosplayer", fallback: "Cosplayer") - /// Female - public static let female = L10n.tr("Localizable", "enum.tag_namespace.value.female", fallback: "Female") - /// Group - public static let group = L10n.tr("Localizable", "enum.tag_namespace.value.group", fallback: "Group") - /// Language - public static let language = L10n.tr("Localizable", "enum.tag_namespace.value.language", fallback: "Language") - /// Male - public static let male = L10n.tr("Localizable", "enum.tag_namespace.value.male", fallback: "Male") - /// Mixed - public static let mixed = L10n.tr("Localizable", "enum.tag_namespace.value.mixed", fallback: "Mixed") - /// Other - public static let other = L10n.tr("Localizable", "enum.tag_namespace.value.other", fallback: "Other") - /// Parody - public static let parody = L10n.tr("Localizable", "enum.tag_namespace.value.parody", fallback: "Parody") - /// Reclass - public static let reclass = L10n.tr("Localizable", "enum.tag_namespace.value.reclass", fallback: "Reclass") - /// Temp - public static let temp = L10n.tr("Localizable", "enum.tag_namespace.value.temp", fallback: "Temp") - } - } - public enum ToplistsType { - public enum Value { - /// All time - public static let allTime = L10n.tr("Localizable", "enum.toplists_type.value.all_time", fallback: "All time") - /// Past month - public static let pastMonth = L10n.tr("Localizable", "enum.toplists_type.value.past_month", fallback: "Past month") - /// Past year - public static let pastYear = L10n.tr("Localizable", "enum.toplists_type.value.past_year", fallback: "Past year") - /// Yesterday - public static let yesterday = L10n.tr("Localizable", "enum.toplists_type.value.yesterday", fallback: "Yesterday") - } - } + public enum EhSettingView { + /// Archiver behavior + public static let archiverBehavior = L10n.tr("Localizable", "eh_setting_view.archiver_behavior", fallback: "Archiver behavior") + /// The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here. + public static let archiverBehaviorDescription = L10n.tr("Localizable", "eh_setting_view.archiver_behavior_description", fallback: "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here.") + /// Archiver Settings + public static let archiverSettings = L10n.tr("Localizable", "eh_setting_view.archiver_settings", fallback: "Archiver Settings") + /// Browsing country + public static let browsingCountry = L10n.tr("Localizable", "eh_setting_view.browsing_country", fallback: "Browsing country") + /// You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below. + public static func browsingCountryDescription(_ p1: Any) -> String { + return L10n.tr("Localizable", "eh_setting_view.browsing_country_description", String(describing: p1), fallback: "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below.") + } + /// Comments sort order + public static let commentsSortOrder = L10n.tr("Localizable", "eh_setting_view.comments_sort_order", fallback: "Comments sort order") + /// Comment votes show timing + public static let commentsVotesShowTiming = L10n.tr("Localizable", "eh_setting_view.comments_votes_show_timing", fallback: "Comment votes show timing") + /// The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes. + public static let coverScaleFactor = L10n.tr("Localizable", "eh_setting_view.cover_scale_factor", fallback: "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes.") + /// Cover Scaling + public static let coverScaling = L10n.tr("Localizable", "eh_setting_view.cover_scaling", fallback: "Cover Scaling") + /// Create new + public static let createNew = L10n.tr("Localizable", "eh_setting_view.create_new", fallback: "Create new") + /// Delete profile + public static let deleteProfile = L10n.tr("Localizable", "eh_setting_view.delete_profile", fallback: "Delete profile") + /// Display mode + public static let displayMode = L10n.tr("Localizable", "eh_setting_view.display_mode", fallback: "Display mode") + /// Which display mode would you like to use on the front and search pages? + public static let displayModeDescription = L10n.tr("Localizable", "eh_setting_view.display_mode_description", fallback: "Which display mode would you like to use on the front and search pages?") + /// Display style + public static let displayStyle = L10n.tr("Localizable", "eh_setting_view.display_style", fallback: "Display style") + /// Done + public static let done = L10n.tr("Localizable", "eh_setting_view.done", fallback: "Done") + /// Enable thumbnail selector on gallery screen + public static let enableGalleryThumbnailSelector = L10n.tr("Localizable", "eh_setting_view.enable_gallery_thumbnail_selector", fallback: "Enable thumbnail selector on gallery screen") + /// Excluded Languages + public static let excludedLanguages = L10n.tr("Localizable", "eh_setting_view.excluded_languages", fallback: "Excluded Languages") + /// If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query. + public static let excludedLanguagesDescription = L10n.tr("Localizable", "eh_setting_view.excluded_languages_description", fallback: "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query.") + /// Excluded Uploaders + public static let excludedUploaders = L10n.tr("Localizable", "eh_setting_view.excluded_uploaders", fallback: "Excluded Uploaders") + /// You are currently using **%@ / %@** exclusion slots. + public static func excludedUploadersCount(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "eh_setting_view.excluded_uploaders_count", String(describing: p1), String(describing: p2), fallback: "You are currently using **%@ / %@** exclusion slots.") + } + /// If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query. + public static let excludedUploadersDescription = L10n.tr("Localizable", "eh_setting_view.excluded_uploaders_description", fallback: "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query.") + /// Here you can choose and rename your favorite categories. + public static let favoriteCategories = L10n.tr("Localizable", "eh_setting_view.favorite_categories", fallback: "Here you can choose and rename your favorite categories.") + /// Favorites + public static let favorites = L10n.tr("Localizable", "eh_setting_view.favorites", fallback: "Favorites") + /// Favorites sort order + public static let favoritesSortOrder = L10n.tr("Localizable", "eh_setting_view.favorites_sort_order", fallback: "Favorites sort order") + /// You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting. + public static let favoritesSortOrderDescription = L10n.tr("Localizable", "eh_setting_view.favorites_sort_order_description", fallback: "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting.") + /// Show the "Your default filters removed XX galleries from this page" readout? + public static let filteredRemovalCountDescription = L10n.tr("Localizable", "eh_setting_view.filtered_removal_count_description", fallback: "Show the \"Your default filters removed XX galleries from this page\" readout?") + /// Front Page Settings + public static let frontPageSettings = L10n.tr("Localizable", "eh_setting_view.front_page_settings", fallback: "Front Page Settings") + /// What categories would you like to show by default on the front page and in searches? + public static let galleryCategory = L10n.tr("Localizable", "eh_setting_view.gallery_category", fallback: "What categories would you like to show by default on the front page and in searches?") + /// Gallery Comments + public static let galleryComments = L10n.tr("Localizable", "eh_setting_view.gallery_comments", fallback: "Gallery Comments") + /// Gallery name + public static let galleryName = L10n.tr("Localizable", "eh_setting_view.gallery_name", fallback: "Gallery name") + /// Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default? + public static let galleryNameDescription = L10n.tr("Localizable", "eh_setting_view.gallery_name_description", fallback: "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?") + /// Gallery Name Display + public static let galleryNameDisplay = L10n.tr("Localizable", "eh_setting_view.gallery_name_display", fallback: "Gallery Name Display") + /// Gallery Page Thumbnail Labeling + public static let galleryPageThumbnailLabeling = L10n.tr("Localizable", "eh_setting_view.gallery_page_thumbnail_labeling", fallback: "Gallery Page Thumbnail Labeling") + /// Gallery Tags + public static let galleryTags = L10n.tr("Localizable", "eh_setting_view.gallery_tags", fallback: "Gallery Tags") + /// Hath Local Network Host + public static let hathLocalNetworkHost = L10n.tr("Localizable", "eh_setting_view.hath_local_network_host", fallback: "Hath Local Network Host") + /// Horizontal + public static let horizontal = L10n.tr("Localizable", "eh_setting_view.horizontal", fallback: "Horizontal") + /// %@ settings + public static func hostSettings(_ p1: Any) -> String { + return L10n.tr("Localizable", "eh_setting_view.host_settings", String(describing: p1), fallback: "%@ settings") + } + /// Image Load Settings + public static let imageLoadSettings = L10n.tr("Localizable", "eh_setting_view.image_load_settings", fallback: "Image Load Settings") + /// Image resolution + public static let imageResolution = L10n.tr("Localizable", "eh_setting_view.image_resolution", fallback: "Image resolution") + /// Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000. + public static let imageResolutionDescription = L10n.tr("Localizable", "eh_setting_view.image_resolution_description", fallback: "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000.") + /// Image size + public static let imageSize = L10n.tr("Localizable", "eh_setting_view.image_size", fallback: "Image size") + /// While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit) + public static let imageSizeDescription = L10n.tr("Localizable", "eh_setting_view.image_size_description", fallback: "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)") + /// Image Size Settings + public static let imageSizeSettings = L10n.tr("Localizable", "eh_setting_view.image_size_settings", fallback: "Image Size Settings") + /// IP address:Port + public static let ipAddressPort = L10n.tr("Localizable", "eh_setting_view.ip_address_port", fallback: "IP address:Port") + /// This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem. + /// If you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work. + public static let ipAddressPortDescription = L10n.tr("Localizable", "eh_setting_view.ip_address_port_description", fallback: "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work.") + /// Load images through the Hath network + public static let loadImagesThroughTheHathNetwork = L10n.tr("Localizable", "eh_setting_view.load_images_through_the_hath_network", fallback: "Load images through the Hath network") + /// Multi-Page Viewer + public static let multiPageViewer = L10n.tr("Localizable", "eh_setting_view.multi_page_viewer", fallback: "Multi-Page Viewer") + /// Optional UI Elements + public static let optionalUIElements = L10n.tr("Localizable", "eh_setting_view.optional_UI_elements", fallback: "Optional UI Elements") + /// Some historic UI elements are now disabled by default. You can enable those here. + public static let optionalUIElementsDescription = L10n.tr("Localizable", "eh_setting_view.optional_UI_elements_description", fallback: "Some historic UI elements are now disabled by default. You can enable those here.") + /// Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than "Auto" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year). + public static let originalImages = L10n.tr("Localizable", "eh_setting_view.original_images", fallback: "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year).") + /// Profile Settings + public static let profileSettings = L10n.tr("Localizable", "eh_setting_view.profile_settings", fallback: "Profile Settings") + /// Ratings + public static let ratings = L10n.tr("Localizable", "eh_setting_view.ratings", fallback: "Ratings") + /// Ratings color + public static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.ratings_color", fallback: "Ratings color") + /// By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works. + public static let ratingsColorDescription = L10n.tr("Localizable", "eh_setting_view.ratings_color_description", fallback: "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works.") + /// RRGGB + public static let ratingsColorPrompt = L10n.tr("Localizable", "eh_setting_view.ratings_color_prompt", fallback: "RRGGB") + /// Rename + public static let rename = L10n.tr("Localizable", "eh_setting_view.rename", fallback: "Rename") + /// Result count + public static let resultCount = L10n.tr("Localizable", "eh_setting_view.result_count", fallback: "Result count") + /// How many results would you like per page for the index/search page and torrent search pages? + /// (Hath Perk: Paging Enlargement Required) + public static let resultCountDescription = L10n.tr("Localizable", "eh_setting_view.result_count_description", fallback: "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)") + /// Scale factor + public static let scaleFactor = L10n.tr("Localizable", "eh_setting_view.scale_factor", fallback: "Scale factor") + /// Search Result Count + public static let searchResultCount = L10n.tr("Localizable", "eh_setting_view.search_result_count", fallback: "Search Result Count") + /// Selected profile + public static let selectedProfile = L10n.tr("Localizable", "eh_setting_view.selected_profile", fallback: "Selected profile") + /// Set as default + public static let setAsDefault = L10n.tr("Localizable", "eh_setting_view.set_as_default", fallback: "Set as default") + /// Show filtered removal count + public static let showFilteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.show_filtered_removal_count", fallback: "Show filtered removal count") + /// Show label below gallery thumbnails + public static let showLabelBelowGalleryThumbnails = L10n.tr("Localizable", "eh_setting_view.show_label_below_gallery_thumbnails", fallback: "Show label below gallery thumbnails") + /// Search Range Indicator + public static let showSearchRangeIndicator = L10n.tr("Localizable", "eh_setting_view.show_search_range_indicator", fallback: "Search Range Indicator") + /// Show search range indicator + public static let showSearchRangeIndicatorDescription = L10n.tr("Localizable", "eh_setting_view.show_search_range_indicator_description", fallback: "Show search range indicator") + /// Show thumbnail pane + public static let showThumbnailPane = L10n.tr("Localizable", "eh_setting_view.show_thumbnail_pane", fallback: "Show thumbnail pane") + /// Tag Filtering Threshold + public static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.tag_filtering_threshold", fallback: "Tag Filtering Threshold") + /// You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999. + public static let tagFilteringThresholdDescription = L10n.tr("Localizable", "eh_setting_view.tag_filtering_threshold_description", fallback: "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999.") + /// Tag Watching Threshold + public static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.tag_watching_threshold", fallback: "Tag Watching Threshold") + /// Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999. + public static let tagWatchingThresholdDescription = L10n.tr("Localizable", "eh_setting_view.tag_watching_threshold_description", fallback: "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999.") + /// Tags sort order + public static let tagsSortOrder = L10n.tr("Localizable", "eh_setting_view.tags_sort_order", fallback: "Tags sort order") + /// You can set a default thumbnail configuration for all galleries you visit. + public static let thumbnailConfiguration = L10n.tr("Localizable", "eh_setting_view.thumbnail_configuration", fallback: "You can set a default thumbnail configuration for all galleries you visit.") + /// Thumbnail load timing + public static let thumbnailLoadTiming = L10n.tr("Localizable", "eh_setting_view.thumbnail_load_timing", fallback: "Thumbnail load timing") + /// How would you like the mouse-over thumbnails on the front page to load when using List Mode? + public static let thumbnailLoadTimingDescription = L10n.tr("Localizable", "eh_setting_view.thumbnail_load_timing_description", fallback: "How would you like the mouse-over thumbnails on the front page to load when using List Mode?") + /// Rows + public static let thumbnailRowCount = L10n.tr("Localizable", "eh_setting_view.thumbnail_row_count", fallback: "Rows") + /// Thumbnail Settings + public static let thumbnailSettings = L10n.tr("Localizable", "eh_setting_view.thumbnail_settings", fallback: "Thumbnail Settings") + /// Size + public static let thumbnailSize = L10n.tr("Localizable", "eh_setting_view.thumbnail_size", fallback: "Size") + /// Use Multi-Page Viewer + public static let useMultiPageViewer = L10n.tr("Localizable", "eh_setting_view.use_multi_page_viewer", fallback: "Use Multi-Page Viewer") + /// Use original images + public static let useOriginalImages = L10n.tr("Localizable", "eh_setting_view.use_original_images", fallback: "Use original images") + /// Vertical + public static let vertical = L10n.tr("Localizable", "eh_setting_view.vertical", fallback: "Vertical") + /// Viewport Override + public static let viewportOverride = L10n.tr("Localizable", "eh_setting_view.viewport_override", fallback: "Viewport Override") + /// Virtual width + public static let virtualWidth = L10n.tr("Localizable", "eh_setting_view.virtual_width", fallback: "Virtual width") + /// Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400. + public static let virtualWidthDescription = L10n.tr("Localizable", "eh_setting_view.virtual_width_description", fallback: "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400.") } public enum ErrorView { - public enum Button { - /// Drop the database - public static let dropDatabase = L10n.tr("Localizable", "error_view.button.drop_database", fallback: "Drop the database") - /// Retry - public static let retry = L10n.tr("Localizable", "error_view.button.retry", fallback: "Retry") - } - public enum Title { - /// This gallery is unavailable due to a copyright claim by %@. Sorry about that. - public static func copyrightClaim(_ p1: Any) -> String { - return L10n.tr("Localizable", "error_view.title.copyright_claim", String(describing: p1), fallback: "This gallery is unavailable due to a copyright claim by %@. Sorry about that.") - } - /// The database is corrupted. - /// Please submit an issue on GitHub. - public static let databaseCorrupted = L10n.tr("Localizable", "error_view.title.database_corrupted", fallback: "The database is corrupted.\nPlease submit an issue on GitHub.") - /// This gallery has been removed or is unavailable. - public static let galleryUnavailable = L10n.tr("Localizable", "error_view.title.gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") - /// Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@. - public static func ipBanned(_ p1: Any) -> String { - return L10n.tr("Localizable", "error_view.title.ip_banned", String(describing: p1), fallback: "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@.") - } - /// A network error occurred. - public static let network = L10n.tr("Localizable", "error_view.title.network", fallback: "A network error occurred.") - /// There seems to be nothing here. - public static let notFound = L10n.tr("Localizable", "error_view.title.not_found", fallback: "There seems to be nothing here.") - /// A parsing error occurred. - public static let parsing = L10n.tr("Localizable", "error_view.title.parsing", fallback: "A parsing error occurred.") - /// Please try again later. - public static let tryLater = L10n.tr("Localizable", "error_view.title.try_later", fallback: "Please try again later.") - /// An unknown error occurred. - public static let unknown = L10n.tr("Localizable", "error_view.title.unknown", fallback: "An unknown error occurred.") + /// This gallery is unavailable due to a copyright claim by %@. Sorry about that. + public static func copyrightClaim(_ p1: Any) -> String { + return L10n.tr("Localizable", "error_view.copyright_claim", String(describing: p1), fallback: "This gallery is unavailable due to a copyright claim by %@. Sorry about that.") + } + /// The database is corrupted. + /// Please submit an issue on GitHub. + public static let databaseCorrupted = L10n.tr("Localizable", "error_view.database_corrupted", fallback: "The database is corrupted.\nPlease submit an issue on GitHub.") + /// Drop the database + public static let dropDatabase = L10n.tr("Localizable", "error_view.drop_database", fallback: "Drop the database") + /// This gallery has been removed or is unavailable. + public static let galleryUnavailable = L10n.tr("Localizable", "error_view.gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") + /// Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@. + public static func ipBanned(_ p1: Any) -> String { + return L10n.tr("Localizable", "error_view.ip_banned", String(describing: p1), fallback: "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@.") + } + /// A network error occurred. + public static let network = L10n.tr("Localizable", "error_view.network", fallback: "A network error occurred.") + /// There seems to be nothing here. + public static let notFound = L10n.tr("Localizable", "error_view.not_found", fallback: "There seems to be nothing here.") + /// A parsing error occurred. + public static let parsing = L10n.tr("Localizable", "error_view.parsing", fallback: "A parsing error occurred.") + /// Retry + public static let retry = L10n.tr("Localizable", "error_view.retry", fallback: "Retry") + /// Please try again later. + public static let tryLater = L10n.tr("Localizable", "error_view.try_later", fallback: "Please try again later.") + /// An unknown error occurred. + public static let unknown = L10n.tr("Localizable", "error_view.unknown", fallback: "An unknown error occurred.") + } + public enum ExcludedLanguagesCategory { + /// Original + public static let original = L10n.tr("Localizable", "excluded_languages_category.original", fallback: "Original") + /// Rewrite + public static let rewrite = L10n.tr("Localizable", "excluded_languages_category.rewrite", fallback: "Rewrite") + /// Translated + public static let translated = L10n.tr("Localizable", "excluded_languages_category.translated", fallback: "Translated") + } + public enum FavoriteCategory { + /// All + public static let all = L10n.tr("Localizable", "favorite_category.all", fallback: "All") + /// Favorites %@ + public static func `default`(_ p1: Any) -> String { + return L10n.tr("Localizable", "favorite_category.default", String(describing: p1), fallback: "Favorites %@") } } + public enum FavoritesSortOrder { + /// By favorited time + public static let favoritedTime = L10n.tr("Localizable", "favorites_sort_order.favorited_time", fallback: "By favorited time") + /// By last gallery update time + public static let lastUpdateTime = L10n.tr("Localizable", "favorites_sort_order.last_update_time", fallback: "By last gallery update time") + } public enum FavoritesView { - public enum Title { - /// Favorites - public static let favorites = L10n.tr("Localizable", "favorites_view.title.favorites", fallback: "Favorites") - } + /// Favorites + public static let favorites = L10n.tr("Localizable", "favorites_view.favorites", fallback: "Favorites") + } + public enum FilterRange { + /// Global + public static let global = L10n.tr("Localizable", "filter_range.global", fallback: "Global") + /// Search + public static let search = L10n.tr("Localizable", "filter_range.search", fallback: "Search") + /// Watched + public static let watched = L10n.tr("Localizable", "filter_range.watched", fallback: "Watched") } public enum FiltersView { - public enum Button { - /// Reset filters - public static let resetFilters = L10n.tr("Localizable", "filters_view.button.reset_filters", fallback: "Reset filters") - } - public enum Section { - public enum Title { - /// Advanced - public static let advanced = L10n.tr("Localizable", "filters_view.section.title.advanced", fallback: "Advanced") - /// Default filter - public static let defaultFilter = L10n.tr("Localizable", "filters_view.section.title.default_filter", fallback: "Default filter") - } - } - public enum Title { - /// Advanced settings - public static let advancedSettings = L10n.tr("Localizable", "filters_view.title.advanced_settings", fallback: "Advanced settings") - /// Disable language filter - public static let disableLanguageFilter = L10n.tr("Localizable", "filters_view.title.disable_language_filter", fallback: "Disable language filter") - /// Disable tags filter - public static let disableTagsFilter = L10n.tr("Localizable", "filters_view.title.disable_tags_filter", fallback: "Disable tags filter") - /// Disable uploader filter - public static let disableUploaderFilter = L10n.tr("Localizable", "filters_view.title.disable_uploader_filter", fallback: "Disable uploader filter") - /// Filters - public static let filters = L10n.tr("Localizable", "filters_view.title.filters", fallback: "Filters") - /// Minimum rating - public static let minimumRating = L10n.tr("Localizable", "filters_view.title.minimum_rating", fallback: "Minimum rating") - /// Only show galleries with torrents - public static let onlyShowGalleriesWithTorrents = L10n.tr("Localizable", "filters_view.title.only_show_galleries_with_torrents", fallback: "Only show galleries with torrents") - /// Pages range - public static let pagesRange = L10n.tr("Localizable", "filters_view.title.pages_range", fallback: "Pages range") - /// Search downvoted tags - public static let searchDownvotedTags = L10n.tr("Localizable", "filters_view.title.search_downvoted_tags", fallback: "Search downvoted tags") - /// Search expunged galleries - public static let searchExpungedGalleries = L10n.tr("Localizable", "filters_view.title.search_expunged_galleries", fallback: "Search expunged galleries") - /// Search gallery description - public static let searchGalleryDescription = L10n.tr("Localizable", "filters_view.title.search_gallery_description", fallback: "Search gallery description") - /// Search gallery name - public static let searchGalleryName = L10n.tr("Localizable", "filters_view.title.search_gallery_name", fallback: "Search gallery name") - /// Search gallery tags - public static let searchGalleryTags = L10n.tr("Localizable", "filters_view.title.search_gallery_tags", fallback: "Search gallery tags") - /// Search Low-Power tags - public static let searchLowPowerTags = L10n.tr("Localizable", "filters_view.title.search_low_power_tags", fallback: "Search Low-Power tags") - /// Search torrent filenames - public static let searchTorrentFilenames = L10n.tr("Localizable", "filters_view.title.search_torrent_filenames", fallback: "Search torrent filenames") - /// Set minimum rating - public static let setMinimumRating = L10n.tr("Localizable", "filters_view.title.set_minimum_rating", fallback: "Set minimum rating") - /// Set pages range - public static let setPagesRange = L10n.tr("Localizable", "filters_view.title.set_pages_range", fallback: "Set pages range") - } + /// Advanced + public static let advanced = L10n.tr("Localizable", "filters_view.advanced", fallback: "Advanced") + /// Advanced settings + public static let advancedSettings = L10n.tr("Localizable", "filters_view.advanced_settings", fallback: "Advanced settings") + /// Default filter + public static let defaultFilter = L10n.tr("Localizable", "filters_view.default_filter", fallback: "Default filter") + /// Disable language filter + public static let disableLanguageFilter = L10n.tr("Localizable", "filters_view.disable_language_filter", fallback: "Disable language filter") + /// Disable tags filter + public static let disableTagsFilter = L10n.tr("Localizable", "filters_view.disable_tags_filter", fallback: "Disable tags filter") + /// Disable uploader filter + public static let disableUploaderFilter = L10n.tr("Localizable", "filters_view.disable_uploader_filter", fallback: "Disable uploader filter") + /// Filters + public static let filters = L10n.tr("Localizable", "filters_view.filters", fallback: "Filters") + /// Minimum rating + public static let minimumRating = L10n.tr("Localizable", "filters_view.minimum_rating", fallback: "Minimum rating") + /// Only show galleries with torrents + public static let onlyShowGalleriesWithTorrents = L10n.tr("Localizable", "filters_view.only_show_galleries_with_torrents", fallback: "Only show galleries with torrents") + /// Pages range + public static let pagesRange = L10n.tr("Localizable", "filters_view.pages_range", fallback: "Pages range") + /// Reset filters + public static let resetFilters = L10n.tr("Localizable", "filters_view.reset_filters", fallback: "Reset filters") + /// Search downvoted tags + public static let searchDownvotedTags = L10n.tr("Localizable", "filters_view.search_downvoted_tags", fallback: "Search downvoted tags") + /// Search expunged galleries + public static let searchExpungedGalleries = L10n.tr("Localizable", "filters_view.search_expunged_galleries", fallback: "Search expunged galleries") + /// Search gallery description + public static let searchGalleryDescription = L10n.tr("Localizable", "filters_view.search_gallery_description", fallback: "Search gallery description") + /// Search gallery name + public static let searchGalleryName = L10n.tr("Localizable", "filters_view.search_gallery_name", fallback: "Search gallery name") + /// Search gallery tags + public static let searchGalleryTags = L10n.tr("Localizable", "filters_view.search_gallery_tags", fallback: "Search gallery tags") + /// Search Low-Power tags + public static let searchLowPowerTags = L10n.tr("Localizable", "filters_view.search_low_power_tags", fallback: "Search Low-Power tags") + /// Search torrent filenames + public static let searchTorrentFilenames = L10n.tr("Localizable", "filters_view.search_torrent_filenames", fallback: "Search torrent filenames") + /// Set minimum rating + public static let setMinimumRating = L10n.tr("Localizable", "filters_view.set_minimum_rating", fallback: "Set minimum rating") + /// Set pages range + public static let setPagesRange = L10n.tr("Localizable", "filters_view.set_pages_range", fallback: "Set pages range") } public enum FolderManagerView { - public enum Dialog { - public enum Message { - /// This will delete the folder and all downloaded galleries inside it. - public static let deleteFolder = L10n.tr("Localizable", "folder_manager_view.dialog.message.delete_folder", fallback: "This will delete the folder and all downloaded galleries inside it.") - } - } - public enum EmptyState { - /// Folders you create will appear here. - public static let folders = L10n.tr("Localizable", "folder_manager_view.empty_state.folders", fallback: "Folders you create will appear here.") - } - public enum Placeholder { - /// Folder name - public static let folderName = L10n.tr("Localizable", "folder_manager_view.placeholder.folder_name", fallback: "Folder name") - } - public enum Title { - /// Folders - public static let folders = L10n.tr("Localizable", "folder_manager_view.title.folders", fallback: "Folders") - } + /// This will delete the folder and all downloaded galleries inside it. + public static let deleteFolder = L10n.tr("Localizable", "folder_manager_view.delete_folder", fallback: "This will delete the folder and all downloaded galleries inside it.") + /// Folders you create will appear here. + public static let emptyFolders = L10n.tr("Localizable", "folder_manager_view.empty_folders", fallback: "Folders you create will appear here.") + /// Folder name + public static let folderName = L10n.tr("Localizable", "folder_manager_view.folder_name", fallback: "Folder name") + /// Folders + public static let folders = L10n.tr("Localizable", "folder_manager_view.folders", fallback: "Folders") } public enum FrontpageView { - public enum Title { - /// Frontpage - public static let frontpage = L10n.tr("Localizable", "frontpage_view.title.frontpage", fallback: "Frontpage") - } + /// Frontpage + public static let frontpage = L10n.tr("Localizable", "frontpage_view.frontpage", fallback: "Frontpage") } public enum GalleryInfosView { - public enum Title { - /// Archive URL - public static let archiveURL = L10n.tr("Localizable", "gallery_infos_view.title.archive_URL", fallback: "Archive URL") - /// Average rating - public static let averageRating = L10n.tr("Localizable", "gallery_infos_view.title.average_rating", fallback: "Average rating") - /// Category - public static let category = L10n.tr("Localizable", "gallery_infos_view.title.category", fallback: "Category") - /// Cover URL - public static let coverURL = L10n.tr("Localizable", "gallery_infos_view.title.cover_URL", fallback: "Cover URL") - /// Favorited - public static let favorited = L10n.tr("Localizable", "gallery_infos_view.title.favorited", fallback: "Favorited") - /// Favorited times - public static let favoritedTimes = L10n.tr("Localizable", "gallery_infos_view.title.favorited_times", fallback: "Favorited times") - /// File size - public static let fileSize = L10n.tr("Localizable", "gallery_infos_view.title.file_size", fallback: "File size") - /// Gallery infos - public static let galleryInfos = L10n.tr("Localizable", "gallery_infos_view.title.gallery_infos", fallback: "Gallery infos") - /// Gallery URL - public static let galleryURL = L10n.tr("Localizable", "gallery_infos_view.title.gallery_URL", fallback: "Gallery URL") - /// ID - public static let id = L10n.tr("Localizable", "gallery_infos_view.title.id", fallback: "ID") - /// Japanese title - public static let japaneseTitle = L10n.tr("Localizable", "gallery_infos_view.title.japanese_title", fallback: "Japanese title") - /// Language - public static let language = L10n.tr("Localizable", "gallery_infos_view.title.language", fallback: "Language") - /// My rating - public static let myRating = L10n.tr("Localizable", "gallery_infos_view.title.my_rating", fallback: "My rating") - /// Page count - public static let pageCount = L10n.tr("Localizable", "gallery_infos_view.title.page_count", fallback: "Page count") - /// Parent URL - public static let parentURL = L10n.tr("Localizable", "gallery_infos_view.title.parent_URL", fallback: "Parent URL") - /// Posted date - public static let postedDate = L10n.tr("Localizable", "gallery_infos_view.title.posted_date", fallback: "Posted date") - /// Rating count - public static let ratingCount = L10n.tr("Localizable", "gallery_infos_view.title.rating_count", fallback: "Rating count") - /// Title - public static let title = L10n.tr("Localizable", "gallery_infos_view.title.title", fallback: "Title") - /// Token - public static let token = L10n.tr("Localizable", "gallery_infos_view.title.token", fallback: "Token") - /// Torrent count - public static let torrentCount = L10n.tr("Localizable", "gallery_infos_view.title.torrent_count", fallback: "Torrent count") - /// Torrent URL - public static let torrentURL = L10n.tr("Localizable", "gallery_infos_view.title.torrent_URL", fallback: "Torrent URL") - /// Uploader - public static let uploader = L10n.tr("Localizable", "gallery_infos_view.title.uploader", fallback: "Uploader") - /// Visibility - public static let visibility = L10n.tr("Localizable", "gallery_infos_view.title.visibility", fallback: "Visibility") - } - public enum Value { - /// No - public static let no = L10n.tr("Localizable", "gallery_infos_view.value.no", fallback: "No") - /// None - public static let `none` = L10n.tr("Localizable", "gallery_infos_view.value.none", fallback: "None") - /// Yes - public static let yes = L10n.tr("Localizable", "gallery_infos_view.value.yes", fallback: "Yes") - } + /// Archive URL + public static let archiveURL = L10n.tr("Localizable", "gallery_infos_view.archive_URL", fallback: "Archive URL") + /// Average rating + public static let averageRating = L10n.tr("Localizable", "gallery_infos_view.average_rating", fallback: "Average rating") + /// Category + public static let category = L10n.tr("Localizable", "gallery_infos_view.category", fallback: "Category") + /// Cover URL + public static let coverURL = L10n.tr("Localizable", "gallery_infos_view.cover_URL", fallback: "Cover URL") + /// Favorited + public static let favorited = L10n.tr("Localizable", "gallery_infos_view.favorited", fallback: "Favorited") + /// Favorited times + public static let favoritedTimes = L10n.tr("Localizable", "gallery_infos_view.favorited_times", fallback: "Favorited times") + /// File size + public static let fileSize = L10n.tr("Localizable", "gallery_infos_view.file_size", fallback: "File size") + /// Gallery infos + public static let galleryInfos = L10n.tr("Localizable", "gallery_infos_view.gallery_infos", fallback: "Gallery infos") + /// Gallery URL + public static let galleryURL = L10n.tr("Localizable", "gallery_infos_view.gallery_URL", fallback: "Gallery URL") + /// ID + public static let id = L10n.tr("Localizable", "gallery_infos_view.id", fallback: "ID") + /// Japanese title + public static let japaneseTitle = L10n.tr("Localizable", "gallery_infos_view.japanese_title", fallback: "Japanese title") + /// Language + public static let language = L10n.tr("Localizable", "gallery_infos_view.language", fallback: "Language") + /// My rating + public static let myRating = L10n.tr("Localizable", "gallery_infos_view.my_rating", fallback: "My rating") + /// No + public static let no = L10n.tr("Localizable", "gallery_infos_view.no", fallback: "No") + /// None + public static let `none` = L10n.tr("Localizable", "gallery_infos_view.none", fallback: "None") + /// Page count + public static let pageCount = L10n.tr("Localizable", "gallery_infos_view.page_count", fallback: "Page count") + /// Parent URL + public static let parentURL = L10n.tr("Localizable", "gallery_infos_view.parent_URL", fallback: "Parent URL") + /// Posted date + public static let postedDate = L10n.tr("Localizable", "gallery_infos_view.posted_date", fallback: "Posted date") + /// Rating count + public static let ratingCount = L10n.tr("Localizable", "gallery_infos_view.rating_count", fallback: "Rating count") + /// Title + public static let title = L10n.tr("Localizable", "gallery_infos_view.title", fallback: "Title") + /// Token + public static let token = L10n.tr("Localizable", "gallery_infos_view.token", fallback: "Token") + /// Torrent count + public static let torrentCount = L10n.tr("Localizable", "gallery_infos_view.torrent_count", fallback: "Torrent count") + /// Torrent URL + public static let torrentURL = L10n.tr("Localizable", "gallery_infos_view.torrent_URL", fallback: "Torrent URL") + /// Uploader + public static let uploader = L10n.tr("Localizable", "gallery_infos_view.uploader", fallback: "Uploader") + /// Visibility + public static let visibility = L10n.tr("Localizable", "gallery_infos_view.visibility", fallback: "Visibility") + /// Yes + public static let yes = L10n.tr("Localizable", "gallery_infos_view.yes", fallback: "Yes") + } + public enum GalleryName { + /// Default Title + public static let `default` = L10n.tr("Localizable", "gallery_name.default", fallback: "Default Title") + /// Japanese Title (if available) + public static let japanese = L10n.tr("Localizable", "gallery_name.japanese", fallback: "Japanese Title (if available)") + } + public enum GalleryPageNumbering { + /// None + public static let `none` = L10n.tr("Localizable", "gallery_page_numbering.none", fallback: "None") + /// Page Number + Name + public static let pageNumberAndName = L10n.tr("Localizable", "gallery_page_numbering.page_number_and_name", fallback: "Page Number + Name") + /// Page Number Only + public static let pageNumberOnly = L10n.tr("Localizable", "gallery_page_numbering.page_number_only", fallback: "Page Number Only") + } + public enum GalleryVisibility { + /// Expunged + public static let expunged = L10n.tr("Localizable", "gallery_visibility.expunged", fallback: "Expunged") + /// No (%@) + public static func no(_ p1: Any) -> String { + return L10n.tr("Localizable", "gallery_visibility.no", String(describing: p1), fallback: "No (%@)") + } + /// Yes + public static let yes = L10n.tr("Localizable", "gallery_visibility.yes", fallback: "Yes") } public enum GeneralSettingView { - public enum Button { - /// App activity logs - public static let appActivityLogs = L10n.tr("Localizable", "general_setting_view.button.app_activity_logs", fallback: "App activity logs") - /// Clear image caches - public static let clearImageCaches = L10n.tr("Localizable", "general_setting_view.button.clear_image_caches", fallback: "Clear image caches") - /// Import custom translations - public static let importCustomTranslations = L10n.tr("Localizable", "general_setting_view.button.import_custom_translations", fallback: "Import custom translations") - /// Remove custom translations - public static let removeCustomTranslations = L10n.tr("Localizable", "general_setting_view.button.remove_custom_translations", fallback: "Remove custom translations") - } - public enum Section { - public enum Title { - /// Caches - public static let caches = L10n.tr("Localizable", "general_setting_view.section.title.caches", fallback: "Caches") - /// Navigation - public static let navigation = L10n.tr("Localizable", "general_setting_view.section.title.navigation", fallback: "Navigation") - /// Security - public static let security = L10n.tr("Localizable", "general_setting_view.section.title.security", fallback: "Security") - /// Tags - public static let tags = L10n.tr("Localizable", "general_setting_view.section.title.tags", fallback: "Tags") - } - } - public enum Title { - /// Auto-Lock - public static let autoLock = L10n.tr("Localizable", "general_setting_view.title.auto_lock", fallback: "Auto-Lock") - /// Background blur radius - public static let backgroundBlurRadius = L10n.tr("Localizable", "general_setting_view.title.background_blur_radius", fallback: "Background blur radius") - /// Detects links from the clipboard - public static let detectsLinksFromClipboard = L10n.tr("Localizable", "general_setting_view.title.detects_links_from_clipboard", fallback: "Detects links from the clipboard") - /// Enables tags extension - public static let enablesTagsExtension = L10n.tr("Localizable", "general_setting_view.title.enables_tags_extension", fallback: "Enables tags extension") - /// General - public static let general = L10n.tr("Localizable", "general_setting_view.title.general", fallback: "General") - /// Language - public static let language = L10n.tr("Localizable", "general_setting_view.title.language", fallback: "Language") - /// Redirects links to the selected host - public static let redirectsLinksToTheSelectedHost = L10n.tr("Localizable", "general_setting_view.title.redirects_links_to_the_selected_host", fallback: "Redirects links to the selected host") - /// Shows images in tags - public static let showsImagesInTags = L10n.tr("Localizable", "general_setting_view.title.shows_images_in_tags", fallback: "Shows images in tags") - /// Shows tags search suggestion - public static let showsTagsSearchSuggestion = L10n.tr("Localizable", "general_setting_view.title.shows_tags_search_suggestion", fallback: "Shows tags search suggestion") - /// Translates tags - public static let translatesTags = L10n.tr("Localizable", "general_setting_view.title.translates_tags", fallback: "Translates tags") - } - public enum Value { - /// N/A - public static let defaultLanguageDescription = L10n.tr("Localizable", "general_setting_view.value.default_language_description", fallback: "N/A") - } + /// App activity logs + public static let appActivityLogs = L10n.tr("Localizable", "general_setting_view.app_activity_logs", fallback: "App activity logs") + /// Auto-Lock + public static let autoLock = L10n.tr("Localizable", "general_setting_view.auto_lock", fallback: "Auto-Lock") + /// Background blur radius + public static let backgroundBlurRadius = L10n.tr("Localizable", "general_setting_view.background_blur_radius", fallback: "Background blur radius") + /// Caches + public static let caches = L10n.tr("Localizable", "general_setting_view.caches", fallback: "Caches") + /// Clear image caches + public static let clearImageCaches = L10n.tr("Localizable", "general_setting_view.clear_image_caches", fallback: "Clear image caches") + /// N/A + public static let defaultLanguageDescription = L10n.tr("Localizable", "general_setting_view.default_language_description", fallback: "N/A") + /// Detects links from the clipboard + public static let detectsLinksFromClipboard = L10n.tr("Localizable", "general_setting_view.detects_links_from_clipboard", fallback: "Detects links from the clipboard") + /// Enables tags extension + public static let enablesTagsExtension = L10n.tr("Localizable", "general_setting_view.enables_tags_extension", fallback: "Enables tags extension") + /// General + public static let general = L10n.tr("Localizable", "general_setting_view.general", fallback: "General") + /// Import custom translations + public static let importCustomTranslations = L10n.tr("Localizable", "general_setting_view.import_custom_translations", fallback: "Import custom translations") + /// Language + public static let language = L10n.tr("Localizable", "general_setting_view.language", fallback: "Language") + /// Navigation + public static let navigation = L10n.tr("Localizable", "general_setting_view.navigation", fallback: "Navigation") + /// Redirects links to the selected host + public static let redirectsLinksToTheSelectedHost = L10n.tr("Localizable", "general_setting_view.redirects_links_to_the_selected_host", fallback: "Redirects links to the selected host") + /// Remove custom translations + public static let removeCustomTranslations = L10n.tr("Localizable", "general_setting_view.remove_custom_translations", fallback: "Remove custom translations") + /// Security + public static let security = L10n.tr("Localizable", "general_setting_view.security", fallback: "Security") + /// Shows images in tags + public static let showsImagesInTags = L10n.tr("Localizable", "general_setting_view.shows_images_in_tags", fallback: "Shows images in tags") + /// Shows tags search suggestion + public static let showsTagsSearchSuggestion = L10n.tr("Localizable", "general_setting_view.shows_tags_search_suggestion", fallback: "Shows tags search suggestion") + /// Tags + public static let tags = L10n.tr("Localizable", "general_setting_view.tags", fallback: "Tags") + /// Translates tags + public static let translatesTags = L10n.tr("Localizable", "general_setting_view.translates_tags", fallback: "Translates tags") + } + public enum Greeting { + /// and + public static let and = L10n.tr("Localizable", "greeting.and", fallback: " and ") + /// ! + public static let end = L10n.tr("Localizable", "greeting.end", fallback: "!") + /// , + public static let separator = L10n.tr("Localizable", "greeting.separator", fallback: ", ") + /// You gain + public static let start = L10n.tr("Localizable", "greeting.start", fallback: "You gain ") + } + public enum HathArchive { + /// Free + public static let free = L10n.tr("Localizable", "hath_archive.free", fallback: "Free") + /// N/A + public static let notAvailable = L10n.tr("Localizable", "hath_archive.not_available", fallback: "N/A") } public enum HistoryView { - public enum Title { - /// History - public static let history = L10n.tr("Localizable", "history_view.title.history", fallback: "History") - } + /// History + public static let history = L10n.tr("Localizable", "history_view.history", fallback: "History") + } + public enum HomeMiscGridType { + /// History + public static let history = L10n.tr("Localizable", "home_misc_grid_type.history", fallback: "History") + /// Popular + public static let popular = L10n.tr("Localizable", "home_misc_grid_type.popular", fallback: "Popular") + /// Watched + public static let watched = L10n.tr("Localizable", "home_misc_grid_type.watched", fallback: "Watched") } public enum HomeView { - public enum Section { - public enum Title { - /// Frontpage - public static let frontpage = L10n.tr("Localizable", "home_view.section.title.frontpage", fallback: "Frontpage") - /// Other - public static let other = L10n.tr("Localizable", "home_view.section.title.other", fallback: "Other") - /// Toplists - public static let toplists = L10n.tr("Localizable", "home_view.section.title.toplists", fallback: "Toplists") - } - } - public enum Title { - /// Home - public static let home = L10n.tr("Localizable", "home_view.title.home", fallback: "Home") - } + /// Frontpage + public static let frontpage = L10n.tr("Localizable", "home_view.frontpage", fallback: "Frontpage") + /// Home + public static let home = L10n.tr("Localizable", "home_view.home", fallback: "Home") + /// Other + public static let other = L10n.tr("Localizable", "home_view.other", fallback: "Other") + /// Toplists + public static let toplists = L10n.tr("Localizable", "home_view.toplists", fallback: "Toplists") + } + public enum ImageResolution { + /// Auto + public static let auto = L10n.tr("Localizable", "image_resolution.auto", fallback: "Auto") } public enum JumpPageView { - public enum Button { - /// Confirm - public static let confirm = L10n.tr("Localizable", "jump_page_view.button.confirm", fallback: "Confirm") - } - public enum Description { - /// Enter a page number between 1 and %d to jump to. - public static func jumpPage(_ p1: Int) -> String { - return L10n.tr("Localizable", "jump_page_view.description.jump_page", p1, fallback: "Enter a page number between 1 and %d to jump to.") - } - } - public enum Title { - /// Jump page - public static let jumpPage = L10n.tr("Localizable", "jump_page_view.title.jump_page", fallback: "Jump page") + /// Confirm + public static let confirm = L10n.tr("Localizable", "jump_page_view.confirm", fallback: "Confirm") + /// Jump page + public static let jumpPage = L10n.tr("Localizable", "jump_page_view.jump_page", fallback: "Jump page") + /// Enter a page number between 1 and %d to jump to. + public static func jumpPageDescription(_ p1: Int) -> String { + return L10n.tr("Localizable", "jump_page_view.jump_page_description", p1, fallback: "Enter a page number between 1 and %d to jump to.") } } public enum LaboratorySettingView { - public enum Title { - /// Bypasses SNI Filtering - public static let bypassesSNIFiltering = L10n.tr("Localizable", "laboratory_setting_view.title.bypasses_SNI_filtering", fallback: "Bypasses SNI Filtering") - /// Laboratory - public static let laboratory = L10n.tr("Localizable", "laboratory_setting_view.title.laboratory", fallback: "Laboratory") - } + /// Bypasses SNI Filtering + public static let bypassesSNIFiltering = L10n.tr("Localizable", "laboratory_setting_view.bypasses_SNI_filtering", fallback: "Bypasses SNI Filtering") + /// Laboratory + public static let laboratory = L10n.tr("Localizable", "laboratory_setting_view.laboratory", fallback: "Laboratory") + } + public enum Language { + /// Afrikaans + public static let afrikaans = L10n.tr("Localizable", "language.afrikaans", fallback: "Afrikaans") + /// Albanian + public static let albanian = L10n.tr("Localizable", "language.albanian", fallback: "Albanian") + /// Arabic + public static let arabic = L10n.tr("Localizable", "language.arabic", fallback: "Arabic") + /// Bengali + public static let bengali = L10n.tr("Localizable", "language.bengali", fallback: "Bengali") + /// Bosnian + public static let bosnian = L10n.tr("Localizable", "language.bosnian", fallback: "Bosnian") + /// Bulgarian + public static let bulgarian = L10n.tr("Localizable", "language.bulgarian", fallback: "Bulgarian") + /// Burmese + public static let burmese = L10n.tr("Localizable", "language.burmese", fallback: "Burmese") + /// Catalan + public static let catalan = L10n.tr("Localizable", "language.catalan", fallback: "Catalan") + /// Cebuano + public static let cebuano = L10n.tr("Localizable", "language.cebuano", fallback: "Cebuano") + /// Chinese + public static let chinese = L10n.tr("Localizable", "language.chinese", fallback: "Chinese") + /// Croatian + public static let croatian = L10n.tr("Localizable", "language.croatian", fallback: "Croatian") + /// Czech + public static let czech = L10n.tr("Localizable", "language.czech", fallback: "Czech") + /// Danish + public static let danish = L10n.tr("Localizable", "language.danish", fallback: "Danish") + /// Dutch + public static let dutch = L10n.tr("Localizable", "language.dutch", fallback: "Dutch") + /// English + public static let english = L10n.tr("Localizable", "language.english", fallback: "English") + /// Esperanto + public static let esperanto = L10n.tr("Localizable", "language.esperanto", fallback: "Esperanto") + /// Estonian + public static let estonian = L10n.tr("Localizable", "language.estonian", fallback: "Estonian") + /// Finnish + public static let finnish = L10n.tr("Localizable", "language.finnish", fallback: "Finnish") + /// French + public static let french = L10n.tr("Localizable", "language.french", fallback: "French") + /// Georgian + public static let georgian = L10n.tr("Localizable", "language.georgian", fallback: "Georgian") + /// German + public static let german = L10n.tr("Localizable", "language.german", fallback: "German") + /// Greek + public static let greek = L10n.tr("Localizable", "language.greek", fallback: "Greek") + /// Hebrew + public static let hebrew = L10n.tr("Localizable", "language.hebrew", fallback: "Hebrew") + /// Hindi + public static let hindi = L10n.tr("Localizable", "language.hindi", fallback: "Hindi") + /// Hmong + public static let hmong = L10n.tr("Localizable", "language.hmong", fallback: "Hmong") + /// Hungarian + public static let hungarian = L10n.tr("Localizable", "language.hungarian", fallback: "Hungarian") + /// Indonesian + public static let indonesian = L10n.tr("Localizable", "language.indonesian", fallback: "Indonesian") + /// N/A + public static let invalid = L10n.tr("Localizable", "language.invalid", fallback: "N/A") + /// Italian + public static let italian = L10n.tr("Localizable", "language.italian", fallback: "Italian") + /// Japanese + public static let japanese = L10n.tr("Localizable", "language.japanese", fallback: "Japanese") + /// Kazakh + public static let kazakh = L10n.tr("Localizable", "language.kazakh", fallback: "Kazakh") + /// Khmer + public static let khmer = L10n.tr("Localizable", "language.khmer", fallback: "Khmer") + /// Korean + public static let korean = L10n.tr("Localizable", "language.korean", fallback: "Korean") + /// Kurdish + public static let kurdish = L10n.tr("Localizable", "language.kurdish", fallback: "Kurdish") + /// Lao + public static let lao = L10n.tr("Localizable", "language.lao", fallback: "Lao") + /// Latin + public static let latin = L10n.tr("Localizable", "language.latin", fallback: "Latin") + /// Mongolian + public static let mongolian = L10n.tr("Localizable", "language.mongolian", fallback: "Mongolian") + /// Ndebele + public static let ndebele = L10n.tr("Localizable", "language.ndebele", fallback: "Ndebele") + /// Nepali + public static let nepali = L10n.tr("Localizable", "language.nepali", fallback: "Nepali") + /// Norwegian + public static let norwegian = L10n.tr("Localizable", "language.norwegian", fallback: "Norwegian") + /// Oromo + public static let oromo = L10n.tr("Localizable", "language.oromo", fallback: "Oromo") + /// Other + public static let other = L10n.tr("Localizable", "language.other", fallback: "Other") + /// Pashto + public static let pashto = L10n.tr("Localizable", "language.pashto", fallback: "Pashto") + /// Persian + public static let persian = L10n.tr("Localizable", "language.persian", fallback: "Persian") + /// Polish + public static let polish = L10n.tr("Localizable", "language.polish", fallback: "Polish") + /// Portuguese + public static let portuguese = L10n.tr("Localizable", "language.portuguese", fallback: "Portuguese") + /// Punjabi + public static let punjabi = L10n.tr("Localizable", "language.punjabi", fallback: "Punjabi") + /// Romanian + public static let romanian = L10n.tr("Localizable", "language.romanian", fallback: "Romanian") + /// Russian + public static let russian = L10n.tr("Localizable", "language.russian", fallback: "Russian") + /// Sango + public static let sango = L10n.tr("Localizable", "language.sango", fallback: "Sango") + /// Serbian + public static let serbian = L10n.tr("Localizable", "language.serbian", fallback: "Serbian") + /// Shona + public static let shona = L10n.tr("Localizable", "language.shona", fallback: "Shona") + /// Slovak + public static let slovak = L10n.tr("Localizable", "language.slovak", fallback: "Slovak") + /// Slovenian + public static let slovenian = L10n.tr("Localizable", "language.slovenian", fallback: "Slovenian") + /// Somali + public static let somali = L10n.tr("Localizable", "language.somali", fallback: "Somali") + /// Spanish + public static let spanish = L10n.tr("Localizable", "language.spanish", fallback: "Spanish") + /// Swahili + public static let swahili = L10n.tr("Localizable", "language.swahili", fallback: "Swahili") + /// Swedish + public static let swedish = L10n.tr("Localizable", "language.swedish", fallback: "Swedish") + /// Tagalog + public static let tagalog = L10n.tr("Localizable", "language.tagalog", fallback: "Tagalog") + /// Thai + public static let thai = L10n.tr("Localizable", "language.thai", fallback: "Thai") + /// Tigrinya + public static let tigrinya = L10n.tr("Localizable", "language.tigrinya", fallback: "Tigrinya") + /// Turkish + public static let turkish = L10n.tr("Localizable", "language.turkish", fallback: "Turkish") + /// Ukrainian + public static let ukrainian = L10n.tr("Localizable", "language.ukrainian", fallback: "Ukrainian") + /// Urdu + public static let urdu = L10n.tr("Localizable", "language.urdu", fallback: "Urdu") + /// Vietnamese + public static let vietnamese = L10n.tr("Localizable", "language.vietnamese", fallback: "Vietnamese") + /// Zulu + public static let zulu = L10n.tr("Localizable", "language.zulu", fallback: "Zulu") + } + public enum ListDisplayMode { + /// Detail + public static let detail = L10n.tr("Localizable", "list_display_mode.detail", fallback: "Detail") + /// Thumbnail + public static let thumbnail = L10n.tr("Localizable", "list_display_mode.thumbnail", fallback: "Thumbnail") + } + public enum LoadThroughHathSetting { + /// Any client + public static let anyClient = L10n.tr("Localizable", "load_through_hath_setting.any_client", fallback: "Any client") + /// Recommended. + public static let anyClientDescription = L10n.tr("Localizable", "load_through_hath_setting.any_client_description", fallback: "Recommended.") + /// Default port clients only + public static let defaultPortOnly = L10n.tr("Localizable", "load_through_hath_setting.default_port_only", fallback: "Default port clients only") + /// Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports. + public static let defaultPortOnlyDescription = L10n.tr("Localizable", "load_through_hath_setting.default_port_only_description", fallback: "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports.") + /// No [Legacy/HTTP] + public static let legacyNo = L10n.tr("Localizable", "load_through_hath_setting.legacy_no", fallback: "No [Legacy/HTTP]") + /// Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only. + public static let legacyNoDescription = L10n.tr("Localizable", "load_through_hath_setting.legacy_no_description", fallback: "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only.") + /// No [Modern/HTTPS] + public static let modernNo = L10n.tr("Localizable", "load_through_hath_setting.modern_no", fallback: "No [Modern/HTTPS]") + /// Donator only. You will not be able to browse as many pages. Recommended only if having severe problems. + public static let modernNoDescription = L10n.tr("Localizable", "load_through_hath_setting.modern_no_description", fallback: "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems.") } public enum LoadingView { - public enum Title { - /// Loading... - public static let loading = L10n.tr("Localizable", "loading_view.title.loading", fallback: "Loading...") - /// Preparing the database... - public static let preparingDatabase = L10n.tr("Localizable", "loading_view.title.preparing_database", fallback: "Preparing the database...") - } + /// Loading... + public static let loading = L10n.tr("Localizable", "loading_view.loading", fallback: "Loading...") + /// Preparing the database... + public static let preparingDatabase = L10n.tr("Localizable", "loading_view.preparing_database", fallback: "Preparing the database...") } public enum LocalAuthorization { /// The App has been locked due to the Auto-Lock expiration. public static let reason = L10n.tr("Localizable", "local_authorization.reason", fallback: "The App has been locked due to the Auto-Lock expiration.") } public enum LoginView { - public enum Title { - /// Login - public static let login = L10n.tr("Localizable", "login_view.title.login", fallback: "Login") - /// Password - public static let password = L10n.tr("Localizable", "login_view.title.password", fallback: "Password") - /// Username - public static let username = L10n.tr("Localizable", "login_view.title.username", fallback: "Username") - } + /// Login + public static let login = L10n.tr("Localizable", "login_view.login", fallback: "Login") + /// Password + public static let password = L10n.tr("Localizable", "login_view.password", fallback: "Password") + /// Username + public static let username = L10n.tr("Localizable", "login_view.username", fallback: "Username") + } + public enum MultiplePageViewerStyle { + /// Align center, always scale + public static let alignCenterAlwaysScale = L10n.tr("Localizable", "multiple_page_viewer_style.align_center_always_scale", fallback: "Align center, always scale") + /// Align center, scale if overwidth + public static let alignCenterScaleIfOverWidth = L10n.tr("Localizable", "multiple_page_viewer_style.align_center_scale_if_over_width", fallback: "Align center, scale if overwidth") + /// Align left, scale if overwidth + public static let alignLeftScaleIfOverWidth = L10n.tr("Localizable", "multiple_page_viewer_style.align_left_scale_if_over_width", fallback: "Align left, scale if overwidth") } public enum NewDawnView { - public enum Title { - /// It is the dawn of a new day! - public static let first = L10n.tr("Localizable", "new_dawn_view.title.first", fallback: "It is the dawn of a new day!") - /// Reflecting on your journey so far, you find that you are a little wiser. - public static let second = L10n.tr("Localizable", "new_dawn_view.title.second", fallback: "Reflecting on your journey so far, you find that you are a little wiser.") - } + /// It is the dawn of a new day! + public static let first = L10n.tr("Localizable", "new_dawn_view.first", fallback: "It is the dawn of a new day!") + /// Reflecting on your journey so far, you find that you are a little wiser. + public static let second = L10n.tr("Localizable", "new_dawn_view.second", fallback: "Reflecting on your journey so far, you find that you are a little wiser.") } public enum NotLoginView { - public enum Button { - /// Login - public static let login = L10n.tr("Localizable", "not_login_view.button.login", fallback: "Login") - } - public enum Title { - /// You need to login to access this feature. - public static let needLogin = L10n.tr("Localizable", "not_login_view.title.need_login", fallback: "You need to login to access this feature.") - } + /// You need to login to access this feature. + public static let needLogin = L10n.tr("Localizable", "not_login_view.need_login", fallback: "You need to login to access this feature.") } public enum PopularView { - public enum Title { - /// Popular - public static let popular = L10n.tr("Localizable", "popular_view.title.popular", fallback: "Popular") - } + /// Popular + public static let popular = L10n.tr("Localizable", "popular_view.popular", fallback: "Popular") } public enum PostCommentView { - public enum Title { - /// Edit comment - public static let editComment = L10n.tr("Localizable", "post_comment_view.title.edit_comment", fallback: "Edit comment") - /// Post comment - public static let postComment = L10n.tr("Localizable", "post_comment_view.title.post_comment", fallback: "Post comment") - } + /// Edit comment + public static let editComment = L10n.tr("Localizable", "post_comment_view.edit_comment", fallback: "Edit comment") + /// Post comment + public static let postComment = L10n.tr("Localizable", "post_comment_view.post_comment", fallback: "Post comment") + } + public enum PreferredColorScheme { + /// Automatic + public static let automatic = L10n.tr("Localizable", "preferred_color_scheme.automatic", fallback: "Automatic") + /// Dark + public static let dark = L10n.tr("Localizable", "preferred_color_scheme.dark", fallback: "Dark") + /// Light + public static let light = L10n.tr("Localizable", "preferred_color_scheme.light", fallback: "Light") } public enum PreviewsView { - public enum Title { - /// Previews - public static let previews = L10n.tr("Localizable", "previews_view.title.previews", fallback: "Previews") - } + /// Previews + public static let previews = L10n.tr("Localizable", "previews_view.previews", fallback: "Previews") } public enum QuickSearchView { - public enum Placeholder { - /// Optional - public static let `optional` = L10n.tr("Localizable", "quick_search_view.placeholder.optional", fallback: "Optional") - } - public enum Title { - /// Content - public static let content = L10n.tr("Localizable", "quick_search_view.title.content", fallback: "Content") - /// Edit word - public static let editWord = L10n.tr("Localizable", "quick_search_view.title.edit_word", fallback: "Edit word") - /// Name - public static let name = L10n.tr("Localizable", "quick_search_view.title.name", fallback: "Name") - /// New word - public static let newWord = L10n.tr("Localizable", "quick_search_view.title.new_word", fallback: "New word") - /// Quick search - public static let quickSearch = L10n.tr("Localizable", "quick_search_view.title.quick_search", fallback: "Quick search") - } + /// Content + public static let content = L10n.tr("Localizable", "quick_search_view.content", fallback: "Content") + /// Edit word + public static let editWord = L10n.tr("Localizable", "quick_search_view.edit_word", fallback: "Edit word") + /// Name + public static let name = L10n.tr("Localizable", "quick_search_view.name", fallback: "Name") + /// New word + public static let newWord = L10n.tr("Localizable", "quick_search_view.new_word", fallback: "New word") + /// Optional + public static let `optional` = L10n.tr("Localizable", "quick_search_view.optional", fallback: "Optional") + /// Quick search + public static let quickSearch = L10n.tr("Localizable", "quick_search_view.quick_search", fallback: "Quick search") + } + public enum ReadingDirection { + /// Left-to-right + public static let leftToRight = L10n.tr("Localizable", "reading_direction.left_to_right", fallback: "Left-to-right") + /// Right-to-left + public static let rightToLeft = L10n.tr("Localizable", "reading_direction.right_to_left", fallback: "Right-to-left") + /// Vertical + public static let vertical = L10n.tr("Localizable", "reading_direction.vertical", fallback: "Vertical") } public enum ReadingSettingView { - public enum Section { - public enum Title { - /// Appearance - public static let appearance = L10n.tr("Localizable", "reading_setting_view.section.title.appearance", fallback: "Appearance") - } - } - public enum Title { - /// Direction - public static let direction = L10n.tr("Localizable", "reading_setting_view.title.direction", fallback: "Direction") - /// Double tap scale factor - public static let doubleTapScaleFactor = L10n.tr("Localizable", "reading_setting_view.title.double_tap_scale_factor", fallback: "Double tap scale factor") - /// Enables landscape - public static let enablesLandscape = L10n.tr("Localizable", "reading_setting_view.title.enables_landscape", fallback: "Enables landscape") - /// Maximum scale factor - public static let maximumScaleFactor = L10n.tr("Localizable", "reading_setting_view.title.maximum_scale_factor", fallback: "Maximum scale factor") - /// Preload limit - public static let preloadLimit = L10n.tr("Localizable", "reading_setting_view.title.preload_limit", fallback: "Preload limit") - /// Reading - public static let reading = L10n.tr("Localizable", "reading_setting_view.title.reading", fallback: "Reading") - /// Separator height - public static let separatorHeight = L10n.tr("Localizable", "reading_setting_view.title.separator_height", fallback: "Separator height") - } + /// Appearance + public static let appearance = L10n.tr("Localizable", "reading_setting_view.appearance", fallback: "Appearance") + /// Direction + public static let direction = L10n.tr("Localizable", "reading_setting_view.direction", fallback: "Direction") + /// Double tap scale factor + public static let doubleTapScaleFactor = L10n.tr("Localizable", "reading_setting_view.double_tap_scale_factor", fallback: "Double tap scale factor") + /// Enables landscape + public static let enablesLandscape = L10n.tr("Localizable", "reading_setting_view.enables_landscape", fallback: "Enables landscape") + /// Maximum scale factor + public static let maximumScaleFactor = L10n.tr("Localizable", "reading_setting_view.maximum_scale_factor", fallback: "Maximum scale factor") + /// Preload limit + public static let preloadLimit = L10n.tr("Localizable", "reading_setting_view.preload_limit", fallback: "Preload limit") + /// Reading + public static let reading = L10n.tr("Localizable", "reading_setting_view.reading", fallback: "Reading") + /// Separator height + public static let separatorHeight = L10n.tr("Localizable", "reading_setting_view.separator_height", fallback: "Separator height") } public enum ReadingView { - public enum ContextMenu { - public enum Button { - /// Copy - public static let copy = L10n.tr("Localizable", "reading_view.context_menu.button.copy", fallback: "Copy") - /// Reload - public static let reload = L10n.tr("Localizable", "reading_view.context_menu.button.reload", fallback: "Reload") - /// Save - public static let save = L10n.tr("Localizable", "reading_view.context_menu.button.save", fallback: "Save") - /// Save original - public static let saveOriginal = L10n.tr("Localizable", "reading_view.context_menu.button.save_original", fallback: "Save original") - /// Share - public static let share = L10n.tr("Localizable", "reading_view.context_menu.button.share", fallback: "Share") - } - } - public enum ToolbarItem { - public enum Button { - /// Reading setting - public static let readingSetting = L10n.tr("Localizable", "reading_view.toolbar_item.button.reading_setting", fallback: "Reading setting") - /// Reload all images - public static let reloadAllImages = L10n.tr("Localizable", "reading_view.toolbar_item.button.reload_all_images", fallback: "Reload all images") - /// Retry all failed images - public static let retryAllFailedImages = L10n.tr("Localizable", "reading_view.toolbar_item.button.retry_all_failed_images", fallback: "Retry all failed images") - } - public enum Title { - /// Auto-Play - public static let autoPlay = L10n.tr("Localizable", "reading_view.toolbar_item.title.auto_play", fallback: "Auto-Play") - /// Dual-Page mode - public static let dualPageMode = L10n.tr("Localizable", "reading_view.toolbar_item.title.dual_page_mode", fallback: "Dual-Page mode") - /// Except the cover - public static let exceptTheCover = L10n.tr("Localizable", "reading_view.toolbar_item.title.except_the_cover", fallback: "Except the cover") - } - } + /// Auto-Play + public static let autoPlay = L10n.tr("Localizable", "reading_view.auto_play", fallback: "Auto-Play") + /// Copy + public static let copy = L10n.tr("Localizable", "reading_view.copy", fallback: "Copy") + /// Dual-Page mode + public static let dualPageMode = L10n.tr("Localizable", "reading_view.dual_page_mode", fallback: "Dual-Page mode") + /// Except the cover + public static let exceptTheCover = L10n.tr("Localizable", "reading_view.except_the_cover", fallback: "Except the cover") + /// Reading setting + public static let readingSetting = L10n.tr("Localizable", "reading_view.reading_setting", fallback: "Reading setting") + /// Reload + public static let reload = L10n.tr("Localizable", "reading_view.reload", fallback: "Reload") + /// Reload all images + public static let reloadAllImages = L10n.tr("Localizable", "reading_view.reload_all_images", fallback: "Reload all images") + /// Retry all failed images + public static let retryAllFailedImages = L10n.tr("Localizable", "reading_view.retry_all_failed_images", fallback: "Retry all failed images") + /// Save + public static let save = L10n.tr("Localizable", "reading_view.save", fallback: "Save") + /// Save original + public static let saveOriginal = L10n.tr("Localizable", "reading_view.save_original", fallback: "Save original") + /// Share + public static let share = L10n.tr("Localizable", "reading_view.share", fallback: "Share") } public enum SearchView { - public enum Section { - public enum Title { - /// Quick search - public static let quickSearch = L10n.tr("Localizable", "search_view.section.title.quick_search", fallback: "Quick search") - /// Recently searched - public static let recentlySearched = L10n.tr("Localizable", "search_view.section.title.recently_searched", fallback: "Recently searched") - /// Recently seen - public static let recentlySeen = L10n.tr("Localizable", "search_view.section.title.recently_seen", fallback: "Recently seen") - } - } - public enum Title { - /// Search - public static let search = L10n.tr("Localizable", "search_view.title.search", fallback: "Search") - } + /// Quick search + public static let quickSearch = L10n.tr("Localizable", "search_view.quick_search", fallback: "Quick search") + /// Recently searched + public static let recentlySearched = L10n.tr("Localizable", "search_view.recently_searched", fallback: "Recently searched") + /// Recently seen + public static let recentlySeen = L10n.tr("Localizable", "search_view.recently_seen", fallback: "Recently seen") + /// Search + public static let search = L10n.tr("Localizable", "search_view.search", fallback: "Search") } public enum Searchable { - public enum Prompt { - /// Filter - public static let filter = L10n.tr("Localizable", "searchable.prompt.filter", fallback: "Filter") - } - public enum Title { - /// Found %d matches. - public static func matchesCount(_ p1: Int) -> String { - return L10n.tr("Localizable", "searchable.title.matches_count", p1, fallback: "Found %d matches.") - } + /// Filter + public static let filter = L10n.tr("Localizable", "searchable.filter", fallback: "Filter") + /// Found %d matches. + public static func matchesCount(_ p1: Int) -> String { + return L10n.tr("Localizable", "searchable.matches_count", p1, fallback: "Found %d matches.") } } - public enum SettingView { - public enum Title { - /// Setting - public static let setting = L10n.tr("Localizable", "setting_view.title.setting", fallback: "Setting") - } + public enum SettingStateRoute { + /// About + public static let about = L10n.tr("Localizable", "setting_state_route.about", fallback: "About") + /// Account + public static let account = L10n.tr("Localizable", "setting_state_route.account", fallback: "Account") + /// Appearance + public static let appearance = L10n.tr("Localizable", "setting_state_route.appearance", fallback: "Appearance") + /// Download + public static let download = L10n.tr("Localizable", "setting_state_route.download", fallback: "Download") + /// General + public static let general = L10n.tr("Localizable", "setting_state_route.general", fallback: "General") + /// Laboratory + public static let laboratory = L10n.tr("Localizable", "setting_state_route.laboratory", fallback: "Laboratory") + /// Reading + public static let reading = L10n.tr("Localizable", "setting_state_route.reading", fallback: "Reading") } - public enum Struct { - public enum CookieValue { - public enum LocalizedString { - /// Expired - public static let expired = L10n.tr("Localizable", "struct.cookie_value.localized_string.expired", fallback: "Expired") - /// Rejected - public static let mystery = L10n.tr("Localizable", "struct.cookie_value.localized_string.mystery", fallback: "Rejected") - /// None - public static let `none` = L10n.tr("Localizable", "struct.cookie_value.localized_string.none", fallback: "None") - } - } - public enum DownloadBadge { - /// %d/%d - public static func progress(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "struct.download_badge.progress", p1, p2, fallback: "%d/%d") - } - public enum Text { - /// Downloaded - public static let downloaded = L10n.tr("Localizable", "struct.download_badge.text.downloaded", fallback: "Downloaded") - /// Downloading - public static let downloading = L10n.tr("Localizable", "struct.download_badge.text.downloading", fallback: "Downloading") - /// Needs Attention - public static let needsAttention = L10n.tr("Localizable", "struct.download_badge.text.needs_attention", fallback: "Needs Attention") - /// Needs Repair - public static let needsRepair = L10n.tr("Localizable", "struct.download_badge.text.needs_repair", fallback: "Needs Repair") - /// Paused - public static let paused = L10n.tr("Localizable", "struct.download_badge.text.paused", fallback: "Paused") - /// Queued - public static let queued = L10n.tr("Localizable", "struct.download_badge.text.queued", fallback: "Queued") - /// Update Available - public static let updateAvailable = L10n.tr("Localizable", "struct.download_badge.text.update_available", fallback: "Update Available") - } - } - public enum Greeting { - public enum Mark { - /// and - public static let and = L10n.tr("Localizable", "struct.greeting.mark.and", fallback: " and ") - /// ! - public static let end = L10n.tr("Localizable", "struct.greeting.mark.end", fallback: "!") - /// , - public static let separator = L10n.tr("Localizable", "struct.greeting.mark.separator", fallback: ", ") - /// You gain - public static let start = L10n.tr("Localizable", "struct.greeting.mark.start", fallback: "You gain ") - } - } - public enum HathArchive { - public enum Price { - /// Free - public static let free = L10n.tr("Localizable", "struct.hath_archive.price.free", fallback: "Free") - /// N/A - public static let notAvailable = L10n.tr("Localizable", "struct.hath_archive.price.not_available", fallback: "N/A") - } - } - public enum User { - public enum FavoriteCategory { - /// All - public static let all = L10n.tr("Localizable", "struct.user.favorite_category.all", fallback: "All") - /// Favorites %@ - public static func `default`(_ p1: Any) -> String { - return L10n.tr("Localizable", "struct.user.favorite_category.default", String(describing: p1), fallback: "Favorites %@") - } - } - } + public enum SettingView { + /// Setting + public static let setting = L10n.tr("Localizable", "setting_view.setting", fallback: "Setting") } public enum SubSection { - public enum Button { - /// Show all - public static let showAll = L10n.tr("Localizable", "sub_section.button.show_all", fallback: "Show all") - } + /// Show all + public static let showAll = L10n.tr("Localizable", "sub_section.show_all", fallback: "Show all") } public enum TabItem { - public enum Title { - /// Downloads - public static let downloads = L10n.tr("Localizable", "tab_item.title.downloads", fallback: "Downloads") - /// Favorites - public static let favorites = L10n.tr("Localizable", "tab_item.title.favorites", fallback: "Favorites") - /// Home - public static let home = L10n.tr("Localizable", "tab_item.title.home", fallback: "Home") - /// Search - public static let search = L10n.tr("Localizable", "tab_item.title.search", fallback: "Search") - /// Setting - public static let setting = L10n.tr("Localizable", "tab_item.title.setting", fallback: "Setting") - } + /// Downloads + public static let downloads = L10n.tr("Localizable", "tab_item.downloads", fallback: "Downloads") + /// Favorites + public static let favorites = L10n.tr("Localizable", "tab_item.favorites", fallback: "Favorites") + /// Home + public static let home = L10n.tr("Localizable", "tab_item.home", fallback: "Home") + /// Search + public static let search = L10n.tr("Localizable", "tab_item.search", fallback: "Search") + /// Setting + public static let setting = L10n.tr("Localizable", "tab_item.setting", fallback: "Setting") } public enum TagDetailView { - public enum Section { - public enum Title { - /// Images - public static let images = L10n.tr("Localizable", "tag_detail_view.section.title.images", fallback: "Images") - /// Links - public static let links = L10n.tr("Localizable", "tag_detail_view.section.title.links", fallback: "Links") - } - } + /// Images + public static let images = L10n.tr("Localizable", "tag_detail_view.images", fallback: "Images") + /// Links + public static let links = L10n.tr("Localizable", "tag_detail_view.links", fallback: "Links") + } + public enum TagNamespace { + /// Artist + public static let artist = L10n.tr("Localizable", "tag_namespace.artist", fallback: "Artist") + /// Character + public static let character = L10n.tr("Localizable", "tag_namespace.character", fallback: "Character") + /// Cosplayer + public static let cosplayer = L10n.tr("Localizable", "tag_namespace.cosplayer", fallback: "Cosplayer") + /// Female + public static let female = L10n.tr("Localizable", "tag_namespace.female", fallback: "Female") + /// Group + public static let group = L10n.tr("Localizable", "tag_namespace.group", fallback: "Group") + /// Language + public static let language = L10n.tr("Localizable", "tag_namespace.language", fallback: "Language") + /// Male + public static let male = L10n.tr("Localizable", "tag_namespace.male", fallback: "Male") + /// Mixed + public static let mixed = L10n.tr("Localizable", "tag_namespace.mixed", fallback: "Mixed") + /// Other + public static let other = L10n.tr("Localizable", "tag_namespace.other", fallback: "Other") + /// Parody + public static let parody = L10n.tr("Localizable", "tag_namespace.parody", fallback: "Parody") + /// Reclass + public static let reclass = L10n.tr("Localizable", "tag_namespace.reclass", fallback: "Reclass") + /// Temp + public static let temp = L10n.tr("Localizable", "tag_namespace.temp", fallback: "Temp") + } + public enum TagsSortOrder { + /// Alphabetical + public static let alphabetical = L10n.tr("Localizable", "tags_sort_order.alphabetical", fallback: "Alphabetical") + /// By tag power + public static let tagPower = L10n.tr("Localizable", "tags_sort_order.tag_power", fallback: "By tag power") + } + public enum ThumbnailLoadTiming { + /// On mouse-over + public static let onMouseOver = L10n.tr("Localizable", "thumbnail_load_timing.on_mouse_over", fallback: "On mouse-over") + /// Pages load faster, but there may be a slight delay before a thumb appears. + public static let onMouseOverDescription = L10n.tr("Localizable", "thumbnail_load_timing.on_mouse_over_description", fallback: "Pages load faster, but there may be a slight delay before a thumb appears.") + /// On page load + public static let onPageLoad = L10n.tr("Localizable", "thumbnail_load_timing.on_page_load", fallback: "On page load") + /// Pages take longer to load, but there is no delay for loading a thumb after the page has loaded. + public static let onPageLoadDescription = L10n.tr("Localizable", "thumbnail_load_timing.on_page_load_description", fallback: "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded.") + } + public enum ThumbnailSize { + /// Auto + public static let auto = L10n.tr("Localizable", "thumbnail_size.auto", fallback: "Auto") + /// Large + public static let large = L10n.tr("Localizable", "thumbnail_size.large", fallback: "Large") + /// Normal + public static let normal = L10n.tr("Localizable", "thumbnail_size.normal", fallback: "Normal") + /// Small + public static let small = L10n.tr("Localizable", "thumbnail_size.small", fallback: "Small") } public enum Toast { - public enum Caption { - /// Copied to clipboard - public static let copiedToClipboard = L10n.tr("Localizable", "toast.caption.copied_to_clipboard", fallback: "Copied to clipboard") - /// Saved to photo library - public static let savedToPhotoLibrary = L10n.tr("Localizable", "toast.caption.saved_to_photo_library", fallback: "Saved to photo library") - } - public enum Title { - /// Communicating... - public static let communicating = L10n.tr("Localizable", "toast.title.communicating", fallback: "Communicating...") - /// Error - public static let error = L10n.tr("Localizable", "toast.title.error", fallback: "Error") - /// Loading... - public static let loading = L10n.tr("Localizable", "toast.title.loading", fallback: "Loading...") - /// Success - public static let success = L10n.tr("Localizable", "toast.title.success", fallback: "Success") - } + /// Communicating... + public static let communicating = L10n.tr("Localizable", "toast.communicating", fallback: "Communicating...") + /// Copied to clipboard + public static let copiedToClipboard = L10n.tr("Localizable", "toast.copied_to_clipboard", fallback: "Copied to clipboard") + /// Error + public static let error = L10n.tr("Localizable", "toast.error", fallback: "Error") + /// Loading... + public static let loading = L10n.tr("Localizable", "toast.loading", fallback: "Loading...") + /// Saved to photo library + public static let savedToPhotoLibrary = L10n.tr("Localizable", "toast.saved_to_photo_library", fallback: "Saved to photo library") + /// Success + public static let success = L10n.tr("Localizable", "toast.success", fallback: "Success") } public enum ToolbarItem { - public enum Button { - /// Seek to date - public static let dateSeek = L10n.tr("Localizable", "toolbar_item.button.date_seek", fallback: "Seek to date") - /// Filters - public static let filters = L10n.tr("Localizable", "toolbar_item.button.filters", fallback: "Filters") - /// Jump page - public static let jumpPage = L10n.tr("Localizable", "toolbar_item.button.jump_page", fallback: "Jump page") - /// Quick search - public static let quickSearch = L10n.tr("Localizable", "toolbar_item.button.quick_search", fallback: "Quick search") - } + /// Seek to date + public static let dateSeek = L10n.tr("Localizable", "toolbar_item.date_seek", fallback: "Seek to date") + /// Filters + public static let filters = L10n.tr("Localizable", "toolbar_item.filters", fallback: "Filters") + /// Jump page + public static let jumpPage = L10n.tr("Localizable", "toolbar_item.jump_page", fallback: "Jump page") + /// Quick search + public static let quickSearch = L10n.tr("Localizable", "toolbar_item.quick_search", fallback: "Quick search") + } + public enum ToplistsType { + /// All time + public static let allTime = L10n.tr("Localizable", "toplists_type.all_time", fallback: "All time") + /// Past month + public static let pastMonth = L10n.tr("Localizable", "toplists_type.past_month", fallback: "Past month") + /// Past year + public static let pastYear = L10n.tr("Localizable", "toplists_type.past_year", fallback: "Past year") + /// Yesterday + public static let yesterday = L10n.tr("Localizable", "toplists_type.yesterday", fallback: "Yesterday") } public enum ToplistsView { - public enum Title { - /// Toplists - public static let toplists = L10n.tr("Localizable", "toplists_view.title.toplists", fallback: "Toplists") - } + /// Toplists + public static let toplists = L10n.tr("Localizable", "toplists_view.toplists", fallback: "Toplists") } public enum TorrentsView { - public enum Title { - /// Torrents - public static let torrents = L10n.tr("Localizable", "torrents_view.title.torrents", fallback: "Torrents") - } + /// Torrents + public static let torrents = L10n.tr("Localizable", "torrents_view.torrents", fallback: "Torrents") } public enum WatchedView { - public enum Title { - /// Watched - public static let watched = L10n.tr("Localizable", "watched_view.title.watched", fallback: "Watched") - } - } - public enum Website { - public enum Response { - /// You must have a H@H client assigned to your account to use this feature. - public static let hathClientNotFound = L10n.tr("Localizable", "website.response.hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") - /// Your H@H client appears to be offline. Turn it on, then try again. - public static let hathClientNotOnline = L10n.tr("Localizable", "website.response.hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") - /// The requested gallery cannot be downloaded with the selected resolution. - public static let invalidResolution = L10n.tr("Localizable", "website.response.invalid_resolution", fallback: "The requested gallery cannot be downloaded with the selected resolution.") - } + /// Watched + public static let watched = L10n.tr("Localizable", "watched_view.watched", fallback: "Watched") } } } diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index 99604cf99..be8bad835 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -77,7 +77,7 @@ public struct SearchRootView: View { store.send(.fetchDatabaseInfos) } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.SearchView.Title.search) + .navigationTitle(L10n.Localizable.SearchView.search) // Workaround: Prevent the title disappearing issue. if store.historyKeywords.isEmpty && store.historyGalleries.isEmpty { @@ -204,7 +204,7 @@ private struct QuickSearchWordsSection: View { var body: some View { SubSection( - title: L10n.Localizable.SearchView.Section.Title.quickSearch, + title: L10n.Localizable.SearchView.quickSearch, showAll: true, tint: .primary, showAllAction: showAllAction ) { DoubleVerticalKeywordsStack(keywords: keywords, searchAction: searchAction) @@ -225,7 +225,7 @@ private struct HistoryKeywordsSection: View { } var body: some View { - SubSection(title: L10n.Localizable.SearchView.Section.Title.recentlySearched, showAll: false) { + SubSection(title: L10n.Localizable.SearchView.recentlySearched, showAll: false) { DoubleVerticalKeywordsStack( keywords: keywords.map(WrappedKeyword.init), searchAction: searchAction, @@ -246,7 +246,7 @@ private struct HistoryGalleriesSection: View { } var body: some View { - SubSection(title: L10n.Localizable.SearchView.Section.Title.recentlySeen, showAll: false) { + SubSection(title: L10n.Localizable.SearchView.recentlySeen, showAll: false) { ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(galleries) { gallery in diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index b797a107e..d2ed8e14b 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -87,13 +87,13 @@ public struct AccountSettingReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmLogout) { - TextState(L10n.Localizable.ConfirmationDialog.Button.logout) + TextState(L10n.Localizable.ConfirmationDialog.logout) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.Title.logout) + TextState(L10n.Localizable.ConfirmationDialog.logoutDescription) } return .none diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index accc16a60..aac69e31f 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -60,7 +60,7 @@ struct AccountSettingView: View { .autoBlur(radius: blurRadius) } .onAppear { store.send(.loadCookies) } - .navigationTitle(L10n.Localizable.AccountSettingView.Title.account) + .navigationTitle(L10n.Localizable.AccountSettingView.account) } } @@ -95,27 +95,27 @@ private struct AccountSection: View { var body: some View { if !CookieUtil.didLogin { - Button(L10n.Localizable.AccountSettingView.Button.login, action: loginAction) + Button(L10n.Localizable.AccountSettingView.login, action: loginAction) } else { Button( - L10n.Localizable.ConfirmationDialog.Button.logout, + L10n.Localizable.ConfirmationDialog.logout, role: .destructive, action: logoutDialogAction ) .confirmationDialog(logoutConfirmationDialog) Group { Button( - L10n.Localizable.AccountSettingView.Button.accountConfiguration, + L10n.Localizable.AccountSettingView.accountConfiguration, action: configureAccountAction ) .withArrow() if !bypassesSNIFiltering { Button( - L10n.Localizable.AccountSettingView.Button.tagsManagement, + L10n.Localizable.AccountSettingView.tagsManagement, action: manageTagsAction ) .withArrow() } - Toggle(L10n.Localizable.AccountSettingView.Title.showsNewDawnGreeting, isOn: $showsNewDawnGreeting) + Toggle(L10n.Localizable.AccountSettingView.showsNewDawnGreeting, isOn: $showsNewDawnGreeting) } .foregroundColor(.primary) } @@ -142,7 +142,7 @@ private struct CookieSection: View { Section(GalleryHost.ehentai.rawValue) { CookieRow(cookieState: $ehCookiesState.memberID) CookieRow(cookieState: $ehCookiesState.passHash) - Button(L10n.Localizable.AccountSettingView.Button.copyCookies) { + Button(L10n.Localizable.AccountSettingView.copyCookies) { copyAction(.ehentai) } .foregroundStyle(.tint).font(.subheadline) @@ -151,7 +151,7 @@ private struct CookieSection: View { CookieRow(cookieState: $exCookiesState.igneous) CookieRow(cookieState: $exCookiesState.memberID) CookieRow(cookieState: $exCookiesState.passHash) - Button(L10n.Localizable.AccountSettingView.Button.copyCookies) { + Button(L10n.Localizable.AccountSettingView.copyCookies) { copyAction(.exhentai) } .foregroundStyle(.tint).font(.subheadline) diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift index d51a11a67..140e9acfe 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -26,7 +26,7 @@ struct AppActivityLogsView: View { LoadingView() .opacity(store.loadingState == .loading && store.displayedLogs.isEmpty ? 1 : 0) - Text(L10n.Localizable.AppActivityLogsView.Placeholder.noLogs) + Text(L10n.Localizable.AppActivityLogsView.noLogs) .foregroundColor(.secondary) .opacity(store.loadingState != .loading && store.displayedLogs.isEmpty ? 1 : 0) } @@ -70,7 +70,7 @@ struct AppActivityLogsView: View { @ViewBuilder private var runMenu: some View { - Section(L10n.Localizable.AppActivityLogsView.Section.current) { + Section(L10n.Localizable.AppActivityLogsView.current) { RunButton( run: store.currentRun, isSelected: store.selectedRun == nil @@ -111,7 +111,7 @@ private struct RunPickerSheet: View { var body: some View { NavigationStack { List { - Section(L10n.Localizable.AppActivityLogsView.Section.current) { + Section(L10n.Localizable.AppActivityLogsView.current) { RunButton( run: store.currentRun, isSelected: store.selectedRun == nil @@ -167,7 +167,7 @@ private struct RunButton: View { // A nil run is the current run before its count is resolved; fall back to "Current". private func runLabel(_ run: RunLogFile?) -> String { guard let run else { - return L10n.Localizable.AppActivityLogsView.Section.current + return L10n.Localizable.AppActivityLogsView.current } let title = L10n.Localizable.AppActivityLogsView.run("\(run.runCount)") return "\(title) (\(runTimeFormatter.string(from: run.date)))" diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift index f1321a679..5f81ea1e3 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -39,7 +39,7 @@ struct AppearanceSettingView: View { Form { Section { Picker( - L10n.Localizable.AppearanceSettingView.Title.theme, + L10n.Localizable.AppearanceSettingView.theme, selection: $preferredColorScheme ) { ForEach(PreferredColorScheme.allCases) { colorScheme in @@ -49,17 +49,17 @@ struct AppearanceSettingView: View { } .pickerStyle(.menu) - ColorPicker(L10n.Localizable.AppearanceSettingView.Title.tintColor, selection: $accentColor) + ColorPicker(L10n.Localizable.AppearanceSettingView.tintColor, selection: $accentColor) - Button(L10n.Localizable.AppearanceSettingView.Button.appIcon) { + Button(L10n.Localizable.AppearanceSettingView.appIcon) { store.send(.delegate(.pushAppIcon)) } .foregroundStyle(.primary) .withArrow() } - Section(L10n.Localizable.AppearanceSettingView.Section.Title.list) { + Section(L10n.Localizable.AppearanceSettingView.list) { Picker( - L10n.Localizable.AppearanceSettingView.Title.displayMode, + L10n.Localizable.AppearanceSettingView.displayMode, selection: $listDisplayMode, content: { ForEach(ListDisplayMode.allCases) { listMode in @@ -71,14 +71,14 @@ struct AppearanceSettingView: View { .pickerStyle(.menu) Toggle(isOn: $showsTagsInList) { - Text(L10n.Localizable.AppearanceSettingView.Title.showsTagsInList) + Text(L10n.Localizable.AppearanceSettingView.showsTagsInList) } Picker( - L10n.Localizable.AppearanceSettingView.Title.maximumNumberOfTags, + L10n.Localizable.AppearanceSettingView.maximumNumberOfTags, selection: $listTagsNumberMaximum ) { - Text(L10n.Localizable.AppearanceSettingView.Menu.Title.infite) + Text(L10n.Localizable.AppearanceSettingView.infite) .tag(0) ForEach(Array(stride(from: 5, through: 20, by: 5)), id: \.self) { num in @@ -89,14 +89,14 @@ struct AppearanceSettingView: View { .pickerStyle(.menu) .disabled(!showsTagsInList) } - Section(L10n.Localizable.AppearanceSettingView.Section.Title.gallery) { + Section(L10n.Localizable.AppearanceSettingView.gallery) { Toggle( - L10n.Localizable.AppearanceSettingView.Title.displaysJapaneseTitle, + L10n.Localizable.AppearanceSettingView.displaysJapaneseTitle, isOn: $displaysJapaneseTitle ) } } - .navigationTitle(L10n.Localizable.AppearanceSettingView.Title.appearance) + .navigationTitle(L10n.Localizable.AppearanceSettingView.appearance) } } @@ -122,7 +122,7 @@ struct AppIconView: View { } } } - .navigationTitle(L10n.Localizable.AppIconView.Title.appIcon) + .navigationTitle(L10n.Localizable.AppIconView.appIcon) } } diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index ec0fa6311..04dcca661 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -7,7 +7,7 @@ import AppComponents struct AboutView: View { private var version: String { [ - L10n.Localizable.AboutView.Title.version, + L10n.Localizable.AboutView.version, AppUtil.version, "(\(AppUtil.build))" ] .joined(separator: " ") @@ -20,32 +20,32 @@ struct AboutView: View { LinkRow(urlString: contact.urlString, text: contact.text) } } - Section(L10n.Localizable.AboutView.Section.Title.specialThanks) { + Section(L10n.Localizable.AboutView.specialThanks) { ForEach(specialThanks) { specialThank in LinkRow(urlString: specialThank.urlString, text: specialThank.text) } } - Section(L10n.Localizable.AboutView.Section.Title.codeLevelContributors) { + Section(L10n.Localizable.AboutView.codeLevelContributors) { ForEach(codeLevelContributors) { codeLevelContributor in LinkRow(urlString: codeLevelContributor.urlString, text: codeLevelContributor.text) } } - Section(L10n.Localizable.AboutView.Section.Title.translationContributors) { + Section(L10n.Localizable.AboutView.translationContributors) { ForEach(translationContributors) { translationContributor in LinkRow(urlString: translationContributor.urlString, text: translationContributor.text) } } - Section(L10n.Localizable.AboutView.Section.Title.acknowledgements) { + Section(L10n.Localizable.AboutView.acknowledgements) { ForEach(acknowledgements) { acknowledgement in LinkRow(urlString: acknowledgement.urlString, text: acknowledgement.text) } } } - .navigationTitle(L10n.Localizable.AboutView.Title.ehPanda) + .navigationTitle(L10n.Localizable.AboutView.ehPanda) .toolbar { ToolbarItem(placement: .largeSubtitle) { VStack(alignment: .leading) { - Text(L10n.Constant.App.copyright) + Text(L10n.Constant.copyright) Text(version) } .foregroundStyle(.gray) @@ -59,144 +59,144 @@ struct AboutView: View { // MARK: Contacts private let contacts: [Info] = {[ .init( - urlString: L10n.Constant.App.Contact.Link.website, - text: L10n.Localizable.AboutView.Button.website + urlString: L10n.Constant.Contact.website, + text: L10n.Localizable.AboutView.website ), .init( - urlString: L10n.Constant.App.Contact.Link.gitHub, - text: L10n.Constant.App.Contact.Text.gitHub + urlString: L10n.Constant.Contact.gitHub, + text: L10n.Constant.Contact.gitHubLink ), .init( - urlString: L10n.Constant.App.Contact.Link.discord, - text: L10n.Constant.App.Contact.Text.discord + urlString: L10n.Constant.Contact.discord, + text: L10n.Constant.Contact.discordLink ), .init( - urlString: L10n.Constant.App.Contact.Link.telegram, - text: L10n.Constant.App.Contact.Text.telegram + urlString: L10n.Constant.Contact.telegram, + text: L10n.Constant.Contact.telegramLink ), .init( - urlString: L10n.Constant.App.Contact.Link.altStore, - text: L10n.Localizable.AboutView.Button.altStoreSource + urlString: L10n.Constant.Contact.altStoreLink, + text: L10n.Localizable.AboutView.altStoreSource ) ]}() // MARK: Special thanks private let specialThanks: [Info] = {[ .init( - urlString: L10n.Constant.App.SpecialThanks.Link.taylorlannister, - text: L10n.Constant.App.SpecialThanks.Text.taylorlannister + urlString: L10n.Constant.SpecialThanks.taylorlannisterLink, + text: L10n.Constant.SpecialThanks.taylorlannister ), .init( - urlString: L10n.Constant.App.SpecialThanks.Link.luminescentYq, - text: L10n.Constant.App.SpecialThanks.Text.luminescentYq + urlString: L10n.Constant.SpecialThanks.luminescentYqLink, + text: L10n.Constant.SpecialThanks.luminescentYq ), .init( - urlString: L10n.Constant.App.SpecialThanks.Link.caxerx, - text: L10n.Constant.App.SpecialThanks.Text.caxerx + urlString: L10n.Constant.SpecialThanks.caxerxLink, + text: L10n.Constant.SpecialThanks.caxerx ), .init( - urlString: L10n.Constant.App.SpecialThanks.Link.honjow, - text: L10n.Constant.App.SpecialThanks.Text.honjow + urlString: L10n.Constant.SpecialThanks.honjowLink, + text: L10n.Constant.SpecialThanks.honjow ) ]}() // MARK: Code level contributors private let codeLevelContributors: [Info] = {[ .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.vvbbnn00, - text: L10n.Constant.App.CodeLevelContributor.Text.vvbbnn00 + urlString: L10n.Constant.CodeLevelContributor.vvbbnn00Link, + text: L10n.Constant.CodeLevelContributor.vvbbnn00 ), .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.kaed3mi, - text: L10n.Constant.App.CodeLevelContributor.Text.kaed3mi + urlString: L10n.Constant.CodeLevelContributor.kaed3miLink, + text: L10n.Constant.CodeLevelContributor.kaed3mi ), .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.aalberrty, - text: L10n.Constant.App.CodeLevelContributor.Text.aalberrty + urlString: L10n.Constant.CodeLevelContributor.aalberrtyLink, + text: L10n.Constant.CodeLevelContributor.aalberrty ), .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.jimmyPrime, - text: L10n.Constant.App.CodeLevelContributor.Text.jimmyPrime + urlString: L10n.Constant.CodeLevelContributor.jimmyPrimeLink, + text: L10n.Constant.CodeLevelContributor.jimmyPrime ), .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.xioxin, - text: L10n.Constant.App.CodeLevelContributor.Text.xioxin + urlString: L10n.Constant.CodeLevelContributor.xioxinLink, + text: L10n.Constant.CodeLevelContributor.xioxin ) ]}() // MARK: Translation contributors private let translationContributors: [Info] = {[ .init( - urlString: L10n.Constant.App.TranslationContributor.Link.nebulosaCat, - text: L10n.Constant.App.TranslationContributor.Text.nebulosaCat + urlString: L10n.Constant.TranslationContributor.nebulosaCatLink, + text: L10n.Constant.TranslationContributor.nebulosaCat ), .init( - urlString: L10n.Constant.App.TranslationContributor.Link.paulHaeussler, - text: L10n.Constant.App.TranslationContributor.Text.paulHaeussler + urlString: L10n.Constant.TranslationContributor.paulHaeusslerLink, + text: L10n.Constant.TranslationContributor.paulHaeussler ), .init( - urlString: L10n.Constant.App.TranslationContributor.Link.caxerx, - text: L10n.Constant.App.TranslationContributor.Text.caxerx + urlString: L10n.Constant.TranslationContributor.caxerxLink, + text: L10n.Constant.TranslationContributor.caxerx ), .init( - urlString: L10n.Constant.App.TranslationContributor.Link.neKoOuO, - text: L10n.Constant.App.TranslationContributor.Text.neKoOuO + urlString: L10n.Constant.TranslationContributor.neKoOuOLink, + text: L10n.Constant.TranslationContributor.neKoOuO ) ]}() // MARK: Acknowledgements private let acknowledgements: [Info] = {[ .init( - urlString: L10n.Constant.App.Acknowledgement.Link.kanna, - text: L10n.Constant.App.Acknowledgement.Text.kanna + urlString: L10n.Constant.Acknowledgement.kannaLink, + text: L10n.Constant.Acknowledgement.kanna ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.colorful, - text: L10n.Constant.App.Acknowledgement.Text.colorful + urlString: L10n.Constant.Acknowledgement.colorfulLink, + text: L10n.Constant.Acknowledgement.colorful ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftGen, - text: L10n.Constant.App.Acknowledgement.Text.swiftGen + urlString: L10n.Constant.Acknowledgement.swiftGenLink, + text: L10n.Constant.Acknowledgement.swiftGen ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.kingfisher, - text: L10n.Constant.App.Acknowledgement.Text.kingfisher + urlString: L10n.Constant.Acknowledgement.kingfisherLink, + text: L10n.Constant.Acknowledgement.kingfisher ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftUIPager, - text: L10n.Constant.App.Acknowledgement.Text.swiftUIPager + urlString: L10n.Constant.Acknowledgement.swiftUIPagerLink, + text: L10n.Constant.Acknowledgement.swiftUIPager ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.waterfallGrid, - text: L10n.Constant.App.Acknowledgement.Text.waterfallGrid + urlString: L10n.Constant.Acknowledgement.waterfallGridLink, + text: L10n.Constant.Acknowledgement.waterfallGrid ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftyOpenCC, - text: L10n.Constant.App.Acknowledgement.Text.swiftyOpenCC + urlString: L10n.Constant.Acknowledgement.swiftyOpenCCLink, + text: L10n.Constant.Acknowledgement.swiftyOpenCC ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.uiImageColors, - text: L10n.Constant.App.Acknowledgement.Text.uiImageColors + urlString: L10n.Constant.Acknowledgement.uiImageColorsLink, + text: L10n.Constant.Acknowledgement.uiImageColors ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.sfSafeSymbols, - text: L10n.Constant.App.Acknowledgement.Text.sfSafeSymbols + urlString: L10n.Constant.Acknowledgement.sfSafeSymbolsLink, + text: L10n.Constant.Acknowledgement.sfSafeSymbols ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.systemNotification, - text: L10n.Constant.App.Acknowledgement.Text.systemNotification + urlString: L10n.Constant.Acknowledgement.systemNotificationLink, + text: L10n.Constant.Acknowledgement.systemNotification ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftCommonMark, - text: L10n.Constant.App.Acknowledgement.Text.swiftCommonMark + urlString: L10n.Constant.Acknowledgement.swiftCommonMarkLink, + text: L10n.Constant.Acknowledgement.swiftCommonMark ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.ehTagTranslationDatabase, - text: L10n.Constant.App.Acknowledgement.Text.ehTagTranslationDatabase + urlString: L10n.Constant.Acknowledgement.ehTagTranslationDatabaseLink, + text: L10n.Constant.Acknowledgement.ehTagTranslationDatabase ), .init( - urlString: L10n.Constant.App.Acknowledgement.Link.tca, - text: L10n.Constant.App.Acknowledgement.Text.tca + urlString: L10n.Constant.Acknowledgement.tcaLink, + text: L10n.Constant.Acknowledgement.tca ) ]}() } diff --git a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift index 7664c339b..e4f18771f 100644 --- a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift @@ -20,27 +20,27 @@ struct DownloadSettingView: View { Form { Section { VStack(alignment: .leading) { - LabeledContent(L10n.Localizable.DownloadSettingView.Title.concurrentImageDownloads) { + LabeledContent(L10n.Localizable.DownloadSettingView.concurrentImageDownloads) { Text(downloadThreadLimit, format: .number) .monospacedDigit() } Slider(value: downloadThreadLimitValue, in: 1...5, step: 1) } Toggle( - L10n.Localizable.DownloadSettingView.Title.retryFailedPagesAutomatically, + L10n.Localizable.DownloadSettingView.retryFailedPagesAutomatically, isOn: $downloadAutoRetryFailedPages ) } Section { Toggle( - L10n.Localizable.DownloadSettingView.Title.allowCellularDownloads, + L10n.Localizable.DownloadSettingView.allowCellularDownloads, isOn: $downloadAllowCellular ) } header: { - Text(L10n.Localizable.DownloadSettingView.Section.Title.network) + Text(L10n.Localizable.DownloadSettingView.network) } footer: { - Text(L10n.Localizable.DownloadSettingView.Footer.network) + Text(L10n.Localizable.DownloadSettingView.networkDescription) } } .navigationTitle(L10n.Localizable.DownloadSettingView.title) diff --git a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift index bec390b4f..480c943ba 100644 --- a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift @@ -15,13 +15,13 @@ struct LaboratorySettingView: View { VStack { LaboratoryCell( isOn: $bypassesSNIFiltering, - title: L10n.Localizable.LaboratorySettingView.Title.bypassesSNIFiltering, + title: L10n.Localizable.LaboratorySettingView.bypassesSNIFiltering, symbol: .theatermasksFill, tintColor: .purple ) } .padding() } - .navigationTitle(L10n.Localizable.LaboratorySettingView.Title.laboratory) + .navigationTitle(L10n.Localizable.LaboratorySettingView.laboratory) } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index 7c348a4c3..3034514de 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -86,13 +86,13 @@ public struct EhSettingReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmDeleteProfile) { - TextState(L10n.Localizable.ConfirmationDialog.Button.delete) + TextState(L10n.Localizable.ConfirmationDialog.delete) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.Title.delete) + TextState(L10n.Localizable.ConfirmationDialog.deleteDescription) } return .none diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift index 0f74753f3..67a80377a 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -21,7 +21,7 @@ struct EhProfileSection: View { var body: some View { Section { - Picker(L10n.Localizable.EhSettingView.Title.selectedProfile, selection: $ehProfile) { + Picker(L10n.Localizable.EhSettingView.selectedProfile, selection: $ehProfile) { ForEach(ehSetting.ehProfiles) { ehProfile in Text(ehProfile.name) .tag(ehProfile) @@ -30,19 +30,19 @@ struct EhProfileSection: View { .pickerStyle(.menu) if !ehProfile.isDefault { - Button(L10n.Localizable.EhSettingView.Button.setAsDefault) { + Button(L10n.Localizable.EhSettingView.setAsDefault) { performEhProfileAction(.default, nil, ehProfile.value) } Button( - L10n.Localizable.EhSettingView.Button.deleteProfile, + L10n.Localizable.EhSettingView.deleteProfile, role: .destructive, action: deleteDialogAction ) .confirmationDialog(deleteConfirmationDialog) } } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.profileSettings) + Text(L10n.Localizable.EhSettingView.profileSettings) .ehSettingRegularHeaderStyled() } .onChange(of: ehProfile) { _, newValue in @@ -53,13 +53,13 @@ struct EhProfileSection: View { SettingTextField(text: $editingProfileName, width: nil, alignment: .leading, background: .clear) .focused($isFocused) - Button(L10n.Localizable.EhSettingView.Button.rename) { + Button(L10n.Localizable.EhSettingView.rename) { performEhProfileAction(.rename, editingProfileName, ehProfile.value) } .disabled(isFocused) if ehSetting.isCapableOfCreatingNewProfile { - Button(L10n.Localizable.EhSettingView.Button.createNew) { + Button(L10n.Localizable.EhSettingView.createNew) { performEhProfileAction(.create, editingProfileName, ehProfile.value) } .disabled(isFocused) @@ -75,7 +75,7 @@ struct ImageLoadSettingsSection: View { var body: some View { Section { Picker( - L10n.Localizable.EhSettingView.Title.loadImagesThroughTheHathNetwork, + L10n.Localizable.EhSettingView.loadImagesThroughTheHathNetwork, selection: $ehSetting.loadThroughHathSetting ) { ForEach(ehSetting.capableLoadThroughHathSettings) { setting in @@ -85,13 +85,13 @@ struct ImageLoadSettingsSection: View { } .pickerStyle(.menu) } header: { - Text.ehSettingBoldHeader(L10n.Localizable.EhSettingView.Section.Title.imageLoadSettings) + Text.ehSettingBoldHeader(L10n.Localizable.EhSettingView.imageLoadSettings) } footer: { Text(ehSetting.loadThroughHathSetting.description) } Section { - Picker(L10n.Localizable.EhSettingView.Title.browsingCountry, selection: $ehSetting.browsingCountry) { + Picker(L10n.Localizable.EhSettingView.browsingCountry, selection: $ehSetting.browsingCountry) { ForEach(EhSetting.BrowsingCountry.allCases) { country in Text(country.name) .tag(country) @@ -100,7 +100,7 @@ struct ImageLoadSettingsSection: View { } } header: { Text( - L10n.Localizable.EhSettingView.Description.browsingCountry( + L10n.Localizable.EhSettingView.browsingCountryDescription( ehSetting.localizedLiteralBrowsingCountry ?? ehSetting.literalBrowsingCountry ) .localizedKey @@ -116,7 +116,7 @@ struct ImageSizeSettingsSection: View { var body: some View { Section { - Picker(L10n.Localizable.EhSettingView.Title.imageResolution, selection: $ehSetting.imageResolution) { + Picker(L10n.Localizable.EhSettingView.imageResolution, selection: $ehSetting.imageResolution) { ForEach(ehSetting.capableImageResolutions) { setting in Text(setting.value) .tag(setting) @@ -125,37 +125,37 @@ struct ImageSizeSettingsSection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.imageSizeSettings, - description: L10n.Localizable.EhSettingView.Description.imageResolution + L10n.Localizable.EhSettingView.imageSizeSettings, + description: L10n.Localizable.EhSettingView.imageResolutionDescription ) } if let useOriginalImagesBinding = Binding($ehSetting.useOriginalImages) { Section { Toggle( - L10n.Localizable.EhSettingView.Title.useOriginalImages, + L10n.Localizable.EhSettingView.useOriginalImages, isOn: useOriginalImagesBinding ) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.originalImages) + Text(L10n.Localizable.EhSettingView.originalImages) .ehSettingRegularHeaderStyled() } } Section { - Text(L10n.Localizable.EhSettingView.Title.imageSize) + Text(L10n.Localizable.EhSettingView.imageSize) ValuePicker( - title: L10n.Localizable.EhSettingView.Title.horizontal, + title: L10n.Localizable.EhSettingView.horizontal, value: $ehSetting.imageSizeWidth, range: 0...65535, unit: "px" ) ValuePicker( - title: L10n.Localizable.EhSettingView.Title.vertical, + title: L10n.Localizable.EhSettingView.vertical, value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px" ) } header: { - Text(L10n.Localizable.EhSettingView.Description.imageSize) + Text(L10n.Localizable.EhSettingView.imageSizeDescription) .ehSettingRegularHeaderStyled() } } @@ -167,7 +167,7 @@ struct GalleryNameDisplaySection: View { var body: some View { Section { - Picker(L10n.Localizable.EhSettingView.Title.galleryName, selection: $ehSetting.galleryName) { + Picker(L10n.Localizable.EhSettingView.galleryName, selection: $ehSetting.galleryName) { ForEach(EhSetting.GalleryName.allCases) { name in Text(name.value) .tag(name) @@ -176,8 +176,8 @@ struct GalleryNameDisplaySection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.galleryNameDisplay, - description: L10n.Localizable.EhSettingView.Description.galleryName + L10n.Localizable.EhSettingView.galleryNameDisplay, + description: L10n.Localizable.EhSettingView.galleryNameDescription ) } } @@ -189,7 +189,7 @@ struct ArchiverSettingsSection: View { var body: some View { Section { - Picker(L10n.Localizable.EhSettingView.Title.archiverBehavior, selection: $ehSetting.archiverBehavior) { + Picker(L10n.Localizable.EhSettingView.archiverBehavior, selection: $ehSetting.archiverBehavior) { ForEach(EhSetting.ArchiverBehavior.allCases) { behavior in Text(behavior.value) .tag(behavior) @@ -198,8 +198,8 @@ struct ArchiverSettingsSection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.archiverSettings, - description: L10n.Localizable.EhSettingView.Description.archiverBehavior + L10n.Localizable.EhSettingView.archiverSettings, + description: L10n.Localizable.EhSettingView.archiverBehaviorDescription ) } } @@ -218,13 +218,13 @@ struct FrontPageSettingsSection: View { CategoryView(bindings: categoryBindings) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.frontPageSettings, - description: L10n.Localizable.EhSettingView.Description.galleryCategory + L10n.Localizable.EhSettingView.frontPageSettings, + description: L10n.Localizable.EhSettingView.galleryCategory ) } Section { - Picker(L10n.Localizable.EhSettingView.Title.displayMode, selection: $ehSetting.displayMode) { + Picker(L10n.Localizable.EhSettingView.displayMode, selection: $ehSetting.displayMode) { ForEach(EhSetting.DisplayMode.allCases) { mode in Text(mode.value) .tag(mode) @@ -232,17 +232,17 @@ struct FrontPageSettingsSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.Description.displayMode) + Text(L10n.Localizable.EhSettingView.displayModeDescription) .ehSettingRegularHeaderStyled() } Section { Toggle( - L10n.Localizable.EhSettingView.Title.showSearchRangeIndicator, + L10n.Localizable.EhSettingView.showSearchRangeIndicatorDescription, isOn: $ehSetting.showSearchRangeIndicator ) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.showSearchRangeIndicator) + Text(L10n.Localizable.EhSettingView.showSearchRangeIndicator) .ehSettingRegularHeaderStyled() } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift index 7ebcfe23f..222f13eb5 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift @@ -13,13 +13,13 @@ struct OptionalUIElementsSection: View { var body: some View { Section { Toggle( - L10n.Localizable.EhSettingView.Title.enableGalleryThumbnailSelector, + L10n.Localizable.EhSettingView.enableGalleryThumbnailSelector, isOn: $ehSetting.enableGalleryThumbnailSelector ) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.optionalUIElements, - description: L10n.Localizable.EhSettingView.Description.optionalUIElements + L10n.Localizable.EhSettingView.optionalUIElements, + description: L10n.Localizable.EhSettingView.optionalUIElementsDescription ) } } @@ -51,14 +51,14 @@ struct FavoritesSection: View { } } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.favorites, - description: L10n.Localizable.EhSettingView.Description.favoriteCategories + L10n.Localizable.EhSettingView.favorites, + description: L10n.Localizable.EhSettingView.favoriteCategories ) } Section { Picker( - L10n.Localizable.EhSettingView.Title.favoritesSortOrder, + L10n.Localizable.EhSettingView.favoritesSortOrder, selection: $ehSetting.favoritesSortOrder ) { ForEach(EhSetting.FavoritesSortOrder.allCases) { order in @@ -68,7 +68,7 @@ struct FavoritesSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.Description.favoritesSortOrder) + Text(L10n.Localizable.EhSettingView.favoritesSortOrderDescription) .ehSettingRegularHeaderStyled() } } @@ -81,18 +81,18 @@ struct RatingsSection: View { var body: some View { Section { - LabeledContent(L10n.Localizable.EhSettingView.Title.ratingsColor) { + LabeledContent(L10n.Localizable.EhSettingView.ratingsColor) { SettingTextField( text: $ehSetting.ratingsColor, - promptText: L10n.Localizable.EhSettingView.Promt.ratingsColor, + promptText: L10n.Localizable.EhSettingView.ratingsColorPrompt, width: 80 ) .focused($isFocused) } } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.ratings, - description: L10n.Localizable.EhSettingView.Description.ratingsColor + L10n.Localizable.EhSettingView.ratings, + description: L10n.Localizable.EhSettingView.ratingsColorDescription ) } } @@ -106,7 +106,7 @@ struct SearchResultCountSection: View { var body: some View { Section { - Picker(L10n.Localizable.EhSettingView.Title.resultCount, selection: $ehSetting.searchResultCount) { + Picker(L10n.Localizable.EhSettingView.resultCount, selection: $ehSetting.searchResultCount) { ForEach(ehSetting.capableSearchResultCounts) { count in Text(String(count.value)) .tag(count) @@ -115,8 +115,8 @@ struct SearchResultCountSection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.searchResultCount, - description: L10n.Localizable.EhSettingView.Description.resultCount + L10n.Localizable.EhSettingView.searchResultCount, + description: L10n.Localizable.EhSettingView.resultCountDescription ) } } @@ -129,7 +129,7 @@ struct ThumbnailSettingsSection: View { var body: some View { Section { Picker( - L10n.Localizable.EhSettingView.Title.thumbnailLoadTiming, + L10n.Localizable.EhSettingView.thumbnailLoadTiming, selection: $ehSetting.thumbnailLoadTiming ) { ForEach(EhSetting.ThumbnailLoadTiming.allCases) { timing in @@ -140,15 +140,15 @@ struct ThumbnailSettingsSection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.thumbnailSettings, - description: L10n.Localizable.EhSettingView.Description.thumbnailLoadTiming + L10n.Localizable.EhSettingView.thumbnailSettings, + description: L10n.Localizable.EhSettingView.thumbnailLoadTimingDescription ) } footer: { Text(ehSetting.thumbnailLoadTiming.description) } Section { - LabeledContent(L10n.Localizable.EhSettingView.Title.thumbnailSize) { + LabeledContent(L10n.Localizable.EhSettingView.thumbnailSize) { Picker(selection: $ehSetting.thumbnailConfigSize) { ForEach(ehSetting.capableThumbnailConfigSizes) { size in Text(size.value) @@ -161,7 +161,7 @@ struct ThumbnailSettingsSection: View { .frame(width: 200) } - LabeledContent(L10n.Localizable.EhSettingView.Title.thumbnailRowCount) { + LabeledContent(L10n.Localizable.EhSettingView.thumbnailRowCount) { Picker(selection: $ehSetting.thumbnailConfigRows) { ForEach(ehSetting.capableThumbnailConfigRowCounts) { row in Text(row.value) @@ -174,7 +174,7 @@ struct ThumbnailSettingsSection: View { .frame(width: 200) } } header: { - Text(L10n.Localizable.EhSettingView.Description.thumbnailConfiguration) + Text(L10n.Localizable.EhSettingView.thumbnailConfiguration) .ehSettingRegularHeaderStyled() } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift index 522f0eb49..bd5b324f4 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -12,15 +12,15 @@ struct CoverScalingSection: View { var body: some View { Section { ValuePicker( - title: L10n.Localizable.EhSettingView.Title.scaleFactor, + title: L10n.Localizable.EhSettingView.scaleFactor, value: $ehSetting.coverScaleFactor, range: 75...150, unit: "%" ) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.coverScaling, - description: L10n.Localizable.EhSettingView.Description.coverScaleFactor + L10n.Localizable.EhSettingView.coverScaling, + description: L10n.Localizable.EhSettingView.coverScaleFactor ) } } @@ -33,13 +33,13 @@ struct TagFilteringThresholdSection: View { var body: some View { Section { ValuePicker( - title: L10n.Localizable.EhSettingView.Title.tagFilteringThreshold, + title: L10n.Localizable.EhSettingView.tagFilteringThreshold, value: $ehSetting.tagFilteringThreshold, range: -9999...0 ) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.tagFilteringThreshold, - description: L10n.Localizable.EhSettingView.Description.tagFilteringThreshold + L10n.Localizable.EhSettingView.tagFilteringThreshold, + description: L10n.Localizable.EhSettingView.tagFilteringThresholdDescription ) } } @@ -52,13 +52,13 @@ struct TagWatchingThresholdSection: View { var body: some View { Section { ValuePicker( - title: L10n.Localizable.EhSettingView.Title.tagWatchingThreshold, + title: L10n.Localizable.EhSettingView.tagWatchingThreshold, value: $ehSetting.tagWatchingThreshold, range: 0...9999 ) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.tagWatchingThreshold, - description: L10n.Localizable.EhSettingView.Description.tagWatchingThreshold + L10n.Localizable.EhSettingView.tagWatchingThreshold, + description: L10n.Localizable.EhSettingView.tagWatchingThresholdDescription ) } } @@ -71,13 +71,13 @@ struct FilteredRemovalCountSection: View { var body: some View { Section { Toggle( - L10n.Localizable.EhSettingView.Title.showFilteredRemovalCount, + L10n.Localizable.EhSettingView.showFilteredRemovalCount, isOn: $ehSetting.showFilteredRemovalCount ) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.filteredRemovalCount, - description: L10n.Localizable.EhSettingView.Description.filteredRemovalCount + L10n.Localizable.ehSettingViewfilteredRemovalCount, + description: L10n.Localizable.EhSettingView.filteredRemovalCountDescription ) } } @@ -128,8 +128,8 @@ struct ExcludedLanguagesSection: View { } } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.excludedLanguages, - description: L10n.Localizable.EhSettingView.Description.excludedLanguages + L10n.Localizable.EhSettingView.excludedLanguages, + description: L10n.Localizable.EhSettingView.excludedLanguagesDescription ) } } @@ -188,12 +188,12 @@ struct ExcludedUploadersSection: View { .focused($isFocused) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.excludedUploaders, - description: L10n.Localizable.EhSettingView.Description.excludedUploaders + L10n.Localizable.EhSettingView.excludedUploaders, + description: L10n.Localizable.EhSettingView.excludedUploadersDescription ) } footer: { Text( - L10n.Localizable.EhSettingView.Description.excludedUploadersCount( + L10n.Localizable.EhSettingView.excludedUploadersCount( "\(ehSetting.excludedUploaders.ehSettingLineCount)", "\(1000)" ) .localizedKey @@ -209,15 +209,15 @@ struct ViewportOverrideSection: View { var body: some View { Section { ValuePicker( - title: L10n.Localizable.EhSettingView.Title.virtualWidth, + title: L10n.Localizable.EhSettingView.virtualWidth, value: $ehSetting.viewportVirtualWidth, range: 0...9999, unit: "px" ) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.Section.Title.viewportOverride, - description: L10n.Localizable.EhSettingView.Description.virtualWidth + L10n.Localizable.EhSettingView.viewportOverride, + description: L10n.Localizable.EhSettingView.virtualWidthDescription ) } } @@ -230,7 +230,7 @@ struct GalleryCommentsSection: View { var body: some View { Section { Picker( - L10n.Localizable.EhSettingView.Title.commentsSortOrder, + L10n.Localizable.EhSettingView.commentsSortOrder, selection: $ehSetting.commentsSortOrder ) { ForEach(EhSetting.CommentsSortOrder.allCases) { order in @@ -241,7 +241,7 @@ struct GalleryCommentsSection: View { .pickerStyle(.menu) Picker( - L10n.Localizable.EhSettingView.Title.commentsVotesShowTiming, + L10n.Localizable.EhSettingView.commentsVotesShowTiming, selection: $ehSetting.commentVotesShowTiming ) { ForEach(EhSetting.CommentVotesShowTiming.allCases) { timing in @@ -251,7 +251,7 @@ struct GalleryCommentsSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.galleryComments) + Text(L10n.Localizable.EhSettingView.galleryComments) .ehSettingRegularHeaderStyled() } } @@ -263,7 +263,7 @@ struct GalleryTagsSection: View { var body: some View { Section { - Picker(L10n.Localizable.EhSettingView.Title.tagsSortOrder, selection: $ehSetting.tagsSortOrder) { + Picker(L10n.Localizable.EhSettingView.tagsSortOrder, selection: $ehSetting.tagsSortOrder) { ForEach(EhSetting.TagsSortOrder.allCases) { order in Text(order.value) .tag(order) @@ -271,7 +271,7 @@ struct GalleryTagsSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.galleryTags) + Text(L10n.Localizable.EhSettingView.galleryTags) .ehSettingRegularHeaderStyled() } } @@ -284,7 +284,7 @@ struct GalleryPageThumbnailLabelingSection: View { var body: some View { Section { Picker( - L10n.Localizable.EhSettingView.Title.showLabelBelowGalleryThumbnails, + L10n.Localizable.EhSettingView.showLabelBelowGalleryThumbnails, selection: $ehSetting.galleryPageNumbering ) { ForEach(EhSetting.GalleryPageNumbering.allCases) { behavior in @@ -294,7 +294,7 @@ struct GalleryPageThumbnailLabelingSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.galleryPageThumbnailLabeling) + Text(L10n.Localizable.EhSettingView.galleryPageThumbnailLabeling) .ehSettingRegularHeaderStyled() } } @@ -310,12 +310,12 @@ struct MultiplePageViewerSection: View { let multiplePageViewerShowPaneBinding = Binding($ehSetting.multiplePageViewerShowThumbnailPane) { Section { Toggle( - L10n.Localizable.EhSettingView.Title.useMultiPageViewer, + L10n.Localizable.EhSettingView.useMultiPageViewer, isOn: useMultiplePageViewerBinding ) Picker( - L10n.Localizable.EhSettingView.Title.displayStyle, + L10n.Localizable.EhSettingView.displayStyle, selection: multiplePageViewerStyleBinding ) { ForEach(EhSetting.MultiplePageViewerStyle.allCases) { style in @@ -326,11 +326,11 @@ struct MultiplePageViewerSection: View { .pickerStyle(.menu) Toggle( - L10n.Localizable.EhSettingView.Title.showThumbnailPane, + L10n.Localizable.EhSettingView.showThumbnailPane, isOn: multiplePageViewerShowPaneBinding ) } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.multiPageViewer) + Text(L10n.Localizable.EhSettingView.multiPageViewer) .ehSettingRegularHeaderStyled() } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift index d43a9b7f5..193b219f6 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift @@ -53,7 +53,7 @@ struct EhSettingView: View { .autoBlur(radius: blurRadius) } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.EhSettingView.Title.hostSettings(galleryHost.rawValue)) + .navigationTitle(L10n.Localizable.EhSettingView.hostSettings(galleryHost.rawValue)) } // MARK: Form private func form(ehSetting: Binding, ehProfile: Binding) -> some View { @@ -120,7 +120,7 @@ struct EhSettingView: View { } ToolbarItem(placement: .keyboard) { - Button(L10n.Localizable.EhSettingView.ToolbarItem.Button.done) { + Button(L10n.Localizable.EhSettingView.done) { store.send(.setKeyboardHidden) } .frame(maxWidth: .infinity, alignment: .trailing) diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index aa810d824..b83c1bd5f 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -93,13 +93,13 @@ public struct GeneralSettingReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmRemoveCustomTranslations) { - TextState(L10n.Localizable.ConfirmationDialog.Button.remove) + TextState(L10n.Localizable.ConfirmationDialog.remove) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.Title.removeCustomTranslations) + TextState(L10n.Localizable.ConfirmationDialog.removeCustomTranslations) } return .none @@ -108,13 +108,13 @@ public struct GeneralSettingReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmClearCache) { - TextState(L10n.Localizable.ConfirmationDialog.Button.clear) + TextState(L10n.Localizable.ConfirmationDialog.clear) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.Button.cancel) + TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.Title.clear) + TextState(L10n.Localizable.ConfirmationDialog.clearDescription) } return .none diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index af3537adb..dc842b3c3 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -44,28 +44,28 @@ struct GeneralSettingView: View { private var language: String { Locale.current.language.languageCode.map(\.identifier).flatMap(Locale.current.localizedString(forLanguageCode:)) - ?? L10n.Localizable.GeneralSettingView.Value.defaultLanguageDescription + ?? L10n.Localizable.GeneralSettingView.defaultLanguageDescription } var body: some View { Form { Section { HStack { - Text(L10n.Localizable.GeneralSettingView.Title.language) + Text(L10n.Localizable.GeneralSettingView.language) Spacer() Button(language) { store.send(.navigateToSystemSetting) } .foregroundStyle(.tint) } - Button(L10n.Localizable.GeneralSettingView.Button.appActivityLogs) { + Button(L10n.Localizable.GeneralSettingView.appActivityLogs) { store.send(.delegate(.pushAppActivityLogs)) } .foregroundColor(.primary).withArrow() } - Section(L10n.Localizable.GeneralSettingView.Section.Title.tags) { + Section(L10n.Localizable.GeneralSettingView.tags) { HStack { - Text(L10n.Localizable.GeneralSettingView.Title.enablesTagsExtension) + Text(L10n.Localizable.GeneralSettingView.enablesTagsExtension) .frame(maxWidth: .infinity, alignment: .leading) ZStack { @@ -85,14 +85,14 @@ struct GeneralSettingView: View { .padding(.leading, 20) } if enablesTagsExtension && !tagTranslatorEmpty { - Toggle(L10n.Localizable.GeneralSettingView.Title.translatesTags, isOn: $translatesTags) + Toggle(L10n.Localizable.GeneralSettingView.translatesTags, isOn: $translatesTags) Toggle( - L10n.Localizable.GeneralSettingView.Title.showsTagsSearchSuggestion, + L10n.Localizable.GeneralSettingView.showsTagsSearchSuggestion, isOn: $showsTagsSearchSuggestion ) - Toggle(L10n.Localizable.GeneralSettingView.Title.showsImagesInTags, isOn: $showsImagesInTags) + Toggle(L10n.Localizable.GeneralSettingView.showsImagesInTags, isOn: $showsImagesInTags) } - Button(L10n.Localizable.GeneralSettingView.Button.importCustomTranslations) { + Button(L10n.Localizable.GeneralSettingView.importCustomTranslations) { store.send(.importCustomTranslationsButtonTapped) } .fileImporter( @@ -105,7 +105,7 @@ struct GeneralSettingView: View { } if tagTranslatorHasCustomTranslations { Button( - L10n.Localizable.GeneralSettingView.Button.removeCustomTranslations, + L10n.Localizable.GeneralSettingView.removeCustomTranslations, role: .destructive, action: { store.send(.removeCustomTranslationsButtonTapped) } ) .confirmationDialog( @@ -113,20 +113,20 @@ struct GeneralSettingView: View { ) } } - Section(L10n.Localizable.GeneralSettingView.Section.Title.navigation) { + Section(L10n.Localizable.GeneralSettingView.navigation) { Toggle( - L10n.Localizable.GeneralSettingView.Title.redirectsLinksToTheSelectedHost, + L10n.Localizable.GeneralSettingView.redirectsLinksToTheSelectedHost, isOn: $redirectsLinksToSelectedHost ) Toggle( - L10n.Localizable.GeneralSettingView.Title.detectsLinksFromClipboard, + L10n.Localizable.GeneralSettingView.detectsLinksFromClipboard, isOn: $detectsLinksFromClipboard ) } - Section(L10n.Localizable.GeneralSettingView.Section.Title.security) { + Section(L10n.Localizable.GeneralSettingView.security) { HStack { Picker( - L10n.Localizable.GeneralSettingView.Title.autoLock, + L10n.Localizable.GeneralSettingView.autoLock, selection: $autoLockPolicy ) { ForEach(AutoLockPolicy.allCases) { policy in @@ -139,7 +139,7 @@ struct GeneralSettingView: View { } } VStack(alignment: .leading) { - Text(L10n.Localizable.GeneralSettingView.Title.backgroundBlurRadius) + Text(L10n.Localizable.GeneralSettingView.backgroundBlurRadius) HStack { Image(systemSymbol: .eye) Slider(value: $backgroundBlurRadius, in: 0...100, step: 10) @@ -147,12 +147,12 @@ struct GeneralSettingView: View { } } } - Section(L10n.Localizable.GeneralSettingView.Section.Title.caches) { + Section(L10n.Localizable.GeneralSettingView.caches) { Button { store.send(.clearImageCachesButtonTapped) } label: { HStack { - Text(L10n.Localizable.GeneralSettingView.Button.clearImageCaches) + Text(L10n.Localizable.GeneralSettingView.clearImageCaches) Spacer() Text(store.diskImageCacheSize).foregroundStyle(.tint) } @@ -171,7 +171,7 @@ struct GeneralSettingView: View { store.send(.checkPasscodeSetting) store.send(.calculateWebImageDiskCache) } - .navigationTitle(L10n.Localizable.GeneralSettingView.Title.general) + .navigationTitle(L10n.Localizable.GeneralSettingView.general) } } diff --git a/AppPackage/Sources/SettingFeature/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift index 561d55946..8571aac72 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginView.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginView.swift @@ -34,13 +34,13 @@ struct LoginView: View { LoginTextField( focusedField: $focusedField, text: $store.username, - description: L10n.Localizable.LoginView.Title.username, + description: L10n.Localizable.LoginView.username, isPassword: false ) LoginTextField( focusedField: $focusedField, text: $store.password, - description: L10n.Localizable.LoginView.Title.password, + description: L10n.Localizable.LoginView.password, isPassword: true ) } @@ -86,7 +86,7 @@ struct LoginView: View { } .animation(.default, value: store.loginState) .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.LoginView.Title.login) + .navigationTitle(L10n.Localizable.LoginView.login) .ignoresSafeArea() } // MARK: Toolbar diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 82fdc0859..2182465d2 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -27,7 +27,7 @@ public struct SettingView: View { } .padding(.vertical, 40).padding(.horizontal) } - .navigationTitle(L10n.Localizable.SettingView.Title.setting) + .navigationTitle(L10n.Localizable.SettingView.setting) } destination: { pathStore in destination(pathStore) .tint(store.setting.accentColor) @@ -167,19 +167,19 @@ extension SettingReducer.RootScreen { var value: String { switch self { case .account: - return L10n.Localizable.Enum.SettingStateRoute.Value.account + return L10n.Localizable.SettingStateRoute.account case .general: - return L10n.Localizable.Enum.SettingStateRoute.Value.general + return L10n.Localizable.SettingStateRoute.general case .appearance: - return L10n.Localizable.Enum.SettingStateRoute.Value.appearance + return L10n.Localizable.SettingStateRoute.appearance case .download: - return L10n.Localizable.Enum.SettingStateRoute.Value.download + return L10n.Localizable.SettingStateRoute.download case .reading: - return L10n.Localizable.Enum.SettingStateRoute.Value.reading + return L10n.Localizable.SettingStateRoute.reading case .laboratory: - return L10n.Localizable.Enum.SettingStateRoute.Value.laboratory + return L10n.Localizable.SettingStateRoute.laboratory case .about: - return L10n.Localizable.Enum.SettingStateRoute.Value.about + return L10n.Localizable.SettingStateRoute.about } } var symbol: SFSymbol { diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift index 5063671bb..2456e8565 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift @@ -404,7 +404,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { let validation = await manager.validateImageData(gid: "440") - #expect(validation == .missingFiles(L10n.Localizable.DownloadStore.Validation.pageMissing(1))) + #expect(validation == .missingFiles(L10n.Localizable.DownloadStore.pageMissing(1))) let download = try #require(await manager.fetchDownload(gid: "440")) #expect(download.displayStatus == .error) #expect(download.displayStatus == .error) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift index f3ca8e838..38e05d7d6 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift @@ -156,7 +156,7 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { await store.receive(\.validateImageDataDone) { $0.isValidatingImageData = false $0.toast = .success( - caption: L10n.Localizable.DownloadsView.Inspector.Toast.imageDataValid + caption: L10n.Localizable.DownloadInspectorView.imageDataValid ) } await store.receive(\.loadInspection) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift index be1bf08ad..94a91b86f 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift @@ -24,7 +24,7 @@ struct DownloadStoreHashTests { #expect( storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( - L10n.Localizable.DownloadStore.Validation.pageImageCorrupted(2) + L10n.Localizable.DownloadStore.pageImageCorrupted(2) ) ) } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift index f60aec13c..26885d795 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift @@ -70,7 +70,7 @@ struct DownloadStoreTests { #expect( throws: AppError.fileOperationFailed( - L10n.Localizable.DownloadStore.Validation.manifestCorrupted + L10n.Localizable.DownloadStore.manifestCorrupted ) ) { try storage.readManifest(folderURL: folderURL) @@ -89,7 +89,7 @@ struct DownloadStoreTests { #expect( throws: AppError.fileOperationFailed( - L10n.Localizable.DownloadStore.Validation.manifestCorrupted + L10n.Localizable.DownloadStore.manifestCorrupted ) ) { try storage.readManifest(folderURL: folderURL) @@ -132,7 +132,7 @@ struct DownloadStoreTests { #expect( storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( - L10n.Localizable.DownloadStore.Validation.pageMissing(2) + L10n.Localizable.DownloadStore.pageMissing(2) ) ) } @@ -169,7 +169,7 @@ struct DownloadStoreTests { #expect( storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( - L10n.Localizable.DownloadStore.Validation.pageMissing(1) + L10n.Localizable.DownloadStore.pageMissing(1) ) ) #expect( diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 41eb8066a..5df934cfa 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -40,9 +40,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A0F00000000000000000F004 /* AppPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AppPackage; sourceTree = ""; }; AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; ABC3C7542593696C00E0C11B /* EhPanda.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EhPanda.app; sourceTree = BUILT_PRODUCTS_DIR; }; - A0F00000000000000000F004 /* AppPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AppPackage; sourceTree = ""; }; EA0C92482C3EB45E00D211F6 /* AltStore.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = AltStore.json; sourceTree = ""; }; EA0C92492C3EB45E00D211F6 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; EA0C924A2C3EB45E00D211F6 /* .gitattributes */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = .gitattributes; sourceTree = ""; }; @@ -250,8 +250,6 @@ "zh-Hans", ko, de, - "zh-Hant-TW", - "zh-Hant-HK", "zh-Hant", ); mainGroup = ABC3C74B2593696C00E0C11B; @@ -288,9 +286,6 @@ }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ AB5BE67226B95FDD007D4A55 /* Sources */ = { isa = PBXSourcesBuildPhase; From b3a02364ea0ac46aca2cf10c8df2b20ab559439b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 00:50:14 +0800 Subject: [PATCH 449/614] Remove InfoPlist.strings header comments --- App/de.lproj/InfoPlist.strings | 5 ----- App/en.lproj/InfoPlist.strings | 5 ----- App/ja.lproj/InfoPlist.strings | 5 ----- App/ko.lproj/InfoPlist.strings | 5 ----- App/zh-Hans.lproj/InfoPlist.strings | 5 ----- App/zh-Hant.lproj/InfoPlist.strings | 5 ----- 6 files changed, 30 deletions(-) diff --git a/App/de.lproj/InfoPlist.strings b/App/de.lproj/InfoPlist.strings index f8593f4bd..716af9d94 100644 --- a/App/de.lproj/InfoPlist.strings +++ b/App/de.lproj/InfoPlist.strings @@ -1,7 +1,2 @@ -/* - InfoPlist.strings - EhPanda -*/ - "NSFaceIDUsageDescription" = "Diese Berechtigung ist notwendig um Face ID zum Entsperren der Anwendung verwenden zu können."; "NSPhotoLibraryAddUsageDescription" = "We need this permission to save images to your photo library."; diff --git a/App/en.lproj/InfoPlist.strings b/App/en.lproj/InfoPlist.strings index a924bb3b3..5c393b28f 100644 --- a/App/en.lproj/InfoPlist.strings +++ b/App/en.lproj/InfoPlist.strings @@ -1,7 +1,2 @@ -/* - InfoPlist.strings - EhPanda -*/ - "NSFaceIDUsageDescription" = "We need this permission to provide Face ID option while unlocking the App."; "NSPhotoLibraryAddUsageDescription" = "We need this permission to save images to your photo library."; diff --git a/App/ja.lproj/InfoPlist.strings b/App/ja.lproj/InfoPlist.strings index db744b850..1d07c4bf5 100644 --- a/App/ja.lproj/InfoPlist.strings +++ b/App/ja.lproj/InfoPlist.strings @@ -1,7 +1,2 @@ -/* - InfoPlist.strings - EhPanda -*/ - "NSFaceIDUsageDescription" = "アプリアンロック認証時に Face ID オプションを提供するにはこの権限が必要です"; "NSPhotoLibraryAddUsageDescription" = "画像をライブラリに保存するにはこの権限が必要です"; diff --git a/App/ko.lproj/InfoPlist.strings b/App/ko.lproj/InfoPlist.strings index d96b58bb8..1fc0c2206 100644 --- a/App/ko.lproj/InfoPlist.strings +++ b/App/ko.lproj/InfoPlist.strings @@ -1,7 +1,2 @@ -/* - InfoPlist.strings - EhPanda -*/ - "NSFaceIDUsageDescription" = "이 권한을 허용해야 앱 잠금 해제할때 Face ID 옵션을 제공합니다."; "NSPhotoLibraryAddUsageDescription" = "이미지를 사진 라이브러리에 저정하고 싶으면 이 권한을 허용해주세요."; diff --git a/App/zh-Hans.lproj/InfoPlist.strings b/App/zh-Hans.lproj/InfoPlist.strings index b01d42fcb..4808d3705 100644 --- a/App/zh-Hans.lproj/InfoPlist.strings +++ b/App/zh-Hans.lproj/InfoPlist.strings @@ -1,7 +1,2 @@ -/* - InfoPlist.strings - EhPanda -*/ - "NSFaceIDUsageDescription" = "需要此权限以在解锁 App 时提供 Face ID 选项"; "NSPhotoLibraryAddUsageDescription" = "需要此权限以保存图像到相册"; diff --git a/App/zh-Hant.lproj/InfoPlist.strings b/App/zh-Hant.lproj/InfoPlist.strings index c19da4373..e2103c531 100644 --- a/App/zh-Hant.lproj/InfoPlist.strings +++ b/App/zh-Hant.lproj/InfoPlist.strings @@ -1,7 +1,2 @@ -/* - InfoPlist.strings - EhPanda -*/ - "NSFaceIDUsageDescription" = "我們需要您提供 Face ID 權限來使用 Face ID 解鎖"; "NSPhotoLibraryAddUsageDescription" = "我們需要您提供照片權限來保存圖片到照片"; From 2d14cd9bcfc0f11577053ca65f3523d093d21846 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:09:23 +0800 Subject: [PATCH 450/614] Localize strings with placeholder values --- .../Resources/de.lproj/Localizable.strings | 552 +++++++++--------- .../Resources/ja.lproj/Localizable.strings | 30 +- .../Resources/ko.lproj/Localizable.strings | 210 +++---- .../zh-Hans.lproj/Localizable.strings | 30 +- .../zh-Hant.lproj/Localizable.strings | 30 +- 5 files changed, 426 insertions(+), 426 deletions(-) diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index b2380e4ef..dbdcdeecf 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -1,11 +1,11 @@ // MARK: BanInterval -"ban_interval.and" = "and"; +"ban_interval.and" = "und"; // MARK: ToplistsType -"toplists_type.yesterday" = "Yesterday"; -"toplists_type.past_month" = "Past month"; -"toplists_type.past_year" = "Past year"; -"toplists_type.all_time" = "All time"; +"toplists_type.yesterday" = "Gestern"; +"toplists_type.past_month" = "Letzter Monat"; +"toplists_type.past_year" = "Letztes Jahr"; +"toplists_type.all_time" = "Gesamt"; // MARK: Response "hath_client_not_found" = "Du benötigst einen deinem Konto zugehörigen H@H Client um diese Funktion nutzen zu können"; @@ -18,38 +18,38 @@ "toast.loading" = "Wird geladen..."; "toast.communicating" = "Verbinde..."; "toast.copied_to_clipboard" = "In Zwischenablage kopiert"; -"toast.saved_to_photo_library" = "Saved to photo library"; +"toast.saved_to_photo_library" = "In der Fotomediathek gesichert"; // MARK: AutoLock "local_authorization.reason" = "Die App hat sich selbst gesperrt, da der auto-lock Zeitraum abgelaufen ist."; // MARK: Common value "common.stars" = "%@ Sterne"; -"common.pages" = "%@ pages"; -"common.day" = "%@ day"; -"common.days" = "%@ days"; -"common.hour" = "%@ hour"; -"common.hours" = "%@ hours"; -"common.minute" = "Nach %@ Minute"; -"common.minutes" = "Nach %@ Minuten"; -"common.second" = "%@ second"; -"common.seconds" = "Nach %@ Sekunden"; +"common.pages" = "%@ Seiten"; +"common.day" = "%@ Tag"; +"common.days" = "%@ Tage"; +"common.hour" = "%@ Stunde"; +"common.hours" = "%@ Stunden"; +"common.minute" = "%@ Minute"; +"common.minutes" = "%@ Minuten"; +"common.second" = "%@ Sekunde"; +"common.seconds" = "%@ Sekunden"; // MARK: Common button "common.cancel" = "Abbrechen"; // MARK: TabItem -"tab_item.home" = "Home"; +"tab_item.home" = "Start"; "tab_item.favorites" = "Favoriten"; "tab_item.search" = "Suche"; "tab_item.downloads" = "Downloads"; "tab_item.setting" = "Einstellungen"; // MARK: ToolbarItem -"toolbar_item.filters" = "Filters"; -"toolbar_item.jump_page" = "Jump page"; +"toolbar_item.filters" = "Filter"; +"toolbar_item.jump_page" = "Zu Seite springen"; "toolbar_item.date_seek" = "Datum aufsuchen"; -"toolbar_item.quick_search" = "Quick search"; +"toolbar_item.quick_search" = "Schnellsuche"; // MARK: DateSeek "date_seek_view.date_seek" = "Datum aufsuchen"; @@ -58,26 +58,26 @@ "date_seek_view.seek_newer" = "Neuere"; "date_seek_view.seek_older" = "Ältere"; // MARK: JumpPage -"jump_page_view.jump_page" = "Jump page"; +"jump_page_view.jump_page" = "Zu Seite springen"; "jump_page_view.jump_page_description" = "Geben Sie eine Seitenzahl zwischen 1 und %d ein."; -"jump_page_view.confirm" = "Confirm"; +"jump_page_view.confirm" = "Bestätigen"; // MARK: AlertView "loading_view.loading" = "Wird geladen..."; -"loading_view.preparing_database" = "Preparing the database..."; -"not_login_view.need_login" = "You need to login to access this feature."; -"not_login_viewlogin" = "Login"; +"loading_view.preparing_database" = "Datenbank wird vorbereitet..."; +"not_login_view.need_login" = "Du musst dich einloggen, um diese Funktion nutzen zu können."; +"not_login_viewlogin" = "Einloggen"; "error_view.retry" = "Erneut versuchen"; -"error_view.drop_database" = "Drop the database"; -"error_view.try_later" = "Please try again later."; -"error_view.network" = "A network error occurred."; -"error_view.parsing" = "A parsing error occurred."; -"error_view.unknown" = "An unknown error occurred."; -"error_view.not_found" = "There seems to be nothing here."; -"error_view.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; -"error_view.ip_banned" = "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@."; -"error_view.copyright_claim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; -"error_view.gallery_unavailable" = "This gallery has been removed or is unavailable."; +"error_view.drop_database" = "Datenbank löschen"; +"error_view.try_later" = "Bitte versuche es später erneut."; +"error_view.network" = "Ein Netzwerkfehler ist aufgetreten."; +"error_view.parsing" = "Ein Parserfehler ist aufgetreten."; +"error_view.unknown" = "Ein unbekannter Fehler ist aufgetreten."; +"error_view.not_found" = "Hier scheint es nichts zu geben."; +"error_view.database_corrupted" = "Die Datenbank ist beschädigt.\nBitte erstelle ein Issue auf GitHub."; +"error_view.ip_banned" = "Deine IP-Adresse wurde wegen übermäßig vieler Seitenaufrufe vorübergehend gesperrt. Das deutet auf automatische Mirroring- oder Harvesting-Software hin. Die Sperre läuft in %@ ab."; +"error_view.copyright_claim" = "Diese Galerie ist wegen eines Urheberrechtsanspruchs von %@ nicht verfügbar. Das tut uns leid."; +"error_view.gallery_unavailable" = "Diese Galerie wurde entfernt oder ist nicht verfügbar."; // MARK: AppError "app_error.database_corrupted" = "Datenbank beschädigt"; @@ -98,16 +98,16 @@ "app_error.local_file_operation_failed" = "Lokaler Dateivorgang fehlgeschlagen."; // MARK: ConfirmationDialog -"confirmation_dialog.drop_database_description" = "You will lose all your data in this app.\nAre you sure to drop the database?"; -"confirmation_dialog.remove_custom_translations" = "Are you sure to remove your custom translations?"; +"confirmation_dialog.drop_database_description" = "Du verlierst alle Daten in dieser App.\nMöchtest du die Datenbank wirklich löschen?"; +"confirmation_dialog.remove_custom_translations" = "Möchtest du deine benutzerdefinierten Übersetzungen wirklich entfernen?"; "confirmation_dialog.logout_description" = "Bist du sicher das du dich ausloggen möchtest?"; -"confirmation_dialog.delete_description" = "Are you sure to delete this item?"; +"confirmation_dialog.delete_description" = "Möchtest du dieses Element wirklich löschen?"; "confirmation_dialog.clear_description" = "Bist du sicher das du das löschen möchtest?"; "confirmation_dialog.reset_description" = "Bist du sicher?"; -"confirmation_dialog.drop_database" = "Drop the database"; -"confirmation_dialog.remove" = "Remove"; +"confirmation_dialog.drop_database" = "Datenbank löschen"; +"confirmation_dialog.remove" = "Entfernen"; "confirmation_dialog.logout" = "Ausloggen"; -"confirmation_dialog.delete" = "Delete"; +"confirmation_dialog.delete" = "Löschen"; "confirmation_dialog.clear" = "Löschen"; "confirmation_dialog.reset" = "Zurücksetzen"; @@ -124,20 +124,20 @@ "greeting.end" = "!"; // MARK: HomeView -"home_view.home" = "Home"; -"home_view.frontpage" = "Frontpage"; -"home_view.toplists" = "Toplists"; -"home_view.other" = "Other"; +"home_view.home" = "Start"; +"home_view.frontpage" = "Startseite"; +"home_view.toplists" = "Toplisten"; +"home_view.other" = "Sonstiges"; // HomeMiscGridType "home_misc_grid_type.popular" = "Beliebt"; "home_misc_grid_type.watched" = "Meine Tags"; "home_misc_grid_type.history" = "Verlauf"; // MARK: FrontpageView -"frontpage_view.frontpage" = "Frontpage"; +"frontpage_view.frontpage" = "Startseite"; // MARK: ToplistsView -"toplists_view.toplists" = "Toplists"; +"toplists_view.toplists" = "Toplisten"; // MARK: PopularView "popular_view.popular" = "Beliebt"; @@ -156,18 +156,18 @@ // MARK: SearchView "search_view.search" = "Suche"; -"search_view.recently_searched" = "Recently searched"; -"search_view.recently_seen" = "Recently seen"; -"search_view.quick_search" = "Quick search"; +"search_view.recently_searched" = "Zuletzt gesucht"; +"search_view.recently_seen" = "Zuletzt angesehen"; +"search_view.quick_search" = "Schnellsuche"; // Searchable "searchable.filter" = "Filter"; -"searchable.matches_count" = "Found %d matches."; +"searchable.matches_count" = "%d Treffer gefunden."; // MARK: QuickSearchView -"quick_search_view.quick_search" = "Quick search"; -"quick_search_view.edit_word" = "Edit word"; -"quick_search_view.new_word" = "New word"; -"quick_search_view.content" = "Content"; +"quick_search_view.quick_search" = "Schnellsuche"; +"quick_search_view.edit_word" = "Suchbegriff bearbeiten"; +"quick_search_view.new_word" = "Neuer Suchbegriff"; +"quick_search_view.content" = "Inhalt"; "quick_search_view.name" = "Name"; "quick_search_view.optional" = "Optional"; @@ -180,7 +180,7 @@ "setting_state_route.reading" = "Am Lesen"; "setting_state_route.download" = "Download"; "setting_state_route.laboratory" = "Experimentelles"; -"setting_state_route.about" = "About"; +"setting_state_route.about" = "Über diese App"; // MARK: AccountSettingView "account_setting_view.account" = "Konto"; @@ -192,33 +192,33 @@ // CookieValue "cookie_value.expired" = "Abgelaufen"; "cookie_value.mystery" = "Abgelehnt"; -"cookie_value.none" = "None"; +"cookie_value.none" = "Nicht vorhanden"; // MARK: LoginView "login_view.login" = "Einloggen"; -"login_view.username" = "Username"; -"login_view.password" = "Password"; +"login_view.username" = "Benutzername"; +"login_view.password" = "Passwort"; // MARK: GeneralSettingView "general_setting_view.general" = "Allgemein"; "general_setting_view.language" = "Sprache"; -"general_setting_view.auto_lock" = "Auto-Lock"; -"general_setting_view.enables_tags_extension" = "Enables tags extension"; -"general_setting_view.translates_tags" = "Translates tags"; -"general_setting_view.shows_tags_search_suggestion" = "Shows tags search suggestion"; -"general_setting_view.shows_images_in_tags" = "Shows images in tags"; +"general_setting_view.auto_lock" = "Automatische Sperre"; +"general_setting_view.enables_tags_extension" = "Tag-Erweiterung aktivieren"; +"general_setting_view.translates_tags" = "Tags übersetzen"; +"general_setting_view.shows_tags_search_suggestion" = "Tag-Vorschläge bei der Suche anzeigen"; +"general_setting_view.shows_images_in_tags" = "Bilder in Tags anzeigen"; "general_setting_view.redirects_links_to_the_selected_host" = "Links zum ausgewählten Host umleiten"; "general_setting_view.detects_links_from_clipboard" = "Übernimmt automatisch Links aus der Zwischenablage"; -"general_setting_view.background_blur_radius" = "Background blur radius"; +"general_setting_view.background_blur_radius" = "Hintergrund-Unschärfe"; "general_setting_view.app_activity_logs" = "App-Aktivitätsprotokolle"; -"general_setting_view.import_custom_translations" = "Import custom translations"; -"general_setting_view.remove_custom_translations" = "Remove custom translations"; +"general_setting_view.import_custom_translations" = "Benutzerdefinierte Übersetzungen importieren"; +"general_setting_view.remove_custom_translations" = "Benutzerdefinierte Übersetzungen entfernen"; "general_setting_view.clear_image_caches" = "Zwischengespeicherte Bilder (Cache) löschen"; -"general_setting_view.default_language_description" = "N/A"; +"general_setting_view.default_language_description" = "Unbekannt"; "general_setting_view.tags" = "Tags"; "general_setting_view.navigation" = "Navigation"; "general_setting_view.security" = "Sicherheit"; -"general_setting_view.caches" = "Caches"; +"general_setting_view.caches" = "Cache"; // AutoLockPolicy "auto_lock_policy.never" = "Nie"; "auto_lock_policy.instantly" = "Sofort"; @@ -240,16 +240,16 @@ // MARK: AppearanceSettingView "appearance_setting_view.appearance" = "Oberfläche"; -"appearance_setting_view.theme" = "Theme"; +"appearance_setting_view.theme" = "Erscheinungsbild"; "appearance_setting_view.tint_color" = "Farbe"; -"appearance_setting_view.display_mode" = "Display mode"; +"appearance_setting_view.display_mode" = "Anzeigemodus"; "appearance_setting_view.shows_tags_in_list" = "Tags als Liste anzeigen"; "appearance_setting_view.maximum_number_of_tags" = "Maximale Anzahl an Tags"; -"appearance_setting_view.displays_japanese_title" = "Displays Japanese title"; -"appearance_setting_view.app_icon" = "App icon"; -"appearance_setting_view.infite" = "Infite"; -"appearance_setting_view.list" = "List"; -"appearance_setting_view.gallery" = "Gallery"; +"appearance_setting_view.displays_japanese_title" = "Japanischen Titel anzeigen"; +"appearance_setting_view.app_icon" = "App-Symbol"; +"appearance_setting_view.infite" = "Unbegrenzt"; +"appearance_setting_view.list" = "Liste"; +"appearance_setting_view.gallery" = "Galerie"; // PreferredColorScheme "preferred_color_scheme.automatic" = "Automatisch"; "preferred_color_scheme.light" = "Hell"; @@ -257,21 +257,21 @@ // AppIconType "app_icon_type.default" = "Standard"; "app_icon_type.ukiyoe" = "Ukiyo-e"; -"app_icon_type.developer" = "Developer"; -"app_icon_type.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"app_icon_type.not_my_president" = "NOT MY PRESIDENT"; +"app_icon_type.developer" = "Entwickler"; +"app_icon_type.stand_with_ukraine_2022" = "Solidarität mit der Ukraine (2022)"; +"app_icon_type.not_my_president" = "NICHT MEIN PRÄSIDENT"; // ListDisplayMode -"list_display_mode.detail" = "Detail"; -"list_display_mode.thumbnail" = "Thumbnail"; +"list_display_mode.detail" = "Details"; +"list_display_mode.thumbnail" = "Vorschaubilder"; // MARK: AppIconView -"app_icon_view.app_icon" = "App icon"; +"app_icon_view.app_icon" = "App-Symbol"; // MARK: reading_settingView "reading_setting_view.reading" = "Am Lesen"; -"reading_setting_view.direction" = "Direction"; -"reading_setting_view.preload_limit" = "Preload limit"; -"reading_setting_view.enables_landscape" = "Enables landscape"; +"reading_setting_view.direction" = "Leserichtung"; +"reading_setting_view.preload_limit" = "Vorausladen"; +"reading_setting_view.enables_landscape" = "Querformat aktivieren"; "reading_setting_view.separator_height" = "Höhe der Teilung"; "reading_setting_view.maximum_scale_factor" = "Maximaler Skalierungsfaktor"; "reading_setting_view.double_tap_scale_factor" = "Doppel-Tap Skalierungsfaktor"; @@ -290,10 +290,10 @@ "about_view.website" = "Website"; "about_view.altStore_source" = "AltStore Quelle"; "about_view.version" = "Version"; -"about_view.special_thanks" = "Special thanks"; -"about_view.code_level_contributors" = "Code-level contributors"; -"about_view.translation_contributors" = "Translation contributors"; -"about_view.acknowledgements" = "OK"; +"about_view.special_thanks" = "Besonderer Dank"; +"about_view.code_level_contributors" = "Mitwirkende am Code"; +"about_view.translation_contributors" = "Mitwirkende an der Übersetzung"; +"about_view.acknowledgements" = "Danksagungen"; // MARK: DetailView "detail_view.read" = "Lesen"; @@ -313,10 +313,10 @@ "detail_view.archives" = "Archiv"; "detail_view.torrents" = "Torrents"; "detail_view.share" = "Teilen"; -"detail_view.detail" = "Detail"; -"detail_view.withdraw_vote" = "Withdraw vote"; -"detail_view.vote_up" = "Vote up"; -"detail_view.vote_down" = "Vote down"; +"detail_view.detail" = "Details"; +"detail_view.withdraw_vote" = "Stimme zurückziehen"; +"detail_view.vote_up" = "Dafür stimmen"; +"detail_view.vote_down" = "Dagegen stimmen"; "detail_view.favorited" = "Favorisiert"; "detail_view.language" = "Sprache"; "detail_view.ratings" = "%@ Bewertungen"; @@ -353,53 +353,53 @@ "torrents_view.torrents" = "Torrents"; // MARK: GalleryInfosView -"gallery_infos_view.gallery_infos" = "Gallery infos"; +"gallery_infos_view.gallery_infos" = "Galerie-Infos"; "gallery_infos_view.id" = "ID"; "gallery_infos_view.token" = "Token"; -"gallery_infos_view.title" = "Title"; -"gallery_infos_view.japanese_title" = "Japanese title"; -"gallery_infos_view.gallery_URL" = "Gallery URL"; -"gallery_infos_view.cover_URL" = "Cover URL"; -"gallery_infos_view.archive_URL" = "Archive URL"; -"gallery_infos_view.torrent_URL" = "Torrent URL"; -"gallery_infos_view.parent_URL" = "Parent URL"; -"gallery_infos_view.category" = "Category"; +"gallery_infos_view.title" = "Titel"; +"gallery_infos_view.japanese_title" = "Japanischer Titel"; +"gallery_infos_view.gallery_URL" = "Galerie-URL"; +"gallery_infos_view.cover_URL" = "Cover-URL"; +"gallery_infos_view.archive_URL" = "Archiv-URL"; +"gallery_infos_view.torrent_URL" = "Torrent-URL"; +"gallery_infos_view.parent_URL" = "Übergeordnete URL"; +"gallery_infos_view.category" = "Kategorie"; "gallery_infos_view.uploader" = "Uploader"; -"gallery_infos_view.posted_date" = "Posted date"; -"gallery_infos_view.visibility" = "Visibility"; -"gallery_infos_view.language" = "Language"; -"gallery_infos_view.page_count" = "Page count"; -"gallery_infos_view.file_size" = "File size"; -"gallery_infos_view.favorited_times" = "Favorited times"; -"gallery_infos_view.favorited" = "Favorited"; -"gallery_infos_view.rating_count" = "Rating count"; -"gallery_infos_view.average_rating" = "Average rating"; -"gallery_infos_view.my_rating" = "My rating"; -"gallery_infos_view.torrent_count" = "Torrent count"; -"gallery_infos_view.none" = "None"; -"gallery_infos_view.yes" = "Yes"; -"gallery_infos_view.no" = "No"; +"gallery_infos_view.posted_date" = "Veröffentlicht am"; +"gallery_infos_view.visibility" = "Sichtbarkeit"; +"gallery_infos_view.language" = "Sprache"; +"gallery_infos_view.page_count" = "Seitenzahl"; +"gallery_infos_view.file_size" = "Dateigröße"; +"gallery_infos_view.favorited_times" = "Anzahl Favorisierungen"; +"gallery_infos_view.favorited" = "Favorisiert"; +"gallery_infos_view.rating_count" = "Anzahl Bewertungen"; +"gallery_infos_view.average_rating" = "Durchschnittliche Bewertung"; +"gallery_infos_view.my_rating" = "Meine Bewertung"; +"gallery_infos_view.torrent_count" = "Anzahl Torrents"; +"gallery_infos_view.none" = "Keine"; +"gallery_infos_view.yes" = "Ja"; +"gallery_infos_view.no" = "Nein"; // GalleryVisibility -"gallery_visibility.yes" = "Yes"; -"gallery_visibility.no" = "No (%@)"; -"gallery_visibility.expunged" = "Expunged"; +"gallery_visibility.yes" = "Ja"; +"gallery_visibility.no" = "Nein (%@)"; +"gallery_visibility.expunged" = "Entfernt"; // MARK: TagDetailView -"tag_detail_view.images" = "Images"; +"tag_detail_view.images" = "Bilder"; "tag_detail_view.links" = "Links"; // MARK: DownloadsView -"download_folder_filter.all" = "All"; -"detail_view.manage_folders" = "Manage Folders"; -"detail_view.create_default_folder" = "Create Default Folder"; -"detail_view.no_folders" = "No folders yet"; -"downloads_view.manage_folders" = "Manage Folders"; -"downloads_view.move_to_folder" = "Move to Folder"; -"downloads_view.move" = "Move"; -"folder_manager_view.folders" = "Folders"; -"folder_manager_view.folder_name" = "Folder name"; -"folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_folders" = "Folders you create will appear here."; +"download_folder_filter.all" = "Alle"; +"detail_view.manage_folders" = "Ordner verwalten"; +"detail_view.create_default_folder" = "Standardordner erstellen"; +"detail_view.no_folders" = "Noch keine Ordner"; +"downloads_view.manage_folders" = "Ordner verwalten"; +"downloads_view.move_to_folder" = "In Ordner verschieben"; +"downloads_view.move" = "Verschieben"; +"folder_manager_view.folders" = "Ordner"; +"folder_manager_view.folder_name" = "Ordnername"; +"folder_manager_view.delete_folder" = "Der Ordner und alle heruntergeladenen Galerien darin werden gelöscht."; +"folder_manager_view.empty_folders" = "Von dir erstellte Ordner werden hier angezeigt."; "downloads_view.downloads" = "Downloads"; "downloads_view.search_downloads" = "Downloads durchsuchen"; "downloads_view.delete_download" = "Download löschen?"; @@ -443,19 +443,19 @@ "previews_view.previews" = "Vorschau"; // MARK: ReadingView -"reading_view.reload" = "Reload"; -"reading_view.copy" = "Copy"; -"reading_view.save" = "Save"; -"reading_view.save_original" = "Save original"; +"reading_view.reload" = "Neu laden"; +"reading_view.copy" = "Kopieren"; +"reading_view.save" = "Sichern"; +"reading_view.save_original" = "Original sichern"; "reading_view.share" = "Teilen"; -"reading_view.auto_play" = "Auto-Play"; -"reading_view.dual_page_mode" = "Dual-Page mode"; -"reading_view.except_the_cover" = "Except the cover"; -"reading_view.retry_all_failed_images" = "Retry failed images"; -"reading_view.reload_all_images" = "Reload all images"; -"reading_view.reading_setting" = "Reading setting"; +"reading_view.auto_play" = "Autoplay"; +"reading_view.dual_page_mode" = "Doppelseitenmodus"; +"reading_view.except_the_cover" = "Außer dem Cover"; +"reading_view.retry_all_failed_images" = "Fehlgeschlagene Bilder erneut laden"; +"reading_view.reload_all_images" = "Alle Bilder neu laden"; +"reading_view.reading_setting" = "Leseeinstellungen"; // AutoPlayPolicy -"auto_play_policy.off" = "Off"; +"auto_play_policy.off" = "Aus"; // MARK: DownloadBadge @@ -469,10 +469,10 @@ // MARK: DownloadStore "download_store.asset_unreadable" = "Asset-Datei ist nicht lesbar: %@"; -"download_store.invalid_folder_name" = "The folder name is invalid."; -"download_store.folder_already_exists" = "A folder with this name already exists."; -"download_store.folder_busy_downloading" = "The folder contains an active download."; -"download_store.download_busy" = "The download is currently active."; +"download_store.invalid_folder_name" = "Der Ordnername ist ungültig."; +"download_store.folder_already_exists" = "Ein Ordner mit diesem Namen existiert bereits."; +"download_store.folder_busy_downloading" = "Der Ordner enthält einen aktiven Download."; +"download_store.download_busy" = "Der Download läuft gerade."; "download_store.download_folder_missing" = "Download-Ordner fehlt."; "download_store.manifest_missing" = "Manifest-Datei fehlt."; "download_store.manifest_corrupted" = "Manifest-Datei ist beschädigt."; @@ -480,7 +480,7 @@ "download_store.page_image_corrupted" = "Bilddaten von Seite %d sind beschädigt."; // MARK: FiltersView -"filters_view.filters" = "Filters"; +"filters_view.filters" = "Filter"; "filters_view.advanced_settings" = "Erweiterte Einstellungen"; "filters_view.search_gallery_name" = "Galerienamen durchsuchen"; "filters_view.search_gallery_tags" = "Galerietags durchsuchen"; @@ -506,173 +506,173 @@ "filter_range.watched" = "Meine Tags"; // MARK: EhSettingView -"eh_setting_view.host_settings" = "%@ settings"; -"eh_setting_view.profile_settings" = "Profile Settings"; -"eh_setting_view.selected_profile" = "Selected profile"; -"eh_setting_view.set_as_default" = "Set as default"; -"eh_setting_view.delete_profile" = "Delete profile"; -"eh_setting_view.rename" = "Rename"; -"eh_setting_view.create_new" = "Create new"; -"eh_setting_view.done" = "Done"; - -"eh_setting_view.image_load_settings" = "Image Load Settings"; -"eh_setting_view.load_images_through_the_hath_network" = "Load images through the Hath network"; -"eh_setting_view.browsing_country" = "Browsing country"; -"eh_setting_view.browsing_country_description" = "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below."; +"eh_setting_view.host_settings" = "%@-Einstellungen"; +"eh_setting_view.profile_settings" = "Profile"; +"eh_setting_view.selected_profile" = "Ausgewähltes Profil"; +"eh_setting_view.set_as_default" = "Als Standard festlegen"; +"eh_setting_view.delete_profile" = "Profil löschen"; +"eh_setting_view.rename" = "Umbenennen"; +"eh_setting_view.create_new" = "Neu erstellen"; +"eh_setting_view.done" = "Fertig"; + +"eh_setting_view.image_load_settings" = "Laden von Bildern"; +"eh_setting_view.load_images_through_the_hath_network" = "Bilder über das Hath-Netzwerk laden"; +"eh_setting_view.browsing_country" = "Browsing-Land"; +"eh_setting_view.browsing_country_description" = "Es sieht so aus, als würdest du die Seite aus **%@** aufrufen oder ein VPN bzw. einen Proxy in diesem Land verwenden. Die Seite versucht daher, Bilder von H@H-Clients in dieser Region zu laden. Falls das nicht stimmt oder du aus irgendeinem Grund eine andere Region verwenden möchtest (etwa mit einem Split-Tunneling-VPN), kannst du unten ein anderes Land auswählen."; // EhSetting.LoadThroughHathSetting -"load_through_hath_setting.any_client" = "Any client"; -"load_through_hath_setting.default_port_only" = "Default port clients only"; -"load_through_hath_setting.modern_no" = "No [Modern/HTTPS]"; -"load_through_hath_setting.legacy_no" = "No [Legacy/HTTP]"; -"load_through_hath_setting.any_client_description" = "Recommended."; -"load_through_hath_setting.default_port_only_description" = "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports."; -"load_through_hath_setting.modern_no_description" = "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems."; -"load_through_hath_setting.legacy_no_description" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; - -"eh_setting_view.image_size_settings" = "Image Size Settings"; -"eh_setting_view.image_resolution" = "Image resolution"; -"eh_setting_view.image_resolution_description" = "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000."; -"eh_setting_view.image_size" = "Image size"; -"eh_setting_view.image_size_description" = "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)"; +"load_through_hath_setting.any_client" = "Jeder Client"; +"load_through_hath_setting.default_port_only" = "Nur Clients mit Standardport"; +"load_through_hath_setting.modern_no" = "Nein [Modern/HTTPS]"; +"load_through_hath_setting.legacy_no" = "Nein [Legacy/HTTP]"; +"load_through_hath_setting.any_client_description" = "Empfohlen."; +"load_through_hath_setting.default_port_only_description" = "Kann langsamer sein. Aktiviere dies nur, wenn eine Firewall oder ein Proxy ausgehende Nicht-Standard-Ports blockiert."; +"load_through_hath_setting.modern_no_description" = "Nur für Spender. Du kannst damit weniger Seiten aufrufen. Nur bei schwerwiegenden Problemen empfohlen."; +"load_through_hath_setting.legacy_no_description" = "Nur für Spender. Funktioniert in modernen Browsern unter Umständen nicht. Nur für alte oder veraltete Browser empfohlen."; + +"eh_setting_view.image_size_settings" = "Bildgrößen-Einstellungen"; +"eh_setting_view.image_resolution" = "Bildauflösung"; +"eh_setting_view.image_resolution_description" = "Normalerweise werden Bilder für die Online-Ansicht auf eine horizontale Auflösung von 1280 Pixeln neu berechnet. Alternativ kannst du eine der folgenden Auflösungen wählen. Um die Server nicht zu überlasten, sind Auflösungen über 1280x vorübergehend Spendern, Nutzern mit einem Hath-Perk und Nutzern mit einer UID unter 3.000.000 vorbehalten."; +"eh_setting_view.image_size" = "Bildgröße"; +"eh_setting_view.image_size_description" = "Die Seite verkleinert Bilder automatisch passend zu deiner Bildschirmbreite, du kannst die maximale Anzeigegröße aber auch manuell begrenzen. Wie bei der automatischen Skalierung wird das Bild dabei nicht neu berechnet, da die Größenänderung im Browser erfolgt. (0 = keine Begrenzung)"; "eh_setting_view.horizontal" = "Horizontal"; -"eh_setting_view.vertical" = "Vertical"; +"eh_setting_view.vertical" = "Vertikal"; // EhSetting.ImageResolution -"image_resolution.auto" = "Auto"; +"image_resolution.auto" = "Automatisch"; -"eh_setting_view.gallery_name_display" = "Gallery Name Display"; -"eh_setting_view.gallery_name" = "Gallery name"; -"eh_setting_view.gallery_name_description" = "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?"; +"eh_setting_view.gallery_name_display" = "Anzeige des Galerienamens"; +"eh_setting_view.gallery_name" = "Galeriename"; +"eh_setting_view.gallery_name_description" = "Viele Galerien haben sowohl einen englischen bzw. romanisierten Titel als auch einen Titel in japanischer Schrift. Welchen Namen möchtest du standardmäßig sehen?"; // EhSetting.GalleryName -"gallery_name.default" = "Default Title"; -"gallery_name.japanese" = "Japanese Title (if available)"; +"gallery_name.default" = "Standardtitel"; +"gallery_name.japanese" = "Japanischer Titel (falls vorhanden)"; -"eh_setting_view.archiver_settings" = "Archiver Settings"; -"eh_setting_view.archiver_behavior" = "Archiver behavior"; -"eh_setting_view.archiver_behavior_description" = "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here."; +"eh_setting_view.archiver_settings" = "Archiver"; +"eh_setting_view.archiver_behavior" = "Archiver-Verhalten"; +"eh_setting_view.archiver_behavior_description" = "Standardmäßig fragt der Archiver Kosten und Auswahl (Original oder neu berechnet) ab und zeigt dann einen Link an, den du anklicken oder woanders kopieren kannst. Dieses Verhalten kannst du hier ändern."; // EhSetting.ArchiverBehavior -"eh_setting.archiver_behavior.manual_select_manual_start" = "Manual Select, Manual Start (Default)"; -"eh_setting.archiver_behavior.manual_select_auto_start" = "Manual Select, Auto Start"; -"eh_setting.archiver_behavior.auto_select_original_manual_start" = "Auto Select Original, Manual Start"; -"eh_setting.archiver_behavior.auto_select_original_auto_start" = "Auto Select Original, Auto Start"; -"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "Auto Select Resample, Manual Start"; -"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "Auto Select Resample, Auto Start"; - -"eh_setting_view.front_page_settings" = "Front Page Settings"; -"eh_setting_view.display_mode" = "Display mode"; -"eh_setting_view.display_mode_description" = "Which display mode would you like to use on the front and search pages?"; -"eh_setting_view.show_search_range_indicator" = "Search Range Indicator"; -"eh_setting_view.show_search_range_indicator_description" = "Show search range indicator"; -"eh_setting_view.gallery_category" = "What categories would you like to show by default on the front page and in searches?"; +"eh_setting.archiver_behavior.manual_select_manual_start" = "Manuell wählen, manuell starten (Standard)"; +"eh_setting.archiver_behavior.manual_select_auto_start" = "Manuell wählen, automatisch starten"; +"eh_setting.archiver_behavior.auto_select_original_manual_start" = "Original automatisch wählen, manuell starten"; +"eh_setting.archiver_behavior.auto_select_original_auto_start" = "Original automatisch wählen, automatisch starten"; +"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "Neu berechnete Version automatisch wählen, manuell starten"; +"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "Neu berechnete Version automatisch wählen, automatisch starten"; + +"eh_setting_view.front_page_settings" = "Startseite"; +"eh_setting_view.display_mode" = "Anzeigemodus"; +"eh_setting_view.display_mode_description" = "Welchen Anzeigemodus möchtest du auf der Startseite und den Suchseiten verwenden?"; +"eh_setting_view.show_search_range_indicator" = "Suchbereichsanzeige"; +"eh_setting_view.show_search_range_indicator_description" = "Suchbereichsanzeige einblenden"; +"eh_setting_view.gallery_category" = "Welche Kategorien sollen standardmäßig auf der Startseite und in Suchergebnissen angezeigt werden?"; // EhSetting.DisplayMode -"display_mode.compact" = "Compact"; -"display_mode.thumbnail" = "Thumbnail"; -"display_mode.extended" = "Extended"; +"display_mode.compact" = "Kompakt"; +"display_mode.thumbnail" = "Vorschaubilder"; +"display_mode.extended" = "Erweitert"; "display_mode.minimal" = "Minimal"; "display_mode.minimalPlus" = "Minimal+"; -"eh_setting_view.optional_UI_elements" = "Optional UI Elements"; -"eh_setting_view.optional_UI_elements_description" = "Some historic UI elements are now disabled by default. You can enable those here."; -"eh_setting_view.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; +"eh_setting_view.optional_UI_elements" = "Optionale UI-Elemente"; +"eh_setting_view.optional_UI_elements_description" = "Einige ältere UI-Elemente sind inzwischen standardmäßig deaktiviert. Hier kannst du sie wieder aktivieren."; +"eh_setting_view.enable_gallery_thumbnail_selector" = "Vorschaubild-Auswahl auf der Galerieseite aktivieren"; -"eh_setting_view.favorites" = "Favorites"; -"eh_setting_view.favorite_categories" = "Here you can choose and rename your favorite categories."; -"eh_setting_view.favorites_sort_order" = "Favorites sort order"; -"eh_setting_view.favorites_sort_order_description" = "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting."; +"eh_setting_view.favorites" = "Favoriten"; +"eh_setting_view.favorite_categories" = "Hier kannst du deine Favoriten-Kategorien auswählen und umbenennen."; +"eh_setting_view.favorites_sort_order" = "Sortierung der Favoriten"; +"eh_setting_view.favorites_sort_order_description" = "Du kannst auch die Standardsortierung für Galerien auf deiner Favoritenseite festlegen. Favoriten, die vor der Überarbeitung im März 2016 hinzugefügt wurden, haben keinen Zeitstempel und werden unabhängig von dieser Einstellung nach dem Veröffentlichungszeitpunkt der Galerie sortiert."; // EhSetting.FavoritesSortOrder -"favorites_sort_order.last_update_time" = "By last gallery update time"; -"favorites_sort_order.favorited_time" = "By favorited time"; +"favorites_sort_order.last_update_time" = "Nach letzter Aktualisierung der Galerie"; +"favorites_sort_order.favorited_time" = "Nach Zeitpunkt des Favorisierens"; -"eh_setting_view.ratings" = "Ratings"; -"eh_setting_view.ratings_color" = "Ratings color"; +"eh_setting_view.ratings" = "Bewertungen"; +"eh_setting_view.ratings_color" = "Farbe der Bewertungen"; "eh_setting_view.ratings_color_prompt" = "RRGGB"; -"eh_setting_view.ratings_color_description" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; +"eh_setting_view.ratings_color_description" = "Standardmäßig erscheinen von dir bewertete Galerien mit roten Sternen bei Bewertungen bis 2 Sternen, mit grünen zwischen 2,5 und 4 Sternen und mit blauen bei 4,5 oder 5 Sternen. Du kannst das anpassen, indem du unten deine gewünschte Farbkombination eingibst. Jeder Buchstabe steht für einen Stern. Das Standard-RRGGB bedeutet R(ot) für den ersten und zweiten Stern, G(rün) für den dritten und vierten und B(lau) für den fünften. Mit Y bekommst du gelbe Sterne. Jede fünfstellige Kombination aus R/G/B/Y funktioniert."; -"eh_setting_view.tag_filtering_threshold" = "Tag Filtering Threshold"; -"eh_setting_view.tag_filtering_threshold_description" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; +"eh_setting_view.tag_filtering_threshold" = "Schwellenwert für Tag-Filterung"; +"eh_setting_view.tag_filtering_threshold_description" = "Du kannst Tags weich filtern, indem du sie mit negativem Gewicht zu „Meine Tags“ hinzufügst. Ergeben die Tags einer Galerie zusammen ein Gewicht unter diesem Wert, wird sie ausgeblendet. Der Schwellenwert kann zwischen 0 und -9999 liegen."; -"eh_setting_view.tag_watching_threshold" = "Tag Watching Threshold"; -"eh_setting_view.tag_watching_threshold_description" = "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999."; +"eh_setting_view.tag_watching_threshold" = "Schwellenwert für Tag-Beobachtung"; +"eh_setting_view.tag_watching_threshold_description" = "Kürzlich hochgeladene Galerien erscheinen unter „Meine Tags“, wenn sie mindestens einen beobachteten Tag mit positivem Gewicht haben und die Gewichte ihrer beobachteten Tags zusammen diesen Wert erreichen oder überschreiten. Der Schwellenwert kann zwischen 0 und 9999 liegen."; -"eh_setting_viewfiltered_removal_count" = "Show Filtered Removal Count"; -"eh_setting_view.filtered_removal_count_description" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; -"eh_setting_view.show_filtered_removal_count" = "Show filtered removal count"; +"eh_setting_viewfiltered_removal_count" = "Anzahl gefilterter Galerien"; +"eh_setting_view.filtered_removal_count_description" = "Soll die Meldung „Deine Standardfilter haben XX Galerien von dieser Seite entfernt“ angezeigt werden?"; +"eh_setting_view.show_filtered_removal_count" = "Anzahl gefilterter Galerien anzeigen"; -"eh_setting_view.excluded_languages" = "Excluded Languages"; -"eh_setting_view.excluded_languages_description" = "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query."; +"eh_setting_view.excluded_languages" = "Ausgeschlossene Sprachen"; +"eh_setting_view.excluded_languages_description" = "Wenn du Galerien in bestimmten Sprachen aus der Galerieliste und den Suchergebnissen ausblenden möchtest, wähle sie unten aus. Passende Galerien erscheinen dann unabhängig von deiner Suchanfrage nie."; // EhSetting.ExcludedLanguagesCategory "excluded_languages_category.original" = "Original"; -"excluded_languages_category.translated" = "Translated"; -"excluded_languages_category.rewrite" = "Rewrite"; - -"eh_setting_view.excluded_uploaders" = "Excluded Uploaders"; -"eh_setting_view.excluded_uploaders_description" = "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query."; -"eh_setting_view.excluded_uploaders_count" = "You are currently using **%@ / %@** exclusion slots."; - -"eh_setting_view.search_result_count" = "Search Result Count"; -"eh_setting_view.result_count" = "Result count"; -"eh_setting_view.result_count_description" = "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)"; - -"eh_setting_view.thumbnail_settings" = "Thumbnail Settings"; -"eh_setting_view.thumbnail_load_timing" = "Thumbnail load timing"; -"eh_setting_view.thumbnail_load_timing_description" = "How would you like the mouse-over thumbnails on the front page to load when using List Mode?"; -"eh_setting_view.thumbnail_configuration" = "You can set a default thumbnail configuration for all galleries you visit."; -"eh_setting_view.thumbnail_size" = "Size"; -"eh_setting_view.thumbnail_row_count" = "Rows"; +"excluded_languages_category.translated" = "Übersetzt"; +"excluded_languages_category.rewrite" = "Umgeschrieben"; + +"eh_setting_view.excluded_uploaders" = "Ausgeschlossene Uploader"; +"eh_setting_view.excluded_uploaders_description" = "Wenn du Galerien bestimmter Uploader aus der Galerieliste und den Suchergebnissen ausblenden möchtest, füge sie unten hinzu. Ein Benutzername pro Zeile. Galerien dieser Uploader erscheinen dann unabhängig von deiner Suchanfrage nie."; +"eh_setting_view.excluded_uploaders_count" = "Du belegst derzeit **%@ / %@** Ausschlussplätze."; + +"eh_setting_view.search_result_count" = "Anzahl der Suchergebnisse"; +"eh_setting_view.result_count" = "Anzahl Ergebnisse"; +"eh_setting_view.result_count_description" = "Wie viele Ergebnisse pro Seite möchtest du auf Index-, Such- und Torrent-Suchseiten sehen?\n(Hath-Perk „Paging Enlargement“ erforderlich)"; + +"eh_setting_view.thumbnail_settings" = "Vorschaubilder"; +"eh_setting_view.thumbnail_load_timing" = "Ladezeitpunkt der Vorschaubilder"; +"eh_setting_view.thumbnail_load_timing_description" = "Wann sollen die Mouseover-Vorschaubilder auf der Startseite im Listenmodus geladen werden?"; +"eh_setting_view.thumbnail_configuration" = "Du kannst eine Standardkonfiguration der Vorschaubilder für alle Galerien festlegen, die du besuchst."; +"eh_setting_view.thumbnail_size" = "Größe"; +"eh_setting_view.thumbnail_row_count" = "Zeilen"; // EhSetting.ThumbnailLoadTiming -"thumbnail_load_timing.on_mouse_over" = "On mouse-over"; -"thumbnail_load_timing.on_page_load" = "On page load"; -"thumbnail_load_timing.on_mouse_over_description" = "Pages load faster, but there may be a slight delay before a thumb appears."; -"thumbnail_load_timing.on_page_load_description" = "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded."; +"thumbnail_load_timing.on_mouse_over" = "Bei Mouseover"; +"thumbnail_load_timing.on_page_load" = "Beim Laden der Seite"; +"thumbnail_load_timing.on_mouse_over_description" = "Seiten laden schneller, Vorschaubilder erscheinen aber unter Umständen leicht verzögert."; +"thumbnail_load_timing.on_page_load_description" = "Seiten brauchen länger zum Laden, dafür erscheinen die Vorschaubilder danach ohne Verzögerung."; // EhSetting.ThumbnailSize "thumbnail_size.normal" = "Normal"; -"thumbnail_size.large" = "Large"; -"thumbnail_size.small" = "Small"; -"thumbnail_size.auto" = "Auto"; +"thumbnail_size.large" = "Groß"; +"thumbnail_size.small" = "Klein"; +"thumbnail_size.auto" = "Automatisch"; -"eh_setting_view.cover_scaling" = "Cover Scaling"; -"eh_setting_view.scale_factor" = "Scale factor"; -"eh_setting_view.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; +"eh_setting_view.cover_scaling" = "Cover-Skalierung"; +"eh_setting_view.scale_factor" = "Skalierungsfaktor"; +"eh_setting_view.cover_scale_factor" = "Die Covergröße in Galerielisten kann in den Anzeigemodi „Vorschaubilder“ und „Erweitert“ auf 75%% bis 150%% skaliert werden."; -"eh_setting_view.viewport_override" = "Viewport Override"; -"eh_setting_view.virtual_width" = "Virtual width"; -"eh_setting_view.virtual_width_description" = "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400."; +"eh_setting_view.viewport_override" = "Viewport-Überschreibung"; +"eh_setting_view.virtual_width" = "Virtuelle Breite"; +"eh_setting_view.virtual_width_description" = "Hiermit kannst du die virtuelle Breite der Seite auf Mobilgeräten überschreiben. Normalerweise bestimmt dein Gerät sie automatisch anhand der DPI. Sinnvolle Werte bei 100%% Vorschaubild-Skalierung liegen zwischen 640 und 1400."; -"eh_setting_view.gallery_comments" = "Gallery Comments"; -"eh_setting_view.comments_sort_order" = "Comments sort order"; -"eh_setting_view.comments_votes_show_timing" = "Comment votes show timing"; +"eh_setting_view.gallery_comments" = "Galerie-Kommentare"; +"eh_setting_view.comments_sort_order" = "Sortierung der Kommentare"; +"eh_setting_view.comments_votes_show_timing" = "Anzeige der Kommentarbewertungen"; // EhSetting.CommentsSortOrder -"comments_sort_order.oldest" = "Oldest comments first"; -"comments_sort_order.recent" = "Recent comments first"; -"comments_sort_order.highest_score" = "By highest score"; +"comments_sort_order.oldest" = "Älteste Kommentare zuerst"; +"comments_sort_order.recent" = "Neueste Kommentare zuerst"; +"comments_sort_order.highest_score" = "Nach höchster Punktzahl"; // EhSetting.CommentVotesShowTiming -"comments_votes_show_timing.on_hover_or_click" = "On score hover or click"; -"comments_votes_show_timing.always" = "Always"; +"comments_votes_show_timing.on_hover_or_click" = "Beim Überfahren oder Anklicken der Punktzahl"; +"comments_votes_show_timing.always" = "Immer"; -"eh_setting_view.gallery_tags" = "Gallery Tags"; -"eh_setting_view.tags_sort_order" = "Tags sort order"; +"eh_setting_view.gallery_tags" = "Galerie-Tags"; +"eh_setting_view.tags_sort_order" = "Sortierung der Tags"; // EhSetting.tags_sort_order -"tags_sort_order.alphabetical" = "Alphabetical"; -"tags_sort_order.tag_power" = "By tag power"; +"tags_sort_order.alphabetical" = "Alphabetisch"; +"tags_sort_order.tag_power" = "Nach Tag-Gewicht"; -"eh_setting_view.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; -"eh_setting_view.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; +"eh_setting_view.gallery_page_thumbnail_labeling" = "Beschriftung der Vorschaubilder"; +"eh_setting_view.show_label_below_gallery_thumbnails" = "Beschriftung unter Galerie-Vorschaubildern anzeigen"; -"eh_setting_view.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; -"eh_setting_view.use_original_images" = "Use original images"; +"eh_setting_view.original_images" = "Sollen Originalbilder statt der neu berechneten Versionen verwendet werden? Neu berechnete Bilder werden weiterhin verwendet, wenn du oben eine andere horizontale Auflösung als „Automatisch“ wählst und das betreffende Bild breiter ist, oder wenn das Originalbild größer als 10 MiB ist (bzw. 4 MiB bei Galerien, die älter als ein Jahr sind)."; +"eh_setting_view.use_original_images" = "Originalbilder verwenden"; -"eh_setting_view.multi_page_viewer" = "Multi-Page Viewer"; -"eh_setting_view.use_multi_page_viewer" = "Use Multi-Page Viewer"; -"eh_setting_view.display_style" = "Display style"; -"eh_setting_view.show_thumbnail_pane" = "Show thumbnail pane"; +"eh_setting_view.multi_page_viewer" = "Multi-Page-Viewer"; +"eh_setting_view.use_multi_page_viewer" = "Multi-Page-Viewer verwenden"; +"eh_setting_view.display_style" = "Darstellungsstil"; +"eh_setting_view.show_thumbnail_pane" = "Vorschaubild-Leiste anzeigen"; // EhSetting.MultiplePageViewerStyle -"multiple_page_viewer_style.align_left_scale_if_over_width" = "Align left, scale if overwidth"; -"multiple_page_viewer_style.align_center_scale_if_over_width" = "Align center, scale if overwidth"; -"multiple_page_viewer_style.align_center_always_scale" = "Align center, always scale"; +"multiple_page_viewer_style.align_left_scale_if_over_width" = "Linksbündig, bei Überbreite skalieren"; +"multiple_page_viewer_style.align_center_scale_if_over_width" = "Zentriert, bei Überbreite skalieren"; +"multiple_page_viewer_style.align_center_always_scale" = "Zentriert, immer skalieren"; // EhSetting.GalleryPageNumbering -"gallery_page_numbering.none" = "None"; -"gallery_page_numbering.page_number_only" = "Page Number Only"; -"gallery_page_numbering.page_number_and_name" = "Page Number + Name"; +"gallery_page_numbering.none" = "Keine"; +"gallery_page_numbering.page_number_only" = "Nur Seitenzahl"; +"gallery_page_numbering.page_number_and_name" = "Seitenzahl + Name"; // MARK: Category "category.doujinshi" = "Doujinshi"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index a08e5c21a..e82a5371d 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -389,17 +389,17 @@ "tag_detail_view.links" = "リンク"; // MARK: DownloadsView -"download_folder_filter.all" = "All"; -"detail_view.manage_folders" = "Manage Folders"; -"detail_view.create_default_folder" = "Create Default Folder"; -"detail_view.no_folders" = "No folders yet"; -"downloads_view.manage_folders" = "Manage Folders"; -"downloads_view.move_to_folder" = "Move to Folder"; -"downloads_view.move" = "Move"; -"folder_manager_view.folders" = "Folders"; -"folder_manager_view.folder_name" = "Folder name"; -"folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_folders" = "Folders you create will appear here."; +"download_folder_filter.all" = "すべて"; +"detail_view.manage_folders" = "フォルダを管理"; +"detail_view.create_default_folder" = "デフォルトフォルダを作成"; +"detail_view.no_folders" = "フォルダはまだありません"; +"downloads_view.manage_folders" = "フォルダを管理"; +"downloads_view.move_to_folder" = "フォルダに移動"; +"downloads_view.move" = "移動"; +"folder_manager_view.folders" = "フォルダ"; +"folder_manager_view.folder_name" = "フォルダ名"; +"folder_manager_view.delete_folder" = "フォルダとその中のダウンロード済みギャラリーをすべて削除します。"; +"folder_manager_view.empty_folders" = "作成したフォルダはここに表示されます。"; "downloads_view.downloads" = "ダウンロード"; "downloads_view.search_downloads" = "ダウンロードを検索"; "downloads_view.delete_download" = "ダウンロードを削除しますか?"; @@ -469,10 +469,10 @@ // MARK: DownloadStore "download_store.asset_unreadable" = "アセットファイルを読み取れません: %@"; -"download_store.invalid_folder_name" = "The folder name is invalid."; -"download_store.folder_already_exists" = "A folder with this name already exists."; -"download_store.folder_busy_downloading" = "The folder contains an active download."; -"download_store.download_busy" = "The download is currently active."; +"download_store.invalid_folder_name" = "フォルダ名が無効です。"; +"download_store.folder_already_exists" = "同じ名前のフォルダがすでに存在します。"; +"download_store.folder_busy_downloading" = "フォルダに進行中のダウンロードがあります。"; +"download_store.download_busy" = "ダウンロードは現在進行中です。"; "download_store.download_folder_missing" = "ダウンロードフォルダが見つかりません。"; "download_store.manifest_missing" = "マニフェストファイルが見つかりません。"; "download_store.manifest_corrupted" = "マニフェストファイルが破損しています。"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index fa5b7848e..214dc8c54 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -1,5 +1,5 @@ // MARK: BanInterval -"ban_interval.and" = "and"; +"ban_interval.and" = ""; // MARK: ToplistsType "toplists_type.yesterday" = "어제"; @@ -26,20 +26,20 @@ // MARK: Common value "common.stars" = "%@별"; "common.pages" = "%@페이지"; -"common.day" = "%@ day"; -"common.days" = "%@ days"; -"common.hour" = "%@ hour"; -"common.hours" = "%@ hours"; -"common.minute" = "%@ 분"; -"common.minutes" = "%@ 분"; -"common.second" = "%@ 초"; -"common.seconds" = "%@ 초"; +"common.day" = "%@일"; +"common.days" = "%@일"; +"common.hour" = "%@시간"; +"common.hours" = "%@시간"; +"common.minute" = "%@분"; +"common.minutes" = "%@분"; +"common.second" = "%@초"; +"common.seconds" = "%@초"; // MARK: Common button "common.cancel" = "취소"; // MARK: TabItem -"tab_item.home" = "Home"; +"tab_item.home" = "홈"; "tab_item.favorites" = "즐겨찾기"; "tab_item.search" = "검색"; "tab_item.downloads" = "다운로드"; @@ -64,18 +64,18 @@ // MARK: AlertView "loading_view.loading" = "로딩 중..."; -"loading_view.preparing_database" = "Preparing the database..."; -"not_login_view.need_login" = "You need to login to access this feature."; -"not_login_viewlogin" = "Login"; +"loading_view.preparing_database" = "데이터베이스 준비 중..."; +"not_login_view.need_login" = "이 기능을 사용하려면 로그인이 필요해요."; +"not_login_viewlogin" = "로그인"; "error_view.retry" = "재시도"; -"error_view.drop_database" = "Drop the database"; +"error_view.drop_database" = "데이터베이스 삭제"; "error_view.try_later" = "잠시 후 다시 시도해 주세요."; "error_view.network" = "인터넷 접속 오류가 발생했어요."; "error_view.parsing" = "구분 분석 오류가 발생했어요."; "error_view.unknown" = "알 수 없는 오류가 발생했어요."; "error_view.not_found" = "여기가 아무도 없는 것 같습니다."; -"error_view.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; -"error_view.ip_banned" = "자동화된 미러링/수집 소프트웨어를 사용 중임을 나타내는 과도한 페이지 로드로 인해 IP 주소가 일시적으로 금지되었습니다. 금지효과는 %@ 에서 만료되었습니다."; +"error_view.database_corrupted" = "데이터베이스가 손상되었어요.\nGitHub에 이슈를 남겨주세요."; +"error_view.ip_banned" = "자동화된 미러링/수집 소프트웨어 사용이 의심되는 과도한 페이지 로드로 인해 IP 주소가 일시적으로 차단되었어요. 차단은 %@ 후에 해제돼요."; "error_view.copyright_claim" = "%@의 저작권 요청으로 인하여 이 갤러리를 사용할 수 없어요."; "error_view.gallery_unavailable" = "이 갤러리는 제거되었거나 사용할 수 없어요."; @@ -98,14 +98,14 @@ "app_error.local_file_operation_failed" = "로컬 파일 작업에 실패했습니다."; // MARK: ConfirmationDialog -"confirmation_dialog.drop_database_description" = "You will lose all your data in this app.\nAre you sure to drop the database?"; -"confirmation_dialog.remove_custom_translations" = "Are you sure to remove your custom translations?"; +"confirmation_dialog.drop_database_description" = "이 앱의 모든 데이터를 잃게 돼요.\n정말 데이터베이스를 삭제하시겠어요?"; +"confirmation_dialog.remove_custom_translations" = "사용자 지정 번역을 삭제하시겠어요?"; "confirmation_dialog.logout_description" = "로그아웃 하시겠어요?"; -"confirmation_dialog.delete_description" = "Are you sure to delete this item?"; +"confirmation_dialog.delete_description" = "이 항목을 삭제하시겠어요?"; "confirmation_dialog.clear_description" = "삭제하시겠어요?"; "confirmation_dialog.reset_description" = "초기화하시겠어요?"; -"confirmation_dialog.drop_database" = "Drop the database"; -"confirmation_dialog.remove" = "Remove"; +"confirmation_dialog.drop_database" = "데이터베이스 삭제"; +"confirmation_dialog.remove" = "삭제"; "confirmation_dialog.logout" = "로그아웃"; "confirmation_dialog.delete" = "삭제"; "confirmation_dialog.clear" = "삭제"; @@ -127,7 +127,7 @@ "home_view.home" = "홈"; "home_view.frontpage" = "프론트 페이지"; "home_view.toplists" = "상위 목록"; -"home_view.other" = "Other"; +"home_view.other" = "기타"; // HomeMiscGridType "home_misc_grid_type.popular" = "인기 작품"; "home_misc_grid_type.watched" = "주시 태그"; @@ -156,20 +156,20 @@ // MARK: SearchView "search_view.search" = "검색"; -"search_view.recently_searched" = "Recently searched"; -"search_view.recently_seen" = "Recently seen"; +"search_view.recently_searched" = "최근 검색어"; +"search_view.recently_seen" = "최근 본 항목"; "search_view.quick_search" = "빠른 검색"; // Searchable -"searchable.filter" = "Filter"; -"searchable.matches_count" = "Found %d matches."; +"searchable.filter" = "필터"; +"searchable.matches_count" = "검색 결과 %d개"; // MARK: QuickSearchView "quick_search_view.quick_search" = "빠른 검색"; -"quick_search_view.edit_word" = "Edit word"; -"quick_search_view.new_word" = "New word"; -"quick_search_view.content" = "Content"; -"quick_search_view.name" = "Name"; -"quick_search_view.optional" = "Optional"; +"quick_search_view.edit_word" = "키워드 편집"; +"quick_search_view.new_word" = "새 키워드"; +"quick_search_view.content" = "내용"; +"quick_search_view.name" = "이름"; +"quick_search_view.optional" = "선택 사항"; // MARK: SettingView "setting_view.setting" = "설정"; @@ -180,7 +180,7 @@ "setting_state_route.reading" = "읽기"; "setting_state_route.download" = "다운로드"; "setting_state_route.laboratory" = "실험실"; -"setting_state_route.about" = "About"; +"setting_state_route.about" = "정보"; // MARK: AccountSettingView "account_setting_view.account" = "계정"; @@ -192,7 +192,7 @@ // CookieValue "cookie_value.expired" = "만료됨"; "cookie_value.mystery" = "거절됨"; -"cookie_value.none" = "None"; +"cookie_value.none" = "없음"; // MARK: LoginView "login_view.login" = "로그인"; @@ -203,19 +203,19 @@ "general_setting_view.general" = "일반"; "general_setting_view.language" = "언어"; "general_setting_view.auto_lock" = "앱 자동 잠금"; -"general_setting_view.enables_tags_extension" = "Enables tags extension"; +"general_setting_view.enables_tags_extension" = "태그 확장 기능 사용하기"; "general_setting_view.translates_tags" = "태그 번역하기"; -"general_setting_view.shows_tags_search_suggestion" = "Shows tags search suggestion"; -"general_setting_view.shows_images_in_tags" = "Shows images in tags"; +"general_setting_view.shows_tags_search_suggestion" = "태그 검색 제안 보여주기"; +"general_setting_view.shows_images_in_tags" = "태그에 이미지 보여주기"; "general_setting_view.redirects_links_to_the_selected_host" = "선택한 서버로 이동하기"; "general_setting_view.detects_links_from_clipboard" = "클립보드의 링크 인식하기"; -"general_setting_view.background_blur_radius" = "Background blur radius"; +"general_setting_view.background_blur_radius" = "백그라운드 흐림 정도"; "general_setting_view.app_activity_logs" = "앱 활동 로그"; -"general_setting_view.import_custom_translations" = "Import custom translations"; -"general_setting_view.remove_custom_translations" = "Remove custom translations"; +"general_setting_view.import_custom_translations" = "사용자 지정 번역 가져오기"; +"general_setting_view.remove_custom_translations" = "사용자 지정 번역 삭제"; "general_setting_view.clear_image_caches" = "이미지 캐시 지우기"; -"general_setting_view.default_language_description" = "N/A"; -"general_setting_view.tags" = "Tags"; +"general_setting_view.default_language_description" = "알 수 없음"; +"general_setting_view.tags" = "태그"; "general_setting_view.navigation" = "내비게이션"; "general_setting_view.security" = "개인 정보 보호"; "general_setting_view.caches" = "캐시"; @@ -245,21 +245,21 @@ "appearance_setting_view.display_mode" = "표시방식"; "appearance_setting_view.shows_tags_in_list" = "리스트에서 태그 보여주기"; "appearance_setting_view.maximum_number_of_tags" = "태그 갯수"; -"appearance_setting_view.displays_japanese_title" = "Displays Japanese title"; +"appearance_setting_view.displays_japanese_title" = "일본어 제목 보여주기"; "appearance_setting_view.app_icon" = "앱 아이콘"; "appearance_setting_view.infite" = "제한 없음"; "appearance_setting_view.list" = "리스트"; -"appearance_setting_view.gallery" = "Gallery"; +"appearance_setting_view.gallery" = "갤러리"; // PreferredColorScheme "preferred_color_scheme.automatic" = "자동"; "preferred_color_scheme.light" = "라이트"; "preferred_color_scheme.dark" = "다크"; // AppIconType "app_icon_type.default" = "기본"; -"app_icon_type.ukiyoe" = "Ukiyo-e"; -"app_icon_type.developer" = "Developer"; -"app_icon_type.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"app_icon_type.not_my_president" = "NOT MY PRESIDENT"; +"app_icon_type.ukiyoe" = "우키요에"; +"app_icon_type.developer" = "개발자"; +"app_icon_type.stand_with_ukraine_2022" = "우크라이나와 함께 (2022)"; +"app_icon_type.not_my_president" = "내 대통령이 아니다"; // ListDisplayMode "list_display_mode.detail" = "자세히"; "list_display_mode.thumbnail" = "썸네일"; @@ -271,7 +271,7 @@ "reading_setting_view.reading" = "읽기"; "reading_setting_view.direction" = "방향"; "reading_setting_view.preload_limit" = "페이지 미리 로딩"; -"reading_setting_view.enables_landscape" = "Enables landscape"; +"reading_setting_view.enables_landscape" = "가로 화면 사용하기"; "reading_setting_view.separator_height" = "페이지 간 여백 두께"; "reading_setting_view.maximum_scale_factor" = "최대 확대 비율"; "reading_setting_view.double_tap_scale_factor" = "더블 탭 확대 비율"; @@ -290,9 +290,9 @@ "about_view.website" = "웹사이트"; "about_view.altStore_source" = "AltStore 소스"; "about_view.version" = "버전"; -"about_view.special_thanks" = "Special thanks"; -"about_view.code_level_contributors" = "Code-level contributors"; -"about_view.translation_contributors" = "Translation contributors"; +"about_view.special_thanks" = "특별히 감사드리는 분들"; +"about_view.code_level_contributors" = "코드 기여자"; +"about_view.translation_contributors" = "번역 기여자"; "about_view.acknowledgements" = "도움을 주신 분들"; // MARK: DetailView @@ -313,10 +313,10 @@ "detail_view.archives" = "아카이브"; "detail_view.torrents" = "토렌트"; "detail_view.share" = "공유"; -"detail_view.detail" = "Detail"; -"detail_view.withdraw_vote" = "Withdraw vote"; -"detail_view.vote_up" = "Vote up"; -"detail_view.vote_down" = "Vote down"; +"detail_view.detail" = "세부 정보"; +"detail_view.withdraw_vote" = "투표 취소"; +"detail_view.vote_up" = "찬성 투표"; +"detail_view.vote_down" = "반대 투표"; "detail_view.favorited" = "즐겨찾기"; "detail_view.language" = "언어"; "detail_view.ratings" = "%@명의 별점"; @@ -355,7 +355,7 @@ // MARK: GalleryInfosView "gallery_infos_view.gallery_infos" = "갤러리 정보"; "gallery_infos_view.id" = "ID"; -"gallery_infos_view.token" = "Token"; +"gallery_infos_view.token" = "토큰"; "gallery_infos_view.title" = "제목"; "gallery_infos_view.japanese_title" = "일본어 제목"; "gallery_infos_view.gallery_URL" = "갤러리 주소"; @@ -374,9 +374,9 @@ "gallery_infos_view.favorited" = "즐겨찾기에 저장 됨"; "gallery_infos_view.rating_count" = "별점 갯수"; "gallery_infos_view.average_rating" = "평균 별점"; -"gallery_infos_view.my_rating" = "My rating"; +"gallery_infos_view.my_rating" = "내 별점"; "gallery_infos_view.torrent_count" = "토렌트 수"; -"gallery_infos_view.none" = "None"; +"gallery_infos_view.none" = "없음"; "gallery_infos_view.yes" = "네"; "gallery_infos_view.no" = "아니요"; // GalleryVisibility @@ -385,21 +385,21 @@ "gallery_visibility.expunged" = "삭제됨"; // MARK: TagDetailView -"tag_detail_view.images" = "Images"; -"tag_detail_view.links" = "Links"; +"tag_detail_view.images" = "이미지"; +"tag_detail_view.links" = "링크"; // MARK: DownloadsView -"download_folder_filter.all" = "All"; -"detail_view.manage_folders" = "Manage Folders"; -"detail_view.create_default_folder" = "Create Default Folder"; -"detail_view.no_folders" = "No folders yet"; -"downloads_view.manage_folders" = "Manage Folders"; -"downloads_view.move_to_folder" = "Move to Folder"; -"downloads_view.move" = "Move"; -"folder_manager_view.folders" = "Folders"; -"folder_manager_view.folder_name" = "Folder name"; -"folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_folders" = "Folders you create will appear here."; +"download_folder_filter.all" = "전체"; +"detail_view.manage_folders" = "폴더 관리"; +"detail_view.create_default_folder" = "기본 폴더 만들기"; +"detail_view.no_folders" = "아직 폴더가 없습니다"; +"downloads_view.manage_folders" = "폴더 관리"; +"downloads_view.move_to_folder" = "폴더로 이동"; +"downloads_view.move" = "이동"; +"folder_manager_view.folders" = "폴더"; +"folder_manager_view.folder_name" = "폴더 이름"; +"folder_manager_view.delete_folder" = "폴더와 그 안에 다운로드한 모든 갤러리를 삭제합니다."; +"folder_manager_view.empty_folders" = "만든 폴더가 여기에 표시됩니다."; "downloads_view.downloads" = "다운로드"; "downloads_view.search_downloads" = "다운로드 검색"; "downloads_view.delete_download" = "다운로드를 삭제할까요?"; @@ -446,16 +446,16 @@ "reading_view.reload" = "재시도"; "reading_view.copy" = "복사"; "reading_view.save" = "저장"; -"reading_view.save_original" = "Save original"; +"reading_view.save_original" = "원본 저장"; "reading_view.share" = "공유"; "reading_view.auto_play" = "자동 재생"; "reading_view.dual_page_mode" = "두 장을 한 화면으로 보기"; "reading_view.except_the_cover" = "표지 제외하기"; -"reading_view.retry_all_failed_images" = "Retry failed images"; -"reading_view.reload_all_images" = "Reload all images"; -"reading_view.reading_setting" = "Reading setting"; +"reading_view.retry_all_failed_images" = "실패한 이미지 모두 재시도"; +"reading_view.reload_all_images" = "모든 이미지 다시 불러오기"; +"reading_view.reading_setting" = "읽기 설정"; // AutoPlayPolicy -"auto_play_policy.off" = "Off"; +"auto_play_policy.off" = "끔"; // MARK: DownloadBadge @@ -469,10 +469,10 @@ // MARK: DownloadStore "download_store.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; -"download_store.invalid_folder_name" = "The folder name is invalid."; -"download_store.folder_already_exists" = "A folder with this name already exists."; -"download_store.folder_busy_downloading" = "The folder contains an active download."; -"download_store.download_busy" = "The download is currently active."; +"download_store.invalid_folder_name" = "폴더 이름이 올바르지 않습니다."; +"download_store.folder_already_exists" = "같은 이름의 폴더가 이미 있습니다."; +"download_store.folder_busy_downloading" = "폴더에 진행 중인 다운로드가 있습니다."; +"download_store.download_busy" = "다운로드가 진행 중입니다."; "download_store.download_folder_missing" = "다운로드 폴더가 없습니다."; "download_store.manifest_missing" = "매니페스트 파일이 없습니다."; "download_store.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; @@ -513,7 +513,7 @@ "eh_setting_view.delete_profile" = "프로필 삭제"; "eh_setting_view.rename" = "이름 변경"; "eh_setting_view.create_new" = "추가"; -"eh_setting_view.done" = "Done"; +"eh_setting_view.done" = "완료"; "eh_setting_view.image_load_settings" = "이미지 로드 설정"; "eh_setting_view.load_images_through_the_hath_network" = "Hath 네트워크를 통하여 이미지 로드"; @@ -527,7 +527,7 @@ "load_through_hath_setting.any_client_description" = "추천."; "load_through_hath_setting.default_port_only_description" = "더 느려질 수 있어요. 나가는 비표준 포트를 차단하는 방화벽/프록시가 있는 경우 사용하세요."; "load_through_hath_setting.modern_no_description" = "기부자 전용 기능이에요. 심각한 문제가 있는 경우를 제외하고는 사용하지 말아주세요."; -"load_through_hath_setting.legacy_no_description" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; +"load_through_hath_setting.legacy_no_description" = "기부자 전용 기능이에요. 최신 브라우저에서는 제대로 작동하지 않을 수 있어요. 오래된 브라우저에서만 사용해주세요."; "eh_setting_view.image_size_settings" = "이미지 사이즈 설정"; "eh_setting_view.image_resolution" = "이미지 해상도"; @@ -560,19 +560,19 @@ "eh_setting_view.front_page_settings" = "프론트 페이지 설정"; "eh_setting_view.display_mode" = "표시방식"; "eh_setting_view.display_mode_description" = "프론트와 검색 페이지에서 사용할 디스플레이 모드를 선택하세요."; -"eh_setting_view.show_search_range_indicator" = "Search Range Indicator"; -"eh_setting_view.show_search_range_indicator_description" = "Show search range indicator"; +"eh_setting_view.show_search_range_indicator" = "검색 범위 표시기"; +"eh_setting_view.show_search_range_indicator_description" = "검색 범위 표시기 표시"; "eh_setting_view.gallery_category" = "프론트와 검색 페이지에서 어떤 카테고리가 보여지도록 할까요?"; // EhSetting.DisplayMode -"display_mode.compact" = "Compact"; -"display_mode.thumbnail" = "Thumbnail"; -"display_mode.extended" = "Extended"; -"display_mode.minimal" = "Minimal"; -"display_mode.minimalPlus" = "Minimal+"; +"display_mode.compact" = "컴팩트"; +"display_mode.thumbnail" = "썸네일"; +"display_mode.extended" = "확장"; +"display_mode.minimal" = "미니멀"; +"display_mode.minimalPlus" = "미니멀+"; -"eh_setting_view.optional_UI_elements" = "Optional UI Elements"; -"eh_setting_view.optional_UI_elements_description" = "Some historic UI elements are now disabled by default. You can enable those here."; -"eh_setting_view.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; +"eh_setting_view.optional_UI_elements" = "선택적 UI 요소"; +"eh_setting_view.optional_UI_elements_description" = "일부 예전 UI 요소는 이제 기본적으로 꺼져 있어요. 여기서 다시 켤 수 있어요."; +"eh_setting_view.enable_gallery_thumbnail_selector" = "갤러리 화면에서 썸네일 선택기 사용"; "eh_setting_view.favorites" = "즐겨찾기"; "eh_setting_view.favorite_categories" = "여기서 좋아하는 장르들을 선택하고 이름을 바꿀 수 있어요."; @@ -593,9 +593,9 @@ "eh_setting_view.tag_watching_threshold" = "태그 보여주기 임계값"; "eh_setting_view.tag_watching_threshold_description" = "최근에 업로드된 갤러리는 최소 1개의 Watched 태그가 있고 Watched 태그의 가중치의 합이 이 값 이상이 될 경우 Watched 화면에 포함되어요. 이 임계값은 0과 9999 사이에서 설정할 수 있어요."; -"eh_setting_viewfiltered_removal_count" = "Show Filtered Removal Count"; -"eh_setting_view.filtered_removal_count_description" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; -"eh_setting_view.show_filtered_removal_count" = "Show filtered removal count"; +"eh_setting_viewfiltered_removal_count" = "필터로 제거된 수"; +"eh_setting_view.filtered_removal_count_description" = "\"기본 필터가 이 페이지에서 갤러리 XX개를 제거했어요\" 문구를 표시할까요?"; +"eh_setting_view.show_filtered_removal_count" = "필터로 제거된 수 표시"; "eh_setting_view.excluded_languages" = "제외된 언어"; "eh_setting_view.excluded_languages_description" = "갤러리 목록에서 특정 언어로 된 갤러리를 숨기고 검색하려면 아래 목록에서 해당 갤러리를 선택해주세요. 검색어에 관계없이 일치하는 갤러리는 나타나지 않아요."; @@ -626,12 +626,12 @@ // EhSetting.ThumbnailSize "thumbnail_size.normal" = "보통"; "thumbnail_size.large" = "크게"; -"thumbnail_size.small" = "Small"; -"thumbnail_size.auto" = "Auto"; +"thumbnail_size.small" = "작게"; +"thumbnail_size.auto" = "자동"; -"eh_setting_view.cover_scaling" = "Cover Scaling"; +"eh_setting_view.cover_scaling" = "표지 크기 조절"; "eh_setting_view.scale_factor" = "크기 비율"; -"eh_setting_view.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; +"eh_setting_view.cover_scale_factor" = "썸네일 또는 확장 표시방식에서는 갤러리 목록의 표지 크기를 75%%에서 150%% 사이로 조절할 수 있어요."; "eh_setting_view.viewport_override" = "뷰포트 조정"; "eh_setting_view.virtual_width" = "가상 너비"; @@ -654,11 +654,11 @@ "tags_sort_order.alphabetical" = "알파벳순으로"; "tags_sort_order.tag_power" = "태크 가중치로"; -"eh_setting_view.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; -"eh_setting_view.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; +"eh_setting_view.gallery_page_thumbnail_labeling" = "갤러리 페이지 썸네일 라벨"; +"eh_setting_view.show_label_below_gallery_thumbnails" = "갤러리 썸네일 아래에 라벨 표시"; -"eh_setting_view.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; +"eh_setting_view.original_images" = "다시 샘플링된 버전 대신 원본 이미지를 사용할까요? 위에서 가로 해상도를 \"자동\" 이외로 선택했고 해당 이미지가 그보다 넓은 경우나, 원본 이미지가 10 MiB(1년 넘은 갤러리는 4 MiB)보다 큰 경우에는 다시 샘플링된 이미지가 계속 사용되어요."; "eh_setting_view.use_original_images" = "원본 뷰어 적용"; "eh_setting_view.multi_page_viewer" = "멀티 페이지 뷰어"; @@ -670,9 +670,9 @@ "multiple_page_viewer_style.align_center_scale_if_over_width" = "가운데 정렬, 너비 초과할 때 크기 맞추기"; "multiple_page_viewer_style.align_center_always_scale" = "가운데 정렬, 항상 크기 맞추기"; // EhSetting.GalleryPageNumbering -"gallery_page_numbering.none" = "None"; -"gallery_page_numbering.page_number_only" = "Page Number Only"; -"gallery_page_numbering.page_number_and_name" = "Page Number + Name"; +"gallery_page_numbering.none" = "표시 안 함"; +"gallery_page_numbering.page_number_only" = "페이지 번호만"; +"gallery_page_numbering.page_number_and_name" = "페이지 번호와 이름"; // MARK: Category "category.doujinshi" = "동인지"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index c233e860b..62f70d143 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -389,17 +389,17 @@ "tag_detail_view.links" = "链接"; // MARK: DownloadsView -"download_folder_filter.all" = "All"; -"detail_view.manage_folders" = "Manage Folders"; -"detail_view.create_default_folder" = "Create Default Folder"; -"detail_view.no_folders" = "No folders yet"; -"downloads_view.manage_folders" = "Manage Folders"; -"downloads_view.move_to_folder" = "Move to Folder"; -"downloads_view.move" = "Move"; -"folder_manager_view.folders" = "Folders"; -"folder_manager_view.folder_name" = "Folder name"; -"folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_folders" = "Folders you create will appear here."; +"download_folder_filter.all" = "全部"; +"detail_view.manage_folders" = "管理文件夹"; +"detail_view.create_default_folder" = "创建默认文件夹"; +"detail_view.no_folders" = "还没有文件夹"; +"downloads_view.manage_folders" = "管理文件夹"; +"downloads_view.move_to_folder" = "移动到文件夹"; +"downloads_view.move" = "移动"; +"folder_manager_view.folders" = "文件夹"; +"folder_manager_view.folder_name" = "文件夹名称"; +"folder_manager_view.delete_folder" = "这将删除该文件夹及其中所有已下载的画廊。"; +"folder_manager_view.empty_folders" = "创建的文件夹会显示在这里。"; "downloads_view.downloads" = "下载"; "downloads_view.search_downloads" = "搜索下载"; "downloads_view.delete_download" = "删除下载?"; @@ -469,10 +469,10 @@ // MARK: DownloadStore "download_store.asset_unreadable" = "资源文件无法读取:%@"; -"download_store.invalid_folder_name" = "The folder name is invalid."; -"download_store.folder_already_exists" = "A folder with this name already exists."; -"download_store.folder_busy_downloading" = "The folder contains an active download."; -"download_store.download_busy" = "The download is currently active."; +"download_store.invalid_folder_name" = "文件夹名称无效。"; +"download_store.folder_already_exists" = "已存在同名文件夹。"; +"download_store.folder_busy_downloading" = "该文件夹中有正在进行的下载。"; +"download_store.download_busy" = "该下载正在进行中。"; "download_store.download_folder_missing" = "下载文件夹缺失。"; "download_store.manifest_missing" = "Manifest 文件缺失。"; "download_store.manifest_corrupted" = "Manifest 文件已损坏。"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index ffb0c2017..68da053f6 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -389,17 +389,17 @@ "tag_detail_view.links" = "連結"; // MARK: DownloadsView -"download_folder_filter.all" = "All"; -"detail_view.manage_folders" = "Manage Folders"; -"detail_view.create_default_folder" = "Create Default Folder"; -"detail_view.no_folders" = "No folders yet"; -"downloads_view.manage_folders" = "Manage Folders"; -"downloads_view.move_to_folder" = "Move to Folder"; -"downloads_view.move" = "Move"; -"folder_manager_view.folders" = "Folders"; -"folder_manager_view.folder_name" = "Folder name"; -"folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_folders" = "Folders you create will appear here."; +"download_folder_filter.all" = "全部"; +"detail_view.manage_folders" = "管理資料夾"; +"detail_view.create_default_folder" = "建立預設資料夾"; +"detail_view.no_folders" = "還沒有資料夾"; +"downloads_view.manage_folders" = "管理資料夾"; +"downloads_view.move_to_folder" = "移到資料夾"; +"downloads_view.move" = "移動"; +"folder_manager_view.folders" = "資料夾"; +"folder_manager_view.folder_name" = "資料夾名稱"; +"folder_manager_view.delete_folder" = "這將刪除此資料夾及其中所有已下載的畫廊。"; +"folder_manager_view.empty_folders" = "建立的資料夾會顯示在這裡。"; "downloads_view.downloads" = "下載"; "downloads_view.search_downloads" = "搜尋下載"; "downloads_view.delete_download" = "刪除下載?"; @@ -469,10 +469,10 @@ // MARK: DownloadStore "download_store.asset_unreadable" = "資源檔案無法讀取:%@"; -"download_store.invalid_folder_name" = "The folder name is invalid."; -"download_store.folder_already_exists" = "A folder with this name already exists."; -"download_store.folder_busy_downloading" = "The folder contains an active download."; -"download_store.download_busy" = "The download is currently active."; +"download_store.invalid_folder_name" = "資料夾名稱無效。"; +"download_store.folder_already_exists" = "已存在同名的資料夾。"; +"download_store.folder_busy_downloading" = "此資料夾中有正在進行的下載。"; +"download_store.download_busy" = "此下載正在進行中。"; "download_store.download_folder_missing" = "下載資料夾缺失。"; "download_store.manifest_missing" = "Manifest 檔案缺失。"; "download_store.manifest_corrupted" = "Manifest 檔案已損壞。"; From b7a693ed0c0b7406f20fdadbe7a8d36215e845a2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:14:27 +0800 Subject: [PATCH 451/614] Realign locales, drop straggler unused key The unused key `download_store.cover_image_missing` had been removed from the 5 non-en locales but left in en.lproj, leaving en with 865 keys vs 864 elsewhere. Removing the leftover in a prior pass also left a stray double blank line before `eh_setting_view.original_images` in each non-en locale. - Remove `download_store.cover_image_missing` from en.lproj and regenerate Strings.swift. - Collapse the stray blank line in de/ja/ko/zh-Hans/zh-Hant. All 6 locales now identical: 1023 lines, 864 keys, key names and comment/blank lines at matching positions, zero duplicates. --- .../Resources/de.lproj/Localizable.strings | 1 - .../Resources/en.lproj/Localizable.strings | 1 - .../Resources/ja.lproj/Localizable.strings | 1 - .../Resources/ko.lproj/Localizable.strings | 1 - .../zh-Hans.lproj/Localizable.strings | 1 - .../zh-Hant.lproj/Localizable.strings | 1 - AppPackage/Sources/Resources/Strings.swift | 57 ------------------- 7 files changed, 63 deletions(-) diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index dbdcdeecf..4ef069e46 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -657,7 +657,6 @@ "eh_setting_view.gallery_page_thumbnail_labeling" = "Beschriftung der Vorschaubilder"; "eh_setting_view.show_label_below_gallery_thumbnails" = "Beschriftung unter Galerie-Vorschaubildern anzeigen"; - "eh_setting_view.original_images" = "Sollen Originalbilder statt der neu berechneten Versionen verwendet werden? Neu berechnete Bilder werden weiterhin verwendet, wenn du oben eine andere horizontale Auflösung als „Automatisch“ wählst und das betreffende Bild breiter ist, oder wenn das Originalbild größer als 10 MiB ist (bzw. 4 MiB bei Galerien, die älter als ein Jahr sind)."; "eh_setting_view.use_original_images" = "Originalbilder verwenden"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 57f8e0ebf..b44e29e80 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -476,7 +476,6 @@ "download_store.download_folder_missing" = "Download folder is missing."; "download_store.manifest_missing" = "Manifest file is missing."; "download_store.manifest_corrupted" = "Manifest file is corrupted."; -"download_store.cover_image_missing" = "Cover image is missing."; "download_store.page_missing" = "Page %d is missing."; "download_store.page_image_corrupted" = "Page %d image data is corrupted."; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index e82a5371d..74bb6f182 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -657,7 +657,6 @@ "eh_setting_view.gallery_page_thumbnail_labeling" = "ギャラリーサムネイルのラベル"; "eh_setting_view.show_label_below_gallery_thumbnails" = "ギャラリーサムネイルの下にラベルを表示"; - "eh_setting_view.original_images" = "オリジナル画像を使いますか?リサンプリングされた画像は、上記の解像度で「自動」以外を選択し、該当する画像の方が幅が広い場合、またはオリジナル画像が 10 MiB(一年以上前のギャラリーの場合は 4 MiB)より大きい場合に使用されます。"; "eh_setting_view.use_original_images" = "オリジナル画像を使う"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index 214dc8c54..7cf7ba17f 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -657,7 +657,6 @@ "eh_setting_view.gallery_page_thumbnail_labeling" = "갤러리 페이지 썸네일 라벨"; "eh_setting_view.show_label_below_gallery_thumbnails" = "갤러리 썸네일 아래에 라벨 표시"; - "eh_setting_view.original_images" = "다시 샘플링된 버전 대신 원본 이미지를 사용할까요? 위에서 가로 해상도를 \"자동\" 이외로 선택했고 해당 이미지가 그보다 넓은 경우나, 원본 이미지가 10 MiB(1년 넘은 갤러리는 4 MiB)보다 큰 경우에는 다시 샘플링된 이미지가 계속 사용되어요."; "eh_setting_view.use_original_images" = "원본 뷰어 적용"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 62f70d143..2c31133b7 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -657,7 +657,6 @@ "eh_setting_view.gallery_page_thumbnail_labeling" = "画廊页面缩略图标签"; "eh_setting_view.show_label_below_gallery_thumbnails" = "在画廊缩略图下方显示标签"; - "eh_setting_view.original_images" = "是否使用原始图像而非重新采样的版本?如果您在上方选择的水平分辨率不是“自动”,并且所查看的图像更宽,或者原始图像大于 10 MiB(对于超过一年的图库,则为 4 MiB),那么仍将使用重新采样的图像。"; "eh_setting_view.use_original_images" = "显示原图"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 68da053f6..986005399 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -657,7 +657,6 @@ "eh_setting_view.gallery_page_thumbnail_labeling" = "畫廊頁面縮圖標籤"; "eh_setting_view.show_label_below_gallery_thumbnails" = "在畫廊縮圖下方顯示標籤"; - "eh_setting_view.original_images" = "要使用原始圖片而非重新取樣的版本嗎? 若您在上方選擇「自動」以外的水準解析度且圖片較寬,或原始圖片大於 10 MiB(一年以上的圖庫則為 4 MiB),系統仍會使用重新取樣的圖片。"; "eh_setting_view.use_original_images" = "使用原始圖片(原解析度)"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index e669fa0b0..228142d9d 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -51,8 +51,6 @@ public enum L10n { public static let swiftGen = L10n.tr("Constant", "acknowledgement.swiftGen", fallback: "SwiftGen") /// https://github.com/SwiftGen/SwiftGen public static let swiftGenLink = L10n.tr("Constant", "acknowledgement.swiftGen_link", fallback: "https://github.com/SwiftGen/SwiftGen") - /// SwiftUI Navigation - public static let swiftUINavigation = L10n.tr("Constant", "acknowledgement.swiftUINavigation", fallback: "SwiftUI Navigation") /// SwiftUIPager public static let swiftUIPager = L10n.tr("Constant", "acknowledgement.swiftUIPager", fallback: "SwiftUIPager") /// https://github.com/fermoya/SwiftUIPager @@ -193,8 +191,6 @@ public enum L10n { public static let copyCookies = L10n.tr("Localizable", "account_setting_view.copy_cookies", fallback: "Copy cookies") /// Login public static let login = L10n.tr("Localizable", "account_setting_view.login", fallback: "Login") - /// Logout - public static let logout = L10n.tr("Localizable", "account_setting_view.logout", fallback: "Logout") /// Shows new dawn greeting public static let showsNewDawnGreeting = L10n.tr("Localizable", "account_setting_view.shows_new_dawn_greeting", fallback: "Shows new dawn greeting") /// Manage tags subscription @@ -910,10 +906,6 @@ public enum L10n { public static func pages(_ p1: Any) -> String { return L10n.tr("Localizable", "common.pages", String(describing: p1), fallback: "%@ pages") } - /// %@ records - public static func records(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.records", String(describing: p1), fallback: "%@ records") - } /// %@ second public static func second(_ p1: Any) -> String { return L10n.tr("Localizable", "common.second", String(describing: p1), fallback: "%@ second") @@ -926,10 +918,6 @@ public enum L10n { public static func stars(_ p1: Any) -> String { return L10n.tr("Localizable", "common.stars", String(describing: p1), fallback: "%@ stars") } - /// %@ times - public static func times(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.times", String(describing: p1), fallback: "%@ times") - } } public enum ConfirmationDialog { /// Clear @@ -991,20 +979,6 @@ public enum L10n { public static let deleteDownloadedGallery = L10n.tr("Localizable", "detail_view.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") /// Detail public static let detail = L10n.tr("Localizable", "detail_view.detail", fallback: "Detail") - /// DONE - public static let downloadDone = L10n.tr("Localizable", "detail_view.download_done", fallback: "DONE") - /// GET - public static let downloadGet = L10n.tr("Localizable", "detail_view.download_get", fallback: "GET") - /// LOG IN - public static let downloadLogin = L10n.tr("Localizable", "detail_view.download_login", fallback: "LOG IN") - /// REPAIR - public static let downloadRepair = L10n.tr("Localizable", "detail_view.download_repair", fallback: "REPAIR") - /// RETRY - public static let downloadRetry = L10n.tr("Localizable", "detail_view.download_retry", fallback: "RETRY") - /// UPDATE - public static let downloadUpdate = L10n.tr("Localizable", "detail_view.download_update", fallback: "UPDATE") - /// WAIT - public static let downloadWait = L10n.tr("Localizable", "detail_view.download_wait", fallback: "WAIT") /// Favorited public static let favorited = L10n.tr("Localizable", "detail_view.favorited", fallback: "Favorited") /// Times @@ -1117,8 +1091,6 @@ public enum L10n { public static let downloading = L10n.tr("Localizable", "download_badge.downloading", fallback: "Downloading") /// Needs Attention public static let needsAttention = L10n.tr("Localizable", "download_badge.needs_attention", fallback: "Needs Attention") - /// Needs Repair - public static let needsRepair = L10n.tr("Localizable", "download_badge.needs_repair", fallback: "Needs Repair") /// Paused public static let paused = L10n.tr("Localizable", "download_badge.paused", fallback: "Paused") /// %d/%d @@ -1149,20 +1121,10 @@ public enum L10n { public static let imageDataValid = L10n.tr("Localizable", "download_inspector_view.image_data_valid", fallback: "Image data is valid") /// No pages public static let `none` = L10n.tr("Localizable", "download_inspector_view.none", fallback: "No pages") - /// Pages - public static let pages = L10n.tr("Localizable", "download_inspector_view.pages", fallback: "Pages") /// Pending public static let pending = L10n.tr("Localizable", "download_inspector_view.pending", fallback: "Pending") /// Retry Failed Pages public static let retryFailedPages = L10n.tr("Localizable", "download_inspector_view.retry_failed_pages", fallback: "Retry Failed Pages") - /// Tap to retry this page - public static let tapToRetry = L10n.tr("Localizable", "download_inspector_view.tap_to_retry", fallback: "Tap to retry this page") - /// Page %d - public static func title(_ p1: Int) -> String { - return L10n.tr("Localizable", "download_inspector_view.title", p1, fallback: "Page %d") - } - /// Update Download - public static let updateDownload = L10n.tr("Localizable", "download_inspector_view.update_download", fallback: "Update Download") /// Validating Image Data... public static let validatingImageData = L10n.tr("Localizable", "download_inspector_view.validating_image_data", fallback: "Validating Image Data...") } @@ -1171,8 +1133,6 @@ public enum L10n { public static let allowCellularDownloads = L10n.tr("Localizable", "download_setting_view.allow_cellular_downloads", fallback: "Allow cellular downloads") /// Concurrent image downloads public static let concurrentImageDownloads = L10n.tr("Localizable", "download_setting_view.concurrent_image_downloads", fallback: "Concurrent image downloads") - /// Download Queue - public static let downloadQueue = L10n.tr("Localizable", "download_setting_view.download_queue", fallback: "Download Queue") /// Network public static let network = L10n.tr("Localizable", "download_setting_view.network", fallback: "Network") /// Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder. @@ -1187,18 +1147,10 @@ public enum L10n { public static func assetUnreadable(_ p1: Any) -> String { return L10n.tr("Localizable", "download_store.asset_unreadable", String(describing: p1), fallback: "Asset file is unreadable: %@") } - /// Cover image data is corrupted. - public static let coverImageCorrupted = L10n.tr("Localizable", "download_store.cover_image_corrupted", fallback: "Cover image data is corrupted.") - /// Cover image is missing. - public static let coverImageMissing = L10n.tr("Localizable", "download_store.cover_image_missing", fallback: "Cover image is missing.") /// The download is currently active. public static let downloadBusy = L10n.tr("Localizable", "download_store.download_busy", fallback: "The download is currently active.") /// Download folder is missing. public static let downloadFolderMissing = L10n.tr("Localizable", "download_store.download_folder_missing", fallback: "Download folder is missing.") - /// Download folder could not be resolved. - public static let downloadFolderUnresolved = L10n.tr("Localizable", "download_store.download_folder_unresolved", fallback: "Download folder could not be resolved.") - /// Downloaded pages are incomplete. - public static let downloadedPagesIncomplete = L10n.tr("Localizable", "download_store.downloaded_pages_incomplete", fallback: "Downloaded pages are incomplete.") /// A folder with this name already exists. public static let folderAlreadyExists = L10n.tr("Localizable", "download_store.folder_already_exists", fallback: "A folder with this name already exists.") /// The folder contains an active download. @@ -1341,8 +1293,6 @@ public enum L10n { public static let galleryPageThumbnailLabeling = L10n.tr("Localizable", "eh_setting_view.gallery_page_thumbnail_labeling", fallback: "Gallery Page Thumbnail Labeling") /// Gallery Tags public static let galleryTags = L10n.tr("Localizable", "eh_setting_view.gallery_tags", fallback: "Gallery Tags") - /// Hath Local Network Host - public static let hathLocalNetworkHost = L10n.tr("Localizable", "eh_setting_view.hath_local_network_host", fallback: "Hath Local Network Host") /// Horizontal public static let horizontal = L10n.tr("Localizable", "eh_setting_view.horizontal", fallback: "Horizontal") /// %@ settings @@ -1361,11 +1311,6 @@ public enum L10n { public static let imageSizeDescription = L10n.tr("Localizable", "eh_setting_view.image_size_description", fallback: "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)") /// Image Size Settings public static let imageSizeSettings = L10n.tr("Localizable", "eh_setting_view.image_size_settings", fallback: "Image Size Settings") - /// IP address:Port - public static let ipAddressPort = L10n.tr("Localizable", "eh_setting_view.ip_address_port", fallback: "IP address:Port") - /// This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem. - /// If you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work. - public static let ipAddressPortDescription = L10n.tr("Localizable", "eh_setting_view.ip_address_port_description", fallback: "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work.") /// Load images through the Hath network public static let loadImagesThroughTheHathNetwork = L10n.tr("Localizable", "eh_setting_view.load_images_through_the_hath_network", fallback: "Load images through the Hath network") /// Multi-Page Viewer @@ -1696,8 +1641,6 @@ public enum L10n { public enum HathArchive { /// Free public static let free = L10n.tr("Localizable", "hath_archive.free", fallback: "Free") - /// N/A - public static let notAvailable = L10n.tr("Localizable", "hath_archive.not_available", fallback: "N/A") } public enum HistoryView { /// History From bdb4d60d6c80ec95c22dac4a7f548d4cb28af9f6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:40:57 +0800 Subject: [PATCH 452/614] Move CookieClient strings to catalog Pilot of the per-module String Catalog migration. CookieClient owns the cookie_value.* keys exclusively, so they move to a module-local Localizable.xcstrings (6 locales) and resolve via Xcode 26's generated internal LocalizedStringResource symbols instead of SwiftGen's L10n. - Add AppPackage/Sources/CookieClient/Resources/Localizable.xcstrings (cookie_value.expired/mystery/none, extractionState manual). - Rewrite call sites to String(localized: .cookieValue*). - Package.swift: add resources: [.process(.resources)], drop the now unused Resources dependency; remove import Resources. - Delete the 3 keys from all 6 .lproj files; regenerate Strings.swift. Build, test build, and SwiftLint all clean. --- AppPackage/Package.swift | 2 +- .../Sources/CookieClient/CookieClient.swift | 7 +- .../Resources/Localizable.xcstrings | 129 ++++++++++++++++++ .../Resources/de.lproj/Localizable.strings | 3 - .../Resources/en.lproj/Localizable.strings | 3 - .../Resources/ja.lproj/Localizable.strings | 3 - .../Resources/ko.lproj/Localizable.strings | 3 - .../zh-Hans.lproj/Localizable.strings | 3 - .../zh-Hant.lproj/Localizable.strings | 3 - AppPackage/Sources/Resources/Strings.swift | 8 -- 10 files changed, 133 insertions(+), 31 deletions(-) create mode 100644 AppPackage/Sources/CookieClient/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 2b65c747d..91e0c5ecd 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -423,9 +423,9 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.appTools), - .module(.resources), .targetDependency(.composableArchitecture) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/CookieClient/CookieClient.swift b/AppPackage/Sources/CookieClient/CookieClient.swift index 0229f4d86..cb57c2ef9 100644 --- a/AppPackage/Sources/CookieClient/CookieClient.swift +++ b/AppPackage/Sources/CookieClient/CookieClient.swift @@ -1,6 +1,5 @@ import Foundation import AppModels -import Resources import ComposableArchitecture import AppTools #if DEBUG @@ -32,7 +31,7 @@ extension CookieClient { }, getCookie: { url, key in var value = CookieValue( - rawValue: "", localizedString: L10n.Localizable.CookieValue.none + rawValue: "", localizedString: String(localized: .cookieValueNone) ) guard let cookies = cookieStorage.cookies(for: url), !cookies.isEmpty else { return value } @@ -42,14 +41,14 @@ extension CookieClient { expiresDate <= .now { value = CookieValue( rawValue: "", - localizedString: L10n.Localizable.CookieValue.expired + localizedString: String(localized: .cookieValueExpired) ) return } guard cookie.value != Defaults.Cookie.mystery else { value = CookieValue( rawValue: cookie.value, localizedString: - L10n.Localizable.CookieValue.mystery + String(localized: .cookieValueMystery) ) return } diff --git a/AppPackage/Sources/CookieClient/Resources/Localizable.xcstrings b/AppPackage/Sources/CookieClient/Resources/Localizable.xcstrings new file mode 100644 index 000000000..52e0f4815 --- /dev/null +++ b/AppPackage/Sources/CookieClient/Resources/Localizable.xcstrings @@ -0,0 +1,129 @@ +{ + "sourceLanguage": "en", + "strings": { + "cookie_value.expired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expired" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abgelaufen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "期限切れ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "만료됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已过期" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已過期" + } + } + } + }, + "cookie_value.mystery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rejected" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abgelehnt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拒否" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "거절됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "被拒绝" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "被拒絕" + } + } + } + }, + "cookie_value.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht vorhanden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无内容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "None" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 4ef069e46..0cf6ddcae 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -190,9 +190,6 @@ "account_setting_view.tags_management" = "Meine Tags bearbeiten"; "account_setting_view.copy_cookies" = "Cookies kopieren"; // CookieValue -"cookie_value.expired" = "Abgelaufen"; -"cookie_value.mystery" = "Abgelehnt"; -"cookie_value.none" = "Nicht vorhanden"; // MARK: LoginView "login_view.login" = "Einloggen"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index b44e29e80..0409561f5 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -190,9 +190,6 @@ "account_setting_view.tags_management" = "Manage tags subscription"; "account_setting_view.copy_cookies" = "Copy cookies"; // CookieValue -"cookie_value.expired" = "Expired"; -"cookie_value.mystery" = "Rejected"; -"cookie_value.none" = "None"; // MARK: LoginView "login_view.login" = "Login"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 74bb6f182..c3bb7fcef 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -190,9 +190,6 @@ "account_setting_view.tags_management" = "タグの購読を管理"; "account_setting_view.copy_cookies" = "クッキーをコピー"; // CookieValue -"cookie_value.expired" = "期限切れ"; -"cookie_value.mystery" = "拒否"; -"cookie_value.none" = "なし"; // MARK: LoginView "login_view.login" = "ログイン"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index 7cf7ba17f..b572e205a 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -190,9 +190,6 @@ "account_setting_view.tags_management" = "태그 구독 관리"; "account_setting_view.copy_cookies" = "쿠키 복사하기"; // CookieValue -"cookie_value.expired" = "만료됨"; -"cookie_value.mystery" = "거절됨"; -"cookie_value.none" = "없음"; // MARK: LoginView "login_view.login" = "로그인"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 2c31133b7..4301efc29 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -190,9 +190,6 @@ "account_setting_view.tags_management" = "管理标签订阅"; "account_setting_view.copy_cookies" = "复制 Cookies"; // CookieValue -"cookie_value.expired" = "已过期"; -"cookie_value.mystery" = "被拒绝"; -"cookie_value.none" = "无内容"; // MARK: LoginView "login_view.login" = "登录"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 986005399..a811e3bee 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -190,9 +190,6 @@ "account_setting_view.tags_management" = "管理訂閱標籤"; "account_setting_view.copy_cookies" = "複製 Cookies"; // CookieValue -"cookie_value.expired" = "已過期"; -"cookie_value.mystery" = "被拒絕"; -"cookie_value.none" = "None"; // MARK: LoginView "login_view.login" = "登入"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 228142d9d..cbe31e7f8 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -946,14 +946,6 @@ public enum L10n { /// Are you sure to reset? public static let resetDescription = L10n.tr("Localizable", "confirmation_dialog.reset_description", fallback: "Are you sure to reset?") } - public enum CookieValue { - /// Expired - public static let expired = L10n.tr("Localizable", "cookie_value.expired", fallback: "Expired") - /// Rejected - public static let mystery = L10n.tr("Localizable", "cookie_value.mystery", fallback: "Rejected") - /// None - public static let `none` = L10n.tr("Localizable", "cookie_value.none", fallback: "None") - } public enum DateSeekView { /// Date public static let date = L10n.tr("Localizable", "date_seek_view.date", fallback: "Date") From 17f92391d73c9d8892c3ac98d31f666d2f2e96e5 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:48:35 +0800 Subject: [PATCH 453/614] Move GalleryListComponents strings to catalog download_badge.* keys (7) are exclusive to GalleryListComponents, so they move to a module-local Localizable.xcstrings resolving via Xcode's generated symbols. The multi-arg download_badge.progress uses positional %lld placeholders, which Xcode generates as downloadBadgeProgress(_:_:). - Add Resources/Localizable.xcstrings; rewrite call sites (incl. the former `typealias BadgeText = L10n...` indirection, now removed). - Package.swift: add resources, drop the now-unused Resources dep; remove import Resources from DownloadBadgeLabel.swift. - Delete the 7 keys from all locales; regenerate Strings.swift. Build and SwiftLint clean. --- AppPackage/Package.swift | 2 +- .../DownloadBadgeLabel.swift | 18 +- .../Resources/Localizable.xcstrings | 293 ++++++++++++++++++ .../Resources/de.lproj/Localizable.strings | 7 - .../Resources/en.lproj/Localizable.strings | 7 - .../Resources/ja.lproj/Localizable.strings | 7 - .../Resources/ko.lproj/Localizable.strings | 7 - .../zh-Hans.lproj/Localizable.strings | 7 - .../zh-Hant.lproj/Localizable.strings | 7 - AppPackage/Sources/Resources/Strings.swift | 18 -- 10 files changed, 302 insertions(+), 71 deletions(-) create mode 100644 AppPackage/Sources/GalleryListComponents/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 91e0c5ecd..1e2a11fe7 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -496,12 +496,12 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appModels), .module(.appTools), - .module(.resources), .module(.tagTranslationFeature), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols), .targetDependency(.waterfallGrid) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift b/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift index 9f318328c..a4735d818 100644 --- a/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift +++ b/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift @@ -1,7 +1,6 @@ import SwiftUI import SFSafeSymbols import AppModels -import Resources public struct DownloadBadgeLabel: View { private let badge: DownloadBadge @@ -28,27 +27,26 @@ public struct DownloadBadgeLabel: View { } private var progressText: String { - L10n.Localizable.DownloadBadge.progress( + String(localized: .downloadBadgeProgress( badge.progress.displayCompletedPageCount, badge.progress.displayPageCount - ) + )) } private var statusText: String { - typealias BadgeText = L10n.Localizable.DownloadBadge switch badge.status { case .queued: - return BadgeText.queued + return String(localized: .downloadBadgeQueued) case .active: - return BadgeText.downloading + return String(localized: .downloadBadgeDownloading) case .inactive: - return BadgeText.paused + return String(localized: .downloadBadgePaused) case .completed: - return BadgeText.downloaded + return String(localized: .downloadBadgeDownloaded) case .updateAvailable: - return BadgeText.updateAvailable + return String(localized: .downloadBadgeUpdateAvailable) case .error: - return BadgeText.needsAttention + return String(localized: .downloadBadgeNeedsAttention) } } diff --git a/AppPackage/Sources/GalleryListComponents/Resources/Localizable.xcstrings b/AppPackage/Sources/GalleryListComponents/Resources/Localizable.xcstrings new file mode 100644 index 000000000..7528e8e4d --- /dev/null +++ b/AppPackage/Sources/GalleryListComponents/Resources/Localizable.xcstrings @@ -0,0 +1,293 @@ +{ + "sourceLanguage": "en", + "strings": { + "download_badge.downloaded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloaded" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heruntergeladen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已下載" + } + } + } + }, + "download_badge.downloading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt herunter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 중" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載中" + } + } + } + }, + "download_badge.needs_attention": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs Attention" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benötigt Aufmerksamkeit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "要対応" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "조치 필요" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需处理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需處理" + } + } + } + }, + "download_badge.paused": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paused" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pausiert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一時停止" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일시 정지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已暂停" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已暫停" + } + } + } + }, + "download_badge.progress": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld/%lld" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld/%lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld/%lld" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld/%lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld/%lld" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld/%lld" + } + } + } + }, + "download_badge.queued": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Queued" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Warteschlange" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대기 중" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已排队" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已排隊" + } + } + } + }, + "download_badge.update_available": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Available" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update verfügbar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新あり" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 가능" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有可更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "有可更新" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 0cf6ddcae..d315d64cf 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -456,13 +456,6 @@ // MARK: DownloadBadge -"download_badge.queued" = "In Warteschlange"; -"download_badge.downloading" = "Lädt herunter"; -"download_badge.paused" = "Pausiert"; -"download_badge.downloaded" = "Heruntergeladen"; -"download_badge.needs_attention" = "Benötigt Aufmerksamkeit"; -"download_badge.update_available" = "Update verfügbar"; -"download_badge.progress" = "%d/%d"; // MARK: DownloadStore "download_store.asset_unreadable" = "Asset-Datei ist nicht lesbar: %@"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 0409561f5..11a486dd9 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -456,13 +456,6 @@ // MARK: DownloadBadge -"download_badge.queued" = "Queued"; -"download_badge.downloading" = "Downloading"; -"download_badge.paused" = "Paused"; -"download_badge.downloaded" = "Downloaded"; -"download_badge.needs_attention" = "Needs Attention"; -"download_badge.update_available" = "Update Available"; -"download_badge.progress" = "%d/%d"; // MARK: DownloadStore "download_store.asset_unreadable" = "Asset file is unreadable: %@"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index c3bb7fcef..d02a79915 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -456,13 +456,6 @@ // MARK: DownloadBadge -"download_badge.queued" = "待機中"; -"download_badge.downloading" = "ダウンロード中"; -"download_badge.paused" = "一時停止"; -"download_badge.downloaded" = "ダウンロード済み"; -"download_badge.needs_attention" = "要対応"; -"download_badge.update_available" = "更新あり"; -"download_badge.progress" = "%d/%d"; // MARK: DownloadStore "download_store.asset_unreadable" = "アセットファイルを読み取れません: %@"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index b572e205a..2fb41932d 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -456,13 +456,6 @@ // MARK: DownloadBadge -"download_badge.queued" = "대기 중"; -"download_badge.downloading" = "다운로드 중"; -"download_badge.paused" = "일시 정지"; -"download_badge.downloaded" = "다운로드됨"; -"download_badge.needs_attention" = "조치 필요"; -"download_badge.update_available" = "업데이트 가능"; -"download_badge.progress" = "%d/%d"; // MARK: DownloadStore "download_store.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 4301efc29..a4972a920 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -456,13 +456,6 @@ // MARK: DownloadBadge -"download_badge.queued" = "已排队"; -"download_badge.downloading" = "下载中"; -"download_badge.paused" = "已暂停"; -"download_badge.downloaded" = "已下载"; -"download_badge.needs_attention" = "需处理"; -"download_badge.update_available" = "有可更新"; -"download_badge.progress" = "%d/%d"; // MARK: DownloadStore "download_store.asset_unreadable" = "资源文件无法读取:%@"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index a811e3bee..feca5aa12 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -456,13 +456,6 @@ // MARK: DownloadBadge -"download_badge.queued" = "已排隊"; -"download_badge.downloading" = "下載中"; -"download_badge.paused" = "已暫停"; -"download_badge.downloaded" = "已下載"; -"download_badge.needs_attention" = "需處理"; -"download_badge.update_available" = "有可更新"; -"download_badge.progress" = "%d/%d"; // MARK: DownloadStore "download_store.asset_unreadable" = "資源檔案無法讀取:%@"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index cbe31e7f8..f02f48d87 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -1076,24 +1076,6 @@ public enum L10n { /// Thumbnail public static let thumbnail = L10n.tr("Localizable", "display_mode.thumbnail", fallback: "Thumbnail") } - public enum DownloadBadge { - /// Downloaded - public static let downloaded = L10n.tr("Localizable", "download_badge.downloaded", fallback: "Downloaded") - /// Downloading - public static let downloading = L10n.tr("Localizable", "download_badge.downloading", fallback: "Downloading") - /// Needs Attention - public static let needsAttention = L10n.tr("Localizable", "download_badge.needs_attention", fallback: "Needs Attention") - /// Paused - public static let paused = L10n.tr("Localizable", "download_badge.paused", fallback: "Paused") - /// %d/%d - public static func progress(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "download_badge.progress", p1, p2, fallback: "%d/%d") - } - /// Queued - public static let queued = L10n.tr("Localizable", "download_badge.queued", fallback: "Queued") - /// Update Available - public static let updateAvailable = L10n.tr("Localizable", "download_badge.update_available", fallback: "Update Available") - } public enum DownloadFolderFilter { /// All public static let all = L10n.tr("Localizable", "download_folder_filter.all", fallback: "All") From 4269e6bb3c87057a32343da22381ed5fc9a34c3c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:50:15 +0800 Subject: [PATCH 454/614] Move SearchFeature strings to catalog search_view.recently_searched / recently_seen are exclusive to SearchFeature; the search / quick_search titles are shared and stay in the Resources catalog until the finale. - Add SearchFeature/Resources/Localizable.xcstrings (2 keys); rewrite those call sites to generated symbols. - Package.swift: add resources: [.process(.resources)] (Resources dep retained for the still-shared keys). - Delete the 2 keys from all locales; regenerate Strings.swift. Build and SwiftLint clean. --- AppPackage/Package.swift | 1 + .../Resources/de.lproj/Localizable.strings | 2 - .../Resources/en.lproj/Localizable.strings | 2 - .../Resources/ja.lproj/Localizable.strings | 2 - .../Resources/ko.lproj/Localizable.strings | 2 - .../zh-Hans.lproj/Localizable.strings | 2 - .../zh-Hant.lproj/Localizable.strings | 2 - AppPackage/Sources/Resources/Strings.swift | 4 - .../Resources/Localizable.xcstrings | 88 +++++++++++++++++++ .../SearchFeature/SearchRootView.swift | 4 +- 10 files changed, 91 insertions(+), 18 deletions(-) create mode 100644 AppPackage/Sources/SearchFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 1e2a11fe7..debe06b64 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -715,6 +715,7 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index d315d64cf..ae25eb914 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -156,8 +156,6 @@ // MARK: SearchView "search_view.search" = "Suche"; -"search_view.recently_searched" = "Zuletzt gesucht"; -"search_view.recently_seen" = "Zuletzt angesehen"; "search_view.quick_search" = "Schnellsuche"; // Searchable "searchable.filter" = "Filter"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 11a486dd9..8f670cb2d 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -156,8 +156,6 @@ // MARK: SearchView "search_view.search" = "Search"; -"search_view.recently_searched" = "Recently searched"; -"search_view.recently_seen" = "Recently seen"; "search_view.quick_search" = "Quick search"; // Searchable "searchable.filter" = "Filter"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index d02a79915..9aeb94703 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -156,8 +156,6 @@ // MARK: SearchView "search_view.search" = "検索"; -"search_view.recently_searched" = "最近検索した項目"; -"search_view.recently_seen" = "最近閲覧した項目"; "search_view.quick_search" = "クイック検索"; // Searchable "searchable.filter" = "フィルター"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index 2fb41932d..da5e2b9fb 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -156,8 +156,6 @@ // MARK: SearchView "search_view.search" = "검색"; -"search_view.recently_searched" = "최근 검색어"; -"search_view.recently_seen" = "최근 본 항목"; "search_view.quick_search" = "빠른 검색"; // Searchable "searchable.filter" = "필터"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index a4972a920..f2f0cf89c 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -156,8 +156,6 @@ // MARK: SearchView "search_view.search" = "搜索"; -"search_view.recently_searched" = "最近搜索"; -"search_view.recently_seen" = "最近看过"; "search_view.quick_search" = "快速搜索"; // Searchable "searchable.filter" = "筛选"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index feca5aa12..5308402c7 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -156,8 +156,6 @@ // MARK: SearchView "search_view.search" = "搜尋"; -"search_view.recently_searched" = "最近搜尋"; -"search_view.recently_seen" = "最近閱讀"; "search_view.quick_search" = "快速搜尋"; // Searchable "searchable.filter" = "過濾"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index f02f48d87..066877234 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -1941,10 +1941,6 @@ public enum L10n { public enum SearchView { /// Quick search public static let quickSearch = L10n.tr("Localizable", "search_view.quick_search", fallback: "Quick search") - /// Recently searched - public static let recentlySearched = L10n.tr("Localizable", "search_view.recently_searched", fallback: "Recently searched") - /// Recently seen - public static let recentlySeen = L10n.tr("Localizable", "search_view.recently_seen", fallback: "Recently seen") /// Search public static let search = L10n.tr("Localizable", "search_view.search", fallback: "Search") } diff --git a/AppPackage/Sources/SearchFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/SearchFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..a978d3505 --- /dev/null +++ b/AppPackage/Sources/SearchFeature/Resources/Localizable.xcstrings @@ -0,0 +1,88 @@ +{ + "sourceLanguage": "en", + "strings": { + "recently_searched": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recently searched" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zuletzt gesucht" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最近検索した項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근 검색어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最近搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最近搜尋" + } + } + } + }, + "recently_seen": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recently seen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zuletzt angesehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最近閲覧した項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근 본 항목" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最近看过" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最近閱讀" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index be8bad835..9e02a8f37 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -225,7 +225,7 @@ private struct HistoryKeywordsSection: View { } var body: some View { - SubSection(title: L10n.Localizable.SearchView.recentlySearched, showAll: false) { + SubSection(title: String(localized: .recentlySearched), showAll: false) { DoubleVerticalKeywordsStack( keywords: keywords.map(WrappedKeyword.init), searchAction: searchAction, @@ -246,7 +246,7 @@ private struct HistoryGalleriesSection: View { } var body: some View { - SubSection(title: L10n.Localizable.SearchView.recentlySeen, showAll: false) { + SubSection(title: String(localized: .recentlySeen), showAll: false) { ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(galleries) { gallery in From a2530296f511238630191f811cc8672131c02807 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:52:14 +0800 Subject: [PATCH 455/614] Move AppFeature strings to catalog local_authorization.reason (the auto-lock prompt) is AppFeature's only exclusive key; renamed to auto_lock_reason and moved to a module-local catalog. The TabItem titles it uses are shared and stay in Resources. - Add AppFeature/Resources/Localizable.xcstrings (auto_lock_reason); rewrite the call site to String(localized: .autoLockReason). - Package.swift: add resources: [.process(.resources)]. - Delete the key from all locales; regenerate Strings.swift. Build and SwiftLint clean. --- AppPackage/Package.swift | 1 + .../AppFeature/DataFlow/AppLockReducer.swift | 2 +- .../Resources/Localizable.xcstrings | 47 +++++++++++++++++++ .../Resources/de.lproj/Localizable.strings | 1 - .../Resources/en.lproj/Localizable.strings | 1 - .../Resources/ja.lproj/Localizable.strings | 1 - .../Resources/ko.lproj/Localizable.strings | 1 - .../zh-Hans.lproj/Localizable.strings | 1 - .../zh-Hant.lproj/Localizable.strings | 1 - AppPackage/Sources/Resources/Strings.swift | 4 -- 10 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 AppPackage/Sources/AppFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index debe06b64..071cd5b03 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -298,6 +298,7 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.uiImageColors), .targetDependency(.waterfallGrid) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift index 8cf251073..3b7c88061 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift @@ -60,7 +60,7 @@ struct AppLockReducer { case .authorize: return .run { send in - let success = await authorizationClient.localAuthroize(L10n.Localizable.LocalAuthorization.reason) + let success = await authorizationClient.localAuthroize(String(localized: .autoLockReason)) await send(.authorizeDone(success)) } diff --git a/AppPackage/Sources/AppFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/AppFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..250126972 --- /dev/null +++ b/AppPackage/Sources/AppFeature/Resources/Localizable.xcstrings @@ -0,0 +1,47 @@ +{ + "sourceLanguage": "en", + "strings": { + "auto_lock_reason": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The App has been locked due to the Auto-Lock expiration." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die App hat sich selbst gesperrt, da der auto-lock Zeitraum abgelaufen ist." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動ロック期限が切れたため、アプリがロックされています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 잠금으로 앱이 잠겼어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "因超过设置的自动锁定期限,App 已被锁定" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "由於超過 APP 自動鎖定期限,APP 已被鎖定,請重新解鎖" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index ae25eb914..236d826e1 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -21,7 +21,6 @@ "toast.saved_to_photo_library" = "In der Fotomediathek gesichert"; // MARK: AutoLock -"local_authorization.reason" = "Die App hat sich selbst gesperrt, da der auto-lock Zeitraum abgelaufen ist."; // MARK: Common value "common.stars" = "%@ Sterne"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 8f670cb2d..0102749ec 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -21,7 +21,6 @@ "toast.saved_to_photo_library" = "Saved to photo library"; // MARK: AutoLock -"local_authorization.reason" = "The App has been locked due to the Auto-Lock expiration."; // MARK: Common value "common.stars" = "%@ stars"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 9aeb94703..fa05cb46f 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -21,7 +21,6 @@ "toast.saved_to_photo_library" = "ライブラリに保存しました"; // MARK: AutoLock -"local_authorization.reason" = "自動ロック期限が切れたため、アプリがロックされています"; // MARK: Common value "common.stars" = "%@ つ星"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index da5e2b9fb..acaaee4eb 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -21,7 +21,6 @@ "toast.saved_to_photo_library" = "이미지 저장"; // MARK: AutoLock -"local_authorization.reason" = "자동 잠금으로 앱이 잠겼어요."; // MARK: Common value "common.stars" = "%@별"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index f2f0cf89c..9a5ed830d 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -21,7 +21,6 @@ "toast.saved_to_photo_library" = "已保存到图库"; // MARK: AutoLock -"local_authorization.reason" = "因超过设置的自动锁定期限,App 已被锁定"; // MARK: Common value "common.stars" = "%@ 星"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 5308402c7..e4a013580 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -21,7 +21,6 @@ "toast.saved_to_photo_library" = "已儲存到照片"; // MARK: AutoLock -"local_authorization.reason" = "由於超過 APP 自動鎖定期限,APP 已被鎖定,請重新解鎖"; // MARK: Common value "common.stars" = "%@ 星"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 066877234..0e17787dc 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -1822,10 +1822,6 @@ public enum L10n { /// Preparing the database... public static let preparingDatabase = L10n.tr("Localizable", "loading_view.preparing_database", fallback: "Preparing the database...") } - public enum LocalAuthorization { - /// The App has been locked due to the Auto-Lock expiration. - public static let reason = L10n.tr("Localizable", "local_authorization.reason", fallback: "The App has been locked due to the Auto-Lock expiration.") - } public enum LoginView { /// Login public static let login = L10n.tr("Localizable", "login_view.login", fallback: "Login") From 10d18e8d2595147bf1c5104e1466cb36027ba2fa Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:53:18 +0800 Subject: [PATCH 456/614] Move MigrationFeature strings to catalog MigrationFeature exclusively owns its error/dialog strings. The former error_view.drop_database and confirmation_dialog.drop_database (identical text) merge into one drop_database key; drop_database_description and preparing_database move alongside. - Add MigrationFeature/Resources/Localizable.xcstrings (3 keys); rewrite 4 call sites. common.cancel stays shared in Resources. - Package.swift: add resources; delete keys from all locales; regen. Build and SwiftLint clean. --- AppPackage/Package.swift | 1 + .../MigrationFeature/MigrationReducer.swift | 4 +- .../MigrationFeature/MigrationView.swift | 4 +- .../Resources/Localizable.xcstrings | 129 ++++++++++++++++++ .../Resources/de.lproj/Localizable.strings | 4 - .../Resources/en.lproj/Localizable.strings | 4 - .../Resources/ja.lproj/Localizable.strings | 4 - .../Resources/ko.lproj/Localizable.strings | 4 - .../zh-Hans.lproj/Localizable.strings | 4 - .../zh-Hant.lproj/Localizable.strings | 4 - AppPackage/Sources/Resources/Strings.swift | 9 -- 11 files changed, 134 insertions(+), 37 deletions(-) create mode 100644 AppPackage/Sources/MigrationFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 071cd5b03..d10177696 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -570,6 +570,7 @@ let targets: [PackageDescription.Target] = [ .module(.resources), .targetDependency(.composableArchitecture) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift index a18957760..09c7f126f 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift @@ -47,13 +47,13 @@ public struct MigrationReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmDropDatabase) { - TextState(L10n.Localizable.ConfirmationDialog.dropDatabase) + TextState(String(localized: .dropDatabase)) } ButtonState(role: .cancel) { TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.dropDatabaseDescription) + TextState(String(localized: .dropDatabaseDescription)) } return .none diff --git a/AppPackage/Sources/MigrationFeature/MigrationView.swift b/AppPackage/Sources/MigrationFeature/MigrationView.swift index d54209479..880528afb 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationView.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationView.swift @@ -20,12 +20,12 @@ public struct MigrationView: View { NavigationStack { ZStack { reversedPrimary.ignoresSafeArea() - LoadingView(title: L10n.Localizable.LoadingView.preparingDatabase) + LoadingView(title: String(localized: .preparingDatabase)) .opacity(store.databaseState == .loading ? 1 : 0) let error = store.databaseState.failed let errorNonNil = error ?? .databaseCorrupted(nil) AlertView(symbol: errorNonNil.symbol, message: errorNonNil.localizedDescription) { - AlertViewButton(title: L10n.Localizable.ErrorView.dropDatabase) { + AlertViewButton(title: String(localized: .dropDatabase)) { store.send(.dropDatabaseButtonTapped) } .confirmationDialog( diff --git a/AppPackage/Sources/MigrationFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/MigrationFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..bf4550e11 --- /dev/null +++ b/AppPackage/Sources/MigrationFeature/Resources/Localizable.xcstrings @@ -0,0 +1,129 @@ +{ + "sourceLanguage": "en", + "strings": { + "drop_database": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Drop the database" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datenbank löschen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "データベースを削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "데이터베이스 삭제" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "丢弃数据库" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除資料庫" + } + } + } + }, + "drop_database_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You will lose all your data in this app.\\nAre you sure to drop the database?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du verlierst alle Daten in dieser App.\\nMöchtest du die Datenbank wirklich löschen?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本アプリでのすべてのデータを失うことになります。\\n本当にデータベースを削除してもよろしいですか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 앱의 모든 데이터를 잃게 돼요.\\n정말 데이터베이스를 삭제하시겠어요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你将失去这个 App 中所有的数据。\\n确定要丢弃数据库吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "繼續此操作將會清除 APP 中的所有資料\\n確定要刪除資料庫?" + } + } + } + }, + "preparing_database": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing the database..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datenbank wird vorbereitet..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "データベース準備中..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "데이터베이스 준비 중..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在准备数据库..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在準備..." + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 236d826e1..e20e900fd 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -63,11 +63,9 @@ // MARK: AlertView "loading_view.loading" = "Wird geladen..."; -"loading_view.preparing_database" = "Datenbank wird vorbereitet..."; "not_login_view.need_login" = "Du musst dich einloggen, um diese Funktion nutzen zu können."; "not_login_viewlogin" = "Einloggen"; "error_view.retry" = "Erneut versuchen"; -"error_view.drop_database" = "Datenbank löschen"; "error_view.try_later" = "Bitte versuche es später erneut."; "error_view.network" = "Ein Netzwerkfehler ist aufgetreten."; "error_view.parsing" = "Ein Parserfehler ist aufgetreten."; @@ -97,13 +95,11 @@ "app_error.local_file_operation_failed" = "Lokaler Dateivorgang fehlgeschlagen."; // MARK: ConfirmationDialog -"confirmation_dialog.drop_database_description" = "Du verlierst alle Daten in dieser App.\nMöchtest du die Datenbank wirklich löschen?"; "confirmation_dialog.remove_custom_translations" = "Möchtest du deine benutzerdefinierten Übersetzungen wirklich entfernen?"; "confirmation_dialog.logout_description" = "Bist du sicher das du dich ausloggen möchtest?"; "confirmation_dialog.delete_description" = "Möchtest du dieses Element wirklich löschen?"; "confirmation_dialog.clear_description" = "Bist du sicher das du das löschen möchtest?"; "confirmation_dialog.reset_description" = "Bist du sicher?"; -"confirmation_dialog.drop_database" = "Datenbank löschen"; "confirmation_dialog.remove" = "Entfernen"; "confirmation_dialog.logout" = "Ausloggen"; "confirmation_dialog.delete" = "Löschen"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 0102749ec..28efc65fe 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -63,11 +63,9 @@ // MARK: AlertView "loading_view.loading" = "Loading..."; -"loading_view.preparing_database" = "Preparing the database..."; "not_login_view.need_login" = "You need to login to access this feature."; "not_login_viewlogin" = "Login"; "error_view.retry" = "Retry"; -"error_view.drop_database" = "Drop the database"; "error_view.try_later" = "Please try again later."; "error_view.network" = "A network error occurred."; "error_view.parsing" = "A parsing error occurred."; @@ -97,13 +95,11 @@ "app_error.local_file_operation_failed" = "Local file operation failed."; // MARK: ConfirmationDialog -"confirmation_dialog.drop_database_description" = "You will lose all your data in this app.\nAre you sure to drop the database?"; "confirmation_dialog.remove_custom_translations" = "Are you sure to remove your custom translations?"; "confirmation_dialog.logout_description" = "Are you sure to logout?"; "confirmation_dialog.delete_description" = "Are you sure to delete this item?"; "confirmation_dialog.clear_description" = "Are you sure to clear?"; "confirmation_dialog.reset_description" = "Are you sure to reset?"; -"confirmation_dialog.drop_database" = "Drop the database"; "confirmation_dialog.remove" = "Remove"; "confirmation_dialog.logout" = "Logout"; "confirmation_dialog.delete" = "Delete"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index fa05cb46f..798ba0936 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -63,11 +63,9 @@ // MARK: AlertView "loading_view.loading" = "読み込み中..."; -"loading_view.preparing_database" = "データベース準備中..."; "not_login_view.need_login" = "本機能をご利用になるにはログインが必要です"; "not_login_viewlogin" = "ログイン"; "error_view.retry" = "リトライ"; -"error_view.drop_database" = "データベースを削除"; "error_view.try_later" = "しばらくしてからもう一度お試しください"; "error_view.network" = "ネットワーク障害が発生しました"; "error_view.parsing" = "解析中に問題が発生しました"; @@ -97,13 +95,11 @@ "app_error.local_file_operation_failed" = "ローカルファイルの操作に失敗しました。"; // MARK: ConfirmationDialog -"confirmation_dialog.drop_database_description" = "本アプリでのすべてのデータを失うことになります。\n本当にデータベースを削除してもよろしいですか?"; "confirmation_dialog.remove_custom_translations" = "本当にカスタム翻訳を削除してもよろしいですか?"; "confirmation_dialog.logout_description" = "本当にログアウトしてもよろしいですか?"; "confirmation_dialog.delete_description" = "本当にこれを削除してもよろしいですか?"; "confirmation_dialog.clear_description" = "本当に削除してもよろしいですか?"; "confirmation_dialog.reset_description" = "本当に戻してもよろしいですか?"; -"confirmation_dialog.drop_database" = "データベースを削除"; "confirmation_dialog.remove" = "削除"; "confirmation_dialog.logout" = "ログアウト"; "confirmation_dialog.delete" = "削除"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index acaaee4eb..547658a75 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -63,11 +63,9 @@ // MARK: AlertView "loading_view.loading" = "로딩 중..."; -"loading_view.preparing_database" = "데이터베이스 준비 중..."; "not_login_view.need_login" = "이 기능을 사용하려면 로그인이 필요해요."; "not_login_viewlogin" = "로그인"; "error_view.retry" = "재시도"; -"error_view.drop_database" = "데이터베이스 삭제"; "error_view.try_later" = "잠시 후 다시 시도해 주세요."; "error_view.network" = "인터넷 접속 오류가 발생했어요."; "error_view.parsing" = "구분 분석 오류가 발생했어요."; @@ -97,13 +95,11 @@ "app_error.local_file_operation_failed" = "로컬 파일 작업에 실패했습니다."; // MARK: ConfirmationDialog -"confirmation_dialog.drop_database_description" = "이 앱의 모든 데이터를 잃게 돼요.\n정말 데이터베이스를 삭제하시겠어요?"; "confirmation_dialog.remove_custom_translations" = "사용자 지정 번역을 삭제하시겠어요?"; "confirmation_dialog.logout_description" = "로그아웃 하시겠어요?"; "confirmation_dialog.delete_description" = "이 항목을 삭제하시겠어요?"; "confirmation_dialog.clear_description" = "삭제하시겠어요?"; "confirmation_dialog.reset_description" = "초기화하시겠어요?"; -"confirmation_dialog.drop_database" = "데이터베이스 삭제"; "confirmation_dialog.remove" = "삭제"; "confirmation_dialog.logout" = "로그아웃"; "confirmation_dialog.delete" = "삭제"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 9a5ed830d..9dba7ea0b 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -63,11 +63,9 @@ // MARK: AlertView "loading_view.loading" = "加载中..."; -"loading_view.preparing_database" = "正在准备数据库..."; "not_login_view.need_login" = "你需要登录才能使用该功能"; "not_login_viewlogin" = "登录"; "error_view.retry" = "重试"; -"error_view.drop_database" = "丢弃数据库"; "error_view.try_later" = "请稍后再试"; "error_view.network" = "发生了网络故障"; "error_view.parsing" = "发生了解析错误"; @@ -97,13 +95,11 @@ "app_error.local_file_operation_failed" = "本地文件操作失败。"; // MARK: ConfirmationDialog -"confirmation_dialog.drop_database_description" = "你将失去这个 App 中所有的数据。\n确定要丢弃数据库吗?"; "confirmation_dialog.remove_custom_translations" = "确定要移除自定义翻译吗?"; "confirmation_dialog.logout_description" = "确定要退出登录吗?"; "confirmation_dialog.delete_description" = "确定要删除吗?"; "confirmation_dialog.clear_description" = "确定要清空吗?"; "confirmation_dialog.reset_description" = "确定要重置吗?"; -"confirmation_dialog.drop_database" = "丢弃数据库"; "confirmation_dialog.remove" = "移除"; "confirmation_dialog.logout" = "退出登录"; "confirmation_dialog.delete" = "删除"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index e4a013580..a0e4a0fa9 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -63,11 +63,9 @@ // MARK: AlertView "loading_view.loading" = "載入中..."; -"loading_view.preparing_database" = "正在準備..."; "not_login_view.need_login" = "你需要登入才能完成這個動作"; "not_login_viewlogin" = "登入"; "error_view.retry" = "重試"; -"error_view.drop_database" = "刪除資料庫"; "error_view.try_later" = "請稍後再試"; "error_view.network" = "網路發生故障,請檢查網路狀態"; "error_view.parsing" = "網頁解析器發生故障"; @@ -97,13 +95,11 @@ "app_error.local_file_operation_failed" = "本機檔案操作失敗。"; // MARK: ConfirmationDialog -"confirmation_dialog.drop_database_description" = "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?"; "confirmation_dialog.remove_custom_translations" = "是否確定要刪除所有自訂翻譯?"; "confirmation_dialog.logout_description" = "確定要登出嗎?"; "confirmation_dialog.delete_description" = "確定要刪除?"; "confirmation_dialog.clear_description" = "確定要清空嗎?"; "confirmation_dialog.reset_description" = "確定要重設嗎?"; -"confirmation_dialog.drop_database" = "刪除資料庫"; "confirmation_dialog.remove" = "移除"; "confirmation_dialog.logout" = "登出"; "confirmation_dialog.delete" = "刪除"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 0e17787dc..b09359c2a 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -928,11 +928,6 @@ public enum L10n { public static let delete = L10n.tr("Localizable", "confirmation_dialog.delete", fallback: "Delete") /// Are you sure to delete this item? public static let deleteDescription = L10n.tr("Localizable", "confirmation_dialog.delete_description", fallback: "Are you sure to delete this item?") - /// Drop the database - public static let dropDatabase = L10n.tr("Localizable", "confirmation_dialog.drop_database", fallback: "Drop the database") - /// You will lose all your data in this app. - /// Are you sure to drop the database? - public static let dropDatabaseDescription = L10n.tr("Localizable", "confirmation_dialog.drop_database_description", fallback: "You will lose all your data in this app.\nAre you sure to drop the database?") /// Logout public static let logout = L10n.tr("Localizable", "confirmation_dialog.logout", fallback: "Logout") /// Are you sure to logout? @@ -1373,8 +1368,6 @@ public enum L10n { /// The database is corrupted. /// Please submit an issue on GitHub. public static let databaseCorrupted = L10n.tr("Localizable", "error_view.database_corrupted", fallback: "The database is corrupted.\nPlease submit an issue on GitHub.") - /// Drop the database - public static let dropDatabase = L10n.tr("Localizable", "error_view.drop_database", fallback: "Drop the database") /// This gallery has been removed or is unavailable. public static let galleryUnavailable = L10n.tr("Localizable", "error_view.gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") /// Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@. @@ -1819,8 +1812,6 @@ public enum L10n { public enum LoadingView { /// Loading... public static let loading = L10n.tr("Localizable", "loading_view.loading", fallback: "Loading...") - /// Preparing the database... - public static let preparingDatabase = L10n.tr("Localizable", "loading_view.preparing_database", fallback: "Preparing the database...") } public enum LoginView { /// Login From e75b5c1688d17de5d8bc19ed29c982cee39a63a7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:54:15 +0800 Subject: [PATCH 457/614] Move DateSeekFeature strings to catalog date_seek_view.* (date, seek_around_date, seek_newer, seek_older) are exclusive to DateSeekFeature. The shared "Seek to date" title stays in Resources. - Add DateSeekFeature/Resources/Localizable.xcstrings (4 keys); rewrite call sites; Package.swift add resources; delete keys; regen. Build and SwiftLint clean. --- AppPackage/Package.swift | 1 + .../DateSeekFeature/DateSeekPickerView.swift | 8 +- .../Resources/Localizable.xcstrings | 170 ++++++++++++++++++ .../Resources/de.lproj/Localizable.strings | 4 - .../Resources/en.lproj/Localizable.strings | 4 - .../Resources/ja.lproj/Localizable.strings | 4 - .../Resources/ko.lproj/Localizable.strings | 4 - .../zh-Hans.lproj/Localizable.strings | 4 - .../zh-Hant.lproj/Localizable.strings | 4 - AppPackage/Sources/Resources/Strings.swift | 8 - 10 files changed, 175 insertions(+), 36 deletions(-) create mode 100644 AppPackage/Sources/DateSeekFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index d10177696..8d6d32760 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -596,6 +596,7 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.composableArchitecture), .targetDependency(.sfSafeSymbols) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift index 92ea3fef4..c884a1648 100644 --- a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift @@ -33,21 +33,21 @@ public struct DateSeekPickerView: View { Form { Section { DatePicker( - L10n.Localizable.DateSeekView.date, + String(localized: .date), selection: $selectedDate, in: navigation.dateRange, displayedComponents: .date ) .datePickerStyle(.graphical) } footer: { - Text(L10n.Localizable.DateSeekView.seekAroundDate) + Text(String(localized: .seekAroundDate)) } Section { let seekOlderButton = SeekButton( symbol: .chevronLeftChevronLeftDotted, - title: L10n.Localizable.DateSeekView.seekOlder, + title: String(localized: .seekOlder), reversedIconTitlePosition: false, action: { seekAction(.older) } ) @@ -56,7 +56,7 @@ public struct DateSeekPickerView: View { let seekNewerButton = SeekButton( symbol: .chevronRightDottedChevronRight, - title: L10n.Localizable.DateSeekView.seekNewer, + title: String(localized: .seekNewer), reversedIconTitlePosition: true, action: { seekAction(.newer) } ) diff --git a/AppPackage/Sources/DateSeekFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/DateSeekFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..54125eefd --- /dev/null +++ b/AppPackage/Sources/DateSeekFeature/Resources/Localizable.xcstrings @@ -0,0 +1,170 @@ +{ + "sourceLanguage": "en", + "strings": { + "date": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Date" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datum" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日付" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "날짜" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日期" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日期" + } + } + } + }, + "seek_around_date": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Seek to galleries around the selected date." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerien rund um das gewählte Datum aufsuchen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択した日付付近のギャラリーへ移動します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택한 날짜 근처의 갤러리로 이동합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "前往所选日期附近的画廊。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "前往所選日期附近的畫廊。" + } + } + } + }, + "seek_newer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Newer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuere" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しい方" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로운 쪽" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "较新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "較新" + } + } + } + }, + "seek_older": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Older" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ältere" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "古い方" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오래된 쪽" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "较旧" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "較舊" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index e20e900fd..5f68fd394 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -52,10 +52,6 @@ // MARK: DateSeek "date_seek_view.date_seek" = "Datum aufsuchen"; -"date_seek_view.date" = "Datum"; -"date_seek_view.seek_around_date" = "Galerien rund um das gewählte Datum aufsuchen."; -"date_seek_view.seek_newer" = "Neuere"; -"date_seek_view.seek_older" = "Ältere"; // MARK: JumpPage "jump_page_view.jump_page" = "Zu Seite springen"; "jump_page_view.jump_page_description" = "Geben Sie eine Seitenzahl zwischen 1 und %d ein."; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 28efc65fe..54c2b71d9 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -52,10 +52,6 @@ // MARK: DateSeek "date_seek_view.date_seek" = "Seek to date"; -"date_seek_view.date" = "Date"; -"date_seek_view.seek_around_date" = "Seek to galleries around the selected date."; -"date_seek_view.seek_newer" = "Newer"; -"date_seek_view.seek_older" = "Older"; // MARK: JumpPage "jump_page_view.jump_page" = "Jump page"; "jump_page_view.jump_page_description" = "Enter a page number between 1 and %d to jump to."; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 798ba0936..50a60f9ae 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -52,10 +52,6 @@ // MARK: DateSeek "date_seek_view.date_seek" = "日付指定"; -"date_seek_view.date" = "日付"; -"date_seek_view.seek_around_date" = "選択した日付付近のギャラリーへ移動します。"; -"date_seek_view.seek_newer" = "新しい方"; -"date_seek_view.seek_older" = "古い方"; // MARK: JumpPage "jump_page_view.jump_page" = "ページジャンプ"; "jump_page_view.jump_page_description" = "1 ~ %d のページ番号を入力してください。"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index 547658a75..cf3c3731d 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -52,10 +52,6 @@ // MARK: DateSeek "date_seek_view.date_seek" = "날짜로 이동"; -"date_seek_view.date" = "날짜"; -"date_seek_view.seek_around_date" = "선택한 날짜 근처의 갤러리로 이동합니다."; -"date_seek_view.seek_newer" = "새로운 쪽"; -"date_seek_view.seek_older" = "오래된 쪽"; // MARK: JumpPage "jump_page_view.jump_page" = "페이지 이동"; "jump_page_view.jump_page_description" = "1에서 %d 사이의 페이지 번호를 입력하세요."; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 9dba7ea0b..9e2389559 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -52,10 +52,6 @@ // MARK: DateSeek "date_seek_view.date_seek" = "日期定位"; -"date_seek_view.date" = "日期"; -"date_seek_view.seek_around_date" = "前往所选日期附近的画廊。"; -"date_seek_view.seek_newer" = "较新"; -"date_seek_view.seek_older" = "较旧"; // MARK: JumpPage "jump_page_view.jump_page" = "页码跳转"; "jump_page_view.jump_page_description" = "请输入 1 到 %d 之间的页码。"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index a0e4a0fa9..014827673 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -52,10 +52,6 @@ // MARK: DateSeek "date_seek_view.date_seek" = "日期定位"; -"date_seek_view.date" = "日期"; -"date_seek_view.seek_around_date" = "前往所選日期附近的畫廊。"; -"date_seek_view.seek_newer" = "較新"; -"date_seek_view.seek_older" = "較舊"; // MARK: JumpPage "jump_page_view.jump_page" = "跳到..."; "jump_page_view.jump_page_description" = "請輸入 1 到 %d 之間的頁碼。"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index b09359c2a..84bd3926d 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -942,16 +942,8 @@ public enum L10n { public static let resetDescription = L10n.tr("Localizable", "confirmation_dialog.reset_description", fallback: "Are you sure to reset?") } public enum DateSeekView { - /// Date - public static let date = L10n.tr("Localizable", "date_seek_view.date", fallback: "Date") /// Seek to date public static let dateSeek = L10n.tr("Localizable", "date_seek_view.date_seek", fallback: "Seek to date") - /// Seek to galleries around the selected date. - public static let seekAroundDate = L10n.tr("Localizable", "date_seek_view.seek_around_date", fallback: "Seek to galleries around the selected date.") - /// Newer - public static let seekNewer = L10n.tr("Localizable", "date_seek_view.seek_newer", fallback: "Newer") - /// Older - public static let seekOlder = L10n.tr("Localizable", "date_seek_view.seek_older", fallback: "Older") } public enum DetailView { /// Archives From b9c106cc5c56c94248f996a4df5572e7c05ca2db Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:55:23 +0800 Subject: [PATCH 458/614] Move DownloadClient strings to catalog The DownloadStore validation strings owned solely by DownloadClient (asset_unreadable, download_busy, download_folder_missing, folder_already_exists, folder_busy_downloading, manifest_missing) move to its catalog, keeping the download_store.* value-type namespace. The DownloadStore keys shared with DetailFeature/DownloadsFeature stay in Resources until the finale. - Add DownloadClient/Resources/Localizable.xcstrings (6 keys); rewrite 8 call sites; Package.swift add resources; delete keys; regen. Build and SwiftLint clean. --- AppPackage/Package.swift | 1 + .../DownloadClient+Folders.swift | 10 +- .../DownloadStore+Operations.swift | 6 +- .../Resources/Localizable.xcstrings | 252 ++++++++++++++++++ .../Resources/de.lproj/Localizable.strings | 6 - .../Resources/en.lproj/Localizable.strings | 6 - .../Resources/ja.lproj/Localizable.strings | 6 - .../Resources/ko.lproj/Localizable.strings | 6 - .../zh-Hans.lproj/Localizable.strings | 6 - .../zh-Hant.lproj/Localizable.strings | 6 - AppPackage/Sources/Resources/Strings.swift | 14 - 11 files changed, 261 insertions(+), 58 deletions(-) create mode 100644 AppPackage/Sources/DownloadClient/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 8d6d32760..fee240639 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -344,6 +344,7 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.composableArchitecture), .targetDependency(.kanna) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift index 302a5bacd..e94a90e17 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift @@ -23,7 +23,7 @@ extension DownloadCoordinator { guard !fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.folderAlreadyExists + String(localized: .downloadStoreFolderAlreadyExists) ) ) } @@ -60,7 +60,7 @@ extension DownloadCoordinator { guard !fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.folderAlreadyExists + String(localized: .downloadStoreFolderAlreadyExists) ) ) } @@ -70,7 +70,7 @@ extension DownloadCoordinator { downloadIndex[activeGalleryID]?.parentFolderName == oldName { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.folderBusyDownloading + String(localized: .downloadStoreFolderBusyDownloading) ) ) } @@ -158,7 +158,7 @@ extension DownloadCoordinator { guard activeGalleryID != gid else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.downloadBusy + String(localized: .downloadStoreDownloadBusy) ) ) } @@ -173,7 +173,7 @@ extension DownloadCoordinator { guard !fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.folderAlreadyExists + String(localized: .downloadStoreFolderAlreadyExists) ) ) } diff --git a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift index 5aa73eaa0..7417059ba 100644 --- a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift @@ -7,7 +7,7 @@ extension DownloadStore { public func linkOrCopyReadableAsset(at sourceURL: URL, to destinationURL: URL) throws { guard sanitizeAssetFileIfNeeded(at: sourceURL) else { throw AppError.fileOperationFailed( - L10n.Localizable.DownloadStore.assetUnreadable(sourceURL.lastPathComponent) + String(localized: .downloadStoreAssetUnreadable(sourceURL.lastPathComponent)) ) } @@ -201,11 +201,11 @@ extension DownloadStore { ) -> DownloadValidationState { let folderURL = download.folderURL guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { - return .missingFiles(L10n.Localizable.DownloadStore.downloadFolderMissing) + return .missingFiles(String(localized: .downloadStoreDownloadFolderMissing)) } let manifestURL = download.manifestURL guard fileManager.operate({ $0.fileExists(atPath: manifestURL.path) }) else { - return .missingFiles(L10n.Localizable.DownloadStore.manifestMissing) + return .missingFiles(String(localized: .downloadStoreManifestMissing)) } guard let manifest = try? readManifest(folderURL: folderURL) else { return .missingFiles(L10n.Localizable.DownloadStore.manifestCorrupted) diff --git a/AppPackage/Sources/DownloadClient/Resources/Localizable.xcstrings b/AppPackage/Sources/DownloadClient/Resources/Localizable.xcstrings new file mode 100644 index 000000000..7df21c808 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/Resources/Localizable.xcstrings @@ -0,0 +1,252 @@ +{ + "sourceLanguage": "en", + "strings": { + "download_store.asset_unreadable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Asset file is unreadable: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Asset-Datei ist nicht lesbar: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アセットファイルを読み取れません: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에셋 파일을 읽을 수 없습니다: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "资源文件无法读取:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資源檔案無法讀取:%@" + } + } + } + }, + "download_store.download_busy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The download is currently active." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Download läuft gerade." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードは現在進行中です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드가 진행 중입니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该下载正在进行中。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此下載正在進行中。" + } + } + } + }, + "download_store.download_folder_missing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download folder is missing." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download-Ordner fehlt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードフォルダが見つかりません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 폴더가 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载文件夹缺失。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載資料夾缺失。" + } + } + } + }, + "download_store.folder_already_exists": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A folder with this name already exists." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Ordner mit diesem Namen existiert bereits." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "同じ名前のフォルダがすでに存在します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "같은 이름의 폴더가 이미 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已存在同名文件夹。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已存在同名的資料夾。" + } + } + } + }, + "download_store.folder_busy_downloading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The folder contains an active download." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Ordner enthält einen aktiven Download." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダに進行中のダウンロードがあります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더에 진행 중인 다운로드가 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该文件夹中有正在进行的下载。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此資料夾中有正在進行的下載。" + } + } + } + }, + "download_store.manifest_missing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manifest file is missing." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Manifest-Datei fehlt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マニフェストファイルが見つかりません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "매니페스트 파일이 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Manifest 文件缺失。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Manifest 檔案缺失。" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 5f68fd394..9a8b2fcdf 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -447,13 +447,7 @@ // MARK: DownloadBadge // MARK: DownloadStore -"download_store.asset_unreadable" = "Asset-Datei ist nicht lesbar: %@"; "download_store.invalid_folder_name" = "Der Ordnername ist ungültig."; -"download_store.folder_already_exists" = "Ein Ordner mit diesem Namen existiert bereits."; -"download_store.folder_busy_downloading" = "Der Ordner enthält einen aktiven Download."; -"download_store.download_busy" = "Der Download läuft gerade."; -"download_store.download_folder_missing" = "Download-Ordner fehlt."; -"download_store.manifest_missing" = "Manifest-Datei fehlt."; "download_store.manifest_corrupted" = "Manifest-Datei ist beschädigt."; "download_store.page_missing" = "Seite %d fehlt."; "download_store.page_image_corrupted" = "Bilddaten von Seite %d sind beschädigt."; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 54c2b71d9..737176861 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -447,13 +447,7 @@ // MARK: DownloadBadge // MARK: DownloadStore -"download_store.asset_unreadable" = "Asset file is unreadable: %@"; "download_store.invalid_folder_name" = "The folder name is invalid."; -"download_store.folder_already_exists" = "A folder with this name already exists."; -"download_store.folder_busy_downloading" = "The folder contains an active download."; -"download_store.download_busy" = "The download is currently active."; -"download_store.download_folder_missing" = "Download folder is missing."; -"download_store.manifest_missing" = "Manifest file is missing."; "download_store.manifest_corrupted" = "Manifest file is corrupted."; "download_store.page_missing" = "Page %d is missing."; "download_store.page_image_corrupted" = "Page %d image data is corrupted."; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 50a60f9ae..7fcb247f1 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -447,13 +447,7 @@ // MARK: DownloadBadge // MARK: DownloadStore -"download_store.asset_unreadable" = "アセットファイルを読み取れません: %@"; "download_store.invalid_folder_name" = "フォルダ名が無効です。"; -"download_store.folder_already_exists" = "同じ名前のフォルダがすでに存在します。"; -"download_store.folder_busy_downloading" = "フォルダに進行中のダウンロードがあります。"; -"download_store.download_busy" = "ダウンロードは現在進行中です。"; -"download_store.download_folder_missing" = "ダウンロードフォルダが見つかりません。"; -"download_store.manifest_missing" = "マニフェストファイルが見つかりません。"; "download_store.manifest_corrupted" = "マニフェストファイルが破損しています。"; "download_store.page_missing" = "ページ %d が見つかりません。"; "download_store.page_image_corrupted" = "ページ %d の画像データが破損しています。"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index cf3c3731d..aeabdea5a 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -447,13 +447,7 @@ // MARK: DownloadBadge // MARK: DownloadStore -"download_store.asset_unreadable" = "에셋 파일을 읽을 수 없습니다: %@"; "download_store.invalid_folder_name" = "폴더 이름이 올바르지 않습니다."; -"download_store.folder_already_exists" = "같은 이름의 폴더가 이미 있습니다."; -"download_store.folder_busy_downloading" = "폴더에 진행 중인 다운로드가 있습니다."; -"download_store.download_busy" = "다운로드가 진행 중입니다."; -"download_store.download_folder_missing" = "다운로드 폴더가 없습니다."; -"download_store.manifest_missing" = "매니페스트 파일이 없습니다."; "download_store.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; "download_store.page_missing" = "페이지 %d가 없습니다."; "download_store.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 9e2389559..829b2f09b 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -447,13 +447,7 @@ // MARK: DownloadBadge // MARK: DownloadStore -"download_store.asset_unreadable" = "资源文件无法读取:%@"; "download_store.invalid_folder_name" = "文件夹名称无效。"; -"download_store.folder_already_exists" = "已存在同名文件夹。"; -"download_store.folder_busy_downloading" = "该文件夹中有正在进行的下载。"; -"download_store.download_busy" = "该下载正在进行中。"; -"download_store.download_folder_missing" = "下载文件夹缺失。"; -"download_store.manifest_missing" = "Manifest 文件缺失。"; "download_store.manifest_corrupted" = "Manifest 文件已损坏。"; "download_store.page_missing" = "第 %d 页缺失。"; "download_store.page_image_corrupted" = "第 %d 页图片数据已损坏。"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 014827673..b3c584754 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -447,13 +447,7 @@ // MARK: DownloadBadge // MARK: DownloadStore -"download_store.asset_unreadable" = "資源檔案無法讀取:%@"; "download_store.invalid_folder_name" = "資料夾名稱無效。"; -"download_store.folder_already_exists" = "已存在同名的資料夾。"; -"download_store.folder_busy_downloading" = "此資料夾中有正在進行的下載。"; -"download_store.download_busy" = "此下載正在進行中。"; -"download_store.download_folder_missing" = "下載資料夾缺失。"; -"download_store.manifest_missing" = "Manifest 檔案缺失。"; "download_store.manifest_corrupted" = "Manifest 檔案已損壞。"; "download_store.page_missing" = "第 %d 頁缺失。"; "download_store.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 84bd3926d..a27fbbe1b 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -1104,24 +1104,10 @@ public enum L10n { public static let title = L10n.tr("Localizable", "download_setting_view.title", fallback: "Download") } public enum DownloadStore { - /// Asset file is unreadable: %@ - public static func assetUnreadable(_ p1: Any) -> String { - return L10n.tr("Localizable", "download_store.asset_unreadable", String(describing: p1), fallback: "Asset file is unreadable: %@") - } - /// The download is currently active. - public static let downloadBusy = L10n.tr("Localizable", "download_store.download_busy", fallback: "The download is currently active.") - /// Download folder is missing. - public static let downloadFolderMissing = L10n.tr("Localizable", "download_store.download_folder_missing", fallback: "Download folder is missing.") - /// A folder with this name already exists. - public static let folderAlreadyExists = L10n.tr("Localizable", "download_store.folder_already_exists", fallback: "A folder with this name already exists.") - /// The folder contains an active download. - public static let folderBusyDownloading = L10n.tr("Localizable", "download_store.folder_busy_downloading", fallback: "The folder contains an active download.") /// The folder name is invalid. public static let invalidFolderName = L10n.tr("Localizable", "download_store.invalid_folder_name", fallback: "The folder name is invalid.") /// Manifest file is corrupted. public static let manifestCorrupted = L10n.tr("Localizable", "download_store.manifest_corrupted", fallback: "Manifest file is corrupted.") - /// Manifest file is missing. - public static let manifestMissing = L10n.tr("Localizable", "download_store.manifest_missing", fallback: "Manifest file is missing.") /// Page %d image data is corrupted. public static func pageImageCorrupted(_ p1: Int) -> String { return L10n.tr("Localizable", "download_store.page_image_corrupted", p1, fallback: "Page %d image data is corrupted.") From 98d8e574bf181a029cd98017c853c4fb7045b8a1 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:56:17 +0800 Subject: [PATCH 459/614] Move QuickSearchFeature strings to catalog quick_search_view.* exclusive keys (content, edit_word, name, new_word, optional) move to a module-local catalog. The shared "Quick search" title stays in Resources. - Add QuickSearchFeature/Resources/Localizable.xcstrings (5 keys); rewrite call sites; Package.swift add resources; delete keys; regen. Build and SwiftLint clean. --- AppPackage/Package.swift | 1 + .../QuickSearchFeature/QuickSearchView.swift | 10 +- .../Resources/Localizable.xcstrings | 211 ++++++++++++++++++ .../Resources/de.lproj/Localizable.strings | 5 - .../Resources/en.lproj/Localizable.strings | 5 - .../Resources/ja.lproj/Localizable.strings | 5 - .../Resources/ko.lproj/Localizable.strings | 5 - .../zh-Hans.lproj/Localizable.strings | 5 - .../zh-Hant.lproj/Localizable.strings | 5 - AppPackage/Sources/Resources/Strings.swift | 10 - 10 files changed, 217 insertions(+), 45 deletions(-) create mode 100644 AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index fee240639..14a7743d0 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -621,6 +621,7 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.composableArchitecture), .targetDependency(.sfSafeSymbols) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index 86ca01009..5554228cb 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -112,8 +112,8 @@ public struct QuickSearchView: View { @ViewBuilder private func editWordView(for kind: QuickSearchReducer.WordEditKind) -> some View { EditWordView( title: kind == .new - ? L10n.Localizable.QuickSearchView.newWord - : L10n.Localizable.QuickSearchView.editWord, + ? String(localized: .newWord) + : String(localized: .editWord), word: $store.editingWord, focusedField: $focusedField, submitAction: onTextFieldSubmitted, @@ -147,11 +147,11 @@ extension QuickSearchView { var body: some View { Form { - Section(L10n.Localizable.QuickSearchView.name) { - TextField(L10n.Localizable.QuickSearchView.optional, text: $word.name) + Section(String(localized: .name)) { + TextField(String(localized: .optional), text: $word.name) .submitLabel(.next).focused(focusedField, equals: .name) } - Section(L10n.Localizable.QuickSearchView.content) { + Section(String(localized: .content)) { TextEditor(text: $word.content) .disableAutocorrection(true) .textInputAutocapitalization(.never) diff --git a/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..f9aa7a5b1 --- /dev/null +++ b/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings @@ -0,0 +1,211 @@ +{ + "sourceLanguage": "en", + "strings": { + "content": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Content" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inhalt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内容" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내용" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋內容" + } + } + } + }, + "edit_word": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit word" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchbegriff bearbeiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーワードを編集" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키워드 편집" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑关键词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編輯關鍵字" + } + } + } + }, + "name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "名稱" + } + } + } + }, + "new_word": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New word" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Suchbegriff" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーワードを追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 키워드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加关键词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新關鍵字" + } + } + } + }, + "optional": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Optional" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Optional" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "任意" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택 사항" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "(可選)" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 9a8b2fcdf..b26989e47 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -154,11 +154,6 @@ // MARK: QuickSearchView "quick_search_view.quick_search" = "Schnellsuche"; -"quick_search_view.edit_word" = "Suchbegriff bearbeiten"; -"quick_search_view.new_word" = "Neuer Suchbegriff"; -"quick_search_view.content" = "Inhalt"; -"quick_search_view.name" = "Name"; -"quick_search_view.optional" = "Optional"; // MARK: SettingView "setting_view.setting" = "Einstellungen"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 737176861..444e24534 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -154,11 +154,6 @@ // MARK: QuickSearchView "quick_search_view.quick_search" = "Quick search"; -"quick_search_view.edit_word" = "Edit word"; -"quick_search_view.new_word" = "New word"; -"quick_search_view.content" = "Content"; -"quick_search_view.name" = "Name"; -"quick_search_view.optional" = "Optional"; // MARK: SettingView "setting_view.setting" = "Setting"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 7fcb247f1..abec484f5 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -154,11 +154,6 @@ // MARK: QuickSearchView "quick_search_view.quick_search" = "クイック検索"; -"quick_search_view.edit_word" = "キーワードを編集"; -"quick_search_view.new_word" = "キーワードを追加"; -"quick_search_view.content" = "内容"; -"quick_search_view.name" = "名前"; -"quick_search_view.optional" = "任意"; // MARK: SettingView "setting_view.setting" = "設定"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index aeabdea5a..d3138145d 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -154,11 +154,6 @@ // MARK: QuickSearchView "quick_search_view.quick_search" = "빠른 검색"; -"quick_search_view.edit_word" = "키워드 편집"; -"quick_search_view.new_word" = "새 키워드"; -"quick_search_view.content" = "내용"; -"quick_search_view.name" = "이름"; -"quick_search_view.optional" = "선택 사항"; // MARK: SettingView "setting_view.setting" = "설정"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 829b2f09b..6e5df4a24 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -154,11 +154,6 @@ // MARK: QuickSearchView "quick_search_view.quick_search" = "快速搜索"; -"quick_search_view.edit_word" = "编辑关键词"; -"quick_search_view.new_word" = "添加关键词"; -"quick_search_view.content" = "内容"; -"quick_search_view.name" = "名称"; -"quick_search_view.optional" = "可选"; // MARK: SettingView "setting_view.setting" = "设置"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index b3c584754..09d3d61fe 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -154,11 +154,6 @@ // MARK: QuickSearchView "quick_search_view.quick_search" = "快速搜尋"; -"quick_search_view.edit_word" = "編輯關鍵字"; -"quick_search_view.new_word" = "新關鍵字"; -"quick_search_view.content" = "搜尋內容"; -"quick_search_view.name" = "名稱"; -"quick_search_view.optional" = "(可選)"; // MARK: SettingView "setting_view.setting" = "設定"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index a27fbbe1b..fb8db13c2 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -1840,16 +1840,6 @@ public enum L10n { public static let previews = L10n.tr("Localizable", "previews_view.previews", fallback: "Previews") } public enum QuickSearchView { - /// Content - public static let content = L10n.tr("Localizable", "quick_search_view.content", fallback: "Content") - /// Edit word - public static let editWord = L10n.tr("Localizable", "quick_search_view.edit_word", fallback: "Edit word") - /// Name - public static let name = L10n.tr("Localizable", "quick_search_view.name", fallback: "Name") - /// New word - public static let newWord = L10n.tr("Localizable", "quick_search_view.new_word", fallback: "New word") - /// Optional - public static let `optional` = L10n.tr("Localizable", "quick_search_view.optional", fallback: "Optional") /// Quick search public static let quickSearch = L10n.tr("Localizable", "quick_search_view.quick_search", fallback: "Quick search") } From c8b5677e8f72e536d4edf75d5fd57f6b4424b2cf Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:57:28 +0800 Subject: [PATCH 460/614] Move ReadingSettingFeature strings to catalog reading_setting_view.* exclusive keys move to a module-local catalog; its Appearance header is renamed reading_appearance to stay globally unique against SettingFeature's Appearance. The shared "%@ pages" count stays in Resources. - Add ReadingSettingFeature/Resources/Localizable.xcstrings (8 keys); rewrite call sites; Package.swift add resources; delete keys; regen. Build and SwiftLint clean. --- AppPackage/Package.swift | 1 + .../ReadingSettingView.swift | 16 +- .../Resources/Localizable.xcstrings | 334 ++++++++++++++++++ .../Resources/de.lproj/Localizable.strings | 8 - .../Resources/en.lproj/Localizable.strings | 8 - .../Resources/ja.lproj/Localizable.strings | 8 - .../Resources/ko.lproj/Localizable.strings | 8 - .../zh-Hans.lproj/Localizable.strings | 8 - .../zh-Hant.lproj/Localizable.strings | 8 - AppPackage/Sources/Resources/Strings.swift | 18 - 10 files changed, 343 insertions(+), 74 deletions(-) create mode 100644 AppPackage/Sources/ReadingSettingFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 14a7743d0..8db0d82f3 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -608,6 +608,7 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .module(.resources) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index 8233a7adf..760bced9c 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -27,25 +27,25 @@ public struct ReadingSettingView: View { public var body: some View { Form { Section { - Picker(L10n.Localizable.ReadingSettingView.direction, selection: $readingDirection) { + Picker(String(localized: .direction), selection: $readingDirection) { ForEach(ReadingDirection.allCases) { Text($0.value).tag($0) } } .pickerStyle(.menu) - Picker(L10n.Localizable.ReadingSettingView.preloadLimit, selection: $prefetchLimit) { + Picker(String(localized: .preloadLimit), selection: $prefetchLimit) { ForEach(Array(stride(from: 6, through: 18, by: 4)), id: \.self) { value in Text(L10n.Localizable.Common.pages("\(value)")).tag(value) } } .pickerStyle(.menu) if !DeviceUtil.isPad { - Toggle(L10n.Localizable.ReadingSettingView.enablesLandscape, isOn: $enablesLandscape) + Toggle(String(localized: .enablesLandscape), isOn: $enablesLandscape) } } - Section(L10n.Localizable.ReadingSettingView.appearance) { + Section(String(localized: .readingAppearance)) { Picker( - L10n.Localizable.ReadingSettingView.separatorHeight, + String(localized: .separatorHeight), selection: $contentDividerHeight ) { ForEach(Array(stride(from: 0, through: 20, by: 5)), id: \.self) { value in @@ -56,17 +56,17 @@ public struct ReadingSettingView: View { .disabled(readingDirection != .vertical) ScaleFactorRow( scaleFactor: $maximumScaleFactor, - labelContent: L10n.Localizable.ReadingSettingView.maximumScaleFactor, + labelContent: String(localized: .maximumScaleFactor), minFactor: 1.5, maxFactor: 10 ) ScaleFactorRow( scaleFactor: $doubleTapScaleFactor, - labelContent: L10n.Localizable.ReadingSettingView.doubleTapScaleFactor, + labelContent: String(localized: .doubleTapScaleFactor), minFactor: 1.5, maxFactor: 5 ) } } - .navigationTitle(L10n.Localizable.ReadingSettingView.reading) + .navigationTitle(String(localized: .reading)) } } diff --git a/AppPackage/Sources/ReadingSettingFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/ReadingSettingFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..49a6a23a7 --- /dev/null +++ b/AppPackage/Sources/ReadingSettingFeature/Resources/Localizable.xcstrings @@ -0,0 +1,334 @@ +{ + "sourceLanguage": "en", + "strings": { + "direction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Direction" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Leserichtung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "方向" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "방향" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "方向" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "翻頁方向" + } + } + } + }, + "double_tap_scale_factor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Double tap scale factor" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Doppel-Tap Skalierungsfaktor" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダブルタップスケール係数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "더블 탭 확대 비율" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "双击缩放系数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雙擊縮放值" + } + } + } + }, + "enables_landscape": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enables landscape" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Querformat aktivieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "横向きを有効" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가로 화면 사용하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用横屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用橫向顯示" + } + } + } + }, + "maximum_scale_factor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maximum scale factor" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maximaler Skalierungsfaktor" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最大スケール係数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최대 확대 비율" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最大缩放系数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮放上限" + } + } + } + }, + "preload_limit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preload limit" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorausladen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プリロード上限数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 미리 로딩" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预加载数量上限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預先載入頁數" + } + } + } + }, + "reading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Am Lesen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阅读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "閱讀設定" + } + } + } + }, + "reading_appearance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oberfläche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "外観" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "외관" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "外观" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "外觀" + } + } + } + }, + "separator_height": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Separator height" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Höhe der Teilung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "仕切りの高さ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 간 여백 두께" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分隔线高度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "頁間分隔高度" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index b26989e47..a72a8d5c7 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -249,14 +249,6 @@ "app_icon_view.app_icon" = "App-Symbol"; // MARK: reading_settingView -"reading_setting_view.reading" = "Am Lesen"; -"reading_setting_view.direction" = "Leserichtung"; -"reading_setting_view.preload_limit" = "Vorausladen"; -"reading_setting_view.enables_landscape" = "Querformat aktivieren"; -"reading_setting_view.separator_height" = "Höhe der Teilung"; -"reading_setting_view.maximum_scale_factor" = "Maximaler Skalierungsfaktor"; -"reading_setting_view.double_tap_scale_factor" = "Doppel-Tap Skalierungsfaktor"; -"reading_setting_view.appearance" = "Oberfläche"; // ReadingDirection "reading_direction.vertical" = "Vertikal"; "reading_direction.right_to_left" = "Von rechts nach links"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 444e24534..72cae9254 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -249,14 +249,6 @@ "app_icon_view.app_icon" = "App icon"; // MARK: reading_settingView -"reading_setting_view.reading" = "Reading"; -"reading_setting_view.direction" = "Direction"; -"reading_setting_view.preload_limit" = "Preload limit"; -"reading_setting_view.enables_landscape" = "Enables landscape"; -"reading_setting_view.separator_height" = "Separator height"; -"reading_setting_view.maximum_scale_factor" = "Maximum scale factor"; -"reading_setting_view.double_tap_scale_factor" = "Double tap scale factor"; -"reading_setting_view.appearance" = "Appearance"; // ReadingDirection "reading_direction.vertical" = "Vertical"; "reading_direction.right_to_left" = "Right-to-left"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index abec484f5..8cb44660e 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -249,14 +249,6 @@ "app_icon_view.app_icon" = "アプリアイコン"; // MARK: reading_settingView -"reading_setting_view.reading" = "閲覧"; -"reading_setting_view.direction" = "方向"; -"reading_setting_view.preload_limit" = "プリロード上限数"; -"reading_setting_view.enables_landscape" = "横向きを有効"; -"reading_setting_view.separator_height" = "仕切りの高さ"; -"reading_setting_view.maximum_scale_factor" = "最大スケール係数"; -"reading_setting_view.double_tap_scale_factor" = "ダブルタップスケール係数"; -"reading_setting_view.appearance" = "外観"; // ReadingDirection "reading_direction.vertical" = "縦読み"; "reading_direction.right_to_left" = "右開き"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index d3138145d..af876059f 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -249,14 +249,6 @@ "app_icon_view.app_icon" = "앱 아이콘"; // MARK: reading_settingView -"reading_setting_view.reading" = "읽기"; -"reading_setting_view.direction" = "방향"; -"reading_setting_view.preload_limit" = "페이지 미리 로딩"; -"reading_setting_view.enables_landscape" = "가로 화면 사용하기"; -"reading_setting_view.separator_height" = "페이지 간 여백 두께"; -"reading_setting_view.maximum_scale_factor" = "최대 확대 비율"; -"reading_setting_view.double_tap_scale_factor" = "더블 탭 확대 비율"; -"reading_setting_view.appearance" = "외관"; // ReadingDirection "reading_direction.vertical" = "위에서 아래로"; "reading_direction.right_to_left" = "오른쪽에서 왼쪽으로"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 6e5df4a24..b67b77fd7 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -249,14 +249,6 @@ "app_icon_view.app_icon" = "应用图标"; // MARK: reading_settingView -"reading_setting_view.reading" = "阅读"; -"reading_setting_view.direction" = "方向"; -"reading_setting_view.preload_limit" = "预加载数量上限"; -"reading_setting_view.enables_landscape" = "启用横屏"; -"reading_setting_view.separator_height" = "分隔线高度"; -"reading_setting_view.maximum_scale_factor" = "最大缩放系数"; -"reading_setting_view.double_tap_scale_factor" = "双击缩放系数"; -"reading_setting_view.appearance" = "外观"; // ReadingDirection "reading_direction.vertical" = "垂直"; "reading_direction.right_to_left" = "右至左"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 09d3d61fe..345aefd9e 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -249,14 +249,6 @@ "app_icon_view.app_icon" = "App 圖案"; // MARK: reading_settingView -"reading_setting_view.reading" = "閱讀設定"; -"reading_setting_view.direction" = "翻頁方向"; -"reading_setting_view.preload_limit" = "預先載入頁數"; -"reading_setting_view.enables_landscape" = "啟用橫向顯示"; -"reading_setting_view.separator_height" = "頁間分隔高度"; -"reading_setting_view.maximum_scale_factor" = "縮放上限"; -"reading_setting_view.double_tap_scale_factor" = "雙擊縮放值"; -"reading_setting_view.appearance" = "外觀"; // ReadingDirection "reading_direction.vertical" = "垂直"; "reading_direction.right_to_left" = "由右至左滑"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index fb8db13c2..b800c161a 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -1851,24 +1851,6 @@ public enum L10n { /// Vertical public static let vertical = L10n.tr("Localizable", "reading_direction.vertical", fallback: "Vertical") } - public enum ReadingSettingView { - /// Appearance - public static let appearance = L10n.tr("Localizable", "reading_setting_view.appearance", fallback: "Appearance") - /// Direction - public static let direction = L10n.tr("Localizable", "reading_setting_view.direction", fallback: "Direction") - /// Double tap scale factor - public static let doubleTapScaleFactor = L10n.tr("Localizable", "reading_setting_view.double_tap_scale_factor", fallback: "Double tap scale factor") - /// Enables landscape - public static let enablesLandscape = L10n.tr("Localizable", "reading_setting_view.enables_landscape", fallback: "Enables landscape") - /// Maximum scale factor - public static let maximumScaleFactor = L10n.tr("Localizable", "reading_setting_view.maximum_scale_factor", fallback: "Maximum scale factor") - /// Preload limit - public static let preloadLimit = L10n.tr("Localizable", "reading_setting_view.preload_limit", fallback: "Preload limit") - /// Reading - public static let reading = L10n.tr("Localizable", "reading_setting_view.reading", fallback: "Reading") - /// Separator height - public static let separatorHeight = L10n.tr("Localizable", "reading_setting_view.separator_height", fallback: "Separator height") - } public enum ReadingView { /// Auto-Play public static let autoPlay = L10n.tr("Localizable", "reading_view.auto_play", fallback: "Auto-Play") From feec3097230b0a2ac3d6aa5531563afc0638a1e3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:58:35 +0800 Subject: [PATCH 461/614] Move ReadingFeature strings to catalog reading_view.* exclusive keys and the auto_play_policy.off value move to a module-local catalog. The shared "Share" and "%@ seconds" strings stay in Resources. - Add ReadingFeature/Resources/Localizable.xcstrings (11 keys); rewrite call sites; Package.swift add resources; delete keys; regen. Build and SwiftLint clean. --- AppPackage/Package.swift | 1 + .../ReadingViewComponents.swift | 10 +- .../Resources/Localizable.xcstrings | 457 ++++++++++++++++++ .../ReadingFeature/Support/ControlPanel.swift | 12 +- .../Resources/de.lproj/Localizable.strings | 11 - .../Resources/en.lproj/Localizable.strings | 11 - .../Resources/ja.lproj/Localizable.strings | 11 - .../Resources/ko.lproj/Localizable.strings | 11 - .../zh-Hans.lproj/Localizable.strings | 11 - .../zh-Hant.lproj/Localizable.strings | 11 - AppPackage/Sources/Resources/Strings.swift | 24 - 11 files changed, 469 insertions(+), 101 deletions(-) create mode 100644 AppPackage/Sources/ReadingFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 8db0d82f3..ee2755ed7 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -812,6 +812,7 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sfSafeSymbols), .targetDependency(.swiftUIPager) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift index bdea0e0a3..d44a877cf 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift @@ -34,7 +34,7 @@ extension AutoPlayPolicy { var value: String { switch self { case .off: - return L10n.Localizable.AutoPlayPolicy.off + return String(localized: .autoPlayPolicyOff) default: return L10n.Localizable.Common.seconds("\(rawValue)") } @@ -144,25 +144,25 @@ struct HorizontalImageStack: View { Button { refetchAction(index) } label: { - Label(L10n.Localizable.ReadingView.reload, systemSymbol: .arrowCounterclockwise) + Label(String(localized: .reload), systemSymbol: .arrowCounterclockwise) } if let imageURL = imageURLs[index] { Button { copyImageAction(imageURL) } label: { - Label(L10n.Localizable.ReadingView.copy, systemSymbol: .plusSquareOnSquare) + Label(String(localized: .copy), systemSymbol: .plusSquareOnSquare) } Button { saveImageAction(imageURL) } label: { - Label(L10n.Localizable.ReadingView.save, systemSymbol: .squareAndArrowDown) + Label(String(localized: .save), systemSymbol: .squareAndArrowDown) } if let originalImageURL = originalImageURLs[index] { Button { saveImageAction(originalImageURL) } label: { Label( - L10n.Localizable.ReadingView.saveOriginal, + String(localized: .saveOriginal), systemSymbol: .squareAndArrowDownOnSquare ) } diff --git a/AppPackage/Sources/ReadingFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/ReadingFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..0bae21daa --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/Resources/Localizable.xcstrings @@ -0,0 +1,457 @@ +{ + "sourceLanguage": "en", + "strings": { + "auto_play": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-Play" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Autoplay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動再生" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 재생" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动播放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動播放" + } + } + } + }, + "auto_play_policy.off": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끔" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不启用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉" + } + } + } + }, + "copy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kopieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복사" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "複製" + } + } + } + }, + "dual_page_mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dual-Page mode" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Doppelseitenmodus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デュアルページモード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "두 장을 한 화면으로 보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "双页模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雙頁模式" + } + } + } + }, + "except_the_cover": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Except the cover" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Außer dem Cover" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カバーを除く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표지 제외하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "封面除外" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "封面除外" + } + } + } + }, + "reading_setting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading setting" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Leseeinstellungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阅读设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "閱讀設定" + } + } + } + }, + "reload": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu laden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재시도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新整理" + } + } + } + }, + "reload_all_images": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload all images" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Bilder neu laden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像をすべて再読み込み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 이미지 다시 불러오기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载所有图片" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入所有圖片" + } + } + } + }, + "retry_all_failed_images": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry all failed images" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagene Bilder erneut laden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込み失敗した画像をすべてリトライ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패한 이미지 모두 재시도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试所有读取失败图片" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入失敗圖片" + } + } + } + }, + "save": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sichern" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "儲存圖片" + } + } + } + }, + "save_original": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save original" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Original sichern" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナルを保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본 저장" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存原图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "儲存原始圖片" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift index 63b9030ac..99d8ac405 100644 --- a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift +++ b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift @@ -151,7 +151,7 @@ private struct UpperPanel: View { Button { setting.enablesDualPageMode.toggle() } label: { - Text(L10n.Localizable.ReadingView.dualPageMode) + Text(String(localized: .dualPageMode)) if setting.enablesDualPageMode { Image(systemSymbol: .checkmark) } @@ -159,7 +159,7 @@ private struct UpperPanel: View { Button { setting.exceptCover.toggle() } label: { - Text(L10n.Localizable.ReadingView.exceptTheCover) + Text(String(localized: .exceptTheCover)) if setting.exceptCover { Image(systemSymbol: .checkmark) } @@ -173,7 +173,7 @@ private struct UpperPanel: View { } Menu { - Text(L10n.Localizable.ReadingView.autoPlay).foregroundColor(.secondary) + Text(String(localized: .autoPlay)).foregroundColor(.secondary) ForEach(AutoPlayPolicy.allCases) { policy in Button { autoPlayPolicy = policy @@ -193,15 +193,15 @@ private struct UpperPanel: View { ToolbarFeaturesMenu { Button(action: retryAllFailedImagesAction) { Image(systemSymbol: .exclamationmarkArrowTrianglehead2ClockwiseRotate90) - Text(L10n.Localizable.ReadingView.retryAllFailedImages) + Text(String(localized: .retryAllFailedImages)) } Button(action: reloadAllImagesAction) { Image(systemSymbol: .arrowCounterclockwise) - Text(L10n.Localizable.ReadingView.reloadAllImages) + Text(String(localized: .reloadAllImages)) } Button(action: navigateSettingAction) { Image(systemSymbol: .gear) - Text(L10n.Localizable.ReadingView.readingSetting) + Text(String(localized: .readingSetting)) } } .buttonStyle(.borderless) diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index a72a8d5c7..154e44850 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -416,19 +416,8 @@ "previews_view.previews" = "Vorschau"; // MARK: ReadingView -"reading_view.reload" = "Neu laden"; -"reading_view.copy" = "Kopieren"; -"reading_view.save" = "Sichern"; -"reading_view.save_original" = "Original sichern"; "reading_view.share" = "Teilen"; -"reading_view.auto_play" = "Autoplay"; -"reading_view.dual_page_mode" = "Doppelseitenmodus"; -"reading_view.except_the_cover" = "Außer dem Cover"; -"reading_view.retry_all_failed_images" = "Fehlgeschlagene Bilder erneut laden"; -"reading_view.reload_all_images" = "Alle Bilder neu laden"; -"reading_view.reading_setting" = "Leseeinstellungen"; // AutoPlayPolicy -"auto_play_policy.off" = "Aus"; // MARK: DownloadBadge diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 72cae9254..44008c124 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -416,19 +416,8 @@ "previews_view.previews" = "Previews"; // MARK: ReadingView -"reading_view.reload" = "Reload"; -"reading_view.copy" = "Copy"; -"reading_view.save" = "Save"; -"reading_view.save_original" = "Save original"; "reading_view.share" = "Share"; -"reading_view.auto_play" = "Auto-Play"; -"reading_view.dual_page_mode" = "Dual-Page mode"; -"reading_view.except_the_cover" = "Except the cover"; -"reading_view.retry_all_failed_images" = "Retry all failed images"; -"reading_view.reload_all_images" = "Reload all images"; -"reading_view.reading_setting" = "Reading setting"; // AutoPlayPolicy -"auto_play_policy.off" = "Off"; // MARK: DownloadBadge diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 8cb44660e..985f70cf7 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -416,19 +416,8 @@ "previews_view.previews" = "プレビュー"; // MARK: ReadingView -"reading_view.reload" = "再読み込み"; -"reading_view.copy" = "コピー"; -"reading_view.save" = "保存"; -"reading_view.save_original" = "オリジナルを保存"; "reading_view.share" = "共有"; -"reading_view.auto_play" = "自動再生"; -"reading_view.dual_page_mode" = "デュアルページモード"; -"reading_view.except_the_cover" = "カバーを除く"; -"reading_view.retry_all_failed_images" = "読み込み失敗した画像をすべてリトライ"; -"reading_view.reload_all_images" = "画像をすべて再読み込み"; -"reading_view.reading_setting" = "閲覧設定"; // AutoPlayPolicy -"auto_play_policy.off" = "オフ"; // MARK: DownloadBadge diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index af876059f..80e035591 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -416,19 +416,8 @@ "previews_view.previews" = "미리보기"; // MARK: ReadingView -"reading_view.reload" = "재시도"; -"reading_view.copy" = "복사"; -"reading_view.save" = "저장"; -"reading_view.save_original" = "원본 저장"; "reading_view.share" = "공유"; -"reading_view.auto_play" = "자동 재생"; -"reading_view.dual_page_mode" = "두 장을 한 화면으로 보기"; -"reading_view.except_the_cover" = "표지 제외하기"; -"reading_view.retry_all_failed_images" = "실패한 이미지 모두 재시도"; -"reading_view.reload_all_images" = "모든 이미지 다시 불러오기"; -"reading_view.reading_setting" = "읽기 설정"; // AutoPlayPolicy -"auto_play_policy.off" = "끔"; // MARK: DownloadBadge diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index b67b77fd7..36b7f4443 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -416,19 +416,8 @@ "previews_view.previews" = "预览"; // MARK: ReadingView -"reading_view.reload" = "重新加载"; -"reading_view.copy" = "复制"; -"reading_view.save" = "保存"; -"reading_view.save_original" = "保存原图"; "reading_view.share" = "分享"; -"reading_view.auto_play" = "自动播放"; -"reading_view.dual_page_mode" = "双页模式"; -"reading_view.except_the_cover" = "封面除外"; -"reading_view.retry_all_failed_images" = "重试所有读取失败图片"; -"reading_view.reload_all_images" = "重新加载所有图片"; -"reading_view.reading_setting" = "阅读设置"; // AutoPlayPolicy -"auto_play_policy.off" = "不启用"; // MARK: DownloadBadge diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 345aefd9e..7b6f066d8 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -416,19 +416,8 @@ "previews_view.previews" = "預覽"; // MARK: ReadingView -"reading_view.reload" = "重新整理"; -"reading_view.copy" = "複製"; -"reading_view.save" = "儲存圖片"; -"reading_view.save_original" = "儲存原始圖片"; "reading_view.share" = "分享"; -"reading_view.auto_play" = "自動播放"; -"reading_view.dual_page_mode" = "雙頁模式"; -"reading_view.except_the_cover" = "封面除外"; -"reading_view.retry_all_failed_images" = "重新載入失敗圖片"; -"reading_view.reload_all_images" = "重新載入所有圖片"; -"reading_view.reading_setting" = "閱讀設定"; // AutoPlayPolicy -"auto_play_policy.off" = "關閉"; // MARK: DownloadBadge diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index b800c161a..fa46e0f8c 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -319,10 +319,6 @@ public enum L10n { /// Never public static let never = L10n.tr("Localizable", "auto_lock_policy.never", fallback: "Never") } - public enum AutoPlayPolicy { - /// Off - public static let off = L10n.tr("Localizable", "auto_play_policy.off", fallback: "Off") - } public enum BanInterval { /// and public static let and = L10n.tr("Localizable", "ban_interval.and", fallback: "and") @@ -1852,26 +1848,6 @@ public enum L10n { public static let vertical = L10n.tr("Localizable", "reading_direction.vertical", fallback: "Vertical") } public enum ReadingView { - /// Auto-Play - public static let autoPlay = L10n.tr("Localizable", "reading_view.auto_play", fallback: "Auto-Play") - /// Copy - public static let copy = L10n.tr("Localizable", "reading_view.copy", fallback: "Copy") - /// Dual-Page mode - public static let dualPageMode = L10n.tr("Localizable", "reading_view.dual_page_mode", fallback: "Dual-Page mode") - /// Except the cover - public static let exceptTheCover = L10n.tr("Localizable", "reading_view.except_the_cover", fallback: "Except the cover") - /// Reading setting - public static let readingSetting = L10n.tr("Localizable", "reading_view.reading_setting", fallback: "Reading setting") - /// Reload - public static let reload = L10n.tr("Localizable", "reading_view.reload", fallback: "Reload") - /// Reload all images - public static let reloadAllImages = L10n.tr("Localizable", "reading_view.reload_all_images", fallback: "Reload all images") - /// Retry all failed images - public static let retryAllFailedImages = L10n.tr("Localizable", "reading_view.retry_all_failed_images", fallback: "Retry all failed images") - /// Save - public static let save = L10n.tr("Localizable", "reading_view.save", fallback: "Save") - /// Save original - public static let saveOriginal = L10n.tr("Localizable", "reading_view.save_original", fallback: "Save original") /// Share public static let share = L10n.tr("Localizable", "reading_view.share", fallback: "Share") } From c1e7c5900bc9cec033628942e0355f34f5e9541b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 01:59:50 +0800 Subject: [PATCH 462/614] Move HomeFeature strings to catalog home_view/frontpage/toplists/etc. exclusive keys and home_misc_grid_type values move to a module-local catalog; home_view.frontpage/toplists merge with their sub-view titles. jump_page_description adopts %lld. Shared home/frontpage/toplists/jump_page/quick_search/cancel/clear stay in Resources. - Add HomeFeature/Resources/Localizable.xcstrings (12 keys); rewrite 17 accessors across 8 files; Package.swift add resources; delete; regen. Build and SwiftLint clean. --- AppPackage/Package.swift | 1 + .../HomeFeature/Frontpage/FrontpageView.swift | 4 +- .../HomeFeature/History/HistoryView.swift | 4 +- .../HomeFeature/HomeView+Sections.swift | 6 +- AppPackage/Sources/HomeFeature/HomeView.swift | 6 +- .../HomeFeature/Popular/PopularView.swift | 4 +- .../Resources/Localizable.xcstrings | 498 ++++++++++++++++++ .../Toplists/ToplistsReducer.swift | 4 +- .../HomeFeature/Toplists/ToplistsView.swift | 4 +- .../HomeFeature/Watched/WatchedView.swift | 2 +- .../Resources/de.lproj/Localizable.strings | 14 - .../Resources/en.lproj/Localizable.strings | 14 - .../Resources/ja.lproj/Localizable.strings | 14 - .../Resources/ko.lproj/Localizable.strings | 14 - .../zh-Hans.lproj/Localizable.strings | 14 - .../zh-Hant.lproj/Localizable.strings | 14 - AppPackage/Sources/Resources/Strings.swift | 42 -- 17 files changed, 516 insertions(+), 143 deletions(-) create mode 100644 AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index ee2755ed7..2b469e1eb 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -751,6 +751,7 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.swiftUIPager), .targetDependency(.uiImageColors) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 3ce03adfa..87e8913dd 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -59,7 +59,7 @@ struct FrontpageView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.filter) + .searchable(text: $store.keyword, prompt: String(localized: .filter)) .onAppear { if store.galleries.isEmpty { DispatchQueue.main.async { @@ -68,7 +68,7 @@ struct FrontpageView: View { } } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.FrontpageView.frontpage) + .navigationTitle(String(localized: .frontpage)) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index 23a3bfb41..b39b82292 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -39,7 +39,7 @@ struct HistoryView: View { }, downloadBadges: store.downloadBadges ) - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.filter) + .searchable(text: $store.keyword, prompt: String(localized: .filter)) .onAppear { store.send(.onAppear) if store.galleries.isEmpty { @@ -49,7 +49,7 @@ struct HistoryView: View { } } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.HistoryView.history) + .navigationTitle(String(localized: .history)) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift index 79b9a1a0b..5af15b353 100644 --- a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift +++ b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift @@ -97,7 +97,7 @@ struct CoverWallSection: View { var body: some View { SubSection( - title: L10n.Localizable.HomeView.frontpage, + title: String(localized: .frontpage), tint: .secondary, isLoading: isLoading, reloadAction: reloadAction, showAllAction: showAllAction @@ -190,7 +190,7 @@ struct ToplistsSection: View { var body: some View { SubSection( - title: L10n.Localizable.HomeView.toplists, + title: String(localized: .toplists), tint: .secondary, isLoading: isLoading, reloadAction: reloadAction, showAllAction: showAllAction @@ -264,7 +264,7 @@ struct MiscGridSection: View { } var body: some View { - SubSection(title: L10n.Localizable.HomeView.other, showAll: false) { + SubSection(title: String(localized: .other), showAll: false) { ScrollView(.horizontal, showsIndicators: false) { HStack { let types = HomeMiscGridType.allCases diff --git a/AppPackage/Sources/HomeFeature/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift index b69ea69b0..bfaafb213 100644 --- a/AppPackage/Sources/HomeFeature/HomeView.swift +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -164,11 +164,11 @@ extension HomeMiscGridType { var title: String { switch self { case .popular: - return L10n.Localizable.HomeMiscGridType.popular + return String(localized: .homeMiscGridTypePopular) case .watched: - return L10n.Localizable.HomeMiscGridType.watched + return String(localized: .homeMiscGridTypeWatched) case .history: - return L10n.Localizable.HomeMiscGridType.history + return String(localized: .homeMiscGridTypeHistory) } } var symbol: SFSymbol { diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index 0770c29f4..cf86e71cd 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -44,7 +44,7 @@ struct PopularView: View { FiltersView(store: store) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.filter) + .searchable(text: $store.keyword, prompt: String(localized: .filter)) .onAppear { if store.galleries.isEmpty { DispatchQueue.main.async { @@ -53,7 +53,7 @@ struct PopularView: View { } } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.PopularView.popular) + .navigationTitle(String(localized: .popular)) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..344ba5cae --- /dev/null +++ b/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings @@ -0,0 +1,498 @@ +{ + "sourceLanguage": "en", + "strings": { + "confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bestätigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "確認" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確定" + } + } + } + }, + "filter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Filter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Filter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィルター" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필터" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "過濾" + } + } + } + }, + "frontpage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Frontpage" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Startseite" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フロントページ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프론트 페이지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扉页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "首頁" + } + } + } + }, + "history": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧履歴" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽은 목록" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "历史" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "歷程" + } + } + } + }, + "home_misc_grid_type.history": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧履歴" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽은 목록" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "历史" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "歷程" + } + } + } + }, + "home_misc_grid_type.popular": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Popular" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beliebt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "人気" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인기 작품" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "热门" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "熱門" + } + } + } + }, + "home_misc_grid_type.watched": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Watched" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Meine Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの購読" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주시 태그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關注" + } + } + } + }, + "jump_page_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a page number between 1 and %lld to jump to." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie eine Seitenzahl zwischen 1 und %lld ein." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 ~ %lld のページ番号を入力してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "1에서 %lld 사이의 페이지 번호를 입력하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请输入 1 到 %lld 之间的页码。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請輸入 1 到 %lld 之間的頁碼。" + } + } + } + }, + "other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sonstiges" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기타" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其它" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "其他" + } + } + } + }, + "popular": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Popular" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beliebt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "人気" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인기 작품" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "热门" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "熱門" + } + } + } + }, + "toplists": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toplists" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Toplisten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ランキング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상위 목록" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "排行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排行" + } + } + } + }, + "watched": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Watched" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Meine Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの購読" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주시 태그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關注" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index fa65f5aed..a899b6d04 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -121,14 +121,14 @@ public struct ToplistsReducer: Sendable { ), actions: { ButtonState(action: .performJumpPage) { - TextState(L10n.Localizable.JumpPageView.confirm) + TextState(String(localized: .confirm)) } ButtonState(role: .cancel) { TextState(L10n.Localizable.Common.cancel) } }, message: { - TextState(L10n.Localizable.JumpPageView.jumpPageDescription(maximumPage)) + TextState(String(localized: .jumpPageDescription(maximumPage))) } ) return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index 9a620e81a..ee6119ae4 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -26,7 +26,7 @@ struct ToplistsView: View { } private var navigationTitle: String { - [L10n.Localizable.ToplistsView.toplists, store.type.value].joined(separator: " - ") + [String(localized: .toplists), store.type.value].joined(separator: " - ") } var body: some View { @@ -43,7 +43,7 @@ struct ToplistsView: View { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.filter) + .searchable(text: $store.keyword, prompt: String(localized: .filter)) .appAlert($store.scope(state: \.alert, action: \.alert), text: $store.jumpPageIndex) .onAppear { if store.galleries?.isEmpty != false { diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index e4b3bac0a..bc8749054 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -96,7 +96,7 @@ struct WatchedView: View { } } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.WatchedView.watched) + .navigationTitle(String(localized: .watched)) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 154e44850..8aa4c29a8 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -54,8 +54,6 @@ "date_seek_view.date_seek" = "Datum aufsuchen"; // MARK: JumpPage "jump_page_view.jump_page" = "Zu Seite springen"; -"jump_page_view.jump_page_description" = "Geben Sie eine Seitenzahl zwischen 1 und %d ein."; -"jump_page_view.confirm" = "Bestätigen"; // MARK: AlertView "loading_view.loading" = "Wird geladen..."; @@ -116,28 +114,17 @@ // MARK: HomeView "home_view.home" = "Start"; -"home_view.frontpage" = "Startseite"; -"home_view.toplists" = "Toplisten"; -"home_view.other" = "Sonstiges"; // HomeMiscGridType -"home_misc_grid_type.popular" = "Beliebt"; -"home_misc_grid_type.watched" = "Meine Tags"; -"home_misc_grid_type.history" = "Verlauf"; // MARK: FrontpageView -"frontpage_view.frontpage" = "Startseite"; // MARK: ToplistsView -"toplists_view.toplists" = "Toplisten"; // MARK: PopularView -"popular_view.popular" = "Beliebt"; // MARK: WatchedView -"watched_view.watched" = "Meine Tags"; // MARK: HistoryView -"history_view.history" = "Verlauf"; // MARK: FavoritesView "favorites_view.favorites" = "Favoriten"; @@ -149,7 +136,6 @@ "search_view.search" = "Suche"; "search_view.quick_search" = "Schnellsuche"; // Searchable -"searchable.filter" = "Filter"; "searchable.matches_count" = "%d Treffer gefunden."; // MARK: QuickSearchView diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 44008c124..8123dc90e 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -54,8 +54,6 @@ "date_seek_view.date_seek" = "Seek to date"; // MARK: JumpPage "jump_page_view.jump_page" = "Jump page"; -"jump_page_view.jump_page_description" = "Enter a page number between 1 and %d to jump to."; -"jump_page_view.confirm" = "Confirm"; // MARK: AlertView "loading_view.loading" = "Loading..."; @@ -116,28 +114,17 @@ // MARK: HomeView "home_view.home" = "Home"; -"home_view.frontpage" = "Frontpage"; -"home_view.toplists" = "Toplists"; -"home_view.other" = "Other"; // HomeMiscGridType -"home_misc_grid_type.popular" = "Popular"; -"home_misc_grid_type.watched" = "Watched"; -"home_misc_grid_type.history" = "History"; // MARK: FrontpageView -"frontpage_view.frontpage" = "Frontpage"; // MARK: ToplistsView -"toplists_view.toplists" = "Toplists"; // MARK: PopularView -"popular_view.popular" = "Popular"; // MARK: WatchedView -"watched_view.watched" = "Watched"; // MARK: HistoryView -"history_view.history" = "History"; // MARK: FavoritesView "favorites_view.favorites" = "Favorites"; @@ -149,7 +136,6 @@ "search_view.search" = "Search"; "search_view.quick_search" = "Quick search"; // Searchable -"searchable.filter" = "Filter"; "searchable.matches_count" = "Found %d matches."; // MARK: QuickSearchView diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 985f70cf7..f1684e521 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -54,8 +54,6 @@ "date_seek_view.date_seek" = "日付指定"; // MARK: JumpPage "jump_page_view.jump_page" = "ページジャンプ"; -"jump_page_view.jump_page_description" = "1 ~ %d のページ番号を入力してください。"; -"jump_page_view.confirm" = "確認"; // MARK: AlertView "loading_view.loading" = "読み込み中..."; @@ -116,28 +114,17 @@ // MARK: HomeView "home_view.home" = "ホーム"; -"home_view.frontpage" = "フロントページ"; -"home_view.toplists" = "ランキング"; -"home_view.other" = "その他"; // HomeMiscGridType -"home_misc_grid_type.popular" = "人気"; -"home_misc_grid_type.watched" = "タグの購読"; -"home_misc_grid_type.history" = "閲覧履歴"; // MARK: FrontpageView -"frontpage_view.frontpage" = "フロントページ"; // MARK: ToplistsView -"toplists_view.toplists" = "ランキング"; // MARK: PopularView -"popular_view.popular" = "人気"; // MARK: WatchedView -"watched_view.watched" = "タグの購読"; // MARK: HistoryView -"history_view.history" = "閲覧履歴"; // MARK: FavoritesView "favorites_view.favorites" = "お気に入り"; @@ -149,7 +136,6 @@ "search_view.search" = "検索"; "search_view.quick_search" = "クイック検索"; // Searchable -"searchable.filter" = "フィルター"; "searchable.matches_count" = "%d 件の該当項目"; // MARK: QuickSearchView diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index 80e035591..d682f8f58 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -54,8 +54,6 @@ "date_seek_view.date_seek" = "날짜로 이동"; // MARK: JumpPage "jump_page_view.jump_page" = "페이지 이동"; -"jump_page_view.jump_page_description" = "1에서 %d 사이의 페이지 번호를 입력하세요."; -"jump_page_view.confirm" = "확인"; // MARK: AlertView "loading_view.loading" = "로딩 중..."; @@ -116,28 +114,17 @@ // MARK: HomeView "home_view.home" = "홈"; -"home_view.frontpage" = "프론트 페이지"; -"home_view.toplists" = "상위 목록"; -"home_view.other" = "기타"; // HomeMiscGridType -"home_misc_grid_type.popular" = "인기 작품"; -"home_misc_grid_type.watched" = "주시 태그"; -"home_misc_grid_type.history" = "읽은 목록"; // MARK: FrontpageView -"frontpage_view.frontpage" = "프론트 페이지"; // MARK: ToplistsView -"toplists_view.toplists" = "상위 목록"; // MARK: PopularView -"popular_view.popular" = "인기 작품"; // MARK: WatchedView -"watched_view.watched" = "주시 태그"; // MARK: HistoryView -"history_view.history" = "읽은 목록"; // MARK: FavoritesView "favorites_view.favorites" = "즐겨찾기"; @@ -149,7 +136,6 @@ "search_view.search" = "검색"; "search_view.quick_search" = "빠른 검색"; // Searchable -"searchable.filter" = "필터"; "searchable.matches_count" = "검색 결과 %d개"; // MARK: QuickSearchView diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 36b7f4443..0647d8fd7 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -54,8 +54,6 @@ "date_seek_view.date_seek" = "日期定位"; // MARK: JumpPage "jump_page_view.jump_page" = "页码跳转"; -"jump_page_view.jump_page_description" = "请输入 1 到 %d 之间的页码。"; -"jump_page_view.confirm" = "确认"; // MARK: AlertView "loading_view.loading" = "加载中..."; @@ -116,28 +114,17 @@ // MARK: HomeView "home_view.home" = "主页"; -"home_view.frontpage" = "扉页"; -"home_view.toplists" = "排行"; -"home_view.other" = "其它"; // HomeMiscGridType -"home_misc_grid_type.popular" = "热门"; -"home_misc_grid_type.watched" = "标签"; -"home_misc_grid_type.history" = "历史"; // MARK: FrontpageView -"frontpage_view.frontpage" = "扉页"; // MARK: ToplistsView -"toplists_view.toplists" = "排行"; // MARK: PopularView -"popular_view.popular" = "热门"; // MARK: WatchedView -"watched_view.watched" = "标签"; // MARK: HistoryView -"history_view.history" = "历史"; // MARK: FavoritesView "favorites_view.favorites" = "收藏"; @@ -149,7 +136,6 @@ "search_view.search" = "搜索"; "search_view.quick_search" = "快速搜索"; // Searchable -"searchable.filter" = "筛选"; "searchable.matches_count" = "找到 %d 项结果"; // MARK: QuickSearchView diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 7b6f066d8..3569d8918 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -54,8 +54,6 @@ "date_seek_view.date_seek" = "日期定位"; // MARK: JumpPage "jump_page_view.jump_page" = "跳到..."; -"jump_page_view.jump_page_description" = "請輸入 1 到 %d 之間的頁碼。"; -"jump_page_view.confirm" = "確定"; // MARK: AlertView "loading_view.loading" = "載入中..."; @@ -116,28 +114,17 @@ // MARK: HomeView "home_view.home" = "總覽"; -"home_view.frontpage" = "首頁"; -"home_view.toplists" = "排行"; -"home_view.other" = "其他"; // HomeMiscGridType -"home_misc_grid_type.popular" = "熱門"; -"home_misc_grid_type.watched" = "關注"; -"home_misc_grid_type.history" = "歷程"; // MARK: FrontpageView -"frontpage_view.frontpage" = "首頁"; // MARK: ToplistsView -"toplists_view.toplists" = "排行"; // MARK: PopularView -"popular_view.popular" = "熱門"; // MARK: WatchedView -"watched_view.watched" = "關注"; // MARK: HistoryView -"history_view.history" = "歷程"; // MARK: FavoritesView "favorites_view.favorites" = "收藏"; @@ -149,7 +136,6 @@ "search_view.search" = "搜尋"; "search_view.quick_search" = "快速搜尋"; // Searchable -"searchable.filter" = "過濾"; "searchable.matches_count" = "找到 %d 項結果"; // MARK: QuickSearchView diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index fa46e0f8c..d25b3b918 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -1447,10 +1447,6 @@ public enum L10n { /// Folders public static let folders = L10n.tr("Localizable", "folder_manager_view.folders", fallback: "Folders") } - public enum FrontpageView { - /// Frontpage - public static let frontpage = L10n.tr("Localizable", "frontpage_view.frontpage", fallback: "Frontpage") - } public enum GalleryInfosView { /// Archive URL public static let archiveURL = L10n.tr("Localizable", "gallery_infos_view.archive_URL", fallback: "Archive URL") @@ -1583,41 +1579,17 @@ public enum L10n { /// Free public static let free = L10n.tr("Localizable", "hath_archive.free", fallback: "Free") } - public enum HistoryView { - /// History - public static let history = L10n.tr("Localizable", "history_view.history", fallback: "History") - } - public enum HomeMiscGridType { - /// History - public static let history = L10n.tr("Localizable", "home_misc_grid_type.history", fallback: "History") - /// Popular - public static let popular = L10n.tr("Localizable", "home_misc_grid_type.popular", fallback: "Popular") - /// Watched - public static let watched = L10n.tr("Localizable", "home_misc_grid_type.watched", fallback: "Watched") - } public enum HomeView { - /// Frontpage - public static let frontpage = L10n.tr("Localizable", "home_view.frontpage", fallback: "Frontpage") /// Home public static let home = L10n.tr("Localizable", "home_view.home", fallback: "Home") - /// Other - public static let other = L10n.tr("Localizable", "home_view.other", fallback: "Other") - /// Toplists - public static let toplists = L10n.tr("Localizable", "home_view.toplists", fallback: "Toplists") } public enum ImageResolution { /// Auto public static let auto = L10n.tr("Localizable", "image_resolution.auto", fallback: "Auto") } public enum JumpPageView { - /// Confirm - public static let confirm = L10n.tr("Localizable", "jump_page_view.confirm", fallback: "Confirm") /// Jump page public static let jumpPage = L10n.tr("Localizable", "jump_page_view.jump_page", fallback: "Jump page") - /// Enter a page number between 1 and %d to jump to. - public static func jumpPageDescription(_ p1: Int) -> String { - return L10n.tr("Localizable", "jump_page_view.jump_page_description", p1, fallback: "Enter a page number between 1 and %d to jump to.") - } } public enum LaboratorySettingView { /// Bypasses SNI Filtering @@ -1813,10 +1785,6 @@ public enum L10n { /// You need to login to access this feature. public static let needLogin = L10n.tr("Localizable", "not_login_view.need_login", fallback: "You need to login to access this feature.") } - public enum PopularView { - /// Popular - public static let popular = L10n.tr("Localizable", "popular_view.popular", fallback: "Popular") - } public enum PostCommentView { /// Edit comment public static let editComment = L10n.tr("Localizable", "post_comment_view.edit_comment", fallback: "Edit comment") @@ -1858,8 +1826,6 @@ public enum L10n { public static let search = L10n.tr("Localizable", "search_view.search", fallback: "Search") } public enum Searchable { - /// Filter - public static let filter = L10n.tr("Localizable", "searchable.filter", fallback: "Filter") /// Found %d matches. public static func matchesCount(_ p1: Int) -> String { return L10n.tr("Localizable", "searchable.matches_count", p1, fallback: "Found %d matches.") @@ -1993,18 +1959,10 @@ public enum L10n { /// Yesterday public static let yesterday = L10n.tr("Localizable", "toplists_type.yesterday", fallback: "Yesterday") } - public enum ToplistsView { - /// Toplists - public static let toplists = L10n.tr("Localizable", "toplists_view.toplists", fallback: "Toplists") - } public enum TorrentsView { /// Torrents public static let torrents = L10n.tr("Localizable", "torrents_view.torrents", fallback: "Torrents") } - public enum WatchedView { - /// Watched - public static let watched = L10n.tr("Localizable", "watched_view.watched", fallback: "Watched") - } } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length From df4080a423288bfd6c806933ed5828efcaa2987d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 02:01:55 +0800 Subject: [PATCH 463/614] Move AppComponents strings to catalog Exclusive AppComponents strings move to a module-local catalog: the toast.* group (namespace kept, it has an internal loading conflict), new_dawn.first/second, need_login, show_all, matches_count (now %lld), and loading. matches_count/loading resolve via generated symbols. LoadingView.init default title becomes nil-resolved in the body, since the generated .loading symbol is internal and can't appear in a public default argument. - Add AppComponents/Resources/Localizable.xcstrings (12 keys); rewrite 12 accessors across 5 files; Package.swift add resources; delete; regen. Build and SwiftLint clean. --- AppPackage/Package.swift | 1 + .../Sources/AppComponents/AlertView.swift | 6 +- .../Sources/AppComponents/AppAlertState.swift | 12 +- .../Sources/AppComponents/NewDawnView.swift | 4 +- .../Resources/Localizable.xcstrings | 498 ++++++++++++++++++ .../Sources/AppComponents/SubSection.swift | 2 +- .../AppComponents/TagSuggestionView.swift | 2 +- .../Resources/de.lproj/Localizable.strings | 12 - .../Resources/en.lproj/Localizable.strings | 12 - .../Resources/ja.lproj/Localizable.strings | 12 - .../Resources/ko.lproj/Localizable.strings | 12 - .../zh-Hans.lproj/Localizable.strings | 12 - .../zh-Hant.lproj/Localizable.strings | 12 - AppPackage/Sources/Resources/Strings.swift | 38 -- 14 files changed, 512 insertions(+), 123 deletions(-) create mode 100644 AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 2b469e1eb..92ee2c9bf 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -489,6 +489,7 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/AppComponents/AlertView.swift b/AppPackage/Sources/AppComponents/AlertView.swift index 37dfd6390..4aea3adb5 100644 --- a/AppPackage/Sources/AppComponents/AlertView.swift +++ b/AppPackage/Sources/AppComponents/AlertView.swift @@ -7,8 +7,8 @@ import AppTools public struct LoadingView: View { private let title: String - public init(title: String = L10n.Localizable.LoadingView.loading) { - self.title = title + public init(title: String? = nil) { + self.title = title ?? String(localized: .loading) } public var body: some View { @@ -54,7 +54,7 @@ public struct NotLoginView: View { public var body: some View { AlertView( symbol: .personCropCircleBadgeQuestionmarkFill, - message: L10n.Localizable.NotLoginView.needLogin + message: String(localized: .needLogin) ) { AlertViewButton(title: L10n.Localizable.notLoginViewlogin, action: action) } diff --git a/AppPackage/Sources/AppComponents/AppAlertState.swift b/AppPackage/Sources/AppComponents/AppAlertState.swift index 2d9473dc4..0ce198c21 100644 --- a/AppPackage/Sources/AppComponents/AppAlertState.swift +++ b/AppPackage/Sources/AppComponents/AppAlertState.swift @@ -125,34 +125,34 @@ extension AppAlertState where Action == Never { public static func loading(title: String? = nil) -> Self { .init( style: .toast(icon: .loading, autoHide: false), - title: TextState(title ?? L10n.Localizable.Toast.loading) + title: TextState(title ?? String(localized: .toastLoading)) ) } public static var communicating: Self { .init( style: .toast(icon: .loading, autoHide: false), - title: TextState(L10n.Localizable.Toast.communicating) + title: TextState(String(localized: .toastCommunicating)) ) } public static func error(caption: String? = nil) -> Self { .init( style: .toast(icon: .error, autoHide: true), - title: TextState(L10n.Localizable.Toast.error), + title: TextState(String(localized: .toastError)), message: caption.map { TextState($0) } ) } public static func success(caption: String? = nil) -> Self { .init( style: .toast(icon: .success, autoHide: true), - title: TextState(L10n.Localizable.Toast.success), + title: TextState(String(localized: .toastSuccess)), message: caption.map { TextState($0) } ) } public static var savedToPhotoLibrary: Self { - .success(caption: L10n.Localizable.Toast.savedToPhotoLibrary) + .success(caption: String(localized: .toastSavedToPhotoLibrary)) } public static var copiedToClipboardSucceeded: Self { - .success(caption: L10n.Localizable.Toast.copiedToClipboard) + .success(caption: String(localized: .toastCopiedToClipboard)) } } diff --git a/AppPackage/Sources/AppComponents/NewDawnView.swift b/AppPackage/Sources/AppComponents/NewDawnView.swift index f77ad1a4c..a4f227f9d 100644 --- a/AppPackage/Sources/AppComponents/NewDawnView.swift +++ b/AppPackage/Sources/AppComponents/NewDawnView.swift @@ -47,8 +47,8 @@ public struct NewDawnView: View { } VStack(spacing: 50) { VStack(spacing: 10) { - TextView(text: L10n.Localizable.NewDawnView.first, font: .largeTitle) - TextView(text: L10n.Localizable.NewDawnView.second, font: .title2) + TextView(text: String(localized: .newDawnFirst), font: .largeTitle) + TextView(text: String(localized: .newDawnSecond), font: .title2) } TextView(text: greeting.gainContent ?? "", font: .title3, fontWeight: .bold) } diff --git a/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings b/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings new file mode 100644 index 000000000..52af6cfb8 --- /dev/null +++ b/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings @@ -0,0 +1,498 @@ +{ + "sourceLanguage": "en", + "strings": { + "loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird geladen..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込み中..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로딩 중..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加载中..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "載入中..." + } + } + } + }, + "matches_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Found %lld matches." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Treffer gefunden." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 件の該当項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색 결과 %d개" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "找到 %lld 项结果" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找到 %lld 項結果" + } + } + } + }, + "need_login": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You need to login to access this feature." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du musst dich einloggen, um diese Funktion nutzen zu können." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本機能をご利用になるにはログインが必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 기능을 사용하려면 로그인이 필요해요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你需要登录才能使用该功能" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你需要登入才能完成這個動作" + } + } + } + }, + "new_dawn.first": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "It is the dawn of a new day!" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es ist der Beginn eines neuen Tages!" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しい一日の夜明けです!" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로운 하루가 시작되었어요!" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "又是全新的一天!" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "現在是嶄新的一天!" + } + } + } + }, + "new_dawn.second": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reflecting on your journey so far, you find that you are a little wiser." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Als du auf deine bisherige Reise zurückblickst wirst du ein kleines bisschen weiser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今までの歩みを振り返り、少し賢くなった気がする。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금까지의 여정을 돌이켜보면, 당신은 조금 더 현명해진 것 같죠?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "回顾至今的历程,发觉自己更睿智了一些。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "回顧到目前為止的旅程,你發現自己睿智了一點。" + } + } + } + }, + "show_all": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show all" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모두 보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示全部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示全部" + } + } + } + }, + "toast.communicating": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Communicating..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbinde..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通信中..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "접속 중..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通信中..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連線中..." + } + } + } + }, + "toast.copied_to_clipboard": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copied to clipboard" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Zwischenablage kopiert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリップボードにコピーしました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클립보드에 복사되었어요" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已复制到剪切板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已複製到剪貼簿" + } + } + } + }, + "toast.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "錯誤" + } + } + } + }, + "toast.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird geladen..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込み中..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로딩 중..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加载中..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "載入中..." + } + } + } + }, + "toast.saved_to_photo_library": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved to photo library" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In der Fotomediathek gesichert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライブラリに保存しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 저장" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已保存到图库" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已儲存到照片" + } + } + } + }, + "toast.success": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Success" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erfolg" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "成功" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "성공" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "成功" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "成功" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/AppComponents/SubSection.swift b/AppPackage/Sources/AppComponents/SubSection.swift index 34ee497a1..29cae5437 100644 --- a/AppPackage/Sources/AppComponents/SubSection.swift +++ b/AppPackage/Sources/AppComponents/SubSection.swift @@ -45,7 +45,7 @@ public struct SubSection: View { .foregroundColor(.primary) Spacer() Button(action: showAllAction) { - Text(L10n.Localizable.SubSection.showAll).font(.subheadline) + Text(String(localized: .showAll)).font(.subheadline) } .tint(tint).opacity(showAll ? 1 : 0) } diff --git a/AppPackage/Sources/AppComponents/TagSuggestionView.swift b/AppPackage/Sources/AppComponents/TagSuggestionView.swift index f21753347..74a348796 100644 --- a/AppPackage/Sources/AppComponents/TagSuggestionView.swift +++ b/AppPackage/Sources/AppComponents/TagSuggestionView.swift @@ -25,7 +25,7 @@ public struct TagSuggestionView: View { public var body: some View { if isEnabled { if DeviceUtil.isPhone { - Text(L10n.Localizable.Searchable.matchesCount(translationHandler.suggestions.count)) + Text(String(localized: .matchesCount(translationHandler.suggestions.count))) .foregroundColor(.secondary) .font(.subheadline) } diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 8aa4c29a8..0b46588f3 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -13,12 +13,6 @@ "invalid_resolution" = "Die gewünschte Galerie kann nicht in der gewählten Auflösung heruntergeladen werden"; // MARK: Toast -"toast.error" = "Fehler"; -"toast.success" = "Erfolg"; -"toast.loading" = "Wird geladen..."; -"toast.communicating" = "Verbinde..."; -"toast.copied_to_clipboard" = "In Zwischenablage kopiert"; -"toast.saved_to_photo_library" = "In der Fotomediathek gesichert"; // MARK: AutoLock @@ -56,8 +50,6 @@ "jump_page_view.jump_page" = "Zu Seite springen"; // MARK: AlertView -"loading_view.loading" = "Wird geladen..."; -"not_login_view.need_login" = "Du musst dich einloggen, um diese Funktion nutzen zu können."; "not_login_viewlogin" = "Einloggen"; "error_view.retry" = "Erneut versuchen"; "error_view.try_later" = "Bitte versuche es später erneut."; @@ -101,11 +93,8 @@ "confirmation_dialog.reset" = "Zurücksetzen"; // MARK: SubSection -"sub_section.show_all" = "Alle anzeigen"; // MARK: NewDawnView -"new_dawn_view.first" = "Es ist der Beginn eines neuen Tages!"; -"new_dawn_view.second" = "Als du auf deine bisherige Reise zurückblickst wirst du ein kleines bisschen weiser."; // Greeting "greeting.start" = "Du erhälst "; "greeting.separator" = ", "; @@ -136,7 +125,6 @@ "search_view.search" = "Suche"; "search_view.quick_search" = "Schnellsuche"; // Searchable -"searchable.matches_count" = "%d Treffer gefunden."; // MARK: QuickSearchView "quick_search_view.quick_search" = "Schnellsuche"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 8123dc90e..694484f67 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -13,12 +13,6 @@ "invalid_resolution" = "The requested gallery cannot be downloaded with the selected resolution."; // MARK: Toast -"toast.error" = "Error"; -"toast.success" = "Success"; -"toast.loading" = "Loading..."; -"toast.communicating" = "Communicating..."; -"toast.copied_to_clipboard" = "Copied to clipboard"; -"toast.saved_to_photo_library" = "Saved to photo library"; // MARK: AutoLock @@ -56,8 +50,6 @@ "jump_page_view.jump_page" = "Jump page"; // MARK: AlertView -"loading_view.loading" = "Loading..."; -"not_login_view.need_login" = "You need to login to access this feature."; "not_login_viewlogin" = "Login"; "error_view.retry" = "Retry"; "error_view.try_later" = "Please try again later."; @@ -101,11 +93,8 @@ "confirmation_dialog.reset" = "Reset"; // MARK: SubSection -"sub_section.show_all" = "Show all"; // MARK: NewDawnView -"new_dawn_view.first" = "It is the dawn of a new day!"; -"new_dawn_view.second" = "Reflecting on your journey so far, you find that you are a little wiser."; // Greeting "greeting.start" = "You gain "; "greeting.separator" = ", "; @@ -136,7 +125,6 @@ "search_view.search" = "Search"; "search_view.quick_search" = "Quick search"; // Searchable -"searchable.matches_count" = "Found %d matches."; // MARK: QuickSearchView "quick_search_view.quick_search" = "Quick search"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index f1684e521..28abf1e3d 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -13,12 +13,6 @@ "invalid_resolution" = "このギャラリーは選択された解像度ではダウンロードできません"; // MARK: Toast -"toast.error" = "エラー"; -"toast.success" = "成功"; -"toast.loading" = "読み込み中..."; -"toast.communicating" = "通信中..."; -"toast.copied_to_clipboard" = "クリップボードにコピーしました"; -"toast.saved_to_photo_library" = "ライブラリに保存しました"; // MARK: AutoLock @@ -56,8 +50,6 @@ "jump_page_view.jump_page" = "ページジャンプ"; // MARK: AlertView -"loading_view.loading" = "読み込み中..."; -"not_login_view.need_login" = "本機能をご利用になるにはログインが必要です"; "not_login_viewlogin" = "ログイン"; "error_view.retry" = "リトライ"; "error_view.try_later" = "しばらくしてからもう一度お試しください"; @@ -101,11 +93,8 @@ "confirmation_dialog.reset" = "戻す"; // MARK: SubSection -"sub_section.show_all" = "すべて表示"; // MARK: NewDawnView -"new_dawn_view.first" = "新しい一日の夜明けです!"; -"new_dawn_view.second" = "今までの歩みを振り返り、少し賢くなった気がする。"; // Greeting "greeting.start" = ""; "greeting.separator" = "、"; @@ -136,7 +125,6 @@ "search_view.search" = "検索"; "search_view.quick_search" = "クイック検索"; // Searchable -"searchable.matches_count" = "%d 件の該当項目"; // MARK: QuickSearchView "quick_search_view.quick_search" = "クイック検索"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index d682f8f58..b1a28d2f0 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -13,12 +13,6 @@ "invalid_resolution" = "이 콘텐츠는 선택한 해상도로 다운로드할 수 없어요."; // MARK: Toast -"toast.error" = "실패"; -"toast.success" = "성공"; -"toast.loading" = "로딩 중..."; -"toast.communicating" = "접속 중..."; -"toast.copied_to_clipboard" = "클립보드에 복사되었어요"; -"toast.saved_to_photo_library" = "이미지 저장"; // MARK: AutoLock @@ -56,8 +50,6 @@ "jump_page_view.jump_page" = "페이지 이동"; // MARK: AlertView -"loading_view.loading" = "로딩 중..."; -"not_login_view.need_login" = "이 기능을 사용하려면 로그인이 필요해요."; "not_login_viewlogin" = "로그인"; "error_view.retry" = "재시도"; "error_view.try_later" = "잠시 후 다시 시도해 주세요."; @@ -101,11 +93,8 @@ "confirmation_dialog.reset" = "초기화"; // MARK: SubSection -"sub_section.show_all" = "모두 보기"; // MARK: NewDawnView -"new_dawn_view.first" = "새로운 하루가 시작되었어요!"; -"new_dawn_view.second" = "지금까지의 여정을 돌이켜보면, 당신은 조금 더 현명해진 것 같죠?"; // Greeting "greeting.start" = ""; "greeting.separator" = ", "; @@ -136,7 +125,6 @@ "search_view.search" = "검색"; "search_view.quick_search" = "빠른 검색"; // Searchable -"searchable.matches_count" = "검색 결과 %d개"; // MARK: QuickSearchView "quick_search_view.quick_search" = "빠른 검색"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 0647d8fd7..ccd2873b5 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -13,12 +13,6 @@ "invalid_resolution" = "该画廊不能以选中的分辨率下载"; // MARK: Toast -"toast.error" = "错误"; -"toast.success" = "成功"; -"toast.loading" = "加载中..."; -"toast.communicating" = "通信中..."; -"toast.copied_to_clipboard" = "已复制到剪切板"; -"toast.saved_to_photo_library" = "已保存到图库"; // MARK: AutoLock @@ -56,8 +50,6 @@ "jump_page_view.jump_page" = "页码跳转"; // MARK: AlertView -"loading_view.loading" = "加载中..."; -"not_login_view.need_login" = "你需要登录才能使用该功能"; "not_login_viewlogin" = "登录"; "error_view.retry" = "重试"; "error_view.try_later" = "请稍后再试"; @@ -101,11 +93,8 @@ "confirmation_dialog.reset" = "重置"; // MARK: SubSection -"sub_section.show_all" = "显示全部"; // MARK: NewDawnView -"new_dawn_view.first" = "又是全新的一天!"; -"new_dawn_view.second" = "回顾至今的历程,发觉自己更睿智了一些。"; // Greeting "greeting.start" = "你获得了 "; "greeting.separator" = "、"; @@ -136,7 +125,6 @@ "search_view.search" = "搜索"; "search_view.quick_search" = "快速搜索"; // Searchable -"searchable.matches_count" = "找到 %d 项结果"; // MARK: QuickSearchView "quick_search_view.quick_search" = "快速搜索"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 3569d8918..c3e5cebb3 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -13,12 +13,6 @@ "invalid_resolution" = "該畫廊不能以目前選擇的解析度下載"; // MARK: Toast -"toast.error" = "錯誤"; -"toast.success" = "成功"; -"toast.loading" = "載入中..."; -"toast.communicating" = "連線中..."; -"toast.copied_to_clipboard" = "已複製到剪貼簿"; -"toast.saved_to_photo_library" = "已儲存到照片"; // MARK: AutoLock @@ -56,8 +50,6 @@ "jump_page_view.jump_page" = "跳到..."; // MARK: AlertView -"loading_view.loading" = "載入中..."; -"not_login_view.need_login" = "你需要登入才能完成這個動作"; "not_login_viewlogin" = "登入"; "error_view.retry" = "重試"; "error_view.try_later" = "請稍後再試"; @@ -101,11 +93,8 @@ "confirmation_dialog.reset" = "重設"; // MARK: SubSection -"sub_section.show_all" = "顯示全部"; // MARK: NewDawnView -"new_dawn_view.first" = "現在是嶄新的一天!"; -"new_dawn_view.second" = "回顧到目前為止的旅程,你發現自己睿智了一點。"; // Greeting "greeting.start" = "你獲得了 "; "greeting.separator" = "、"; @@ -136,7 +125,6 @@ "search_view.search" = "搜尋"; "search_view.quick_search" = "快速搜尋"; // Searchable -"searchable.matches_count" = "找到 %d 項結果"; // MARK: QuickSearchView "quick_search_view.quick_search" = "快速搜尋"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index d25b3b918..70d8aecc8 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -1755,10 +1755,6 @@ public enum L10n { /// Donator only. You will not be able to browse as many pages. Recommended only if having severe problems. public static let modernNoDescription = L10n.tr("Localizable", "load_through_hath_setting.modern_no_description", fallback: "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems.") } - public enum LoadingView { - /// Loading... - public static let loading = L10n.tr("Localizable", "loading_view.loading", fallback: "Loading...") - } public enum LoginView { /// Login public static let login = L10n.tr("Localizable", "login_view.login", fallback: "Login") @@ -1775,16 +1771,6 @@ public enum L10n { /// Align left, scale if overwidth public static let alignLeftScaleIfOverWidth = L10n.tr("Localizable", "multiple_page_viewer_style.align_left_scale_if_over_width", fallback: "Align left, scale if overwidth") } - public enum NewDawnView { - /// It is the dawn of a new day! - public static let first = L10n.tr("Localizable", "new_dawn_view.first", fallback: "It is the dawn of a new day!") - /// Reflecting on your journey so far, you find that you are a little wiser. - public static let second = L10n.tr("Localizable", "new_dawn_view.second", fallback: "Reflecting on your journey so far, you find that you are a little wiser.") - } - public enum NotLoginView { - /// You need to login to access this feature. - public static let needLogin = L10n.tr("Localizable", "not_login_view.need_login", fallback: "You need to login to access this feature.") - } public enum PostCommentView { /// Edit comment public static let editComment = L10n.tr("Localizable", "post_comment_view.edit_comment", fallback: "Edit comment") @@ -1825,12 +1811,6 @@ public enum L10n { /// Search public static let search = L10n.tr("Localizable", "search_view.search", fallback: "Search") } - public enum Searchable { - /// Found %d matches. - public static func matchesCount(_ p1: Int) -> String { - return L10n.tr("Localizable", "searchable.matches_count", p1, fallback: "Found %d matches.") - } - } public enum SettingStateRoute { /// About public static let about = L10n.tr("Localizable", "setting_state_route.about", fallback: "About") @@ -1851,10 +1831,6 @@ public enum L10n { /// Setting public static let setting = L10n.tr("Localizable", "setting_view.setting", fallback: "Setting") } - public enum SubSection { - /// Show all - public static let showAll = L10n.tr("Localizable", "sub_section.show_all", fallback: "Show all") - } public enum TabItem { /// Downloads public static let downloads = L10n.tr("Localizable", "tab_item.downloads", fallback: "Downloads") @@ -1925,20 +1901,6 @@ public enum L10n { /// Small public static let small = L10n.tr("Localizable", "thumbnail_size.small", fallback: "Small") } - public enum Toast { - /// Communicating... - public static let communicating = L10n.tr("Localizable", "toast.communicating", fallback: "Communicating...") - /// Copied to clipboard - public static let copiedToClipboard = L10n.tr("Localizable", "toast.copied_to_clipboard", fallback: "Copied to clipboard") - /// Error - public static let error = L10n.tr("Localizable", "toast.error", fallback: "Error") - /// Loading... - public static let loading = L10n.tr("Localizable", "toast.loading", fallback: "Loading...") - /// Saved to photo library - public static let savedToPhotoLibrary = L10n.tr("Localizable", "toast.saved_to_photo_library", fallback: "Saved to photo library") - /// Success - public static let success = L10n.tr("Localizable", "toast.success", fallback: "Success") - } public enum ToolbarItem { /// Seek to date public static let dateSeek = L10n.tr("Localizable", "toolbar_item.date_seek", fallback: "Seek to date") From 9da75889170f007a74b5c7a95fb203d5cbdd9e8d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 02:04:36 +0800 Subject: [PATCH 464/614] Move FiltersFeature strings to catalog Emit 21 localizable keys into FiltersFeature/Resources/Localizable.xcstrings, delete them from the 6 lproj files, and rewrite call sites to String(localized: .symbol). Strip filter view-name namespaces; keep advanced/reset/search groupings. Shared keys (stars, cancel, reset) stay on L10n via Resources. --- AppPackage/Package.swift | 1 + .../FiltersFeature/FiltersReducer.swift | 4 +- .../Sources/FiltersFeature/FiltersView.swift | 38 +- .../Resources/Localizable.xcstrings | 867 ++++++++++++++++++ .../Resources/de.lproj/Localizable.strings | 21 - .../Resources/en.lproj/Localizable.strings | 21 - .../Resources/ja.lproj/Localizable.strings | 21 - .../Resources/ko.lproj/Localizable.strings | 21 - .../zh-Hans.lproj/Localizable.strings | 21 - .../zh-Hant.lproj/Localizable.strings | 21 - AppPackage/Sources/Resources/Strings.swift | 42 - 11 files changed, 889 insertions(+), 189 deletions(-) create mode 100644 AppPackage/Sources/FiltersFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 92ee2c9bf..68421c12e 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -585,6 +585,7 @@ let targets: [PackageDescription.Target] = [ .module(.resources), .targetDependency(.composableArchitecture) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift index 97c7d305b..58bdfb61c 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift @@ -68,13 +68,13 @@ public struct FiltersReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmReset) { - TextState(L10n.Localizable.ConfirmationDialog.reset) + TextState(String(localized: .reset)) } ButtonState(role: .cancel) { TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.resetDescription) + TextState(String(localized: .resetDescription)) } return .none diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index 2592e4864..21124af31 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -79,10 +79,10 @@ private struct BasicSection: View { .pickerStyle(.segmented) CategoryView(bindings: categoryBindings) Button(action: resetFiltersDialogAction) { - Text(L10n.Localizable.FiltersView.resetFilters).foregroundStyle(.red) + Text(String(localized: .resetFilters)).foregroundStyle(.red) } .confirmationDialog(confirmationDialog) - Toggle(L10n.Localizable.FiltersView.advancedSettings, isOn: $filter.advanced) + Toggle(String(localized: .advancedSettings), isOn: $filter.advanced) } } } @@ -105,24 +105,24 @@ private struct AdvancedSection: View { var body: some View { Group { - Section(L10n.Localizable.FiltersView.advanced) { - Toggle(L10n.Localizable.FiltersView.searchGalleryName, isOn: $filter.galleryName) - Toggle(L10n.Localizable.FiltersView.searchGalleryTags, isOn: $filter.galleryTags) - Toggle(L10n.Localizable.FiltersView.searchGalleryDescription, isOn: $filter.galleryDesc) - Toggle(L10n.Localizable.FiltersView.searchTorrentFilenames, isOn: $filter.torrentFilenames) + Section(String(localized: .advanced)) { + Toggle(String(localized: .searchGalleryName), isOn: $filter.galleryName) + Toggle(String(localized: .searchGalleryTags), isOn: $filter.galleryTags) + Toggle(String(localized: .searchGalleryDescription), isOn: $filter.galleryDesc) + Toggle(String(localized: .searchTorrentFilenames), isOn: $filter.torrentFilenames) Toggle( - L10n.Localizable.FiltersView.onlyShowGalleriesWithTorrents, + String(localized: .onlyShowGalleriesWithTorrents), isOn: $filter.onlyWithTorrents ) - Toggle(L10n.Localizable.FiltersView.searchLowPowerTags, isOn: $filter.lowPowerTags) - Toggle(L10n.Localizable.FiltersView.searchDownvotedTags, isOn: $filter.downvotedTags) - Toggle(L10n.Localizable.FiltersView.searchExpungedGalleries, isOn: $filter.expungedGalleries) + Toggle(String(localized: .searchLowPowerTags), isOn: $filter.lowPowerTags) + Toggle(String(localized: .searchDownvotedTags), isOn: $filter.downvotedTags) + Toggle(String(localized: .searchExpungedGalleries), isOn: $filter.expungedGalleries) } Section { - Toggle(L10n.Localizable.FiltersView.setMinimumRating, isOn: $filter.minRatingActivated) + Toggle(String(localized: .setMinimumRating), isOn: $filter.minRatingActivated) MinimumRatingSetter(minimum: $filter.minRating) .disabled(!filter.minRatingActivated) - Toggle(L10n.Localizable.FiltersView.setPagesRange, isOn: $filter.pageRangeActivated) + Toggle(String(localized: .setPagesRange), isOn: $filter.pageRangeActivated) .disabled(focusedBound.wrappedValue != nil) PagesRangeSetter( lowerBound: $filter.pageLowerBound, @@ -132,10 +132,10 @@ private struct AdvancedSection: View { ) .disabled(!filter.pageRangeActivated) } - Section(L10n.Localizable.FiltersView.defaultFilter) { - Toggle(L10n.Localizable.FiltersView.disableLanguageFilter, isOn: $filter.disableLanguage) - Toggle(L10n.Localizable.FiltersView.disableUploaderFilter, isOn: $filter.disableUploader) - Toggle(L10n.Localizable.FiltersView.disableTagsFilter, isOn: $filter.disableTags) + Section(String(localized: .defaultFilter)) { + Toggle(String(localized: .disableLanguageFilter), isOn: $filter.disableLanguage) + Toggle(String(localized: .disableUploaderFilter), isOn: $filter.disableUploader) + Toggle(String(localized: .disableTagsFilter), isOn: $filter.disableTags) } } .disabled(!filter.advanced) @@ -151,7 +151,7 @@ private struct MinimumRatingSetter: View { } var body: some View { - Picker(L10n.Localizable.FiltersView.minimumRating, selection: $minimum) { + Picker(String(localized: .minimumRating), selection: $minimum) { ForEach(Array(2...5), id: \.self) { number in Text(L10n.Localizable.Common.stars("\(number)")).tag(number) } @@ -181,7 +181,7 @@ private struct PagesRangeSetter: View { var body: some View { HStack { - Text(L10n.Localizable.FiltersView.pagesRange) + Text(String(localized: .pagesRange)) Spacer() SettingTextField(text: $lowerBound) .focused(focusedBound, equals: .lower) diff --git a/AppPackage/Sources/FiltersFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/FiltersFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..22ab0f6dc --- /dev/null +++ b/AppPackage/Sources/FiltersFeature/Resources/Localizable.xcstrings @@ -0,0 +1,867 @@ +{ + "sourceLanguage": "en", + "strings": { + "advanced": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Advanced" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erweitert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "高度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "고급" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高级" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "進階" + } + } + } + }, + "advanced_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Advanced settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erweiterte Einstellungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "高度な設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "고급 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高级选项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "進階選項" + } + } + } + }, + "default_filter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default filter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardfilter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "既定フィルター" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 옵션" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設篩選" + } + } + } + }, + "disable_language_filter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable language filter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gefilterte Sprachen miteinbeziehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "言語フィルターを無効化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "언어 필터 끄기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用语言筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用語言篩選" + } + } + } + }, + "disable_tags_filter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable tags filter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gefilterte Tags miteinbeziehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグフィルターを無効化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 필터 끄기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用标签筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用標籤篩選" + } + } + } + }, + "disable_uploader_filter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable uploader filter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gefilterte Uploader miteinbeziehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップローダフィルターを無効化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업로더 필터 끄기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用上传者筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用上傳者篩選" + } + } + } + }, + "minimum_rating": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimum rating" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Minimale Bewertung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "評価の下限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최소 별점" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "评分下限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "評分下限" + } + } + } + }, + "only_show_galleries_with_torrents": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only show galleries with torrents" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Galerien mit Torrents zeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トレントを含むもののみを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토렌트 있는 갤러리만 보이기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "只显示带有种子的画廊" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只顯示有種子的畫廊" + } + } + } + }, + "pages_range": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pages range" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenzahl-Bereich" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ数範囲" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 범위" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页数范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "頁數範圍" + } + } + } + }, + "reset": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurücksetzen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "戻す" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "초기화" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重設" + } + } + } + }, + "reset_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Are you sure to reset?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bist du sicher?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本当に戻してもよろしいですか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "초기화하시겠어요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定要重置吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確定要重設嗎?" + } + } + } + }, + "reset_filters": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset filters" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Filter zurücksetzen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "既定値に戻す" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 필터 초기화" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置所有选项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重設所有選項" + } + } + } + }, + "search_downvoted_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search downvoted tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Negativ bewertete Tags miteinbeziehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "低評価タグを検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "낮은 평가의 태그를 찾아보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索低评价标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋低評價標籤" + } + } + } + }, + "search_expunged_galleries": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search expunged galleries" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gelöschte Galerien zeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除済みのギャラリーを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제된 갤러리를 보여주기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示已被删除的画廊" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示被刪除的畫廊" + } + } + } + }, + "search_gallery_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search gallery description" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galeriebeschreibung durchsuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー説明を検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 설명을 찾아보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索画廊描述" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋畫廊描述" + } + } + } + }, + "search_gallery_name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search gallery name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerienamen durchsuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー名を検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 이름을 찾아보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索画廊名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋畫廊名稱" + } + } + } + }, + "search_gallery_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search gallery tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerietags durchsuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリータグを検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 태그를 찾아보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索画廊标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋畫廊標籤" + } + } + } + }, + "search_low_power_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search Low-Power tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Low-Power Tags miteinbeziehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "低希望タグを検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인기가 없는 태그를 찾아보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索低期望标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋低期望標籤" + } + } + } + }, + "search_torrent_filenames": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search torrent filenames" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Torrents durchsuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トレントファイル名を検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토렌트 파일 이름을 찾아보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索种子文件名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋種子檔案名" + } + } + } + }, + "set_minimum_rating": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set minimum rating" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Minimaleste Bewertung festlegen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "評価の下限を指定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최소 별점 설정하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置评分下限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定評分下限" + } + } + } + }, + "set_pages_range": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set pages range" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenzahl-Bereich festlegen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ数範囲を指定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 범위 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置页数范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定頁數範圍" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 0b46588f3..329020297 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -85,12 +85,10 @@ "confirmation_dialog.logout_description" = "Bist du sicher das du dich ausloggen möchtest?"; "confirmation_dialog.delete_description" = "Möchtest du dieses Element wirklich löschen?"; "confirmation_dialog.clear_description" = "Bist du sicher das du das löschen möchtest?"; -"confirmation_dialog.reset_description" = "Bist du sicher?"; "confirmation_dialog.remove" = "Entfernen"; "confirmation_dialog.logout" = "Ausloggen"; "confirmation_dialog.delete" = "Löschen"; "confirmation_dialog.clear" = "Löschen"; -"confirmation_dialog.reset" = "Zurücksetzen"; // MARK: SubSection @@ -404,25 +402,6 @@ // MARK: FiltersView "filters_view.filters" = "Filter"; -"filters_view.advanced_settings" = "Erweiterte Einstellungen"; -"filters_view.search_gallery_name" = "Galerienamen durchsuchen"; -"filters_view.search_gallery_tags" = "Galerietags durchsuchen"; -"filters_view.search_gallery_description" = "Galeriebeschreibung durchsuchen"; -"filters_view.search_torrent_filenames" = "Torrents durchsuchen"; -"filters_view.only_show_galleries_with_torrents" = "Nur Galerien mit Torrents zeigen"; -"filters_view.search_low_power_tags" = "Low-Power Tags miteinbeziehen"; -"filters_view.search_downvoted_tags" = "Negativ bewertete Tags miteinbeziehen"; -"filters_view.search_expunged_galleries" = "Gelöschte Galerien zeigen"; -"filters_view.set_minimum_rating" = "Minimaleste Bewertung festlegen"; -"filters_view.minimum_rating" = "Minimale Bewertung"; -"filters_view.set_pages_range" = "Seitenzahl-Bereich festlegen"; -"filters_view.pages_range" = "Seitenzahl-Bereich"; -"filters_view.disable_language_filter" = "Gefilterte Sprachen miteinbeziehen"; -"filters_view.disable_uploader_filter" = "Gefilterte Uploader miteinbeziehen"; -"filters_view.disable_tags_filter" = "Gefilterte Tags miteinbeziehen"; -"filters_view.reset_filters" = "Filter zurücksetzen"; -"filters_view.advanced" = "Erweitert"; -"filters_view.default_filter" = "Standardfilter"; // FilterRange "filter_range.search" = "Suche"; "filter_range.global" = "Global"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 694484f67..b7add74da 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -85,12 +85,10 @@ "confirmation_dialog.logout_description" = "Are you sure to logout?"; "confirmation_dialog.delete_description" = "Are you sure to delete this item?"; "confirmation_dialog.clear_description" = "Are you sure to clear?"; -"confirmation_dialog.reset_description" = "Are you sure to reset?"; "confirmation_dialog.remove" = "Remove"; "confirmation_dialog.logout" = "Logout"; "confirmation_dialog.delete" = "Delete"; "confirmation_dialog.clear" = "Clear"; -"confirmation_dialog.reset" = "Reset"; // MARK: SubSection @@ -404,25 +402,6 @@ // MARK: FiltersView "filters_view.filters" = "Filters"; -"filters_view.advanced_settings" = "Advanced settings"; -"filters_view.search_gallery_name" = "Search gallery name"; -"filters_view.search_gallery_tags" = "Search gallery tags"; -"filters_view.search_gallery_description" = "Search gallery description"; -"filters_view.search_torrent_filenames" = "Search torrent filenames"; -"filters_view.only_show_galleries_with_torrents" = "Only show galleries with torrents"; -"filters_view.search_low_power_tags" = "Search Low-Power tags"; -"filters_view.search_downvoted_tags" = "Search downvoted tags"; -"filters_view.search_expunged_galleries" = "Search expunged galleries"; -"filters_view.set_minimum_rating" = "Set minimum rating"; -"filters_view.minimum_rating" = "Minimum rating"; -"filters_view.set_pages_range" = "Set pages range"; -"filters_view.pages_range" = "Pages range"; -"filters_view.disable_language_filter" = "Disable language filter"; -"filters_view.disable_uploader_filter" = "Disable uploader filter"; -"filters_view.disable_tags_filter" = "Disable tags filter"; -"filters_view.reset_filters" = "Reset filters"; -"filters_view.advanced" = "Advanced"; -"filters_view.default_filter" = "Default filter"; // FilterRange "filter_range.search" = "Search"; "filter_range.global" = "Global"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 28abf1e3d..9f7e2a8d3 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -85,12 +85,10 @@ "confirmation_dialog.logout_description" = "本当にログアウトしてもよろしいですか?"; "confirmation_dialog.delete_description" = "本当にこれを削除してもよろしいですか?"; "confirmation_dialog.clear_description" = "本当に削除してもよろしいですか?"; -"confirmation_dialog.reset_description" = "本当に戻してもよろしいですか?"; "confirmation_dialog.remove" = "削除"; "confirmation_dialog.logout" = "ログアウト"; "confirmation_dialog.delete" = "削除"; "confirmation_dialog.clear" = "削除"; -"confirmation_dialog.reset" = "戻す"; // MARK: SubSection @@ -404,25 +402,6 @@ // MARK: FiltersView "filters_view.filters" = "フィルター"; -"filters_view.advanced_settings" = "高度な設定"; -"filters_view.search_gallery_name" = "ギャラリー名を検索"; -"filters_view.search_gallery_tags" = "ギャラリータグを検索"; -"filters_view.search_gallery_description" = "ギャラリー説明を検索"; -"filters_view.search_torrent_filenames" = "トレントファイル名を検索"; -"filters_view.only_show_galleries_with_torrents" = "トレントを含むもののみを表示"; -"filters_view.search_low_power_tags" = "低希望タグを検索"; -"filters_view.search_downvoted_tags" = "低評価タグを検索"; -"filters_view.search_expunged_galleries" = "削除済みのギャラリーを表示"; -"filters_view.set_minimum_rating" = "評価の下限を指定"; -"filters_view.minimum_rating" = "評価の下限"; -"filters_view.set_pages_range" = "ページ数範囲を指定"; -"filters_view.pages_range" = "ページ数範囲"; -"filters_view.disable_language_filter" = "言語フィルターを無効化"; -"filters_view.disable_uploader_filter" = "アップローダフィルターを無効化"; -"filters_view.disable_tags_filter" = "タグフィルターを無効化"; -"filters_view.reset_filters" = "既定値に戻す"; -"filters_view.advanced" = "高度"; -"filters_view.default_filter" = "既定フィルター"; // FilterRange "filter_range.search" = "検索"; "filter_range.global" = "全般"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index b1a28d2f0..a24a698ee 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -85,12 +85,10 @@ "confirmation_dialog.logout_description" = "로그아웃 하시겠어요?"; "confirmation_dialog.delete_description" = "이 항목을 삭제하시겠어요?"; "confirmation_dialog.clear_description" = "삭제하시겠어요?"; -"confirmation_dialog.reset_description" = "초기화하시겠어요?"; "confirmation_dialog.remove" = "삭제"; "confirmation_dialog.logout" = "로그아웃"; "confirmation_dialog.delete" = "삭제"; "confirmation_dialog.clear" = "삭제"; -"confirmation_dialog.reset" = "초기화"; // MARK: SubSection @@ -404,25 +402,6 @@ // MARK: FiltersView "filters_view.filters" = "필터"; -"filters_view.advanced_settings" = "고급 설정"; -"filters_view.search_gallery_name" = "갤러리 이름을 찾아보기"; -"filters_view.search_gallery_tags" = "갤러리 태그를 찾아보기"; -"filters_view.search_gallery_description" = "갤러리 설명을 찾아보기"; -"filters_view.search_torrent_filenames" = "토렌트 파일 이름을 찾아보기"; -"filters_view.only_show_galleries_with_torrents" = "토렌트 있는 갤러리만 보이기"; -"filters_view.search_low_power_tags" = "인기가 없는 태그를 찾아보기"; -"filters_view.search_downvoted_tags" = "낮은 평가의 태그를 찾아보기"; -"filters_view.search_expunged_galleries" = "삭제된 갤러리를 보여주기"; -"filters_view.set_minimum_rating" = "최소 별점 설정하기"; -"filters_view.minimum_rating" = "최소 별점"; -"filters_view.set_pages_range" = "페이지 범위 설정"; -"filters_view.pages_range" = "페이지 범위"; -"filters_view.disable_language_filter" = "언어 필터 끄기"; -"filters_view.disable_uploader_filter" = "업로더 필터 끄기"; -"filters_view.disable_tags_filter" = "태그 필터 끄기"; -"filters_view.reset_filters" = "모든 필터 초기화"; -"filters_view.advanced" = "고급"; -"filters_view.default_filter" = "기본 옵션"; // FilterRange "filter_range.search" = "검색"; "filter_range.global" = "전체"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index ccd2873b5..fa0ff3447 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -85,12 +85,10 @@ "confirmation_dialog.logout_description" = "确定要退出登录吗?"; "confirmation_dialog.delete_description" = "确定要删除吗?"; "confirmation_dialog.clear_description" = "确定要清空吗?"; -"confirmation_dialog.reset_description" = "确定要重置吗?"; "confirmation_dialog.remove" = "移除"; "confirmation_dialog.logout" = "退出登录"; "confirmation_dialog.delete" = "删除"; "confirmation_dialog.clear" = "清空"; -"confirmation_dialog.reset" = "重置"; // MARK: SubSection @@ -404,25 +402,6 @@ // MARK: FiltersView "filters_view.filters" = "筛选"; -"filters_view.advanced_settings" = "高级选项"; -"filters_view.search_gallery_name" = "搜索画廊名称"; -"filters_view.search_gallery_tags" = "搜索画廊标签"; -"filters_view.search_gallery_description" = "搜索画廊描述"; -"filters_view.search_torrent_filenames" = "搜索种子文件名"; -"filters_view.only_show_galleries_with_torrents" = "只显示带有种子的画廊"; -"filters_view.search_low_power_tags" = "搜索低期望标签"; -"filters_view.search_downvoted_tags" = "搜索低评价标签"; -"filters_view.search_expunged_galleries" = "显示已被删除的画廊"; -"filters_view.set_minimum_rating" = "设置评分下限"; -"filters_view.minimum_rating" = "评分下限"; -"filters_view.set_pages_range" = "设置页数范围"; -"filters_view.pages_range" = "页数范围"; -"filters_view.disable_language_filter" = "禁用语言筛选"; -"filters_view.disable_uploader_filter" = "禁用上传者筛选"; -"filters_view.disable_tags_filter" = "禁用标签筛选"; -"filters_view.reset_filters" = "重置所有选项"; -"filters_view.advanced" = "高级"; -"filters_view.default_filter" = "默认筛选"; // FilterRange "filter_range.search" = "搜索"; "filter_range.global" = "全局"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index c3e5cebb3..8ab070481 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -85,12 +85,10 @@ "confirmation_dialog.logout_description" = "確定要登出嗎?"; "confirmation_dialog.delete_description" = "確定要刪除?"; "confirmation_dialog.clear_description" = "確定要清空嗎?"; -"confirmation_dialog.reset_description" = "確定要重設嗎?"; "confirmation_dialog.remove" = "移除"; "confirmation_dialog.logout" = "登出"; "confirmation_dialog.delete" = "刪除"; "confirmation_dialog.clear" = "清空"; -"confirmation_dialog.reset" = "重設"; // MARK: SubSection @@ -404,25 +402,6 @@ // MARK: FiltersView "filters_view.filters" = "過濾"; -"filters_view.advanced_settings" = "進階選項"; -"filters_view.search_gallery_name" = "搜尋畫廊名稱"; -"filters_view.search_gallery_tags" = "搜尋畫廊標籤"; -"filters_view.search_gallery_description" = "搜尋畫廊描述"; -"filters_view.search_torrent_filenames" = "搜尋種子檔案名"; -"filters_view.only_show_galleries_with_torrents" = "只顯示有種子的畫廊"; -"filters_view.search_low_power_tags" = "搜尋低期望標籤"; -"filters_view.search_downvoted_tags" = "搜尋低評價標籤"; -"filters_view.search_expunged_galleries" = "顯示被刪除的畫廊"; -"filters_view.set_minimum_rating" = "設定評分下限"; -"filters_view.minimum_rating" = "評分下限"; -"filters_view.set_pages_range" = "設定頁數範圍"; -"filters_view.pages_range" = "頁數範圍"; -"filters_view.disable_language_filter" = "停用語言篩選"; -"filters_view.disable_uploader_filter" = "停用上傳者篩選"; -"filters_view.disable_tags_filter" = "停用標籤篩選"; -"filters_view.reset_filters" = "重設所有選項"; -"filters_view.advanced" = "進階"; -"filters_view.default_filter" = "預設篩選"; // FilterRange "filter_range.search" = "搜尋"; "filter_range.global" = "全域"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 70d8aecc8..bde738473 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -932,10 +932,6 @@ public enum L10n { public static let remove = L10n.tr("Localizable", "confirmation_dialog.remove", fallback: "Remove") /// Are you sure to remove your custom translations? public static let removeCustomTranslations = L10n.tr("Localizable", "confirmation_dialog.remove_custom_translations", fallback: "Are you sure to remove your custom translations?") - /// Reset - public static let reset = L10n.tr("Localizable", "confirmation_dialog.reset", fallback: "Reset") - /// Are you sure to reset? - public static let resetDescription = L10n.tr("Localizable", "confirmation_dialog.reset_description", fallback: "Are you sure to reset?") } public enum DateSeekView { /// Seek to date @@ -1396,46 +1392,8 @@ public enum L10n { public static let watched = L10n.tr("Localizable", "filter_range.watched", fallback: "Watched") } public enum FiltersView { - /// Advanced - public static let advanced = L10n.tr("Localizable", "filters_view.advanced", fallback: "Advanced") - /// Advanced settings - public static let advancedSettings = L10n.tr("Localizable", "filters_view.advanced_settings", fallback: "Advanced settings") - /// Default filter - public static let defaultFilter = L10n.tr("Localizable", "filters_view.default_filter", fallback: "Default filter") - /// Disable language filter - public static let disableLanguageFilter = L10n.tr("Localizable", "filters_view.disable_language_filter", fallback: "Disable language filter") - /// Disable tags filter - public static let disableTagsFilter = L10n.tr("Localizable", "filters_view.disable_tags_filter", fallback: "Disable tags filter") - /// Disable uploader filter - public static let disableUploaderFilter = L10n.tr("Localizable", "filters_view.disable_uploader_filter", fallback: "Disable uploader filter") /// Filters public static let filters = L10n.tr("Localizable", "filters_view.filters", fallback: "Filters") - /// Minimum rating - public static let minimumRating = L10n.tr("Localizable", "filters_view.minimum_rating", fallback: "Minimum rating") - /// Only show galleries with torrents - public static let onlyShowGalleriesWithTorrents = L10n.tr("Localizable", "filters_view.only_show_galleries_with_torrents", fallback: "Only show galleries with torrents") - /// Pages range - public static let pagesRange = L10n.tr("Localizable", "filters_view.pages_range", fallback: "Pages range") - /// Reset filters - public static let resetFilters = L10n.tr("Localizable", "filters_view.reset_filters", fallback: "Reset filters") - /// Search downvoted tags - public static let searchDownvotedTags = L10n.tr("Localizable", "filters_view.search_downvoted_tags", fallback: "Search downvoted tags") - /// Search expunged galleries - public static let searchExpungedGalleries = L10n.tr("Localizable", "filters_view.search_expunged_galleries", fallback: "Search expunged galleries") - /// Search gallery description - public static let searchGalleryDescription = L10n.tr("Localizable", "filters_view.search_gallery_description", fallback: "Search gallery description") - /// Search gallery name - public static let searchGalleryName = L10n.tr("Localizable", "filters_view.search_gallery_name", fallback: "Search gallery name") - /// Search gallery tags - public static let searchGalleryTags = L10n.tr("Localizable", "filters_view.search_gallery_tags", fallback: "Search gallery tags") - /// Search Low-Power tags - public static let searchLowPowerTags = L10n.tr("Localizable", "filters_view.search_low_power_tags", fallback: "Search Low-Power tags") - /// Search torrent filenames - public static let searchTorrentFilenames = L10n.tr("Localizable", "filters_view.search_torrent_filenames", fallback: "Search torrent filenames") - /// Set minimum rating - public static let setMinimumRating = L10n.tr("Localizable", "filters_view.set_minimum_rating", fallback: "Set minimum rating") - /// Set pages range - public static let setPagesRange = L10n.tr("Localizable", "filters_view.set_pages_range", fallback: "Set pages range") } public enum FolderManagerView { /// This will delete the folder and all downloaded galleries inside it. From 79185b168c369e2cbd28047c5752ae1187074d45 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 02:07:08 +0800 Subject: [PATCH 465/614] Move DownloadsFeature strings to catalog Emit 21 localizable keys into DownloadsFeature/Resources/Localizable.xcstrings, delete them from the 6 lproj files, and rewrite 28 accessors across 5 files to String(localized: .symbol). Strip downloads/inspector view-name namespaces; keep status/action groupings. download_inspector_view.none -> .none resolves to the generated symbol (non-optional param disambiguates). Shared keys (downloads, delete_download, detail, cancel, delete) stay on L10n via Resources. --- AppPackage/Package.swift | 1 + .../DownloadInspectorReducer.swift | 4 +- .../DownloadsFeature/DownloadsReducer.swift | 4 +- .../DownloadsView+Subviews.swift | 22 +- .../DownloadsFeature/DownloadsView.swift | 24 +- .../Resources/Localizable.xcstrings | 867 ++++++++++++++++++ .../Resources/de.lproj/Localizable.strings | 21 - .../Resources/en.lproj/Localizable.strings | 21 - .../Resources/ja.lproj/Localizable.strings | 21 - .../Resources/ko.lproj/Localizable.strings | 21 - .../zh-Hans.lproj/Localizable.strings | 21 - .../zh-Hant.lproj/Localizable.strings | 21 - AppPackage/Sources/Resources/Strings.swift | 44 - .../DownloadInspectorLoadTests.swift | 2 +- 14 files changed, 896 insertions(+), 198 deletions(-) create mode 100644 AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 68421c12e..040b0f224 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -645,6 +645,7 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.composableArchitecture), .targetDependency(.sfSafeSymbols) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift index d09c059e7..1c39a9733 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift @@ -218,7 +218,7 @@ private extension Optional where Wrapped == DownloadValidationState { switch self { case .some(.valid): return .success( - caption: L10n.Localizable.DownloadInspectorView.imageDataValid + caption: String(localized: .imageDataValid) ) case .some(.missingFiles(let message)): @@ -226,7 +226,7 @@ private extension Optional where Wrapped == DownloadValidationState { case nil: return .error( - caption: L10n.Localizable.DownloadInspectorView.imageDataUnavailable + caption: String(localized: .imageDataUnavailable) ) } } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 1c7c1bcc2..8da768d41 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -151,7 +151,7 @@ public struct DownloadsReducer: Sendable { } message: { TextState( download.canTogglePause - ? L10n.Localizable.DownloadsView.deleteActiveDownload + ? String(localized: .deleteActiveDownload) : L10n.Localizable.DownloadsView.deleteDownloadedGallery ) } @@ -160,7 +160,7 @@ public struct DownloadsReducer: Sendable { case .moveButtonTapped(let download): let destinations = state.folders.filter { $0 != download.folderName } state.confirmationDialog = ConfirmationDialogState { - TextState(L10n.Localizable.DownloadsView.moveToFolder) + TextState(String(localized: .moveToFolder)) } actions: { for folder in destinations { ButtonState(action: .move(download.gid, folder)) { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index 3779d8196..1464ad0ee 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -72,7 +72,7 @@ struct DownloadInspectorView: View { let isRetryFailedPagesDisabled = !inspection.canRetryFailedPages let isValidateImageDataDisabled = !inspection.canValidateImageData || store.isValidatingImageData - Section(L10n.Localizable.DownloadInspectorView.actions) { + Section(String(localized: .actions)) { Button { store.send(.toggleDownloadPause) } label: { @@ -88,7 +88,7 @@ struct DownloadInspectorView: View { store.send(.retryPages(inspection.failedPageIndices)) } label: { Label( - L10n.Localizable.DownloadInspectorView.retryFailedPages, + String(localized: .retryFailedPages), systemSymbol: .arrowClockwise ) .disabledActionForegroundStyle(isRetryFailedPagesDisabled) @@ -113,7 +113,7 @@ struct DownloadInspectorView: View { } .autoBlur(radius: blurRadius) .toast($store.scope(state: \.toast, action: \.toast)) - .navigationTitle(L10n.Localizable.DownloadInspectorView.downloadStatus) + .navigationTitle(String(localized: .downloadStatus)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -133,8 +133,8 @@ private struct DownloadInspectorValidationActionLabel: View { private var title: String { isValidating - ? L10n.Localizable.DownloadInspectorView.validatingImageData - : L10n.Localizable.DownloadsView.validateImageData + ? String(localized: .validatingImageData) + : String(localized: .validateImageData) } private var progressAnimation: Animation? { @@ -174,7 +174,7 @@ struct DownloadInspectorPageGroupRow: View { private var pageNumbersText: String { let indices = pages.map(\.index).sorted() guard !indices.isEmpty else { - return L10n.Localizable.DownloadInspectorView.none + return String(localized: .none) } return Self.formattedPageRanges(indices) } @@ -244,11 +244,11 @@ private extension DownloadPageStatus { var title: String { switch self { case .pending: - return L10n.Localizable.DownloadInspectorView.pending + return String(localized: .pending) case .downloaded: - return L10n.Localizable.DownloadInspectorView.downloaded + return String(localized: .downloaded) case .failed: - return L10n.Localizable.DownloadInspectorView.failed + return String(localized: .failed) } } @@ -276,8 +276,8 @@ private extension DownloadPageStatus { private extension DownloadedGallery { var inspectorPauseResumeTitle: String { displayStatus == .inactive - ? L10n.Localizable.DownloadsView.resume - : L10n.Localizable.DownloadsView.pause + ? String(localized: .resume) + : String(localized: .pause) } var inspectorPauseResumeSymbol: SFSymbol { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index 5fdd69682..2e360c7c4 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -64,7 +64,7 @@ public struct DownloadsView: View { .searchable( text: $store.keyword, placement: .navigationBarDrawer(displayMode: .automatic), - prompt: L10n.Localizable.DownloadsView.searchDownloads + prompt: String(localized: .searchDownloads) ) .sheet( item: $store.scope(state: \.destination?.inspector, action: \.destination.inspector) @@ -140,7 +140,7 @@ private extension DownloadsView { store.send(.inspectorButtonTapped(download.gid)) } label: { Label( - L10n.Localizable.DownloadsView.pages, + String(localized: .inspectPages), systemSymbol: .listBulletRectanglePortrait ) } @@ -151,7 +151,7 @@ private extension DownloadsView { store.send(.moveButtonTapped(download)) } label: { Label( - L10n.Localizable.DownloadsView.move, + String(localized: .move), systemSymbol: .folder ) } @@ -177,8 +177,8 @@ private extension DownloadsView { } label: { Label( download.displayStatus == .inactive - ? L10n.Localizable.DownloadsView.resume - : L10n.Localizable.DownloadsView.pause, + ? String(localized: .resume) + : String(localized: .pause), systemSymbol: download.displayStatus == .inactive ? .playFill : .pauseFill @@ -213,7 +213,7 @@ private extension DownloadsView { store.send(.inspectorButtonTapped(download.gid)) } label: { Label( - L10n.Localizable.DownloadsView.pages, + String(localized: .inspectPages), systemSymbol: .listBulletRectanglePortrait ) } @@ -227,7 +227,7 @@ private extension DownloadsView { } } label: { Label( - L10n.Localizable.DownloadsView.moveToFolder, + String(localized: .moveToFolder), systemSymbol: .folder ) } @@ -250,8 +250,8 @@ private extension DownloadsView { } label: { Label( download.displayStatus == .inactive - ? L10n.Localizable.DownloadsView.resume - : L10n.Localizable.DownloadsView.pause, + ? String(localized: .resume) + : String(localized: .pause), systemSymbol: download.displayStatus == .inactive ? .playFill : .pauseFill @@ -270,16 +270,16 @@ private extension DownloadsView { if store.downloads.isEmpty { AlertView( symbol: .squareAndArrowDown, - message: L10n.Localizable.DownloadsView.emptyDownloads + message: String(localized: .emptyDownloads) ) { EmptyView() } } else { AlertView( symbol: .line3HorizontalDecreaseCircle, - message: L10n.Localizable.DownloadsView.noMatchingFilters + message: String(localized: .noMatchingFilters) ) { - AlertViewButton(title: L10n.Localizable.DownloadsView.clearFilters) { + AlertViewButton(title: String(localized: .clearFilters)) { store.keyword = "" store.folderFilter = .all } diff --git a/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..b0e39766a --- /dev/null +++ b/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings @@ -0,0 +1,867 @@ +{ + "sourceLanguage": "en", + "strings": { + "actions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Actions" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktionen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "동작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "操作" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "操作" + } + } + } + }, + "clear_filters": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Filters" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Filter löschen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィルターをクリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필터 지우기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除篩選" + } + } + } + }, + "delete_active_download": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will cancel the current download and remove it from this device." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der aktuelle Download wird abgebrochen und von diesem Gerät entfernt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のダウンロードをキャンセルし、このデバイスから削除します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 다운로드를 취소하고 이 기기에서 삭제합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将取消当前下载并从此设备移除它。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這將取消目前下載並從此裝置移除它。" + } + } + } + }, + "download_status": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download Status" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloadstatus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード状況" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 상태" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載狀態" + } + } + } + }, + "downloaded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloaded" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heruntergeladen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已下載" + } + } + } + }, + "empty_downloads": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloaded galleries will appear here." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heruntergeladene Galerien werden hier angezeigt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードしたギャラリーはここに表示されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드한 갤러리가 여기에 표시됩니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已下载的画廊会显示在这里。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已下載的畫廊會顯示在這裡。" + } + } + } + }, + "failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + } + } + }, + "image_data_unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image data could not be validated." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilddaten konnten nicht geprüft werden." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像データを検証できませんでした。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 데이터를 검증할 수 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法验证图像数据。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法驗證圖片資料。" + } + } + } + }, + "image_data_valid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image data is valid" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilddaten sind gültig" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像データは有効です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 데이터가 유효합니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图像数据有效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片資料有效" + } + } + } + }, + "inspect_pages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pages" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "頁面" + } + } + } + }, + "move": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verschieben" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移動" + } + } + } + }, + "move_to_folder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Folder" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Ordner verschieben" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダに移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더로 이동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移动到文件夹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移到資料夾" + } + } + } + }, + "no_matching_filters": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No downloads match the current filters." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Downloads entsprechen den aktuellen Filtern." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のフィルターに一致するダウンロードはありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 필터와 일치하는 다운로드가 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有下载项符合当前筛选条件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有下載項符合目前的篩選條件。" + } + } + } + }, + "none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No pages" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Seiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページなし" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有頁面" + } + } + } + }, + "pause": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pausieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一時停止" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일시 정지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂停" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "暫停" + } + } + } + }, + "pending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausstehend" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대기 중" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待中" + } + } + } + }, + "resume": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fortsetzen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再開" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재개" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "继续" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "繼續" + } + } + } + }, + "retry_failed_pages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry Failed Pages" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagene Seiten erneut versuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗したページを再試行" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패한 페이지 다시 시도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试失败页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重試失敗頁面" + } + } + } + }, + "search_downloads": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search downloads" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloads durchsuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 검색" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋下載" + } + } + } + }, + "validate_image_data": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Validate Image Data" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilddaten validieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像データを検証" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 데이터 검증" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "验证图片数据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "驗證圖片資料" + } + } + } + }, + "validating_image_data": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Validating Image Data..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilddaten werden geprüft..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像データを検証中..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 데이터 검증 중..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在验证图像数据..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在驗證圖片資料..." + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 329020297..fc61062ab 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -339,35 +339,14 @@ "detail_view.create_default_folder" = "Standardordner erstellen"; "detail_view.no_folders" = "Noch keine Ordner"; "downloads_view.manage_folders" = "Ordner verwalten"; -"downloads_view.move_to_folder" = "In Ordner verschieben"; -"downloads_view.move" = "Verschieben"; "folder_manager_view.folders" = "Ordner"; "folder_manager_view.folder_name" = "Ordnername"; "folder_manager_view.delete_folder" = "Der Ordner und alle heruntergeladenen Galerien darin werden gelöscht."; "folder_manager_view.empty_folders" = "Von dir erstellte Ordner werden hier angezeigt."; "downloads_view.downloads" = "Downloads"; -"downloads_view.search_downloads" = "Downloads durchsuchen"; "downloads_view.delete_download" = "Download löschen?"; -"downloads_view.delete_active_download" = "Der aktuelle Download wird abgebrochen und von diesem Gerät entfernt."; "downloads_view.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; -"downloads_view.pages" = "Seiten"; "downloads_view.update" = "Aktualisieren"; -"downloads_view.resume" = "Fortsetzen"; -"downloads_view.pause" = "Pausieren"; -"downloads_view.empty_downloads" = "Heruntergeladene Galerien werden hier angezeigt."; -"downloads_view.no_matching_filters" = "Keine Downloads entsprechen den aktuellen Filtern."; -"downloads_view.clear_filters" = "Filter löschen"; -"downloads_view.validate_image_data" = "Bilddaten validieren"; -"download_inspector_view.actions" = "Aktionen"; -"download_inspector_view.retry_failed_pages" = "Fehlgeschlagene Seiten erneut versuchen"; -"download_inspector_view.validating_image_data" = "Bilddaten werden geprüft..."; -"download_inspector_view.image_data_valid" = "Bilddaten sind gültig"; -"download_inspector_view.image_data_unavailable" = "Bilddaten konnten nicht geprüft werden."; -"download_inspector_view.download_status" = "Downloadstatus"; -"download_inspector_view.pending" = "Ausstehend"; -"download_inspector_view.none" = "Keine Seiten"; -"download_inspector_view.downloaded" = "Heruntergeladen"; -"download_inspector_view.failed" = "Fehlgeschlagen"; // MARK: DownloadSettingView "download_setting_view.title" = "Download"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index b7add74da..e22fc2218 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -339,35 +339,14 @@ "detail_view.create_default_folder" = "Create Default Folder"; "detail_view.no_folders" = "No folders yet"; "downloads_view.manage_folders" = "Manage Folders"; -"downloads_view.move_to_folder" = "Move to Folder"; -"downloads_view.move" = "Move"; "folder_manager_view.folders" = "Folders"; "folder_manager_view.folder_name" = "Folder name"; "folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; "folder_manager_view.empty_folders" = "Folders you create will appear here."; "downloads_view.downloads" = "Downloads"; -"downloads_view.search_downloads" = "Search downloads"; "downloads_view.delete_download" = "Delete Download?"; -"downloads_view.delete_active_download" = "This will cancel the current download and remove it from this device."; "downloads_view.delete_downloaded_gallery" = "This will remove the downloaded gallery from this device."; -"downloads_view.pages" = "Pages"; "downloads_view.update" = "Update"; -"downloads_view.resume" = "Resume"; -"downloads_view.pause" = "Pause"; -"downloads_view.empty_downloads" = "Downloaded galleries will appear here."; -"downloads_view.no_matching_filters" = "No downloads match the current filters."; -"downloads_view.clear_filters" = "Clear Filters"; -"downloads_view.validate_image_data" = "Validate Image Data"; -"download_inspector_view.actions" = "Actions"; -"download_inspector_view.retry_failed_pages" = "Retry Failed Pages"; -"download_inspector_view.validating_image_data" = "Validating Image Data..."; -"download_inspector_view.image_data_valid" = "Image data is valid"; -"download_inspector_view.image_data_unavailable" = "Image data could not be validated."; -"download_inspector_view.download_status" = "Download Status"; -"download_inspector_view.pending" = "Pending"; -"download_inspector_view.none" = "No pages"; -"download_inspector_view.downloaded" = "Downloaded"; -"download_inspector_view.failed" = "Failed"; // MARK: DownloadSettingView "download_setting_view.title" = "Download"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 9f7e2a8d3..f7d92b40c 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -339,35 +339,14 @@ "detail_view.create_default_folder" = "デフォルトフォルダを作成"; "detail_view.no_folders" = "フォルダはまだありません"; "downloads_view.manage_folders" = "フォルダを管理"; -"downloads_view.move_to_folder" = "フォルダに移動"; -"downloads_view.move" = "移動"; "folder_manager_view.folders" = "フォルダ"; "folder_manager_view.folder_name" = "フォルダ名"; "folder_manager_view.delete_folder" = "フォルダとその中のダウンロード済みギャラリーをすべて削除します。"; "folder_manager_view.empty_folders" = "作成したフォルダはここに表示されます。"; "downloads_view.downloads" = "ダウンロード"; -"downloads_view.search_downloads" = "ダウンロードを検索"; "downloads_view.delete_download" = "ダウンロードを削除しますか?"; -"downloads_view.delete_active_download" = "現在のダウンロードをキャンセルし、このデバイスから削除します。"; "downloads_view.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; -"downloads_view.pages" = "ページ"; "downloads_view.update" = "更新"; -"downloads_view.resume" = "再開"; -"downloads_view.pause" = "一時停止"; -"downloads_view.empty_downloads" = "ダウンロードしたギャラリーはここに表示されます。"; -"downloads_view.no_matching_filters" = "現在のフィルターに一致するダウンロードはありません。"; -"downloads_view.clear_filters" = "フィルターをクリア"; -"downloads_view.validate_image_data" = "画像データを検証"; -"download_inspector_view.actions" = "操作"; -"download_inspector_view.retry_failed_pages" = "失敗したページを再試行"; -"download_inspector_view.validating_image_data" = "画像データを検証中..."; -"download_inspector_view.image_data_valid" = "画像データは有効です"; -"download_inspector_view.image_data_unavailable" = "画像データを検証できませんでした。"; -"download_inspector_view.download_status" = "ダウンロード状況"; -"download_inspector_view.pending" = "待機中"; -"download_inspector_view.none" = "ページなし"; -"download_inspector_view.downloaded" = "ダウンロード済み"; -"download_inspector_view.failed" = "失敗"; // MARK: DownloadSettingView "download_setting_view.title" = "ダウンロード"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index a24a698ee..b66e26525 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -339,35 +339,14 @@ "detail_view.create_default_folder" = "기본 폴더 만들기"; "detail_view.no_folders" = "아직 폴더가 없습니다"; "downloads_view.manage_folders" = "폴더 관리"; -"downloads_view.move_to_folder" = "폴더로 이동"; -"downloads_view.move" = "이동"; "folder_manager_view.folders" = "폴더"; "folder_manager_view.folder_name" = "폴더 이름"; "folder_manager_view.delete_folder" = "폴더와 그 안에 다운로드한 모든 갤러리를 삭제합니다."; "folder_manager_view.empty_folders" = "만든 폴더가 여기에 표시됩니다."; "downloads_view.downloads" = "다운로드"; -"downloads_view.search_downloads" = "다운로드 검색"; "downloads_view.delete_download" = "다운로드를 삭제할까요?"; -"downloads_view.delete_active_download" = "현재 다운로드를 취소하고 이 기기에서 삭제합니다."; "downloads_view.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; -"downloads_view.pages" = "페이지"; "downloads_view.update" = "업데이트"; -"downloads_view.resume" = "재개"; -"downloads_view.pause" = "일시 정지"; -"downloads_view.empty_downloads" = "다운로드한 갤러리가 여기에 표시됩니다."; -"downloads_view.no_matching_filters" = "현재 필터와 일치하는 다운로드가 없습니다."; -"downloads_view.clear_filters" = "필터 지우기"; -"downloads_view.validate_image_data" = "이미지 데이터 검증"; -"download_inspector_view.actions" = "동작"; -"download_inspector_view.retry_failed_pages" = "실패한 페이지 다시 시도"; -"download_inspector_view.validating_image_data" = "이미지 데이터 검증 중..."; -"download_inspector_view.image_data_valid" = "이미지 데이터가 유효합니다"; -"download_inspector_view.image_data_unavailable" = "이미지 데이터를 검증할 수 없습니다."; -"download_inspector_view.download_status" = "다운로드 상태"; -"download_inspector_view.pending" = "대기 중"; -"download_inspector_view.none" = "페이지 없음"; -"download_inspector_view.downloaded" = "다운로드됨"; -"download_inspector_view.failed" = "실패"; // MARK: DownloadSettingView "download_setting_view.title" = "다운로드"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index fa0ff3447..784bc0f72 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -339,35 +339,14 @@ "detail_view.create_default_folder" = "创建默认文件夹"; "detail_view.no_folders" = "还没有文件夹"; "downloads_view.manage_folders" = "管理文件夹"; -"downloads_view.move_to_folder" = "移动到文件夹"; -"downloads_view.move" = "移动"; "folder_manager_view.folders" = "文件夹"; "folder_manager_view.folder_name" = "文件夹名称"; "folder_manager_view.delete_folder" = "这将删除该文件夹及其中所有已下载的画廊。"; "folder_manager_view.empty_folders" = "创建的文件夹会显示在这里。"; "downloads_view.downloads" = "下载"; -"downloads_view.search_downloads" = "搜索下载"; "downloads_view.delete_download" = "删除下载?"; -"downloads_view.delete_active_download" = "这将取消当前下载并从此设备移除它。"; "downloads_view.delete_downloaded_gallery" = "这将从此设备移除已下载的画廊。"; -"downloads_view.pages" = "页面"; "downloads_view.update" = "更新"; -"downloads_view.resume" = "继续"; -"downloads_view.pause" = "暂停"; -"downloads_view.empty_downloads" = "已下载的画廊会显示在这里。"; -"downloads_view.no_matching_filters" = "没有下载项符合当前筛选条件。"; -"downloads_view.clear_filters" = "清除筛选"; -"downloads_view.validate_image_data" = "验证图片数据"; -"download_inspector_view.actions" = "操作"; -"download_inspector_view.retry_failed_pages" = "重试失败页面"; -"download_inspector_view.validating_image_data" = "正在验证图像数据..."; -"download_inspector_view.image_data_valid" = "图像数据有效"; -"download_inspector_view.image_data_unavailable" = "无法验证图像数据。"; -"download_inspector_view.download_status" = "下载状态"; -"download_inspector_view.pending" = "等待中"; -"download_inspector_view.none" = "无页面"; -"download_inspector_view.downloaded" = "已下载"; -"download_inspector_view.failed" = "失败"; // MARK: DownloadSettingView "download_setting_view.title" = "下载"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 8ab070481..f7f89c302 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -339,35 +339,14 @@ "detail_view.create_default_folder" = "建立預設資料夾"; "detail_view.no_folders" = "還沒有資料夾"; "downloads_view.manage_folders" = "管理資料夾"; -"downloads_view.move_to_folder" = "移到資料夾"; -"downloads_view.move" = "移動"; "folder_manager_view.folders" = "資料夾"; "folder_manager_view.folder_name" = "資料夾名稱"; "folder_manager_view.delete_folder" = "這將刪除此資料夾及其中所有已下載的畫廊。"; "folder_manager_view.empty_folders" = "建立的資料夾會顯示在這裡。"; "downloads_view.downloads" = "下載"; -"downloads_view.search_downloads" = "搜尋下載"; "downloads_view.delete_download" = "刪除下載?"; -"downloads_view.delete_active_download" = "這將取消目前下載並從此裝置移除它。"; "downloads_view.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"downloads_view.pages" = "頁面"; "downloads_view.update" = "更新"; -"downloads_view.resume" = "繼續"; -"downloads_view.pause" = "暫停"; -"downloads_view.empty_downloads" = "已下載的畫廊會顯示在這裡。"; -"downloads_view.no_matching_filters" = "沒有下載項符合目前的篩選條件。"; -"downloads_view.clear_filters" = "清除篩選"; -"downloads_view.validate_image_data" = "驗證圖片資料"; -"download_inspector_view.actions" = "操作"; -"download_inspector_view.retry_failed_pages" = "重試失敗頁面"; -"download_inspector_view.validating_image_data" = "正在驗證圖片資料..."; -"download_inspector_view.image_data_valid" = "圖片資料有效"; -"download_inspector_view.image_data_unavailable" = "無法驗證圖片資料。"; -"download_inspector_view.download_status" = "下載狀態"; -"download_inspector_view.pending" = "等待中"; -"download_inspector_view.none" = "沒有頁面"; -"download_inspector_view.downloaded" = "已下載"; -"download_inspector_view.failed" = "失敗"; // MARK: DownloadSettingView "download_setting_view.title" = "下載"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index bde738473..98296cb55 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -1059,28 +1059,6 @@ public enum L10n { /// All public static let all = L10n.tr("Localizable", "download_folder_filter.all", fallback: "All") } - public enum DownloadInspectorView { - /// Actions - public static let actions = L10n.tr("Localizable", "download_inspector_view.actions", fallback: "Actions") - /// Download Status - public static let downloadStatus = L10n.tr("Localizable", "download_inspector_view.download_status", fallback: "Download Status") - /// Downloaded - public static let downloaded = L10n.tr("Localizable", "download_inspector_view.downloaded", fallback: "Downloaded") - /// Failed - public static let failed = L10n.tr("Localizable", "download_inspector_view.failed", fallback: "Failed") - /// Image data could not be validated. - public static let imageDataUnavailable = L10n.tr("Localizable", "download_inspector_view.image_data_unavailable", fallback: "Image data could not be validated.") - /// Image data is valid - public static let imageDataValid = L10n.tr("Localizable", "download_inspector_view.image_data_valid", fallback: "Image data is valid") - /// No pages - public static let `none` = L10n.tr("Localizable", "download_inspector_view.none", fallback: "No pages") - /// Pending - public static let pending = L10n.tr("Localizable", "download_inspector_view.pending", fallback: "Pending") - /// Retry Failed Pages - public static let retryFailedPages = L10n.tr("Localizable", "download_inspector_view.retry_failed_pages", fallback: "Retry Failed Pages") - /// Validating Image Data... - public static let validatingImageData = L10n.tr("Localizable", "download_inspector_view.validating_image_data", fallback: "Validating Image Data...") - } public enum DownloadSettingView { /// Allow cellular downloads public static let allowCellularDownloads = L10n.tr("Localizable", "download_setting_view.allow_cellular_downloads", fallback: "Allow cellular downloads") @@ -1110,38 +1088,16 @@ public enum L10n { } } public enum DownloadsView { - /// Clear Filters - public static let clearFilters = L10n.tr("Localizable", "downloads_view.clear_filters", fallback: "Clear Filters") - /// This will cancel the current download and remove it from this device. - public static let deleteActiveDownload = L10n.tr("Localizable", "downloads_view.delete_active_download", fallback: "This will cancel the current download and remove it from this device.") /// Delete Download? public static let deleteDownload = L10n.tr("Localizable", "downloads_view.delete_download", fallback: "Delete Download?") /// This will remove the downloaded gallery from this device. public static let deleteDownloadedGallery = L10n.tr("Localizable", "downloads_view.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") /// Downloads public static let downloads = L10n.tr("Localizable", "downloads_view.downloads", fallback: "Downloads") - /// Downloaded galleries will appear here. - public static let emptyDownloads = L10n.tr("Localizable", "downloads_view.empty_downloads", fallback: "Downloaded galleries will appear here.") /// Manage Folders public static let manageFolders = L10n.tr("Localizable", "downloads_view.manage_folders", fallback: "Manage Folders") - /// Move - public static let move = L10n.tr("Localizable", "downloads_view.move", fallback: "Move") - /// Move to Folder - public static let moveToFolder = L10n.tr("Localizable", "downloads_view.move_to_folder", fallback: "Move to Folder") - /// No downloads match the current filters. - public static let noMatchingFilters = L10n.tr("Localizable", "downloads_view.no_matching_filters", fallback: "No downloads match the current filters.") - /// Pages - public static let pages = L10n.tr("Localizable", "downloads_view.pages", fallback: "Pages") - /// Pause - public static let pause = L10n.tr("Localizable", "downloads_view.pause", fallback: "Pause") - /// Resume - public static let resume = L10n.tr("Localizable", "downloads_view.resume", fallback: "Resume") - /// Search downloads - public static let searchDownloads = L10n.tr("Localizable", "downloads_view.search_downloads", fallback: "Search downloads") /// Update public static let update = L10n.tr("Localizable", "downloads_view.update", fallback: "Update") - /// Validate Image Data - public static let validateImageData = L10n.tr("Localizable", "downloads_view.validate_image_data", fallback: "Validate Image Data") } public enum EhSetting { public enum ArchiverBehavior { diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift index 38e05d7d6..58ae095e9 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift @@ -156,7 +156,7 @@ struct DownloadInspectorLoadTests: DownloadFeatureTestCase { await store.receive(\.validateImageDataDone) { $0.isValidatingImageData = false $0.toast = .success( - caption: L10n.Localizable.DownloadInspectorView.imageDataValid + caption: String(localized: .imageDataValid) ) } await store.receive(\.loadInspection) From 628fe2156afdf9b375dd24f375385ca90be3d3d7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 02:14:11 +0800 Subject: [PATCH 466/614] Move DetailFeature strings to catalog Emit 78 localizable + 3 constant keys into DetailFeature/Resources (Localizable.xcstrings + Constant.xcstrings), delete them from the 6 lproj files, and rewrite 86 accessors across 15 files. Strip detail/comments/torrents/previews view-name namespaces (4 in-module identical-value merges). Constant table gets the response-matching hath_client/invalid_resolution copies under a response. prefix. Fix _URL acronym symbols to match Xcode flattening (metadataGalleryUrl, not ...URL). Shared keys stay on L10n via Resources. --- AppPackage/Package.swift | 1 + .../Archives/ArchivesReducer.swift | 12 +- .../DetailFeature/Archives/ArchivesView.swift | 4 +- .../DetailFeature/Comments/CommentsView.swift | 6 +- .../Components/TagDetailView.swift | 4 +- .../DetailReducer+Download.swift | 16 +- .../DetailView+CommentCells.swift | 2 +- .../DetailView+HeaderSection.swift | 36 +- .../DetailFeature/DetailView+Navigation.swift | 4 +- .../DetailFeature/DetailView+Subviews.swift | 26 +- .../Sources/DetailFeature/DetailView.swift | 4 +- .../FolderManager/FolderManagerReducer.swift | 2 +- .../FolderManager/FolderManagerView.swift | 6 +- .../GalleryInfos/GalleryInfosView.swift | 52 +- .../DetailFeature/Previews/PreviewsView.swift | 2 +- .../Resources/Constant.xcstrings | 42 + .../Resources/Localizable.xcstrings | 3204 +++++++++++++++++ .../DetailFeature/Torrents/TorrentsView.swift | 2 +- .../Resources/de.lproj/Localizable.strings | 82 - .../Resources/en.lproj/Constant.strings | 3 - .../Resources/en.lproj/Localizable.strings | 82 - .../Resources/ja.lproj/Localizable.strings | 82 - .../Resources/ko.lproj/Localizable.strings | 82 - .../zh-Hans.lproj/Localizable.strings | 82 - .../zh-Hant.lproj/Localizable.strings | 82 - AppPackage/Sources/Resources/Strings.swift | 198 +- 26 files changed, 3337 insertions(+), 781 deletions(-) create mode 100644 AppPackage/Sources/DetailFeature/Resources/Constant.xcstrings create mode 100644 AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 040b0f224..87e1eb9e7 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -786,6 +786,7 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index 4d86bb2fc..0930a73e4 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -124,14 +124,14 @@ public struct ArchivesReducer: Sendable { switch result { case .success(let response): switch response { - case L10n.Constant.hathClientNotFound: - state.toast = .error(caption: L10n.Localizable.hathClientNotFound) + case String(localized: .Constant.responseHathClientNotFound): + state.toast = .error(caption: String(localized: .hathClientNotFound)) isSuccess = false - case L10n.Constant.hathClientNotOnline: - state.toast = .error(caption: L10n.Localizable.hathClientNotOnline) + case String(localized: .Constant.responseHathClientNotOnline): + state.toast = .error(caption: String(localized: .hathClientNotOnline)) isSuccess = false - case L10n.Constant.invalidResolution: - state.toast = .error(caption: L10n.Localizable.invalidResolution) + case String(localized: .Constant.responseInvalidResolution): + state.toast = .error(caption: String(localized: .invalidResolution)) isSuccess = false default: state.toast = .success(caption: response) diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift index 852922420..6712eaa67 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift @@ -63,7 +63,7 @@ struct ArchivesView: View { .onAppear { store.send(.fetchArchive(gid, galleryURL, archiveURL)) } - .navigationTitle(L10n.Localizable.ArchivesView.archives) + .navigationTitle(String(localized: .archives)) } } } @@ -205,7 +205,7 @@ private struct DownloadButton: View { } var body: some View { - Text(L10n.Localizable.ArchivesView.downloadToHathClient) + Text(String(localized: .downloadToHathClient)) .font(.headline) .foregroundStyle(textColor) .frame(maxWidth: .infinity) diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index 0735581cc..37e69df87 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -93,8 +93,8 @@ struct CommentsView: View { let hasCommentID = !commentID.wrappedValue.isEmpty PostCommentView( title: hasCommentID - ? L10n.Localizable.PostCommentView.editComment - : L10n.Localizable.PostCommentView.postComment, + ? String(localized: .editComment) + : String(localized: .postComment), content: $store.commentContent, isFocused: $store.postCommentFocused, postAction: { @@ -117,7 +117,7 @@ struct CommentsView: View { store.send(.onAppear) } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.CommentsView.comments) + .navigationTitle(String(localized: .comments)) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift index 865a89962..5765f4cb9 100644 --- a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift +++ b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift @@ -57,7 +57,7 @@ private struct ImagesSection: View { } var body: some View { - SubSection(title: L10n.Localizable.TagDetailView.images, showAll: false) { + SubSection(title: String(localized: .images), showAll: false) { VStack { if !imageURLs.isEmpty { ScrollView(.horizontal, showsIndicators: false) { @@ -92,7 +92,7 @@ private struct LinksSection: View { } var body: some View { - SubSection(title: L10n.Localizable.TagDetailView.links, showAll: false) { + SubSection(title: String(localized: .links), showAll: false) { HStack { if !links.isEmpty { VStack(alignment: .leading) { diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift index a87d5cd48..77bc7661f 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift @@ -305,33 +305,33 @@ extension DetailReducer { private static func retryDownloadTitle(for mode: DownloadStartMode) -> String { switch mode { case .repair: - return L10n.Localizable.DetailView.repairDownload + return String(localized: .repairDownload) case .update: - return L10n.Localizable.DetailView.updateDownload + return String(localized: .updateDownload) case .initial, .redownload: - return L10n.Localizable.DetailView.redownloadGallery + return String(localized: .redownloadGallery) } } private static func retryDownloadMessage(for mode: DownloadStartMode) -> String { switch mode { case .repair: - return L10n.Localizable.DetailView.repairDownloadDescription + return String(localized: .repairDownloadDescription) case .update: - return L10n.Localizable.DetailView.updateDownloadDescription + return String(localized: .updateDownloadDescription) case .initial, .redownload: - return L10n.Localizable.DetailView.redownloadGalleryDescription + return String(localized: .redownloadGalleryDescription) } } private static func retryDownloadConfirmTitle(for mode: DownloadStartMode) -> String { switch mode { case .repair: - return L10n.Localizable.DetailView.repair + return String(localized: .repair) case .update: return L10n.Localizable.DetailView.update case .initial, .redownload: - return L10n.Localizable.DetailView.redownload + return String(localized: .redownload) } } } diff --git a/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift b/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift index 962a8b7b2..48f87ffa5 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift @@ -52,7 +52,7 @@ struct CommentButton: View { Button(action: action) { HStack { Image(systemSymbol: .squareAndPencil) - Text(L10n.Localizable.DetailView.postComment) + Text(String(localized: .postComment)) .bold() } .padding() diff --git a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift index 19cf053a9..b85f6275b 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift @@ -98,7 +98,7 @@ struct HeaderSection: View { if downloadFolders.isEmpty { Button(action: createDefaultFolderAction) { Label( - L10n.Localizable.DetailView.createDefaultFolder, + String(localized: .createDefaultFolder), systemSymbol: .folderBadgePlus ) } @@ -107,7 +107,7 @@ struct HeaderSection: View { } Section { if downloadFolders.isEmpty { - Text(L10n.Localizable.DetailView.noFolders) + Text(String(localized: .noFolders)) } else { ForEach(downloadFolders, id: \.self) { folder in Button { @@ -181,7 +181,7 @@ struct HeaderSection: View { } .buttonStyle(.glassProminent) .buttonBorderShape(.circle) - .accessibilityLabel(L10n.Localizable.DetailView.read) + .accessibilityLabel(String(localized: .read)) } private func progressIndicator( progress: Double, isDeterminate: Bool, centerSymbol: SFSymbol @@ -293,43 +293,43 @@ struct HeaderSection: View { // MARK: HeaderSection Accessibility extension HeaderSection { var downloadButtonAccessibilityLabel: String { - guard canDownload else { return L10n.Localizable.DetailView.Accessibility.login } + guard canDownload else { return String(localized: .accessibilityLogin) } guard !showsMetadataPreparation else { - return L10n.Localizable.DetailView.Accessibility.preparing + return String(localized: .accessibilityPreparing) } return downloadBadgeAccessibilityLabel } var downloadBadgeAccessibilityLabel: String { guard let badge = downloadBadge else { - return L10n.Localizable.DetailView.Accessibility.download + return String(localized: .accessibilityDownload) } let progress = badge.progress switch badge.status { case .queued: - return L10n.Localizable.DetailView.Accessibility.queued + return String(localized: .accessibilityQueued) case .active: - let downloading = L10n.Localizable.DetailView.Accessibility.downloading( + let downloading = String(localized: .accessibilityDownloading( progress.completedPageCount, progress.displayPageCount - ) - return [downloading, L10n.Localizable.DetailView.Accessibility.pauseAction] + )) + return [downloading, String(localized: .accessibilityPauseAction)] .joined(separator: ". ") case .inactive: - return L10n.Localizable.DetailView.Accessibility.paused( + return String(localized: .accessibilityPaused( progress.completedPageCount, progress.displayPageCount - ) + )) case .completed: - return L10n.Localizable.DetailView.Accessibility.downloaded + return String(localized: .accessibilityDownloaded) case .updateAvailable: - return L10n.Localizable.DetailView.Accessibility.update + return String(localized: .accessibilityUpdate) case .error: if isPartialDownloadError { - return L10n.Localizable.DetailView.Accessibility.partial( + return String(localized: .accessibilityPartial( progress.completedPageCount, progress.displayPageCount - ) + )) } return downloadNeedsRepair - ? L10n.Localizable.DetailView.Accessibility.repair - : L10n.Localizable.DetailView.Accessibility.retry + ? String(localized: .accessibilityRepair) + : String(localized: .accessibilityRetry) } } } diff --git a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift index 46daf1af4..72be31091 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift @@ -12,13 +12,13 @@ extension DetailView { Button { store.send(.archivesButtonTapped) } label: { - Label(L10n.Localizable.DetailView.archives, systemSymbol: .zipperPage) + Label(String(localized: .archivesAction), systemSymbol: .zipperPage) } .disabled(store.galleryDetail?.archiveURL == nil || !CookieUtil.didLogin) Button { store.send(.torrentsButtonTapped) } label: { - let base = L10n.Localizable.DetailView.torrents + let base = String(localized: .torrents) let torrentCount = store.galleryDetail?.torrentCount ?? 0 let baseWithCount = [base, "(\(torrentCount))"].joined(separator: " ") Label(torrentCount > 0 ? baseWithCount : base, systemSymbol: .leaf) diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index 369f6c9b9..9fcb5e974 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -14,8 +14,8 @@ struct DescriptionSection: View { private var infos: [DescScrollInfo] {[ DescScrollInfo( - title: L10n.Localizable.DetailView.favorited, - description: L10n.Localizable.DetailView.favoritedUnit, + title: String(localized: .favorited), + description: String(localized: .favoritedUnit), value: .init(galleryDetail.favoritedCount) ), DescScrollInfo( @@ -24,16 +24,16 @@ struct DescriptionSection: View { value: galleryDetail.language.abbreviation ), DescScrollInfo( - title: L10n.Localizable.DetailView.ratings("\(galleryDetail.ratingCount)"), + title: String(localized: .ratingsCount("\(galleryDetail.ratingCount)")), description: .init(), value: .init(), rating: galleryDetail.rating, isRating: true ), DescScrollInfo( - title: L10n.Localizable.DetailView.pageCount, - description: L10n.Localizable.DetailView.pageCountUnit, + title: String(localized: .pageCount), + description: String(localized: .pageCountUnit), value: .init(galleryDetail.pageCount) ), DescScrollInfo( - title: L10n.Localizable.DetailView.fileSize, + title: String(localized: .fileSize), description: galleryDetail.sizeType, value: .init(galleryDetail.sizeCount) ) ]} @@ -122,14 +122,14 @@ struct ActionSection: View { Button(action: showUserRatingAction) { Spacer() Image(systemSymbol: .squareAndPencil) - Text(L10n.Localizable.DetailView.giveARating).bold() + Text(String(localized: .giveARating)).bold() Spacer() } .disabled(!CookieUtil.didLogin) Button(action: navigateSimilarGalleryAction) { Spacer() Image(systemSymbol: .photoOnRectangleAngled) - Text(L10n.Localizable.DetailView.similarGallery).bold() + Text(String(localized: .similarGallery)).bold() Spacer() } } @@ -258,20 +258,20 @@ extension TagsSection { } label: { Image(systemSymbol: content.isVotedUp ? .handThumbsup : .handThumbsdown) .symbolVariant(.fill) - Text(L10n.Localizable.DetailView.withdrawVote) + Text(String(localized: .withdrawVote)) } } else { Button { voteTagAction(content.voteKeyword(tag: tag), 1) } label: { Image(systemSymbol: .handThumbsup) - Text(L10n.Localizable.DetailView.voteUp) + Text(String(localized: .voteUp)) } Button { voteTagAction(content.voteKeyword(tag: tag), -1) } label: { Image(systemSymbol: .handThumbsdown) - Text(L10n.Localizable.DetailView.voteDown) + Text(String(localized: .voteDown)) } } } @@ -290,7 +290,7 @@ struct PreviewsSection: View { var body: some View { SubSection( - title: L10n.Localizable.DetailView.previews, + title: String(localized: .previews), showAll: pageCount > 20, showAllAction: navigatePreviewsAction ) { ScrollView(.horizontal, showsIndicators: false) { @@ -325,7 +325,7 @@ struct CommentsSection: View { var body: some View { SubSection( - title: L10n.Localizable.DetailView.comments, + title: String(localized: .comments), showAll: !comments.isEmpty, showAllAction: navigateCommentAction ) { ScrollView(.horizontal, showsIndicators: false) { diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 136fba6b0..65e64825e 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -185,7 +185,7 @@ private extension DetailView { primaryModalModifiers(content: content) .sheet(item: $store.destination.postComment, id: \.id) { _ in PostCommentView( - title: L10n.Localizable.PostCommentView.postComment, + title: String(localized: .postComment), content: $store.commentContent, isFocused: $store.postCommentFocused, postAction: { @@ -292,7 +292,7 @@ private extension DetailView { @ViewBuilder private func offlineFallbackNotice(error: AppError) -> some View { VStack(alignment: .leading, spacing: 10) { Label( - L10n.Localizable.DetailView.savedDetails, + String(localized: .savedDetails), systemSymbol: .wifiExclamationmark ) .font(.subheadline.weight(.semibold)) diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift index 9a85fda11..0f9b1fb25 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift @@ -93,7 +93,7 @@ public struct FolderManagerReducer: Sendable { TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.FolderManagerView.deleteFolder) + TextState(String(localized: .deleteFolder)) } return .none diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift index 9d67735fc..9d880246e 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift @@ -52,7 +52,7 @@ public struct FolderManagerView: View { store.send(.fetchFolders) } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.FolderManagerView.folders) + .navigationTitle(String(localized: .folders)) .navigationBarTitleDisplayMode(.inline) } } @@ -71,7 +71,7 @@ public struct FolderManagerView: View { if store.folders.isEmpty && store.editingField != .newFolder { AlertView( symbol: .folder, - message: L10n.Localizable.FolderManagerView.emptyFolders + message: String(localized: .emptyFolders) ) { EmptyView() } @@ -101,7 +101,7 @@ public struct FolderManagerView: View { private func editingTextField(_ field: FolderManagerReducer.EditingField) -> some View { TextField( - L10n.Localizable.FolderManagerView.folderName, + String(localized: .folderName), text: $store.editingFolderName ) .disableAutocorrection(true) diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index 9c1cdb66a..7247442a1 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -18,72 +18,72 @@ struct GalleryInfosView: View { private var infos: [Info] { [ - Info(title: L10n.Localizable.GalleryInfosView.id, value: galleryDetail.gid), - Info(title: L10n.Localizable.GalleryInfosView.token, value: gallery.token), - Info(title: L10n.Localizable.GalleryInfosView.title, value: galleryDetail.title), - Info(title: L10n.Localizable.GalleryInfosView.japaneseTitle, value: galleryDetail.jpnTitle), + Info(title: String(localized: .metadataId), value: galleryDetail.gid), + Info(title: String(localized: .metadataToken), value: gallery.token), + Info(title: String(localized: .metadataTitle), value: galleryDetail.title), + Info(title: String(localized: .metadataJapaneseTitle), value: galleryDetail.jpnTitle), Info( - title: L10n.Localizable.GalleryInfosView.galleryURL, + title: String(localized: .metadataGalleryUrl), value: gallery.galleryURL?.absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.coverURL, + title: String(localized: .metadataCoverUrl), value: galleryDetail.coverURL?.absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.archiveURL, + title: String(localized: .metadataArchiveUrl), value: galleryDetail.archiveURL?.absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.torrentURL, + title: String(localized: .metadataTorrentUrl), value: URLUtil.galleryTorrents(gid: gallery.gid, token: gallery.token).absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.parentURL, + title: String(localized: .metadataParentUrl), value: galleryDetail.parentURL?.absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.category, + title: String(localized: .metadataCategory), value: galleryDetail.category.value ), - Info(title: L10n.Localizable.GalleryInfosView.uploader, value: galleryDetail.uploader), + Info(title: String(localized: .metadataUploader), value: galleryDetail.uploader), Info( - title: L10n.Localizable.GalleryInfosView.postedDate, + title: String(localized: .metadataPostedDate), value: galleryDetail.formattedDateString ), Info( - title: L10n.Localizable.GalleryInfosView.visibility, + title: String(localized: .metadataVisibility), value: galleryDetail.visibility.value ), - Info(title: L10n.Localizable.GalleryInfosView.language, value: galleryDetail.language.value), - Info(title: L10n.Localizable.GalleryInfosView.pageCount, value: String(galleryDetail.pageCount)), + Info(title: String(localized: .metadataLanguage), value: galleryDetail.language.value), + Info(title: String(localized: .metadataPageCount), value: String(galleryDetail.pageCount)), Info( - title: L10n.Localizable.GalleryInfosView.fileSize, + title: String(localized: .metadataFileSize), value: String(Int(galleryDetail.sizeCount)) + galleryDetail.sizeType ), Info( - title: L10n.Localizable.GalleryInfosView.favoritedTimes, + title: String(localized: .metadataFavoritedTimes), value: String(galleryDetail.favoritedCount) ), Info( - title: L10n.Localizable.GalleryInfosView.favorited, - value: galleryDetail.isFavorited ? L10n.Localizable.GalleryInfosView.yes - : L10n.Localizable.GalleryInfosView.no + title: String(localized: .metadataFavorited), + value: galleryDetail.isFavorited ? String(localized: .metadataYes) + : String(localized: .metadataNo) ), Info( - title: L10n.Localizable.GalleryInfosView.ratingCount, + title: String(localized: .metadataRatingCount), value: String(galleryDetail.ratingCount) ), Info( - title: L10n.Localizable.GalleryInfosView.averageRating, + title: String(localized: .metadataAverageRating), value: String(Int(galleryDetail.rating)) ), Info( - title: L10n.Localizable.GalleryInfosView.myRating, + title: String(localized: .metadataMyRating), value: galleryDetail.userRating == 0 ? nil : String(Int(galleryDetail.userRating)) ), Info( - title: L10n.Localizable.GalleryInfosView.torrentCount, + title: String(localized: .metadataTorrentCount), value: String(galleryDetail.torrentCount) ) ] @@ -104,7 +104,7 @@ struct GalleryInfosView: View { store.send(.copyText(text)) } } label: { - Text(info.value ?? L10n.Localizable.GalleryInfosView.none) + Text(info.value ?? String(localized: .metadataNone)) .lineLimit(3).font(.caption) .foregroundStyle(.tint) } @@ -112,7 +112,7 @@ struct GalleryInfosView: View { } } .toast($store.scope(state: \.toast, action: \.toast)) - .navigationTitle(L10n.Localizable.GalleryInfosView.galleryInfos) + .navigationTitle(String(localized: .metadataGalleryInfos)) } } diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift index 54202aec7..964431cd7 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift @@ -76,7 +76,7 @@ struct PreviewsView: View { .onAppear { store.send(.fetchDatabaseInfos(gid)) } - .navigationTitle(L10n.Localizable.PreviewsView.previews) + .navigationTitle(String(localized: .previews)) } } diff --git a/AppPackage/Sources/DetailFeature/Resources/Constant.xcstrings b/AppPackage/Sources/DetailFeature/Resources/Constant.xcstrings new file mode 100644 index 000000000..2c60024af --- /dev/null +++ b/AppPackage/Sources/DetailFeature/Resources/Constant.xcstrings @@ -0,0 +1,42 @@ +{ + "sourceLanguage": "en", + "strings": { + "response.hath_client_not_found": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You must have a H@H client assigned to your account to use this feature." + } + } + } + }, + "response.hath_client_not_online": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your H@H client appears to be offline. Turn it on, then try again." + } + } + } + }, + "response.invalid_resolution": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The requested gallery cannot be downloaded with the selected resolution." + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..bb17d9145 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings @@ -0,0 +1,3204 @@ +{ + "sourceLanguage": "en", + "strings": { + "accessibility.download": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Herunterladen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載" + } + } + } + }, + "accessibility.downloaded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete downloaded gallery" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heruntergeladene Galerie löschen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード済みのギャラリーを削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드한 갤러리 삭제" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除已下载画廊" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除已下載畫廊" + } + } + } + }, + "accessibility.downloading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading %lld of %lld" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt %lld von %lld herunter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld / %lld ページをダウンロード中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld / %lld 페이지 다운로드 중" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在下载第 %lld / %lld 页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在下載第 %lld / %lld 頁" + } + } + } + }, + "accessibility.login": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Log in to download" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Herunterladen anmelden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードするにはログインが必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드하려면 로그인해야 합니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "登录后即可下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登入後即可下載" + } + } + } + }, + "accessibility.partial": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry download. %lld of %lld pages are already available." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download erneut versuchen. %lld von %lld Seiten sind bereits verfügbar." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを再試行。すでに %lld / %lld ページが利用可能です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 다시 시도. 이미 %lld / %lld 페이지를 사용할 수 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新下载,已有 %lld / %lld 页可用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新下載,已有 %lld / %lld 頁可用。" + } + } + } + }, + "accessibility.pause_action": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause download" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download pausieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを一時停止" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 일시 정지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂停下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "暫停下載" + } + } + } + }, + "accessibility.paused": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume download. Paused at %lld of %lld" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download fortsetzen. Pausiert bei %lld von %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを再開。%lld / %lld ページで停止中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 다시 시작. %lld / %lld 페이지에서 일시 정지됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "继续下载,当前暂停在第 %lld / %lld 页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "繼續下載,目前暫停在第 %lld / %lld 頁" + } + } + } + }, + "accessibility.preparing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing download" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download-Informationen werden geladen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード情報を取得中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 정보를 불러오는 중" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在获取下载信息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在取得下載資訊" + } + } + } + }, + "accessibility.queued": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Queued" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Warteschlange" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード待ち" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 대기 중" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已加入下载队列" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已加入下載佇列" + } + } + } + }, + "accessibility.repair": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Repair download" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download reparieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを修復" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 복구" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "修复下载文件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "修復下載檔案" + } + } + } + }, + "accessibility.retry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry download" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download erneut versuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを再試行" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 다시 시도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新下載" + } + } + } + }, + "accessibility.update": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update download" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download aktualisieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 업데이트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新下载内容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新下載內容" + } + } + } + }, + "archives": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archives" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Archiv" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイブ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아카이브" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "归档" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "存檔" + } + } + } + }, + "archives_action": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archives" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Archiv" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイブ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아카이브" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "归档" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "存檔至 H@H 用戶端" + } + } + } + }, + "comments": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Comments" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kommentar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コメント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "댓글" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "评论" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "留言" + } + } + } + }, + "create_default_folder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create Default Folder" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardordner erstellen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトフォルダを作成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 폴더 만들기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建默认文件夹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建立預設資料夾" + } + } + } + }, + "delete_folder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will delete the folder and all downloaded galleries inside it." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Ordner und alle heruntergeladenen Galerien darin werden gelöscht." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダとその中のダウンロード済みギャラリーをすべて削除します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더와 그 안에 다운로드한 모든 갤러리를 삭제합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将删除该文件夹及其中所有已下载的画廊。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這將刪除此資料夾及其中所有已下載的畫廊。" + } + } + } + }, + "download_to_hath_client": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download To H@H Client" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit H@H Client herunterladen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "H@H クライアントにダウンロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "H@H 클라이언트로 저장" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载到 H@H 客户端" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載至 H@H 用戶端" + } + } + } + }, + "edit_comment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit comment" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kommentar bearbeiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コメントを編集" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "평가 수정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑评论" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編輯留言" + } + } + } + }, + "empty_folders": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Folders you create will appear here." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Von dir erstellte Ordner werden hier angezeigt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成したフォルダはここに表示されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "만든 폴더가 여기에 표시됩니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建的文件夹会显示在这里。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建立的資料夾會顯示在這裡。" + } + } + } + }, + "favorited": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorited" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favorisiert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "気に入り" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收藏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收藏" + } + } + } + }, + "favorited_unit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Times" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "mal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "回" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "번" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "次" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "次" + } + } + } + }, + "file_size": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File Size" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dateigröße" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルサイズ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일 크기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件大小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檔案大小" + } + } + } + }, + "folder_name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Folder name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ordnername" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダ名" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 이름" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件夹名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資料夾名稱" + } + } + } + }, + "folders": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Folders" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ordner" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件夹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資料夾" + } + } + } + }, + "give_a_rating": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Give a Rating" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bewertung abgeben" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "評価する" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "별점 주기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "给予评分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "給予評分" + } + } + } + }, + "hath_client_not_found": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You must have a H@H client assigned to your account to use this feature." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du benötigst einen deinem Konto zugehörigen H@H Client um diese Funktion nutzen zu können" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この機能を使うには、アカウントに関連付けられている H@H クライアントが必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "H@H 클라이언트를 아이디에 연동시킨 후 사용해주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你需要一个关联到账户的 H@H 客户端才能使用这个功能" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你需要一個連結到帳號的 H@H 用戶端才能使用這個功能" + } + } + } + }, + "hath_client_not_online": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your H@H client appears to be offline. Turn it on, then try again." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dein H@H Client scheint offline zu sein. Sieh nach, ob er läuft und probier's nochmal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "H@H クライアントは現在オフラインのようです、起動してからもう一度お試しください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "H@H 클라이언트가 오프라인인 것 같네요. 클라이언트를 켜고 다시 시도해주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你的 H@H 客户端似乎处于离线状态,请启动它后再试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你的 H@H 用戶端為離線狀態,請啟動後再試" + } + } + } + }, + "images": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Images" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图片" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片" + } + } + } + }, + "invalid_resolution": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The requested gallery cannot be downloaded with the selected resolution." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die gewünschte Galerie kann nicht in der gewählten Auflösung heruntergeladen werden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このギャラリーは選択された解像度ではダウンロードできません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 콘텐츠는 선택한 해상도로 다운로드할 수 없어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该画廊不能以选中的分辨率下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "該畫廊不能以目前選擇的解析度下載" + } + } + } + }, + "links": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Links" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Links" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リンク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "링크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連結" + } + } + } + }, + "metadata.archive_URL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archive URL" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Archiv-URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイブリンク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아카이브 주소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "归档链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "存檔 URL" + } + } + } + }, + "metadata.average_rating": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Average rating" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Durchschnittliche Bewertung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "平均評価" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "평균 별점" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "平均评分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "平均評分" + } + } + } + }, + "metadata.category": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Category" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kategorie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カテゴリー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "장르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分类" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "類別" + } + } + } + }, + "metadata.cover_URL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cover URL" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cover-URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カバーリンク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표지 주소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "封面链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "封面 URL" + } + } + } + }, + "metadata.favorited": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorited" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favorisiert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入り済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기에 저장 됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已收藏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已收藏" + } + } + } + }, + "metadata.favorited_times": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorited times" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzahl Favorisierungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "気に入り数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기된 수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收藏次数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "被收藏次數" + } + } + } + }, + "metadata.file_size": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File size" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dateigröße" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルサイズ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일 크기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件大小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檔案大小" + } + } + } + }, + "metadata.gallery_URL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery URL" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerie-URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリーリンク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 주소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊 URL" + } + } + } + }, + "metadata.gallery_infos": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery infos" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerie-Infos" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー情報" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 정보" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊信息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊資訊" + } + } + } + }, + "metadata.id": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "ID" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "ID" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ID" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "ID" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "ID" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊 ID" + } + } + } + }, + "metadata.japanese_title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Japanese title" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Japanischer Titel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日本語タイトル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일본어 제목" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日文标题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日文標題" + } + } + } + }, + "metadata.language": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sprache" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "言語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "언어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言" + } + } + } + }, + "metadata.my_rating": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "My rating" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Meine Bewertung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自分の評価" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내 별점" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "我的评分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "我的評分" + } + } + } + }, + "metadata.no": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nein" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "いいえ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아니요" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "否" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "No" + } + } + } + }, + "metadata.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "None" + } + } + } + }, + "metadata.page_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page count" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenzahl" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "頁數" + } + } + } + }, + "metadata.parent_URL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Parent URL" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übergeordnete URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "親ギャラリーリンク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "부모 갤러리 링크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上游画廊链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Parent URL" + } + } + } + }, + "metadata.posted_date": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Posted date" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Veröffentlicht am" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "投稿日付" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업로드된 날짜" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发布日期" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "發布日期" + } + } + } + }, + "metadata.rating_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rating count" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzahl Bewertungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "評価数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "별점 갯수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "评分次数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "被評分次數" + } + } + } + }, + "metadata.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Title" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Titel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイトル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제목" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標題" + } + } + } + }, + "metadata.token": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Token" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Token" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Token" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토큰" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Token" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Token" + } + } + } + }, + "metadata.torrent_URL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Torrent URL" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Torrent-URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トレントリンク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토렌트 주소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "种子链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "種子 URL" + } + } + } + }, + "metadata.torrent_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Torrent count" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzahl Torrents" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トレント数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토렌트 수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "种子个数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "種子數量" + } + } + } + }, + "metadata.uploader": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uploader" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Uploader" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップローダー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업로드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上传者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上傳者" + } + } + } + }, + "metadata.visibility": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Visibility" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sichtbarkeit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "可視" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가시성" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可见" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "能見度" + } + } + } + }, + "metadata.yes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yes" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ja" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "はい" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "是" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Yes" + } + } + } + }, + "no_folders": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No folders yet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Ordner" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダはまだありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 폴더가 없습니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "还没有文件夹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "還沒有資料夾" + } + } + } + }, + "page_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page Count" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenzahl" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "頁數" + } + } + } + }, + "page_count_unit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pages" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "頁" + } + } + } + }, + "post_comment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Post comment" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kommentar abgeben" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コメントを書く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "평가 남기기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发布评论" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "發表留言" + } + } + } + }, + "previews": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Previews" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プレビュー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미리보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预览" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預覽" + } + } + } + }, + "ratings_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ Ratings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Bewertungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ 件の評価" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@명의 별점" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 个评分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 個評分" + } + } + } + }, + "read": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lesen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阅读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "閱讀" + } + } + } + }, + "redownload": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Redownload" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneut laden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再ダウンロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 다운로드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新下載" + } + } + } + }, + "redownload_gallery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Redownload Gallery?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerie erneut herunterladen?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリーを再ダウンロードしますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리를 다시 다운로드할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新下载画廊?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新下載畫廊?" + } + } + } + }, + "redownload_gallery_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start a fresh download for this gallery now?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Galerie jetzt vollständig neu herunterladen?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このギャラリーを今すぐ最初から再ダウンロードしますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 갤러리를 지금 처음부터 다시 다운로드할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "现在重新完整下载此画廊吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "現在重新完整下載此畫廊嗎?" + } + } + } + }, + "repair": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Repair" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reparieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "修復" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복구" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "修复" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "修復" + } + } + } + }, + "repair_download": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Repair Download?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download reparieren?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを修復しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드를 복구할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "修复下载?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "修復下載?" + } + } + } + }, + "repair_download_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Repair the offline files for this gallery now?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Offline-Dateien dieser Galerie jetzt reparieren?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このギャラリーのオフラインファイルを今すぐ修復しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 갤러리의 오프라인 파일을 지금 복구할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "现在修复此画廊的离线文件吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "現在修復此畫廊的離線檔案嗎?" + } + } + } + }, + "saved_details": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't refresh online details. Showing saved details instead." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Online-Details konnten nicht aktualisiert werden. Stattdessen werden gespeicherte Details angezeigt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンラインの詳細を更新できなかったため、保存済みの詳細を表示しています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法刷新在线详情,现显示已保存的详情。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法重新整理線上詳情,現顯示已儲存的詳情。" + } + } + } + }, + "similar_gallery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Similar Gallery" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ähnliche Galerien" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "類似ギャラリー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비슷한 작품" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "相似画廊" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "類似畫廊" + } + } + } + }, + "torrents": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Torrents" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Torrents" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トレント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토렌트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "种子" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "種子" + } + } + } + }, + "update_download": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Download?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download aktualisieren?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを更新しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드를 업데이트할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新下载?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新下載?" + } + } + } + }, + "update_download_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update this gallery to the newest online version now?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Galerie jetzt auf die neueste Online-Version aktualisieren?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このギャラリーを今すぐオンラインの最新バージョンに更新しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 갤러리를 지금 온라인 최신 버전으로 업데이트할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "现在将此画廊更新到线上最新版本吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "現在將此畫廊更新到線上最新版本嗎?" + } + } + } + }, + "vote_down": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vote down" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dagegen stimmen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "反対票を投じる" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "반대 투표" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "投票反对" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Vote down" + } + } + } + }, + "vote_up": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vote up" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dafür stimmen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "賛成票を投じる" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "찬성 투표" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "投票赞成" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Vote up" + } + } + } + }, + "withdraw_vote": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Withdraw vote" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Stimme zurückziehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "投票を取り消す" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "투표 취소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "撤销投票" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收回評分" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift index 4bed1aac1..937d8d19c 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift @@ -52,7 +52,7 @@ struct TorrentsView: View { .onAppear { store.send(.fetchGalleryTorrents(gid, token)) } - .navigationTitle(L10n.Localizable.TorrentsView.torrents) + .navigationTitle(String(localized: .torrents)) } } } diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index fc61062ab..6f9f5a2d4 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -8,9 +8,6 @@ "toplists_type.all_time" = "Gesamt"; // MARK: Response -"hath_client_not_found" = "Du benötigst einen deinem Konto zugehörigen H@H Client um diese Funktion nutzen zu können"; -"hath_client_not_online" = "Dein H@H Client scheint offline zu sein. Sieh nach, ob er läuft und probier's nochmal"; -"invalid_resolution" = "Die gewünschte Galerie kann nicht in der gewählten Auflösung heruntergeladen werden"; // MARK: Toast @@ -241,108 +238,33 @@ "about_view.acknowledgements" = "Danksagungen"; // MARK: DetailView -"detail_view.read" = "Lesen"; -"detail_view.post_comment" = "Kommentar abgeben"; -"detail_view.accessibility.login" = "Zum Herunterladen anmelden"; -"detail_view.accessibility.download" = "Herunterladen"; -"detail_view.accessibility.queued" = "In Warteschlange"; -"detail_view.accessibility.downloading" = "Lädt %d von %d herunter"; -"detail_view.accessibility.downloaded" = "Heruntergeladene Galerie löschen"; -"detail_view.accessibility.update" = "Download aktualisieren"; -"detail_view.accessibility.retry" = "Download erneut versuchen"; -"detail_view.accessibility.repair" = "Download reparieren"; -"detail_view.accessibility.preparing" = "Download-Informationen werden geladen"; -"detail_view.accessibility.pause_action" = "Download pausieren"; -"detail_view.accessibility.paused" = "Download fortsetzen. Pausiert bei %d von %d"; -"detail_view.accessibility.partial" = "Download erneut versuchen. %d von %d Seiten sind bereits verfügbar."; -"detail_view.archives" = "Archiv"; -"detail_view.torrents" = "Torrents"; "detail_view.share" = "Teilen"; "detail_view.detail" = "Details"; -"detail_view.withdraw_vote" = "Stimme zurückziehen"; -"detail_view.vote_up" = "Dafür stimmen"; -"detail_view.vote_down" = "Dagegen stimmen"; -"detail_view.favorited" = "Favorisiert"; "detail_view.language" = "Sprache"; -"detail_view.ratings" = "%@ Bewertungen"; -"detail_view.page_count" = "Seitenzahl"; -"detail_view.file_size" = "Dateigröße"; -"detail_view.favorited_unit" = "mal"; -"detail_view.page_count_unit" = "Seiten"; -"detail_view.give_a_rating" = "Bewertung abgeben"; -"detail_view.similar_gallery" = "Ähnliche Galerien"; -"detail_view.previews" = "Vorschau"; -"detail_view.comments" = "Kommentar"; "detail_view.delete_download" = "Download löschen?"; -"detail_view.repair_download" = "Download reparieren?"; -"detail_view.update_download" = "Download aktualisieren?"; -"detail_view.redownload_gallery" = "Galerie erneut herunterladen?"; "detail_view.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; -"detail_view.repair_download_description" = "Die Offline-Dateien dieser Galerie jetzt reparieren?"; -"detail_view.update_download_description" = "Diese Galerie jetzt auf die neueste Online-Version aktualisieren?"; -"detail_view.redownload_gallery_description" = "Diese Galerie jetzt vollständig neu herunterladen?"; -"detail_view.repair" = "Reparieren"; "detail_view.update" = "Aktualisieren"; -"detail_view.redownload" = "Erneut laden"; -"detail_view.saved_details" = "Online-Details konnten nicht aktualisiert werden. Stattdessen werden gespeicherte Details angezeigt."; // MARK: ArchivesView -"archives_view.archives" = "Archiv"; -"archives_view.download_to_hath_client" = "Mit H@H Client herunterladen"; // HathArchive "hath_archive.free" = "Frei"; // ArchiveResolution "archive_resolution.original" = "Original"; // MARK: TorrentsView -"torrents_view.torrents" = "Torrents"; // MARK: GalleryInfosView -"gallery_infos_view.gallery_infos" = "Galerie-Infos"; -"gallery_infos_view.id" = "ID"; -"gallery_infos_view.token" = "Token"; -"gallery_infos_view.title" = "Titel"; -"gallery_infos_view.japanese_title" = "Japanischer Titel"; -"gallery_infos_view.gallery_URL" = "Galerie-URL"; -"gallery_infos_view.cover_URL" = "Cover-URL"; -"gallery_infos_view.archive_URL" = "Archiv-URL"; -"gallery_infos_view.torrent_URL" = "Torrent-URL"; -"gallery_infos_view.parent_URL" = "Übergeordnete URL"; -"gallery_infos_view.category" = "Kategorie"; -"gallery_infos_view.uploader" = "Uploader"; -"gallery_infos_view.posted_date" = "Veröffentlicht am"; -"gallery_infos_view.visibility" = "Sichtbarkeit"; -"gallery_infos_view.language" = "Sprache"; -"gallery_infos_view.page_count" = "Seitenzahl"; -"gallery_infos_view.file_size" = "Dateigröße"; -"gallery_infos_view.favorited_times" = "Anzahl Favorisierungen"; -"gallery_infos_view.favorited" = "Favorisiert"; -"gallery_infos_view.rating_count" = "Anzahl Bewertungen"; -"gallery_infos_view.average_rating" = "Durchschnittliche Bewertung"; -"gallery_infos_view.my_rating" = "Meine Bewertung"; -"gallery_infos_view.torrent_count" = "Anzahl Torrents"; -"gallery_infos_view.none" = "Keine"; -"gallery_infos_view.yes" = "Ja"; -"gallery_infos_view.no" = "Nein"; // GalleryVisibility "gallery_visibility.yes" = "Ja"; "gallery_visibility.no" = "Nein (%@)"; "gallery_visibility.expunged" = "Entfernt"; // MARK: TagDetailView -"tag_detail_view.images" = "Bilder"; -"tag_detail_view.links" = "Links"; // MARK: DownloadsView "download_folder_filter.all" = "Alle"; "detail_view.manage_folders" = "Ordner verwalten"; -"detail_view.create_default_folder" = "Standardordner erstellen"; -"detail_view.no_folders" = "Noch keine Ordner"; "downloads_view.manage_folders" = "Ordner verwalten"; -"folder_manager_view.folders" = "Ordner"; -"folder_manager_view.folder_name" = "Ordnername"; -"folder_manager_view.delete_folder" = "Der Ordner und alle heruntergeladenen Galerien darin werden gelöscht."; -"folder_manager_view.empty_folders" = "Von dir erstellte Ordner werden hier angezeigt."; "downloads_view.downloads" = "Downloads"; "downloads_view.delete_download" = "Download löschen?"; "downloads_view.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; @@ -357,14 +279,10 @@ "download_setting_view.network_description" = "Es wird immer nur eine Galerie gleichzeitig heruntergeladen. Mit dieser Einstellung steuerst du, wie viele Galerieseiten parallel geladen werden, ob Mobilfunk erlaubt ist und dass Dateien im Downloads-Ordner der App gespeichert werden."; // MARK: CommentsView -"comments_view.comments" = "Kommentar"; // MARK: PostCommentView -"post_comment_view.post_comment" = "Kommentar abgeben"; -"post_comment_view.edit_comment" = "Kommentar bearbeiten"; // MARK: PreviewsView -"previews_view.previews" = "Vorschau"; // MARK: ReadingView "reading_view.share" = "Teilen"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings index 4a4994a3c..93944e8a4 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings @@ -4,9 +4,6 @@ */ // MARK: Website response -"hath_client_not_found" = "You must have a H@H client assigned to your account to use this feature."; -"hath_client_not_online" = "Your H@H client appears to be offline. Turn it on, then try again."; -"invalid_resolution" = "The requested gallery cannot be downloaded with the selected resolution."; "gallery_unavailable" = "This gallery has been removed or is unavailable."; // MARK: App diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index e22fc2218..432d0c956 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -8,9 +8,6 @@ "toplists_type.all_time" = "All time"; // MARK: Response -"hath_client_not_found" = "You must have a H@H client assigned to your account to use this feature."; -"hath_client_not_online" = "Your H@H client appears to be offline. Turn it on, then try again."; -"invalid_resolution" = "The requested gallery cannot be downloaded with the selected resolution."; // MARK: Toast @@ -241,108 +238,33 @@ "about_view.acknowledgements" = "Acknowledgements"; // MARK: DetailView -"detail_view.read" = "Read"; -"detail_view.post_comment" = "Post comment"; -"detail_view.accessibility.login" = "Log in to download"; -"detail_view.accessibility.download" = "Download"; -"detail_view.accessibility.queued" = "Queued"; -"detail_view.accessibility.downloading" = "Downloading %d of %d"; -"detail_view.accessibility.downloaded" = "Delete downloaded gallery"; -"detail_view.accessibility.update" = "Update download"; -"detail_view.accessibility.retry" = "Retry download"; -"detail_view.accessibility.repair" = "Repair download"; -"detail_view.accessibility.preparing" = "Preparing download"; -"detail_view.accessibility.pause_action" = "Pause download"; -"detail_view.accessibility.paused" = "Resume download. Paused at %d of %d"; -"detail_view.accessibility.partial" = "Retry download. %d of %d pages are already available."; -"detail_view.archives" = "Archives"; -"detail_view.torrents" = "Torrents"; "detail_view.share" = "Share"; "detail_view.detail" = "Detail"; -"detail_view.withdraw_vote" = "Withdraw vote"; -"detail_view.vote_up" = "Vote up"; -"detail_view.vote_down" = "Vote down"; -"detail_view.favorited" = "Favorited"; "detail_view.language" = "Language"; -"detail_view.ratings" = "%@ Ratings"; -"detail_view.page_count" = "Page Count"; -"detail_view.file_size" = "File Size"; -"detail_view.favorited_unit" = "Times"; -"detail_view.page_count_unit" = "Pages"; -"detail_view.give_a_rating" = "Give a Rating"; -"detail_view.similar_gallery" = "Similar Gallery"; -"detail_view.previews" = "Previews"; -"detail_view.comments" = "Comments"; "detail_view.delete_download" = "Delete Download?"; -"detail_view.repair_download" = "Repair Download?"; -"detail_view.update_download" = "Update Download?"; -"detail_view.redownload_gallery" = "Redownload Gallery?"; "detail_view.delete_downloaded_gallery" = "This will remove the downloaded gallery from this device."; -"detail_view.repair_download_description" = "Repair the offline files for this gallery now?"; -"detail_view.update_download_description" = "Update this gallery to the newest online version now?"; -"detail_view.redownload_gallery_description" = "Start a fresh download for this gallery now?"; -"detail_view.repair" = "Repair"; "detail_view.update" = "Update"; -"detail_view.redownload" = "Redownload"; -"detail_view.saved_details" = "Couldn't refresh online details. Showing saved details instead."; // MARK: ArchivesView -"archives_view.archives" = "Archives"; -"archives_view.download_to_hath_client" = "Download To H@H Client"; // HathArchive "hath_archive.free" = "Free"; // ArchiveResolution "archive_resolution.original" = "Original"; // MARK: TorrentsView -"torrents_view.torrents" = "Torrents"; // MARK: GalleryInfosView -"gallery_infos_view.gallery_infos" = "Gallery infos"; -"gallery_infos_view.id" = "ID"; -"gallery_infos_view.token" = "Token"; -"gallery_infos_view.title" = "Title"; -"gallery_infos_view.japanese_title" = "Japanese title"; -"gallery_infos_view.gallery_URL" = "Gallery URL"; -"gallery_infos_view.cover_URL" = "Cover URL"; -"gallery_infos_view.archive_URL" = "Archive URL"; -"gallery_infos_view.torrent_URL" = "Torrent URL"; -"gallery_infos_view.parent_URL" = "Parent URL"; -"gallery_infos_view.category" = "Category"; -"gallery_infos_view.uploader" = "Uploader"; -"gallery_infos_view.posted_date" = "Posted date"; -"gallery_infos_view.visibility" = "Visibility"; -"gallery_infos_view.language" = "Language"; -"gallery_infos_view.page_count" = "Page count"; -"gallery_infos_view.file_size" = "File size"; -"gallery_infos_view.favorited_times" = "Favorited times"; -"gallery_infos_view.favorited" = "Favorited"; -"gallery_infos_view.rating_count" = "Rating count"; -"gallery_infos_view.average_rating" = "Average rating"; -"gallery_infos_view.my_rating" = "My rating"; -"gallery_infos_view.torrent_count" = "Torrent count"; -"gallery_infos_view.none" = "None"; -"gallery_infos_view.yes" = "Yes"; -"gallery_infos_view.no" = "No"; // GalleryVisibility "gallery_visibility.yes" = "Yes"; "gallery_visibility.no" = "No (%@)"; "gallery_visibility.expunged" = "Expunged"; // MARK: TagDetailView -"tag_detail_view.images" = "Images"; -"tag_detail_view.links" = "Links"; // MARK: DownloadsView "download_folder_filter.all" = "All"; "detail_view.manage_folders" = "Manage Folders"; -"detail_view.create_default_folder" = "Create Default Folder"; -"detail_view.no_folders" = "No folders yet"; "downloads_view.manage_folders" = "Manage Folders"; -"folder_manager_view.folders" = "Folders"; -"folder_manager_view.folder_name" = "Folder name"; -"folder_manager_view.delete_folder" = "This will delete the folder and all downloaded galleries inside it."; -"folder_manager_view.empty_folders" = "Folders you create will appear here."; "downloads_view.downloads" = "Downloads"; "downloads_view.delete_download" = "Delete Download?"; "downloads_view.delete_downloaded_gallery" = "This will remove the downloaded gallery from this device."; @@ -357,14 +279,10 @@ "download_setting_view.network_description" = "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder."; // MARK: CommentsView -"comments_view.comments" = "Comments"; // MARK: PostCommentView -"post_comment_view.post_comment" = "Post comment"; -"post_comment_view.edit_comment" = "Edit comment"; // MARK: PreviewsView -"previews_view.previews" = "Previews"; // MARK: ReadingView "reading_view.share" = "Share"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index f7d92b40c..af474f54d 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -8,9 +8,6 @@ "toplists_type.all_time" = "すべて"; // MARK: Response -"hath_client_not_found" = "この機能を使うには、アカウントに関連付けられている H@H クライアントが必要です"; -"hath_client_not_online" = "H@H クライアントは現在オフラインのようです、起動してからもう一度お試しください"; -"invalid_resolution" = "このギャラリーは選択された解像度ではダウンロードできません"; // MARK: Toast @@ -241,108 +238,33 @@ "about_view.acknowledgements" = "謝辞"; // MARK: DetailView -"detail_view.read" = "閲覧"; -"detail_view.post_comment" = "コメントを書く"; -"detail_view.accessibility.login" = "ダウンロードするにはログインが必要です"; -"detail_view.accessibility.download" = "ダウンロード"; -"detail_view.accessibility.queued" = "ダウンロード待ち"; -"detail_view.accessibility.downloading" = "%d / %d ページをダウンロード中"; -"detail_view.accessibility.downloaded" = "ダウンロード済みのギャラリーを削除"; -"detail_view.accessibility.update" = "ダウンロードを更新"; -"detail_view.accessibility.retry" = "ダウンロードを再試行"; -"detail_view.accessibility.repair" = "ダウンロードを修復"; -"detail_view.accessibility.preparing" = "ダウンロード情報を取得中"; -"detail_view.accessibility.pause_action" = "ダウンロードを一時停止"; -"detail_view.accessibility.paused" = "ダウンロードを再開。%d / %d ページで停止中"; -"detail_view.accessibility.partial" = "ダウンロードを再試行。すでに %d / %d ページが利用可能です。"; -"detail_view.archives" = "アーカイブ"; -"detail_view.torrents" = "トレント"; "detail_view.share" = "共有"; "detail_view.detail" = "詳細"; -"detail_view.withdraw_vote" = "投票を取り消す"; -"detail_view.vote_up" = "賛成票を投じる"; -"detail_view.vote_down" = "反対票を投じる"; -"detail_view.favorited" = "気に入り"; "detail_view.language" = "言語"; -"detail_view.ratings" = "%@ 件の評価"; -"detail_view.page_count" = "ページ数"; -"detail_view.file_size" = "ファイルサイズ"; -"detail_view.favorited_unit" = "回"; -"detail_view.page_count_unit" = "ページ"; -"detail_view.give_a_rating" = "評価する"; -"detail_view.similar_gallery" = "類似ギャラリー"; -"detail_view.previews" = "プレビュー"; -"detail_view.comments" = "コメント"; "detail_view.delete_download" = "ダウンロードを削除しますか?"; -"detail_view.repair_download" = "ダウンロードを修復しますか?"; -"detail_view.update_download" = "ダウンロードを更新しますか?"; -"detail_view.redownload_gallery" = "ギャラリーを再ダウンロードしますか?"; "detail_view.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; -"detail_view.repair_download_description" = "このギャラリーのオフラインファイルを今すぐ修復しますか?"; -"detail_view.update_download_description" = "このギャラリーを今すぐオンラインの最新バージョンに更新しますか?"; -"detail_view.redownload_gallery_description" = "このギャラリーを今すぐ最初から再ダウンロードしますか?"; -"detail_view.repair" = "修復"; "detail_view.update" = "更新"; -"detail_view.redownload" = "再ダウンロード"; -"detail_view.saved_details" = "オンラインの詳細を更新できなかったため、保存済みの詳細を表示しています。"; // MARK: ArchivesView -"archives_view.archives" = "アーカイブ"; -"archives_view.download_to_hath_client" = "H@H クライアントにダウンロード"; // HathArchive "hath_archive.free" = "無料"; // ArchiveResolution "archive_resolution.original" = "オリジナル"; // MARK: TorrentsView -"torrents_view.torrents" = "トレント"; // MARK: GalleryInfosView -"gallery_infos_view.gallery_infos" = "ギャラリー情報"; -"gallery_infos_view.id" = "ID"; -"gallery_infos_view.token" = "Token"; -"gallery_infos_view.title" = "タイトル"; -"gallery_infos_view.japanese_title" = "日本語タイトル"; -"gallery_infos_view.gallery_URL" = "ギャラリーリンク"; -"gallery_infos_view.cover_URL" = "カバーリンク"; -"gallery_infos_view.archive_URL" = "アーカイブリンク"; -"gallery_infos_view.torrent_URL" = "トレントリンク"; -"gallery_infos_view.parent_URL" = "親ギャラリーリンク"; -"gallery_infos_view.category" = "カテゴリー"; -"gallery_infos_view.uploader" = "アップローダー"; -"gallery_infos_view.posted_date" = "投稿日付"; -"gallery_infos_view.visibility" = "可視"; -"gallery_infos_view.language" = "言語"; -"gallery_infos_view.page_count" = "ページ数"; -"gallery_infos_view.file_size" = "ファイルサイズ"; -"gallery_infos_view.favorited_times" = "気に入り数"; -"gallery_infos_view.favorited" = "お気に入り済み"; -"gallery_infos_view.rating_count" = "評価数"; -"gallery_infos_view.average_rating" = "平均評価"; -"gallery_infos_view.my_rating" = "自分の評価"; -"gallery_infos_view.torrent_count" = "トレント数"; -"gallery_infos_view.none" = "なし"; -"gallery_infos_view.yes" = "はい"; -"gallery_infos_view.no" = "いいえ"; // GalleryVisibility "gallery_visibility.yes" = "はい"; "gallery_visibility.no" = "いいえ (%@)"; "gallery_visibility.expunged" = "削除済み"; // MARK: TagDetailView -"tag_detail_view.images" = "画像"; -"tag_detail_view.links" = "リンク"; // MARK: DownloadsView "download_folder_filter.all" = "すべて"; "detail_view.manage_folders" = "フォルダを管理"; -"detail_view.create_default_folder" = "デフォルトフォルダを作成"; -"detail_view.no_folders" = "フォルダはまだありません"; "downloads_view.manage_folders" = "フォルダを管理"; -"folder_manager_view.folders" = "フォルダ"; -"folder_manager_view.folder_name" = "フォルダ名"; -"folder_manager_view.delete_folder" = "フォルダとその中のダウンロード済みギャラリーをすべて削除します。"; -"folder_manager_view.empty_folders" = "作成したフォルダはここに表示されます。"; "downloads_view.downloads" = "ダウンロード"; "downloads_view.delete_download" = "ダウンロードを削除しますか?"; "downloads_view.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; @@ -357,14 +279,10 @@ "download_setting_view.network_description" = "一度にダウンロードされるギャラリーは 1 件だけです。この設定では、1 つのギャラリー内で同時にダウンロードするページ数、モバイル通信の許可または禁止、そしてファイルをアプリの Downloads フォルダに保存する動作を管理します。"; // MARK: CommentsView -"comments_view.comments" = "コメント"; // MARK: PostCommentView -"post_comment_view.post_comment" = "コメントを書く"; -"post_comment_view.edit_comment" = "コメントを編集"; // MARK: PreviewsView -"previews_view.previews" = "プレビュー"; // MARK: ReadingView "reading_view.share" = "共有"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index b66e26525..0937e701a 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -8,9 +8,6 @@ "toplists_type.all_time" = "전체"; // MARK: Response -"hath_client_not_found" = "H@H 클라이언트를 아이디에 연동시킨 후 사용해주세요."; -"hath_client_not_online" = "H@H 클라이언트가 오프라인인 것 같네요. 클라이언트를 켜고 다시 시도해주세요."; -"invalid_resolution" = "이 콘텐츠는 선택한 해상도로 다운로드할 수 없어요."; // MARK: Toast @@ -241,108 +238,33 @@ "about_view.acknowledgements" = "도움을 주신 분들"; // MARK: DetailView -"detail_view.read" = "읽기"; -"detail_view.post_comment" = "평가 남기기"; -"detail_view.accessibility.login" = "다운로드하려면 로그인해야 합니다"; -"detail_view.accessibility.download" = "다운로드"; -"detail_view.accessibility.queued" = "다운로드 대기 중"; -"detail_view.accessibility.downloading" = "%d / %d 페이지 다운로드 중"; -"detail_view.accessibility.downloaded" = "다운로드한 갤러리 삭제"; -"detail_view.accessibility.update" = "다운로드 업데이트"; -"detail_view.accessibility.retry" = "다운로드 다시 시도"; -"detail_view.accessibility.repair" = "다운로드 복구"; -"detail_view.accessibility.preparing" = "다운로드 정보를 불러오는 중"; -"detail_view.accessibility.pause_action" = "다운로드 일시 정지"; -"detail_view.accessibility.paused" = "다운로드 다시 시작. %d / %d 페이지에서 일시 정지됨"; -"detail_view.accessibility.partial" = "다운로드 다시 시도. 이미 %d / %d 페이지를 사용할 수 있습니다."; -"detail_view.archives" = "아카이브"; -"detail_view.torrents" = "토렌트"; "detail_view.share" = "공유"; "detail_view.detail" = "세부 정보"; -"detail_view.withdraw_vote" = "투표 취소"; -"detail_view.vote_up" = "찬성 투표"; -"detail_view.vote_down" = "반대 투표"; -"detail_view.favorited" = "즐겨찾기"; "detail_view.language" = "언어"; -"detail_view.ratings" = "%@명의 별점"; -"detail_view.page_count" = "페이지 수"; -"detail_view.file_size" = "파일 크기"; -"detail_view.favorited_unit" = "번"; -"detail_view.page_count_unit" = "페이지"; -"detail_view.give_a_rating" = "별점 주기"; -"detail_view.similar_gallery" = "비슷한 작품"; -"detail_view.previews" = "미리보기"; -"detail_view.comments" = "댓글"; "detail_view.delete_download" = "다운로드를 삭제할까요?"; -"detail_view.repair_download" = "다운로드를 복구할까요?"; -"detail_view.update_download" = "다운로드를 업데이트할까요?"; -"detail_view.redownload_gallery" = "갤러리를 다시 다운로드할까요?"; "detail_view.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; -"detail_view.repair_download_description" = "이 갤러리의 오프라인 파일을 지금 복구할까요?"; -"detail_view.update_download_description" = "이 갤러리를 지금 온라인 최신 버전으로 업데이트할까요?"; -"detail_view.redownload_gallery_description" = "이 갤러리를 지금 처음부터 다시 다운로드할까요?"; -"detail_view.repair" = "복구"; "detail_view.update" = "업데이트"; -"detail_view.redownload" = "다시 다운로드"; -"detail_view.saved_details" = "온라인 세부 정보를 새로고침할 수 없어 저장된 세부 정보를 표시합니다."; // MARK: ArchivesView -"archives_view.archives" = "아카이브"; -"archives_view.download_to_hath_client" = "H@H 클라이언트로 저장"; // HathArchive "hath_archive.free" = "무료"; // ArchiveResolution "archive_resolution.original" = "원본"; // MARK: TorrentsView -"torrents_view.torrents" = "토렌트"; // MARK: GalleryInfosView -"gallery_infos_view.gallery_infos" = "갤러리 정보"; -"gallery_infos_view.id" = "ID"; -"gallery_infos_view.token" = "토큰"; -"gallery_infos_view.title" = "제목"; -"gallery_infos_view.japanese_title" = "일본어 제목"; -"gallery_infos_view.gallery_URL" = "갤러리 주소"; -"gallery_infos_view.cover_URL" = "표지 주소"; -"gallery_infos_view.archive_URL" = "아카이브 주소"; -"gallery_infos_view.torrent_URL" = "토렌트 주소"; -"gallery_infos_view.parent_URL" = "부모 갤러리 링크"; -"gallery_infos_view.category" = "장르"; -"gallery_infos_view.uploader" = "업로드"; -"gallery_infos_view.posted_date" = "업로드된 날짜"; -"gallery_infos_view.visibility" = "가시성"; -"gallery_infos_view.language" = "언어"; -"gallery_infos_view.page_count" = "페이지 수"; -"gallery_infos_view.file_size" = "파일 크기"; -"gallery_infos_view.favorited_times" = "즐겨찾기된 수"; -"gallery_infos_view.favorited" = "즐겨찾기에 저장 됨"; -"gallery_infos_view.rating_count" = "별점 갯수"; -"gallery_infos_view.average_rating" = "평균 별점"; -"gallery_infos_view.my_rating" = "내 별점"; -"gallery_infos_view.torrent_count" = "토렌트 수"; -"gallery_infos_view.none" = "없음"; -"gallery_infos_view.yes" = "네"; -"gallery_infos_view.no" = "아니요"; // GalleryVisibility "gallery_visibility.yes" = "네"; "gallery_visibility.no" = "아니요 (%@)"; "gallery_visibility.expunged" = "삭제됨"; // MARK: TagDetailView -"tag_detail_view.images" = "이미지"; -"tag_detail_view.links" = "링크"; // MARK: DownloadsView "download_folder_filter.all" = "전체"; "detail_view.manage_folders" = "폴더 관리"; -"detail_view.create_default_folder" = "기본 폴더 만들기"; -"detail_view.no_folders" = "아직 폴더가 없습니다"; "downloads_view.manage_folders" = "폴더 관리"; -"folder_manager_view.folders" = "폴더"; -"folder_manager_view.folder_name" = "폴더 이름"; -"folder_manager_view.delete_folder" = "폴더와 그 안에 다운로드한 모든 갤러리를 삭제합니다."; -"folder_manager_view.empty_folders" = "만든 폴더가 여기에 표시됩니다."; "downloads_view.downloads" = "다운로드"; "downloads_view.delete_download" = "다운로드를 삭제할까요?"; "downloads_view.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; @@ -357,14 +279,10 @@ "download_setting_view.network_description" = "한 번에 하나의 갤러리만 다운로드됩니다. 이 설정으로 한 갤러리 안에서 동시에 다운로드할 페이지 수, 셀룰러 다운로드 허용 여부, 그리고 파일을 앱의 Downloads 폴더에 저장하는 방식을 제어합니다."; // MARK: CommentsView -"comments_view.comments" = "댓글"; // MARK: PostCommentView -"post_comment_view.post_comment" = "평가 남기기"; -"post_comment_view.edit_comment" = "평가 수정"; // MARK: PreviewsView -"previews_view.previews" = "미리보기"; // MARK: ReadingView "reading_view.share" = "공유"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 784bc0f72..cb40cbab0 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -8,9 +8,6 @@ "toplists_type.all_time" = "全部"; // MARK: Response -"hath_client_not_found" = "你需要一个关联到账户的 H@H 客户端才能使用这个功能"; -"hath_client_not_online" = "你的 H@H 客户端似乎处于离线状态,请启动它后再试"; -"invalid_resolution" = "该画廊不能以选中的分辨率下载"; // MARK: Toast @@ -241,108 +238,33 @@ "about_view.acknowledgements" = "致谢"; // MARK: DetailView -"detail_view.read" = "阅读"; -"detail_view.post_comment" = "发布评论"; -"detail_view.accessibility.login" = "登录后即可下载"; -"detail_view.accessibility.download" = "下载"; -"detail_view.accessibility.queued" = "已加入下载队列"; -"detail_view.accessibility.downloading" = "正在下载第 %d / %d 页"; -"detail_view.accessibility.downloaded" = "删除已下载画廊"; -"detail_view.accessibility.update" = "更新下载内容"; -"detail_view.accessibility.retry" = "重新下载"; -"detail_view.accessibility.repair" = "修复下载文件"; -"detail_view.accessibility.preparing" = "正在获取下载信息"; -"detail_view.accessibility.pause_action" = "暂停下载"; -"detail_view.accessibility.paused" = "继续下载,当前暂停在第 %d / %d 页"; -"detail_view.accessibility.partial" = "重新下载,已有 %d / %d 页可用。"; -"detail_view.archives" = "归档"; -"detail_view.torrents" = "种子"; "detail_view.share" = "分享"; "detail_view.detail" = "详情"; -"detail_view.withdraw_vote" = "撤销投票"; -"detail_view.vote_up" = "投票赞成"; -"detail_view.vote_down" = "投票反对"; -"detail_view.favorited" = "收藏"; "detail_view.language" = "语言"; -"detail_view.ratings" = "%@ 个评分"; -"detail_view.page_count" = "页数"; -"detail_view.file_size" = "文件大小"; -"detail_view.favorited_unit" = "次"; -"detail_view.page_count_unit" = "页"; -"detail_view.give_a_rating" = "给予评分"; -"detail_view.similar_gallery" = "相似画廊"; -"detail_view.previews" = "预览"; -"detail_view.comments" = "评论"; "detail_view.delete_download" = "删除下载?"; -"detail_view.repair_download" = "修复下载?"; -"detail_view.update_download" = "更新下载?"; -"detail_view.redownload_gallery" = "重新下载画廊?"; "detail_view.delete_downloaded_gallery" = "这将从此设备移除已下载的画廊。"; -"detail_view.repair_download_description" = "现在修复此画廊的离线文件吗?"; -"detail_view.update_download_description" = "现在将此画廊更新到线上最新版本吗?"; -"detail_view.redownload_gallery_description" = "现在重新完整下载此画廊吗?"; -"detail_view.repair" = "修复"; "detail_view.update" = "更新"; -"detail_view.redownload" = "重新下载"; -"detail_view.saved_details" = "无法刷新在线详情,现显示已保存的详情。"; // MARK: ArchivesView -"archives_view.archives" = "归档"; -"archives_view.download_to_hath_client" = "下载到 H@H 客户端"; // HathArchive "hath_archive.free" = "免费"; // ArchiveResolution "archive_resolution.original" = "原始分辨率"; // MARK: TorrentsView -"torrents_view.torrents" = "种子"; // MARK: GalleryInfosView -"gallery_infos_view.gallery_infos" = "画廊信息"; -"gallery_infos_view.id" = "ID"; -"gallery_infos_view.token" = "Token"; -"gallery_infos_view.title" = "标题"; -"gallery_infos_view.japanese_title" = "日文标题"; -"gallery_infos_view.gallery_URL" = "画廊链接"; -"gallery_infos_view.cover_URL" = "封面链接"; -"gallery_infos_view.archive_URL" = "归档链接"; -"gallery_infos_view.torrent_URL" = "种子链接"; -"gallery_infos_view.parent_URL" = "上游画廊链接"; -"gallery_infos_view.category" = "分类"; -"gallery_infos_view.uploader" = "上传者"; -"gallery_infos_view.posted_date" = "发布日期"; -"gallery_infos_view.visibility" = "可见"; -"gallery_infos_view.language" = "语言"; -"gallery_infos_view.page_count" = "页数"; -"gallery_infos_view.file_size" = "文件大小"; -"gallery_infos_view.favorited_times" = "收藏次数"; -"gallery_infos_view.favorited" = "已收藏"; -"gallery_infos_view.rating_count" = "评分次数"; -"gallery_infos_view.average_rating" = "平均评分"; -"gallery_infos_view.my_rating" = "我的评分"; -"gallery_infos_view.torrent_count" = "种子个数"; -"gallery_infos_view.none" = "无"; -"gallery_infos_view.yes" = "是"; -"gallery_infos_view.no" = "否"; // GalleryVisibility "gallery_visibility.yes" = "是"; "gallery_visibility.no" = "否 (%@)"; "gallery_visibility.expunged" = "已删除"; // MARK: TagDetailView -"tag_detail_view.images" = "图片"; -"tag_detail_view.links" = "链接"; // MARK: DownloadsView "download_folder_filter.all" = "全部"; "detail_view.manage_folders" = "管理文件夹"; -"detail_view.create_default_folder" = "创建默认文件夹"; -"detail_view.no_folders" = "还没有文件夹"; "downloads_view.manage_folders" = "管理文件夹"; -"folder_manager_view.folders" = "文件夹"; -"folder_manager_view.folder_name" = "文件夹名称"; -"folder_manager_view.delete_folder" = "这将删除该文件夹及其中所有已下载的画廊。"; -"folder_manager_view.empty_folders" = "创建的文件夹会显示在这里。"; "downloads_view.downloads" = "下载"; "downloads_view.delete_download" = "删除下载?"; "downloads_view.delete_downloaded_gallery" = "这将从此设备移除已下载的画廊。"; @@ -357,14 +279,10 @@ "download_setting_view.network_description" = "每次只会下载一个画廊。这个设置用于控制单个画廊内页面的并行下载数量、是否允许蜂窝网络下载,以及文件在应用 Downloads 文件夹中的存储方式。"; // MARK: CommentsView -"comments_view.comments" = "评论"; // MARK: PostCommentView -"post_comment_view.post_comment" = "发布评论"; -"post_comment_view.edit_comment" = "编辑评论"; // MARK: PreviewsView -"previews_view.previews" = "预览"; // MARK: ReadingView "reading_view.share" = "分享"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index f7f89c302..70c213b41 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -8,9 +8,6 @@ "toplists_type.all_time" = "所有時間"; // MARK: Response -"hath_client_not_found" = "你需要一個連結到帳號的 H@H 用戶端才能使用這個功能"; -"hath_client_not_online" = "你的 H@H 用戶端為離線狀態,請啟動後再試"; -"invalid_resolution" = "該畫廊不能以目前選擇的解析度下載"; // MARK: Toast @@ -241,108 +238,33 @@ "about_view.acknowledgements" = "致謝"; // MARK: DetailView -"detail_view.read" = "閱讀"; -"detail_view.post_comment" = "發表留言"; -"detail_view.accessibility.login" = "登入後即可下載"; -"detail_view.accessibility.download" = "下載"; -"detail_view.accessibility.queued" = "已加入下載佇列"; -"detail_view.accessibility.downloading" = "正在下載第 %d / %d 頁"; -"detail_view.accessibility.downloaded" = "刪除已下載畫廊"; -"detail_view.accessibility.update" = "更新下載內容"; -"detail_view.accessibility.retry" = "重新下載"; -"detail_view.accessibility.repair" = "修復下載檔案"; -"detail_view.accessibility.preparing" = "正在取得下載資訊"; -"detail_view.accessibility.pause_action" = "暫停下載"; -"detail_view.accessibility.paused" = "繼續下載,目前暫停在第 %d / %d 頁"; -"detail_view.accessibility.partial" = "重新下載,已有 %d / %d 頁可用。"; -"detail_view.archives" = "存檔至 H@H 用戶端"; -"detail_view.torrents" = "種子"; "detail_view.share" = "分享"; "detail_view.detail" = "Detail"; -"detail_view.withdraw_vote" = "收回評分"; -"detail_view.vote_up" = "Vote up"; -"detail_view.vote_down" = "Vote down"; -"detail_view.favorited" = "收藏"; "detail_view.language" = "語言"; -"detail_view.ratings" = "%@ 個評分"; -"detail_view.page_count" = "頁數"; -"detail_view.file_size" = "檔案大小"; -"detail_view.favorited_unit" = "次"; -"detail_view.page_count_unit" = "頁"; -"detail_view.give_a_rating" = "給予評分"; -"detail_view.similar_gallery" = "類似畫廊"; -"detail_view.previews" = "預覽"; -"detail_view.comments" = "留言"; "detail_view.delete_download" = "刪除下載?"; -"detail_view.repair_download" = "修復下載?"; -"detail_view.update_download" = "更新下載?"; -"detail_view.redownload_gallery" = "重新下載畫廊?"; "detail_view.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"detail_view.repair_download_description" = "現在修復此畫廊的離線檔案嗎?"; -"detail_view.update_download_description" = "現在將此畫廊更新到線上最新版本嗎?"; -"detail_view.redownload_gallery_description" = "現在重新完整下載此畫廊嗎?"; -"detail_view.repair" = "修復"; "detail_view.update" = "更新"; -"detail_view.redownload" = "重新下載"; -"detail_view.saved_details" = "無法重新整理線上詳情,現顯示已儲存的詳情。"; // MARK: ArchivesView -"archives_view.archives" = "存檔"; -"archives_view.download_to_hath_client" = "下載至 H@H 用戶端"; // HathArchive "hath_archive.free" = "免費"; // ArchiveResolution "archive_resolution.original" = "原始畫質"; // MARK: TorrentsView -"torrents_view.torrents" = "種子"; // MARK: GalleryInfosView -"gallery_infos_view.gallery_infos" = "畫廊資訊"; -"gallery_infos_view.id" = "畫廊 ID"; -"gallery_infos_view.token" = "Token"; -"gallery_infos_view.title" = "標題"; -"gallery_infos_view.japanese_title" = "日文標題"; -"gallery_infos_view.gallery_URL" = "畫廊 URL"; -"gallery_infos_view.cover_URL" = "封面 URL"; -"gallery_infos_view.archive_URL" = "存檔 URL"; -"gallery_infos_view.torrent_URL" = "種子 URL"; -"gallery_infos_view.parent_URL" = "Parent URL"; -"gallery_infos_view.category" = "類別"; -"gallery_infos_view.uploader" = "上傳者"; -"gallery_infos_view.posted_date" = "發布日期"; -"gallery_infos_view.visibility" = "能見度"; -"gallery_infos_view.language" = "語言"; -"gallery_infos_view.page_count" = "頁數"; -"gallery_infos_view.file_size" = "檔案大小"; -"gallery_infos_view.favorited_times" = "被收藏次數"; -"gallery_infos_view.favorited" = "已收藏"; -"gallery_infos_view.rating_count" = "被評分次數"; -"gallery_infos_view.average_rating" = "平均評分"; -"gallery_infos_view.my_rating" = "我的評分"; -"gallery_infos_view.torrent_count" = "種子數量"; -"gallery_infos_view.none" = "None"; -"gallery_infos_view.yes" = "Yes"; -"gallery_infos_view.no" = "No"; // GalleryVisibility "gallery_visibility.yes" = "Yes"; "gallery_visibility.no" = "No (%@)"; "gallery_visibility.expunged" = "已被刪除"; // MARK: TagDetailView -"tag_detail_view.images" = "圖片"; -"tag_detail_view.links" = "連結"; // MARK: DownloadsView "download_folder_filter.all" = "全部"; "detail_view.manage_folders" = "管理資料夾"; -"detail_view.create_default_folder" = "建立預設資料夾"; -"detail_view.no_folders" = "還沒有資料夾"; "downloads_view.manage_folders" = "管理資料夾"; -"folder_manager_view.folders" = "資料夾"; -"folder_manager_view.folder_name" = "資料夾名稱"; -"folder_manager_view.delete_folder" = "這將刪除此資料夾及其中所有已下載的畫廊。"; -"folder_manager_view.empty_folders" = "建立的資料夾會顯示在這裡。"; "downloads_view.downloads" = "下載"; "downloads_view.delete_download" = "刪除下載?"; "downloads_view.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; @@ -357,14 +279,10 @@ "download_setting_view.network_description" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許行動網路下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; // MARK: CommentsView -"comments_view.comments" = "留言"; // MARK: PostCommentView -"post_comment_view.post_comment" = "發表留言"; -"post_comment_view.edit_comment" = "編輯留言"; // MARK: PreviewsView -"previews_view.previews" = "預覽"; // MARK: ReadingView "reading_view.share" = "分享"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 98296cb55..4f3f2ca42 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -13,15 +13,9 @@ public enum L10n { public enum Constant { /// Copyright © 2026 EhPanda Team public static let copyright = L10n.tr("Constant", "copyright", fallback: "Copyright © 2026 EhPanda Team") - /// This gallery has been removed or is unavailable. - public static let galleryUnavailable = L10n.tr("Constant", "gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") /// Constant.strings /// EhPanda - public static let hathClientNotFound = L10n.tr("Constant", "hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") - /// Your H@H client appears to be offline. Turn it on, then try again. - public static let hathClientNotOnline = L10n.tr("Constant", "hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") - /// The requested gallery cannot be downloaded with the selected resolution. - public static let invalidResolution = L10n.tr("Constant", "invalid_resolution", fallback: "The requested gallery cannot be downloaded with the selected resolution.") + public static let galleryUnavailable = L10n.tr("Constant", "gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") public enum Acknowledgement { /// Colorful public static let colorful = L10n.tr("Constant", "acknowledgement.colorful", fallback: "Colorful") @@ -156,12 +150,6 @@ public enum L10n { public enum Localizable { /// Show Filtered Removal Count public static let ehSettingViewfilteredRemovalCount = L10n.tr("Localizable", "eh_setting_viewfiltered_removal_count", fallback: "Show Filtered Removal Count") - /// You must have a H@H client assigned to your account to use this feature. - public static let hathClientNotFound = L10n.tr("Localizable", "hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") - /// Your H@H client appears to be offline. Turn it on, then try again. - public static let hathClientNotOnline = L10n.tr("Localizable", "hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") - /// The requested gallery cannot be downloaded with the selected resolution. - public static let invalidResolution = L10n.tr("Localizable", "invalid_resolution", fallback: "The requested gallery cannot be downloaded with the selected resolution.") /// Login public static let notLoginViewlogin = L10n.tr("Localizable", "not_login_viewlogin", fallback: "Login") public enum AboutView { @@ -307,12 +295,6 @@ public enum L10n { /// Original public static let original = L10n.tr("Localizable", "archive_resolution.original", fallback: "Original") } - public enum ArchivesView { - /// Archives - public static let archives = L10n.tr("Localizable", "archives_view.archives", fallback: "Archives") - /// Download To H@H Client - public static let downloadToHathClient = L10n.tr("Localizable", "archives_view.download_to_hath_client", fallback: "Download To H@H Client") - } public enum AutoLockPolicy { /// Instantly public static let instantly = L10n.tr("Localizable", "auto_lock_policy.instantly", fallback: "Instantly") @@ -861,10 +843,6 @@ public enum L10n { /// Recent comments first public static let recent = L10n.tr("Localizable", "comments_sort_order.recent", fallback: "Recent comments first") } - public enum CommentsView { - /// Comments - public static let comments = L10n.tr("Localizable", "comments_view.comments", fallback: "Comments") - } public enum CommentsVotesShowTiming { /// Always public static let always = L10n.tr("Localizable", "comments_votes_show_timing.always", fallback: "Always") @@ -938,110 +916,20 @@ public enum L10n { public static let dateSeek = L10n.tr("Localizable", "date_seek_view.date_seek", fallback: "Seek to date") } public enum DetailView { - /// Archives - public static let archives = L10n.tr("Localizable", "detail_view.archives", fallback: "Archives") - /// Comments - public static let comments = L10n.tr("Localizable", "detail_view.comments", fallback: "Comments") - /// Create Default Folder - public static let createDefaultFolder = L10n.tr("Localizable", "detail_view.create_default_folder", fallback: "Create Default Folder") /// Delete Download? public static let deleteDownload = L10n.tr("Localizable", "detail_view.delete_download", fallback: "Delete Download?") /// This will remove the downloaded gallery from this device. public static let deleteDownloadedGallery = L10n.tr("Localizable", "detail_view.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") /// Detail public static let detail = L10n.tr("Localizable", "detail_view.detail", fallback: "Detail") - /// Favorited - public static let favorited = L10n.tr("Localizable", "detail_view.favorited", fallback: "Favorited") - /// Times - public static let favoritedUnit = L10n.tr("Localizable", "detail_view.favorited_unit", fallback: "Times") - /// File Size - public static let fileSize = L10n.tr("Localizable", "detail_view.file_size", fallback: "File Size") - /// Give a Rating - public static let giveARating = L10n.tr("Localizable", "detail_view.give_a_rating", fallback: "Give a Rating") /// Language public static let language = L10n.tr("Localizable", "detail_view.language", fallback: "Language") /// Manage Folders public static let manageFolders = L10n.tr("Localizable", "detail_view.manage_folders", fallback: "Manage Folders") - /// No folders yet - public static let noFolders = L10n.tr("Localizable", "detail_view.no_folders", fallback: "No folders yet") - /// Page Count - public static let pageCount = L10n.tr("Localizable", "detail_view.page_count", fallback: "Page Count") - /// Pages - public static let pageCountUnit = L10n.tr("Localizable", "detail_view.page_count_unit", fallback: "Pages") - /// Post comment - public static let postComment = L10n.tr("Localizable", "detail_view.post_comment", fallback: "Post comment") - /// Previews - public static let previews = L10n.tr("Localizable", "detail_view.previews", fallback: "Previews") - /// %@ Ratings - public static func ratings(_ p1: Any) -> String { - return L10n.tr("Localizable", "detail_view.ratings", String(describing: p1), fallback: "%@ Ratings") - } - /// Read - public static let read = L10n.tr("Localizable", "detail_view.read", fallback: "Read") - /// Redownload - public static let redownload = L10n.tr("Localizable", "detail_view.redownload", fallback: "Redownload") - /// Redownload Gallery? - public static let redownloadGallery = L10n.tr("Localizable", "detail_view.redownload_gallery", fallback: "Redownload Gallery?") - /// Start a fresh download for this gallery now? - public static let redownloadGalleryDescription = L10n.tr("Localizable", "detail_view.redownload_gallery_description", fallback: "Start a fresh download for this gallery now?") - /// Repair - public static let repair = L10n.tr("Localizable", "detail_view.repair", fallback: "Repair") - /// Repair Download? - public static let repairDownload = L10n.tr("Localizable", "detail_view.repair_download", fallback: "Repair Download?") - /// Repair the offline files for this gallery now? - public static let repairDownloadDescription = L10n.tr("Localizable", "detail_view.repair_download_description", fallback: "Repair the offline files for this gallery now?") - /// Couldn't refresh online details. Showing saved details instead. - public static let savedDetails = L10n.tr("Localizable", "detail_view.saved_details", fallback: "Couldn't refresh online details. Showing saved details instead.") /// Share public static let share = L10n.tr("Localizable", "detail_view.share", fallback: "Share") - /// Similar Gallery - public static let similarGallery = L10n.tr("Localizable", "detail_view.similar_gallery", fallback: "Similar Gallery") - /// Torrents - public static let torrents = L10n.tr("Localizable", "detail_view.torrents", fallback: "Torrents") /// Update public static let update = L10n.tr("Localizable", "detail_view.update", fallback: "Update") - /// Update Download? - public static let updateDownload = L10n.tr("Localizable", "detail_view.update_download", fallback: "Update Download?") - /// Update this gallery to the newest online version now? - public static let updateDownloadDescription = L10n.tr("Localizable", "detail_view.update_download_description", fallback: "Update this gallery to the newest online version now?") - /// Vote down - public static let voteDown = L10n.tr("Localizable", "detail_view.vote_down", fallback: "Vote down") - /// Vote up - public static let voteUp = L10n.tr("Localizable", "detail_view.vote_up", fallback: "Vote up") - /// Withdraw vote - public static let withdrawVote = L10n.tr("Localizable", "detail_view.withdraw_vote", fallback: "Withdraw vote") - public enum Accessibility { - /// Download - public static let download = L10n.tr("Localizable", "detail_view.accessibility.download", fallback: "Download") - /// Delete downloaded gallery - public static let downloaded = L10n.tr("Localizable", "detail_view.accessibility.downloaded", fallback: "Delete downloaded gallery") - /// Downloading %d of %d - public static func downloading(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "detail_view.accessibility.downloading", p1, p2, fallback: "Downloading %d of %d") - } - /// Log in to download - public static let login = L10n.tr("Localizable", "detail_view.accessibility.login", fallback: "Log in to download") - /// Retry download. %d of %d pages are already available. - public static func partial(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "detail_view.accessibility.partial", p1, p2, fallback: "Retry download. %d of %d pages are already available.") - } - /// Pause download - public static let pauseAction = L10n.tr("Localizable", "detail_view.accessibility.pause_action", fallback: "Pause download") - /// Resume download. Paused at %d of %d - public static func paused(_ p1: Int, _ p2: Int) -> String { - return L10n.tr("Localizable", "detail_view.accessibility.paused", p1, p2, fallback: "Resume download. Paused at %d of %d") - } - /// Preparing download - public static let preparing = L10n.tr("Localizable", "detail_view.accessibility.preparing", fallback: "Preparing download") - /// Queued - public static let queued = L10n.tr("Localizable", "detail_view.accessibility.queued", fallback: "Queued") - /// Repair download - public static let repair = L10n.tr("Localizable", "detail_view.accessibility.repair", fallback: "Repair download") - /// Retry download - public static let retry = L10n.tr("Localizable", "detail_view.accessibility.retry", fallback: "Retry download") - /// Update download - public static let update = L10n.tr("Localizable", "detail_view.accessibility.update", fallback: "Update download") - } } public enum DisplayMode { /// Compact @@ -1351,70 +1239,6 @@ public enum L10n { /// Filters public static let filters = L10n.tr("Localizable", "filters_view.filters", fallback: "Filters") } - public enum FolderManagerView { - /// This will delete the folder and all downloaded galleries inside it. - public static let deleteFolder = L10n.tr("Localizable", "folder_manager_view.delete_folder", fallback: "This will delete the folder and all downloaded galleries inside it.") - /// Folders you create will appear here. - public static let emptyFolders = L10n.tr("Localizable", "folder_manager_view.empty_folders", fallback: "Folders you create will appear here.") - /// Folder name - public static let folderName = L10n.tr("Localizable", "folder_manager_view.folder_name", fallback: "Folder name") - /// Folders - public static let folders = L10n.tr("Localizable", "folder_manager_view.folders", fallback: "Folders") - } - public enum GalleryInfosView { - /// Archive URL - public static let archiveURL = L10n.tr("Localizable", "gallery_infos_view.archive_URL", fallback: "Archive URL") - /// Average rating - public static let averageRating = L10n.tr("Localizable", "gallery_infos_view.average_rating", fallback: "Average rating") - /// Category - public static let category = L10n.tr("Localizable", "gallery_infos_view.category", fallback: "Category") - /// Cover URL - public static let coverURL = L10n.tr("Localizable", "gallery_infos_view.cover_URL", fallback: "Cover URL") - /// Favorited - public static let favorited = L10n.tr("Localizable", "gallery_infos_view.favorited", fallback: "Favorited") - /// Favorited times - public static let favoritedTimes = L10n.tr("Localizable", "gallery_infos_view.favorited_times", fallback: "Favorited times") - /// File size - public static let fileSize = L10n.tr("Localizable", "gallery_infos_view.file_size", fallback: "File size") - /// Gallery infos - public static let galleryInfos = L10n.tr("Localizable", "gallery_infos_view.gallery_infos", fallback: "Gallery infos") - /// Gallery URL - public static let galleryURL = L10n.tr("Localizable", "gallery_infos_view.gallery_URL", fallback: "Gallery URL") - /// ID - public static let id = L10n.tr("Localizable", "gallery_infos_view.id", fallback: "ID") - /// Japanese title - public static let japaneseTitle = L10n.tr("Localizable", "gallery_infos_view.japanese_title", fallback: "Japanese title") - /// Language - public static let language = L10n.tr("Localizable", "gallery_infos_view.language", fallback: "Language") - /// My rating - public static let myRating = L10n.tr("Localizable", "gallery_infos_view.my_rating", fallback: "My rating") - /// No - public static let no = L10n.tr("Localizable", "gallery_infos_view.no", fallback: "No") - /// None - public static let `none` = L10n.tr("Localizable", "gallery_infos_view.none", fallback: "None") - /// Page count - public static let pageCount = L10n.tr("Localizable", "gallery_infos_view.page_count", fallback: "Page count") - /// Parent URL - public static let parentURL = L10n.tr("Localizable", "gallery_infos_view.parent_URL", fallback: "Parent URL") - /// Posted date - public static let postedDate = L10n.tr("Localizable", "gallery_infos_view.posted_date", fallback: "Posted date") - /// Rating count - public static let ratingCount = L10n.tr("Localizable", "gallery_infos_view.rating_count", fallback: "Rating count") - /// Title - public static let title = L10n.tr("Localizable", "gallery_infos_view.title", fallback: "Title") - /// Token - public static let token = L10n.tr("Localizable", "gallery_infos_view.token", fallback: "Token") - /// Torrent count - public static let torrentCount = L10n.tr("Localizable", "gallery_infos_view.torrent_count", fallback: "Torrent count") - /// Torrent URL - public static let torrentURL = L10n.tr("Localizable", "gallery_infos_view.torrent_URL", fallback: "Torrent URL") - /// Uploader - public static let uploader = L10n.tr("Localizable", "gallery_infos_view.uploader", fallback: "Uploader") - /// Visibility - public static let visibility = L10n.tr("Localizable", "gallery_infos_view.visibility", fallback: "Visibility") - /// Yes - public static let yes = L10n.tr("Localizable", "gallery_infos_view.yes", fallback: "Yes") - } public enum GalleryName { /// Default Title public static let `default` = L10n.tr("Localizable", "gallery_name.default", fallback: "Default Title") @@ -1685,12 +1509,6 @@ public enum L10n { /// Align left, scale if overwidth public static let alignLeftScaleIfOverWidth = L10n.tr("Localizable", "multiple_page_viewer_style.align_left_scale_if_over_width", fallback: "Align left, scale if overwidth") } - public enum PostCommentView { - /// Edit comment - public static let editComment = L10n.tr("Localizable", "post_comment_view.edit_comment", fallback: "Edit comment") - /// Post comment - public static let postComment = L10n.tr("Localizable", "post_comment_view.post_comment", fallback: "Post comment") - } public enum PreferredColorScheme { /// Automatic public static let automatic = L10n.tr("Localizable", "preferred_color_scheme.automatic", fallback: "Automatic") @@ -1699,10 +1517,6 @@ public enum L10n { /// Light public static let light = L10n.tr("Localizable", "preferred_color_scheme.light", fallback: "Light") } - public enum PreviewsView { - /// Previews - public static let previews = L10n.tr("Localizable", "previews_view.previews", fallback: "Previews") - } public enum QuickSearchView { /// Quick search public static let quickSearch = L10n.tr("Localizable", "quick_search_view.quick_search", fallback: "Quick search") @@ -1757,12 +1571,6 @@ public enum L10n { /// Setting public static let setting = L10n.tr("Localizable", "tab_item.setting", fallback: "Setting") } - public enum TagDetailView { - /// Images - public static let images = L10n.tr("Localizable", "tag_detail_view.images", fallback: "Images") - /// Links - public static let links = L10n.tr("Localizable", "tag_detail_view.links", fallback: "Links") - } public enum TagNamespace { /// Artist public static let artist = L10n.tr("Localizable", "tag_namespace.artist", fallback: "Artist") @@ -1835,10 +1643,6 @@ public enum L10n { /// Yesterday public static let yesterday = L10n.tr("Localizable", "toplists_type.yesterday", fallback: "Yesterday") } - public enum TorrentsView { - /// Torrents - public static let torrents = L10n.tr("Localizable", "torrents_view.torrents", fallback: "Torrents") - } } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length From af29851d4af5d699d7f4b55c606c88d6037ed1fb Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 02:19:51 +0800 Subject: [PATCH 467/614] Move SettingFeature strings to catalog Emit 152 localizable + 61 constant keys into SettingFeature/Resources (Localizable.xcstrings + Constant.xcstrings), delete them from the 6 lproj files, and rewrite 220 accessors across 15 files. Constant table holds do-not-translate acknowledgement/contact/contributor names and links. Symbols taken from Xcode's authoritative generated map (handles SNI->Sni, UI->Ui, Kaed3mi->Kaed3Mi digit boundary, hyphen names). Shared keys (cancel, clear, delete, language, setting, login) stay on L10n via Resources. --- AppPackage/Package.swift | 1 + .../Resources/de.lproj/Localizable.strings | 155 +- .../Resources/en.lproj/Constant.strings | 61 - .../Resources/en.lproj/Localizable.strings | 155 +- .../Resources/ja.lproj/Localizable.strings | 155 +- .../Resources/ko.lproj/Localizable.strings | 155 +- .../zh-Hans.lproj/Localizable.strings | 155 +- .../zh-Hant.lproj/Localizable.strings | 155 +- AppPackage/Sources/Resources/Strings.swift | 461 -- .../AccountSettingReducer.swift | 4 +- .../AccountSetting/AccountSettingView.swift | 14 +- .../AppActivityLogs/AppActivityLogsView.swift | 18 +- .../AppearanceSettingView.swift | 24 +- .../SettingFeature/Components/AboutView.swift | 138 +- .../Components/DownloadSettingView.swift | 12 +- .../Components/LaboratorySettingView.swift | 4 +- .../EhSetting/EhSettingView+Sections1.swift | 64 +- .../EhSetting/EhSettingView+Sections2.swift | 40 +- .../EhSetting/EhSettingView+Sections3.swift | 64 +- .../EhSetting/EhSettingView.swift | 4 +- .../GeneralSettingReducer.swift | 4 +- .../GeneralSetting/GeneralSettingView.swift | 36 +- .../SettingFeature/Login/LoginView.swift | 4 +- .../Resources/Constant.xcstrings | 738 ++ .../Resources/Localizable.xcstrings | 6238 +++++++++++++++++ .../Sources/SettingFeature/SettingView.swift | 14 +- 26 files changed, 7205 insertions(+), 1668 deletions(-) create mode 100644 AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings create mode 100644 AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 87e1eb9e7..de95007cb 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -699,6 +699,7 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sfSafeSymbols), .targetDependency(.sharing) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index 6f9f5a2d4..b1f2187a6 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -78,12 +78,8 @@ "app_error.local_file_operation_failed" = "Lokaler Dateivorgang fehlgeschlagen."; // MARK: ConfirmationDialog -"confirmation_dialog.remove_custom_translations" = "Möchtest du deine benutzerdefinierten Übersetzungen wirklich entfernen?"; -"confirmation_dialog.logout_description" = "Bist du sicher das du dich ausloggen möchtest?"; "confirmation_dialog.delete_description" = "Möchtest du dieses Element wirklich löschen?"; "confirmation_dialog.clear_description" = "Bist du sicher das du das löschen möchtest?"; -"confirmation_dialog.remove" = "Entfernen"; -"confirmation_dialog.logout" = "Ausloggen"; "confirmation_dialog.delete" = "Löschen"; "confirmation_dialog.clear" = "Löschen"; @@ -127,60 +123,21 @@ // MARK: SettingView "setting_view.setting" = "Einstellungen"; // SettingStateRoute -"setting_state_route.account" = "Konto"; -"setting_state_route.general" = "Allgemein"; -"setting_state_route.appearance" = "Oberfläche"; -"setting_state_route.reading" = "Am Lesen"; -"setting_state_route.download" = "Download"; -"setting_state_route.laboratory" = "Experimentelles"; -"setting_state_route.about" = "Über diese App"; // MARK: AccountSettingView -"account_setting_view.account" = "Konto"; -"account_setting_view.shows_new_dawn_greeting" = "Neuer-Tag-Meldung anzeigen"; "account_setting_view.login" = "Einloggen"; -"account_setting_view.account_configuration" = "Kontoeinstellungen"; -"account_setting_view.tags_management" = "Meine Tags bearbeiten"; -"account_setting_view.copy_cookies" = "Cookies kopieren"; // CookieValue // MARK: LoginView "login_view.login" = "Einloggen"; -"login_view.username" = "Benutzername"; -"login_view.password" = "Passwort"; // MARK: GeneralSettingView -"general_setting_view.general" = "Allgemein"; "general_setting_view.language" = "Sprache"; -"general_setting_view.auto_lock" = "Automatische Sperre"; -"general_setting_view.enables_tags_extension" = "Tag-Erweiterung aktivieren"; -"general_setting_view.translates_tags" = "Tags übersetzen"; -"general_setting_view.shows_tags_search_suggestion" = "Tag-Vorschläge bei der Suche anzeigen"; -"general_setting_view.shows_images_in_tags" = "Bilder in Tags anzeigen"; -"general_setting_view.redirects_links_to_the_selected_host" = "Links zum ausgewählten Host umleiten"; -"general_setting_view.detects_links_from_clipboard" = "Übernimmt automatisch Links aus der Zwischenablage"; -"general_setting_view.background_blur_radius" = "Hintergrund-Unschärfe"; -"general_setting_view.app_activity_logs" = "App-Aktivitätsprotokolle"; -"general_setting_view.import_custom_translations" = "Benutzerdefinierte Übersetzungen importieren"; -"general_setting_view.remove_custom_translations" = "Benutzerdefinierte Übersetzungen entfernen"; -"general_setting_view.clear_image_caches" = "Zwischengespeicherte Bilder (Cache) löschen"; -"general_setting_view.default_language_description" = "Unbekannt"; -"general_setting_view.tags" = "Tags"; -"general_setting_view.navigation" = "Navigation"; -"general_setting_view.security" = "Sicherheit"; -"general_setting_view.caches" = "Cache"; // AutoLockPolicy "auto_lock_policy.never" = "Nie"; "auto_lock_policy.instantly" = "Sofort"; // MARK: AppActivityLogsView -"app_activity_logs_view.title" = "App-Aktivitätsprotokolle"; -"app_activity_logs_view.no_logs" = "Keine Protokolle gefunden"; -"app_activity_logs_view.current" = "Aktuell"; -"app_activity_logs_view.run" = "Ausführung %@"; -"app_activity_logs_view.more_logs" = "Weitere Protokolle"; -"app_activity_logs_view.open_in_files" = "In „Dateien“ öffnen"; -"app_activity_logs_view.runs" = "Ausführungen"; "app_activity_logs_view.level.undefined" = "Undefiniert"; "app_activity_logs_view.level.debug" = "Debug"; "app_activity_logs_view.level.info" = "Info"; @@ -189,17 +146,6 @@ "app_activity_logs_view.level.fault" = "Störung"; // MARK: AppearanceSettingView -"appearance_setting_view.appearance" = "Oberfläche"; -"appearance_setting_view.theme" = "Erscheinungsbild"; -"appearance_setting_view.tint_color" = "Farbe"; -"appearance_setting_view.display_mode" = "Anzeigemodus"; -"appearance_setting_view.shows_tags_in_list" = "Tags als Liste anzeigen"; -"appearance_setting_view.maximum_number_of_tags" = "Maximale Anzahl an Tags"; -"appearance_setting_view.displays_japanese_title" = "Japanischen Titel anzeigen"; -"appearance_setting_view.app_icon" = "App-Symbol"; -"appearance_setting_view.infite" = "Unbegrenzt"; -"appearance_setting_view.list" = "Liste"; -"appearance_setting_view.gallery" = "Galerie"; // PreferredColorScheme "preferred_color_scheme.automatic" = "Automatisch"; "preferred_color_scheme.light" = "Hell"; @@ -215,7 +161,6 @@ "list_display_mode.thumbnail" = "Vorschaubilder"; // MARK: AppIconView -"app_icon_view.app_icon" = "App-Symbol"; // MARK: reading_settingView // ReadingDirection @@ -224,18 +169,8 @@ "reading_direction.left_to_right" = "Von links nach rechts"; // MARK: LaboratorySettingView -"laboratory_setting_view.laboratory" = "Experimentelles"; -"laboratory_setting_view.bypasses_SNI_filtering" = "SNI Filter umgehen"; // MARK: AboutView -"about_view.ehPanda" = "EhPanda"; -"about_view.website" = "Website"; -"about_view.altStore_source" = "AltStore Quelle"; -"about_view.version" = "Version"; -"about_view.special_thanks" = "Besonderer Dank"; -"about_view.code_level_contributors" = "Mitwirkende am Code"; -"about_view.translation_contributors" = "Mitwirkende an der Übersetzung"; -"about_view.acknowledgements" = "Danksagungen"; // MARK: DetailView "detail_view.share" = "Teilen"; @@ -271,12 +206,6 @@ "downloads_view.update" = "Aktualisieren"; // MARK: DownloadSettingView -"download_setting_view.title" = "Download"; -"download_setting_view.network" = "Netzwerk"; -"download_setting_view.concurrent_image_downloads" = "Gleichzeitige Bilddownloads"; -"download_setting_view.retry_failed_pages_automatically" = "Fehlgeschlagene Seiten automatisch erneut versuchen"; -"download_setting_view.allow_cellular_downloads" = "Downloads über Mobilfunk erlauben"; -"download_setting_view.network_description" = "Es wird immer nur eine Galerie gleichzeitig heruntergeladen. Mit dieser Einstellung steuerst du, wie viele Galerieseiten parallel geladen werden, ob Mobilfunk erlaubt ist und dass Dateien im Downloads-Ordner der App gespeichert werden."; // MARK: CommentsView @@ -305,19 +234,7 @@ "filter_range.watched" = "Meine Tags"; // MARK: EhSettingView -"eh_setting_view.host_settings" = "%@-Einstellungen"; -"eh_setting_view.profile_settings" = "Profile"; -"eh_setting_view.selected_profile" = "Ausgewähltes Profil"; -"eh_setting_view.set_as_default" = "Als Standard festlegen"; -"eh_setting_view.delete_profile" = "Profil löschen"; -"eh_setting_view.rename" = "Umbenennen"; -"eh_setting_view.create_new" = "Neu erstellen"; -"eh_setting_view.done" = "Fertig"; - -"eh_setting_view.image_load_settings" = "Laden von Bildern"; -"eh_setting_view.load_images_through_the_hath_network" = "Bilder über das Hath-Netzwerk laden"; -"eh_setting_view.browsing_country" = "Browsing-Land"; -"eh_setting_view.browsing_country_description" = "Es sieht so aus, als würdest du die Seite aus **%@** aufrufen oder ein VPN bzw. einen Proxy in diesem Land verwenden. Die Seite versucht daher, Bilder von H@H-Clients in dieser Region zu laden. Falls das nicht stimmt oder du aus irgendeinem Grund eine andere Region verwenden möchtest (etwa mit einem Split-Tunneling-VPN), kannst du unten ein anderes Land auswählen."; + // EhSetting.LoadThroughHathSetting "load_through_hath_setting.any_client" = "Jeder Client"; "load_through_hath_setting.default_port_only" = "Nur Clients mit Standardport"; @@ -328,26 +245,13 @@ "load_through_hath_setting.modern_no_description" = "Nur für Spender. Du kannst damit weniger Seiten aufrufen. Nur bei schwerwiegenden Problemen empfohlen."; "load_through_hath_setting.legacy_no_description" = "Nur für Spender. Funktioniert in modernen Browsern unter Umständen nicht. Nur für alte oder veraltete Browser empfohlen."; -"eh_setting_view.image_size_settings" = "Bildgrößen-Einstellungen"; -"eh_setting_view.image_resolution" = "Bildauflösung"; -"eh_setting_view.image_resolution_description" = "Normalerweise werden Bilder für die Online-Ansicht auf eine horizontale Auflösung von 1280 Pixeln neu berechnet. Alternativ kannst du eine der folgenden Auflösungen wählen. Um die Server nicht zu überlasten, sind Auflösungen über 1280x vorübergehend Spendern, Nutzern mit einem Hath-Perk und Nutzern mit einer UID unter 3.000.000 vorbehalten."; -"eh_setting_view.image_size" = "Bildgröße"; -"eh_setting_view.image_size_description" = "Die Seite verkleinert Bilder automatisch passend zu deiner Bildschirmbreite, du kannst die maximale Anzeigegröße aber auch manuell begrenzen. Wie bei der automatischen Skalierung wird das Bild dabei nicht neu berechnet, da die Größenänderung im Browser erfolgt. (0 = keine Begrenzung)"; -"eh_setting_view.horizontal" = "Horizontal"; -"eh_setting_view.vertical" = "Vertikal"; // EhSetting.ImageResolution "image_resolution.auto" = "Automatisch"; -"eh_setting_view.gallery_name_display" = "Anzeige des Galerienamens"; -"eh_setting_view.gallery_name" = "Galeriename"; -"eh_setting_view.gallery_name_description" = "Viele Galerien haben sowohl einen englischen bzw. romanisierten Titel als auch einen Titel in japanischer Schrift. Welchen Namen möchtest du standardmäßig sehen?"; // EhSetting.GalleryName "gallery_name.default" = "Standardtitel"; "gallery_name.japanese" = "Japanischer Titel (falls vorhanden)"; -"eh_setting_view.archiver_settings" = "Archiver"; -"eh_setting_view.archiver_behavior" = "Archiver-Verhalten"; -"eh_setting_view.archiver_behavior_description" = "Standardmäßig fragt der Archiver Kosten und Auswahl (Original oder neu berechnet) ab und zeigt dann einen Link an, den du anklicken oder woanders kopieren kannst. Dieses Verhalten kannst du hier ändern."; // EhSetting.ArchiverBehavior "eh_setting.archiver_behavior.manual_select_manual_start" = "Manuell wählen, manuell starten (Standard)"; "eh_setting.archiver_behavior.manual_select_auto_start" = "Manuell wählen, automatisch starten"; @@ -356,12 +260,6 @@ "eh_setting.archiver_behavior.auto_select_resample_manual_start" = "Neu berechnete Version automatisch wählen, manuell starten"; "eh_setting.archiver_behavior.auto_select_resample_auto_start" = "Neu berechnete Version automatisch wählen, automatisch starten"; -"eh_setting_view.front_page_settings" = "Startseite"; -"eh_setting_view.display_mode" = "Anzeigemodus"; -"eh_setting_view.display_mode_description" = "Welchen Anzeigemodus möchtest du auf der Startseite und den Suchseiten verwenden?"; -"eh_setting_view.show_search_range_indicator" = "Suchbereichsanzeige"; -"eh_setting_view.show_search_range_indicator_description" = "Suchbereichsanzeige einblenden"; -"eh_setting_view.gallery_category" = "Welche Kategorien sollen standardmäßig auf der Startseite und in Suchergebnissen angezeigt werden?"; // EhSetting.DisplayMode "display_mode.compact" = "Kompakt"; "display_mode.thumbnail" = "Vorschaubilder"; @@ -369,54 +267,22 @@ "display_mode.minimal" = "Minimal"; "display_mode.minimalPlus" = "Minimal+"; -"eh_setting_view.optional_UI_elements" = "Optionale UI-Elemente"; -"eh_setting_view.optional_UI_elements_description" = "Einige ältere UI-Elemente sind inzwischen standardmäßig deaktiviert. Hier kannst du sie wieder aktivieren."; -"eh_setting_view.enable_gallery_thumbnail_selector" = "Vorschaubild-Auswahl auf der Galerieseite aktivieren"; -"eh_setting_view.favorites" = "Favoriten"; -"eh_setting_view.favorite_categories" = "Hier kannst du deine Favoriten-Kategorien auswählen und umbenennen."; -"eh_setting_view.favorites_sort_order" = "Sortierung der Favoriten"; -"eh_setting_view.favorites_sort_order_description" = "Du kannst auch die Standardsortierung für Galerien auf deiner Favoritenseite festlegen. Favoriten, die vor der Überarbeitung im März 2016 hinzugefügt wurden, haben keinen Zeitstempel und werden unabhängig von dieser Einstellung nach dem Veröffentlichungszeitpunkt der Galerie sortiert."; // EhSetting.FavoritesSortOrder "favorites_sort_order.last_update_time" = "Nach letzter Aktualisierung der Galerie"; "favorites_sort_order.favorited_time" = "Nach Zeitpunkt des Favorisierens"; -"eh_setting_view.ratings" = "Bewertungen"; -"eh_setting_view.ratings_color" = "Farbe der Bewertungen"; -"eh_setting_view.ratings_color_prompt" = "RRGGB"; -"eh_setting_view.ratings_color_description" = "Standardmäßig erscheinen von dir bewertete Galerien mit roten Sternen bei Bewertungen bis 2 Sternen, mit grünen zwischen 2,5 und 4 Sternen und mit blauen bei 4,5 oder 5 Sternen. Du kannst das anpassen, indem du unten deine gewünschte Farbkombination eingibst. Jeder Buchstabe steht für einen Stern. Das Standard-RRGGB bedeutet R(ot) für den ersten und zweiten Stern, G(rün) für den dritten und vierten und B(lau) für den fünften. Mit Y bekommst du gelbe Sterne. Jede fünfstellige Kombination aus R/G/B/Y funktioniert."; -"eh_setting_view.tag_filtering_threshold" = "Schwellenwert für Tag-Filterung"; -"eh_setting_view.tag_filtering_threshold_description" = "Du kannst Tags weich filtern, indem du sie mit negativem Gewicht zu „Meine Tags“ hinzufügst. Ergeben die Tags einer Galerie zusammen ein Gewicht unter diesem Wert, wird sie ausgeblendet. Der Schwellenwert kann zwischen 0 und -9999 liegen."; -"eh_setting_view.tag_watching_threshold" = "Schwellenwert für Tag-Beobachtung"; -"eh_setting_view.tag_watching_threshold_description" = "Kürzlich hochgeladene Galerien erscheinen unter „Meine Tags“, wenn sie mindestens einen beobachteten Tag mit positivem Gewicht haben und die Gewichte ihrer beobachteten Tags zusammen diesen Wert erreichen oder überschreiten. Der Schwellenwert kann zwischen 0 und 9999 liegen."; -"eh_setting_viewfiltered_removal_count" = "Anzahl gefilterter Galerien"; -"eh_setting_view.filtered_removal_count_description" = "Soll die Meldung „Deine Standardfilter haben XX Galerien von dieser Seite entfernt“ angezeigt werden?"; -"eh_setting_view.show_filtered_removal_count" = "Anzahl gefilterter Galerien anzeigen"; -"eh_setting_view.excluded_languages" = "Ausgeschlossene Sprachen"; -"eh_setting_view.excluded_languages_description" = "Wenn du Galerien in bestimmten Sprachen aus der Galerieliste und den Suchergebnissen ausblenden möchtest, wähle sie unten aus. Passende Galerien erscheinen dann unabhängig von deiner Suchanfrage nie."; // EhSetting.ExcludedLanguagesCategory "excluded_languages_category.original" = "Original"; "excluded_languages_category.translated" = "Übersetzt"; "excluded_languages_category.rewrite" = "Umgeschrieben"; -"eh_setting_view.excluded_uploaders" = "Ausgeschlossene Uploader"; -"eh_setting_view.excluded_uploaders_description" = "Wenn du Galerien bestimmter Uploader aus der Galerieliste und den Suchergebnissen ausblenden möchtest, füge sie unten hinzu. Ein Benutzername pro Zeile. Galerien dieser Uploader erscheinen dann unabhängig von deiner Suchanfrage nie."; -"eh_setting_view.excluded_uploaders_count" = "Du belegst derzeit **%@ / %@** Ausschlussplätze."; -"eh_setting_view.search_result_count" = "Anzahl der Suchergebnisse"; -"eh_setting_view.result_count" = "Anzahl Ergebnisse"; -"eh_setting_view.result_count_description" = "Wie viele Ergebnisse pro Seite möchtest du auf Index-, Such- und Torrent-Suchseiten sehen?\n(Hath-Perk „Paging Enlargement“ erforderlich)"; -"eh_setting_view.thumbnail_settings" = "Vorschaubilder"; -"eh_setting_view.thumbnail_load_timing" = "Ladezeitpunkt der Vorschaubilder"; -"eh_setting_view.thumbnail_load_timing_description" = "Wann sollen die Mouseover-Vorschaubilder auf der Startseite im Listenmodus geladen werden?"; -"eh_setting_view.thumbnail_configuration" = "Du kannst eine Standardkonfiguration der Vorschaubilder für alle Galerien festlegen, die du besuchst."; -"eh_setting_view.thumbnail_size" = "Größe"; -"eh_setting_view.thumbnail_row_count" = "Zeilen"; // EhSetting.ThumbnailLoadTiming "thumbnail_load_timing.on_mouse_over" = "Bei Mouseover"; "thumbnail_load_timing.on_page_load" = "Beim Laden der Seite"; @@ -428,17 +294,8 @@ "thumbnail_size.small" = "Klein"; "thumbnail_size.auto" = "Automatisch"; -"eh_setting_view.cover_scaling" = "Cover-Skalierung"; -"eh_setting_view.scale_factor" = "Skalierungsfaktor"; -"eh_setting_view.cover_scale_factor" = "Die Covergröße in Galerielisten kann in den Anzeigemodi „Vorschaubilder“ und „Erweitert“ auf 75%% bis 150%% skaliert werden."; -"eh_setting_view.viewport_override" = "Viewport-Überschreibung"; -"eh_setting_view.virtual_width" = "Virtuelle Breite"; -"eh_setting_view.virtual_width_description" = "Hiermit kannst du die virtuelle Breite der Seite auf Mobilgeräten überschreiben. Normalerweise bestimmt dein Gerät sie automatisch anhand der DPI. Sinnvolle Werte bei 100%% Vorschaubild-Skalierung liegen zwischen 640 und 1400."; -"eh_setting_view.gallery_comments" = "Galerie-Kommentare"; -"eh_setting_view.comments_sort_order" = "Sortierung der Kommentare"; -"eh_setting_view.comments_votes_show_timing" = "Anzeige der Kommentarbewertungen"; // EhSetting.CommentsSortOrder "comments_sort_order.oldest" = "Älteste Kommentare zuerst"; "comments_sort_order.recent" = "Neueste Kommentare zuerst"; @@ -447,22 +304,12 @@ "comments_votes_show_timing.on_hover_or_click" = "Beim Überfahren oder Anklicken der Punktzahl"; "comments_votes_show_timing.always" = "Immer"; -"eh_setting_view.gallery_tags" = "Galerie-Tags"; -"eh_setting_view.tags_sort_order" = "Sortierung der Tags"; // EhSetting.tags_sort_order "tags_sort_order.alphabetical" = "Alphabetisch"; "tags_sort_order.tag_power" = "Nach Tag-Gewicht"; -"eh_setting_view.gallery_page_thumbnail_labeling" = "Beschriftung der Vorschaubilder"; -"eh_setting_view.show_label_below_gallery_thumbnails" = "Beschriftung unter Galerie-Vorschaubildern anzeigen"; -"eh_setting_view.original_images" = "Sollen Originalbilder statt der neu berechneten Versionen verwendet werden? Neu berechnete Bilder werden weiterhin verwendet, wenn du oben eine andere horizontale Auflösung als „Automatisch“ wählst und das betreffende Bild breiter ist, oder wenn das Originalbild größer als 10 MiB ist (bzw. 4 MiB bei Galerien, die älter als ein Jahr sind)."; -"eh_setting_view.use_original_images" = "Originalbilder verwenden"; -"eh_setting_view.multi_page_viewer" = "Multi-Page-Viewer"; -"eh_setting_view.use_multi_page_viewer" = "Multi-Page-Viewer verwenden"; -"eh_setting_view.display_style" = "Darstellungsstil"; -"eh_setting_view.show_thumbnail_pane" = "Vorschaubild-Leiste anzeigen"; // EhSetting.MultiplePageViewerStyle "multiple_page_viewer_style.align_left_scale_if_over_width" = "Linksbündig, bei Überbreite skalieren"; "multiple_page_viewer_style.align_center_scale_if_over_width" = "Zentriert, bei Überbreite skalieren"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings index 93944e8a4..92a67223b 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings @@ -7,76 +7,15 @@ "gallery_unavailable" = "This gallery has been removed or is unavailable."; // MARK: App -"copyright" = "Copyright © 2026 EhPanda Team"; // Contact -"contact.website" = "https://ehpanda.app"; -"contact.gitHub" = "https://github.com/EhPanda-Team/EhPanda"; -"contact.discord" = "https://discord.gg/BSBE9FCBTq"; -"contact.telegram" = "https://t.me/ehpanda"; -"contact.altStore_link" = "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json"; -"contact.gitHub_link" = "GitHub"; -"contact.discord_link" = "Discord"; -"contact.telegram_link" = "Telegram"; // Special thanks -"special_thanks.taylorlannister_link" = "https://github.com/taylorlannister"; -"special_thanks.luminescent_yq_link" = ""; -"special_thanks.caxerx_link" = "https://github.com/caxerx"; -"special_thanks.honjow_link" = "https://github.com/honjow"; -"special_thanks.taylorlannister" = "taylorlannister"; -"special_thanks.luminescent_yq" = "Luminescent_yq"; -"special_thanks.caxerx" = "caxerx"; -"special_thanks.honjow" = "honjow"; // Code level contributor -"code_level_contributor.vvbbnn00_link" = "https://github.com/vvbbnn00"; -"code_level_contributor.Kaed3mi_link" = "https://github.com/Kaed3mi"; -"code_level_contributor.aalberrty_link" = "https://github.com/aalberrty"; -"code_level_contributor.Jimmy-Prime_link" = "https://github.com/Jimmy-Prime"; -"code_level_contributor.xioxin_link" = "https://github.com/xioxin"; -"code_level_contributor.vvbbnn00" = "vvbbnn00"; -"code_level_contributor.Kaed3mi" = "Kaed3mi"; -"code_level_contributor.aalberrty" = "Zack Asahina"; -"code_level_contributor.Jimmy-Prime" = "Jimmy Prime"; -"code_level_contributor.xioxin" = "xioxin"; // Translation contributor -"translation_contributor.nebulosa-cat_link" = "https://github.com/Nebulosa-Cat"; -"translation_contributor.paulHaeussler_link" = "https://github.com/PaulHaeussler"; -"translation_contributor.caxerx_link" = "https://github.com/caxerx"; -"translation_contributor.NeKoOuO_link" = "https://github.com/NeKoOuO"; -"translation_contributor.nebulosa-cat" = "雲豹 ΦωΦ"; -"translation_contributor.paulHaeussler" = "PaulHaeussler"; -"translation_contributor.caxerx" = "caxerx"; -"translation_contributor.NeKoOuO" = "ɴᴇᴋᴏ"; // Acknowledgement link -"acknowledgement.kanna_link" = "https://github.com/tid-kijyun/Kanna"; -"acknowledgement.swiftGen_link" = "https://github.com/SwiftGen/SwiftGen"; -"acknowledgement.colorful_link" = "https://github.com/Co2333/Colorful"; -"acknowledgement.kingfisher_link" = "https://github.com/onevcat/Kingfisher"; -"acknowledgement.swiftUIPager_link" = "https://github.com/fermoya/SwiftUIPager"; -"acknowledgement.waterfallGrid_link" = "https://github.com/paololeonardi/WaterfallGrid"; -"acknowledgement.swiftyOpenCC_link" = "https://github.com/ddddxxx/SwiftyOpenCC"; -"acknowledgement.uiImageColors_link" = "https://github.com/jathu/UIImageColors"; -"acknowledgement.sfSafeSymbols_link" = "https://github.com/SFSafeSymbols/SFSafeSymbols"; -"acknowledgement.systemNotification_link" = "https://github.com/danielsaidi/SystemNotification"; -"acknowledgement.swiftCommonMark_link" = "https://github.com/gonzalezreal/SwiftCommonMark"; -"acknowledgement.ehTagTranslationDatabase_link" = "https://github.com/EhTagTranslation/Database"; -"acknowledgement.tca_link" = "https://github.com/pointfreeco/swift-composable-architecture"; // Acknowledgement text -"acknowledgement.kanna" = "Kanna"; -"acknowledgement.swiftGen" = "SwiftGen"; -"acknowledgement.colorful" = "Colorful"; -"acknowledgement.kingfisher" = "Kingfisher"; -"acknowledgement.swiftUIPager" = "SwiftUIPager"; -"acknowledgement.waterfallGrid" = "WaterfallGrid"; -"acknowledgement.swiftyOpenCC" = "SwiftyOpenCC"; -"acknowledgement.uiImageColors" = "UIImageColors"; -"acknowledgement.sfSafeSymbols" = "SFSafeSymbols"; -"acknowledgement.systemNotification" = "SystemNotification"; -"acknowledgement.swiftCommonMark" = "SwiftCommonMark"; -"acknowledgement.ehTagTranslationDatabase" = "EhTagTranslation/Database"; -"acknowledgement.tca" = "The Composable Architecture"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 432d0c956..4d717d83d 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -78,12 +78,8 @@ "app_error.local_file_operation_failed" = "Local file operation failed."; // MARK: ConfirmationDialog -"confirmation_dialog.remove_custom_translations" = "Are you sure to remove your custom translations?"; -"confirmation_dialog.logout_description" = "Are you sure to logout?"; "confirmation_dialog.delete_description" = "Are you sure to delete this item?"; "confirmation_dialog.clear_description" = "Are you sure to clear?"; -"confirmation_dialog.remove" = "Remove"; -"confirmation_dialog.logout" = "Logout"; "confirmation_dialog.delete" = "Delete"; "confirmation_dialog.clear" = "Clear"; @@ -127,60 +123,21 @@ // MARK: SettingView "setting_view.setting" = "Setting"; // SettingStateRoute -"setting_state_route.account" = "Account"; -"setting_state_route.general" = "General"; -"setting_state_route.appearance" = "Appearance"; -"setting_state_route.reading" = "Reading"; -"setting_state_route.download" = "Download"; -"setting_state_route.laboratory" = "Laboratory"; -"setting_state_route.about" = "About"; // MARK: AccountSettingView -"account_setting_view.account" = "Account"; -"account_setting_view.shows_new_dawn_greeting" = "Shows new dawn greeting"; "account_setting_view.login" = "Login"; -"account_setting_view.account_configuration" = "Account configuration"; -"account_setting_view.tags_management" = "Manage tags subscription"; -"account_setting_view.copy_cookies" = "Copy cookies"; // CookieValue // MARK: LoginView "login_view.login" = "Login"; -"login_view.username" = "Username"; -"login_view.password" = "Password"; // MARK: GeneralSettingView -"general_setting_view.general" = "General"; "general_setting_view.language" = "Language"; -"general_setting_view.auto_lock" = "Auto-Lock"; -"general_setting_view.enables_tags_extension" = "Enables tags extension"; -"general_setting_view.translates_tags" = "Translates tags"; -"general_setting_view.shows_tags_search_suggestion" = "Shows tags search suggestion"; -"general_setting_view.shows_images_in_tags" = "Shows images in tags"; -"general_setting_view.redirects_links_to_the_selected_host" = "Redirects links to the selected host"; -"general_setting_view.detects_links_from_clipboard" = "Detects links from the clipboard"; -"general_setting_view.background_blur_radius" = "Background blur radius"; -"general_setting_view.app_activity_logs" = "App activity logs"; -"general_setting_view.import_custom_translations" = "Import custom translations"; -"general_setting_view.remove_custom_translations" = "Remove custom translations"; -"general_setting_view.clear_image_caches" = "Clear image caches"; -"general_setting_view.default_language_description" = "N/A"; -"general_setting_view.tags" = "Tags"; -"general_setting_view.navigation" = "Navigation"; -"general_setting_view.security" = "Security"; -"general_setting_view.caches" = "Caches"; // AutoLockPolicy "auto_lock_policy.never" = "Never"; "auto_lock_policy.instantly" = "Instantly"; // MARK: AppActivityLogsView -"app_activity_logs_view.title" = "App activity logs"; -"app_activity_logs_view.no_logs" = "No logs found"; -"app_activity_logs_view.current" = "Current"; -"app_activity_logs_view.run" = "Run %@"; -"app_activity_logs_view.more_logs" = "More logs"; -"app_activity_logs_view.open_in_files" = "Open in Files"; -"app_activity_logs_view.runs" = "Runs"; "app_activity_logs_view.level.undefined" = "Undefined"; "app_activity_logs_view.level.debug" = "Debug"; "app_activity_logs_view.level.info" = "Info"; @@ -189,17 +146,6 @@ "app_activity_logs_view.level.fault" = "Fault"; // MARK: AppearanceSettingView -"appearance_setting_view.appearance" = "Appearance"; -"appearance_setting_view.theme" = "Theme"; -"appearance_setting_view.tint_color" = "Tint color"; -"appearance_setting_view.display_mode" = "Display mode"; -"appearance_setting_view.shows_tags_in_list" = "Shows tags in list"; -"appearance_setting_view.maximum_number_of_tags" = "Maximum number of tags"; -"appearance_setting_view.displays_japanese_title" = "Displays Japanese title"; -"appearance_setting_view.app_icon" = "App icon"; -"appearance_setting_view.infite" = "Infite"; -"appearance_setting_view.list" = "List"; -"appearance_setting_view.gallery" = "Gallery"; // PreferredColorScheme "preferred_color_scheme.automatic" = "Automatic"; "preferred_color_scheme.light" = "Light"; @@ -215,7 +161,6 @@ "list_display_mode.thumbnail" = "Thumbnail"; // MARK: AppIconView -"app_icon_view.app_icon" = "App icon"; // MARK: reading_settingView // ReadingDirection @@ -224,18 +169,8 @@ "reading_direction.left_to_right" = "Left-to-right"; // MARK: LaboratorySettingView -"laboratory_setting_view.laboratory" = "Laboratory"; -"laboratory_setting_view.bypasses_SNI_filtering" = "Bypasses SNI Filtering"; // MARK: AboutView -"about_view.ehPanda" = "EhPanda"; -"about_view.website" = "Website"; -"about_view.altStore_source" = "AltStore source"; -"about_view.version" = "Version"; -"about_view.special_thanks" = "Special thanks"; -"about_view.code_level_contributors" = "Code-level contributors"; -"about_view.translation_contributors" = "Translation contributors"; -"about_view.acknowledgements" = "Acknowledgements"; // MARK: DetailView "detail_view.share" = "Share"; @@ -271,12 +206,6 @@ "downloads_view.update" = "Update"; // MARK: DownloadSettingView -"download_setting_view.title" = "Download"; -"download_setting_view.network" = "Network"; -"download_setting_view.concurrent_image_downloads" = "Concurrent image downloads"; -"download_setting_view.retry_failed_pages_automatically" = "Retry failed pages automatically"; -"download_setting_view.allow_cellular_downloads" = "Allow cellular downloads"; -"download_setting_view.network_description" = "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder."; // MARK: CommentsView @@ -305,19 +234,7 @@ "filter_range.watched" = "Watched"; // MARK: EhSettingView -"eh_setting_view.host_settings" = "%@ settings"; -"eh_setting_view.profile_settings" = "Profile Settings"; -"eh_setting_view.selected_profile" = "Selected profile"; -"eh_setting_view.set_as_default" = "Set as default"; -"eh_setting_view.delete_profile" = "Delete profile"; -"eh_setting_view.rename" = "Rename"; -"eh_setting_view.create_new" = "Create new"; -"eh_setting_view.done" = "Done"; - -"eh_setting_view.image_load_settings" = "Image Load Settings"; -"eh_setting_view.load_images_through_the_hath_network" = "Load images through the Hath network"; -"eh_setting_view.browsing_country" = "Browsing country"; -"eh_setting_view.browsing_country_description" = "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below."; + // EhSetting.LoadThroughHathSetting "load_through_hath_setting.any_client" = "Any client"; "load_through_hath_setting.default_port_only" = "Default port clients only"; @@ -328,26 +245,13 @@ "load_through_hath_setting.modern_no_description" = "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems."; "load_through_hath_setting.legacy_no_description" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; -"eh_setting_view.image_size_settings" = "Image Size Settings"; -"eh_setting_view.image_resolution" = "Image resolution"; -"eh_setting_view.image_resolution_description" = "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000."; -"eh_setting_view.image_size" = "Image size"; -"eh_setting_view.image_size_description" = "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)"; -"eh_setting_view.horizontal" = "Horizontal"; -"eh_setting_view.vertical" = "Vertical"; // EhSetting.ImageResolution "image_resolution.auto" = "Auto"; -"eh_setting_view.gallery_name_display" = "Gallery Name Display"; -"eh_setting_view.gallery_name" = "Gallery name"; -"eh_setting_view.gallery_name_description" = "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?"; // EhSetting.GalleryName "gallery_name.default" = "Default Title"; "gallery_name.japanese" = "Japanese Title (if available)"; -"eh_setting_view.archiver_settings" = "Archiver Settings"; -"eh_setting_view.archiver_behavior" = "Archiver behavior"; -"eh_setting_view.archiver_behavior_description" = "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here."; // EhSetting.ArchiverBehavior "eh_setting.archiver_behavior.manual_select_manual_start" = "Manual Select, Manual Start (Default)"; "eh_setting.archiver_behavior.manual_select_auto_start" = "Manual Select, Auto Start"; @@ -356,12 +260,6 @@ "eh_setting.archiver_behavior.auto_select_resample_manual_start" = "Auto Select Resample, Manual Start"; "eh_setting.archiver_behavior.auto_select_resample_auto_start" = "Auto Select Resample, Auto Start"; -"eh_setting_view.front_page_settings" = "Front Page Settings"; -"eh_setting_view.display_mode" = "Display mode"; -"eh_setting_view.display_mode_description" = "Which display mode would you like to use on the front and search pages?"; -"eh_setting_view.show_search_range_indicator" = "Search Range Indicator"; -"eh_setting_view.show_search_range_indicator_description" = "Show search range indicator"; -"eh_setting_view.gallery_category" = "What categories would you like to show by default on the front page and in searches?"; // EhSetting.DisplayMode "display_mode.compact" = "Compact"; "display_mode.thumbnail" = "Thumbnail"; @@ -369,54 +267,22 @@ "display_mode.minimal" = "Minimal"; "display_mode.minimalPlus" = "Minimal+"; -"eh_setting_view.optional_UI_elements" = "Optional UI Elements"; -"eh_setting_view.optional_UI_elements_description" = "Some historic UI elements are now disabled by default. You can enable those here."; -"eh_setting_view.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; -"eh_setting_view.favorites" = "Favorites"; -"eh_setting_view.favorite_categories" = "Here you can choose and rename your favorite categories."; -"eh_setting_view.favorites_sort_order" = "Favorites sort order"; -"eh_setting_view.favorites_sort_order_description" = "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting."; // EhSetting.FavoritesSortOrder "favorites_sort_order.last_update_time" = "By last gallery update time"; "favorites_sort_order.favorited_time" = "By favorited time"; -"eh_setting_view.ratings" = "Ratings"; -"eh_setting_view.ratings_color" = "Ratings color"; -"eh_setting_view.ratings_color_prompt" = "RRGGB"; -"eh_setting_view.ratings_color_description" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; -"eh_setting_view.tag_filtering_threshold" = "Tag Filtering Threshold"; -"eh_setting_view.tag_filtering_threshold_description" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; -"eh_setting_view.tag_watching_threshold" = "Tag Watching Threshold"; -"eh_setting_view.tag_watching_threshold_description" = "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999."; -"eh_setting_viewfiltered_removal_count" = "Show Filtered Removal Count"; -"eh_setting_view.filtered_removal_count_description" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; -"eh_setting_view.show_filtered_removal_count" = "Show filtered removal count"; -"eh_setting_view.excluded_languages" = "Excluded Languages"; -"eh_setting_view.excluded_languages_description" = "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query."; // EhSetting.ExcludedLanguagesCategory "excluded_languages_category.original" = "Original"; "excluded_languages_category.translated" = "Translated"; "excluded_languages_category.rewrite" = "Rewrite"; -"eh_setting_view.excluded_uploaders" = "Excluded Uploaders"; -"eh_setting_view.excluded_uploaders_description" = "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query."; -"eh_setting_view.excluded_uploaders_count" = "You are currently using **%@ / %@** exclusion slots."; -"eh_setting_view.search_result_count" = "Search Result Count"; -"eh_setting_view.result_count" = "Result count"; -"eh_setting_view.result_count_description" = "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)"; -"eh_setting_view.thumbnail_settings" = "Thumbnail Settings"; -"eh_setting_view.thumbnail_load_timing" = "Thumbnail load timing"; -"eh_setting_view.thumbnail_load_timing_description" = "How would you like the mouse-over thumbnails on the front page to load when using List Mode?"; -"eh_setting_view.thumbnail_configuration" = "You can set a default thumbnail configuration for all galleries you visit."; -"eh_setting_view.thumbnail_size" = "Size"; -"eh_setting_view.thumbnail_row_count" = "Rows"; // EhSetting.ThumbnailLoadTiming "thumbnail_load_timing.on_mouse_over" = "On mouse-over"; "thumbnail_load_timing.on_page_load" = "On page load"; @@ -428,17 +294,8 @@ "thumbnail_size.small" = "Small"; "thumbnail_size.auto" = "Auto"; -"eh_setting_view.cover_scaling" = "Cover Scaling"; -"eh_setting_view.scale_factor" = "Scale factor"; -"eh_setting_view.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; -"eh_setting_view.viewport_override" = "Viewport Override"; -"eh_setting_view.virtual_width" = "Virtual width"; -"eh_setting_view.virtual_width_description" = "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400."; -"eh_setting_view.gallery_comments" = "Gallery Comments"; -"eh_setting_view.comments_sort_order" = "Comments sort order"; -"eh_setting_view.comments_votes_show_timing" = "Comment votes show timing"; // EhSetting.CommentsSortOrder "comments_sort_order.oldest" = "Oldest comments first"; "comments_sort_order.recent" = "Recent comments first"; @@ -447,22 +304,12 @@ "comments_votes_show_timing.on_hover_or_click" = "On score hover or click"; "comments_votes_show_timing.always" = "Always"; -"eh_setting_view.gallery_tags" = "Gallery Tags"; -"eh_setting_view.tags_sort_order" = "Tags sort order"; // EhSetting.tags_sort_order "tags_sort_order.alphabetical" = "Alphabetical"; "tags_sort_order.tag_power" = "By tag power"; -"eh_setting_view.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; -"eh_setting_view.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; -"eh_setting_view.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; -"eh_setting_view.use_original_images" = "Use original images"; -"eh_setting_view.multi_page_viewer" = "Multi-Page Viewer"; -"eh_setting_view.use_multi_page_viewer" = "Use Multi-Page Viewer"; -"eh_setting_view.display_style" = "Display style"; -"eh_setting_view.show_thumbnail_pane" = "Show thumbnail pane"; // EhSetting.MultiplePageViewerStyle "multiple_page_viewer_style.align_left_scale_if_over_width" = "Align left, scale if overwidth"; "multiple_page_viewer_style.align_center_scale_if_over_width" = "Align center, scale if overwidth"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index af474f54d..9c8ee8424 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -78,12 +78,8 @@ "app_error.local_file_operation_failed" = "ローカルファイルの操作に失敗しました。"; // MARK: ConfirmationDialog -"confirmation_dialog.remove_custom_translations" = "本当にカスタム翻訳を削除してもよろしいですか?"; -"confirmation_dialog.logout_description" = "本当にログアウトしてもよろしいですか?"; "confirmation_dialog.delete_description" = "本当にこれを削除してもよろしいですか?"; "confirmation_dialog.clear_description" = "本当に削除してもよろしいですか?"; -"confirmation_dialog.remove" = "削除"; -"confirmation_dialog.logout" = "ログアウト"; "confirmation_dialog.delete" = "削除"; "confirmation_dialog.clear" = "削除"; @@ -127,60 +123,21 @@ // MARK: SettingView "setting_view.setting" = "設定"; // SettingStateRoute -"setting_state_route.account" = "アカウント"; -"setting_state_route.general" = "一般"; -"setting_state_route.appearance" = "外観"; -"setting_state_route.reading" = "閲覧"; -"setting_state_route.download" = "ダウンロード"; -"setting_state_route.laboratory" = "ラボ"; -"setting_state_route.about" = "アプリについて"; // MARK: AccountSettingView -"account_setting_view.account" = "アカウント"; -"account_setting_view.shows_new_dawn_greeting" = "夜明けの挨拶を表示"; "account_setting_view.login" = "ログイン"; -"account_setting_view.account_configuration" = "アカウント設定"; -"account_setting_view.tags_management" = "タグの購読を管理"; -"account_setting_view.copy_cookies" = "クッキーをコピー"; // CookieValue // MARK: LoginView "login_view.login" = "ログイン"; -"login_view.username" = "ユーザー名"; -"login_view.password" = "パスワード"; // MARK: GeneralSettingView -"general_setting_view.general" = "一般"; "general_setting_view.language" = "言語"; -"general_setting_view.auto_lock" = "自動ロック"; -"general_setting_view.enables_tags_extension" = "タグの拡張機能を有効"; -"general_setting_view.translates_tags" = "タグを訳す"; -"general_setting_view.shows_tags_search_suggestion" = "タグの検索提案を表示"; -"general_setting_view.shows_images_in_tags" = "タグの画像を表示"; -"general_setting_view.redirects_links_to_the_selected_host" = "リンクを選択されたホストへリダイレクト"; -"general_setting_view.detects_links_from_clipboard" = "クリップボードからリンクを探知"; -"general_setting_view.background_blur_radius" = "バッググラウンドぼかし度"; -"general_setting_view.app_activity_logs" = "アプリアクティビティログ"; -"general_setting_view.import_custom_translations" = "カスタム翻訳を取り込む"; -"general_setting_view.remove_custom_translations" = "カスタム翻訳を削除"; -"general_setting_view.clear_image_caches" = "画像キャッシュを削除"; -"general_setting_view.default_language_description" = "無効"; -"general_setting_view.tags" = "タグ"; -"general_setting_view.navigation" = "ナビゲーション"; -"general_setting_view.security" = "セキュリティ"; -"general_setting_view.caches" = "キャッシュ"; // AutoLockPolicy "auto_lock_policy.never" = "なし"; "auto_lock_policy.instantly" = "すぐに"; // MARK: AppActivityLogsView -"app_activity_logs_view.title" = "アプリアクティビティログ"; -"app_activity_logs_view.no_logs" = "ログが見つかりません"; -"app_activity_logs_view.current" = "現在"; -"app_activity_logs_view.run" = "起動 %@"; -"app_activity_logs_view.more_logs" = "他のログ"; -"app_activity_logs_view.open_in_files" = "ファイルで開く"; -"app_activity_logs_view.runs" = "起動"; "app_activity_logs_view.level.undefined" = "未定義"; "app_activity_logs_view.level.debug" = "デバッグ"; "app_activity_logs_view.level.info" = "情報"; @@ -189,17 +146,6 @@ "app_activity_logs_view.level.fault" = "障害"; // MARK: AppearanceSettingView -"appearance_setting_view.appearance" = "外観"; -"appearance_setting_view.theme" = "テーマ"; -"appearance_setting_view.tint_color" = "テーマの色"; -"appearance_setting_view.display_mode" = "表示モード"; -"appearance_setting_view.shows_tags_in_list" = "リストでタグを表示"; -"appearance_setting_view.maximum_number_of_tags" = "タグ数上限"; -"appearance_setting_view.displays_japanese_title" = "日本語タイトルを表示"; -"appearance_setting_view.app_icon" = "アプリアイコン"; -"appearance_setting_view.infite" = "無制限"; -"appearance_setting_view.list" = "リスト"; -"appearance_setting_view.gallery" = "ギャラリー"; // PreferredColorScheme "preferred_color_scheme.automatic" = "自動"; "preferred_color_scheme.light" = "ライト"; @@ -215,7 +161,6 @@ "list_display_mode.thumbnail" = "サムネイル"; // MARK: AppIconView -"app_icon_view.app_icon" = "アプリアイコン"; // MARK: reading_settingView // ReadingDirection @@ -224,18 +169,8 @@ "reading_direction.left_to_right" = "左開き"; // MARK: LaboratorySettingView -"laboratory_setting_view.laboratory" = "ラボ"; -"laboratory_setting_view.bypasses_SNI_filtering" = "SNI フィルタリング回避"; // MARK: AboutView -"about_view.ehPanda" = "EhPanda"; -"about_view.website" = "ウェブサイト"; -"about_view.altStore_source" = "AltStore ソース"; -"about_view.version" = "バージョン"; -"about_view.special_thanks" = "特別な感謝"; -"about_view.code_level_contributors" = "コードレベル貢献者"; -"about_view.translation_contributors" = "翻訳貢献者"; -"about_view.acknowledgements" = "謝辞"; // MARK: DetailView "detail_view.share" = "共有"; @@ -271,12 +206,6 @@ "downloads_view.update" = "更新"; // MARK: DownloadSettingView -"download_setting_view.title" = "ダウンロード"; -"download_setting_view.network" = "ネットワーク"; -"download_setting_view.concurrent_image_downloads" = "同時画像ダウンロード数"; -"download_setting_view.retry_failed_pages_automatically" = "失敗したページを自動で再試行"; -"download_setting_view.allow_cellular_downloads" = "モバイル通信でのダウンロードを許可"; -"download_setting_view.network_description" = "一度にダウンロードされるギャラリーは 1 件だけです。この設定では、1 つのギャラリー内で同時にダウンロードするページ数、モバイル通信の許可または禁止、そしてファイルをアプリの Downloads フォルダに保存する動作を管理します。"; // MARK: CommentsView @@ -305,19 +234,7 @@ "filter_range.watched" = "タグの購読"; // MARK: EhSettingView -"eh_setting_view.host_settings" = "%@ 設定"; -"eh_setting_view.profile_settings" = "プロファイル設定"; -"eh_setting_view.selected_profile" = "選択されたプロファイル"; -"eh_setting_view.set_as_default" = "デフォルトに設定"; -"eh_setting_view.delete_profile" = "プロファイルを削除"; -"eh_setting_view.rename" = "名前を変更"; -"eh_setting_view.create_new" = "新規作成"; -"eh_setting_view.done" = "完了"; - -"eh_setting_view.image_load_settings" = "画像読み込み設定"; -"eh_setting_view.load_images_through_the_hath_network" = "Hath ネットワーク経由で画像を読み込む"; -"eh_setting_view.browsing_country" = "閲覧国"; -"eh_setting_view.browsing_country_description" = "**%@** から本サイトを閲覧している、またはその国の VPN・プロキシを使用しているようです。本サイトはその地域の H@H クライアントから画像を読み込もうとしますが、もし自動検知の結果が誤っている、または特別な事情でほかの地域のクライアントを希望する場合(例えばスプリットトンネル VPN を使用している)は下に手動選択できます。"; + // EhSetting.LoadThroughHathSetting "load_through_hath_setting.any_client" = "任意のクライアント"; "load_through_hath_setting.default_port_only" = "デフォルトポートのクライアントのみ"; @@ -328,26 +245,13 @@ "load_through_hath_setting.modern_no_description" = "寄付者独占オプション。閲覧による割当額の消耗は激しくなります。厳重な問題が起こった場合以外おすすめしません。"; "load_through_hath_setting.legacy_no_description" = "寄付者独占オプション。モダンブラウザでは機能しないこともあります。レガシー・旧型ブラウザの場合以外おすすめしません。"; -"eh_setting_view.image_size_settings" = "画像サイズ設定"; -"eh_setting_view.image_resolution" = "画像解像度"; -"eh_setting_view.image_resolution_description" = "一般的に、オンライン閲覧の画像は 1280x までにリサンプリングされます。下のいずれかのリサンプリング解像度に変更できます。サーバー負荷軽減のため、1280x 以上の解像度は現時点で寄付者、Hath Perks 利用者または UID が 3,000,000 以下の者に限定されます。"; -"eh_setting_view.image_size" = "画像サイズ"; -"eh_setting_view.image_size_description" = "サイト側は画像を自動的にスクリーンに適したサイズにスケールしますが、手動的にその画像の表示サイズ最大値を指定することも可能です。ブラウザが処理を実行するため、画像のリサンプリングは行われません。(ゼロは無制限を意味します)"; -"eh_setting_view.horizontal" = "幅"; -"eh_setting_view.vertical" = "高さ"; // EhSetting.ImageResolution "image_resolution.auto" = "自動"; -"eh_setting_view.gallery_name_display" = "ギャラリー名表示"; -"eh_setting_view.gallery_name" = "ギャラリー名"; -"eh_setting_view.gallery_name_description" = "英語・ローマ字と日本語両方のタイトルを持つギャラリーはたくさんあります。どちらをデフォルトにしますか?"; // EhSetting.GalleryName "gallery_name.default" = "デフォルトタイトル"; "gallery_name.japanese" = "日本語タイトル(可能なら)"; -"eh_setting_view.archiver_settings" = "アーカイバー設定"; -"eh_setting_view.archiver_behavior" = "アーカイバー動作"; -"eh_setting_view.archiver_behavior_description" = "アーカイバーのデフォルト動作はオリジナルとリサンプルのアーカイブのコストと選択を確認してからリンクを提供し、それからそのリンクをクリックしたりどこかにペーストしたりすることも可能です。そのデフォルト動作はここで変更できます。"; // EhSetting.ArchiverBehavior "eh_setting.archiver_behavior.manual_select_manual_start" = "手動で選択、手動で開始(デフォルト)"; "eh_setting.archiver_behavior.manual_select_auto_start" = "手動で選択、自動で開始"; @@ -356,12 +260,6 @@ "eh_setting.archiver_behavior.auto_select_resample_manual_start" = "自動でリサンプルを選択、手動で開始"; "eh_setting.archiver_behavior.auto_select_resample_auto_start" = "自動でリサンプルを選択、自動で開始"; -"eh_setting_view.front_page_settings" = "フロントページ設定"; -"eh_setting_view.display_mode" = "表示モード"; -"eh_setting_view.display_mode_description" = "フロント・検索ページで使う表示モードはどれにしますか?"; -"eh_setting_view.show_search_range_indicator" = "検索範囲インジケーター"; -"eh_setting_view.show_search_range_indicator_description" = "検索範囲インジケーターを表示"; -"eh_setting_view.gallery_category" = "フロント・検索ページでどれらのカテゴリーのギャラリーを表示しますか?"; // EhSetting.DisplayMode "display_mode.compact" = "コンパクト"; "display_mode.thumbnail" = "サムネイル"; @@ -369,54 +267,22 @@ "display_mode.minimal" = "最小化"; "display_mode.minimalPlus" = "最小化+"; -"eh_setting_view.optional_UI_elements" = "UI の表示制御"; -"eh_setting_view.optional_UI_elements_description" = "一部の従来の UI はデフォルトで無効になっています。ここで有効にすることができます。"; -"eh_setting_view.enable_gallery_thumbnail_selector" = "ギャラリーのサムネイルセレクタ"; -"eh_setting_view.favorites" = "お気に入り"; -"eh_setting_view.favorite_categories" = "ここではお気に入りカテゴリー名の変更ができます。"; -"eh_setting_view.favorites_sort_order" = "お気に入りの並び替え"; -"eh_setting_view.favorites_sort_order_description" = "お気に入りページのデフォルト並び替え順序も変更可能です。注意:平成28年3月の改修前にお気に入りに追加した項目はタイムスタンプが含まれていないため、この設定を無視して代わりにギャラリーの投稿時間を使います。"; // EhSetting.FavoritesSortOrder "favorites_sort_order.last_update_time" = "更新時間の新しい順"; "favorites_sort_order.favorited_time" = "気に入った時間の新しい順"; -"eh_setting_view.ratings" = "評価"; -"eh_setting_view.ratings_color" = "評価の色"; -"eh_setting_view.ratings_color_prompt" = "RRGGB"; -"eh_setting_view.ratings_color_description" = "デフォルトでは、評価済みのギャラリーは 2 以下の評価に赤い星を使う、2.5 ~ 4 には緑、4.5 以上には青。下に色の組み合わせを入れることでこのルールをカスタマイズできます。一つの星の色は一つの文字で指定します。デフォルトの「RRGGB」は「一番目と二番目の星は赤(Red)、三番と四番は緑(Green)、五番は青(Blue)」を意味します。黄色(Yellow)も使用可能です。R・G・B・Yで組み合わせた五文字はどれも機能します。"; -"eh_setting_view.tag_filtering_threshold" = "タグフィルタリングしきい値"; -"eh_setting_view.tag_filtering_threshold_description" = "負の重み付きでマイタグに追加することでタグをソフトフィルタリングすることができます。もしあるギャラリーが持つタグの重み総和がこのしきい値より低ければ、そのギャラリーはフィルタリングされます。このしきい値はゼロから -9999 まで設定できます。"; -"eh_setting_view.tag_watching_threshold" = "タグ購読しきい値"; -"eh_setting_view.tag_watching_threshold_description" = "もしあるギャラリーは最近投稿されたもので、少なくても一つの正の重みの購読タグを持っていて、購読タグの重み総和がこのしきい値と同じまたはより高ければ、そのギャラリーは購読画面で表示されます。このしきい値はゼロから 9999 まで設定できます。"; -"eh_setting_viewfiltered_removal_count" = "フィルター除去数"; -"eh_setting_view.filtered_removal_count_description" = "「既定フィルターにより本ページから XX 個のギャラリーが除去されました」を表示しますか?"; -"eh_setting_view.show_filtered_removal_count" = "フィルター除去数を表示"; -"eh_setting_view.excluded_languages" = "排除された言語"; -"eh_setting_view.excluded_languages_description" = "特定の言語のギャラリーをリストと検索結果から隠したい場合、下に選択してください。注意:どんな検索クエリーを使ってもこれらの言語のギャラリーは表示されません。"; // EhSetting.ExcludedLanguagesCategory "excluded_languages_category.original" = "オリジナル"; "excluded_languages_category.translated" = "翻訳版"; "excluded_languages_category.rewrite" = "書き換え版"; -"eh_setting_view.excluded_uploaders" = "排除された投稿者"; -"eh_setting_view.excluded_uploaders_description" = "特定の投稿者のギャラリーをリストと検索結果から隠したい場合、下に名前を記入してください。一行に一つのユーザー名で。注意:どんな検索クエリーを使ってもこの投稿者たちのギャラリーは表示されません。"; -"eh_setting_view.excluded_uploaders_count" = "現時点で **%@ / %@** の排除スロットが使用済みです。"; -"eh_setting_view.search_result_count" = "検索結果数"; -"eh_setting_view.result_count" = "結果数"; -"eh_setting_view.result_count_description" = "インデックス・トレントの検索ページで、各ページにどれくらいの結果数がお望みですか?\n(「Hath Perk:ページング拡張」が必要)"; -"eh_setting_view.thumbnail_settings" = "サムネイル設定"; -"eh_setting_view.thumbnail_load_timing" = "サムネイル読み込みタイミング"; -"eh_setting_view.thumbnail_load_timing_description" = "リストでは、どんなタイミングでホームページのマウスオーバーサムネイルを読み込みますか?"; -"eh_setting_view.thumbnail_configuration" = "すべてのギャラリーに適応するデフォルトのサムネイル構成を設定できます。"; -"eh_setting_view.thumbnail_size" = "サイズ"; -"eh_setting_view.thumbnail_row_count" = "行数"; // EhSetting.ThumbnailLoadTiming "thumbnail_load_timing.on_mouse_over" = "マウス経過時"; "thumbnail_load_timing.on_page_load" = "ページ読み込み時"; @@ -428,17 +294,8 @@ "thumbnail_size.small" = "小さめ"; "thumbnail_size.auto" = "自動"; -"eh_setting_view.cover_scaling" = "カバースケーリング"; -"eh_setting_view.scale_factor" = "スケール係数"; -"eh_setting_view.cover_scale_factor" = "サムネイル・拡張表示モードでのカバーを 75%% ~ 150%% にスケールすることができます。"; -"eh_setting_view.viewport_override" = "表示領域オーバーライド"; -"eh_setting_view.virtual_width" = "仮想幅"; -"eh_setting_view.virtual_width_description" = "モバイルデバイスの仮想幅をオーバーライドすることができます。一般的にはデバイスの DPI に基づいて自動的に決定されます。例えばサムネイルスケール係数が 100%% の場合、640 ~ 1400 の幅が合理的です。"; -"eh_setting_view.gallery_comments" = "ギャラリーコメント"; -"eh_setting_view.comments_sort_order" = "コメントの並び替え"; -"eh_setting_view.comments_votes_show_timing" = "コメントスコア表示タイミング"; // EhSetting.CommentsSortOrder "comments_sort_order.oldest" = "コメントの古い順"; "comments_sort_order.recent" = "コメントの新しい順"; @@ -447,22 +304,12 @@ "comments_votes_show_timing.on_hover_or_click" = "スコアに経過・クリック時"; "comments_votes_show_timing.always" = "常時"; -"eh_setting_view.gallery_tags" = "ギャラリータグ"; -"eh_setting_view.tags_sort_order" = "タグの並び替え"; // EhSetting.tags_sort_order "tags_sort_order.alphabetical" = "アルファベット順"; "tags_sort_order.tag_power" = "タグパワーの高い順"; -"eh_setting_view.gallery_page_thumbnail_labeling" = "ギャラリーサムネイルのラベル"; -"eh_setting_view.show_label_below_gallery_thumbnails" = "ギャラリーサムネイルの下にラベルを表示"; -"eh_setting_view.original_images" = "オリジナル画像を使いますか?リサンプリングされた画像は、上記の解像度で「自動」以外を選択し、該当する画像の方が幅が広い場合、またはオリジナル画像が 10 MiB(一年以上前のギャラリーの場合は 4 MiB)より大きい場合に使用されます。"; -"eh_setting_view.use_original_images" = "オリジナル画像を使う"; -"eh_setting_view.multi_page_viewer" = "マルチページビューア"; -"eh_setting_view.use_multi_page_viewer" = "マルチページビューアを使う"; -"eh_setting_view.display_style" = "表示仕様"; -"eh_setting_view.show_thumbnail_pane" = "サムネイルパネルを表示"; // EhSetting.MultiplePageViewerStyle "multiple_page_viewer_style.align_left_scale_if_over_width" = "左寄せ、幅によってスケール"; "multiple_page_viewer_style.align_center_scale_if_over_width" = "中央揃え、幅によってスケール"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index 0937e701a..aa4c40e25 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -78,12 +78,8 @@ "app_error.local_file_operation_failed" = "로컬 파일 작업에 실패했습니다."; // MARK: ConfirmationDialog -"confirmation_dialog.remove_custom_translations" = "사용자 지정 번역을 삭제하시겠어요?"; -"confirmation_dialog.logout_description" = "로그아웃 하시겠어요?"; "confirmation_dialog.delete_description" = "이 항목을 삭제하시겠어요?"; "confirmation_dialog.clear_description" = "삭제하시겠어요?"; -"confirmation_dialog.remove" = "삭제"; -"confirmation_dialog.logout" = "로그아웃"; "confirmation_dialog.delete" = "삭제"; "confirmation_dialog.clear" = "삭제"; @@ -127,60 +123,21 @@ // MARK: SettingView "setting_view.setting" = "설정"; // SettingStateRoute -"setting_state_route.account" = "계정"; -"setting_state_route.general" = "일반"; -"setting_state_route.appearance" = "외관"; -"setting_state_route.reading" = "읽기"; -"setting_state_route.download" = "다운로드"; -"setting_state_route.laboratory" = "실험실"; -"setting_state_route.about" = "정보"; // MARK: AccountSettingView -"account_setting_view.account" = "계정"; -"account_setting_view.shows_new_dawn_greeting" = "새벽 인사 구독하기"; "account_setting_view.login" = "로그인"; -"account_setting_view.account_configuration" = "계정 설정"; -"account_setting_view.tags_management" = "태그 구독 관리"; -"account_setting_view.copy_cookies" = "쿠키 복사하기"; // CookieValue // MARK: LoginView "login_view.login" = "로그인"; -"login_view.username" = "이름"; -"login_view.password" = "비밀번호"; // MARK: GeneralSettingView -"general_setting_view.general" = "일반"; "general_setting_view.language" = "언어"; -"general_setting_view.auto_lock" = "앱 자동 잠금"; -"general_setting_view.enables_tags_extension" = "태그 확장 기능 사용하기"; -"general_setting_view.translates_tags" = "태그 번역하기"; -"general_setting_view.shows_tags_search_suggestion" = "태그 검색 제안 보여주기"; -"general_setting_view.shows_images_in_tags" = "태그에 이미지 보여주기"; -"general_setting_view.redirects_links_to_the_selected_host" = "선택한 서버로 이동하기"; -"general_setting_view.detects_links_from_clipboard" = "클립보드의 링크 인식하기"; -"general_setting_view.background_blur_radius" = "백그라운드 흐림 정도"; -"general_setting_view.app_activity_logs" = "앱 활동 로그"; -"general_setting_view.import_custom_translations" = "사용자 지정 번역 가져오기"; -"general_setting_view.remove_custom_translations" = "사용자 지정 번역 삭제"; -"general_setting_view.clear_image_caches" = "이미지 캐시 지우기"; -"general_setting_view.default_language_description" = "알 수 없음"; -"general_setting_view.tags" = "태그"; -"general_setting_view.navigation" = "내비게이션"; -"general_setting_view.security" = "개인 정보 보호"; -"general_setting_view.caches" = "캐시"; // AutoLockPolicy "auto_lock_policy.never" = "안 함"; "auto_lock_policy.instantly" = "즉시"; // MARK: AppActivityLogsView -"app_activity_logs_view.title" = "앱 활동 로그"; -"app_activity_logs_view.no_logs" = "로그가 없습니다"; -"app_activity_logs_view.current" = "현재"; -"app_activity_logs_view.run" = "실행 %@"; -"app_activity_logs_view.more_logs" = "더 많은 로그"; -"app_activity_logs_view.open_in_files" = "파일 앱에서 열기"; -"app_activity_logs_view.runs" = "실행"; "app_activity_logs_view.level.undefined" = "정의되지 않음"; "app_activity_logs_view.level.debug" = "디버그"; "app_activity_logs_view.level.info" = "정보"; @@ -189,17 +146,6 @@ "app_activity_logs_view.level.fault" = "결함"; // MARK: AppearanceSettingView -"appearance_setting_view.appearance" = "외관"; -"appearance_setting_view.theme" = "테마"; -"appearance_setting_view.tint_color" = "액센트 색상"; -"appearance_setting_view.display_mode" = "표시방식"; -"appearance_setting_view.shows_tags_in_list" = "리스트에서 태그 보여주기"; -"appearance_setting_view.maximum_number_of_tags" = "태그 갯수"; -"appearance_setting_view.displays_japanese_title" = "일본어 제목 보여주기"; -"appearance_setting_view.app_icon" = "앱 아이콘"; -"appearance_setting_view.infite" = "제한 없음"; -"appearance_setting_view.list" = "리스트"; -"appearance_setting_view.gallery" = "갤러리"; // PreferredColorScheme "preferred_color_scheme.automatic" = "자동"; "preferred_color_scheme.light" = "라이트"; @@ -215,7 +161,6 @@ "list_display_mode.thumbnail" = "썸네일"; // MARK: AppIconView -"app_icon_view.app_icon" = "앱 아이콘"; // MARK: reading_settingView // ReadingDirection @@ -224,18 +169,8 @@ "reading_direction.left_to_right" = "왼쪽에서 오른쪽으로"; // MARK: LaboratorySettingView -"laboratory_setting_view.laboratory" = "실험실"; -"laboratory_setting_view.bypasses_SNI_filtering" = "SNI 차단 우회"; // MARK: AboutView -"about_view.ehPanda" = "EhPanda"; -"about_view.website" = "웹사이트"; -"about_view.altStore_source" = "AltStore 소스"; -"about_view.version" = "버전"; -"about_view.special_thanks" = "특별히 감사드리는 분들"; -"about_view.code_level_contributors" = "코드 기여자"; -"about_view.translation_contributors" = "번역 기여자"; -"about_view.acknowledgements" = "도움을 주신 분들"; // MARK: DetailView "detail_view.share" = "공유"; @@ -271,12 +206,6 @@ "downloads_view.update" = "업데이트"; // MARK: DownloadSettingView -"download_setting_view.title" = "다운로드"; -"download_setting_view.network" = "네트워크"; -"download_setting_view.concurrent_image_downloads" = "동시 이미지 다운로드 수"; -"download_setting_view.retry_failed_pages_automatically" = "실패한 페이지 자동 재시도"; -"download_setting_view.allow_cellular_downloads" = "셀룰러 다운로드 허용"; -"download_setting_view.network_description" = "한 번에 하나의 갤러리만 다운로드됩니다. 이 설정으로 한 갤러리 안에서 동시에 다운로드할 페이지 수, 셀룰러 다운로드 허용 여부, 그리고 파일을 앱의 Downloads 폴더에 저장하는 방식을 제어합니다."; // MARK: CommentsView @@ -305,19 +234,7 @@ "filter_range.watched" = "주시 태그"; // MARK: EhSettingView -"eh_setting_view.host_settings" = "%@ 설정"; -"eh_setting_view.profile_settings" = "프로필 설정"; -"eh_setting_view.selected_profile" = "선택한 프로필"; -"eh_setting_view.set_as_default" = "기본으로 설정"; -"eh_setting_view.delete_profile" = "프로필 삭제"; -"eh_setting_view.rename" = "이름 변경"; -"eh_setting_view.create_new" = "추가"; -"eh_setting_view.done" = "완료"; - -"eh_setting_view.image_load_settings" = "이미지 로드 설정"; -"eh_setting_view.load_images_through_the_hath_network" = "Hath 네트워크를 통하여 이미지 로드"; -"eh_setting_view.browsing_country" = "브라우징하는 나라"; -"eh_setting_view.browsing_country_description" = "**%@**에서 사이트를 탐색하거나 이 나라에서 VPN이나 프록시를 사용하려고 하는 것 같네요. 이런 경우엔 사이트에서 이 지역의 H@H 클라이언트의 이미지를 로드하려고 시도할 거에요. 만약에 이 나라가 잘못되었거나 분할 터널링 VPN을 사용하는 경우와 같이 어떤 이유로든 다른 지역을 사용하려는 경우라면, 아래에서 다른 나라를 선택할 수 있어요."; + // EhSetting.LoadThroughHathSetting "load_through_hath_setting.any_client" = "어떤 클라이언트에서든"; "load_through_hath_setting.default_port_only" = "기본 포트 클라이언트만"; @@ -328,26 +245,13 @@ "load_through_hath_setting.modern_no_description" = "기부자 전용 기능이에요. 심각한 문제가 있는 경우를 제외하고는 사용하지 말아주세요."; "load_through_hath_setting.legacy_no_description" = "기부자 전용 기능이에요. 최신 브라우저에서는 제대로 작동하지 않을 수 있어요. 오래된 브라우저에서만 사용해주세요."; -"eh_setting_view.image_size_settings" = "이미지 사이즈 설정"; -"eh_setting_view.image_resolution" = "이미지 해상도"; -"eh_setting_view.image_resolution_description" = "일반적으로 이미지는 온라인 뷰어를 위해 1280 픽셀의 수평 해상도로 작아져요. 아래의 압축된 해상도 중 하나를 선택할 수 있어요. 서버 과부하를 막기 위해, 1280 이상의 해상도는 도네이션을 한 사람, hath perk를 가진 사람, 그리고 UID가 300만 이하인 사람들로 일시적으로 제한되어요."; -"eh_setting_view.image_size" = "이미지 사이즈"; -"eh_setting_view.image_size_description" = "사이트가 사용자의 화면 너비에 맞게 이미지를 자동으로 축소시키지만, 수동으로 크기를 정할 수도 있어요. 크기 조정은 브라우저 측에서 수행되므로 이미지가 다시 샘플링되지 않아요. (0 = no limit)"; -"eh_setting_view.horizontal" = "가로"; -"eh_setting_view.vertical" = "세로"; // EhSetting.ImageResolution "image_resolution.auto" = "자동"; -"eh_setting_view.gallery_name_display" = "갤러리 이름 보이기"; -"eh_setting_view.gallery_name" = "갤러리 이름"; -"eh_setting_view.gallery_name_description" = "영어 제목과 일본어 제목 중 기본값으로 보일 언어를 선택해주세요."; // EhSetting.GalleryName "gallery_name.default" = "영어 제목"; "gallery_name.japanese" = "일본어 제목(가능하면)"; -"eh_setting_view.archiver_settings" = "아카이버"; -"eh_setting_view.archiver_behavior" = "아카이버 동작 방법 설정"; -"eh_setting_view.archiver_behavior_description" = "아카이버의 기본 동작은 원본 또는 저화질 갤러리 저장에 대한 비용과 선택을 확인한 다음 다른 곳에서 클릭하거나 복사할 수 있는 링크를 표시하는 것입니다. 여기서 이 동작을 변경할 수 있습니다."; // EhSetting.ArchiverBehavior "eh_setting.archiver_behavior.manual_select_manual_start" = "수동 선택, 수동 시작 (기본)"; "eh_setting.archiver_behavior.manual_select_auto_start" = "수동 선택, 자동 시작"; @@ -356,12 +260,6 @@ "eh_setting.archiver_behavior.auto_select_resample_manual_start" = "자동으로 저화질을 선택, 수동 시작"; "eh_setting.archiver_behavior.auto_select_resample_auto_start" = "자동으로 저화질을 선택, 자동 시작"; -"eh_setting_view.front_page_settings" = "프론트 페이지 설정"; -"eh_setting_view.display_mode" = "표시방식"; -"eh_setting_view.display_mode_description" = "프론트와 검색 페이지에서 사용할 디스플레이 모드를 선택하세요."; -"eh_setting_view.show_search_range_indicator" = "검색 범위 표시기"; -"eh_setting_view.show_search_range_indicator_description" = "검색 범위 표시기 표시"; -"eh_setting_view.gallery_category" = "프론트와 검색 페이지에서 어떤 카테고리가 보여지도록 할까요?"; // EhSetting.DisplayMode "display_mode.compact" = "컴팩트"; "display_mode.thumbnail" = "썸네일"; @@ -369,54 +267,22 @@ "display_mode.minimal" = "미니멀"; "display_mode.minimalPlus" = "미니멀+"; -"eh_setting_view.optional_UI_elements" = "선택적 UI 요소"; -"eh_setting_view.optional_UI_elements_description" = "일부 예전 UI 요소는 이제 기본적으로 꺼져 있어요. 여기서 다시 켤 수 있어요."; -"eh_setting_view.enable_gallery_thumbnail_selector" = "갤러리 화면에서 썸네일 선택기 사용"; -"eh_setting_view.favorites" = "즐겨찾기"; -"eh_setting_view.favorite_categories" = "여기서 좋아하는 장르들을 선택하고 이름을 바꿀 수 있어요."; -"eh_setting_view.favorites_sort_order" = "관심 순서를 배열"; -"eh_setting_view.favorites_sort_order_description" = "당신의 관심 페이지의 기본 정렬 방식을 선택할 수 있어요. 2016년 3월 개정 전에 추가된 즐겨찾기는 타임스탬프가 저장되지 않아 이 설정에 관계없이 갤러리가 게시된 시간으로 정렬되어요."; // EhSetting.FavoritesSortOrder "favorites_sort_order.last_update_time" = "마지막 업데이트 시간으로"; "favorites_sort_order.favorited_time" = "별점 시간으로"; -"eh_setting_view.ratings" = "별점"; -"eh_setting_view.ratings_color" = "별점 색깔"; -"eh_setting_view.ratings_color_prompt" = "RRGGB"; -"eh_setting_view.ratings_color_description" = "기본적으로 등급을 매긴 갤러리는 별 2개 이하의 등급에 대해 빨간색, 2.5~4개의 등급에 대해 녹색, 4.5~5개의 등급에 대해 파란색 별로 표시되어요. 아래에 원하는 색상 조합을 입력하여 사용자 정의할 수 있어요. 각 문자는 별 하나를 표현해요. 기본 RRGGB는 첫 번째와 두 번째 별의 경우 R(ed), 세 번째와 네 번째 별의 경우 G(reen), 다섯 번째 별의 경우 B(lue)를 의미해요. 일반 별에 (Y)ellow를 사용할 수도 있어요. 모든 5글자의 R/G/B/Y 콤보가 작동해요."; -"eh_setting_view.tag_filtering_threshold" = "태그 필터링 임계값"; -"eh_setting_view.tag_filtering_threshold_description" = "마이너스 가중치로 My Tags에 추가하여 태그를 소프트 필터할 수 있어요. 갤러리에 이 값 이하의 가중치를 추가하는 태그가 있으면 보기에서 필터링되어요. 이 임계값은 0과 -9999 사이에서 설정할 수 있어요."; -"eh_setting_view.tag_watching_threshold" = "태그 보여주기 임계값"; -"eh_setting_view.tag_watching_threshold_description" = "최근에 업로드된 갤러리는 최소 1개의 Watched 태그가 있고 Watched 태그의 가중치의 합이 이 값 이상이 될 경우 Watched 화면에 포함되어요. 이 임계값은 0과 9999 사이에서 설정할 수 있어요."; -"eh_setting_viewfiltered_removal_count" = "필터로 제거된 수"; -"eh_setting_view.filtered_removal_count_description" = "\"기본 필터가 이 페이지에서 갤러리 XX개를 제거했어요\" 문구를 표시할까요?"; -"eh_setting_view.show_filtered_removal_count" = "필터로 제거된 수 표시"; -"eh_setting_view.excluded_languages" = "제외된 언어"; -"eh_setting_view.excluded_languages_description" = "갤러리 목록에서 특정 언어로 된 갤러리를 숨기고 검색하려면 아래 목록에서 해당 갤러리를 선택해주세요. 검색어에 관계없이 일치하는 갤러리는 나타나지 않아요."; // EhSetting.ExcludedLanguagesCategory "excluded_languages_category.original" = "원본"; "excluded_languages_category.translated" = "번역됨"; "excluded_languages_category.rewrite" = "다시 쓰기"; -"eh_setting_view.excluded_uploaders" = "제외된 업로드"; -"eh_setting_view.excluded_uploaders_description" = "갤러리 목록 및 검색에서 특정 업로더의 갤러리를 숨기려면 아래에 해당 갤러리를 추가해주세요. 한 줄에 하나의 사용자 이름을 입력해주세요. 이러한 업로더의 갤러리는 검색 쿼리에 관계없이 나타나지 않아요."; -"eh_setting_view.excluded_uploaders_count" = "**%@ / %@** 개의 슬롯을 사용하고 있어요."; -"eh_setting_view.search_result_count" = "검색 결과 수"; -"eh_setting_view.result_count" = "결과 수"; -"eh_setting_view.result_count_description" = "인덱스 / 검색 / 토렌트 검색 페이지에 대해 페이지당 몇 개의 결과를 원하시나요?\n(Hath Perk: 페이징 확장 필요)"; -"eh_setting_view.thumbnail_settings" = "썸네일 설정"; -"eh_setting_view.thumbnail_load_timing" = "썸네일 로드 시간"; -"eh_setting_view.thumbnail_load_timing_description" = "목록 모드를 사용할 때 앞 페이지의 마우스 오버 미리 보기를 어떻게 로드할까요?"; -"eh_setting_view.thumbnail_configuration" = "모든 방문한 갤러리에 대하여 기본 썸네일을 설정할 수 있어요."; -"eh_setting_view.thumbnail_size" = "사이즈"; -"eh_setting_view.thumbnail_row_count" = "줄"; // EhSetting.ThumbnailLoadTiming "thumbnail_load_timing.on_mouse_over" = "마우스를 올릴 때"; "thumbnail_load_timing.on_page_load" = "페이지 로드될 때"; @@ -428,17 +294,8 @@ "thumbnail_size.small" = "작게"; "thumbnail_size.auto" = "자동"; -"eh_setting_view.cover_scaling" = "표지 크기 조절"; -"eh_setting_view.scale_factor" = "크기 비율"; -"eh_setting_view.cover_scale_factor" = "썸네일 또는 확장 표시방식에서는 갤러리 목록의 표지 크기를 75%%에서 150%% 사이로 조절할 수 있어요."; -"eh_setting_view.viewport_override" = "뷰포트 조정"; -"eh_setting_view.virtual_width" = "가상 너비"; -"eh_setting_view.virtual_width_description" = "모바일 장치의 사이트 가상 너비를 설정할 수 있어요. 일반적으로 DPI에 따라 장치에 의해 자동으로 결정되어요. 100%% 썸네일 스케일의 추천 값은 640에서 1400 사이에요."; -"eh_setting_view.gallery_comments" = "갤러리 댓글"; -"eh_setting_view.comments_sort_order" = "댓글 순서"; -"eh_setting_view.comments_votes_show_timing" = "평가의 시간을 보이기"; // EhSetting.CommentsSortOrder "comments_sort_order.oldest" = "가장 이른 순서"; "comments_sort_order.recent" = "최신순"; @@ -447,22 +304,12 @@ "comments_votes_show_timing.on_hover_or_click" = "점수를 가리키커나 클리하기"; "comments_votes_show_timing.always" = "항상"; -"eh_setting_view.gallery_tags" = "갤러리 태그"; -"eh_setting_view.tags_sort_order" = "태그 순서를 배열"; // EhSetting.tags_sort_order "tags_sort_order.alphabetical" = "알파벳순으로"; "tags_sort_order.tag_power" = "태크 가중치로"; -"eh_setting_view.gallery_page_thumbnail_labeling" = "갤러리 페이지 썸네일 라벨"; -"eh_setting_view.show_label_below_gallery_thumbnails" = "갤러리 썸네일 아래에 라벨 표시"; -"eh_setting_view.original_images" = "다시 샘플링된 버전 대신 원본 이미지를 사용할까요? 위에서 가로 해상도를 \"자동\" 이외로 선택했고 해당 이미지가 그보다 넓은 경우나, 원본 이미지가 10 MiB(1년 넘은 갤러리는 4 MiB)보다 큰 경우에는 다시 샘플링된 이미지가 계속 사용되어요."; -"eh_setting_view.use_original_images" = "원본 뷰어 적용"; -"eh_setting_view.multi_page_viewer" = "멀티 페이지 뷰어"; -"eh_setting_view.use_multi_page_viewer" = "다중 페이지 뷰어 적용"; -"eh_setting_view.display_style" = "보여주기 스타일"; -"eh_setting_view.show_thumbnail_pane" = "썸네일 창 표시"; // EhSetting.MultiplePageViewerStyle "multiple_page_viewer_style.align_left_scale_if_over_width" = "왼쪽 정렬, 너비 초과할 때 크기 맞추기"; "multiple_page_viewer_style.align_center_scale_if_over_width" = "가운데 정렬, 너비 초과할 때 크기 맞추기"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index cb40cbab0..33423e0ad 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -78,12 +78,8 @@ "app_error.local_file_operation_failed" = "本地文件操作失败。"; // MARK: ConfirmationDialog -"confirmation_dialog.remove_custom_translations" = "确定要移除自定义翻译吗?"; -"confirmation_dialog.logout_description" = "确定要退出登录吗?"; "confirmation_dialog.delete_description" = "确定要删除吗?"; "confirmation_dialog.clear_description" = "确定要清空吗?"; -"confirmation_dialog.remove" = "移除"; -"confirmation_dialog.logout" = "退出登录"; "confirmation_dialog.delete" = "删除"; "confirmation_dialog.clear" = "清空"; @@ -127,60 +123,21 @@ // MARK: SettingView "setting_view.setting" = "设置"; // SettingStateRoute -"setting_state_route.account" = "账户"; -"setting_state_route.general" = "一般"; -"setting_state_route.appearance" = "外观"; -"setting_state_route.reading" = "阅读"; -"setting_state_route.download" = "下载"; -"setting_state_route.laboratory" = "实验室"; -"setting_state_route.about" = "关于"; // MARK: AccountSettingView -"account_setting_view.account" = "账户"; -"account_setting_view.shows_new_dawn_greeting" = "显示黎明问候"; "account_setting_view.login" = "登录"; -"account_setting_view.account_configuration" = "账户设置"; -"account_setting_view.tags_management" = "管理标签订阅"; -"account_setting_view.copy_cookies" = "复制 Cookies"; // CookieValue // MARK: LoginView "login_view.login" = "登录"; -"login_view.username" = "用户名"; -"login_view.password" = "密码"; // MARK: GeneralSettingView -"general_setting_view.general" = "一般"; "general_setting_view.language" = "语言"; -"general_setting_view.auto_lock" = "自动锁定"; -"general_setting_view.enables_tags_extension" = "启用标签扩展"; -"general_setting_view.translates_tags" = "翻译标签"; -"general_setting_view.shows_tags_search_suggestion" = "显示标签搜索建议"; -"general_setting_view.shows_images_in_tags" = "显示标签中的图像"; -"general_setting_view.redirects_links_to_the_selected_host" = "重定向链接到选定的站点"; -"general_setting_view.detects_links_from_clipboard" = "从剪切板检测链接"; -"general_setting_view.background_blur_radius" = "后台模糊效果"; -"general_setting_view.app_activity_logs" = "应用活动日志"; -"general_setting_view.import_custom_translations" = "导入自定义翻译"; -"general_setting_view.remove_custom_translations" = "移除自定义翻译"; -"general_setting_view.clear_image_caches" = "清空图片缓存"; -"general_setting_view.default_language_description" = "无效"; -"general_setting_view.tags" = "标签"; -"general_setting_view.navigation" = "导航"; -"general_setting_view.security" = "安全"; -"general_setting_view.caches" = "缓存"; // AutoLockPolicy "auto_lock_policy.never" = "不锁定"; "auto_lock_policy.instantly" = "立即"; // MARK: AppActivityLogsView -"app_activity_logs_view.title" = "应用活动日志"; -"app_activity_logs_view.no_logs" = "未找到日志"; -"app_activity_logs_view.current" = "当前"; -"app_activity_logs_view.run" = "运行 %@"; -"app_activity_logs_view.more_logs" = "更多日志"; -"app_activity_logs_view.open_in_files" = "在“文件”中打开"; -"app_activity_logs_view.runs" = "运行"; "app_activity_logs_view.level.undefined" = "未定义"; "app_activity_logs_view.level.debug" = "调试"; "app_activity_logs_view.level.info" = "信息"; @@ -189,17 +146,6 @@ "app_activity_logs_view.level.fault" = "故障"; // MARK: AppearanceSettingView -"appearance_setting_view.appearance" = "外观"; -"appearance_setting_view.theme" = "主题"; -"appearance_setting_view.tint_color" = "主题色"; -"appearance_setting_view.display_mode" = "显示样式"; -"appearance_setting_view.shows_tags_in_list" = "在列表中显示标签"; -"appearance_setting_view.maximum_number_of_tags" = "标签数量上限"; -"appearance_setting_view.displays_japanese_title" = "显示日文标题"; -"appearance_setting_view.app_icon" = "应用图标"; -"appearance_setting_view.infite" = "无限"; -"appearance_setting_view.list" = "列表"; -"appearance_setting_view.gallery" = "画廊"; // PreferredColorScheme "preferred_color_scheme.automatic" = "自动"; "preferred_color_scheme.light" = "浅色"; @@ -215,7 +161,6 @@ "list_display_mode.thumbnail" = "缩略图"; // MARK: AppIconView -"app_icon_view.app_icon" = "应用图标"; // MARK: reading_settingView // ReadingDirection @@ -224,18 +169,8 @@ "reading_direction.left_to_right" = "左至右"; // MARK: LaboratorySettingView -"laboratory_setting_view.laboratory" = "实验室"; -"laboratory_setting_view.bypasses_SNI_filtering" = "域前置绕过 SNI 阻断"; // MARK: AboutView -"about_view.ehPanda" = "EhPanda"; -"about_view.website" = "网站"; -"about_view.altStore_source" = "AltStore 源"; -"about_view.version" = "版本"; -"about_view.special_thanks" = "特别致谢"; -"about_view.code_level_contributors" = "代码级贡献者"; -"about_view.translation_contributors" = "翻译贡献者"; -"about_view.acknowledgements" = "致谢"; // MARK: DetailView "detail_view.share" = "分享"; @@ -271,12 +206,6 @@ "downloads_view.update" = "更新"; // MARK: DownloadSettingView -"download_setting_view.title" = "下载"; -"download_setting_view.network" = "网络"; -"download_setting_view.concurrent_image_downloads" = "并发图片下载"; -"download_setting_view.retry_failed_pages_automatically" = "自动重试失败页面"; -"download_setting_view.allow_cellular_downloads" = "允许蜂窝网络下载"; -"download_setting_view.network_description" = "每次只会下载一个画廊。这个设置用于控制单个画廊内页面的并行下载数量、是否允许蜂窝网络下载,以及文件在应用 Downloads 文件夹中的存储方式。"; // MARK: CommentsView @@ -305,19 +234,7 @@ "filter_range.watched" = "标签"; // MARK: EhSettingView -"eh_setting_view.host_settings" = "%@ 设置"; -"eh_setting_view.profile_settings" = "档案设置"; -"eh_setting_view.selected_profile" = "当前选定档案"; -"eh_setting_view.set_as_default" = "设为默认"; -"eh_setting_view.delete_profile" = "删除档案"; -"eh_setting_view.rename" = "重命名"; -"eh_setting_view.create_new" = "创建新档案"; -"eh_setting_view.done" = "完成"; - -"eh_setting_view.image_load_settings" = "图片加载设置"; -"eh_setting_view.load_images_through_the_hath_network" = "通过 Hath 网络加载图像"; -"eh_setting_view.browsing_country" = "浏览国家"; -"eh_setting_view.browsing_country_description" = "你似乎正在 **%@** 浏览此网页,或是使用了一个来自这个国家的 VPN 或代理,这意味着网站将尝试通过在此区域的 H@H 客户端加载图片。如果该结果不正确,或你想通过其它地区的 H@H 客户端加载图片(例如你正在使用分割隧道 VPN),你可以在下方选择另一个国家。"; + // EhSetting.LoadThroughHathSetting "load_through_hath_setting.any_client" = "所有客户端"; "load_through_hath_setting.default_port_only" = "仅使用默认端口的客户端"; @@ -328,26 +245,13 @@ "load_through_hath_setting.modern_no_description" = "仅限赞助者。配额消耗会加快。只建议在遇到严重问题时使用。"; "load_through_hath_setting.legacy_no_description" = "仅限赞助者。在现代浏览器可能不可用。只建议在旧式 / 过时的浏览器使用。"; -"eh_setting_view.image_size_settings" = "图像尺寸设置"; -"eh_setting_view.image_resolution" = "图像分辨率"; -"eh_setting_view.image_resolution_description" = "通常情况,图像将重采样到 1280 像素宽度以用于在线浏览,你也可以选择以下重新采样分辨率。但是为了避免负载过高,高于 1280 像素将只供给于赞助者、特殊贡献者,以及 UID 小于 3,000,000 的用户。"; -"eh_setting_view.image_size" = "图像尺寸"; -"eh_setting_view.image_size_description" = "虽然图片会自动根据窗口缩小,你也可以手动设置最大大小,图片并没有重新采样。(0 为不限制)"; -"eh_setting_view.horizontal" = "宽度"; -"eh_setting_view.vertical" = "高度"; // EhSetting.ImageResolution "image_resolution.auto" = "自动"; -"eh_setting_view.gallery_name_display" = "画廊名称显示"; -"eh_setting_view.gallery_name" = "画廊名称"; -"eh_setting_view.gallery_name_description" = "很多画廊都同时拥有英文或者日文标题,你想默认显示哪一个?"; // EhSetting.GalleryName "gallery_name.default" = "默认标题"; "gallery_name.japanese" = "日文标题(如果有)"; -"eh_setting_view.archiver_settings" = "归档设置"; -"eh_setting_view.archiver_behavior" = "归档下载方式"; -"eh_setting_view.archiver_behavior_description" = "默认归档下载方式为手动选择(原画质或压缩画质),然后手动复制或点击下载链接。你可以修改归档下载方式。"; // EhSetting.ArchiverBehavior "eh_setting.archiver_behavior.manual_select_manual_start" = "手动选择,手动下载(默认)"; "eh_setting.archiver_behavior.manual_select_auto_start" = "手动选择,自动下载"; @@ -356,12 +260,6 @@ "eh_setting.archiver_behavior.auto_select_resample_manual_start" = "自动选择压缩画质,手动下载"; "eh_setting.archiver_behavior.auto_select_resample_auto_start" = "自动选择压缩画质,自动下载"; -"eh_setting_view.front_page_settings" = "扉页设置"; -"eh_setting_view.display_mode" = "显示样式"; -"eh_setting_view.display_mode_description" = "你希望在扉页和搜索页显示哪种样式?"; -"eh_setting_view.show_search_range_indicator" = "搜索范围指示器"; -"eh_setting_view.show_search_range_indicator_description" = "显示搜索范围指示器"; -"eh_setting_view.gallery_category" = "你希望在扉页和搜索页看到哪些类别?"; // EhSetting.DisplayMode "display_mode.compact" = "紧凑"; "display_mode.thumbnail" = "缩略图"; @@ -369,54 +267,22 @@ "display_mode.minimal" = "最小化"; "display_mode.minimalPlus" = "最小化 +"; -"eh_setting_view.optional_UI_elements" = "可选的 UI 组件"; -"eh_setting_view.optional_UI_elements_description" = "一些旧版 UI 组件现已默认禁用。您可以在此启用这些组件。"; -"eh_setting_view.enable_gallery_thumbnail_selector" = "在画廊页面启用缩图选择器"; -"eh_setting_view.favorites" = "收藏"; -"eh_setting_view.favorite_categories" = "在这里你可以重命名你的收藏夹。"; -"eh_setting_view.favorites_sort_order" = "收藏排序方式"; -"eh_setting_view.favorites_sort_order_description" = "你也可以选择收藏夹中默认排序。注意:2016 年 3 月改版之前加入收藏夹的画廊并未保存收藏时间,会以画廊发布时间代替。"; // EhSetting.FavoritesSortOrder "favorites_sort_order.last_update_time" = "按更新时间"; "favorites_sort_order.favorited_time" = "按收藏时间"; -"eh_setting_view.ratings" = "评分"; -"eh_setting_view.ratings_color" = "评分颜色"; -"eh_setting_view.ratings_color_prompt" = "RRGGB"; -"eh_setting_view.ratings_color_description" = "默认设置下,你评为 2 星及以下的画廊显示为红星,2.5 ~ 4 星显示为绿星,4.5 ~ 5 星显示为蓝星。你可以将其设定为其它颜色组合。每一个字幕代表一颗星, 默认的 RRGGB 表示第一第二颗星显示为红色 R(ed),第三第四颗星显示是绿色 G(reen),第五颗星显示为蓝色 B(lue)。你也可以使用黄色 (Y)ellow,R/G/B/Y 任何五个组合都是有效的。"; -"eh_setting_view.tag_filtering_threshold" = "标签筛选阈值"; -"eh_setting_view.tag_filtering_threshold_description" = "你可以通过将标签加入“我的标签”并设置一个负权重来软过滤它们。如果一个作品所有的标签权重之和低于设定值,此作品将从视图中被过滤。这个值可以设定为 0 ~ -9999。"; -"eh_setting_view.tag_watching_threshold" = "标签订阅阈值"; -"eh_setting_view.tag_watching_threshold_description" = "你可以通过将标签加入“我的标签”并设置一个正权重来关注它们。如果一个最近上传的作品所有标签的权重之和高于设定值,则它将会被包含在“关注”里。这个值可以设定为 0 ~ 9999。"; -"eh_setting_viewfiltered_removal_count" = "筛选器移除数"; -"eh_setting_view.filtered_removal_count_description" = "要显示“你的默认筛选器从本页移除了 XX 个画廊”提示吗?"; -"eh_setting_view.show_filtered_removal_count" = "显示筛选器移除数"; -"eh_setting_view.excluded_languages" = "屏蔽的语言"; -"eh_setting_view.excluded_languages_description" = "如果你希望以从列表或搜索结果中隐藏特定语言的画廊,请从下面的列表中选择。注意:无论搜索条件为何,这些画廊都不会出现。"; // EhSetting.ExcludedLanguagesCategory "excluded_languages_category.original" = "原始版本"; "excluded_languages_category.translated" = "翻译版本"; "excluded_languages_category.rewrite" = "改编版本"; -"eh_setting_view.excluded_uploaders" = "屏蔽的上传者"; -"eh_setting_view.excluded_uploaders_description" = "如果你希望在画廊中和搜索中隐藏某个上传者的话,请把他们的用户名填写在下方,每行一个。注意:无论搜索条件为何,这些上传者都不会出现。"; -"eh_setting_view.excluded_uploaders_count" = "已使用 **%@ / %@** 个屏蔽槽位。"; -"eh_setting_view.search_result_count" = "搜索结果数"; -"eh_setting_view.result_count" = "结果数"; -"eh_setting_view.result_count_description" = "搜索页面每页显示多少条数据?\n(需要“Hath Perk:页面扩大”)"; -"eh_setting_view.thumbnail_settings" = "缩略图设置"; -"eh_setting_view.thumbnail_load_timing" = "缩略图加载时机"; -"eh_setting_view.thumbnail_load_timing_description" = "你希望列表中的鼠标悬停缩略图何时加载?"; -"eh_setting_view.thumbnail_configuration" = "你可以设定一个对所有画廊生效的默认缩略图配置。"; -"eh_setting_view.thumbnail_size" = "尺寸"; -"eh_setting_view.thumbnail_row_count" = "行数"; // EhSetting.ThumbnailLoadTiming "thumbnail_load_timing.on_mouse_over" = "鼠标悬停时"; "thumbnail_load_timing.on_page_load" = "页面加载时"; @@ -428,17 +294,8 @@ "thumbnail_size.small" = "较小"; "thumbnail_size.auto" = "自动"; -"eh_setting_view.cover_scaling" = "封面缩放"; -"eh_setting_view.scale_factor" = "缩放比例"; -"eh_setting_view.cover_scale_factor" = "缩略图和扩展模式下的画廊列表封面可以缩放为 75%% 到 150%% 之间的值。"; -"eh_setting_view.viewport_override" = "覆写可视区域"; -"eh_setting_view.virtual_width" = "虚拟宽度"; -"eh_setting_view.virtual_width_description" = "允许你覆写移动设备的可视区域,默认是根据 DPI 自动计算的,100%% 缩略图比例下的合理值在 640 到 1400 之间。"; -"eh_setting_view.gallery_comments" = "画廊评论"; -"eh_setting_view.comments_sort_order" = "评论排序方式"; -"eh_setting_view.comments_votes_show_timing" = "显示评论分数时机"; // EhSetting.CommentsSortOrder "comments_sort_order.oldest" = "按最早的评论"; "comments_sort_order.recent" = "按最新的评论"; @@ -447,22 +304,12 @@ "comments_votes_show_timing.on_hover_or_click" = "悬停或点击时"; "comments_votes_show_timing.always" = "始终显示"; -"eh_setting_view.gallery_tags" = "画廊标签"; -"eh_setting_view.tags_sort_order" = "标签排序方式"; // EhSetting.tags_sort_order "tags_sort_order.alphabetical" = "按字母排序"; "tags_sort_order.tag_power" = "按标签权重"; -"eh_setting_view.gallery_page_thumbnail_labeling" = "画廊页面缩略图标签"; -"eh_setting_view.show_label_below_gallery_thumbnails" = "在画廊缩略图下方显示标签"; -"eh_setting_view.original_images" = "是否使用原始图像而非重新采样的版本?如果您在上方选择的水平分辨率不是“自动”,并且所查看的图像更宽,或者原始图像大于 10 MiB(对于超过一年的图库,则为 4 MiB),那么仍将使用重新采样的图像。"; -"eh_setting_view.use_original_images" = "显示原图"; -"eh_setting_view.multi_page_viewer" = "多页查看器"; -"eh_setting_view.use_multi_page_viewer" = "使用多页查看器"; -"eh_setting_view.display_style" = "显示样式"; -"eh_setting_view.show_thumbnail_pane" = "显示缩略图侧栏"; // EhSetting.MultiplePageViewerStyle "multiple_page_viewer_style.align_left_scale_if_over_width" = "左对齐,图像过宽时缩放"; "multiple_page_viewer_style.align_center_scale_if_over_width" = "居中对齐,图像过宽时缩放"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 70c213b41..4ef22a30f 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -78,12 +78,8 @@ "app_error.local_file_operation_failed" = "本機檔案操作失敗。"; // MARK: ConfirmationDialog -"confirmation_dialog.remove_custom_translations" = "是否確定要刪除所有自訂翻譯?"; -"confirmation_dialog.logout_description" = "確定要登出嗎?"; "confirmation_dialog.delete_description" = "確定要刪除?"; "confirmation_dialog.clear_description" = "確定要清空嗎?"; -"confirmation_dialog.remove" = "移除"; -"confirmation_dialog.logout" = "登出"; "confirmation_dialog.delete" = "刪除"; "confirmation_dialog.clear" = "清空"; @@ -127,60 +123,21 @@ // MARK: SettingView "setting_view.setting" = "設定"; // SettingStateRoute -"setting_state_route.account" = "帳號"; -"setting_state_route.general" = "一般"; -"setting_state_route.appearance" = "外觀"; -"setting_state_route.reading" = "閱讀"; -"setting_state_route.download" = "下載"; -"setting_state_route.laboratory" = "實驗性功能"; -"setting_state_route.about" = "關於"; // MARK: AccountSettingView -"account_setting_view.account" = "帳號設定"; -"account_setting_view.shows_new_dawn_greeting" = "顯示黎明問候"; "account_setting_view.login" = "登入"; -"account_setting_view.account_configuration" = "帳號設定"; -"account_setting_view.tags_management" = "管理訂閱標籤"; -"account_setting_view.copy_cookies" = "複製 Cookies"; // CookieValue // MARK: LoginView "login_view.login" = "登入"; -"login_view.username" = "Username"; -"login_view.password" = "Password"; // MARK: GeneralSettingView -"general_setting_view.general" = "一般設定"; "general_setting_view.language" = "語言"; -"general_setting_view.auto_lock" = "自動鎖定"; -"general_setting_view.enables_tags_extension" = "啟用自訂標籤擴充功能"; -"general_setting_view.translates_tags" = "標籤翻譯"; -"general_setting_view.shows_tags_search_suggestion" = "搜尋時顯示標籤建議"; -"general_setting_view.shows_images_in_tags" = "在標籤顯示圖片"; -"general_setting_view.redirects_links_to_the_selected_host" = "將連結重新導向至選擇的網站"; -"general_setting_view.detects_links_from_clipboard" = "偵測剪貼簿中的連結"; -"general_setting_view.background_blur_radius" = "後台背景模糊"; -"general_setting_view.app_activity_logs" = "應用程式活動日誌"; -"general_setting_view.import_custom_translations" = "匯入自訂標籤翻譯"; -"general_setting_view.remove_custom_translations" = "刪除自訂標籤翻譯"; -"general_setting_view.clear_image_caches" = "清理圖片快取"; -"general_setting_view.default_language_description" = "N/A"; -"general_setting_view.tags" = "標籤"; -"general_setting_view.navigation" = "導覽"; -"general_setting_view.security" = "安全"; -"general_setting_view.caches" = "快取"; // AutoLockPolicy "auto_lock_policy.never" = "永不自動鎖定"; "auto_lock_policy.instantly" = "立刻"; // MARK: AppActivityLogsView -"app_activity_logs_view.title" = "應用程式活動日誌"; -"app_activity_logs_view.no_logs" = "找不到日誌"; -"app_activity_logs_view.current" = "目前"; -"app_activity_logs_view.run" = "運行 %@"; -"app_activity_logs_view.more_logs" = "更多日誌"; -"app_activity_logs_view.open_in_files" = "在「檔案」中開啟"; -"app_activity_logs_view.runs" = "運行"; "app_activity_logs_view.level.undefined" = "未定義"; "app_activity_logs_view.level.debug" = "偵錯"; "app_activity_logs_view.level.info" = "資訊"; @@ -189,17 +146,6 @@ "app_activity_logs_view.level.fault" = "故障"; // MARK: AppearanceSettingView -"appearance_setting_view.appearance" = "外觀設定"; -"appearance_setting_view.theme" = "主題"; -"appearance_setting_view.tint_color" = "強調色"; -"appearance_setting_view.display_mode" = "顯示模式"; -"appearance_setting_view.shows_tags_in_list" = "在列表中顯示標籤"; -"appearance_setting_view.maximum_number_of_tags" = "標籤最大顯示數量"; -"appearance_setting_view.displays_japanese_title" = "以日文顯示標籤"; -"appearance_setting_view.app_icon" = "App 圖案"; -"appearance_setting_view.infite" = "無限"; -"appearance_setting_view.list" = "列表"; -"appearance_setting_view.gallery" = "畫廊"; // PreferredColorScheme "preferred_color_scheme.automatic" = "自動"; "preferred_color_scheme.light" = "淺色"; @@ -215,7 +161,6 @@ "list_display_mode.thumbnail" = "縮圖"; // MARK: AppIconView -"app_icon_view.app_icon" = "App 圖案"; // MARK: reading_settingView // ReadingDirection @@ -224,18 +169,8 @@ "reading_direction.left_to_right" = "由左至右滑"; // MARK: LaboratorySettingView -"laboratory_setting_view.laboratory" = "實驗性功能"; -"laboratory_setting_view.bypasses_SNI_filtering" = "繞過 SNI 過濾"; // MARK: AboutView -"about_view.ehPanda" = "EhPanda"; -"about_view.website" = "官方網站"; -"about_view.altStore_source" = "AltStore source"; -"about_view.version" = "版本"; -"about_view.special_thanks" = "特別銘謝"; -"about_view.code_level_contributors" = "程式碼貢獻者"; -"about_view.translation_contributors" = "翻譯貢獻者"; -"about_view.acknowledgements" = "致謝"; // MARK: DetailView "detail_view.share" = "分享"; @@ -271,12 +206,6 @@ "downloads_view.update" = "更新"; // MARK: DownloadSettingView -"download_setting_view.title" = "下載"; -"download_setting_view.network" = "網路"; -"download_setting_view.concurrent_image_downloads" = "並行圖片下載"; -"download_setting_view.retry_failed_pages_automatically" = "自動重試失敗頁面"; -"download_setting_view.allow_cellular_downloads" = "允許行動網路下載"; -"download_setting_view.network_description" = "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許行動網路下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。"; // MARK: CommentsView @@ -305,19 +234,7 @@ "filter_range.watched" = "標籤"; // MARK: EhSettingView -"eh_setting_view.host_settings" = "%@ 設定"; -"eh_setting_view.profile_settings" = "設定檔設定"; -"eh_setting_view.selected_profile" = "選擇設定檔"; -"eh_setting_view.set_as_default" = "設為預設"; -"eh_setting_view.delete_profile" = "刪除設定檔"; -"eh_setting_view.rename" = "重新命名"; -"eh_setting_view.create_new" = "新增設定檔"; -"eh_setting_view.done" = "完成"; - -"eh_setting_view.image_load_settings" = "圖片來源設定"; -"eh_setting_view.load_images_through_the_hath_network" = "透過 Hath Network 載入圖片"; -"eh_setting_view.browsing_country" = "所在國家"; -"eh_setting_view.browsing_country_description" = "你似乎是從 **%@** 瀏覽這個網站或在使用當地的 VPN,這意味著網站將嘗試從這個地理區域的 H@H 用戶端載入圖片。如果這不正確或你出於任何原因想要使用不同的區域(例如你正在透過 VPN 連線),您可以在下面選擇不同的國家/地區。"; + // EhSetting.LoadThroughHathSetting "load_through_hath_setting.any_client" = "任何用戶端"; "load_through_hath_setting.default_port_only" = "只使用預設連接埠的用戶端"; @@ -328,26 +245,13 @@ "load_through_hath_setting.modern_no_description" = "E-Hentai 贊助者功能: 你將無法同時瀏覽多個頁面,僅在出現重大錯誤時才啟用這個選項"; "load_through_hath_setting.legacy_no_description" = "E-Hentai 贊助者功能: 在現代瀏覽器上預設設定可能無法正常工作,僅推薦用於傳統瀏覽器"; -"eh_setting_view.image_size_settings" = "圖片尺寸設定"; -"eh_setting_view.image_resolution" = "圖片解析度"; -"eh_setting_view.image_resolution_description" = "一般情況下圖片會被縮放成 1280 px 水平解析度以供線上瀏覽,你也可以選擇下列解析度之一。為了避免破壞伺服器正常運作,高於 1280x 的解析度暫時僅提供下列使用者使用: 贊助者、具有任何 Hath Perk 的使用者以及 UID 低於 3,000,000 的使用者"; -"eh_setting_view.image_size" = "圖片尺寸"; -"eh_setting_view.image_size_description" = "雖然網站會自動縮小圖片以適應瀏覽裝置的螢幕寬度,但您也可以手動限製圖片的最大顯示尺寸。 像自動縮放一樣,這不會重新採樣圖像,因為調整大小是在瀏覽器完成的 (0 = 沒有限制)"; -"eh_setting_view.horizontal" = "水平尺寸(寬)"; -"eh_setting_view.vertical" = "垂直尺寸(長)"; // EhSetting.ImageResolution "image_resolution.auto" = "自動"; -"eh_setting_view.gallery_name_display" = "畫廊顯示名稱"; -"eh_setting_view.gallery_name" = "畫廊名稱"; -"eh_setting_view.gallery_name_description" = "許多畫廊同時提供了英文/預設標題與日文標題,你想優先顯示哪種畫廊名稱?"; // EhSetting.GalleryName "gallery_name.default" = "預設標題"; "gallery_name.japanese" = "日文標題(若該畫廊支援)"; -"eh_setting_view.archiver_settings" = "存檔設定"; -"eh_setting_view.archiver_behavior" = "存檔邏輯設定"; -"eh_setting_view.archiver_behavior_description" = "存檔的預設邏輯是確認原始圖片或重新採樣後進行存檔的成本差異與選擇,然後顯示一個可以在其他地方點擊、複製的連結,你可以在此處更改他的運作方式。"; // EhSetting.ArchiverBehavior "eh_setting.archiver_behavior.manual_select_manual_start" = "手動選擇,手動開始下載(預設)"; "eh_setting.archiver_behavior.manual_select_auto_start" = "手動選擇,自動開始下載"; @@ -356,12 +260,6 @@ "eh_setting.archiver_behavior.auto_select_resample_manual_start" = "自動選擇重新採樣,手動開始下載"; "eh_setting.archiver_behavior.auto_select_resample_auto_start" = "自動選擇重新採樣並開始下載"; -"eh_setting_view.front_page_settings" = "首頁設定"; -"eh_setting_view.display_mode" = "顯示模式"; -"eh_setting_view.display_mode_description" = "你想在首頁和搜尋結果中使用哪一種顯示方式?"; -"eh_setting_view.show_search_range_indicator" = "搜尋範圍指示器"; -"eh_setting_view.show_search_range_indicator_description" = "顯示搜尋範圍指示器"; -"eh_setting_view.gallery_category" = "預設情況下你希望在首頁和搜尋結果中顯示哪些類別的結果?"; // EhSetting.DisplayMode "display_mode.compact" = "緊湊(Compact)"; "display_mode.thumbnail" = "縮圖(Thumbnail)"; @@ -369,54 +267,22 @@ "display_mode.minimal" = "最小(Minimal)"; "display_mode.minimalPlus" = "Minimal+"; -"eh_setting_view.optional_UI_elements" = "可選用的 UI 元件"; -"eh_setting_view.optional_UI_elements_description" = "一些舊版 UI 元件現已預設停用。您可以在此啟用這些元件。"; -"eh_setting_view.enable_gallery_thumbnail_selector" = "在畫廊頁面啟用縮圖選擇器"; -"eh_setting_view.favorites" = "收藏匣"; -"eh_setting_view.favorite_categories" = "在這裡你可以選擇並重新命名收藏匣"; -"eh_setting_view.favorites_sort_order" = "收藏匣排序"; -"eh_setting_view.favorites_sort_order_description" = "你還可以在收藏匣頁面上為畫廊選擇預設排列順序。 請注意,在 2016 年 3 月網站改版之前新增的畫廊並不儲存時間印記,並且無論使用何種設定,都將使用畫廊發佈時間作為排序參考。"; // EhSetting.FavoritesSortOrder "favorites_sort_order.last_update_time" = "透過最後更新時間排序"; "favorites_sort_order.favorited_time" = "透過收藏順序排序"; -"eh_setting_view.ratings" = "評分"; -"eh_setting_view.ratings_color" = "評分顏色"; -"eh_setting_view.ratings_color_prompt" = "RRGGB"; -"eh_setting_view.ratings_color_description" = "預設情況下,你評分的畫廊將 2 星及以下的評分顯示為紅色星,2.5 ~ 4 顆星的評分為綠色,4.5 ~ 5 顆星的評分為藍色。 透過在下面輸入顏色組合你可以自訂想顯示的顏色。 每個字母各代表一顆星(1~5),預設的 RRGGB 表示第一顆和第二顆星的 R(ed),第三顆和第四顆的 G(reen),第五顆的 B(lue)。 你也可以將 (Y)ellow 用於普通星星。 任何五個字母的 R/G/B/Y 組合都有效"; -"eh_setting_view.tag_filtering_threshold" = "過濾標籤閾值"; -"eh_setting_view.tag_filtering_threshold_description" = "你可以透過將標籤新增到具有負數權重的“我的標籤”清單中來過濾標籤。 如果畫廊的標籤加起來的權重低於此值,則會被從列表中過濾掉,此閾值可以設定在 0 ~ -9999 之間"; -"eh_setting_view.tag_watching_threshold" = "關注標籤閾值"; -"eh_setting_view.tag_watching_threshold_description" = "如果最近上傳的畫廊中至少有一個具有正權重的你正在關注/追蹤的標籤,並且這些標籤所具有的權重高於此設定中的數值,那這個畫廊將會出現在「追蹤標籤」頁面中,此閾值可以設定在 0 ~ -9999 之間"; -"eh_setting_viewfiltered_removal_count" = "顯示過濾結果計數器"; -"eh_setting_view.filtered_removal_count_description" = "顯示 \"Your default filters removed XX galleries from this page\" ?"; -"eh_setting_view.show_filtered_removal_count" = "是否顯示過濾結果計數器"; -"eh_setting_view.excluded_languages" = "排除語言"; -"eh_setting_view.excluded_languages_description" = "如果你希望從畫廊列表和搜尋中隱藏掉某些語言的畫廊,請從下面的清單中選取它們。請注意,無論你的搜尋查詢如何,相符於篩除規則的畫廊都不會出現。"; // EhSetting.ExcludedLanguagesCategory "excluded_languages_category.original" = "原始語言"; "excluded_languages_category.translated" = "翻譯語言"; "excluded_languages_category.rewrite" = "覆寫"; -"eh_setting_view.excluded_uploaders" = "排除的上傳者"; -"eh_setting_view.excluded_uploaders_description" = "如果你希望從畫廊列表和搜尋結果中隱藏某些上傳者的畫廊,請將它們新增到下方,每行輸入一個使用者名稱。請注意,無論你的搜尋結果如何,這些上傳者的畫廊都不會出現。"; -"eh_setting_view.excluded_uploaders_count" = "你正在使用 **%@ / %@** 排除欄位"; -"eh_setting_view.search_result_count" = "搜尋結果數量上限"; -"eh_setting_view.result_count" = "數量上限"; -"eh_setting_view.result_count_description" = "你希望每頁顯示幾個搜尋結果?\n(該功能需要有 Hath Perk: Paging Enlargement)"; -"eh_setting_view.thumbnail_settings" = "縮圖設定"; -"eh_setting_view.thumbnail_load_timing" = "縮圖載入時機設定"; -"eh_setting_view.thumbnail_load_timing_description" = "使用列表模式時,你希望如何載入以滑鼠位置顯示的縮圖?"; -"eh_setting_view.thumbnail_configuration" = "你可以為存取的所有畫廊設定預設的縮圖配置。"; -"eh_setting_view.thumbnail_size" = "尺寸"; -"eh_setting_view.thumbnail_row_count" = "行數"; // EhSetting.ThumbnailLoadTiming "thumbnail_load_timing.on_mouse_over" = "滑鼠位置"; "thumbnail_load_timing.on_page_load" = "網頁載入位置"; @@ -428,17 +294,8 @@ "thumbnail_size.small" = "小型"; "thumbnail_size.auto" = "自動"; -"eh_setting_view.cover_scaling" = "封面縮放"; -"eh_setting_view.scale_factor" = "縮放比例"; -"eh_setting_view.cover_scale_factor" = "在縮圖與放大檢視這兩種檢視模式下,封面的重新採樣比率介於 75%% 至 150%%."; -"eh_setting_view.viewport_override" = "視窗覆蓋"; -"eh_setting_view.virtual_width" = "虛擬寬度"; -"eh_setting_view.virtual_width_description" = "允許你覆蓋行動裝置網站的虛擬寬度。 這通常由你的裝置根據其 DPI 自動確定。 100%% 縮圖比例的合理值介於 640 和 1400 之間。"; -"eh_setting_view.gallery_comments" = "畫廊留言"; -"eh_setting_view.comments_sort_order" = "留言排序方式"; -"eh_setting_view.comments_votes_show_timing" = "留言投票數顯示時機"; // EhSetting.CommentsSortOrder "comments_sort_order.oldest" = "最舊留言優先"; "comments_sort_order.recent" = "最新留言優先"; @@ -447,22 +304,12 @@ "comments_votes_show_timing.on_hover_or_click" = "滑鼠在分數上停留或點擊時"; "comments_votes_show_timing.always" = "總是顯示"; -"eh_setting_view.gallery_tags" = "畫廊標籤"; -"eh_setting_view.tags_sort_order" = "標籤顯示順序"; // EhSetting.tags_sort_order "tags_sort_order.alphabetical" = "字母順序"; "tags_sort_order.tag_power" = "標籤權重"; -"eh_setting_view.gallery_page_thumbnail_labeling" = "畫廊頁面縮圖標籤"; -"eh_setting_view.show_label_below_gallery_thumbnails" = "在畫廊縮圖下方顯示標籤"; -"eh_setting_view.original_images" = "要使用原始圖片而非重新取樣的版本嗎? 若您在上方選擇「自動」以外的水準解析度且圖片較寬,或原始圖片大於 10 MiB(一年以上的圖庫則為 4 MiB),系統仍會使用重新取樣的圖片。"; -"eh_setting_view.use_original_images" = "使用原始圖片(原解析度)"; -"eh_setting_view.multi_page_viewer" = "多頁瀏覽"; -"eh_setting_view.use_multi_page_viewer" = "使用多頁瀏覽"; -"eh_setting_view.display_style" = "顯示方式"; -"eh_setting_view.show_thumbnail_pane" = "顯示縮圖窗格"; // EhSetting.MultiplePageViewerStyle "multiple_page_viewer_style.align_left_scale_if_over_width" = "向左對齊,若寬度超出頁面則進行縮放"; "multiple_page_viewer_style.align_center_scale_if_over_width" = "置中對齊,若寬度超出頁面則進行縮放"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index 4f3f2ca42..bec5975a6 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -11,196 +11,18 @@ import Foundation // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum L10n { public enum Constant { - /// Copyright © 2026 EhPanda Team - public static let copyright = L10n.tr("Constant", "copyright", fallback: "Copyright © 2026 EhPanda Team") /// Constant.strings /// EhPanda public static let galleryUnavailable = L10n.tr("Constant", "gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") - public enum Acknowledgement { - /// Colorful - public static let colorful = L10n.tr("Constant", "acknowledgement.colorful", fallback: "Colorful") - /// https://github.com/Co2333/Colorful - public static let colorfulLink = L10n.tr("Constant", "acknowledgement.colorful_link", fallback: "https://github.com/Co2333/Colorful") - /// EhTagTranslation/Database - public static let ehTagTranslationDatabase = L10n.tr("Constant", "acknowledgement.ehTagTranslationDatabase", fallback: "EhTagTranslation/Database") - /// https://github.com/EhTagTranslation/Database - public static let ehTagTranslationDatabaseLink = L10n.tr("Constant", "acknowledgement.ehTagTranslationDatabase_link", fallback: "https://github.com/EhTagTranslation/Database") - /// Kanna - public static let kanna = L10n.tr("Constant", "acknowledgement.kanna", fallback: "Kanna") - /// https://github.com/tid-kijyun/Kanna - public static let kannaLink = L10n.tr("Constant", "acknowledgement.kanna_link", fallback: "https://github.com/tid-kijyun/Kanna") - /// Kingfisher - public static let kingfisher = L10n.tr("Constant", "acknowledgement.kingfisher", fallback: "Kingfisher") - /// https://github.com/onevcat/Kingfisher - public static let kingfisherLink = L10n.tr("Constant", "acknowledgement.kingfisher_link", fallback: "https://github.com/onevcat/Kingfisher") - /// SFSafeSymbols - public static let sfSafeSymbols = L10n.tr("Constant", "acknowledgement.sfSafeSymbols", fallback: "SFSafeSymbols") - /// https://github.com/SFSafeSymbols/SFSafeSymbols - public static let sfSafeSymbolsLink = L10n.tr("Constant", "acknowledgement.sfSafeSymbols_link", fallback: "https://github.com/SFSafeSymbols/SFSafeSymbols") - /// SwiftCommonMark - public static let swiftCommonMark = L10n.tr("Constant", "acknowledgement.swiftCommonMark", fallback: "SwiftCommonMark") - /// https://github.com/gonzalezreal/SwiftCommonMark - public static let swiftCommonMarkLink = L10n.tr("Constant", "acknowledgement.swiftCommonMark_link", fallback: "https://github.com/gonzalezreal/SwiftCommonMark") - /// SwiftGen - public static let swiftGen = L10n.tr("Constant", "acknowledgement.swiftGen", fallback: "SwiftGen") - /// https://github.com/SwiftGen/SwiftGen - public static let swiftGenLink = L10n.tr("Constant", "acknowledgement.swiftGen_link", fallback: "https://github.com/SwiftGen/SwiftGen") - /// SwiftUIPager - public static let swiftUIPager = L10n.tr("Constant", "acknowledgement.swiftUIPager", fallback: "SwiftUIPager") - /// https://github.com/fermoya/SwiftUIPager - public static let swiftUIPagerLink = L10n.tr("Constant", "acknowledgement.swiftUIPager_link", fallback: "https://github.com/fermoya/SwiftUIPager") - /// SwiftyOpenCC - public static let swiftyOpenCC = L10n.tr("Constant", "acknowledgement.swiftyOpenCC", fallback: "SwiftyOpenCC") - /// https://github.com/ddddxxx/SwiftyOpenCC - public static let swiftyOpenCCLink = L10n.tr("Constant", "acknowledgement.swiftyOpenCC_link", fallback: "https://github.com/ddddxxx/SwiftyOpenCC") - /// SystemNotification - public static let systemNotification = L10n.tr("Constant", "acknowledgement.systemNotification", fallback: "SystemNotification") - /// https://github.com/danielsaidi/SystemNotification - public static let systemNotificationLink = L10n.tr("Constant", "acknowledgement.systemNotification_link", fallback: "https://github.com/danielsaidi/SystemNotification") - /// The Composable Architecture - public static let tca = L10n.tr("Constant", "acknowledgement.tca", fallback: "The Composable Architecture") - /// https://github.com/pointfreeco/swift-composable-architecture - public static let tcaLink = L10n.tr("Constant", "acknowledgement.tca_link", fallback: "https://github.com/pointfreeco/swift-composable-architecture") - /// UIImageColors - public static let uiImageColors = L10n.tr("Constant", "acknowledgement.uiImageColors", fallback: "UIImageColors") - /// https://github.com/jathu/UIImageColors - public static let uiImageColorsLink = L10n.tr("Constant", "acknowledgement.uiImageColors_link", fallback: "https://github.com/jathu/UIImageColors") - /// WaterfallGrid - public static let waterfallGrid = L10n.tr("Constant", "acknowledgement.waterfallGrid", fallback: "WaterfallGrid") - /// https://github.com/paololeonardi/WaterfallGrid - public static let waterfallGridLink = L10n.tr("Constant", "acknowledgement.waterfallGrid_link", fallback: "https://github.com/paololeonardi/WaterfallGrid") - } - public enum CodeLevelContributor { - /// Zack Asahina - public static let aalberrty = L10n.tr("Constant", "code_level_contributor.aalberrty", fallback: "Zack Asahina") - /// https://github.com/aalberrty - public static let aalberrtyLink = L10n.tr("Constant", "code_level_contributor.aalberrty_link", fallback: "https://github.com/aalberrty") - /// Jimmy Prime - public static let jimmyPrime = L10n.tr("Constant", "code_level_contributor.Jimmy-Prime", fallback: "Jimmy Prime") - /// https://github.com/Jimmy-Prime - public static let jimmyPrimeLink = L10n.tr("Constant", "code_level_contributor.Jimmy-Prime_link", fallback: "https://github.com/Jimmy-Prime") - /// Kaed3mi - public static let kaed3mi = L10n.tr("Constant", "code_level_contributor.Kaed3mi", fallback: "Kaed3mi") - /// https://github.com/Kaed3mi - public static let kaed3miLink = L10n.tr("Constant", "code_level_contributor.Kaed3mi_link", fallback: "https://github.com/Kaed3mi") - /// vvbbnn00 - public static let vvbbnn00 = L10n.tr("Constant", "code_level_contributor.vvbbnn00", fallback: "vvbbnn00") - /// https://github.com/vvbbnn00 - public static let vvbbnn00Link = L10n.tr("Constant", "code_level_contributor.vvbbnn00_link", fallback: "https://github.com/vvbbnn00") - /// xioxin - public static let xioxin = L10n.tr("Constant", "code_level_contributor.xioxin", fallback: "xioxin") - /// https://github.com/xioxin - public static let xioxinLink = L10n.tr("Constant", "code_level_contributor.xioxin_link", fallback: "https://github.com/xioxin") - } - public enum Contact { - /// altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json - public static let altStoreLink = L10n.tr("Constant", "contact.altStore_link", fallback: "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json") - /// https://discord.gg/BSBE9FCBTq - public static let discord = L10n.tr("Constant", "contact.discord", fallback: "https://discord.gg/BSBE9FCBTq") - /// Discord - public static let discordLink = L10n.tr("Constant", "contact.discord_link", fallback: "Discord") - /// https://github.com/EhPanda-Team/EhPanda - public static let gitHub = L10n.tr("Constant", "contact.gitHub", fallback: "https://github.com/EhPanda-Team/EhPanda") - /// GitHub - public static let gitHubLink = L10n.tr("Constant", "contact.gitHub_link", fallback: "GitHub") - /// https://t.me/ehpanda - public static let telegram = L10n.tr("Constant", "contact.telegram", fallback: "https://t.me/ehpanda") - /// Telegram - public static let telegramLink = L10n.tr("Constant", "contact.telegram_link", fallback: "Telegram") - /// https://ehpanda.app - public static let website = L10n.tr("Constant", "contact.website", fallback: "https://ehpanda.app") - } - public enum SpecialThanks { - /// caxerx - public static let caxerx = L10n.tr("Constant", "special_thanks.caxerx", fallback: "caxerx") - /// https://github.com/caxerx - public static let caxerxLink = L10n.tr("Constant", "special_thanks.caxerx_link", fallback: "https://github.com/caxerx") - /// honjow - public static let honjow = L10n.tr("Constant", "special_thanks.honjow", fallback: "honjow") - /// https://github.com/honjow - public static let honjowLink = L10n.tr("Constant", "special_thanks.honjow_link", fallback: "https://github.com/honjow") - /// Luminescent_yq - public static let luminescentYq = L10n.tr("Constant", "special_thanks.luminescent_yq", fallback: "Luminescent_yq") - /// - public static let luminescentYqLink = L10n.tr("Constant", "special_thanks.luminescent_yq_link", fallback: "") - /// taylorlannister - public static let taylorlannister = L10n.tr("Constant", "special_thanks.taylorlannister", fallback: "taylorlannister") - /// https://github.com/taylorlannister - public static let taylorlannisterLink = L10n.tr("Constant", "special_thanks.taylorlannister_link", fallback: "https://github.com/taylorlannister") - } - public enum TranslationContributor { - /// caxerx - public static let caxerx = L10n.tr("Constant", "translation_contributor.caxerx", fallback: "caxerx") - /// https://github.com/caxerx - public static let caxerxLink = L10n.tr("Constant", "translation_contributor.caxerx_link", fallback: "https://github.com/caxerx") - /// 雲豹 ΦωΦ - public static let nebulosaCat = L10n.tr("Constant", "translation_contributor.nebulosa-cat", fallback: "雲豹 ΦωΦ") - /// https://github.com/Nebulosa-Cat - public static let nebulosaCatLink = L10n.tr("Constant", "translation_contributor.nebulosa-cat_link", fallback: "https://github.com/Nebulosa-Cat") - /// ɴᴇᴋᴏ - public static let neKoOuO = L10n.tr("Constant", "translation_contributor.NeKoOuO", fallback: "ɴᴇᴋᴏ") - /// https://github.com/NeKoOuO - public static let neKoOuOLink = L10n.tr("Constant", "translation_contributor.NeKoOuO_link", fallback: "https://github.com/NeKoOuO") - /// PaulHaeussler - public static let paulHaeussler = L10n.tr("Constant", "translation_contributor.paulHaeussler", fallback: "PaulHaeussler") - /// https://github.com/PaulHaeussler - public static let paulHaeusslerLink = L10n.tr("Constant", "translation_contributor.paulHaeussler_link", fallback: "https://github.com/PaulHaeussler") - } } public enum Localizable { - /// Show Filtered Removal Count - public static let ehSettingViewfilteredRemovalCount = L10n.tr("Localizable", "eh_setting_viewfiltered_removal_count", fallback: "Show Filtered Removal Count") /// Login public static let notLoginViewlogin = L10n.tr("Localizable", "not_login_viewlogin", fallback: "Login") - public enum AboutView { - /// Acknowledgements - public static let acknowledgements = L10n.tr("Localizable", "about_view.acknowledgements", fallback: "Acknowledgements") - /// AltStore source - public static let altStoreSource = L10n.tr("Localizable", "about_view.altStore_source", fallback: "AltStore source") - /// Code-level contributors - public static let codeLevelContributors = L10n.tr("Localizable", "about_view.code_level_contributors", fallback: "Code-level contributors") - /// EhPanda - public static let ehPanda = L10n.tr("Localizable", "about_view.ehPanda", fallback: "EhPanda") - /// Special thanks - public static let specialThanks = L10n.tr("Localizable", "about_view.special_thanks", fallback: "Special thanks") - /// Translation contributors - public static let translationContributors = L10n.tr("Localizable", "about_view.translation_contributors", fallback: "Translation contributors") - /// Version - public static let version = L10n.tr("Localizable", "about_view.version", fallback: "Version") - /// Website - public static let website = L10n.tr("Localizable", "about_view.website", fallback: "Website") - } public enum AccountSettingView { - /// Account - public static let account = L10n.tr("Localizable", "account_setting_view.account", fallback: "Account") - /// Account configuration - public static let accountConfiguration = L10n.tr("Localizable", "account_setting_view.account_configuration", fallback: "Account configuration") - /// Copy cookies - public static let copyCookies = L10n.tr("Localizable", "account_setting_view.copy_cookies", fallback: "Copy cookies") /// Login public static let login = L10n.tr("Localizable", "account_setting_view.login", fallback: "Login") - /// Shows new dawn greeting - public static let showsNewDawnGreeting = L10n.tr("Localizable", "account_setting_view.shows_new_dawn_greeting", fallback: "Shows new dawn greeting") - /// Manage tags subscription - public static let tagsManagement = L10n.tr("Localizable", "account_setting_view.tags_management", fallback: "Manage tags subscription") } public enum AppActivityLogsView { - /// Current - public static let current = L10n.tr("Localizable", "app_activity_logs_view.current", fallback: "Current") - /// More logs - public static let moreLogs = L10n.tr("Localizable", "app_activity_logs_view.more_logs", fallback: "More logs") - /// No logs found - public static let noLogs = L10n.tr("Localizable", "app_activity_logs_view.no_logs", fallback: "No logs found") - /// Open in Files - public static let openInFiles = L10n.tr("Localizable", "app_activity_logs_view.open_in_files", fallback: "Open in Files") - /// Run %@ - public static func run(_ p1: Any) -> String { - return L10n.tr("Localizable", "app_activity_logs_view.run", String(describing: p1), fallback: "Run %@") - } - /// Runs - public static let runs = L10n.tr("Localizable", "app_activity_logs_view.runs", fallback: "Runs") - /// App activity logs - public static let title = L10n.tr("Localizable", "app_activity_logs_view.title", fallback: "App activity logs") public enum Level { /// Debug public static let debug = L10n.tr("Localizable", "app_activity_logs_view.level.debug", fallback: "Debug") @@ -263,34 +85,6 @@ public enum L10n { /// Ukiyo-e public static let ukiyoe = L10n.tr("Localizable", "app_icon_type.ukiyoe", fallback: "Ukiyo-e") } - public enum AppIconView { - /// App icon - public static let appIcon = L10n.tr("Localizable", "app_icon_view.app_icon", fallback: "App icon") - } - public enum AppearanceSettingView { - /// App icon - public static let appIcon = L10n.tr("Localizable", "appearance_setting_view.app_icon", fallback: "App icon") - /// Appearance - public static let appearance = L10n.tr("Localizable", "appearance_setting_view.appearance", fallback: "Appearance") - /// Display mode - public static let displayMode = L10n.tr("Localizable", "appearance_setting_view.display_mode", fallback: "Display mode") - /// Displays Japanese title - public static let displaysJapaneseTitle = L10n.tr("Localizable", "appearance_setting_view.displays_japanese_title", fallback: "Displays Japanese title") - /// Gallery - public static let gallery = L10n.tr("Localizable", "appearance_setting_view.gallery", fallback: "Gallery") - /// Infite - public static let infite = L10n.tr("Localizable", "appearance_setting_view.infite", fallback: "Infite") - /// List - public static let list = L10n.tr("Localizable", "appearance_setting_view.list", fallback: "List") - /// Maximum number of tags - public static let maximumNumberOfTags = L10n.tr("Localizable", "appearance_setting_view.maximum_number_of_tags", fallback: "Maximum number of tags") - /// Shows tags in list - public static let showsTagsInList = L10n.tr("Localizable", "appearance_setting_view.shows_tags_in_list", fallback: "Shows tags in list") - /// Theme - public static let theme = L10n.tr("Localizable", "appearance_setting_view.theme", fallback: "Theme") - /// Tint color - public static let tintColor = L10n.tr("Localizable", "appearance_setting_view.tint_color", fallback: "Tint color") - } public enum ArchiveResolution { /// Original public static let original = L10n.tr("Localizable", "archive_resolution.original", fallback: "Original") @@ -902,14 +696,6 @@ public enum L10n { public static let delete = L10n.tr("Localizable", "confirmation_dialog.delete", fallback: "Delete") /// Are you sure to delete this item? public static let deleteDescription = L10n.tr("Localizable", "confirmation_dialog.delete_description", fallback: "Are you sure to delete this item?") - /// Logout - public static let logout = L10n.tr("Localizable", "confirmation_dialog.logout", fallback: "Logout") - /// Are you sure to logout? - public static let logoutDescription = L10n.tr("Localizable", "confirmation_dialog.logout_description", fallback: "Are you sure to logout?") - /// Remove - public static let remove = L10n.tr("Localizable", "confirmation_dialog.remove", fallback: "Remove") - /// Are you sure to remove your custom translations? - public static let removeCustomTranslations = L10n.tr("Localizable", "confirmation_dialog.remove_custom_translations", fallback: "Are you sure to remove your custom translations?") } public enum DateSeekView { /// Seek to date @@ -947,20 +733,6 @@ public enum L10n { /// All public static let all = L10n.tr("Localizable", "download_folder_filter.all", fallback: "All") } - public enum DownloadSettingView { - /// Allow cellular downloads - public static let allowCellularDownloads = L10n.tr("Localizable", "download_setting_view.allow_cellular_downloads", fallback: "Allow cellular downloads") - /// Concurrent image downloads - public static let concurrentImageDownloads = L10n.tr("Localizable", "download_setting_view.concurrent_image_downloads", fallback: "Concurrent image downloads") - /// Network - public static let network = L10n.tr("Localizable", "download_setting_view.network", fallback: "Network") - /// Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder. - public static let networkDescription = L10n.tr("Localizable", "download_setting_view.network_description", fallback: "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder.") - /// Retry failed pages automatically - public static let retryFailedPagesAutomatically = L10n.tr("Localizable", "download_setting_view.retry_failed_pages_automatically", fallback: "Retry failed pages automatically") - /// Download - public static let title = L10n.tr("Localizable", "download_setting_view.title", fallback: "Download") - } public enum DownloadStore { /// The folder name is invalid. public static let invalidFolderName = L10n.tr("Localizable", "download_store.invalid_folder_name", fallback: "The folder name is invalid.") @@ -1003,177 +775,6 @@ public enum L10n { public static let manualSelectManualStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.manual_select_manual_start", fallback: "Manual Select, Manual Start (Default)") } } - public enum EhSettingView { - /// Archiver behavior - public static let archiverBehavior = L10n.tr("Localizable", "eh_setting_view.archiver_behavior", fallback: "Archiver behavior") - /// The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here. - public static let archiverBehaviorDescription = L10n.tr("Localizable", "eh_setting_view.archiver_behavior_description", fallback: "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here.") - /// Archiver Settings - public static let archiverSettings = L10n.tr("Localizable", "eh_setting_view.archiver_settings", fallback: "Archiver Settings") - /// Browsing country - public static let browsingCountry = L10n.tr("Localizable", "eh_setting_view.browsing_country", fallback: "Browsing country") - /// You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below. - public static func browsingCountryDescription(_ p1: Any) -> String { - return L10n.tr("Localizable", "eh_setting_view.browsing_country_description", String(describing: p1), fallback: "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below.") - } - /// Comments sort order - public static let commentsSortOrder = L10n.tr("Localizable", "eh_setting_view.comments_sort_order", fallback: "Comments sort order") - /// Comment votes show timing - public static let commentsVotesShowTiming = L10n.tr("Localizable", "eh_setting_view.comments_votes_show_timing", fallback: "Comment votes show timing") - /// The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes. - public static let coverScaleFactor = L10n.tr("Localizable", "eh_setting_view.cover_scale_factor", fallback: "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes.") - /// Cover Scaling - public static let coverScaling = L10n.tr("Localizable", "eh_setting_view.cover_scaling", fallback: "Cover Scaling") - /// Create new - public static let createNew = L10n.tr("Localizable", "eh_setting_view.create_new", fallback: "Create new") - /// Delete profile - public static let deleteProfile = L10n.tr("Localizable", "eh_setting_view.delete_profile", fallback: "Delete profile") - /// Display mode - public static let displayMode = L10n.tr("Localizable", "eh_setting_view.display_mode", fallback: "Display mode") - /// Which display mode would you like to use on the front and search pages? - public static let displayModeDescription = L10n.tr("Localizable", "eh_setting_view.display_mode_description", fallback: "Which display mode would you like to use on the front and search pages?") - /// Display style - public static let displayStyle = L10n.tr("Localizable", "eh_setting_view.display_style", fallback: "Display style") - /// Done - public static let done = L10n.tr("Localizable", "eh_setting_view.done", fallback: "Done") - /// Enable thumbnail selector on gallery screen - public static let enableGalleryThumbnailSelector = L10n.tr("Localizable", "eh_setting_view.enable_gallery_thumbnail_selector", fallback: "Enable thumbnail selector on gallery screen") - /// Excluded Languages - public static let excludedLanguages = L10n.tr("Localizable", "eh_setting_view.excluded_languages", fallback: "Excluded Languages") - /// If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query. - public static let excludedLanguagesDescription = L10n.tr("Localizable", "eh_setting_view.excluded_languages_description", fallback: "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query.") - /// Excluded Uploaders - public static let excludedUploaders = L10n.tr("Localizable", "eh_setting_view.excluded_uploaders", fallback: "Excluded Uploaders") - /// You are currently using **%@ / %@** exclusion slots. - public static func excludedUploadersCount(_ p1: Any, _ p2: Any) -> String { - return L10n.tr("Localizable", "eh_setting_view.excluded_uploaders_count", String(describing: p1), String(describing: p2), fallback: "You are currently using **%@ / %@** exclusion slots.") - } - /// If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query. - public static let excludedUploadersDescription = L10n.tr("Localizable", "eh_setting_view.excluded_uploaders_description", fallback: "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query.") - /// Here you can choose and rename your favorite categories. - public static let favoriteCategories = L10n.tr("Localizable", "eh_setting_view.favorite_categories", fallback: "Here you can choose and rename your favorite categories.") - /// Favorites - public static let favorites = L10n.tr("Localizable", "eh_setting_view.favorites", fallback: "Favorites") - /// Favorites sort order - public static let favoritesSortOrder = L10n.tr("Localizable", "eh_setting_view.favorites_sort_order", fallback: "Favorites sort order") - /// You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting. - public static let favoritesSortOrderDescription = L10n.tr("Localizable", "eh_setting_view.favorites_sort_order_description", fallback: "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting.") - /// Show the "Your default filters removed XX galleries from this page" readout? - public static let filteredRemovalCountDescription = L10n.tr("Localizable", "eh_setting_view.filtered_removal_count_description", fallback: "Show the \"Your default filters removed XX galleries from this page\" readout?") - /// Front Page Settings - public static let frontPageSettings = L10n.tr("Localizable", "eh_setting_view.front_page_settings", fallback: "Front Page Settings") - /// What categories would you like to show by default on the front page and in searches? - public static let galleryCategory = L10n.tr("Localizable", "eh_setting_view.gallery_category", fallback: "What categories would you like to show by default on the front page and in searches?") - /// Gallery Comments - public static let galleryComments = L10n.tr("Localizable", "eh_setting_view.gallery_comments", fallback: "Gallery Comments") - /// Gallery name - public static let galleryName = L10n.tr("Localizable", "eh_setting_view.gallery_name", fallback: "Gallery name") - /// Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default? - public static let galleryNameDescription = L10n.tr("Localizable", "eh_setting_view.gallery_name_description", fallback: "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?") - /// Gallery Name Display - public static let galleryNameDisplay = L10n.tr("Localizable", "eh_setting_view.gallery_name_display", fallback: "Gallery Name Display") - /// Gallery Page Thumbnail Labeling - public static let galleryPageThumbnailLabeling = L10n.tr("Localizable", "eh_setting_view.gallery_page_thumbnail_labeling", fallback: "Gallery Page Thumbnail Labeling") - /// Gallery Tags - public static let galleryTags = L10n.tr("Localizable", "eh_setting_view.gallery_tags", fallback: "Gallery Tags") - /// Horizontal - public static let horizontal = L10n.tr("Localizable", "eh_setting_view.horizontal", fallback: "Horizontal") - /// %@ settings - public static func hostSettings(_ p1: Any) -> String { - return L10n.tr("Localizable", "eh_setting_view.host_settings", String(describing: p1), fallback: "%@ settings") - } - /// Image Load Settings - public static let imageLoadSettings = L10n.tr("Localizable", "eh_setting_view.image_load_settings", fallback: "Image Load Settings") - /// Image resolution - public static let imageResolution = L10n.tr("Localizable", "eh_setting_view.image_resolution", fallback: "Image resolution") - /// Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000. - public static let imageResolutionDescription = L10n.tr("Localizable", "eh_setting_view.image_resolution_description", fallback: "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000.") - /// Image size - public static let imageSize = L10n.tr("Localizable", "eh_setting_view.image_size", fallback: "Image size") - /// While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit) - public static let imageSizeDescription = L10n.tr("Localizable", "eh_setting_view.image_size_description", fallback: "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)") - /// Image Size Settings - public static let imageSizeSettings = L10n.tr("Localizable", "eh_setting_view.image_size_settings", fallback: "Image Size Settings") - /// Load images through the Hath network - public static let loadImagesThroughTheHathNetwork = L10n.tr("Localizable", "eh_setting_view.load_images_through_the_hath_network", fallback: "Load images through the Hath network") - /// Multi-Page Viewer - public static let multiPageViewer = L10n.tr("Localizable", "eh_setting_view.multi_page_viewer", fallback: "Multi-Page Viewer") - /// Optional UI Elements - public static let optionalUIElements = L10n.tr("Localizable", "eh_setting_view.optional_UI_elements", fallback: "Optional UI Elements") - /// Some historic UI elements are now disabled by default. You can enable those here. - public static let optionalUIElementsDescription = L10n.tr("Localizable", "eh_setting_view.optional_UI_elements_description", fallback: "Some historic UI elements are now disabled by default. You can enable those here.") - /// Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than "Auto" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year). - public static let originalImages = L10n.tr("Localizable", "eh_setting_view.original_images", fallback: "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year).") - /// Profile Settings - public static let profileSettings = L10n.tr("Localizable", "eh_setting_view.profile_settings", fallback: "Profile Settings") - /// Ratings - public static let ratings = L10n.tr("Localizable", "eh_setting_view.ratings", fallback: "Ratings") - /// Ratings color - public static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.ratings_color", fallback: "Ratings color") - /// By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works. - public static let ratingsColorDescription = L10n.tr("Localizable", "eh_setting_view.ratings_color_description", fallback: "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works.") - /// RRGGB - public static let ratingsColorPrompt = L10n.tr("Localizable", "eh_setting_view.ratings_color_prompt", fallback: "RRGGB") - /// Rename - public static let rename = L10n.tr("Localizable", "eh_setting_view.rename", fallback: "Rename") - /// Result count - public static let resultCount = L10n.tr("Localizable", "eh_setting_view.result_count", fallback: "Result count") - /// How many results would you like per page for the index/search page and torrent search pages? - /// (Hath Perk: Paging Enlargement Required) - public static let resultCountDescription = L10n.tr("Localizable", "eh_setting_view.result_count_description", fallback: "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)") - /// Scale factor - public static let scaleFactor = L10n.tr("Localizable", "eh_setting_view.scale_factor", fallback: "Scale factor") - /// Search Result Count - public static let searchResultCount = L10n.tr("Localizable", "eh_setting_view.search_result_count", fallback: "Search Result Count") - /// Selected profile - public static let selectedProfile = L10n.tr("Localizable", "eh_setting_view.selected_profile", fallback: "Selected profile") - /// Set as default - public static let setAsDefault = L10n.tr("Localizable", "eh_setting_view.set_as_default", fallback: "Set as default") - /// Show filtered removal count - public static let showFilteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.show_filtered_removal_count", fallback: "Show filtered removal count") - /// Show label below gallery thumbnails - public static let showLabelBelowGalleryThumbnails = L10n.tr("Localizable", "eh_setting_view.show_label_below_gallery_thumbnails", fallback: "Show label below gallery thumbnails") - /// Search Range Indicator - public static let showSearchRangeIndicator = L10n.tr("Localizable", "eh_setting_view.show_search_range_indicator", fallback: "Search Range Indicator") - /// Show search range indicator - public static let showSearchRangeIndicatorDescription = L10n.tr("Localizable", "eh_setting_view.show_search_range_indicator_description", fallback: "Show search range indicator") - /// Show thumbnail pane - public static let showThumbnailPane = L10n.tr("Localizable", "eh_setting_view.show_thumbnail_pane", fallback: "Show thumbnail pane") - /// Tag Filtering Threshold - public static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.tag_filtering_threshold", fallback: "Tag Filtering Threshold") - /// You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999. - public static let tagFilteringThresholdDescription = L10n.tr("Localizable", "eh_setting_view.tag_filtering_threshold_description", fallback: "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999.") - /// Tag Watching Threshold - public static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.tag_watching_threshold", fallback: "Tag Watching Threshold") - /// Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999. - public static let tagWatchingThresholdDescription = L10n.tr("Localizable", "eh_setting_view.tag_watching_threshold_description", fallback: "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999.") - /// Tags sort order - public static let tagsSortOrder = L10n.tr("Localizable", "eh_setting_view.tags_sort_order", fallback: "Tags sort order") - /// You can set a default thumbnail configuration for all galleries you visit. - public static let thumbnailConfiguration = L10n.tr("Localizable", "eh_setting_view.thumbnail_configuration", fallback: "You can set a default thumbnail configuration for all galleries you visit.") - /// Thumbnail load timing - public static let thumbnailLoadTiming = L10n.tr("Localizable", "eh_setting_view.thumbnail_load_timing", fallback: "Thumbnail load timing") - /// How would you like the mouse-over thumbnails on the front page to load when using List Mode? - public static let thumbnailLoadTimingDescription = L10n.tr("Localizable", "eh_setting_view.thumbnail_load_timing_description", fallback: "How would you like the mouse-over thumbnails on the front page to load when using List Mode?") - /// Rows - public static let thumbnailRowCount = L10n.tr("Localizable", "eh_setting_view.thumbnail_row_count", fallback: "Rows") - /// Thumbnail Settings - public static let thumbnailSettings = L10n.tr("Localizable", "eh_setting_view.thumbnail_settings", fallback: "Thumbnail Settings") - /// Size - public static let thumbnailSize = L10n.tr("Localizable", "eh_setting_view.thumbnail_size", fallback: "Size") - /// Use Multi-Page Viewer - public static let useMultiPageViewer = L10n.tr("Localizable", "eh_setting_view.use_multi_page_viewer", fallback: "Use Multi-Page Viewer") - /// Use original images - public static let useOriginalImages = L10n.tr("Localizable", "eh_setting_view.use_original_images", fallback: "Use original images") - /// Vertical - public static let vertical = L10n.tr("Localizable", "eh_setting_view.vertical", fallback: "Vertical") - /// Viewport Override - public static let viewportOverride = L10n.tr("Localizable", "eh_setting_view.viewport_override", fallback: "Viewport Override") - /// Virtual width - public static let virtualWidth = L10n.tr("Localizable", "eh_setting_view.virtual_width", fallback: "Virtual width") - /// Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400. - public static let virtualWidthDescription = L10n.tr("Localizable", "eh_setting_view.virtual_width_description", fallback: "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400.") - } public enum ErrorView { /// This gallery is unavailable due to a copyright claim by %@. Sorry about that. public static func copyrightClaim(_ p1: Any) -> String { @@ -1264,44 +865,8 @@ public enum L10n { public static let yes = L10n.tr("Localizable", "gallery_visibility.yes", fallback: "Yes") } public enum GeneralSettingView { - /// App activity logs - public static let appActivityLogs = L10n.tr("Localizable", "general_setting_view.app_activity_logs", fallback: "App activity logs") - /// Auto-Lock - public static let autoLock = L10n.tr("Localizable", "general_setting_view.auto_lock", fallback: "Auto-Lock") - /// Background blur radius - public static let backgroundBlurRadius = L10n.tr("Localizable", "general_setting_view.background_blur_radius", fallback: "Background blur radius") - /// Caches - public static let caches = L10n.tr("Localizable", "general_setting_view.caches", fallback: "Caches") - /// Clear image caches - public static let clearImageCaches = L10n.tr("Localizable", "general_setting_view.clear_image_caches", fallback: "Clear image caches") - /// N/A - public static let defaultLanguageDescription = L10n.tr("Localizable", "general_setting_view.default_language_description", fallback: "N/A") - /// Detects links from the clipboard - public static let detectsLinksFromClipboard = L10n.tr("Localizable", "general_setting_view.detects_links_from_clipboard", fallback: "Detects links from the clipboard") - /// Enables tags extension - public static let enablesTagsExtension = L10n.tr("Localizable", "general_setting_view.enables_tags_extension", fallback: "Enables tags extension") - /// General - public static let general = L10n.tr("Localizable", "general_setting_view.general", fallback: "General") - /// Import custom translations - public static let importCustomTranslations = L10n.tr("Localizable", "general_setting_view.import_custom_translations", fallback: "Import custom translations") /// Language public static let language = L10n.tr("Localizable", "general_setting_view.language", fallback: "Language") - /// Navigation - public static let navigation = L10n.tr("Localizable", "general_setting_view.navigation", fallback: "Navigation") - /// Redirects links to the selected host - public static let redirectsLinksToTheSelectedHost = L10n.tr("Localizable", "general_setting_view.redirects_links_to_the_selected_host", fallback: "Redirects links to the selected host") - /// Remove custom translations - public static let removeCustomTranslations = L10n.tr("Localizable", "general_setting_view.remove_custom_translations", fallback: "Remove custom translations") - /// Security - public static let security = L10n.tr("Localizable", "general_setting_view.security", fallback: "Security") - /// Shows images in tags - public static let showsImagesInTags = L10n.tr("Localizable", "general_setting_view.shows_images_in_tags", fallback: "Shows images in tags") - /// Shows tags search suggestion - public static let showsTagsSearchSuggestion = L10n.tr("Localizable", "general_setting_view.shows_tags_search_suggestion", fallback: "Shows tags search suggestion") - /// Tags - public static let tags = L10n.tr("Localizable", "general_setting_view.tags", fallback: "Tags") - /// Translates tags - public static let translatesTags = L10n.tr("Localizable", "general_setting_view.translates_tags", fallback: "Translates tags") } public enum Greeting { /// and @@ -1329,12 +894,6 @@ public enum L10n { /// Jump page public static let jumpPage = L10n.tr("Localizable", "jump_page_view.jump_page", fallback: "Jump page") } - public enum LaboratorySettingView { - /// Bypasses SNI Filtering - public static let bypassesSNIFiltering = L10n.tr("Localizable", "laboratory_setting_view.bypasses_SNI_filtering", fallback: "Bypasses SNI Filtering") - /// Laboratory - public static let laboratory = L10n.tr("Localizable", "laboratory_setting_view.laboratory", fallback: "Laboratory") - } public enum Language { /// Afrikaans public static let afrikaans = L10n.tr("Localizable", "language.afrikaans", fallback: "Afrikaans") @@ -1496,10 +1055,6 @@ public enum L10n { public enum LoginView { /// Login public static let login = L10n.tr("Localizable", "login_view.login", fallback: "Login") - /// Password - public static let password = L10n.tr("Localizable", "login_view.password", fallback: "Password") - /// Username - public static let username = L10n.tr("Localizable", "login_view.username", fallback: "Username") } public enum MultiplePageViewerStyle { /// Align center, always scale @@ -1539,22 +1094,6 @@ public enum L10n { /// Search public static let search = L10n.tr("Localizable", "search_view.search", fallback: "Search") } - public enum SettingStateRoute { - /// About - public static let about = L10n.tr("Localizable", "setting_state_route.about", fallback: "About") - /// Account - public static let account = L10n.tr("Localizable", "setting_state_route.account", fallback: "Account") - /// Appearance - public static let appearance = L10n.tr("Localizable", "setting_state_route.appearance", fallback: "Appearance") - /// Download - public static let download = L10n.tr("Localizable", "setting_state_route.download", fallback: "Download") - /// General - public static let general = L10n.tr("Localizable", "setting_state_route.general", fallback: "General") - /// Laboratory - public static let laboratory = L10n.tr("Localizable", "setting_state_route.laboratory", fallback: "Laboratory") - /// Reading - public static let reading = L10n.tr("Localizable", "setting_state_route.reading", fallback: "Reading") - } public enum SettingView { /// Setting public static let setting = L10n.tr("Localizable", "setting_view.setting", fallback: "Setting") diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index d2ed8e14b..5a8d5fcd3 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -87,13 +87,13 @@ public struct AccountSettingReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmLogout) { - TextState(L10n.Localizable.ConfirmationDialog.logout) + TextState(String(localized: .logout)) } ButtonState(role: .cancel) { TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.logoutDescription) + TextState(String(localized: .logoutDescription)) } return .none diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index aac69e31f..61782c29d 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -60,7 +60,7 @@ struct AccountSettingView: View { .autoBlur(radius: blurRadius) } .onAppear { store.send(.loadCookies) } - .navigationTitle(L10n.Localizable.AccountSettingView.account) + .navigationTitle(String(localized: .account)) } } @@ -98,24 +98,24 @@ private struct AccountSection: View { Button(L10n.Localizable.AccountSettingView.login, action: loginAction) } else { Button( - L10n.Localizable.ConfirmationDialog.logout, + String(localized: .logout), role: .destructive, action: logoutDialogAction ) .confirmationDialog(logoutConfirmationDialog) Group { Button( - L10n.Localizable.AccountSettingView.accountConfiguration, + String(localized: .accountConfiguration), action: configureAccountAction ) .withArrow() if !bypassesSNIFiltering { Button( - L10n.Localizable.AccountSettingView.tagsManagement, + String(localized: .tagsManagement), action: manageTagsAction ) .withArrow() } - Toggle(L10n.Localizable.AccountSettingView.showsNewDawnGreeting, isOn: $showsNewDawnGreeting) + Toggle(String(localized: .showsNewDawnGreeting), isOn: $showsNewDawnGreeting) } .foregroundColor(.primary) } @@ -142,7 +142,7 @@ private struct CookieSection: View { Section(GalleryHost.ehentai.rawValue) { CookieRow(cookieState: $ehCookiesState.memberID) CookieRow(cookieState: $ehCookiesState.passHash) - Button(L10n.Localizable.AccountSettingView.copyCookies) { + Button(String(localized: .copyCookies)) { copyAction(.ehentai) } .foregroundStyle(.tint).font(.subheadline) @@ -151,7 +151,7 @@ private struct CookieSection: View { CookieRow(cookieState: $exCookiesState.igneous) CookieRow(cookieState: $exCookiesState.memberID) CookieRow(cookieState: $exCookiesState.passHash) - Button(L10n.Localizable.AccountSettingView.copyCookies) { + Button(String(localized: .copyCookies)) { copyAction(.exhentai) } .foregroundStyle(.tint).font(.subheadline) diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift index 140e9acfe..a62a15051 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -26,7 +26,7 @@ struct AppActivityLogsView: View { LoadingView() .opacity(store.loadingState == .loading && store.displayedLogs.isEmpty ? 1 : 0) - Text(L10n.Localizable.AppActivityLogsView.noLogs) + Text(String(localized: .appActivityLogsViewNoLogs)) .foregroundColor(.secondary) .opacity(store.loadingState != .loading && store.displayedLogs.isEmpty ? 1 : 0) } @@ -43,7 +43,7 @@ struct AppActivityLogsView: View { store.send(.refreshAvailableRuns) } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.AppActivityLogsView.title) + .navigationTitle(String(localized: .appActivityLogsViewTitle)) .navigationBarTitleDisplayMode(.large) .sheet(isPresented: $isRunPickerPresented) { RunPickerSheet(store: store) { isRunPickerPresented = false } @@ -63,14 +63,14 @@ struct AppActivityLogsView: View { Button { store.send(.navigateToFileApp) } label: { - Label(L10n.Localizable.AppActivityLogsView.openInFiles, systemSymbol: .folderBadgeGearshape) + Label(String(localized: .appActivityLogsViewOpenInFiles), systemSymbol: .folderBadgeGearshape) } } } @ViewBuilder private var runMenu: some View { - Section(L10n.Localizable.AppActivityLogsView.current) { + Section(String(localized: .appActivityLogsViewCurrent)) { RunButton( run: store.currentRun, isSelected: store.selectedRun == nil @@ -97,7 +97,7 @@ struct AppActivityLogsView: View { Button { isRunPickerPresented = true } label: { - Label(L10n.Localizable.AppActivityLogsView.moreLogs, systemSymbol: .ellipsisCalendar) + Label(String(localized: .appActivityLogsViewMoreLogs), systemSymbol: .ellipsisCalendar) } } } @@ -111,7 +111,7 @@ private struct RunPickerSheet: View { var body: some View { NavigationStack { List { - Section(L10n.Localizable.AppActivityLogsView.current) { + Section(String(localized: .appActivityLogsViewCurrent)) { RunButton( run: store.currentRun, isSelected: store.selectedRun == nil @@ -135,7 +135,7 @@ private struct RunPickerSheet: View { } } } - .navigationTitle(L10n.Localizable.AppActivityLogsView.runs) + .navigationTitle(String(localized: .appActivityLogsViewRuns)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -167,9 +167,9 @@ private struct RunButton: View { // A nil run is the current run before its count is resolved; fall back to "Current". private func runLabel(_ run: RunLogFile?) -> String { guard let run else { - return L10n.Localizable.AppActivityLogsView.current + return String(localized: .appActivityLogsViewCurrent) } - let title = L10n.Localizable.AppActivityLogsView.run("\(run.runCount)") + let title = String(localized: .appActivityLogsViewRun("\(run.runCount)")) return "\(title) (\(runTimeFormatter.string(from: run.date)))" } diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift index 5f81ea1e3..ae8f58228 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -39,7 +39,7 @@ struct AppearanceSettingView: View { Form { Section { Picker( - L10n.Localizable.AppearanceSettingView.theme, + String(localized: .theme), selection: $preferredColorScheme ) { ForEach(PreferredColorScheme.allCases) { colorScheme in @@ -49,17 +49,17 @@ struct AppearanceSettingView: View { } .pickerStyle(.menu) - ColorPicker(L10n.Localizable.AppearanceSettingView.tintColor, selection: $accentColor) + ColorPicker(String(localized: .tintColor), selection: $accentColor) - Button(L10n.Localizable.AppearanceSettingView.appIcon) { + Button(String(localized: .appIcon)) { store.send(.delegate(.pushAppIcon)) } .foregroundStyle(.primary) .withArrow() } - Section(L10n.Localizable.AppearanceSettingView.list) { + Section(String(localized: .list)) { Picker( - L10n.Localizable.AppearanceSettingView.displayMode, + String(localized: .appearanceDisplayMode), selection: $listDisplayMode, content: { ForEach(ListDisplayMode.allCases) { listMode in @@ -71,14 +71,14 @@ struct AppearanceSettingView: View { .pickerStyle(.menu) Toggle(isOn: $showsTagsInList) { - Text(L10n.Localizable.AppearanceSettingView.showsTagsInList) + Text(String(localized: .showsTagsInList)) } Picker( - L10n.Localizable.AppearanceSettingView.maximumNumberOfTags, + String(localized: .maximumNumberOfTags), selection: $listTagsNumberMaximum ) { - Text(L10n.Localizable.AppearanceSettingView.infite) + Text(String(localized: .infite)) .tag(0) ForEach(Array(stride(from: 5, through: 20, by: 5)), id: \.self) { num in @@ -89,14 +89,14 @@ struct AppearanceSettingView: View { .pickerStyle(.menu) .disabled(!showsTagsInList) } - Section(L10n.Localizable.AppearanceSettingView.gallery) { + Section(String(localized: .gallery)) { Toggle( - L10n.Localizable.AppearanceSettingView.displaysJapaneseTitle, + String(localized: .displaysJapaneseTitle), isOn: $displaysJapaneseTitle ) } } - .navigationTitle(L10n.Localizable.AppearanceSettingView.appearance) + .navigationTitle(String(localized: .appearance)) } } @@ -122,7 +122,7 @@ struct AppIconView: View { } } } - .navigationTitle(L10n.Localizable.AppIconView.appIcon) + .navigationTitle(String(localized: .appIcon)) } } diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index 04dcca661..fe7cc91df 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -7,7 +7,7 @@ import AppComponents struct AboutView: View { private var version: String { [ - L10n.Localizable.AboutView.version, + String(localized: .version), AppUtil.version, "(\(AppUtil.build))" ] .joined(separator: " ") @@ -20,32 +20,32 @@ struct AboutView: View { LinkRow(urlString: contact.urlString, text: contact.text) } } - Section(L10n.Localizable.AboutView.specialThanks) { + Section(String(localized: .specialThanks)) { ForEach(specialThanks) { specialThank in LinkRow(urlString: specialThank.urlString, text: specialThank.text) } } - Section(L10n.Localizable.AboutView.codeLevelContributors) { + Section(String(localized: .codeLevelContributors)) { ForEach(codeLevelContributors) { codeLevelContributor in LinkRow(urlString: codeLevelContributor.urlString, text: codeLevelContributor.text) } } - Section(L10n.Localizable.AboutView.translationContributors) { + Section(String(localized: .translationContributors)) { ForEach(translationContributors) { translationContributor in LinkRow(urlString: translationContributor.urlString, text: translationContributor.text) } } - Section(L10n.Localizable.AboutView.acknowledgements) { + Section(String(localized: .acknowledgements)) { ForEach(acknowledgements) { acknowledgement in LinkRow(urlString: acknowledgement.urlString, text: acknowledgement.text) } } } - .navigationTitle(L10n.Localizable.AboutView.ehPanda) + .navigationTitle(String(localized: .ehPanda)) .toolbar { ToolbarItem(placement: .largeSubtitle) { VStack(alignment: .leading) { - Text(L10n.Constant.copyright) + Text(String(localized: .Constant.copyright)) Text(version) } .foregroundStyle(.gray) @@ -59,144 +59,144 @@ struct AboutView: View { // MARK: Contacts private let contacts: [Info] = {[ .init( - urlString: L10n.Constant.Contact.website, - text: L10n.Localizable.AboutView.website + urlString: String(localized: .Constant.contactWebsite), + text: String(localized: .website) ), .init( - urlString: L10n.Constant.Contact.gitHub, - text: L10n.Constant.Contact.gitHubLink + urlString: String(localized: .Constant.contactGitHub), + text: String(localized: .Constant.contactGitHubLink) ), .init( - urlString: L10n.Constant.Contact.discord, - text: L10n.Constant.Contact.discordLink + urlString: String(localized: .Constant.contactDiscord), + text: String(localized: .Constant.contactDiscordLink) ), .init( - urlString: L10n.Constant.Contact.telegram, - text: L10n.Constant.Contact.telegramLink + urlString: String(localized: .Constant.contactTelegram), + text: String(localized: .Constant.contactTelegramLink) ), .init( - urlString: L10n.Constant.Contact.altStoreLink, - text: L10n.Localizable.AboutView.altStoreSource + urlString: String(localized: .Constant.contactAltStoreLink), + text: String(localized: .altStoreSource) ) ]}() // MARK: Special thanks private let specialThanks: [Info] = {[ .init( - urlString: L10n.Constant.SpecialThanks.taylorlannisterLink, - text: L10n.Constant.SpecialThanks.taylorlannister + urlString: String(localized: .Constant.specialThanksTaylorlannisterLink), + text: String(localized: .Constant.specialThanksTaylorlannister) ), .init( - urlString: L10n.Constant.SpecialThanks.luminescentYqLink, - text: L10n.Constant.SpecialThanks.luminescentYq + urlString: String(localized: .Constant.specialThanksLuminescentYqLink), + text: String(localized: .Constant.specialThanksLuminescentYq) ), .init( - urlString: L10n.Constant.SpecialThanks.caxerxLink, - text: L10n.Constant.SpecialThanks.caxerx + urlString: String(localized: .Constant.specialThanksCaxerxLink), + text: String(localized: .Constant.specialThanksCaxerx) ), .init( - urlString: L10n.Constant.SpecialThanks.honjowLink, - text: L10n.Constant.SpecialThanks.honjow + urlString: String(localized: .Constant.specialThanksHonjowLink), + text: String(localized: .Constant.specialThanksHonjow) ) ]}() // MARK: Code level contributors private let codeLevelContributors: [Info] = {[ .init( - urlString: L10n.Constant.CodeLevelContributor.vvbbnn00Link, - text: L10n.Constant.CodeLevelContributor.vvbbnn00 + urlString: String(localized: .Constant.codeLevelContributorVvbbnn00Link), + text: String(localized: .Constant.codeLevelContributorVvbbnn00) ), .init( - urlString: L10n.Constant.CodeLevelContributor.kaed3miLink, - text: L10n.Constant.CodeLevelContributor.kaed3mi + urlString: String(localized: .Constant.codeLevelContributorKaed3MiLink), + text: String(localized: .Constant.codeLevelContributorKaed3Mi) ), .init( - urlString: L10n.Constant.CodeLevelContributor.aalberrtyLink, - text: L10n.Constant.CodeLevelContributor.aalberrty + urlString: String(localized: .Constant.codeLevelContributorAalberrtyLink), + text: String(localized: .Constant.codeLevelContributorAalberrty) ), .init( - urlString: L10n.Constant.CodeLevelContributor.jimmyPrimeLink, - text: L10n.Constant.CodeLevelContributor.jimmyPrime + urlString: String(localized: .Constant.codeLevelContributorJimmyPrimeLink), + text: String(localized: .Constant.codeLevelContributorJimmyPrime) ), .init( - urlString: L10n.Constant.CodeLevelContributor.xioxinLink, - text: L10n.Constant.CodeLevelContributor.xioxin + urlString: String(localized: .Constant.codeLevelContributorXioxinLink), + text: String(localized: .Constant.codeLevelContributorXioxin) ) ]}() // MARK: Translation contributors private let translationContributors: [Info] = {[ .init( - urlString: L10n.Constant.TranslationContributor.nebulosaCatLink, - text: L10n.Constant.TranslationContributor.nebulosaCat + urlString: String(localized: .Constant.translationContributorNebulosaCatLink), + text: String(localized: .Constant.translationContributorNebulosaCat) ), .init( - urlString: L10n.Constant.TranslationContributor.paulHaeusslerLink, - text: L10n.Constant.TranslationContributor.paulHaeussler + urlString: String(localized: .Constant.translationContributorPaulHaeusslerLink), + text: String(localized: .Constant.translationContributorPaulHaeussler) ), .init( - urlString: L10n.Constant.TranslationContributor.caxerxLink, - text: L10n.Constant.TranslationContributor.caxerx + urlString: String(localized: .Constant.translationContributorCaxerxLink), + text: String(localized: .Constant.translationContributorCaxerx) ), .init( - urlString: L10n.Constant.TranslationContributor.neKoOuOLink, - text: L10n.Constant.TranslationContributor.neKoOuO + urlString: String(localized: .Constant.translationContributorNeKoOuOLink), + text: String(localized: .Constant.translationContributorNeKoOuO) ) ]}() // MARK: Acknowledgements private let acknowledgements: [Info] = {[ .init( - urlString: L10n.Constant.Acknowledgement.kannaLink, - text: L10n.Constant.Acknowledgement.kanna + urlString: String(localized: .Constant.acknowledgementKannaLink), + text: String(localized: .Constant.acknowledgementKanna) ), .init( - urlString: L10n.Constant.Acknowledgement.colorfulLink, - text: L10n.Constant.Acknowledgement.colorful + urlString: String(localized: .Constant.acknowledgementColorfulLink), + text: String(localized: .Constant.acknowledgementColorful) ), .init( - urlString: L10n.Constant.Acknowledgement.swiftGenLink, - text: L10n.Constant.Acknowledgement.swiftGen + urlString: String(localized: .Constant.acknowledgementSwiftGenLink), + text: String(localized: .Constant.acknowledgementSwiftGen) ), .init( - urlString: L10n.Constant.Acknowledgement.kingfisherLink, - text: L10n.Constant.Acknowledgement.kingfisher + urlString: String(localized: .Constant.acknowledgementKingfisherLink), + text: String(localized: .Constant.acknowledgementKingfisher) ), .init( - urlString: L10n.Constant.Acknowledgement.swiftUIPagerLink, - text: L10n.Constant.Acknowledgement.swiftUIPager + urlString: String(localized: .Constant.acknowledgementSwiftUIPagerLink), + text: String(localized: .Constant.acknowledgementSwiftUIPager) ), .init( - urlString: L10n.Constant.Acknowledgement.waterfallGridLink, - text: L10n.Constant.Acknowledgement.waterfallGrid + urlString: String(localized: .Constant.acknowledgementWaterfallGridLink), + text: String(localized: .Constant.acknowledgementWaterfallGrid) ), .init( - urlString: L10n.Constant.Acknowledgement.swiftyOpenCCLink, - text: L10n.Constant.Acknowledgement.swiftyOpenCC + urlString: String(localized: .Constant.acknowledgementSwiftyOpenCCLink), + text: String(localized: .Constant.acknowledgementSwiftyOpenCC) ), .init( - urlString: L10n.Constant.Acknowledgement.uiImageColorsLink, - text: L10n.Constant.Acknowledgement.uiImageColors + urlString: String(localized: .Constant.acknowledgementUiImageColorsLink), + text: String(localized: .Constant.acknowledgementUiImageColors) ), .init( - urlString: L10n.Constant.Acknowledgement.sfSafeSymbolsLink, - text: L10n.Constant.Acknowledgement.sfSafeSymbols + urlString: String(localized: .Constant.acknowledgementSfSafeSymbolsLink), + text: String(localized: .Constant.acknowledgementSfSafeSymbols) ), .init( - urlString: L10n.Constant.Acknowledgement.systemNotificationLink, - text: L10n.Constant.Acknowledgement.systemNotification + urlString: String(localized: .Constant.acknowledgementSystemNotificationLink), + text: String(localized: .Constant.acknowledgementSystemNotification) ), .init( - urlString: L10n.Constant.Acknowledgement.swiftCommonMarkLink, - text: L10n.Constant.Acknowledgement.swiftCommonMark + urlString: String(localized: .Constant.acknowledgementSwiftCommonMarkLink), + text: String(localized: .Constant.acknowledgementSwiftCommonMark) ), .init( - urlString: L10n.Constant.Acknowledgement.ehTagTranslationDatabaseLink, - text: L10n.Constant.Acknowledgement.ehTagTranslationDatabase + urlString: String(localized: .Constant.acknowledgementEhTagTranslationDatabaseLink), + text: String(localized: .Constant.acknowledgementEhTagTranslationDatabase) ), .init( - urlString: L10n.Constant.Acknowledgement.tcaLink, - text: L10n.Constant.Acknowledgement.tca + urlString: String(localized: .Constant.acknowledgementTcaLink), + text: String(localized: .Constant.acknowledgementTca) ) ]}() } diff --git a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift index e4f18771f..109b6d45c 100644 --- a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift @@ -20,30 +20,30 @@ struct DownloadSettingView: View { Form { Section { VStack(alignment: .leading) { - LabeledContent(L10n.Localizable.DownloadSettingView.concurrentImageDownloads) { + LabeledContent(String(localized: .concurrentImageDownloads)) { Text(downloadThreadLimit, format: .number) .monospacedDigit() } Slider(value: downloadThreadLimitValue, in: 1...5, step: 1) } Toggle( - L10n.Localizable.DownloadSettingView.retryFailedPagesAutomatically, + String(localized: .retryFailedPagesAutomatically), isOn: $downloadAutoRetryFailedPages ) } Section { Toggle( - L10n.Localizable.DownloadSettingView.allowCellularDownloads, + String(localized: .allowCellularDownloads), isOn: $downloadAllowCellular ) } header: { - Text(L10n.Localizable.DownloadSettingView.network) + Text(String(localized: .network)) } footer: { - Text(L10n.Localizable.DownloadSettingView.networkDescription) + Text(String(localized: .networkDescription)) } } - .navigationTitle(L10n.Localizable.DownloadSettingView.title) + .navigationTitle(String(localized: .title)) } private var downloadThreadLimitValue: Binding { diff --git a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift index 480c943ba..4d50757d0 100644 --- a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift @@ -15,13 +15,13 @@ struct LaboratorySettingView: View { VStack { LaboratoryCell( isOn: $bypassesSNIFiltering, - title: L10n.Localizable.LaboratorySettingView.bypassesSNIFiltering, + title: String(localized: .bypassesSniFiltering), symbol: .theatermasksFill, tintColor: .purple ) } .padding() } - .navigationTitle(L10n.Localizable.LaboratorySettingView.laboratory) + .navigationTitle(String(localized: .laboratory)) } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift index 67a80377a..88d13041d 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -21,7 +21,7 @@ struct EhProfileSection: View { var body: some View { Section { - Picker(L10n.Localizable.EhSettingView.selectedProfile, selection: $ehProfile) { + Picker(String(localized: .selectedProfile), selection: $ehProfile) { ForEach(ehSetting.ehProfiles) { ehProfile in Text(ehProfile.name) .tag(ehProfile) @@ -30,19 +30,19 @@ struct EhProfileSection: View { .pickerStyle(.menu) if !ehProfile.isDefault { - Button(L10n.Localizable.EhSettingView.setAsDefault) { + Button(String(localized: .setAsDefault)) { performEhProfileAction(.default, nil, ehProfile.value) } Button( - L10n.Localizable.EhSettingView.deleteProfile, + String(localized: .deleteProfile), role: .destructive, action: deleteDialogAction ) .confirmationDialog(deleteConfirmationDialog) } } header: { - Text(L10n.Localizable.EhSettingView.profileSettings) + Text(String(localized: .profileSettings)) .ehSettingRegularHeaderStyled() } .onChange(of: ehProfile) { _, newValue in @@ -53,13 +53,13 @@ struct EhProfileSection: View { SettingTextField(text: $editingProfileName, width: nil, alignment: .leading, background: .clear) .focused($isFocused) - Button(L10n.Localizable.EhSettingView.rename) { + Button(String(localized: .rename)) { performEhProfileAction(.rename, editingProfileName, ehProfile.value) } .disabled(isFocused) if ehSetting.isCapableOfCreatingNewProfile { - Button(L10n.Localizable.EhSettingView.createNew) { + Button(String(localized: .createNew)) { performEhProfileAction(.create, editingProfileName, ehProfile.value) } .disabled(isFocused) @@ -75,7 +75,7 @@ struct ImageLoadSettingsSection: View { var body: some View { Section { Picker( - L10n.Localizable.EhSettingView.loadImagesThroughTheHathNetwork, + String(localized: .loadImagesThroughTheHathNetwork), selection: $ehSetting.loadThroughHathSetting ) { ForEach(ehSetting.capableLoadThroughHathSettings) { setting in @@ -85,13 +85,13 @@ struct ImageLoadSettingsSection: View { } .pickerStyle(.menu) } header: { - Text.ehSettingBoldHeader(L10n.Localizable.EhSettingView.imageLoadSettings) + Text.ehSettingBoldHeader(String(localized: .imageLoadSettings)) } footer: { Text(ehSetting.loadThroughHathSetting.description) } Section { - Picker(L10n.Localizable.EhSettingView.browsingCountry, selection: $ehSetting.browsingCountry) { + Picker(String(localized: .browsingCountry), selection: $ehSetting.browsingCountry) { ForEach(EhSetting.BrowsingCountry.allCases) { country in Text(country.name) .tag(country) @@ -100,9 +100,9 @@ struct ImageLoadSettingsSection: View { } } header: { Text( - L10n.Localizable.EhSettingView.browsingCountryDescription( + String(localized: .browsingCountryDescription( ehSetting.localizedLiteralBrowsingCountry ?? ehSetting.literalBrowsingCountry - ) + )) .localizedKey ) .ehSettingRegularHeaderStyled() @@ -116,7 +116,7 @@ struct ImageSizeSettingsSection: View { var body: some View { Section { - Picker(L10n.Localizable.EhSettingView.imageResolution, selection: $ehSetting.imageResolution) { + Picker(String(localized: .imageResolution), selection: $ehSetting.imageResolution) { ForEach(ehSetting.capableImageResolutions) { setting in Text(setting.value) .tag(setting) @@ -125,37 +125,37 @@ struct ImageSizeSettingsSection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.imageSizeSettings, - description: L10n.Localizable.EhSettingView.imageResolutionDescription + String(localized: .imageSizeSettings), + description: String(localized: .imageResolutionDescription) ) } if let useOriginalImagesBinding = Binding($ehSetting.useOriginalImages) { Section { Toggle( - L10n.Localizable.EhSettingView.useOriginalImages, + String(localized: .useOriginalImages), isOn: useOriginalImagesBinding ) } header: { - Text(L10n.Localizable.EhSettingView.originalImages) + Text(String(localized: .originalImages)) .ehSettingRegularHeaderStyled() } } Section { - Text(L10n.Localizable.EhSettingView.imageSize) + Text(String(localized: .imageSize)) ValuePicker( - title: L10n.Localizable.EhSettingView.horizontal, + title: String(localized: .horizontal), value: $ehSetting.imageSizeWidth, range: 0...65535, unit: "px" ) ValuePicker( - title: L10n.Localizable.EhSettingView.vertical, + title: String(localized: .vertical), value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px" ) } header: { - Text(L10n.Localizable.EhSettingView.imageSizeDescription) + Text(String(localized: .imageSizeDescription)) .ehSettingRegularHeaderStyled() } } @@ -167,7 +167,7 @@ struct GalleryNameDisplaySection: View { var body: some View { Section { - Picker(L10n.Localizable.EhSettingView.galleryName, selection: $ehSetting.galleryName) { + Picker(String(localized: .galleryName), selection: $ehSetting.galleryName) { ForEach(EhSetting.GalleryName.allCases) { name in Text(name.value) .tag(name) @@ -176,8 +176,8 @@ struct GalleryNameDisplaySection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.galleryNameDisplay, - description: L10n.Localizable.EhSettingView.galleryNameDescription + String(localized: .galleryNameDisplay), + description: String(localized: .galleryNameDescription) ) } } @@ -189,7 +189,7 @@ struct ArchiverSettingsSection: View { var body: some View { Section { - Picker(L10n.Localizable.EhSettingView.archiverBehavior, selection: $ehSetting.archiverBehavior) { + Picker(String(localized: .archiverBehavior), selection: $ehSetting.archiverBehavior) { ForEach(EhSetting.ArchiverBehavior.allCases) { behavior in Text(behavior.value) .tag(behavior) @@ -198,8 +198,8 @@ struct ArchiverSettingsSection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.archiverSettings, - description: L10n.Localizable.EhSettingView.archiverBehaviorDescription + String(localized: .archiverSettings), + description: String(localized: .archiverBehaviorDescription) ) } } @@ -218,13 +218,13 @@ struct FrontPageSettingsSection: View { CategoryView(bindings: categoryBindings) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.frontPageSettings, - description: L10n.Localizable.EhSettingView.galleryCategory + String(localized: .frontPageSettings), + description: String(localized: .galleryCategory) ) } Section { - Picker(L10n.Localizable.EhSettingView.displayMode, selection: $ehSetting.displayMode) { + Picker(String(localized: .displayMode), selection: $ehSetting.displayMode) { ForEach(EhSetting.DisplayMode.allCases) { mode in Text(mode.value) .tag(mode) @@ -232,17 +232,17 @@ struct FrontPageSettingsSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.displayModeDescription) + Text(String(localized: .displayModeDescription)) .ehSettingRegularHeaderStyled() } Section { Toggle( - L10n.Localizable.EhSettingView.showSearchRangeIndicatorDescription, + String(localized: .showSearchRangeIndicatorDescription), isOn: $ehSetting.showSearchRangeIndicator ) } header: { - Text(L10n.Localizable.EhSettingView.showSearchRangeIndicator) + Text(String(localized: .showSearchRangeIndicator)) .ehSettingRegularHeaderStyled() } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift index 222f13eb5..fc738f922 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift @@ -13,13 +13,13 @@ struct OptionalUIElementsSection: View { var body: some View { Section { Toggle( - L10n.Localizable.EhSettingView.enableGalleryThumbnailSelector, + String(localized: .enableGalleryThumbnailSelector), isOn: $ehSetting.enableGalleryThumbnailSelector ) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.optionalUIElements, - description: L10n.Localizable.EhSettingView.optionalUIElementsDescription + String(localized: .optionalUiElements), + description: String(localized: .optionalUiElementsDescription) ) } } @@ -51,14 +51,14 @@ struct FavoritesSection: View { } } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.favorites, - description: L10n.Localizable.EhSettingView.favoriteCategories + String(localized: .favoritesSection), + description: String(localized: .favoriteCategories) ) } Section { Picker( - L10n.Localizable.EhSettingView.favoritesSortOrder, + String(localized: .favoritesSortOrder), selection: $ehSetting.favoritesSortOrder ) { ForEach(EhSetting.FavoritesSortOrder.allCases) { order in @@ -68,7 +68,7 @@ struct FavoritesSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.favoritesSortOrderDescription) + Text(String(localized: .favoritesSortOrderDescription)) .ehSettingRegularHeaderStyled() } } @@ -81,18 +81,18 @@ struct RatingsSection: View { var body: some View { Section { - LabeledContent(L10n.Localizable.EhSettingView.ratingsColor) { + LabeledContent(String(localized: .ratingsColor)) { SettingTextField( text: $ehSetting.ratingsColor, - promptText: L10n.Localizable.EhSettingView.ratingsColorPrompt, + promptText: String(localized: .ratingsColorPrompt), width: 80 ) .focused($isFocused) } } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.ratings, - description: L10n.Localizable.EhSettingView.ratingsColorDescription + String(localized: .ratings), + description: String(localized: .ratingsColorDescription) ) } } @@ -106,7 +106,7 @@ struct SearchResultCountSection: View { var body: some View { Section { - Picker(L10n.Localizable.EhSettingView.resultCount, selection: $ehSetting.searchResultCount) { + Picker(String(localized: .resultCount), selection: $ehSetting.searchResultCount) { ForEach(ehSetting.capableSearchResultCounts) { count in Text(String(count.value)) .tag(count) @@ -115,8 +115,8 @@ struct SearchResultCountSection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.searchResultCount, - description: L10n.Localizable.EhSettingView.resultCountDescription + String(localized: .searchResultCount), + description: String(localized: .resultCountDescription) ) } } @@ -129,7 +129,7 @@ struct ThumbnailSettingsSection: View { var body: some View { Section { Picker( - L10n.Localizable.EhSettingView.thumbnailLoadTiming, + String(localized: .thumbnailLoadTiming), selection: $ehSetting.thumbnailLoadTiming ) { ForEach(EhSetting.ThumbnailLoadTiming.allCases) { timing in @@ -140,15 +140,15 @@ struct ThumbnailSettingsSection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.thumbnailSettings, - description: L10n.Localizable.EhSettingView.thumbnailLoadTimingDescription + String(localized: .thumbnailSettings), + description: String(localized: .thumbnailLoadTimingDescription) ) } footer: { Text(ehSetting.thumbnailLoadTiming.description) } Section { - LabeledContent(L10n.Localizable.EhSettingView.thumbnailSize) { + LabeledContent(String(localized: .thumbnailSize)) { Picker(selection: $ehSetting.thumbnailConfigSize) { ForEach(ehSetting.capableThumbnailConfigSizes) { size in Text(size.value) @@ -161,7 +161,7 @@ struct ThumbnailSettingsSection: View { .frame(width: 200) } - LabeledContent(L10n.Localizable.EhSettingView.thumbnailRowCount) { + LabeledContent(String(localized: .thumbnailRowCount)) { Picker(selection: $ehSetting.thumbnailConfigRows) { ForEach(ehSetting.capableThumbnailConfigRowCounts) { row in Text(row.value) @@ -174,7 +174,7 @@ struct ThumbnailSettingsSection: View { .frame(width: 200) } } header: { - Text(L10n.Localizable.EhSettingView.thumbnailConfiguration) + Text(String(localized: .thumbnailConfiguration)) .ehSettingRegularHeaderStyled() } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift index bd5b324f4..169076712 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -12,15 +12,15 @@ struct CoverScalingSection: View { var body: some View { Section { ValuePicker( - title: L10n.Localizable.EhSettingView.scaleFactor, + title: String(localized: .scaleFactor), value: $ehSetting.coverScaleFactor, range: 75...150, unit: "%" ) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.coverScaling, - description: L10n.Localizable.EhSettingView.coverScaleFactor + String(localized: .coverScaling), + description: String(localized: .coverScaleFactor) ) } } @@ -33,13 +33,13 @@ struct TagFilteringThresholdSection: View { var body: some View { Section { ValuePicker( - title: L10n.Localizable.EhSettingView.tagFilteringThreshold, + title: String(localized: .tagFilteringThreshold), value: $ehSetting.tagFilteringThreshold, range: -9999...0 ) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.tagFilteringThreshold, - description: L10n.Localizable.EhSettingView.tagFilteringThresholdDescription + String(localized: .tagFilteringThreshold), + description: String(localized: .tagFilteringThresholdDescription) ) } } @@ -52,13 +52,13 @@ struct TagWatchingThresholdSection: View { var body: some View { Section { ValuePicker( - title: L10n.Localizable.EhSettingView.tagWatchingThreshold, + title: String(localized: .tagWatchingThreshold), value: $ehSetting.tagWatchingThreshold, range: 0...9999 ) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.tagWatchingThreshold, - description: L10n.Localizable.EhSettingView.tagWatchingThresholdDescription + String(localized: .tagWatchingThreshold), + description: String(localized: .tagWatchingThresholdDescription) ) } } @@ -71,13 +71,13 @@ struct FilteredRemovalCountSection: View { var body: some View { Section { Toggle( - L10n.Localizable.EhSettingView.showFilteredRemovalCount, + String(localized: .showFilteredRemovalCount), isOn: $ehSetting.showFilteredRemovalCount ) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.ehSettingViewfilteredRemovalCount, - description: L10n.Localizable.EhSettingView.filteredRemovalCountDescription + String(localized: .filteredRemovalCount), + description: String(localized: .filteredRemovalCountDescription) ) } } @@ -128,8 +128,8 @@ struct ExcludedLanguagesSection: View { } } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.excludedLanguages, - description: L10n.Localizable.EhSettingView.excludedLanguagesDescription + String(localized: .excludedLanguages), + description: String(localized: .excludedLanguagesDescription) ) } } @@ -188,14 +188,14 @@ struct ExcludedUploadersSection: View { .focused($isFocused) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.excludedUploaders, - description: L10n.Localizable.EhSettingView.excludedUploadersDescription + String(localized: .excludedUploaders), + description: String(localized: .excludedUploadersDescription) ) } footer: { Text( - L10n.Localizable.EhSettingView.excludedUploadersCount( + String(localized: .excludedUploadersCount( "\(ehSetting.excludedUploaders.ehSettingLineCount)", "\(1000)" - ) + )) .localizedKey ) } @@ -209,15 +209,15 @@ struct ViewportOverrideSection: View { var body: some View { Section { ValuePicker( - title: L10n.Localizable.EhSettingView.virtualWidth, + title: String(localized: .virtualWidth), value: $ehSetting.viewportVirtualWidth, range: 0...9999, unit: "px" ) } header: { Text.ehSettingBoldHeader( - L10n.Localizable.EhSettingView.viewportOverride, - description: L10n.Localizable.EhSettingView.virtualWidthDescription + String(localized: .viewportOverride), + description: String(localized: .virtualWidthDescription) ) } } @@ -230,7 +230,7 @@ struct GalleryCommentsSection: View { var body: some View { Section { Picker( - L10n.Localizable.EhSettingView.commentsSortOrder, + String(localized: .commentsSortOrder), selection: $ehSetting.commentsSortOrder ) { ForEach(EhSetting.CommentsSortOrder.allCases) { order in @@ -241,7 +241,7 @@ struct GalleryCommentsSection: View { .pickerStyle(.menu) Picker( - L10n.Localizable.EhSettingView.commentsVotesShowTiming, + String(localized: .commentsVotesShowTiming), selection: $ehSetting.commentVotesShowTiming ) { ForEach(EhSetting.CommentVotesShowTiming.allCases) { timing in @@ -251,7 +251,7 @@ struct GalleryCommentsSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.galleryComments) + Text(String(localized: .galleryComments)) .ehSettingRegularHeaderStyled() } } @@ -263,7 +263,7 @@ struct GalleryTagsSection: View { var body: some View { Section { - Picker(L10n.Localizable.EhSettingView.tagsSortOrder, selection: $ehSetting.tagsSortOrder) { + Picker(String(localized: .tagsSortOrder), selection: $ehSetting.tagsSortOrder) { ForEach(EhSetting.TagsSortOrder.allCases) { order in Text(order.value) .tag(order) @@ -271,7 +271,7 @@ struct GalleryTagsSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.galleryTags) + Text(String(localized: .galleryTags)) .ehSettingRegularHeaderStyled() } } @@ -284,7 +284,7 @@ struct GalleryPageThumbnailLabelingSection: View { var body: some View { Section { Picker( - L10n.Localizable.EhSettingView.showLabelBelowGalleryThumbnails, + String(localized: .showLabelBelowGalleryThumbnails), selection: $ehSetting.galleryPageNumbering ) { ForEach(EhSetting.GalleryPageNumbering.allCases) { behavior in @@ -294,7 +294,7 @@ struct GalleryPageThumbnailLabelingSection: View { } .pickerStyle(.menu) } header: { - Text(L10n.Localizable.EhSettingView.galleryPageThumbnailLabeling) + Text(String(localized: .galleryPageThumbnailLabeling)) .ehSettingRegularHeaderStyled() } } @@ -310,12 +310,12 @@ struct MultiplePageViewerSection: View { let multiplePageViewerShowPaneBinding = Binding($ehSetting.multiplePageViewerShowThumbnailPane) { Section { Toggle( - L10n.Localizable.EhSettingView.useMultiPageViewer, + String(localized: .useMultiPageViewer), isOn: useMultiplePageViewerBinding ) Picker( - L10n.Localizable.EhSettingView.displayStyle, + String(localized: .displayStyle), selection: multiplePageViewerStyleBinding ) { ForEach(EhSetting.MultiplePageViewerStyle.allCases) { style in @@ -326,11 +326,11 @@ struct MultiplePageViewerSection: View { .pickerStyle(.menu) Toggle( - L10n.Localizable.EhSettingView.showThumbnailPane, + String(localized: .showThumbnailPane), isOn: multiplePageViewerShowPaneBinding ) } header: { - Text(L10n.Localizable.EhSettingView.multiPageViewer) + Text(String(localized: .multiPageViewer)) .ehSettingRegularHeaderStyled() } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift index 193b219f6..1b60eda6e 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift @@ -53,7 +53,7 @@ struct EhSettingView: View { .autoBlur(radius: blurRadius) } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.EhSettingView.hostSettings(galleryHost.rawValue)) + .navigationTitle(String(localized: .hostSettings(galleryHost.rawValue))) } // MARK: Form private func form(ehSetting: Binding, ehProfile: Binding) -> some View { @@ -120,7 +120,7 @@ struct EhSettingView: View { } ToolbarItem(placement: .keyboard) { - Button(L10n.Localizable.EhSettingView.done) { + Button(String(localized: .done)) { store.send(.setKeyboardHidden) } .frame(maxWidth: .infinity, alignment: .trailing) diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index b83c1bd5f..24b11c91b 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -93,13 +93,13 @@ public struct GeneralSettingReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmRemoveCustomTranslations) { - TextState(L10n.Localizable.ConfirmationDialog.remove) + TextState(String(localized: .remove)) } ButtonState(role: .cancel) { TextState(L10n.Localizable.Common.cancel) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.removeCustomTranslations) + TextState(String(localized: .removeCustomTranslationsConfirmation)) } return .none diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index dc842b3c3..711d9785d 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -44,7 +44,7 @@ struct GeneralSettingView: View { private var language: String { Locale.current.language.languageCode.map(\.identifier).flatMap(Locale.current.localizedString(forLanguageCode:)) - ?? L10n.Localizable.GeneralSettingView.defaultLanguageDescription + ?? String(localized: .defaultLanguageDescription) } var body: some View { @@ -58,14 +58,14 @@ struct GeneralSettingView: View { } .foregroundStyle(.tint) } - Button(L10n.Localizable.GeneralSettingView.appActivityLogs) { + Button(String(localized: .appActivityLogs)) { store.send(.delegate(.pushAppActivityLogs)) } .foregroundColor(.primary).withArrow() } - Section(L10n.Localizable.GeneralSettingView.tags) { + Section(String(localized: .tags)) { HStack { - Text(L10n.Localizable.GeneralSettingView.enablesTagsExtension) + Text(String(localized: .enablesTagsExtension)) .frame(maxWidth: .infinity, alignment: .leading) ZStack { @@ -85,14 +85,14 @@ struct GeneralSettingView: View { .padding(.leading, 20) } if enablesTagsExtension && !tagTranslatorEmpty { - Toggle(L10n.Localizable.GeneralSettingView.translatesTags, isOn: $translatesTags) + Toggle(String(localized: .translatesTags), isOn: $translatesTags) Toggle( - L10n.Localizable.GeneralSettingView.showsTagsSearchSuggestion, + String(localized: .showsTagsSearchSuggestion), isOn: $showsTagsSearchSuggestion ) - Toggle(L10n.Localizable.GeneralSettingView.showsImagesInTags, isOn: $showsImagesInTags) + Toggle(String(localized: .showsImagesInTags), isOn: $showsImagesInTags) } - Button(L10n.Localizable.GeneralSettingView.importCustomTranslations) { + Button(String(localized: .importCustomTranslations)) { store.send(.importCustomTranslationsButtonTapped) } .fileImporter( @@ -105,7 +105,7 @@ struct GeneralSettingView: View { } if tagTranslatorHasCustomTranslations { Button( - L10n.Localizable.GeneralSettingView.removeCustomTranslations, + String(localized: .removeCustomTranslations), role: .destructive, action: { store.send(.removeCustomTranslationsButtonTapped) } ) .confirmationDialog( @@ -113,20 +113,20 @@ struct GeneralSettingView: View { ) } } - Section(L10n.Localizable.GeneralSettingView.navigation) { + Section(String(localized: .navigation)) { Toggle( - L10n.Localizable.GeneralSettingView.redirectsLinksToTheSelectedHost, + String(localized: .redirectsLinksToTheSelectedHost), isOn: $redirectsLinksToSelectedHost ) Toggle( - L10n.Localizable.GeneralSettingView.detectsLinksFromClipboard, + String(localized: .detectsLinksFromClipboard), isOn: $detectsLinksFromClipboard ) } - Section(L10n.Localizable.GeneralSettingView.security) { + Section(String(localized: .security)) { HStack { Picker( - L10n.Localizable.GeneralSettingView.autoLock, + String(localized: .autoLock), selection: $autoLockPolicy ) { ForEach(AutoLockPolicy.allCases) { policy in @@ -139,7 +139,7 @@ struct GeneralSettingView: View { } } VStack(alignment: .leading) { - Text(L10n.Localizable.GeneralSettingView.backgroundBlurRadius) + Text(String(localized: .backgroundBlurRadius)) HStack { Image(systemSymbol: .eye) Slider(value: $backgroundBlurRadius, in: 0...100, step: 10) @@ -147,12 +147,12 @@ struct GeneralSettingView: View { } } } - Section(L10n.Localizable.GeneralSettingView.caches) { + Section(String(localized: .caches)) { Button { store.send(.clearImageCachesButtonTapped) } label: { HStack { - Text(L10n.Localizable.GeneralSettingView.clearImageCaches) + Text(String(localized: .clearImageCaches)) Spacer() Text(store.diskImageCacheSize).foregroundStyle(.tint) } @@ -171,7 +171,7 @@ struct GeneralSettingView: View { store.send(.checkPasscodeSetting) store.send(.calculateWebImageDiskCache) } - .navigationTitle(L10n.Localizable.GeneralSettingView.general) + .navigationTitle(String(localized: .general)) } } diff --git a/AppPackage/Sources/SettingFeature/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift index 8571aac72..31c4a9f4f 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginView.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginView.swift @@ -34,13 +34,13 @@ struct LoginView: View { LoginTextField( focusedField: $focusedField, text: $store.username, - description: L10n.Localizable.LoginView.username, + description: String(localized: .username), isPassword: false ) LoginTextField( focusedField: $focusedField, text: $store.password, - description: L10n.Localizable.LoginView.password, + description: String(localized: .password), isPassword: true ) } diff --git a/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings b/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings new file mode 100644 index 000000000..bea9ac257 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings @@ -0,0 +1,738 @@ +{ + "sourceLanguage": "en", + "strings": { + "acknowledgement.colorful": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Colorful" + } + } + } + }, + "acknowledgement.colorful_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Co2333/Colorful" + } + } + } + }, + "acknowledgement.ehTagTranslationDatabase": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "EhTagTranslation/Database" + } + } + } + }, + "acknowledgement.ehTagTranslationDatabase_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhTagTranslation/Database" + } + } + } + }, + "acknowledgement.kanna": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kanna" + } + } + } + }, + "acknowledgement.kanna_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/tid-kijyun/Kanna" + } + } + } + }, + "acknowledgement.kingfisher": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kingfisher" + } + } + } + }, + "acknowledgement.kingfisher_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/onevcat/Kingfisher" + } + } + } + }, + "acknowledgement.sfSafeSymbols": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SFSafeSymbols" + } + } + } + }, + "acknowledgement.sfSafeSymbols_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" + } + } + } + }, + "acknowledgement.swiftCommonMark": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftCommonMark" + } + } + } + }, + "acknowledgement.swiftCommonMark_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/gonzalezreal/SwiftCommonMark" + } + } + } + }, + "acknowledgement.swiftGen": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftGen" + } + } + } + }, + "acknowledgement.swiftGen_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SwiftGen/SwiftGen" + } + } + } + }, + "acknowledgement.swiftUIPager": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftUIPager" + } + } + } + }, + "acknowledgement.swiftUIPager_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/fermoya/SwiftUIPager" + } + } + } + }, + "acknowledgement.swiftyOpenCC": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftyOpenCC" + } + } + } + }, + "acknowledgement.swiftyOpenCC_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + } + } + }, + "acknowledgement.systemNotification": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + } + } + }, + "acknowledgement.systemNotification_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + } + } + }, + "acknowledgement.tca": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + } + } + }, + "acknowledgement.tca_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + } + } + }, + "acknowledgement.uiImageColors": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + } + } + }, + "acknowledgement.uiImageColors_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + } + } + }, + "acknowledgement.waterfallGrid": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + } + } + }, + "acknowledgement.waterfallGrid_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + } + } + }, + "code_level_contributor.Jimmy-Prime": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + } + } + }, + "code_level_contributor.Jimmy-Prime_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + } + } + }, + "code_level_contributor.Kaed3mi": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + } + } + }, + "code_level_contributor.Kaed3mi_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + } + } + }, + "code_level_contributor.aalberrty": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + } + } + }, + "code_level_contributor.aalberrty_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + } + } + }, + "code_level_contributor.vvbbnn00": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + } + } + }, + "code_level_contributor.vvbbnn00_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + } + } + }, + "code_level_contributor.xioxin": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + } + } + }, + "code_level_contributor.xioxin_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + } + } + }, + "contact.altStore_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + } + } + }, + "contact.discord": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + } + } + }, + "contact.discord_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + } + } + }, + "contact.gitHub": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + } + } + }, + "contact.gitHub_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + } + } + }, + "contact.telegram": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + } + } + }, + "contact.telegram_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Telegram" + } + } + } + }, + "contact.website": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://ehpanda.app" + } + } + } + }, + "copyright": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copyright © 2026 EhPanda Team" + } + } + } + }, + "special_thanks.caxerx": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + } + } + }, + "special_thanks.caxerx_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + } + } + }, + "special_thanks.honjow": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + } + } + }, + "special_thanks.honjow_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" + } + } + } + }, + "special_thanks.luminescent_yq": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Luminescent_yq" + } + } + } + }, + "special_thanks.luminescent_yq_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "" + } + } + } + }, + "special_thanks.taylorlannister": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "taylorlannister" + } + } + } + }, + "special_thanks.taylorlannister_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/taylorlannister" + } + } + } + }, + "translation_contributor.NeKoOuO": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "ɴᴇᴋᴏ" + } + } + } + }, + "translation_contributor.NeKoOuO_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/NeKoOuO" + } + } + } + }, + "translation_contributor.caxerx": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + } + } + }, + "translation_contributor.caxerx_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + } + } + }, + "translation_contributor.nebulosa-cat": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "雲豹 ΦωΦ" + } + } + } + }, + "translation_contributor.nebulosa-cat_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Nebulosa-Cat" + } + } + } + }, + "translation_contributor.paulHaeussler": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "PaulHaeussler" + } + } + } + }, + "translation_contributor.paulHaeussler_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/PaulHaeussler" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..6c28042d7 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings @@ -0,0 +1,6238 @@ +{ + "sourceLanguage": "en", + "strings": { + "account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "账户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "帳號設定" + } + } + } + }, + "account_configuration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account configuration" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kontoeinstellungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウント設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "账户设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "帳號設定" + } + } + } + }, + "acknowledgements": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Acknowledgements" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Danksagungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "謝辞" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "도움을 주신 분들" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "致谢" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "致謝" + } + } + } + }, + "allow_cellular_downloads": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow cellular downloads" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloads über Mobilfunk erlauben" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モバイル通信でのダウンロードを許可" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "셀룰러 다운로드 허용" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "允许蜂窝网络下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "允許行動網路下載" + } + } + } + }, + "altStore_source": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AltStore source" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "AltStore Quelle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AltStore ソース" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AltStore 소스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AltStore 源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "AltStore source" + } + } + } + }, + "app_activity_logs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App activity logs" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App-Aktivitätsprotokolle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリアクティビティログ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 활동 로그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用活动日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "應用程式活動日誌" + } + } + } + }, + "app_activity_logs_view.current": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuell" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前" + } + } + } + }, + "app_activity_logs_view.more_logs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "More logs" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weitere Protokolle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "他のログ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "더 많은 로그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更多日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更多日誌" + } + } + } + }, + "app_activity_logs_view.no_logs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No logs found" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Protokolle gefunden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ログが見つかりません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그가 없습니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到日誌" + } + } + } + }, + "app_activity_logs_view.open_in_files": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open in Files" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In „Dateien“ öffnen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルで開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일 앱에서 열기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在“文件”中打开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在「檔案」中開啟" + } + } + } + }, + "app_activity_logs_view.run": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausführung %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "運行 %@" + } + } + } + }, + "app_activity_logs_view.runs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Runs" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausführungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "運行" + } + } + } + }, + "app_activity_logs_view.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App activity logs" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App-Aktivitätsprotokolle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリアクティビティログ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 활동 로그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用活动日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "應用程式活動日誌" + } + } + } + }, + "app_icon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App icon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App-Symbol" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリアイコン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 아이콘" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用图标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "App 圖案" + } + } + } + }, + "appearance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oberfläche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "外観" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "외관" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "外观" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "外觀設定" + } + } + } + }, + "appearance_display_mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display mode" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigemodus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示モード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시방식" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示样式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示模式" + } + } + } + }, + "archiver_behavior": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archiver behavior" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Archiver-Verhalten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイバー動作" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아카이버 동작 방법 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "归档下载方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "存檔邏輯設定" + } + } + } + }, + "archiver_behavior_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardmäßig fragt der Archiver Kosten und Auswahl (Original oder neu berechnet) ab und zeigt dann einen Link an, den du anklicken oder woanders kopieren kannst. Dieses Verhalten kannst du hier ändern." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイバーのデフォルト動作はオリジナルとリサンプルのアーカイブのコストと選択を確認してからリンクを提供し、それからそのリンクをクリックしたりどこかにペーストしたりすることも可能です。そのデフォルト動作はここで変更できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아카이버의 기본 동작은 원본 또는 저화질 갤러리 저장에 대한 비용과 선택을 확인한 다음 다른 곳에서 클릭하거나 복사할 수 있는 링크를 표시하는 것입니다. 여기서 이 동작을 변경할 수 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认归档下载方式为手动选择(原画质或压缩画质),然后手动复制或点击下载链接。你可以修改归档下载方式。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "存檔的預設邏輯是確認原始圖片或重新採樣後進行存檔的成本差異與選擇,然後顯示一個可以在其他地方點擊、複製的連結,你可以在此處更改他的運作方式。" + } + } + } + }, + "archiver_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archiver Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Archiver" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイバー設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아카이버" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "归档设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "存檔設定" + } + } + } + }, + "auto_lock": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-Lock" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatische Sperre" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動ロック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 자동 잠금" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动锁定" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動鎖定" + } + } + } + }, + "background_blur_radius": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Background blur radius" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hintergrund-Unschärfe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッググラウンドぼかし度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "백그라운드 흐림 정도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "后台模糊效果" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "後台背景模糊" + } + } + } + }, + "browsing_country": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browsing country" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browsing-Land" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우징하는 나라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览国家" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所在國家" + } + } + } + }, + "browsing_country_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es sieht so aus, als würdest du die Seite aus **%@** aufrufen oder ein VPN bzw. einen Proxy in diesem Land verwenden. Die Seite versucht daher, Bilder von H@H-Clients in dieser Region zu laden. Falls das nicht stimmt oder du aus irgendeinem Grund eine andere Region verwenden möchtest (etwa mit einem Split-Tunneling-VPN), kannst du unten ein anderes Land auswählen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "**%@** から本サイトを閲覧している、またはその国の VPN・プロキシを使用しているようです。本サイトはその地域の H@H クライアントから画像を読み込もうとしますが、もし自動検知の結果が誤っている、または特別な事情でほかの地域のクライアントを希望する場合(例えばスプリットトンネル VPN を使用している)は下に手動選択できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "**%@**에서 사이트를 탐색하거나 이 나라에서 VPN이나 프록시를 사용하려고 하는 것 같네요. 이런 경우엔 사이트에서 이 지역의 H@H 클라이언트의 이미지를 로드하려고 시도할 거에요. 만약에 이 나라가 잘못되었거나 분할 터널링 VPN을 사용하는 경우와 같이 어떤 이유로든 다른 지역을 사용하려는 경우라면, 아래에서 다른 나라를 선택할 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你似乎正在 **%@** 浏览此网页,或是使用了一个来自这个国家的 VPN 或代理,这意味着网站将尝试通过在此区域的 H@H 客户端加载图片。如果该结果不正确,或你想通过其它地区的 H@H 客户端加载图片(例如你正在使用分割隧道 VPN),你可以在下方选择另一个国家。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你似乎是從 **%@** 瀏覽這個網站或在使用當地的 VPN,這意味著網站將嘗試從這個地理區域的 H@H 用戶端載入圖片。如果這不正確或你出於任何原因想要使用不同的區域(例如你正在透過 VPN 連線),您可以在下面選擇不同的國家/地區。" + } + } + } + }, + "bypasses_SNI_filtering": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bypasses SNI Filtering" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SNI Filter umgehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SNI フィルタリング回避" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SNI 차단 우회" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "域前置绕过 SNI 阻断" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "繞過 SNI 過濾" + } + } + } + }, + "caches": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Caches" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cache" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャッシュ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캐시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缓存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "快取" + } + } + } + }, + "clear_image_caches": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear image caches" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwischengespeicherte Bilder (Cache) löschen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像キャッシュを削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 캐시 지우기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清空图片缓存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理圖片快取" + } + } + } + }, + "code_level_contributors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Code-level contributors" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mitwirkende am Code" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コードレベル貢献者" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코드 기여자" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代码级贡献者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "程式碼貢獻者" + } + } + } + }, + "comments_sort_order": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Comments sort order" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sortierung der Kommentare" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コメントの並び替え" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "댓글 순서" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "评论排序方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "留言排序方式" + } + } + } + }, + "comments_votes_show_timing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Comment votes show timing" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige der Kommentarbewertungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コメントスコア表示タイミング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "평가의 시간을 보이기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示评论分数时机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "留言投票數顯示時機" + } + } + } + }, + "concurrent_image_downloads": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Concurrent image downloads" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gleichzeitige Bilddownloads" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "同時画像ダウンロード数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "동시 이미지 다운로드 수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "并发图片下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "並行圖片下載" + } + } + } + }, + "copy_cookies": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy cookies" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cookies kopieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クッキーをコピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쿠키 복사하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制 Cookies" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "複製 Cookies" + } + } + } + }, + "cover_scale_factor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Covergröße in Galerielisten kann in den Anzeigemodi „Vorschaubilder“ und „Erweitert“ auf 75%% bis 150%% skaliert werden." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サムネイル・拡張表示モードでのカバーを 75%% ~ 150%% にスケールすることができます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "썸네일 또는 확장 표시방식에서는 갤러리 목록의 표지 크기를 75%%에서 150%% 사이로 조절할 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩略图和扩展模式下的画廊列表封面可以缩放为 75%% 到 150%% 之间的值。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在縮圖與放大檢視這兩種檢視模式下,封面的重新採樣比率介於 75%% 至 150%%." + } + } + } + }, + "cover_scaling": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cover Scaling" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cover-Skalierung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カバースケーリング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표지 크기 조절" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "封面缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "封面縮放" + } + } + } + }, + "create_new": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create new" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu erstellen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規作成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추가" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建新档案" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增設定檔" + } + } + } + }, + "default_language_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "N/A" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "N/A" + } + } + } + }, + "delete_profile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete profile" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Profil löschen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイルを削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필 삭제" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除档案" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除設定檔" + } + } + } + }, + "detects_links_from_clipboard": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detects links from the clipboard" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übernimmt automatisch Links aus der Zwischenablage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリップボードからリンクを探知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클립보드의 링크 인식하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从剪切板检测链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "偵測剪貼簿中的連結" + } + } + } + }, + "display_mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display mode" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigemodus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示モード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시방식" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示样式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示模式" + } + } + } + }, + "display_mode_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Which display mode would you like to use on the front and search pages?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Welchen Anzeigemodus möchtest du auf der Startseite und den Suchseiten verwenden?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フロント・検索ページで使う表示モードはどれにしますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프론트와 검색 페이지에서 사용할 디스플레이 모드를 선택하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你希望在扉页和搜索页显示哪种样式?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你想在首頁和搜尋結果中使用哪一種顯示方式?" + } + } + } + }, + "display_style": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display style" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Darstellungsstil" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示仕様" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보여주기 스타일" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示样式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示方式" + } + } + } + }, + "displays_japanese_title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Displays Japanese title" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Japanischen Titel anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日本語タイトルを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일본어 제목 보여주기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示日文标题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "以日文顯示標籤" + } + } + } + }, + "done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fertig" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "완료" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完成" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "完成" + } + } + } + }, + "ehPanda": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + } + } + }, + "enable_gallery_thumbnail_selector": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable thumbnail selector on gallery screen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschaubild-Auswahl auf der Galerieseite aktivieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリーのサムネイルセレクタ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 화면에서 썸네일 선택기 사용" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在画廊页面启用缩图选择器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在畫廊頁面啟用縮圖選擇器" + } + } + } + }, + "enables_tags_extension": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enables tags extension" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tag-Erweiterung aktivieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの拡張機能を有効" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 확장 기능 사용하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用标签扩展" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用自訂標籤擴充功能" + } + } + } + }, + "excluded_languages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Excluded Languages" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgeschlossene Sprachen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "排除された言語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제외된 언어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏蔽的语言" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排除語言" + } + } + } + }, + "excluded_languages_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn du Galerien in bestimmten Sprachen aus der Galerieliste und den Suchergebnissen ausblenden möchtest, wähle sie unten aus. Passende Galerien erscheinen dann unabhängig von deiner Suchanfrage nie." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "特定の言語のギャラリーをリストと検索結果から隠したい場合、下に選択してください。注意:どんな検索クエリーを使ってもこれらの言語のギャラリーは表示されません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 목록에서 특정 언어로 된 갤러리를 숨기고 검색하려면 아래 목록에서 해당 갤러리를 선택해주세요. 검색어에 관계없이 일치하는 갤러리는 나타나지 않아요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如果你希望以从列表或搜索结果中隐藏特定语言的画廊,请从下面的列表中选择。注意:无论搜索条件为何,这些画廊都不会出现。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如果你希望從畫廊列表和搜尋中隱藏掉某些語言的畫廊,請從下面的清單中選取它們。請注意,無論你的搜尋查詢如何,相符於篩除規則的畫廊都不會出現。" + } + } + } + }, + "excluded_uploaders": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Excluded Uploaders" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgeschlossene Uploader" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "排除された投稿者" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제외된 업로드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏蔽的上传者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排除的上傳者" + } + } + } + }, + "excluded_uploaders_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You are currently using **%@ / %@** exclusion slots." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du belegst derzeit **%@ / %@** Ausschlussplätze." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現時点で **%@ / %@** の排除スロットが使用済みです。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "**%@ / %@** 개의 슬롯을 사용하고 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已使用 **%@ / %@** 个屏蔽槽位。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你正在使用 **%@ / %@** 排除欄位" + } + } + } + }, + "excluded_uploaders_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn du Galerien bestimmter Uploader aus der Galerieliste und den Suchergebnissen ausblenden möchtest, füge sie unten hinzu. Ein Benutzername pro Zeile. Galerien dieser Uploader erscheinen dann unabhängig von deiner Suchanfrage nie." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "特定の投稿者のギャラリーをリストと検索結果から隠したい場合、下に名前を記入してください。一行に一つのユーザー名で。注意:どんな検索クエリーを使ってもこの投稿者たちのギャラリーは表示されません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 목록 및 검색에서 특정 업로더의 갤러리를 숨기려면 아래에 해당 갤러리를 추가해주세요. 한 줄에 하나의 사용자 이름을 입력해주세요. 이러한 업로더의 갤러리는 검색 쿼리에 관계없이 나타나지 않아요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如果你希望在画廊中和搜索中隐藏某个上传者的话,请把他们的用户名填写在下方,每行一个。注意:无论搜索条件为何,这些上传者都不会出现。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如果你希望從畫廊列表和搜尋結果中隱藏某些上傳者的畫廊,請將它們新增到下方,每行輸入一個使用者名稱。請注意,無論你的搜尋結果如何,這些上傳者的畫廊都不會出現。" + } + } + } + }, + "favorite_categories": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Here you can choose and rename your favorite categories." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hier kannst du deine Favoriten-Kategorien auswählen und umbenennen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここではお気に入りカテゴリー名の変更ができます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여기서 좋아하는 장르들을 선택하고 이름을 바꿀 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在这里你可以重命名你的收藏夹。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在這裡你可以選擇並重新命名收藏匣" + } + } + } + }, + "favorites_section": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorites" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favoriten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入り" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收藏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收藏匣" + } + } + } + }, + "favorites_sort_order": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorites sort order" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sortierung der Favoriten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入りの並び替え" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관심 순서를 배열" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收藏排序方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收藏匣排序" + } + } + } + }, + "favorites_sort_order_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du kannst auch die Standardsortierung für Galerien auf deiner Favoritenseite festlegen. Favoriten, die vor der Überarbeitung im März 2016 hinzugefügt wurden, haben keinen Zeitstempel und werden unabhängig von dieser Einstellung nach dem Veröffentlichungszeitpunkt der Galerie sortiert." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入りページのデフォルト並び替え順序も変更可能です。注意:平成28年3月の改修前にお気に入りに追加した項目はタイムスタンプが含まれていないため、この設定を無視して代わりにギャラリーの投稿時間を使います。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "당신의 관심 페이지의 기본 정렬 방식을 선택할 수 있어요. 2016년 3월 개정 전에 추가된 즐겨찾기는 타임스탬프가 저장되지 않아 이 설정에 관계없이 갤러리가 게시된 시간으로 정렬되어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你也可以选择收藏夹中默认排序。注意:2016 年 3 月改版之前加入收藏夹的画廊并未保存收藏时间,会以画廊发布时间代替。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你還可以在收藏匣頁面上為畫廊選擇預設排列順序。 請注意,在 2016 年 3 月網站改版之前新增的畫廊並不儲存時間印記,並且無論使用何種設定,都將使用畫廊發佈時間作為排序參考。" + } + } + } + }, + "filtered_removal_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Filtered Removal Count" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzahl gefilterter Galerien" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィルター除去数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필터로 제거된 수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "筛选器移除数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示過濾結果計數器" + } + } + } + }, + "filtered_removal_count_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show the \\\"Your default filters removed XX galleries from this page\\\" readout?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Soll die Meldung „Deine Standardfilter haben XX Galerien von dieser Seite entfernt“ angezeigt werden?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「既定フィルターにより本ページから XX 個のギャラリーが除去されました」を表示しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "\\\"기본 필터가 이 페이지에서 갤러리 XX개를 제거했어요\\\" 문구를 표시할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "要显示“你的默认筛选器从本页移除了 XX 个画廊”提示吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示 \\\"Your default filters removed XX galleries from this page\\\" ?" + } + } + } + }, + "front_page_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Front Page Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Startseite" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フロントページ設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프론트 페이지 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扉页设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "首頁設定" + } + } + } + }, + "gallery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊" + } + } + } + }, + "gallery_category": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "What categories would you like to show by default on the front page and in searches?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Welche Kategorien sollen standardmäßig auf der Startseite und in Suchergebnissen angezeigt werden?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フロント・検索ページでどれらのカテゴリーのギャラリーを表示しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프론트와 검색 페이지에서 어떤 카테고리가 보여지도록 할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你希望在扉页和搜索页看到哪些类别?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設情況下你希望在首頁和搜尋結果中顯示哪些類別的結果?" + } + } + } + }, + "gallery_comments": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery Comments" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerie-Kommentare" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリーコメント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 댓글" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊评论" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊留言" + } + } + } + }, + "gallery_name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galeriename" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー名" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 이름" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊名稱" + } + } + } + }, + "gallery_name_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Viele Galerien haben sowohl einen englischen bzw. romanisierten Titel als auch einen Titel in japanischer Schrift. Welchen Namen möchtest du standardmäßig sehen?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "英語・ローマ字と日本語両方のタイトルを持つギャラリーはたくさんあります。どちらをデフォルトにしますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영어 제목과 일본어 제목 중 기본값으로 보일 언어를 선택해주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "很多画廊都同时拥有英文或者日文标题,你想默认显示哪一个?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "許多畫廊同時提供了英文/預設標題與日文標題,你想優先顯示哪種畫廊名稱?" + } + } + } + }, + "gallery_name_display": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery Name Display" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige des Galerienamens" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー名表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 이름 보이기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊名称显示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊顯示名稱" + } + } + } + }, + "gallery_page_thumbnail_labeling": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery Page Thumbnail Labeling" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beschriftung der Vorschaubilder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリーサムネイルのラベル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 페이지 썸네일 라벨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊页面缩略图标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊頁面縮圖標籤" + } + } + } + }, + "gallery_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery Tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerie-Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリータグ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 태그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊標籤" + } + } + } + }, + "general": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Allgemein" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일반" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一般設定" + } + } + } + }, + "horizontal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Horizontal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Horizontal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "幅" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "宽度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "水平尺寸(寬)" + } + } + } + }, + "host_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@-Einstellungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ 設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 設定" + } + } + } + }, + "image_load_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image Load Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Laden von Bildern" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像読み込み設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 로드 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图片加载设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片來源設定" + } + } + } + }, + "image_resolution": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image resolution" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildauflösung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像解像度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 해상도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图像分辨率" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片解析度" + } + } + } + }, + "image_resolution_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Normalerweise werden Bilder für die Online-Ansicht auf eine horizontale Auflösung von 1280 Pixeln neu berechnet. Alternativ kannst du eine der folgenden Auflösungen wählen. Um die Server nicht zu überlasten, sind Auflösungen über 1280x vorübergehend Spendern, Nutzern mit einem Hath-Perk und Nutzern mit einer UID unter 3.000.000 vorbehalten." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一般的に、オンライン閲覧の画像は 1280x までにリサンプリングされます。下のいずれかのリサンプリング解像度に変更できます。サーバー負荷軽減のため、1280x 以上の解像度は現時点で寄付者、Hath Perks 利用者または UID が 3,000,000 以下の者に限定されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일반적으로 이미지는 온라인 뷰어를 위해 1280 픽셀의 수평 해상도로 작아져요. 아래의 압축된 해상도 중 하나를 선택할 수 있어요. 서버 과부하를 막기 위해, 1280 이상의 해상도는 도네이션을 한 사람, hath perk를 가진 사람, 그리고 UID가 300만 이하인 사람들로 일시적으로 제한되어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通常情况,图像将重采样到 1280 像素宽度以用于在线浏览,你也可以选择以下重新采样分辨率。但是为了避免负载过高,高于 1280 像素将只供给于赞助者、特殊贡献者,以及 UID 小于 3,000,000 的用户。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一般情況下圖片會被縮放成 1280 px 水平解析度以供線上瀏覽,你也可以選擇下列解析度之一。為了避免破壞伺服器正常運作,高於 1280x 的解析度暫時僅提供下列使用者使用: 贊助者、具有任何 Hath Perk 的使用者以及 UID 低於 3,000,000 的使用者" + } + } + } + }, + "image_size": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image size" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildgröße" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像サイズ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 사이즈" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图像尺寸" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片尺寸" + } + } + } + }, + "image_size_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Seite verkleinert Bilder automatisch passend zu deiner Bildschirmbreite, du kannst die maximale Anzeigegröße aber auch manuell begrenzen. Wie bei der automatischen Skalierung wird das Bild dabei nicht neu berechnet, da die Größenänderung im Browser erfolgt. (0 = keine Begrenzung)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイト側は画像を自動的にスクリーンに適したサイズにスケールしますが、手動的にその画像の表示サイズ最大値を指定することも可能です。ブラウザが処理を実行するため、画像のリサンプリングは行われません。(ゼロは無制限を意味します)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이트가 사용자의 화면 너비에 맞게 이미지를 자동으로 축소시키지만, 수동으로 크기를 정할 수도 있어요. 크기 조정은 브라우저 측에서 수행되므로 이미지가 다시 샘플링되지 않아요. (0 = no limit)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "虽然图片会自动根据窗口缩小,你也可以手动设置最大大小,图片并没有重新采样。(0 为不限制)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雖然網站會自動縮小圖片以適應瀏覽裝置的螢幕寬度,但您也可以手動限製圖片的最大顯示尺寸。 像自動縮放一樣,這不會重新採樣圖像,因為調整大小是在瀏覽器完成的 (0 = 沒有限制)" + } + } + } + }, + "image_size_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image Size Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildgrößen-Einstellungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像サイズ設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 사이즈 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图像尺寸设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片尺寸設定" + } + } + } + }, + "import_custom_translations": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import custom translations" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Übersetzungen importieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム翻訳を取り込む" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 번역 가져오기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入自定义翻译" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匯入自訂標籤翻譯" + } + } + } + }, + "infite": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Infite" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbegrenzt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無制限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제한 없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無限" + } + } + } + }, + "laboratory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Laboratory" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Experimentelles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラボ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실험실" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实验室" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "實驗性功能" + } + } + } + }, + "list": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "List" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liste" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リスト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리스트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "列表" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "列表" + } + } + } + }, + "load_images_through_the_hath_network": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load images through the Hath network" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilder über das Hath-Netzwerk laden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Hath ネットワーク経由で画像を読み込む" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Hath 네트워크를 통하여 이미지 로드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通过 Hath 网络加载图像" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "透過 Hath Network 載入圖片" + } + } + } + }, + "logout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Logout" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausloggen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ログアウト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그아웃" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出登录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登出" + } + } + } + }, + "logout_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Are you sure to logout?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bist du sicher das du dich ausloggen möchtest?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本当にログアウトしてもよろしいですか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그아웃 하시겠어요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定要退出登录吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確定要登出嗎?" + } + } + } + }, + "maximum_number_of_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maximum number of tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maximale Anzahl an Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグ数上限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 갯수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签数量上限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤最大顯示數量" + } + } + } + }, + "multi_page_viewer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Multi-Page Viewer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Multi-Page-Viewer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マルチページビューア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "멀티 페이지 뷰어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "多页查看器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "多頁瀏覽" + } + } + } + }, + "navigation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Navigation" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Navigation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ナビゲーション" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내비게이션" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导航" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "導覽" + } + } + } + }, + "network": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerk" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네트워크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網路" + } + } + } + }, + "network_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es wird immer nur eine Galerie gleichzeitig heruntergeladen. Mit dieser Einstellung steuerst du, wie viele Galerieseiten parallel geladen werden, ob Mobilfunk erlaubt ist und dass Dateien im Downloads-Ordner der App gespeichert werden." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一度にダウンロードされるギャラリーは 1 件だけです。この設定では、1 つのギャラリー内で同時にダウンロードするページ数、モバイル通信の許可または禁止、そしてファイルをアプリの Downloads フォルダに保存する動作を管理します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한 번에 하나의 갤러리만 다운로드됩니다. 이 설정으로 한 갤러리 안에서 동시에 다운로드할 페이지 수, 셀룰러 다운로드 허용 여부, 그리고 파일을 앱의 Downloads 폴더에 저장하는 방식을 제어합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每次只会下载一个画廊。这个设置用于控制单个画廊内页面的并行下载数量、是否允许蜂窝网络下载,以及文件在应用 Downloads 文件夹中的存储方式。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許行動網路下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。" + } + } + } + }, + "optional_UI_elements": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Optional UI Elements" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Optionale UI-Elemente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "UI の表示制御" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택적 UI 요소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可选的 UI 组件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可選用的 UI 元件" + } + } + } + }, + "optional_UI_elements_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Some historic UI elements are now disabled by default. You can enable those here." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einige ältere UI-Elemente sind inzwischen standardmäßig deaktiviert. Hier kannst du sie wieder aktivieren." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一部の従来の UI はデフォルトで無効になっています。ここで有効にすることができます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일부 예전 UI 요소는 이제 기본적으로 꺼져 있어요. 여기서 다시 켤 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一些旧版 UI 组件现已默认禁用。您可以在此启用这些组件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一些舊版 UI 元件現已預設停用。您可以在此啟用這些元件。" + } + } + } + }, + "original_images": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \\\"Auto\\\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sollen Originalbilder statt der neu berechneten Versionen verwendet werden? Neu berechnete Bilder werden weiterhin verwendet, wenn du oben eine andere horizontale Auflösung als „Automatisch“ wählst und das betreffende Bild breiter ist, oder wenn das Originalbild größer als 10 MiB ist (bzw. 4 MiB bei Galerien, die älter als ein Jahr sind)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナル画像を使いますか?リサンプリングされた画像は、上記の解像度で「自動」以外を選択し、該当する画像の方が幅が広い場合、またはオリジナル画像が 10 MiB(一年以上前のギャラリーの場合は 4 MiB)より大きい場合に使用されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 샘플링된 버전 대신 원본 이미지를 사용할까요? 위에서 가로 해상도를 \\\"자동\\\" 이외로 선택했고 해당 이미지가 그보다 넓은 경우나, 원본 이미지가 10 MiB(1년 넘은 갤러리는 4 MiB)보다 큰 경우에는 다시 샘플링된 이미지가 계속 사용되어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "是否使用原始图像而非重新采样的版本?如果您在上方选择的水平分辨率不是“自动”,并且所查看的图像更宽,或者原始图像大于 10 MiB(对于超过一年的图库,则为 4 MiB),那么仍将使用重新采样的图像。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要使用原始圖片而非重新取樣的版本嗎? 若您在上方選擇「自動」以外的水準解析度且圖片較寬,或原始圖片大於 10 MiB(一年以上的圖庫則為 4 MiB),系統仍會使用重新取樣的圖片。" + } + } + } + }, + "password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスワード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비밀번호" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "密码" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + } + } + }, + "profile_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "档案设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定檔設定" + } + } + } + }, + "ratings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ratings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bewertungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "評価" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "별점" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "评分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "評分" + } + } + } + }, + "ratings_color": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ratings color" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbe der Bewertungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "評価の色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "별점 색깔" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "评分颜色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "評分顏色" + } + } + } + }, + "ratings_color_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardmäßig erscheinen von dir bewertete Galerien mit roten Sternen bei Bewertungen bis 2 Sternen, mit grünen zwischen 2,5 und 4 Sternen und mit blauen bei 4,5 oder 5 Sternen. Du kannst das anpassen, indem du unten deine gewünschte Farbkombination eingibst. Jeder Buchstabe steht für einen Stern. Das Standard-RRGGB bedeutet R(ot) für den ersten und zweiten Stern, G(rün) für den dritten und vierten und B(lau) für den fünften. Mit Y bekommst du gelbe Sterne. Jede fünfstellige Kombination aus R/G/B/Y funktioniert." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトでは、評価済みのギャラリーは 2 以下の評価に赤い星を使う、2.5 ~ 4 には緑、4.5 以上には青。下に色の組み合わせを入れることでこのルールをカスタマイズできます。一つの星の色は一つの文字で指定します。デフォルトの「RRGGB」は「一番目と二番目の星は赤(Red)、三番と四番は緑(Green)、五番は青(Blue)」を意味します。黄色(Yellow)も使用可能です。R・G・B・Yで組み合わせた五文字はどれも機能します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본적으로 등급을 매긴 갤러리는 별 2개 이하의 등급에 대해 빨간색, 2.5~4개의 등급에 대해 녹색, 4.5~5개의 등급에 대해 파란색 별로 표시되어요. 아래에 원하는 색상 조합을 입력하여 사용자 정의할 수 있어요. 각 문자는 별 하나를 표현해요. 기본 RRGGB는 첫 번째와 두 번째 별의 경우 R(ed), 세 번째와 네 번째 별의 경우 G(reen), 다섯 번째 별의 경우 B(lue)를 의미해요. 일반 별에 (Y)ellow를 사용할 수도 있어요. 모든 5글자의 R/G/B/Y 콤보가 작동해요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认设置下,你评为 2 星及以下的画廊显示为红星,2.5 ~ 4 星显示为绿星,4.5 ~ 5 星显示为蓝星。你可以将其设定为其它颜色组合。每一个字幕代表一颗星, 默认的 RRGGB 表示第一第二颗星显示为红色 R(ed),第三第四颗星显示是绿色 G(reen),第五颗星显示为蓝色 B(lue)。你也可以使用黄色 (Y)ellow,R/G/B/Y 任何五个组合都是有效的。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設情況下,你評分的畫廊將 2 星及以下的評分顯示為紅色星,2.5 ~ 4 顆星的評分為綠色,4.5 ~ 5 顆星的評分為藍色。 透過在下面輸入顏色組合你可以自訂想顯示的顏色。 每個字母各代表一顆星(1~5),預設的 RRGGB 表示第一顆和第二顆星的 R(ed),第三顆和第四顆的 G(reen),第五顆的 B(lue)。 你也可以將 (Y)ellow 用於普通星星。 任何五個字母的 R/G/B/Y 組合都有效" + } + } + } + }, + "ratings_color_prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "RRGGB" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "RRGGB" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "RRGGB" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "RRGGB" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "RRGGB" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "RRGGB" + } + } + } + }, + "redirects_links_to_the_selected_host": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Redirects links to the selected host" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Links zum ausgewählten Host umleiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リンクを選択されたホストへリダイレクト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택한 서버로 이동하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重定向链接到选定的站点" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將連結重新導向至選擇的網站" + } + } + } + }, + "remove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "remove_custom_translations": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove custom translations" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Übersetzungen entfernen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム翻訳を削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 번역 삭제" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除自定义翻译" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除自訂標籤翻譯" + } + } + } + }, + "remove_custom_translations_confirmation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Are you sure to remove your custom translations?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Möchtest du deine benutzerdefinierten Übersetzungen wirklich entfernen?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本当にカスタム翻訳を削除してもよろしいですか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 번역을 삭제하시겠어요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定要移除自定义翻译吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "是否確定要刪除所有自訂翻譯?" + } + } + } + }, + "rename": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Umbenennen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前を変更" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름 변경" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名" + } + } + } + }, + "result_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Result count" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzahl Ergebnisse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "結果数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "결과 수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "结果数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "數量上限" + } + } + } + }, + "result_count_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "How many results would you like per page for the index/search page and torrent search pages?\\n(Hath Perk: Paging Enlargement Required)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wie viele Ergebnisse pro Seite möchtest du auf Index-, Such- und Torrent-Suchseiten sehen?\\n(Hath-Perk „Paging Enlargement“ erforderlich)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インデックス・トレントの検索ページで、各ページにどれくらいの結果数がお望みですか?\\n(「Hath Perk:ページング拡張」が必要)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인덱스 / 검색 / 토렌트 검색 페이지에 대해 페이지당 몇 개의 결과를 원하시나요?\\n(Hath Perk: 페이징 확장 필요)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索页面每页显示多少条数据?\\n(需要“Hath Perk:页面扩大”)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你希望每頁顯示幾個搜尋結果?\\n(該功能需要有 Hath Perk: Paging Enlargement)" + } + } + } + }, + "retry_failed_pages_automatically": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry failed pages automatically" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagene Seiten automatisch erneut versuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗したページを自動で再試行" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패한 페이지 자동 재시도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动重试失败页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動重試失敗頁面" + } + } + } + }, + "scale_factor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scale factor" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Skalierungsfaktor" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スケール係数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크기 비율" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩放比例" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮放比例" + } + } + } + }, + "search_result_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search Result Count" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzahl der Suchergebnisse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索結果数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색 결과 수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索结果数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋結果數量上限" + } + } + } + }, + "security": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Security" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sicherheit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セキュリティ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "개인 정보 보호" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安全" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安全" + } + } + } + }, + "selected_profile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Selected profile" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgewähltes Profil" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択されたプロファイル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택한 프로필" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前选定档案" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇設定檔" + } + } + } + }, + "set_as_default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set as default" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Als Standard festlegen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトに設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본으로 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设为默认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設為預設" + } + } + } + }, + "setting_state_route.about": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Über diese App" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリについて" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정보" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关于" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關於" + } + } + } + }, + "setting_state_route.account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "账户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "帳號" + } + } + } + }, + "setting_state_route.appearance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oberfläche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "外観" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "외관" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "外观" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "外觀" + } + } + } + }, + "setting_state_route.download": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載" + } + } + } + }, + "setting_state_route.general": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Allgemein" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일반" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + } + } + }, + "setting_state_route.laboratory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Laboratory" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Experimentelles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラボ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실험실" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实验室" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "實驗性功能" + } + } + } + }, + "setting_state_route.reading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Am Lesen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阅读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "閱讀" + } + } + } + }, + "show_filtered_removal_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show filtered removal count" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzahl gefilterter Galerien anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィルター除去数を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필터로 제거된 수 표시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示筛选器移除数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "是否顯示過濾結果計數器" + } + } + } + }, + "show_label_below_gallery_thumbnails": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show label below gallery thumbnails" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beschriftung unter Galerie-Vorschaubildern anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリーサムネイルの下にラベルを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 썸네일 아래에 라벨 표시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在画廊缩略图下方显示标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在畫廊縮圖下方顯示標籤" + } + } + } + }, + "show_search_range_indicator": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search Range Indicator" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchbereichsanzeige" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索範囲インジケーター" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색 범위 표시기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索范围指示器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋範圍指示器" + } + } + } + }, + "show_search_range_indicator_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show search range indicator" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchbereichsanzeige einblenden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索範囲インジケーターを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색 범위 표시기 표시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示搜索范围指示器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示搜尋範圍指示器" + } + } + } + }, + "show_thumbnail_pane": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show thumbnail pane" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschaubild-Leiste anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サムネイルパネルを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "썸네일 창 표시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示缩略图侧栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示縮圖窗格" + } + } + } + }, + "shows_images_in_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows images in tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilder in Tags anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの画像を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그에 이미지 보여주기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示标签中的图像" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在標籤顯示圖片" + } + } + } + }, + "shows_new_dawn_greeting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows new dawn greeting" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer-Tag-Meldung anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "夜明けの挨拶を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새벽 인사 구독하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示黎明问候" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示黎明問候" + } + } + } + }, + "shows_tags_in_list": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows tags in list" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tags als Liste anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リストでタグを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리스트에서 태그 보여주기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在列表中显示标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在列表中顯示標籤" + } + } + } + }, + "shows_tags_search_suggestion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows tags search suggestion" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tag-Vorschläge bei der Suche anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの検索提案を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 검색 제안 보여주기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示标签搜索建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋時顯示標籤建議" + } + } + } + }, + "special_thanks": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Special thanks" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Besonderer Dank" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "特別な感謝" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "특별히 감사드리는 분들" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "特别致谢" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "特別銘謝" + } + } + } + }, + "tag_filtering_threshold": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tag Filtering Threshold" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schwellenwert für Tag-Filterung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグフィルタリングしきい値" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 필터링 임계값" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签筛选阈值" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "過濾標籤閾值" + } + } + } + }, + "tag_filtering_threshold_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du kannst Tags weich filtern, indem du sie mit negativem Gewicht zu „Meine Tags“ hinzufügst. Ergeben die Tags einer Galerie zusammen ein Gewicht unter diesem Wert, wird sie ausgeblendet. Der Schwellenwert kann zwischen 0 und -9999 liegen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "負の重み付きでマイタグに追加することでタグをソフトフィルタリングすることができます。もしあるギャラリーが持つタグの重み総和がこのしきい値より低ければ、そのギャラリーはフィルタリングされます。このしきい値はゼロから -9999 まで設定できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마이너스 가중치로 My Tags에 추가하여 태그를 소프트 필터할 수 있어요. 갤러리에 이 값 이하의 가중치를 추가하는 태그가 있으면 보기에서 필터링되어요. 이 임계값은 0과 -9999 사이에서 설정할 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你可以通过将标签加入“我的标签”并设置一个负权重来软过滤它们。如果一个作品所有的标签权重之和低于设定值,此作品将从视图中被过滤。这个值可以设定为 0 ~ -9999。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你可以透過將標籤新增到具有負數權重的“我的標籤”清單中來過濾標籤。 如果畫廊的標籤加起來的權重低於此值,則會被從列表中過濾掉,此閾值可以設定在 0 ~ -9999 之間" + } + } + } + }, + "tag_watching_threshold": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tag Watching Threshold" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schwellenwert für Tag-Beobachtung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグ購読しきい値" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 보여주기 임계값" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签订阅阈值" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關注標籤閾值" + } + } + } + }, + "tag_watching_threshold_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kürzlich hochgeladene Galerien erscheinen unter „Meine Tags“, wenn sie mindestens einen beobachteten Tag mit positivem Gewicht haben und die Gewichte ihrer beobachteten Tags zusammen diesen Wert erreichen oder überschreiten. Der Schwellenwert kann zwischen 0 und 9999 liegen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "もしあるギャラリーは最近投稿されたもので、少なくても一つの正の重みの購読タグを持っていて、購読タグの重み総和がこのしきい値と同じまたはより高ければ、そのギャラリーは購読画面で表示されます。このしきい値はゼロから 9999 まで設定できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근에 업로드된 갤러리는 최소 1개의 Watched 태그가 있고 Watched 태그의 가중치의 합이 이 값 이상이 될 경우 Watched 화면에 포함되어요. 이 임계값은 0과 9999 사이에서 설정할 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你可以通过将标签加入“我的标签”并设置一个正权重来关注它们。如果一个最近上传的作品所有标签的权重之和高于设定值,则它将会被包含在“关注”里。这个值可以设定为 0 ~ 9999。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如果最近上傳的畫廊中至少有一個具有正權重的你正在關注/追蹤的標籤,並且這些標籤所具有的權重高於此設定中的數值,那這個畫廊將會出現在「追蹤標籤」頁面中,此閾值可以設定在 0 ~ -9999 之間" + } + } + } + }, + "tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤" + } + } + } + }, + "tags_management": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage tags subscription" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Meine Tags bearbeiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの購読を管理" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 구독 관리" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理标签订阅" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "管理訂閱標籤" + } + } + } + }, + "tags_sort_order": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tags sort order" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sortierung der Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの並び替え" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 순서를 배열" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签排序方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤顯示順序" + } + } + } + }, + "theme": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Theme" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erscheinungsbild" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テーマ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "테마" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "主题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "主題" + } + } + } + }, + "thumbnail_configuration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can set a default thumbnail configuration for all galleries you visit." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du kannst eine Standardkonfiguration der Vorschaubilder für alle Galerien festlegen, die du besuchst." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのギャラリーに適応するデフォルトのサムネイル構成を設定できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 방문한 갤러리에 대하여 기본 썸네일을 설정할 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你可以设定一个对所有画廊生效的默认缩略图配置。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你可以為存取的所有畫廊設定預設的縮圖配置。" + } + } + } + }, + "thumbnail_load_timing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thumbnail load timing" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladezeitpunkt der Vorschaubilder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サムネイル読み込みタイミング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "썸네일 로드 시간" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩略图加载时机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮圖載入時機設定" + } + } + } + }, + "thumbnail_load_timing_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "How would you like the mouse-over thumbnails on the front page to load when using List Mode?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wann sollen die Mouseover-Vorschaubilder auf der Startseite im Listenmodus geladen werden?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リストでは、どんなタイミングでホームページのマウスオーバーサムネイルを読み込みますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "목록 모드를 사용할 때 앞 페이지의 마우스 오버 미리 보기를 어떻게 로드할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你希望列表中的鼠标悬停缩略图何时加载?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用列表模式時,你希望如何載入以滑鼠位置顯示的縮圖?" + } + } + } + }, + "thumbnail_row_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rows" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeilen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "줄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "行数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "行數" + } + } + } + }, + "thumbnail_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thumbnail Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschaubilder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サムネイル設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "썸네일 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩略图设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮圖設定" + } + } + } + }, + "thumbnail_size": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Size" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Größe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイズ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이즈" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尺寸" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尺寸" + } + } + } + }, + "tint_color": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tint color" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テーマの色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "액센트 색상" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "主题色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "強調色" + } + } + } + }, + "title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載" + } + } + } + }, + "translates_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Translates tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tags übersetzen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグを訳す" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 번역하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "翻译标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤翻譯" + } + } + } + }, + "translation_contributors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Translation contributors" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mitwirkende an der Übersetzung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "翻訳貢献者" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "번역 기여자" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "翻译贡献者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "翻譯貢獻者" + } + } + } + }, + "use_multi_page_viewer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Multi-Page Viewer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Multi-Page-Viewer verwenden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マルチページビューアを使う" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다중 페이지 뷰어 적용" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用多页查看器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用多頁瀏覽" + } + } + } + }, + "use_original_images": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use original images" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Originalbilder verwenden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナル画像を使う" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본 뷰어 적용" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示原图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用原始圖片(原解析度)" + } + } + } + }, + "username": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Username" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzername" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ユーザー名" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Username" + } + } + } + }, + "version": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バージョン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "버전" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "版本" + } + } + } + }, + "vertical": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vertical" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vertikal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "高さ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "垂直尺寸(長)" + } + } + } + }, + "viewport_override": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Viewport Override" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Viewport-Überschreibung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示領域オーバーライド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "뷰포트 조정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "覆写可视区域" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗覆蓋" + } + } + } + }, + "virtual_width": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Virtual width" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Virtuelle Breite" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "仮想幅" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가상 너비" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "虚拟宽度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "虛擬寬度" + } + } + } + }, + "virtual_width_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hiermit kannst du die virtuelle Breite der Seite auf Mobilgeräten überschreiben. Normalerweise bestimmt dein Gerät sie automatisch anhand der DPI. Sinnvolle Werte bei 100%% Vorschaubild-Skalierung liegen zwischen 640 und 1400." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モバイルデバイスの仮想幅をオーバーライドすることができます。一般的にはデバイスの DPI に基づいて自動的に決定されます。例えばサムネイルスケール係数が 100%% の場合、640 ~ 1400 の幅が合理的です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모바일 장치의 사이트 가상 너비를 설정할 수 있어요. 일반적으로 DPI에 따라 장치에 의해 자동으로 결정되어요. 100%% 썸네일 스케일의 추천 값은 640에서 1400 사이에요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "允许你覆写移动设备的可视区域,默认是根据 DPI 自动计算的,100%% 缩略图比例下的合理值在 640 到 1400 之间。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "允許你覆蓋行動裝置網站的虛擬寬度。 這通常由你的裝置根據其 DPI 自動確定。 100%% 縮圖比例的合理值介於 640 和 1400 之間。" + } + } + } + }, + "website": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Website" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Website" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウェブサイト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "웹사이트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网站" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "官方網站" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 2182465d2..f784d0d92 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -167,19 +167,19 @@ extension SettingReducer.RootScreen { var value: String { switch self { case .account: - return L10n.Localizable.SettingStateRoute.account + return String(localized: .settingStateRouteAccount) case .general: - return L10n.Localizable.SettingStateRoute.general + return String(localized: .settingStateRouteGeneral) case .appearance: - return L10n.Localizable.SettingStateRoute.appearance + return String(localized: .settingStateRouteAppearance) case .download: - return L10n.Localizable.SettingStateRoute.download + return String(localized: .settingStateRouteDownload) case .reading: - return L10n.Localizable.SettingStateRoute.reading + return String(localized: .settingStateRouteReading) case .laboratory: - return L10n.Localizable.SettingStateRoute.laboratory + return String(localized: .settingStateRouteLaboratory) case .about: - return L10n.Localizable.SettingStateRoute.about + return String(localized: .settingStateRouteAbout) } } var symbol: SFSymbol { From d52660bc1dd57c9a01e4935cc03acd4bc42f53c2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 02:28:11 +0800 Subject: [PATCH 468/614] Move AppModels strings to catalog Emit 455 localizable keys into AppModels/Resources/Localizable.xcstrings, delete them from the 6 lproj files, and rewrite 455 accessors across 18 files. Covers the enum-value tables (browsing_country x252, language x66, category, EhSetting sub-enums, tag_namespace, greeting, etc.). Symbols from Xcode's authoritative map (artist_CG->categoryArtistCg, virgin_islands_US->...Us). Shared common.* duration keys and Constant gallery_unavailable stay on L10n via Resources. Full test suite green (308). --- AppPackage/Package.swift | 1 + .../Download/DownloadFolderFilter.swift | 2 +- .../Sources/AppModels/Gallery/Category.swift | 22 +- .../AppModels/Gallery/GalleryArchive.swift | 4 +- .../AppModels/Gallery/GalleryDetail.swift | 6 +- .../Sources/AppModels/Gallery/Language.swift | 132 +- .../AppModels/Persistent/AppIconType.swift | 10 +- .../Sources/AppModels/Persistent/Filter.swift | 6 +- .../AppModels/Persistent/Greeting.swift | 8 +- .../AppModels/Persistent/Setting.swift | 20 +- .../Sources/AppModels/Persistent/User.swift | 4 +- .../AppModels/Resources/Localizable.xcstrings | 18661 ++++++++++++++++ .../AppModels/Support/AppActivityLog.swift | 12 +- .../Sources/AppModels/Support/AppError.swift | 52 +- .../AppModels/Support/BrowsingCountry.swift | 504 +- .../AppModels/Support/EhSetting+Enums.swift | 26 +- .../Support/EhSetting+Extensions.swift | 16 +- .../Sources/AppModels/Support/EhSetting.swift | 54 +- .../AppModels/Support/ToplistsType.swift | 8 +- .../Sources/AppModels/Tags/TagNamespace.swift | 24 +- .../Resources/de.lproj/Localizable.strings | 455 - .../Resources/en.lproj/Localizable.strings | 455 - .../Resources/ja.lproj/Localizable.strings | 455 - .../Resources/ko.lproj/Localizable.strings | 455 - .../zh-Hans.lproj/Localizable.strings | 455 - .../zh-Hant.lproj/Localizable.strings | 455 - AppPackage/Sources/Resources/Strings.swift | 992 - 27 files changed, 19117 insertions(+), 4177 deletions(-) create mode 100644 AppPackage/Sources/AppModels/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index de95007cb..8696ef484 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -310,6 +310,7 @@ let targets: [PackageDescription.Target] = [ .module(.osLogExt), .targetDependency(.casePaths) ], + resources: [.process(.resources)], swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), diff --git a/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift b/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift index 2d76d6d61..216d99865 100644 --- a/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift @@ -7,7 +7,7 @@ public enum DownloadFolderFilter: Equatable, Sendable { public var title: String { switch self { case .all: - return L10n.Localizable.DownloadFolderFilter.all + return String(localized: .downloadFolderFilterAll) case .folder(let name): return name } diff --git a/AppPackage/Sources/AppModels/Gallery/Category.swift b/AppPackage/Sources/AppModels/Gallery/Category.swift index ff555f947..0901b45c0 100644 --- a/AppPackage/Sources/AppModels/Gallery/Category.swift +++ b/AppPackage/Sources/AppModels/Gallery/Category.swift @@ -47,17 +47,17 @@ extension Category { } public var value: String { switch self { - case .doujinshi: return L10n.Localizable.Category.doujinshi - case .manga: return L10n.Localizable.Category.manga - case .artistCG: return L10n.Localizable.Category.artistCG - case .gameCG: return L10n.Localizable.Category.gameCG - case .western: return L10n.Localizable.Category.western - case .nonH: return L10n.Localizable.Category.nonH - case .imageSet: return L10n.Localizable.Category.imageSet - case .cosplay: return L10n.Localizable.Category.cosplay - case .asianPorn: return L10n.Localizable.Category.asianPorn - case .misc: return L10n.Localizable.Category.misc - case .private: return L10n.Localizable.Category.private + case .doujinshi: return String(localized: .categoryDoujinshi) + case .manga: return String(localized: .categoryManga) + case .artistCG: return String(localized: .categoryArtistCg) + case .gameCG: return String(localized: .categoryGameCg) + case .western: return String(localized: .categoryWestern) + case .nonH: return String(localized: .categoryNonH) + case .imageSet: return String(localized: .categoryImageSet) + case .cosplay: return String(localized: .categoryCosplay) + case .asianPorn: return String(localized: .categoryAsianPorn) + case .misc: return String(localized: .categoryMisc) + case .private: return String(localized: .categoryPrivate) } } } diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift b/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift index a37555d49..457a76be0 100644 --- a/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift +++ b/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift @@ -26,7 +26,7 @@ public struct GalleryArchive: Codable, Equatable, Sendable { public var price: String { switch gpPrice { case "Free": - return L10n.Localizable.HathArchive.free + return String(localized: .hathArchiveFree) default: return gpPrice } @@ -51,7 +51,7 @@ extension ArchiveResolution { case .x780, .x980, .x1280, .x1600, .x2400: return rawValue case .original: - return L10n.Localizable.ArchiveResolution.original + return String(localized: .archiveResolutionOriginal) } } public var parameter: String { diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift b/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift index 2f645f3bb..c427aba57 100644 --- a/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift +++ b/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift @@ -128,16 +128,16 @@ extension GalleryVisibility { public var value: String { switch self { case .yes: - return L10n.Localizable.GalleryVisibility.yes + return String(localized: .galleryVisibilityYes) case .no(let reason): let localizedReason: String switch reason { case "Expunged": - localizedReason = L10n.Localizable.GalleryVisibility.expunged + localizedReason = String(localized: .galleryVisibilityExpunged) default: localizedReason = reason } - return L10n.Localizable.GalleryVisibility.no(localizedReason) + return String(localized: .galleryVisibilityNo(localizedReason)) } } } diff --git a/AppPackage/Sources/AppModels/Gallery/Language.swift b/AppPackage/Sources/AppModels/Gallery/Language.swift index 1788f1365..13c9e26a1 100644 --- a/AppPackage/Sources/AppModels/Gallery/Language.swift +++ b/AppPackage/Sources/AppModels/Gallery/Language.swift @@ -32,72 +32,72 @@ extension Language { } public var value: String { switch self { - case .invalid: return L10n.Localizable.Language.invalid - case .other: return L10n.Localizable.Language.other - case .afrikaans: return L10n.Localizable.Language.afrikaans - case .albanian: return L10n.Localizable.Language.albanian - case .arabic: return L10n.Localizable.Language.arabic - case .bengali: return L10n.Localizable.Language.bengali - case .bosnian: return L10n.Localizable.Language.bosnian - case .bulgarian: return L10n.Localizable.Language.bulgarian - case .burmese: return L10n.Localizable.Language.burmese - case .catalan: return L10n.Localizable.Language.catalan - case .cebuano: return L10n.Localizable.Language.cebuano - case .chinese: return L10n.Localizable.Language.chinese - case .croatian: return L10n.Localizable.Language.croatian - case .czech: return L10n.Localizable.Language.czech - case .danish: return L10n.Localizable.Language.danish - case .dutch: return L10n.Localizable.Language.dutch - case .english: return L10n.Localizable.Language.english - case .esperanto: return L10n.Localizable.Language.esperanto - case .estonian: return L10n.Localizable.Language.estonian - case .finnish: return L10n.Localizable.Language.finnish - case .french: return L10n.Localizable.Language.french - case .georgian: return L10n.Localizable.Language.georgian - case .german: return L10n.Localizable.Language.german - case .greek: return L10n.Localizable.Language.greek - case .hebrew: return L10n.Localizable.Language.hebrew - case .hindi: return L10n.Localizable.Language.hindi - case .hmong: return L10n.Localizable.Language.hmong - case .hungarian: return L10n.Localizable.Language.hungarian - case .indonesian: return L10n.Localizable.Language.indonesian - case .italian: return L10n.Localizable.Language.italian - case .japanese: return L10n.Localizable.Language.japanese - case .kazakh: return L10n.Localizable.Language.kazakh - case .khmer: return L10n.Localizable.Language.khmer - case .korean: return L10n.Localizable.Language.korean - case .kurdish: return L10n.Localizable.Language.kurdish - case .lao: return L10n.Localizable.Language.lao - case .latin: return L10n.Localizable.Language.latin - case .mongolian: return L10n.Localizable.Language.mongolian - case .ndebele: return L10n.Localizable.Language.ndebele - case .nepali: return L10n.Localizable.Language.nepali - case .norwegian: return L10n.Localizable.Language.norwegian - case .oromo: return L10n.Localizable.Language.oromo - case .pashto: return L10n.Localizable.Language.pashto - case .persian: return L10n.Localizable.Language.persian - case .polish: return L10n.Localizable.Language.polish - case .portuguese: return L10n.Localizable.Language.portuguese - case .punjabi: return L10n.Localizable.Language.punjabi - case .romanian: return L10n.Localizable.Language.romanian - case .russian: return L10n.Localizable.Language.russian - case .sango: return L10n.Localizable.Language.sango - case .serbian: return L10n.Localizable.Language.serbian - case .shona: return L10n.Localizable.Language.shona - case .slovak: return L10n.Localizable.Language.slovak - case .slovenian: return L10n.Localizable.Language.slovenian - case .somali: return L10n.Localizable.Language.somali - case .spanish: return L10n.Localizable.Language.spanish - case .swahili: return L10n.Localizable.Language.swahili - case .swedish: return L10n.Localizable.Language.swedish - case .tagalog: return L10n.Localizable.Language.tagalog - case .thai: return L10n.Localizable.Language.thai - case .tigrinya: return L10n.Localizable.Language.tigrinya - case .turkish: return L10n.Localizable.Language.turkish - case .ukrainian: return L10n.Localizable.Language.ukrainian - case .urdu: return L10n.Localizable.Language.urdu - case .vietnamese: return L10n.Localizable.Language.vietnamese - case .zulu: return L10n.Localizable.Language.zulu + case .invalid: return String(localized: .languageInvalid) + case .other: return String(localized: .languageOther) + case .afrikaans: return String(localized: .languageAfrikaans) + case .albanian: return String(localized: .languageAlbanian) + case .arabic: return String(localized: .languageArabic) + case .bengali: return String(localized: .languageBengali) + case .bosnian: return String(localized: .languageBosnian) + case .bulgarian: return String(localized: .languageBulgarian) + case .burmese: return String(localized: .languageBurmese) + case .catalan: return String(localized: .languageCatalan) + case .cebuano: return String(localized: .languageCebuano) + case .chinese: return String(localized: .languageChinese) + case .croatian: return String(localized: .languageCroatian) + case .czech: return String(localized: .languageCzech) + case .danish: return String(localized: .languageDanish) + case .dutch: return String(localized: .languageDutch) + case .english: return String(localized: .languageEnglish) + case .esperanto: return String(localized: .languageEsperanto) + case .estonian: return String(localized: .languageEstonian) + case .finnish: return String(localized: .languageFinnish) + case .french: return String(localized: .languageFrench) + case .georgian: return String(localized: .languageGeorgian) + case .german: return String(localized: .languageGerman) + case .greek: return String(localized: .languageGreek) + case .hebrew: return String(localized: .languageHebrew) + case .hindi: return String(localized: .languageHindi) + case .hmong: return String(localized: .languageHmong) + case .hungarian: return String(localized: .languageHungarian) + case .indonesian: return String(localized: .languageIndonesian) + case .italian: return String(localized: .languageItalian) + case .japanese: return String(localized: .languageJapanese) + case .kazakh: return String(localized: .languageKazakh) + case .khmer: return String(localized: .languageKhmer) + case .korean: return String(localized: .languageKorean) + case .kurdish: return String(localized: .languageKurdish) + case .lao: return String(localized: .languageLao) + case .latin: return String(localized: .languageLatin) + case .mongolian: return String(localized: .languageMongolian) + case .ndebele: return String(localized: .languageNdebele) + case .nepali: return String(localized: .languageNepali) + case .norwegian: return String(localized: .languageNorwegian) + case .oromo: return String(localized: .languageOromo) + case .pashto: return String(localized: .languagePashto) + case .persian: return String(localized: .languagePersian) + case .polish: return String(localized: .languagePolish) + case .portuguese: return String(localized: .languagePortuguese) + case .punjabi: return String(localized: .languagePunjabi) + case .romanian: return String(localized: .languageRomanian) + case .russian: return String(localized: .languageRussian) + case .sango: return String(localized: .languageSango) + case .serbian: return String(localized: .languageSerbian) + case .shona: return String(localized: .languageShona) + case .slovak: return String(localized: .languageSlovak) + case .slovenian: return String(localized: .languageSlovenian) + case .somali: return String(localized: .languageSomali) + case .spanish: return String(localized: .languageSpanish) + case .swahili: return String(localized: .languageSwahili) + case .swedish: return String(localized: .languageSwedish) + case .tagalog: return String(localized: .languageTagalog) + case .thai: return String(localized: .languageThai) + case .tigrinya: return String(localized: .languageTigrinya) + case .turkish: return String(localized: .languageTurkish) + case .ukrainian: return String(localized: .languageUkrainian) + case .urdu: return String(localized: .languageUrdu) + case .vietnamese: return String(localized: .languageVietnamese) + case .zulu: return String(localized: .languageZulu) } } } diff --git a/AppPackage/Sources/AppModels/Persistent/AppIconType.swift b/AppPackage/Sources/AppModels/Persistent/AppIconType.swift index 4a366c33b..16a896b88 100644 --- a/AppPackage/Sources/AppModels/Persistent/AppIconType.swift +++ b/AppPackage/Sources/AppModels/Persistent/AppIconType.swift @@ -14,19 +14,19 @@ extension AppIconType { public var name: String { switch self { case .default: - return L10n.Localizable.AppIconType.default + return String(localized: .appIconTypeDefault) case .ukiyoe: - return L10n.Localizable.AppIconType.ukiyoe + return String(localized: .appIconTypeUkiyoe) case .developer: - return L10n.Localizable.AppIconType.developer + return String(localized: .appIconTypeDeveloper) case .standWithUkraine2022: - return L10n.Localizable.AppIconType.standWithUkraine2022 + return String(localized: .appIconTypeStandWithUkraine2022) case .notMyPresidnet: - return L10n.Localizable.AppIconType.notMyPresident + return String(localized: .appIconTypeNotMyPresident) } } diff --git a/AppPackage/Sources/AppModels/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift index b5788eb81..dd158056d 100644 --- a/AppPackage/Sources/AppModels/Persistent/Filter.swift +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -162,11 +162,11 @@ public extension FilterRange { var value: String { switch self { case .search: - return L10n.Localizable.FilterRange.search + return String(localized: .filterRangeSearch) case .global: - return L10n.Localizable.FilterRange.global + return String(localized: .filterRangeGlobal) case .watched: - return L10n.Localizable.FilterRange.watched + return String(localized: .filterRangeWatched) } } } diff --git a/AppPackage/Sources/AppModels/Persistent/Greeting.swift b/AppPackage/Sources/AppModels/Persistent/Greeting.swift index bb7d78f6b..d7dc0e3e6 100644 --- a/AppPackage/Sources/AppModels/Persistent/Greeting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Greeting.swift @@ -61,10 +61,10 @@ public struct Greeting: Codable, Equatable, Hashable, Identifiable, Sendable { public var gainContent: String? { let rewards = rewards guard !rewards.isEmpty else { return nil } - let and = L10n.Localizable.Greeting.and - let end = L10n.Localizable.Greeting.end - let start = L10n.Localizable.Greeting.start - let separator = L10n.Localizable.Greeting.separator + let and = String(localized: .greetingAnd) + let end = String(localized: .greetingEnd) + let start = String(localized: .greetingStart) + let separator = String(localized: .greetingSeparator) let rewardDescription = rewards.enumerated().map { (offset, element) in if offset == 0 { return element diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index d44aabac3..db68f53f6 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -169,9 +169,9 @@ extension AutoLockPolicy { public var value: String { switch self { case .never: - return L10n.Localizable.AutoLockPolicy.never + return String(localized: .autoLockPolicyNever) case .instantly: - return L10n.Localizable.AutoLockPolicy.instantly + return String(localized: .autoLockPolicyInstantly) case .sec15: return L10n.Localizable.Common.seconds("\(rawValue)") case .min1: @@ -193,11 +193,11 @@ extension PreferredColorScheme { public var value: String { switch self { case .automatic: - return L10n.Localizable.PreferredColorScheme.automatic + return String(localized: .preferredColorSchemeAutomatic) case .light: - return L10n.Localizable.PreferredColorScheme.light + return String(localized: .preferredColorSchemeLight) case .dark: - return L10n.Localizable.PreferredColorScheme.dark + return String(localized: .preferredColorSchemeDark) } } public var userInterfaceStyle: UIUserInterfaceStyle { @@ -223,11 +223,11 @@ extension ReadingDirection { public var value: String { switch self { case .vertical: - return L10n.Localizable.ReadingDirection.vertical + return String(localized: .readingDirectionVertical) case .rightToLeft: - return L10n.Localizable.ReadingDirection.rightToLeft + return String(localized: .readingDirectionRightToLeft) case .leftToRight: - return L10n.Localizable.ReadingDirection.leftToRight + return String(localized: .readingDirectionLeftToRight) } } } @@ -242,9 +242,9 @@ extension ListDisplayMode { public var value: String { switch self { case .detail: - return L10n.Localizable.ListDisplayMode.detail + return String(localized: .listDisplayModeDetail) case .thumbnail: - return L10n.Localizable.ListDisplayMode.thumbnail + return String(localized: .listDisplayModeThumbnail) } } } diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index ab8328e86..71b58a176 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -33,8 +33,8 @@ public struct User: Codable, Equatable, Sendable { public var favoriteCategories: [Int: String]? public func getFavoriteCategory(index: Int) -> String { - guard index != -1 else { return L10n.Localizable.FavoriteCategory.all } - let defaultCategory = L10n.Localizable.FavoriteCategory.default("\(index)") + guard index != -1 else { return String(localized: .favoriteCategoryAll) } + let defaultCategory = String(localized: .favoriteCategoryDefault("\(index)")) let category = favoriteCategories?[index] ?? defaultCategory let isDefault = category == "Favorites \(index)" return isDefault ? defaultCategory : category diff --git a/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings b/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings new file mode 100644 index 000000000..09a9b9773 --- /dev/null +++ b/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings @@ -0,0 +1,18661 @@ +{ + "sourceLanguage": "en", + "strings": { + "app_activity_log_level.debug": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Debug" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Debug" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバッグ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디버그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "调试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "偵錯" + } + } + } + }, + "app_activity_log_level.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "錯誤" + } + } + } + }, + "app_activity_log_level.fault": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fault" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Störung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "障害" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "결함" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "故障" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "故障" + } + } + } + }, + "app_activity_log_level.info": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Info" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Info" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "情報" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정보" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "信息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資訊" + } + } + } + }, + "app_activity_log_level.notice": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notice" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinweis" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + } + } + }, + "app_activity_log_level.undefined": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Undefined" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Undefiniert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未定義" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정의되지 않음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未定义" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未定義" + } + } + } + }, + "app_error.authentication_required": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authentication Required" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Authentifizierung erforderlich" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認証が必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인증 필요" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要登录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要登入" + } + } + } + }, + "app_error.authentication_required_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Login required to access this download." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Für diesen Download ist eine Anmeldung erforderlich." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このダウンロードにアクセスするにはログインが必要です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 다운로드에 접근하려면 로그인해야 합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "访问此下载内容需要登录。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "存取此下載內容需要登入。" + } + } + } + }, + "app_error.copyright_claim": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copyright Claim" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Urheberrechtsanspruch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "著作権侵害の申し立て" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저작권 신고" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版权声明" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "版權聲明" + } + } + } + }, + "app_error.database_corrupted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Database Corrupted" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datenbank beschädigt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "データベース破損" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "데이터베이스 손상" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "数据库损坏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資料庫損壞" + } + } + } + }, + "app_error.file_operation_failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File Operation Failed" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dateivorgang fehlgeschlagen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル操作に失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일 작업 실패" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件操作失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檔案操作失敗" + } + } + } + }, + "app_error.gallery_expunged": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery Expunged" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerie entfernt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー削除済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 삭제됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊已删除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊已刪除" + } + } + } + }, + "app_error.ip_banned": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "IP Banned" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "IP-Adresse gesperrt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IP アドレスがブロックされました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "IP 차단됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "IP 已封禁" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "IP 已封禁" + } + } + } + }, + "app_error.local_file_operation_failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Local file operation failed." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lokaler Dateivorgang fehlgeschlagen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカルファイルの操作に失敗しました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로컬 파일 작업에 실패했습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本地文件操作失败。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "本機檔案操作失敗。" + } + } + } + }, + "app_error.network_error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network Error" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerkfehler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワークエラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네트워크 오류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網路錯誤" + } + } + } + }, + "app_error.no_updates_available": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No updates available" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Updates verfügbar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "利用可能な更新はありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 가능한 업데이트 없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有可用更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有可用更新" + } + } + } + }, + "app_error.not_found": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not found" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht gefunden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "見つかりません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "찾을 수 없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未找到" + } + } + } + }, + "app_error.parse_error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Parse Error" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Parserfehler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "解析エラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파싱 오류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "解析错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "解析錯誤" + } + } + } + }, + "app_error.quota_exceeded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quota Exceeded" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kontingent überschritten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像割り当て超過" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "할당량 초과" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "流量额度已用尽" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "流量額度已用盡" + } + } + } + }, + "app_error.quota_exceeded_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image quota exceeded.\\nPlease wait and try again later." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildkontingent überschritten.\\nBitte warte einen Moment und versuche es dann erneut." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像の帯域割り当てを使い切りました。\\nしばらく待ってからもう一度お試しください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 할당량을 모두 사용했습니다.\\n잠시 후 다시 시도해 주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图片流量额度已用尽。\\n请稍后再试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片流量額度已用盡。\\n請稍後再試。" + } + } + } + }, + "app_error.unknown_error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown Error" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannter Fehler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なエラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없는 오류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知錯誤" + } + } + } + }, + "app_error.web_image_loading_error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Web image loading error" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler beim Laden des Webbilds" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Web 画像の読み込みエラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "웹 이미지 로드 오류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网页图片加载错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網頁圖片載入錯誤" + } + } + } + }, + "app_icon_type.default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standard" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設" + } + } + } + }, + "app_icon_type.developer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Developer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entwickler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デベロッパー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "개발자" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开发者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Developer" + } + } + } + }, + "app_icon_type.not_my_president": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "NOT MY PRESIDENT" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "NICHT MEIN PRÄSIDENT" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "私の大統領ではない" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내 대통령이 아니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "他不是我的主席" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "NOT MY PRESIDENT" + } + } + } + }, + "app_icon_type.stand_with_ukraine_2022": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stand With Ukraine (2022)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Solidarität mit der Ukraine (2022)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウクライナと共に (2022)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우크라이나와 함께 (2022)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "与乌克兰同在 (2022)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Stand With Ukraine (2022)" + } + } + } + }, + "app_icon_type.ukiyoe": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ukiyo-e" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ukiyo-e" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "浮世絵" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우키요에" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浮世绘" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Ukiyo-e" + } + } + } + }, + "archive_resolution.original": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原始分辨率" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原始畫質" + } + } + } + }, + "auto_lock_policy.instantly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Instantly" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sofort" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すぐに" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즉시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立刻" + } + } + } + }, + "auto_lock_policy.never": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Never" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "안 함" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不锁定" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "永不自動鎖定" + } + } + } + }, + "ban_interval.and": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "and" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "und" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "" + } + } + } + }, + "browsing_country.afghanistan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Afghanistan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Afghanistan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アフガニスタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아프가니스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿富汗" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿富汗" + } + } + } + }, + "browsing_country.aland_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Aland Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aland Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オーランド諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알란드 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "奥兰群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "奧蘭群島" + } + } + } + }, + "browsing_country.albania": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Albania" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Albania" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルバニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알바니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿尔巴尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿爾巴尼亞" + } + } + } + }, + "browsing_country.algeria": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Algeria" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Algeria" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルジェリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알제리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿尔及利亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿爾及利亞" + } + } + } + }, + "browsing_country.american_samoa": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "American Samoa" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "American Samoa" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アメリカ領サモア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아메리칸 사모아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "美属萨摩亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "美屬薩摩亞" + } + } + } + }, + "browsing_country.andorra": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Andorra" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Andorra" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アンドラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "안도라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安道尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安道爾" + } + } + } + }, + "browsing_country.angola": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Angola" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Angola" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アンゴラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앙골라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安哥拉" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安哥拉" + } + } + } + }, + "browsing_country.anguilla": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Anguilla" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anguilla" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アンギラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "안젤라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安圭拉" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安圭拉" + } + } + } + }, + "browsing_country.antarctica": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Antarctica" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Antarctica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "南極大陸" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "남극" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "南极洲" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "南極洲" + } + } + } + }, + "browsing_country.antigua_and_barbuda": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Antigua and Barbuda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Antigua and Barbuda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アンティグア・バーブーダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앤티가 바부다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安提瓜和巴布达" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安地卡及巴布達" + } + } + } + }, + "browsing_country.argentina": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Argentina" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Argentina" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルゼンチン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아르헨티나" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿根廷" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿根廷" + } + } + } + }, + "browsing_country.armenia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Armenia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Armenia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルメニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아르메니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "亚美尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "亞美尼亞" + } + } + } + }, + "browsing_country.aruba": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Aruba" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aruba" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルバ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아루바 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿鲁巴" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿魯巴" + } + } + } + }, + "browsing_country.asia_pacific_region": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Asia-Pacific Region" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Asia-Pacific Region" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アジア太平洋地域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아시아 태평양 영역" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "亚太地区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "亞太地區" + } + } + } + }, + "browsing_country.australia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Australia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Australia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オーストラリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "호주" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "澳大利亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "澳洲" + } + } + } + }, + "browsing_country.austria": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Austria" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Austria" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オーストリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오스트리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "奥地利" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "奧地利" + } + } + } + }, + "browsing_country.auto_detect": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-Detect" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auto-Detect" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動検出" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동으로 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動偵測" + } + } + } + }, + "browsing_country.azerbaijan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Azerbaijan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Azerbaijan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アゼルバイジャン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아제르바이잔" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿塞拜疆" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "亞塞拜然" + } + } + } + }, + "browsing_country.bahamas": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bahamas" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bahamas" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バハマ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "바하마스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴哈马" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴哈馬" + } + } + } + }, + "browsing_country.bahrain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bahrain" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bahrain" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バーレーン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "바레인" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴林" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴林" + } + } + } + }, + "browsing_country.bangladesh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bangladesh" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bangladesh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バングラデシュ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "방글라데시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "孟加拉国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "孟加拉" + } + } + } + }, + "browsing_country.barbados": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Barbados" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Barbados" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バルバドス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "바베이도스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴巴多斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴貝多" + } + } + } + }, + "browsing_country.belarus": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Belarus" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Belarus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベラルーシ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "벨라루스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "白俄罗斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "白俄羅斯" + } + } + } + }, + "browsing_country.belgium": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Belgium" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Belgium" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベルギー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "벨기에" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "比利时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "比利時" + } + } + } + }, + "browsing_country.belize": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Belize" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Belize" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベリーズ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "벨리즈" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "伯利兹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "貝里斯" + } + } + } + }, + "browsing_country.benin": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Benin" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベナン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "베냉" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "贝宁" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "貝南" + } + } + } + }, + "browsing_country.bermuda": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bermuda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bermuda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バミューダ諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "버뮤다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "百慕大" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "百慕達" + } + } + } + }, + "browsing_country.bhutan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bhutan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bhutan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブータン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "부탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不丹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不丹" + } + } + } + }, + "browsing_country.bolivia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bolivia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bolivia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボリビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "볼리비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "玻利维亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "玻利維亞" + } + } + } + }, + "browsing_country.bonaire_saint_eustatius_and_saba": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bonaire Saint Eustatius and Saba" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bonaire Saint Eustatius and Saba" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボネール、シント・ユースタティウスおよびサバ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보네르 성 유스타티우스와 사바" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "博奈尔、圣尤斯特歇斯与萨巴" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "博奈爾、聖尤斯特歇斯和薩巴" + } + } + } + }, + "browsing_country.bosnia_and_herzegovina": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bosnia and Herzegovina" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bosnia and Herzegovina" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボスニア・ヘルツェゴビナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보스니아 헤르체코비나 " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "波斯尼亚和黑塞哥维那" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波士尼亞" + } + } + } + }, + "browsing_country.botswana": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Botswana" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Botswana" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボツワナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보츠와나" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "博茨瓦纳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波札那" + } + } + } + }, + "browsing_country.bouvet_island": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bouvet Island" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bouvet Island" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブーベ島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "부베섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "布韦岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "布威島" + } + } + } + }, + "browsing_country.brazil": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Brazil" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Brazil" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラジル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라질" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴西" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴西" + } + } + } + }, + "browsing_country.british_indian_ocean_territory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "British Indian Ocean Territory" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "British Indian Ocean Territory" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イギリス領インド洋地域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영국령 인도양 식민지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "英属印度洋领地" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "英屬印度洋領地" + } + } + } + }, + "browsing_country.brunei_darussalam": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Brunei Darussalam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Brunei Darussalam" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブルネイ・ダルサラーム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브루나이 다루살람" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文莱" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "汶萊" + } + } + } + }, + "browsing_country.bulgaria": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bulgaria" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bulgaria" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブルガリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "불가리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保加利亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "保加利亞" + } + } + } + }, + "browsing_country.burkina_faso": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Burkina Faso" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Burkina Faso" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブルキナファソ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "부르키나 파소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "布基纳法索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "布吉納法索" + } + } + } + }, + "browsing_country.burundi": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Burundi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Burundi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブルンジ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "부룬디" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蒲隆地" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蒲隆地" + } + } + } + }, + "browsing_country.cambodia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cambodia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cambodia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カンボジア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캄보디아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "柬埔寨" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "柬埔寨" + } + } + } + }, + "browsing_country.cameroon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cameroon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cameroon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カメルーン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "카메룬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "喀麦隆" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "喀麥隆" + } + } + } + }, + "browsing_country.canada": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Canada" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Canada" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カナダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캐나다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加拿大" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加拿大" + } + } + } + }, + "browsing_country.cape_verde": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cape Verde" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cape Verde" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カーボベルデ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포르투갈어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "佛得角" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "維德角" + } + } + } + }, + "browsing_country.cayman_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cayman Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cayman Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ケイマン諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "케이맨 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开曼群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開曼群島" + } + } + } + }, + "browsing_country.central_african_republic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Central African Republic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Central African Republic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中央アフリカ共和国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중앙아프리카 공화국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "中非" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "中非" + } + } + } + }, + "browsing_country.chad": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chad" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chad" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "차드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乍得" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "查德" + } + } + } + }, + "browsing_country.chile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chile" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チリ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "칠레" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "智利" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "智利" + } + } + } + }, + "browsing_country.china": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "China" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "China" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中華人民共和国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "中华人民共和国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "中國" + } + } + } + }, + "browsing_country.christmas_island": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Christmas Island" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Christmas Island" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリスマス島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크리스마스 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣诞岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖誕島" + } + } + } + }, + "browsing_country.cocos_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cocos Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cocos Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ココス諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코코스 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "科科斯岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "科科斯(基林)群島" + } + } + } + }, + "browsing_country.colombia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Colombia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Colombia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コロンビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "콜롬비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "哥伦比亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "哥倫比亞" + } + } + } + }, + "browsing_country.comoros": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Comoros" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Comoros" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コモロ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코모로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "科摩罗" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "葛摩" + } + } + } + }, + "browsing_country.congo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Congo" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Congo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンゴ共和国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "콩고" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刚果共和国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "剛果共和國" + } + } + } + }, + "browsing_country.cook_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cook Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cook Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クック諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쿡제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "库克群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "庫克群島" + } + } + } + }, + "browsing_country.costa_rica": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Costa Rica" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Costa Rica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コスタリカ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코스타리카" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "哥斯达黎加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "哥斯大黎加" + } + } + } + }, + "browsing_country.cote_d_ivoire": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cote D'Ivoire" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cote D'Ivoire" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コートジボワール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코트디부아르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "科特迪瓦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "象牙海岸" + } + } + } + }, + "browsing_country.croatia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Croatia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Croatia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クロアチア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크로아티아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "克罗地亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "克羅埃西亞" + } + } + } + }, + "browsing_country.cuba": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cuba" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cuba" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キューバ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쿠바" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "古巴" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "古巴" + } + } + } + }, + "browsing_country.curacao": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Curacao" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Curacao" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キュラソー島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "큐라소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "库拉索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "古拉索" + } + } + } + }, + "browsing_country.cyprus": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cyprus" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cyprus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キプロス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키프로스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塞浦路斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "賽普勒斯" + } + } + } + }, + "browsing_country.czech_republic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Czech Republic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Czech Republic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チェコ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "체코 공화국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "捷克" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "捷克共和國" + } + } + } + }, + "browsing_country.denmark": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Denmark" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Denmark" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デンマーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "덴마크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "丹麦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "丹麥" + } + } + } + }, + "browsing_country.djibouti": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Djibouti" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Djibouti" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジブチ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지부티" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "吉布提" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "吉布地" + } + } + } + }, + "browsing_country.dominica": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dominica" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dominica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドミニカ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "도미니카" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "多米尼克" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "多米尼克" + } + } + } + }, + "browsing_country.dominican_republic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dominican Republic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dominican Republic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドミニカ共和国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "도미니카 공화국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "多米尼加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "多明尼加" + } + } + } + }, + "browsing_country.ecuador": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ecuador" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ecuador" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エクアドル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에콰도르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "厄瓜多尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "厄瓜多" + } + } + } + }, + "browsing_country.egypt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Egypt" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Egypt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エジプト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이집트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "埃及" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "埃及" + } + } + } + }, + "browsing_country.el_salvador": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "El Salvador" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "El Salvador" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エルサルバドル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "엘살바도르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "萨尔瓦多" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "薩爾瓦多" + } + } + } + }, + "browsing_country.equatorial_guinea": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Equatorial Guinea" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Equatorial Guinea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "赤道ギニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "적도 기니" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "赤道几内亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "赤道幾內亞" + } + } + } + }, + "browsing_country.eritrea": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Eritrea" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eritrea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エリトリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에리트레아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "厄立特里亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "厄利垂亞" + } + } + } + }, + "browsing_country.estonia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Estonia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Estonia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エストニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에스토니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "爱沙尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "愛沙尼亞" + } + } + } + }, + "browsing_country.ethiopia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ethiopia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ethiopia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エチオピア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에티오피아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "埃塞俄比亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "衣索比亞" + } + } + } + }, + "browsing_country.europe": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Europe" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Europe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヨーロッパ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유럽" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "欧洲" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "歐洲" + } + } + } + }, + "browsing_country.falkland_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Falkland Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Falkland Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォークランド諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포클랜드 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "福克兰群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "福克蘭群島" + } + } + } + }, + "browsing_country.faroe_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Faroe Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Faroe Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フェロー諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페로스 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "法罗群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "法羅群島" + } + } + } + }, + "browsing_country.fiji": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fiji" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fiji" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィジー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "피지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斐济" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斐濟" + } + } + } + }, + "browsing_country.finland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Finland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィンランド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "핀란드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "芬兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "芬蘭" + } + } + } + }, + "browsing_country.france": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "France" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "France" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フランス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프랑스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "法国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "法國" + } + } + } + }, + "browsing_country.french_guiana": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "French Guiana" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "French Guiana" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フランス領ギアナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프랑스령 기아나" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "法属圭亚那" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "法屬圭亞那" + } + } + } + }, + "browsing_country.french_polynesia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "French Polynesia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "French Polynesia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フランス領ポリネシア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프랑스령 폴리네시아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "法属波利尼西亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "法屬玻里尼西亞" + } + } + } + }, + "browsing_country.french_southern_territories": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "French Southern Territories" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "French Southern Territories" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フランス領南方・南極地域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프랑스령 남부와 남극지역" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "法属南部和南极领地" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "法屬南部領土" + } + } + } + }, + "browsing_country.gabon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gabon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gabon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガボン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가봉" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加蓬" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加彭" + } + } + } + }, + "browsing_country.gambia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gambia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gambia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガンビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "감비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "冈比亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "甘比亞" + } + } + } + }, + "browsing_country.georgia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Georgia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Georgia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジョージア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그루지야" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "格鲁吉亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "喬治亞" + } + } + } + }, + "browsing_country.germany": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Germany" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Germany" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドイツ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "독일" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "德国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "德國" + } + } + } + }, + "browsing_country.ghana": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ghana" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ghana" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガーナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가나" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加纳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "迦納" + } + } + } + }, + "browsing_country.gibraltar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gibraltar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gibraltar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジブラルタル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지브롤터" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "直布罗陀" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "直布羅陀" + } + } + } + }, + "browsing_country.greece": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Greece" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Greece" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギリシャ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "희랍" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "希腊" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "希臘" + } + } + } + }, + "browsing_country.greenland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Greenland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Greenland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グリーンランド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그린란드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "格陵兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "格陵蘭" + } + } + } + }, + "browsing_country.grenada": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Grenada" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Grenada" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グレナダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그레나다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "格林纳达" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "格瑞那達" + } + } + } + }, + "browsing_country.guadeloupe": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guadeloupe" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guadeloupe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グアドループ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "과들루프 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "瓜德罗普" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瓜地洛普" + } + } + } + }, + "browsing_country.guam": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guam" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グアム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "괌" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關島" + } + } + } + }, + "browsing_country.guatemala": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guatemala" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guatemala" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グアテマラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "과테말라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "危地马拉" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瓜地馬拉" + } + } + } + }, + "browsing_country.guernsey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guernsey" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guernsey" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガーンジー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건지종 젖소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "根西" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "耿西" + } + } + } + }, + "browsing_country.guinea": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guinea" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guinea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기니" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "几内亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "幾內亞" + } + } + } + }, + "browsing_country.guinea_bissau": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guinea-Bissau" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guinea-Bissau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギニアビサウ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기니비사우" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "几内亚比绍" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "幾內亞比索" + } + } + } + }, + "browsing_country.guyana": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guyana" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guyana" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガイアナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가이아나" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圭亚那" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蓋亞那" + } + } + } + }, + "browsing_country.haiti": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Haiti" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Haiti" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ハイチ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이티" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "海地" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "海地" + } + } + } + }, + "browsing_country.heard_island_and_mc_donald_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Heard Island and McDonald Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heard Island and McDonald Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ハード島とマクドナルド諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "허드 맥도널드 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "赫德岛和麦克唐纳群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "赫德島和麥克唐納群島" + } + } + } + }, + "browsing_country.honduras": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Honduras" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Honduras" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホンジュラス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온두라스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "洪都拉斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "宏都拉斯" + } + } + } + }, + "browsing_country.hong_kong": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hong Kong" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hong Kong" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "香港" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "홍콩" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "香港" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "香港" + } + } + } + }, + "browsing_country.hungary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hungary" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hungary" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ハンガリー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "헝가리" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "匈牙利" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匈牙利" + } + } + } + }, + "browsing_country.iceland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iceland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Iceland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アイスランド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Iceland" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "冰岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "冰島" + } + } + } + }, + "browsing_country.india": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "India" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "India" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "印度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "印度" + } + } + } + }, + "browsing_country.indonesia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Indonesia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Indonesia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インドネシア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인도네시아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "印度尼西亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "印度尼西亞(印尼)" + } + } + } + }, + "browsing_country.iran": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iran" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Iran" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イラン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이란" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "伊朗" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "伊朗" + } + } + } + }, + "browsing_country.iraq": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iraq" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Iraq" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イラク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이라크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "伊拉克" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "伊拉克" + } + } + } + }, + "browsing_country.ireland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ireland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ireland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アイルランド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아일랜드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "爱尔兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "愛爾蘭" + } + } + } + }, + "browsing_country.isle_of_man": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Isle of Man" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Isle of Man" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マン島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "맨 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "曼岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "曼島" + } + } + } + }, + "browsing_country.israel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Israel" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Israel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イスラエル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이스라엘" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "以色列" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "以色列" + } + } + } + }, + "browsing_country.italy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Italy" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Italy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イタリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이탈리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "意大利" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "義大利" + } + } + } + }, + "browsing_country.jamaica": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jamaica" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jamaica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジャマイカ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자마이카" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "牙买加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "牙買加" + } + } + } + }, + "browsing_country.japan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Japan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Japan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日本" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일본" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日本" + } + } + } + }, + "browsing_country.jersey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jersey" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jersey" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジャージー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "泽西" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "澤西島" + } + } + } + }, + "browsing_country.jordan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jordan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jordan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヨルダン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "요단" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "约旦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "約旦" + } + } + } + }, + "browsing_country.kazakhstan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kazakhstan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kazakhstan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カザフスタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "카자흐스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "哈萨克斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "哈薩克共和國" + } + } + } + }, + "browsing_country.kenya": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kenya" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kenya" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ケニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "케냐" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "肯尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "肯亞" + } + } + } + }, + "browsing_country.kiribati": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kiribati" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kiribati" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キリバス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키리바시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "基里巴斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "吉里巴斯" + } + } + } + }, + "browsing_country.kuwait": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kuwait" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kuwait" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クウェート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쿠웨이트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "科威特" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "科威特" + } + } + } + }, + "browsing_country.kyrgyzstan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kyrgyzstan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kyrgyzstan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キルギス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키르기스스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "吉尔吉斯斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "吉爾吉斯" + } + } + } + }, + "browsing_country.lao_peoples_democratic_republic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lao People's Democratic Republic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lao People's Democratic Republic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラオス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라오 인민민주공화국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "老挝" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "寮國" + } + } + } + }, + "browsing_country.latvia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Latvia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Latvia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラトビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라트비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拉脱维亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拉脫維亞" + } + } + } + }, + "browsing_country.lebanon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lebanon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lebanon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レバノン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "레바논" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "黎巴嫩" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "黎巴嫩" + } + } + } + }, + "browsing_country.lesotho": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lesotho" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lesotho" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レソト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "레소토" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "莱索托" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "賴索托" + } + } + } + }, + "browsing_country.liberia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Liberia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liberia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リベリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리베리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "利比里亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "賴比瑞亞" + } + } + } + }, + "browsing_country.libya": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Libya" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Libya" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "利比亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "利比亞" + } + } + } + }, + "browsing_country.liechtenstein": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Liechtenstein" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liechtenstein" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リヒテンシュタイン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리히텐슈타인" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "列支敦士登" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "列支敦斯登" + } + } + } + }, + "browsing_country.lithuania": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lithuania" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lithuania" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リトアニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리투아니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立陶宛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立陶宛" + } + } + } + }, + "browsing_country.luxembourg": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Luxembourg" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Luxembourg" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルクセンブルク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "룩셈부르크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卢森堡" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "盧森堡" + } + } + } + }, + "browsing_country.macau": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Macau" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Macau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マカオ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마카오" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "澳门" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "澳門" + } + } + } + }, + "browsing_country.macedonia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Macedonia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Macedonia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マケドニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마케도니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马其顿" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "北馬其頓" + } + } + } + }, + "browsing_country.madagascar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Madagascar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Madagascar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マダガスカル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마다스카르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马达加斯加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬達加斯加" + } + } + } + }, + "browsing_country.malawi": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Malawi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Malawi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マラウイ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "말라위" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马拉维" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬拉威" + } + } + } + }, + "browsing_country.malaysia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Malaysia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Malaysia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マレーシア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "말레이시아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马来西亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬來西亞" + } + } + } + }, + "browsing_country.maldives": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maldives" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maldives" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モルディブ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "말디브" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马尔代夫" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬爾地夫" + } + } + } + }, + "browsing_country.mali": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mali" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mali" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マリ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "말리" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马里" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬利" + } + } + } + }, + "browsing_country.malta": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Malta" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Malta" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マルタ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "말타" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马耳他" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬爾他" + } + } + } + }, + "browsing_country.marshall_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Marshall Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Marshall Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マーシャル諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마샬군도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马绍尔群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬紹爾群島" + } + } + } + }, + "browsing_country.martinique": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Martinique" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Martinique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マルティニーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마르티니크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马提尼克" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬丁尼克" + } + } + } + }, + "browsing_country.mauritania": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mauritania" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mauritania" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モーリタニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모리타니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "毛里塔尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "茅利塔尼亞" + } + } + } + }, + "browsing_country.mauritius": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mauritius" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mauritius" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モーリシャス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모리셔스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模里西斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "模里西斯" + } + } + } + }, + "browsing_country.mayotte": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mayotte" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mayotte" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マヨット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마요트 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马约特" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬約特" + } + } + } + }, + "browsing_country.mexico": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mexico" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mexico" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メキシコ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "맥시코" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "墨西哥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "墨西哥" + } + } + } + }, + "browsing_country.micronesia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Micronesia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Micronesia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ミクロネシア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마크로네시아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "密克罗尼西亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "密克羅尼西亞" + } + } + } + }, + "browsing_country.moldova": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Moldova" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Moldova" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モルドバ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "몰도바" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "摩尔多瓦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "摩爾多瓦" + } + } + } + }, + "browsing_country.monaco": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Monaco" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Monaco" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モナコ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모나코" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "摩纳哥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "摩納哥" + } + } + } + }, + "browsing_country.mongolia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mongolia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mongolia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モンゴル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "몽콜" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蒙古" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蒙古" + } + } + } + }, + "browsing_country.montenegro": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Montenegro" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Montenegro" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モンテネグロ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "몬테네그로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "黑山" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蒙特內哥羅" + } + } + } + }, + "browsing_country.montserrat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Montserrat" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Montserrat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モントセラト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "몬트세라트섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蒙塞拉特岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蒙特塞拉特" + } + } + } + }, + "browsing_country.morocco": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Morocco" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Morocco" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モロッコ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모로코가족" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "摩洛哥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "摩洛哥" + } + } + } + }, + "browsing_country.mozambique": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mozambique" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mozambique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モザンビーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모잠비크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "莫桑比克" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "莫三比克" + } + } + } + }, + "browsing_country.myanmar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Myanmar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Myanmar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ミャンマー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미얀마" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缅甸" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "緬甸" + } + } + } + }, + "browsing_country.namibia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Namibia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Namibia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ナミビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나미비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "纳米比亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "納米比亞" + } + } + } + }, + "browsing_country.nauru": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nauru" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nauru" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ナウル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나우루" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "诺鲁" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "諾魯" + } + } + } + }, + "browsing_country.nepal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nepal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nepal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネパール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네팔" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尼泊尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尼泊爾" + } + } + } + }, + "browsing_country.netherlands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Netherlands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netherlands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オランダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네덜란드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "荷兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "荷蘭" + } + } + } + }, + "browsing_country.new_caledonia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Caledonia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "New Caledonia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ニューカレドニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "뉴칼레도니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新喀里多尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新喀里多尼亞" + } + } + } + }, + "browsing_country.new_zealand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Zealand" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "New Zealand" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ニュージーランド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "뉴질랜드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新西兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "紐西蘭" + } + } + } + }, + "browsing_country.nicaragua": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nicaragua" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicaragua" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ニカラグア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나카라과" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尼加拉瓜" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尼加拉瓜" + } + } + } + }, + "browsing_country.niger": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Niger" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Niger" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ニジェール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "니제르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尼日尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尼日" + } + } + } + }, + "browsing_country.nigeria": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nigeria" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nigeria" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ナイジェリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나이지리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尼日利亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "奈及利亞" + } + } + } + }, + "browsing_country.niue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Niue" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Niue" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ニウエ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "니우에 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "纽埃" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "紐埃" + } + } + } + }, + "browsing_country.norfolk_island": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Norfolk Island" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Norfolk Island" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ノーフォーク島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "노퍽섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "诺福克岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "諾福克島" + } + } + } + }, + "browsing_country.north_korea": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "North Korea" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "North Korea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "朝鮮" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "북한" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "朝鲜" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "朝鮮" + } + } + } + }, + "browsing_country.northern_mariana_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Northern Mariana Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Northern Mariana Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "北マリアナ諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "북마리아나제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "北马里亚纳群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "北馬里亞納群島" + } + } + } + }, + "browsing_country.norway": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Norway" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Norway" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ノルウェー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "노르웨이" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "挪威" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "挪威" + } + } + } + }, + "browsing_country.oman": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Oman" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oman" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オマーン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오만" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿曼" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿曼" + } + } + } + }, + "browsing_country.pakistan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pakistan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pakistan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パキスタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파키스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴基斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴基斯坦" + } + } + } + }, + "browsing_country.palau": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Palau" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Palau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パラオ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팔라우" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "帛琉" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "帛琉" + } + } + } + }, + "browsing_country.palestinian_territory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Palestinian Territory" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Palestinian Territory" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パレスチナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팔레스타인의 지역" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴勒斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴勒斯坦領土" + } + } + } + }, + "browsing_country.panama": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Panama" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Panama" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パナマ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파나마모자" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴拿马" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴拿馬" + } + } + } + }, + "browsing_country.papua_new_guinea": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Papua New Guinea" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Papua New Guinea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パプアニューギニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파푸아뉴기니" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴布亚新几内亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴布亞紐幾內亞" + } + } + } + }, + "browsing_country.paraguay": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paraguay" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Paraguay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パラグアイ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파라과이" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴拉圭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴拉圭" + } + } + } + }, + "browsing_country.peru": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Peru" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Peru" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペルー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페루" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "秘鲁" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "秘魯" + } + } + } + }, + "browsing_country.philippines": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Philippines" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Philippines" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィリピン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필리핀" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "菲律宾" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "菲律賓" + } + } + } + }, + "browsing_country.pitcairn_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pitcairn Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pitcairn Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ピトケアン諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "핏케언 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "皮特凯恩群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "皮特凱恩群島" + } + } + } + }, + "browsing_country.poland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Poland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Poland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポーランド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴란드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "波兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波蘭" + } + } + } + }, + "browsing_country.portugal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Portugal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Portugal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポルトガル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포르투갈" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "葡萄牙" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "葡萄牙" + } + } + } + }, + "browsing_country.puerto_rico": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Puerto Rico" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Puerto Rico" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プエルトリコ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "푸에르토리코" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "波多黎各" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波多黎各" + } + } + } + }, + "browsing_country.qatar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Qatar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Qatar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カタール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "카타로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卡塔尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "卡達" + } + } + } + }, + "browsing_country.reunion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reunion" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reunion" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ユニオン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "레워니옹" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "留尼汪" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "留尼旺" + } + } + } + }, + "browsing_country.romania": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Romania" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Romania" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルーマニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "루마니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "羅馬尼亞" + } + } + } + }, + "browsing_country.russian_federation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Russian Federation" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Russian Federation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ロシア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "러시아 연방" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "俄罗斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "俄羅斯" + } + } + } + }, + "browsing_country.rwanda": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rwanda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Rwanda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルワンダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "르완다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卢旺达" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "盧安達" + } + } + } + }, + "browsing_country.saint_barthelemy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Barthelemy" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Barthelemy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サン・バルテルミー島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "생바르텔레미" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣巴泰勒米" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖巴瑟米" + } + } + } + }, + "browsing_country.saint_helena": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Helena" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Helena" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セントヘレナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세인츠헬레나 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣赫勒拿" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖赫勒拿" + } + } + } + }, + "browsing_country.saint_kitts_and_nevis": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Kitts and Nevis" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Kitts and Nevis" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セントクリストファー・ネービス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세인트키츠네비스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣基茨岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖克里斯多福及尼維斯" + } + } + } + }, + "browsing_country.saint_lucia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Lucia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Lucia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セントルシア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세인트루시아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣卢西亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖露西亞" + } + } + } + }, + "browsing_country.saint_martin": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Martin" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Martin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サン・マルタン島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세인트 마틴" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣马丁岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖馬丁" + } + } + } + }, + "browsing_country.saint_pierre_and_miquelon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Pierre and Miquelon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Pierre and Miquelon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サンピエール島・ミクロン島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "생피에르 미글롱" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣皮埃尔和密克隆" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖皮耶與密克隆" + } + } + } + }, + "browsing_country.saint_vincent_and_the_grenadines": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Vincent and the Grenadines" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Vincent and the Grenadines" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セントビンセントおよびグレナディーン諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세인트빈센트 그레나딘" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣文森特和格林纳丁斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖文森及格瑞那丁" + } + } + } + }, + "browsing_country.samoa": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Samoa" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Samoa" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サモア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사모아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "萨摩亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "薩摩亞" + } + } + } + }, + "browsing_country.san_marino": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "San Marino" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "San Marino" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サンマリノ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "산마리노" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣马力诺" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖馬利諾" + } + } + } + }, + "browsing_country.sao_tome_and_principe": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sao Tome and Principe" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sao Tome and Principe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サントメ・プリンシペ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상투메 프린시페 도브라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣多美和普林西比" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖多美普林西比" + } + } + } + }, + "browsing_country.saudi_arabia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saudi Arabia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saudi Arabia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サウジアラビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사우디 아라비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "沙地阿拉伯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沙烏地阿拉伯" + } + } + } + }, + "browsing_country.senegal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Senegal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Senegal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セネガル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세네갈" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塞内加尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "塞內加爾" + } + } + } + }, + "browsing_country.serbia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Serbia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Serbia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セルビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세르비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塞尔维亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "塞爾維亞" + } + } + } + }, + "browsing_country.seychelles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Seychelles" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seychelles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セーシェル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세이셸" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塞舌尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "塞席爾" + } + } + } + }, + "browsing_country.sierra_leone": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sierra Leone" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sierra Leone" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シエラレオネ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시에라리온" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塞拉利昂" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "獅子山" + } + } + } + }, + "browsing_country.singapore": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Singapore" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Singapore" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シンガポール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "싱가포르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新加坡" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新加坡" + } + } + } + }, + "browsing_country.sint_maarten": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sint Maarten" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sint Maarten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シント・マールテン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "신트마르턴" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣马丁岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖馬丁" + } + } + } + }, + "browsing_country.slovakia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Slovakia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Slovakia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スロバキア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "슬로바키아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯洛伐克" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯洛伐克" + } + } + } + }, + "browsing_country.slovenia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Slovenia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Slovenia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スロベニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "슬로베니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯洛文尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯洛維尼亞" + } + } + } + }, + "browsing_country.solomon_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Solomon Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Solomon Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソロモン諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "솔로몬 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所罗门群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "索羅門群島" + } + } + } + }, + "browsing_country.somalia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Somalia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Somalia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソマリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소말리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "索马里" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "索馬利亞" + } + } + } + }, + "browsing_country.south_africa": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "South Africa" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "South Africa" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "南アフリカ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "남아프리카" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "南非" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "南非" + } + } + } + }, + "browsing_country.south_georgia_and_the_south_sandwich_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "South Georgia and the South Sandwich Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "South Georgia and the South Sandwich Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サウスジョージア・サウスサンドウィッチ諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사우스조지아 사우스샌드위치 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "南乔治亚和南桑威奇群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "南喬治亞和南桑威奇群島" + } + } + } + }, + "browsing_country.south_korea": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "South Korea" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "South Korea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "韓国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "韩国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "韓國" + } + } + } + }, + "browsing_country.south_sudan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "South Sudan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "South Sudan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "南スーダン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "남수단" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "南苏丹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "南蘇丹" + } + } + } + }, + "browsing_country.spain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Spain" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Spain" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スペイン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스페인" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "西班牙" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "西班牙" + } + } + } + }, + "browsing_country.sri_lanka": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sri Lanka" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sri Lanka" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スリランカ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스리랑카" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯里兰卡" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯里蘭卡" + } + } + } + }, + "browsing_country.sudan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sudan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sudan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スーダン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수단" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "苏丹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蘇丹" + } + } + } + }, + "browsing_country.suriname": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Suriname" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suriname" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スリナム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수리남" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "苏里南" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蘇利南" + } + } + } + }, + "browsing_country.svalbard_and_jan_mayen": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Svalbard and Jan Mayen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Svalbard and Jan Mayen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スヴァールバル諸島およびヤンマイエン島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스발바르 얀마옌 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯瓦尔巴和扬马延" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯瓦巴和揚馬延" + } + } + } + }, + "browsing_country.swaziland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Swaziland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Swaziland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エスワティニ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스와질란드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "史瓦帝尼" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "史瓦帝尼" + } + } + } + }, + "browsing_country.sweden": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sweden" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sweden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スウェーデン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스웨덴" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "瑞典" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瑞典" + } + } + } + }, + "browsing_country.switzerland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switzerland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Switzerland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スイス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스위스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "瑞士" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瑞士" + } + } + } + }, + "browsing_country.syrian_arab_republic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Syrian Arab Republic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Syrian Arab Republic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "叙利亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "敘利亞" + } + } + } + }, + "browsing_country.taiwan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Taiwan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Taiwan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "台湾" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대만" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "台湾" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "臺灣" + } + } + } + }, + "browsing_country.tajikistan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tajikistan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tajikistan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タジキスタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "타지키스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塔吉克斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "塔吉克" + } + } + } + }, + "browsing_country.tanzania": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tanzania" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tanzania" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タンザニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탄지니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "坦桑尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "坦尚尼亞" + } + } + } + }, + "browsing_country.thailand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thailand" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Thailand" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "泰国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "泰國" + } + } + } + }, + "browsing_country.the_democratic_republic_of_the_congo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Democratic Republic of the Congo" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "The Democratic Republic of the Congo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンゴ民主共和国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "콩고민주공화국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刚果民主共和国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "剛果民主共和國" + } + } + } + }, + "browsing_country.timor_leste": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Timor-Leste" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Timor-Leste" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "東ティモール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "동티모르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "东帝汶" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "東帝汶" + } + } + } + }, + "browsing_country.togo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Togo" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Togo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トーゴ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토고" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "多哥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "多哥" + } + } + } + }, + "browsing_country.tokelau": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tokelau" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tokelau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トケラウ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토켈라우" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "托克劳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "托克勞" + } + } + } + }, + "browsing_country.tonga": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tonga" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tonga" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トンガ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "통가" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "汤加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "東加" + } + } + } + }, + "browsing_country.trinidad_and_tobago": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Trinidad and Tobago" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Trinidad and Tobago" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トリニダード・トバゴ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "트리니다드토바고" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "特立尼达和多巴哥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "千里達和托巴哥" + } + } + } + }, + "browsing_country.tunisia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tunisia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tunisia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チュニジア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "튀니지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "突尼斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "突尼西亞" + } + } + } + }, + "browsing_country.turkey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turkey" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Turkey" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トルコ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터키" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "土耳其" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "土耳其" + } + } + } + }, + "browsing_country.turkmenistan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turkmenistan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Turkmenistan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トルクメニスタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "투르크메니스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "土库曼斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "土庫曼" + } + } + } + }, + "browsing_country.turks_and_caicos_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turks and Caicos Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Turks and Caicos Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タークス・カイコス諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터크스카이코스 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "特克斯和凯科斯群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "土克斯及開科斯群島" + } + } + } + }, + "browsing_country.tuvalu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tuvalu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tuvalu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ツバル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "투발루" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图瓦卢" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "吐瓦魯" + } + } + } + }, + "browsing_country.uganda": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uganda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Uganda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウガンダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우간다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乌干达" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "烏干達" + } + } + } + }, + "browsing_country.ukraine": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ukraine" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ukraine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウクライナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우크라이나" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乌克兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "烏克蘭" + } + } + } + }, + "browsing_country.united_arab_emirates": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "United Arab Emirates" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "United Arab Emirates" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アラブ首長国連邦" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아랍 에미리트 연합국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿拉伯联合酋长国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿拉伯聯合大公國" + } + } + } + }, + "browsing_country.united_kingdom": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "United Kingdom" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "United Kingdom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イギリス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "英国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "英國" + } + } + } + }, + "browsing_country.united_states": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "United States" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "United States" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アメリカ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "美国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "美國" + } + } + } + }, + "browsing_country.united_states_minor_outlying_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "United States Minor Outlying Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "United States Minor Outlying Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "合衆国領有小離島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미국령 군소 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "美国本土外小岛屿" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "美國外圍小島嶼" + } + } + } + }, + "browsing_country.uruguay": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uruguay" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Uruguay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウルグアイ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우루과이" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乌拉圭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "烏拉圭" + } + } + } + }, + "browsing_country.uzbekistan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uzbekistan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Uzbekistan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウズベキスタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우즈베키스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乌兹别克斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "烏茲別克" + } + } + } + }, + "browsing_country.vanuatu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vanuatu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vanuatu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バヌアツ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "바누어투" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "瓦努阿图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "萬那杜" + } + } + } + }, + "browsing_country.vatican_city_state": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vatican City State" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vatican City State" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バチカン市国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "바티칸 시국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "梵蒂冈城国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "梵蒂岡" + } + } + } + }, + "browsing_country.venezuela": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Venezuela" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Venezuela" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベネズエラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "베네수엘라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "委內瑞拉" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "委內瑞拉" + } + } + } + }, + "browsing_country.vietnam": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vietnam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vietnam" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベトナム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "베트남" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "越南" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "越南" + } + } + } + }, + "browsing_country.virgin_islands_US": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "U.S. Virgin Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "U.S. Virgin Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アメリカ領ヴァージン諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세인트존 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "美属维尔京群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "美屬維京群島" + } + } + } + }, + "browsing_country.virgin_islands_british": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "British Virgin Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "British Virgin Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イギリス領バージン諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영국령 버진 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "英属维尔京群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "英屬維京群島" + } + } + } + }, + "browsing_country.wallis_and_futuna": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Wallis and Futuna" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wallis and Futuna" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォリス・フツナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "월리스 푸투나제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "瓦利斯和富图纳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瓦利斯和富圖納" + } + } + } + }, + "browsing_country.western_sahara": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Western Sahara" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Western Sahara" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "西サハラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서사하라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "西撒哈拉" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "西撒哈拉" + } + } + } + }, + "browsing_country.yemen": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yemen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Yemen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イエメン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예멘" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "也门" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "葉門" + } + } + } + }, + "browsing_country.zambia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zambia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zambia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ザンビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잠비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "赞比亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚比亞" + } + } + } + }, + "browsing_country.zimbabwe": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zimbabwe" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zimbabwe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジンバブエ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "짐바브웨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "津巴布韦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "辛巴威" + } + } + } + }, + "category.artist_CG": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Artist CG" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Artist CG" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イラスト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일러스트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "插画" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "插畫" + } + } + } + }, + "category.asian_porn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Asian Porn" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Asian Porn" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アジア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Asian Porn" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "亚洲" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "亞洲" + } + } + } + }, + "category.cosplay": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cosplay" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cosplay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コスプレ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코스프레" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "角色扮演" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "角色扮演" + } + } + } + }, + "category.doujinshi": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Doujinshi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Doujinshi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "同人誌" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "동인지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "同人志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "同人誌" + } + } + } + }, + "category.game_CG": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Game CG" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Game CG" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゲーム CG" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "게임 CG" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "游戏 CG" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遊戲 CG" + } + } + } + }, + "category.image_set": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image Set" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Image Set" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像集" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포토북" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "照片集" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片集" + } + } + } + }, + "category.manga": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manga" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Manga" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "漫画" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "만화" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "漫画" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "漫畫" + } + } + } + }, + "category.misc": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Misc" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Misc" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기타" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其它" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "其它" + } + } + } + }, + "category.non_h": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Non-H" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Non-H" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "健全" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Non-H" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "健康" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "健康" + } + } + } + }, + "category.private": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Private" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Private" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プライベート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Private" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "非公开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "私人" + } + } + } + }, + "category.western": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Western" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Western" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "西洋" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서양" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "西方" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "西方" + } + } + } + }, + "comments_sort_order.highest_score": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By highest score" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach höchster Punktzahl" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スコアの高い順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "평가가 가장 높은 순서" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按最高分的评论" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最相關留言優先" + } + } + } + }, + "comments_sort_order.oldest": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Oldest comments first" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Älteste Kommentare zuerst" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コメントの古い順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가장 이른 순서" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按最早的评论" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最舊留言優先" + } + } + } + }, + "comments_sort_order.recent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recent comments first" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neueste Kommentare zuerst" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コメントの新しい順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최신순" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按最新的评论" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最新留言優先" + } + } + } + }, + "comments_votes_show_timing.always": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Immer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "常時" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "항상" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "始终显示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "總是顯示" + } + } + } + }, + "comments_votes_show_timing.on_hover_or_click": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On score hover or click" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Überfahren oder Anklicken der Punktzahl" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スコアに経過・クリック時" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "점수를 가리키커나 클리하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "悬停或点击时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "滑鼠在分數上停留或點擊時" + } + } + } + }, + "copyright_claim": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This gallery is unavailable due to a copyright claim by %@. Sorry about that." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Galerie ist wegen eines Urheberrechtsanspruchs von %@ nicht verfügbar. Das tut uns leid." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "申し訳ありませんが、このギャラリーは %@ の著作権主張によってアクセス不可になっています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@의 저작권 요청으로 인하여 이 갤러리를 사용할 수 없어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "抱歉,该画廊因 %@ 的版权主张已无法访问。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取" + } + } + } + }, + "database_corrupted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The database is corrupted.\\nPlease submit an issue on GitHub." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Datenbank ist beschädigt.\\nBitte erstelle ein Issue auf GitHub." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "データベースが破損しています。\\nGitHub で Issue を作成していただくようお願いいたします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "데이터베이스가 손상되었어요.\\nGitHub에 이슈를 남겨주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "数据库已损毁。\\n请到 GitHub 提起 Issue 反馈。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資料庫已經損壞\\n請提交 issue 至 GitHub." + } + } + } + }, + "display_mode.compact": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Compact" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kompakt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンパクト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "컴팩트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "紧凑" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "緊湊(Compact)" + } + } + } + }, + "display_mode.extended": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Extended" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erweitert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拡張" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확장" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扩展" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "放大(Extended)" + } + } + } + }, + "display_mode.minimal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Minimal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最小化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미니멀" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最小化" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最小(Minimal)" + } + } + } + }, + "display_mode.minimalPlus": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimal+" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Minimal+" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最小化+" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미니멀+" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最小化 +" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Minimal+" + } + } + } + }, + "display_mode.thumbnail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thumbnail" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschaubilder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サムネイル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "썸네일" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩略图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮圖(Thumbnail)" + } + } + } + }, + "download_folder_filter.all": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + } + } + }, + "eh_setting.archiver_behavior.auto_select_original_auto_start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto Select Original, Auto Start" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Original automatisch wählen, automatisch starten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動でオリジナルを選択、自動で開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동으로 원본을 선택, 자동 시작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动选择原始画质,自动下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動選擇原始畫質並開始下載" + } + } + } + }, + "eh_setting.archiver_behavior.auto_select_original_manual_start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto Select Original, Manual Start" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Original automatisch wählen, manuell starten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動でオリジナルを選択、手動で開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동으로 원본을 선택, 수동 시작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动选择原始画质,手动下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動選擇原始畫質,,手動開始下載" + } + } + } + }, + "eh_setting.archiver_behavior.auto_select_resample_auto_start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto Select Resample, Auto Start" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu berechnete Version automatisch wählen, automatisch starten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動でリサンプルを選択、自動で開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동으로 저화질을 선택, 자동 시작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动选择压缩画质,自动下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動選擇重新採樣並開始下載" + } + } + } + }, + "eh_setting.archiver_behavior.auto_select_resample_manual_start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto Select Resample, Manual Start" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu berechnete Version automatisch wählen, manuell starten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動でリサンプルを選択、手動で開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동으로 저화질을 선택, 수동 시작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动选择压缩画质,手动下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動選擇重新採樣,手動開始下載" + } + } + } + }, + "eh_setting.archiver_behavior.manual_select_auto_start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual Select, Auto Start" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Manuell wählen, automatisch starten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動で選択、自動で開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수동 선택, 자동 시작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手动选择,自动下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手動選擇,自動開始下載" + } + } + } + }, + "eh_setting.archiver_behavior.manual_select_manual_start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual Select, Manual Start (Default)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Manuell wählen, manuell starten (Standard)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動で選択、手動で開始(デフォルト)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수동 선택, 수동 시작 (기본)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手动选择,手动下载(默认)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手動選擇,手動開始下載(預設)" + } + } + } + }, + "excluded_languages_category.original": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原始版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原始語言" + } + } + } + }, + "excluded_languages_category.rewrite": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rewrite" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Umgeschrieben" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "書き換え版" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 쓰기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "改编版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "覆寫" + } + } + } + }, + "excluded_languages_category.translated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Translated" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übersetzt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "翻訳版" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "번역됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "翻译版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "翻譯語言" + } + } + } + }, + "favorite_category.all": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모두" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + } + } + }, + "favorite_category.default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorites %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favoriten %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入り %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기 %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收藏夹 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收藏匣 %@" + } + } + } + }, + "favorites_sort_order.favorited_time": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By favorited time" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach Zeitpunkt des Favorisierens" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "気に入った時間の新しい順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "별점 시간으로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按收藏时间" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "透過收藏順序排序" + } + } + } + }, + "favorites_sort_order.last_update_time": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By last gallery update time" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach letzter Aktualisierung der Galerie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新時間の新しい順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마지막 업데이트 시간으로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按更新时间" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "透過最後更新時間排序" + } + } + } + }, + "filter_range.global": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "全般" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全域" + } + } + } + }, + "filter_range.search": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋" + } + } + } + }, + "filter_range.watched": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Watched" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Meine Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの購読" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주시 태그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤" + } + } + } + }, + "gallery_name.default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default Title" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardtitel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトタイトル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영어 제목" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认标题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設標題" + } + } + } + }, + "gallery_name.japanese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Japanese Title (if available)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Japanischer Titel (falls vorhanden)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日本語タイトル(可能なら)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일본어 제목(가능하면)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日文标题(如果有)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日文標題(若該畫廊支援)" + } + } + } + }, + "gallery_page_numbering.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示しない" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시 안 함" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不显示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不顯示" + } + } + } + }, + "gallery_page_numbering.page_number_and_name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page Number + Name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenzahl + Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ番号と名前を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 번호와 이름" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示页码和名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示頁碼和名稱" + } + } + } + }, + "gallery_page_numbering.page_number_only": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page Number Only" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Seitenzahl" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ番号のみ表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 번호만" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅显示页码" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只顯示頁碼" + } + } + } + }, + "gallery_unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Galerie wurde entfernt oder ist nicht verfügbar." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このギャラリーはすでに削除済みまたは無効です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 갤러리는 제거되었거나 사용할 수 없어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该画廊已被移除或不可用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此畫廊已被刪除或你沒有權限存取" + } + } + } + }, + "gallery_visibility.expunged": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expunged" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已删除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已被刪除" + } + } + } + }, + "gallery_visibility.no": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No (%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nein (%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "いいえ (%@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아니요 (%@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "否 (%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "No (%@)" + } + } + } + }, + "gallery_visibility.yes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yes" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ja" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "はい" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "是" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Yes" + } + } + } + }, + "greeting.and": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " and " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " und " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " と " + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " 과 " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 和 " + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 和 " + } + } + } + }, + "greeting.end": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "!" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "!" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "を手に入れた!" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "획득했어요!" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "!" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "!" + } + } + } + }, + "greeting.separator": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "、" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "、" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "、" + } + } + } + }, + "greeting.start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You gain " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du erhälst " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你获得了 " + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你獲得了 " + } + } + } + }, + "hath_archive.free": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Free" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Frei" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無料" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "무료" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "免费" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "免費" + } + } + } + }, + "image_resolution.auto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "ip_banned": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deine IP-Adresse wurde wegen übermäßig vieler Seitenaufrufe vorübergehend gesperrt. Das deutet auf automatische Mirroring- oder Harvesting-Software hin. Die Sperre läuft in %@ ab." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この IP アドレスを経由して過剰なページロードが行われました。クローラの疑いがあるため、この IP アドレスは一時的にブロックされました。ブロックは %@後に解除されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동화된 미러링/수집 소프트웨어 사용이 의심되는 과도한 페이지 로드로 인해 IP 주소가 일시적으로 차단되었어요. 차단은 %@ 후에 해제돼요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前的 IP 地址发生了过量的页面加载,因有使用爬虫程序的嫌疑已被暂时封禁。封禁将在 %@后解除。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你的 IP 因為頁面載入次數過於頻繁而被暫時禁止存取,這可能是因為你正在使用爬蟲/鏡像軟體,禁止存取將在 %@ 後解除" + } + } + } + }, + "language.afrikaans": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Afrikaans" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Afrikaan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アフリカーンス語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아프리칸스어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "南非语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "南非語" + } + } + } + }, + "language.albanian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Albanian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Albanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルバニア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알바니아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿尔巴尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿爾巴尼亞語" + } + } + } + }, + "language.arabic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Arabic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arabisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アラビア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아랍어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿拉伯语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿拉伯語" + } + } + } + }, + "language.bengali": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bengali" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bengali" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベンガル語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "벵갈어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "孟加拉语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "孟加拉語" + } + } + } + }, + "language.bosnian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bosnian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bosnisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボスニア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보스니아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "波斯尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波士尼亞語" + } + } + } + }, + "language.bulgarian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bulgarian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bulgarisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブルガリア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "불가리아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保加利亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "保加利亞語" + } + } + } + }, + "language.burmese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Burmese" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Birmanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ビルマ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "버마어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缅甸语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "緬甸語" + } + } + } + }, + "language.catalan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Catalan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Katalanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カタルーニャ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "카탈루냐어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加泰罗尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加泰隆尼亞語" + } + } + } + }, + "language.cebuano": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cebuano" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cebuano" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セブアノ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세부어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "宿雾語" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "宿霧語" + } + } + } + }, + "language.chinese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chinese" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chinesisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中国語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중국어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "汉语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "漢語" + } + } + } + }, + "language.croatian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Croatian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kroatisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クロアチア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크로아티아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "克罗地亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "克羅埃西亞語" + } + } + } + }, + "language.czech": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Czech" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tschechisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チェコ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "체코어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "捷克语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "捷克語" + } + } + } + }, + "language.danish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Danish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dänisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デンマーク語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "덴마크어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "丹麦语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "丹麥語" + } + } + } + }, + "language.dutch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dutch" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Niederländisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オランダ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네덜란드어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "荷兰语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "荷蘭語" + } + } + } + }, + "language.english": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "English" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Englisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "英語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "英语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "英語" + } + } + } + }, + "language.esperanto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Esperanto" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Esperanto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "国際語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "국제어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "国际语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "國際語" + } + } + } + }, + "language.estonian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Estonian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Estländisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エストニア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에스토니아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "爱沙尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "愛沙尼亞語" + } + } + } + }, + "language.finnish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finnish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Finnisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィンランド語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "핀란드어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "芬兰语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "芬蘭語" + } + } + } + }, + "language.french": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "French" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Französisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フランス語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프랑스어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "法语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "法語" + } + } + } + }, + "language.georgian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Georgian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Georgisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グルジア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그루지야어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "格鲁吉亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "喬治亞語" + } + } + } + }, + "language.german": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "German" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deutsch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドイツ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "독일어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "德语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "德語" + } + } + } + }, + "language.greek": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Greek" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Griechisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギリシア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그리스어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "希腊语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "希臘語" + } + } + } + }, + "language.hebrew": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hebrew" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hebräisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヘブライ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "히브리어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "希伯来语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "希伯來語" + } + } + } + }, + "language.hindi": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hindi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hindi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヒンディー語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "힌디어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "印地语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "印度語" + } + } + } + }, + "language.hmong": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hmong" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hmong" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ミャオ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "묘어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "苗语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "苗語" + } + } + } + }, + "language.hungarian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hungarian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungarisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ハンガリー語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "헝가리어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "匈牙利语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匈牙利語" + } + } + } + }, + "language.indonesian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Indonesian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Indonesisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インドネシア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인도네시아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "印度尼西亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "印尼語" + } + } + } + }, + "language.invalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "N/A" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "./." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "무효" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無效" + } + } + } + }, + "language.italian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Italian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Italian" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イタリア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이탈리아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "意大利语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "義大利語" + } + } + } + }, + "language.japanese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Japanese" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Japanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日本語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일본어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日語" + } + } + } + }, + "language.kazakh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kazakh" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kazakhstanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カザフ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "카자흐어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "哈萨克语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "哈薩克語" + } + } + } + }, + "language.khmer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Khmer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Khmer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クメール語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크메르원" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高棉文" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "高棉語" + } + } + } + }, + "language.korean": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Korean" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Koreanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "韓国語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한국어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "韩语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "韓語" + } + } + } + }, + "language.kurdish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kurdish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kurdisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クルド語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쿠르드어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "库尔德语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "庫德語" + } + } + } + }, + "language.lao": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lao" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lao" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラーオ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라오스어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "老挝语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "寮語" + } + } + } + }, + "language.latin": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Latin" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Latein" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラテン語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라틴어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拉丁语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拉丁語" + } + } + } + }, + "language.mongolian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mongolian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mongolisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モンゴル語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "몽골어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蒙古语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蒙古語" + } + } + } + }, + "language.ndebele": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ndebele" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ndebele" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ンデベレ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "은데벨리어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恩德贝莱语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "恩德貝萊語" + } + } + } + }, + "language.nepali": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nepali" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nepali" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネパール語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네팔어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尼泊尔语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尼泊爾語" + } + } + } + }, + "language.norwegian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Norwegian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Norwegisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ノルウェー語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "노르웨이어로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "挪威语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "挪威語" + } + } + } + }, + "language.oromo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Oromo" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oromo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オロモ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오로모어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "奥罗莫语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "奧羅莫語" + } + } + } + }, + "language.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其它" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "其它" + } + } + } + }, + "language.pashto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pashto" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pashto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パシュトー語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파슈토어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "普什图语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "普什圖語" + } + } + } + }, + "language.persian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Persian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Persisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペルシア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페르시아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "波斯语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波斯語" + } + } + } + }, + "language.polish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Polish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Polnisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポーランド語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴란드어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "波兰语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波蘭語" + } + } + } + }, + "language.portuguese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Portuguese" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Portugiesisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポルトガル語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포르투갈어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "葡萄牙语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "葡萄牙語" + } + } + } + }, + "language.punjabi": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Punjabi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Punjabi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パンジャーブ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "펀자브어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "旁遮普语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "旁遮普語" + } + } + } + }, + "language.romanian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Romanian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Rumänisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルーマニア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "루마니아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "罗马尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "羅馬尼亞語" + } + } + } + }, + "language.russian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Russian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Russisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ロシア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "러시아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "俄语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "俄語" + } + } + } + }, + "language.sango": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sango" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sango" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サンゴ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쌍고어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "桑戈语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "桑戈語" + } + } + } + }, + "language.serbian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Serbian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Serbisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セルビア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세르비아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塞尔维亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "塞爾維亞語" + } + } + } + }, + "language.shona": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shona" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Shona" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショナ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쇼나어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "绍纳语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "紹納語" + } + } + } + }, + "language.slovak": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Slovak" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Slovakisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スロバキア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "슬로바키아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯洛伐克语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯洛伐克語" + } + } + } + }, + "language.slovenian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Slovenian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Slovenisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スロベニア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "슬로베니아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯洛文尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯洛維尼亞語" + } + } + } + }, + "language.somali": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Somali" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Somali" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソマリ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소말리아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "索马里语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "索馬利語" + } + } + } + }, + "language.spanish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Spanish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Spanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スペイン語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스페인어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "西班牙语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "西班牙語" + } + } + } + }, + "language.swahili": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Swahili" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Swahili" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スワヒリ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스와히리어로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯瓦希里语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯瓦希里語" + } + } + } + }, + "language.swedish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Swedish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schwedisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スウェーデン語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스웨덴어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "瑞典语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瑞典語" + } + } + } + }, + "language.tagalog": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tagalog" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tagalog" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タガログ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "타갈로어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "他加洛语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "他加祿語" + } + } + } + }, + "language.thai": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thai" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Thai" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "타이어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "泰语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "泰語" + } + } + } + }, + "language.tigrinya": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tigrinya" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tigrinya" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ティグリニャ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "티글리니아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "提格利尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "提格利尼亞語" + } + } + } + }, + "language.turkish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turkish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Türkisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トルコ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터키어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "土耳其语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "土耳其語" + } + } + } + }, + "language.ukrainian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ukrainian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ukrainisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウクライナ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우크라이나어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乌克兰语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "烏克蘭語" + } + } + } + }, + "language.urdu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Urdu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Urdu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウルドゥー語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우르두어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乌尔都语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "烏爾都語" + } + } + } + }, + "language.vietnamese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vietnamese" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vietnamesisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベトナム語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "베트남어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "越南语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "越南語" + } + } + } + }, + "language.zulu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zulu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zulu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ズールー語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "줄루어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "祖鲁语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "祖魯語" + } + } + } + }, + "list_display_mode.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detail" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Details" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자세히" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "详情" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "詳細" + } + } + } + }, + "list_display_mode.thumbnail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thumbnail" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschaubilder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サムネイル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "썸네일" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩略图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮圖" + } + } + } + }, + "load_through_hath_setting.any_client": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Any client" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jeder Client" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "任意のクライアント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "어떤 클라이언트에서든" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所有客户端" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "任何用戶端" + } + } + } + }, + "load_through_hath_setting.any_client_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recommended." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Empfohlen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "推奨。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추천." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "推荐。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建議選項(預設)" + } + } + } + }, + "load_through_hath_setting.default_port_only": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default port clients only" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Clients mit Standardport" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトポートのクライアントのみ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 포트 클라이언트만" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅使用默认端口的客户端" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只使用預設連接埠的用戶端" + } + } + } + }, + "load_through_hath_setting.default_port_only_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kann langsamer sein. Aktiviere dies nur, wenn eine Firewall oder ein Proxy ausgehende Nicht-Standard-Ports blockiert." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "遅くなることがあります。非標準発信ポートがファイヤーウォール・プロキシにブロックされた場合のみ有効にしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "더 느려질 수 있어요. 나가는 비표준 포트를 차단하는 방화벽/프록시가 있는 경우 사용하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可能稍慢。当防火墙或代理阻止非标准接口的流量时启用此项。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如果網路防火牆會阻擋任何非預設傳出連接埠則使用這個選項(可能較慢)" + } + } + } + }, + "load_through_hath_setting.legacy_no": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No [Legacy/HTTP]" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nein [Legacy/HTTP]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使わない [レガシー / HTTP]" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아닙니다 [Legacy/HTTP]" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不通过 [旧式 / HTTP]" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "No [傳統/Legacy/HTTP]" + } + } + } + }, + "load_through_hath_setting.legacy_no_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur für Spender. Funktioniert in modernen Browsern unter Umständen nicht. Nur für alte oder veraltete Browser empfohlen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "寄付者独占オプション。モダンブラウザでは機能しないこともあります。レガシー・旧型ブラウザの場合以外おすすめしません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기부자 전용 기능이에요. 최신 브라우저에서는 제대로 작동하지 않을 수 있어요. 오래된 브라우저에서만 사용해주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅限赞助者。在现代浏览器可能不可用。只建议在旧式 / 过时的浏览器使用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "E-Hentai 贊助者功能: 在現代瀏覽器上預設設定可能無法正常工作,僅推薦用於傳統瀏覽器" + } + } + } + }, + "load_through_hath_setting.modern_no": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No [Modern/HTTPS]" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nein [Modern/HTTPS]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使わない [モダン / HTTPS]" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아닙니다 [Modern/HTTPS]" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不通过 [现代 / HTTPS]" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "No [現代/Modern/HTTPS]" + } + } + } + }, + "load_through_hath_setting.modern_no_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur für Spender. Du kannst damit weniger Seiten aufrufen. Nur bei schwerwiegenden Problemen empfohlen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "寄付者独占オプション。閲覧による割当額の消耗は激しくなります。厳重な問題が起こった場合以外おすすめしません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기부자 전용 기능이에요. 심각한 문제가 있는 경우를 제외하고는 사용하지 말아주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅限赞助者。配额消耗会加快。只建议在遇到严重问题时使用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "E-Hentai 贊助者功能: 你將無法同時瀏覽多個頁面,僅在出現重大錯誤時才啟用這個選項" + } + } + } + }, + "multiple_page_viewer_style.align_center_always_scale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Align center, always scale" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zentriert, immer skalieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中央揃え、常時スケール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가운데 정렬, 항상 크기 맞추기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "居中对齐,图像始终缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "置中對齊且總是縮放" + } + } + } + }, + "multiple_page_viewer_style.align_center_scale_if_over_width": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Align center, scale if overwidth" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zentriert, bei Überbreite skalieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中央揃え、幅によってスケール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가운데 정렬, 너비 초과할 때 크기 맞추기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "居中对齐,图像过宽时缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "置中對齊,若寬度超出頁面則進行縮放" + } + } + } + }, + "multiple_page_viewer_style.align_left_scale_if_over_width": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Align left, scale if overwidth" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Linksbündig, bei Überbreite skalieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左寄せ、幅によってスケール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽 정렬, 너비 초과할 때 크기 맞추기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "左对齐,图像过宽时缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向左對齊,若寬度超出頁面則進行縮放" + } + } + } + }, + "network_error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A network error occurred." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Netzwerkfehler ist aufgetreten." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク障害が発生しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인터넷 접속 오류가 발생했어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发生了网络故障" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網路發生故障,請檢查網路狀態" + } + } + } + }, + "not_found": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "There seems to be nothing here." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hier scheint es nichts zu geben." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここには何もないようです" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여기가 아무도 없는 것 같습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这里似乎什么也没有" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這邊看起來空空如也" + } + } + } + }, + "parsing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A parsing error occurred." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Parserfehler ist aufgetreten." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "解析中に問題が発生しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "구분 분석 오류가 발생했어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发生了解析错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網頁解析器發生故障" + } + } + } + }, + "preferred_color_scheme.automatic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "preferred_color_scheme.dark": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dunkel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + } + } + }, + "preferred_color_scheme.light": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hell" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라이트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浅色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "淺色" + } + } + } + }, + "reading_direction.left_to_right": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Left-to-right" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Von links nach rechts" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左開き" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽에서 오른쪽으로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "左至右" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "由左至右滑" + } + } + } + }, + "reading_direction.right_to_left": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Right-to-left" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Von rechts nach links" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右開き" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽에서 왼쪽으로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "右至左" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "由右至左滑" + } + } + } + }, + "reading_direction.vertical": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vertical" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vertikal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "縦読み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "위에서 아래로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "垂直" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "垂直" + } + } + } + }, + "tag_namespace.artist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Artist" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Künstler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作者" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작가" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "作者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "作者" + } + } + } + }, + "tag_namespace.character": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Character" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Charakter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캐릭터" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "角色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "角色" + } + } + } + }, + "tag_namespace.cosplayer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cosplayer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cosplayer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レイヤー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cosplayer" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扮装者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cosplayer" + } + } + } + }, + "tag_namespace.female": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Female" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weiblich" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "女性" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여성" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "女性" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "女性" + } + } + } + }, + "tag_namespace.group": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Group" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gruppe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "団体" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그룹" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "团体" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "團體" + } + } + } + }, + "tag_namespace.language": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sprache" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "言語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "언어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言" + } + } + } + }, + "tag_namespace.male": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Male" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Männlich" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "男性" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "남성" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "男性" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "男性" + } + } + } + }, + "tag_namespace.mixed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mixed" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mixed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "混在性別" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Mixed" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "混合性别" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Mixed" + } + } + } + }, + "tag_namespace.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其它" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "其他" + } + } + } + }, + "tag_namespace.parody": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Parody" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Parodie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "原作" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原作" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原作" + } + } + } + }, + "tag_namespace.reclass": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reclass" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reclass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再分類" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재분류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重归类" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新分類" + } + } + } + }, + "tag_namespace.temp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Temp" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Temp" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一時的" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Temp" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "临时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Temp" + } + } + } + }, + "tags_sort_order.alphabetical": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Alphabetical" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alphabetisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルファベット順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알파벳순으로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按字母排序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "字母順序" + } + } + } + }, + "tags_sort_order.tag_power": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By tag power" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach Tag-Gewicht" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグパワーの高い順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태크 가중치로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按标签权重" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤權重" + } + } + } + }, + "thumbnail_load_timing.on_mouse_over": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On mouse-over" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bei Mouseover" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マウス経過時" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마우스를 올릴 때" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "鼠标悬停时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "滑鼠位置" + } + } + } + }, + "thumbnail_load_timing.on_mouse_over_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pages load faster, but there may be a slight delay before a thumb appears." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seiten laden schneller, Vorschaubilder erscheinen aber unter Umständen leicht verzögert." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページの読み込みが速くなりますが、サムネイルの表示はちょっぴり遅れてきます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지가 더 빨리 로드되지만 엄지손가락이 나타나기 전까지 약간의 지연이 있을 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页面加载快,缩略图加载有延迟。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網頁的載入速度更快,但縮圖出現的時間可能稍有延遲" + } + } + } + }, + "thumbnail_load_timing.on_page_load": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On page load" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Laden der Seite" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ読み込み時" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 로드될 때" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页面加载时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網頁載入位置" + } + } + } + }, + "thumbnail_load_timing.on_page_load_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seiten brauchen länger zum Laden, dafür erscheinen die Vorschaubilder danach ohne Verzögerung." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページの読み込み時間が増えますが、サムネイルはすぐに表示できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 로드에 시간이 더 오래 걸리지만, 페이지가 로드된 후 썸네일을 로드하는데 지연이 없어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页面加载时间更长,显示缩略图无需等待。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網頁需要更多的載入時間,但是在網頁完全載入後縮圖顯示不會有任何延遲" + } + } + } + }, + "thumbnail_size.auto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "thumbnail_size.large": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Large" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Groß" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "大きめ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크게" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "较大" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "大型" + } + } + } + }, + "thumbnail_size.normal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "普通" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보통" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "普通" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正常" + } + } + } + }, + "thumbnail_size.small": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Small" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klein" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "小さめ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작게" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "较小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "小型" + } + } + } + }, + "toplists_type.all_time": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All time" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesamt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所有時間" + } + } + } + }, + "toplists_type.past_month": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Past month" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Letzter Monat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "先月" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지난 달" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上個月" + } + } + } + }, + "toplists_type.past_year": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Past year" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Letztes Jahr" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "去年" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지난 해" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "去年" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "去年" + } + } + } + }, + "toplists_type.yesterday": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yesterday" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gestern" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "昨日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "어제" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "昨日" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "昨天" + } + } + } + }, + "try_later": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Please try again later." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bitte versuche es später erneut." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "しばらくしてからもう一度お試しください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잠시 후 다시 시도해 주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请稍后再试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請稍後再試" + } + } + } + }, + "unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An unknown error occurred." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein unbekannter Fehler ist aufgetreten." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なエラーが発生しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없는 오류가 발생했어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发生了未知错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "發生不明錯誤" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/AppModels/Support/AppActivityLog.swift b/AppPackage/Sources/AppModels/Support/AppActivityLog.swift index 194be1297..aa357c43f 100644 --- a/AppPackage/Sources/AppModels/Support/AppActivityLog.swift +++ b/AppPackage/Sources/AppModels/Support/AppActivityLog.swift @@ -84,12 +84,12 @@ public extension OSLogEntryLog.Level { var title: String { switch self { - case .undefined: L10n.Localizable.AppActivityLogsView.Level.undefined - case .debug: L10n.Localizable.AppActivityLogsView.Level.debug - case .info: L10n.Localizable.AppActivityLogsView.Level.info - case .notice: L10n.Localizable.AppActivityLogsView.Level.notice - case .error: L10n.Localizable.AppActivityLogsView.Level.error - case .fault: L10n.Localizable.AppActivityLogsView.Level.fault + case .undefined: String(localized: .appActivityLogLevelUndefined) + case .debug: String(localized: .appActivityLogLevelDebug) + case .info: String(localized: .appActivityLogLevelInfo) + case .notice: String(localized: .appActivityLogLevelNotice) + case .error: String(localized: .appActivityLogLevelError) + case .fault: String(localized: .appActivityLogLevelFault) @unknown default: "" } } diff --git a/AppPackage/Sources/AppModels/Support/AppError.swift b/AppPackage/Sources/AppModels/Support/AppError.swift index 52d09384e..8aa1fe1ef 100644 --- a/AppPackage/Sources/AppModels/Support/AppError.swift +++ b/AppPackage/Sources/AppModels/Support/AppError.swift @@ -37,71 +37,71 @@ extension AppError { public var localizedDescription: String { switch self { case .databaseCorrupted: - return L10n.Localizable.AppError.databaseCorrupted + return String(localized: .appErrorDatabaseCorrupted) case .copyrightClaim: - return L10n.Localizable.AppError.copyrightClaim + return String(localized: .appErrorCopyrightClaim) case .ipBanned: - return L10n.Localizable.AppError.ipBanned + return String(localized: .appErrorIpBanned) case .expunged: - return L10n.Localizable.AppError.galleryExpunged + return String(localized: .appErrorGalleryExpunged) case .networkingFailed: - return L10n.Localizable.AppError.networkError + return String(localized: .appErrorNetworkError) case .webImageFailed: - return L10n.Localizable.AppError.webImageLoadingError + return String(localized: .appErrorWebImageLoadingError) case .parseFailed: - return L10n.Localizable.AppError.parseError + return String(localized: .appErrorParseError) case .quotaExceeded: - return L10n.Localizable.AppError.quotaExceeded + return String(localized: .appErrorQuotaExceeded) case .authenticationRequired: - return L10n.Localizable.AppError.authenticationRequired + return String(localized: .appErrorAuthenticationRequired) case .fileOperationFailed: - return L10n.Localizable.AppError.fileOperationFailed + return String(localized: .appErrorFileOperationFailed) case .noUpdates: - return L10n.Localizable.AppError.noUpdatesAvailable + return String(localized: .appErrorNoUpdatesAvailable) case .notFound: - return L10n.Localizable.AppError.notFound + return String(localized: .appErrorNotFound) case .unknown: - return L10n.Localizable.AppError.unknownError + return String(localized: .appErrorUnknownError) } } public var alertText: String { - let tryLater = L10n.Localizable.ErrorView.tryLater + let tryLater = String(localized: .tryLater) switch self { case .databaseCorrupted(let reason): - var lines = [L10n.Localizable.ErrorView.databaseCorrupted] + var lines = [String(localized: .databaseCorrupted)] if let reason = reason { lines.append("(\(reason))") } return lines.joined(separator: "\n") case .copyrightClaim(let owner): - return L10n.Localizable.ErrorView.copyrightClaim(owner) + return String(localized: .copyrightClaim(owner)) case .ipBanned(let interval): - return L10n.Localizable.ErrorView.ipBanned(interval.description) + return String(localized: .ipBanned(interval.description)) case .expunged(let reason): switch reason { case L10n.Constant.galleryUnavailable: - return L10n.Localizable.ErrorView.galleryUnavailable + return String(localized: .galleryUnavailable) default: return reason } case .networkingFailed: - return [L10n.Localizable.ErrorView.network, tryLater].joined(separator: "\n") + return [String(localized: .networkError), tryLater].joined(separator: "\n") case .parseFailed: - return [L10n.Localizable.ErrorView.parsing, tryLater].joined(separator: "\n") + return [String(localized: .parsing), tryLater].joined(separator: "\n") case .quotaExceeded: - return L10n.Localizable.AppError.quotaExceededDescription + return String(localized: .appErrorQuotaExceededDescription) case .authenticationRequired: - return L10n.Localizable.AppError.authenticationRequiredDescription + return String(localized: .appErrorAuthenticationRequiredDescription) case .fileOperationFailed(let reason): - return [L10n.Localizable.AppError.localFileOperationFailed, reason] + return [String(localized: .appErrorLocalFileOperationFailed), reason] .filter { !$0.isEmpty } .joined(separator: "\n") case .noUpdates, .webImageFailed: return "" case .notFound: - return L10n.Localizable.ErrorView.notFound + return String(localized: .notFound) case .unknown: - return [L10n.Localizable.ErrorView.unknown, tryLater].joined(separator: "\n") + return [String(localized: .unknown), tryLater].joined(separator: "\n") } } } @@ -116,7 +116,7 @@ public enum BanInterval: Equatable, Hashable, Sendable { extension BanInterval { public var description: String { var params: [String] - let and = L10n.Localizable.BanInterval.and + let and = String(localized: .banIntervalAnd) switch self { case .days(let days, let hours): diff --git a/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift b/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift index d9748a2dd..6306c8975 100644 --- a/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift +++ b/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift @@ -12,258 +12,258 @@ extension EhSetting.BrowsingCountry { public var id: Int { hashValue } public var name: String { switch self { - case .autoDetect: return L10n.Localizable.BrowsingCountry.autoDetect - case .afghanistan: return L10n.Localizable.BrowsingCountry.afghanistan - case .alandIslands: return L10n.Localizable.BrowsingCountry.alandIslands - case .albania: return L10n.Localizable.BrowsingCountry.albania - case .algeria: return L10n.Localizable.BrowsingCountry.algeria - case .americanSamoa: return L10n.Localizable.BrowsingCountry.americanSamoa - case .andorra: return L10n.Localizable.BrowsingCountry.andorra - case .angola: return L10n.Localizable.BrowsingCountry.angola - case .anguilla: return L10n.Localizable.BrowsingCountry.anguilla - case .antarctica: return L10n.Localizable.BrowsingCountry.antarctica - case .antiguaAndBarbuda: return L10n.Localizable.BrowsingCountry.antiguaAndBarbuda - case .argentina: return L10n.Localizable.BrowsingCountry.argentina - case .armenia: return L10n.Localizable.BrowsingCountry.armenia - case .aruba: return L10n.Localizable.BrowsingCountry.aruba - case .asiaPacificRegion: return L10n.Localizable.BrowsingCountry.asiaPacificRegion - case .australia: return L10n.Localizable.BrowsingCountry.australia - case .austria: return L10n.Localizable.BrowsingCountry.austria - case .azerbaijan: return L10n.Localizable.BrowsingCountry.azerbaijan - case .bahamas: return L10n.Localizable.BrowsingCountry.bahamas - case .bahrain: return L10n.Localizable.BrowsingCountry.bahrain - case .bangladesh: return L10n.Localizable.BrowsingCountry.bangladesh - case .barbados: return L10n.Localizable.BrowsingCountry.barbados - case .belarus: return L10n.Localizable.BrowsingCountry.belarus - case .belgium: return L10n.Localizable.BrowsingCountry.belgium - case .belize: return L10n.Localizable.BrowsingCountry.belize - case .benin: return L10n.Localizable.BrowsingCountry.benin - case .bermuda: return L10n.Localizable.BrowsingCountry.bermuda - case .bhutan: return L10n.Localizable.BrowsingCountry.bhutan - case .bolivia: return L10n.Localizable.BrowsingCountry.bolivia - case .bonaireSaintEustatiusAndSaba: return L10n.Localizable.BrowsingCountry.bonaireSaintEustatiusAndSaba - case .bosniaAndHerzegovina: return L10n.Localizable.BrowsingCountry.bosniaAndHerzegovina - case .botswana: return L10n.Localizable.BrowsingCountry.botswana - case .bouvetIsland: return L10n.Localizable.BrowsingCountry.bouvetIsland - case .brazil: return L10n.Localizable.BrowsingCountry.brazil - case .britishIndianOceanTerritory: return L10n.Localizable.BrowsingCountry.britishIndianOceanTerritory - case .bruneiDarussalam: return L10n.Localizable.BrowsingCountry.bruneiDarussalam - case .bulgaria: return L10n.Localizable.BrowsingCountry.bulgaria - case .burkinaFaso: return L10n.Localizable.BrowsingCountry.burkinaFaso - case .burundi: return L10n.Localizable.BrowsingCountry.burundi - case .cambodia: return L10n.Localizable.BrowsingCountry.cambodia - case .cameroon: return L10n.Localizable.BrowsingCountry.cameroon - case .canada: return L10n.Localizable.BrowsingCountry.canada - case .capeVerde: return L10n.Localizable.BrowsingCountry.capeVerde - case .caymanIslands: return L10n.Localizable.BrowsingCountry.caymanIslands - case .centralAfricanRepublic: return L10n.Localizable.BrowsingCountry.centralAfricanRepublic - case .chad: return L10n.Localizable.BrowsingCountry.chad - case .chile: return L10n.Localizable.BrowsingCountry.chile - case .china: return L10n.Localizable.BrowsingCountry.china - case .christmasIsland: return L10n.Localizable.BrowsingCountry.christmasIsland - case .cocosIslands: return L10n.Localizable.BrowsingCountry.cocosIslands - case .colombia: return L10n.Localizable.BrowsingCountry.colombia - case .comoros: return L10n.Localizable.BrowsingCountry.comoros - case .congo: return L10n.Localizable.BrowsingCountry.congo - case .theDemocraticRepublicOfTheCongo: return L10n.Localizable.BrowsingCountry.theDemocraticRepublicOfTheCongo - case .cookIslands: return L10n.Localizable.BrowsingCountry.cookIslands - case .costaRica: return L10n.Localizable.BrowsingCountry.costaRica - case .coteDIvoire: return L10n.Localizable.BrowsingCountry.coteDIvoire - case .croatia: return L10n.Localizable.BrowsingCountry.croatia - case .cuba: return L10n.Localizable.BrowsingCountry.cuba - case .curacao: return L10n.Localizable.BrowsingCountry.curacao - case .cyprus: return L10n.Localizable.BrowsingCountry.cyprus - case .czechRepublic: return L10n.Localizable.BrowsingCountry.czechRepublic - case .denmark: return L10n.Localizable.BrowsingCountry.denmark - case .djibouti: return L10n.Localizable.BrowsingCountry.djibouti - case .dominica: return L10n.Localizable.BrowsingCountry.dominica - case .dominicanRepublic: return L10n.Localizable.BrowsingCountry.dominicanRepublic - case .ecuador: return L10n.Localizable.BrowsingCountry.ecuador - case .egypt: return L10n.Localizable.BrowsingCountry.egypt - case .elSalvador: return L10n.Localizable.BrowsingCountry.elSalvador - case .equatorialGuinea: return L10n.Localizable.BrowsingCountry.equatorialGuinea - case .eritrea: return L10n.Localizable.BrowsingCountry.eritrea - case .estonia: return L10n.Localizable.BrowsingCountry.estonia - case .ethiopia: return L10n.Localizable.BrowsingCountry.ethiopia - case .europe: return L10n.Localizable.BrowsingCountry.europe - case .falklandIslands: return L10n.Localizable.BrowsingCountry.falklandIslands - case .faroeIslands: return L10n.Localizable.BrowsingCountry.faroeIslands - case .fiji: return L10n.Localizable.BrowsingCountry.fiji - case .finland: return L10n.Localizable.BrowsingCountry.finland - case .france: return L10n.Localizable.BrowsingCountry.france - case .frenchGuiana: return L10n.Localizable.BrowsingCountry.frenchGuiana - case .frenchPolynesia: return L10n.Localizable.BrowsingCountry.frenchPolynesia - case .frenchSouthernTerritories: return L10n.Localizable.BrowsingCountry.frenchSouthernTerritories - case .gabon: return L10n.Localizable.BrowsingCountry.gabon - case .gambia: return L10n.Localizable.BrowsingCountry.gambia - case .georgia: return L10n.Localizable.BrowsingCountry.georgia - case .germany: return L10n.Localizable.BrowsingCountry.germany - case .ghana: return L10n.Localizable.BrowsingCountry.ghana - case .gibraltar: return L10n.Localizable.BrowsingCountry.gibraltar - case .greece: return L10n.Localizable.BrowsingCountry.greece - case .greenland: return L10n.Localizable.BrowsingCountry.greenland - case .grenada: return L10n.Localizable.BrowsingCountry.grenada - case .guadeloupe: return L10n.Localizable.BrowsingCountry.guadeloupe - case .guam: return L10n.Localizable.BrowsingCountry.guam - case .guatemala: return L10n.Localizable.BrowsingCountry.guatemala - case .guernsey: return L10n.Localizable.BrowsingCountry.guernsey - case .guinea: return L10n.Localizable.BrowsingCountry.guinea - case .guineaBissau: return L10n.Localizable.BrowsingCountry.guineaBissau - case .guyana: return L10n.Localizable.BrowsingCountry.guyana - case .haiti: return L10n.Localizable.BrowsingCountry.haiti - case .heardIslandAndMcDonaldIslands: return L10n.Localizable.BrowsingCountry.heardIslandAndMcDonaldIslands - case .vaticanCityState: return L10n.Localizable.BrowsingCountry.vaticanCityState - case .honduras: return L10n.Localizable.BrowsingCountry.honduras - case .hongKong: return L10n.Localizable.BrowsingCountry.hongKong - case .hungary: return L10n.Localizable.BrowsingCountry.hungary - case .iceland: return L10n.Localizable.BrowsingCountry.iceland - case .india: return L10n.Localizable.BrowsingCountry.india - case .indonesia: return L10n.Localizable.BrowsingCountry.indonesia - case .iran: return L10n.Localizable.BrowsingCountry.iran - case .iraq: return L10n.Localizable.BrowsingCountry.iraq - case .ireland: return L10n.Localizable.BrowsingCountry.ireland - case .isleOfMan: return L10n.Localizable.BrowsingCountry.isleOfMan - case .israel: return L10n.Localizable.BrowsingCountry.israel - case .italy: return L10n.Localizable.BrowsingCountry.italy - case .jamaica: return L10n.Localizable.BrowsingCountry.jamaica - case .japan: return L10n.Localizable.BrowsingCountry.japan - case .jersey: return L10n.Localizable.BrowsingCountry.jersey - case .jordan: return L10n.Localizable.BrowsingCountry.jordan - case .kazakhstan: return L10n.Localizable.BrowsingCountry.kazakhstan - case .kenya: return L10n.Localizable.BrowsingCountry.kenya - case .kiribati: return L10n.Localizable.BrowsingCountry.kiribati - case .kuwait: return L10n.Localizable.BrowsingCountry.kuwait - case .kyrgyzstan: return L10n.Localizable.BrowsingCountry.kyrgyzstan - case .laoPeoplesDemocraticRepublic: return L10n.Localizable.BrowsingCountry.laoPeoplesDemocraticRepublic - case .latvia: return L10n.Localizable.BrowsingCountry.latvia - case .lebanon: return L10n.Localizable.BrowsingCountry.lebanon - case .lesotho: return L10n.Localizable.BrowsingCountry.lesotho - case .liberia: return L10n.Localizable.BrowsingCountry.liberia - case .libya: return L10n.Localizable.BrowsingCountry.libya - case .liechtenstein: return L10n.Localizable.BrowsingCountry.liechtenstein - case .lithuania: return L10n.Localizable.BrowsingCountry.lithuania - case .luxembourg: return L10n.Localizable.BrowsingCountry.luxembourg - case .macau: return L10n.Localizable.BrowsingCountry.macau - case .macedonia: return L10n.Localizable.BrowsingCountry.macedonia - case .madagascar: return L10n.Localizable.BrowsingCountry.madagascar - case .malawi: return L10n.Localizable.BrowsingCountry.malawi - case .malaysia: return L10n.Localizable.BrowsingCountry.malaysia - case .maldives: return L10n.Localizable.BrowsingCountry.maldives - case .mali: return L10n.Localizable.BrowsingCountry.mali - case .malta: return L10n.Localizable.BrowsingCountry.malta - case .marshallIslands: return L10n.Localizable.BrowsingCountry.marshallIslands - case .martinique: return L10n.Localizable.BrowsingCountry.martinique - case .mauritania: return L10n.Localizable.BrowsingCountry.mauritania - case .mauritius: return L10n.Localizable.BrowsingCountry.mauritius - case .mayotte: return L10n.Localizable.BrowsingCountry.mayotte - case .mexico: return L10n.Localizable.BrowsingCountry.mexico - case .micronesia: return L10n.Localizable.BrowsingCountry.micronesia - case .moldova: return L10n.Localizable.BrowsingCountry.moldova - case .monaco: return L10n.Localizable.BrowsingCountry.monaco - case .mongolia: return L10n.Localizable.BrowsingCountry.mongolia - case .montenegro: return L10n.Localizable.BrowsingCountry.montenegro - case .montserrat: return L10n.Localizable.BrowsingCountry.montserrat - case .morocco: return L10n.Localizable.BrowsingCountry.morocco - case .mozambique: return L10n.Localizable.BrowsingCountry.mozambique - case .myanmar: return L10n.Localizable.BrowsingCountry.myanmar - case .namibia: return L10n.Localizable.BrowsingCountry.namibia - case .nauru: return L10n.Localizable.BrowsingCountry.nauru - case .nepal: return L10n.Localizable.BrowsingCountry.nepal - case .netherlands: return L10n.Localizable.BrowsingCountry.netherlands - case .newCaledonia: return L10n.Localizable.BrowsingCountry.newCaledonia - case .newZealand: return L10n.Localizable.BrowsingCountry.newZealand - case .nicaragua: return L10n.Localizable.BrowsingCountry.nicaragua - case .niger: return L10n.Localizable.BrowsingCountry.niger - case .nigeria: return L10n.Localizable.BrowsingCountry.nigeria - case .niue: return L10n.Localizable.BrowsingCountry.niue - case .norfolkIsland: return L10n.Localizable.BrowsingCountry.norfolkIsland - case .northKorea: return L10n.Localizable.BrowsingCountry.northKorea - case .northernMarianaIslands: return L10n.Localizable.BrowsingCountry.northernMarianaIslands - case .norway: return L10n.Localizable.BrowsingCountry.norway - case .oman: return L10n.Localizable.BrowsingCountry.oman - case .pakistan: return L10n.Localizable.BrowsingCountry.pakistan - case .palau: return L10n.Localizable.BrowsingCountry.palau - case .palestinianTerritory: return L10n.Localizable.BrowsingCountry.palestinianTerritory - case .panama: return L10n.Localizable.BrowsingCountry.panama - case .papuaNewGuinea: return L10n.Localizable.BrowsingCountry.papuaNewGuinea - case .paraguay: return L10n.Localizable.BrowsingCountry.paraguay - case .peru: return L10n.Localizable.BrowsingCountry.peru - case .philippines: return L10n.Localizable.BrowsingCountry.philippines - case .pitcairnIslands: return L10n.Localizable.BrowsingCountry.pitcairnIslands - case .poland: return L10n.Localizable.BrowsingCountry.poland - case .portugal: return L10n.Localizable.BrowsingCountry.portugal - case .puertoRico: return L10n.Localizable.BrowsingCountry.puertoRico - case .qatar: return L10n.Localizable.BrowsingCountry.qatar - case .reunion: return L10n.Localizable.BrowsingCountry.reunion - case .romania: return L10n.Localizable.BrowsingCountry.romania - case .russianFederation: return L10n.Localizable.BrowsingCountry.russianFederation - case .rwanda: return L10n.Localizable.BrowsingCountry.rwanda - case .saintBarthelemy: return L10n.Localizable.BrowsingCountry.saintBarthelemy - case .saintHelena: return L10n.Localizable.BrowsingCountry.saintHelena - case .saintKittsAndNevis: return L10n.Localizable.BrowsingCountry.saintKittsAndNevis - case .saintLucia: return L10n.Localizable.BrowsingCountry.saintLucia - case .saintMartin: return L10n.Localizable.BrowsingCountry.saintMartin - case .saintPierreAndMiquelon: return L10n.Localizable.BrowsingCountry.saintPierreAndMiquelon - case .saintVincentAndTheGrenadines: return L10n.Localizable.BrowsingCountry.saintVincentAndTheGrenadines - case .samoa: return L10n.Localizable.BrowsingCountry.samoa - case .sanMarino: return L10n.Localizable.BrowsingCountry.sanMarino - case .saoTomeAndPrincipe: return L10n.Localizable.BrowsingCountry.saoTomeAndPrincipe - case .saudiArabia: return L10n.Localizable.BrowsingCountry.saudiArabia - case .senegal: return L10n.Localizable.BrowsingCountry.senegal - case .serbia: return L10n.Localizable.BrowsingCountry.serbia - case .seychelles: return L10n.Localizable.BrowsingCountry.seychelles - case .sierraLeone: return L10n.Localizable.BrowsingCountry.sierraLeone - case .singapore: return L10n.Localizable.BrowsingCountry.singapore - case .sintMaarten: return L10n.Localizable.BrowsingCountry.sintMaarten - case .slovakia: return L10n.Localizable.BrowsingCountry.slovakia - case .slovenia: return L10n.Localizable.BrowsingCountry.slovenia - case .solomonIslands: return L10n.Localizable.BrowsingCountry.solomonIslands - case .somalia: return L10n.Localizable.BrowsingCountry.somalia - case .southAfrica: return L10n.Localizable.BrowsingCountry.southAfrica - case .southGeorgiaAndTheSouthSandwichIslands: return L10n.Localizable.BrowsingCountry.southGeorgiaAndTheSouthSandwichIslands - case .southKorea: return L10n.Localizable.BrowsingCountry.southKorea - case .southSudan: return L10n.Localizable.BrowsingCountry.southSudan - case .spain: return L10n.Localizable.BrowsingCountry.spain - case .sriLanka: return L10n.Localizable.BrowsingCountry.sriLanka - case .sudan: return L10n.Localizable.BrowsingCountry.sudan - case .suriname: return L10n.Localizable.BrowsingCountry.suriname - case .svalbardAndJanMayen: return L10n.Localizable.BrowsingCountry.svalbardAndJanMayen - case .swaziland: return L10n.Localizable.BrowsingCountry.swaziland - case .sweden: return L10n.Localizable.BrowsingCountry.sweden - case .switzerland: return L10n.Localizable.BrowsingCountry.switzerland - case .syrianArabRepublic: return L10n.Localizable.BrowsingCountry.syrianArabRepublic - case .taiwan: return L10n.Localizable.BrowsingCountry.taiwan - case .tajikistan: return L10n.Localizable.BrowsingCountry.tajikistan - case .tanzania: return L10n.Localizable.BrowsingCountry.tanzania - case .thailand: return L10n.Localizable.BrowsingCountry.thailand - case .timorLeste: return L10n.Localizable.BrowsingCountry.timorLeste - case .togo: return L10n.Localizable.BrowsingCountry.togo - case .tokelau: return L10n.Localizable.BrowsingCountry.tokelau - case .tonga: return L10n.Localizable.BrowsingCountry.tonga - case .trinidadAndTobago: return L10n.Localizable.BrowsingCountry.trinidadAndTobago - case .tunisia: return L10n.Localizable.BrowsingCountry.tunisia - case .turkey: return L10n.Localizable.BrowsingCountry.turkey - case .turkmenistan: return L10n.Localizable.BrowsingCountry.turkmenistan - case .turksAndCaicosIslands: return L10n.Localizable.BrowsingCountry.turksAndCaicosIslands - case .tuvalu: return L10n.Localizable.BrowsingCountry.tuvalu - case .uganda: return L10n.Localizable.BrowsingCountry.uganda - case .ukraine: return L10n.Localizable.BrowsingCountry.ukraine - case .unitedArabEmirates: return L10n.Localizable.BrowsingCountry.unitedArabEmirates - case .unitedKingdom: return L10n.Localizable.BrowsingCountry.unitedKingdom - case .unitedStates: return L10n.Localizable.BrowsingCountry.unitedStates - case .unitedStatesMinorOutlyingIslands: return L10n.Localizable.BrowsingCountry.unitedStatesMinorOutlyingIslands - case .uruguay: return L10n.Localizable.BrowsingCountry.uruguay - case .uzbekistan: return L10n.Localizable.BrowsingCountry.uzbekistan - case .vanuatu: return L10n.Localizable.BrowsingCountry.vanuatu - case .venezuela: return L10n.Localizable.BrowsingCountry.venezuela - case .vietnam: return L10n.Localizable.BrowsingCountry.vietnam - case .virginIslandsBritish: return L10n.Localizable.BrowsingCountry.virginIslandsBritish - case .virginIslandsUS: return L10n.Localizable.BrowsingCountry.virginIslandsUS - case .wallisAndFutuna: return L10n.Localizable.BrowsingCountry.wallisAndFutuna - case .westernSahara: return L10n.Localizable.BrowsingCountry.westernSahara - case .yemen: return L10n.Localizable.BrowsingCountry.yemen - case .zambia: return L10n.Localizable.BrowsingCountry.zambia - case .zimbabwe: return L10n.Localizable.BrowsingCountry.zimbabwe + case .autoDetect: return String(localized: .browsingCountryAutoDetect) + case .afghanistan: return String(localized: .browsingCountryAfghanistan) + case .alandIslands: return String(localized: .browsingCountryAlandIslands) + case .albania: return String(localized: .browsingCountryAlbania) + case .algeria: return String(localized: .browsingCountryAlgeria) + case .americanSamoa: return String(localized: .browsingCountryAmericanSamoa) + case .andorra: return String(localized: .browsingCountryAndorra) + case .angola: return String(localized: .browsingCountryAngola) + case .anguilla: return String(localized: .browsingCountryAnguilla) + case .antarctica: return String(localized: .browsingCountryAntarctica) + case .antiguaAndBarbuda: return String(localized: .browsingCountryAntiguaAndBarbuda) + case .argentina: return String(localized: .browsingCountryArgentina) + case .armenia: return String(localized: .browsingCountryArmenia) + case .aruba: return String(localized: .browsingCountryAruba) + case .asiaPacificRegion: return String(localized: .browsingCountryAsiaPacificRegion) + case .australia: return String(localized: .browsingCountryAustralia) + case .austria: return String(localized: .browsingCountryAustria) + case .azerbaijan: return String(localized: .browsingCountryAzerbaijan) + case .bahamas: return String(localized: .browsingCountryBahamas) + case .bahrain: return String(localized: .browsingCountryBahrain) + case .bangladesh: return String(localized: .browsingCountryBangladesh) + case .barbados: return String(localized: .browsingCountryBarbados) + case .belarus: return String(localized: .browsingCountryBelarus) + case .belgium: return String(localized: .browsingCountryBelgium) + case .belize: return String(localized: .browsingCountryBelize) + case .benin: return String(localized: .browsingCountryBenin) + case .bermuda: return String(localized: .browsingCountryBermuda) + case .bhutan: return String(localized: .browsingCountryBhutan) + case .bolivia: return String(localized: .browsingCountryBolivia) + case .bonaireSaintEustatiusAndSaba: return String(localized: .browsingCountryBonaireSaintEustatiusAndSaba) + case .bosniaAndHerzegovina: return String(localized: .browsingCountryBosniaAndHerzegovina) + case .botswana: return String(localized: .browsingCountryBotswana) + case .bouvetIsland: return String(localized: .browsingCountryBouvetIsland) + case .brazil: return String(localized: .browsingCountryBrazil) + case .britishIndianOceanTerritory: return String(localized: .browsingCountryBritishIndianOceanTerritory) + case .bruneiDarussalam: return String(localized: .browsingCountryBruneiDarussalam) + case .bulgaria: return String(localized: .browsingCountryBulgaria) + case .burkinaFaso: return String(localized: .browsingCountryBurkinaFaso) + case .burundi: return String(localized: .browsingCountryBurundi) + case .cambodia: return String(localized: .browsingCountryCambodia) + case .cameroon: return String(localized: .browsingCountryCameroon) + case .canada: return String(localized: .browsingCountryCanada) + case .capeVerde: return String(localized: .browsingCountryCapeVerde) + case .caymanIslands: return String(localized: .browsingCountryCaymanIslands) + case .centralAfricanRepublic: return String(localized: .browsingCountryCentralAfricanRepublic) + case .chad: return String(localized: .browsingCountryChad) + case .chile: return String(localized: .browsingCountryChile) + case .china: return String(localized: .browsingCountryChina) + case .christmasIsland: return String(localized: .browsingCountryChristmasIsland) + case .cocosIslands: return String(localized: .browsingCountryCocosIslands) + case .colombia: return String(localized: .browsingCountryColombia) + case .comoros: return String(localized: .browsingCountryComoros) + case .congo: return String(localized: .browsingCountryCongo) + case .theDemocraticRepublicOfTheCongo: return String(localized: .browsingCountryTheDemocraticRepublicOfTheCongo) + case .cookIslands: return String(localized: .browsingCountryCookIslands) + case .costaRica: return String(localized: .browsingCountryCostaRica) + case .coteDIvoire: return String(localized: .browsingCountryCoteDIvoire) + case .croatia: return String(localized: .browsingCountryCroatia) + case .cuba: return String(localized: .browsingCountryCuba) + case .curacao: return String(localized: .browsingCountryCuracao) + case .cyprus: return String(localized: .browsingCountryCyprus) + case .czechRepublic: return String(localized: .browsingCountryCzechRepublic) + case .denmark: return String(localized: .browsingCountryDenmark) + case .djibouti: return String(localized: .browsingCountryDjibouti) + case .dominica: return String(localized: .browsingCountryDominica) + case .dominicanRepublic: return String(localized: .browsingCountryDominicanRepublic) + case .ecuador: return String(localized: .browsingCountryEcuador) + case .egypt: return String(localized: .browsingCountryEgypt) + case .elSalvador: return String(localized: .browsingCountryElSalvador) + case .equatorialGuinea: return String(localized: .browsingCountryEquatorialGuinea) + case .eritrea: return String(localized: .browsingCountryEritrea) + case .estonia: return String(localized: .browsingCountryEstonia) + case .ethiopia: return String(localized: .browsingCountryEthiopia) + case .europe: return String(localized: .browsingCountryEurope) + case .falklandIslands: return String(localized: .browsingCountryFalklandIslands) + case .faroeIslands: return String(localized: .browsingCountryFaroeIslands) + case .fiji: return String(localized: .browsingCountryFiji) + case .finland: return String(localized: .browsingCountryFinland) + case .france: return String(localized: .browsingCountryFrance) + case .frenchGuiana: return String(localized: .browsingCountryFrenchGuiana) + case .frenchPolynesia: return String(localized: .browsingCountryFrenchPolynesia) + case .frenchSouthernTerritories: return String(localized: .browsingCountryFrenchSouthernTerritories) + case .gabon: return String(localized: .browsingCountryGabon) + case .gambia: return String(localized: .browsingCountryGambia) + case .georgia: return String(localized: .browsingCountryGeorgia) + case .germany: return String(localized: .browsingCountryGermany) + case .ghana: return String(localized: .browsingCountryGhana) + case .gibraltar: return String(localized: .browsingCountryGibraltar) + case .greece: return String(localized: .browsingCountryGreece) + case .greenland: return String(localized: .browsingCountryGreenland) + case .grenada: return String(localized: .browsingCountryGrenada) + case .guadeloupe: return String(localized: .browsingCountryGuadeloupe) + case .guam: return String(localized: .browsingCountryGuam) + case .guatemala: return String(localized: .browsingCountryGuatemala) + case .guernsey: return String(localized: .browsingCountryGuernsey) + case .guinea: return String(localized: .browsingCountryGuinea) + case .guineaBissau: return String(localized: .browsingCountryGuineaBissau) + case .guyana: return String(localized: .browsingCountryGuyana) + case .haiti: return String(localized: .browsingCountryHaiti) + case .heardIslandAndMcDonaldIslands: return String(localized: .browsingCountryHeardIslandAndMcDonaldIslands) + case .vaticanCityState: return String(localized: .browsingCountryVaticanCityState) + case .honduras: return String(localized: .browsingCountryHonduras) + case .hongKong: return String(localized: .browsingCountryHongKong) + case .hungary: return String(localized: .browsingCountryHungary) + case .iceland: return String(localized: .browsingCountryIceland) + case .india: return String(localized: .browsingCountryIndia) + case .indonesia: return String(localized: .browsingCountryIndonesia) + case .iran: return String(localized: .browsingCountryIran) + case .iraq: return String(localized: .browsingCountryIraq) + case .ireland: return String(localized: .browsingCountryIreland) + case .isleOfMan: return String(localized: .browsingCountryIsleOfMan) + case .israel: return String(localized: .browsingCountryIsrael) + case .italy: return String(localized: .browsingCountryItaly) + case .jamaica: return String(localized: .browsingCountryJamaica) + case .japan: return String(localized: .browsingCountryJapan) + case .jersey: return String(localized: .browsingCountryJersey) + case .jordan: return String(localized: .browsingCountryJordan) + case .kazakhstan: return String(localized: .browsingCountryKazakhstan) + case .kenya: return String(localized: .browsingCountryKenya) + case .kiribati: return String(localized: .browsingCountryKiribati) + case .kuwait: return String(localized: .browsingCountryKuwait) + case .kyrgyzstan: return String(localized: .browsingCountryKyrgyzstan) + case .laoPeoplesDemocraticRepublic: return String(localized: .browsingCountryLaoPeoplesDemocraticRepublic) + case .latvia: return String(localized: .browsingCountryLatvia) + case .lebanon: return String(localized: .browsingCountryLebanon) + case .lesotho: return String(localized: .browsingCountryLesotho) + case .liberia: return String(localized: .browsingCountryLiberia) + case .libya: return String(localized: .browsingCountryLibya) + case .liechtenstein: return String(localized: .browsingCountryLiechtenstein) + case .lithuania: return String(localized: .browsingCountryLithuania) + case .luxembourg: return String(localized: .browsingCountryLuxembourg) + case .macau: return String(localized: .browsingCountryMacau) + case .macedonia: return String(localized: .browsingCountryMacedonia) + case .madagascar: return String(localized: .browsingCountryMadagascar) + case .malawi: return String(localized: .browsingCountryMalawi) + case .malaysia: return String(localized: .browsingCountryMalaysia) + case .maldives: return String(localized: .browsingCountryMaldives) + case .mali: return String(localized: .browsingCountryMali) + case .malta: return String(localized: .browsingCountryMalta) + case .marshallIslands: return String(localized: .browsingCountryMarshallIslands) + case .martinique: return String(localized: .browsingCountryMartinique) + case .mauritania: return String(localized: .browsingCountryMauritania) + case .mauritius: return String(localized: .browsingCountryMauritius) + case .mayotte: return String(localized: .browsingCountryMayotte) + case .mexico: return String(localized: .browsingCountryMexico) + case .micronesia: return String(localized: .browsingCountryMicronesia) + case .moldova: return String(localized: .browsingCountryMoldova) + case .monaco: return String(localized: .browsingCountryMonaco) + case .mongolia: return String(localized: .browsingCountryMongolia) + case .montenegro: return String(localized: .browsingCountryMontenegro) + case .montserrat: return String(localized: .browsingCountryMontserrat) + case .morocco: return String(localized: .browsingCountryMorocco) + case .mozambique: return String(localized: .browsingCountryMozambique) + case .myanmar: return String(localized: .browsingCountryMyanmar) + case .namibia: return String(localized: .browsingCountryNamibia) + case .nauru: return String(localized: .browsingCountryNauru) + case .nepal: return String(localized: .browsingCountryNepal) + case .netherlands: return String(localized: .browsingCountryNetherlands) + case .newCaledonia: return String(localized: .browsingCountryNewCaledonia) + case .newZealand: return String(localized: .browsingCountryNewZealand) + case .nicaragua: return String(localized: .browsingCountryNicaragua) + case .niger: return String(localized: .browsingCountryNiger) + case .nigeria: return String(localized: .browsingCountryNigeria) + case .niue: return String(localized: .browsingCountryNiue) + case .norfolkIsland: return String(localized: .browsingCountryNorfolkIsland) + case .northKorea: return String(localized: .browsingCountryNorthKorea) + case .northernMarianaIslands: return String(localized: .browsingCountryNorthernMarianaIslands) + case .norway: return String(localized: .browsingCountryNorway) + case .oman: return String(localized: .browsingCountryOman) + case .pakistan: return String(localized: .browsingCountryPakistan) + case .palau: return String(localized: .browsingCountryPalau) + case .palestinianTerritory: return String(localized: .browsingCountryPalestinianTerritory) + case .panama: return String(localized: .browsingCountryPanama) + case .papuaNewGuinea: return String(localized: .browsingCountryPapuaNewGuinea) + case .paraguay: return String(localized: .browsingCountryParaguay) + case .peru: return String(localized: .browsingCountryPeru) + case .philippines: return String(localized: .browsingCountryPhilippines) + case .pitcairnIslands: return String(localized: .browsingCountryPitcairnIslands) + case .poland: return String(localized: .browsingCountryPoland) + case .portugal: return String(localized: .browsingCountryPortugal) + case .puertoRico: return String(localized: .browsingCountryPuertoRico) + case .qatar: return String(localized: .browsingCountryQatar) + case .reunion: return String(localized: .browsingCountryReunion) + case .romania: return String(localized: .browsingCountryRomania) + case .russianFederation: return String(localized: .browsingCountryRussianFederation) + case .rwanda: return String(localized: .browsingCountryRwanda) + case .saintBarthelemy: return String(localized: .browsingCountrySaintBarthelemy) + case .saintHelena: return String(localized: .browsingCountrySaintHelena) + case .saintKittsAndNevis: return String(localized: .browsingCountrySaintKittsAndNevis) + case .saintLucia: return String(localized: .browsingCountrySaintLucia) + case .saintMartin: return String(localized: .browsingCountrySaintMartin) + case .saintPierreAndMiquelon: return String(localized: .browsingCountrySaintPierreAndMiquelon) + case .saintVincentAndTheGrenadines: return String(localized: .browsingCountrySaintVincentAndTheGrenadines) + case .samoa: return String(localized: .browsingCountrySamoa) + case .sanMarino: return String(localized: .browsingCountrySanMarino) + case .saoTomeAndPrincipe: return String(localized: .browsingCountrySaoTomeAndPrincipe) + case .saudiArabia: return String(localized: .browsingCountrySaudiArabia) + case .senegal: return String(localized: .browsingCountrySenegal) + case .serbia: return String(localized: .browsingCountrySerbia) + case .seychelles: return String(localized: .browsingCountrySeychelles) + case .sierraLeone: return String(localized: .browsingCountrySierraLeone) + case .singapore: return String(localized: .browsingCountrySingapore) + case .sintMaarten: return String(localized: .browsingCountrySintMaarten) + case .slovakia: return String(localized: .browsingCountrySlovakia) + case .slovenia: return String(localized: .browsingCountrySlovenia) + case .solomonIslands: return String(localized: .browsingCountrySolomonIslands) + case .somalia: return String(localized: .browsingCountrySomalia) + case .southAfrica: return String(localized: .browsingCountrySouthAfrica) + case .southGeorgiaAndTheSouthSandwichIslands: return String(localized: .browsingCountrySouthGeorgiaAndTheSouthSandwichIslands) + case .southKorea: return String(localized: .browsingCountrySouthKorea) + case .southSudan: return String(localized: .browsingCountrySouthSudan) + case .spain: return String(localized: .browsingCountrySpain) + case .sriLanka: return String(localized: .browsingCountrySriLanka) + case .sudan: return String(localized: .browsingCountrySudan) + case .suriname: return String(localized: .browsingCountrySuriname) + case .svalbardAndJanMayen: return String(localized: .browsingCountrySvalbardAndJanMayen) + case .swaziland: return String(localized: .browsingCountrySwaziland) + case .sweden: return String(localized: .browsingCountrySweden) + case .switzerland: return String(localized: .browsingCountrySwitzerland) + case .syrianArabRepublic: return String(localized: .browsingCountrySyrianArabRepublic) + case .taiwan: return String(localized: .browsingCountryTaiwan) + case .tajikistan: return String(localized: .browsingCountryTajikistan) + case .tanzania: return String(localized: .browsingCountryTanzania) + case .thailand: return String(localized: .browsingCountryThailand) + case .timorLeste: return String(localized: .browsingCountryTimorLeste) + case .togo: return String(localized: .browsingCountryTogo) + case .tokelau: return String(localized: .browsingCountryTokelau) + case .tonga: return String(localized: .browsingCountryTonga) + case .trinidadAndTobago: return String(localized: .browsingCountryTrinidadAndTobago) + case .tunisia: return String(localized: .browsingCountryTunisia) + case .turkey: return String(localized: .browsingCountryTurkey) + case .turkmenistan: return String(localized: .browsingCountryTurkmenistan) + case .turksAndCaicosIslands: return String(localized: .browsingCountryTurksAndCaicosIslands) + case .tuvalu: return String(localized: .browsingCountryTuvalu) + case .uganda: return String(localized: .browsingCountryUganda) + case .ukraine: return String(localized: .browsingCountryUkraine) + case .unitedArabEmirates: return String(localized: .browsingCountryUnitedArabEmirates) + case .unitedKingdom: return String(localized: .browsingCountryUnitedKingdom) + case .unitedStates: return String(localized: .browsingCountryUnitedStates) + case .unitedStatesMinorOutlyingIslands: return String(localized: .browsingCountryUnitedStatesMinorOutlyingIslands) + case .uruguay: return String(localized: .browsingCountryUruguay) + case .uzbekistan: return String(localized: .browsingCountryUzbekistan) + case .vanuatu: return String(localized: .browsingCountryVanuatu) + case .venezuela: return String(localized: .browsingCountryVenezuela) + case .vietnam: return String(localized: .browsingCountryVietnam) + case .virginIslandsBritish: return String(localized: .browsingCountryVirginIslandsBritish) + case .virginIslandsUS: return String(localized: .browsingCountryVirginIslandsUs) + case .wallisAndFutuna: return String(localized: .browsingCountryWallisAndFutuna) + case .westernSahara: return String(localized: .browsingCountryWesternSahara) + case .yemen: return String(localized: .browsingCountryYemen) + case .zambia: return String(localized: .browsingCountryZambia) + case .zimbabwe: return String(localized: .browsingCountryZimbabwe) } } } diff --git a/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift b/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift index 65cb6a11a..a136505c9 100644 --- a/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift @@ -14,11 +14,11 @@ extension EhSetting.CommentsSortOrder { public var value: String { switch self { case .oldest: - return L10n.Localizable.CommentsSortOrder.oldest + return String(localized: .commentsSortOrderOldest) case .recent: - return L10n.Localizable.CommentsSortOrder.recent + return String(localized: .commentsSortOrderRecent) case .highestScore: - return L10n.Localizable.CommentsSortOrder.highestScore + return String(localized: .commentsSortOrderHighestScore) } } } @@ -36,9 +36,9 @@ extension EhSetting.CommentVotesShowTiming { public var value: String { switch self { case .onHoverOrClick: - return L10n.Localizable.CommentsVotesShowTiming.onHoverOrClick + return String(localized: .commentsVotesShowTimingOnHoverOrClick) case .always: - return L10n.Localizable.CommentsVotesShowTiming.always + return String(localized: .commentsVotesShowTimingAlways) } } } @@ -56,9 +56,9 @@ extension EhSetting.TagsSortOrder { public var value: String { switch self { case .alphabetical: - return L10n.Localizable.TagsSortOrder.alphabetical + return String(localized: .tagsSortOrderAlphabetical) case .tagPower: - return L10n.Localizable.TagsSortOrder.tagPower + return String(localized: .tagsSortOrderTagPower) } } } @@ -77,11 +77,11 @@ extension EhSetting.MultiplePageViewerStyle { public var value: String { switch self { case .alignLeftScaleIfOverWidth: - return L10n.Localizable.MultiplePageViewerStyle.alignLeftScaleIfOverWidth + return String(localized: .multiplePageViewerStyleAlignLeftScaleIfOverWidth) case .alignCenterScaleIfOverWidth: - return L10n.Localizable.MultiplePageViewerStyle.alignCenterScaleIfOverWidth + return String(localized: .multiplePageViewerStyleAlignCenterScaleIfOverWidth) case .alignCenterAlwaysScale: - return L10n.Localizable.MultiplePageViewerStyle.alignCenterAlwaysScale + return String(localized: .multiplePageViewerStyleAlignCenterAlwaysScale) } } } @@ -99,9 +99,9 @@ extension EhSetting.GalleryPageNumbering { public var value: String { switch self { - case .none: L10n.Localizable.GalleryPageNumbering.none - case .pageNumberOnly: L10n.Localizable.GalleryPageNumbering.pageNumberOnly - case .pageNumberAndName: L10n.Localizable.GalleryPageNumbering.pageNumberAndName + case .none: String(localized: .galleryPageNumberingNone) + case .pageNumberOnly: String(localized: .galleryPageNumberingPageNumberOnly) + case .pageNumberAndName: String(localized: .galleryPageNumberingPageNumberAndName) } } } diff --git a/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift b/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift index d51d4af45..5a3278e00 100644 --- a/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift @@ -13,17 +13,17 @@ extension EhSetting.ThumbnailLoadTiming { public var value: String { switch self { case .onMouseOver: - return L10n.Localizable.ThumbnailLoadTiming.onMouseOver + return String(localized: .thumbnailLoadTimingOnMouseOver) case .onPageLoad: - return L10n.Localizable.ThumbnailLoadTiming.onPageLoad + return String(localized: .thumbnailLoadTimingOnPageLoad) } } public var description: String { switch self { case .onMouseOver: - return L10n.Localizable.ThumbnailLoadTiming.onMouseOverDescription + return String(localized: .thumbnailLoadTimingOnMouseOverDescription) case .onPageLoad: - return L10n.Localizable.ThumbnailLoadTiming.onPageLoadDescription + return String(localized: .thumbnailLoadTimingOnPageLoadDescription) } } } @@ -47,13 +47,13 @@ extension EhSetting.ThumbnailSize { public var value: String { switch self { case .normal: - return L10n.Localizable.ThumbnailSize.normal + return String(localized: .thumbnailSizeNormal) case .large: - return L10n.Localizable.ThumbnailSize.large + return String(localized: .thumbnailSizeLarge) case .small: - return L10n.Localizable.ThumbnailSize.small + return String(localized: .thumbnailSizeSmall) case .auto: - return L10n.Localizable.ThumbnailSize.auto + return String(localized: .thumbnailSizeAuto) } } } diff --git a/AppPackage/Sources/AppModels/Support/EhSetting.swift b/AppPackage/Sources/AppModels/Support/EhSetting.swift index 4f19ba9cb..236e05e4b 100644 --- a/AppPackage/Sources/AppModels/Support/EhSetting.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting.swift @@ -232,25 +232,25 @@ extension EhSetting.LoadThroughHathSetting { public var value: String { switch self { case .anyClient: - return L10n.Localizable.LoadThroughHathSetting.anyClient + return String(localized: .loadThroughHathSettingAnyClient) case .defaultPortOnly: - return L10n.Localizable.LoadThroughHathSetting.defaultPortOnly + return String(localized: .loadThroughHathSettingDefaultPortOnly) case .modernNo: - return L10n.Localizable.LoadThroughHathSetting.modernNo + return String(localized: .loadThroughHathSettingModernNo) case .legacyNo: - return L10n.Localizable.LoadThroughHathSetting.legacyNo + return String(localized: .loadThroughHathSettingLegacyNo) } } public var description: String { switch self { case .anyClient: - return L10n.Localizable.LoadThroughHathSetting.anyClientDescription + return String(localized: .loadThroughHathSettingAnyClientDescription) case .defaultPortOnly: - return L10n.Localizable.LoadThroughHathSetting.defaultPortOnlyDescription + return String(localized: .loadThroughHathSettingDefaultPortOnlyDescription) case .modernNo: - return L10n.Localizable.LoadThroughHathSetting.modernNoDescription + return String(localized: .loadThroughHathSettingModernNoDescription) case .legacyNo: - return L10n.Localizable.LoadThroughHathSetting.legacyNoDescription + return String(localized: .loadThroughHathSettingLegacyNoDescription) } } } @@ -276,7 +276,7 @@ extension EhSetting.ImageResolution { public var value: String { switch self { case .auto: - return L10n.Localizable.ImageResolution.auto + return String(localized: .imageResolutionAuto) case .x780: return "780x" case .x980: @@ -304,9 +304,9 @@ extension EhSetting.GalleryName { public var value: String { switch self { case .default: - return L10n.Localizable.GalleryName.default + return String(localized: .galleryNameDefault) case .japanese: - return L10n.Localizable.GalleryName.japanese + return String(localized: .galleryNameJapanese) } } } @@ -328,17 +328,17 @@ extension EhSetting.ArchiverBehavior { public var value: String { switch self { case .manualSelectManualStart: - return L10n.Localizable.EhSetting.ArchiverBehavior.manualSelectManualStart + return String(localized: .ehSettingArchiverBehaviorManualSelectManualStart) case .manualSelectAutoStart: - return L10n.Localizable.EhSetting.ArchiverBehavior.manualSelectAutoStart + return String(localized: .ehSettingArchiverBehaviorManualSelectAutoStart) case .autoSelectOriginalManualStart: - return L10n.Localizable.EhSetting.ArchiverBehavior.autoSelectOriginalManualStart + return String(localized: .ehSettingArchiverBehaviorAutoSelectOriginalManualStart) case .autoSelectOriginalAutoStart: - return L10n.Localizable.EhSetting.ArchiverBehavior.autoSelectOriginalAutoStart + return String(localized: .ehSettingArchiverBehaviorAutoSelectOriginalAutoStart) case .autoSelectResampleManualStart: - return L10n.Localizable.EhSetting.ArchiverBehavior.autoSelectResampleManualStart + return String(localized: .ehSettingArchiverBehaviorAutoSelectResampleManualStart) case .autoSelectResampleAutoStart: - return L10n.Localizable.EhSetting.ArchiverBehavior.autoSelectResampleAutoStart + return String(localized: .ehSettingArchiverBehaviorAutoSelectResampleAutoStart) } } } @@ -359,15 +359,15 @@ extension EhSetting.DisplayMode { public var value: String { switch self { case .compact: - return L10n.Localizable.DisplayMode.compact + return String(localized: .displayModeCompact) case .thumbnail: - return L10n.Localizable.DisplayMode.thumbnail + return String(localized: .displayModeThumbnail) case .extended: - return L10n.Localizable.DisplayMode.extended + return String(localized: .displayModeExtended) case .minimal: - return L10n.Localizable.DisplayMode.minimal + return String(localized: .displayModeMinimal) case .minimalPlus: - return L10n.Localizable.DisplayMode.minimalPlus + return String(localized: .displayModeMinimalPlus) } } } @@ -385,9 +385,9 @@ extension EhSetting.FavoritesSortOrder { public var value: String { switch self { case .lastUpdateTime: - return L10n.Localizable.FavoritesSortOrder.lastUpdateTime + return String(localized: .favoritesSortOrderLastUpdateTime) case .favoritedTime: - return L10n.Localizable.FavoritesSortOrder.favoritedTime + return String(localized: .favoritesSortOrderFavoritedTime) } } } @@ -406,11 +406,11 @@ extension EhSetting.ExcludedLanguagesCategory { public var value: String { switch self { case .original: - return L10n.Localizable.ExcludedLanguagesCategory.original + return String(localized: .excludedLanguagesCategoryOriginal) case .translated: - return L10n.Localizable.ExcludedLanguagesCategory.translated + return String(localized: .excludedLanguagesCategoryTranslated) case .rewrite: - return L10n.Localizable.ExcludedLanguagesCategory.rewrite + return String(localized: .excludedLanguagesCategoryRewrite) } } } diff --git a/AppPackage/Sources/AppModels/Support/ToplistsType.swift b/AppPackage/Sources/AppModels/Support/ToplistsType.swift index 6c2f0f77a..09a0fc248 100644 --- a/AppPackage/Sources/AppModels/Support/ToplistsType.swift +++ b/AppPackage/Sources/AppModels/Support/ToplistsType.swift @@ -13,13 +13,13 @@ extension ToplistsType { public var value: String { switch self { case .yesterday: - return L10n.Localizable.ToplistsType.yesterday + return String(localized: .toplistsTypeYesterday) case .pastMonth: - return L10n.Localizable.ToplistsType.pastMonth + return String(localized: .toplistsTypePastMonth) case .pastYear: - return L10n.Localizable.ToplistsType.pastYear + return String(localized: .toplistsTypePastYear) case .allTime: - return L10n.Localizable.ToplistsType.allTime + return String(localized: .toplistsTypeAllTime) } } public var categoryIndex: Int { diff --git a/AppPackage/Sources/AppModels/Tags/TagNamespace.swift b/AppPackage/Sources/AppModels/Tags/TagNamespace.swift index b6e4fb3cc..5679621dc 100644 --- a/AppPackage/Sources/AppModels/Tags/TagNamespace.swift +++ b/AppPackage/Sources/AppModels/Tags/TagNamespace.swift @@ -61,18 +61,18 @@ extension TagNamespace { } public var value: String { switch self { - case .reclass: return L10n.Localizable.TagNamespace.reclass - case .language: return L10n.Localizable.TagNamespace.language - case .parody: return L10n.Localizable.TagNamespace.parody - case .character: return L10n.Localizable.TagNamespace.character - case .group: return L10n.Localizable.TagNamespace.group - case .artist: return L10n.Localizable.TagNamespace.artist - case .male: return L10n.Localizable.TagNamespace.male - case .female: return L10n.Localizable.TagNamespace.female - case .mixed: return L10n.Localizable.TagNamespace.mixed - case .cosplayer: return L10n.Localizable.TagNamespace.cosplayer - case .other: return L10n.Localizable.TagNamespace.other - case .temp: return L10n.Localizable.TagNamespace.temp + case .reclass: return String(localized: .tagNamespaceReclass) + case .language: return String(localized: .tagNamespaceLanguage) + case .parody: return String(localized: .tagNamespaceParody) + case .character: return String(localized: .tagNamespaceCharacter) + case .group: return String(localized: .tagNamespaceGroup) + case .artist: return String(localized: .tagNamespaceArtist) + case .male: return String(localized: .tagNamespaceMale) + case .female: return String(localized: .tagNamespaceFemale) + case .mixed: return String(localized: .tagNamespaceMixed) + case .cosplayer: return String(localized: .tagNamespaceCosplayer) + case .other: return String(localized: .tagNamespaceOther) + case .temp: return String(localized: .tagNamespaceTemp) } } } diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings index b1f2187a6..93e50fe08 100644 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings @@ -1,11 +1,6 @@ // MARK: BanInterval -"ban_interval.and" = "und"; // MARK: ToplistsType -"toplists_type.yesterday" = "Gestern"; -"toplists_type.past_month" = "Letzter Monat"; -"toplists_type.past_year" = "Letztes Jahr"; -"toplists_type.all_time" = "Gesamt"; // MARK: Response @@ -49,33 +44,8 @@ // MARK: AlertView "not_login_viewlogin" = "Einloggen"; "error_view.retry" = "Erneut versuchen"; -"error_view.try_later" = "Bitte versuche es später erneut."; -"error_view.network" = "Ein Netzwerkfehler ist aufgetreten."; -"error_view.parsing" = "Ein Parserfehler ist aufgetreten."; -"error_view.unknown" = "Ein unbekannter Fehler ist aufgetreten."; -"error_view.not_found" = "Hier scheint es nichts zu geben."; -"error_view.database_corrupted" = "Die Datenbank ist beschädigt.\nBitte erstelle ein Issue auf GitHub."; -"error_view.ip_banned" = "Deine IP-Adresse wurde wegen übermäßig vieler Seitenaufrufe vorübergehend gesperrt. Das deutet auf automatische Mirroring- oder Harvesting-Software hin. Die Sperre läuft in %@ ab."; -"error_view.copyright_claim" = "Diese Galerie ist wegen eines Urheberrechtsanspruchs von %@ nicht verfügbar. Das tut uns leid."; -"error_view.gallery_unavailable" = "Diese Galerie wurde entfernt oder ist nicht verfügbar."; // MARK: AppError -"app_error.database_corrupted" = "Datenbank beschädigt"; -"app_error.copyright_claim" = "Urheberrechtsanspruch"; -"app_error.ip_banned" = "IP-Adresse gesperrt"; -"app_error.gallery_expunged" = "Galerie entfernt"; -"app_error.network_error" = "Netzwerkfehler"; -"app_error.web_image_loading_error" = "Fehler beim Laden des Webbilds"; -"app_error.parse_error" = "Parserfehler"; -"app_error.quota_exceeded" = "Kontingent überschritten"; -"app_error.authentication_required" = "Authentifizierung erforderlich"; -"app_error.file_operation_failed" = "Dateivorgang fehlgeschlagen"; -"app_error.no_updates_available" = "Keine Updates verfügbar"; -"app_error.not_found" = "Nicht gefunden"; -"app_error.unknown_error" = "Unbekannter Fehler"; -"app_error.quota_exceeded_description" = "Bildkontingent überschritten.\nBitte warte einen Moment und versuche es dann erneut."; -"app_error.authentication_required_description" = "Für diesen Download ist eine Anmeldung erforderlich."; -"app_error.local_file_operation_failed" = "Lokaler Dateivorgang fehlgeschlagen."; // MARK: ConfirmationDialog "confirmation_dialog.delete_description" = "Möchtest du dieses Element wirklich löschen?"; @@ -87,10 +57,6 @@ // MARK: NewDawnView // Greeting -"greeting.start" = "Du erhälst "; -"greeting.separator" = ", "; -"greeting.and" = " und "; -"greeting.end" = "!"; // MARK: HomeView "home_view.home" = "Start"; @@ -109,8 +75,6 @@ // MARK: FavoritesView "favorites_view.favorites" = "Favoriten"; // FavoriteCategory -"favorite_category.default" = "Favoriten %@"; -"favorite_category.all" = "Alle"; // MARK: SearchView "search_view.search" = "Suche"; @@ -134,39 +98,18 @@ // MARK: GeneralSettingView "general_setting_view.language" = "Sprache"; // AutoLockPolicy -"auto_lock_policy.never" = "Nie"; -"auto_lock_policy.instantly" = "Sofort"; // MARK: AppActivityLogsView -"app_activity_logs_view.level.undefined" = "Undefiniert"; -"app_activity_logs_view.level.debug" = "Debug"; -"app_activity_logs_view.level.info" = "Info"; -"app_activity_logs_view.level.notice" = "Hinweis"; -"app_activity_logs_view.level.error" = "Fehler"; -"app_activity_logs_view.level.fault" = "Störung"; // MARK: AppearanceSettingView // PreferredColorScheme -"preferred_color_scheme.automatic" = "Automatisch"; -"preferred_color_scheme.light" = "Hell"; -"preferred_color_scheme.dark" = "Dunkel"; // AppIconType -"app_icon_type.default" = "Standard"; -"app_icon_type.ukiyoe" = "Ukiyo-e"; -"app_icon_type.developer" = "Entwickler"; -"app_icon_type.stand_with_ukraine_2022" = "Solidarität mit der Ukraine (2022)"; -"app_icon_type.not_my_president" = "NICHT MEIN PRÄSIDENT"; // ListDisplayMode -"list_display_mode.detail" = "Details"; -"list_display_mode.thumbnail" = "Vorschaubilder"; // MARK: AppIconView // MARK: reading_settingView // ReadingDirection -"reading_direction.vertical" = "Vertikal"; -"reading_direction.right_to_left" = "Von rechts nach links"; -"reading_direction.left_to_right" = "Von links nach rechts"; // MARK: LaboratorySettingView @@ -182,22 +125,16 @@ // MARK: ArchivesView // HathArchive -"hath_archive.free" = "Frei"; // ArchiveResolution -"archive_resolution.original" = "Original"; // MARK: TorrentsView // MARK: GalleryInfosView // GalleryVisibility -"gallery_visibility.yes" = "Ja"; -"gallery_visibility.no" = "Nein (%@)"; -"gallery_visibility.expunged" = "Entfernt"; // MARK: TagDetailView // MARK: DownloadsView -"download_folder_filter.all" = "Alle"; "detail_view.manage_folders" = "Ordner verwalten"; "downloads_view.manage_folders" = "Ordner verwalten"; "downloads_view.downloads" = "Downloads"; @@ -229,441 +166,49 @@ // MARK: FiltersView "filters_view.filters" = "Filter"; // FilterRange -"filter_range.search" = "Suche"; -"filter_range.global" = "Global"; -"filter_range.watched" = "Meine Tags"; // MARK: EhSettingView // EhSetting.LoadThroughHathSetting -"load_through_hath_setting.any_client" = "Jeder Client"; -"load_through_hath_setting.default_port_only" = "Nur Clients mit Standardport"; -"load_through_hath_setting.modern_no" = "Nein [Modern/HTTPS]"; -"load_through_hath_setting.legacy_no" = "Nein [Legacy/HTTP]"; -"load_through_hath_setting.any_client_description" = "Empfohlen."; -"load_through_hath_setting.default_port_only_description" = "Kann langsamer sein. Aktiviere dies nur, wenn eine Firewall oder ein Proxy ausgehende Nicht-Standard-Ports blockiert."; -"load_through_hath_setting.modern_no_description" = "Nur für Spender. Du kannst damit weniger Seiten aufrufen. Nur bei schwerwiegenden Problemen empfohlen."; -"load_through_hath_setting.legacy_no_description" = "Nur für Spender. Funktioniert in modernen Browsern unter Umständen nicht. Nur für alte oder veraltete Browser empfohlen."; // EhSetting.ImageResolution -"image_resolution.auto" = "Automatisch"; // EhSetting.GalleryName -"gallery_name.default" = "Standardtitel"; -"gallery_name.japanese" = "Japanischer Titel (falls vorhanden)"; // EhSetting.ArchiverBehavior -"eh_setting.archiver_behavior.manual_select_manual_start" = "Manuell wählen, manuell starten (Standard)"; -"eh_setting.archiver_behavior.manual_select_auto_start" = "Manuell wählen, automatisch starten"; -"eh_setting.archiver_behavior.auto_select_original_manual_start" = "Original automatisch wählen, manuell starten"; -"eh_setting.archiver_behavior.auto_select_original_auto_start" = "Original automatisch wählen, automatisch starten"; -"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "Neu berechnete Version automatisch wählen, manuell starten"; -"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "Neu berechnete Version automatisch wählen, automatisch starten"; // EhSetting.DisplayMode -"display_mode.compact" = "Kompakt"; -"display_mode.thumbnail" = "Vorschaubilder"; -"display_mode.extended" = "Erweitert"; -"display_mode.minimal" = "Minimal"; -"display_mode.minimalPlus" = "Minimal+"; // EhSetting.FavoritesSortOrder -"favorites_sort_order.last_update_time" = "Nach letzter Aktualisierung der Galerie"; -"favorites_sort_order.favorited_time" = "Nach Zeitpunkt des Favorisierens"; // EhSetting.ExcludedLanguagesCategory -"excluded_languages_category.original" = "Original"; -"excluded_languages_category.translated" = "Übersetzt"; -"excluded_languages_category.rewrite" = "Umgeschrieben"; // EhSetting.ThumbnailLoadTiming -"thumbnail_load_timing.on_mouse_over" = "Bei Mouseover"; -"thumbnail_load_timing.on_page_load" = "Beim Laden der Seite"; -"thumbnail_load_timing.on_mouse_over_description" = "Seiten laden schneller, Vorschaubilder erscheinen aber unter Umständen leicht verzögert."; -"thumbnail_load_timing.on_page_load_description" = "Seiten brauchen länger zum Laden, dafür erscheinen die Vorschaubilder danach ohne Verzögerung."; // EhSetting.ThumbnailSize -"thumbnail_size.normal" = "Normal"; -"thumbnail_size.large" = "Groß"; -"thumbnail_size.small" = "Klein"; -"thumbnail_size.auto" = "Automatisch"; // EhSetting.CommentsSortOrder -"comments_sort_order.oldest" = "Älteste Kommentare zuerst"; -"comments_sort_order.recent" = "Neueste Kommentare zuerst"; -"comments_sort_order.highest_score" = "Nach höchster Punktzahl"; // EhSetting.CommentVotesShowTiming -"comments_votes_show_timing.on_hover_or_click" = "Beim Überfahren oder Anklicken der Punktzahl"; -"comments_votes_show_timing.always" = "Immer"; // EhSetting.tags_sort_order -"tags_sort_order.alphabetical" = "Alphabetisch"; -"tags_sort_order.tag_power" = "Nach Tag-Gewicht"; // EhSetting.MultiplePageViewerStyle -"multiple_page_viewer_style.align_left_scale_if_over_width" = "Linksbündig, bei Überbreite skalieren"; -"multiple_page_viewer_style.align_center_scale_if_over_width" = "Zentriert, bei Überbreite skalieren"; -"multiple_page_viewer_style.align_center_always_scale" = "Zentriert, immer skalieren"; // EhSetting.GalleryPageNumbering -"gallery_page_numbering.none" = "Keine"; -"gallery_page_numbering.page_number_only" = "Nur Seitenzahl"; -"gallery_page_numbering.page_number_and_name" = "Seitenzahl + Name"; // MARK: Category -"category.doujinshi" = "Doujinshi"; -"category.manga" = "Manga"; -"category.artist_CG" = "Artist CG"; -"category.game_CG" = "Game CG"; -"category.western" = "Western"; -"category.non_h" = "Non-H"; -"category.image_set" = "Image Set"; -"category.cosplay" = "Cosplay"; -"category.asian_porn" = "Asian Porn"; -"category.misc" = "Misc"; -"category.private" = "Private"; // MARK: TagNamespace -"tag_namespace.reclass" = "Reclass"; -"tag_namespace.language" = "Sprache"; -"tag_namespace.parody" = "Parodie"; -"tag_namespace.character" = "Charakter"; -"tag_namespace.group" = "Gruppe"; -"tag_namespace.artist" = "Künstler"; -"tag_namespace.male" = "Männlich"; -"tag_namespace.female" = "Weiblich"; -"tag_namespace.mixed" = "Mixed"; -"tag_namespace.cosplayer" = "Cosplayer"; -"tag_namespace.other" = "Other"; -"tag_namespace.temp" = "Temp"; // MARK: Language -"language.invalid" = "./."; -"language.other" = "Other"; -"language.afrikaans" = "Afrikaan"; -"language.albanian" = "Albanisch"; -"language.arabic" = "Arabisch"; -"language.bengali" = "Bengali"; -"language.bosnian" = "Bosnisch"; -"language.bulgarian" = "Bulgarisch"; -"language.burmese" = "Birmanisch"; -"language.catalan" = "Katalanisch"; -"language.cebuano" = "Cebuano"; -"language.chinese" = "Chinesisch"; -"language.croatian" = "Kroatisch"; -"language.czech" = "Tschechisch"; -"language.danish" = "Dänisch"; -"language.dutch" = "Niederländisch"; -"language.english" = "Englisch"; -"language.esperanto" = "Esperanto"; -"language.estonian" = "Estländisch"; -"language.finnish" = "Finnisch"; -"language.french" = "Französisch"; -"language.georgian" = "Georgisch"; -"language.german" = "Deutsch"; -"language.greek" = "Griechisch"; -"language.hebrew" = "Hebräisch"; -"language.hindi" = "Hindi"; -"language.hmong" = "Hmong"; -"language.hungarian" = "Ungarisch"; -"language.indonesian" = "Indonesisch"; -"language.italian" = "Italian"; -"language.japanese" = "Japanisch"; -"language.kazakh" = "Kazakhstanisch"; -"language.khmer" = "Khmer"; -"language.korean" = "Koreanisch"; -"language.kurdish" = "Kurdisch"; -"language.lao" = "Lao"; -"language.latin" = "Latein"; -"language.mongolian" = "Mongolisch"; -"language.ndebele" = "Ndebele"; -"language.nepali" = "Nepali"; -"language.norwegian" = "Norwegisch"; -"language.oromo" = "Oromo"; -"language.pashto" = "Pashto"; -"language.persian" = "Persisch"; -"language.polish" = "Polnisch"; -"language.portuguese" = "Portugiesisch"; -"language.punjabi" = "Punjabi"; -"language.romanian" = "Rumänisch"; -"language.russian" = "Russisch"; -"language.sango" = "Sango"; -"language.serbian" = "Serbisch"; -"language.shona" = "Shona"; -"language.slovak" = "Slovakisch"; -"language.slovenian" = "Slovenisch"; -"language.somali" = "Somali"; -"language.spanish" = "Spanisch"; -"language.swahili" = "Swahili"; -"language.swedish" = "Schwedisch"; -"language.tagalog" = "Tagalog"; -"language.thai" = "Thai"; -"language.tigrinya" = "Tigrinya"; -"language.turkish" = "Türkisch"; -"language.ukrainian" = "Ukrainisch"; -"language.urdu" = "Urdu"; -"language.vietnamese" = "Vietnamesisch"; -"language.zulu" = "Zulu"; // MARK: BrowsingCountry -"browsing_country.auto_detect" = "Auto-Detect"; -"browsing_country.afghanistan" = "Afghanistan"; -"browsing_country.aland_islands" = "Aland Islands"; -"browsing_country.albania" = "Albania"; -"browsing_country.algeria" = "Algeria"; -"browsing_country.american_samoa" = "American Samoa"; -"browsing_country.andorra" = "Andorra"; -"browsing_country.angola" = "Angola"; -"browsing_country.anguilla" = "Anguilla"; -"browsing_country.antarctica" = "Antarctica"; -"browsing_country.antigua_and_barbuda" = "Antigua and Barbuda"; -"browsing_country.argentina" = "Argentina"; -"browsing_country.armenia" = "Armenia"; -"browsing_country.aruba" = "Aruba"; -"browsing_country.asia_pacific_region" = "Asia-Pacific Region"; -"browsing_country.australia" = "Australia"; -"browsing_country.austria" = "Austria"; -"browsing_country.azerbaijan" = "Azerbaijan"; -"browsing_country.bahamas" = "Bahamas"; -"browsing_country.bahrain" = "Bahrain"; -"browsing_country.bangladesh" = "Bangladesh"; -"browsing_country.barbados" = "Barbados"; -"browsing_country.belarus" = "Belarus"; -"browsing_country.belgium" = "Belgium"; -"browsing_country.belize" = "Belize"; -"browsing_country.benin" = "Benin"; -"browsing_country.bermuda" = "Bermuda"; -"browsing_country.bhutan" = "Bhutan"; -"browsing_country.bolivia" = "Bolivia"; -"browsing_country.bonaire_saint_eustatius_and_saba" = "Bonaire Saint Eustatius and Saba"; -"browsing_country.bosnia_and_herzegovina" = "Bosnia and Herzegovina"; -"browsing_country.botswana" = "Botswana"; -"browsing_country.bouvet_island" = "Bouvet Island"; -"browsing_country.brazil" = "Brazil"; -"browsing_country.british_indian_ocean_territory" = "British Indian Ocean Territory"; -"browsing_country.brunei_darussalam" = "Brunei Darussalam"; -"browsing_country.bulgaria" = "Bulgaria"; -"browsing_country.burkina_faso" = "Burkina Faso"; -"browsing_country.burundi" = "Burundi"; -"browsing_country.cambodia" = "Cambodia"; -"browsing_country.cameroon" = "Cameroon"; -"browsing_country.canada" = "Canada"; -"browsing_country.cape_verde" = "Cape Verde"; -"browsing_country.cayman_islands" = "Cayman Islands"; -"browsing_country.central_african_republic" = "Central African Republic"; -"browsing_country.chad" = "Chad"; -"browsing_country.chile" = "Chile"; -"browsing_country.china" = "China"; -"browsing_country.christmas_island" = "Christmas Island"; -"browsing_country.cocos_islands" = "Cocos Islands"; -"browsing_country.colombia" = "Colombia"; -"browsing_country.comoros" = "Comoros"; -"browsing_country.congo" = "Congo"; -"browsing_country.the_democratic_republic_of_the_congo" = "The Democratic Republic of the Congo"; -"browsing_country.cook_islands" = "Cook Islands"; -"browsing_country.costa_rica" = "Costa Rica"; -"browsing_country.cote_d_ivoire" = "Cote D'Ivoire"; -"browsing_country.croatia" = "Croatia"; -"browsing_country.cuba" = "Cuba"; -"browsing_country.curacao" = "Curacao"; -"browsing_country.cyprus" = "Cyprus"; -"browsing_country.czech_republic" = "Czech Republic"; -"browsing_country.denmark" = "Denmark"; -"browsing_country.djibouti" = "Djibouti"; -"browsing_country.dominica" = "Dominica"; -"browsing_country.dominican_republic" = "Dominican Republic"; -"browsing_country.ecuador" = "Ecuador"; -"browsing_country.egypt" = "Egypt"; -"browsing_country.el_salvador" = "El Salvador"; -"browsing_country.equatorial_guinea" = "Equatorial Guinea"; -"browsing_country.eritrea" = "Eritrea"; -"browsing_country.estonia" = "Estonia"; -"browsing_country.ethiopia" = "Ethiopia"; -"browsing_country.europe" = "Europe"; -"browsing_country.falkland_islands" = "Falkland Islands"; -"browsing_country.faroe_islands" = "Faroe Islands"; -"browsing_country.fiji" = "Fiji"; -"browsing_country.finland" = "Finland"; -"browsing_country.france" = "France"; -"browsing_country.french_guiana" = "French Guiana"; -"browsing_country.french_polynesia" = "French Polynesia"; -"browsing_country.french_southern_territories" = "French Southern Territories"; -"browsing_country.gabon" = "Gabon"; -"browsing_country.gambia" = "Gambia"; -"browsing_country.georgia" = "Georgia"; -"browsing_country.germany" = "Germany"; -"browsing_country.ghana" = "Ghana"; -"browsing_country.gibraltar" = "Gibraltar"; -"browsing_country.greece" = "Greece"; -"browsing_country.greenland" = "Greenland"; -"browsing_country.grenada" = "Grenada"; -"browsing_country.guadeloupe" = "Guadeloupe"; -"browsing_country.guam" = "Guam"; -"browsing_country.guatemala" = "Guatemala"; -"browsing_country.guernsey" = "Guernsey"; -"browsing_country.guinea" = "Guinea"; -"browsing_country.guinea_bissau" = "Guinea-Bissau"; -"browsing_country.guyana" = "Guyana"; -"browsing_country.haiti" = "Haiti"; -"browsing_country.heard_island_and_mc_donald_islands" = "Heard Island and McDonald Islands"; -"browsing_country.vatican_city_state" = "Vatican City State"; -"browsing_country.honduras" = "Honduras"; -"browsing_country.hong_kong" = "Hong Kong"; -"browsing_country.hungary" = "Hungary"; -"browsing_country.iceland" = "Iceland"; -"browsing_country.india" = "India"; -"browsing_country.indonesia" = "Indonesia"; -"browsing_country.iran" = "Iran"; -"browsing_country.iraq" = "Iraq"; -"browsing_country.ireland" = "Ireland"; -"browsing_country.isle_of_man" = "Isle of Man"; -"browsing_country.israel" = "Israel"; -"browsing_country.italy" = "Italy"; -"browsing_country.jamaica" = "Jamaica"; -"browsing_country.japan" = "Japan"; -"browsing_country.jersey" = "Jersey"; -"browsing_country.jordan" = "Jordan"; -"browsing_country.kazakhstan" = "Kazakhstan"; -"browsing_country.kenya" = "Kenya"; -"browsing_country.kiribati" = "Kiribati"; -"browsing_country.kuwait" = "Kuwait"; -"browsing_country.kyrgyzstan" = "Kyrgyzstan"; -"browsing_country.lao_peoples_democratic_republic" = "Lao People's Democratic Republic"; -"browsing_country.latvia" = "Latvia"; -"browsing_country.lebanon" = "Lebanon"; -"browsing_country.lesotho" = "Lesotho"; -"browsing_country.liberia" = "Liberia"; -"browsing_country.libya" = "Libya"; -"browsing_country.liechtenstein" = "Liechtenstein"; -"browsing_country.lithuania" = "Lithuania"; -"browsing_country.luxembourg" = "Luxembourg"; -"browsing_country.macau" = "Macau"; -"browsing_country.macedonia" = "Macedonia"; -"browsing_country.madagascar" = "Madagascar"; -"browsing_country.malawi" = "Malawi"; -"browsing_country.malaysia" = "Malaysia"; -"browsing_country.maldives" = "Maldives"; -"browsing_country.mali" = "Mali"; -"browsing_country.malta" = "Malta"; -"browsing_country.marshall_islands" = "Marshall Islands"; -"browsing_country.martinique" = "Martinique"; -"browsing_country.mauritania" = "Mauritania"; -"browsing_country.mauritius" = "Mauritius"; -"browsing_country.mayotte" = "Mayotte"; -"browsing_country.mexico" = "Mexico"; -"browsing_country.micronesia" = "Micronesia"; -"browsing_country.moldova" = "Moldova"; -"browsing_country.monaco" = "Monaco"; -"browsing_country.mongolia" = "Mongolia"; -"browsing_country.montenegro" = "Montenegro"; -"browsing_country.montserrat" = "Montserrat"; -"browsing_country.morocco" = "Morocco"; -"browsing_country.mozambique" = "Mozambique"; -"browsing_country.myanmar" = "Myanmar"; -"browsing_country.namibia" = "Namibia"; -"browsing_country.nauru" = "Nauru"; -"browsing_country.nepal" = "Nepal"; -"browsing_country.netherlands" = "Netherlands"; -"browsing_country.new_caledonia" = "New Caledonia"; -"browsing_country.new_zealand" = "New Zealand"; -"browsing_country.nicaragua" = "Nicaragua"; -"browsing_country.niger" = "Niger"; -"browsing_country.nigeria" = "Nigeria"; -"browsing_country.niue" = "Niue"; -"browsing_country.norfolk_island" = "Norfolk Island"; -"browsing_country.north_korea" = "North Korea"; -"browsing_country.northern_mariana_islands" = "Northern Mariana Islands"; -"browsing_country.norway" = "Norway"; -"browsing_country.oman" = "Oman"; -"browsing_country.pakistan" = "Pakistan"; -"browsing_country.palau" = "Palau"; -"browsing_country.palestinian_territory" = "Palestinian Territory"; -"browsing_country.panama" = "Panama"; -"browsing_country.papua_new_guinea" = "Papua New Guinea"; -"browsing_country.paraguay" = "Paraguay"; -"browsing_country.peru" = "Peru"; -"browsing_country.philippines" = "Philippines"; -"browsing_country.pitcairn_islands" = "Pitcairn Islands"; -"browsing_country.poland" = "Poland"; -"browsing_country.portugal" = "Portugal"; -"browsing_country.puerto_rico" = "Puerto Rico"; -"browsing_country.qatar" = "Qatar"; -"browsing_country.reunion" = "Reunion"; -"browsing_country.romania" = "Romania"; -"browsing_country.russian_federation" = "Russian Federation"; -"browsing_country.rwanda" = "Rwanda"; -"browsing_country.saint_barthelemy" = "Saint Barthelemy"; -"browsing_country.saint_helena" = "Saint Helena"; -"browsing_country.saint_kitts_and_nevis" = "Saint Kitts and Nevis"; -"browsing_country.saint_lucia" = "Saint Lucia"; -"browsing_country.saint_martin" = "Saint Martin"; -"browsing_country.saint_pierre_and_miquelon" = "Saint Pierre and Miquelon"; -"browsing_country.saint_vincent_and_the_grenadines" = "Saint Vincent and the Grenadines"; -"browsing_country.samoa" = "Samoa"; -"browsing_country.san_marino" = "San Marino"; -"browsing_country.sao_tome_and_principe" = "Sao Tome and Principe"; -"browsing_country.saudi_arabia" = "Saudi Arabia"; -"browsing_country.senegal" = "Senegal"; -"browsing_country.serbia" = "Serbia"; -"browsing_country.seychelles" = "Seychelles"; -"browsing_country.sierra_leone" = "Sierra Leone"; -"browsing_country.singapore" = "Singapore"; -"browsing_country.sint_maarten" = "Sint Maarten"; -"browsing_country.slovakia" = "Slovakia"; -"browsing_country.slovenia" = "Slovenia"; -"browsing_country.solomon_islands" = "Solomon Islands"; -"browsing_country.somalia" = "Somalia"; -"browsing_country.south_africa" = "South Africa"; -"browsing_country.south_georgia_and_the_south_sandwich_islands" = "South Georgia and the South Sandwich Islands"; -"browsing_country.south_korea" = "South Korea"; -"browsing_country.south_sudan" = "South Sudan"; -"browsing_country.spain" = "Spain"; -"browsing_country.sri_lanka" = "Sri Lanka"; -"browsing_country.sudan" = "Sudan"; -"browsing_country.suriname" = "Suriname"; -"browsing_country.svalbard_and_jan_mayen" = "Svalbard and Jan Mayen"; -"browsing_country.swaziland" = "Swaziland"; -"browsing_country.sweden" = "Sweden"; -"browsing_country.switzerland" = "Switzerland"; -"browsing_country.syrian_arab_republic" = "Syrian Arab Republic"; -"browsing_country.taiwan" = "Taiwan"; -"browsing_country.tajikistan" = "Tajikistan"; -"browsing_country.tanzania" = "Tanzania"; -"browsing_country.thailand" = "Thailand"; -"browsing_country.timor_leste" = "Timor-Leste"; -"browsing_country.togo" = "Togo"; -"browsing_country.tokelau" = "Tokelau"; -"browsing_country.tonga" = "Tonga"; -"browsing_country.trinidad_and_tobago" = "Trinidad and Tobago"; -"browsing_country.tunisia" = "Tunisia"; -"browsing_country.turkey" = "Turkey"; -"browsing_country.turkmenistan" = "Turkmenistan"; -"browsing_country.turks_and_caicos_islands" = "Turks and Caicos Islands"; -"browsing_country.tuvalu" = "Tuvalu"; -"browsing_country.uganda" = "Uganda"; -"browsing_country.ukraine" = "Ukraine"; -"browsing_country.united_arab_emirates" = "United Arab Emirates"; -"browsing_country.united_kingdom" = "United Kingdom"; -"browsing_country.united_states" = "United States"; -"browsing_country.united_states_minor_outlying_islands" = "United States Minor Outlying Islands"; -"browsing_country.uruguay" = "Uruguay"; -"browsing_country.uzbekistan" = "Uzbekistan"; -"browsing_country.vanuatu" = "Vanuatu"; -"browsing_country.venezuela" = "Venezuela"; -"browsing_country.vietnam" = "Vietnam"; -"browsing_country.virgin_islands_british" = "British Virgin Islands"; -"browsing_country.virgin_islands_US" = "U.S. Virgin Islands"; -"browsing_country.wallis_and_futuna" = "Wallis and Futuna"; -"browsing_country.western_sahara" = "Western Sahara"; -"browsing_country.yemen" = "Yemen"; -"browsing_country.zambia" = "Zambia"; -"browsing_country.zimbabwe" = "Zimbabwe"; diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings index 4d717d83d..7a4259485 100644 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings @@ -1,11 +1,6 @@ // MARK: BanInterval -"ban_interval.and" = "and"; // MARK: ToplistsType -"toplists_type.yesterday" = "Yesterday"; -"toplists_type.past_month" = "Past month"; -"toplists_type.past_year" = "Past year"; -"toplists_type.all_time" = "All time"; // MARK: Response @@ -49,33 +44,8 @@ // MARK: AlertView "not_login_viewlogin" = "Login"; "error_view.retry" = "Retry"; -"error_view.try_later" = "Please try again later."; -"error_view.network" = "A network error occurred."; -"error_view.parsing" = "A parsing error occurred."; -"error_view.unknown" = "An unknown error occurred."; -"error_view.not_found" = "There seems to be nothing here."; -"error_view.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; -"error_view.ip_banned" = "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@."; -"error_view.copyright_claim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; -"error_view.gallery_unavailable" = "This gallery has been removed or is unavailable."; // MARK: AppError -"app_error.database_corrupted" = "Database Corrupted"; -"app_error.copyright_claim" = "Copyright Claim"; -"app_error.ip_banned" = "IP Banned"; -"app_error.gallery_expunged" = "Gallery Expunged"; -"app_error.network_error" = "Network Error"; -"app_error.web_image_loading_error" = "Web image loading error"; -"app_error.parse_error" = "Parse Error"; -"app_error.quota_exceeded" = "Quota Exceeded"; -"app_error.authentication_required" = "Authentication Required"; -"app_error.file_operation_failed" = "File Operation Failed"; -"app_error.no_updates_available" = "No updates available"; -"app_error.not_found" = "Not found"; -"app_error.unknown_error" = "Unknown Error"; -"app_error.quota_exceeded_description" = "Image quota exceeded.\nPlease wait and try again later."; -"app_error.authentication_required_description" = "Login required to access this download."; -"app_error.local_file_operation_failed" = "Local file operation failed."; // MARK: ConfirmationDialog "confirmation_dialog.delete_description" = "Are you sure to delete this item?"; @@ -87,10 +57,6 @@ // MARK: NewDawnView // Greeting -"greeting.start" = "You gain "; -"greeting.separator" = ", "; -"greeting.and" = " and "; -"greeting.end" = "!"; // MARK: HomeView "home_view.home" = "Home"; @@ -109,8 +75,6 @@ // MARK: FavoritesView "favorites_view.favorites" = "Favorites"; // FavoriteCategory -"favorite_category.default" = "Favorites %@"; -"favorite_category.all" = "All"; // MARK: SearchView "search_view.search" = "Search"; @@ -134,39 +98,18 @@ // MARK: GeneralSettingView "general_setting_view.language" = "Language"; // AutoLockPolicy -"auto_lock_policy.never" = "Never"; -"auto_lock_policy.instantly" = "Instantly"; // MARK: AppActivityLogsView -"app_activity_logs_view.level.undefined" = "Undefined"; -"app_activity_logs_view.level.debug" = "Debug"; -"app_activity_logs_view.level.info" = "Info"; -"app_activity_logs_view.level.notice" = "Notice"; -"app_activity_logs_view.level.error" = "Error"; -"app_activity_logs_view.level.fault" = "Fault"; // MARK: AppearanceSettingView // PreferredColorScheme -"preferred_color_scheme.automatic" = "Automatic"; -"preferred_color_scheme.light" = "Light"; -"preferred_color_scheme.dark" = "Dark"; // AppIconType -"app_icon_type.default" = "Default"; -"app_icon_type.ukiyoe" = "Ukiyo-e"; -"app_icon_type.developer" = "Developer"; -"app_icon_type.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"app_icon_type.not_my_president" = "NOT MY PRESIDENT"; // ListDisplayMode -"list_display_mode.detail" = "Detail"; -"list_display_mode.thumbnail" = "Thumbnail"; // MARK: AppIconView // MARK: reading_settingView // ReadingDirection -"reading_direction.vertical" = "Vertical"; -"reading_direction.right_to_left" = "Right-to-left"; -"reading_direction.left_to_right" = "Left-to-right"; // MARK: LaboratorySettingView @@ -182,22 +125,16 @@ // MARK: ArchivesView // HathArchive -"hath_archive.free" = "Free"; // ArchiveResolution -"archive_resolution.original" = "Original"; // MARK: TorrentsView // MARK: GalleryInfosView // GalleryVisibility -"gallery_visibility.yes" = "Yes"; -"gallery_visibility.no" = "No (%@)"; -"gallery_visibility.expunged" = "Expunged"; // MARK: TagDetailView // MARK: DownloadsView -"download_folder_filter.all" = "All"; "detail_view.manage_folders" = "Manage Folders"; "downloads_view.manage_folders" = "Manage Folders"; "downloads_view.downloads" = "Downloads"; @@ -229,441 +166,49 @@ // MARK: FiltersView "filters_view.filters" = "Filters"; // FilterRange -"filter_range.search" = "Search"; -"filter_range.global" = "Global"; -"filter_range.watched" = "Watched"; // MARK: EhSettingView // EhSetting.LoadThroughHathSetting -"load_through_hath_setting.any_client" = "Any client"; -"load_through_hath_setting.default_port_only" = "Default port clients only"; -"load_through_hath_setting.modern_no" = "No [Modern/HTTPS]"; -"load_through_hath_setting.legacy_no" = "No [Legacy/HTTP]"; -"load_through_hath_setting.any_client_description" = "Recommended."; -"load_through_hath_setting.default_port_only_description" = "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports."; -"load_through_hath_setting.modern_no_description" = "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems."; -"load_through_hath_setting.legacy_no_description" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; // EhSetting.ImageResolution -"image_resolution.auto" = "Auto"; // EhSetting.GalleryName -"gallery_name.default" = "Default Title"; -"gallery_name.japanese" = "Japanese Title (if available)"; // EhSetting.ArchiverBehavior -"eh_setting.archiver_behavior.manual_select_manual_start" = "Manual Select, Manual Start (Default)"; -"eh_setting.archiver_behavior.manual_select_auto_start" = "Manual Select, Auto Start"; -"eh_setting.archiver_behavior.auto_select_original_manual_start" = "Auto Select Original, Manual Start"; -"eh_setting.archiver_behavior.auto_select_original_auto_start" = "Auto Select Original, Auto Start"; -"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "Auto Select Resample, Manual Start"; -"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "Auto Select Resample, Auto Start"; // EhSetting.DisplayMode -"display_mode.compact" = "Compact"; -"display_mode.thumbnail" = "Thumbnail"; -"display_mode.extended" = "Extended"; -"display_mode.minimal" = "Minimal"; -"display_mode.minimalPlus" = "Minimal+"; // EhSetting.FavoritesSortOrder -"favorites_sort_order.last_update_time" = "By last gallery update time"; -"favorites_sort_order.favorited_time" = "By favorited time"; // EhSetting.ExcludedLanguagesCategory -"excluded_languages_category.original" = "Original"; -"excluded_languages_category.translated" = "Translated"; -"excluded_languages_category.rewrite" = "Rewrite"; // EhSetting.ThumbnailLoadTiming -"thumbnail_load_timing.on_mouse_over" = "On mouse-over"; -"thumbnail_load_timing.on_page_load" = "On page load"; -"thumbnail_load_timing.on_mouse_over_description" = "Pages load faster, but there may be a slight delay before a thumb appears."; -"thumbnail_load_timing.on_page_load_description" = "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded."; // EhSetting.ThumbnailSize -"thumbnail_size.normal" = "Normal"; -"thumbnail_size.large" = "Large"; -"thumbnail_size.small" = "Small"; -"thumbnail_size.auto" = "Auto"; // EhSetting.CommentsSortOrder -"comments_sort_order.oldest" = "Oldest comments first"; -"comments_sort_order.recent" = "Recent comments first"; -"comments_sort_order.highest_score" = "By highest score"; // EhSetting.CommentVotesShowTiming -"comments_votes_show_timing.on_hover_or_click" = "On score hover or click"; -"comments_votes_show_timing.always" = "Always"; // EhSetting.tags_sort_order -"tags_sort_order.alphabetical" = "Alphabetical"; -"tags_sort_order.tag_power" = "By tag power"; // EhSetting.MultiplePageViewerStyle -"multiple_page_viewer_style.align_left_scale_if_over_width" = "Align left, scale if overwidth"; -"multiple_page_viewer_style.align_center_scale_if_over_width" = "Align center, scale if overwidth"; -"multiple_page_viewer_style.align_center_always_scale" = "Align center, always scale"; // EhSetting.GalleryPageNumbering -"gallery_page_numbering.none" = "None"; -"gallery_page_numbering.page_number_only" = "Page Number Only"; -"gallery_page_numbering.page_number_and_name" = "Page Number + Name"; // MARK: Category -"category.doujinshi" = "Doujinshi"; -"category.manga" = "Manga"; -"category.artist_CG" = "Artist CG"; -"category.game_CG" = "Game CG"; -"category.western" = "Western"; -"category.non_h" = "Non-H"; -"category.image_set" = "Image Set"; -"category.cosplay" = "Cosplay"; -"category.asian_porn" = "Asian Porn"; -"category.misc" = "Misc"; -"category.private" = "Private"; // MARK: TagNamespace -"tag_namespace.reclass" = "Reclass"; -"tag_namespace.language" = "Language"; -"tag_namespace.parody" = "Parody"; -"tag_namespace.character" = "Character"; -"tag_namespace.group" = "Group"; -"tag_namespace.artist" = "Artist"; -"tag_namespace.male" = "Male"; -"tag_namespace.female" = "Female"; -"tag_namespace.mixed" = "Mixed"; -"tag_namespace.cosplayer" = "Cosplayer"; -"tag_namespace.other" = "Other"; -"tag_namespace.temp" = "Temp"; // MARK: Language -"language.invalid" = "N/A"; -"language.other" = "Other"; -"language.afrikaans" = "Afrikaans"; -"language.albanian" = "Albanian"; -"language.arabic" = "Arabic"; -"language.bengali" = "Bengali"; -"language.bosnian" = "Bosnian"; -"language.bulgarian" = "Bulgarian"; -"language.burmese" = "Burmese"; -"language.catalan" = "Catalan"; -"language.cebuano" = "Cebuano"; -"language.chinese" = "Chinese"; -"language.croatian" = "Croatian"; -"language.czech" = "Czech"; -"language.danish" = "Danish"; -"language.dutch" = "Dutch"; -"language.english" = "English"; -"language.esperanto" = "Esperanto"; -"language.estonian" = "Estonian"; -"language.finnish" = "Finnish"; -"language.french" = "French"; -"language.georgian" = "Georgian"; -"language.german" = "German"; -"language.greek" = "Greek"; -"language.hebrew" = "Hebrew"; -"language.hindi" = "Hindi"; -"language.hmong" = "Hmong"; -"language.hungarian" = "Hungarian"; -"language.indonesian" = "Indonesian"; -"language.italian" = "Italian"; -"language.japanese" = "Japanese"; -"language.kazakh" = "Kazakh"; -"language.khmer" = "Khmer"; -"language.korean" = "Korean"; -"language.kurdish" = "Kurdish"; -"language.lao" = "Lao"; -"language.latin" = "Latin"; -"language.mongolian" = "Mongolian"; -"language.ndebele" = "Ndebele"; -"language.nepali" = "Nepali"; -"language.norwegian" = "Norwegian"; -"language.oromo" = "Oromo"; -"language.pashto" = "Pashto"; -"language.persian" = "Persian"; -"language.polish" = "Polish"; -"language.portuguese" = "Portuguese"; -"language.punjabi" = "Punjabi"; -"language.romanian" = "Romanian"; -"language.russian" = "Russian"; -"language.sango" = "Sango"; -"language.serbian" = "Serbian"; -"language.shona" = "Shona"; -"language.slovak" = "Slovak"; -"language.slovenian" = "Slovenian"; -"language.somali" = "Somali"; -"language.spanish" = "Spanish"; -"language.swahili" = "Swahili"; -"language.swedish" = "Swedish"; -"language.tagalog" = "Tagalog"; -"language.thai" = "Thai"; -"language.tigrinya" = "Tigrinya"; -"language.turkish" = "Turkish"; -"language.ukrainian" = "Ukrainian"; -"language.urdu" = "Urdu"; -"language.vietnamese" = "Vietnamese"; -"language.zulu" = "Zulu"; // MARK: BrowsingCountry -"browsing_country.auto_detect" = "Auto-Detect"; -"browsing_country.afghanistan" = "Afghanistan"; -"browsing_country.aland_islands" = "Aland Islands"; -"browsing_country.albania" = "Albania"; -"browsing_country.algeria" = "Algeria"; -"browsing_country.american_samoa" = "American Samoa"; -"browsing_country.andorra" = "Andorra"; -"browsing_country.angola" = "Angola"; -"browsing_country.anguilla" = "Anguilla"; -"browsing_country.antarctica" = "Antarctica"; -"browsing_country.antigua_and_barbuda" = "Antigua and Barbuda"; -"browsing_country.argentina" = "Argentina"; -"browsing_country.armenia" = "Armenia"; -"browsing_country.aruba" = "Aruba"; -"browsing_country.asia_pacific_region" = "Asia-Pacific Region"; -"browsing_country.australia" = "Australia"; -"browsing_country.austria" = "Austria"; -"browsing_country.azerbaijan" = "Azerbaijan"; -"browsing_country.bahamas" = "Bahamas"; -"browsing_country.bahrain" = "Bahrain"; -"browsing_country.bangladesh" = "Bangladesh"; -"browsing_country.barbados" = "Barbados"; -"browsing_country.belarus" = "Belarus"; -"browsing_country.belgium" = "Belgium"; -"browsing_country.belize" = "Belize"; -"browsing_country.benin" = "Benin"; -"browsing_country.bermuda" = "Bermuda"; -"browsing_country.bhutan" = "Bhutan"; -"browsing_country.bolivia" = "Bolivia"; -"browsing_country.bonaire_saint_eustatius_and_saba" = "Bonaire Saint Eustatius and Saba"; -"browsing_country.bosnia_and_herzegovina" = "Bosnia and Herzegovina"; -"browsing_country.botswana" = "Botswana"; -"browsing_country.bouvet_island" = "Bouvet Island"; -"browsing_country.brazil" = "Brazil"; -"browsing_country.british_indian_ocean_territory" = "British Indian Ocean Territory"; -"browsing_country.brunei_darussalam" = "Brunei Darussalam"; -"browsing_country.bulgaria" = "Bulgaria"; -"browsing_country.burkina_faso" = "Burkina Faso"; -"browsing_country.burundi" = "Burundi"; -"browsing_country.cambodia" = "Cambodia"; -"browsing_country.cameroon" = "Cameroon"; -"browsing_country.canada" = "Canada"; -"browsing_country.cape_verde" = "Cape Verde"; -"browsing_country.cayman_islands" = "Cayman Islands"; -"browsing_country.central_african_republic" = "Central African Republic"; -"browsing_country.chad" = "Chad"; -"browsing_country.chile" = "Chile"; -"browsing_country.china" = "China"; -"browsing_country.christmas_island" = "Christmas Island"; -"browsing_country.cocos_islands" = "Cocos Islands"; -"browsing_country.colombia" = "Colombia"; -"browsing_country.comoros" = "Comoros"; -"browsing_country.congo" = "Congo"; -"browsing_country.the_democratic_republic_of_the_congo" = "The Democratic Republic of the Congo"; -"browsing_country.cook_islands" = "Cook Islands"; -"browsing_country.costa_rica" = "Costa Rica"; -"browsing_country.cote_d_ivoire" = "Cote D'Ivoire"; -"browsing_country.croatia" = "Croatia"; -"browsing_country.cuba" = "Cuba"; -"browsing_country.curacao" = "Curacao"; -"browsing_country.cyprus" = "Cyprus"; -"browsing_country.czech_republic" = "Czech Republic"; -"browsing_country.denmark" = "Denmark"; -"browsing_country.djibouti" = "Djibouti"; -"browsing_country.dominica" = "Dominica"; -"browsing_country.dominican_republic" = "Dominican Republic"; -"browsing_country.ecuador" = "Ecuador"; -"browsing_country.egypt" = "Egypt"; -"browsing_country.el_salvador" = "El Salvador"; -"browsing_country.equatorial_guinea" = "Equatorial Guinea"; -"browsing_country.eritrea" = "Eritrea"; -"browsing_country.estonia" = "Estonia"; -"browsing_country.ethiopia" = "Ethiopia"; -"browsing_country.europe" = "Europe"; -"browsing_country.falkland_islands" = "Falkland Islands"; -"browsing_country.faroe_islands" = "Faroe Islands"; -"browsing_country.fiji" = "Fiji"; -"browsing_country.finland" = "Finland"; -"browsing_country.france" = "France"; -"browsing_country.french_guiana" = "French Guiana"; -"browsing_country.french_polynesia" = "French Polynesia"; -"browsing_country.french_southern_territories" = "French Southern Territories"; -"browsing_country.gabon" = "Gabon"; -"browsing_country.gambia" = "Gambia"; -"browsing_country.georgia" = "Georgia"; -"browsing_country.germany" = "Germany"; -"browsing_country.ghana" = "Ghana"; -"browsing_country.gibraltar" = "Gibraltar"; -"browsing_country.greece" = "Greece"; -"browsing_country.greenland" = "Greenland"; -"browsing_country.grenada" = "Grenada"; -"browsing_country.guadeloupe" = "Guadeloupe"; -"browsing_country.guam" = "Guam"; -"browsing_country.guatemala" = "Guatemala"; -"browsing_country.guernsey" = "Guernsey"; -"browsing_country.guinea" = "Guinea"; -"browsing_country.guinea_bissau" = "Guinea-Bissau"; -"browsing_country.guyana" = "Guyana"; -"browsing_country.haiti" = "Haiti"; -"browsing_country.heard_island_and_mc_donald_islands" = "Heard Island and McDonald Islands"; -"browsing_country.vatican_city_state" = "Vatican City State"; -"browsing_country.honduras" = "Honduras"; -"browsing_country.hong_kong" = "Hong Kong"; -"browsing_country.hungary" = "Hungary"; -"browsing_country.iceland" = "Iceland"; -"browsing_country.india" = "India"; -"browsing_country.indonesia" = "Indonesia"; -"browsing_country.iran" = "Iran"; -"browsing_country.iraq" = "Iraq"; -"browsing_country.ireland" = "Ireland"; -"browsing_country.isle_of_man" = "Isle of Man"; -"browsing_country.israel" = "Israel"; -"browsing_country.italy" = "Italy"; -"browsing_country.jamaica" = "Jamaica"; -"browsing_country.japan" = "Japan"; -"browsing_country.jersey" = "Jersey"; -"browsing_country.jordan" = "Jordan"; -"browsing_country.kazakhstan" = "Kazakhstan"; -"browsing_country.kenya" = "Kenya"; -"browsing_country.kiribati" = "Kiribati"; -"browsing_country.kuwait" = "Kuwait"; -"browsing_country.kyrgyzstan" = "Kyrgyzstan"; -"browsing_country.lao_peoples_democratic_republic" = "Lao People's Democratic Republic"; -"browsing_country.latvia" = "Latvia"; -"browsing_country.lebanon" = "Lebanon"; -"browsing_country.lesotho" = "Lesotho"; -"browsing_country.liberia" = "Liberia"; -"browsing_country.libya" = "Libya"; -"browsing_country.liechtenstein" = "Liechtenstein"; -"browsing_country.lithuania" = "Lithuania"; -"browsing_country.luxembourg" = "Luxembourg"; -"browsing_country.macau" = "Macau"; -"browsing_country.macedonia" = "Macedonia"; -"browsing_country.madagascar" = "Madagascar"; -"browsing_country.malawi" = "Malawi"; -"browsing_country.malaysia" = "Malaysia"; -"browsing_country.maldives" = "Maldives"; -"browsing_country.mali" = "Mali"; -"browsing_country.malta" = "Malta"; -"browsing_country.marshall_islands" = "Marshall Islands"; -"browsing_country.martinique" = "Martinique"; -"browsing_country.mauritania" = "Mauritania"; -"browsing_country.mauritius" = "Mauritius"; -"browsing_country.mayotte" = "Mayotte"; -"browsing_country.mexico" = "Mexico"; -"browsing_country.micronesia" = "Micronesia"; -"browsing_country.moldova" = "Moldova"; -"browsing_country.monaco" = "Monaco"; -"browsing_country.mongolia" = "Mongolia"; -"browsing_country.montenegro" = "Montenegro"; -"browsing_country.montserrat" = "Montserrat"; -"browsing_country.morocco" = "Morocco"; -"browsing_country.mozambique" = "Mozambique"; -"browsing_country.myanmar" = "Myanmar"; -"browsing_country.namibia" = "Namibia"; -"browsing_country.nauru" = "Nauru"; -"browsing_country.nepal" = "Nepal"; -"browsing_country.netherlands" = "Netherlands"; -"browsing_country.new_caledonia" = "New Caledonia"; -"browsing_country.new_zealand" = "New Zealand"; -"browsing_country.nicaragua" = "Nicaragua"; -"browsing_country.niger" = "Niger"; -"browsing_country.nigeria" = "Nigeria"; -"browsing_country.niue" = "Niue"; -"browsing_country.norfolk_island" = "Norfolk Island"; -"browsing_country.north_korea" = "North Korea"; -"browsing_country.northern_mariana_islands" = "Northern Mariana Islands"; -"browsing_country.norway" = "Norway"; -"browsing_country.oman" = "Oman"; -"browsing_country.pakistan" = "Pakistan"; -"browsing_country.palau" = "Palau"; -"browsing_country.palestinian_territory" = "Palestinian Territory"; -"browsing_country.panama" = "Panama"; -"browsing_country.papua_new_guinea" = "Papua New Guinea"; -"browsing_country.paraguay" = "Paraguay"; -"browsing_country.peru" = "Peru"; -"browsing_country.philippines" = "Philippines"; -"browsing_country.pitcairn_islands" = "Pitcairn Islands"; -"browsing_country.poland" = "Poland"; -"browsing_country.portugal" = "Portugal"; -"browsing_country.puerto_rico" = "Puerto Rico"; -"browsing_country.qatar" = "Qatar"; -"browsing_country.reunion" = "Reunion"; -"browsing_country.romania" = "Romania"; -"browsing_country.russian_federation" = "Russian Federation"; -"browsing_country.rwanda" = "Rwanda"; -"browsing_country.saint_barthelemy" = "Saint Barthelemy"; -"browsing_country.saint_helena" = "Saint Helena"; -"browsing_country.saint_kitts_and_nevis" = "Saint Kitts and Nevis"; -"browsing_country.saint_lucia" = "Saint Lucia"; -"browsing_country.saint_martin" = "Saint Martin"; -"browsing_country.saint_pierre_and_miquelon" = "Saint Pierre and Miquelon"; -"browsing_country.saint_vincent_and_the_grenadines" = "Saint Vincent and the Grenadines"; -"browsing_country.samoa" = "Samoa"; -"browsing_country.san_marino" = "San Marino"; -"browsing_country.sao_tome_and_principe" = "Sao Tome and Principe"; -"browsing_country.saudi_arabia" = "Saudi Arabia"; -"browsing_country.senegal" = "Senegal"; -"browsing_country.serbia" = "Serbia"; -"browsing_country.seychelles" = "Seychelles"; -"browsing_country.sierra_leone" = "Sierra Leone"; -"browsing_country.singapore" = "Singapore"; -"browsing_country.sint_maarten" = "Sint Maarten"; -"browsing_country.slovakia" = "Slovakia"; -"browsing_country.slovenia" = "Slovenia"; -"browsing_country.solomon_islands" = "Solomon Islands"; -"browsing_country.somalia" = "Somalia"; -"browsing_country.south_africa" = "South Africa"; -"browsing_country.south_georgia_and_the_south_sandwich_islands" = "South Georgia and the South Sandwich Islands"; -"browsing_country.south_korea" = "South Korea"; -"browsing_country.south_sudan" = "South Sudan"; -"browsing_country.spain" = "Spain"; -"browsing_country.sri_lanka" = "Sri Lanka"; -"browsing_country.sudan" = "Sudan"; -"browsing_country.suriname" = "Suriname"; -"browsing_country.svalbard_and_jan_mayen" = "Svalbard and Jan Mayen"; -"browsing_country.swaziland" = "Swaziland"; -"browsing_country.sweden" = "Sweden"; -"browsing_country.switzerland" = "Switzerland"; -"browsing_country.syrian_arab_republic" = "Syrian Arab Republic"; -"browsing_country.taiwan" = "Taiwan"; -"browsing_country.tajikistan" = "Tajikistan"; -"browsing_country.tanzania" = "Tanzania"; -"browsing_country.thailand" = "Thailand"; -"browsing_country.timor_leste" = "Timor-Leste"; -"browsing_country.togo" = "Togo"; -"browsing_country.tokelau" = "Tokelau"; -"browsing_country.tonga" = "Tonga"; -"browsing_country.trinidad_and_tobago" = "Trinidad and Tobago"; -"browsing_country.tunisia" = "Tunisia"; -"browsing_country.turkey" = "Turkey"; -"browsing_country.turkmenistan" = "Turkmenistan"; -"browsing_country.turks_and_caicos_islands" = "Turks and Caicos Islands"; -"browsing_country.tuvalu" = "Tuvalu"; -"browsing_country.uganda" = "Uganda"; -"browsing_country.ukraine" = "Ukraine"; -"browsing_country.united_arab_emirates" = "United Arab Emirates"; -"browsing_country.united_kingdom" = "United Kingdom"; -"browsing_country.united_states" = "United States"; -"browsing_country.united_states_minor_outlying_islands" = "United States Minor Outlying Islands"; -"browsing_country.uruguay" = "Uruguay"; -"browsing_country.uzbekistan" = "Uzbekistan"; -"browsing_country.vanuatu" = "Vanuatu"; -"browsing_country.venezuela" = "Venezuela"; -"browsing_country.vietnam" = "Vietnam"; -"browsing_country.virgin_islands_british" = "British Virgin Islands"; -"browsing_country.virgin_islands_US" = "U.S. Virgin Islands"; -"browsing_country.wallis_and_futuna" = "Wallis and Futuna"; -"browsing_country.western_sahara" = "Western Sahara"; -"browsing_country.yemen" = "Yemen"; -"browsing_country.zambia" = "Zambia"; -"browsing_country.zimbabwe" = "Zimbabwe"; diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings index 9c8ee8424..f968c0030 100644 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings @@ -1,11 +1,6 @@ // MARK: BanInterval -"ban_interval.and" = ""; // MARK: ToplistsType -"toplists_type.yesterday" = "昨日"; -"toplists_type.past_month" = "先月"; -"toplists_type.past_year" = "去年"; -"toplists_type.all_time" = "すべて"; // MARK: Response @@ -49,33 +44,8 @@ // MARK: AlertView "not_login_viewlogin" = "ログイン"; "error_view.retry" = "リトライ"; -"error_view.try_later" = "しばらくしてからもう一度お試しください"; -"error_view.network" = "ネットワーク障害が発生しました"; -"error_view.parsing" = "解析中に問題が発生しました"; -"error_view.unknown" = "不明なエラーが発生しました"; -"error_view.not_found" = "ここには何もないようです"; -"error_view.database_corrupted" = "データベースが破損しています。\nGitHub で Issue を作成していただくようお願いいたします。"; -"error_view.ip_banned" = "この IP アドレスを経由して過剰なページロードが行われました。クローラの疑いがあるため、この IP アドレスは一時的にブロックされました。ブロックは %@後に解除されます。"; -"error_view.copyright_claim" = "申し訳ありませんが、このギャラリーは %@ の著作権主張によってアクセス不可になっています。"; -"error_view.gallery_unavailable" = "このギャラリーはすでに削除済みまたは無効です。"; // MARK: AppError -"app_error.database_corrupted" = "データベース破損"; -"app_error.copyright_claim" = "著作権侵害の申し立て"; -"app_error.ip_banned" = "IP アドレスがブロックされました"; -"app_error.gallery_expunged" = "ギャラリー削除済み"; -"app_error.network_error" = "ネットワークエラー"; -"app_error.web_image_loading_error" = "Web 画像の読み込みエラー"; -"app_error.parse_error" = "解析エラー"; -"app_error.quota_exceeded" = "画像割り当て超過"; -"app_error.authentication_required" = "認証が必要です"; -"app_error.file_operation_failed" = "ファイル操作に失敗しました"; -"app_error.no_updates_available" = "利用可能な更新はありません"; -"app_error.not_found" = "見つかりません"; -"app_error.unknown_error" = "不明なエラー"; -"app_error.quota_exceeded_description" = "画像の帯域割り当てを使い切りました。\nしばらく待ってからもう一度お試しください。"; -"app_error.authentication_required_description" = "このダウンロードにアクセスするにはログインが必要です。"; -"app_error.local_file_operation_failed" = "ローカルファイルの操作に失敗しました。"; // MARK: ConfirmationDialog "confirmation_dialog.delete_description" = "本当にこれを削除してもよろしいですか?"; @@ -87,10 +57,6 @@ // MARK: NewDawnView // Greeting -"greeting.start" = ""; -"greeting.separator" = "、"; -"greeting.and" = " と "; -"greeting.end" = "を手に入れた!"; // MARK: HomeView "home_view.home" = "ホーム"; @@ -109,8 +75,6 @@ // MARK: FavoritesView "favorites_view.favorites" = "お気に入り"; // FavoriteCategory -"favorite_category.default" = "お気に入り %@"; -"favorite_category.all" = "すべて"; // MARK: SearchView "search_view.search" = "検索"; @@ -134,39 +98,18 @@ // MARK: GeneralSettingView "general_setting_view.language" = "言語"; // AutoLockPolicy -"auto_lock_policy.never" = "なし"; -"auto_lock_policy.instantly" = "すぐに"; // MARK: AppActivityLogsView -"app_activity_logs_view.level.undefined" = "未定義"; -"app_activity_logs_view.level.debug" = "デバッグ"; -"app_activity_logs_view.level.info" = "情報"; -"app_activity_logs_view.level.notice" = "通知"; -"app_activity_logs_view.level.error" = "エラー"; -"app_activity_logs_view.level.fault" = "障害"; // MARK: AppearanceSettingView // PreferredColorScheme -"preferred_color_scheme.automatic" = "自動"; -"preferred_color_scheme.light" = "ライト"; -"preferred_color_scheme.dark" = "ダーク"; // AppIconType -"app_icon_type.default" = "デフォルト"; -"app_icon_type.ukiyoe" = "浮世絵"; -"app_icon_type.developer" = "デベロッパー"; -"app_icon_type.stand_with_ukraine_2022" = "ウクライナと共に (2022)"; -"app_icon_type.not_my_president" = "私の大統領ではない"; // ListDisplayMode -"list_display_mode.detail" = "詳細"; -"list_display_mode.thumbnail" = "サムネイル"; // MARK: AppIconView // MARK: reading_settingView // ReadingDirection -"reading_direction.vertical" = "縦読み"; -"reading_direction.right_to_left" = "右開き"; -"reading_direction.left_to_right" = "左開き"; // MARK: LaboratorySettingView @@ -182,22 +125,16 @@ // MARK: ArchivesView // HathArchive -"hath_archive.free" = "無料"; // ArchiveResolution -"archive_resolution.original" = "オリジナル"; // MARK: TorrentsView // MARK: GalleryInfosView // GalleryVisibility -"gallery_visibility.yes" = "はい"; -"gallery_visibility.no" = "いいえ (%@)"; -"gallery_visibility.expunged" = "削除済み"; // MARK: TagDetailView // MARK: DownloadsView -"download_folder_filter.all" = "すべて"; "detail_view.manage_folders" = "フォルダを管理"; "downloads_view.manage_folders" = "フォルダを管理"; "downloads_view.downloads" = "ダウンロード"; @@ -229,441 +166,49 @@ // MARK: FiltersView "filters_view.filters" = "フィルター"; // FilterRange -"filter_range.search" = "検索"; -"filter_range.global" = "全般"; -"filter_range.watched" = "タグの購読"; // MARK: EhSettingView // EhSetting.LoadThroughHathSetting -"load_through_hath_setting.any_client" = "任意のクライアント"; -"load_through_hath_setting.default_port_only" = "デフォルトポートのクライアントのみ"; -"load_through_hath_setting.modern_no" = "使わない [モダン / HTTPS]"; -"load_through_hath_setting.legacy_no" = "使わない [レガシー / HTTP]"; -"load_through_hath_setting.any_client_description" = "推奨。"; -"load_through_hath_setting.default_port_only_description" = "遅くなることがあります。非標準発信ポートがファイヤーウォール・プロキシにブロックされた場合のみ有効にしてください。"; -"load_through_hath_setting.modern_no_description" = "寄付者独占オプション。閲覧による割当額の消耗は激しくなります。厳重な問題が起こった場合以外おすすめしません。"; -"load_through_hath_setting.legacy_no_description" = "寄付者独占オプション。モダンブラウザでは機能しないこともあります。レガシー・旧型ブラウザの場合以外おすすめしません。"; // EhSetting.ImageResolution -"image_resolution.auto" = "自動"; // EhSetting.GalleryName -"gallery_name.default" = "デフォルトタイトル"; -"gallery_name.japanese" = "日本語タイトル(可能なら)"; // EhSetting.ArchiverBehavior -"eh_setting.archiver_behavior.manual_select_manual_start" = "手動で選択、手動で開始(デフォルト)"; -"eh_setting.archiver_behavior.manual_select_auto_start" = "手動で選択、自動で開始"; -"eh_setting.archiver_behavior.auto_select_original_manual_start" = "自動でオリジナルを選択、手動で開始"; -"eh_setting.archiver_behavior.auto_select_original_auto_start" = "自動でオリジナルを選択、自動で開始"; -"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "自動でリサンプルを選択、手動で開始"; -"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "自動でリサンプルを選択、自動で開始"; // EhSetting.DisplayMode -"display_mode.compact" = "コンパクト"; -"display_mode.thumbnail" = "サムネイル"; -"display_mode.extended" = "拡張"; -"display_mode.minimal" = "最小化"; -"display_mode.minimalPlus" = "最小化+"; // EhSetting.FavoritesSortOrder -"favorites_sort_order.last_update_time" = "更新時間の新しい順"; -"favorites_sort_order.favorited_time" = "気に入った時間の新しい順"; // EhSetting.ExcludedLanguagesCategory -"excluded_languages_category.original" = "オリジナル"; -"excluded_languages_category.translated" = "翻訳版"; -"excluded_languages_category.rewrite" = "書き換え版"; // EhSetting.ThumbnailLoadTiming -"thumbnail_load_timing.on_mouse_over" = "マウス経過時"; -"thumbnail_load_timing.on_page_load" = "ページ読み込み時"; -"thumbnail_load_timing.on_mouse_over_description" = "ページの読み込みが速くなりますが、サムネイルの表示はちょっぴり遅れてきます。"; -"thumbnail_load_timing.on_page_load_description" = "ページの読み込み時間が増えますが、サムネイルはすぐに表示できます。"; // EhSetting.ThumbnailSize -"thumbnail_size.normal" = "普通"; -"thumbnail_size.large" = "大きめ"; -"thumbnail_size.small" = "小さめ"; -"thumbnail_size.auto" = "自動"; // EhSetting.CommentsSortOrder -"comments_sort_order.oldest" = "コメントの古い順"; -"comments_sort_order.recent" = "コメントの新しい順"; -"comments_sort_order.highest_score" = "スコアの高い順"; // EhSetting.CommentVotesShowTiming -"comments_votes_show_timing.on_hover_or_click" = "スコアに経過・クリック時"; -"comments_votes_show_timing.always" = "常時"; // EhSetting.tags_sort_order -"tags_sort_order.alphabetical" = "アルファベット順"; -"tags_sort_order.tag_power" = "タグパワーの高い順"; // EhSetting.MultiplePageViewerStyle -"multiple_page_viewer_style.align_left_scale_if_over_width" = "左寄せ、幅によってスケール"; -"multiple_page_viewer_style.align_center_scale_if_over_width" = "中央揃え、幅によってスケール"; -"multiple_page_viewer_style.align_center_always_scale" = "中央揃え、常時スケール"; // EhSetting.GalleryPageNumbering -"gallery_page_numbering.none" = "表示しない"; -"gallery_page_numbering.page_number_only" = "ページ番号のみ表示"; -"gallery_page_numbering.page_number_and_name" = "ページ番号と名前を表示"; // MARK: Category -"category.doujinshi" = "同人誌"; -"category.manga" = "漫画"; -"category.artist_CG" = "イラスト"; -"category.game_CG" = "ゲーム CG"; -"category.western" = "西洋"; -"category.non_h" = "健全"; -"category.image_set" = "画像集"; -"category.cosplay" = "コスプレ"; -"category.asian_porn" = "アジア"; -"category.misc" = "その他"; -"category.private" = "プライベート"; // MARK: TagNamespace -"tag_namespace.reclass" = "再分類"; -"tag_namespace.language" = "言語"; -"tag_namespace.parody" = "原作"; -"tag_namespace.character" = "キャラ"; -"tag_namespace.group" = "団体"; -"tag_namespace.artist" = "作者"; -"tag_namespace.male" = "男性"; -"tag_namespace.female" = "女性"; -"tag_namespace.mixed" = "混在性別"; -"tag_namespace.cosplayer" = "レイヤー"; -"tag_namespace.other" = "その他"; -"tag_namespace.temp" = "一時的"; // MARK: Language -"language.invalid" = "無効"; -"language.other" = "その他"; -"language.afrikaans" = "アフリカーンス語"; -"language.albanian" = "アルバニア語"; -"language.arabic" = "アラビア語"; -"language.bengali" = "ベンガル語"; -"language.bosnian" = "ボスニア語"; -"language.bulgarian" = "ブルガリア語"; -"language.burmese" = "ビルマ語"; -"language.catalan" = "カタルーニャ語"; -"language.cebuano" = "セブアノ語"; -"language.chinese" = "中国語"; -"language.croatian" = "クロアチア語"; -"language.czech" = "チェコ語"; -"language.danish" = "デンマーク語"; -"language.dutch" = "オランダ語"; -"language.english" = "英語"; -"language.esperanto" = "国際語"; -"language.estonian" = "エストニア語"; -"language.finnish" = "フィンランド語"; -"language.french" = "フランス語"; -"language.georgian" = "グルジア語"; -"language.german" = "ドイツ語"; -"language.greek" = "ギリシア語"; -"language.hebrew" = "ヘブライ語"; -"language.hindi" = "ヒンディー語"; -"language.hmong" = "ミャオ語"; -"language.hungarian" = "ハンガリー語"; -"language.indonesian" = "インドネシア語"; -"language.italian" = "イタリア語"; -"language.japanese" = "日本語"; -"language.kazakh" = "カザフ語"; -"language.khmer" = "クメール語"; -"language.korean" = "韓国語"; -"language.kurdish" = "クルド語"; -"language.lao" = "ラーオ語"; -"language.latin" = "ラテン語"; -"language.mongolian" = "モンゴル語"; -"language.ndebele" = "ンデベレ語"; -"language.nepali" = "ネパール語"; -"language.norwegian" = "ノルウェー語"; -"language.oromo" = "オロモ語"; -"language.pashto" = "パシュトー語"; -"language.persian" = "ペルシア語"; -"language.polish" = "ポーランド語"; -"language.portuguese" = "ポルトガル語"; -"language.punjabi" = "パンジャーブ語"; -"language.romanian" = "ルーマニア語"; -"language.russian" = "ロシア語"; -"language.sango" = "サンゴ語"; -"language.serbian" = "セルビア語"; -"language.shona" = "ショナ語"; -"language.slovak" = "スロバキア語"; -"language.slovenian" = "スロベニア語"; -"language.somali" = "ソマリ語"; -"language.spanish" = "スペイン語"; -"language.swahili" = "スワヒリ語"; -"language.swedish" = "スウェーデン語"; -"language.tagalog" = "タガログ語"; -"language.thai" = "タイ語"; -"language.tigrinya" = "ティグリニャ語"; -"language.turkish" = "トルコ語"; -"language.ukrainian" = "ウクライナ語"; -"language.urdu" = "ウルドゥー語"; -"language.vietnamese" = "ベトナム語"; -"language.zulu" = "ズールー語"; // MARK: BrowsingCountry -"browsing_country.auto_detect" = "自動検出"; -"browsing_country.afghanistan" = "アフガニスタン"; -"browsing_country.aland_islands" = "オーランド諸島"; -"browsing_country.albania" = "アルバニア"; -"browsing_country.algeria" = "アルジェリア"; -"browsing_country.american_samoa" = "アメリカ領サモア"; -"browsing_country.andorra" = "アンドラ"; -"browsing_country.angola" = "アンゴラ"; -"browsing_country.anguilla" = "アンギラ"; -"browsing_country.antarctica" = "南極大陸"; -"browsing_country.antigua_and_barbuda" = "アンティグア・バーブーダ"; -"browsing_country.argentina" = "アルゼンチン"; -"browsing_country.armenia" = "アルメニア"; -"browsing_country.aruba" = "アルバ"; -"browsing_country.asia_pacific_region" = "アジア太平洋地域"; -"browsing_country.australia" = "オーストラリア"; -"browsing_country.austria" = "オーストリア"; -"browsing_country.azerbaijan" = "アゼルバイジャン"; -"browsing_country.bahamas" = "バハマ"; -"browsing_country.bahrain" = "バーレーン"; -"browsing_country.bangladesh" = "バングラデシュ"; -"browsing_country.barbados" = "バルバドス"; -"browsing_country.belarus" = "ベラルーシ"; -"browsing_country.belgium" = "ベルギー"; -"browsing_country.belize" = "ベリーズ"; -"browsing_country.benin" = "ベナン"; -"browsing_country.bermuda" = "バミューダ諸島"; -"browsing_country.bhutan" = "ブータン"; -"browsing_country.bolivia" = "ボリビア"; -"browsing_country.bonaire_saint_eustatius_and_saba" = "ボネール、シント・ユースタティウスおよびサバ"; -"browsing_country.bosnia_and_herzegovina" = "ボスニア・ヘルツェゴビナ"; -"browsing_country.botswana" = "ボツワナ"; -"browsing_country.bouvet_island" = "ブーベ島"; -"browsing_country.brazil" = "ブラジル"; -"browsing_country.british_indian_ocean_territory" = "イギリス領インド洋地域"; -"browsing_country.brunei_darussalam" = "ブルネイ・ダルサラーム"; -"browsing_country.bulgaria" = "ブルガリア"; -"browsing_country.burkina_faso" = "ブルキナファソ"; -"browsing_country.burundi" = "ブルンジ"; -"browsing_country.cambodia" = "カンボジア"; -"browsing_country.cameroon" = "カメルーン"; -"browsing_country.canada" = "カナダ"; -"browsing_country.cape_verde" = "カーボベルデ"; -"browsing_country.cayman_islands" = "ケイマン諸島"; -"browsing_country.central_african_republic" = "中央アフリカ共和国"; -"browsing_country.chad" = "チャド"; -"browsing_country.chile" = "チリ"; -"browsing_country.china" = "中華人民共和国"; -"browsing_country.christmas_island" = "クリスマス島"; -"browsing_country.cocos_islands" = "ココス諸島"; -"browsing_country.colombia" = "コロンビア"; -"browsing_country.comoros" = "コモロ"; -"browsing_country.congo" = "コンゴ共和国"; -"browsing_country.the_democratic_republic_of_the_congo" = "コンゴ民主共和国"; -"browsing_country.cook_islands" = "クック諸島"; -"browsing_country.costa_rica" = "コスタリカ"; -"browsing_country.cote_d_ivoire" = "コートジボワール"; -"browsing_country.croatia" = "クロアチア"; -"browsing_country.cuba" = "キューバ"; -"browsing_country.curacao" = "キュラソー島"; -"browsing_country.cyprus" = "キプロス"; -"browsing_country.czech_republic" = "チェコ"; -"browsing_country.denmark" = "デンマーク"; -"browsing_country.djibouti" = "ジブチ"; -"browsing_country.dominica" = "ドミニカ"; -"browsing_country.dominican_republic" = "ドミニカ共和国"; -"browsing_country.ecuador" = "エクアドル"; -"browsing_country.egypt" = "エジプト"; -"browsing_country.el_salvador" = "エルサルバドル"; -"browsing_country.equatorial_guinea" = "赤道ギニア"; -"browsing_country.eritrea" = "エリトリア"; -"browsing_country.estonia" = "エストニア"; -"browsing_country.ethiopia" = "エチオピア"; -"browsing_country.europe" = "ヨーロッパ"; -"browsing_country.falkland_islands" = "フォークランド諸島"; -"browsing_country.faroe_islands" = "フェロー諸島"; -"browsing_country.fiji" = "フィジー"; -"browsing_country.finland" = "フィンランド"; -"browsing_country.france" = "フランス"; -"browsing_country.french_guiana" = "フランス領ギアナ"; -"browsing_country.french_polynesia" = "フランス領ポリネシア"; -"browsing_country.french_southern_territories" = "フランス領南方・南極地域"; -"browsing_country.gabon" = "ガボン"; -"browsing_country.gambia" = "ガンビア"; -"browsing_country.georgia" = "ジョージア"; -"browsing_country.germany" = "ドイツ"; -"browsing_country.ghana" = "ガーナ"; -"browsing_country.gibraltar" = "ジブラルタル"; -"browsing_country.greece" = "ギリシャ"; -"browsing_country.greenland" = "グリーンランド"; -"browsing_country.grenada" = "グレナダ"; -"browsing_country.guadeloupe" = "グアドループ"; -"browsing_country.guam" = "グアム"; -"browsing_country.guatemala" = "グアテマラ"; -"browsing_country.guernsey" = "ガーンジー"; -"browsing_country.guinea" = "ギニア"; -"browsing_country.guinea_bissau" = "ギニアビサウ"; -"browsing_country.guyana" = "ガイアナ"; -"browsing_country.haiti" = "ハイチ"; -"browsing_country.heard_island_and_mc_donald_islands" = "ハード島とマクドナルド諸島"; -"browsing_country.vatican_city_state" = "バチカン市国"; -"browsing_country.honduras" = "ホンジュラス"; -"browsing_country.hong_kong" = "香港"; -"browsing_country.hungary" = "ハンガリー"; -"browsing_country.iceland" = "アイスランド"; -"browsing_country.india" = "インド"; -"browsing_country.indonesia" = "インドネシア"; -"browsing_country.iran" = "イラン"; -"browsing_country.iraq" = "イラク"; -"browsing_country.ireland" = "アイルランド"; -"browsing_country.isle_of_man" = "マン島"; -"browsing_country.israel" = "イスラエル"; -"browsing_country.italy" = "イタリア"; -"browsing_country.jamaica" = "ジャマイカ"; -"browsing_country.japan" = "日本"; -"browsing_country.jersey" = "ジャージー"; -"browsing_country.jordan" = "ヨルダン"; -"browsing_country.kazakhstan" = "カザフスタン"; -"browsing_country.kenya" = "ケニア"; -"browsing_country.kiribati" = "キリバス"; -"browsing_country.kuwait" = "クウェート"; -"browsing_country.kyrgyzstan" = "キルギス"; -"browsing_country.lao_peoples_democratic_republic" = "ラオス"; -"browsing_country.latvia" = "ラトビア"; -"browsing_country.lebanon" = "レバノン"; -"browsing_country.lesotho" = "レソト"; -"browsing_country.liberia" = "リベリア"; -"browsing_country.libya" = "リビア"; -"browsing_country.liechtenstein" = "リヒテンシュタイン"; -"browsing_country.lithuania" = "リトアニア"; -"browsing_country.luxembourg" = "ルクセンブルク"; -"browsing_country.macau" = "マカオ"; -"browsing_country.macedonia" = "マケドニア"; -"browsing_country.madagascar" = "マダガスカル"; -"browsing_country.malawi" = "マラウイ"; -"browsing_country.malaysia" = "マレーシア"; -"browsing_country.maldives" = "モルディブ"; -"browsing_country.mali" = "マリ"; -"browsing_country.malta" = "マルタ"; -"browsing_country.marshall_islands" = "マーシャル諸島"; -"browsing_country.martinique" = "マルティニーク"; -"browsing_country.mauritania" = "モーリタニア"; -"browsing_country.mauritius" = "モーリシャス"; -"browsing_country.mayotte" = "マヨット"; -"browsing_country.mexico" = "メキシコ"; -"browsing_country.micronesia" = "ミクロネシア"; -"browsing_country.moldova" = "モルドバ"; -"browsing_country.monaco" = "モナコ"; -"browsing_country.mongolia" = "モンゴル"; -"browsing_country.montenegro" = "モンテネグロ"; -"browsing_country.montserrat" = "モントセラト"; -"browsing_country.morocco" = "モロッコ"; -"browsing_country.mozambique" = "モザンビーク"; -"browsing_country.myanmar" = "ミャンマー"; -"browsing_country.namibia" = "ナミビア"; -"browsing_country.nauru" = "ナウル"; -"browsing_country.nepal" = "ネパール"; -"browsing_country.netherlands" = "オランダ"; -"browsing_country.new_caledonia" = "ニューカレドニア"; -"browsing_country.new_zealand" = "ニュージーランド"; -"browsing_country.nicaragua" = "ニカラグア"; -"browsing_country.niger" = "ニジェール"; -"browsing_country.nigeria" = "ナイジェリア"; -"browsing_country.niue" = "ニウエ"; -"browsing_country.norfolk_island" = "ノーフォーク島"; -"browsing_country.north_korea" = "朝鮮"; -"browsing_country.northern_mariana_islands" = "北マリアナ諸島"; -"browsing_country.norway" = "ノルウェー"; -"browsing_country.oman" = "オマーン"; -"browsing_country.pakistan" = "パキスタン"; -"browsing_country.palau" = "パラオ"; -"browsing_country.palestinian_territory" = "パレスチナ"; -"browsing_country.panama" = "パナマ"; -"browsing_country.papua_new_guinea" = "パプアニューギニア"; -"browsing_country.paraguay" = "パラグアイ"; -"browsing_country.peru" = "ペルー"; -"browsing_country.philippines" = "フィリピン"; -"browsing_country.pitcairn_islands" = "ピトケアン諸島"; -"browsing_country.poland" = "ポーランド"; -"browsing_country.portugal" = "ポルトガル"; -"browsing_country.puerto_rico" = "プエルトリコ"; -"browsing_country.qatar" = "カタール"; -"browsing_country.reunion" = "ユニオン"; -"browsing_country.romania" = "ルーマニア"; -"browsing_country.russian_federation" = "ロシア"; -"browsing_country.rwanda" = "ルワンダ"; -"browsing_country.saint_barthelemy" = "サン・バルテルミー島"; -"browsing_country.saint_helena" = "セントヘレナ"; -"browsing_country.saint_kitts_and_nevis" = "セントクリストファー・ネービス"; -"browsing_country.saint_lucia" = "セントルシア"; -"browsing_country.saint_martin" = "サン・マルタン島"; -"browsing_country.saint_pierre_and_miquelon" = "サンピエール島・ミクロン島"; -"browsing_country.saint_vincent_and_the_grenadines" = "セントビンセントおよびグレナディーン諸島"; -"browsing_country.samoa" = "サモア"; -"browsing_country.san_marino" = "サンマリノ"; -"browsing_country.sao_tome_and_principe" = "サントメ・プリンシペ"; -"browsing_country.saudi_arabia" = "サウジアラビア"; -"browsing_country.senegal" = "セネガル"; -"browsing_country.serbia" = "セルビア"; -"browsing_country.seychelles" = "セーシェル"; -"browsing_country.sierra_leone" = "シエラレオネ"; -"browsing_country.singapore" = "シンガポール"; -"browsing_country.sint_maarten" = "シント・マールテン"; -"browsing_country.slovakia" = "スロバキア"; -"browsing_country.slovenia" = "スロベニア"; -"browsing_country.solomon_islands" = "ソロモン諸島"; -"browsing_country.somalia" = "ソマリア"; -"browsing_country.south_africa" = "南アフリカ"; -"browsing_country.south_georgia_and_the_south_sandwich_islands" = "サウスジョージア・サウスサンドウィッチ諸島"; -"browsing_country.south_korea" = "韓国"; -"browsing_country.south_sudan" = "南スーダン"; -"browsing_country.spain" = "スペイン"; -"browsing_country.sri_lanka" = "スリランカ"; -"browsing_country.sudan" = "スーダン"; -"browsing_country.suriname" = "スリナム"; -"browsing_country.svalbard_and_jan_mayen" = "スヴァールバル諸島およびヤンマイエン島"; -"browsing_country.swaziland" = "エスワティニ"; -"browsing_country.sweden" = "スウェーデン"; -"browsing_country.switzerland" = "スイス"; -"browsing_country.syrian_arab_republic" = "シリア"; -"browsing_country.taiwan" = "台湾"; -"browsing_country.tajikistan" = "タジキスタン"; -"browsing_country.tanzania" = "タンザニア"; -"browsing_country.thailand" = "タイ"; -"browsing_country.timor_leste" = "東ティモール"; -"browsing_country.togo" = "トーゴ"; -"browsing_country.tokelau" = "トケラウ"; -"browsing_country.tonga" = "トンガ"; -"browsing_country.trinidad_and_tobago" = "トリニダード・トバゴ"; -"browsing_country.tunisia" = "チュニジア"; -"browsing_country.turkey" = "トルコ"; -"browsing_country.turkmenistan" = "トルクメニスタン"; -"browsing_country.turks_and_caicos_islands" = "タークス・カイコス諸島"; -"browsing_country.tuvalu" = "ツバル"; -"browsing_country.uganda" = "ウガンダ"; -"browsing_country.ukraine" = "ウクライナ"; -"browsing_country.united_arab_emirates" = "アラブ首長国連邦"; -"browsing_country.united_kingdom" = "イギリス"; -"browsing_country.united_states" = "アメリカ"; -"browsing_country.united_states_minor_outlying_islands" = "合衆国領有小離島"; -"browsing_country.uruguay" = "ウルグアイ"; -"browsing_country.uzbekistan" = "ウズベキスタン"; -"browsing_country.vanuatu" = "バヌアツ"; -"browsing_country.venezuela" = "ベネズエラ"; -"browsing_country.vietnam" = "ベトナム"; -"browsing_country.virgin_islands_british" = "イギリス領バージン諸島"; -"browsing_country.virgin_islands_US" = "アメリカ領ヴァージン諸島"; -"browsing_country.wallis_and_futuna" = "ウォリス・フツナ"; -"browsing_country.western_sahara" = "西サハラ"; -"browsing_country.yemen" = "イエメン"; -"browsing_country.zambia" = "ザンビア"; -"browsing_country.zimbabwe" = "ジンバブエ"; diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings index aa4c40e25..816286a01 100644 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings @@ -1,11 +1,6 @@ // MARK: BanInterval -"ban_interval.and" = ""; // MARK: ToplistsType -"toplists_type.yesterday" = "어제"; -"toplists_type.past_month" = "지난 달"; -"toplists_type.past_year" = "지난 해"; -"toplists_type.all_time" = "전체"; // MARK: Response @@ -49,33 +44,8 @@ // MARK: AlertView "not_login_viewlogin" = "로그인"; "error_view.retry" = "재시도"; -"error_view.try_later" = "잠시 후 다시 시도해 주세요."; -"error_view.network" = "인터넷 접속 오류가 발생했어요."; -"error_view.parsing" = "구분 분석 오류가 발생했어요."; -"error_view.unknown" = "알 수 없는 오류가 발생했어요."; -"error_view.not_found" = "여기가 아무도 없는 것 같습니다."; -"error_view.database_corrupted" = "데이터베이스가 손상되었어요.\nGitHub에 이슈를 남겨주세요."; -"error_view.ip_banned" = "자동화된 미러링/수집 소프트웨어 사용이 의심되는 과도한 페이지 로드로 인해 IP 주소가 일시적으로 차단되었어요. 차단은 %@ 후에 해제돼요."; -"error_view.copyright_claim" = "%@의 저작권 요청으로 인하여 이 갤러리를 사용할 수 없어요."; -"error_view.gallery_unavailable" = "이 갤러리는 제거되었거나 사용할 수 없어요."; // MARK: AppError -"app_error.database_corrupted" = "데이터베이스 손상"; -"app_error.copyright_claim" = "저작권 신고"; -"app_error.ip_banned" = "IP 차단됨"; -"app_error.gallery_expunged" = "갤러리 삭제됨"; -"app_error.network_error" = "네트워크 오류"; -"app_error.web_image_loading_error" = "웹 이미지 로드 오류"; -"app_error.parse_error" = "파싱 오류"; -"app_error.quota_exceeded" = "할당량 초과"; -"app_error.authentication_required" = "인증 필요"; -"app_error.file_operation_failed" = "파일 작업 실패"; -"app_error.no_updates_available" = "사용 가능한 업데이트 없음"; -"app_error.not_found" = "찾을 수 없음"; -"app_error.unknown_error" = "알 수 없는 오류"; -"app_error.quota_exceeded_description" = "이미지 할당량을 모두 사용했습니다.\n잠시 후 다시 시도해 주세요."; -"app_error.authentication_required_description" = "이 다운로드에 접근하려면 로그인해야 합니다."; -"app_error.local_file_operation_failed" = "로컬 파일 작업에 실패했습니다."; // MARK: ConfirmationDialog "confirmation_dialog.delete_description" = "이 항목을 삭제하시겠어요?"; @@ -87,10 +57,6 @@ // MARK: NewDawnView // Greeting -"greeting.start" = ""; -"greeting.separator" = ", "; -"greeting.and" = " 과 "; -"greeting.end" = "획득했어요!"; // MARK: HomeView "home_view.home" = "홈"; @@ -109,8 +75,6 @@ // MARK: FavoritesView "favorites_view.favorites" = "즐겨찾기"; // FavoriteCategory -"favorite_category.default" = "즐겨찾기 %@"; -"favorite_category.all" = "모두"; // MARK: SearchView "search_view.search" = "검색"; @@ -134,39 +98,18 @@ // MARK: GeneralSettingView "general_setting_view.language" = "언어"; // AutoLockPolicy -"auto_lock_policy.never" = "안 함"; -"auto_lock_policy.instantly" = "즉시"; // MARK: AppActivityLogsView -"app_activity_logs_view.level.undefined" = "정의되지 않음"; -"app_activity_logs_view.level.debug" = "디버그"; -"app_activity_logs_view.level.info" = "정보"; -"app_activity_logs_view.level.notice" = "알림"; -"app_activity_logs_view.level.error" = "오류"; -"app_activity_logs_view.level.fault" = "결함"; // MARK: AppearanceSettingView // PreferredColorScheme -"preferred_color_scheme.automatic" = "자동"; -"preferred_color_scheme.light" = "라이트"; -"preferred_color_scheme.dark" = "다크"; // AppIconType -"app_icon_type.default" = "기본"; -"app_icon_type.ukiyoe" = "우키요에"; -"app_icon_type.developer" = "개발자"; -"app_icon_type.stand_with_ukraine_2022" = "우크라이나와 함께 (2022)"; -"app_icon_type.not_my_president" = "내 대통령이 아니다"; // ListDisplayMode -"list_display_mode.detail" = "자세히"; -"list_display_mode.thumbnail" = "썸네일"; // MARK: AppIconView // MARK: reading_settingView // ReadingDirection -"reading_direction.vertical" = "위에서 아래로"; -"reading_direction.right_to_left" = "오른쪽에서 왼쪽으로"; -"reading_direction.left_to_right" = "왼쪽에서 오른쪽으로"; // MARK: LaboratorySettingView @@ -182,22 +125,16 @@ // MARK: ArchivesView // HathArchive -"hath_archive.free" = "무료"; // ArchiveResolution -"archive_resolution.original" = "원본"; // MARK: TorrentsView // MARK: GalleryInfosView // GalleryVisibility -"gallery_visibility.yes" = "네"; -"gallery_visibility.no" = "아니요 (%@)"; -"gallery_visibility.expunged" = "삭제됨"; // MARK: TagDetailView // MARK: DownloadsView -"download_folder_filter.all" = "전체"; "detail_view.manage_folders" = "폴더 관리"; "downloads_view.manage_folders" = "폴더 관리"; "downloads_view.downloads" = "다운로드"; @@ -229,441 +166,49 @@ // MARK: FiltersView "filters_view.filters" = "필터"; // FilterRange -"filter_range.search" = "검색"; -"filter_range.global" = "전체"; -"filter_range.watched" = "주시 태그"; // MARK: EhSettingView // EhSetting.LoadThroughHathSetting -"load_through_hath_setting.any_client" = "어떤 클라이언트에서든"; -"load_through_hath_setting.default_port_only" = "기본 포트 클라이언트만"; -"load_through_hath_setting.modern_no" = "아닙니다 [Modern/HTTPS]"; -"load_through_hath_setting.legacy_no" = "아닙니다 [Legacy/HTTP]"; -"load_through_hath_setting.any_client_description" = "추천."; -"load_through_hath_setting.default_port_only_description" = "더 느려질 수 있어요. 나가는 비표준 포트를 차단하는 방화벽/프록시가 있는 경우 사용하세요."; -"load_through_hath_setting.modern_no_description" = "기부자 전용 기능이에요. 심각한 문제가 있는 경우를 제외하고는 사용하지 말아주세요."; -"load_through_hath_setting.legacy_no_description" = "기부자 전용 기능이에요. 최신 브라우저에서는 제대로 작동하지 않을 수 있어요. 오래된 브라우저에서만 사용해주세요."; // EhSetting.ImageResolution -"image_resolution.auto" = "자동"; // EhSetting.GalleryName -"gallery_name.default" = "영어 제목"; -"gallery_name.japanese" = "일본어 제목(가능하면)"; // EhSetting.ArchiverBehavior -"eh_setting.archiver_behavior.manual_select_manual_start" = "수동 선택, 수동 시작 (기본)"; -"eh_setting.archiver_behavior.manual_select_auto_start" = "수동 선택, 자동 시작"; -"eh_setting.archiver_behavior.auto_select_original_manual_start" = "자동으로 원본을 선택, 수동 시작"; -"eh_setting.archiver_behavior.auto_select_original_auto_start" = "자동으로 원본을 선택, 자동 시작"; -"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "자동으로 저화질을 선택, 수동 시작"; -"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "자동으로 저화질을 선택, 자동 시작"; // EhSetting.DisplayMode -"display_mode.compact" = "컴팩트"; -"display_mode.thumbnail" = "썸네일"; -"display_mode.extended" = "확장"; -"display_mode.minimal" = "미니멀"; -"display_mode.minimalPlus" = "미니멀+"; // EhSetting.FavoritesSortOrder -"favorites_sort_order.last_update_time" = "마지막 업데이트 시간으로"; -"favorites_sort_order.favorited_time" = "별점 시간으로"; // EhSetting.ExcludedLanguagesCategory -"excluded_languages_category.original" = "원본"; -"excluded_languages_category.translated" = "번역됨"; -"excluded_languages_category.rewrite" = "다시 쓰기"; // EhSetting.ThumbnailLoadTiming -"thumbnail_load_timing.on_mouse_over" = "마우스를 올릴 때"; -"thumbnail_load_timing.on_page_load" = "페이지 로드될 때"; -"thumbnail_load_timing.on_mouse_over_description" = "페이지가 더 빨리 로드되지만 엄지손가락이 나타나기 전까지 약간의 지연이 있을 수 있어요."; -"thumbnail_load_timing.on_page_load_description" = "페이지 로드에 시간이 더 오래 걸리지만, 페이지가 로드된 후 썸네일을 로드하는데 지연이 없어요."; // EhSetting.ThumbnailSize -"thumbnail_size.normal" = "보통"; -"thumbnail_size.large" = "크게"; -"thumbnail_size.small" = "작게"; -"thumbnail_size.auto" = "자동"; // EhSetting.CommentsSortOrder -"comments_sort_order.oldest" = "가장 이른 순서"; -"comments_sort_order.recent" = "최신순"; -"comments_sort_order.highest_score" = "평가가 가장 높은 순서"; // EhSetting.CommentVotesShowTiming -"comments_votes_show_timing.on_hover_or_click" = "점수를 가리키커나 클리하기"; -"comments_votes_show_timing.always" = "항상"; // EhSetting.tags_sort_order -"tags_sort_order.alphabetical" = "알파벳순으로"; -"tags_sort_order.tag_power" = "태크 가중치로"; // EhSetting.MultiplePageViewerStyle -"multiple_page_viewer_style.align_left_scale_if_over_width" = "왼쪽 정렬, 너비 초과할 때 크기 맞추기"; -"multiple_page_viewer_style.align_center_scale_if_over_width" = "가운데 정렬, 너비 초과할 때 크기 맞추기"; -"multiple_page_viewer_style.align_center_always_scale" = "가운데 정렬, 항상 크기 맞추기"; // EhSetting.GalleryPageNumbering -"gallery_page_numbering.none" = "표시 안 함"; -"gallery_page_numbering.page_number_only" = "페이지 번호만"; -"gallery_page_numbering.page_number_and_name" = "페이지 번호와 이름"; // MARK: Category -"category.doujinshi" = "동인지"; -"category.manga" = "만화"; -"category.artist_CG" = "일러스트"; -"category.game_CG" = "게임 CG"; -"category.western" = "서양"; -"category.non_h" = "Non-H"; -"category.image_set" = "포토북"; -"category.cosplay" = "코스프레"; -"category.asian_porn" = "Asian Porn"; -"category.misc" = "기타"; -"category.private" = "Private"; // MARK: TagNamespace -"tag_namespace.reclass" = "재분류"; -"tag_namespace.language" = "언어"; -"tag_namespace.parody" = "원작"; -"tag_namespace.character" = "캐릭터"; -"tag_namespace.group" = "그룹"; -"tag_namespace.artist" = "작가"; -"tag_namespace.male" = "남성"; -"tag_namespace.female" = "여성"; -"tag_namespace.mixed" = "Mixed"; -"tag_namespace.cosplayer" = "Cosplayer"; -"tag_namespace.other" = "Other"; -"tag_namespace.temp" = "Temp"; // MARK: Language -"language.invalid" = "무효"; -"language.other" = "Other"; -"language.afrikaans" = "아프리칸스어"; -"language.albanian" = "알바니아어"; -"language.arabic" = "아랍어"; -"language.bengali" = "벵갈어"; -"language.bosnian" = "보스니아어"; -"language.bulgarian" = "불가리아어"; -"language.burmese" = "버마어"; -"language.catalan" = "카탈루냐어"; -"language.cebuano" = "세부어"; -"language.chinese" = "중국어"; -"language.croatian" = "크로아티아어"; -"language.czech" = "체코어"; -"language.danish" = "덴마크어"; -"language.dutch" = "네덜란드어"; -"language.english" = "영어"; -"language.esperanto" = "국제어"; -"language.estonian" = "에스토니아어"; -"language.finnish" = "핀란드어"; -"language.french" = "프랑스어"; -"language.georgian" = "그루지야어"; -"language.german" = "독일어"; -"language.greek" = "그리스어"; -"language.hebrew" = "히브리어"; -"language.hindi" = "힌디어"; -"language.hmong" = "묘어"; -"language.hungarian" = "헝가리어"; -"language.indonesian" = "인도네시아어"; -"language.italian" = "이탈리아어"; -"language.japanese" = "일본어"; -"language.kazakh" = "카자흐어"; -"language.khmer" = "크메르원"; -"language.korean" = "한국어"; -"language.kurdish" = "쿠르드어"; -"language.lao" = "라오스어"; -"language.latin" = "라틴어"; -"language.mongolian" = "몽골어"; -"language.ndebele" = "은데벨리어"; -"language.nepali" = "네팔어"; -"language.norwegian" = "노르웨이어로"; -"language.oromo" = "오로모어"; -"language.pashto" = "파슈토어"; -"language.persian" = "페르시아어"; -"language.polish" = "폴란드어"; -"language.portuguese" = "포르투갈어"; -"language.punjabi" = "펀자브어"; -"language.romanian" = "루마니아어"; -"language.russian" = "러시아어"; -"language.sango" = "쌍고어"; -"language.serbian" = "세르비아어"; -"language.shona" = "쇼나어"; -"language.slovak" = "슬로바키아어"; -"language.slovenian" = "슬로베니아어"; -"language.somali" = "소말리아어"; -"language.spanish" = "스페인어"; -"language.swahili" = "스와히리어로"; -"language.swedish" = "스웨덴어"; -"language.tagalog" = "타갈로어"; -"language.thai" = "타이어"; -"language.tigrinya" = "티글리니아어"; -"language.turkish" = "터키어"; -"language.ukrainian" = "우크라이나어"; -"language.urdu" = "우르두어"; -"language.vietnamese" = "베트남어"; -"language.zulu" = "줄루어"; // MARK: BrowsingCountry -"browsing_country.auto_detect" = "자동으로 설정"; -"browsing_country.afghanistan" = "아프가니스탄"; -"browsing_country.aland_islands" = "알란드 제도"; -"browsing_country.albania" = "알바니아"; -"browsing_country.algeria" = "알제리아"; -"browsing_country.american_samoa" = "아메리칸 사모아"; -"browsing_country.andorra" = "안도라"; -"browsing_country.angola" = "앙골라"; -"browsing_country.anguilla" = "안젤라"; -"browsing_country.antarctica" = "남극"; -"browsing_country.antigua_and_barbuda" = "앤티가 바부다"; -"browsing_country.argentina" = "아르헨티나"; -"browsing_country.armenia" = "아르메니아"; -"browsing_country.aruba" = "아루바 섬"; -"browsing_country.asia_pacific_region" = "아시아 태평양 영역"; -"browsing_country.australia" = "호주"; -"browsing_country.austria" = "오스트리아"; -"browsing_country.azerbaijan" = "아제르바이잔"; -"browsing_country.bahamas" = "바하마스"; -"browsing_country.bahrain" = "바레인"; -"browsing_country.bangladesh" = "방글라데시"; -"browsing_country.barbados" = "바베이도스"; -"browsing_country.belarus" = "벨라루스"; -"browsing_country.belgium" = "벨기에"; -"browsing_country.belize" = "벨리즈"; -"browsing_country.benin" = "베냉"; -"browsing_country.bermuda" = "버뮤다"; -"browsing_country.bhutan" = "부탄"; -"browsing_country.bolivia" = "볼리비아"; -"browsing_country.bonaire_saint_eustatius_and_saba" = "보네르 성 유스타티우스와 사바"; -"browsing_country.bosnia_and_herzegovina" = "보스니아 헤르체코비나 "; -"browsing_country.botswana" = "보츠와나"; -"browsing_country.bouvet_island" = "부베섬"; -"browsing_country.brazil" = "브라질"; -"browsing_country.british_indian_ocean_territory" = "영국령 인도양 식민지"; -"browsing_country.brunei_darussalam" = "브루나이 다루살람"; -"browsing_country.bulgaria" = "불가리아"; -"browsing_country.burkina_faso" = "부르키나 파소"; -"browsing_country.burundi" = "부룬디"; -"browsing_country.cambodia" = "캄보디아"; -"browsing_country.cameroon" = "카메룬"; -"browsing_country.canada" = "캐나다"; -"browsing_country.cape_verde" = "포르투갈어"; -"browsing_country.cayman_islands" = "케이맨 제도"; -"browsing_country.central_african_republic" = "중앙아프리카 공화국"; -"browsing_country.chad" = "차드"; -"browsing_country.chile" = "칠레"; -"browsing_country.china" = "중국"; -"browsing_country.christmas_island" = "크리스마스 섬"; -"browsing_country.cocos_islands" = "코코스 제도"; -"browsing_country.colombia" = "콜롬비아"; -"browsing_country.comoros" = "코모로"; -"browsing_country.congo" = "콩고"; -"browsing_country.the_democratic_republic_of_the_congo" = "콩고민주공화국"; -"browsing_country.cook_islands" = "쿡제도"; -"browsing_country.costa_rica" = "코스타리카"; -"browsing_country.cote_d_ivoire" = "코트디부아르"; -"browsing_country.croatia" = "크로아티아"; -"browsing_country.cuba" = "쿠바"; -"browsing_country.curacao" = "큐라소"; -"browsing_country.cyprus" = "키프로스"; -"browsing_country.czech_republic" = "체코 공화국"; -"browsing_country.denmark" = "덴마크"; -"browsing_country.djibouti" = "지부티"; -"browsing_country.dominica" = "도미니카"; -"browsing_country.dominican_republic" = "도미니카 공화국"; -"browsing_country.ecuador" = "에콰도르"; -"browsing_country.egypt" = "이집트"; -"browsing_country.el_salvador" = "엘살바도르"; -"browsing_country.equatorial_guinea" = "적도 기니"; -"browsing_country.eritrea" = "에리트레아"; -"browsing_country.estonia" = "에스토니아"; -"browsing_country.ethiopia" = "에티오피아"; -"browsing_country.europe" = "유럽"; -"browsing_country.falkland_islands" = "포클랜드 제도"; -"browsing_country.faroe_islands" = "페로스 제도"; -"browsing_country.fiji" = "피지"; -"browsing_country.finland" = "핀란드"; -"browsing_country.france" = "프랑스"; -"browsing_country.french_guiana" = "프랑스령 기아나"; -"browsing_country.french_polynesia" = "프랑스령 폴리네시아"; -"browsing_country.french_southern_territories" = "프랑스령 남부와 남극지역"; -"browsing_country.gabon" = "가봉"; -"browsing_country.gambia" = "감비아"; -"browsing_country.georgia" = "그루지야"; -"browsing_country.germany" = "독일"; -"browsing_country.ghana" = "가나"; -"browsing_country.gibraltar" = "지브롤터"; -"browsing_country.greece" = "희랍"; -"browsing_country.greenland" = "그린란드"; -"browsing_country.grenada" = "그레나다"; -"browsing_country.guadeloupe" = "과들루프 섬"; -"browsing_country.guam" = "괌"; -"browsing_country.guatemala" = "과테말라"; -"browsing_country.guernsey" = "건지종 젖소"; -"browsing_country.guinea" = "기니"; -"browsing_country.guinea_bissau" = "기니비사우"; -"browsing_country.guyana" = "가이아나"; -"browsing_country.haiti" = "아이티"; -"browsing_country.heard_island_and_mc_donald_islands" = "허드 맥도널드 제도"; -"browsing_country.vatican_city_state" = "바티칸 시국"; -"browsing_country.honduras" = "온두라스"; -"browsing_country.hong_kong" = "홍콩"; -"browsing_country.hungary" = "헝가리"; -"browsing_country.iceland" = "Iceland"; -"browsing_country.india" = "인도"; -"browsing_country.indonesia" = "인도네시아"; -"browsing_country.iran" = "이란"; -"browsing_country.iraq" = "이라크"; -"browsing_country.ireland" = "아일랜드"; -"browsing_country.isle_of_man" = "맨 섬"; -"browsing_country.israel" = "이스라엘"; -"browsing_country.italy" = "이탈리아"; -"browsing_country.jamaica" = "자마이카"; -"browsing_country.japan" = "일본"; -"browsing_country.jersey" = "저시"; -"browsing_country.jordan" = "요단"; -"browsing_country.kazakhstan" = "카자흐스탄"; -"browsing_country.kenya" = "케냐"; -"browsing_country.kiribati" = "키리바시"; -"browsing_country.kuwait" = "쿠웨이트"; -"browsing_country.kyrgyzstan" = "키르기스스탄"; -"browsing_country.lao_peoples_democratic_republic" = "라오 인민민주공화국"; -"browsing_country.latvia" = "라트비아"; -"browsing_country.lebanon" = "레바논"; -"browsing_country.lesotho" = "레소토"; -"browsing_country.liberia" = "리베리아"; -"browsing_country.libya" = "리비아"; -"browsing_country.liechtenstein" = "리히텐슈타인"; -"browsing_country.lithuania" = "리투아니아"; -"browsing_country.luxembourg" = "룩셈부르크"; -"browsing_country.macau" = "마카오"; -"browsing_country.macedonia" = "마케도니아"; -"browsing_country.madagascar" = "마다스카르"; -"browsing_country.malawi" = "말라위"; -"browsing_country.malaysia" = "말레이시아"; -"browsing_country.maldives" = "말디브"; -"browsing_country.mali" = "말리"; -"browsing_country.malta" = "말타"; -"browsing_country.marshall_islands" = "마샬군도"; -"browsing_country.martinique" = "마르티니크"; -"browsing_country.mauritania" = "모리타니아"; -"browsing_country.mauritius" = "모리셔스"; -"browsing_country.mayotte" = "마요트 섬"; -"browsing_country.mexico" = "맥시코"; -"browsing_country.micronesia" = "마크로네시아"; -"browsing_country.moldova" = "몰도바"; -"browsing_country.monaco" = "모나코"; -"browsing_country.mongolia" = "몽콜"; -"browsing_country.montenegro" = "몬테네그로"; -"browsing_country.montserrat" = "몬트세라트섬"; -"browsing_country.morocco" = "모로코가족"; -"browsing_country.mozambique" = "모잠비크"; -"browsing_country.myanmar" = "미얀마"; -"browsing_country.namibia" = "나미비아"; -"browsing_country.nauru" = "나우루"; -"browsing_country.nepal" = "네팔"; -"browsing_country.netherlands" = "네덜란드"; -"browsing_country.new_caledonia" = "뉴칼레도니아"; -"browsing_country.new_zealand" = "뉴질랜드"; -"browsing_country.nicaragua" = "나카라과"; -"browsing_country.niger" = "니제르"; -"browsing_country.nigeria" = "나이지리아"; -"browsing_country.niue" = "니우에 섬"; -"browsing_country.norfolk_island" = "노퍽섬"; -"browsing_country.north_korea" = "북한"; -"browsing_country.northern_mariana_islands" = "북마리아나제도"; -"browsing_country.norway" = "노르웨이"; -"browsing_country.oman" = "오만"; -"browsing_country.pakistan" = "파키스탄"; -"browsing_country.palau" = "팔라우"; -"browsing_country.palestinian_territory" = "팔레스타인의 지역"; -"browsing_country.panama" = "파나마모자"; -"browsing_country.papua_new_guinea" = "파푸아뉴기니"; -"browsing_country.paraguay" = "파라과이"; -"browsing_country.peru" = "페루"; -"browsing_country.philippines" = "필리핀"; -"browsing_country.pitcairn_islands" = "핏케언 제도"; -"browsing_country.poland" = "폴란드"; -"browsing_country.portugal" = "포르투갈"; -"browsing_country.puerto_rico" = "푸에르토리코"; -"browsing_country.qatar" = "카타로"; -"browsing_country.reunion" = "레워니옹"; -"browsing_country.romania" = "루마니아"; -"browsing_country.russian_federation" = "러시아 연방"; -"browsing_country.rwanda" = "르완다"; -"browsing_country.saint_barthelemy" = "생바르텔레미"; -"browsing_country.saint_helena" = "세인츠헬레나 섬"; -"browsing_country.saint_kitts_and_nevis" = "세인트키츠네비스"; -"browsing_country.saint_lucia" = "세인트루시아"; -"browsing_country.saint_martin" = "세인트 마틴"; -"browsing_country.saint_pierre_and_miquelon" = "생피에르 미글롱"; -"browsing_country.saint_vincent_and_the_grenadines" = "세인트빈센트 그레나딘"; -"browsing_country.samoa" = "사모아"; -"browsing_country.san_marino" = "산마리노"; -"browsing_country.sao_tome_and_principe" = "상투메 프린시페 도브라"; -"browsing_country.saudi_arabia" = "사우디 아라비아"; -"browsing_country.senegal" = "세네갈"; -"browsing_country.serbia" = "세르비아"; -"browsing_country.seychelles" = "세이셸"; -"browsing_country.sierra_leone" = "시에라리온"; -"browsing_country.singapore" = "싱가포르"; -"browsing_country.sint_maarten" = "신트마르턴"; -"browsing_country.slovakia" = "슬로바키아"; -"browsing_country.slovenia" = "슬로베니아"; -"browsing_country.solomon_islands" = "솔로몬 제도"; -"browsing_country.somalia" = "소말리아"; -"browsing_country.south_africa" = "남아프리카"; -"browsing_country.south_georgia_and_the_south_sandwich_islands" = "사우스조지아 사우스샌드위치 제도"; -"browsing_country.south_korea" = "한국"; -"browsing_country.south_sudan" = "남수단"; -"browsing_country.spain" = "스페인"; -"browsing_country.sri_lanka" = "스리랑카"; -"browsing_country.sudan" = "수단"; -"browsing_country.suriname" = "수리남"; -"browsing_country.svalbard_and_jan_mayen" = "스발바르 얀마옌 제도"; -"browsing_country.swaziland" = "스와질란드"; -"browsing_country.sweden" = "스웨덴"; -"browsing_country.switzerland" = "스위스"; -"browsing_country.syrian_arab_republic" = "시리아"; -"browsing_country.taiwan" = "대만"; -"browsing_country.tajikistan" = "타지키스탄"; -"browsing_country.tanzania" = "탄지니아"; -"browsing_country.thailand" = "태국"; -"browsing_country.timor_leste" = "동티모르"; -"browsing_country.togo" = "토고"; -"browsing_country.tokelau" = "토켈라우"; -"browsing_country.tonga" = "통가"; -"browsing_country.trinidad_and_tobago" = "트리니다드토바고"; -"browsing_country.tunisia" = "튀니지"; -"browsing_country.turkey" = "터키"; -"browsing_country.turkmenistan" = "투르크메니스탄"; -"browsing_country.turks_and_caicos_islands" = "터크스카이코스 제도"; -"browsing_country.tuvalu" = "투발루"; -"browsing_country.uganda" = "우간다"; -"browsing_country.ukraine" = "우크라이나"; -"browsing_country.united_arab_emirates" = "아랍 에미리트 연합국"; -"browsing_country.united_kingdom" = "영국"; -"browsing_country.united_states" = "미국"; -"browsing_country.united_states_minor_outlying_islands" = "미국령 군소 제도"; -"browsing_country.uruguay" = "우루과이"; -"browsing_country.uzbekistan" = "우즈베키스탄"; -"browsing_country.vanuatu" = "바누어투"; -"browsing_country.venezuela" = "베네수엘라"; -"browsing_country.vietnam" = "베트남"; -"browsing_country.virgin_islands_british" = "영국령 버진 제도"; -"browsing_country.virgin_islands_US" = "세인트존 섬"; -"browsing_country.wallis_and_futuna" = "월리스 푸투나제도"; -"browsing_country.western_sahara" = "서사하라"; -"browsing_country.yemen" = "예멘"; -"browsing_country.zambia" = "잠비아"; -"browsing_country.zimbabwe" = "짐바브웨"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings index 33423e0ad..d788ca5b6 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings @@ -1,11 +1,6 @@ // MARK: BanInterval -"ban_interval.and" = ""; // MARK: ToplistsType -"toplists_type.yesterday" = "昨日"; -"toplists_type.past_month" = "上月"; -"toplists_type.past_year" = "去年"; -"toplists_type.all_time" = "全部"; // MARK: Response @@ -49,33 +44,8 @@ // MARK: AlertView "not_login_viewlogin" = "登录"; "error_view.retry" = "重试"; -"error_view.try_later" = "请稍后再试"; -"error_view.network" = "发生了网络故障"; -"error_view.parsing" = "发生了解析错误"; -"error_view.unknown" = "发生了未知错误"; -"error_view.not_found" = "这里似乎什么也没有"; -"error_view.database_corrupted" = "数据库已损毁。\n请到 GitHub 提起 Issue 反馈。"; -"error_view.ip_banned" = "当前的 IP 地址发生了过量的页面加载,因有使用爬虫程序的嫌疑已被暂时封禁。封禁将在 %@后解除。"; -"error_view.copyright_claim" = "抱歉,该画廊因 %@ 的版权主张已无法访问。"; -"error_view.gallery_unavailable" = "该画廊已被移除或不可用。"; // MARK: AppError -"app_error.database_corrupted" = "数据库损坏"; -"app_error.copyright_claim" = "版权声明"; -"app_error.ip_banned" = "IP 已封禁"; -"app_error.gallery_expunged" = "画廊已删除"; -"app_error.network_error" = "网络错误"; -"app_error.web_image_loading_error" = "网页图片加载错误"; -"app_error.parse_error" = "解析错误"; -"app_error.quota_exceeded" = "流量额度已用尽"; -"app_error.authentication_required" = "需要登录"; -"app_error.file_operation_failed" = "文件操作失败"; -"app_error.no_updates_available" = "没有可用更新"; -"app_error.not_found" = "未找到"; -"app_error.unknown_error" = "未知错误"; -"app_error.quota_exceeded_description" = "图片流量额度已用尽。\n请稍后再试。"; -"app_error.authentication_required_description" = "访问此下载内容需要登录。"; -"app_error.local_file_operation_failed" = "本地文件操作失败。"; // MARK: ConfirmationDialog "confirmation_dialog.delete_description" = "确定要删除吗?"; @@ -87,10 +57,6 @@ // MARK: NewDawnView // Greeting -"greeting.start" = "你获得了 "; -"greeting.separator" = "、"; -"greeting.and" = " 和 "; -"greeting.end" = "!"; // MARK: HomeView "home_view.home" = "主页"; @@ -109,8 +75,6 @@ // MARK: FavoritesView "favorites_view.favorites" = "收藏"; // FavoriteCategory -"favorite_category.default" = "收藏夹 %@"; -"favorite_category.all" = "全部"; // MARK: SearchView "search_view.search" = "搜索"; @@ -134,39 +98,18 @@ // MARK: GeneralSettingView "general_setting_view.language" = "语言"; // AutoLockPolicy -"auto_lock_policy.never" = "不锁定"; -"auto_lock_policy.instantly" = "立即"; // MARK: AppActivityLogsView -"app_activity_logs_view.level.undefined" = "未定义"; -"app_activity_logs_view.level.debug" = "调试"; -"app_activity_logs_view.level.info" = "信息"; -"app_activity_logs_view.level.notice" = "通知"; -"app_activity_logs_view.level.error" = "错误"; -"app_activity_logs_view.level.fault" = "故障"; // MARK: AppearanceSettingView // PreferredColorScheme -"preferred_color_scheme.automatic" = "自动"; -"preferred_color_scheme.light" = "浅色"; -"preferred_color_scheme.dark" = "深色"; // AppIconType -"app_icon_type.default" = "默认"; -"app_icon_type.ukiyoe" = "浮世绘"; -"app_icon_type.developer" = "开发者"; -"app_icon_type.stand_with_ukraine_2022" = "与乌克兰同在 (2022)"; -"app_icon_type.not_my_president" = "他不是我的主席"; // ListDisplayMode -"list_display_mode.detail" = "详情"; -"list_display_mode.thumbnail" = "缩略图"; // MARK: AppIconView // MARK: reading_settingView // ReadingDirection -"reading_direction.vertical" = "垂直"; -"reading_direction.right_to_left" = "右至左"; -"reading_direction.left_to_right" = "左至右"; // MARK: LaboratorySettingView @@ -182,22 +125,16 @@ // MARK: ArchivesView // HathArchive -"hath_archive.free" = "免费"; // ArchiveResolution -"archive_resolution.original" = "原始分辨率"; // MARK: TorrentsView // MARK: GalleryInfosView // GalleryVisibility -"gallery_visibility.yes" = "是"; -"gallery_visibility.no" = "否 (%@)"; -"gallery_visibility.expunged" = "已删除"; // MARK: TagDetailView // MARK: DownloadsView -"download_folder_filter.all" = "全部"; "detail_view.manage_folders" = "管理文件夹"; "downloads_view.manage_folders" = "管理文件夹"; "downloads_view.downloads" = "下载"; @@ -229,441 +166,49 @@ // MARK: FiltersView "filters_view.filters" = "筛选"; // FilterRange -"filter_range.search" = "搜索"; -"filter_range.global" = "全局"; -"filter_range.watched" = "标签"; // MARK: EhSettingView // EhSetting.LoadThroughHathSetting -"load_through_hath_setting.any_client" = "所有客户端"; -"load_through_hath_setting.default_port_only" = "仅使用默认端口的客户端"; -"load_through_hath_setting.modern_no" = "不通过 [现代 / HTTPS]"; -"load_through_hath_setting.legacy_no" = "不通过 [旧式 / HTTP]"; -"load_through_hath_setting.any_client_description" = "推荐。"; -"load_through_hath_setting.default_port_only_description" = "可能稍慢。当防火墙或代理阻止非标准接口的流量时启用此项。"; -"load_through_hath_setting.modern_no_description" = "仅限赞助者。配额消耗会加快。只建议在遇到严重问题时使用。"; -"load_through_hath_setting.legacy_no_description" = "仅限赞助者。在现代浏览器可能不可用。只建议在旧式 / 过时的浏览器使用。"; // EhSetting.ImageResolution -"image_resolution.auto" = "自动"; // EhSetting.GalleryName -"gallery_name.default" = "默认标题"; -"gallery_name.japanese" = "日文标题(如果有)"; // EhSetting.ArchiverBehavior -"eh_setting.archiver_behavior.manual_select_manual_start" = "手动选择,手动下载(默认)"; -"eh_setting.archiver_behavior.manual_select_auto_start" = "手动选择,自动下载"; -"eh_setting.archiver_behavior.auto_select_original_manual_start" = "自动选择原始画质,手动下载"; -"eh_setting.archiver_behavior.auto_select_original_auto_start" = "自动选择原始画质,自动下载"; -"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "自动选择压缩画质,手动下载"; -"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "自动选择压缩画质,自动下载"; // EhSetting.DisplayMode -"display_mode.compact" = "紧凑"; -"display_mode.thumbnail" = "缩略图"; -"display_mode.extended" = "扩展"; -"display_mode.minimal" = "最小化"; -"display_mode.minimalPlus" = "最小化 +"; // EhSetting.FavoritesSortOrder -"favorites_sort_order.last_update_time" = "按更新时间"; -"favorites_sort_order.favorited_time" = "按收藏时间"; // EhSetting.ExcludedLanguagesCategory -"excluded_languages_category.original" = "原始版本"; -"excluded_languages_category.translated" = "翻译版本"; -"excluded_languages_category.rewrite" = "改编版本"; // EhSetting.ThumbnailLoadTiming -"thumbnail_load_timing.on_mouse_over" = "鼠标悬停时"; -"thumbnail_load_timing.on_page_load" = "页面加载时"; -"thumbnail_load_timing.on_mouse_over_description" = "页面加载快,缩略图加载有延迟。"; -"thumbnail_load_timing.on_page_load_description" = "页面加载时间更长,显示缩略图无需等待。"; // EhSetting.ThumbnailSize -"thumbnail_size.normal" = "普通"; -"thumbnail_size.large" = "较大"; -"thumbnail_size.small" = "较小"; -"thumbnail_size.auto" = "自动"; // EhSetting.CommentsSortOrder -"comments_sort_order.oldest" = "按最早的评论"; -"comments_sort_order.recent" = "按最新的评论"; -"comments_sort_order.highest_score" = "按最高分的评论"; // EhSetting.CommentVotesShowTiming -"comments_votes_show_timing.on_hover_or_click" = "悬停或点击时"; -"comments_votes_show_timing.always" = "始终显示"; // EhSetting.tags_sort_order -"tags_sort_order.alphabetical" = "按字母排序"; -"tags_sort_order.tag_power" = "按标签权重"; // EhSetting.MultiplePageViewerStyle -"multiple_page_viewer_style.align_left_scale_if_over_width" = "左对齐,图像过宽时缩放"; -"multiple_page_viewer_style.align_center_scale_if_over_width" = "居中对齐,图像过宽时缩放"; -"multiple_page_viewer_style.align_center_always_scale" = "居中对齐,图像始终缩放"; // EhSetting.GalleryPageNumbering -"gallery_page_numbering.none" = "不显示"; -"gallery_page_numbering.page_number_only" = "仅显示页码"; -"gallery_page_numbering.page_number_and_name" = "显示页码和名称"; // MARK: Category -"category.doujinshi" = "同人志"; -"category.manga" = "漫画"; -"category.artist_CG" = "插画"; -"category.game_CG" = "游戏 CG"; -"category.western" = "西方"; -"category.non_h" = "健康"; -"category.image_set" = "照片集"; -"category.cosplay" = "角色扮演"; -"category.asian_porn" = "亚洲"; -"category.misc" = "其它"; -"category.private" = "非公开"; // MARK: TagNamespace -"tag_namespace.reclass" = "重归类"; -"tag_namespace.language" = "语言"; -"tag_namespace.parody" = "原作"; -"tag_namespace.character" = "角色"; -"tag_namespace.group" = "团体"; -"tag_namespace.artist" = "作者"; -"tag_namespace.male" = "男性"; -"tag_namespace.female" = "女性"; -"tag_namespace.mixed" = "混合性别"; -"tag_namespace.cosplayer" = "扮装者"; -"tag_namespace.other" = "其它"; -"tag_namespace.temp" = "临时"; // MARK: Language -"language.invalid" = "无效"; -"language.other" = "其它"; -"language.afrikaans" = "南非语"; -"language.albanian" = "阿尔巴尼亚语"; -"language.arabic" = "阿拉伯语"; -"language.bengali" = "孟加拉语"; -"language.bosnian" = "波斯尼亚语"; -"language.bulgarian" = "保加利亚语"; -"language.burmese" = "缅甸语"; -"language.catalan" = "加泰罗尼亚语"; -"language.cebuano" = "宿雾語"; -"language.chinese" = "汉语"; -"language.croatian" = "克罗地亚语"; -"language.czech" = "捷克语"; -"language.danish" = "丹麦语"; -"language.dutch" = "荷兰语"; -"language.english" = "英语"; -"language.esperanto" = "国际语"; -"language.estonian" = "爱沙尼亚语"; -"language.finnish" = "芬兰语"; -"language.french" = "法语"; -"language.georgian" = "格鲁吉亚语"; -"language.german" = "德语"; -"language.greek" = "希腊语"; -"language.hebrew" = "希伯来语"; -"language.hindi" = "印地语"; -"language.hmong" = "苗语"; -"language.hungarian" = "匈牙利语"; -"language.indonesian" = "印度尼西亚语"; -"language.italian" = "意大利语"; -"language.japanese" = "日语"; -"language.kazakh" = "哈萨克语"; -"language.khmer" = "高棉文"; -"language.korean" = "韩语"; -"language.kurdish" = "库尔德语"; -"language.lao" = "老挝语"; -"language.latin" = "拉丁语"; -"language.mongolian" = "蒙古语"; -"language.ndebele" = "恩德贝莱语"; -"language.nepali" = "尼泊尔语"; -"language.norwegian" = "挪威语"; -"language.oromo" = "奥罗莫语"; -"language.pashto" = "普什图语"; -"language.persian" = "波斯语"; -"language.polish" = "波兰语"; -"language.portuguese" = "葡萄牙语"; -"language.punjabi" = "旁遮普语"; -"language.romanian" = "罗马尼亚语"; -"language.russian" = "俄语"; -"language.sango" = "桑戈语"; -"language.serbian" = "塞尔维亚语"; -"language.shona" = "绍纳语"; -"language.slovak" = "斯洛伐克语"; -"language.slovenian" = "斯洛文尼亚语"; -"language.somali" = "索马里语"; -"language.spanish" = "西班牙语"; -"language.swahili" = "斯瓦希里语"; -"language.swedish" = "瑞典语"; -"language.tagalog" = "他加洛语"; -"language.thai" = "泰语"; -"language.tigrinya" = "提格利尼亚语"; -"language.turkish" = "土耳其语"; -"language.ukrainian" = "乌克兰语"; -"language.urdu" = "乌尔都语"; -"language.vietnamese" = "越南语"; -"language.zulu" = "祖鲁语"; // MARK: BrowsingCountry -"browsing_country.auto_detect" = "自动检测"; -"browsing_country.afghanistan" = "阿富汗"; -"browsing_country.aland_islands" = "奥兰群岛"; -"browsing_country.albania" = "阿尔巴尼亚"; -"browsing_country.algeria" = "阿尔及利亚"; -"browsing_country.american_samoa" = "美属萨摩亚"; -"browsing_country.andorra" = "安道尔"; -"browsing_country.angola" = "安哥拉"; -"browsing_country.anguilla" = "安圭拉"; -"browsing_country.antarctica" = "南极洲"; -"browsing_country.antigua_and_barbuda" = "安提瓜和巴布达"; -"browsing_country.argentina" = "阿根廷"; -"browsing_country.armenia" = "亚美尼亚"; -"browsing_country.aruba" = "阿鲁巴"; -"browsing_country.asia_pacific_region" = "亚太地区"; -"browsing_country.australia" = "澳大利亚"; -"browsing_country.austria" = "奥地利"; -"browsing_country.azerbaijan" = "阿塞拜疆"; -"browsing_country.bahamas" = "巴哈马"; -"browsing_country.bahrain" = "巴林"; -"browsing_country.bangladesh" = "孟加拉国"; -"browsing_country.barbados" = "巴巴多斯"; -"browsing_country.belarus" = "白俄罗斯"; -"browsing_country.belgium" = "比利时"; -"browsing_country.belize" = "伯利兹"; -"browsing_country.benin" = "贝宁"; -"browsing_country.bermuda" = "百慕大"; -"browsing_country.bhutan" = "不丹"; -"browsing_country.bolivia" = "玻利维亚"; -"browsing_country.bonaire_saint_eustatius_and_saba" = "博奈尔、圣尤斯特歇斯与萨巴"; -"browsing_country.bosnia_and_herzegovina" = "波斯尼亚和黑塞哥维那"; -"browsing_country.botswana" = "博茨瓦纳"; -"browsing_country.bouvet_island" = "布韦岛"; -"browsing_country.brazil" = "巴西"; -"browsing_country.british_indian_ocean_territory" = "英属印度洋领地"; -"browsing_country.brunei_darussalam" = "文莱"; -"browsing_country.bulgaria" = "保加利亚"; -"browsing_country.burkina_faso" = "布基纳法索"; -"browsing_country.burundi" = "蒲隆地"; -"browsing_country.cambodia" = "柬埔寨"; -"browsing_country.cameroon" = "喀麦隆"; -"browsing_country.canada" = "加拿大"; -"browsing_country.cape_verde" = "佛得角"; -"browsing_country.cayman_islands" = "开曼群岛"; -"browsing_country.central_african_republic" = "中非"; -"browsing_country.chad" = "乍得"; -"browsing_country.chile" = "智利"; -"browsing_country.china" = "中华人民共和国"; -"browsing_country.christmas_island" = "圣诞岛"; -"browsing_country.cocos_islands" = "科科斯岛"; -"browsing_country.colombia" = "哥伦比亚"; -"browsing_country.comoros" = "科摩罗"; -"browsing_country.congo" = "刚果共和国"; -"browsing_country.the_democratic_republic_of_the_congo" = "刚果民主共和国"; -"browsing_country.cook_islands" = "库克群岛"; -"browsing_country.costa_rica" = "哥斯达黎加"; -"browsing_country.cote_d_ivoire" = "科特迪瓦"; -"browsing_country.croatia" = "克罗地亚"; -"browsing_country.cuba" = "古巴"; -"browsing_country.curacao" = "库拉索"; -"browsing_country.cyprus" = "塞浦路斯"; -"browsing_country.czech_republic" = "捷克"; -"browsing_country.denmark" = "丹麦"; -"browsing_country.djibouti" = "吉布提"; -"browsing_country.dominica" = "多米尼克"; -"browsing_country.dominican_republic" = "多米尼加"; -"browsing_country.ecuador" = "厄瓜多尔"; -"browsing_country.egypt" = "埃及"; -"browsing_country.el_salvador" = "萨尔瓦多"; -"browsing_country.equatorial_guinea" = "赤道几内亚"; -"browsing_country.eritrea" = "厄立特里亚"; -"browsing_country.estonia" = "爱沙尼亚"; -"browsing_country.ethiopia" = "埃塞俄比亚"; -"browsing_country.europe" = "欧洲"; -"browsing_country.falkland_islands" = "福克兰群岛"; -"browsing_country.faroe_islands" = "法罗群岛"; -"browsing_country.fiji" = "斐济"; -"browsing_country.finland" = "芬兰"; -"browsing_country.france" = "法国"; -"browsing_country.french_guiana" = "法属圭亚那"; -"browsing_country.french_polynesia" = "法属波利尼西亚"; -"browsing_country.french_southern_territories" = "法属南部和南极领地"; -"browsing_country.gabon" = "加蓬"; -"browsing_country.gambia" = "冈比亚"; -"browsing_country.georgia" = "格鲁吉亚"; -"browsing_country.germany" = "德国"; -"browsing_country.ghana" = "加纳"; -"browsing_country.gibraltar" = "直布罗陀"; -"browsing_country.greece" = "希腊"; -"browsing_country.greenland" = "格陵兰"; -"browsing_country.grenada" = "格林纳达"; -"browsing_country.guadeloupe" = "瓜德罗普"; -"browsing_country.guam" = "关岛"; -"browsing_country.guatemala" = "危地马拉"; -"browsing_country.guernsey" = "根西"; -"browsing_country.guinea" = "几内亚"; -"browsing_country.guinea_bissau" = "几内亚比绍"; -"browsing_country.guyana" = "圭亚那"; -"browsing_country.haiti" = "海地"; -"browsing_country.heard_island_and_mc_donald_islands" = "赫德岛和麦克唐纳群岛"; -"browsing_country.vatican_city_state" = "梵蒂冈城国"; -"browsing_country.honduras" = "洪都拉斯"; -"browsing_country.hong_kong" = "香港"; -"browsing_country.hungary" = "匈牙利"; -"browsing_country.iceland" = "冰岛"; -"browsing_country.india" = "印度"; -"browsing_country.indonesia" = "印度尼西亚"; -"browsing_country.iran" = "伊朗"; -"browsing_country.iraq" = "伊拉克"; -"browsing_country.ireland" = "爱尔兰"; -"browsing_country.isle_of_man" = "曼岛"; -"browsing_country.israel" = "以色列"; -"browsing_country.italy" = "意大利"; -"browsing_country.jamaica" = "牙买加"; -"browsing_country.japan" = "日本"; -"browsing_country.jersey" = "泽西"; -"browsing_country.jordan" = "约旦"; -"browsing_country.kazakhstan" = "哈萨克斯坦"; -"browsing_country.kenya" = "肯尼亚"; -"browsing_country.kiribati" = "基里巴斯"; -"browsing_country.kuwait" = "科威特"; -"browsing_country.kyrgyzstan" = "吉尔吉斯斯坦"; -"browsing_country.lao_peoples_democratic_republic" = "老挝"; -"browsing_country.latvia" = "拉脱维亚"; -"browsing_country.lebanon" = "黎巴嫩"; -"browsing_country.lesotho" = "莱索托"; -"browsing_country.liberia" = "利比里亚"; -"browsing_country.libya" = "利比亚"; -"browsing_country.liechtenstein" = "列支敦士登"; -"browsing_country.lithuania" = "立陶宛"; -"browsing_country.luxembourg" = "卢森堡"; -"browsing_country.macau" = "澳门"; -"browsing_country.macedonia" = "马其顿"; -"browsing_country.madagascar" = "马达加斯加"; -"browsing_country.malawi" = "马拉维"; -"browsing_country.malaysia" = "马来西亚"; -"browsing_country.maldives" = "马尔代夫"; -"browsing_country.mali" = "马里"; -"browsing_country.malta" = "马耳他"; -"browsing_country.marshall_islands" = "马绍尔群岛"; -"browsing_country.martinique" = "马提尼克"; -"browsing_country.mauritania" = "毛里塔尼亚"; -"browsing_country.mauritius" = "模里西斯"; -"browsing_country.mayotte" = "马约特"; -"browsing_country.mexico" = "墨西哥"; -"browsing_country.micronesia" = "密克罗尼西亚"; -"browsing_country.moldova" = "摩尔多瓦"; -"browsing_country.monaco" = "摩纳哥"; -"browsing_country.mongolia" = "蒙古"; -"browsing_country.montenegro" = "黑山"; -"browsing_country.montserrat" = "蒙塞拉特岛"; -"browsing_country.morocco" = "摩洛哥"; -"browsing_country.mozambique" = "莫桑比克"; -"browsing_country.myanmar" = "缅甸"; -"browsing_country.namibia" = "纳米比亚"; -"browsing_country.nauru" = "诺鲁"; -"browsing_country.nepal" = "尼泊尔"; -"browsing_country.netherlands" = "荷兰"; -"browsing_country.new_caledonia" = "新喀里多尼亚"; -"browsing_country.new_zealand" = "新西兰"; -"browsing_country.nicaragua" = "尼加拉瓜"; -"browsing_country.niger" = "尼日尔"; -"browsing_country.nigeria" = "尼日利亚"; -"browsing_country.niue" = "纽埃"; -"browsing_country.norfolk_island" = "诺福克岛"; -"browsing_country.north_korea" = "朝鲜"; -"browsing_country.northern_mariana_islands" = "北马里亚纳群岛"; -"browsing_country.norway" = "挪威"; -"browsing_country.oman" = "阿曼"; -"browsing_country.pakistan" = "巴基斯坦"; -"browsing_country.palau" = "帛琉"; -"browsing_country.palestinian_territory" = "巴勒斯坦"; -"browsing_country.panama" = "巴拿马"; -"browsing_country.papua_new_guinea" = "巴布亚新几内亚"; -"browsing_country.paraguay" = "巴拉圭"; -"browsing_country.peru" = "秘鲁"; -"browsing_country.philippines" = "菲律宾"; -"browsing_country.pitcairn_islands" = "皮特凯恩群岛"; -"browsing_country.poland" = "波兰"; -"browsing_country.portugal" = "葡萄牙"; -"browsing_country.puerto_rico" = "波多黎各"; -"browsing_country.qatar" = "卡塔尔"; -"browsing_country.reunion" = "留尼汪"; -"browsing_country.romania" = ""; -"browsing_country.russian_federation" = "俄罗斯"; -"browsing_country.rwanda" = "卢旺达"; -"browsing_country.saint_barthelemy" = "圣巴泰勒米"; -"browsing_country.saint_helena" = "圣赫勒拿"; -"browsing_country.saint_kitts_and_nevis" = "圣基茨岛"; -"browsing_country.saint_lucia" = "圣卢西亚"; -"browsing_country.saint_martin" = "圣马丁岛"; -"browsing_country.saint_pierre_and_miquelon" = "圣皮埃尔和密克隆"; -"browsing_country.saint_vincent_and_the_grenadines" = "圣文森特和格林纳丁斯"; -"browsing_country.samoa" = "萨摩亚"; -"browsing_country.san_marino" = "圣马力诺"; -"browsing_country.sao_tome_and_principe" = "圣多美和普林西比"; -"browsing_country.saudi_arabia" = "沙地阿拉伯"; -"browsing_country.senegal" = "塞内加尔"; -"browsing_country.serbia" = "塞尔维亚"; -"browsing_country.seychelles" = "塞舌尔"; -"browsing_country.sierra_leone" = "塞拉利昂"; -"browsing_country.singapore" = "新加坡"; -"browsing_country.sint_maarten" = "圣马丁岛"; -"browsing_country.slovakia" = "斯洛伐克"; -"browsing_country.slovenia" = "斯洛文尼亚"; -"browsing_country.solomon_islands" = "所罗门群岛"; -"browsing_country.somalia" = "索马里"; -"browsing_country.south_africa" = "南非"; -"browsing_country.south_georgia_and_the_south_sandwich_islands" = "南乔治亚和南桑威奇群岛"; -"browsing_country.south_korea" = "韩国"; -"browsing_country.south_sudan" = "南苏丹"; -"browsing_country.spain" = "西班牙"; -"browsing_country.sri_lanka" = "斯里兰卡"; -"browsing_country.sudan" = "苏丹"; -"browsing_country.suriname" = "苏里南"; -"browsing_country.svalbard_and_jan_mayen" = "斯瓦尔巴和扬马延"; -"browsing_country.swaziland" = "史瓦帝尼"; -"browsing_country.sweden" = "瑞典"; -"browsing_country.switzerland" = "瑞士"; -"browsing_country.syrian_arab_republic" = "叙利亚"; -"browsing_country.taiwan" = "台湾"; -"browsing_country.tajikistan" = "塔吉克斯坦"; -"browsing_country.tanzania" = "坦桑尼亚"; -"browsing_country.thailand" = "泰国"; -"browsing_country.timor_leste" = "东帝汶"; -"browsing_country.togo" = "多哥"; -"browsing_country.tokelau" = "托克劳"; -"browsing_country.tonga" = "汤加"; -"browsing_country.trinidad_and_tobago" = "特立尼达和多巴哥"; -"browsing_country.tunisia" = "突尼斯"; -"browsing_country.turkey" = "土耳其"; -"browsing_country.turkmenistan" = "土库曼斯坦"; -"browsing_country.turks_and_caicos_islands" = "特克斯和凯科斯群岛"; -"browsing_country.tuvalu" = "图瓦卢"; -"browsing_country.uganda" = "乌干达"; -"browsing_country.ukraine" = "乌克兰"; -"browsing_country.united_arab_emirates" = "阿拉伯联合酋长国"; -"browsing_country.united_kingdom" = "英国"; -"browsing_country.united_states" = "美国"; -"browsing_country.united_states_minor_outlying_islands" = "美国本土外小岛屿"; -"browsing_country.uruguay" = "乌拉圭"; -"browsing_country.uzbekistan" = "乌兹别克斯坦"; -"browsing_country.vanuatu" = "瓦努阿图"; -"browsing_country.venezuela" = "委內瑞拉"; -"browsing_country.vietnam" = "越南"; -"browsing_country.virgin_islands_british" = "英属维尔京群岛"; -"browsing_country.virgin_islands_US" = "美属维尔京群岛"; -"browsing_country.wallis_and_futuna" = "瓦利斯和富图纳"; -"browsing_country.western_sahara" = "西撒哈拉"; -"browsing_country.yemen" = "也门"; -"browsing_country.zambia" = "赞比亚"; -"browsing_country.zimbabwe" = "津巴布韦"; diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings index 4ef22a30f..8ac2194f4 100644 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings @@ -1,11 +1,6 @@ // MARK: BanInterval -"ban_interval.and" = ""; // MARK: ToplistsType -"toplists_type.yesterday" = "昨天"; -"toplists_type.past_month" = "上個月"; -"toplists_type.past_year" = "去年"; -"toplists_type.all_time" = "所有時間"; // MARK: Response @@ -49,33 +44,8 @@ // MARK: AlertView "not_login_viewlogin" = "登入"; "error_view.retry" = "重試"; -"error_view.try_later" = "請稍後再試"; -"error_view.network" = "網路發生故障,請檢查網路狀態"; -"error_view.parsing" = "網頁解析器發生故障"; -"error_view.unknown" = "發生不明錯誤"; -"error_view.not_found" = "這邊看起來空空如也"; -"error_view.database_corrupted" = "資料庫已經損壞\n請提交 issue 至 GitHub."; -"error_view.ip_banned" = "你的 IP 因為頁面載入次數過於頻繁而被暫時禁止存取,這可能是因為你正在使用爬蟲/鏡像軟體,禁止存取將在 %@ 後解除"; -"error_view.copyright_claim" = "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取"; -"error_view.gallery_unavailable" = "此畫廊已被刪除或你沒有權限存取"; // MARK: AppError -"app_error.database_corrupted" = "資料庫損壞"; -"app_error.copyright_claim" = "版權聲明"; -"app_error.ip_banned" = "IP 已封禁"; -"app_error.gallery_expunged" = "畫廊已刪除"; -"app_error.network_error" = "網路錯誤"; -"app_error.web_image_loading_error" = "網頁圖片載入錯誤"; -"app_error.parse_error" = "解析錯誤"; -"app_error.quota_exceeded" = "流量額度已用盡"; -"app_error.authentication_required" = "需要登入"; -"app_error.file_operation_failed" = "檔案操作失敗"; -"app_error.no_updates_available" = "沒有可用更新"; -"app_error.not_found" = "未找到"; -"app_error.unknown_error" = "未知錯誤"; -"app_error.quota_exceeded_description" = "圖片流量額度已用盡。\n請稍後再試。"; -"app_error.authentication_required_description" = "存取此下載內容需要登入。"; -"app_error.local_file_operation_failed" = "本機檔案操作失敗。"; // MARK: ConfirmationDialog "confirmation_dialog.delete_description" = "確定要刪除?"; @@ -87,10 +57,6 @@ // MARK: NewDawnView // Greeting -"greeting.start" = "你獲得了 "; -"greeting.separator" = "、"; -"greeting.and" = " 和 "; -"greeting.end" = "!"; // MARK: HomeView "home_view.home" = "總覽"; @@ -109,8 +75,6 @@ // MARK: FavoritesView "favorites_view.favorites" = "收藏"; // FavoriteCategory -"favorite_category.default" = "收藏匣 %@"; -"favorite_category.all" = "全部"; // MARK: SearchView "search_view.search" = "搜尋"; @@ -134,39 +98,18 @@ // MARK: GeneralSettingView "general_setting_view.language" = "語言"; // AutoLockPolicy -"auto_lock_policy.never" = "永不自動鎖定"; -"auto_lock_policy.instantly" = "立刻"; // MARK: AppActivityLogsView -"app_activity_logs_view.level.undefined" = "未定義"; -"app_activity_logs_view.level.debug" = "偵錯"; -"app_activity_logs_view.level.info" = "資訊"; -"app_activity_logs_view.level.notice" = "通知"; -"app_activity_logs_view.level.error" = "錯誤"; -"app_activity_logs_view.level.fault" = "故障"; // MARK: AppearanceSettingView // PreferredColorScheme -"preferred_color_scheme.automatic" = "自動"; -"preferred_color_scheme.light" = "淺色"; -"preferred_color_scheme.dark" = "深色"; // AppIconType -"app_icon_type.default" = "預設"; -"app_icon_type.ukiyoe" = "Ukiyo-e"; -"app_icon_type.developer" = "Developer"; -"app_icon_type.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"app_icon_type.not_my_president" = "NOT MY PRESIDENT"; // ListDisplayMode -"list_display_mode.detail" = "詳細"; -"list_display_mode.thumbnail" = "縮圖"; // MARK: AppIconView // MARK: reading_settingView // ReadingDirection -"reading_direction.vertical" = "垂直"; -"reading_direction.right_to_left" = "由右至左滑"; -"reading_direction.left_to_right" = "由左至右滑"; // MARK: LaboratorySettingView @@ -182,22 +125,16 @@ // MARK: ArchivesView // HathArchive -"hath_archive.free" = "免費"; // ArchiveResolution -"archive_resolution.original" = "原始畫質"; // MARK: TorrentsView // MARK: GalleryInfosView // GalleryVisibility -"gallery_visibility.yes" = "Yes"; -"gallery_visibility.no" = "No (%@)"; -"gallery_visibility.expunged" = "已被刪除"; // MARK: TagDetailView // MARK: DownloadsView -"download_folder_filter.all" = "全部"; "detail_view.manage_folders" = "管理資料夾"; "downloads_view.manage_folders" = "管理資料夾"; "downloads_view.downloads" = "下載"; @@ -229,441 +166,49 @@ // MARK: FiltersView "filters_view.filters" = "過濾"; // FilterRange -"filter_range.search" = "搜尋"; -"filter_range.global" = "全域"; -"filter_range.watched" = "標籤"; // MARK: EhSettingView // EhSetting.LoadThroughHathSetting -"load_through_hath_setting.any_client" = "任何用戶端"; -"load_through_hath_setting.default_port_only" = "只使用預設連接埠的用戶端"; -"load_through_hath_setting.modern_no" = "No [現代/Modern/HTTPS]"; -"load_through_hath_setting.legacy_no" = "No [傳統/Legacy/HTTP]"; -"load_through_hath_setting.any_client_description" = "建議選項(預設)"; -"load_through_hath_setting.default_port_only_description" = "如果網路防火牆會阻擋任何非預設傳出連接埠則使用這個選項(可能較慢)"; -"load_through_hath_setting.modern_no_description" = "E-Hentai 贊助者功能: 你將無法同時瀏覽多個頁面,僅在出現重大錯誤時才啟用這個選項"; -"load_through_hath_setting.legacy_no_description" = "E-Hentai 贊助者功能: 在現代瀏覽器上預設設定可能無法正常工作,僅推薦用於傳統瀏覽器"; // EhSetting.ImageResolution -"image_resolution.auto" = "自動"; // EhSetting.GalleryName -"gallery_name.default" = "預設標題"; -"gallery_name.japanese" = "日文標題(若該畫廊支援)"; // EhSetting.ArchiverBehavior -"eh_setting.archiver_behavior.manual_select_manual_start" = "手動選擇,手動開始下載(預設)"; -"eh_setting.archiver_behavior.manual_select_auto_start" = "手動選擇,自動開始下載"; -"eh_setting.archiver_behavior.auto_select_original_manual_start" = "自動選擇原始畫質,,手動開始下載"; -"eh_setting.archiver_behavior.auto_select_original_auto_start" = "自動選擇原始畫質並開始下載"; -"eh_setting.archiver_behavior.auto_select_resample_manual_start" = "自動選擇重新採樣,手動開始下載"; -"eh_setting.archiver_behavior.auto_select_resample_auto_start" = "自動選擇重新採樣並開始下載"; // EhSetting.DisplayMode -"display_mode.compact" = "緊湊(Compact)"; -"display_mode.thumbnail" = "縮圖(Thumbnail)"; -"display_mode.extended" = "放大(Extended)"; -"display_mode.minimal" = "最小(Minimal)"; -"display_mode.minimalPlus" = "Minimal+"; // EhSetting.FavoritesSortOrder -"favorites_sort_order.last_update_time" = "透過最後更新時間排序"; -"favorites_sort_order.favorited_time" = "透過收藏順序排序"; // EhSetting.ExcludedLanguagesCategory -"excluded_languages_category.original" = "原始語言"; -"excluded_languages_category.translated" = "翻譯語言"; -"excluded_languages_category.rewrite" = "覆寫"; // EhSetting.ThumbnailLoadTiming -"thumbnail_load_timing.on_mouse_over" = "滑鼠位置"; -"thumbnail_load_timing.on_page_load" = "網頁載入位置"; -"thumbnail_load_timing.on_mouse_over_description" = "網頁的載入速度更快,但縮圖出現的時間可能稍有延遲"; -"thumbnail_load_timing.on_page_load_description" = "網頁需要更多的載入時間,但是在網頁完全載入後縮圖顯示不會有任何延遲"; // EhSetting.ThumbnailSize -"thumbnail_size.normal" = "正常"; -"thumbnail_size.large" = "大型"; -"thumbnail_size.small" = "小型"; -"thumbnail_size.auto" = "自動"; // EhSetting.CommentsSortOrder -"comments_sort_order.oldest" = "最舊留言優先"; -"comments_sort_order.recent" = "最新留言優先"; -"comments_sort_order.highest_score" = "最相關留言優先"; // EhSetting.CommentVotesShowTiming -"comments_votes_show_timing.on_hover_or_click" = "滑鼠在分數上停留或點擊時"; -"comments_votes_show_timing.always" = "總是顯示"; // EhSetting.tags_sort_order -"tags_sort_order.alphabetical" = "字母順序"; -"tags_sort_order.tag_power" = "標籤權重"; // EhSetting.MultiplePageViewerStyle -"multiple_page_viewer_style.align_left_scale_if_over_width" = "向左對齊,若寬度超出頁面則進行縮放"; -"multiple_page_viewer_style.align_center_scale_if_over_width" = "置中對齊,若寬度超出頁面則進行縮放"; -"multiple_page_viewer_style.align_center_always_scale" = "置中對齊且總是縮放"; // EhSetting.GalleryPageNumbering -"gallery_page_numbering.none" = "不顯示"; -"gallery_page_numbering.page_number_only" = "只顯示頁碼"; -"gallery_page_numbering.page_number_and_name" = "顯示頁碼和名稱"; // MARK: Category -"category.doujinshi" = "同人誌"; -"category.manga" = "漫畫"; -"category.artist_CG" = "插畫"; -"category.game_CG" = "遊戲 CG"; -"category.western" = "西方"; -"category.non_h" = "健康"; -"category.image_set" = "圖片集"; -"category.cosplay" = "角色扮演"; -"category.asian_porn" = "亞洲"; -"category.misc" = "其它"; -"category.private" = "私人"; // MARK: TagNamespace -"tag_namespace.reclass" = "重新分類"; -"tag_namespace.language" = "語言"; -"tag_namespace.parody" = "原作"; -"tag_namespace.character" = "角色"; -"tag_namespace.group" = "團體"; -"tag_namespace.artist" = "作者"; -"tag_namespace.male" = "男性"; -"tag_namespace.female" = "女性"; -"tag_namespace.mixed" = "Mixed"; -"tag_namespace.cosplayer" = "Cosplayer"; -"tag_namespace.other" = "其他"; -"tag_namespace.temp" = "Temp"; // MARK: Language -"language.invalid" = "無效"; -"language.other" = "其它"; -"language.afrikaans" = "南非語"; -"language.albanian" = "阿爾巴尼亞語"; -"language.arabic" = "阿拉伯語"; -"language.bengali" = "孟加拉語"; -"language.bosnian" = "波士尼亞語"; -"language.bulgarian" = "保加利亞語"; -"language.burmese" = "緬甸語"; -"language.catalan" = "加泰隆尼亞語"; -"language.cebuano" = "宿霧語"; -"language.chinese" = "漢語"; -"language.croatian" = "克羅埃西亞語"; -"language.czech" = "捷克語"; -"language.danish" = "丹麥語"; -"language.dutch" = "荷蘭語"; -"language.english" = "英語"; -"language.esperanto" = "國際語"; -"language.estonian" = "愛沙尼亞語"; -"language.finnish" = "芬蘭語"; -"language.french" = "法語"; -"language.georgian" = "喬治亞語"; -"language.german" = "德語"; -"language.greek" = "希臘語"; -"language.hebrew" = "希伯來語"; -"language.hindi" = "印度語"; -"language.hmong" = "苗語"; -"language.hungarian" = "匈牙利語"; -"language.indonesian" = "印尼語"; -"language.italian" = "義大利語"; -"language.japanese" = "日語"; -"language.kazakh" = "哈薩克語"; -"language.khmer" = "高棉語"; -"language.korean" = "韓語"; -"language.kurdish" = "庫德語"; -"language.lao" = "寮語"; -"language.latin" = "拉丁語"; -"language.mongolian" = "蒙古語"; -"language.ndebele" = "恩德貝萊語"; -"language.nepali" = "尼泊爾語"; -"language.norwegian" = "挪威語"; -"language.oromo" = "奧羅莫語"; -"language.pashto" = "普什圖語"; -"language.persian" = "波斯語"; -"language.polish" = "波蘭語"; -"language.portuguese" = "葡萄牙語"; -"language.punjabi" = "旁遮普語"; -"language.romanian" = "羅馬尼亞語"; -"language.russian" = "俄語"; -"language.sango" = "桑戈語"; -"language.serbian" = "塞爾維亞語"; -"language.shona" = "紹納語"; -"language.slovak" = "斯洛伐克語"; -"language.slovenian" = "斯洛維尼亞語"; -"language.somali" = "索馬利語"; -"language.spanish" = "西班牙語"; -"language.swahili" = "斯瓦希里語"; -"language.swedish" = "瑞典語"; -"language.tagalog" = "他加祿語"; -"language.thai" = "泰語"; -"language.tigrinya" = "提格利尼亞語"; -"language.turkish" = "土耳其語"; -"language.ukrainian" = "烏克蘭語"; -"language.urdu" = "烏爾都語"; -"language.vietnamese" = "越南語"; -"language.zulu" = "祖魯語"; // MARK: BrowsingCountry -"browsing_country.auto_detect" = "自動偵測"; -"browsing_country.afghanistan" = "阿富汗"; -"browsing_country.aland_islands" = "奧蘭群島"; -"browsing_country.albania" = "阿爾巴尼亞"; -"browsing_country.algeria" = "阿爾及利亞"; -"browsing_country.american_samoa" = "美屬薩摩亞"; -"browsing_country.andorra" = "安道爾"; -"browsing_country.angola" = "安哥拉"; -"browsing_country.anguilla" = "安圭拉"; -"browsing_country.antarctica" = "南極洲"; -"browsing_country.antigua_and_barbuda" = "安地卡及巴布達"; -"browsing_country.argentina" = "阿根廷"; -"browsing_country.armenia" = "亞美尼亞"; -"browsing_country.aruba" = "阿魯巴"; -"browsing_country.asia_pacific_region" = "亞太地區"; -"browsing_country.australia" = "澳洲"; -"browsing_country.austria" = "奧地利"; -"browsing_country.azerbaijan" = "亞塞拜然"; -"browsing_country.bahamas" = "巴哈馬"; -"browsing_country.bahrain" = "巴林"; -"browsing_country.bangladesh" = "孟加拉"; -"browsing_country.barbados" = "巴貝多"; -"browsing_country.belarus" = "白俄羅斯"; -"browsing_country.belgium" = "比利時"; -"browsing_country.belize" = "貝里斯"; -"browsing_country.benin" = "貝南"; -"browsing_country.bermuda" = "百慕達"; -"browsing_country.bhutan" = "不丹"; -"browsing_country.bolivia" = "玻利維亞"; -"browsing_country.bonaire_saint_eustatius_and_saba" = "博奈爾、聖尤斯特歇斯和薩巴"; -"browsing_country.bosnia_and_herzegovina" = "波士尼亞"; -"browsing_country.botswana" = "波札那"; -"browsing_country.bouvet_island" = "布威島"; -"browsing_country.brazil" = "巴西"; -"browsing_country.british_indian_ocean_territory" = "英屬印度洋領地"; -"browsing_country.brunei_darussalam" = "汶萊"; -"browsing_country.bulgaria" = "保加利亞"; -"browsing_country.burkina_faso" = "布吉納法索"; -"browsing_country.burundi" = "蒲隆地"; -"browsing_country.cambodia" = "柬埔寨"; -"browsing_country.cameroon" = "喀麥隆"; -"browsing_country.canada" = "加拿大"; -"browsing_country.cape_verde" = "維德角"; -"browsing_country.cayman_islands" = "開曼群島"; -"browsing_country.central_african_republic" = "中非"; -"browsing_country.chad" = "查德"; -"browsing_country.chile" = "智利"; -"browsing_country.china" = "中國"; -"browsing_country.christmas_island" = "聖誕島"; -"browsing_country.cocos_islands" = "科科斯(基林)群島"; -"browsing_country.colombia" = "哥倫比亞"; -"browsing_country.comoros" = "葛摩"; -"browsing_country.congo" = "剛果共和國"; -"browsing_country.the_democratic_republic_of_the_congo" = "剛果民主共和國"; -"browsing_country.cook_islands" = "庫克群島"; -"browsing_country.costa_rica" = "哥斯大黎加"; -"browsing_country.cote_d_ivoire" = "象牙海岸"; -"browsing_country.croatia" = "克羅埃西亞"; -"browsing_country.cuba" = "古巴"; -"browsing_country.curacao" = "古拉索"; -"browsing_country.cyprus" = "賽普勒斯"; -"browsing_country.czech_republic" = "捷克共和國"; -"browsing_country.denmark" = "丹麥"; -"browsing_country.djibouti" = "吉布地"; -"browsing_country.dominica" = "多米尼克"; -"browsing_country.dominican_republic" = "多明尼加"; -"browsing_country.ecuador" = "厄瓜多"; -"browsing_country.egypt" = "埃及"; -"browsing_country.el_salvador" = "薩爾瓦多"; -"browsing_country.equatorial_guinea" = "赤道幾內亞"; -"browsing_country.eritrea" = "厄利垂亞"; -"browsing_country.estonia" = "愛沙尼亞"; -"browsing_country.ethiopia" = "衣索比亞"; -"browsing_country.europe" = "歐洲"; -"browsing_country.falkland_islands" = "福克蘭群島"; -"browsing_country.faroe_islands" = "法羅群島"; -"browsing_country.fiji" = "斐濟"; -"browsing_country.finland" = "芬蘭"; -"browsing_country.france" = "法國"; -"browsing_country.french_guiana" = "法屬圭亞那"; -"browsing_country.french_polynesia" = "法屬玻里尼西亞"; -"browsing_country.french_southern_territories" = "法屬南部領土"; -"browsing_country.gabon" = "加彭"; -"browsing_country.gambia" = "甘比亞"; -"browsing_country.georgia" = "喬治亞"; -"browsing_country.germany" = "德國"; -"browsing_country.ghana" = "迦納"; -"browsing_country.gibraltar" = "直布羅陀"; -"browsing_country.greece" = "希臘"; -"browsing_country.greenland" = "格陵蘭"; -"browsing_country.grenada" = "格瑞那達"; -"browsing_country.guadeloupe" = "瓜地洛普"; -"browsing_country.guam" = "關島"; -"browsing_country.guatemala" = "瓜地馬拉"; -"browsing_country.guernsey" = "耿西"; -"browsing_country.guinea" = "幾內亞"; -"browsing_country.guinea_bissau" = "幾內亞比索"; -"browsing_country.guyana" = "蓋亞那"; -"browsing_country.haiti" = "海地"; -"browsing_country.heard_island_and_mc_donald_islands" = "赫德島和麥克唐納群島"; -"browsing_country.vatican_city_state" = "梵蒂岡"; -"browsing_country.honduras" = "宏都拉斯"; -"browsing_country.hong_kong" = "香港"; -"browsing_country.hungary" = "匈牙利"; -"browsing_country.iceland" = "冰島"; -"browsing_country.india" = "印度"; -"browsing_country.indonesia" = "印度尼西亞(印尼)"; -"browsing_country.iran" = "伊朗"; -"browsing_country.iraq" = "伊拉克"; -"browsing_country.ireland" = "愛爾蘭"; -"browsing_country.isle_of_man" = "曼島"; -"browsing_country.israel" = "以色列"; -"browsing_country.italy" = "義大利"; -"browsing_country.jamaica" = "牙買加"; -"browsing_country.japan" = "日本"; -"browsing_country.jersey" = "澤西島"; -"browsing_country.jordan" = "約旦"; -"browsing_country.kazakhstan" = "哈薩克共和國"; -"browsing_country.kenya" = "肯亞"; -"browsing_country.kiribati" = "吉里巴斯"; -"browsing_country.kuwait" = "科威特"; -"browsing_country.kyrgyzstan" = "吉爾吉斯"; -"browsing_country.lao_peoples_democratic_republic" = "寮國"; -"browsing_country.latvia" = "拉脫維亞"; -"browsing_country.lebanon" = "黎巴嫩"; -"browsing_country.lesotho" = "賴索托"; -"browsing_country.liberia" = "賴比瑞亞"; -"browsing_country.libya" = "利比亞"; -"browsing_country.liechtenstein" = "列支敦斯登"; -"browsing_country.lithuania" = "立陶宛"; -"browsing_country.luxembourg" = "盧森堡"; -"browsing_country.macau" = "澳門"; -"browsing_country.macedonia" = "北馬其頓"; -"browsing_country.madagascar" = "馬達加斯加"; -"browsing_country.malawi" = "馬拉威"; -"browsing_country.malaysia" = "馬來西亞"; -"browsing_country.maldives" = "馬爾地夫"; -"browsing_country.mali" = "馬利"; -"browsing_country.malta" = "馬爾他"; -"browsing_country.marshall_islands" = "馬紹爾群島"; -"browsing_country.martinique" = "馬丁尼克"; -"browsing_country.mauritania" = "茅利塔尼亞"; -"browsing_country.mauritius" = "模里西斯"; -"browsing_country.mayotte" = "馬約特"; -"browsing_country.mexico" = "墨西哥"; -"browsing_country.micronesia" = "密克羅尼西亞"; -"browsing_country.moldova" = "摩爾多瓦"; -"browsing_country.monaco" = "摩納哥"; -"browsing_country.mongolia" = "蒙古"; -"browsing_country.montenegro" = "蒙特內哥羅"; -"browsing_country.montserrat" = "蒙特塞拉特"; -"browsing_country.morocco" = "摩洛哥"; -"browsing_country.mozambique" = "莫三比克"; -"browsing_country.myanmar" = "緬甸"; -"browsing_country.namibia" = "納米比亞"; -"browsing_country.nauru" = "諾魯"; -"browsing_country.nepal" = "尼泊爾"; -"browsing_country.netherlands" = "荷蘭"; -"browsing_country.new_caledonia" = "新喀里多尼亞"; -"browsing_country.new_zealand" = "紐西蘭"; -"browsing_country.nicaragua" = "尼加拉瓜"; -"browsing_country.niger" = "尼日"; -"browsing_country.nigeria" = "奈及利亞"; -"browsing_country.niue" = "紐埃"; -"browsing_country.norfolk_island" = "諾福克島"; -"browsing_country.north_korea" = "朝鮮"; -"browsing_country.northern_mariana_islands" = "北馬里亞納群島"; -"browsing_country.norway" = "挪威"; -"browsing_country.oman" = "阿曼"; -"browsing_country.pakistan" = "巴基斯坦"; -"browsing_country.palau" = "帛琉"; -"browsing_country.palestinian_territory" = "巴勒斯坦領土"; -"browsing_country.panama" = "巴拿馬"; -"browsing_country.papua_new_guinea" = "巴布亞紐幾內亞"; -"browsing_country.paraguay" = "巴拉圭"; -"browsing_country.peru" = "秘魯"; -"browsing_country.philippines" = "菲律賓"; -"browsing_country.pitcairn_islands" = "皮特凱恩群島"; -"browsing_country.poland" = "波蘭"; -"browsing_country.portugal" = "葡萄牙"; -"browsing_country.puerto_rico" = "波多黎各"; -"browsing_country.qatar" = "卡達"; -"browsing_country.reunion" = "留尼旺"; -"browsing_country.romania" = "羅馬尼亞"; -"browsing_country.russian_federation" = "俄羅斯"; -"browsing_country.rwanda" = "盧安達"; -"browsing_country.saint_barthelemy" = "聖巴瑟米"; -"browsing_country.saint_helena" = "聖赫勒拿"; -"browsing_country.saint_kitts_and_nevis" = "聖克里斯多福及尼維斯"; -"browsing_country.saint_lucia" = "聖露西亞"; -"browsing_country.saint_martin" = "聖馬丁"; -"browsing_country.saint_pierre_and_miquelon" = "聖皮耶與密克隆"; -"browsing_country.saint_vincent_and_the_grenadines" = "聖文森及格瑞那丁"; -"browsing_country.samoa" = "薩摩亞"; -"browsing_country.san_marino" = "聖馬利諾"; -"browsing_country.sao_tome_and_principe" = "聖多美普林西比"; -"browsing_country.saudi_arabia" = "沙烏地阿拉伯"; -"browsing_country.senegal" = "塞內加爾"; -"browsing_country.serbia" = "塞爾維亞"; -"browsing_country.seychelles" = "塞席爾"; -"browsing_country.sierra_leone" = "獅子山"; -"browsing_country.singapore" = "新加坡"; -"browsing_country.sint_maarten" = "聖馬丁"; -"browsing_country.slovakia" = "斯洛伐克"; -"browsing_country.slovenia" = "斯洛維尼亞"; -"browsing_country.solomon_islands" = "索羅門群島"; -"browsing_country.somalia" = "索馬利亞"; -"browsing_country.south_africa" = "南非"; -"browsing_country.south_georgia_and_the_south_sandwich_islands" = "南喬治亞和南桑威奇群島"; -"browsing_country.south_korea" = "韓國"; -"browsing_country.south_sudan" = "南蘇丹"; -"browsing_country.spain" = "西班牙"; -"browsing_country.sri_lanka" = "斯里蘭卡"; -"browsing_country.sudan" = "蘇丹"; -"browsing_country.suriname" = "蘇利南"; -"browsing_country.svalbard_and_jan_mayen" = "斯瓦巴和揚馬延"; -"browsing_country.swaziland" = "史瓦帝尼"; -"browsing_country.sweden" = "瑞典"; -"browsing_country.switzerland" = "瑞士"; -"browsing_country.syrian_arab_republic" = "敘利亞"; -"browsing_country.taiwan" = "臺灣"; -"browsing_country.tajikistan" = "塔吉克"; -"browsing_country.tanzania" = "坦尚尼亞"; -"browsing_country.thailand" = "泰國"; -"browsing_country.timor_leste" = "東帝汶"; -"browsing_country.togo" = "多哥"; -"browsing_country.tokelau" = "托克勞"; -"browsing_country.tonga" = "東加"; -"browsing_country.trinidad_and_tobago" = "千里達和托巴哥"; -"browsing_country.tunisia" = "突尼西亞"; -"browsing_country.turkey" = "土耳其"; -"browsing_country.turkmenistan" = "土庫曼"; -"browsing_country.turks_and_caicos_islands" = "土克斯及開科斯群島"; -"browsing_country.tuvalu" = "吐瓦魯"; -"browsing_country.uganda" = "烏干達"; -"browsing_country.ukraine" = "烏克蘭"; -"browsing_country.united_arab_emirates" = "阿拉伯聯合大公國"; -"browsing_country.united_kingdom" = "英國"; -"browsing_country.united_states" = "美國"; -"browsing_country.united_states_minor_outlying_islands" = "美國外圍小島嶼"; -"browsing_country.uruguay" = "烏拉圭"; -"browsing_country.uzbekistan" = "烏茲別克"; -"browsing_country.vanuatu" = "萬那杜"; -"browsing_country.venezuela" = "委內瑞拉"; -"browsing_country.vietnam" = "越南"; -"browsing_country.virgin_islands_british" = "英屬維京群島"; -"browsing_country.virgin_islands_US" = "美屬維京群島"; -"browsing_country.wallis_and_futuna" = "瓦利斯和富圖納"; -"browsing_country.western_sahara" = "西撒哈拉"; -"browsing_country.yemen" = "葉門"; -"browsing_country.zambia" = "尚比亞"; -"browsing_country.zimbabwe" = "辛巴威"; diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift index bec5975a6..0d7440fbe 100644 --- a/AppPackage/Sources/Resources/Strings.swift +++ b/AppPackage/Sources/Resources/Strings.swift @@ -22,627 +22,6 @@ public enum L10n { /// Login public static let login = L10n.tr("Localizable", "account_setting_view.login", fallback: "Login") } - public enum AppActivityLogsView { - public enum Level { - /// Debug - public static let debug = L10n.tr("Localizable", "app_activity_logs_view.level.debug", fallback: "Debug") - /// Error - public static let error = L10n.tr("Localizable", "app_activity_logs_view.level.error", fallback: "Error") - /// Fault - public static let fault = L10n.tr("Localizable", "app_activity_logs_view.level.fault", fallback: "Fault") - /// Info - public static let info = L10n.tr("Localizable", "app_activity_logs_view.level.info", fallback: "Info") - /// Notice - public static let notice = L10n.tr("Localizable", "app_activity_logs_view.level.notice", fallback: "Notice") - /// Undefined - public static let undefined = L10n.tr("Localizable", "app_activity_logs_view.level.undefined", fallback: "Undefined") - } - } - public enum AppError { - /// Authentication Required - public static let authenticationRequired = L10n.tr("Localizable", "app_error.authentication_required", fallback: "Authentication Required") - /// Login required to access this download. - public static let authenticationRequiredDescription = L10n.tr("Localizable", "app_error.authentication_required_description", fallback: "Login required to access this download.") - /// Copyright Claim - public static let copyrightClaim = L10n.tr("Localizable", "app_error.copyright_claim", fallback: "Copyright Claim") - /// Database Corrupted - public static let databaseCorrupted = L10n.tr("Localizable", "app_error.database_corrupted", fallback: "Database Corrupted") - /// File Operation Failed - public static let fileOperationFailed = L10n.tr("Localizable", "app_error.file_operation_failed", fallback: "File Operation Failed") - /// Gallery Expunged - public static let galleryExpunged = L10n.tr("Localizable", "app_error.gallery_expunged", fallback: "Gallery Expunged") - /// IP Banned - public static let ipBanned = L10n.tr("Localizable", "app_error.ip_banned", fallback: "IP Banned") - /// Local file operation failed. - public static let localFileOperationFailed = L10n.tr("Localizable", "app_error.local_file_operation_failed", fallback: "Local file operation failed.") - /// Network Error - public static let networkError = L10n.tr("Localizable", "app_error.network_error", fallback: "Network Error") - /// No updates available - public static let noUpdatesAvailable = L10n.tr("Localizable", "app_error.no_updates_available", fallback: "No updates available") - /// Not found - public static let notFound = L10n.tr("Localizable", "app_error.not_found", fallback: "Not found") - /// Parse Error - public static let parseError = L10n.tr("Localizable", "app_error.parse_error", fallback: "Parse Error") - /// Quota Exceeded - public static let quotaExceeded = L10n.tr("Localizable", "app_error.quota_exceeded", fallback: "Quota Exceeded") - /// Image quota exceeded. - /// Please wait and try again later. - public static let quotaExceededDescription = L10n.tr("Localizable", "app_error.quota_exceeded_description", fallback: "Image quota exceeded.\nPlease wait and try again later.") - /// Unknown Error - public static let unknownError = L10n.tr("Localizable", "app_error.unknown_error", fallback: "Unknown Error") - /// Web image loading error - public static let webImageLoadingError = L10n.tr("Localizable", "app_error.web_image_loading_error", fallback: "Web image loading error") - } - public enum AppIconType { - /// Default - public static let `default` = L10n.tr("Localizable", "app_icon_type.default", fallback: "Default") - /// Developer - public static let developer = L10n.tr("Localizable", "app_icon_type.developer", fallback: "Developer") - /// NOT MY PRESIDENT - public static let notMyPresident = L10n.tr("Localizable", "app_icon_type.not_my_president", fallback: "NOT MY PRESIDENT") - /// Stand With Ukraine (2022) - public static let standWithUkraine2022 = L10n.tr("Localizable", "app_icon_type.stand_with_ukraine_2022", fallback: "Stand With Ukraine (2022)") - /// Ukiyo-e - public static let ukiyoe = L10n.tr("Localizable", "app_icon_type.ukiyoe", fallback: "Ukiyo-e") - } - public enum ArchiveResolution { - /// Original - public static let original = L10n.tr("Localizable", "archive_resolution.original", fallback: "Original") - } - public enum AutoLockPolicy { - /// Instantly - public static let instantly = L10n.tr("Localizable", "auto_lock_policy.instantly", fallback: "Instantly") - /// Never - public static let never = L10n.tr("Localizable", "auto_lock_policy.never", fallback: "Never") - } - public enum BanInterval { - /// and - public static let and = L10n.tr("Localizable", "ban_interval.and", fallback: "and") - } - public enum BrowsingCountry { - /// Afghanistan - public static let afghanistan = L10n.tr("Localizable", "browsing_country.afghanistan", fallback: "Afghanistan") - /// Aland Islands - public static let alandIslands = L10n.tr("Localizable", "browsing_country.aland_islands", fallback: "Aland Islands") - /// Albania - public static let albania = L10n.tr("Localizable", "browsing_country.albania", fallback: "Albania") - /// Algeria - public static let algeria = L10n.tr("Localizable", "browsing_country.algeria", fallback: "Algeria") - /// American Samoa - public static let americanSamoa = L10n.tr("Localizable", "browsing_country.american_samoa", fallback: "American Samoa") - /// Andorra - public static let andorra = L10n.tr("Localizable", "browsing_country.andorra", fallback: "Andorra") - /// Angola - public static let angola = L10n.tr("Localizable", "browsing_country.angola", fallback: "Angola") - /// Anguilla - public static let anguilla = L10n.tr("Localizable", "browsing_country.anguilla", fallback: "Anguilla") - /// Antarctica - public static let antarctica = L10n.tr("Localizable", "browsing_country.antarctica", fallback: "Antarctica") - /// Antigua and Barbuda - public static let antiguaAndBarbuda = L10n.tr("Localizable", "browsing_country.antigua_and_barbuda", fallback: "Antigua and Barbuda") - /// Argentina - public static let argentina = L10n.tr("Localizable", "browsing_country.argentina", fallback: "Argentina") - /// Armenia - public static let armenia = L10n.tr("Localizable", "browsing_country.armenia", fallback: "Armenia") - /// Aruba - public static let aruba = L10n.tr("Localizable", "browsing_country.aruba", fallback: "Aruba") - /// Asia-Pacific Region - public static let asiaPacificRegion = L10n.tr("Localizable", "browsing_country.asia_pacific_region", fallback: "Asia-Pacific Region") - /// Australia - public static let australia = L10n.tr("Localizable", "browsing_country.australia", fallback: "Australia") - /// Austria - public static let austria = L10n.tr("Localizable", "browsing_country.austria", fallback: "Austria") - /// Auto-Detect - public static let autoDetect = L10n.tr("Localizable", "browsing_country.auto_detect", fallback: "Auto-Detect") - /// Azerbaijan - public static let azerbaijan = L10n.tr("Localizable", "browsing_country.azerbaijan", fallback: "Azerbaijan") - /// Bahamas - public static let bahamas = L10n.tr("Localizable", "browsing_country.bahamas", fallback: "Bahamas") - /// Bahrain - public static let bahrain = L10n.tr("Localizable", "browsing_country.bahrain", fallback: "Bahrain") - /// Bangladesh - public static let bangladesh = L10n.tr("Localizable", "browsing_country.bangladesh", fallback: "Bangladesh") - /// Barbados - public static let barbados = L10n.tr("Localizable", "browsing_country.barbados", fallback: "Barbados") - /// Belarus - public static let belarus = L10n.tr("Localizable", "browsing_country.belarus", fallback: "Belarus") - /// Belgium - public static let belgium = L10n.tr("Localizable", "browsing_country.belgium", fallback: "Belgium") - /// Belize - public static let belize = L10n.tr("Localizable", "browsing_country.belize", fallback: "Belize") - /// Benin - public static let benin = L10n.tr("Localizable", "browsing_country.benin", fallback: "Benin") - /// Bermuda - public static let bermuda = L10n.tr("Localizable", "browsing_country.bermuda", fallback: "Bermuda") - /// Bhutan - public static let bhutan = L10n.tr("Localizable", "browsing_country.bhutan", fallback: "Bhutan") - /// Bolivia - public static let bolivia = L10n.tr("Localizable", "browsing_country.bolivia", fallback: "Bolivia") - /// Bonaire Saint Eustatius and Saba - public static let bonaireSaintEustatiusAndSaba = L10n.tr("Localizable", "browsing_country.bonaire_saint_eustatius_and_saba", fallback: "Bonaire Saint Eustatius and Saba") - /// Bosnia and Herzegovina - public static let bosniaAndHerzegovina = L10n.tr("Localizable", "browsing_country.bosnia_and_herzegovina", fallback: "Bosnia and Herzegovina") - /// Botswana - public static let botswana = L10n.tr("Localizable", "browsing_country.botswana", fallback: "Botswana") - /// Bouvet Island - public static let bouvetIsland = L10n.tr("Localizable", "browsing_country.bouvet_island", fallback: "Bouvet Island") - /// Brazil - public static let brazil = L10n.tr("Localizable", "browsing_country.brazil", fallback: "Brazil") - /// British Indian Ocean Territory - public static let britishIndianOceanTerritory = L10n.tr("Localizable", "browsing_country.british_indian_ocean_territory", fallback: "British Indian Ocean Territory") - /// Brunei Darussalam - public static let bruneiDarussalam = L10n.tr("Localizable", "browsing_country.brunei_darussalam", fallback: "Brunei Darussalam") - /// Bulgaria - public static let bulgaria = L10n.tr("Localizable", "browsing_country.bulgaria", fallback: "Bulgaria") - /// Burkina Faso - public static let burkinaFaso = L10n.tr("Localizable", "browsing_country.burkina_faso", fallback: "Burkina Faso") - /// Burundi - public static let burundi = L10n.tr("Localizable", "browsing_country.burundi", fallback: "Burundi") - /// Cambodia - public static let cambodia = L10n.tr("Localizable", "browsing_country.cambodia", fallback: "Cambodia") - /// Cameroon - public static let cameroon = L10n.tr("Localizable", "browsing_country.cameroon", fallback: "Cameroon") - /// Canada - public static let canada = L10n.tr("Localizable", "browsing_country.canada", fallback: "Canada") - /// Cape Verde - public static let capeVerde = L10n.tr("Localizable", "browsing_country.cape_verde", fallback: "Cape Verde") - /// Cayman Islands - public static let caymanIslands = L10n.tr("Localizable", "browsing_country.cayman_islands", fallback: "Cayman Islands") - /// Central African Republic - public static let centralAfricanRepublic = L10n.tr("Localizable", "browsing_country.central_african_republic", fallback: "Central African Republic") - /// Chad - public static let chad = L10n.tr("Localizable", "browsing_country.chad", fallback: "Chad") - /// Chile - public static let chile = L10n.tr("Localizable", "browsing_country.chile", fallback: "Chile") - /// China - public static let china = L10n.tr("Localizable", "browsing_country.china", fallback: "China") - /// Christmas Island - public static let christmasIsland = L10n.tr("Localizable", "browsing_country.christmas_island", fallback: "Christmas Island") - /// Cocos Islands - public static let cocosIslands = L10n.tr("Localizable", "browsing_country.cocos_islands", fallback: "Cocos Islands") - /// Colombia - public static let colombia = L10n.tr("Localizable", "browsing_country.colombia", fallback: "Colombia") - /// Comoros - public static let comoros = L10n.tr("Localizable", "browsing_country.comoros", fallback: "Comoros") - /// Congo - public static let congo = L10n.tr("Localizable", "browsing_country.congo", fallback: "Congo") - /// Cook Islands - public static let cookIslands = L10n.tr("Localizable", "browsing_country.cook_islands", fallback: "Cook Islands") - /// Costa Rica - public static let costaRica = L10n.tr("Localizable", "browsing_country.costa_rica", fallback: "Costa Rica") - /// Cote D'Ivoire - public static let coteDIvoire = L10n.tr("Localizable", "browsing_country.cote_d_ivoire", fallback: "Cote D'Ivoire") - /// Croatia - public static let croatia = L10n.tr("Localizable", "browsing_country.croatia", fallback: "Croatia") - /// Cuba - public static let cuba = L10n.tr("Localizable", "browsing_country.cuba", fallback: "Cuba") - /// Curacao - public static let curacao = L10n.tr("Localizable", "browsing_country.curacao", fallback: "Curacao") - /// Cyprus - public static let cyprus = L10n.tr("Localizable", "browsing_country.cyprus", fallback: "Cyprus") - /// Czech Republic - public static let czechRepublic = L10n.tr("Localizable", "browsing_country.czech_republic", fallback: "Czech Republic") - /// Denmark - public static let denmark = L10n.tr("Localizable", "browsing_country.denmark", fallback: "Denmark") - /// Djibouti - public static let djibouti = L10n.tr("Localizable", "browsing_country.djibouti", fallback: "Djibouti") - /// Dominica - public static let dominica = L10n.tr("Localizable", "browsing_country.dominica", fallback: "Dominica") - /// Dominican Republic - public static let dominicanRepublic = L10n.tr("Localizable", "browsing_country.dominican_republic", fallback: "Dominican Republic") - /// Ecuador - public static let ecuador = L10n.tr("Localizable", "browsing_country.ecuador", fallback: "Ecuador") - /// Egypt - public static let egypt = L10n.tr("Localizable", "browsing_country.egypt", fallback: "Egypt") - /// El Salvador - public static let elSalvador = L10n.tr("Localizable", "browsing_country.el_salvador", fallback: "El Salvador") - /// Equatorial Guinea - public static let equatorialGuinea = L10n.tr("Localizable", "browsing_country.equatorial_guinea", fallback: "Equatorial Guinea") - /// Eritrea - public static let eritrea = L10n.tr("Localizable", "browsing_country.eritrea", fallback: "Eritrea") - /// Estonia - public static let estonia = L10n.tr("Localizable", "browsing_country.estonia", fallback: "Estonia") - /// Ethiopia - public static let ethiopia = L10n.tr("Localizable", "browsing_country.ethiopia", fallback: "Ethiopia") - /// Europe - public static let europe = L10n.tr("Localizable", "browsing_country.europe", fallback: "Europe") - /// Falkland Islands - public static let falklandIslands = L10n.tr("Localizable", "browsing_country.falkland_islands", fallback: "Falkland Islands") - /// Faroe Islands - public static let faroeIslands = L10n.tr("Localizable", "browsing_country.faroe_islands", fallback: "Faroe Islands") - /// Fiji - public static let fiji = L10n.tr("Localizable", "browsing_country.fiji", fallback: "Fiji") - /// Finland - public static let finland = L10n.tr("Localizable", "browsing_country.finland", fallback: "Finland") - /// France - public static let france = L10n.tr("Localizable", "browsing_country.france", fallback: "France") - /// French Guiana - public static let frenchGuiana = L10n.tr("Localizable", "browsing_country.french_guiana", fallback: "French Guiana") - /// French Polynesia - public static let frenchPolynesia = L10n.tr("Localizable", "browsing_country.french_polynesia", fallback: "French Polynesia") - /// French Southern Territories - public static let frenchSouthernTerritories = L10n.tr("Localizable", "browsing_country.french_southern_territories", fallback: "French Southern Territories") - /// Gabon - public static let gabon = L10n.tr("Localizable", "browsing_country.gabon", fallback: "Gabon") - /// Gambia - public static let gambia = L10n.tr("Localizable", "browsing_country.gambia", fallback: "Gambia") - /// Georgia - public static let georgia = L10n.tr("Localizable", "browsing_country.georgia", fallback: "Georgia") - /// Germany - public static let germany = L10n.tr("Localizable", "browsing_country.germany", fallback: "Germany") - /// Ghana - public static let ghana = L10n.tr("Localizable", "browsing_country.ghana", fallback: "Ghana") - /// Gibraltar - public static let gibraltar = L10n.tr("Localizable", "browsing_country.gibraltar", fallback: "Gibraltar") - /// Greece - public static let greece = L10n.tr("Localizable", "browsing_country.greece", fallback: "Greece") - /// Greenland - public static let greenland = L10n.tr("Localizable", "browsing_country.greenland", fallback: "Greenland") - /// Grenada - public static let grenada = L10n.tr("Localizable", "browsing_country.grenada", fallback: "Grenada") - /// Guadeloupe - public static let guadeloupe = L10n.tr("Localizable", "browsing_country.guadeloupe", fallback: "Guadeloupe") - /// Guam - public static let guam = L10n.tr("Localizable", "browsing_country.guam", fallback: "Guam") - /// Guatemala - public static let guatemala = L10n.tr("Localizable", "browsing_country.guatemala", fallback: "Guatemala") - /// Guernsey - public static let guernsey = L10n.tr("Localizable", "browsing_country.guernsey", fallback: "Guernsey") - /// Guinea - public static let guinea = L10n.tr("Localizable", "browsing_country.guinea", fallback: "Guinea") - /// Guinea-Bissau - public static let guineaBissau = L10n.tr("Localizable", "browsing_country.guinea_bissau", fallback: "Guinea-Bissau") - /// Guyana - public static let guyana = L10n.tr("Localizable", "browsing_country.guyana", fallback: "Guyana") - /// Haiti - public static let haiti = L10n.tr("Localizable", "browsing_country.haiti", fallback: "Haiti") - /// Heard Island and McDonald Islands - public static let heardIslandAndMcDonaldIslands = L10n.tr("Localizable", "browsing_country.heard_island_and_mc_donald_islands", fallback: "Heard Island and McDonald Islands") - /// Honduras - public static let honduras = L10n.tr("Localizable", "browsing_country.honduras", fallback: "Honduras") - /// Hong Kong - public static let hongKong = L10n.tr("Localizable", "browsing_country.hong_kong", fallback: "Hong Kong") - /// Hungary - public static let hungary = L10n.tr("Localizable", "browsing_country.hungary", fallback: "Hungary") - /// Iceland - public static let iceland = L10n.tr("Localizable", "browsing_country.iceland", fallback: "Iceland") - /// India - public static let india = L10n.tr("Localizable", "browsing_country.india", fallback: "India") - /// Indonesia - public static let indonesia = L10n.tr("Localizable", "browsing_country.indonesia", fallback: "Indonesia") - /// Iran - public static let iran = L10n.tr("Localizable", "browsing_country.iran", fallback: "Iran") - /// Iraq - public static let iraq = L10n.tr("Localizable", "browsing_country.iraq", fallback: "Iraq") - /// Ireland - public static let ireland = L10n.tr("Localizable", "browsing_country.ireland", fallback: "Ireland") - /// Isle of Man - public static let isleOfMan = L10n.tr("Localizable", "browsing_country.isle_of_man", fallback: "Isle of Man") - /// Israel - public static let israel = L10n.tr("Localizable", "browsing_country.israel", fallback: "Israel") - /// Italy - public static let italy = L10n.tr("Localizable", "browsing_country.italy", fallback: "Italy") - /// Jamaica - public static let jamaica = L10n.tr("Localizable", "browsing_country.jamaica", fallback: "Jamaica") - /// Japan - public static let japan = L10n.tr("Localizable", "browsing_country.japan", fallback: "Japan") - /// Jersey - public static let jersey = L10n.tr("Localizable", "browsing_country.jersey", fallback: "Jersey") - /// Jordan - public static let jordan = L10n.tr("Localizable", "browsing_country.jordan", fallback: "Jordan") - /// Kazakhstan - public static let kazakhstan = L10n.tr("Localizable", "browsing_country.kazakhstan", fallback: "Kazakhstan") - /// Kenya - public static let kenya = L10n.tr("Localizable", "browsing_country.kenya", fallback: "Kenya") - /// Kiribati - public static let kiribati = L10n.tr("Localizable", "browsing_country.kiribati", fallback: "Kiribati") - /// Kuwait - public static let kuwait = L10n.tr("Localizable", "browsing_country.kuwait", fallback: "Kuwait") - /// Kyrgyzstan - public static let kyrgyzstan = L10n.tr("Localizable", "browsing_country.kyrgyzstan", fallback: "Kyrgyzstan") - /// Lao People's Democratic Republic - public static let laoPeoplesDemocraticRepublic = L10n.tr("Localizable", "browsing_country.lao_peoples_democratic_republic", fallback: "Lao People's Democratic Republic") - /// Latvia - public static let latvia = L10n.tr("Localizable", "browsing_country.latvia", fallback: "Latvia") - /// Lebanon - public static let lebanon = L10n.tr("Localizable", "browsing_country.lebanon", fallback: "Lebanon") - /// Lesotho - public static let lesotho = L10n.tr("Localizable", "browsing_country.lesotho", fallback: "Lesotho") - /// Liberia - public static let liberia = L10n.tr("Localizable", "browsing_country.liberia", fallback: "Liberia") - /// Libya - public static let libya = L10n.tr("Localizable", "browsing_country.libya", fallback: "Libya") - /// Liechtenstein - public static let liechtenstein = L10n.tr("Localizable", "browsing_country.liechtenstein", fallback: "Liechtenstein") - /// Lithuania - public static let lithuania = L10n.tr("Localizable", "browsing_country.lithuania", fallback: "Lithuania") - /// Luxembourg - public static let luxembourg = L10n.tr("Localizable", "browsing_country.luxembourg", fallback: "Luxembourg") - /// Macau - public static let macau = L10n.tr("Localizable", "browsing_country.macau", fallback: "Macau") - /// Macedonia - public static let macedonia = L10n.tr("Localizable", "browsing_country.macedonia", fallback: "Macedonia") - /// Madagascar - public static let madagascar = L10n.tr("Localizable", "browsing_country.madagascar", fallback: "Madagascar") - /// Malawi - public static let malawi = L10n.tr("Localizable", "browsing_country.malawi", fallback: "Malawi") - /// Malaysia - public static let malaysia = L10n.tr("Localizable", "browsing_country.malaysia", fallback: "Malaysia") - /// Maldives - public static let maldives = L10n.tr("Localizable", "browsing_country.maldives", fallback: "Maldives") - /// Mali - public static let mali = L10n.tr("Localizable", "browsing_country.mali", fallback: "Mali") - /// Malta - public static let malta = L10n.tr("Localizable", "browsing_country.malta", fallback: "Malta") - /// Marshall Islands - public static let marshallIslands = L10n.tr("Localizable", "browsing_country.marshall_islands", fallback: "Marshall Islands") - /// Martinique - public static let martinique = L10n.tr("Localizable", "browsing_country.martinique", fallback: "Martinique") - /// Mauritania - public static let mauritania = L10n.tr("Localizable", "browsing_country.mauritania", fallback: "Mauritania") - /// Mauritius - public static let mauritius = L10n.tr("Localizable", "browsing_country.mauritius", fallback: "Mauritius") - /// Mayotte - public static let mayotte = L10n.tr("Localizable", "browsing_country.mayotte", fallback: "Mayotte") - /// Mexico - public static let mexico = L10n.tr("Localizable", "browsing_country.mexico", fallback: "Mexico") - /// Micronesia - public static let micronesia = L10n.tr("Localizable", "browsing_country.micronesia", fallback: "Micronesia") - /// Moldova - public static let moldova = L10n.tr("Localizable", "browsing_country.moldova", fallback: "Moldova") - /// Monaco - public static let monaco = L10n.tr("Localizable", "browsing_country.monaco", fallback: "Monaco") - /// Mongolia - public static let mongolia = L10n.tr("Localizable", "browsing_country.mongolia", fallback: "Mongolia") - /// Montenegro - public static let montenegro = L10n.tr("Localizable", "browsing_country.montenegro", fallback: "Montenegro") - /// Montserrat - public static let montserrat = L10n.tr("Localizable", "browsing_country.montserrat", fallback: "Montserrat") - /// Morocco - public static let morocco = L10n.tr("Localizable", "browsing_country.morocco", fallback: "Morocco") - /// Mozambique - public static let mozambique = L10n.tr("Localizable", "browsing_country.mozambique", fallback: "Mozambique") - /// Myanmar - public static let myanmar = L10n.tr("Localizable", "browsing_country.myanmar", fallback: "Myanmar") - /// Namibia - public static let namibia = L10n.tr("Localizable", "browsing_country.namibia", fallback: "Namibia") - /// Nauru - public static let nauru = L10n.tr("Localizable", "browsing_country.nauru", fallback: "Nauru") - /// Nepal - public static let nepal = L10n.tr("Localizable", "browsing_country.nepal", fallback: "Nepal") - /// Netherlands - public static let netherlands = L10n.tr("Localizable", "browsing_country.netherlands", fallback: "Netherlands") - /// New Caledonia - public static let newCaledonia = L10n.tr("Localizable", "browsing_country.new_caledonia", fallback: "New Caledonia") - /// New Zealand - public static let newZealand = L10n.tr("Localizable", "browsing_country.new_zealand", fallback: "New Zealand") - /// Nicaragua - public static let nicaragua = L10n.tr("Localizable", "browsing_country.nicaragua", fallback: "Nicaragua") - /// Niger - public static let niger = L10n.tr("Localizable", "browsing_country.niger", fallback: "Niger") - /// Nigeria - public static let nigeria = L10n.tr("Localizable", "browsing_country.nigeria", fallback: "Nigeria") - /// Niue - public static let niue = L10n.tr("Localizable", "browsing_country.niue", fallback: "Niue") - /// Norfolk Island - public static let norfolkIsland = L10n.tr("Localizable", "browsing_country.norfolk_island", fallback: "Norfolk Island") - /// North Korea - public static let northKorea = L10n.tr("Localizable", "browsing_country.north_korea", fallback: "North Korea") - /// Northern Mariana Islands - public static let northernMarianaIslands = L10n.tr("Localizable", "browsing_country.northern_mariana_islands", fallback: "Northern Mariana Islands") - /// Norway - public static let norway = L10n.tr("Localizable", "browsing_country.norway", fallback: "Norway") - /// Oman - public static let oman = L10n.tr("Localizable", "browsing_country.oman", fallback: "Oman") - /// Pakistan - public static let pakistan = L10n.tr("Localizable", "browsing_country.pakistan", fallback: "Pakistan") - /// Palau - public static let palau = L10n.tr("Localizable", "browsing_country.palau", fallback: "Palau") - /// Palestinian Territory - public static let palestinianTerritory = L10n.tr("Localizable", "browsing_country.palestinian_territory", fallback: "Palestinian Territory") - /// Panama - public static let panama = L10n.tr("Localizable", "browsing_country.panama", fallback: "Panama") - /// Papua New Guinea - public static let papuaNewGuinea = L10n.tr("Localizable", "browsing_country.papua_new_guinea", fallback: "Papua New Guinea") - /// Paraguay - public static let paraguay = L10n.tr("Localizable", "browsing_country.paraguay", fallback: "Paraguay") - /// Peru - public static let peru = L10n.tr("Localizable", "browsing_country.peru", fallback: "Peru") - /// Philippines - public static let philippines = L10n.tr("Localizable", "browsing_country.philippines", fallback: "Philippines") - /// Pitcairn Islands - public static let pitcairnIslands = L10n.tr("Localizable", "browsing_country.pitcairn_islands", fallback: "Pitcairn Islands") - /// Poland - public static let poland = L10n.tr("Localizable", "browsing_country.poland", fallback: "Poland") - /// Portugal - public static let portugal = L10n.tr("Localizable", "browsing_country.portugal", fallback: "Portugal") - /// Puerto Rico - public static let puertoRico = L10n.tr("Localizable", "browsing_country.puerto_rico", fallback: "Puerto Rico") - /// Qatar - public static let qatar = L10n.tr("Localizable", "browsing_country.qatar", fallback: "Qatar") - /// Reunion - public static let reunion = L10n.tr("Localizable", "browsing_country.reunion", fallback: "Reunion") - /// Romania - public static let romania = L10n.tr("Localizable", "browsing_country.romania", fallback: "Romania") - /// Russian Federation - public static let russianFederation = L10n.tr("Localizable", "browsing_country.russian_federation", fallback: "Russian Federation") - /// Rwanda - public static let rwanda = L10n.tr("Localizable", "browsing_country.rwanda", fallback: "Rwanda") - /// Saint Barthelemy - public static let saintBarthelemy = L10n.tr("Localizable", "browsing_country.saint_barthelemy", fallback: "Saint Barthelemy") - /// Saint Helena - public static let saintHelena = L10n.tr("Localizable", "browsing_country.saint_helena", fallback: "Saint Helena") - /// Saint Kitts and Nevis - public static let saintKittsAndNevis = L10n.tr("Localizable", "browsing_country.saint_kitts_and_nevis", fallback: "Saint Kitts and Nevis") - /// Saint Lucia - public static let saintLucia = L10n.tr("Localizable", "browsing_country.saint_lucia", fallback: "Saint Lucia") - /// Saint Martin - public static let saintMartin = L10n.tr("Localizable", "browsing_country.saint_martin", fallback: "Saint Martin") - /// Saint Pierre and Miquelon - public static let saintPierreAndMiquelon = L10n.tr("Localizable", "browsing_country.saint_pierre_and_miquelon", fallback: "Saint Pierre and Miquelon") - /// Saint Vincent and the Grenadines - public static let saintVincentAndTheGrenadines = L10n.tr("Localizable", "browsing_country.saint_vincent_and_the_grenadines", fallback: "Saint Vincent and the Grenadines") - /// Samoa - public static let samoa = L10n.tr("Localizable", "browsing_country.samoa", fallback: "Samoa") - /// San Marino - public static let sanMarino = L10n.tr("Localizable", "browsing_country.san_marino", fallback: "San Marino") - /// Sao Tome and Principe - public static let saoTomeAndPrincipe = L10n.tr("Localizable", "browsing_country.sao_tome_and_principe", fallback: "Sao Tome and Principe") - /// Saudi Arabia - public static let saudiArabia = L10n.tr("Localizable", "browsing_country.saudi_arabia", fallback: "Saudi Arabia") - /// Senegal - public static let senegal = L10n.tr("Localizable", "browsing_country.senegal", fallback: "Senegal") - /// Serbia - public static let serbia = L10n.tr("Localizable", "browsing_country.serbia", fallback: "Serbia") - /// Seychelles - public static let seychelles = L10n.tr("Localizable", "browsing_country.seychelles", fallback: "Seychelles") - /// Sierra Leone - public static let sierraLeone = L10n.tr("Localizable", "browsing_country.sierra_leone", fallback: "Sierra Leone") - /// Singapore - public static let singapore = L10n.tr("Localizable", "browsing_country.singapore", fallback: "Singapore") - /// Sint Maarten - public static let sintMaarten = L10n.tr("Localizable", "browsing_country.sint_maarten", fallback: "Sint Maarten") - /// Slovakia - public static let slovakia = L10n.tr("Localizable", "browsing_country.slovakia", fallback: "Slovakia") - /// Slovenia - public static let slovenia = L10n.tr("Localizable", "browsing_country.slovenia", fallback: "Slovenia") - /// Solomon Islands - public static let solomonIslands = L10n.tr("Localizable", "browsing_country.solomon_islands", fallback: "Solomon Islands") - /// Somalia - public static let somalia = L10n.tr("Localizable", "browsing_country.somalia", fallback: "Somalia") - /// South Africa - public static let southAfrica = L10n.tr("Localizable", "browsing_country.south_africa", fallback: "South Africa") - /// South Georgia and the South Sandwich Islands - public static let southGeorgiaAndTheSouthSandwichIslands = L10n.tr("Localizable", "browsing_country.south_georgia_and_the_south_sandwich_islands", fallback: "South Georgia and the South Sandwich Islands") - /// South Korea - public static let southKorea = L10n.tr("Localizable", "browsing_country.south_korea", fallback: "South Korea") - /// South Sudan - public static let southSudan = L10n.tr("Localizable", "browsing_country.south_sudan", fallback: "South Sudan") - /// Spain - public static let spain = L10n.tr("Localizable", "browsing_country.spain", fallback: "Spain") - /// Sri Lanka - public static let sriLanka = L10n.tr("Localizable", "browsing_country.sri_lanka", fallback: "Sri Lanka") - /// Sudan - public static let sudan = L10n.tr("Localizable", "browsing_country.sudan", fallback: "Sudan") - /// Suriname - public static let suriname = L10n.tr("Localizable", "browsing_country.suriname", fallback: "Suriname") - /// Svalbard and Jan Mayen - public static let svalbardAndJanMayen = L10n.tr("Localizable", "browsing_country.svalbard_and_jan_mayen", fallback: "Svalbard and Jan Mayen") - /// Swaziland - public static let swaziland = L10n.tr("Localizable", "browsing_country.swaziland", fallback: "Swaziland") - /// Sweden - public static let sweden = L10n.tr("Localizable", "browsing_country.sweden", fallback: "Sweden") - /// Switzerland - public static let switzerland = L10n.tr("Localizable", "browsing_country.switzerland", fallback: "Switzerland") - /// Syrian Arab Republic - public static let syrianArabRepublic = L10n.tr("Localizable", "browsing_country.syrian_arab_republic", fallback: "Syrian Arab Republic") - /// Taiwan - public static let taiwan = L10n.tr("Localizable", "browsing_country.taiwan", fallback: "Taiwan") - /// Tajikistan - public static let tajikistan = L10n.tr("Localizable", "browsing_country.tajikistan", fallback: "Tajikistan") - /// Tanzania - public static let tanzania = L10n.tr("Localizable", "browsing_country.tanzania", fallback: "Tanzania") - /// Thailand - public static let thailand = L10n.tr("Localizable", "browsing_country.thailand", fallback: "Thailand") - /// The Democratic Republic of the Congo - public static let theDemocraticRepublicOfTheCongo = L10n.tr("Localizable", "browsing_country.the_democratic_republic_of_the_congo", fallback: "The Democratic Republic of the Congo") - /// Timor-Leste - public static let timorLeste = L10n.tr("Localizable", "browsing_country.timor_leste", fallback: "Timor-Leste") - /// Togo - public static let togo = L10n.tr("Localizable", "browsing_country.togo", fallback: "Togo") - /// Tokelau - public static let tokelau = L10n.tr("Localizable", "browsing_country.tokelau", fallback: "Tokelau") - /// Tonga - public static let tonga = L10n.tr("Localizable", "browsing_country.tonga", fallback: "Tonga") - /// Trinidad and Tobago - public static let trinidadAndTobago = L10n.tr("Localizable", "browsing_country.trinidad_and_tobago", fallback: "Trinidad and Tobago") - /// Tunisia - public static let tunisia = L10n.tr("Localizable", "browsing_country.tunisia", fallback: "Tunisia") - /// Turkey - public static let turkey = L10n.tr("Localizable", "browsing_country.turkey", fallback: "Turkey") - /// Turkmenistan - public static let turkmenistan = L10n.tr("Localizable", "browsing_country.turkmenistan", fallback: "Turkmenistan") - /// Turks and Caicos Islands - public static let turksAndCaicosIslands = L10n.tr("Localizable", "browsing_country.turks_and_caicos_islands", fallback: "Turks and Caicos Islands") - /// Tuvalu - public static let tuvalu = L10n.tr("Localizable", "browsing_country.tuvalu", fallback: "Tuvalu") - /// Uganda - public static let uganda = L10n.tr("Localizable", "browsing_country.uganda", fallback: "Uganda") - /// Ukraine - public static let ukraine = L10n.tr("Localizable", "browsing_country.ukraine", fallback: "Ukraine") - /// United Arab Emirates - public static let unitedArabEmirates = L10n.tr("Localizable", "browsing_country.united_arab_emirates", fallback: "United Arab Emirates") - /// United Kingdom - public static let unitedKingdom = L10n.tr("Localizable", "browsing_country.united_kingdom", fallback: "United Kingdom") - /// United States - public static let unitedStates = L10n.tr("Localizable", "browsing_country.united_states", fallback: "United States") - /// United States Minor Outlying Islands - public static let unitedStatesMinorOutlyingIslands = L10n.tr("Localizable", "browsing_country.united_states_minor_outlying_islands", fallback: "United States Minor Outlying Islands") - /// Uruguay - public static let uruguay = L10n.tr("Localizable", "browsing_country.uruguay", fallback: "Uruguay") - /// Uzbekistan - public static let uzbekistan = L10n.tr("Localizable", "browsing_country.uzbekistan", fallback: "Uzbekistan") - /// Vanuatu - public static let vanuatu = L10n.tr("Localizable", "browsing_country.vanuatu", fallback: "Vanuatu") - /// Vatican City State - public static let vaticanCityState = L10n.tr("Localizable", "browsing_country.vatican_city_state", fallback: "Vatican City State") - /// Venezuela - public static let venezuela = L10n.tr("Localizable", "browsing_country.venezuela", fallback: "Venezuela") - /// Vietnam - public static let vietnam = L10n.tr("Localizable", "browsing_country.vietnam", fallback: "Vietnam") - /// British Virgin Islands - public static let virginIslandsBritish = L10n.tr("Localizable", "browsing_country.virgin_islands_british", fallback: "British Virgin Islands") - /// U.S. Virgin Islands - public static let virginIslandsUS = L10n.tr("Localizable", "browsing_country.virgin_islands_US", fallback: "U.S. Virgin Islands") - /// Wallis and Futuna - public static let wallisAndFutuna = L10n.tr("Localizable", "browsing_country.wallis_and_futuna", fallback: "Wallis and Futuna") - /// Western Sahara - public static let westernSahara = L10n.tr("Localizable", "browsing_country.western_sahara", fallback: "Western Sahara") - /// Yemen - public static let yemen = L10n.tr("Localizable", "browsing_country.yemen", fallback: "Yemen") - /// Zambia - public static let zambia = L10n.tr("Localizable", "browsing_country.zambia", fallback: "Zambia") - /// Zimbabwe - public static let zimbabwe = L10n.tr("Localizable", "browsing_country.zimbabwe", fallback: "Zimbabwe") - } - public enum Category { - /// Artist CG - public static let artistCG = L10n.tr("Localizable", "category.artist_CG", fallback: "Artist CG") - /// Asian Porn - public static let asianPorn = L10n.tr("Localizable", "category.asian_porn", fallback: "Asian Porn") - /// Cosplay - public static let cosplay = L10n.tr("Localizable", "category.cosplay", fallback: "Cosplay") - /// Doujinshi - public static let doujinshi = L10n.tr("Localizable", "category.doujinshi", fallback: "Doujinshi") - /// Game CG - public static let gameCG = L10n.tr("Localizable", "category.game_CG", fallback: "Game CG") - /// Image Set - public static let imageSet = L10n.tr("Localizable", "category.image_set", fallback: "Image Set") - /// Manga - public static let manga = L10n.tr("Localizable", "category.manga", fallback: "Manga") - /// Misc - public static let misc = L10n.tr("Localizable", "category.misc", fallback: "Misc") - /// Non-H - public static let nonH = L10n.tr("Localizable", "category.non_h", fallback: "Non-H") - /// Private - public static let `private` = L10n.tr("Localizable", "category.private", fallback: "Private") - /// Western - public static let western = L10n.tr("Localizable", "category.western", fallback: "Western") - } - public enum CommentsSortOrder { - /// By highest score - public static let highestScore = L10n.tr("Localizable", "comments_sort_order.highest_score", fallback: "By highest score") - /// Oldest comments first - public static let oldest = L10n.tr("Localizable", "comments_sort_order.oldest", fallback: "Oldest comments first") - /// Recent comments first - public static let recent = L10n.tr("Localizable", "comments_sort_order.recent", fallback: "Recent comments first") - } - public enum CommentsVotesShowTiming { - /// Always - public static let always = L10n.tr("Localizable", "comments_votes_show_timing.always", fallback: "Always") - /// On score hover or click - public static let onHoverOrClick = L10n.tr("Localizable", "comments_votes_show_timing.on_hover_or_click", fallback: "On score hover or click") - } public enum Common { /// Cancel public static let cancel = L10n.tr("Localizable", "common.cancel", fallback: "Cancel") @@ -717,22 +96,6 @@ public enum L10n { /// Update public static let update = L10n.tr("Localizable", "detail_view.update", fallback: "Update") } - public enum DisplayMode { - /// Compact - public static let compact = L10n.tr("Localizable", "display_mode.compact", fallback: "Compact") - /// Extended - public static let extended = L10n.tr("Localizable", "display_mode.extended", fallback: "Extended") - /// Minimal - public static let minimal = L10n.tr("Localizable", "display_mode.minimal", fallback: "Minimal") - /// Minimal+ - public static let minimalPlus = L10n.tr("Localizable", "display_mode.minimalPlus", fallback: "Minimal+") - /// Thumbnail - public static let thumbnail = L10n.tr("Localizable", "display_mode.thumbnail", fallback: "Thumbnail") - } - public enum DownloadFolderFilter { - /// All - public static let all = L10n.tr("Localizable", "download_folder_filter.all", fallback: "All") - } public enum DownloadStore { /// The folder name is invalid. public static let invalidFolderName = L10n.tr("Localizable", "download_store.invalid_folder_name", fallback: "The folder name is invalid.") @@ -759,331 +122,38 @@ public enum L10n { /// Update public static let update = L10n.tr("Localizable", "downloads_view.update", fallback: "Update") } - public enum EhSetting { - public enum ArchiverBehavior { - /// Auto Select Original, Auto Start - public static let autoSelectOriginalAutoStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.auto_select_original_auto_start", fallback: "Auto Select Original, Auto Start") - /// Auto Select Original, Manual Start - public static let autoSelectOriginalManualStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.auto_select_original_manual_start", fallback: "Auto Select Original, Manual Start") - /// Auto Select Resample, Auto Start - public static let autoSelectResampleAutoStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.auto_select_resample_auto_start", fallback: "Auto Select Resample, Auto Start") - /// Auto Select Resample, Manual Start - public static let autoSelectResampleManualStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.auto_select_resample_manual_start", fallback: "Auto Select Resample, Manual Start") - /// Manual Select, Auto Start - public static let manualSelectAutoStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.manual_select_auto_start", fallback: "Manual Select, Auto Start") - /// Manual Select, Manual Start (Default) - public static let manualSelectManualStart = L10n.tr("Localizable", "eh_setting.archiver_behavior.manual_select_manual_start", fallback: "Manual Select, Manual Start (Default)") - } - } public enum ErrorView { - /// This gallery is unavailable due to a copyright claim by %@. Sorry about that. - public static func copyrightClaim(_ p1: Any) -> String { - return L10n.tr("Localizable", "error_view.copyright_claim", String(describing: p1), fallback: "This gallery is unavailable due to a copyright claim by %@. Sorry about that.") - } - /// The database is corrupted. - /// Please submit an issue on GitHub. - public static let databaseCorrupted = L10n.tr("Localizable", "error_view.database_corrupted", fallback: "The database is corrupted.\nPlease submit an issue on GitHub.") - /// This gallery has been removed or is unavailable. - public static let galleryUnavailable = L10n.tr("Localizable", "error_view.gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") - /// Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@. - public static func ipBanned(_ p1: Any) -> String { - return L10n.tr("Localizable", "error_view.ip_banned", String(describing: p1), fallback: "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@.") - } - /// A network error occurred. - public static let network = L10n.tr("Localizable", "error_view.network", fallback: "A network error occurred.") - /// There seems to be nothing here. - public static let notFound = L10n.tr("Localizable", "error_view.not_found", fallback: "There seems to be nothing here.") - /// A parsing error occurred. - public static let parsing = L10n.tr("Localizable", "error_view.parsing", fallback: "A parsing error occurred.") /// Retry public static let retry = L10n.tr("Localizable", "error_view.retry", fallback: "Retry") - /// Please try again later. - public static let tryLater = L10n.tr("Localizable", "error_view.try_later", fallback: "Please try again later.") - /// An unknown error occurred. - public static let unknown = L10n.tr("Localizable", "error_view.unknown", fallback: "An unknown error occurred.") - } - public enum ExcludedLanguagesCategory { - /// Original - public static let original = L10n.tr("Localizable", "excluded_languages_category.original", fallback: "Original") - /// Rewrite - public static let rewrite = L10n.tr("Localizable", "excluded_languages_category.rewrite", fallback: "Rewrite") - /// Translated - public static let translated = L10n.tr("Localizable", "excluded_languages_category.translated", fallback: "Translated") - } - public enum FavoriteCategory { - /// All - public static let all = L10n.tr("Localizable", "favorite_category.all", fallback: "All") - /// Favorites %@ - public static func `default`(_ p1: Any) -> String { - return L10n.tr("Localizable", "favorite_category.default", String(describing: p1), fallback: "Favorites %@") - } - } - public enum FavoritesSortOrder { - /// By favorited time - public static let favoritedTime = L10n.tr("Localizable", "favorites_sort_order.favorited_time", fallback: "By favorited time") - /// By last gallery update time - public static let lastUpdateTime = L10n.tr("Localizable", "favorites_sort_order.last_update_time", fallback: "By last gallery update time") } public enum FavoritesView { /// Favorites public static let favorites = L10n.tr("Localizable", "favorites_view.favorites", fallback: "Favorites") } - public enum FilterRange { - /// Global - public static let global = L10n.tr("Localizable", "filter_range.global", fallback: "Global") - /// Search - public static let search = L10n.tr("Localizable", "filter_range.search", fallback: "Search") - /// Watched - public static let watched = L10n.tr("Localizable", "filter_range.watched", fallback: "Watched") - } public enum FiltersView { /// Filters public static let filters = L10n.tr("Localizable", "filters_view.filters", fallback: "Filters") } - public enum GalleryName { - /// Default Title - public static let `default` = L10n.tr("Localizable", "gallery_name.default", fallback: "Default Title") - /// Japanese Title (if available) - public static let japanese = L10n.tr("Localizable", "gallery_name.japanese", fallback: "Japanese Title (if available)") - } - public enum GalleryPageNumbering { - /// None - public static let `none` = L10n.tr("Localizable", "gallery_page_numbering.none", fallback: "None") - /// Page Number + Name - public static let pageNumberAndName = L10n.tr("Localizable", "gallery_page_numbering.page_number_and_name", fallback: "Page Number + Name") - /// Page Number Only - public static let pageNumberOnly = L10n.tr("Localizable", "gallery_page_numbering.page_number_only", fallback: "Page Number Only") - } - public enum GalleryVisibility { - /// Expunged - public static let expunged = L10n.tr("Localizable", "gallery_visibility.expunged", fallback: "Expunged") - /// No (%@) - public static func no(_ p1: Any) -> String { - return L10n.tr("Localizable", "gallery_visibility.no", String(describing: p1), fallback: "No (%@)") - } - /// Yes - public static let yes = L10n.tr("Localizable", "gallery_visibility.yes", fallback: "Yes") - } public enum GeneralSettingView { /// Language public static let language = L10n.tr("Localizable", "general_setting_view.language", fallback: "Language") } - public enum Greeting { - /// and - public static let and = L10n.tr("Localizable", "greeting.and", fallback: " and ") - /// ! - public static let end = L10n.tr("Localizable", "greeting.end", fallback: "!") - /// , - public static let separator = L10n.tr("Localizable", "greeting.separator", fallback: ", ") - /// You gain - public static let start = L10n.tr("Localizable", "greeting.start", fallback: "You gain ") - } - public enum HathArchive { - /// Free - public static let free = L10n.tr("Localizable", "hath_archive.free", fallback: "Free") - } public enum HomeView { /// Home public static let home = L10n.tr("Localizable", "home_view.home", fallback: "Home") } - public enum ImageResolution { - /// Auto - public static let auto = L10n.tr("Localizable", "image_resolution.auto", fallback: "Auto") - } public enum JumpPageView { /// Jump page public static let jumpPage = L10n.tr("Localizable", "jump_page_view.jump_page", fallback: "Jump page") } - public enum Language { - /// Afrikaans - public static let afrikaans = L10n.tr("Localizable", "language.afrikaans", fallback: "Afrikaans") - /// Albanian - public static let albanian = L10n.tr("Localizable", "language.albanian", fallback: "Albanian") - /// Arabic - public static let arabic = L10n.tr("Localizable", "language.arabic", fallback: "Arabic") - /// Bengali - public static let bengali = L10n.tr("Localizable", "language.bengali", fallback: "Bengali") - /// Bosnian - public static let bosnian = L10n.tr("Localizable", "language.bosnian", fallback: "Bosnian") - /// Bulgarian - public static let bulgarian = L10n.tr("Localizable", "language.bulgarian", fallback: "Bulgarian") - /// Burmese - public static let burmese = L10n.tr("Localizable", "language.burmese", fallback: "Burmese") - /// Catalan - public static let catalan = L10n.tr("Localizable", "language.catalan", fallback: "Catalan") - /// Cebuano - public static let cebuano = L10n.tr("Localizable", "language.cebuano", fallback: "Cebuano") - /// Chinese - public static let chinese = L10n.tr("Localizable", "language.chinese", fallback: "Chinese") - /// Croatian - public static let croatian = L10n.tr("Localizable", "language.croatian", fallback: "Croatian") - /// Czech - public static let czech = L10n.tr("Localizable", "language.czech", fallback: "Czech") - /// Danish - public static let danish = L10n.tr("Localizable", "language.danish", fallback: "Danish") - /// Dutch - public static let dutch = L10n.tr("Localizable", "language.dutch", fallback: "Dutch") - /// English - public static let english = L10n.tr("Localizable", "language.english", fallback: "English") - /// Esperanto - public static let esperanto = L10n.tr("Localizable", "language.esperanto", fallback: "Esperanto") - /// Estonian - public static let estonian = L10n.tr("Localizable", "language.estonian", fallback: "Estonian") - /// Finnish - public static let finnish = L10n.tr("Localizable", "language.finnish", fallback: "Finnish") - /// French - public static let french = L10n.tr("Localizable", "language.french", fallback: "French") - /// Georgian - public static let georgian = L10n.tr("Localizable", "language.georgian", fallback: "Georgian") - /// German - public static let german = L10n.tr("Localizable", "language.german", fallback: "German") - /// Greek - public static let greek = L10n.tr("Localizable", "language.greek", fallback: "Greek") - /// Hebrew - public static let hebrew = L10n.tr("Localizable", "language.hebrew", fallback: "Hebrew") - /// Hindi - public static let hindi = L10n.tr("Localizable", "language.hindi", fallback: "Hindi") - /// Hmong - public static let hmong = L10n.tr("Localizable", "language.hmong", fallback: "Hmong") - /// Hungarian - public static let hungarian = L10n.tr("Localizable", "language.hungarian", fallback: "Hungarian") - /// Indonesian - public static let indonesian = L10n.tr("Localizable", "language.indonesian", fallback: "Indonesian") - /// N/A - public static let invalid = L10n.tr("Localizable", "language.invalid", fallback: "N/A") - /// Italian - public static let italian = L10n.tr("Localizable", "language.italian", fallback: "Italian") - /// Japanese - public static let japanese = L10n.tr("Localizable", "language.japanese", fallback: "Japanese") - /// Kazakh - public static let kazakh = L10n.tr("Localizable", "language.kazakh", fallback: "Kazakh") - /// Khmer - public static let khmer = L10n.tr("Localizable", "language.khmer", fallback: "Khmer") - /// Korean - public static let korean = L10n.tr("Localizable", "language.korean", fallback: "Korean") - /// Kurdish - public static let kurdish = L10n.tr("Localizable", "language.kurdish", fallback: "Kurdish") - /// Lao - public static let lao = L10n.tr("Localizable", "language.lao", fallback: "Lao") - /// Latin - public static let latin = L10n.tr("Localizable", "language.latin", fallback: "Latin") - /// Mongolian - public static let mongolian = L10n.tr("Localizable", "language.mongolian", fallback: "Mongolian") - /// Ndebele - public static let ndebele = L10n.tr("Localizable", "language.ndebele", fallback: "Ndebele") - /// Nepali - public static let nepali = L10n.tr("Localizable", "language.nepali", fallback: "Nepali") - /// Norwegian - public static let norwegian = L10n.tr("Localizable", "language.norwegian", fallback: "Norwegian") - /// Oromo - public static let oromo = L10n.tr("Localizable", "language.oromo", fallback: "Oromo") - /// Other - public static let other = L10n.tr("Localizable", "language.other", fallback: "Other") - /// Pashto - public static let pashto = L10n.tr("Localizable", "language.pashto", fallback: "Pashto") - /// Persian - public static let persian = L10n.tr("Localizable", "language.persian", fallback: "Persian") - /// Polish - public static let polish = L10n.tr("Localizable", "language.polish", fallback: "Polish") - /// Portuguese - public static let portuguese = L10n.tr("Localizable", "language.portuguese", fallback: "Portuguese") - /// Punjabi - public static let punjabi = L10n.tr("Localizable", "language.punjabi", fallback: "Punjabi") - /// Romanian - public static let romanian = L10n.tr("Localizable", "language.romanian", fallback: "Romanian") - /// Russian - public static let russian = L10n.tr("Localizable", "language.russian", fallback: "Russian") - /// Sango - public static let sango = L10n.tr("Localizable", "language.sango", fallback: "Sango") - /// Serbian - public static let serbian = L10n.tr("Localizable", "language.serbian", fallback: "Serbian") - /// Shona - public static let shona = L10n.tr("Localizable", "language.shona", fallback: "Shona") - /// Slovak - public static let slovak = L10n.tr("Localizable", "language.slovak", fallback: "Slovak") - /// Slovenian - public static let slovenian = L10n.tr("Localizable", "language.slovenian", fallback: "Slovenian") - /// Somali - public static let somali = L10n.tr("Localizable", "language.somali", fallback: "Somali") - /// Spanish - public static let spanish = L10n.tr("Localizable", "language.spanish", fallback: "Spanish") - /// Swahili - public static let swahili = L10n.tr("Localizable", "language.swahili", fallback: "Swahili") - /// Swedish - public static let swedish = L10n.tr("Localizable", "language.swedish", fallback: "Swedish") - /// Tagalog - public static let tagalog = L10n.tr("Localizable", "language.tagalog", fallback: "Tagalog") - /// Thai - public static let thai = L10n.tr("Localizable", "language.thai", fallback: "Thai") - /// Tigrinya - public static let tigrinya = L10n.tr("Localizable", "language.tigrinya", fallback: "Tigrinya") - /// Turkish - public static let turkish = L10n.tr("Localizable", "language.turkish", fallback: "Turkish") - /// Ukrainian - public static let ukrainian = L10n.tr("Localizable", "language.ukrainian", fallback: "Ukrainian") - /// Urdu - public static let urdu = L10n.tr("Localizable", "language.urdu", fallback: "Urdu") - /// Vietnamese - public static let vietnamese = L10n.tr("Localizable", "language.vietnamese", fallback: "Vietnamese") - /// Zulu - public static let zulu = L10n.tr("Localizable", "language.zulu", fallback: "Zulu") - } - public enum ListDisplayMode { - /// Detail - public static let detail = L10n.tr("Localizable", "list_display_mode.detail", fallback: "Detail") - /// Thumbnail - public static let thumbnail = L10n.tr("Localizable", "list_display_mode.thumbnail", fallback: "Thumbnail") - } - public enum LoadThroughHathSetting { - /// Any client - public static let anyClient = L10n.tr("Localizable", "load_through_hath_setting.any_client", fallback: "Any client") - /// Recommended. - public static let anyClientDescription = L10n.tr("Localizable", "load_through_hath_setting.any_client_description", fallback: "Recommended.") - /// Default port clients only - public static let defaultPortOnly = L10n.tr("Localizable", "load_through_hath_setting.default_port_only", fallback: "Default port clients only") - /// Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports. - public static let defaultPortOnlyDescription = L10n.tr("Localizable", "load_through_hath_setting.default_port_only_description", fallback: "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports.") - /// No [Legacy/HTTP] - public static let legacyNo = L10n.tr("Localizable", "load_through_hath_setting.legacy_no", fallback: "No [Legacy/HTTP]") - /// Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only. - public static let legacyNoDescription = L10n.tr("Localizable", "load_through_hath_setting.legacy_no_description", fallback: "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only.") - /// No [Modern/HTTPS] - public static let modernNo = L10n.tr("Localizable", "load_through_hath_setting.modern_no", fallback: "No [Modern/HTTPS]") - /// Donator only. You will not be able to browse as many pages. Recommended only if having severe problems. - public static let modernNoDescription = L10n.tr("Localizable", "load_through_hath_setting.modern_no_description", fallback: "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems.") - } public enum LoginView { /// Login public static let login = L10n.tr("Localizable", "login_view.login", fallback: "Login") } - public enum MultiplePageViewerStyle { - /// Align center, always scale - public static let alignCenterAlwaysScale = L10n.tr("Localizable", "multiple_page_viewer_style.align_center_always_scale", fallback: "Align center, always scale") - /// Align center, scale if overwidth - public static let alignCenterScaleIfOverWidth = L10n.tr("Localizable", "multiple_page_viewer_style.align_center_scale_if_over_width", fallback: "Align center, scale if overwidth") - /// Align left, scale if overwidth - public static let alignLeftScaleIfOverWidth = L10n.tr("Localizable", "multiple_page_viewer_style.align_left_scale_if_over_width", fallback: "Align left, scale if overwidth") - } - public enum PreferredColorScheme { - /// Automatic - public static let automatic = L10n.tr("Localizable", "preferred_color_scheme.automatic", fallback: "Automatic") - /// Dark - public static let dark = L10n.tr("Localizable", "preferred_color_scheme.dark", fallback: "Dark") - /// Light - public static let light = L10n.tr("Localizable", "preferred_color_scheme.light", fallback: "Light") - } public enum QuickSearchView { /// Quick search public static let quickSearch = L10n.tr("Localizable", "quick_search_view.quick_search", fallback: "Quick search") } - public enum ReadingDirection { - /// Left-to-right - public static let leftToRight = L10n.tr("Localizable", "reading_direction.left_to_right", fallback: "Left-to-right") - /// Right-to-left - public static let rightToLeft = L10n.tr("Localizable", "reading_direction.right_to_left", fallback: "Right-to-left") - /// Vertical - public static let vertical = L10n.tr("Localizable", "reading_direction.vertical", fallback: "Vertical") - } public enum ReadingView { /// Share public static let share = L10n.tr("Localizable", "reading_view.share", fallback: "Share") @@ -1110,58 +180,6 @@ public enum L10n { /// Setting public static let setting = L10n.tr("Localizable", "tab_item.setting", fallback: "Setting") } - public enum TagNamespace { - /// Artist - public static let artist = L10n.tr("Localizable", "tag_namespace.artist", fallback: "Artist") - /// Character - public static let character = L10n.tr("Localizable", "tag_namespace.character", fallback: "Character") - /// Cosplayer - public static let cosplayer = L10n.tr("Localizable", "tag_namespace.cosplayer", fallback: "Cosplayer") - /// Female - public static let female = L10n.tr("Localizable", "tag_namespace.female", fallback: "Female") - /// Group - public static let group = L10n.tr("Localizable", "tag_namespace.group", fallback: "Group") - /// Language - public static let language = L10n.tr("Localizable", "tag_namespace.language", fallback: "Language") - /// Male - public static let male = L10n.tr("Localizable", "tag_namespace.male", fallback: "Male") - /// Mixed - public static let mixed = L10n.tr("Localizable", "tag_namespace.mixed", fallback: "Mixed") - /// Other - public static let other = L10n.tr("Localizable", "tag_namespace.other", fallback: "Other") - /// Parody - public static let parody = L10n.tr("Localizable", "tag_namespace.parody", fallback: "Parody") - /// Reclass - public static let reclass = L10n.tr("Localizable", "tag_namespace.reclass", fallback: "Reclass") - /// Temp - public static let temp = L10n.tr("Localizable", "tag_namespace.temp", fallback: "Temp") - } - public enum TagsSortOrder { - /// Alphabetical - public static let alphabetical = L10n.tr("Localizable", "tags_sort_order.alphabetical", fallback: "Alphabetical") - /// By tag power - public static let tagPower = L10n.tr("Localizable", "tags_sort_order.tag_power", fallback: "By tag power") - } - public enum ThumbnailLoadTiming { - /// On mouse-over - public static let onMouseOver = L10n.tr("Localizable", "thumbnail_load_timing.on_mouse_over", fallback: "On mouse-over") - /// Pages load faster, but there may be a slight delay before a thumb appears. - public static let onMouseOverDescription = L10n.tr("Localizable", "thumbnail_load_timing.on_mouse_over_description", fallback: "Pages load faster, but there may be a slight delay before a thumb appears.") - /// On page load - public static let onPageLoad = L10n.tr("Localizable", "thumbnail_load_timing.on_page_load", fallback: "On page load") - /// Pages take longer to load, but there is no delay for loading a thumb after the page has loaded. - public static let onPageLoadDescription = L10n.tr("Localizable", "thumbnail_load_timing.on_page_load_description", fallback: "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded.") - } - public enum ThumbnailSize { - /// Auto - public static let auto = L10n.tr("Localizable", "thumbnail_size.auto", fallback: "Auto") - /// Large - public static let large = L10n.tr("Localizable", "thumbnail_size.large", fallback: "Large") - /// Normal - public static let normal = L10n.tr("Localizable", "thumbnail_size.normal", fallback: "Normal") - /// Small - public static let small = L10n.tr("Localizable", "thumbnail_size.small", fallback: "Small") - } public enum ToolbarItem { /// Seek to date public static let dateSeek = L10n.tr("Localizable", "toolbar_item.date_seek", fallback: "Seek to date") @@ -1172,16 +190,6 @@ public enum L10n { /// Quick search public static let quickSearch = L10n.tr("Localizable", "toolbar_item.quick_search", fallback: "Quick search") } - public enum ToplistsType { - /// All time - public static let allTime = L10n.tr("Localizable", "toplists_type.all_time", fallback: "All time") - /// Past month - public static let pastMonth = L10n.tr("Localizable", "toplists_type.past_month", fallback: "Past month") - /// Past year - public static let pastYear = L10n.tr("Localizable", "toplists_type.past_year", fallback: "Past year") - /// Yesterday - public static let yesterday = L10n.tr("Localizable", "toplists_type.yesterday", fallback: "Yesterday") - } } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length From 05c404ac1ecc184c3753e6cb659399e546c60956 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 02:44:41 +0800 Subject: [PATCH 469/614] Move shared strings to Resources, drop SwiftGen Emit the 33 shared Localizable + 1 Constant keys into Resources/Resources/*.xcstrings and hand-publish them via ResourceStringSymbols.swift (public LocalizedStringResource.RLocalizable/.RConstant, generated from Xcode's authoritative symbols, multi-line-wrapped for the 120-col limit, no swiftlint:disable). Rewrite 89 shared accessors across 17 modules to .RLocalizable/.RConstant. Merge the common day/hour/minute/second singular-plural pairs into %lld plural-variant funcs and collapse the manual > 1 branching in AppError/Setting/ReadingView. Delete Strings.swift, all 6 lproj dirs, swiftgen.yml, and the stale pbxproj file ref. Fix two Korean %d -> %lld the modernizer's word-boundary regex missed. Build + 308 tests + full SwiftLint all green; 0 duplicate keys across 21 catalogs, 0 L10n refs, 0 literal keys. --- .../Sources/AppComponents/AlertView.swift | 4 +- .../Resources/Localizable.xcstrings | 2 +- .../Sources/AppComponents/ToolbarItems.swift | 8 +- .../AppFeature/View/TabBar/TabBarView.swift | 10 +- .../AppModels/Persistent/Setting.swift | 8 +- .../Sources/AppModels/Support/AppError.swift | 14 +- .../DateSeekFeature/DateSeekPickerView.swift | 2 +- .../DetailReducer+Download.swift | 12 +- .../DetailView+HeaderSection.swift | 2 +- .../DetailFeature/DetailView+Navigation.swift | 2 +- .../DetailFeature/DetailView+Subviews.swift | 4 +- .../Sources/DetailFeature/DetailView.swift | 2 +- .../FolderManager/FolderManagerReducer.swift | 6 +- .../DownloadClient+Folders.swift | 6 +- .../DownloadClient+PublicAPI.swift | 2 +- .../DownloadStore+Operations.swift | 12 +- .../DownloadClient/DownloadStore.swift | 2 +- .../DownloadsFeature/DownloadsReducer.swift | 10 +- .../DownloadsFeature/DownloadsView.swift | 14 +- .../FavoritesFeature/FavoritesView.swift | 2 +- .../FiltersFeature/FiltersReducer.swift | 2 +- .../Sources/FiltersFeature/FiltersView.swift | 4 +- .../HomeFeature/History/HistoryReducer.swift | 6 +- AppPackage/Sources/HomeFeature/HomeView.swift | 2 +- .../Toplists/ToplistsReducer.swift | 6 +- .../MigrationFeature/MigrationReducer.swift | 2 +- .../ParserFeature/Parser+ResponseError.swift | 2 +- .../QuickSearchReducer.swift | 6 +- .../QuickSearchFeature/QuickSearchView.swift | 2 +- .../ReadingViewComponents.swift | 4 +- .../ReadingSettingView.swift | 2 +- .../Resources/ResourceStringSymbols.swift | 300 ++++ .../Resources/Resources/Constant.xcstrings | 18 + .../Resources/Resources/Localizable.xcstrings | 1551 +++++++++++++++++ .../Resources/de.lproj/Localizable.strings | 214 --- .../Resources/en.lproj/Constant.strings | 21 - .../Resources/en.lproj/Localizable.strings | 214 --- .../Resources/ja.lproj/Localizable.strings | 214 --- .../Resources/ko.lproj/Localizable.strings | 214 --- .../zh-Hans.lproj/Localizable.strings | 214 --- .../zh-Hant.lproj/Localizable.strings | 214 --- AppPackage/Sources/Resources/Strings.swift | 217 --- .../SearchFeature/SearchRootView.swift | 4 +- .../AccountSettingReducer.swift | 2 +- .../AccountSetting/AccountSettingView.swift | 2 +- .../EhSetting/EhSettingReducer.swift | 6 +- .../GeneralSettingReducer.swift | 8 +- .../GeneralSetting/GeneralSettingView.swift | 2 +- .../SettingFeature/Login/LoginView.swift | 2 +- .../Sources/SettingFeature/SettingView.swift | 2 +- .../DownloadCoordinatorStorageTests.swift | 2 +- .../DownloadStoreHashTests.swift | 2 +- .../DownloadStoreTests.swift | 8 +- EhPanda.xcodeproj/project.pbxproj | 2 - swiftgen.yml | 9 - 55 files changed, 1967 insertions(+), 1637 deletions(-) create mode 100644 AppPackage/Sources/Resources/ResourceStringSymbols.swift create mode 100644 AppPackage/Sources/Resources/Resources/Constant.xcstrings create mode 100644 AppPackage/Sources/Resources/Resources/Localizable.xcstrings delete mode 100644 AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings delete mode 100644 AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings delete mode 100644 AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings delete mode 100644 AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings delete mode 100644 AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings delete mode 100644 AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings delete mode 100644 AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings delete mode 100644 AppPackage/Sources/Resources/Strings.swift delete mode 100644 swiftgen.yml diff --git a/AppPackage/Sources/AppComponents/AlertView.swift b/AppPackage/Sources/AppComponents/AlertView.swift index 4aea3adb5..b9ffa1697 100644 --- a/AppPackage/Sources/AppComponents/AlertView.swift +++ b/AppPackage/Sources/AppComponents/AlertView.swift @@ -56,7 +56,7 @@ public struct NotLoginView: View { symbol: .personCropCircleBadgeQuestionmarkFill, message: String(localized: .needLogin) ) { - AlertViewButton(title: L10n.Localizable.notLoginViewlogin, action: action) + AlertViewButton(title: String(localized: .RLocalizable.login), action: action) } } } @@ -68,7 +68,7 @@ public struct ErrorView: View { public init( error: AppError, - buttonTitle: String = L10n.Localizable.ErrorView.retry, + buttonTitle: String = String(localized: .RLocalizable.retry), action: (() -> Void)? = nil ) { self.error = error diff --git a/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings b/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings index 52af6cfb8..ad240dc6b 100644 --- a/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings @@ -66,7 +66,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "검색 결과 %d개" + "value": "검색 결과 %lld개" } }, "zh-Hans": { diff --git a/AppPackage/Sources/AppComponents/ToolbarItems.swift b/AppPackage/Sources/AppComponents/ToolbarItems.swift index 3a461db35..9e13a4b09 100644 --- a/AppPackage/Sources/AppComponents/ToolbarItems.swift +++ b/AppPackage/Sources/AppComponents/ToolbarItems.swift @@ -62,7 +62,7 @@ public struct FiltersButton: View { Button(action: action) { Image(systemSymbol: .line3HorizontalDecrease) if !hideText { - Text(L10n.Localizable.ToolbarItem.filters) + Text(String(localized: .RLocalizable.filters)) } } } @@ -81,7 +81,7 @@ public struct QuickSearchButton: View { Button(action: action) { Image(systemSymbol: .magnifyingglass) if !hideText { - Text(L10n.Localizable.ToolbarItem.quickSearch) + Text(String(localized: .RLocalizable.quickSearch)) } } } @@ -102,7 +102,7 @@ public struct JumpPageButton: View { Button(action: action) { Image(systemSymbol: .arrowshapeBounceForward) if !hideText { - Text(L10n.Localizable.ToolbarItem.jumpPage) + Text(String(localized: .RLocalizable.jumpPage)) } } .disabled(pageNumber.isSinglePage) @@ -122,7 +122,7 @@ public struct DateSeekButton: View { Button { navigation.map(action) } label: { - Label(L10n.Localizable.ToolbarItem.dateSeek, systemSymbol: .calendar) + Label(String(localized: .RLocalizable.dateSeek), systemSymbol: .calendar) } .disabled(navigation == nil) } diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 33584e413..4da0347ee 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -137,15 +137,15 @@ extension TabBarItemType { var title: String { switch self { case .home: - return L10n.Localizable.TabItem.home + return String(localized: .RLocalizable.home) case .favorites: - return L10n.Localizable.TabItem.favorites + return String(localized: .RLocalizable.favorites) case .search: - return L10n.Localizable.TabItem.search + return String(localized: .RLocalizable.search) case .downloads: - return L10n.Localizable.TabItem.downloads + return String(localized: .RLocalizable.downloads) case .setting: - return L10n.Localizable.TabItem.setting + return String(localized: .RLocalizable.setting) } } var symbol: SFSymbol { diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index db68f53f6..a97174ef2 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -173,11 +173,9 @@ extension AutoLockPolicy { case .instantly: return String(localized: .autoLockPolicyInstantly) case .sec15: - return L10n.Localizable.Common.seconds("\(rawValue)") - case .min1: - return L10n.Localizable.Common.minute("\(rawValue / 60)") - case .min5, .min10, .min30: - return L10n.Localizable.Common.minutes("\(rawValue / 60)") + return String(localized: .RLocalizable.seconds(rawValue)) + case .min1, .min5, .min10, .min30: + return String(localized: .RLocalizable.minutes(rawValue / 60)) } } } diff --git a/AppPackage/Sources/AppModels/Support/AppError.swift b/AppPackage/Sources/AppModels/Support/AppError.swift index 8aa1fe1ef..39fb226c5 100644 --- a/AppPackage/Sources/AppModels/Support/AppError.swift +++ b/AppPackage/Sources/AppModels/Support/AppError.swift @@ -79,7 +79,7 @@ extension AppError { return String(localized: .ipBanned(interval.description)) case .expunged(let reason): switch reason { - case L10n.Constant.galleryUnavailable: + case String(localized: .RConstant.responseGalleryUnavailable): return String(localized: .galleryUnavailable) default: return reason @@ -141,19 +141,15 @@ extension BanInterval { } private func daysWithUnit(_ days: Int) -> String { - days > 1 ? L10n.Localizable.Common.days("\(days)") - : L10n.Localizable.Common.day("\(days)") + String(localized: .RLocalizable.days(days)) } private func hoursWithUnit(_ hours: Int) -> String { - hours > 1 ? L10n.Localizable.Common.hours("\(hours)") - : L10n.Localizable.Common.hour("\(hours)") + String(localized: .RLocalizable.hours(hours)) } private func minutesWithUnit(_ minutes: Int) -> String { - minutes > 1 ? L10n.Localizable.Common.minutes("\(minutes)") - : L10n.Localizable.Common.minute("\(minutes)") + String(localized: .RLocalizable.minutes(minutes)) } private func secondsWithUnit(_ seconds: Int) -> String { - seconds > 1 ? L10n.Localizable.Common.seconds("\(seconds)") - : L10n.Localizable.Common.second("\(seconds)") + String(localized: .RLocalizable.seconds(seconds)) } } diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift index c884a1648..77b41d195 100644 --- a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift @@ -80,7 +80,7 @@ public struct DateSeekPickerView: View { .listRowInsets(.init()) } } - .navigationTitle(L10n.Localizable.DateSeekView.dateSeek) + .navigationTitle(String(localized: .RLocalizable.dateSeek)) .navigationBarTitleDisplayMode(.large) } } diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift index 77bc7661f..4ddf4db30 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift @@ -13,16 +13,16 @@ extension DetailReducer { switch action { case .deleteDownloadButtonTapped: state.alert = AppAlertState { - TextState(L10n.Localizable.DetailView.deleteDownload) + TextState(String(localized: .RLocalizable.deleteDownload)) } actions: { ButtonState(role: .destructive, action: .confirmDeleteDownload) { - TextState(L10n.Localizable.ConfirmationDialog.delete) + TextState(String(localized: .RLocalizable.delete)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } message: { - TextState(L10n.Localizable.DetailView.deleteDownloadedGallery) + TextState(String(localized: .RLocalizable.deleteDownloadedGallery)) } return .none @@ -34,7 +34,7 @@ extension DetailReducer { TextState(Self.retryDownloadConfirmTitle(for: mode)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } message: { TextState(Self.retryDownloadMessage(for: mode)) @@ -329,7 +329,7 @@ extension DetailReducer { case .repair: return String(localized: .repair) case .update: - return L10n.Localizable.DetailView.update + return String(localized: .RLocalizable.update) case .initial, .redownload: return String(localized: .redownload) } diff --git a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift index b85f6275b..59e9f4b4d 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift @@ -88,7 +88,7 @@ struct HeaderSection: View { Section { Button(action: manageFoldersAction) { Label( - L10n.Localizable.DetailView.manageFolders, + String(localized: .RLocalizable.manageFolders), systemSymbol: .folderBadgeGearshape ) } diff --git a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift index 72be31091..2fd2e8994 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift @@ -29,7 +29,7 @@ extension DetailView { store.send(.shareButtonTapped(galleryURL)) } } label: { - Label(L10n.Localizable.DetailView.share, systemSymbol: .squareAndArrowUp) + Label(String(localized: .RLocalizable.share), systemSymbol: .squareAndArrowUp) } } .disabled(store.galleryDetail == nil || store.loadingState == .loading) diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index 9fcb5e974..9e576c7b1 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -19,7 +19,7 @@ struct DescriptionSection: View { value: .init(galleryDetail.favoritedCount) ), DescScrollInfo( - title: L10n.Localizable.DetailView.language, + title: String(localized: .RLocalizable.language), description: galleryDetail.language.value, value: galleryDetail.language.abbreviation ), @@ -242,7 +242,7 @@ extension TagsSection { )) } label: { Image(systemSymbol: .richtextPage) - Text(L10n.Localizable.DetailView.detail) + Text(String(localized: .RLocalizable.detail)) } } if CookieUtil.didLogin { diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 65e64825e..02de97a3e 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -298,7 +298,7 @@ private extension DetailView { .font(.subheadline.weight(.semibold)) .foregroundStyle(.orange) if error.isRetryable != false { - Button(L10n.Localizable.ErrorView.retry) { + Button(String(localized: .RLocalizable.retry)) { store.send(.fetchGalleryDetail) } .buttonStyle(.glass) diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift index 0f9b1fb25..5807c43ae 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift @@ -21,7 +21,7 @@ public struct FolderManagerReducer: Sendable { private static var invalidFolderNameError: AppError { .fileOperationFailed( - L10n.Localizable.DownloadStore.invalidFolderName + String(localized: .RLocalizable.downloadStoreInvalidFolderName) ) } @@ -87,10 +87,10 @@ public struct FolderManagerReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmDelete(folder)) { - TextState(L10n.Localizable.ConfirmationDialog.delete) + TextState(String(localized: .RLocalizable.delete)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } message: { TextState(String(localized: .deleteFolder)) diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift index e94a90e17..31bf9255e 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift @@ -15,7 +15,7 @@ extension DownloadCoordinator { guard let normalizedName = storage.normalizedUserFolderName(name) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.invalidFolderName + String(localized: .RLocalizable.downloadStoreInvalidFolderName) ) ) } @@ -45,7 +45,7 @@ extension DownloadCoordinator { guard let normalizedName = storage.normalizedUserFolderName(newName) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.invalidFolderName + String(localized: .RLocalizable.downloadStoreInvalidFolderName) ) ) } @@ -144,7 +144,7 @@ extension DownloadCoordinator { guard let normalizedName = storage.normalizedUserFolderName(folderName) else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.invalidFolderName + String(localized: .RLocalizable.downloadStoreInvalidFolderName) ) ) } diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift index 5539278b7..4e51e5a04 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift @@ -74,7 +74,7 @@ extension DownloadCoordinator { } else { return .failure( .fileOperationFailed( - L10n.Localizable.DownloadStore.invalidFolderName + String(localized: .RLocalizable.downloadStoreInvalidFolderName) ) ) } diff --git a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift index 7417059ba..1f7ce1006 100644 --- a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift @@ -91,13 +91,13 @@ extension DownloadStore { } guard let relativePath = existingPages[index] else { throw AppError.fileOperationFailed( - L10n.Localizable.DownloadStore.pageMissing(index) + String(localized: .RLocalizable.downloadStorePageMissing(index)) ) } pages[index] = try hashReadableAsset( folderURL: folderURL, relativePath: relativePath, - missingMessage: L10n.Localizable.DownloadStore.pageMissing(index) + missingMessage: String(localized: .RLocalizable.downloadStorePageMissing(index)) ) } @@ -152,7 +152,7 @@ extension DownloadStore { pages[index] = try hashReadableAsset( folderURL: folderURL, relativePath: refreshedRelativePath, - missingMessage: L10n.Localizable.DownloadStore.pageMissing(index) + missingMessage: String(localized: .RLocalizable.downloadStorePageMissing(index)) ) didUpdate = true } @@ -208,7 +208,7 @@ extension DownloadStore { return .missingFiles(String(localized: .downloadStoreManifestMissing)) } guard let manifest = try? readManifest(folderURL: folderURL) else { - return .missingFiles(L10n.Localizable.DownloadStore.manifestCorrupted) + return .missingFiles(String(localized: .RLocalizable.downloadStoreManifestCorrupted)) } if let pageValidationFailure = validatePages( folderURL: folderURL, @@ -290,12 +290,12 @@ extension DownloadStore { let pageURL = validatedChildURL(root: folderURL, relativePath: relativePath), sanitizeAssetFileIfNeeded(at: pageURL) else { - return .missingFiles(L10n.Localizable.DownloadStore.pageMissing(index)) + return .missingFiles(String(localized: .RLocalizable.downloadStorePageMissing(index))) } if verifiesContentHash, (try? fileHash(at: pageURL)) != expectedHash { return .missingFiles( - L10n.Localizable.DownloadStore.pageImageCorrupted(index) + String(localized: .RLocalizable.downloadStorePageImageCorrupted(index)) ) } diff --git a/AppPackage/Sources/DownloadClient/DownloadStore.swift b/AppPackage/Sources/DownloadClient/DownloadStore.swift index 9a2d98d3f..ca6062288 100644 --- a/AppPackage/Sources/DownloadClient/DownloadStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore.swift @@ -409,7 +409,7 @@ public struct DownloadStore: Sendable { } private func manifestCorruptedError() -> AppError { - .fileOperationFailed(L10n.Localizable.DownloadStore.manifestCorrupted) + .fileOperationFailed(String(localized: .RLocalizable.downloadStoreManifestCorrupted)) } public func scanDownloadFolders() throws -> [DownloadFolderRecord] { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 8da768d41..947a0d6b0 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -140,19 +140,19 @@ public struct DownloadsReducer: Sendable { case .deleteDownloadButtonTapped(let download): state.alert = AppAlertState { - TextState(L10n.Localizable.DownloadsView.deleteDownload) + TextState(String(localized: .RLocalizable.deleteDownload)) } actions: { ButtonState(role: .destructive, action: .confirmDelete(download.gid)) { - TextState(L10n.Localizable.ConfirmationDialog.delete) + TextState(String(localized: .RLocalizable.delete)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } message: { TextState( download.canTogglePause ? String(localized: .deleteActiveDownload) - : L10n.Localizable.DownloadsView.deleteDownloadedGallery + : String(localized: .RLocalizable.deleteDownloadedGallery) ) } return .none @@ -168,7 +168,7 @@ public struct DownloadsReducer: Sendable { } } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } return .none diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index 2e360c7c4..faa9ca73a 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -105,7 +105,7 @@ public struct DownloadsView: View { .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) - .navigationTitle(L10n.Localizable.DownloadsView.downloads) + .navigationTitle(String(localized: .RLocalizable.downloads)) .navigationBarTitleDisplayMode(.large) .toolbar(content: toolbar) } @@ -164,7 +164,7 @@ private extension DownloadsView { store.send(.updateDownload(download.gid)) } label: { Label( - L10n.Localizable.DownloadsView.update, + String(localized: .RLocalizable.update), systemSymbol: .arrowTrianglehead2ClockwiseRotate90 ) } @@ -190,7 +190,7 @@ private extension DownloadsView { Button(role: .destructive) { store.send(.deleteDownloadButtonTapped(download)) } label: { - Label(L10n.Localizable.ConfirmationDialog.delete, systemSymbol: .trash) + Label(String(localized: .RLocalizable.delete), systemSymbol: .trash) } } } @@ -204,7 +204,7 @@ private extension DownloadsView { store.send(.galleryTapped(download.gid)) } label: { Label( - L10n.Localizable.DetailView.detail, + String(localized: .RLocalizable.detail), systemSymbol: .infoCircle ) } @@ -238,7 +238,7 @@ private extension DownloadsView { store.send(.updateDownload(download.gid)) } label: { Label( - L10n.Localizable.DownloadsView.update, + String(localized: .RLocalizable.update), systemSymbol: .arrowTrianglehead2ClockwiseRotate90 ) } @@ -262,7 +262,7 @@ private extension DownloadsView { Button(role: .destructive) { store.send(.deleteDownloadButtonTapped(download)) } label: { - Label(L10n.Localizable.ConfirmationDialog.delete, systemSymbol: .trash) + Label(String(localized: .RLocalizable.delete), systemSymbol: .trash) } } @@ -303,7 +303,7 @@ private extension DownloadsView { store.send(.folderManagerButtonTapped) } label: { Label( - L10n.Localizable.DownloadsView.manageFolders, + String(localized: .RLocalizable.manageFolders), systemSymbol: .folderBadgeGearshape ) } diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index cc0d73352..63f6f6334 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -30,7 +30,7 @@ public struct FavoritesView: View { private var navigationTitle: String { let favoriteCategory = user.getFavoriteCategory(index: store.index) - return (store.index == -1 ? L10n.Localizable.FavoritesView.favorites : favoriteCategory) + return (store.index == -1 ? String(localized: .RLocalizable.favorites) : favoriteCategory) } public var body: some View { diff --git a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift index 58bdfb61c..fe7134618 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift @@ -71,7 +71,7 @@ public struct FiltersReducer: Sendable { TextState(String(localized: .reset)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } message: { TextState(String(localized: .resetDescription)) diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index 21124af31..ccc3c70d6 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -39,7 +39,7 @@ public struct FiltersView: View { ) } .synchronize($store.focusedBound, $focusedBound) - .navigationTitle(L10n.Localizable.FiltersView.filters) + .navigationTitle(String(localized: .RLocalizable.filters)) .onAppear { store.send(.fetchFilters) } } } @@ -153,7 +153,7 @@ private struct MinimumRatingSetter: View { var body: some View { Picker(String(localized: .minimumRating), selection: $minimum) { ForEach(Array(2...5), id: \.self) { number in - Text(L10n.Localizable.Common.stars("\(number)")).tag(number) + Text(String(localized: .RLocalizable.stars("\(number)"))).tag(number) } } .pickerStyle(.menu) diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index cc44f0531..0fb75dfdc 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -76,13 +76,13 @@ public struct HistoryReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmClearHistory) { - TextState(L10n.Localizable.ConfirmationDialog.clear) + TextState(String(localized: .RLocalizable.clear)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.clearDescription) + TextState(String(localized: .RLocalizable.clearDescription)) } return .none diff --git a/AppPackage/Sources/HomeFeature/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift index bfaafb213..fdf5dfa32 100644 --- a/AppPackage/Sources/HomeFeature/HomeView.swift +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -91,7 +91,7 @@ public struct HomeView: View { } } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.HomeView.home) + .navigationTitle(String(localized: .RLocalizable.home)) } destination: { store in switch store.case { case .frontpage(let store): diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index a899b6d04..ed7c33d54 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -113,10 +113,10 @@ public struct ToplistsReducer: Sendable { let maximumPage = (state.pageNumber?.maximum ?? 0) + 1 state.alert = AppAlertState( title: { - TextState(L10n.Localizable.JumpPageView.jumpPage) + TextState(String(localized: .RLocalizable.jumpPage)) }, textField: .init( - placeholder: TextState(L10n.Localizable.JumpPageView.jumpPage), + placeholder: TextState(String(localized: .RLocalizable.jumpPage)), keyboard: .numberPad ), actions: { @@ -124,7 +124,7 @@ public struct ToplistsReducer: Sendable { TextState(String(localized: .confirm)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } }, message: { diff --git a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift index 09c7f126f..820a9e79c 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift @@ -50,7 +50,7 @@ public struct MigrationReducer: Sendable { TextState(String(localized: .dropDatabase)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } message: { TextState(String(localized: .dropDatabaseDescription)) diff --git a/AppPackage/Sources/ParserFeature/Parser+ResponseError.swift b/AppPackage/Sources/ParserFeature/Parser+ResponseError.swift index 43afc73b1..e7b7d7fa4 100644 --- a/AppPackage/Sources/ParserFeature/Parser+ResponseError.swift +++ b/AppPackage/Sources/ParserFeature/Parser+ResponseError.swift @@ -43,7 +43,7 @@ extension Parser { // gallery-dl treats `404 + Gallery Not Available` as an authorization-like unavailable state: // https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py if normalizedContent.contains("gallery not available") - || normalizedContent.contains(L10n.Constant.galleryUnavailable.lowercased()) { + || normalizedContent.contains(String(localized: .RConstant.responseGalleryUnavailable).lowercased()) { return nil } // JDownloader treats `bounce_login.php` as an account / re-login required signal for EH/EX. diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift index 2901a4f8b..90b9a7279 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift @@ -92,13 +92,13 @@ public struct QuickSearchReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmDelete(word)) { - TextState(L10n.Localizable.ConfirmationDialog.delete) + TextState(String(localized: .RLocalizable.delete)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.deleteDescription) + TextState(String(localized: .RLocalizable.deleteDescription)) } return .none diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index 5554228cb..6da76d8e7 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -81,7 +81,7 @@ public struct QuickSearchView: View { } .toolbar(content: toolbar) .navigationDestination(item: $store.editKind) { editWordView(for: $0) } - .navigationTitle(L10n.Localizable.QuickSearchView.quickSearch) + .navigationTitle(String(localized: .RLocalizable.quickSearch)) } } diff --git a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift index d44a877cf..abc0e9429 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift @@ -36,7 +36,7 @@ extension AutoPlayPolicy { case .off: return String(localized: .autoPlayPolicyOff) default: - return L10n.Localizable.Common.seconds("\(rawValue)") + return String(localized: .RLocalizable.seconds(rawValue)) } } } @@ -170,7 +170,7 @@ struct HorizontalImageStack: View { Button { shareImageAction(imageURL) } label: { - Label(L10n.Localizable.ReadingView.share, systemSymbol: .squareAndArrowUp) + Label(String(localized: .RLocalizable.share), systemSymbol: .squareAndArrowUp) } } } diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index 760bced9c..abd2944ef 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -35,7 +35,7 @@ public struct ReadingSettingView: View { .pickerStyle(.menu) Picker(String(localized: .preloadLimit), selection: $prefetchLimit) { ForEach(Array(stride(from: 6, through: 18, by: 4)), id: \.self) { value in - Text(L10n.Localizable.Common.pages("\(value)")).tag(value) + Text(String(localized: .RLocalizable.pages("\(value)"))).tag(value) } } .pickerStyle(.menu) diff --git a/AppPackage/Sources/Resources/ResourceStringSymbols.swift b/AppPackage/Sources/Resources/ResourceStringSymbols.swift new file mode 100644 index 000000000..7cae9d14a --- /dev/null +++ b/AppPackage/Sources/Resources/ResourceStringSymbols.swift @@ -0,0 +1,300 @@ +import Foundation + +#if SWIFT_PACKAGE +private nonisolated let resourceStringSymbolsBundle = Foundation.Bundle.module +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +private nonisolated let resourceStringSymbolsBundleDescription = LocalizedStringResource.BundleDescription + .atURL(resourceStringSymbolsBundle.bundleURL) +#else +private final class ResourceStringSymbolsBundleClass {} +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +private nonisolated let resourceStringSymbolsBundleDescription = LocalizedStringResource.BundleDescription + .forClass(ResourceStringSymbolsBundleClass.self) +#endif + +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +public nonisolated extension LocalizedStringResource { + enum RLocalizable { + public static var cancel: LocalizedStringResource { + LocalizedStringResource( + "cancel", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var clear: LocalizedStringResource { + LocalizedStringResource( + "clear", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var clearDescription: LocalizedStringResource { + LocalizedStringResource( + "clear_description", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var dateSeek: LocalizedStringResource { + LocalizedStringResource( + "date_seek", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func days(_ arg1: Int) -> LocalizedStringResource { + LocalizedStringResource( + "days", + defaultValue: "\(arg1, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var delete: LocalizedStringResource { + LocalizedStringResource( + "delete", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var deleteDescription: LocalizedStringResource { + LocalizedStringResource( + "delete_description", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var deleteDownload: LocalizedStringResource { + LocalizedStringResource( + "delete_download", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var deleteDownloadedGallery: LocalizedStringResource { + LocalizedStringResource( + "delete_downloaded_gallery", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var detail: LocalizedStringResource { + LocalizedStringResource( + "detail", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var downloadStoreInvalidFolderName: LocalizedStringResource { + LocalizedStringResource( + "download_store.invalid_folder_name", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var downloadStoreManifestCorrupted: LocalizedStringResource { + LocalizedStringResource( + "download_store.manifest_corrupted", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func downloadStorePageImageCorrupted(_ arg1: Int) -> LocalizedStringResource { + LocalizedStringResource( + "download_store.page_image_corrupted", + defaultValue: "\(arg1, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func downloadStorePageMissing(_ arg1: Int) -> LocalizedStringResource { + LocalizedStringResource( + "download_store.page_missing", + defaultValue: "\(arg1, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var downloads: LocalizedStringResource { + LocalizedStringResource( + "downloads", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var favorites: LocalizedStringResource { + LocalizedStringResource( + "favorites", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var filters: LocalizedStringResource { + LocalizedStringResource( + "filters", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var home: LocalizedStringResource { + LocalizedStringResource( + "home", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func hours(_ arg1: Int) -> LocalizedStringResource { + LocalizedStringResource( + "hours", + defaultValue: "\(arg1, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var jumpPage: LocalizedStringResource { + LocalizedStringResource( + "jump_page", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var language: LocalizedStringResource { + LocalizedStringResource( + "language", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var login: LocalizedStringResource { + LocalizedStringResource( + "login", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var manageFolders: LocalizedStringResource { + LocalizedStringResource( + "manage_folders", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func minutes(_ arg1: Int) -> LocalizedStringResource { + LocalizedStringResource( + "minutes", + defaultValue: "\(arg1, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func pages(_ arg1: String) -> LocalizedStringResource { + LocalizedStringResource( + "pages", + defaultValue: "\(arg1)", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var quickSearch: LocalizedStringResource { + LocalizedStringResource( + "quick_search", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var retry: LocalizedStringResource { + LocalizedStringResource( + "retry", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var search: LocalizedStringResource { + LocalizedStringResource( + "search", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func seconds(_ arg1: Int) -> LocalizedStringResource { + LocalizedStringResource( + "seconds", + defaultValue: "\(arg1, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var setting: LocalizedStringResource { + LocalizedStringResource( + "setting", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var share: LocalizedStringResource { + LocalizedStringResource( + "share", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func stars(_ arg1: String) -> LocalizedStringResource { + LocalizedStringResource( + "stars", + defaultValue: "\(arg1)", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var update: LocalizedStringResource { + LocalizedStringResource( + "update", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + } + + enum RConstant { + public static var responseGalleryUnavailable: LocalizedStringResource { + LocalizedStringResource( + "response.gallery_unavailable", + table: "Constant", + bundle: resourceStringSymbolsBundleDescription + ) + } + } +} diff --git a/AppPackage/Sources/Resources/Resources/Constant.xcstrings b/AppPackage/Sources/Resources/Resources/Constant.xcstrings new file mode 100644 index 000000000..4d2a349eb --- /dev/null +++ b/AppPackage/Sources/Resources/Resources/Constant.xcstrings @@ -0,0 +1,18 @@ +{ + "sourceLanguage": "en", + "strings": { + "response.gallery_unavailable": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/Localizable.xcstrings b/AppPackage/Sources/Resources/Resources/Localizable.xcstrings new file mode 100644 index 000000000..2f6331b8c --- /dev/null +++ b/AppPackage/Sources/Resources/Resources/Localizable.xcstrings @@ -0,0 +1,1551 @@ +{ + "sourceLanguage": "en", + "strings": { + "cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "clear": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Löschen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清空" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清空" + } + } + } + }, + "clear_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Are you sure to clear?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bist du sicher das du das löschen möchtest?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本当に削除してもよろしいですか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제하시겠어요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定要清空吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確定要清空嗎?" + } + } + } + }, + "date_seek": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Seek to date" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datum aufsuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日付指定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "날짜로 이동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日期定位" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日期定位" + } + } + } + }, + "days": { + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld day" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld days" + } + } + } + } + }, + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Tag" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Tage" + } + } + } + } + }, + "ja": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 日" + } + } + } + } + }, + "ko": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld일" + } + } + } + } + }, + "zh-Hans": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 天" + } + } + } + } + }, + "zh-Hant": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 天" + } + } + } + } + } + } + }, + "delete": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Löschen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除" + } + } + } + }, + "delete_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Are you sure to delete this item?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Möchtest du dieses Element wirklich löschen?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本当にこれを削除してもよろしいですか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 항목을 삭제하시겠어요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定要删除吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確定要刪除?" + } + } + } + }, + "delete_download": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete Download?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download löschen?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを削除しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드를 삭제할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除下载?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除下載?" + } + } + } + }, + "delete_downloaded_gallery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will remove the downloaded gallery from this device." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die heruntergeladene Galerie wird von diesem Gerät entfernt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード済みのギャラリーをこのデバイスから削除します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드한 갤러리를 이 기기에서 삭제합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将从此设备移除已下载的画廊。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這將從此裝置移除已下載的畫廊。" + } + } + } + }, + "detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detail" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Details" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세부 정보" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "详情" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Detail" + } + } + } + }, + "download_store.invalid_folder_name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The folder name is invalid." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Ordnername ist ungültig." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダ名が無効です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 이름이 올바르지 않습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件夹名称无效。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資料夾名稱無效。" + } + } + } + }, + "download_store.manifest_corrupted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manifest file is corrupted." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Manifest-Datei ist beschädigt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マニフェストファイルが破損しています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "매니페스트 파일이 손상되었습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Manifest 文件已损坏。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Manifest 檔案已損壞。" + } + } + } + }, + "download_store.page_image_corrupted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page %lld image data is corrupted." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilddaten von Seite %lld sind beschädigt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ %lld の画像データが破損しています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 %lld 이미지 데이터가 손상되었습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "第 %lld 页图片数据已损坏。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "第 %lld 頁圖片資料已損壞。" + } + } + } + }, + "download_store.page_missing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page %lld is missing." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seite %lld fehlt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ %lld が見つかりません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 %lld가 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "第 %lld 页缺失。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "第 %lld 頁缺失。" + } + } + } + }, + "downloads": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloads" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloads" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載" + } + } + } + }, + "favorites": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorites" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favoriten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入り" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收藏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收藏" + } + } + } + }, + "filters": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Filters" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Filter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィルター" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필터" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "過濾" + } + } + } + }, + "home": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Home" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Start" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホーム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "홈" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "主页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "總覽" + } + } + } + }, + "hours": { + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld hour" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld hours" + } + } + } + } + }, + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Stunde" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Stunden" + } + } + } + } + }, + "ja": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 時間" + } + } + } + } + }, + "ko": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld시간" + } + } + } + } + }, + "zh-Hans": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 小时" + } + } + } + } + }, + "zh-Hant": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 小時" + } + } + } + } + } + } + }, + "jump_page": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jump page" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zu Seite springen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページジャンプ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 이동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页码跳转" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跳到..." + } + } + } + }, + "language": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sprache" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "言語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "언어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言" + } + } + } + }, + "login": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Login" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einloggen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ログイン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그인" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "登录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登入" + } + } + } + }, + "manage_folders": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage Folders" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ordner verwalten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを管理" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 관리" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理文件夹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "管理資料夾" + } + } + } + }, + "minutes": { + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld minute" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld minutes" + } + } + } + } + }, + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Minute" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Minuten" + } + } + } + } + }, + "ja": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 分" + } + } + } + } + }, + "ko": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld분" + } + } + } + } + }, + "zh-Hans": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 分" + } + } + } + } + }, + "zh-Hant": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 分" + } + } + } + } + } + } + }, + "pages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ pages" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Seiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ ページ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@페이지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 頁" + } + } + } + }, + "quick_search": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quick search" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schnellsuche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クイック検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "빠른 검색" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快速搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "快速搜尋" + } + } + } + }, + "retry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneut versuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リトライ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재시도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重試" + } + } + } + }, + "search": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋" + } + } + } + }, + "seconds": { + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld second" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld seconds" + } + } + } + } + }, + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Sekunde" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Sekunden" + } + } + } + } + }, + "ja": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 秒" + } + } + } + } + }, + "ko": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld초" + } + } + } + } + }, + "zh-Hans": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 秒" + } + } + } + } + }, + "zh-Hant": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 秒" + } + } + } + } + } + } + }, + "setting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Setting" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定" + } + } + } + }, + "share": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Share" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Teilen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "共有" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공유" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分享" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "分享" + } + } + } + }, + "stars": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ stars" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Sterne" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ つ星" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@별" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 星" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 星" + } + } + } + }, + "update": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings deleted file mode 100644 index 93e50fe08..000000000 --- a/AppPackage/Sources/Resources/Resources/de.lproj/Localizable.strings +++ /dev/null @@ -1,214 +0,0 @@ -// MARK: BanInterval - -// MARK: ToplistsType - -// MARK: Response - -// MARK: Toast - -// MARK: AutoLock - -// MARK: Common value -"common.stars" = "%@ Sterne"; -"common.pages" = "%@ Seiten"; -"common.day" = "%@ Tag"; -"common.days" = "%@ Tage"; -"common.hour" = "%@ Stunde"; -"common.hours" = "%@ Stunden"; -"common.minute" = "%@ Minute"; -"common.minutes" = "%@ Minuten"; -"common.second" = "%@ Sekunde"; -"common.seconds" = "%@ Sekunden"; - -// MARK: Common button -"common.cancel" = "Abbrechen"; - -// MARK: TabItem -"tab_item.home" = "Start"; -"tab_item.favorites" = "Favoriten"; -"tab_item.search" = "Suche"; -"tab_item.downloads" = "Downloads"; -"tab_item.setting" = "Einstellungen"; - -// MARK: ToolbarItem -"toolbar_item.filters" = "Filter"; -"toolbar_item.jump_page" = "Zu Seite springen"; -"toolbar_item.date_seek" = "Datum aufsuchen"; -"toolbar_item.quick_search" = "Schnellsuche"; - -// MARK: DateSeek -"date_seek_view.date_seek" = "Datum aufsuchen"; -// MARK: JumpPage -"jump_page_view.jump_page" = "Zu Seite springen"; - -// MARK: AlertView -"not_login_viewlogin" = "Einloggen"; -"error_view.retry" = "Erneut versuchen"; - -// MARK: AppError - -// MARK: ConfirmationDialog -"confirmation_dialog.delete_description" = "Möchtest du dieses Element wirklich löschen?"; -"confirmation_dialog.clear_description" = "Bist du sicher das du das löschen möchtest?"; -"confirmation_dialog.delete" = "Löschen"; -"confirmation_dialog.clear" = "Löschen"; - -// MARK: SubSection - -// MARK: NewDawnView -// Greeting - -// MARK: HomeView -"home_view.home" = "Start"; -// HomeMiscGridType - -// MARK: FrontpageView - -// MARK: ToplistsView - -// MARK: PopularView - -// MARK: WatchedView - -// MARK: HistoryView - -// MARK: FavoritesView -"favorites_view.favorites" = "Favoriten"; -// FavoriteCategory - -// MARK: SearchView -"search_view.search" = "Suche"; -"search_view.quick_search" = "Schnellsuche"; -// Searchable - -// MARK: QuickSearchView -"quick_search_view.quick_search" = "Schnellsuche"; - -// MARK: SettingView -"setting_view.setting" = "Einstellungen"; -// SettingStateRoute - -// MARK: AccountSettingView -"account_setting_view.login" = "Einloggen"; -// CookieValue - -// MARK: LoginView -"login_view.login" = "Einloggen"; - -// MARK: GeneralSettingView -"general_setting_view.language" = "Sprache"; -// AutoLockPolicy - -// MARK: AppActivityLogsView - -// MARK: AppearanceSettingView -// PreferredColorScheme -// AppIconType -// ListDisplayMode - -// MARK: AppIconView - -// MARK: reading_settingView -// ReadingDirection - -// MARK: LaboratorySettingView - -// MARK: AboutView - -// MARK: DetailView -"detail_view.share" = "Teilen"; -"detail_view.detail" = "Details"; -"detail_view.language" = "Sprache"; -"detail_view.delete_download" = "Download löschen?"; -"detail_view.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; -"detail_view.update" = "Aktualisieren"; - -// MARK: ArchivesView -// HathArchive -// ArchiveResolution - -// MARK: TorrentsView - -// MARK: GalleryInfosView -// GalleryVisibility - -// MARK: TagDetailView - -// MARK: DownloadsView -"detail_view.manage_folders" = "Ordner verwalten"; -"downloads_view.manage_folders" = "Ordner verwalten"; -"downloads_view.downloads" = "Downloads"; -"downloads_view.delete_download" = "Download löschen?"; -"downloads_view.delete_downloaded_gallery" = "Die heruntergeladene Galerie wird von diesem Gerät entfernt."; -"downloads_view.update" = "Aktualisieren"; - -// MARK: DownloadSettingView - -// MARK: CommentsView - -// MARK: PostCommentView - -// MARK: PreviewsView - -// MARK: ReadingView -"reading_view.share" = "Teilen"; -// AutoPlayPolicy - - -// MARK: DownloadBadge - -// MARK: DownloadStore -"download_store.invalid_folder_name" = "Der Ordnername ist ungültig."; -"download_store.manifest_corrupted" = "Manifest-Datei ist beschädigt."; -"download_store.page_missing" = "Seite %d fehlt."; -"download_store.page_image_corrupted" = "Bilddaten von Seite %d sind beschädigt."; - -// MARK: FiltersView -"filters_view.filters" = "Filter"; -// FilterRange - -// MARK: EhSettingView - -// EhSetting.LoadThroughHathSetting - -// EhSetting.ImageResolution - -// EhSetting.GalleryName - -// EhSetting.ArchiverBehavior - -// EhSetting.DisplayMode - - -// EhSetting.FavoritesSortOrder - - - - - -// EhSetting.ExcludedLanguagesCategory - - - -// EhSetting.ThumbnailLoadTiming -// EhSetting.ThumbnailSize - - - -// EhSetting.CommentsSortOrder -// EhSetting.CommentVotesShowTiming - -// EhSetting.tags_sort_order - - - -// EhSetting.MultiplePageViewerStyle -// EhSetting.GalleryPageNumbering - -// MARK: Category - -// MARK: TagNamespace - -// MARK: Language - -// MARK: BrowsingCountry diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings deleted file mode 100644 index 92a67223b..000000000 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Constant.strings +++ /dev/null @@ -1,21 +0,0 @@ -/* - Constant.strings - EhPanda -*/ - -// MARK: Website response -"gallery_unavailable" = "This gallery has been removed or is unavailable."; - -// MARK: App - -// Contact - -// Special thanks - -// Code level contributor - -// Translation contributor - -// Acknowledgement link - -// Acknowledgement text diff --git a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings deleted file mode 100644 index 7a4259485..000000000 --- a/AppPackage/Sources/Resources/Resources/en.lproj/Localizable.strings +++ /dev/null @@ -1,214 +0,0 @@ -// MARK: BanInterval - -// MARK: ToplistsType - -// MARK: Response - -// MARK: Toast - -// MARK: AutoLock - -// MARK: Common value -"common.stars" = "%@ stars"; -"common.pages" = "%@ pages"; -"common.day" = "%@ day"; -"common.days" = "%@ days"; -"common.hour" = "%@ hour"; -"common.hours" = "%@ hours"; -"common.minute" = "%@ minute"; -"common.minutes" = "%@ minutes"; -"common.second" = "%@ second"; -"common.seconds" = "%@ seconds"; - -// MARK: Common button -"common.cancel" = "Cancel"; - -// MARK: TabItem -"tab_item.home" = "Home"; -"tab_item.favorites" = "Favorites"; -"tab_item.search" = "Search"; -"tab_item.downloads" = "Downloads"; -"tab_item.setting" = "Setting"; - -// MARK: ToolbarItem -"toolbar_item.filters" = "Filters"; -"toolbar_item.jump_page" = "Jump page"; -"toolbar_item.date_seek" = "Seek to date"; -"toolbar_item.quick_search" = "Quick search"; - -// MARK: DateSeek -"date_seek_view.date_seek" = "Seek to date"; -// MARK: JumpPage -"jump_page_view.jump_page" = "Jump page"; - -// MARK: AlertView -"not_login_viewlogin" = "Login"; -"error_view.retry" = "Retry"; - -// MARK: AppError - -// MARK: ConfirmationDialog -"confirmation_dialog.delete_description" = "Are you sure to delete this item?"; -"confirmation_dialog.clear_description" = "Are you sure to clear?"; -"confirmation_dialog.delete" = "Delete"; -"confirmation_dialog.clear" = "Clear"; - -// MARK: SubSection - -// MARK: NewDawnView -// Greeting - -// MARK: HomeView -"home_view.home" = "Home"; -// HomeMiscGridType - -// MARK: FrontpageView - -// MARK: ToplistsView - -// MARK: PopularView - -// MARK: WatchedView - -// MARK: HistoryView - -// MARK: FavoritesView -"favorites_view.favorites" = "Favorites"; -// FavoriteCategory - -// MARK: SearchView -"search_view.search" = "Search"; -"search_view.quick_search" = "Quick search"; -// Searchable - -// MARK: QuickSearchView -"quick_search_view.quick_search" = "Quick search"; - -// MARK: SettingView -"setting_view.setting" = "Setting"; -// SettingStateRoute - -// MARK: AccountSettingView -"account_setting_view.login" = "Login"; -// CookieValue - -// MARK: LoginView -"login_view.login" = "Login"; - -// MARK: GeneralSettingView -"general_setting_view.language" = "Language"; -// AutoLockPolicy - -// MARK: AppActivityLogsView - -// MARK: AppearanceSettingView -// PreferredColorScheme -// AppIconType -// ListDisplayMode - -// MARK: AppIconView - -// MARK: reading_settingView -// ReadingDirection - -// MARK: LaboratorySettingView - -// MARK: AboutView - -// MARK: DetailView -"detail_view.share" = "Share"; -"detail_view.detail" = "Detail"; -"detail_view.language" = "Language"; -"detail_view.delete_download" = "Delete Download?"; -"detail_view.delete_downloaded_gallery" = "This will remove the downloaded gallery from this device."; -"detail_view.update" = "Update"; - -// MARK: ArchivesView -// HathArchive -// ArchiveResolution - -// MARK: TorrentsView - -// MARK: GalleryInfosView -// GalleryVisibility - -// MARK: TagDetailView - -// MARK: DownloadsView -"detail_view.manage_folders" = "Manage Folders"; -"downloads_view.manage_folders" = "Manage Folders"; -"downloads_view.downloads" = "Downloads"; -"downloads_view.delete_download" = "Delete Download?"; -"downloads_view.delete_downloaded_gallery" = "This will remove the downloaded gallery from this device."; -"downloads_view.update" = "Update"; - -// MARK: DownloadSettingView - -// MARK: CommentsView - -// MARK: PostCommentView - -// MARK: PreviewsView - -// MARK: ReadingView -"reading_view.share" = "Share"; -// AutoPlayPolicy - - -// MARK: DownloadBadge - -// MARK: DownloadStore -"download_store.invalid_folder_name" = "The folder name is invalid."; -"download_store.manifest_corrupted" = "Manifest file is corrupted."; -"download_store.page_missing" = "Page %d is missing."; -"download_store.page_image_corrupted" = "Page %d image data is corrupted."; - -// MARK: FiltersView -"filters_view.filters" = "Filters"; -// FilterRange - -// MARK: EhSettingView - -// EhSetting.LoadThroughHathSetting - -// EhSetting.ImageResolution - -// EhSetting.GalleryName - -// EhSetting.ArchiverBehavior - -// EhSetting.DisplayMode - - -// EhSetting.FavoritesSortOrder - - - - - -// EhSetting.ExcludedLanguagesCategory - - - -// EhSetting.ThumbnailLoadTiming -// EhSetting.ThumbnailSize - - - -// EhSetting.CommentsSortOrder -// EhSetting.CommentVotesShowTiming - -// EhSetting.tags_sort_order - - - -// EhSetting.MultiplePageViewerStyle -// EhSetting.GalleryPageNumbering - -// MARK: Category - -// MARK: TagNamespace - -// MARK: Language - -// MARK: BrowsingCountry diff --git a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings deleted file mode 100644 index f968c0030..000000000 --- a/AppPackage/Sources/Resources/Resources/ja.lproj/Localizable.strings +++ /dev/null @@ -1,214 +0,0 @@ -// MARK: BanInterval - -// MARK: ToplistsType - -// MARK: Response - -// MARK: Toast - -// MARK: AutoLock - -// MARK: Common value -"common.stars" = "%@ つ星"; -"common.pages" = "%@ ページ"; -"common.day" = "%@ 日"; -"common.days" = "%@ 日"; -"common.hour" = "%@ 時間"; -"common.hours" = "%@ 時間"; -"common.minute" = "%@ 分"; -"common.minutes" = "%@ 分"; -"common.second" = "%@ 秒"; -"common.seconds" = "%@ 秒"; - -// MARK: Common button -"common.cancel" = "キャンセル"; - -// MARK: TabItem -"tab_item.home" = "ホーム"; -"tab_item.favorites" = "お気に入り"; -"tab_item.search" = "検索"; -"tab_item.downloads" = "ダウンロード"; -"tab_item.setting" = "設定"; - -// MARK: ToolbarItem -"toolbar_item.filters" = "フィルター"; -"toolbar_item.jump_page" = "ページジャンプ"; -"toolbar_item.date_seek" = "日付指定"; -"toolbar_item.quick_search" = "クイック検索"; - -// MARK: DateSeek -"date_seek_view.date_seek" = "日付指定"; -// MARK: JumpPage -"jump_page_view.jump_page" = "ページジャンプ"; - -// MARK: AlertView -"not_login_viewlogin" = "ログイン"; -"error_view.retry" = "リトライ"; - -// MARK: AppError - -// MARK: ConfirmationDialog -"confirmation_dialog.delete_description" = "本当にこれを削除してもよろしいですか?"; -"confirmation_dialog.clear_description" = "本当に削除してもよろしいですか?"; -"confirmation_dialog.delete" = "削除"; -"confirmation_dialog.clear" = "削除"; - -// MARK: SubSection - -// MARK: NewDawnView -// Greeting - -// MARK: HomeView -"home_view.home" = "ホーム"; -// HomeMiscGridType - -// MARK: FrontpageView - -// MARK: ToplistsView - -// MARK: PopularView - -// MARK: WatchedView - -// MARK: HistoryView - -// MARK: FavoritesView -"favorites_view.favorites" = "お気に入り"; -// FavoriteCategory - -// MARK: SearchView -"search_view.search" = "検索"; -"search_view.quick_search" = "クイック検索"; -// Searchable - -// MARK: QuickSearchView -"quick_search_view.quick_search" = "クイック検索"; - -// MARK: SettingView -"setting_view.setting" = "設定"; -// SettingStateRoute - -// MARK: AccountSettingView -"account_setting_view.login" = "ログイン"; -// CookieValue - -// MARK: LoginView -"login_view.login" = "ログイン"; - -// MARK: GeneralSettingView -"general_setting_view.language" = "言語"; -// AutoLockPolicy - -// MARK: AppActivityLogsView - -// MARK: AppearanceSettingView -// PreferredColorScheme -// AppIconType -// ListDisplayMode - -// MARK: AppIconView - -// MARK: reading_settingView -// ReadingDirection - -// MARK: LaboratorySettingView - -// MARK: AboutView - -// MARK: DetailView -"detail_view.share" = "共有"; -"detail_view.detail" = "詳細"; -"detail_view.language" = "言語"; -"detail_view.delete_download" = "ダウンロードを削除しますか?"; -"detail_view.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; -"detail_view.update" = "更新"; - -// MARK: ArchivesView -// HathArchive -// ArchiveResolution - -// MARK: TorrentsView - -// MARK: GalleryInfosView -// GalleryVisibility - -// MARK: TagDetailView - -// MARK: DownloadsView -"detail_view.manage_folders" = "フォルダを管理"; -"downloads_view.manage_folders" = "フォルダを管理"; -"downloads_view.downloads" = "ダウンロード"; -"downloads_view.delete_download" = "ダウンロードを削除しますか?"; -"downloads_view.delete_downloaded_gallery" = "ダウンロード済みのギャラリーをこのデバイスから削除します。"; -"downloads_view.update" = "更新"; - -// MARK: DownloadSettingView - -// MARK: CommentsView - -// MARK: PostCommentView - -// MARK: PreviewsView - -// MARK: ReadingView -"reading_view.share" = "共有"; -// AutoPlayPolicy - - -// MARK: DownloadBadge - -// MARK: DownloadStore -"download_store.invalid_folder_name" = "フォルダ名が無効です。"; -"download_store.manifest_corrupted" = "マニフェストファイルが破損しています。"; -"download_store.page_missing" = "ページ %d が見つかりません。"; -"download_store.page_image_corrupted" = "ページ %d の画像データが破損しています。"; - -// MARK: FiltersView -"filters_view.filters" = "フィルター"; -// FilterRange - -// MARK: EhSettingView - -// EhSetting.LoadThroughHathSetting - -// EhSetting.ImageResolution - -// EhSetting.GalleryName - -// EhSetting.ArchiverBehavior - -// EhSetting.DisplayMode - - -// EhSetting.FavoritesSortOrder - - - - - -// EhSetting.ExcludedLanguagesCategory - - - -// EhSetting.ThumbnailLoadTiming -// EhSetting.ThumbnailSize - - - -// EhSetting.CommentsSortOrder -// EhSetting.CommentVotesShowTiming - -// EhSetting.tags_sort_order - - - -// EhSetting.MultiplePageViewerStyle -// EhSetting.GalleryPageNumbering - -// MARK: Category - -// MARK: TagNamespace - -// MARK: Language - -// MARK: BrowsingCountry diff --git a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings deleted file mode 100644 index 816286a01..000000000 --- a/AppPackage/Sources/Resources/Resources/ko.lproj/Localizable.strings +++ /dev/null @@ -1,214 +0,0 @@ -// MARK: BanInterval - -// MARK: ToplistsType - -// MARK: Response - -// MARK: Toast - -// MARK: AutoLock - -// MARK: Common value -"common.stars" = "%@별"; -"common.pages" = "%@페이지"; -"common.day" = "%@일"; -"common.days" = "%@일"; -"common.hour" = "%@시간"; -"common.hours" = "%@시간"; -"common.minute" = "%@분"; -"common.minutes" = "%@분"; -"common.second" = "%@초"; -"common.seconds" = "%@초"; - -// MARK: Common button -"common.cancel" = "취소"; - -// MARK: TabItem -"tab_item.home" = "홈"; -"tab_item.favorites" = "즐겨찾기"; -"tab_item.search" = "검색"; -"tab_item.downloads" = "다운로드"; -"tab_item.setting" = "설정"; - -// MARK: ToolbarItem -"toolbar_item.filters" = "필터"; -"toolbar_item.jump_page" = "페이지 이동"; -"toolbar_item.date_seek" = "날짜로 이동"; -"toolbar_item.quick_search" = "빠른 검색"; - -// MARK: DateSeek -"date_seek_view.date_seek" = "날짜로 이동"; -// MARK: JumpPage -"jump_page_view.jump_page" = "페이지 이동"; - -// MARK: AlertView -"not_login_viewlogin" = "로그인"; -"error_view.retry" = "재시도"; - -// MARK: AppError - -// MARK: ConfirmationDialog -"confirmation_dialog.delete_description" = "이 항목을 삭제하시겠어요?"; -"confirmation_dialog.clear_description" = "삭제하시겠어요?"; -"confirmation_dialog.delete" = "삭제"; -"confirmation_dialog.clear" = "삭제"; - -// MARK: SubSection - -// MARK: NewDawnView -// Greeting - -// MARK: HomeView -"home_view.home" = "홈"; -// HomeMiscGridType - -// MARK: FrontpageView - -// MARK: ToplistsView - -// MARK: PopularView - -// MARK: WatchedView - -// MARK: HistoryView - -// MARK: FavoritesView -"favorites_view.favorites" = "즐겨찾기"; -// FavoriteCategory - -// MARK: SearchView -"search_view.search" = "검색"; -"search_view.quick_search" = "빠른 검색"; -// Searchable - -// MARK: QuickSearchView -"quick_search_view.quick_search" = "빠른 검색"; - -// MARK: SettingView -"setting_view.setting" = "설정"; -// SettingStateRoute - -// MARK: AccountSettingView -"account_setting_view.login" = "로그인"; -// CookieValue - -// MARK: LoginView -"login_view.login" = "로그인"; - -// MARK: GeneralSettingView -"general_setting_view.language" = "언어"; -// AutoLockPolicy - -// MARK: AppActivityLogsView - -// MARK: AppearanceSettingView -// PreferredColorScheme -// AppIconType -// ListDisplayMode - -// MARK: AppIconView - -// MARK: reading_settingView -// ReadingDirection - -// MARK: LaboratorySettingView - -// MARK: AboutView - -// MARK: DetailView -"detail_view.share" = "공유"; -"detail_view.detail" = "세부 정보"; -"detail_view.language" = "언어"; -"detail_view.delete_download" = "다운로드를 삭제할까요?"; -"detail_view.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; -"detail_view.update" = "업데이트"; - -// MARK: ArchivesView -// HathArchive -// ArchiveResolution - -// MARK: TorrentsView - -// MARK: GalleryInfosView -// GalleryVisibility - -// MARK: TagDetailView - -// MARK: DownloadsView -"detail_view.manage_folders" = "폴더 관리"; -"downloads_view.manage_folders" = "폴더 관리"; -"downloads_view.downloads" = "다운로드"; -"downloads_view.delete_download" = "다운로드를 삭제할까요?"; -"downloads_view.delete_downloaded_gallery" = "다운로드한 갤러리를 이 기기에서 삭제합니다."; -"downloads_view.update" = "업데이트"; - -// MARK: DownloadSettingView - -// MARK: CommentsView - -// MARK: PostCommentView - -// MARK: PreviewsView - -// MARK: ReadingView -"reading_view.share" = "공유"; -// AutoPlayPolicy - - -// MARK: DownloadBadge - -// MARK: DownloadStore -"download_store.invalid_folder_name" = "폴더 이름이 올바르지 않습니다."; -"download_store.manifest_corrupted" = "매니페스트 파일이 손상되었습니다."; -"download_store.page_missing" = "페이지 %d가 없습니다."; -"download_store.page_image_corrupted" = "페이지 %d 이미지 데이터가 손상되었습니다."; - -// MARK: FiltersView -"filters_view.filters" = "필터"; -// FilterRange - -// MARK: EhSettingView - -// EhSetting.LoadThroughHathSetting - -// EhSetting.ImageResolution - -// EhSetting.GalleryName - -// EhSetting.ArchiverBehavior - -// EhSetting.DisplayMode - - -// EhSetting.FavoritesSortOrder - - - - - -// EhSetting.ExcludedLanguagesCategory - - - -// EhSetting.ThumbnailLoadTiming -// EhSetting.ThumbnailSize - - - -// EhSetting.CommentsSortOrder -// EhSetting.CommentVotesShowTiming - -// EhSetting.tags_sort_order - - - -// EhSetting.MultiplePageViewerStyle -// EhSetting.GalleryPageNumbering - -// MARK: Category - -// MARK: TagNamespace - -// MARK: Language - -// MARK: BrowsingCountry diff --git a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings deleted file mode 100644 index d788ca5b6..000000000 --- a/AppPackage/Sources/Resources/Resources/zh-Hans.lproj/Localizable.strings +++ /dev/null @@ -1,214 +0,0 @@ -// MARK: BanInterval - -// MARK: ToplistsType - -// MARK: Response - -// MARK: Toast - -// MARK: AutoLock - -// MARK: Common value -"common.stars" = "%@ 星"; -"common.pages" = "%@ 页"; -"common.day" = "%@ 天"; -"common.days" = "%@ 天"; -"common.hour" = "%@ 小时"; -"common.hours" = "%@ 小时"; -"common.minute" = "%@ 分"; -"common.minutes" = "%@ 分"; -"common.second" = "%@ 秒"; -"common.seconds" = "%@ 秒"; - -// MARK: Common button -"common.cancel" = "取消"; - -// MARK: TabItem -"tab_item.home" = "主页"; -"tab_item.favorites" = "收藏"; -"tab_item.search" = "搜索"; -"tab_item.downloads" = "下载"; -"tab_item.setting" = "设置"; - -// MARK: ToolbarItem -"toolbar_item.filters" = "筛选"; -"toolbar_item.jump_page" = "页码跳转"; -"toolbar_item.date_seek" = "日期定位"; -"toolbar_item.quick_search" = "快速搜索"; - -// MARK: DateSeek -"date_seek_view.date_seek" = "日期定位"; -// MARK: JumpPage -"jump_page_view.jump_page" = "页码跳转"; - -// MARK: AlertView -"not_login_viewlogin" = "登录"; -"error_view.retry" = "重试"; - -// MARK: AppError - -// MARK: ConfirmationDialog -"confirmation_dialog.delete_description" = "确定要删除吗?"; -"confirmation_dialog.clear_description" = "确定要清空吗?"; -"confirmation_dialog.delete" = "删除"; -"confirmation_dialog.clear" = "清空"; - -// MARK: SubSection - -// MARK: NewDawnView -// Greeting - -// MARK: HomeView -"home_view.home" = "主页"; -// HomeMiscGridType - -// MARK: FrontpageView - -// MARK: ToplistsView - -// MARK: PopularView - -// MARK: WatchedView - -// MARK: HistoryView - -// MARK: FavoritesView -"favorites_view.favorites" = "收藏"; -// FavoriteCategory - -// MARK: SearchView -"search_view.search" = "搜索"; -"search_view.quick_search" = "快速搜索"; -// Searchable - -// MARK: QuickSearchView -"quick_search_view.quick_search" = "快速搜索"; - -// MARK: SettingView -"setting_view.setting" = "设置"; -// SettingStateRoute - -// MARK: AccountSettingView -"account_setting_view.login" = "登录"; -// CookieValue - -// MARK: LoginView -"login_view.login" = "登录"; - -// MARK: GeneralSettingView -"general_setting_view.language" = "语言"; -// AutoLockPolicy - -// MARK: AppActivityLogsView - -// MARK: AppearanceSettingView -// PreferredColorScheme -// AppIconType -// ListDisplayMode - -// MARK: AppIconView - -// MARK: reading_settingView -// ReadingDirection - -// MARK: LaboratorySettingView - -// MARK: AboutView - -// MARK: DetailView -"detail_view.share" = "分享"; -"detail_view.detail" = "详情"; -"detail_view.language" = "语言"; -"detail_view.delete_download" = "删除下载?"; -"detail_view.delete_downloaded_gallery" = "这将从此设备移除已下载的画廊。"; -"detail_view.update" = "更新"; - -// MARK: ArchivesView -// HathArchive -// ArchiveResolution - -// MARK: TorrentsView - -// MARK: GalleryInfosView -// GalleryVisibility - -// MARK: TagDetailView - -// MARK: DownloadsView -"detail_view.manage_folders" = "管理文件夹"; -"downloads_view.manage_folders" = "管理文件夹"; -"downloads_view.downloads" = "下载"; -"downloads_view.delete_download" = "删除下载?"; -"downloads_view.delete_downloaded_gallery" = "这将从此设备移除已下载的画廊。"; -"downloads_view.update" = "更新"; - -// MARK: DownloadSettingView - -// MARK: CommentsView - -// MARK: PostCommentView - -// MARK: PreviewsView - -// MARK: ReadingView -"reading_view.share" = "分享"; -// AutoPlayPolicy - - -// MARK: DownloadBadge - -// MARK: DownloadStore -"download_store.invalid_folder_name" = "文件夹名称无效。"; -"download_store.manifest_corrupted" = "Manifest 文件已损坏。"; -"download_store.page_missing" = "第 %d 页缺失。"; -"download_store.page_image_corrupted" = "第 %d 页图片数据已损坏。"; - -// MARK: FiltersView -"filters_view.filters" = "筛选"; -// FilterRange - -// MARK: EhSettingView - -// EhSetting.LoadThroughHathSetting - -// EhSetting.ImageResolution - -// EhSetting.GalleryName - -// EhSetting.ArchiverBehavior - -// EhSetting.DisplayMode - - -// EhSetting.FavoritesSortOrder - - - - - -// EhSetting.ExcludedLanguagesCategory - - - -// EhSetting.ThumbnailLoadTiming -// EhSetting.ThumbnailSize - - - -// EhSetting.CommentsSortOrder -// EhSetting.CommentVotesShowTiming - -// EhSetting.tags_sort_order - - - -// EhSetting.MultiplePageViewerStyle -// EhSetting.GalleryPageNumbering - -// MARK: Category - -// MARK: TagNamespace - -// MARK: Language - -// MARK: BrowsingCountry diff --git a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings b/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings deleted file mode 100644 index 8ac2194f4..000000000 --- a/AppPackage/Sources/Resources/Resources/zh-Hant.lproj/Localizable.strings +++ /dev/null @@ -1,214 +0,0 @@ -// MARK: BanInterval - -// MARK: ToplistsType - -// MARK: Response - -// MARK: Toast - -// MARK: AutoLock - -// MARK: Common value -"common.stars" = "%@ 星"; -"common.pages" = "%@ 頁"; -"common.day" = "%@ 天"; -"common.days" = "%@ 天"; -"common.hour" = "%@ 小時"; -"common.hours" = "%@ 小時"; -"common.minute" = "%@ 分"; -"common.minutes" = "%@ 分"; -"common.second" = "%@ 秒"; -"common.seconds" = "%@ 秒"; - -// MARK: Common button -"common.cancel" = "取消"; - -// MARK: TabItem -"tab_item.home" = "總覽"; -"tab_item.favorites" = "收藏"; -"tab_item.search" = "搜尋"; -"tab_item.downloads" = "下載"; -"tab_item.setting" = "設定"; - -// MARK: ToolbarItem -"toolbar_item.filters" = "過濾"; -"toolbar_item.jump_page" = "跳到..."; -"toolbar_item.date_seek" = "日期定位"; -"toolbar_item.quick_search" = "快速搜尋"; - -// MARK: DateSeek -"date_seek_view.date_seek" = "日期定位"; -// MARK: JumpPage -"jump_page_view.jump_page" = "跳到..."; - -// MARK: AlertView -"not_login_viewlogin" = "登入"; -"error_view.retry" = "重試"; - -// MARK: AppError - -// MARK: ConfirmationDialog -"confirmation_dialog.delete_description" = "確定要刪除?"; -"confirmation_dialog.clear_description" = "確定要清空嗎?"; -"confirmation_dialog.delete" = "刪除"; -"confirmation_dialog.clear" = "清空"; - -// MARK: SubSection - -// MARK: NewDawnView -// Greeting - -// MARK: HomeView -"home_view.home" = "總覽"; -// HomeMiscGridType - -// MARK: FrontpageView - -// MARK: ToplistsView - -// MARK: PopularView - -// MARK: WatchedView - -// MARK: HistoryView - -// MARK: FavoritesView -"favorites_view.favorites" = "收藏"; -// FavoriteCategory - -// MARK: SearchView -"search_view.search" = "搜尋"; -"search_view.quick_search" = "快速搜尋"; -// Searchable - -// MARK: QuickSearchView -"quick_search_view.quick_search" = "快速搜尋"; - -// MARK: SettingView -"setting_view.setting" = "設定"; -// SettingStateRoute - -// MARK: AccountSettingView -"account_setting_view.login" = "登入"; -// CookieValue - -// MARK: LoginView -"login_view.login" = "登入"; - -// MARK: GeneralSettingView -"general_setting_view.language" = "語言"; -// AutoLockPolicy - -// MARK: AppActivityLogsView - -// MARK: AppearanceSettingView -// PreferredColorScheme -// AppIconType -// ListDisplayMode - -// MARK: AppIconView - -// MARK: reading_settingView -// ReadingDirection - -// MARK: LaboratorySettingView - -// MARK: AboutView - -// MARK: DetailView -"detail_view.share" = "分享"; -"detail_view.detail" = "Detail"; -"detail_view.language" = "語言"; -"detail_view.delete_download" = "刪除下載?"; -"detail_view.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"detail_view.update" = "更新"; - -// MARK: ArchivesView -// HathArchive -// ArchiveResolution - -// MARK: TorrentsView - -// MARK: GalleryInfosView -// GalleryVisibility - -// MARK: TagDetailView - -// MARK: DownloadsView -"detail_view.manage_folders" = "管理資料夾"; -"downloads_view.manage_folders" = "管理資料夾"; -"downloads_view.downloads" = "下載"; -"downloads_view.delete_download" = "刪除下載?"; -"downloads_view.delete_downloaded_gallery" = "這將從此裝置移除已下載的畫廊。"; -"downloads_view.update" = "更新"; - -// MARK: DownloadSettingView - -// MARK: CommentsView - -// MARK: PostCommentView - -// MARK: PreviewsView - -// MARK: ReadingView -"reading_view.share" = "分享"; -// AutoPlayPolicy - - -// MARK: DownloadBadge - -// MARK: DownloadStore -"download_store.invalid_folder_name" = "資料夾名稱無效。"; -"download_store.manifest_corrupted" = "Manifest 檔案已損壞。"; -"download_store.page_missing" = "第 %d 頁缺失。"; -"download_store.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; - -// MARK: FiltersView -"filters_view.filters" = "過濾"; -// FilterRange - -// MARK: EhSettingView - -// EhSetting.LoadThroughHathSetting - -// EhSetting.ImageResolution - -// EhSetting.GalleryName - -// EhSetting.ArchiverBehavior - -// EhSetting.DisplayMode - - -// EhSetting.FavoritesSortOrder - - - - - -// EhSetting.ExcludedLanguagesCategory - - - -// EhSetting.ThumbnailLoadTiming -// EhSetting.ThumbnailSize - - - -// EhSetting.CommentsSortOrder -// EhSetting.CommentVotesShowTiming - -// EhSetting.tags_sort_order - - - -// EhSetting.MultiplePageViewerStyle -// EhSetting.GalleryPageNumbering - -// MARK: Category - -// MARK: TagNamespace - -// MARK: Language - -// MARK: BrowsingCountry diff --git a/AppPackage/Sources/Resources/Strings.swift b/AppPackage/Sources/Resources/Strings.swift deleted file mode 100644 index 0d7440fbe..000000000 --- a/AppPackage/Sources/Resources/Strings.swift +++ /dev/null @@ -1,217 +0,0 @@ -// swiftlint:disable all -// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen - -import Foundation - -// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references - -// MARK: - Strings - -// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length -// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces -public enum L10n { - public enum Constant { - /// Constant.strings - /// EhPanda - public static let galleryUnavailable = L10n.tr("Constant", "gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") - } - public enum Localizable { - /// Login - public static let notLoginViewlogin = L10n.tr("Localizable", "not_login_viewlogin", fallback: "Login") - public enum AccountSettingView { - /// Login - public static let login = L10n.tr("Localizable", "account_setting_view.login", fallback: "Login") - } - public enum Common { - /// Cancel - public static let cancel = L10n.tr("Localizable", "common.cancel", fallback: "Cancel") - /// %@ day - public static func day(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.day", String(describing: p1), fallback: "%@ day") - } - /// %@ days - public static func days(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.days", String(describing: p1), fallback: "%@ days") - } - /// %@ hour - public static func hour(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.hour", String(describing: p1), fallback: "%@ hour") - } - /// %@ hours - public static func hours(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.hours", String(describing: p1), fallback: "%@ hours") - } - /// %@ minute - public static func minute(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.minute", String(describing: p1), fallback: "%@ minute") - } - /// %@ minutes - public static func minutes(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.minutes", String(describing: p1), fallback: "%@ minutes") - } - /// %@ pages - public static func pages(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.pages", String(describing: p1), fallback: "%@ pages") - } - /// %@ second - public static func second(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.second", String(describing: p1), fallback: "%@ second") - } - /// %@ seconds - public static func seconds(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.seconds", String(describing: p1), fallback: "%@ seconds") - } - /// %@ stars - public static func stars(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.stars", String(describing: p1), fallback: "%@ stars") - } - } - public enum ConfirmationDialog { - /// Clear - public static let clear = L10n.tr("Localizable", "confirmation_dialog.clear", fallback: "Clear") - /// Are you sure to clear? - public static let clearDescription = L10n.tr("Localizable", "confirmation_dialog.clear_description", fallback: "Are you sure to clear?") - /// Delete - public static let delete = L10n.tr("Localizable", "confirmation_dialog.delete", fallback: "Delete") - /// Are you sure to delete this item? - public static let deleteDescription = L10n.tr("Localizable", "confirmation_dialog.delete_description", fallback: "Are you sure to delete this item?") - } - public enum DateSeekView { - /// Seek to date - public static let dateSeek = L10n.tr("Localizable", "date_seek_view.date_seek", fallback: "Seek to date") - } - public enum DetailView { - /// Delete Download? - public static let deleteDownload = L10n.tr("Localizable", "detail_view.delete_download", fallback: "Delete Download?") - /// This will remove the downloaded gallery from this device. - public static let deleteDownloadedGallery = L10n.tr("Localizable", "detail_view.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") - /// Detail - public static let detail = L10n.tr("Localizable", "detail_view.detail", fallback: "Detail") - /// Language - public static let language = L10n.tr("Localizable", "detail_view.language", fallback: "Language") - /// Manage Folders - public static let manageFolders = L10n.tr("Localizable", "detail_view.manage_folders", fallback: "Manage Folders") - /// Share - public static let share = L10n.tr("Localizable", "detail_view.share", fallback: "Share") - /// Update - public static let update = L10n.tr("Localizable", "detail_view.update", fallback: "Update") - } - public enum DownloadStore { - /// The folder name is invalid. - public static let invalidFolderName = L10n.tr("Localizable", "download_store.invalid_folder_name", fallback: "The folder name is invalid.") - /// Manifest file is corrupted. - public static let manifestCorrupted = L10n.tr("Localizable", "download_store.manifest_corrupted", fallback: "Manifest file is corrupted.") - /// Page %d image data is corrupted. - public static func pageImageCorrupted(_ p1: Int) -> String { - return L10n.tr("Localizable", "download_store.page_image_corrupted", p1, fallback: "Page %d image data is corrupted.") - } - /// Page %d is missing. - public static func pageMissing(_ p1: Int) -> String { - return L10n.tr("Localizable", "download_store.page_missing", p1, fallback: "Page %d is missing.") - } - } - public enum DownloadsView { - /// Delete Download? - public static let deleteDownload = L10n.tr("Localizable", "downloads_view.delete_download", fallback: "Delete Download?") - /// This will remove the downloaded gallery from this device. - public static let deleteDownloadedGallery = L10n.tr("Localizable", "downloads_view.delete_downloaded_gallery", fallback: "This will remove the downloaded gallery from this device.") - /// Downloads - public static let downloads = L10n.tr("Localizable", "downloads_view.downloads", fallback: "Downloads") - /// Manage Folders - public static let manageFolders = L10n.tr("Localizable", "downloads_view.manage_folders", fallback: "Manage Folders") - /// Update - public static let update = L10n.tr("Localizable", "downloads_view.update", fallback: "Update") - } - public enum ErrorView { - /// Retry - public static let retry = L10n.tr("Localizable", "error_view.retry", fallback: "Retry") - } - public enum FavoritesView { - /// Favorites - public static let favorites = L10n.tr("Localizable", "favorites_view.favorites", fallback: "Favorites") - } - public enum FiltersView { - /// Filters - public static let filters = L10n.tr("Localizable", "filters_view.filters", fallback: "Filters") - } - public enum GeneralSettingView { - /// Language - public static let language = L10n.tr("Localizable", "general_setting_view.language", fallback: "Language") - } - public enum HomeView { - /// Home - public static let home = L10n.tr("Localizable", "home_view.home", fallback: "Home") - } - public enum JumpPageView { - /// Jump page - public static let jumpPage = L10n.tr("Localizable", "jump_page_view.jump_page", fallback: "Jump page") - } - public enum LoginView { - /// Login - public static let login = L10n.tr("Localizable", "login_view.login", fallback: "Login") - } - public enum QuickSearchView { - /// Quick search - public static let quickSearch = L10n.tr("Localizable", "quick_search_view.quick_search", fallback: "Quick search") - } - public enum ReadingView { - /// Share - public static let share = L10n.tr("Localizable", "reading_view.share", fallback: "Share") - } - public enum SearchView { - /// Quick search - public static let quickSearch = L10n.tr("Localizable", "search_view.quick_search", fallback: "Quick search") - /// Search - public static let search = L10n.tr("Localizable", "search_view.search", fallback: "Search") - } - public enum SettingView { - /// Setting - public static let setting = L10n.tr("Localizable", "setting_view.setting", fallback: "Setting") - } - public enum TabItem { - /// Downloads - public static let downloads = L10n.tr("Localizable", "tab_item.downloads", fallback: "Downloads") - /// Favorites - public static let favorites = L10n.tr("Localizable", "tab_item.favorites", fallback: "Favorites") - /// Home - public static let home = L10n.tr("Localizable", "tab_item.home", fallback: "Home") - /// Search - public static let search = L10n.tr("Localizable", "tab_item.search", fallback: "Search") - /// Setting - public static let setting = L10n.tr("Localizable", "tab_item.setting", fallback: "Setting") - } - public enum ToolbarItem { - /// Seek to date - public static let dateSeek = L10n.tr("Localizable", "toolbar_item.date_seek", fallback: "Seek to date") - /// Filters - public static let filters = L10n.tr("Localizable", "toolbar_item.filters", fallback: "Filters") - /// Jump page - public static let jumpPage = L10n.tr("Localizable", "toolbar_item.jump_page", fallback: "Jump page") - /// Quick search - public static let quickSearch = L10n.tr("Localizable", "toolbar_item.quick_search", fallback: "Quick search") - } - } -} -// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length -// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces - -// MARK: - Implementation Details - -extension L10n { - private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { - let format = BundleToken.bundle.localizedString(forKey: key, value: value, table: table) - return String(format: format, locale: Locale.current, arguments: args) - } -} - -// swiftlint:disable convenience_type -private final class BundleToken { - static let bundle: Bundle = { - #if SWIFT_PACKAGE - return Bundle.module - #else - return Bundle(for: BundleToken.self) - #endif - }() -} -// swiftlint:enable convenience_type diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index 9e02a8f37..4e1a91e61 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -77,7 +77,7 @@ public struct SearchRootView: View { store.send(.fetchDatabaseInfos) } .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.SearchView.search) + .navigationTitle(String(localized: .RLocalizable.search)) // Workaround: Prevent the title disappearing issue. if store.historyKeywords.isEmpty && store.historyGalleries.isEmpty { @@ -204,7 +204,7 @@ private struct QuickSearchWordsSection: View { var body: some View { SubSection( - title: L10n.Localizable.SearchView.quickSearch, + title: String(localized: .RLocalizable.quickSearch), showAll: true, tint: .primary, showAllAction: showAllAction ) { DoubleVerticalKeywordsStack(keywords: keywords, searchAction: searchAction) diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index 5a8d5fcd3..bfc08add7 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -90,7 +90,7 @@ public struct AccountSettingReducer: Sendable { TextState(String(localized: .logout)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } message: { TextState(String(localized: .logoutDescription)) diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index 61782c29d..7f7677a3f 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -95,7 +95,7 @@ private struct AccountSection: View { var body: some View { if !CookieUtil.didLogin { - Button(L10n.Localizable.AccountSettingView.login, action: loginAction) + Button(String(localized: .RLocalizable.login), action: loginAction) } else { Button( String(localized: .logout), diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index 3034514de..10a5d8597 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -86,13 +86,13 @@ public struct EhSettingReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmDeleteProfile) { - TextState(L10n.Localizable.ConfirmationDialog.delete) + TextState(String(localized: .RLocalizable.delete)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.deleteDescription) + TextState(String(localized: .RLocalizable.deleteDescription)) } return .none diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index 24b11c91b..d2d073ed3 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -96,7 +96,7 @@ public struct GeneralSettingReducer: Sendable { TextState(String(localized: .remove)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } message: { TextState(String(localized: .removeCustomTranslationsConfirmation)) @@ -108,13 +108,13 @@ public struct GeneralSettingReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmClearCache) { - TextState(L10n.Localizable.ConfirmationDialog.clear) + TextState(String(localized: .RLocalizable.clear)) } ButtonState(role: .cancel) { - TextState(L10n.Localizable.Common.cancel) + TextState(String(localized: .RLocalizable.cancel)) } } message: { - TextState(L10n.Localizable.ConfirmationDialog.clearDescription) + TextState(String(localized: .RLocalizable.clearDescription)) } return .none diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index 711d9785d..2ae712e60 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -51,7 +51,7 @@ struct GeneralSettingView: View { Form { Section { HStack { - Text(L10n.Localizable.GeneralSettingView.language) + Text(String(localized: .RLocalizable.language)) Spacer() Button(language) { store.send(.navigateToSystemSetting) diff --git a/AppPackage/Sources/SettingFeature/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift index 31c4a9f4f..e055a0d69 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginView.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginView.swift @@ -86,7 +86,7 @@ struct LoginView: View { } .animation(.default, value: store.loginState) .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.LoginView.login) + .navigationTitle(String(localized: .RLocalizable.login)) .ignoresSafeArea() } // MARK: Toolbar diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index f784d0d92..f9d6e12b6 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -27,7 +27,7 @@ public struct SettingView: View { } .padding(.vertical, 40).padding(.horizontal) } - .navigationTitle(L10n.Localizable.SettingView.setting) + .navigationTitle(String(localized: .RLocalizable.setting)) } destination: { pathStore in destination(pathStore) .tint(store.setting.accentColor) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift index 2456e8565..df70d570d 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift @@ -404,7 +404,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { let validation = await manager.validateImageData(gid: "440") - #expect(validation == .missingFiles(L10n.Localizable.DownloadStore.pageMissing(1))) + #expect(validation == .missingFiles(String(localized: .RLocalizable.downloadStorePageMissing(1)))) let download = try #require(await manager.fetchDownload(gid: "440")) #expect(download.displayStatus == .error) #expect(download.displayStatus == .error) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift index 94a91b86f..d9e8d022d 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift @@ -24,7 +24,7 @@ struct DownloadStoreHashTests { #expect( storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( - L10n.Localizable.DownloadStore.pageImageCorrupted(2) + String(localized: .RLocalizable.downloadStorePageImageCorrupted(2)) ) ) } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift index 26885d795..980425de6 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift @@ -70,7 +70,7 @@ struct DownloadStoreTests { #expect( throws: AppError.fileOperationFailed( - L10n.Localizable.DownloadStore.manifestCorrupted + String(localized: .RLocalizable.downloadStoreManifestCorrupted) ) ) { try storage.readManifest(folderURL: folderURL) @@ -89,7 +89,7 @@ struct DownloadStoreTests { #expect( throws: AppError.fileOperationFailed( - L10n.Localizable.DownloadStore.manifestCorrupted + String(localized: .RLocalizable.downloadStoreManifestCorrupted) ) ) { try storage.readManifest(folderURL: folderURL) @@ -132,7 +132,7 @@ struct DownloadStoreTests { #expect( storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( - L10n.Localizable.DownloadStore.pageMissing(2) + String(localized: .RLocalizable.downloadStorePageMissing(2)) ) ) } @@ -169,7 +169,7 @@ struct DownloadStoreTests { #expect( storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( - L10n.Localizable.DownloadStore.pageMissing(1) + String(localized: .RLocalizable.downloadStorePageMissing(1)) ) ) #expect( diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 5df934cfa..d1bf28d76 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -44,7 +44,6 @@ AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; ABC3C7542593696C00E0C11B /* EhPanda.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EhPanda.app; sourceTree = BUILT_PRODUCTS_DIR; }; EA0C92482C3EB45E00D211F6 /* AltStore.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = AltStore.json; sourceTree = ""; }; - EA0C92492C3EB45E00D211F6 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; EA0C924A2C3EB45E00D211F6 /* .gitattributes */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = .gitattributes; sourceTree = ""; }; EA0C924B2C3EB45E00D211F6 /* .swiftlint.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = ""; }; EA0C924C2C3EB45E00D211F6 /* .gitignore */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; @@ -157,7 +156,6 @@ EA0C924C2C3EB45E00D211F6 /* .gitignore */, EA0C924B2C3EB45E00D211F6 /* .swiftlint.yml */, EA0C92482C3EB45E00D211F6 /* AltStore.json */, - EA0C92492C3EB45E00D211F6 /* swiftgen.yml */, ); name = Config; sourceTree = ""; diff --git a/swiftgen.yml b/swiftgen.yml deleted file mode 100644 index 49effcd67..000000000 --- a/swiftgen.yml +++ /dev/null @@ -1,9 +0,0 @@ -output_dir: AppPackage/Sources/Resources - -strings: - inputs: AppPackage/Sources/Resources/Resources/en.lproj - outputs: - - templateName: structured-swift5 - output: Strings.swift - params: - publicAccess: true From 7db197a00dd9c7161f0244bf5deb645926713384 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 02:51:09 +0800 Subject: [PATCH 470/614] Add SFSafeSymbolsExt, use Label resource init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New SFSafeSymbolsExt module publishing Label(_ titleResource: LocalizedStringResource, systemSymbol: SFSymbol) via the closure initializer (no banned systemImage: param, no swiftlint:disable) — the initializer the label_text_image_shorthand rule expects. Wire it into 5 consumers (AppComponents, DetailFeature, DownloadsFeature, ReadingFeature, SettingFeature) and convert their 11 Label(String(localized:), systemSymbol:) sites to Label(.symbol, systemSymbol:). Module gets its own .swiftlint.yml (parent_config). --- AppPackage/Package.swift | 14 ++++++++++++++ .../Sources/AppComponents/ToolbarItems.swift | 3 ++- .../DetailFeature/DetailView+Navigation.swift | 5 +++-- .../Sources/DownloadsFeature/DownloadsView.swift | 5 +++-- .../ReadingFeature/ReadingViewComponents.swift | 9 +++++---- .../Sources/SFSafeSymbolsExt/.swiftlint.yml | 1 + .../SFSafeSymbols+LocalizedStringResource.swift | 16 ++++++++++++++++ .../AppActivityLogs/AppActivityLogsView.swift | 5 +++-- 8 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 AppPackage/Sources/SFSafeSymbolsExt/.swiftlint.yml create mode 100644 AppPackage/Sources/SFSafeSymbolsExt/SFSafeSymbols+LocalizedStringResource.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 8696ef484..9e04316d6 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -100,6 +100,7 @@ enum Module: String { case resources = "Resources" case searchFeature = "SearchFeature" case settingFeature = "SettingFeature" + case sfSafeSymbolsExt = "SFSafeSymbolsExt" case systemNotificationExt = "SystemNotificationExt" case tagTranslationFeature = "TagTranslationFeature" case urlClient = "URLClient" @@ -481,6 +482,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .appComponents, dependencies: [ + .module(.sfSafeSymbolsExt), .module(.appModels), .module(.appTools), .module(.parserFeature), @@ -526,6 +528,14 @@ let targets: [PackageDescription.Target] = [ swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), + .target( + module: .sfSafeSymbolsExt, + dependencies: [ + .targetDependency(.sfSafeSymbols) + ], + swiftSettings: sharedSwiftSettings, + plugins: swiftLintPlugins + ), .target( module: .openCCExt, dependencies: [ @@ -632,6 +642,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .downloadsFeature, dependencies: [ + .module(.sfSafeSymbolsExt), .module(.appComponents), .module(.appModels), .module(.appTools), @@ -675,6 +686,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .settingFeature, dependencies: [ + .module(.sfSafeSymbolsExt), .module(.appComponents), .module(.appDelegateClient), .module(.appModels), @@ -763,6 +775,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .detailFeature, dependencies: [ + .module(.sfSafeSymbolsExt), .module(.appComponents), .module(.appLaunchAutomationClient), .module(.appModels), @@ -795,6 +808,7 @@ let targets: [PackageDescription.Target] = [ .target( module: .readingFeature, dependencies: [ + .module(.sfSafeSymbolsExt), .module(.appComponents), .module(.appDelegateClient), .module(.appModels), diff --git a/AppPackage/Sources/AppComponents/ToolbarItems.swift b/AppPackage/Sources/AppComponents/ToolbarItems.swift index 9e13a4b09..67d8d8df6 100644 --- a/AppPackage/Sources/AppComponents/ToolbarItems.swift +++ b/AppPackage/Sources/AppComponents/ToolbarItems.swift @@ -2,6 +2,7 @@ import SwiftUI import SFSafeSymbols import AppModels import Resources +import SFSafeSymbolsExt public struct CustomToolbarItem: ToolbarContent { private let placement: ToolbarItemPlacement @@ -122,7 +123,7 @@ public struct DateSeekButton: View { Button { navigation.map(action) } label: { - Label(String(localized: .RLocalizable.dateSeek), systemSymbol: .calendar) + Label(.RLocalizable.dateSeek, systemSymbol: .calendar) } .disabled(navigation == nil) } diff --git a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift index 2fd2e8994..0b5f7a010 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift @@ -3,6 +3,7 @@ import Resources import ComposableArchitecture import AppTools import AppComponents +import SFSafeSymbolsExt // MARK: ToolBar extension DetailView { @@ -12,7 +13,7 @@ extension DetailView { Button { store.send(.archivesButtonTapped) } label: { - Label(String(localized: .archivesAction), systemSymbol: .zipperPage) + Label(.archivesAction, systemSymbol: .zipperPage) } .disabled(store.galleryDetail?.archiveURL == nil || !CookieUtil.didLogin) Button { @@ -29,7 +30,7 @@ extension DetailView { store.send(.shareButtonTapped(galleryURL)) } } label: { - Label(String(localized: .RLocalizable.share), systemSymbol: .squareAndArrowUp) + Label(.RLocalizable.share, systemSymbol: .squareAndArrowUp) } } .disabled(store.galleryDetail == nil || store.loadingState == .loading) diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index faa9ca73a..67ff91a10 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -7,6 +7,7 @@ import AppTools import AppComponents import ReadingFeature import DetailFeature +import SFSafeSymbolsExt public struct DownloadsView: View { @Bindable private var store: StoreOf @@ -190,7 +191,7 @@ private extension DownloadsView { Button(role: .destructive) { store.send(.deleteDownloadButtonTapped(download)) } label: { - Label(String(localized: .RLocalizable.delete), systemSymbol: .trash) + Label(.RLocalizable.delete, systemSymbol: .trash) } } } @@ -262,7 +263,7 @@ private extension DownloadsView { Button(role: .destructive) { store.send(.deleteDownloadButtonTapped(download)) } label: { - Label(String(localized: .RLocalizable.delete), systemSymbol: .trash) + Label(.RLocalizable.delete, systemSymbol: .trash) } } diff --git a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift index abc0e9429..252cefccf 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift @@ -9,6 +9,7 @@ import AppTools import ImageClient import AppComponents import SFSafeSymbols +import SFSafeSymbolsExt // MARK: ImageStackConfig struct ImageStackConfig { @@ -144,18 +145,18 @@ struct HorizontalImageStack: View { Button { refetchAction(index) } label: { - Label(String(localized: .reload), systemSymbol: .arrowCounterclockwise) + Label(.reload, systemSymbol: .arrowCounterclockwise) } if let imageURL = imageURLs[index] { Button { copyImageAction(imageURL) } label: { - Label(String(localized: .copy), systemSymbol: .plusSquareOnSquare) + Label(.copy, systemSymbol: .plusSquareOnSquare) } Button { saveImageAction(imageURL) } label: { - Label(String(localized: .save), systemSymbol: .squareAndArrowDown) + Label(.save, systemSymbol: .squareAndArrowDown) } if let originalImageURL = originalImageURLs[index] { Button { @@ -170,7 +171,7 @@ struct HorizontalImageStack: View { Button { shareImageAction(imageURL) } label: { - Label(String(localized: .RLocalizable.share), systemSymbol: .squareAndArrowUp) + Label(.RLocalizable.share, systemSymbol: .squareAndArrowUp) } } } diff --git a/AppPackage/Sources/SFSafeSymbolsExt/.swiftlint.yml b/AppPackage/Sources/SFSafeSymbolsExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/SFSafeSymbolsExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/SFSafeSymbolsExt/SFSafeSymbols+LocalizedStringResource.swift b/AppPackage/Sources/SFSafeSymbolsExt/SFSafeSymbols+LocalizedStringResource.swift new file mode 100644 index 000000000..d53026f0f --- /dev/null +++ b/AppPackage/Sources/SFSafeSymbolsExt/SFSafeSymbols+LocalizedStringResource.swift @@ -0,0 +1,16 @@ +import SwiftUI +import SFSafeSymbols + +public extension Label where Title == Text, Icon == Image { + /// Builds a `Label` from a `LocalizedStringResource` title and an `SFSymbol` icon, + /// composing `Text` and `Image(systemSymbol:)` directly so no system-name string + /// parameter is needed. This is the initializer the `label_text_image_shorthand` + /// lint rule expects call sites to use. + nonisolated init(_ titleResource: LocalizedStringResource, systemSymbol: SFSymbol) { + self.init { + Text(titleResource) + } icon: { + Image(systemSymbol: systemSymbol) + } + } +} diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift index a62a15051..880c56570 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -4,6 +4,7 @@ import AppModels import AppComponents import SFSafeSymbols import ComposableArchitecture +import SFSafeSymbolsExt struct AppActivityLogsView: View { @Bindable private var store: StoreOf @@ -63,7 +64,7 @@ struct AppActivityLogsView: View { Button { store.send(.navigateToFileApp) } label: { - Label(String(localized: .appActivityLogsViewOpenInFiles), systemSymbol: .folderBadgeGearshape) + Label(.appActivityLogsViewOpenInFiles, systemSymbol: .folderBadgeGearshape) } } } @@ -97,7 +98,7 @@ struct AppActivityLogsView: View { Button { isRunPickerPresented = true } label: { - Label(String(localized: .appActivityLogsViewMoreLogs), systemSymbol: .ellipsisCalendar) + Label(.appActivityLogsViewMoreLogs, systemSymbol: .ellipsisCalendar) } } } From a91c1372eec45031c7591084b21ff87af8ab29d4 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 02:56:29 +0800 Subject: [PATCH 471/614] Drop redundant String(localized:) in view APIs Remove 78 String(localized:) wrappers where the SwiftUI API already has a LocalizedStringResource overload: Text, .navigationTitle, .accessibilityLabel/Value/Hint, .help. Each now passes the resource directly (Text(.symbol), .navigationTitle(.symbol)). Build + 308 tests + full SwiftLint green. --- AppPackage/Sources/AppComponents/SubSection.swift | 2 +- .../Sources/AppComponents/TagSuggestionView.swift | 2 +- AppPackage/Sources/AppComponents/ToolbarItems.swift | 6 +++--- .../Sources/DateSeekFeature/DateSeekPickerView.swift | 4 ++-- .../DetailFeature/Archives/ArchivesView.swift | 4 ++-- .../DetailFeature/Comments/CommentsView.swift | 2 +- .../DetailFeature/DetailView+CommentCells.swift | 2 +- .../DetailFeature/DetailView+HeaderSection.swift | 4 ++-- .../Sources/DetailFeature/DetailView+Subviews.swift | 12 ++++++------ .../FolderManager/FolderManagerView.swift | 2 +- .../GalleryInfos/GalleryInfosView.swift | 2 +- .../DetailFeature/Previews/PreviewsView.swift | 2 +- .../DetailFeature/Torrents/TorrentsView.swift | 2 +- .../DownloadsFeature/DownloadsView+Subviews.swift | 2 +- .../Sources/DownloadsFeature/DownloadsView.swift | 2 +- AppPackage/Sources/FiltersFeature/FiltersView.swift | 8 ++++---- .../HomeFeature/Frontpage/FrontpageView.swift | 2 +- .../Sources/HomeFeature/History/HistoryView.swift | 2 +- AppPackage/Sources/HomeFeature/HomeView.swift | 2 +- .../Sources/HomeFeature/Popular/PopularView.swift | 2 +- .../Sources/HomeFeature/Watched/WatchedView.swift | 2 +- .../Sources/QuickSearchFeature/QuickSearchView.swift | 2 +- .../ReadingFeature/Support/ControlPanel.swift | 12 ++++++------ .../ReadingSettingFeature/ReadingSettingView.swift | 4 ++-- .../Sources/SearchFeature/SearchRootView.swift | 2 +- .../AccountSetting/AccountSettingView.swift | 2 +- .../AppActivityLogs/AppActivityLogsView.swift | 6 +++--- .../AppearanceSetting/AppearanceSettingView.swift | 8 ++++---- .../SettingFeature/Components/AboutView.swift | 4 ++-- .../Components/DownloadSettingView.swift | 6 +++--- .../Components/LaboratorySettingView.swift | 2 +- .../EhSetting/EhSettingView+Sections1.swift | 12 ++++++------ .../EhSetting/EhSettingView+Sections2.swift | 4 ++-- .../EhSetting/EhSettingView+Sections3.swift | 8 ++++---- .../SettingFeature/EhSetting/EhSettingView.swift | 2 +- .../GeneralSetting/GeneralSettingView.swift | 10 +++++----- .../Sources/SettingFeature/Login/LoginView.swift | 2 +- AppPackage/Sources/SettingFeature/SettingView.swift | 2 +- 38 files changed, 78 insertions(+), 78 deletions(-) diff --git a/AppPackage/Sources/AppComponents/SubSection.swift b/AppPackage/Sources/AppComponents/SubSection.swift index 29cae5437..fa816b2f7 100644 --- a/AppPackage/Sources/AppComponents/SubSection.swift +++ b/AppPackage/Sources/AppComponents/SubSection.swift @@ -45,7 +45,7 @@ public struct SubSection: View { .foregroundColor(.primary) Spacer() Button(action: showAllAction) { - Text(String(localized: .showAll)).font(.subheadline) + Text(.showAll).font(.subheadline) } .tint(tint).opacity(showAll ? 1 : 0) } diff --git a/AppPackage/Sources/AppComponents/TagSuggestionView.swift b/AppPackage/Sources/AppComponents/TagSuggestionView.swift index 74a348796..491bf2d85 100644 --- a/AppPackage/Sources/AppComponents/TagSuggestionView.swift +++ b/AppPackage/Sources/AppComponents/TagSuggestionView.swift @@ -25,7 +25,7 @@ public struct TagSuggestionView: View { public var body: some View { if isEnabled { if DeviceUtil.isPhone { - Text(String(localized: .matchesCount(translationHandler.suggestions.count))) + Text(.matchesCount(translationHandler.suggestions.count)) .foregroundColor(.secondary) .font(.subheadline) } diff --git a/AppPackage/Sources/AppComponents/ToolbarItems.swift b/AppPackage/Sources/AppComponents/ToolbarItems.swift index 67d8d8df6..54be85ae3 100644 --- a/AppPackage/Sources/AppComponents/ToolbarItems.swift +++ b/AppPackage/Sources/AppComponents/ToolbarItems.swift @@ -63,7 +63,7 @@ public struct FiltersButton: View { Button(action: action) { Image(systemSymbol: .line3HorizontalDecrease) if !hideText { - Text(String(localized: .RLocalizable.filters)) + Text(.RLocalizable.filters) } } } @@ -82,7 +82,7 @@ public struct QuickSearchButton: View { Button(action: action) { Image(systemSymbol: .magnifyingglass) if !hideText { - Text(String(localized: .RLocalizable.quickSearch)) + Text(.RLocalizable.quickSearch) } } } @@ -103,7 +103,7 @@ public struct JumpPageButton: View { Button(action: action) { Image(systemSymbol: .arrowshapeBounceForward) if !hideText { - Text(String(localized: .RLocalizable.jumpPage)) + Text(.RLocalizable.jumpPage) } } .disabled(pageNumber.isSinglePage) diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift index 77b41d195..06bc236e5 100644 --- a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift @@ -40,7 +40,7 @@ public struct DateSeekPickerView: View { ) .datePickerStyle(.graphical) } footer: { - Text(String(localized: .seekAroundDate)) + Text(.seekAroundDate) } Section { @@ -80,7 +80,7 @@ public struct DateSeekPickerView: View { .listRowInsets(.init()) } } - .navigationTitle(String(localized: .RLocalizable.dateSeek)) + .navigationTitle(.RLocalizable.dateSeek) .navigationBarTitleDisplayMode(.large) } } diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift index 6712eaa67..f2610a682 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift @@ -63,7 +63,7 @@ struct ArchivesView: View { .onAppear { store.send(.fetchArchive(gid, galleryURL, archiveURL)) } - .navigationTitle(String(localized: .archives)) + .navigationTitle(.archives) } } } @@ -205,7 +205,7 @@ private struct DownloadButton: View { } var body: some View { - Text(String(localized: .downloadToHathClient)) + Text(.downloadToHathClient) .font(.headline) .foregroundStyle(textColor) .frame(maxWidth: .infinity) diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index 37e69df87..3d5917a09 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -117,7 +117,7 @@ struct CommentsView: View { store.send(.onAppear) } .toolbar(content: toolbar) - .navigationTitle(String(localized: .comments)) + .navigationTitle(.comments) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift b/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift index 48f87ffa5..bc261847b 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift @@ -52,7 +52,7 @@ struct CommentButton: View { Button(action: action) { HStack { Image(systemSymbol: .squareAndPencil) - Text(String(localized: .postComment)) + Text(.postComment) .bold() } .padding() diff --git a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift index 59e9f4b4d..f3fde29b4 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift @@ -107,7 +107,7 @@ struct HeaderSection: View { } Section { if downloadFolders.isEmpty { - Text(String(localized: .noFolders)) + Text(.noFolders) } else { ForEach(downloadFolders, id: \.self) { folder in Button { @@ -181,7 +181,7 @@ struct HeaderSection: View { } .buttonStyle(.glassProminent) .buttonBorderShape(.circle) - .accessibilityLabel(String(localized: .read)) + .accessibilityLabel(.read) } private func progressIndicator( progress: Double, isDeterminate: Bool, centerSymbol: SFSymbol diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index 9e576c7b1..aa3e8a116 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -122,14 +122,14 @@ struct ActionSection: View { Button(action: showUserRatingAction) { Spacer() Image(systemSymbol: .squareAndPencil) - Text(String(localized: .giveARating)).bold() + Text(.giveARating).bold() Spacer() } .disabled(!CookieUtil.didLogin) Button(action: navigateSimilarGalleryAction) { Spacer() Image(systemSymbol: .photoOnRectangleAngled) - Text(String(localized: .similarGallery)).bold() + Text(.similarGallery).bold() Spacer() } } @@ -242,7 +242,7 @@ extension TagsSection { )) } label: { Image(systemSymbol: .richtextPage) - Text(String(localized: .RLocalizable.detail)) + Text(.RLocalizable.detail) } } if CookieUtil.didLogin { @@ -258,20 +258,20 @@ extension TagsSection { } label: { Image(systemSymbol: content.isVotedUp ? .handThumbsup : .handThumbsdown) .symbolVariant(.fill) - Text(String(localized: .withdrawVote)) + Text(.withdrawVote) } } else { Button { voteTagAction(content.voteKeyword(tag: tag), 1) } label: { Image(systemSymbol: .handThumbsup) - Text(String(localized: .voteUp)) + Text(.voteUp) } Button { voteTagAction(content.voteKeyword(tag: tag), -1) } label: { Image(systemSymbol: .handThumbsdown) - Text(String(localized: .voteDown)) + Text(.voteDown) } } } diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift index 9d880246e..8aa4159c4 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift @@ -52,7 +52,7 @@ public struct FolderManagerView: View { store.send(.fetchFolders) } .toolbar(content: toolbar) - .navigationTitle(String(localized: .folders)) + .navigationTitle(.folders) .navigationBarTitleDisplayMode(.inline) } } diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index 7247442a1..8a30d8651 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -112,7 +112,7 @@ struct GalleryInfosView: View { } } .toast($store.scope(state: \.toast, action: \.toast)) - .navigationTitle(String(localized: .metadataGalleryInfos)) + .navigationTitle(.metadataGalleryInfos) } } diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift index 964431cd7..21b918e61 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift @@ -76,7 +76,7 @@ struct PreviewsView: View { .onAppear { store.send(.fetchDatabaseInfos(gid)) } - .navigationTitle(String(localized: .previews)) + .navigationTitle(.previews) } } diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift index 937d8d19c..91476e857 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift @@ -52,7 +52,7 @@ struct TorrentsView: View { .onAppear { store.send(.fetchGalleryTorrents(gid, token)) } - .navigationTitle(String(localized: .torrents)) + .navigationTitle(.torrents) } } } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index 1464ad0ee..be9a6cb81 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -113,7 +113,7 @@ struct DownloadInspectorView: View { } .autoBlur(radius: blurRadius) .toast($store.scope(state: \.toast, action: \.toast)) - .navigationTitle(String(localized: .downloadStatus)) + .navigationTitle(.downloadStatus) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index 67ff91a10..170f064d8 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -106,7 +106,7 @@ public struct DownloadsView: View { .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) - .navigationTitle(String(localized: .RLocalizable.downloads)) + .navigationTitle(.RLocalizable.downloads) .navigationBarTitleDisplayMode(.large) .toolbar(content: toolbar) } diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index ccc3c70d6..9fbf0fc85 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -39,7 +39,7 @@ public struct FiltersView: View { ) } .synchronize($store.focusedBound, $focusedBound) - .navigationTitle(String(localized: .RLocalizable.filters)) + .navigationTitle(.RLocalizable.filters) .onAppear { store.send(.fetchFilters) } } } @@ -79,7 +79,7 @@ private struct BasicSection: View { .pickerStyle(.segmented) CategoryView(bindings: categoryBindings) Button(action: resetFiltersDialogAction) { - Text(String(localized: .resetFilters)).foregroundStyle(.red) + Text(.resetFilters).foregroundStyle(.red) } .confirmationDialog(confirmationDialog) Toggle(String(localized: .advancedSettings), isOn: $filter.advanced) @@ -153,7 +153,7 @@ private struct MinimumRatingSetter: View { var body: some View { Picker(String(localized: .minimumRating), selection: $minimum) { ForEach(Array(2...5), id: \.self) { number in - Text(String(localized: .RLocalizable.stars("\(number)"))).tag(number) + Text(.RLocalizable.stars("\(number)")).tag(number) } } .pickerStyle(.menu) @@ -181,7 +181,7 @@ private struct PagesRangeSetter: View { var body: some View { HStack { - Text(String(localized: .pagesRange)) + Text(.pagesRange) Spacer() SettingTextField(text: $lowerBound) .focused(focusedBound, equals: .lower) diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 87e8913dd..de52c75c7 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -68,7 +68,7 @@ struct FrontpageView: View { } } .toolbar(content: toolbar) - .navigationTitle(String(localized: .frontpage)) + .navigationTitle(.frontpage) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index b39b82292..b87b42862 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -49,7 +49,7 @@ struct HistoryView: View { } } .toolbar(content: toolbar) - .navigationTitle(String(localized: .history)) + .navigationTitle(.history) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/HomeFeature/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift index fdf5dfa32..ad926ef0e 100644 --- a/AppPackage/Sources/HomeFeature/HomeView.swift +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -91,7 +91,7 @@ public struct HomeView: View { } } .toolbar(content: toolbar) - .navigationTitle(String(localized: .RLocalizable.home)) + .navigationTitle(.RLocalizable.home) } destination: { store in switch store.case { case .frontpage(let store): diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index cf86e71cd..3df4777f5 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -53,7 +53,7 @@ struct PopularView: View { } } .toolbar(content: toolbar) - .navigationTitle(String(localized: .popular)) + .navigationTitle(.popular) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index bc8749054..20bbed557 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -96,7 +96,7 @@ struct WatchedView: View { } } .toolbar(content: toolbar) - .navigationTitle(String(localized: .watched)) + .navigationTitle(.watched) } private func toolbar() -> some ToolbarContent { diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index 6da76d8e7..ca4cb301b 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -81,7 +81,7 @@ public struct QuickSearchView: View { } .toolbar(content: toolbar) .navigationDestination(item: $store.editKind) { editWordView(for: $0) } - .navigationTitle(String(localized: .RLocalizable.quickSearch)) + .navigationTitle(.RLocalizable.quickSearch) } } diff --git a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift index 99d8ac405..293dd2c5b 100644 --- a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift +++ b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift @@ -151,7 +151,7 @@ private struct UpperPanel: View { Button { setting.enablesDualPageMode.toggle() } label: { - Text(String(localized: .dualPageMode)) + Text(.dualPageMode) if setting.enablesDualPageMode { Image(systemSymbol: .checkmark) } @@ -159,7 +159,7 @@ private struct UpperPanel: View { Button { setting.exceptCover.toggle() } label: { - Text(String(localized: .exceptTheCover)) + Text(.exceptTheCover) if setting.exceptCover { Image(systemSymbol: .checkmark) } @@ -173,7 +173,7 @@ private struct UpperPanel: View { } Menu { - Text(String(localized: .autoPlay)).foregroundColor(.secondary) + Text(.autoPlay).foregroundColor(.secondary) ForEach(AutoPlayPolicy.allCases) { policy in Button { autoPlayPolicy = policy @@ -193,15 +193,15 @@ private struct UpperPanel: View { ToolbarFeaturesMenu { Button(action: retryAllFailedImagesAction) { Image(systemSymbol: .exclamationmarkArrowTrianglehead2ClockwiseRotate90) - Text(String(localized: .retryAllFailedImages)) + Text(.retryAllFailedImages) } Button(action: reloadAllImagesAction) { Image(systemSymbol: .arrowCounterclockwise) - Text(String(localized: .reloadAllImages)) + Text(.reloadAllImages) } Button(action: navigateSettingAction) { Image(systemSymbol: .gear) - Text(String(localized: .readingSetting)) + Text(.readingSetting) } } .buttonStyle(.borderless) diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index abd2944ef..2cea33cd9 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -35,7 +35,7 @@ public struct ReadingSettingView: View { .pickerStyle(.menu) Picker(String(localized: .preloadLimit), selection: $prefetchLimit) { ForEach(Array(stride(from: 6, through: 18, by: 4)), id: \.self) { value in - Text(String(localized: .RLocalizable.pages("\(value)"))).tag(value) + Text(.RLocalizable.pages("\(value)")).tag(value) } } .pickerStyle(.menu) @@ -66,7 +66,7 @@ public struct ReadingSettingView: View { ) } } - .navigationTitle(String(localized: .reading)) + .navigationTitle(.reading) } } diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index 4e1a91e61..b3654f05f 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -77,7 +77,7 @@ public struct SearchRootView: View { store.send(.fetchDatabaseInfos) } .toolbar(content: toolbar) - .navigationTitle(String(localized: .RLocalizable.search)) + .navigationTitle(.RLocalizable.search) // Workaround: Prevent the title disappearing issue. if store.historyKeywords.isEmpty && store.historyGalleries.isEmpty { diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index 7f7677a3f..41743c9c4 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -60,7 +60,7 @@ struct AccountSettingView: View { .autoBlur(radius: blurRadius) } .onAppear { store.send(.loadCookies) } - .navigationTitle(String(localized: .account)) + .navigationTitle(.account) } } diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift index 880c56570..c7bd8f83d 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -27,7 +27,7 @@ struct AppActivityLogsView: View { LoadingView() .opacity(store.loadingState == .loading && store.displayedLogs.isEmpty ? 1 : 0) - Text(String(localized: .appActivityLogsViewNoLogs)) + Text(.appActivityLogsViewNoLogs) .foregroundColor(.secondary) .opacity(store.loadingState != .loading && store.displayedLogs.isEmpty ? 1 : 0) } @@ -44,7 +44,7 @@ struct AppActivityLogsView: View { store.send(.refreshAvailableRuns) } .toolbar(content: toolbar) - .navigationTitle(String(localized: .appActivityLogsViewTitle)) + .navigationTitle(.appActivityLogsViewTitle) .navigationBarTitleDisplayMode(.large) .sheet(isPresented: $isRunPickerPresented) { RunPickerSheet(store: store) { isRunPickerPresented = false } @@ -136,7 +136,7 @@ private struct RunPickerSheet: View { } } } - .navigationTitle(String(localized: .appActivityLogsViewRuns)) + .navigationTitle(.appActivityLogsViewRuns) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift index ae8f58228..6fddef30d 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -71,14 +71,14 @@ struct AppearanceSettingView: View { .pickerStyle(.menu) Toggle(isOn: $showsTagsInList) { - Text(String(localized: .showsTagsInList)) + Text(.showsTagsInList) } Picker( String(localized: .maximumNumberOfTags), selection: $listTagsNumberMaximum ) { - Text(String(localized: .infite)) + Text(.infite) .tag(0) ForEach(Array(stride(from: 5, through: 20, by: 5)), id: \.self) { num in @@ -96,7 +96,7 @@ struct AppearanceSettingView: View { ) } } - .navigationTitle(String(localized: .appearance)) + .navigationTitle(.appearance) } } @@ -122,7 +122,7 @@ struct AppIconView: View { } } } - .navigationTitle(String(localized: .appIcon)) + .navigationTitle(.appIcon) } } diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index fe7cc91df..2b556099b 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -41,11 +41,11 @@ struct AboutView: View { } } } - .navigationTitle(String(localized: .ehPanda)) + .navigationTitle(.ehPanda) .toolbar { ToolbarItem(placement: .largeSubtitle) { VStack(alignment: .leading) { - Text(String(localized: .Constant.copyright)) + Text(.Constant.copyright) Text(version) } .foregroundStyle(.gray) diff --git a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift index 109b6d45c..1dd417ea4 100644 --- a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift @@ -38,12 +38,12 @@ struct DownloadSettingView: View { isOn: $downloadAllowCellular ) } header: { - Text(String(localized: .network)) + Text(.network) } footer: { - Text(String(localized: .networkDescription)) + Text(.networkDescription) } } - .navigationTitle(String(localized: .title)) + .navigationTitle(.title) } private var downloadThreadLimitValue: Binding { diff --git a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift index 4d50757d0..c623131c8 100644 --- a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift @@ -21,7 +21,7 @@ struct LaboratorySettingView: View { } .padding() } - .navigationTitle(String(localized: .laboratory)) + .navigationTitle(.laboratory) } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift index 88d13041d..22f0fbf56 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -42,7 +42,7 @@ struct EhProfileSection: View { .confirmationDialog(deleteConfirmationDialog) } } header: { - Text(String(localized: .profileSettings)) + Text(.profileSettings) .ehSettingRegularHeaderStyled() } .onChange(of: ehProfile) { _, newValue in @@ -137,13 +137,13 @@ struct ImageSizeSettingsSection: View { isOn: useOriginalImagesBinding ) } header: { - Text(String(localized: .originalImages)) + Text(.originalImages) .ehSettingRegularHeaderStyled() } } Section { - Text(String(localized: .imageSize)) + Text(.imageSize) ValuePicker( title: String(localized: .horizontal), @@ -155,7 +155,7 @@ struct ImageSizeSettingsSection: View { value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px" ) } header: { - Text(String(localized: .imageSizeDescription)) + Text(.imageSizeDescription) .ehSettingRegularHeaderStyled() } } @@ -232,7 +232,7 @@ struct FrontPageSettingsSection: View { } .pickerStyle(.menu) } header: { - Text(String(localized: .displayModeDescription)) + Text(.displayModeDescription) .ehSettingRegularHeaderStyled() } @@ -242,7 +242,7 @@ struct FrontPageSettingsSection: View { isOn: $ehSetting.showSearchRangeIndicator ) } header: { - Text(String(localized: .showSearchRangeIndicator)) + Text(.showSearchRangeIndicator) .ehSettingRegularHeaderStyled() } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift index fc738f922..641457546 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift @@ -68,7 +68,7 @@ struct FavoritesSection: View { } .pickerStyle(.menu) } header: { - Text(String(localized: .favoritesSortOrderDescription)) + Text(.favoritesSortOrderDescription) .ehSettingRegularHeaderStyled() } } @@ -174,7 +174,7 @@ struct ThumbnailSettingsSection: View { .frame(width: 200) } } header: { - Text(String(localized: .thumbnailConfiguration)) + Text(.thumbnailConfiguration) .ehSettingRegularHeaderStyled() } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift index 169076712..beea762ff 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -251,7 +251,7 @@ struct GalleryCommentsSection: View { } .pickerStyle(.menu) } header: { - Text(String(localized: .galleryComments)) + Text(.galleryComments) .ehSettingRegularHeaderStyled() } } @@ -271,7 +271,7 @@ struct GalleryTagsSection: View { } .pickerStyle(.menu) } header: { - Text(String(localized: .galleryTags)) + Text(.galleryTags) .ehSettingRegularHeaderStyled() } } @@ -294,7 +294,7 @@ struct GalleryPageThumbnailLabelingSection: View { } .pickerStyle(.menu) } header: { - Text(String(localized: .galleryPageThumbnailLabeling)) + Text(.galleryPageThumbnailLabeling) .ehSettingRegularHeaderStyled() } } @@ -330,7 +330,7 @@ struct MultiplePageViewerSection: View { isOn: multiplePageViewerShowPaneBinding ) } header: { - Text(String(localized: .multiPageViewer)) + Text(.multiPageViewer) .ehSettingRegularHeaderStyled() } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift index 1b60eda6e..d60d19a38 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift @@ -53,7 +53,7 @@ struct EhSettingView: View { .autoBlur(radius: blurRadius) } .toolbar(content: toolbar) - .navigationTitle(String(localized: .hostSettings(galleryHost.rawValue))) + .navigationTitle(.hostSettings(galleryHost.rawValue)) } // MARK: Form private func form(ehSetting: Binding, ehProfile: Binding) -> some View { diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index 2ae712e60..0f52789c3 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -51,7 +51,7 @@ struct GeneralSettingView: View { Form { Section { HStack { - Text(String(localized: .RLocalizable.language)) + Text(.RLocalizable.language) Spacer() Button(language) { store.send(.navigateToSystemSetting) @@ -65,7 +65,7 @@ struct GeneralSettingView: View { } Section(String(localized: .tags)) { HStack { - Text(String(localized: .enablesTagsExtension)) + Text(.enablesTagsExtension) .frame(maxWidth: .infinity, alignment: .leading) ZStack { @@ -139,7 +139,7 @@ struct GeneralSettingView: View { } } VStack(alignment: .leading) { - Text(String(localized: .backgroundBlurRadius)) + Text(.backgroundBlurRadius) HStack { Image(systemSymbol: .eye) Slider(value: $backgroundBlurRadius, in: 0...100, step: 10) @@ -152,7 +152,7 @@ struct GeneralSettingView: View { store.send(.clearImageCachesButtonTapped) } label: { HStack { - Text(String(localized: .clearImageCaches)) + Text(.clearImageCaches) Spacer() Text(store.diskImageCacheSize).foregroundStyle(.tint) } @@ -171,7 +171,7 @@ struct GeneralSettingView: View { store.send(.checkPasscodeSetting) store.send(.calculateWebImageDiskCache) } - .navigationTitle(String(localized: .general)) + .navigationTitle(.general) } } diff --git a/AppPackage/Sources/SettingFeature/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift index e055a0d69..02a225df9 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginView.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginView.swift @@ -86,7 +86,7 @@ struct LoginView: View { } .animation(.default, value: store.loginState) .toolbar(content: toolbar) - .navigationTitle(String(localized: .RLocalizable.login)) + .navigationTitle(.RLocalizable.login) .ignoresSafeArea() } // MARK: Toolbar diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index f9d6e12b6..86b027e13 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -27,7 +27,7 @@ public struct SettingView: View { } .padding(.vertical, 40).padding(.horizontal) } - .navigationTitle(String(localized: .RLocalizable.setting)) + .navigationTitle(.RLocalizable.setting) } destination: { pathStore in destination(pathStore) .tint(store.setting.accentColor) From 91bde55750f8e0ab796cb8d7e41f34835ae4f56d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 03:02:36 +0800 Subject: [PATCH 472/614] Drop String(localized:) in labeled view controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove 63 more String(localized:) wrappers now passing the resource directly to Button, Section, Toggle, Picker, LabeledContent, TextField, ColorPicker — all have LocalizedStringResource title overloads on iOS 26. Remaining String(localized:) call sites are genuine String contexts (TextState, error-case payloads, comparisons). Build + 308 tests + full SwiftLint green. --- .../Sources/DetailFeature/DetailView.swift | 2 +- .../DownloadsView+Subviews.swift | 2 +- .../Sources/FiltersFeature/FiltersView.swift | 32 +++++++++---------- .../QuickSearchFeature/QuickSearchView.swift | 6 ++-- .../ReadingSettingView.swift | 8 ++--- .../AccountSetting/AccountSettingView.swift | 8 ++--- .../AppActivityLogs/AppActivityLogsView.swift | 4 +-- .../AppearanceSettingView.swift | 8 ++--- .../SettingFeature/Components/AboutView.swift | 8 ++--- .../Components/DownloadSettingView.swift | 2 +- .../EhSetting/EhSettingView+Sections1.swift | 18 +++++------ .../EhSetting/EhSettingView+Sections2.swift | 8 ++--- .../EhSetting/EhSettingView+Sections3.swift | 2 +- .../EhSetting/EhSettingView.swift | 2 +- .../GeneralSetting/GeneralSettingView.swift | 16 +++++----- 15 files changed, 63 insertions(+), 63 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 02de97a3e..2e159438b 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -298,7 +298,7 @@ private extension DetailView { .font(.subheadline.weight(.semibold)) .foregroundStyle(.orange) if error.isRetryable != false { - Button(String(localized: .RLocalizable.retry)) { + Button(.RLocalizable.retry) { store.send(.fetchGalleryDetail) } .buttonStyle(.glass) diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index be9a6cb81..64954095d 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -72,7 +72,7 @@ struct DownloadInspectorView: View { let isRetryFailedPagesDisabled = !inspection.canRetryFailedPages let isValidateImageDataDisabled = !inspection.canValidateImageData || store.isValidatingImageData - Section(String(localized: .actions)) { + Section(.actions) { Button { store.send(.toggleDownloadPause) } label: { diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index 9fbf0fc85..f0847c5c8 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -82,7 +82,7 @@ private struct BasicSection: View { Text(.resetFilters).foregroundStyle(.red) } .confirmationDialog(confirmationDialog) - Toggle(String(localized: .advancedSettings), isOn: $filter.advanced) + Toggle(.advancedSettings, isOn: $filter.advanced) } } } @@ -105,24 +105,24 @@ private struct AdvancedSection: View { var body: some View { Group { - Section(String(localized: .advanced)) { - Toggle(String(localized: .searchGalleryName), isOn: $filter.galleryName) - Toggle(String(localized: .searchGalleryTags), isOn: $filter.galleryTags) - Toggle(String(localized: .searchGalleryDescription), isOn: $filter.galleryDesc) - Toggle(String(localized: .searchTorrentFilenames), isOn: $filter.torrentFilenames) + Section(.advanced) { + Toggle(.searchGalleryName, isOn: $filter.galleryName) + Toggle(.searchGalleryTags, isOn: $filter.galleryTags) + Toggle(.searchGalleryDescription, isOn: $filter.galleryDesc) + Toggle(.searchTorrentFilenames, isOn: $filter.torrentFilenames) Toggle( String(localized: .onlyShowGalleriesWithTorrents), isOn: $filter.onlyWithTorrents ) - Toggle(String(localized: .searchLowPowerTags), isOn: $filter.lowPowerTags) - Toggle(String(localized: .searchDownvotedTags), isOn: $filter.downvotedTags) - Toggle(String(localized: .searchExpungedGalleries), isOn: $filter.expungedGalleries) + Toggle(.searchLowPowerTags, isOn: $filter.lowPowerTags) + Toggle(.searchDownvotedTags, isOn: $filter.downvotedTags) + Toggle(.searchExpungedGalleries, isOn: $filter.expungedGalleries) } Section { - Toggle(String(localized: .setMinimumRating), isOn: $filter.minRatingActivated) + Toggle(.setMinimumRating, isOn: $filter.minRatingActivated) MinimumRatingSetter(minimum: $filter.minRating) .disabled(!filter.minRatingActivated) - Toggle(String(localized: .setPagesRange), isOn: $filter.pageRangeActivated) + Toggle(.setPagesRange, isOn: $filter.pageRangeActivated) .disabled(focusedBound.wrappedValue != nil) PagesRangeSetter( lowerBound: $filter.pageLowerBound, @@ -132,10 +132,10 @@ private struct AdvancedSection: View { ) .disabled(!filter.pageRangeActivated) } - Section(String(localized: .defaultFilter)) { - Toggle(String(localized: .disableLanguageFilter), isOn: $filter.disableLanguage) - Toggle(String(localized: .disableUploaderFilter), isOn: $filter.disableUploader) - Toggle(String(localized: .disableTagsFilter), isOn: $filter.disableTags) + Section(.defaultFilter) { + Toggle(.disableLanguageFilter, isOn: $filter.disableLanguage) + Toggle(.disableUploaderFilter, isOn: $filter.disableUploader) + Toggle(.disableTagsFilter, isOn: $filter.disableTags) } } .disabled(!filter.advanced) @@ -151,7 +151,7 @@ private struct MinimumRatingSetter: View { } var body: some View { - Picker(String(localized: .minimumRating), selection: $minimum) { + Picker(.minimumRating, selection: $minimum) { ForEach(Array(2...5), id: \.self) { number in Text(.RLocalizable.stars("\(number)")).tag(number) } diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index ca4cb301b..b6bfb9301 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -147,11 +147,11 @@ extension QuickSearchView { var body: some View { Form { - Section(String(localized: .name)) { - TextField(String(localized: .optional), text: $word.name) + Section(.name) { + TextField(.optional, text: $word.name) .submitLabel(.next).focused(focusedField, equals: .name) } - Section(String(localized: .content)) { + Section(.content) { TextEditor(text: $word.content) .disableAutocorrection(true) .textInputAutocapitalization(.never) diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index 2cea33cd9..6bc9ab852 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -27,23 +27,23 @@ public struct ReadingSettingView: View { public var body: some View { Form { Section { - Picker(String(localized: .direction), selection: $readingDirection) { + Picker(.direction, selection: $readingDirection) { ForEach(ReadingDirection.allCases) { Text($0.value).tag($0) } } .pickerStyle(.menu) - Picker(String(localized: .preloadLimit), selection: $prefetchLimit) { + Picker(.preloadLimit, selection: $prefetchLimit) { ForEach(Array(stride(from: 6, through: 18, by: 4)), id: \.self) { value in Text(.RLocalizable.pages("\(value)")).tag(value) } } .pickerStyle(.menu) if !DeviceUtil.isPad { - Toggle(String(localized: .enablesLandscape), isOn: $enablesLandscape) + Toggle(.enablesLandscape, isOn: $enablesLandscape) } } - Section(String(localized: .readingAppearance)) { + Section(.readingAppearance) { Picker( String(localized: .separatorHeight), selection: $contentDividerHeight diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index 41743c9c4..c656ff004 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -95,7 +95,7 @@ private struct AccountSection: View { var body: some View { if !CookieUtil.didLogin { - Button(String(localized: .RLocalizable.login), action: loginAction) + Button(.RLocalizable.login, action: loginAction) } else { Button( String(localized: .logout), @@ -115,7 +115,7 @@ private struct AccountSection: View { ) .withArrow() } - Toggle(String(localized: .showsNewDawnGreeting), isOn: $showsNewDawnGreeting) + Toggle(.showsNewDawnGreeting, isOn: $showsNewDawnGreeting) } .foregroundColor(.primary) } @@ -142,7 +142,7 @@ private struct CookieSection: View { Section(GalleryHost.ehentai.rawValue) { CookieRow(cookieState: $ehCookiesState.memberID) CookieRow(cookieState: $ehCookiesState.passHash) - Button(String(localized: .copyCookies)) { + Button(.copyCookies) { copyAction(.ehentai) } .foregroundStyle(.tint).font(.subheadline) @@ -151,7 +151,7 @@ private struct CookieSection: View { CookieRow(cookieState: $exCookiesState.igneous) CookieRow(cookieState: $exCookiesState.memberID) CookieRow(cookieState: $exCookiesState.passHash) - Button(String(localized: .copyCookies)) { + Button(.copyCookies) { copyAction(.exhentai) } .foregroundStyle(.tint).font(.subheadline) diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift index c7bd8f83d..75e5dd520 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -71,7 +71,7 @@ struct AppActivityLogsView: View { @ViewBuilder private var runMenu: some View { - Section(String(localized: .appActivityLogsViewCurrent)) { + Section(.appActivityLogsViewCurrent) { RunButton( run: store.currentRun, isSelected: store.selectedRun == nil @@ -112,7 +112,7 @@ private struct RunPickerSheet: View { var body: some View { NavigationStack { List { - Section(String(localized: .appActivityLogsViewCurrent)) { + Section(.appActivityLogsViewCurrent) { RunButton( run: store.currentRun, isSelected: store.selectedRun == nil diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift index 6fddef30d..ef0c95913 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -49,15 +49,15 @@ struct AppearanceSettingView: View { } .pickerStyle(.menu) - ColorPicker(String(localized: .tintColor), selection: $accentColor) + ColorPicker(.tintColor, selection: $accentColor) - Button(String(localized: .appIcon)) { + Button(.appIcon) { store.send(.delegate(.pushAppIcon)) } .foregroundStyle(.primary) .withArrow() } - Section(String(localized: .list)) { + Section(.list) { Picker( String(localized: .appearanceDisplayMode), selection: $listDisplayMode, @@ -89,7 +89,7 @@ struct AppearanceSettingView: View { .pickerStyle(.menu) .disabled(!showsTagsInList) } - Section(String(localized: .gallery)) { + Section(.gallery) { Toggle( String(localized: .displaysJapaneseTitle), isOn: $displaysJapaneseTitle diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index 2b556099b..6fda74b77 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -20,22 +20,22 @@ struct AboutView: View { LinkRow(urlString: contact.urlString, text: contact.text) } } - Section(String(localized: .specialThanks)) { + Section(.specialThanks) { ForEach(specialThanks) { specialThank in LinkRow(urlString: specialThank.urlString, text: specialThank.text) } } - Section(String(localized: .codeLevelContributors)) { + Section(.codeLevelContributors) { ForEach(codeLevelContributors) { codeLevelContributor in LinkRow(urlString: codeLevelContributor.urlString, text: codeLevelContributor.text) } } - Section(String(localized: .translationContributors)) { + Section(.translationContributors) { ForEach(translationContributors) { translationContributor in LinkRow(urlString: translationContributor.urlString, text: translationContributor.text) } } - Section(String(localized: .acknowledgements)) { + Section(.acknowledgements) { ForEach(acknowledgements) { acknowledgement in LinkRow(urlString: acknowledgement.urlString, text: acknowledgement.text) } diff --git a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift index 1dd417ea4..10970bb60 100644 --- a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift @@ -20,7 +20,7 @@ struct DownloadSettingView: View { Form { Section { VStack(alignment: .leading) { - LabeledContent(String(localized: .concurrentImageDownloads)) { + LabeledContent(.concurrentImageDownloads) { Text(downloadThreadLimit, format: .number) .monospacedDigit() } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift index 22f0fbf56..2bb615bc5 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -21,7 +21,7 @@ struct EhProfileSection: View { var body: some View { Section { - Picker(String(localized: .selectedProfile), selection: $ehProfile) { + Picker(.selectedProfile, selection: $ehProfile) { ForEach(ehSetting.ehProfiles) { ehProfile in Text(ehProfile.name) .tag(ehProfile) @@ -30,7 +30,7 @@ struct EhProfileSection: View { .pickerStyle(.menu) if !ehProfile.isDefault { - Button(String(localized: .setAsDefault)) { + Button(.setAsDefault) { performEhProfileAction(.default, nil, ehProfile.value) } @@ -53,13 +53,13 @@ struct EhProfileSection: View { SettingTextField(text: $editingProfileName, width: nil, alignment: .leading, background: .clear) .focused($isFocused) - Button(String(localized: .rename)) { + Button(.rename) { performEhProfileAction(.rename, editingProfileName, ehProfile.value) } .disabled(isFocused) if ehSetting.isCapableOfCreatingNewProfile { - Button(String(localized: .createNew)) { + Button(.createNew) { performEhProfileAction(.create, editingProfileName, ehProfile.value) } .disabled(isFocused) @@ -91,7 +91,7 @@ struct ImageLoadSettingsSection: View { } Section { - Picker(String(localized: .browsingCountry), selection: $ehSetting.browsingCountry) { + Picker(.browsingCountry, selection: $ehSetting.browsingCountry) { ForEach(EhSetting.BrowsingCountry.allCases) { country in Text(country.name) .tag(country) @@ -116,7 +116,7 @@ struct ImageSizeSettingsSection: View { var body: some View { Section { - Picker(String(localized: .imageResolution), selection: $ehSetting.imageResolution) { + Picker(.imageResolution, selection: $ehSetting.imageResolution) { ForEach(ehSetting.capableImageResolutions) { setting in Text(setting.value) .tag(setting) @@ -167,7 +167,7 @@ struct GalleryNameDisplaySection: View { var body: some View { Section { - Picker(String(localized: .galleryName), selection: $ehSetting.galleryName) { + Picker(.galleryName, selection: $ehSetting.galleryName) { ForEach(EhSetting.GalleryName.allCases) { name in Text(name.value) .tag(name) @@ -189,7 +189,7 @@ struct ArchiverSettingsSection: View { var body: some View { Section { - Picker(String(localized: .archiverBehavior), selection: $ehSetting.archiverBehavior) { + Picker(.archiverBehavior, selection: $ehSetting.archiverBehavior) { ForEach(EhSetting.ArchiverBehavior.allCases) { behavior in Text(behavior.value) .tag(behavior) @@ -224,7 +224,7 @@ struct FrontPageSettingsSection: View { } Section { - Picker(String(localized: .displayMode), selection: $ehSetting.displayMode) { + Picker(.displayMode, selection: $ehSetting.displayMode) { ForEach(EhSetting.DisplayMode.allCases) { mode in Text(mode.value) .tag(mode) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift index 641457546..73ae21a12 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift @@ -81,7 +81,7 @@ struct RatingsSection: View { var body: some View { Section { - LabeledContent(String(localized: .ratingsColor)) { + LabeledContent(.ratingsColor) { SettingTextField( text: $ehSetting.ratingsColor, promptText: String(localized: .ratingsColorPrompt), @@ -106,7 +106,7 @@ struct SearchResultCountSection: View { var body: some View { Section { - Picker(String(localized: .resultCount), selection: $ehSetting.searchResultCount) { + Picker(.resultCount, selection: $ehSetting.searchResultCount) { ForEach(ehSetting.capableSearchResultCounts) { count in Text(String(count.value)) .tag(count) @@ -148,7 +148,7 @@ struct ThumbnailSettingsSection: View { } Section { - LabeledContent(String(localized: .thumbnailSize)) { + LabeledContent(.thumbnailSize) { Picker(selection: $ehSetting.thumbnailConfigSize) { ForEach(ehSetting.capableThumbnailConfigSizes) { size in Text(size.value) @@ -161,7 +161,7 @@ struct ThumbnailSettingsSection: View { .frame(width: 200) } - LabeledContent(String(localized: .thumbnailRowCount)) { + LabeledContent(.thumbnailRowCount) { Picker(selection: $ehSetting.thumbnailConfigRows) { ForEach(ehSetting.capableThumbnailConfigRowCounts) { row in Text(row.value) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift index beea762ff..64ce1283d 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -263,7 +263,7 @@ struct GalleryTagsSection: View { var body: some View { Section { - Picker(String(localized: .tagsSortOrder), selection: $ehSetting.tagsSortOrder) { + Picker(.tagsSortOrder, selection: $ehSetting.tagsSortOrder) { ForEach(EhSetting.TagsSortOrder.allCases) { order in Text(order.value) .tag(order) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift index d60d19a38..ad0b4740c 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift @@ -120,7 +120,7 @@ struct EhSettingView: View { } ToolbarItem(placement: .keyboard) { - Button(String(localized: .done)) { + Button(.done) { store.send(.setKeyboardHidden) } .frame(maxWidth: .infinity, alignment: .trailing) diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index 0f52789c3..63e4bab76 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -58,12 +58,12 @@ struct GeneralSettingView: View { } .foregroundStyle(.tint) } - Button(String(localized: .appActivityLogs)) { + Button(.appActivityLogs) { store.send(.delegate(.pushAppActivityLogs)) } .foregroundColor(.primary).withArrow() } - Section(String(localized: .tags)) { + Section(.tags) { HStack { Text(.enablesTagsExtension) .frame(maxWidth: .infinity, alignment: .leading) @@ -85,14 +85,14 @@ struct GeneralSettingView: View { .padding(.leading, 20) } if enablesTagsExtension && !tagTranslatorEmpty { - Toggle(String(localized: .translatesTags), isOn: $translatesTags) + Toggle(.translatesTags, isOn: $translatesTags) Toggle( String(localized: .showsTagsSearchSuggestion), isOn: $showsTagsSearchSuggestion ) - Toggle(String(localized: .showsImagesInTags), isOn: $showsImagesInTags) + Toggle(.showsImagesInTags, isOn: $showsImagesInTags) } - Button(String(localized: .importCustomTranslations)) { + Button(.importCustomTranslations) { store.send(.importCustomTranslationsButtonTapped) } .fileImporter( @@ -113,7 +113,7 @@ struct GeneralSettingView: View { ) } } - Section(String(localized: .navigation)) { + Section(.navigation) { Toggle( String(localized: .redirectsLinksToTheSelectedHost), isOn: $redirectsLinksToSelectedHost @@ -123,7 +123,7 @@ struct GeneralSettingView: View { isOn: $detectsLinksFromClipboard ) } - Section(String(localized: .security)) { + Section(.security) { HStack { Picker( String(localized: .autoLock), @@ -147,7 +147,7 @@ struct GeneralSettingView: View { } } } - Section(String(localized: .caches)) { + Section(.caches) { Button { store.send(.clearImageCachesButtonTapped) } label: { From 5ed0cd344204b6fcd3a4b9247b86a4efe9be9510 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 03:15:50 +0800 Subject: [PATCH 473/614] Bridge TextState to LocalizedStringResource Add a labeled TextState(localized: LocalizedStringResource) initializer in AppComponents and route the 42 TextState(String(localized: .symbol)) alert/toast sites plus the AppAlertState toast-title bodies through it (TextState(localized: .symbol)). loading(title:) now takes LocalizedStringResource?. The label keeps the init out of unlabeled overload resolution so it never competes with TextState's string-literal / LocalizedStringKey inits inside ButtonState/ConfirmationDialogState result builders (an unlabeled overload crashed the type checker). error/success caption stays String? since it also carries dynamic server/error text. Build + 308 tests + full SwiftLint green. --- .../Sources/AppComponents/AppAlertState.swift | 10 +++++----- .../TextState+LocalizedStringResource.swift | 12 ++++++++++++ .../DetailFeature/DetailReducer+Download.swift | 10 +++++----- .../FolderManager/FolderManagerReducer.swift | 7 ++++--- .../Sources/DownloadsFeature/DownloadsReducer.swift | 10 +++++----- .../Sources/FiltersFeature/FiltersReducer.swift | 7 ++++--- .../HomeFeature/History/HistoryReducer.swift | 7 ++++--- .../HomeFeature/Toplists/ToplistsReducer.swift | 10 +++++----- .../Sources/MigrationFeature/MigrationReducer.swift | 7 ++++--- .../QuickSearchFeature/QuickSearchReducer.swift | 7 ++++--- .../AccountSetting/AccountSettingReducer.swift | 6 +++--- .../SettingFeature/EhSetting/EhSettingReducer.swift | 7 ++++--- .../GeneralSetting/GeneralSettingReducer.swift | 13 +++++++------ 13 files changed, 66 insertions(+), 47 deletions(-) create mode 100644 AppPackage/Sources/AppComponents/TextState+LocalizedStringResource.swift diff --git a/AppPackage/Sources/AppComponents/AppAlertState.swift b/AppPackage/Sources/AppComponents/AppAlertState.swift index 0ce198c21..ce605a803 100644 --- a/AppPackage/Sources/AppComponents/AppAlertState.swift +++ b/AppPackage/Sources/AppComponents/AppAlertState.swift @@ -122,29 +122,29 @@ public struct AppAlertTextFieldState: Equatable, Hashable, Sendable { // Button-less toast presentations. `SystemNotificationExt` maps `ToastIcon` + `title`/`message` // onto the rendered Liquid Glass toast content. extension AppAlertState where Action == Never { - public static func loading(title: String? = nil) -> Self { + public static func loading(title: LocalizedStringResource? = nil) -> Self { .init( style: .toast(icon: .loading, autoHide: false), - title: TextState(title ?? String(localized: .toastLoading)) + title: TextState(localized: title ?? .toastLoading) ) } public static var communicating: Self { .init( style: .toast(icon: .loading, autoHide: false), - title: TextState(String(localized: .toastCommunicating)) + title: TextState(localized: .toastCommunicating) ) } public static func error(caption: String? = nil) -> Self { .init( style: .toast(icon: .error, autoHide: true), - title: TextState(String(localized: .toastError)), + title: TextState(localized: .toastError), message: caption.map { TextState($0) } ) } public static func success(caption: String? = nil) -> Self { .init( style: .toast(icon: .success, autoHide: true), - title: TextState(String(localized: .toastSuccess)), + title: TextState(localized: .toastSuccess), message: caption.map { TextState($0) } ) } diff --git a/AppPackage/Sources/AppComponents/TextState+LocalizedStringResource.swift b/AppPackage/Sources/AppComponents/TextState+LocalizedStringResource.swift new file mode 100644 index 000000000..8c53aa96c --- /dev/null +++ b/AppPackage/Sources/AppComponents/TextState+LocalizedStringResource.swift @@ -0,0 +1,12 @@ +import Foundation +import ComposableArchitecture + +public extension TextState { + /// Builds a `TextState` from a `LocalizedStringResource`, resolving it eagerly the same way a + /// `String(localized:)`-wrapped construction would. The `localized:` label keeps this out of + /// unlabeled overload resolution, so it never competes with `TextState`'s string-literal and + /// `LocalizedStringKey` initializers inside `ButtonState`/`ConfirmationDialogState` builders. + init(localized resource: LocalizedStringResource) { + self.init(verbatim: String(localized: resource)) + } +} diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift index 4ddf4db30..730dc5e5f 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift @@ -13,16 +13,16 @@ extension DetailReducer { switch action { case .deleteDownloadButtonTapped: state.alert = AppAlertState { - TextState(String(localized: .RLocalizable.deleteDownload)) + TextState(localized: .RLocalizable.deleteDownload) } actions: { ButtonState(role: .destructive, action: .confirmDeleteDownload) { - TextState(String(localized: .RLocalizable.delete)) + TextState(localized: .RLocalizable.delete) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } message: { - TextState(String(localized: .RLocalizable.deleteDownloadedGallery)) + TextState(localized: .RLocalizable.deleteDownloadedGallery) } return .none @@ -34,7 +34,7 @@ extension DetailReducer { TextState(Self.retryDownloadConfirmTitle(for: mode)) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } message: { TextState(Self.retryDownloadMessage(for: mode)) diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift index 5807c43ae..3e5d09684 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import DownloadClient +import AppComponents @Reducer public struct FolderManagerReducer: Sendable { @@ -87,13 +88,13 @@ public struct FolderManagerReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmDelete(folder)) { - TextState(String(localized: .RLocalizable.delete)) + TextState(localized: .RLocalizable.delete) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } message: { - TextState(String(localized: .deleteFolder)) + TextState(localized: .deleteFolder) } return .none diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 947a0d6b0..d1c1792d6 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -140,13 +140,13 @@ public struct DownloadsReducer: Sendable { case .deleteDownloadButtonTapped(let download): state.alert = AppAlertState { - TextState(String(localized: .RLocalizable.deleteDownload)) + TextState(localized: .RLocalizable.deleteDownload) } actions: { ButtonState(role: .destructive, action: .confirmDelete(download.gid)) { - TextState(String(localized: .RLocalizable.delete)) + TextState(localized: .RLocalizable.delete) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } message: { TextState( @@ -160,7 +160,7 @@ public struct DownloadsReducer: Sendable { case .moveButtonTapped(let download): let destinations = state.folders.filter { $0 != download.folderName } state.confirmationDialog = ConfirmationDialogState { - TextState(String(localized: .moveToFolder)) + TextState(localized: .moveToFolder) } actions: { for folder in destinations { ButtonState(action: .move(download.gid, folder)) { @@ -168,7 +168,7 @@ public struct DownloadsReducer: Sendable { } } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } return .none diff --git a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift index fe7134618..e2bc8f3fd 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import AppModels import Resources import DatabaseClient +import AppComponents @Reducer public struct FiltersReducer: Sendable { @@ -68,13 +69,13 @@ public struct FiltersReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmReset) { - TextState(String(localized: .reset)) + TextState(localized: .reset) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } message: { - TextState(String(localized: .resetDescription)) + TextState(localized: .resetDescription) } return .none diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index 0fb75dfdc..bb681f96d 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -6,6 +6,7 @@ import AppTools import HapticsClient import DatabaseClient import DownloadClient +import AppComponents @Reducer public struct HistoryReducer: Sendable { @@ -76,13 +77,13 @@ public struct HistoryReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmClearHistory) { - TextState(String(localized: .RLocalizable.clear)) + TextState(localized: .RLocalizable.clear) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } message: { - TextState(String(localized: .RLocalizable.clearDescription)) + TextState(localized: .RLocalizable.clearDescription) } return .none diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index ed7c33d54..256b99fc6 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -113,22 +113,22 @@ public struct ToplistsReducer: Sendable { let maximumPage = (state.pageNumber?.maximum ?? 0) + 1 state.alert = AppAlertState( title: { - TextState(String(localized: .RLocalizable.jumpPage)) + TextState(localized: .RLocalizable.jumpPage) }, textField: .init( - placeholder: TextState(String(localized: .RLocalizable.jumpPage)), + placeholder: TextState(localized: .RLocalizable.jumpPage), keyboard: .numberPad ), actions: { ButtonState(action: .performJumpPage) { - TextState(String(localized: .confirm)) + TextState(localized: .confirm) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } }, message: { - TextState(String(localized: .jumpPageDescription(maximumPage))) + TextState(localized: .jumpPageDescription(maximumPage)) } ) return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) diff --git a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift index 820a9e79c..3e28544d6 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import DatabaseClient +import AppComponents @Reducer public struct MigrationReducer: Sendable { @@ -47,13 +48,13 @@ public struct MigrationReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmDropDatabase) { - TextState(String(localized: .dropDatabase)) + TextState(localized: .dropDatabase) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } message: { - TextState(String(localized: .dropDatabaseDescription)) + TextState(localized: .dropDatabaseDescription) } return .none diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift index 90b9a7279..11c3008e2 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift @@ -3,6 +3,7 @@ import AppModels import Resources import ComposableArchitecture import DatabaseClient +import AppComponents @Reducer public struct QuickSearchReducer: Sendable { @@ -92,13 +93,13 @@ public struct QuickSearchReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmDelete(word)) { - TextState(String(localized: .RLocalizable.delete)) + TextState(localized: .RLocalizable.delete) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } message: { - TextState(String(localized: .RLocalizable.deleteDescription)) + TextState(localized: .RLocalizable.deleteDescription) } return .none diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index bfc08add7..bd54d3c7c 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -87,13 +87,13 @@ public struct AccountSettingReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmLogout) { - TextState(String(localized: .logout)) + TextState(localized: .logout) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } message: { - TextState(String(localized: .logoutDescription)) + TextState(localized: .logoutDescription) } return .none diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index 10a5d8597..cd1b49b0a 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -7,6 +7,7 @@ import ApplicationClient import HapticsClient import NetworkingFeature import CookieClient +import AppComponents @Reducer public struct EhSettingReducer: Sendable { @@ -86,13 +87,13 @@ public struct EhSettingReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmDeleteProfile) { - TextState(String(localized: .RLocalizable.delete)) + TextState(localized: .RLocalizable.delete) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } message: { - TextState(String(localized: .RLocalizable.deleteDescription)) + TextState(localized: .RLocalizable.deleteDescription) } return .none diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index d2d073ed3..62a39a219 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -7,6 +7,7 @@ import ApplicationClient import LibraryClient import DatabaseClient import OSLogExt +import AppComponents private let logger = Logger(category: .init(describing: GeneralSettingReducer.self)) @@ -93,13 +94,13 @@ public struct GeneralSettingReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmRemoveCustomTranslations) { - TextState(String(localized: .remove)) + TextState(localized: .remove) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } message: { - TextState(String(localized: .removeCustomTranslationsConfirmation)) + TextState(localized: .removeCustomTranslationsConfirmation) } return .none @@ -108,13 +109,13 @@ public struct GeneralSettingReducer: Sendable { TextState("") } actions: { ButtonState(role: .destructive, action: .confirmClearCache) { - TextState(String(localized: .RLocalizable.clear)) + TextState(localized: .RLocalizable.clear) } ButtonState(role: .cancel) { - TextState(String(localized: .RLocalizable.cancel)) + TextState(localized: .RLocalizable.cancel) } } message: { - TextState(String(localized: .RLocalizable.clearDescription)) + TextState(localized: .RLocalizable.clearDescription) } return .none From 08c60683795b341ebf496da252131e065df77790 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 03:25:41 +0800 Subject: [PATCH 474/614] Type shared component titles as resources Convert the title/buttonTitle chains of shared AppComponents views to LocalizedStringResource where every call site supplies a localized value: SubSection.title (10 call sites), AlertViewButton.title, ErrorView.buttonTitle (default .RLocalizable.retry), LoadingView.title. Text/ProgressView take the resource directly. AlertView.message stays String since it also renders dynamic AppError.alertText. Build + 308 tests + full SwiftLint green. --- AppPackage/Sources/AppComponents/AlertView.swift | 16 ++++++++-------- .../Sources/AppComponents/SubSection.swift | 4 ++-- .../DetailFeature/Components/TagDetailView.swift | 4 ++-- .../DetailFeature/DetailView+Subviews.swift | 4 ++-- .../Sources/DownloadsFeature/DownloadsView.swift | 2 +- .../Sources/HomeFeature/HomeView+Sections.swift | 6 +++--- .../Sources/MigrationFeature/MigrationView.swift | 4 ++-- .../Sources/SearchFeature/SearchRootView.swift | 6 +++--- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/AppPackage/Sources/AppComponents/AlertView.swift b/AppPackage/Sources/AppComponents/AlertView.swift index b9ffa1697..14a7ae18e 100644 --- a/AppPackage/Sources/AppComponents/AlertView.swift +++ b/AppPackage/Sources/AppComponents/AlertView.swift @@ -5,10 +5,10 @@ import SFSafeSymbols import AppTools public struct LoadingView: View { - private let title: String + private let title: LocalizedStringResource - public init(title: String? = nil) { - self.title = title ?? String(localized: .loading) + public init(title: LocalizedStringResource? = nil) { + self.title = title ?? .loading } public var body: some View { @@ -56,19 +56,19 @@ public struct NotLoginView: View { symbol: .personCropCircleBadgeQuestionmarkFill, message: String(localized: .needLogin) ) { - AlertViewButton(title: String(localized: .RLocalizable.login), action: action) + AlertViewButton(title: .RLocalizable.login, action: action) } } } public struct ErrorView: View { private let error: AppError - private let buttonTitle: String + private let buttonTitle: LocalizedStringResource private let action: (() -> Void)? public init( error: AppError, - buttonTitle: String = String(localized: .RLocalizable.retry), + buttonTitle: LocalizedStringResource = .RLocalizable.retry, action: (() -> Void)? = nil ) { self.error = error @@ -109,10 +109,10 @@ public struct AlertView: View { } public struct AlertViewButton: View { - private let title: String + private let title: LocalizedStringResource private let action: () -> Void - public init(title: String, action: @escaping () -> Void) { + public init(title: LocalizedStringResource, action: @escaping () -> Void) { self.title = title self.action = action } diff --git a/AppPackage/Sources/AppComponents/SubSection.swift b/AppPackage/Sources/AppComponents/SubSection.swift index fa816b2f7..a27cf94f4 100644 --- a/AppPackage/Sources/AppComponents/SubSection.swift +++ b/AppPackage/Sources/AppComponents/SubSection.swift @@ -3,7 +3,7 @@ import Resources import AppTools public struct SubSection: View { - private let title: String + private let title: LocalizedStringResource private let showAll: Bool private let tint: Color? private let isLoading: Bool? @@ -12,7 +12,7 @@ public struct SubSection: View { private let content: Content public init( - title: String, showAll: Bool = true, + title: LocalizedStringResource, showAll: Bool = true, tint: Color? = nil, isLoading: Bool? = nil, reloadAction: (() -> Void)? = nil, showAllAction: @escaping () -> Void = {}, diff --git a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift index 5765f4cb9..9e9a721e5 100644 --- a/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift +++ b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift @@ -57,7 +57,7 @@ private struct ImagesSection: View { } var body: some View { - SubSection(title: String(localized: .images), showAll: false) { + SubSection(title: .images, showAll: false) { VStack { if !imageURLs.isEmpty { ScrollView(.horizontal, showsIndicators: false) { @@ -92,7 +92,7 @@ private struct LinksSection: View { } var body: some View { - SubSection(title: String(localized: .links), showAll: false) { + SubSection(title: .links, showAll: false) { HStack { if !links.isEmpty { VStack(alignment: .leading) { diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index aa3e8a116..21fa69980 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -290,7 +290,7 @@ struct PreviewsSection: View { var body: some View { SubSection( - title: String(localized: .previews), + title: .previews, showAll: pageCount > 20, showAllAction: navigatePreviewsAction ) { ScrollView(.horizontal, showsIndicators: false) { @@ -325,7 +325,7 @@ struct CommentsSection: View { var body: some View { SubSection( - title: String(localized: .comments), + title: .comments, showAll: !comments.isEmpty, showAllAction: navigateCommentAction ) { ScrollView(.horizontal, showsIndicators: false) { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index 170f064d8..5561ab22e 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -280,7 +280,7 @@ private extension DownloadsView { symbol: .line3HorizontalDecreaseCircle, message: String(localized: .noMatchingFilters) ) { - AlertViewButton(title: String(localized: .clearFilters)) { + AlertViewButton(title: .clearFilters) { store.keyword = "" store.folderFilter = .all } diff --git a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift index 5af15b353..805d97644 100644 --- a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift +++ b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift @@ -97,7 +97,7 @@ struct CoverWallSection: View { var body: some View { SubSection( - title: String(localized: .frontpage), + title: .frontpage, tint: .secondary, isLoading: isLoading, reloadAction: reloadAction, showAllAction: showAllAction @@ -190,7 +190,7 @@ struct ToplistsSection: View { var body: some View { SubSection( - title: String(localized: .toplists), + title: .toplists, tint: .secondary, isLoading: isLoading, reloadAction: reloadAction, showAllAction: showAllAction @@ -264,7 +264,7 @@ struct MiscGridSection: View { } var body: some View { - SubSection(title: String(localized: .other), showAll: false) { + SubSection(title: .other, showAll: false) { ScrollView(.horizontal, showsIndicators: false) { HStack { let types = HomeMiscGridType.allCases diff --git a/AppPackage/Sources/MigrationFeature/MigrationView.swift b/AppPackage/Sources/MigrationFeature/MigrationView.swift index 880528afb..ff1af5508 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationView.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationView.swift @@ -20,12 +20,12 @@ public struct MigrationView: View { NavigationStack { ZStack { reversedPrimary.ignoresSafeArea() - LoadingView(title: String(localized: .preparingDatabase)) + LoadingView(title: .preparingDatabase) .opacity(store.databaseState == .loading ? 1 : 0) let error = store.databaseState.failed let errorNonNil = error ?? .databaseCorrupted(nil) AlertView(symbol: errorNonNil.symbol, message: errorNonNil.localizedDescription) { - AlertViewButton(title: String(localized: .dropDatabase)) { + AlertViewButton(title: .dropDatabase) { store.send(.dropDatabaseButtonTapped) } .confirmationDialog( diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index b3654f05f..96270e6ea 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -204,7 +204,7 @@ private struct QuickSearchWordsSection: View { var body: some View { SubSection( - title: String(localized: .RLocalizable.quickSearch), + title: .RLocalizable.quickSearch, showAll: true, tint: .primary, showAllAction: showAllAction ) { DoubleVerticalKeywordsStack(keywords: keywords, searchAction: searchAction) @@ -225,7 +225,7 @@ private struct HistoryKeywordsSection: View { } var body: some View { - SubSection(title: String(localized: .recentlySearched), showAll: false) { + SubSection(title: .recentlySearched, showAll: false) { DoubleVerticalKeywordsStack( keywords: keywords.map(WrappedKeyword.init), searchAction: searchAction, @@ -246,7 +246,7 @@ private struct HistoryGalleriesSection: View { } var body: some View { - SubSection(title: String(localized: .recentlySeen), showAll: false) { + SubSection(title: .recentlySeen, showAll: false) { ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(galleries) { gallery in From 43b186dda1ad641044f09be9afa3ebbd6e08ec97 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 03:28:48 +0800 Subject: [PATCH 475/614] Convert App InfoPlist strings to catalog Replace the 6 App/.lproj/InfoPlist.strings with a single App/InfoPlist.xcstrings (NSFaceIDUsageDescription, NSPhotoLibraryAddUsageDescription, all 6 locales, extractionState manual). The App target's synchronized file-system group auto-discovers it; STRING_CATALOG_GENERATE_SYMBOLS is already YES. Verified the bundle still emits all 6 localized InfoPlist.strings with correct translations. --- App/InfoPlist.xcstrings | 88 +++++++++++++++++++++++++++++ App/de.lproj/InfoPlist.strings | 2 - App/en.lproj/InfoPlist.strings | 2 - App/ja.lproj/InfoPlist.strings | 2 - App/ko.lproj/InfoPlist.strings | 2 - App/zh-Hans.lproj/InfoPlist.strings | 2 - App/zh-Hant.lproj/InfoPlist.strings | 2 - 7 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 App/InfoPlist.xcstrings delete mode 100644 App/de.lproj/InfoPlist.strings delete mode 100644 App/en.lproj/InfoPlist.strings delete mode 100644 App/ja.lproj/InfoPlist.strings delete mode 100644 App/ko.lproj/InfoPlist.strings delete mode 100644 App/zh-Hans.lproj/InfoPlist.strings delete mode 100644 App/zh-Hant.lproj/InfoPlist.strings diff --git a/App/InfoPlist.xcstrings b/App/InfoPlist.xcstrings new file mode 100644 index 000000000..f074dbbd2 --- /dev/null +++ b/App/InfoPlist.xcstrings @@ -0,0 +1,88 @@ +{ + "sourceLanguage": "en", + "strings": { + "NSFaceIDUsageDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "We need this permission to provide Face ID option while unlocking the App." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Berechtigung ist notwendig um Face ID zum Entsperren der Anwendung verwenden zu können." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリアンロック認証時に Face ID オプションを提供するにはこの権限が必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 권한을 허용해야 앱 잠금 해제할때 Face ID 옵션을 제공합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要此权限以在解锁 App 时提供 Face ID 选项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "我們需要您提供 Face ID 權限來使用 Face ID 解鎖" + } + } + } + }, + "NSPhotoLibraryAddUsageDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "We need this permission to save images to your photo library." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "We need this permission to save images to your photo library." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像をライブラリに保存するにはこの権限が必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지를 사진 라이브러리에 저정하고 싶으면 이 권한을 허용해주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要此权限以保存图像到相册" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "我們需要您提供照片權限來保存圖片到照片" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/App/de.lproj/InfoPlist.strings b/App/de.lproj/InfoPlist.strings deleted file mode 100644 index 716af9d94..000000000 --- a/App/de.lproj/InfoPlist.strings +++ /dev/null @@ -1,2 +0,0 @@ -"NSFaceIDUsageDescription" = "Diese Berechtigung ist notwendig um Face ID zum Entsperren der Anwendung verwenden zu können."; -"NSPhotoLibraryAddUsageDescription" = "We need this permission to save images to your photo library."; diff --git a/App/en.lproj/InfoPlist.strings b/App/en.lproj/InfoPlist.strings deleted file mode 100644 index 5c393b28f..000000000 --- a/App/en.lproj/InfoPlist.strings +++ /dev/null @@ -1,2 +0,0 @@ -"NSFaceIDUsageDescription" = "We need this permission to provide Face ID option while unlocking the App."; -"NSPhotoLibraryAddUsageDescription" = "We need this permission to save images to your photo library."; diff --git a/App/ja.lproj/InfoPlist.strings b/App/ja.lproj/InfoPlist.strings deleted file mode 100644 index 1d07c4bf5..000000000 --- a/App/ja.lproj/InfoPlist.strings +++ /dev/null @@ -1,2 +0,0 @@ -"NSFaceIDUsageDescription" = "アプリアンロック認証時に Face ID オプションを提供するにはこの権限が必要です"; -"NSPhotoLibraryAddUsageDescription" = "画像をライブラリに保存するにはこの権限が必要です"; diff --git a/App/ko.lproj/InfoPlist.strings b/App/ko.lproj/InfoPlist.strings deleted file mode 100644 index 1fc0c2206..000000000 --- a/App/ko.lproj/InfoPlist.strings +++ /dev/null @@ -1,2 +0,0 @@ -"NSFaceIDUsageDescription" = "이 권한을 허용해야 앱 잠금 해제할때 Face ID 옵션을 제공합니다."; -"NSPhotoLibraryAddUsageDescription" = "이미지를 사진 라이브러리에 저정하고 싶으면 이 권한을 허용해주세요."; diff --git a/App/zh-Hans.lproj/InfoPlist.strings b/App/zh-Hans.lproj/InfoPlist.strings deleted file mode 100644 index 4808d3705..000000000 --- a/App/zh-Hans.lproj/InfoPlist.strings +++ /dev/null @@ -1,2 +0,0 @@ -"NSFaceIDUsageDescription" = "需要此权限以在解锁 App 时提供 Face ID 选项"; -"NSPhotoLibraryAddUsageDescription" = "需要此权限以保存图像到相册"; diff --git a/App/zh-Hant.lproj/InfoPlist.strings b/App/zh-Hant.lproj/InfoPlist.strings deleted file mode 100644 index e2103c531..000000000 --- a/App/zh-Hant.lproj/InfoPlist.strings +++ /dev/null @@ -1,2 +0,0 @@ -"NSFaceIDUsageDescription" = "我們需要您提供 Face ID 權限來使用 Face ID 解鎖"; -"NSPhotoLibraryAddUsageDescription" = "我們需要您提供照片權限來保存圖片到照片"; From 52e85648caa895bbe94a9e39fb75f3312032a932 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 08:32:54 +0800 Subject: [PATCH 476/614] Update to Xcode recommended settings --- EhPanda.xcodeproj/project.pbxproj | 6 +++++- EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index d1bf28d76..d6255a11f 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -228,7 +228,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1300; - LastUpgradeCheck = 2640; + LastUpgradeCheck = 2660; TargetAttributes = { AB5BE67526B95FDD007D4A55 = { CreatedOnToolsVersion = 13.0; @@ -378,6 +378,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -412,6 +413,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -442,6 +444,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -476,6 +479,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; diff --git a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme index a9b13c51e..c6fda6856 100644 --- a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme +++ b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme @@ -1,6 +1,6 @@ Date: Sat, 4 Jul 2026 08:54:21 +0800 Subject: [PATCH 477/614] Fix double-escaped newlines in catalog values The .strings->xcstrings migration copied backslash escapes verbatim into JSON, so 29 locale-values across app_error.quota_exceeded_description, database_corrupted, drop_database_description (and 3 SettingFeature keys) rendered a literal \n / \" instead of a newline / quote. Unescape them back to real characters. Verified via xcstringstool compile. --- .../AppModels/Resources/Localizable.xcstrings | 24 +++++++++---------- .../Resources/Localizable.xcstrings | 12 +++++----- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings b/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings index 09a9b9773..4c71c89c8 100644 --- a/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings @@ -786,37 +786,37 @@ "en": { "stringUnit": { "state": "translated", - "value": "Image quota exceeded.\\nPlease wait and try again later." + "value": "Image quota exceeded.\nPlease wait and try again later." } }, "de": { "stringUnit": { "state": "translated", - "value": "Bildkontingent überschritten.\\nBitte warte einen Moment und versuche es dann erneut." + "value": "Bildkontingent überschritten.\nBitte warte einen Moment und versuche es dann erneut." } }, "ja": { "stringUnit": { "state": "translated", - "value": "画像の帯域割り当てを使い切りました。\\nしばらく待ってからもう一度お試しください。" + "value": "画像の帯域割り当てを使い切りました。\nしばらく待ってからもう一度お試しください。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이미지 할당량을 모두 사용했습니다.\\n잠시 후 다시 시도해 주세요." + "value": "이미지 할당량을 모두 사용했습니다.\n잠시 후 다시 시도해 주세요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "图片流量额度已用尽。\\n请稍后再试。" + "value": "图片流量额度已用尽。\n请稍后再试。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "圖片流量額度已用盡。\\n請稍後再試。" + "value": "圖片流量額度已用盡。\n請稍後再試。" } } } @@ -12307,37 +12307,37 @@ "en": { "stringUnit": { "state": "translated", - "value": "The database is corrupted.\\nPlease submit an issue on GitHub." + "value": "The database is corrupted.\nPlease submit an issue on GitHub." } }, "de": { "stringUnit": { "state": "translated", - "value": "Die Datenbank ist beschädigt.\\nBitte erstelle ein Issue auf GitHub." + "value": "Die Datenbank ist beschädigt.\nBitte erstelle ein Issue auf GitHub." } }, "ja": { "stringUnit": { "state": "translated", - "value": "データベースが破損しています。\\nGitHub で Issue を作成していただくようお願いいたします。" + "value": "データベースが破損しています。\nGitHub で Issue を作成していただくようお願いいたします。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "데이터베이스가 손상되었어요.\\nGitHub에 이슈를 남겨주세요." + "value": "데이터베이스가 손상되었어요.\nGitHub에 이슈를 남겨주세요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "数据库已损毁。\\n请到 GitHub 提起 Issue 反馈。" + "value": "数据库已损毁。\n请到 GitHub 提起 Issue 反馈。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "資料庫已經損壞\\n請提交 issue 至 GitHub." + "value": "資料庫已經損壞\n請提交 issue 至 GitHub." } } } diff --git a/AppPackage/Sources/MigrationFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/MigrationFeature/Resources/Localizable.xcstrings index bf4550e11..466652bf5 100644 --- a/AppPackage/Sources/MigrationFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/MigrationFeature/Resources/Localizable.xcstrings @@ -48,37 +48,37 @@ "en": { "stringUnit": { "state": "translated", - "value": "You will lose all your data in this app.\\nAre you sure to drop the database?" + "value": "You will lose all your data in this app.\nAre you sure to drop the database?" } }, "de": { "stringUnit": { "state": "translated", - "value": "Du verlierst alle Daten in dieser App.\\nMöchtest du die Datenbank wirklich löschen?" + "value": "Du verlierst alle Daten in dieser App.\nMöchtest du die Datenbank wirklich löschen?" } }, "ja": { "stringUnit": { "state": "translated", - "value": "本アプリでのすべてのデータを失うことになります。\\n本当にデータベースを削除してもよろしいですか?" + "value": "本アプリでのすべてのデータを失うことになります。\n本当にデータベースを削除してもよろしいですか?" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이 앱의 모든 데이터를 잃게 돼요.\\n정말 데이터베이스를 삭제하시겠어요?" + "value": "이 앱의 모든 데이터를 잃게 돼요.\n정말 데이터베이스를 삭제하시겠어요?" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "你将失去这个 App 中所有的数据。\\n确定要丢弃数据库吗?" + "value": "你将失去这个 App 中所有的数据。\n确定要丢弃数据库吗?" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "繼續此操作將會清除 APP 中的所有資料\\n確定要刪除資料庫?" + "value": "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?" } } } From 2aedfac14fa17933d7dea772e3a1213ac3ff61ce Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 08:54:41 +0800 Subject: [PATCH 478/614] Fix escapes and %% in SettingFeature catalog Unescape mis-migrated newlines/quotes in result_count_description, filtered_removal_count_description, original_images. Convert %% -> % in cover_scale_factor and virtual_width_description: these keys have no placeholders, so String(localized:) runs no format pass (unlike the old SwiftGen tr() which always ran String(format:)), and the settings footers were showing literal 75%% / 100%% / 150%%. --- .../Resources/Localizable.xcstrings | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings index 6c28042d7..2279c4a9f 100644 --- a/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings @@ -1278,37 +1278,37 @@ "en": { "stringUnit": { "state": "translated", - "value": "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes." + "value": "The cover size in gallery list views can be scaled to between 75% and 150% when using the Thumbnail or Extended display modes." } }, "de": { "stringUnit": { "state": "translated", - "value": "Die Covergröße in Galerielisten kann in den Anzeigemodi „Vorschaubilder“ und „Erweitert“ auf 75%% bis 150%% skaliert werden." + "value": "Die Covergröße in Galerielisten kann in den Anzeigemodi „Vorschaubilder“ und „Erweitert“ auf 75% bis 150% skaliert werden." } }, "ja": { "stringUnit": { "state": "translated", - "value": "サムネイル・拡張表示モードでのカバーを 75%% ~ 150%% にスケールすることができます。" + "value": "サムネイル・拡張表示モードでのカバーを 75% ~ 150% にスケールすることができます。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "썸네일 또는 확장 표시방식에서는 갤러리 목록의 표지 크기를 75%%에서 150%% 사이로 조절할 수 있어요." + "value": "썸네일 또는 확장 표시방식에서는 갤러리 목록의 표지 크기를 75%에서 150% 사이로 조절할 수 있어요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "缩略图和扩展模式下的画廊列表封面可以缩放为 75%% 到 150%% 之间的值。" + "value": "缩略图和扩展模式下的画廊列表封面可以缩放为 75% 到 150% 之间的值。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "在縮圖與放大檢視這兩種檢視模式下,封面的重新採樣比率介於 75%% 至 150%%." + "value": "在縮圖與放大檢視這兩種檢視模式下,封面的重新採樣比率介於 75% 至 150%." } } } @@ -2262,7 +2262,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "Show the \\\"Your default filters removed XX galleries from this page\\\" readout?" + "value": "Show the \"Your default filters removed XX galleries from this page\" readout?" } }, "de": { @@ -2280,7 +2280,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "\\\"기본 필터가 이 페이지에서 갤러리 XX개를 제거했어요\\\" 문구를 표시할까요?" + "value": "\"기본 필터가 이 페이지에서 갤러리 XX개를 제거했어요\" 문구를 표시할까요?" } }, "zh-Hans": { @@ -2292,7 +2292,7 @@ "zh-Hant": { "stringUnit": { "state": "translated", - "value": "顯示 \\\"Your default filters removed XX galleries from this page\\\" ?" + "value": "顯示 \"Your default filters removed XX galleries from this page\" ?" } } } @@ -3615,7 +3615,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \\\"Auto\\\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)." + "value": "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)." } }, "de": { @@ -3633,7 +3633,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "다시 샘플링된 버전 대신 원본 이미지를 사용할까요? 위에서 가로 해상도를 \\\"자동\\\" 이외로 선택했고 해당 이미지가 그보다 넓은 경우나, 원본 이미지가 10 MiB(1년 넘은 갤러리는 4 MiB)보다 큰 경우에는 다시 샘플링된 이미지가 계속 사용되어요." + "value": "다시 샘플링된 버전 대신 원본 이미지를 사용할까요? 위에서 가로 해상도를 \"자동\" 이외로 선택했고 해당 이미지가 그보다 넓은 경우나, 원본 이미지가 10 MiB(1년 넘은 갤러리는 4 MiB)보다 큰 경우에는 다시 샘플링된 이미지가 계속 사용되어요." } }, "zh-Hans": { @@ -4148,37 +4148,37 @@ "en": { "stringUnit": { "state": "translated", - "value": "How many results would you like per page for the index/search page and torrent search pages?\\n(Hath Perk: Paging Enlargement Required)" + "value": "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)" } }, "de": { "stringUnit": { "state": "translated", - "value": "Wie viele Ergebnisse pro Seite möchtest du auf Index-, Such- und Torrent-Suchseiten sehen?\\n(Hath-Perk „Paging Enlargement“ erforderlich)" + "value": "Wie viele Ergebnisse pro Seite möchtest du auf Index-, Such- und Torrent-Suchseiten sehen?\n(Hath-Perk „Paging Enlargement“ erforderlich)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "インデックス・トレントの検索ページで、各ページにどれくらいの結果数がお望みですか?\\n(「Hath Perk:ページング拡張」が必要)" + "value": "インデックス・トレントの検索ページで、各ページにどれくらいの結果数がお望みですか?\n(「Hath Perk:ページング拡張」が必要)" } }, "ko": { "stringUnit": { "state": "translated", - "value": "인덱스 / 검색 / 토렌트 검색 페이지에 대해 페이지당 몇 개의 결과를 원하시나요?\\n(Hath Perk: 페이징 확장 필요)" + "value": "인덱스 / 검색 / 토렌트 검색 페이지에 대해 페이지당 몇 개의 결과를 원하시나요?\n(Hath Perk: 페이징 확장 필요)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "搜索页面每页显示多少条数据?\\n(需要“Hath Perk:页面扩大”)" + "value": "搜索页面每页显示多少条数据?\n(需要“Hath Perk:页面扩大”)" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "你希望每頁顯示幾個搜尋結果?\\n(該功能需要有 Hath Perk: Paging Enlargement)" + "value": "你希望每頁顯示幾個搜尋結果?\n(該功能需要有 Hath Perk: Paging Enlargement)" } } } @@ -6157,37 +6157,37 @@ "en": { "stringUnit": { "state": "translated", - "value": "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400." + "value": "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400." } }, "de": { "stringUnit": { "state": "translated", - "value": "Hiermit kannst du die virtuelle Breite der Seite auf Mobilgeräten überschreiben. Normalerweise bestimmt dein Gerät sie automatisch anhand der DPI. Sinnvolle Werte bei 100%% Vorschaubild-Skalierung liegen zwischen 640 und 1400." + "value": "Hiermit kannst du die virtuelle Breite der Seite auf Mobilgeräten überschreiben. Normalerweise bestimmt dein Gerät sie automatisch anhand der DPI. Sinnvolle Werte bei 100% Vorschaubild-Skalierung liegen zwischen 640 und 1400." } }, "ja": { "stringUnit": { "state": "translated", - "value": "モバイルデバイスの仮想幅をオーバーライドすることができます。一般的にはデバイスの DPI に基づいて自動的に決定されます。例えばサムネイルスケール係数が 100%% の場合、640 ~ 1400 の幅が合理的です。" + "value": "モバイルデバイスの仮想幅をオーバーライドすることができます。一般的にはデバイスの DPI に基づいて自動的に決定されます。例えばサムネイルスケール係数が 100% の場合、640 ~ 1400 の幅が合理的です。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "모바일 장치의 사이트 가상 너비를 설정할 수 있어요. 일반적으로 DPI에 따라 장치에 의해 자동으로 결정되어요. 100%% 썸네일 스케일의 추천 값은 640에서 1400 사이에요." + "value": "모바일 장치의 사이트 가상 너비를 설정할 수 있어요. 일반적으로 DPI에 따라 장치에 의해 자동으로 결정되어요. 100% 썸네일 스케일의 추천 값은 640에서 1400 사이에요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "允许你覆写移动设备的可视区域,默认是根据 DPI 自动计算的,100%% 缩略图比例下的合理值在 640 到 1400 之间。" + "value": "允许你覆写移动设备的可视区域,默认是根据 DPI 自动计算的,100% 缩略图比例下的合理值在 640 到 1400 之间。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "允許你覆蓋行動裝置網站的虛擬寬度。 這通常由你的裝置根據其 DPI 自動確定。 100%% 縮圖比例的合理值介於 640 和 1400 之間。" + "value": "允許你覆蓋行動裝置網站的虛擬寬度。 這通常由你的裝置根據其 DPI 自動確定。 100% 縮圖比例的合理值介於 640 和 1400 之間。" } } } From afe79d2f530c72a42a8f4ff21c73e2b52416e22d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 08:54:41 +0800 Subject: [PATCH 479/614] Add matches_count singular plural variant matches_count only had the plural form (Found %lld matches.), so a single match showed 'Found 1 matches.'. Add CLDR plural variants: en one='Found %lld match.'/other; de one/other (Treffer invariant); ja/ko/zh other-only. matchesCount(Int) signature and call site unchanged. --- .../Resources/Localizable.xcstrings | 84 +++++++++++++++---- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings b/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings index ad240dc6b..aa3d4d8a2 100644 --- a/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings @@ -46,39 +46,87 @@ "extractionState": "manual", "localizations": { "en": { - "stringUnit": { - "state": "translated", - "value": "Found %lld matches." + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Found %lld match." + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Found %lld matches." + } + } + } } }, "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Treffer gefunden." + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Treffer gefunden." + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Treffer gefunden." + } + } + } } }, "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld 件の該当項目" + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 件の該当項目" + } + } + } } }, "ko": { - "stringUnit": { - "state": "translated", - "value": "검색 결과 %lld개" + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "검색 결과 %lld개" + } + } + } } }, "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "找到 %lld 项结果" + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "找到 %lld 项结果" + } + } + } } }, "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "找到 %lld 項結果" + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "找到 %lld 項結果" + } + } + } } } } From b714d0d295f2a3162295e9f7b8c180b551f18569 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 09:01:15 +0800 Subject: [PATCH 480/614] Default swiftSettings to fix Package file_length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding SFSafeSymbolsExt and the 17 resources: lines pushed Package.swift to 1017 lines, tripping SwiftLint file_length (>1000) — missed because the Xcode build plugin never lints Package.swift and per-module lint runs don't cover it. Default the target()/testTarget() helper swiftSettings param to sharedSwiftSettings (all 51 targets used it) and drop the now-redundant per-target line: 1017 -> 966. plugins stays explicit since PluginUsage is main-actor-isolated and can't be a nonisolated default. Full-repo SwiftLint now clean. --- AppPackage/Package.swift | 55 ++-------------------------------------- 1 file changed, 2 insertions(+), 53 deletions(-) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 9e04316d6..9c6146559 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -191,7 +191,7 @@ extension PackageDescription.Target { packageAccess: Bool = true, cSettings: [PackageDescription.CSetting]? = nil, cxxSettings: [PackageDescription.CXXSetting]? = nil, - swiftSettings: [PackageDescription.SwiftSetting]? = nil, + swiftSettings: [PackageDescription.SwiftSetting]? = sharedSwiftSettings, linkerSettings: [PackageDescription.LinkerSetting]? = nil, plugins: [PackageDescription.Target.PluginUsage]? = nil ) -> PackageDescription.Target { @@ -222,7 +222,7 @@ extension PackageDescription.Target { packageAccess: Bool = true, cSettings: [PackageDescription.CSetting]? = nil, cxxSettings: [PackageDescription.CXXSetting]? = nil, - swiftSettings: [PackageDescription.SwiftSetting]? = nil, + swiftSettings: [PackageDescription.SwiftSetting]? = sharedSwiftSettings, linkerSettings: [PackageDescription.LinkerSetting]? = nil, plugins: [PackageDescription.Target.PluginUsage]? = nil ) -> PackageDescription.Target { @@ -300,7 +300,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.waterfallGrid) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -312,13 +311,11 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.casePaths) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( module: .resources, resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -327,7 +324,6 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -347,7 +343,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.kanna) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -357,7 +352,6 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -367,13 +361,11 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.composableArchitecture), .targetDependency(.sfSafeSymbols) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( module: .appTools, dependencies: [], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -382,7 +374,6 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -392,7 +383,6 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -400,7 +390,6 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -410,7 +399,6 @@ let targets: [PackageDescription.Target] = [ .module(.osLogExt), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -419,7 +407,6 @@ let targets: [PackageDescription.Target] = [ .module(.animatedImageFeature), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -430,7 +417,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.composableArchitecture) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -440,7 +426,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.composableArchitecture), .targetDependency(.kingfisher) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -455,7 +440,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.deprecatedAPI), .targetDependency(.kanna) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -467,7 +451,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.composableArchitecture) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -476,7 +459,6 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -493,7 +475,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sfSafeSymbols) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -508,7 +489,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.waterfallGrid) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -516,7 +496,6 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .targetDependency(.sdWebImageSwiftUI) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -525,7 +504,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.casePaths), .targetDependency(.commonMark) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -533,7 +511,6 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .targetDependency(.sfSafeSymbols) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -542,7 +519,6 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .targetDependency(.openCC) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -550,7 +526,6 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appTools) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -561,7 +536,6 @@ let targets: [PackageDescription.Target] = [ .module(.osLogExt), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -571,7 +545,6 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .module(.commonMarkExt) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -584,7 +557,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.composableArchitecture) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -597,7 +569,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.composableArchitecture) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -611,7 +582,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sfSafeSymbols) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -622,7 +592,6 @@ let targets: [PackageDescription.Target] = [ .module(.resources) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -636,7 +605,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sfSafeSymbols) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -658,7 +626,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sfSafeSymbols) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -680,7 +647,6 @@ let targets: [PackageDescription.Target] = [ .module(.tagTranslationFeature), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -713,7 +679,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sharing) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -739,7 +704,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sfSafeSymbols) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -769,7 +733,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.uiImageColors) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -802,7 +765,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sfSafeSymbols) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -834,7 +796,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.swiftUIPager) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -845,7 +806,6 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -860,7 +820,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.sdWebImageWebPCoder), .targetDependency(.uiImageColors) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -872,7 +831,6 @@ let targets: [PackageDescription.Target] = [ .module(.osLogExt), .targetDependency(.kanna) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -881,7 +839,6 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -891,7 +848,6 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .target( @@ -900,7 +856,6 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), @@ -911,7 +866,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.kanna) ], resources: [.process(.resources)], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), @@ -929,7 +883,6 @@ let targets: [PackageDescription.Target] = [ .module(.urlClient), .targetDependency(.kanna) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .testTarget( @@ -963,7 +916,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .testTarget( @@ -972,7 +924,6 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.fileClient) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .testTarget( @@ -987,7 +938,6 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.composableArchitecture), .targetDependency(.sharing) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ), .testTarget( @@ -998,7 +948,6 @@ let targets: [PackageDescription.Target] = [ .module(.hapticsClient), .targetDependency(.composableArchitecture) ], - swiftSettings: sharedSwiftSettings, plugins: swiftLintPlugins ) ] From c08b790f02c6aa3d61efac3f5b56a61b3b94cf0f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 09:03:20 +0800 Subject: [PATCH 481/614] Use searchable prompt resource overload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop String(localized:) from the 5 .searchable(prompt:) sites (4 HomeFeature lists + DownloadsView) — the SDK has a prompt: LocalizedStringResource overload. --- AppPackage/Sources/DownloadsFeature/DownloadsView.swift | 2 +- AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift | 2 +- AppPackage/Sources/HomeFeature/History/HistoryView.swift | 2 +- AppPackage/Sources/HomeFeature/Popular/PopularView.swift | 2 +- AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index 5561ab22e..dba336266 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -65,7 +65,7 @@ public struct DownloadsView: View { .searchable( text: $store.keyword, placement: .navigationBarDrawer(displayMode: .automatic), - prompt: String(localized: .searchDownloads) + prompt: .searchDownloads ) .sheet( item: $store.scope(state: \.destination?.inspector, action: \.destination.inspector) diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index de52c75c7..83d9e4147 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -59,7 +59,7 @@ struct FrontpageView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .searchable(text: $store.keyword, prompt: String(localized: .filter)) + .searchable(text: $store.keyword, prompt: .filter) .onAppear { if store.galleries.isEmpty { DispatchQueue.main.async { diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index b87b42862..1b93580cd 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -39,7 +39,7 @@ struct HistoryView: View { }, downloadBadges: store.downloadBadges ) - .searchable(text: $store.keyword, prompt: String(localized: .filter)) + .searchable(text: $store.keyword, prompt: .filter) .onAppear { store.send(.onAppear) if store.galleries.isEmpty { diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index 3df4777f5..881ff6e6a 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -44,7 +44,7 @@ struct PopularView: View { FiltersView(store: store) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .searchable(text: $store.keyword, prompt: String(localized: .filter)) + .searchable(text: $store.keyword, prompt: .filter) .onAppear { if store.galleries.isEmpty { DispatchQueue.main.async { diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index ee6119ae4..6a33958b4 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -43,7 +43,7 @@ struct ToplistsView: View { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) - .searchable(text: $store.keyword, prompt: String(localized: .filter)) + .searchable(text: $store.keyword, prompt: .filter) .appAlert($store.scope(state: \.alert, action: \.alert), text: $store.jumpPageIndex) .onAppear { if store.galleries?.isEmpty != false { From c53b1ea77b03df5af7656cb5ed676790e78cd5b0 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 09:10:06 +0800 Subject: [PATCH 482/614] Strip toast/new_dawn prefixes, qualify none/optional Apply the rename rules the migration missed. Strip the grouping prefix from toast.communicating/copied_to_clipboard/error/saved_to_photo_library/success (no destination conflict, per the strip-unless-conflict rule); toast.loading keeps its prefix since bare 'loading' already exists. Strip the view name from new_dawn.first/.second -> first/second. Qualify the rule-6 keyword-risky symbols: DownloadsFeature none -> no_pages, QuickSearchFeature optional -> optional_placeholder. Symbols/call sites updated; 0 duplicate keys, 308 tests green. --- .../Sources/AppComponents/AppAlertState.swift | 10 +- .../Sources/AppComponents/NewDawnView.swift | 4 +- .../Resources/Localizable.xcstrings | 300 +++++++++--------- .../DownloadsView+Subviews.swift | 2 +- .../Resources/Localizable.xcstrings | 2 +- .../QuickSearchFeature/QuickSearchView.swift | 2 +- .../Resources/Localizable.xcstrings | 2 +- 7 files changed, 161 insertions(+), 161 deletions(-) diff --git a/AppPackage/Sources/AppComponents/AppAlertState.swift b/AppPackage/Sources/AppComponents/AppAlertState.swift index ce605a803..d976c6960 100644 --- a/AppPackage/Sources/AppComponents/AppAlertState.swift +++ b/AppPackage/Sources/AppComponents/AppAlertState.swift @@ -131,28 +131,28 @@ extension AppAlertState where Action == Never { public static var communicating: Self { .init( style: .toast(icon: .loading, autoHide: false), - title: TextState(localized: .toastCommunicating) + title: TextState(localized: .communicating) ) } public static func error(caption: String? = nil) -> Self { .init( style: .toast(icon: .error, autoHide: true), - title: TextState(localized: .toastError), + title: TextState(localized: .error), message: caption.map { TextState($0) } ) } public static func success(caption: String? = nil) -> Self { .init( style: .toast(icon: .success, autoHide: true), - title: TextState(localized: .toastSuccess), + title: TextState(localized: .success), message: caption.map { TextState($0) } ) } public static var savedToPhotoLibrary: Self { - .success(caption: String(localized: .toastSavedToPhotoLibrary)) + .success(caption: String(localized: .savedToPhotoLibrary)) } public static var copiedToClipboardSucceeded: Self { - .success(caption: String(localized: .toastCopiedToClipboard)) + .success(caption: String(localized: .copiedToClipboard)) } } diff --git a/AppPackage/Sources/AppComponents/NewDawnView.swift b/AppPackage/Sources/AppComponents/NewDawnView.swift index a4f227f9d..f25dda795 100644 --- a/AppPackage/Sources/AppComponents/NewDawnView.swift +++ b/AppPackage/Sources/AppComponents/NewDawnView.swift @@ -47,8 +47,8 @@ public struct NewDawnView: View { } VStack(spacing: 50) { VStack(spacing: 10) { - TextView(text: String(localized: .newDawnFirst), font: .largeTitle) - TextView(text: String(localized: .newDawnSecond), font: .title2) + TextView(text: String(localized: .first), font: .largeTitle) + TextView(text: String(localized: .second), font: .title2) } TextView(text: greeting.gainContent ?? "", font: .title3, fontWeight: .bold) } diff --git a/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings b/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings index aa3d4d8a2..58d80cc56 100644 --- a/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings @@ -1,178 +1,130 @@ { "sourceLanguage": "en", "strings": { - "loading": { + "communicating": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Loading..." + "value": "Communicating..." } }, "de": { "stringUnit": { "state": "translated", - "value": "Wird geladen..." + "value": "Verbinde..." } }, "ja": { "stringUnit": { "state": "translated", - "value": "読み込み中..." + "value": "通信中..." } }, "ko": { "stringUnit": { "state": "translated", - "value": "로딩 중..." + "value": "접속 중..." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "加载中..." + "value": "通信中..." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "載入中..." + "value": "連線中..." } } } }, - "matches_count": { + "copied_to_clipboard": { "extractionState": "manual", "localizations": { "en": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "Found %lld match." - } - }, - "other": { - "stringUnit": { - "state": "translated", - "value": "Found %lld matches." - } - } - } + "stringUnit": { + "state": "translated", + "value": "Copied to clipboard" } }, "de": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%lld Treffer gefunden." - } - }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld Treffer gefunden." - } - } - } + "stringUnit": { + "state": "translated", + "value": "In Zwischenablage kopiert" } }, "ja": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 件の該当項目" - } - } - } + "stringUnit": { + "state": "translated", + "value": "クリップボードにコピーしました" } }, "ko": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "검색 결과 %lld개" - } - } - } + "stringUnit": { + "state": "translated", + "value": "클립보드에 복사되었어요" } }, "zh-Hans": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "找到 %lld 项结果" - } - } - } + "stringUnit": { + "state": "translated", + "value": "已复制到剪切板" } }, "zh-Hant": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "找到 %lld 項結果" - } - } - } + "stringUnit": { + "state": "translated", + "value": "已複製到剪貼簿" } } } }, - "need_login": { + "error": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "You need to login to access this feature." + "value": "Error" } }, "de": { "stringUnit": { "state": "translated", - "value": "Du musst dich einloggen, um diese Funktion nutzen zu können." + "value": "Fehler" } }, "ja": { "stringUnit": { "state": "translated", - "value": "本機能をご利用になるにはログインが必要です" + "value": "エラー" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이 기능을 사용하려면 로그인이 필요해요." + "value": "실패" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "你需要登录才能使用该功能" + "value": "错误" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "你需要登入才能完成這個動作" + "value": "錯誤" } } } }, - "new_dawn.first": { + "first": { "extractionState": "manual", "localizations": { "en": { @@ -213,330 +165,378 @@ } } }, - "new_dawn.second": { + "loading": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reflecting on your journey so far, you find that you are a little wiser." + "value": "Loading..." } }, "de": { "stringUnit": { "state": "translated", - "value": "Als du auf deine bisherige Reise zurückblickst wirst du ein kleines bisschen weiser." + "value": "Wird geladen..." } }, "ja": { "stringUnit": { "state": "translated", - "value": "今までの歩みを振り返り、少し賢くなった気がする。" + "value": "読み込み中..." } }, "ko": { "stringUnit": { "state": "translated", - "value": "지금까지의 여정을 돌이켜보면, 당신은 조금 더 현명해진 것 같죠?" + "value": "로딩 중..." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "回顾至今的历程,发觉自己更睿智了一些。" + "value": "加载中..." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "回顧到目前為止的旅程,你發現自己睿智了一點。" + "value": "載入中..." } } } }, - "show_all": { + "matches_count": { "extractionState": "manual", "localizations": { "en": { - "stringUnit": { - "state": "translated", - "value": "Show all" + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Found %lld match." + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Found %lld matches." + } + } + } } }, "de": { - "stringUnit": { - "state": "translated", - "value": "Alle anzeigen" + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Treffer gefunden." + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Treffer gefunden." + } + } + } } }, "ja": { - "stringUnit": { - "state": "translated", - "value": "すべて表示" + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 件の該当項目" + } + } + } } }, "ko": { - "stringUnit": { - "state": "translated", - "value": "모두 보기" + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "검색 결과 %lld개" + } + } + } } }, "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "显示全部" + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "找到 %lld 项结果" + } + } + } } }, "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "顯示全部" + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "找到 %lld 項結果" + } + } + } } } } }, - "toast.communicating": { + "need_login": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Communicating..." + "value": "You need to login to access this feature." } }, "de": { "stringUnit": { "state": "translated", - "value": "Verbinde..." + "value": "Du musst dich einloggen, um diese Funktion nutzen zu können." } }, "ja": { "stringUnit": { "state": "translated", - "value": "通信中..." + "value": "本機能をご利用になるにはログインが必要です" } }, "ko": { "stringUnit": { "state": "translated", - "value": "접속 중..." + "value": "이 기능을 사용하려면 로그인이 필요해요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "通信中..." + "value": "你需要登录才能使用该功能" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "連線中..." + "value": "你需要登入才能完成這個動作" } } } }, - "toast.copied_to_clipboard": { + "saved_to_photo_library": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copied to clipboard" + "value": "Saved to photo library" } }, "de": { "stringUnit": { "state": "translated", - "value": "In Zwischenablage kopiert" + "value": "In der Fotomediathek gesichert" } }, "ja": { "stringUnit": { "state": "translated", - "value": "クリップボードにコピーしました" + "value": "ライブラリに保存しました" } }, "ko": { "stringUnit": { "state": "translated", - "value": "클립보드에 복사되었어요" + "value": "이미지 저장" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "已复制到剪切板" + "value": "已保存到图库" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "已複製到剪貼簿" + "value": "已儲存到照片" } } } }, - "toast.error": { + "second": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Error" + "value": "Reflecting on your journey so far, you find that you are a little wiser." } }, "de": { "stringUnit": { "state": "translated", - "value": "Fehler" + "value": "Als du auf deine bisherige Reise zurückblickst wirst du ein kleines bisschen weiser." } }, "ja": { "stringUnit": { "state": "translated", - "value": "エラー" + "value": "今までの歩みを振り返り、少し賢くなった気がする。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "실패" + "value": "지금까지의 여정을 돌이켜보면, 당신은 조금 더 현명해진 것 같죠?" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "错误" + "value": "回顾至今的历程,发觉自己更睿智了一些。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "錯誤" + "value": "回顧到目前為止的旅程,你發現自己睿智了一點。" } } } }, - "toast.loading": { + "show_all": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Loading..." + "value": "Show all" } }, "de": { "stringUnit": { "state": "translated", - "value": "Wird geladen..." + "value": "Alle anzeigen" } }, "ja": { "stringUnit": { "state": "translated", - "value": "読み込み中..." + "value": "すべて表示" } }, "ko": { "stringUnit": { "state": "translated", - "value": "로딩 중..." + "value": "모두 보기" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "加载中..." + "value": "显示全部" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "載入中..." + "value": "顯示全部" } } } }, - "toast.saved_to_photo_library": { + "success": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Saved to photo library" + "value": "Success" } }, "de": { "stringUnit": { "state": "translated", - "value": "In der Fotomediathek gesichert" + "value": "Erfolg" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ライブラリに保存しました" + "value": "成功" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이미지 저장" + "value": "성공" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "已保存到图库" + "value": "成功" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "已儲存到照片" + "value": "成功" } } } }, - "toast.success": { + "toast.loading": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Success" + "value": "Loading..." } }, "de": { "stringUnit": { "state": "translated", - "value": "Erfolg" + "value": "Wird geladen..." } }, "ja": { "stringUnit": { "state": "translated", - "value": "成功" + "value": "読み込み中..." } }, "ko": { "stringUnit": { "state": "translated", - "value": "성공" + "value": "로딩 중..." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "成功" + "value": "加载中..." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "成功" + "value": "載入中..." } } } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index 64954095d..69eac7ca4 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -174,7 +174,7 @@ struct DownloadInspectorPageGroupRow: View { private var pageNumbersText: String { let indices = pages.map(\.index).sorted() guard !indices.isEmpty else { - return String(localized: .none) + return String(localized: .noPages) } return Self.formattedPageRanges(indices) } diff --git a/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings index b0e39766a..c9d5f0833 100644 --- a/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings @@ -534,7 +534,7 @@ } } }, - "none": { + "no_pages": { "extractionState": "manual", "localizations": { "en": { diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index b6bfb9301..83d0974d6 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -148,7 +148,7 @@ extension QuickSearchView { var body: some View { Form { Section(.name) { - TextField(.optional, text: $word.name) + TextField(.optionalPlaceholder, text: $word.name) .submitLabel(.next).focused(focusedField, equals: .name) } Section(.content) { diff --git a/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings index f9aa7a5b1..79aa45a3a 100644 --- a/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings @@ -165,7 +165,7 @@ } } }, - "optional": { + "optional_placeholder": { "extractionState": "manual", "localizations": { "en": { From fc8df4d775c3b67504be2a951d744c4e4e44bde2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 09:15:21 +0800 Subject: [PATCH 483/614] Type count placeholders as %lld with Int args stars, pages (shared), ratings_count (DetailFeature) and excluded_uploaders_count (SettingFeature) used %@ and took pre-stringified Int callers (stars("\(number)")). Retype to %lld and pass the Int directly; regenerate the RLocalizable wrapper so stars/pages take Int. Plan items 3-4. Callers all hold Int (rating 2...5, stride pages, ratingCount, line count); no plural variants added since none of these ever render a count of 1. Build + 308 tests + lint green. --- .../DetailFeature/DetailView+Subviews.swift | 2 +- .../Resources/Localizable.xcstrings | 12 +++++----- .../Sources/FiltersFeature/FiltersView.swift | 2 +- .../ReadingSettingView.swift | 2 +- .../Resources/ResourceStringSymbols.swift | 8 +++---- .../Resources/Resources/Localizable.xcstrings | 24 +++++++++---------- .../EhSetting/EhSettingView+Sections3.swift | 2 +- .../Resources/Localizable.xcstrings | 12 +++++----- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index 21fa69980..bdbf0281e 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -24,7 +24,7 @@ struct DescriptionSection: View { value: galleryDetail.language.abbreviation ), DescScrollInfo( - title: String(localized: .ratingsCount("\(galleryDetail.ratingCount)")), + title: String(localized: .ratingsCount(galleryDetail.ratingCount)), description: .init(), value: .init(), rating: galleryDetail.rating, isRating: true ), DescScrollInfo( diff --git a/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings index bb17d9145..6ec84af25 100644 --- a/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings @@ -2549,37 +2549,37 @@ "en": { "stringUnit": { "state": "translated", - "value": "%@ Ratings" + "value": "%lld Ratings" } }, "de": { "stringUnit": { "state": "translated", - "value": "%@ Bewertungen" + "value": "%lld Bewertungen" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ 件の評価" + "value": "%lld 件の評価" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%@명의 별점" + "value": "%lld명의 별점" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%@ 个评分" + "value": "%lld 个评分" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "%@ 個評分" + "value": "%lld 個評分" } } } diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index f0847c5c8..023df711c 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -153,7 +153,7 @@ private struct MinimumRatingSetter: View { var body: some View { Picker(.minimumRating, selection: $minimum) { ForEach(Array(2...5), id: \.self) { number in - Text(.RLocalizable.stars("\(number)")).tag(number) + Text(.RLocalizable.stars(number)).tag(number) } } .pickerStyle(.menu) diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index 6bc9ab852..39f7106c3 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -35,7 +35,7 @@ public struct ReadingSettingView: View { .pickerStyle(.menu) Picker(.preloadLimit, selection: $prefetchLimit) { ForEach(Array(stride(from: 6, through: 18, by: 4)), id: \.self) { value in - Text(.RLocalizable.pages("\(value)")).tag(value) + Text(.RLocalizable.pages(value)).tag(value) } } .pickerStyle(.menu) diff --git a/AppPackage/Sources/Resources/ResourceStringSymbols.swift b/AppPackage/Sources/Resources/ResourceStringSymbols.swift index 7cae9d14a..3bf638580 100644 --- a/AppPackage/Sources/Resources/ResourceStringSymbols.swift +++ b/AppPackage/Sources/Resources/ResourceStringSymbols.swift @@ -212,10 +212,10 @@ public nonisolated extension LocalizedStringResource { ) } - public static func pages(_ arg1: String) -> LocalizedStringResource { + public static func pages(_ arg1: Int) -> LocalizedStringResource { LocalizedStringResource( "pages", - defaultValue: "\(arg1)", + defaultValue: "\(arg1, specifier: "%lld")", table: "Localizable", bundle: resourceStringSymbolsBundleDescription ) @@ -270,10 +270,10 @@ public nonisolated extension LocalizedStringResource { ) } - public static func stars(_ arg1: String) -> LocalizedStringResource { + public static func stars(_ arg1: Int) -> LocalizedStringResource { LocalizedStringResource( "stars", - defaultValue: "\(arg1)", + defaultValue: "\(arg1, specifier: "%lld")", table: "Localizable", bundle: resourceStringSymbolsBundleDescription ) diff --git a/AppPackage/Sources/Resources/Resources/Localizable.xcstrings b/AppPackage/Sources/Resources/Resources/Localizable.xcstrings index 2f6331b8c..56fb61fe4 100644 --- a/AppPackage/Sources/Resources/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/Resources/Resources/Localizable.xcstrings @@ -1135,37 +1135,37 @@ "en": { "stringUnit": { "state": "translated", - "value": "%@ pages" + "value": "%lld pages" } }, "de": { "stringUnit": { "state": "translated", - "value": "%@ Seiten" + "value": "%lld Seiten" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ ページ" + "value": "%lld ページ" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%@페이지" + "value": "%lld페이지" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%@ 页" + "value": "%lld 页" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "%@ 頁" + "value": "%lld 頁" } } } @@ -1470,37 +1470,37 @@ "en": { "stringUnit": { "state": "translated", - "value": "%@ stars" + "value": "%lld stars" } }, "de": { "stringUnit": { "state": "translated", - "value": "%@ Sterne" + "value": "%lld Sterne" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ つ星" + "value": "%lld つ星" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%@별" + "value": "%lld별" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%@ 星" + "value": "%lld 星" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "%@ 星" + "value": "%lld 星" } } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift index 64ce1283d..adedcc671 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -194,7 +194,7 @@ struct ExcludedUploadersSection: View { } footer: { Text( String(localized: .excludedUploadersCount( - "\(ehSetting.excludedUploaders.ehSettingLineCount)", "\(1000)" + ehSetting.excludedUploaders.ehSettingLineCount, 1000 )) .localizedKey ) diff --git a/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings index 2279c4a9f..1ee61accb 100644 --- a/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings @@ -1975,37 +1975,37 @@ "en": { "stringUnit": { "state": "translated", - "value": "You are currently using **%@ / %@** exclusion slots." + "value": "You are currently using **%lld / %lld** exclusion slots." } }, "de": { "stringUnit": { "state": "translated", - "value": "Du belegst derzeit **%@ / %@** Ausschlussplätze." + "value": "Du belegst derzeit **%lld / %lld** Ausschlussplätze." } }, "ja": { "stringUnit": { "state": "translated", - "value": "現時点で **%@ / %@** の排除スロットが使用済みです。" + "value": "現時点で **%lld / %lld** の排除スロットが使用済みです。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "**%@ / %@** 개의 슬롯을 사용하고 있어요." + "value": "**%lld / %lld** 개의 슬롯을 사용하고 있어요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "已使用 **%@ / %@** 个屏蔽槽位。" + "value": "已使用 **%lld / %lld** 个屏蔽槽位。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "你正在使用 **%@ / %@** 排除欄位" + "value": "你正在使用 **%lld / %lld** 排除欄位" } } } From 89ae1598186b960af63c7780bde34f2dd501889f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 09:18:16 +0800 Subject: [PATCH 484/614] Add LOCALIZATION_PREFERS_STRING_CATALOGS build setting Set LOCALIZATION_PREFERS_STRING_CATALOGS = YES in both project-level configs alongside the existing STRING_CATALOG_GENERATE_SYMBOLS, matching the reference projects and the plan's App-target polish. --- EhPanda.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index d6255a11f..ecd6c69b2 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -429,6 +429,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -489,6 +490,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ""; From f9777ff938be4b99b88b173ea7605f5509efd267 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 10:06:45 +0800 Subject: [PATCH 485/614] Resolve bundle via #bundle macro in symbols Replace the #if SWIFT_PACKAGE / Bundle.module vs forClass dance in ResourceStringSymbols.swift with the #bundle macro (available in this toolchain), per plan item 6. Trims the header from 13 to 5 lines; build + SwiftLint green. --- .../Sources/Resources/ResourceStringSymbols.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/AppPackage/Sources/Resources/ResourceStringSymbols.swift b/AppPackage/Sources/Resources/ResourceStringSymbols.swift index 3bf638580..d015fe282 100644 --- a/AppPackage/Sources/Resources/ResourceStringSymbols.swift +++ b/AppPackage/Sources/Resources/ResourceStringSymbols.swift @@ -1,16 +1,8 @@ import Foundation -#if SWIFT_PACKAGE -private nonisolated let resourceStringSymbolsBundle = Foundation.Bundle.module @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) private nonisolated let resourceStringSymbolsBundleDescription = LocalizedStringResource.BundleDescription - .atURL(resourceStringSymbolsBundle.bundleURL) -#else -private final class ResourceStringSymbolsBundleClass {} -@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) -private nonisolated let resourceStringSymbolsBundleDescription = LocalizedStringResource.BundleDescription - .forClass(ResourceStringSymbolsBundleClass.self) -#endif + .atURL(#bundle.bundleURL) @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) public nonisolated extension LocalizedStringResource { From b6493ec450737fb7e5dab098e5226e583c50b027 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 10:23:12 +0800 Subject: [PATCH 486/614] Convert component title params to resources Extend chain conversion (finding #5) to the remaining component `title: String` params that carry localized display text, retyping them to `LocalizedStringResource` and dropping the `String(localized:)` wrappers at all 37 call sites: - Info (GalleryInfosView): 22 metadata rows; id forks to `String(localized: title).hashValue` since LSR isn't Hashable. - DescScrollInfo/DescScrollItem/DescScrollRatingItem chain; the Identifiable id forks to `String(localized: title)`. - ValuePicker (LabeledContent), SeekButton, LaboratoryCell, PostCommentView (both call sites, incl. the edit/post ternary). - MiscGridItem + HomeMiscGridType.title; enum id forks to `String(localized: title)`. Deliberately left as String (genuine runtime values, not localizable): ExcludeRow (per-language names), ControlPanel/UpperPanel (slider page counter), and the data-model / CoreData / DownloadStore-path titles. Build, 308 tests, and SwiftLint all green. --- .../DateSeekFeature/DateSeekPickerView.swift | 6 +-- .../DetailFeature/Comments/CommentsView.swift | 4 +- .../Components/PostCommentView.swift | 4 +- .../DetailFeature/DetailView+Subviews.swift | 18 +++---- .../Sources/DetailFeature/DetailView.swift | 2 +- .../GalleryInfos/GalleryInfosView.swift | 48 +++++++++---------- .../HomeFeature/HomeView+Sections.swift | 6 +-- AppPackage/Sources/HomeFeature/HomeView.swift | 10 ++-- .../Components/LaboratorySettingView.swift | 6 +-- .../EhSetting/EhSettingView+Sections1.swift | 8 ++-- .../EhSetting/EhSettingView+Sections3.swift | 8 ++-- 11 files changed, 59 insertions(+), 61 deletions(-) diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift index 06bc236e5..b61986a98 100644 --- a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift @@ -47,7 +47,7 @@ public struct DateSeekPickerView: View { let seekOlderButton = SeekButton( symbol: .chevronLeftChevronLeftDotted, - title: String(localized: .seekOlder), + title: .seekOlder, reversedIconTitlePosition: false, action: { seekAction(.older) } ) @@ -56,7 +56,7 @@ public struct DateSeekPickerView: View { let seekNewerButton = SeekButton( symbol: .chevronRightDottedChevronRight, - title: String(localized: .seekNewer), + title: .seekNewer, reversedIconTitlePosition: true, action: { seekAction(.newer) } ) @@ -88,7 +88,7 @@ public struct DateSeekPickerView: View { private struct SeekButton: View { let symbol: SFSymbol - let title: String + let title: LocalizedStringResource let reversedIconTitlePosition: Bool let action: () -> Void diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index 3d5917a09..96641f1d1 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -92,9 +92,7 @@ struct CommentsView: View { .sheet(item: $store.destination.postComment, id: \.self) { commentID in let hasCommentID = !commentID.wrappedValue.isEmpty PostCommentView( - title: hasCommentID - ? String(localized: .editComment) - : String(localized: .postComment), + title: hasCommentID ? .editComment : .postComment, content: $store.commentContent, isFocused: $store.postCommentFocused, postAction: { diff --git a/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift b/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift index a2bb7b744..82c61fa2a 100644 --- a/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift +++ b/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift @@ -2,7 +2,7 @@ import SwiftUI import AppComponents struct PostCommentView: View { - private let title: String + private let title: LocalizedStringResource @Binding private var content: String @Binding private var isFocused: Bool private let postAction: () -> Void @@ -12,7 +12,7 @@ struct PostCommentView: View { @FocusState private var isTextEditorFocused: Bool init( - title: String, + title: LocalizedStringResource, content: Binding, isFocused: Binding, postAction: @escaping () -> Void, diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index bdbf0281e..fe4b818e1 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -14,26 +14,26 @@ struct DescriptionSection: View { private var infos: [DescScrollInfo] {[ DescScrollInfo( - title: String(localized: .favorited), + title: .favorited, description: String(localized: .favoritedUnit), value: .init(galleryDetail.favoritedCount) ), DescScrollInfo( - title: String(localized: .RLocalizable.language), + title: .RLocalizable.language, description: galleryDetail.language.value, value: galleryDetail.language.abbreviation ), DescScrollInfo( - title: String(localized: .ratingsCount(galleryDetail.ratingCount)), + title: .ratingsCount(galleryDetail.ratingCount), description: .init(), value: .init(), rating: galleryDetail.rating, isRating: true ), DescScrollInfo( - title: String(localized: .pageCount), + title: .pageCount, description: String(localized: .pageCountUnit), value: .init(galleryDetail.pageCount) ), DescScrollInfo( - title: String(localized: .fileSize), + title: .fileSize, description: galleryDetail.sizeType, value: .init(galleryDetail.sizeCount) ) ]} @@ -71,15 +71,15 @@ struct DescriptionSection: View { extension DescriptionSection { struct DescScrollInfo: Identifiable, Equatable { - var id: String { title } - let title: String + var id: String { String(localized: title) } + let title: LocalizedStringResource let description: String let value: String var rating: Float = 0 var isRating = false } struct DescScrollItem: View { - let title: String + let title: LocalizedStringResource let value: String let description: String @@ -92,7 +92,7 @@ extension DescriptionSection { } } struct DescScrollRatingItem: View { - let title: String + let title: LocalizedStringResource let rating: Float var body: some View { diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 2e159438b..b571e2623 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -185,7 +185,7 @@ private extension DetailView { primaryModalModifiers(content: content) .sheet(item: $store.destination.postComment, id: \.id) { _ in PostCommentView( - title: String(localized: .postComment), + title: .postComment, content: $store.commentContent, isFocused: $store.postCommentFocused, postAction: { diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index 8a30d8651..5ec9dbab1 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -18,72 +18,72 @@ struct GalleryInfosView: View { private var infos: [Info] { [ - Info(title: String(localized: .metadataId), value: galleryDetail.gid), - Info(title: String(localized: .metadataToken), value: gallery.token), - Info(title: String(localized: .metadataTitle), value: galleryDetail.title), - Info(title: String(localized: .metadataJapaneseTitle), value: galleryDetail.jpnTitle), + Info(title: .metadataId, value: galleryDetail.gid), + Info(title: .metadataToken, value: gallery.token), + Info(title: .metadataTitle, value: galleryDetail.title), + Info(title: .metadataJapaneseTitle, value: galleryDetail.jpnTitle), Info( - title: String(localized: .metadataGalleryUrl), + title: .metadataGalleryUrl, value: gallery.galleryURL?.absoluteString ), Info( - title: String(localized: .metadataCoverUrl), + title: .metadataCoverUrl, value: galleryDetail.coverURL?.absoluteString ), Info( - title: String(localized: .metadataArchiveUrl), + title: .metadataArchiveUrl, value: galleryDetail.archiveURL?.absoluteString ), Info( - title: String(localized: .metadataTorrentUrl), + title: .metadataTorrentUrl, value: URLUtil.galleryTorrents(gid: gallery.gid, token: gallery.token).absoluteString ), Info( - title: String(localized: .metadataParentUrl), + title: .metadataParentUrl, value: galleryDetail.parentURL?.absoluteString ), Info( - title: String(localized: .metadataCategory), + title: .metadataCategory, value: galleryDetail.category.value ), - Info(title: String(localized: .metadataUploader), value: galleryDetail.uploader), + Info(title: .metadataUploader, value: galleryDetail.uploader), Info( - title: String(localized: .metadataPostedDate), + title: .metadataPostedDate, value: galleryDetail.formattedDateString ), Info( - title: String(localized: .metadataVisibility), + title: .metadataVisibility, value: galleryDetail.visibility.value ), - Info(title: String(localized: .metadataLanguage), value: galleryDetail.language.value), - Info(title: String(localized: .metadataPageCount), value: String(galleryDetail.pageCount)), + Info(title: .metadataLanguage, value: galleryDetail.language.value), + Info(title: .metadataPageCount, value: String(galleryDetail.pageCount)), Info( - title: String(localized: .metadataFileSize), + title: .metadataFileSize, value: String(Int(galleryDetail.sizeCount)) + galleryDetail.sizeType ), Info( - title: String(localized: .metadataFavoritedTimes), + title: .metadataFavoritedTimes, value: String(galleryDetail.favoritedCount) ), Info( - title: String(localized: .metadataFavorited), + title: .metadataFavorited, value: galleryDetail.isFavorited ? String(localized: .metadataYes) : String(localized: .metadataNo) ), Info( - title: String(localized: .metadataRatingCount), + title: .metadataRatingCount, value: String(galleryDetail.ratingCount) ), Info( - title: String(localized: .metadataAverageRating), + title: .metadataAverageRating, value: String(Int(galleryDetail.rating)) ), Info( - title: String(localized: .metadataMyRating), + title: .metadataMyRating, value: galleryDetail.userRating == 0 ? nil : String(Int(galleryDetail.userRating)) ), Info( - title: String(localized: .metadataTorrentCount), + title: .metadataTorrentCount, value: String(galleryDetail.torrentCount) ) ] @@ -117,8 +117,8 @@ struct GalleryInfosView: View { } private struct Info: Identifiable { - var id: Int { title.hashValue } - let title: String + var id: Int { String(localized: title).hashValue } + let title: LocalizedStringResource let value: String? } diff --git a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift index 805d97644..fcd42cb87 100644 --- a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift +++ b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift @@ -284,11 +284,11 @@ struct MiscGridSection: View { } struct MiscGridItem: View { - private let title: String - private let subTitle: String? + private let title: LocalizedStringResource + private let subTitle: LocalizedStringResource? private let symbol: SFSymbol - init(title: String, subTitle: String? = nil, symbol: SFSymbol) { + init(title: LocalizedStringResource, subTitle: LocalizedStringResource? = nil, symbol: SFSymbol) { self.title = title self.subTitle = subTitle self.symbol = symbol diff --git a/AppPackage/Sources/HomeFeature/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift index ad926ef0e..59668d80a 100644 --- a/AppPackage/Sources/HomeFeature/HomeView.swift +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -153,7 +153,7 @@ private extension HomeView { // MARK: Definition public enum HomeMiscGridType: CaseIterable, Identifiable, Sendable { - public var id: String { title } + public var id: String { String(localized: title) } case popular case watched @@ -161,14 +161,14 @@ public enum HomeMiscGridType: CaseIterable, Identifiable, Sendable { } extension HomeMiscGridType { - var title: String { + var title: LocalizedStringResource { switch self { case .popular: - return String(localized: .homeMiscGridTypePopular) + return .homeMiscGridTypePopular case .watched: - return String(localized: .homeMiscGridTypeWatched) + return .homeMiscGridTypeWatched case .history: - return String(localized: .homeMiscGridTypeHistory) + return .homeMiscGridTypeHistory } } var symbol: SFSymbol { diff --git a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift index c623131c8..8775c8d7c 100644 --- a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift @@ -15,7 +15,7 @@ struct LaboratorySettingView: View { VStack { LaboratoryCell( isOn: $bypassesSNIFiltering, - title: String(localized: .bypassesSniFiltering), + title: .bypassesSniFiltering, symbol: .theatermasksFill, tintColor: .purple ) } @@ -27,12 +27,12 @@ struct LaboratorySettingView: View { struct LaboratoryCell: View { @Binding private var isOn: Bool - private let title: String + private let title: LocalizedStringResource private let symbol: SFSymbol private let tintColor: Color init( - isOn: Binding, title: String, + isOn: Binding, title: LocalizedStringResource, symbol: SFSymbol, tintColor: Color ) { _isOn = isOn diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift index 2bb615bc5..6e0c78082 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -146,12 +146,12 @@ struct ImageSizeSettingsSection: View { Text(.imageSize) ValuePicker( - title: String(localized: .horizontal), + title: .horizontal, value: $ehSetting.imageSizeWidth, range: 0...65535, unit: "px" ) ValuePicker( - title: String(localized: .vertical), + title: .vertical, value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px" ) } header: { @@ -250,12 +250,12 @@ struct FrontPageSettingsSection: View { // MARK: Shared Helpers struct ValuePicker: View { - private let title: String + private let title: LocalizedStringResource @Binding var value: Float private let range: ClosedRange private let unit: String - init(title: String, value: Binding, range: ClosedRange, unit: String = "") { + init(title: LocalizedStringResource, value: Binding, range: ClosedRange, unit: String = "") { self.title = title _value = value self.range = range diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift index adedcc671..878a598e0 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -12,7 +12,7 @@ struct CoverScalingSection: View { var body: some View { Section { ValuePicker( - title: String(localized: .scaleFactor), + title: .scaleFactor, value: $ehSetting.coverScaleFactor, range: 75...150, unit: "%" @@ -33,7 +33,7 @@ struct TagFilteringThresholdSection: View { var body: some View { Section { ValuePicker( - title: String(localized: .tagFilteringThreshold), + title: .tagFilteringThreshold, value: $ehSetting.tagFilteringThreshold, range: -9999...0 ) } header: { @@ -52,7 +52,7 @@ struct TagWatchingThresholdSection: View { var body: some View { Section { ValuePicker( - title: String(localized: .tagWatchingThreshold), + title: .tagWatchingThreshold, value: $ehSetting.tagWatchingThreshold, range: 0...9999 ) } header: { @@ -209,7 +209,7 @@ struct ViewportOverrideSection: View { var body: some View { Section { ValuePicker( - title: String(localized: .virtualWidth), + title: .virtualWidth, value: $ehSetting.viewportVirtualWidth, range: 0...9999, unit: "px" From 4e3cacb7e4d1b1e58b82454ec61da265b2e0f47e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 10:32:23 +0800 Subject: [PATCH 487/614] Add resource overloads to toast caption Address finding #10. `AppAlertState.error/success(caption:)` is a mixed parameter: most callers pass a localized key, but two pass a genuinely dynamic runtime string (a server response in ArchivesReducer, a missing-files message in DownloadInspectorReducer) that must not be looked up as a localization key. Add a `LocalizedStringResource` overload alongside the existing `String?` one so the applicable chains express the resource directly, and route the six static-key call sites through it: - savedToPhotoLibrary / copiedToClipboardSucceeded - ArchivesReducer hath/resolution errors (x3) - DownloadInspectorReducer imageDataValid / imageDataUnavailable The two dynamic callers keep the `String?` overload. `.error()` with no argument still resolves unambiguously (only the String? overload has a default). Build, 308 tests, and SwiftLint all green. --- .../Sources/AppComponents/AppAlertState.swift | 22 +++++++++++++++++-- .../Archives/ArchivesReducer.swift | 6 ++--- .../DownloadInspectorReducer.swift | 8 ++----- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/AppPackage/Sources/AppComponents/AppAlertState.swift b/AppPackage/Sources/AppComponents/AppAlertState.swift index d976c6960..05e1d40b1 100644 --- a/AppPackage/Sources/AppComponents/AppAlertState.swift +++ b/AppPackage/Sources/AppComponents/AppAlertState.swift @@ -134,6 +134,17 @@ extension AppAlertState where Action == Never { title: TextState(localized: .communicating) ) } + // `caption` has two overloads: a `LocalizedStringResource` one for captions that come from a + // localized key (the applicable end of a chain), and a `String?` one for captions carrying a + // dynamic runtime value — a server response or a pre-formatted error message — that genuinely + // isn't a localization key and must not be looked up as one. + public static func error(caption: LocalizedStringResource) -> Self { + .init( + style: .toast(icon: .error, autoHide: true), + title: TextState(localized: .error), + message: TextState(localized: caption) + ) + } public static func error(caption: String? = nil) -> Self { .init( style: .toast(icon: .error, autoHide: true), @@ -141,6 +152,13 @@ extension AppAlertState where Action == Never { message: caption.map { TextState($0) } ) } + public static func success(caption: LocalizedStringResource) -> Self { + .init( + style: .toast(icon: .success, autoHide: true), + title: TextState(localized: .success), + message: TextState(localized: caption) + ) + } public static func success(caption: String? = nil) -> Self { .init( style: .toast(icon: .success, autoHide: true), @@ -149,10 +167,10 @@ extension AppAlertState where Action == Never { ) } public static var savedToPhotoLibrary: Self { - .success(caption: String(localized: .savedToPhotoLibrary)) + .success(caption: .savedToPhotoLibrary) } public static var copiedToClipboardSucceeded: Self { - .success(caption: String(localized: .copiedToClipboard)) + .success(caption: .copiedToClipboard) } } diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index 0930a73e4..38751d177 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -125,13 +125,13 @@ public struct ArchivesReducer: Sendable { case .success(let response): switch response { case String(localized: .Constant.responseHathClientNotFound): - state.toast = .error(caption: String(localized: .hathClientNotFound)) + state.toast = .error(caption: .hathClientNotFound) isSuccess = false case String(localized: .Constant.responseHathClientNotOnline): - state.toast = .error(caption: String(localized: .hathClientNotOnline)) + state.toast = .error(caption: .hathClientNotOnline) isSuccess = false case String(localized: .Constant.responseInvalidResolution): - state.toast = .error(caption: String(localized: .invalidResolution)) + state.toast = .error(caption: .invalidResolution) isSuccess = false default: state.toast = .success(caption: response) diff --git a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift index 1c39a9733..60a9438d8 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift @@ -217,17 +217,13 @@ private extension Optional where Wrapped == DownloadValidationState { var toastConfig: AppAlertState { switch self { case .some(.valid): - return .success( - caption: String(localized: .imageDataValid) - ) + return .success(caption: .imageDataValid) case .some(.missingFiles(let message)): return .error(caption: message) case nil: - return .error( - caption: String(localized: .imageDataUnavailable) - ) + return .error(caption: .imageDataUnavailable) } } } From 60db0bc19328ad14f1c848c91d8a7dbb1acb0274 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 10:57:12 +0800 Subject: [PATCH 488/614] Label numeric l10n args via substitutions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve finding #8 (labeled localized-format arguments). Every numeric format argument now surfaces as a labeled Swift parameter. Module-local numeric keys (8) → named %#@variable@ substitutions, so Xcode generates func key(variable: Int): - matches_count(count:) [plural moved inside the sub] - ratings_count(count:) [other-only, noun inside] - accessibility.downloading/paused(completed:total:), .partial(available:total:) - download_badge.progress(completed:total:) - jump_page_description(max:) - excluded_uploaders_count(used:limit:) [markdown preserved] Shared numeric funcs (8) in ResourceStringSymbols.swift relabeled from _ arg1 to semantic labels: days/hours/minutes/seconds/pages/stars(count:), downloadStorePage{ImageCorrupted,Missing}(page:). All call sites updated. String (%@) args stay positional: a %@ cannot be a substitution (a substitution is a plural variation, and plural selection needs a number, so a String renders (null) at runtime — verified in-simulator). Xcode cannot label string args and both reference repos leave them as _ arg1: String, so the 8 string keys keep positional %@. The plan's %{name}lld inline syntax is fictional (generates an argument-less var). Catalog version stays "1.0". Build, 308 tests, SwiftLint, the verify script, and a simulator spot-check (badge 3/40, accessibility label, jump-page bound, both **bold** markdown footers) all green. --- AGENTS.md | 2 + .../Resources/Localizable.xcstrings | 156 +++- .../AppComponents/TagSuggestionView.swift | 2 +- .../AppModels/Persistent/Setting.swift | 4 +- .../Sources/AppModels/Support/AppError.swift | 8 +- .../DetailView+HeaderSection.swift | 6 +- .../DetailFeature/DetailView+Subviews.swift | 2 +- .../Resources/Localizable.xcstrings | 882 +++++++++++++++--- .../DownloadStore+Operations.swift | 10 +- .../Sources/FiltersFeature/FiltersView.swift | 2 +- .../DownloadBadgeLabel.swift | 4 +- .../Resources/Localizable.xcstrings | 192 +++- .../Resources/Localizable.xcstrings | 108 ++- .../Toplists/ToplistsReducer.swift | 2 +- .../ReadingViewComponents.swift | 2 +- .../ReadingSettingView.swift | 2 +- .../Resources/ResourceStringSymbols.swift | 32 +- .../EhSetting/EhSettingView+Sections3.swift | 2 +- .../Resources/Localizable.xcstrings | 242 ++++- .../DownloadCoordinatorStorageTests.swift | 2 +- .../DownloadStoreHashTests.swift | 2 +- .../DownloadStoreTests.swift | 4 +- 22 files changed, 1411 insertions(+), 257 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2f50b8fea..9462887a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,8 @@ This file gives coding agents a reliable working guide for this repository. **Read SwiftLint rules**: Before writing or changing Swift code, read the root `.swiftlint.yml` to learn the project's lint rules, including the custom regex rules and banned APIs it defines. Write code that conforms to those rules from the start, and resolve every violation at its root. Suppressing a rule, disabling it, adding a `// swiftlint:disable`, or otherwise removing it, is forbidden without the user's explicit permission. +**Labeled localized-format arguments**: Surface every *numeric* localized-format argument as a labeled Swift parameter via a named `%#@variable@` substitution: module-local keys generate `func key(variable: Int)`; shared keys carry semantic labels hand-written in `ResourceStringSymbols.swift`. Never put a bare numeric specifier (`%lld`, `%d`, …) in a module-local catalog's outer value or top-level plural variant. Keep *string* (`%@`) arguments positional (auto-generated `func key(_ arg1: String)`); never make a `String` a substitution. Keep substitution plural categories coherent: a variable's `en` category set must equal its `de` set; `ja`/`ko`/`zh-Hans`/`zh-Hant` are `other`-only. + **Confirmation dialog / alert placement**: Attach a `.confirmationDialog`/`.alert` modifier to a UI element that is both **stable** (stays in the hierarchy until the dialog is dismissed — being `.disabled` is fine, being removed or `.opacity`-hidden is not) and the **action source** (the control that triggers it). On iPad these render as popovers anchored to the view the modifier is attached to, so the anchor must be the triggering control for the arrow to point at the right place; and if that view leaves the hierarchy while the dialog is up, the dialog is torn down with it. Do not move such a modifier onto a transient or unrelated container (a whole `Form`/`List`, or a view gated by a condition) for convenience — keep it on the triggering button/row. When the trigger lives inside a subview, thread the store-scoped dialog binding into that subview and attach it there rather than hoisting the modifier to an ancestor. Exception: for a per-row destructive action whose row can scroll out of view, the stable action-source is the enclosing list container, so attach it there. ## Project structure diff --git a/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings b/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings index 58d80cc56..6c474b5c3 100644 --- a/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings @@ -210,84 +210,144 @@ "extractionState": "manual", "localizations": { "en": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "Found %lld match." - } - }, - "other": { - "stringUnit": { - "state": "translated", - "value": "Found %lld matches." + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Found %arg match." + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Found %arg matches." + } + } } } } } }, "de": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%lld Treffer gefunden." - } - }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld Treffer gefunden." + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%arg Treffer gefunden." + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg Treffer gefunden." + } + } } } } } }, "ja": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 件の該当項目" + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg 件の該当項目" + } + } } } } } }, "ko": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "검색 결과 %lld개" + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "검색 결과 %arg개" + } + } } } } } }, "zh-Hans": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "找到 %lld 项结果" + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "找到 %arg 项结果" + } + } } } } } }, "zh-Hant": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "找到 %lld 項結果" + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "找到 %arg 項結果" + } + } } } } diff --git a/AppPackage/Sources/AppComponents/TagSuggestionView.swift b/AppPackage/Sources/AppComponents/TagSuggestionView.swift index 491bf2d85..95e6589a6 100644 --- a/AppPackage/Sources/AppComponents/TagSuggestionView.swift +++ b/AppPackage/Sources/AppComponents/TagSuggestionView.swift @@ -25,7 +25,7 @@ public struct TagSuggestionView: View { public var body: some View { if isEnabled { if DeviceUtil.isPhone { - Text(.matchesCount(translationHandler.suggestions.count)) + Text(.matchesCount(count: translationHandler.suggestions.count)) .foregroundColor(.secondary) .font(.subheadline) } diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index a97174ef2..6b921de6b 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -173,9 +173,9 @@ extension AutoLockPolicy { case .instantly: return String(localized: .autoLockPolicyInstantly) case .sec15: - return String(localized: .RLocalizable.seconds(rawValue)) + return String(localized: .RLocalizable.seconds(count: rawValue)) case .min1, .min5, .min10, .min30: - return String(localized: .RLocalizable.minutes(rawValue / 60)) + return String(localized: .RLocalizable.minutes(count: rawValue / 60)) } } } diff --git a/AppPackage/Sources/AppModels/Support/AppError.swift b/AppPackage/Sources/AppModels/Support/AppError.swift index 39fb226c5..c60ac1124 100644 --- a/AppPackage/Sources/AppModels/Support/AppError.swift +++ b/AppPackage/Sources/AppModels/Support/AppError.swift @@ -141,15 +141,15 @@ extension BanInterval { } private func daysWithUnit(_ days: Int) -> String { - String(localized: .RLocalizable.days(days)) + String(localized: .RLocalizable.days(count: days)) } private func hoursWithUnit(_ hours: Int) -> String { - String(localized: .RLocalizable.hours(hours)) + String(localized: .RLocalizable.hours(count: hours)) } private func minutesWithUnit(_ minutes: Int) -> String { - String(localized: .RLocalizable.minutes(minutes)) + String(localized: .RLocalizable.minutes(count: minutes)) } private func secondsWithUnit(_ seconds: Int) -> String { - String(localized: .RLocalizable.seconds(seconds)) + String(localized: .RLocalizable.seconds(count: seconds)) } } diff --git a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift index f3fde29b4..36c51ae09 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift @@ -309,13 +309,13 @@ extension HeaderSection { return String(localized: .accessibilityQueued) case .active: let downloading = String(localized: .accessibilityDownloading( - progress.completedPageCount, progress.displayPageCount + completed: progress.completedPageCount, total: progress.displayPageCount )) return [downloading, String(localized: .accessibilityPauseAction)] .joined(separator: ". ") case .inactive: return String(localized: .accessibilityPaused( - progress.completedPageCount, progress.displayPageCount + completed: progress.completedPageCount, total: progress.displayPageCount )) case .completed: return String(localized: .accessibilityDownloaded) @@ -324,7 +324,7 @@ extension HeaderSection { case .error: if isPartialDownloadError { return String(localized: .accessibilityPartial( - progress.completedPageCount, progress.displayPageCount + available: progress.completedPageCount, total: progress.displayPageCount )) } return downloadNeedsRepair diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index fe4b818e1..2fce12b23 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -24,7 +24,7 @@ struct DescriptionSection: View { value: galleryDetail.language.abbreviation ), DescScrollInfo( - title: .ratingsCount(galleryDetail.ratingCount), + title: .ratingsCount(count: galleryDetail.ratingCount), description: .init(), value: .init(), rating: galleryDetail.rating, isRating: true ), DescScrollInfo( diff --git a/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings index 6ec84af25..6019d8264 100644 --- a/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings @@ -89,37 +89,217 @@ "en": { "stringUnit": { "state": "translated", - "value": "Downloading %lld of %lld" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Lädt %lld von %lld herunter" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld / %lld ページをダウンロード中" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%lld / %lld 페이지 다운로드 중" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在下载第 %lld / %lld 页" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "正在下載第 %lld / %lld 頁" + "value": "Downloading %#@completed@ of %#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt %#@completed@ von %#@total@ herunter" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%#@completed@ / %#@total@ ページをダウンロード中" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%#@completed@ / %#@total@ 페이지 다운로드 중" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在下载第 %#@completed@ / %#@total@ 页" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在下載第 %#@completed@ / %#@total@ 頁" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } } } @@ -171,37 +351,217 @@ "en": { "stringUnit": { "state": "translated", - "value": "Retry download. %lld of %lld pages are already available." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Download erneut versuchen. %lld von %lld Seiten sind bereits verfügbar." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダウンロードを再試行。すでに %lld / %lld ページが利用可能です。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다운로드 다시 시도. 이미 %lld / %lld 페이지를 사용할 수 있습니다." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重新下载,已有 %lld / %lld 页可用。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重新下載,已有 %lld / %lld 頁可用。" + "value": "Retry download. %#@available@ of %#@total@ pages are already available." + }, + "substitutions": { + "available": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download erneut versuchen. %#@available@ von %#@total@ Seiten sind bereits verfügbar." + }, + "substitutions": { + "available": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを再試行。すでに %#@available@ / %#@total@ ページが利用可能です。" + }, + "substitutions": { + "available": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 다시 시도. 이미 %#@available@ / %#@total@ 페이지를 사용할 수 있습니다." + }, + "substitutions": { + "available": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新下载,已有 %#@available@ / %#@total@ 页可用。" + }, + "substitutions": { + "available": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新下載,已有 %#@available@ / %#@total@ 頁可用。" + }, + "substitutions": { + "available": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } } } @@ -253,37 +613,217 @@ "en": { "stringUnit": { "state": "translated", - "value": "Resume download. Paused at %lld of %lld" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Download fortsetzen. Pausiert bei %lld von %lld" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダウンロードを再開。%lld / %lld ページで停止中" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다운로드 다시 시작. %lld / %lld 페이지에서 일시 정지됨" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "继续下载,当前暂停在第 %lld / %lld 页" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "繼續下載,目前暫停在第 %lld / %lld 頁" + "value": "Resume download. Paused at %#@completed@ of %#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download fortsetzen. Pausiert bei %#@completed@ von %#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを再開。%#@completed@ / %#@total@ ページで停止中" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 다시 시작. %#@completed@ / %#@total@ 페이지에서 일시 정지됨" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "继续下载,当前暂停在第 %#@completed@ / %#@total@ 页" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "繼續下載,目前暫停在第 %#@completed@ / %#@total@ 頁" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } } } @@ -2549,37 +3089,133 @@ "en": { "stringUnit": { "state": "translated", - "value": "%lld Ratings" + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg Ratings" + } + } + } + } + } } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Bewertungen" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld 件の評価" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%lld명의 별점" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%lld 个评分" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%lld 個評分" + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg Bewertungen" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg 件の評価" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg명의 별점" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg 个评分" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg 個評分" + } + } + } + } + } } } } diff --git a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift index 1f7ce1006..e38509ca7 100644 --- a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift @@ -91,13 +91,13 @@ extension DownloadStore { } guard let relativePath = existingPages[index] else { throw AppError.fileOperationFailed( - String(localized: .RLocalizable.downloadStorePageMissing(index)) + String(localized: .RLocalizable.downloadStorePageMissing(page: index)) ) } pages[index] = try hashReadableAsset( folderURL: folderURL, relativePath: relativePath, - missingMessage: String(localized: .RLocalizable.downloadStorePageMissing(index)) + missingMessage: String(localized: .RLocalizable.downloadStorePageMissing(page: index)) ) } @@ -152,7 +152,7 @@ extension DownloadStore { pages[index] = try hashReadableAsset( folderURL: folderURL, relativePath: refreshedRelativePath, - missingMessage: String(localized: .RLocalizable.downloadStorePageMissing(index)) + missingMessage: String(localized: .RLocalizable.downloadStorePageMissing(page: index)) ) didUpdate = true } @@ -290,12 +290,12 @@ extension DownloadStore { let pageURL = validatedChildURL(root: folderURL, relativePath: relativePath), sanitizeAssetFileIfNeeded(at: pageURL) else { - return .missingFiles(String(localized: .RLocalizable.downloadStorePageMissing(index))) + return .missingFiles(String(localized: .RLocalizable.downloadStorePageMissing(page: index))) } if verifiesContentHash, (try? fileHash(at: pageURL)) != expectedHash { return .missingFiles( - String(localized: .RLocalizable.downloadStorePageImageCorrupted(index)) + String(localized: .RLocalizable.downloadStorePageImageCorrupted(page: index)) ) } diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index 023df711c..b0b8c819d 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -153,7 +153,7 @@ private struct MinimumRatingSetter: View { var body: some View { Picker(.minimumRating, selection: $minimum) { ForEach(Array(2...5), id: \.self) { number in - Text(.RLocalizable.stars(number)).tag(number) + Text(.RLocalizable.stars(count: number)).tag(number) } } .pickerStyle(.menu) diff --git a/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift b/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift index a4735d818..51bbfc4ea 100644 --- a/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift +++ b/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift @@ -28,8 +28,8 @@ public struct DownloadBadgeLabel: View { private var progressText: String { String(localized: .downloadBadgeProgress( - badge.progress.displayCompletedPageCount, - badge.progress.displayPageCount + completed: badge.progress.displayCompletedPageCount, + total: badge.progress.displayPageCount )) } diff --git a/AppPackage/Sources/GalleryListComponents/Resources/Localizable.xcstrings b/AppPackage/Sources/GalleryListComponents/Resources/Localizable.xcstrings index 7528e8e4d..61c5420f4 100644 --- a/AppPackage/Sources/GalleryListComponents/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/GalleryListComponents/Resources/Localizable.xcstrings @@ -171,37 +171,217 @@ "en": { "stringUnit": { "state": "translated", - "value": "%lld/%lld" + "value": "%#@completed@/%#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "de": { "stringUnit": { "state": "translated", - "value": "%lld/%lld" + "value": "%#@completed@/%#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "ja": { "stringUnit": { "state": "translated", - "value": "%lld/%lld" + "value": "%#@completed@/%#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "ko": { "stringUnit": { "state": "translated", - "value": "%lld/%lld" + "value": "%#@completed@/%#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%lld/%lld" + "value": "%#@completed@/%#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "%lld/%lld" + "value": "%#@completed@/%#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } } } diff --git a/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings index 344ba5cae..78133707a 100644 --- a/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings @@ -294,37 +294,133 @@ "en": { "stringUnit": { "state": "translated", - "value": "Enter a page number between 1 and %lld to jump to." + "value": "Enter a page number between 1 and %#@max@ to jump to." + }, + "substitutions": { + "max": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "de": { "stringUnit": { "state": "translated", - "value": "Geben Sie eine Seitenzahl zwischen 1 und %lld ein." + "value": "Geben Sie eine Seitenzahl zwischen 1 und %#@max@ ein." + }, + "substitutions": { + "max": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "ja": { "stringUnit": { "state": "translated", - "value": "1 ~ %lld のページ番号を入力してください。" + "value": "1 ~ %#@max@ のページ番号を入力してください。" + }, + "substitutions": { + "max": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "ko": { "stringUnit": { "state": "translated", - "value": "1에서 %lld 사이의 페이지 번호를 입력하세요." + "value": "1에서 %#@max@ 사이의 페이지 번호를 입력하세요." + }, + "substitutions": { + "max": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "请输入 1 到 %lld 之间的页码。" + "value": "请输入 1 到 %#@max@ 之间的页码。" + }, + "substitutions": { + "max": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "請輸入 1 到 %lld 之間的頁碼。" + "value": "請輸入 1 到 %#@max@ 之間的頁碼。" + }, + "substitutions": { + "max": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } } } diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index 256b99fc6..5146681c8 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -128,7 +128,7 @@ public struct ToplistsReducer: Sendable { } }, message: { - TextState(localized: .jumpPageDescription(maximumPage)) + TextState(localized: .jumpPageDescription(max: maximumPage)) } ) return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) diff --git a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift index 252cefccf..7a4c86b8a 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift @@ -37,7 +37,7 @@ extension AutoPlayPolicy { case .off: return String(localized: .autoPlayPolicyOff) default: - return String(localized: .RLocalizable.seconds(rawValue)) + return String(localized: .RLocalizable.seconds(count: rawValue)) } } } diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index 39f7106c3..8030c1085 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -35,7 +35,7 @@ public struct ReadingSettingView: View { .pickerStyle(.menu) Picker(.preloadLimit, selection: $prefetchLimit) { ForEach(Array(stride(from: 6, through: 18, by: 4)), id: \.self) { value in - Text(.RLocalizable.pages(value)).tag(value) + Text(.RLocalizable.pages(count: value)).tag(value) } } .pickerStyle(.menu) diff --git a/AppPackage/Sources/Resources/ResourceStringSymbols.swift b/AppPackage/Sources/Resources/ResourceStringSymbols.swift index d015fe282..5fb7a933c 100644 --- a/AppPackage/Sources/Resources/ResourceStringSymbols.swift +++ b/AppPackage/Sources/Resources/ResourceStringSymbols.swift @@ -39,10 +39,10 @@ public nonisolated extension LocalizedStringResource { ) } - public static func days(_ arg1: Int) -> LocalizedStringResource { + public static func days(count: Int) -> LocalizedStringResource { LocalizedStringResource( "days", - defaultValue: "\(arg1, specifier: "%lld")", + defaultValue: "\(count, specifier: "%lld")", table: "Localizable", bundle: resourceStringSymbolsBundleDescription ) @@ -104,19 +104,19 @@ public nonisolated extension LocalizedStringResource { ) } - public static func downloadStorePageImageCorrupted(_ arg1: Int) -> LocalizedStringResource { + public static func downloadStorePageImageCorrupted(page: Int) -> LocalizedStringResource { LocalizedStringResource( "download_store.page_image_corrupted", - defaultValue: "\(arg1, specifier: "%lld")", + defaultValue: "\(page, specifier: "%lld")", table: "Localizable", bundle: resourceStringSymbolsBundleDescription ) } - public static func downloadStorePageMissing(_ arg1: Int) -> LocalizedStringResource { + public static func downloadStorePageMissing(page: Int) -> LocalizedStringResource { LocalizedStringResource( "download_store.page_missing", - defaultValue: "\(arg1, specifier: "%lld")", + defaultValue: "\(page, specifier: "%lld")", table: "Localizable", bundle: resourceStringSymbolsBundleDescription ) @@ -154,10 +154,10 @@ public nonisolated extension LocalizedStringResource { ) } - public static func hours(_ arg1: Int) -> LocalizedStringResource { + public static func hours(count: Int) -> LocalizedStringResource { LocalizedStringResource( "hours", - defaultValue: "\(arg1, specifier: "%lld")", + defaultValue: "\(count, specifier: "%lld")", table: "Localizable", bundle: resourceStringSymbolsBundleDescription ) @@ -195,19 +195,19 @@ public nonisolated extension LocalizedStringResource { ) } - public static func minutes(_ arg1: Int) -> LocalizedStringResource { + public static func minutes(count: Int) -> LocalizedStringResource { LocalizedStringResource( "minutes", - defaultValue: "\(arg1, specifier: "%lld")", + defaultValue: "\(count, specifier: "%lld")", table: "Localizable", bundle: resourceStringSymbolsBundleDescription ) } - public static func pages(_ arg1: Int) -> LocalizedStringResource { + public static func pages(count: Int) -> LocalizedStringResource { LocalizedStringResource( "pages", - defaultValue: "\(arg1, specifier: "%lld")", + defaultValue: "\(count, specifier: "%lld")", table: "Localizable", bundle: resourceStringSymbolsBundleDescription ) @@ -237,10 +237,10 @@ public nonisolated extension LocalizedStringResource { ) } - public static func seconds(_ arg1: Int) -> LocalizedStringResource { + public static func seconds(count: Int) -> LocalizedStringResource { LocalizedStringResource( "seconds", - defaultValue: "\(arg1, specifier: "%lld")", + defaultValue: "\(count, specifier: "%lld")", table: "Localizable", bundle: resourceStringSymbolsBundleDescription ) @@ -262,10 +262,10 @@ public nonisolated extension LocalizedStringResource { ) } - public static func stars(_ arg1: Int) -> LocalizedStringResource { + public static func stars(count: Int) -> LocalizedStringResource { LocalizedStringResource( "stars", - defaultValue: "\(arg1, specifier: "%lld")", + defaultValue: "\(count, specifier: "%lld")", table: "Localizable", bundle: resourceStringSymbolsBundleDescription ) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift index 878a598e0..86519ca2a 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -194,7 +194,7 @@ struct ExcludedUploadersSection: View { } footer: { Text( String(localized: .excludedUploadersCount( - ehSetting.excludedUploaders.ehSettingLineCount, 1000 + used: ehSetting.excludedUploaders.ehSettingLineCount, limit: 1000 )) .localizedKey ) diff --git a/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings index 1ee61accb..225f2b3ee 100644 --- a/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings @@ -1975,37 +1975,217 @@ "en": { "stringUnit": { "state": "translated", - "value": "You are currently using **%lld / %lld** exclusion slots." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Du belegst derzeit **%lld / %lld** Ausschlussplätze." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "現時点で **%lld / %lld** の排除スロットが使用済みです。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "**%lld / %lld** 개의 슬롯을 사용하고 있어요." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已使用 **%lld / %lld** 个屏蔽槽位。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "你正在使用 **%lld / %lld** 排除欄位" + "value": "You are currently using **%#@used@ / %#@limit@** exclusion slots." + }, + "substitutions": { + "used": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "limit": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du belegst derzeit **%#@used@ / %#@limit@** Ausschlussplätze." + }, + "substitutions": { + "used": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "limit": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現時点で **%#@used@ / %#@limit@** の排除スロットが使用済みです。" + }, + "substitutions": { + "used": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "limit": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "**%#@used@ / %#@limit@** 개의 슬롯을 사용하고 있어요." + }, + "substitutions": { + "used": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "limit": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已使用 **%#@used@ / %#@limit@** 个屏蔽槽位。" + }, + "substitutions": { + "used": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "limit": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你正在使用 **%#@used@ / %#@limit@** 排除欄位" + }, + "substitutions": { + "used": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "limit": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } } } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift index df70d570d..f6dc1a89a 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift @@ -404,7 +404,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { let validation = await manager.validateImageData(gid: "440") - #expect(validation == .missingFiles(String(localized: .RLocalizable.downloadStorePageMissing(1)))) + #expect(validation == .missingFiles(String(localized: .RLocalizable.downloadStorePageMissing(page: 1)))) let download = try #require(await manager.fetchDownload(gid: "440")) #expect(download.displayStatus == .error) #expect(download.displayStatus == .error) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift index d9e8d022d..62bb17fbc 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift @@ -24,7 +24,7 @@ struct DownloadStoreHashTests { #expect( storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( - String(localized: .RLocalizable.downloadStorePageImageCorrupted(2)) + String(localized: .RLocalizable.downloadStorePageImageCorrupted(page: 2)) ) ) } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift index 980425de6..51a491acc 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift @@ -132,7 +132,7 @@ struct DownloadStoreTests { #expect( storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( - String(localized: .RLocalizable.downloadStorePageMissing(2)) + String(localized: .RLocalizable.downloadStorePageMissing(page: 2)) ) ) } @@ -169,7 +169,7 @@ struct DownloadStoreTests { #expect( storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( - String(localized: .RLocalizable.downloadStorePageMissing(1)) + String(localized: .RLocalizable.downloadStorePageMissing(page: 1)) ) ) #expect( From f99739b261494239fab86fba3404f589bcd35f72 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 11:45:41 +0800 Subject: [PATCH 489/614] Stop Xcode auto-extracting non-string UI text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build extracted junk keys (empty strings, bare interpolations, a visual dash, unit suffixes) into the catalogs because SwiftUI treats Text string literals/interpolations as LocalizedStringKey. Fix the sources so nothing spurious is extracted (verified: a clean rebuild re-adds no keys). - App name: keep CFBundleName in InfoPlist.xcstrings, marked shouldTranslate:false (accepted, not translated). - Empty strings → proper labels: 9 ConfirmationDialogState empty titles become titleVisibility:.hidden with the action's label as a hidden title (no UX change); the NewDawn preview host and an EhSetting grid corner use Color.clear / a hidden .language label that keeps the row height; the Search subtitle workaround uses Text(verbatim:). - %@\n%@: TagSuggestionView builds its two-line search-completion label as an AttributedString instead of an interpolated Text. - %lld: six int-interpolation Texts (Torrents, Previews, ControlPanel, Appearance) use Text(value, format: .number) for locale formatting. - Visual/unit text → do-not-translate Constant keys: the pages-range dash (range_separator) and the pt/x unit suffixes (point_value "%lldpt", scale_factor "%@x") move to new Constant.xcstrings files. --- App/InfoPlist.xcstrings | 13 ++++++++ .../Sources/AppComponents/NewDawnView.swift | 2 +- .../AppComponents/TagSuggestionView.swift | 19 +++++++++++- .../FolderManager/FolderManagerReducer.swift | 4 +-- .../DetailFeature/Previews/PreviewsView.swift | 2 +- .../DetailFeature/Torrents/TorrentsView.swift | 6 ++-- .../FiltersFeature/FiltersReducer.swift | 4 +-- .../Sources/FiltersFeature/FiltersView.swift | 2 +- .../Resources/Constant.xcstrings | 18 +++++++++++ .../HomeFeature/History/HistoryReducer.swift | 4 +-- .../MigrationFeature/MigrationReducer.swift | 4 +-- .../QuickSearchReducer.swift | 4 +-- .../ReadingFeature/Support/ControlPanel.swift | 2 +- .../ReadingSettingView.swift | 8 ++--- .../Resources/Constant.xcstrings | 30 +++++++++++++++++++ .../SearchFeature/SearchRootView.swift | 5 ++-- .../AccountSettingReducer.swift | 4 +-- .../AppearanceSettingView.swift | 2 +- .../EhSetting/EhSettingReducer.swift | 4 +-- .../EhSetting/EhSettingView+Sections3.swift | 5 +++- .../GeneralSettingReducer.swift | 8 ++--- 21 files changed, 116 insertions(+), 34 deletions(-) create mode 100644 AppPackage/Sources/FiltersFeature/Resources/Constant.xcstrings create mode 100644 AppPackage/Sources/ReadingSettingFeature/Resources/Constant.xcstrings diff --git a/App/InfoPlist.xcstrings b/App/InfoPlist.xcstrings index f074dbbd2..0a5a4752d 100644 --- a/App/InfoPlist.xcstrings +++ b/App/InfoPlist.xcstrings @@ -1,6 +1,19 @@ { "sourceLanguage": "en", "strings": { + "CFBundleName": { + "comment": "Bundle name", + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + } + } + }, "NSFaceIDUsageDescription": { "extractionState": "manual", "localizations": { diff --git a/AppPackage/Sources/AppComponents/NewDawnView.swift b/AppPackage/Sources/AppComponents/NewDawnView.swift index f25dda795..6a42b0b11 100644 --- a/AppPackage/Sources/AppComponents/NewDawnView.swift +++ b/AppPackage/Sources/AppComponents/NewDawnView.swift @@ -156,7 +156,7 @@ private struct SunBeamItem: View { struct NewDawnView_Previews: PreviewProvider { static var previews: some View { - Text("") + Color.clear .sheet(isPresented: .constant(true)) { NewDawnView(greeting: .mock) } diff --git a/AppPackage/Sources/AppComponents/TagSuggestionView.swift b/AppPackage/Sources/AppComponents/TagSuggestionView.swift index 95e6589a6..ee5a05122 100644 --- a/AppPackage/Sources/AppComponents/TagSuggestionView.swift +++ b/AppPackage/Sources/AppComponents/TagSuggestionView.swift @@ -62,6 +62,23 @@ private struct SuggestionCell: View { return showsImages ? value : value.emojisRipped } + // Two-line label for the search-completion suggestion, built as an `AttributedString` rather + // than an interpolated `Text("\(…)\n\(…)")` so Xcode does not extract a "%@\n%@" catalog key. + // Markdown is parsed inline (matching `Text(_ key: LocalizedStringKey)` above) since the tag + // strings are dynamic content, not localization keys. + private var searchCompletionLabel: AttributedString { + var label = Self.markdown(displayValue) + label.append(AttributedString("\n")) + label.append(Self.markdown(suggestion.displayKey)) + return label + } + private static func markdown(_ string: String) -> AttributedString { + (try? AttributedString( + markdown: string, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(string) + } + var body: some View { if DeviceUtil.isPhone { HStack(spacing: 20) { @@ -96,7 +113,7 @@ private struct SuggestionCell: View { .contentShape(.rect) .onTapGesture(perform: action) } else { - Text("\(Text(displayValue.localizedKey))\n\(Text(suggestion.displayKey.localizedKey))") + Text(searchCompletionLabel) .searchCompletion(suggestion.tag.searchKeyword) } } diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift index 3e5d09684..6c9a12ea1 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift @@ -84,8 +84,8 @@ public struct FolderManagerReducer: Sendable { return .none case .deleteButtonTapped(let folder): - state.confirmationDialog = ConfirmationDialogState { - TextState("") + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .RLocalizable.delete) } actions: { ButtonState(role: .destructive, action: .confirmDelete(folder)) { TextState(localized: .RLocalizable.delete) diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift index 21b918e61..e7fc3deab 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift @@ -47,7 +47,7 @@ struct PreviewsView: View { } label: { PreviewImageView(originalURL: displayPreviewURLs[index]) } - Text("\(index)") + Text(index, format: .number) .font(DeviceUtil.isPadWidth ? .callout : .caption) .foregroundColor(.secondary) } diff --git a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift index 91476e857..2d66b7d62 100644 --- a/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift @@ -72,15 +72,15 @@ private extension TorrentsView { HStack(spacing: 10) { HStack(spacing: 3) { Image(systemSymbol: .arrowUpCircle) - Text("\(torrent.seedCount)") + Text(torrent.seedCount, format: .number) } HStack(spacing: 3) { Image(systemSymbol: .arrowDownCircle) - Text("\(torrent.peerCount)") + Text(torrent.peerCount, format: .number) } HStack(spacing: 3) { Image(systemSymbol: .checkmarkCircle) - Text("\(torrent.downloadCount)") + Text(torrent.downloadCount, format: .number) } Spacer() HStack(spacing: 3) { diff --git a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift index e2bc8f3fd..8b4d54d21 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift @@ -65,8 +65,8 @@ public struct FiltersReducer: Sendable { return .none case .resetFiltersButtonTapped: - state.confirmationDialog = ConfirmationDialogState { - TextState("") + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .reset) } actions: { ButtonState(role: .destructive, action: .confirmReset) { TextState(localized: .reset) diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index b0b8c819d..d284f57df 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -186,7 +186,7 @@ private struct PagesRangeSetter: View { SettingTextField(text: $lowerBound) .focused(focusedBound, equals: .lower) .submitLabel(.next) - Text("-") + Text(.Constant.rangeSeparator) SettingTextField(text: $upperBound) .focused(focusedBound, equals: .upper) .submitLabel(.done) diff --git a/AppPackage/Sources/FiltersFeature/Resources/Constant.xcstrings b/AppPackage/Sources/FiltersFeature/Resources/Constant.xcstrings new file mode 100644 index 000000000..5e80b4170 --- /dev/null +++ b/AppPackage/Sources/FiltersFeature/Resources/Constant.xcstrings @@ -0,0 +1,18 @@ +{ + "sourceLanguage": "en", + "strings": { + "range_separator": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "-" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index bb681f96d..4bdd60694 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -73,8 +73,8 @@ public struct HistoryReducer: Sendable { return .none case .clearHistoryButtonTapped: - state.confirmationDialog = ConfirmationDialogState { - TextState("") + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .RLocalizable.clear) } actions: { ButtonState(role: .destructive, action: .confirmClearHistory) { TextState(localized: .RLocalizable.clear) diff --git a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift index 3e28544d6..042c56282 100644 --- a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift +++ b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift @@ -44,8 +44,8 @@ public struct MigrationReducer: Sendable { return .none case .dropDatabaseButtonTapped: - state.confirmationDialog = ConfirmationDialogState { - TextState("") + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .dropDatabase) } actions: { ButtonState(role: .destructive, action: .confirmDropDatabase) { TextState(localized: .dropDatabase) diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift index 11c3008e2..999b122b6 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift @@ -89,8 +89,8 @@ public struct QuickSearchReducer: Sendable { return .none case .deleteWordButtonTapped(let word): - state.confirmationDialog = ConfirmationDialogState { - TextState("") + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .RLocalizable.delete) } actions: { ButtonState(role: .destructive, action: .confirmDelete(word)) { TextState(localized: .RLocalizable.delete) diff --git a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift index 293dd2c5b..9b636f46d 100644 --- a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift +++ b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift @@ -329,7 +329,7 @@ private struct SliderPreivew: View { PreviewImageView(originalURL: previewURLs[index]) .frame(width: previewWidth, height: showsSliderPreview ? previewHeight : 0) - Text("\(index)") + Text(index, format: .number) .font(DeviceUtil.isPadWidth ? .callout : .caption) .foregroundColor(index == Int(sliderValue) ? .accentColor : .secondary) } diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index 8030c1085..c9074695b 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -49,7 +49,7 @@ public struct ReadingSettingView: View { selection: $contentDividerHeight ) { ForEach(Array(stride(from: 0, through: 20, by: 5)), id: \.self) { value in - Text("\(value)pt").tag(Double(value)) + Text(.Constant.pointValue(value)).tag(Double(value)) } } .pickerStyle(.menu) @@ -91,13 +91,13 @@ private struct ScaleFactorRow: View { HStack { Text(labelContent) Spacer() - Text("\(scaleFactor.roundedString())x").foregroundStyle(.tint) + Text(.Constant.scaleFactor(scaleFactor.roundedString())).foregroundStyle(.tint) } Slider( value: $scaleFactor, in: minFactor...maxFactor, step: 0.5, - minimumValueLabel: Text("\(minFactor.roundedString())x") + minimumValueLabel: Text(.Constant.scaleFactor(minFactor.roundedString())) .fontWeight(.medium).font(.callout), - maximumValueLabel: Text("\(maxFactor.roundedString())x") + maximumValueLabel: Text(.Constant.scaleFactor(maxFactor.roundedString())) .fontWeight(.medium).font(.callout), label: EmptyView.init ) diff --git a/AppPackage/Sources/ReadingSettingFeature/Resources/Constant.xcstrings b/AppPackage/Sources/ReadingSettingFeature/Resources/Constant.xcstrings new file mode 100644 index 000000000..d17fe870f --- /dev/null +++ b/AppPackage/Sources/ReadingSettingFeature/Resources/Constant.xcstrings @@ -0,0 +1,30 @@ +{ + "sourceLanguage": "en", + "strings": { + "point_value": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lldpt" + } + } + } + }, + "scale_factor": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@x" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index 96270e6ea..9d5aa1248 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -79,10 +79,11 @@ public struct SearchRootView: View { .toolbar(content: toolbar) .navigationTitle(.RLocalizable.search) - // Workaround: Prevent the title disappearing issue. + // Workaround: Prevent the title disappearing issue. The blank subtitle only reserves + // layout; `verbatim` keeps Xcode from extracting it as a localizable " " key. if store.historyKeywords.isEmpty && store.historyGalleries.isEmpty { content - .navigationSubtitle(Text(" ")) + .navigationSubtitle(Text(verbatim: " ")) } else { content } diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index bd54d3c7c..50dd0508c 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -83,8 +83,8 @@ public struct AccountSettingReducer: Sendable { return .none case .logoutButtonTapped: - state.confirmationDialog = ConfirmationDialogState { - TextState("") + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .logout) } actions: { ButtonState(role: .destructive, action: .confirmLogout) { TextState(localized: .logout) diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift index ef0c95913..6a2e584fc 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -82,7 +82,7 @@ struct AppearanceSettingView: View { .tag(0) ForEach(Array(stride(from: 5, through: 20, by: 5)), id: \.self) { num in - Text("\(num)") + Text(num, format: .number) .tag(num) } } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index cd1b49b0a..ab3acfb6b 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -83,8 +83,8 @@ public struct EhSettingReducer: Sendable { return .none case .deleteProfileButtonTapped: - state.confirmationDialog = ConfirmationDialogState { - TextState("") + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .RLocalizable.delete) } actions: { ButtonState(role: .destructive, action: .confirmDeleteProfile) { TextState(localized: .RLocalizable.delete) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift index 86519ca2a..2f09bedfe 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -105,7 +105,10 @@ struct ExcludedLanguagesSection: View { var body: some View { Section { HStack { - Text("") + // Blank corner above the language column; the hidden label names the column and + // supplies the row's line height (the category cells below are height-less Color.clear). + Text(.RLocalizable.language) + .hidden() .frame(width: DeviceUtil.windowW * 0.25) ForEach(EhSetting.ExcludedLanguagesCategory.allCases) { category in diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index 62a39a219..091e49b43 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -90,8 +90,8 @@ public struct GeneralSettingReducer: Sendable { return .none case .removeCustomTranslationsButtonTapped: - state.removeTranslationsDialog = ConfirmationDialogState { - TextState("") + state.removeTranslationsDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .remove) } actions: { ButtonState(role: .destructive, action: .confirmRemoveCustomTranslations) { TextState(localized: .remove) @@ -105,8 +105,8 @@ public struct GeneralSettingReducer: Sendable { return .none case .clearImageCachesButtonTapped: - state.clearCacheDialog = ConfirmationDialogState { - TextState("") + state.clearCacheDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .RLocalizable.clear) } actions: { ButtonState(role: .destructive, action: .confirmClearCache) { TextState(localized: .RLocalizable.clear) From 2e45c6dacf26742950349d15539636fcb6674de3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 11:47:22 +0800 Subject: [PATCH 490/614] Stop remaining UI string auto-extraction Xcode kept extracting empty "" labels and %lld number interpolations from a few controls the earlier pass missed. Give each a proper (hidden) label and format numbers in code. Empty-string labels -> real localized labels, hidden via .labelsHidden(): - SettingTextField takes a required `title`; 5 call sites pass semantic labels (.favoriteCategories, .ratingsColor, .selectedProfile, .pagesRange x2). - Filters range Picker -> .searchRange (new key, 6 locales); Account host Picker -> .website; tags Toggle -> .enablesTagsExtension. - Login username/password fields labeled with their description. - ReadingView preview host Text("") -> Color.clear. Integer interpolations -> Text(_, format: .number): - Archives GP/credits Labels (closure form, monospacedDigit). - ControlPanel slider bounds. A clean build now extracts nothing; only search_range is added. --- .../AppComponents/SettingTextField.swift | 17 ++++++-- .../DetailFeature/Archives/ArchivesView.swift | 14 +++++-- .../Sources/FiltersFeature/FiltersView.swift | 7 ++-- .../Resources/Localizable.xcstrings | 41 +++++++++++++++++++ .../Sources/ReadingFeature/ReadingView.swift | 2 +- .../ReadingFeature/Support/ControlPanel.swift | 4 +- .../AccountSetting/AccountSettingView.swift | 3 +- .../EhSetting/EhSettingView+Sections1.swift | 7 +++- .../EhSetting/EhSettingView+Sections2.swift | 8 +++- .../GeneralSetting/GeneralSettingView.swift | 3 +- .../SettingFeature/Login/LoginView.swift | 5 ++- 11 files changed, 90 insertions(+), 21 deletions(-) diff --git a/AppPackage/Sources/AppComponents/SettingTextField.swift b/AppPackage/Sources/AppComponents/SettingTextField.swift index 766985413..f98b528fa 100644 --- a/AppPackage/Sources/AppComponents/SettingTextField.swift +++ b/AppPackage/Sources/AppComponents/SettingTextField.swift @@ -4,6 +4,7 @@ public struct SettingTextField: View { @Environment(\.colorScheme) private var colorScheme @Binding private var text: String + private let title: LocalizedStringResource private let promptText: String? private let width: CGFloat? private let alignment: TextAlignment @@ -19,10 +20,12 @@ public struct SettingTextField: View { } public init( - text: Binding, promptText: String? = nil, width: CGFloat? = 50, + text: Binding, title: LocalizedStringResource, + promptText: String? = nil, width: CGFloat? = 50, alignment: TextAlignment = .center, background: Color? = nil ) { _text = text + self.title = title self.promptText = promptText self.width = width self.alignment = alignment @@ -30,8 +33,14 @@ public struct SettingTextField: View { } public var body: some View { - TextField("", text: $text, prompt: prompt).keyboardType(.numbersAndPunctuation) - .textInputAutocapitalization(.none).multilineTextAlignment(alignment) - .disableAutocorrection(true).background(color).frame(width: width).cornerRadius(5) + // A non-empty, localized label keeps VoiceOver informative; `.labelsHidden()` keeps the + // field's appearance unchanged (only the prompt shows). Avoids an empty `""` title literal. + TextField(text: $text, prompt: prompt) { + Text(title) + } + .labelsHidden() + .keyboardType(.numbersAndPunctuation) + .textInputAutocapitalization(.none).multilineTextAlignment(alignment) + .disableAutocorrection(true).background(color).frame(width: width).cornerRadius(5) } } diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift index f2610a682..0035f378f 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift @@ -117,10 +117,18 @@ private struct ArchiveFundsView: View { var body: some View { HStack(spacing: 20) { - Label("\(galleryPoints)", systemSymbol: .gCircleFill) - Label("\(credits)", systemSymbol: .cCircleFill) + Label { + Text(galleryPoints, format: .number) + } icon: { + Image(systemSymbol: .gCircleFill) + } + Label { + Text(credits, format: .number) + } icon: { + Image(systemSymbol: .cCircleFill) + } } - .font(.headline).lineLimit(1).padding() + .font(.headline.monospacedDigit()).lineLimit(1).padding() } } diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index d284f57df..5e0a03663 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -71,12 +71,13 @@ private struct BasicSection: View { var body: some View { Section { - Picker("", selection: $filterRange) { + Picker(.searchRange, selection: $filterRange) { ForEach(FilterRange.allCases) { range in Text(range.value).tag(range) } } .pickerStyle(.segmented) + .labelsHidden() CategoryView(bindings: categoryBindings) Button(action: resetFiltersDialogAction) { Text(.resetFilters).foregroundStyle(.red) @@ -183,11 +184,11 @@ private struct PagesRangeSetter: View { HStack { Text(.pagesRange) Spacer() - SettingTextField(text: $lowerBound) + SettingTextField(text: $lowerBound, title: .pagesRange) .focused(focusedBound, equals: .lower) .submitLabel(.next) Text(.Constant.rangeSeparator) - SettingTextField(text: $upperBound) + SettingTextField(text: $upperBound, title: .pagesRange) .focused(focusedBound, equals: .upper) .submitLabel(.done) } diff --git a/AppPackage/Sources/FiltersFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/FiltersFeature/Resources/Localizable.xcstrings index 22ab0f6dc..5616db5e7 100644 --- a/AppPackage/Sources/FiltersFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/FiltersFeature/Resources/Localizable.xcstrings @@ -739,6 +739,47 @@ } } }, + "search_range": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search range" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchbereich" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索範囲" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색 범위" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋範圍" + } + } + } + }, "search_torrent_filenames": { "extractionState": "manual", "localizations": { diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index adf5185a2..7d8f27149 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -337,7 +337,7 @@ extension ReadingView { struct ReadingView_Previews: PreviewProvider { static var previews: some View { NavigationStack { - Text("") + Color.clear .fullScreenCover(isPresented: .constant(true)) { ReadingView( store: .init(initialState: .init(), reducer: ReadingReducer.init), diff --git a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift index 9b636f46d..a69cdaaec 100644 --- a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift +++ b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift @@ -266,7 +266,7 @@ private struct LowerPanel: View { ) HStack { - Text(isReversed ? "\(Int(range.upperBound))" : "\(Int(range.lowerBound))") + Text(isReversed ? Int(range.upperBound) : Int(range.lowerBound), format: .number) .fontWeight(.medium) .font(.caption) .padding() @@ -283,7 +283,7 @@ private struct LowerPanel: View { .onChanged({ if $0 { showsSliderPreview = true } }) ) - Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") + Text(isReversed ? Int(range.lowerBound) : Int(range.upperBound), format: .number) .fontWeight(.medium) .font(.caption) .padding() diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index c656ff004..61506a640 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -29,12 +29,13 @@ struct AccountSettingView: View { var body: some View { Form { Section { - Picker("", selection: $galleryHost) { + Picker(.website, selection: $galleryHost) { ForEach(GalleryHost.allCases) { Text($0.rawValue).tag($0) } } .pickerStyle(.segmented) + .labelsHidden() AccountSection( showsNewDawnGreeting: $showsNewDawnGreeting, bypassesSNIFiltering: bypassesSNIFiltering, diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift index 6e0c78082..01a40138e 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -50,8 +50,11 @@ struct EhProfileSection: View { } Section { - SettingTextField(text: $editingProfileName, width: nil, alignment: .leading, background: .clear) - .focused($isFocused) + SettingTextField( + text: $editingProfileName, title: .selectedProfile, + width: nil, alignment: .leading, background: .clear + ) + .focused($isFocused) Button(.rename) { performEhProfileAction(.rename, editingProfileName, ehProfile.value) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift index 73ae21a12..ca6beec18 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift @@ -44,8 +44,11 @@ struct FavoritesSection: View { .foregroundColor(category.color(host: AppUtil.galleryHost)) .frame(width: 10) - SettingTextField(text: nameBinding, width: nil, alignment: .leading, background: .clear) - .focused($isFocused) + SettingTextField( + text: nameBinding, title: .favoriteCategories, + width: nil, alignment: .leading, background: .clear + ) + .focused($isFocused) } .padding(.leading) } @@ -84,6 +87,7 @@ struct RatingsSection: View { LabeledContent(.ratingsColor) { SettingTextField( text: $ehSetting.ratingsColor, + title: .ratingsColor, promptText: String(localized: .ratingsColorPrompt), width: 80 ) diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index 63e4bab76..f8ce388f2 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -80,7 +80,8 @@ struct GeneralSettingView: View { .opacity(tagTranslatorLoadingState == .loading ? 1 : 0) } - Toggle("", isOn: $enablesTagsExtension) + Toggle(.enablesTagsExtension, isOn: $enablesTagsExtension) + .labelsHidden() .frame(width: 50) .padding(.leading, 20) } diff --git a/AppPackage/Sources/SettingFeature/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift index 02a225df9..9f40038c5 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginView.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginView.swift @@ -128,11 +128,12 @@ private struct LoginTextField: View { Group { if isPassword { - SecureField("", text: $text) + SecureField(description, text: $text) } else { - TextField("", text: $text) + TextField(description, text: $text) } } + .labelsHidden() .focused(focusedField.projectedValue, equals: isPassword ? .password : .username) .textContentType(isPassword ? .password : .username) .submitLabel(isPassword ? .done : .next) From e16e8d474c9279f54374e02583248b1b7b03eaa5 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 11:59:38 +0800 Subject: [PATCH 491/614] Update label_text_image_shorthand rule --- .swiftlint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index e0789333c..e388e2c2d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -89,7 +89,7 @@ custom_rules: label_text_image_shorthand: name: "Label Text + Image Shorthand" - regex: 'Label\s*\{\s*Text\s*\((?:[^()]|\([^()]*\))*\)\s*\}\s*icon:\s*\{\s*Image\s*\(\s*systemSymbol:\s*(?:[^()]|\([^()]*\))*\)\s*\}' + regex: 'Label\s*\{\s*Text\s*\((?:[^(),]|\([^()]*\))*\)\s*\}\s*icon:\s*\{\s*Image\s*\(\s*systemSymbol:\s*(?:[^()]|\([^()]*\))*\)\s*\}' message: "Use Label(_ titleResource:systemSymbol:) instead of an unmodified Text + Image(systemSymbol:) Label." excluded_match_kinds: - comment From 28a1e1ddf90a4a1f98366686c1988838c6a0ec1c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sat, 4 Jul 2026 12:27:45 +0800 Subject: [PATCH 492/614] Fill all locales for non-translated xcstrings keys Non-translated keys (proper nouns, URLs, format placeholders) only had an en value, so Xcode flagged them as missing translations elsewhere. Also documents the convention in AGENTS.md. --- AGENTS.md | 2 + App/InfoPlist.xcstrings | 30 + .../Resources/Constant.xcstrings | 90 + .../Resources/Constant.xcstrings | 30 + .../Resources/Constant.xcstrings | 60 + .../Resources/Resources/Constant.xcstrings | 30 + .../Resources/Constant.xcstrings | 2342 +++++++++++++++-- 7 files changed, 2328 insertions(+), 256 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9462887a3..825f83838 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ This file gives coding agents a reliable working guide for this repository. **Labeled localized-format arguments**: Surface every *numeric* localized-format argument as a labeled Swift parameter via a named `%#@variable@` substitution: module-local keys generate `func key(variable: Int)`; shared keys carry semantic labels hand-written in `ResourceStringSymbols.swift`. Never put a bare numeric specifier (`%lld`, `%d`, …) in a module-local catalog's outer value or top-level plural variant. Keep *string* (`%@`) arguments positional (auto-generated `func key(_ arg1: String)`); never make a `String` a substitution. Keep substitution plural categories coherent: a variable's `en` category set must equal its `de` set; `ja`/`ko`/`zh-Hans`/`zh-Hant` are `other`-only. +**Non-translated keys need every locale filled**: An `.xcstrings` entry marked `"shouldTranslate": false` (e.g. proper nouns, URLs, copyright strings, format placeholders) must still carry a `localizations` entry for every locale the catalog otherwise supports, not just `en`. Since the string is intentionally not translated, copy the English `stringUnit` (`value` and `state`) verbatim into each missing locale rather than leaving it absent, so Xcode doesn't flag the key as missing a translation in other languages. + **Confirmation dialog / alert placement**: Attach a `.confirmationDialog`/`.alert` modifier to a UI element that is both **stable** (stays in the hierarchy until the dialog is dismissed — being `.disabled` is fine, being removed or `.opacity`-hidden is not) and the **action source** (the control that triggers it). On iPad these render as popovers anchored to the view the modifier is attached to, so the anchor must be the triggering control for the arrow to point at the right place; and if that view leaves the hierarchy while the dialog is up, the dialog is torn down with it. Do not move such a modifier onto a transient or unrelated container (a whole `Form`/`List`, or a view gated by a condition) for convenience — keep it on the triggering button/row. When the trigger lives inside a subview, thread the store-scoped dialog binding into that subview and attach it there rather than hoisting the modifier to an ancestor. Exception: for a per-row destructive action whose row can scroll out of view, the stable action-source is the enclosing list container, so attach it there. ## Project structure diff --git a/App/InfoPlist.xcstrings b/App/InfoPlist.xcstrings index 0a5a4752d..88f8964ea 100644 --- a/App/InfoPlist.xcstrings +++ b/App/InfoPlist.xcstrings @@ -11,6 +11,36 @@ "state": "translated", "value": "EhPanda" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } } } }, diff --git a/AppPackage/Sources/DetailFeature/Resources/Constant.xcstrings b/AppPackage/Sources/DetailFeature/Resources/Constant.xcstrings index 2c60024af..9a3af9ba0 100644 --- a/AppPackage/Sources/DetailFeature/Resources/Constant.xcstrings +++ b/AppPackage/Sources/DetailFeature/Resources/Constant.xcstrings @@ -10,6 +10,36 @@ "state": "translated", "value": "You must have a H@H client assigned to your account to use this feature." } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "You must have a H@H client assigned to your account to use this feature." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "You must have a H@H client assigned to your account to use this feature." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "You must have a H@H client assigned to your account to use this feature." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "You must have a H@H client assigned to your account to use this feature." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "You must have a H@H client assigned to your account to use this feature." + } } } }, @@ -22,6 +52,36 @@ "state": "translated", "value": "Your H@H client appears to be offline. Turn it on, then try again." } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Your H@H client appears to be offline. Turn it on, then try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Your H@H client appears to be offline. Turn it on, then try again." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Your H@H client appears to be offline. Turn it on, then try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Your H@H client appears to be offline. Turn it on, then try again." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Your H@H client appears to be offline. Turn it on, then try again." + } } } }, @@ -34,6 +94,36 @@ "state": "translated", "value": "The requested gallery cannot be downloaded with the selected resolution." } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "The requested gallery cannot be downloaded with the selected resolution." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "The requested gallery cannot be downloaded with the selected resolution." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "The requested gallery cannot be downloaded with the selected resolution." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "The requested gallery cannot be downloaded with the selected resolution." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "The requested gallery cannot be downloaded with the selected resolution." + } } } } diff --git a/AppPackage/Sources/FiltersFeature/Resources/Constant.xcstrings b/AppPackage/Sources/FiltersFeature/Resources/Constant.xcstrings index 5e80b4170..8d755d9a2 100644 --- a/AppPackage/Sources/FiltersFeature/Resources/Constant.xcstrings +++ b/AppPackage/Sources/FiltersFeature/Resources/Constant.xcstrings @@ -10,6 +10,36 @@ "state": "translated", "value": "-" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "-" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "-" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "-" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "-" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "-" + } } } } diff --git a/AppPackage/Sources/ReadingSettingFeature/Resources/Constant.xcstrings b/AppPackage/Sources/ReadingSettingFeature/Resources/Constant.xcstrings index d17fe870f..b0d8ca6f0 100644 --- a/AppPackage/Sources/ReadingSettingFeature/Resources/Constant.xcstrings +++ b/AppPackage/Sources/ReadingSettingFeature/Resources/Constant.xcstrings @@ -10,6 +10,36 @@ "state": "translated", "value": "%lldpt" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lldpt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lldpt" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lldpt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lldpt" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lldpt" + } } } }, @@ -22,6 +52,36 @@ "state": "translated", "value": "%@x" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@x" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@x" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@x" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@x" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@x" + } } } } diff --git a/AppPackage/Sources/Resources/Resources/Constant.xcstrings b/AppPackage/Sources/Resources/Resources/Constant.xcstrings index 4d2a349eb..3e71c8c68 100644 --- a/AppPackage/Sources/Resources/Resources/Constant.xcstrings +++ b/AppPackage/Sources/Resources/Resources/Constant.xcstrings @@ -10,6 +10,36 @@ "state": "translated", "value": "This gallery has been removed or is unavailable." } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } } } } diff --git a/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings b/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings index bea9ac257..e38c8a6fb 100644 --- a/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings +++ b/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings @@ -10,6 +10,36 @@ "state": "translated", "value": "Colorful" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Colorful" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Colorful" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Colorful" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Colorful" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Colorful" + } } } }, @@ -22,6 +52,36 @@ "state": "translated", "value": "https://github.com/Co2333/Colorful" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Co2333/Colorful" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Co2333/Colorful" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Co2333/Colorful" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Co2333/Colorful" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Co2333/Colorful" + } } } }, @@ -34,6 +94,36 @@ "state": "translated", "value": "EhTagTranslation/Database" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "EhTagTranslation/Database" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "EhTagTranslation/Database" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "EhTagTranslation/Database" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "EhTagTranslation/Database" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "EhTagTranslation/Database" + } } } }, @@ -46,6 +136,36 @@ "state": "translated", "value": "https://github.com/EhTagTranslation/Database" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhTagTranslation/Database" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhTagTranslation/Database" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhTagTranslation/Database" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhTagTranslation/Database" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhTagTranslation/Database" + } } } }, @@ -58,6 +178,36 @@ "state": "translated", "value": "Kanna" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kanna" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kanna" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kanna" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Kanna" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Kanna" + } } } }, @@ -70,6 +220,36 @@ "state": "translated", "value": "https://github.com/tid-kijyun/Kanna" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/tid-kijyun/Kanna" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/tid-kijyun/Kanna" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/tid-kijyun/Kanna" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/tid-kijyun/Kanna" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/tid-kijyun/Kanna" + } } } }, @@ -82,6 +262,36 @@ "state": "translated", "value": "Kingfisher" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kingfisher" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kingfisher" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kingfisher" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Kingfisher" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Kingfisher" + } } } }, @@ -94,6 +304,36 @@ "state": "translated", "value": "https://github.com/onevcat/Kingfisher" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/onevcat/Kingfisher" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/onevcat/Kingfisher" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/onevcat/Kingfisher" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/onevcat/Kingfisher" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/onevcat/Kingfisher" + } } } }, @@ -106,6 +346,36 @@ "state": "translated", "value": "SFSafeSymbols" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SFSafeSymbols" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SFSafeSymbols" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SFSafeSymbols" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SFSafeSymbols" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "SFSafeSymbols" + } } } }, @@ -118,6 +388,36 @@ "state": "translated", "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" + } } } }, @@ -130,6 +430,36 @@ "state": "translated", "value": "SwiftCommonMark" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SwiftCommonMark" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SwiftCommonMark" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SwiftCommonMark" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SwiftCommonMark" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "SwiftCommonMark" + } } } }, @@ -142,374 +472,1334 @@ "state": "translated", "value": "https://github.com/gonzalezreal/SwiftCommonMark" } - } - } - }, - "acknowledgement.swiftGen": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "de": { "stringUnit": { "state": "translated", - "value": "SwiftGen" + "value": "https://github.com/gonzalezreal/SwiftCommonMark" } - } - } - }, - "acknowledgement.swiftGen_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "https://github.com/SwiftGen/SwiftGen" + "value": "https://github.com/gonzalezreal/SwiftCommonMark" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/gonzalezreal/SwiftCommonMark" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/gonzalezreal/SwiftCommonMark" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/gonzalezreal/SwiftCommonMark" } } } }, - "acknowledgement.swiftUIPager": { + "acknowledgement.swiftGen": { "extractionState": "manual", "shouldTranslate": false, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "SwiftUIPager" + "value": "SwiftGen" } - } - } - }, - "acknowledgement.swiftUIPager_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "de": { "stringUnit": { "state": "translated", - "value": "https://github.com/fermoya/SwiftUIPager" + "value": "SwiftGen" } - } - } - }, - "acknowledgement.swiftyOpenCC": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "SwiftyOpenCC" + "value": "SwiftGen" } - } - } - }, - "acknowledgement.swiftyOpenCC_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "ko": { "stringUnit": { "state": "translated", - "value": "https://github.com/ddddxxx/SwiftyOpenCC" + "value": "SwiftGen" } - } - } - }, - "acknowledgement.systemNotification": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "SystemNotification" + "value": "SwiftGen" } - } - } - }, - "acknowledgement.systemNotification_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "https://github.com/danielsaidi/SystemNotification" + "value": "SwiftGen" } } } }, - "acknowledgement.tca": { + "acknowledgement.swiftGen_link": { "extractionState": "manual", "shouldTranslate": false, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The Composable Architecture" + "value": "https://github.com/SwiftGen/SwiftGen" } - } - } - }, - "acknowledgement.tca_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "de": { "stringUnit": { "state": "translated", - "value": "https://github.com/pointfreeco/swift-composable-architecture" + "value": "https://github.com/SwiftGen/SwiftGen" } - } - } - }, - "acknowledgement.uiImageColors": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "UIImageColors" + "value": "https://github.com/SwiftGen/SwiftGen" } - } - } - }, - "acknowledgement.uiImageColors_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "ko": { "stringUnit": { "state": "translated", - "value": "https://github.com/jathu/UIImageColors" + "value": "https://github.com/SwiftGen/SwiftGen" } - } - } - }, - "acknowledgement.waterfallGrid": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "WaterfallGrid" + "value": "https://github.com/SwiftGen/SwiftGen" } - } - } - }, - "acknowledgement.waterfallGrid_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "https://github.com/paololeonardi/WaterfallGrid" + "value": "https://github.com/SwiftGen/SwiftGen" } } } }, - "code_level_contributor.Jimmy-Prime": { + "acknowledgement.swiftUIPager": { "extractionState": "manual", "shouldTranslate": false, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Jimmy Prime" + "value": "SwiftUIPager" } - } - } - }, - "code_level_contributor.Jimmy-Prime_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "de": { "stringUnit": { "state": "translated", - "value": "https://github.com/Jimmy-Prime" + "value": "SwiftUIPager" } - } - } - }, - "code_level_contributor.Kaed3mi": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Kaed3mi" + "value": "SwiftUIPager" } - } - } - }, - "code_level_contributor.Kaed3mi_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "ko": { "stringUnit": { "state": "translated", - "value": "https://github.com/Kaed3mi" + "value": "SwiftUIPager" } - } - } - }, - "code_level_contributor.aalberrty": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Zack Asahina" + "value": "SwiftUIPager" } - } - } - }, - "code_level_contributor.aalberrty_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "https://github.com/aalberrty" + "value": "SwiftUIPager" } } } }, - "code_level_contributor.vvbbnn00": { + "acknowledgement.swiftUIPager_link": { "extractionState": "manual", "shouldTranslate": false, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "vvbbnn00" + "value": "https://github.com/fermoya/SwiftUIPager" } - } - } - }, - "code_level_contributor.vvbbnn00_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "de": { "stringUnit": { "state": "translated", - "value": "https://github.com/vvbbnn00" + "value": "https://github.com/fermoya/SwiftUIPager" } - } - } - }, - "code_level_contributor.xioxin": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "xioxin" + "value": "https://github.com/fermoya/SwiftUIPager" } - } - } - }, - "code_level_contributor.xioxin_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "ko": { "stringUnit": { "state": "translated", - "value": "https://github.com/xioxin" + "value": "https://github.com/fermoya/SwiftUIPager" } - } - } - }, - "contact.altStore_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + "value": "https://github.com/fermoya/SwiftUIPager" } - } - } - }, - "contact.discord": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "https://discord.gg/BSBE9FCBTq" + "value": "https://github.com/fermoya/SwiftUIPager" } } } }, - "contact.discord_link": { + "acknowledgement.swiftyOpenCC": { "extractionState": "manual", "shouldTranslate": false, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Discord" + "value": "SwiftyOpenCC" } - } - } - }, - "contact.gitHub": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "de": { "stringUnit": { "state": "translated", - "value": "https://github.com/EhPanda-Team/EhPanda" + "value": "SwiftyOpenCC" } - } - } - }, - "contact.gitHub_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "GitHub" + "value": "SwiftyOpenCC" } - } - } - }, - "contact.telegram": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "ko": { "stringUnit": { "state": "translated", - "value": "https://t.me/ehpanda" + "value": "SwiftyOpenCC" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SwiftyOpenCC" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "SwiftyOpenCC" } } } }, - "contact.telegram_link": { + "acknowledgement.swiftyOpenCC_link": { "extractionState": "manual", "shouldTranslate": false, "localizations": { "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + } + } + }, + "acknowledgement.systemNotification": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + } + } + }, + "acknowledgement.systemNotification_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + } + } + }, + "acknowledgement.tca": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + } + } + }, + "acknowledgement.tca_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + } + } + }, + "acknowledgement.uiImageColors": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + } + } + }, + "acknowledgement.uiImageColors_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + } + } + }, + "acknowledgement.waterfallGrid": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + } + } + }, + "acknowledgement.waterfallGrid_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + } + } + }, + "code_level_contributor.Jimmy-Prime": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + } + } + }, + "code_level_contributor.Jimmy-Prime_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + } + } + }, + "code_level_contributor.Kaed3mi": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + } + } + }, + "code_level_contributor.Kaed3mi_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + } + } + }, + "code_level_contributor.aalberrty": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + } + } + }, + "code_level_contributor.aalberrty_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + } + } + }, + "code_level_contributor.vvbbnn00": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + } + } + }, + "code_level_contributor.vvbbnn00_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + } + } + }, + "code_level_contributor.xioxin": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + } + } + }, + "code_level_contributor.xioxin_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + } + } + }, + "contact.altStore_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + } + } + }, + "contact.discord": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + } + } + }, + "contact.discord_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + } + } + }, + "contact.gitHub": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + } + } + }, + "contact.gitHub_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + } + } + }, + "contact.telegram": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + } + } + }, + "contact.telegram_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Telegram" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Telegram" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Telegram" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Telegram" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Telegram" + } + }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Telegram" @@ -526,6 +1816,36 @@ "state": "translated", "value": "https://ehpanda.app" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://ehpanda.app" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://ehpanda.app" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://ehpanda.app" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://ehpanda.app" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://ehpanda.app" + } } } }, @@ -538,6 +1858,36 @@ "state": "translated", "value": "Copyright © 2026 EhPanda Team" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Copyright © 2026 EhPanda Team" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Copyright © 2026 EhPanda Team" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Copyright © 2026 EhPanda Team" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Copyright © 2026 EhPanda Team" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Copyright © 2026 EhPanda Team" + } } } }, @@ -550,6 +1900,36 @@ "state": "translated", "value": "caxerx" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } } } }, @@ -560,40 +1940,160 @@ "en": { "stringUnit": { "state": "translated", - "value": "https://github.com/caxerx" + "value": "https://github.com/caxerx" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + } + } + }, + "special_thanks.honjow": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + } + } + }, + "special_thanks.honjow_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" } } } }, - "special_thanks.honjow": { + "special_thanks.luminescent_yq": { "extractionState": "manual", "shouldTranslate": false, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "honjow" + "value": "Luminescent_yq" } - } - } - }, - "special_thanks.honjow_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "de": { "stringUnit": { "state": "translated", - "value": "https://github.com/honjow" + "value": "Luminescent_yq" } - } - } - }, - "special_thanks.luminescent_yq": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Luminescent_yq" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Luminescent_yq" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Luminescent_yq" + } + }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Luminescent_yq" @@ -610,6 +2110,36 @@ "state": "translated", "value": "" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "" + } } } }, @@ -622,6 +2152,36 @@ "state": "translated", "value": "taylorlannister" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "taylorlannister" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "taylorlannister" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "taylorlannister" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "taylorlannister" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "taylorlannister" + } } } }, @@ -634,6 +2194,36 @@ "state": "translated", "value": "https://github.com/taylorlannister" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/taylorlannister" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/taylorlannister" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/taylorlannister" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/taylorlannister" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/taylorlannister" + } } } }, @@ -646,6 +2236,36 @@ "state": "translated", "value": "ɴᴇᴋᴏ" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "ɴᴇᴋᴏ" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ɴᴇᴋᴏ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "ɴᴇᴋᴏ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "ɴᴇᴋᴏ" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "ɴᴇᴋᴏ" + } } } }, @@ -658,6 +2278,36 @@ "state": "translated", "value": "https://github.com/NeKoOuO" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/NeKoOuO" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/NeKoOuO" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/NeKoOuO" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/NeKoOuO" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/NeKoOuO" + } } } }, @@ -670,6 +2320,36 @@ "state": "translated", "value": "caxerx" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } } } }, @@ -682,6 +2362,36 @@ "state": "translated", "value": "https://github.com/caxerx" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } } } }, @@ -694,6 +2404,36 @@ "state": "translated", "value": "雲豹 ΦωΦ" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "雲豹 ΦωΦ" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "雲豹 ΦωΦ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "雲豹 ΦωΦ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "雲豹 ΦωΦ" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雲豹 ΦωΦ" + } } } }, @@ -706,6 +2446,36 @@ "state": "translated", "value": "https://github.com/Nebulosa-Cat" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Nebulosa-Cat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Nebulosa-Cat" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Nebulosa-Cat" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Nebulosa-Cat" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Nebulosa-Cat" + } } } }, @@ -718,6 +2488,36 @@ "state": "translated", "value": "PaulHaeussler" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "PaulHaeussler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "PaulHaeussler" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "PaulHaeussler" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "PaulHaeussler" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "PaulHaeussler" + } } } }, @@ -730,6 +2530,36 @@ "state": "translated", "value": "https://github.com/PaulHaeussler" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/PaulHaeussler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/PaulHaeussler" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/PaulHaeussler" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/PaulHaeussler" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/PaulHaeussler" + } } } } From a55001452208ef46de5d1288f48323cfce7bbac9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 01:26:11 +0800 Subject: [PATCH 493/614] Type Category.value as LocalizedStringResource --- .../Sources/AppComponents/CategoryView.swift | 4 ++-- .../DownloadedGallery+SupportTypes.swift | 2 +- .../Sources/AppModels/Gallery/Category.swift | 24 +++++++++---------- .../GalleryInfos/GalleryInfosView.swift | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/AppPackage/Sources/AppComponents/CategoryView.swift b/AppPackage/Sources/AppComponents/CategoryView.swift index afcddd05d..846a0ff22 100644 --- a/AppPackage/Sources/AppComponents/CategoryView.swift +++ b/AppPackage/Sources/AppComponents/CategoryView.swift @@ -4,7 +4,7 @@ import AppTools // MARK: CategoryLabel public struct CategoryLabel: View { - private let text: String + private let text: LocalizedStringResource private let color: Color private let font: Font private let insets: EdgeInsets @@ -12,7 +12,7 @@ public struct CategoryLabel: View { private let corners: UIRectCorner public init( - text: String, color: Color, font: Font = .footnote, + text: LocalizedStringResource, color: Color, font: Font = .footnote, insets: EdgeInsets = .init(top: 1, leading: 3, bottom: 1, trailing: 3), cornerRadius: CGFloat = 2, corners: UIRectCorner = .allCorners ) { diff --git a/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift index 01d2355c0..1d843681b 100644 --- a/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift @@ -12,7 +12,7 @@ extension DownloadedGallery { title, jpnTitle, uploader, - category.value, + String(localized: category.value), tags.flatMap(\.contents).map(\.text).joined(separator: " ") ] .compactMap { $0 } diff --git a/AppPackage/Sources/AppModels/Gallery/Category.swift b/AppPackage/Sources/AppModels/Gallery/Category.swift index 0901b45c0..f9968671f 100644 --- a/AppPackage/Sources/AppModels/Gallery/Category.swift +++ b/AppPackage/Sources/AppModels/Gallery/Category.swift @@ -45,19 +45,19 @@ extension Category { fatalError(message) } } - public var value: String { + public var value: LocalizedStringResource { switch self { - case .doujinshi: return String(localized: .categoryDoujinshi) - case .manga: return String(localized: .categoryManga) - case .artistCG: return String(localized: .categoryArtistCg) - case .gameCG: return String(localized: .categoryGameCg) - case .western: return String(localized: .categoryWestern) - case .nonH: return String(localized: .categoryNonH) - case .imageSet: return String(localized: .categoryImageSet) - case .cosplay: return String(localized: .categoryCosplay) - case .asianPorn: return String(localized: .categoryAsianPorn) - case .misc: return String(localized: .categoryMisc) - case .private: return String(localized: .categoryPrivate) + case .doujinshi: return .categoryDoujinshi + case .manga: return .categoryManga + case .artistCG: return .categoryArtistCg + case .gameCG: return .categoryGameCg + case .western: return .categoryWestern + case .nonH: return .categoryNonH + case .imageSet: return .categoryImageSet + case .cosplay: return .categoryCosplay + case .asianPorn: return .categoryAsianPorn + case .misc: return .categoryMisc + case .private: return .categoryPrivate } } } diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index 5ec9dbab1..b42ce3df1 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -44,7 +44,7 @@ struct GalleryInfosView: View { ), Info( title: .metadataCategory, - value: galleryDetail.category.value + value: String(localized: galleryDetail.category.value) ), Info(title: .metadataUploader, value: galleryDetail.uploader), Info( From 2501a86bf2c2fd6543f6913ebde16080f5226585 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 01:29:32 +0800 Subject: [PATCH 494/614] Type Language.value as LocalizedStringResource --- .../Sources/AppModels/Gallery/Language.swift | 135 +++++++++--------- .../DetailFeature/DetailView+Subviews.swift | 2 +- .../GalleryInfos/GalleryInfosView.swift | 2 +- .../EhSetting/EhSettingView+Sections3.swift | 2 +- 4 files changed, 71 insertions(+), 70 deletions(-) diff --git a/AppPackage/Sources/AppModels/Gallery/Language.swift b/AppPackage/Sources/AppModels/Gallery/Language.swift index 13c9e26a1..3088e21dc 100644 --- a/AppPackage/Sources/AppModels/Gallery/Language.swift +++ b/AppPackage/Sources/AppModels/Gallery/Language.swift @@ -1,3 +1,4 @@ +import Foundation import Resources public enum Language: String, Codable, Sendable { @@ -30,74 +31,74 @@ extension Language { // swiftlint:enable switch_case_alignment line_length } } - public var value: String { + public var value: LocalizedStringResource { switch self { - case .invalid: return String(localized: .languageInvalid) - case .other: return String(localized: .languageOther) - case .afrikaans: return String(localized: .languageAfrikaans) - case .albanian: return String(localized: .languageAlbanian) - case .arabic: return String(localized: .languageArabic) - case .bengali: return String(localized: .languageBengali) - case .bosnian: return String(localized: .languageBosnian) - case .bulgarian: return String(localized: .languageBulgarian) - case .burmese: return String(localized: .languageBurmese) - case .catalan: return String(localized: .languageCatalan) - case .cebuano: return String(localized: .languageCebuano) - case .chinese: return String(localized: .languageChinese) - case .croatian: return String(localized: .languageCroatian) - case .czech: return String(localized: .languageCzech) - case .danish: return String(localized: .languageDanish) - case .dutch: return String(localized: .languageDutch) - case .english: return String(localized: .languageEnglish) - case .esperanto: return String(localized: .languageEsperanto) - case .estonian: return String(localized: .languageEstonian) - case .finnish: return String(localized: .languageFinnish) - case .french: return String(localized: .languageFrench) - case .georgian: return String(localized: .languageGeorgian) - case .german: return String(localized: .languageGerman) - case .greek: return String(localized: .languageGreek) - case .hebrew: return String(localized: .languageHebrew) - case .hindi: return String(localized: .languageHindi) - case .hmong: return String(localized: .languageHmong) - case .hungarian: return String(localized: .languageHungarian) - case .indonesian: return String(localized: .languageIndonesian) - case .italian: return String(localized: .languageItalian) - case .japanese: return String(localized: .languageJapanese) - case .kazakh: return String(localized: .languageKazakh) - case .khmer: return String(localized: .languageKhmer) - case .korean: return String(localized: .languageKorean) - case .kurdish: return String(localized: .languageKurdish) - case .lao: return String(localized: .languageLao) - case .latin: return String(localized: .languageLatin) - case .mongolian: return String(localized: .languageMongolian) - case .ndebele: return String(localized: .languageNdebele) - case .nepali: return String(localized: .languageNepali) - case .norwegian: return String(localized: .languageNorwegian) - case .oromo: return String(localized: .languageOromo) - case .pashto: return String(localized: .languagePashto) - case .persian: return String(localized: .languagePersian) - case .polish: return String(localized: .languagePolish) - case .portuguese: return String(localized: .languagePortuguese) - case .punjabi: return String(localized: .languagePunjabi) - case .romanian: return String(localized: .languageRomanian) - case .russian: return String(localized: .languageRussian) - case .sango: return String(localized: .languageSango) - case .serbian: return String(localized: .languageSerbian) - case .shona: return String(localized: .languageShona) - case .slovak: return String(localized: .languageSlovak) - case .slovenian: return String(localized: .languageSlovenian) - case .somali: return String(localized: .languageSomali) - case .spanish: return String(localized: .languageSpanish) - case .swahili: return String(localized: .languageSwahili) - case .swedish: return String(localized: .languageSwedish) - case .tagalog: return String(localized: .languageTagalog) - case .thai: return String(localized: .languageThai) - case .tigrinya: return String(localized: .languageTigrinya) - case .turkish: return String(localized: .languageTurkish) - case .ukrainian: return String(localized: .languageUkrainian) - case .urdu: return String(localized: .languageUrdu) - case .vietnamese: return String(localized: .languageVietnamese) - case .zulu: return String(localized: .languageZulu) + case .invalid: return .languageInvalid + case .other: return .languageOther + case .afrikaans: return .languageAfrikaans + case .albanian: return .languageAlbanian + case .arabic: return .languageArabic + case .bengali: return .languageBengali + case .bosnian: return .languageBosnian + case .bulgarian: return .languageBulgarian + case .burmese: return .languageBurmese + case .catalan: return .languageCatalan + case .cebuano: return .languageCebuano + case .chinese: return .languageChinese + case .croatian: return .languageCroatian + case .czech: return .languageCzech + case .danish: return .languageDanish + case .dutch: return .languageDutch + case .english: return .languageEnglish + case .esperanto: return .languageEsperanto + case .estonian: return .languageEstonian + case .finnish: return .languageFinnish + case .french: return .languageFrench + case .georgian: return .languageGeorgian + case .german: return .languageGerman + case .greek: return .languageGreek + case .hebrew: return .languageHebrew + case .hindi: return .languageHindi + case .hmong: return .languageHmong + case .hungarian: return .languageHungarian + case .indonesian: return .languageIndonesian + case .italian: return .languageItalian + case .japanese: return .languageJapanese + case .kazakh: return .languageKazakh + case .khmer: return .languageKhmer + case .korean: return .languageKorean + case .kurdish: return .languageKurdish + case .lao: return .languageLao + case .latin: return .languageLatin + case .mongolian: return .languageMongolian + case .ndebele: return .languageNdebele + case .nepali: return .languageNepali + case .norwegian: return .languageNorwegian + case .oromo: return .languageOromo + case .pashto: return .languagePashto + case .persian: return .languagePersian + case .polish: return .languagePolish + case .portuguese: return .languagePortuguese + case .punjabi: return .languagePunjabi + case .romanian: return .languageRomanian + case .russian: return .languageRussian + case .sango: return .languageSango + case .serbian: return .languageSerbian + case .shona: return .languageShona + case .slovak: return .languageSlovak + case .slovenian: return .languageSlovenian + case .somali: return .languageSomali + case .spanish: return .languageSpanish + case .swahili: return .languageSwahili + case .swedish: return .languageSwedish + case .tagalog: return .languageTagalog + case .thai: return .languageThai + case .tigrinya: return .languageTigrinya + case .turkish: return .languageTurkish + case .ukrainian: return .languageUkrainian + case .urdu: return .languageUrdu + case .vietnamese: return .languageVietnamese + case .zulu: return .languageZulu } } } diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index 2fce12b23..7d16e52fb 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -20,7 +20,7 @@ struct DescriptionSection: View { ), DescScrollInfo( title: .RLocalizable.language, - description: galleryDetail.language.value, + description: String(localized: galleryDetail.language.value), value: galleryDetail.language.abbreviation ), DescScrollInfo( diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index b42ce3df1..140bd7dc5 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -55,7 +55,7 @@ struct GalleryInfosView: View { title: .metadataVisibility, value: galleryDetail.visibility.value ), - Info(title: .metadataLanguage, value: galleryDetail.language.value), + Info(title: .metadataLanguage, value: String(localized: galleryDetail.language.value)), Info(title: .metadataPageCount, value: String(galleryDetail.pageCount)), Info( title: .metadataFileSize, diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift index 2f09bedfe..e1416c18d 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -139,7 +139,7 @@ struct ExcludedLanguagesSection: View { } struct ExcludeRow: View { - let title: String + let title: LocalizedStringResource let bindings: [Binding] let isFirstRow: Bool From 0defd6fd585c810047a17b958e1cecfa0e4ebc65 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 01:31:44 +0800 Subject: [PATCH 495/614] Type BrowsingCountry.name as resource --- .../AppModels/Support/BrowsingCountry.swift | 506 +++++++++--------- .../Sources/AppModels/Support/EhSetting.swift | 3 +- 2 files changed, 255 insertions(+), 254 deletions(-) diff --git a/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift b/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift index 6306c8975..f211d4933 100644 --- a/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift +++ b/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift @@ -10,260 +10,260 @@ extension EhSetting { } extension EhSetting.BrowsingCountry { public var id: Int { hashValue } - public var name: String { + public var name: LocalizedStringResource { switch self { - case .autoDetect: return String(localized: .browsingCountryAutoDetect) - case .afghanistan: return String(localized: .browsingCountryAfghanistan) - case .alandIslands: return String(localized: .browsingCountryAlandIslands) - case .albania: return String(localized: .browsingCountryAlbania) - case .algeria: return String(localized: .browsingCountryAlgeria) - case .americanSamoa: return String(localized: .browsingCountryAmericanSamoa) - case .andorra: return String(localized: .browsingCountryAndorra) - case .angola: return String(localized: .browsingCountryAngola) - case .anguilla: return String(localized: .browsingCountryAnguilla) - case .antarctica: return String(localized: .browsingCountryAntarctica) - case .antiguaAndBarbuda: return String(localized: .browsingCountryAntiguaAndBarbuda) - case .argentina: return String(localized: .browsingCountryArgentina) - case .armenia: return String(localized: .browsingCountryArmenia) - case .aruba: return String(localized: .browsingCountryAruba) - case .asiaPacificRegion: return String(localized: .browsingCountryAsiaPacificRegion) - case .australia: return String(localized: .browsingCountryAustralia) - case .austria: return String(localized: .browsingCountryAustria) - case .azerbaijan: return String(localized: .browsingCountryAzerbaijan) - case .bahamas: return String(localized: .browsingCountryBahamas) - case .bahrain: return String(localized: .browsingCountryBahrain) - case .bangladesh: return String(localized: .browsingCountryBangladesh) - case .barbados: return String(localized: .browsingCountryBarbados) - case .belarus: return String(localized: .browsingCountryBelarus) - case .belgium: return String(localized: .browsingCountryBelgium) - case .belize: return String(localized: .browsingCountryBelize) - case .benin: return String(localized: .browsingCountryBenin) - case .bermuda: return String(localized: .browsingCountryBermuda) - case .bhutan: return String(localized: .browsingCountryBhutan) - case .bolivia: return String(localized: .browsingCountryBolivia) - case .bonaireSaintEustatiusAndSaba: return String(localized: .browsingCountryBonaireSaintEustatiusAndSaba) - case .bosniaAndHerzegovina: return String(localized: .browsingCountryBosniaAndHerzegovina) - case .botswana: return String(localized: .browsingCountryBotswana) - case .bouvetIsland: return String(localized: .browsingCountryBouvetIsland) - case .brazil: return String(localized: .browsingCountryBrazil) - case .britishIndianOceanTerritory: return String(localized: .browsingCountryBritishIndianOceanTerritory) - case .bruneiDarussalam: return String(localized: .browsingCountryBruneiDarussalam) - case .bulgaria: return String(localized: .browsingCountryBulgaria) - case .burkinaFaso: return String(localized: .browsingCountryBurkinaFaso) - case .burundi: return String(localized: .browsingCountryBurundi) - case .cambodia: return String(localized: .browsingCountryCambodia) - case .cameroon: return String(localized: .browsingCountryCameroon) - case .canada: return String(localized: .browsingCountryCanada) - case .capeVerde: return String(localized: .browsingCountryCapeVerde) - case .caymanIslands: return String(localized: .browsingCountryCaymanIslands) - case .centralAfricanRepublic: return String(localized: .browsingCountryCentralAfricanRepublic) - case .chad: return String(localized: .browsingCountryChad) - case .chile: return String(localized: .browsingCountryChile) - case .china: return String(localized: .browsingCountryChina) - case .christmasIsland: return String(localized: .browsingCountryChristmasIsland) - case .cocosIslands: return String(localized: .browsingCountryCocosIslands) - case .colombia: return String(localized: .browsingCountryColombia) - case .comoros: return String(localized: .browsingCountryComoros) - case .congo: return String(localized: .browsingCountryCongo) - case .theDemocraticRepublicOfTheCongo: return String(localized: .browsingCountryTheDemocraticRepublicOfTheCongo) - case .cookIslands: return String(localized: .browsingCountryCookIslands) - case .costaRica: return String(localized: .browsingCountryCostaRica) - case .coteDIvoire: return String(localized: .browsingCountryCoteDIvoire) - case .croatia: return String(localized: .browsingCountryCroatia) - case .cuba: return String(localized: .browsingCountryCuba) - case .curacao: return String(localized: .browsingCountryCuracao) - case .cyprus: return String(localized: .browsingCountryCyprus) - case .czechRepublic: return String(localized: .browsingCountryCzechRepublic) - case .denmark: return String(localized: .browsingCountryDenmark) - case .djibouti: return String(localized: .browsingCountryDjibouti) - case .dominica: return String(localized: .browsingCountryDominica) - case .dominicanRepublic: return String(localized: .browsingCountryDominicanRepublic) - case .ecuador: return String(localized: .browsingCountryEcuador) - case .egypt: return String(localized: .browsingCountryEgypt) - case .elSalvador: return String(localized: .browsingCountryElSalvador) - case .equatorialGuinea: return String(localized: .browsingCountryEquatorialGuinea) - case .eritrea: return String(localized: .browsingCountryEritrea) - case .estonia: return String(localized: .browsingCountryEstonia) - case .ethiopia: return String(localized: .browsingCountryEthiopia) - case .europe: return String(localized: .browsingCountryEurope) - case .falklandIslands: return String(localized: .browsingCountryFalklandIslands) - case .faroeIslands: return String(localized: .browsingCountryFaroeIslands) - case .fiji: return String(localized: .browsingCountryFiji) - case .finland: return String(localized: .browsingCountryFinland) - case .france: return String(localized: .browsingCountryFrance) - case .frenchGuiana: return String(localized: .browsingCountryFrenchGuiana) - case .frenchPolynesia: return String(localized: .browsingCountryFrenchPolynesia) - case .frenchSouthernTerritories: return String(localized: .browsingCountryFrenchSouthernTerritories) - case .gabon: return String(localized: .browsingCountryGabon) - case .gambia: return String(localized: .browsingCountryGambia) - case .georgia: return String(localized: .browsingCountryGeorgia) - case .germany: return String(localized: .browsingCountryGermany) - case .ghana: return String(localized: .browsingCountryGhana) - case .gibraltar: return String(localized: .browsingCountryGibraltar) - case .greece: return String(localized: .browsingCountryGreece) - case .greenland: return String(localized: .browsingCountryGreenland) - case .grenada: return String(localized: .browsingCountryGrenada) - case .guadeloupe: return String(localized: .browsingCountryGuadeloupe) - case .guam: return String(localized: .browsingCountryGuam) - case .guatemala: return String(localized: .browsingCountryGuatemala) - case .guernsey: return String(localized: .browsingCountryGuernsey) - case .guinea: return String(localized: .browsingCountryGuinea) - case .guineaBissau: return String(localized: .browsingCountryGuineaBissau) - case .guyana: return String(localized: .browsingCountryGuyana) - case .haiti: return String(localized: .browsingCountryHaiti) - case .heardIslandAndMcDonaldIslands: return String(localized: .browsingCountryHeardIslandAndMcDonaldIslands) - case .vaticanCityState: return String(localized: .browsingCountryVaticanCityState) - case .honduras: return String(localized: .browsingCountryHonduras) - case .hongKong: return String(localized: .browsingCountryHongKong) - case .hungary: return String(localized: .browsingCountryHungary) - case .iceland: return String(localized: .browsingCountryIceland) - case .india: return String(localized: .browsingCountryIndia) - case .indonesia: return String(localized: .browsingCountryIndonesia) - case .iran: return String(localized: .browsingCountryIran) - case .iraq: return String(localized: .browsingCountryIraq) - case .ireland: return String(localized: .browsingCountryIreland) - case .isleOfMan: return String(localized: .browsingCountryIsleOfMan) - case .israel: return String(localized: .browsingCountryIsrael) - case .italy: return String(localized: .browsingCountryItaly) - case .jamaica: return String(localized: .browsingCountryJamaica) - case .japan: return String(localized: .browsingCountryJapan) - case .jersey: return String(localized: .browsingCountryJersey) - case .jordan: return String(localized: .browsingCountryJordan) - case .kazakhstan: return String(localized: .browsingCountryKazakhstan) - case .kenya: return String(localized: .browsingCountryKenya) - case .kiribati: return String(localized: .browsingCountryKiribati) - case .kuwait: return String(localized: .browsingCountryKuwait) - case .kyrgyzstan: return String(localized: .browsingCountryKyrgyzstan) - case .laoPeoplesDemocraticRepublic: return String(localized: .browsingCountryLaoPeoplesDemocraticRepublic) - case .latvia: return String(localized: .browsingCountryLatvia) - case .lebanon: return String(localized: .browsingCountryLebanon) - case .lesotho: return String(localized: .browsingCountryLesotho) - case .liberia: return String(localized: .browsingCountryLiberia) - case .libya: return String(localized: .browsingCountryLibya) - case .liechtenstein: return String(localized: .browsingCountryLiechtenstein) - case .lithuania: return String(localized: .browsingCountryLithuania) - case .luxembourg: return String(localized: .browsingCountryLuxembourg) - case .macau: return String(localized: .browsingCountryMacau) - case .macedonia: return String(localized: .browsingCountryMacedonia) - case .madagascar: return String(localized: .browsingCountryMadagascar) - case .malawi: return String(localized: .browsingCountryMalawi) - case .malaysia: return String(localized: .browsingCountryMalaysia) - case .maldives: return String(localized: .browsingCountryMaldives) - case .mali: return String(localized: .browsingCountryMali) - case .malta: return String(localized: .browsingCountryMalta) - case .marshallIslands: return String(localized: .browsingCountryMarshallIslands) - case .martinique: return String(localized: .browsingCountryMartinique) - case .mauritania: return String(localized: .browsingCountryMauritania) - case .mauritius: return String(localized: .browsingCountryMauritius) - case .mayotte: return String(localized: .browsingCountryMayotte) - case .mexico: return String(localized: .browsingCountryMexico) - case .micronesia: return String(localized: .browsingCountryMicronesia) - case .moldova: return String(localized: .browsingCountryMoldova) - case .monaco: return String(localized: .browsingCountryMonaco) - case .mongolia: return String(localized: .browsingCountryMongolia) - case .montenegro: return String(localized: .browsingCountryMontenegro) - case .montserrat: return String(localized: .browsingCountryMontserrat) - case .morocco: return String(localized: .browsingCountryMorocco) - case .mozambique: return String(localized: .browsingCountryMozambique) - case .myanmar: return String(localized: .browsingCountryMyanmar) - case .namibia: return String(localized: .browsingCountryNamibia) - case .nauru: return String(localized: .browsingCountryNauru) - case .nepal: return String(localized: .browsingCountryNepal) - case .netherlands: return String(localized: .browsingCountryNetherlands) - case .newCaledonia: return String(localized: .browsingCountryNewCaledonia) - case .newZealand: return String(localized: .browsingCountryNewZealand) - case .nicaragua: return String(localized: .browsingCountryNicaragua) - case .niger: return String(localized: .browsingCountryNiger) - case .nigeria: return String(localized: .browsingCountryNigeria) - case .niue: return String(localized: .browsingCountryNiue) - case .norfolkIsland: return String(localized: .browsingCountryNorfolkIsland) - case .northKorea: return String(localized: .browsingCountryNorthKorea) - case .northernMarianaIslands: return String(localized: .browsingCountryNorthernMarianaIslands) - case .norway: return String(localized: .browsingCountryNorway) - case .oman: return String(localized: .browsingCountryOman) - case .pakistan: return String(localized: .browsingCountryPakistan) - case .palau: return String(localized: .browsingCountryPalau) - case .palestinianTerritory: return String(localized: .browsingCountryPalestinianTerritory) - case .panama: return String(localized: .browsingCountryPanama) - case .papuaNewGuinea: return String(localized: .browsingCountryPapuaNewGuinea) - case .paraguay: return String(localized: .browsingCountryParaguay) - case .peru: return String(localized: .browsingCountryPeru) - case .philippines: return String(localized: .browsingCountryPhilippines) - case .pitcairnIslands: return String(localized: .browsingCountryPitcairnIslands) - case .poland: return String(localized: .browsingCountryPoland) - case .portugal: return String(localized: .browsingCountryPortugal) - case .puertoRico: return String(localized: .browsingCountryPuertoRico) - case .qatar: return String(localized: .browsingCountryQatar) - case .reunion: return String(localized: .browsingCountryReunion) - case .romania: return String(localized: .browsingCountryRomania) - case .russianFederation: return String(localized: .browsingCountryRussianFederation) - case .rwanda: return String(localized: .browsingCountryRwanda) - case .saintBarthelemy: return String(localized: .browsingCountrySaintBarthelemy) - case .saintHelena: return String(localized: .browsingCountrySaintHelena) - case .saintKittsAndNevis: return String(localized: .browsingCountrySaintKittsAndNevis) - case .saintLucia: return String(localized: .browsingCountrySaintLucia) - case .saintMartin: return String(localized: .browsingCountrySaintMartin) - case .saintPierreAndMiquelon: return String(localized: .browsingCountrySaintPierreAndMiquelon) - case .saintVincentAndTheGrenadines: return String(localized: .browsingCountrySaintVincentAndTheGrenadines) - case .samoa: return String(localized: .browsingCountrySamoa) - case .sanMarino: return String(localized: .browsingCountrySanMarino) - case .saoTomeAndPrincipe: return String(localized: .browsingCountrySaoTomeAndPrincipe) - case .saudiArabia: return String(localized: .browsingCountrySaudiArabia) - case .senegal: return String(localized: .browsingCountrySenegal) - case .serbia: return String(localized: .browsingCountrySerbia) - case .seychelles: return String(localized: .browsingCountrySeychelles) - case .sierraLeone: return String(localized: .browsingCountrySierraLeone) - case .singapore: return String(localized: .browsingCountrySingapore) - case .sintMaarten: return String(localized: .browsingCountrySintMaarten) - case .slovakia: return String(localized: .browsingCountrySlovakia) - case .slovenia: return String(localized: .browsingCountrySlovenia) - case .solomonIslands: return String(localized: .browsingCountrySolomonIslands) - case .somalia: return String(localized: .browsingCountrySomalia) - case .southAfrica: return String(localized: .browsingCountrySouthAfrica) - case .southGeorgiaAndTheSouthSandwichIslands: return String(localized: .browsingCountrySouthGeorgiaAndTheSouthSandwichIslands) - case .southKorea: return String(localized: .browsingCountrySouthKorea) - case .southSudan: return String(localized: .browsingCountrySouthSudan) - case .spain: return String(localized: .browsingCountrySpain) - case .sriLanka: return String(localized: .browsingCountrySriLanka) - case .sudan: return String(localized: .browsingCountrySudan) - case .suriname: return String(localized: .browsingCountrySuriname) - case .svalbardAndJanMayen: return String(localized: .browsingCountrySvalbardAndJanMayen) - case .swaziland: return String(localized: .browsingCountrySwaziland) - case .sweden: return String(localized: .browsingCountrySweden) - case .switzerland: return String(localized: .browsingCountrySwitzerland) - case .syrianArabRepublic: return String(localized: .browsingCountrySyrianArabRepublic) - case .taiwan: return String(localized: .browsingCountryTaiwan) - case .tajikistan: return String(localized: .browsingCountryTajikistan) - case .tanzania: return String(localized: .browsingCountryTanzania) - case .thailand: return String(localized: .browsingCountryThailand) - case .timorLeste: return String(localized: .browsingCountryTimorLeste) - case .togo: return String(localized: .browsingCountryTogo) - case .tokelau: return String(localized: .browsingCountryTokelau) - case .tonga: return String(localized: .browsingCountryTonga) - case .trinidadAndTobago: return String(localized: .browsingCountryTrinidadAndTobago) - case .tunisia: return String(localized: .browsingCountryTunisia) - case .turkey: return String(localized: .browsingCountryTurkey) - case .turkmenistan: return String(localized: .browsingCountryTurkmenistan) - case .turksAndCaicosIslands: return String(localized: .browsingCountryTurksAndCaicosIslands) - case .tuvalu: return String(localized: .browsingCountryTuvalu) - case .uganda: return String(localized: .browsingCountryUganda) - case .ukraine: return String(localized: .browsingCountryUkraine) - case .unitedArabEmirates: return String(localized: .browsingCountryUnitedArabEmirates) - case .unitedKingdom: return String(localized: .browsingCountryUnitedKingdom) - case .unitedStates: return String(localized: .browsingCountryUnitedStates) - case .unitedStatesMinorOutlyingIslands: return String(localized: .browsingCountryUnitedStatesMinorOutlyingIslands) - case .uruguay: return String(localized: .browsingCountryUruguay) - case .uzbekistan: return String(localized: .browsingCountryUzbekistan) - case .vanuatu: return String(localized: .browsingCountryVanuatu) - case .venezuela: return String(localized: .browsingCountryVenezuela) - case .vietnam: return String(localized: .browsingCountryVietnam) - case .virginIslandsBritish: return String(localized: .browsingCountryVirginIslandsBritish) - case .virginIslandsUS: return String(localized: .browsingCountryVirginIslandsUs) - case .wallisAndFutuna: return String(localized: .browsingCountryWallisAndFutuna) - case .westernSahara: return String(localized: .browsingCountryWesternSahara) - case .yemen: return String(localized: .browsingCountryYemen) - case .zambia: return String(localized: .browsingCountryZambia) - case .zimbabwe: return String(localized: .browsingCountryZimbabwe) + case .autoDetect: return .browsingCountryAutoDetect + case .afghanistan: return .browsingCountryAfghanistan + case .alandIslands: return .browsingCountryAlandIslands + case .albania: return .browsingCountryAlbania + case .algeria: return .browsingCountryAlgeria + case .americanSamoa: return .browsingCountryAmericanSamoa + case .andorra: return .browsingCountryAndorra + case .angola: return .browsingCountryAngola + case .anguilla: return .browsingCountryAnguilla + case .antarctica: return .browsingCountryAntarctica + case .antiguaAndBarbuda: return .browsingCountryAntiguaAndBarbuda + case .argentina: return .browsingCountryArgentina + case .armenia: return .browsingCountryArmenia + case .aruba: return .browsingCountryAruba + case .asiaPacificRegion: return .browsingCountryAsiaPacificRegion + case .australia: return .browsingCountryAustralia + case .austria: return .browsingCountryAustria + case .azerbaijan: return .browsingCountryAzerbaijan + case .bahamas: return .browsingCountryBahamas + case .bahrain: return .browsingCountryBahrain + case .bangladesh: return .browsingCountryBangladesh + case .barbados: return .browsingCountryBarbados + case .belarus: return .browsingCountryBelarus + case .belgium: return .browsingCountryBelgium + case .belize: return .browsingCountryBelize + case .benin: return .browsingCountryBenin + case .bermuda: return .browsingCountryBermuda + case .bhutan: return .browsingCountryBhutan + case .bolivia: return .browsingCountryBolivia + case .bonaireSaintEustatiusAndSaba: return .browsingCountryBonaireSaintEustatiusAndSaba + case .bosniaAndHerzegovina: return .browsingCountryBosniaAndHerzegovina + case .botswana: return .browsingCountryBotswana + case .bouvetIsland: return .browsingCountryBouvetIsland + case .brazil: return .browsingCountryBrazil + case .britishIndianOceanTerritory: return .browsingCountryBritishIndianOceanTerritory + case .bruneiDarussalam: return .browsingCountryBruneiDarussalam + case .bulgaria: return .browsingCountryBulgaria + case .burkinaFaso: return .browsingCountryBurkinaFaso + case .burundi: return .browsingCountryBurundi + case .cambodia: return .browsingCountryCambodia + case .cameroon: return .browsingCountryCameroon + case .canada: return .browsingCountryCanada + case .capeVerde: return .browsingCountryCapeVerde + case .caymanIslands: return .browsingCountryCaymanIslands + case .centralAfricanRepublic: return .browsingCountryCentralAfricanRepublic + case .chad: return .browsingCountryChad + case .chile: return .browsingCountryChile + case .china: return .browsingCountryChina + case .christmasIsland: return .browsingCountryChristmasIsland + case .cocosIslands: return .browsingCountryCocosIslands + case .colombia: return .browsingCountryColombia + case .comoros: return .browsingCountryComoros + case .congo: return .browsingCountryCongo + case .theDemocraticRepublicOfTheCongo: return .browsingCountryTheDemocraticRepublicOfTheCongo + case .cookIslands: return .browsingCountryCookIslands + case .costaRica: return .browsingCountryCostaRica + case .coteDIvoire: return .browsingCountryCoteDIvoire + case .croatia: return .browsingCountryCroatia + case .cuba: return .browsingCountryCuba + case .curacao: return .browsingCountryCuracao + case .cyprus: return .browsingCountryCyprus + case .czechRepublic: return .browsingCountryCzechRepublic + case .denmark: return .browsingCountryDenmark + case .djibouti: return .browsingCountryDjibouti + case .dominica: return .browsingCountryDominica + case .dominicanRepublic: return .browsingCountryDominicanRepublic + case .ecuador: return .browsingCountryEcuador + case .egypt: return .browsingCountryEgypt + case .elSalvador: return .browsingCountryElSalvador + case .equatorialGuinea: return .browsingCountryEquatorialGuinea + case .eritrea: return .browsingCountryEritrea + case .estonia: return .browsingCountryEstonia + case .ethiopia: return .browsingCountryEthiopia + case .europe: return .browsingCountryEurope + case .falklandIslands: return .browsingCountryFalklandIslands + case .faroeIslands: return .browsingCountryFaroeIslands + case .fiji: return .browsingCountryFiji + case .finland: return .browsingCountryFinland + case .france: return .browsingCountryFrance + case .frenchGuiana: return .browsingCountryFrenchGuiana + case .frenchPolynesia: return .browsingCountryFrenchPolynesia + case .frenchSouthernTerritories: return .browsingCountryFrenchSouthernTerritories + case .gabon: return .browsingCountryGabon + case .gambia: return .browsingCountryGambia + case .georgia: return .browsingCountryGeorgia + case .germany: return .browsingCountryGermany + case .ghana: return .browsingCountryGhana + case .gibraltar: return .browsingCountryGibraltar + case .greece: return .browsingCountryGreece + case .greenland: return .browsingCountryGreenland + case .grenada: return .browsingCountryGrenada + case .guadeloupe: return .browsingCountryGuadeloupe + case .guam: return .browsingCountryGuam + case .guatemala: return .browsingCountryGuatemala + case .guernsey: return .browsingCountryGuernsey + case .guinea: return .browsingCountryGuinea + case .guineaBissau: return .browsingCountryGuineaBissau + case .guyana: return .browsingCountryGuyana + case .haiti: return .browsingCountryHaiti + case .heardIslandAndMcDonaldIslands: return .browsingCountryHeardIslandAndMcDonaldIslands + case .vaticanCityState: return .browsingCountryVaticanCityState + case .honduras: return .browsingCountryHonduras + case .hongKong: return .browsingCountryHongKong + case .hungary: return .browsingCountryHungary + case .iceland: return .browsingCountryIceland + case .india: return .browsingCountryIndia + case .indonesia: return .browsingCountryIndonesia + case .iran: return .browsingCountryIran + case .iraq: return .browsingCountryIraq + case .ireland: return .browsingCountryIreland + case .isleOfMan: return .browsingCountryIsleOfMan + case .israel: return .browsingCountryIsrael + case .italy: return .browsingCountryItaly + case .jamaica: return .browsingCountryJamaica + case .japan: return .browsingCountryJapan + case .jersey: return .browsingCountryJersey + case .jordan: return .browsingCountryJordan + case .kazakhstan: return .browsingCountryKazakhstan + case .kenya: return .browsingCountryKenya + case .kiribati: return .browsingCountryKiribati + case .kuwait: return .browsingCountryKuwait + case .kyrgyzstan: return .browsingCountryKyrgyzstan + case .laoPeoplesDemocraticRepublic: return .browsingCountryLaoPeoplesDemocraticRepublic + case .latvia: return .browsingCountryLatvia + case .lebanon: return .browsingCountryLebanon + case .lesotho: return .browsingCountryLesotho + case .liberia: return .browsingCountryLiberia + case .libya: return .browsingCountryLibya + case .liechtenstein: return .browsingCountryLiechtenstein + case .lithuania: return .browsingCountryLithuania + case .luxembourg: return .browsingCountryLuxembourg + case .macau: return .browsingCountryMacau + case .macedonia: return .browsingCountryMacedonia + case .madagascar: return .browsingCountryMadagascar + case .malawi: return .browsingCountryMalawi + case .malaysia: return .browsingCountryMalaysia + case .maldives: return .browsingCountryMaldives + case .mali: return .browsingCountryMali + case .malta: return .browsingCountryMalta + case .marshallIslands: return .browsingCountryMarshallIslands + case .martinique: return .browsingCountryMartinique + case .mauritania: return .browsingCountryMauritania + case .mauritius: return .browsingCountryMauritius + case .mayotte: return .browsingCountryMayotte + case .mexico: return .browsingCountryMexico + case .micronesia: return .browsingCountryMicronesia + case .moldova: return .browsingCountryMoldova + case .monaco: return .browsingCountryMonaco + case .mongolia: return .browsingCountryMongolia + case .montenegro: return .browsingCountryMontenegro + case .montserrat: return .browsingCountryMontserrat + case .morocco: return .browsingCountryMorocco + case .mozambique: return .browsingCountryMozambique + case .myanmar: return .browsingCountryMyanmar + case .namibia: return .browsingCountryNamibia + case .nauru: return .browsingCountryNauru + case .nepal: return .browsingCountryNepal + case .netherlands: return .browsingCountryNetherlands + case .newCaledonia: return .browsingCountryNewCaledonia + case .newZealand: return .browsingCountryNewZealand + case .nicaragua: return .browsingCountryNicaragua + case .niger: return .browsingCountryNiger + case .nigeria: return .browsingCountryNigeria + case .niue: return .browsingCountryNiue + case .norfolkIsland: return .browsingCountryNorfolkIsland + case .northKorea: return .browsingCountryNorthKorea + case .northernMarianaIslands: return .browsingCountryNorthernMarianaIslands + case .norway: return .browsingCountryNorway + case .oman: return .browsingCountryOman + case .pakistan: return .browsingCountryPakistan + case .palau: return .browsingCountryPalau + case .palestinianTerritory: return .browsingCountryPalestinianTerritory + case .panama: return .browsingCountryPanama + case .papuaNewGuinea: return .browsingCountryPapuaNewGuinea + case .paraguay: return .browsingCountryParaguay + case .peru: return .browsingCountryPeru + case .philippines: return .browsingCountryPhilippines + case .pitcairnIslands: return .browsingCountryPitcairnIslands + case .poland: return .browsingCountryPoland + case .portugal: return .browsingCountryPortugal + case .puertoRico: return .browsingCountryPuertoRico + case .qatar: return .browsingCountryQatar + case .reunion: return .browsingCountryReunion + case .romania: return .browsingCountryRomania + case .russianFederation: return .browsingCountryRussianFederation + case .rwanda: return .browsingCountryRwanda + case .saintBarthelemy: return .browsingCountrySaintBarthelemy + case .saintHelena: return .browsingCountrySaintHelena + case .saintKittsAndNevis: return .browsingCountrySaintKittsAndNevis + case .saintLucia: return .browsingCountrySaintLucia + case .saintMartin: return .browsingCountrySaintMartin + case .saintPierreAndMiquelon: return .browsingCountrySaintPierreAndMiquelon + case .saintVincentAndTheGrenadines: return .browsingCountrySaintVincentAndTheGrenadines + case .samoa: return .browsingCountrySamoa + case .sanMarino: return .browsingCountrySanMarino + case .saoTomeAndPrincipe: return .browsingCountrySaoTomeAndPrincipe + case .saudiArabia: return .browsingCountrySaudiArabia + case .senegal: return .browsingCountrySenegal + case .serbia: return .browsingCountrySerbia + case .seychelles: return .browsingCountrySeychelles + case .sierraLeone: return .browsingCountrySierraLeone + case .singapore: return .browsingCountrySingapore + case .sintMaarten: return .browsingCountrySintMaarten + case .slovakia: return .browsingCountrySlovakia + case .slovenia: return .browsingCountrySlovenia + case .solomonIslands: return .browsingCountrySolomonIslands + case .somalia: return .browsingCountrySomalia + case .southAfrica: return .browsingCountrySouthAfrica + case .southGeorgiaAndTheSouthSandwichIslands: return .browsingCountrySouthGeorgiaAndTheSouthSandwichIslands + case .southKorea: return .browsingCountrySouthKorea + case .southSudan: return .browsingCountrySouthSudan + case .spain: return .browsingCountrySpain + case .sriLanka: return .browsingCountrySriLanka + case .sudan: return .browsingCountrySudan + case .suriname: return .browsingCountrySuriname + case .svalbardAndJanMayen: return .browsingCountrySvalbardAndJanMayen + case .swaziland: return .browsingCountrySwaziland + case .sweden: return .browsingCountrySweden + case .switzerland: return .browsingCountrySwitzerland + case .syrianArabRepublic: return .browsingCountrySyrianArabRepublic + case .taiwan: return .browsingCountryTaiwan + case .tajikistan: return .browsingCountryTajikistan + case .tanzania: return .browsingCountryTanzania + case .thailand: return .browsingCountryThailand + case .timorLeste: return .browsingCountryTimorLeste + case .togo: return .browsingCountryTogo + case .tokelau: return .browsingCountryTokelau + case .tonga: return .browsingCountryTonga + case .trinidadAndTobago: return .browsingCountryTrinidadAndTobago + case .tunisia: return .browsingCountryTunisia + case .turkey: return .browsingCountryTurkey + case .turkmenistan: return .browsingCountryTurkmenistan + case .turksAndCaicosIslands: return .browsingCountryTurksAndCaicosIslands + case .tuvalu: return .browsingCountryTuvalu + case .uganda: return .browsingCountryUganda + case .ukraine: return .browsingCountryUkraine + case .unitedArabEmirates: return .browsingCountryUnitedArabEmirates + case .unitedKingdom: return .browsingCountryUnitedKingdom + case .unitedStates: return .browsingCountryUnitedStates + case .unitedStatesMinorOutlyingIslands: return .browsingCountryUnitedStatesMinorOutlyingIslands + case .uruguay: return .browsingCountryUruguay + case .uzbekistan: return .browsingCountryUzbekistan + case .vanuatu: return .browsingCountryVanuatu + case .venezuela: return .browsingCountryVenezuela + case .vietnam: return .browsingCountryVietnam + case .virginIslandsBritish: return .browsingCountryVirginIslandsBritish + case .virginIslandsUS: return .browsingCountryVirginIslandsUs + case .wallisAndFutuna: return .browsingCountryWallisAndFutuna + case .westernSahara: return .browsingCountryWesternSahara + case .yemen: return .browsingCountryYemen + case .zambia: return .browsingCountryZambia + case .zimbabwe: return .browsingCountryZimbabwe } } } diff --git a/AppPackage/Sources/AppModels/Support/EhSetting.swift b/AppPackage/Sources/AppModels/Support/EhSetting.swift index 236e05e4b..52665e0e6 100644 --- a/AppPackage/Sources/AppModels/Support/EhSetting.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting.swift @@ -139,7 +139,8 @@ public struct EhSetting: Equatable, Sendable { } } public var localizedLiteralBrowsingCountry: String? { - BrowsingCountry.allCases.first(where: { $0.englishName == literalBrowsingCountry })?.name + BrowsingCountry.allCases.first(where: { $0.englishName == literalBrowsingCountry }) + .map { String(localized: $0.name) } } public var loadThroughHathSetting: LoadThroughHathSetting From 03d2d508226f8333bf9e35d9edcae47340f6ca68 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 01:33:55 +0800 Subject: [PATCH 496/614] Type TagNamespace.value as resource --- .../Sources/AppModels/Tags/TagNamespace.swift | 27 ++++++++++--------- .../DetailFeature/DetailView+Subviews.swift | 2 +- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/AppPackage/Sources/AppModels/Tags/TagNamespace.swift b/AppPackage/Sources/AppModels/Tags/TagNamespace.swift index 5679621dc..1ae007068 100644 --- a/AppPackage/Sources/AppModels/Tags/TagNamespace.swift +++ b/AppPackage/Sources/AppModels/Tags/TagNamespace.swift @@ -1,3 +1,4 @@ +import Foundation import Resources public enum TagNamespace: String, Codable, CaseIterable, Sendable { @@ -59,20 +60,20 @@ extension TagNamespace { case .temp: return nil } } - public var value: String { + public var value: LocalizedStringResource { switch self { - case .reclass: return String(localized: .tagNamespaceReclass) - case .language: return String(localized: .tagNamespaceLanguage) - case .parody: return String(localized: .tagNamespaceParody) - case .character: return String(localized: .tagNamespaceCharacter) - case .group: return String(localized: .tagNamespaceGroup) - case .artist: return String(localized: .tagNamespaceArtist) - case .male: return String(localized: .tagNamespaceMale) - case .female: return String(localized: .tagNamespaceFemale) - case .mixed: return String(localized: .tagNamespaceMixed) - case .cosplayer: return String(localized: .tagNamespaceCosplayer) - case .other: return String(localized: .tagNamespaceOther) - case .temp: return String(localized: .tagNamespaceTemp) + case .reclass: return .tagNamespaceReclass + case .language: return .tagNamespaceLanguage + case .parody: return .tagNamespaceParody + case .character: return .tagNamespaceCharacter + case .group: return .tagNamespaceGroup + case .artist: return .tagNamespaceArtist + case .male: return .tagNamespaceMale + case .female: return .tagNamespaceFemale + case .mixed: return .tagNamespaceMixed + case .cosplayer: return .tagNamespaceCosplayer + case .other: return .tagNamespaceOther + case .temp: return .tagNamespaceTemp } } } diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index 7d16e52fb..91ab02f1b 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -198,7 +198,7 @@ extension TagsSection { var body: some View { HStack(alignment: .top) { - Text(tag.namespace?.value ?? tag.rawNamespace).font(.subheadline.bold()) + Text(tag.namespace.map { String(localized: $0.value) } ?? tag.rawNamespace).font(.subheadline.bold()) .foregroundColor(reversedPrimary).padding(padding) .background(Color(.systemGray)).cornerRadius(5) TagCloudView(data: tag.contents) { content in From 094900e9a92421f62774349a33afafefe6521f10 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 01:38:32 +0800 Subject: [PATCH 497/614] Type EhSetting option enums as resources --- .../AppModels/Support/EhSetting+Enums.swift | 37 +++++----- .../Support/EhSetting+Extensions.swift | 23 ++++--- .../Sources/AppModels/Support/EhSetting.swift | 67 ++++++++++--------- 3 files changed, 65 insertions(+), 62 deletions(-) diff --git a/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift b/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift index a136505c9..4c8cd2c7c 100644 --- a/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift @@ -1,3 +1,4 @@ +import Foundation import Resources // MARK: CommentsSortOrder @@ -11,14 +12,14 @@ extension EhSetting { extension EhSetting.CommentsSortOrder { public var id: Int { rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { case .oldest: - return String(localized: .commentsSortOrderOldest) + return .commentsSortOrderOldest case .recent: - return String(localized: .commentsSortOrderRecent) + return .commentsSortOrderRecent case .highestScore: - return String(localized: .commentsSortOrderHighestScore) + return .commentsSortOrderHighestScore } } } @@ -33,12 +34,12 @@ extension EhSetting { extension EhSetting.CommentVotesShowTiming { public var id: Int { rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { case .onHoverOrClick: - return String(localized: .commentsVotesShowTimingOnHoverOrClick) + return .commentsVotesShowTimingOnHoverOrClick case .always: - return String(localized: .commentsVotesShowTimingAlways) + return .commentsVotesShowTimingAlways } } } @@ -53,12 +54,12 @@ extension EhSetting { extension EhSetting.TagsSortOrder { public var id: Int { rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { case .alphabetical: - return String(localized: .tagsSortOrderAlphabetical) + return .tagsSortOrderAlphabetical case .tagPower: - return String(localized: .tagsSortOrderTagPower) + return .tagsSortOrderTagPower } } } @@ -74,14 +75,14 @@ extension EhSetting { extension EhSetting.MultiplePageViewerStyle { public var id: Int { rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { case .alignLeftScaleIfOverWidth: - return String(localized: .multiplePageViewerStyleAlignLeftScaleIfOverWidth) + return .multiplePageViewerStyleAlignLeftScaleIfOverWidth case .alignCenterScaleIfOverWidth: - return String(localized: .multiplePageViewerStyleAlignCenterScaleIfOverWidth) + return .multiplePageViewerStyleAlignCenterScaleIfOverWidth case .alignCenterAlwaysScale: - return String(localized: .multiplePageViewerStyleAlignCenterAlwaysScale) + return .multiplePageViewerStyleAlignCenterAlwaysScale } } } @@ -97,11 +98,11 @@ extension EhSetting { extension EhSetting.GalleryPageNumbering { public var id: Int { rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { - case .none: String(localized: .galleryPageNumberingNone) - case .pageNumberOnly: String(localized: .galleryPageNumberingPageNumberOnly) - case .pageNumberAndName: String(localized: .galleryPageNumberingPageNumberAndName) + case .none: .galleryPageNumberingNone + case .pageNumberOnly: .galleryPageNumberingPageNumberOnly + case .pageNumberAndName: .galleryPageNumberingPageNumberAndName } } } diff --git a/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift b/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift index 5a3278e00..59370d283 100644 --- a/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift @@ -1,3 +1,4 @@ +import Foundation import Resources // MARK: ThumbnailLoadTiming @@ -10,20 +11,20 @@ extension EhSetting { extension EhSetting.ThumbnailLoadTiming { public var id: Int { rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { case .onMouseOver: - return String(localized: .thumbnailLoadTimingOnMouseOver) + return .thumbnailLoadTimingOnMouseOver case .onPageLoad: - return String(localized: .thumbnailLoadTimingOnPageLoad) + return .thumbnailLoadTimingOnPageLoad } } - public var description: String { + public var description: LocalizedStringResource { switch self { case .onMouseOver: - return String(localized: .thumbnailLoadTimingOnMouseOverDescription) + return .thumbnailLoadTimingOnMouseOverDescription case .onPageLoad: - return String(localized: .thumbnailLoadTimingOnPageLoadDescription) + return .thumbnailLoadTimingOnPageLoadDescription } } } @@ -44,16 +45,16 @@ extension EhSetting.ThumbnailSize { lhs.rawValue < rhs.rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { case .normal: - return String(localized: .thumbnailSizeNormal) + return .thumbnailSizeNormal case .large: - return String(localized: .thumbnailSizeLarge) + return .thumbnailSizeLarge case .small: - return String(localized: .thumbnailSizeSmall) + return .thumbnailSizeSmall case .auto: - return String(localized: .thumbnailSizeAuto) + return .thumbnailSizeAuto } } } diff --git a/AppPackage/Sources/AppModels/Support/EhSetting.swift b/AppPackage/Sources/AppModels/Support/EhSetting.swift index 52665e0e6..5dd4216c0 100644 --- a/AppPackage/Sources/AppModels/Support/EhSetting.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting.swift @@ -1,3 +1,4 @@ +import Foundation import Resources // MARK: EhSetting @@ -230,28 +231,28 @@ extension EhSetting.LoadThroughHathSetting { lhs.rawValue < rhs.rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { case .anyClient: - return String(localized: .loadThroughHathSettingAnyClient) + return .loadThroughHathSettingAnyClient case .defaultPortOnly: - return String(localized: .loadThroughHathSettingDefaultPortOnly) + return .loadThroughHathSettingDefaultPortOnly case .modernNo: - return String(localized: .loadThroughHathSettingModernNo) + return .loadThroughHathSettingModernNo case .legacyNo: - return String(localized: .loadThroughHathSettingLegacyNo) + return .loadThroughHathSettingLegacyNo } } - public var description: String { + public var description: LocalizedStringResource { switch self { case .anyClient: - return String(localized: .loadThroughHathSettingAnyClientDescription) + return .loadThroughHathSettingAnyClientDescription case .defaultPortOnly: - return String(localized: .loadThroughHathSettingDefaultPortOnlyDescription) + return .loadThroughHathSettingDefaultPortOnlyDescription case .modernNo: - return String(localized: .loadThroughHathSettingModernNoDescription) + return .loadThroughHathSettingModernNoDescription case .legacyNo: - return String(localized: .loadThroughHathSettingLegacyNoDescription) + return .loadThroughHathSettingLegacyNoDescription } } } @@ -302,12 +303,12 @@ extension EhSetting { extension EhSetting.GalleryName { public var id: Int { rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { case .default: - return String(localized: .galleryNameDefault) + return .galleryNameDefault case .japanese: - return String(localized: .galleryNameJapanese) + return .galleryNameJapanese } } } @@ -326,20 +327,20 @@ extension EhSetting { extension EhSetting.ArchiverBehavior { public var id: Int { rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { case .manualSelectManualStart: - return String(localized: .ehSettingArchiverBehaviorManualSelectManualStart) + return .ehSettingArchiverBehaviorManualSelectManualStart case .manualSelectAutoStart: - return String(localized: .ehSettingArchiverBehaviorManualSelectAutoStart) + return .ehSettingArchiverBehaviorManualSelectAutoStart case .autoSelectOriginalManualStart: - return String(localized: .ehSettingArchiverBehaviorAutoSelectOriginalManualStart) + return .ehSettingArchiverBehaviorAutoSelectOriginalManualStart case .autoSelectOriginalAutoStart: - return String(localized: .ehSettingArchiverBehaviorAutoSelectOriginalAutoStart) + return .ehSettingArchiverBehaviorAutoSelectOriginalAutoStart case .autoSelectResampleManualStart: - return String(localized: .ehSettingArchiverBehaviorAutoSelectResampleManualStart) + return .ehSettingArchiverBehaviorAutoSelectResampleManualStart case .autoSelectResampleAutoStart: - return String(localized: .ehSettingArchiverBehaviorAutoSelectResampleAutoStart) + return .ehSettingArchiverBehaviorAutoSelectResampleAutoStart } } } @@ -357,18 +358,18 @@ extension EhSetting { extension EhSetting.DisplayMode { public var id: Int { rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { case .compact: - return String(localized: .displayModeCompact) + return .displayModeCompact case .thumbnail: - return String(localized: .displayModeThumbnail) + return .displayModeThumbnail case .extended: - return String(localized: .displayModeExtended) + return .displayModeExtended case .minimal: - return String(localized: .displayModeMinimal) + return .displayModeMinimal case .minimalPlus: - return String(localized: .displayModeMinimalPlus) + return .displayModeMinimalPlus } } } @@ -383,12 +384,12 @@ extension EhSetting { extension EhSetting.FavoritesSortOrder { public var id: Int { rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { case .lastUpdateTime: - return String(localized: .favoritesSortOrderLastUpdateTime) + return .favoritesSortOrderLastUpdateTime case .favoritedTime: - return String(localized: .favoritesSortOrderFavoritedTime) + return .favoritesSortOrderFavoritedTime } } } @@ -404,14 +405,14 @@ extension EhSetting { extension EhSetting.ExcludedLanguagesCategory { public var id: Int { rawValue } - public var value: String { + public var value: LocalizedStringResource { switch self { case .original: - return String(localized: .excludedLanguagesCategoryOriginal) + return .excludedLanguagesCategoryOriginal case .translated: - return String(localized: .excludedLanguagesCategoryTranslated) + return .excludedLanguagesCategoryTranslated case .rewrite: - return String(localized: .excludedLanguagesCategoryRewrite) + return .excludedLanguagesCategoryRewrite } } } From 94a5d9ba2c1e6e02406557a19ef46976b5db5ab8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 01:44:32 +0800 Subject: [PATCH 498/614] Type remaining AppModels display enums --- .../AppModels/Persistent/AppIconType.swift | 13 ++++---- .../Sources/AppModels/Persistent/Filter.swift | 8 ++--- .../AppModels/Persistent/Setting.swift | 32 +++++++++---------- .../AppModels/Support/AppActivityLog.swift | 14 ++++---- .../AppModels/Support/ToplistsType.swift | 11 ++++--- .../HomeFeature/Toplists/ToplistsView.swift | 2 +- .../AppActivityLogsReducer.swift | 2 +- .../AppearanceSettingView.swift | 4 +-- 8 files changed, 44 insertions(+), 42 deletions(-) diff --git a/AppPackage/Sources/AppModels/Persistent/AppIconType.swift b/AppPackage/Sources/AppModels/Persistent/AppIconType.swift index 16a896b88..eb13c2df4 100644 --- a/AppPackage/Sources/AppModels/Persistent/AppIconType.swift +++ b/AppPackage/Sources/AppModels/Persistent/AppIconType.swift @@ -1,3 +1,4 @@ +import Foundation import Resources public enum AppIconType: Int, Codable, Identifiable, CaseIterable, Sendable { @@ -11,22 +12,22 @@ public enum AppIconType: Int, Codable, Identifiable, CaseIterable, Sendable { } extension AppIconType { - public var name: String { + public var name: LocalizedStringResource { switch self { case .default: - return String(localized: .appIconTypeDefault) + return .appIconTypeDefault case .ukiyoe: - return String(localized: .appIconTypeUkiyoe) + return .appIconTypeUkiyoe case .developer: - return String(localized: .appIconTypeDeveloper) + return .appIconTypeDeveloper case .standWithUkraine2022: - return String(localized: .appIconTypeStandWithUkraine2022) + return .appIconTypeStandWithUkraine2022 case .notMyPresidnet: - return String(localized: .appIconTypeNotMyPresident) + return .appIconTypeNotMyPresident } } diff --git a/AppPackage/Sources/AppModels/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift index dd158056d..80a836c98 100644 --- a/AppPackage/Sources/AppModels/Persistent/Filter.swift +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -159,14 +159,14 @@ public enum FilterRange: Int, CaseIterable, Identifiable, Sendable { case watched } public extension FilterRange { - var value: String { + var value: LocalizedStringResource { switch self { case .search: - return String(localized: .filterRangeSearch) + return .filterRangeSearch case .global: - return String(localized: .filterRangeGlobal) + return .filterRangeGlobal case .watched: - return String(localized: .filterRangeWatched) + return .filterRangeWatched } } } diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index 6b921de6b..5739f31df 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -166,16 +166,16 @@ public enum AutoLockPolicy: Int, Codable, CaseIterable, Identifiable, Sendable { } extension AutoLockPolicy { - public var value: String { + public var value: LocalizedStringResource { switch self { case .never: - return String(localized: .autoLockPolicyNever) + return .autoLockPolicyNever case .instantly: - return String(localized: .autoLockPolicyInstantly) + return .autoLockPolicyInstantly case .sec15: - return String(localized: .RLocalizable.seconds(count: rawValue)) + return .RLocalizable.seconds(count: rawValue) case .min1, .min5, .min10, .min30: - return String(localized: .RLocalizable.minutes(count: rawValue / 60)) + return .RLocalizable.minutes(count: rawValue / 60) } } } @@ -188,14 +188,14 @@ public enum PreferredColorScheme: Int, Codable, CaseIterable, Identifiable, Send case dark } extension PreferredColorScheme { - public var value: String { + public var value: LocalizedStringResource { switch self { case .automatic: - return String(localized: .preferredColorSchemeAutomatic) + return .preferredColorSchemeAutomatic case .light: - return String(localized: .preferredColorSchemeLight) + return .preferredColorSchemeLight case .dark: - return String(localized: .preferredColorSchemeDark) + return .preferredColorSchemeDark } } public var userInterfaceStyle: UIUserInterfaceStyle { @@ -218,14 +218,14 @@ public enum ReadingDirection: Int, Codable, CaseIterable, Identifiable, Sendable case leftToRight } extension ReadingDirection { - public var value: String { + public var value: LocalizedStringResource { switch self { case .vertical: - return String(localized: .readingDirectionVertical) + return .readingDirectionVertical case .rightToLeft: - return String(localized: .readingDirectionRightToLeft) + return .readingDirectionRightToLeft case .leftToRight: - return String(localized: .readingDirectionLeftToRight) + return .readingDirectionLeftToRight } } } @@ -237,12 +237,12 @@ public enum ListDisplayMode: Int, Codable, CaseIterable, Identifiable, Sendable case thumbnail } extension ListDisplayMode { - public var value: String { + public var value: LocalizedStringResource { switch self { case .detail: - return String(localized: .listDisplayModeDetail) + return .listDisplayModeDetail case .thumbnail: - return String(localized: .listDisplayModeThumbnail) + return .listDisplayModeThumbnail } } } diff --git a/AppPackage/Sources/AppModels/Support/AppActivityLog.swift b/AppPackage/Sources/AppModels/Support/AppActivityLog.swift index aa357c43f..2e639b70b 100644 --- a/AppPackage/Sources/AppModels/Support/AppActivityLog.swift +++ b/AppPackage/Sources/AppModels/Support/AppActivityLog.swift @@ -82,14 +82,14 @@ public extension OSLogEntryLog.Level { } } - var title: String { + var title: LocalizedStringResource { switch self { - case .undefined: String(localized: .appActivityLogLevelUndefined) - case .debug: String(localized: .appActivityLogLevelDebug) - case .info: String(localized: .appActivityLogLevelInfo) - case .notice: String(localized: .appActivityLogLevelNotice) - case .error: String(localized: .appActivityLogLevelError) - case .fault: String(localized: .appActivityLogLevelFault) + case .undefined: .appActivityLogLevelUndefined + case .debug: .appActivityLogLevelDebug + case .info: .appActivityLogLevelInfo + case .notice: .appActivityLogLevelNotice + case .error: .appActivityLogLevelError + case .fault: .appActivityLogLevelFault @unknown default: "" } } diff --git a/AppPackage/Sources/AppModels/Support/ToplistsType.swift b/AppPackage/Sources/AppModels/Support/ToplistsType.swift index 09a0fc248..b17e84903 100644 --- a/AppPackage/Sources/AppModels/Support/ToplistsType.swift +++ b/AppPackage/Sources/AppModels/Support/ToplistsType.swift @@ -1,3 +1,4 @@ +import Foundation import Resources public enum ToplistsType: Int, Codable, CaseIterable, Identifiable, Sendable { @@ -10,16 +11,16 @@ public enum ToplistsType: Int, Codable, CaseIterable, Identifiable, Sendable { } extension ToplistsType { - public var value: String { + public var value: LocalizedStringResource { switch self { case .yesterday: - return String(localized: .toplistsTypeYesterday) + return .toplistsTypeYesterday case .pastMonth: - return String(localized: .toplistsTypePastMonth) + return .toplistsTypePastMonth case .pastYear: - return String(localized: .toplistsTypePastYear) + return .toplistsTypePastYear case .allTime: - return String(localized: .toplistsTypeAllTime) + return .toplistsTypeAllTime } } public var categoryIndex: Int { diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index 6a33958b4..dc7cd03d7 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -26,7 +26,7 @@ struct ToplistsView: View { } private var navigationTitle: String { - [String(localized: .toplists), store.type.value].joined(separator: " - ") + [String(localized: .toplists), String(localized: store.type.value)].joined(separator: " - ") } var body: some View { diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift index 66a298bfe..3a272ca96 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift @@ -29,7 +29,7 @@ public struct AppActivityLogsReducer: Sendable { public var displayedLogs: [AppActivityLog] { let source = selectedRun == nil ? currentRunLogs : selectedRunLogs let filtered = keyword.isEmpty ? source : source.filter { log in - [log.dateDescription, log.level.title, log.category, log.message] + [log.dateDescription, String(localized: log.level.title), log.category, log.message] .joined(separator: " ") .caseInsensitiveContains(keyword) } diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift index 6a2e584fc..72f1d80cf 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -128,11 +128,11 @@ struct AppIconView: View { // MARK: AppIconRow private struct AppIconRow: View { - private let iconName: String + private let iconName: LocalizedStringResource private let filename: String private let isSelected: Bool - init(iconName: String, filename: String, isSelected: Bool) { + init(iconName: LocalizedStringResource, filename: String, isSelected: Bool) { self.iconName = iconName self.filename = filename self.isSelected = isSelected From 1d66294b550e0898d9bdfb027e8fb1bc1037f872 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 01:52:32 +0800 Subject: [PATCH 499/614] Convert EhSetting section labels to resources --- .../EhSetting/EhSettingView+Sections1.swift | 34 ++++++++------- .../EhSetting/EhSettingView+Sections2.swift | 26 ++++++------ .../EhSetting/EhSettingView+Sections3.swift | 42 +++++++++---------- 3 files changed, 52 insertions(+), 50 deletions(-) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift index 01a40138e..c7b5efd40 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -35,7 +35,7 @@ struct EhProfileSection: View { } Button( - String(localized: .deleteProfile), + .deleteProfile, role: .destructive, action: deleteDialogAction ) @@ -78,7 +78,7 @@ struct ImageLoadSettingsSection: View { var body: some View { Section { Picker( - String(localized: .loadImagesThroughTheHathNetwork), + .loadImagesThroughTheHathNetwork, selection: $ehSetting.loadThroughHathSetting ) { ForEach(ehSetting.capableLoadThroughHathSettings) { setting in @@ -88,7 +88,7 @@ struct ImageLoadSettingsSection: View { } .pickerStyle(.menu) } header: { - Text.ehSettingBoldHeader(String(localized: .imageLoadSettings)) + Text.ehSettingBoldHeader(.imageLoadSettings) } footer: { Text(ehSetting.loadThroughHathSetting.description) } @@ -128,15 +128,15 @@ struct ImageSizeSettingsSection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - String(localized: .imageSizeSettings), - description: String(localized: .imageResolutionDescription) + .imageSizeSettings, + description: .imageResolutionDescription ) } if let useOriginalImagesBinding = Binding($ehSetting.useOriginalImages) { Section { Toggle( - String(localized: .useOriginalImages), + .useOriginalImages, isOn: useOriginalImagesBinding ) } header: { @@ -179,8 +179,8 @@ struct GalleryNameDisplaySection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - String(localized: .galleryNameDisplay), - description: String(localized: .galleryNameDescription) + .galleryNameDisplay, + description: .galleryNameDescription ) } } @@ -201,8 +201,8 @@ struct ArchiverSettingsSection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - String(localized: .archiverSettings), - description: String(localized: .archiverBehaviorDescription) + .archiverSettings, + description: .archiverBehaviorDescription ) } } @@ -221,8 +221,8 @@ struct FrontPageSettingsSection: View { CategoryView(bindings: categoryBindings) } header: { Text.ehSettingBoldHeader( - String(localized: .frontPageSettings), - description: String(localized: .galleryCategory) + .frontPageSettings, + description: .galleryCategory ) } @@ -241,7 +241,7 @@ struct FrontPageSettingsSection: View { Section { Toggle( - String(localized: .showSearchRangeIndicatorDescription), + .showSearchRangeIndicatorDescription, isOn: $ehSetting.showSearchRangeIndicator ) } header: { @@ -292,11 +292,13 @@ struct ValuePicker: View { } extension Text { - static func ehSettingBoldHeader(_ title: String, description: String? = nil) -> Self { - var result = AttributedString(title) + static func ehSettingBoldHeader( + _ title: LocalizedStringResource, description: LocalizedStringResource? = nil + ) -> Self { + var result = AttributedString(String(localized: title)) result.font = .body.weight(.bold) if let description { - var descriptionString = AttributedString("\n\(description)") + var descriptionString = AttributedString("\n\(String(localized: description))") descriptionString.font = .subheadline.weight(.regular) result.append(descriptionString) } diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift index ca6beec18..a4d74d000 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift @@ -13,13 +13,13 @@ struct OptionalUIElementsSection: View { var body: some View { Section { Toggle( - String(localized: .enableGalleryThumbnailSelector), + .enableGalleryThumbnailSelector, isOn: $ehSetting.enableGalleryThumbnailSelector ) } header: { Text.ehSettingBoldHeader( - String(localized: .optionalUiElements), - description: String(localized: .optionalUiElementsDescription) + .optionalUiElements, + description: .optionalUiElementsDescription ) } } @@ -54,14 +54,14 @@ struct FavoritesSection: View { } } header: { Text.ehSettingBoldHeader( - String(localized: .favoritesSection), - description: String(localized: .favoriteCategories) + .favoritesSection, + description: .favoriteCategories ) } Section { Picker( - String(localized: .favoritesSortOrder), + .favoritesSortOrder, selection: $ehSetting.favoritesSortOrder ) { ForEach(EhSetting.FavoritesSortOrder.allCases) { order in @@ -95,8 +95,8 @@ struct RatingsSection: View { } } header: { Text.ehSettingBoldHeader( - String(localized: .ratings), - description: String(localized: .ratingsColorDescription) + .ratings, + description: .ratingsColorDescription ) } } @@ -119,8 +119,8 @@ struct SearchResultCountSection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - String(localized: .searchResultCount), - description: String(localized: .resultCountDescription) + .searchResultCount, + description: .resultCountDescription ) } } @@ -133,7 +133,7 @@ struct ThumbnailSettingsSection: View { var body: some View { Section { Picker( - String(localized: .thumbnailLoadTiming), + .thumbnailLoadTiming, selection: $ehSetting.thumbnailLoadTiming ) { ForEach(EhSetting.ThumbnailLoadTiming.allCases) { timing in @@ -144,8 +144,8 @@ struct ThumbnailSettingsSection: View { .pickerStyle(.menu) } header: { Text.ehSettingBoldHeader( - String(localized: .thumbnailSettings), - description: String(localized: .thumbnailLoadTimingDescription) + .thumbnailSettings, + description: .thumbnailLoadTimingDescription ) } footer: { Text(ehSetting.thumbnailLoadTiming.description) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift index e1416c18d..8d690d0af 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -19,8 +19,8 @@ struct CoverScalingSection: View { ) } header: { Text.ehSettingBoldHeader( - String(localized: .coverScaling), - description: String(localized: .coverScaleFactor) + .coverScaling, + description: .coverScaleFactor ) } } @@ -38,8 +38,8 @@ struct TagFilteringThresholdSection: View { ) } header: { Text.ehSettingBoldHeader( - String(localized: .tagFilteringThreshold), - description: String(localized: .tagFilteringThresholdDescription) + .tagFilteringThreshold, + description: .tagFilteringThresholdDescription ) } } @@ -57,8 +57,8 @@ struct TagWatchingThresholdSection: View { ) } header: { Text.ehSettingBoldHeader( - String(localized: .tagWatchingThreshold), - description: String(localized: .tagWatchingThresholdDescription) + .tagWatchingThreshold, + description: .tagWatchingThresholdDescription ) } } @@ -71,13 +71,13 @@ struct FilteredRemovalCountSection: View { var body: some View { Section { Toggle( - String(localized: .showFilteredRemovalCount), + .showFilteredRemovalCount, isOn: $ehSetting.showFilteredRemovalCount ) } header: { Text.ehSettingBoldHeader( - String(localized: .filteredRemovalCount), - description: String(localized: .filteredRemovalCountDescription) + .filteredRemovalCount, + description: .filteredRemovalCountDescription ) } } @@ -131,8 +131,8 @@ struct ExcludedLanguagesSection: View { } } header: { Text.ehSettingBoldHeader( - String(localized: .excludedLanguages), - description: String(localized: .excludedLanguagesDescription) + .excludedLanguages, + description: .excludedLanguagesDescription ) } } @@ -191,8 +191,8 @@ struct ExcludedUploadersSection: View { .focused($isFocused) } header: { Text.ehSettingBoldHeader( - String(localized: .excludedUploaders), - description: String(localized: .excludedUploadersDescription) + .excludedUploaders, + description: .excludedUploadersDescription ) } footer: { Text( @@ -219,8 +219,8 @@ struct ViewportOverrideSection: View { ) } header: { Text.ehSettingBoldHeader( - String(localized: .viewportOverride), - description: String(localized: .virtualWidthDescription) + .viewportOverride, + description: .virtualWidthDescription ) } } @@ -233,7 +233,7 @@ struct GalleryCommentsSection: View { var body: some View { Section { Picker( - String(localized: .commentsSortOrder), + .commentsSortOrder, selection: $ehSetting.commentsSortOrder ) { ForEach(EhSetting.CommentsSortOrder.allCases) { order in @@ -244,7 +244,7 @@ struct GalleryCommentsSection: View { .pickerStyle(.menu) Picker( - String(localized: .commentsVotesShowTiming), + .commentsVotesShowTiming, selection: $ehSetting.commentVotesShowTiming ) { ForEach(EhSetting.CommentVotesShowTiming.allCases) { timing in @@ -287,7 +287,7 @@ struct GalleryPageThumbnailLabelingSection: View { var body: some View { Section { Picker( - String(localized: .showLabelBelowGalleryThumbnails), + .showLabelBelowGalleryThumbnails, selection: $ehSetting.galleryPageNumbering ) { ForEach(EhSetting.GalleryPageNumbering.allCases) { behavior in @@ -313,12 +313,12 @@ struct MultiplePageViewerSection: View { let multiplePageViewerShowPaneBinding = Binding($ehSetting.multiplePageViewerShowThumbnailPane) { Section { Toggle( - String(localized: .useMultiPageViewer), + .useMultiPageViewer, isOn: useMultiplePageViewerBinding ) Picker( - String(localized: .displayStyle), + .displayStyle, selection: multiplePageViewerStyleBinding ) { ForEach(EhSetting.MultiplePageViewerStyle.allCases) { style in @@ -329,7 +329,7 @@ struct MultiplePageViewerSection: View { .pickerStyle(.menu) Toggle( - String(localized: .showThumbnailPane), + .showThumbnailPane, isOn: multiplePageViewerShowPaneBinding ) } header: { From 8bd52be629833a3995ed1bf77bf33382a3296318 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 01:54:49 +0800 Subject: [PATCH 500/614] Convert SettingFeature view labels to resources --- .../AccountSetting/AccountSettingView.swift | 6 +++--- .../AppearanceSettingView.swift | 8 ++++---- .../Components/DownloadSettingView.swift | 4 ++-- .../GeneralSetting/GeneralSettingView.swift | 10 +++++----- .../Sources/SettingFeature/SettingView.swift | 16 ++++++++-------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index 61506a640..1a74010a3 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -99,19 +99,19 @@ private struct AccountSection: View { Button(.RLocalizable.login, action: loginAction) } else { Button( - String(localized: .logout), + .logout, role: .destructive, action: logoutDialogAction ) .confirmationDialog(logoutConfirmationDialog) Group { Button( - String(localized: .accountConfiguration), + .accountConfiguration, action: configureAccountAction ) .withArrow() if !bypassesSNIFiltering { Button( - String(localized: .tagsManagement), + .tagsManagement, action: manageTagsAction ) .withArrow() diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift index 72f1d80cf..ab31e704d 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -39,7 +39,7 @@ struct AppearanceSettingView: View { Form { Section { Picker( - String(localized: .theme), + .theme, selection: $preferredColorScheme ) { ForEach(PreferredColorScheme.allCases) { colorScheme in @@ -59,7 +59,7 @@ struct AppearanceSettingView: View { } Section(.list) { Picker( - String(localized: .appearanceDisplayMode), + .appearanceDisplayMode, selection: $listDisplayMode, content: { ForEach(ListDisplayMode.allCases) { listMode in @@ -75,7 +75,7 @@ struct AppearanceSettingView: View { } Picker( - String(localized: .maximumNumberOfTags), + .maximumNumberOfTags, selection: $listTagsNumberMaximum ) { Text(.infite) @@ -91,7 +91,7 @@ struct AppearanceSettingView: View { } Section(.gallery) { Toggle( - String(localized: .displaysJapaneseTitle), + .displaysJapaneseTitle, isOn: $displaysJapaneseTitle ) } diff --git a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift index 10970bb60..4d46f8ad1 100644 --- a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift @@ -27,14 +27,14 @@ struct DownloadSettingView: View { Slider(value: downloadThreadLimitValue, in: 1...5, step: 1) } Toggle( - String(localized: .retryFailedPagesAutomatically), + .retryFailedPagesAutomatically, isOn: $downloadAutoRetryFailedPages ) } Section { Toggle( - String(localized: .allowCellularDownloads), + .allowCellularDownloads, isOn: $downloadAllowCellular ) } header: { diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index f8ce388f2..9d4784eb1 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -88,7 +88,7 @@ struct GeneralSettingView: View { if enablesTagsExtension && !tagTranslatorEmpty { Toggle(.translatesTags, isOn: $translatesTags) Toggle( - String(localized: .showsTagsSearchSuggestion), + .showsTagsSearchSuggestion, isOn: $showsTagsSearchSuggestion ) Toggle(.showsImagesInTags, isOn: $showsImagesInTags) @@ -106,7 +106,7 @@ struct GeneralSettingView: View { } if tagTranslatorHasCustomTranslations { Button( - String(localized: .removeCustomTranslations), + .removeCustomTranslations, role: .destructive, action: { store.send(.removeCustomTranslationsButtonTapped) } ) .confirmationDialog( @@ -116,18 +116,18 @@ struct GeneralSettingView: View { } Section(.navigation) { Toggle( - String(localized: .redirectsLinksToTheSelectedHost), + .redirectsLinksToTheSelectedHost, isOn: $redirectsLinksToSelectedHost ) Toggle( - String(localized: .detectsLinksFromClipboard), + .detectsLinksFromClipboard, isOn: $detectsLinksFromClipboard ) } Section(.security) { HStack { Picker( - String(localized: .autoLock), + .autoLock, selection: $autoLockPolicy ) { ForEach(AutoLockPolicy.allCases) { policy in diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 86b027e13..559cd61ab 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -164,22 +164,22 @@ private struct SettingRow: View { // MARK: Definition extension SettingReducer.RootScreen { - var value: String { + var value: LocalizedStringResource { switch self { case .account: - return String(localized: .settingStateRouteAccount) + return .settingStateRouteAccount case .general: - return String(localized: .settingStateRouteGeneral) + return .settingStateRouteGeneral case .appearance: - return String(localized: .settingStateRouteAppearance) + return .settingStateRouteAppearance case .download: - return String(localized: .settingStateRouteDownload) + return .settingStateRouteDownload case .reading: - return String(localized: .settingStateRouteReading) + return .settingStateRouteReading case .laboratory: - return String(localized: .settingStateRouteLaboratory) + return .settingStateRouteLaboratory case .about: - return String(localized: .settingStateRouteAbout) + return .settingStateRouteAbout } } var symbol: SFSymbol { From 46f62e502b51cbaf88a6ab8fda92d6c1d6744f86 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 01:58:56 +0800 Subject: [PATCH 501/614] Convert DetailFeature view labels to resources --- .../DetailReducer+Download.swift | 30 +++++++++---------- .../DetailView+HeaderSection.swift | 4 +-- .../Sources/DetailFeature/DetailView.swift | 2 +- .../FolderManager/FolderManagerView.swift | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift index 730dc5e5f..e6e7c74d9 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift @@ -28,16 +28,16 @@ extension DetailReducer { case .retryDownloadButtonTapped(let mode): state.alert = AppAlertState { - TextState(Self.retryDownloadTitle(for: mode)) + TextState(localized: Self.retryDownloadTitle(for: mode)) } actions: { ButtonState(action: .confirmRetryDownload(mode)) { - TextState(Self.retryDownloadConfirmTitle(for: mode)) + TextState(localized: Self.retryDownloadConfirmTitle(for: mode)) } ButtonState(role: .cancel) { TextState(localized: .RLocalizable.cancel) } } message: { - TextState(Self.retryDownloadMessage(for: mode)) + TextState(localized: Self.retryDownloadMessage(for: mode)) } return .none @@ -302,36 +302,36 @@ extension DetailReducer { } } - private static func retryDownloadTitle(for mode: DownloadStartMode) -> String { + private static func retryDownloadTitle(for mode: DownloadStartMode) -> LocalizedStringResource { switch mode { case .repair: - return String(localized: .repairDownload) + return .repairDownload case .update: - return String(localized: .updateDownload) + return .updateDownload case .initial, .redownload: - return String(localized: .redownloadGallery) + return .redownloadGallery } } - private static func retryDownloadMessage(for mode: DownloadStartMode) -> String { + private static func retryDownloadMessage(for mode: DownloadStartMode) -> LocalizedStringResource { switch mode { case .repair: - return String(localized: .repairDownloadDescription) + return .repairDownloadDescription case .update: - return String(localized: .updateDownloadDescription) + return .updateDownloadDescription case .initial, .redownload: - return String(localized: .redownloadGalleryDescription) + return .redownloadGalleryDescription } } - private static func retryDownloadConfirmTitle(for mode: DownloadStartMode) -> String { + private static func retryDownloadConfirmTitle(for mode: DownloadStartMode) -> LocalizedStringResource { switch mode { case .repair: - return String(localized: .repair) + return .repair case .update: - return String(localized: .RLocalizable.update) + return .RLocalizable.update case .initial, .redownload: - return String(localized: .redownload) + return .redownload } } } diff --git a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift index 36c51ae09..b3e8ff080 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift @@ -88,7 +88,7 @@ struct HeaderSection: View { Section { Button(action: manageFoldersAction) { Label( - String(localized: .RLocalizable.manageFolders), + .RLocalizable.manageFolders, systemSymbol: .folderBadgeGearshape ) } @@ -98,7 +98,7 @@ struct HeaderSection: View { if downloadFolders.isEmpty { Button(action: createDefaultFolderAction) { Label( - String(localized: .createDefaultFolder), + .createDefaultFolder, systemSymbol: .folderBadgePlus ) } diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index b571e2623..a523332ce 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -292,7 +292,7 @@ private extension DetailView { @ViewBuilder private func offlineFallbackNotice(error: AppError) -> some View { VStack(alignment: .leading, spacing: 10) { Label( - String(localized: .savedDetails), + .savedDetails, systemSymbol: .wifiExclamationmark ) .font(.subheadline.weight(.semibold)) diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift index 8aa4159c4..7f1ca49bd 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift @@ -101,7 +101,7 @@ public struct FolderManagerView: View { private func editingTextField(_ field: FolderManagerReducer.EditingField) -> some View { TextField( - String(localized: .folderName), + .folderName, text: $store.editingFolderName ) .disableAutocorrection(true) From e26b0853e8b76a170be481b069efdb6f4b9ffbee Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 02:01:58 +0800 Subject: [PATCH 502/614] Convert DownloadsFeature view labels to resources --- .../DownloadsFeature/DownloadsReducer.swift | 6 ++--- .../DownloadsView+Subviews.swift | 14 +++++------ .../DownloadsFeature/DownloadsView.swift | 24 +++++++++---------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index d1c1792d6..0771b5105 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -150,9 +150,9 @@ public struct DownloadsReducer: Sendable { } } message: { TextState( - download.canTogglePause - ? String(localized: .deleteActiveDownload) - : String(localized: .RLocalizable.deleteDownloadedGallery) + localized: download.canTogglePause + ? .deleteActiveDownload + : .RLocalizable.deleteDownloadedGallery ) } return .none diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index 69eac7ca4..7dfeb5a16 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -88,7 +88,7 @@ struct DownloadInspectorView: View { store.send(.retryPages(inspection.failedPageIndices)) } label: { Label( - String(localized: .retryFailedPages), + .retryFailedPages, systemSymbol: .arrowClockwise ) .disabledActionForegroundStyle(isRetryFailedPagesDisabled) @@ -131,10 +131,10 @@ private struct DownloadInspectorValidationActionLabel: View { let isDisabled: Bool let reduceMotion: Bool - private var title: String { + private var title: LocalizedStringResource { isValidating - ? String(localized: .validatingImageData) - : String(localized: .validateImageData) + ? .validatingImageData + : .validateImageData } private var progressAnimation: Animation? { @@ -274,10 +274,10 @@ private extension DownloadPageStatus { } private extension DownloadedGallery { - var inspectorPauseResumeTitle: String { + var inspectorPauseResumeTitle: LocalizedStringResource { displayStatus == .inactive - ? String(localized: .resume) - : String(localized: .pause) + ? .resume + : .pause } var inspectorPauseResumeSymbol: SFSymbol { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index dba336266..155f312b4 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -141,7 +141,7 @@ private extension DownloadsView { store.send(.inspectorButtonTapped(download.gid)) } label: { Label( - String(localized: .inspectPages), + .inspectPages, systemSymbol: .listBulletRectanglePortrait ) } @@ -152,7 +152,7 @@ private extension DownloadsView { store.send(.moveButtonTapped(download)) } label: { Label( - String(localized: .move), + .move, systemSymbol: .folder ) } @@ -165,7 +165,7 @@ private extension DownloadsView { store.send(.updateDownload(download.gid)) } label: { Label( - String(localized: .RLocalizable.update), + .RLocalizable.update, systemSymbol: .arrowTrianglehead2ClockwiseRotate90 ) } @@ -178,8 +178,8 @@ private extension DownloadsView { } label: { Label( download.displayStatus == .inactive - ? String(localized: .resume) - : String(localized: .pause), + ? .resume + : .pause, systemSymbol: download.displayStatus == .inactive ? .playFill : .pauseFill @@ -205,7 +205,7 @@ private extension DownloadsView { store.send(.galleryTapped(download.gid)) } label: { Label( - String(localized: .RLocalizable.detail), + .RLocalizable.detail, systemSymbol: .infoCircle ) } @@ -214,7 +214,7 @@ private extension DownloadsView { store.send(.inspectorButtonTapped(download.gid)) } label: { Label( - String(localized: .inspectPages), + .inspectPages, systemSymbol: .listBulletRectanglePortrait ) } @@ -228,7 +228,7 @@ private extension DownloadsView { } } label: { Label( - String(localized: .moveToFolder), + .moveToFolder, systemSymbol: .folder ) } @@ -239,7 +239,7 @@ private extension DownloadsView { store.send(.updateDownload(download.gid)) } label: { Label( - String(localized: .RLocalizable.update), + .RLocalizable.update, systemSymbol: .arrowTrianglehead2ClockwiseRotate90 ) } @@ -251,8 +251,8 @@ private extension DownloadsView { } label: { Label( download.displayStatus == .inactive - ? String(localized: .resume) - : String(localized: .pause), + ? .resume + : .pause, systemSymbol: download.displayStatus == .inactive ? .playFill : .pauseFill @@ -304,7 +304,7 @@ private extension DownloadsView { store.send(.folderManagerButtonTapped) } label: { Label( - String(localized: .RLocalizable.manageFolders), + .RLocalizable.manageFolders, systemSymbol: .folderBadgeGearshape ) } From ab6b3703be12c9c530082692b835739e9b284fac Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 02:05:19 +0800 Subject: [PATCH 503/614] Convert misc feature view labels to resources --- .../AppFeature/View/TabBar/TabBarView.swift | 12 +++++----- .../DateSeekFeature/DateSeekPickerView.swift | 2 +- .../Sources/FiltersFeature/FiltersView.swift | 2 +- .../DownloadBadgeLabel.swift | 22 +++++++++---------- .../ReadingViewComponents.swift | 8 +++---- .../ReadingSettingView.swift | 2 +- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 4da0347ee..dd59e9373 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -134,18 +134,18 @@ struct TabBarView: View { // MARK: TabType extension TabBarItemType { - var title: String { + var title: LocalizedStringResource { switch self { case .home: - return String(localized: .RLocalizable.home) + return .RLocalizable.home case .favorites: - return String(localized: .RLocalizable.favorites) + return .RLocalizable.favorites case .search: - return String(localized: .RLocalizable.search) + return .RLocalizable.search case .downloads: - return String(localized: .RLocalizable.downloads) + return .RLocalizable.downloads case .setting: - return String(localized: .RLocalizable.setting) + return .RLocalizable.setting } } var symbol: SFSymbol { diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift index b61986a98..0e6f76807 100644 --- a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift @@ -33,7 +33,7 @@ public struct DateSeekPickerView: View { Form { Section { DatePicker( - String(localized: .date), + .date, selection: $selectedDate, in: navigation.dateRange, displayedComponents: .date diff --git a/AppPackage/Sources/FiltersFeature/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift index 5e0a03663..e139b020f 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -112,7 +112,7 @@ private struct AdvancedSection: View { Toggle(.searchGalleryDescription, isOn: $filter.galleryDesc) Toggle(.searchTorrentFilenames, isOn: $filter.torrentFilenames) Toggle( - String(localized: .onlyShowGalleriesWithTorrents), + .onlyShowGalleriesWithTorrents, isOn: $filter.onlyWithTorrents ) Toggle(.searchLowPowerTags, isOn: $filter.lowPowerTags) diff --git a/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift b/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift index 51bbfc4ea..926102ed2 100644 --- a/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift +++ b/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift @@ -26,31 +26,31 @@ public struct DownloadBadgeLabel: View { .accessibilityLabel(accessibilityText) } - private var progressText: String { - String(localized: .downloadBadgeProgress( + private var progressText: LocalizedStringResource { + .downloadBadgeProgress( completed: badge.progress.displayCompletedPageCount, total: badge.progress.displayPageCount - )) + ) } - private var statusText: String { + private var statusText: LocalizedStringResource { switch badge.status { case .queued: - return String(localized: .downloadBadgeQueued) + return .downloadBadgeQueued case .active: - return String(localized: .downloadBadgeDownloading) + return .downloadBadgeDownloading case .inactive: - return String(localized: .downloadBadgePaused) + return .downloadBadgePaused case .completed: - return String(localized: .downloadBadgeDownloaded) + return .downloadBadgeDownloaded case .updateAvailable: - return String(localized: .downloadBadgeUpdateAvailable) + return .downloadBadgeUpdateAvailable case .error: - return String(localized: .downloadBadgeNeedsAttention) + return .downloadBadgeNeedsAttention } } private var accessibilityText: String { - [statusText, progressText].joined(separator: " ") + [String(localized: statusText), String(localized: progressText)].joined(separator: " ") } } diff --git a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift index 7a4c86b8a..6100035cb 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift @@ -32,12 +32,12 @@ enum AutoPlayPolicy: Int, CaseIterable, Identifiable { } extension AutoPlayPolicy { - var value: String { + var value: LocalizedStringResource { switch self { case .off: - return String(localized: .autoPlayPolicyOff) + return .autoPlayPolicyOff default: - return String(localized: .RLocalizable.seconds(count: rawValue)) + return .RLocalizable.seconds(count: rawValue) } } } @@ -163,7 +163,7 @@ struct HorizontalImageStack: View { saveImageAction(originalImageURL) } label: { Label( - String(localized: .saveOriginal), + .saveOriginal, systemSymbol: .squareAndArrowDownOnSquare ) } diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index c9074695b..685d2a0ac 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -45,7 +45,7 @@ public struct ReadingSettingView: View { } Section(.readingAppearance) { Picker( - String(localized: .separatorHeight), + .separatorHeight, selection: $contentDividerHeight ) { ForEach(Array(stride(from: 0, through: 20, by: 5)), id: \.self) { value in From dc50769fcf06ac8f11af237350e0f28c5e5b4963 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 02:08:21 +0800 Subject: [PATCH 504/614] Type LinkRow text as LocalizedStringResource --- .../SettingFeature/Components/AboutView.swift | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index 6fda74b77..a34199a3e 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -60,23 +60,23 @@ struct AboutView: View { private let contacts: [Info] = {[ .init( urlString: String(localized: .Constant.contactWebsite), - text: String(localized: .website) + text: .website ), .init( urlString: String(localized: .Constant.contactGitHub), - text: String(localized: .Constant.contactGitHubLink) + text: .Constant.contactGitHubLink ), .init( urlString: String(localized: .Constant.contactDiscord), - text: String(localized: .Constant.contactDiscordLink) + text: .Constant.contactDiscordLink ), .init( urlString: String(localized: .Constant.contactTelegram), - text: String(localized: .Constant.contactTelegramLink) + text: .Constant.contactTelegramLink ), .init( urlString: String(localized: .Constant.contactAltStoreLink), - text: String(localized: .altStoreSource) + text: .altStoreSource ) ]}() @@ -84,19 +84,19 @@ struct AboutView: View { private let specialThanks: [Info] = {[ .init( urlString: String(localized: .Constant.specialThanksTaylorlannisterLink), - text: String(localized: .Constant.specialThanksTaylorlannister) + text: .Constant.specialThanksTaylorlannister ), .init( urlString: String(localized: .Constant.specialThanksLuminescentYqLink), - text: String(localized: .Constant.specialThanksLuminescentYq) + text: .Constant.specialThanksLuminescentYq ), .init( urlString: String(localized: .Constant.specialThanksCaxerxLink), - text: String(localized: .Constant.specialThanksCaxerx) + text: .Constant.specialThanksCaxerx ), .init( urlString: String(localized: .Constant.specialThanksHonjowLink), - text: String(localized: .Constant.specialThanksHonjow) + text: .Constant.specialThanksHonjow ) ]}() @@ -104,23 +104,23 @@ struct AboutView: View { private let codeLevelContributors: [Info] = {[ .init( urlString: String(localized: .Constant.codeLevelContributorVvbbnn00Link), - text: String(localized: .Constant.codeLevelContributorVvbbnn00) + text: .Constant.codeLevelContributorVvbbnn00 ), .init( urlString: String(localized: .Constant.codeLevelContributorKaed3MiLink), - text: String(localized: .Constant.codeLevelContributorKaed3Mi) + text: .Constant.codeLevelContributorKaed3Mi ), .init( urlString: String(localized: .Constant.codeLevelContributorAalberrtyLink), - text: String(localized: .Constant.codeLevelContributorAalberrty) + text: .Constant.codeLevelContributorAalberrty ), .init( urlString: String(localized: .Constant.codeLevelContributorJimmyPrimeLink), - text: String(localized: .Constant.codeLevelContributorJimmyPrime) + text: .Constant.codeLevelContributorJimmyPrime ), .init( urlString: String(localized: .Constant.codeLevelContributorXioxinLink), - text: String(localized: .Constant.codeLevelContributorXioxin) + text: .Constant.codeLevelContributorXioxin ) ]}() @@ -128,19 +128,19 @@ struct AboutView: View { private let translationContributors: [Info] = {[ .init( urlString: String(localized: .Constant.translationContributorNebulosaCatLink), - text: String(localized: .Constant.translationContributorNebulosaCat) + text: .Constant.translationContributorNebulosaCat ), .init( urlString: String(localized: .Constant.translationContributorPaulHaeusslerLink), - text: String(localized: .Constant.translationContributorPaulHaeussler) + text: .Constant.translationContributorPaulHaeussler ), .init( urlString: String(localized: .Constant.translationContributorCaxerxLink), - text: String(localized: .Constant.translationContributorCaxerx) + text: .Constant.translationContributorCaxerx ), .init( urlString: String(localized: .Constant.translationContributorNeKoOuOLink), - text: String(localized: .Constant.translationContributorNeKoOuO) + text: .Constant.translationContributorNeKoOuO ) ]}() @@ -148,55 +148,55 @@ struct AboutView: View { private let acknowledgements: [Info] = {[ .init( urlString: String(localized: .Constant.acknowledgementKannaLink), - text: String(localized: .Constant.acknowledgementKanna) + text: .Constant.acknowledgementKanna ), .init( urlString: String(localized: .Constant.acknowledgementColorfulLink), - text: String(localized: .Constant.acknowledgementColorful) + text: .Constant.acknowledgementColorful ), .init( urlString: String(localized: .Constant.acknowledgementSwiftGenLink), - text: String(localized: .Constant.acknowledgementSwiftGen) + text: .Constant.acknowledgementSwiftGen ), .init( urlString: String(localized: .Constant.acknowledgementKingfisherLink), - text: String(localized: .Constant.acknowledgementKingfisher) + text: .Constant.acknowledgementKingfisher ), .init( urlString: String(localized: .Constant.acknowledgementSwiftUIPagerLink), - text: String(localized: .Constant.acknowledgementSwiftUIPager) + text: .Constant.acknowledgementSwiftUIPager ), .init( urlString: String(localized: .Constant.acknowledgementWaterfallGridLink), - text: String(localized: .Constant.acknowledgementWaterfallGrid) + text: .Constant.acknowledgementWaterfallGrid ), .init( urlString: String(localized: .Constant.acknowledgementSwiftyOpenCCLink), - text: String(localized: .Constant.acknowledgementSwiftyOpenCC) + text: .Constant.acknowledgementSwiftyOpenCC ), .init( urlString: String(localized: .Constant.acknowledgementUiImageColorsLink), - text: String(localized: .Constant.acknowledgementUiImageColors) + text: .Constant.acknowledgementUiImageColors ), .init( urlString: String(localized: .Constant.acknowledgementSfSafeSymbolsLink), - text: String(localized: .Constant.acknowledgementSfSafeSymbols) + text: .Constant.acknowledgementSfSafeSymbols ), .init( urlString: String(localized: .Constant.acknowledgementSystemNotificationLink), - text: String(localized: .Constant.acknowledgementSystemNotification) + text: .Constant.acknowledgementSystemNotification ), .init( urlString: String(localized: .Constant.acknowledgementSwiftCommonMarkLink), - text: String(localized: .Constant.acknowledgementSwiftCommonMark) + text: .Constant.acknowledgementSwiftCommonMark ), .init( urlString: String(localized: .Constant.acknowledgementEhTagTranslationDatabaseLink), - text: String(localized: .Constant.acknowledgementEhTagTranslationDatabase) + text: .Constant.acknowledgementEhTagTranslationDatabase ), .init( urlString: String(localized: .Constant.acknowledgementTcaLink), - text: String(localized: .Constant.acknowledgementTca) + text: .Constant.acknowledgementTca ) ]}() } @@ -204,9 +204,9 @@ struct AboutView: View { // MARK: LinkRow private struct LinkRow: View { private let urlString: String - private let text: String + private let text: LocalizedStringResource - init(urlString: String, text: String) { + init(urlString: String, text: LocalizedStringResource) { self.urlString = urlString self.text = text } @@ -231,7 +231,7 @@ private struct Info: Identifiable { var id: String { urlString } let urlString: String - let text: String + let text: LocalizedStringResource } struct EhPandaView_Previews: PreviewProvider { From ea8beb1697e01b3d33ac65e2badc037213c4b260 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 02:09:44 +0800 Subject: [PATCH 505/614] Type form-field component labels as resources --- AppPackage/Sources/AppComponents/SettingTextField.swift | 4 ++-- .../Sources/QuickSearchFeature/QuickSearchView.swift | 8 ++++---- .../ReadingSettingFeature/ReadingSettingView.swift | 8 ++++---- .../EhSetting/EhSettingView+Sections2.swift | 2 +- AppPackage/Sources/SettingFeature/Login/LoginView.swift | 8 ++++---- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/AppPackage/Sources/AppComponents/SettingTextField.swift b/AppPackage/Sources/AppComponents/SettingTextField.swift index f98b528fa..7cc08a0c2 100644 --- a/AppPackage/Sources/AppComponents/SettingTextField.swift +++ b/AppPackage/Sources/AppComponents/SettingTextField.swift @@ -5,7 +5,7 @@ public struct SettingTextField: View { @Binding private var text: String private let title: LocalizedStringResource - private let promptText: String? + private let promptText: LocalizedStringResource? private let width: CGFloat? private let alignment: TextAlignment private let background: Color? @@ -21,7 +21,7 @@ public struct SettingTextField: View { public init( text: Binding, title: LocalizedStringResource, - promptText: String? = nil, width: CGFloat? = 50, + promptText: LocalizedStringResource? = nil, width: CGFloat? = 50, alignment: TextAlignment = .center, background: Color? = nil ) { _text = text diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index 83d0974d6..65a39ee01 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -112,8 +112,8 @@ public struct QuickSearchView: View { @ViewBuilder private func editWordView(for kind: QuickSearchReducer.WordEditKind) -> some View { EditWordView( title: kind == .new - ? String(localized: .newWord) - : String(localized: .editWord), + ? .newWord + : .editWord, word: $store.editingWord, focusedField: $focusedField, submitAction: onTextFieldSubmitted, @@ -127,14 +127,14 @@ public struct QuickSearchView: View { extension QuickSearchView { // MARK: EditWordView struct EditWordView: View { - private let title: String + private let title: LocalizedStringResource @Binding private var word: QuickSearchWord private let focusedField: FocusState.Binding private let submitAction: () -> Void private let confirmAction: () -> Void init( - title: String, word: Binding, + title: LocalizedStringResource, word: Binding, focusedField: FocusState.Binding, submitAction: @escaping () -> Void, confirmAction: @escaping () -> Void ) { diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index 685d2a0ac..c086dec81 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -56,12 +56,12 @@ public struct ReadingSettingView: View { .disabled(readingDirection != .vertical) ScaleFactorRow( scaleFactor: $maximumScaleFactor, - labelContent: String(localized: .maximumScaleFactor), + labelContent: .maximumScaleFactor, minFactor: 1.5, maxFactor: 10 ) ScaleFactorRow( scaleFactor: $doubleTapScaleFactor, - labelContent: String(localized: .doubleTapScaleFactor), + labelContent: .doubleTapScaleFactor, minFactor: 1.5, maxFactor: 5 ) } @@ -72,12 +72,12 @@ public struct ReadingSettingView: View { private struct ScaleFactorRow: View { @Binding private var scaleFactor: Double - private let labelContent: String + private let labelContent: LocalizedStringResource private let minFactor: Double private let maxFactor: Double init( - scaleFactor: Binding, labelContent: String, + scaleFactor: Binding, labelContent: LocalizedStringResource, minFactor: Double, maxFactor: Double ) { _scaleFactor = scaleFactor diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift index a4d74d000..59289525d 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift @@ -88,7 +88,7 @@ struct RatingsSection: View { SettingTextField( text: $ehSetting.ratingsColor, title: .ratingsColor, - promptText: String(localized: .ratingsColorPrompt), + promptText: .ratingsColorPrompt, width: 80 ) .focused($isFocused) diff --git a/AppPackage/Sources/SettingFeature/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift index 9f40038c5..de82d4290 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginView.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginView.swift @@ -34,13 +34,13 @@ struct LoginView: View { LoginTextField( focusedField: $focusedField, text: $store.username, - description: String(localized: .username), + description: .username, isPassword: false ) LoginTextField( focusedField: $focusedField, text: $store.password, - description: String(localized: .password), + description: .password, isPassword: true ) } @@ -107,12 +107,12 @@ private struct LoginTextField: View { @Environment(\.colorScheme) private var colorScheme private let focusedField: FocusState.Binding @Binding private var text: String - private let description: String + private let description: LocalizedStringResource private let isPassword: Bool init( focusedField: FocusState.Binding, - text: Binding, description: String, isPassword: Bool + text: Binding, description: LocalizedStringResource, isPassword: Bool ) { self.focusedField = focusedField _text = text From 574327e208e2f3ea1ea744b4968cdc09fbda8435 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 02:11:01 +0800 Subject: [PATCH 506/614] Add AlertView resource message overload --- AppPackage/Sources/AppComponents/AlertView.swift | 10 +++++++++- .../FolderManager/FolderManagerView.swift | 2 +- .../Sources/DownloadsFeature/DownloadsView.swift | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/AppPackage/Sources/AppComponents/AlertView.swift b/AppPackage/Sources/AppComponents/AlertView.swift index 14a7ae18e..2bcd35c02 100644 --- a/AppPackage/Sources/AppComponents/AlertView.swift +++ b/AppPackage/Sources/AppComponents/AlertView.swift @@ -54,7 +54,7 @@ public struct NotLoginView: View { public var body: some View { AlertView( symbol: .personCropCircleBadgeQuestionmarkFill, - message: String(localized: .needLogin) + message: .needLogin ) { AlertViewButton(title: .RLocalizable.login, action: action) } @@ -97,6 +97,14 @@ public struct AlertView: View { self.actions = actions() } + // Resource overload for static localized messages; the `String` init above remains for + // dynamic messages that are already resolved (e.g. `AppError.alertText`). + public init( + symbol: SFSymbol, message: LocalizedStringResource, @ViewBuilder actions: () -> Content + ) { + self.init(symbol: symbol, message: String(localized: message), actions: actions) + } + public var body: some View { VStack { Image(systemSymbol: symbol).font(.system(size: 50)).padding(.bottom, 15) diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift index 7f1ca49bd..226d88c59 100644 --- a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift @@ -71,7 +71,7 @@ public struct FolderManagerView: View { if store.folders.isEmpty && store.editingField != .newFolder { AlertView( symbol: .folder, - message: String(localized: .emptyFolders) + message: .emptyFolders ) { EmptyView() } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index 155f312b4..98aa44c47 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -271,14 +271,14 @@ private extension DownloadsView { if store.downloads.isEmpty { AlertView( symbol: .squareAndArrowDown, - message: String(localized: .emptyDownloads) + message: .emptyDownloads ) { EmptyView() } } else { AlertView( symbol: .line3HorizontalDecreaseCircle, - message: String(localized: .noMatchingFilters) + message: .noMatchingFilters ) { AlertViewButton(title: .clearFilters) { store.keyword = "" From 027f0b1caeba7c4d4723260d4b23a3b158ab0554 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 02:12:12 +0800 Subject: [PATCH 507/614] Add NewDawnView resource text overload --- AppPackage/Sources/AppComponents/NewDawnView.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/AppPackage/Sources/AppComponents/NewDawnView.swift b/AppPackage/Sources/AppComponents/NewDawnView.swift index 6a42b0b11..debf890b7 100644 --- a/AppPackage/Sources/AppComponents/NewDawnView.swift +++ b/AppPackage/Sources/AppComponents/NewDawnView.swift @@ -47,8 +47,8 @@ public struct NewDawnView: View { } VStack(spacing: 50) { VStack(spacing: 10) { - TextView(text: String(localized: .first), font: .largeTitle) - TextView(text: String(localized: .second), font: .title2) + TextView(text: .first, font: .largeTitle) + TextView(text: .second, font: .title2) } TextView(text: greeting.gainContent ?? "", font: .title3, fontWeight: .bold) } @@ -76,6 +76,12 @@ private struct TextView: View { self.fontWeight = fontWeight } + // Resource overload for the static greeting lines; the `String` init above remains for the + // dynamic gain content. + init(text: LocalizedStringResource, font: Font, fontWeight: Font.Weight = .bold) { + self.init(text: String(localized: text), font: font, fontWeight: fontWeight) + } + var body: some View { HStack { Text(text) From 56723efe157bd2084776fe06356f371fe7c64dbb Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 02:25:17 +0800 Subject: [PATCH 508/614] Type missingFiles message as resource --- .../DownloadClient+PersistenceNormalize.swift | 2 +- .../DownloadClient/DownloadClient+PublicAPI.swift | 2 +- .../DownloadClient/DownloadStore+Operations.swift | 12 +++++------- .../Sources/DownloadClient/DownloadStore.swift | 2 +- .../DownloadCoordinatorStorageTests.swift | 2 +- .../DownloadFilterAndBadgeTests.swift | 4 +++- .../DownloadStoreHashTests.swift | 4 +--- .../DownloadsFeatureTests/DownloadStoreTests.swift | 10 ++++------ 8 files changed, 17 insertions(+), 21 deletions(-) diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceNormalize.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceNormalize.swift index 58167319d..9853d5e17 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceNormalize.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceNormalize.swift @@ -91,7 +91,7 @@ extension DownloadCoordinator { case .missingFiles(let message): validationErrors[download.gid] = DownloadFailure( code: .fileOperationFailed, - message: message + message: String(localized: message) ) } await notifyObservers() diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift index 4e51e5a04..ffe1d2fdd 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift @@ -222,7 +222,7 @@ extension DownloadCoordinator { case .valid: break case .missingFiles(let message): - return .failure(.fileOperationFailed(message)) + return .failure(.fileOperationFailed(String(localized: message))) } do { let manifest = try storage.readManifest(folderURL: download.folderURL) diff --git a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift index e38509ca7..c62f734f9 100644 --- a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift @@ -201,14 +201,14 @@ extension DownloadStore { ) -> DownloadValidationState { let folderURL = download.folderURL guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { - return .missingFiles(String(localized: .downloadStoreDownloadFolderMissing)) + return .missingFiles(.downloadStoreDownloadFolderMissing) } let manifestURL = download.manifestURL guard fileManager.operate({ $0.fileExists(atPath: manifestURL.path) }) else { - return .missingFiles(String(localized: .downloadStoreManifestMissing)) + return .missingFiles(.downloadStoreManifestMissing) } guard let manifest = try? readManifest(folderURL: folderURL) else { - return .missingFiles(String(localized: .RLocalizable.downloadStoreManifestCorrupted)) + return .missingFiles(.RLocalizable.downloadStoreManifestCorrupted) } if let pageValidationFailure = validatePages( folderURL: folderURL, @@ -290,13 +290,11 @@ extension DownloadStore { let pageURL = validatedChildURL(root: folderURL, relativePath: relativePath), sanitizeAssetFileIfNeeded(at: pageURL) else { - return .missingFiles(String(localized: .RLocalizable.downloadStorePageMissing(page: index))) + return .missingFiles(.RLocalizable.downloadStorePageMissing(page: index)) } if verifiesContentHash, (try? fileHash(at: pageURL)) != expectedHash { - return .missingFiles( - String(localized: .RLocalizable.downloadStorePageImageCorrupted(page: index)) - ) + return .missingFiles(.RLocalizable.downloadStorePageImageCorrupted(page: index)) } return nil diff --git a/AppPackage/Sources/DownloadClient/DownloadStore.swift b/AppPackage/Sources/DownloadClient/DownloadStore.swift index ca6062288..f0dc6cf24 100644 --- a/AppPackage/Sources/DownloadClient/DownloadStore.swift +++ b/AppPackage/Sources/DownloadClient/DownloadStore.swift @@ -6,7 +6,7 @@ import AppTools public enum DownloadValidationState: Equatable, Sendable { case valid - case missingFiles(String) + case missingFiles(LocalizedStringResource) } public struct DownloadFolderRecord: Equatable, Sendable { diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift index f6dc1a89a..cfc511900 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift @@ -404,7 +404,7 @@ struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { let validation = await manager.validateImageData(gid: "440") - #expect(validation == .missingFiles(String(localized: .RLocalizable.downloadStorePageMissing(page: 1)))) + #expect(validation == .missingFiles(.RLocalizable.downloadStorePageMissing(page: 1))) let download = try #require(await manager.fetchDownload(gid: "440")) #expect(download.displayStatus == .error) #expect(download.displayStatus == .error) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadFilterAndBadgeTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFilterAndBadgeTests.swift index 5eac3a8ba..33a600235 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadFilterAndBadgeTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadFilterAndBadgeTests.swift @@ -52,7 +52,9 @@ struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { lastError: nil ) - #expect(download.searchableText == ["Solo Title", AppModels.Category.doujinshi.value].joined(separator: " ")) + #expect(download.searchableText == [ + "Solo Title", String(localized: AppModels.Category.doujinshi.value) + ].joined(separator: " ")) #expect(!download.searchableText.contains(" ")) #expect(download.searchableText == download.searchableText.trimmingCharacters(in: .whitespaces)) } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift index 62bb17fbc..39b17cf2b 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift @@ -23,9 +23,7 @@ struct DownloadStoreHashTests { #expect( storage.validate(download: download, verifiesContentHashes: true) - == .missingFiles( - String(localized: .RLocalizable.downloadStorePageImageCorrupted(page: 2)) - ) + == .missingFiles(.RLocalizable.downloadStorePageImageCorrupted(page: 2)) ) } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift index 51a491acc..f39a3e30b 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift @@ -131,9 +131,8 @@ struct DownloadStoreTests { ) #expect( - storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( - String(localized: .RLocalizable.downloadStorePageMissing(page: 2)) - ) + storage.validate(download: download, verifiesContentHashes: true) + == .missingFiles(.RLocalizable.downloadStorePageMissing(page: 2)) ) } @@ -168,9 +167,8 @@ struct DownloadStoreTests { ) #expect( - storage.validate(download: download, verifiesContentHashes: true) == .missingFiles( - String(localized: .RLocalizable.downloadStorePageMissing(page: 1)) - ) + storage.validate(download: download, verifiesContentHashes: true) + == .missingFiles(.RLocalizable.downloadStorePageMissing(page: 1)) ) #expect( FileManager.default.fileExists( From 4cdf85bd936ba9e80182f43ac4ba4284ae0a1ddc Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 03:22:47 +0800 Subject: [PATCH 509/614] Embed count in download summary keys --- .../DownloadsView+Subviews.swift | 12 +- .../Resources/Localizable.xcstrings | 324 +++++++++++++++++- 2 files changed, 310 insertions(+), 26 deletions(-) diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index 7dfeb5a16..672ee0d41 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -241,21 +241,17 @@ private extension DownloadPageStatus { .failed ] - var title: String { + func summaryTitle(count: Int) -> LocalizedStringResource { switch self { case .pending: - return String(localized: .pending) + return .pending(count: count) case .downloaded: - return String(localized: .downloaded) + return .downloaded(count: count) case .failed: - return String(localized: .failed) + return .failed(count: count) } } - func summaryTitle(count: Int) -> String { - "\(title) (\(count))" - } - var symbol: SFSymbol { switch self { case .pending: .clock diff --git a/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings index c9d5f0833..198ce674f 100644 --- a/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings @@ -171,37 +171,133 @@ "en": { "stringUnit": { "state": "translated", - "value": "Downloaded" + "value": "Downloaded (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "de": { "stringUnit": { "state": "translated", - "value": "Heruntergeladen" + "value": "Heruntergeladen (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "ja": { "stringUnit": { "state": "translated", - "value": "ダウンロード済み" + "value": "ダウンロード済み (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "ko": { "stringUnit": { "state": "translated", - "value": "다운로드됨" + "value": "다운로드됨 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "已下载" + "value": "已下载 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "已下載" + "value": "已下載 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } } } @@ -253,37 +349,133 @@ "en": { "stringUnit": { "state": "translated", - "value": "Failed" + "value": "Failed (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "de": { "stringUnit": { "state": "translated", - "value": "Fehlgeschlagen" + "value": "Fehlgeschlagen (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "ja": { "stringUnit": { "state": "translated", - "value": "失敗" + "value": "失敗 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "ko": { "stringUnit": { "state": "translated", - "value": "실패" + "value": "실패 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "失败" + "value": "失败 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "失敗" + "value": "失敗 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } } } @@ -622,37 +814,133 @@ "en": { "stringUnit": { "state": "translated", - "value": "Pending" + "value": "Pending (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "de": { "stringUnit": { "state": "translated", - "value": "Ausstehend" + "value": "Ausstehend (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "ja": { "stringUnit": { "state": "translated", - "value": "待機中" + "value": "待機中 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "ko": { "stringUnit": { "state": "translated", - "value": "대기 중" + "value": "대기 중 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "等待中" + "value": "等待中 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "等待中" + "value": "等待中 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } } } From bec40f41487115c34df4fd467c91f24a5a5daed1 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 03:47:49 +0800 Subject: [PATCH 510/614] Embed count in torrents label key --- .../DetailFeature/DetailView+Navigation.swift | 6 +- .../Resources/Localizable.xcstrings | 137 ++++++++++++++++++ 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift index 0b5f7a010..fc0fb427d 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift @@ -19,10 +19,10 @@ extension DetailView { Button { store.send(.torrentsButtonTapped) } label: { - let base = String(localized: .torrents) let torrentCount = store.galleryDetail?.torrentCount ?? 0 - let baseWithCount = [base, "(\(torrentCount))"].joined(separator: " ") - Label(torrentCount > 0 ? baseWithCount : base, systemSymbol: .leaf) + let title: LocalizedStringResource = torrentCount > 0 + ? .torrentsCount(count: torrentCount) : .torrents + Label(title, systemSymbol: .leaf) } .disabled((store.galleryDetail?.torrentCount ?? 0 > 0) != true) Button { diff --git a/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings index 6019d8264..d13986872 100644 --- a/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/DetailFeature/Resources/Localizable.xcstrings @@ -3630,6 +3630,143 @@ } } }, + "torrents_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Torrents (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Torrents (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トレント (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토렌트 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "种子 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "種子 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } + }, "update_download": { "extractionState": "manual", "localizations": { From 6328cae20b89e1f2d985809bac1b80395c6783a9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 03:47:49 +0800 Subject: [PATCH 511/614] Embed run count in activity log run key --- .../AppActivityLogs/AppActivityLogsView.swift | 2 +- .../Resources/Localizable.xcstrings | 108 +++++++++++++++++- 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift index 75e5dd520..0caaaee58 100644 --- a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -170,7 +170,7 @@ private func runLabel(_ run: RunLogFile?) -> String { guard let run else { return String(localized: .appActivityLogsViewCurrent) } - let title = String(localized: .appActivityLogsViewRun("\(run.runCount)")) + let title = String(localized: .appActivityLogsViewRun(count: run.runCount)) return "\(title) (\(runTimeFormatter.string(from: run.date)))" } diff --git a/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings index 225f2b3ee..9d3feac54 100644 --- a/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings @@ -417,37 +417,133 @@ "en": { "stringUnit": { "state": "translated", - "value": "Run %@" + "value": "Run %#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "de": { "stringUnit": { "state": "translated", - "value": "Ausführung %@" + "value": "Ausführung %#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "ja": { "stringUnit": { "state": "translated", - "value": "起動 %@" + "value": "起動 %#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "ko": { "stringUnit": { "state": "translated", - "value": "실행 %@" + "value": "실행 %#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "运行 %@" + "value": "运行 %#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "運行 %@" + "value": "運行 %#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } } } From 03f5ae806cc48281d769321aefd9afaaf5f11880 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 03:47:49 +0800 Subject: [PATCH 512/614] Embed index in favorite category key --- .../Sources/AppModels/Persistent/User.swift | 2 +- .../AppModels/Resources/Localizable.xcstrings | 158 ++++++++++++++---- 2 files changed, 128 insertions(+), 32 deletions(-) diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index 71b58a176..7c56e3484 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -34,7 +34,7 @@ public struct User: Codable, Equatable, Sendable { public func getFavoriteCategory(index: Int) -> String { guard index != -1 else { return String(localized: .favoriteCategoryAll) } - let defaultCategory = String(localized: .favoriteCategoryDefault("\(index)")) + let defaultCategory = String(localized: .favoriteCategoryDefault(index: index)) let category = favoriteCategories?[index] ?? defaultCategory let isDefault = category == "Favorites \(index)" return isDefault ? defaultCategory : category diff --git a/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings b/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings index 4c71c89c8..7e4e701a8 100644 --- a/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings @@ -13004,37 +13004,133 @@ "en": { "stringUnit": { "state": "translated", - "value": "Favorites %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Favoriten %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "お気に入り %@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "즐겨찾기 %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "收藏夹 %@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "收藏匣 %@" + "value": "Favorites %#@index@" + }, + "substitutions": { + "index": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favoriten %#@index@" + }, + "substitutions": { + "index": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入り %#@index@" + }, + "substitutions": { + "index": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기 %#@index@" + }, + "substitutions": { + "index": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收藏夹 %#@index@" + }, + "substitutions": { + "index": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收藏匣 %#@index@" + }, + "substitutions": { + "index": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } } } } From 6f9cf62f5b46b63b36fdd80ace4102f1494329f5 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 16:17:58 +0800 Subject: [PATCH 513/614] Remove empty string literal usage --- .../AppModels/Resources/Localizable.xcstrings | 41 +++++++++++++++++++ .../AppModels/Support/AppActivityLog.swift | 2 +- .../Cells/GalleryDetailCell.swift | 19 +++++---- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings b/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings index 7e4e701a8..23110c818 100644 --- a/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings @@ -247,6 +247,47 @@ } } }, + "app_activity_log_level.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + } + } + }, "app_error.authentication_required": { "extractionState": "manual", "localizations": { diff --git a/AppPackage/Sources/AppModels/Support/AppActivityLog.swift b/AppPackage/Sources/AppModels/Support/AppActivityLog.swift index 2e639b70b..e8ad0c0ce 100644 --- a/AppPackage/Sources/AppModels/Support/AppActivityLog.swift +++ b/AppPackage/Sources/AppModels/Support/AppActivityLog.swift @@ -90,7 +90,7 @@ public extension OSLogEntryLog.Level { case .notice: .appActivityLogLevelNotice case .error: .appActivityLogLevelError case .fault: .appActivityLogLevelFault - @unknown default: "" + @unknown default: .appActivityLogLevelUnknown } } } diff --git a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift index fda1a7635..7a87e7f93 100644 --- a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift @@ -95,15 +95,18 @@ private struct GalleryDetailCellContent: View { .foregroundStyle(.primary) .fixedSize(horizontal: false, vertical: true) - HStack { - Text(gallery.uploader ?? "") - .frame(maxWidth: .infinity, alignment: .leading) - - Text(gallery.language?.value ?? "") + if gallery.uploader != nil || gallery.language != nil { + HStack { + gallery.uploader.map(Text.init) + + Spacer(minLength: 8) + + (gallery.language?.value).map(Text.init) + } + .foregroundStyle(.secondary) + .font(.subheadline) + .lineLimit(1) } - .foregroundStyle(.secondary) - .font(.subheadline) - .lineLimit(1) let tagContents = gallery.tagContents(maximum: setting.listTagsNumberMaximum) if setting.showsTagsInList, !tagContents.isEmpty { From 5272e6982844d0357aea9195343226aa92929355 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 16:50:48 +0800 Subject: [PATCH 514/614] Remove SwiftGen acknowledgement entry --- .../Cells/GalleryDetailCell.swift | 4 +- .../SettingFeature/Components/AboutView.swift | 4 - .../Resources/Constant.xcstrings | 84 ------------------- 3 files changed, 2 insertions(+), 90 deletions(-) diff --git a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift index 7a87e7f93..e99b9c63a 100644 --- a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift @@ -98,9 +98,9 @@ private struct GalleryDetailCellContent: View { if gallery.uploader != nil || gallery.language != nil { HStack { gallery.uploader.map(Text.init) - + Spacer(minLength: 8) - + (gallery.language?.value).map(Text.init) } .foregroundStyle(.secondary) diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift index a34199a3e..f2a04bafc 100644 --- a/AppPackage/Sources/SettingFeature/Components/AboutView.swift +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -154,10 +154,6 @@ struct AboutView: View { urlString: String(localized: .Constant.acknowledgementColorfulLink), text: .Constant.acknowledgementColorful ), - .init( - urlString: String(localized: .Constant.acknowledgementSwiftGenLink), - text: .Constant.acknowledgementSwiftGen - ), .init( urlString: String(localized: .Constant.acknowledgementKingfisherLink), text: .Constant.acknowledgementKingfisher diff --git a/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings b/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings index e38c8a6fb..02cb1109c 100644 --- a/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings +++ b/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings @@ -505,90 +505,6 @@ } } }, - "acknowledgement.swiftGen": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "SwiftGen" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "SwiftGen" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "SwiftGen" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "SwiftGen" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "SwiftGen" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "SwiftGen" - } - } - } - }, - "acknowledgement.swiftGen_link": { - "extractionState": "manual", - "shouldTranslate": false, - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "https://github.com/SwiftGen/SwiftGen" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "https://github.com/SwiftGen/SwiftGen" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "https://github.com/SwiftGen/SwiftGen" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "https://github.com/SwiftGen/SwiftGen" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "https://github.com/SwiftGen/SwiftGen" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "https://github.com/SwiftGen/SwiftGen" - } - } - } - }, "acknowledgement.swiftUIPager": { "extractionState": "manual", "shouldTranslate": false, From c6b0835672d6cd2ccd6d3e5cb8e2311cf07434e3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 18:06:52 +0800 Subject: [PATCH 515/614] Add `@Shared` appStorage keys and light models --- AppPackage/Package.swift | 3 +- .../AppModels/Persistence/AppSharedKeys.swift | 80 +++++++++++++++++++ .../Persistent/GalleryHistoryEntry.swift | 40 ++++++++++ .../Sources/AppModels/Support/Misc.swift | 11 +++ .../AppModels/Tags/TagTranslatorInfo.swift | 36 +++++++++ 5 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift create mode 100644 AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift create mode 100644 AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 9c6146559..70526e601 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -308,7 +308,8 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .module(.resources), .module(.osLogExt), - .targetDependency(.casePaths) + .targetDependency(.casePaths), + .targetDependency(.sharing) ], resources: [.process(.resources)], plugins: swiftLintPlugins diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift new file mode 100644 index 000000000..2242dd816 --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -0,0 +1,80 @@ +import Foundation +import Sharing + +// MARK: - Persisted app data (@Shared) +// +// EhPanda is a thin browsing client: it parses and delivers website content and deliberately +// keeps no database. Only light, *bounded* app data is persisted, and it is persisted whole — +// each model as a single Codable value in UserDefaults via Sharing's `appStorage` strategy. +// +// Why whole structs in app storage rather than a database or per-field keys: +// • Every value here is either capped (`galleryHistory`, `historyKeywords`, `quickSearchWords`) +// or inherently small (`setting`, `user`, the filters, `tagTranslatorInfo`), so the defaults +// domain comfortably holds it. +// • Keeping the structs intact preserves their invariants (e.g. `Setting`/`Filter` `didSet` +// cascades) and makes resets/logout a single atomic assignment. +// • Forward migration rides on each model's tolerant `init(from:)` decoder (`decodeIfPresent` +// + defaults): additive changes never invalidate an existing persisted value, and a decode +// failure falls back to the key's default — there is no store-fails-to-open failure mode. +// +// Large or derived data is intentionally *not* here: the tag-translation table is rebuilt at +// launch from a cached raw JSON file (only `tagTranslatorInfo` metadata is persisted), and web +// images keep their own caches. `appStorage` keys must not contain `.` or `@`. + +// MARK: Account & preferences + +extension SharedKey where Self == AppStorageKey.Default { + public static var setting: Self { + Self[.appStorage("setting"), default: Setting()] + } +} + +extension SharedKey where Self == AppStorageKey.Default { + public static var user: Self { + Self[.appStorage("user"), default: User()] + } +} + +// MARK: Filters + +extension SharedKey where Self == AppStorageKey.Default { + public static var searchFilter: Self { + Self[.appStorage("searchFilter"), default: Filter()] + } + public static var globalFilter: Self { + Self[.appStorage("globalFilter"), default: Filter()] + } + public static var watchedFilter: Self { + Self[.appStorage("watchedFilter"), default: Filter()] + } +} + +// MARK: Search history & presets + +extension SharedKey where Self == AppStorageKey<[String]>.Default { + public static var historyKeywords: Self { + Self[.appStorage("historyKeywords"), default: []] + } +} + +extension SharedKey where Self == AppStorageKey<[QuickSearchWord]>.Default { + public static var quickSearchWords: Self { + Self[.appStorage("quickSearchWords"), default: []] + } +} + +// MARK: Tag translations (metadata only — the table is rebuilt at launch) + +extension SharedKey where Self == AppStorageKey.Default { + public static var tagTranslatorInfo: Self { + Self[.appStorage("tagTranslatorInfo"), default: TagTranslatorInfo()] + } +} + +// MARK: Browsing history (merged recency + reading progress; capped, pruned at launch) + +extension SharedKey where Self == AppStorageKey<[GalleryHistoryEntry]>.Default { + public static var galleryHistory: Self { + Self[.appStorage("galleryHistory"), default: []] + } +} diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift new file mode 100644 index 000000000..ea2f96990 --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift @@ -0,0 +1,40 @@ +import Foundation + +/// A single browsing-history record, merging what used to be split across +/// `GalleryMO.lastOpenDate` (recency) and `GalleryStateMO.readingProgress` (resume position). +/// +/// Only the minimal identity (`gid`/`token`), the recency key and the resume page are +/// persisted — never a gallery snapshot. The History screen re-fetches display metadata +/// from the site's `gdata` API on demand, keeping persisted website content at zero. +/// +/// The manual `init(from:)` decodes every field tolerantly (`decodeIfPresent` + default) so +/// that future additive changes to this record never invalidate an existing persisted list. +public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable { + public init( + gid: String, + token: String, + lastOpenDate: Date, + readingProgress: Int = 0 + ) { + self.gid = gid + self.token = token + self.lastOpenDate = lastOpenDate + self.readingProgress = readingProgress + } + public var id: String { gid } + public var gid: String + public var token: String + public var lastOpenDate: Date + public var readingProgress: Int +} + +// MARK: Manually decode +extension GalleryHistoryEntry { + public init(from decoder: Decoder) { + let container = try? decoder.container(keyedBy: CodingKeys.self) + gid = (try? container?.decodeIfPresent(String.self, forKey: .gid)) ?? "" + token = (try? container?.decodeIfPresent(String.self, forKey: .token)) ?? "" + lastOpenDate = (try? container?.decodeIfPresent(Date.self, forKey: .lastOpenDate)) ?? .distantPast + readingProgress = (try? container?.decodeIfPresent(Int.self, forKey: .readingProgress)) ?? 0 + } +} diff --git a/AppPackage/Sources/AppModels/Support/Misc.swift b/AppPackage/Sources/AppModels/Support/Misc.swift index d75b5badb..187ea5a3b 100644 --- a/AppPackage/Sources/AppModels/Support/Misc.swift +++ b/AppPackage/Sources/AppModels/Support/Misc.swift @@ -155,6 +155,17 @@ public struct QuickSearchWord: Codable, Equatable, Identifiable, Sendable { } } +// MARK: Manually decode +extension QuickSearchWord { + // Tolerant decoding keeps an existing persisted list valid across future additive changes. + public init(from decoder: Decoder) { + let container = try? decoder.container(keyedBy: CodingKeys.self) + id = (try? container?.decodeIfPresent(UUID.self, forKey: .id)) ?? .init() + name = (try? container?.decodeIfPresent(String.self, forKey: .name)) ?? "" + content = (try? container?.decodeIfPresent(String.self, forKey: .content)) ?? "" + } +} + @dynamicMemberLookup @CasePathable public enum LoadingState: Equatable, Hashable, Sendable { case idle diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift new file mode 100644 index 000000000..93e354bf9 --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Lightweight, persisted metadata about the tag-translation database. +/// +/// The translation dictionary itself is *not* persisted — it is rebuilt in memory at launch +/// from a cached raw JSON file (see the tag-translator fetch/import flow). Only this small +/// envelope is stored, so the app can decide on launch whether a fresh download is due +/// (`updatedDate`) and which locale/customization is active without holding the full table +/// in the defaults domain. +/// +/// The manual `init(from:)` decodes tolerantly so future additive changes never invalidate +/// an existing persisted value. +public struct TagTranslatorInfo: Codable, Equatable, Sendable { + public init( + language: TranslatableLanguage? = nil, + updatedDate: Date = .distantPast, + hasCustomTranslations: Bool = false + ) { + self.language = language + self.updatedDate = updatedDate + self.hasCustomTranslations = hasCustomTranslations + } + public var language: TranslatableLanguage? + public var updatedDate: Date + public var hasCustomTranslations: Bool +} + +// MARK: Manually decode +extension TagTranslatorInfo { + public init(from decoder: Decoder) { + let container = try? decoder.container(keyedBy: CodingKeys.self) + language = (try? container?.decodeIfPresent(TranslatableLanguage.self, forKey: .language)) ?? nil + updatedDate = (try? container?.decodeIfPresent(Date.self, forKey: .updatedDate)) ?? .distantPast + hasCustomTranslations = (try? container?.decodeIfPresent(Bool.self, forKey: .hasCustomTranslations)) ?? false + } +} From 2f6c7ca7bd7ced93711091b5ed1b44ad3e52b48d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 18:32:53 +0800 Subject: [PATCH 516/614] Persist user and settings via `@Shared` appStorage --- AppPackage/Package.swift | 7 +-- .../Sources/AppModels/Persistent/User.swift | 9 ++++ .../Archives/ArchivesReducer.swift | 10 ++-- .../DetailFeature/DetailReducer+Actions.swift | 8 +++- .../DownloadClient/DownloadClient.swift | 5 +- .../SettingFeature/SettingReducer+Body.swift | 21 +++------ .../SettingReducer+Helpers.swift | 7 ++- .../SettingFeature/SettingReducer.swift | 46 +++++++++++-------- 8 files changed, 68 insertions(+), 45 deletions(-) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 70526e601..5027c71d8 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -332,7 +332,6 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.appTools), - .module(.databaseClient), .module(.libraryClient), .module(.networkingFeature), .module(.osLogExt), @@ -341,7 +340,8 @@ let targets: [PackageDescription.Target] = [ .module(.animatedImageFeature), .module(.urlClient), .targetDependency(.composableArchitecture), - .targetDependency(.kanna) + .targetDependency(.kanna), + .targetDependency(.sharing) ], resources: [.process(.resources)], plugins: swiftLintPlugins @@ -763,7 +763,8 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.commonMark), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), - .targetDependency(.sfSafeSymbols) + .targetDependency(.sfSafeSymbols), + .targetDependency(.sharing) ], resources: [.process(.resources)], plugins: swiftLintPlugins diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index 7c56e3484..733d239e2 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -28,10 +28,19 @@ public struct User: Codable, Equatable, Sendable { public var credits: String? public var galleryPoints: String? + // Not persisted: `greeting` is the daily "New Dawn" reward — ephemeral session data rather than + // durable account identity. It is omitted from `CodingKeys` below so persisting `User` (via + // `@Shared(.user)`) never writes it; it stays live in memory for the session and resets to `nil` + // on the next launch. See the greeting-fetch throttle in `SettingReducer`. public var greeting: Greeting? public var favoriteCategories: [Int: String]? + // `greeting` is intentionally absent so Codable skips it (it keeps its `nil` default on decode). + private enum CodingKeys: String, CodingKey { + case displayName, avatarURL, apikey, credits, galleryPoints, favoriteCategories + } + public func getFavoriteCategory(index: Int) -> String { guard index != -1 else { return String(localized: .favoriteCategoryAll) } let defaultCategory = String(localized: .favoriteCategoryDefault(index: index)) diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index 38751d177..4241a5d43 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -1,10 +1,10 @@ import Foundation import AppModels +import Sharing import Resources import ComposableArchitecture import AppTools import HapticsClient -import DatabaseClient import NetworkingFeature import CookieClient import AppComponents @@ -38,7 +38,6 @@ public struct ArchivesReducer: Sendable { case fetchDownloadResponseDone(Result) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient @@ -56,9 +55,12 @@ public struct ArchivesReducer: Sendable { return .none case .syncGalleryFunds(let galleryPoints, let credits): - return .run { _ in - await databaseClient.updateGalleryFunds(galleryPoints: galleryPoints, credits: credits) + @Shared(.user) var user + $user.withLock { + $0.galleryPoints = galleryPoints + $0.credits = credits } + return .none case .fetchArchive(let gid, let galleryURL, let archiveURL): guard state.loadingState != .loading else { return .none } diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index 4386decc1..2b67b575b 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -1,4 +1,6 @@ import Foundation +import AppModels +import Sharing import ComposableArchitecture import ReadingFeature @@ -155,7 +157,11 @@ extension DetailReducer { } case .syncGreeting(let greeting): - return .run(operation: { _ in await databaseClient.updateGreeting(greeting) }) + // Greeting is session-only (not persisted with `User`); write it to the shared + // in-memory user so the greeting-fetch throttle stays coherent across features. + @Shared(.user) var user + $user.withLock { $0.greeting = greeting } + return .none case .syncPreviewConfig(let config): return .run { [gid = state.gallery.id] _ in diff --git a/AppPackage/Sources/DownloadClient/DownloadClient.swift b/AppPackage/Sources/DownloadClient/DownloadClient.swift index 90dc65d67..843407d97 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient.swift @@ -2,7 +2,7 @@ import Foundation import AppModels import ComposableArchitecture import AppTools -import DatabaseClient +import Sharing @DependencyClient public struct DownloadClient: Sendable { @@ -73,7 +73,8 @@ extension DownloadClient { backgroundTaskStore: backgroundTaskStore, backgroundTaskClient: .live, downloadOptionsProvider: { - await DatabaseClient.live.fetchAppEnv().setting.downloadRequestOptions + @Shared(.setting) var setting + return setting.downloadRequestOptions } ) Task { diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 5112a960f..33f70d711 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -96,7 +96,6 @@ extension SettingReducer { switch action { case .binding: return .merge( - .send(.syncUser), .send(.syncSetting), .send(.syncTagTranslator) ) @@ -144,17 +143,15 @@ extension SettingReducer { return .run(operation: { _ in await applicationClient.setUserInterfaceStyle(style) }) case .syncSetting: - return .run { [state] _ in - await databaseClient.updateSetting(state.setting) - } + // Write-through to the persisted store. `setting` stays a working copy so that its + // `BindingReducer` `.onChange` side effects keep firing; this mirrors it to storage. + @Shared(.setting) var storedSetting + $storedSetting.withLock { $0 = state.setting } + return .none case .syncTagTranslator: return .run { [state] _ in await databaseClient.updateTagTranslator(state.tagTranslator) } - case .syncUser: - return .run { [state] _ in - await databaseClient.updateUser(state.user) - } case .loadUserSettings: return .run { send in @@ -209,7 +206,6 @@ extension SettingReducer { case .fetchUserInfoDone(let result): if case .success(let user) = result { state.updateUser(user) - return .send(.syncUser) } return .none @@ -220,13 +216,11 @@ extension SettingReducer { switch result { case .success(let greeting): state.setGreeting(greeting) - return .send(.syncUser) case .failure(let error): if case .parseFailed = error { var greeting = Greeting() greeting.updateTime = Date() state.setGreeting(greeting) - return .send(.syncUser) } } return .none @@ -264,7 +258,7 @@ extension SettingReducer { case .fetchFavoriteCategoriesDone(let result): if case .success(let categories) = result { - state.user.favoriteCategories = categories + state.$user.withLock { $0.favoriteCategories = categories } } return .none @@ -282,9 +276,8 @@ extension SettingReducer { ) case .path(.element(id: _, action: .account(.onLogoutConfirmButtonTapped))): - state.user = User() + state.$user.withLock { $0 = User() } return .merge( - .send(.syncUser), .run(operation: { _ in cookieClient.clearAll() }), .run(operation: { _ in await databaseClient.removeImageURLs() }), .run(operation: { _ in await libraryClient.removeAllCachedImages() }), diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift index 83b844403..bd63466f1 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift @@ -1,6 +1,7 @@ import AppTools import Foundation import AppModels +import Sharing import OSLogExt import ComposableArchitecture import NetworkingFeature @@ -11,9 +12,11 @@ extension SettingReducer { func handleLoadUserSettings( _ state: inout State, appEnv: AppEnv ) -> Effect { - state.setting = appEnv.setting + // `setting` loads from persisted storage into its working copy; `user` is `@Shared` and + // auto-loads. `tagTranslator` still comes from the database here (reworked in a later step). + @Shared(.setting) var storedSetting + state.setting = storedSetting state.tagTranslator = appEnv.tagTranslator - state.user = appEnv.user var effects: [Effect] = [ .send(.syncAppIconType), .send(.loadUserSettingsDone), diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index eb6dfc65b..182508c36 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import Sharing import ComposableArchitecture import UserDefaultsClient import ApplicationClient @@ -49,10 +50,14 @@ public struct SettingReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - // AppEnvStorage + // `setting` stays a working copy edited through `BindingReducer` (its `.onChange` handlers + // enforce cross-field invariants and fire side effects); persistence is a write-through to + // `@Shared(.setting)` via `.syncSetting`. `user` is not form-bound, so it is stored directly + // in `@Shared(.user)` (auto-loaded, mutated via `withLock`). `tagTranslator` is rebuilt at + // launch from a cached file rather than persisted whole. public var setting = Setting() public var tagTranslator = TagTranslator() - public var user = User() + @Shared(.user) public var user: User public var hasLoadedInitialSetting = false @@ -64,26 +69,30 @@ public struct SettingReducer: Sendable { mutating func setGreeting(_ greeting: Greeting) { guard let currDate = greeting.updateTime else { return } - if let prevGreeting = user.greeting, - let prevDate = prevGreeting.updateTime, - prevDate < currDate { - user.greeting = greeting - } else if user.greeting == nil { - user.greeting = greeting + $user.withLock { user in + if let prevGreeting = user.greeting, + let prevDate = prevGreeting.updateTime, + prevDate < currDate { + user.greeting = greeting + } else if user.greeting == nil { + user.greeting = greeting + } } } mutating func updateUser(_ user: User) { - if let displayName = user.displayName { - self.user.displayName = displayName - } - if let avatarURL = user.avatarURL { - self.user.avatarURL = avatarURL - } - if let galleryPoints = user.galleryPoints, - let credits = user.credits { - self.user.galleryPoints = galleryPoints - self.user.credits = credits + $user.withLock { current in + if let displayName = user.displayName { + current.displayName = displayName + } + if let avatarURL = user.avatarURL { + current.avatarURL = avatarURL + } + if let galleryPoints = user.galleryPoints, + let credits = user.credits { + current.galleryPoints = galleryPoints + current.credits = credits + } } } } @@ -99,7 +108,6 @@ public struct SettingReducer: Sendable { case syncUserInterfaceStyle case syncSetting case syncTagTranslator - case syncUser case loadUserSettings case onLoadUserSettings(AppEnv) From 198322b473f8b89f6c11f2b9c980b37e8caf967f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 18:42:29 +0800 Subject: [PATCH 517/614] Persist search filters via `@Shared` appStorage --- AppPackage/Package.swift | 10 +++--- .../DetailSearch/DetailSearchReducer.swift | 7 ++-- .../FiltersFeature/FiltersReducer.swift | 35 +++++++++---------- .../Frontpage/FrontpageReducer.swift | 7 ++-- .../HomeFeature/HomeReducer+Body.swift | 7 ++-- .../HomeFeature/Popular/PopularReducer.swift | 4 ++- .../HomeFeature/Watched/WatchedReducer.swift | 7 ++-- .../Sources/SearchFeature/SearchReducer.swift | 7 ++-- 8 files changed, 51 insertions(+), 33 deletions(-) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 5027c71d8..f032969c4 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -565,9 +565,9 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appComponents), .module(.appModels), - .module(.databaseClient), .module(.resources), - .targetDependency(.composableArchitecture) + .targetDependency(.composableArchitecture), + .targetDependency(.sharing) ], resources: [.process(.resources)], plugins: swiftLintPlugins @@ -702,7 +702,8 @@ let targets: [PackageDescription.Target] = [ .module(.tagTranslationFeature), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), - .targetDependency(.sfSafeSymbols) + .targetDependency(.sfSafeSymbols), + .targetDependency(.sharing) ], resources: [.process(.resources)], plugins: swiftLintPlugins @@ -731,7 +732,8 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols), .targetDependency(.swiftUIPager), - .targetDependency(.uiImageColors) + .targetDependency(.uiImageColors), + .targetDependency(.sharing) ], resources: [.process(.resources)], plugins: swiftLintPlugins diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index 8195620ee..00cb23010 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -1,6 +1,7 @@ import AppTools import ComposableArchitecture import AppModels +import Sharing import HapticsClient import DatabaseClient import NetworkingFeature @@ -102,7 +103,8 @@ public struct DetailSearchReducer: Sendable { } state.loadingState = .loading state.pageNumber.resetPages() - let filter = databaseClient.fetchFilterSynchronously(range: .search) + @Shared(.searchFilter) var storedFilter + let filter = storedFilter return .run { [lastKeyword = state.lastKeyword] send in let response = await SearchGalleriesRequest(keyword: lastKeyword, filter: filter).response() await send(.fetchGalleriesDone(response.map { ($0.pageNumber, $0.galleries) })) @@ -133,7 +135,8 @@ public struct DetailSearchReducer: Sendable { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .search) + @Shared(.searchFilter) var storedFilter + let filter = storedFilter return .run { [lastKeyword = state.lastKeyword] send in let response = await MoreSearchGalleriesRequest( keyword: lastKeyword, filter: filter, lastID: lastID diff --git a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift index 8b4d54d21..201321521 100644 --- a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift @@ -1,7 +1,7 @@ import ComposableArchitecture import AppModels +import Sharing import Resources -import DatabaseClient import AppComponents @Reducer @@ -37,11 +37,8 @@ public struct FiltersReducer: Sendable { case syncFilter(FilterRange) case resetFilters case fetchFilters - case fetchFiltersDone(AppEnv) } - @Dependency(\.databaseClient) private var databaseClient - public init() {} public var body: some Reducer { @@ -97,16 +94,20 @@ public struct FiltersReducer: Sendable { return .none case .syncFilter(let range): - let filter: Filter + // Write-through to persisted storage; the working copies stay the edit source so the + // `BindingReducer` `.onChange` `fixInvalidData` normalization keeps running. switch range { case .search: - filter = state.searchFilter + @Shared(.searchFilter) var storedFilter + $storedFilter.withLock { $0 = state.searchFilter } case .global: - filter = state.globalFilter + @Shared(.globalFilter) var storedFilter + $storedFilter.withLock { $0 = state.globalFilter } case .watched: - filter = state.watchedFilter + @Shared(.watchedFilter) var storedFilter + $storedFilter.withLock { $0 = state.watchedFilter } } - return .run(operation: { _ in await databaseClient.updateFilter(filter, range: range) }) + return .none case .resetFilters: switch state.filterRange { @@ -122,15 +123,13 @@ public struct FiltersReducer: Sendable { } case .fetchFilters: - return .run { send in - let appEnv = await databaseClient.fetchAppEnv() - await send(.fetchFiltersDone(appEnv)) - } - - case .fetchFiltersDone(let appEnv): - state.searchFilter = appEnv.searchFilter - state.globalFilter = appEnv.globalFilter - state.watchedFilter = appEnv.watchedFilter + // Load the persisted filters into the working copies (synchronous @Shared reads). + @Shared(.searchFilter) var searchFilter + @Shared(.globalFilter) var globalFilter + @Shared(.watchedFilter) var watchedFilter + state.searchFilter = searchFilter + state.globalFilter = globalFilter + state.watchedFilter = watchedFilter return .none } } diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index f1875c8ac..4bbfa143e 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import AppModels +import Sharing import Foundation import AppTools import HapticsClient @@ -92,7 +93,8 @@ public struct FrontpageReducer: Sendable { guard state.loadingState != .loading else { return .none } state.loadingState = .loading state.pageNumber.resetPages() - let filter = databaseClient.fetchFilterSynchronously(range: .global) + @Shared(.globalFilter) var storedFilter + let filter = storedFilter return .run { send in let response = await FrontpageGalleriesRequest(filter: filter).response() await send(.fetchGalleriesDone(response)) @@ -125,7 +127,8 @@ public struct FrontpageReducer: Sendable { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .global) + @Shared(.globalFilter) var storedFilter + let filter = storedFilter return .run { send in let response = await MoreFrontpageGalleriesRequest(filter: filter, lastID: lastID).response() await send(.fetchMoreGalleriesDone(response)) diff --git a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift index 7d9580676..56eaa5748 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift @@ -3,6 +3,7 @@ import Kingfisher import ComposableArchitecture import NetworkingFeature import AppModels +import Sharing import DetailFeature extension HomeReducer { @@ -98,7 +99,8 @@ extension HomeReducer { guard state.popularLoadingState != .loading else { return .none } state.popularLoadingState = .loading state.rawCardColors = [String: [Color]]() - let filter = databaseClient.fetchFilterSynchronously(range: .global) + @Shared(.globalFilter) var storedFilter + let filter = storedFilter return .run { send in let response = await PopularGalleriesRequest(filter: filter).response() await send(.fetchPopularGalleriesDone(response)) @@ -122,7 +124,8 @@ extension HomeReducer { case .fetchFrontpageGalleries: guard state.frontpageLoadingState != .loading else { return .none } state.frontpageLoadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .global) + @Shared(.globalFilter) var storedFilter + let filter = storedFilter return .run { send in let response = await FrontpageGalleriesRequest(filter: filter).response() await send(.fetchFrontpageGalleriesDone(response.map { ($0.pageNumber, $0.galleries) })) diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index 29b07eb6e..7b241abff 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import AppModels +import Sharing import AppTools import HapticsClient import DatabaseClient @@ -72,7 +73,8 @@ public struct PopularReducer: Sendable { case .fetchGalleries: guard state.loadingState != .loading else { return .none } state.loadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .global) + @Shared(.globalFilter) var storedFilter + let filter = storedFilter return .run { send in let response = await PopularGalleriesRequest(filter: filter).response() await send(.fetchGalleriesDone(response)) diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index ef67ec9a5..d87070dc8 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -1,6 +1,7 @@ import AppTools import ComposableArchitecture import AppModels +import Sharing import HapticsClient import DatabaseClient import NetworkingFeature @@ -110,7 +111,8 @@ public struct WatchedReducer: Sendable { } state.loadingState = .loading state.pageNumber.resetPages() - let filter = databaseClient.fetchFilterSynchronously(range: .watched) + @Shared(.watchedFilter) var storedFilter + let filter = storedFilter return .run { [keyword = state.keyword] send in let response = await WatchedGalleriesRequest(filter: filter, keyword: keyword).response() await send(.fetchGalleriesDone(response)) @@ -143,7 +145,8 @@ public struct WatchedReducer: Sendable { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .watched) + @Shared(.watchedFilter) var storedFilter + let filter = storedFilter return .run { [keyword = state.keyword] send in let response = await MoreWatchedGalleriesRequest( filter: filter, lastID: lastID, keyword: keyword diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index 6f2d8ceaa..d1f6fddc5 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -1,6 +1,7 @@ import AppTools import ComposableArchitecture import AppModels +import Sharing import Foundation import HapticsClient import DatabaseClient @@ -123,7 +124,8 @@ public struct SearchReducer: Sendable { } state.loadingState = .loading state.pageNumber.resetPages() - let filter = databaseClient.fetchFilterSynchronously(range: .search) + @Shared(.searchFilter) var storedFilter + let filter = storedFilter return .merge( historyEffect, .run { [lastKeyword = state.lastKeyword] send in @@ -159,7 +161,8 @@ public struct SearchReducer: Sendable { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .search) + @Shared(.searchFilter) var storedFilter + let filter = storedFilter return .run { [lastKeyword = state.lastKeyword] send in let response = await MoreSearchGalleriesRequest( keyword: lastKeyword, filter: filter, lastID: lastID From b06f1224a2fbc668c56aadfc65a19301b0d282f0 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 18:53:05 +0800 Subject: [PATCH 518/614] Persist search history and quick-search words --- AppPackage/Package.swift | 4 +- .../AppFeature/DataFlow/AppReducer.swift | 4 +- .../QuickSearchReducer.swift | 62 ++++++------------- .../QuickSearchFeature/QuickSearchView.swift | 15 +---- .../SearchFeature/SearchRootReducer.swift | 44 +++---------- .../SearchFeature/SearchRootView.swift | 1 - 6 files changed, 37 insertions(+), 93 deletions(-) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index f032969c4..adb9eda16 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -600,10 +600,10 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appComponents), .module(.appModels), - .module(.databaseClient), .module(.resources), .targetDependency(.composableArchitecture), - .targetDependency(.sfSafeSymbols) + .targetDependency(.sfSafeSymbols), + .targetDependency(.sharing) ], resources: [.process(.resources)], plugins: swiftLintPlugins diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 5208e956e..ae78d1707 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -208,7 +208,9 @@ struct AppReducer { if !state.searchRootState.path.isEmpty { state.searchRootState.path.removeAll() } else { - effects.append(.send(.searchRoot(.fetchDatabaseInfos))) + // Keywords/quick-search words are live via @Shared now; re-tapping the + // Search tab at its root refreshes the recently-viewed galleries instead. + effects.append(.send(.searchRoot(.fetchHistoryGalleries))) } case .downloads: if !state.downloadsState.path.isEmpty { diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift index 999b122b6..e1dbdac87 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift @@ -1,12 +1,16 @@ import SwiftUI import AppModels +import Sharing import Resources import ComposableArchitecture -import DatabaseClient import AppComponents @Reducer public struct QuickSearchReducer: Sendable { + // Quick-search words are deliberate user content, so they are never auto-evicted. Instead the + // list is bounded by a UI limit: the add button is disabled at this count (with a guard here as + // a backstop). Capping keeps the persisted `@Shared(.quickSearchWords)` value small. + public static let wordLimit = 1000 // Which flavour of the word editor is pushed onto the stack; drives `.navigationDestination(item:)`. public enum WordEditKind: Hashable, Sendable { case new @@ -22,10 +26,6 @@ public struct QuickSearchReducer: Sendable { case content } - private enum CancelID { - case fetchQuickSearchWords - } - @ObservableState public struct State: Equatable, Sendable { public var editKind: WordEditKind? @@ -38,10 +38,12 @@ public struct QuickSearchReducer: Sendable { set { listEditMode = newValue ? .active : .inactive } } - public var loadingState: LoadingState = .idle - public var quickSearchWords = [QuickSearchWord]() + @Shared(.quickSearchWords) public var quickSearchWords: [QuickSearchWord] public init() {} + + // The add button is disabled once this is true (see `wordLimit`). + public var isAtWordLimit: Bool { quickSearchWords.count >= QuickSearchReducer.wordLimit } } public enum Action: BindableAction, Equatable { @@ -51,8 +53,6 @@ public struct QuickSearchReducer: Sendable { case newWordButtonTapped case editWordButtonTapped(QuickSearchWord) - case syncQuickSearchWords - case toggleListEditing case appendWord @@ -60,13 +60,8 @@ public struct QuickSearchReducer: Sendable { case deleteWord(QuickSearchWord) case deleteWordWithOffsets(IndexSet) case moveWord(IndexSet, Int) - - case fetchQuickSearchWords - case fetchQuickSearchWordsDone([QuickSearchWord]) } - @Dependency(\.databaseClient) private var databaseClient - public init() {} public var body: some Reducer { @@ -109,52 +104,35 @@ public struct QuickSearchReducer: Sendable { case .confirmationDialog: return .none - case .syncQuickSearchWords: - return .run { [state] _ in - await databaseClient.updateQuickSearchWords(state.quickSearchWords) - } - case .toggleListEditing: state.isListEditing.toggle() return .none case .appendWord: - state.quickSearchWords.append(state.editingWord) + guard !state.isAtWordLimit else { return .none } + let word = state.editingWord + state.$quickSearchWords.withLock { $0.append(word) } state.editKind = nil - return .send(.syncQuickSearchWords) + return .none case .editWord: if let index = state.quickSearchWords.firstIndex(where: { $0.id == state.editingWord.id }) { - state.quickSearchWords[index] = state.editingWord - state.editKind = nil - return .send(.syncQuickSearchWords) + let word = state.editingWord + state.$quickSearchWords.withLock { $0[index] = word } } state.editKind = nil return .none case .deleteWord(let word): - state.quickSearchWords = state.quickSearchWords.filter({ $0 != word }) - return .send(.syncQuickSearchWords) + state.$quickSearchWords.withLock { $0.removeAll { $0 == word } } + return .none case .deleteWordWithOffsets(let offsets): - state.quickSearchWords.remove(atOffsets: offsets) - return .send(.syncQuickSearchWords) + state.$quickSearchWords.withLock { $0.remove(atOffsets: offsets) } + return .none case .moveWord(let source, let destination): - state.quickSearchWords.move(fromOffsets: source, toOffset: destination) - return .send(.syncQuickSearchWords) - - case .fetchQuickSearchWords: - state.loadingState = .loading - return .run { send in - let quickSearchWords = await databaseClient.fetchQuickSearchWords() - await send(.fetchQuickSearchWordsDone(quickSearchWords)) - } - .cancellable(id: CancelID.fetchQuickSearchWords) - - case .fetchQuickSearchWordsDone(let words): - state.loadingState = .idle - state.quickSearchWords = words + state.$quickSearchWords.withLock { $0.move(fromOffsets: source, toOffset: destination) } return .none } } diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index 65a39ee01..8ae806c97 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -57,15 +57,8 @@ public struct QuickSearchView: View { store.send(.moveWord(source, destination)) } } - LoadingView().opacity( - store.loadingState == .loading - && store.quickSearchWords.isEmpty ? 1 : 0 - ) ErrorView(error: .notFound) - .opacity( - store.loadingState != .loading - && store.quickSearchWords.isEmpty ? 1 : 0 - ) + .opacity(store.quickSearchWords.isEmpty ? 1 : 0) } .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) @@ -74,11 +67,6 @@ public struct QuickSearchView: View { .environment(\.editMode, $store.listEditMode) .animation(.default, value: store.quickSearchWords) .animation(.default, value: store.listEditMode) - .onAppear { - if store.quickSearchWords.isEmpty { - store.send(.fetchQuickSearchWords) - } - } .toolbar(content: toolbar) .navigationDestination(item: $store.editKind) { editWordView(for: $0) } .navigationTitle(.RLocalizable.quickSearch) @@ -101,6 +89,7 @@ public struct QuickSearchView: View { } label: { Image(systemSymbol: .plus) } + .disabled(store.isAtWordLimit) Button { store.send(.toggleListEditing) } label: { diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift index 521593c2b..6ab1a2a15 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import AppModels +import Sharing import AppTools import HapticsClient import DatabaseClient @@ -27,8 +28,10 @@ public struct SearchRootReducer: Sendable { public var keyword = "" public var historyGalleries = [Gallery]() - public var historyKeywords = [String]() - public var quickSearchWords = [QuickSearchWord]() + // Persisted directly in app storage; both are also read/written by pushed Search screens + // and the QuickSearch editor, which share the same keys, so changes stay live without reloads. + @Shared(.historyKeywords) public var historyKeywords: [String] + @Shared(.quickSearchWords) public var quickSearchWords: [QuickSearchWord] public init() {} @@ -55,11 +58,11 @@ public struct SearchRootReducer: Sendable { } } } - self.historyKeywords = historyKeywords + $historyKeywords.withLock { $0 = historyKeywords } } mutating func removeHistoryKeyword(_ keyword: String) { - historyKeywords = historyKeywords.filter { $0 != keyword } + $historyKeywords.withLock { $0.removeAll { $0 == keyword } } } } @@ -75,9 +78,6 @@ public struct SearchRootReducer: Sendable { case quickSearchButtonTapped case destination(PresentationAction) - case syncHistoryKeywords - case fetchDatabaseInfos - case fetchDatabaseInfosDone(AppEnv) case appendHistoryKeyword(String) case removeHistoryKeyword(String) case fetchHistoryGalleries @@ -92,14 +92,6 @@ public struct SearchRootReducer: Sendable { public var body: some Reducer { BindingReducer() - .onChange(of: \.path) { oldValue, state in - // Returning to the root refreshes history keywords / quick-search words that a - // pushed Search screen (or the QuickSearch editor) may have changed. - if !oldValue.isEmpty, state.path.isEmpty { - return .send(.fetchDatabaseInfos) - } - return .none - } Reduce { state, action in switch action { @@ -127,7 +119,7 @@ public struct SearchRootReducer: Sendable { case let .path(.element(id: _, action: .search(.delegate(.searchPerformed(keyword))))): state.appendHistoryKeywords([keyword]) - return .send(.syncHistoryKeywords) + return .none case let .path(.element(id: _, action: .gallery(.comments(.delegate(.performedCommentAction(gid)))))): guard let id = state.path.galleryDetailID(forGID: gid) else { return .none } @@ -157,29 +149,13 @@ public struct SearchRootReducer: Sendable { case .destination: return .none - case .syncHistoryKeywords: - return .run { [historyKeywords = state.historyKeywords] _ in - await databaseClient.updateHistoryKeywords(historyKeywords) - } - - case .fetchDatabaseInfos: - return .run { send in - let appEnv = await databaseClient.fetchAppEnv() - await send(.fetchDatabaseInfosDone(appEnv)) - } - - case .fetchDatabaseInfosDone(let appEnv): - state.historyKeywords = appEnv.historyKeywords - state.quickSearchWords = appEnv.quickSearchWords - return .none - case .appendHistoryKeyword(let keyword): state.appendHistoryKeywords([keyword]) - return .send(.syncHistoryKeywords) + return .none case .removeHistoryKeyword(let keyword): state.removeHistoryKeyword(keyword) - return .send(.syncHistoryKeywords) + return .none case .fetchHistoryGalleries: return .run { send in diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index 9d5aa1248..e4093b644 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -74,7 +74,6 @@ public struct SearchRootView: View { } .onAppear { store.send(.fetchHistoryGalleries) - store.send(.fetchDatabaseInfos) } .toolbar(content: toolbar) .navigationTitle(.RLocalizable.search) From 49fcba84ce0c114c10d52e366f9dc8d8ba893c2e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 19:05:55 +0800 Subject: [PATCH 519/614] Persist tag translator via `@Shared` fileStorage --- .../AppModels/Persistence/AppSharedKeys.swift | 22 +++++++----- .../AppModels/Tags/TagTranslatorInfo.swift | 36 ------------------- .../SettingFeature/SettingReducer+Body.swift | 29 +++++---------- .../SettingReducer+Helpers.swift | 22 ++++-------- .../SettingFeature/SettingReducer.swift | 10 +++--- .../SettingReducerNavigationTests.swift | 6 ++-- 6 files changed, 36 insertions(+), 89 deletions(-) delete mode 100644 AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift index 2242dd816..d756124db 100644 --- a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -9,17 +9,18 @@ import Sharing // // Why whole structs in app storage rather than a database or per-field keys: // • Every value here is either capped (`galleryHistory`, `historyKeywords`, `quickSearchWords`) -// or inherently small (`setting`, `user`, the filters, `tagTranslatorInfo`), so the defaults -// domain comfortably holds it. +// or inherently small (`setting`, `user`, the filters), so the defaults domain comfortably +// holds it. // • Keeping the structs intact preserves their invariants (e.g. `Setting`/`Filter` `didSet` // cascades) and makes resets/logout a single atomic assignment. // • Forward migration rides on each model's tolerant `init(from:)` decoder (`decodeIfPresent` // + defaults): additive changes never invalidate an existing persisted value, and a decode // failure falls back to the key's default — there is no store-fails-to-open failure mode. // -// Large or derived data is intentionally *not* here: the tag-translation table is rebuilt at -// launch from a cached raw JSON file (only `tagTranslatorInfo` metadata is persisted), and web -// images keep their own caches. `appStorage` keys must not contain `.` or `@`. +// The one exception is the tag-translation table (`tagTranslator`): it is multi-megabyte, far too +// large for the UserDefaults domain, so it uses the `fileStorage` strategy (a JSON file) instead. +// Everything else fits app storage. Web images keep their own caches. `appStorage` keys must not +// contain `.` or `@`. // MARK: Account & preferences @@ -63,11 +64,14 @@ extension SharedKey where Self == AppStorageKey<[QuickSearchWord]>.Default { } } -// MARK: Tag translations (metadata only — the table is rebuilt at launch) +// MARK: Tag translations (large — file-backed rather than in the defaults domain) -extension SharedKey where Self == AppStorageKey.Default { - public static var tagTranslatorInfo: Self { - Self[.appStorage("tagTranslatorInfo"), default: TagTranslatorInfo()] +extension SharedKey where Self == FileStorageKey.Default { + public static var tagTranslator: Self { + Self[ + .fileStorage(.applicationSupportDirectory.appending(component: "tagTranslator.json")), + default: TagTranslator() + ] } } diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift deleted file mode 100644 index 93e354bf9..000000000 --- a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation - -/// Lightweight, persisted metadata about the tag-translation database. -/// -/// The translation dictionary itself is *not* persisted — it is rebuilt in memory at launch -/// from a cached raw JSON file (see the tag-translator fetch/import flow). Only this small -/// envelope is stored, so the app can decide on launch whether a fresh download is due -/// (`updatedDate`) and which locale/customization is active without holding the full table -/// in the defaults domain. -/// -/// The manual `init(from:)` decodes tolerantly so future additive changes never invalidate -/// an existing persisted value. -public struct TagTranslatorInfo: Codable, Equatable, Sendable { - public init( - language: TranslatableLanguage? = nil, - updatedDate: Date = .distantPast, - hasCustomTranslations: Bool = false - ) { - self.language = language - self.updatedDate = updatedDate - self.hasCustomTranslations = hasCustomTranslations - } - public var language: TranslatableLanguage? - public var updatedDate: Date - public var hasCustomTranslations: Bool -} - -// MARK: Manually decode -extension TagTranslatorInfo { - public init(from decoder: Decoder) { - let container = try? decoder.container(keyedBy: CodingKeys.self) - language = (try? container?.decodeIfPresent(TranslatableLanguage.self, forKey: .language)) ?? nil - updatedDate = (try? container?.decodeIfPresent(Date.self, forKey: .updatedDate)) ?? .distantPast - hasCustomTranslations = (try? container?.decodeIfPresent(Bool.self, forKey: .hasCustomTranslations)) ?? false - } -} diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 33f70d711..e07dbaa11 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -95,10 +95,7 @@ extension SettingReducer { Reduce { state, action in switch action { case .binding: - return .merge( - .send(.syncSetting), - .send(.syncTagTranslator) - ) + return .send(.syncSetting) case .settingRowTapped(let screen): state.path.appendGuardingDuplicate(screen.pathElement) @@ -148,19 +145,10 @@ extension SettingReducer { @Shared(.setting) var storedSetting $storedSetting.withLock { $0 = state.setting } return .none - case .syncTagTranslator: - return .run { [state] _ in - await databaseClient.updateTagTranslator(state.tagTranslator) - } case .loadUserSettings: - return .run { send in - let appEnv = await databaseClient.fetchAppEnv() - await send(.onLoadUserSettings(appEnv)) - } - - case .onLoadUserSettings(let appEnv): - return handleLoadUserSettings(&state, appEnv: appEnv) + // `setting`/`user`/`tagTranslator` are all @Shared (auto-loaded); no database read. + return handleLoadUserSettings(&state) case .loadUserSettingsDone: state.hasLoadedInitialSetting = true @@ -232,8 +220,7 @@ extension SettingReducer { state.tagTranslatorLoadingState = .idle switch result { case .success(let tagTranslator): - state.tagTranslator = tagTranslator - return .send(.syncTagTranslator) + state.$tagTranslator.withLock { $0 = tagTranslator } case .failure(let error): state.tagTranslatorLoadingState = .failed(error) } @@ -291,9 +278,11 @@ extension SettingReducer { } case .path(.element(id: _, action: .general(.onRemoveCustomTranslations))): - state.tagTranslator.hasCustomTranslations = false - state.tagTranslator.translations = .init() - return .send(.syncTagTranslator) + state.$tagTranslator.withLock { + $0.hasCustomTranslations = false + $0.translations = .init() + } + return .none case .igneousRefreshed: return .none diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift index bd63466f1..660a4d1ee 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift @@ -9,14 +9,11 @@ import NetworkingFeature private let logger = Logger(category: .init(describing: SettingReducer.self)) extension SettingReducer { - func handleLoadUserSettings( - _ state: inout State, appEnv: AppEnv - ) -> Effect { - // `setting` loads from persisted storage into its working copy; `user` is `@Shared` and - // auto-loads. `tagTranslator` still comes from the database here (reworked in a later step). + func handleLoadUserSettings(_ state: inout State) -> Effect { + // `setting` loads from persisted storage into its working copy; `user` and `tagTranslator` + // are `@Shared` and auto-load, so nothing to copy for them here. @Shared(.setting) var storedSetting state.setting = storedSetting - state.tagTranslator = appEnv.tagTranslator var effects: [Effect] = [ .send(.syncAppIconType), .send(.loadUserSettingsDone), @@ -88,21 +85,16 @@ extension SettingReducer { else { return .none } state.tagTranslatorLoadingState = .loading - var databaseEffect: Effect? + // A language switch resets the table; the write-through to `@Shared` is the assignment + // itself (no separate sync step). The subsequent request fetches the new language's data. if state.tagTranslator.language != language { - state.tagTranslator = TagTranslator(language: language) - databaseEffect = .send(.syncTagTranslator) + state.$tagTranslator.withLock { $0 = TagTranslator(language: language) } } let updatedDate = state.tagTranslator.updatedDate - let requestEffect = Effect.run { send in + return .run { send in let response = await TagTranslatorRequest(language: language, updatedDate: updatedDate).response() await send(Action.fetchTagTranslatorDone(response)) } - if let databaseEffect = databaseEffect { - return .merge(databaseEffect, requestEffect) - } else { - return requestEffect - } } func handleFetchEhProfileIndexDone( diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index 182508c36..335625ea8 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -52,11 +52,11 @@ public struct SettingReducer: Sendable { public struct State: Equatable, Sendable { // `setting` stays a working copy edited through `BindingReducer` (its `.onChange` handlers // enforce cross-field invariants and fire side effects); persistence is a write-through to - // `@Shared(.setting)` via `.syncSetting`. `user` is not form-bound, so it is stored directly - // in `@Shared(.user)` (auto-loaded, mutated via `withLock`). `tagTranslator` is rebuilt at - // launch from a cached file rather than persisted whole. + // `@Shared(.setting)` via `.syncSetting`. `user` and `tagTranslator` are not form-bound, so + // they are stored directly in `@Shared` (auto-loaded, mutated via `withLock`). The tag table + // is file-backed because it is large. public var setting = Setting() - public var tagTranslator = TagTranslator() + @Shared(.tagTranslator) public var tagTranslator: TagTranslator @Shared(.user) public var user: User public var hasLoadedInitialSetting = false @@ -107,10 +107,8 @@ public struct SettingReducer: Sendable { case syncAppIconTypeDone(String?) case syncUserInterfaceStyle case syncSetting - case syncTagTranslator case loadUserSettings - case onLoadUserSettings(AppEnv) case loadUserSettingsDone case createDefaultEhProfile case fetchIgneous diff --git a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift index 5ea2c687d..2391c1be6 100644 --- a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift @@ -134,11 +134,11 @@ struct SettingReducerNavigationTests { let url = URL(filePath: "/tmp/tags.json") await store.send(.path(.element(id: id, action: .general(.onTranslationsFilePicked(url))))) - // The parent intercept runs `fileClient.importTagTranslator` and stores the result. + // The parent intercept runs `fileClient.importTagTranslator` and stores the result + // (write-through to `@Shared(.tagTranslator)`). await store.receive(\.fetchTagTranslatorDone) { - $0.tagTranslator = imported + $0.$tagTranslator.withLock { $0 = imported } } - await store.receive(\.syncTagTranslator) } // MARK: Post-login cascade From e63d713ab4ac36a2ed879075c9ab7fd121073fb1 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 19:19:14 +0800 Subject: [PATCH 520/614] Persist reading progress via gallery history --- .../AppFeature/DataFlow/AppRouteReducer.swift | 10 ++++- .../GalleryHistory+Operations.swift | 38 +++++++++++++++++++ .../Comments/CommentsReducer.swift | 10 ++++- .../DetailFeature/DetailReducer+Actions.swift | 14 +++++-- .../DetailFeature/DetailReducer+Fetch.swift | 11 ++---- .../Sources/DetailFeature/DetailReducer.swift | 1 + .../Previews/PreviewsReducer.swift | 10 ++++- .../ReadingReducer+Database.swift | 13 +++++-- .../ReadingFeature/ReadingReducer.swift | 1 + .../DetailReducerObserveTests.swift | 1 + 10 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 6a72e2d72..345fdd1be 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -1,6 +1,7 @@ import AppTools import SwiftUI import AppModels +import Sharing import ComposableArchitecture import URLClient import UserDefaultsClient @@ -59,6 +60,7 @@ struct AppRouteReducer { @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.urlClient) private var urlClient + @Dependency(\.date) private var date var body: some Reducer { BindingReducer() @@ -176,9 +178,13 @@ struct AppRouteReducer { case .updateReadingProgress(let gid, let progress): guard !gid.isEmpty else { return .none } - return .run { _ in - await databaseClient.updateReadingProgress(gid: gid, progress: progress) + // Deep link straight to a page: the token isn't known here, so the entry is created + // tokenless and backfilled when the detail screen records the open. + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.updateReadingProgress(gid: gid, token: "", progress: progress, date: date.now) } + return .none case .fetchGallery(let url, let isGalleryImageURL): state.toast = .loading() diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift new file mode 100644 index 000000000..c83f61d6a --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift @@ -0,0 +1,38 @@ +import Foundation + +// The browsing-history list persisted behind `@Shared(.galleryHistory)`. Kept most-recent-first; +// these are the only mutators, so the ordering and per-gid uniqueness invariants live here rather +// than being re-derived at each call site. None of them trim — the 1,000-entry cap is enforced +// solely by a launch-time prune, so in-session upserts may temporarily grow the list past the cap. +extension Array where Element == GalleryHistoryEntry { + /// The saved resume page for `gid`, or 0 when the gallery has no history entry yet. + public func readingProgress(gid: String) -> Int { + first { $0.gid == gid }?.readingProgress ?? 0 + } + + /// Records that `gid` was just opened: stamps its recency with `date`, moves it to the front, + /// fills in a previously-missing token, and preserves any saved reading progress. Inserts a + /// fresh entry when the gallery is new to the history. + public mutating func recordGalleryOpen(gid: String, token: String, date: Date) { + var entry = first { $0.gid == gid } ?? GalleryHistoryEntry(gid: gid, token: token, lastOpenDate: date) + removeAll { $0.gid == gid } + entry.lastOpenDate = date + if entry.token.isEmpty { entry.token = token } + insert(entry, at: 0) + } + + /// Updates the saved resume page for `gid` in place, leaving its recency and position untouched. + /// Inserts a fresh front entry stamped `date` when the gallery has no history yet — e.g. a deep + /// link that jumps straight to a page before the detail screen records the open (that later open + /// backfills the token via `recordGalleryOpen`). + public mutating func updateReadingProgress(gid: String, token: String, progress: Int, date: Date) { + if let index = firstIndex(where: { $0.gid == gid }) { + self[index].readingProgress = progress + } else { + insert( + GalleryHistoryEntry(gid: gid, token: token, lastOpenDate: date, readingProgress: progress), + at: 0 + ) + } + } +} diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index 6760134ed..cc789aded 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import Sharing import ComposableArchitecture import URLClient import ApplicationClient @@ -88,6 +89,7 @@ public struct CommentsReducer: Sendable { @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient @Dependency(\.urlClient) private var urlClient + @Dependency(\.date) private var date public init() {} @@ -187,9 +189,13 @@ public struct CommentsReducer: Sendable { case .updateReadingProgress(let gid, let progress): guard !gid.isEmpty else { return .none } - return .run { _ in - await databaseClient.updateReadingProgress(gid: gid, progress: progress) + // Deep link straight to a page: the token isn't known here, so the entry is created + // tokenless and backfilled when the detail screen records the open. + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.updateReadingProgress(gid: gid, token: "", progress: progress, date: date.now) } + return .none case .postComment(let galleryURL, let commentID): guard !state.commentContent.isEmpty else { return .none } diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index 2b67b575b..a24687f49 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -169,14 +169,20 @@ extension DetailReducer { } case .saveGalleryHistory: - return .run { [gid = state.gallery.id] _ in - await databaseClient.updateLastOpenDate(gid: gid) + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.recordGalleryOpen(gid: state.gallery.id, token: state.gallery.token, date: date.now) } + return .none case .updateReadingProgress(let progress): - return .run { [gid = state.gallery.id] _ in - await databaseClient.updateReadingProgress(gid: gid, progress: progress) + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.updateReadingProgress( + gid: state.gallery.id, token: state.gallery.token, progress: progress, date: date.now + ) } + return .none default: return .none diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift index 5c8dd8e9d..f8def1817 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift @@ -85,14 +85,9 @@ extension DetailReducer { state.pendingDeepLink = nil switch deepLink { case .reading: - // The linking comment already wrote the reading progress; open the reader - // after a short beat so that write has landed before ReadingView appears. - effects.append( - .run { send in - try await Task.sleep(for: .milliseconds(750)) - await send(.presentReading) - } - ) + // The linking comment already wrote the reading progress synchronously to + // `@Shared(.galleryHistory)`, so the reader can open immediately. + effects.append(.send(.presentReading)) case .comments(let commentID): if let galleryURL = state.gallery.galleryURL { effects.append(.send(.delegate(.pushComments( diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index 92d2f56e1..b8accda47 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -183,6 +183,7 @@ public struct DetailReducer: Sendable { @Dependency(\.hapticsClient) var hapticsClient @Dependency(\.cookieClient) var cookieClient @Dependency(\.appLaunchAutomationClient) var appLaunchAutomationClient + @Dependency(\.date) var date public init() {} diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index 709e326ad..190ecae59 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -1,5 +1,6 @@ import Foundation import AppModels +import Sharing import ComposableArchitecture import AppTools import HapticsClient @@ -71,6 +72,7 @@ public struct PreviewsReducer: Sendable { @Dependency(\.databaseClient) private var databaseClient @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.date) private var date public init() {} @@ -94,9 +96,13 @@ public struct PreviewsReducer: Sendable { } case .updateReadingProgress(let progress): - return .run { [state] _ in - await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.updateReadingProgress( + gid: state.gallery.id, token: state.gallery.token, progress: progress, date: date.now + ) } + return .none case .fetchDatabaseInfos(let gid): guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift index faf802f1e..d52cc86f9 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import Sharing import ComposableArchitecture import AppTools @@ -9,9 +10,13 @@ extension ReadingReducer { Reduce { state, action in switch action { case .syncReadingProgress(let progress): - return .run { [state] _ in - await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.updateReadingProgress( + gid: state.gallery.id, token: state.gallery.token, progress: progress, date: date.now + ) } + return .none case .syncPreviewURLs(let previewURLs): guard !state.isOffline else { return .none } @@ -70,6 +75,9 @@ extension ReadingReducer { state.gallery = gallery state.language = databaseClient.fetchGalleryDetail(gid: state.gallery.id)?.language } + // Resume position comes from the persisted browsing history, keyed by gid. + @Shared(.galleryHistory) var galleryHistory + state.readingProgress = galleryHistory.readingProgress(gid: state.gallery.id) return .run { [state] send in guard let dbState = await databaseClient.fetchGalleryState(gid: state.gallery.id) else { return } await send(.fetchDatabaseInfosDone(dbState)) @@ -87,7 +95,6 @@ extension ReadingReducer { state.thumbnailURLs = galleryState.thumbnailURLs state.originalImageURLs = galleryState.originalImageURLs } - state.readingProgress = galleryState.readingProgress state.databaseLoadingState = .idle return .none } diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 779014a52..8378804a0 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -201,6 +201,7 @@ public struct ReadingReducer: Sendable { @Dependency(\.deviceClient) var deviceClient @Dependency(\.imageClient) var imageClient @Dependency(\.urlClient) var urlClient + @Dependency(\.date) var date public init() {} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift index 19da213e0..2233abddd 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift @@ -161,6 +161,7 @@ private extension DetailReducerObserveTests { $0.hapticsClient = .noop $0.databaseClient = .noop $0.cookieClient = .noop + $0.date = .constant(.init(timeIntervalSince1970: 0)) } ) } From 79122dc7991616bde1d4df11981a7a4d67bf9a24 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 19:33:38 +0800 Subject: [PATCH 521/614] Render history from batched gdata metadata --- .../DataFlow/AppDelegateReducer.swift | 4 + .../GalleryHistory+Operations.swift | 9 + .../Persistent/GalleryHistoryEntry.swift | 4 + .../HomeFeature/History/HistoryReducer.swift | 46 +++-- .../HomeFeature/History/HistoryView.swift | 2 +- .../Request+GalleriesMetadata.swift | 180 ++++++++++++++++++ .../SearchFeature/SearchRootReducer.swift | 23 ++- 7 files changed, 243 insertions(+), 25 deletions(-) create mode 100644 AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index 16293fe75..b9456057c 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -1,5 +1,6 @@ import AppModels import SwiftUI +import Sharing import BackgroundTasks import ComposableArchitecture import AppTools @@ -36,6 +37,9 @@ struct AppDelegateReducer { Reduce { _, action in switch action { case .onLaunchFinish: + // Enforce the browsing-history cap once per launch; in-session upserts never trim. + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { $0.pruneToHistoryCap() } return .merge( .run(operation: { _ in libraryClient.initializeWebImage() }), .run(operation: { _ in cookieClient.removeYay() }), diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift index c83f61d6a..7af7b7fbf 100644 --- a/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift @@ -35,4 +35,13 @@ extension Array where Element == GalleryHistoryEntry { ) } } + + /// Trims the list to the `historyCap` most-recent entries by `lastOpenDate`. Called only at + /// launch: it also normalises the order to most-recent-first when it has to drop anything. + public mutating func pruneToHistoryCap() { + guard count > GalleryHistoryEntry.historyCap else { return } + self = Array( + sorted { $0.lastOpenDate > $1.lastOpenDate }.prefix(GalleryHistoryEntry.historyCap) + ) + } } diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift index ea2f96990..5455a97e2 100644 --- a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift @@ -10,6 +10,10 @@ import Foundation /// The manual `init(from:)` decodes every field tolerantly (`decodeIfPresent` + default) so /// that future additive changes to this record never invalidate an existing persisted list. public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable { + /// The most entries kept across launches. Enforced only by a launch-time prune (see + /// `Array.pruneToHistoryCap`); in-session upserts may temporarily exceed it. + public static let historyCap = 1000 + public init( gid: String, token: String, diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index 4bdd60694..560b7b1c6 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -1,11 +1,12 @@ import Foundation import AppModels +import Sharing import Resources import ComposableArchitecture import AppTools import HapticsClient -import DatabaseClient import DownloadClient +import NetworkingFeature import AppComponents @Reducer @@ -28,6 +29,10 @@ public struct HistoryReducer: Sendable { public var keyword = "" public var downloadBadges = [String: DownloadBadge]() + // The persisted browsing history (identity + recency + resume page, most-recent-first). + // No gallery snapshot is stored, so `galleries` is the display metadata refetched on demand. + @Shared(.galleryHistory) public var galleryHistory: [GalleryHistoryEntry] + var filteredGalleries: [Gallery] { guard !keyword.isEmpty else { return galleries } return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) @@ -47,12 +52,11 @@ public struct HistoryReducer: Sendable { case clearHistoryGalleries case fetchGalleries - case fetchGalleriesDone([Gallery]) + case fetchGalleriesDone(Result<[Gallery], AppError>) case observeDownloads case observeDownloadsDone([DownloadedGallery]) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient @@ -94,28 +98,36 @@ public struct HistoryReducer: Sendable { return .none case .clearHistoryGalleries: - return .merge( - .run(operation: { _ in await databaseClient.clearHistoryGalleries() }), - .run { send in - try await Task.sleep(for: .milliseconds(200)) - await send(.fetchGalleries) - } - ) + // Clearing also drops resume positions (they live on the same entries) — deliberate, + // browser-like. The write is synchronous, so we can refetch straight away. + state.$galleryHistory.withLock { $0.removeAll() } + return .send(.fetchGalleries) case .fetchGalleries: guard state.loadingState != .loading else { return .none } + let pairs = state.galleryHistory.map { (gid: $0.gid, token: $0.token) } + guard !pairs.isEmpty else { + state.galleries = [] + state.loadingState = .failed(.notFound) + return .none + } state.loadingState = .loading return .run { send in - let historyGalleries = await databaseClient.fetchHistoryGalleries() - await send(.fetchGalleriesDone(historyGalleries)) + let response = await GalleriesMetadataRequest(gidList: pairs).response() + await send(.fetchGalleriesDone(response)) } - case .fetchGalleriesDone(let galleries): + case .fetchGalleriesDone(let result): state.loadingState = .idle - if galleries.isEmpty { - state.loadingState = .failed(.notFound) - } else { - state.galleries = galleries + switch result { + case .success(let galleries): + if galleries.isEmpty { + state.loadingState = .failed(.notFound) + } else { + state.galleries = galleries + } + case .failure(let error): + state.loadingState = .failed(error) } return .none diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index 1b93580cd..9243ace1f 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -59,7 +59,7 @@ struct HistoryView: View { } label: { Image(systemSymbol: .trashCircle) } - .disabled(store.loadingState != .idle || store.galleries.isEmpty) + .disabled(store.loadingState == .loading || store.galleryHistory.isEmpty) .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) diff --git a/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift new file mode 100644 index 000000000..c8e58c96f --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift @@ -0,0 +1,180 @@ +import AppModels +import Combine +import Foundation +import AppTools + +// MARK: Response types +private struct GalleriesMetadataAPIResponse: Decodable { + let gmetadata: [GalleryMetadata] +} + +/// One `gmetadata` entry from the `gdata` API. Every display field is optional because the API +/// returns a bare `{ gid, error }` object for gids it can't resolve (expunged/removed galleries); +/// `gallery` yields `nil` for those so a single bad entry never fails the whole batch. +private struct GalleryMetadata: Decodable { + let gid: Int + let token: String + let error: String? + let title: String? + let category: String? + let thumb: String? + let uploader: String? + let posted: String? + let filecount: String? + let rating: String? + let tags: [String]? + + var gallery: Gallery? { + guard error == nil, + let title, + let posted, let postedInterval = TimeInterval(posted) + else { return nil } + return Gallery( + gid: String(gid), + token: token, + title: title.htmlEntitiesDecoded, + rating: rating.flatMap(Float.init) ?? 0, + tags: Self.parseTags(tags ?? []), + category: category.flatMap(AppModels.Category.init(rawValue:)) ?? .misc, + uploader: uploader, + pageCount: filecount.flatMap(Int.init) ?? 0, + postedDate: Date(timeIntervalSince1970: postedInterval), + coverURL: thumb.flatMap { URL(string: $0) }, + galleryURL: Defaults.URL.host + .appendingPathComponent("g") + .appendingPathComponent(String(gid)) + .appendingPathComponent(token) + ) + } + + /// Groups the flat `"namespace:content"` tag list (returned because the request sets + /// `namespace: 1`) into `GalleryTag`s. A tag without a namespace falls under `misc`. + private static func parseTags(_ raw: [String]) -> [GalleryTag] { + var tags = [GalleryTag]() + for entry in raw { + let parts = entry.split(separator: ":", maxSplits: 1).map(String.init) + let namespace = parts.count == 2 ? parts[0] : "misc" + let text = parts.count == 2 ? parts[1] : entry + let content = GalleryTag.Content( + rawNamespace: namespace, text: text, isVotedUp: false, isVotedDown: false + ) + if let index = tags.firstIndex(where: { $0.rawNamespace == namespace }) { + tags[index] = .init(rawNamespace: namespace, contents: tags[index].contents + [content]) + } else { + tags.append(.init(rawNamespace: namespace, contents: [content])) + } + } + return tags + } +} + +// MARK: Request +/// Resolves display metadata for a set of galleries via the `gdata` API. The app persists no +/// gallery snapshots, so the History screen and "recently seen" suggestions rebuild their cells +/// from this on demand. The `gdata` endpoint accepts at most 25 gid/token pairs per call, so the +/// input is chunked and the chunks are fetched concurrently, then reassembled in input order +/// (unresolved gids are dropped). +public struct GalleriesMetadataRequest: Request { + public let gidList: [(gid: String, token: String)] + public let urlSession: URLSession + + public init(gidList: [(gid: String, token: String)], urlSession: URLSession = .shared) { + self.gidList = gidList + self.urlSession = urlSession + } + + public var publisher: AnyPublisher<[Gallery], AppError> { + let order = gidList.map(\.gid) + let chunks = gidList.chunked(into: 25) + guard !chunks.isEmpty else { + return Just([]).setFailureType(to: AppError.self).eraseToAnyPublisher() + } + return Publishers.MergeMany(chunks.map(chunkPublisher)) + .collect() + .map { pages in + let byGID = Dictionary( + pages.flatMap { $0 }.map { ($0.gid, $0) }, + uniquingKeysWith: { first, _ in first } + ) + return order.compactMap { byGID[$0] } + } + .eraseToAnyPublisher() + } + + private func chunkPublisher(_ chunk: [(gid: String, token: String)]) -> AnyPublisher<[Gallery], AppError> { + let gidlist = chunk.compactMap { pair -> [Any]? in + guard let gid = Int(pair.gid) else { return nil } + return [gid, pair.token] + } + guard !gidlist.isEmpty else { + return Just([]).setFailureType(to: AppError.self).eraseToAnyPublisher() + } + + let params: [String: Any] = [ + "method": "gdata", + "gidlist": gidlist, + "namespace": 1 + ] + var request = URLRequest(url: Defaults.URL.api) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) + + return urlSession.dataTaskPublisher(for: request) + .genericRetry() + .map(\.data) + .tryMap { data in + try parseResponse(data: data) { + let response = try JSONDecoder().decode(GalleriesMetadataAPIResponse.self, from: $0) + return response.gmetadata.compactMap(\.gallery) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +// MARK: Helpers +private extension Array { + func chunked(into size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { + Array(self[$0..") + .replacingOccurrences(of: """, with: "\"") + .replacingOccurrences(of: "'", with: "'") + .replacingOccurrences(of: "&", with: "&") + } + + private func decodingCharacterReferences() -> String { + var output = "" + var remainder = Substring(self) + while let start = remainder.range(of: "&#") { + output += remainder[remainder.startIndex..) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.deviceClient) private var deviceClient @Dependency(\.hapticsClient) private var hapticsClient @@ -158,13 +157,23 @@ public struct SearchRootReducer: Sendable { return .none case .fetchHistoryGalleries: + // "Recently seen" suggestions: the 10 most-recent history entries, metadata + // refetched on demand since no gallery snapshot is persisted. + @Shared(.galleryHistory) var galleryHistory + let pairs = galleryHistory.prefix(10).map { (gid: $0.gid, token: $0.token) } + guard !pairs.isEmpty else { + state.historyGalleries = [] + return .none + } return .run { send in - let historyGalleries = await databaseClient.fetchHistoryGalleries(fetchLimit: 10) - await send(.fetchHistoryGalleriesDone(historyGalleries)) + let response = await GalleriesMetadataRequest(gidList: pairs).response() + await send(.fetchHistoryGalleriesDone(response)) } - case .fetchHistoryGalleriesDone(let galleries): - state.historyGalleries = Array(galleries.prefix(min(galleries.count, 10))) + case .fetchHistoryGalleriesDone(let result): + if case .success(let galleries) = result { + state.historyGalleries = galleries + } return .none } } From 352f1d20eb23e1631d6e6e176957f34a3d5ce7f3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 20:02:58 +0800 Subject: [PATCH 522/614] Seed gallery detail from navigation, drop DB reads --- .../AppFeature/DataFlow/AppReducer.swift | 12 +++---- .../AppFeature/DataFlow/AppRouteReducer.swift | 32 +++++++++---------- .../Comments/CommentsReducer.swift | 18 ++++------- .../DetailFeature/DetailReducer+Actions.swift | 5 ++- .../DetailFeature/DetailReducer+Fetch.swift | 28 ---------------- .../Sources/DetailFeature/DetailReducer.swift | 14 +++++--- .../DetailSearch/DetailSearchReducer.swift | 2 +- .../Sources/DetailFeature/DetailView.swift | 2 +- .../DetailFeature/GalleryNavigation.swift | 12 +++---- .../Previews/PreviewsReducer.swift | 25 +++------------ .../DetailFeature/Previews/PreviewsView.swift | 2 +- .../DownloadsFeature/DownloadsReducer.swift | 4 +-- .../FavoritesFeature/FavoritesReducer.swift | 16 +++++----- .../GalleryListComponents/GenericList.swift | 16 +++++----- .../Frontpage/FrontpageReducer.swift | 2 +- .../HomeFeature/History/HistoryReducer.swift | 2 +- .../HomeFeature/HomeReducer+Body.swift | 20 ++++++------ .../Sources/HomeFeature/HomeReducer.swift | 6 ++-- .../HomeFeature/HomeView+Sections.swift | 26 +++++++-------- AppPackage/Sources/HomeFeature/HomeView.swift | 10 +++--- .../HomeFeature/Popular/PopularReducer.swift | 2 +- .../Toplists/ToplistsReducer.swift | 2 +- .../HomeFeature/Watched/WatchedReducer.swift | 2 +- .../ReadingFeature/ReadingReducer+Body.swift | 1 - .../ReadingReducer+Database.swift | 30 +++-------------- .../ReadingFeature/ReadingReducer.swift | 1 - .../Sources/SearchFeature/SearchReducer.swift | 2 +- .../SearchFeature/SearchRootReducer.swift | 18 +++++------ .../SearchFeature/SearchRootView.swift | 10 +++--- 29 files changed, 128 insertions(+), 194 deletions(-) diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index ae78d1707..b15003d87 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -255,13 +255,13 @@ struct AppReducer { // A gallery tapped on iPad presents modally (hosted by AppRoute) instead of pushing // inline; the tab hosts delegate that presentation up here. - case let .home(.delegate(.presentGalleryDetail(gid))), - let .searchRoot(.delegate(.presentGalleryDetail(gid))), - let .favorites(.delegate(.presentGalleryDetail(gid))): - return .send(.appRoute(.presentGalleryDetail(gid, nil))) + case let .home(.delegate(.presentGalleryDetail(gallery))), + let .searchRoot(.delegate(.presentGalleryDetail(gallery))), + let .favorites(.delegate(.presentGalleryDetail(gallery))): + return .send(.appRoute(.presentGalleryDetail(gallery, nil))) - case let .downloads(.delegate(.presentGalleryDetail(gid, download))): - return .send(.appRoute(.presentGalleryDetail(gid, download))) + case let .downloads(.delegate(.presentGalleryDetail(gallery, download))): + return .send(.appRoute(.presentGalleryDetail(gallery, download))) case .home: return .none diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 345fdd1be..2fc4223ca 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -41,12 +41,12 @@ struct AppRouteReducer { case path(StackActionOf) case presentSetting case presentNewDawn(Greeting) - case presentGalleryDetail(String, DownloadedGallery?) + case presentGalleryDetail(Gallery, DownloadedGallery?) case setToast(AppAlertState) case detectClipboardURL case handleDeepLink(URL) - case handleGalleryLink(URL) + case handleGalleryLink(URL, Gallery) case updateReadingProgress(String, Int) @@ -113,11 +113,16 @@ struct AppRouteReducer { state.destination = .newDawn(greeting) return .none - case .presentGalleryDetail(let gid, let download): + case .presentGalleryDetail(let gallery, let download): // A gallery opened from a tab on iPad: modal detail rooting its own gallery stack, - // seeded from the local download when one exists so it renders offline. + // seeded from the local download when one exists so it renders offline, otherwise + // from the tapped gallery. state.path.removeAll() - state.detail = .init(gid: gid, seededFrom: download) + if let download { + state.detail = .init(gid: gallery.id, seededFrom: download) + } else { + state.detail = .init(gallery: gallery) + } return .none case .setToast(let config): @@ -145,34 +150,27 @@ struct AppRouteReducer { state.detail = nil state.path.removeAll() } + // Always fetch the gallery so the pushed detail is seeded from it (no cache lookup). let analysis = urlClient.analyzeURL(url) - let gid = urlClient.parseGalleryID(url) - guard databaseClient.fetchGallery(gid: gid) == nil else { - return .run { [delay] send in - try await Task.sleep(for: .milliseconds(delay + 250)) - await send(.handleGalleryLink(url)) - } - } return .run { [delay] send in try await Task.sleep(for: .milliseconds(delay)) await send(.fetchGallery(url, analysis.isGalleryImageURL)) } - case .handleGalleryLink(let url): + case .handleGalleryLink(let url, let gallery): let analysis = urlClient.analyzeURL(url) let pageIndex = analysis.pageIndex let commentID = analysis.commentID - let gid = urlClient.parseGalleryID(url) var deepLink: GalleryDeepLink? var effects = [Effect]() if let pageIndex = pageIndex { - effects.append(.send(.updateReadingProgress(gid, pageIndex))) + effects.append(.send(.updateReadingProgress(gallery.id, pageIndex))) deepLink = .reading(page: pageIndex) } else if let commentID = commentID { deepLink = .comments(commentID: commentID) } state.path.removeAll() - state.detail = DetailReducer.State(gid: gid, pendingDeepLink: deepLink) + state.detail = DetailReducer.State(gallery: gallery, pendingDeepLink: deepLink) effects.append(.run(operation: { _ in await hapticsClient.generateFeedback(.light) })) return .merge(effects) @@ -202,7 +200,7 @@ struct AppRouteReducer { case .success(let gallery): return .run { send in await databaseClient.cacheGalleries([gallery]) - await send(.handleGalleryLink(url)) + await send(.handleGalleryLink(url, gallery)) } case .failure: // Let the loading toast animate out before showing the error toast. diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index cc789aded..23f77604a 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -20,7 +20,7 @@ public struct CommentsReducer: Sendable { public enum Delegate: Equatable, Sendable { // Open the linked gallery (optionally deep-linking to a page or comment) as a new stack element. - case pushDetail(String, GalleryDeepLink?) + case pushDetail(Gallery, GalleryDeepLink?) // A comment was voted/edited; ask the host to refresh the detail with this gid so it stays in sync. case performedCommentAction(String) } @@ -71,7 +71,7 @@ public struct CommentsReducer: Sendable { case setScrollRowOpacity(Double) case performScrollOpacityEffect case handleCommentLink(URL) - case handleGalleryLink(URL) + case handleGalleryLink(URL, Gallery) case onPostCommentAppear case onAppear @@ -155,27 +155,23 @@ public struct CommentsReducer: Sendable { guard urlClient.checkIfHandleable(url) else { return .run(operation: { _ in await applicationClient.openURL(url) }) } + // Always fetch the linked gallery so the pushed detail is seeded from it (no cache). let analysis = urlClient.analyzeURL(url) - let gid = urlClient.parseGalleryID(url) - guard databaseClient.fetchGallery(gid: gid) == nil else { - return .send(.handleGalleryLink(url)) - } return .send(.fetchGallery(url, analysis.isGalleryImageURL)) - case .handleGalleryLink(let url): + case .handleGalleryLink(let url, let gallery): let analysis = urlClient.analyzeURL(url) let pageIndex = analysis.pageIndex let commentID = analysis.commentID - let gid = urlClient.parseGalleryID(url) var deepLink: GalleryDeepLink? var effects = [Effect]() if let pageIndex = pageIndex { - effects.append(.send(.updateReadingProgress(gid, pageIndex))) + effects.append(.send(.updateReadingProgress(gallery.id, pageIndex))) deepLink = .reading(page: pageIndex) } else if let commentID = commentID { deepLink = .comments(commentID: commentID) } - effects.append(.send(.delegate(.pushDetail(gid, deepLink)))) + effects.append(.send(.delegate(.pushDetail(gallery, deepLink)))) return .merge(effects) case .onPostCommentAppear: @@ -267,7 +263,7 @@ public struct CommentsReducer: Sendable { case .success(let gallery): return .merge( .run(operation: { _ in await databaseClient.cacheGalleries([gallery]) }), - .send(.handleGalleryLink(url)) + .send(.handleGalleryLink(url, gallery)) ) case .failure: // Let the loading toast animate out before showing the error toast. diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index a24687f49..6836b5588 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -84,8 +84,11 @@ extension DetailReducer { state.hasLoadedDownloadBadge = false state.didRunLaunchAutomation = false state.localPreviewURLs = .init() + // The gallery is already seeded from the pushing context, so we record the visit and fetch + // the (always network-sourced) detail directly — no database read. return .merge( - .send(.fetchDatabaseInfos(gid)), + .send(.saveGalleryHistory), + .send(.fetchGalleryDetail), .send(.fetchDownloadBadge), .send(.fetchDownloadFolders), .send(.observeDownload), diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift index f8def1817..82bf80956 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift @@ -7,34 +7,6 @@ extension DetailReducer { var fetchReducer: some ReducerOf { Reduce { state, action in switch action { - case .fetchDatabaseInfos(let gid): - if let gallery = databaseClient.fetchGallery(gid: gid) { - state.gallery = gallery - } else if state.gallery.id != gid { - return .none - } - if let detail = databaseClient.fetchGalleryDetail(gid: gid) { - state.galleryDetail = detail - } - return .merge( - .send(.fetchDownloadBadge), - .send(.saveGalleryHistory), - .run { [galleryID = state.gallery.id] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: galleryID) else { return } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: CancelID.fetchDatabaseInfos(state.cancellationGalleryID)) - ) - - case .fetchDatabaseInfosDone(let galleryState): - state.galleryTags = galleryState.tags - state.galleryPreviewURLs = galleryState.previewURLs - state.galleryComments = galleryState.comments - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - return .send(.fetchGalleryDetail) - case .fetchGalleryDetail: guard state.loadingState != .loading, let galleryURL = state.gallery.galleryURL diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index b8accda47..e3d00fea5 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -17,7 +17,7 @@ public struct DetailReducer: Sendable { // The gallery sub-screens are now standalone elements on the host's navigation stack. Detail asks // the host to push them via these delegate actions instead of owning nested child state itself. public enum Delegate: Equatable, Sendable { - case pushPreviews(String) + case pushPreviews(Gallery) case pushComments( gid: String, token: String, apiKey: String, galleryURL: URL, comments: [GalleryComment], scrollCommentID: String? @@ -44,7 +44,6 @@ public struct DetailReducer: Sendable { } public enum CancelID: Hashable, Sendable { - case fetchDatabaseInfos(String) case fetchGalleryDetail(String) case fetchVersionMetadata(String) case fetchDownloadBadge(String) @@ -106,6 +105,15 @@ public struct DetailReducer: Sendable { self.pendingDeepLink = pendingDeepLink } + // Seeded from the pushing context (a tapped list item or a freshly-fetched gallery) so the + // detail header renders immediately and `fetchGalleryDetail` has a `galleryURL` without any + // database lookup. Gallery data lives only here and dies when the screen pops. + public init(gallery: Gallery, pendingDeepLink: GalleryDeepLink? = nil) { + self.gid = gallery.id + self.gallery = gallery + self.pendingDeepLink = pendingDeepLink + } + mutating func updateRating(value: DragGesture.Value) { let rating = Int(value.location.x / 31 * 2) + 1 userRating = min(max(rating, 1), 10) @@ -164,8 +172,6 @@ public struct DetailReducer: Sendable { case retryDownloadDone(Result) case deleteDownload case deleteDownloadDone(Result) - case fetchDatabaseInfos(String) - case fetchDatabaseInfosDone(GalleryState) case fetchGalleryDetail case fetchGalleryDetailDone(Result) case fetchVersionMetadataIfNeeded diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index 00cb23010..0e8a56a86 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -17,7 +17,7 @@ public struct DetailSearchReducer: Sendable { } public enum Delegate: Equatable, Sendable { - case pushDetail(String) + case pushDetail(Gallery) } private enum CancelID { diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index a523332ce..b19254b3e 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -135,7 +135,7 @@ private extension DetailView { PreviewsSection( pageCount: store.galleryDetail?.pageCount ?? 0, previewURLs: displayPreviewURLs, - navigatePreviewsAction: { store.send(.delegate(.pushPreviews(gid))) }, + navigatePreviewsAction: { store.send(.delegate(.pushPreviews(store.gallery))) }, navigateReadingAction: { store.send(.updateReadingProgress($0)) store.send(.openReading) diff --git a/AppPackage/Sources/DetailFeature/GalleryNavigation.swift b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift index 05e068bec..c8ae1ce38 100644 --- a/AppPackage/Sources/DetailFeature/GalleryNavigation.swift +++ b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift @@ -21,8 +21,8 @@ public enum GalleryNavigation { switch action { case let .detail(.delegate(delegate)): switch delegate { - case .pushPreviews(let gid): - return .previews(.init(gid: gid)) + case .pushPreviews(let gallery): + return .previews(.init(gid: gallery.id, gallery: gallery)) case let .pushComments(gid, token, apiKey, galleryURL, comments, scrollCommentID): return .comments(.init( gid: gid, token: token, apiKey: apiKey, @@ -36,14 +36,14 @@ public enum GalleryNavigation { case let .comments(.delegate(delegate)): switch delegate { - case let .pushDetail(gid, deepLink): - return .detail(.init(gid: gid, pendingDeepLink: deepLink)) + case let .pushDetail(gallery, deepLink): + return .detail(.init(gallery: gallery, pendingDeepLink: deepLink)) case .performedCommentAction: return nil } - case let .detailSearch(.delegate(.pushDetail(gid))): - return .detail(.init(gid: gid)) + case let .detailSearch(.delegate(.pushDetail(gallery))): + return .detail(.init(gallery: gallery)) default: return nil diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index 190ecae59..f097afa5b 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -17,7 +17,6 @@ public struct PreviewsReducer: Sendable { } private enum CancelID { - case fetchDatabaseInfos case observeDownloads case loadLocalPreviewURLs case fetchPreviewURLs @@ -57,8 +56,7 @@ public struct PreviewsReducer: Sendable { case syncPreviewURLs([Int: URL]) case updateReadingProgress(Int) - case fetchDatabaseInfos(String) - case fetchDatabaseInfosDone(GalleryState) + case onAppear(String) case observeDownloads(String) case observeDownloadsDone([DownloadedGallery]) case loadLocalPreviewURLs(String) @@ -104,29 +102,14 @@ public struct PreviewsReducer: Sendable { } return .none - case .fetchDatabaseInfos(let gid): - guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery + case .onAppear(let gid): + // Gallery is seeded from the pushing context; preview URLs are fetched on demand. + state.databaseLoadingState = .idle return .merge( - .run { [state] send in - guard let dbState = await databaseClient.fetchGalleryState( - gid: state.gallery.id - ) else { return } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: CancelID.fetchDatabaseInfos), .send(.observeDownloads(gid)), .send(.loadLocalPreviewURLs(gid)) ) - case .fetchDatabaseInfosDone(let galleryState): - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - state.previewURLs = galleryState.previewURLs - state.databaseLoadingState = .idle - return .none - case .observeDownloads(let gid): guard gid.isValidGID else { return .none } return .run { send in diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift index e7fc3deab..77b2f9b7a 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift @@ -74,7 +74,7 @@ struct PreviewsView: View { .autoBlur(radius: blurRadius) } .onAppear { - store.send(.fetchDatabaseInfos(gid)) + store.send(.onAppear(gid)) } .navigationTitle(.previews) } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 0771b5105..7eb800e81 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -12,7 +12,7 @@ import DetailFeature @Reducer public struct DownloadsReducer: Sendable { public enum Delegate: Equatable, Sendable { - case presentGalleryDetail(String, DownloadedGallery?) + case presentGalleryDetail(Gallery, DownloadedGallery?) } @Reducer @@ -115,7 +115,7 @@ public struct DownloadsReducer: Sendable { let download = state.downloads.first(where: { $0.gid == gid }) return GalleryNavigation.routeGalleryDetail( isPad: deviceClient.isPad, - present: { .delegate(.presentGalleryDetail(gid, download)) }, + present: { .delegate(.presentGalleryDetail(download?.gallery ?? .empty, download)) }, push: { .pushGalleryDetail(gid) } ) diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index 7ce7dc41d..2e7ff2b9f 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -15,7 +15,7 @@ import DetailFeature @Reducer public struct FavoritesReducer: Sendable { public enum Delegate: Equatable, Sendable { - case presentGalleryDetail(String) + case presentGalleryDetail(Gallery) } private enum CancelID { @@ -75,8 +75,8 @@ public struct FavoritesReducer: Sendable { case binding(BindingAction) case onAppear case delegate(Delegate) - case galleryTapped(String) - case pushGalleryDetail(String) + case galleryTapped(Gallery) + case pushGalleryDetail(Gallery) case path(StackActionOf) case setFavoritesIndex(Int) case quickSearchButtonTapped @@ -111,15 +111,15 @@ public struct FavoritesReducer: Sendable { case .onAppear: return .send(.observeDownloads) - case .galleryTapped(let gid): + case .galleryTapped(let gallery): return GalleryNavigation.routeGalleryDetail( isPad: deviceClient.isPad, - present: { .delegate(.presentGalleryDetail(gid)) }, - push: { .pushGalleryDetail(gid) } + present: { .delegate(.presentGalleryDetail(gallery)) }, + push: { .pushGalleryDetail(gallery) } ) - case .pushGalleryDetail(let gid): - state.path.appendGuardingDuplicate(.detail(.init(gid: gid))) + case .pushGalleryDetail(let gallery): + state.path.appendGuardingDuplicate(.detail(.init(gallery: gallery))) return .none case .delegate: diff --git a/AppPackage/Sources/GalleryListComponents/GenericList.swift b/AppPackage/Sources/GalleryListComponents/GenericList.swift index b3a7c9132..4b4b8e5aa 100644 --- a/AppPackage/Sources/GalleryListComponents/GenericList.swift +++ b/AppPackage/Sources/GalleryListComponents/GenericList.swift @@ -14,7 +14,7 @@ public struct GenericList: View { private let footerLoadingState: LoadingState private let fetchAction: (() -> Void)? private let fetchMoreAction: (() -> Void)? - private let navigateAction: ((String) -> Void)? + private let navigateAction: ((Gallery) -> Void)? private let translateAction: ((String) -> (String, TagTranslation?))? public init( @@ -22,7 +22,7 @@ public struct GenericList: View { loadingState: LoadingState, footerLoadingState: LoadingState, fetchAction: (() -> Void)? = nil, fetchMoreAction: (() -> Void)? = nil, - navigateAction: ((String) -> Void)? = nil, + navigateAction: ((Gallery) -> Void)? = nil, translateAction: ((String) -> (String, TagTranslation?))? = nil, downloadBadges: [String: DownloadBadge] = [:] ) { @@ -81,14 +81,14 @@ private struct DetailList: View { private let pageNumber: PageNumber? private let footerLoadingState: LoadingState private let fetchMoreAction: (() -> Void)? - private let navigateAction: ((String) -> Void)? + private let navigateAction: ((Gallery) -> Void)? private let translateAction: ((String) -> (String, TagTranslation?))? init( galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, footerLoadingState: LoadingState, fetchMoreAction: (() -> Void)?, - navigateAction: ((String) -> Void)? = nil, + navigateAction: ((Gallery) -> Void)? = nil, translateAction: ((String) -> (String, TagTranslation?))? = nil, downloadBadges: [String: DownloadBadge] = [:] ) { @@ -115,7 +115,7 @@ private struct DetailList: View { var body: some View { List(galleries) { gallery in Button { - navigateAction?(gallery.id) + navigateAction?(gallery) } label: { GalleryDetailCell( gallery: gallery, @@ -145,7 +145,7 @@ private struct WaterfallList: View { private let pageNumber: PageNumber? private let footerLoadingState: LoadingState private let fetchMoreAction: (() -> Void)? - private let navigateAction: ((String) -> Void)? + private let navigateAction: ((Gallery) -> Void)? private let translateAction: ((String) -> (String, TagTranslation?))? private var columnsInPortrait: Int { @@ -168,7 +168,7 @@ private struct WaterfallList: View { galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, footerLoadingState: LoadingState, fetchMoreAction: (() -> Void)?, - navigateAction: ((String) -> Void)? = nil, + navigateAction: ((Gallery) -> Void)? = nil, translateAction: ((String) -> (String, TagTranslation?))? = nil, downloadBadges: [String: DownloadBadge] = [:] ) { @@ -186,7 +186,7 @@ private struct WaterfallList: View { List { WaterfallGrid(galleries) { gallery in Button { - navigateAction?(gallery.id) + navigateAction?(gallery) } label: { GalleryThumbnailCell( gallery: gallery, diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index 4bbfa143e..026f066ba 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -12,7 +12,7 @@ import DateSeekFeature @Reducer public struct FrontpageReducer: Sendable { public enum Delegate: Equatable, Sendable { - case pushDetail(String) + case pushDetail(Gallery) } @Reducer diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index 560b7b1c6..01b75cf9e 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -16,7 +16,7 @@ public struct HistoryReducer: Sendable { } public enum Delegate: Equatable, Sendable { - case pushDetail(String) + case pushDetail(Gallery) } public enum Dialog: Equatable, Sendable { diff --git a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift index 56eaa5748..6ece7ead2 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift @@ -25,20 +25,20 @@ extension HomeReducer { case .binding: return .none - case .galleryTapped(let gid), - let .path(.element(id: _, action: .frontpage(.delegate(.pushDetail(gid))))), - let .path(.element(id: _, action: .popular(.delegate(.pushDetail(gid))))), - let .path(.element(id: _, action: .toplists(.delegate(.pushDetail(gid))))), - let .path(.element(id: _, action: .watched(.delegate(.pushDetail(gid))))), - let .path(.element(id: _, action: .history(.delegate(.pushDetail(gid))))): + case .galleryTapped(let gallery), + let .path(.element(id: _, action: .frontpage(.delegate(.pushDetail(gallery))))), + let .path(.element(id: _, action: .popular(.delegate(.pushDetail(gallery))))), + let .path(.element(id: _, action: .toplists(.delegate(.pushDetail(gallery))))), + let .path(.element(id: _, action: .watched(.delegate(.pushDetail(gallery))))), + let .path(.element(id: _, action: .history(.delegate(.pushDetail(gallery))))): return GalleryNavigation.routeGalleryDetail( isPad: deviceClient.isPad, - present: { .delegate(.presentGalleryDetail(gid)) }, - push: { .pushGalleryDetail(gid) } + present: { .delegate(.presentGalleryDetail(gallery)) }, + push: { .pushGalleryDetail(gallery) } ) - case .pushGalleryDetail(let gid): - state.path.appendGuardingDuplicate(.gallery(.detail(.init(gid: gid)))) + case .pushGalleryDetail(let gallery): + state.path.appendGuardingDuplicate(.gallery(.detail(.init(gallery: gallery)))) return .none case .delegate: diff --git a/AppPackage/Sources/HomeFeature/HomeReducer.swift b/AppPackage/Sources/HomeFeature/HomeReducer.swift index a9701a497..20fb3fb6b 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer.swift @@ -10,7 +10,7 @@ import DeviceClient @Reducer public struct HomeReducer: Sendable { public enum Delegate: Equatable, Sendable { - case presentGalleryDetail(String) + case presentGalleryDetail(Gallery) } @ObservableState @@ -57,8 +57,8 @@ public struct HomeReducer: Sendable { public enum Action: BindableAction { case binding(BindingAction) case delegate(Delegate) - case galleryTapped(String) - case pushGalleryDetail(String) + case galleryTapped(Gallery) + case pushGalleryDetail(Gallery) case sectionTapped(HomeSectionType) case miscTapped(HomeMiscGridType) case path(StackActionOf) diff --git a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift index fcd42cb87..b267ffabf 100644 --- a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift +++ b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift @@ -15,13 +15,13 @@ struct CardSlideSection: View, Equatable { private let galleries: [Gallery] private let currentID: String private let colors: [Color] - private let navigateAction: (String) -> Void + private let navigateAction: (Gallery) -> Void private let webImageSuccessAction: (String, RetrieveImageResult) -> Void init( galleries: [Gallery], pageIndex: Binding, currentID: String, colors: [Color], - navigateAction: @escaping (String) -> Void, + navigateAction: @escaping (Gallery) -> Void, webImageSuccessAction: @escaping (String, RetrieveImageResult) -> Void ) { self.galleries = galleries @@ -41,7 +41,7 @@ struct CardSlideSection: View, Equatable { var body: some View { Pager(page: page, data: galleries) { gallery in Button { - navigateAction(gallery.id) + navigateAction(gallery) } label: { GalleryCardCell( gallery: gallery, @@ -67,13 +67,13 @@ struct CardSlideSection: View, Equatable { struct CoverWallSection: View { private let galleries: [Gallery] private let isLoading: Bool - private let navigateAction: (String) -> Void + private let navigateAction: (Gallery) -> Void private let showAllAction: () -> Void private let reloadAction: () -> Void init( galleries: [Gallery], isLoading: Bool, - navigateAction: @escaping (String) -> Void, + navigateAction: @escaping (Gallery) -> Void, showAllAction: @escaping () -> Void, reloadAction: @escaping () -> Void ) { @@ -117,9 +117,9 @@ struct CoverWallSection: View { struct VerticalCoverStack: View { private let galleries: [Gallery] - private let navigateAction: (String) -> Void + private let navigateAction: (Gallery) -> Void - init(galleries: [Gallery], navigateAction: @escaping (String) -> Void) { + init(galleries: [Gallery], navigateAction: @escaping (Gallery) -> Void) { self.galleries = galleries self.navigateAction = navigateAction } @@ -129,7 +129,7 @@ struct VerticalCoverStack: View { } private func imageContainer(gallery: Gallery) -> some View { Button { - navigateAction(gallery.id) + navigateAction(gallery) } label: { KFImage(gallery.coverURL) .placeholder(placeholder) @@ -150,13 +150,13 @@ struct VerticalCoverStack: View { struct ToplistsSection: View { private let galleries: [Int: [Gallery]] private let isLoading: Bool - private let navigateAction: (String) -> Void + private let navigateAction: (Gallery) -> Void private let showAllAction: () -> Void private let reloadAction: () -> Void init( galleries: [Int: [Gallery]], isLoading: Bool, - navigateAction: @escaping (String) -> Void, + navigateAction: @escaping (Gallery) -> Void, showAllAction: @escaping () -> Void, reloadAction: @escaping () -> Void ) { @@ -225,12 +225,12 @@ struct ToplistsSection: View { struct VerticalToplistsStack: View { private let galleries: [Gallery] private let startRanking: Int - private let navigateAction: (String) -> Void + private let navigateAction: (Gallery) -> Void init( galleries: [Gallery], startRanking: Int, - navigateAction: @escaping (String) -> Void + navigateAction: @escaping (Gallery) -> Void ) { self.galleries = galleries self.startRanking = startRanking @@ -242,7 +242,7 @@ struct VerticalToplistsStack: View { ForEach(0.. Effect { if case .local(let download, let manifest) = state.contentSource { applyLocalSource(state: &state, download: download, manifest: manifest) - } else { - guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery - state.language = databaseClient.fetchGalleryDetail(gid: state.gallery.id)?.language } - // Resume position comes from the persisted browsing history, keyed by gid. + // Remote galleries are seeded from the pushing context; URL maps are rebuilt per session + // (fetched on demand), so nothing is read from a database here. The resume position comes + // from the persisted browsing history. @Shared(.galleryHistory) var galleryHistory - state.readingProgress = galleryHistory.readingProgress(gid: state.gallery.id) - return .run { [state] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: state.gallery.id) else { return } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: ReadingCancelID.fetchDatabaseInfos) - } - - func reduceFetchDatabaseInfosDone(state: inout State, galleryState: GalleryState) -> Effect { - if state.contentSource == .remote { - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - state.previewURLs = galleryState.previewURLs - state.imageURLs = galleryState.imageURLs - state.thumbnailURLs = galleryState.thumbnailURLs - state.originalImageURLs = galleryState.originalImageURLs - } + state.readingProgress = galleryHistory.readingProgress(gid: gid) state.databaseLoadingState = .idle return .none } diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 8378804a0..a1e126b94 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -165,7 +165,6 @@ public struct ReadingReducer: Sendable { case syncImageURLs([Int: URL], [Int: URL]) case fetchDatabaseInfos(String) - case fetchDatabaseInfosDone(GalleryState) case observeDownloads(String) case observeDownloadsDone([DownloadedGallery]) case loadLocalPageURLs(String) diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index d1f6fddc5..2f4d4cb72 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -14,7 +14,7 @@ import QuickSearchFeature @Reducer public struct SearchReducer: Sendable { public enum Delegate: Equatable, Sendable { - case pushDetail(String) + case pushDetail(Gallery) case searchPerformed(String) } diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift index d51ce80d1..8090bfb2f 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -12,7 +12,7 @@ import DetailFeature @Reducer public struct SearchRootReducer: Sendable { public enum Delegate: Equatable, Sendable { - case presentGalleryDetail(String) + case presentGalleryDetail(Gallery) } @Reducer @@ -70,8 +70,8 @@ public struct SearchRootReducer: Sendable { case binding(BindingAction) case delegate(Delegate) case pushSearch - case galleryTapped(String) - case pushGalleryDetail(String) + case galleryTapped(Gallery) + case pushGalleryDetail(Gallery) case path(StackActionOf) case setKeyword(String) case filtersButtonTapped @@ -101,16 +101,16 @@ public struct SearchRootReducer: Sendable { state.path.appendGuardingDuplicate(.search(.init(keyword: state.keyword))) return .none - case .galleryTapped(let gid), - let .path(.element(id: _, action: .search(.delegate(.pushDetail(gid))))): + case .galleryTapped(let gallery), + let .path(.element(id: _, action: .search(.delegate(.pushDetail(gallery))))): return GalleryNavigation.routeGalleryDetail( isPad: deviceClient.isPad, - present: { .delegate(.presentGalleryDetail(gid)) }, - push: { .pushGalleryDetail(gid) } + present: { .delegate(.presentGalleryDetail(gallery)) }, + push: { .pushGalleryDetail(gallery) } ) - case .pushGalleryDetail(let gid): - state.path.appendGuardingDuplicate(.gallery(.detail(.init(gid: gid)))) + case .pushGalleryDetail(let gallery): + state.path.appendGuardingDuplicate(.gallery(.detail(.init(gallery: gallery)))) return .none case .delegate: diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index e4093b644..129d3660b 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -121,7 +121,7 @@ private struct SuggestionsPanel: View { private let historyKeywords: [String] private let historyGalleries: [Gallery] private let quickSearchWords: [QuickSearchWord] - private let navigateGalleryAction: (String) -> Void + private let navigateGalleryAction: (Gallery) -> Void private let navigateQuickSearchAction: () -> Void private let searchKeywordAction: (String) -> Void private let removeKeywordAction: (String) -> Void @@ -129,7 +129,7 @@ private struct SuggestionsPanel: View { init( historyKeywords: [String], historyGalleries: [Gallery], quickSearchWords: [QuickSearchWord], - navigateGalleryAction: @escaping (String) -> Void, + navigateGalleryAction: @escaping (Gallery) -> Void, navigateQuickSearchAction: @escaping () -> Void, searchKeywordAction: @escaping (String) -> Void, removeKeywordAction: @escaping (String) -> Void @@ -238,9 +238,9 @@ private struct HistoryKeywordsSection: View { // MARK: HistoryGalleriesSection private struct HistoryGalleriesSection: View { private let galleries: [Gallery] - private let navigationAction: (String) -> Void + private let navigationAction: (Gallery) -> Void - init(galleries: [Gallery], navigationAction: @escaping (String) -> Void) { + init(galleries: [Gallery], navigationAction: @escaping (Gallery) -> Void) { self.galleries = galleries self.navigationAction = navigationAction } @@ -251,7 +251,7 @@ private struct HistoryGalleriesSection: View { HStack { ForEach(galleries) { gallery in Button { - navigationAction(gallery.id) + navigationAction(gallery) } label: { GalleryHistoryCell(gallery: gallery) .tint(.primary).multilineTextAlignment(.leading) From 2b381ca046d838033827a136ea42665bdf85d74a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 20:13:26 +0800 Subject: [PATCH 523/614] Stop caching gallery data in the database --- .../DataFlow/AppDelegateReducer.swift | 4 --- .../AppFeature/DataFlow/AppReducer.swift | 1 - .../AppFeature/DataFlow/AppRouteReducer.swift | 5 +--- .../Comments/CommentsReducer.swift | 5 +--- .../DetailFeature/DetailReducer+Actions.swift | 25 ------------------- .../DetailFeature/DetailReducer+Fetch.swift | 7 ------ .../Sources/DetailFeature/DetailReducer.swift | 5 ---- .../DetailSearch/DetailSearchReducer.swift | 6 ++--- .../Previews/PreviewsReducer.swift | 8 +----- .../FavoritesFeature/FavoritesReducer.swift | 8 +++--- .../Frontpage/FrontpageReducer.swift | 8 +++--- .../HomeFeature/HomeReducer+Body.swift | 6 ++--- .../HomeFeature/Popular/PopularReducer.swift | 2 +- .../Toplists/ToplistsReducer.swift | 6 ++--- .../HomeFeature/Watched/WatchedReducer.swift | 8 +++--- .../ReadingFeature/ReadingReducer+Body.swift | 5 ++-- .../ReadingReducer+Database.swift | 22 ---------------- .../ReadingReducer+ImageFetch.swift | 14 ++++------- .../ReadingFeature/ReadingReducer.swift | 3 --- .../Sources/SearchFeature/SearchReducer.swift | 8 +++--- .../GeneralSettingReducer.swift | 6 +---- .../SettingFeature/SettingReducer+Body.swift | 1 - .../DownloadAutomationTests.swift | 1 - 23 files changed, 31 insertions(+), 133 deletions(-) diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index b9456057c..686f0ee79 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -24,7 +24,6 @@ struct AppDelegateReducer { enum Action: Equatable { case onLaunchFinish - case removeExpiredImageURLs case migration(MigrationReducer.Action) } @@ -49,9 +48,6 @@ struct AppDelegateReducer { .send(.migration(.prepareDatabase)) ) - case .removeExpiredImageURLs: - return .run(operation: { _ in await databaseClient.removeExpiredImageURLs() }) - case .migration: return .none } diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index b15003d87..2e4b336b6 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -161,7 +161,6 @@ struct AppReducer { igneous: loginCookies.igneous ) } - await send(.appDelegate(.removeExpiredImageURLs)) await send(.setting(.loadUserSettings)) } diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 2fc4223ca..ec70c664e 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -198,10 +198,7 @@ struct AppRouteReducer { state.toast = nil switch result { case .success(let gallery): - return .run { send in - await databaseClient.cacheGalleries([gallery]) - await send(.handleGalleryLink(url, gallery)) - } + return .send(.handleGalleryLink(url, gallery)) case .failure: // Let the loading toast animate out before showing the error toast. return .run { send in diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index 23f77604a..e4e3b2b31 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -261,10 +261,7 @@ public struct CommentsReducer: Sendable { state.toast = nil switch result { case .success(let gallery): - return .merge( - .run(operation: { _ in await databaseClient.cacheGalleries([gallery]) }), - .send(.handleGalleryLink(url, gallery)) - ) + return .send(.handleGalleryLink(url, gallery)) case .failure: // Let the loading toast animate out before showing the error toast. return .run { send in diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index 6836b5588..67871eb64 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -139,26 +139,6 @@ extension DetailReducer { var syncReducer: some ReducerOf { Reduce { state, action in switch action { - case .syncGalleryTags: - return .run { [gid = state.gallery.id, tags = state.galleryTags] _ in - await databaseClient.updateGalleryTags(gid: gid, tags: tags) - } - - case .syncGalleryDetail: - guard let detail = state.galleryDetail else { return .none } - return .run(operation: { _ in await databaseClient.cacheGalleryDetail(detail) }) - - case .syncGalleryPreviewURLs: - return .run { [gid = state.gallery.id, previewURLs = state.galleryPreviewURLs] _ in - await databaseClient - .updatePreviewURLs(gid: gid, previewURLs: previewURLs) - } - - case .syncGalleryComments: - return .run { [gid = state.gallery.id, comments = state.galleryComments] _ in - await databaseClient.updateComments(gid: gid, comments: comments) - } - case .syncGreeting(let greeting): // Greeting is session-only (not persisted with `User`); write it to the shared // in-memory user so the greeting-fetch throttle stays coherent across features. @@ -166,11 +146,6 @@ extension DetailReducer { $user.withLock { $0.greeting = greeting } return .none - case .syncPreviewConfig(let config): - return .run { [gid = state.gallery.id] _ in - await databaseClient.updatePreviewConfig(gid: gid, config: config) - } - case .saveGalleryHistory: @Shared(.galleryHistory) var galleryHistory $galleryHistory.withLock { diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift index 82bf80956..4ecd3d8dd 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift @@ -26,10 +26,6 @@ extension DetailReducer { switch result { case .success(let response): var effects: [Effect] = [ - .send(.syncGalleryTags), - .send(.syncGalleryDetail), - .send(.syncGalleryPreviewURLs), - .send(.syncGalleryComments), .send(.fetchDownloadBadge) ] state.apiKey = response.apiKey @@ -50,9 +46,6 @@ extension DetailReducer { effects.append(.send(.presentNewDawn(greeting))) } } - if let config = response.galleryState.previewConfig { - effects.append(.send(.syncPreviewConfig(config))) - } if let deepLink = state.pendingDeepLink { state.pendingDeepLink = nil switch deepLink { diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index e3d00fea5..0b2d72117 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -143,12 +143,7 @@ public struct DetailReducer: Sendable { case updateRating(DragGesture.Value) case confirmRating(DragGesture.Value) case confirmRatingDone - case syncGalleryTags - case syncGalleryDetail - case syncGalleryPreviewURLs - case syncGalleryComments case syncGreeting(Greeting) - case syncPreviewConfig(PreviewConfig) case saveGalleryHistory case updateReadingProgress(Int) case fetchDownloadBadge diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index 0e8a56a86..e0dd6030c 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -122,7 +122,7 @@ public struct DetailSearchReducer: Sendable { } state.pageNumber = pageNumber state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.loadingState = .failed(error) } @@ -153,9 +153,7 @@ public struct DetailSearchReducer: Sendable { state.pageNumber = pageNumber state.insertGalleries(galleries) - var effects: [Effect] = [ - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - ] + var effects: [Effect] = [] if galleries.isEmpty, pageNumber.hasNextPage() { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index f097afa5b..339b1d434 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -53,7 +53,6 @@ public struct PreviewsReducer: Sendable { case binding(BindingAction) case destination(PresentationAction) - case syncPreviewURLs([Int: URL]) case updateReadingProgress(Int) case onAppear(String) @@ -88,11 +87,6 @@ public struct PreviewsReducer: Sendable { case .destination: return .none - case .syncPreviewURLs(let previewURLs): - return .run { [state] _ in - await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) - } - case .updateReadingProgress(let progress): @Shared(.galleryHistory) var galleryHistory $galleryHistory.withLock { @@ -198,7 +192,7 @@ public struct PreviewsReducer: Sendable { return .none } state.updatePreviewURLs(previewURLs) - return .send(.syncPreviewURLs(previewURLs)) + return .none case .failure(let error): state.loadingState = .failed(error) } diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index 2e7ff2b9f..cd64891d8 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -188,7 +188,7 @@ public struct FavoritesReducer: Sendable { state.rawDateSeekNavigation[targetFavIndex] = fetchResult.dateSeekNavigation state.rawGalleries[targetFavIndex] = galleries state.sortOrder = fetchResult.sortOrder - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.rawLoadingState[targetFavIndex] = .failed(error) } @@ -224,9 +224,7 @@ public struct FavoritesReducer: Sendable { state.insertGalleries(index: targetFavIndex, galleries: galleries) state.sortOrder = fetchResult.sortOrder - var effects: [Effect] = [ - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - ] + var effects: [Effect] = [] if galleries.isEmpty, pageNumber.hasNextPage() { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { @@ -275,7 +273,7 @@ public struct FavoritesReducer: Sendable { state.rawPageNumber[targetFavIndex] = response.pageNumber state.rawDateSeekNavigation[targetFavIndex] = response.dateSeekNavigation state.rawGalleries[targetFavIndex] = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.rawLoadingState[targetFavIndex] = .failed(error) } diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index 026f066ba..2d3f09fc9 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -114,7 +114,7 @@ public struct FrontpageReducer: Sendable { state.pageNumber = response.pageNumber state.dateSeekNavigation = response.dateSeekNavigation state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.loadingState = .failed(error) } @@ -144,9 +144,7 @@ public struct FrontpageReducer: Sendable { state.dateSeekNavigation = response.dateSeekNavigation state.insertGalleries(galleries) - var effects: [Effect] = [ - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - ] + var effects: [Effect] = [] if galleries.isEmpty, response.pageNumber.hasNextPage() { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { @@ -182,7 +180,7 @@ public struct FrontpageReducer: Sendable { state.pageNumber = response.pageNumber state.dateSeekNavigation = response.dateSeekNavigation state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.loadingState = .failed(error) } diff --git a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift index 6ece7ead2..658a4f83c 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift @@ -115,7 +115,7 @@ extension HomeReducer { return .none } state.setPopularGalleries(galleries) - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.popularLoadingState = .failed(error) } @@ -140,7 +140,7 @@ extension HomeReducer { return .none } state.setFrontpageGalleries(galleries) - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.frontpageLoadingState = .failed(error) } @@ -163,7 +163,7 @@ extension HomeReducer { return .none } state.toplistsGalleries[index] = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.toplistsLoadingState[index] = .failed(error) } diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index fb39b2fab..54eb75796 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -90,7 +90,7 @@ public struct PopularReducer: Sendable { return .none } state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.loadingState = .failed(error) } diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index 38f33f318..22a306089 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -161,7 +161,7 @@ public struct ToplistsReducer: Sendable { } state.rawPageNumber[type] = pageNumber state.rawGalleries[type] = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.rawLoadingState[type] = .failed(error) } @@ -190,9 +190,7 @@ public struct ToplistsReducer: Sendable { state.rawPageNumber[type] = pageNumber state.insertGalleries(type: type, galleries: galleries) - var effects: [Effect] = [ - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - ] + var effects: [Effect] = [] if galleries.isEmpty, pageNumber.hasNextPage() { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index 21f60ec22..af2c977b9 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -132,7 +132,7 @@ public struct WatchedReducer: Sendable { state.pageNumber = response.pageNumber state.dateSeekNavigation = response.dateSeekNavigation state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.loadingState = .failed(error) } @@ -165,9 +165,7 @@ public struct WatchedReducer: Sendable { state.dateSeekNavigation = response.dateSeekNavigation state.insertGalleries(galleries) - var effects: [Effect] = [ - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - ] + var effects: [Effect] = [] if galleries.isEmpty, response.pageNumber.hasNextPage() { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { @@ -217,7 +215,7 @@ public struct WatchedReducer: Sendable { state.pageNumber = response.pageNumber state.dateSeekNavigation = response.dateSeekNavigation state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.loadingState = .failed(error) } diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index 85694c8f9..1dfc89891 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -139,9 +139,8 @@ extension ReadingReducer { state.mpvImageKeys = .init() state.mpvSkipServerIdentifiers = .init() state.forceRefreshID = .init() - return .run { [state] _ in - await databaseClient.removeImageURLs(gid: state.gallery.id) - } + // URL maps live only in reducer state now; clearing them above is the whole reset. + return .none case .retryAllFailedWebImages: guard !state.isOffline else { return .none } diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift index 222a62901..b5248f92c 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift @@ -18,28 +18,6 @@ extension ReadingReducer { } return .none - case .syncPreviewURLs(let previewURLs): - guard !state.isOffline else { return .none } - return .run { [state] _ in - await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) - } - - case .syncThumbnailURLs(let thumbnailURLs): - guard !state.isOffline else { return .none } - return .run { [state] _ in - await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) - } - - case .syncImageURLs(let imageURLs, let originalImageURLs): - guard !state.isOffline else { return .none } - return .run { [state] _ in - await databaseClient.updateImageURLs( - gid: state.gallery.id, - imageURLs: imageURLs, - originalImageURLs: originalImageURLs - ) - } - case .fetchDatabaseInfos(let gid): return reduceFetchDatabaseInfos(state: &state, gid: gid) diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift index d0e1dcf93..b62cf1b7a 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift @@ -33,7 +33,7 @@ extension ReadingReducer { } state.previewLoadingStates[index] = .idle state.updatePreviewURLs(previewURLs) - return .send(.syncPreviewURLs(previewURLs)) + return .none case .failure(let error): state.previewLoadingStates[index] = .failed(error) } @@ -151,10 +151,7 @@ extension ReadingReducer { return .send(.fetchMPVKeys(index, url)) } else { state.updateThumbnailURLs(thumbnailURLs) - return .merge( - .send(.syncThumbnailURLs(thumbnailURLs)), - .send(.fetchNormalImageURLs(index, thumbnailURLs)) - ) + return .send(.fetchNormalImageURLs(index, thumbnailURLs)) } case .failure(let error): batchRange.forEach { @@ -188,7 +185,7 @@ extension ReadingReducer { state.imageURLLoadingStates[$0] = .idle } state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) + return .none case .failure(let error): batchRange.forEach { state.imageURLLoadingStates[$0] = .failed(error) @@ -233,8 +230,7 @@ extension ReadingReducer { } state.imageURLLoadingStates[index] = .idle state.updateImageURLs(imageURLs, [:]) - effects.append(.send(.syncImageURLs(imageURLs, [:]))) - return .merge(effects) + return effects.isEmpty ? .none : .merge(effects) case .failure(let error): state.imageURLLoadingStates[index] = .failed(error) } @@ -314,7 +310,7 @@ extension ReadingReducer { state.imageURLLoadingStates[index] = .idle state.mpvSkipServerIdentifiers[index] = mpvResult.skipServerIdentifier state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) + return .none case .failure(let error): state.imageURLLoadingStates[index] = .failed(error) } diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index a1e126b94..e8c9bfd82 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -160,9 +160,6 @@ public struct ReadingReducer: Sendable { case fetchImageDone(ImageAction, Result) case syncReadingProgress(Int) - case syncPreviewURLs([Int: URL]) - case syncThumbnailURLs([Int: URL]) - case syncImageURLs([Int: URL], [Int: URL]) case fetchDatabaseInfos(String) case observeDownloads(String) diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index 2f4d4cb72..708d11496 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -148,7 +148,7 @@ public struct SearchReducer: Sendable { state.pageNumber = response.pageNumber state.dateSeekNavigation = response.dateSeekNavigation state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.loadingState = .failed(error) } @@ -181,9 +181,7 @@ public struct SearchReducer: Sendable { state.dateSeekNavigation = response.dateSeekNavigation state.insertGalleries(galleries) - var effects: [Effect] = [ - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - ] + var effects: [Effect] = [] if galleries.isEmpty, response.pageNumber.hasNextPage() { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { @@ -233,7 +231,7 @@ public struct SearchReducer: Sendable { state.pageNumber = response.pageNumber state.dateSeekNavigation = response.dateSeekNavigation state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.loadingState = .failed(error) } diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index 091e49b43..ca4a665fb 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -136,11 +136,7 @@ public struct GeneralSettingReducer: Sendable { case .clearWebImageCache: return .run { send in - async let removeCachedImages: Void = - libraryClient.removeAllCachedImages() - async let removeImageURLs: Void = - databaseClient.removeImageURLs() - _ = await (removeCachedImages, removeImageURLs) + await libraryClient.removeAllCachedImages() logger.notice("Cleared image cache.") await send(.calculateWebImageDiskCache) } diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index e07dbaa11..d77efe156 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -266,7 +266,6 @@ extension SettingReducer { state.$user.withLock { $0 = User() } return .merge( .run(operation: { _ in cookieClient.clearAll() }), - .run(operation: { _ in await databaseClient.removeImageURLs() }), .run(operation: { _ in await libraryClient.removeAllCachedImages() }), .run { _ in logger.notice("Logged out.") } ) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift index f4c6d9514..c55e5d75a 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift @@ -207,7 +207,6 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { store.exhaustivity = .off await store.send(.appDelegate(.migration(.onDatabasePreparationSuccess))) - await store.receive(\.appDelegate.removeExpiredImageURLs) #expect(cookieClient.shouldFetchIgneous) await store.receive(\.setting.loadUserSettings) } From e9c29a8dc4d8a79e527f45c8b03458da46b98d41 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 21:28:31 +0800 Subject: [PATCH 524/614] Remove MigrationFeature launch gating --- AppPackage/Package.swift | 14 -- .../DataFlow/AppDelegateReducer.swift | 17 +-- .../AppFeature/DataFlow/AppReducer.swift | 26 ++-- AppPackage/Sources/AppFeature/RootView.swift | 18 +-- .../Sources/MigrationFeature/.swiftlint.yml | 1 - .../MigrationFeature/MigrationReducer.swift | 116 ---------------- .../MigrationFeature/MigrationView.swift | 46 ------- .../Resources/Localizable.xcstrings | 129 ------------------ .../DownloadAutomationTests.swift | 15 +- 9 files changed, 30 insertions(+), 352 deletions(-) delete mode 100644 AppPackage/Sources/MigrationFeature/.swiftlint.yml delete mode 100644 AppPackage/Sources/MigrationFeature/MigrationReducer.swift delete mode 100644 AppPackage/Sources/MigrationFeature/MigrationView.swift delete mode 100644 AppPackage/Sources/MigrationFeature/Resources/Localizable.xcstrings diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index adb9eda16..259709512 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -89,7 +89,6 @@ enum Module: String { case imageClient = "ImageClient" case libraryClient = "LibraryClient" case logsClient = "LogsClient" - case migrationFeature = "MigrationFeature" case networkingFeature = "NetworkingFeature" case openCCExt = "OpenCCExt" case osLogExt = "OSLogExt" @@ -272,7 +271,6 @@ let targets: [PackageDescription.Target] = [ .module(.homeFeature), .module(.imageClient), .module(.libraryClient), - .module(.migrationFeature), .module(.networkingFeature), .module(.osLogExt), .module(.parserFeature), @@ -548,18 +546,6 @@ let targets: [PackageDescription.Target] = [ ], plugins: swiftLintPlugins ), - .target( - module: .migrationFeature, - dependencies: [ - .module(.appComponents), - .module(.appModels), - .module(.databaseClient), - .module(.resources), - .targetDependency(.composableArchitecture) - ], - resources: [.process(.resources)], - plugins: swiftLintPlugins - ), .target( module: .filtersFeature, dependencies: [ diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index 686f0ee79..88ae655d7 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -6,11 +6,9 @@ import ComposableArchitecture import AppTools import AppDelegateClient import LibraryClient -import DatabaseClient import DownloadClient import BackgroundProcessingClient import CookieClient -import MigrationFeature import OSLogExt private let logger = Logger(category: .init(describing: AppDelegateReducer.self)) @@ -18,17 +16,12 @@ private let logger = Logger(category: .init(describing: AppDelegateReducer.self) @Reducer struct AppDelegateReducer { @ObservableState - struct State: Equatable { - var migrationState = MigrationReducer.State() - } + struct State: Equatable {} enum Action: Equatable { case onLaunchFinish - - case migration(MigrationReducer.Action) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.libraryClient) private var libraryClient @Dependency(\.cookieClient) private var cookieClient @@ -44,16 +37,10 @@ struct AppDelegateReducer { .run(operation: { _ in cookieClient.removeYay() }), .run(operation: { _ in cookieClient.syncExCookies() }), .run(operation: { _ in cookieClient.ignoreOffensive() }), - .run(operation: { _ in cookieClient.fulfillAnotherHostField() }), - .send(.migration(.prepareDatabase)) + .run(operation: { _ in cookieClient.fulfillAnotherHostField() }) ) - - case .migration: - return .none } } - - Scope(state: \.migrationState, action: \.migration, child: MigrationReducer.init) } } diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 2e4b336b6..16484767f 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -149,20 +149,22 @@ struct AppReducer { } case .appDelegate(.onLaunchFinish): - return .send(.appLogsPump(.startPump)) - - case .appDelegate(.migration(.onDatabasePreparationSuccess)): + // No database preparation to await anymore: import any launch-automation cookies and + // load the persisted settings straight away. let loginCookies = appLaunchAutomationClient.current()?.loginCookies - return .run { send in - if let loginCookies { - cookieClient.importAutomationCookies( - memberID: loginCookies.memberID, - passHash: loginCookies.passHash, - igneous: loginCookies.igneous - ) + return .merge( + .send(.appLogsPump(.startPump)), + .run { send in + if let loginCookies { + cookieClient.importAutomationCookies( + memberID: loginCookies.memberID, + passHash: loginCookies.passHash, + igneous: loginCookies.igneous + ) + } + await send(.setting(.loadUserSettings)) } - await send(.setting(.loadUserSettings)) - } + ) case .appDelegate: return .none diff --git a/AppPackage/Sources/AppFeature/RootView.swift b/AppPackage/Sources/AppFeature/RootView.swift index be1b488bb..d9710cf70 100644 --- a/AppPackage/Sources/AppFeature/RootView.swift +++ b/AppPackage/Sources/AppFeature/RootView.swift @@ -2,7 +2,6 @@ import ComposableArchitecture import SwiftUI import UIKit import AppTools -import MigrationFeature // MARK: RootView public struct RootView: View { @@ -13,21 +12,8 @@ public struct RootView: View { } public var body: some View { - ZStack { - let databaseState = appDelegate.store.appDelegateState.migrationState.databaseState - - if databaseState == .idle { - TabBarView(store: appDelegate.store).onAppear(perform: addTouchHandler).accentColor(.primary) - } - MigrationView( - store: appDelegate.store.scope( - state: \.appDelegateState.migrationState, - action: \.appDelegate.migration - ) - ) - .opacity(databaseState != .idle ? 1 : 0) - .animation(.linear(duration: 0.5), value: databaseState) - } + // No database to prepare anymore: the tab bar is the root view from launch. + TabBarView(store: appDelegate.store).onAppear(perform: addTouchHandler).accentColor(.primary) } private func addTouchHandler() { diff --git a/AppPackage/Sources/MigrationFeature/.swiftlint.yml b/AppPackage/Sources/MigrationFeature/.swiftlint.yml deleted file mode 100644 index 1242ffcaa..000000000 --- a/AppPackage/Sources/MigrationFeature/.swiftlint.yml +++ /dev/null @@ -1 +0,0 @@ -parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift b/AppPackage/Sources/MigrationFeature/MigrationReducer.swift deleted file mode 100644 index 042c56282..000000000 --- a/AppPackage/Sources/MigrationFeature/MigrationReducer.swift +++ /dev/null @@ -1,116 +0,0 @@ -import Foundation -import AppModels -import Resources -import ComposableArchitecture -import DatabaseClient -import AppComponents - -@Reducer -public struct MigrationReducer: Sendable { - public enum Dialog: Equatable, Sendable { - case confirmDropDatabase - } - - @ObservableState - public struct State: Equatable { - @Presents public var confirmationDialog: ConfirmationDialogState? - public var databaseState: LoadingState = .loading - - public init() {} - } - - public enum Action: BindableAction, Equatable { - case binding(BindingAction) - case confirmationDialog(PresentationAction) - case dropDatabaseButtonTapped - case onDatabasePreparationSuccess - - case prepareDatabase - case prepareDatabaseDone(AppError?) - case dropDatabase - case dropDatabaseDone(AppError?) - } - - @Dependency(\.databaseClient) private var databaseClient - - public init() {} - - public var body: some Reducer { - BindingReducer() - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .dropDatabaseButtonTapped: - state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { - TextState(localized: .dropDatabase) - } actions: { - ButtonState(role: .destructive, action: .confirmDropDatabase) { - TextState(localized: .dropDatabase) - } - ButtonState(role: .cancel) { - TextState(localized: .RLocalizable.cancel) - } - } message: { - TextState(localized: .dropDatabaseDescription) - } - return .none - - case .confirmationDialog(.presented(.confirmDropDatabase)): - return .send(.dropDatabase) - - case .confirmationDialog: - return .none - - case .onDatabasePreparationSuccess: - return .none - - case .prepareDatabase: - return .run { send in - let result = await databaseClient.prepareDatabase() - await send(.prepareDatabaseDone(result.error)) - } - - case .prepareDatabaseDone(let appError): - if let appError { - state.databaseState = .failed(appError) - return .none - } else { - state.databaseState = .idle - return .send(.onDatabasePreparationSuccess) - } - - case .dropDatabase: - state.databaseState = .loading - return .run { send in - try await Task.sleep(for: .milliseconds(500)) - let result = await databaseClient.dropDatabase() - await send(.dropDatabaseDone(result.error)) - } - - case .dropDatabaseDone(let appError): - if let appError { - state.databaseState = .failed(appError) - return .none - } else { - state.databaseState = .idle - return .send(.onDatabasePreparationSuccess) - } - } - } - .ifLet(\.$confirmationDialog, action: \.confirmationDialog) - } -} - -private extension Result { - var error: Failure? { - switch self { - case .success: - return nil - case let .failure(error): - return error - } - } -} diff --git a/AppPackage/Sources/MigrationFeature/MigrationView.swift b/AppPackage/Sources/MigrationFeature/MigrationView.swift deleted file mode 100644 index ff1af5508..000000000 --- a/AppPackage/Sources/MigrationFeature/MigrationView.swift +++ /dev/null @@ -1,46 +0,0 @@ -import SwiftUI -import AppModels -import Resources -import ComposableArchitecture -import AppComponents - -public struct MigrationView: View { - @Environment(\.colorScheme) private var colorScheme - @Bindable private var store: StoreOf - - private var reversedPrimary: Color { - colorScheme == .light ? .white : .black - } - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - NavigationStack { - ZStack { - reversedPrimary.ignoresSafeArea() - LoadingView(title: .preparingDatabase) - .opacity(store.databaseState == .loading ? 1 : 0) - let error = store.databaseState.failed - let errorNonNil = error ?? .databaseCorrupted(nil) - AlertView(symbol: errorNonNil.symbol, message: errorNonNil.localizedDescription) { - AlertViewButton(title: .dropDatabase) { - store.send(.dropDatabaseButtonTapped) - } - .confirmationDialog( - $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) - ) - } - .opacity(error != nil ? 1 : 0) - } - .animation(.default, value: store.databaseState) - } - } -} - -struct MigrationView_Previews: PreviewProvider { - static var previews: some View { - MigrationView(store: .init(initialState: .init(), reducer: MigrationReducer.init)) - } -} diff --git a/AppPackage/Sources/MigrationFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/MigrationFeature/Resources/Localizable.xcstrings deleted file mode 100644 index 466652bf5..000000000 --- a/AppPackage/Sources/MigrationFeature/Resources/Localizable.xcstrings +++ /dev/null @@ -1,129 +0,0 @@ -{ - "sourceLanguage": "en", - "strings": { - "drop_database": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Drop the database" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Datenbank löschen" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "データベースを削除" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "데이터베이스 삭제" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "丢弃数据库" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "刪除資料庫" - } - } - } - }, - "drop_database_description": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "You will lose all your data in this app.\nAre you sure to drop the database?" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Du verlierst alle Daten in dieser App.\nMöchtest du die Datenbank wirklich löschen?" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "本アプリでのすべてのデータを失うことになります。\n本当にデータベースを削除してもよろしいですか?" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "이 앱의 모든 데이터를 잃게 돼요.\n정말 데이터베이스를 삭제하시겠어요?" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "你将失去这个 App 中所有的数据。\n确定要丢弃数据库吗?" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?" - } - } - } - }, - "preparing_database": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Preparing the database..." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Datenbank wird vorbereitet..." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "データベース準備中..." - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "데이터베이스 준비 중..." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在准备数据库..." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "正在準備..." - } - } - } - } - }, - "version": "1.0" -} \ No newline at end of file diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift index c55e5d75a..55cd0fc72 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift @@ -172,7 +172,7 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { @MainActor @Test - func testDatabasePreparationImportsAutomationCookiesBeforeLoadingSettings() async { + func testLaunchFinishImportsAutomationCookiesBeforeLoadingSettings() async { let cookieClient = CookieClient.testing() let automation = AppLaunchAutomation( initialTab: nil, @@ -202,13 +202,22 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { $0.fileClient = .noop $0.dfClient = .noop $0.urlClient = .noop + $0.logsClient = .noop + $0.continuousClock = TestClock() + $0.date = .constant(.init(timeIntervalSince1970: 0)) } ) store.exhaustivity = .off - await store.send(.appDelegate(.migration(.onDatabasePreparationSuccess))) - #expect(cookieClient.shouldFetchIgneous) + // The launch effect imports the automation cookies and only then sends + // loadUserSettings, so receiving it proves the import already ran. Assert + // on `didLogin` (backed by the imported e-hentai auth cookies) rather than + // the transient `shouldFetchIgneous`, which loadUserSettings itself consumes + // by kicking off an igneous fetch. Left in flight (with the log pump) at + // deinit: the login cascade's network effects, which the store cancels. + await store.send(.appDelegate(.onLaunchFinish)) await store.receive(\.setting.loadUserSettings) + #expect(cookieClient.didLogin) } @MainActor From bc0618ba8d877c2b6e4c4ad2967d68445308a380 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 21:45:47 +0800 Subject: [PATCH 525/614] Remove DatabaseClient module and Core Data --- AppPackage/Package.swift | 20 - .../AppFeature/DataFlow/AppRouteReducer.swift | 2 - .../Sources/AppModels/Persistent/AppEnv.swift | 45 -- .../Persistent/GalleryHistoryEntry.swift | 4 +- .../Sources/DatabaseClient/.swiftlint.yml | 1 - .../FileManager+ApplicationSupport.swift | 15 - .../Database/Extensions/Logger+.swift | 7 - .../NSManagedObjectModel+Compatible.swift | 8 - .../NSManagedObjectModel+Resource.swift | 20 - .../NSPersistentStoreCoordinator+SQLite.swift | 44 -- .../MODefinition/AppEnvMO+CoreDataClass.swift | 37 -- .../AppEnvMO+CoreDataProperties.swift | 16 - .../GalleryDetailMO+CoreDataClass.swift | 50 --- .../GalleryDetailMO+CoreDataProperties.swift | 28 -- .../GalleryMO+CoreDataClass.swift | 40 -- .../GalleryMO+CoreDataProperties.swift | 20 - .../GalleryStateMO+CoreDataClass.swift | 39 -- .../GalleryStateMO+CoreDataProperties.swift | 17 - .../Migration/CoreDataMigrationStep.swift | 47 -- .../Migration/CoreDataMigrationVersion.swift | 32 -- .../Database/Migration/CoreDataMigrator.swift | 105 ----- .../Model5toModel6MigrationPolicy.swift | 79 ---- .../DatabaseClient/Database/Persistence.swift | 109 ----- .../DatabaseClient+Updates.swift | 136 ------ .../DatabaseClient/DatabaseClient.swift | 409 ------------------ .../Model.xcdatamodeld/.xccurrentversion | 8 - .../Model 2.xcdatamodel/contents | 60 --- .../Model 3.xcdatamodel/contents | 61 --- .../Model 4.xcdatamodel/contents | 62 --- .../Model 5.xcdatamodel/contents | 64 --- .../Model 6.xcdatamodel/contents | 67 --- .../Model 7.xcdatamodel/contents | 66 --- .../Model.xcdatamodel/contents | 60 --- .../xcmapping.xml | 343 --------------- .../Comments/CommentsReducer.swift | 2 - .../Sources/DetailFeature/DetailReducer.swift | 2 - .../DetailSearch/DetailSearchReducer.swift | 2 - .../Previews/PreviewsReducer.swift | 2 - .../FavoritesFeature/FavoritesReducer.swift | 2 - .../Frontpage/FrontpageReducer.swift | 2 - .../Sources/HomeFeature/HomeReducer.swift | 2 - .../HomeFeature/Popular/PopularReducer.swift | 2 - .../Toplists/ToplistsReducer.swift | 2 - .../HomeFeature/Watched/WatchedReducer.swift | 2 - .../ReadingFeature/ReadingReducer.swift | 2 - .../Sources/SearchFeature/SearchReducer.swift | 2 - .../GeneralSettingReducer.swift | 2 - .../SettingFeature/SettingReducer.swift | 2 - .../DatabaseClientUpdateTests.swift | 67 --- .../DetailReducerDownloadTests.swift | 2 - .../DetailReducerMetadataTests.swift | 3 - .../DetailReducerMetadataUpdateTests.swift | 3 - .../DetailReducerObserveTests.swift | 4 - .../DetailReducerPauseAndGuardTests.swift | 4 - .../DownloadAutomationTests.swift | 4 - .../DownloadFeatureTestFactories.swift | 55 +-- .../DownloadFeatureTestHelpers.swift | 9 - .../DownloadImageErrorTests.swift | 1 - .../DownloadImageParsingCacheTests.swift | 1 - .../DownloadImageParsingTests.swift | 1 - .../DownloadObserverReadingTests.swift | 4 - .../DownloadObserverRefreshTests.swift | 3 - .../PreviewsReducerDownloadTests.swift | 3 - .../ReadingReducerDownloadTests.swift | 3 - .../ReadingReducerLocalTests.swift | 3 - .../SettingReducerNavigationTests.swift | 1 - 66 files changed, 3 insertions(+), 2317 deletions(-) delete mode 100644 AppPackage/Sources/AppModels/Persistent/AppEnv.swift delete mode 100644 AppPackage/Sources/DatabaseClient/.swiftlint.yml delete mode 100755 AppPackage/Sources/DatabaseClient/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift delete mode 100644 AppPackage/Sources/DatabaseClient/Database/Extensions/Logger+.swift delete mode 100755 AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift delete mode 100755 AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift delete mode 100755 AppPackage/Sources/DatabaseClient/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift delete mode 100644 AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift delete mode 100644 AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataProperties.swift delete mode 100644 AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift delete mode 100644 AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift delete mode 100644 AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift delete mode 100644 AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataProperties.swift delete mode 100644 AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift delete mode 100644 AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift delete mode 100755 AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationStep.swift delete mode 100755 AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationVersion.swift delete mode 100755 AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrator.swift delete mode 100644 AppPackage/Sources/DatabaseClient/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift delete mode 100644 AppPackage/Sources/DatabaseClient/Database/Persistence.swift delete mode 100644 AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift delete mode 100644 AppPackage/Sources/DatabaseClient/DatabaseClient.swift delete mode 100644 AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/.xccurrentversion delete mode 100644 AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents delete mode 100644 AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents delete mode 100644 AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents delete mode 100644 AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents delete mode 100644 AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents delete mode 100644 AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents delete mode 100644 AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents delete mode 100644 AppPackage/Sources/DatabaseClient/Resources/Model5toModel6.xcmappingmodel/xcmapping.xml delete mode 100644 AppPackage/Tests/DownloadsFeatureTests/DatabaseClientUpdateTests.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 259709512..f6692e5c0 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -74,7 +74,6 @@ enum Module: String { case commonMarkExt = "CommonMarkExt" case cookieClient = "CookieClient" case dfClient = "DFClient" - case databaseClient = "DatabaseClient" case dateSeekFeature = "DateSeekFeature" case detailFeature = "DetailFeature" case deviceClient = "DeviceClient" @@ -257,7 +256,6 @@ let targets: [PackageDescription.Target] = [ .module(.backgroundProcessingClient), .module(.clipboardClient), .module(.cookieClient), - .module(.databaseClient), .module(.dateSeekFeature), .module(.detailFeature), .module(.dfClient), @@ -441,17 +439,6 @@ let targets: [PackageDescription.Target] = [ ], plugins: swiftLintPlugins ), - .target( - module: .databaseClient, - dependencies: [ - .module(.appModels), - .module(.appTools), - .module(.osLogExt), - .targetDependency(.composableArchitecture) - ], - resources: [.process(.resources)], - plugins: swiftLintPlugins - ), .target( module: .hapticsClient, dependencies: [ @@ -621,7 +608,6 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appModels), .module(.appTools), - .module(.databaseClient), .module(.dateSeekFeature), .module(.detailFeature), .module(.deviceClient), @@ -648,7 +634,6 @@ let targets: [PackageDescription.Target] = [ .module(.authorizationClient), .module(.clipboardClient), .module(.cookieClient), - .module(.databaseClient), .module(.deviceClient), .module(.dfClient), .module(.fileClient), @@ -674,7 +659,6 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appModels), .module(.appTools), - .module(.databaseClient), .module(.dateSeekFeature), .module(.detailFeature), .module(.deviceClient), @@ -700,7 +684,6 @@ let targets: [PackageDescription.Target] = [ .module(.appComponents), .module(.appModels), .module(.appTools), - .module(.databaseClient), .module(.dateSeekFeature), .module(.detailFeature), .module(.deviceClient), @@ -735,7 +718,6 @@ let targets: [PackageDescription.Target] = [ .module(.applicationClient), .module(.clipboardClient), .module(.cookieClient), - .module(.databaseClient), .module(.downloadClient), .module(.fileClient), .module(.filtersFeature), @@ -767,7 +749,6 @@ let targets: [PackageDescription.Target] = [ .module(.appTools), .module(.clipboardClient), .module(.cookieClient), - .module(.databaseClient), .module(.deviceClient), .module(.downloadClient), .module(.hapticsClient), @@ -888,7 +869,6 @@ let targets: [PackageDescription.Target] = [ .module(.backgroundProcessingClient), .module(.clipboardClient), .module(.cookieClient), - .module(.databaseClient), .module(.detailFeature), .module(.dfClient), .module(.deviceClient), diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index ec70c664e..1de0fcdfd 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -6,7 +6,6 @@ import ComposableArchitecture import URLClient import UserDefaultsClient import HapticsClient -import DatabaseClient import NetworkingFeature import ClipboardClient import AppComponents @@ -57,7 +56,6 @@ struct AppRouteReducer { @Dependency(\.userDefaultsClient) private var userDefaultsClient @Dependency(\.clipboardClient) private var clipboardClient - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.urlClient) private var urlClient @Dependency(\.date) private var date diff --git a/AppPackage/Sources/AppModels/Persistent/AppEnv.swift b/AppPackage/Sources/AppModels/Persistent/AppEnv.swift deleted file mode 100644 index 18ef9ea06..000000000 --- a/AppPackage/Sources/AppModels/Persistent/AppEnv.swift +++ /dev/null @@ -1,45 +0,0 @@ -public struct AppEnv: Codable, Equatable, Sendable { - public init( - user: User, - setting: Setting, - searchFilter: Filter, - globalFilter: Filter, - watchedFilter: Filter, - tagTranslator: TagTranslator, - historyKeywords: [String], - quickSearchWords: [QuickSearchWord] - ) { - self.user = user - self.setting = setting - self.searchFilter = searchFilter - self.globalFilter = globalFilter - self.watchedFilter = watchedFilter - self.tagTranslator = tagTranslator - self.historyKeywords = historyKeywords - self.quickSearchWords = quickSearchWords - } - public let user: User - public let setting: Setting - public let searchFilter: Filter - public let globalFilter: Filter - public let watchedFilter: Filter - public let tagTranslator: TagTranslator - public let historyKeywords: [String] - public let quickSearchWords: [QuickSearchWord] -} - -extension AppEnv: CustomStringConvertible { - public var description: String { - let params = String( - describing: [ - "user": user, - "setting": setting, - "tagTranslator": tagTranslator, - "historyKeywordsCount": historyKeywords.count, - "quickSearchWordsCount": quickSearchWords.count - ] - as [String: Any] - ) - return "AppEnv(\(params))" - } -} diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift index 5455a97e2..a5715b676 100644 --- a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift @@ -1,7 +1,7 @@ import Foundation -/// A single browsing-history record, merging what used to be split across -/// `GalleryMO.lastOpenDate` (recency) and `GalleryStateMO.readingProgress` (resume position). +/// A single browsing-history record, pairing a gallery's recency (`lastOpenDate`) with its +/// resume position (`readingProgress`) in one lightweight value. /// /// Only the minimal identity (`gid`/`token`), the recency key and the resume page are /// persisted — never a gallery snapshot. The History screen re-fetches display metadata diff --git a/AppPackage/Sources/DatabaseClient/.swiftlint.yml b/AppPackage/Sources/DatabaseClient/.swiftlint.yml deleted file mode 100644 index 1242ffcaa..000000000 --- a/AppPackage/Sources/DatabaseClient/.swiftlint.yml +++ /dev/null @@ -1 +0,0 @@ -parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/DatabaseClient/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift b/AppPackage/Sources/DatabaseClient/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift deleted file mode 100755 index 08e5e2ca5..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -extension FileManager { - public static func clearApplicationSupportDirectoryContents() { - guard let applicationSupportURL = FileManager.default.urls( - for: .applicationSupportDirectory, in: .userDomainMask).first, - let applicationSupportDirectoryContents = try? FileManager - .default.contentsOfDirectory(atPath: applicationSupportURL.path) - else { return } - applicationSupportDirectoryContents.forEach { - let fileURL = URL(fileURLWithPath: applicationSupportURL.path, isDirectory: true).appendingPathComponent($0) - try? FileManager.default.removeItem(atPath: fileURL.path) - } - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/Extensions/Logger+.swift b/AppPackage/Sources/DatabaseClient/Database/Extensions/Logger+.swift deleted file mode 100644 index dc116c0b8..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/Extensions/Logger+.swift +++ /dev/null @@ -1,7 +0,0 @@ -import OSLogExt - -extension Logger { - init(category: String) { - self.init(moduleName: "DatabaseClient", category: category) - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift b/AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift deleted file mode 100755 index 0f0274cd3..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation -import CoreData - -extension NSManagedObjectModel { - public static func compatibleModelForStoreMetadata(_ metadata: [String: Any]) -> NSManagedObjectModel? { - NSManagedObjectModel.mergedModel(from: [Bundle.module], forStoreMetadata: metadata) - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift b/AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift deleted file mode 100755 index 512f474c2..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation -import AppModels -import CoreData - -extension NSManagedObjectModel { - public static func managedObjectModel(forResource resource: String) throws -> NSManagedObjectModel { - let subdirectory = "Model.momd" - let omoURL = Bundle.module.url(forResource: resource, withExtension: "omo", subdirectory: subdirectory) - let momURL = Bundle.module.url(forResource: resource, withExtension: "mom", subdirectory: subdirectory) - - guard let url = omoURL ?? momURL else { - throw AppError.databaseCorrupted("Unable to find model in bundle.") - } - guard let model = NSManagedObjectModel(contentsOf: url) else { - throw AppError.databaseCorrupted("Unable to load model in bundle.") - } - - return model - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift b/AppPackage/Sources/DatabaseClient/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift deleted file mode 100755 index 7e3fc7e80..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift +++ /dev/null @@ -1,44 +0,0 @@ -import CoreData -import AppModels - -extension NSPersistentStoreCoordinator { - public static func destroyStore(at storeURL: URL) throws { - do { - let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: NSManagedObjectModel()) - try persistentStoreCoordinator.destroyPersistentStore(at: storeURL, ofType: NSSQLiteStoreType, options: nil) - } catch let error { - let message = ("Failed to destroy persistent store at \(storeURL), error: \(error).") - throw AppError.databaseCorrupted(message) - } - } - public static func replaceStore(at targetURL: URL, withStoreAt sourceURL: URL) throws { - do { - let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: NSManagedObjectModel()) - try persistentStoreCoordinator.replacePersistentStore( - at: targetURL, destinationOptions: nil, - withPersistentStoreFrom: sourceURL, - sourceOptions: nil, ofType: NSSQLiteStoreType - ) - } catch let error { - let message = "Failed to replace persistent store at \(targetURL) with \(sourceURL), error: \(error)." - throw AppError.databaseCorrupted(message) - } - } - - public static func metadata(at storeURL: URL) -> [String: Any]? { - try? NSPersistentStoreCoordinator.metadataForPersistentStore( - ofType: NSSQLiteStoreType, at: storeURL, options: nil - ) - } - - public func addPersistentStore(at storeURL: URL, options: [AnyHashable: Any]) throws -> NSPersistentStore { - do { - return try addPersistentStore( - ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, options: options - ) - } catch { - let message = ("Failed to add persistent store to coordinator, error: \(error).") - throw AppError.databaseCorrupted(message) - } - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift deleted file mode 100644 index 0bb9eade9..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataClass.swift +++ /dev/null @@ -1,37 +0,0 @@ -import CoreData -import AppModels -import AppTools - -public class AppEnvMO: NSManagedObject {} - -extension AppEnvMO: ManagedObjectProtocol { - public func toEntity() -> AppEnv { - AppEnv( - user: user?.toObject() ?? User(), - setting: setting?.toObject() ?? Setting(), - searchFilter: searchFilter?.toObject() ?? Filter(), - globalFilter: globalFilter?.toObject() ?? Filter(), - watchedFilter: watchedFilter?.toObject() ?? Filter(), - tagTranslator: tagTranslator?.toObject() ?? TagTranslator(), - historyKeywords: historyKeywords?.toObject() ?? [String](), - quickSearchWords: quickSearchWords?.toObject() ?? [QuickSearchWord]() - ) - } -} - -extension AppEnv: ManagedObjectConvertible { - @discardableResult public func toManagedObject(in context: NSManagedObjectContext) -> AppEnvMO { - let appEnvMO = AppEnvMO(context: context) - - appEnvMO.user = user.toData() - appEnvMO.setting = setting.toData() - appEnvMO.searchFilter = searchFilter.toData() - appEnvMO.globalFilter = globalFilter.toData() - appEnvMO.watchedFilter = watchedFilter.toData() - appEnvMO.tagTranslator = tagTranslator.toData() - appEnvMO.historyKeywords = historyKeywords.toData() - appEnvMO.quickSearchWords = quickSearchWords.toData() - - return appEnvMO - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataProperties.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataProperties.swift deleted file mode 100644 index 2ecabaacb..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/AppEnvMO+CoreDataProperties.swift +++ /dev/null @@ -1,16 +0,0 @@ -import CoreData - -extension AppEnvMO { - @nonobjc public class func fetchRequest() -> NSFetchRequest { - NSFetchRequest(entityName: "AppEnvMO") - } - - @NSManaged public var user: Data? - @NSManaged public var setting: Data? - @NSManaged public var searchFilter: Data? - @NSManaged public var globalFilter: Data? - @NSManaged public var watchedFilter: Data? - @NSManaged public var tagTranslator: Data? - @NSManaged public var historyKeywords: Data? - @NSManaged public var quickSearchWords: Data? -} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift deleted file mode 100644 index 2dd77b12e..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift +++ /dev/null @@ -1,50 +0,0 @@ -import CoreData -import AppModels -import AppTools - -public class GalleryDetailMO: NSManagedObject {} - -extension GalleryDetailMO: ManagedObjectProtocol { - public func toEntity() -> GalleryDetail { - GalleryDetail( - gid: gid, title: title, jpnTitle: jpnTitle, isFavorited: isFavorited, - visibility: visibility?.toObject() ?? GalleryVisibility.yes, - rating: rating, userRating: userRating, ratingCount: Int(ratingCount), - category: AppModels.Category(rawValue: category).forceUnwrapped, - language: Language(rawValue: language).forceUnwrapped, - uploader: uploader, postedDate: postedDate, - coverURL: coverURL, archiveURL: archiveURL, parentURL: parentURL, - favoritedCount: Int(favoritedCount), pageCount: Int(pageCount), - sizeCount: sizeCount, sizeType: sizeType, - torrentCount: Int(torrentCount) - ) - } -} -extension GalleryDetail: ManagedObjectConvertible { - @discardableResult public func toManagedObject(in context: NSManagedObjectContext) -> GalleryDetailMO { - let galleryDetailMO = GalleryDetailMO(context: context) - - galleryDetailMO.gid = gid - galleryDetailMO.archiveURL = archiveURL - galleryDetailMO.category = category.rawValue - galleryDetailMO.coverURL = coverURL - galleryDetailMO.isFavorited = isFavorited - galleryDetailMO.visibility = visibility.toData() - galleryDetailMO.jpnTitle = jpnTitle - galleryDetailMO.language = language.rawValue - galleryDetailMO.favoritedCount = Int64(favoritedCount) - galleryDetailMO.pageCount = Int64(pageCount) - galleryDetailMO.parentURL = parentURL - galleryDetailMO.postedDate = postedDate - galleryDetailMO.rating = rating - galleryDetailMO.userRating = userRating - galleryDetailMO.ratingCount = Int64(ratingCount) - galleryDetailMO.sizeCount = sizeCount - galleryDetailMO.sizeType = sizeType - galleryDetailMO.title = title - galleryDetailMO.torrentCount = Int64(torrentCount) - galleryDetailMO.uploader = uploader - - return galleryDetailMO - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift deleted file mode 100644 index d9bd2e89d..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift +++ /dev/null @@ -1,28 +0,0 @@ -import CoreData - -extension GalleryDetailMO: GalleryIdentifiable { - @nonobjc public class func fetchRequest() -> NSFetchRequest { - NSFetchRequest(entityName: "GalleryDetailMO") - } - - @NSManaged public var archiveURL: URL? - @NSManaged public var category: String - @NSManaged public var coverURL: URL? - @NSManaged public var gid: String - @NSManaged public var isFavorited: Bool - @NSManaged public var jpnTitle: String? - @NSManaged public var language: String - @NSManaged public var favoritedCount: Int64 - @NSManaged public var pageCount: Int64 - @NSManaged public var parentURL: URL? - @NSManaged public var postedDate: Date - @NSManaged public var rating: Float - @NSManaged public var userRating: Float - @NSManaged public var ratingCount: Int64 - @NSManaged public var sizeCount: Float - @NSManaged public var sizeType: String - @NSManaged public var title: String - @NSManaged public var torrentCount: Int64 - @NSManaged public var uploader: String - @NSManaged public var visibility: Data? -} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift deleted file mode 100644 index 18e2b8a4d..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataClass.swift +++ /dev/null @@ -1,40 +0,0 @@ -import CoreData -import AppModels -import AppTools - -public class GalleryMO: NSManagedObject {} - -extension GalleryMO: ManagedObjectProtocol { - public func toEntity() -> Gallery { - Gallery( - gid: gid, token: token, - title: title, rating: rating, - tags: tags?.toObject() ?? [GalleryTag](), - category: AppModels.Category(rawValue: category) ?? .private, - uploader: uploader, pageCount: Int(pageCount), - postedDate: postedDate, - coverURL: coverURL, galleryURL: galleryURL, - lastOpenDate: lastOpenDate - ) - } -} -extension Gallery: ManagedObjectConvertible { - @discardableResult public func toManagedObject(in context: NSManagedObjectContext) -> GalleryMO { - let galleryMO = GalleryMO(context: context) - - galleryMO.gid = gid - galleryMO.category = category.rawValue - galleryMO.coverURL = coverURL - galleryMO.galleryURL = galleryURL - galleryMO.lastOpenDate = lastOpenDate - galleryMO.pageCount = Int64(pageCount) - galleryMO.postedDate = postedDate - galleryMO.rating = rating - galleryMO.tags = tags.toData() - galleryMO.title = title - galleryMO.token = token - galleryMO.uploader = uploader - - return galleryMO - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataProperties.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataProperties.swift deleted file mode 100644 index 08913e1da..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryMO+CoreDataProperties.swift +++ /dev/null @@ -1,20 +0,0 @@ -import CoreData - -extension GalleryMO: GalleryIdentifiable { - @nonobjc public class func fetchRequest() -> NSFetchRequest { - NSFetchRequest(entityName: "GalleryMO") - } - - @NSManaged public var category: String - @NSManaged public var coverURL: URL? - @NSManaged public var galleryURL: URL? - @NSManaged public var gid: String - @NSManaged public var lastOpenDate: Date? - @NSManaged public var pageCount: Int64 - @NSManaged public var postedDate: Date - @NSManaged public var rating: Float - @NSManaged public var tags: Data? - @NSManaged public var title: String - @NSManaged public var token: String - @NSManaged public var uploader: String? -} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift deleted file mode 100644 index dc147ddcc..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataClass.swift +++ /dev/null @@ -1,39 +0,0 @@ -import SwiftUI -import AppModels -import CoreData -import AppTools - -public class GalleryStateMO: NSManagedObject {} - -extension GalleryStateMO: ManagedObjectProtocol { - public func toEntity() -> GalleryState { - GalleryState( - gid: gid, tags: tags?.toObject() ?? [GalleryTag](), - readingProgress: Int(readingProgress), - previewURLs: previewURLs?.toObject() ?? [Int: URL](), - previewConfig: previewConfig?.toObject() ?? PreviewConfig.normal(rows: 4), - comments: comments?.toObject() ?? [GalleryComment](), - imageURLs: imageURLs?.toObject() ?? [Int: URL](), - originalImageURLs: originalImageURLs?.toObject() ?? [Int: URL](), - thumbnailURLs: thumbnailURLs?.toObject() ?? [Int: URL]() - ) - } -} - -extension GalleryState: ManagedObjectConvertible { - @discardableResult public func toManagedObject(in context: NSManagedObjectContext) -> GalleryStateMO { - let galleryStateMO = GalleryStateMO(context: context) - - galleryStateMO.gid = gid - galleryStateMO.tags = tags.toData() - galleryStateMO.readingProgress = Int64(readingProgress) - galleryStateMO.previewConfig = previewConfig?.toData() - galleryStateMO.previewURLs = previewURLs.toData() - galleryStateMO.comments = comments.toData() - galleryStateMO.imageURLs = imageURLs.toData() - galleryStateMO.originalImageURLs = originalImageURLs.toData() - galleryStateMO.thumbnailURLs = thumbnailURLs.toData() - - return galleryStateMO - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift b/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift deleted file mode 100644 index 36ed13839..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift +++ /dev/null @@ -1,17 +0,0 @@ -import CoreData - -extension GalleryStateMO: GalleryIdentifiable { - @nonobjc public class func fetchRequest() -> NSFetchRequest { - NSFetchRequest(entityName: "GalleryStateMO") - } - - @NSManaged public var comments: Data? - @NSManaged public var imageURLs: Data? - @NSManaged public var originalImageURLs: Data? - @NSManaged public var gid: String - @NSManaged public var previewConfig: Data? - @NSManaged public var previewURLs: Data? - @NSManaged public var readingProgress: Int64 - @NSManaged public var tags: Data? - @NSManaged public var thumbnailURLs: Data? -} diff --git a/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationStep.swift b/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationStep.swift deleted file mode 100755 index 27ac8562c..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationStep.swift +++ /dev/null @@ -1,47 +0,0 @@ -import CoreData -import AppModels - -public struct CoreDataMigrationStep { - public let sourceModel: NSManagedObjectModel - public let destinationModel: NSManagedObjectModel - public let mappingModel: NSMappingModel - - public init(sourceVersion: CoreDataMigrationVersion, destinationVersion: CoreDataMigrationVersion) throws { - let sourceModel = try NSManagedObjectModel.managedObjectModel(forResource: sourceVersion.rawValue) - let destinationModel = try NSManagedObjectModel.managedObjectModel(forResource: destinationVersion.rawValue) - - guard let mappingModel = CoreDataMigrationStep.mappingModel( - fromSourceModel: sourceModel, toDestinationModel: destinationModel - ) else { - throw AppError.databaseCorrupted("Expected modal mapping not present.") - } - - self.sourceModel = sourceModel - self.destinationModel = destinationModel - self.mappingModel = mappingModel - } - - private static func mappingModel( - fromSourceModel sourceModel: NSManagedObjectModel, - toDestinationModel destinationModel: NSManagedObjectModel - ) -> NSMappingModel? { - guard let customMapping = customMappingModel( - fromSourceModel: sourceModel, toDestinationModel: destinationModel - ) else { - return inferredMappingModel(fromSourceModel: sourceModel, toDestinationModel: destinationModel) - } - return customMapping - } - private static func inferredMappingModel( - fromSourceModel sourceModel: NSManagedObjectModel, - toDestinationModel destinationModel: NSManagedObjectModel - ) -> NSMappingModel? { - try? NSMappingModel.inferredMappingModel(forSourceModel: sourceModel, destinationModel: destinationModel) - } - private static func customMappingModel( - fromSourceModel sourceModel: NSManagedObjectModel, - toDestinationModel destinationModel: NSManagedObjectModel - ) -> NSMappingModel? { - NSMappingModel(from: [Bundle.module], forSourceModel: sourceModel, destinationModel: destinationModel) - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationVersion.swift b/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationVersion.swift deleted file mode 100755 index 7722ebf0c..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrationVersion.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation -import AppModels -import CoreData - -public enum CoreDataMigrationVersion: String, CaseIterable { - case version1 = "Model" - case version2 = "Model 2" - case version3 = "Model 3" - case version4 = "Model 4" - case version5 = "Model 5" - case version6 = "Model 6" - case version7 = "Model 7" - - public static func current() throws -> CoreDataMigrationVersion { - guard let latest = allCases.last else { - throw AppError.databaseCorrupted("No model versions found.") - } - return latest - } - - public func nextVersion() -> CoreDataMigrationVersion? { - switch self { - case .version1: return .version2 - case .version2: return .version3 - case .version3: return .version4 - case .version4: return .version5 - case .version5: return .version6 - case .version6: return .version7 - case .version7: return nil - } - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrator.swift b/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrator.swift deleted file mode 100755 index 779de136f..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/Migration/CoreDataMigrator.swift +++ /dev/null @@ -1,105 +0,0 @@ -import CoreData -import AppModels - -public protocol CoreDataMigratorProtocol { - func requiresMigration(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws -> Bool - func migrateStore(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws -} - -public final class CoreDataMigrator: CoreDataMigratorProtocol, Sendable { - public func requiresMigration(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws -> Bool { - guard let metadata = NSPersistentStoreCoordinator.metadata(at: storeURL) else { return false } - return (try CoreDataMigrationVersion.compatibleVersionForStoreMetadata(metadata) != version) - } - - public func migrateStore(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws { - try forceWALCheckpointingForStore(at: storeURL) - - var currentURL = storeURL - let migrationSteps = try migrationStepsForStore(at: storeURL, toVersion: version) - - for migrationStep in migrationSteps { - let manager = NSMigrationManager( - sourceModel: migrationStep.sourceModel, destinationModel: migrationStep.destinationModel - ) - let destinationURL = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) - - do { - try manager.migrateStore( - from: currentURL, sourceType: NSSQLiteStoreType, options: nil, - with: migrationStep.mappingModel, toDestinationURL: destinationURL, - destinationType: NSSQLiteStoreType, destinationOptions: nil - ) - } catch { - let message = "Failed attempting to migrate from \(migrationStep.sourceModel) " - + "to \(migrationStep.destinationModel), error: \(error)." - throw AppError.databaseCorrupted(message) - } - - if currentURL != storeURL { - try NSPersistentStoreCoordinator.destroyStore(at: currentURL) - } - - currentURL = destinationURL - } - - try NSPersistentStoreCoordinator.replaceStore(at: storeURL, withStoreAt: currentURL) - - if currentURL != storeURL { - try NSPersistentStoreCoordinator.destroyStore(at: currentURL) - } - } - - private func migrationStepsForStore( - at storeURL: URL, toVersion destinationVersion: CoreDataMigrationVersion - ) throws -> [CoreDataMigrationStep] { - guard let metadata = NSPersistentStoreCoordinator.metadata(at: storeURL), - let sourceVersion = try CoreDataMigrationVersion.compatibleVersionForStoreMetadata(metadata) - else { - throw AppError.databaseCorrupted("Unknown store version at URL \(storeURL).") - } - return try migrationSteps(fromSourceVersion: sourceVersion, toDestinationVersion: destinationVersion) - } - - private func migrationSteps( - fromSourceVersion sourceVersion: CoreDataMigrationVersion, - toDestinationVersion destinationVersion: CoreDataMigrationVersion - ) throws -> [CoreDataMigrationStep] { - var sourceVersion = sourceVersion - var migrationSteps = [CoreDataMigrationStep]() - - while sourceVersion != destinationVersion, let nextVersion = sourceVersion.nextVersion() { - let migrationStep = try CoreDataMigrationStep(sourceVersion: sourceVersion, destinationVersion: nextVersion) - migrationSteps.append(migrationStep) - - sourceVersion = nextVersion - } - - return migrationSteps - } - - public func forceWALCheckpointingForStore(at storeURL: URL) throws { - guard let metadata = NSPersistentStoreCoordinator.metadata(at: storeURL), - let currentModel = NSManagedObjectModel.compatibleModelForStoreMetadata(metadata) - else { return } - - do { - let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: currentModel) - let options = [NSSQLitePragmasOption: ["journal_mode": "DELETE"]] - let store = try persistentStoreCoordinator.addPersistentStore(at: storeURL, options: options) - try persistentStoreCoordinator.remove(store) - } catch { - throw AppError.databaseCorrupted("Failed to force WAL checkpointing, error: \(error).") - } - } -} - -private extension CoreDataMigrationVersion { - static func compatibleVersionForStoreMetadata(_ metadata: [String: Any]) throws -> CoreDataMigrationVersion? { - let compatibleVersion = try CoreDataMigrationVersion.allCases.first { - let model = try NSManagedObjectModel.managedObjectModel(forResource: $0.rawValue) - return model.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata) - } - return compatibleVersion - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift b/AppPackage/Sources/DatabaseClient/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift deleted file mode 100644 index 40519b76a..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift +++ /dev/null @@ -1,79 +0,0 @@ -import CoreData -import AppModels - -// reason: the migration policy name must encode both the source and destination model versions -// swiftlint:disable type_name -final class GalleryMO5toGalleryMO6MigrationPolicy: NSEntityMigrationPolicy { - override func createDestinationInstances( - forSource sourceInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager - ) throws { - try super.createDestinationInstances(forSource: sourceInstance, in: mapping, manager: manager) - guard let destinationGalleryMO = manager.destinationInstances( - forEntityMappingName: mapping.name, sourceInstances: [sourceInstance] - ).first else { - throw AppError.databaseCorrupted("Was expected a GalleryMO.") - } - guard let coverURLString = sourceInstance.value(forKey: "coverURL") as? String, - let galleryURLString = sourceInstance.value(forKey: "galleryURL") as? String, - let coverURL = URL(string: coverURLString), let galleryURL = URL(string: galleryURLString) - else { throw AppError.databaseCorrupted("Failed in resolving coverURL, galleryURL.") } - destinationGalleryMO.setValue(coverURL, forKey: "coverURL") - destinationGalleryMO.setValue(galleryURL, forKey: "galleryURL") - } -} - -final class GalleryDetailMO5toGalleryDetailMO6MigrationPolicy: NSEntityMigrationPolicy { - override func createDestinationInstances( - forSource sourceInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager - ) throws { - try super.createDestinationInstances(forSource: sourceInstance, in: mapping, manager: manager) - guard let destinationGalleryDetailMO = manager.destinationInstances( - forEntityMappingName: mapping.name, sourceInstances: [sourceInstance] - ).first else { - throw AppError.databaseCorrupted("Was expected a GalleryDetailMO.") - } - let parentURLString = sourceInstance.value(forKey: "parentURL") as? String - let archiveURLString = sourceInstance.value(forKey: "archiveURL") as? String - guard let coverURLString = sourceInstance.value(forKey: "coverURL") as? String, - let coverURL = URL(string: coverURLString) - else { throw AppError.databaseCorrupted("Failed in resolving coverURL.") } - destinationGalleryDetailMO.setValue(URL(string: parentURLString ?? ""), forKey: "parentURL") - destinationGalleryDetailMO.setValue(URL(string: archiveURLString ?? ""), forKey: "archiveURL") - destinationGalleryDetailMO.setValue(coverURL, forKey: "coverURL") - } -} - -final class GalleryStateMO5toGalleryStateMO6MigrationPolicy: NSEntityMigrationPolicy { - override func createDestinationInstances( - forSource sourceInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager - ) throws { - try super.createDestinationInstances(forSource: sourceInstance, in: mapping, manager: manager) - guard let destinationGalleryStateMO = manager.destinationInstances( - forEntityMappingName: mapping.name, sourceInstances: [sourceInstance] - ).first else { - throw AppError.databaseCorrupted("Was expected a GalleryStateMO.") - } - let previews = sourceInstance.value(forKey: "previews") as? [Int: String] - let thumbnails = sourceInstance.value(forKey: "thumbnails") as? [Int: String] - let contents = sourceInstance.value(forKey: "contents") as? [Int: String] - let originalContents = sourceInstance.value(forKey: "originalContents") as? [Int: String] - destinationGalleryStateMO.setValue(previews?.mapToURLs, forKey: "previewURLs") - destinationGalleryStateMO.setValue(thumbnails?.mapToURLs, forKey: "thumbnailURLs") - destinationGalleryStateMO.setValue(contents?.mapToURLs, forKey: "imageURLs") - destinationGalleryStateMO.setValue(originalContents?.mapToURLs, forKey: "originalImageURLs") - } -} -// swiftlint:enable type_name - -private extension Dictionary where Value == String { - func mapToURLs() -> [Key: URL] { - compactMap { (key, value) -> (Key, URL)? in - if let url = URL(string: value) { - return (key, url) - } else { - return nil - } - } - .reduce(into: [:]) { $0[$1.0] = $1.1 } - } -} diff --git a/AppPackage/Sources/DatabaseClient/Database/Persistence.swift b/AppPackage/Sources/DatabaseClient/Database/Persistence.swift deleted file mode 100644 index 98cfed52e..000000000 --- a/AppPackage/Sources/DatabaseClient/Database/Persistence.swift +++ /dev/null @@ -1,109 +0,0 @@ -import CoreData -import AppModels - -public struct PersistenceController: Sendable { - public static let shared = PersistenceController() - public let migrator = CoreDataMigrator() - - public let container: NSPersistentCloudKitContainer = { - guard let modelURL = Bundle.module.url(forResource: "Model", withExtension: "momd"), - let model = NSManagedObjectModel(contentsOf: modelURL) else { - fatalError("Failed to load the Core Data model from the module bundle.") - } - let container = NSPersistentCloudKitContainer(name: "Model", managedObjectModel: model) - let description = container.persistentStoreDescriptions.first - description?.shouldInferMappingModelAutomatically = false - description?.shouldMigrateStoreAutomatically = false - return container - }() -} - -// MARK: Preparation -extension PersistenceController { - public func prepare(completion: @escaping @Sendable (Result) -> Void) { - do { - try loadPersistentStore(completion: completion) - } catch { - completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) - } - } - public func rebuild(completion: @escaping @Sendable (Result) -> Void) { - guard let storeURL = container.persistentStoreDescriptions.first?.url else { - completion(.failure(.databaseCorrupted("PersistentContainer was not set up properly."))) - return - } - DispatchQueue.global(qos: .userInitiated).async { - do { - try NSPersistentStoreCoordinator.destroyStore(at: storeURL) - } catch { - completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) - return - } - container.loadPersistentStores { _, error in - guard error == nil else { - let message = "Was unable to load store \(String(describing: error))." - completion(.failure(.databaseCorrupted(message))) - return - } - completion(.success(())) - } - } - } - private func loadPersistentStore( - completion: @escaping @Sendable (Result) -> Void - ) throws { - try migrateStoreIfNeeded { result in - switch result { - case .success: - container.loadPersistentStores { _, error in - guard error == nil else { - let message = "Was unable to load store \(String(describing: error))." - completion(.failure(.databaseCorrupted(message))) - return - } - completion(.success(())) - } - case .failure(let error): - completion(.failure(error)) - } - } - } - private func migrateStoreIfNeeded( - completion: @escaping @Sendable (Result) -> Void - ) throws { - guard let storeURL = container.persistentStoreDescriptions.first?.url else { - throw AppError.databaseCorrupted("PersistentContainer was not set up properly.") - } - - if try migrator.requiresMigration(at: storeURL, toVersion: try CoreDataMigrationVersion.current()) { - DispatchQueue.global(qos: .userInitiated).async { - do { - try migrator.migrateStore(at: storeURL, toVersion: try CoreDataMigrationVersion.current()) - } catch { - completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) - return - } - completion(.success(())) - } - } else { - completion(.success(())) - } - } -} - -// MARK: Definition -public protocol ManagedObjectProtocol { - associatedtype Entity - func toEntity() -> Entity -} - -public protocol ManagedObjectConvertible { - associatedtype ManagedObject: NSManagedObject, ManagedObjectProtocol - - @discardableResult - func toManagedObject(in context: NSManagedObjectContext) -> ManagedObject -} - -public protocol GalleryIdentifiable: NSManagedObject { - var gid: String { get set } -} diff --git a/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift b/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift deleted file mode 100644 index 8c2d8ace1..000000000 --- a/AppPackage/Sources/DatabaseClient/DatabaseClient+Updates.swift +++ /dev/null @@ -1,136 +0,0 @@ -import SwiftUI -import AppModels -import CoreData -import AppTools - -// MARK: UpdateGalleryState -extension DatabaseClient { - @MainActor public func updateGalleryState(gid: String, commitChanges: @escaping (GalleryStateMO) -> Void) { - guard gid.isValidGID else { return } - update( - entityType: GalleryStateMO.self, gid: gid, createIfNil: true, - commitChanges: commitChanges - ) - } - @MainActor public func updateGalleryState(gid: String, key: String, value: Any?) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { stateMO in - stateMO.setValue(value, forKeyPath: key) - } - } - @MainActor public func updateGalleryTags(gid: String, tags: [GalleryTag]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid, key: "tags", value: tags.toData()) - } - @MainActor public func updatePreviewConfig(gid: String, config: PreviewConfig) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid, key: "previewConfig", value: config.toData()) - } - @MainActor public func updateReadingProgress(gid: String, progress: Int) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid, key: "readingProgress", value: Int64(progress)) - } - @MainActor public func updateComments(gid: String, comments: [GalleryComment]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid, key: "comments", value: comments.toData()) - } - - @MainActor public func removeImageURLs(gid: String) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { galleryStateMO in - galleryStateMO.imageURLs = nil - galleryStateMO.previewURLs = nil - galleryStateMO.thumbnailURLs = nil - galleryStateMO.originalImageURLs = nil - } - } - @MainActor public func removeImageURLs() { - batchUpdate(entityType: GalleryStateMO.self) { galleryStateMOs in - galleryStateMOs.forEach { galleryStateMO in - galleryStateMO.imageURLs = nil - galleryStateMO.previewURLs = nil - galleryStateMO.thumbnailURLs = nil - galleryStateMO.originalImageURLs = nil - } - } - } - @MainActor public func removeExpiredImageURLs() { - fetchHistoryGalleries() - .filter { Date().timeIntervalSince($0.lastOpenDate ?? .distantPast) > .oneWeek } - .forEach { removeImageURLs(gid: $0.id) } - } - @MainActor public func updateThumbnailURLs(gid: String, thumbnailURLs: [Int: URL]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { galleryStateMO in - update(gid: gid, storedData: &galleryStateMO.thumbnailURLs, new: thumbnailURLs) - } - } - @MainActor public func updateImageURLs(gid: String, imageURLs: [Int: URL], originalImageURLs: [Int: URL]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { galleryStateMO in - update(gid: gid, storedData: &galleryStateMO.imageURLs, new: imageURLs) - update(gid: gid, storedData: &galleryStateMO.originalImageURLs, new: originalImageURLs) - } - } - @MainActor public func updatePreviewURLs(gid: String, previewURLs: [Int: URL]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { galleryStateMO in - update(gid: gid, storedData: &galleryStateMO.previewURLs, new: previewURLs) - } - } -} - -// MARK: UpdateAppEnv -extension DatabaseClient { - @MainActor public func updateAppEnv(key: String, value: Any?) { - update( - entityType: AppEnvMO.self, createIfNil: true, - commitChanges: { $0.setValue(value, forKeyPath: key) } - ) - } - @MainActor public func updateSetting(_ setting: Setting) { - updateAppEnv(key: "setting", value: setting.toData()) - } - @MainActor public func updateFilter(_ filter: Filter, range: FilterRange) { - let key: String - switch range { - case .search: - key = "searchFilter" - case .global: - key = "globalFilter" - case .watched: - key = "watchedFilter" - } - updateAppEnv(key: key, value: filter.toData()) - } - @MainActor public func updateTagTranslator(_ tagTranslator: TagTranslator) { - updateAppEnv(key: "tagTranslator", value: tagTranslator.toData()) - } - @MainActor public func updateUser(_ user: User) { - updateAppEnv(key: "user", value: user.toData()) - } - @MainActor public func updateHistoryKeywords(_ keywords: [String]) { - updateAppEnv(key: "historyKeywords", value: keywords.toData()) - } - @MainActor public func updateQuickSearchWords(_ words: [QuickSearchWord]) { - updateAppEnv(key: "quickSearchWords", value: words.toData()) - } - - // Update User - @MainActor public func updateUserProperty(_ commitChanges: @escaping (inout User) -> Void) { - var user = fetchAppEnv().user - commitChanges(&user) - updateUser(user) - } - @MainActor public func updateGreeting(_ greeting: Greeting) { - updateUserProperty { user in - user.greeting = greeting - } - } - @MainActor public func updateGalleryFunds(galleryPoints: String, credits: String) { - updateUserProperty { user in - user.credits = credits - user.galleryPoints = galleryPoints - } - } -} diff --git a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift b/AppPackage/Sources/DatabaseClient/DatabaseClient.swift deleted file mode 100644 index 0a7329b3a..000000000 --- a/AppPackage/Sources/DatabaseClient/DatabaseClient.swift +++ /dev/null @@ -1,409 +0,0 @@ -import SwiftUI -import AppModels -import Combine -import CoreData -import OSLogExt -import ComposableArchitecture -import AppTools - -private let logger = Logger(category: .init(describing: DatabaseClient.self)) - -public struct DatabaseClient: Sendable { - public let prepareDatabase: @Sendable () async -> Result - public let dropDatabase: @Sendable () async -> Result - private let viewContext: @Sendable () -> NSManagedObjectContext - private let saveContext: @Sendable () -> Void - private let materializedObjects: - @Sendable (NSManagedObjectContext, NSPredicate) -> [NSManagedObject] -} - -extension DatabaseClient { - public static let live: Self = .init( - prepareDatabase: { - await withCheckedContinuation { continuation in - PersistenceController.shared.prepare { result in - continuation.resume(returning: result) - } - } - }, - dropDatabase: { - await withCheckedContinuation { continuation in - PersistenceController.shared.rebuild { result in - if case .success = result { - logger.notice("Database dropped.") - } - continuation.resume(returning: result) - } - } - }, - viewContext: { - PersistenceController.shared.container.viewContext - }, - saveContext: { - let context = PersistenceController.shared.container.viewContext - AppUtil.dispatchMainSync { - guard context.hasChanges else { return } - do { - try context.save() - } catch { - logger.error("\(error, privacy: .public)") - fatalError("Unresolved error \(error)") - } - } - }, - materializedObjects: materializedObjectsFromContext - ) - - public static func live(persistenceContainer container: NSPersistentContainer) -> Self { - .init( - prepareDatabase: { .success(()) }, - dropDatabase: { .success(()) }, - viewContext: { - container.viewContext - }, - saveContext: { - let context = container.viewContext - AppUtil.dispatchMainSync { - guard context.hasChanges else { return } - do { - try context.save() - } catch { - logger.error("\(error, privacy: .public)") - fatalError("Unresolved error \(error)") - } - } - }, - materializedObjects: materializedObjectsFromContext - ) - } - - private static let materializedObjectsFromContext: - @Sendable (NSManagedObjectContext, NSPredicate) -> [NSManagedObject] = { context, predicate in - var objects = [NSManagedObject]() - for object in context.registeredObjects where !object.isFault { - guard object.entity.attributesByName.keys.contains("gid"), - predicate.evaluate(with: object) - else { continue } - objects.append(object) - } - return objects - } -} - -// MARK: Foundation -extension DatabaseClient { - public func batchFetch( - entityType: MO.Type, fetchLimit: Int = 0, predicate: NSPredicate? = nil, - findBeforeFetch: Bool = true, sortDescriptors: [NSSortDescriptor]? = nil - ) -> [MO] { - var results = [MO]() - let context = viewContext() - AppUtil.dispatchMainSync { - if findBeforeFetch, let predicate = predicate { - if let objects = materializedObjects(context, predicate) as? [MO], !objects.isEmpty { - results = objects - return - } - } - let request = NSFetchRequest( - entityName: String(describing: entityType) - ) - request.predicate = predicate - request.fetchLimit = fetchLimit - request.sortDescriptors = sortDescriptors - results = (try? context.fetch(request)) ?? [] - } - return results - } - - public func fetch( - entityType: MO.Type, predicate: NSPredicate? = nil, - findBeforeFetch: Bool = true, commitChanges: ((MO?) -> Void)? = nil - ) -> MO? { - let managedObject = batchFetch( - entityType: entityType, fetchLimit: 1, - predicate: predicate, findBeforeFetch: findBeforeFetch - ).first - commitChanges?(managedObject) - return managedObject - } - - public func fetchOrCreate( - entityType: MO.Type, predicate: NSPredicate? = nil, - commitChanges: ((MO?) -> Void)? = nil - ) -> MO { - if let storedMO = fetch( - entityType: entityType, predicate: predicate, commitChanges: commitChanges - ) { - return storedMO - } else { - let newMO = MO(context: viewContext()) - commitChanges?(newMO) - saveContext() - return newMO - } - } - - public func batchUpdate( - entityType: MO.Type, predicate: NSPredicate? = nil, commitChanges: ([MO]) -> Void - ) { - commitChanges(batchFetch( - entityType: entityType, - predicate: predicate, - findBeforeFetch: false - )) - saveContext() - } - public func update( - entityType: MO.Type, predicate: NSPredicate? = nil, - createIfNil: Bool = false, commitChanges: (MO) -> Void - ) { - AppUtil.dispatchMainSync { - let storedMO: MO? - if createIfNil { - storedMO = fetchOrCreate(entityType: entityType, predicate: predicate) - } else { - storedMO = fetch(entityType: entityType, predicate: predicate) - } - if let storedMO = storedMO { - commitChanges(storedMO) - saveContext() - } - } - } -} - -// MARK: GalleryIdentifiable -extension DatabaseClient { - public func fetch( - entityType: MO.Type, gid: String, - findBeforeFetch: Bool = true, - commitChanges: ((MO?) -> Void)? = nil - ) -> MO? { - fetch( - entityType: entityType, predicate: NSPredicate(format: "gid == %@", gid), - findBeforeFetch: findBeforeFetch, commitChanges: commitChanges - ) - } - public func fetchOrCreate(entityType: MO.Type, gid: String) -> MO { - fetchOrCreate( - entityType: entityType, - predicate: NSPredicate(format: "gid == %@", gid), - commitChanges: { $0?.gid = gid } - ) - } - public func update( - entityType: MO.Type, gid: String, - createIfNil: Bool = false, - commitChanges: @escaping @Sendable ((MO) -> Void) - ) { - AppUtil.dispatchMainSync { - let storedMO: MO? - if createIfNil { - storedMO = fetchOrCreate(entityType: entityType, gid: gid) - } else { - storedMO = fetch(entityType: entityType, gid: gid) - } - if let storedMO = storedMO { - commitChanges(storedMO) - saveContext() - } - } - } -} - -// MARK: GalleryState Helpers -extension DatabaseClient { - public func update(gid: String, storedData: inout Data?, new: [Int: T]) { - guard !new.isEmpty, gid.isValidGID else { return } - storedData = ((storedData?.toObject() as [Int: T]?) ?? [:]) - .merging(new, uniquingKeysWith: { _, new in new }) - .toData() - } -} - -// MARK: Fetch -extension DatabaseClient { - public func fetchGallery(gid: String) -> Gallery? { - guard gid.isValidGID else { return nil } - var entity: Gallery? - AppUtil.dispatchMainSync { - entity = fetch(entityType: GalleryMO.self, gid: gid)?.toEntity() - } - return entity - } - public func fetchGalleryDetail(gid: String) -> GalleryDetail? { - guard gid.isValidGID else { return nil } - var entity: GalleryDetail? - AppUtil.dispatchMainSync { - entity = fetch(entityType: GalleryDetailMO.self, gid: gid)?.toEntity() - } - return entity - } - @MainActor public func fetchAppEnv() -> AppEnv { - fetchOrCreate(entityType: AppEnvMO.self).toEntity() - } - public func fetchAppEnvSynchronously() -> AppEnv { - fetchOrCreate(entityType: AppEnvMO.self).toEntity() - } - @MainActor public func fetchGalleryState(gid: String) async -> GalleryState? { - guard gid.isValidGID else { return nil } - return fetchOrCreate(entityType: GalleryStateMO.self, gid: gid).toEntity() - } - @MainActor public func fetchHistoryGalleries(fetchLimit: Int = 0) -> [Gallery] { - let predicate = NSPredicate(format: "lastOpenDate != nil") - let sortDescriptor = NSSortDescriptor( - keyPath: \GalleryMO.lastOpenDate, ascending: false - ) - let galleries = batchFetch( - entityType: GalleryMO.self, fetchLimit: fetchLimit, predicate: predicate, - findBeforeFetch: false, sortDescriptors: [sortDescriptor] - ) - .map { $0.toEntity() } - return galleries - } -} -// MARK: FetchAccessor -extension DatabaseClient { - public func fetchFilterSynchronously(range: FilterRange) -> Filter { - switch range { - case .search: - return fetchAppEnvSynchronously().searchFilter - case .global: - return fetchAppEnvSynchronously().globalFilter - case .watched: - return fetchAppEnvSynchronously().watchedFilter - } - } - @MainActor public func fetchHistoryKeywords() -> [String] { - fetchAppEnv().historyKeywords - } - @MainActor public func fetchQuickSearchWords() -> [QuickSearchWord] { - fetchAppEnv().quickSearchWords - } - @MainActor public func fetchGalleryPreviewURLs(gid: String) async -> [Int: URL]? { - guard gid.isValidGID else { return nil } - return await fetchGalleryState(gid: gid).map(\.previewURLs) - } -} - -// MARK: UpdateGallery -extension DatabaseClient { - @MainActor public func updateGallery(gid: String, key: String, value: Any?) { - guard gid.isValidGID else { return } - update( - entityType: GalleryMO.self, gid: gid, createIfNil: true, - commitChanges: { $0.setValue(value, forKeyPath: key) } - ) - } - @MainActor public func updateLastOpenDate(gid: String, date: Date = .now) { - guard gid.isValidGID else { return } - updateGallery(gid: gid, key: "lastOpenDate", value: date) - } - @MainActor public func clearHistoryGalleries() { - let predicate = NSPredicate(format: "lastOpenDate != nil") - batchUpdate(entityType: GalleryMO.self, predicate: predicate) { galleryMOs in - galleryMOs.forEach { galleryMO in - galleryMO.lastOpenDate = nil - } - } - } - @MainActor public func cacheGalleries(_ galleries: [Gallery]) { - for gallery in galleries.filter({ $0.id.isValidGID }) { - let storedMO = fetch( - entityType: GalleryMO.self, gid: gallery.gid - ) { managedObject in - managedObject?.category = gallery.category.rawValue - managedObject?.coverURL = gallery.coverURL - managedObject?.galleryURL = gallery.galleryURL - // managedObject?.lastOpenDate = gallery.lastOpenDate - managedObject?.pageCount = Int64(gallery.pageCount) - managedObject?.postedDate = gallery.postedDate - managedObject?.rating = gallery.rating - managedObject?.tags = gallery.tags.toData() - managedObject?.title = gallery.title - managedObject?.token = gallery.token - if let uploader = gallery.uploader { - managedObject?.uploader = uploader - } - } - if storedMO == nil { - gallery.toManagedObject(in: viewContext()) - } - } - saveContext() - } -} - -// MARK: UpdateGalleryDetail -extension DatabaseClient { - @MainActor public func cacheGalleryDetail(_ detail: GalleryDetail) { - guard detail.gid.isValidGID else { return } - let storedMO = fetch( - entityType: GalleryDetailMO.self, gid: detail.gid - ) { managedObject in - managedObject?.archiveURL = detail.archiveURL - managedObject?.category = detail.category.rawValue - managedObject?.coverURL = detail.coverURL - managedObject?.isFavorited = detail.isFavorited - managedObject?.visibility = detail.visibility.toData() - managedObject?.jpnTitle = detail.jpnTitle - managedObject?.language = detail.language.rawValue - managedObject?.favoritedCount = Int64(detail.favoritedCount) - managedObject?.pageCount = Int64(detail.pageCount) - managedObject?.parentURL = detail.parentURL - managedObject?.postedDate = detail.postedDate - managedObject?.rating = detail.rating - managedObject?.userRating = detail.userRating - managedObject?.ratingCount = Int64(detail.ratingCount) - managedObject?.sizeCount = detail.sizeCount - managedObject?.sizeType = detail.sizeType - managedObject?.title = detail.title - managedObject?.torrentCount = Int64(detail.torrentCount) - managedObject?.uploader = detail.uploader - } - if storedMO == nil { - detail.toManagedObject(in: viewContext()) - } - saveContext() - } -} - -// UpdateGalleryState and UpdateAppEnv are in DatabaseClient+Updates.swift - -// MARK: API -public enum DatabaseClientKey: DependencyKey { - public static let liveValue = DatabaseClient.live - public static let previewValue = DatabaseClient.noop - public static let testValue = DatabaseClient.unimplemented -} - -extension DependencyValues { - public var databaseClient: DatabaseClient { - get { self[DatabaseClientKey.self] } - set { self[DatabaseClientKey.self] = newValue } - } -} - -// MARK: Test -extension DatabaseClient { - public static let noop: Self = .init( - prepareDatabase: { .success(()) }, - dropDatabase: { .success(()) }, - viewContext: { - PersistenceController.shared.container.viewContext - }, - saveContext: {}, - materializedObjects: { _, _ in .init() } - ) - - public static func placeholder() -> Result { fatalError() } - - public static let unimplemented: Self = .init( - prepareDatabase: IssueReporting.unimplemented(placeholder: placeholder()), - dropDatabase: IssueReporting.unimplemented(placeholder: placeholder()), - viewContext: IssueReporting.unimplemented(placeholder: placeholder()), - saveContext: IssueReporting.unimplemented(placeholder: placeholder()), - materializedObjects: IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/.xccurrentversion b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/.xccurrentversion deleted file mode 100644 index f5b3fac01..000000000 --- a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/.xccurrentversion +++ /dev/null @@ -1,8 +0,0 @@ - - - - - _XCCurrentVersionName - Model 7.xcdatamodel - - diff --git a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents deleted file mode 100644 index ede8a2693..000000000 --- a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 2.xcdatamodel/contents +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents deleted file mode 100644 index cea21fb25..000000000 --- a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 3.xcdatamodel/contents +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents deleted file mode 100644 index 67bbf5d10..000000000 --- a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 4.xcdatamodel/contents +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents deleted file mode 100644 index 01cdfa34f..000000000 --- a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 5.xcdatamodel/contents +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents deleted file mode 100644 index 94bdc98ad..000000000 --- a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 6.xcdatamodel/contents +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents deleted file mode 100644 index d3327e779..000000000 --- a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model 7.xcdatamodel/contents +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents b/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents deleted file mode 100644 index 01182bddb..000000000 --- a/AppPackage/Sources/DatabaseClient/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/AppPackage/Sources/DatabaseClient/Resources/Model5toModel6.xcmappingmodel/xcmapping.xml b/AppPackage/Sources/DatabaseClient/Resources/Model5toModel6.xcmappingmodel/xcmapping.xml deleted file mode 100644 index 95d12a760..000000000 --- a/AppPackage/Sources/DatabaseClient/Resources/Model5toModel6.xcmappingmodel/xcmapping.xml +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - 134481920 - 521A9C87-4856-47D3-B239-6780806CD202 - 157 - - - - NSPersistenceFrameworkVersion - 1145 - NSStoreModelVersionHashes - - XDDevAttributeMapping - - 0plcXXRN7XHKl5CcF+fwriFmUpON3ZtcI/AfK748aWc= - - XDDevEntityMapping - - qeN1Ym3TkWN1G6dU9RfX6Kd2ccEvcDVWHpd3LpLgboI= - - XDDevMappingModel - - EqtMzvRnVZWkXwBHu4VeVGy8UyoOe+bi67KC79kphlQ= - - XDDevPropertyMapping - - XN33V44TTGY4JETlMoOB5yyTKxB+u4slvDIinv0rtGA= - - XDDevRelationshipMapping - - akYY9LhehVA/mCb4ATLWuI9XGLcjpm14wWL1oEBtIcs= - - - NSStoreModelVersionHashesDigest - +Hmc2uYZK6og+Pvx5GUJ7oW75UG4V/ksQanTjfTKUnxyGWJRMtB5tIRgVwGsrd7lz/QR57++wbvWsr6nxwyS0A== - NSStoreModelVersionHashesVersion - 3 - NSStoreModelVersionIdentifiers - - - - - - - - - EhPanda.GalleryStateMO5toGalleryStateMO6MigrationPolicy - GalleryStateMO - Undefined - 3 - GalleryStateMO - 1 - - - - - - sizeType - - - - EhPanda.GalleryDetailMO5toGalleryDetailMO6MigrationPolicy - GalleryDetailMO - Undefined - 1 - GalleryDetailMO - 1 - - - - - - language - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIXxAQb3JpZ2luYWxDb250ZW50c9IfIDIzXxAcTlNLZXlQYXRoU3BlY2lmaWVyRXhwcmVzc2lvbqMyJCXSHyA1Nl5OU011dGFibGVBcnJheaM1NyVXTlNBcnJhedIfIDk6XxATTlNLZXlQYXRoRXhwcmVzc2lvbqQ5OyQlXxAUTlNGdW5jdGlvbkV4cHJlc3Npb24ACAARABoAJAApADIANwBJAEwAUQBTAGAAZgBxAHsAigCdAKkAsACyALQAtgC4ALoAzQDUAN8A4QDjAOUA7ADxAPwBBQEcASABNwFEAU0BUgFdAV8BYQFjAWoBdAF2AXgBegGNAZIBsQG1AboByQHNAdUB2gHwAfUAAAAAAAACAQAAAAAAAAA8AAAAAAAAAAAAAAAAAAACDA== - - originalImageURLs - - - - visibility - - - - postedDate - - - - category - - - - previewConfig - - - - setting - - - - gid - - - - jpnTitle - - - - postedDate - - - - EhPanda.GalleryMO5toGalleryMO6MigrationPolicy - GalleryMO - Undefined - 4 - GalleryMO - 1 - - - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwTFFUkbnVsbNMNDg8QERJfEA9OU0NvbnN0YW50VmFsdWVfEBBOU0V4cHJlc3Npb25UeXBlViRjbGFzc4ACEACAA1DSFRYXGFokY2xhc3NuYW1lWCRjbGFzc2VzXxAZTlNDb25zdGFudFZhbHVlRXhwcmVzc2lvbqMXGRpcTlNFeHByZXNzaW9uWE5TT2JqZWN0CBEaJCkyN0lMUVNYXmV3ipGTlZeYnaixzdHeAAAAAAAAAQEAAAAAAAAAGwAAAAAAAAAAAAAAAAAAAOc= - - coverURL - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwTFFUkbnVsbNMNDg8QERJfEA9OU0NvbnN0YW50VmFsdWVfEBBOU0V4cHJlc3Npb25UeXBlViRjbGFzc4ACEACAA1DSFRYXGFokY2xhc3NuYW1lWCRjbGFzc2VzXxAZTlNDb25zdGFudFZhbHVlRXhwcmVzc2lvbqMXGRpcTlNFeHByZXNzaW9uWE5TT2JqZWN0CBEaJCkyN0lMUVNYXmV3ipGTlZeYnaixzdHeAAAAAAAAAQEAAAAAAAAAGwAAAAAAAAAAAAAAAAAAAOc= - - parentURL - - - - rating - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIXGdsb2JhbEZpbHRlctIfIDIzXxAcTlNLZXlQYXRoU3BlY2lmaWVyRXhwcmVzc2lvbqMyJCXSHyA1Nl5OU011dGFibGVBcnJheaM1NyVXTlNBcnJhedIfIDk6XxATTlNLZXlQYXRoRXhwcmVzc2lvbqQ5OyQlXxAUTlNGdW5jdGlvbkV4cHJlc3Npb24ACAARABoAJAApADIANwBJAEwAUQBTAGAAZgBxAHsAigCdAKkAsACyALQAtgC4ALoAzQDUAN8A4QDjAOUA7ADxAPwBBQEcASABNwFEAU0BUgFdAV8BYQFjAWoBdAF2AXgBegGHAYwBqwGvAbQBwwHHAc8B1AHqAe8AAAAAAAACAQAAAAAAAAA8AAAAAAAAAAAAAAAAAAACBg== - - watchedFilter - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIWnRodW1ibmFpbHPSHyAyM18QHE5TS2V5UGF0aFNwZWNpZmllckV4cHJlc3Npb26jMiQl0h8gNTZeTlNNdXRhYmxlQXJyYXmjNTclV05TQXJyYXnSHyA5Ol8QE05TS2V5UGF0aEV4cHJlc3Npb26kOTskJV8QFE5TRnVuY3Rpb25FeHByZXNzaW9uAAgAEQAaACQAKQAyADcASQBMAFEAUwBgAGYAcQB7AIoAnQCpALAAsgC0ALYAuAC6AM0A1ADfAOEA4wDlAOwA8QD8AQUBHAEgATcBRAFNAVIBXQFfAWEBYwFqAXQBdgF4AXoBhQGKAakBrQGyAcEBxQHNAdIB6AHtAAAAAAAAAgEAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAgQ= - - thumbnailURLs - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIXGZhdm9yZWRDb3VudNIfIDIzXxAcTlNLZXlQYXRoU3BlY2lmaWVyRXhwcmVzc2lvbqMyJCXSHyA1Nl5OU011dGFibGVBcnJheaM1NyVXTlNBcnJhedIfIDk6XxATTlNLZXlQYXRoRXhwcmVzc2lvbqQ5OyQlXxAUTlNGdW5jdGlvbkV4cHJlc3Npb24ACAARABoAJAApADIANwBJAEwAUQBTAGAAZgBxAHsAigCdAKkAsACyALQAtgC4ALoAzQDUAN8A4QDjAOUA7ADxAPwBBQEcASABNwFEAU0BUgFdAV8BYQFjAWoBdAF2AXgBegGHAYwBqwGvAbQBwwHHAc8B1AHqAe8AAAAAAAACAQAAAAAAAAA8AAAAAAAAAAAAAAAAAAACBg== - - favoritedCount - - - - torrentCount - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwTFFUkbnVsbNMNDg8QERJfEA9OU0NvbnN0YW50VmFsdWVfEBBOU0V4cHJlc3Npb25UeXBlViRjbGFzc4ACEACAA1DSFRYXGFokY2xhc3NuYW1lWCRjbGFzc2VzXxAZTlNDb25zdGFudFZhbHVlRXhwcmVzc2lvbqMXGRpcTlNFeHByZXNzaW9uWE5TT2JqZWN0CBEaJCkyN0lMUVNYXmV3ipGTlZeYnaixzdHeAAAAAAAAAQEAAAAAAAAAGwAAAAAAAAAAAAAAAAAAAOc= - - archiveURL - - - - category - - - - readingProgress - - - - ratingCount - - - - tags - - - - pageCount - - - - token - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIWWlzRmF2b3JlZNIfIDIzXxAcTlNLZXlQYXRoU3BlY2lmaWVyRXhwcmVzc2lvbqMyJCXSHyA1Nl5OU011dGFibGVBcnJheaM1NyVXTlNBcnJhedIfIDk6XxATTlNLZXlQYXRoRXhwcmVzc2lvbqQ5OyQlXxAUTlNGdW5jdGlvbkV4cHJlc3Npb24ACAARABoAJAApADIANwBJAEwAUQBTAGAAZgBxAHsAigCdAKkAsACyALQAtgC4ALoAzQDUAN8A4QDjAOUA7ADxAPwBBQEcASABNwFEAU0BUgFdAV8BYQFjAWoBdAF2AXgBegGEAYkBqAGsAbEBwAHEAcwB0QHnAewAAAAAAAACAQAAAAAAAAA8AAAAAAAAAAAAAAAAAAACAw== - - isFavorited - - - - comments - - - - gid - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwTFFUkbnVsbNMNDg8QERJfEA9OU0NvbnN0YW50VmFsdWVfEBBOU0V4cHJlc3Npb25UeXBlViRjbGFzc4ACEACAA1DSFRYXGFokY2xhc3NuYW1lWCRjbGFzc2VzXxAZTlNDb25zdGFudFZhbHVlRXhwcmVzc2lvbqMXGRpcTlNFeHByZXNzaW9uWE5TT2JqZWN0CBEaJCkyN0lMUVNYXmV3ipGTlZeYnaixzdHeAAAAAAAAAQEAAAAAAAAAGwAAAAAAAAAAAAAAAAAAAOc= - - coverURL - - - - uploader - - - - EhPanda/Database/Model.xcdatamodeld/Model 5.xcdatamodel - YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 -cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxEE+AALAAwAGwA3ADgAOQBHAEgASQBKAEsAZgBnAGgAbgBvAHsAkQCSAJMAlACVAJYAlwCYAJkAmgCzALYAvQDDANIA4QDkAPMBAgEFAGUBFQEkASgBLAE7AUEBQgFKAVkBWgFjAXcBeAF5AXoBewF8AX0BfgF/AZQBlQGdAZ4BnwGrAb8BwAHBAcIBwwHEAcUBxgHHAdYB5QH0AfgCBwIWAhcCJgI1AkQCUAJiAmMCZAJlAmYCZwJoAmkCeAKHApYCpQKmArUCxALTAtsC8ALxAvkDBQMZAygDNwNGA0oDWQNoA3cDhgOVA6EDswPCA9ED4APvA/4EDQQcBDEEMgQ6BEYEWgRpBHgEhwSLBJoEqQS4BMcE1gTiBPQFAwUEBRMFIgUxBTIFQQVQBV8FdAV1BX0FiQWdBawFuwXKBc4F3QXsBfsGCgYZBiUGNwZGBlUGZAZzBoIGkQagBrUGtga+BsoG3gbtBvwHCwcPBx4HLQc8B0sHWgdmB3gHhweWB6UHtAfDB9IH4Qf2B/cH/wgLCB8ILgg9CEwIUAhfCG4IfQiMCJsIpwi5CMgI1wjmCPUJBAkTCSIJNwk4CUAJTAlgCW8JfgmNCZEJoAmvCb4JzQncCegJ+goJChgKJwo2CkUKVApjCngKeQqBCo0KoQqwCr8KzgrSCuEK8Ar/Cw4LHQspCzsLSgtLC1oLaQt4C3kLiAuXC6YLpwuqC7MLzQvOC9QL4Av2DAUMCAwXDCYMKQw4DEcMSgxZDGgMbAx7DIoMiwy3DLgMuQy6DLsMvAy9DL4MvwzADMEMwgzDDMQMxQzGDMcMyAzJDMoM3wzgDOgM9A0IDRcNJg01DTkNSA1XDWYNdQ2EDZANog2xDcANzw3eDe0N/A4LDiAOIQ4pDjUOSQ5YDmcOdg56DokOmA6nDrYOxQ7RDuMO8g8BDxAPHw8uDz0PTA9hD2IPag92D4oPmQ+oD7cPuw/KD9kP6A/3EAYQEhAkEDMQQhBREGAQYRBwEH8QjhCjEKQQrBC4EMwQ2xDqEPkQ/REMERsRKhE5EUgRVBFmEXURhBGTEaIRsRHAEc8R5BHlEe0R+RINEhwSKxI6Ej4STRJcEmsSehKJEpUSpxK2EsUS1BLjEvITARMQEyUTJhMuEzoTThNdE2wTexN/E44TnROsE7sTyhPWE+gT9xP4FAcUFhQlFDQUQxRSFGcUaBRwFHwUkBSfFK4UvRTBFNAU3xTuFP0VDBUYFSoVORVIFVcVZhV1FYQVkxWoFakVsRW9FdEV4BXvFf4WAhYRFiAWLxY+Fk0WWRZrFnoWiRaYFqcWthbFFtQW6RbqFvIW/hcSFyEXMBc/F0MXUhdhF3AXfxeOF5oXrBe7F8oX2RfoF/cYBhgVGCoYKxgzGD8YUxhiGHEYgBiEGJMYohixGMAYzxjbGO0Y/Bj9GQwZGxkqGSsZOhlJGVgZbRluGXYZghmWGaUZtBnDGccZ1hnlGfQaAxoSGh4aMBo/Gk4aXRpsGnsaihqZGq4arxq3GsMa1xrmGvUbBBsIGxcbJhs1G0QbUxtfG3EbgBuPG54brRu8G8sb2hvvG/Ab+BwEHBgcJxw2HEUcSRxYHGccdhyFHJQcoByyHMEc0BzfHO4c/R0MHRsdMB0xHTkdRR1ZHWgddx2GHYodmR2oHbcdxh3VHeEd8x4CHhEeIB4vHj4eTR5cHnEech56HoYemh6pHrgexx7LHtoe6R74HwcfFh8iHzQfQx9SH2EfcB9/H44fnR+yH7Mfux/HH9sf6h/5IAggDCAbICogOSBIIFcgYyB1IIQgkyCiILEgwCDPIN4g8yD0IPwhCCEcISshOiFJIU0hXCFrIXohiSGYIaQhtiHFIcYh1SHkIfMh9CIDIhIiISI2IjciPyJLIl8ibiJ9IowikCKfIq4ivSLMItsi5yL5IwgjFyMmIzUjRCNTI2IjdyN4I4AjjCOgI68jviPNI9Ej4CPvI/4kDSQcJCgkOiRJJFgkZyR2JIUklCSjJLgkuSTBJM0k4STwJP8lDiUSJSElMCU/JU4lXSVpJXsliiWZJagltyXGJdUl5CXnJgEmAiYIJhQmKiY5JjwmSyZaJl0mbCZ7Jn4mjSacJqAmrya+Jr8m2ybcJt0m3ibfJvQm9Sb9JwknHScsJzsnSidOJ10nbCd7J4onmSelJ7cnxifVJ+Qn8ygCKBEoICg1KDYoPihKKF4obSh8KIsojyieKK0ovCjLKNoo5ij4KQcpFiklKTQpQylSKWEpdil3KX8piymfKa4pvSnMKdAp3ynuKf0qDCobKicqOSpIKlcqZip1KoQqkyqiKrcquCrAKswq4CrvKv4rDSsRKyArLys+K00rXCtoK3oriSuKK5krqCu3K8Yr1SvkK/kr+iwCLA4sIiwxLEAsTyxTLGIscSyALI8sniyqLLwsyyzaLOks+C0HLRYtJS06LTstQy1PLWMtci2BLZAtlC2jLbItwS3QLd8t6y39LgwuGy4qLjkuSC5XLmYuey58LoQukC6kLrMuwi7RLtUu5C7zLwIvES8gLywvPi9NL1wvay96L4kvmC+nL7wvvS/FL9Ev5S/0MAMwEjAWMCUwNDBDMFIwYTBtMH8wjjCdMKwwuzDKMNkw6DD9MP4xBjESMSYxNTFEMVMxVzFmMXUxhDGTMaIxrjHAMc8x3jHtMfwyCzIaMikyPjI/MkcyUzJnMnYyhTKUMpgypzK2MsUy1DLjMu8zATMQMx8zLjM9M0wzWzNqM38zgDOIM5QzqDO3M8Yz1TPZM+gz9zQGNBU0JDQwNEI0UTRgNG80fjSNNJw0qzTANME0yTTVNOk0+DUHNRY1GjUpNTg1RzVWNWU1cTWDNZI1oTWwNb81zjXdNew17zYJNgo2EDYcNjI2QTZENlM2YjZlNnQ2gzaGNpU2pDaoNrc2xjbHNtk22jbbNtw23TbeNt824Db1NvY2/jcKNx43LTc8N0s3TzdeN203fDeLN5o3pje4N8c31jflN/Q4AzgSOCE4Njg3OD84SzhfOG44fTiMOJA4nziuOL04zDjbOOc4+TkIORc5Jjk1OUQ5UzliOXc5eDmAOYw5oDmvOb45zTnROeA57zn+Og06HDooOjo6STpYOmc6djqFOpQ6ozq4Ork6wTrNOuE68Dr/Ow47EjshOzA7PztOO107aTt7O4o7mTuoO7c7xjvVO+Q7+Tv6PAI8DjwiPDE8QDxPPFM8YjxxPIA8jzyePKo8vDzLPNo86Tz4PQc9Fj0lPTo9Oz1DPU89Yz1yPYE9kD2UPaM9sj3BPdA93z3rPf0+DD4bPio+OT5IPlc+Zj57Pnw+hD6QPqQ+sz7CPtE+1T7kPvM/Aj8RPyA/LD8+P00/XD9rP3o/iT+YP6c/qj+uP7I/tj++P8E/xVUkbnVsbNcADQAOAA8AEAARABIAEwAUABUAFgAXABgAFwAaXxAPX3hkX3Jvb3RQYWNrYWdlViRjbGFzc1xfeGRfY29tbWVudHNfEBBfeGRfbW9kZWxNYW5hZ2VyXxAVX2NvbmZpZ3VyYXRpb25zQnlOYW1lXV94ZF9tb2RlbE5hbWVfEBdfbW9kZWxWZXJzaW9uSWRlbnRpZmllcoACgQT3gQT1gACBBPaAAIEBAd4AHAAdAB4AHwAgACEAIgAOACMAJAAlACYAJwAoACkAKgArAAkAKQAXAC8AMAAxADIAMwApACkAF18QHFhEQnVja2V0Rm9yQ2xhc3Nlc3dhc0VuY29kZWRfEBpYREJ1Y2tldEZvclBhY2thZ2Vzc3RvcmFnZV8QHFhEQnVja2V0Rm9ySW50ZXJmYWNlc3N0b3JhZ2VfEA9feGRfb3duaW5nTW9kZWxfEB1YREJ1Y2tldEZvclBhY2thZ2Vzd2FzRW5jb2RlZFZfb3duZXJfEBtYREJ1Y2tldEZvckRhdGFUeXBlc3N0b3JhZ2VbX3Zpc2liaWxpdHlfEBlYREJ1Y2tldEZvckNsYXNzZXNzdG9yYWdlVV9uYW1lXxAfWERCdWNrZXRGb3JJbnRlcmZhY2Vzd2FzRW5jb2RlZF8QHlhEQnVja2V0Rm9yRGF0YVR5cGVzd2FzRW5jb2RlZF8QEF91bmlxdWVFbGVtZW50SUSABIEE84EE8YABgASAAIEE8oEE9BAAgAWAA4AEgASAAFBTWUVT0wA6ADsADgA8AEEARldOUy5rZXlzWk5TLm9iamVjdHOkAD0APgA/AECABoAHgAiACaQAQgBDAEQARYAKgQEMgQMGgQQ0gCheR2FsbGVyeVN0YXRlTU9fEA9HYWxsZXJ5RGV0YWlsTU9ZR2FsbGVyeU1PWEFwcEVudk1P3xAQAEwATQBOAE8AIQBQAFEAIwBSAFMADgAlAFQAVQAoAFYAVwBYACkAKQAUAFwAXQAxACkAVwBgAD0AVwBjAGQAZV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZVtfaXNBYnN0cmFjdIAMgDCABIAEgAKADYEBCYAEgAyBAQuABoAMgQEKgAsIEi7IzU9Xb3JkZXJlZNMAOgA7AA4AaQBrAEahAGqADqEAbIAPgCheWERfUFN0ZXJlb3R5cGXZACEAJQBwAA4AKABxACMAVgByAEIAagBXAHYAFwApADEAZQB6XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgAqADoAMgC+AAIAECIAQ0wA6ADsADgB8AIYARqkAfQB+AH8AgACBAIIAgwCEAIWAEYASgBOAFIAVgBaAF4AYgBmpAIcAiACJAIoAiwCMAI0AjgCPgBqAHoAfgCGAIoAkgCaAKYAtgChfEBNYRFBNQ29tcG91bmRJbmRleGVzXxAQWERfUFNLX2VsZW1lbnRJRF8QGVhEUE1VbmlxdWVuZXNzQ29uc3RyYWludHNfEBpYRF9QU0tfdmVyc2lvbkhhc2hNb2RpZmllcl8QGVhEX1BTS19mZXRjaFJlcXVlc3RzQXJyYXlfEBFYRF9QU0tfaXNBYnN0cmFjdF8QD1hEX1BTS191c2VySW5mb18QE1hEX1BTS19jbGFzc01hcHBpbmdfEBZYRF9QU0tfZW50aXR5Q2xhc3NOYW1l3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcApgAXAGwAZQBlAGUAMQBlAK0AfQBlAGUAFwBlVV90eXBlWF9kZWZhdWx0XF9hc3NvY2lhdGlvbltfaXNSZWFkT25seVlfaXNTdGF0aWNZX2lzVW5pcXVlWl9pc0Rlcml2ZWRaX2lzT3JkZXJlZFxfaXNDb21wb3NpdGVXX2lzTGVhZoAAgBuAAIAPCAgICIAdgBEICIAACNIAOwAOALQAtaCAHNIAtwC4ALkAulokY2xhc3NuYW1lWCRjbGFzc2VzXk5TTXV0YWJsZUFycmF5owC5ALsAvFdOU0FycmF5WE5TT2JqZWN00gC3ALgAvgC/XxAQWERVTUxQcm9wZXJ0eUltcKQAwADBAMIAvF8QEFhEVU1MUHJvcGVydHlJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwBsAGUAZQBlADEAZQCtAH4AZQBlABcAZYAAgACAAIAPCAgICIAdgBIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXANQAFwBsAGUAZQBlADEAZQCtAH8AZQBlABcAZYAAgCCAAIAPCAgICIAdgBMICIAACNIAOwAOAOIAtaCAHN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwBsAGUAZQBlADEAZQCtAIAAZQBlABcAZYAAgACAAIAPCAgICIAdgBQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAPUAFwBsAGUAZQBlADEAZQCtAIEAZQBlABcAZYAAgCOAAIAPCAgICIAdgBUICIAACNIAOwAOAQMAtaCAHN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwBsAGUAZQBlADEAZQCtAIIAZQBlABcAZYAAgCWAAIAPCAgICIAdgBYICIAACAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEXABcAbABlAGUAZQAxAGUArQCDAGUAZQAXAGWAAIAngACADwgICAiAHYAXCAiAAAjTADoAOwAOASUBJgBGoKCAKNIAtwC4ASkBKl8QE05TTXV0YWJsZURpY3Rpb25hcnmjASkBKwC8XE5TRGljdGlvbmFyed8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAS4AFwBsAGUAZQBlADEAZQCtAIQAZQBlABcAZYAAgCqAAIAPCAgICIAdgBgICIAACNYAJQAOACgAVgAhACMBPAE9ABcAZQAXADGAK4AsgAAIgABfEBRYREdlbmVyaWNSZWNvcmRDbGFzc9IAtwC4AUMBRF1YRFVNTENsYXNzSW1wpgFFAUYBRwFIAUkAvF1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAUwAFwBsAGUAZQBlADEAZQCtAIUAZQBlABcAZYAAgC6AAIAPCAgICIAdgBkICIAACF8QDy5HYWxsZXJ5U3RhdGVNT9IAtwC4AVsBXF8QElhEVU1MU3RlcmVvdHlwZUltcKcBXQFeAV8BYAFhAWIAvF8QElhEVU1MU3RlcmVvdHlwZUltcF1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcNMAOgA7AA4BZAFtAEaoAWUBZgFnAWgBaQFqAWsBbIAxgDKAM4A0gDWANoA3gDioAW4BbwFwAXEBcgFzAXQBdYA5gGSAe4CUgKuAwoDZgPCAKFhjb21tZW50c1p0aHVtYm5haWxzXxAPcmVhZGluZ1Byb2dyZXNzXxAQb3JpZ2luYWxDb250ZW50c1hjb250ZW50c1hwcmV2aWV3c1R0YWdzU2dpZN8QEgCbAJwAnQGAACEAnwCgAYEAIwCeAYIAoQAOACUAogCjACgApAAXABcAFwApAEIAZQBlAYoAMQBlAFcAZQGOAWUAZQBlAZIAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgAoICIA7CIAMCIBjgDEICIA6CBL1LwGf0wA6ADsADgGWAZkARqIBlwGYgDyAPaIBmgGbgD6AUoAoXxASWERfUFByb3BTdGVyZW90eXBlXxASWERfUEF0dF9TdGVyZW90eXBl2QAhACUBoAAOACgBoQAjAFYBogFuAZcAVwB2ABcAKQAxAGUBql8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYA5gDyADIAvgACABAiAP9MAOgA7AA4BrAG1AEaoAa0BrgGvAbABsQGyAbMBtIBAgEGAQoBDgESARYBGgEeoAbYBtwG4AbkBugG7AbwBvYBIgEmASoBMgE2AT4BQgFGAKF8QG1hEX1BQU0tfaXNTdG9yZWRJblRydXRoRmlsZV8QG1hEX1BQU0tfdmVyc2lvbkhhc2hNb2RpZmllcl8QEFhEX1BQU0tfdXNlckluZm9fEBFYRF9QUFNLX2lzSW5kZXhlZF8QElhEX1BQU0tfaXNPcHRpb25hbF8QGlhEX1BQU0tfaXNTcG90bGlnaHRJbmRleGVkXxARWERfUFBTS19lbGVtZW50SURfEBNYRF9QUFNLX2lzVHJhbnNpZW503xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAZoAZQBlAGUAMQBlAK0BrQBlAGUAFwBlgACAJYAAgD4ICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAZoAZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgD4ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcB5wAXAZoAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACAS4AAgD4ICAgIgB2AQggIgAAI0wA6ADsADgH1AfYARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcBmgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACAPggICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABcBmgBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACAPggICAiAHYBECAiAAAgJ3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAZoAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgD4ICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAZoAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAAIAAgD4ICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAZoAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgD4ICAgIgB2ARwgIgAAI2QAhACUCRQAOACgCRgAjAFYCRwFuAZgAVwB2ABcAKQAxAGUCT18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYA5gD2ADIAvgACABAiAU9MAOgA7AA4CUQJZAEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcCWgJbAlwCXQJeAl8CYIBbgFyAXYBegGCAYYBigChfEB1YRF9QQXR0S19kZWZhdWx0VmFsdWVBc1N0cmluZ18QKFhEX1BBdHRLX2FsbG93c0V4dGVybmFsQmluYXJ5RGF0YVN0b3JhZ2VfEBdYRF9QQXR0S19taW5WYWx1ZVN0cmluZ18QFlhEX1BBdHRLX2F0dHJpYnV0ZVR5cGVfEBdYRF9QQXR0S19tYXhWYWx1ZVN0cmluZ18QHVhEX1BBdHRLX3ZhbHVlVHJhbnNmb3JtZXJOYW1lXxAgWERfUEF0dEtfcmVndWxhckV4cHJlc3Npb25TdHJpbmffEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcBmwBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIAAgACAUggICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcBmwBlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACAUggICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcBmwBlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACAUggICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKYABcBmwBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIBfgACAUggICAiAHYBXCAiAAAgRA+jfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcBmwBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAAgACAUggICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcBmwBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACAUggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcBmwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACAUggICAiAHYBaCAiAAAjSALcAuALUAtVdWERQTUF0dHJpYnV0ZaYC1gLXAtgC2QLaALxdWERQTUF0dHJpYnV0ZVxYRFBNUHJvcGVydHlfEBBYRFVNTFByb3BlcnR5SW1wXxAUWERVTUxOYW1lZEVsZW1lbnRJbXBfEA9YRFVNTEVsZW1lbnRJbXDfEBIAmwCcAJ0C3AAhAJ8AoALdACMAngLeAKEADgAlAKIAowAoAKQAFwAXABcAKQBCAGUAZQLmADEAZQBXAGUBjgFmAGUAZQLuAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIAKCAiAZgiADAiAY4AyCAiAZQgSNyAe89MAOgA7AA4C8gL1AEaiAZcBmIA8gD2iAvYC94BngHKAKNkAIQAlAvoADgAoAvsAIwBWAvwBbwGXAFcAdgAXACkAMQBlAwRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAZIA8gAyAL4AAgAQIgGjTADoAOwAOAwYDDwBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqAMQAxEDEgMTAxQDFQMWAxeAaYBqgGuAbYBugG+AcIBxgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcC9gBlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACAZwgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcC9gBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACAZwgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwM5ABcC9gBlAGUAZQAxAGUArQGvAGUAZQAXAGWAAIBsgACAZwgICAiAHYBCCAiAAAjTADoAOwAOA0cDSABGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwL2AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIBnCAgICIAdgEMICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgkAFwL2AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgE6AAIBnCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwL2AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIBnCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwL2AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIBnCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwL2AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIBnCAgICIAdgEcICIAACNkAIQAlA5YADgAoA5cAIwBWA5gBbwGYAFcAdgAXACkAMQBlA6BfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAZIA9gAyAL4AAgAQIgHPTADoAOwAOA6IDqgBGpwJSAlMCVAJVAlYCVwJYgFSAVYBWgFeAWIBZgFqnA6sDrAOtA64DrwOwA7GAdIB1gHaAd4B4gHmAeoAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAvcAZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACAAIAAgHIICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAvcAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgHIICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAvcAZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgHIICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmAAXAvcAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAX4AAgHIICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAvcAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgHIICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAvcAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgHIICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAvcAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgHIICAgIgB2AWggIgAAI3xASAJsAnACdBB0AIQCfAKAEHgAjAJ4EHwChAA4AJQCiAKMAKACkABcAFwAXACkAQgBlAGUEJwAxAGUAVwBlAY4BZwBlAGUELwBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASACggIgH0IgAwIgGOAMwgIgHwIElyQg4zTADoAOwAOBDMENgBGogGXAZiAPIA9ogQ3BDiAfoCJgCjZACEAJQQ7AA4AKAQ8ACMAVgQ9AXABlwBXAHYAFwApADEAZQRFXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgHuAPIAMgC+AAIAECIB/0wA6ADsADgRHBFAARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gEUQRSBFMEVARVBFYEVwRYgICAgYCCgISAhYCGgIeAiIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXBDcAZQBlAGUAMQBlAK0BrQBlAGUAFwBlgACAJYAAgH4ICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBDcAZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgH4ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcEegAXBDcAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACAg4AAgH4ICAgIgB2AQggIgAAI0wA6ADsADgSIBIkARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcENwBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACAfggICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABcENwBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACAfggICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcENwBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIAlgACAfggICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcENwBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACAfggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcENwBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACAfggICAiAHYBHCAiAAAjZACEAJQTXAA4AKATYACMAVgTZAXABmABXAHYAFwApADEAZQThXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgHuAPYAMgC+AAIAECICK0wA6ADsADgTjBOsARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapwTsBO0E7gTvBPAE8QTygIuAjYCOgI+AkYCSgJOAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXBPYAFwQ4AGUAZQBlADEAZQCtAlIAZQBlABcAZYAAgIyAAICJCAgICIAdgFQICIAACFEw3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXBDgAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgIkICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBDgAZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgIkICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFJAAXBDgAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAkIAAgIkICAgIgB2AVwgIgAAIEQEs3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBDgAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgIkICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBDgAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgIkICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBDgAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgIkICAgIgB2AWggIgAAI3xASAJsAnACdBWAAIQCfAKAFYQAjAJ4FYgChAA4AJQCiAKMAKACkABcAFwAXACkAQgBlAGUFagAxAGUAVwBlAY4BaABlAGUFcgBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASACggIgJYIgAwIgGOANAgIgJUIEsl5wG/TADoAOwAOBXYFeQBGogGXAZiAPIA9ogV6BXuAl4CigCjZACEAJQV+AA4AKAV/ACMAVgWAAXEBlwBXAHYAFwApADEAZQWIXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgJSAPIAMgC+AAIAECICY0wA6ADsADgWKBZMARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gFlAWVBZYFlwWYBZkFmgWbgJmAmoCbgJ2AnoCfgKCAoYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXBXoAZQBlAGUAMQBlAK0BrQBlAGUAFwBlgACAJYAAgJcICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBXoAZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgJcICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFvQAXBXoAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACAnIAAgJcICAgIgB2AQggIgAAI0wA6ADsADgXLBcwARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcFegBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACAlwgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABcFegBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACAlwgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcFegBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIAlgACAlwgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcFegBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACAlwgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcFegBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACAlwgICAiAHYBHCAiAAAjZACEAJQYaAA4AKAYbACMAVgYcAXEBmABXAHYAFwApADEAZQYkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgJSAPYAMgC+AAIAECICj0wA6ADsADgYmBi4ARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapwYvBjAGMQYyBjMGNAY1gKSApYCmgKeAqICpgKqAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwV7AGUAZQBlADEAZQCtAlIAZQBlABcAZYAAgACAAICiCAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwV7AGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAICiCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwV7AGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAICiCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXApgAFwV7AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgF+AAICiCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwV7AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAICiCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwV7AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAICiCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwV7AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgACAAICiCAgICIAdgFoICIAACN8QEgCbAJwAnQahACEAnwCgBqIAIwCeBqMAoQAOACUAogCjACgApAAXABcAFwApAEIAZQBlBqsAMQBlAFcAZQGOAWkAZQBlBrMAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgAoICICtCIAMCIBjgDUICICsCBLCUtlX0wA6ADsADga3BroARqIBlwGYgDyAPaIGuwa8gK6AuYAo2QAhACUGvwAOACgGwAAjAFYGwQFyAZcAVwB2ABcAKQAxAGUGyV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYCrgDyADIAvgACABAiAr9MAOgA7AA4GywbUAEaoAa0BrgGvAbABsQGyAbMBtIBAgEGAQoBDgESARYBGgEeoBtUG1gbXBtgG2QbaBtsG3ICwgLGAsoC0gLWAtoC3gLiAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwa7AGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAICuCAgICIAdgEAICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwa7AGUAZQBlADEAZQCtAa4AZQBlABcAZYAAgACAAICuCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXBv4AFwa7AGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgLOAAICuCAgICIAdgEIICIAACNMAOgA7AA4HDAcNAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXBrsAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgK4ICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCCQAXBrsAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAToAAgK4ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXBrsAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgK4ICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBrsAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAAIAAgK4ICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXBrsAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgK4ICAgIgB2ARwgIgAAI2QAhACUHWwAOACgHXAAjAFYHXQFyAZgAVwB2ABcAKQAxAGUHZV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYCrgD2ADIAvgACABAiAutMAOgA7AA4HZwdvAEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcHcAdxB3IHcwd0B3UHdoC7gLyAvYC+gL+AwIDBgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcGvABlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIAAgACAuQgICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcGvABlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACAuQgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcGvABlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACAuQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKYABcGvABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIBfgACAuQgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcGvABlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAAgACAuQgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcGvABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACAuQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcGvABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACAuQgICAiAHYBaCAiAAAjfEBIAmwCcAJ0H4gAhAJ8AoAfjACMAngfkAKEADgAlAKIAowAoAKQAFwAXABcAKQBCAGUAZQfsADEAZQBXAGUBjgFqAGUAZQf0AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIAKCAiAxAiADAiAY4A2CAiAwwgSUsA8xNMAOgA7AA4H+Af7AEaiAZcBmIA8gD2iB/wH/YDFgNCAKNkAIQAlCAAADgAoCAEAIwBWCAIBcwGXAFcAdgAXACkAMQBlCApfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAwoA8gAyAL4AAgAQIgMbTADoAOwAOCAwIFQBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqAgWCBcIGAgZCBoIGwgcCB2Ax4DIgMmAy4DMgM2AzoDPgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcH/ABlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACAxQgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcH/ABlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACAxQgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwg/ABcH/ABlAGUAZQAxAGUArQGvAGUAZQAXAGWAAIDKgACAxQgICAiAHYBCCAiAAAjTADoAOwAOCE0ITgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwf8AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIDFCAgICIAdgEMICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgkAFwf8AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgE6AAIDFCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwf8AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIDFCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwf8AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIDFCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwf8AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIDFCAgICIAdgEcICIAACNkAIQAlCJwADgAoCJ0AIwBWCJ4BcwGYAFcAdgAXACkAMQBlCKZfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAwoA9gAyAL4AAgAQIgNHTADoAOwAOCKgIsABGpwJSAlMCVAJVAlYCVwJYgFSAVYBWgFeAWIBZgFqnCLEIsgizCLQItQi2CLeA0oDTgNSA1YDWgNeA2IAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXB/0AZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACAAIAAgNAICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXB/0AZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgNAICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXB/0AZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgNAICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmAAXB/0AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAX4AAgNAICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXB/0AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgNAICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXB/0AZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgNAICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXB/0AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgNAICAgIgB2AWggIgAAI3xASAJsAnACdCSMAIQCfAKAJJAAjAJ4JJQChAA4AJQCiAKMAKACkABcAFwAXACkAQgBlAGUJLQAxAGUAVwBlAY4BawBlAGUJNQBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASACggIgNsIgAwIgGOANwgIgNoIEpLRZ5LTADoAOwAOCTkJPABGogGXAZiAPIA9ogk9CT6A3IDngCjZACEAJQlBAA4AKAlCACMAVglDAXQBlwBXAHYAFwApADEAZQlLXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgNmAPIAMgC+AAIAECIDd0wA6ADsADglNCVYARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gJVwlYCVkJWglbCVwJXQlegN6A34DggOKA44DkgOWA5oAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXCT0AZQBlAGUAMQBlAK0BrQBlAGUAFwBlgACAJYAAgNwICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCT0AZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgNwICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcJgAAXCT0AZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACA4YAAgNwICAgIgB2AQggIgAAI0wA6ADsADgmOCY8ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcJPQBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACA3AgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABcJPQBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACA3AgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcJPQBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIAlgACA3AgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcJPQBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACA3AgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcJPQBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACA3AgICAiAHYBHCAiAAAjZACEAJQndAA4AKAneACMAVgnfAXQBmABXAHYAFwApADEAZQnnXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgNmAPYAMgC+AAIAECIDo0wA6ADsADgnpCfEARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapwnyCfMJ9An1CfYJ9wn4gOmA6oDrgOyA7YDugO+AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwk+AGUAZQBlADEAZQCtAlIAZQBlABcAZYAAgACAAIDnCAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwk+AGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAIDnCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwk+AGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIDnCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXApgAFwk+AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgF+AAIDnCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwk+AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIDnCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwk+AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIDnCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwk+AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgACAAIDnCAgICIAdgFoICIAACN8QEgCbAJwAnQpkACEAnwCgCmUAIwCeCmYAoQAOACUAogCjACgApAAXABcAFwApAEIAZQBlCm4AMQBlAFcAZQGOAWwAZQBlCnYAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgAoICIDyCIAMCIBjgDgICIDxCBK9SdSI0wA6ADsADgp6Cn0ARqIBlwGYgDyAPaIKfgp/gPOA/oAo2QAhACUKggAOACgKgwAjAFYKhAF1AZcAVwB2ABcAKQAxAGUKjF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYDwgDyADIAvgACABAiA9NMAOgA7AA4KjgqXAEaoAa0BrgGvAbABsQGyAbMBtIBAgEGAQoBDgESARYBGgEeoCpgKmQqaCpsKnAqdCp4Kn4D1gPaA94D5gPqA+4D8gP2AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwp+AGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIDzCAgICIAdgEAICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwp+AGUAZQBlADEAZQCtAa4AZQBlABcAZYAAgACAAIDzCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXCsEAFwp+AGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgPiAAIDzCAgICIAdgEIICIAACNMAOgA7AA4KzwrQAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXCn4AZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgPMICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXCn4AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAJYAAgPMICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXCn4AZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgPMICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCn4AZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAAIAAgPMICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXCn4AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgPMICAgIgB2ARwgIgAAI2QAhACULHgAOACgLHwAjAFYLIAF1AZgAVwB2ABcAKQAxAGULKF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYDwgD2ADIAvgACABAiA/9MAOgA7AA4LKgsyAEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcLMws0CzULNgs3CzgLOYEBAIEBAoEBA4EBBIEBBoEBB4EBCIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXCn8AZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACBAQGAAID+CAgICIAdgFQICIAACFDfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcKfwBlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACA/ggICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcKfwBlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACA/ggICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtrABcKfwBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEBBYAAgP4ICAgIgB2AVwgIgAAIEQK83xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCn8AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgP4ICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCn8AZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgP4ICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCn8AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgP4ICAgIgB2AWggIgAAIWmR1cGxpY2F0ZXPSADsADguoALWggBzSALcAuAurC6xaWERQTUVudGl0eacLrQuuC68LsAuxC7IAvFpYRFBNRW50aXR5XVhEVU1MQ2xhc3NJbXBfEBJYRFVNTENsYXNzaWZpZXJJbXBfEBFYRFVNTE5hbWVzcGFjZUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w3xAQC7QLtQu2C7cAIQu4C7kAIwu6C7sADgAlC7wLvQAoAFYAVwu/ACkAKQAUC8MAXQAxACkAVwBgAD4AVwvKC8sAZV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZYAMgQEfgASABIACgQEOgQEJgASADIEBC4AHgAyBAwWBAQ0IEqlMFM3TADoAOwAOC88L0QBGoQBqgA6hC9KBAQ+AKNkAIQAlC9UADgAoC9YAIwBWC9cAQwBqAFcAdgAXACkAMQBlC99fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAQyADoAMgC+AAIAECIEBENMAOgA7AA4L4QvrAEapAH0AfgB/AIAAgQCCAIMAhACFgBGAEoATgBSAFYAWgBeAGIAZqQvsC+0L7gvvC/AL8QvyC/ML9IEBEYEBE4EBFIEBFoEBF4EBGYEBGoEBHIEBHYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcL+AAXC9IAZQBlAGUAMQBlAK0AfQBlAGUAFwBlgACBARKAAIEBDwgICAiAHYARCAiAAAjSADsADgwGALWggBzfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcL0gBlAGUAZQAxAGUArQB+AGUAZQAXAGWAAIAAgACBAQ8ICAgIgB2AEggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcMGQAXC9IAZQBlAGUAMQBlAK0AfwBlAGUAFwBlgACBARWAAIEBDwgICAiAHYATCAiAAAjSADsADgwnALWggBzfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcL0gBlAGUAZQAxAGUArQCAAGUAZQAXAGWAAIAAgACBAQ8ICAgIgB2AFAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcMOgAXC9IAZQBlAGUAMQBlAK0AgQBlAGUAFwBlgACBARiAAIEBDwgICAiAHYAVCAiAAAjSADsADgxIALWggBzfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcL0gBlAGUAZQAxAGUArQCCAGUAZQAXAGWAAIAlgACBAQ8ICAgIgB2AFggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcMWwAXC9IAZQBlAGUAMQBlAK0AgwBlAGUAFwBlgACBARuAAIEBDwgICAiAHYAXCAiAAAjTADoAOwAODGkMagBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAS4AFwvSAGUAZQBlADEAZQCtAIQAZQBlABcAZYAAgCqAAIEBDwgICAiAHYAYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwx9ABcL0gBlAGUAZQAxAGUArQCFAGUAZQAXAGWAAIEBHoAAgQEPCAgICIAdgBkICIAACF8QEC5HYWxsZXJ5RGV0YWlsTU/TADoAOwAODIwMoQBGrxAUDI0MjgyPDJAMkQySDJMMlAyVDJYMlwyYDJkMmgybDJwMnQyeAWwMoIEBIIEBIYEBIoEBI4EBJIEBJYEBJoEBJ4EBKIEBKYEBKoEBK4EBLIEBLYEBLoEBL4EBMIEBMYA4gQEyrxAUDKIMowykDKUMpgynDKgMqQyqDKsMrAytDK4MrwywDLEMsgyzDLQMtYEBM4EBSoEBYYEBeYEBkIEBp4EBv4EB1oEB7YECBIECHYECNIECS4ECYoECeYECkIECp4ECwIEC14EC7oAoWGNvdmVyVVJMXHRvcnJlbnRDb3VudFlzaXplQ291bnRVdGl0bGVYbGFuZ3VhZ2VadXNlclJhdGluZ1xmYXZvcmVkQ291bnRbcmF0aW5nQ291bnRaYXJjaGl2ZVVSTFlpc0Zhdm9yZWRadmlzaWJpbGl0eVhjYXRlZ29yeVlwYWdlQ291bnRZcGFyZW50VVJMWHVwbG9hZGVyWHNpemVUeXBlWnBvc3RlZERhdGVYanBuVGl0bGVWcmF0aW5n3xASAJsAnACdDMsAIQCfAKAMzAAjAJ4MzQChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUM1QAxAGUAVwBlAY4MjQBlAGUM3QBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAQwICIEBNQiADAiAY4EBIAgIgQE0CBJXzw5F0wA6ADsADgzhDOQARqIBlwGYgDyAPaIM5QzmgQE2gQFBgCjZACEAJQzpAA4AKAzqACMAVgzrDKIBlwBXAHYAFwApADEAZQzzXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQEzgDyADIAvgACABAiBATfTADoAOwAODPUM/gBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqAz/DQANAQ0CDQMNBA0FDQaBATiBATmBATqBATyBAT2BAT6BAT+BAUCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwzlAGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEBNggICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcM5QBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBATYICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcNKAAXDOUAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBATuAAIEBNggICAiAHYBCCAiAAAjTADoAOwAODTYNNwBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwzlAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEBNggICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcM5QBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAlgACBATYICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXDOUAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQE2CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwzlAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEBNggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcM5QBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBATYICAgIgB2ARwgIgAAI2QAhACUNhQAOACgNhgAjAFYNhwyiAZgAVwB2ABcAKQAxAGUNj18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBM4A9gAyAL4AAgAQIgQFC0wA6ADsADg2RDZkARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapw2aDZsNnA2dDZ4Nnw2ggQFDgQFEgQFFgQFGgQFHgQFIgQFJgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAaABcM5gBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIEBAYAAgQFBCAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwzmAGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAIEBQQgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcM5gBlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACBAUEICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcLawAXDOYAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQWAAIEBQQgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcM5gBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAAgACBAUEICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXDOYAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQFBCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwzmAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgACAAIEBQQgICAiAHYBaCAiAAAjfEBIAmwCcAJ0ODAAhAJ8AoA4NACMAng4OAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZQ4WADEAZQBXAGUBjgyOAGUAZQ4eAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBDAgIgQFMCIAMCIBjgQEhCAiBAUsIEoIcnObTADoAOwAODiIOJQBGogGXAZiAPIA9og4mDieBAU2BAViAKNkAIQAlDioADgAoDisAIwBWDiwMowGXAFcAdgAXACkAMQBlDjRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAUqAPIAMgC+AAIAECIEBTtMAOgA7AA4ONg4/AEaoAa0BrgGvAbABsQGyAbMBtIBAgEGAQoBDgESARYBGgEeoDkAOQQ5CDkMORA5FDkYOR4EBT4EBUIEBUYEBU4EBVIEBVYEBVoEBV4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXDiYAZQBlAGUAMQBlAK0BrQBlAGUAFwBlgACAJYAAgQFNCAgICIAdgEAICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw4mAGUAZQBlADEAZQCtAa4AZQBlABcAZYAAgACAAIEBTQgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFw5pABcOJgBlAGUAZQAxAGUArQGvAGUAZQAXAGWAAIEBUoAAgQFNCAgICIAdgEIICIAACNMAOgA7AA4Odw54AEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXDiYAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQFNCAgICIAdgEMICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFw4mAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgCWAAIEBTQgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcOJgBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIAlgACBAU0ICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXDiYAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAAIAAgQFNCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFw4mAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEBTQgICAiAHYBHCAiAAAjZACEAJQ7GAA4AKA7HACMAVg7IDKMBmABXAHYAFwApADEAZQ7QXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQFKgD2ADIAvgACABAiBAVnTADoAOwAODtIO2gBGpwJSAlMCVAJVAlYCVwJYgFSAVYBWgFeAWIBZgFqnDtsO3A7dDt4O3w7gDuGBAVqBAVuBAVyBAV2BAV6BAV+BAWCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXBPYAFw4nAGUAZQBlADEAZQCtAlIAZQBlABcAZYAAgIyAAIEBWAgICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcOJwBlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBAVgICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXDicAZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQFYCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXBSQAFw4nAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgJCAAIEBWAgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcOJwBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAAgACBAVgICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXDicAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQFYCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw4nAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgACAAIEBWAgICAiAHYBaCAiAAAjfEBIAmwCcAJ0PTQAhAJ8AoA9OACMAng9PAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZQ9XADEAZQBXAGUBjgyPAGUAZQ9fAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBDAgIgQFjCIAMCIBjgQEiCAiBAWIIEwAAAAEATCy/0wA6ADsADg9jD2YARqIBlwGYgDyAPaIPZw9ogQFkgQFvgCjZACEAJQ9rAA4AKA9sACMAVg9tDKQBlwBXAHYAFwApADEAZQ91XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQFhgDyADIAvgACABAiBAWXTADoAOwAOD3cPgABGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqA+BD4IPgw+ED4UPhg+HD4iBAWaBAWeBAWiBAWqBAWuBAWyBAW2BAW6AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFw9nAGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEBZAgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcPZwBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBAWQICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcPqgAXD2cAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBAWmAAIEBZAgICAiAHYBCCAiAAAjTADoAOwAOD7gPuQBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFw9nAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEBZAgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcPZwBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAlgACBAWQICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXD2cAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQFkCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw9nAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEBZAgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcPZwBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBAWQICAgIgB2ARwgIgAAI2QAhACUQBwAOACgQCAAjAFYQCQykAZgAVwB2ABcAKQAxAGUQEV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBYYA9gAyAL4AAgAQIgQFw0wA6ADsADhATEBsARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapxAcEB0QHhAfECAQIRAigQFxgQFygQFzgQF0gQF2gQF3gQF4gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwT2ABcPaABlAGUAZQAxAGUArQJSAGUAZQAXAGWAAICMgACBAW8ICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXD2gAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQFvCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw9oAGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIEBbwgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxBTABcPaABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEBdYAAgQFvCAgICIAdgFcICIAACBECWN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw9oAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIEBbwgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcPaABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAW8ICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXD2gAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQFvCAgICIAdgFoICIAACN8QEgCbAJwAnRCPACEAnwCgEJAAIwCeEJEAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlEJkAMQBlAFcAZQGODJAAZQBlEKEAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEMCAiBAXsIgAwIgGOBASMICIEBeggSOVN3QdMAOgA7AA4QpRCoAEaiAZcBmIA8gD2iEKkQqoEBfIEBh4Ao2QAhACUQrQAOACgQrgAjAFYQrwylAZcAVwB2ABcAKQAxAGUQt18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBeYA8gAyAL4AAgAQIgQF90wA6ADsADhC5EMIARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gQwxDEEMUQxhDHEMgQyRDKgQF+gQF/gQGAgQGCgQGDgQGEgQGFgQGGgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcQqQBlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBAXwICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEKkAZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQF8CAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXEOwAFxCpAGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQGBgACBAXwICAgIgB2AQggIgAAI0wA6ADsADhD6EPsARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcQqQBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAXwICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEKkAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAJYAAgQF8CAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxCpAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIEBfAgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcQqQBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBAXwICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEKkAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQF8CAgICIAdgEcICIAACNkAIQAlEUkADgAoEUoAIwBWEUsMpQGYAFcAdgAXACkAMQBlEVNfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAXmAPYAMgC+AAIAECIEBiNMAOgA7AA4RVRFdAEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcRXhFfEWARYRFiEWMRZIEBiYEBioEBi4EBjIEBjYEBjoEBj4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXEKoAZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACBAQGAAIEBhwgICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcQqgBlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBAYcICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEKoAZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQGHCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC2sAFxCqAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQEFgACBAYcICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEKoAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQGHCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxCqAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEBhwgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcQqgBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBAYcICAgIgB2AWggIgAAI3xASAJsAnACdEdAAIQCfAKAR0QAjAJ4R0gChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUR2gAxAGUAVwBlAY4MkQBlAGUR4gBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAQwICIEBkgiADAiAY4EBJAgIgQGRCBJXOttv0wA6ADsADhHmEekARqIBlwGYgDyAPaIR6hHrgQGTgQGegCjZACEAJRHuAA4AKBHvACMAVhHwDKYBlwBXAHYAFwApADEAZRH4XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQGQgDyADIAvgACABAiBAZTTADoAOwAOEfoSAwBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqBIEEgUSBhIHEggSCRIKEguBAZWBAZaBAZeBAZmBAZqBAZuBAZyBAZ2AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxHqAGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEBkwgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcR6gBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBAZMICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcSLQAXEeoAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBAZiAAIEBkwgICAiAHYBCCAiAAAjTADoAOwAOEjsSPABGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxHqAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEBkwgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcR6gBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAlgACBAZMICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEeoAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQGTCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxHqAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEBkwgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcR6gBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBAZMICAgIgB2ARwgIgAAI2QAhACUSigAOACgSiwAjAFYSjAymAZgAVwB2ABcAKQAxAGUSlF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBkIA9gAyAL4AAgAQIgQGf0wA6ADsADhKWEp4ARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapxKfEqASoRKiEqMSpBKlgQGggQGhgQGigQGjgQGkgQGlgQGmgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAaABcR6wBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIEBAYAAgQGeCAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxHrAGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAIEBnggICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcR6wBlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACBAZ4ICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcLawAXEesAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQWAAIEBnggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcR6wBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAAgACBAZ4ICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEesAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQGeCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxHrAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgACAAIEBnggICAiAHYBaCAiAAAjfEBIAmwCcAJ0TEQAhAJ8AoBMSACMAnhMTAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZRMbADEAZQBXAGUBjgySAGUAZRMjAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBDAgIgQGpCIAMCIBjgQElCAiBAagIEwAAAAEREuLX0wA6ADsADhMnEyoARqIBlwGYgDyAPaITKxMsgQGqgQG1gCjZACEAJRMvAA4AKBMwACMAVhMxDKcBlwBXAHYAFwApADEAZRM5XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQGngDyADIAvgACABAiBAavTADoAOwAOEzsTRABGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqBNFE0YTRxNIE0kTShNLE0yBAayBAa2BAa6BAbCBAbGBAbKBAbOBAbSAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxMrAGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEBqggICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcTKwBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBAaoICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcTbgAXEysAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBAa+AAIEBqggICAiAHYBCCAiAAAjTADoAOwAOE3wTfQBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxMrAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEBqggICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcTKwBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAlgACBAaoICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEysAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQGqCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxMrAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEBqggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcTKwBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBAaoICAgIgB2ARwgIgAAI2QAhACUTywAOACgTzAAjAFYTzQynAZgAVwB2ABcAKQAxAGUT1V8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBp4A9gAyAL4AAgAQIgQG20wA6ADsADhPXE98ARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapxPgE+ET4hPjE+QT5RPmgQG3gQG5gQG6gQG7gQG8gQG9gQG+gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxPqABcTLABlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIEBuIAAgQG1CAgICIAdgFQICIAACFMwLjDfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcTLABlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBAbUICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEywAZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQG1CAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXEFMAFxMsAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQF1gACBAbUICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEywAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQG1CAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxMsAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEBtQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcTLABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBAbUICAgIgB2AWggIgAAI3xASAJsAnACdFFMAIQCfAKAUVAAjAJ4UVQChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUUXQAxAGUAVwBlAY4MkwBlAGUUZQBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAQwICIEBwQiADAiAY4EBJggIgQHACBMAAAABG6VOqdMAOgA7AA4UaRRsAEaiAZcBmIA8gD2iFG0UboEBwoEBzYAo2QAhACUUcQAOACgUcgAjAFYUcwyoAZcAVwB2ABcAKQAxAGUUe18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBv4A8gAyAL4AAgAQIgQHD0wA6ADsADhR9FIYARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gUhxSIFIkUihSLFIwUjRSOgQHEgQHFgQHGgQHIgQHJgQHKgQHLgQHMgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcUbQBlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBAcIICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFG0AZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQHCCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXFLAAFxRtAGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQHHgACBAcIICAgIgB2AQggIgAAI0wA6ADsADhS+FL8ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcUbQBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAcIICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFG0AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAJYAAgQHCCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxRtAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIEBwggICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcUbQBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBAcIICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFG0AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQHCCAgICIAdgEcICIAACNkAIQAlFQ0ADgAoFQ4AIwBWFQ8MqAGYAFcAdgAXACkAMQBlFRdfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAb+APYAMgC+AAIAECIEBztMAOgA7AA4VGRUhAEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcVIhUjFSQVJRUmFScVKIEBz4EB0IEB0YEB0oEB04EB1IEB1YAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE9gAXFG4AZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACAjIAAgQHNCAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxRuAGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAIEBzQgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcUbgBlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACBAc0ICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFJAAXFG4AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAkIAAgQHNCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxRuAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIEBzQgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcUbgBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAc0ICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFG4AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQHNCAgICIAdgFoICIAACN8QEgCbAJwAnRWUACEAnwCgFZUAIwCeFZYAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlFZ4AMQBlAFcAZQGODJQAZQBlFaYAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEMCAiBAdgIgAwIgGOBAScICIEB1wgSnWxSgNMAOgA7AA4VqhWtAEaiAZcBmIA8gD2iFa4Vr4EB2YEB5IAo2QAhACUVsgAOACgVswAjAFYVtAypAZcAVwB2ABcAKQAxAGUVvF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEB1oA8gAyAL4AAgAQIgQHa0wA6ADsADhW+FccARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gVyBXJFcoVyxXMFc0VzhXPgQHbgQHcgQHdgQHfgQHggQHhgQHigQHjgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcVrgBlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBAdkICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFa4AZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQHZCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXFfEAFxWuAGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQHegACBAdkICAgIgB2AQggIgAAI0wA6ADsADhX/FgAARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcVrgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAdkICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFa4AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAJYAAgQHZCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxWuAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIEB2QgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcVrgBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBAdkICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFa4AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQHZCAgICIAdgEcICIAACNkAIQAlFk4ADgAoFk8AIwBWFlAMqQGYAFcAdgAXACkAMQBlFlhfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAdaAPYAMgC+AAIAECIEB5dMAOgA7AA4WWhZiAEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcWYxZkFmUWZhZnFmgWaYEB5oEB54EB6IEB6YEB6oEB64EB7IAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE9gAXFa8AZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACAjIAAgQHkCAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxWvAGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAIEB5AgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcVrwBlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACBAeQICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFJAAXFa8AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAkIAAgQHkCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxWvAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIEB5AgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcVrwBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAeQICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFa8AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQHkCAgICIAdgFoICIAACN8QEgCbAJwAnRbVACEAnwCgFtYAIwCeFtcAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlFt8AMQBlAFcAZQGODJUAZQBlFucAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEMCAiBAe8IgAwIgGOBASgICIEB7ggSTVAa7NMAOgA7AA4W6xbuAEaiAZcBmIA8gD2iFu8W8IEB8IEB+4Ao2QAhACUW8wAOACgW9AAjAFYW9QyqAZcAVwB2ABcAKQAxAGUW/V8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEB7YA8gAyAL4AAgAQIgQHx0wA6ADsADhb/FwgARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gXCRcKFwsXDBcNFw4XDxcQgQHygQHzgQH0gQH2gQH3gQH4gQH5gQH6gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcW7wBlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBAfAICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFu8AZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQHwCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXFzIAFxbvAGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQH1gACBAfAICAgIgB2AQggIgAAI0wA6ADsADhdAF0EARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcW7wBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAfAICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCCQAXFu8AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAToAAgQHwCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxbvAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIEB8AgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcW7wBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBAfAICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFu8AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQHwCAgICIAdgEcICIAACNkAIQAlF48ADgAoF5AAIwBWF5EMqgGYAFcAdgAXACkAMQBlF5lfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAe2APYAMgC+AAIAECIEB/NMAOgA7AA4XmxejAEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcXpBelF6YXpxeoF6kXqoEB/YEB/oEB/4ECAIECAYECAoECA4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFvAAZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACAAIAAgQH7CAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxbwAGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAIEB+wgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcW8ABlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACBAfsICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcLawAXFvAAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQWAAIEB+wgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcW8ABlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAAgACBAfsICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFvAAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQH7CAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxbwAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgACAAIEB+wgICAiAHYBaCAiAAAjfEBIAmwCcAJ0YFgAhAJ8AoBgXACMAnhgYAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZRggADEAZQBXAGUBjgyWAGUAZRgoAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBDAgIgQIGCIAMCIBjgQEpCAiBAgUIEvNToGXTADoAOwAOGCwYLwBGogGXAZiAPIA9ohgwGDGBAgeBAhKAKNkAIQAlGDQADgAoGDUAIwBWGDYMqwGXAFcAdgAXACkAMQBlGD5fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAgSAPIAMgC+AAIAECIECCNMAOgA7AA4YQBhJAEaoAa0BrgGvAbABsQGyAbMBtIBAgEGAQoBDgESARYBGgEeoGEoYSxhMGE0YThhPGFAYUYECCYECCoECC4ECDYECDoECD4ECEIECEYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGDAAZQBlAGUAMQBlAK0BrQBlAGUAFwBlgACAJYAAgQIHCAgICIAdgEAICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxgwAGUAZQBlADEAZQCtAa4AZQBlABcAZYAAgACAAIECBwgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxhzABcYMABlAGUAZQAxAGUArQGvAGUAZQAXAGWAAIECDIAAgQIHCAgICIAdgEIICIAACNMAOgA7AA4YgRiCAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGDAAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQIHCAgICIAdgEMICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxgwAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgCWAAIECBwgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcYMABlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIAlgACBAgcICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGDAAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAAIAAgQIHCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxgwAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIECBwgICAiAHYBHCAiAAAjZACEAJRjQAA4AKBjRACMAVhjSDKsBmABXAHYAFwApADEAZRjaXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQIEgD2ADIAvgACABAiBAhPTADoAOwAOGNwY5ABGpwJSAlMCVAJVAlYCVwJYgFSAVYBWgFeAWIBZgFqnGOUY5hjnGOgY6RjqGOuBAhSBAhaBAheBAhiBAhqBAhuBAhyAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXGO8AFxgxAGUAZQBlADEAZQCtAlIAZQBlABcAZYAAgQIVgACBAhIICAgIgB2AVAgIgAAIUk5P3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGDEAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQISCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxgxAGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIECEggICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxkdABcYMQBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIECGYAAgQISCAgICIAdgFcICIAACBEDIN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxgxAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIECEggICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcYMQBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAhIICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGDEAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQISCAgICIAdgFoICIAACN8QEgCbAJwAnRlZACEAnwCgGVoAIwCeGVsAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlGWMAMQBlAFcAZQGODJcAZQBlGWsAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEMCAiBAh8IgAwIgGOBASoICIECHggSrE1qNtMAOgA7AA4ZbxlyAEaiAZcBmIA8gD2iGXMZdIECIIECK4Ao2QAhACUZdwAOACgZeAAjAFYZeQysAZcAVwB2ABcAKQAxAGUZgV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECHYA8gAyAL4AAgAQIgQIh0wA6ADsADhmDGYwARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gZjRmOGY8ZkBmRGZIZkxmUgQIigQIjgQIkgQImgQIngQIogQIpgQIqgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcZcwBlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBAiAICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGXMAZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQIgCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXGbYAFxlzAGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQIlgACBAiAICAgIgB2AQggIgAAI0wA6ADsADhnEGcUARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcZcwBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAiAICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCCQAXGXMAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAToAAgQIgCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxlzAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIECIAgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcZcwBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBAiAICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGXMAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQIgCAgICIAdgEcICIAACNkAIQAlGhMADgAoGhQAIwBWGhUMrAGYAFcAdgAXACkAMQBlGh1fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAh2APYAMgC+AAIAECIECLNMAOgA7AA4aHxonAEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcaKBopGioaKxosGi0aLoECLYECLoECL4ECMIECMYECMoECM4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGXQAZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACAAIAAgQIrCAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxl0AGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAIECKwgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcZdABlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACBAisICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmAAXGXQAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAX4AAgQIrCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxl0AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIECKwgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcZdABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAisICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGXQAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQIrCAgICIAdgFoICIAACN8QEgCbAJwAnRqaACEAnwCgGpsAIwCeGpwAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlGqQAMQBlAFcAZQGODJgAZQBlGqwAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEMCAiBAjYIgAwIgGOBASsICIECNQgSc/EhI9MAOgA7AA4asBqzAEaiAZcBmIA8gD2iGrQatYECN4ECQoAo2QAhACUauAAOACgauQAjAFYaugytAZcAVwB2ABcAKQAxAGUawl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECNIA8gAyAL4AAgAQIgQI40wA6ADsADhrEGs0ARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gazhrPGtAa0RrSGtMa1BrVgQI5gQI6gQI7gQI9gQI+gQI/gQJAgQJBgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcatABlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBAjcICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGrQAZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQI3CAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXGvcAFxq0AGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQI8gACBAjcICAgIgB2AQggIgAAI0wA6ADsADhsFGwYARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcatABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAjcICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGrQAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAJYAAgQI3CAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxq0AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIECNwgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcatABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBAjcICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGrQAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQI3CAgICIAdgEcICIAACNkAIQAlG1QADgAoG1UAIwBWG1YMrQGYAFcAdgAXACkAMQBlG15fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAjSAPYAMgC+AAIAECIECQ9MAOgA7AA4bYBtoAEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcbaRtqG2sbbBttG24bb4ECRIECRYECRoECR4ECSIECSYECSoAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXGrUAZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACBAQGAAIECQggICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcatQBlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBAkIICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGrUAZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQJCCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC2sAFxq1AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQEFgACBAkIICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGrUAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQJCCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxq1AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIECQggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcatQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBAkIICAgIgB2AWggIgAAI3xASAJsAnACdG9sAIQCfAKAb3AAjAJ4b3QChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUb5QAxAGUAVwBlAY4MmQBlAGUb7QBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAQwICIECTQiADAiAY4EBLAgIgQJMCBJxIllF0wA6ADsADhvxG/QARqIBlwGYgDyAPaIb9Rv2gQJOgQJZgCjZACEAJRv5AA4AKBv6ACMAVhv7DK4BlwBXAHYAFwApADEAZRwDXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQJLgDyADIAvgACABAiBAk/TADoAOwAOHAUcDgBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqBwPHBAcERwSHBMcFBwVHBaBAlCBAlGBAlKBAlSBAlWBAlaBAleBAliAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxv1AGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIECTggICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcb9QBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBAk4ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABccOAAXG/UAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBAlOAAIECTggICAiAHYBCCAiAAAjTADoAOwAOHEYcRwBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxv1AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIECTggICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcb9QBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAlgACBAk4ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXG/UAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQJOCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxv1AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIECTggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcb9QBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBAk4ICAgIgB2ARwgIgAAI2QAhACUclQAOACgclgAjAFYclwyuAZgAVwB2ABcAKQAxAGUcn18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECS4A9gAyAL4AAgAQIgQJa0wA6ADsADhyhHKkARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapxyqHKscrBytHK4crxywgQJbgQJcgQJdgQJegQJfgQJggQJhgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwT2ABcb9gBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAICMgACBAlkICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXG/YAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQJZCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxv2AGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIECWQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwUkABcb9gBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAICQgACBAlkICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXG/YAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQJZCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxv2AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIECWQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcb9gBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBAlkICAgIgB2AWggIgAAI3xASAJsAnACdHRwAIQCfAKAdHQAjAJ4dHgChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUdJgAxAGUAVwBlAY4MmgBlAGUdLgBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAQwICIECZAiADAiAY4EBLQgIgQJjCBKMa1+u0wA6ADsADh0yHTUARqIBlwGYgDyAPaIdNh03gQJlgQJwgCjZACEAJR06AA4AKB07ACMAVh08DK8BlwBXAHYAFwApADEAZR1EXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQJigDyADIAvgACABAiBAmbTADoAOwAOHUYdTwBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqB1QHVEdUh1THVQdVR1WHVeBAmeBAmiBAmmBAmuBAmyBAm2BAm6BAm+AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx02AGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIECZQgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcdNgBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBAmUICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcdeQAXHTYAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBAmqAAIECZQgICAiAHYBCCAiAAAjTADoAOwAOHYcdiABGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx02AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIECZQgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABcdNgBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACBAmUICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXHTYAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQJlCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx02AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIECZQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcdNgBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBAmUICAgIgB2ARwgIgAAI2QAhACUd1gAOACgd1wAjAFYd2AyvAZgAVwB2ABcAKQAxAGUd4F8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECYoA9gAyAL4AAgAQIgQJx0wA6ADsADh3iHeoARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapx3rHewd7R3uHe8d8B3xgQJygQJzgQJ0gQJ1gQJ2gQJ3gQJ4gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcdNwBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIAAgACBAnAICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXHTcAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQJwCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx03AGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIECcAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtrABcdNwBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEBBYAAgQJwCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx03AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIECcAgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcdNwBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAnAICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHTcAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQJwCAgICIAdgFoICIAACN8QEgCbAJwAnR5dACEAnwCgHl4AIwCeHl8AoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlHmcAMQBlAFcAZQGODJsAZQBlHm8AZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEMCAiBAnsIgAwIgGOBAS4ICIECeggSpzhKA9MAOgA7AA4ecx52AEaiAZcBmIA8gD2iHnceeIECfIECh4Ao2QAhACUeewAOACgefAAjAFYefQywAZcAVwB2ABcAKQAxAGUehV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECeYA8gAyAL4AAgAQIgQJ90wA6ADsADh6HHpAARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gekR6SHpMelB6VHpYelx6YgQJ+gQJ/gQKAgQKCgQKDgQKEgQKFgQKGgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcedwBlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBAnwICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHncAZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQJ8CAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXHroAFx53AGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQKBgACBAnwICAgIgB2AQggIgAAI0wA6ADsADh7IHskARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcedwBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAnwICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXHncAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAJYAAgQJ8CAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx53AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIECfAgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcedwBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBAnwICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXHncAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQJ8CAgICIAdgEcICIAACNkAIQAlHxcADgAoHxgAIwBWHxkMsAGYAFcAdgAXACkAMQBlHyFfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAnmAPYAMgC+AAIAECIECiNMAOgA7AA4fIx8rAEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcfLB8tHy4fLx8wHzEfMoECiYECioECi4ECjIECjYECjoECj4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXHngAZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACBAQGAAIEChwgICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABceeABlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBAocICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHngAZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQKHCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC2sAFx54AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQEFgACBAocICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHngAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQKHCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx54AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEChwgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABceeABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBAocICAgIgB2AWggIgAAI3xASAJsAnACdH54AIQCfAKAfnwAjAJ4foAChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUfqAAxAGUAVwBlAY4MnABlAGUfsABlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAQwICIECkgiADAiAY4EBLwgIgQKRCBJkZiNn0wA6ADsADh+0H7cARqIBlwGYgDyAPaIfuB+5gQKTgQKegCjZACEAJR+8AA4AKB+9ACMAVh++DLEBlwBXAHYAFwApADEAZR/GXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQKQgDyADIAvgACABAiBApTTADoAOwAOH8gf0QBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqB/SH9Mf1B/VH9Yf1x/YH9mBApWBApaBApeBApmBApqBApuBApyBAp2AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx+4AGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIECkwgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcfuABlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBApMICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcf+wAXH7gAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBApiAAIECkwgICAiAHYBCCAiAAAjTADoAOwAOIAkgCgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx+4AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIECkwgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcfuABlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAlgACBApMICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXH7gAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQKTCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx+4AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIECkwgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcfuABlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBApMICAgIgB2ARwgIgAAI2QAhACUgWAAOACggWQAjAFYgWgyxAZgAVwB2ABcAKQAxAGUgYl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECkIA9gAyAL4AAgAQIgQKf0wA6ADsADiBkIGwARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapyBtIG4gbyBwIHEgciBzgQKggQKhgQKigQKjgQKkgQKlgQKmgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAaABcfuQBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIEBAYAAgQKeCAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx+5AGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAIECnggICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcfuQBlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACBAp4ICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcLawAXH7kAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQWAAIECnggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcfuQBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAAgACBAp4ICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXH7kAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQKeCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx+5AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgACAAIECnggICAiAHYBaCAiAAAjfEBIAmwCcAJ0g3wAhAJ8AoCDgACMAniDhAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZSDpADEAZQBXAGUBjgydAGUAZSDxAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBDAgIgQKpCIAMCIBjgQEwCAiBAqgIEi7YB9PTADoAOwAOIPUg+ABGogGXAZiAPIA9oiD5IPqBAqqBArWAKNkAIQAlIP0ADgAoIP4AIwBWIP8MsgGXAFcAdgAXACkAMQBlIQdfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAqeAPIAMgC+AAIAECIECq9MAOgA7AA4hCSESAEaoAa0BrgGvAbABsQGyAbMBtIBAgEGAQoBDgESARYBGgEeoIRMhFCEVIRYhFyEYIRkhGoECrIECrYECroECsIECsYECsoECs4ECtIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXIPkAZQBlAGUAMQBlAK0BrQBlAGUAFwBlgACAJYAAgQKqCAgICIAdgEAICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyD5AGUAZQBlADEAZQCtAa4AZQBlABcAZYAAgACAAIECqggICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyE8ABcg+QBlAGUAZQAxAGUArQGvAGUAZQAXAGWAAIECr4AAgQKqCAgICIAdgEIICIAACNMAOgA7AA4hSiFLAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXIPkAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQKqCAgICIAdgEMICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyD5AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgCWAAIECqggICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcg+QBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIAlgACBAqoICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIPkAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAAIAAgQKqCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyD5AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIECqggICAiAHYBHCAiAAAjZACEAJSGZAA4AKCGaACMAViGbDLIBmABXAHYAFwApADEAZSGjXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQKngD2ADIAvgACABAiBArbTADoAOwAOIaUhrQBGpwJSAlMCVAJVAlYCVwJYgFSAVYBWgFeAWIBZgFqnIa4hryGwIbEhsiGzIbSBAreBArmBArqBAruBAr2BAr6BAr+AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXIbgAFyD6AGUAZQBlADEAZQCtAlIAZQBlABcAZYAAgQK4gACBArUICAgIgB2AVAgIgAAIXlIgMy8wNi8yOSAwOjEw3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXIPoAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQK1CAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyD6AGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIECtQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyHmABcg+gBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIECvIAAgQK1CAgICIAdgFcICIAACBEDhN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyD6AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIECtQgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcg+gBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBArUICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIPoAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQK1CAgICIAdgFoICIAACN8QEgCbAJwAnSIiACEAnwCgIiMAIwCeIiQAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlIiwAMQBlAFcAZQGODJ4AZQBlIjQAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEMCAiBAsIIgAwIgGOBATEICIECwQgTAAAAART5WdTTADoAOwAOIjgiOwBGogGXAZiAPIA9oiI8Ij2BAsOBAs6AKNkAIQAlIkAADgAoIkEAIwBWIkIMswGXAFcAdgAXACkAMQBlIkpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAsCAPIAMgC+AAIAECIECxNMAOgA7AA4iTCJVAEaoAa0BrgGvAbABsQGyAbMBtIBAgEGAQoBDgESARYBGgEeoIlYiVyJYIlkiWiJbIlwiXYECxYECxoECx4ECyYECyoECy4ECzIECzYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXIjwAZQBlAGUAMQBlAK0BrQBlAGUAFwBlgACAJYAAgQLDCAgICIAdgEAICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyI8AGUAZQBlADEAZQCtAa4AZQBlABcAZYAAgACAAIECwwgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyJ/ABciPABlAGUAZQAxAGUArQGvAGUAZQAXAGWAAIECyIAAgQLDCAgICIAdgEIICIAACNMAOgA7AA4ijSKOAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXIjwAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQLDCAgICIAdgEMICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgkAFyI8AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgE6AAIECwwgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABciPABlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIAlgACBAsMICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIjwAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAAIAAgQLDCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyI8AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIECwwgICAiAHYBHCAiAAAjZACEAJSLcAA4AKCLdACMAViLeDLMBmABXAHYAFwApADEAZSLmXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQLAgD2ADIAvgACABAiBAs/TADoAOwAOIugi8ABGpwJSAlMCVAJVAlYCVwJYgFSAVYBWgFeAWIBZgFqnIvEi8iLzIvQi9SL2IveBAtCBAtGBAtKBAtOBAtSBAtWBAtaAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyI9AGUAZQBlADEAZQCtAlIAZQBlABcAZYAAgACAAIECzggICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABciPQBlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBAs4ICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIj0AZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQLOCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC2sAFyI9AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQEFgACBAs4ICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIj0AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQLOCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyI9AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIECzggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABciPQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBAs4ICAgIgB2AWggIgAAI3xASAJsAnACdI2MAIQCfAKAjZAAjAJ4jZQChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUjbQAxAGUAVwBlAY4BbABlAGUjdQBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAQwICIEC2QiADAiAY4A4CAiBAtgIEwAAAAEnkoKE0wA6ADsADiN5I3wARqIBlwGYgDyAPaIjfSN+gQLagQLlgCjZACEAJSOBAA4AKCOCACMAViODDLQBlwBXAHYAFwApADEAZSOLXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQLXgDyADIAvgACABAiBAtvTADoAOwAOI40jlgBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqCOXI5gjmSOaI5sjnCOdI56BAtyBAt2BAt6BAuCBAuGBAuKBAuOBAuSAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyN9AGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEC2ggICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcjfQBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBAtoICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcjwAAXI30AZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBAt+AAIEC2ggICAiAHYBCCAiAAAjTADoAOwAOI84jzwBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyN9AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEC2ggICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcjfQBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAlgACBAtoICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXI30AZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQLaCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyN9AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEC2ggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcjfQBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBAtoICAgIgB2ARwgIgAAI2QAhACUkHQAOACgkHgAjAFYkHwy0AZgAVwB2ABcAKQAxAGUkJ18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEC14A9gAyAL4AAgAQIgQLm0wA6ADsADiQpJDEARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapyQyJDMkNCQ1JDYkNyQ4gQLngQLogQLpgQLqgQLrgQLsgQLtgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAaABcjfgBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIEBAYAAgQLlCAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyN+AGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAIEC5QgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcjfgBlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACBAuUICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcLawAXI34AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQWAAIEC5QgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcjfgBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAAgACBAuUICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXI34AZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQLlCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyN+AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgACAAIEC5QgICAiAHYBaCAiAAAjfEBIAmwCcAJ0kpAAhAJ8AoCSlACMAniSmAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZSSuADEAZQBXAGUBjgygAGUAZSS2AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBDAgIgQLwCIAMCIBjgQEyCAiBAu8IEs/2PCTTADoAOwAOJLokvQBGogGXAZiAPIA9oiS+JL+BAvGBAvyAKNkAIQAlJMIADgAoJMMAIwBWJMQMtQGXAFcAdgAXACkAMQBlJMxfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAu6APIAMgC+AAIAECIEC8tMAOgA7AA4kziTXAEaoAa0BrgGvAbABsQGyAbMBtIBAgEGAQoBDgESARYBGgEeoJNgk2STaJNsk3CTdJN4k34EC84EC9IEC9YEC94EC+IEC+YEC+oEC+4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJL4AZQBlAGUAMQBlAK0BrQBlAGUAFwBlgACAJYAAgQLxCAgICIAdgEAICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyS+AGUAZQBlADEAZQCtAa4AZQBlABcAZYAAgACAAIEC8QgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyUBABckvgBlAGUAZQAxAGUArQGvAGUAZQAXAGWAAIEC9oAAgQLxCAgICIAdgEIICIAACNMAOgA7AA4lDyUQAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJL4AZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQLxCAgICIAdgEMICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyS+AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgCWAAIEC8QgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABckvgBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIAlgACBAvEICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJL4AZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAAIAAgQLxCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyS+AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEC8QgICAiAHYBHCAiAAAjZACEAJSVeAA4AKCVfACMAViVgDLUBmABXAHYAFwApADEAZSVoXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQLugD2ADIAvgACABAiBAv3TADoAOwAOJWolcgBGpwJSAlMCVAJVAlYCVwJYgFSAVYBWgFeAWIBZgFqnJXMldCV1JXYldyV4JXmBAv6BAv+BAwCBAwGBAwKBAwOBAwSAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXE+oAFyS/AGUAZQBlADEAZQCtAlIAZQBlABcAZYAAgQG4gACBAvwICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJL8AZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQL8CAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyS/AGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIEC/AgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxBTABckvwBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEBdYAAgQL8CAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyS/AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIEC/AgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABckvwBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAvwICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJL8AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQL8CAgICIAdgFoICIAACNIAOwAOJeUAtaCAHN8QECXoJekl6iXrACEl7CXtACMl7iXvAA4AJSXwJfEAKABWAFcl8wApACkAFCX3AF0AMQApAFcAYAA/AFcl/iX/AGVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2WADIEDGYAEgASAAoEDCIEBCYAEgAyBAQuACIAMgQQzgQMHCBKwDU0s0wA6ADsADiYDJgUARqEAaoAOoSYGgQMJgCjZACEAJSYJAA4AKCYKACMAViYLAEQAagBXAHYAFwApADEAZSYTXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQMGgA6ADIAvgACABAiBAwrTADoAOwAOJhUmHwBGqQB9AH4AfwCAAIEAggCDAIQAhYARgBKAE4AUgBWAFoAXgBiAGakmICYhJiImIyYkJiUmJiYnJiiBAwuBAw2BAw6BAxCBAxGBAxOBAxSBAxaBAxeAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJiwAFyYGAGUAZQBlADEAZQCtAH0AZQBlABcAZYAAgQMMgACBAwkICAgIgB2AEQgIgAAI0gA7AA4mOgC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJgYAZQBlAGUAMQBlAK0AfgBlAGUAFwBlgACAAIAAgQMJCAgICIAdgBIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJk0AFyYGAGUAZQBlADEAZQCtAH8AZQBlABcAZYAAgQMPgACBAwkICAgIgB2AEwgIgAAI0gA7AA4mWwC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJgYAZQBlAGUAMQBlAK0AgABlAGUAFwBlgACAAIAAgQMJCAgICIAdgBQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJm4AFyYGAGUAZQBlADEAZQCtAIEAZQBlABcAZYAAgQMSgACBAwkICAgIgB2AFQgIgAAI0gA7AA4mfAC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJgYAZQBlAGUAMQBlAK0AggBlAGUAFwBlgACAJYAAgQMJCAgICIAdgBYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJo8AFyYGAGUAZQBlADEAZQCtAIMAZQBlABcAZYAAgQMVgACBAwkICAgIgB2AFwgIgAAI0wA6ADsADiadJp4ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEuABcmBgBlAGUAZQAxAGUArQCEAGUAZQAXAGWAAIAqgACBAwkICAgIgB2AGAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcmsQAXJgYAZQBlAGUAMQBlAK0AhQBlAGUAFwBlgACBAxiAAIEDCQgICAiAHYAZCAiAAAhaLkdhbGxlcnlNT9MAOgA7AA4mwCbNAEasDKAMmAybJsQmxQyQAWwmyAyRDJkmywyNgQEygQErgQEugQMagQMbgQEjgDiBAxyBASSBASyBAx2BASCsJs4mzybQJtEm0ibTJtQm1SbWJtcm2CbZgQMegQM1gQNMgQNjgQN7gQOSgQOpgQPAgQPXgQPugQQFgQQcgChacG9zdGVkRGF0ZVxsYXN0T3BlbkRhdGVVdG9rZW5aZ2FsbGVyeVVSTN8QEgCbAJwAnSbgACEAnwCgJuEAIwCeJuIAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlJuoAMQBlAFcAZQGODKAAZQBlJvIAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMGCAiBAyAIgAwIgGOBATIICIEDHwgSZ3FANtMAOgA7AA4m9ib5AEaiAZcBmIA8gD2iJvom+4EDIYEDLIAo2QAhACUm/gAOACgm/wAjAFYnACbOAZcAVwB2ABcAKQAxAGUnCF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDHoA8gAyAL4AAgAQIgQMi0wA6ADsADicKJxMARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gnFCcVJxYnFycYJxknGicbgQMjgQMkgQMlgQMngQMogQMpgQMqgQMrgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcm+gBlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBAyEICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJvoAZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQMhCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJz0AFyb6AGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQMmgACBAyEICAgIgB2AQggIgAAI0wA6ADsADidLJ0wARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcm+gBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAyEICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJvoAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAJYAAgQMhCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyb6AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIEDIQgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcm+gBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBAyEICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJvoAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQMhCAgICIAdgEcICIAACNkAIQAlJ5oADgAoJ5sAIwBWJ5wmzgGYAFcAdgAXACkAMQBlJ6RfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAx6APYAMgC+AAIAECIEDLdMAOgA7AA4npieuAEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcnryewJ7EnsiezJ7QntYEDLoEDL4EDMIEDMYEDMoEDM4EDNIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcT6gAXJvsAZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACBAbiAAIEDLAgICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcm+wBlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBAywICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJvsAZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQMsCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXEFMAFyb7AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQF1gACBAywICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJvsAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQMsCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyb7AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEDLAgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcm+wBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBAywICAgIgB2AWggIgAAI3xASAJsAnACdKCEAIQCfAKAoIgAjAJ4oIwChAA4AJQCiAKMAKACkABcAFwAXACkARABlAGUoKwAxAGUAVwBlAY4MmABlAGUoMwBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAwYICIEDNwiADAiAY4EBKwgIgQM2CBJsLgO30wA6ADsADig3KDoARqIBlwGYgDyAPaIoOyg8gQM4gQNDgCjZACEAJSg/AA4AKChAACMAVihBJs8BlwBXAHYAFwApADEAZShJXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQM1gDyADIAvgACABAiBAznTADoAOwAOKEsoVABGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqChVKFYoVyhYKFkoWihbKFyBAzqBAzuBAzyBAz6BAz+BA0CBA0GBA0KAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyg7AGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEDOAgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcoOwBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBAzgICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcofgAXKDsAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBAz2AAIEDOAgICAiAHYBCCAiAAAjTADoAOwAOKIwojQBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyg7AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEDOAgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcoOwBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAlgACBAzgICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKDsAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQM4CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyg7AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEDOAgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcoOwBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBAzgICAgIgB2ARwgIgAAI2QAhACUo2wAOACgo3AAjAFYo3SbPAZgAVwB2ABcAKQAxAGUo5V8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDNYA9gAyAL4AAgAQIgQNE0wA6ADsADijnKO8ARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapyjwKPEo8ijzKPQo9Sj2gQNFgQNGgQNHgQNIgQNJgQNKgQNLgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAaABcoPABlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIEBAYAAgQNDCAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyg8AGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAIEDQwgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcoPABlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACBA0MICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcLawAXKDwAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQWAAIEDQwgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcoPABlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAAgACBA0MICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKDwAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQNDCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyg8AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgACAAIEDQwgICAiAHYBaCAiAAAjfEBIAmwCcAJ0pYgAhAJ8AoCljACMAnilkAKEADgAlAKIAowAoAKQAFwAXABcAKQBEAGUAZSlsADEAZQBXAGUBjgybAGUAZSl0AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEDBggIgQNOCIAMCIBjgQEuCAiBA00IEwAAAAEhOJFj0wA6ADsADil4KXsARqIBlwGYgDyAPaIpfCl9gQNPgQNagCjZACEAJSmAAA4AKCmBACMAVimCJtABlwBXAHYAFwApADEAZSmKXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQNMgDyADIAvgACABAiBA1DTADoAOwAOKYwplQBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqCmWKZcpmCmZKZopmymcKZ2BA1GBA1KBA1OBA1WBA1aBA1eBA1iBA1mAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyl8AGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEDTwgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcpfABlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBA08ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcpvwAXKXwAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBA1SAAIEDTwgICAiAHYBCCAiAAAjTADoAOwAOKc0pzgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyl8AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEDTwgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABcpfABlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACBA08ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKXwAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQNPCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyl8AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEDTwgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcpfABlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBA08ICAgIgB2ARwgIgAAI2QAhACUqHAAOACgqHQAjAFYqHibQAZgAVwB2ABcAKQAxAGUqJl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDTIA9gAyAL4AAgAQIgQNb0wA6ADsADiooKjAARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapyoxKjIqMyo0KjUqNio3gQNcgQNdgQNegQNfgQNggQNhgQNigCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcpfQBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIAAgACBA1oICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKX0AZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQNaCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyl9AGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIEDWggICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtrABcpfQBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEBBYAAgQNaCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyl9AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIEDWggICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcpfQBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBA1oICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKX0AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQNaCAgICIAdgFoICIAACN8QEgCbAJwAnSqjACEAnwCgKqQAIwCeKqUAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlKq0AMQBlAFcAZQGOJsQAZQBlKrUAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMGCAiBA2UIgAwIgGOBAxoICIEDZAgSadT3JdMAOgA7AA4quSq8AEaiAZcBmIA8gD2iKr0qvoEDZoEDcYAo2QAhACUqwQAOACgqwgAjAFYqwybRAZcAVwB2ABcAKQAxAGUqy18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDY4A8gAyAL4AAgAQIgQNn0wA6ADsADirNKtYARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gq1yrYKtkq2irbKtwq3SregQNogQNpgQNqgQNsgQNtgQNugQNvgQNwgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcqvQBlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBA2YICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKr0AZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQNmCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXKwAAFyq9AGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQNrgACBA2YICAgIgB2AQggIgAAI0wA6ADsADisOKw8ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcqvQBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBA2YICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKr0AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAJYAAgQNmCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyq9AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIEDZggICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcqvQBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBA2YICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKr0AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQNmCAgICIAdgEcICIAACNkAIQAlK10ADgAoK14AIwBWK18m0QGYAFcAdgAXACkAMQBlK2dfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA2OAPYAMgC+AAIAECIEDctMAOgA7AA4raStxAEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcrcitzK3QrdSt2K3creIEDc4EDdYEDdoEDd4EDeIEDeYEDeoAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcrfAAXKr4AZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACBA3SAAIEDcQgICAiAHYBUCAiAAAheUiAzLzA2LzI5IDA6MTHfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcqvgBlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBA3EICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKr4AZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQNxCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXIeYAFyq+AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQK8gACBA3EICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKr4AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQNxCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyq+AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEDcQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcqvgBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBA3EICAgIgB2AWggIgAAI3xASAJsAnACdK+UAIQCfAKAr5gAjAJ4r5wChAA4AJQCiAKMAKACkABcAFwAXACkARABlAGUr7wAxAGUAVwBlAY4mxQBlAGUr9wBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAwYICIEDfQiADAiAY4EDGwgIgQN8CBKmAlJj0wA6ADsADiv7K/4ARqIBlwGYgDyAPaIr/ywAgQN+gQOJgCjZACEAJSwDAA4AKCwEACMAViwFJtIBlwBXAHYAFwApADEAZSwNXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQN7gDyADIAvgACABAiBA3/TADoAOwAOLA8sGABGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqCwZLBosGywcLB0sHiwfLCCBA4CBA4GBA4KBA4SBA4WBA4aBA4eBA4iAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyv/AGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEDfggICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcr/wBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBA34ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcsQgAXK/8AZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBA4OAAIEDfggICAiAHYBCCAiAAAjTADoAOwAOLFAsUQBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyv/AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEDfggICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABcr/wBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACBA34ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXK/8AZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQN+CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyv/AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEDfggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcr/wBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBA34ICAgIgB2ARwgIgAAI2QAhACUsnwAOACgsoAAjAFYsoSbSAZgAVwB2ABcAKQAxAGUsqV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDe4A9gAyAL4AAgAQIgQOK0wA6ADsADiyrLLMARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapyy0LLUstiy3LLgsuSy6gQOLgQOMgQONgQOOgQOPgQOQgQORgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcsAABlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIAAgACBA4kICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLAAAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQOJCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFywAAGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIEDiQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyHmABcsAABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIECvIAAgQOJCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFywAAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIEDiQgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcsAABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBA4kICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLAAAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQOJCAgICIAdgFoICIAACN8QEgCbAJwAnS0mACEAnwCgLScAIwCeLSgAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlLTAAMQBlAFcAZQGODJAAZQBlLTgAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMGCAiBA5QIgAwIgGOBASMICIEDkwgSScwHFNMAOgA7AA4tPC0/AEaiAZcBmIA8gD2iLUAtQYEDlYEDoIAo2QAhACUtRAAOACgtRQAjAFYtRibTAZcAVwB2ABcAKQAxAGUtTl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDkoA8gAyAL4AAgAQIgQOW0wA6ADsADi1QLVkARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gtWi1bLVwtXS1eLV8tYC1hgQOXgQOYgQOZgQObgQOcgQOdgQOegQOfgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABctQABlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBA5UICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLUAAZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQOVCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXLYMAFy1AAGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQOagACBA5UICAgIgB2AQggIgAAI0wA6ADsADi2RLZIARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABctQABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBA5UICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLUAAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAJYAAgQOVCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy1AAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIEDlQgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABctQABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBA5UICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLUAAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQOVCAgICIAdgEcICIAACNkAIQAlLeAADgAoLeEAIwBWLeIm0wGYAFcAdgAXACkAMQBlLepfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA5KAPYAMgC+AAIAECIEDodMAOgA7AA4t7C30AEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqct9S32Lfct+C35Lfot+4EDooEDo4EDpIEDpYEDpoEDp4EDqIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXLUEAZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACBAQGAAIEDoAgICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABctQQBlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBA6AICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLUEAZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQOgCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC2sAFy1BAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQEFgACBA6AICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLUEAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQOgCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy1BAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEDoAgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABctQQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBA6AICAgIgB2AWggIgAAI3xASAJsAnACdLmcAIQCfAKAuaAAjAJ4uaQChAA4AJQCiAKMAKACkABcAFwAXACkARABlAGUucQAxAGUAVwBlAY4BbABlAGUueQBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAwYICIEDqwiADAiAY4A4CAiBA6oIEla2eY3TADoAOwAOLn0ugABGogGXAZiAPIA9oi6BLoKBA6yBA7eAKNkAIQAlLoUADgAoLoYAIwBWLocm1AGXAFcAdgAXACkAMQBlLo9fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA6mAPIAMgC+AAIAECIEDrdMAOgA7AA4ukS6aAEaoAa0BrgGvAbABsQGyAbMBtIBAgEGAQoBDgESARYBGgEeoLpsunC6dLp4uny6gLqEuooEDroEDr4EDsIEDsoEDs4EDtIEDtYEDtoAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLoEAZQBlAGUAMQBlAK0BrQBlAGUAFwBlgACAJYAAgQOsCAgICIAdgEAICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy6BAGUAZQBlADEAZQCtAa4AZQBlABcAZYAAgACAAIEDrAgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFy7EABcugQBlAGUAZQAxAGUArQGvAGUAZQAXAGWAAIEDsYAAgQOsCAgICIAdgEIICIAACNMAOgA7AA4u0i7TAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLoEAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQOsCAgICIAdgEMICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy6BAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgCWAAIEDrAgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcugQBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIAlgACBA6wICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLoEAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAAIAAgQOsCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy6BAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEDrAgICAiAHYBHCAiAAAjZACEAJS8hAA4AKC8iACMAVi8jJtQBmABXAHYAFwApADEAZS8rXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQOpgD2ADIAvgACABAiBA7jTADoAOwAOLy0vNQBGpwJSAlMCVAJVAlYCVwJYgFSAVYBWgFeAWIBZgFqnLzYvNy84LzkvOi87LzyBA7mBA7qBA7uBA7yBA72BA76BA7+AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFy6CAGUAZQBlADEAZQCtAlIAZQBlABcAZYAAgQEBgACBA7cICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLoIAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQO3CAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy6CAGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIEDtwgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtrABcuggBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEBBYAAgQO3CAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy6CAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIEDtwgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcuggBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBA7cICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLoIAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQO3CAgICIAdgFoICIAACN8QEgCbAJwAnS+oACEAnwCgL6kAIwCeL6oAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlL7IAMQBlAFcAZQGOJsgAZQBlL7oAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMGCAiBA8IIgAwIgGOBAxwICIEDwQgSKgB7zNMAOgA7AA4vvi/BAEaiAZcBmIA8gD2iL8Ivw4EDw4EDzoAo2QAhACUvxgAOACgvxwAjAFYvyCbVAZcAVwB2ABcAKQAxAGUv0F8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDwIA8gAyAL4AAgAQIgQPE0wA6ADsADi/SL9sARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gv3C/dL94v3y/gL+Ev4i/jgQPFgQPGgQPHgQPJgQPKgQPLgQPMgQPNgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcvwgBlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBA8MICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXL8IAZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQPDCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXMAUAFy/CAGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQPIgACBA8MICAgIgB2AQggIgAAI0wA6ADsADjATMBQARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcvwgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBA8MICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXL8IAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAJYAAgQPDCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy/CAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIEDwwgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcvwgBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBA8MICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXL8IAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQPDCAgICIAdgEcICIAACNkAIQAlMGIADgAoMGMAIwBWMGQm1QGYAFcAdgAXACkAMQBlMGxfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA8CAPYAMgC+AAIAECIEDz9MAOgA7AA4wbjB2AEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcwdzB4MHkwejB7MHwwfYED0IED0YED0oED04ED1IED1YED1oAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXL8MAZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACBAQGAAIEDzggICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcvwwBlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBA84ICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXL8MAZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQPOCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC2sAFy/DAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQEFgACBA84ICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXL8MAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQPOCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy/DAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEDzggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcvwwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBA84ICAgIgB2AWggIgAAI3xASAJsAnACdMOkAIQCfAKAw6gAjAJ4w6wChAA4AJQCiAKMAKACkABcAFwAXACkARABlAGUw8wAxAGUAVwBlAY4MkQBlAGUw+wBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAwYICIED2QiADAiAY4EBJAgIgQPYCBIrpZwW0wA6ADsADjD/MQIARqIBlwGYgDyAPaIxAzEEgQPagQPlgCjZACEAJTEHAA4AKDEIACMAVjEJJtYBlwBXAHYAFwApADEAZTERXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQPXgDyADIAvgACABAiBA9vTADoAOwAOMRMxHABGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqDEdMR4xHzEgMSExIjEjMSSBA9yBA92BA96BA+CBA+GBA+KBA+OBA+SAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzEDAGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIED2ggICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcxAwBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBA9oICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcxRgAXMQMAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBA9+AAIED2ggICAiAHYBCCAiAAAjTADoAOwAOMVQxVQBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzEDAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIED2ggICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABcxAwBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACBA9oICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXMQMAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQPaCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzEDAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIED2ggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcxAwBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBA9oICAgIgB2ARwgIgAAI2QAhACUxowAOACgxpAAjAFYxpSbWAZgAVwB2ABcAKQAxAGUxrV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYED14A9gAyAL4AAgAQIgQPm0wA6ADsADjGvMbcARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapzG4MbkxujG7MbwxvTG+gQPngQPogQPpgQPqgQPrgQPsgQPtgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcxBABlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIAAgACBA+UICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXMQQAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQPlCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzEEAGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIED5QgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtrABcxBABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEBBYAAgQPlCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzEEAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIED5QgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcxBABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBA+UICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMQQAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQPlCAgICIAdgFoICIAACN8QEgCbAJwAnTIqACEAnwCgMisAIwCeMiwAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlMjQAMQBlAFcAZQGODJkAZQBlMjwAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMGCAiBA/AIgAwIgGOBASwICIED7wgSXzhaSNMAOgA7AA4yQDJDAEaiAZcBmIA8gD2iMkQyRYED8YED/IAo2QAhACUySAAOACgySQAjAFYySibXAZcAVwB2ABcAKQAxAGUyUl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYED7oA8gAyAL4AAgAQIgQPy0wA6ADsADjJUMl0ARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gyXjJfMmAyYTJiMmMyZDJlgQPzgQP0gQP1gQP3gQP4gQP5gQP6gQP7gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcyRABlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBA/EICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMkQAZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQPxCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXMocAFzJEAGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQP2gACBA/EICAgIgB2AQggIgAAI0wA6ADsADjKVMpYARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcyRABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBA/EICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXMkQAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAJYAAgQPxCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzJEAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIED8QgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcyRABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBA/EICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXMkQAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQPxCAgICIAdgEcICIAACNkAIQAlMuQADgAoMuUAIwBWMuYm1wGYAFcAdgAXACkAMQBlMu5fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA+6APYAMgC+AAIAECIED/dMAOgA7AA4y8DL4AEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqcy+TL6Mvsy/DL9Mv4y/4ED/oED/4EEAIEEAYEEAoEEA4EEBIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE9gAXMkUAZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACAjIAAgQP8CAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzJFAGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAIED/AgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcyRQBlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACBA/wICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFJAAXMkUAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAkIAAgQP8CAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzJFAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgACAAIED/AgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcyRQBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBA/wICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMkUAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAAIAAgQP8CAgICIAdgFoICIAACN8QEgCbAJwAnTNrACEAnwCgM2wAIwCeM20AoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlM3UAMQBlAFcAZQGOJssAZQBlM30AZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMGCAiBBAcIgAwIgGOBAx0ICIEEBggSgVT2NdMAOgA7AA4zgTOEAEaiAZcBmIA8gD2iM4UzhoEECIEEE4Ao2QAhACUziQAOACgzigAjAFYziybYAZcAVwB2ABcAKQAxAGUzk18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEEBYA8gAyAL4AAgAQIgQQJ0wA6ADsADjOVM54ARqgBrQGuAa8BsAGxAbIBswG0gECAQYBCgEOARIBFgEaAR6gznzOgM6EzojOjM6QzpTOmgQQKgQQLgQQMgQQOgQQPgQQQgQQRgQQSgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABczhQBlAGUAZQAxAGUArQGtAGUAZQAXAGWAAIAlgACBBAgICAgIgB2AQAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXM4UAZQBlAGUAMQBlAK0BrgBlAGUAFwBlgACAAIAAgQQICAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXM8gAFzOFAGUAZQBlADEAZQCtAa8AZQBlABcAZYAAgQQNgACBBAgICAgIgB2AQggIgAAI0wA6ADsADjPWM9cARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABczhQBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBBAgICAgIgB2AQwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXM4UAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAJYAAgQQICAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzOFAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgCWAAIEECAgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABczhQBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAAgACBBAgICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXM4UAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQQICAgICIAdgEcICIAACNkAIQAlNCUADgAoNCYAIwBWNCcm2AGYAFcAdgAXACkAMQBlNC9fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBAWAPYAMgC+AAIAECIEEFNMAOgA7AA40MTQ5AEanAlICUwJUAlUCVgJXAliAVIBVgFaAV4BYgFmAWqc0OjQ7NDw0PTQ+ND80QIEEFYEEFoEEF4EEGIEEGYEEGoEEG4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXM4YAZQBlAGUAMQBlAK0CUgBlAGUAFwBlgACBAQGAAIEEEwgICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABczhgBlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBBBMICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXM4YAZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQQTCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC2sAFzOGAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQEFgACBBBMICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXM4YAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQQTCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzOGAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEEEwgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABczhgBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBBBMICAgIgB2AWggIgAAI3xASAJsAnACdNKwAIQCfAKA0rQAjAJ40rgChAA4AJQCiAKMAKACkABcAFwAXACkARABlAGU0tgAxAGUAVwBlAY4MjQBlAGU0vgBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAwYICIEEHgiADAiAY4EBIAgIgQQdCBJhu/TR0wA6ADsADjTCNMUARqIBlwGYgDyAPaI0xjTHgQQfgQQqgCjZACEAJTTKAA4AKDTLACMAVjTMJtkBlwBXAHYAFwApADEAZTTUXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQQcgDyADIAvgACABAiBBCDTADoAOwAONNY03wBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqDTgNOE04jTjNOQ05TTmNOeBBCGBBCKBBCOBBCWBBCaBBCeBBCiBBCmAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzTGAGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEEHwgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc0xgBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBBB8ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc1CQAXNMYAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBBCSAAIEEHwgICAiAHYBCCAiAAAjTADoAOwAONRc1GABGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzTGAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEEHwgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc0xgBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAlgACBBB8ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXNMYAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQQfCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzTGAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEEHwgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc0xgBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBBB8ICAgIgB2ARwgIgAAI2QAhACU1ZgAOACg1ZwAjAFY1aCbZAZgAVwB2ABcAKQAxAGU1cF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEEHIA9gAyAL4AAgAQIgQQr0wA6ADsADjVyNXoARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapzV7NXw1fTV+NX81gDWBgQQsgQQtgQQugQQvgQQwgQQxgQQygCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAaABc0xwBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIEBAYAAgQQqCAgICIAdgFQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzTHAGUAZQBlADEAZQCtAlMAZQBlABcAZYAAgCWAAIEEKggICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc0xwBlAGUAZQAxAGUArQJUAGUAZQAXAGWAAIAAgACBBCoICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcLawAXNMcAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQWAAIEEKggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc0xwBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAAgACBBCoICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNMcAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQQqCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzTHAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgACAAIEEKggICAiAHYBaCAiAAAjSADsADjXtALWggBzfEBA18DXxNfI18wAhNfQ19QAjNfY19wAOACU1+DX5ACgAVgBXNfsAKQApABQ1/wBdADEAKQBXAGAAQABXNgY2BwBlXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zZHVwbGljYXRlc18QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNzdG9yYWdlgAyBBEeABIAEgAKBBDaBAQmABIAMgQELgAmADIEE8IEENQgTAAAAARhqFbvTADoAOwAONgs2DQBGoQBqgA6hNg6BBDeAKNkAIQAlNhEADgAoNhIAIwBWNhMARQBqAFcAdgAXACkAMQBlNhtfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBDSADoAMgC+AAIAECIEEONMAOgA7AA42HTYnAEapAH0AfgB/AIAAgQCCAIMAhACFgBGAEoATgBSAFYAWgBeAGIAZqTYoNik2KjYrNiw2LTYuNi82MIEEOYEEO4EEPIEEPoEEP4EEQYEEQoEERIEERYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc2NAAXNg4AZQBlAGUAMQBlAK0AfQBlAGUAFwBlgACBBDqAAIEENwgICAiAHYARCAiAAAjSADsADjZCALWggBzfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc2DgBlAGUAZQAxAGUArQB+AGUAZQAXAGWAAIAAgACBBDcICAgIgB2AEggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc2VQAXNg4AZQBlAGUAMQBlAK0AfwBlAGUAFwBlgACBBD2AAIEENwgICAiAHYATCAiAAAjSADsADjZjALWggBzfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc2DgBlAGUAZQAxAGUArQCAAGUAZQAXAGWAAIAAgACBBDcICAgIgB2AFAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc2dgAXNg4AZQBlAGUAMQBlAK0AgQBlAGUAFwBlgACBBECAAIEENwgICAiAHYAVCAiAAAjSADsADjaEALWggBzfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc2DgBlAGUAZQAxAGUArQCCAGUAZQAXAGWAAIAlgACBBDcICAgIgB2AFggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc2lwAXNg4AZQBlAGUAMQBlAK0AgwBlAGUAFwBlgACBBEOAAIEENwgICAiAHYAXCAiAAAjTADoAOwAONqU2pgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAS4AFzYOAGUAZQBlADEAZQCtAIQAZQBlABcAZYAAgCqAAIEENwgICAiAHYAYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFza5ABc2DgBlAGUAZQAxAGUArQCFAGUAZQAXAGWAAIEERoAAgQQ3CAgICIAdgBkICIAACFkuQXBwRW52TU/TADoAOwAONsg20ABGpzbJNso2yzbMNs02zjbPgQRIgQRJgQRKgQRLgQRMgQRNgQROpzbRNtI20zbUNtU21jbXgQRPgQRmgQR9gQSUgQSrgQTCgQTZgChfEBBxdWlja1NlYXJjaFdvcmRzXxAPaGlzdG9yeUtleXdvcmRzXGdsb2JhbEZpbHRlclxzZWFyY2hGaWx0ZXJddGFnVHJhbnNsYXRvcldzZXR0aW5nVHVzZXLfEBIAmwCcAJ024QAhAJ8AoDbiACMAnjbjAKEADgAlAKIAowAoAKQAFwAXABcAKQBFAGUAZTbrADEAZQBXAGUBjjbJAGUAZTbzAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEENAgIgQRRCIAMCIBjgQRICAiBBFAIEvattejTADoAOwAONvc2+gBGogGXAZiAPIA9ojb7NvyBBFKBBF2AKNkAIQAlNv8ADgAoNwAAIwBWNwE20QGXAFcAdgAXACkAMQBlNwlfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBE+APIAMgC+AAIAECIEEU9MAOgA7AA43CzcUAEaoAa0BrgGvAbABsQGyAbMBtIBAgEGAQoBDgESARYBGgEeoNxU3FjcXNxg3GTcaNxs3HIEEVIEEVYEEVoEEWIEEWYEEWoEEW4EEXIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXNvsAZQBlAGUAMQBlAK0BrQBlAGUAFwBlgACAJYAAgQRSCAgICIAdgEAICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzb7AGUAZQBlADEAZQCtAa4AZQBlABcAZYAAgACAAIEEUggICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFzc+ABc2+wBlAGUAZQAxAGUArQGvAGUAZQAXAGWAAIEEV4AAgQRSCAgICIAdgEIICIAACNMAOgA7AA43TDdNAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXNvsAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQRSCAgICIAdgEMICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgkAFzb7AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgE6AAIEEUggICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc2+wBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIAlgACBBFIICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNvsAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAAIAAgQRSCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzb7AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEEUggICAiAHYBHCAiAAAjZACEAJTebAA4AKDecACMAVjedNtEBmABXAHYAFwApADEAZTelXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQRPgD2ADIAvgACABAiBBF7TADoAOwAON6c3rwBGpwJSAlMCVAJVAlYCVwJYgFSAVYBWgFeAWIBZgFqnN7A3sTeyN7M3tDe1N7aBBF+BBGCBBGGBBGKBBGOBBGSBBGWAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzb8AGUAZQBlADEAZQCtAlIAZQBlABcAZYAAgACAAIEEXQgICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc2/ABlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBBF0ICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNvwAZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQRdCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXApgAFzb8AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgF+AAIEEXQgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc2/ABlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAAgACBBF0ICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNvwAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQRdCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzb8AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgACAAIEEXQgICAiAHYBaCAiAAAjfEBIAmwCcAJ04IgAhAJ8AoDgjACMAnjgkAKEADgAlAKIAowAoAKQAFwAXABcAKQBFAGUAZTgsADEAZQBXAGUBjjbKAGUAZTg0AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEENAgIgQRoCIAMCIBjgQRJCAiBBGcIEp+VuHvTADoAOwAOODg4OwBGogGXAZiAPIA9ojg8OD2BBGmBBHSAKNkAIQAlOEAADgAoOEEAIwBWOEI20gGXAFcAdgAXACkAMQBlOEpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBGaAPIAMgC+AAIAECIEEatMAOgA7AA44TDhVAEaoAa0BrgGvAbABsQGyAbMBtIBAgEGAQoBDgESARYBGgEeoOFY4VzhYOFk4WjhbOFw4XYEEa4EEbIEEbYEEb4EEcIEEcYEEcoEEc4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXODwAZQBlAGUAMQBlAK0BrQBlAGUAFwBlgACAJYAAgQRpCAgICIAdgEAICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzg8AGUAZQBlADEAZQCtAa4AZQBlABcAZYAAgACAAIEEaQgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFzh/ABc4PABlAGUAZQAxAGUArQGvAGUAZQAXAGWAAIEEboAAgQRpCAgICIAdgEIICIAACNMAOgA7AA44jTiOAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXODwAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQRpCAgICIAdgEMICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgkAFzg8AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgE6AAIEEaQgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc4PABlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIAlgACBBGkICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXODwAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAAIAAgQRpCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzg8AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEEaQgICAiAHYBHCAiAAAjZACEAJTjcAA4AKDjdACMAVjjeNtIBmABXAHYAFwApADEAZTjmXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQRmgD2ADIAvgACABAiBBHXTADoAOwAOOOg48ABGpwJSAlMCVAJVAlYCVwJYgFSAVYBWgFeAWIBZgFqnOPE48jjzOPQ49Tj2OPeBBHaBBHeBBHiBBHmBBHqBBHuBBHyAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzg9AGUAZQBlADEAZQCtAlIAZQBlABcAZYAAgACAAIEEdAgICAiAHYBUCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc4PQBlAGUAZQAxAGUArQJTAGUAZQAXAGWAAIAlgACBBHQICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXOD0AZQBlAGUAMQBlAK0CVABlAGUAFwBlgACAAIAAgQR0CAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXApgAFzg9AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgF+AAIEEdAgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc4PQBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAAgACBBHQICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXOD0AZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQR0CAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzg9AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgACAAIEEdAgICAiAHYBaCAiAAAjfEBIAmwCcAJ05YwAhAJ8AoDlkACMAnjllAKEADgAlAKIAowAoAKQAFwAXABcAKQBFAGUAZTltADEAZQBXAGUBjjbLAGUAZTl1AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEENAgIgQR/CIAMCIBjgQRKCAiBBH4IEwAAAAEG6s3i0wA6ADsADjl5OXwARqIBlwGYgDyAPaI5fTl+gQSAgQSLgCjZACEAJTmBAA4AKDmCACMAVjmDNtMBlwBXAHYAFwApADEAZTmLXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQR9gDyADIAvgACABAiBBIHTADoAOwAOOY05lgBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqDmXOZg5mTmaOZs5nDmdOZ6BBIKBBIOBBISBBIaBBIeBBIiBBImBBIqAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzl9AGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEEgAgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc5fQBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBBIAICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc5wAAXOX0AZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBBIWAAIEEgAgICAiAHYBCCAiAAAjTADoAOwAOOc45zwBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzl9AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEEgAgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABc5fQBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACBBIAICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOX0AZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQSACAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzl9AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEEgAgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc5fQBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBBIAICAgIgB2ARwgIgAAI2QAhACU6HQAOACg6HgAjAFY6HzbTAZgAVwB2ABcAKQAxAGU6J18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEEfYA9gAyAL4AAgAQIgQSM0wA6ADsADjopOjEARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapzoyOjM6NDo1OjY6Nzo4gQSNgQSOgQSPgQSQgQSRgQSSgQSTgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc5fgBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIAAgACBBIsICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOX4AZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQSLCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzl+AGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIEEiwgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKYABc5fgBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIBfgACBBIsICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXOX4AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQSLCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzl+AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEEiwgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc5fgBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBBIsICAgIgB2AWggIgAAI3xASAJsAnACdOqQAIQCfAKA6pQAjAJ46pgChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU6rgAxAGUAVwBlAY42zABlAGU6tgBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBDQICIEElgiADAiAY4EESwgIgQSVCBKjdvzE0wA6ADsADjq6Or0ARqIBlwGYgDyAPaI6vjq/gQSXgQSigCjZACEAJTrCAA4AKDrDACMAVjrENtQBlwBXAHYAFwApADEAZTrMXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQSUgDyADIAvgACABAiBBJjTADoAOwAOOs461wBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqDrYOtk62jrbOtw63TreOt+BBJmBBJqBBJuBBJ2BBJ6BBJ+BBKCBBKGAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzq+AGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEElwgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc6vgBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBBJcICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc7AQAXOr4AZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBBJyAAIEElwgICAiAHYBCCAiAAAjTADoAOwAOOw87EABGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzq+AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEElwgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABc6vgBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACBBJcICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOr4AZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQSXCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzq+AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEElwgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc6vgBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBBJcICAgIgB2ARwgIgAAI2QAhACU7XgAOACg7XwAjAFY7YDbUAZgAVwB2ABcAKQAxAGU7aF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEElIA9gAyAL4AAgAQIgQSj0wA6ADsADjtqO3IARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapztzO3Q7dTt2O3c7eDt5gQSkgQSlgQSmgQSngQSogQSpgQSqgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc6vwBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIAAgACBBKIICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOr8AZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQSiCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzq/AGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIEEoggICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKYABc6vwBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIBfgACBBKIICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXOr8AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQSiCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzq/AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEEoggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc6vwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBBKIICAgIgB2AWggIgAAI3xASAJsAnACdO+UAIQCfAKA75gAjAJ475wChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU77wAxAGUAVwBlAY42zQBlAGU79wBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBDQICIEErQiADAiAY4EETAgIgQSsCBLHMH3I0wA6ADsADjv7O/4ARqIBlwGYgDyAPaI7/zwAgQSugQS5gCjZACEAJTwDAA4AKDwEACMAVjwFNtUBlwBXAHYAFwApADEAZTwNXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQSrgDyADIAvgACABAiBBK/TADoAOwAOPA88GABGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqDwZPBo8GzwcPB08HjwfPCCBBLCBBLGBBLKBBLSBBLWBBLaBBLeBBLiAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzv/AGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEErggICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc7/wBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBBK4ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc8QgAXO/8AZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBBLOAAIEErggICAiAHYBCCAiAAAjTADoAOwAOPFA8UQBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzv/AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEErggICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABc7/wBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACBBK4ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXO/8AZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQSuCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzv/AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEErggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc7/wBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBBK4ICAgIgB2ARwgIgAAI2QAhACU8nwAOACg8oAAjAFY8oTbVAZgAVwB2ABcAKQAxAGU8qV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEEq4A9gAyAL4AAgAQIgQS60wA6ADsADjyrPLMARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapzy0PLU8tjy3PLg8uTy6gQS7gQS8gQS9gQS+gQS/gQTAgQTBgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc8AABlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIAAgACBBLkICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPAAAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQS5CAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzwAAGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIEEuQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKYABc8AABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIBfgACBBLkICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXPAAAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQS5CAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzwAAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEEuQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc8AABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBBLkICAgIgB2AWggIgAAI3xASAJsAnACdPSYAIQCfAKA9JwAjAJ49KAChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU9MAAxAGUAVwBlAY42zgBlAGU9OABlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBDQICIEExAiADAiAY4EETQgIgQTDCBKM1mDj0wA6ADsADj08PT8ARqIBlwGYgDyAPaI9QD1BgQTFgQTQgCjZACEAJT1EAA4AKD1FACMAVj1GNtYBlwBXAHYAFwApADEAZT1OXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQTCgDyADIAvgACABAiBBMbTADoAOwAOPVA9WQBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqD1aPVs9XD1dPV49Xz1gPWGBBMeBBMiBBMmBBMuBBMyBBM2BBM6BBM+AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz1AAGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEExQgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc9QABlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBBMUICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc9gwAXPUAAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBBMqAAIEExQgICAiAHYBCCAiAAAjTADoAOwAOPZE9kgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz1AAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEExQgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABc9QABlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACBBMUICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPUAAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQTFCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz1AAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEExQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc9QABlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBBMUICAgIgB2ARwgIgAAI2QAhACU94AAOACg94QAjAFY94jbWAZgAVwB2ABcAKQAxAGU96l8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEEwoA9gAyAL4AAgAQIgQTR0wA6ADsADj3sPfQARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapz31PfY99z34Pfk9+j37gQTSgQTTgQTUgQTVgQTWgQTXgQTYgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc9QQBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIAAgACBBNAICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPUEAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQTQCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz1BAGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIEE0AgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKYABc9QQBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIBfgACBBNAICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXPUEAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQTQCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz1BAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEE0AgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc9QQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBBNAICAgIgB2AWggIgAAI3xASAJsAnACdPmcAIQCfAKA+aAAjAJ4+aQChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU+cQAxAGUAVwBlAY42zwBlAGU+eQBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBDQICIEE2wiADAiAY4EETggIgQTaCBIqcKVf0wA6ADsADj59PoAARqIBlwGYgDyAPaI+gT6CgQTcgQTngCjZACEAJT6FAA4AKD6GACMAVj6HNtcBlwBXAHYAFwApADEAZT6PXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQTZgDyADIAvgACABAiBBN3TADoAOwAOPpE+mgBGqAGtAa4BrwGwAbEBsgGzAbSAQIBBgEKAQ4BEgEWARoBHqD6bPpw+nT6ePp8+oD6hPqKBBN6BBN+BBOCBBOKBBOOBBOSBBOWBBOaAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz6BAGUAZQBlADEAZQCtAa0AZQBlABcAZYAAgCWAAIEE3AgICAiAHYBACAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc+gQBlAGUAZQAxAGUArQGuAGUAZQAXAGWAAIAAgACBBNwICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc+xAAXPoEAZQBlAGUAMQBlAK0BrwBlAGUAFwBlgACBBOGAAIEE3AgICAiAHYBCCAiAAAjTADoAOwAOPtI+0wBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz6BAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEE3AgICAiAHYBDCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIJABc+gQBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIBOgACBBNwICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPoEAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAJYAAgQTcCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz6BAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgACAAIEE3AgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc+gQBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBBNwICAgIgB2ARwgIgAAI2QAhACU/IQAOACg/IgAjAFY/IzbXAZgAVwB2ABcAKQAxAGU/K18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEE2YA9gAyAL4AAgAQIgQTo0wA6ADsADj8tPzUARqcCUgJTAlQCVQJWAlcCWIBUgFWAVoBXgFiAWYBapz82Pzc/OD85Pzo/Oz88gQTpgQTqgQTrgQTsgQTtgQTugQTvgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc+ggBlAGUAZQAxAGUArQJSAGUAZQAXAGWAAIAAgACBBOcICAgIgB2AVAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPoIAZQBlAGUAMQBlAK0CUwBlAGUAFwBlgACAJYAAgQTnCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz6CAGUAZQBlADEAZQCtAlQAZQBlABcAZYAAgACAAIEE5wgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKYABc+ggBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIBfgACBBOcICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXPoIAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAAIAAgQTnCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz6CAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEE5wgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc+ggBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIAAgACBBOcICAgIgB2AWggIgAAI0gA7AA4/qAC1oIAc0wA6ADsADj+rP6wARqCggCjTADoAOwAOP68/sABGoKCAKNMAOgA7AA4/sz+0AEagoIAo0gC3ALg/tz+4XlhETW9kZWxQYWNrYWdlpj+5P7o/uz+8P70AvF5YRE1vZGVsUGFja2FnZV8QD1hEVU1MUGFja2FnZUltcF8QEVhEVU1MTmFtZXNwYWNlSW1wXxAUWERVTUxOYW1lZEVsZW1lbnRJbXBfEA9YRFVNTEVsZW1lbnRJbXDSADsADj+/ALWggBzTADoAOwAOP8I/wwBGoKCAKNIAtwC4P8Y/x1lYRFBNTW9kZWyjP8Y/yAC8V1hETW9kZWwAAAAIAAAAGQAAACIAAAAsAAAAMQAAADoAAAA/AAAAUQAAAFYAAABbAAAAXQAAClEAAApXAAAKdAAACoYAAAqNAAAKmgAACq0AAArFAAAK0wAACu0AAArvAAAK8gAACvUAAAr3AAAK+gAACvwAAAr/AAALOAAAC1cAAAt0AAALkwAAC6UAAAvFAAALzAAAC+oAAAv2AAAMEgAADBgAAAw6AAAMWwAADG4AAAxwAAAMcwAADHYAAAx4AAAMegAADHwAAAx/AAAMggAADIQAAAyGAAAMiAAADIoAAAyMAAAMjgAADI8AAAyTAAAMoAAADKgAAAyzAAAMvAAADL4AAAzAAAAMwgAADMQAAAzNAAAMzwAADNIAAAzVAAAM2AAADNoAAAzpAAAM+wAADQUAAA0OAAANUQAADXUAAA2ZAAANvAAADeMAAA4DAAAOKgAADlEAAA5xAAAOlQAADrkAAA7FAAAOxwAADskAAA7LAAAOzQAADs8AAA7RAAAO1AAADtYAAA7YAAAO2wAADt0AAA7fAAAO4gAADuQAAA7lAAAO6gAADvIAAA7/AAAPAgAADwQAAA8HAAAPCQAADwsAAA8aAAAPPwAAD2MAAA+KAAAPrgAAD7AAAA+yAAAPtAAAD7YAAA+4AAAPugAAD7sAAA+9AAAPygAAD90AAA/fAAAP4QAAD+MAAA/lAAAP5wAAD+kAAA/rAAAP7QAAD+8AABACAAAQBAAAEAYAABAIAAAQCgAAEAwAABAOAAAQEAAAEBIAABAUAAAQFgAAECwAABA/AAAQWwAAEHgAABCUAAAQqAAAELoAABDQAAAQ6QAAESgAABEuAAARNwAAEUQAABFQAAARWgAAEWQAABFvAAARegAAEYcAABGPAAARkQAAEZMAABGVAAARlwAAEZgAABGZAAARmgAAEZsAABGdAAARnwAAEaAAABGhAAARowAAEaQAABGtAAARrgAAEbAAABG5AAARxAAAEc0AABHcAAAR4wAAEesAABH0AAAR/QAAEhAAABIZAAASLAAAEkMAABJVAAASlAAAEpYAABKYAAASmgAAEpwAABKdAAASngAAEp8AABKgAAASogAAEqQAABKlAAASpgAAEqgAABKpAAAS6AAAEuoAABLsAAAS7gAAEvAAABLxAAAS8gAAEvMAABL0AAAS9gAAEvgAABL5AAAS+gAAEvwAABL9AAATBgAAEwcAABMJAAATSAAAE0oAABNMAAATTgAAE1AAABNRAAATUgAAE1MAABNUAAATVgAAE1gAABNZAAATWgAAE1wAABNdAAATnAAAE54AABOgAAATogAAE6QAABOlAAATpgAAE6cAABOoAAATqgAAE6wAABOtAAATrgAAE7AAABOxAAATugAAE7sAABO9AAAT/AAAE/4AABQAAAAUAgAAFAQAABQFAAAUBgAAFAcAABQIAAAUCgAAFAwAABQNAAAUDgAAFBAAABQRAAAUEgAAFFEAABRTAAAUVQAAFFcAABRZAAAUWgAAFFsAABRcAAAUXQAAFF8AABRhAAAUYgAAFGMAABRlAAAUZgAAFHMAABR0AAAUdQAAFHcAABSAAAAUlgAAFJ0AABSqAAAU6QAAFOsAABTtAAAU7wAAFPEAABTyAAAU8wAAFPQAABT1AAAU9wAAFPkAABT6AAAU+wAAFP0AABT+AAAVFwAAFRkAABUbAAAVHQAAFR4AABUgAAAVNwAAFUAAABVOAAAVWwAAFWkAABV+AAAVkgAAFakAABW7AAAV+gAAFfwAABX+AAAWAAAAFgIAABYDAAAWBAAAFgUAABYGAAAWCAAAFgoAABYLAAAWDAAAFg4AABYPAAAWIQAAFioAABY/AAAWTgAAFmMAABZxAAAWhgAAFpoAABaxAAAWwwAAFtAAABbhAAAW4wAAFuUAABbnAAAW6QAAFusAABbtAAAW7wAAFvEAABcCAAAXBAAAFwYAABcIAAAXCgAAFwwAABcOAAAXEAAAFxIAABcUAAAXHQAAFygAABc6AAAXTQAAF1YAABdfAAAXZAAAF2gAABezAAAX1gAAF/YAABgWAAAYGAAAGBoAABgcAAAYHgAAGCAAABghAAAYIgAAGCQAABglAAAYJwAAGCgAABgqAAAYLAAAGC0AABguAAAYMAAAGDEAABg2AAAYQwAAGEgAABhKAAAYTAAAGFEAABhTAAAYVQAAGFcAABhsAAAYgQAAGKYAABjKAAAY8QAAGRUAABkXAAAZGQAAGRsAABkdAAAZHwAAGSEAABkiAAAZJAAAGTEAABlCAAAZRAAAGUYAABlIAAAZSgAAGUwAABlOAAAZUAAAGVIAABljAAAZZQAAGWcAABlpAAAZawAAGW0AABlvAAAZcQAAGXMAABl1AAAZkwAAGbEAABnEAAAZ2AAAGe0AABoKAAAaHgAAGjQAABpzAAAadQAAGncAABp5AAAaewAAGnwAABp9AAAafgAAGn8AABqBAAAagwAAGoQAABqFAAAahwAAGogAABrHAAAayQAAGssAABrNAAAazwAAGtAAABrRAAAa0gAAGtMAABrVAAAa1wAAGtgAABrZAAAa2wAAGtwAABsbAAAbHQAAGx8AABshAAAbIwAAGyQAABslAAAbJgAAGycAABspAAAbKwAAGywAABstAAAbLwAAGzAAABs9AAAbPgAAGz8AABtBAAAbgAAAG4IAABuEAAAbhgAAG4gAABuJAAAbigAAG4sAABuMAAAbjgAAG5AAABuRAAAbkgAAG5QAABuVAAAb1AAAG9YAABvYAAAb2gAAG9wAABvdAAAb3gAAG98AABvgAAAb4gAAG+QAABvlAAAb5gAAG+gAABvpAAAb6gAAHCkAABwrAAAcLQAAHC8AABwxAAAcMgAAHDMAABw0AAAcNQAAHDcAABw5AAAcOgAAHDsAABw9AAAcPgAAHH0AABx/AAAcgQAAHIMAAByFAAAchgAAHIcAAByIAAAciQAAHIsAAByNAAAcjgAAHI8AAByRAAAckgAAHNEAABzTAAAc1QAAHNcAABzZAAAc2gAAHNsAABzcAAAc3QAAHN8AABzhAAAc4gAAHOMAABzlAAAc5gAAHQsAAB0vAAAdVgAAHXoAAB18AAAdfgAAHYAAAB2CAAAdhAAAHYYAAB2HAAAdiQAAHZYAAB2lAAAdpwAAHakAAB2rAAAdrQAAHa8AAB2xAAAdswAAHcIAAB3EAAAdxgAAHcgAAB3KAAAdzAAAHc4AAB3QAAAd0gAAHfIAAB4dAAAeNwAAHlAAAB5qAAAeigAAHq0AAB7sAAAe7gAAHvAAAB7yAAAe9AAAHvUAAB72AAAe9wAAHvgAAB76AAAe/AAAHv0AAB7+AAAfAAAAHwEAAB9AAAAfQgAAH0QAAB9GAAAfSAAAH0kAAB9KAAAfSwAAH0wAAB9OAAAfUAAAH1EAAB9SAAAfVAAAH1UAAB+UAAAflgAAH5gAAB+aAAAfnAAAH50AAB+eAAAfnwAAH6AAAB+iAAAfpAAAH6UAAB+mAAAfqAAAH6kAAB/oAAAf6gAAH+wAAB/uAAAf8AAAH/EAAB/yAAAf8wAAH/QAAB/2AAAf+AAAH/kAAB/6AAAf/AAAH/0AACAAAAAgPwAAIEEAACBDAAAgRQAAIEcAACBIAAAgSQAAIEoAACBLAAAgTQAAIE8AACBQAAAgUQAAIFMAACBUAAAgkwAAIJUAACCXAAAgmQAAIJsAACCcAAAgnQAAIJ4AACCfAAAgoQAAIKMAACCkAAAgpQAAIKcAACCoAAAg5wAAIOkAACDrAAAg7QAAIO8AACDwAAAg8QAAIPIAACDzAAAg9QAAIPcAACD4AAAg+QAAIPsAACD8AAAhBQAAIRMAACEgAAAhLgAAITsAACFOAAAhZQAAIXcAACHCAAAh5QAAIgUAACIlAAAiJwAAIikAACIrAAAiLQAAIi8AACIwAAAiMQAAIjMAACI0AAAiNgAAIjcAACI5AAAiOwAAIjwAACI9AAAiPwAAIkAAACJFAAAiUgAAIlcAACJZAAAiWwAAImAAACJiAAAiZAAAImYAACKLAAAirwAAItYAACL6AAAi/AAAIv4AACMAAAAjAgAAIwQAACMGAAAjBwAAIwkAACMWAAAjJwAAIykAACMrAAAjLQAAIy8AACMxAAAjMwAAIzUAACM3AAAjSAAAI0oAACNMAAAjTgAAI1AAACNSAAAjVAAAI1YAACNYAAAjWgAAI5kAACObAAAjnQAAI58AACOhAAAjogAAI6MAACOkAAAjpQAAI6cAACOpAAAjqgAAI6sAACOtAAAjrgAAI+0AACPvAAAj8QAAI/MAACP1AAAj9gAAI/cAACP4AAAj+QAAI/sAACP9AAAj/gAAI/8AACQBAAAkAgAAJEEAACRDAAAkRQAAJEcAACRJAAAkSgAAJEsAACRMAAAkTQAAJE8AACRRAAAkUgAAJFMAACRVAAAkVgAAJGMAACRkAAAkZQAAJGcAACSmAAAkqAAAJKoAACSsAAAkrgAAJK8AACSwAAAksQAAJLIAACS0AAAktgAAJLcAACS4AAAkugAAJLsAACT6AAAk/AAAJP4AACUAAAAlAgAAJQMAACUEAAAlBQAAJQYAACUIAAAlCgAAJQsAACUMAAAlDgAAJQ8AACVOAAAlUAAAJVIAACVUAAAlVgAAJVcAACVYAAAlWQAAJVoAACVcAAAlXgAAJV8AACVgAAAlYgAAJWMAACWiAAAlpAAAJaYAACWoAAAlqgAAJasAACWsAAAlrQAAJa4AACWwAAAlsgAAJbMAACW0AAAltgAAJbcAACX2AAAl+AAAJfoAACX8AAAl/gAAJf8AACYAAAAmAQAAJgIAACYEAAAmBgAAJgcAACYIAAAmCgAAJgsAACYwAAAmVAAAJnsAACafAAAmoQAAJqMAACalAAAmpwAAJqkAACarAAAmrAAAJq4AACa7AAAmygAAJswAACbOAAAm0AAAJtIAACbUAAAm1gAAJtgAACbnAAAm6QAAJusAACbtAAAm7wAAJvEAACbzAAAm9QAAJvcAACc2AAAnOAAAJzoAACc8AAAnPgAAJz8AACdAAAAnQQAAJ0IAACdEAAAnRgAAJ0cAACdIAAAnSgAAJ0sAACeKAAAnjAAAJ44AACeQAAAnkgAAJ5MAACeUAAAnlQAAJ5YAACeYAAAnmgAAJ5sAACecAAAnngAAJ58AACfeAAAn4AAAJ+IAACfkAAAn5gAAJ+cAACfoAAAn6QAAJ+oAACfsAAAn7gAAJ+8AACfwAAAn8gAAJ/MAACgyAAAoNAAAKDYAACg4AAAoOgAAKDsAACg8AAAoPQAAKD4AAChAAAAoQgAAKEMAAChEAAAoRgAAKEcAACiGAAAoiAAAKIoAACiMAAAojgAAKI8AACiQAAAokQAAKJIAACiUAAAolgAAKJcAACiYAAAomgAAKJsAACjaAAAo3AAAKN4AACjgAAAo4gAAKOMAACjkAAAo5QAAKOYAACjoAAAo6gAAKOsAACjsAAAo7gAAKO8AACkuAAApMAAAKTIAACk0AAApNgAAKTcAACk4AAApOQAAKToAACk8AAApPgAAKT8AAClAAAApQgAAKUMAACmOAAApsQAAKdEAACnxAAAp8wAAKfUAACn3AAAp+QAAKfsAACn8AAAp/QAAKf8AACoAAAAqAgAAKgMAACoFAAAqBwAAKggAACoJAAAqCwAAKgwAACoRAAAqHgAAKiMAAColAAAqJwAAKiwAACouAAAqMAAAKjIAACpXAAAqewAAKqIAACrGAAAqyAAAKsoAACrMAAAqzgAAKtAAACrSAAAq0wAAKtUAACriAAAq8wAAKvUAACr3AAAq+QAAKvsAACr9AAAq/wAAKwEAACsDAAArFAAAKxYAACsYAAArGgAAKxwAACseAAArIAAAKyIAACskAAArJgAAK2UAACtnAAAraQAAK2sAACttAAArbgAAK28AACtwAAArcQAAK3MAACt1AAArdgAAK3cAACt5AAAregAAK7kAACu7AAArvQAAK78AACvBAAArwgAAK8MAACvEAAArxQAAK8cAACvJAAArygAAK8sAACvNAAArzgAALA0AACwPAAAsEQAALBMAACwVAAAsFgAALBcAACwYAAAsGQAALBsAACwdAAAsHgAALB8AACwhAAAsIgAALC8AACwwAAAsMQAALDMAACxyAAAsdAAALHYAACx4AAAsegAALHsAACx8AAAsfQAALH4AACyAAAAsggAALIMAACyEAAAshgAALIcAACzGAAAsyAAALMoAACzMAAAszgAALM8AACzQAAAs0QAALNIAACzUAAAs1gAALNcAACzYAAAs2gAALNsAAC0aAAAtHAAALR4AAC0gAAAtIgAALSMAAC0kAAAtJQAALSYAAC0oAAAtKgAALSsAAC0sAAAtLgAALS8AAC1uAAAtcAAALXIAAC10AAAtdgAALXcAAC14AAAteQAALXoAAC18AAAtfgAALX8AAC2AAAAtggAALYMAAC3CAAAtxAAALcYAAC3IAAAtygAALcsAAC3MAAAtzQAALc4AAC3QAAAt0gAALdMAAC3UAAAt1gAALdcAAC38AAAuIAAALkcAAC5rAAAubQAALm8AAC5xAAAucwAALnUAAC53AAAueAAALnoAAC6HAAAulgAALpgAAC6aAAAunAAALp4AAC6gAAAuogAALqQAAC6zAAAutQAALrcAAC65AAAuuwAALr0AAC6/AAAuwQAALsMAAC8CAAAvBAAALwYAAC8IAAAvCgAALwsAAC8MAAAvDQAALw4AAC8QAAAvEgAALxMAAC8UAAAvFgAALxcAAC8ZAAAvWAAAL1oAAC9cAAAvXgAAL2AAAC9hAAAvYgAAL2MAAC9kAAAvZgAAL2gAAC9pAAAvagAAL2wAAC9tAAAvrAAAL64AAC+wAAAvsgAAL7QAAC+1AAAvtgAAL7cAAC+4AAAvugAAL7wAAC+9AAAvvgAAL8AAAC/BAAAwAAAAMAIAADAEAAAwBgAAMAgAADAJAAAwCgAAMAsAADAMAAAwDgAAMBAAADARAAAwEgAAMBQAADAVAAAwGAAAMFcAADBZAAAwWwAAMF0AADBfAAAwYAAAMGEAADBiAAAwYwAAMGUAADBnAAAwaAAAMGkAADBrAAAwbAAAMKsAADCtAAAwrwAAMLEAADCzAAAwtAAAMLUAADC2AAAwtwAAMLkAADC7AAAwvAAAML0AADC/AAAwwAAAMP8AADEBAAAxAwAAMQUAADEHAAAxCAAAMQkAADEKAAAxCwAAMQ0AADEPAAAxEAAAMREAADETAAAxFAAAMV8AADGCAAAxogAAMcIAADHEAAAxxgAAMcgAADHKAAAxzAAAMc0AADHOAAAx0AAAMdEAADHTAAAx1AAAMdYAADHYAAAx2QAAMdoAADHcAAAx3QAAMeIAADHvAAAx9AAAMfYAADH4AAAx/QAAMf8AADIBAAAyAwAAMigAADJMAAAycwAAMpcAADKZAAAymwAAMp0AADKfAAAyoQAAMqMAADKkAAAypgAAMrMAADLEAAAyxgAAMsgAADLKAAAyzAAAMs4AADLQAAAy0gAAMtQAADLlAAAy5wAAMukAADLrAAAy7QAAMu8AADLxAAAy8wAAMvUAADL3AAAzNgAAMzgAADM6AAAzPAAAMz4AADM/AAAzQAAAM0EAADNCAAAzRAAAM0YAADNHAAAzSAAAM0oAADNLAAAzigAAM4wAADOOAAAzkAAAM5IAADOTAAAzlAAAM5UAADOWAAAzmAAAM5oAADObAAAznAAAM54AADOfAAAz3gAAM+AAADPiAAAz5AAAM+YAADPnAAAz6AAAM+kAADPqAAAz7AAAM+4AADPvAAAz8AAAM/IAADPzAAA0AAAANAEAADQCAAA0BAAANEMAADRFAAA0RwAANEkAADRLAAA0TAAANE0AADROAAA0TwAANFEAADRTAAA0VAAANFUAADRXAAA0WAAANJcAADSZAAA0mwAANJ0AADSfAAA0oAAANKEAADSiAAA0owAANKUAADSnAAA0qAAANKkAADSrAAA0rAAANOsAADTtAAA07wAANPEAADTzAAA09AAANPUAADT2AAA09wAANPkAADT7AAA0/AAANP0AADT/AAA1AAAANT8AADVBAAA1QwAANUUAADVHAAA1SAAANUkAADVKAAA1SwAANU0AADVPAAA1UAAANVEAADVTAAA1VAAANZMAADWVAAA1lwAANZkAADWbAAA1nAAANZ0AADWeAAA1nwAANaEAADWjAAA1pAAANaUAADWnAAA1qAAANc0AADXxAAA2GAAANjwAADY+AAA2QAAANkIAADZEAAA2RgAANkgAADZJAAA2SwAANlgAADZnAAA2aQAANmsAADZtAAA2bwAANnEAADZzAAA2dQAANoQAADaGAAA2iAAANooAADaMAAA2jgAANpAAADaSAAA2lAAANtMAADbVAAA21wAANtkAADbbAAA23AAANt0AADbeAAA23wAANuEAADbjAAA25AAANuUAADbnAAA26AAANycAADcpAAA3KwAANy0AADcvAAA3MAAANzEAADcyAAA3MwAANzUAADc3AAA3OAAANzkAADc7AAA3PAAAN3sAADd9AAA3fwAAN4EAADeDAAA3hAAAN4UAADeGAAA3hwAAN4kAADeLAAA3jAAAN40AADePAAA3kAAAN88AADfRAAA30wAAN9UAADfXAAA32AAAN9kAADfaAAA32wAAN90AADffAAA34AAAN+EAADfjAAA35AAAOCMAADglAAA4JwAAOCkAADgrAAA4LAAAOC0AADguAAA4LwAAODEAADgzAAA4NAAAODUAADg3AAA4OAAAOHcAADh5AAA4ewAAOH0AADh/AAA4gAAAOIEAADiCAAA4gwAAOIUAADiHAAA4iAAAOIkAADiLAAA4jAAAOMsAADjNAAA4zwAAONEAADjTAAA41AAAONUAADjWAAA41wAAONkAADjbAAA43AAAON0AADjfAAA44AAAOSsAADlOAAA5bgAAOY4AADmQAAA5kgAAOZQAADmWAAA5mAAAOZkAADmaAAA5nAAAOZ0AADmfAAA5oAAAOaIAADmkAAA5pQAAOaYAADmoAAA5qQAAOa4AADm7AAA5wAAAOcIAADnEAAA5yQAAOcsAADnNAAA5zwAAOfQAADoYAAA6PwAAOmMAADplAAA6ZwAAOmkAADprAAA6bQAAOm8AADpwAAA6cgAAOn8AADqQAAA6kgAAOpQAADqWAAA6mAAAOpoAADqcAAA6ngAAOqAAADqxAAA6swAAOrUAADq3AAA6uQAAOrsAADq9AAA6vwAAOsEAADrDAAA7AgAAOwQAADsGAAA7CAAAOwoAADsLAAA7DAAAOw0AADsOAAA7EAAAOxIAADsTAAA7FAAAOxYAADsXAAA7VgAAO1gAADtaAAA7XAAAO14AADtfAAA7YAAAO2EAADtiAAA7ZAAAO2YAADtnAAA7aAAAO2oAADtrAAA7qgAAO6wAADuuAAA7sAAAO7IAADuzAAA7tAAAO7UAADu2AAA7uAAAO7oAADu7AAA7vAAAO74AADu/AAA7zAAAO80AADvOAAA70AAAPA8AADwRAAA8EwAAPBUAADwXAAA8GAAAPBkAADwaAAA8GwAAPB0AADwfAAA8IAAAPCEAADwjAAA8JAAAPGMAADxlAAA8ZwAAPGkAADxrAAA8bAAAPG0AADxuAAA8bwAAPHEAADxzAAA8dAAAPHUAADx3AAA8eAAAPLcAADy5AAA8uwAAPL0AADy/AAA8wAAAPMEAADzCAAA8wwAAPMUAADzHAAA8yAAAPMkAADzLAAA8zAAAPQsAAD0NAAA9DwAAPREAAD0TAAA9FAAAPRUAAD0WAAA9FwAAPRkAAD0bAAA9HAAAPR0AAD0fAAA9IAAAPV8AAD1hAAA9YwAAPWUAAD1nAAA9aAAAPWkAAD1qAAA9awAAPW0AAD1vAAA9cAAAPXEAAD1zAAA9dAAAPZkAAD29AAA95AAAPggAAD4KAAA+DAAAPg4AAD4QAAA+EgAAPhQAAD4VAAA+FwAAPiQAAD4zAAA+NQAAPjcAAD45AAA+OwAAPj0AAD4/AAA+QQAAPlAAAD5SAAA+VAAAPlYAAD5YAAA+WgAAPlwAAD5eAAA+YAAAPp8AAD6hAAA+owAAPqUAAD6nAAA+qAAAPqkAAD6qAAA+qwAAPq0AAD6vAAA+sAAAPrEAAD6zAAA+tAAAPvMAAD71AAA+9wAAPvkAAD77AAA+/AAAPv0AAD7+AAA+/wAAPwEAAD8DAAA/BAAAPwUAAD8HAAA/CAAAP0cAAD9JAAA/SwAAP00AAD9PAAA/UAAAP1EAAD9SAAA/UwAAP1UAAD9XAAA/WAAAP1kAAD9bAAA/XAAAP5sAAD+dAAA/nwAAP6EAAD+jAAA/pAAAP6UAAD+mAAA/pwAAP6kAAD+rAAA/rAAAP60AAD+vAAA/sAAAP+8AAD/xAAA/8wAAP/UAAD/3AAA/+AAAP/kAAD/6AAA/+wAAP/0AAD//AABAAAAAQAEAAEADAABABAAAQEMAAEBFAABARwAAQEkAAEBLAABATAAAQE0AAEBOAABATwAAQFEAAEBTAABAVAAAQFUAAEBXAABAWAAAQJcAAECZAABAmwAAQJ0AAECfAABAoAAAQKEAAECiAABAowAAQKUAAECnAABAqAAAQKkAAECrAABArAAAQPcAAEEaAABBOgAAQVoAAEFcAABBXgAAQWAAAEFiAABBZAAAQWUAAEFmAABBaAAAQWkAAEFrAABBbAAAQW4AAEFwAABBcQAAQXIAAEF0AABBdQAAQXoAAEGHAABBjAAAQY4AAEGQAABBlQAAQZcAAEGZAABBmwAAQcAAAEHkAABCCwAAQi8AAEIxAABCMwAAQjUAAEI3AABCOQAAQjsAAEI8AABCPgAAQksAAEJcAABCXgAAQmAAAEJiAABCZAAAQmYAAEJoAABCagAAQmwAAEJ9AABCfwAAQoEAAEKDAABChQAAQocAAEKJAABCiwAAQo0AAEKPAABCzgAAQtAAAELSAABC1AAAQtYAAELXAABC2AAAQtkAAELaAABC3AAAQt4AAELfAABC4AAAQuIAAELjAABDIgAAQyQAAEMmAABDKAAAQyoAAEMrAABDLAAAQy0AAEMuAABDMAAAQzIAAEMzAABDNAAAQzYAAEM3AABDdgAAQ3gAAEN6AABDfAAAQ34AAEN/AABDgAAAQ4EAAEOCAABDhAAAQ4YAAEOHAABDiAAAQ4oAAEOLAABDmAAAQ5kAAEOaAABDnAAAQ9sAAEPdAABD3wAAQ+EAAEPjAABD5AAAQ+UAAEPmAABD5wAAQ+kAAEPrAABD7AAAQ+0AAEPvAABD8AAARC8AAEQxAABEMwAARDUAAEQ3AABEOAAARDkAAEQ6AABEOwAARD0AAEQ/AABEQAAAREEAAERDAABERAAARIMAAESFAABEhwAARIkAAESLAABEjAAARI0AAESOAABEjwAARJEAAESTAABElAAARJUAAESXAABEmAAARNcAAETZAABE2wAARN0AAETfAABE4AAAROEAAETiAABE4wAAROUAAETnAABE6AAAROkAAETrAABE7AAARSsAAEUtAABFLwAARTEAAEUzAABFNAAARTUAAEU2AABFNwAARTkAAEU7AABFPAAART0AAEU/AABFQAAARWUAAEWJAABFsAAARdQAAEXWAABF2AAARdoAAEXcAABF3gAAReAAAEXhAABF4wAARfAAAEX/AABGAQAARgMAAEYFAABGBwAARgkAAEYLAABGDQAARhwAAEYeAABGIAAARiIAAEYkAABGJgAARigAAEYqAABGLAAARmsAAEZtAABGbwAARnEAAEZzAABGdAAARnUAAEZ2AABGdwAARnkAAEZ7AABGfAAARn0AAEZ/AABGgAAARr8AAEbBAABGwwAARsUAAEbHAABGyAAARskAAEbKAABGywAARs0AAEbPAABG0AAARtEAAEbTAABG1AAARxMAAEcVAABHFwAARxkAAEcbAABHHAAARx0AAEceAABHHwAARyEAAEcjAABHJAAARyUAAEcnAABHKAAAR2cAAEdpAABHawAAR20AAEdvAABHcAAAR3EAAEdyAABHcwAAR3UAAEd3AABHeAAAR3kAAEd7AABHfAAAR7sAAEe9AABHvwAAR8EAAEfDAABHxAAAR8UAAEfGAABHxwAAR8kAAEfLAABHzAAAR80AAEfPAABH0AAASA8AAEgRAABIEwAASBUAAEgXAABIGAAASBkAAEgaAABIGwAASB0AAEgfAABIIAAASCEAAEgjAABIJAAASGMAAEhlAABIZwAASGkAAEhrAABIbAAASG0AAEhuAABIbwAASHEAAEhzAABIdAAASHUAAEh3AABIeAAASMMAAEjmAABJBgAASSYAAEkoAABJKgAASSwAAEkuAABJMAAASTEAAEkyAABJNAAASTUAAEk3AABJOAAASToAAEk8AABJPQAAST4AAElAAABJQQAASUYAAElTAABJWAAASVoAAElcAABJYQAASWMAAEllAABJZwAASYwAAEmwAABJ1wAASfsAAEn9AABJ/wAASgEAAEoDAABKBQAASgcAAEoIAABKCgAAShcAAEooAABKKgAASiwAAEouAABKMAAASjIAAEo0AABKNgAASjgAAEpJAABKSwAASk0AAEpPAABKUQAASlMAAEpVAABKVwAASlkAAEpbAABKmgAASpwAAEqeAABKoAAASqIAAEqjAABKpAAASqUAAEqmAABKqAAASqoAAEqrAABKrAAASq4AAEqvAABK7gAASvAAAEryAABK9AAASvYAAEr3AABK+AAASvkAAEr6AABK/AAASv4AAEr/AABLAAAASwIAAEsDAABLQgAAS0QAAEtGAABLSAAAS0oAAEtLAABLTAAAS00AAEtOAABLUAAAS1IAAEtTAABLVAAAS1YAAEtXAABLZAAAS2UAAEtmAABLaAAAS6cAAEupAABLqwAAS60AAEuvAABLsAAAS7EAAEuyAABLswAAS7UAAEu3AABLuAAAS7kAAEu7AABLvAAAS/sAAEv9AABL/wAATAEAAEwDAABMBAAATAUAAEwGAABMBwAATAkAAEwLAABMDAAATA0AAEwPAABMEAAATE8AAExRAABMUwAATFUAAExXAABMWAAATFkAAExaAABMWwAATF0AAExfAABMYAAATGEAAExjAABMZAAATKMAAEylAABMpwAATKkAAEyrAABMrAAATK0AAEyuAABMrwAATLEAAEyzAABMtAAATLUAAEy3AABMuAAATPcAAEz5AABM+wAATP0AAEz/AABNAAAATQEAAE0CAABNAwAATQUAAE0HAABNCAAATQkAAE0LAABNDAAATTEAAE1VAABNfAAATaAAAE2iAABNpAAATaYAAE2oAABNqgAATawAAE2tAABNrwAATbwAAE3LAABNzQAATc8AAE3RAABN0wAATdUAAE3XAABN2QAATegAAE3qAABN7AAATe4AAE3wAABN8gAATfQAAE32AABN+AAATjcAAE45AABOOwAATj0AAE4/AABOQAAATkEAAE5CAABOQwAATkUAAE5HAABOSAAATkkAAE5LAABOTAAATosAAE6NAABOjwAATpEAAE6TAABOlAAATpUAAE6WAABOlwAATpkAAE6bAABOnAAATp0AAE6fAABOoAAATt8AAE7hAABO4wAATuUAAE7nAABO6AAATukAAE7qAABO6wAATu0AAE7vAABO8AAATvEAAE7zAABO9AAATzMAAE81AABPNwAATzkAAE87AABPPAAATz0AAE8+AABPPwAAT0EAAE9DAABPRAAAT0UAAE9HAABPSAAAT4cAAE+JAABPiwAAT40AAE+PAABPkAAAT5EAAE+SAABPkwAAT5UAAE+XAABPmAAAT5kAAE+bAABPnAAAT9sAAE/dAABP3wAAT+EAAE/jAABP5AAAT+UAAE/mAABP5wAAT+kAAE/rAABP7AAAT+0AAE/vAABP8AAAUC8AAFAxAABQMwAAUDUAAFA3AABQOAAAUDkAAFA6AABQOwAAUD0AAFA/AABQQAAAUEEAAFBDAABQRAAAUI8AAFCyAABQ0gAAUPIAAFD0AABQ9gAAUPgAAFD6AABQ/AAAUP0AAFD+AABRAAAAUQEAAFEDAABRBAAAUQYAAFEIAABRCQAAUQoAAFEMAABRDQAAURIAAFEfAABRJAAAUSYAAFEoAABRLQAAUS8AAFExAABRMwAAUVgAAFF8AABRowAAUccAAFHJAABRywAAUc0AAFHPAABR0QAAUdMAAFHUAABR1gAAUeMAAFH0AABR9gAAUfgAAFH6AABR/AAAUf4AAFIAAABSAgAAUgQAAFIVAABSFwAAUhkAAFIbAABSHQAAUh8AAFIhAABSIwAAUiUAAFInAABSZgAAUmgAAFJqAABSbAAAUm4AAFJvAABScAAAUnEAAFJyAABSdAAAUnYAAFJ3AABSeAAAUnoAAFJ7AABSugAAUrwAAFK+AABSwAAAUsIAAFLDAABSxAAAUsUAAFLGAABSyAAAUsoAAFLLAABSzAAAUs4AAFLPAABTDgAAUxAAAFMSAABTFAAAUxYAAFMXAABTGAAAUxkAAFMaAABTHAAAUx4AAFMfAABTIAAAUyIAAFMjAABTMAAAUzEAAFMyAABTNAAAU3MAAFN1AABTdwAAU3kAAFN7AABTfAAAU30AAFN+AABTfwAAU4EAAFODAABThAAAU4UAAFOHAABTiAAAU8cAAFPJAABTywAAU80AAFPPAABT0AAAU9EAAFPSAABT0wAAU9UAAFPXAABT2AAAU9kAAFPbAABT3AAAVBsAAFQdAABUHwAAVCEAAFQjAABUJAAAVCUAAFQmAABUJwAAVCkAAFQrAABULAAAVC0AAFQvAABUMAAAVG8AAFRxAABUcwAAVHUAAFR3AABUeAAAVHkAAFR6AABUewAAVH0AAFR/AABUgAAAVIEAAFSDAABUhAAAVMMAAFTFAABUxwAAVMkAAFTLAABUzAAAVM0AAFTOAABUzwAAVNEAAFTTAABU1AAAVNUAAFTXAABU2AAAVP0AAFUhAABVSAAAVWwAAFVuAABVcAAAVXIAAFV0AABVdgAAVXgAAFV5AABVewAAVYgAAFWXAABVmQAAVZsAAFWdAABVnwAAVaEAAFWjAABVpQAAVbQAAFW3AABVugAAVb0AAFXAAABVwwAAVcYAAFXJAABVywAAVgoAAFYMAABWDwAAVhEAAFYTAABWFAAAVhUAAFYWAABWFwAAVhkAAFYbAABWHAAAVh0AAFYfAABWIAAAViEAAFZgAABWYgAAVmQAAFZmAABWaAAAVmkAAFZqAABWawAAVmwAAFZuAABWcAAAVnEAAFZyAABWdAAAVnUAAFa0AABWtgAAVrgAAFa6AABWvAAAVr0AAFa+AABWvwAAVsAAAFbCAABWxAAAVsUAAFbGAABWyAAAVskAAFcIAABXCgAAVw0AAFcPAABXEQAAVxIAAFcTAABXFAAAVxUAAFcXAABXGQAAVxoAAFcbAABXHQAAVx4AAFchAABXYAAAV2IAAFdkAABXZgAAV2gAAFdpAABXagAAV2sAAFdsAABXbgAAV3AAAFdxAABXcgAAV3QAAFd1AABXtAAAV7YAAFe4AABXugAAV7wAAFe9AABXvgAAV78AAFfAAABXwgAAV8QAAFfFAABXxgAAV8gAAFfJAABYCAAAWAoAAFgMAABYDgAAWBAAAFgRAABYEgAAWBMAAFgUAABYFgAAWBgAAFgZAABYGgAAWBwAAFgdAABYKAAAWDEAAFgyAABYNAAAWD0AAFhIAABYVwAAWGIAAFhwAABYhQAAWJkAAFiwAABYwgAAWQUAAFkpAABZTQAAWXAAAFmXAABZtwAAWd4AAFoFAABaJQAAWkkAAFptAABabwAAWnIAAFp0AABadgAAWngAAFp7AABafgAAWoAAAFqCAABahQAAWocAAFqJAABajAAAWo8AAFqQAABalQAAWqIAAFqlAABapwAAWqoAAFqtAABarwAAWtQAAFr4AABbHwAAW0MAAFtGAABbSAAAW0oAAFtMAABbTgAAW1AAAFtRAABbVAAAW2EAAFt0AABbdgAAW3gAAFt6AABbfAAAW34AAFuAAABbggAAW4QAAFuGAABbmQAAW5wAAFufAABbogAAW6UAAFuoAABbqwAAW64AAFuxAABbtAAAW7YAAFv1AABb9wAAW/oAAFv8AABb/wAAXAAAAFwBAABcAgAAXAMAAFwFAABcBwAAXAgAAFwJAABcCwAAXAwAAFwVAABcFgAAXBgAAFxXAABcWQAAXFsAAFxdAABcYAAAXGEAAFxiAABcYwAAXGQAAFxmAABcaAAAXGkAAFxqAABcbAAAXG0AAFysAABcrgAAXLEAAFyzAABctgAAXLcAAFy4AABcuQAAXLoAAFy8AABcvgAAXL8AAFzAAABcwgAAXMMAAFzMAABczQAAXM8AAF0OAABdEAAAXRIAAF0UAABdFwAAXRgAAF0ZAABdGgAAXRsAAF0dAABdHwAAXSAAAF0hAABdIwAAXSQAAF1jAABdZQAAXWgAAF1qAABdbQAAXW4AAF1vAABdcAAAXXEAAF1zAABddQAAXXYAAF13AABdeQAAXXoAAF2DAABdhAAAXYYAAF3FAABdxwAAXckAAF3LAABdzgAAXc8AAF3QAABd0QAAXdIAAF3UAABd1gAAXdcAAF3YAABd2gAAXdsAAF4aAABeHAAAXh8AAF4hAABeJAAAXiUAAF4mAABeJwAAXigAAF4qAABeLAAAXi0AAF4uAABeMAAAXjEAAF4+AABePwAAXkAAAF5CAABegQAAXoMAAF6FAABehwAAXooAAF6LAABejAAAXo0AAF6OAABekAAAXpIAAF6TAABelAAAXpYAAF6XAABe1gAAXtgAAF7bAABe3QAAXuAAAF7hAABe4gAAXuMAAF7kAABe5gAAXugAAF7pAABe6gAAXuwAAF7tAABfAAAAXw0AAF84AABfOwAAXz4AAF9BAABfRAAAX0cAAF9KAABfTQAAX1AAAF9TAABfVgAAX1kAAF9cAABfXwAAX2IAAF9lAABfaAAAX2sAAF9uAABfcAAAX3MAAF+eAABfoQAAX6QAAF+nAABfqgAAX60AAF+wAABfswAAX7YAAF+5AABfvAAAX78AAF/CAABfxQAAX8gAAF/LAABfzgAAX9EAAF/UAABf1wAAX9oAAF/cAABf5QAAX/IAAF/8AABgAgAAYAsAAGAWAABgIwAAYC8AAGA6AABgRAAAYE8AAGBYAABgYgAAYGwAAGB1AABgfgAAYIkAAGCSAABgmQAAYOQAAGEHAABhJwAAYUcAAGFJAABhSwAAYU0AAGFPAABhUgAAYVMAAGFUAABhVwAAYVgAAGFaAABhWwAAYV0AAGFgAABhYQAAYWIAAGFlAABhZgAAYWsAAGF4AABhfQAAYX8AAGGBAABhhgAAYYkAAGGMAABhjgAAYbMAAGHXAABh/gAAYiIAAGIlAABiJwAAYikAAGIrAABiLQAAYi8AAGIwAABiMwAAYkAAAGJRAABiUwAAYlUAAGJXAABiWQAAYlsAAGJdAABiXwAAYmEAAGJyAABidQAAYngAAGJ7AABifgAAYoEAAGKEAABihwAAYooAAGKMAABiywAAYs0AAGLPAABi0QAAYtQAAGLVAABi1gAAYtcAAGLYAABi2gAAYtwAAGLdAABi3gAAYuAAAGLhAABjIAAAYyIAAGMkAABjJgAAYykAAGMqAABjKwAAYywAAGMtAABjLwAAYzEAAGMyAABjMwAAYzUAAGM2AABjdQAAY3cAAGN6AABjfAAAY38AAGOAAABjgQAAY4IAAGODAABjhQAAY4cAAGOIAABjiQAAY4sAAGOMAABjmQAAY5oAAGObAABjnQAAY9wAAGPeAABj4AAAY+IAAGPlAABj5gAAY+cAAGPoAABj6QAAY+sAAGPtAABj7gAAY+8AAGPxAABj8gAAZDEAAGQzAABkNQAAZDcAAGQ6AABkOwAAZDwAAGQ9AABkPgAAZEAAAGRCAABkQwAAZEQAAGRGAABkRwAAZIYAAGSIAABkigAAZIwAAGSPAABkkAAAZJEAAGSSAABkkwAAZJUAAGSXAABkmAAAZJkAAGSbAABknAAAZNsAAGTdAABk3wAAZOEAAGTkAABk5QAAZOYAAGTnAABk6AAAZOoAAGTsAABk7QAAZO4AAGTwAABk8QAAZTAAAGUyAABlNAAAZTYAAGU5AABlOgAAZTsAAGU8AABlPQAAZT8AAGVBAABlQgAAZUMAAGVFAABlRgAAZWsAAGWPAABltgAAZdoAAGXdAABl3wAAZeEAAGXjAABl5QAAZecAAGXoAABl6wAAZfgAAGYHAABmCQAAZgsAAGYNAABmDwAAZhEAAGYTAABmFQAAZiQAAGYnAABmKgAAZi0AAGYwAABmMwAAZjYAAGY5AABmOwAAZnoAAGZ8AABmfwAAZoEAAGaEAABmhQAAZoYAAGaHAABmiAAAZooAAGaMAABmjQAAZo4AAGaQAABmkQAAZtAAAGbSAABm1AAAZtYAAGbZAABm2gAAZtsAAGbcAABm3QAAZt8AAGbhAABm4gAAZuMAAGblAABm5gAAZyUAAGcnAABnKQAAZysAAGcuAABnLwAAZzAAAGcxAABnMgAAZzQAAGc2AABnNwAAZzgAAGc6AABnOwAAZ3oAAGd8AABnfwAAZ4EAAGeEAABnhQAAZ4YAAGeHAABniAAAZ4oAAGeMAABnjQAAZ44AAGeQAABnkQAAZ9AAAGfSAABn1AAAZ9YAAGfZAABn2gAAZ9sAAGfcAABn3QAAZ98AAGfhAABn4gAAZ+MAAGflAABn5gAAaCUAAGgnAABoKQAAaCsAAGguAABoLwAAaDAAAGgxAABoMgAAaDQAAGg2AABoNwAAaDgAAGg6AABoOwAAaHoAAGh8AABofgAAaIAAAGiDAABohAAAaIUAAGiGAABohwAAaIkAAGiLAABojAAAaI0AAGiPAABokAAAaNsAAGj+AABpHgAAaT4AAGlAAABpQgAAaUQAAGlGAABpSQAAaUoAAGlLAABpTgAAaU8AAGlRAABpUgAAaVQAAGlXAABpWAAAaVkAAGlcAABpXQAAaWIAAGlvAABpdAAAaXYAAGl4AABpfQAAaYAAAGmDAABphQAAaaoAAGnOAABp9QAAahkAAGocAABqHgAAaiAAAGoiAABqJAAAaiYAAGonAABqKgAAajcAAGpIAABqSgAAakwAAGpOAABqUAAAalIAAGpUAABqVgAAalgAAGppAABqbAAAam8AAGpyAABqdQAAangAAGp7AABqfgAAaoEAAGqDAABqwgAAasQAAGrGAABqyAAAassAAGrMAABqzQAAas4AAGrPAABq0QAAatMAAGrUAABq1QAAatcAAGrYAABrFwAAaxkAAGsbAABrHQAAayAAAGshAABrIgAAayMAAGskAABrJgAAaygAAGspAABrKgAAaywAAGstAABrbAAAa24AAGtxAABrcwAAa3YAAGt3AABreAAAa3kAAGt6AABrfAAAa34AAGt/AABrgAAAa4IAAGuDAABrkAAAa5EAAGuSAABrlAAAa9MAAGvVAABr1wAAa9kAAGvcAABr3QAAa94AAGvfAABr4AAAa+IAAGvkAABr5QAAa+YAAGvoAABr6QAAbCgAAGwqAABsLAAAbC4AAGwxAABsMgAAbDMAAGw0AABsNQAAbDcAAGw5AABsOgAAbDsAAGw9AABsPgAAbH0AAGx/AABsgQAAbIMAAGyGAABshwAAbIgAAGyJAABsigAAbIwAAGyOAABsjwAAbJAAAGySAABskwAAbNIAAGzUAABs1gAAbNgAAGzbAABs3AAAbN0AAGzeAABs3wAAbOEAAGzjAABs5AAAbOUAAGznAABs6AAAbScAAG0pAABtKwAAbS0AAG0wAABtMQAAbTIAAG0zAABtNAAAbTYAAG04AABtOQAAbToAAG08AABtPQAAbWIAAG2GAABtrQAAbdEAAG3UAABt1gAAbdgAAG3aAABt3AAAbd4AAG3fAABt4gAAbe8AAG3+AABuAAAAbgIAAG4EAABuBgAAbggAAG4KAABuDAAAbhsAAG4eAABuIQAAbiQAAG4nAABuKgAAbi0AAG4wAABuMgAAbnEAAG5zAABudQAAbncAAG56AABuewAAbnwAAG59AABufgAAboAAAG6CAABugwAAboQAAG6GAABuhwAAbsYAAG7IAABuygAAbswAAG7PAABu0AAAbtEAAG7SAABu0wAAbtUAAG7XAABu2AAAbtkAAG7bAABu3AAAbxsAAG8dAABvHwAAbyEAAG8kAABvJQAAbyYAAG8nAABvKAAAbyoAAG8sAABvLQAAby4AAG8wAABvMQAAb3AAAG9yAABvdAAAb3YAAG95AABvegAAb3sAAG98AABvfQAAb38AAG+BAABvggAAb4MAAG+FAABvhgAAb8UAAG/HAABvyQAAb8sAAG/OAABvzwAAb9AAAG/RAABv0gAAb9QAAG/WAABv1wAAb9gAAG/aAABv2wAAcBoAAHAcAABwHgAAcCAAAHAjAABwJAAAcCUAAHAmAABwJwAAcCkAAHArAABwLAAAcC0AAHAvAABwMAAAcG8AAHBxAABwcwAAcHUAAHB4AABweQAAcHoAAHB7AABwfAAAcH4AAHCAAABwgQAAcIIAAHCEAABwhQAAcNAAAHDzAABxEwAAcTMAAHE1AABxNwAAcTkAAHE7AABxPgAAcT8AAHFAAABxQwAAcUQAAHFGAABxRwAAcUkAAHFMAABxTQAAcU4AAHFRAABxUgAAcVsAAHFoAABxbQAAcW8AAHFxAABxdgAAcXkAAHF8AABxfgAAcaMAAHHHAABx7gAAchIAAHIVAAByFwAAchkAAHIbAAByHQAAch8AAHIgAAByIwAAcjAAAHJBAAByQwAAckUAAHJHAABySQAAcksAAHJNAAByTwAAclEAAHJiAAByZQAAcmgAAHJrAABybgAAcnEAAHJ0AABydwAAcnoAAHJ8AAByuwAAcr0AAHK/AABywQAAcsQAAHLFAAByxgAAcscAAHLIAAByygAAcswAAHLNAAByzgAActAAAHLRAABzEAAAcxIAAHMUAABzFgAAcxkAAHMaAABzGwAAcxwAAHMdAABzHwAAcyEAAHMiAABzIwAAcyUAAHMmAABzZQAAc2cAAHNqAABzbAAAc28AAHNwAABzcQAAc3IAAHNzAABzdQAAc3cAAHN4AABzeQAAc3sAAHN8AABziQAAc4oAAHOLAABzjQAAc8wAAHPOAABz0AAAc9IAAHPVAABz1gAAc9cAAHPYAABz2QAAc9sAAHPdAABz3gAAc98AAHPhAABz4gAAdCEAAHQjAAB0JQAAdCcAAHQqAAB0KwAAdCwAAHQtAAB0LgAAdDAAAHQyAAB0MwAAdDQAAHQ2AAB0NwAAdHYAAHR4AAB0egAAdHwAAHR/AAB0gAAAdIEAAHSCAAB0gwAAdIUAAHSHAAB0iAAAdIkAAHSLAAB0jAAAdMsAAHTNAAB0zwAAdNEAAHTUAAB01QAAdNYAAHTXAAB02AAAdNoAAHTcAAB03QAAdN4AAHTgAAB04QAAdSAAAHUiAAB1JAAAdSYAAHUpAAB1KgAAdSsAAHUsAAB1LQAAdS8AAHUxAAB1MgAAdTMAAHU1AAB1NgAAdVsAAHV/AAB1pgAAdcoAAHXNAAB1zwAAddEAAHXTAAB11QAAddcAAHXYAAB12wAAdegAAHX3AAB1+QAAdfsAAHX9AAB1/wAAdgEAAHYDAAB2BQAAdhQAAHYXAAB2GgAAdh0AAHYgAAB2IwAAdiYAAHYpAAB2KwAAdmoAAHZsAAB2bgAAdnAAAHZzAAB2dAAAdnUAAHZ2AAB2dwAAdnkAAHZ7AAB2fAAAdn0AAHZ/AAB2gAAAdr8AAHbBAAB2wwAAdsUAAHbIAAB2yQAAdsoAAHbLAAB2zAAAds4AAHbQAAB20QAAdtIAAHbUAAB21QAAdxQAAHcWAAB3GAAAdxoAAHcdAAB3HgAAdx8AAHcgAAB3IQAAdyMAAHclAAB3JgAAdycAAHcpAAB3KgAAd2kAAHdrAAB3bgAAd3AAAHdzAAB3dAAAd3UAAHd2AAB3dwAAd3kAAHd7AAB3fAAAd30AAHd/AAB3gAAAd4MAAHfCAAB3xAAAd8YAAHfIAAB3ywAAd8wAAHfNAAB3zgAAd88AAHfRAAB30wAAd9QAAHfVAAB31wAAd9gAAHgXAAB4GQAAeBsAAHgdAAB4IAAAeCEAAHgiAAB4IwAAeCQAAHgmAAB4KAAAeCkAAHgqAAB4LAAAeC0AAHhsAAB4bgAAeHAAAHhyAAB4dQAAeHYAAHh3AAB4eAAAeHkAAHh7AAB4fQAAeH4AAHh/AAB4gQAAeIIAAHjNAAB48AAAeRAAAHkwAAB5MgAAeTQAAHk2AAB5OAAAeTsAAHk8AAB5PQAAeUAAAHlBAAB5QwAAeUQAAHlGAAB5SQAAeUoAAHlLAAB5TgAAeU8AAHlUAAB5YQAAeWYAAHloAAB5agAAeW8AAHlyAAB5dQAAeXcAAHmcAAB5wAAAeecAAHoLAAB6DgAAehAAAHoSAAB6FAAAehYAAHoYAAB6GQAAehwAAHopAAB6OgAAejwAAHo+AAB6QAAAekIAAHpEAAB6RgAAekgAAHpKAAB6WwAAel4AAHphAAB6ZAAAemcAAHpqAAB6bQAAenAAAHpzAAB6dQAAerQAAHq2AAB6uAAAeroAAHq9AAB6vgAAer8AAHrAAAB6wQAAesMAAHrFAAB6xgAAescAAHrJAAB6ygAAewkAAHsLAAB7DQAAew8AAHsSAAB7EwAAexQAAHsVAAB7FgAAexgAAHsaAAB7GwAAexwAAHseAAB7HwAAe14AAHtgAAB7YwAAe2UAAHtoAAB7aQAAe2oAAHtrAAB7bAAAe24AAHtwAAB7cQAAe3IAAHt0AAB7dQAAe4IAAHuDAAB7hAAAe4YAAHvFAAB7xwAAe8kAAHvLAAB7zgAAe88AAHvQAAB70QAAe9IAAHvUAAB71gAAe9cAAHvYAAB72gAAe9sAAHwaAAB8HAAAfB4AAHwgAAB8IwAAfCQAAHwlAAB8JgAAfCcAAHwpAAB8KwAAfCwAAHwtAAB8LwAAfDAAAHxvAAB8cQAAfHMAAHx1AAB8eAAAfHkAAHx6AAB8ewAAfHwAAHx+AAB8gAAAfIEAAHyCAAB8hAAAfIUAAHzEAAB8xgAAfMgAAHzKAAB8zQAAfM4AAHzPAAB80AAAfNEAAHzTAAB81QAAfNYAAHzXAAB82QAAfNoAAH0ZAAB9GwAAfR0AAH0fAAB9IgAAfSMAAH0kAAB9JQAAfSYAAH0oAAB9KgAAfSsAAH0sAAB9LgAAfS8AAH1UAAB9eAAAfZ8AAH3DAAB9xgAAfcgAAH3KAAB9zAAAfc4AAH3QAAB90QAAfdQAAH3hAAB98AAAffIAAH30AAB99gAAffgAAH36AAB9/AAAff4AAH4NAAB+EAAAfhMAAH4WAAB+GQAAfhwAAH4fAAB+IgAAfiQAAH5jAAB+ZQAAfmgAAH5qAAB+bQAAfm4AAH5vAAB+cAAAfnEAAH5zAAB+dQAAfnYAAH53AAB+eQAAfnoAAH65AAB+uwAAfr0AAH6/AAB+wgAAfsMAAH7EAAB+xQAAfsYAAH7IAAB+ygAAfssAAH7MAAB+zgAAfs8AAH8OAAB/EAAAfxIAAH8UAAB/FwAAfxgAAH8ZAAB/GgAAfxsAAH8dAAB/HwAAfyAAAH8hAAB/IwAAfyQAAH9jAAB/ZQAAf2gAAH9qAAB/bQAAf24AAH9vAAB/cAAAf3EAAH9zAAB/dQAAf3YAAH93AAB/eQAAf3oAAH+5AAB/uwAAf70AAH+/AAB/wgAAf8MAAH/EAAB/xQAAf8YAAH/IAAB/ygAAf8sAAH/MAAB/zgAAf88AAIAOAACAEAAAgBIAAIAUAACAFwAAgBgAAIAZAACAGgAAgBsAAIAdAACAHwAAgCAAAIAhAACAIwAAgCQAAIBjAACAZQAAgGcAAIBpAACAbAAAgG0AAIBuAACAbwAAgHAAAIByAACAdAAAgHUAAIB2AACAeAAAgHkAAIDEAACA5wAAgQcAAIEnAACBKQAAgSsAAIEtAACBLwAAgTIAAIEzAACBNAAAgTcAAIE4AACBOgAAgTsAAIE9AACBQAAAgUEAAIFCAACBRQAAgUYAAIFLAACBWAAAgV0AAIFfAACBYQAAgWYAAIFpAACBbAAAgW4AAIGTAACBtwAAgd4AAIICAACCBQAAggcAAIIJAACCCwAAgg0AAIIPAACCEAAAghMAAIIgAACCMQAAgjMAAII1AACCNwAAgjkAAII7AACCPQAAgj8AAIJBAACCUgAAglUAAIJYAACCWwAAgl4AAIJhAACCZAAAgmcAAIJqAACCbAAAgqsAAIKtAACCrwAAgrEAAIK0AACCtQAAgrYAAIK3AACCuAAAgroAAIK8AACCvQAAgr4AAILAAACCwQAAgwAAAIMCAACDBAAAgwYAAIMJAACDCgAAgwsAAIMMAACDDQAAgw8AAIMRAACDEgAAgxMAAIMVAACDFgAAg1UAAINXAACDWgAAg1wAAINfAACDYAAAg2EAAINiAACDYwAAg2UAAINnAACDaAAAg2kAAINrAACDbAAAg3kAAIN6AACDewAAg30AAIO8AACDvgAAg8AAAIPCAACDxQAAg8YAAIPHAACDyAAAg8kAAIPLAACDzQAAg84AAIPPAACD0QAAg9IAAIQRAACEEwAAhBUAAIQXAACEGgAAhBsAAIQcAACEHQAAhB4AAIQgAACEIgAAhCMAAIQkAACEJgAAhCcAAIRmAACEaAAAhGoAAIRsAACEbwAAhHAAAIRxAACEcgAAhHMAAIR1AACEdwAAhHgAAIR5AACEewAAhHwAAIS7AACEvQAAhL8AAITBAACExAAAhMUAAITGAACExwAAhMgAAITKAACEzAAAhM0AAITOAACE0AAAhNEAAIUQAACFEgAAhRQAAIUWAACFGQAAhRoAAIUbAACFHAAAhR0AAIUfAACFIQAAhSIAAIUjAACFJQAAhSYAAIVLAACFbwAAhZYAAIW6AACFvQAAhb8AAIXBAACFwwAAhcUAAIXHAACFyAAAhcsAAIXYAACF5wAAhekAAIXrAACF7QAAhe8AAIXxAACF8wAAhfUAAIYEAACGBwAAhgoAAIYNAACGEAAAhhMAAIYWAACGGQAAhhsAAIZaAACGXAAAhl8AAIZhAACGZAAAhmUAAIZmAACGZwAAhmgAAIZqAACGbAAAhm0AAIZuAACGcAAAhnEAAIawAACGsgAAhrQAAIa2AACGuQAAhroAAIa7AACGvAAAhr0AAIa/AACGwQAAhsIAAIbDAACGxQAAhsYAAIcFAACHBwAAhwkAAIcLAACHDgAAhw8AAIcQAACHEQAAhxIAAIcUAACHFgAAhxcAAIcYAACHGgAAhxsAAIdaAACHXAAAh18AAIdhAACHZAAAh2UAAIdmAACHZwAAh2gAAIdqAACHbAAAh20AAIduAACHcAAAh3EAAIewAACHsgAAh7QAAIe2AACHuQAAh7oAAIe7AACHvAAAh70AAIe/AACHwQAAh8IAAIfDAACHxQAAh8YAAIgFAACIBwAAiAkAAIgLAACIDgAAiA8AAIgQAACIEQAAiBIAAIgUAACIFgAAiBcAAIgYAACIGgAAiBsAAIhaAACIXAAAiF4AAIhgAACIYwAAiGQAAIhlAACIZgAAiGcAAIhpAACIawAAiGwAAIhtAACIbwAAiHAAAIi7AACI3gAAiP4AAIkeAACJIAAAiSIAAIkkAACJJgAAiSkAAIkqAACJKwAAiS4AAIkvAACJMQAAiTIAAIk0AACJNwAAiTgAAIk5AACJPAAAiT0AAIlGAACJUwAAiVgAAIlaAACJXAAAiWEAAIlkAACJZwAAiWkAAImOAACJsgAAidkAAIn9AACKAAAAigIAAIoEAACKBgAAiggAAIoKAACKCwAAig4AAIobAACKLAAAii4AAIowAACKMgAAijQAAIo2AACKOAAAijoAAIo8AACKTQAAilAAAIpTAACKVgAAilkAAIpcAACKXwAAimIAAIplAACKZwAAiqYAAIqoAACKqgAAiqwAAIqvAACKsAAAirEAAIqyAACKswAAirUAAIq3AACKuAAAirkAAIq7AACKvAAAivsAAIr9AACK/wAAiwEAAIsEAACLBQAAiwYAAIsHAACLCAAAiwoAAIsMAACLDQAAiw4AAIsQAACLEQAAi1AAAItSAACLVQAAi1cAAItaAACLWwAAi1wAAItdAACLXgAAi2AAAItiAACLYwAAi2QAAItmAACLZwAAi3QAAIt1AACLdgAAi3gAAIu3AACLuQAAi7sAAIu9AACLwAAAi8EAAIvCAACLwwAAi8QAAIvGAACLyAAAi8kAAIvKAACLzAAAi80AAIwMAACMDgAAjBAAAIwSAACMFQAAjBYAAIwXAACMGAAAjBkAAIwbAACMHQAAjB4AAIwfAACMIQAAjCIAAIxhAACMYwAAjGUAAIxnAACMagAAjGsAAIxsAACMbQAAjG4AAIxwAACMcgAAjHMAAIx0AACMdgAAjHcAAIy2AACMuAAAjLoAAIy8AACMvwAAjMAAAIzBAACMwgAAjMMAAIzFAACMxwAAjMgAAIzJAACMywAAjMwAAI0LAACNDQAAjQ8AAI0RAACNFAAAjRUAAI0WAACNFwAAjRgAAI0aAACNHAAAjR0AAI0eAACNIAAAjSEAAI1GAACNagAAjZEAAI21AACNuAAAjboAAI28AACNvgAAjcAAAI3CAACNwwAAjcYAAI3TAACN4gAAjeQAAI3mAACN6AAAjeoAAI3sAACN7gAAjfAAAI3/AACOAgAAjgUAAI4IAACOCwAAjg4AAI4RAACOFAAAjhYAAI5VAACOVwAAjloAAI5cAACOXwAAjmAAAI5hAACOYgAAjmMAAI5lAACOZwAAjmgAAI5pAACOawAAjmwAAI5wAACOrwAAjrEAAI6zAACOtQAAjrgAAI65AACOugAAjrsAAI68AACOvgAAjsAAAI7BAACOwgAAjsQAAI7FAACPBAAAjwYAAI8IAACPCgAAjw0AAI8OAACPDwAAjxAAAI8RAACPEwAAjxUAAI8WAACPFwAAjxkAAI8aAACPWQAAj1sAAI9eAACPYAAAj2MAAI9kAACPZQAAj2YAAI9nAACPaQAAj2sAAI9sAACPbQAAj28AAI9wAACPrwAAj7EAAI+zAACPtQAAj7gAAI+5AACPugAAj7sAAI+8AACPvgAAj8AAAI/BAACPwgAAj8QAAI/FAACQBAAAkAYAAJAIAACQCgAAkA0AAJAOAACQDwAAkBAAAJARAACQEwAAkBUAAJAWAACQFwAAkBkAAJAaAACQWQAAkFsAAJBdAACQXwAAkGIAAJBjAACQZAAAkGUAAJBmAACQaAAAkGoAAJBrAACQbAAAkG4AAJBvAACQugAAkN0AAJD9AACRHQAAkR8AAJEhAACRIwAAkSUAAJEoAACRKQAAkSoAAJEtAACRLgAAkTAAAJExAACRMwAAkTYAAJE3AACROAAAkTsAAJE8AACRRQAAkVIAAJFXAACRWQAAkVsAAJFgAACRYwAAkWYAAJFoAACRjQAAkbEAAJHYAACR/AAAkf8AAJIBAACSAwAAkgUAAJIHAACSCQAAkgoAAJINAACSGgAAkisAAJItAACSLwAAkjEAAJIzAACSNQAAkjcAAJI5AACSOwAAkkwAAJJPAACSUgAAklUAAJJYAACSWwAAkl4AAJJhAACSZAAAkmYAAJKlAACSpwAAkqkAAJKrAACSrgAAkq8AAJKwAACSsQAAkrIAAJK0AACStgAAkrcAAJK4AACSugAAkrsAAJL6AACS/AAAkv4AAJMAAACTAwAAkwQAAJMFAACTBgAAkwcAAJMJAACTCwAAkwwAAJMNAACTDwAAkxAAAJNPAACTUQAAk1QAAJNWAACTWQAAk1oAAJNbAACTXAAAk10AAJNfAACTYQAAk2IAAJNjAACTZQAAk2YAAJNzAACTdAAAk3UAAJN3AACTtgAAk7gAAJO6AACTvAAAk78AAJPAAACTwQAAk8IAAJPDAACTxQAAk8cAAJPIAACTyQAAk8sAAJPMAACUCwAAlA0AAJQPAACUEQAAlBQAAJQVAACUFgAAlBcAAJQYAACUGgAAlBwAAJQdAACUHgAAlCAAAJQhAACUYAAAlGIAAJRkAACUZgAAlGkAAJRqAACUawAAlGwAAJRtAACUbwAAlHEAAJRyAACUcwAAlHUAAJR2AACUtQAAlLcAAJS5AACUuwAAlL4AAJS/AACUwAAAlMEAAJTCAACUxAAAlMYAAJTHAACUyAAAlMoAAJTLAACVCgAAlQwAAJUOAACVEAAAlRMAAJUUAACVFQAAlRYAAJUXAACVGQAAlRsAAJUcAACVHQAAlR8AAJUgAACVRQAAlWkAAJWQAACVtAAAlbcAAJW5AACVuwAAlb0AAJW/AACVwQAAlcIAAJXFAACV0gAAleEAAJXjAACV5QAAlecAAJXpAACV6wAAle0AAJXvAACV/gAAlgEAAJYEAACWBwAAlgoAAJYNAACWEAAAlhMAAJYVAACWVAAAllYAAJZYAACWWgAAll0AAJZeAACWXwAAlmAAAJZhAACWYwAAlmUAAJZmAACWZwAAlmkAAJZqAACWqQAAlqsAAJatAACWrwAAlrIAAJazAACWtAAAlrUAAJa2AACWuAAAlroAAJa7AACWvAAAlr4AAJa/AACW/gAAlwAAAJcCAACXBAAAlwcAAJcIAACXCQAAlwoAAJcLAACXDQAAlw8AAJcQAACXEQAAlxMAAJcUAACXUwAAl1UAAJdXAACXWQAAl1wAAJddAACXXgAAl18AAJdgAACXYgAAl2QAAJdlAACXZgAAl2gAAJdpAACXqAAAl6oAAJesAACXrgAAl7EAAJeyAACXswAAl7QAAJe1AACXtwAAl7kAAJe6AACXuwAAl70AAJe+AACX/QAAl/8AAJgBAACYAwAAmAYAAJgHAACYCAAAmAkAAJgKAACYDAAAmA4AAJgPAACYEAAAmBIAAJgTAACYUgAAmFQAAJhWAACYWAAAmFsAAJhcAACYXQAAmF4AAJhfAACYYQAAmGMAAJhkAACYZQAAmGcAAJhoAACYswAAmNYAAJj2AACZFgAAmRgAAJkaAACZHAAAmR4AAJkhAACZIgAAmSMAAJkmAACZJwAAmSkAAJkqAACZLAAAmS8AAJkwAACZMQAAmTQAAJk1AACZOgAAmUcAAJlMAACZTgAAmVAAAJlVAACZWAAAmVsAAJldAACZggAAmaYAAJnNAACZ8QAAmfQAAJn2AACZ+AAAmfoAAJn8AACZ/gAAmf8AAJoCAACaDwAAmiAAAJoiAACaJAAAmiYAAJooAACaKgAAmiwAAJouAACaMAAAmkEAAJpEAACaRwAAmkoAAJpNAACaUAAAmlMAAJpWAACaWQAAmlsAAJqaAACanAAAmp4AAJqgAACaowAAmqQAAJqlAACapgAAmqcAAJqpAACaqwAAmqwAAJqtAACarwAAmrAAAJrvAACa8QAAmvMAAJr1AACa+AAAmvkAAJr6AACa+wAAmvwAAJr+AACbAAAAmwEAAJsCAACbBAAAmwUAAJtEAACbRgAAm0kAAJtLAACbTgAAm08AAJtQAACbUQAAm1IAAJtUAACbVgAAm1cAAJtYAACbWgAAm1sAAJtoAACbaQAAm2oAAJtsAACbqwAAm60AAJuvAACbsQAAm7QAAJu1AACbtgAAm7cAAJu4AACbugAAm7wAAJu9AACbvgAAm8AAAJvBAACcAAAAnAIAAJwEAACcBgAAnAkAAJwKAACcCwAAnAwAAJwNAACcDwAAnBEAAJwSAACcEwAAnBUAAJwWAACcVQAAnFcAAJxZAACcWwAAnF4AAJxfAACcYAAAnGEAAJxiAACcZAAAnGYAAJxnAACcaAAAnGoAAJxrAACcqgAAnKwAAJyuAACcsAAAnLMAAJy0AACctQAAnLYAAJy3AACcuQAAnLsAAJy8AACcvQAAnL8AAJzAAACc/wAAnQEAAJ0DAACdBQAAnQgAAJ0JAACdCgAAnQsAAJ0MAACdDgAAnRAAAJ0RAACdEgAAnRQAAJ0VAACdOgAAnV4AAJ2FAACdqQAAnawAAJ2uAACdsAAAnbIAAJ20AACdtgAAnbcAAJ26AACdxwAAndYAAJ3YAACd2gAAndwAAJ3eAACd4AAAneIAAJ3kAACd8wAAnfYAAJ35AACd/AAAnf8AAJ4CAACeBQAAnggAAJ4KAACeSQAAnksAAJ5NAACeTwAAnlIAAJ5TAACeVAAAnlUAAJ5WAACeWAAAnloAAJ5bAACeXAAAnl4AAJ5fAACengAAnqAAAJ6iAACepAAAnqcAAJ6oAACeqQAAnqoAAJ6rAACerQAAnq8AAJ6wAACesQAAnrMAAJ60AACe8wAAnvUAAJ73AACe+QAAnvwAAJ79AACe/gAAnv8AAJ8AAACfAgAAnwQAAJ8FAACfBgAAnwgAAJ8JAACfSAAAn0oAAJ9MAACfTgAAn1EAAJ9SAACfUwAAn1QAAJ9VAACfVwAAn1kAAJ9aAACfWwAAn10AAJ9eAACfnQAAn58AAJ+hAACfowAAn6YAAJ+nAACfqAAAn6kAAJ+qAACfrAAAn64AAJ+vAACfsAAAn7IAAJ+zAACf8gAAn/QAAJ/2AACf+AAAn/sAAJ/8AACf/QAAn/4AAJ//AACgAQAAoAMAAKAEAACgBQAAoAcAAKAIAACgRwAAoEkAAKBLAACgTQAAoFAAAKBRAACgUgAAoFMAAKBUAACgVgAAoFgAAKBZAACgWgAAoFwAAKBdAACgqAAAoMsAAKDrAAChCwAAoQ0AAKEPAAChEQAAoRMAAKEWAAChFwAAoRgAAKEbAAChHAAAoR4AAKEfAAChIQAAoSQAAKElAAChJgAAoSkAAKEqAAChLwAAoTwAAKFBAAChQwAAoUUAAKFKAAChTQAAoVAAAKFSAAChdwAAoZsAAKHCAACh5gAAoekAAKHrAACh7QAAoe8AAKHxAACh8wAAofQAAKH3AACiBAAAohUAAKIXAACiGQAAohsAAKIdAACiHwAAoiEAAKIjAACiJQAAojYAAKI5AACiPAAAoj8AAKJCAACiRQAAokgAAKJLAACiTgAAolAAAKKPAACikQAAopMAAKKVAACimAAAopkAAKKaAACimwAAopwAAKKeAACioAAAoqEAAKKiAACipAAAoqUAAKLkAACi5gAAougAAKLqAACi7QAAou4AAKLvAACi8AAAovEAAKLzAACi9QAAovYAAKL3AACi+QAAovoAAKM5AACjOwAAoz4AAKNAAACjQwAAo0QAAKNFAACjRgAAo0cAAKNJAACjSwAAo0wAAKNNAACjTwAAo1AAAKNdAACjXgAAo18AAKNhAACjoAAAo6IAAKOkAACjpgAAo6kAAKOqAACjqwAAo6wAAKOtAACjrwAAo7EAAKOyAACjswAAo7UAAKO2AACj9QAAo/cAAKP5AACj+wAAo/4AAKP/AACkAAAApAEAAKQCAACkBAAApAYAAKQHAACkCAAApAoAAKQLAACkSgAApEwAAKROAACkUAAApFMAAKRUAACkVQAApFYAAKRXAACkWQAApFsAAKRcAACkXQAApF8AAKRgAACknwAApKEAAKSjAACkpQAApKgAAKSpAACkqgAApKsAAKSsAACkrgAApLAAAKSxAACksgAApLQAAKS1AACk9AAApPYAAKT4AACk+gAApP0AAKT+AACk/wAApQAAAKUBAAClAwAApQUAAKUGAAClBwAApQkAAKUKAAClLwAApVMAAKV6AAClngAApaEAAKWjAAClpQAApacAAKWpAAClqwAApawAAKWvAAClvAAApcsAAKXNAAClzwAApdEAAKXTAACl1QAApdcAAKXZAACl6AAApesAAKXuAACl8QAApfQAAKX3AACl+gAApf0AAKX/AACmPgAApkAAAKZCAACmRAAApkcAAKZIAACmSQAApkoAAKZLAACmTQAApk8AAKZQAACmUQAAplMAAKZUAACmkwAAppUAAKaXAACmmQAAppwAAKadAACmngAApp8AAKagAACmogAApqQAAKalAACmpgAApqgAAKapAACm6AAApuoAAKbsAACm7gAApvEAAKbyAACm8wAApvQAAKb1AACm9wAApvkAAKb6AACm+wAApv0AAKb+AACnPQAApz8AAKdCAACnRAAAp0cAAKdIAACnSQAAp0oAAKdLAACnTQAAp08AAKdQAACnUQAAp1MAAKdUAACnkwAAp5UAAKeXAACnmQAAp5wAAKedAACnngAAp58AAKegAACnogAAp6QAAKelAACnpgAAp6gAAKepAACn6AAAp+oAAKfsAACn7gAAp/EAAKfyAACn8wAAp/QAAKf1AACn9wAAp/kAAKf6AACn+wAAp/0AAKf+AACoPQAAqD8AAKhBAACoQwAAqEYAAKhHAACoSAAAqEkAAKhKAACoTAAAqE4AAKhPAACoUAAAqFIAAKhTAACongAAqMEAAKjhAACpAQAAqQMAAKkFAACpBwAAqQkAAKkMAACpDQAAqQ4AAKkRAACpEgAAqRQAAKkVAACpFwAAqRoAAKkbAACpHAAAqR8AAKkgAACpJQAAqTIAAKk3AACpOQAAqTsAAKlAAACpQwAAqUYAAKlIAACpbQAAqZEAAKm4AACp3AAAqd8AAKnhAACp4wAAqeUAAKnnAACp6QAAqeoAAKntAACp+gAAqgsAAKoNAACqDwAAqhEAAKoTAACqFQAAqhcAAKoZAACqGwAAqiwAAKovAACqMgAAqjUAAKo4AACqOwAAqj4AAKpBAACqRAAAqkYAAKqFAACqhwAAqokAAKqLAACqjgAAqo8AAKqQAACqkQAAqpIAAKqUAACqlgAAqpcAAKqYAACqmgAAqpsAAKraAACq3AAAqt4AAKrgAACq4wAAquQAAKrlAACq5gAAqucAAKrpAACq6wAAquwAAKrtAACq7wAAqvAAAKsvAACrMQAAqzQAAKs2AACrOQAAqzoAAKs7AACrPAAAqz0AAKs/AACrQQAAq0IAAKtDAACrRQAAq0YAAKtTAACrVAAAq1UAAKtXAACrlgAAq5gAAKuaAACrnAAAq58AAKugAACroQAAq6IAAKujAACrpQAAq6cAAKuoAACrqQAAq6sAAKusAACr6wAAq+0AAKvvAACr8QAAq/QAAKv1AACr9gAAq/cAAKv4AACr+gAAq/wAAKv9AACr/gAArAAAAKwBAACsQAAArEIAAKxEAACsRgAArEkAAKxKAACsSwAArEwAAKxNAACsTwAArFEAAKxSAACsUwAArFUAAKxWAACslQAArJcAAKyZAACsmwAArJ4AAKyfAACsoAAArKEAAKyiAACspAAArKYAAKynAACsqAAArKoAAKyrAACs6gAArOwAAKzuAACs8AAArPMAAKz0AACs9QAArPYAAKz3AACs+QAArPsAAKz8AACs/QAArP8AAK0AAACtJQAArUkAAK1wAACtlAAArZcAAK2ZAACtmwAArZ0AAK2fAACtoQAAraIAAK2lAACtsgAArcEAAK3DAACtxQAArccAAK3JAACtywAArc0AAK3PAACt3gAAreEAAK3kAACt5wAAreoAAK3tAACt8AAArfMAAK31AACuNAAArjYAAK45AACuOwAArj4AAK4/AACuQAAArkEAAK5CAACuRAAArkYAAK5HAACuSAAArkoAAK5LAACuTgAAro0AAK6PAACukQAArpMAAK6WAACulwAArpgAAK6ZAACumgAArpwAAK6eAACunwAArqAAAK6iAACuowAAruIAAK7kAACu5gAArugAAK7rAACu7AAAru0AAK7uAACu7wAArvEAAK7zAACu9AAArvUAAK73AACu+AAArzcAAK85AACvPAAArz4AAK9BAACvQgAAr0MAAK9EAACvRQAAr0cAAK9JAACvSgAAr0sAAK9NAACvTgAAr1EAAK+QAACvkgAAr5QAAK+WAACvmQAAr5oAAK+bAACvnAAAr50AAK+fAACvoQAAr6IAAK+jAACvpQAAr6YAAK/lAACv5wAAr+kAAK/rAACv7gAAr+8AAK/wAACv8QAAr/IAAK/0AACv9gAAr/cAAK/4AACv+gAAr/sAALA6AACwPAAAsD4AALBAAACwQwAAsEQAALBFAACwRgAAsEcAALBJAACwSwAAsEwAALBNAACwTwAAsFAAALCbAACwvgAAsN4AALD+AACxAAAAsQIAALEEAACxBgAAsQkAALEKAACxCwAAsQ4AALEPAACxEQAAsRIAALEUAACxFwAAsRgAALEZAACxHAAAsR0AALEiAACxLwAAsTQAALE2AACxOAAAsT0AALFAAACxQwAAsUUAALFqAACxjgAAsbUAALHZAACx3AAAsd4AALHgAACx4gAAseQAALHmAACx5wAAseoAALH3AACyCAAAsgoAALIMAACyDgAAshAAALISAACyFAAAshYAALIYAACyKQAAsiwAALIvAACyMgAAsjUAALI4AACyOwAAsj4AALJBAACyQwAAsoIAALKEAACyhgAAsogAALKLAACyjAAAso0AALKOAACyjwAAspEAALKTAACylAAAspUAALKXAACymAAAstcAALLZAACy2wAAst0AALLgAACy4QAAsuIAALLjAACy5AAAsuYAALLoAACy6QAAsuoAALLsAACy7QAAsywAALMuAACzMQAAszMAALM2AACzNwAAszgAALM5AACzOgAAszwAALM+AACzPwAAs0AAALNCAACzQwAAs1AAALNRAACzUgAAs1QAALOTAACzlQAAs5cAALOZAACznAAAs50AALOeAACznwAAs6AAALOiAACzpAAAs6UAALOmAACzqAAAs6kAALPoAACz6gAAs+wAALPuAACz8QAAs/IAALPzAACz9AAAs/UAALP3AACz+QAAs/oAALP7AACz/QAAs/4AALQ9AAC0PwAAtEEAALRDAAC0RgAAtEcAALRIAAC0SQAAtEoAALRMAAC0TgAAtE8AALRQAAC0UgAAtFMAALSSAAC0lAAAtJYAALSYAAC0mwAAtJwAALSdAAC0ngAAtJ8AALShAAC0owAAtKQAALSlAAC0pwAAtKgAALTnAAC06QAAtOsAALTtAAC08AAAtPEAALTyAAC08wAAtPQAALT2AAC0+AAAtPkAALT6AAC0/AAAtP0AALUiAAC1RgAAtW0AALWRAAC1lAAAtZYAALWYAAC1mgAAtZwAALWeAAC1nwAAtaIAALWvAAC1vgAAtcAAALXCAAC1xAAAtcYAALXIAAC1ygAAtcwAALXbAAC13gAAteEAALXkAAC15wAAteoAALXtAAC18AAAtfIAALYxAAC2MwAAtjUAALY3AAC2OgAAtjsAALY8AAC2PQAAtj4AALZAAAC2QgAAtkMAALZEAAC2RgAAtkcAALaGAAC2iAAAtooAALaMAAC2jwAAtpAAALaRAAC2kgAAtpMAALaVAAC2lwAAtpgAALaZAAC2mwAAtpwAALbbAAC23QAAtt8AALbhAAC25AAAtuUAALbmAAC25wAAtugAALbqAAC27AAAtu0AALbuAAC28AAAtvEAALcwAAC3MgAAtzQAALc2AAC3OQAAtzoAALc7AAC3PAAAtz0AALc/AAC3QQAAt0IAALdDAAC3RQAAt0YAALeFAAC3hwAAt4kAALeLAAC3jgAAt48AALeQAAC3kQAAt5IAALeUAAC3lgAAt5cAALeYAAC3mgAAt5sAALfaAAC33AAAt94AALfgAAC34wAAt+QAALflAAC35gAAt+cAALfpAAC36wAAt+wAALftAAC37wAAt/AAALgvAAC4MQAAuDMAALg1AAC4OAAAuDkAALg6AAC4OwAAuDwAALg+AAC4QAAAuEEAALhCAAC4RAAAuEUAALiQAAC4swAAuNMAALjzAAC49QAAuPcAALj5AAC4+wAAuP4AALj/AAC5AAAAuQMAALkEAAC5BgAAuQcAALkJAAC5DAAAuQ0AALkOAAC5EQAAuRIAALkXAAC5JAAAuSkAALkrAAC5LQAAuTIAALk1AAC5OAAAuToAALlfAAC5gwAAuaoAALnOAAC50QAAudMAALnVAAC51wAAudkAALnbAAC53AAAud8AALnsAAC5/QAAuf8AALoBAAC6AwAAugUAALoHAAC6CQAAugsAALoNAAC6HgAAuiEAALokAAC6JwAAuioAALotAAC6MAAAujMAALo2AAC6OAAAuncAALp5AAC6ewAAun0AALqAAAC6gQAAuoIAALqDAAC6hAAAuoYAALqIAAC6iQAAuooAALqMAAC6jQAAuswAALrOAAC60AAAutIAALrVAAC61gAAutcAALrYAAC62QAAutsAALrdAAC63gAAut8AALrhAAC64gAAuyEAALsjAAC7JgAAuygAALsrAAC7LAAAuy0AALsuAAC7LwAAuzEAALszAAC7NAAAuzUAALs3AAC7OAAAu0UAALtGAAC7RwAAu0kAALuIAAC7igAAu4wAALuOAAC7kQAAu5IAALuTAAC7lAAAu5UAALuXAAC7mQAAu5oAALubAAC7nQAAu54AALvdAAC73wAAu+EAALvjAAC75gAAu+cAALvoAAC76QAAu+oAALvsAAC77gAAu+8AALvwAAC78gAAu/MAALwyAAC8NAAAvDYAALw4AAC8OwAAvDwAALw9AAC8PgAAvD8AALxBAAC8QwAAvEQAALxFAAC8RwAAvEgAALyHAAC8iQAAvIsAALyNAAC8kAAAvJEAALySAAC8kwAAvJQAALyWAAC8mAAAvJkAALyaAAC8nAAAvJ0AALzcAAC83gAAvOAAALziAAC85QAAvOYAALznAAC86AAAvOkAALzrAAC87QAAvO4AALzvAAC88QAAvPIAAL0XAAC9OwAAvWIAAL2GAAC9iQAAvYsAAL2NAAC9jwAAvZEAAL2TAAC9lAAAvZcAAL2kAAC9swAAvbUAAL23AAC9uQAAvbsAAL29AAC9vwAAvcEAAL3QAAC90wAAvdYAAL3ZAAC93AAAvd8AAL3iAAC95QAAvecAAL4mAAC+KAAAvisAAL4tAAC+MAAAvjEAAL4yAAC+MwAAvjQAAL42AAC+OAAAvjkAAL46AAC+PAAAvj0AAL58AAC+fgAAvoAAAL6CAAC+hQAAvoYAAL6HAAC+iAAAvokAAL6LAAC+jQAAvo4AAL6PAAC+kQAAvpIAAL7RAAC+0wAAvtUAAL7XAAC+2gAAvtsAAL7cAAC+3QAAvt4AAL7gAAC+4gAAvuMAAL7kAAC+5gAAvucAAL8mAAC/KAAAvysAAL8tAAC/MAAAvzEAAL8yAAC/MwAAvzQAAL82AAC/OAAAvzkAAL86AAC/PAAAvz0AAL98AAC/fgAAv4AAAL+CAAC/hQAAv4YAAL+HAAC/iAAAv4kAAL+LAAC/jQAAv44AAL+PAAC/kQAAv5IAAL/RAAC/0wAAv9UAAL/XAAC/2gAAv9sAAL/cAAC/3QAAv94AAL/gAAC/4gAAv+MAAL/kAAC/5gAAv+cAAMAmAADAKAAAwCoAAMAsAADALwAAwDAAAMAxAADAMgAAwDMAAMA1AADANwAAwDgAAMA5AADAOwAAwDwAAMCHAADAqgAAwMoAAMDqAADA7AAAwO4AAMDwAADA8gAAwPUAAMD2AADA9wAAwPoAAMD7AADA/QAAwP4AAMEAAADBAwAAwQQAAMEFAADBCAAAwQkAAMEOAADBGwAAwSAAAMEiAADBJAAAwSkAAMEsAADBLwAAwTEAAMFWAADBegAAwaEAAMHFAADByAAAwcoAAMHMAADBzgAAwdAAAMHSAADB0wAAwdYAAMHjAADB9AAAwfYAAMH4AADB+gAAwfwAAMH+AADCAAAAwgIAAMIEAADCFQAAwhgAAMIbAADCHgAAwiEAAMIkAADCJwAAwioAAMItAADCLwAAwm4AAMJwAADCcgAAwnQAAMJ3AADCeAAAwnkAAMJ6AADCewAAwn0AAMJ/AADCgAAAwoEAAMKDAADChAAAwsMAAMLFAADCxwAAwskAAMLMAADCzQAAws4AAMLPAADC0AAAwtIAAMLUAADC1QAAwtYAAMLYAADC2QAAwxgAAMMaAADDHQAAwx8AAMMiAADDIwAAwyQAAMMlAADDJgAAwygAAMMqAADDKwAAwywAAMMuAADDLwAAwzwAAMM9AADDPgAAw0AAAMN/AADDgQAAw4MAAMOFAADDiAAAw4kAAMOKAADDiwAAw4wAAMOOAADDkAAAw5EAAMOSAADDlAAAw5UAAMPUAADD1gAAw9gAAMPaAADD3QAAw94AAMPfAADD4AAAw+EAAMPjAADD5QAAw+YAAMPnAADD6QAAw+oAAMQpAADEKwAAxC0AAMQvAADEMgAAxDMAAMQ0AADENQAAxDYAAMQ4AADEOgAAxDsAAMQ8AADEPgAAxD8AAMR+AADEgAAAxIIAAMSEAADEhwAAxIgAAMSJAADEigAAxIsAAMSNAADEjwAAxJAAAMSRAADEkwAAxJQAAMTTAADE1QAAxNcAAMTZAADE3AAAxN0AAMTeAADE3wAAxOAAAMTiAADE5AAAxOUAAMTmAADE6AAAxOkAAMUOAADFMgAAxVkAAMV9AADFgAAAxYIAAMWEAADFhgAAxYgAAMWKAADFiwAAxY4AAMWbAADFqgAAxawAAMWuAADFsAAAxbIAAMW0AADFtgAAxbgAAMXHAADFygAAxc0AAMXQAADF0wAAxdYAAMXZAADF3AAAxd4AAMYdAADGHwAAxiEAAMYjAADGJgAAxicAAMYoAADGKQAAxioAAMYsAADGLgAAxi8AAMYwAADGMgAAxjMAAMZyAADGdAAAxnYAAMZ4AADGewAAxnwAAMZ9AADGfgAAxn8AAMaBAADGgwAAxoQAAMaFAADGhwAAxogAAMbHAADGyQAAxssAAMbNAADG0AAAxtEAAMbSAADG0wAAxtQAAMbWAADG2AAAxtkAAMbaAADG3AAAxt0AAMccAADHHgAAxyAAAMciAADHJQAAxyYAAMcnAADHKAAAxykAAMcrAADHLQAAxy4AAMcvAADHMQAAxzIAAMdxAADHcwAAx3UAAMd3AADHegAAx3sAAMd8AADHfQAAx34AAMeAAADHggAAx4MAAMeEAADHhgAAx4cAAMfGAADHyAAAx8oAAMfMAADHzwAAx9AAAMfRAADH0gAAx9MAAMfVAADH1wAAx9gAAMfZAADH2wAAx9wAAMgbAADIHQAAyB8AAMghAADIJAAAyCUAAMgmAADIJwAAyCgAAMgqAADILAAAyC0AAMguAADIMAAAyDEAAMh8AADInwAAyL8AAMjfAADI4QAAyOMAAMjlAADI5wAAyOoAAMjrAADI7AAAyO8AAMjwAADI8gAAyPMAAMj1AADI+AAAyPkAAMj6AADI/QAAyP4AAMkDAADJEAAAyRUAAMkXAADJGQAAyR4AAMkhAADJJAAAySYAAMlLAADJbwAAyZYAAMm6AADJvQAAyb8AAMnBAADJwwAAycUAAMnHAADJyAAAycsAAMnYAADJ6QAAyesAAMntAADJ7wAAyfEAAMnzAADJ9QAAyfcAAMn5AADKCgAAyg0AAMoQAADKEwAAyhYAAMoZAADKHAAAyh8AAMoiAADKJAAAymMAAMplAADKZwAAymkAAMpsAADKbQAAym4AAMpvAADKcAAAynIAAMp0AADKdQAAynYAAMp4AADKeQAAyrgAAMq6AADKvAAAyr4AAMrBAADKwgAAysMAAMrEAADKxQAAyscAAMrJAADKygAAyssAAMrNAADKzgAAyw0AAMsPAADLEgAAyxQAAMsXAADLGAAAyxkAAMsaAADLGwAAyx0AAMsfAADLIAAAyyEAAMsjAADLJAAAyzEAAMsyAADLMwAAyzUAAMt0AADLdgAAy3gAAMt6AADLfQAAy34AAMt/AADLgAAAy4EAAMuDAADLhQAAy4YAAMuHAADLiQAAy4oAAMvJAADLywAAy80AAMvPAADL0gAAy9MAAMvUAADL1QAAy9YAAMvYAADL2gAAy9sAAMvcAADL3gAAy98AAMweAADMIAAAzCIAAMwkAADMJwAAzCgAAMwpAADMKgAAzCsAAMwtAADMLwAAzDAAAMwxAADMMwAAzDQAAMxzAADMdQAAzHcAAMx5AADMfAAAzH0AAMx+AADMfwAAzIAAAMyCAADMhAAAzIUAAMyGAADMiAAAzIkAAMzIAADMygAAzMwAAMzOAADM0QAAzNIAAMzTAADM1AAAzNUAAMzXAADM2QAAzNoAAMzbAADM3QAAzN4AAM0DAADNJwAAzU4AAM1yAADNdQAAzXcAAM15AADNewAAzX0AAM1/AADNgAAAzYMAAM2QAADNnwAAzaEAAM2jAADNpQAAzacAAM2pAADNqwAAza0AAM28AADNvwAAzcIAAM3FAADNyAAAzcsAAM3OAADN0QAAzdMAAM4SAADOFAAAzhYAAM4YAADOGwAAzhwAAM4dAADOHgAAzh8AAM4hAADOIwAAziQAAM4lAADOJwAAzigAAM5nAADOaQAAzmsAAM5tAADOcAAAznEAAM5yAADOcwAAznQAAM52AADOeAAAznkAAM56AADOfAAAzn0AAM68AADOvgAAzsAAAM7CAADOxQAAzsYAAM7HAADOyAAAzskAAM7LAADOzQAAzs4AAM7PAADO0QAAztIAAM8RAADPEwAAzxYAAM8YAADPGwAAzxwAAM8dAADPHgAAzx8AAM8hAADPIwAAzyQAAM8lAADPJwAAzygAAM9nAADPaQAAz2sAAM9tAADPcAAAz3EAAM9yAADPcwAAz3QAAM92AADPeAAAz3kAAM96AADPfAAAz30AAM+8AADPvgAAz8AAAM/CAADPxQAAz8YAAM/HAADPyAAAz8kAAM/LAADPzQAAz84AAM/PAADP0QAAz9IAANARAADQEwAA0BUAANAXAADQGgAA0BsAANAcAADQHQAA0B4AANAgAADQIgAA0CMAANAkAADQJgAA0CcAANByAADQlQAA0LUAANDVAADQ1wAA0NkAANDbAADQ3QAA0OAAANDhAADQ4gAA0OUAANDmAADQ6AAA0OkAANDrAADQ7gAA0O8AANDwAADQ8wAA0PQAAND5AADRBgAA0QsAANENAADRDwAA0RQAANEXAADRGgAA0RwAANFBAADRZQAA0YwAANGwAADRswAA0bUAANG3AADRuQAA0bsAANG9AADRvgAA0cEAANHOAADR3wAA0eEAANHjAADR5QAA0ecAANHpAADR6wAA0e0AANHvAADSAAAA0gMAANIGAADSCQAA0gwAANIPAADSEgAA0hUAANIYAADSGgAA0lkAANJbAADSXQAA0l8AANJiAADSYwAA0mQAANJlAADSZgAA0mgAANJqAADSawAA0mwAANJuAADSbwAA0q4AANKwAADSsgAA0rQAANK3AADSuAAA0rkAANK6AADSuwAA0r0AANK/AADSwAAA0sEAANLDAADSxAAA0wMAANMFAADTCAAA0woAANMNAADTDgAA0w8AANMQAADTEQAA0xMAANMVAADTFgAA0xcAANMZAADTGgAA0ycAANMoAADTKQAA0ysAANNqAADTbAAA024AANNwAADTcwAA03QAANN1AADTdgAA03cAANN5AADTewAA03wAANN9AADTfwAA04AAANO/AADTwQAA08MAANPFAADTyAAA08kAANPKAADTywAA08wAANPOAADT0AAA09EAANPSAADT1AAA09UAANQUAADUFgAA1BgAANQaAADUHQAA1B4AANQfAADUIAAA1CEAANQjAADUJQAA1CYAANQnAADUKQAA1CoAANRpAADUawAA1G0AANRvAADUcgAA1HMAANR0AADUdQAA1HYAANR4AADUegAA1HsAANR8AADUfgAA1H8AANS+AADUwAAA1MIAANTEAADUxwAA1MgAANTJAADUygAA1MsAANTNAADUzwAA1NAAANTRAADU0wAA1NQAANT5AADVHQAA1UQAANVoAADVawAA1W0AANVvAADVcQAA1XMAANV1AADVdgAA1XkAANWGAADVlQAA1ZcAANWZAADVmwAA1Z0AANWfAADVoQAA1aMAANWyAADVtQAA1bgAANW7AADVvgAA1cEAANXEAADVxwAA1ckAANYIAADWCgAA1g0AANYPAADWEgAA1hMAANYUAADWFQAA1hYAANYYAADWGgAA1hsAANYcAADWHgAA1h8AANZeAADWYAAA1mIAANZkAADWZwAA1mgAANZpAADWagAA1msAANZtAADWbwAA1nAAANZxAADWcwAA1nQAANazAADWtQAA1rcAANa5AADWvAAA1r0AANa+AADWvwAA1sAAANbCAADWxAAA1sUAANbGAADWyAAA1skAANcIAADXCgAA1w0AANcPAADXEgAA1xMAANcUAADXFQAA1xYAANcYAADXGgAA1xsAANccAADXHgAA1x8AANdeAADXYAAA12IAANdkAADXZwAA12gAANdpAADXagAA12sAANdtAADXbwAA13AAANdxAADXcwAA13QAANezAADXtQAA17cAANe5AADXvAAA170AANe+AADXvwAA18AAANfCAADXxAAA18UAANfGAADXyAAA18kAANgIAADYCgAA2AwAANgOAADYEQAA2BIAANgTAADYFAAA2BUAANgXAADYGQAA2BoAANgbAADYHQAA2B4AANhpAADYjAAA2KwAANjMAADYzgAA2NAAANjSAADY1AAA2NcAANjYAADY2QAA2NwAANjdAADY3wAA2OAAANjiAADY5QAA2OYAANjnAADY6gAA2OsAANjwAADY/QAA2QIAANkEAADZBgAA2QsAANkOAADZEQAA2RMAANk4AADZXAAA2YMAANmnAADZqgAA2awAANmuAADZsAAA2bIAANm0AADZtQAA2bgAANnFAADZ1gAA2dgAANnaAADZ3AAA2d4AANngAADZ4gAA2eQAANnmAADZ9wAA2foAANn9AADaAAAA2gMAANoGAADaCQAA2gwAANoPAADaEQAA2lAAANpSAADaVAAA2lYAANpZAADaWgAA2lsAANpcAADaXQAA2l8AANphAADaYgAA2mMAANplAADaZgAA2qUAANqnAADaqQAA2qsAANquAADarwAA2rAAANqxAADasgAA2rQAANq2AADatwAA2rgAANq6AADauwAA2voAANr8AADa/wAA2wEAANsEAADbBQAA2wYAANsHAADbCAAA2woAANsMAADbDQAA2w4AANsQAADbEQAA2x4AANsfAADbIAAA2yIAANthAADbYwAA22UAANtnAADbagAA22sAANtsAADbbQAA224AANtwAADbcgAA23MAANt0AADbdgAA23cAANu2AADbuAAA27oAANu8AADbvwAA28AAANvBAADbwgAA28MAANvFAADbxwAA28gAANvJAADbywAA28wAANwLAADcDQAA3A8AANwRAADcFAAA3BUAANwWAADcFwAA3BgAANwaAADcHAAA3B0AANweAADcIAAA3CEAANxgAADcYgAA3GQAANxmAADcaQAA3GoAANxrAADcbAAA3G0AANxvAADccQAA3HIAANxzAADcdQAA3HYAANy1AADctwAA3LkAANy7AADcvgAA3L8AANzAAADcwQAA3MIAANzEAADcxgAA3McAANzIAADcygAA3MsAANzwAADdFAAA3TsAAN1fAADdYgAA3WQAAN1mAADdaAAA3WoAAN1sAADdbQAA3XAAAN19AADdjAAA3Y4AAN2QAADdkgAA3ZQAAN2WAADdmAAA3ZoAAN2pAADdrAAA3a8AAN2yAADdtQAA3bgAAN27AADdvgAA3cAAAN3/AADeAQAA3gQAAN4GAADeCQAA3goAAN4LAADeDAAA3g0AAN4PAADeEQAA3hIAAN4TAADeFQAA3hYAAN5VAADeVwAA3lkAAN5bAADeXgAA3l8AAN5gAADeYQAA3mIAAN5kAADeZgAA3mcAAN5oAADeagAA3msAAN6qAADerAAA3q4AAN6wAADeswAA3rQAAN61AADetgAA3rcAAN65AADeuwAA3rwAAN69AADevwAA3sAAAN7/AADfAQAA3wQAAN8GAADfCQAA3woAAN8LAADfDAAA3w0AAN8PAADfEQAA3xIAAN8TAADfFQAA3xYAAN9VAADfVwAA31kAAN9bAADfXgAA318AAN9gAADfYQAA32IAAN9kAADfZgAA32cAAN9oAADfagAA32sAAN+qAADfrAAA364AAN+wAADfswAA37QAAN+1AADftgAA37cAAN+5AADfuwAA37wAAN+9AADfvwAA38AAAN//AADgAQAA4AMAAOAFAADgCAAA4AkAAOAKAADgCwAA4AwAAOAOAADgEAAA4BEAAOASAADgFAAA4BUAAOBgAADggwAA4KMAAODDAADgxQAA4McAAODJAADgywAA4M4AAODPAADg0AAA4NMAAODUAADg1gAA4NcAAODZAADg3AAA4N0AAODeAADg4QAA4OIAAODnAADg9AAA4PkAAOD7AADg/QAA4QIAAOEFAADhCAAA4QoAAOEvAADhUwAA4XoAAOGeAADhoQAA4aMAAOGlAADhpwAA4akAAOGrAADhrAAA4a8AAOG8AADhzQAA4c8AAOHRAADh0wAA4dUAAOHXAADh2QAA4dsAAOHdAADh7gAA4fEAAOH0AADh9wAA4foAAOH9AADiAAAA4gMAAOIGAADiCAAA4kcAAOJJAADiSwAA4k0AAOJQAADiUQAA4lIAAOJTAADiVAAA4lYAAOJYAADiWQAA4loAAOJcAADiXQAA4pwAAOKeAADioAAA4qIAAOKlAADipgAA4qcAAOKoAADiqQAA4qsAAOKtAADirgAA4q8AAOKxAADisgAA4vEAAOLzAADi9gAA4vgAAOL7AADi/AAA4v0AAOL+AADi/wAA4wEAAOMDAADjBAAA4wUAAOMHAADjCAAA4xUAAOMWAADjFwAA4xkAAONYAADjWgAA41wAAONeAADjYQAA42IAAONjAADjZAAA42UAAONnAADjaQAA42oAAONrAADjbQAA424AAOOtAADjrwAA47EAAOOzAADjtgAA47cAAOO4AADjuQAA47oAAOO8AADjvgAA478AAOPAAADjwgAA48MAAOQCAADkBAAA5AYAAOQIAADkCwAA5AwAAOQNAADkDgAA5A8AAOQRAADkEwAA5BQAAOQVAADkFwAA5BgAAORXAADkWQAA5FsAAORdAADkYAAA5GEAAORiAADkYwAA5GQAAORmAADkaAAA5GkAAORqAADkbAAA5G0AAOSsAADkrgAA5LAAAOSyAADktQAA5LYAAOS3AADkuAAA5LkAAOS7AADkvQAA5L4AAOS/AADkwQAA5MIAAOTnAADlCwAA5TIAAOVWAADlWQAA5VsAAOVdAADlXwAA5WEAAOVjAADlZAAA5WcAAOV0AADlgwAA5YUAAOWHAADliQAA5YsAAOWNAADljwAA5ZEAAOWgAADlowAA5aYAAOWpAADlrAAA5a8AAOWyAADltQAA5bcAAOX2AADl+AAA5fsAAOX9AADmAAAA5gEAAOYCAADmAwAA5gQAAOYGAADmCAAA5gkAAOYKAADmDAAA5g0AAOYcAADmWwAA5l0AAOZfAADmYQAA5mQAAOZlAADmZgAA5mcAAOZoAADmagAA5mwAAOZtAADmbgAA5nAAAOZxAADmsAAA5rIAAOa0AADmtgAA5rkAAOa6AADmuwAA5rwAAOa9AADmvwAA5sEAAObCAADmwwAA5sUAAObGAADnBQAA5wcAAOcKAADnDAAA5w8AAOcQAADnEQAA5xIAAOcTAADnFQAA5xcAAOcYAADnGQAA5xsAAOccAADnHwAA514AAOdgAADnYgAA52QAAOdnAADnaAAA52kAAOdqAADnawAA520AAOdvAADncAAA53EAAOdzAADndAAA57MAAOe1AADntwAA57kAAOe8AADnvQAA574AAOe/AADnwAAA58IAAOfEAADnxQAA58YAAOfIAADnyQAA6AgAAOgKAADoDAAA6A4AAOgRAADoEgAA6BMAAOgUAADoFQAA6BcAAOgZAADoGgAA6BsAAOgdAADoHgAA6GkAAOiMAADorAAA6MwAAOjOAADo0AAA6NIAAOjUAADo1wAA6NgAAOjZAADo3AAA6N0AAOjfAADo4AAA6OIAAOjlAADo5gAA6OcAAOjqAADo6wAA6PQAAOkBAADpBgAA6QgAAOkKAADpDwAA6RIAAOkVAADpFwAA6TwAAOlgAADphwAA6asAAOmuAADpsAAA6bIAAOm0AADptgAA6bgAAOm5AADpvAAA6ckAAOnaAADp3AAA6d4AAOngAADp4gAA6eQAAOnmAADp6AAA6eoAAOn7AADp/gAA6gEAAOoEAADqBwAA6goAAOoNAADqEAAA6hMAAOoVAADqVAAA6lYAAOpYAADqWgAA6l0AAOpeAADqXwAA6mAAAOphAADqYwAA6mUAAOpmAADqZwAA6mkAAOpqAADqqQAA6qsAAOqtAADqrwAA6rIAAOqzAADqtAAA6rUAAOq2AADquAAA6roAAOq7AADqvAAA6r4AAOq/AADq/gAA6wAAAOsDAADrBQAA6wgAAOsJAADrCgAA6wsAAOsMAADrDgAA6xAAAOsRAADrEgAA6xQAAOsVAADrIgAA6yMAAOskAADrJgAA62UAAOtnAADraQAA62sAAOtuAADrbwAA63AAAOtxAADrcgAA63QAAOt2AADrdwAA63gAAOt6AADrewAA67oAAOu8AADrvgAA68AAAOvDAADrxAAA68UAAOvGAADrxwAA68kAAOvLAADrzAAA680AAOvPAADr0AAA7A8AAOwRAADsEwAA7BUAAOwYAADsGQAA7BoAAOwbAADsHAAA7B4AAOwgAADsIQAA7CIAAOwkAADsJQAA7GQAAOxmAADsaAAA7GoAAOxtAADsbgAA7G8AAOxwAADscQAA7HMAAOx1AADsdgAA7HcAAOx5AADsegAA7LkAAOy7AADsvQAA7L8AAOzCAADswwAA7MQAAOzFAADsxgAA7MgAAOzKAADsywAA7MwAAOzOAADszwAA7PQAAO0YAADtPwAA7WMAAO1mAADtaAAA7WoAAO1sAADtbgAA7XAAAO1xAADtdAAA7YEAAO2QAADtkgAA7ZQAAO2WAADtmAAA7ZoAAO2cAADtngAA7a0AAO2wAADtswAA7bYAAO25AADtvAAA7b8AAO3CAADtxAAA7gMAAO4FAADuBwAA7gkAAO4MAADuDQAA7g4AAO4PAADuEAAA7hIAAO4UAADuFQAA7hYAAO4YAADuGQAA7lgAAO5aAADuXAAA7l4AAO5hAADuYgAA7mMAAO5kAADuZQAA7mcAAO5pAADuagAA7msAAO5tAADubgAA7q0AAO6vAADusQAA7rMAAO62AADutwAA7rgAAO65AADuugAA7rwAAO6+AADuvwAA7sAAAO7CAADuwwAA7wIAAO8EAADvBwAA7wkAAO8MAADvDQAA7w4AAO8PAADvEAAA7xIAAO8UAADvFQAA7xYAAO8YAADvGQAA71gAAO9aAADvXAAA714AAO9hAADvYgAA72MAAO9kAADvZQAA72cAAO9pAADvagAA72sAAO9tAADvbgAA760AAO+vAADvsQAA77MAAO+2AADvtwAA77gAAO+5AADvugAA77wAAO++AADvvwAA78AAAO/CAADvwwAA8AIAAPAEAADwBgAA8AgAAPALAADwDAAA8A0AAPAOAADwDwAA8BEAAPATAADwFAAA8BUAAPAXAADwGAAA8GMAAPCGAADwpgAA8MYAAPDIAADwygAA8MwAAPDOAADw0QAA8NIAAPDTAADw1gAA8NcAAPDZAADw2gAA8NwAAPDeAADw3wAA8OAAAPDjAADw5AAA8O0AAPD6AADw/wAA8QEAAPEDAADxCAAA8QsAAPEOAADxEAAA8TUAAPFZAADxgAAA8aQAAPGnAADxqQAA8asAAPGtAADxrwAA8bEAAPGyAADxtQAA8cIAAPHTAADx1QAA8dcAAPHZAADx2wAA8d0AAPHfAADx4QAA8eMAAPH0AADx9wAA8foAAPH9AADyAAAA8gMAAPIGAADyCQAA8gwAAPIOAADyTQAA8k8AAPJRAADyUwAA8lYAAPJXAADyWAAA8lkAAPJaAADyXAAA8l4AAPJfAADyYAAA8mIAAPJjAADyogAA8qQAAPKmAADyqAAA8qsAAPKsAADyrQAA8q4AAPKvAADysQAA8rMAAPK0AADytQAA8rcAAPK4AADy9wAA8vkAAPL8AADy/gAA8wEAAPMCAADzAwAA8wQAAPMFAADzBwAA8wkAAPMKAADzCwAA8w0AAPMOAADzGwAA8xwAAPMdAADzHwAA814AAPNgAADzYgAA82QAAPNnAADzaAAA82kAAPNqAADzawAA820AAPNvAADzcAAA83EAAPNzAADzdAAA87MAAPO1AADztwAA87kAAPO8AADzvQAA874AAPO/AADzwAAA88IAAPPEAADzxQAA88YAAPPIAADzyQAA9AgAAPQKAAD0DAAA9A4AAPQRAAD0EgAA9BMAAPQUAAD0FQAA9BcAAPQZAAD0GgAA9BsAAPQdAAD0HgAA9F0AAPRfAAD0YQAA9GMAAPRmAAD0ZwAA9GgAAPRpAAD0agAA9GwAAPRuAAD0bwAA9HAAAPRyAAD0cwAA9LIAAPS0AAD0tgAA9LgAAPS7AAD0vAAA9L0AAPS+AAD0vwAA9MEAAPTDAAD0xAAA9MUAAPTHAAD0yAAA9O0AAPURAAD1OAAA9VwAAPVfAAD1YQAA9WMAAPVlAAD1ZwAA9WkAAPVqAAD1bQAA9XoAAPWJAAD1iwAA9Y0AAPWPAAD1kQAA9ZMAAPWVAAD1lwAA9aYAAPWpAAD1rAAA9a8AAPWyAAD1tQAA9bgAAPW7AAD1vQAA9fwAAPX+AAD2AQAA9gMAAPYGAAD2BwAA9ggAAPYJAAD2CgAA9gwAAPYOAAD2DwAA9hAAAPYSAAD2EwAA9lIAAPZUAAD2VgAA9lgAAPZbAAD2XAAA9l0AAPZeAAD2XwAA9mEAAPZjAAD2ZAAA9mUAAPZnAAD2aAAA9qcAAPapAAD2qwAA9q0AAPawAAD2sQAA9rIAAPazAAD2tAAA9rYAAPa4AAD2uQAA9roAAPa8AAD2vQAA9vwAAPb+AAD3AQAA9wMAAPcGAAD3BwAA9wgAAPcJAAD3CgAA9wwAAPcOAAD3DwAA9xAAAPcSAAD3EwAA91IAAPdUAAD3VgAA91gAAPdbAAD3XAAA910AAPdeAAD3XwAA92EAAPdjAAD3ZAAA92UAAPdnAAD3aAAA96cAAPepAAD3qwAA960AAPewAAD3sQAA97IAAPezAAD3tAAA97YAAPe4AAD3uQAA97oAAPe8AAD3vQAA9/wAAPf+AAD4AAAA+AIAAPgFAAD4BgAA+AcAAPgIAAD4CQAA+AsAAPgNAAD4DgAA+A8AAPgRAAD4EgAA+F0AAPiAAAD4oAAA+MAAAPjCAAD4xAAA+MYAAPjIAAD4ywAA+MwAAPjNAAD40AAA+NEAAPjTAAD41AAA+NYAAPjZAAD42gAA+NsAAPjeAAD43wAA+OQAAPjxAAD49gAA+PgAAPj6AAD4/wAA+QIAAPkFAAD5BwAA+SwAAPlQAAD5dwAA+ZsAAPmeAAD5oAAA+aIAAPmkAAD5pgAA+agAAPmpAAD5rAAA+bkAAPnKAAD5zAAA+c4AAPnQAAD50gAA+dQAAPnWAAD52AAA+doAAPnrAAD57gAA+fEAAPn0AAD59wAA+foAAPn9AAD6AAAA+gMAAPoFAAD6RAAA+kYAAPpIAAD6SgAA+k0AAPpOAAD6TwAA+lAAAPpRAAD6UwAA+lUAAPpWAAD6VwAA+lkAAPpaAAD6mQAA+psAAPqdAAD6nwAA+qIAAPqjAAD6pAAA+qUAAPqmAAD6qAAA+qoAAPqrAAD6rAAA+q4AAPqvAAD67gAA+vAAAPrzAAD69QAA+vgAAPr5AAD6+gAA+vsAAPr8AAD6/gAA+wAAAPsBAAD7AgAA+wQAAPsFAAD7EgAA+xMAAPsUAAD7FgAA+1UAAPtXAAD7WQAA+1sAAPteAAD7XwAA+2AAAPthAAD7YgAA+2QAAPtmAAD7ZwAA+2gAAPtqAAD7awAA+6oAAPusAAD7rgAA+7AAAPuzAAD7tAAA+7UAAPu2AAD7twAA+7kAAPu7AAD7vAAA+70AAPu/AAD7wAAA+/8AAPwBAAD8AwAA/AUAAPwIAAD8CQAA/AoAAPwLAAD8DAAA/A4AAPwQAAD8EQAA/BIAAPwUAAD8FQAA/FQAAPxWAAD8WAAA/FoAAPxdAAD8XgAA/F8AAPxgAAD8YQAA/GMAAPxlAAD8ZgAA/GcAAPxpAAD8agAA/KkAAPyrAAD8rQAA/K8AAPyyAAD8swAA/LQAAPy1AAD8tgAA/LgAAPy6AAD8uwAA/LwAAPy+AAD8vwAA/OQAAP0IAAD9LwAA/VMAAP1WAAD9WAAA/VoAAP1cAAD9XgAA/WAAAP1hAAD9ZAAA/XEAAP2AAAD9ggAA/YQAAP2GAAD9iAAA/YoAAP2MAAD9jgAA/Z0AAP2gAAD9owAA/aYAAP2pAAD9rAAA/a8AAP2yAAD9tAAA/fMAAP31AAD9+AAA/foAAP39AAD9/gAA/f8AAP4AAAD+AQAA/gMAAP4FAAD+BgAA/gcAAP4JAAD+CgAA/kkAAP5LAAD+TQAA/k8AAP5SAAD+UwAA/lQAAP5VAAD+VgAA/lgAAP5aAAD+WwAA/lwAAP5eAAD+XwAA/p4AAP6gAAD+ogAA/qQAAP6nAAD+qAAA/qkAAP6qAAD+qwAA/q0AAP6vAAD+sAAA/rEAAP6zAAD+tAAA/vMAAP71AAD++AAA/voAAP79AAD+/gAA/v8AAP8AAAD/AQAA/wMAAP8FAAD/BgAA/wcAAP8JAAD/CgAA/0kAAP9LAAD/TQAA/08AAP9SAAD/UwAA/1QAAP9VAAD/VgAA/1gAAP9aAAD/WwAA/1wAAP9eAAD/XwAA/54AAP+gAAD/ogAA/6QAAP+nAAD/qAAA/6kAAP+qAAD/qwAA/60AAP+vAAD/sAAA/7EAAP+zAAD/tAAA//MAAP/1AAD/9wAA//kAAP/8AAD//QAA//4AAP//AAEAAAABAAIAAQAEAAEABQABAAYAAQAIAAEACQABABIAAQATAAEAFQABAFgAAQB8AAEAoAABAMMAAQDqAAEBCgABATEAAQFYAAEBeAABAZwAAQHAAAEBwgABAcUAAQHHAAEByQABAcsAAQHOAAEB0QABAdMAAQHVAAEB2AABAdoAAQHcAAEB3wABAeIAAQHjAAEB6AABAfUAAQH4AAEB+gABAf0AAQIAAAECAgABAicAAQJLAAECcgABApYAAQKZAAECmwABAp0AAQKfAAECoQABAqMAAQKkAAECpwABArQAAQLHAAECyQABAssAAQLNAAECzwABAtEAAQLTAAEC1QABAtcAAQLZAAEC7AABAu8AAQLyAAEC9QABAvgAAQL7AAEC/gABAwEAAQMEAAEDBwABAwkAAQNIAAEDSgABA00AAQNPAAEDUgABA1MAAQNUAAEDVQABA1YAAQNYAAEDWgABA1sAAQNcAAEDXgABA18AAQNoAAEDaQABA2sAAQOqAAEDrAABA64AAQOwAAEDswABA7QAAQO1AAEDtgABA7cAAQO5AAEDuwABA7wAAQO9AAEDvwABA8AAAQP/AAEEAQABBAQAAQQGAAEECQABBAoAAQQLAAEEDAABBA0AAQQPAAEEEQABBBIAAQQTAAEEFQABBBYAAQQfAAEEIAABBCIAAQRhAAEEYwABBGUAAQRnAAEEagABBGsAAQRsAAEEbQABBG4AAQRwAAEEcgABBHMAAQR0AAEEdgABBHcAAQS2AAEEuAABBLsAAQS9AAEEwAABBMEAAQTCAAEEwwABBMQAAQTGAAEEyAABBMkAAQTKAAEEzAABBM0AAQTWAAEE1wABBNkAAQUYAAEFGgABBRwAAQUeAAEFIQABBSIAAQUjAAEFJAABBSUAAQUnAAEFKQABBSoAAQUrAAEFLQABBS4AAQVtAAEFbwABBXIAAQV0AAEFdwABBXgAAQV5AAEFegABBXsAAQV9AAEFfwABBYAAAQWBAAEFgwABBYQAAQWRAAEFkgABBZMAAQWVAAEF1AABBdYAAQXYAAEF2gABBd0AAQXeAAEF3wABBeAAAQXhAAEF4wABBeUAAQXmAAEF5wABBekAAQXqAAEGKQABBisAAQYuAAEGMAABBjMAAQY0AAEGNQABBjYAAQY3AAEGOQABBjsAAQY8AAEGPQABBj8AAQZAAAEGSwABBlgAAQZxAAEGdAABBncAAQZ6AAEGfQABBoAAAQaDAAEGhQABBogAAQaLAAEGjgABBpEAAQaUAAEGrQABBrAAAQazAAEGtgABBrkAAQa8AAEGvwABBsIAAQbFAAEGyAABBssAAQbOAAEG0QABBtMAAQbeAAEG6wABBvEAAQb8AAEHRwABB2oAAQeKAAEHqgABB6wAAQeuAAEHsAABB7IAAQe1AAEHtgABB7cAAQe6AAEHuwABB70AAQe+AAEHwAABB8MAAQfEAAEHxQABB8gAAQfJAAEHzgABB9sAAQfgAAEH4gABB+QAAQfpAAEH7AABB+8AAQfxAAEIFgABCDoAAQhhAAEIhQABCIgAAQiKAAEIjAABCI4AAQiQAAEIkgABCJMAAQiWAAEIowABCLQAAQi2AAEIuAABCLoAAQi8AAEIvgABCMAAAQjCAAEIxAABCNUAAQjYAAEI2wABCN4AAQjhAAEI5AABCOcAAQjqAAEI7QABCO8AAQkuAAEJMAABCTIAAQk0AAEJNwABCTgAAQk5AAEJOgABCTsAAQk9AAEJPwABCUAAAQlBAAEJQwABCUQAAQmDAAEJhQABCYcAAQmJAAEJjAABCY0AAQmOAAEJjwABCZAAAQmSAAEJlAABCZUAAQmWAAEJmAABCZkAAQnYAAEJ2gABCd0AAQnfAAEJ4gABCeMAAQnkAAEJ5QABCeYAAQnoAAEJ6gABCesAAQnsAAEJ7gABCe8AAQn8AAEJ/QABCf4AAQoAAAEKPwABCkEAAQpDAAEKRQABCkgAAQpJAAEKSgABCksAAQpMAAEKTgABClAAAQpRAAEKUgABClQAAQpVAAEKlAABCpYAAQqYAAEKmgABCp0AAQqeAAEKnwABCqAAAQqhAAEKowABCqUAAQqmAAEKpwABCqkAAQqqAAEK6QABCusAAQrtAAEK7wABCvIAAQrzAAEK9AABCvUAAQr2AAEK+AABCvoAAQr7AAEK/AABCv4AAQr/AAELPgABC0AAAQtCAAELRAABC0cAAQtIAAELSQABC0oAAQtLAAELTQABC08AAQtQAAELUQABC1MAAQtUAAELkwABC5UAAQuXAAELmQABC5wAAQudAAELngABC58AAQugAAELogABC6QAAQulAAELpgABC6gAAQupAAELzgABC/IAAQwZAAEMPQABDEAAAQxCAAEMRAABDEYAAQxIAAEMSgABDEsAAQxOAAEMWwABDGoAAQxsAAEMbgABDHAAAQxyAAEMdAABDHYAAQx4AAEMhwABDIoAAQyNAAEMkAABDJMAAQyWAAEMmQABDJwAAQyeAAEM3QABDN8AAQziAAEM5AABDOcAAQzoAAEM6QABDOoAAQzrAAEM7QABDO8AAQzwAAEM8QABDPMAAQz0AAENMwABDTUAAQ03AAENOQABDTwAAQ09AAENPgABDT8AAQ1AAAENQgABDUQAAQ1FAAENRgABDUgAAQ1JAAENiAABDYoAAQ2MAAENjgABDZEAAQ2SAAENkwABDZQAAQ2VAAENlwABDZkAAQ2aAAENmwABDZ0AAQ2eAAEN3QABDd8AAQ3iAAEN5AABDecAAQ3oAAEN6QABDeoAAQ3rAAEN7QABDe8AAQ3wAAEN8QABDfMAAQ30AAEOMwABDjUAAQ43AAEOOQABDjwAAQ49AAEOPgABDj8AAQ5AAAEOQgABDkQAAQ5FAAEORgABDkgAAQ5JAAEOiAABDooAAQ6MAAEOjgABDpEAAQ6SAAEOkwABDpQAAQ6VAAEOlwABDpkAAQ6aAAEOmwABDp0AAQ6eAAEO3QABDt8AAQ7hAAEO4wABDuYAAQ7nAAEO6AABDukAAQ7qAAEO7AABDu4AAQ7vAAEO8AABDvIAAQ7zAAEPPgABD2EAAQ+BAAEPoQABD6MAAQ+lAAEPpwABD6kAAQ+sAAEPrQABD64AAQ+xAAEPsgABD7QAAQ+1AAEPtwABD7oAAQ+7AAEPvAABD78AAQ/AAAEPxQABD9IAAQ/XAAEP2QABD9sAAQ/gAAEP4wABD+YAAQ/oAAEQDQABEDEAARBYAAEQfAABEH8AARCBAAEQgwABEIUAARCHAAEQiQABEIoAARCNAAEQmgABEKsAARCtAAEQrwABELEAARCzAAEQtQABELcAARC5AAEQuwABEMwAARDPAAEQ0gABENUAARDYAAEQ2wABEN4AARDhAAEQ5AABEOYAARElAAERJwABESkAARErAAERLgABES8AAREwAAERMQABETIAARE0AAERNgABETcAARE4AAEROgABETsAARF6AAERfAABEX4AARGAAAERgwABEYQAARGFAAERhgABEYcAARGJAAERiwABEYwAARGNAAERjwABEZAAARHPAAER0QABEdQAARHWAAER2QABEdoAARHbAAER3AABEd0AARHfAAER4QABEeIAARHjAAER5QABEeYAARHzAAER9AABEfUAARH3AAESNgABEjgAARI6AAESPAABEj8AARJAAAESQQABEkIAARJDAAESRQABEkcAARJIAAESSQABEksAARJMAAESiwABEo0AARKPAAESkQABEpQAARKVAAESlgABEpcAARKYAAESmgABEpwAARKdAAESngABEqAAARKhAAES4AABEuIAARLkAAES5gABEukAARLqAAES6wABEuwAARLtAAES7wABEvEAARLyAAES8wABEvUAARL2AAETNQABEzcAARM5AAETOwABEz4AARM/AAETQAABE0EAARNCAAETRAABE0YAARNHAAETSAABE0oAARNLAAETigABE4wAAROOAAETkAABE5MAAROUAAETlQABE5YAAROXAAETmQABE5sAAROcAAETnQABE58AAROgAAETxQABE+kAARQQAAEUNAABFDcAARQ5AAEUOwABFD0AARQ/AAEUQQABFEIAARRFAAEUUgABFGEAARRjAAEUZQABFGcAARRpAAEUawABFG0AARRvAAEUfgABFIEAARSEAAEUhwABFIoAARSNAAEUkAABFJMAARSVAAEU1AABFNYAARTZAAEU2wABFN4AARTfAAEU4AABFOEAARTiAAEU5AABFOYAARTnAAEU6AABFOoAARTrAAEVKgABFSwAARUuAAEVMAABFTMAARU0AAEVNQABFTYAARU3AAEVOQABFTsAARU8AAEVPQABFT8AARVAAAEVfwABFYEAARWDAAEVhQABFYgAARWJAAEVigABFYsAARWMAAEVjgABFZAAARWRAAEVkgABFZQAARWVAAEV1AABFdYAARXZAAEV2wABFd4AARXfAAEV4AABFeEAARXiAAEV5AABFeYAARXnAAEV6AABFeoAARXrAAEWKgABFiwAARYuAAEWMAABFjMAARY0AAEWNQABFjYAARY3AAEWOQABFjsAARY8AAEWPQABFj8AARZAAAEWfwABFoEAARaDAAEWhQABFogAARaJAAEWigABFosAARaMAAEWjgABFpAAARaRAAEWkgABFpQAARaVAAEW1AABFtYAARbYAAEW2gABFt0AARbeAAEW3wABFuAAARbhAAEW4wABFuUAARbmAAEW5wABFukAARbqAAEXNQABF1gAARd4AAEXmAABF5oAARecAAEXngABF6AAARejAAEXpAABF6UAAReoAAEXqQABF6sAAResAAEXrgABF7EAAReyAAEXswABF7YAARe3AAEXwAABF80AARfSAAEX1AABF9YAARfbAAEX3gABF+EAARfjAAEYCAABGCwAARhTAAEYdwABGHoAARh8AAEYfgABGIAAARiCAAEYhAABGIUAARiIAAEYlQABGKYAARioAAEYqgABGKwAARiuAAEYsAABGLIAARi0AAEYtgABGMcAARjKAAEYzQABGNAAARjTAAEY1gABGNkAARjcAAEY3wABGOEAARkgAAEZIgABGSQAARkmAAEZKQABGSoAARkrAAEZLAABGS0AARkvAAEZMQABGTIAARkzAAEZNQABGTYAARl1AAEZdwABGXkAARl7AAEZfgABGX8AARmAAAEZgQABGYIAARmEAAEZhgABGYcAARmIAAEZigABGYsAARnKAAEZzAABGc8AARnRAAEZ1AABGdUAARnWAAEZ1wABGdgAARnaAAEZ3AABGd0AARneAAEZ4AABGeEAARnuAAEZ7wABGfAAARnyAAEaMQABGjMAARo1AAEaNwABGjoAARo7AAEaPAABGj0AARo+AAEaQAABGkIAARpDAAEaRAABGkYAARpHAAEahgABGogAARqKAAEajAABGo8AARqQAAEakQABGpIAARqTAAEalQABGpcAARqYAAEamQABGpsAARqcAAEa2wABGt0AARrfAAEa4QABGuQAARrlAAEa5gABGucAARroAAEa6gABGuwAARrtAAEa7gABGvAAARrxAAEbMAABGzIAARs0AAEbNgABGzkAARs6AAEbOwABGzwAARs9AAEbPwABG0EAARtCAAEbQwABG0UAARtGAAEbhQABG4cAARuJAAEbiwABG44AARuPAAEbkAABG5EAARuSAAEblAABG5YAARuXAAEbmAABG5oAARubAAEbwAABG+QAARwLAAEcLwABHDIAARw0AAEcNgABHDgAARw6AAEcPAABHD0AARxAAAEcTQABHFwAARxeAAEcYAABHGIAARxkAAEcZgABHGgAARxqAAEceQABHHwAARx/AAEcggABHIUAARyIAAEciwABHI4AARyQAAEczwABHNEAARzTAAEc1QABHNgAARzZAAEc2gABHNsAARzcAAEc3gABHOAAARzhAAEc4gABHOQAARzlAAEdJAABHSYAAR0oAAEdKgABHS0AAR0uAAEdLwABHTAAAR0xAAEdMwABHTUAAR02AAEdNwABHTkAAR06AAEdeQABHXsAAR19AAEdfwABHYIAAR2DAAEdhAABHYUAAR2GAAEdiAABHYoAAR2LAAEdjAABHY4AAR2PAAEdzgABHdAAAR3TAAEd1QABHdgAAR3ZAAEd2gABHdsAAR3cAAEd3gABHeAAAR3hAAEd4gABHeQAAR3lAAEeJAABHiYAAR4oAAEeKgABHi0AAR4uAAEeLwABHjAAAR4xAAEeMwABHjUAAR42AAEeNwABHjkAAR46AAEeeQABHnsAAR59AAEefwABHoIAAR6DAAEehAABHoUAAR6GAAEeiAABHooAAR6LAAEejAABHo4AAR6PAAEezgABHtAAAR7SAAEe1AABHtcAAR7YAAEe2QABHtoAAR7bAAEe3QABHt8AAR7gAAEe4QABHuMAAR7kAAEfLwABH1IAAR9yAAEfkgABH5QAAR+WAAEfmAABH5oAAR+dAAEfngABH58AAR+iAAEfowABH6UAAR+mAAEfqAABH6sAAR+sAAEfrQABH7AAAR+xAAEftgABH8MAAR/IAAEfygABH8wAAR/RAAEf1AABH9cAAR/ZAAEf/gABICIAASBJAAEgbQABIHAAASByAAEgdAABIHYAASB4AAEgegABIHsAASB+AAEgiwABIJwAASCeAAEgoAABIKIAASCkAAEgpgABIKgAASCqAAEgrAABIL0AASDAAAEgwwABIMYAASDJAAEgzAABIM8AASDSAAEg1QABINcAASEWAAEhGAABIRoAASEcAAEhHwABISAAASEhAAEhIgABISMAASElAAEhJwABISgAASEpAAEhKwABISwAASFrAAEhbQABIW8AASFxAAEhdAABIXUAASF2AAEhdwABIXgAASF6AAEhfAABIX0AASF+AAEhgAABIYEAASHAAAEhwgABIcUAASHHAAEhygABIcsAASHMAAEhzQABIc4AASHQAAEh0gABIdMAASHUAAEh1gABIdcAASHkAAEh5QABIeYAASHoAAEiJwABIikAASIrAAEiLQABIjAAASIxAAEiMgABIjMAASI0AAEiNgABIjgAASI5AAEiOgABIjwAASI9AAEifAABIn4AASKAAAEiggABIoUAASKGAAEihwABIogAASKJAAEiiwABIo0AASKOAAEijwABIpEAASKSAAEi0QABItMAASLVAAEi1wABItoAASLbAAEi3AABIt0AASLeAAEi4AABIuIAASLjAAEi5AABIuYAASLnAAEjJgABIygAASMqAAEjLAABIy8AASMwAAEjMQABIzIAASMzAAEjNQABIzcAASM4AAEjOQABIzsAASM8AAEjewABI30AASN/AAEjgQABI4QAASOFAAEjhgABI4cAASOIAAEjigABI4wAASONAAEjjgABI5AAASORAAEjtgABI9oAASQBAAEkJQABJCgAASQqAAEkLAABJC4AASQwAAEkMgABJDMAASQ2AAEkQwABJFIAASRUAAEkVgABJFgAASRaAAEkXAABJF4AASRgAAEkbwABJHIAASR1AAEkeAABJHsAASR+AAEkgQABJIQAASSGAAEkxQABJMcAASTKAAEkzAABJM8AASTQAAEk0QABJNIAASTTAAEk1QABJNcAASTYAAEk2QABJNsAASTcAAEk6wABJSoAASUsAAElLgABJTAAASUzAAElNAABJTUAASU2AAElNwABJTkAASU7AAElPAABJT0AASU/AAElQAABJX8AASWBAAElgwABJYUAASWIAAEliQABJYoAASWLAAEljAABJY4AASWQAAElkQABJZIAASWUAAEllQABJdQAASXWAAEl2QABJdsAASXeAAEl3wABJeAAASXhAAEl4gABJeQAASXmAAEl5wABJegAASXqAAEl6wABJioAASYsAAEmLgABJjAAASYzAAEmNAABJjUAASY2AAEmNwABJjkAASY7AAEmPAABJj0AASY/AAEmQAABJn8AASaBAAEmgwABJoUAASaIAAEmiQABJooAASaLAAEmjAABJo4AASaQAAEmkQABJpIAASaUAAEmlQABJtQAASbWAAEm2AABJtoAASbdAAEm3gABJt8AASbgAAEm4QABJuMAASblAAEm5gABJucAASbpAAEm6gABJzUAASdYAAEneAABJ5gAASeaAAEnnAABJ54AASegAAEnowABJ6QAASelAAEnqAABJ6kAASerAAEnrAABJ64AASexAAEnsgABJ7MAASe2AAEntwABJ7wAASfJAAEnzgABJ9AAASfSAAEn1wABJ9oAASfdAAEn3wABKAQAASgoAAEoTwABKHMAASh2AAEoeAABKHoAASh8AAEofgABKIAAASiBAAEohAABKJEAASiiAAEopAABKKYAASioAAEoqgABKKwAASiuAAEosAABKLIAASjDAAEoxgABKMkAASjMAAEozwABKNIAASjVAAEo2AABKNsAASjdAAEpHAABKR4AASkgAAEpIgABKSUAASkmAAEpJwABKSgAASkpAAEpKwABKS0AASkuAAEpLwABKTEAASkyAAEpcQABKXMAASl1AAEpdwABKXoAASl7AAEpfAABKX0AASl+AAEpgAABKYIAASmDAAEphAABKYYAASmHAAEpxgABKcgAASnLAAEpzQABKdAAASnRAAEp0gABKdMAASnUAAEp1gABKdgAASnZAAEp2gABKdwAASndAAEp6gABKesAASnsAAEp7gABKi0AASovAAEqMQABKjMAASo2AAEqNwABKjgAASo5AAEqOgABKjwAASo+AAEqPwABKkAAASpCAAEqQwABKoIAASqEAAEqhgABKogAASqLAAEqjAABKo0AASqOAAEqjwABKpEAASqTAAEqlAABKpUAASqXAAEqmAABKtcAASrZAAEq2wABKt0AASrgAAEq4QABKuIAASrjAAEq5AABKuYAASroAAEq6QABKuoAASrsAAEq7QABKywAASsuAAErMAABKzIAASs1AAErNgABKzcAASs4AAErOQABKzsAASs9AAErPgABKz8AAStBAAErQgABK4EAASuDAAErhQABK4cAASuKAAEriwABK4wAASuNAAErjgABK5AAASuSAAErkwABK5QAASuWAAErlwABK7wAASvgAAEsBwABLCsAASwuAAEsMAABLDIAASw0AAEsNgABLDgAASw5AAEsPAABLEkAASxYAAEsWgABLFwAASxeAAEsYAABLGIAASxkAAEsZgABLHUAASx4AAEsewABLH4AASyBAAEshAABLIcAASyKAAEsjAABLMsAASzNAAEszwABLNEAASzUAAEs1QABLNYAASzXAAEs2AABLNoAASzcAAEs3QABLN4AASzgAAEs4QABLSAAAS0iAAEtJAABLSYAAS0pAAEtKgABLSsAAS0sAAEtLQABLS8AAS0xAAEtMgABLTMAAS01AAEtNgABLXUAAS13AAEteQABLXsAAS1+AAEtfwABLYAAAS2BAAEtggABLYQAAS2GAAEthwABLYgAAS2KAAEtiwABLcoAAS3MAAEtzwABLdEAAS3UAAEt1QABLdYAAS3XAAEt2AABLdoAAS3cAAEt3QABLd4AAS3gAAEt4QABLiAAAS4iAAEuJAABLiYAAS4pAAEuKgABLisAAS4sAAEuLQABLi8AAS4xAAEuMgABLjMAAS41AAEuNgABLnUAAS53AAEueQABLnsAAS5+AAEufwABLoAAAS6BAAEuggABLoQAAS6GAAEuhwABLogAAS6KAAEuiwABLsoAAS7MAAEuzgABLtAAAS7TAAEu1AABLtUAAS7WAAEu1wABLtkAAS7bAAEu3AABLt0AAS7fAAEu4AABLysAAS9OAAEvbgABL44AAS+QAAEvkgABL5QAAS+WAAEvmQABL5oAAS+bAAEvngABL58AAS+hAAEvogABL6QAAS+nAAEvqAABL6kAAS+sAAEvrQABL7IAAS+/AAEvxAABL8YAAS/IAAEvzQABL9AAAS/TAAEv1QABL/oAATAeAAEwRQABMGkAATBsAAEwbgABMHAAATByAAEwdAABMHYAATB3AAEwegABMIcAATCYAAEwmgABMJwAATCeAAEwoAABMKIAATCkAAEwpgABMKgAATC5AAEwvAABML8AATDCAAEwxQABMMgAATDLAAEwzgABMNEAATDTAAExEgABMRQAATEWAAExGAABMRsAATEcAAExHQABMR4AATEfAAExIQABMSMAATEkAAExJQABMScAATEoAAExZwABMWkAATFrAAExbQABMXAAATFxAAExcgABMXMAATF0AAExdgABMXgAATF5AAExegABMXwAATF9AAExvAABMb4AATHBAAExwwABMcYAATHHAAExyAABMckAATHKAAExzAABMc4AATHPAAEx0AABMdIAATHTAAEx4AABMeEAATHiAAEx5AABMiMAATIlAAEyJwABMikAATIsAAEyLQABMi4AATIvAAEyMAABMjIAATI0AAEyNQABMjYAATI4AAEyOQABMngAATJ6AAEyfAABMn4AATKBAAEyggABMoMAATKEAAEyhQABMocAATKJAAEyigABMosAATKNAAEyjgABMs0AATLPAAEy0QABMtMAATLWAAEy1wABMtgAATLZAAEy2gABMtwAATLeAAEy3wABMuAAATLiAAEy4wABMyIAATMkAAEzJgABMygAATMrAAEzLAABMy0AATMuAAEzLwABMzEAATMzAAEzNAABMzUAATM3AAEzOAABM3cAATN5AAEzewABM30AATOAAAEzgQABM4IAATODAAEzhAABM4YAATOIAAEziQABM4oAATOMAAEzjQABM7IAATPWAAEz/QABNCEAATQkAAE0JgABNCgAATQqAAE0LAABNC4AATQvAAE0MgABND8AATROAAE0UAABNFIAATRUAAE0VgABNFgAATRaAAE0XAABNGsAATRuAAE0cQABNHQAATR3AAE0egABNH0AATSAAAE0ggABNMEAATTDAAE0xgABNMgAATTLAAE0zAABNM0AATTOAAE0zwABNNEAATTTAAE01AABNNUAATTXAAE02AABNRcAATUZAAE1GwABNR0AATUgAAE1IQABNSIAATUjAAE1JAABNSYAATUoAAE1KQABNSoAATUsAAE1LQABNWwAATVuAAE1cAABNXIAATV1AAE1dgABNXcAATV4AAE1eQABNXsAATV9AAE1fgABNX8AATWBAAE1ggABNcEAATXDAAE1xgABNcgAATXLAAE1zAABNc0AATXOAAE1zwABNdEAATXTAAE11AABNdUAATXXAAE12AABNhcAATYZAAE2GwABNh0AATYgAAE2IQABNiIAATYjAAE2JAABNiYAATYoAAE2KQABNioAATYsAAE2LQABNmwAATZuAAE2cAABNnIAATZ1AAE2dgABNncAATZ4AAE2eQABNnsAATZ9AAE2fgABNn8AATaBAAE2ggABNsEAATbDAAE2xQABNscAATbKAAE2ywABNswAATbNAAE2zgABNtAAATbSAAE20wABNtQAATbWAAE21wABNyIAATdFAAE3ZQABN4UAATeHAAE3iQABN4sAATeNAAE3kAABN5EAATeSAAE3lQABN5YAATeYAAE3mQABN5sAATedAAE3ngABN58AATeiAAE3owABN6gAATe1AAE3ugABN7wAATe+AAE3wwABN8YAATfJAAE3ywABN/AAATgUAAE4OwABOF8AAThiAAE4ZAABOGYAAThoAAE4agABOGwAAThtAAE4cAABOH0AATiOAAE4kAABOJIAATiUAAE4lgABOJgAATiaAAE4nAABOJ4AATivAAE4sgABOLUAATi4AAE4uwABOL4AATjBAAE4xAABOMcAATjJAAE5CAABOQoAATkMAAE5DgABOREAATkSAAE5EwABORQAATkVAAE5FwABORkAATkaAAE5GwABOR0AATkeAAE5XQABOV8AATlhAAE5YwABOWYAATlnAAE5aAABOWkAATlqAAE5bAABOW4AATlvAAE5cAABOXIAATlzAAE5sgABObQAATm3AAE5uQABObwAATm9AAE5vgABOb8AATnAAAE5wgABOcQAATnFAAE5xgABOcgAATnJAAE51gABOdcAATnYAAE52gABOhkAATobAAE6HQABOh8AAToiAAE6IwABOiQAATolAAE6JgABOigAAToqAAE6KwABOiwAATouAAE6LwABOm4AATpwAAE6cgABOnQAATp3AAE6eAABOnkAATp6AAE6ewABOn0AATp/AAE6gAABOoEAATqDAAE6hAABOsMAATrFAAE6xwABOskAATrMAAE6zQABOs4AATrPAAE60AABOtIAATrUAAE61QABOtYAATrYAAE62QABOxgAATsaAAE7HAABOx4AATshAAE7IgABOyMAATskAAE7JQABOycAATspAAE7KgABOysAATstAAE7LgABO20AATtvAAE7cQABO3MAATt2AAE7dwABO3gAATt5AAE7egABO3wAATt+AAE7fwABO4AAATuCAAE7gwABO6gAATvMAAE78wABPBcAATwaAAE8HAABPB4AATwgAAE8IgABPCQAATwlAAE8KAABPDUAATxEAAE8RgABPEgAATxKAAE8TAABPE4AATxQAAE8UgABPGEAATxkAAE8ZwABPGoAATxtAAE8cAABPHMAATx2AAE8eAABPLcAATy5AAE8vAABPL4AATzBAAE8wgABPMMAATzEAAE8xQABPMcAATzJAAE8ygABPMsAATzNAAE8zgABPQ0AAT0PAAE9EQABPRMAAT0WAAE9FwABPRgAAT0ZAAE9GgABPRwAAT0eAAE9HwABPSAAAT0iAAE9IwABPWIAAT1kAAE9ZgABPWgAAT1rAAE9bAABPW0AAT1uAAE9bwABPXEAAT1zAAE9dAABPXUAAT13AAE9eAABPbcAAT25AAE9vAABPb4AAT3BAAE9wgABPcMAAT3EAAE9xQABPccAAT3JAAE9ygABPcsAAT3NAAE9zgABPg0AAT4PAAE+EQABPhMAAT4WAAE+FwABPhgAAT4ZAAE+GgABPhwAAT4eAAE+HwABPiAAAT4iAAE+IwABPmIAAT5kAAE+ZgABPmgAAT5rAAE+bAABPm0AAT5uAAE+bwABPnEAAT5zAAE+dAABPnUAAT53AAE+eAABPrcAAT65AAE+uwABPr0AAT7AAAE+wQABPsIAAT7DAAE+xAABPsYAAT7IAAE+yQABPsoAAT7MAAE+zQABPxgAAT87AAE/WwABP3sAAT99AAE/fwABP4EAAT+DAAE/hgABP4cAAT+IAAE/iwABP4wAAT+OAAE/jwABP5EAAT+UAAE/lQABP5YAAT+ZAAE/mgABP58AAT+sAAE/sQABP7MAAT+1AAE/ugABP70AAT/AAAE/wgABP+cAAUALAAFAMgABQFYAAUBZAAFAWwABQF0AAUBfAAFAYQABQGMAAUBkAAFAZwABQHQAAUCFAAFAhwABQIkAAUCLAAFAjQABQI8AAUCRAAFAkwABQJUAAUCmAAFAqQABQKwAAUCvAAFAsgABQLUAAUC4AAFAuwABQL4AAUDAAAFA/wABQQEAAUEDAAFBBQABQQgAAUEJAAFBCgABQQsAAUEMAAFBDgABQRAAAUERAAFBEgABQRQAAUEVAAFBVAABQVYAAUFYAAFBWgABQV0AAUFeAAFBXwABQWAAAUFhAAFBYwABQWUAAUFmAAFBZwABQWkAAUFqAAFBqQABQasAAUGuAAFBsAABQbMAAUG0AAFBtQABQbYAAUG3AAFBuQABQbsAAUG8AAFBvQABQb8AAUHAAAFBzQABQc4AAUHPAAFB0QABQhAAAUISAAFCFAABQhYAAUIZAAFCGgABQhsAAUIcAAFCHQABQh8AAUIhAAFCIgABQiMAAUIlAAFCJgABQmUAAUJnAAFCaQABQmsAAUJuAAFCbwABQnAAAUJxAAFCcgABQnQAAUJ2AAFCdwABQngAAUJ6AAFCewABQroAAUK8AAFCvgABQsAAAULDAAFCxAABQsUAAULGAAFCxwABQskAAULLAAFCzAABQs0AAULPAAFC0AABQw8AAUMRAAFDEwABQxUAAUMYAAFDGQABQxoAAUMbAAFDHAABQx4AAUMgAAFDIQABQyIAAUMkAAFDJQABQ2QAAUNmAAFDaAABQ2oAAUNtAAFDbgABQ28AAUNwAAFDcQABQ3MAAUN1AAFDdgABQ3cAAUN5AAFDegABQ58AAUPDAAFD6gABRA4AAUQRAAFEEwABRBUAAUQXAAFEGQABRBsAAUQcAAFEHwABRCwAAUQ7AAFEPQABRD8AAURBAAFEQwABREUAAURHAAFESQABRFgAAURbAAFEXgABRGEAAURkAAFEZwABRGoAAURtAAFEbwABRK4AAUSwAAFEswABRLUAAUS4AAFEuQABRLoAAUS7AAFEvAABRL4AAUTAAAFEwQABRMIAAUTEAAFExQABRQQAAUUGAAFFCAABRQoAAUUNAAFFDgABRQ8AAUUQAAFFEQABRRMAAUUVAAFFFgABRRcAAUUZAAFFGgABRVkAAUVbAAFFXQABRV8AAUViAAFFYwABRWQAAUVlAAFFZgABRWgAAUVqAAFFawABRWwAAUVuAAFFbwABRa4AAUWwAAFFswABRbUAAUW4AAFFuQABRboAAUW7AAFFvAABRb4AAUXAAAFFwQABRcIAAUXEAAFFxQABRgQAAUYGAAFGCAABRgoAAUYNAAFGDgABRg8AAUYQAAFGEQABRhMAAUYVAAFGFgABRhcAAUYZAAFGGgABRlkAAUZbAAFGXQABRl8AAUZiAAFGYwABRmQAAUZlAAFGZgABRmgAAUZqAAFGawABRmwAAUZuAAFGbwABRq4AAUawAAFGsgABRrQAAUa3AAFGuAABRrkAAUa6AAFGuwABRr0AAUa/AAFGwAABRsEAAUbDAAFGxAABRw8AAUcyAAFHUgABR3IAAUd0AAFHdgABR3gAAUd6AAFHfQABR34AAUd/AAFHggABR4MAAUeFAAFHhgABR4gAAUeLAAFHjAABR40AAUeQAAFHkQABR5YAAUejAAFHqAABR6oAAUesAAFHsQABR7QAAUe3AAFHuQABR94AAUgCAAFIKQABSE0AAUhQAAFIUgABSFQAAUhWAAFIWAABSFoAAUhbAAFIXgABSGsAAUh8AAFIfgABSIAAAUiCAAFIhAABSIYAAUiIAAFIigABSIwAAUidAAFIoAABSKMAAUimAAFIqQABSKwAAUivAAFIsgABSLUAAUi3AAFI9gABSPgAAUj6AAFI/AABSP8AAUkAAAFJAQABSQIAAUkDAAFJBQABSQcAAUkIAAFJCQABSQsAAUkMAAFJSwABSU0AAUlPAAFJUQABSVQAAUlVAAFJVgABSVcAAUlYAAFJWgABSVwAAUldAAFJXgABSWAAAUlhAAFJoAABSaIAAUmlAAFJpwABSaoAAUmrAAFJrAABSa0AAUmuAAFJsAABSbIAAUmzAAFJtAABSbYAAUm3AAFJxAABScUAAUnGAAFJyAABSgcAAUoJAAFKCwABSg0AAUoQAAFKEQABShIAAUoTAAFKFAABShYAAUoYAAFKGQABShoAAUocAAFKHQABSlwAAUpeAAFKYAABSmIAAUplAAFKZgABSmcAAUpoAAFKaQABSmsAAUptAAFKbgABSm8AAUpxAAFKcgABSrEAAUqzAAFKtQABSrcAAUq6AAFKuwABSrwAAUq9AAFKvgABSsAAAUrCAAFKwwABSsQAAUrGAAFKxwABSwYAAUsIAAFLCgABSwwAAUsPAAFLEAABSxEAAUsSAAFLEwABSxUAAUsXAAFLGAABSxkAAUsbAAFLHAABS1sAAUtdAAFLXwABS2EAAUtkAAFLZQABS2YAAUtnAAFLaAABS2oAAUtsAAFLbQABS24AAUtwAAFLcQABS5YAAUu6AAFL4QABTAUAAUwIAAFMCgABTAwAAUwOAAFMEAABTBIAAUwTAAFMFgABTCMAAUwyAAFMNAABTDYAAUw4AAFMOgABTDwAAUw+AAFMQAABTE8AAUxSAAFMVQABTFgAAUxbAAFMXgABTGEAAUxkAAFMZgABTKUAAUynAAFMqQABTKsAAUyuAAFMrwABTLAAAUyxAAFMsgABTLQAAUy2AAFMtwABTLgAAUy6AAFMuwABTPoAAUz8AAFM/gABTQAAAU0DAAFNBAABTQUAAU0GAAFNBwABTQkAAU0LAAFNDAABTQ0AAU0PAAFNEAABTU8AAU1RAAFNUwABTVUAAU1YAAFNWQABTVoAAU1bAAFNXAABTV4AAU1gAAFNYQABTWIAAU1kAAFNZQABTaQAAU2mAAFNqQABTasAAU2uAAFNrwABTbAAAU2xAAFNsgABTbQAAU22AAFNtwABTbgAAU26AAFNuwABTfoAAU38AAFN/gABTgAAAU4DAAFOBAABTgUAAU4GAAFOBwABTgkAAU4LAAFODAABTg0AAU4PAAFOEAABTk8AAU5RAAFOUwABTlUAAU5YAAFOWQABTloAAU5bAAFOXAABTl4AAU5gAAFOYQABTmIAAU5kAAFOZQABTqQAAU6mAAFOqAABTqoAAU6tAAFOrgABTq8AAU6wAAFOsQABTrMAAU61AAFOtgABTrcAAU65AAFOugABTwUAAU8oAAFPSAABT2gAAU9qAAFPbAABT24AAU9wAAFPcwABT3QAAU91AAFPeAABT3kAAU97AAFPfAABT34AAU+BAAFPggABT4MAAU+GAAFPhwABT4wAAU+ZAAFPngABT6AAAU+iAAFPpwABT6oAAU+tAAFPrwABT9QAAU/4AAFQHwABUEMAAVBGAAFQSAABUEoAAVBMAAFQTgABUFAAAVBRAAFQVAABUGEAAVByAAFQdAABUHYAAVB4AAFQegABUHwAAVB+AAFQgAABUIIAAVCTAAFQlgABUJkAAVCcAAFQnwABUKIAAVClAAFQqAABUKsAAVCtAAFQ7AABUO4AAVDwAAFQ8gABUPUAAVD2AAFQ9wABUPgAAVD5AAFQ+wABUP0AAVD+AAFQ/wABUQEAAVECAAFRQQABUUMAAVFFAAFRRwABUUoAAVFLAAFRTAABUU0AAVFOAAFRUAABUVIAAVFTAAFRVAABUVYAAVFXAAFRlgABUZgAAVGbAAFRnQABUaAAAVGhAAFRogABUaMAAVGkAAFRpgABUagAAVGpAAFRqgABUawAAVGtAAFRugABUbsAAVG8AAFRvgABUf0AAVH/AAFSAQABUgMAAVIGAAFSBwABUggAAVIJAAFSCgABUgwAAVIOAAFSDwABUhAAAVISAAFSEwABUlIAAVJUAAFSVgABUlgAAVJbAAFSXAABUl0AAVJeAAFSXwABUmEAAVJjAAFSZAABUmUAAVJnAAFSaAABUqcAAVKpAAFSqwABUq0AAVKwAAFSsQABUrIAAVKzAAFStAABUrYAAVK4AAFSuQABUroAAVK8AAFSvQABUvwAAVL+AAFTAAABUwIAAVMFAAFTBgABUwcAAVMIAAFTCQABUwsAAVMNAAFTDgABUw8AAVMRAAFTEgABU1EAAVNTAAFTVQABU1cAAVNaAAFTWwABU1wAAVNdAAFTXgABU2AAAVNiAAFTYwABU2QAAVNmAAFTZwABU4wAAVOwAAFT1wABU/sAAVP+AAFUAAABVAIAAVQEAAFUBgABVAgAAVQJAAFUDAABVBkAAVQoAAFUKgABVCwAAVQuAAFUMAABVDIAAVQ0AAFUNgABVEUAAVRIAAFUSwABVE4AAVRRAAFUVAABVFcAAVRaAAFUXAABVJsAAVSdAAFUnwABVKEAAVSkAAFUpQABVKYAAVSnAAFUqAABVKoAAVSsAAFUrQABVK4AAVSwAAFUsQABVPAAAVTyAAFU9AABVPYAAVT5AAFU+gABVPsAAVT8AAFU/QABVP8AAVUBAAFVAgABVQMAAVUFAAFVBgABVUUAAVVHAAFVSQABVUsAAVVOAAFVTwABVVAAAVVRAAFVUgABVVQAAVVWAAFVVwABVVgAAVVaAAFVWwABVZoAAVWcAAFVngABVaAAAVWjAAFVpAABVaUAAVWmAAFVpwABVakAAVWrAAFVrAABVa0AAVWvAAFVsAABVe8AAVXxAAFV8wABVfUAAVX4AAFV+QABVfoAAVX7AAFV/AABVf4AAVYAAAFWAQABVgIAAVYEAAFWBQABVkQAAVZGAAFWSAABVkoAAVZNAAFWTgABVk8AAVZQAAFWUQABVlMAAVZVAAFWVgABVlcAAVZZAAFWWgABVpkAAVabAAFWnQABVp8AAVaiAAFWowABVqQAAValAAFWpgABVqgAAVaqAAFWqwABVqwAAVauAAFWrwABVvoAAVcdAAFXPQABV10AAVdfAAFXYQABV2MAAVdlAAFXaAABV2kAAVdqAAFXbQABV24AAVdwAAFXcQABV3MAAVd2AAFXdwABV3gAAVd7AAFXfAABV4EAAVeOAAFXkwABV5UAAVeXAAFXnAABV58AAVeiAAFXpAABV8kAAVftAAFYFAABWDgAAVg7AAFYPQABWD8AAVhBAAFYQwABWEUAAVhGAAFYSQABWFYAAVhnAAFYaQABWGsAAVhtAAFYbwABWHEAAVhzAAFYdQABWHcAAViIAAFYiwABWI4AAViRAAFYlAABWJcAAViaAAFYnQABWKAAAViiAAFY4QABWOMAAVjlAAFY5wABWOoAAVjrAAFY7AABWO0AAVjuAAFY8AABWPIAAVjzAAFY9AABWPYAAVj3AAFZNgABWTgAAVk6AAFZPAABWT8AAVlAAAFZQQABWUIAAVlDAAFZRQABWUcAAVlIAAFZSQABWUsAAVlMAAFZiwABWY0AAVmQAAFZkgABWZUAAVmWAAFZlwABWZgAAVmZAAFZmwABWZ0AAVmeAAFZnwABWaEAAVmiAAFZrwABWbAAAVmxAAFZswABWfIAAVn0AAFZ9gABWfgAAVn7AAFZ/AABWf0AAVn+AAFZ/wABWgEAAVoDAAFaBAABWgUAAVoHAAFaCAABWkcAAVpJAAFaSwABWk0AAVpQAAFaUQABWlIAAVpTAAFaVAABWlYAAVpYAAFaWQABWloAAVpcAAFaXQABWpwAAVqeAAFaoAABWqIAAVqlAAFapgABWqcAAVqoAAFaqQABWqsAAVqtAAFargABWq8AAVqxAAFasgABWvEAAVrzAAFa9QABWvcAAVr6AAFa+wABWvwAAVr9AAFa/gABWwAAAVsCAAFbAwABWwQAAVsGAAFbBwABW0YAAVtIAAFbSgABW0wAAVtPAAFbUAABW1EAAVtSAAFbUwABW1UAAVtXAAFbWAABW1kAAVtbAAFbXAABW4EAAVulAAFbzAABW/AAAVvzAAFb9QABW/cAAVv5AAFb+wABW/0AAVv+AAFcAQABXA4AAVwdAAFcHwABXCEAAVwjAAFcJQABXCcAAVwpAAFcKwABXDoAAVw9AAFcQAABXEMAAVxGAAFcSQABXEwAAVxPAAFcUQABXJAAAVySAAFclQABXJcAAVyaAAFcmwABXJwAAVydAAFcngABXKAAAVyiAAFcowABXKQAAVymAAFcpwABXOYAAVzoAAFc6gABXOwAAVzvAAFc8AABXPEAAVzyAAFc8wABXPUAAVz3AAFc+AABXPkAAVz7AAFc/AABXTsAAV09AAFdPwABXUEAAV1EAAFdRQABXUYAAV1HAAFdSAABXUoAAV1MAAFdTQABXU4AAV1QAAFdUQABXZAAAV2SAAFdlQABXZcAAV2aAAFdmwABXZwAAV2dAAFdngABXaAAAV2iAAFdowABXaQAAV2mAAFdpwABXeYAAV3oAAFd6gABXewAAV3vAAFd8AABXfEAAV3yAAFd8wABXfUAAV33AAFd+AABXfkAAV37AAFd/AABXjsAAV49AAFePwABXkEAAV5EAAFeRQABXkYAAV5HAAFeSAABXkoAAV5MAAFeTQABXk4AAV5QAAFeUQABXpAAAV6SAAFelAABXpYAAV6ZAAFemgABXpsAAV6cAAFenQABXp8AAV6hAAFeogABXqMAAV6lAAFepgABXvEAAV8UAAFfNAABX1QAAV9WAAFfWAABX1oAAV9cAAFfXwABX2AAAV9hAAFfZAABX2UAAV9nAAFfaAABX2oAAV9tAAFfbgABX28AAV9yAAFfcwABX3gAAV+FAAFfigABX4wAAV+OAAFfkwABX5YAAV+ZAAFfmwABX8AAAV/kAAFgCwABYC8AAWAyAAFgNAABYDYAAWA4AAFgOgABYDwAAWA9AAFgQAABYE0AAWBeAAFgYAABYGIAAWBkAAFgZgABYGgAAWBqAAFgbAABYG4AAWB/AAFgggABYIUAAWCIAAFgiwABYI4AAWCRAAFglAABYJcAAWCZAAFg2AABYNoAAWDcAAFg3gABYOEAAWDiAAFg4wABYOQAAWDlAAFg5wABYOkAAWDqAAFg6wABYO0AAWDuAAFhLQABYS8AAWExAAFhMwABYTYAAWE3AAFhOAABYTkAAWE6AAFhPAABYT4AAWE/AAFhQAABYUIAAWFDAAFhggABYYQAAWGHAAFhiQABYYwAAWGNAAFhjgABYY8AAWGQAAFhkgABYZQAAWGVAAFhlgABYZgAAWGZAAFhpgABYacAAWGoAAFhqgABYekAAWHrAAFh7QABYe8AAWHyAAFh8wABYfQAAWH1AAFh9gABYfgAAWH6AAFh+wABYfwAAWH+AAFh/wABYj4AAWJAAAFiQgABYkQAAWJHAAFiSAABYkkAAWJKAAFiSwABYk0AAWJPAAFiUAABYlEAAWJTAAFiVAABYpMAAWKVAAFilwABYpkAAWKcAAFinQABYp4AAWKfAAFioAABYqIAAWKkAAFipQABYqYAAWKoAAFiqQABYugAAWLqAAFi7AABYu4AAWLxAAFi8gABYvMAAWL0AAFi9QABYvcAAWL5AAFi+gABYvsAAWL9AAFi/gABYz0AAWM/AAFjQQABY0MAAWNGAAFjRwABY0gAAWNJAAFjSgABY0wAAWNOAAFjTwABY1AAAWNSAAFjUwABY3gAAWOcAAFjwwABY+cAAWPqAAFj7AABY+4AAWPwAAFj8gABY/QAAWP1AAFj+AABZAUAAWQUAAFkFgABZBgAAWQaAAFkHAABZB4AAWQgAAFkIgABZDEAAWQ0AAFkNwABZDoAAWQ9AAFkQAABZEMAAWRGAAFkSAABZIcAAWSJAAFkjAABZI4AAWSRAAFkkgABZJMAAWSUAAFklQABZJcAAWSZAAFkmgABZJsAAWSdAAFkngABZN0AAWTfAAFk4QABZOMAAWTmAAFk5wABZOgAAWTpAAFk6gABZOwAAWTuAAFk7wABZPAAAWTyAAFk8wABZTIAAWU0AAFlNgABZTgAAWU7AAFlPAABZT0AAWU+AAFlPwABZUEAAWVDAAFlRAABZUUAAWVHAAFlSAABZYcAAWWJAAFljAABZY4AAWWRAAFlkgABZZMAAWWUAAFllQABZZcAAWWZAAFlmgABZZsAAWWdAAFlngABZd0AAWXfAAFl4QABZeMAAWXmAAFl5wABZegAAWXpAAFl6gABZewAAWXuAAFl7wABZfAAAWXyAAFl8wABZjIAAWY0AAFmNgABZjgAAWY7AAFmPAABZj0AAWY+AAFmPwABZkEAAWZDAAFmRAABZkUAAWZHAAFmSAABZocAAWaJAAFmiwABZo0AAWaQAAFmkQABZpIAAWaTAAFmlAABZpYAAWaYAAFmmQABZpoAAWacAAFmnQABZqYAAWanAAFmqQABZuwAAWcQAAFnNAABZ1cAAWd+AAFnngABZ8UAAWfsAAFoDAABaDAAAWhUAAFoVgABaFkAAWhbAAFoXQABaF8AAWhiAAFoZQABaGcAAWhpAAFobAABaG4AAWhwAAFocwABaHYAAWh3AAFogAABaI0AAWiQAAFokgABaJUAAWiYAAFomgABaL8AAWjjAAFpCgABaS4AAWkxAAFpMwABaTUAAWk3AAFpOQABaTsAAWk8AAFpPwABaUwAAWlfAAFpYQABaWMAAWllAAFpZwABaWkAAWlrAAFpbQABaW8AAWlxAAFphAABaYcAAWmKAAFpjQABaZAAAWmTAAFplgABaZkAAWmcAAFpnwABaaEAAWngAAFp4gABaeUAAWnnAAFp6gABaesAAWnsAAFp7QABae4AAWnwAAFp8gABafMAAWn0AAFp9gABafcAAWoAAAFqAQABagMAAWpCAAFqRAABakYAAWpIAAFqSwABakwAAWpNAAFqTgABak8AAWpRAAFqUwABalQAAWpVAAFqVwABalgAAWqXAAFqmQABapwAAWqeAAFqoQABaqIAAWqjAAFqpAABaqUAAWqnAAFqqQABaqoAAWqrAAFqrQABaq4AAWq3AAFquAABaroAAWr5AAFq+wABav0AAWr/AAFrAgABawMAAWsEAAFrBQABawYAAWsIAAFrCgABawsAAWsMAAFrDgABaw8AAWtOAAFrUAABa1MAAWtVAAFrWAABa1kAAWtaAAFrWwABa1wAAWteAAFrYAABa2EAAWtiAAFrZAABa2UAAWtuAAFrbwABa3EAAWuwAAFrsgABa7QAAWu2AAFruQABa7oAAWu7AAFrvAABa70AAWu/AAFrwQABa8IAAWvDAAFrxQABa8YAAWwFAAFsBwABbAoAAWwMAAFsDwABbBAAAWwRAAFsEgABbBMAAWwVAAFsFwABbBgAAWwZAAFsGwABbBwAAWwpAAFsKgABbCsAAWwtAAFsbAABbG4AAWxwAAFscgABbHUAAWx2AAFsdwABbHgAAWx5AAFsewABbH0AAWx+AAFsfwABbIEAAWyCAAFswQABbMMAAWzGAAFsyAABbMsAAWzMAAFszQABbM4AAWzPAAFs0QABbNMAAWzUAAFs1QABbNcAAWzYAAFs4gABbO8AAWz+AAFtAQABbQQAAW0HAAFtCgABbQ0AAW0QAAFtEwABbSIAAW0lAAFtKAABbSsAAW0uAAFtMQABbTQAAW03AAFtOQABbUwAAW1eAAFtawABbXgAAW2GAAFtjgABbZMAAW3eAAFuAQABbiEAAW5BAAFuQwABbkUAAW5HAAFuSQABbkwAAW5NAAFuTgABblEAAW5SAAFuVAABblUAAW5XAAFuWgABblsAAW5cAAFuXwABbmAAAW5lAAFucgABbncAAW55AAFuewABboAAAW6DAAFuhgABbogAAW6tAAFu0QABbvgAAW8cAAFvHwABbyEAAW8jAAFvJQABbycAAW8pAAFvKgABby0AAW86AAFvSwABb00AAW9PAAFvUQABb1MAAW9VAAFvVwABb1kAAW9bAAFvbAABb28AAW9yAAFvdQABb3gAAW97AAFvfgABb4EAAW+EAAFvhgABb8UAAW/HAAFvyQABb8sAAW/OAAFvzwABb9AAAW/RAAFv0gABb9QAAW/WAAFv1wABb9gAAW/aAAFv2wABcBoAAXAcAAFwHgABcCAAAXAjAAFwJAABcCUAAXAmAAFwJwABcCkAAXArAAFwLAABcC0AAXAvAAFwMAABcG8AAXBxAAFwdAABcHYAAXB5AAFwegABcHsAAXB8AAFwfQABcH8AAXCBAAFwggABcIMAAXCFAAFwhgABcJMAAXCUAAFwlQABcJcAAXDWAAFw2AABcNoAAXDcAAFw3wABcOAAAXDhAAFw4gABcOMAAXDlAAFw5wABcOgAAXDpAAFw6wABcOwAAXErAAFxLQABcS8AAXExAAFxNAABcTUAAXE2AAFxNwABcTgAAXE6AAFxPAABcT0AAXE+AAFxQAABcUEAAXGAAAFxggABcYQAAXGGAAFxiQABcYoAAXGLAAFxjAABcY0AAXGPAAFxkQABcZIAAXGTAAFxlQABcZYAAXHVAAFx1wABcdkAAXHbAAFx3gABcd8AAXHgAAFx4QABceIAAXHkAAFx5gABcecAAXHoAAFx6gABcesAAXIqAAFyLAABci4AAXIwAAFyMwABcjQAAXI1AAFyNgABcjcAAXI5AAFyOwABcjwAAXI9AAFyPwABckAAAXJlAAFyiQABcrAAAXLUAAFy1wABctkAAXLbAAFy3QABct8AAXLhAAFy4gABcuUAAXLyAAFzAQABcwMAAXMFAAFzBwABcwkAAXMLAAFzDQABcw8AAXMeAAFzIQABcyQAAXMnAAFzKgABcy0AAXMwAAFzMwABczUAAXN0AAFzdgABc3gAAXN6AAFzfQABc34AAXN/AAFzgAABc4EAAXODAAFzhQABc4YAAXOHAAFziQABc4oAAXPJAAFzywABc80AAXPPAAFz0gABc9MAAXPUAAFz1QABc9YAAXPYAAFz2gABc9sAAXPcAAFz3gABc98AAXQeAAF0IAABdCIAAXQkAAF0JwABdCgAAXQpAAF0KgABdCsAAXQtAAF0LwABdDAAAXQxAAF0MwABdDQAAXRzAAF0dQABdHcAAXR5AAF0fAABdH0AAXR+AAF0fwABdIAAAXSCAAF0hAABdIUAAXSGAAF0iAABdIkAAXTIAAF0ygABdMwAAXTOAAF00QABdNIAAXTTAAF01AABdNUAAXTXAAF02QABdNoAAXTbAAF03QABdN4AAXUdAAF1HwABdSEAAXUjAAF1JgABdScAAXUoAAF1KQABdSoAAXUsAAF1LgABdS8AAXUwAAF1MgABdTMAAXVyAAF1dAABdXYAAXV4AAF1ewABdXwAAXV9AAF1fgABdX8AAXWBAAF1gwABdYQAAXWFAAF1hwABdYgAAXXTAAF19gABdhYAAXY2AAF2OAABdjoAAXY8AAF2PgABdkEAAXZCAAF2QwABdkYAAXZHAAF2SQABdkoAAXZMAAF2TwABdlAAAXZRAAF2VAABdlUAAXZaAAF2ZwABdmwAAXZuAAF2cAABdnUAAXZ4AAF2ewABdn0AAXaiAAF2xgABdu0AAXcRAAF3FAABdxYAAXcYAAF3GgABdxwAAXceAAF3HwABdyIAAXcvAAF3QAABd0IAAXdEAAF3RgABd0gAAXdKAAF3TAABd04AAXdQAAF3YQABd2QAAXdnAAF3agABd20AAXdwAAF3cwABd3YAAXd5AAF3ewABd7oAAXe8AAF3vgABd8AAAXfDAAF3xAABd8UAAXfGAAF3xwABd8kAAXfLAAF3zAABd80AAXfPAAF30AABeA8AAXgRAAF4EwABeBUAAXgYAAF4GQABeBoAAXgbAAF4HAABeB4AAXggAAF4IQABeCIAAXgkAAF4JQABeGQAAXhmAAF4aQABeGsAAXhuAAF4bwABeHAAAXhxAAF4cgABeHQAAXh2AAF4dwABeHgAAXh6AAF4ewABeIgAAXiJAAF4igABeIwAAXjLAAF4zQABeM8AAXjRAAF41AABeNUAAXjWAAF41wABeNgAAXjaAAF43AABeN0AAXjeAAF44AABeOEAAXkgAAF5IgABeSQAAXkmAAF5KQABeSoAAXkrAAF5LAABeS0AAXkvAAF5MQABeTIAAXkzAAF5NQABeTYAAXl1AAF5dwABeXkAAXl7AAF5fgABeX8AAXmAAAF5gQABeYIAAXmEAAF5hgABeYcAAXmIAAF5igABeYsAAXnKAAF5zAABec4AAXnQAAF50wABedQAAXnVAAF51gABedcAAXnZAAF52wABedwAAXndAAF53wABeeAAAXofAAF6IQABeiMAAXolAAF6KAABeikAAXoqAAF6KwABeiwAAXouAAF6MAABejEAAXoyAAF6NAABejUAAXpaAAF6fgABeqUAAXrJAAF6zAABes4AAXrQAAF60gABetQAAXrWAAF61wABetoAAXrnAAF69gABevgAAXr6AAF6/AABev4AAXsAAAF7AgABewQAAXsTAAF7FgABexkAAXscAAF7HwABeyIAAXslAAF7KAABeyoAAXtpAAF7awABe20AAXtvAAF7cgABe3MAAXt0AAF7dQABe3YAAXt4AAF7egABe3sAAXt8AAF7fgABe38AAXu+AAF7wAABe8IAAXvEAAF7xwABe8gAAXvJAAF7ygABe8sAAXvNAAF7zwABe9AAAXvRAAF70wABe9QAAXwTAAF8FQABfBcAAXwZAAF8HAABfB0AAXweAAF8HwABfCAAAXwiAAF8JAABfCUAAXwmAAF8KAABfCkAAXxoAAF8agABfGwAAXxuAAF8cQABfHIAAXxzAAF8dAABfHUAAXx3AAF8eQABfHoAAXx7AAF8fQABfH4AAXy9AAF8vwABfMEAAXzDAAF8xgABfMcAAXzIAAF8yQABfMoAAXzMAAF8zgABfM8AAXzQAAF80gABfNMAAX0SAAF9FAABfRYAAX0YAAF9GwABfRwAAX0dAAF9HgABfR8AAX0hAAF9IwABfSQAAX0lAAF9JwABfSgAAX1nAAF9aQABfWsAAX1tAAF9cAABfXEAAX1yAAF9cwABfXQAAX12AAF9eAABfXkAAX16AAF9fAABfX0AAX3IAAF96wABfgsAAX4rAAF+LQABfi8AAX4xAAF+MwABfjYAAX43AAF+OAABfjsAAX48AAF+PgABfj8AAX5BAAF+RAABfkUAAX5GAAF+SQABfkoAAX5TAAF+YAABfmUAAX5nAAF+aQABfm4AAX5xAAF+dAABfnYAAX6bAAF+vwABfuYAAX8KAAF/DQABfw8AAX8RAAF/EwABfxUAAX8XAAF/GAABfxsAAX8oAAF/OQABfzsAAX89AAF/PwABf0EAAX9DAAF/RQABf0cAAX9JAAF/WgABf10AAX9gAAF/YwABf2YAAX9pAAF/bAABf28AAX9yAAF/dAABf7MAAX+1AAF/twABf7kAAX+8AAF/vQABf74AAX+/AAF/wAABf8IAAX/EAAF/xQABf8YAAX/IAAF/yQABgAgAAYAKAAGADAABgA4AAYARAAGAEgABgBMAAYAUAAGAFQABgBcAAYAZAAGAGgABgBsAAYAdAAGAHgABgF0AAYBfAAGAYgABgGQAAYBnAAGAaAABgGkAAYBqAAGAawABgG0AAYBvAAGAcAABgHEAAYBzAAGAdAABgIEAAYCCAAGAgwABgIUAAYDEAAGAxgABgMgAAYDKAAGAzQABgM4AAYDPAAGA0AABgNEAAYDTAAGA1QABgNYAAYDXAAGA2QABgNoAAYEZAAGBGwABgR0AAYEfAAGBIgABgSMAAYEkAAGBJQABgSYAAYEoAAGBKgABgSsAAYEsAAGBLgABgS8AAYFuAAGBcAABgXIAAYF0AAGBdwABgXgAAYF5AAGBegABgXsAAYF9AAGBfwABgYAAAYGBAAGBgwABgYQAAYHDAAGBxQABgccAAYHJAAGBzAABgc0AAYHOAAGBzwABgdAAAYHSAAGB1AABgdUAAYHWAAGB2AABgdkAAYIYAAGCGgABghwAAYIeAAGCIQABgiIAAYIjAAGCJAABgiUAAYInAAGCKQABgioAAYIrAAGCLQABgi4AAYJTAAGCdwABgp4AAYLCAAGCxQABgscAAYLJAAGCywABgs0AAYLPAAGC0AABgtMAAYLgAAGC7wABgvEAAYLzAAGC9QABgvcAAYL5AAGC+wABgv0AAYMMAAGDDwABgxIAAYMVAAGDGAABgxsAAYMeAAGDIQABgyMAAYNiAAGDZAABg2YAAYNoAAGDawABg2wAAYNtAAGDbgABg28AAYNxAAGDcwABg3QAAYN1AAGDdwABg3gAAYO3AAGDuQABg7sAAYO9AAGDwAABg8EAAYPCAAGDwwABg8QAAYPGAAGDyAABg8kAAYPKAAGDzAABg80AAYQMAAGEDgABhBAAAYQSAAGEFQABhBYAAYQXAAGEGAABhBkAAYQbAAGEHQABhB4AAYQfAAGEIQABhCIAAYRhAAGEYwABhGUAAYRnAAGEagABhGsAAYRsAAGEbQABhG4AAYRwAAGEcgABhHMAAYR0AAGEdgABhHcAAYS2AAGEuAABhLoAAYS8AAGEvwABhMAAAYTBAAGEwgABhMMAAYTFAAGExwABhMgAAYTJAAGEywABhMwAAYULAAGFDQABhQ8AAYURAAGFFAABhRUAAYUWAAGFFwABhRgAAYUaAAGFHAABhR0AAYUeAAGFIAABhSEAAYVgAAGFYgABhWQAAYVmAAGFaQABhWoAAYVrAAGFbAABhW0AAYVvAAGFcQABhXIAAYVzAAGFdQABhXYAAYXBAAGF5AABhgQAAYYkAAGGJgABhigAAYYqAAGGLAABhi8AAYYwAAGGMQABhjQAAYY1AAGGNwABhjgAAYY6AAGGPQABhj4AAYY/AAGGQgABhkMAAYZIAAGGVQABhloAAYZcAAGGXgABhmMAAYZmAAGGaQABhmsAAYaQAAGGtAABhtsAAYb/AAGHAgABhwQAAYcGAAGHCAABhwoAAYcMAAGHDQABhxAAAYcdAAGHLgABhzAAAYcyAAGHNAABhzYAAYc4AAGHOgABhzwAAYc+AAGHTwABh1IAAYdVAAGHWAABh1sAAYdeAAGHYQABh2QAAYdnAAGHaQABh6gAAYeqAAGHrAABh64AAYexAAGHsgABh7MAAYe0AAGHtQABh7cAAYe5AAGHugABh7sAAYe9AAGHvgABh/0AAYf/AAGIAQABiAMAAYgGAAGIBwABiAgAAYgJAAGICgABiAwAAYgOAAGIDwABiBAAAYgSAAGIEwABiFIAAYhUAAGIVwABiFkAAYhcAAGIXQABiF4AAYhfAAGIYAABiGIAAYhkAAGIZQABiGYAAYhoAAGIaQABiHYAAYh3AAGIeAABiHoAAYi5AAGIuwABiL0AAYi/AAGIwgABiMMAAYjEAAGIxQABiMYAAYjIAAGIygABiMsAAYjMAAGIzgABiM8AAYkOAAGJEAABiRIAAYkUAAGJFwABiRgAAYkZAAGJGgABiRsAAYkdAAGJHwABiSAAAYkhAAGJIwABiSQAAYljAAGJZQABiWcAAYlpAAGJbAABiW0AAYluAAGJbwABiXAAAYlyAAGJdAABiXUAAYl2AAGJeAABiXkAAYm4AAGJugABibwAAYm+AAGJwQABicIAAYnDAAGJxAABicUAAYnHAAGJyQABicoAAYnLAAGJzQABic4AAYoNAAGKDwABihEAAYoTAAGKFgABihcAAYoYAAGKGQABihoAAYocAAGKHgABih8AAYogAAGKIgABiiMAAYpIAAGKbAABipMAAYq3AAGKugABirwAAYq+AAGKwAABisIAAYrEAAGKxQABisgAAYrVAAGK5AABiuYAAYroAAGK6gABiuwAAYruAAGK8AABivIAAYsBAAGLBAABiwcAAYsKAAGLDQABixAAAYsTAAGLFgABixgAAYtXAAGLWQABi1sAAYtdAAGLYAABi2EAAYtiAAGLYwABi2QAAYtmAAGLaAABi2kAAYtqAAGLbAABi20AAYusAAGLrgABi7AAAYuyAAGLtQABi7YAAYu3AAGLuAABi7kAAYu7AAGLvQABi74AAYu/AAGLwQABi8IAAYwBAAGMAwABjAUAAYwHAAGMCgABjAsAAYwMAAGMDQABjA4AAYwQAAGMEgABjBMAAYwUAAGMFgABjBcAAYxWAAGMWAABjFoAAYxcAAGMXwABjGAAAYxhAAGMYgABjGMAAYxlAAGMZwABjGgAAYxpAAGMawABjGwAAYyrAAGMrQABjK8AAYyxAAGMtAABjLUAAYy2AAGMtwABjLgAAYy6AAGMvAABjL0AAYy+AAGMwAABjMEAAY0AAAGNAgABjQQAAY0GAAGNCQABjQoAAY0LAAGNDAABjQ0AAY0PAAGNEQABjRIAAY0TAAGNFQABjRYAAY1VAAGNVwABjVkAAY1bAAGNXgABjV8AAY1gAAGNYQABjWIAAY1kAAGNZgABjWcAAY1oAAGNagABjWsAAY22AAGN2QABjfkAAY4ZAAGOGwABjh0AAY4fAAGOIQABjiQAAY4lAAGOJgABjikAAY4qAAGOLAABji0AAY4vAAGOMgABjjMAAY40AAGONwABjjgAAY49AAGOSgABjk8AAY5RAAGOUwABjlgAAY5bAAGOXgABjmAAAY6FAAGOqQABjtAAAY70AAGO9wABjvkAAY77AAGO/QABjv8AAY8BAAGPAgABjwUAAY8SAAGPIwABjyUAAY8nAAGPKQABjysAAY8tAAGPLwABjzEAAY8zAAGPRAABj0cAAY9KAAGPTQABj1AAAY9TAAGPVgABj1kAAY9cAAGPXgABj50AAY+fAAGPoQABj6MAAY+mAAGPpwABj6gAAY+pAAGPqgABj6wAAY+uAAGPrwABj7AAAY+yAAGPswABj/IAAY/0AAGP9gABj/gAAY/7AAGP/AABj/0AAY/+AAGP/wABkAEAAZADAAGQBAABkAUAAZAHAAGQCAABkEcAAZBJAAGQTAABkE4AAZBRAAGQUgABkFMAAZBUAAGQVQABkFcAAZBZAAGQWgABkFsAAZBdAAGQXgABkGsAAZBsAAGQbQABkG8AAZCuAAGQsAABkLIAAZC0AAGQtwABkLgAAZC5AAGQugABkLsAAZC9AAGQvwABkMAAAZDBAAGQwwABkMQAAZEDAAGRBQABkQcAAZEJAAGRDAABkQ0AAZEOAAGRDwABkRAAAZESAAGRFAABkRUAAZEWAAGRGAABkRkAAZFYAAGRWgABkVwAAZFeAAGRYQABkWIAAZFjAAGRZAABkWUAAZFnAAGRaQABkWoAAZFrAAGRbQABkW4AAZGtAAGRrwABkbEAAZGzAAGRtgABkbcAAZG4AAGRuQABkboAAZG8AAGRvgABkb8AAZHAAAGRwgABkcMAAZICAAGSBAABkgYAAZIIAAGSCwABkgwAAZINAAGSDgABkg8AAZIRAAGSEwABkhQAAZIVAAGSFwABkhgAAZI9AAGSYQABkogAAZKsAAGSrwABkrEAAZKzAAGStQABkrcAAZK5AAGSugABkr0AAZLKAAGS2QABktsAAZLdAAGS3wABkuEAAZLjAAGS5QABkucAAZL2AAGS+QABkvwAAZL/AAGTAgABkwUAAZMIAAGTCwABkw0AAZNMAAGTTgABk1AAAZNSAAGTVQABk1YAAZNXAAGTWAABk1kAAZNbAAGTXQABk14AAZNfAAGTYQABk2IAAZOhAAGTowABk6UAAZOnAAGTqgABk6sAAZOsAAGTrQABk64AAZOwAAGTsgABk7MAAZO0AAGTtgABk7cAAZP2AAGT+AABk/oAAZP8AAGT/wABlAAAAZQBAAGUAgABlAMAAZQFAAGUBwABlAgAAZQJAAGUCwABlAwAAZRLAAGUTQABlE8AAZRRAAGUVAABlFUAAZRWAAGUVwABlFgAAZRaAAGUXAABlF0AAZReAAGUYAABlGEAAZSgAAGUogABlKQAAZSmAAGUqQABlKoAAZSrAAGUrAABlK0AAZSvAAGUsQABlLIAAZSzAAGUtQABlLYAAZT1AAGU9wABlPkAAZT7AAGU/gABlP8AAZUAAAGVAQABlQIAAZUEAAGVBgABlQcAAZUIAAGVCgABlQsAAZVKAAGVTAABlU4AAZVQAAGVUwABlVQAAZVVAAGVVgABlVcAAZVZAAGVWwABlVwAAZVdAAGVXwABlWAAAZWrAAGVzgABle4AAZYOAAGWEAABlhIAAZYUAAGWFgABlhkAAZYaAAGWGwABlh4AAZYfAAGWIQABliIAAZYkAAGWJwABligAAZYpAAGWLAABli0AAZYyAAGWPwABlkQAAZZGAAGWSAABlk0AAZZQAAGWUwABllUAAZZ6AAGWngABlsUAAZbpAAGW7AABlu4AAZbwAAGW8gABlvQAAZb2AAGW9wABlvoAAZcHAAGXGAABlxoAAZccAAGXHgABlyAAAZciAAGXJAABlyYAAZcoAAGXOQABlzwAAZc/AAGXQgABl0UAAZdIAAGXSwABl04AAZdRAAGXUwABl5IAAZeUAAGXlgABl5gAAZebAAGXnAABl50AAZeeAAGXnwABl6EAAZejAAGXpAABl6UAAZenAAGXqAABl+cAAZfpAAGX6wABl+0AAZfwAAGX8QABl/IAAZfzAAGX9AABl/YAAZf4AAGX+QABl/oAAZf8AAGX/QABmDwAAZg+AAGYQQABmEMAAZhGAAGYRwABmEgAAZhJAAGYSgABmEwAAZhOAAGYTwABmFAAAZhSAAGYUwABmGAAAZhhAAGYYgABmGQAAZijAAGYpQABmKcAAZipAAGYrAABmK0AAZiuAAGYrwABmLAAAZiyAAGYtAABmLUAAZi2AAGYuAABmLkAAZj4AAGY+gABmPwAAZj+AAGZAQABmQIAAZkDAAGZBAABmQUAAZkHAAGZCQABmQoAAZkLAAGZDQABmQ4AAZlNAAGZTwABmVEAAZlTAAGZVgABmVcAAZlYAAGZWQABmVoAAZlcAAGZXgABmV8AAZlgAAGZYgABmWMAAZmiAAGZpAABmaYAAZmoAAGZqwABmawAAZmtAAGZrgABma8AAZmxAAGZswABmbQAAZm1AAGZtwABmbgAAZn3AAGZ+QABmfsAAZn9AAGaAAABmgEAAZoCAAGaAwABmgQAAZoGAAGaCAABmgkAAZoKAAGaDAABmg0AAZoyAAGaVgABmn0AAZqhAAGapAABmqYAAZqoAAGaqgABmqwAAZquAAGarwABmrIAAZq/AAGazgABmtAAAZrSAAGa1AABmtYAAZrYAAGa2gABmtwAAZrrAAGa7gABmvEAAZr0AAGa9wABmvoAAZr9AAGbAAABmwIAAZtBAAGbQwABm0UAAZtHAAGbSgABm0sAAZtMAAGbTQABm04AAZtQAAGbUgABm1MAAZtUAAGbVgABm1cAAZuWAAGbmAABm5oAAZucAAGbnwABm6AAAZuhAAGbogABm6MAAZulAAGbpwABm6gAAZupAAGbqwABm6wAAZvrAAGb7QABm+8AAZvxAAGb9AABm/UAAZv2AAGb9wABm/gAAZv6AAGb/AABm/0AAZv+AAGcAAABnAEAAZxAAAGcQgABnEQAAZxGAAGcSQABnEoAAZxLAAGcTAABnE0AAZxPAAGcUQABnFIAAZxTAAGcVQABnFYAAZyVAAGclwABnJkAAZybAAGcngABnJ8AAZygAAGcoQABnKIAAZykAAGcpgABnKcAAZyoAAGcqgABnKsAAZzqAAGc7AABnO4AAZzwAAGc8wABnPQAAZz1AAGc9gABnPcAAZz5AAGc+wABnPwAAZz9AAGc/wABnQAAAZ0/AAGdQQABnUMAAZ1FAAGdSAABnUkAAZ1KAAGdSwABnUwAAZ1OAAGdUAABnVEAAZ1SAAGdVAABnVUAAZ2gAAGdwwABneMAAZ4DAAGeBQABngcAAZ4JAAGeCwABng4AAZ4PAAGeEAABnhMAAZ4UAAGeFgABnhcAAZ4ZAAGeHAABnh0AAZ4eAAGeIQABniIAAZ4nAAGeNAABnjkAAZ47AAGePQABnkIAAZ5FAAGeSAABnkoAAZ5vAAGekwABnroAAZ7eAAGe4QABnuMAAZ7lAAGe5wABnukAAZ7rAAGe7AABnu8AAZ78AAGfDQABnw8AAZ8RAAGfEwABnxUAAZ8XAAGfGQABnxsAAZ8dAAGfLgABnzEAAZ80AAGfNwABnzoAAZ89AAGfQAABn0MAAZ9GAAGfSAABn4cAAZ+JAAGfiwABn40AAZ+QAAGfkQABn5IAAZ+TAAGflAABn5YAAZ+YAAGfmQABn5oAAZ+cAAGfnQABn9wAAZ/eAAGf4AABn+IAAZ/lAAGf5gABn+cAAZ/oAAGf6QABn+sAAZ/tAAGf7gABn+8AAZ/xAAGf8gABoDEAAaAzAAGgNgABoDgAAaA7AAGgPAABoD0AAaA+AAGgPwABoEEAAaBDAAGgRAABoEUAAaBHAAGgSAABoFUAAaBWAAGgVwABoFkAAaCYAAGgmgABoJwAAaCeAAGgoQABoKIAAaCjAAGgpAABoKUAAaCnAAGgqQABoKoAAaCrAAGgrQABoK4AAaDtAAGg7wABoPEAAaDzAAGg9gABoPcAAaD4AAGg+QABoPoAAaD8AAGg/gABoP8AAaEAAAGhAgABoQMAAaFCAAGhRAABoUYAAaFIAAGhSwABoUwAAaFNAAGhTgABoU8AAaFRAAGhUwABoVQAAaFVAAGhVwABoVgAAaGXAAGhmQABoZsAAaGdAAGhoAABoaEAAaGiAAGhowABoaQAAaGmAAGhqAABoakAAaGqAAGhrAABoa0AAaHsAAGh7gABofAAAaHyAAGh9QABofYAAaH3AAGh+AABofkAAaH7AAGh/QABof4AAaH/AAGiAQABogIAAaInAAGiSwABonIAAaKWAAGimQABopsAAaKdAAGinwABoqEAAaKjAAGipAABoqcAAaK0AAGiwwABosUAAaLHAAGiyQABossAAaLNAAGizwABotEAAaLgAAGi4wABouYAAaLpAAGi7AABou8AAaLyAAGi9QABovcAAaM2AAGjOAABozoAAaM8AAGjPwABo0AAAaNBAAGjQgABo0MAAaNFAAGjRwABo0gAAaNJAAGjSwABo0wAAaOLAAGjjQABo48AAaORAAGjlAABo5UAAaOWAAGjlwABo5gAAaOaAAGjnAABo50AAaOeAAGjoAABo6EAAaPgAAGj4gABo+QAAaPmAAGj6QABo+oAAaPrAAGj7AABo+0AAaPvAAGj8QABo/IAAaPzAAGj9QABo/YAAaQ1AAGkNwABpDkAAaQ7AAGkPgABpD8AAaRAAAGkQQABpEIAAaREAAGkRgABpEcAAaRIAAGkSgABpEsAAaSKAAGkjAABpI4AAaSQAAGkkwABpJQAAaSVAAGklgABpJcAAaSZAAGkmwABpJwAAaSdAAGknwABpKAAAaTfAAGk4QABpOMAAaTlAAGk6AABpOkAAaTqAAGk6wABpOwAAaTuAAGk8AABpPEAAaTyAAGk9AABpPUAAaU0AAGlNgABpTgAAaU6AAGlPQABpT4AAaU/AAGlQAABpUEAAaVDAAGlRQABpUYAAaVHAAGlSQABpUoAAaVTAAGlVAABpVYAAaVjAAGlZAABpWUAAaVnAAGldAABpXUAAaV2AAGleAABpYUAAaWGAAGlhwABpYkAAaWSAAGloQABpa4AAaW9AAGlzwABpeMAAaX6AAGmDAABphUAAaYWAAGmGAABpiUAAaYmAAGmJwABpikAAaYyAAGmPAABpkMAAAAAAAAEAgAAAAAAAD/JAAAAAAAAAAAAAAAAAAGmSw== - - EhPanda/Database/Model.xcdatamodeld/Model 6.xcdatamodel - YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 -cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxEFQQALAAwAGwA3ADgAOQBHAEgASQBKAEsAZgBnAGgAbgBvAHsAkQCSAJMAlACVAJYAlwCYAJkAmgCzALYAvQDDANIA4QDkAPMBAgEFAGUBFQEkASgBLAE7AUEBQgFKAVkBWgFjAXkBegF7AXwBfQF+AX8BgAGBAYIBlwGYAaABoQGiAa4BwgHDAcQBxQHGAccByAHJAcoB2QHoAfcB+wIKAhkCGgIpAjgCRwJTAmUCZgJnAmgCaQJqAmsCbAJ7AooCmQKoAqkCuALHAtYC3gLzAvQC/AMIAxwDKwM6A0kDTQNcA2sDegOJA5gDpAO2A8UD1APjA/IEAQQQBB8ENAQ1BD0ESQRdBGwEewSKBI4EnQSsBLsEygTZBOUE9wUGBQcFFgUlBTQFNQVEBVMFYgV3BXgFgAWMBaAFrwW+Bc0F0QXgBe8F/gYNBhwGKAY6BkkGWAZnBnYGhQaUBqMGuAa5BsEGzQbhBvAG/wcOBxIHIQcwBz8HTgddB2kHeweKB5kHqAe3B8YH1QfkB/kH+ggCCA4IIggxCEAITwhTCGIIcQiACI8IngiqCLwIywjaCOkI+AkHCRYJJQk6CTsJQwlPCWMJcgmBCZAJlAmjCbIJwQnQCd8J6wn9CgwKGwoqCjkKSApXCmYKewp8CoQKkAqkCrMKwgrRCtUK5ArzCwILEQsgCywLPgtNC04LXQtsC3sLfAuLC5oLqQu+C78LxwvTC+cL9gwFDBQMGAwnDDYMRQxUDGMMbwyBDJAMnwyuDL0MzAzbDOoM6wzuDPcNEQ0SDRgNJA06DUkNTA1bDWoNbQ18DYsNjg2dDawNsA2/Dc4Nzw37DfwN/Q3+Df8OAA4BDgIOAw4EDgUOBg4HDggOCQ4KDgsODA4NDg4OIw4kDiwOOA5MDlsOag55Dn0OjA6bDqoOuQ7IDtQO5g71DwQPEw8iDyMPMg9BD1APZQ9mD24Peg+OD50PrA+7D78Pzg/dD+wP+xAKEBYQKBA3EEYQVRBkEHMQghCREKYQpxCvELsQzxDeEO0Q/BEAEQ8RHhEtETwRSxFXEWkReBGHEZYRpRGmEbURxBHTEegR6RHxEf0SERIgEi8SPhJCElESYBJvEn4SjRKZEqsSuhLJEtgS5xL2EwUTFBMpEyoTMhM+E1ITYRNwE38TgxOSE6ETsBO/E84T2hPsE/sUChQZFCgUNxRGFFUUahRrFHMUfxSTFKIUsRTAFMQU0xTiFPEVABUPFRsVLRU8FT0VTBVbFWoVeRWIFZcVrBWtFbUVwRXVFeQV8xYCFgYWFRYkFjMWQhZRFl0WbxZ+Fo0WnBarFroWyRbYFu0W7hb2FwIXFhclFzQXQxdHF1YXZRd0F4MXkheeF7AXvxfOF90X7Bf7GAoYGRguGC8YNxhDGFcYZhh1GIQYiBiXGKYYtRjEGNMY3xjxGQAZDxkeGS0ZPBlLGVoZbxlwGXgZhBmYGacZthnFGckZ2BnnGfYaBRoUGiAaMhpBGlAaXxpuGn0ajBqbGrAasRq5GsUa2RroGvcbBhsKGxkbKBs3G0YbVRthG3MbghuRG6Abrxu+G80b3BvxG/Ib+hwGHBocKRw4HEccSxxaHGkceByHHJYcohy0HMMc0hzhHPAc/x0OHR0dMh0zHTsdRx1bHWodeR2IHYwdmx2qHbkdyB3XHeMd9R4EHgUeFB4jHjIeMx5CHlEeYB51HnYefh6KHp4erR68Hssezx7eHu0e/B8LHxofJh84H0cfVh9lH3Qfgx+SH6Efth+3H78fyx/fH+4f/SAMIBAgHyAuID0gTCBbIGcgeSCIIJcgpiC1IMQg0yDiIPcg+CEAIQwhICEvIT4hTSFRIWAhbyF+IY0hnCGoIbohySHYIech9iIFIhQiIyI4IjkiQSJNImEicCJ/Io4ikiKhIrAivyLOIt0i6SL7IwojCyMaIykjOCM5I0gjVyNmI3sjfCOEI5AjpCOzI8Ij0SPVI+Qj8yQCJBEkICQsJD4kTSRcJGskeiSJJJgkpyS8JL0kxSTRJOUk9CUDJRIlFiUlJTQlQyVSJWElbSV/JY4lnSWsJbslyiXZJegl/SX+JgYmEiYmJjUmRCZTJlcmZiZ1JoQmkyaiJq4mwCbPJt4m7Sb8JwsnGicpJywnRidHJ00nWSdvJ34ngSeQJ58noiexJ8AnwyfSJ+En5Sf0KAMoBCgiKCMoJCglKCYoJyg8KD0oRShRKGUodCiDKJIoliilKLQowyjSKOEo7Sj/KQ4pHSksKTspSilZKWgpfSl+KYYpkimmKbUpxCnTKdcp5in1KgQqEyoiKi4qQCpPKl4qbSp8KosqmiqpKr4qvyrHKtMq5yr2KwUrFCsYKycrNitFK1QrYytvK4ErkCuRK6Arryu+K80r3CvrLAAsASwJLBUsKSw4LEcsVixaLGkseCyHLJYspSyxLMMs0izhLPAs/y0OLR0tLC1BLUItSi1WLWoteS2ILZctmy2qLbktyC3XLeYt8i4ELhMuIi4xLkAuTy5eLm0ugi6DLosuly6rLrouyS7YLtwu6y76LwkvGC8nLzMvRS9UL2Mvci+BL5Avny+uL8MvxC/ML9gv7C/7MAowGTAdMCwwOzBKMFkwaDB0MIYwlTCkMLMwwjDRMOAw7zEEMQUxDTEZMS0xPDFLMVoxXjFtMXwxizGaMakxtTHHMdYx5TH0MgMyEjIhMjAyRTJGMk4yWjJuMn0yjDKbMp8yrjK9Mswy2zLqMvYzCDMXMyYzNTNEM1MzYjNxM4YzhzOPM5szrzO+M80z3DPgM+8z/jQNNBw0KzQ3NEk0WDRnNHY0hTSUNKM0sjTHNMg00DTcNPA0/zUONR01ITUwNT81TjVdNWw1eDWKNZk1qDW3NcY11TXkNfM2CDYJNhE2HTYxNkA2TzZeNmI2cTaANo82njatNrk2yzbaNuk2+DcHNxY3JTc0N0k3SjdSN143cjeBN5A3nzejN7I3wTfQN9837jf6OAw4GzgqODk4SDhXOGY4dTh4OJI4kziZOKU4uzjKOM043DjrOO44/TkMOQ85HjktOTE5QDlPOVA5ZDllOWY5ZzloOWk5ajlrOWw5gTmCOYo5ljmqObk5yDnXOds56jn5Ogg6FzomOjI6RDpTOmI6cTqAOo86njqtOsI6wzrLOtc66zr6Owk7GDscOys7OjtJO1g7ZztzO4U7lDujO7I7wTvQO9877jwDPAQ8DDwYPCw8OzxKPFk8XTxsPHs8ijyZPKg8tDzGPNU85DzzPQI9ET0gPS89RD1FPU09WT1tPXw9iz2aPZ49rT28Pcs92j3pPfU+Bz4WPiU+ND5DPlI+YT5wPoU+hj6OPpo+rj69Psw+2z7fPu4+/T8MPxs/Kj82P0g/Vz9mP3U/hD+TP6I/sT/GP8c/zz/bP+8//kANQBxAIEAvQD5ATUBcQGtAd0CJQJhAp0C2QMVA1EDjQPJBB0EIQRBBHEEwQT9BTkFdQWFBcEF/QY5BnUGsQbhBykHZQehB90IGQhVCJEIzQkhCSUJRQl1CcUKAQo9CnkKiQrFCwELPQt5C7UL5QwtDGkMpQzhDR0NWQ2VDdEN3Q3tDf0ODQ4tDjkOSVSRudWxs1wANAA4ADwAQABEAEgATABQAFQAWABcAGAAXABpfEA9feGRfcm9vdFBhY2thZ2VWJGNsYXNzXF94ZF9jb21tZW50c18QEF94ZF9tb2RlbE1hbmFnZXJfEBVfY29uZmlndXJhdGlvbnNCeU5hbWVdX3hkX21vZGVsTmFtZV8QF19tb2RlbFZlcnNpb25JZGVudGlmaWVygAKBBUCBBT6AAIEFP4AAgQEC3gAcAB0AHgAfACAAIQAiAA4AIwAkACUAJgAnACgAKQAqACsACQApABcALwAwADEAMgAzACkAKQAXXxAcWERCdWNrZXRGb3JDbGFzc2Vzd2FzRW5jb2RlZF8QGlhEQnVja2V0Rm9yUGFja2FnZXNzdG9yYWdlXxAcWERCdWNrZXRGb3JJbnRlcmZhY2Vzc3RvcmFnZV8QD194ZF9vd25pbmdNb2RlbF8QHVhEQnVja2V0Rm9yUGFja2FnZXN3YXNFbmNvZGVkVl9vd25lcl8QG1hEQnVja2V0Rm9yRGF0YVR5cGVzc3RvcmFnZVtfdmlzaWJpbGl0eV8QGVhEQnVja2V0Rm9yQ2xhc3Nlc3N0b3JhZ2VVX25hbWVfEB9YREJ1Y2tldEZvckludGVyZmFjZXN3YXNFbmNvZGVkXxAeWERCdWNrZXRGb3JEYXRhVHlwZXN3YXNFbmNvZGVkXxAQX3VuaXF1ZUVsZW1lbnRJRIAEgQU8gQU6gAGABIAAgQU7gQU9EACABYADgASABIAAUFNZRVPTADoAOwAOADwAQQBGV05TLmtleXNaTlMub2JqZWN0c6QAPQA+AD8AQIAGgAeACIAJpABCAEMARABFgAqBASSBAx+BBGWAKF5HYWxsZXJ5U3RhdGVNT18QD0dhbGxlcnlEZXRhaWxNT1lHYWxsZXJ5TU9YQXBwRW52TU/fEBAATABNAE4ATwAhAFAAUQAjAFIAUwAOACUAVABVACgAVgBXAFgAKQApABQAXABdADEAKQBXAGAAPQBXAGMAZABlXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zZHVwbGljYXRlc18QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNzdG9yYWdlW19pc0Fic3RyYWN0gAyAMIAEgASAAoANgQEhgASADIEBI4AGgAyBASKACwgSq62Cw1dvcmRlcmVk0wA6ADsADgBpAGsARqEAaoAOoQBsgA+AKF5YRF9QU3RlcmVvdHlwZdkAIQAlAHAADgAoAHEAIwBWAHIAQgBqAFcAdgAXACkAMQBlAHpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WACoAOgAyAL4AAgAQIgBDTADoAOwAOAHwAhgBGqQB9AH4AfwCAAIEAggCDAIQAhYARgBKAE4AUgBWAFoAXgBiAGakAhwCIAIkAigCLAIwAjQCOAI+AGoAegB+AIYAigCSAJoApgC2AKF8QE1hEUE1Db21wb3VuZEluZGV4ZXNfEBBYRF9QU0tfZWxlbWVudElEXxAZWERQTVVuaXF1ZW5lc3NDb25zdHJhaW50c18QGlhEX1BTS192ZXJzaW9uSGFzaE1vZGlmaWVyXxAZWERfUFNLX2ZldGNoUmVxdWVzdHNBcnJheV8QEVhEX1BTS19pc0Fic3RyYWN0XxAPWERfUFNLX3VzZXJJbmZvXxATWERfUFNLX2NsYXNzTWFwcGluZ18QFlhEX1BTS19lbnRpdHlDbGFzc05hbWXfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwCmABcAbABlAGUAZQAxAGUArQB9AGUAZQAXAGVVX3R5cGVYX2RlZmF1bHRcX2Fzc29jaWF0aW9uW19pc1JlYWRPbmx5WV9pc1N0YXRpY1lfaXNVbmlxdWVaX2lzRGVyaXZlZFpfaXNPcmRlcmVkXF9pc0NvbXBvc2l0ZVdfaXNMZWFmgACAG4AAgA8ICAgIgB2AEQgIgAAI0gA7AA4AtAC1oIAc0gC3ALgAuQC6WiRjbGFzc25hbWVYJGNsYXNzZXNeTlNNdXRhYmxlQXJyYXmjALkAuwC8V05TQXJyYXlYTlNPYmplY3TSALcAuAC+AL9fEBBYRFVNTFByb3BlcnR5SW1wpADAAMEAwgC8XxAQWERVTUxQcm9wZXJ0eUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAGwAZQBlAGUAMQBlAK0AfgBlAGUAFwBlgACAAIAAgA8ICAgIgB2AEggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcA1AAXAGwAZQBlAGUAMQBlAK0AfwBlAGUAFwBlgACAIIAAgA8ICAgIgB2AEwgIgAAI0gA7AA4A4gC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAGwAZQBlAGUAMQBlAK0AgABlAGUAFwBlgACAAIAAgA8ICAgIgB2AFAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcA9QAXAGwAZQBlAGUAMQBlAK0AgQBlAGUAFwBlgACAI4AAgA8ICAgIgB2AFQgIgAAI0gA7AA4BAwC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAGwAZQBlAGUAMQBlAK0AggBlAGUAFwBlgACAJYAAgA8ICAgIgB2AFggIgAAICN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXARcAFwBsAGUAZQBlADEAZQCtAIMAZQBlABcAZYAAgCeAAIAPCAgICIAdgBcICIAACNMAOgA7AA4BJQEmAEagoIAo0gC3ALgBKQEqXxATTlNNdXRhYmxlRGljdGlvbmFyeaMBKQErALxcTlNEaWN0aW9uYXJ53xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBLgAXAGwAZQBlAGUAMQBlAK0AhABlAGUAFwBlgACAKoAAgA8ICAgIgB2AGAgIgAAI1gAlAA4AKABWACEAIwE8AT0AFwBlABcAMYArgCyAAAiAAF8QFFhER2VuZXJpY1JlY29yZENsYXNz0gC3ALgBQwFEXVhEVU1MQ2xhc3NJbXCmAUUBRgFHAUgBSQC8XVhEVU1MQ2xhc3NJbXBfEBJYRFVNTENsYXNzaWZpZXJJbXBfEBFYRFVNTE5hbWVzcGFjZUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBTAAXAGwAZQBlAGUAMQBlAK0AhQBlAGUAFwBlgACALoAAgA8ICAgIgB2AGQgIgAAIXxAPLkdhbGxlcnlTdGF0ZU1P0gC3ALgBWwFcXxASWERVTUxTdGVyZW90eXBlSW1wpwFdAV4BXwFgAWEBYgC8XxASWERVTUxTdGVyZW90eXBlSW1wXVhEVU1MQ2xhc3NJbXBfEBJYRFVNTENsYXNzaWZpZXJJbXBfEBFYRFVNTE5hbWVzcGFjZUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w0wA6ADsADgFkAW4ARqkBZQFmAWcBaAFpAWoBawFsAW2AMYAygDOANIA1gDaAN4A4gDmpAW8BcAFxAXIBcwF0AXUBdgF3gDqAZYB8gJWArIDDgNqA8YEBCoAoWGNvbW1lbnRzVHRhZ3NfEA9yZWFkaW5nUHJvZ3Jlc3NdcHJldmlld0NvbmZpZ1lpbWFnZVVSTHNfEBFvcmlnaW5hbEltYWdlVVJMc110aHVtYm5haWxVUkxzU2dpZFtwcmV2aWV3VVJMc98QEgCbAJwAnQGDACEAnwCgAYQAIwCeAYUAoQAOACUAogCjACgApAAXABcAFwApAEIAZQBlAY0AMQBlAFcAZQGRAWUAZQBlAZUAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgAoICIA8CIAMCIBkgDEICIA7CBMAAAABHkDGztMAOgA7AA4BmQGcAEaiAZoBm4A9gD6iAZ0BnoA/gFOAKF8QElhEX1BQcm9wU3RlcmVvdHlwZV8QElhEX1BBdHRfU3RlcmVvdHlwZdkAIQAlAaMADgAoAaQAIwBWAaUBbwGaAFcAdgAXACkAMQBlAa1fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAOoA9gAyAL4AAgAQIgEDTADoAOwAOAa8BuABGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqAG5AboBuwG8Ab0BvgG/AcCASYBKgEuATYBOgFCAUYBSgChfEBtYRF9QUFNLX2lzU3RvcmVkSW5UcnV0aEZpbGVfEBtYRF9QUFNLX3ZlcnNpb25IYXNoTW9kaWZpZXJfEBBYRF9QUFNLX3VzZXJJbmZvXxARWERfUFBTS19pc0luZGV4ZWRfEBJYRF9QUFNLX2lzT3B0aW9uYWxfEBpYRF9QUFNLX2lzU3BvdGxpZ2h0SW5kZXhlZF8QEVhEX1BQU0tfZWxlbWVudElEXxATWERfUFBTS19pc1RyYW5zaWVudN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwGdAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIA/CAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwGdAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIA/CAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAeoAFwGdAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgEyAAIA/CAgICIAdgEMICIAACNMAOgA7AA4B+AH5AEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAZ0AZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgD8ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXAZ0AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgD8ICAgIgB2ARQgIgAAICd8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwGdAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIA/CAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwGdAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIA/CAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwGdAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIA/CAgICIAdgEgICIAACNkAIQAlAkgADgAoAkkAIwBWAkoBbwGbAFcAdgAXACkAMQBlAlJfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAOoA+gAyAL4AAgAQIgFTTADoAOwAOAlQCXABGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunAl0CXgJfAmACYQJiAmOAXIBdgF6AX4BhgGKAY4AoXxAdWERfUEF0dEtfZGVmYXVsdFZhbHVlQXNTdHJpbmdfEChYRF9QQXR0S19hbGxvd3NFeHRlcm5hbEJpbmFyeURhdGFTdG9yYWdlXxAXWERfUEF0dEtfbWluVmFsdWVTdHJpbmdfEBZYRF9QQXR0S19hdHRyaWJ1dGVUeXBlXxAXWERfUEF0dEtfbWF4VmFsdWVTdHJpbmdfEB1YRF9QQXR0S192YWx1ZVRyYW5zZm9ybWVyTmFtZV8QIFhEX1BBdHRLX3JlZ3VsYXJFeHByZXNzaW9uU3RyaW5n3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAZ4AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgFMICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAZ4AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgFMICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAZ4AZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgFMICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXAZ4AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgFMICAgIgB2AWAgIgAAIEQPo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAZ4AZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgFMICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAZ4AZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgFMICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAZ4AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgFMICAgIgB2AWwgIgAAI0gC3ALgC1wLYXVhEUE1BdHRyaWJ1dGWmAtkC2gLbAtwC3QC8XVhEUE1BdHRyaWJ1dGVcWERQTVByb3BlcnR5XxAQWERVTUxQcm9wZXJ0eUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w3xASAJsAnACdAt8AIQCfAKAC4AAjAJ4C4QChAA4AJQCiAKMAKACkABcAFwAXACkAQgBlAGUC6QAxAGUAVwBlAZEBZgBlAGUC8QBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASACggIgGcIgAwIgGSAMggIgGYIEwAAAAETHl920wA6ADsADgL1AvgARqIBmgGbgD2APqIC+QL6gGiAc4Ao2QAhACUC/QAOACgC/gAjAFYC/wFwAZoAVwB2ABcAKQAxAGUDB18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYBlgD2ADIAvgACABAiAadMAOgA7AA4DCQMSAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioAxMDFAMVAxYDFwMYAxkDGoBqgGuAbIBugG+AcIBxgHKAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwL5AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIBoCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwL5AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIBoCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAzwAFwL5AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgG2AAIBoCAgICIAdgEMICIAACNMAOgA7AA4DSgNLAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAvkAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgGgICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXAvkAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgGgICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAvkAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgGgICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAvkAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgGgICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAvkAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgGgICAgIgB2ASAgIgAAI2QAhACUDmQAOACgDmgAjAFYDmwFwAZsAVwB2ABcAKQAxAGUDo18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYBlgD6ADIAvgACABAiAdNMAOgA7AA4DpQOtAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cDrgOvA7ADsQOyA7MDtIB1gHaAd4B4gHmAeoB7gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcC+gBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACAcwgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcC+gBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACAcwgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcC+gBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACAcwgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABcC+gBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACAcwgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcC+gBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACAcwgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcC+gBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACAcwgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcC+gBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACAcwgICAiAHYBbCAiAAAjfEBIAmwCcAJ0EIAAhAJ8AoAQhACMAngQiAKEADgAlAKIAowAoAKQAFwAXABcAKQBCAGUAZQQqADEAZQBXAGUBkQFnAGUAZQQyAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIAKCAiAfgiADAiAZIAzCAiAfQgSV/wyTdMAOgA7AA4ENgQ5AEaiAZoBm4A9gD6iBDoEO4B/gIqAKNkAIQAlBD4ADgAoBD8AIwBWBEABcQGaAFcAdgAXACkAMQBlBEhfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAfIA9gAyAL4AAgAQIgIDTADoAOwAOBEoEUwBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqARUBFUEVgRXBFgEWQRaBFuAgYCCgIOAhYCGgIeAiICJgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcEOgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACAfwgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcEOgBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACAfwgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwR9ABcEOgBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAICEgACAfwgICAiAHYBDCAiAAAjTADoAOwAOBIsEjABGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwQ6AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIB/CAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFwQ6AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIB/CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwQ6AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIB/CAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwQ6AGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIB/CAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwQ6AGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIB/CAgICIAdgEgICIAACNkAIQAlBNoADgAoBNsAIwBWBNwBcQGbAFcAdgAXACkAMQBlBORfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAfIA+gAyAL4AAgAQIgIvTADoAOwAOBOYE7gBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunBO8E8ATxBPIE8wT0BPWAjICOgI+AkICSgJOAlIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE+QAXBDsAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAjYAAgIoICAgIgB2AVQgIgAAIUTDfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcEOwBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACAiggICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcEOwBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACAiggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwUnABcEOwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAICRgACAiggICAiAHYBYCAiAAAgRASzfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcEOwBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACAiggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcEOwBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACAiggICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcEOwBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACAiggICAiAHYBbCAiAAAjfEBIAmwCcAJ0FYwAhAJ8AoAVkACMAngVlAKEADgAlAKIAowAoAKQAFwAXABcAKQBCAGUAZQVtADEAZQBXAGUBkQFoAGUAZQV1AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIAKCAiAlwiADAiAZIA0CAiAlggS4RW54dMAOgA7AA4FeQV8AEaiAZoBm4A9gD6iBX0FfoCYgKOAKNkAIQAlBYEADgAoBYIAIwBWBYMBcgGaAFcAdgAXACkAMQBlBYtfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAlYA9gAyAL4AAgAQIgJnTADoAOwAOBY0FlgBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqAWXBZgFmQWaBZsFnAWdBZ6AmoCbgJyAnoCfgKCAoYCigCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcFfQBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACAmAgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcFfQBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACAmAgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwXAABcFfQBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAICdgACAmAgICAiAHYBDCAiAAAjTADoAOwAOBc4FzwBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwV9AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAICYCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFwV9AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAICYCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwV9AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAICYCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwV9AGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAICYCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwV9AGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAICYCAgICIAdgEgICIAACNkAIQAlBh0ADgAoBh4AIwBWBh8BcgGbAFcAdgAXACkAMQBlBidfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAlYA+gAyAL4AAgAQIgKTTADoAOwAOBikGMQBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunBjIGMwY0BjUGNgY3BjiApYCmgKeAqICpgKqAq4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBX4AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgKMICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXBX4AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgKMICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBX4AZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgKMICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXBX4AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgKMICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBX4AZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgKMICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBX4AZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgKMICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBX4AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgKMICAgIgB2AWwgIgAAI3xASAJsAnACdBqQAIQCfAKAGpQAjAJ4GpgChAA4AJQCiAKMAKACkABcAFwAXACkAQgBlAGUGrgAxAGUAVwBlAZEBaQBlAGUGtgBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASACggIgK4IgAwIgGSANQgIgK0IEj3xFsnTADoAOwAOBroGvQBGogGaAZuAPYA+oga+Br+Ar4C6gCjZACEAJQbCAA4AKAbDACMAVgbEAXMBmgBXAHYAFwApADEAZQbMXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgKyAPYAMgC+AAIAECICw0wA6ADsADgbOBtcARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgG2AbZBtoG2wbcBt0G3gbfgLGAsoCzgLWAtoC3gLiAuYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXBr4AZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgK8ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBr4AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgK8ICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcHAQAXBr4AZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAtIAAgK8ICAgIgB2AQwgIgAAI0wA6ADsADgcPBxAARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcGvgBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACArwgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABcGvgBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACArwgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcGvgBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACArwgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcGvgBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACArwgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcGvgBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACArwgICAiAHYBICAiAAAjZACEAJQdeAA4AKAdfACMAVgdgAXMBmwBXAHYAFwApADEAZQdoXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgKyAPoAMgC+AAIAECIC70wA6ADsADgdqB3IARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpwdzB3QHdQd2B3cHeAd5gLyAvYC+gL+AwIDBgMKAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwa/AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgACAAIC6CAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwa/AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIC6CAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwa/AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIC6CAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXApsAFwa/AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgGCAAIC6CAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwa/AGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIC6CAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwa/AGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIC6CAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwa/AGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIC6CAgICIAdgFsICIAACN8QEgCbAJwAnQflACEAnwCgB+YAIwCeB+cAoQAOACUAogCjACgApAAXABcAFwApAEIAZQBlB+8AMQBlAFcAZQGRAWoAZQBlB/cAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgAoICIDFCIAMCIBkgDYICIDECBKl+hhs0wA6ADsADgf7B/4ARqIBmgGbgD2APqIH/wgAgMaA0YAo2QAhACUIAwAOACgIBAAjAFYIBQF0AZoAVwB2ABcAKQAxAGUIDV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYDDgD2ADIAvgACABAiAx9MAOgA7AA4IDwgYAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioCBkIGggbCBwIHQgeCB8IIIDIgMmAyoDMgM2AzoDPgNCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwf/AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIDGCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwf/AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIDGCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXCEIAFwf/AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgMuAAIDGCAgICIAdgEMICIAACNMAOgA7AA4IUAhRAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXB/8AZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgMYICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXB/8AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgMYICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXB/8AZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgMYICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXB/8AZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgMYICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXB/8AZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgMYICAgIgB2ASAgIgAAI2QAhACUInwAOACgIoAAjAFYIoQF0AZsAVwB2ABcAKQAxAGUIqV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYDDgD6ADIAvgACABAiA0tMAOgA7AA4IqwizAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cItAi1CLYItwi4CLkIuoDTgNSA1YDWgNeA2IDZgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcIAABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACA0QgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcIAABlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACA0QgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcIAABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACA0QgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABcIAABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACA0QgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcIAABlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACA0QgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcIAABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACA0QgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcIAABlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACA0QgICAiAHYBbCAiAAAjfEBIAmwCcAJ0JJgAhAJ8AoAknACMAngkoAKEADgAlAKIAowAoAKQAFwAXABcAKQBCAGUAZQkwADEAZQBXAGUBkQFrAGUAZQk4AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIAKCAiA3AiADAiAZIA3CAiA2wgShnCgcdMAOgA7AA4JPAk/AEaiAZoBm4A9gD6iCUAJQYDdgOiAKNkAIQAlCUQADgAoCUUAIwBWCUYBdQGaAFcAdgAXACkAMQBlCU5fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WA2oA9gAyAL4AAgAQIgN7TADoAOwAOCVAJWQBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqAlaCVsJXAldCV4JXwlgCWGA34DggOGA44DkgOWA5oDngCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcJQABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACA3QgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcJQABlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACA3QgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwmDABcJQABlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIDigACA3QgICAiAHYBDCAiAAAjTADoAOwAOCZEJkgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwlAAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIDdCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFwlAAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIDdCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwlAAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIDdCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwlAAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIDdCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwlAAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIDdCAgICIAdgEgICIAACNkAIQAlCeAADgAoCeEAIwBWCeIBdQGbAFcAdgAXACkAMQBlCepfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WA2oA+gAyAL4AAgAQIgOnTADoAOwAOCewJ9ABGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunCfUJ9gn3CfgJ+Qn6CfuA6oDrgOyA7YDugO+A8IAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCUEAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgOgICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXCUEAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgOgICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCUEAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgOgICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXCUEAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgOgICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCUEAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgOgICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCUEAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgOgICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCUEAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgOgICAgIgB2AWwgIgAAI3xASAJsAnACdCmcAIQCfAKAKaAAjAJ4KaQChAA4AJQCiAKMAKACkABcAFwAXACkAQgBlAGUKcQAxAGUAVwBlAZEBbABlAGUKeQBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASACggIgPMIgAwIgGSAOAgIgPIIEjx2uwTTADoAOwAOCn0KgABGogGaAZuAPYA+ogqBCoKA9ID/gCjZACEAJQqFAA4AKAqGACMAVgqHAXYBmgBXAHYAFwApADEAZQqPXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgPGAPYAMgC+AAIAECID10wA6ADsADgqRCpoARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgKmwqcCp0KngqfCqAKoQqigPaA94D4gPqA+4D8gP2A/oAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXCoEAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgPQICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCoEAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgPQICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcKxAAXCoEAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACA+YAAgPQICAgIgB2AQwgIgAAI0wA6ADsADgrSCtMARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcKgQBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACA9AgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcKgQBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACA9AgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcKgQBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACA9AgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcKgQBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACA9AgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcKgQBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACA9AgICAiAHYBICAiAAAjZACEAJQshAA4AKAsiACMAVgsjAXYBmwBXAHYAFwApADEAZQsrXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgPGAPoAMgC+AAIAECIEBANMAOgA7AA4LLQs1AEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cLNgs3CzgLOQs6CzsLPIEBAYEBA4EBBIEBBYEBB4EBCIEBCYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXCoIAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQKAAID/CAgICIAdgFUICIAACFDfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcKggBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACA/wgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcKggBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACA/wgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABcKggBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgP8ICAgIgB2AWAgIgAAIEQK83xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCoIAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgP8ICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCoIAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgP8ICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCoIAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgP8ICAgIgB2AWwgIgAAI3xASAJsAnACdC6oAIQCfAKALqwAjAJ4LrAChAA4AJQCiAKMAKACkABcAFwAXACkAQgBlAGULtAAxAGUAVwBlAZEBbQBlAGULvABlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASACggIgQEMCIAMCIBkgDkICIEBCwgS4o6SGtMAOgA7AA4LwAvDAEaiAZoBm4A9gD6iC8QLxYEBDYEBGIAo2QAhACULyAAOACgLyQAjAFYLygF3AZoAVwB2ABcAKQAxAGUL0l8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBCoA9gAyAL4AAgAQIgQEO0wA6ADsADgvUC90ARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgL3gvfC+AL4QviC+ML5AvlgQEPgQEQgQERgQETgQEUgQEVgQEWgQEXgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcLxABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAQ0ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXC8QAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQENCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXDAcAFwvEAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQESgACBAQ0ICAgIgB2AQwgIgAAI0wA6ADsADgwVDBYARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcLxABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAQ0ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXC8QAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQENCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwvEAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEBDQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcLxABlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAQ0ICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXC8QAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQENCAgICIAdgEgICIAACNkAIQAlDGQADgAoDGUAIwBWDGYBdwGbAFcAdgAXACkAMQBlDG5fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAQqAPoAMgC+AAIAECIEBGdMAOgA7AA4McAx4AEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cMeQx6DHsMfAx9DH4Mf4EBGoEBG4EBHIEBHYEBHoEBH4EBIIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXC8UAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQEYCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwvFAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEBGAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcLxQBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBARgICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXC8UAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgQEYCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwvFAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEBGAgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcLxQBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBARgICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXC8UAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQEYCAgICIAdgFsICIAACFpkdXBsaWNhdGVz0gA7AA4M7AC1oIAc0gC3ALgM7wzwWlhEUE1FbnRpdHmnDPEM8gzzDPQM9Qz2ALxaWERQTUVudGl0eV1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QEAz4DPkM+gz7ACEM/Az9ACMM/gz/AA4AJQ0ADQEAKABWAFcNAwApACkAFA0HAF0AMQApAFcAYAA+AFcNDg0PAGVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2WADIEBN4AEgASAAoEBJoEBIYAEgAyBASOAB4AMgQMegQElCBLAxFO/0wA6ADsADg0TDRUARqEAaoAOoQ0WgQEngCjZACEAJQ0ZAA4AKA0aACMAVg0bAEMAagBXAHYAFwApADEAZQ0jXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQEkgA6ADIAvgACABAiBASjTADoAOwAODSUNLwBGqQB9AH4AfwCAAIEAggCDAIQAhYARgBKAE4AUgBWAFoAXgBiAGakNMA0xDTINMw00DTUNNg03DTiBASmBASuBASyBAS6BAS+BATGBATKBATSBATWAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXDTwAFw0WAGUAZQBlADEAZQCtAH0AZQBlABcAZYAAgQEqgACBAScICAgIgB2AEQgIgAAI0gA7AA4NSgC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXDRYAZQBlAGUAMQBlAK0AfgBlAGUAFwBlgACAAIAAgQEnCAgICIAdgBIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXDV0AFw0WAGUAZQBlADEAZQCtAH8AZQBlABcAZYAAgQEtgACBAScICAgIgB2AEwgIgAAI0gA7AA4NawC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXDRYAZQBlAGUAMQBlAK0AgABlAGUAFwBlgACAAIAAgQEnCAgICIAdgBQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXDX4AFw0WAGUAZQBlADEAZQCtAIEAZQBlABcAZYAAgQEwgACBAScICAgIgB2AFQgIgAAI0gA7AA4NjAC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXDRYAZQBlAGUAMQBlAK0AggBlAGUAFwBlgACAJYAAgQEnCAgICIAdgBYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXDZ8AFw0WAGUAZQBlADEAZQCtAIMAZQBlABcAZYAAgQEzgACBAScICAgIgB2AFwgIgAAI0wA6ADsADg2tDa4ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEuABcNFgBlAGUAZQAxAGUArQCEAGUAZQAXAGWAAIAqgACBAScICAgIgB2AGAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcNwQAXDRYAZQBlAGUAMQBlAK0AhQBlAGUAFwBlgACBATaAAIEBJwgICAiAHYAZCAiAAAhfEBAuR2FsbGVyeURldGFpbE1P0wA6ADsADg3QDeUARq8QFA3RDdIN0w3UDdUN1g3XDdgN2Q3aDdsN3A3dDd4N3w3gDeEN4gFsDeSBATiBATmBATqBATuBATyBAT2BAT6BAT+BAUCBAUGBAUKBAUOBAUSBAUWBAUaBAUeBAUiBAUmAOIEBSq8QFA3mDecN6A3pDeoN6w3sDe0N7g3vDfAN8Q3yDfMN9A31DfYN9w34DfmBAUuBAWOBAXqBAZKBAamBAcCBAdiBAe+BAgaBAh2BAjSBAkuBAmKBAnuBApKBAqmBAsCBAtmBAvCBAweAKFhjb3ZlclVSTFx0b3JyZW50Q291bnRZc2l6ZUNvdW50VXRpdGxlWGxhbmd1YWdlWnVzZXJSYXRpbmdbcmF0aW5nQ291bnReZmF2b3JpdGVkQ291bnRaYXJjaGl2ZVVSTFp2aXNpYmlsaXR5WGNhdGVnb3J5WXBhZ2VDb3VudFtpc0Zhdm9yaXRlZFlwYXJlbnRVUkxYdXBsb2FkZXJYc2l6ZVR5cGVacG9zdGVkRGF0ZVhqcG5UaXRsZVZyYXRpbmffEBIAmwCcAJ0ODwAhAJ8AoA4QACMAng4RAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZQ4ZADEAZQBXAGUBkQ3RAGUAZQ4hAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBJAgIgQFNCIAMCIBkgQE4CAiBAUwIEtsDJnzTADoAOwAODiUOKABGogGaAZuAPYA+og4pDiqBAU6BAVmAKNkAIQAlDi0ADgAoDi4AIwBWDi8N5gGaAFcAdgAXACkAMQBlDjdfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAUuAPYAMgC+AAIAECIEBT9MAOgA7AA4OOQ5CAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioDkMORA5FDkYORw5IDkkOSoEBUIEBUYEBUoEBVIEBVYEBVoEBV4EBWIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXDikAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQFOCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw4pAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEBTggICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFw5sABcOKQBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEBU4AAgQFOCAgICIAdgEMICIAACNMAOgA7AA4Oeg57AEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXDikAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQFOCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFw4pAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIEBTggICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcOKQBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAU4ICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXDikAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQFOCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFw4pAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEBTggICAiAHYBICAiAAAjZACEAJQ7JAA4AKA7KACMAVg7LDeYBmwBXAHYAFwApADEAZQ7TXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQFLgD6ADIAvgACABAiBAVrTADoAOwAODtUO3QBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunDt4O3w7gDuEO4g7jDuSBAVuBAVyBAV2BAV6BAWCBAWGBAWKAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFw4qAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBAVkICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXDioAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQFZCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw4qAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEBWQgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFw8VABcOKgBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBX4AAgQFZCAgICIAdgFgICIAACBEEsN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw4qAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEBWQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcOKgBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAVkICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXDioAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQFZCAgICIAdgFsICIAACN8QEgCbAJwAnQ9RACEAnwCgD1IAIwCeD1MAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlD1sAMQBlAFcAZQGRDdIAZQBlD2MAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAWUIgAwIgGSBATkICIEBZAgS38JE5dMAOgA7AA4PZw9qAEaiAZoBm4A9gD6iD2sPbIEBZoEBcYAo2QAhACUPbwAOACgPcAAjAFYPcQ3nAZoAVwB2ABcAKQAxAGUPeV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBY4A9gAyAL4AAgAQIgQFn0wA6ADsADg97D4QARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgPhQ+GD4cPiA+JD4oPiw+MgQFogQFpgQFqgQFsgQFtgQFugQFvgQFwgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcPawBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAWYICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXD2sAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQFmCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXD64AFw9rAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQFrgACBAWYICAgIgB2AQwgIgAAI0wA6ADsADg+8D70ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcPawBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAWYICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXD2sAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQFmCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFw9rAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEBZggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcPawBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAWYICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXD2sAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQFmCAgICIAdgEgICIAACNkAIQAlEAsADgAoEAwAIwBWEA0N5wGbAFcAdgAXACkAMQBlEBVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAWOAPoAMgC+AAIAECIEBctMAOgA7AA4QFxAfAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cQIBAhECIQIxAkECUQJoEBc4EBdIEBdYEBdoEBd4EBeIEBeYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE+QAXD2wAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAjYAAgQFxCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFw9sAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEBcQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcPbABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAXEICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFJwAXD2wAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAkYAAgQFxCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw9sAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEBcQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcPbABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAXEICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXD2wAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQFxCAgICIAdgFsICIAACN8QEgCbAJwAnRCSACEAnwCgEJMAIwCeEJQAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlEJwAMQBlAFcAZQGRDdMAZQBlEKQAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAXwIgAwIgGSBAToICIEBewgS3C+dINMAOgA7AA4QqBCrAEaiAZoBm4A9gD6iEKwQrYEBfYEBiIAo2QAhACUQsAAOACgQsQAjAFYQsg3oAZoAVwB2ABcAKQAxAGUQul8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBeoA9gAyAL4AAgAQIgQF+0wA6ADsADhC8EMUARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgQxhDHEMgQyRDKEMsQzBDNgQF/gQGAgQGBgQGDgQGEgQGFgQGGgQGHgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcQrABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAX0ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEKwAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQF9CAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXEO8AFxCsAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQGCgACBAX0ICAgIgB2AQwgIgAAI0wA6ADsADhD9EP4ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcQrABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAX0ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEKwAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQF9CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxCsAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEBfQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcQrABlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAX0ICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEKwAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQF9CAgICIAdgEgICIAACNkAIQAlEUwADgAoEU0AIwBWEU4N6AGbAFcAdgAXACkAMQBlEVZfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAXqAPoAMgC+AAIAECIEBidMAOgA7AA4RWBFgAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cRYRFiEWMRZBFlEWYRZ4EBioEBi4EBjIEBjYEBj4EBkIEBkYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE+QAXEK0AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAjYAAgQGICAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxCtAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEBiAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcQrQBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAYgICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcRmAAXEK0AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAY6AAIEBiAgICAiAHYBYCAiAAAgRAljfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcQrQBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBAYgICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEK0AZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQGICAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxCtAGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIEBiAgICAiAHYBbCAiAAAjfEBIAmwCcAJ0R1AAhAJ8AoBHVACMAnhHWAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZRHeADEAZQBXAGUBkQ3UAGUAZRHmAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBJAgIgQGUCIAMCIBkgQE7CAiBAZMIEinOajrTADoAOwAOEeoR7QBGogGaAZuAPYA+ohHuEe+BAZWBAaCAKNkAIQAlEfIADgAoEfMAIwBWEfQN6QGaAFcAdgAXACkAMQBlEfxfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAZKAPYAMgC+AAIAECIEBltMAOgA7AA4R/hIHAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioEggSCRIKEgsSDBINEg4SD4EBl4EBmIEBmYEBm4EBnIEBnYEBnoEBn4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEe4AZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQGVCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxHuAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEBlQgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxIxABcR7gBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEBmoAAgQGVCAgICIAdgEMICIAACNMAOgA7AA4SPxJAAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEe4AZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQGVCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxHuAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEBlQgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcR7gBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAZUICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEe4AZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQGVCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxHuAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEBlQgICAiAHYBICAiAAAjZACEAJRKOAA4AKBKPACMAVhKQDekBmwBXAHYAFwApADEAZRKYXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQGSgD6ADIAvgACABAiBAaHTADoAOwAOEpoSogBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunEqMSpBKlEqYSpxKoEqmBAaKBAaOBAaSBAaWBAaaBAaeBAaiAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFxHvAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBAaAICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEe8AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQGgCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxHvAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEBoAgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABcR7wBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgQGgCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxHvAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEBoAgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcR7wBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAaAICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEe8AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQGgCAgICIAdgFsICIAACN8QEgCbAJwAnRMVACEAnwCgExYAIwCeExcAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlEx8AMQBlAFcAZQGRDdUAZQBlEycAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAasIgAwIgGSBATwICIEBqggSakV21tMAOgA7AA4TKxMuAEaiAZoBm4A9gD6iEy8TMIEBrIEBt4Ao2QAhACUTMwAOACgTNAAjAFYTNQ3qAZoAVwB2ABcAKQAxAGUTPV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBqYA9gAyAL4AAgAQIgQGt0wA6ADsADhM/E0gARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgTSRNKE0sTTBNNE04TTxNQgQGugQGvgQGwgQGygQGzgQG0gQG1gQG2gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcTLwBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAawICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEy8AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQGsCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXE3IAFxMvAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQGxgACBAawICAgIgB2AQwgIgAAI0wA6ADsADhOAE4EARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcTLwBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAawICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEy8AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQGsCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxMvAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEBrAgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcTLwBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAawICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEy8AZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQGsCAgICIAdgEgICIAACNkAIQAlE88ADgAoE9AAIwBWE9EN6gGbAFcAdgAXACkAMQBlE9lfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAamAPoAMgC+AAIAECIEBuNMAOgA7AA4T2xPjAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cT5BPlE+YT5xPoE+kT6oEBuYEBuoEBu4EBvIEBvYEBvoEBv4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXEzAAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQKAAIEBtwgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcTMABlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBAbcICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEzAAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQG3CAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC24AFxMwAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQEGgACBAbcICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEzAAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQG3CAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxMwAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEBtwgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcTMABlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBAbcICAgIgB2AWwgIgAAI3xASAJsAnACdFFYAIQCfAKAUVwAjAJ4UWAChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUUYAAxAGUAVwBlAZEN1gBlAGUUaABlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBASQICIEBwgiADAiAZIEBPQgIgQHBCBMAAAABG0vG8dMAOgA7AA4UbBRvAEaiAZoBm4A9gD6iFHAUcYEBw4EBzoAo2QAhACUUdAAOACgUdQAjAFYUdg3rAZoAVwB2ABcAKQAxAGUUfl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBwIA9gAyAL4AAgAQIgQHE0wA6ADsADhSAFIkARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgUihSLFIwUjRSOFI8UkBSRgQHFgQHGgQHHgQHJgQHKgQHLgQHMgQHNgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcUcABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAcMICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFHAAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQHDCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXFLMAFxRwAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQHIgACBAcMICAgIgB2AQwgIgAAI0wA6ADsADhTBFMIARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcUcABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAcMICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFHAAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQHDCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxRwAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEBwwgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcUcABlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAcMICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFHAAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQHDCAgICIAdgEgICIAACNkAIQAlFRAADgAoFREAIwBWFRIN6wGbAFcAdgAXACkAMQBlFRpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAcCAPoAMgC+AAIAECIEBz9MAOgA7AA4VHBUkAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cVJRUmFScVKBUpFSoVK4EB0IEB0oEB04EB1IEB1YEB1oEB14Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcVLwAXFHEAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAdGAAIEBzggICAiAHYBVCAiAAAhTMC4w3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFHEAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQHOCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxRxAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEBzggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxGYABcUcQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBjoAAgQHOCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxRxAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEBzggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcUcQBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAc4ICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFHEAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQHOCAgICIAdgFsICIAACN8QEgCbAJwAnRWYACEAnwCgFZkAIwCeFZoAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlFaIAMQBlAFcAZQGRDdcAZQBlFaoAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAdoIgAwIgGSBAT4ICIEB2QgSpOwRfNMAOgA7AA4VrhWxAEaiAZoBm4A9gD6iFbIVs4EB24EB5oAo2QAhACUVtgAOACgVtwAjAFYVuA3sAZoAVwB2ABcAKQAxAGUVwF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEB2IA9gAyAL4AAgAQIgQHc0wA6ADsADhXCFcsARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgVzBXNFc4VzxXQFdEV0hXTgQHdgQHegQHfgQHhgQHigQHjgQHkgQHlgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcVsgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAdsICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFbIAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQHbCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXFfUAFxWyAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQHggACBAdsICAgIgB2AQwgIgAAI0wA6ADsADhYDFgQARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcVsgBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAdsICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFbIAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQHbCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxWyAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEB2wgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcVsgBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAdsICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFbIAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQHbCAgICIAdgEgICIAACNkAIQAlFlIADgAoFlMAIwBWFlQN7AGbAFcAdgAXACkAMQBlFlxfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAdiAPoAMgC+AAIAECIEB59MAOgA7AA4WXhZmAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cWZxZoFmkWahZrFmwWbYEB6IEB6YEB6oEB64EB7IEB7YEB7oAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE+QAXFbMAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAjYAAgQHmCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxWzAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEB5ggICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcVswBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAeYICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFJwAXFbMAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAkYAAgQHmCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxWzAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEB5ggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcVswBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAeYICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFbMAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQHmCAgICIAdgFsICIAACN8QEgCbAJwAnRbZACEAnwCgFtoAIwCeFtsAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlFuMAMQBlAFcAZQGRDdgAZQBlFusAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAfEIgAwIgGSBAT8ICIEB8AgSwn3L3NMAOgA7AA4W7xbyAEaiAZoBm4A9gD6iFvMW9IEB8oEB/YAo2QAhACUW9wAOACgW+AAjAFYW+Q3tAZoAVwB2ABcAKQAxAGUXAV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEB74A9gAyAL4AAgAQIgQHz0wA6ADsADhcDFwwARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgXDRcOFw8XEBcRFxIXExcUgQH0gQH1gQH2gQH4gQH5gQH6gQH7gQH8gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcW8wBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAfIICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFvMAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQHyCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXFzYAFxbzAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQH3gACBAfIICAgIgB2AQwgIgAAI0wA6ADsADhdEF0UARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcW8wBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAfIICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFvMAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQHyCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxbzAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEB8ggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcW8wBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAfIICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFvMAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQHyCAgICIAdgEgICIAACNkAIQAlF5MADgAoF5QAIwBWF5UN7QGbAFcAdgAXACkAMQBlF51fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAe+APoAMgC+AAIAECIEB/tMAOgA7AA4XnxenAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cXqBepF6oXqxesF60XroEB/4ECAIECAYECAoECA4ECBIECBYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE+QAXFvQAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAjYAAgQH9CAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxb0AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEB/QgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcW9ABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAf0ICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFJwAXFvQAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAkYAAgQH9CAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxb0AGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEB/QgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcW9ABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAf0ICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFvQAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQH9CAgICIAdgFsICIAACN8QEgCbAJwAnRgaACEAnwCgGBsAIwCeGBwAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlGCQAMQBlAFcAZQGRDdkAZQBlGCwAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAggIgAwIgGSBAUAICIECBwgSK1zmftMAOgA7AA4YMBgzAEaiAZoBm4A9gD6iGDQYNYECCYECFIAo2QAhACUYOAAOACgYOQAjAFYYOg3uAZoAVwB2ABcAKQAxAGUYQl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECBoA9gAyAL4AAgAQIgQIK0wA6ADsADhhEGE0ARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgYThhPGFAYURhSGFMYVBhVgQILgQIMgQINgQIPgQIQgQIRgQISgQITgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcYNABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAgkICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGDQAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQIJCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXGHcAFxg0AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQIOgACBAgkICAgIgB2AQwgIgAAI0wA6ADsADhiFGIYARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcYNABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAgkICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXGDQAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQIJCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxg0AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIECCQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcYNABlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAgkICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGDQAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQIJCAgICIAdgEgICIAACNkAIQAlGNQADgAoGNUAIwBWGNYN7gGbAFcAdgAXACkAMQBlGN5fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAgaAPoAMgC+AAIAECIECFdMAOgA7AA4Y4BjoAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cY6RjqGOsY7BjtGO4Y74ECFoECF4ECGIECGYECGoECG4ECHIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGDUAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQIUCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxg1AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIECFAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcYNQBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAhQICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcPFQAXGDUAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAV+AAIECFAgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcYNQBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBAhQICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGDUAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQIUCAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxg1AGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIECFAgICAiAHYBbCAiAAAjfEBIAmwCcAJ0ZWwAhAJ8AoBlcACMAnhldAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZRllADEAZQBXAGUBkQ3aAGUAZRltAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBJAgIgQIfCIAMCIBkgQFBCAiBAh4IEjb8IzrTADoAOwAOGXEZdABGogGaAZuAPYA+ohl1GXaBAiCBAiuAKNkAIQAlGXkADgAoGXoAIwBWGXsN7wGaAFcAdgAXACkAMQBlGYNfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAh2APYAMgC+AAIAECIECIdMAOgA7AA4ZhRmOAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioGY8ZkBmRGZIZkxmUGZUZloECIoECI4ECJIECJoECJ4ECKIECKYECKoAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGXUAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQIgCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxl1AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIECIAgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxm4ABcZdQBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIECJYAAgQIgCAgICIAdgEMICIAACNMAOgA7AA4ZxhnHAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGXUAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQIgCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFxl1AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIECIAgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcZdQBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAiAICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGXUAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQIgCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxl1AGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIECIAgICAiAHYBICAiAAAjZACEAJRoVAA4AKBoWACMAVhoXDe8BmwBXAHYAFwApADEAZRofXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQIdgD6ADIAvgACABAiBAizTADoAOwAOGiEaKQBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunGioaKxosGi0aLhovGjCBAi2BAi6BAi+BAjCBAjGBAjKBAjOAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxl2AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgACAAIECKwgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcZdgBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBAisICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGXYAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQIrCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXApsAFxl2AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgGCAAIECKwgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcZdgBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBAisICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGXYAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQIrCAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxl2AGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIECKwgICAiAHYBbCAiAAAjfEBIAmwCcAJ0anAAhAJ8AoBqdACMAnhqeAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZRqmADEAZQBXAGUBkQ3bAGUAZRquAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBJAgIgQI2CIAMCIBkgQFCCAiBAjUIEv7K2DPTADoAOwAOGrIatQBGogGaAZuAPYA+ohq2GreBAjeBAkKAKNkAIQAlGroADgAoGrsAIwBWGrwN8AGaAFcAdgAXACkAMQBlGsRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAjSAPYAMgC+AAIAECIECONMAOgA7AA4axhrPAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioGtAa0RrSGtMa1BrVGtYa14ECOYECOoECO4ECPYECPoECP4ECQIECQYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGrYAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQI3CAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxq2AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIECNwgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxr5ABcatgBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIECPIAAgQI3CAgICIAdgEMICIAACNMAOgA7AA4bBxsIAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGrYAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQI3CAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxq2AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIECNwgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcatgBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAjcICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGrYAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQI3CAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxq2AGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIECNwgICAiAHYBICAiAAAjZACEAJRtWAA4AKBtXACMAVhtYDfABmwBXAHYAFwApADEAZRtgXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQI0gD6ADIAvgACABAiBAkPTADoAOwAOG2IbagBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunG2sbbBttG24bbxtwG3GBAkSBAkWBAkaBAkeBAkiBAkmBAkqAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFxq3AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBAkIICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGrcAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQJCCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxq3AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIECQggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABcatwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgQJCCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxq3AGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIECQggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcatwBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAkIICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGrcAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQJCCAgICIAdgFsICIAACN8QEgCbAJwAnRvdACEAnwCgG94AIwCeG98AoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlG+cAMQBlAFcAZQGRDdwAZQBlG+8AZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAk0IgAwIgGSBAUMICIECTAgSuUnP8dMAOgA7AA4b8xv2AEaiAZoBm4A9gD6iG/cb+IECToECWYAo2QAhACUb+wAOACgb/AAjAFYb/Q3xAZoAVwB2ABcAKQAxAGUcBV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECS4A9gAyAL4AAgAQIgQJP0wA6ADsADhwHHBAARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgcERwSHBMcFBwVHBYcFxwYgQJQgQJRgQJSgQJUgQJVgQJWgQJXgQJYgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcb9wBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAk4ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXG/cAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQJOCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXHDoAFxv3AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQJTgACBAk4ICAgIgB2AQwgIgAAI0wA6ADsADhxIHEkARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcb9wBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAk4ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXG/cAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQJOCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxv3AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIECTggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcb9wBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAk4ICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXG/cAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQJOCAgICIAdgEgICIAACNkAIQAlHJcADgAoHJgAIwBWHJkN8QGbAFcAdgAXACkAMQBlHKFfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAkuAPoAMgC+AAIAECIECWtMAOgA7AA4coxyrAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6ccrBytHK4crxywHLEcsoECW4ECXIECXYECXoECX4ECYIECYYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE+QAXG/gAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAjYAAgQJZCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxv4AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIECWQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcb+ABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAlkICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFJwAXG/gAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAkYAAgQJZCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxv4AGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIECWQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcb+ABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAlkICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXG/gAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQJZCAgICIAdgFsICIAACN8QEgCbAJwAnR0eACEAnwCgHR8AIwCeHSAAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlHSgAMQBlAFcAZQGRDd0AZQBlHTAAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAmQIgAwIgGSBAUQICIECYwgS5Xw6MtMAOgA7AA4dNB03AEaiAZoBm4A9gD6iHTgdOYECZYECcIAo2QAhACUdPAAOACgdPQAjAFYdPg3yAZoAVwB2ABcAKQAxAGUdRl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECYoA9gAyAL4AAgAQIgQJm0wA6ADsADh1IHVEARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgdUh1THVQdVR1WHVcdWB1ZgQJngQJogQJpgQJrgQJsgQJtgQJugQJvgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcdOABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAmUICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHTgAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQJlCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXHXsAFx04AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQJqgACBAmUICAgIgB2AQwgIgAAI0wA6ADsADh2JHYoARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcdOABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAmUICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXHTgAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQJlCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx04AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIECZQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcdOABlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAmUICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXHTgAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQJlCAgICIAdgEgICIAACNkAIQAlHdgADgAoHdkAIwBWHdoN8gGbAFcAdgAXACkAMQBlHeJfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAmKAPoAMgC+AAIAECIECcdMAOgA7AA4d5B3sAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cd7R3uHe8d8B3xHfId84ECcoECdIECdYECdoECeIECeYECeoAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcd9wAXHTkAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAnOAAIECcAgICAiAHYBVCAiAAAhSTk/fEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcdOQBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBAnAICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHTkAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQJwCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXHiUAFx05AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQJ3gACBAnAICAgIgB2AWAgIgAAIEQMg3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHTkAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQJwCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx05AGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIECcAgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcdOQBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBAnAICAgIgB2AWwgIgAAI3xASAJsAnACdHmEAIQCfAKAeYgAjAJ4eYwChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUeawAxAGUAVwBlAZEN3gBlAGUecwBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBASQICIECfQiADAiAZIEBRQgIgQJ8CBMAAAABCLMLmNMAOgA7AA4edx56AEaiAZoBm4A9gD6iHnsefIECfoECiYAo2QAhACUefwAOACgegAAjAFYegQ3zAZoAVwB2ABcAKQAxAGUeiV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECe4A9gAyAL4AAgAQIgQJ/0wA6ADsADh6LHpQARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgelR6WHpcemB6ZHpoemx6cgQKAgQKBgQKCgQKEgQKFgQKGgQKHgQKIgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABceewBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAn4ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHnsAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQJ+CAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXHr4AFx57AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQKDgACBAn4ICAgIgB2AQwgIgAAI0wA6ADsADh7MHs0ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABceewBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAn4ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXHnsAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQJ+CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx57AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIECfggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABceewBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAn4ICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXHnsAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQJ+CAgICIAdgEgICIAACNkAIQAlHxsADgAoHxwAIwBWHx0N8wGbAFcAdgAXACkAMQBlHyVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAnuAPoAMgC+AAIAECIECitMAOgA7AA4fJx8vAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cfMB8xHzIfMx80HzUfNoECi4ECjIECjYECjoECj4ECkIECkYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHnwAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQKJCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx58AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIECiQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcefABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAokICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcPFQAXHnwAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAV+AAIECiQgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcefABlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBAokICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHnwAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQKJCAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx58AGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIECiQgICAiAHYBbCAiAAAjfEBIAmwCcAJ0fogAhAJ8AoB+jACMAnh+kAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZR+sADEAZQBXAGUBkQ3fAGUAZR+0AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBJAgIgQKUCIAMCIBkgQFGCAiBApMIEj0qJJTTADoAOwAOH7gfuwBGogGaAZuAPYA+oh+8H72BApWBAqCAKNkAIQAlH8AADgAoH8EAIwBWH8IN9AGaAFcAdgAXACkAMQBlH8pfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBApKAPYAMgC+AAIAECIECltMAOgA7AA4fzB/VAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioH9Yf1x/YH9kf2h/bH9wf3YECl4ECmIECmYECm4ECnIECnYECnoECn4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXH7wAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQKVCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx+8AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEClQgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFx//ABcfvABlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIECmoAAgQKVCAgICIAdgEMICIAACNMAOgA7AA4gDSAOAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXH7wAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQKVCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx+8AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEClQgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcfvABlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBApUICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXH7wAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQKVCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx+8AGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEClQgICAiAHYBICAiAAAjZACEAJSBcAA4AKCBdACMAViBeDfQBmwBXAHYAFwApADEAZSBmXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQKSgD6ADIAvgACABAiBAqHTADoAOwAOIGggcABGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunIHEgciBzIHQgdSB2IHeBAqKBAqOBAqSBAqWBAqaBAqeBAqiAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFx+9AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBAqAICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXH70AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQKgCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx+9AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIECoAgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABcfvQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgQKgCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx+9AGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIECoAgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcfvQBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAqAICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXH70AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQKgCAgICIAdgFsICIAACN8QEgCbAJwAnSDjACEAnwCgIOQAIwCeIOUAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlIO0AMQBlAFcAZQGRDeAAZQBlIPUAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAqsIgAwIgGSBAUcICIECqggSLBnP7tMAOgA7AA4g+SD8AEaiAZoBm4A9gD6iIP0g/oECrIECt4Ao2QAhACUhAQAOACghAgAjAFYhAw31AZoAVwB2ABcAKQAxAGUhC18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECqYA9gAyAL4AAgAQIgQKt0wA6ADsADiENIRYARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKghFyEYIRkhGiEbIRwhHSEegQKugQKvgQKwgQKygQKzgQK0gQK1gQK2gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcg/QBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAqwICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIP0AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQKsCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXIUAAFyD9AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQKxgACBAqwICAgIgB2AQwgIgAAI0wA6ADsADiFOIU8ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcg/QBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAqwICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXIP0AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQKsCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyD9AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIECrAgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcg/QBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAqwICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXIP0AZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQKsCAgICIAdgEgICIAACNkAIQAlIZ0ADgAoIZ4AIwBWIZ8N9QGbAFcAdgAXACkAMQBlIadfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAqmAPoAMgC+AAIAECIECuNMAOgA7AA4hqSGxAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6chsiGzIbQhtSG2IbchuIECuYECuoECu4ECvIECvYECvoECv4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXIP4AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQKAAIECtwgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcg/gBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBArcICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIP4AZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQK3CAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC24AFyD+AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQEGgACBArcICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIP4AZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQK3CAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyD+AGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIECtwgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcg/gBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBArcICAgIgB2AWwgIgAAI3xASAJsAnACdIiQAIQCfAKAiJQAjAJ4iJgChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUiLgAxAGUAVwBlAZEN4QBlAGUiNgBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBASQICIECwgiADAiAZIEBSAgIgQLBCBKv1klu0wA6ADsADiI6Ij0ARqIBmgGbgD2APqIiPiI/gQLDgQLOgCjZACEAJSJCAA4AKCJDACMAViJEDfYBmgBXAHYAFwApADEAZSJMXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQLAgD2ADIAvgACABAiBAsTTADoAOwAOIk4iVwBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqCJYIlkiWiJbIlwiXSJeIl+BAsWBAsaBAseBAsmBAsqBAsuBAsyBAs2AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyI+AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIECwwgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABciPgBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBAsMICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcigQAXIj4AZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBAsiAAIECwwgICAiAHYBDCAiAAAjTADoAOwAOIo8ikABGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyI+AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIECwwgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABciPgBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBAsMICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXIj4AZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQLDCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyI+AGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIECwwgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABciPgBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBAsMICAgIgB2ASAgIgAAI2QAhACUi3gAOACgi3wAjAFYi4A32AZsAVwB2ABcAKQAxAGUi6F8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECwIA+gAyAL4AAgAQIgQLP0wA6ADsADiLqIvIARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpyLzIvQi9SL2Ivci+CL5gQLQgQLSgQLTgQLUgQLWgQLXgQLYgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyL9ABciPwBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEC0YAAgQLOCAgICIAdgFUICIAACF5SIDMvMDYvMjkgMDoxMN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyI/AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIECzggICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABciPwBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAs4ICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcjKwAXIj8AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAtWAAIECzggICAiAHYBYCAiAAAgRA4TfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABciPwBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBAs4ICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIj8AZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQLOCAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyI/AGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIECzggICAiAHYBbCAiAAAjfEBIAmwCcAJ0jZwAhAJ8AoCNoACMAniNpAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZSNxADEAZQBXAGUBkQ3iAGUAZSN5AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBJAgIgQLbCIAMCIBkgQFJCAiBAtoIEqcwrGbTADoAOwAOI30jgABGogGaAZuAPYA+oiOBI4KBAtyBAueAKNkAIQAlI4UADgAoI4YAIwBWI4cN9wGaAFcAdgAXACkAMQBlI49fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAtmAPYAMgC+AAIAECIEC3dMAOgA7AA4jkSOaAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioI5sjnCOdI54jnyOgI6EjooEC3oEC34EC4IEC4oEC44EC5IEC5YEC5oAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXI4EAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQLcCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyOBAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEC3AgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyPEABcjgQBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEC4YAAgQLcCAgICIAdgEMICIAACNMAOgA7AA4j0iPTAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXI4EAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQLcCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFyOBAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIEC3AgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcjgQBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAtwICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXI4EAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQLcCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyOBAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEC3AgICAiAHYBICAiAAAjZACEAJSQhAA4AKCQiACMAViQjDfcBmwBXAHYAFwApADEAZSQrXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQLZgD6ADIAvgACABAiBAujTADoAOwAOJC0kNQBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunJDYkNyQ4JDkkOiQ7JDyBAumBAuqBAuuBAuyBAu2BAu6BAu+AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyOCAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgACAAIEC5wgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcjggBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBAucICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXI4IAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQLnCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC24AFyOCAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQEGgACBAucICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXI4IAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQLnCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyOCAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEC5wgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcjggBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBAucICAgIgB2AWwgIgAAI3xASAJsAnACdJKgAIQCfAKAkqQAjAJ4kqgChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUksgAxAGUAVwBlAZEBbABlAGUkugBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBASQICIEC8giADAiAZIA4CAiBAvEIEuPetQTTADoAOwAOJL4kwQBGogGaAZuAPYA+oiTCJMOBAvOBAv6AKNkAIQAlJMYADgAoJMcAIwBWJMgN+AGaAFcAdgAXACkAMQBlJNBfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAvCAPYAMgC+AAIAECIEC9NMAOgA7AA4k0iTbAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioJNwk3STeJN8k4CThJOIk44EC9YEC9oEC94EC+YEC+oEC+4EC/IEC/YAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJMIAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQLzCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyTCAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEC8wgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyUFABckwgBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEC+IAAgQLzCAgICIAdgEMICIAACNMAOgA7AA4lEyUUAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJMIAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQLzCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyTCAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEC8wgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABckwgBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAvMICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJMIAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQLzCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyTCAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEC8wgICAiAHYBICAiAAAjZACEAJSViAA4AKCVjACMAViVkDfgBmwBXAHYAFwApADEAZSVsXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQLwgD6ADIAvgACABAiBAv/TADoAOwAOJW4ldgBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunJXcleCV5JXoleyV8JX2BAwCBAwGBAwKBAwOBAwSBAwWBAwaAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFyTDAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBAv4ICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJMMAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQL+CAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyTDAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEC/ggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABckwwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgQL+CAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyTDAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEC/ggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABckwwBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAv4ICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJMMAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQL+CAgICIAdgFsICIAACN8QEgCbAJwAnSXpACEAnwCgJeoAIwCeJesAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlJfMAMQBlAFcAZQGRDeQAZQBlJfsAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAwkIgAwIgGSBAUoICIEDCAgTAAAAASVrX+bTADoAOwAOJf8mAgBGogGaAZuAPYA+oiYDJgSBAwqBAxWAKNkAIQAlJgcADgAoJggAIwBWJgkN+QGaAFcAdgAXACkAMQBlJhFfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAweAPYAMgC+AAIAECIEDC9MAOgA7AA4mEyYcAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioJh0mHiYfJiAmISYiJiMmJIEDDIEDDYEDDoEDEIEDEYEDEoEDE4EDFIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJgMAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQMKCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyYDAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEDCggICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyZGABcmAwBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEDD4AAgQMKCAgICIAdgEMICIAACNMAOgA7AA4mVCZVAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJgMAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQMKCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyYDAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEDCggICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcmAwBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAwoICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJgMAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQMKCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyYDAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEDCggICAiAHYBICAiAAAjZACEAJSajAA4AKCakACMAVialDfkBmwBXAHYAFwApADEAZSatXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQMHgD6ADIAvgACABAiBAxbTADoAOwAOJq8mtwBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunJrgmuSa6JrsmvCa9Jr6BAxeBAxiBAxmBAxqBAxuBAxyBAx2AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXFS8AFyYEAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQHRgACBAxUICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJgQAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQMVCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyYEAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEDFQgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxGYABcmBABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBjoAAgQMVCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyYEAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEDFQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcmBABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAxUICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJgQAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQMVCAgICIAdgFsICIAACNIAOwAOJyoAtaCAHN8QECctJy4nLycwACEnMScyACMnMyc0AA4AJSc1JzYAKABWAFcnOAApACkAFCc8AF0AMQApAFcAYAA/AFcnQydEAGVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2WADIEDMoAEgASAAoEDIYEBIYAEgAyBASOACIAMgQRkgQMgCBKIZBBe0wA6ADsADidIJ0oARqEAaoAOoSdLgQMigCjZACEAJSdOAA4AKCdPACMAVidQAEQAagBXAHYAFwApADEAZSdYXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQMfgA6ADIAvgACABAiBAyPTADoAOwAOJ1onZABGqQB9AH4AfwCAAIEAggCDAIQAhYARgBKAE4AUgBWAFoAXgBiAGaknZSdmJ2cnaCdpJ2onaydsJ22BAySBAyaBAyeBAymBAyqBAyyBAy2BAy+BAzCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJ3EAFydLAGUAZQBlADEAZQCtAH0AZQBlABcAZYAAgQMlgACBAyIICAgIgB2AEQgIgAAI0gA7AA4nfwC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJ0sAZQBlAGUAMQBlAK0AfgBlAGUAFwBlgACAAIAAgQMiCAgICIAdgBIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJ5IAFydLAGUAZQBlADEAZQCtAH8AZQBlABcAZYAAgQMogACBAyIICAgIgB2AEwgIgAAI0gA7AA4noAC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJ0sAZQBlAGUAMQBlAK0AgABlAGUAFwBlgACAAIAAgQMiCAgICIAdgBQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJ7MAFydLAGUAZQBlADEAZQCtAIEAZQBlABcAZYAAgQMrgACBAyIICAgIgB2AFQgIgAAI0gA7AA4nwQC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJ0sAZQBlAGUAMQBlAK0AggBlAGUAFwBlgACAJYAAgQMiCAgICIAdgBYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJ9QAFydLAGUAZQBlADEAZQCtAIMAZQBlABcAZYAAgQMugACBAyIICAgIgB2AFwgIgAAI0wA6ADsADifiJ+MARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEuABcnSwBlAGUAZQAxAGUArQCEAGUAZQAXAGWAAIAqgACBAyIICAgIgB2AGAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcn9gAXJ0sAZQBlAGUAMQBlAK0AhQBlAGUAFwBlgACBAzGAAIEDIggICAiAHYAZCAiAAAhaLkdhbGxlcnlNT9MAOgA7AA4oBSgTAEatDeQN2ygIDd8oCg3UAWwoDQ3VDdwoECgRDdGBAUqBAUKBAzOBAUaBAzSBATuAOIEDNYEBPIEBQ4EDNoEDN4EBOK0oFCgVKBYoFygYKBkoGigbKBwoHSgeKB8oIIEDOIEDT4EDZoEDfoEDlYEDrIEDw4ED2oED8YEECIEEH4EENoEETYAoWnBvc3RlZERhdGVcbGFzdE9wZW5EYXRlVXRva2VuWmdhbGxlcnlVUkxadGFnU3RyaW5nc98QEgCbAJwAnSgoACEAnwCgKCkAIwCeKCoAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlKDIAMQBlAFcAZQGRDeQAZQBlKDoAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMfCAiBAzoIgAwIgGSBAUoICIEDOQgSyW9B69MAOgA7AA4oPihBAEaiAZoBm4A9gD6iKEIoQ4EDO4EDRoAo2QAhACUoRgAOACgoRwAjAFYoSCgUAZoAVwB2ABcAKQAxAGUoUF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDOIA9gAyAL4AAgAQIgQM80wA6ADsADihSKFsARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgoXChdKF4oXyhgKGEoYihjgQM9gQM+gQM/gQNBgQNCgQNDgQNEgQNFgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcoQgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAzsICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKEIAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQM7CAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXKIUAFyhCAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQNAgACBAzsICAgIgB2AQwgIgAAI0wA6ADsADiiTKJQARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcoQgBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAzsICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKEIAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQM7CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyhCAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEDOwgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcoQgBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAzsICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKEIAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQM7CAgICIAdgEgICIAACNkAIQAlKOIADgAoKOMAIwBWKOQoFAGbAFcAdgAXACkAMQBlKOxfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAziAPoAMgC+AAIAECIEDR9MAOgA7AA4o7ij2AEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6co9yj4KPko+ij7KPwo/YEDSIEDSYEDSoEDS4EDTIEDTYEDToAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcVLwAXKEMAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAdGAAIEDRggICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcoQwBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBA0YICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKEMAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQNGCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXEZgAFyhDAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQGOgACBA0YICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKEMAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQNGCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyhDAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEDRggICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcoQwBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBA0YICAgIgB2AWwgIgAAI3xASAJsAnACdKWkAIQCfAKApagAjAJ4pawChAA4AJQCiAKMAKACkABcAFwAXACkARABlAGUpcwAxAGUAVwBlAZEN2wBlAGUpewBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAx8ICIEDUQiADAiAZIEBQggIgQNQCBMAAAABJehGndMAOgA7AA4pfymCAEaiAZoBm4A9gD6iKYMphIEDUoEDXYAo2QAhACUphwAOACgpiAAjAFYpiSgVAZoAVwB2ABcAKQAxAGUpkV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDT4A9gAyAL4AAgAQIgQNT0wA6ADsADimTKZwARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgpnSmeKZ8poCmhKaIpoymkgQNUgQNVgQNWgQNYgQNZgQNagQNbgQNcgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcpgwBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBA1IICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKYMAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQNSCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXKcYAFymDAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQNXgACBA1IICAgIgB2AQwgIgAAI0wA6ADsADinUKdUARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcpgwBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBA1IICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKYMAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQNSCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFymDAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEDUggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcpgwBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBA1IICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKYMAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQNSCAgICIAdgEgICIAACNkAIQAlKiMADgAoKiQAIwBWKiUoFQGbAFcAdgAXACkAMQBlKi1fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA0+APoAMgC+AAIAECIEDXtMAOgA7AA4qLyo3AEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cqOCo5KjoqOyo8Kj0qPoEDX4EDYIEDYYEDYoEDY4EDZIEDZYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXKYQAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQKAAIEDXQgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcphABlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBA10ICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKYQAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQNdCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC24AFymEAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQEGgACBA10ICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKYQAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQNdCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFymEAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEDXQgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcphABlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBA10ICAgIgB2AWwgIgAAI3xASAJsAnACdKqoAIQCfAKAqqwAjAJ4qrAChAA4AJQCiAKMAKACkABcAFwAXACkARABlAGUqtAAxAGUAVwBlAZEoCABlAGUqvABlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAx8ICIEDaAiADAiAZIEDMwgIgQNnCBLD8ov20wA6ADsADirAKsMARqIBmgGbgD2APqIqxCrFgQNpgQN0gCjZACEAJSrIAA4AKCrJACMAVirKKBYBmgBXAHYAFwApADEAZSrSXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQNmgD2ADIAvgACABAiBA2rTADoAOwAOKtQq3QBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqCreKt8q4CrhKuIq4yrkKuWBA2uBA2yBA22BA2+BA3CBA3GBA3KBA3OAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyrEAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEDaQgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcqxABlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBA2kICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcrBwAXKsQAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBA26AAIEDaQgICAiAHYBDCAiAAAjTADoAOwAOKxUrFgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyrEAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEDaQgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcqxABlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBA2kICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKsQAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQNpCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyrEAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEDaQgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcqxABlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBA2kICAgIgB2ASAgIgAAI2QAhACUrZAAOACgrZQAjAFYrZigWAZsAVwB2ABcAKQAxAGUrbl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDZoA+gAyAL4AAgAQIgQN10wA6ADsADitwK3gARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpyt5K3oreyt8K30rfit/gQN2gQN4gQN5gQN6gQN7gQN8gQN9gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyuDABcqxQBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEDd4AAgQN0CAgICIAdgFUICIAACF5SIDMvMDYvMjkgMDoxMd8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyrFAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEDdAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcqxQBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBA3QICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcjKwAXKsUAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAtWAAIEDdAgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcqxQBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBA3QICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKsUAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQN0CAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyrFAGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIEDdAgICAiAHYBbCAiAAAjfEBIAmwCcAJ0r7AAhAJ8AoCvtACMAnivuAKEADgAlAKIAowAoAKQAFwAXABcAKQBEAGUAZSv2ADEAZQBXAGUBkQ3fAGUAZSv+AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEDHwgIgQOACIAMCIBkgQFGCAiBA38IEq7LSQzTADoAOwAOLAIsBQBGogGaAZuAPYA+oiwGLAeBA4GBA4yAKNkAIQAlLAoADgAoLAsAIwBWLAwoFwGaAFcAdgAXACkAMQBlLBRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA36APYAMgC+AAIAECIEDgtMAOgA7AA4sFiwfAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioLCAsISwiLCMsJCwlLCYsJ4EDg4EDhIEDhYEDh4EDiIEDiYEDioEDi4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLAYAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQOBCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFywGAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEDgQgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyxJABcsBgBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEDhoAAgQOBCAgICIAdgEMICIAACNMAOgA7AA4sVyxYAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLAYAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQOBCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFywGAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIEDgQgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcsBgBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBA4EICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLAYAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQOBCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFywGAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEDgQgICAiAHYBICAiAAAjZACEAJSymAA4AKCynACMAViyoKBcBmwBXAHYAFwApADEAZSywXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQN+gD6ADIAvgACABAiBA43TADoAOwAOLLIsugBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunLLssvCy9LL4svyzALMGBA46BA4+BA5CBA5GBA5KBA5OBA5SAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFywHAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgACAAIEDjAgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcsBwBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBA4wICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLAcAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQOMCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC24AFywHAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQEGgACBA4wICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLAcAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQOMCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFywHAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEDjAgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcsBwBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBA4wICAgIgB2AWwgIgAAI3xASAJsAnACdLS0AIQCfAKAtLgAjAJ4tLwChAA4AJQCiAKMAKACkABcAFwAXACkARABlAGUtNwAxAGUAVwBlAZEoCgBlAGUtPwBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAx8ICIEDlwiADAiAZIEDNAgIgQOWCBLhLFaW0wA6ADsADi1DLUYARqIBmgGbgD2APqItRy1IgQOYgQOjgCjZACEAJS1LAA4AKC1MACMAVi1NKBgBmgBXAHYAFwApADEAZS1VXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQOVgD2ADIAvgACABAiBA5nTADoAOwAOLVctYABGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqC1hLWItYy1kLWUtZi1nLWiBA5qBA5uBA5yBA56BA5+BA6CBA6GBA6KAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy1HAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEDmAgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABctRwBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBA5gICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABctigAXLUcAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBA52AAIEDmAgICAiAHYBDCAiAAAjTADoAOwAOLZgtmQBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy1HAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEDmAgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABctRwBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBA5gICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLUcAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQOYCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy1HAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEDmAgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABctRwBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBA5gICAgIgB2ASAgIgAAI2QAhACUt5wAOACgt6AAjAFYt6SgYAZsAVwB2ABcAKQAxAGUt8V8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDlYA+gAyAL4AAgAQIgQOk0wA6ADsADi3zLfsARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpy38Lf0t/i3/LgAuAS4CgQOlgQOmgQOngQOogQOpgQOqgQOrgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABctSABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBA6MICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLUgAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQOjCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy1IAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEDowgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyMrABctSABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEC1YAAgQOjCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy1IAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEDowgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABctSABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBA6MICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLUgAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQOjCAgICIAdgFsICIAACN8QEgCbAJwAnS5uACEAnwCgLm8AIwCeLnAAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlLngAMQBlAFcAZQGRDdQAZQBlLoAAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMfCAiBA64IgAwIgGSBATsICIEDrQgTAAAAASWXzaTTADoAOwAOLoQuhwBGogGaAZuAPYA+oi6ILomBA6+BA7qAKNkAIQAlLowADgAoLo0AIwBWLo4oGQGaAFcAdgAXACkAMQBlLpZfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA6yAPYAMgC+AAIAECIEDsNMAOgA7AA4umC6hAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioLqIuoy6kLqUupi6nLqguqYEDsYEDsoEDs4EDtYEDtoEDt4EDuIEDuYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLogAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQOvCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy6IAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEDrwgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFy7LABcuiABlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEDtIAAgQOvCAgICIAdgEMICIAACNMAOgA7AA4u2S7aAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLogAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQOvCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy6IAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEDrwgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcuiABlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBA68ICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLogAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQOvCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy6IAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEDrwgICAiAHYBICAiAAAjZACEAJS8oAA4AKC8pACMAVi8qKBkBmwBXAHYAFwApADEAZS8yXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQOsgD6ADIAvgACABAiBA7vTADoAOwAOLzQvPABGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunLz0vPi8/L0AvQS9CL0OBA7yBA72BA76BA7+BA8CBA8GBA8KAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFy6JAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBA7oICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLokAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQO6CAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy6JAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEDuggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABcuiQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgQO6CAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy6JAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEDuggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcuiQBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBA7oICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLokAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQO6CAgICIAdgFsICIAACN8QEgCbAJwAnS+vACEAnwCgL7AAIwCeL7EAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlL7kAMQBlAFcAZQGRAWwAZQBlL8EAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMfCAiBA8UIgAwIgGSAOAgIgQPECBJDDi5g0wA6ADsADi/FL8gARqIBmgGbgD2APqIvyS/KgQPGgQPRgCjZACEAJS/NAA4AKC/OACMAVi/PKBoBmgBXAHYAFwApADEAZS/XXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQPDgD2ADIAvgACABAiBA8fTADoAOwAOL9kv4gBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqC/jL+Qv5S/mL+cv6C/pL+qBA8iBA8mBA8qBA8yBA82BA86BA8+BA9CAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy/JAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEDxggICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcvyQBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBA8YICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcwDAAXL8kAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBA8uAAIEDxggICAiAHYBDCAiAAAjTADoAOwAOMBowGwBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy/JAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEDxggICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcvyQBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBA8YICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXL8kAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQPGCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy/JAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEDxggICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcvyQBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBA8YICAgIgB2ASAgIgAAI2QAhACUwaQAOACgwagAjAFYwaygaAZsAVwB2ABcAKQAxAGUwc18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDw4A+gAyAL4AAgAQIgQPS0wA6ADsADjB1MH0ARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpzB+MH8wgDCBMIIwgzCEgQPTgQPUgQPVgQPWgQPXgQPYgQPZgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAaABcvygBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEBAoAAgQPRCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy/KAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIED0QgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcvygBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBA9EICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcLbgAXL8oAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAQaAAIED0QgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcvygBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBA9EICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXL8oAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQPRCAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy/KAGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIED0QgICAiAHYBbCAiAAAjfEBIAmwCcAJ0w8AAhAJ8AoDDxACMAnjDyAKEADgAlAKIAowAoAKQAFwAXABcAKQBEAGUAZTD6ADEAZQBXAGUBkSgNAGUAZTECAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEDHwgIgQPcCIAMCIBkgQM1CAiBA9sIEo5OWe7TADoAOwAOMQYxCQBGogGaAZuAPYA+ojEKMQuBA92BA+iAKNkAIQAlMQ4ADgAoMQ8AIwBWMRAoGwGaAFcAdgAXACkAMQBlMRhfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA9qAPYAMgC+AAIAECIED3tMAOgA7AA4xGjEjAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioMSQxJTEmMScxKDEpMSoxK4ED34ED4IED4YED44ED5IED5YED5oED54Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXMQoAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQPdCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzEKAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIED3QgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFzFNABcxCgBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIED4oAAgQPdCAgICIAdgEMICIAACNMAOgA7AA4xWzFcAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXMQoAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQPdCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzEKAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIED3QgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcxCgBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBA90ICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMQoAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQPdCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzEKAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIED3QgICAiAHYBICAiAAAjZACEAJTGqAA4AKDGrACMAVjGsKBsBmwBXAHYAFwApADEAZTG0XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQPagD6ADIAvgACABAiBA+nTADoAOwAOMbYxvgBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunMb8xwDHBMcIxwzHEMcWBA+qBA+uBA+yBA+2BA+6BA++BA/CAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFzELAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBA+gICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXMQsAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQPoCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzELAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIED6AgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABcxCwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgQPoCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzELAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIED6AgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcxCwBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBA+gICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMQsAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQPoCAgICIAdgFsICIAACN8QEgCbAJwAnTIxACEAnwCgMjIAIwCeMjMAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlMjsAMQBlAFcAZQGRDdUAZQBlMkMAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMfCAiBA/MIgAwIgGSBATwICIED8ggSYqDXpNMAOgA7AA4yRzJKAEaiAZoBm4A9gD6iMksyTIED9IED/4Ao2QAhACUyTwAOACgyUAAjAFYyUSgcAZoAVwB2ABcAKQAxAGUyWV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYED8YA9gAyAL4AAgAQIgQP10wA6ADsADjJbMmQARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgyZTJmMmcyaDJpMmoyazJsgQP2gQP3gQP4gQP6gQP7gQP8gQP9gQP+gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcySwBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBA/QICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMksAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQP0CAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXMo4AFzJLAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQP5gACBA/QICAgIgB2AQwgIgAAI0wA6ADsADjKcMp0ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcySwBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBA/QICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXMksAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQP0CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzJLAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIED9AgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcySwBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBA/QICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXMksAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQP0CAgICIAdgEgICIAACNkAIQAlMusADgAoMuwAIwBWMu0oHAGbAFcAdgAXACkAMQBlMvVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA/GAPoAMgC+AAIAECIEEANMAOgA7AA4y9zL/AEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6czADMBMwIzAzMEMwUzBoEEAYEEAoEEA4EEBIEEBYEEBoEEB4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMkwAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQP/CAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzJMAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIED/wgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcyTABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBA/8ICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcLbgAXMkwAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAQaAAIED/wgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcyTABlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBA/8ICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMkwAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQP/CAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzJMAGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIED/wgICAiAHYBbCAiAAAjfEBIAmwCcAJ0zcgAhAJ8AoDNzACMAnjN0AKEADgAlAKIAowAoAKQAFwAXABcAKQBEAGUAZTN8ADEAZQBXAGUBkQ3cAGUAZTOEAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEDHwgIgQQKCIAMCIBkgQFDCAiBBAkIEtab1pzTADoAOwAOM4gziwBGogGaAZuAPYA+ojOMM42BBAuBBBaAKNkAIQAlM5AADgAoM5EAIwBWM5IoHQGaAFcAdgAXACkAMQBlM5pfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBAiAPYAMgC+AAIAECIEEDNMAOgA7AA4znDOlAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioM6YzpzOoM6kzqjOrM6wzrYEEDYEEDoEED4EEEYEEEoEEE4EEFIEEFYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXM4wAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQQLCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzOMAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEECwgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFzPPABczjABlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEEEIAAgQQLCAgICIAdgEMICIAACNMAOgA7AA4z3TPeAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXM4wAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQQLCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzOMAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEECwgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABczjABlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBBAsICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXM4wAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQQLCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzOMAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEECwgICAiAHYBICAiAAAjZACEAJTQsAA4AKDQtACMAVjQuKB0BmwBXAHYAFwApADEAZTQ2XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQQIgD6ADIAvgACABAiBBBfTADoAOwAONDg0QABGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunNEE0QjRDNEQ0RTRGNEeBBBiBBBmBBBqBBBuBBByBBB2BBB6AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXBPkAFzONAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgI2AAIEEFggICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABczjQBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBBBYICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXM40AZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQQWCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXBScAFzONAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgJGAAIEEFggICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABczjQBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBBBYICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXM40AZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQQWCAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzONAGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIEEFggICAiAHYBbCAiAAAjfEBIAmwCcAJ00swAhAJ8AoDS0ACMAnjS1AKEADgAlAKIAowAoAKQAFwAXABcAKQBEAGUAZTS9ADEAZQBXAGUBkSgQAGUAZTTFAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEDHwgIgQQhCIAMCIBkgQM2CAiBBCAIEi93jUzTADoAOwAONMk0zABGogGaAZuAPYA+ojTNNM6BBCKBBC2AKNkAIQAlNNEADgAoNNIAIwBWNNMoHgGaAFcAdgAXACkAMQBlNNtfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBB+APYAMgC+AAIAECIEEI9MAOgA7AA403TTmAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioNOc06DTpNOo06zTsNO007oEEJIEEJYEEJoEEKIEEKYEEKoEEK4EELIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXNM0AZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQQiCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzTNAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEEIggICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFzUQABc0zQBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEEJ4AAgQQiCAgICIAdgEMICIAACNMAOgA7AA41HjUfAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXNM0AZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQQiCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFzTNAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIEEIggICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc0zQBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBBCIICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNM0AZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQQiCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzTNAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEEIggICAiAHYBICAiAAAjZACEAJTVtAA4AKDVuACMAVjVvKB4BmwBXAHYAFwApADEAZTV3XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQQfgD6ADIAvgACABAiBBC7TADoAOwAONXk1gQBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunNYI1gzWENYU1hjWHNYiBBC+BBDCBBDGBBDKBBDOBBDSBBDWAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFzTOAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBBC0ICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXNM4AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQQtCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzTOAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEELQgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFw8VABc0zgBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBX4AAgQQtCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzTOAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEELQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc0zgBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBBC0ICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNM4AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQQtCAgICIAdgFsICIAACN8QEgCbAJwAnTX0ACEAnwCgNfUAIwCeNfYAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlNf4AMQBlAFcAZQGRKBEAZQBlNgYAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMfCAiBBDgIgAwIgGSBAzcICIEENwgS6NUHrNMAOgA7AA42CjYNAEaiAZoBm4A9gD6iNg42D4EEOYEERIAo2QAhACU2EgAOACg2EwAjAFY2FCgfAZoAVwB2ABcAKQAxAGU2HF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEENoA9gAyAL4AAgAQIgQQ60wA6ADsADjYeNicARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKg2KDYpNio2KzYsNi02LjYvgQQ7gQQ8gQQ9gQQ/gQRAgQRBgQRCgQRDgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc2DgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBBDkICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNg4AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQQ5CAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXNlEAFzYOAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQQ+gACBBDkICAgIgB2AQwgIgAAI0wA6ADsADjZfNmAARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc2DgBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBBDkICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXNg4AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQQ5CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzYOAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEEOQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc2DgBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBBDkICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXNg4AZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQQ5CAgICIAdgEgICIAACNkAIQAlNq4ADgAoNq8AIwBWNrAoHwGbAFcAdgAXACkAMQBlNrhfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBDaAPoAMgC+AAIAECIEERdMAOgA7AA42ujbCAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6c2wzbENsU2xjbHNsg2yYEERoEER4EESIEESYEESoEES4EETIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNg8AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQRECAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzYPAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEERAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc2DwBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBBEQICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXNg8AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgQRECAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzYPAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEERAgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc2DwBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBBEQICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNg8AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQRECAgICIAdgFsICIAACN8QEgCbAJwAnTc1ACEAnwCgNzYAIwCeNzcAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlNz8AMQBlAFcAZQGRDdEAZQBlN0cAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMfCAiBBE8IgAwIgGSBATgICIEETggTAAAAASCad27TADoAOwAON0s3TgBGogGaAZuAPYA+ojdPN1CBBFCBBFuAKNkAIQAlN1MADgAoN1QAIwBWN1UoIAGaAFcAdgAXACkAMQBlN11fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBE2APYAMgC+AAIAECIEEUdMAOgA7AA43XzdoAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioN2k3ajdrN2w3bTduN283cIEEUoEEU4EEVIEEVoEEV4EEWIEEWYEEWoAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXN08AZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQRQCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzdPAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEEUAgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFzeSABc3TwBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEEVYAAgQRQCAgICIAdgEMICIAACNMAOgA7AA43oDehAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXN08AZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQRQCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFzdPAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIEEUAgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc3TwBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBBFAICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXN08AZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQRQCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzdPAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEEUAgICAiAHYBICAiAAAjZACEAJTfvAA4AKDfwACMAVjfxKCABmwBXAHYAFwApADEAZTf5XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQRNgD6ADIAvgACABAiBBFzTADoAOwAON/s4AwBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunOAQ4BTgGOAc4CDgJOAqBBF2BBF6BBF+BBGCBBGGBBGKBBGOAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFzdQAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBBFsICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXN1AAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQRbCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzdQAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEEWwgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFw8VABc3UABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBX4AAgQRbCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzdQAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEEWwgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc3UABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBBFsICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXN1AAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQRbCAgICIAdgFsICIAACNIAOwAOOHYAtaCAHN8QEDh5OHo4ezh8ACE4fTh+ACM4fziAAA4AJTiBOIIAKABWAFc4hAApACkAFDiIAF0AMQApAFcAYABAAFc4jziQAGVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2WADIEEeIAEgASAAoEEZ4EBIYAEgAyBASOACYAMgQU5gQRmCBJNlg0R0wA6ADsADjiUOJYARqEAaoAOoTiXgQRogCjZACEAJTiaAA4AKDibACMAVjicAEUAagBXAHYAFwApADEAZTikXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQRlgA6ADIAvgACABAiBBGnTADoAOwAOOKY4sABGqQB9AH4AfwCAAIEAggCDAIQAhYARgBKAE4AUgBWAFoAXgBiAGak4sTiyOLM4tDi1OLY4tzi4OLmBBGqBBGyBBG2BBG+BBHCBBHKBBHOBBHWBBHaAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXOL0AFziXAGUAZQBlADEAZQCtAH0AZQBlABcAZYAAgQRrgACBBGgICAgIgB2AEQgIgAAI0gA7AA44ywC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXOJcAZQBlAGUAMQBlAK0AfgBlAGUAFwBlgACAAIAAgQRoCAgICIAdgBIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXON4AFziXAGUAZQBlADEAZQCtAH8AZQBlABcAZYAAgQRugACBBGgICAgIgB2AEwgIgAAI0gA7AA447AC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXOJcAZQBlAGUAMQBlAK0AgABlAGUAFwBlgACAAIAAgQRoCAgICIAdgBQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXOP8AFziXAGUAZQBlADEAZQCtAIEAZQBlABcAZYAAgQRxgACBBGgICAgIgB2AFQgIgAAI0gA7AA45DQC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOJcAZQBlAGUAMQBlAK0AggBlAGUAFwBlgACAJYAAgQRoCAgICIAdgBYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXOSAAFziXAGUAZQBlADEAZQCtAIMAZQBlABcAZYAAgQR0gACBBGgICAgIgB2AFwgIgAAI0wA6ADsADjkuOS8ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEuABc4lwBlAGUAZQAxAGUArQCEAGUAZQAXAGWAAIAqgACBBGgICAgIgB2AGAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc5QgAXOJcAZQBlAGUAMQBlAK0AhQBlAGUAFwBlgACBBHeAAIEEaAgICAiAHYAZCAiAAAhZLkFwcEVudk1P0wA6ADsADjlROVoARqg5UjlTOVQ5VTlWOVc5WDlZgQR5gQR6gQR7gQR8gQR9gQR+gQR/gQSAqDlbOVw5XTleOV85YDlhOWKBBIGBBJiBBK+BBMaBBN2BBPSBBQuBBSKAKF8QEHF1aWNrU2VhcmNoV29yZHNfEA9oaXN0b3J5S2V5d29yZHNcZ2xvYmFsRmlsdGVyXXdhdGNoZWRGaWx0ZXJcc2VhcmNoRmlsdGVyXXRhZ1RyYW5zbGF0b3JXc2V0dGluZ1R1c2Vy3xASAJsAnACdOW0AIQCfAKA5bgAjAJ45bwChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU5dwAxAGUAVwBlAZE5UgBlAGU5fwBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEEgwiADAiAZIEEeQgIgQSCCBK5ebCZ0wA6ADsADjmDOYYARqIBmgGbgD2APqI5hzmIgQSEgQSPgCjZACEAJTmLAA4AKDmMACMAVjmNOVsBmgBXAHYAFwApADEAZTmVXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQSBgD2ADIAvgACABAiBBIXTADoAOwAOOZc5oABGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqDmhOaI5ozmkOaU5pjmnOaiBBIaBBIeBBIiBBIqBBIuBBIyBBI2BBI6AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzmHAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEEhAgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc5hwBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBBIQICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc5ygAXOYcAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBBImAAIEEhAgICAiAHYBDCAiAAAjTADoAOwAOOdg52QBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzmHAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEEhAgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABc5hwBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBBIQICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOYcAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQSECAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzmHAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEEhAgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc5hwBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBBIQICAgIgB2ASAgIgAAI2QAhACU6JwAOACg6KAAjAFY6KTlbAZsAVwB2ABcAKQAxAGU6MV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEEgYA+gAyAL4AAgAQIgQSQ0wA6ADsADjozOjsARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpzo8Oj06Pjo/OkA6QTpCgQSRgQSSgQSTgQSUgQSVgQSWgQSXgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc5iABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBBI8ICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOYgAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQSPCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzmIAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEEjwgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABc5iABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACBBI8ICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXOYgAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQSPCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzmIAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEEjwgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc5iABlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBBI8ICAgIgB2AWwgIgAAI3xASAJsAnACdOq4AIQCfAKA6rwAjAJ46sAChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU6uAAxAGUAVwBlAZE5UwBlAGU6wABlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEEmgiADAiAZIEEeggIgQSZCBJGtS8B0wA6ADsADjrEOscARqIBmgGbgD2APqI6yDrJgQSbgQSmgCjZACEAJTrMAA4AKDrNACMAVjrOOVwBmgBXAHYAFwApADEAZTrWXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQSYgD2ADIAvgACABAiBBJzTADoAOwAOOtg64QBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqDriOuM65DrlOuY65zroOumBBJ2BBJ6BBJ+BBKGBBKKBBKOBBKSBBKWAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzrIAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEEmwgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc6yABlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBBJsICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc7CwAXOsgAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBBKCAAIEEmwgICAiAHYBDCAiAAAjTADoAOwAOOxk7GgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzrIAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEEmwgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABc6yABlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBBJsICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOsgAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQSbCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzrIAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEEmwgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc6yABlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBBJsICAgIgB2ASAgIgAAI2QAhACU7aAAOACg7aQAjAFY7ajlcAZsAVwB2ABcAKQAxAGU7cl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEEmIA+gAyAL4AAgAQIgQSn0wA6ADsADjt0O3wARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpzt9O347fzuAO4E7gjuDgQSogQSpgQSqgQSrgQSsgQStgQSugCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc6yQBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBBKYICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOskAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQSmCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzrJAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEEpggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABc6yQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACBBKYICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXOskAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQSmCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzrJAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEEpggICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc6yQBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBBKYICAgIgB2AWwgIgAAI3xASAJsAnACdO+8AIQCfAKA78AAjAJ478QChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU7+QAxAGUAVwBlAZE5VABlAGU8AQBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEEsQiADAiAZIEEewgIgQSwCBJ9AWc60wA6ADsADjwFPAgARqIBmgGbgD2APqI8CTwKgQSygQS9gCjZACEAJTwNAA4AKDwOACMAVjwPOV0BmgBXAHYAFwApADEAZTwXXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQSvgD2ADIAvgACABAiBBLPTADoAOwAOPBk8IgBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqDwjPCQ8JTwmPCc8KDwpPCqBBLSBBLWBBLaBBLiBBLmBBLqBBLuBBLyAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzwJAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEEsggICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc8CQBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBBLIICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc8TAAXPAkAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBBLeAAIEEsggICAiAHYBDCAiAAAjTADoAOwAOPFo8WwBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzwJAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEEsggICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABc8CQBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBBLIICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPAkAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQSyCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzwJAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEEsggICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc8CQBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBBLIICAgIgB2ASAgIgAAI2QAhACU8qQAOACg8qgAjAFY8qzldAZsAVwB2ABcAKQAxAGU8s18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEEr4A+gAyAL4AAgAQIgQS+0wA6ADsADjy1PL0ARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpzy+PL88wDzBPMI8wzzEgQS/gQTAgQTBgQTCgQTDgQTEgQTFgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc8CgBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBBL0ICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPAoAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQS9CAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzwKAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEEvQgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABc8CgBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACBBL0ICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXPAoAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQS9CAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzwKAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEEvQgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc8CgBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBBL0ICAgIgB2AWwgIgAAI3xASAJsAnACdPTAAIQCfAKA9MQAjAJ49MgChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU9OgAxAGUAVwBlAZE5VQBlAGU9QgBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEEyAiADAiAZIEEfAgIgQTHCBLQuBGg0wA6ADsADj1GPUkARqIBmgGbgD2APqI9Sj1LgQTJgQTUgCjZACEAJT1OAA4AKD1PACMAVj1QOV4BmgBXAHYAFwApADEAZT1YXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQTGgD2ADIAvgACABAiBBMrTADoAOwAOPVo9YwBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqD1kPWU9Zj1nPWg9aT1qPWuBBMuBBMyBBM2BBM+BBNCBBNGBBNKBBNOAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz1KAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEEyQgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc9SgBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBBMkICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc9jQAXPUoAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBBM6AAIEEyQgICAiAHYBDCAiAAAjTADoAOwAOPZs9nABGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz1KAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEEyQgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABc9SgBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBBMkICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPUoAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQTJCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz1KAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEEyQgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc9SgBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBBMkICAgIgB2ASAgIgAAI2QAhACU96gAOACg96wAjAFY97DleAZsAVwB2ABcAKQAxAGU99F8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEExoA+gAyAL4AAgAQIgQTV0wA6ADsADj32Pf4ARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpz3/PgA+AT4CPgM+BD4FgQTWgQTXgQTYgQTZgQTagQTbgQTcgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc9SwBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBBNQICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPUsAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQTUCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz1LAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEE1AgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABc9SwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACBBNQICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXPUsAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQTUCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz1LAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEE1AgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc9SwBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBBNQICAgIgB2AWwgIgAAI3xASAJsAnACdPnEAIQCfAKA+cgAjAJ4+cwChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU+ewAxAGUAVwBlAZE5VgBlAGU+gwBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEE3wiADAiAZIEEfQgIgQTeCBKIjaz00wA6ADsADj6HPooARqIBmgGbgD2APqI+iz6MgQTggQTrgCjZACEAJT6PAA4AKD6QACMAVj6ROV8BmgBXAHYAFwApADEAZT6ZXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQTdgD2ADIAvgACABAiBBOHTADoAOwAOPps+pABGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqD6lPqY+pz6oPqk+qj6rPqyBBOKBBOOBBOSBBOaBBOeBBOiBBOmBBOqAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz6LAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEE4AgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc+iwBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBBOAICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc+zgAXPosAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBBOWAAIEE4AgICAiAHYBDCAiAAAjTADoAOwAOPtw+3QBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz6LAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEE4AgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABc+iwBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBBOAICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPosAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQTgCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz6LAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEE4AgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc+iwBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBBOAICAgIgB2ASAgIgAAI2QAhACU/KwAOACg/LAAjAFY/LTlfAZsAVwB2ABcAKQAxAGU/NV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEE3YA+gAyAL4AAgAQIgQTs0wA6ADsADj83Pz8ARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpz9AP0E/Qj9DP0Q/RT9GgQTtgQTugQTvgQTwgQTxgQTygQTzgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc+jABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBBOsICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPowAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQTrCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz6MAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEE6wgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABc+jABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACBBOsICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXPowAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQTrCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz6MAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEE6wgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc+jABlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBBOsICAgIgB2AWwgIgAAI3xASAJsAnACdP7IAIQCfAKA/swAjAJ4/tAChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU/vAAxAGUAVwBlAZE5VwBlAGU/xABlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEE9giADAiAZIEEfggIgQT1CBK/C3Zn0wA6ADsADj/IP8sARqIBmgGbgD2APqI/zD/NgQT3gQUCgCjZACEAJT/QAA4AKD/RACMAVj/SOWABmgBXAHYAFwApADEAZT/aXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQT0gD2ADIAvgACABAiBBPjTADoAOwAOP9w/5QBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqD/mP+c/6D/pP+o/6z/sP+2BBPmBBPqBBPuBBP2BBP6BBP+BBQCBBQGAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz/MAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEE9wgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc/zABlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBBPcICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABdADwAXP8wAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBBPyAAIEE9wgICAiAHYBDCAiAAAjTADoAOwAOQB1AHgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz/MAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEE9wgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABc/zABlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBBPcICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXP8wAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQT3CAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz/MAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEE9wgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc/zABlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBBPcICAgIgB2ASAgIgAAI2QAhACVAbAAOAChAbQAjAFZAbjlgAZsAVwB2ABcAKQAxAGVAdl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEE9IA+gAyAL4AAgAQIgQUD0wA6ADsADkB4QIAARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbp0CBQIJAg0CEQIVAhkCHgQUEgQUFgQUGgQUHgQUIgQUJgQUKgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc/zQBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBBQIICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXP80AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQUCCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz/NAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEFAggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABc/zQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACBBQIICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXP80AZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQUCCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz/NAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEFAggICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc/zQBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBBQIICAgIgB2AWwgIgAAI3xASAJsAnACdQPMAIQCfAKBA9AAjAJ5A9QChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGVA/QAxAGUAVwBlAZE5WABlAGVBBQBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEFDQiADAiAZIEEfwgIgQUMCBMAAAABJXl6eNMAOgA7AA5BCUEMAEaiAZoBm4A9gD6iQQ1BDoEFDoEFGYAo2QAhACVBEQAOAChBEgAjAFZBEzlhAZoAVwB2ABcAKQAxAGVBG18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEFC4A9gAyAL4AAgAQIgQUP0wA6ADsADkEdQSYARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKhBJ0EoQSlBKkErQSxBLUEugQUQgQURgQUSgQUUgQUVgQUWgQUXgQUYgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABdBDQBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBBQ4ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXQQ0AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQUOCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXQVAAF0ENAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQUTgACBBQ4ICAgIgB2AQwgIgAAI0wA6ADsADkFeQV8ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABdBDQBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBBQ4ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXQQ0AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQUOCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAF0ENAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEFDggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABdBDQBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBBQ4ICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXQQ0AZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQUOCAgICIAdgEgICIAACNkAIQAlQa0ADgAoQa4AIwBWQa85YQGbAFcAdgAXACkAMQBlQbdfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBQuAPoAMgC+AAIAECIEFGtMAOgA7AA5BuUHBAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6dBwkHDQcRBxUHGQcdByIEFG4EFHIEFHYEFHoEFH4EFIIEFIYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXQQ4AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQUZCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAF0EOAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEFGQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABdBDgBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBBRkICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXQQ4AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgQUZCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAF0EOAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEFGQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABdBDgBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBBRkICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXQQ4AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQUZCAgICIAdgFsICIAACN8QEgCbAJwAnUI0ACEAnwCgQjUAIwCeQjYAoQAOACUAogCjACgApAAXABcAFwApAEUAZQBlQj4AMQBlAFcAZQGROVkAZQBlQkYAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQRlCAiBBSQIgAwIgGSBBIAICIEFIwgSvsaxe9MAOgA7AA5CSkJNAEaiAZoBm4A9gD6iQk5CT4EFJYEFMIAo2QAhACVCUgAOAChCUwAjAFZCVDliAZoAVwB2ABcAKQAxAGVCXF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEFIoA9gAyAL4AAgAQIgQUm0wA6ADsADkJeQmcARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKhCaEJpQmpCa0JsQm1CbkJvgQUngQUogQUpgQUrgQUsgQUtgQUugQUvgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABdCTgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBBSUICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXQk4AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQUlCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXQpEAF0JOAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQUqgACBBSUICAgIgB2AQwgIgAAI0wA6ADsADkKfQqAARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABdCTgBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBBSUICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXQk4AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQUlCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAF0JOAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEFJQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABdCTgBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBBSUICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXQk4AZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQUlCAgICIAdgEgICIAACNkAIQAlQu4ADgAoQu8AIwBWQvA5YgGbAFcAdgAXACkAMQBlQvhfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBSKAPoAMgC+AAIAECIEFMdMAOgA7AA5C+kMCAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6dDA0MEQwVDBkMHQwhDCYEFMoEFM4EFNIEFNYEFNoEFN4EFOIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXQk8AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQUwCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAF0JPAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEFMAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABdCTwBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBBTAICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXQk8AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgQUwCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAF0JPAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEFMAgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABdCTwBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBBTAICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXQk8AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQUwCAgICIAdgFsICIAACNIAOwAOQ3UAtaCAHNMAOgA7AA5DeEN5AEagoIAo0wA6ADsADkN8Q30ARqCggCjTADoAOwAOQ4BDgQBGoKCAKNIAtwC4Q4RDhV5YRE1vZGVsUGFja2FnZaZDhkOHQ4hDiUOKALxeWERNb2RlbFBhY2thZ2VfEA9YRFVNTFBhY2thZ2VJbXBfEBFYRFVNTE5hbWVzcGFjZUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w0gA7AA5DjAC1oIAc0wA6ADsADkOPQ5AARqCggCjSALcAuEOTQ5RZWERQTU1vZGVso0OTQ5UAvFdYRE1vZGVsAAAACAAAABkAAAAiAAAALAAAADEAAAA6AAAAPwAAAFEAAABWAAAAWwAAAF0AAArjAAAK6QAACwYAAAsYAAALHwAACywAAAs/AAALVwAAC2UAAAt/AAALgQAAC4QAAAuHAAALiQAAC4wAAAuOAAALkQAAC8oAAAvpAAAMBgAADCUAAAw3AAAMVwAADF4AAAx8AAAMiAAADKQAAAyqAAAMzAAADO0AAA0AAAANAgAADQUAAA0IAAANCgAADQwAAA0OAAANEQAADRQAAA0WAAANGAAADRoAAA0cAAANHgAADSAAAA0hAAANJQAADTIAAA06AAANRQAADU4AAA1QAAANUgAADVQAAA1WAAANXwAADWEAAA1kAAANZwAADWoAAA1sAAANewAADY0AAA2XAAANoAAADeMAAA4HAAAOKwAADk4AAA51AAAOlQAADrwAAA7jAAAPAwAADycAAA9LAAAPVwAAD1kAAA9bAAAPXQAAD18AAA9hAAAPYwAAD2YAAA9oAAAPagAAD20AAA9vAAAPcQAAD3QAAA92AAAPdwAAD3wAAA+EAAAPkQAAD5QAAA+WAAAPmQAAD5sAAA+dAAAPrAAAD9EAAA/1AAAQHAAAEEAAABBCAAAQRAAAEEYAABBIAAAQSgAAEEwAABBNAAAQTwAAEFwAABBvAAAQcQAAEHMAABB1AAAQdwAAEHkAABB7AAAQfQAAEH8AABCBAAAQlAAAEJYAABCYAAAQmgAAEJwAABCeAAAQoAAAEKIAABCkAAAQpgAAEKgAABC+AAAQ0QAAEO0AABEKAAARJgAAEToAABFMAAARYgAAEXsAABG6AAARwAAAEckAABHWAAAR4gAAEewAABH2AAASAQAAEgwAABIZAAASIQAAEiMAABIlAAASJwAAEikAABIqAAASKwAAEiwAABItAAASLwAAEjEAABIyAAASMwAAEjUAABI2AAASPwAAEkAAABJCAAASSwAAElYAABJfAAASbgAAEnUAABJ9AAAShgAAEo8AABKiAAASqwAAEr4AABLVAAAS5wAAEyYAABMoAAATKgAAEywAABMuAAATLwAAEzAAABMxAAATMgAAEzQAABM2AAATNwAAEzgAABM6AAATOwAAE3oAABN8AAATfgAAE4AAABOCAAATgwAAE4QAABOFAAAThgAAE4gAABOKAAATiwAAE4wAABOOAAATjwAAE5gAABOZAAATmwAAE9oAABPcAAAT3gAAE+AAABPiAAAT4wAAE+QAABPlAAAT5gAAE+gAABPqAAAT6wAAE+wAABPuAAAT7wAAFC4AABQwAAAUMgAAFDQAABQ2AAAUNwAAFDgAABQ5AAAUOgAAFDwAABQ+AAAUPwAAFEAAABRCAAAUQwAAFEwAABRNAAAUTwAAFI4AABSQAAAUkgAAFJQAABSWAAAUlwAAFJgAABSZAAAUmgAAFJwAABSeAAAUnwAAFKAAABSiAAAUowAAFKQAABTjAAAU5QAAFOcAABTpAAAU6wAAFOwAABTtAAAU7gAAFO8AABTxAAAU8wAAFPQAABT1AAAU9wAAFPgAABUFAAAVBgAAFQcAABUJAAAVEgAAFSgAABUvAAAVPAAAFXsAABV9AAAVfwAAFYEAABWDAAAVhAAAFYUAABWGAAAVhwAAFYkAABWLAAAVjAAAFY0AABWPAAAVkAAAFakAABWrAAAVrQAAFa8AABWwAAAVsgAAFckAABXSAAAV4AAAFe0AABX7AAAWEAAAFiQAABY7AAAWTQAAFowAABaOAAAWkAAAFpIAABaUAAAWlQAAFpYAABaXAAAWmAAAFpoAABacAAAWnQAAFp4AABagAAAWoQAAFrMAABa8AAAW0QAAFuAAABb1AAAXAwAAFxgAABcsAAAXQwAAF1UAABdiAAAXdQAAF3cAABd5AAAXewAAF30AABd/AAAXgQAAF4MAABeFAAAXhwAAF5oAABecAAAXngAAF6AAABeiAAAXpAAAF6YAABeoAAAXqgAAF60AABevAAAXuAAAF70AABfPAAAX3QAAF+cAABf7AAAYCQAAGA0AABgZAAAYZAAAGIcAABinAAAYxwAAGMkAABjLAAAYzQAAGM8AABjRAAAY0gAAGNMAABjVAAAY1gAAGNgAABjZAAAY2wAAGN0AABjeAAAY3wAAGOEAABjiAAAY6wAAGPgAABj9AAAY/wAAGQEAABkGAAAZCAAAGQoAABkMAAAZIQAAGTYAABlbAAAZfwAAGaYAABnKAAAZzAAAGc4AABnQAAAZ0gAAGdQAABnWAAAZ1wAAGdkAABnmAAAZ9wAAGfkAABn7AAAZ/QAAGf8AABoBAAAaAwAAGgUAABoHAAAaGAAAGhoAABocAAAaHgAAGiAAABoiAAAaJAAAGiYAABooAAAaKgAAGkgAABpmAAAaeQAAGo0AABqiAAAavwAAGtMAABrpAAAbKAAAGyoAABssAAAbLgAAGzAAABsxAAAbMgAAGzMAABs0AAAbNgAAGzgAABs5AAAbOgAAGzwAABs9AAAbfAAAG34AABuAAAAbggAAG4QAABuFAAAbhgAAG4cAABuIAAAbigAAG4wAABuNAAAbjgAAG5AAABuRAAAb0AAAG9IAABvUAAAb1gAAG9gAABvZAAAb2gAAG9sAABvcAAAb3gAAG+AAABvhAAAb4gAAG+QAABvlAAAb8gAAG/MAABv0AAAb9gAAHDUAABw3AAAcOQAAHDsAABw9AAAcPgAAHD8AABxAAAAcQQAAHEMAABxFAAAcRgAAHEcAABxJAAAcSgAAHIkAAByLAAAcjQAAHI8AAByRAAAckgAAHJMAAByUAAAclQAAHJcAAByZAAAcmgAAHJsAABydAAAcngAAHJ8AABzeAAAc4AAAHOIAABzkAAAc5gAAHOcAABzoAAAc6QAAHOoAABzsAAAc7gAAHO8AABzwAAAc8gAAHPMAAB0yAAAdNAAAHTYAAB04AAAdOgAAHTsAAB08AAAdPQAAHT4AAB1AAAAdQgAAHUMAAB1EAAAdRgAAHUcAAB2GAAAdiAAAHYoAAB2MAAAdjgAAHY8AAB2QAAAdkQAAHZIAAB2UAAAdlgAAHZcAAB2YAAAdmgAAHZsAAB3AAAAd5AAAHgsAAB4vAAAeMQAAHjMAAB41AAAeNwAAHjkAAB47AAAePAAAHj4AAB5LAAAeWgAAHlwAAB5eAAAeYAAAHmIAAB5kAAAeZgAAHmgAAB53AAAeeQAAHnsAAB59AAAefwAAHoEAAB6DAAAehQAAHocAAB6nAAAe0gAAHuwAAB8FAAAfHwAAHz8AAB9iAAAfoQAAH6MAAB+lAAAfpwAAH6kAAB+qAAAfqwAAH6wAAB+tAAAfrwAAH7EAAB+yAAAfswAAH7UAAB+2AAAf9QAAH/cAAB/5AAAf+wAAH/0AAB/+AAAf/wAAIAAAACABAAAgAwAAIAUAACAGAAAgBwAAIAkAACAKAAAgSQAAIEsAACBNAAAgTwAAIFEAACBSAAAgUwAAIFQAACBVAAAgVwAAIFkAACBaAAAgWwAAIF0AACBeAAAgnQAAIJ8AACChAAAgowAAIKUAACCmAAAgpwAAIKgAACCpAAAgqwAAIK0AACCuAAAgrwAAILEAACCyAAAgtQAAIPQAACD2AAAg+AAAIPoAACD8AAAg/QAAIP4AACD/AAAhAAAAIQIAACEEAAAhBQAAIQYAACEIAAAhCQAAIUgAACFKAAAhTAAAIU4AACFQAAAhUQAAIVIAACFTAAAhVAAAIVYAACFYAAAhWQAAIVoAACFcAAAhXQAAIZwAACGeAAAhoAAAIaIAACGkAAAhpQAAIaYAACGnAAAhqAAAIaoAACGsAAAhrQAAIa4AACGwAAAhsQAAIboAACHIAAAh1QAAIeMAACHwAAAiAwAAIhoAACIsAAAidwAAIpoAACK6AAAi2gAAItwAACLeAAAi4AAAIuIAACLkAAAi5QAAIuYAACLoAAAi6QAAIusAACLsAAAi7gAAIvAAACLxAAAi8gAAIvQAACL1AAAi/gAAIwsAACMQAAAjEgAAIxQAACMZAAAjGwAAIx0AACMfAAAjRAAAI2gAACOPAAAjswAAI7UAACO3AAAjuQAAI7sAACO9AAAjvwAAI8AAACPCAAAjzwAAI+AAACPiAAAj5AAAI+YAACPoAAAj6gAAI+wAACPuAAAj8AAAJAEAACQDAAAkBQAAJAcAACQJAAAkCwAAJA0AACQPAAAkEQAAJBMAACRSAAAkVAAAJFYAACRYAAAkWgAAJFsAACRcAAAkXQAAJF4AACRgAAAkYgAAJGMAACRkAAAkZgAAJGcAACSmAAAkqAAAJKoAACSsAAAkrgAAJK8AACSwAAAksQAAJLIAACS0AAAktgAAJLcAACS4AAAkugAAJLsAACT6AAAk/AAAJP4AACUAAAAlAgAAJQMAACUEAAAlBQAAJQYAACUIAAAlCgAAJQsAACUMAAAlDgAAJQ8AACUcAAAlHQAAJR4AACUgAAAlXwAAJWEAACVjAAAlZQAAJWcAACVoAAAlaQAAJWoAACVrAAAlbQAAJW8AACVwAAAlcQAAJXMAACV0AAAlswAAJbUAACW3AAAluQAAJbsAACW8AAAlvQAAJb4AACW/AAAlwQAAJcMAACXEAAAlxQAAJccAACXIAAAmBwAAJgkAACYLAAAmDQAAJg8AACYQAAAmEQAAJhIAACYTAAAmFQAAJhcAACYYAAAmGQAAJhsAACYcAAAmWwAAJl0AACZfAAAmYQAAJmMAACZkAAAmZQAAJmYAACZnAAAmaQAAJmsAACZsAAAmbQAAJm8AACZwAAAmrwAAJrEAACazAAAmtQAAJrcAACa4AAAmuQAAJroAACa7AAAmvQAAJr8AACbAAAAmwQAAJsMAACbEAAAm6QAAJw0AACc0AAAnWAAAJ1oAACdcAAAnXgAAJ2AAACdiAAAnZAAAJ2UAACdnAAAndAAAJ4MAACeFAAAnhwAAJ4kAACeLAAAnjQAAJ48AACeRAAAnoAAAJ6IAACekAAAnpgAAJ6gAACeqAAAnrAAAJ64AACewAAAn7wAAJ/EAACfzAAAn9QAAJ/cAACf4AAAn+QAAJ/oAACf7AAAn/QAAJ/8AACgAAAAoAQAAKAMAACgEAAAoQwAAKEUAAChHAAAoSQAAKEsAAChMAAAoTQAAKE4AAChPAAAoUQAAKFMAAChUAAAoVQAAKFcAAChYAAAolwAAKJkAACibAAAonQAAKJ8AACigAAAooQAAKKIAACijAAAopQAAKKcAACioAAAoqQAAKKsAACisAAAo6wAAKO0AACjvAAAo8QAAKPMAACj0AAAo9QAAKPYAACj3AAAo+QAAKPsAACj8AAAo/QAAKP8AACkAAAApPwAAKUEAAClDAAApRQAAKUcAAClIAAApSQAAKUoAAClLAAApTQAAKU8AAClQAAApUQAAKVMAAClUAAApkwAAKZUAACmXAAApmQAAKZsAACmcAAApnQAAKZ4AACmfAAApoQAAKaMAACmkAAAppQAAKacAACmoAAAp5wAAKekAACnrAAAp7QAAKe8AACnwAAAp8QAAKfIAACnzAAAp9QAAKfcAACn4AAAp+QAAKfsAACn8AAAqRwAAKmoAACqKAAAqqgAAKqwAACquAAAqsAAAKrIAACq0AAAqtQAAKrYAACq4AAAquQAAKrsAACq8AAAqvgAAKsAAACrBAAAqwgAAKsQAACrFAAAqygAAKtcAACrcAAAq3gAAKuAAACrlAAAq5wAAKukAACrrAAArEAAAKzQAACtbAAArfwAAK4EAACuDAAArhQAAK4cAACuJAAAriwAAK4wAACuOAAArmwAAK6wAACuuAAArsAAAK7IAACu0AAArtgAAK7gAACu6AAArvAAAK80AACvPAAAr0QAAK9MAACvVAAAr1wAAK9kAACvbAAAr3QAAK98AACweAAAsIAAALCIAACwkAAAsJgAALCcAACwoAAAsKQAALCoAACwsAAAsLgAALC8AACwwAAAsMgAALDMAACxyAAAsdAAALHYAACx4AAAsegAALHsAACx8AAAsfQAALH4AACyAAAAsggAALIMAACyEAAAshgAALIcAACzGAAAsyAAALMoAACzMAAAszgAALM8AACzQAAAs0QAALNIAACzUAAAs1gAALNcAACzYAAAs2gAALNsAACzoAAAs6QAALOoAACzsAAAtKwAALS0AAC0vAAAtMQAALTMAAC00AAAtNQAALTYAAC03AAAtOQAALTsAAC08AAAtPQAALT8AAC1AAAAtfwAALYEAAC2DAAAthQAALYcAAC2IAAAtiQAALYoAAC2LAAAtjQAALY8AAC2QAAAtkQAALZMAAC2UAAAt0wAALdUAAC3XAAAt2QAALdsAAC3cAAAt3QAALd4AAC3fAAAt4QAALeMAAC3kAAAt5QAALecAAC3oAAAuJwAALikAAC4rAAAuLQAALi8AAC4wAAAuMQAALjIAAC4zAAAuNQAALjcAAC44AAAuOQAALjsAAC48AAAuewAALn0AAC5/AAAugQAALoMAAC6EAAAuhQAALoYAAC6HAAAuiQAALosAAC6MAAAujQAALo8AAC6QAAAutQAALtkAAC8AAAAvJAAALyYAAC8oAAAvKgAALywAAC8uAAAvMAAALzEAAC8zAAAvQAAAL08AAC9RAAAvUwAAL1UAAC9XAAAvWQAAL1sAAC9dAAAvbAAAL24AAC9wAAAvcgAAL3QAAC92AAAveAAAL3oAAC98AAAvuwAAL70AAC+/AAAvwQAAL8MAAC/EAAAvxQAAL8YAAC/HAAAvyQAAL8sAAC/MAAAvzQAAL88AAC/QAAAv0gAAMBEAADATAAAwFQAAMBcAADAZAAAwGgAAMBsAADAcAAAwHQAAMB8AADAhAAAwIgAAMCMAADAlAAAwJgAAMGUAADBnAAAwaQAAMGsAADBtAAAwbgAAMG8AADBwAAAwcQAAMHMAADB1AAAwdgAAMHcAADB5AAAwegAAMLkAADC7AAAwvQAAML8AADDBAAAwwgAAMMMAADDEAAAwxQAAMMcAADDJAAAwygAAMMsAADDNAAAwzgAAMNEAADEQAAAxEgAAMRQAADEWAAAxGAAAMRkAADEaAAAxGwAAMRwAADEeAAAxIAAAMSEAADEiAAAxJAAAMSUAADFkAAAxZgAAMWgAADFqAAAxbAAAMW0AADFuAAAxbwAAMXAAADFyAAAxdAAAMXUAADF2AAAxeAAAMXkAADG4AAAxugAAMbwAADG+AAAxwAAAMcEAADHCAAAxwwAAMcQAADHGAAAxyAAAMckAADHKAAAxzAAAMc0AADIYAAAyOwAAMlsAADJ7AAAyfQAAMn8AADKBAAAygwAAMoUAADKGAAAyhwAAMokAADKKAAAyjAAAMo0AADKPAAAykQAAMpIAADKTAAAylQAAMpYAADKbAAAyqAAAMq0AADKvAAAysQAAMrYAADK4AAAyugAAMrwAADLhAAAzBQAAMywAADNQAAAzUgAAM1QAADNWAAAzWAAAM1oAADNcAAAzXQAAM18AADNsAAAzfQAAM38AADOBAAAzgwAAM4UAADOHAAAziQAAM4sAADONAAAzngAAM6AAADOiAAAzpAAAM6YAADOoAAAzqgAAM6wAADOuAAAzsAAAM+8AADPxAAAz8wAAM/UAADP3AAAz+AAAM/kAADP6AAAz+wAAM/0AADP/AAA0AAAANAEAADQDAAA0BAAANEMAADRFAAA0RwAANEkAADRLAAA0TAAANE0AADROAAA0TwAANFEAADRTAAA0VAAANFUAADRXAAA0WAAANJcAADSZAAA0mwAANJ0AADSfAAA0oAAANKEAADSiAAA0owAANKUAADSnAAA0qAAANKkAADSrAAA0rAAANLkAADS6AAA0uwAANL0AADT8AAA0/gAANQAAADUCAAA1BAAANQUAADUGAAA1BwAANQgAADUKAAA1DAAANQ0AADUOAAA1EAAANREAADVQAAA1UgAANVQAADVWAAA1WAAANVkAADVaAAA1WwAANVwAADVeAAA1YAAANWEAADViAAA1ZAAANWUAADWkAAA1pgAANagAADWqAAA1rAAANa0AADWuAAA1rwAANbAAADWyAAA1tAAANbUAADW2AAA1uAAANbkAADX4AAA1+gAANfwAADX+AAA2AAAANgEAADYCAAA2AwAANgQAADYGAAA2CAAANgkAADYKAAA2DAAANg0AADZMAAA2TgAANlAAADZSAAA2VAAANlUAADZWAAA2VwAANlgAADZaAAA2XAAANl0AADZeAAA2YAAANmEAADaGAAA2qgAANtEAADb1AAA29wAANvkAADb7AAA2/QAANv8AADcBAAA3AgAANwQAADcRAAA3IAAANyIAADckAAA3JgAANygAADcqAAA3LAAANy4AADc9AAA3PwAAN0EAADdDAAA3RQAAN0cAADdJAAA3SwAAN00AADeMAAA3jgAAN5AAADeSAAA3lAAAN5UAADeWAAA3lwAAN5gAADeaAAA3nAAAN50AADeeAAA3oAAAN6EAADfgAAA34gAAN+QAADfmAAA36AAAN+kAADfqAAA36wAAN+wAADfuAAA38AAAN/EAADfyAAA39AAAN/UAADg0AAA4NgAAODgAADg6AAA4PAAAOD0AADg+AAA4PwAAOEAAADhCAAA4RAAAOEUAADhGAAA4SAAAOEkAADiIAAA4igAAOIwAADiOAAA4kAAAOJEAADiSAAA4kwAAOJQAADiWAAA4mAAAOJkAADiaAAA4nAAAOJ0AADjcAAA43gAAOOAAADjiAAA45AAAOOUAADjmAAA45wAAOOgAADjqAAA47AAAOO0AADjuAAA48AAAOPEAADkwAAA5MgAAOTQAADk2AAA5OAAAOTkAADk6AAA5OwAAOTwAADk+AAA5QAAAOUEAADlCAAA5RAAAOUUAADmEAAA5hgAAOYgAADmKAAA5jAAAOY0AADmOAAA5jwAAOZAAADmSAAA5lAAAOZUAADmWAAA5mAAAOZkAADnkAAA6BwAAOicAADpHAAA6SQAAOksAADpNAAA6TwAAOlEAADpSAAA6UwAAOlUAADpWAAA6WAAAOlkAADpbAAA6XQAAOl4AADpfAAA6YQAAOmIAADpnAAA6dAAAOnkAADp7AAA6fQAAOoIAADqEAAA6hgAAOogAADqtAAA60QAAOvgAADscAAA7HgAAOyAAADsiAAA7JAAAOyYAADsoAAA7KQAAOysAADs4AAA7SQAAO0sAADtNAAA7TwAAO1EAADtTAAA7VQAAO1cAADtZAAA7agAAO2wAADtuAAA7cAAAO3IAADt0AAA7dgAAO3gAADt6AAA7fAAAO7sAADu9AAA7vwAAO8EAADvDAAA7xAAAO8UAADvGAAA7xwAAO8kAADvLAAA7zAAAO80AADvPAAA70AAAPA8AADwRAAA8EwAAPBUAADwXAAA8GAAAPBkAADwaAAA8GwAAPB0AADwfAAA8IAAAPCEAADwjAAA8JAAAPGMAADxlAAA8ZwAAPGkAADxrAAA8bAAAPG0AADxuAAA8bwAAPHEAADxzAAA8dAAAPHUAADx3AAA8eAAAPIUAADyGAAA8hwAAPIkAADzIAAA8ygAAPMwAADzOAAA80AAAPNEAADzSAAA80wAAPNQAADzWAAA82AAAPNkAADzaAAA83AAAPN0AAD0cAAA9HgAAPSAAAD0iAAA9JAAAPSUAAD0mAAA9JwAAPSgAAD0qAAA9LAAAPS0AAD0uAAA9MAAAPTEAAD1wAAA9cgAAPXQAAD12AAA9eAAAPXkAAD16AAA9ewAAPXwAAD1+AAA9gAAAPYEAAD2CAAA9hAAAPYUAAD3EAAA9xgAAPcgAAD3KAAA9zAAAPc0AAD3OAAA9zwAAPdAAAD3SAAA91AAAPdUAAD3WAAA92AAAPdkAAD4YAAA+GgAAPhwAAD4eAAA+IAAAPiEAAD4iAAA+IwAAPiQAAD4mAAA+KAAAPikAAD4qAAA+LAAAPi0AAD5SAAA+dgAAPp0AAD7BAAA+wwAAPsUAAD7HAAA+yQAAPssAAD7NAAA+zgAAPtAAAD7dAAA+7AAAPu4AAD7wAAA+8gAAPvQAAD72AAA++AAAPvoAAD8JAAA/CwAAPw0AAD8PAAA/EQAAPxMAAD8VAAA/FwAAPxkAAD9YAAA/WgAAP1wAAD9eAAA/YAAAP2EAAD9iAAA/YwAAP2QAAD9mAAA/aAAAP2kAAD9qAAA/bAAAP20AAD+sAAA/rgAAP7AAAD+yAAA/tAAAP7UAAD+2AAA/twAAP7gAAD+6AAA/vAAAP70AAD++AAA/wAAAP8EAAEAAAABAAgAAQAQAAEAGAABACAAAQAkAAEAKAABACwAAQAwAAEAOAABAEAAAQBEAAEASAABAFAAAQBUAAEBUAABAVgAAQFgAAEBaAABAXAAAQF0AAEBeAABAXwAAQGAAAEBiAABAZAAAQGUAAEBmAABAaAAAQGkAAECoAABAqgAAQKwAAECuAABAsAAAQLEAAECyAABAswAAQLQAAEC2AABAuAAAQLkAAEC6AABAvAAAQL0AAED8AABA/gAAQQAAAEECAABBBAAAQQUAAEEGAABBBwAAQQgAAEEKAABBDAAAQQ0AAEEOAABBEAAAQREAAEFQAABBUgAAQVQAAEFWAABBWAAAQVkAAEFaAABBWwAAQVwAAEFeAABBYAAAQWEAAEFiAABBZAAAQWUAAEGwAABB0wAAQfMAAEITAABCFQAAQhcAAEIZAABCGwAAQh0AAEIeAABCHwAAQiEAAEIiAABCJAAAQiUAAEInAABCKQAAQioAAEIrAABCLQAAQi4AAEIzAABCQAAAQkUAAEJHAABCSQAAQk4AAEJQAABCUgAAQlQAAEJ5AABCnQAAQsQAAELoAABC6gAAQuwAAELuAABC8AAAQvIAAEL0AABC9QAAQvcAAEMEAABDFQAAQxcAAEMZAABDGwAAQx0AAEMfAABDIQAAQyMAAEMlAABDNgAAQzgAAEM6AABDPAAAQz4AAENAAABDQgAAQ0QAAENGAABDSAAAQ4cAAEOJAABDiwAAQ40AAEOPAABDkAAAQ5EAAEOSAABDkwAAQ5UAAEOXAABDmAAAQ5kAAEObAABDnAAAQ9sAAEPdAABD3wAAQ+EAAEPjAABD5AAAQ+UAAEPmAABD5wAAQ+kAAEPrAABD7AAAQ+0AAEPvAABD8AAARC8AAEQxAABEMwAARDUAAEQ3AABEOAAARDkAAEQ6AABEOwAARD0AAEQ/AABEQAAAREEAAERDAABERAAARFEAAERSAABEUwAARFUAAESUAABElgAARJgAAESaAABEnAAARJ0AAESeAABEnwAARKAAAESiAABEpAAARKUAAESmAABEqAAARKkAAEToAABE6gAAROwAAETuAABE8AAARPEAAETyAABE8wAARPQAAET2AABE+AAARPkAAET6AABE/AAARP0AAEU8AABFPgAARUAAAEVCAABFRAAARUUAAEVGAABFRwAARUgAAEVKAABFTAAARU0AAEVOAABFUAAARVEAAEWQAABFkgAARZQAAEWWAABFmAAARZkAAEWaAABFmwAARZwAAEWeAABFoAAARaEAAEWiAABFpAAARaUAAEXkAABF5gAARegAAEXqAABF7AAARe0AAEXuAABF7wAARfAAAEXyAABF9AAARfUAAEX2AABF+AAARfkAAEYeAABGQgAARmkAAEaNAABGjwAARpEAAEaTAABGlQAARpcAAEaZAABGmgAARpwAAEapAABGuAAARroAAEa8AABGvgAARsAAAEbCAABGxAAARsYAAEbVAABG1wAARtkAAEbbAABG3QAARt8AAEbhAABG4wAARuUAAEckAABHJgAARygAAEcqAABHLAAARy0AAEcuAABHLwAARzAAAEcyAABHNAAARzUAAEc2AABHOAAARzkAAEd4AABHegAAR3wAAEd+AABHgAAAR4EAAEeCAABHgwAAR4QAAEeGAABHiAAAR4kAAEeKAABHjAAAR40AAEfMAABHzgAAR9AAAEfSAABH1AAAR9UAAEfWAABH1wAAR9gAAEfaAABH3AAAR90AAEfeAABH4AAAR+EAAEggAABIIgAASCQAAEgmAABIKAAASCkAAEgqAABIKwAASCwAAEguAABIMAAASDEAAEgyAABINAAASDUAAEh0AABIdgAASHgAAEh6AABIfAAASH0AAEh+AABIfwAASIAAAEiCAABIhAAASIUAAEiGAABIiAAASIkAAEjIAABIygAASMwAAEjOAABI0AAASNEAAEjSAABI0wAASNQAAEjWAABI2AAASNkAAEjaAABI3AAASN0AAEkcAABJHgAASSAAAEkiAABJJAAASSUAAEkmAABJJwAASSgAAEkqAABJLAAASS0AAEkuAABJMAAASTEAAEl8AABJnwAASb8AAEnfAABJ4QAASeMAAEnlAABJ5wAASekAAEnqAABJ6wAASe0AAEnuAABJ8AAASfEAAEnzAABJ9QAASfYAAEn3AABJ+QAASfoAAEn/AABKDAAAShEAAEoTAABKFQAAShoAAEocAABKHgAASiAAAEpFAABKaQAASpAAAEq0AABKtgAASrgAAEq6AABKvAAASr4AAErAAABKwQAASsMAAErQAABK4QAASuMAAErlAABK5wAASukAAErrAABK7QAASu8AAErxAABLAgAASwQAAEsGAABLCAAASwoAAEsMAABLDgAASxAAAEsSAABLFAAAS1MAAEtVAABLVwAAS1kAAEtbAABLXAAAS10AAEteAABLXwAAS2EAAEtjAABLZAAAS2UAAEtnAABLaAAAS6cAAEupAABLqwAAS60AAEuvAABLsAAAS7EAAEuyAABLswAAS7UAAEu3AABLuAAAS7kAAEu7AABLvAAAS/sAAEv9AABL/wAATAEAAEwDAABMBAAATAUAAEwGAABMBwAATAkAAEwLAABMDAAATA0AAEwPAABMEAAATB0AAEweAABMHwAATCEAAExgAABMYgAATGQAAExmAABMaAAATGkAAExqAABMawAATGwAAExuAABMcAAATHEAAExyAABMdAAATHUAAEy0AABMtgAATLgAAEy6AABMvAAATL0AAEy+AABMvwAATMAAAEzCAABMxAAATMUAAEzGAABMyAAATMkAAE0IAABNCgAATQwAAE0OAABNEAAATREAAE0SAABNEwAATRQAAE0WAABNGAAATRkAAE0aAABNHAAATR0AAE1cAABNXgAATWAAAE1iAABNZAAATWUAAE1mAABNZwAATWgAAE1qAABNbAAATW0AAE1uAABNcAAATXEAAE2wAABNsgAATbQAAE22AABNuAAATbkAAE26AABNuwAATbwAAE2+AABNwAAATcEAAE3CAABNxAAATcUAAE3qAABODgAATjUAAE5ZAABOWwAATl0AAE5fAABOYQAATmMAAE5lAABOZgAATmgAAE51AABOhAAAToYAAE6IAABOigAATowAAE6OAABOkAAATpIAAE6hAABOowAATqUAAE6nAABOqQAATqsAAE6tAABOrwAATrEAAE7wAABO8gAATvQAAE72AABO+AAATvkAAE76AABO+wAATvwAAE7+AABPAAAATwEAAE8CAABPBAAATwUAAE9EAABPRgAAT0gAAE9KAABPTAAAT00AAE9OAABPTwAAT1AAAE9SAABPVAAAT1UAAE9WAABPWAAAT1kAAE+YAABPmgAAT5wAAE+eAABPoAAAT6EAAE+iAABPowAAT6QAAE+mAABPqAAAT6kAAE+qAABPrAAAT60AAE/sAABP7gAAT/AAAE/yAABP9AAAT/UAAE/2AABP9wAAT/gAAE/6AABP/AAAT/0AAE/+AABQAAAAUAEAAFBAAABQQgAAUEQAAFBGAABQSAAAUEkAAFBKAABQSwAAUEwAAFBOAABQUAAAUFEAAFBSAABQVAAAUFUAAFCUAABQlgAAUJgAAFCaAABQnAAAUJ0AAFCeAABQnwAAUKAAAFCiAABQpAAAUKUAAFCmAABQqAAAUKkAAFDoAABQ6gAAUOwAAFDuAABQ8AAAUPEAAFDyAABQ8wAAUPQAAFD2AABQ+AAAUPkAAFD6AABQ/AAAUP0AAFFIAABRawAAUYsAAFGrAABRrQAAUa8AAFGxAABRswAAUbUAAFG2AABRtwAAUbkAAFG6AABRvAAAUb0AAFG/AABRwQAAUcIAAFHDAABRxQAAUcYAAFHLAABR2AAAUd0AAFHfAABR4QAAUeYAAFHoAABR6gAAUewAAFIRAABSNQAAUlwAAFKAAABSggAAUoQAAFKGAABSiAAAUooAAFKMAABSjQAAUo8AAFKcAABSrQAAUq8AAFKxAABSswAAUrUAAFK3AABSuQAAUrsAAFK9AABSzgAAUtAAAFLSAABS1AAAUtYAAFLYAABS2gAAUtwAAFLeAABS4AAAUx8AAFMhAABTIwAAUyUAAFMnAABTKAAAUykAAFMqAABTKwAAUy0AAFMvAABTMAAAUzEAAFMzAABTNAAAU3MAAFN1AABTdwAAU3kAAFN7AABTfAAAU30AAFN+AABTfwAAU4EAAFODAABThAAAU4UAAFOHAABTiAAAU8cAAFPJAABTywAAU80AAFPPAABT0AAAU9EAAFPSAABT0wAAU9UAAFPXAABT2AAAU9kAAFPbAABT3AAAU+kAAFPqAABT6wAAU+0AAFQsAABULgAAVDAAAFQyAABUNAAAVDUAAFQ2AABUNwAAVDgAAFQ6AABUPAAAVD0AAFQ+AABUQAAAVEEAAFSAAABUggAAVIQAAFSGAABUiAAAVIkAAFSKAABUiwAAVIwAAFSOAABUkAAAVJEAAFSSAABUlAAAVJUAAFTUAABU1gAAVNgAAFTaAABU3AAAVN0AAFTeAABU3wAAVOAAAFTiAABU5AAAVOUAAFTmAABU6AAAVOkAAFUoAABVKgAAVSwAAFUuAABVMAAAVTEAAFUyAABVMwAAVTQAAFU2AABVOAAAVTkAAFU6AABVPAAAVT0AAFV8AABVfgAAVYAAAFWCAABVhAAAVYUAAFWGAABVhwAAVYgAAFWKAABVjAAAVY0AAFWOAABVkAAAVZEAAFW2AABV2gAAVgEAAFYlAABWJwAAVikAAFYrAABWLQAAVi8AAFYxAABWMgAAVjUAAFZCAABWUQAAVlMAAFZVAABWVwAAVlkAAFZbAABWXQAAVl8AAFZuAABWcQAAVnQAAFZ3AABWegAAVn0AAFaAAABWgwAAVoUAAFbEAABWxgAAVskAAFbLAABWzQAAVs4AAFbPAABW0AAAVtEAAFbTAABW1QAAVtYAAFbXAABW2QAAVtoAAFbbAABXGgAAVxwAAFceAABXIAAAVyIAAFcjAABXJAAAVyUAAFcmAABXKAAAVyoAAFcrAABXLAAAVy4AAFcvAABXbgAAV3AAAFdyAABXdAAAV3YAAFd3AABXeAAAV3kAAFd6AABXfAAAV34AAFd/AABXgAAAV4IAAFeDAABXwgAAV8QAAFfHAABXyQAAV8sAAFfMAABXzQAAV84AAFfPAABX0QAAV9MAAFfUAABX1QAAV9cAAFfYAABX2wAAWBoAAFgcAABYHgAAWCAAAFgiAABYIwAAWCQAAFglAABYJgAAWCgAAFgqAABYKwAAWCwAAFguAABYLwAAWG4AAFhwAABYcgAAWHQAAFh2AABYdwAAWHgAAFh5AABYegAAWHwAAFh+AABYfwAAWIAAAFiCAABYgwAAWMIAAFjEAABYxgAAWMgAAFjKAABYywAAWMwAAFjNAABYzgAAWNAAAFjSAABY0wAAWNQAAFjWAABY1wAAWSIAAFlFAABZZQAAWYUAAFmHAABZiQAAWYsAAFmNAABZjwAAWZAAAFmRAABZlAAAWZUAAFmXAABZmAAAWZoAAFmcAABZnQAAWZ4AAFmhAABZogAAWacAAFm0AABZuQAAWbsAAFm9AABZwgAAWcUAAFnIAABZygAAWe8AAFoTAABaOgAAWl4AAFphAABaYwAAWmUAAFpnAABaaQAAWmsAAFpsAABabwAAWnwAAFqNAABajwAAWpEAAFqTAABalQAAWpcAAFqZAABamwAAWp0AAFquAABasQAAWrQAAFq3AABaugAAWr0AAFrAAABawwAAWsYAAFrIAABbBwAAWwkAAFsLAABbDQAAWxAAAFsRAABbEgAAWxMAAFsUAABbFgAAWxgAAFsZAABbGgAAWxwAAFsdAABbXAAAW14AAFtgAABbYgAAW2UAAFtmAABbZwAAW2gAAFtpAABbawAAW20AAFtuAABbbwAAW3EAAFtyAABbsQAAW7MAAFu2AABbuAAAW7sAAFu8AABbvQAAW74AAFu/AABbwQAAW8MAAFvEAABbxQAAW8cAAFvIAABb1QAAW9YAAFvXAABb2QAAXBgAAFwaAABcHAAAXB4AAFwhAABcIgAAXCMAAFwkAABcJQAAXCcAAFwpAABcKgAAXCsAAFwtAABcLgAAXG0AAFxvAABccQAAXHMAAFx2AABcdwAAXHgAAFx5AABcegAAXHwAAFx+AABcfwAAXIAAAFyCAABcgwAAXMIAAFzEAABcxgAAXMgAAFzLAABczAAAXM0AAFzOAABczwAAXNEAAFzTAABc1AAAXNUAAFzXAABc2AAAXRcAAF0ZAABdGwAAXR0AAF0gAABdIQAAXSIAAF0jAABdJAAAXSYAAF0oAABdKQAAXSoAAF0sAABdLQAAXWwAAF1uAABdcAAAXXIAAF11AABddgAAXXcAAF14AABdeQAAXXsAAF19AABdfgAAXX8AAF2BAABdggAAXacAAF3LAABd8gAAXhYAAF4ZAABeGwAAXh0AAF4fAABeIQAAXiMAAF4kAABeJwAAXjQAAF5DAABeRQAAXkcAAF5JAABeSwAAXk0AAF5PAABeUQAAXmAAAF5jAABeZgAAXmkAAF5sAABebwAAXnIAAF51AABedwAAXrYAAF64AABeugAAXrwAAF6/AABewAAAXsEAAF7CAABewwAAXsUAAF7HAABeyAAAXskAAF7LAABezAAAXwsAAF8NAABfDwAAXxEAAF8UAABfFQAAXxYAAF8XAABfGAAAXxoAAF8cAABfHQAAXx4AAF8gAABfIQAAX2AAAF9iAABfZAAAX2YAAF9pAABfagAAX2sAAF9sAABfbQAAX28AAF9xAABfcgAAX3MAAF91AABfdgAAX7UAAF+3AABfuQAAX7sAAF++AABfvwAAX8AAAF/BAABfwgAAX8QAAF/GAABfxwAAX8gAAF/KAABfywAAYAoAAGAMAABgDgAAYBAAAGATAABgFAAAYBUAAGAWAABgFwAAYBkAAGAbAABgHAAAYB0AAGAfAABgIAAAYF8AAGBhAABgYwAAYGUAAGBoAABgaQAAYGoAAGBrAABgbAAAYG4AAGBwAABgcQAAYHIAAGB0AABgdQAAYLQAAGC2AABguAAAYLoAAGC9AABgvgAAYL8AAGDAAABgwQAAYMMAAGDFAABgxgAAYMcAAGDJAABgygAAYNUAAGDeAABg3wAAYOEAAGDqAABg9QAAYQQAAGEPAABhHQAAYTIAAGFGAABhXQAAYW8AAGGyAABh1gAAYfoAAGIdAABiRAAAYmQAAGKLAABisgAAYtIAAGL2AABjGgAAYxwAAGMfAABjIQAAYyMAAGMlAABjKAAAYysAAGMtAABjLwAAYzIAAGM0AABjNgAAYzkAAGM8AABjPQAAY0IAAGNPAABjUgAAY1QAAGNXAABjWgAAY1wAAGOBAABjpQAAY8wAAGPwAABj8wAAY/UAAGP3AABj+QAAY/sAAGP9AABj/gAAZAEAAGQOAABkIQAAZCMAAGQlAABkJwAAZCkAAGQrAABkLQAAZC8AAGQxAABkMwAAZEYAAGRJAABkTAAAZE8AAGRSAABkVQAAZFgAAGRbAABkXgAAZGEAAGRjAABkogAAZKQAAGSnAABkqQAAZKwAAGStAABkrgAAZK8AAGSwAABksgAAZLQAAGS1AABktgAAZLgAAGS5AABkwgAAZMMAAGTFAABlBAAAZQYAAGUIAABlCgAAZQ0AAGUOAABlDwAAZRAAAGURAABlEwAAZRUAAGUWAABlFwAAZRkAAGUaAABlWQAAZVsAAGVeAABlYAAAZWMAAGVkAABlZQAAZWYAAGVnAABlaQAAZWsAAGVsAABlbQAAZW8AAGVwAABleQAAZXoAAGV8AABluwAAZb0AAGW/AABlwQAAZcQAAGXFAABlxgAAZccAAGXIAABlygAAZcwAAGXNAABlzgAAZdAAAGXRAABmEAAAZhIAAGYVAABmFwAAZhoAAGYbAABmHAAAZh0AAGYeAABmIAAAZiIAAGYjAABmJAAAZiYAAGYnAABmMAAAZjEAAGYzAABmcgAAZnQAAGZ2AABmeAAAZnsAAGZ8AABmfQAAZn4AAGZ/AABmgQAAZoMAAGaEAABmhQAAZocAAGaIAABmxwAAZskAAGbMAABmzgAAZtEAAGbSAABm0wAAZtQAAGbVAABm1wAAZtkAAGbaAABm2wAAZt0AAGbeAABm6wAAZuwAAGbtAABm7wAAZy4AAGcwAABnMgAAZzQAAGc3AABnOAAAZzkAAGc6AABnOwAAZz0AAGc/AABnQAAAZ0EAAGdDAABnRAAAZ4MAAGeFAABniAAAZ4oAAGeNAABnjgAAZ48AAGeQAABnkQAAZ5MAAGeVAABnlgAAZ5cAAGeZAABnmgAAZ60AAGe6AABn5QAAZ+gAAGfrAABn7gAAZ/EAAGf0AABn9wAAZ/oAAGf9AABoAAAAaAMAAGgGAABoCQAAaAwAAGgPAABoEgAAaBUAAGgYAABoGwAAaB0AAGggAABoSwAAaE4AAGhRAABoVAAAaFcAAGhaAABoXQAAaGAAAGhjAABoZgAAaGkAAGhsAABobwAAaHIAAGh1AABoeAAAaHsAAGh+AABogQAAaIQAAGiHAABoiQAAaJIAAGifAABoqQAAaK8AAGi4AABowwAAaM8AAGjeAABo6QAAaPQAAGj9AABpBwAAaRMAAGkdAABpJgAAaS8AAGk6AABpQwAAaUoAAGmVAABpuAAAadgAAGn4AABp+gAAafwAAGn+AABqAAAAagMAAGoEAABqBQAAaggAAGoJAABqCwAAagwAAGoOAABqEQAAahIAAGoTAABqFgAAahcAAGocAABqKQAAai4AAGowAABqMgAAajcAAGo6AABqPQAAaj8AAGpkAABqiAAAaq8AAGrTAABq1gAAatgAAGraAABq3AAAat4AAGrgAABq4QAAauQAAGrxAABrAgAAawQAAGsGAABrCAAAawoAAGsMAABrDgAAaxAAAGsSAABrIwAAayYAAGspAABrLAAAay8AAGsyAABrNQAAazgAAGs7AABrPQAAa3wAAGt+AABrgAAAa4IAAGuFAABrhgAAa4cAAGuIAABriQAAa4sAAGuNAABrjgAAa48AAGuRAABrkgAAa9EAAGvTAABr1QAAa9cAAGvaAABr2wAAa9wAAGvdAABr3gAAa+AAAGviAABr4wAAa+QAAGvmAABr5wAAbCYAAGwoAABsKwAAbC0AAGwwAABsMQAAbDIAAGwzAABsNAAAbDYAAGw4AABsOQAAbDoAAGw8AABsPQAAbEoAAGxLAABsTAAAbE4AAGyNAABsjwAAbJEAAGyTAABslgAAbJcAAGyYAABsmQAAbJoAAGycAABsngAAbJ8AAGygAABsogAAbKMAAGziAABs5AAAbOYAAGzoAABs6wAAbOwAAGztAABs7gAAbO8AAGzxAABs8wAAbPQAAGz1AABs9wAAbPgAAG03AABtOQAAbTsAAG09AABtQAAAbUEAAG1CAABtQwAAbUQAAG1GAABtSAAAbUkAAG1KAABtTAAAbU0AAG2MAABtjgAAbZAAAG2SAABtlQAAbZYAAG2XAABtmAAAbZkAAG2bAABtnQAAbZ4AAG2fAABtoQAAbaIAAG3hAABt4wAAbeUAAG3nAABt6gAAbesAAG3sAABt7QAAbe4AAG3wAABt8gAAbfMAAG30AABt9gAAbfcAAG4cAABuQAAAbmcAAG6LAABujgAAbpAAAG6SAABulAAAbpYAAG6YAABumQAAbpwAAG6pAABuuAAAbroAAG68AABuvgAAbsAAAG7CAABuxAAAbsYAAG7VAABu2AAAbtsAAG7eAABu4QAAbuQAAG7nAABu6gAAbuwAAG8rAABvLQAAbzAAAG8yAABvNQAAbzYAAG83AABvOAAAbzkAAG87AABvPQAAbz4AAG8/AABvQQAAb0IAAG+BAABvgwAAb4UAAG+HAABvigAAb4sAAG+MAABvjQAAb44AAG+QAABvkgAAb5MAAG+UAABvlgAAb5cAAG/WAABv2AAAb9oAAG/cAABv3wAAb+AAAG/hAABv4gAAb+MAAG/lAABv5wAAb+gAAG/pAABv6wAAb+wAAHArAABwLQAAcDAAAHAyAABwNQAAcDYAAHA3AABwOAAAcDkAAHA7AABwPQAAcD4AAHA/AABwQQAAcEIAAHBFAABwhAAAcIYAAHCIAABwigAAcI0AAHCOAABwjwAAcJAAAHCRAABwkwAAcJUAAHCWAABwlwAAcJkAAHCaAABw2QAAcNsAAHDdAABw3wAAcOIAAHDjAABw5AAAcOUAAHDmAABw6AAAcOoAAHDrAABw7AAAcO4AAHDvAABxLgAAcTAAAHEyAABxNAAAcTcAAHE4AABxOQAAcToAAHE7AABxPQAAcT8AAHFAAABxQQAAcUMAAHFEAABxjwAAcbIAAHHSAABx8gAAcfQAAHH2AABx+AAAcfoAAHH9AABx/gAAcf8AAHICAAByAwAAcgUAAHIGAAByCAAAcgsAAHIMAAByDQAAchAAAHIRAAByFgAAciMAAHIoAAByKgAAciwAAHIxAAByNAAAcjcAAHI5AAByXgAAcoIAAHKpAAByzQAActAAAHLSAABy1AAActYAAHLYAABy2gAActsAAHLeAABy6wAAcvwAAHL+AABzAAAAcwIAAHMEAABzBgAAcwgAAHMKAABzDAAAcx0AAHMgAABzIwAAcyYAAHMpAABzLAAAcy8AAHMyAABzNQAAczcAAHN2AABzeAAAc3oAAHN8AABzfwAAc4AAAHOBAABzggAAc4MAAHOFAABzhwAAc4gAAHOJAABziwAAc4wAAHPLAABzzQAAc88AAHPRAABz1AAAc9UAAHPWAABz1wAAc9gAAHPaAABz3AAAc90AAHPeAABz4AAAc+EAAHQgAAB0IgAAdCUAAHQnAAB0KgAAdCsAAHQsAAB0LQAAdC4AAHQwAAB0MgAAdDMAAHQ0AAB0NgAAdDcAAHREAAB0RQAAdEYAAHRIAAB0hwAAdIkAAHSLAAB0jQAAdJAAAHSRAAB0kgAAdJMAAHSUAAB0lgAAdJgAAHSZAAB0mgAAdJwAAHSdAAB03AAAdN4AAHTgAAB04gAAdOUAAHTmAAB05wAAdOgAAHTpAAB06wAAdO0AAHTuAAB07wAAdPEAAHTyAAB1MQAAdTMAAHU1AAB1NwAAdToAAHU7AAB1PAAAdT0AAHU+AAB1QAAAdUIAAHVDAAB1RAAAdUYAAHVHAAB1hgAAdYgAAHWKAAB1jAAAdY8AAHWQAAB1kQAAdZIAAHWTAAB1lQAAdZcAAHWYAAB1mQAAdZsAAHWcAAB12wAAdd0AAHXfAAB14QAAdeQAAHXlAAB15gAAdecAAHXoAAB16gAAdewAAHXtAAB17gAAdfAAAHXxAAB2FgAAdjoAAHZhAAB2hQAAdogAAHaKAAB2jAAAdo4AAHaQAAB2kgAAdpMAAHaWAAB2owAAdrIAAHa0AAB2tgAAdrgAAHa6AAB2vAAAdr4AAHbAAAB2zwAAdtIAAHbVAAB22AAAdtsAAHbeAAB24QAAduQAAHbmAAB3JQAAdycAAHcpAAB3KwAAdy4AAHcvAAB3MAAAdzEAAHcyAAB3NAAAdzYAAHc3AAB3OAAAdzoAAHc7AAB3egAAd3wAAHd+AAB3gAAAd4MAAHeEAAB3hQAAd4YAAHeHAAB3iQAAd4sAAHeMAAB3jQAAd48AAHeQAAB3zwAAd9EAAHfTAAB31QAAd9gAAHfZAAB32gAAd9sAAHfcAAB33gAAd+AAAHfhAAB34gAAd+QAAHflAAB4JAAAeCYAAHgoAAB4KgAAeC0AAHguAAB4LwAAeDAAAHgxAAB4MwAAeDUAAHg2AAB4NwAAeDkAAHg6AAB4eQAAeHsAAHh9AAB4fwAAeIIAAHiDAAB4hAAAeIUAAHiGAAB4iAAAeIoAAHiLAAB4jAAAeI4AAHiPAAB4zgAAeNAAAHjSAAB41AAAeNcAAHjYAAB42QAAeNoAAHjbAAB43QAAeN8AAHjgAAB44QAAeOMAAHjkAAB5IwAAeSUAAHknAAB5KQAAeSwAAHktAAB5LgAAeS8AAHkwAAB5MgAAeTQAAHk1AAB5NgAAeTgAAHk5AAB5hAAAeacAAHnHAAB55wAAeekAAHnrAAB57QAAee8AAHnyAAB58wAAefQAAHn3AAB5+AAAefoAAHn7AAB5/QAAegAAAHoBAAB6AgAAegUAAHoGAAB6CwAAehgAAHodAAB6HwAAeiEAAHomAAB6KQAAeiwAAHouAAB6UwAAencAAHqeAAB6wgAAesUAAHrHAAB6yQAAessAAHrNAAB6zwAAetAAAHrTAAB64AAAevEAAHrzAAB69QAAevcAAHr5AAB6+wAAev0AAHr/AAB7AQAAexIAAHsVAAB7GAAAexsAAHseAAB7IQAAeyQAAHsnAAB7KgAAeywAAHtrAAB7bQAAe28AAHtxAAB7dAAAe3UAAHt2AAB7dwAAe3gAAHt6AAB7fAAAe30AAHt+AAB7gAAAe4EAAHvAAAB7wgAAe8QAAHvGAAB7yQAAe8oAAHvLAAB7zAAAe80AAHvPAAB70QAAe9IAAHvTAAB71QAAe9YAAHwVAAB8FwAAfBoAAHwcAAB8HwAAfCAAAHwhAAB8IgAAfCMAAHwlAAB8JwAAfCgAAHwpAAB8KwAAfCwAAHw5AAB8OgAAfDsAAHw9AAB8fAAAfH4AAHyAAAB8ggAAfIUAAHyGAAB8hwAAfIgAAHyJAAB8iwAAfI0AAHyOAAB8jwAAfJEAAHySAAB80QAAfNMAAHzVAAB81wAAfNoAAHzbAAB83AAAfN0AAHzeAAB84AAAfOIAAHzjAAB85AAAfOYAAHznAAB9JgAAfSgAAH0qAAB9LAAAfS8AAH0wAAB9MQAAfTIAAH0zAAB9NQAAfTcAAH04AAB9OQAAfTsAAH08AAB9ewAAfX0AAH1/AAB9gQAAfYQAAH2FAAB9hgAAfYcAAH2IAAB9igAAfYwAAH2NAAB9jgAAfZAAAH2RAAB90AAAfdIAAH3UAAB91gAAfdkAAH3aAAB92wAAfdwAAH3dAAB93wAAfeEAAH3iAAB94wAAfeUAAH3mAAB+CwAAfi8AAH5WAAB+egAAfn0AAH5/AAB+gQAAfoMAAH6FAAB+hwAAfogAAH6LAAB+mAAAfqcAAH6pAAB+qwAAfq0AAH6vAAB+sQAAfrMAAH61AAB+xAAAfscAAH7KAAB+zQAAftAAAH7TAAB+1gAAftkAAH7bAAB/GgAAfxwAAH8eAAB/IAAAfyMAAH8kAAB/JQAAfyYAAH8nAAB/KQAAfysAAH8sAAB/LQAAfy8AAH8wAAB/bwAAf3EAAH9zAAB/dQAAf3gAAH95AAB/egAAf3sAAH98AAB/fgAAf4AAAH+BAAB/ggAAf4QAAH+FAAB/xAAAf8YAAH/IAAB/ygAAf80AAH/OAAB/zwAAf9AAAH/RAAB/0wAAf9UAAH/WAAB/1wAAf9kAAH/aAACAGQAAgBsAAIAeAACAIAAAgCMAAIAkAACAJQAAgCYAAIAnAACAKQAAgCsAAIAsAACALQAAgC8AAIAwAACAMwAAgHIAAIB0AACAdgAAgHgAAIB7AACAfAAAgH0AAIB+AACAfwAAgIEAAICDAACAhAAAgIUAAICHAACAiAAAgMcAAIDJAACAywAAgM0AAIDQAACA0QAAgNIAAIDTAACA1AAAgNYAAIDYAACA2QAAgNoAAIDcAACA3QAAgRwAAIEeAACBIAAAgSIAAIElAACBJgAAgScAAIEoAACBKQAAgSsAAIEtAACBLgAAgS8AAIExAACBMgAAgX0AAIGgAACBwAAAgeAAAIHiAACB5AAAgeYAAIHoAACB6wAAgewAAIHtAACB8AAAgfEAAIHzAACB9AAAgfYAAIH5AACB+gAAgfsAAIH+AACB/wAAggQAAIIRAACCFgAAghgAAIIaAACCHwAAgiIAAIIlAACCJwAAgkwAAIJwAACClwAAgrsAAIK+AACCwAAAgsIAAILEAACCxgAAgsgAAILJAACCzAAAgtkAAILqAACC7AAAgu4AAILwAACC8gAAgvQAAIL2AACC+AAAgvoAAIMLAACDDgAAgxEAAIMUAACDFwAAgxoAAIMdAACDIAAAgyMAAIMlAACDZAAAg2YAAINoAACDagAAg20AAINuAACDbwAAg3AAAINxAACDcwAAg3UAAIN2AACDdwAAg3kAAIN6AACDuQAAg7sAAIO9AACDvwAAg8IAAIPDAACDxAAAg8UAAIPGAACDyAAAg8oAAIPLAACDzAAAg84AAIPPAACEDgAAhBAAAIQTAACEFQAAhBgAAIQZAACEGgAAhBsAAIQcAACEHgAAhCAAAIQhAACEIgAAhCQAAIQlAACEMgAAhDMAAIQ0AACENgAAhHUAAIR3AACEeQAAhHsAAIR+AACEfwAAhIAAAISBAACEggAAhIQAAISGAACEhwAAhIgAAISKAACEiwAAhMoAAITMAACEzgAAhNAAAITTAACE1AAAhNUAAITWAACE1wAAhNkAAITbAACE3AAAhN0AAITfAACE4AAAhR8AAIUhAACFIwAAhSUAAIUoAACFKQAAhSoAAIUrAACFLAAAhS4AAIUwAACFMQAAhTIAAIU0AACFNQAAhXQAAIV2AACFeAAAhXoAAIV9AACFfgAAhX8AAIWAAACFgQAAhYMAAIWFAACFhgAAhYcAAIWJAACFigAAhckAAIXLAACFzQAAhc8AAIXSAACF0wAAhdQAAIXVAACF1gAAhdgAAIXaAACF2wAAhdwAAIXeAACF3wAAhgQAAIYoAACGTwAAhnMAAIZ2AACGeAAAhnoAAIZ8AACGfgAAhoAAAIaBAACGhAAAhpEAAIagAACGogAAhqQAAIamAACGqAAAhqoAAIasAACGrgAAhr0AAIbAAACGwwAAhsYAAIbJAACGzAAAhs8AAIbSAACG1AAAhxMAAIcVAACHGAAAhxoAAIcdAACHHgAAhx8AAIcgAACHIQAAhyMAAIclAACHJgAAhycAAIcpAACHKgAAh2kAAIdrAACHbQAAh28AAIdyAACHcwAAh3QAAId1AACHdgAAh3gAAId6AACHewAAh3wAAId+AACHfwAAh74AAIfAAACHwgAAh8QAAIfHAACHyAAAh8kAAIfKAACHywAAh80AAIfPAACH0AAAh9EAAIfTAACH1AAAiBMAAIgVAACIGAAAiBoAAIgdAACIHgAAiB8AAIggAACIIQAAiCMAAIglAACIJgAAiCcAAIgpAACIKgAAiGkAAIhrAACIbQAAiG8AAIhyAACIcwAAiHQAAIh1AACIdgAAiHgAAIh6AACIewAAiHwAAIh+AACIfwAAiL4AAIjAAACIwgAAiMQAAIjHAACIyAAAiMkAAIjKAACIywAAiM0AAIjPAACI0AAAiNEAAIjTAACI1AAAiRMAAIkVAACJFwAAiRkAAIkcAACJHQAAiR4AAIkfAACJIAAAiSIAAIkkAACJJQAAiSYAAIkoAACJKQAAiXQAAImXAACJtwAAidcAAInZAACJ2wAAid0AAInfAACJ4gAAieMAAInkAACJ5wAAiegAAInqAACJ6wAAie0AAInwAACJ8QAAifIAAIn1AACJ9gAAifsAAIoIAACKDQAAig8AAIoRAACKFgAAihkAAIocAACKHgAAikMAAIpnAACKjgAAirIAAIq1AACKtwAAirkAAIq7AACKvQAAir8AAIrAAACKwwAAitAAAIrhAACK4wAAiuUAAIrnAACK6QAAiusAAIrtAACK7wAAivEAAIsCAACLBQAAiwgAAIsLAACLDgAAixEAAIsUAACLFwAAixoAAIscAACLWwAAi10AAItfAACLYQAAi2QAAItlAACLZgAAi2cAAItoAACLagAAi2wAAIttAACLbgAAi3AAAItxAACLsAAAi7IAAIu0AACLtgAAi7kAAIu6AACLuwAAi7wAAIu9AACLvwAAi8EAAIvCAACLwwAAi8UAAIvGAACMBQAAjAcAAIwKAACMDAAAjA8AAIwQAACMEQAAjBIAAIwTAACMFQAAjBcAAIwYAACMGQAAjBsAAIwcAACMKQAAjCoAAIwrAACMLQAAjGwAAIxuAACMcAAAjHIAAIx1AACMdgAAjHcAAIx4AACMeQAAjHsAAIx9AACMfgAAjH8AAIyBAACMggAAjMEAAIzDAACMxQAAjMcAAIzKAACMywAAjMwAAIzNAACMzgAAjNAAAIzSAACM0wAAjNQAAIzWAACM1wAAjRYAAI0YAACNGgAAjRwAAI0fAACNIAAAjSEAAI0iAACNIwAAjSUAAI0nAACNKAAAjSkAAI0rAACNLAAAjWsAAI1tAACNbwAAjXEAAI10AACNdQAAjXYAAI13AACNeAAAjXoAAI18AACNfQAAjX4AAI2AAACNgQAAjcAAAI3CAACNxAAAjcYAAI3JAACNygAAjcsAAI3MAACNzQAAjc8AAI3RAACN0gAAjdMAAI3VAACN1gAAjfsAAI4fAACORgAAjmoAAI5tAACObwAAjnEAAI5zAACOdQAAjncAAI54AACOewAAjogAAI6XAACOmQAAjpsAAI6dAACOnwAAjqEAAI6jAACOpQAAjrQAAI63AACOugAAjr0AAI7AAACOwwAAjsYAAI7JAACOywAAjwoAAI8MAACPDwAAjxEAAI8UAACPFQAAjxYAAI8XAACPGAAAjxoAAI8cAACPHQAAjx4AAI8gAACPIQAAj2AAAI9iAACPZAAAj2YAAI9pAACPagAAj2sAAI9sAACPbQAAj28AAI9xAACPcgAAj3MAAI91AACPdgAAj7UAAI+3AACPuQAAj7sAAI++AACPvwAAj8AAAI/BAACPwgAAj8QAAI/GAACPxwAAj8gAAI/KAACPywAAkAoAAJAMAACQDwAAkBEAAJAUAACQFQAAkBYAAJAXAACQGAAAkBoAAJAcAACQHQAAkB4AAJAgAACQIQAAkGAAAJBiAACQZAAAkGYAAJBpAACQagAAkGsAAJBsAACQbQAAkG8AAJBxAACQcgAAkHMAAJB1AACQdgAAkLUAAJC3AACQuQAAkLsAAJC+AACQvwAAkMAAAJDBAACQwgAAkMQAAJDGAACQxwAAkMgAAJDKAACQywAAkQoAAJEMAACRDgAAkRAAAJETAACRFAAAkRUAAJEWAACRFwAAkRkAAJEbAACRHAAAkR0AAJEfAACRIAAAkWsAAJGOAACRrgAAkc4AAJHQAACR0gAAkdQAAJHWAACR2QAAkdoAAJHbAACR3gAAkd8AAJHhAACR4gAAkeQAAJHnAACR6AAAkekAAJHsAACR7QAAkfYAAJIDAACSCAAAkgoAAJIMAACSEQAAkhQAAJIXAACSGQAAkj4AAJJiAACSiQAAkq0AAJKwAACSsgAAkrQAAJK2AACSuAAAkroAAJK7AACSvgAAkssAAJLcAACS3gAAkuAAAJLiAACS5AAAkuYAAJLoAACS6gAAkuwAAJL9AACTAAAAkwMAAJMGAACTCQAAkwwAAJMPAACTEgAAkxUAAJMXAACTVgAAk1gAAJNaAACTXAAAk18AAJNgAACTYQAAk2IAAJNjAACTZQAAk2cAAJNoAACTaQAAk2sAAJNsAACTqwAAk60AAJOvAACTsQAAk7QAAJO1AACTtgAAk7cAAJO4AACTugAAk7wAAJO9AACTvgAAk8AAAJPBAACUAAAAlAIAAJQFAACUBwAAlAoAAJQLAACUDAAAlA0AAJQOAACUEAAAlBIAAJQTAACUFAAAlBYAAJQXAACUJAAAlCUAAJQmAACUKAAAlGcAAJRpAACUawAAlG0AAJRwAACUcQAAlHIAAJRzAACUdAAAlHYAAJR4AACUeQAAlHoAAJR8AACUfQAAlLwAAJS+AACUwAAAlMIAAJTFAACUxgAAlMcAAJTIAACUyQAAlMsAAJTNAACUzgAAlM8AAJTRAACU0gAAlREAAJUTAACVFQAAlRcAAJUaAACVGwAAlRwAAJUdAACVHgAAlSAAAJUiAACVIwAAlSQAAJUmAACVJwAAlWYAAJVoAACVagAAlWwAAJVvAACVcAAAlXEAAJVyAACVcwAAlXUAAJV3AACVeAAAlXkAAJV7AACVfAAAlbsAAJW9AACVvwAAlcEAAJXEAACVxQAAlcYAAJXHAACVyAAAlcoAAJXMAACVzQAAlc4AAJXQAACV0QAAlfYAAJYaAACWQQAAlmUAAJZoAACWagAAlmwAAJZuAACWcAAAlnIAAJZzAACWdgAAloMAAJaSAACWlAAAlpYAAJaYAACWmgAAlpwAAJaeAACWoAAAlq8AAJayAACWtQAAlrgAAJa7AACWvgAAlsEAAJbEAACWxgAAlwUAAJcHAACXCgAAlwwAAJcPAACXEAAAlxEAAJcSAACXEwAAlxUAAJcXAACXGAAAlxkAAJcbAACXHAAAlyAAAJdfAACXYQAAl2MAAJdlAACXaAAAl2kAAJdqAACXawAAl2wAAJduAACXcAAAl3EAAJdyAACXdAAAl3UAAJe0AACXtgAAl7gAAJe6AACXvQAAl74AAJe/AACXwAAAl8EAAJfDAACXxQAAl8YAAJfHAACXyQAAl8oAAJgJAACYCwAAmA4AAJgQAACYEwAAmBQAAJgVAACYFgAAmBcAAJgZAACYGwAAmBwAAJgdAACYHwAAmCAAAJhfAACYYQAAmGMAAJhlAACYaAAAmGkAAJhqAACYawAAmGwAAJhuAACYcAAAmHEAAJhyAACYdAAAmHUAAJi0AACYtgAAmLgAAJi6AACYvQAAmL4AAJi/AACYwAAAmMEAAJjDAACYxQAAmMYAAJjHAACYyQAAmMoAAJkJAACZCwAAmQ0AAJkPAACZEgAAmRMAAJkUAACZFQAAmRYAAJkYAACZGgAAmRsAAJkcAACZHgAAmR8AAJlqAACZjQAAma0AAJnNAACZzwAAmdEAAJnTAACZ1QAAmdgAAJnZAACZ2gAAmd0AAJneAACZ4AAAmeEAAJnjAACZ5gAAmecAAJnoAACZ6wAAmewAAJnxAACZ/gAAmgMAAJoFAACaBwAAmgwAAJoPAACaEgAAmhQAAJo5AACaXQAAmoQAAJqoAACaqwAAmq0AAJqvAACasQAAmrMAAJq1AACatgAAmrkAAJrGAACa1wAAmtkAAJrbAACa3QAAmt8AAJrhAACa4wAAmuUAAJrnAACa+AAAmvsAAJr+AACbAQAAmwQAAJsHAACbCgAAmw0AAJsQAACbEgAAm1EAAJtTAACbVQAAm1cAAJtaAACbWwAAm1wAAJtdAACbXgAAm2AAAJtiAACbYwAAm2QAAJtmAACbZwAAm6YAAJuoAACbqgAAm6wAAJuvAACbsAAAm7EAAJuyAACbswAAm7UAAJu3AACbuAAAm7kAAJu7AACbvAAAm/sAAJv9AACcAAAAnAIAAJwFAACcBgAAnAcAAJwIAACcCQAAnAsAAJwNAACcDgAAnA8AAJwRAACcEgAAnB8AAJwgAACcIQAAnCMAAJxiAACcZAAAnGYAAJxoAACcawAAnGwAAJxtAACcbgAAnG8AAJxxAACccwAAnHQAAJx1AACcdwAAnHgAAJy3AACcuQAAnLsAAJy9AACcwAAAnMEAAJzCAACcwwAAnMQAAJzGAACcyAAAnMkAAJzKAACczAAAnM0AAJ0MAACdDgAAnRAAAJ0SAACdFQAAnRYAAJ0XAACdGAAAnRkAAJ0bAACdHQAAnR4AAJ0fAACdIQAAnSIAAJ1hAACdYwAAnWUAAJ1nAACdagAAnWsAAJ1sAACdbQAAnW4AAJ1wAACdcgAAnXMAAJ10AACddgAAnXcAAJ22AACduAAAnboAAJ28AACdvwAAncAAAJ3BAACdwgAAncMAAJ3FAACdxwAAncgAAJ3JAACdywAAncwAAJ3xAACeFQAAnjwAAJ5gAACeYwAAnmUAAJ5nAACeaQAAnmsAAJ5tAACebgAAnnEAAJ5+AACejQAAno8AAJ6RAACekwAAnpUAAJ6XAACemQAAnpsAAJ6qAACerQAAnrAAAJ6zAACetgAAnrkAAJ68AACevwAAnsEAAJ8AAACfAgAAnwQAAJ8GAACfCQAAnwoAAJ8LAACfDAAAnw0AAJ8PAACfEQAAnxIAAJ8TAACfFQAAnxYAAJ9VAACfVwAAn1kAAJ9bAACfXgAAn18AAJ9gAACfYQAAn2IAAJ9kAACfZgAAn2cAAJ9oAACfagAAn2sAAJ+qAACfrAAAn64AAJ+wAACfswAAn7QAAJ+1AACftgAAn7cAAJ+5AACfuwAAn7wAAJ+9AACfvwAAn8AAAJ//AACgAQAAoAMAAKAFAACgCAAAoAkAAKAKAACgCwAAoAwAAKAOAACgEAAAoBEAAKASAACgFAAAoBUAAKBUAACgVgAAoFgAAKBaAACgXQAAoF4AAKBfAACgYAAAoGEAAKBjAACgZQAAoGYAAKBnAACgaQAAoGoAAKCpAACgqwAAoK0AAKCvAACgsgAAoLMAAKC0AACgtQAAoLYAAKC4AACgugAAoLsAAKC8AACgvgAAoL8AAKD+AAChAAAAoQIAAKEEAAChBwAAoQgAAKEJAAChCgAAoQsAAKENAAChDwAAoRAAAKERAAChEwAAoRQAAKFfAAChggAAoaIAAKHCAAChxAAAocYAAKHIAAChygAAoc0AAKHOAAChzwAAodIAAKHTAACh1QAAodYAAKHYAACh2wAAodwAAKHdAACh4AAAoeEAAKHmAACh8wAAofgAAKH6AACh/AAAogEAAKIEAACiBwAAogkAAKIuAACiUgAAonkAAKKdAACioAAAoqIAAKKkAACipgAAoqgAAKKqAACiqwAAoq4AAKK7AACizAAAos4AAKLQAACi0gAAotQAAKLWAACi2AAAotoAAKLcAACi7QAAovAAAKLzAACi9gAAovkAAKL8AACi/wAAowIAAKMFAACjBwAAo0YAAKNIAACjSgAAo0wAAKNPAACjUAAAo1EAAKNSAACjUwAAo1UAAKNXAACjWAAAo1kAAKNbAACjXAAAo5sAAKOdAACjnwAAo6EAAKOkAACjpQAAo6YAAKOnAACjqAAAo6oAAKOsAACjrQAAo64AAKOwAACjsQAAo/AAAKPyAACj9QAAo/cAAKP6AACj+wAAo/wAAKP9AACj/gAApAAAAKQCAACkAwAApAQAAKQGAACkBwAApBQAAKQVAACkFgAApBgAAKRXAACkWQAApFsAAKRdAACkYAAApGEAAKRiAACkYwAApGQAAKRmAACkaAAApGkAAKRqAACkbAAApG0AAKSsAACkrgAApLAAAKSyAACktQAApLYAAKS3AACkuAAApLkAAKS7AACkvQAApL4AAKS/AACkwQAApMIAAKUBAAClAwAApQUAAKUHAAClCgAApQsAAKUMAAClDQAApQ4AAKUQAAClEgAApRMAAKUUAAClFgAApRcAAKVWAAClWAAApVoAAKVcAAClXwAApWAAAKVhAAClYgAApWMAAKVlAAClZwAApWgAAKVpAAClawAApWwAAKWrAAClrQAApa8AAKWxAACltAAApbUAAKW2AACltwAApbgAAKW6AAClvAAApb0AAKW+AAClwAAApcEAAKXmAACmCgAApjEAAKZVAACmWAAAploAAKZcAACmXgAApmAAAKZiAACmYwAApmYAAKZzAACmggAApoQAAKaGAACmiAAApooAAKaMAACmjgAAppAAAKafAACmogAApqUAAKaoAACmqwAApq4AAKaxAACmtAAAprYAAKb1AACm9wAApvkAAKb7AACm/gAApv8AAKcAAACnAQAApwIAAKcEAACnBgAApwcAAKcIAACnCgAApwsAAKdKAACnTAAAp04AAKdQAACnUwAAp1QAAKdVAACnVgAAp1cAAKdZAACnWwAAp1wAAKddAACnXwAAp2AAAKefAACnoQAAp6MAAKelAACnqAAAp6kAAKeqAACnqwAAp6wAAKeuAACnsAAAp7EAAKeyAACntAAAp7UAAKf0AACn9gAAp/gAAKf6AACn/QAAp/4AAKf/AACoAAAAqAEAAKgDAACoBQAAqAYAAKgHAACoCQAAqAoAAKhJAACoSwAAqE0AAKhPAACoUgAAqFMAAKhUAACoVQAAqFYAAKhYAACoWgAAqFsAAKhcAACoXgAAqF8AAKieAACooAAAqKIAAKikAACopwAAqKgAAKipAACoqgAAqKsAAKitAACorwAAqLAAAKixAACoswAAqLQAAKjzAACo9QAAqPcAAKj5AACo/AAAqP0AAKj+AACo/wAAqQAAAKkCAACpBAAAqQUAAKkGAACpCAAAqQkAAKlUAACpdwAAqZcAAKm3AACpuQAAqbsAAKm9AACpvwAAqcIAAKnDAACpxAAAqccAAKnIAACpygAAqcsAAKnNAACp0AAAqdEAAKnSAACp1QAAqdYAAKnbAACp6AAAqe0AAKnvAACp8QAAqfYAAKn5AACp/AAAqf4AAKojAACqRwAAqm4AAKqSAACqlQAAqpcAAKqZAACqmwAAqp0AAKqfAACqoAAAqqMAAKqwAACqwQAAqsMAAKrFAACqxwAAqskAAKrLAACqzQAAqs8AAKrRAACq4gAAquUAAKroAACq6wAAqu4AAKrxAACq9AAAqvcAAKr6AACq/AAAqzsAAKs9AACrPwAAq0EAAKtEAACrRQAAq0YAAKtHAACrSAAAq0oAAKtMAACrTQAAq04AAKtQAACrUQAAq5AAAKuSAACrlAAAq5YAAKuZAACrmgAAq5sAAKucAACrnQAAq58AAKuhAACrogAAq6MAAKulAACrpgAAq+UAAKvnAACr6gAAq+wAAKvvAACr8AAAq/EAAKvyAACr8wAAq/UAAKv3AACr+AAAq/kAAKv7AACr/AAArAkAAKwKAACsCwAArA0AAKxMAACsTgAArFAAAKxSAACsVQAArFYAAKxXAACsWAAArFkAAKxbAACsXQAArF4AAKxfAACsYQAArGIAAKyhAACsowAArKUAAKynAACsqgAArKsAAKysAACsrQAArK4AAKywAACssgAArLMAAKy0AACstgAArLcAAKz2AACs+AAArPoAAKz8AACs/wAArQAAAK0BAACtAgAArQMAAK0FAACtBwAArQgAAK0JAACtCwAArQwAAK1LAACtTQAArU8AAK1RAACtVAAArVUAAK1WAACtVwAArVgAAK1aAACtXAAArV0AAK1eAACtYAAArWEAAK2gAACtogAAraQAAK2mAACtqQAAraoAAK2rAACtrAAAra0AAK2vAACtsQAArbIAAK2zAACttQAArbYAAK3bAACt/wAAriYAAK5KAACuTQAArk8AAK5RAACuUwAArlUAAK5XAACuWAAArlsAAK5oAACudwAArnkAAK57AACufQAArn8AAK6BAACugwAAroUAAK6UAACulwAArpoAAK6dAACuoAAArqMAAK6mAACuqQAArqsAAK7qAACu7AAAru4AAK7wAACu8wAArvQAAK71AACu9gAArvcAAK75AACu+wAArvwAAK79AACu/wAArwAAAK8/AACvQQAAr0MAAK9FAACvSAAAr0kAAK9KAACvSwAAr0wAAK9OAACvUAAAr1EAAK9SAACvVAAAr1UAAK+UAACvlgAAr5gAAK+aAACvnQAAr54AAK+fAACvoAAAr6EAAK+jAACvpQAAr6YAAK+nAACvqQAAr6oAAK/pAACv6wAAr+4AAK/wAACv8wAAr/QAAK/1AACv9gAAr/cAAK/5AACv+wAAr/wAAK/9AACv/wAAsAAAALA/AACwQQAAsEMAALBFAACwSAAAsEkAALBKAACwSwAAsEwAALBOAACwUAAAsFEAALBSAACwVAAAsFUAALCUAACwlgAAsJgAALCaAACwnQAAsJ4AALCfAACwoAAAsKEAALCjAACwpQAAsKYAALCnAACwqQAAsKoAALDpAACw6wAAsO0AALDvAACw8gAAsPMAALD0AACw9QAAsPYAALD4AACw+gAAsPsAALD8AACw/gAAsP8AALFKAACxbQAAsY0AALGtAACxrwAAsbEAALGzAACxtQAAsbgAALG5AACxugAAsb0AALG+AACxwAAAscEAALHDAACxxgAAsccAALHIAACxywAAscwAALHRAACx3gAAseMAALHlAACx5wAAsewAALHvAACx8gAAsfQAALIZAACyPQAAsmQAALKIAACyiwAAso0AALKPAACykQAAspMAALKVAACylgAAspkAALKmAACytwAAsrkAALK7AACyvQAAsr8AALLBAACywwAAssUAALLHAACy2AAAstsAALLeAACy4QAAsuQAALLnAACy6gAAsu0AALLwAACy8gAAszEAALMzAACzNQAAszcAALM6AACzOwAAszwAALM9AACzPgAAs0AAALNCAACzQwAAs0QAALNGAACzRwAAs4YAALOIAACzigAAs4wAALOPAACzkAAAs5EAALOSAACzkwAAs5UAALOXAACzmAAAs5kAALObAACznAAAs9sAALPdAACz4AAAs+IAALPlAACz5gAAs+cAALPoAACz6QAAs+sAALPtAACz7gAAs+8AALPxAACz8gAAs/8AALQAAAC0AQAAtAMAALRCAAC0RAAAtEYAALRIAAC0SwAAtEwAALRNAAC0TgAAtE8AALRRAAC0UwAAtFQAALRVAAC0VwAAtFgAALSXAAC0mQAAtJsAALSdAAC0oAAAtKEAALSiAAC0owAAtKQAALSmAAC0qAAAtKkAALSqAAC0rAAAtK0AALTsAAC07gAAtPAAALTyAAC09QAAtPYAALT3AAC0+AAAtPkAALT7AAC0/QAAtP4AALT/AAC1AQAAtQIAALVBAAC1QwAAtUUAALVHAAC1SgAAtUsAALVMAAC1TQAAtU4AALVQAAC1UgAAtVMAALVUAAC1VgAAtVcAALWWAAC1mAAAtZoAALWcAAC1nwAAtaAAALWhAAC1ogAAtaMAALWlAAC1pwAAtagAALWpAAC1qwAAtawAALXRAAC19QAAthwAALZAAAC2QwAAtkUAALZHAAC2SQAAtksAALZNAAC2TgAAtlEAALZeAAC2bQAAtm8AALZxAAC2cwAAtnUAALZ3AAC2eQAAtnsAALaKAAC2jQAAtpAAALaTAAC2lgAAtpkAALacAAC2nwAAtqEAALbgAAC24gAAtuQAALbmAAC26QAAtuoAALbrAAC27AAAtu0AALbvAAC28QAAtvIAALbzAAC29QAAtvYAALc1AAC3NwAAtzkAALc7AAC3PgAAtz8AALdAAAC3QQAAt0IAALdEAAC3RgAAt0cAALdIAAC3SgAAt0sAALeKAAC3jAAAt44AALeQAAC3kwAAt5QAALeVAAC3lgAAt5cAALeZAAC3mwAAt5wAALedAAC3nwAAt6AAALffAAC34QAAt+MAALflAAC36AAAt+kAALfqAAC36wAAt+wAALfuAAC38AAAt/EAALfyAAC39AAAt/UAALg0AAC4NgAAuDgAALg6AAC4PQAAuD4AALg/AAC4QAAAuEEAALhDAAC4RQAAuEYAALhHAAC4SQAAuEoAALiJAAC4iwAAuI0AALiPAAC4kgAAuJMAALiUAAC4lQAAuJYAALiYAAC4mgAAuJsAALicAAC4ngAAuJ8AALjeAAC44AAAuOIAALjkAAC45wAAuOgAALjpAAC46gAAuOsAALjtAAC47wAAuPAAALjxAAC48wAAuPQAALk/AAC5YgAAuYIAALmiAAC5pAAAuaYAALmoAAC5qgAAua0AALmuAAC5rwAAubIAALmzAAC5tQAAubYAALm4AAC5uwAAubwAALm9AAC5wAAAucEAALnGAAC50wAAudgAALnaAAC53AAAueEAALnkAAC55wAAuekAALoOAAC6MgAAulkAALp9AAC6gAAAuoIAALqEAAC6hgAAuogAALqKAAC6iwAAuo4AALqbAAC6rAAAuq4AALqwAAC6sgAAurQAALq2AAC6uAAAuroAALq8AAC6zQAAutAAALrTAAC61gAAutkAALrcAAC63wAAuuIAALrlAAC65wAAuyYAALsoAAC7KgAAuywAALsvAAC7MAAAuzEAALsyAAC7MwAAuzUAALs3AAC7OAAAuzkAALs7AAC7PAAAu3sAALt9AAC7fwAAu4EAALuEAAC7hQAAu4YAALuHAAC7iAAAu4oAALuMAAC7jQAAu44AALuQAAC7kQAAu9AAALvSAAC71QAAu9cAALvaAAC72wAAu9wAALvdAAC73gAAu+AAALviAAC74wAAu+QAALvmAAC75wAAu/QAALv1AAC79gAAu/gAALw3AAC8OQAAvDsAALw9AAC8QAAAvEEAALxCAAC8QwAAvEQAALxGAAC8SAAAvEkAALxKAAC8TAAAvE0AALyMAAC8jgAAvJAAALySAAC8lQAAvJYAALyXAAC8mAAAvJkAALybAAC8nQAAvJ4AALyfAAC8oQAAvKIAALzhAAC84wAAvOUAALznAAC86gAAvOsAALzsAAC87QAAvO4AALzwAAC88gAAvPMAALz0AAC89gAAvPcAAL02AAC9OAAAvToAAL08AAC9PwAAvUAAAL1BAAC9QgAAvUMAAL1FAAC9RwAAvUgAAL1JAAC9SwAAvUwAAL2LAAC9jQAAvY8AAL2RAAC9lAAAvZUAAL2WAAC9lwAAvZgAAL2aAAC9nAAAvZ0AAL2eAAC9oAAAvaEAAL3GAAC96gAAvhEAAL41AAC+OAAAvjoAAL48AAC+PgAAvkAAAL5CAAC+QwAAvkYAAL5TAAC+YgAAvmQAAL5mAAC+aAAAvmoAAL5sAAC+bgAAvnAAAL5/AAC+ggAAvoUAAL6IAAC+iwAAvo4AAL6RAAC+lAAAvpYAAL7VAAC+1wAAvtoAAL7cAAC+3wAAvuAAAL7hAAC+4gAAvuMAAL7lAAC+5wAAvugAAL7pAAC+6wAAvuwAAL8rAAC/LQAAvy8AAL8xAAC/NAAAvzUAAL82AAC/NwAAvzgAAL86AAC/PAAAvz0AAL8+AAC/QAAAv0EAAL+AAAC/ggAAv4QAAL+GAAC/iQAAv4oAAL+LAAC/jAAAv40AAL+PAAC/kQAAv5IAAL+TAAC/lQAAv5YAAL/VAAC/1wAAv9oAAL/cAAC/3wAAv+AAAL/hAAC/4gAAv+MAAL/lAAC/5wAAv+gAAL/pAAC/6wAAv+wAAMArAADALQAAwC8AAMAxAADANAAAwDUAAMA2AADANwAAwDgAAMA6AADAPAAAwD0AAMA+AADAQAAAwEEAAMCAAADAggAAwIQAAMCGAADAiQAAwIoAAMCLAADAjAAAwI0AAMCPAADAkQAAwJIAAMCTAADAlQAAwJYAAMDVAADA1wAAwNkAAMDbAADA3gAAwN8AAMDgAADA4QAAwOIAAMDkAADA5gAAwOcAAMDoAADA6gAAwOsAAME2AADBWQAAwXkAAMGZAADBmwAAwZ0AAMGfAADBoQAAwaQAAMGlAADBpgAAwakAAMGqAADBrAAAwa0AAMGvAADBsgAAwbMAAMG0AADBtwAAwbgAAMG9AADBygAAwc8AAMHRAADB0wAAwdgAAMHbAADB3gAAweAAAMIFAADCKQAAwlAAAMJ0AADCdwAAwnkAAMJ7AADCfQAAwn8AAMKBAADCggAAwoUAAMKSAADCowAAwqUAAMKnAADCqQAAwqsAAMKtAADCrwAAwrEAAMKzAADCxAAAwscAAMLKAADCzQAAwtAAAMLTAADC1gAAwtkAAMLcAADC3gAAwx0AAMMfAADDIQAAwyMAAMMmAADDJwAAwygAAMMpAADDKgAAwywAAMMuAADDLwAAwzAAAMMyAADDMwAAw3IAAMN0AADDdgAAw3gAAMN7AADDfAAAw30AAMN+AADDfwAAw4EAAMODAADDhAAAw4UAAMOHAADDiAAAw8cAAMPJAADDzAAAw84AAMPRAADD0gAAw9MAAMPUAADD1QAAw9cAAMPZAADD2gAAw9sAAMPdAADD3gAAw+sAAMPsAADD7QAAw+8AAMQuAADEMAAAxDIAAMQ0AADENwAAxDgAAMQ5AADEOgAAxDsAAMQ9AADEPwAAxEAAAMRBAADEQwAAxEQAAMSDAADEhQAAxIcAAMSJAADEjAAAxI0AAMSOAADEjwAAxJAAAMSSAADElAAAxJUAAMSWAADEmAAAxJkAAMTYAADE2gAAxNwAAMTeAADE4QAAxOIAAMTjAADE5AAAxOUAAMTnAADE6QAAxOoAAMTrAADE7QAAxO4AAMUtAADFLwAAxTEAAMUzAADFNgAAxTcAAMU4AADFOQAAxToAAMU8AADFPgAAxT8AAMVAAADFQgAAxUMAAMWCAADFhAAAxYYAAMWIAADFiwAAxYwAAMWNAADFjgAAxY8AAMWRAADFkwAAxZQAAMWVAADFlwAAxZgAAMW9AADF4QAAxggAAMYsAADGLwAAxjEAAMYzAADGNQAAxjcAAMY5AADGOgAAxj0AAMZKAADGWQAAxlsAAMZdAADGXwAAxmEAAMZjAADGZQAAxmcAAMZ2AADGeQAAxnwAAMZ/AADGggAAxoUAAMaIAADGiwAAxo0AAMbMAADGzgAAxtAAAMbSAADG1QAAxtYAAMbXAADG2AAAxtkAAMbbAADG3QAAxt4AAMbfAADG4QAAxuIAAMchAADHIwAAxyUAAMcnAADHKgAAxysAAMcsAADHLQAAxy4AAMcwAADHMgAAxzMAAMc0AADHNgAAxzcAAMd2AADHeAAAx3oAAMd8AADHfwAAx4AAAMeBAADHggAAx4MAAMeFAADHhwAAx4gAAMeJAADHiwAAx4wAAMfLAADHzQAAx88AAMfRAADH1AAAx9UAAMfWAADH1wAAx9gAAMfaAADH3AAAx90AAMfeAADH4AAAx+EAAMggAADIIgAAyCQAAMgmAADIKQAAyCoAAMgrAADILAAAyC0AAMgvAADIMQAAyDIAAMgzAADINQAAyDYAAMh1AADIdwAAyHkAAMh7AADIfgAAyH8AAMiAAADIgQAAyIIAAMiEAADIhgAAyIcAAMiIAADIigAAyIsAAMjKAADIzAAAyM4AAMjQAADI0wAAyNQAAMjVAADI1gAAyNcAAMjZAADI2wAAyNwAAMjdAADI3wAAyOAAAMkrAADJTgAAyW4AAMmOAADJkAAAyZIAAMmUAADJlgAAyZkAAMmaAADJmwAAyZ4AAMmfAADJoQAAyaIAAMmkAADJpwAAyagAAMmpAADJrAAAya0AAMmyAADJvwAAycQAAMnGAADJyAAAyc0AAMnQAADJ0wAAydUAAMn6AADKHgAAykUAAMppAADKbAAAym4AAMpwAADKcgAAynQAAMp2AADKdwAAynoAAMqHAADKmAAAypoAAMqcAADKngAAyqAAAMqiAADKpAAAyqYAAMqoAADKuQAAyrwAAMq/AADKwgAAysUAAMrIAADKywAAys4AAMrRAADK0wAAyxIAAMsUAADLFgAAyxgAAMsbAADLHAAAyx0AAMseAADLHwAAyyEAAMsjAADLJAAAyyUAAMsnAADLKAAAy2cAAMtpAADLawAAy20AAMtwAADLcQAAy3IAAMtzAADLdAAAy3YAAMt4AADLeQAAy3oAAMt8AADLfQAAy7wAAMu+AADLwQAAy8MAAMvGAADLxwAAy8gAAMvJAADLygAAy8wAAMvOAADLzwAAy9AAAMvSAADL0wAAy+AAAMvhAADL4gAAy+QAAMwjAADMJQAAzCcAAMwpAADMLAAAzC0AAMwuAADMLwAAzDAAAMwyAADMNAAAzDUAAMw2AADMOAAAzDkAAMx4AADMegAAzHwAAMx+AADMgQAAzIIAAMyDAADMhAAAzIUAAMyHAADMiQAAzIoAAMyLAADMjQAAzI4AAMzNAADMzwAAzNEAAMzTAADM1gAAzNcAAMzYAADM2QAAzNoAAMzcAADM3gAAzN8AAMzgAADM4gAAzOMAAM0iAADNJAAAzSYAAM0oAADNKwAAzSwAAM0tAADNLgAAzS8AAM0xAADNMwAAzTQAAM01AADNNwAAzTgAAM13AADNeQAAzXsAAM19AADNgAAAzYEAAM2CAADNgwAAzYQAAM2GAADNiAAAzYkAAM2KAADNjAAAzY0AAM2yAADN1gAAzf0AAM4hAADOJAAAziYAAM4oAADOKgAAziwAAM4uAADOLwAAzjIAAM4/AADOTgAAzlAAAM5SAADOVAAAzlYAAM5YAADOWgAAzlwAAM5rAADObgAAznEAAM50AADOdwAAznoAAM59AADOgAAAzoIAAM7BAADOwwAAzsYAAM7IAADOywAAzswAAM7NAADOzgAAzs8AAM7RAADO0wAAztQAAM7VAADO1wAAztgAAM7bAADPGgAAzxwAAM8eAADPIAAAzyMAAM8kAADPJQAAzyYAAM8nAADPKQAAzysAAM8sAADPLQAAzy8AAM8wAADPbwAAz3EAAM9zAADPdQAAz3gAAM95AADPegAAz3sAAM98AADPfgAAz4AAAM+BAADPggAAz4QAAM+FAADPxAAAz8YAAM/JAADPywAAz84AAM/PAADP0AAAz9EAAM/SAADP1AAAz9YAAM/XAADP2AAAz9oAAM/bAADP3gAA0B0AANAfAADQIQAA0CMAANAmAADQJwAA0CgAANApAADQKgAA0CwAANAuAADQLwAA0DAAANAyAADQMwAA0HIAANB0AADQdgAA0HgAANB7AADQfAAA0H0AANB+AADQfwAA0IEAANCDAADQhAAA0IUAANCHAADQiAAA0McAANDJAADQywAA0M0AANDQAADQ0QAA0NIAANDTAADQ1AAA0NYAANDYAADQ2QAA0NoAANDcAADQ3QAA0SgAANFLAADRawAA0YsAANGNAADRjwAA0ZEAANGTAADRlgAA0ZcAANGYAADRmwAA0ZwAANGeAADRnwAA0aEAANGkAADRpQAA0aYAANGpAADRqgAA0bMAANHAAADRxQAA0ccAANHJAADRzgAA0dEAANHUAADR1gAA0fsAANIfAADSRgAA0moAANJtAADSbwAA0nEAANJzAADSdQAA0ncAANJ4AADSewAA0ogAANKZAADSmwAA0p0AANKfAADSoQAA0qMAANKlAADSpwAA0qkAANK6AADSvQAA0sAAANLDAADSxgAA0skAANLMAADSzwAA0tIAANLUAADTEwAA0xUAANMXAADTGQAA0xwAANMdAADTHgAA0x8AANMgAADTIgAA0yQAANMlAADTJgAA0ygAANMpAADTaAAA02oAANNsAADTbgAA03EAANNyAADTcwAA03QAANN1AADTdwAA03kAANN6AADTewAA030AANN+AADTvQAA078AANPCAADTxAAA08cAANPIAADTyQAA08oAANPLAADTzQAA088AANPQAADT0QAA09MAANPUAADT4QAA0+IAANPjAADT5QAA1CQAANQmAADUKAAA1CoAANQtAADULgAA1C8AANQwAADUMQAA1DMAANQ1AADUNgAA1DcAANQ5AADUOgAA1HkAANR7AADUfQAA1H8AANSCAADUgwAA1IQAANSFAADUhgAA1IgAANSKAADUiwAA1IwAANSOAADUjwAA1M4AANTQAADU0gAA1NQAANTXAADU2AAA1NkAANTaAADU2wAA1N0AANTfAADU4AAA1OEAANTjAADU5AAA1SMAANUlAADVJwAA1SkAANUsAADVLQAA1S4AANUvAADVMAAA1TIAANU0AADVNQAA1TYAANU4AADVOQAA1XgAANV6AADVfAAA1X4AANWBAADVggAA1YMAANWEAADVhQAA1YcAANWJAADVigAA1YsAANWNAADVjgAA1bMAANXXAADV/gAA1iIAANYlAADWJwAA1ikAANYrAADWLQAA1i8AANYwAADWMwAA1kAAANZPAADWUQAA1lMAANZVAADWVwAA1lkAANZbAADWXQAA1mwAANZvAADWcgAA1nUAANZ4AADWewAA1n4AANaBAADWgwAA1sIAANbEAADWxgAA1sgAANbLAADWzAAA1s0AANbOAADWzwAA1tEAANbTAADW1AAA1tUAANbXAADW2AAA1xcAANcZAADXGwAA1x0AANcgAADXIQAA1yIAANcjAADXJAAA1yYAANcoAADXKQAA1yoAANcsAADXLQAA12wAANduAADXcAAA13IAANd1AADXdgAA13cAANd4AADXeQAA13sAANd9AADXfgAA138AANeBAADXggAA18EAANfDAADXxgAA18gAANfLAADXzAAA180AANfOAADXzwAA19EAANfTAADX1AAA19UAANfXAADX2AAA2BcAANgZAADYGwAA2B0AANggAADYIQAA2CIAANgjAADYJAAA2CYAANgoAADYKQAA2CoAANgsAADYLQAA2GwAANhuAADYcAAA2HIAANh1AADYdgAA2HcAANh4AADYeQAA2HsAANh9AADYfgAA2H8AANiBAADYggAA2MEAANjDAADYxQAA2McAANjKAADYywAA2MwAANjNAADYzgAA2NAAANjSAADY0wAA2NQAANjWAADY1wAA2SIAANlFAADZZQAA2YUAANmHAADZiQAA2YsAANmNAADZkAAA2ZEAANmSAADZlQAA2ZYAANmYAADZmQAA2ZsAANmeAADZnwAA2aAAANmjAADZpAAA2akAANm2AADZuwAA2b0AANm/AADZxAAA2ccAANnKAADZzAAA2fEAANoVAADaPAAA2mAAANpjAADaZQAA2mcAANppAADaawAA2m0AANpuAADacQAA2n4AANqPAADakQAA2pMAANqVAADalwAA2pkAANqbAADanQAA2p8AANqwAADaswAA2rYAANq5AADavAAA2r8AANrCAADaxQAA2sgAANrKAADbCQAA2wsAANsNAADbDwAA2xIAANsTAADbFAAA2xUAANsWAADbGAAA2xoAANsbAADbHAAA2x4AANsfAADbXgAA22AAANtiAADbZAAA22cAANtoAADbaQAA22oAANtrAADbbQAA228AANtwAADbcQAA23MAANt0AADbswAA27UAANu4AADbugAA270AANu+AADbvwAA28AAANvBAADbwwAA28UAANvGAADbxwAA28kAANvKAADb1wAA29gAANvZAADb2wAA3BoAANwcAADcHgAA3CAAANwjAADcJAAA3CUAANwmAADcJwAA3CkAANwrAADcLAAA3C0AANwvAADcMAAA3G8AANxxAADccwAA3HUAANx4AADceQAA3HoAANx7AADcfAAA3H4AANyAAADcgQAA3IIAANyEAADchQAA3MQAANzGAADcyAAA3MoAANzNAADczgAA3M8AANzQAADc0QAA3NMAANzVAADc1gAA3NcAANzZAADc2gAA3RkAAN0bAADdHQAA3R8AAN0iAADdIwAA3SQAAN0lAADdJgAA3SgAAN0qAADdKwAA3SwAAN0uAADdLwAA3W4AAN1wAADdcgAA3XQAAN13AADdeAAA3XkAAN16AADdewAA3X0AAN1/AADdgAAA3YEAAN2DAADdhAAA3akAAN3NAADd9AAA3hgAAN4bAADeHQAA3h8AAN4hAADeIwAA3iUAAN4mAADeKQAA3jYAAN5FAADeRwAA3kkAAN5LAADeTQAA3k8AAN5RAADeUwAA3mIAAN5lAADeaAAA3msAAN5uAADecQAA3nQAAN53AADeeQAA3rgAAN66AADevQAA3r8AAN7CAADewwAA3sQAAN7FAADexgAA3sgAAN7KAADeywAA3swAAN7OAADezwAA3w4AAN8QAADfEgAA3xQAAN8XAADfGAAA3xkAAN8aAADfGwAA3x0AAN8fAADfIAAA3yEAAN8jAADfJAAA32MAAN9lAADfZwAA32kAAN9sAADfbQAA324AAN9vAADfcAAA33IAAN90AADfdQAA33YAAN94AADfeQAA37gAAN+6AADfvQAA378AAN/CAADfwwAA38QAAN/FAADfxgAA38gAAN/KAADfywAA38wAAN/OAADfzwAA4A4AAOAQAADgEgAA4BQAAOAXAADgGAAA4BkAAOAaAADgGwAA4B0AAOAfAADgIAAA4CEAAOAjAADgJAAA4GMAAOBlAADgZwAA4GkAAOBsAADgbQAA4G4AAOBvAADgcAAA4HIAAOB0AADgdQAA4HYAAOB4AADgeQAA4LgAAOC6AADgvAAA4L4AAODBAADgwgAA4MMAAODEAADgxQAA4McAAODJAADgygAA4MsAAODNAADgzgAA4RkAAOE8AADhXAAA4XwAAOF+AADhgAAA4YIAAOGEAADhhwAA4YgAAOGJAADhjAAA4Y0AAOGPAADhkAAA4ZIAAOGVAADhlgAA4ZcAAOGaAADhmwAA4aAAAOGtAADhsgAA4bQAAOG2AADhuwAA4b4AAOHBAADhwwAA4egAAOIMAADiMwAA4lcAAOJaAADiXAAA4l4AAOJgAADiYgAA4mQAAOJlAADiaAAA4nUAAOKGAADiiAAA4ooAAOKMAADijgAA4pAAAOKSAADilAAA4pYAAOKnAADiqgAA4q0AAOKwAADiswAA4rYAAOK5AADivAAA4r8AAOLBAADjAAAA4wIAAOMEAADjBgAA4wkAAOMKAADjCwAA4wwAAOMNAADjDwAA4xEAAOMSAADjEwAA4xUAAOMWAADjVQAA41cAAONZAADjWwAA414AAONfAADjYAAA42EAAONiAADjZAAA42YAAONnAADjaAAA42oAAONrAADjqgAA46wAAOOvAADjsQAA47QAAOO1AADjtgAA47cAAOO4AADjugAA47wAAOO9AADjvgAA48AAAOPBAADjzgAA488AAOPQAADj0gAA5BEAAOQTAADkFQAA5BcAAOQaAADkGwAA5BwAAOQdAADkHgAA5CAAAOQiAADkIwAA5CQAAOQmAADkJwAA5GYAAORoAADkagAA5GwAAORvAADkcAAA5HEAAORyAADkcwAA5HUAAOR3AADkeAAA5HkAAOR7AADkfAAA5LsAAOS9AADkvwAA5MEAAOTEAADkxQAA5MYAAOTHAADkyAAA5MoAAOTMAADkzQAA5M4AAOTQAADk0QAA5RAAAOUSAADlFAAA5RYAAOUZAADlGgAA5RsAAOUcAADlHQAA5R8AAOUhAADlIgAA5SMAAOUlAADlJgAA5WUAAOVnAADlaQAA5WsAAOVuAADlbwAA5XAAAOVxAADlcgAA5XQAAOV2AADldwAA5XgAAOV6AADlewAA5aAAAOXEAADl6wAA5g8AAOYSAADmFAAA5hYAAOYYAADmGgAA5hwAAOYdAADmIAAA5i0AAOY8AADmPgAA5kAAAOZCAADmRAAA5kYAAOZIAADmSgAA5lkAAOZcAADmXwAA5mIAAOZlAADmaAAA5msAAOZuAADmcAAA5q8AAOaxAADmtAAA5rYAAOa5AADmugAA5rsAAOa8AADmvQAA5r8AAObBAADmwgAA5sMAAObFAADmxgAA5wUAAOcHAADnCQAA5wsAAOcOAADnDwAA5xAAAOcRAADnEgAA5xQAAOcWAADnFwAA5xgAAOcaAADnGwAA51oAAOdcAADnXgAA52AAAOdjAADnZAAA52UAAOdmAADnZwAA52kAAOdrAADnbAAA520AAOdvAADncAAA568AAOexAADntAAA57YAAOe5AADnugAA57sAAOe8AADnvQAA578AAOfBAADnwgAA58MAAOfFAADnxgAA6AUAAOgHAADoCQAA6AsAAOgOAADoDwAA6BAAAOgRAADoEgAA6BQAAOgWAADoFwAA6BgAAOgaAADoGwAA6FoAAOhcAADoXgAA6GAAAOhjAADoZAAA6GUAAOhmAADoZwAA6GkAAOhrAADobAAA6G0AAOhvAADocAAA6K8AAOixAADoswAA6LUAAOi4AADouQAA6LoAAOi7AADovAAA6L4AAOjAAADowQAA6MIAAOjEAADoxQAA6RAAAOkzAADpUwAA6XMAAOl1AADpdwAA6XkAAOl7AADpfgAA6X8AAOmAAADpgwAA6YQAAOmGAADphwAA6YkAAOmMAADpjQAA6Y4AAOmRAADpkgAA6ZcAAOmkAADpqQAA6asAAOmtAADpsgAA6bUAAOm4AADpugAA6d8AAOoDAADqKgAA6k4AAOpRAADqUwAA6lUAAOpXAADqWQAA6lsAAOpcAADqXwAA6mwAAOp9AADqfwAA6oEAAOqDAADqhQAA6ocAAOqJAADqiwAA6o0AAOqeAADqoQAA6qQAAOqnAADqqgAA6q0AAOqwAADqswAA6rYAAOq4AADq9wAA6vkAAOr7AADq/QAA6wAAAOsBAADrAgAA6wMAAOsEAADrBgAA6wgAAOsJAADrCgAA6wwAAOsNAADrTAAA604AAOtQAADrUgAA61UAAOtWAADrVwAA61gAAOtZAADrWwAA610AAOteAADrXwAA62EAAOtiAADroQAA66MAAOumAADrqAAA66sAAOusAADrrQAA664AAOuvAADrsQAA67MAAOu0AADrtQAA67cAAOu4AADrxQAA68YAAOvHAADryQAA7AgAAOwKAADsDAAA7A4AAOwRAADsEgAA7BMAAOwUAADsFQAA7BcAAOwZAADsGgAA7BsAAOwdAADsHgAA7F0AAOxfAADsYQAA7GMAAOxmAADsZwAA7GgAAOxpAADsagAA7GwAAOxuAADsbwAA7HAAAOxyAADscwAA7LIAAOy0AADstgAA7LgAAOy7AADsvAAA7L0AAOy+AADsvwAA7MEAAOzDAADsxAAA7MUAAOzHAADsyAAA7QcAAO0JAADtCwAA7Q0AAO0QAADtEQAA7RIAAO0TAADtFAAA7RYAAO0YAADtGQAA7RoAAO0cAADtHQAA7VwAAO1eAADtYAAA7WIAAO1lAADtZgAA7WcAAO1oAADtaQAA7WsAAO1tAADtbgAA7W8AAO1xAADtcgAA7ZcAAO27AADt4gAA7gYAAO4JAADuCwAA7g0AAO4PAADuEQAA7hMAAO4UAADuFwAA7iQAAO4zAADuNQAA7jcAAO45AADuOwAA7j0AAO4/AADuQQAA7lAAAO5TAADuVgAA7lkAAO5cAADuXwAA7mIAAO5lAADuZwAA7qYAAO6oAADuqwAA7q0AAO6wAADusQAA7rIAAO6zAADutAAA7rYAAO64AADuuQAA7roAAO68AADuvQAA7swAAO8LAADvDQAA7w8AAO8RAADvFAAA7xUAAO8WAADvFwAA7xgAAO8aAADvHAAA7x0AAO8eAADvIAAA7yEAAO9gAADvYgAA72QAAO9mAADvaQAA72oAAO9rAADvbAAA720AAO9vAADvcQAA73IAAO9zAADvdQAA73YAAO+1AADvtwAA77oAAO+8AADvvwAA78AAAO/BAADvwgAA78MAAO/FAADvxwAA78gAAO/JAADvywAA78wAAO/PAADwDgAA8BAAAPASAADwFAAA8BcAAPAYAADwGQAA8BoAAPAbAADwHQAA8B8AAPAgAADwIQAA8CMAAPAkAADwYwAA8GUAAPBnAADwaQAA8GwAAPBtAADwbgAA8G8AAPBwAADwcgAA8HQAAPB1AADwdgAA8HgAAPB5AADwuAAA8LoAAPC8AADwvgAA8MEAAPDCAADwwwAA8MQAAPDFAADwxwAA8MkAAPDKAADwywAA8M0AAPDOAADxGQAA8TwAAPFcAADxfAAA8X4AAPGAAADxggAA8YQAAPGHAADxiAAA8YkAAPGMAADxjQAA8Y8AAPGQAADxkgAA8ZUAAPGWAADxlwAA8ZoAAPGbAADxoAAA8a0AAPGyAADxtAAA8bYAAPG7AADxvgAA8cEAAPHDAADx6AAA8gwAAPIzAADyVwAA8loAAPJcAADyXgAA8mAAAPJiAADyZAAA8mUAAPJoAADydQAA8oYAAPKIAADyigAA8owAAPKOAADykAAA8pIAAPKUAADylgAA8qcAAPKqAADyrQAA8rAAAPKzAADytgAA8rkAAPK8AADyvwAA8sEAAPMAAADzAgAA8wQAAPMGAADzCQAA8woAAPMLAADzDAAA8w0AAPMPAADzEQAA8xIAAPMTAADzFQAA8xYAAPNVAADzVwAA81kAAPNbAADzXgAA818AAPNgAADzYQAA82IAAPNkAADzZgAA82cAAPNoAADzagAA82sAAPOqAADzrAAA868AAPOxAADztAAA87UAAPO2AADztwAA87gAAPO6AADzvAAA870AAPO+AADzwAAA88EAAPPOAADzzwAA89AAAPPSAAD0EQAA9BMAAPQVAAD0FwAA9BoAAPQbAAD0HAAA9B0AAPQeAAD0IAAA9CIAAPQjAAD0JAAA9CYAAPQnAAD0ZgAA9GgAAPRqAAD0bAAA9G8AAPRwAAD0cQAA9HIAAPRzAAD0dQAA9HcAAPR4AAD0eQAA9HsAAPR8AAD0uwAA9L0AAPS/AAD0wQAA9MQAAPTFAAD0xgAA9McAAPTIAAD0ygAA9MwAAPTNAAD0zgAA9NAAAPTRAAD1EAAA9RIAAPUUAAD1FgAA9RkAAPUaAAD1GwAA9RwAAPUdAAD1HwAA9SEAAPUiAAD1IwAA9SUAAPUmAAD1ZQAA9WcAAPVpAAD1awAA9W4AAPVvAAD1cAAA9XEAAPVyAAD1dAAA9XYAAPV3AAD1eAAA9XoAAPV7AAD1oAAA9cQAAPXrAAD2DwAA9hIAAPYUAAD2FgAA9hgAAPYaAAD2HAAA9h0AAPYgAAD2LQAA9jwAAPY+AAD2QAAA9kIAAPZEAAD2RgAA9kgAAPZKAAD2WQAA9lwAAPZfAAD2YgAA9mUAAPZoAAD2awAA9m4AAPZwAAD2rwAA9rEAAPazAAD2tQAA9rgAAPa5AAD2ugAA9rsAAPa8AAD2vgAA9sAAAPbBAAD2wgAA9sQAAPbFAAD3BAAA9wYAAPcIAAD3CgAA9w0AAPcOAAD3DwAA9xAAAPcRAAD3EwAA9xUAAPcWAAD3FwAA9xkAAPcaAAD3WQAA91sAAPddAAD3XwAA92IAAPdjAAD3ZAAA92UAAPdmAAD3aAAA92oAAPdrAAD3bAAA924AAPdvAAD3rgAA97AAAPezAAD3tQAA97gAAPe5AAD3ugAA97sAAPe8AAD3vgAA98AAAPfBAAD3wgAA98QAAPfFAAD4BAAA+AYAAPgIAAD4CgAA+A0AAPgOAAD4DwAA+BAAAPgRAAD4EwAA+BUAAPgWAAD4FwAA+BkAAPgaAAD4WQAA+FsAAPhdAAD4XwAA+GIAAPhjAAD4ZAAA+GUAAPhmAAD4aAAA+GoAAPhrAAD4bAAA+G4AAPhvAAD4rgAA+LAAAPiyAAD4tAAA+LcAAPi4AAD4uQAA+LoAAPi7AAD4vQAA+L8AAPjAAAD4wQAA+MMAAPjEAAD5DwAA+TIAAPlSAAD5cgAA+XQAAPl2AAD5eAAA+XoAAPl9AAD5fgAA+X8AAPmCAAD5gwAA+YUAAPmGAAD5iAAA+YoAAPmLAAD5jAAA+Y8AAPmQAAD5lQAA+aIAAPmnAAD5qQAA+asAAPmwAAD5swAA+bYAAPm4AAD53QAA+gEAAPooAAD6TAAA+k8AAPpRAAD6UwAA+lUAAPpXAAD6WQAA+loAAPpdAAD6agAA+nsAAPp9AAD6fwAA+oEAAPqDAAD6hQAA+ocAAPqJAAD6iwAA+pwAAPqfAAD6ogAA+qUAAPqoAAD6qwAA+q4AAPqxAAD6tAAA+rYAAPr1AAD69wAA+vkAAPr7AAD6/gAA+v8AAPsAAAD7AQAA+wIAAPsEAAD7BgAA+wcAAPsIAAD7CgAA+wsAAPtKAAD7TAAA+04AAPtQAAD7UwAA+1QAAPtVAAD7VgAA+1cAAPtZAAD7WwAA+1wAAPtdAAD7XwAA+2AAAPufAAD7oQAA+6QAAPumAAD7qQAA+6oAAPurAAD7rAAA+60AAPuvAAD7sQAA+7IAAPuzAAD7tQAA+7YAAPvDAAD7xAAA+8UAAPvHAAD8BgAA/AgAAPwKAAD8DAAA/A8AAPwQAAD8EQAA/BIAAPwTAAD8FQAA/BcAAPwYAAD8GQAA/BsAAPwcAAD8WwAA/F0AAPxfAAD8YQAA/GQAAPxlAAD8ZgAA/GcAAPxoAAD8agAA/GwAAPxtAAD8bgAA/HAAAPxxAAD8sAAA/LIAAPy0AAD8tgAA/LkAAPy6AAD8uwAA/LwAAPy9AAD8vwAA/MEAAPzCAAD8wwAA/MUAAPzGAAD9BQAA/QcAAP0JAAD9CwAA/Q4AAP0PAAD9EAAA/REAAP0SAAD9FAAA/RYAAP0XAAD9GAAA/RoAAP0bAAD9WgAA/VwAAP1eAAD9YAAA/WMAAP1kAAD9ZQAA/WYAAP1nAAD9aQAA/WsAAP1sAAD9bQAA/W8AAP1wAAD9lQAA/bkAAP3gAAD+BAAA/gcAAP4JAAD+CwAA/g0AAP4PAAD+EQAA/hIAAP4VAAD+IgAA/jEAAP4zAAD+NQAA/jcAAP45AAD+OwAA/j0AAP4/AAD+TgAA/lEAAP5UAAD+VwAA/loAAP5dAAD+YAAA/mMAAP5lAAD+pAAA/qYAAP6pAAD+qwAA/q4AAP6vAAD+sAAA/rEAAP6yAAD+tAAA/rYAAP63AAD+uAAA/roAAP67AAD++gAA/vwAAP7+AAD/AAAA/wMAAP8EAAD/BQAA/wYAAP8HAAD/CQAA/wsAAP8MAAD/DQAA/w8AAP8QAAD/TwAA/1EAAP9TAAD/VQAA/1gAAP9ZAAD/WgAA/1sAAP9cAAD/XgAA/2AAAP9hAAD/YgAA/2QAAP9lAAD/pAAA/6YAAP+pAAD/qwAA/64AAP+vAAD/sAAA/7EAAP+yAAD/tAAA/7YAAP+3AAD/uAAA/7oAAP+7AAD/+gAA//wAAP/+AAEAAAABAAMAAQAEAAEABQABAAYAAQAHAAEACQABAAsAAQAMAAEADQABAA8AAQAQAAEATwABAFEAAQBTAAEAVQABAFgAAQBZAAEAWgABAFsAAQBcAAEAXgABAGAAAQBhAAEAYgABAGQAAQBlAAEApAABAKYAAQCoAAEAqgABAK0AAQCuAAEArwABALAAAQCxAAEAswABALUAAQC2AAEAtwABALkAAQC6AAEBBQABASgAAQFIAAEBaAABAWoAAQFsAAEBbgABAXAAAQFzAAEBdAABAXUAAQF4AAEBeQABAXsAAQF8AAEBfgABAYEAAQGCAAEBgwABAYYAAQGHAAEBkAABAZ0AAQGiAAEBpAABAaYAAQGrAAEBrgABAbEAAQGzAAEB2AABAfwAAQIjAAECRwABAkoAAQJMAAECTgABAlAAAQJSAAECVAABAlUAAQJYAAECZQABAnYAAQJ4AAECegABAnwAAQJ+AAECgAABAoIAAQKEAAEChgABApcAAQKaAAECnQABAqAAAQKjAAECpgABAqkAAQKsAAECrwABArEAAQLwAAEC8gABAvQAAQL2AAEC+QABAvoAAQL7AAEC/AABAv0AAQL/AAEDAQABAwIAAQMDAAEDBQABAwYAAQNFAAEDRwABA0kAAQNLAAEDTgABA08AAQNQAAEDUQABA1IAAQNUAAEDVgABA1cAAQNYAAEDWgABA1sAAQOaAAEDnAABA58AAQOhAAEDpAABA6UAAQOmAAEDpwABA6gAAQOqAAEDrAABA60AAQOuAAEDsAABA7EAAQO+AAEDvwABA8AAAQPCAAEEAQABBAMAAQQFAAEEBwABBAoAAQQLAAEEDAABBA0AAQQOAAEEEAABBBIAAQQTAAEEFAABBBYAAQQXAAEEVgABBFgAAQRaAAEEXAABBF8AAQRgAAEEYQABBGIAAQRjAAEEZQABBGcAAQRoAAEEaQABBGsAAQRsAAEEqwABBK0AAQSvAAEEsQABBLQAAQS1AAEEtgABBLcAAQS4AAEEugABBLwAAQS9AAEEvgABBMAAAQTBAAEFAAABBQIAAQUEAAEFBgABBQkAAQUKAAEFCwABBQwAAQUNAAEFDwABBREAAQUSAAEFEwABBRUAAQUWAAEFVQABBVcAAQVZAAEFWwABBV4AAQVfAAEFYAABBWEAAQViAAEFZAABBWYAAQVnAAEFaAABBWoAAQVrAAEFkAABBbQAAQXbAAEF/wABBgIAAQYEAAEGBgABBggAAQYKAAEGDAABBg0AAQYQAAEGHQABBiwAAQYuAAEGMAABBjIAAQY0AAEGNgABBjgAAQY6AAEGSQABBkwAAQZPAAEGUgABBlUAAQZYAAEGWwABBl4AAQZgAAEGnwABBqEAAQakAAEGpgABBqkAAQaqAAEGqwABBqwAAQatAAEGrwABBrEAAQayAAEGswABBrUAAQa2AAEG9QABBvcAAQb5AAEG+wABBv4AAQb/AAEHAAABBwEAAQcCAAEHBAABBwYAAQcHAAEHCAABBwoAAQcLAAEHSgABB0wAAQdOAAEHUAABB1MAAQdUAAEHVQABB1YAAQdXAAEHWQABB1sAAQdcAAEHXQABB18AAQdgAAEHnwABB6EAAQekAAEHpgABB6kAAQeqAAEHqwABB6wAAQetAAEHrwABB7EAAQeyAAEHswABB7UAAQe2AAEH9QABB/cAAQf5AAEH+wABB/4AAQf/AAEIAAABCAEAAQgCAAEIBAABCAYAAQgHAAEICAABCAoAAQgLAAEISgABCEwAAQhOAAEIUAABCFMAAQhUAAEIVQABCFYAAQhXAAEIWQABCFsAAQhcAAEIXQABCF8AAQhgAAEInwABCKEAAQijAAEIpQABCKgAAQipAAEIqgABCKsAAQisAAEIrgABCLAAAQixAAEIsgABCLQAAQi1AAEIvgABCL8AAQjBAAEJBAABCSgAAQlMAAEJbwABCZYAAQm2AAEJ3QABCgQAAQokAAEKSAABCmwAAQpuAAEKcQABCnMAAQp1AAEKdwABCnoAAQp9AAEKfwABCoEAAQqEAAEKhgABCogAAQqLAAEKjgABCo8AAQqUAAEKoQABCqQAAQqmAAEKqQABCqwAAQquAAEK0wABCvcAAQseAAELQgABC0UAAQtHAAELSQABC0sAAQtNAAELTwABC1AAAQtTAAELYAABC3MAAQt1AAELdwABC3kAAQt7AAELfQABC38AAQuBAAELgwABC4UAAQuYAAELmwABC54AAQuhAAELpAABC6cAAQuqAAELrQABC7AAAQuzAAELtQABC/QAAQv2AAEL+QABC/sAAQv+AAEL/wABDAAAAQwBAAEMAgABDAQAAQwGAAEMBwABDAgAAQwKAAEMCwABDBQAAQwVAAEMFwABDFYAAQxYAAEMWgABDFwAAQxfAAEMYAABDGEAAQxiAAEMYwABDGUAAQxnAAEMaAABDGkAAQxrAAEMbAABDKsAAQytAAEMsAABDLIAAQy1AAEMtgABDLcAAQy4AAEMuQABDLsAAQy9AAEMvgABDL8AAQzBAAEMwgABDMsAAQzMAAEMzgABDQ0AAQ0PAAENEQABDRMAAQ0WAAENFwABDRgAAQ0ZAAENGgABDRwAAQ0eAAENHwABDSAAAQ0iAAENIwABDWIAAQ1kAAENZwABDWkAAQ1sAAENbQABDW4AAQ1vAAENcAABDXIAAQ10AAENdQABDXYAAQ14AAENeQABDYIAAQ2DAAENhQABDcQAAQ3GAAENyAABDcoAAQ3NAAENzgABDc8AAQ3QAAEN0QABDdMAAQ3VAAEN1gABDdcAAQ3ZAAEN2gABDhkAAQ4bAAEOHgABDiAAAQ4jAAEOJAABDiUAAQ4mAAEOJwABDikAAQ4rAAEOLAABDi0AAQ4vAAEOMAABDj0AAQ4+AAEOPwABDkEAAQ6AAAEOggABDoQAAQ6GAAEOiQABDooAAQ6LAAEOjAABDo0AAQ6PAAEOkQABDpIAAQ6TAAEOlQABDpYAAQ7VAAEO1wABDtoAAQ7cAAEO3wABDuAAAQ7hAAEO4gABDuMAAQ7lAAEO5wABDugAAQ7pAAEO6wABDuwAAQ73AAEPBAABDx8AAQ8iAAEPJQABDygAAQ8rAAEPLgABDzEAAQ8zAAEPNgABDzkAAQ88AAEPPwABD0IAAQ9FAAEPYAABD2MAAQ9mAAEPaQABD2wAAQ9vAAEPcgABD3UAAQ94AAEPewABD34AAQ+BAAEPhAABD4cAAQ+JAAEPlAABD6EAAQ+nAAEPsgABD70AARAIAAEQKwABEEsAARBrAAEQbQABEG8AARBxAAEQcwABEHYAARB3AAEQeAABEHsAARB8AAEQfgABEH8AARCBAAEQhAABEIUAARCGAAEQiQABEIoAARCPAAEQnAABEKEAARCjAAEQpQABEKoAARCtAAEQsAABELIAARDXAAEQ+wABESIAARFGAAERSQABEUsAARFNAAERTwABEVEAARFTAAERVAABEVcAARFkAAERdQABEXcAARF5AAERewABEX0AARF/AAERgQABEYMAARGFAAERlgABEZkAARGcAAERnwABEaIAARGlAAERqAABEasAARGuAAERsAABEe8AARHxAAER8wABEfUAARH4AAER+QABEfoAARH7AAER/AABEf4AARIAAAESAQABEgIAARIEAAESBQABEkQAARJGAAESSAABEkoAARJNAAESTgABEk8AARJQAAESUQABElMAARJVAAESVgABElcAARJZAAESWgABEpkAARKbAAESngABEqAAARKjAAESpAABEqUAARKmAAESpwABEqkAARKrAAESrAABEq0AARKvAAESsAABEr0AARK+AAESvwABEsEAARMAAAETAgABEwQAARMGAAETCQABEwoAARMLAAETDAABEw0AARMPAAETEQABExIAARMTAAETFQABExYAARNVAAETVwABE1kAARNbAAETXgABE18AARNgAAETYQABE2IAARNkAAETZgABE2cAARNoAAETagABE2sAAROqAAETrAABE64AAROwAAETswABE7QAARO1AAETtgABE7cAARO5AAETuwABE7wAARO9AAETvwABE8AAARP/AAEUAQABFAMAARQFAAEUCAABFAkAARQKAAEUCwABFAwAARQOAAEUEAABFBEAARQSAAEUFAABFBUAARRUAAEUVgABFFgAARRaAAEUXQABFF4AARRfAAEUYAABFGEAARRjAAEUZQABFGYAARRnAAEUaQABFGoAARSPAAEUswABFNoAART+AAEVAQABFQMAARUFAAEVBwABFQkAARULAAEVDAABFQ8AARUcAAEVKwABFS0AARUvAAEVMQABFTMAARU1AAEVNwABFTkAARVIAAEVSwABFU4AARVRAAEVVAABFVcAARVaAAEVXQABFV8AARWeAAEVoAABFaMAARWlAAEVqAABFakAARWqAAEVqwABFawAARWuAAEVsAABFbEAARWyAAEVtAABFbUAARX0AAEV9gABFfgAARX6AAEV/QABFf4AARX/AAEWAAABFgEAARYDAAEWBQABFgYAARYHAAEWCQABFgoAARZJAAEWSwABFk0AARZPAAEWUgABFlMAARZUAAEWVQABFlYAARZYAAEWWgABFlsAARZcAAEWXgABFl8AARaeAAEWoAABFqMAARalAAEWqAABFqkAARaqAAEWqwABFqwAARauAAEWsAABFrEAARayAAEWtAABFrUAARb0AAEW9gABFvgAARb6AAEW/QABFv4AARb/AAEXAAABFwEAARcDAAEXBQABFwYAARcHAAEXCQABFwoAARdJAAEXSwABF00AARdPAAEXUgABF1MAARdUAAEXVQABF1YAARdYAAEXWgABF1sAARdcAAEXXgABF18AAReeAAEXoAABF6IAARekAAEXpwABF6gAARepAAEXqgABF6sAARetAAEXrwABF7AAARexAAEXswABF7QAARf/AAEYIgABGEIAARhiAAEYZAABGGYAARhoAAEYagABGG0AARhuAAEYbwABGHIAARhzAAEYdQABGHYAARh4AAEYewABGHwAARh9AAEYgAABGIEAARiKAAEYlwABGJwAARieAAEYoAABGKUAARioAAEYqwABGK0AARjSAAEY9gABGR0AARlBAAEZRAABGUYAARlIAAEZSgABGUwAARlOAAEZTwABGVIAARlfAAEZcAABGXIAARl0AAEZdgABGXgAARl6AAEZfAABGX4AARmAAAEZkQABGZQAARmXAAEZmgABGZ0AARmgAAEZowABGaYAARmpAAEZqwABGeoAARnsAAEZ7gABGfAAARnzAAEZ9AABGfUAARn2AAEZ9wABGfkAARn7AAEZ/AABGf0AARn/AAEaAAABGj8AARpBAAEaQwABGkUAARpIAAEaSQABGkoAARpLAAEaTAABGk4AARpQAAEaUQABGlIAARpUAAEaVQABGpQAARqWAAEamQABGpsAARqeAAEanwABGqAAARqhAAEaogABGqQAARqmAAEapwABGqgAARqqAAEaqwABGrgAARq5AAEaugABGrwAARr7AAEa/QABGv8AARsBAAEbBAABGwUAARsGAAEbBwABGwgAARsKAAEbDAABGw0AARsOAAEbEAABGxEAARtQAAEbUgABG1QAARtWAAEbWQABG1oAARtbAAEbXAABG10AARtfAAEbYQABG2IAARtjAAEbZQABG2YAARulAAEbpwABG6kAARurAAEbrgABG68AARuwAAEbsQABG7IAARu0AAEbtgABG7cAARu4AAEbugABG7sAARv6AAEb/AABG/4AARwAAAEcAwABHAQAARwFAAEcBgABHAcAARwJAAEcCwABHAwAARwNAAEcDwABHBAAARxPAAEcUQABHFMAARxVAAEcWAABHFkAARxaAAEcWwABHFwAARxeAAEcYAABHGEAARxiAAEcZAABHGUAARyKAAEcrgABHNUAARz5AAEc/AABHP4AAR0AAAEdAgABHQQAAR0GAAEdBwABHQoAAR0XAAEdJgABHSgAAR0qAAEdLAABHS4AAR0wAAEdMgABHTQAAR1DAAEdRgABHUkAAR1MAAEdTwABHVIAAR1VAAEdWAABHVoAAR2ZAAEdmwABHZ4AAR2gAAEdowABHaQAAR2lAAEdpgABHacAAR2pAAEdqwABHawAAR2tAAEdrwABHbAAAR3vAAEd8QABHfMAAR31AAEd+AABHfkAAR36AAEd+wABHfwAAR3+AAEeAAABHgEAAR4CAAEeBAABHgUAAR5EAAEeRgABHkgAAR5KAAEeTQABHk4AAR5PAAEeUAABHlEAAR5TAAEeVQABHlYAAR5XAAEeWQABHloAAR6ZAAEemwABHp4AAR6gAAEeowABHqQAAR6lAAEepgABHqcAAR6pAAEeqwABHqwAAR6tAAEerwABHrAAAR7vAAEe8QABHvMAAR71AAEe+AABHvkAAR76AAEe+wABHvwAAR7+AAEfAAABHwEAAR8CAAEfBAABHwUAAR9EAAEfRgABH0gAAR9KAAEfTQABH04AAR9PAAEfUAABH1EAAR9TAAEfVQABH1YAAR9XAAEfWQABH1oAAR+ZAAEfmwABH50AAR+fAAEfogABH6MAAR+kAAEfpQABH6YAAR+oAAEfqgABH6sAAR+sAAEfrgABH68AAR/6AAEgHQABID0AASBdAAEgXwABIGEAASBjAAEgZQABIGgAASBpAAEgagABIG0AASBuAAEgcAABIHEAASBzAAEgdgABIHcAASB4AAEgewABIHwAASCBAAEgjgABIJMAASCVAAEglwABIJwAASCfAAEgogABIKQAASDJAAEg7QABIRQAASE4AAEhOwABIT0AASE/AAEhQQABIUMAASFFAAEhRgABIUkAASFWAAEhZwABIWkAASFrAAEhbQABIW8AASFxAAEhcwABIXUAASF3AAEhiAABIYsAASGOAAEhkQABIZQAASGXAAEhmgABIZ0AASGgAAEhogABIeEAASHjAAEh5QABIecAASHqAAEh6wABIewAASHtAAEh7gABIfAAASHyAAEh8wABIfQAASH2AAEh9wABIjYAASI4AAEiOgABIjwAASI/AAEiQAABIkEAASJCAAEiQwABIkUAASJHAAEiSAABIkkAASJLAAEiTAABIosAASKNAAEikAABIpIAASKVAAEilgABIpcAASKYAAEimQABIpsAASKdAAEingABIp8AASKhAAEiogABIq8AASKwAAEisQABIrMAASLyAAEi9AABIvYAASL4AAEi+wABIvwAASL9AAEi/gABIv8AASMBAAEjAwABIwQAASMFAAEjBwABIwgAASNHAAEjSQABI0sAASNNAAEjUAABI1EAASNSAAEjUwABI1QAASNWAAEjWAABI1kAASNaAAEjXAABI10AASOcAAEjngABI6AAASOiAAEjpQABI6YAASOnAAEjqAABI6kAASOrAAEjrQABI64AASOvAAEjsQABI7IAASPxAAEj8wABI/UAASP3AAEj+gABI/sAASP8AAEj/QABI/4AASQAAAEkAgABJAMAASQEAAEkBgABJAcAASRGAAEkSAABJEoAASRMAAEkTwABJFAAASRRAAEkUgABJFMAASRVAAEkVwABJFgAASRZAAEkWwABJFwAASSBAAEkpQABJMwAASTwAAEk8wABJPUAAST3AAEk+QABJPsAAST9AAEk/gABJQEAASUOAAElHQABJR8AASUhAAElIwABJSUAASUnAAElKQABJSsAASU6AAElPQABJUAAASVDAAElRgABJUkAASVMAAElTwABJVEAASWQAAElkgABJZUAASWXAAElmgABJZsAASWcAAElnQABJZ4AASWgAAElogABJaMAASWkAAElpgABJacAASW2AAEl9QABJfcAASX5AAEl+wABJf4AASX/AAEmAAABJgEAASYCAAEmBAABJgYAASYHAAEmCAABJgoAASYLAAEmSgABJkwAASZOAAEmUAABJlMAASZUAAEmVQABJlYAASZXAAEmWQABJlsAASZcAAEmXQABJl8AASZgAAEmnwABJqEAASakAAEmpgABJqkAASaqAAEmqwABJqwAASatAAEmrwABJrEAASayAAEmswABJrUAASa2AAEm9QABJvcAASb5AAEm+wABJv4AASb/AAEnAAABJwEAAScCAAEnBAABJwYAAScHAAEnCAABJwoAAScLAAEnSgABJ0wAASdOAAEnUAABJ1MAASdUAAEnVQABJ1YAASdXAAEnWQABJ1sAASdcAAEnXQABJ18AASdgAAEnnwABJ6EAASejAAEnpQABJ6gAASepAAEnqgABJ6sAASesAAEnrgABJ7AAASexAAEnsgABJ7QAASe1AAEoAAABKCMAAShDAAEoYwABKGUAAShnAAEoaQABKGsAAShuAAEobwABKHAAAShzAAEodAABKHYAASh3AAEoeQABKHwAASh9AAEofgABKIEAASiCAAEohwABKJQAASiZAAEomwABKJ0AASiiAAEopQABKKgAASiqAAEozwABKPMAASkaAAEpPgABKUEAASlDAAEpRQABKUcAASlJAAEpSwABKUwAASlPAAEpXAABKW0AASlvAAEpcQABKXMAASl1AAEpdwABKXkAASl7AAEpfQABKY4AASmRAAEplAABKZcAASmaAAEpnQABKaAAASmjAAEppgABKagAASnnAAEp6QABKesAASntAAEp8AABKfEAASnyAAEp8wABKfQAASn2AAEp+AABKfkAASn6AAEp/AABKf0AASo8AAEqPgABKkAAASpCAAEqRQABKkYAASpHAAEqSAABKkkAASpLAAEqTQABKk4AASpPAAEqUQABKlIAASqRAAEqkwABKpYAASqYAAEqmwABKpwAASqdAAEqngABKp8AASqhAAEqowABKqQAASqlAAEqpwABKqgAASq1AAEqtgABKrcAASq5AAEq+AABKvoAASr8AAEq/gABKwEAASsCAAErAwABKwQAASsFAAErBwABKwkAASsKAAErCwABKw0AASsOAAErTQABK08AAStRAAErUwABK1YAAStXAAErWAABK1kAAStaAAErXAABK14AAStfAAErYAABK2IAAStjAAErogABK6QAASumAAErqAABK6sAASusAAErrQABK64AASuvAAErsQABK7MAASu0AAErtQABK7cAASu4AAEr9wABK/kAASv7AAEr/QABLAAAASwBAAEsAgABLAMAASwEAAEsBgABLAgAASwJAAEsCgABLAwAASwNAAEsTAABLE4AASxQAAEsUgABLFUAASxWAAEsVwABLFgAASxZAAEsWwABLF0AASxeAAEsXwABLGEAASxiAAEshwABLKsAASzSAAEs9gABLPkAASz7AAEs/QABLP8AAS0BAAEtAwABLQQAAS0HAAEtFAABLSMAAS0lAAEtJwABLSkAAS0rAAEtLQABLS8AAS0xAAEtQAABLUMAAS1GAAEtSQABLUwAAS1PAAEtUgABLVUAAS1XAAEtlgABLZgAAS2aAAEtnAABLZ8AAS2gAAEtoQABLaIAAS2jAAEtpQABLacAAS2oAAEtqQABLasAAS2sAAEt6wABLe0AAS3vAAEt8QABLfQAAS31AAEt9gABLfcAAS34AAEt+gABLfwAAS39AAEt/gABLgAAAS4BAAEuQAABLkIAAS5EAAEuRgABLkkAAS5KAAEuSwABLkwAAS5NAAEuTwABLlEAAS5SAAEuUwABLlUAAS5WAAEulQABLpcAAS6aAAEunAABLp8AAS6gAAEuoQABLqIAAS6jAAEupQABLqcAAS6oAAEuqQABLqsAAS6sAAEu6wABLu0AAS7vAAEu8QABLvQAAS71AAEu9gABLvcAAS74AAEu+gABLvwAAS79AAEu/gABLwAAAS8BAAEvQAABL0IAAS9EAAEvRgABL0kAAS9KAAEvSwABL0wAAS9NAAEvTwABL1EAAS9SAAEvUwABL1UAAS9WAAEvlQABL5cAAS+ZAAEvmwABL54AAS+fAAEvoAABL6EAAS+iAAEvpAABL6YAAS+nAAEvqAABL6oAAS+rAAEv9gABMBkAATA5AAEwWQABMFsAATBdAAEwXwABMGEAATBkAAEwZQABMGYAATBpAAEwagABMGwAATBtAAEwbwABMHIAATBzAAEwdAABMHcAATB4AAEwfQABMIoAATCPAAEwkQABMJMAATCYAAEwmwABMJ4AATCgAAEwxQABMOkAATEQAAExNAABMTcAATE5AAExOwABMT0AATE/AAExQQABMUIAATFFAAExUgABMWMAATFlAAExZwABMWkAATFrAAExbQABMW8AATFxAAExcwABMYQAATGHAAExigABMY0AATGQAAExkwABMZYAATGZAAExnAABMZ4AATHdAAEx3wABMeEAATHjAAEx5gABMecAATHoAAEx6QABMeoAATHsAAEx7gABMe8AATHwAAEx8gABMfMAATIyAAEyNAABMjYAATI4AAEyOwABMjwAATI9AAEyPgABMj8AATJBAAEyQwABMkQAATJFAAEyRwABMkgAATKHAAEyiQABMowAATKOAAEykQABMpIAATKTAAEylAABMpUAATKXAAEymQABMpoAATKbAAEynQABMp4AATKrAAEyrAABMq0AATKvAAEy7gABMvAAATLyAAEy9AABMvcAATL4AAEy+QABMvoAATL7AAEy/QABMv8AATMAAAEzAQABMwMAATMEAAEzQwABM0UAATNHAAEzSQABM0wAATNNAAEzTgABM08AATNQAAEzUgABM1QAATNVAAEzVgABM1gAATNZAAEzmAABM5oAATOcAAEzngABM6EAATOiAAEzowABM6QAATOlAAEzpwABM6kAATOqAAEzqwABM60AATOuAAEz7QABM+8AATPxAAEz8wABM/YAATP3AAEz+AABM/kAATP6AAEz/AABM/4AATP/AAE0AAABNAIAATQDAAE0QgABNEQAATRGAAE0SAABNEsAATRMAAE0TQABNE4AATRPAAE0UQABNFMAATRUAAE0VQABNFcAATRYAAE0fQABNKEAATTIAAE07AABNO8AATTxAAE08wABNPUAATT3AAE0+QABNPoAATT9AAE1CgABNRkAATUbAAE1HQABNR8AATUhAAE1IwABNSUAATUnAAE1NgABNTkAATU8AAE1PwABNUIAATVFAAE1SAABNUsAATVNAAE1jAABNY4AATWQAAE1kgABNZUAATWWAAE1lwABNZgAATWZAAE1mwABNZ0AATWeAAE1nwABNaEAATWiAAE14QABNeMAATXlAAE15wABNeoAATXrAAE17AABNe0AATXuAAE18AABNfIAATXzAAE19AABNfYAATX3AAE2NgABNjgAATY6AAE2PAABNj8AATZAAAE2QQABNkIAATZDAAE2RQABNkcAATZIAAE2SQABNksAATZMAAE2iwABNo0AATaQAAE2kgABNpUAATaWAAE2lwABNpgAATaZAAE2mwABNp0AATaeAAE2nwABNqEAATaiAAE24QABNuMAATblAAE25wABNuoAATbrAAE27AABNu0AATbuAAE28AABNvIAATbzAAE29AABNvYAATb3AAE3NgABNzgAATc6AAE3PAABNz8AATdAAAE3QQABN0IAATdDAAE3RQABN0cAATdIAAE3SQABN0sAATdMAAE3iwABN40AATePAAE3kQABN5QAATeVAAE3lgABN5cAATeYAAE3mgABN5wAATedAAE3ngABN6AAATehAAE37AABOA8AATgvAAE4TwABOFEAAThTAAE4VQABOFcAAThaAAE4WwABOFwAAThfAAE4YAABOGIAAThjAAE4ZQABOGgAAThpAAE4agABOG0AAThuAAE4dwABOIQAATiJAAE4iwABOI0AATiSAAE4lQABOJgAATiaAAE4vwABOOMAATkKAAE5LgABOTEAATkzAAE5NQABOTcAATk5AAE5OwABOTwAATk/AAE5TAABOV0AATlfAAE5YQABOWMAATllAAE5ZwABOWkAATlrAAE5bQABOX4AATmBAAE5hAABOYcAATmKAAE5jQABOZAAATmTAAE5lgABOZgAATnXAAE52QABOdsAATndAAE54AABOeEAATniAAE54wABOeQAATnmAAE56AABOekAATnqAAE57AABOe0AATosAAE6LgABOjAAAToyAAE6NQABOjYAATo3AAE6OAABOjkAATo7AAE6PQABOj4AATo/AAE6QQABOkIAATqBAAE6gwABOoYAATqIAAE6iwABOowAATqNAAE6jgABOo8AATqRAAE6kwABOpQAATqVAAE6lwABOpgAATqlAAE6pgABOqcAATqpAAE66AABOuoAATrsAAE67gABOvEAATryAAE68wABOvQAATr1AAE69wABOvkAATr6AAE6+wABOv0AATr+AAE7PQABOz8AATtBAAE7QwABO0YAATtHAAE7SAABO0kAATtKAAE7TAABO04AATtPAAE7UAABO1IAATtTAAE7kgABO5QAATuWAAE7mAABO5sAATucAAE7nQABO54AATufAAE7oQABO6MAATukAAE7pQABO6cAATuoAAE75wABO+kAATvrAAE77QABO/AAATvxAAE78gABO/MAATv0AAE79gABO/gAATv5AAE7+gABO/wAATv9AAE8PAABPD4AATxAAAE8QgABPEUAATxGAAE8RwABPEgAATxJAAE8SwABPE0AATxOAAE8TwABPFEAATxSAAE8dwABPJsAATzCAAE85gABPOkAATzrAAE87QABPO8AATzxAAE88wABPPQAATz3AAE9BAABPRMAAT0VAAE9FwABPRkAAT0bAAE9HQABPR8AAT0hAAE9MAABPTMAAT02AAE9OQABPTwAAT0/AAE9QgABPUUAAT1HAAE9hgABPYgAAT2LAAE9jQABPZAAAT2RAAE9kgABPZMAAT2UAAE9lgABPZgAAT2ZAAE9mgABPZwAAT2dAAE93AABPd4AAT3gAAE94gABPeUAAT3mAAE95wABPegAAT3pAAE96wABPe0AAT3uAAE97wABPfEAAT3yAAE+MQABPjMAAT41AAE+NwABPjoAAT47AAE+PAABPj0AAT4+AAE+QAABPkIAAT5DAAE+RAABPkYAAT5HAAE+hgABPogAAT6LAAE+jQABPpAAAT6RAAE+kgABPpMAAT6UAAE+lgABPpgAAT6ZAAE+mgABPpwAAT6dAAE+3AABPt4AAT7gAAE+4gABPuUAAT7mAAE+5wABPugAAT7pAAE+6wABPu0AAT7uAAE+7wABPvEAAT7yAAE/MQABPzMAAT81AAE/NwABPzoAAT87AAE/PAABPz0AAT8+AAE/QAABP0IAAT9DAAE/RAABP0YAAT9HAAE/hgABP4gAAT+KAAE/jAABP48AAT+QAAE/kQABP5IAAT+TAAE/lQABP5cAAT+YAAE/mQABP5sAAT+cAAE/5wABQAoAAUAqAAFASgABQEwAAUBOAAFAUAABQFIAAUBVAAFAVgABQFcAAUBaAAFAWwABQF0AAUBeAAFAYAABQGIAAUBjAAFAZAABQGcAAUBoAAFAbQABQHoAAUB/AAFAgQABQIMAAUCIAAFAiwABQI4AAUCQAAFAtQABQNkAAUEAAAFBJAABQScAAUEpAAFBKwABQS0AAUEvAAFBMQABQTIAAUE1AAFBQgABQVMAAUFVAAFBVwABQVkAAUFbAAFBXQABQV8AAUFhAAFBYwABQXQAAUF3AAFBegABQX0AAUGAAAFBgwABQYYAAUGJAAFBjAABQY4AAUHNAAFBzwABQdEAAUHTAAFB1gABQdcAAUHYAAFB2QABQdoAAUHcAAFB3gABQd8AAUHgAAFB4gABQeMAAUIiAAFCJAABQiYAAUIoAAFCKwABQiwAAUItAAFCLgABQi8AAUIxAAFCMwABQjQAAUI1AAFCNwABQjgAAUJ3AAFCeQABQnwAAUJ+AAFCgQABQoIAAUKDAAFChAABQoUAAUKHAAFCiQABQooAAUKLAAFCjQABQo4AAUKbAAFCnAABQp0AAUKfAAFC3gABQuAAAULiAAFC5AABQucAAULoAAFC6QABQuoAAULrAAFC7QABQu8AAULwAAFC8QABQvMAAUL0AAFDMwABQzUAAUM3AAFDOQABQzwAAUM9AAFDPgABQz8AAUNAAAFDQgABQ0QAAUNFAAFDRgABQ0gAAUNJAAFDiAABQ4oAAUOMAAFDjgABQ5EAAUOSAAFDkwABQ5QAAUOVAAFDlwABQ5kAAUOaAAFDmwABQ50AAUOeAAFD3QABQ98AAUPhAAFD4wABQ+YAAUPnAAFD6AABQ+kAAUPqAAFD7AABQ+4AAUPvAAFD8AABQ/IAAUPzAAFEMgABRDQAAUQ2AAFEOAABRDsAAUQ8AAFEPQABRD4AAUQ/AAFEQQABREMAAUREAAFERQABREcAAURIAAFEbQABRJEAAUS4AAFE3AABRN8AAUThAAFE4wABROUAAUTnAAFE6QABROoAAUTtAAFE+gABRQkAAUULAAFFDQABRQ8AAUURAAFFEwABRRUAAUUXAAFFJgABRSkAAUUsAAFFLwABRTIAAUU1AAFFOAABRTsAAUU9AAFFfAABRX4AAUWBAAFFgwABRYYAAUWHAAFFiAABRYkAAUWKAAFFjAABRY4AAUWPAAFFkAABRZIAAUWTAAFF0gABRdQAAUXWAAFF2AABRdsAAUXcAAFF3QABRd4AAUXfAAFF4QABReMAAUXkAAFF5QABRecAAUXoAAFGJwABRikAAUYrAAFGLQABRjAAAUYxAAFGMgABRjMAAUY0AAFGNgABRjgAAUY5AAFGOgABRjwAAUY9AAFGfAABRn4AAUaBAAFGgwABRoYAAUaHAAFGiAABRokAAUaKAAFGjAABRo4AAUaPAAFGkAABRpIAAUaTAAFG0gABRtQAAUbWAAFG2AABRtsAAUbcAAFG3QABRt4AAUbfAAFG4QABRuMAAUbkAAFG5QABRucAAUboAAFHJwABRykAAUcrAAFHLQABRzAAAUcxAAFHMgABRzMAAUc0AAFHNgABRzgAAUc5AAFHOgABRzwAAUc9AAFHfAABR34AAUeAAAFHggABR4UAAUeGAAFHhwABR4gAAUeJAAFHiwABR40AAUeOAAFHjwABR5EAAUeSAAFH3QABSAAAAUggAAFIQAABSEIAAUhEAAFIRgABSEgAAUhLAAFITAABSE0AAUhQAAFIUQABSFMAAUhUAAFIVgABSFkAAUhaAAFIWwABSF4AAUhfAAFIZAABSHEAAUh2AAFIeAABSHoAAUh/AAFIggABSIUAAUiHAAFIrAABSNAAAUj3AAFJGwABSR4AAUkgAAFJIgABSSQAAUkmAAFJKAABSSkAAUksAAFJOQABSUoAAUlMAAFJTgABSVAAAUlSAAFJVAABSVYAAUlYAAFJWgABSWsAAUluAAFJcQABSXQAAUl3AAFJegABSX0AAUmAAAFJgwABSYUAAUnEAAFJxgABScgAAUnKAAFJzQABSc4AAUnPAAFJ0AABSdEAAUnTAAFJ1QABSdYAAUnXAAFJ2QABSdoAAUoZAAFKGwABSh0AAUofAAFKIgABSiMAAUokAAFKJQABSiYAAUooAAFKKgABSisAAUosAAFKLgABSi8AAUpuAAFKcAABSnMAAUp1AAFKeAABSnkAAUp6AAFKewABSnwAAUp+AAFKgAABSoEAAUqCAAFKhAABSoUAAUqSAAFKkwABSpQAAUqWAAFK1QABStcAAUrZAAFK2wABSt4AAUrfAAFK4AABSuEAAUriAAFK5AABSuYAAUrnAAFK6AABSuoAAUrrAAFLKgABSywAAUsuAAFLMAABSzMAAUs0AAFLNQABSzYAAUs3AAFLOQABSzsAAUs8AAFLPQABSz8AAUtAAAFLfwABS4EAAUuDAAFLhQABS4gAAUuJAAFLigABS4sAAUuMAAFLjgABS5AAAUuRAAFLkgABS5QAAUuVAAFL1AABS9YAAUvYAAFL2gABS90AAUveAAFL3wABS+AAAUvhAAFL4wABS+UAAUvmAAFL5wABS+kAAUvqAAFMKQABTCsAAUwtAAFMLwABTDIAAUwzAAFMNAABTDUAAUw2AAFMOAABTDoAAUw7AAFMPAABTD4AAUw/AAFMZAABTIgAAUyvAAFM0wABTNYAAUzYAAFM2gABTNwAAUzeAAFM4AABTOEAAUzkAAFM8QABTQAAAU0CAAFNBAABTQYAAU0IAAFNCgABTQwAAU0OAAFNHQABTSAAAU0jAAFNJgABTSkAAU0sAAFNLwABTTIAAU00AAFNcwABTXUAAU14AAFNegABTX0AAU1+AAFNfwABTYAAAU2BAAFNgwABTYUAAU2GAAFNhwABTYkAAU2KAAFNyQABTcsAAU3NAAFNzwABTdIAAU3TAAFN1AABTdUAAU3WAAFN2AABTdoAAU3bAAFN3AABTd4AAU3fAAFOHgABTiAAAU4iAAFOJAABTicAAU4oAAFOKQABTioAAU4rAAFOLQABTi8AAU4wAAFOMQABTjMAAU40AAFOcwABTnUAAU54AAFOegABTn0AAU5+AAFOfwABToAAAU6BAAFOgwABToUAAU6GAAFOhwABTokAAU6KAAFOyQABTssAAU7NAAFOzwABTtIAAU7TAAFO1AABTtUAAU7WAAFO2AABTtoAAU7bAAFO3AABTt4AAU7fAAFPHgABTyAAAU8iAAFPJAABTycAAU8oAAFPKQABTyoAAU8rAAFPLQABTy8AAU8wAAFPMQABTzMAAU80AAFPcwABT3UAAU93AAFPeQABT3wAAU99AAFPfgABT38AAU+AAAFPggABT4QAAU+FAAFPhgABT4gAAU+JAAFP1AABT/cAAVAXAAFQNwABUDkAAVA7AAFQPQABUD8AAVBCAAFQQwABUEQAAVBHAAFQSAABUEoAAVBLAAFQTQABUFAAAVBRAAFQUgABUFUAAVBWAAFQWwABUGgAAVBtAAFQbwABUHEAAVB2AAFQeQABUHwAAVB+AAFQowABUMcAAVDuAAFREgABURUAAVEXAAFRGQABURsAAVEdAAFRHwABUSAAAVEjAAFRMAABUUEAAVFDAAFRRQABUUcAAVFJAAFRSwABUU0AAVFPAAFRUQABUWIAAVFlAAFRaAABUWsAAVFuAAFRcQABUXQAAVF3AAFRegABUXwAAVG7AAFRvQABUb8AAVHBAAFRxAABUcUAAVHGAAFRxwABUcgAAVHKAAFRzAABUc0AAVHOAAFR0AABUdEAAVIQAAFSEgABUhQAAVIWAAFSGQABUhoAAVIbAAFSHAABUh0AAVIfAAFSIQABUiIAAVIjAAFSJQABUiYAAVJlAAFSZwABUmoAAVJsAAFSbwABUnAAAVJxAAFScgABUnMAAVJ1AAFSdwABUngAAVJ5AAFSewABUnwAAVKJAAFSigABUosAAVKNAAFSzAABUs4AAVLQAAFS0gABUtUAAVLWAAFS1wABUtgAAVLZAAFS2wABUt0AAVLeAAFS3wABUuEAAVLiAAFTIQABUyMAAVMlAAFTJwABUyoAAVMrAAFTLAABUy0AAVMuAAFTMAABUzIAAVMzAAFTNAABUzYAAVM3AAFTdgABU3gAAVN6AAFTfAABU38AAVOAAAFTgQABU4IAAVODAAFThQABU4cAAVOIAAFTiQABU4sAAVOMAAFTywABU80AAVPPAAFT0QABU9QAAVPVAAFT1gABU9cAAVPYAAFT2gABU9wAAVPdAAFT3gABU+AAAVPhAAFUIAABVCIAAVQkAAFUJgABVCkAAVQqAAFUKwABVCwAAVQtAAFULwABVDEAAVQyAAFUMwABVDUAAVQ2AAFUWwABVH8AAVSmAAFUygABVM0AAVTPAAFU0QABVNMAAVTVAAFU1wABVNgAAVTbAAFU6AABVPcAAVT5AAFU+wABVP0AAVT/AAFVAQABVQMAAVUFAAFVFAABVRcAAVUaAAFVHQABVSAAAVUjAAFVJgABVSkAAVUrAAFVagABVWwAAVVuAAFVcAABVXMAAVV0AAFVdQABVXYAAVV3AAFVeQABVXsAAVV8AAFVfQABVX8AAVWAAAFVvwABVcEAAVXDAAFVxQABVcgAAVXJAAFVygABVcsAAVXMAAFVzgABVdAAAVXRAAFV0gABVdQAAVXVAAFWFAABVhYAAVYYAAFWGgABVh0AAVYeAAFWHwABViAAAVYhAAFWIwABViUAAVYmAAFWJwABVikAAVYqAAFWaQABVmsAAVZuAAFWcAABVnMAAVZ0AAFWdQABVnYAAVZ3AAFWeQABVnsAAVZ8AAFWfQABVn8AAVaAAAFWvwABVsEAAVbDAAFWxQABVsgAAVbJAAFWygABVssAAVbMAAFWzgABVtAAAVbRAAFW0gABVtQAAVbVAAFXFAABVxYAAVcYAAFXGgABVx0AAVceAAFXHwABVyAAAVchAAFXIwABVyUAAVcmAAFXJwABVykAAVcqAAFXaQABV2sAAVdtAAFXbwABV3IAAVdzAAFXdAABV3UAAVd2AAFXeAABV3oAAVd7AAFXfAABV34AAVd/AAFXygABV+0AAVgNAAFYLQABWC8AAVgxAAFYMwABWDUAAVg4AAFYOQABWDoAAVg9AAFYPgABWEAAAVhBAAFYQwABWEYAAVhHAAFYSAABWEsAAVhMAAFYUQABWF4AAVhjAAFYZQABWGcAAVhsAAFYbwABWHIAAVh0AAFYmQABWL0AAVjkAAFZCAABWQsAAVkNAAFZDwABWREAAVkTAAFZFQABWRYAAVkZAAFZJgABWTcAAVk5AAFZOwABWT0AAVk/AAFZQQABWUMAAVlFAAFZRwABWVgAAVlbAAFZXgABWWEAAVlkAAFZZwABWWoAAVltAAFZcAABWXIAAVmxAAFZswABWbUAAVm3AAFZugABWbsAAVm8AAFZvQABWb4AAVnAAAFZwgABWcMAAVnEAAFZxgABWccAAVoGAAFaCAABWgoAAVoMAAFaDwABWhAAAVoRAAFaEgABWhMAAVoVAAFaFwABWhgAAVoZAAFaGwABWhwAAVpbAAFaXQABWmAAAVpiAAFaZQABWmYAAVpnAAFaaAABWmkAAVprAAFabQABWm4AAVpvAAFacQABWnIAAVp/AAFagAABWoEAAVqDAAFawgABWsQAAVrGAAFayAABWssAAVrMAAFazQABWs4AAVrPAAFa0QABWtMAAVrUAAFa1QABWtcAAVrYAAFbFwABWxkAAVsbAAFbHQABWyAAAVshAAFbIgABWyMAAVskAAFbJgABWygAAVspAAFbKgABWywAAVstAAFbbAABW24AAVtwAAFbcgABW3UAAVt2AAFbdwABW3gAAVt5AAFbewABW30AAVt+AAFbfwABW4EAAVuCAAFbwQABW8MAAVvFAAFbxwABW8oAAVvLAAFbzAABW80AAVvOAAFb0AABW9IAAVvTAAFb1AABW9YAAVvXAAFcFgABXBgAAVwaAAFcHAABXB8AAVwgAAFcIQABXCIAAVwjAAFcJQABXCcAAVwoAAFcKQABXCsAAVwsAAFcUQABXHUAAVycAAFcwAABXMMAAVzFAAFcxwABXMkAAVzLAAFczQABXM4AAVzRAAFc3gABXO0AAVzvAAFc8QABXPMAAVz1AAFc9wABXPkAAVz7AAFdCgABXQ0AAV0QAAFdEwABXRYAAV0ZAAFdHAABXR8AAV0hAAFdYAABXWIAAV1kAAFdZgABXWkAAV1qAAFdawABXWwAAV1tAAFdbwABXXEAAV1yAAFdcwABXXUAAV12AAFdtQABXbcAAV25AAFduwABXb4AAV2/AAFdwAABXcEAAV3CAAFdxAABXcYAAV3HAAFdyAABXcoAAV3LAAFeCgABXgwAAV4OAAFeEAABXhMAAV4UAAFeFQABXhYAAV4XAAFeGQABXhsAAV4cAAFeHQABXh8AAV4gAAFeXwABXmEAAV5jAAFeZQABXmgAAV5pAAFeagABXmsAAV5sAAFebgABXnAAAV5xAAFecgABXnQAAV51AAFetAABXrYAAV64AAFeugABXr0AAV6+AAFevwABXsAAAV7BAAFewwABXsUAAV7GAAFexwABXskAAV7KAAFfCQABXwsAAV8NAAFfDwABXxIAAV8TAAFfFAABXxUAAV8WAAFfGAABXxoAAV8bAAFfHAABXx4AAV8fAAFfXgABX2AAAV9iAAFfZAABX2cAAV9oAAFfaQABX2oAAV9rAAFfbQABX28AAV9wAAFfcQABX3MAAV90AAFfvwABX+IAAWACAAFgIgABYCQAAWAmAAFgKAABYCoAAWAtAAFgLgABYC8AAWAyAAFgMwABYDUAAWA2AAFgOAABYDsAAWA8AAFgPQABYEAAAWBBAAFgRgABYFMAAWBYAAFgWgABYFwAAWBhAAFgZAABYGcAAWBpAAFgjgABYLIAAWDZAAFg/QABYQAAAWECAAFhBAABYQYAAWEIAAFhCgABYQsAAWEOAAFhGwABYSwAAWEuAAFhMAABYTIAAWE0AAFhNgABYTgAAWE6AAFhPAABYU0AAWFQAAFhUwABYVYAAWFZAAFhXAABYV8AAWFiAAFhZQABYWcAAWGmAAFhqAABYaoAAWGsAAFhrwABYbAAAWGxAAFhsgABYbMAAWG1AAFhtwABYbgAAWG5AAFhuwABYbwAAWH7AAFh/QABYf8AAWIBAAFiBAABYgUAAWIGAAFiBwABYggAAWIKAAFiDAABYg0AAWIOAAFiEAABYhEAAWJQAAFiUgABYlUAAWJXAAFiWgABYlsAAWJcAAFiXQABYl4AAWJgAAFiYgABYmMAAWJkAAFiZgABYmcAAWJ0AAFidQABYnYAAWJ4AAFitwABYrkAAWK7AAFivQABYsAAAWLBAAFiwgABYsMAAWLEAAFixgABYsgAAWLJAAFiygABYswAAWLNAAFjDAABYw4AAWMQAAFjEgABYxUAAWMWAAFjFwABYxgAAWMZAAFjGwABYx0AAWMeAAFjHwABYyEAAWMiAAFjYQABY2MAAWNlAAFjZwABY2oAAWNrAAFjbAABY20AAWNuAAFjcAABY3IAAWNzAAFjdAABY3YAAWN3AAFjtgABY7gAAWO6AAFjvAABY78AAWPAAAFjwQABY8IAAWPDAAFjxQABY8cAAWPIAAFjyQABY8sAAWPMAAFkCwABZA0AAWQPAAFkEQABZBQAAWQVAAFkFgABZBcAAWQYAAFkGgABZBwAAWQdAAFkHgABZCAAAWQhAAFkRgABZGoAAWSRAAFktQABZLgAAWS6AAFkvAABZL4AAWTAAAFkwgABZMMAAWTGAAFk0wABZOIAAWTkAAFk5gABZOgAAWTqAAFk7AABZO4AAWTwAAFk/wABZQIAAWUFAAFlCAABZQsAAWUOAAFlEQABZRQAAWUWAAFlVQABZVcAAWVaAAFlXAABZV8AAWVgAAFlYQABZWIAAWVjAAFlZQABZWcAAWVoAAFlaQABZWsAAWVsAAFlqwABZa0AAWWvAAFlsQABZbQAAWW1AAFltgABZbcAAWW4AAFlugABZbwAAWW9AAFlvgABZcAAAWXBAAFmAAABZgIAAWYEAAFmBgABZgkAAWYKAAFmCwABZgwAAWYNAAFmDwABZhEAAWYSAAFmEwABZhUAAWYWAAFmVQABZlcAAWZaAAFmXAABZl8AAWZgAAFmYQABZmIAAWZjAAFmZQABZmcAAWZoAAFmaQABZmsAAWZsAAFmqwABZq0AAWavAAFmsQABZrQAAWa1AAFmtgABZrcAAWa4AAFmugABZrwAAWa9AAFmvgABZsAAAWbBAAFnAAABZwIAAWcEAAFnBgABZwkAAWcKAAFnCwABZwwAAWcNAAFnDwABZxEAAWcSAAFnEwABZxUAAWcWAAFnVQABZ1cAAWdZAAFnWwABZ14AAWdfAAFnYAABZ2EAAWdiAAFnZAABZ2YAAWdnAAFnaAABZ2oAAWdrAAFntgABZ9kAAWf5AAFoGQABaBsAAWgdAAFoHwABaCEAAWgkAAFoJQABaCYAAWgpAAFoKgABaCwAAWgtAAFoLwABaDIAAWgzAAFoNAABaDcAAWg4AAFoPQABaEoAAWhPAAFoUQABaFMAAWhYAAFoWwABaF4AAWhgAAFohQABaKkAAWjQAAFo9AABaPcAAWj5AAFo+wABaP0AAWj/AAFpAQABaQIAAWkFAAFpEgABaSMAAWklAAFpJwABaSkAAWkrAAFpLQABaS8AAWkxAAFpMwABaUQAAWlHAAFpSgABaU0AAWlQAAFpUwABaVYAAWlZAAFpXAABaV4AAWmdAAFpnwABaaEAAWmjAAFppgABaacAAWmoAAFpqQABaaoAAWmsAAFprgABaa8AAWmwAAFpsgABabMAAWnyAAFp9AABafYAAWn4AAFp+wABafwAAWn9AAFp/gABaf8AAWoBAAFqAwABagQAAWoFAAFqBwABaggAAWpHAAFqSQABakwAAWpOAAFqUQABalIAAWpTAAFqVAABalUAAWpXAAFqWQABaloAAWpbAAFqXQABal4AAWprAAFqbAABam0AAWpvAAFqrgABarAAAWqyAAFqtAABarcAAWq4AAFquQABaroAAWq7AAFqvQABar8AAWrAAAFqwQABasMAAWrEAAFrAwABawUAAWsHAAFrCQABawwAAWsNAAFrDgABaw8AAWsQAAFrEgABaxQAAWsVAAFrFgABaxgAAWsZAAFrWAABa1oAAWtcAAFrXgABa2EAAWtiAAFrYwABa2QAAWtlAAFrZwABa2kAAWtqAAFrawABa20AAWtuAAFrrQABa68AAWuxAAFrswABa7YAAWu3AAFruAABa7kAAWu6AAFrvAABa74AAWu/AAFrwAABa8IAAWvDAAFsAgABbAQAAWwGAAFsCAABbAsAAWwMAAFsDQABbA4AAWwPAAFsEQABbBMAAWwUAAFsFQABbBcAAWwYAAFsPQABbGEAAWyIAAFsrAABbK8AAWyxAAFsswABbLUAAWy3AAFsuQABbLoAAWy9AAFsygABbNkAAWzbAAFs3QABbN8AAWzhAAFs4wABbOUAAWznAAFs9gABbPkAAWz8AAFs/wABbQIAAW0FAAFtCAABbQsAAW0NAAFtTAABbU4AAW1QAAFtUgABbVUAAW1WAAFtVwABbVgAAW1ZAAFtWwABbV0AAW1eAAFtXwABbWEAAW1iAAFtoQABbaMAAW2lAAFtpwABbaoAAW2rAAFtrAABba0AAW2uAAFtsAABbbIAAW2zAAFttAABbbYAAW23AAFt9gABbfgAAW36AAFt/AABbf8AAW4AAAFuAQABbgIAAW4DAAFuBQABbgcAAW4IAAFuCQABbgsAAW4MAAFuSwABbk0AAW5PAAFuUQABblQAAW5VAAFuVgABblcAAW5YAAFuWgABblwAAW5dAAFuXgABbmAAAW5hAAFuoAABbqIAAW6kAAFupgABbqkAAW6qAAFuqwABbqwAAW6tAAFurwABbrEAAW6yAAFuswABbrUAAW62AAFu9QABbvcAAW75AAFu+wABbv4AAW7/AAFvAAABbwEAAW8CAAFvBAABbwYAAW8HAAFvCAABbwoAAW8LAAFvSgABb0wAAW9OAAFvUAABb1MAAW9UAAFvVQABb1YAAW9XAAFvWQABb1sAAW9cAAFvXQABb18AAW9gAAFvqwABb84AAW/uAAFwDgABcBAAAXASAAFwFAABcBYAAXAZAAFwGgABcBsAAXAeAAFwHwABcCEAAXAiAAFwJAABcCcAAXAoAAFwKQABcCwAAXAtAAFwNgABcEMAAXBIAAFwSgABcEwAAXBRAAFwVAABcFcAAXBZAAFwfgABcKIAAXDJAAFw7QABcPAAAXDyAAFw9AABcPYAAXD4AAFw+gABcPsAAXD+AAFxCwABcRwAAXEeAAFxIAABcSIAAXEkAAFxJgABcSgAAXEqAAFxLAABcT0AAXFAAAFxQwABcUYAAXFJAAFxTAABcU8AAXFSAAFxVQABcVcAAXGWAAFxmAABcZoAAXGcAAFxnwABcaAAAXGhAAFxogABcaMAAXGlAAFxpwABcagAAXGpAAFxqwABcawAAXHrAAFx7QABce8AAXHxAAFx9AABcfUAAXH2AAFx9wABcfgAAXH6AAFx/AABcf0AAXH+AAFyAAABcgEAAXJAAAFyQgABckUAAXJHAAFySgABcksAAXJMAAFyTQABck4AAXJQAAFyUgABclMAAXJUAAFyVgABclcAAXJkAAFyZQABcmYAAXJoAAFypwABcqkAAXKrAAFyrQABcrAAAXKxAAFysgABcrMAAXK0AAFytgABcrgAAXK5AAFyugABcrwAAXK9AAFy/AABcv4AAXMAAAFzAgABcwUAAXMGAAFzBwABcwgAAXMJAAFzCwABcw0AAXMOAAFzDwABcxEAAXMSAAFzUQABc1MAAXNVAAFzVwABc1oAAXNbAAFzXAABc10AAXNeAAFzYAABc2IAAXNjAAFzZAABc2YAAXNnAAFzpgABc6gAAXOqAAFzrAABc68AAXOwAAFzsQABc7IAAXOzAAFztQABc7cAAXO4AAFzuQABc7sAAXO8AAFz+wABc/0AAXP/AAF0AQABdAQAAXQFAAF0BgABdAcAAXQIAAF0CgABdAwAAXQNAAF0DgABdBAAAXQRAAF0NgABdFoAAXSBAAF0pQABdKgAAXSqAAF0rAABdK4AAXSwAAF0sgABdLMAAXS2AAF0wwABdNIAAXTUAAF01gABdNgAAXTaAAF03AABdN4AAXTgAAF07wABdPIAAXT1AAF0+AABdPsAAXT+AAF1AQABdQQAAXUGAAF1RQABdUcAAXVKAAF1TAABdU8AAXVQAAF1UQABdVIAAXVTAAF1VQABdVcAAXVYAAF1WQABdVsAAXVcAAF1mwABdZ0AAXWfAAF1oQABdaQAAXWlAAF1pgABdacAAXWoAAF1qgABdawAAXWtAAF1rgABdbAAAXWxAAF18AABdfIAAXX0AAF19gABdfkAAXX6AAF1+wABdfwAAXX9AAF1/wABdgEAAXYCAAF2AwABdgUAAXYGAAF2RQABdkcAAXZKAAF2TAABdk8AAXZQAAF2UQABdlIAAXZTAAF2VQABdlcAAXZYAAF2WQABdlsAAXZcAAF2mwABdp0AAXafAAF2oQABdqQAAXalAAF2pgABdqcAAXaoAAF2qgABdqwAAXatAAF2rgABdrAAAXaxAAF28AABdvIAAXb0AAF29gABdvkAAXb6AAF2+wABdvwAAXb9AAF2/wABdwEAAXcCAAF3AwABdwUAAXcGAAF3RQABd0cAAXdJAAF3SwABd04AAXdPAAF3UAABd1EAAXdSAAF3VAABd1YAAXdXAAF3WAABd1oAAXdbAAF3ZAABd2UAAXdnAAF3qgABd84AAXfyAAF4FQABeDwAAXhcAAF4gwABeKoAAXjKAAF47gABeRIAAXkUAAF5FwABeRkAAXkbAAF5HQABeSAAAXkjAAF5JQABeScAAXkqAAF5LAABeS4AAXkxAAF5NAABeTUAAXk6AAF5RwABeUoAAXlMAAF5TwABeVIAAXlUAAF5eQABeZ0AAXnEAAF56AABeesAAXntAAF57wABefEAAXnzAAF59QABefYAAXn5AAF6BgABehkAAXobAAF6HQABeh8AAXohAAF6IwABeiUAAXonAAF6KQABeisAAXo+AAF6QQABekQAAXpHAAF6SgABek0AAXpQAAF6UwABelYAAXpZAAF6WwABepoAAXqcAAF6nwABeqEAAXqkAAF6pQABeqYAAXqnAAF6qAABeqoAAXqsAAF6rQABeq4AAXqwAAF6sQABeroAAXq7AAF6vQABevwAAXr+AAF7AAABewIAAXsFAAF7BgABewcAAXsIAAF7CQABewsAAXsNAAF7DgABew8AAXsRAAF7EgABe1EAAXtTAAF7VgABe1gAAXtbAAF7XAABe10AAXteAAF7XwABe2EAAXtjAAF7ZAABe2UAAXtnAAF7aAABe3EAAXtyAAF7dAABe7MAAXu1AAF7twABe7kAAXu8AAF7vQABe74AAXu/AAF7wAABe8IAAXvEAAF7xQABe8YAAXvIAAF7yQABfAgAAXwKAAF8DQABfA8AAXwSAAF8EwABfBQAAXwVAAF8FgABfBgAAXwaAAF8GwABfBwAAXweAAF8HwABfCgAAXwpAAF8KwABfGoAAXxsAAF8bgABfHAAAXxzAAF8dAABfHUAAXx2AAF8dwABfHkAAXx7AAF8fAABfH0AAXx/AAF8gAABfL8AAXzBAAF8xAABfMYAAXzJAAF8ygABfMsAAXzMAAF8zQABfM8AAXzRAAF80gABfNMAAXzVAAF81gABfOMAAXzkAAF85QABfOcAAX0mAAF9KAABfSoAAX0sAAF9LwABfTAAAX0xAAF9MgABfTMAAX01AAF9NwABfTgAAX05AAF9OwABfTwAAX17AAF9fQABfYAAAX2CAAF9hQABfYYAAX2HAAF9iAABfYkAAX2LAAF9jQABfY4AAX2PAAF9kQABfZIAAX2cAAF9qQABfboAAX29AAF9wAABfcMAAX3GAAF9yQABfcwAAX3PAAF90gABfeMAAX3mAAF96QABfewAAX3vAAF98gABffUAAX34AAF9+wABff0AAX4QAAF+IgABfi8AAX49AAF+SgABflgAAX5gAAF+ZQABfrAAAX7TAAF+8wABfxMAAX8VAAF/FwABfxkAAX8bAAF/HgABfx8AAX8gAAF/IwABfyQAAX8mAAF/JwABfykAAX8sAAF/LQABfy4AAX8xAAF/MgABfzcAAX9EAAF/SQABf0sAAX9NAAF/UgABf1UAAX9YAAF/WgABf38AAX+jAAF/ygABf+4AAX/xAAF/8wABf/UAAX/3AAF/+QABf/sAAX/8AAF//wABgAwAAYAdAAGAHwABgCEAAYAjAAGAJQABgCcAAYApAAGAKwABgC0AAYA+AAGAQQABgEQAAYBHAAGASgABgE0AAYBQAAGAUwABgFYAAYBYAAGAlwABgJkAAYCbAAGAnQABgKAAAYChAAGAogABgKMAAYCkAAGApgABgKgAAYCpAAGAqgABgKwAAYCtAAGA7AABgO4AAYDwAAGA8gABgPUAAYD2AAGA9wABgPgAAYD5AAGA+wABgP0AAYD+AAGA/wABgQEAAYECAAGBQQABgUMAAYFGAAGBSAABgUsAAYFMAAGBTQABgU4AAYFPAAGBUQABgVMAAYFUAAGBVQABgVcAAYFYAAGBZQABgWYAAYFnAAGBaQABgagAAYGqAAGBrAABga4AAYGxAAGBsgABgbMAAYG0AAGBtQABgbcAAYG5AAGBugABgbsAAYG9AAGBvgABgf0AAYH/AAGCAQABggMAAYIGAAGCBwABgggAAYIJAAGCCgABggwAAYIOAAGCDwABghAAAYISAAGCEwABglIAAYJUAAGCVgABglgAAYJbAAGCXAABgl0AAYJeAAGCXwABgmEAAYJjAAGCZAABgmUAAYJnAAGCaAABgqcAAYKpAAGCqwABgq0AAYKwAAGCsQABgrIAAYKzAAGCtAABgrYAAYK4AAGCuQABgroAAYK8AAGCvQABgvwAAYL+AAGDAAABgwIAAYMFAAGDBgABgwcAAYMIAAGDCQABgwsAAYMNAAGDDgABgw8AAYMRAAGDEgABgzcAAYNbAAGDggABg6YAAYOpAAGDqwABg60AAYOvAAGDsQABg7MAAYO0AAGDtwABg8QAAYPTAAGD1QABg9cAAYPZAAGD2wABg90AAYPfAAGD4QABg/AAAYPzAAGD9gABg/kAAYP8AAGD/wABhAIAAYQFAAGEBwABhEYAAYRIAAGESgABhEwAAYRPAAGEUAABhFEAAYRSAAGEUwABhFUAAYRXAAGEWAABhFkAAYRbAAGEXAABhJsAAYSdAAGEnwABhKEAAYSkAAGEpQABhKYAAYSnAAGEqAABhKoAAYSsAAGErQABhK4AAYSwAAGEsQABhPAAAYTyAAGE9AABhPYAAYT5AAGE+gABhPsAAYT8AAGE/QABhP8AAYUBAAGFAgABhQMAAYUFAAGFBgABhUUAAYVHAAGFSQABhUsAAYVOAAGFTwABhVAAAYVRAAGFUgABhVQAAYVWAAGFVwABhVgAAYVaAAGFWwABhZoAAYWcAAGFngABhaAAAYWjAAGFpAABhaUAAYWmAAGFpwABhakAAYWrAAGFrAABha0AAYWvAAGFsAABhe8AAYXxAAGF8wABhfUAAYX4AAGF+QABhfoAAYX7AAGF/AABhf4AAYYAAAGGAQABhgIAAYYEAAGGBQABhkQAAYZGAAGGSAABhkoAAYZNAAGGTgABhk8AAYZQAAGGUQABhlMAAYZVAAGGVgABhlcAAYZZAAGGWgABhqUAAYbIAAGG6AABhwgAAYcKAAGHDAABhw4AAYcQAAGHEwABhxQAAYcVAAGHGAABhxkAAYcbAAGHHAABhx4AAYchAAGHIgABhyMAAYcmAAGHJwABhywAAYc5AAGHPgABh0AAAYdCAAGHRwABh0oAAYdNAAGHTwABh3QAAYeYAAGHvwABh+MAAYfmAAGH6AABh+oAAYfsAAGH7gABh/AAAYfxAAGH9AABiAEAAYgSAAGIFAABiBYAAYgYAAGIGgABiBwAAYgeAAGIIAABiCIAAYgzAAGINgABiDkAAYg8AAGIPwABiEIAAYhFAAGISAABiEsAAYhNAAGIjAABiI4AAYiQAAGIkgABiJUAAYiWAAGIlwABiJgAAYiZAAGImwABiJ0AAYieAAGInwABiKEAAYiiAAGI4QABiOMAAYjlAAGI5wABiOoAAYjrAAGI7AABiO0AAYjuAAGI8AABiPIAAYjzAAGI9AABiPYAAYj3AAGJNgABiTgAAYk7AAGJPQABiUAAAYlBAAGJQgABiUMAAYlEAAGJRgABiUgAAYlJAAGJSgABiUwAAYlNAAGJWgABiVsAAYlcAAGJXgABiZ0AAYmfAAGJoQABiaMAAYmmAAGJpwABiagAAYmpAAGJqgABiawAAYmuAAGJrwABibAAAYmyAAGJswABifIAAYn0AAGJ9gABifgAAYn7AAGJ/AABif0AAYn+AAGJ/wABigEAAYoDAAGKBAABigUAAYoHAAGKCAABikcAAYpJAAGKSwABik0AAYpQAAGKUQABilIAAYpTAAGKVAABilYAAYpYAAGKWQABiloAAYpcAAGKXQABipwAAYqeAAGKoAABiqIAAYqlAAGKpgABiqcAAYqoAAGKqQABiqsAAYqtAAGKrgABiq8AAYqxAAGKsgABivEAAYrzAAGK9QABivcAAYr6AAGK+wABivwAAYr9AAGK/gABiwAAAYsCAAGLAwABiwQAAYsGAAGLBwABiywAAYtQAAGLdwABi5sAAYueAAGLoAABi6IAAYukAAGLpgABi6gAAYupAAGLrAABi7kAAYvIAAGLygABi8wAAYvOAAGL0AABi9IAAYvUAAGL1gABi+UAAYvoAAGL6wABi+4AAYvxAAGL9AABi/cAAYv6AAGL/AABjDsAAYw9AAGMPwABjEEAAYxEAAGMRQABjEYAAYxHAAGMSAABjEoAAYxMAAGMTQABjE4AAYxQAAGMUQABjJAAAYySAAGMlAABjJYAAYyZAAGMmgABjJsAAYycAAGMnQABjJ8AAYyhAAGMogABjKMAAYylAAGMpgABjOUAAYznAAGM6QABjOsAAYzuAAGM7wABjPAAAYzxAAGM8gABjPQAAYz2AAGM9wABjPgAAYz6AAGM+wABjToAAY08AAGNPgABjUAAAY1DAAGNRAABjUUAAY1GAAGNRwABjUkAAY1LAAGNTAABjU0AAY1PAAGNUAABjY8AAY2RAAGNkwABjZUAAY2YAAGNmQABjZoAAY2bAAGNnAABjZ4AAY2gAAGNoQABjaIAAY2kAAGNpQABjeQAAY3mAAGN6AABjeoAAY3tAAGN7gABje8AAY3wAAGN8QABjfMAAY31AAGN9gABjfcAAY35AAGN+gABjjkAAY47AAGOPQABjj8AAY5CAAGOQwABjkQAAY5FAAGORgABjkgAAY5KAAGOSwABjkwAAY5OAAGOTwABjpoAAY69AAGO3QABjv0AAY7/AAGPAQABjwMAAY8FAAGPCAABjwkAAY8KAAGPDQABjw4AAY8QAAGPEQABjxMAAY8WAAGPFwABjxgAAY8bAAGPHAABjyEAAY8uAAGPMwABjzUAAY83AAGPPAABjz8AAY9CAAGPRAABj2kAAY+NAAGPtAABj9gAAY/bAAGP3QABj98AAY/hAAGP4wABj+UAAY/mAAGP6QABj/YAAZAHAAGQCQABkAsAAZANAAGQDwABkBEAAZATAAGQFQABkBcAAZAoAAGQKwABkC4AAZAxAAGQNAABkDcAAZA6AAGQPQABkEAAAZBCAAGQgQABkIMAAZCFAAGQhwABkIoAAZCLAAGQjAABkI0AAZCOAAGQkAABkJIAAZCTAAGQlAABkJYAAZCXAAGQ1gABkNgAAZDaAAGQ3AABkN8AAZDgAAGQ4QABkOIAAZDjAAGQ5QABkOcAAZDoAAGQ6QABkOsAAZDsAAGRKwABkS0AAZEwAAGRMgABkTUAAZE2AAGRNwABkTgAAZE5AAGROwABkT0AAZE+AAGRPwABkUEAAZFCAAGRTwABkVAAAZFRAAGRUwABkZIAAZGUAAGRlgABkZgAAZGbAAGRnAABkZ0AAZGeAAGRnwABkaEAAZGjAAGRpAABkaUAAZGnAAGRqAABkecAAZHpAAGR6wABke0AAZHwAAGR8QABkfIAAZHzAAGR9AABkfYAAZH4AAGR+QABkfoAAZH8AAGR/QABkjwAAZI+AAGSQAABkkIAAZJFAAGSRgABkkcAAZJIAAGSSQABkksAAZJNAAGSTgABkk8AAZJRAAGSUgABkpEAAZKTAAGSlQABkpcAAZKaAAGSmwABkpwAAZKdAAGSngABkqAAAZKiAAGSowABkqQAAZKmAAGSpwABkuYAAZLoAAGS6gABkuwAAZLvAAGS8AABkvEAAZLyAAGS8wABkvUAAZL3AAGS+AABkvkAAZL7AAGS/AABkyEAAZNFAAGTbAABk5AAAZOTAAGTlQABk5cAAZOZAAGTmwABk50AAZOeAAGToQABk64AAZO9AAGTvwABk8EAAZPDAAGTxQABk8cAAZPJAAGTywABk9oAAZPdAAGT4AABk+MAAZPmAAGT6QABk+wAAZPvAAGT8QABlDAAAZQyAAGUNAABlDYAAZQ5AAGUOgABlDsAAZQ8AAGUPQABlD8AAZRBAAGUQgABlEMAAZRFAAGURgABlIUAAZSHAAGUiQABlIsAAZSOAAGUjwABlJAAAZSRAAGUkgABlJQAAZSWAAGUlwABlJgAAZSaAAGUmwABlNoAAZTcAAGU3gABlOAAAZTjAAGU5AABlOUAAZTmAAGU5wABlOkAAZTrAAGU7AABlO0AAZTvAAGU8AABlS8AAZUxAAGVMwABlTUAAZU4AAGVOQABlToAAZU7AAGVPAABlT4AAZVAAAGVQQABlUIAAZVEAAGVRQABlYQAAZWGAAGViAABlYoAAZWNAAGVjgABlY8AAZWQAAGVkQABlZMAAZWVAAGVlgABlZcAAZWZAAGVmgABldkAAZXbAAGV3QABld8AAZXiAAGV4wABleQAAZXlAAGV5gABlegAAZXqAAGV6wABlewAAZXuAAGV7wABli4AAZYwAAGWMgABljQAAZY3AAGWOAABljkAAZY6AAGWOwABlj0AAZY/AAGWQAABlkEAAZZDAAGWRAABlo8AAZayAAGW0gABlvIAAZb0AAGW9gABlvgAAZb6AAGW/QABlv4AAZb/AAGXAgABlwMAAZcFAAGXBgABlwgAAZcLAAGXDAABlw0AAZcQAAGXEQABlxYAAZcjAAGXKAABlyoAAZcsAAGXMQABlzQAAZc3AAGXOQABl14AAZeCAAGXqQABl80AAZfQAAGX0gABl9QAAZfWAAGX2AABl9oAAZfbAAGX3gABl+sAAZf8AAGX/gABmAAAAZgCAAGYBAABmAYAAZgIAAGYCgABmAwAAZgdAAGYIAABmCMAAZgmAAGYKQABmCwAAZgvAAGYMgABmDUAAZg3AAGYdgABmHgAAZh6AAGYfAABmH8AAZiAAAGYgQABmIIAAZiDAAGYhQABmIcAAZiIAAGYiQABmIsAAZiMAAGYywABmM0AAZjPAAGY0QABmNQAAZjVAAGY1gABmNcAAZjYAAGY2gABmNwAAZjdAAGY3gABmOAAAZjhAAGZIAABmSIAAZklAAGZJwABmSoAAZkrAAGZLAABmS0AAZkuAAGZMAABmTIAAZkzAAGZNAABmTYAAZk3AAGZRAABmUUAAZlGAAGZSAABmYcAAZmJAAGZiwABmY0AAZmQAAGZkQABmZIAAZmTAAGZlAABmZYAAZmYAAGZmQABmZoAAZmcAAGZnQABmdwAAZneAAGZ4AABmeIAAZnlAAGZ5gABmecAAZnoAAGZ6QABmesAAZntAAGZ7gABme8AAZnxAAGZ8gABmjEAAZozAAGaNQABmjcAAZo6AAGaOwABmjwAAZo9AAGaPgABmkAAAZpCAAGaQwABmkQAAZpGAAGaRwABmoYAAZqIAAGaigABmowAAZqPAAGakAABmpEAAZqSAAGakwABmpUAAZqXAAGamAABmpkAAZqbAAGanAABmtsAAZrdAAGa3wABmuEAAZrkAAGa5QABmuYAAZrnAAGa6AABmuoAAZrsAAGa7QABmu4AAZrwAAGa8QABmxYAAZs6AAGbYQABm4UAAZuIAAGbigABm4wAAZuOAAGbkAABm5IAAZuTAAGblgABm6MAAZuyAAGbtAABm7YAAZu4AAGbugABm7wAAZu+AAGbwAABm88AAZvSAAGb1QABm9gAAZvbAAGb3gABm+EAAZvkAAGb5gABnCUAAZwnAAGcKQABnCsAAZwuAAGcLwABnDAAAZwxAAGcMgABnDQAAZw2AAGcNwABnDgAAZw6AAGcOwABnHoAAZx8AAGcfgABnIAAAZyDAAGchAABnIUAAZyGAAGchwABnIkAAZyLAAGcjAABnI0AAZyPAAGckAABnM8AAZzRAAGc0wABnNUAAZzYAAGc2QABnNoAAZzbAAGc3AABnN4AAZzgAAGc4QABnOIAAZzkAAGc5QABnSQAAZ0mAAGdKAABnSoAAZ0tAAGdLgABnS8AAZ0wAAGdMQABnTMAAZ01AAGdNgABnTcAAZ05AAGdOgABnXkAAZ17AAGdfQABnX8AAZ2CAAGdgwABnYQAAZ2FAAGdhgABnYgAAZ2KAAGdiwABnYwAAZ2OAAGdjwABnc4AAZ3QAAGd0gABndQAAZ3XAAGd2AABndkAAZ3aAAGd2wABnd0AAZ3fAAGd4AABneEAAZ3jAAGd5AABniMAAZ4lAAGeJwABnikAAZ4sAAGeLQABni4AAZ4vAAGeMAABnjIAAZ40AAGeNQABnjYAAZ44AAGeOQABnoQAAZ6nAAGexwABnucAAZ7pAAGe6wABnu0AAZ7vAAGe8gABnvMAAZ70AAGe9wABnvgAAZ76AAGe+wABnv0AAZ8AAAGfAQABnwIAAZ8FAAGfBgABnwsAAZ8YAAGfHQABnx8AAZ8hAAGfJgABnykAAZ8sAAGfLgABn1MAAZ93AAGfngABn8IAAZ/FAAGfxwABn8kAAZ/LAAGfzQABn88AAZ/QAAGf0wABn+AAAZ/xAAGf8wABn/UAAZ/3AAGf+QABn/sAAZ/9AAGf/wABoAEAAaASAAGgFQABoBgAAaAbAAGgHgABoCEAAaAkAAGgJwABoCoAAaAsAAGgawABoG0AAaBvAAGgcQABoHQAAaB1AAGgdgABoHcAAaB4AAGgegABoHwAAaB9AAGgfgABoIAAAaCBAAGgwAABoMIAAaDEAAGgxgABoMkAAaDKAAGgywABoMwAAaDNAAGgzwABoNEAAaDSAAGg0wABoNUAAaDWAAGhFQABoRcAAaEaAAGhHAABoR8AAaEgAAGhIQABoSIAAaEjAAGhJQABoScAAaEoAAGhKQABoSsAAaEsAAGhOQABoToAAaE7AAGhPQABoXwAAaF+AAGhgAABoYIAAaGFAAGhhgABoYcAAaGIAAGhiQABoYsAAaGNAAGhjgABoY8AAaGRAAGhkgABodEAAaHTAAGh1QABodcAAaHaAAGh2wABodwAAaHdAAGh3gABoeAAAaHiAAGh4wABoeQAAaHmAAGh5wABoiYAAaIoAAGiKgABoiwAAaIvAAGiMAABojEAAaIyAAGiMwABojUAAaI3AAGiOAABojkAAaI7AAGiPAABonsAAaJ9AAGifwABooEAAaKEAAGihQABooYAAaKHAAGiiAABoooAAaKMAAGijQABoo4AAaKQAAGikQABotAAAaLSAAGi1AABotYAAaLZAAGi2gABotsAAaLcAAGi3QABot8AAaLhAAGi4gABouMAAaLlAAGi5gABowsAAaMvAAGjVgABo3oAAaN9AAGjfwABo4EAAaODAAGjhQABo4cAAaOIAAGjiwABo5gAAaOnAAGjqQABo6sAAaOtAAGjrwABo7EAAaOzAAGjtQABo8QAAaPHAAGjygABo80AAaPQAAGj0wABo9YAAaPZAAGj2wABpBoAAaQcAAGkHgABpCAAAaQjAAGkJAABpCUAAaQmAAGkJwABpCkAAaQrAAGkLAABpC0AAaQvAAGkMAABpG8AAaRxAAGkcwABpHUAAaR4AAGkeQABpHoAAaR7AAGkfAABpH4AAaSAAAGkgQABpIIAAaSEAAGkhQABpMQAAaTGAAGkyAABpMoAAaTNAAGkzgABpM8AAaTQAAGk0QABpNMAAaTVAAGk1gABpNcAAaTZAAGk2gABpRkAAaUbAAGlHQABpR8AAaUiAAGlIwABpSQAAaUlAAGlJgABpSgAAaUqAAGlKwABpSwAAaUuAAGlLwABpW4AAaVwAAGlcgABpXQAAaV3AAGleAABpXkAAaV6AAGlewABpX0AAaV/AAGlgAABpYEAAaWDAAGlhAABpcMAAaXFAAGlxwABpckAAaXMAAGlzQABpc4AAaXPAAGl0AABpdIAAaXUAAGl1QABpdYAAaXYAAGl2QABphgAAaYaAAGmHAABph4AAaYhAAGmIgABpiMAAaYkAAGmJQABpicAAaYpAAGmKgABpisAAaYtAAGmLgABpnkAAaacAAGmvAABptwAAabeAAGm4AABpuIAAabkAAGm5wABpugAAabpAAGm7AABpu0AAabvAAGm8AABpvIAAab1AAGm9gABpvcAAab6AAGm+wABpwAAAacNAAGnEgABpxQAAacWAAGnGwABpx4AAachAAGnIwABp0gAAadsAAGnkwABp7cAAae6AAGnvAABp74AAafAAAGnwgABp8QAAafFAAGnyAABp9UAAafmAAGn6AABp+oAAafsAAGn7gABp/AAAafyAAGn9AABp/YAAagHAAGoCgABqA0AAagQAAGoEwABqBYAAagZAAGoHAABqB8AAaghAAGoYAABqGIAAahkAAGoZgABqGkAAahqAAGoawABqGwAAahtAAGobwABqHEAAahyAAGocwABqHUAAah2AAGotQABqLcAAai5AAGouwABqL4AAai/AAGowAABqMEAAajCAAGoxAABqMYAAajHAAGoyAABqMoAAajLAAGpCgABqQwAAakPAAGpEQABqRQAAakVAAGpFgABqRcAAakYAAGpGgABqRwAAakdAAGpHgABqSAAAakhAAGpLgABqS8AAakwAAGpMgABqXEAAalzAAGpdQABqXcAAal6AAGpewABqXwAAal9AAGpfgABqYAAAamCAAGpgwABqYQAAamGAAGphwABqcYAAanIAAGpygABqcwAAanPAAGp0AABqdEAAanSAAGp0wABqdUAAanXAAGp2AABqdkAAanbAAGp3AABqhsAAaodAAGqHwABqiEAAaokAAGqJQABqiYAAaonAAGqKAABqioAAaosAAGqLQABqi4AAaowAAGqMQABqnAAAapyAAGqdAABqnYAAap5AAGqegABqnsAAap8AAGqfQABqn8AAaqBAAGqggABqoMAAaqFAAGqhgABqsUAAarHAAGqyQABqssAAarOAAGqzwABqtAAAarRAAGq0gABqtQAAarWAAGq1wABqtgAAaraAAGq2wABqwAAAaskAAGrSwABq28AAatyAAGrdAABq3YAAat4AAGregABq3wAAat9AAGrgAABq40AAaucAAGrngABq6AAAauiAAGrpAABq6YAAauoAAGrqgABq7kAAau8AAGrvwABq8IAAavFAAGryAABq8sAAavOAAGr0AABrA8AAawRAAGsEwABrBUAAawYAAGsGQABrBoAAawbAAGsHAABrB4AAawgAAGsIQABrCIAAawkAAGsJQABrGQAAaxmAAGsaAABrGoAAaxtAAGsbgABrG8AAaxwAAGscQABrHMAAax1AAGsdgABrHcAAax5AAGsegABrLkAAay7AAGsvQABrL8AAazCAAGswwABrMQAAazFAAGsxgABrMgAAazKAAGsywABrMwAAazOAAGszwABrQ4AAa0QAAGtEgABrRQAAa0XAAGtGAABrRkAAa0aAAGtGwABrR0AAa0fAAGtIAABrSEAAa0jAAGtJAABrWMAAa1lAAGtZwABrWkAAa1sAAGtbQABrW4AAa1vAAGtcAABrXIAAa10AAGtdQABrXYAAa14AAGteQABrbgAAa26AAGtvAABrb4AAa3BAAGtwgABrcMAAa3EAAGtxQABrccAAa3JAAGtygABrcsAAa3NAAGtzgABrg0AAa4PAAGuEQABrhMAAa4WAAGuFwABrhgAAa4ZAAGuGgABrhwAAa4eAAGuHwABriAAAa4iAAGuIwABrm4AAa6RAAGusQABrtEAAa7TAAGu1QABrtcAAa7ZAAGu3AABrt0AAa7eAAGu4QABruIAAa7kAAGu5QABrucAAa7qAAGu6wABruwAAa7vAAGu8AABrvkAAa8GAAGvCwABrw0AAa8PAAGvFAABrxcAAa8aAAGvHAABr0EAAa9lAAGvjAABr7AAAa+zAAGvtQABr7cAAa+5AAGvuwABr70AAa++AAGvwQABr84AAa/fAAGv4QABr+MAAa/lAAGv5wABr+kAAa/rAAGv7QABr+8AAbAAAAGwAwABsAYAAbAJAAGwDAABsA8AAbASAAGwFQABsBgAAbAaAAGwWQABsFsAAbBdAAGwXwABsGIAAbBjAAGwZAABsGUAAbBmAAGwaAABsGoAAbBrAAGwbAABsG4AAbBvAAGwrgABsLAAAbCyAAGwtAABsLcAAbC4AAGwuQABsLoAAbC7AAGwvQABsL8AAbDAAAGwwQABsMMAAbDEAAGxAwABsQUAAbEIAAGxCgABsQ0AAbEOAAGxDwABsRAAAbERAAGxEwABsRUAAbEWAAGxFwABsRkAAbEaAAGxJwABsSgAAbEpAAGxKwABsWoAAbFsAAGxbgABsXAAAbFzAAGxdAABsXUAAbF2AAGxdwABsXkAAbF7AAGxfAABsX0AAbF/AAGxgAABsb8AAbHBAAGxwwABscUAAbHIAAGxyQABscoAAbHLAAGxzAABsc4AAbHQAAGx0QABsdIAAbHUAAGx1QABshQAAbIWAAGyGAABshoAAbIdAAGyHgABsh8AAbIgAAGyIQABsiMAAbIlAAGyJgABsicAAbIpAAGyKgABsmkAAbJrAAGybQABsm8AAbJyAAGycwABsnQAAbJ1AAGydgABsngAAbJ6AAGyewABsnwAAbJ+AAGyfwABsr4AAbLAAAGywgABssQAAbLHAAGyyAABsskAAbLKAAGyywABss0AAbLPAAGy0AABstEAAbLTAAGy1AABsvkAAbMdAAGzRAABs2gAAbNrAAGzbQABs28AAbNxAAGzcwABs3UAAbN2AAGzeQABs4YAAbOVAAGzlwABs5kAAbObAAGznQABs58AAbOhAAGzowABs7IAAbO1AAGzuAABs7sAAbO+AAGzwQABs8QAAbPHAAGzyQABtAgAAbQKAAG0DAABtA4AAbQRAAG0EgABtBMAAbQUAAG0FQABtBcAAbQZAAG0GgABtBsAAbQdAAG0HgABtF0AAbRfAAG0YQABtGMAAbRmAAG0ZwABtGgAAbRpAAG0agABtGwAAbRuAAG0bwABtHAAAbRyAAG0cwABtLIAAbS0AAG0tgABtLgAAbS7AAG0vAABtL0AAbS+AAG0vwABtMEAAbTDAAG0xAABtMUAAbTHAAG0yAABtQcAAbUJAAG1CwABtQ0AAbUQAAG1EQABtRIAAbUTAAG1FAABtRYAAbUYAAG1GQABtRoAAbUcAAG1HQABtVwAAbVeAAG1YAABtWIAAbVlAAG1ZgABtWcAAbVoAAG1aQABtWsAAbVtAAG1bgABtW8AAbVxAAG1cgABtbEAAbWzAAG1tQABtbcAAbW6AAG1uwABtbwAAbW9AAG1vgABtcAAAbXCAAG1wwABtcQAAbXGAAG1xwABtgYAAbYIAAG2CgABtgwAAbYPAAG2EAABthEAAbYSAAG2EwABthUAAbYXAAG2GAABthkAAbYbAAG2HAABtmcAAbaKAAG2qgABtsoAAbbMAAG2zgABttAAAbbSAAG21QABttYAAbbXAAG22gABttsAAbbdAAG23gABtuAAAbbjAAG25AABtuUAAbboAAG26QABtu4AAbb7AAG3AAABtwIAAbcEAAG3CQABtwwAAbcPAAG3EQABtzYAAbdaAAG3gQABt6UAAbeoAAG3qgABt6wAAbeuAAG3sAABt7IAAbezAAG3tgABt8MAAbfUAAG31gABt9gAAbfaAAG33AABt94AAbfgAAG34gABt+QAAbf1AAG3+AABt/sAAbf+AAG4AQABuAQAAbgHAAG4CgABuA0AAbgPAAG4TgABuFAAAbhSAAG4VAABuFcAAbhYAAG4WQABuFoAAbhbAAG4XQABuF8AAbhgAAG4YQABuGMAAbhkAAG4owABuKUAAbinAAG4qQABuKwAAbitAAG4rgABuK8AAbiwAAG4sgABuLQAAbi1AAG4tgABuLgAAbi5AAG4+AABuPoAAbj9AAG4/wABuQIAAbkDAAG5BAABuQUAAbkGAAG5CAABuQoAAbkLAAG5DAABuQ4AAbkPAAG5HAABuR0AAbkeAAG5IAABuV8AAblhAAG5YwABuWUAAbloAAG5aQABuWoAAblrAAG5bAABuW4AAblwAAG5cQABuXIAAbl0AAG5dQABubQAAbm2AAG5uAABuboAAbm9AAG5vgABub8AAbnAAAG5wQABucMAAbnFAAG5xgABuccAAbnJAAG5ygABugkAAboLAAG6DQABug8AAboSAAG6EwABuhQAAboVAAG6FgABuhgAAboaAAG6GwABuhwAAboeAAG6HwABul4AAbpgAAG6YgABumQAAbpnAAG6aAABumkAAbpqAAG6awABum0AAbpvAAG6cAABunEAAbpzAAG6dAABurMAAbq1AAG6twABurkAAbq8AAG6vQABur4AAbq/AAG6wAABusIAAbrEAAG6xQABusYAAbrIAAG6yQABuu4AAbsSAAG7OQABu10AAbtgAAG7YgABu2QAAbtmAAG7aAABu2oAAbtrAAG7bgABu3sAAbuKAAG7jAABu44AAbuQAAG7kgABu5QAAbuWAAG7mAABu6cAAbuqAAG7rQABu7AAAbuzAAG7tgABu7kAAbu8AAG7vgABu/0AAbv/AAG8AQABvAMAAbwGAAG8BwABvAgAAbwJAAG8CgABvAwAAbwOAAG8DwABvBAAAbwSAAG8EwABvFIAAbxUAAG8VgABvFgAAbxbAAG8XAABvF0AAbxeAAG8XwABvGEAAbxjAAG8ZAABvGUAAbxnAAG8aAABvKcAAbypAAG8qwABvK0AAbywAAG8sQABvLIAAbyzAAG8tAABvLYAAby4AAG8uQABvLoAAby8AAG8vQABvPwAAbz+AAG9AAABvQIAAb0FAAG9BgABvQcAAb0IAAG9CQABvQsAAb0NAAG9DgABvQ8AAb0RAAG9EgABvVEAAb1TAAG9VQABvVcAAb1aAAG9WwABvVwAAb1dAAG9XgABvWAAAb1iAAG9YwABvWQAAb1mAAG9ZwABvaYAAb2oAAG9qgABvawAAb2vAAG9sAABvbEAAb2yAAG9swABvbUAAb23AAG9uAABvbkAAb27AAG9vAABvfsAAb39AAG9/wABvgEAAb4EAAG+BQABvgYAAb4HAAG+CAABvgoAAb4MAAG+DQABvg4AAb4QAAG+EQABvhoAAb4bAAG+HQABvioAAb4rAAG+LAABvi4AAb47AAG+PAABvj0AAb4/AAG+TAABvk0AAb5OAAG+UAABvlkAAb5oAAG+dQABvoQAAb6WAAG+qgABvsEAAb7TAAG+3AABvt0AAb7fAAG+7AABvu0AAb7uAAG+8AABvvkAAb8DAAG/CgAAAAAAAAQCAAAAAAAAQ5YAAAAAAAAAAAAAAAAAAb8S - - - - - AppEnvMO - Undefined - 2 - AppEnvMO - 1 - - - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIWGNvbnRlbnRz0h8gMjNfEBxOU0tleVBhdGhTcGVjaWZpZXJFeHByZXNzaW9uozIkJdIfIDU2Xk5TTXV0YWJsZUFycmF5ozU3JVdOU0FycmF50h8gOTpfEBNOU0tleVBhdGhFeHByZXNzaW9upDk7JCVfEBROU0Z1bmN0aW9uRXhwcmVzc2lvbgAIABEAGgAkACkAMgA3AEkATABRAFMAYABmAHEAewCKAJ0AqQCwALIAtAC2ALgAugDNANQA3wDhAOMA5QDsAPEA/AEFARwBIAE3AUQBTQFSAV0BXwFhAWMBagF0AXYBeAF6AYMBiAGnAasBsAG/AcMBywHQAeYB6wAAAAAAAAIBAAAAAAAAADwAAAAAAAAAAAAAAAAAAAIC - - imageURLs - - - - user - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIWHByZXZpZXdz0h8gMjNfEBxOU0tleVBhdGhTcGVjaWZpZXJFeHByZXNzaW9uozIkJdIfIDU2Xk5TTXV0YWJsZUFycmF5ozU3JVdOU0FycmF50h8gOTpfEBNOU0tleVBhdGhFeHByZXNzaW9upDk7JCVfEBROU0Z1bmN0aW9uRXhwcmVzc2lvbgAIABEAGgAkACkAMgA3AEkATABRAFMAYABmAHEAewCKAJ0AqQCwALIAtAC2ALgAugDNANQA3wDhAOMA5QDsAPEA/AEFARwBIAE3AUQBTQFSAV0BXwFhAWMBagF0AXYBeAF6AYMBiAGnAasBsAG/AcMBywHQAeYB6wAAAAAAAAIBAAAAAAAAADwAAAAAAAAAAAAAAAAAAAIC - - previewURLs - - - - lastOpenDate - - - - quickSearchWords - - - - title - - - - title - - - - sizeCount - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIVHRhZ3PSHyAyM18QHE5TS2V5UGF0aFNwZWNpZmllckV4cHJlc3Npb26jMiQl0h8gNTZeTlNNdXRhYmxlQXJyYXmjNTclV05TQXJyYXnSHyA5Ol8QE05TS2V5UGF0aEV4cHJlc3Npb26kOTskJV8QFE5TRnVuY3Rpb25FeHByZXNzaW9uAAgAEQAaACQAKQAyADcASQBMAFEAUwBgAGYAcQB7AIoAnQCpALAAsgC0ALYAuAC6AM0A1ADfAOEA4wDlAOwA8QD8AQUBHAEgATcBRAFNAVIBXQFfAWEBYwFqAXQBdgF4AXoBfwGEAaMBpwGsAbsBvwHHAcwB4gHnAAAAAAAAAgEAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAf4= - - tagStrings - - - - language - - - - uploader - - - - gid - - - - rating - - - - globalFilter - - - - pageCount - - - - tagTranslator - - - - historyKeywords - - - - galleryURL - - - - searchFilter - - - - userRating - - - - tagStrings - - - \ No newline at end of file diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index e4e3b2b31..43ff052f7 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -5,7 +5,6 @@ import ComposableArchitecture import URLClient import ApplicationClient import HapticsClient -import DatabaseClient import NetworkingFeature import CookieClient import AppComponents @@ -85,7 +84,6 @@ public struct CommentsReducer: Sendable { } @Dependency(\.applicationClient) private var applicationClient - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient @Dependency(\.urlClient) private var urlClient diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index 0b2d72117..89aa77126 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -5,7 +5,6 @@ import Foundation import ComposableArchitecture import AppComponents import HapticsClient -import DatabaseClient import NetworkingFeature import DownloadClient import CookieClient @@ -179,7 +178,6 @@ public struct DetailReducer: Sendable { case anyGalleryOpsDone(Result) } - @Dependency(\.databaseClient) var databaseClient @Dependency(\.downloadClient) var downloadClient @Dependency(\.hapticsClient) var hapticsClient @Dependency(\.cookieClient) var cookieClient diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index e0dd6030c..d696876ee 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -3,7 +3,6 @@ import ComposableArchitecture import AppModels import Sharing import HapticsClient -import DatabaseClient import NetworkingFeature import FiltersFeature import QuickSearchFeature @@ -62,7 +61,6 @@ public struct DetailSearchReducer: Sendable { case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient public init() {} diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index 339b1d434..d64e47035 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -4,7 +4,6 @@ import Sharing import ComposableArchitecture import AppTools import HapticsClient -import DatabaseClient import NetworkingFeature import DownloadClient import ReadingFeature @@ -66,7 +65,6 @@ public struct PreviewsReducer: Sendable { case fetchPreviewURLsDone(Result<[Int: URL], AppError>) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.date) private var date diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index cd64891d8..7e72db4d9 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -4,7 +4,6 @@ import AppModels import IdentifiedCollections import ComposableArchitecture import HapticsClient -import DatabaseClient import NetworkingFeature import DownloadClient import DeviceClient @@ -93,7 +92,6 @@ public struct FavoritesReducer: Sendable { case performDateSeekDone(Int, Result) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.deviceClient) private var deviceClient @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index 2d3f09fc9..cbdb0d61c 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -4,7 +4,6 @@ import Sharing import Foundation import AppTools import HapticsClient -import DatabaseClient import NetworkingFeature import FiltersFeature import DateSeekFeature @@ -65,7 +64,6 @@ public struct FrontpageReducer: Sendable { case performDateSeekDone(Result) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient public init() {} diff --git a/AppPackage/Sources/HomeFeature/HomeReducer.swift b/AppPackage/Sources/HomeFeature/HomeReducer.swift index 20fb3fb6b..d2f1d5858 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer.swift @@ -4,7 +4,6 @@ import Kingfisher import ComposableArchitecture import AppTools import LibraryClient -import DatabaseClient import DeviceClient @Reducer @@ -76,7 +75,6 @@ public struct HomeReducer: Sendable { case fetchToplistsGalleriesDone(Int, Result<(PageNumber, [Gallery]), AppError>) } - @Dependency(\.databaseClient) var databaseClient @Dependency(\.deviceClient) var deviceClient @Dependency(\.libraryClient) var libraryClient diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index 54eb75796..9071cfc3d 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -3,7 +3,6 @@ import AppModels import Sharing import AppTools import HapticsClient -import DatabaseClient import NetworkingFeature import FiltersFeature @@ -47,7 +46,6 @@ public struct PopularReducer: Sendable { case fetchGalleriesDone(Result<[Gallery], AppError>) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient public init() {} diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index 22a306089..4689dd12b 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -4,7 +4,6 @@ import AppTools import AppComponents import Resources import HapticsClient -import DatabaseClient import NetworkingFeature @Reducer @@ -77,7 +76,6 @@ public struct ToplistsReducer: Sendable { case fetchMoreGalleriesDone(ToplistsType, Result<(PageNumber, [Gallery]), AppError>) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient public init() {} diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index af2c977b9..0a56b0e54 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -3,7 +3,6 @@ import ComposableArchitecture import AppModels import Sharing import HapticsClient -import DatabaseClient import NetworkingFeature import DownloadClient import FiltersFeature @@ -69,7 +68,6 @@ public struct WatchedReducer: Sendable { case performDateSeekDone(Result) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index e8c9bfd82..e45af6d94 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -5,7 +5,6 @@ import ComposableArchitecture import URLClient import HapticsClient import ImageClient -import DatabaseClient import NetworkingFeature import DownloadClient import ClipboardClient @@ -190,7 +189,6 @@ public struct ReadingReducer: Sendable { @Dependency(\.appDelegateClient) var appDelegateClient @Dependency(\.clipboardClient) var clipboardClient - @Dependency(\.databaseClient) var databaseClient @Dependency(\.downloadClient) var downloadClient @Dependency(\.hapticsClient) var hapticsClient @Dependency(\.cookieClient) var cookieClient diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index 708d11496..67a43e9ed 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -4,7 +4,6 @@ import AppModels import Sharing import Foundation import HapticsClient -import DatabaseClient import NetworkingFeature import DownloadClient import FiltersFeature @@ -74,7 +73,6 @@ public struct SearchReducer: Sendable { case performDateSeekDone(Result) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index ca4a665fb..9fd6faeef 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -5,7 +5,6 @@ import ComposableArchitecture import AuthorizationClient import ApplicationClient import LibraryClient -import DatabaseClient import OSLogExt import AppComponents @@ -66,7 +65,6 @@ public struct GeneralSettingReducer: Sendable { @Dependency(\.authorizationClient) private var authorizationClient @Dependency(\.applicationClient) private var applicationClient - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.libraryClient) private var libraryClient public init() {} diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index 335625ea8..d208956d0 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -6,7 +6,6 @@ import UserDefaultsClient import ApplicationClient import HapticsClient import LibraryClient -import DatabaseClient import DFClient import FileClient import CookieClient @@ -129,7 +128,6 @@ public struct SettingReducer: Sendable { @Dependency(\.applicationClient) var applicationClient @Dependency(\.userDefaultsClient) var userDefaultsClient @Dependency(\.appDelegateClient) var appDelegateClient - @Dependency(\.databaseClient) var databaseClient @Dependency(\.libraryClient) var libraryClient @Dependency(\.hapticsClient) var hapticsClient @Dependency(\.cookieClient) var cookieClient diff --git a/AppPackage/Tests/DownloadsFeatureTests/DatabaseClientUpdateTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DatabaseClientUpdateTests.swift deleted file mode 100644 index 8d6159050..000000000 --- a/AppPackage/Tests/DownloadsFeatureTests/DatabaseClientUpdateTests.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation -import Testing -import DatabaseClient -@testable import AppFeature - -struct DatabaseClientUpdateTests: DownloadFeatureTestCase { - @MainActor - @Test - func testURLUpdatesMergePageBatches() async throws { - let container = try makeInMemoryContainer() - let databaseClient = DatabaseClient.live(persistenceContainer: container) - let gid = "123456" - - let imageURLs = try urlBatches(path: "image") - let originalImageURLs = try urlBatches(path: "original") - let thumbnailURLs = try urlBatches(path: "thumbnail") - let previewURLs = try urlBatches(path: "preview") - - databaseClient.updateImageURLs( - gid: gid, - imageURLs: imageURLs.first, - originalImageURLs: originalImageURLs.first - ) - databaseClient.updateThumbnailURLs(gid: gid, thumbnailURLs: thumbnailURLs.first) - databaseClient.updatePreviewURLs(gid: gid, previewURLs: previewURLs.first) - - databaseClient.updateImageURLs( - gid: gid, - imageURLs: imageURLs.second, - originalImageURLs: originalImageURLs.second - ) - databaseClient.updateThumbnailURLs(gid: gid, thumbnailURLs: thumbnailURLs.second) - databaseClient.updatePreviewURLs(gid: gid, previewURLs: previewURLs.second) - - let galleryState = try #require(await databaseClient.fetchGalleryState(gid: gid)) - - #expect(galleryState.imageURLs == imageURLs.merged) - #expect(galleryState.originalImageURLs == originalImageURLs.merged) - #expect(galleryState.thumbnailURLs == thumbnailURLs.merged) - #expect(galleryState.previewURLs == previewURLs.merged) - } - - private func urlBatches( - path: String - ) throws -> URLBatches { - let first = [ - 1: try #require(URL(string: "https://example.com/\(path)-1.jpg")), - 2: try #require(URL(string: "https://example.com/\(path)-2.jpg")) - ] - let second = [ - 3: try #require(URL(string: "https://example.com/\(path)-3.jpg")), - 4: try #require(URL(string: "https://example.com/\(path)-4.jpg")) - ] - - return .init( - first: first, - second: second, - merged: first.merging(second, uniquingKeysWith: { _, new in new }) - ) - } -} - -private struct URLBatches { - let first: [Int: URL] - let second: [Int: URL] - let merged: [Int: URL] -} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift index fad7f0ed1..881485f83 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift @@ -4,7 +4,6 @@ import AppModels import ComposableArchitecture import Testing import HapticsClient -import DatabaseClient import DownloadClient import CookieClient import AppLaunchAutomationClient @@ -206,7 +205,6 @@ private extension DetailReducerDownloadTests { $0.downloadClient.fetchFolders = { folders() } $0.downloadClient.createFolder = createFolder $0.hapticsClient = .noop - $0.databaseClient = .noop $0.cookieClient = .noop if let automationGID { $0.appLaunchAutomationClient = appLaunchAutomationClient( diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift index 34e65f167..37cd5c190 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift @@ -3,7 +3,6 @@ import AppModels import ComposableArchitecture import Testing import HapticsClient -import DatabaseClient import NetworkingFeature import DownloadClient import CookieClient @@ -152,7 +151,6 @@ private extension DetailReducerMetadataTests { $0.downloadClient.loadManifest = { _ in throw AppError.notFound } $0.downloadClient.loadLocalPageURLs = { _ in [:] } $0.hapticsClient = .noop - $0.databaseClient = .noop $0.cookieClient = .noop } ) @@ -193,7 +191,6 @@ private extension DetailReducerMetadataTests { $0.downloadClient.loadManifest = { _ in throw AppError.notFound } $0.downloadClient.loadLocalPageURLs = { _ in [:] } $0.hapticsClient = .noop - $0.databaseClient = .noop $0.cookieClient = .noop } ) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift index 42b352566..3ac8cf559 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift @@ -3,7 +3,6 @@ import AppModels import ComposableArchitecture import Testing import HapticsClient -import DatabaseClient import DownloadClient import CookieClient @testable import DetailFeature @@ -98,7 +97,6 @@ struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { withDependencies: { $0.downloadClient = makeDeleteTestClient(download: download) $0.hapticsClient = .noop - $0.databaseClient = .noop $0.cookieClient = .noop } ) @@ -155,7 +153,6 @@ private extension DetailReducerMetadataUpdateTests { $0.downloadClient.loadManifest = { _ in throw AppError.notFound } $0.downloadClient.loadLocalPageURLs = { _ in [:] } $0.hapticsClient = .noop - $0.databaseClient = .noop $0.cookieClient = .noop } ) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift index 2233abddd..c8ad819b1 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift @@ -3,7 +3,6 @@ import AppModels import ComposableArchitecture import Testing import HapticsClient -import DatabaseClient import DownloadClient import CookieClient @testable import DetailFeature @@ -89,7 +88,6 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { withDependencies: { $0.downloadClient = makeLocalManifestClient(download: download, manifest: manifest) $0.hapticsClient = .noop - $0.databaseClient = .noop $0.cookieClient = .noop } ) @@ -117,7 +115,6 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { withDependencies: { $0.downloadClient = makeNoManifestClient() $0.hapticsClient = .noop - $0.databaseClient = .noop $0.cookieClient = .noop } ) @@ -159,7 +156,6 @@ private extension DetailReducerObserveTests { $0.downloadClient.fetchVersionMetadata = { _, _ in nil } $0.downloadClient.fetchFolders = { [] } $0.hapticsClient = .noop - $0.databaseClient = .noop $0.cookieClient = .noop $0.date = .constant(.init(timeIntervalSince1970: 0)) } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift index f184dcd2b..a86e26305 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift @@ -3,7 +3,6 @@ import AppModels import ComposableArchitecture import Testing import HapticsClient -import DatabaseClient import DownloadClient import CookieClient import AppLaunchAutomationClient @@ -34,7 +33,6 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { ) $0.downloadClient = .noop $0.hapticsClient = .noop - $0.databaseClient = .noop $0.cookieClient = .noop } ) @@ -83,7 +81,6 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { $0.downloadClient.delete = { _ in } $0.downloadClient.loadManifest = { _ in throw AppError.notFound } $0.hapticsClient = .noop - $0.databaseClient = .noop $0.cookieClient = .noop } ) @@ -170,7 +167,6 @@ private extension DetailReducerPauseAndGuardTests { $0.downloadClient.loadLocalPageURLs = { _ in [:] } $0.downloadClient.fetchVersionMetadata = { _, _ in nil } $0.hapticsClient = .noop - $0.databaseClient = .noop $0.cookieClient = .noop } ) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift index 55cd0fc72..26ae59e05 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift @@ -9,7 +9,6 @@ import UserDefaultsClient import ApplicationClient import HapticsClient import LibraryClient -import DatabaseClient import DFClient import DownloadClient import FileClient @@ -192,7 +191,6 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { withDependencies: { $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) $0.cookieClient = cookieClient - $0.databaseClient = .noop $0.deviceClient = .noop $0.hapticsClient = .noop $0.applicationClient = .noop @@ -242,7 +240,6 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { withDependencies: { $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) $0.cookieClient = cookieClient - $0.databaseClient = .noop $0.deviceClient = .noop $0.hapticsClient = .noop $0.applicationClient = .noop @@ -301,7 +298,6 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { withDependencies: { $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) $0.cookieClient = cookieClient - $0.databaseClient = .noop $0.deviceClient = .noop $0.hapticsClient = .noop $0.applicationClient = .noop diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestFactories.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestFactories.swift index e69bacec1..8db79af1a 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestFactories.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestFactories.swift @@ -1,14 +1,12 @@ -import CoreData import AppModels import Foundation import Testing import AppTools -import DatabaseClient import DownloadClient import AppLaunchAutomationClient @testable import AppFeature -// MARK: - Sample Data Factories & CoreData Helpers +// MARK: - Sample Data Factories enum DownloadFixtureStatus { case queued @@ -258,57 +256,6 @@ extension DownloadFeatureTestCase { ) return folderURL } - - func makeInMemoryContainer() throws -> NSPersistentContainer { - let container = NSPersistentContainer( - name: UUID().uuidString, - managedObjectModel: PersistenceController.shared.container.managedObjectModel - ) - let description = NSPersistentStoreDescription() - description.type = NSInMemoryStoreType - container.persistentStoreDescriptions = [description] - let semaphore = DispatchSemaphore(value: 0) - var loadError: Error? - container.loadPersistentStores { _, error in - loadError = error - semaphore.signal() - } - let waitResult = semaphore.wait(timeout: .now() + 5) - if waitResult == .timedOut { - Issue.record("Timed out loading in-memory persistent store.") - } - if let loadError { - Issue.record( - "Failed to load in-memory persistent store: \(loadError)" - ) - } - return container - } - - func insertPersistedGalleryState( - in container: NSPersistentContainer, - gid: String, - previewURLs: [Int: URL] = [:], - imageURLs: [Int: URL], - originalImageURLs: [Int: URL] = [:] - ) throws { - let context = container.viewContext - try performAndWait(in: context) { - let object = GalleryStateMO(context: context) - object.gid = gid - object.previewURLs = previewURLs.toData() - object.imageURLs = imageURLs.toData() - object.originalImageURLs = originalImageURLs.toData() - try context.save() - } - } - - private func performAndWait( - in context: NSManagedObjectContext, - operation: @Sendable () throws -> Void - ) throws { - try context.performAndWait(operation) - } } // MARK: - Stub Handler Content diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestHelpers.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestHelpers.swift index b034315bc..1505c5399 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestHelpers.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestHelpers.swift @@ -2,7 +2,6 @@ import TestingSupport import AppTools import Foundation import AppModels -import CoreData import ComposableArchitecture import Kingfisher import UIKit @@ -53,14 +52,6 @@ protocol DownloadFeatureTestCase: TestHelper { download: DownloadedGallery, manifest: DownloadManifest ) throws -> URL - func makeInMemoryContainer() throws -> NSPersistentContainer - func insertPersistedGalleryState( - in container: NSPersistentContainer, - gid: String, - previewURLs: [Int: URL], - imageURLs: [Int: URL], - originalImageURLs: [Int: URL] - ) throws } // MARK: - Default Implementations diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadImageErrorTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageErrorTests.swift index 3ad1a027a..2309f287e 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadImageErrorTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageErrorTests.swift @@ -1,4 +1,3 @@ -import CoreData import AppModels import Foundation import Testing diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingCacheTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingCacheTests.swift index e9551fdf2..19eb28a54 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingCacheTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingCacheTests.swift @@ -1,4 +1,3 @@ -import CoreData import AppModels import Kingfisher import UIKit diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingTests.swift index 8e9dcc232..f161e4b8f 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingTests.swift @@ -1,4 +1,3 @@ -import CoreData import AppModels import Kingfisher import UIKit diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift index 8770b6d73..51d3d7fb7 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift @@ -6,7 +6,6 @@ import AppTools import URLClient import HapticsClient import ImageClient -import DatabaseClient import DownloadClient import ClipboardClient import CookieClient @@ -43,7 +42,6 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { $0.appDelegateClient = .noop $0.clipboardClient = .noop $0.cookieClient = .noop - $0.databaseClient = .noop $0.deviceClient = .noop $0.downloadClient = .noop $0.hapticsClient = .noop @@ -177,7 +175,6 @@ private extension DownloadObserverReadingTests { $0.appDelegateClient = .noop $0.clipboardClient = .noop $0.cookieClient = .noop - $0.databaseClient = .noop $0.deviceClient = .noop $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { stream } @@ -228,7 +225,6 @@ private extension DownloadObserverReadingTests { loadCount.value += 1 return [:] } - $0.databaseClient = .noop $0.hapticsClient = .noop } ) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift index d11a141a8..57169bc87 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift @@ -5,7 +5,6 @@ import Testing import URLClient import HapticsClient import ImageClient -import DatabaseClient import DownloadClient import ClipboardClient import CookieClient @@ -113,7 +112,6 @@ private extension DownloadObserverRefreshTests { $0.appDelegateClient = .noop $0.clipboardClient = .noop $0.cookieClient = .noop - $0.databaseClient = .noop $0.deviceClient = .noop $0.downloadClient = makeObserveDownloadClient( stream: stream, loadLocalPageURLs: loadLocalPageURLs @@ -139,7 +137,6 @@ private extension DownloadObserverRefreshTests { $0.downloadClient = makeObserveDownloadClient( stream: stream, loadLocalPageURLs: loadLocalPageURLs ) - $0.databaseClient = .noop $0.hapticsClient = .noop } ) diff --git a/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift index 5916bee37..c5bf98e7a 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift @@ -3,7 +3,6 @@ import AppModels import ComposableArchitecture import Testing import HapticsClient -import DatabaseClient import DownloadClient @testable import DetailFeature @testable import AppFeature @@ -107,7 +106,6 @@ private extension PreviewsReducerDownloadTests { guard gid == download.gid else { throw AppError.notFound } return (download, manifest) } - $0.databaseClient = .noop $0.hapticsClient = .noop } ) @@ -146,7 +144,6 @@ private extension PreviewsReducerDownloadTests { reducer: PreviewsReducer.init, withDependencies: { $0.downloadClient = downloadClient - $0.databaseClient = .noop $0.hapticsClient = .noop } ) diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift index 03b838d87..346a9e05c 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift @@ -5,7 +5,6 @@ import Testing import URLClient import HapticsClient import ImageClient -import DatabaseClient import DownloadClient import ClipboardClient import CookieClient @@ -139,7 +138,6 @@ private extension ReadingReducerDownloadTests { $0.appDelegateClient = .noop $0.clipboardClient = .noop $0.cookieClient = .noop - $0.databaseClient = .noop $0.deviceClient = .noop $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { $0.yield([]); $0.finish() } } @@ -175,7 +173,6 @@ private extension ReadingReducerDownloadTests { $0.appDelegateClient = .noop $0.clipboardClient = .noop $0.cookieClient = .noop - $0.databaseClient = .noop $0.deviceClient = .noop $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { AsyncStream { $0.finish() } } diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift index eece044ad..d6e52fad2 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift @@ -6,7 +6,6 @@ import AppTools import URLClient import HapticsClient import ImageClient -import DatabaseClient import DownloadClient import ClipboardClient import CookieClient @@ -52,7 +51,6 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { $0.appDelegateClient = .noop $0.clipboardClient = .noop $0.cookieClient = .noop - $0.databaseClient = .noop $0.deviceClient = .noop $0.downloadClient = DownloadClient() $0.downloadClient.observeDownloads = { @@ -118,7 +116,6 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { $0.appDelegateClient = .noop $0.clipboardClient = .noop $0.cookieClient = .noop - $0.databaseClient = .noop $0.deviceClient = .noop $0.hapticsClient = .noop $0.imageClient = .noop diff --git a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift index 2391c1be6..ff513a15a 100644 --- a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift @@ -124,7 +124,6 @@ struct SettingReducerNavigationTests { let imported = TagTranslator(hasCustomTranslations: true) let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { $0.fileClient.importTagTranslator = { _ in .success(imported) } - $0.databaseClient = .noop } await store.send(.settingRowTapped(.general)) { From bfcb220f7a0bda9fd4bfd2ac399faa371df0a489 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 22:41:18 +0800 Subject: [PATCH 526/614] Add limit notices to history and quick search --- .../HomeFeature/History/HistoryView.swift | 12 ++ .../Resources/Localizable.xcstrings | 137 ++++++++++++++++++ .../QuickSearchFeature/QuickSearchView.swift | 12 ++ .../Resources/Localizable.xcstrings | 137 ++++++++++++++++++ 4 files changed, 298 insertions(+) diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index 9243ace1f..ab835ac16 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -39,6 +39,7 @@ struct HistoryView: View { }, downloadBadges: store.downloadBadges ) + .safeAreaInset(edge: .top, spacing: 0) { historyLimitBanner } .searchable(text: $store.keyword, prompt: .filter) .onAppear { store.send(.onAppear) @@ -52,6 +53,17 @@ struct HistoryView: View { .navigationTitle(.history) } + // Always-visible notice: only the most-recent records survive the launch-time prune. + private var historyLimitBanner: some View { + Text(.historyLimitDescription(limit: GalleryHistoryEntry.historyCap)) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.bar) + } + private func toolbar() -> some ToolbarContent { CustomToolbarItem { Button { diff --git a/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings index 78133707a..07f1f4782 100644 --- a/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings @@ -165,6 +165,143 @@ } } }, + "history_limit_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only the latest %#@limit@ records are preserved." + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es werden nur die neuesten %#@limit@ Einträge aufbewahrt." + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新の %#@limit@ 件のみが保存されます。" + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근 %#@limit@개의 기록만 보관됩니다." + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅保留最近的 %#@limit@ 条记录。" + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "僅保留最近的 %#@limit@ 筆記錄。" + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } + }, "home_misc_grid_type.history": { "extractionState": "manual", "localizations": { diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index 8ae806c97..84e417cd5 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -60,6 +60,7 @@ public struct QuickSearchView: View { ErrorView(error: .notFound) .opacity(store.quickSearchWords.isEmpty ? 1 : 0) } + .safeAreaInset(edge: .top, spacing: 0) { wordLimitBanner } .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) @@ -73,6 +74,17 @@ public struct QuickSearchView: View { } } + // Always-visible notice: the word list is capped and the add button disables at the limit. + private var wordLimitBanner: some View { + Text(.wordLimitDescription(limit: QuickSearchReducer.wordLimit)) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.bar) + } + private func onTextFieldSubmitted() { switch focusedField { case .name: diff --git a/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings index 79aa45a3a..a0f5e7c0f 100644 --- a/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings @@ -205,6 +205,143 @@ } } } + }, + "word_limit_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Up to %#@limit@ words can be saved." + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es können bis zu %#@limit@ Suchbegriffe gespeichert werden." + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最大 %#@limit@ 件のキーワードを保存できます。" + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최대 %#@limit@개의 키워드를 저장할 수 있습니다." + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最多可保存 %#@limit@ 个关键词。" + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最多可儲存 %#@limit@ 個關鍵字。" + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } } }, "version": "1.0" From 06a082989b827b855def9148136ebe3ce828a053 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 5 Jul 2026 23:55:41 +0800 Subject: [PATCH 527/614] Rebuild tag translator from cached raw JSON --- AppPackage/Package.swift | 2 +- .../AppModels/Persistence/AppSharedKeys.swift | 20 +++-- .../AppModels/Tags/TagTranslatorInfo.swift | 38 ++++++++++ .../AppModels/Tags/TagTranslatorPayload.swift | 13 ++++ .../AppModels/Tags/TranslatableLanguage.swift | 7 ++ .../Sources/FileClient/FileClient.swift | 75 +++++++++++++++++-- .../Sources/NetworkingFeature/Request.swift | 19 +---- .../SettingFeature/SettingReducer+Body.swift | 31 ++++++-- .../SettingReducer+Helpers.swift | 34 ++++++--- .../SettingFeature/SettingReducer.swift | 12 ++- .../FileClientTests/FileClientTests.swift | 68 +++++++++++++++-- .../SettingReducerNavigationTests.swift | 7 +- 12 files changed, 266 insertions(+), 60 deletions(-) create mode 100644 AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift create mode 100644 AppPackage/Sources/AppModels/Tags/TagTranslatorPayload.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index f6692e5c0..0f5f37a5b 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -347,6 +347,7 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.appTools), + .module(.openCCExt), .targetDependency(.composableArchitecture) ], plugins: swiftLintPlugins @@ -430,7 +431,6 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.appTools), - .module(.openCCExt), .module(.osLogExt), .module(.parserFeature), .targetDependency(.composableArchitecture), diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift index d756124db..df4745d30 100644 --- a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -17,10 +17,11 @@ import Sharing // + defaults): additive changes never invalidate an existing persisted value, and a decode // failure falls back to the key's default — there is no store-fails-to-open failure mode. // -// The one exception is the tag-translation table (`tagTranslator`): it is multi-megabyte, far too -// large for the UserDefaults domain, so it uses the `fileStorage` strategy (a JSON file) instead. -// Everything else fits app storage. Web images keep their own caches. `appStorage` keys must not -// contain `.` or `@`. +// Nothing here uses the `fileStorage` strategy. The tag-translation table is the only large +// artifact, and it is deliberately NOT persisted through Sharing: only its thin +// `tagTranslatorInfo` metadata lives in app storage, while the multi-megabyte translations are a +// plain, re-downloadable cache file rebuilt at launch (see `TagTranslatorInfo`). Web images keep +// their own caches. `appStorage` keys must not contain `.` or `@`. // MARK: Account & preferences @@ -64,14 +65,11 @@ extension SharedKey where Self == AppStorageKey<[QuickSearchWord]>.Default { } } -// MARK: Tag translations (large — file-backed rather than in the defaults domain) +// MARK: Tag translations (thin metadata only — the table itself is a rebuilt cache file) -extension SharedKey where Self == FileStorageKey.Default { - public static var tagTranslator: Self { - Self[ - .fileStorage(.applicationSupportDirectory.appending(component: "tagTranslator.json")), - default: TagTranslator() - ] +extension SharedKey where Self == AppStorageKey.Default { + public static var tagTranslatorInfo: Self { + Self[.appStorage("tagTranslatorInfo"), default: TagTranslatorInfo()] } } diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift new file mode 100644 index 000000000..9e4a038f2 --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Thin, `appStorage`-persisted metadata for the tag-translation table. +/// +/// The translations dictionary itself is NOT persisted — it is large, derived, and +/// re-downloadable, so it lives only in memory and is rebuilt at launch from the cached raw JSON +/// (Caches for a remote download, Application Support for a user import). This record remembers just +/// enough to rebuild the right file and to let the update check know what it already has: which +/// language/version is cached and whether a custom import is active. +public struct TagTranslatorInfo: Codable, Equatable, Sendable { + public init( + schemaVersion: Int = 1, + language: TranslatableLanguage? = nil, + updatedDate: Date = .distantPast, + hasCustomTranslations: Bool = false + ) { + self.schemaVersion = schemaVersion + self.language = language + self.updatedDate = updatedDate + self.hasCustomTranslations = hasCustomTranslations + } + public var schemaVersion: Int + public var language: TranslatableLanguage? + public var updatedDate: Date + public var hasCustomTranslations: Bool +} + +// MARK: Manually decode +extension TagTranslatorInfo { + public init(from decoder: Decoder) { + let container = try? decoder.container(keyedBy: CodingKeys.self) + schemaVersion = (try? container?.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 + language = (try? container?.decodeIfPresent(TranslatableLanguage.self, forKey: .language)) ?? nil + updatedDate = (try? container?.decodeIfPresent(Date.self, forKey: .updatedDate)) ?? .distantPast + hasCustomTranslations = + (try? container?.decodeIfPresent(Bool.self, forKey: .hasCustomTranslations)) ?? false + } +} diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslatorPayload.swift b/AppPackage/Sources/AppModels/Tags/TagTranslatorPayload.swift new file mode 100644 index 000000000..2070d4d1b --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/TagTranslatorPayload.swift @@ -0,0 +1,13 @@ +import Foundation + +/// The raw result of a tag-translation download: the untouched DB JSON bytes plus the release date +/// resolved from the GitHub metadata. Decoding, OpenCC conversion, and caching happen downstream +/// (see `FileClient.cacheAndBuildRemoteTagTranslator`) so the request layer stays purely network. +public struct TagTranslatorPayload: Sendable, Equatable { + public init(data: Data, updatedDate: Date) { + self.data = data + self.updatedDate = updatedDate + } + public let data: Data + public let updatedDate: Date +} diff --git a/AppPackage/Sources/AppModels/Tags/TranslatableLanguage.swift b/AppPackage/Sources/AppModels/Tags/TranslatableLanguage.swift index 1f92f2786..9b7f340fb 100644 --- a/AppPackage/Sources/AppModels/Tags/TranslatableLanguage.swift +++ b/AppPackage/Sources/AppModels/Tags/TranslatableLanguage.swift @@ -43,4 +43,11 @@ extension TranslatableLanguage { return "db.raw.json" } } + /// Filesystem-safe name for this language's cached raw translation JSON. Keyed on + /// `languageCode` (not `repoName`, which contains `/`), so each language gets its own file — + /// `simplifiedChinese`/`traditionalChinese` share a repo but need distinct caches (the latter + /// is OpenCC-converted). + public var cachedTranslationsFilename: String { + "tagTranslations-\(languageCode).json" + } } diff --git a/AppPackage/Sources/FileClient/FileClient.swift b/AppPackage/Sources/FileClient/FileClient.swift index c1eb4ec44..8c32febff 100644 --- a/AppPackage/Sources/FileClient/FileClient.swift +++ b/AppPackage/Sources/FileClient/FileClient.swift @@ -1,10 +1,50 @@ import AppModels import Foundation +import OpenCCExt import ComposableArchitecture public struct FileClient: Sendable { public var createFile: @Sendable (String, Data?) -> Bool public var importTagTranslator: @Sendable (URL) async -> Result + /// Decodes the raw downloaded DB JSON, applies OpenCC conversion for Traditional Chinese, caches + /// the raw bytes for a launch-time rebuild, and returns the built translator (`nil` on decode + /// failure). The raw file — not the converted dictionary — is what persists. + public var cacheAndBuildRemoteTagTranslator: @Sendable (Data, TranslatableLanguage, Date) -> TagTranslator? + /// Rebuilds the in-memory translator from the cached raw JSON described by `info` — Application + /// Support for a custom import, Caches for a remote download. `nil` if the cache is missing. + public var loadCachedTagTranslator: @Sendable (TagTranslatorInfo) -> TagTranslator? +} + +// Fixed name for a user-imported table, kept in Application Support because it cannot be +// re-downloaded (unlike a remote table, which lives in purgeable Caches). +private let customTranslationsFilename = "tagTranslations-custom.json" + +private var customTranslationsURL: URL { + .applicationSupportDirectory.appending(component: customTranslationsFilename) +} +private func remoteTranslationsURL(_ language: TranslatableLanguage) -> URL { + .cachesDirectory.appending(component: language.cachedTranslationsFilename) +} + +// Decode raw DB JSON → flatten → OpenCC-convert for Traditional Chinese. `nil` if empty/undecodable. +private func decodeTranslations( + _ data: Data, applyingChtFor language: TranslatableLanguage? +) -> [String: TagTranslation]? { + guard var translations = try? JSONDecoder() + .decode(EhTagTranslationDatabaseResponse.self, from: data).tagTranslations, + !translations.isEmpty + else { return nil } + if language == .traditionalChinese { + translations = translations.chtConverted + } + return translations +} + +private func writeTranslations(_ data: Data, to url: URL) { + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try? data.write(to: url, options: .atomic) } extension FileClient { @@ -25,19 +65,38 @@ extension FileClient { defer { if didAccess { url.stopAccessingSecurityScopedResource() } } guard error == nil, let data = try? Data(contentsOf: intent.url), - let translations = try? JSONDecoder().decode( - EhTagTranslationDatabaseResponse.self, from: data - ).tagTranslations, - !translations.isEmpty + let translations = decodeTranslations(data, applyingChtFor: nil) else { continuation.resume(returning: .failure(.parseFailed)) return } + // Persist the raw bytes so a launch-time rebuild can restore the import. + writeTranslations(data, to: customTranslationsURL) continuation.resume( returning: .success(.init(hasCustomTranslations: true, translations: translations)) ) } } + }, + cacheAndBuildRemoteTagTranslator: { data, language, date in + guard let translations = decodeTranslations(data, applyingChtFor: language) else { return nil } + writeTranslations(data, to: remoteTranslationsURL(language)) + return TagTranslator(language: language, updatedDate: date, translations: translations) + }, + loadCachedTagTranslator: { info in + if info.hasCustomTranslations { + guard let data = try? Data(contentsOf: customTranslationsURL), + let translations = decodeTranslations(data, applyingChtFor: nil) + else { return nil } + return TagTranslator(hasCustomTranslations: true, translations: translations) + } + guard let language = info.language, + let data = try? Data(contentsOf: remoteTranslationsURL(language)), + let translations = decodeTranslations(data, applyingChtFor: language) + else { return nil } + return TagTranslator( + language: language, updatedDate: info.updatedDate, translations: translations + ) } ) @@ -65,13 +124,17 @@ extension DependencyValues { extension FileClient { public static let noop: Self = .init( createFile: { _, _ in false }, - importTagTranslator: { _ in .success(.init()) } + importTagTranslator: { _ in .success(.init()) }, + cacheAndBuildRemoteTagTranslator: { _, _, _ in nil }, + loadCachedTagTranslator: { _ in nil } ) public static func placeholder() -> Result { fatalError() } public static let unimplemented: Self = .init( createFile: IssueReporting.unimplemented(placeholder: placeholder()), - importTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()) + importTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()), + cacheAndBuildRemoteTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()), + loadCachedTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()) ) } diff --git a/AppPackage/Sources/NetworkingFeature/Request.swift b/AppPackage/Sources/NetworkingFeature/Request.swift index b878e3080..424fd5b56 100644 --- a/AppPackage/Sources/NetworkingFeature/Request.swift +++ b/AppPackage/Sources/NetworkingFeature/Request.swift @@ -3,7 +3,6 @@ import AppModels import Combine import Foundation import ComposableArchitecture -import OpenCCExt import AppTools import ParserFeature @@ -316,7 +315,9 @@ public struct TagTranslatorRequest: Request { return formatter } - public var publisher: AnyPublisher { + // Returns the untouched DB JSON bytes plus the release date; decoding, OpenCC conversion, and + // caching are done downstream by `FileClient` so this layer stays purely network. + public var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: URLUtil.githubAPI(repoName: language.repoName)) .genericRetry().tryMap { data, _ -> Date in guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -332,19 +333,7 @@ public struct TagTranslatorRequest: Request { URLSession.shared.dataTaskPublisher( for: URLUtil.githubDownload(repoName: language.repoName, fileName: language.remoteFilename) ) - .tryMap { data, _ in - let response = try JSONDecoder().decode( - EhTagTranslationDatabaseResponse.self, from: data - ) - var translations = response.tagTranslations - guard !translations.isEmpty else { throw AppError.parseFailed } - if language == .traditionalChinese { - translations = translations.chtConverted - } - return TagTranslator( - language: language, updatedDate: date, translations: translations - ) - } + .tryMap { data, _ in TagTranslatorPayload(data: data, updatedDate: date) } } .mapError(mapAppError) .eraseToAnyPublisher() diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index d77efe156..abb747673 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -27,6 +27,7 @@ extension SettingReducer { .send(.syncSetting) ] if state.setting.enablesTagsExtension { + effects.append(.send(.rebuildTagTranslator)) effects.append(.send(.fetchTagTranslator)) } return .merge(effects) @@ -220,12 +221,33 @@ extension SettingReducer { state.tagTranslatorLoadingState = .idle switch result { case .success(let tagTranslator): - state.$tagTranslator.withLock { $0 = tagTranslator } + state.tagTranslator = tagTranslator + state.$tagTranslatorInfo.withLock { + $0 = TagTranslatorInfo( + language: tagTranslator.language, + updatedDate: tagTranslator.updatedDate, + hasCustomTranslations: tagTranslator.hasCustomTranslations + ) + } case .failure(let error): state.tagTranslatorLoadingState = .failed(error) } return .none + // Offline rebuild of the in-memory table from the cached raw JSON described by the + // persisted metadata (custom import → Application Support, remote → Caches). + case .rebuildTagTranslator: + let info = state.tagTranslatorInfo + return .run { send in + if let tagTranslator = fileClient.loadCachedTagTranslator(info) { + await send(.tagTranslatorRebuilt(tagTranslator)) + } + } + + case .tagTranslatorRebuilt(let tagTranslator): + state.tagTranslator = tagTranslator + return .none + case .fetchEhProfileIndex: guard cookieClient.didLogin else { return .none } return .run { send in @@ -277,10 +299,9 @@ extension SettingReducer { } case .path(.element(id: _, action: .general(.onRemoveCustomTranslations))): - state.$tagTranslator.withLock { - $0.hasCustomTranslations = false - $0.translations = .init() - } + // Drop the custom table from memory and metadata; the launch/remote flow refills it. + state.tagTranslator = TagTranslator() + state.$tagTranslatorInfo.withLock { $0.hasCustomTranslations = false } return .none case .igneousRefreshed: diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift index 660a4d1ee..7db168b3f 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift @@ -10,8 +10,8 @@ private let logger = Logger(category: .init(describing: SettingReducer.self)) extension SettingReducer { func handleLoadUserSettings(_ state: inout State) -> Effect { - // `setting` loads from persisted storage into its working copy; `user` and `tagTranslator` - // are `@Shared` and auto-load, so nothing to copy for them here. + // `setting` loads from persisted storage into its working copy; `user` is `@Shared` and + // auto-loads. `tagTranslator` is in-memory and rebuilt from its cache below. @Shared(.setting) var storedSetting state.setting = storedSetting var effects: [Effect] = [ @@ -38,6 +38,8 @@ extension SettingReducer { ]) } if state.setting.enablesTagsExtension { + // Rebuild the table from cache first (offline, immediate), then check for a newer remote. + effects.append(.send(.rebuildTagTranslator)) effects.append(.send(.fetchTagTranslator)) } return .merge(effects) @@ -80,20 +82,32 @@ extension SettingReducer { func handleFetchTagTranslator(_ state: inout State) -> Effect { guard state.tagTranslatorLoadingState != .loading, - !state.tagTranslator.hasCustomTranslations, + !state.tagTranslatorInfo.hasCustomTranslations, let language = TranslatableLanguage.current else { return .none } state.tagTranslatorLoadingState = .loading - // A language switch resets the table; the write-through to `@Shared` is the assignment - // itself (no separate sync step). The subsequent request fetches the new language's data. - if state.tagTranslator.language != language { - state.$tagTranslator.withLock { $0 = TagTranslator(language: language) } + // A language switch resets the in-memory table and its persisted metadata; the request then + // downloads the new language's data from scratch. + if state.tagTranslatorInfo.language != language { + state.tagTranslator = TagTranslator(language: language) + state.$tagTranslatorInfo.withLock { $0 = TagTranslatorInfo(language: language) } } - let updatedDate = state.tagTranslator.updatedDate + let updatedDate = state.tagTranslatorInfo.updatedDate return .run { send in - let response = await TagTranslatorRequest(language: language, updatedDate: updatedDate).response() - await send(Action.fetchTagTranslatorDone(response)) + // Download the raw JSON, then let `FileClient` decode/convert/cache it into a translator. + switch await TagTranslatorRequest(language: language, updatedDate: updatedDate).response() { + case .success(let payload): + if let tagTranslator = fileClient.cacheAndBuildRemoteTagTranslator( + payload.data, language, payload.updatedDate + ) { + await send(.fetchTagTranslatorDone(.success(tagTranslator))) + } else { + await send(.fetchTagTranslatorDone(.failure(.parseFailed))) + } + case .failure(let error): + await send(.fetchTagTranslatorDone(.failure(error))) + } } } diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index d208956d0..3e140c8bb 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -51,11 +51,13 @@ public struct SettingReducer: Sendable { public struct State: Equatable, Sendable { // `setting` stays a working copy edited through `BindingReducer` (its `.onChange` handlers // enforce cross-field invariants and fire side effects); persistence is a write-through to - // `@Shared(.setting)` via `.syncSetting`. `user` and `tagTranslator` are not form-bound, so - // they are stored directly in `@Shared` (auto-loaded, mutated via `withLock`). The tag table - // is file-backed because it is large. + // `@Shared(.setting)` via `.syncSetting`. `user` is not form-bound, so it is stored directly + // in `@Shared`. `tagTranslator` is derived, re-downloadable data: it lives in memory only and + // is rebuilt at launch from the cached raw JSON; only its thin `tagTranslatorInfo` metadata + // persists (see `AppSharedKeys`). public var setting = Setting() - @Shared(.tagTranslator) public var tagTranslator: TagTranslator + public var tagTranslator = TagTranslator() + @Shared(.tagTranslatorInfo) public var tagTranslatorInfo: TagTranslatorInfo @Shared(.user) public var user: User public var hasLoadedInitialSetting = false @@ -118,6 +120,8 @@ public struct SettingReducer: Sendable { case fetchGreetingDone(Result) case fetchTagTranslator case fetchTagTranslatorDone(Result) + case rebuildTagTranslator + case tagTranslatorRebuilt(TagTranslator) case fetchEhProfileIndex case fetchEhProfileIndexDone(Result) case fetchFavoriteCategories diff --git a/AppPackage/Tests/FileClientTests/FileClientTests.swift b/AppPackage/Tests/FileClientTests/FileClientTests.swift index b1fde0a3c..ed4512fb8 100644 --- a/AppPackage/Tests/FileClientTests/FileClientTests.swift +++ b/AppPackage/Tests/FileClientTests/FileClientTests.swift @@ -5,7 +5,9 @@ import FileClient // Exercises the live importer's coordinated, security-scoped read (REV-1) against local files; // the iCloud download that coordination triggers is system behavior, smoke-tested manually. -@Suite +// Serialized: the tag-translation cache/import endpoints write fixed paths in the real Caches and +// Application Support directories, so parallel cases would race on the same files. +@Suite(.serialized) struct FileClientTests { private func writeTemporaryFile(_ data: Data) throws -> URL { let url = FileManager.default.temporaryDirectory @@ -14,13 +16,24 @@ struct FileClientTests { return url } - @Test - func importsValidTranslationFileViaCoordinatedRead() async throws { + private func sampleResponseData() throws -> Data { let response = EhTagTranslationDatabaseResponse( data: [.init(namespace: "female", data: ["tag": .init(name: "translated")])] ) - let url = try writeTemporaryFile(JSONEncoder().encode(response)) - defer { try? FileManager.default.removeItem(at: url) } + return try JSONEncoder().encode(response) + } + + private var customTranslationsURL: URL { + .applicationSupportDirectory.appending(component: "tagTranslations-custom.json") + } + + @Test + func importsValidTranslationFileViaCoordinatedRead() async throws { + let url = try writeTemporaryFile(try sampleResponseData()) + defer { + try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem(at: customTranslationsURL) + } let translator = try await FileClient.live.importTagTranslator(url).get() #expect(translator.hasCustomTranslations) @@ -35,4 +48,49 @@ struct FileClientTests { let result = await FileClient.live.importTagTranslator(url) #expect(result == .failure(.parseFailed)) } + + @Test + func cachesRemoteTableAndRebuildsItFromMetadata() throws { + let language = TranslatableLanguage.english + let cacheURL = URL.cachesDirectory.appending(component: language.cachedTranslationsFilename) + defer { try? FileManager.default.removeItem(at: cacheURL) } + + let built = try #require( + FileClient.live.cacheAndBuildRemoteTagTranslator(try sampleResponseData(), language, .distantPast) + ) + #expect(built.language == language) + #expect(built.translations.count == 1) + #expect(FileManager.default.fileExists(atPath: cacheURL.path)) + + // A launch-time rebuild restores the same table from the cached file the metadata points at. + let rebuilt = try #require(FileClient.live.loadCachedTagTranslator(TagTranslatorInfo(language: language))) + #expect(rebuilt.language == language) + #expect(rebuilt.translations.count == 1) + } + + @Test + func rebuildsCustomTableFromApplicationSupport() async throws { + let url = try writeTemporaryFile(try sampleResponseData()) + defer { + try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem(at: customTranslationsURL) + } + + _ = try await FileClient.live.importTagTranslator(url).get() + + let rebuilt = try #require( + FileClient.live.loadCachedTagTranslator(TagTranslatorInfo(hasCustomTranslations: true)) + ) + #expect(rebuilt.hasCustomTranslations) + #expect(rebuilt.translations.count == 1) + } + + @Test + func loadCachedTagTranslatorReturnsNilWhenCacheMissing() throws { + let language = TranslatableLanguage.japanese + try? FileManager.default.removeItem( + at: URL.cachesDirectory.appending(component: language.cachedTranslationsFilename) + ) + #expect(FileClient.live.loadCachedTagTranslator(TagTranslatorInfo(language: language)) == nil) + } } diff --git a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift index ff513a15a..124ebf25b 100644 --- a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift @@ -133,10 +133,11 @@ struct SettingReducerNavigationTests { let url = URL(filePath: "/tmp/tags.json") await store.send(.path(.element(id: id, action: .general(.onTranslationsFilePicked(url))))) - // The parent intercept runs `fileClient.importTagTranslator` and stores the result - // (write-through to `@Shared(.tagTranslator)`). + // The parent intercept runs `fileClient.importTagTranslator`, stores the (in-memory) table + // and records the custom-import flag in the persisted `tagTranslatorInfo`. await store.receive(\.fetchTagTranslatorDone) { - $0.$tagTranslator.withLock { $0 = imported } + $0.tagTranslator = imported + $0.$tagTranslatorInfo.withLock { $0 = TagTranslatorInfo(hasCustomTranslations: true) } } } From f4652b3a85d55c87ce41489aaf0c9992a423fd1e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 00:13:29 +0800 Subject: [PATCH 528/614] Add schemaVersion anchor to persisted models --- .../Sources/AppModels/Persistent/Filter.swift | 3 +++ .../Persistent/GalleryHistoryEntry.swift | 3 +++ .../AppModels/Persistent/Setting.swift | 3 +++ .../Sources/AppModels/Persistent/User.swift | 24 ++++++++++++++++++- .../Sources/AppModels/Support/Misc.swift | 3 +++ 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/AppPackage/Sources/AppModels/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift index 80a836c98..28151117c 100644 --- a/AppPackage/Sources/AppModels/Persistent/Filter.swift +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -59,6 +59,8 @@ public struct Filter: Codable, Equatable, Sendable { self.disableUploader = disableUploader self.disableTags = disableTags } + // Version anchor for future breaking migrations; additive changes ride the tolerant decoder. + public var schemaVersion = 1 public var doujinshi = false public var manga = false public var artistCG = false @@ -117,6 +119,7 @@ public struct Filter: Codable, Equatable, Sendable { extension Filter { public init(from decoder: Decoder) { let container = try? decoder.container(keyedBy: CodingKeys.self) + schemaVersion = (try? container?.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 doujinshi = (try? container?.decodeIfPresent(Bool.self, forKey: .doujinshi)) ?? false manga = (try? container?.decodeIfPresent(Bool.self, forKey: .manga)) ?? false artistCG = (try? container?.decodeIfPresent(Bool.self, forKey: .artistCG)) ?? false diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift index a5715b676..770f5aeb0 100644 --- a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift @@ -26,6 +26,8 @@ public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable { self.readingProgress = readingProgress } public var id: String { gid } + // Version anchor for future breaking migrations; additive changes ride the tolerant decoder. + public var schemaVersion = 1 public var gid: String public var token: String public var lastOpenDate: Date @@ -36,6 +38,7 @@ public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable { extension GalleryHistoryEntry { public init(from decoder: Decoder) { let container = try? decoder.container(keyedBy: CodingKeys.self) + schemaVersion = (try? container?.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 gid = (try? container?.decodeIfPresent(String.self, forKey: .gid)) ?? "" token = (try? container?.decodeIfPresent(String.self, forKey: .token)) ?? "" lastOpenDate = (try? container?.decodeIfPresent(Date.self, forKey: .lastOpenDate)) ?? .distantPast diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index 5739f31df..e8ae7a1be 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -57,6 +57,8 @@ public struct Setting: Codable, Equatable, Sendable { self.doubleTapScaleFactor = doubleTapScaleFactor self.bypassesSNIFiltering = bypassesSNIFiltering } + // Version anchor for future breaking migrations; additive changes ride the tolerant decoder. + public var schemaVersion = 1 // Account public var galleryHost: GalleryHost = .ehentai public var showsNewDawnGreeting = false @@ -253,6 +255,7 @@ extension ListDisplayMode { extension Setting { public init(from decoder: Decoder) { let container = try? decoder.container(keyedBy: CodingKeys.self) + schemaVersion = (try? container?.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 // Account galleryHost = (try? container?.decodeIfPresent(GalleryHost.self, forKey: .galleryHost)) ?? .ehentai showsNewDawnGreeting = (try? container?.decodeIfPresent(Bool.self, forKey: .showsNewDawnGreeting)) ?? false diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index 733d239e2..8e73aee45 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -21,6 +21,8 @@ public struct User: Codable, Equatable, Sendable { } public static let empty = User() + // Version anchor for future breaking migrations; additive changes ride the tolerant decoder. + public var schemaVersion = 1 public var displayName: String? public var avatarURL: URL? public var apikey: String? @@ -38,7 +40,7 @@ public struct User: Codable, Equatable, Sendable { // `greeting` is intentionally absent so Codable skips it (it keeps its `nil` default on decode). private enum CodingKeys: String, CodingKey { - case displayName, avatarURL, apikey, credits, galleryPoints, favoriteCategories + case schemaVersion, displayName, avatarURL, apikey, credits, galleryPoints, favoriteCategories } public func getFavoriteCategory(index: Int) -> String { @@ -50,6 +52,26 @@ public struct User: Codable, Equatable, Sendable { } } +// MARK: Manually decode +extension User { + // Tolerant decoding keeps an existing persisted value valid across future additive changes; a + // non-optional field like `schemaVersion` would otherwise fail synthesized decode of an older + // record. `greeting` is intentionally not decoded (absent from `CodingKeys`) and stays `nil`. + public init(from decoder: Decoder) { + guard let container = try? decoder.container(keyedBy: CodingKeys.self) else { + schemaVersion = 1 + return + } + schemaVersion = (try? container.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 + displayName = try? container.decodeIfPresent(String.self, forKey: .displayName) + avatarURL = try? container.decodeIfPresent(URL.self, forKey: .avatarURL) + apikey = try? container.decodeIfPresent(String.self, forKey: .apikey) + credits = try? container.decodeIfPresent(String.self, forKey: .credits) + galleryPoints = try? container.decodeIfPresent(String.self, forKey: .galleryPoints) + favoriteCategories = try? container.decodeIfPresent([Int: String].self, forKey: .favoriteCategories) + } +} + public enum FavoritesType: String, Codable, CaseIterable, Sendable { public static func getTypeFrom(index: Int) -> FavoritesType { FavoritesType.allCases.filter({ $0.index == index }).first ?? .all diff --git a/AppPackage/Sources/AppModels/Support/Misc.swift b/AppPackage/Sources/AppModels/Support/Misc.swift index 187ea5a3b..03c5d1232 100644 --- a/AppPackage/Sources/AppModels/Support/Misc.swift +++ b/AppPackage/Sources/AppModels/Support/Misc.swift @@ -146,6 +146,8 @@ public struct QuickSearchWord: Codable, Equatable, Identifiable, Sendable { } public static var empty: Self { .init(name: "", content: "") } + // Version anchor for future breaking migrations; additive changes ride the tolerant decoder. + public var schemaVersion = 1 public var id: UUID = .init() public var name: String public var content: String @@ -160,6 +162,7 @@ extension QuickSearchWord { // Tolerant decoding keeps an existing persisted list valid across future additive changes. public init(from decoder: Decoder) { let container = try? decoder.container(keyedBy: CodingKeys.self) + schemaVersion = (try? container?.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 id = (try? container?.decodeIfPresent(UUID.self, forKey: .id)) ?? .init() name = (try? container?.decodeIfPresent(String.self, forKey: .name)) ?? "" content = (try? container?.decodeIfPresent(String.self, forKey: .content)) ?? "" From 856ded48698cf018c037142d039c527679f2a38b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 00:25:38 +0800 Subject: [PATCH 529/614] Fix cross-domain deletion in cookie test store --- .../Sources/CookieClient/CookieClient.swift | 5 ++++- .../DownloadAutomationTests.swift | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/AppPackage/Sources/CookieClient/CookieClient.swift b/AppPackage/Sources/CookieClient/CookieClient.swift index cb57c2ef9..a72d255f1 100644 --- a/AppPackage/Sources/CookieClient/CookieClient.swift +++ b/AppPackage/Sources/CookieClient/CookieClient.swift @@ -376,10 +376,13 @@ private struct CookieClientTestingCookie: Sendable { public func matches(url: URL, key: String? = nil) -> Bool { guard let host = url.host?.lowercased() else { return false } + // Host-exact: the store only ever holds concrete-host cookies (never a wildcard `.domain` + // one), so a subdomain query such as `s.exhentai.org` must NOT match an `exhentai.org` + // cookie. A suffix match let a `syncExCookies` write to one host collaterally delete a + // sibling host's login cookies (via `editCookie`'s remove-all-matching), nondeterministically. let normalizedDomain = domain.lowercased() .trimmingCharacters(in: CharacterSet(charactersIn: ".")) let domainMatches = host == normalizedDomain - || host.hasSuffix(".\(normalizedDomain)") let keyMatches = key.map { name == $0 } ?? true return domainMatches && keyMatches } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift index 26ae59e05..c2e3a95eb 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift @@ -134,6 +134,28 @@ struct DownloadAutomationTests: DownloadFeatureTestCase { #expect(cookieClient.shouldFetchIgneous) } + @Test + func testSyncExCookiesPreservesSiblingHostLoginCookies() { + let cookieClient = CookieClient.testing() + cookieClient.clearAll() + defer { cookieClient.clearAll() } + + cookieClient.setOrEditCookie( + for: Defaults.URL.exhentai, key: Defaults.Cookie.ipbMemberId, value: "4172984" + ) + cookieClient.setOrEditCookie( + for: Defaults.URL.exhentai, key: Defaults.Cookie.ipbPassHash, value: "pass-hash" + ) + + // Syncing exhentai.org's cookies onto the sibling host s.exhentai.org must not disturb the + // exhentai.org cookies (regression: a suffix domain match used to delete them). + cookieClient.syncExCookies() + + let exCookies = cookieClient.cookies(for: Defaults.URL.exhentai) + #expect(exCookies.first { $0.name == Defaults.Cookie.ipbMemberId }?.value == "4172984") + #expect(exCookies.first { $0.name == Defaults.Cookie.ipbPassHash }?.value == "pass-hash") + } + @MainActor @Test func testRunLaunchAutomationFallsBackToInitialTabWhenGalleryURLIsUnhandleable() async { From e12d0f42ef719f9f3022021e4c7bd8d77436342c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 16:41:39 +0800 Subject: [PATCH 530/614] Seed pushed reader with gallery, config, language --- .../DetailFeature/DetailReducer+Actions.swift | 5 +- .../DetailReducer+Download.swift | 10 ++- .../Sources/DetailFeature/DetailReducer.swift | 2 +- .../Sources/DetailFeature/DetailView.swift | 6 +- .../DetailFeature/GalleryNavigation.swift | 7 ++- .../Previews/PreviewsReducer.swift | 22 +++++-- .../DownloadsFeature/DownloadsReducer.swift | 19 +++--- .../ReadingFeature/ReadingReducer.swift | 15 ++++- .../Sources/ReadingFeature/ReadingView.swift | 2 +- .../DetailReadingSeedTests.swift | 62 +++++++++++++++++++ .../DownloadObserverReadingTests.swift | 5 +- .../DownloadObserverRefreshTests.swift | 3 +- .../DownloadsReducerReadingDismissTests.swift | 2 +- .../ReadingReducerDownloadTests.swift | 9 +-- .../ReadingReducerLocalTests.swift | 8 +-- 15 files changed, 135 insertions(+), 42 deletions(-) create mode 100644 AppPackage/Tests/DetailFeatureTests/DetailReadingSeedTests.swift diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index 67871eb64..d80c7be84 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -22,7 +22,10 @@ extension DetailReducer { return .none case .presentReading: - state.destination = .reading(ReadingReducer.State()) + state.destination = .reading(.init( + gallery: state.gallery, previewConfig: state.previewConfig, + language: state.galleryDetail?.language + )) return .none case .archivesButtonTapped: diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift index e6e7c74d9..17bfc42f6 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift @@ -152,9 +152,15 @@ extension DetailReducer { case .openReadingDone(let result): var readingState: ReadingReducer.State if case .success(let (download, manifest)) = result { - readingState = .init(contentSource: .local(download, manifest)) + readingState = .init( + gallery: state.gallery, contentSource: .local(download, manifest), + previewConfig: state.previewConfig, language: state.galleryDetail?.language + ) } else { - readingState = .init(contentSource: .remote) + readingState = .init( + gallery: state.gallery, contentSource: .remote, + previewConfig: state.previewConfig, language: state.galleryDetail?.language + ) readingState.localPageURLs = state.localPreviewURLs } state.destination = .reading(readingState) diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index 89aa77126..9134178ad 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -16,7 +16,7 @@ public struct DetailReducer: Sendable { // The gallery sub-screens are now standalone elements on the host's navigation stack. Detail asks // the host to push them via these delegate actions instead of owning nested child state itself. public enum Delegate: Equatable, Sendable { - case pushPreviews(Gallery) + case pushPreviews(Gallery, PreviewConfig, Language?) case pushComments( gid: String, token: String, apiKey: String, galleryURL: URL, comments: [GalleryComment], scrollCommentID: String? diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index b19254b3e..b558267ed 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -135,7 +135,11 @@ private extension DetailView { PreviewsSection( pageCount: store.galleryDetail?.pageCount ?? 0, previewURLs: displayPreviewURLs, - navigatePreviewsAction: { store.send(.delegate(.pushPreviews(store.gallery))) }, + navigatePreviewsAction: { + store.send(.delegate(.pushPreviews( + store.gallery, store.previewConfig, store.galleryDetail?.language + ))) + }, navigateReadingAction: { store.send(.updateReadingProgress($0)) store.send(.openReading) diff --git a/AppPackage/Sources/DetailFeature/GalleryNavigation.swift b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift index c8ae1ce38..b763b619b 100644 --- a/AppPackage/Sources/DetailFeature/GalleryNavigation.swift +++ b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift @@ -21,8 +21,11 @@ public enum GalleryNavigation { switch action { case let .detail(.delegate(delegate)): switch delegate { - case .pushPreviews(let gallery): - return .previews(.init(gid: gallery.id, gallery: gallery)) + case let .pushPreviews(gallery, previewConfig, language): + return .previews(.init( + gid: gallery.id, gallery: gallery, + previewConfig: previewConfig, language: language + )) case let .pushComments(gid, token, apiKey, galleryURL, comments, scrollCommentID): return .comments(.init( gid: gid, token: token, apiKey: apiKey, diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index d64e47035..25b687e6d 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -28,6 +28,10 @@ public struct PreviewsReducer: Sendable { // The gallery id this screen fetches; captured when pushed onto the host's gallery stack. public var gid = "" public var gallery: Gallery = .empty + // Threaded from the detail context (via the `pushPreviews` delegate) so a reader opened from + // this screen keeps the correct page math (`previewConfig`) and Live Text `language` for + // remote sessions — Previews itself never fetches a gallery detail to re-derive them. + public var language: Language? public var loadingState: LoadingState = .idle public var databaseLoadingState: LoadingState = .loading @@ -36,9 +40,14 @@ public struct PreviewsReducer: Sendable { public var previewConfig: PreviewConfig = .normal(rows: 4) public var localPreviewRequestID = UUID() - public init(gid: String = "", gallery: Gallery = .empty) { + public init( + gid: String = "", gallery: Gallery = .empty, + previewConfig: PreviewConfig = .normal(rows: 4), language: Language? = nil + ) { self.gid = gid self.gallery = gallery + self.previewConfig = previewConfig + self.language = language } mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { @@ -159,12 +168,17 @@ public struct PreviewsReducer: Sendable { case .openReadingDone(let result): var readingState: ReadingReducer.State if case .success(let (download, manifest)) = result { - readingState = .init(contentSource: .local(download, manifest)) + readingState = .init( + gallery: state.gallery, contentSource: .local(download, manifest), + previewConfig: state.previewConfig, language: state.language + ) } else { - readingState = .init(contentSource: .remote) + readingState = .init( + gallery: state.gallery, contentSource: .remote, + previewConfig: state.previewConfig, language: state.language + ) readingState.localPageURLs = state.localPreviewURLs } - readingState.gallery = state.gallery state.destination = .reading(readingState) return .none diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 7eb800e81..dba97e96c 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -272,12 +272,14 @@ public struct DownloadsReducer: Sendable { guard state.readingRequestID == requestID else { return .none } var readingState: ReadingReducer.State if case .success(let (download, manifest)) = result { - readingState = .init(contentSource: .local(download, manifest)) - readingState.gallery = download.gallery + readingState = .init(gallery: download.gallery, contentSource: .local(download, manifest)) } else { - readingState = .init(contentSource: .remote) - if let download = state.downloads.first(where: { $0.gid == gid }) { - readingState.applyDownloadFallback(download) + let download = state.downloads.first(where: { $0.gid == gid }) + readingState = .init(gallery: download?.gallery ?? .empty, contentSource: .remote) + // A downloaded gallery has no persisted detail, so fall back to a language-agnostic + // Live Text hint rather than leaving it unset. + if download != nil { + readingState.language = .other } } state.destination = .reading(readingState) @@ -360,10 +362,3 @@ public struct DownloadsReducer: Sendable { } extension DownloadsReducer.Destination.State: Equatable {} - -private extension ReadingReducer.State { - mutating func applyDownloadFallback(_ download: DownloadedGallery) { - gallery = download.gallery - language = .other - } -} diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index e45af6d94..9e67ea4d6 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -75,8 +75,21 @@ public struct ReadingReducer: Sendable { public var showsPanel = false public var showsSliderPreview = false - public init(contentSource: ReadingContentSource = .remote) { + // `gallery` is required (no `.empty` default) so every pushing context is forced to seed it: + // a blank gallery has a `nil` galleryURL and a random-UUID gid, which bricks remote reading + // and writes junk history entries. `previewConfig`/`language` are threaded from the detail + // context so page math and Live Text language stay correct for remote sessions; offline + // sources overwrite both from the manifest on appear (see `applyLocalSource`). + public init( + gallery: Gallery, + contentSource: ReadingContentSource = .remote, + previewConfig: PreviewConfig = .normal(rows: 4), + language: Language? = nil + ) { + self.gallery = gallery self.contentSource = contentSource + self.previewConfig = previewConfig + self.language = language } var isOffline: Bool { contentSource != .remote } diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 7d8f27149..55c52c9bb 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -340,7 +340,7 @@ struct ReadingView_Previews: PreviewProvider { Color.clear .fullScreenCover(isPresented: .constant(true)) { ReadingView( - store: .init(initialState: .init(), reducer: ReadingReducer.init), + store: .init(initialState: .init(gallery: .empty), reducer: ReadingReducer.init), gid: .init(), setting: .constant(.init()), blurRadius: 0 diff --git a/AppPackage/Tests/DetailFeatureTests/DetailReadingSeedTests.swift b/AppPackage/Tests/DetailFeatureTests/DetailReadingSeedTests.swift new file mode 100644 index 000000000..04bf3365d --- /dev/null +++ b/AppPackage/Tests/DetailFeatureTests/DetailReadingSeedTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +import AppModels +import ComposableArchitecture +@testable import DetailFeature +@testable import ReadingFeature + +// REV-1/REV-4/REV-10: pushing the reader from Detail must seed the reader's `gallery` (so remote +// reading isn't blank and history upserts carry the real gid/token), plus the threaded `previewConfig` +// (page math) and `language` (Live Text). A regression here silently bricks the primary read flow. +@Suite +@MainActor +struct DetailReadingSeedTests { + private func makeGallery() -> Gallery { + Gallery( + gid: "42", token: "abc123", title: "Seed", rating: 4.5, tags: [], + category: .doujinshi, pageCount: 30, postedDate: .init(timeIntervalSince1970: 0), + coverURL: nil, galleryURL: URL(string: "https://example.com/g/42/abc123/") + ) + } + + private func makeSeededState() -> DetailReducer.State { + var state = DetailReducer.State(gallery: makeGallery()) + state.galleryDetail = .preview + state.previewConfig = .large(rows: 3) + return state + } + + @Test + func openReadingRemoteSeedsGalleryPreviewConfigAndLanguage() async { + let store = TestStore(initialState: makeSeededState(), reducer: DetailReducer.init) + store.exhaustivity = .off + + await store.send(.openReadingDone(.failure(.notFound))) + + guard case let .reading(readingState)? = store.state.destination else { + Issue.record("expected a reading destination") + return + } + #expect(readingState.gallery.gid == "42") + #expect(readingState.gallery.token == "abc123") + #expect(readingState.gallery.galleryURL != nil) + #expect(readingState.previewConfig == .large(rows: 3)) + #expect(readingState.language == GalleryDetail.preview.language) + } + + @Test + func presentReadingSeedsGalleryPreviewConfigAndLanguage() async { + let store = TestStore(initialState: makeSeededState(), reducer: DetailReducer.init) + store.exhaustivity = .off + + await store.send(.presentReading) + + guard case let .reading(readingState)? = store.state.destination else { + Issue.record("expected a reading destination") + return + } + #expect(readingState.gallery.gid == "42") + #expect(readingState.previewConfig == .large(rows: 3)) + #expect(readingState.language == GalleryDetail.preview.language) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift index 51d3d7fb7..46a37d962 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift @@ -35,7 +35,7 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { ) let manifest = try sampleManifest(gid: download.gid, title: download.title) let store = TestStore( - initialState: ReadingReducer.State(contentSource: .local(download, manifest)) + initialState: ReadingReducer.State(gallery: .empty, contentSource: .local(download, manifest)) ) { ReadingReducer() } withDependencies: { @@ -91,8 +91,7 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { let (stream, continuation) = makeObserverStream() let loadCount = UncheckedBox(0) - var initialState = ReadingReducer.State(contentSource: .remote) - initialState.gallery = gallery + var initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) let store = makeReadingStoreWithLoadCount( initialState: initialState, stream: stream, diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift index 57169bc87..5a516739a 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift @@ -25,8 +25,7 @@ struct DownloadObserverRefreshTests: DownloadFeatureTestCase { let (stream, continuation) = makeObserverStream() let loadCount = UncheckedBox(0) - var initialState = ReadingReducer.State(contentSource: .remote) - initialState.gallery = gallery + var initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) let store = makeReadingObserverStore( initialState: initialState, diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift index 147c7f734..9833fd463 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift @@ -11,7 +11,7 @@ struct DownloadsReducerReadingDismissTests { @Test func readingDismissClearsDestination() async { var initialState = DownloadsReducer.State() - initialState.destination = .reading(.init(contentSource: .remote)) + initialState.destination = .reading(.init(gallery: .empty, contentSource: .remote)) let store = TestStore( initialState: initialState, diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift index 346a9e05c..87194bf69 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift @@ -46,8 +46,7 @@ struct ReadingReducerDownloadTests: DownloadFeatureTestCase { let gallery = sampleGallery() let localPageURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") let remotePageURL = try #require(URL(string: "https://example.com/pages/0001.jpg")) - var initialState = ReadingReducer.State(contentSource: .remote) - initialState.gallery = gallery + var initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) initialState.imageURLs = [1: remotePageURL] let store = makeLocalPageLoadStore( @@ -68,8 +67,7 @@ struct ReadingReducerDownloadTests: DownloadFeatureTestCase { func testReadingReducerLocalPageLoadClearsStaleRemoteImageFailure() async throws { let gallery = sampleGallery() let localPageURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") - var initialState = ReadingReducer.State(contentSource: .remote) - initialState.gallery = gallery + var initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) initialState.imageURLLoadingStates[1] = .failed(.webImageFailed) initialState.previewLoadingStates[1] = .failed(.webImageFailed) @@ -93,8 +91,7 @@ struct ReadingReducerDownloadTests: DownloadFeatureTestCase { let capturedCalls = UncheckedBox([CapturedPageCall]()) let gallery = sampleGallery() let remotePageURL = try #require(URL(string: "https://example.com/pages/0001.jpg")) - var initialState = ReadingReducer.State(contentSource: .remote) - initialState.gallery = gallery + var initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) initialState.imageURLs = [1: remotePageURL] let store = makeCapturePageStore( diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift index d6e52fad2..753725a64 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift @@ -20,8 +20,7 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { func testContainerDataSourceHandlesZeroPageGallery() { var gallery = sampleGallery() gallery.pageCount = 0 - var state = ReadingReducer.State() - state.gallery = gallery + var state = ReadingReducer.State(gallery: gallery) var dualPageSetting = Setting() dualPageSetting.enablesDualPageMode = true @@ -40,8 +39,7 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { let localPageURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) .appendingPathComponent("0001.jpg") - var initialState = ReadingReducer.State(contentSource: .remote) - initialState.gallery = gallery + var initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) initialState.localPageURLs = [1: localPageURL] let store = TestStore( @@ -109,7 +107,7 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: folderURL) } let store = TestStore( - initialState: ReadingReducer.State(contentSource: .local(download, manifest)) + initialState: ReadingReducer.State(gallery: .empty, contentSource: .local(download, manifest)) ) { ReadingReducer() } withDependencies: { From 1183fc4e5ea9247bf81fedc7013eef0e1dfaa931 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 17:00:10 +0800 Subject: [PATCH 531/614] Validate history writes and tolerate gdata errors --- AppPackage/Package.swift | 17 ++++ .../AppFeature/DataFlow/AppRouteReducer.swift | 25 +++--- .../GalleryHistory+Operations.swift | 22 +++-- .../Comments/CommentsReducer.swift | 25 +++--- .../DetailFeature/GalleryDeepLink.swift | 13 +++ .../DownloadsFeature/DownloadsReducer.swift | 6 +- .../Request+GalleriesMetadata.swift | 16 ++-- .../Tests/AppModelsTests/.swiftlint.yml | 1 + .../GalleryHistoryOperationsTests.swift | 81 +++++++++++++++++++ AppPackage/Tests/FeatureTests.xctestplan | 14 ++++ .../NetworkingFeatureTests/.swiftlint.yml | 1 + .../GalleriesMetadataDecodeTests.swift | 51 ++++++++++++ 12 files changed, 230 insertions(+), 42 deletions(-) create mode 100644 AppPackage/Tests/AppModelsTests/.swiftlint.yml create mode 100644 AppPackage/Tests/AppModelsTests/GalleryHistoryOperationsTests.swift create mode 100644 AppPackage/Tests/NetworkingFeatureTests/.swiftlint.yml create mode 100644 AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 0f5f37a5b..44a871bf7 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -113,6 +113,8 @@ enum Module: String { case fileClientTests = "FileClientTests" case settingFeatureTests = "SettingFeatureTests" case detailFeatureTests = "DetailFeatureTests" + case networkingFeatureTests = "NetworkingFeatureTests" + case appModelsTests = "AppModelsTests" } extension Module { @@ -919,6 +921,21 @@ let targets: [PackageDescription.Target] = [ .targetDependency(.composableArchitecture) ], plugins: swiftLintPlugins + ), + .testTarget( + module: .networkingFeatureTests, + dependencies: [ + .module(.appModels), + .module(.networkingFeature) + ], + plugins: swiftLintPlugins + ), + .testTarget( + module: .appModelsTests, + dependencies: [ + .module(.appModels) + ], + plugins: swiftLintPlugins ) ] diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 1de0fcdfd..187679110 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -47,7 +47,7 @@ struct AppRouteReducer { case handleDeepLink(URL) case handleGalleryLink(URL, Gallery) - case updateReadingProgress(String, Int) + case updateReadingProgress(gid: String, token: String, progress: Int) case fetchGallery(URL, Bool) case fetchGalleryDone(URL, Result) @@ -157,28 +157,25 @@ struct AppRouteReducer { case .handleGalleryLink(let url, let gallery): let analysis = urlClient.analyzeURL(url) - let pageIndex = analysis.pageIndex - let commentID = analysis.commentID - var deepLink: GalleryDeepLink? + let deepLink = GalleryDeepLink(pageIndex: analysis.pageIndex, commentID: analysis.commentID) var effects = [Effect]() - if let pageIndex = pageIndex { - effects.append(.send(.updateReadingProgress(gallery.id, pageIndex))) - deepLink = .reading(page: pageIndex) - } else if let commentID = commentID { - deepLink = .comments(commentID: commentID) + if let pageIndex = analysis.pageIndex { + effects.append(.send(.updateReadingProgress( + gid: gallery.id, token: gallery.token, progress: pageIndex + ))) } state.path.removeAll() state.detail = DetailReducer.State(gallery: gallery, pendingDeepLink: deepLink) effects.append(.run(operation: { _ in await hapticsClient.generateFeedback(.light) })) return .merge(effects) - case .updateReadingProgress(let gid, let progress): - guard !gid.isEmpty else { return .none } - // Deep link straight to a page: the token isn't known here, so the entry is created - // tokenless and backfilled when the detail screen records the open. + case let .updateReadingProgress(gid, token, progress): + // The linked gallery is in scope, so persist the real token — the entry resolves + // immediately rather than waiting for the detail screen to backfill it. Invalid + // gid/token records are rejected inside the shared mutator. @Shared(.galleryHistory) var galleryHistory $galleryHistory.withLock { - $0.updateReadingProgress(gid: gid, token: "", progress: progress, date: date.now) + $0.updateReadingProgress(gid: gid, token: token, progress: progress, date: date.now) } return .none diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift index 7af7b7fbf..6b2d28b2f 100644 --- a/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift @@ -1,9 +1,11 @@ import Foundation // The browsing-history list persisted behind `@Shared(.galleryHistory)`. Kept most-recent-first; -// these are the only mutators, so the ordering and per-gid uniqueness invariants live here rather -// than being re-derived at each call site. None of them trim — the 1,000-entry cap is enforced -// solely by a launch-time prune, so in-session upserts may temporarily grow the list past the cap. +// these are the only mutators, so the invariants live here rather than being re-derived at each call +// site: entries stay most-recent-first and unique per gid, and only a resolvable record is ever +// stored — a non-numeric gid is rejected, and a brand-new entry must carry a token (an existing entry +// may keep or backfill its own). None of them trim — the 1,000-entry cap is enforced solely by a +// launch-time prune, so in-session upserts may temporarily grow the list past the cap. extension Array where Element == GalleryHistoryEntry { /// The saved resume page for `gid`, or 0 when the gallery has no history entry yet. public func readingProgress(gid: String) -> Int { @@ -12,9 +14,13 @@ extension Array where Element == GalleryHistoryEntry { /// Records that `gid` was just opened: stamps its recency with `date`, moves it to the front, /// fills in a previously-missing token, and preserves any saved reading progress. Inserts a - /// fresh entry when the gallery is new to the history. + /// fresh entry when the gallery is new to the history. A non-numeric gid, or a new gallery with + /// no token, is rejected so the persisted list never accumulates unresolvable junk. public mutating func recordGalleryOpen(gid: String, token: String, date: Date) { - var entry = first { $0.gid == gid } ?? GalleryHistoryEntry(gid: gid, token: token, lastOpenDate: date) + guard Int(gid) != nil else { return } + let existing = first { $0.gid == gid } + guard existing != nil || !token.isEmpty else { return } + var entry = existing ?? GalleryHistoryEntry(gid: gid, token: token, lastOpenDate: date) removeAll { $0.gid == gid } entry.lastOpenDate = date if entry.token.isEmpty { entry.token = token } @@ -23,12 +29,14 @@ extension Array where Element == GalleryHistoryEntry { /// Updates the saved resume page for `gid` in place, leaving its recency and position untouched. /// Inserts a fresh front entry stamped `date` when the gallery has no history yet — e.g. a deep - /// link that jumps straight to a page before the detail screen records the open (that later open - /// backfills the token via `recordGalleryOpen`). + /// link that jumps straight to a page before the detail screen records the open. A non-numeric + /// gid, or a new entry with no token, is rejected (an in-place update keeps its stored token). public mutating func updateReadingProgress(gid: String, token: String, progress: Int, date: Date) { + guard Int(gid) != nil else { return } if let index = firstIndex(where: { $0.gid == gid }) { self[index].readingProgress = progress } else { + guard !token.isEmpty else { return } insert( GalleryHistoryEntry(gid: gid, token: token, lastOpenDate: date, readingProgress: progress), at: 0 diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index 43ff052f7..af87ddf39 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -74,7 +74,7 @@ public struct CommentsReducer: Sendable { case onPostCommentAppear case onAppear - case updateReadingProgress(String, Int) + case updateReadingProgress(gid: String, token: String, progress: Int) case postComment(URL, String? = nil) case voteComment(String, String, String, String, Int) @@ -159,15 +159,12 @@ public struct CommentsReducer: Sendable { case .handleGalleryLink(let url, let gallery): let analysis = urlClient.analyzeURL(url) - let pageIndex = analysis.pageIndex - let commentID = analysis.commentID - var deepLink: GalleryDeepLink? + let deepLink = GalleryDeepLink(pageIndex: analysis.pageIndex, commentID: analysis.commentID) var effects = [Effect]() - if let pageIndex = pageIndex { - effects.append(.send(.updateReadingProgress(gallery.id, pageIndex))) - deepLink = .reading(page: pageIndex) - } else if let commentID = commentID { - deepLink = .comments(commentID: commentID) + if let pageIndex = analysis.pageIndex { + effects.append(.send(.updateReadingProgress( + gid: gallery.id, token: gallery.token, progress: pageIndex + ))) } effects.append(.send(.delegate(.pushDetail(gallery, deepLink)))) return .merge(effects) @@ -181,13 +178,13 @@ public struct CommentsReducer: Sendable { case .onAppear: return state.scrollCommentID != nil ? .send(.performScrollOpacityEffect) : .none - case .updateReadingProgress(let gid, let progress): - guard !gid.isEmpty else { return .none } - // Deep link straight to a page: the token isn't known here, so the entry is created - // tokenless and backfilled when the detail screen records the open. + case let .updateReadingProgress(gid, token, progress): + // The linked gallery is in scope, so persist the real token — the entry resolves + // immediately rather than waiting for the detail screen to backfill it. Invalid + // gid/token records are rejected inside the shared mutator. @Shared(.galleryHistory) var galleryHistory $galleryHistory.withLock { - $0.updateReadingProgress(gid: gid, token: "", progress: progress, date: date.now) + $0.updateReadingProgress(gid: gid, token: token, progress: progress, date: date.now) } return .none diff --git a/AppPackage/Sources/DetailFeature/GalleryDeepLink.swift b/AppPackage/Sources/DetailFeature/GalleryDeepLink.swift index c338e1c3e..a6460a34e 100644 --- a/AppPackage/Sources/DetailFeature/GalleryDeepLink.swift +++ b/AppPackage/Sources/DetailFeature/GalleryDeepLink.swift @@ -7,4 +7,17 @@ import Foundation public enum GalleryDeepLink: Equatable, Sendable { case reading(page: Int) case comments(commentID: String) + + /// The deep-link intent encoded by a parsed gallery URL: a resume page takes precedence over a + /// target comment. Returns `nil` when the link carries neither. Shared by every gallery-link + /// handler so the precedence can't drift between call sites. + public init?(pageIndex: Int?, commentID: String?) { + if let pageIndex { + self = .reading(page: pageIndex) + } else if let commentID { + self = .comments(commentID: commentID) + } else { + return nil + } + } } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index dba97e96c..098924ec6 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -112,10 +112,12 @@ public struct DownloadsReducer: Sendable { return .none case .galleryTapped(let gid): - let download = state.downloads.first(where: { $0.gid == gid }) + // Bail if the download vanished between the observation update and this tap; routing to + // detail with `.empty` would write an unresolvable (random-gid) history entry. + guard let download = state.downloads.first(where: { $0.gid == gid }) else { return .none } return GalleryNavigation.routeGalleryDetail( isPad: deviceClient.isPad, - present: { .delegate(.presentGalleryDetail(download?.gallery ?? .empty, download)) }, + present: { .delegate(.presentGalleryDetail(download.gallery, download)) }, push: { .pushGalleryDetail(gid) } ) diff --git a/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift index c8e58c96f..863584a1f 100644 --- a/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift +++ b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift @@ -13,7 +13,7 @@ private struct GalleriesMetadataAPIResponse: Decodable { /// `gallery` yields `nil` for those so a single bad entry never fails the whole batch. private struct GalleryMetadata: Decodable { let gid: Int - let token: String + let token: String? let error: String? let title: String? let category: String? @@ -26,6 +26,7 @@ private struct GalleryMetadata: Decodable { var gallery: Gallery? { guard error == nil, + let token, let title, let posted, let postedInterval = TimeInterval(posted) else { return nil } @@ -123,14 +124,19 @@ public struct GalleriesMetadataRequest: Request { .genericRetry() .map(\.data) .tryMap { data in - try parseResponse(data: data) { - let response = try JSONDecoder().decode(GalleriesMetadataAPIResponse.self, from: $0) - return response.gmetadata.compactMap(\.gallery) - } + try parseResponse(data: data) { try Self.galleries(fromResponseData: $0) } } .mapError(mapAppError) .eraseToAnyPublisher() } + + /// Decodes a raw `gdata` payload into galleries, silently dropping every unresolvable + /// `{ gid, error }` (or tokenless) entry. `token` is optional precisely so one such entry can't + /// fail `JSONDecoder` for the whole array — a single bad gid must never blank the History batch. + /// Exposed at `internal` access so tests can assert that per-entry tolerance directly. + static func galleries(fromResponseData data: Data) throws -> [Gallery] { + try JSONDecoder().decode(GalleriesMetadataAPIResponse.self, from: data).gmetadata.compactMap(\.gallery) + } } // MARK: Helpers diff --git a/AppPackage/Tests/AppModelsTests/.swiftlint.yml b/AppPackage/Tests/AppModelsTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/AppModelsTests/GalleryHistoryOperationsTests.swift b/AppPackage/Tests/AppModelsTests/GalleryHistoryOperationsTests.swift new file mode 100644 index 000000000..a228691fd --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/GalleryHistoryOperationsTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +import AppModels + +// REV-13: the shared history mutators are the single home for the persisted-list invariants, so they +// must reject unresolvable records at the source — a non-numeric gid, or a brand-new entry with no +// token — rather than relying on every call site to pre-validate. +@Suite +struct GalleryHistoryOperationsTests { + private let date = Date(timeIntervalSince1970: 1_000) + + @Test + func recordGalleryOpenRejectsNonNumericGID() { + var history = [GalleryHistoryEntry]() + history.recordGalleryOpen(gid: "not-a-number", token: "tok", date: date) + #expect(history.isEmpty) + } + + @Test + func recordGalleryOpenRejectsNewTokenlessEntry() { + var history = [GalleryHistoryEntry]() + history.recordGalleryOpen(gid: "42", token: "", date: date) + #expect(history.isEmpty) + } + + @Test + func recordGalleryOpenInsertsResolvableEntry() { + var history = [GalleryHistoryEntry]() + history.recordGalleryOpen(gid: "42", token: "tok", date: date) + #expect(history.count == 1) + #expect(history.first?.gid == "42") + #expect(history.first?.token == "tok") + } + + @Test + func recordGalleryOpenBackfillsMissingTokenAndMovesToFront() { + var history = [ + GalleryHistoryEntry(gid: "1", token: "t1", lastOpenDate: date), + GalleryHistoryEntry(gid: "42", token: "", lastOpenDate: date, readingProgress: 5) + ] + let later = date.addingTimeInterval(60) + history.recordGalleryOpen(gid: "42", token: "tok", date: later) + #expect(history.count == 2) + #expect(history.first?.gid == "42") + #expect(history.first?.token == "tok") // backfilled + #expect(history.first?.readingProgress == 5) // preserved + #expect(history.first?.lastOpenDate == later) + } + + @Test + func updateReadingProgressRejectsNonNumericGID() { + var history = [GalleryHistoryEntry]() + history.updateReadingProgress(gid: "junk", token: "tok", progress: 3, date: date) + #expect(history.isEmpty) + } + + @Test + func updateReadingProgressRejectsNewTokenlessEntry() { + var history = [GalleryHistoryEntry]() + history.updateReadingProgress(gid: "42", token: "", progress: 3, date: date) + #expect(history.isEmpty) + } + + @Test + func updateReadingProgressInsertsResolvableEntryWithToken() { + var history = [GalleryHistoryEntry]() + history.updateReadingProgress(gid: "42", token: "tok", progress: 3, date: date) + #expect(history.count == 1) + #expect(history.first?.token == "tok") + #expect(history.first?.readingProgress == 3) + } + + @Test + func updateReadingProgressInPlaceKeepsStoredToken() { + var history = [GalleryHistoryEntry(gid: "42", token: "stored", lastOpenDate: date)] + history.updateReadingProgress(gid: "42", token: "ignored", progress: 7, date: date) + #expect(history.count == 1) + #expect(history.first?.token == "stored") // in-place update keeps stored token + #expect(history.first?.readingProgress == 7) + } +} diff --git a/AppPackage/Tests/FeatureTests.xctestplan b/AppPackage/Tests/FeatureTests.xctestplan index 77339d3a2..45c561e0c 100644 --- a/AppPackage/Tests/FeatureTests.xctestplan +++ b/AppPackage/Tests/FeatureTests.xctestplan @@ -12,6 +12,13 @@ "testTimeoutsEnabled" : true }, "testTargets" : [ + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "AppModelsTests", + "name" : "AppModelsTests" + } + }, { "target" : { "containerPath" : "container:AppPackage", @@ -33,6 +40,13 @@ "name" : "FileClientTests" } }, + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "NetworkingFeatureTests", + "name" : "NetworkingFeatureTests" + } + }, { "target" : { "containerPath" : "container:AppPackage", diff --git a/AppPackage/Tests/NetworkingFeatureTests/.swiftlint.yml b/AppPackage/Tests/NetworkingFeatureTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/NetworkingFeatureTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift b/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift new file mode 100644 index 000000000..d1adfb174 --- /dev/null +++ b/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +import AppModels +@testable import NetworkingFeature + +// REV-2: the `gdata` API returns bare `{ gid, error }` objects for expunged/removed gids (and can omit +// `token`). Decoding the whole `[GalleryMetadata]` array must tolerate those per-entry — one bad gid +// must never fail the batch and blank the entire History screen. +@Suite +struct GalleriesMetadataDecodeTests { + @Test + func mixedPayloadDropsErrorEntriesAndKeepsResolvableGalleries() throws { + let json = """ + { + "gmetadata": [ + { + "gid": 100, "token": "aaa", "title": "First & Title", + "category": "Doujinshi", "thumb": "https://example.com/1.jpg", + "uploader": "u1", "posted": "1600000000", "filecount": "20", + "rating": "4.5", "tags": ["language:japanese", "artist:someone"] + }, + { "gid": 999, "error": "Key missing, or incorrect key." }, + { + "gid": 200, "token": "bbb", "title": "Second Title", + "category": "Manga", "thumb": "https://example.com/2.jpg", + "uploader": "u2", "posted": "1600000100", "filecount": "30", + "rating": "3.0", "tags": ["language:english"] + } + ] + } + """ + + let galleries = try GalleriesMetadataRequest.galleries(fromResponseData: Data(json.utf8)) + + #expect(galleries.count == 2) + #expect(galleries.map(\.id) == ["100", "200"]) + #expect(galleries.first?.token == "aaa") + #expect(galleries.first?.title == "First & Title") + } + + // A tokenless (but error-free) entry is unresolvable — its gallery URL can't be built — so it is + // dropped rather than decoded with an empty token. + @Test + func tokenlessEntryIsDropped() throws { + let json = """ + { "gmetadata": [ { "gid": 300, "title": "No Token", "posted": "1600000000" } ] } + """ + let galleries = try GalleriesMetadataRequest.galleries(fromResponseData: Data(json.utf8)) + #expect(galleries.isEmpty) + } +} From 3975f148717f0aed48f12c8c5a1e2037d805e29a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 17:08:51 +0800 Subject: [PATCH 532/614] Page History fetch and bound gdata concurrency --- .../HomeFeature/History/HistoryReducer.swift | 78 ++++++++++++++++--- .../HomeFeature/History/HistoryView.swift | 5 +- .../Request+GalleriesMetadata.swift | 6 +- 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index 01b75cf9e..959488131 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -13,8 +13,13 @@ import AppComponents public struct HistoryReducer: Sendable { private enum CancelID { case observeDownloads + case fetch } + // The gdata endpoint takes 25 gids/call; a page is two chunks, so a History visit costs at most a + // couple of polite requests instead of refetching every (up to 1,000) entry at once. + static let pageSize = 50 + public enum Delegate: Equatable, Sendable { case pushDetail(Gallery) } @@ -39,6 +44,13 @@ public struct HistoryReducer: Sendable { } public var galleries = [Gallery]() public var loadingState: LoadingState = .idle + // Paging over the local history: `fetchedCount` is how many most-recent entries have had their + // display metadata fetched so far; `footerLoadingState` drives the "load more" spinner. + public var footerLoadingState: LoadingState = .idle + public var fetchedCount = 0 + + // More history remains to page in beyond what's already been fetched. + var hasMoreHistory: Bool { fetchedCount < galleryHistory.count } public init() {} } @@ -52,7 +64,9 @@ public struct HistoryReducer: Sendable { case clearHistoryGalleries case fetchGalleries - case fetchGalleriesDone(Result<[Gallery], AppError>) + case fetchGalleriesDone(Result<[Gallery], AppError>, endIndex: Int) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result<[Gallery], AppError>, endIndex: Int) case observeDownloads case observeDownloadsDone([DownloadedGallery]) } @@ -99,38 +113,82 @@ public struct HistoryReducer: Sendable { case .clearHistoryGalleries: // Clearing also drops resume positions (they live on the same entries) — deliberate, - // browser-like. The write is synchronous, so we can refetch straight away. + // browser-like. Cancel any in-flight fetch first so its late `.success` can't + // repopulate the list we just emptied, then drop straight to the empty state. state.$galleryHistory.withLock { $0.removeAll() } - return .send(.fetchGalleries) + state.galleries = [] + state.fetchedCount = 0 + state.footerLoadingState = .idle + state.loadingState = .failed(.notFound) + return .cancel(id: CancelID.fetch) case .fetchGalleries: guard state.loadingState != .loading else { return .none } - let pairs = state.galleryHistory.map { (gid: $0.gid, token: $0.token) } - guard !pairs.isEmpty else { - state.galleries = [] + state.galleries = [] + state.fetchedCount = 0 + state.footerLoadingState = .idle + let end = min(Self.pageSize, state.galleryHistory.count) + guard end > 0 else { state.loadingState = .failed(.notFound) return .none } + let pairs = state.galleryHistory[0.. Date: Mon, 6 Jul 2026 17:15:21 +0800 Subject: [PATCH 533/614] Debounce reading-progress persistence --- .../ReadingFeature/ReadingReducer+Body.swift | 1 + .../ReadingFeature/ReadingReducer+Database.swift | 15 ++++++++++++++- .../Sources/ReadingFeature/ReadingReducer.swift | 5 +++++ .../Sources/ReadingFeature/ReadingView.swift | 9 +++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index 1dfc89891..99de88239 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -7,6 +7,7 @@ import AppTools // MARK: - CancelID enum ReadingCancelID { case fetchImage + case progressFlush case observeDownloads case loadLocalPageURLs case fetchPreviewURLs diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift index b5248f92c..2893116bd 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift @@ -10,10 +10,23 @@ extension ReadingReducer { Reduce { state, action in switch action { case .syncReadingProgress(let progress): + // Debounce the persist: the page index is the hottest interaction in the app (swipe, + // slider scrub, auto-play) and each write re-encodes the whole history array. Keep the + // pending page in state and flush it at most once per second — plus immediately on + // teardown/background (`.flushReadingProgress`) so a force-quit loses under a second. + state.pendingReadingProgress = progress + return .run { send in + try await clock.sleep(for: .seconds(1)) + await send(.flushReadingProgress) + } + .cancellable(id: ReadingCancelID.progressFlush, cancelInFlight: true) + + case .flushReadingProgress: @Shared(.galleryHistory) var galleryHistory $galleryHistory.withLock { $0.updateReadingProgress( - gid: state.gallery.id, token: state.gallery.token, progress: progress, date: date.now + gid: state.gallery.id, token: state.gallery.token, + progress: state.pendingReadingProgress, date: date.now ) } return .none diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 9e67ea4d6..2d4a17b79 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -49,6 +49,9 @@ public struct ReadingReducer: Sendable { public var language: Language? public var readingProgress: Int = .zero + // The latest page awaiting a debounced persist to `@Shared(.galleryHistory)`; kept separate + // from `readingProgress` (which seeds the slider) so tracking it never feeds back into the pager. + public var pendingReadingProgress: Int = .zero public var forceRefreshID: UUID = .init() public var webImageLoadSuccessIndices = Set() @@ -172,6 +175,7 @@ public struct ReadingReducer: Sendable { case fetchImageDone(ImageAction, Result) case syncReadingProgress(Int) + case flushReadingProgress case fetchDatabaseInfos(String) case observeDownloads(String) @@ -209,6 +213,7 @@ public struct ReadingReducer: Sendable { @Dependency(\.imageClient) var imageClient @Dependency(\.urlClient) var urlClient @Dependency(\.date) var date + @Dependency(\.continuousClock) var clock public init() {} diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 55c52c9bb..1536e41ac 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -15,6 +15,7 @@ private let logger = Logger(category: .init(describing: ReadingView.self)) public struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme + @Environment(\.scenePhase) private var scenePhase @Bindable var store: StoreOf let gid: String @@ -101,9 +102,17 @@ public struct ReadingView: View { .animation(.default, value: store.showsPanel) .statusBar(hidden: !store.showsPanel) .onDisappear { + // The reader is tearing down; flush the debounced reading progress immediately so the + // last page swiped-to isn't lost. + store.send(.flushReadingProgress) liveTextHandler.cancelRequests() setAutoPlayPolocy(.off) } + .onChange(of: scenePhase) { _, newPhase in + // Backgrounding doesn't fire `onDisappear`, so flush here too — a force-quit from the + // background otherwise drops the last debounce window of progress. + if newPhase == .background { store.send(.flushReadingProgress) } + } .onAppear { store.send(.onAppear(gid, setting.enablesLandscape)) } } From 622c49621f41f36e8c0dace62d4b2cfbed8533f6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 17:18:33 +0800 Subject: [PATCH 534/614] Sequence tag-translator rebuild before fetch --- .../Sources/SettingFeature/SettingReducer+Body.swift | 7 +++++-- .../Sources/SettingFeature/SettingReducer+Helpers.swift | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index abb747673..c7d060698 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -27,8 +27,8 @@ extension SettingReducer { .send(.syncSetting) ] if state.setting.enablesTagsExtension { + // `.rebuildTagTranslator` sequences the remote fetch after the cache rebuild. effects.append(.send(.rebuildTagTranslator)) - effects.append(.send(.fetchTagTranslator)) } return .merge(effects) } @@ -235,13 +235,16 @@ extension SettingReducer { return .none // Offline rebuild of the in-memory table from the cached raw JSON described by the - // persisted metadata (custom import → Application Support, remote → Caches). + // persisted metadata (custom import → Application Support, remote → Caches), THEN the + // remote update check. Sequenced in one effect so the (slower) network fetch can't land a + // fresh table and metadata only to be overwritten by a rebuild that captured stale info. case .rebuildTagTranslator: let info = state.tagTranslatorInfo return .run { send in if let tagTranslator = fileClient.loadCachedTagTranslator(info) { await send(.tagTranslatorRebuilt(tagTranslator)) } + await send(.fetchTagTranslator) } case .tagTranslatorRebuilt(let tagTranslator): diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift index 7db168b3f..aca69f8f3 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift @@ -38,9 +38,9 @@ extension SettingReducer { ]) } if state.setting.enablesTagsExtension { - // Rebuild the table from cache first (offline, immediate), then check for a newer remote. + // Rebuild the table from cache first (offline, immediate); `.rebuildTagTranslator` then + // sequences the remote update check so a slow fetch can't be clobbered by a stale rebuild. effects.append(.send(.rebuildTagTranslator)) - effects.append(.send(.fetchTagTranslator)) } return .merge(effects) } From 78738c57346565652fe9b51cdfc282a896f9e850 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 17:29:56 +0800 Subject: [PATCH 535/614] Store Setting in `@Shared`, drop write-through --- .../AppFeature/DataFlow/AppReducer.swift | 3 - .../SettingFeature/SettingReducer+Body.swift | 88 ++++++------------- .../SettingFeature/SettingReducer.swift | 16 ++-- .../SettingWriteThroughTests.swift | 23 +++++ 4 files changed, 60 insertions(+), 70 deletions(-) create mode 100644 AppPackage/Tests/SettingFeatureTests/SettingWriteThroughTests.swift diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 16484767f..af83764df 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -73,9 +73,6 @@ struct AppReducer { } return .none } - .onChange(of: \.settingState.setting) { _, _ in - .send(.setting(.syncSetting)) - } Reduce { state, action in switch action { diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index c7d060698..9c1091034 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -10,84 +10,61 @@ private let logger = Logger(category: .init(describing: SettingReducer.self)) extension SettingReducer { @ReducerBuilder var reducerBody: some Reducer { + // `setting` is `@Shared`, so BindingReducer writes and the fixups below persist automatically — + // these `.onChange` handlers now carry only their genuine side effects and cross-field + // invariants (no `.syncSetting` write-through remains). BindingReducer() - .onChange(of: \.setting) { _, _ in - .send(.syncSetting) - } .onChange(of: \.setting.galleryHost) { _, state in - .merge( - .send(.syncSetting), - .run(operation: { [value = state.setting.galleryHost.rawValue] _ in - userDefaultsClient.setValue(value, .galleryHost) - }) - ) + .run(operation: { [value = state.setting.galleryHost.rawValue] _ in + userDefaultsClient.setValue(value, .galleryHost) + }) } .onChange(of: \.setting.enablesTagsExtension) { _, state in - var effects: [Effect] = [ - .send(.syncSetting) - ] - if state.setting.enablesTagsExtension { - // `.rebuildTagTranslator` sequences the remote fetch after the cache rebuild. - effects.append(.send(.rebuildTagTranslator)) - } - return .merge(effects) + // `.rebuildTagTranslator` sequences the remote fetch after the cache rebuild. + state.setting.enablesTagsExtension ? .send(.rebuildTagTranslator) : .none } .onChange(of: \.setting.preferredColorScheme) { _, _ in - .merge( - .send(.syncSetting), - .send(.syncUserInterfaceStyle) - ) + .send(.syncUserInterfaceStyle) } .onChange(of: \.setting.appIconType) { _, state in - .merge( - .send(.syncSetting), - .run { [value = state.setting.appIconType.filename] send in - _ = await applicationClient.setAlternateIconName(value) - await send(.syncAppIconType) - } - ) + .run { [value = state.setting.appIconType.filename] send in + _ = await applicationClient.setAlternateIconName(value) + await send(.syncAppIconType) + } } .onChange(of: \.setting.autoLockPolicy) { _, state in if state.setting.autoLockPolicy != .never && state.setting.backgroundBlurRadius == 0 { - state.setting.backgroundBlurRadius = 10 + state.$setting.withLock { $0.backgroundBlurRadius = 10 } } - return .send(.syncSetting) + return .none } .onChange(of: \.setting.backgroundBlurRadius) { _, state in if state.setting.autoLockPolicy != .never && state.setting.backgroundBlurRadius == 0 { - state.setting.autoLockPolicy = .never + state.$setting.withLock { $0.autoLockPolicy = .never } } - return .send(.syncSetting) + return .none } .onChange(of: \.setting.enablesLandscape) { _, state in - var effects: [Effect] = [ - .send(.syncSetting) - ] - if !state.setting.enablesLandscape { - effects.append( - .run { _ in - guard await !deviceClient.isPad() else { return } - await appDelegateClient.setPortraitOrientationMask() - } - ) + guard !state.setting.enablesLandscape else { return .none } + return .run { _ in + guard await !deviceClient.isPad() else { return } + await appDelegateClient.setPortraitOrientationMask() } - return .merge(effects) } .onChange(of: \.setting.maximumScaleFactor) { _, state in if state.setting.doubleTapScaleFactor > state.setting.maximumScaleFactor { - state.setting.doubleTapScaleFactor = state.setting.maximumScaleFactor + state.$setting.withLock { $0.doubleTapScaleFactor = $0.maximumScaleFactor } } - return .send(.syncSetting) + return .none } .onChange(of: \.setting.doubleTapScaleFactor) { _, state in if state.setting.maximumScaleFactor < state.setting.doubleTapScaleFactor { - state.setting.maximumScaleFactor = state.setting.doubleTapScaleFactor + state.$setting.withLock { $0.maximumScaleFactor = $0.doubleTapScaleFactor } } - return .send(.syncSetting) + return .none } .onChange(of: \.setting.bypassesSNIFiltering) { _, state in .merge( - .send(.syncSetting), .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), .run(operation: { [value = state.setting.bypassesSNIFiltering] _ in dfClient.setActive(value) }) ) @@ -96,7 +73,7 @@ extension SettingReducer { Reduce { state, action in switch action { case .binding: - return .send(.syncSetting) + return .none case .settingRowTapped(let screen): state.path.appendGuardingDuplicate(screen.pathElement) @@ -130,9 +107,9 @@ extension SettingReducer { case .syncAppIconTypeDone(let iconName): if let iconName { - state.setting.appIconType = AppIconType.allCases.filter({ - iconName.contains($0.filename) - }).first ?? .default + let iconType = AppIconType.allCases + .filter({ iconName.contains($0.filename) }).first ?? .default + state.$setting.withLock { $0.appIconType = iconType } } return .none @@ -140,13 +117,6 @@ extension SettingReducer { let style = state.setting.preferredColorScheme.userInterfaceStyle return .run(operation: { _ in await applicationClient.setUserInterfaceStyle(style) }) - case .syncSetting: - // Write-through to the persisted store. `setting` stays a working copy so that its - // `BindingReducer` `.onChange` side effects keep firing; this mirrors it to storage. - @Shared(.setting) var storedSetting - $storedSetting.withLock { $0 = state.setting } - return .none - case .loadUserSettings: // `setting`/`user`/`tagTranslator` are all @Shared (auto-loaded); no database read. return handleLoadUserSettings(&state) diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index 3e140c8bb..8ad399b65 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -49,13 +49,14 @@ public struct SettingReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - // `setting` stays a working copy edited through `BindingReducer` (its `.onChange` handlers - // enforce cross-field invariants and fire side effects); persistence is a write-through to - // `@Shared(.setting)` via `.syncSetting`. `user` is not form-bound, so it is stored directly - // in `@Shared`. `tagTranslator` is derived, re-downloadable data: it lives in memory only and - // is rebuilt at launch from the cached raw JSON; only its thin `tagTranslatorInfo` metadata - // persists (see `AppSharedKeys`). - public var setting = Setting() + // `setting` is stored directly in `@Shared(.setting)`, so every mutation — form bindings, the + // cross-field `.onChange` fixups, and non-binding syncs like `syncAppIconTypeDone` — persists + // atomically. There is no working copy to keep in step, which removes the class of bug where a + // non-binding write path forgot to fire the old `.syncSetting` and the persisted value silently + // diverged. `user` is likewise shared. `tagTranslator` is derived, re-downloadable data: it + // lives in memory only and is rebuilt at launch from the cached raw JSON; only its thin + // `tagTranslatorInfo` metadata persists (see `AppSharedKeys`). + @Shared(.setting) public var setting: Setting public var tagTranslator = TagTranslator() @Shared(.tagTranslatorInfo) public var tagTranslatorInfo: TagTranslatorInfo @Shared(.user) public var user: User @@ -107,7 +108,6 @@ public struct SettingReducer: Sendable { case syncAppIconType case syncAppIconTypeDone(String?) case syncUserInterfaceStyle - case syncSetting case loadUserSettings case loadUserSettingsDone diff --git a/AppPackage/Tests/SettingFeatureTests/SettingWriteThroughTests.swift b/AppPackage/Tests/SettingFeatureTests/SettingWriteThroughTests.swift new file mode 100644 index 000000000..71ec3c62c --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/SettingWriteThroughTests.swift @@ -0,0 +1,23 @@ +import Testing +import AppModels +import Sharing +@testable import SettingFeature +import ComposableArchitecture + +// REV-8: `setting` is now stored directly in `@Shared(.setting)`, so a non-binding write path like +// `syncAppIconTypeDone` (fired at launch) persists atomically. Previously it mutated a working copy and +// returned `.none`, leaving the persisted value silently diverged until an unrelated binding synced it. +@Suite +@MainActor +struct SettingWriteThroughTests { + @Test + func syncAppIconTypeDonePersistsIconTypeToSharedSetting() async { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + store.exhaustivity = .off + + // The alternate-icon name maps to `.ukiyoe`; the derived type is written through `state.$setting`. + await store.send(.syncAppIconTypeDone(AppIconType.ukiyoe.filename)) + + #expect(store.state.setting.appIconType == .ukiyoe) + } +} From d9fe8ea57178ddef03a9a852845bf93416042aad Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 17:36:14 +0800 Subject: [PATCH 536/614] Prune history off the launch path --- .../AppFeature/DataFlow/AppDelegateReducer.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift index 88ae655d7..265d643bb 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -29,10 +29,14 @@ struct AppDelegateReducer { Reduce { _, action in switch action { case .onLaunchFinish: - // Enforce the browsing-history cap once per launch; in-session upserts never trim. - @Shared(.galleryHistory) var galleryHistory - $galleryHistory.withLock { $0.pruneToHistoryCap() } return .merge( + // Enforce the browsing-history cap once per launch (in-session upserts never trim). + // Runs off the launch path as a background effect — decoding + re-encoding up to + // 1,000 entries shouldn't block `didFinishLaunching`; nothing reads history at start. + .run { _ in + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { $0.pruneToHistoryCap() } + }, .run(operation: { _ in libraryClient.initializeWebImage() }), .run(operation: { _ in cookieClient.removeYay() }), .run(operation: { _ in cookieClient.syncExCookies() }), From a208753775e0baea9d5c2f19e1adec71d7cd8e08 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 17:36:14 +0800 Subject: [PATCH 537/614] Remove dead fields from persisted models --- AppPackage/Sources/AppModels/Gallery/GalleryState.swift | 4 ---- AppPackage/Sources/AppModels/Persistent/User.swift | 6 +----- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryState.swift b/AppPackage/Sources/AppModels/Gallery/GalleryState.swift index e4eea7bc7..31ae7aacf 100644 --- a/AppPackage/Sources/AppModels/Gallery/GalleryState.swift +++ b/AppPackage/Sources/AppModels/Gallery/GalleryState.swift @@ -8,7 +8,6 @@ public struct GalleryState: Codable, Sendable { public let gid: String public var tags = [GalleryTag]() - public var readingProgress = 0 public var previewURLs = [Int: URL]() public var previewConfig: PreviewConfig? public var comments = [GalleryComment]() @@ -19,7 +18,6 @@ public struct GalleryState: Codable, Sendable { public init( gid: String, tags: [GalleryTag] = [GalleryTag](), - readingProgress: Int = 0, previewURLs: [Int: URL] = [Int: URL](), previewConfig: PreviewConfig? = nil, comments: [GalleryComment] = [GalleryComment](), @@ -29,7 +27,6 @@ public struct GalleryState: Codable, Sendable { ) { self.gid = gid self.tags = tags - self.readingProgress = readingProgress self.previewURLs = previewURLs self.previewConfig = previewConfig self.comments = comments @@ -44,7 +41,6 @@ extension GalleryState: CustomStringConvertible { describing: [ "gid": gid, "tagsCount": tags.count, - "readingProgress": readingProgress, "previewURLsCount": previewURLs.count, "previewConfig": String(describing: previewConfig), "commentsCount": comments.count, diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index 8e73aee45..0f6491eac 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -5,7 +5,6 @@ public struct User: Codable, Equatable, Sendable { public init( displayName: String? = nil, avatarURL: URL? = nil, - apikey: String? = nil, credits: String? = nil, galleryPoints: String? = nil, greeting: Greeting? = nil, @@ -13,7 +12,6 @@ public struct User: Codable, Equatable, Sendable { ) { self.displayName = displayName self.avatarURL = avatarURL - self.apikey = apikey self.credits = credits self.galleryPoints = galleryPoints self.greeting = greeting @@ -25,7 +23,6 @@ public struct User: Codable, Equatable, Sendable { public var schemaVersion = 1 public var displayName: String? public var avatarURL: URL? - public var apikey: String? public var credits: String? public var galleryPoints: String? @@ -40,7 +37,7 @@ public struct User: Codable, Equatable, Sendable { // `greeting` is intentionally absent so Codable skips it (it keeps its `nil` default on decode). private enum CodingKeys: String, CodingKey { - case schemaVersion, displayName, avatarURL, apikey, credits, galleryPoints, favoriteCategories + case schemaVersion, displayName, avatarURL, credits, galleryPoints, favoriteCategories } public func getFavoriteCategory(index: Int) -> String { @@ -65,7 +62,6 @@ extension User { schemaVersion = (try? container.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 displayName = try? container.decodeIfPresent(String.self, forKey: .displayName) avatarURL = try? container.decodeIfPresent(URL.self, forKey: .avatarURL) - apikey = try? container.decodeIfPresent(String.self, forKey: .apikey) credits = try? container.decodeIfPresent(String.self, forKey: .credits) galleryPoints = try? container.decodeIfPresent(String.self, forKey: .galleryPoints) favoriteCategories = try? container.decodeIfPresent([Int: String].self, forKey: .favoriteCategories) From 48b65f73324d76da7d3e31a51468ca38908f4e41 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 17:37:08 +0800 Subject: [PATCH 538/614] Drop Core Data debug args from scheme --- .../xcshareddata/xcschemes/EhPanda.xcscheme | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme index c6fda6856..50514d90c 100644 --- a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme +++ b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme @@ -54,20 +54,6 @@ ReferencedContainer = "container:EhPanda.xcodeproj"> - - - - - - - -
Date: Mon, 6 Jul 2026 17:40:51 +0800 Subject: [PATCH 539/614] Decode HTML entities in a single pass --- .../Request+GalleriesMetadata.swift | 46 +++++++------------ .../GalleriesMetadataDecodeTests.swift | 21 +++++++++ 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift index e31dc2b8b..facc34729 100644 --- a/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift +++ b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift @@ -153,38 +153,26 @@ private extension Array { } private extension String { - /// Decodes numeric character references (`{` / ``) and the core XML named entities - /// found in `gdata` titles. `&` is resolved last so an already-decoded `&` isn't re-read. + /// Decodes numeric character references (`{` / ``) and the core XML named entities in + /// `gdata` titles in a single left-to-right pass, so a decoded substitution is never rescanned: an + /// escaped literal `<` written as `&lt;` decodes once to `<`, not to `<`. (A two-stage + /// numeric-then-named decode broke that invariant.) var htmlEntitiesDecoded: String { - decodingCharacterReferences() - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: """, with: "\"") - .replacingOccurrences(of: "'", with: "'") - .replacingOccurrences(of: "&", with: "&") - } - - private func decodingCharacterReferences() -> String { - var output = "" - var remainder = Substring(self) - while let start = remainder.range(of: "&#") { - output += remainder[remainder.startIndex.." + case "quot": return "\"" + case "apos": return "'" + default: return String(match.0) } - remainder = remainder[remainder.index(after: semicolon)...] } - output += remainder - return output } } diff --git a/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift b/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift index d1adfb174..723dc2034 100644 --- a/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift +++ b/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift @@ -38,6 +38,27 @@ struct GalleriesMetadataDecodeTests { #expect(galleries.first?.title == "First & Title") } + // REV-15: entities in the title decode in a single left-to-right pass. `&lt;` is an escaped + // literal `<`, so it must decode once to `<` — not be re-read into `<`. Numeric, hex and + // named references all resolve in the same pass. + @Test + func titleEntitiesDecodeInASinglePass() throws { + let json = """ + { + "gmetadata": [ + { + "gid": 100, "token": "aaa", + "title": "&lt; A & <tag>", + "posted": "1600000000" + } + ] + } + """ + let galleries = try GalleriesMetadataRequest.galleries(fromResponseData: Data(json.utf8)) + // &lt; -> "<" (not "<"), A -> "A", & -> "&", <tag> -> "". + #expect(galleries.first?.title == "< A & ") + } + // A tokenless (but error-free) entry is unresolvable — its gallery URL can't be built — so it is // dropped rather than decoded with an empty token. @Test From f2c6c7a612b22430a80d7fb21bd8f82d804f40b4 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 17:42:21 +0800 Subject: [PATCH 540/614] Document cap policies and schemaVersion --- .../AppModels/Persistence/AppSharedKeys.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift index df4745d30..af567da25 100644 --- a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -17,6 +17,20 @@ import Sharing // + defaults): additive changes never invalidate an existing persisted value, and a decode // failure falls back to the key's default — there is no store-fails-to-open failure mode. // +// Cap policies differ by the *kind* of data, deliberately: +// • `galleryHistory` (auto-recorded browsing) is capped at `GalleryHistoryEntry.historyCap` and +// trimmed only by a launch-time prune, so in-session upserts may briefly exceed the cap. +// • `historyKeywords` (auto-recorded searches) is capped at write time by evicting the oldest, +// keeping the most recent 20. +// • `quickSearchWords` (user-*authored* presets) is capped at `QuickSearchReducer.wordLimit` but is +// never evicted — the add control is simply disabled at the limit, because silently dropping a +// saved word would lose user work. Auto-recorded data is disposable; authored data is not. +// +// Every model also carries a `schemaVersion` (default 1): a reserved anchor for a future *breaking* +// migration. Additive changes never touch it — they ride the tolerant `init(from:)` decoders above. +// It exists only so a genuinely incompatible change has an explicit version to branch on, rather than +// inferring compatibility from the shape of the decoded data. +// // Nothing here uses the `fileStorage` strategy. The tag-translation table is the only large // artifact, and it is deliberately NOT persisted through Sharing: only its thin // `tagTranslatorInfo` metadata lives in app storage, while the multi-megabyte translations are a From 1b0ad9fa7c7f9be9dc39609ab5ac6b85daad362f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 17:44:46 +0800 Subject: [PATCH 541/614] Guard recently-seen suggestions from re-fetching --- AppPackage/Sources/SearchFeature/SearchRootReducer.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift index 8090bfb2f..a2300407a 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -11,6 +11,10 @@ import DetailFeature @Reducer public struct SearchRootReducer: Sendable { + private enum CancelID { + case fetchHistoryGalleries + } + public enum Delegate: Equatable, Sendable { case presentGalleryDetail(Gallery) } @@ -165,10 +169,15 @@ public struct SearchRootReducer: Sendable { state.historyGalleries = [] return .none } + // Skip when the shown suggestions already reflect the 10 most-recent gids — a plain pop + // back to the Search root shouldn't re-download identical metadata. `cancelInFlight` + // stops rapid re-entry from stacking overlapping, last-writer-wins requests. + guard pairs.map(\.gid) != state.historyGalleries.map(\.gid) else { return .none } return .run { send in let response = await GalleriesMetadataRequest(gidList: pairs).response() await send(.fetchHistoryGalleriesDone(response)) } + .cancellable(id: CancelID.fetchHistoryGalleries, cancelInFlight: true) case .fetchHistoryGalleriesDone(let result): if case .success(let galleries) = result { From 223e1e5ab44c517deaa47c37c8d646dad4c09e82 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 17:53:46 +0800 Subject: [PATCH 542/614] Replace fossil databaseLoadingState with restore flag --- .../DetailFeature/Previews/PreviewsReducer.swift | 6 ++++-- .../DetailFeature/Previews/PreviewsView.swift | 4 ++-- .../Sources/ReadingFeature/ReadingReducer+Body.swift | 2 +- ...r+Database.swift => ReadingReducer+Session.swift} | 12 ++++++------ .../Sources/ReadingFeature/ReadingReducer.swift | 8 ++++++-- AppPackage/Sources/ReadingFeature/ReadingView.swift | 6 +++--- .../ReadingFeature/ReadingViewComponents.swift | 8 ++++---- .../DownloadObserverReadingTests.swift | 6 +++--- .../ReadingReducerLocalTests.swift | 2 +- 9 files changed, 30 insertions(+), 24 deletions(-) rename AppPackage/Sources/ReadingFeature/{ReadingReducer+Database.swift => ReadingReducer+Session.swift} (94%) diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index 25b687e6d..60b1e4c5a 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -33,7 +33,9 @@ public struct PreviewsReducer: Sendable { // remote sessions — Previews itself never fetches a gallery detail to re-derive them. public var language: Language? public var loadingState: LoadingState = .idle - public var databaseLoadingState: LoadingState = .loading + // True once the session has been restored on first `onAppear` (synchronous — not a load). The + // grid gates preview fetches and its rebuild on this so they run against the restored session. + public var hasRestoredSession = false public var previewURLs = [Int: URL]() public var localPreviewURLs = [Int: URL]() @@ -105,7 +107,7 @@ public struct PreviewsReducer: Sendable { case .onAppear(let gid): // Gallery is seeded from the pushing context; preview URLs are fetched on demand. - state.databaseLoadingState = .idle + state.hasRestoredSession = true return .merge( .send(.observeDownloads(gid)), .send(.loadLocalPreviewURLs(gid)) diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift index 77b2f9b7a..3eca96c53 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift @@ -52,7 +52,7 @@ struct PreviewsView: View { .foregroundColor(.secondary) } .onAppear { - if store.databaseLoadingState != .loading + if store.hasRestoredSession && displayPreviewURLs[index] == nil && (index - 1) % 10 == 0 { store.send(.fetchPreviewURLs(index)) } @@ -61,7 +61,7 @@ struct PreviewsView: View { } .padding(.horizontal) .padding(.bottom) - .id(store.databaseLoadingState) + .id(store.hasRestoredSession) } .fullScreenCover( item: $store.scope(state: \.destination?.reading, action: \.destination.reading) diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index 99de88239..6d9e2f31d 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -85,7 +85,7 @@ extension ReadingReducer { case .onAppear(let gid, let enablesLandscape): var effects: [Effect] = [ - .send(.fetchDatabaseInfos(gid)), + .send(.restoreSession(gid)), .send(.observeDownloads(gid)), .send(.loadLocalPageURLs(gid)) ] diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift similarity index 94% rename from AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift rename to AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift index 2893116bd..fe48118ad 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Database.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift @@ -4,7 +4,7 @@ import Sharing import ComposableArchitecture import AppTools -// MARK: - Database & Download Actions +// MARK: - Session Restore & Download Actions extension ReadingReducer { var databaseReducer: some ReducerOf { Reduce { state, action in @@ -31,8 +31,8 @@ extension ReadingReducer { } return .none - case .fetchDatabaseInfos(let gid): - return reduceFetchDatabaseInfos(state: &state, gid: gid) + case .restoreSession(let gid): + return reduceRestoreSession(state: &state, gid: gid) case .observeDownloads(let gid): return reduceObserveDownloads(gid: gid) @@ -55,7 +55,7 @@ extension ReadingReducer { } } - func reduceFetchDatabaseInfos(state: inout State, gid: String) -> Effect { + func reduceRestoreSession(state: inout State, gid: String) -> Effect { if case .local(let download, let manifest) = state.contentSource { applyLocalSource(state: &state, download: download, manifest: manifest) } @@ -64,7 +64,7 @@ extension ReadingReducer { // from the persisted browsing history. @Shared(.galleryHistory) var galleryHistory state.readingProgress = galleryHistory.readingProgress(gid: gid) - state.databaseLoadingState = .idle + state.hasRestoredSession = true return .none } @@ -146,6 +146,6 @@ extension ReadingReducer { state.mpvSkipServerIdentifiers = .init() state.imageURLLoadingStates = .init() state.previewLoadingStates = .init() - state.databaseLoadingState = .idle + state.hasRestoredSession = true } } diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 2d4a17b79..619bf33e0 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -57,7 +57,11 @@ public struct ReadingReducer: Sendable { public var webImageLoadSuccessIndices = Set() public var imageURLLoadingStates = [Int: LoadingState]() public var previewLoadingStates = [Int: LoadingState]() - public var databaseLoadingState: LoadingState = .loading + // True once the reading session has been restored on the first `onAppear`: the resume page + // from history, plus the local source for downloads. Image fetches, progress syncs and the + // pager rebuild gate on this so they run against a restored session, not the initial + // placeholder. (Not a loading state — the restore is synchronous; there is no async fetch.) + public var hasRestoredSession = false public var previewConfig: PreviewConfig = .normal(rows: 4) public var previewURLs = [Int: URL]() @@ -177,7 +181,7 @@ public struct ReadingReducer: Sendable { case syncReadingProgress(Int) case flushReadingProgress - case fetchDatabaseInfos(String) + case restoreSession(String) case observeDownloads(String) case observeDownloadsDone([DownloadedGallery]) case loadLocalPageURLs(String) diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 1536e41ac..095372f02 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -161,7 +161,7 @@ public struct ReadingView: View { .gesture(tapGesture, isEnabled: gestureHandler.scale == 1) .gesture(magnificationGesture) .ignoresSafeArea() - .id(store.databaseLoadingState) + .id(store.hasRestoredSession) .id(store.forceRefreshID) ControlPanel( @@ -209,7 +209,7 @@ public struct ReadingView: View { index: newValue, pageCount: store.gallery.pageCount, setting: setting ) pageHandler.sliderValue = .init(newValue) - if store.databaseLoadingState == .idle { + if store.hasRestoredSession { store.send(.syncReadingProgress(.init(newValue))) } } @@ -246,7 +246,7 @@ public struct ReadingView: View { index: index, isDualPage: isDualPage, isActive: index == activeStackIndex, - isDatabaseLoading: store.databaseLoadingState != .idle, + isSessionRestored: store.hasRestoredSession, backgroundColor: backgroundColor, config: imageStackConfig, imageURLs: displayImageURLs, diff --git a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift index 6100035cb..dbb7db52a 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift @@ -47,7 +47,7 @@ struct HorizontalImageStack: View { private let index: Int private let isDualPage: Bool private let isActive: Bool - private let isDatabaseLoading: Bool + private let isSessionRestored: Bool private let backgroundColor: Color private let config: ImageStackConfig private let imageURLs: [Int: URL] @@ -68,7 +68,7 @@ struct HorizontalImageStack: View { private let shareImageAction: (URL) -> Void init( - index: Int, isDualPage: Bool, isActive: Bool, isDatabaseLoading: Bool, backgroundColor: Color, + index: Int, isDualPage: Bool, isActive: Bool, isSessionRestored: Bool, backgroundColor: Color, config: ImageStackConfig, imageURLs: [Int: URL], originalImageURLs: [Int: URL], loadingStates: [Int: LoadingState], enablesLiveText: Bool, liveTextGroups: [Int: [LiveTextGroup]], focusedLiveTextGroup: LiveTextGroup?, @@ -82,7 +82,7 @@ struct HorizontalImageStack: View { self.index = index self.isDualPage = isDualPage self.isActive = isActive - self.isDatabaseLoading = isDatabaseLoading + self.isSessionRestored = isSessionRestored self.backgroundColor = backgroundColor self.config = config self.imageURLs = imageURLs @@ -132,7 +132,7 @@ struct HorizontalImageStack: View { loadFailedAction: loadFailedAction ) .onAppear { - if !isDatabaseLoading { + if isSessionRestored { if imageURLs[index] == nil { fetchAction(index) } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift index 46a37d962..dd34a8dc4 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift @@ -63,18 +63,18 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { options: .atomic ) - await store.send(.fetchDatabaseInfos(download.gid)) { + await store.send(.restoreSession(download.gid)) { $0.gallery = download.gallery $0.language = manifest.language $0.localPageURLs = [ 1: folderURL.appendingPathComponent("123_token_1.jpg"), 2: folderURL.appendingPathComponent("123_token_2.jpg") ] - $0.databaseLoadingState = .idle + $0.hasRestoredSession = true } await store.finish() - #expect(store.state.databaseLoadingState == .idle) + #expect(store.state.hasRestoredSession) #expect(store.state.readingProgress == 0) } diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift index 753725a64..6d24856d9 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift @@ -121,7 +121,7 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { } store.exhaustivity = .off - await store.send(.fetchDatabaseInfos(download.gid)) + await store.send(.restoreSession(download.gid)) #expect(store.state.gallery.id == download.gid) #expect(store.state.localPageURLs[1] == folderURL.appendingPathComponent("123_token_1.jpg")) #expect(store.state.localPageURLs[2] == folderURL.appendingPathComponent("123_token_2.jpg")) From 790a947e776d16ceec4259fc82786fa2fffb4d96 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 17:57:18 +0800 Subject: [PATCH 543/614] Delete custom translations file on removal --- .../Sources/FileClient/FileClient.swift | 12 +++++++++-- .../SettingFeature/SettingReducer+Body.swift | 5 +++-- .../FileClientTests/FileClientTests.swift | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/AppPackage/Sources/FileClient/FileClient.swift b/AppPackage/Sources/FileClient/FileClient.swift index 8c32febff..56951ea25 100644 --- a/AppPackage/Sources/FileClient/FileClient.swift +++ b/AppPackage/Sources/FileClient/FileClient.swift @@ -13,6 +13,9 @@ public struct FileClient: Sendable { /// Rebuilds the in-memory translator from the cached raw JSON described by `info` — Application /// Support for a custom import, Caches for a remote download. `nil` if the cache is missing. public var loadCachedTagTranslator: @Sendable (TagTranslatorInfo) -> TagTranslator? + /// Deletes the imported custom-translations file from Application Support. That directory is not + /// purgeable, so a removed import must be cleaned up explicitly rather than left on disk forever. + public var removeCustomTranslations: @Sendable () -> Void } // Fixed name for a user-imported table, kept in Application Support because it cannot be @@ -97,6 +100,9 @@ extension FileClient { return TagTranslator( language: language, updatedDate: info.updatedDate, translations: translations ) + }, + removeCustomTranslations: { + try? FileManager.default.removeItem(at: customTranslationsURL) } ) @@ -126,7 +132,8 @@ extension FileClient { createFile: { _, _ in false }, importTagTranslator: { _ in .success(.init()) }, cacheAndBuildRemoteTagTranslator: { _, _, _ in nil }, - loadCachedTagTranslator: { _ in nil } + loadCachedTagTranslator: { _ in nil }, + removeCustomTranslations: {} ) public static func placeholder() -> Result { fatalError() } @@ -135,6 +142,7 @@ extension FileClient { createFile: IssueReporting.unimplemented(placeholder: placeholder()), importTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()), cacheAndBuildRemoteTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()), - loadCachedTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()) + loadCachedTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()), + removeCustomTranslations: IssueReporting.unimplemented(placeholder: placeholder()) ) } diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 9c1091034..4e8b477de 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -272,10 +272,11 @@ extension SettingReducer { } case .path(.element(id: _, action: .general(.onRemoveCustomTranslations))): - // Drop the custom table from memory and metadata; the launch/remote flow refills it. + // Drop the custom table from memory, metadata, and disk; the launch/remote flow refills + // it. FileClient owns the path, so the file lifecycle stays behind one module. state.tagTranslator = TagTranslator() state.$tagTranslatorInfo.withLock { $0.hasCustomTranslations = false } - return .none + return .run { _ in fileClient.removeCustomTranslations() } case .igneousRefreshed: return .none diff --git a/AppPackage/Tests/FileClientTests/FileClientTests.swift b/AppPackage/Tests/FileClientTests/FileClientTests.swift index ed4512fb8..3d16f0dc0 100644 --- a/AppPackage/Tests/FileClientTests/FileClientTests.swift +++ b/AppPackage/Tests/FileClientTests/FileClientTests.swift @@ -93,4 +93,25 @@ struct FileClientTests { ) #expect(FileClient.live.loadCachedTagTranslator(TagTranslatorInfo(language: language)) == nil) } + + // REV-14: removing custom translations must delete the imported file from Application Support so it + // doesn't linger in non-purgeable storage forever. + @Test + func removeCustomTranslationsDeletesTheImportedFile() async throws { + let url = try writeTemporaryFile(try sampleResponseData()) + defer { + try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem(at: customTranslationsURL) + } + + _ = try await FileClient.live.importTagTranslator(url).get() + #expect(FileManager.default.fileExists(atPath: customTranslationsURL.path)) + + FileClient.live.removeCustomTranslations() + + #expect(!FileManager.default.fileExists(atPath: customTranslationsURL.path)) + #expect( + FileClient.live.loadCachedTagTranslator(TagTranslatorInfo(hasCustomTranslations: true)) == nil + ) + } } From caf4d19454a6ff70cc257b7e3854d94597ed6eb2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 18:01:18 +0800 Subject: [PATCH 544/614] Share newer-only greeting merge across writers --- .../Sources/AppModels/Persistent/User.swift | 17 ++++++ .../DetailFeature/DetailReducer+Actions.swift | 6 +- .../SettingFeature/SettingReducer.swift | 12 +--- .../Tests/AppModelsTests/UserTests.swift | 56 +++++++++++++++++++ 4 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 AppPackage/Tests/AppModelsTests/UserTests.swift diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index 0f6491eac..82cf96c11 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -49,6 +49,23 @@ public struct User: Codable, Equatable, Sendable { } } +extension User { + /// Adopts `greeting` only when it is newer than the one already held (or none is held). Two + /// features write greetings — the Setting daily fetch and the Detail-page parse — so the + /// "keep the newer" rule lives here, not at either call site, and a stale detail-page greeting + /// can't clobber a fresher one. `greeting` is session-only and never persisted (see `CodingKeys`). + public mutating func mergeGreeting(_ greeting: Greeting) { + guard let newDate = greeting.updateTime else { return } + if let current = self.greeting { + if let currentDate = current.updateTime, currentDate < newDate { + self.greeting = greeting + } + } else { + self.greeting = greeting + } + } +} + // MARK: Manually decode extension User { // Tolerant decoding keeps an existing persisted value valid across future additive changes; a diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index d80c7be84..969b60bb6 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -143,10 +143,10 @@ extension DetailReducer { Reduce { state, action in switch action { case .syncGreeting(let greeting): - // Greeting is session-only (not persisted with `User`); write it to the shared - // in-memory user so the greeting-fetch throttle stays coherent across features. + // Greeting is session-only (not persisted with `User`). Merge through the shared user's + // newer-only rule so a stale detail-page greeting can't clobber a fresher Setting fetch. @Shared(.user) var user - $user.withLock { $0.greeting = greeting } + $user.withLock { $0.mergeGreeting(greeting) } return .none case .saveGalleryHistory: diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index 8ad399b65..a2158329c 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -69,17 +69,7 @@ public struct SettingReducer: Sendable { public init() {} mutating func setGreeting(_ greeting: Greeting) { - guard let currDate = greeting.updateTime else { return } - - $user.withLock { user in - if let prevGreeting = user.greeting, - let prevDate = prevGreeting.updateTime, - prevDate < currDate { - user.greeting = greeting - } else if user.greeting == nil { - user.greeting = greeting - } - } + $user.withLock { $0.mergeGreeting(greeting) } } mutating func updateUser(_ user: User) { diff --git a/AppPackage/Tests/AppModelsTests/UserTests.swift b/AppPackage/Tests/AppModelsTests/UserTests.swift new file mode 100644 index 000000000..de149d321 --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/UserTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +import AppModels + +// REV-21: greetings have two writers (the Setting daily fetch and the Detail-page parse), so the +// "keep the newer" merge lives on `User.mergeGreeting`. A stale detail-page greeting must not clobber +// a fresher one. Greeting is also session-only: it must never survive a Codable round-trip. +@Suite +struct UserTests { + private let older = Greeting(gainedCredits: 1, updateTime: Date(timeIntervalSince1970: 100)) + private let newer = Greeting(gainedCredits: 2, updateTime: Date(timeIntervalSince1970: 200)) + + @Test + func mergeGreetingAdoptsWhenNoneHeld() { + var user = User() + user.mergeGreeting(newer) + #expect(user.greeting?.gainedCredits == 2) + } + + @Test + func mergeGreetingAdoptsAStrictlyNewerGreeting() { + var user = User() + user.greeting = older + user.mergeGreeting(newer) + #expect(user.greeting?.gainedCredits == 2) + } + + @Test + func mergeGreetingKeepsTheHeldGreetingWhenIncomingIsOlder() { + var user = User() + user.greeting = newer + user.mergeGreeting(older) + #expect(user.greeting?.gainedCredits == 2) + } + + @Test + func mergeGreetingIgnoresADatelessGreeting() { + var user = User() + user.greeting = newer + user.mergeGreeting(Greeting(gainedCredits: 9, updateTime: nil)) + #expect(user.greeting?.gainedCredits == 2) + } + + @Test + func greetingIsNeverPersistedThroughCodable() throws { + var user = User(displayName: "keep-me", credits: "42") + user.greeting = newer + + let data = try JSONEncoder().encode(user) + let decoded = try JSONDecoder().decode(User.self, from: data) + + #expect(decoded.greeting == nil) // session-only: dropped on encode/decode + #expect(decoded.displayName == "keep-me") // durable fields survive + #expect(decoded.credits == "42") + } +} From a4c0cd48024456311baf5aa16e7a8d5ed990d447 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 18:09:39 +0800 Subject: [PATCH 545/614] Extract shared LimitBanner component --- .../Sources/AppComponents/LimitBanner.swift | 22 +++++++++++++++++++ .../HomeFeature/History/HistoryView.swift | 16 ++++---------- .../QuickSearchFeature/QuickSearchView.swift | 16 ++++---------- 3 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 AppPackage/Sources/AppComponents/LimitBanner.swift diff --git a/AppPackage/Sources/AppComponents/LimitBanner.swift b/AppPackage/Sources/AppComponents/LimitBanner.swift new file mode 100644 index 000000000..22f2a331c --- /dev/null +++ b/AppPackage/Sources/AppComponents/LimitBanner.swift @@ -0,0 +1,22 @@ +import SwiftUI + +/// An always-visible footnote notice, meant to be pinned to the top of a list via `.safeAreaInset`, +/// explaining that the list has a size cap. The caller supplies the localized `Text` so each feature +/// keeps its own module-local string; only the identical presentation is shared here. +public struct LimitBanner: View { + private let text: Text + + public init(_ text: Text) { + self.text = text + } + + public var body: some View { + text + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.bar) + } +} diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index 76802b7d6..eb490c17b 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -40,7 +40,10 @@ struct HistoryView: View { }, downloadBadges: store.downloadBadges ) - .safeAreaInset(edge: .top, spacing: 0) { historyLimitBanner } + .safeAreaInset(edge: .top, spacing: 0) { + // Always-visible notice: only the most-recent records survive the launch-time prune. + LimitBanner(Text(.historyLimitDescription(limit: GalleryHistoryEntry.historyCap))) + } .searchable(text: $store.keyword, prompt: .filter) .onAppear { store.send(.onAppear) @@ -54,17 +57,6 @@ struct HistoryView: View { .navigationTitle(.history) } - // Always-visible notice: only the most-recent records survive the launch-time prune. - private var historyLimitBanner: some View { - Text(.historyLimitDescription(limit: GalleryHistoryEntry.historyCap)) - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.horizontal) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.bar) - } - private func toolbar() -> some ToolbarContent { CustomToolbarItem { Button { diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index 84e417cd5..aa3c1ca11 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -60,7 +60,10 @@ public struct QuickSearchView: View { ErrorView(error: .notFound) .opacity(store.quickSearchWords.isEmpty ? 1 : 0) } - .safeAreaInset(edge: .top, spacing: 0) { wordLimitBanner } + .safeAreaInset(edge: .top, spacing: 0) { + // Always-visible notice: the word list is capped and the add button disables at the limit. + LimitBanner(Text(.wordLimitDescription(limit: QuickSearchReducer.wordLimit))) + } .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) @@ -74,17 +77,6 @@ public struct QuickSearchView: View { } } - // Always-visible notice: the word list is capped and the add button disables at the limit. - private var wordLimitBanner: some View { - Text(.wordLimitDescription(limit: QuickSearchReducer.wordLimit)) - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.horizontal) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.bar) - } - private func onTextFieldSubmitted() { switch focusedField { case .name: From 030a8340220bac646735afbb090ab6011b420691 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 18:15:08 +0800 Subject: [PATCH 546/614] Share one gdata publisher across both requests --- .../NetworkingFeature/Request+Detail.swift | 32 ++++------------- .../NetworkingFeature/Request+GData.swift | 36 +++++++++++++++++++ .../Request+GalleriesMetadata.swift | 21 ++--------- 3 files changed, 46 insertions(+), 43 deletions(-) create mode 100644 AppPackage/Sources/NetworkingFeature/Request+GData.swift diff --git a/AppPackage/Sources/NetworkingFeature/Request+Detail.swift b/AppPackage/Sources/NetworkingFeature/Request+Detail.swift index 442e3129a..85d084eab 100644 --- a/AppPackage/Sources/NetworkingFeature/Request+Detail.swift +++ b/AppPackage/Sources/NetworkingFeature/Request+Detail.swift @@ -128,32 +128,14 @@ public struct GalleryVersionMetadataRequest: Request { return Fail(error: AppError.notFound) .eraseToAnyPublisher() } - - let params: [String: Any] = [ - "method": "gdata", - "gidlist": [[gid, token]], - "namespace": 1 - ] - - var request = URLRequest(url: Defaults.URL.api) - request.httpMethod = "POST" - request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) - - return urlSession.dataTaskPublisher(for: request) - .genericRetry() - .map(\.data) - .tryMap { data in - try parseResponse(data: data) { - let response = try JSONDecoder() - .decode(GalleryVersionMetadataAPIResponse.self, from: $0) - guard let metadata = response.gmetadata.first?.versionMetadata else { - throw AppError.notFound - } - return metadata - } + return gdataPublisher(gidlist: [[gid, token]], urlSession: urlSession) { + let response = try JSONDecoder() + .decode(GalleryVersionMetadataAPIResponse.self, from: $0) + guard let metadata = response.gmetadata.first?.versionMetadata else { + throw AppError.notFound } - .mapError(mapAppError) - .eraseToAnyPublisher() + return metadata + } } } diff --git a/AppPackage/Sources/NetworkingFeature/Request+GData.swift b/AppPackage/Sources/NetworkingFeature/Request+GData.swift new file mode 100644 index 000000000..24c631371 --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Request+GData.swift @@ -0,0 +1,36 @@ +import AppModels +import Combine +import Foundation +import AppTools + +extension Request { + /// Shared plumbing for the `gdata` API (`api.php` `method=gdata`): builds the POST body from a list + /// of `[gid, token]` pairs, retries transient failures, hands the raw payload to `decode`, and maps + /// errors uniformly. Both the batch `GalleriesMetadataRequest` and the single-gallery + /// `GalleryVersionMetadataRequest` are thin wrappers over this, so a future gdata contract change + /// (params, retry policy, error mapping) lands in exactly one place. + /// + /// The endpoint accepts at most 25 pairs per call; callers that may exceed that must chunk before + /// calling and cap their own in-flight fan-out. + func gdataPublisher( + gidlist: [[Any]], + urlSession: URLSession, + decode: @escaping (Data) throws -> T + ) -> AnyPublisher { + let params: [String: Any] = [ + "method": "gdata", + "gidlist": gidlist, + "namespace": 1 + ] + var request = URLRequest(url: Defaults.URL.api) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) + + return urlSession.dataTaskPublisher(for: request) + .genericRetry() + .map(\.data) + .tryMap { data in try parseResponse(data: data, decode) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift index facc34729..86370dfc3 100644 --- a/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift +++ b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift @@ -114,24 +114,9 @@ public struct GalleriesMetadataRequest: Request { guard !gidlist.isEmpty else { return Just([]).setFailureType(to: AppError.self).eraseToAnyPublisher() } - - let params: [String: Any] = [ - "method": "gdata", - "gidlist": gidlist, - "namespace": 1 - ] - var request = URLRequest(url: Defaults.URL.api) - request.httpMethod = "POST" - request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) - - return urlSession.dataTaskPublisher(for: request) - .genericRetry() - .map(\.data) - .tryMap { data in - try parseResponse(data: data) { try Self.galleries(fromResponseData: $0) } - } - .mapError(mapAppError) - .eraseToAnyPublisher() + return gdataPublisher(gidlist: gidlist, urlSession: urlSession) { + try Self.galleries(fromResponseData: $0) + } } /// Decodes a raw `gdata` payload into galleries, silently dropping every unresolvable From eaad9d0ed725a1ec35a05e71c11ee0c027b1347c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 18:21:14 +0800 Subject: [PATCH 547/614] Add Filter snapshot accessors for shared reads --- .../AppModels/Persistence/AppSharedKeys.swift | 19 +++++++++++++++++++ .../DetailSearch/DetailSearchReducer.swift | 6 ++---- .../Frontpage/FrontpageReducer.swift | 6 ++---- .../HomeFeature/HomeReducer+Body.swift | 6 ++---- .../HomeFeature/Popular/PopularReducer.swift | 3 +-- .../HomeFeature/Watched/WatchedReducer.swift | 6 ++---- .../Sources/SearchFeature/SearchReducer.swift | 6 ++---- 7 files changed, 30 insertions(+), 22 deletions(-) diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift index af567da25..86e47cdfd 100644 --- a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -65,6 +65,25 @@ extension SharedKey where Self == AppStorageKey.Default { } } +// A reducer that fires a network request needs a *value* copy of the currently-persisted filter to +// capture into its `.run` closure — never the live `@Shared` reference, which would read a later, +// possibly mid-edit value by the time the effect actually runs. These accessors centralize that +// read-and-copy so no call site can accidentally capture the reference. +extension Filter { + public static var currentSearch: Filter { + @Shared(.searchFilter) var filter + return filter + } + public static var currentGlobal: Filter { + @Shared(.globalFilter) var filter + return filter + } + public static var currentWatched: Filter { + @Shared(.watchedFilter) var filter + return filter + } +} + // MARK: Search history & presets extension SharedKey where Self == AppStorageKey<[String]>.Default { diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index d696876ee..9239c0b34 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -101,8 +101,7 @@ public struct DetailSearchReducer: Sendable { } state.loadingState = .loading state.pageNumber.resetPages() - @Shared(.searchFilter) var storedFilter - let filter = storedFilter + let filter = Filter.currentSearch return .run { [lastKeyword = state.lastKeyword] send in let response = await SearchGalleriesRequest(keyword: lastKeyword, filter: filter).response() await send(.fetchGalleriesDone(response.map { ($0.pageNumber, $0.galleries) })) @@ -133,8 +132,7 @@ public struct DetailSearchReducer: Sendable { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - @Shared(.searchFilter) var storedFilter - let filter = storedFilter + let filter = Filter.currentSearch return .run { [lastKeyword = state.lastKeyword] send in let response = await MoreSearchGalleriesRequest( keyword: lastKeyword, filter: filter, lastID: lastID diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index cbdb0d61c..677797a89 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -91,8 +91,7 @@ public struct FrontpageReducer: Sendable { guard state.loadingState != .loading else { return .none } state.loadingState = .loading state.pageNumber.resetPages() - @Shared(.globalFilter) var storedFilter - let filter = storedFilter + let filter = Filter.currentGlobal return .run { send in let response = await FrontpageGalleriesRequest(filter: filter).response() await send(.fetchGalleriesDone(response)) @@ -125,8 +124,7 @@ public struct FrontpageReducer: Sendable { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - @Shared(.globalFilter) var storedFilter - let filter = storedFilter + let filter = Filter.currentGlobal return .run { send in let response = await MoreFrontpageGalleriesRequest(filter: filter, lastID: lastID).response() await send(.fetchMoreGalleriesDone(response)) diff --git a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift index 658a4f83c..66f9e48ba 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift @@ -99,8 +99,7 @@ extension HomeReducer { guard state.popularLoadingState != .loading else { return .none } state.popularLoadingState = .loading state.rawCardColors = [String: [Color]]() - @Shared(.globalFilter) var storedFilter - let filter = storedFilter + let filter = Filter.currentGlobal return .run { send in let response = await PopularGalleriesRequest(filter: filter).response() await send(.fetchPopularGalleriesDone(response)) @@ -124,8 +123,7 @@ extension HomeReducer { case .fetchFrontpageGalleries: guard state.frontpageLoadingState != .loading else { return .none } state.frontpageLoadingState = .loading - @Shared(.globalFilter) var storedFilter - let filter = storedFilter + let filter = Filter.currentGlobal return .run { send in let response = await FrontpageGalleriesRequest(filter: filter).response() await send(.fetchFrontpageGalleriesDone(response.map { ($0.pageNumber, $0.galleries) })) diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index 9071cfc3d..4eeff4497 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -71,8 +71,7 @@ public struct PopularReducer: Sendable { case .fetchGalleries: guard state.loadingState != .loading else { return .none } state.loadingState = .loading - @Shared(.globalFilter) var storedFilter - let filter = storedFilter + let filter = Filter.currentGlobal return .run { send in let response = await PopularGalleriesRequest(filter: filter).response() await send(.fetchGalleriesDone(response)) diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index 0a56b0e54..0d761634c 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -109,8 +109,7 @@ public struct WatchedReducer: Sendable { } state.loadingState = .loading state.pageNumber.resetPages() - @Shared(.watchedFilter) var storedFilter - let filter = storedFilter + let filter = Filter.currentWatched return .run { [keyword = state.keyword] send in let response = await WatchedGalleriesRequest(filter: filter, keyword: keyword).response() await send(.fetchGalleriesDone(response)) @@ -143,8 +142,7 @@ public struct WatchedReducer: Sendable { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - @Shared(.watchedFilter) var storedFilter - let filter = storedFilter + let filter = Filter.currentWatched return .run { [keyword = state.keyword] send in let response = await MoreWatchedGalleriesRequest( filter: filter, lastID: lastID, keyword: keyword diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index 67a43e9ed..4299cab5d 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -122,8 +122,7 @@ public struct SearchReducer: Sendable { } state.loadingState = .loading state.pageNumber.resetPages() - @Shared(.searchFilter) var storedFilter - let filter = storedFilter + let filter = Filter.currentSearch return .merge( historyEffect, .run { [lastKeyword = state.lastKeyword] send in @@ -159,8 +158,7 @@ public struct SearchReducer: Sendable { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - @Shared(.searchFilter) var storedFilter - let filter = storedFilter + let filter = Filter.currentSearch return .run { [lastKeyword = state.lastKeyword] send in let response = await MoreSearchGalleriesRequest( keyword: lastKeyword, filter: filter, lastID: lastID From 8d83b6f012d275d2894905a90e9d1582cddb0e8b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 6 Jul 2026 18:37:06 +0800 Subject: [PATCH 548/614] Unify tolerant decoders via shared helper --- .../KeyedDecodingContainer+Tolerant.swift | 23 +++++++ .../Sources/AppModels/Persistent/Filter.swift | 56 ++++++++-------- .../Persistent/GalleryHistoryEntry.swift | 10 +-- .../AppModels/Persistent/Setting.swift | 65 +++++++++---------- .../Sources/AppModels/Persistent/User.swift | 17 ++--- .../Sources/AppModels/Support/Misc.swift | 8 +-- .../AppModels/Tags/TagTranslatorInfo.swift | 9 ++- .../TolerantDecodingTests.swift | 58 +++++++++++++++++ 8 files changed, 159 insertions(+), 87 deletions(-) create mode 100644 AppPackage/Sources/AppModels/Persistence/KeyedDecodingContainer+Tolerant.swift create mode 100644 AppPackage/Tests/AppModelsTests/TolerantDecodingTests.swift diff --git a/AppPackage/Sources/AppModels/Persistence/KeyedDecodingContainer+Tolerant.swift b/AppPackage/Sources/AppModels/Persistence/KeyedDecodingContainer+Tolerant.swift new file mode 100644 index 000000000..be79dcc73 --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistence/KeyedDecodingContainer+Tolerant.swift @@ -0,0 +1,23 @@ +import Foundation + +extension Optional { + /// Tolerant keyed decode: returns the decoded value, or `defaultValue` when the key is absent, its + /// value is `null`, decoding it throws (a per-field type mismatch), or the container itself is + /// absent. This is the single home for the read-and-default idiom every persisted model's + /// hand-written `init(from:)` repeats per field, so a new persisted field costs one short line + /// instead of a `(try? container?.decodeIfPresent(…)) ?? default` one. Forgiving a per-field + /// decode failure is what lets an existing persisted value stay valid across future additive + /// changes (see `AppSharedKeys` for the whole-struct persistence rationale). + /// + /// Defined on the *optional* container because those decoders acquire it with + /// `try? decoder.container(keyedBy:)` — which yields `nil` on a shape mismatch rather than + /// throwing — and each field must still fall back to its own default independently, exactly as the + /// per-field `?? default` did. + func decode(_ key: Key, `default` defaultValue: Value) -> Value + where Wrapped == KeyedDecodingContainer { + guard let container = self, + let value = try? container.decodeIfPresent(Value.self, forKey: key) + else { return defaultValue } + return value + } +} diff --git a/AppPackage/Sources/AppModels/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift index 28151117c..0a2134f3e 100644 --- a/AppPackage/Sources/AppModels/Persistent/Filter.swift +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -119,38 +119,38 @@ public struct Filter: Codable, Equatable, Sendable { extension Filter { public init(from decoder: Decoder) { let container = try? decoder.container(keyedBy: CodingKeys.self) - schemaVersion = (try? container?.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 - doujinshi = (try? container?.decodeIfPresent(Bool.self, forKey: .doujinshi)) ?? false - manga = (try? container?.decodeIfPresent(Bool.self, forKey: .manga)) ?? false - artistCG = (try? container?.decodeIfPresent(Bool.self, forKey: .artistCG)) ?? false - gameCG = (try? container?.decodeIfPresent(Bool.self, forKey: .gameCG)) ?? false - western = (try? container?.decodeIfPresent(Bool.self, forKey: .western)) ?? false - nonH = (try? container?.decodeIfPresent(Bool.self, forKey: .nonH)) ?? false - imageSet = (try? container?.decodeIfPresent(Bool.self, forKey: .imageSet)) ?? false - cosplay = (try? container?.decodeIfPresent(Bool.self, forKey: .cosplay)) ?? false - asianPorn = (try? container?.decodeIfPresent(Bool.self, forKey: .asianPorn)) ?? false - misc = (try? container?.decodeIfPresent(Bool.self, forKey: .misc)) ?? false + schemaVersion = container.decode(.schemaVersion, default: 1) + doujinshi = container.decode(.doujinshi, default: false) + manga = container.decode(.manga, default: false) + artistCG = container.decode(.artistCG, default: false) + gameCG = container.decode(.gameCG, default: false) + western = container.decode(.western, default: false) + nonH = container.decode(.nonH, default: false) + imageSet = container.decode(.imageSet, default: false) + cosplay = container.decode(.cosplay, default: false) + asianPorn = container.decode(.asianPorn, default: false) + misc = container.decode(.misc, default: false) - advanced = (try? container?.decodeIfPresent(Bool.self, forKey: .advanced)) ?? false - galleryName = (try? container?.decodeIfPresent(Bool.self, forKey: .galleryName)) ?? false - galleryTags = (try? container?.decodeIfPresent(Bool.self, forKey: .galleryTags)) ?? false - galleryDesc = (try? container?.decodeIfPresent(Bool.self, forKey: .galleryDesc)) ?? false - torrentFilenames = (try? container?.decodeIfPresent(Bool.self, forKey: .torrentFilenames)) ?? false - onlyWithTorrents = (try? container?.decodeIfPresent(Bool.self, forKey: .onlyWithTorrents)) ?? false - lowPowerTags = (try? container?.decodeIfPresent(Bool.self, forKey: .lowPowerTags)) ?? false - downvotedTags = (try? container?.decodeIfPresent(Bool.self, forKey: .downvotedTags)) ?? false - expungedGalleries = (try? container?.decodeIfPresent(Bool.self, forKey: .expungedGalleries)) ?? false + advanced = container.decode(.advanced, default: false) + galleryName = container.decode(.galleryName, default: false) + galleryTags = container.decode(.galleryTags, default: false) + galleryDesc = container.decode(.galleryDesc, default: false) + torrentFilenames = container.decode(.torrentFilenames, default: false) + onlyWithTorrents = container.decode(.onlyWithTorrents, default: false) + lowPowerTags = container.decode(.lowPowerTags, default: false) + downvotedTags = container.decode(.downvotedTags, default: false) + expungedGalleries = container.decode(.expungedGalleries, default: false) - minRatingActivated = (try? container?.decodeIfPresent(Bool.self, forKey: .minRatingActivated)) ?? false - minRating = (try? container?.decodeIfPresent(Int.self, forKey: .minRating)) ?? 2 + minRatingActivated = container.decode(.minRatingActivated, default: false) + minRating = container.decode(.minRating, default: 2) - pageRangeActivated = (try? container?.decodeIfPresent(Bool.self, forKey: .pageRangeActivated)) ?? false - pageLowerBound = (try? container?.decodeIfPresent(String.self, forKey: .pageLowerBound)) ?? "" - pageUpperBound = (try? container?.decodeIfPresent(String.self, forKey: .pageUpperBound)) ?? "" + pageRangeActivated = container.decode(.pageRangeActivated, default: false) + pageLowerBound = container.decode(.pageLowerBound, default: "") + pageUpperBound = container.decode(.pageUpperBound, default: "") - disableLanguage = (try? container?.decodeIfPresent(Bool.self, forKey: .disableLanguage)) ?? false - disableUploader = (try? container?.decodeIfPresent(Bool.self, forKey: .disableUploader)) ?? false - disableTags = (try? container?.decodeIfPresent(Bool.self, forKey: .disableTags)) ?? false + disableLanguage = container.decode(.disableLanguage, default: false) + disableUploader = container.decode(.disableUploader, default: false) + disableTags = container.decode(.disableTags, default: false) } } diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift index 770f5aeb0..eceb3aaa9 100644 --- a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift @@ -38,10 +38,10 @@ public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable { extension GalleryHistoryEntry { public init(from decoder: Decoder) { let container = try? decoder.container(keyedBy: CodingKeys.self) - schemaVersion = (try? container?.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 - gid = (try? container?.decodeIfPresent(String.self, forKey: .gid)) ?? "" - token = (try? container?.decodeIfPresent(String.self, forKey: .token)) ?? "" - lastOpenDate = (try? container?.decodeIfPresent(Date.self, forKey: .lastOpenDate)) ?? .distantPast - readingProgress = (try? container?.decodeIfPresent(Int.self, forKey: .readingProgress)) ?? 0 + schemaVersion = container.decode(.schemaVersion, default: 1) + gid = container.decode(.gid, default: "") + token = container.decode(.token, default: "") + lastOpenDate = container.decode(.lastOpenDate, default: .distantPast) + readingProgress = container.decode(.readingProgress, default: 0) } } diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index e8ae7a1be..76e097973 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -249,50 +249,45 @@ extension ListDisplayMode { } } -// reason: the manual Decodable initializer has long per-key decode/default lines -// swiftlint:disable line_length // MARK: Manually decode extension Setting { public init(from decoder: Decoder) { let container = try? decoder.container(keyedBy: CodingKeys.self) - schemaVersion = (try? container?.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 + schemaVersion = container.decode(.schemaVersion, default: 1) // Account - galleryHost = (try? container?.decodeIfPresent(GalleryHost.self, forKey: .galleryHost)) ?? .ehentai - showsNewDawnGreeting = (try? container?.decodeIfPresent(Bool.self, forKey: .showsNewDawnGreeting)) ?? false + galleryHost = container.decode(.galleryHost, default: .ehentai) + showsNewDawnGreeting = container.decode(.showsNewDawnGreeting, default: false) // General - enablesTagsExtension = (try? container?.decodeIfPresent(Bool.self, forKey: .enablesTagsExtension)) ?? false - translatesTags = (try? container?.decodeIfPresent(Bool.self, forKey: .translatesTags)) ?? false - showsTagsSearchSuggestion = (try? container?.decodeIfPresent(Bool.self, forKey: .showsTagsSearchSuggestion)) ?? false - showsImagesInTags = (try? container?.decodeIfPresent(Bool.self, forKey: .showsImagesInTags)) ?? false - redirectsLinksToSelectedHost = (try? container?.decodeIfPresent(Bool.self, forKey: .redirectsLinksToSelectedHost)) ?? false - detectsLinksFromClipboard = (try? container?.decodeIfPresent(Bool.self, forKey: .detectsLinksFromClipboard)) ?? false - backgroundBlurRadius = (try? container?.decodeIfPresent(Double.self, forKey: .backgroundBlurRadius)) ?? 10 - autoLockPolicy = (try? container?.decodeIfPresent(AutoLockPolicy.self, forKey: .autoLockPolicy)) ?? .never + enablesTagsExtension = container.decode(.enablesTagsExtension, default: false) + translatesTags = container.decode(.translatesTags, default: false) + showsTagsSearchSuggestion = container.decode(.showsTagsSearchSuggestion, default: false) + showsImagesInTags = container.decode(.showsImagesInTags, default: false) + redirectsLinksToSelectedHost = container.decode(.redirectsLinksToSelectedHost, default: false) + detectsLinksFromClipboard = container.decode(.detectsLinksFromClipboard, default: false) + backgroundBlurRadius = container.decode(.backgroundBlurRadius, default: 10) + autoLockPolicy = container.decode(.autoLockPolicy, default: .never) // Appearance - listDisplayMode = (try? container?.decodeIfPresent(ListDisplayMode.self, forKey: .listDisplayMode)) ?? .detail - preferredColorScheme = (try? container?.decodeIfPresent(PreferredColorScheme.self, forKey: .preferredColorScheme)) ?? .automatic - accentColor = (try? container?.decodeIfPresent(Color.self, forKey: .accentColor)) ?? .blue - appIconType = (try? container?.decodeIfPresent(AppIconType.self, forKey: .appIconType)) ?? .default - showsTagsInList = (try? container?.decodeIfPresent(Bool.self, forKey: .showsTagsInList)) ?? false - listTagsNumberMaximum = (try? container?.decodeIfPresent(Int.self, forKey: .listTagsNumberMaximum)) ?? 0 - displaysJapaneseTitle = (try? container?.decodeIfPresent(Bool.self, forKey: .displaysJapaneseTitle)) ?? true + listDisplayMode = container.decode(.listDisplayMode, default: .detail) + preferredColorScheme = container.decode(.preferredColorScheme, default: .automatic) + accentColor = container.decode(.accentColor, default: .blue) + appIconType = container.decode(.appIconType, default: .default) + showsTagsInList = container.decode(.showsTagsInList, default: false) + listTagsNumberMaximum = container.decode(.listTagsNumberMaximum, default: 0) + displaysJapaneseTitle = container.decode(.displaysJapaneseTitle, default: true) // Reading - readingDirection = (try? container?.decodeIfPresent(ReadingDirection.self, forKey: .readingDirection)) ?? .vertical - prefetchLimit = (try? container?.decodeIfPresent(Int.self, forKey: .prefetchLimit)) ?? 10 - enablesLandscape = (try? container?.decodeIfPresent(Bool.self, forKey: .enablesLandscape)) ?? false - enablesDualPageMode = (try? container?.decodeIfPresent(Bool.self, forKey: .enablesDualPageMode)) ?? false - exceptCover = (try? container?.decodeIfPresent(Bool.self, forKey: .exceptCover)) ?? false - contentDividerHeight = (try? container?.decodeIfPresent(Double.self, forKey: .contentDividerHeight)) ?? 0 - maximumScaleFactor = (try? container?.decodeIfPresent(Double.self, forKey: .maximumScaleFactor)) ?? 3 - doubleTapScaleFactor = (try? container?.decodeIfPresent(Double.self, forKey: .doubleTapScaleFactor)) ?? 2 + readingDirection = container.decode(.readingDirection, default: .vertical) + prefetchLimit = container.decode(.prefetchLimit, default: 10) + enablesLandscape = container.decode(.enablesLandscape, default: false) + enablesDualPageMode = container.decode(.enablesDualPageMode, default: false) + exceptCover = container.decode(.exceptCover, default: false) + contentDividerHeight = container.decode(.contentDividerHeight, default: 0) + maximumScaleFactor = container.decode(.maximumScaleFactor, default: 3) + doubleTapScaleFactor = container.decode(.doubleTapScaleFactor, default: 2) // Downloads - downloadThreadLimit = (try? container?.decodeIfPresent(Int.self, forKey: .downloadThreadLimit)) ?? 1 - downloadAllowCellular = (try? container?.decodeIfPresent(Bool.self, forKey: .downloadAllowCellular)) ?? true - downloadAutoRetryFailedPages = ( - try? container?.decodeIfPresent(Bool.self, forKey: .downloadAutoRetryFailedPages) - ) ?? true + downloadThreadLimit = container.decode(.downloadThreadLimit, default: 1) + downloadAllowCellular = container.decode(.downloadAllowCellular, default: true) + downloadAutoRetryFailedPages = container.decode(.downloadAutoRetryFailedPages, default: true) // Laboratory - bypassesSNIFiltering = (try? container?.decodeIfPresent(Bool.self, forKey: .bypassesSNIFiltering)) ?? false + bypassesSNIFiltering = container.decode(.bypassesSNIFiltering, default: false) } } -// swiftlint:enable line_length diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index 82cf96c11..c5e2f536f 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -72,16 +72,13 @@ extension User { // non-optional field like `schemaVersion` would otherwise fail synthesized decode of an older // record. `greeting` is intentionally not decoded (absent from `CodingKeys`) and stays `nil`. public init(from decoder: Decoder) { - guard let container = try? decoder.container(keyedBy: CodingKeys.self) else { - schemaVersion = 1 - return - } - schemaVersion = (try? container.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 - displayName = try? container.decodeIfPresent(String.self, forKey: .displayName) - avatarURL = try? container.decodeIfPresent(URL.self, forKey: .avatarURL) - credits = try? container.decodeIfPresent(String.self, forKey: .credits) - galleryPoints = try? container.decodeIfPresent(String.self, forKey: .galleryPoints) - favoriteCategories = try? container.decodeIfPresent([Int: String].self, forKey: .favoriteCategories) + let container = try? decoder.container(keyedBy: CodingKeys.self) + schemaVersion = container.decode(.schemaVersion, default: 1) + displayName = try? container?.decodeIfPresent(String.self, forKey: .displayName) + avatarURL = try? container?.decodeIfPresent(URL.self, forKey: .avatarURL) + credits = try? container?.decodeIfPresent(String.self, forKey: .credits) + galleryPoints = try? container?.decodeIfPresent(String.self, forKey: .galleryPoints) + favoriteCategories = try? container?.decodeIfPresent([Int: String].self, forKey: .favoriteCategories) } } diff --git a/AppPackage/Sources/AppModels/Support/Misc.swift b/AppPackage/Sources/AppModels/Support/Misc.swift index 03c5d1232..9ad0e8b4a 100644 --- a/AppPackage/Sources/AppModels/Support/Misc.swift +++ b/AppPackage/Sources/AppModels/Support/Misc.swift @@ -162,10 +162,10 @@ extension QuickSearchWord { // Tolerant decoding keeps an existing persisted list valid across future additive changes. public init(from decoder: Decoder) { let container = try? decoder.container(keyedBy: CodingKeys.self) - schemaVersion = (try? container?.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 - id = (try? container?.decodeIfPresent(UUID.self, forKey: .id)) ?? .init() - name = (try? container?.decodeIfPresent(String.self, forKey: .name)) ?? "" - content = (try? container?.decodeIfPresent(String.self, forKey: .content)) ?? "" + schemaVersion = container.decode(.schemaVersion, default: 1) + id = container.decode(.id, default: .init()) + name = container.decode(.name, default: "") + content = container.decode(.content, default: "") } } diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift index 9e4a038f2..c586fbf41 100644 --- a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift +++ b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift @@ -29,10 +29,9 @@ public struct TagTranslatorInfo: Codable, Equatable, Sendable { extension TagTranslatorInfo { public init(from decoder: Decoder) { let container = try? decoder.container(keyedBy: CodingKeys.self) - schemaVersion = (try? container?.decodeIfPresent(Int.self, forKey: .schemaVersion)) ?? 1 - language = (try? container?.decodeIfPresent(TranslatableLanguage.self, forKey: .language)) ?? nil - updatedDate = (try? container?.decodeIfPresent(Date.self, forKey: .updatedDate)) ?? .distantPast - hasCustomTranslations = - (try? container?.decodeIfPresent(Bool.self, forKey: .hasCustomTranslations)) ?? false + schemaVersion = container.decode(.schemaVersion, default: 1) + language = try? container?.decodeIfPresent(TranslatableLanguage.self, forKey: .language) + updatedDate = container.decode(.updatedDate, default: .distantPast) + hasCustomTranslations = container.decode(.hasCustomTranslations, default: false) } } diff --git a/AppPackage/Tests/AppModelsTests/TolerantDecodingTests.swift b/AppPackage/Tests/AppModelsTests/TolerantDecodingTests.swift new file mode 100644 index 000000000..6b0ad6ec7 --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/TolerantDecodingTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing +import AppModels + +// REV-24: every persisted model decodes tolerantly through one shared helper +// (`Optional.decode(_:default:)`). These pin the guarantees that helper must +// keep — a missing key, a wrong-typed value, and even a whole non-object payload each fall back to the +// field's own default instead of throwing, while well-formed values still round-trip intact. +@Suite +struct TolerantDecodingTests { + private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) + } + + @Test + func missingKeysFallBackToPerFieldDefaults() throws { + let filter = try decode(Filter.self, "{}") + #expect(filter.schemaVersion == 1) + #expect(filter.minRating == 2) + #expect(filter.doujinshi == false) + // The decoder default here intentionally differs from the constructed default (`true`). + #expect(filter.galleryName == false) + } + + @Test + func aWrongTypedValueFallsBackWithoutFailingItsSiblings() throws { + let filter = try decode(Filter.self, #"{"minRating": "not-an-int", "doujinshi": true}"#) + #expect(filter.minRating == 2) // wrong type → default + #expect(filter.doujinshi == true) // a valid sibling still decodes + } + + @Test + func aNonObjectPayloadDecodesToDefaultsRatherThanThrowing() throws { + // The container itself is absent (top-level array, not a dictionary); every field must still + // fall back to its default, identical to the empty-object case above. + let filter = try decode(Filter.self, "[]") + #expect(filter.schemaVersion == 1) + #expect(filter.minRating == 2) + #expect(filter.galleryName == false) + } + + @Test + func wellFormedValuesRoundTrip() throws { + var original = Filter(minRating: 5, pageLowerBound: "10") + original.doujinshi = true + let decoded = try JSONDecoder().decode(Filter.self, from: JSONEncoder().encode(original)) + #expect(decoded == original) + } + + @Test + func requiredStringFieldsDecodePartially() throws { + let entry = try decode(GalleryHistoryEntry.self, #"{"gid": "123", "token": "abc", "readingProgress": 5}"#) + #expect(entry.gid == "123") + #expect(entry.token == "abc") + #expect(entry.readingProgress == 5) + #expect(entry.lastOpenDate == .distantPast) // missing → default + } +} From 16d612e3c959e3941883bd9d78213d3285650ed2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 7 Jul 2026 01:03:47 +0800 Subject: [PATCH 549/614] Flush reading progress on reader dismissal --- .../ReadingFeature/ReadingReducer+Body.swift | 3 + .../ReadingReducer+Session.swift | 29 ++++-- .../Sources/ReadingFeature/ReadingView.swift | 10 +- .../ReadingReducerFlushTests.swift | 96 +++++++++++++++++++ 4 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 AppPackage/Tests/DownloadsFeatureTests/ReadingReducerFlushTests.swift diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index 6d9e2f31d..0b405e5bb 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -81,6 +81,9 @@ extension ReadingReducer { return .merge(effects) case .onPerformDismiss: + // Flush synchronously here — this runs before the parent nils the presentation and + // cancels the pending debounce, so the last page swiped-to isn't lost on a normal close. + flushReadingProgress(state) return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) case .onAppear(let gid, let enablesLandscape): diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift index fe48118ad..61510965c 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift @@ -22,13 +22,7 @@ extension ReadingReducer { .cancellable(id: ReadingCancelID.progressFlush, cancelInFlight: true) case .flushReadingProgress: - @Shared(.galleryHistory) var galleryHistory - $galleryHistory.withLock { - $0.updateReadingProgress( - gid: state.gallery.id, token: state.gallery.token, - progress: state.pendingReadingProgress, date: date.now - ) - } + flushReadingProgress(state) return .none case .restoreSession(let gid): @@ -64,10 +58,31 @@ extension ReadingReducer { // from the persisted browsing history. @Shared(.galleryHistory) var galleryHistory state.readingProgress = galleryHistory.readingProgress(gid: gid) + // Seed the pending page with the restored resume position so a flush that fires before the + // first page turn (dismiss or background right after opening) rewrites that position instead + // of clobbering it with a stale `.zero`. + state.pendingReadingProgress = state.readingProgress state.hasRestoredSession = true return .none } + /// Persists the latest pending page into the shared browsing history. Called from the debounced + /// `.flushReadingProgress` and — crucially — synchronously on reader dismissal (`.onPerformDismiss`), + /// which runs in this child reducer before the parent nils the presentation and cancels the pending + /// debounce. A deferred `.send` at that point would be dropped once the destination is gone. + func flushReadingProgress(_ state: State) { + // Nothing to persist for a gallery that can't be keyed into history (e.g. an unseeded reader + // dismissed immediately); bail before touching the clock or the shared store. + guard state.gallery.id.isValidGID else { return } + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.updateReadingProgress( + gid: state.gallery.id, token: state.gallery.token, + progress: state.pendingReadingProgress, date: date.now + ) + } + } + func reduceObserveDownloads(gid: String) -> Effect { guard gid.isValidGID else { return .none } return .run { send in diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 095372f02..014eff624 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -102,15 +102,15 @@ public struct ReadingView: View { .animation(.default, value: store.showsPanel) .statusBar(hidden: !store.showsPanel) .onDisappear { - // The reader is tearing down; flush the debounced reading progress immediately so the - // last page swiped-to isn't lost. - store.send(.flushReadingProgress) + // Progress is flushed in the reducer on `.onPerformDismiss` (before the presentation is + // torn down); an `onDisappear` send would arrive after the destination is nil'd and be + // dropped. So only non-persistence teardown happens here. liveTextHandler.cancelRequests() setAutoPlayPolocy(.off) } .onChange(of: scenePhase) { _, newPhase in - // Backgrounding doesn't fire `onDisappear`, so flush here too — a force-quit from the - // background otherwise drops the last debounce window of progress. + // Backgrounding doesn't fire `onDisappear` or a dismiss, so flush here too — a force-quit + // from the background otherwise drops the last debounce window of progress. if newPhase == .background { store.send(.flushReadingProgress) } } .onAppear { store.send(.onAppear(gid, setting.enablesLandscape)) } diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerFlushTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerFlushTests.swift new file mode 100644 index 000000000..5d887e176 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerFlushTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Sharing +import AppModels +import ComposableArchitecture +import Testing +import HapticsClient +@testable import ReadingFeature + +// V-1 regression guard. Closing the reader must persist the current page *synchronously* on +// `.onPerformDismiss` — that runs in this child reducer before the parent nils the presentation and +// cancels the pending debounce, so a deferred send (the old `onDisappear` approach) would be dropped. +// And a flush that fires before the first page turn must rewrite the *restored* resume position, never +// clobber it with a stale `.zero`. +@Suite(.serialized) +struct ReadingReducerFlushTests: DownloadFeatureTestCase { + private let now = Date(timeIntervalSince1970: 1_000) + + /// Reads `@Shared(.galleryHistory)` from an isolated `UserDefaults` so the test never touches (or + /// depends on) the real app defaults. + private func persistedProgress(_ defaults: UserDefaults, gid: String) -> Int? { + withDependencies { + $0.defaultAppStorage = defaults + } operation: { + @Shared(.galleryHistory) var history + return history.first { $0.gid == gid }?.readingProgress + } + } + + @MainActor + @Test + func dismissFlushesTheLastSyncedPageBeforeTheDebounceFires() async throws { + let suiteName = "reading-flush-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let gallery = sampleGallery() + let clock = TestClock() + + let store = TestStore( + initialState: ReadingReducer.State(gallery: gallery, contentSource: .remote), + reducer: ReadingReducer.init, + withDependencies: { + $0.defaultAppStorage = defaults + $0.continuousClock = clock + $0.date = .constant(now) + $0.hapticsClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.syncReadingProgress(7)) // pending = 7, starts the 1s debounce (not yet fired) + await store.send(.onPerformDismiss) // must persist 7 inline, before the debounce fires + + #expect(persistedProgress(defaults, gid: gallery.id) == 7) + + await clock.advance(by: .seconds(1)) // drain the still-pending debounce so the store finishes + await store.finish() + } + + @MainActor + @Test + func dismissBeforeAnyPageTurnDoesNotClobberTheRestoredResumePosition() async throws { + let suiteName = "reading-flush-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let gallery = sampleGallery() + + // Pre-seed a saved resume position of 5. + withDependencies { + $0.defaultAppStorage = defaults + } operation: { + @Shared(.galleryHistory) var history + $history.withLock { + $0.recordGalleryOpen(gid: gallery.id, token: gallery.token, date: now) + $0.updateReadingProgress(gid: gallery.id, token: gallery.token, progress: 5, date: now) + } + } + + let store = TestStore( + initialState: ReadingReducer.State(gallery: gallery, contentSource: .remote), + reducer: ReadingReducer.init, + withDependencies: { + $0.defaultAppStorage = defaults + $0.continuousClock = TestClock() + $0.date = .constant(now) + $0.hapticsClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.restoreSession(gallery.id)) // seeds pendingReadingProgress from the stored 5 + await store.send(.onPerformDismiss) // flushes 5, must not overwrite with 0 + + #expect(persistedProgress(defaults, gid: gallery.id) == 5) + await store.finish() + } +} From 5e207bfd133d1eac8eb582a57ff4a9b2ce313c4c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 7 Jul 2026 01:08:09 +0800 Subject: [PATCH 550/614] Rename fossil databaseReducer to sessionReducer --- AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift | 2 +- AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index 0b405e5bb..ea6e937a2 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -32,7 +32,7 @@ extension ReadingReducer { var mainReducer: some ReducerOf { CombineReducers { lifecycleReducer - databaseReducer + sessionReducer imageFetchReducer } .haptics( diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift index 61510965c..b4e0e5e3f 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift @@ -6,7 +6,7 @@ import AppTools // MARK: - Session Restore & Download Actions extension ReadingReducer { - var databaseReducer: some ReducerOf { + var sessionReducer: some ReducerOf { Reduce { state, action in switch action { case .syncReadingProgress(let progress): From 874940ea01deb1fd689fdf627f5ce6f5667b125f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 7 Jul 2026 01:23:50 +0800 Subject: [PATCH 551/614] Require gallery in PreviewsReducer.State; bail on vanished download --- .../Sources/DetailFeature/GalleryNavigation.swift | 2 +- .../DetailFeature/Previews/PreviewsReducer.swift | 4 ++-- .../Sources/DownloadsFeature/DownloadsReducer.swift | 11 ++++++----- .../DownloadObserverReadingTests.swift | 3 +-- .../DownloadObserverRefreshTests.swift | 3 +-- .../PreviewsReducerDownloadTests.swift | 12 ++++-------- 6 files changed, 15 insertions(+), 20 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/GalleryNavigation.swift b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift index b763b619b..06f6d9a08 100644 --- a/AppPackage/Sources/DetailFeature/GalleryNavigation.swift +++ b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift @@ -23,7 +23,7 @@ public enum GalleryNavigation { switch delegate { case let .pushPreviews(gallery, previewConfig, language): return .previews(.init( - gid: gallery.id, gallery: gallery, + gallery: gallery, previewConfig: previewConfig, language: language )) case let .pushComments(gid, token, apiKey, galleryURL, comments, scrollCommentID): diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index 60b1e4c5a..a202a1e2e 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -43,10 +43,10 @@ public struct PreviewsReducer: Sendable { public var localPreviewRequestID = UUID() public init( - gid: String = "", gallery: Gallery = .empty, + gallery: Gallery, previewConfig: PreviewConfig = .normal(rows: 4), language: Language? = nil ) { - self.gid = gid + self.gid = gallery.id self.gallery = gallery self.previewConfig = previewConfig self.language = language diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 098924ec6..3d0c8dcf8 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -276,13 +276,14 @@ public struct DownloadsReducer: Sendable { if case .success(let (download, manifest)) = result { readingState = .init(gallery: download.gallery, contentSource: .local(download, manifest)) } else { - let download = state.downloads.first(where: { $0.gid == gid }) - readingState = .init(gallery: download?.gallery ?? .empty, contentSource: .remote) + // Local load failed; fall back to remote — but only if the download record is still + // around to seed the reader. If it vanished mid-flight there's nothing to open, so + // bail rather than push a blank (`.empty`) reader. + guard let download = state.downloads.first(where: { $0.gid == gid }) else { return .none } + readingState = .init(gallery: download.gallery, contentSource: .remote) // A downloaded gallery has no persisted detail, so fall back to a language-agnostic // Live Text hint rather than leaving it unset. - if download != nil { - readingState.language = .other - } + readingState.language = .other } state.destination = .reading(readingState) return .none diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift index dd34a8dc4..9f1dbef24 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift @@ -126,8 +126,7 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { let (stream, continuation) = makeObserverStream() let loadCount = UncheckedBox(0) - var initialState = PreviewsReducer.State() - initialState.gallery = gallery + var initialState = PreviewsReducer.State(gallery: gallery) let store = makePreviewsStoreWithLoadCount( initialState: initialState, stream: stream, diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift index 5a516739a..c4ad187e8 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift @@ -60,8 +60,7 @@ struct DownloadObserverRefreshTests: DownloadFeatureTestCase { let (stream, continuation) = makeObserverStream() let loadCount = UncheckedBox(0) - var initialState = PreviewsReducer.State() - initialState.gallery = gallery + var initialState = PreviewsReducer.State(gallery: gallery) let store = makePreviewsObserverStore( initialState: initialState, diff --git a/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift index c5bf98e7a..c1aab8bd8 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift @@ -17,8 +17,7 @@ struct PreviewsReducerDownloadTests: DownloadFeatureTestCase { gid: "991", title: "Preview Download", status: .completed, pageCount: 2, completedPageCount: 2 ) let manifest = try sampleManifest(gid: download.gid, title: download.title) - var initialState = PreviewsReducer.State() - initialState.gallery = download.gallery + var initialState = PreviewsReducer.State(gallery: download.gallery) let store = makePreviewsManifestStore(download: download, manifest: manifest) @@ -42,8 +41,7 @@ struct PreviewsReducerDownloadTests: DownloadFeatureTestCase { func testPreviewsReducerClearsLocalPreviewURLsWhenObservedDownloadDisappears() async { let gallery = sampleGallery() let localURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") - var initialState = PreviewsReducer.State() - initialState.gallery = gallery + var initialState = PreviewsReducer.State(gallery: gallery) initialState.localPreviewURLs = [1: localURL] let store = makePreviewsNoManifestStore(initialState: initialState, withLoadLocalPageURLs: true) @@ -62,8 +60,7 @@ struct PreviewsReducerDownloadTests: DownloadFeatureTestCase { func testPreviewsReducerRemoteFallbackKeepsExistingLocalPreviewPages() async { let gallery = sampleGallery() let localURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") - var initialState = PreviewsReducer.State() - initialState.gallery = gallery + var initialState = PreviewsReducer.State(gallery: gallery) initialState.localPreviewURLs = [1: localURL] let store = makePreviewsNoManifestStore(initialState: initialState, withLoadLocalPageURLs: false) @@ -87,8 +84,7 @@ private extension PreviewsReducerDownloadTests { download: DownloadedGallery, manifest: DownloadManifest ) -> TestStoreOf { - var initialState = PreviewsReducer.State() - initialState.gallery = download.gallery + var initialState = PreviewsReducer.State(gallery: download.gallery) let store = TestStore( initialState: initialState, reducer: PreviewsReducer.init, From e4c7fd8327faae3ef97a5e23e85c646a61bf9589 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 7 Jul 2026 01:39:25 +0800 Subject: [PATCH 552/614] Eliminate `@Shared` setting setter deprecations --- .../AppFeature/View/TabBar/TabBarView.swift | 12 ++-- .../SettingReducer+Helpers.swift | 12 ++-- .../SettingFeature/SettingReducer.swift | 9 +++ .../Sources/SettingFeature/SettingView.swift | 56 +++++++++---------- .../SettingWriteThroughTests.swift | 29 +++++++--- 5 files changed, 69 insertions(+), 49 deletions(-) diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index dd59e9373..69731ba5f 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -42,7 +42,7 @@ struct TabBarView: View { HomeView( store: store.scope(state: \.homeState, action: \.home), user: store.settingState.user, - setting: $store.settingState.setting, + setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius, tagTranslator: store.settingState.tagTranslator ) @@ -50,7 +50,7 @@ struct TabBarView: View { FavoritesView( store: store.scope(state: \.favoritesState, action: \.favorites), user: store.settingState.user, - setting: $store.settingState.setting, + setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius, tagTranslator: store.settingState.tagTranslator ) @@ -58,7 +58,7 @@ struct TabBarView: View { SearchRootView( store: store.scope(state: \.searchRootState, action: \.searchRoot), user: store.settingState.user, - setting: $store.settingState.setting, + setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius, tagTranslator: store.settingState.tagTranslator ) @@ -66,7 +66,7 @@ struct TabBarView: View { DownloadsView( store: store.scope(state: \.downloadsState, action: \.downloads), user: store.settingState.user, - setting: $store.settingState.setting, + setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius, tagTranslator: store.settingState.tagTranslator ) @@ -109,7 +109,7 @@ struct TabBarView: View { store: detailStore, gid: detailStore.gid, user: store.settingState.user, - setting: $store.settingState.setting, + setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius, tagTranslator: store.settingState.tagTranslator ) @@ -117,7 +117,7 @@ struct TabBarView: View { galleryDestination( elementStore, user: store.settingState.user, - setting: $store.settingState.setting, + setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius, tagTranslator: store.settingState.tagTranslator ) diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift index aca69f8f3..094023c46 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift @@ -10,21 +10,19 @@ private let logger = Logger(category: .init(describing: SettingReducer.self)) extension SettingReducer { func handleLoadUserSettings(_ state: inout State) -> Effect { - // `setting` loads from persisted storage into its working copy; `user` is `@Shared` and - // auto-loads. `tagTranslator` is in-memory and rebuilt from its cache below. - @Shared(.setting) var storedSetting - state.setting = storedSetting + // `setting` and `user` are both `@Shared` and auto-load from persisted storage — there is no + // working copy to prime here. `tagTranslator` is in-memory and rebuilt from its cache below. var effects: [Effect] = [ .send(.syncAppIconType), .send(.loadUserSettingsDone), .send(.syncUserInterfaceStyle), - .run { [state] _ in - dfClient.setActive(state.setting.bypassesSNIFiltering) + .run { [bypassesSNIFiltering = state.setting.bypassesSNIFiltering] _ in + dfClient.setActive(bypassesSNIFiltering) } ] if let value: String = userDefaultsClient.getValue(.galleryHost), let galleryHost = GalleryHost(rawValue: value) { - state.setting.galleryHost = galleryHost + state.$setting.withLock { $0.galleryHost = galleryHost } } if cookieClient.shouldFetchIgneous { effects.append(.send(.fetchIgneous)) diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index a2158329c..bb5961721 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -57,6 +57,15 @@ public struct SettingReducer: Sendable { // lives in memory only and is rebuilt at launch from the cached raw JSON; only its thin // `tagTranslatorInfo` metadata persists (see `AppSharedKeys`). @Shared(.setting) public var setting: Setting + /// A write-through view of `setting` for SwiftUI bindings. `@Shared`'s own value setter is + /// deprecated (it can't take exclusive access), so binding `$store.setting.x` directly warns; + /// bind `$store.settingBinding.x` instead — its setter routes writes through `withLock`, while + /// still flowing through `BindingReducer` so the cross-field `.onChange(of: \.setting.x)` + /// cascades keep firing (both read the same shared storage). Reads should use `setting`. + public var settingBinding: Setting { + get { setting } + set { $setting.withLock { $0 = newValue } } + } public var tagTranslator = TagTranslator() @Shared(.tagTranslatorInfo) public var tagTranslatorInfo: TagTranslatorInfo @Shared(.user) public var user: User diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 559cd61ab..8d75fad76 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -41,8 +41,8 @@ public struct SettingView: View { case .account(let accountStore): AccountSettingView( store: accountStore, - galleryHost: $store.setting.galleryHost, - showsNewDawnGreeting: $store.setting.showsNewDawnGreeting, + galleryHost: $store.settingBinding.galleryHost, + showsNewDawnGreeting: $store.settingBinding.showsNewDawnGreeting, bypassesSNIFiltering: store.setting.bypassesSNIFiltering, blurRadius: blurRadius ) @@ -53,26 +53,26 @@ public struct SettingView: View { tagTranslatorLoadingState: store.tagTranslatorLoadingState, tagTranslatorEmpty: store.tagTranslator.translations.isEmpty, tagTranslatorHasCustomTranslations: store.tagTranslator.hasCustomTranslations, - enablesTagsExtension: $store.setting.enablesTagsExtension, - translatesTags: $store.setting.translatesTags, - showsTagsSearchSuggestion: $store.setting.showsTagsSearchSuggestion, - showsImagesInTags: $store.setting.showsImagesInTags, - redirectsLinksToSelectedHost: $store.setting.redirectsLinksToSelectedHost, - detectsLinksFromClipboard: $store.setting.detectsLinksFromClipboard, - backgroundBlurRadius: $store.setting.backgroundBlurRadius, - autoLockPolicy: $store.setting.autoLockPolicy + enablesTagsExtension: $store.settingBinding.enablesTagsExtension, + translatesTags: $store.settingBinding.translatesTags, + showsTagsSearchSuggestion: $store.settingBinding.showsTagsSearchSuggestion, + showsImagesInTags: $store.settingBinding.showsImagesInTags, + redirectsLinksToSelectedHost: $store.settingBinding.redirectsLinksToSelectedHost, + detectsLinksFromClipboard: $store.settingBinding.detectsLinksFromClipboard, + backgroundBlurRadius: $store.settingBinding.backgroundBlurRadius, + autoLockPolicy: $store.settingBinding.autoLockPolicy ) case .appearance(let appearanceStore): AppearanceSettingView( store: appearanceStore, - preferredColorScheme: $store.setting.preferredColorScheme, - accentColor: $store.setting.accentColor, - appIconType: $store.setting.appIconType, - listDisplayMode: $store.setting.listDisplayMode, - showsTagsInList: $store.setting.showsTagsInList, - listTagsNumberMaximum: $store.setting.listTagsNumberMaximum, - displaysJapaneseTitle: $store.setting.displaysJapaneseTitle + preferredColorScheme: $store.settingBinding.preferredColorScheme, + accentColor: $store.settingBinding.accentColor, + appIconType: $store.settingBinding.appIconType, + listDisplayMode: $store.settingBinding.listDisplayMode, + showsTagsInList: $store.settingBinding.showsTagsInList, + listTagsNumberMaximum: $store.settingBinding.listTagsNumberMaximum, + displaysJapaneseTitle: $store.settingBinding.displaysJapaneseTitle ) case .login(let loginStore): @@ -94,31 +94,31 @@ public struct SettingView: View { case .download: DownloadSettingView( - downloadThreadLimit: $store.setting.downloadThreadLimit, - downloadAllowCellular: $store.setting.downloadAllowCellular, - downloadAutoRetryFailedPages: $store.setting.downloadAutoRetryFailedPages + downloadThreadLimit: $store.settingBinding.downloadThreadLimit, + downloadAllowCellular: $store.settingBinding.downloadAllowCellular, + downloadAutoRetryFailedPages: $store.settingBinding.downloadAutoRetryFailedPages ) case .reading: ReadingSettingView( - readingDirection: $store.setting.readingDirection, - prefetchLimit: $store.setting.prefetchLimit, - enablesLandscape: $store.setting.enablesLandscape, - contentDividerHeight: $store.setting.contentDividerHeight, - maximumScaleFactor: $store.setting.maximumScaleFactor, - doubleTapScaleFactor: $store.setting.doubleTapScaleFactor + readingDirection: $store.settingBinding.readingDirection, + prefetchLimit: $store.settingBinding.prefetchLimit, + enablesLandscape: $store.settingBinding.enablesLandscape, + contentDividerHeight: $store.settingBinding.contentDividerHeight, + maximumScaleFactor: $store.settingBinding.maximumScaleFactor, + doubleTapScaleFactor: $store.settingBinding.doubleTapScaleFactor ) case .laboratory: LaboratorySettingView( - bypassesSNIFiltering: $store.setting.bypassesSNIFiltering + bypassesSNIFiltering: $store.settingBinding.bypassesSNIFiltering ) case .about: AboutView() case .appIcon: - AppIconView(appIconType: $store.setting.appIconType) + AppIconView(appIconType: $store.settingBinding.appIconType) } } } diff --git a/AppPackage/Tests/SettingFeatureTests/SettingWriteThroughTests.swift b/AppPackage/Tests/SettingFeatureTests/SettingWriteThroughTests.swift index 71ec3c62c..7f17f31f5 100644 --- a/AppPackage/Tests/SettingFeatureTests/SettingWriteThroughTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/SettingWriteThroughTests.swift @@ -1,23 +1,36 @@ +import Foundation import Testing import AppModels import Sharing @testable import SettingFeature import ComposableArchitecture -// REV-8: `setting` is now stored directly in `@Shared(.setting)`, so a non-binding write path like -// `syncAppIconTypeDone` (fired at launch) persists atomically. Previously it mutated a working copy and -// returned `.none`, leaving the persisted value silently diverged until an unrelated binding synced it. +// REV-8 / V-2: `setting` is stored directly in `@Shared(.setting)`, so a non-binding write path like +// `syncAppIconTypeDone` (fired at launch) must persist atomically. Previously it mutated a working copy +// and returned `.none`, leaving the persisted value silently diverged until an unrelated binding synced +// it. This pins the fix by reading an INDEPENDENT `@Shared(.setting)` handle rather than `store.state` +// — the working-copy bug updated `state.setting` too, so a `store.state` assertion passed pre-fix and +// wasn't discriminating. Storage is isolated to an in-memory suite so the test never touches real +// UserDefaults. @Suite @MainActor struct SettingWriteThroughTests { @Test func syncAppIconTypeDonePersistsIconTypeToSharedSetting() async { - let store = TestStore(initialState: .init(), reducer: SettingReducer.init) - store.exhaustivity = .off + let defaults = UserDefaults.inMemory + await withDependencies { + $0.defaultAppStorage = defaults + } operation: { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { + $0.defaultAppStorage = defaults + } + store.exhaustivity = .off - // The alternate-icon name maps to `.ukiyoe`; the derived type is written through `state.$setting`. - await store.send(.syncAppIconTypeDone(AppIconType.ukiyoe.filename)) + // The alternate-icon name maps to `.ukiyoe`; the derived type is written through `$setting`. + await store.send(.syncAppIconTypeDone(AppIconType.ukiyoe.filename)) - #expect(store.state.setting.appIconType == .ukiyoe) + @Shared(.setting) var persisted + #expect(persisted.appIconType == .ukiyoe) + } } } From 7a3eebfea564a05f6b4cc0b2528ce91aa5269f2f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 7 Jul 2026 07:23:35 +0800 Subject: [PATCH 553/614] Silence initialState test-target warnings --- .../DownloadsFeatureTests/DownloadObserverReadingTests.swift | 4 ++-- .../DownloadsFeatureTests/DownloadObserverRefreshTests.swift | 4 ++-- .../DownloadsFeatureTests/PreviewsReducerDownloadTests.swift | 3 +-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift index 9f1dbef24..43a85e5a2 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift @@ -91,7 +91,7 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { let (stream, continuation) = makeObserverStream() let loadCount = UncheckedBox(0) - var initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) + let initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) let store = makeReadingStoreWithLoadCount( initialState: initialState, stream: stream, @@ -126,7 +126,7 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { let (stream, continuation) = makeObserverStream() let loadCount = UncheckedBox(0) - var initialState = PreviewsReducer.State(gallery: gallery) + let initialState = PreviewsReducer.State(gallery: gallery) let store = makePreviewsStoreWithLoadCount( initialState: initialState, stream: stream, diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift index c4ad187e8..974eaaeb8 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift @@ -25,7 +25,7 @@ struct DownloadObserverRefreshTests: DownloadFeatureTestCase { let (stream, continuation) = makeObserverStream() let loadCount = UncheckedBox(0) - var initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) + let initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) let store = makeReadingObserverStore( initialState: initialState, @@ -60,7 +60,7 @@ struct DownloadObserverRefreshTests: DownloadFeatureTestCase { let (stream, continuation) = makeObserverStream() let loadCount = UncheckedBox(0) - var initialState = PreviewsReducer.State(gallery: gallery) + let initialState = PreviewsReducer.State(gallery: gallery) let store = makePreviewsObserverStore( initialState: initialState, diff --git a/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift index c1aab8bd8..c0a96dc19 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift @@ -17,7 +17,6 @@ struct PreviewsReducerDownloadTests: DownloadFeatureTestCase { gid: "991", title: "Preview Download", status: .completed, pageCount: 2, completedPageCount: 2 ) let manifest = try sampleManifest(gid: download.gid, title: download.title) - var initialState = PreviewsReducer.State(gallery: download.gallery) let store = makePreviewsManifestStore(download: download, manifest: manifest) @@ -84,7 +83,7 @@ private extension PreviewsReducerDownloadTests { download: DownloadedGallery, manifest: DownloadManifest ) -> TestStoreOf { - var initialState = PreviewsReducer.State(gallery: download.gallery) + let initialState = PreviewsReducer.State(gallery: download.gallery) let store = TestStore( initialState: initialState, reducer: PreviewsReducer.init, From e1b52c68c52ab96ce13f883429bec2f579123370 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 7 Jul 2026 21:31:05 +0800 Subject: [PATCH 554/614] Fix flaky expiration-assertion release test --- .../DownloadBackgroundAssertionTests.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundAssertionTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundAssertionTests.swift index abebfab23..a52d9a0e2 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundAssertionTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundAssertionTests.swift @@ -69,11 +69,15 @@ struct DownloadBackgroundAssertionTests: DownloadFeatureTestCase { context.spy.fireExpiration() + // The expiration handler releases the assertion on a detached task that nils the + // token *before* awaiting the MainActor `end` hop. Wait on `endCount` (the later + // of the two) rather than the token so the assertion below can't observe the + // in-between window where the token is already nil but `end` hasn't run yet. try await waitUntil { - await !context.manager.testingHasBackgroundAssertion() + context.spy.endCount == 1 } #expect(context.spy.beginCount == 1) - #expect(context.spy.endCount == 1) + #expect(!(await context.manager.testingHasBackgroundAssertion())) _ = await context.manager.pause(gid: gid) } From dd5c59d190776bebdaf54b28663d48e32d76489b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 8 Jul 2026 08:16:16 +0800 Subject: [PATCH 555/614] Replace banner with ListNoticeView --- .../Sources/AppComponents/LimitBanner.swift | 22 ------- .../AppComponents/ListNoticeView.swift | 27 ++++++++ .../GalleryListComponents/GenericList.swift | 62 ++++++++++++------- .../HomeFeature/History/HistoryView.swift | 7 +-- .../QuickSearchFeature/QuickSearchView.swift | 8 +-- 5 files changed, 74 insertions(+), 52 deletions(-) delete mode 100644 AppPackage/Sources/AppComponents/LimitBanner.swift create mode 100644 AppPackage/Sources/AppComponents/ListNoticeView.swift diff --git a/AppPackage/Sources/AppComponents/LimitBanner.swift b/AppPackage/Sources/AppComponents/LimitBanner.swift deleted file mode 100644 index 22f2a331c..000000000 --- a/AppPackage/Sources/AppComponents/LimitBanner.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -/// An always-visible footnote notice, meant to be pinned to the top of a list via `.safeAreaInset`, -/// explaining that the list has a size cap. The caller supplies the localized `Text` so each feature -/// keeps its own module-local string; only the identical presentation is shared here. -public struct LimitBanner: View { - private let text: Text - - public init(_ text: Text) { - self.text = text - } - - public var body: some View { - text - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.horizontal) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.bar) - } -} diff --git a/AppPackage/Sources/AppComponents/ListNoticeView.swift b/AppPackage/Sources/AppComponents/ListNoticeView.swift new file mode 100644 index 000000000..d777fb187 --- /dev/null +++ b/AppPackage/Sources/AppComponents/ListNoticeView.swift @@ -0,0 +1,27 @@ +import SwiftUI +import SFSafeSymbols + +/// A leading, non-interactive footnote notice (e.g. a size-cap explanation) meant to sit as a row +/// inside a `List`. Rendering it in the list, rather than pinning it above the list via +/// `.safeAreaInset`, keeps it scrolling with the content and leaves the navigation title intact. +public struct ListNoticeView: View { + private let notice: LocalizedStringResource + + public init(notice: LocalizedStringResource) { + self.notice = notice + } + + public var body: some View { + Label( + title: { + Text(notice) + .font(.footnote) + }, + icon: { + Image(systemSymbol: .infoCircle) + .imageScale(.small) + } + ) + .foregroundStyle(.secondary) + } +} diff --git a/AppPackage/Sources/GalleryListComponents/GenericList.swift b/AppPackage/Sources/GalleryListComponents/GenericList.swift index 4b4b8e5aa..786e30e27 100644 --- a/AppPackage/Sources/GalleryListComponents/GenericList.swift +++ b/AppPackage/Sources/GalleryListComponents/GenericList.swift @@ -12,6 +12,7 @@ public struct GenericList: View { private let pageNumber: PageNumber? private let loadingState: LoadingState private let footerLoadingState: LoadingState + private let notice: LocalizedStringResource? private let fetchAction: (() -> Void)? private let fetchMoreAction: (() -> Void)? private let navigateAction: ((Gallery) -> Void)? @@ -20,6 +21,7 @@ public struct GenericList: View { public init( galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, loadingState: LoadingState, footerLoadingState: LoadingState, + notice: LocalizedStringResource? = nil, fetchAction: (() -> Void)? = nil, fetchMoreAction: (() -> Void)? = nil, navigateAction: ((Gallery) -> Void)? = nil, @@ -32,6 +34,7 @@ public struct GenericList: View { self.pageNumber = pageNumber self.loadingState = loadingState self.footerLoadingState = footerLoadingState + self.notice = notice self.fetchAction = fetchAction self.fetchMoreAction = fetchMoreAction self.navigateAction = navigateAction @@ -45,14 +48,16 @@ public struct GenericList: View { case .detail: DetailList( galleries: galleries, setting: setting, pageNumber: pageNumber, - footerLoadingState: footerLoadingState, fetchMoreAction: fetchMoreAction, + footerLoadingState: footerLoadingState, notice: notice, + fetchMoreAction: fetchMoreAction, navigateAction: navigateAction, translateAction: translateAction, downloadBadges: downloadBadges ) case .thumbnail: WaterfallList( galleries: galleries, setting: setting, pageNumber: pageNumber, - footerLoadingState: footerLoadingState, fetchMoreAction: fetchMoreAction, + footerLoadingState: footerLoadingState, notice: notice, + fetchMoreAction: fetchMoreAction, navigateAction: navigateAction, translateAction: translateAction, downloadBadges: downloadBadges ) @@ -80,13 +85,14 @@ private struct DetailList: View { private let downloadBadges: [String: DownloadBadge] private let pageNumber: PageNumber? private let footerLoadingState: LoadingState + private let notice: LocalizedStringResource? private let fetchMoreAction: (() -> Void)? private let navigateAction: ((Gallery) -> Void)? private let translateAction: ((String) -> (String, TagTranslation?))? init( galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, - footerLoadingState: LoadingState, + footerLoadingState: LoadingState, notice: LocalizedStringResource? = nil, fetchMoreAction: (() -> Void)?, navigateAction: ((Gallery) -> Void)? = nil, translateAction: ((String) -> (String, TagTranslation?))? = nil, @@ -97,6 +103,7 @@ private struct DetailList: View { self.downloadBadges = downloadBadges self.pageNumber = pageNumber self.footerLoadingState = footerLoadingState + self.notice = notice self.fetchMoreAction = fetchMoreAction self.navigateAction = navigateAction self.translateAction = translateAction @@ -113,25 +120,29 @@ private struct DetailList: View { } var body: some View { - List(galleries) { gallery in - Button { - navigateAction?(gallery) - } label: { - GalleryDetailCell( - gallery: gallery, - setting: setting, - translateAction: translateAction, - downloadBadge: downloadBadges[gallery.gid] - ) - } - .foregroundColor(.primary) - .onAppear { - if gallery == galleries.last { - fetchMoreAction?() + List { + notice.map(ListNoticeView.init) + + ForEach(galleries) { gallery in + Button { + navigateAction?(gallery) + } label: { + GalleryDetailCell( + gallery: gallery, + setting: setting, + translateAction: translateAction, + downloadBadge: downloadBadges[gallery.gid] + ) + } + .foregroundColor(.primary) + .onAppear { + if gallery == galleries.last { + fetchMoreAction?() + } + } + if shouldShowFooter(gallery: gallery) { + FetchMoreFooter(loadingState: footerLoadingState, retryAction: fetchMoreAction) } - } - if shouldShowFooter(gallery: gallery) { - FetchMoreFooter(loadingState: footerLoadingState, retryAction: fetchMoreAction) } } } @@ -144,6 +155,7 @@ private struct WaterfallList: View { private let downloadBadges: [String: DownloadBadge] private let pageNumber: PageNumber? private let footerLoadingState: LoadingState + private let notice: LocalizedStringResource? private let fetchMoreAction: (() -> Void)? private let navigateAction: ((Gallery) -> Void)? private let translateAction: ((String) -> (String, TagTranslation?))? @@ -166,7 +178,7 @@ private struct WaterfallList: View { init( galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, - footerLoadingState: LoadingState, + footerLoadingState: LoadingState, notice: LocalizedStringResource? = nil, fetchMoreAction: (() -> Void)?, navigateAction: ((Gallery) -> Void)? = nil, translateAction: ((String) -> (String, TagTranslation?))? = nil, @@ -177,6 +189,7 @@ private struct WaterfallList: View { self.downloadBadges = downloadBadges self.pageNumber = pageNumber self.footerLoadingState = footerLoadingState + self.notice = notice self.fetchMoreAction = fetchMoreAction self.navigateAction = navigateAction self.translateAction = translateAction @@ -184,6 +197,11 @@ private struct WaterfallList: View { var body: some View { List { + if let notice { + Section { + ListNoticeView(notice: notice) + } + } WaterfallGrid(galleries) { gallery in Button { navigateAction?(gallery) diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index eb490c17b..775d22d07 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -32,6 +32,9 @@ struct HistoryView: View { pageNumber: PageNumber(isNextButtonEnabled: store.hasMoreHistory), loadingState: store.loadingState, footerLoadingState: store.footerLoadingState, + // A leading list section, rather than a pinned top banner, keeps the navigation title + // intact: only the most-recent records survive the launch-time prune. + notice: .historyLimitDescription(limit: GalleryHistoryEntry.historyCap), fetchAction: { store.send(.fetchGalleries) }, fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, @@ -40,10 +43,6 @@ struct HistoryView: View { }, downloadBadges: store.downloadBadges ) - .safeAreaInset(edge: .top, spacing: 0) { - // Always-visible notice: only the most-recent records survive the launch-time prune. - LimitBanner(Text(.historyLimitDescription(limit: GalleryHistoryEntry.historyCap))) - } .searchable(text: $store.keyword, prompt: .filter) .onAppear { store.send(.onAppear) diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index aa3c1ca11..6d9c02b28 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -20,6 +20,10 @@ public struct QuickSearchView: View { NavigationStack { ZStack { List { + // A leading list section, rather than a pinned top banner, keeps the navigation + // title intact: the word list is capped and the add button disables at the limit. + ListNoticeView(notice: .wordLimitDescription(limit: QuickSearchReducer.wordLimit)) + ForEach(store.quickSearchWords) { word in Button { searchAction(word.effectiveSearchText) @@ -60,10 +64,6 @@ public struct QuickSearchView: View { ErrorView(error: .notFound) .opacity(store.quickSearchWords.isEmpty ? 1 : 0) } - .safeAreaInset(edge: .top, spacing: 0) { - // Always-visible notice: the word list is capped and the add button disables at the limit. - LimitBanner(Text(.wordLimitDescription(limit: QuickSearchReducer.wordLimit))) - } .confirmationDialog( $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) From 185a4d30a3a293621af8e183eb53672de94227df Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 8 Jul 2026 08:39:06 +0800 Subject: [PATCH 556/614] Update "Date to Seek" to "Date seek" --- .../DateSeekFeature/DateSeekPickerView.swift | 2 +- .../DateSeekFeature/DateSeekReducer.swift | 2 +- .../Resources/Resources/Localizable.xcstrings | 1996 ++++++++--------- 3 files changed, 1000 insertions(+), 1000 deletions(-) diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift index 0e6f76807..64b10ad63 100644 --- a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift @@ -4,7 +4,7 @@ import AppModels import Resources import SwiftUI -/// The "Seek to date" sheet content: a graphical date picker plus newer/older direction buttons. +/// The "Date seek" sheet content: a graphical date picker plus newer/older direction buttons. /// /// This is a store-agnostic, reusable component — it is driven entirely by the values passed in, /// not by a dedicated reducer. Hosts typically wire it to a presented `DateSeekReducer`, but it diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift b/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift index 278f117da..c6bdfc32f 100644 --- a/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift +++ b/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift @@ -3,7 +3,7 @@ import AppModels import Foundation import HapticsClient -/// A headless, reusable feature for the "Seek to date" control. +/// A headless, reusable feature for the "Date seek" control. /// /// Despite the matching name, this reducer is **not** the companion of `DateSeekPickerView`: it /// owns no view, and the picker owns no reducer. `DateSeekPickerView` is a store-agnostic diff --git a/AppPackage/Sources/Resources/Resources/Localizable.xcstrings b/AppPackage/Sources/Resources/Resources/Localizable.xcstrings index 56fb61fe4..75678828f 100644 --- a/AppPackage/Sources/Resources/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/Resources/Resources/Localizable.xcstrings @@ -1,252 +1,252 @@ { - "sourceLanguage": "en", - "strings": { - "cancel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel" + "sourceLanguage" : "en", + "strings" : { + "cancel" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Abbrechen" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キャンセル" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセル" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "취소" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "취소" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "取消" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "取消" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" } } } }, - "clear": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Clear" + "clear" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Löschen" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Löschen" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "削除" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "削除" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "삭제" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "清空" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清空" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "清空" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "清空" } } } }, - "clear_description": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Are you sure to clear?" + "clear_description" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bist du sicher das du das löschen möchtest?" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Bist du sicher das du das löschen möchtest?" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure to clear?" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "本当に削除してもよろしいですか?" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "本当に削除してもよろしいですか?" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "삭제하시겠어요?" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제하시겠어요?" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "确定要清空吗?" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要清空吗?" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "確定要清空嗎?" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要清空嗎?" } } } }, - "date_seek": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Seek to date" + "date_seek" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datum aufsuchen" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Datum aufsuchen" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date seek" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "日付指定" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日付指定" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "날짜로 이동" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "날짜로 이동" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "日期定位" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日期定位" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "日期定位" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "日期定位" } } } }, - "days": { - "extractionState": "manual", - "localizations": { - "en": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%lld day" + "days" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Tag" } }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld days" + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Tage" } } } } }, - "de": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%lld Tag" + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld day" } }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld Tage" + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld days" } } } } }, - "ja": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 日" + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 日" } } } } }, - "ko": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld일" + "ko" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld일" } } } } }, - "zh-Hans": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 天" + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 天" } } } } }, - "zh-Hant": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 天" + "zh-Hant" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 天" } } } @@ -254,621 +254,621 @@ } } }, - "delete": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete" + "delete" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Löschen" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Löschen" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "削除" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "削除" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "삭제" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "删除" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "刪除" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除" } } } }, - "delete_description": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Are you sure to delete this item?" + "delete_description" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Möchtest du dieses Element wirklich löschen?" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Möchtest du dieses Element wirklich löschen?" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure to delete this item?" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "本当にこれを削除してもよろしいですか?" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "本当にこれを削除してもよろしいですか?" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "이 항목을 삭제하시겠어요?" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 항목을 삭제하시겠어요?" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "确定要删除吗?" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要删除吗?" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "確定要刪除?" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要刪除?" } } } }, - "delete_download": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete Download?" + "delete_download" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download löschen?" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Download löschen?" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Download?" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダウンロードを削除しますか?" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロードを削除しますか?" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다운로드를 삭제할까요?" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드를 삭제할까요?" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "删除下载?" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除下载?" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "刪除下載?" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除下載?" } } } }, - "delete_downloaded_gallery": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "This will remove the downloaded gallery from this device." + "delete_downloaded_gallery" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die heruntergeladene Galerie wird von diesem Gerät entfernt." } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Die heruntergeladene Galerie wird von diesem Gerät entfernt." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This will remove the downloaded gallery from this device." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダウンロード済みのギャラリーをこのデバイスから削除します。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロード済みのギャラリーをこのデバイスから削除します。" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다운로드한 갤러리를 이 기기에서 삭제합니다." + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드한 갤러리를 이 기기에서 삭제합니다." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "这将从此设备移除已下载的画廊。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这将从此设备移除已下载的画廊。" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "這將從此裝置移除已下載的畫廊。" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這將從此裝置移除已下載的畫廊。" } } } }, - "detail": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Detail" + "detail" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Details" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "詳細" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "詳細" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "세부 정보" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세부 정보" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "详情" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "详情" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "Detail" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" } } } }, - "download_store.invalid_folder_name": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The folder name is invalid." + "download_store.invalid_folder_name" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Ordnername ist ungültig." } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Ordnername ist ungültig." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The folder name is invalid." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダ名が無効です。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フォルダ名が無効です。" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "폴더 이름이 올바르지 않습니다." + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "폴더 이름이 올바르지 않습니다." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "文件夹名称无效。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件夹名称无效。" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "資料夾名稱無效。" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料夾名稱無效。" } } } }, - "download_store.manifest_corrupted": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Manifest file is corrupted." + "download_store.manifest_corrupted" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manifest-Datei ist beschädigt." } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Manifest-Datei ist beschädigt." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manifest file is corrupted." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "マニフェストファイルが破損しています。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マニフェストファイルが破損しています。" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "매니페스트 파일이 손상되었습니다." + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "매니페스트 파일이 손상되었습니다." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Manifest 文件已损坏。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manifest 文件已损坏。" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "Manifest 檔案已損壞。" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manifest 檔案已損壞。" } } } }, - "download_store.page_image_corrupted": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Page %lld image data is corrupted." + "download_store.page_image_corrupted" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilddaten von Seite %lld sind beschädigt." } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Bilddaten von Seite %lld sind beschädigt." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page %lld image data is corrupted." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページ %lld の画像データが破損しています。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ページ %lld の画像データが破損しています。" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "페이지 %lld 이미지 데이터가 손상되었습니다." + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "페이지 %lld 이미지 데이터가 손상되었습니다." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "第 %lld 页图片数据已损坏。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %lld 页图片数据已损坏。" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "第 %lld 頁圖片資料已損壞。" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %lld 頁圖片資料已損壞。" } } } }, - "download_store.page_missing": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Page %lld is missing." + "download_store.page_missing" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seite %lld fehlt." } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Seite %lld fehlt." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page %lld is missing." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページ %lld が見つかりません。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ページ %lld が見つかりません。" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "페이지 %lld가 없습니다." + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "페이지 %lld가 없습니다." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "第 %lld 页缺失。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %lld 页缺失。" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "第 %lld 頁缺失。" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %lld 頁缺失。" } } } }, - "downloads": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Downloads" + "downloads" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Downloads" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダウンロード" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロード" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다운로드" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "下载" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "下載" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下載" } } } }, - "favorites": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Favorites" + "favorites" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoriten" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Favoriten" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorites" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "お気に入り" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お気に入り" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "즐겨찾기" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "收藏" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "收藏" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" } } } }, - "filters": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Filters" + "filters" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Filter" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filters" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フィルター" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フィルター" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "필터" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "필터" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "筛选" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "筛选" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "過濾" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "過濾" } } } }, - "home": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Home" + "home" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Start" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ホーム" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ホーム" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "홈" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "홈" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "主页" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主页" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "總覽" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "總覽" } } } }, - "hours": { - "extractionState": "manual", - "localizations": { - "en": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%lld hour" + "hours" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Stunde" } }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld hours" + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Stunden" } } } } }, - "de": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%lld Stunde" + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld hour" } }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld Stunden" + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld hours" } } } } }, - "ja": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 時間" + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 時間" } } } } }, - "ko": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld시간" + "ko" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld시간" } } } } }, - "zh-Hans": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 小时" + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 小时" } } } } }, - "zh-Hant": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 小時" + "zh-Hant" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 小時" } } } @@ -876,252 +876,252 @@ } } }, - "jump_page": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Jump page" + "jump_page" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zu Seite springen" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Zu Seite springen" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jump page" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページジャンプ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ページジャンプ" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "페이지 이동" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "페이지 이동" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "页码跳转" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "页码跳转" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "跳到..." + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "跳到..." } } } }, - "language": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Language" + "language" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprache" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Sprache" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Language" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "言語" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "言語" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "언어" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "언어" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "语言" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "语言" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "語言" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "語言" } } } }, - "login": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Login" + "login" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einloggen" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Einloggen" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ログイン" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ログイン" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "로그인" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로그인" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "登录" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "登入" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "登入" } } } }, - "manage_folders": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Manage Folders" + "manage_folders" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordner verwalten" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Ordner verwalten" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage Folders" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダを管理" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フォルダを管理" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "폴더 관리" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "폴더 관리" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "管理文件夹" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理文件夹" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "管理資料夾" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理資料夾" } } } }, - "minutes": { - "extractionState": "manual", - "localizations": { - "en": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%lld minute" + "minutes" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Minute" } }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld minutes" + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Minuten" } } } } }, - "de": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%lld Minute" + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld minute" } }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld Minuten" + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld minutes" } } } } }, - "ja": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 分" + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 分" } } } } }, - "ko": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld분" + "ko" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld분" } } } } }, - "zh-Hans": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 分" + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 分" } } } } }, - "zh-Hant": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 分" + "zh-Hant" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 分" } } } @@ -1129,252 +1129,252 @@ } } }, - "pages": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld pages" + "pages" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Seiten" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Seiten" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld pages" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld ページ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ページ" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%lld페이지" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld페이지" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%lld 页" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 页" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%lld 頁" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 頁" } } } }, - "quick_search": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Quick search" + "quick_search" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schnellsuche" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Schnellsuche" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quick search" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "クイック検索" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クイック検索" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "빠른 검색" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빠른 검색" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "快速搜索" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "快速搜索" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "快速搜尋" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "快速搜尋" } } } }, - "retry": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Retry" + "retry" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erneut versuchen" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Erneut versuchen" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リトライ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リトライ" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "재시도" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "재시도" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重试" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重试" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重試" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重試" } } } }, - "search": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Search" + "search" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suche" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Suche" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "検索" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検索" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "검색" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검색" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "搜索" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "搜尋" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜尋" } } } }, - "seconds": { - "extractionState": "manual", - "localizations": { - "en": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%lld second" + "seconds" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Sekunde" } }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld seconds" + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Sekunden" } } } } }, - "de": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%lld Sekunde" + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld second" } }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld Sekunden" + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld seconds" } } } } }, - "ja": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 秒" + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 秒" } } } } }, - "ko": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld초" + "ko" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld초" } } } } }, - "zh-Hans": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 秒" + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 秒" } } } } }, - "zh-Hant": { - "variations": { - "plural": { - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld 秒" + "zh-Hant" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 秒" } } } @@ -1382,170 +1382,170 @@ } } }, - "setting": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Setting" + "setting" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Einstellungen" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setting" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "設定" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "설정" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "设置" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "設定" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" } } } }, - "share": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Share" + "share" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teilen" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Teilen" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "共有" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "共有" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "공유" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공유" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "分享" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "分享" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享" } } } }, - "stars": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld stars" + "stars" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Sterne" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Sterne" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld stars" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld つ星" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld つ星" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%lld별" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld별" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%lld 星" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 星" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%lld 星" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 星" } } } }, - "update": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Update" + "update" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualisieren" } }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Aktualisieren" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "更新" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "업데이트" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "更新" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新" } }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "更新" + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新" } } } } }, - "version": "1.0" + "version" : "1.0" } \ No newline at end of file From 9ca5f082bd41d282edc345f401ec369ad9c5d560 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 8 Jul 2026 10:33:13 +0800 Subject: [PATCH 557/614] Update outdated comments --- AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift | 3 +-- AppPackage/Sources/AppFeature/RootView.swift | 1 - .../Sources/DownloadClient/DownloadClient+Manager.swift | 4 ++-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index af83764df..3da02ec62 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -146,8 +146,7 @@ struct AppReducer { } case .appDelegate(.onLaunchFinish): - // No database preparation to await anymore: import any launch-automation cookies and - // load the persisted settings straight away. + // Import any launch-automation cookies and load the persisted settings straight away. let loginCookies = appLaunchAutomationClient.current()?.loginCookies return .merge( .send(.appLogsPump(.startPump)), diff --git a/AppPackage/Sources/AppFeature/RootView.swift b/AppPackage/Sources/AppFeature/RootView.swift index d9710cf70..ab262a0c3 100644 --- a/AppPackage/Sources/AppFeature/RootView.swift +++ b/AppPackage/Sources/AppFeature/RootView.swift @@ -12,7 +12,6 @@ public struct RootView: View { } public var body: some View { - // No database to prepare anymore: the tab bar is the root view from launch. TabBarView(store: appDelegate.store).onAppear(perform: addTouchHandler).accentColor(.primary) } diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Manager.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Manager.swift index 2ceae94da..7fbff7c1c 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+Manager.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Manager.swift @@ -39,8 +39,8 @@ public struct DownloadTaskRunner: Sendable { /// The brain of the download subsystem: the in-memory read model (`downloadIndex`, /// `userFolders`) fused with scheduling (`activeGalleryID`, `activeTask`, queued -/// modes / selections). It is one of three types the old monolith was split into by -/// invariant ownership, alongside `DownloadStore` (pure disk I/O) and +/// modes / selections). It is one of three types split by invariant ownership, +/// alongside `DownloadStore` (pure disk I/O) and /// `DownloadObserverHub` (observer fan-out), all behind the unchanged `DownloadClient` /// facade. Read model and scheduling stay fused on purpose: only one gallery downloads at /// a time (E-Hentai rate-limits gallery downloads, so concurrency is unwanted), and From 002bc49d48328389ac8c2ab4f77b39f8564d8e8e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 00:01:13 +0800 Subject: [PATCH 558/614] Drop DB-changelog comments and dead error case --- .../AppComponents/AppError+Symbol.swift | 2 - .../AppFeature/DataFlow/AppRouteReducer.swift | 2 +- .../DownloadedGallery+Extensions.swift | 4 +- .../AppModels/Resources/Localizable.xcstrings | 82 ------------------- .../Sources/AppModels/Support/AppError.swift | 11 +-- .../DetailFeature/DetailReducer+Actions.swift | 2 +- .../Sources/DetailFeature/DetailReducer.swift | 4 +- .../DownloadClient+PageDownload.swift | 2 +- .../ReadingReducer+Session.swift | 7 +- .../SettingFeature/SettingReducer+Body.swift | 5 +- .../SettingFeature/SettingReducer.swift | 9 +- 11 files changed, 17 insertions(+), 113 deletions(-) diff --git a/AppPackage/Sources/AppComponents/AppError+Symbol.swift b/AppPackage/Sources/AppComponents/AppError+Symbol.swift index c5cd1ace4..241d139b7 100644 --- a/AppPackage/Sources/AppComponents/AppError+Symbol.swift +++ b/AppPackage/Sources/AppComponents/AppError+Symbol.swift @@ -4,8 +4,6 @@ import SFSafeSymbols extension AppError { public var symbol: SFSymbol { switch self { - case .databaseCorrupted: - return .exclamationmarkTriangleFill case .ipBanned: return .networkBadgeShieldHalfFilled case .copyrightClaim, .expunged: diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index 187679110..cf9801e38 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -148,7 +148,7 @@ struct AppRouteReducer { state.detail = nil state.path.removeAll() } - // Always fetch the gallery so the pushed detail is seeded from it (no cache lookup). + // Always fetch the gallery so the pushed detail is seeded from it. let analysis = urlClient.analyzeURL(url) return .run { [delay] send in try await Task.sleep(for: .milliseconds(delay)) diff --git a/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift index 003277c04..6553f225c 100644 --- a/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift @@ -58,8 +58,8 @@ public struct DownloadRequestPayload: Equatable, Sendable { /// file per page when present, so a single missing entry would otherwise trigger a live, /// quota-burning H@H fetch; the offline gate prevents that for offline reads) and it carries /// manifest metadata provenance (gallery + language seeded from the manifest, so a downloaded -/// gallery is readable with no database record). When the local files turn up empty it -/// auto-promotes to `.remote`. +/// gallery reads entirely from its local files and manifest). When the local files turn up empty +/// it auto-promotes to `.remote`. public enum ReadingContentSource: Equatable, Sendable { case remote case local(DownloadedGallery, DownloadManifest) diff --git a/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings b/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings index 23110c818..828c90a9b 100644 --- a/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings +++ b/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings @@ -411,47 +411,6 @@ } } }, - "app_error.database_corrupted": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Database Corrupted" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Datenbank beschädigt" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "データベース破損" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "데이터베이스 손상" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "数据库损坏" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "資料庫損壞" - } - } - } - }, "app_error.file_operation_failed": { "extractionState": "manual", "localizations": { @@ -12342,47 +12301,6 @@ } } }, - "database_corrupted": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The database is corrupted.\nPlease submit an issue on GitHub." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Datenbank ist beschädigt.\nBitte erstelle ein Issue auf GitHub." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "データベースが破損しています。\nGitHub で Issue を作成していただくようお願いいたします。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "데이터베이스가 손상되었어요.\nGitHub에 이슈를 남겨주세요." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "数据库已损毁。\n请到 GitHub 提起 Issue 反馈。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "資料庫已經損壞\n請提交 issue 至 GitHub." - } - } - } - }, "display_mode.compact": { "extractionState": "manual", "localizations": { diff --git a/AppPackage/Sources/AppModels/Support/AppError.swift b/AppPackage/Sources/AppModels/Support/AppError.swift index c60ac1124..0889151dd 100644 --- a/AppPackage/Sources/AppModels/Support/AppError.swift +++ b/AppPackage/Sources/AppModels/Support/AppError.swift @@ -8,7 +8,6 @@ public enum AppError: Error, Identifiable, Equatable, Hashable, Sendable { self = error as? AppError ?? .unknown } - case databaseCorrupted(String?) case copyrightClaim(String) case ipBanned(BanInterval) case expunged(String) @@ -26,7 +25,7 @@ public enum AppError: Error, Identifiable, Equatable, Hashable, Sendable { extension AppError { public var isRetryable: Bool { switch self { - case .databaseCorrupted, .networkingFailed, .parseFailed, + case .networkingFailed, .parseFailed, .fileOperationFailed, .noUpdates, .unknown, .webImageFailed: return true case .copyrightClaim, .expunged, .quotaExceeded, .authenticationRequired, .notFound, @@ -36,8 +35,6 @@ extension AppError { } public var localizedDescription: String { switch self { - case .databaseCorrupted: - return String(localized: .appErrorDatabaseCorrupted) case .copyrightClaim: return String(localized: .appErrorCopyrightClaim) case .ipBanned: @@ -67,12 +64,6 @@ extension AppError { public var alertText: String { let tryLater = String(localized: .tryLater) switch self { - case .databaseCorrupted(let reason): - var lines = [String(localized: .databaseCorrupted)] - if let reason = reason { - lines.append("(\(reason))") - } - return lines.joined(separator: "\n") case .copyrightClaim(let owner): return String(localized: .copyrightClaim(owner)) case .ipBanned(let interval): diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index 969b60bb6..747b79ff0 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -88,7 +88,7 @@ extension DetailReducer { state.didRunLaunchAutomation = false state.localPreviewURLs = .init() // The gallery is already seeded from the pushing context, so we record the visit and fetch - // the (always network-sourced) detail directly — no database read. + // the (always network-sourced) detail directly. return .merge( .send(.saveGalleryHistory), .send(.fetchGalleryDetail), diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index 9134178ad..92bd5bf3a 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -105,8 +105,8 @@ public struct DetailReducer: Sendable { } // Seeded from the pushing context (a tapped list item or a freshly-fetched gallery) so the - // detail header renders immediately and `fetchGalleryDetail` has a `galleryURL` without any - // database lookup. Gallery data lives only here and dies when the screen pops. + // detail header renders immediately and `fetchGalleryDetail` has a `galleryURL`. Gallery data + // lives only here and dies when the screen pops. public init(gallery: Gallery, pendingDeepLink: GalleryDeepLink? = nil) { self.gid = gallery.id self.gallery = gallery diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PageDownload.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PageDownload.swift index ccdf36943..5fbf33739 100644 --- a/AppPackage/Sources/DownloadClient/DownloadClient+PageDownload.swift +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PageDownload.swift @@ -313,7 +313,7 @@ extension DownloadCoordinator { switch error { case .quotaExceeded, .authenticationRequired, .ipBanned: return true - case .databaseCorrupted, .copyrightClaim, .expunged, .networkingFailed, + case .copyrightClaim, .expunged, .networkingFailed, .webImageFailed, .parseFailed, .fileOperationFailed, .noUpdates, .notFound, .unknown: return false diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift index b4e0e5e3f..8a37d664d 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift @@ -54,8 +54,7 @@ extension ReadingReducer { applyLocalSource(state: &state, download: download, manifest: manifest) } // Remote galleries are seeded from the pushing context; URL maps are rebuilt per session - // (fetched on demand), so nothing is read from a database here. The resume position comes - // from the persisted browsing history. + // (fetched on demand). The resume position comes from the persisted browsing history. @Shared(.galleryHistory) var galleryHistory state.readingProgress = galleryHistory.readingProgress(gid: gid) // Seed the pending page with the restored resume position so a flush that fires before the @@ -142,8 +141,8 @@ extension ReadingReducer { } /// Enters offline mode: seeds the gallery and language from the manifest (so a downloaded - /// gallery reads with no database record) and makes `localPageURLs` the only page source, - /// clearing the remote URL maps that don't apply offline. + /// gallery reads entirely from its local files and manifest) and makes `localPageURLs` the only + /// page source, clearing the remote URL maps that don't apply offline. func applyLocalSource( state: inout State, download: DownloadedGallery, diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 4e8b477de..559e565e2 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -11,8 +11,7 @@ extension SettingReducer { @ReducerBuilder var reducerBody: some Reducer { // `setting` is `@Shared`, so BindingReducer writes and the fixups below persist automatically — - // these `.onChange` handlers now carry only their genuine side effects and cross-field - // invariants (no `.syncSetting` write-through remains). + // these `.onChange` handlers carry only their genuine side effects and cross-field invariants. BindingReducer() .onChange(of: \.setting.galleryHost) { _, state in .run(operation: { [value = state.setting.galleryHost.rawValue] _ in @@ -118,7 +117,7 @@ extension SettingReducer { return .run(operation: { _ in await applicationClient.setUserInterfaceStyle(style) }) case .loadUserSettings: - // `setting`/`user`/`tagTranslator` are all @Shared (auto-loaded); no database read. + // `setting`/`user`/`tagTranslator` are all @Shared (auto-loaded). return handleLoadUserSettings(&state) case .loadUserSettingsDone: diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index bb5961721..f387738ea 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -51,11 +51,10 @@ public struct SettingReducer: Sendable { public struct State: Equatable, Sendable { // `setting` is stored directly in `@Shared(.setting)`, so every mutation — form bindings, the // cross-field `.onChange` fixups, and non-binding syncs like `syncAppIconTypeDone` — persists - // atomically. There is no working copy to keep in step, which removes the class of bug where a - // non-binding write path forgot to fire the old `.syncSetting` and the persisted value silently - // diverged. `user` is likewise shared. `tagTranslator` is derived, re-downloadable data: it - // lives in memory only and is rebuilt at launch from the cached raw JSON; only its thin - // `tagTranslatorInfo` metadata persists (see `AppSharedKeys`). + // atomically. There is no working copy to keep in step. `user` is likewise shared. + // `tagTranslator` is derived, re-downloadable data: it lives in memory only and is rebuilt at + // launch from the cached raw JSON; only its thin `tagTranslatorInfo` metadata persists (see + // `AppSharedKeys`). @Shared(.setting) public var setting: Setting /// A write-through view of `setting` for SwiftUI bindings. `@Shared`'s own value setter is /// deprecated (it can't take exclusive access), so binding `$store.setting.x` directly warns; From 14dd08095ce52390f717dce879da86e094cc0e21 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 00:05:17 +0800 Subject: [PATCH 559/614] Tighten gdata decode to Parser's required set --- .../Request+GalleriesMetadata.swift | 21 +++++++--- .../GalleriesMetadataDecodeTests.swift | 42 ++++++++++++++++++- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift index 86370dfc3..5e7c21bd5 100644 --- a/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift +++ b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift @@ -10,7 +10,10 @@ private struct GalleriesMetadataAPIResponse: Decodable { /// One `gmetadata` entry from the `gdata` API. Every display field is optional because the API /// returns a bare `{ gid, error }` object for gids it can't resolve (expunged/removed galleries); -/// `gallery` yields `nil` for those so a single bad entry never fails the whole batch. +/// `gallery` yields `nil` for those so a single bad entry never fails the whole batch. A *resolved* +/// entry still missing any field the display needs (token, title, posted, category, rating, cover, +/// pageCount) is likewise dropped rather than defaulted, mirroring the HTML list parser's +/// all-or-nothing row policy. private struct GalleryMetadata: Decodable { let gid: Int let token: String? @@ -25,22 +28,28 @@ private struct GalleryMetadata: Decodable { let tags: [String]? var gallery: Gallery? { + // Match the HTML list parser: a resolved row needs its full display set, or it is dropped. + // Only `tags` and `uploader` stay tolerant. guard error == nil, let token, let title, - let posted, let postedInterval = TimeInterval(posted) + let posted, let postedInterval = TimeInterval(posted), + let category = category.flatMap(AppModels.Category.init(rawValue:)), + let rating = rating.flatMap(Float.init), + let coverURL = thumb.flatMap({ URL(string: $0) }), + let pageCount = filecount.flatMap(Int.init) else { return nil } return Gallery( gid: String(gid), token: token, title: title.htmlEntitiesDecoded, - rating: rating.flatMap(Float.init) ?? 0, + rating: rating, tags: Self.parseTags(tags ?? []), - category: category.flatMap(AppModels.Category.init(rawValue:)) ?? .misc, + category: category, uploader: uploader, - pageCount: filecount.flatMap(Int.init) ?? 0, + pageCount: pageCount, postedDate: Date(timeIntervalSince1970: postedInterval), - coverURL: thumb.flatMap { URL(string: $0) }, + coverURL: coverURL, galleryURL: Defaults.URL.host .appendingPathComponent("g") .appendingPathComponent(String(gid)) diff --git a/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift b/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift index 723dc2034..e58ffe03b 100644 --- a/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift +++ b/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift @@ -5,7 +5,8 @@ import AppModels // REV-2: the `gdata` API returns bare `{ gid, error }` objects for expunged/removed gids (and can omit // `token`). Decoding the whole `[GalleryMetadata]` array must tolerate those per-entry — one bad gid -// must never fail the batch and blank the entire History screen. +// must never fail the batch and blank the entire History screen. A *resolved* entry missing a required +// display field is dropped rather than defaulted, matching the HTML list parser's row policy. @Suite struct GalleriesMetadataDecodeTests { @Test @@ -49,7 +50,8 @@ struct GalleriesMetadataDecodeTests { { "gid": 100, "token": "aaa", "title": "&lt; A & <tag>", - "posted": "1600000000" + "category": "Doujinshi", "thumb": "https://example.com/1.jpg", + "posted": "1600000000", "filecount": "20", "rating": "4.5" } ] } @@ -69,4 +71,40 @@ struct GalleriesMetadataDecodeTests { let galleries = try GalleriesMetadataRequest.galleries(fromResponseData: Data(json.utf8)) #expect(galleries.isEmpty) } + + // A *resolved* entry (error-free, has token) that is nonetheless missing a required display field + // is dropped rather than defaulted — matching the HTML list parser, which drops an incomplete row + // instead of rendering it with `.misc`/`0`/no cover. Here `category`, `rating`, `thumb` and + // `filecount` are each absent in turn. + @Test + func resolvedEntryMissingRequiredFieldIsDropped() throws { + let json = """ + { + "gmetadata": [ + { + "gid": 400, "token": "ddd", "title": "No Category", + "thumb": "https://example.com/4.jpg", "posted": "1600000000", + "filecount": "10", "rating": "4.0" + }, + { + "gid": 500, "token": "eee", "title": "No Rating", + "category": "Manga", "thumb": "https://example.com/5.jpg", + "posted": "1600000000", "filecount": "10" + }, + { + "gid": 600, "token": "fff", "title": "No Cover", + "category": "Manga", "posted": "1600000000", + "filecount": "10", "rating": "4.0" + }, + { + "gid": 700, "token": "ggg", "title": "No Pagecount", + "category": "Manga", "thumb": "https://example.com/7.jpg", + "posted": "1600000000", "rating": "4.0" + } + ] + } + """ + let galleries = try GalleriesMetadataRequest.galleries(fromResponseData: Data(json.utf8)) + #expect(galleries.isEmpty) + } } From adf4ce830e19351c397b5f80fa30705cbb86fcdd Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 00:13:00 +0800 Subject: [PATCH 560/614] Move greeting to in-memory shared slot --- .../AppModels/Persistence/AppSharedKeys.swift | 9 +++ .../Sources/AppModels/Persistent/User.swift | 32 +---------- .../{Persistent => Support}/Greeting.swift | 17 ++++++ .../DetailFeature/DetailReducer+Actions.swift | 9 +-- .../SettingReducer+Helpers.swift | 2 +- .../SettingFeature/SettingReducer.swift | 5 +- .../AppModelsTests/GreetingMergeTests.swift | 41 ++++++++++++++ .../Tests/AppModelsTests/UserTests.swift | 56 ------------------- 8 files changed, 77 insertions(+), 94 deletions(-) rename AppPackage/Sources/AppModels/{Persistent => Support}/Greeting.swift (78%) create mode 100644 AppPackage/Tests/AppModelsTests/GreetingMergeTests.swift delete mode 100644 AppPackage/Tests/AppModelsTests/UserTests.swift diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift index 86e47cdfd..88ceaa6b3 100644 --- a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -51,6 +51,15 @@ extension SharedKey where Self == AppStorageKey.Default { } } +// The daily "New Dawn" greeting is an ephemeral reward, not durable account identity, so it lives in +// memory only and resets to `nil` on the next launch. Two features write it — the Setting daily fetch +// and the Detail-page parse — through the newer-only `mergeNewer(_:)` rule (see `Greeting`). +extension SharedKey where Self == InMemoryKey.Default { + public static var greeting: Self { + Self[.inMemory("greeting"), default: nil] + } +} + // MARK: Filters extension SharedKey where Self == AppStorageKey.Default { diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index c5e2f536f..acda0012a 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -7,14 +7,12 @@ public struct User: Codable, Equatable, Sendable { avatarURL: URL? = nil, credits: String? = nil, galleryPoints: String? = nil, - greeting: Greeting? = nil, favoriteCategories: [Int: String]? = nil ) { self.displayName = displayName self.avatarURL = avatarURL self.credits = credits self.galleryPoints = galleryPoints - self.greeting = greeting self.favoriteCategories = favoriteCategories } public static let empty = User() @@ -27,19 +25,8 @@ public struct User: Codable, Equatable, Sendable { public var credits: String? public var galleryPoints: String? - // Not persisted: `greeting` is the daily "New Dawn" reward — ephemeral session data rather than - // durable account identity. It is omitted from `CodingKeys` below so persisting `User` (via - // `@Shared(.user)`) never writes it; it stays live in memory for the session and resets to `nil` - // on the next launch. See the greeting-fetch throttle in `SettingReducer`. - public var greeting: Greeting? - public var favoriteCategories: [Int: String]? - // `greeting` is intentionally absent so Codable skips it (it keeps its `nil` default on decode). - private enum CodingKeys: String, CodingKey { - case schemaVersion, displayName, avatarURL, credits, galleryPoints, favoriteCategories - } - public func getFavoriteCategory(index: Int) -> String { guard index != -1 else { return String(localized: .favoriteCategoryAll) } let defaultCategory = String(localized: .favoriteCategoryDefault(index: index)) @@ -49,28 +36,11 @@ public struct User: Codable, Equatable, Sendable { } } -extension User { - /// Adopts `greeting` only when it is newer than the one already held (or none is held). Two - /// features write greetings — the Setting daily fetch and the Detail-page parse — so the - /// "keep the newer" rule lives here, not at either call site, and a stale detail-page greeting - /// can't clobber a fresher one. `greeting` is session-only and never persisted (see `CodingKeys`). - public mutating func mergeGreeting(_ greeting: Greeting) { - guard let newDate = greeting.updateTime else { return } - if let current = self.greeting { - if let currentDate = current.updateTime, currentDate < newDate { - self.greeting = greeting - } - } else { - self.greeting = greeting - } - } -} - // MARK: Manually decode extension User { // Tolerant decoding keeps an existing persisted value valid across future additive changes; a // non-optional field like `schemaVersion` would otherwise fail synthesized decode of an older - // record. `greeting` is intentionally not decoded (absent from `CodingKeys`) and stays `nil`. + // record. public init(from decoder: Decoder) { let container = try? decoder.container(keyedBy: CodingKeys.self) schemaVersion = container.decode(.schemaVersion, default: 1) diff --git a/AppPackage/Sources/AppModels/Persistent/Greeting.swift b/AppPackage/Sources/AppModels/Support/Greeting.swift similarity index 78% rename from AppPackage/Sources/AppModels/Persistent/Greeting.swift rename to AppPackage/Sources/AppModels/Support/Greeting.swift index d7dc0e3e6..51e33989b 100644 --- a/AppPackage/Sources/AppModels/Persistent/Greeting.swift +++ b/AppPackage/Sources/AppModels/Support/Greeting.swift @@ -83,3 +83,20 @@ public struct Greeting: Codable, Equatable, Hashable, Identifiable, Sendable { .compactMap({ $0 }).isEmpty } } + +extension Optional where Wrapped == Greeting { + /// Adopts `greeting` only when it is newer than the one already held (or none is held). Two + /// features write the session greeting — the Setting daily fetch and the Detail-page parse — so the + /// "keep the newer" rule lives here, next to the model, rather than at either call site, and a stale + /// detail-page greeting can't clobber a fresher one. + public mutating func mergeNewer(_ greeting: Greeting) { + guard let newDate = greeting.updateTime else { return } + if let current = self { + if let currentDate = current.updateTime, currentDate < newDate { + self = greeting + } + } else { + self = greeting + } + } +} diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index 747b79ff0..0aea27e1f 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -143,10 +143,11 @@ extension DetailReducer { Reduce { state, action in switch action { case .syncGreeting(let greeting): - // Greeting is session-only (not persisted with `User`). Merge through the shared user's - // newer-only rule so a stale detail-page greeting can't clobber a fresher Setting fetch. - @Shared(.user) var user - $user.withLock { $0.mergeGreeting(greeting) } + // Greeting is a session-only in-memory shared slot (resets each launch). Merge through + // the newer-only rule so a stale detail-page greeting can't clobber a fresher Setting + // fetch. + @Shared(.greeting) var sharedGreeting + $sharedGreeting.withLock { $0.mergeNewer(greeting) } return .none case .saveGalleryHistory: diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift index 094023c46..ea645d0b3 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift @@ -68,7 +68,7 @@ extension SettingReducer { let response = await GreetingRequest().response() await send(Action.fetchGreetingDone(response)) } - if let greeting = state.user.greeting { + if let greeting = state.greeting { if verifyDate(with: greeting.updateTime) { return requestEffect } diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index f387738ea..149dbe011 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -68,6 +68,7 @@ public struct SettingReducer: Sendable { public var tagTranslator = TagTranslator() @Shared(.tagTranslatorInfo) public var tagTranslatorInfo: TagTranslatorInfo @Shared(.user) public var user: User + @Shared(.greeting) public var greeting: Greeting? public var hasLoadedInitialSetting = false @@ -76,8 +77,8 @@ public struct SettingReducer: Sendable { public init() {} - mutating func setGreeting(_ greeting: Greeting) { - $user.withLock { $0.mergeGreeting(greeting) } + mutating func setGreeting(_ newGreeting: Greeting) { + $greeting.withLock { $0.mergeNewer(newGreeting) } } mutating func updateUser(_ user: User) { diff --git a/AppPackage/Tests/AppModelsTests/GreetingMergeTests.swift b/AppPackage/Tests/AppModelsTests/GreetingMergeTests.swift new file mode 100644 index 000000000..3f758c9aa --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/GreetingMergeTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +import AppModels + +// REV-21 / #3: greetings have two writers (the Setting daily fetch and the Detail-page parse), so the +// "keep the newer" merge lives on `Optional.mergeNewer`. A stale detail-page greeting must +// not clobber a fresher one. The greeting itself is a session-only in-memory shared slot (`.greeting`), +// so its non-persistence is enforced by the key's strategy rather than a Codable round-trip. +@Suite +struct GreetingMergeTests { + private let older = Greeting(gainedCredits: 1, updateTime: Date(timeIntervalSince1970: 100)) + private let newer = Greeting(gainedCredits: 2, updateTime: Date(timeIntervalSince1970: 200)) + + @Test + func mergeNewerAdoptsWhenNoneHeld() { + var greeting: Greeting? + greeting.mergeNewer(newer) + #expect(greeting?.gainedCredits == 2) + } + + @Test + func mergeNewerAdoptsAStrictlyNewerGreeting() { + var greeting: Greeting? = older + greeting.mergeNewer(newer) + #expect(greeting?.gainedCredits == 2) + } + + @Test + func mergeNewerKeepsTheHeldGreetingWhenIncomingIsOlder() { + var greeting: Greeting? = newer + greeting.mergeNewer(older) + #expect(greeting?.gainedCredits == 2) + } + + @Test + func mergeNewerIgnoresADatelessGreeting() { + var greeting: Greeting? = newer + greeting.mergeNewer(Greeting(gainedCredits: 9, updateTime: nil)) + #expect(greeting?.gainedCredits == 2) + } +} diff --git a/AppPackage/Tests/AppModelsTests/UserTests.swift b/AppPackage/Tests/AppModelsTests/UserTests.swift deleted file mode 100644 index de149d321..000000000 --- a/AppPackage/Tests/AppModelsTests/UserTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation -import Testing -import AppModels - -// REV-21: greetings have two writers (the Setting daily fetch and the Detail-page parse), so the -// "keep the newer" merge lives on `User.mergeGreeting`. A stale detail-page greeting must not clobber -// a fresher one. Greeting is also session-only: it must never survive a Codable round-trip. -@Suite -struct UserTests { - private let older = Greeting(gainedCredits: 1, updateTime: Date(timeIntervalSince1970: 100)) - private let newer = Greeting(gainedCredits: 2, updateTime: Date(timeIntervalSince1970: 200)) - - @Test - func mergeGreetingAdoptsWhenNoneHeld() { - var user = User() - user.mergeGreeting(newer) - #expect(user.greeting?.gainedCredits == 2) - } - - @Test - func mergeGreetingAdoptsAStrictlyNewerGreeting() { - var user = User() - user.greeting = older - user.mergeGreeting(newer) - #expect(user.greeting?.gainedCredits == 2) - } - - @Test - func mergeGreetingKeepsTheHeldGreetingWhenIncomingIsOlder() { - var user = User() - user.greeting = newer - user.mergeGreeting(older) - #expect(user.greeting?.gainedCredits == 2) - } - - @Test - func mergeGreetingIgnoresADatelessGreeting() { - var user = User() - user.greeting = newer - user.mergeGreeting(Greeting(gainedCredits: 9, updateTime: nil)) - #expect(user.greeting?.gainedCredits == 2) - } - - @Test - func greetingIsNeverPersistedThroughCodable() throws { - var user = User(displayName: "keep-me", credits: "42") - user.greeting = newer - - let data = try JSONEncoder().encode(user) - let decoded = try JSONDecoder().decode(User.self, from: data) - - #expect(decoded.greeting == nil) // session-only: dropped on encode/decode - #expect(decoded.displayName == "keep-me") // durable fields survive - #expect(decoded.credits == "42") - } -} From 5854076dd17e29bf38cc4e2b9146b839a78f305e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 00:25:25 +0800 Subject: [PATCH 561/614] Replace tolerant decoders with strict decode --- .../AppModels/Persistence/AppSharedKeys.swift | 17 ++- .../KeyedDecodingContainer+Tolerant.swift | 23 ---- .../Sources/AppModels/Persistent/Filter.swift | 42 +------ .../Persistent/GalleryHistoryEntry.swift | 36 ++++-- .../AppModels/Persistent/Setting.swift | 46 +------- .../Sources/AppModels/Persistent/User.swift | 19 +-- .../Sources/AppModels/Support/Misc.swift | 31 +++-- .../AppModels/Tags/TagTranslatorInfo.swift | 13 +-- .../AppModelsTests/StrictDecodingTests.swift | 108 ++++++++++++++++++ .../TolerantDecodingTests.swift | 58 ---------- .../Other/SettingDownloadTests.swift | 27 ++--- 11 files changed, 190 insertions(+), 230 deletions(-) delete mode 100644 AppPackage/Sources/AppModels/Persistence/KeyedDecodingContainer+Tolerant.swift create mode 100644 AppPackage/Tests/AppModelsTests/StrictDecodingTests.swift delete mode 100644 AppPackage/Tests/AppModelsTests/TolerantDecodingTests.swift diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift index 88ceaa6b3..e853fe1f7 100644 --- a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -13,9 +13,11 @@ import Sharing // holds it. // • Keeping the structs intact preserves their invariants (e.g. `Setting`/`Filter` `didSet` // cascades) and makes resets/logout a single atomic assignment. -// • Forward migration rides on each model's tolerant `init(from:)` decoder (`decodeIfPresent` -// + defaults): additive changes never invalidate an existing persisted value, and a decode -// failure falls back to the key's default — there is no store-fails-to-open failure mode. +// • Models decode *strictly* (synthesized Codable, or a hand-written throwing decoder where an +// identity invariant must hold). A corrupt or shape-incompatible blob fails to decode and +// Sharing falls back to the key's default — a clean, coherent reset, never a partially-filled +// Franken-value. Additive evolution stays cheap: a new *optional* field is absent-tolerant +// automatically under synthesized decode, so old blobs stay valid. // // Cap policies differ by the *kind* of data, deliberately: // • `galleryHistory` (auto-recorded browsing) is capped at `GalleryHistoryEntry.historyCap` and @@ -27,9 +29,12 @@ import Sharing // saved word would lose user work. Auto-recorded data is disposable; authored data is not. // // Every model also carries a `schemaVersion` (default 1): a reserved anchor for a future *breaking* -// migration. Additive changes never touch it — they ride the tolerant `init(from:)` decoders above. -// It exists only so a genuinely incompatible change has an explicit version to branch on, rather than -// inferring compatibility from the shape of the decoded data. +// migration, so a genuinely incompatible change has an explicit version to branch on rather than +// inferring compatibility from the decoded shape. The two array-element models with an identity +// invariant (`GalleryHistoryEntry`, `QuickSearchWord`) decode through hand-written throwing decoders +// that reject an out-of-range `schemaVersion`; the whole-struct models rely on synthesized strict +// decode (a shape mismatch already resets to the key default) and reintroduce a branching decoder +// if and when a breaking change lands. // // Nothing here uses the `fileStorage` strategy. The tag-translation table is the only large // artifact, and it is deliberately NOT persisted through Sharing: only its thin diff --git a/AppPackage/Sources/AppModels/Persistence/KeyedDecodingContainer+Tolerant.swift b/AppPackage/Sources/AppModels/Persistence/KeyedDecodingContainer+Tolerant.swift deleted file mode 100644 index be79dcc73..000000000 --- a/AppPackage/Sources/AppModels/Persistence/KeyedDecodingContainer+Tolerant.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -extension Optional { - /// Tolerant keyed decode: returns the decoded value, or `defaultValue` when the key is absent, its - /// value is `null`, decoding it throws (a per-field type mismatch), or the container itself is - /// absent. This is the single home for the read-and-default idiom every persisted model's - /// hand-written `init(from:)` repeats per field, so a new persisted field costs one short line - /// instead of a `(try? container?.decodeIfPresent(…)) ?? default` one. Forgiving a per-field - /// decode failure is what lets an existing persisted value stay valid across future additive - /// changes (see `AppSharedKeys` for the whole-struct persistence rationale). - /// - /// Defined on the *optional* container because those decoders acquire it with - /// `try? decoder.container(keyedBy:)` — which yields `nil` on a shape mismatch rather than - /// throwing — and each field must still fall back to its own default independently, exactly as the - /// per-field `?? default` did. - func decode(_ key: Key, `default` defaultValue: Value) -> Value - where Wrapped == KeyedDecodingContainer { - guard let container = self, - let value = try? container.decodeIfPresent(Value.self, forKey: key) - else { return defaultValue } - return value - } -} diff --git a/AppPackage/Sources/AppModels/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift index 0a2134f3e..337ce5d90 100644 --- a/AppPackage/Sources/AppModels/Persistent/Filter.swift +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -59,7 +59,8 @@ public struct Filter: Codable, Equatable, Sendable { self.disableUploader = disableUploader self.disableTags = disableTags } - // Version anchor for future breaking migrations; additive changes ride the tolerant decoder. + // Version anchor for a future breaking migration. All current fields decode strictly; a field + // added later must stay optional (or use a custom `decodeIfPresent` decoder) so old blobs decode. public var schemaVersion = 1 public var doujinshi = false public var manga = false @@ -115,45 +116,6 @@ public struct Filter: Codable, Equatable, Sendable { } } -// MARK: Manually decode -extension Filter { - public init(from decoder: Decoder) { - let container = try? decoder.container(keyedBy: CodingKeys.self) - schemaVersion = container.decode(.schemaVersion, default: 1) - doujinshi = container.decode(.doujinshi, default: false) - manga = container.decode(.manga, default: false) - artistCG = container.decode(.artistCG, default: false) - gameCG = container.decode(.gameCG, default: false) - western = container.decode(.western, default: false) - nonH = container.decode(.nonH, default: false) - imageSet = container.decode(.imageSet, default: false) - cosplay = container.decode(.cosplay, default: false) - asianPorn = container.decode(.asianPorn, default: false) - misc = container.decode(.misc, default: false) - - advanced = container.decode(.advanced, default: false) - galleryName = container.decode(.galleryName, default: false) - galleryTags = container.decode(.galleryTags, default: false) - galleryDesc = container.decode(.galleryDesc, default: false) - torrentFilenames = container.decode(.torrentFilenames, default: false) - onlyWithTorrents = container.decode(.onlyWithTorrents, default: false) - lowPowerTags = container.decode(.lowPowerTags, default: false) - downvotedTags = container.decode(.downvotedTags, default: false) - expungedGalleries = container.decode(.expungedGalleries, default: false) - - minRatingActivated = container.decode(.minRatingActivated, default: false) - minRating = container.decode(.minRating, default: 2) - - pageRangeActivated = container.decode(.pageRangeActivated, default: false) - pageLowerBound = container.decode(.pageLowerBound, default: "") - pageUpperBound = container.decode(.pageUpperBound, default: "") - - disableLanguage = container.decode(.disableLanguage, default: false) - disableUploader = container.decode(.disableUploader, default: false) - disableTags = container.decode(.disableTags, default: false) - } -} - public enum FilterRange: Int, CaseIterable, Identifiable, Sendable { public var id: Int { rawValue } diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift index eceb3aaa9..548227dc7 100644 --- a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift @@ -7,8 +7,9 @@ import Foundation /// persisted — never a gallery snapshot. The History screen re-fetches display metadata /// from the site's `gdata` API on demand, keeping persisted website content at zero. /// -/// The manual `init(from:)` decodes every field tolerantly (`decodeIfPresent` + default) so -/// that future additive changes to this record never invalidate an existing persisted list. +/// The manual `init(from:)` decodes strictly: a blank identity or an unknown `schemaVersion` +/// throws, failing the whole `[GalleryHistoryEntry]` decode so Sharing resets this disposable list +/// to `[]` rather than surfacing a `""`-id Franken-entry that would collide in an `Identifiable` list. public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable { /// The most entries kept across launches. Enforced only by a launch-time prune (see /// `Array.pruneToHistoryCap`); in-session upserts may temporarily exceed it. @@ -26,7 +27,9 @@ public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable { self.readingProgress = readingProgress } public var id: String { gid } - // Version anchor for future breaking migrations; additive changes ride the tolerant decoder. + /// Highest `schemaVersion` this build can decode; a blob carrying a newer value (a downgrade) + /// is rejected rather than half-read. Bump and branch here when a breaking change lands. + public static let currentSchemaVersion = 1 public var schemaVersion = 1 public var gid: String public var token: String @@ -36,12 +39,25 @@ public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable { // MARK: Manually decode extension GalleryHistoryEntry { - public init(from decoder: Decoder) { - let container = try? decoder.container(keyedBy: CodingKeys.self) - schemaVersion = container.decode(.schemaVersion, default: 1) - gid = container.decode(.gid, default: "") - token = container.decode(.token, default: "") - lastOpenDate = container.decode(.lastOpenDate, default: .distantPast) - readingProgress = container.decode(.readingProgress, default: 0) + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(Int.self, forKey: .schemaVersion) + guard (1...Self.currentSchemaVersion).contains(version) else { + throw DecodingError.dataCorrupted(.init( + codingPath: container.codingPath, + debugDescription: "Unsupported GalleryHistoryEntry schemaVersion \(version)" + )) + } + schemaVersion = version + gid = try container.decode(String.self, forKey: .gid) + token = try container.decode(String.self, forKey: .token) + lastOpenDate = try container.decode(Date.self, forKey: .lastOpenDate) + readingProgress = try container.decode(Int.self, forKey: .readingProgress) + guard !gid.isEmpty, !token.isEmpty else { + throw DecodingError.dataCorrupted(.init( + codingPath: container.codingPath, + debugDescription: "GalleryHistoryEntry requires a non-empty gid and token" + )) + } } } diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index 76e097973..13731a185 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -57,7 +57,8 @@ public struct Setting: Codable, Equatable, Sendable { self.doubleTapScaleFactor = doubleTapScaleFactor self.bypassesSNIFiltering = bypassesSNIFiltering } - // Version anchor for future breaking migrations; additive changes ride the tolerant decoder. + // Version anchor for a future breaking migration. All current fields decode strictly; a field + // added later must stay optional (or use a custom `decodeIfPresent` decoder) so old blobs decode. public var schemaVersion = 1 // Account public var galleryHost: GalleryHost = .ehentai @@ -248,46 +249,3 @@ extension ListDisplayMode { } } } - -// MARK: Manually decode -extension Setting { - public init(from decoder: Decoder) { - let container = try? decoder.container(keyedBy: CodingKeys.self) - schemaVersion = container.decode(.schemaVersion, default: 1) - // Account - galleryHost = container.decode(.galleryHost, default: .ehentai) - showsNewDawnGreeting = container.decode(.showsNewDawnGreeting, default: false) - // General - enablesTagsExtension = container.decode(.enablesTagsExtension, default: false) - translatesTags = container.decode(.translatesTags, default: false) - showsTagsSearchSuggestion = container.decode(.showsTagsSearchSuggestion, default: false) - showsImagesInTags = container.decode(.showsImagesInTags, default: false) - redirectsLinksToSelectedHost = container.decode(.redirectsLinksToSelectedHost, default: false) - detectsLinksFromClipboard = container.decode(.detectsLinksFromClipboard, default: false) - backgroundBlurRadius = container.decode(.backgroundBlurRadius, default: 10) - autoLockPolicy = container.decode(.autoLockPolicy, default: .never) - // Appearance - listDisplayMode = container.decode(.listDisplayMode, default: .detail) - preferredColorScheme = container.decode(.preferredColorScheme, default: .automatic) - accentColor = container.decode(.accentColor, default: .blue) - appIconType = container.decode(.appIconType, default: .default) - showsTagsInList = container.decode(.showsTagsInList, default: false) - listTagsNumberMaximum = container.decode(.listTagsNumberMaximum, default: 0) - displaysJapaneseTitle = container.decode(.displaysJapaneseTitle, default: true) - // Reading - readingDirection = container.decode(.readingDirection, default: .vertical) - prefetchLimit = container.decode(.prefetchLimit, default: 10) - enablesLandscape = container.decode(.enablesLandscape, default: false) - enablesDualPageMode = container.decode(.enablesDualPageMode, default: false) - exceptCover = container.decode(.exceptCover, default: false) - contentDividerHeight = container.decode(.contentDividerHeight, default: 0) - maximumScaleFactor = container.decode(.maximumScaleFactor, default: 3) - doubleTapScaleFactor = container.decode(.doubleTapScaleFactor, default: 2) - // Downloads - downloadThreadLimit = container.decode(.downloadThreadLimit, default: 1) - downloadAllowCellular = container.decode(.downloadAllowCellular, default: true) - downloadAutoRetryFailedPages = container.decode(.downloadAutoRetryFailedPages, default: true) - // Laboratory - bypassesSNIFiltering = container.decode(.bypassesSNIFiltering, default: false) - } -} diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index acda0012a..6004d1409 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -17,7 +17,8 @@ public struct User: Codable, Equatable, Sendable { } public static let empty = User() - // Version anchor for future breaking migrations; additive changes ride the tolerant decoder. + // Version anchor for a future breaking migration. All current fields decode strictly; a field + // added later must stay optional (or use a custom `decodeIfPresent` decoder) so old blobs decode. public var schemaVersion = 1 public var displayName: String? public var avatarURL: URL? @@ -36,22 +37,6 @@ public struct User: Codable, Equatable, Sendable { } } -// MARK: Manually decode -extension User { - // Tolerant decoding keeps an existing persisted value valid across future additive changes; a - // non-optional field like `schemaVersion` would otherwise fail synthesized decode of an older - // record. - public init(from decoder: Decoder) { - let container = try? decoder.container(keyedBy: CodingKeys.self) - schemaVersion = container.decode(.schemaVersion, default: 1) - displayName = try? container?.decodeIfPresent(String.self, forKey: .displayName) - avatarURL = try? container?.decodeIfPresent(URL.self, forKey: .avatarURL) - credits = try? container?.decodeIfPresent(String.self, forKey: .credits) - galleryPoints = try? container?.decodeIfPresent(String.self, forKey: .galleryPoints) - favoriteCategories = try? container?.decodeIfPresent([Int: String].self, forKey: .favoriteCategories) - } -} - public enum FavoritesType: String, Codable, CaseIterable, Sendable { public static func getTypeFrom(index: Int) -> FavoritesType { FavoritesType.allCases.filter({ $0.index == index }).first ?? .all diff --git a/AppPackage/Sources/AppModels/Support/Misc.swift b/AppPackage/Sources/AppModels/Support/Misc.swift index 9ad0e8b4a..060b2bc3b 100644 --- a/AppPackage/Sources/AppModels/Support/Misc.swift +++ b/AppPackage/Sources/AppModels/Support/Misc.swift @@ -146,7 +146,11 @@ public struct QuickSearchWord: Codable, Equatable, Identifiable, Sendable { } public static var empty: Self { .init(name: "", content: "") } - // Version anchor for future breaking migrations; additive changes ride the tolerant decoder. + /// Highest `schemaVersion` this build can decode; a blob carrying a newer value (a downgrade) + /// is rejected rather than half-read. Bump and branch here when a breaking change lands. + public static let currentSchemaVersion = 1 + // Version anchor for a future breaking migration; the strict decoder below rejects an + // out-of-range value. public var schemaVersion = 1 public var id: UUID = .init() public var name: String @@ -159,13 +163,24 @@ public struct QuickSearchWord: Codable, Equatable, Identifiable, Sendable { // MARK: Manually decode extension QuickSearchWord { - // Tolerant decoding keeps an existing persisted list valid across future additive changes. - public init(from decoder: Decoder) { - let container = try? decoder.container(keyedBy: CodingKeys.self) - schemaVersion = container.decode(.schemaVersion, default: 1) - id = container.decode(.id, default: .init()) - name = container.decode(.name, default: "") - content = container.decode(.content, default: "") + /// Strict, throwing decode. `id` is decoded, never fabricated — a tolerant `UUID()` fallback + /// would hand a corrupt entry a fresh identity on every decode. A blob missing `id`/`name`/ + /// `content`, or carrying an unknown `schemaVersion`, throws, failing the whole + /// `[QuickSearchWord]` decode so Sharing resets the key to `[]` instead of surfacing an entry + /// with a random, unstable identity. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(Int.self, forKey: .schemaVersion) + guard (1...Self.currentSchemaVersion).contains(version) else { + throw DecodingError.dataCorrupted(.init( + codingPath: container.codingPath, + debugDescription: "Unsupported QuickSearchWord schemaVersion \(version)" + )) + } + schemaVersion = version + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + content = try container.decode(String.self, forKey: .content) } } diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift index c586fbf41..611ccff2e 100644 --- a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift +++ b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift @@ -19,19 +19,10 @@ public struct TagTranslatorInfo: Codable, Equatable, Sendable { self.updatedDate = updatedDate self.hasCustomTranslations = hasCustomTranslations } + // Version anchor for a future breaking migration. All current fields decode strictly (synthesized + // Codable); a field added later must stay optional so an old blob still decodes. public var schemaVersion: Int public var language: TranslatableLanguage? public var updatedDate: Date public var hasCustomTranslations: Bool } - -// MARK: Manually decode -extension TagTranslatorInfo { - public init(from decoder: Decoder) { - let container = try? decoder.container(keyedBy: CodingKeys.self) - schemaVersion = container.decode(.schemaVersion, default: 1) - language = try? container?.decodeIfPresent(TranslatableLanguage.self, forKey: .language) - updatedDate = container.decode(.updatedDate, default: .distantPast) - hasCustomTranslations = container.decode(.hasCustomTranslations, default: false) - } -} diff --git a/AppPackage/Tests/AppModelsTests/StrictDecodingTests.swift b/AppPackage/Tests/AppModelsTests/StrictDecodingTests.swift new file mode 100644 index 000000000..3fcdaeb04 --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/StrictDecodingTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing +import AppModels + +// #2: persisted models decode *strictly* — the blanket-tolerant decoder is gone. A corrupt or +// shape-incompatible blob fails to decode so Sharing resets the key to its default (a clean, coherent +// value) instead of surfacing a partially-filled Franken-value. Identity-bearing array elements +// (`GalleryHistoryEntry`, `QuickSearchWord`) validate their identity and reject an unknown +// `schemaVersion`; whole-struct models (`Filter`, `Setting`, …) rely on synthesized strict Codable. +@Suite +struct StrictDecodingTests { + private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) + } + + @Test + func wellFormedValuesRoundTrip() throws { + var original = Filter(minRating: 5, pageLowerBound: "10") + original.doujinshi = true + let decoded = try JSONDecoder().decode(Filter.self, from: JSONEncoder().encode(original)) + #expect(decoded == original) + } + + @Test + func aWholeStructModelRejectsAPartialBlob() { + // Missing required keys → synthesized decode throws → Sharing falls back to the key default. + #expect(throws: (any Error).self) { + try decode(Filter.self, "{}") + } + } + + @Test + func aWholeStructModelRejectsAWrongTypedValue() { + // Previously "not-an-int" silently defaulted `minRating` to 2; now the whole decode fails. + #expect(throws: (any Error).self) { + try decode(Filter.self, #"{"minRating": "not-an-int"}"#) + } + } + + @Test + func galleryHistoryEntryRoundTrips() throws { + let entry = GalleryHistoryEntry( + gid: "123", token: "abc", lastOpenDate: Date(timeIntervalSince1970: 1), readingProgress: 5 + ) + let decoded = try JSONDecoder().decode( + GalleryHistoryEntry.self, from: JSONEncoder().encode(entry) + ) + #expect(decoded == entry) + } + + @Test + func galleryHistoryEntryRejectsABlankIdentity() { + // A blank gid/token is unresolvable; strict decode throws rather than yielding a `""`-id entry + // that would collide with every other corrupt entry in an `Identifiable` list. + let json = #"{"schemaVersion": 1, "gid": "", "token": "", "lastOpenDate": 0, "readingProgress": 0}"# + #expect(throws: (any Error).self) { + try decode(GalleryHistoryEntry.self, json) + } + } + + @Test + func galleryHistoryEntryRejectsAMissingField() { + // `lastOpenDate` absent → strict decode throws (previously it silently defaulted to distantPast). + let json = #"{"schemaVersion": 1, "gid": "1", "token": "a", "readingProgress": 0}"# + #expect(throws: (any Error).self) { + try decode(GalleryHistoryEntry.self, json) + } + } + + @Test + func galleryHistoryEntryRejectsAnUnknownSchemaVersion() { + let json = #"{"schemaVersion": 2, "gid": "1", "token": "a", "lastOpenDate": 0, "readingProgress": 0}"# + #expect(throws: (any Error).self) { + try decode(GalleryHistoryEntry.self, json) + } + } + + @Test + func aSingleBadElementFailsTheWholeHistoryArray() { + // One malformed element fails the array decode; Sharing then resets `galleryHistory` to []. + let json = """ + [ + {"schemaVersion": 1, "gid": "1", "token": "a", "lastOpenDate": 0, "readingProgress": 0}, + {"schemaVersion": 1, "gid": "", "token": "", "lastOpenDate": 0, "readingProgress": 0} + ] + """ + #expect(throws: (any Error).self) { + try decode([GalleryHistoryEntry].self, json) + } + } + + @Test + func quickSearchWordPreservesItsPersistedIdentity() throws { + let id = UUID() + let json = #"{"schemaVersion": 1, "id": "\#(id.uuidString)", "name": "n", "content": "c"}"# + let word = try decode(QuickSearchWord.self, json) + #expect(word.id == id) // decoded, never a fresh random UUID + #expect(word.name == "n") + } + + @Test + func quickSearchWordRejectsAMissingIdentity() { + // Previously a missing `id` fabricated a fresh UUID() on every decode; now it throws. + #expect(throws: (any Error).self) { + try decode(QuickSearchWord.self, #"{"schemaVersion": 1, "name": "n", "content": "c"}"#) + } + } +} diff --git a/AppPackage/Tests/AppModelsTests/TolerantDecodingTests.swift b/AppPackage/Tests/AppModelsTests/TolerantDecodingTests.swift deleted file mode 100644 index 6b0ad6ec7..000000000 --- a/AppPackage/Tests/AppModelsTests/TolerantDecodingTests.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation -import Testing -import AppModels - -// REV-24: every persisted model decodes tolerantly through one shared helper -// (`Optional.decode(_:default:)`). These pin the guarantees that helper must -// keep — a missing key, a wrong-typed value, and even a whole non-object payload each fall back to the -// field's own default instead of throwing, while well-formed values still round-trip intact. -@Suite -struct TolerantDecodingTests { - private func decode(_ type: T.Type, _ json: String) throws -> T { - try JSONDecoder().decode(T.self, from: Data(json.utf8)) - } - - @Test - func missingKeysFallBackToPerFieldDefaults() throws { - let filter = try decode(Filter.self, "{}") - #expect(filter.schemaVersion == 1) - #expect(filter.minRating == 2) - #expect(filter.doujinshi == false) - // The decoder default here intentionally differs from the constructed default (`true`). - #expect(filter.galleryName == false) - } - - @Test - func aWrongTypedValueFallsBackWithoutFailingItsSiblings() throws { - let filter = try decode(Filter.self, #"{"minRating": "not-an-int", "doujinshi": true}"#) - #expect(filter.minRating == 2) // wrong type → default - #expect(filter.doujinshi == true) // a valid sibling still decodes - } - - @Test - func aNonObjectPayloadDecodesToDefaultsRatherThanThrowing() throws { - // The container itself is absent (top-level array, not a dictionary); every field must still - // fall back to its default, identical to the empty-object case above. - let filter = try decode(Filter.self, "[]") - #expect(filter.schemaVersion == 1) - #expect(filter.minRating == 2) - #expect(filter.galleryName == false) - } - - @Test - func wellFormedValuesRoundTrip() throws { - var original = Filter(minRating: 5, pageLowerBound: "10") - original.doujinshi = true - let decoded = try JSONDecoder().decode(Filter.self, from: JSONEncoder().encode(original)) - #expect(decoded == original) - } - - @Test - func requiredStringFieldsDecodePartially() throws { - let entry = try decode(GalleryHistoryEntry.self, #"{"gid": "123", "token": "abc", "readingProgress": 5}"#) - #expect(entry.gid == "123") - #expect(entry.token == "abc") - #expect(entry.readingProgress == 5) - #expect(entry.lastOpenDate == .distantPast) // missing → default - } -} diff --git a/AppPackage/Tests/ParserFeatureTests/Other/SettingDownloadTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/SettingDownloadTests.swift index dd057d6c5..9b0d46455 100644 --- a/AppPackage/Tests/ParserFeatureTests/Other/SettingDownloadTests.swift +++ b/AppPackage/Tests/ParserFeatureTests/Other/SettingDownloadTests.swift @@ -7,19 +7,20 @@ import URLClient struct SettingDownloadTests { @Test - func testLegacySettingDecodesDownloadDefaults() throws { - let data = Data(""" - { - "galleryHost": "E-Hentai", - "showsNewDawnGreeting": true - } - """.utf8) - - let setting = try JSONDecoder().decode(Setting.self, from: data) - - #expect(setting.downloadThreadLimit == 1) - #expect(setting.downloadAllowCellular) - #expect(setting.downloadAutoRetryFailedPages) + func testSettingRoundTripsDownloadFields() throws { + // Strict decode (#2): a persisted Setting is written whole, so its download fields survive an + // encode/decode round-trip intact. (A partial blob no longer decodes — it resets to defaults.) + var setting = Setting() + setting.downloadThreadLimit = 3 + setting.downloadAllowCellular = false + setting.downloadAutoRetryFailedPages = false + + let data = try JSONEncoder().encode(setting) + let decoded = try JSONDecoder().decode(Setting.self, from: data) + + #expect(decoded.downloadThreadLimit == 3) + #expect(!decoded.downloadAllowCellular) + #expect(!decoded.downloadAutoRetryFailedPages) } @Test From f52b119307e16f38a6c36eaa0808c60cecdf5e27 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 00:32:24 +0800 Subject: [PATCH 562/614] Replace Filter singletons with SharedReader --- .../AppModels/Persistence/AppSharedKeys.swift | 19 ------------------- .../DetailSearch/DetailSearchReducer.swift | 5 +++-- .../Frontpage/FrontpageReducer.swift | 5 +++-- .../HomeFeature/HomeReducer+Body.swift | 4 ++-- .../Sources/HomeFeature/HomeReducer.swift | 2 ++ .../HomeFeature/Popular/PopularReducer.swift | 3 ++- .../HomeFeature/Watched/WatchedReducer.swift | 5 +++-- .../Sources/SearchFeature/SearchReducer.swift | 5 +++-- 8 files changed, 18 insertions(+), 30 deletions(-) diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift index e853fe1f7..4ebb613f7 100644 --- a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -79,25 +79,6 @@ extension SharedKey where Self == AppStorageKey.Default { } } -// A reducer that fires a network request needs a *value* copy of the currently-persisted filter to -// capture into its `.run` closure — never the live `@Shared` reference, which would read a later, -// possibly mid-edit value by the time the effect actually runs. These accessors centralize that -// read-and-copy so no call site can accidentally capture the reference. -extension Filter { - public static var currentSearch: Filter { - @Shared(.searchFilter) var filter - return filter - } - public static var currentGlobal: Filter { - @Shared(.globalFilter) var filter - return filter - } - public static var currentWatched: Filter { - @Shared(.watchedFilter) var filter - return filter - } -} - // MARK: Search history & presets extension SharedKey where Self == AppStorageKey<[String]>.Default { diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index 9239c0b34..8347101e2 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -25,6 +25,7 @@ public struct DetailSearchReducer: Sendable { @ObservableState public struct State: Equatable { + @SharedReader(.searchFilter) public var searchFilter: Filter @Presents public var destination: Destination.State? public var keyword = "" public var lastKeyword = "" @@ -101,7 +102,7 @@ public struct DetailSearchReducer: Sendable { } state.loadingState = .loading state.pageNumber.resetPages() - let filter = Filter.currentSearch + let filter = state.searchFilter return .run { [lastKeyword = state.lastKeyword] send in let response = await SearchGalleriesRequest(keyword: lastKeyword, filter: filter).response() await send(.fetchGalleriesDone(response.map { ($0.pageNumber, $0.galleries) })) @@ -132,7 +133,7 @@ public struct DetailSearchReducer: Sendable { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - let filter = Filter.currentSearch + let filter = state.searchFilter return .run { [lastKeyword = state.lastKeyword] send in let response = await MoreSearchGalleriesRequest( keyword: lastKeyword, filter: filter, lastID: lastID diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index 677797a89..715ad117f 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -26,6 +26,7 @@ public struct FrontpageReducer: Sendable { @ObservableState public struct State: Equatable { + @SharedReader(.globalFilter) public var globalFilter: Filter @Presents public var destination: Destination.State? public var keyword = "" @@ -91,7 +92,7 @@ public struct FrontpageReducer: Sendable { guard state.loadingState != .loading else { return .none } state.loadingState = .loading state.pageNumber.resetPages() - let filter = Filter.currentGlobal + let filter = state.globalFilter return .run { send in let response = await FrontpageGalleriesRequest(filter: filter).response() await send(.fetchGalleriesDone(response)) @@ -124,7 +125,7 @@ public struct FrontpageReducer: Sendable { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - let filter = Filter.currentGlobal + let filter = state.globalFilter return .run { send in let response = await MoreFrontpageGalleriesRequest(filter: filter, lastID: lastID).response() await send(.fetchMoreGalleriesDone(response)) diff --git a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift index 66f9e48ba..e01f5769f 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift @@ -99,7 +99,7 @@ extension HomeReducer { guard state.popularLoadingState != .loading else { return .none } state.popularLoadingState = .loading state.rawCardColors = [String: [Color]]() - let filter = Filter.currentGlobal + let filter = state.globalFilter return .run { send in let response = await PopularGalleriesRequest(filter: filter).response() await send(.fetchPopularGalleriesDone(response)) @@ -123,7 +123,7 @@ extension HomeReducer { case .fetchFrontpageGalleries: guard state.frontpageLoadingState != .loading else { return .none } state.frontpageLoadingState = .loading - let filter = Filter.currentGlobal + let filter = state.globalFilter return .run { send in let response = await FrontpageGalleriesRequest(filter: filter).response() await send(.fetchFrontpageGalleriesDone(response.map { ($0.pageNumber, $0.galleries) })) diff --git a/AppPackage/Sources/HomeFeature/HomeReducer.swift b/AppPackage/Sources/HomeFeature/HomeReducer.swift index d2f1d5858..6d5dcc4db 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer.swift @@ -1,6 +1,7 @@ import SwiftUI import AppModels import Kingfisher +import Sharing import ComposableArchitecture import AppTools import LibraryClient @@ -14,6 +15,7 @@ public struct HomeReducer: Sendable { @ObservableState public struct State: Equatable { + @SharedReader(.globalFilter) public var globalFilter: AppModels.Filter public var path = StackState() public var cardPageIndex = 1 public var currentCardID = "" diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index 4eeff4497..e20bd403d 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -23,6 +23,7 @@ public struct PopularReducer: Sendable { @ObservableState public struct State: Equatable { + @SharedReader(.globalFilter) public var globalFilter: Filter @Presents public var destination: Destination.State? public var keyword = "" @@ -71,7 +72,7 @@ public struct PopularReducer: Sendable { case .fetchGalleries: guard state.loadingState != .loading else { return .none } state.loadingState = .loading - let filter = Filter.currentGlobal + let filter = state.globalFilter return .run { send in let response = await PopularGalleriesRequest(filter: filter).response() await send(.fetchGalleriesDone(response)) diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index 0d761634c..a03a0848f 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -28,6 +28,7 @@ public struct WatchedReducer: Sendable { @ObservableState public struct State: Equatable { + @SharedReader(.watchedFilter) public var watchedFilter: Filter @Presents public var destination: Destination.State? public var keyword = "" @@ -109,7 +110,7 @@ public struct WatchedReducer: Sendable { } state.loadingState = .loading state.pageNumber.resetPages() - let filter = Filter.currentWatched + let filter = state.watchedFilter return .run { [keyword = state.keyword] send in let response = await WatchedGalleriesRequest(filter: filter, keyword: keyword).response() await send(.fetchGalleriesDone(response)) @@ -142,7 +143,7 @@ public struct WatchedReducer: Sendable { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - let filter = Filter.currentWatched + let filter = state.watchedFilter return .run { [keyword = state.keyword] send in let response = await MoreWatchedGalleriesRequest( filter: filter, lastID: lastID, keyword: keyword diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index 4299cab5d..c8d8e92e9 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -30,6 +30,7 @@ public struct SearchReducer: Sendable { @ObservableState public struct State: Equatable { + @SharedReader(.searchFilter) public var searchFilter: Filter @Presents public var destination: Destination.State? public var keyword = "" public var lastKeyword = "" @@ -122,7 +123,7 @@ public struct SearchReducer: Sendable { } state.loadingState = .loading state.pageNumber.resetPages() - let filter = Filter.currentSearch + let filter = state.searchFilter return .merge( historyEffect, .run { [lastKeyword = state.lastKeyword] send in @@ -158,7 +159,7 @@ public struct SearchReducer: Sendable { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - let filter = Filter.currentSearch + let filter = state.searchFilter return .run { [lastKeyword = state.lastKeyword] send in let response = await MoreSearchGalleriesRequest( keyword: lastKeyword, filter: filter, lastID: lastID From 0d73966f40501d78c21cf4d5356f83f9f5890f1b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 00:57:10 +0800 Subject: [PATCH 563/614] Enforce Detail always holds a real Gallery --- .../AppFeature/DataFlow/AppRouteReducer.swift | 2 +- .../Sources/DetailFeature/DetailReducer.swift | 19 ++++++------------- .../Sources/DetailFeature/DetailView.swift | 2 +- .../DownloadsFeature/DownloadsReducer.swift | 12 +++++------- .../GalleryNavigationTests.swift | 13 ++++++++++--- .../DetailReducerDownloadTests.swift | 4 +--- .../DetailReducerMetadataTests.swift | 6 ++---- .../DetailReducerMetadataUpdateTests.swift | 10 +++++----- .../DetailReducerObserveTests.swift | 10 +++------- .../DetailReducerPauseAndGuardTests.swift | 11 +++-------- .../ReadingReducerDownloadTests.swift | 3 +-- 11 files changed, 38 insertions(+), 54 deletions(-) diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift index cf9801e38..be044ff28 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -117,7 +117,7 @@ struct AppRouteReducer { // from the tapped gallery. state.path.removeAll() if let download { - state.detail = .init(gid: gallery.id, seededFrom: download) + state.detail = .init(seededFrom: download) } else { state.detail = .init(gallery: gallery) } diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index 92bd5bf3a..99971a790 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -69,7 +69,7 @@ public struct DetailReducer: Sendable { public var apiKey = "" public var gid = "" public var loadingState: LoadingState = .idle - public var gallery: Gallery = .empty + public var gallery: Gallery public var galleryDetail: GalleryDetail? public var galleryVersionMetadata: DownloadVersionMetadata? public var galleryTags = [GalleryTag]() @@ -99,14 +99,10 @@ public struct DetailReducer: Sendable { // A deep-link intent to act on once this detail finishes loading (see GalleryDeepLink). public var pendingDeepLink: GalleryDeepLink? - public init(gid: String = "", pendingDeepLink: GalleryDeepLink? = nil) { - self.gid = gid - self.pendingDeepLink = pendingDeepLink - } - // Seeded from the pushing context (a tapped list item or a freshly-fetched gallery) so the // detail header renders immediately and `fetchGalleryDetail` has a `galleryURL`. Gallery data - // lives only here and dies when the screen pops. + // lives only here and dies when the screen pops. This is the only initializer, so a Detail + // always holds a real `Gallery` — there is no empty-gallery construction path. public init(gallery: Gallery, pendingDeepLink: GalleryDeepLink? = nil) { self.gid = gallery.id self.gallery = gallery @@ -233,12 +229,9 @@ extension DetailReducer.State { // Pre-populated from a local download so a downloaded gallery renders instantly and offline; // the live download observation keeps the state in sync afterwards. Shared by the Downloads // tab's inline push and the app-level modal presentation (iPad / deep link). - public init(gid: String, seededFrom download: DownloadedGallery?) { - self.init(gid: gid) - if let download { - gallery = download.gallery - _ = DetailReducer().applyDownload(download, state: &self) - } + public init(seededFrom download: DownloadedGallery) { + self.init(gallery: download.gallery) + _ = DetailReducer().applyDownload(download, state: &self) } } diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index b558267ed..2e684448a 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -319,7 +319,7 @@ struct DetailView_Previews: PreviewProvider { static var previews: some View { NavigationStack { DetailView( - store: .init(initialState: .init(), reducer: DetailReducer.init), + store: .init(initialState: .init(gallery: .preview), reducer: DetailReducer.init), gid: .init(), user: .init(), setting: .constant(.init()), diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 3d0c8dcf8..39e20276a 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -67,7 +67,7 @@ public struct DownloadsReducer: Sendable { case binding(BindingAction) case delegate(Delegate) case galleryTapped(String) - case pushGalleryDetail(String) + case pushGalleryDetail(DownloadedGallery) case path(StackActionOf) case destination(PresentationAction) case inspectorButtonTapped(String) @@ -118,15 +118,13 @@ public struct DownloadsReducer: Sendable { return GalleryNavigation.routeGalleryDetail( isPad: deviceClient.isPad, present: { .delegate(.presentGalleryDetail(download.gallery, download)) }, - push: { .pushGalleryDetail(gid) } + push: { .pushGalleryDetail(download) } ) - case .pushGalleryDetail(let gid): + case .pushGalleryDetail(let download): // Seed the detail with the locally downloaded gallery/badge so it renders offline. - state.path.appendGuardingDuplicate(.detail(.init( - gid: gid, - seededFrom: state.downloads.first(where: { $0.gid == gid }) - ))) + // The download is carried through the action, so there is no re-lookup by gid. + state.path.appendGuardingDuplicate(.detail(.init(seededFrom: download))) return .none case .delegate: diff --git a/AppPackage/Tests/DetailFeatureTests/GalleryNavigationTests.swift b/AppPackage/Tests/DetailFeatureTests/GalleryNavigationTests.swift index 2d1c5ef32..4cd847cd6 100644 --- a/AppPackage/Tests/DetailFeatureTests/GalleryNavigationTests.swift +++ b/AppPackage/Tests/DetailFeatureTests/GalleryNavigationTests.swift @@ -5,17 +5,24 @@ import ComposableArchitecture @Suite struct GalleryNavigationTests { + // A minimal gallery whose `id` ("1") drives the detail route key that dedup compares. + private let galleryOne = Gallery( + gid: "1", token: "", title: "", rating: 0, tags: [], + category: .doujinshi, uploader: "", pageCount: 1, + postedDate: .distantPast, coverURL: nil, galleryURL: nil + ) + // appendGuardingDuplicate skips only an adjacent identical element, so a rapid double-activation // pushes one screen while a legitimate same-gid re-push through a deeper screen still appends. @Test func appendGuardingDuplicateSkipsOnlyAdjacentDuplicates() { var path = StackState() - path.appendGuardingDuplicate(.detail(.init(gid: "1"))) + path.appendGuardingDuplicate(.detail(.init(gallery: galleryOne))) #expect(path.count == 1) // A second identical push (double-tap) is skipped. - path.appendGuardingDuplicate(.detail(.init(gid: "1"))) + path.appendGuardingDuplicate(.detail(.init(gallery: galleryOne))) #expect(path.count == 1) // A different screen is appended. @@ -23,7 +30,7 @@ struct GalleryNavigationTests { #expect(path.count == 2) // The same detail after a non-adjacent screen is appended (only the top is compared). - path.appendGuardingDuplicate(.detail(.init(gid: "1"))) + path.appendGuardingDuplicate(.detail(.init(gallery: galleryOne))) #expect(path.count == 3) } } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift index 881485f83..925315c91 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift @@ -179,9 +179,7 @@ private extension DetailReducerDownloadTests { configure: (inout DetailReducer.State) -> Void = { _ in }, enqueue: @escaping @Sendable (DownloadRequestPayload) async throws -> Void ) -> TestStoreOf { - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery + var initialState = DetailReducer.State(gallery: gallery) initialState.galleryDetail = detail configure(&initialState) return TestStore( diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift index 37cd5c190..1a7e43258 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift @@ -123,9 +123,8 @@ private extension DetailReducerMetadataTests { gid: String, gallery: Gallery, downloadValue: DownloadedGallery?, updateCheckCount: UncheckedBox ) -> TestStoreOf { - var initialState = DetailReducer.State() + var initialState = DetailReducer.State(gallery: gallery) initialState.gid = gid - initialState.gallery = gallery return TestStore( initialState: initialState, reducer: DetailReducer.init, @@ -163,9 +162,8 @@ private extension DetailReducerMetadataTests { let updatedDownload = sampleDownload( gid: gallery.gid, title: gallery.title, status: .completed ) - var initialState = DetailReducer.State() + var initialState = DetailReducer.State(gallery: gallery) initialState.gid = gid - initialState.gallery = gallery return TestStore( initialState: initialState, reducer: DetailReducer.init, diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift index 3ac8cf559..8373db13d 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift @@ -83,8 +83,7 @@ struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { @Test func testDetailReducerDeleteDownloadResetsMetadataState() async { let download = sampleDownload(gid: "7733", title: "Reset Context", status: .completed) - var initialState = DetailReducer.State() - initialState.gallery = download.gallery + var initialState = DetailReducer.State(gallery: download.gallery) initialState.galleryVersionMetadata = sampleVersionMetadata( gid: download.gid, token: download.token ) @@ -124,9 +123,8 @@ private extension DetailReducerMetadataUpdateTests { updatedDownload: DownloadedGallery, updateCheckCount: UncheckedBox ) -> TestStoreOf { - var initialState = DetailReducer.State() + var initialState = DetailReducer.State(gallery: gallery) initialState.gid = gid - initialState.gallery = gallery initialState.galleryDetail = detail return TestStore( initialState: initialState, @@ -164,7 +162,9 @@ private extension DetailReducerMetadataUpdateTests { AsyncStream { continuation in continuation.finish() } } client.fetchDownloads = { [download] } - client.fetchDownload = { gid in gid == download.gid ? download : nil } + // After the delete the download no longer exists, so the follow-up badge re-fetch finds + // nothing and `shouldCheckForRemoteUpdates` stays reset. + client.fetchDownload = { _ in nil } client.refreshDownloads = {} client.enqueue = { _ in } client.togglePause = { _ in } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift index c8ad819b1..1006c1b41 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift @@ -78,8 +78,7 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { func testDetailReducerOpenReadingUsesLocalManifestWhenAvailable() async throws { let download = sampleDownload(gid: "888", title: "Offline Archive", status: .completed, pageCount: 2) let manifest = try sampleManifest(gid: download.gid, title: download.title) - var initialState = DetailReducer.State() - initialState.gallery = download.gallery + var initialState = DetailReducer.State(gallery: download.gallery) initialState.galleryDetail = sampleGalleryDetail(gid: download.gid, title: download.title) let store = TestStore( @@ -104,9 +103,7 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { func testDetailReducerOpenReadingFallsBackToRemoteWhenManifestUnavailable() async { let gallery = sampleGallery() let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery + var initialState = DetailReducer.State(gallery: gallery) initialState.galleryDetail = detail let store = TestStore( @@ -135,8 +132,7 @@ private extension DetailReducerObserveTests { gallery: Gallery, detail: GalleryDetail, stream: AsyncStream<[DownloadedGallery]> ) -> TestStoreOf { - var initialState = DetailReducer.State() - initialState.gallery = gallery + var initialState = DetailReducer.State(gallery: gallery) initialState.galleryDetail = detail return TestStore( initialState: initialState, diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift index a86e26305..46e27ef73 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift @@ -20,8 +20,7 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { let completedDownload = sampleDownload( gid: gallery.gid, title: gallery.title, status: .completed ) - var initialState = DetailReducer.State() - initialState.gallery = gallery + var initialState = DetailReducer.State(gallery: gallery) initialState.galleryDetail = detail let store = TestStore( @@ -54,9 +53,7 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) let enqueueCount = UncheckedBox(0) - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery + var initialState = DetailReducer.State(gallery: gallery) initialState.galleryDetail = detail initialState.isPreparingDownload = true @@ -103,9 +100,7 @@ struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { ) let togglePauseCount = UncheckedBox(0) - var initialState = DetailReducer.State() - initialState.gid = gallery.gid - initialState.gallery = gallery + var initialState = DetailReducer.State(gallery: gallery) initialState.galleryDetail = detail initialState.downloadBadge = DownloadBadge( status: .active, diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift index 87194bf69..1d59f173f 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift @@ -24,8 +24,7 @@ struct ReadingReducerDownloadTests: DownloadFeatureTestCase { gid: "889", title: "Offline Archive", status: .completed, pageCount: 2 ) let detail = sampleGalleryDetail(gid: download.gid, title: download.title) - var initialState = DetailReducer.State() - initialState.gallery = download.gallery + var initialState = DetailReducer.State(gallery: download.gallery) initialState.galleryDetail = detail let metadata = DownloadVersionMetadata( gid: detail.gid, token: download.token, From 071428d9778cd51ed2c260e9e933ea42c42142b8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 01:09:33 +0800 Subject: [PATCH 564/614] Remove vestigial hasRestoredSession gate --- .../Previews/PreviewsReducer.swift | 4 ---- .../DetailFeature/Previews/PreviewsView.swift | 4 +--- .../ReadingFeature/ReadingReducer+Body.swift | 1 - .../ReadingReducer+Session.swift | 20 ------------------ .../ReadingFeature/ReadingReducer.swift | 18 ++++++++++------ .../Sources/ReadingFeature/ReadingView.swift | 6 +----- .../ReadingViewComponents.swift | 12 ++++------- .../DownloadObserverReadingTests.swift | 21 ++++++++----------- .../ReadingReducerFlushTests.swift | 12 ++++++++--- .../ReadingReducerLocalTests.swift | 2 +- 10 files changed, 37 insertions(+), 63 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index a202a1e2e..84abd9b24 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -33,9 +33,6 @@ public struct PreviewsReducer: Sendable { // remote sessions — Previews itself never fetches a gallery detail to re-derive them. public var language: Language? public var loadingState: LoadingState = .idle - // True once the session has been restored on first `onAppear` (synchronous — not a load). The - // grid gates preview fetches and its rebuild on this so they run against the restored session. - public var hasRestoredSession = false public var previewURLs = [Int: URL]() public var localPreviewURLs = [Int: URL]() @@ -107,7 +104,6 @@ public struct PreviewsReducer: Sendable { case .onAppear(let gid): // Gallery is seeded from the pushing context; preview URLs are fetched on demand. - state.hasRestoredSession = true return .merge( .send(.observeDownloads(gid)), .send(.loadLocalPreviewURLs(gid)) diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift index 3eca96c53..31e2f0d81 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift @@ -52,8 +52,7 @@ struct PreviewsView: View { .foregroundColor(.secondary) } .onAppear { - if store.hasRestoredSession - && displayPreviewURLs[index] == nil && (index - 1) % 10 == 0 { + if displayPreviewURLs[index] == nil && (index - 1) % 10 == 0 { store.send(.fetchPreviewURLs(index)) } } @@ -61,7 +60,6 @@ struct PreviewsView: View { } .padding(.horizontal) .padding(.bottom) - .id(store.hasRestoredSession) } .fullScreenCover( item: $store.scope(state: \.destination?.reading, action: \.destination.reading) diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index ea6e937a2..906d20b56 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -88,7 +88,6 @@ extension ReadingReducer { case .onAppear(let gid, let enablesLandscape): var effects: [Effect] = [ - .send(.restoreSession(gid)), .send(.observeDownloads(gid)), .send(.loadLocalPageURLs(gid)) ] diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift index 8a37d664d..c0855f858 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift @@ -25,9 +25,6 @@ extension ReadingReducer { flushReadingProgress(state) return .none - case .restoreSession(let gid): - return reduceRestoreSession(state: &state, gid: gid) - case .observeDownloads(let gid): return reduceObserveDownloads(gid: gid) @@ -49,22 +46,6 @@ extension ReadingReducer { } } - func reduceRestoreSession(state: inout State, gid: String) -> Effect { - if case .local(let download, let manifest) = state.contentSource { - applyLocalSource(state: &state, download: download, manifest: manifest) - } - // Remote galleries are seeded from the pushing context; URL maps are rebuilt per session - // (fetched on demand). The resume position comes from the persisted browsing history. - @Shared(.galleryHistory) var galleryHistory - state.readingProgress = galleryHistory.readingProgress(gid: gid) - // Seed the pending page with the restored resume position so a flush that fires before the - // first page turn (dismiss or background right after opening) rewrites that position instead - // of clobbering it with a stale `.zero`. - state.pendingReadingProgress = state.readingProgress - state.hasRestoredSession = true - return .none - } - /// Persists the latest pending page into the shared browsing history. Called from the debounced /// `.flushReadingProgress` and — crucially — synchronously on reader dismissal (`.onPerformDismiss`), /// which runs in this child reducer before the parent nils the presentation and cancels the pending @@ -160,6 +141,5 @@ extension ReadingReducer { state.mpvSkipServerIdentifiers = .init() state.imageURLLoadingStates = .init() state.previewLoadingStates = .init() - state.hasRestoredSession = true } } diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 619bf33e0..77e2e3bcf 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -1,6 +1,7 @@ import AppTools import SwiftUI import AppModels +import Sharing import ComposableArchitecture import URLClient import HapticsClient @@ -57,11 +58,6 @@ public struct ReadingReducer: Sendable { public var webImageLoadSuccessIndices = Set() public var imageURLLoadingStates = [Int: LoadingState]() public var previewLoadingStates = [Int: LoadingState]() - // True once the reading session has been restored on the first `onAppear`: the resume page - // from history, plus the local source for downloads. Image fetches, progress syncs and the - // pager rebuild gate on this so they run against a restored session, not the initial - // placeholder. (Not a loading state — the restore is synchronous; there is no async fetch.) - public var hasRestoredSession = false public var previewConfig: PreviewConfig = .normal(rows: 4) public var previewURLs = [Int: URL]() @@ -97,6 +93,17 @@ public struct ReadingReducer: Sendable { self.contentSource = contentSource self.previewConfig = previewConfig self.language = language + // Offline sources overwrite the gallery/language/page source from the manifest, so resolve + // them before keying the resume lookup on the final gallery id. + if case .local(let download, let manifest) = contentSource { + ReadingReducer().applyLocalSource(state: &self, download: download, manifest: manifest) + } + // Seed the resume page synchronously from the persisted browsing history so the pager is + // built at the right page from the start (no post-render jump). `pendingReadingProgress` + // mirrors it so a flush before the first page turn rewrites that position, not a stale zero. + @Shared(.galleryHistory) var galleryHistory + readingProgress = galleryHistory.readingProgress(gid: self.gallery.id) + pendingReadingProgress = readingProgress } var isOffline: Bool { contentSource != .remote } @@ -181,7 +188,6 @@ public struct ReadingReducer: Sendable { case syncReadingProgress(Int) case flushReadingProgress - case restoreSession(String) case observeDownloads(String) case observeDownloadsDone([DownloadedGallery]) case loadLocalPageURLs(String) diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 014eff624..b6ed4a7de 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -161,7 +161,6 @@ public struct ReadingView: View { .gesture(tapGesture, isEnabled: gestureHandler.scale == 1) .gesture(magnificationGesture) .ignoresSafeArea() - .id(store.hasRestoredSession) .id(store.forceRefreshID) ControlPanel( @@ -209,9 +208,7 @@ public struct ReadingView: View { index: newValue, pageCount: store.gallery.pageCount, setting: setting ) pageHandler.sliderValue = .init(newValue) - if store.hasRestoredSession { - store.send(.syncReadingProgress(.init(newValue))) - } + store.send(.syncReadingProgress(.init(newValue))) } .onChange(of: pageHandler.sliderValue) { _, newValue in if !store.showsSliderPreview { @@ -246,7 +243,6 @@ public struct ReadingView: View { index: index, isDualPage: isDualPage, isActive: index == activeStackIndex, - isSessionRestored: store.hasRestoredSession, backgroundColor: backgroundColor, config: imageStackConfig, imageURLs: displayImageURLs, diff --git a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift index dbb7db52a..867532fe1 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift @@ -47,7 +47,6 @@ struct HorizontalImageStack: View { private let index: Int private let isDualPage: Bool private let isActive: Bool - private let isSessionRestored: Bool private let backgroundColor: Color private let config: ImageStackConfig private let imageURLs: [Int: URL] @@ -68,7 +67,7 @@ struct HorizontalImageStack: View { private let shareImageAction: (URL) -> Void init( - index: Int, isDualPage: Bool, isActive: Bool, isSessionRestored: Bool, backgroundColor: Color, + index: Int, isDualPage: Bool, isActive: Bool, backgroundColor: Color, config: ImageStackConfig, imageURLs: [Int: URL], originalImageURLs: [Int: URL], loadingStates: [Int: LoadingState], enablesLiveText: Bool, liveTextGroups: [Int: [LiveTextGroup]], focusedLiveTextGroup: LiveTextGroup?, @@ -82,7 +81,6 @@ struct HorizontalImageStack: View { self.index = index self.isDualPage = isDualPage self.isActive = isActive - self.isSessionRestored = isSessionRestored self.backgroundColor = backgroundColor self.config = config self.imageURLs = imageURLs @@ -132,12 +130,10 @@ struct HorizontalImageStack: View { loadFailedAction: loadFailedAction ) .onAppear { - if isSessionRestored { - if imageURLs[index] == nil { - fetchAction(index) - } - prefetchAction(index) + if imageURLs[index] == nil { + fetchAction(index) } + prefetchAction(index) } .contextMenu { contextMenuItems(index: index) } } diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift index 43a85e5a2..e88642c5c 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift @@ -63,19 +63,16 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { options: .atomic ) - await store.send(.restoreSession(download.gid)) { - $0.gallery = download.gallery - $0.language = manifest.language - $0.localPageURLs = [ - 1: folderURL.appendingPathComponent("123_token_1.jpg"), - 2: folderURL.appendingPathComponent("123_token_2.jpg") - ] - $0.hasRestoredSession = true - } - await store.finish() - - #expect(store.state.hasRestoredSession) + // State.init applies the local source and seeds the resume page at construction, so the reader + // is already offline-seeded — there is no separate restore step. + #expect(store.state.gallery == download.gallery) + #expect(store.state.language == manifest.language) + #expect(store.state.localPageURLs == [ + 1: folderURL.appendingPathComponent("123_token_1.jpg"), + 2: folderURL.appendingPathComponent("123_token_2.jpg") + ]) #expect(store.state.readingProgress == 0) + await store.finish() } @MainActor diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerFlushTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerFlushTests.swift index 5d887e176..67a7547f9 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerFlushTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerFlushTests.swift @@ -75,8 +75,15 @@ struct ReadingReducerFlushTests: DownloadFeatureTestCase { } } + // State.init reads the saved resume position (5) from history, so build it inside the + // `defaults` scope that holds the pre-seeded history. + let initialState = withDependencies { + $0.defaultAppStorage = defaults + } operation: { + ReadingReducer.State(gallery: gallery, contentSource: .remote) + } let store = TestStore( - initialState: ReadingReducer.State(gallery: gallery, contentSource: .remote), + initialState: initialState, reducer: ReadingReducer.init, withDependencies: { $0.defaultAppStorage = defaults @@ -87,8 +94,7 @@ struct ReadingReducerFlushTests: DownloadFeatureTestCase { ) store.exhaustivity = .off - await store.send(.restoreSession(gallery.id)) // seeds pendingReadingProgress from the stored 5 - await store.send(.onPerformDismiss) // flushes 5, must not overwrite with 0 + await store.send(.onPerformDismiss) // flushes the restored 5, must not overwrite with 0 #expect(persistedProgress(defaults, gid: gallery.id) == 5) await store.finish() diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift index 6d24856d9..30ed31073 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift @@ -121,7 +121,7 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { } store.exhaustivity = .off - await store.send(.restoreSession(download.gid)) + // State.init applies the local source at construction, so no separate restore step is needed. #expect(store.state.gallery.id == download.gid) #expect(store.state.localPageURLs[1] == folderURL.appendingPathComponent("123_token_1.jpg")) #expect(store.state.localPageURLs[2] == folderURL.appendingPathComponent("123_token_2.jpg")) From face2889491980fad4ea58999ef8f5e0c318d6c9 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 01:22:28 +0800 Subject: [PATCH 565/614] Route reader flush from AppReducer scene phase --- .../AppFeature/DataFlow/AppReducer.swift | 53 ++++++++- .../DetailFeature/GalleryNavigation.swift | 25 +++++ .../Sources/ReadingFeature/ReadingView.swift | 6 - .../AppReadingFlushTests.swift | 104 ++++++++++++++++++ 4 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 AppPackage/Tests/DownloadsFeatureTests/AppReadingFlushTests.swift diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 3da02ec62..097b7b82b 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -116,7 +116,7 @@ struct AppReducer { // Ask iOS for a later background window to finish the queue; the // beginBackgroundTask assertion only covers the brief grace // period right after backgrounding. - return .merge( + var effects: [Effect] = [ .send(.appLogsPump(.pausePump)), .run { _ in logger.notice("App entered background.") @@ -124,7 +124,12 @@ struct AppReducer { backgroundProcessingClient.schedule() } } - ) + ] + // Backgrounding fires no reader `onDisappear`/dismiss, so flush the active reading + // session's last debounced page here — otherwise a force-quit from the background + // drops it. The reader is located from navigation state (see `readingFlushEffects`). + effects.append(contentsOf: readingFlushEffects(state)) + return .merge(effects) default: return .none @@ -323,6 +328,50 @@ struct AppReducer { } private extension AppReducer { + /// Flush actions for every reading session currently on top of a navigation host, so a background + /// force-quit persists each reader's last debounced page. A reader is presented as a `.reading` + /// destination of `.detail`/`.previews` (elements of the gallery stacks) or, for the Downloads tab + /// and the iPad/deep-link modal, directly as a host destination. Any new host that can present a + /// `.reading` destination must be registered here. + func readingFlushEffects(_ state: State) -> [Effect] { + var effects: [Effect] = [] + + // GalleryPath stacks whose top element presents a reader. + if let (id, action) = state.appRouteState.path.topReadingFlush { + effects.append(.send(.appRoute(.path(.element(id: id, action: action))))) + } + if let (id, action) = state.favoritesState.path.topReadingFlush { + effects.append(.send(.favorites(.path(.element(id: id, action: action))))) + } + if let (id, action) = state.downloadsState.path.topReadingFlush { + effects.append(.send(.downloads(.path(.element(id: id, action: action))))) + } + + // Home / SearchRoot nest the gallery stack under a `.gallery` element. + if let id = state.homeState.path.ids.last, + case .gallery(let gallery)? = state.homeState.path[id: id], + let action = gallery.readingFlushAction { + effects.append(.send(.home(.path(.element(id: id, action: .gallery(action)))))) + } + if let id = state.searchRootState.path.ids.last, + case .gallery(let gallery)? = state.searchRootState.path[id: id], + let action = gallery.readingFlushAction { + effects.append(.send(.searchRoot(.path(.element(id: id, action: .gallery(action)))))) + } + + // The iPad/deep-link modal detail and the Downloads tab present a reader directly. + if state.appRouteState.detail?.destination?.reading != nil { + effects.append(.send(.appRoute(.detail(.presented( + .destination(.presented(.reading(.flushReadingProgress))) + ))))) + } + if state.downloadsState.destination?.reading != nil { + effects.append(.send(.downloads(.destination(.presented(.reading(.flushReadingProgress)))))) + } + + return effects + } + func shouldDelayLaunchAutomationUntilIgneous(state: State) -> Bool { guard !state.didRunLaunchAutomation, cookieClient.shouldFetchIgneous, diff --git a/AppPackage/Sources/DetailFeature/GalleryNavigation.swift b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift index 06f6d9a08..c246cb509 100644 --- a/AppPackage/Sources/DetailFeature/GalleryNavigation.swift +++ b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift @@ -100,4 +100,29 @@ extension StackState where Element == GalleryPath.State { } return nil } + + /// The `(id, action)` that flushes the reading session presented by the top element, if the top + /// element presents one. Used by the app-level scene-phase router so a background force-quit + /// persists the active reader's last debounced page from navigation state. + public var topReadingFlush: (id: StackElementID, action: GalleryPath.Action)? { + guard let id = ids.last, let action = self[id: id]?.readingFlushAction else { return nil } + return (id, action) + } +} + +extension GalleryPath.State { + /// The scoped action that flushes the reading session this gallery screen presents, if any. A + /// reader is a `.reading` destination of `.detail`/`.previews`. A new gallery screen that can host + /// a reader must be handled here (and any new *host* of a reader registered in `AppReducer`'s + /// scene-phase router). + public var readingFlushAction: GalleryPath.Action? { + switch self { + case .detail(let detail) where detail.destination?.reading != nil: + return .detail(.destination(.presented(.reading(.flushReadingProgress)))) + case .previews(let previews) where previews.destination?.reading != nil: + return .previews(.destination(.presented(.reading(.flushReadingProgress)))) + default: + return nil + } + } } diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index b6ed4a7de..c6e48a6d8 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -15,7 +15,6 @@ private let logger = Logger(category: .init(describing: ReadingView.self)) public struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme - @Environment(\.scenePhase) private var scenePhase @Bindable var store: StoreOf let gid: String @@ -108,11 +107,6 @@ public struct ReadingView: View { liveTextHandler.cancelRequests() setAutoPlayPolocy(.off) } - .onChange(of: scenePhase) { _, newPhase in - // Backgrounding doesn't fire `onDisappear` or a dismiss, so flush here too — a force-quit - // from the background otherwise drops the last debounce window of progress. - if newPhase == .background { store.send(.flushReadingProgress) } - } .onAppear { store.send(.onAppear(gid, setting.enablesLandscape)) } } diff --git a/AppPackage/Tests/DownloadsFeatureTests/AppReadingFlushTests.swift b/AppPackage/Tests/DownloadsFeatureTests/AppReadingFlushTests.swift new file mode 100644 index 000000000..ac0b80b8c --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/AppReadingFlushTests.swift @@ -0,0 +1,104 @@ +import Foundation +import ComposableArchitecture +import Testing +import Sharing +import AppModels +import DownloadClient +import CookieClient +import AppLaunchAutomationClient +import ReadingFeature +import DownloadsFeature +@testable import AppFeature + +// #8: ReadingView no longer observes scene phase itself. On `.background`, AppReducer routes +// `.flushReadingProgress` to whichever reading session is on top of a navigation host (located from +// navigation state), so a force-quit from the background still persists the reader's last debounced +// page. When no reader is presented it is a no-op. +@Suite(.serialized) +struct AppReadingFlushTests: DownloadFeatureTestCase { + private let now = Date(timeIntervalSince1970: 1_000) + + private func persistedProgress(_ defaults: UserDefaults, gid: String) -> Int { + withDependencies { + $0.defaultAppStorage = defaults + } operation: { + @Shared(.galleryHistory) var history + return history.readingProgress(gid: gid) + } + } + + @MainActor + @Test + func backgroundFlushesTheActiveReaderProgressIntoHistory() async throws { + let suiteName = "app-reading-flush-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let gallery = sampleGallery() + + var reader = ReadingReducer.State(gallery: gallery, contentSource: .remote) + reader.pendingReadingProgress = 7 + + var initialState = AppReducer.State() + initialState.settingState.hasLoadedInitialSetting = true + initialState.downloadsState.destination = .reading(reader) + + let store = TestStore( + initialState: initialState, + reducer: AppReducer.init, + withDependencies: { + $0.defaultAppStorage = defaults + $0.date = .constant(now) + $0.appLaunchAutomationClient = .none + $0.cookieClient = .noop + $0.downloadClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.onScenePhaseChange(.background)) + await store.finish() + + #expect(persistedProgress(defaults, gid: gallery.id) == 7) + } + + @MainActor + @Test + func backgroundWithoutAReaderLeavesHistoryUntouched() async throws { + let suiteName = "app-reading-flush-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let gallery = sampleGallery() + + // Pre-seed a saved progress of 3; with no reader presented the flush must not touch it. + withDependencies { + $0.defaultAppStorage = defaults + } operation: { + @Shared(.galleryHistory) var history + $history.withLock { + $0.recordGalleryOpen(gid: gallery.id, token: gallery.token, date: now) + $0.updateReadingProgress(gid: gallery.id, token: gallery.token, progress: 3, date: now) + } + } + + var initialState = AppReducer.State() + initialState.settingState.hasLoadedInitialSetting = true + + let store = TestStore( + initialState: initialState, + reducer: AppReducer.init, + withDependencies: { + $0.defaultAppStorage = defaults + $0.date = .constant(now) + $0.appLaunchAutomationClient = .none + $0.cookieClient = .noop + $0.downloadClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.onScenePhaseChange(.background)) + await store.finish() + + #expect(persistedProgress(defaults, gid: gallery.id) == 3) + } +} From 5705b536c455988b2122a3a94f0fc2363d127967 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 01:47:50 +0800 Subject: [PATCH 566/614] Read tagTranslator via `@Shared`, drop threading --- .../AppFeature/View/TabBar/TabBarView.swift | 18 +++++-------- .../AppModels/Persistence/AppSharedKeys.swift | 10 ++++++++ .../DetailFeature/Comments/CommentsView.swift | 7 ++---- .../Sources/DetailFeature/DetailReducer.swift | 1 + .../DetailSearch/DetailSearchReducer.swift | 1 + .../DetailSearch/DetailSearchView.swift | 11 +++----- .../Sources/DetailFeature/DetailView.swift | 9 +++---- .../DetailFeature/GalleryDestination.swift | 16 ++++-------- .../DownloadInspectorReducer.swift | 1 + .../DownloadsView+Subviews.swift | 9 +++---- .../DownloadsFeature/DownloadsView.swift | 17 ++++--------- .../FavoritesFeature/FavoritesReducer.swift | 1 + .../FavoritesFeature/FavoritesView.swift | 14 ++++------- .../Frontpage/FrontpageReducer.swift | 1 + .../HomeFeature/Frontpage/FrontpageView.swift | 9 +++---- .../HomeFeature/History/HistoryReducer.swift | 1 + .../HomeFeature/History/HistoryView.swift | 9 +++---- AppPackage/Sources/HomeFeature/HomeView.swift | 25 ++++++------------- .../HomeFeature/Popular/PopularReducer.swift | 1 + .../HomeFeature/Popular/PopularView.swift | 9 +++---- .../Toplists/ToplistsReducer.swift | 1 + .../HomeFeature/Toplists/ToplistsView.swift | 9 +++---- .../HomeFeature/Watched/WatchedReducer.swift | 1 + .../HomeFeature/Watched/WatchedView.swift | 11 +++----- .../Sources/SearchFeature/SearchReducer.swift | 1 + .../SearchFeature/SearchRootReducer.swift | 1 + .../SearchFeature/SearchRootView.swift | 15 ++++------- .../Sources/SearchFeature/SearchView.swift | 11 +++----- .../SettingFeature/SettingReducer+Body.swift | 6 ++--- .../SettingReducer+Helpers.swift | 2 +- .../SettingFeature/SettingReducer.swift | 2 +- .../SettingReducerNavigationTests.swift | 2 +- 32 files changed, 93 insertions(+), 139 deletions(-) diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index 69731ba5f..c9f223766 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -43,32 +43,28 @@ struct TabBarView: View { store: store.scope(state: \.homeState, action: \.home), user: store.settingState.user, setting: $store.settingState.settingBinding, - blurRadius: store.appLockState.blurRadius, - tagTranslator: store.settingState.tagTranslator + blurRadius: store.appLockState.blurRadius ) case .favorites: FavoritesView( store: store.scope(state: \.favoritesState, action: \.favorites), user: store.settingState.user, setting: $store.settingState.settingBinding, - blurRadius: store.appLockState.blurRadius, - tagTranslator: store.settingState.tagTranslator + blurRadius: store.appLockState.blurRadius ) case .search: SearchRootView( store: store.scope(state: \.searchRootState, action: \.searchRoot), user: store.settingState.user, setting: $store.settingState.settingBinding, - blurRadius: store.appLockState.blurRadius, - tagTranslator: store.settingState.tagTranslator + blurRadius: store.appLockState.blurRadius ) case .downloads: DownloadsView( store: store.scope(state: \.downloadsState, action: \.downloads), user: store.settingState.user, setting: $store.settingState.settingBinding, - blurRadius: store.appLockState.blurRadius, - tagTranslator: store.settingState.tagTranslator + blurRadius: store.appLockState.blurRadius ) case .setting: SettingView( @@ -110,16 +106,14 @@ struct TabBarView: View { gid: detailStore.gid, user: store.settingState.user, setting: $store.settingState.settingBinding, - blurRadius: store.appLockState.blurRadius, - tagTranslator: store.settingState.tagTranslator + blurRadius: store.appLockState.blurRadius ) } destination: { elementStore in galleryDestination( elementStore, user: store.settingState.user, setting: $store.settingState.settingBinding, - blurRadius: store.appLockState.blurRadius, - tagTranslator: store.settingState.tagTranslator + blurRadius: store.appLockState.blurRadius ) } .accentColor(store.settingState.setting.accentColor) diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift index 4ebb613f7..97b2844ef 100644 --- a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -101,6 +101,16 @@ extension SharedKey where Self == AppStorageKey.Default { } } +// The full translation table (multi-megabyte `translations` dictionary) is rebuilt at launch from the +// cache file, so it lives in memory only — never in app storage. `SettingFeature` owns the writes (the +// launch rebuild and language switches); every other feature reads it through `@SharedReader`, so tag +// lookups no longer thread a `TagTranslator` copy down through each view. +extension SharedKey where Self == InMemoryKey.Default { + public static var tagTranslator: Self { + Self[.inMemory("tagTranslator"), default: TagTranslator()] + } +} + // MARK: Browsing history (merged recency + reading progress; capped, pruned at launch) extension SharedKey where Self == AppStorageKey<[GalleryHistoryEntry]>.Default { diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index 96641f1d1..be2376419 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -17,13 +17,12 @@ struct CommentsView: View { private let user: User @Binding private var setting: Setting private let blurRadius: Double - private let tagTranslator: TagTranslator init( store: StoreOf, gid: String, token: String, apiKey: String, galleryURL: URL, comments: [GalleryComment], user: User, setting: Binding, - blurRadius: Double, tagTranslator: TagTranslator + blurRadius: Double ) { self.store = store self.gid = gid @@ -34,7 +33,6 @@ struct CommentsView: View { self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } // MARK: CommentView @@ -258,8 +256,7 @@ struct CommentsView_Previews: PreviewProvider { comments: [], user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index 99971a790..9114b2183 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -58,6 +58,7 @@ public struct DetailReducer: Sendable { @ObservableState public struct State: Equatable { + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator @Presents public var destination: Destination.State? @Presents public var alert: AppAlertState? public var commentContent = "" diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index 8347101e2..748173bad 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -26,6 +26,7 @@ public struct DetailSearchReducer: Sendable { @ObservableState public struct State: Equatable { @SharedReader(.searchFilter) public var searchFilter: Filter + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator @Presents public var destination: Destination.State? public var keyword = "" public var lastKeyword = "" diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift index ec9205e22..a6b938cf1 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift @@ -14,18 +14,16 @@ struct DetailSearchView: View { private let user: User @Binding private var setting: Setting private let blurRadius: Double - private let tagTranslator: TagTranslator init( store: StoreOf, - keyword: String, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + keyword: String, user: User, setting: Binding, blurRadius: Double ) { self.store = store self.keyword = keyword self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } var body: some View { @@ -39,7 +37,7 @@ struct DetailSearchView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) .sheet( @@ -61,7 +59,7 @@ struct DetailSearchView: View { .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, + keyword: $store.keyword, translations: store.tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion ) } @@ -100,8 +98,7 @@ struct DetailSearchView_Previews: PreviewProvider { keyword: .init(), user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 2e684448a..d0cef6785 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -15,18 +15,16 @@ public struct DetailView: View { let user: User @Binding var setting: Setting let blurRadius: Double - let tagTranslator: TagTranslator public init( store: StoreOf, gid: String, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + user: User, setting: Binding, blurRadius: Double ) { self.store = store self.gid = gid self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } public var body: some View { @@ -122,7 +120,7 @@ private extension DetailView { navigateSearchAction: { store.send(.delegate(.pushDetailSearch($0))) }, navigateTagDetailAction: { store.send(.tagDetailButtonTapped($0)) }, translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) .padding(.horizontal) @@ -323,8 +321,7 @@ struct DetailView_Previews: PreviewProvider { gid: .init(), user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/DetailFeature/GalleryDestination.swift b/AppPackage/Sources/DetailFeature/GalleryDestination.swift index 4d1707bcd..4133a323a 100644 --- a/AppPackage/Sources/DetailFeature/GalleryDestination.swift +++ b/AppPackage/Sources/DetailFeature/GalleryDestination.swift @@ -1,6 +1,5 @@ import SwiftUI import AppModels -import TagTranslationFeature import ComposableArchitecture // Builds the view for a single gallery stack element. Shared by every gallery host (and reused by the @@ -11,14 +10,13 @@ public func galleryDestination( _ store: StoreOf, user: User, setting: Binding, - blurRadius: Double, - tagTranslator: TagTranslator + blurRadius: Double ) -> some View { switch store.case { case .detail(let detailStore): DetailView( store: detailStore, gid: detailStore.gid, user: user, - setting: setting, blurRadius: blurRadius, tagTranslator: tagTranslator + setting: setting, blurRadius: blurRadius ) case .previews(let previewsStore): PreviewsView( @@ -30,12 +28,12 @@ public func galleryDestination( store: commentsStore, gid: commentsStore.gid, token: commentsStore.token, apiKey: commentsStore.apiKey, galleryURL: commentsStore.galleryURL, comments: commentsStore.comments, user: user, setting: setting, - blurRadius: blurRadius, tagTranslator: tagTranslator + blurRadius: blurRadius ) case .detailSearch(let searchStore): DetailSearchView( store: searchStore, keyword: searchStore.keyword, user: user, - setting: setting, blurRadius: blurRadius, tagTranslator: tagTranslator + setting: setting, blurRadius: blurRadius ) case .galleryInfos(let infosStore): GalleryInfosView( @@ -54,7 +52,6 @@ public struct GalleryNavigationContainer, blurRadius: Double, - tagTranslator: TagTranslator, @ViewBuilder root: () -> Root ) { self.store = store @@ -73,7 +69,6 @@ public struct GalleryNavigationContainer? public var gid = "" public var inspection: DownloadInspection? diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index 672ee0d41..5b73a3289 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -15,18 +15,15 @@ struct DownloadInspectorView: View { @Bindable private var store: StoreOf private let setting: Setting private let blurRadius: Double - private let tagTranslator: TagTranslator init( store: StoreOf, setting: Setting, - blurRadius: Double, - tagTranslator: TagTranslator + blurRadius: Double ) { self.store = store self.setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } var body: some View { @@ -47,7 +44,7 @@ struct DownloadInspectorView: View { coverSource: .static(inspection.coverURL), setting: setting, translateAction: { - tagTranslator.lookup( + store.tagTranslator.lookup( word: $0, returnOriginal: !setting.translatesTags ) @@ -293,9 +290,9 @@ private extension View { } struct DownloadListRow: View { + @SharedReader(.tagTranslator) private var tagTranslator: TagTranslator let download: DownloadedGallery let setting: Setting - let tagTranslator: TagTranslator let openAction: () -> Void var body: some View { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index 98aa44c47..5f1fbc11f 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -14,20 +14,17 @@ public struct DownloadsView: View { @Binding private var setting: Setting private let user: User private let blurRadius: Double - private let tagTranslator: TagTranslator public init( store: StoreOf, user: User, setting: Binding, - blurRadius: Double, - tagTranslator: TagTranslator + blurRadius: Double ) { self.store = store self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } public var body: some View { @@ -37,8 +34,7 @@ public struct DownloadsView: View { action: \.path, user: user, setting: $setting, - blurRadius: blurRadius, - tagTranslator: tagTranslator + blurRadius: blurRadius ) { contentView } @@ -74,8 +70,7 @@ public struct DownloadsView: View { DownloadInspectorView( store: store, setting: setting, - blurRadius: blurRadius, - tagTranslator: tagTranslator + blurRadius: blurRadius ) } .autoBlur(radius: blurRadius) @@ -128,8 +123,7 @@ private extension DownloadsView { ForEach(store.filteredDownloads) { download in DownloadListRow( download: download, - setting: setting, - tagTranslator: tagTranslator + setting: setting ) { store.send(.openReading(download.gid)) } @@ -340,8 +334,7 @@ struct DownloadsView_Previews: PreviewProvider { store: .init(initialState: .init(), reducer: DownloadsReducer.init), user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index 7e72db4d9..80854c0c3 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -29,6 +29,7 @@ public struct FavoritesReducer: Sendable { @ObservableState public struct State: Equatable { + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator public var path = StackState() @Presents public var destination: Destination.State? public var keyword = "" diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index 63f6f6334..518c3db41 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -15,17 +15,15 @@ public struct FavoritesView: View { private let user: User @Binding private var setting: Setting private let blurRadius: Double - private let tagTranslator: TagTranslator public init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + user: User, setting: Binding, blurRadius: Double ) { self.store = store self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } private var navigationTitle: String { @@ -40,8 +38,7 @@ public struct FavoritesView: View { action: \.path, user: user, setting: $setting, - blurRadius: blurRadius, - tagTranslator: tagTranslator + blurRadius: blurRadius ) { ZStack { if CookieUtil.didLogin { @@ -55,7 +52,7 @@ public struct FavoritesView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.galleryTapped($0)) }, translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) }, downloadBadges: store.downloadBadges ) @@ -88,7 +85,7 @@ public struct FavoritesView: View { .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, + keyword: $store.keyword, translations: store.tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion ) } @@ -136,8 +133,7 @@ struct FavoritesView_Previews: PreviewProvider { store: .init(initialState: .init(), reducer: FavoritesReducer.init), user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index 715ad117f..c8701f77a 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -27,6 +27,7 @@ public struct FrontpageReducer: Sendable { @ObservableState public struct State: Equatable { @SharedReader(.globalFilter) public var globalFilter: Filter + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator @Presents public var destination: Destination.State? public var keyword = "" diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 83d9e4147..5be5525c1 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -14,17 +14,15 @@ struct FrontpageView: View { private let user: User @Binding private var setting: Setting private let blurRadius: Double - private let tagTranslator: TagTranslator init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + user: User, setting: Binding, blurRadius: Double ) { self.store = store self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } var body: some View { @@ -38,7 +36,7 @@ struct FrontpageView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) .sheet( @@ -90,8 +88,7 @@ struct FrontpageView_Previews: PreviewProvider { store: .init(initialState: .init(), reducer: FrontpageReducer.init), user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index 959488131..00f925a4b 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -30,6 +30,7 @@ public struct HistoryReducer: Sendable { @ObservableState public struct State: Equatable { + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator @Presents public var confirmationDialog: ConfirmationDialogState? public var keyword = "" public var downloadBadges = [String: DownloadBadge]() diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index 775d22d07..1b0342e28 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -12,17 +12,15 @@ struct HistoryView: View { private let user: User @Binding private var setting: Setting private let blurRadius: Double - private let tagTranslator: TagTranslator init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + user: User, setting: Binding, blurRadius: Double ) { self.store = store self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } var body: some View { @@ -39,7 +37,7 @@ struct HistoryView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) }, downloadBadges: store.downloadBadges ) @@ -78,8 +76,7 @@ struct HistoryView_Previews: PreviewProvider { store: .init(initialState: .init(), reducer: HistoryReducer.init), user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/HomeFeature/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift index 284bdbde5..3f390ca12 100644 --- a/AppPackage/Sources/HomeFeature/HomeView.swift +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -13,17 +13,15 @@ public struct HomeView: View { private let user: User @Binding private var setting: Setting private let blurRadius: Double - private let tagTranslator: TagTranslator public init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + user: User, setting: Binding, blurRadius: Double ) { self.store = store self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } // MARK: HomeView @@ -96,33 +94,27 @@ public struct HomeView: View { switch store.case { case .frontpage(let store): FrontpageView( - store: store, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator + store: store, user: user, setting: $setting, blurRadius: blurRadius ) case .popular(let store): PopularView( - store: store, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator + store: store, user: user, setting: $setting, blurRadius: blurRadius ) case .toplists(let store): ToplistsView( - store: store, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator + store: store, user: user, setting: $setting, blurRadius: blurRadius ) case .watched(let store): WatchedView( - store: store, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator + store: store, user: user, setting: $setting, blurRadius: blurRadius ) case .history(let store): HistoryView( - store: store, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator + store: store, user: user, setting: $setting, blurRadius: blurRadius ) case .gallery(let store): galleryDestination( - store, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator + store, user: user, setting: $setting, blurRadius: blurRadius ) } } @@ -196,8 +188,7 @@ struct HomeView_Previews: PreviewProvider { store: .init(initialState: .init(), reducer: HomeReducer.init), user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index e20bd403d..fb5a9d911 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -24,6 +24,7 @@ public struct PopularReducer: Sendable { @ObservableState public struct State: Equatable { @SharedReader(.globalFilter) public var globalFilter: Filter + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator @Presents public var destination: Destination.State? public var keyword = "" diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index 881ff6e6a..b084ba318 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -13,17 +13,15 @@ struct PopularView: View { private let user: User @Binding private var setting: Setting private let blurRadius: Double - private let tagTranslator: TagTranslator init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + user: User, setting: Binding, blurRadius: Double ) { self.store = store self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } var body: some View { @@ -35,7 +33,7 @@ struct PopularView: View { fetchAction: { store.send(.fetchGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) .sheet( @@ -72,8 +70,7 @@ struct PopularView_Previews: PreviewProvider { store: .init(initialState: .init(), reducer: PopularReducer.init), user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index 4689dd12b..ecdeb4ed7 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -22,6 +22,7 @@ public struct ToplistsReducer: Sendable { @ObservableState public struct State: Equatable { + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator public var keyword = "" public var jumpPageIndex = "" @Presents public var alert: AppAlertState? diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index dc7cd03d7..40c335e3c 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -12,17 +12,15 @@ struct ToplistsView: View { private let user: User @Binding private var setting: Setting private let blurRadius: Double - private let tagTranslator: TagTranslator init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + user: User, setting: Binding, blurRadius: Double ) { self.store = store self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } private var navigationTitle: String { @@ -40,7 +38,7 @@ struct ToplistsView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } ) .searchable(text: $store.keyword, prompt: .filter) @@ -79,8 +77,7 @@ struct ToplistsView_Previews: PreviewProvider { store: .init(initialState: .init(), reducer: ToplistsReducer.init), user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index a03a0848f..ffa24a6be 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -29,6 +29,7 @@ public struct WatchedReducer: Sendable { @ObservableState public struct State: Equatable { @SharedReader(.watchedFilter) public var watchedFilter: Filter + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator @Presents public var destination: Destination.State? public var keyword = "" diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index 20bbed557..9e6b0e329 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -15,17 +15,15 @@ struct WatchedView: View { private let user: User @Binding private var setting: Setting private let blurRadius: Double - private let tagTranslator: TagTranslator init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + user: User, setting: Binding, blurRadius: Double ) { self.store = store self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } var body: some View { @@ -41,7 +39,7 @@ struct WatchedView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) }, downloadBadges: store.downloadBadges ) @@ -80,7 +78,7 @@ struct WatchedView: View { .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, + keyword: $store.keyword, translations: store.tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion ) } @@ -123,8 +121,7 @@ struct WatchedView_Previews: PreviewProvider { store: .init(initialState: .init(), reducer: WatchedReducer.init), user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index c8d8e92e9..6f2daa34a 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -31,6 +31,7 @@ public struct SearchReducer: Sendable { @ObservableState public struct State: Equatable { @SharedReader(.searchFilter) public var searchFilter: Filter + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator @Presents public var destination: Destination.State? public var keyword = "" public var lastKeyword = "" diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift index a2300407a..86fd8d060 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -36,6 +36,7 @@ public struct SearchRootReducer: Sendable { // and the QuickSearch editor, which share the same keys, so changes stay live without reloads. @Shared(.historyKeywords) public var historyKeywords: [String] @Shared(.quickSearchWords) public var quickSearchWords: [QuickSearchWord] + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator public init() {} diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index 129d3660b..4e0a20f6e 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -13,17 +13,15 @@ public struct SearchRootView: View { private let user: User @Binding private var setting: Setting private let blurRadius: Double - private let tagTranslator: TagTranslator public init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + user: User, setting: Binding, blurRadius: Double ) { self.store = store self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } public var body: some View { @@ -65,7 +63,7 @@ public struct SearchRootView: View { .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, + keyword: $store.keyword, translations: store.tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion ) } @@ -90,13 +88,11 @@ public struct SearchRootView: View { switch store.case { case .search(let store): SearchView( - store: store, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator + store: store, user: user, setting: $setting, blurRadius: blurRadius ) case .gallery(let store): galleryDestination( - store, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator + store, user: user, setting: $setting, blurRadius: blurRadius ) } } @@ -270,8 +266,7 @@ struct SearchRootView_Previews: PreviewProvider { store: .init(initialState: .init(), reducer: SearchRootReducer.init), user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift index 0cd4d805c..072170656 100644 --- a/AppPackage/Sources/SearchFeature/SearchView.swift +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -14,17 +14,15 @@ struct SearchView: View { private let user: User @Binding private var setting: Setting private let blurRadius: Double - private let tagTranslator: TagTranslator init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + user: User, setting: Binding, blurRadius: Double ) { self.store = store self.user = user _setting = setting self.blurRadius = blurRadius - self.tagTranslator = tagTranslator } var body: some View { @@ -38,7 +36,7 @@ struct SearchView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) }, downloadBadges: store.downloadBadges ) @@ -73,7 +71,7 @@ struct SearchView: View { .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, + keyword: $store.keyword, translations: store.tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion ) } @@ -115,8 +113,7 @@ struct SearchView_Previews: PreviewProvider { store: .init(initialState: .init(), reducer: SearchReducer.init), user: .init(), setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() + blurRadius: 0 ) } } diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 559e565e2..f615b54ee 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -190,7 +190,7 @@ extension SettingReducer { state.tagTranslatorLoadingState = .idle switch result { case .success(let tagTranslator): - state.tagTranslator = tagTranslator + state.$tagTranslator.withLock { $0 = tagTranslator } state.$tagTranslatorInfo.withLock { $0 = TagTranslatorInfo( language: tagTranslator.language, @@ -217,7 +217,7 @@ extension SettingReducer { } case .tagTranslatorRebuilt(let tagTranslator): - state.tagTranslator = tagTranslator + state.$tagTranslator.withLock { $0 = tagTranslator } return .none case .fetchEhProfileIndex: @@ -273,7 +273,7 @@ extension SettingReducer { case .path(.element(id: _, action: .general(.onRemoveCustomTranslations))): // Drop the custom table from memory, metadata, and disk; the launch/remote flow refills // it. FileClient owns the path, so the file lifecycle stays behind one module. - state.tagTranslator = TagTranslator() + state.$tagTranslator.withLock { $0 = TagTranslator() } state.$tagTranslatorInfo.withLock { $0.hasCustomTranslations = false } return .run { _ in fileClient.removeCustomTranslations() } diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift index ea645d0b3..f78bfd135 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift @@ -88,7 +88,7 @@ extension SettingReducer { // A language switch resets the in-memory table and its persisted metadata; the request then // downloads the new language's data from scratch. if state.tagTranslatorInfo.language != language { - state.tagTranslator = TagTranslator(language: language) + state.$tagTranslator.withLock { $0 = TagTranslator(language: language) } state.$tagTranslatorInfo.withLock { $0 = TagTranslatorInfo(language: language) } } let updatedDate = state.tagTranslatorInfo.updatedDate diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index 149dbe011..61793dfa6 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -65,7 +65,7 @@ public struct SettingReducer: Sendable { get { setting } set { $setting.withLock { $0 = newValue } } } - public var tagTranslator = TagTranslator() + @Shared(.tagTranslator) public var tagTranslator: TagTranslator @Shared(.tagTranslatorInfo) public var tagTranslatorInfo: TagTranslatorInfo @Shared(.user) public var user: User @Shared(.greeting) public var greeting: Greeting? diff --git a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift index 124ebf25b..aabc41d7d 100644 --- a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift @@ -136,7 +136,7 @@ struct SettingReducerNavigationTests { // The parent intercept runs `fileClient.importTagTranslator`, stores the (in-memory) table // and records the custom-import flag in the persisted `tagTranslatorInfo`. await store.receive(\.fetchTagTranslatorDone) { - $0.tagTranslator = imported + $0.$tagTranslator.withLock { $0 = imported } $0.$tagTranslatorInfo.withLock { $0 = TagTranslatorInfo(hasCustomTranslations: true) } } } From c5bfb70a5c3e2a4ed0fc3b271959a2a8b11d99df Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 02:00:47 +0800 Subject: [PATCH 567/614] Read user via `@Shared`, drop param threading --- .../AppFeature/View/TabBar/TabBarView.swift | 6 ------ .../Archives/ArchivesReducer.swift | 4 ++-- .../DetailFeature/Archives/ArchivesView.swift | 12 +++++------- .../DetailFeature/Comments/CommentsView.swift | 5 +---- .../Sources/DetailFeature/DetailReducer.swift | 1 + .../DetailSearch/DetailSearchView.swift | 5 +---- .../Sources/DetailFeature/DetailView.swift | 8 ++------ .../DetailFeature/GalleryDestination.swift | 12 ++++-------- .../DownloadsFeature/DownloadsView.swift | 5 ----- .../FavoritesFeature/FavoritesReducer.swift | 1 + .../FavoritesFeature/FavoritesView.swift | 10 +++------- .../HomeFeature/Frontpage/FrontpageView.swift | 5 +---- .../HomeFeature/History/HistoryView.swift | 5 +---- AppPackage/Sources/HomeFeature/HomeView.swift | 17 +++++++---------- .../HomeFeature/Popular/PopularView.swift | 5 +---- .../HomeFeature/Toplists/ToplistsView.swift | 5 +---- .../HomeFeature/Watched/WatchedView.swift | 5 +---- .../Sources/SearchFeature/SearchRootView.swift | 9 +++------ .../Sources/SearchFeature/SearchView.swift | 5 +---- 19 files changed, 36 insertions(+), 89 deletions(-) diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index c9f223766..a96d11404 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -41,28 +41,24 @@ struct TabBarView: View { case .home: HomeView( store: store.scope(state: \.homeState, action: \.home), - user: store.settingState.user, setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius ) case .favorites: FavoritesView( store: store.scope(state: \.favoritesState, action: \.favorites), - user: store.settingState.user, setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius ) case .search: SearchRootView( store: store.scope(state: \.searchRootState, action: \.searchRoot), - user: store.settingState.user, setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius ) case .downloads: DownloadsView( store: store.scope(state: \.downloadsState, action: \.downloads), - user: store.settingState.user, setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius ) @@ -104,14 +100,12 @@ struct TabBarView: View { DetailView( store: detailStore, gid: detailStore.gid, - user: store.settingState.user, setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius ) } destination: { elementStore in galleryDestination( elementStore, - user: store.settingState.user, setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius ) diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index 4241a5d43..ba4d74d3b 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -17,6 +17,7 @@ public struct ArchivesReducer: Sendable { @ObservableState public struct State: Equatable { + @Shared(.user) public var user: User @Presents public var toast: AppAlertState? public var selectedArchive: GalleryArchive.HathArchive? @@ -55,8 +56,7 @@ public struct ArchivesReducer: Sendable { return .none case .syncGalleryFunds(let galleryPoints, let credits): - @Shared(.user) var user - $user.withLock { + state.$user.withLock { $0.galleryPoints = galleryPoints $0.credits = credits } diff --git a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift index 0035f378f..af504a34e 100644 --- a/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift @@ -9,17 +9,15 @@ import AppComponents struct ArchivesView: View { @Bindable private var store: StoreOf private let gid: String - private let user: User private let galleryURL: URL private let archiveURL: URL init( store: StoreOf, - gid: String, user: User, galleryURL: URL, archiveURL: URL + gid: String, galleryURL: URL, archiveURL: URL ) { self.store = store self.gid = gid - self.user = user self.galleryURL = galleryURL self.archiveURL = archiveURL } @@ -33,7 +31,8 @@ struct ArchivesView: View { Spacer() - if let credits = Int(user.credits ?? ""), let galleryPoints = Int(user.galleryPoints ?? "") { + if let credits = Int(store.user.credits ?? ""), + let galleryPoints = Int(store.user.galleryPoints ?? "") { ArchiveFundsView(credits: credits, galleryPoints: galleryPoints) } @@ -58,8 +57,8 @@ struct ArchivesView: View { } .toast($store.scope(state: \.toast, action: \.toast)) .animation(.default, value: store.hathArchives) - .animation(.default, value: user.galleryPoints) - .animation(.default, value: user.credits) + .animation(.default, value: store.user.galleryPoints) + .animation(.default, value: store.user.credits) .onAppear { store.send(.fetchArchive(gid, galleryURL, archiveURL)) } @@ -238,7 +237,6 @@ struct ArchivesView_Previews: PreviewProvider { ArchivesView( store: .init(initialState: .init(), reducer: ArchivesReducer.init), gid: .init(), - user: .init(), galleryURL: .mock, archiveURL: .mock ) diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index be2376419..c12ce613d 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -14,14 +14,13 @@ struct CommentsView: View { private let apiKey: String private let galleryURL: URL private let comments: [GalleryComment] - private let user: User @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, gid: String, token: String, apiKey: String, galleryURL: URL, - comments: [GalleryComment], user: User, setting: Binding, + comments: [GalleryComment], setting: Binding, blurRadius: Double ) { self.store = store @@ -30,7 +29,6 @@ struct CommentsView: View { self.apiKey = apiKey self.galleryURL = galleryURL self.comments = comments - self.user = user _setting = setting self.blurRadius = blurRadius } @@ -254,7 +252,6 @@ struct CommentsView_Previews: PreviewProvider { apiKey: .init(), galleryURL: .mock, comments: [], - user: .init(), setting: .constant(.init()), blurRadius: 0 ) diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index 9114b2183..c7aca5653 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -59,6 +59,7 @@ public struct DetailReducer: Sendable { @ObservableState public struct State: Equatable { @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.user) public var user: User @Presents public var destination: Destination.State? @Presents public var alert: AppAlertState? public var commentContent = "" diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift index a6b938cf1..d747e606d 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift @@ -11,17 +11,15 @@ import QuickSearchFeature struct DetailSearchView: View { @Bindable private var store: StoreOf private let keyword: String - private let user: User @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - keyword: String, user: User, setting: Binding, blurRadius: Double + keyword: String, setting: Binding, blurRadius: Double ) { self.store = store self.keyword = keyword - self.user = user _setting = setting self.blurRadius = blurRadius } @@ -96,7 +94,6 @@ struct DetailSearchView_Previews: PreviewProvider { DetailSearchView( store: .init(initialState: .init(), reducer: DetailSearchReducer.init), keyword: .init(), - user: .init(), setting: .constant(.init()), blurRadius: 0 ) diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index d0cef6785..23c9560eb 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -12,17 +12,15 @@ import ReadingFeature public struct DetailView: View { @Bindable var store: StoreOf let gid: String - let user: User @Binding var setting: Setting let blurRadius: Double public init( store: StoreOf, gid: String, - user: User, setting: Binding, blurRadius: Double + setting: Binding, blurRadius: Double ) { self.store = store self.gid = gid - self.user = user _setting = setting self.blurRadius = blurRadius } @@ -64,7 +62,7 @@ private extension DetailView { HeaderSection( gallery: store.gallery, galleryDetail: store.galleryDetail ?? .empty, - user: user, + user: store.user, downloadBadge: store.downloadBadge, downloadNeedsRepair: store.downloadNeedsRepair, downloadFolders: store.downloadFolders, @@ -233,7 +231,6 @@ private extension DetailView { ArchivesView( store: archivesStore, gid: gid, - user: user, galleryURL: galleryURL, archiveURL: archiveURL ) @@ -319,7 +316,6 @@ struct DetailView_Previews: PreviewProvider { DetailView( store: .init(initialState: .init(gallery: .preview), reducer: DetailReducer.init), gid: .init(), - user: .init(), setting: .constant(.init()), blurRadius: 0 ) diff --git a/AppPackage/Sources/DetailFeature/GalleryDestination.swift b/AppPackage/Sources/DetailFeature/GalleryDestination.swift index 4133a323a..cff425701 100644 --- a/AppPackage/Sources/DetailFeature/GalleryDestination.swift +++ b/AppPackage/Sources/DetailFeature/GalleryDestination.swift @@ -8,14 +8,13 @@ import ComposableArchitecture @ViewBuilder public func galleryDestination( _ store: StoreOf, - user: User, setting: Binding, blurRadius: Double ) -> some View { switch store.case { case .detail(let detailStore): DetailView( - store: detailStore, gid: detailStore.gid, user: user, + store: detailStore, gid: detailStore.gid, setting: setting, blurRadius: blurRadius ) case .previews(let previewsStore): @@ -27,12 +26,12 @@ public func galleryDestination( CommentsView( store: commentsStore, gid: commentsStore.gid, token: commentsStore.token, apiKey: commentsStore.apiKey, galleryURL: commentsStore.galleryURL, - comments: commentsStore.comments, user: user, setting: setting, + comments: commentsStore.comments, setting: setting, blurRadius: blurRadius ) case .detailSearch(let searchStore): DetailSearchView( - store: searchStore, keyword: searchStore.keyword, user: user, + store: searchStore, keyword: searchStore.keyword, setting: setting, blurRadius: blurRadius ) case .galleryInfos(let infosStore): @@ -49,7 +48,6 @@ public struct GalleryNavigationContainer private let statePath: KeyPath> private let actionPath: CaseKeyPath> - private let user: User @Binding private var setting: Setting private let blurRadius: Double private let root: Root @@ -58,7 +56,6 @@ public struct GalleryNavigationContainer, state statePath: KeyPath>, action actionPath: CaseKeyPath>, - user: User, setting: Binding, blurRadius: Double, @ViewBuilder root: () -> Root @@ -66,7 +63,6 @@ public struct GalleryNavigationContainer @Binding private var setting: Setting - private let user: User private let blurRadius: Double public init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double ) { self.store = store - self.user = user _setting = setting self.blurRadius = blurRadius } @@ -32,7 +29,6 @@ public struct DownloadsView: View { store: store, state: \.path, action: \.path, - user: user, setting: $setting, blurRadius: blurRadius ) { @@ -332,7 +328,6 @@ struct DownloadsView_Previews: PreviewProvider { static var previews: some View { DownloadsView( store: .init(initialState: .init(), reducer: DownloadsReducer.init), - user: .init(), setting: .constant(.init()), blurRadius: 0 ) diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index 80854c0c3..edcbfea48 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -30,6 +30,7 @@ public struct FavoritesReducer: Sendable { @ObservableState public struct State: Equatable { @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.user) public var user: User public var path = StackState() @Presents public var destination: Destination.State? public var keyword = "" diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index 518c3db41..7cb4bbbc9 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -12,22 +12,20 @@ import DetailFeature public struct FavoritesView: View { @Bindable private var store: StoreOf - private let user: User @Binding private var setting: Setting private let blurRadius: Double public init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double + setting: Binding, blurRadius: Double ) { self.store = store - self.user = user _setting = setting self.blurRadius = blurRadius } private var navigationTitle: String { - let favoriteCategory = user.getFavoriteCategory(index: store.index) + let favoriteCategory = store.user.getFavoriteCategory(index: store.index) return (store.index == -1 ? String(localized: .RLocalizable.favorites) : favoriteCategory) } @@ -36,7 +34,6 @@ public struct FavoritesView: View { store: store, state: \.path, action: \.path, - user: user, setting: $setting, blurRadius: blurRadius ) { @@ -107,7 +104,7 @@ public struct FavoritesView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem(tint: .primary) { - FavoritesIndexMenu(user: user, index: store.index) { index in + FavoritesIndexMenu(user: store.user, index: store.index) { index in if index != store.index { store.send(.setFavoritesIndex(index)) } @@ -131,7 +128,6 @@ struct FavoritesView_Previews: PreviewProvider { static var previews: some View { FavoritesView( store: .init(initialState: .init(), reducer: FavoritesReducer.init), - user: .init(), setting: .constant(.init()), blurRadius: 0 ) diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index 5be5525c1..fe2bc617a 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -11,16 +11,14 @@ import FiltersFeature struct FrontpageView: View { @Bindable private var store: StoreOf - private let user: User @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double + setting: Binding, blurRadius: Double ) { self.store = store - self.user = user _setting = setting self.blurRadius = blurRadius } @@ -86,7 +84,6 @@ struct FrontpageView_Previews: PreviewProvider { NavigationStack { FrontpageView( store: .init(initialState: .init(), reducer: FrontpageReducer.init), - user: .init(), setting: .constant(.init()), blurRadius: 0 ) diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index 1b0342e28..de2c96e8a 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -9,16 +9,14 @@ import GalleryListComponents struct HistoryView: View { @Bindable private var store: StoreOf - private let user: User @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double + setting: Binding, blurRadius: Double ) { self.store = store - self.user = user _setting = setting self.blurRadius = blurRadius } @@ -74,7 +72,6 @@ struct HistoryView_Previews: PreviewProvider { NavigationStack { HistoryView( store: .init(initialState: .init(), reducer: HistoryReducer.init), - user: .init(), setting: .constant(.init()), blurRadius: 0 ) diff --git a/AppPackage/Sources/HomeFeature/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift index 3f390ca12..0a19a4361 100644 --- a/AppPackage/Sources/HomeFeature/HomeView.swift +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -10,16 +10,14 @@ import DetailFeature public struct HomeView: View { @Bindable private var store: StoreOf - private let user: User @Binding private var setting: Setting private let blurRadius: Double public init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double + setting: Binding, blurRadius: Double ) { self.store = store - self.user = user _setting = setting self.blurRadius = blurRadius } @@ -94,27 +92,27 @@ public struct HomeView: View { switch store.case { case .frontpage(let store): FrontpageView( - store: store, user: user, setting: $setting, blurRadius: blurRadius + store: store, setting: $setting, blurRadius: blurRadius ) case .popular(let store): PopularView( - store: store, user: user, setting: $setting, blurRadius: blurRadius + store: store, setting: $setting, blurRadius: blurRadius ) case .toplists(let store): ToplistsView( - store: store, user: user, setting: $setting, blurRadius: blurRadius + store: store, setting: $setting, blurRadius: blurRadius ) case .watched(let store): WatchedView( - store: store, user: user, setting: $setting, blurRadius: blurRadius + store: store, setting: $setting, blurRadius: blurRadius ) case .history(let store): HistoryView( - store: store, user: user, setting: $setting, blurRadius: blurRadius + store: store, setting: $setting, blurRadius: blurRadius ) case .gallery(let store): galleryDestination( - store, user: user, setting: $setting, blurRadius: blurRadius + store, setting: $setting, blurRadius: blurRadius ) } } @@ -186,7 +184,6 @@ struct HomeView_Previews: PreviewProvider { static var previews: some View { HomeView( store: .init(initialState: .init(), reducer: HomeReducer.init), - user: .init(), setting: .constant(.init()), blurRadius: 0 ) diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index b084ba318..6d1cbb08d 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -10,16 +10,14 @@ import FiltersFeature struct PopularView: View { @Bindable private var store: StoreOf - private let user: User @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double + setting: Binding, blurRadius: Double ) { self.store = store - self.user = user _setting = setting self.blurRadius = blurRadius } @@ -68,7 +66,6 @@ struct PopularView_Previews: PreviewProvider { NavigationStack { PopularView( store: .init(initialState: .init(), reducer: PopularReducer.init), - user: .init(), setting: .constant(.init()), blurRadius: 0 ) diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index 40c335e3c..fb8a2e799 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -9,16 +9,14 @@ import GalleryListComponents struct ToplistsView: View { @Bindable private var store: StoreOf - private let user: User @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double + setting: Binding, blurRadius: Double ) { self.store = store - self.user = user _setting = setting self.blurRadius = blurRadius } @@ -75,7 +73,6 @@ struct ToplistsView_Previews: PreviewProvider { NavigationStack { ToplistsView( store: .init(initialState: .init(), reducer: ToplistsReducer.init), - user: .init(), setting: .constant(.init()), blurRadius: 0 ) diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index 9e6b0e329..aadafe780 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -12,16 +12,14 @@ import QuickSearchFeature struct WatchedView: View { @Bindable private var store: StoreOf - private let user: User @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double + setting: Binding, blurRadius: Double ) { self.store = store - self.user = user _setting = setting self.blurRadius = blurRadius } @@ -119,7 +117,6 @@ struct WatchedView_Previews: PreviewProvider { NavigationStack { WatchedView( store: .init(initialState: .init(), reducer: WatchedReducer.init), - user: .init(), setting: .constant(.init()), blurRadius: 0 ) diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index 4e0a20f6e..5387d2b11 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -10,16 +10,14 @@ import DetailFeature public struct SearchRootView: View { @Bindable private var store: StoreOf - private let user: User @Binding private var setting: Setting private let blurRadius: Double public init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double + setting: Binding, blurRadius: Double ) { self.store = store - self.user = user _setting = setting self.blurRadius = blurRadius } @@ -88,11 +86,11 @@ public struct SearchRootView: View { switch store.case { case .search(let store): SearchView( - store: store, user: user, setting: $setting, blurRadius: blurRadius + store: store, setting: $setting, blurRadius: blurRadius ) case .gallery(let store): galleryDestination( - store, user: user, setting: $setting, blurRadius: blurRadius + store, setting: $setting, blurRadius: blurRadius ) } } @@ -264,7 +262,6 @@ struct SearchRootView_Previews: PreviewProvider { static var previews: some View { SearchRootView( store: .init(initialState: .init(), reducer: SearchRootReducer.init), - user: .init(), setting: .constant(.init()), blurRadius: 0 ) diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift index 072170656..896aaf8bc 100644 --- a/AppPackage/Sources/SearchFeature/SearchView.swift +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -11,16 +11,14 @@ import QuickSearchFeature struct SearchView: View { @Bindable private var store: StoreOf - private let user: User @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - user: User, setting: Binding, blurRadius: Double + setting: Binding, blurRadius: Double ) { self.store = store - self.user = user _setting = setting self.blurRadius = blurRadius } @@ -111,7 +109,6 @@ struct SearchView_Previews: PreviewProvider { static var previews: some View { SearchView( store: .init(initialState: .init(), reducer: SearchReducer.init), - user: .init(), setting: .constant(.init()), blurRadius: 0 ) From 0ce0b2dad43f2d6b222be59f059bc8483021575b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 02:14:15 +0800 Subject: [PATCH 568/614] Read setting via `@Shared`; Reading writes binding --- .../AppFeature/View/TabBar/TabBarView.swift | 6 --- .../Comments/CommentsReducer.swift | 1 + .../DetailFeature/Comments/CommentsView.swift | 7 +-- .../Sources/DetailFeature/DetailReducer.swift | 1 + .../DetailSearch/DetailSearchReducer.swift | 1 + .../DetailSearch/DetailSearchView.swift | 15 +++--- .../Sources/DetailFeature/DetailView.swift | 24 ++++----- .../DetailFeature/GalleryDestination.swift | 14 ++---- .../Previews/PreviewsReducer.swift | 1 + .../DetailFeature/Previews/PreviewsView.swift | 9 ++-- .../DownloadInspectorReducer.swift | 1 + .../DownloadsFeature/DownloadsReducer.swift | 1 + .../DownloadsView+Subviews.swift | 9 ++-- .../DownloadsFeature/DownloadsView.swift | 20 +++----- .../FavoritesFeature/FavoritesReducer.swift | 1 + .../FavoritesFeature/FavoritesView.swift | 16 +++--- .../Frontpage/FrontpageReducer.swift | 1 + .../HomeFeature/Frontpage/FrontpageView.swift | 11 ++--- .../HomeFeature/History/HistoryReducer.swift | 1 + .../HomeFeature/History/HistoryView.swift | 9 ++-- AppPackage/Sources/HomeFeature/HomeView.swift | 17 +++---- .../HomeFeature/Popular/PopularReducer.swift | 1 + .../HomeFeature/Popular/PopularView.swift | 9 ++-- .../Toplists/ToplistsReducer.swift | 1 + .../HomeFeature/Toplists/ToplistsView.swift | 9 ++-- .../HomeFeature/Watched/WatchedReducer.swift | 1 + .../HomeFeature/Watched/WatchedView.swift | 15 +++--- .../ReadingFeature/ReadingReducer.swift | 10 ++++ .../ReadingFeature/ReadingView+Gestures.swift | 10 ++-- .../Sources/ReadingFeature/ReadingView.swift | 49 +++++++++---------- .../Sources/SearchFeature/SearchReducer.swift | 1 + .../SearchFeature/SearchRootReducer.swift | 1 + .../SearchFeature/SearchRootView.swift | 13 ++--- .../Sources/SearchFeature/SearchView.swift | 17 +++---- 34 files changed, 132 insertions(+), 171 deletions(-) diff --git a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index a96d11404..36eaf4186 100644 --- a/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -41,25 +41,21 @@ struct TabBarView: View { case .home: HomeView( store: store.scope(state: \.homeState, action: \.home), - setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius ) case .favorites: FavoritesView( store: store.scope(state: \.favoritesState, action: \.favorites), - setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius ) case .search: SearchRootView( store: store.scope(state: \.searchRootState, action: \.searchRoot), - setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius ) case .downloads: DownloadsView( store: store.scope(state: \.downloadsState, action: \.downloads), - setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius ) case .setting: @@ -100,13 +96,11 @@ struct TabBarView: View { DetailView( store: detailStore, gid: detailStore.gid, - setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius ) } destination: { elementStore in galleryDestination( elementStore, - setting: $store.settingState.settingBinding, blurRadius: store.appLockState.blurRadius ) } diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index af87ddf39..70207b5f6 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -30,6 +30,7 @@ public struct CommentsReducer: Sendable { @ObservableState public struct State: Equatable { + @SharedReader(.setting) public var setting: Setting @Presents public var toast: AppAlertState? @Presents public var destination: Destination.State? public var commentContent = "" diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift index c12ce613d..21763171c 100644 --- a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -14,13 +14,12 @@ struct CommentsView: View { private let apiKey: String private let galleryURL: URL private let comments: [GalleryComment] - @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, gid: String, token: String, apiKey: String, galleryURL: URL, - comments: [GalleryComment], setting: Binding, + comments: [GalleryComment], blurRadius: Double ) { self.store = store @@ -29,7 +28,6 @@ struct CommentsView: View { self.apiKey = apiKey self.galleryURL = galleryURL self.comments = comments - _setting = setting self.blurRadius = blurRadius } @@ -102,7 +100,7 @@ struct CommentsView: View { cancelAction: { store.send(.destination(.dismiss)) }, onAppearAction: { store.send(.onPostCommentAppear) } ) - .accentColor(setting.accentColor) + .accentColor(store.setting.accentColor) .autoBlur(radius: blurRadius) } .toast($store.scope(state: \.toast, action: \.toast)) @@ -252,7 +250,6 @@ struct CommentsView_Previews: PreviewProvider { apiKey: .init(), galleryURL: .mock, comments: [], - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index c7aca5653..0e57f7a94 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -60,6 +60,7 @@ public struct DetailReducer: Sendable { public struct State: Equatable { @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator @SharedReader(.user) public var user: User + @SharedReader(.setting) public var setting: Setting @Presents public var destination: Destination.State? @Presents public var alert: AppAlertState? public var commentContent = "" diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index 748173bad..cb0868371 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -27,6 +27,7 @@ public struct DetailSearchReducer: Sendable { public struct State: Equatable { @SharedReader(.searchFilter) public var searchFilter: Filter @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting @Presents public var destination: Destination.State? public var keyword = "" public var lastKeyword = "" diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift index d747e606d..0db3fbfab 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift @@ -11,23 +11,21 @@ import QuickSearchFeature struct DetailSearchView: View { @Bindable private var store: StoreOf private let keyword: String - @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - keyword: String, setting: Binding, blurRadius: Double + keyword: String, blurRadius: Double ) { self.store = store self.keyword = keyword - _setting = setting self.blurRadius = blurRadius } var body: some View { GenericList( galleries: store.galleries, - setting: setting, + setting: store.setting, pageNumber: store.pageNumber, loadingState: store.loadingState, footerLoadingState: store.footerLoadingState, @@ -35,7 +33,7 @@ struct DetailSearchView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) } ) .sheet( @@ -45,20 +43,20 @@ struct DetailSearchView: View { self.store.send(.destination(.dismiss)) self.store.send(.fetchGalleries(keyword)) } - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .sheet( item: $store.scope(state: \.destination?.filters, action: \.destination.filters) ) { store in FiltersView(store: store) - .accentColor(setting.accentColor).autoBlur(radius: blurRadius) + .accentColor(self.store.setting.accentColor).autoBlur(radius: blurRadius) } .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( keyword: $store.keyword, translations: store.tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + showsImages: store.setting.showsImagesInTags, isEnabled: store.setting.showsTagsSearchSuggestion ) } .onSubmit(of: .search) { @@ -94,7 +92,6 @@ struct DetailSearchView_Previews: PreviewProvider { DetailSearchView( store: .init(initialState: .init(), reducer: DetailSearchReducer.init), keyword: .init(), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 23c9560eb..281cbf131 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -12,16 +12,14 @@ import ReadingFeature public struct DetailView: View { @Bindable var store: StoreOf let gid: String - @Binding var setting: Setting let blurRadius: Double public init( store: StoreOf, gid: String, - setting: Binding, blurRadius: Double + blurRadius: Double ) { self.store = store self.gid = gid - _setting = setting self.blurRadius = blurRadius } @@ -32,7 +30,7 @@ public struct DetailView: View { .animation(.default, value: store.galleryDetail) .onAppear { DispatchQueue.main.async { - store.send(.onAppear(gid, setting.showsNewDawnGreeting)) + store.send(.onAppear(gid, store.setting.showsNewDawnGreeting)) } } .onChange(of: store.galleryDetail) { _, _ in @@ -69,7 +67,7 @@ private extension DetailView { isPreparingDownload: store.isPreparingDownload, canDownload: !store.gallery.id.isEmpty && (AppUtil.galleryHost == .ehentai || CookieUtil.didLogin), - displaysJapaneseTitle: setting.displaysJapaneseTitle, + displaysJapaneseTitle: store.setting.displaysJapaneseTitle, showFullTitle: store.showsFullTitle, showFullTitleAction: { store.send(.toggleShowFullTitle) }, downloadAction: { handleDownloadAction() }, @@ -113,12 +111,12 @@ private extension DetailView { ) if !store.galleryTags.isEmpty { TagsSection( - tags: store.galleryTags, showsImages: setting.showsImagesInTags, + tags: store.galleryTags, showsImages: store.setting.showsImagesInTags, voteTagAction: { store.send(.voteTag($0, $1)) }, navigateSearchAction: { store.send(.delegate(.pushDetailSearch($0))) }, navigateTagDetailAction: { store.send(.tagDetailButtonTapped($0)) }, translateAction: { - store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) } ) .padding(.horizontal) @@ -197,7 +195,7 @@ private extension DetailView { cancelAction: { store.send(.destination(.dismiss)) }, onAppearAction: { store.send(.onPostCommentAppear) } ) - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .sheet(item: $store.destination.newDawn) { greeting in @@ -218,10 +216,9 @@ private extension DetailView { ReadingView( store: store, gid: gid, - setting: $setting, blurRadius: blurRadius ) - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .sheet( @@ -234,7 +231,7 @@ private extension DetailView { galleryURL: galleryURL, archiveURL: archiveURL ) - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } } @@ -247,14 +244,14 @@ private extension DetailView { token: self.store.gallery.token, blurRadius: blurRadius ) - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .sheet( item: $store.scope(state: \.destination?.folderManager, action: \.destination.folderManager) ) { store in FolderManagerView(store: store) - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .sheet(item: $store.destination.share, id: \.absoluteString) { url in @@ -316,7 +313,6 @@ struct DetailView_Previews: PreviewProvider { DetailView( store: .init(initialState: .init(gallery: .preview), reducer: DetailReducer.init), gid: .init(), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/DetailFeature/GalleryDestination.swift b/AppPackage/Sources/DetailFeature/GalleryDestination.swift index cff425701..927b83c49 100644 --- a/AppPackage/Sources/DetailFeature/GalleryDestination.swift +++ b/AppPackage/Sources/DetailFeature/GalleryDestination.swift @@ -8,31 +8,30 @@ import ComposableArchitecture @ViewBuilder public func galleryDestination( _ store: StoreOf, - setting: Binding, blurRadius: Double ) -> some View { switch store.case { case .detail(let detailStore): DetailView( store: detailStore, gid: detailStore.gid, - setting: setting, blurRadius: blurRadius + blurRadius: blurRadius ) case .previews(let previewsStore): PreviewsView( store: previewsStore, gid: previewsStore.gid, - setting: setting, blurRadius: blurRadius + blurRadius: blurRadius ) case .comments(let commentsStore): CommentsView( store: commentsStore, gid: commentsStore.gid, token: commentsStore.token, apiKey: commentsStore.apiKey, galleryURL: commentsStore.galleryURL, - comments: commentsStore.comments, setting: setting, + comments: commentsStore.comments, blurRadius: blurRadius ) case .detailSearch(let searchStore): DetailSearchView( store: searchStore, keyword: searchStore.keyword, - setting: setting, blurRadius: blurRadius + blurRadius: blurRadius ) case .galleryInfos(let infosStore): GalleryInfosView( @@ -48,7 +47,6 @@ public struct GalleryNavigationContainer private let statePath: KeyPath> private let actionPath: CaseKeyPath> - @Binding private var setting: Setting private let blurRadius: Double private let root: Root @@ -56,14 +54,12 @@ public struct GalleryNavigationContainer, state statePath: KeyPath>, action actionPath: CaseKeyPath>, - setting: Binding, blurRadius: Double, @ViewBuilder root: () -> Root ) { self.store = store self.statePath = statePath self.actionPath = actionPath - _setting = setting self.blurRadius = blurRadius self.root = root() } @@ -73,7 +69,7 @@ public struct GalleryNavigationContainer private let gid: String - @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - gid: String, setting: Binding, blurRadius: Double + gid: String, blurRadius: Double ) { self.store = store self.gid = gid - _setting = setting self.blurRadius = blurRadius } @@ -66,9 +64,9 @@ struct PreviewsView: View { ) { store in ReadingView( store: store, - gid: store.gallery.id, setting: $setting, blurRadius: blurRadius + gid: store.gallery.id, blurRadius: blurRadius ) - .accentColor(setting.accentColor) + .accentColor(store.setting.accentColor) .autoBlur(radius: blurRadius) } .onAppear { @@ -84,7 +82,6 @@ struct PreviewsView_Previews: PreviewProvider { PreviewsView( store: .init(initialState: .init(gallery: .preview), reducer: PreviewsReducer.init), gid: .init(), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift index 4c180d8b8..22eb0f17f 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift @@ -15,6 +15,7 @@ public struct DownloadInspectorReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting @Presents public var toast: AppAlertState? public var gid = "" public var inspection: DownloadInspection? diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift index 39e20276a..a685ca23e 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -37,6 +37,7 @@ public struct DownloadsReducer: Sendable { @ObservableState public struct State: Equatable { + @SharedReader(.setting) public var setting: Setting public var path = StackState() @Presents public var destination: Destination.State? @Presents public var alert: AppAlertState? diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index 5b73a3289..00da2683b 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -13,16 +13,13 @@ struct DownloadInspectorView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @Bindable private var store: StoreOf - private let setting: Setting private let blurRadius: Double init( store: StoreOf, - setting: Setting, blurRadius: Double ) { self.store = store - self.setting = setting self.blurRadius = blurRadius } @@ -42,11 +39,11 @@ struct DownloadInspectorView: View { GalleryDetailCell( gallery: inspection.download.gallery, coverSource: .static(inspection.coverURL), - setting: setting, + setting: store.setting, translateAction: { store.tagTranslator.lookup( word: $0, - returnOriginal: !setting.translatesTags + returnOriginal: !store.setting.translatesTags ) }, downloadBadge: inspection.download.badge @@ -291,8 +288,8 @@ private extension View { struct DownloadListRow: View { @SharedReader(.tagTranslator) private var tagTranslator: TagTranslator + @SharedReader(.setting) private var setting: Setting let download: DownloadedGallery - let setting: Setting let openAction: () -> Void var body: some View { diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift index a9672e70d..03fe3ba3c 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -11,16 +11,13 @@ import SFSafeSymbolsExt public struct DownloadsView: View { @Bindable private var store: StoreOf - @Binding private var setting: Setting private let blurRadius: Double public init( store: StoreOf, - setting: Binding, blurRadius: Double ) { self.store = store - _setting = setting self.blurRadius = blurRadius } @@ -29,7 +26,6 @@ public struct DownloadsView: View { store: store, state: \.path, action: \.path, - setting: $setting, blurRadius: blurRadius ) { contentView @@ -65,7 +61,6 @@ public struct DownloadsView: View { NavigationStack { DownloadInspectorView( store: store, - setting: setting, blurRadius: blurRadius ) } @@ -73,9 +68,9 @@ public struct DownloadsView: View { } .sheet( item: $store.scope(state: \.destination?.folderManager, action: \.destination.folderManager) - ) { store in - FolderManagerView(store: store) - .accentColor(setting.accentColor) + ) { folderStore in + FolderManagerView(store: folderStore) + .accentColor(store.setting.accentColor) .autoBlur(radius: blurRadius) } .fullScreenCover( @@ -84,10 +79,9 @@ public struct DownloadsView: View { ReadingView( store: store, gid: store.gallery.id, - setting: $setting, blurRadius: blurRadius ) - .accentColor(setting.accentColor) + .accentColor(store.setting.accentColor) .autoBlur(radius: blurRadius) } .onAppear { @@ -118,8 +112,7 @@ private extension DownloadsView { List { ForEach(store.filteredDownloads) { download in DownloadListRow( - download: download, - setting: setting + download: download ) { store.send(.openReading(download.gid)) } @@ -135,7 +128,7 @@ private extension DownloadsView { systemSymbol: .listBulletRectanglePortrait ) } - .tint(setting.accentColor) + .tint(store.setting.accentColor) if canMove(download) { Button { @@ -328,7 +321,6 @@ struct DownloadsView_Previews: PreviewProvider { static var previews: some View { DownloadsView( store: .init(initialState: .init(), reducer: DownloadsReducer.init), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index edcbfea48..8ea82f573 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -31,6 +31,7 @@ public struct FavoritesReducer: Sendable { public struct State: Equatable { @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator @SharedReader(.user) public var user: User + @SharedReader(.setting) public var setting: Setting public var path = StackState() @Presents public var destination: Destination.State? public var keyword = "" diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index 7cb4bbbc9..e799d5765 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -12,15 +12,13 @@ import DetailFeature public struct FavoritesView: View { @Bindable private var store: StoreOf - @Binding private var setting: Setting private let blurRadius: Double public init( store: StoreOf, - setting: Binding, blurRadius: Double + blurRadius: Double ) { self.store = store - _setting = setting self.blurRadius = blurRadius } @@ -34,14 +32,13 @@ public struct FavoritesView: View { store: store, state: \.path, action: \.path, - setting: $setting, blurRadius: blurRadius ) { ZStack { if CookieUtil.didLogin { GenericList( galleries: store.galleries ?? [], - setting: setting, + setting: store.setting, pageNumber: store.pageNumber, loadingState: store.loadingState ?? .idle, footerLoadingState: store.footerLoadingState ?? .idle, @@ -49,7 +46,7 @@ public struct FavoritesView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.galleryTapped($0)) }, translateAction: { - store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) }, downloadBadges: store.downloadBadges ) @@ -64,7 +61,7 @@ public struct FavoritesView: View { self.store.send(.destination(.dismiss)) self.store.send(.fetchGalleries(keyword)) } - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .sheet( @@ -76,14 +73,14 @@ public struct FavoritesView: View { navigation: store.navigation, seekAction: { store.send(.performSeek($0)) } ) - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( keyword: $store.keyword, translations: store.tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + showsImages: store.setting.showsImagesInTags, isEnabled: store.setting.showsTagsSearchSuggestion ) } .onSubmit(of: .search) { @@ -128,7 +125,6 @@ struct FavoritesView_Previews: PreviewProvider { static var previews: some View { FavoritesView( store: .init(initialState: .init(), reducer: FavoritesReducer.init), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift index c8701f77a..16edc7dc2 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -28,6 +28,7 @@ public struct FrontpageReducer: Sendable { public struct State: Equatable { @SharedReader(.globalFilter) public var globalFilter: Filter @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting @Presents public var destination: Destination.State? public var keyword = "" diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index fe2bc617a..af0752d9e 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -11,22 +11,20 @@ import FiltersFeature struct FrontpageView: View { @Bindable private var store: StoreOf - @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - setting: Binding, blurRadius: Double + blurRadius: Double ) { self.store = store - _setting = setting self.blurRadius = blurRadius } var body: some View { GenericList( galleries: store.filteredGalleries, - setting: setting, + setting: store.setting, pageNumber: store.pageNumber, loadingState: store.loadingState, footerLoadingState: store.footerLoadingState, @@ -34,7 +32,7 @@ struct FrontpageView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) } ) .sheet( @@ -52,7 +50,7 @@ struct FrontpageView: View { navigation: store.navigation, seekAction: { store.send(.performSeek($0)) } ) - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .searchable(text: $store.keyword, prompt: .filter) @@ -84,7 +82,6 @@ struct FrontpageView_Previews: PreviewProvider { NavigationStack { FrontpageView( store: .init(initialState: .init(), reducer: FrontpageReducer.init), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index 00f925a4b..2e2a2e5f6 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -31,6 +31,7 @@ public struct HistoryReducer: Sendable { @ObservableState public struct State: Equatable { @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting @Presents public var confirmationDialog: ConfirmationDialogState? public var keyword = "" public var downloadBadges = [String: DownloadBadge]() diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index de2c96e8a..ff7be9cc8 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -9,22 +9,20 @@ import GalleryListComponents struct HistoryView: View { @Bindable private var store: StoreOf - @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - setting: Binding, blurRadius: Double + blurRadius: Double ) { self.store = store - _setting = setting self.blurRadius = blurRadius } var body: some View { GenericList( galleries: store.filteredGalleries, - setting: setting, + setting: store.setting, pageNumber: PageNumber(isNextButtonEnabled: store.hasMoreHistory), loadingState: store.loadingState, footerLoadingState: store.footerLoadingState, @@ -35,7 +33,7 @@ struct HistoryView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) }, downloadBadges: store.downloadBadges ) @@ -72,7 +70,6 @@ struct HistoryView_Previews: PreviewProvider { NavigationStack { HistoryView( store: .init(initialState: .init(), reducer: HistoryReducer.init), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/HomeFeature/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift index 0a19a4361..138a4915a 100644 --- a/AppPackage/Sources/HomeFeature/HomeView.swift +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -10,15 +10,13 @@ import DetailFeature public struct HomeView: View { @Bindable private var store: StoreOf - @Binding private var setting: Setting private let blurRadius: Double public init( store: StoreOf, - setting: Binding, blurRadius: Double + blurRadius: Double ) { self.store = store - _setting = setting self.blurRadius = blurRadius } @@ -92,27 +90,27 @@ public struct HomeView: View { switch store.case { case .frontpage(let store): FrontpageView( - store: store, setting: $setting, blurRadius: blurRadius + store: store, blurRadius: blurRadius ) case .popular(let store): PopularView( - store: store, setting: $setting, blurRadius: blurRadius + store: store, blurRadius: blurRadius ) case .toplists(let store): ToplistsView( - store: store, setting: $setting, blurRadius: blurRadius + store: store, blurRadius: blurRadius ) case .watched(let store): WatchedView( - store: store, setting: $setting, blurRadius: blurRadius + store: store, blurRadius: blurRadius ) case .history(let store): HistoryView( - store: store, setting: $setting, blurRadius: blurRadius + store: store, blurRadius: blurRadius ) case .gallery(let store): galleryDestination( - store, setting: $setting, blurRadius: blurRadius + store, blurRadius: blurRadius ) } } @@ -184,7 +182,6 @@ struct HomeView_Previews: PreviewProvider { static var previews: some View { HomeView( store: .init(initialState: .init(), reducer: HomeReducer.init), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift index fb5a9d911..32efe34df 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -25,6 +25,7 @@ public struct PopularReducer: Sendable { public struct State: Equatable { @SharedReader(.globalFilter) public var globalFilter: Filter @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting @Presents public var destination: Destination.State? public var keyword = "" diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index 6d1cbb08d..4f534d597 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -10,28 +10,26 @@ import FiltersFeature struct PopularView: View { @Bindable private var store: StoreOf - @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - setting: Binding, blurRadius: Double + blurRadius: Double ) { self.store = store - _setting = setting self.blurRadius = blurRadius } var body: some View { GenericList( galleries: store.filteredGalleries, - setting: setting, pageNumber: nil, + setting: store.setting, pageNumber: nil, loadingState: store.loadingState, footerLoadingState: .idle, fetchAction: { store.send(.fetchGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) } ) .sheet( @@ -66,7 +64,6 @@ struct PopularView_Previews: PreviewProvider { NavigationStack { PopularView( store: .init(initialState: .init(), reducer: PopularReducer.init), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index ecdeb4ed7..defc668a5 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -23,6 +23,7 @@ public struct ToplistsReducer: Sendable { @ObservableState public struct State: Equatable { @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting public var keyword = "" public var jumpPageIndex = "" @Presents public var alert: AppAlertState? diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index fb8a2e799..8146fb9fa 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -9,15 +9,13 @@ import GalleryListComponents struct ToplistsView: View { @Bindable private var store: StoreOf - @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - setting: Binding, blurRadius: Double + blurRadius: Double ) { self.store = store - _setting = setting self.blurRadius = blurRadius } @@ -28,7 +26,7 @@ struct ToplistsView: View { var body: some View { GenericList( galleries: store.filteredGalleries ?? [], - setting: setting, + setting: store.setting, pageNumber: store.pageNumber, loadingState: store.loadingState ?? .idle, footerLoadingState: store.footerLoadingState ?? .idle, @@ -36,7 +34,7 @@ struct ToplistsView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) } ) .searchable(text: $store.keyword, prompt: .filter) @@ -73,7 +71,6 @@ struct ToplistsView_Previews: PreviewProvider { NavigationStack { ToplistsView( store: .init(initialState: .init(), reducer: ToplistsReducer.init), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift index ffa24a6be..24c0295f4 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -30,6 +30,7 @@ public struct WatchedReducer: Sendable { public struct State: Equatable { @SharedReader(.watchedFilter) public var watchedFilter: Filter @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting @Presents public var destination: Destination.State? public var keyword = "" diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index aadafe780..b97076966 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -12,15 +12,13 @@ import QuickSearchFeature struct WatchedView: View { @Bindable private var store: StoreOf - @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - setting: Binding, blurRadius: Double + blurRadius: Double ) { self.store = store - _setting = setting self.blurRadius = blurRadius } @@ -29,7 +27,7 @@ struct WatchedView: View { if CookieUtil.didLogin { GenericList( galleries: store.galleries, - setting: setting, + setting: store.setting, pageNumber: store.pageNumber, loadingState: store.loadingState, footerLoadingState: store.footerLoadingState, @@ -37,7 +35,7 @@ struct WatchedView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) }, downloadBadges: store.downloadBadges ) @@ -52,7 +50,7 @@ struct WatchedView: View { self.store.send(.destination(.dismiss)) self.store.send(.fetchGalleries(keyword)) } - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .sheet( @@ -70,14 +68,14 @@ struct WatchedView: View { navigation: store.navigation, seekAction: { store.send(.performSeek($0)) } ) - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( keyword: $store.keyword, translations: store.tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + showsImages: store.setting.showsImagesInTags, isEnabled: store.setting.showsTagsSearchSuggestion ) } .onSubmit(of: .search) { @@ -117,7 +115,6 @@ struct WatchedView_Previews: PreviewProvider { NavigationStack { WatchedView( store: .init(initialState: .init(), reducer: WatchedReducer.init), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 77e2e3bcf..3894f4c4e 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -49,6 +49,16 @@ public struct ReadingReducer: Sendable { public var gallery: Gallery = .empty public var language: Language? + @Shared(.setting) public var setting: Setting + /// A write-through view of `setting` for the reading-setting editor's SwiftUI bindings. Mirrors + /// `SettingReducer.State.settingBinding`: `@Shared`'s own value setter is deprecated (it can't + /// take exclusive access), so bind `$store.settingBinding.x` — its setter routes writes through + /// `withLock`, flowing through `BindingReducer` — while reads use `setting`. + public var settingBinding: Setting { + get { setting } + set { $setting.withLock { $0 = newValue } } + } + public var readingProgress: Int = .zero // The latest page awaiting a debounced persist to `@Shared(.galleryHistory)`; kept separate // from `readingProgress` (which seeds the slider) so tracking it never feeds back into the pager. diff --git a/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift b/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift index 9cb1bc883..386bc92fa 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift @@ -7,7 +7,7 @@ extension ReadingView { let singleTap = TapGesture(count: 1) .onEnded { gestureHandler.onSingleTapGestureEnded( - readingDirection: setting.readingDirection, + readingDirection: store.setting.readingDirection, setPageIndexOffsetAction: { let newValue = page.index + $0 page.update(.new(index: newValue)) @@ -18,8 +18,8 @@ extension ReadingView { let doubleTap = TapGesture(count: 2) .onEnded { gestureHandler.onDoubleTapGestureEnded( - scaleMaximum: setting.maximumScaleFactor, - doubleTapScale: setting.doubleTapScaleFactor + scaleMaximum: store.setting.maximumScaleFactor, + doubleTapScale: store.setting.doubleTapScaleFactor ) } return ExclusiveGesture(doubleTap, singleTap) @@ -28,12 +28,12 @@ extension ReadingView { MagnificationGesture() .onChanged { gestureHandler.onMagnificationGestureChanged( - value: $0, scaleMaximum: setting.maximumScaleFactor + value: $0, scaleMaximum: store.setting.maximumScaleFactor ) } .onEnded { gestureHandler.onMagnificationGestureEnded( - value: $0, scaleMaximum: setting.maximumScaleFactor + value: $0, scaleMaximum: store.setting.maximumScaleFactor ) } } diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index c6e48a6d8..176158881 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -18,7 +18,6 @@ public struct ReadingView: View { @Bindable var store: StoreOf let gid: String - @Binding var setting: Setting let blurRadius: Double @State private var liveTextHandler = LiveTextHandler() @@ -29,11 +28,10 @@ public struct ReadingView: View { public init( store: StoreOf, - gid: String, setting: Binding, blurRadius: Double + gid: String, blurRadius: Double ) { self.store = store self.gid = gid - _setting = setting self.blurRadius = blurRadius } @@ -64,12 +62,12 @@ public struct ReadingView: View { .sheet(item: $store.destination.readingSetting, id: \.id) { _ in NavigationStack { ReadingSettingView( - readingDirection: $setting.readingDirection, - prefetchLimit: $setting.prefetchLimit, - enablesLandscape: $setting.enablesLandscape, - contentDividerHeight: $setting.contentDividerHeight, - maximumScaleFactor: $setting.maximumScaleFactor, - doubleTapScaleFactor: $setting.doubleTapScaleFactor + readingDirection: $store.settingBinding.readingDirection, + prefetchLimit: $store.settingBinding.prefetchLimit, + enablesLandscape: $store.settingBinding.enablesLandscape, + contentDividerHeight: $store.settingBinding.contentDividerHeight, + maximumScaleFactor: $store.settingBinding.maximumScaleFactor, + doubleTapScaleFactor: $store.settingBinding.doubleTapScaleFactor ) .toolbar { if !DeviceUtil.isPad && DeviceUtil.isLandscape { @@ -83,13 +81,13 @@ public struct ReadingView: View { } } } - .accentColor(setting.accentColor) - .tint(setting.accentColor) + .accentColor(store.setting.accentColor) + .tint(store.setting.accentColor) .autoBlur(radius: blurRadius) } .sheet(item: $store.destination.share, id: \.id) { shareItemBox in ActivityView(activityItems: [shareItemBox.wrappedValue.associatedValue]) - .accentColor(setting.accentColor) + .accentColor(store.setting.accentColor) .autoBlur(radius: blurRadius) } .toast($store.scope(state: \.toast, action: \.toast)) @@ -107,7 +105,7 @@ public struct ReadingView: View { liveTextHandler.cancelRequests() setAutoPlayPolocy(.off) } - .onAppear { store.send(.onAppear(gid, setting.enablesLandscape)) } + .onAppear { store.send(.onAppear(gid, store.setting.enablesLandscape)) } } var content: some View { @@ -118,15 +116,15 @@ public struct ReadingView: View { backgroundColor.ignoresSafeArea() ZStack { - if setting.readingDirection == .vertical { + if store.setting.readingDirection == .vertical { AdvancedList( page: page, data: store.state.containerDataSource( - setting: setting, + setting: store.setting, isLandscape: DeviceUtil.isLandscape ), id: \.self, - spacing: setting.contentDividerHeight, + spacing: store.setting.contentDividerHeight, gesture: SimultaneousGesture(magnificationGesture, tapGesture), content: imageStack ) @@ -135,13 +133,13 @@ public struct ReadingView: View { Pager( page: page, data: store.state.containerDataSource( - setting: setting, + setting: store.setting, isLandscape: DeviceUtil.isLandscape ), id: \.self, content: imageStack ) - .horizontal(setting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) + .horizontal(store.setting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) .swipeInteractionArea(.allAvailable) .allowsDragging(gestureHandler.scale == 1) } @@ -160,7 +158,7 @@ public struct ReadingView: View { ControlPanel( showsPanel: $store.showsPanel, showsSliderPreview: $store.showsSliderPreview, - sliderValue: $bindablePageHandler.sliderValue, setting: $setting, + sliderValue: $bindablePageHandler.sliderValue, setting: $store.settingBinding, enablesLiveText: $bindableLiveTextHandler.enablesLiveText, autoPlayPolicy: .init(get: { autoPlayHandler.policy }, set: { setAutoPlayPolocy($0) }), range: 1...Float(store.gallery.pageCount), @@ -188,7 +186,7 @@ public struct ReadingView: View { } } // Orientation - .onChange(of: setting.enablesLandscape) { _, newValue in + .onChange(of: store.setting.enablesLandscape) { _, newValue in store.send(.setOrientationPortrait(!newValue)) } } @@ -199,7 +197,7 @@ public struct ReadingView: View { // Page .onChange(of: page.index) { _, newValue in let newValue = pageHandler.mapFromPager( - index: newValue, pageCount: store.gallery.pageCount, setting: setting + index: newValue, pageCount: store.gallery.pageCount, setting: store.setting ) pageHandler.sliderValue = .init(newValue) store.send(.syncReadingProgress(.init(newValue))) @@ -225,12 +223,14 @@ public struct ReadingView: View { } @ViewBuilder private func imageStack(index: Int) -> some View { + let setting = store.setting let imageStackConfig = store.state.imageContainerConfigs( index: index, setting: setting, isLandscape: DeviceUtil.isLandscape ) - let isDualPage = setting.enablesDualPageMode && setting.readingDirection != .vertical && DeviceUtil.isLandscape + let isDualPage = setting.enablesDualPageMode + && setting.readingDirection != .vertical && DeviceUtil.isLandscape let dataSource = store.state.containerDataSource(setting: setting, isLandscape: DeviceUtil.isLandscape) let activeStackIndex = dataSource.indices.contains(page.index) ? dataSource[page.index] : nil HorizontalImageStack( @@ -248,7 +248,7 @@ public struct ReadingView: View { liveTextTapAction: liveTextHandler.setFocusedLiveTextGroup, fetchAction: { store.send(.fetchImageURLs($0)) }, refetchAction: { store.send(.refetchImageURLs($0)) }, - prefetchAction: { store.send(.prefetchImages($0, setting.prefetchLimit)) }, + prefetchAction: { store.send(.prefetchImages($0, store.setting.prefetchLimit)) }, loadRetryAction: { store.send(.onWebImageRetry($0)) }, loadSucceededAction: { store.send(.onWebImageSucceeded($0)) }, loadFailedAction: { store.send(.onWebImageFailed($0)) }, @@ -263,7 +263,7 @@ public struct ReadingView: View { extension ReadingView { func setPageIndex(sliderValue: Float) { let newValue = pageHandler.mapToPager( - index: .init(sliderValue), setting: setting + index: .init(sliderValue), setting: store.setting ) if page.index != newValue { page.update(.new(index: newValue)) @@ -341,7 +341,6 @@ struct ReadingView_Previews: PreviewProvider { ReadingView( store: .init(initialState: .init(gallery: .empty), reducer: ReadingReducer.init), gid: .init(), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift index 6f2daa34a..c70958dbf 100644 --- a/AppPackage/Sources/SearchFeature/SearchReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -32,6 +32,7 @@ public struct SearchReducer: Sendable { public struct State: Equatable { @SharedReader(.searchFilter) public var searchFilter: Filter @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting @Presents public var destination: Destination.State? public var keyword = "" public var lastKeyword = "" diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift index 86fd8d060..e6f3667be 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -37,6 +37,7 @@ public struct SearchRootReducer: Sendable { @Shared(.historyKeywords) public var historyKeywords: [String] @Shared(.quickSearchWords) public var quickSearchWords: [QuickSearchWord] @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting public init() {} diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift index 5387d2b11..1f1b24489 100644 --- a/AppPackage/Sources/SearchFeature/SearchRootView.swift +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -10,15 +10,13 @@ import DetailFeature public struct SearchRootView: View { @Bindable private var store: StoreOf - @Binding private var setting: Setting private let blurRadius: Double public init( store: StoreOf, - setting: Binding, blurRadius: Double + blurRadius: Double ) { self.store = store - _setting = setting self.blurRadius = blurRadius } @@ -55,14 +53,14 @@ public struct SearchRootView: View { self.store.send(.pushSearch) } } - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( keyword: $store.keyword, translations: store.tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + showsImages: store.setting.showsImagesInTags, isEnabled: store.setting.showsTagsSearchSuggestion ) } .onSubmit(of: .search) { @@ -86,11 +84,11 @@ public struct SearchRootView: View { switch store.case { case .search(let store): SearchView( - store: store, setting: $setting, blurRadius: blurRadius + store: store, blurRadius: blurRadius ) case .gallery(let store): galleryDestination( - store, setting: $setting, blurRadius: blurRadius + store, blurRadius: blurRadius ) } } @@ -262,7 +260,6 @@ struct SearchRootView_Previews: PreviewProvider { static var previews: some View { SearchRootView( store: .init(initialState: .init(), reducer: SearchRootReducer.init), - setting: .constant(.init()), blurRadius: 0 ) } diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift index 896aaf8bc..75a57de15 100644 --- a/AppPackage/Sources/SearchFeature/SearchView.swift +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -11,22 +11,20 @@ import QuickSearchFeature struct SearchView: View { @Bindable private var store: StoreOf - @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - setting: Binding, blurRadius: Double + blurRadius: Double ) { self.store = store - _setting = setting self.blurRadius = blurRadius } var body: some View { GenericList( galleries: store.galleries, - setting: setting, + setting: store.setting, pageNumber: store.pageNumber, loadingState: store.loadingState, footerLoadingState: store.footerLoadingState, @@ -34,7 +32,7 @@ struct SearchView: View { fetchMoreAction: { store.send(.fetchMoreGalleries) }, navigateAction: { store.send(.delegate(.pushDetail($0))) }, translateAction: { - store.tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) }, downloadBadges: store.downloadBadges ) @@ -45,14 +43,14 @@ struct SearchView: View { self.store.send(.destination(.dismiss)) self.store.send(.fetchGalleries(keyword)) } - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .sheet( item: $store.scope(state: \.destination?.filters, action: \.destination.filters) ) { store in FiltersView(store: store) - .accentColor(setting.accentColor).autoBlur(radius: blurRadius) + .accentColor(self.store.setting.accentColor).autoBlur(radius: blurRadius) } .sheet( item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) @@ -63,14 +61,14 @@ struct SearchView: View { navigation: store.navigation, seekAction: { store.send(.performSeek($0)) } ) - .accentColor(setting.accentColor) + .accentColor(self.store.setting.accentColor) .autoBlur(radius: blurRadius) } .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( keyword: $store.keyword, translations: store.tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion + showsImages: store.setting.showsImagesInTags, isEnabled: store.setting.showsTagsSearchSuggestion ) } .onSubmit(of: .search) { @@ -109,7 +107,6 @@ struct SearchView_Previews: PreviewProvider { static var previews: some View { SearchView( store: .init(initialState: .init(), reducer: SearchReducer.init), - setting: .constant(.init()), blurRadius: 0 ) } From 10757b103e06daeed8d14f511034d4bfd583d70a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 02:18:35 +0800 Subject: [PATCH 569/614] Drop setting-derived onAppear action payloads --- .../Sources/DetailFeature/DetailReducer+Actions.swift | 6 ++---- AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift | 2 +- AppPackage/Sources/DetailFeature/DetailReducer.swift | 3 +-- AppPackage/Sources/DetailFeature/DetailView.swift | 2 +- AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift | 4 ++-- AppPackage/Sources/ReadingFeature/ReadingReducer.swift | 2 +- AppPackage/Sources/ReadingFeature/ReadingView.swift | 2 +- .../DownloadsFeatureTests/DetailReducerObserveTests.swift | 3 +-- 8 files changed, 10 insertions(+), 14 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index 0aea27e1f..e2aed90f8 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -67,8 +67,8 @@ extension DetailReducer { await send(.setPostCommentFocused(true)) } - case .onAppear(let gid, let showsNewDawnGreeting): - return handleOnAppear(gid: gid, showsNewDawnGreeting: showsNewDawnGreeting, state: &state) + case .onAppear(let gid): + return handleOnAppear(gid: gid, state: &state) default: return .none @@ -78,11 +78,9 @@ extension DetailReducer { private func handleOnAppear( gid: String, - showsNewDawnGreeting: Bool, state: inout State ) -> Effect { state.gid = gid - state.showsNewDawnGreeting = showsNewDawnGreeting state.isPreparingDownload = false state.hasLoadedDownloadBadge = false state.didRunLaunchAutomation = false diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift index 4ecd3d8dd..dca06ef1d 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift @@ -42,7 +42,7 @@ extension DetailReducer { } if let greeting = response.greeting { effects.append(.send(.syncGreeting(greeting))) - if !greeting.gainedNothing && state.showsNewDawnGreeting { + if !greeting.gainedNothing && state.setting.showsNewDawnGreeting { effects.append(.send(.presentNewDawn(greeting))) } } diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index 0e57f7a94..56bb45689 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -65,7 +65,6 @@ public struct DetailReducer: Sendable { @Presents public var alert: AppAlertState? public var commentContent = "" public var postCommentFocused = false - public var showsNewDawnGreeting = false public var showsUserRating = false public var showsFullTitle = false public var userRating = 0 @@ -134,7 +133,7 @@ public struct DetailReducer: Sendable { case deleteDownloadButtonTapped case retryDownloadButtonTapped(DownloadStartMode) case onPostCommentAppear - case onAppear(String, Bool) + case onAppear(String) case toggleShowFullTitle case toggleShowUserRating case setPostCommentFocused(Bool) diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 281cbf131..5e1bd543a 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -30,7 +30,7 @@ public struct DetailView: View { .animation(.default, value: store.galleryDetail) .onAppear { DispatchQueue.main.async { - store.send(.onAppear(gid, store.setting.showsNewDawnGreeting)) + store.send(.onAppear(gid)) } } .onChange(of: store.galleryDetail) { _, _ in diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift index 906d20b56..294e1996c 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -86,12 +86,12 @@ extension ReadingReducer { flushReadingProgress(state) return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) - case .onAppear(let gid, let enablesLandscape): + case .onAppear(let gid): var effects: [Effect] = [ .send(.observeDownloads(gid)), .send(.loadLocalPageURLs(gid)) ] - if enablesLandscape { + if state.setting.enablesLandscape { effects.append(.send(.setOrientationPortrait(false))) } return .merge(effects) diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 3894f4c4e..09013f27b 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -180,7 +180,7 @@ public struct ReadingReducer: Sendable { case toggleShowsPanel case setOrientationPortrait(Bool) case onPerformDismiss - case onAppear(String, Bool) + case onAppear(String) case onWebImageRetry(Int) case onWebImageSucceeded(Int) diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 176158881..a5dc41f1f 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -105,7 +105,7 @@ public struct ReadingView: View { liveTextHandler.cancelRequests() setAutoPlayPolocy(.off) } - .onAppear { store.send(.onAppear(gid, store.setting.enablesLandscape)) } + .onAppear { store.send(.onAppear(gid)) } } var content: some View { diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift index 1006c1b41..c5a3caf06 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift @@ -23,9 +23,8 @@ struct DetailReducerObserveTests: DownloadFeatureTestCase { let store = makeObserveTestStore(gallery: gallery, detail: detail, stream: stream) store.exhaustivity = .off - await store.send(.onAppear(gallery.gid, false)) { + await store.send(.onAppear(gallery.gid)) { $0.gid = gallery.gid - $0.showsNewDawnGreeting = false $0.hasLoadedDownloadBadge = false $0.didRunLaunchAutomation = false } From 6e61f237447d7c7a2ed8bcadf52e95ffe6180eb3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 02:20:13 +0800 Subject: [PATCH 570/614] Drop now-unused AppModels import in gallery hub --- AppPackage/Sources/DetailFeature/GalleryDestination.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/AppPackage/Sources/DetailFeature/GalleryDestination.swift b/AppPackage/Sources/DetailFeature/GalleryDestination.swift index 927b83c49..13ded2b05 100644 --- a/AppPackage/Sources/DetailFeature/GalleryDestination.swift +++ b/AppPackage/Sources/DetailFeature/GalleryDestination.swift @@ -1,5 +1,4 @@ import SwiftUI -import AppModels import ComposableArchitecture // Builds the view for a single gallery stack element. Shared by every gallery host (and reused by the From 2751772dfb47c91a4d1441dfbc13c070a17d7913 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 08:25:18 +0800 Subject: [PATCH 571/614] Clamp scale factors in the Setting model --- .../AppModels/Persistent/Setting.swift | 20 +++++++++-- .../SettingFeature/SettingReducer+Body.swift | 12 ------- .../SettingScaleClampTests.swift | 34 +++++++++++++++++++ 3 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 AppPackage/Tests/AppModelsTests/SettingScaleClampTests.swift diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index 13731a185..38a7bfee5 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -98,8 +98,24 @@ public struct Setting: Codable, Equatable, Sendable { public var enablesDualPageMode = false public var exceptCover = false public var contentDividerHeight: Double = 0 - public var maximumScaleFactor: Double = 3 - public var doubleTapScaleFactor: Double = 2 + // The two scale factors are mutually clamped so `doubleTapScaleFactor <= maximumScaleFactor` + // always holds, regardless of the write path — SettingReducer's editor or the reader sheet's + // direct `@Shared(.setting)` binding. Keeping the invariant on the model (not in a reducer's + // `BindingReducer`) is what lets a write skip the reducer and still stay consistent. + public var maximumScaleFactor: Double = 3 { + didSet { + if doubleTapScaleFactor > maximumScaleFactor { + doubleTapScaleFactor = maximumScaleFactor + } + } + } + public var doubleTapScaleFactor: Double = 2 { + didSet { + if maximumScaleFactor < doubleTapScaleFactor { + maximumScaleFactor = doubleTapScaleFactor + } + } + } // Downloads public static let downloadThreadLimitDefaultValue = 1 diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index f615b54ee..d5e41880d 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -50,18 +50,6 @@ extension SettingReducer { await appDelegateClient.setPortraitOrientationMask() } } - .onChange(of: \.setting.maximumScaleFactor) { _, state in - if state.setting.doubleTapScaleFactor > state.setting.maximumScaleFactor { - state.$setting.withLock { $0.doubleTapScaleFactor = $0.maximumScaleFactor } - } - return .none - } - .onChange(of: \.setting.doubleTapScaleFactor) { _, state in - if state.setting.maximumScaleFactor < state.setting.doubleTapScaleFactor { - state.$setting.withLock { $0.maximumScaleFactor = $0.doubleTapScaleFactor } - } - return .none - } .onChange(of: \.setting.bypassesSNIFiltering) { _, state in .merge( .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), diff --git a/AppPackage/Tests/AppModelsTests/SettingScaleClampTests.swift b/AppPackage/Tests/AppModelsTests/SettingScaleClampTests.swift new file mode 100644 index 000000000..a1af480d2 --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/SettingScaleClampTests.swift @@ -0,0 +1,34 @@ +import Testing +import AppModels + +// V2-B / #9: `maximumScaleFactor` and `doubleTapScaleFactor` are mutually clamped in the `Setting` +// model itself, so `doubleTapScaleFactor <= maximumScaleFactor` holds on every write path. The reader +// sheet writes these through a direct `@Shared(.setting)` binding that never passes through a reducer, +// so the invariant can no longer live in a `BindingReducer`; this pins it at the model level. +@Suite +struct SettingScaleClampTests { + @Test + func loweringMaximumClampsDoubleTapDown() { + var setting = Setting() + setting.doubleTapScaleFactor = 3 + setting.maximumScaleFactor = 2 + #expect(setting.doubleTapScaleFactor == 2) + #expect(setting.maximumScaleFactor == 2) + } + + @Test + func raisingDoubleTapPushesMaximumUp() { + var setting = Setting() + setting.doubleTapScaleFactor = 5 + #expect(setting.maximumScaleFactor == 5) + #expect(setting.doubleTapScaleFactor == 5) + } + + @Test + func consistentValuesAreLeftUntouched() { + var setting = Setting() + setting.maximumScaleFactor = 4 + #expect(setting.maximumScaleFactor == 4) + #expect(setting.doubleTapScaleFactor == 2) + } +} From 4191237cd7a7ec38fde53378df374f082841faf8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 08:30:21 +0800 Subject: [PATCH 572/614] Reader edits setting via view-owned `@Shared` --- .../ReadingFeature/ReadingReducer.swift | 14 +++++--------- .../Sources/ReadingFeature/ReadingView.swift | 19 ++++++++++++------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index 09013f27b..b116103af 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -49,15 +49,11 @@ public struct ReadingReducer: Sendable { public var gallery: Gallery = .empty public var language: Language? - @Shared(.setting) public var setting: Setting - /// A write-through view of `setting` for the reading-setting editor's SwiftUI bindings. Mirrors - /// `SettingReducer.State.settingBinding`: `@Shared`'s own value setter is deprecated (it can't - /// take exclusive access), so bind `$store.settingBinding.x` — its setter routes writes through - /// `withLock`, flowing through `BindingReducer` — while reads use `setting`. - public var settingBinding: Setting { - get { setting } - set { $setting.withLock { $0 = newValue } } - } + // Read-only here: the reducer only reads `setting` (page math, orientation). The reading-setting + // editor writes it through a view-owned `@Shared(.setting)` binding instead, and the mutual + // scale-factor clamp lives on the `Setting` model, so those writes stay consistent without a + // reducer round-trip. + @SharedReader(.setting) public var setting: Setting public var readingProgress: Int = .zero // The latest page awaiting a debounced persist to `@Shared(.galleryHistory)`; kept separate diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index a5dc41f1f..ff0ec4177 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Sharing import AppModels import OSLogExt import Observation @@ -17,6 +18,10 @@ public struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme @Bindable var store: StoreOf + // The write handle for the reading-setting editor's bindings. Reads still go through + // `store.setting` (the reducer's `@SharedReader`); this is the same underlying storage, exposed + // here so the sheet's controls can write it directly — the model clamps keep every write safe. + @Shared(.setting) private var setting: Setting let gid: String let blurRadius: Double @@ -62,12 +67,12 @@ public struct ReadingView: View { .sheet(item: $store.destination.readingSetting, id: \.id) { _ in NavigationStack { ReadingSettingView( - readingDirection: $store.settingBinding.readingDirection, - prefetchLimit: $store.settingBinding.prefetchLimit, - enablesLandscape: $store.settingBinding.enablesLandscape, - contentDividerHeight: $store.settingBinding.contentDividerHeight, - maximumScaleFactor: $store.settingBinding.maximumScaleFactor, - doubleTapScaleFactor: $store.settingBinding.doubleTapScaleFactor + readingDirection: Binding($setting.readingDirection), + prefetchLimit: Binding($setting.prefetchLimit), + enablesLandscape: Binding($setting.enablesLandscape), + contentDividerHeight: Binding($setting.contentDividerHeight), + maximumScaleFactor: Binding($setting.maximumScaleFactor), + doubleTapScaleFactor: Binding($setting.doubleTapScaleFactor) ) .toolbar { if !DeviceUtil.isPad && DeviceUtil.isLandscape { @@ -158,7 +163,7 @@ public struct ReadingView: View { ControlPanel( showsPanel: $store.showsPanel, showsSliderPreview: $store.showsSliderPreview, - sliderValue: $bindablePageHandler.sliderValue, setting: $store.settingBinding, + sliderValue: $bindablePageHandler.sliderValue, setting: Binding($setting), enablesLiveText: $bindableLiveTextHandler.enablesLiveText, autoPlayPolicy: .init(get: { autoPlayHandler.policy }, set: { setAutoPlayPolocy($0) }), range: 1...Float(store.gallery.pageCount), From c5c359c5beb7c3758b02c2ab344ad87dbab75c6b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 08:35:23 +0800 Subject: [PATCH 573/614] Seed reader pager from saved resume page --- AppPackage/Sources/ReadingFeature/ReadingView.swift | 10 ++++++++++ .../ReadingReducerLocalTests.swift | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index ff0ec4177..6bac33866 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -38,6 +38,16 @@ public struct ReadingView: View { self.store = store self.gid = gid self.blurRadius = blurRadius + // Seed the pager and slider from the resume page the reducer computed in `State.init`, so the + // reader opens on the saved page. Seeding replaced a `.restoreSession` action that mutated + // `readingProgress` after the view had subscribed; with no post-subscribe change event, the + // pager must be positioned at construction or every session would open at page 1. + let resumePage = max(store.state.readingProgress, 1) + let handler = PageHandler() + handler.sliderValue = Float(resumePage) + let pagerIndex = handler.mapToPager(index: resumePage, setting: store.state.setting) + _pageHandler = State(wrappedValue: handler) + _page = StateObject(wrappedValue: .withIndex(pagerIndex)) } private var backgroundColor: Color { diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift index 30ed31073..c495bb78a 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift @@ -31,6 +31,17 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { #expect(state.containerDataSource(setting: dualPageSetting, isLandscape: true) == []) } + // V2-A / #5: `ReadingView.init` seeds the pager from the resume page (`max(readingProgress, 1)` + // mapped through `mapToPager`), because no post-subscribe change event repositions it anymore. + // This pins that mapping so a saved page opens at its index and no history opens at the first page. + @MainActor + @Test + func testResumePageMapsToPagerIndex() { + let handler = PageHandler() + #expect(handler.mapToPager(index: 5, setting: Setting(), isLandscape: false) == 4) + #expect(handler.mapToPager(index: max(0, 1), setting: Setting(), isLandscape: false) == 0) + } + @MainActor @Test func testReadingReducerOnWebImageSucceededDoesNotCaptureAlreadyLocalPage() async { From 52650776c77e64f49983b14d98270dc71b503881 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 08:44:33 +0800 Subject: [PATCH 574/614] Read setting in list leaves via `@SharedReader` --- AppPackage/Package.swift | 1 + .../DetailSearch/DetailSearchView.swift | 1 - .../DownloadsView+Subviews.swift | 2 -- .../FavoritesFeature/FavoritesView.swift | 1 - .../Cells/GalleryDetailCell.swift | 12 ++++------- .../Cells/GalleryThumbnailCell.swift | 7 +++---- .../GalleryListComponents/GenericList.swift | 21 +++++++------------ .../HomeFeature/Frontpage/FrontpageView.swift | 1 - .../HomeFeature/History/HistoryView.swift | 1 - .../HomeFeature/Popular/PopularView.swift | 2 +- .../HomeFeature/Toplists/ToplistsView.swift | 1 - .../HomeFeature/Watched/WatchedView.swift | 1 - .../Sources/SearchFeature/SearchView.swift | 1 - 13 files changed, 17 insertions(+), 35 deletions(-) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 44a871bf7..a0252f760 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -474,6 +474,7 @@ let targets: [PackageDescription.Target] = [ .module(.tagTranslationFeature), .targetDependency(.kingfisher), .targetDependency(.sfSafeSymbols), + .targetDependency(.sharing), .targetDependency(.waterfallGrid) ], resources: [.process(.resources)], diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift index 0db3fbfab..ffedb2632 100644 --- a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift @@ -25,7 +25,6 @@ struct DetailSearchView: View { var body: some View { GenericList( galleries: store.galleries, - setting: store.setting, pageNumber: store.pageNumber, loadingState: store.loadingState, footerLoadingState: store.footerLoadingState, diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift index 00da2683b..c7dc65d34 100644 --- a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -39,7 +39,6 @@ struct DownloadInspectorView: View { GalleryDetailCell( gallery: inspection.download.gallery, coverSource: .static(inspection.coverURL), - setting: store.setting, translateAction: { store.tagTranslator.lookup( word: $0, @@ -297,7 +296,6 @@ struct DownloadListRow: View { GalleryDetailCell( gallery: download.gallery, coverSource: .static(download.coverURL), - setting: setting, translateAction: { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) }, diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index e799d5765..a15a6f152 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -38,7 +38,6 @@ public struct FavoritesView: View { if CookieUtil.didLogin { GenericList( galleries: store.galleries ?? [], - setting: store.setting, pageNumber: store.pageNumber, loadingState: store.loadingState ?? .idle, footerLoadingState: store.footerLoadingState ?? .idle, diff --git a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift index e99b9c63a..258d94357 100644 --- a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift @@ -1,4 +1,5 @@ import SwiftUI +import Sharing import SFSafeSymbols import AppModels import TagTranslationFeature @@ -16,20 +17,17 @@ public struct GalleryDetailCell: View { private let gallery: Gallery private let coverSource: CoverSource - private let setting: Setting private let translateAction: ((String) -> (String, TagTranslation?))? private let downloadBadge: DownloadBadge? public init( gallery: Gallery, coverSource: CoverSource = .dynamic, - setting: Setting, translateAction: ((String) -> (String, TagTranslation?))? = nil, downloadBadge: DownloadBadge? = nil ) { self.gallery = gallery self.coverSource = coverSource - self.setting = setting self.translateAction = translateAction self.downloadBadge = downloadBadge } @@ -47,7 +45,6 @@ public struct GalleryDetailCell: View { GalleryDetailCellContent( gallery: gallery, resolvedCoverURL: resolvedCoverURL, - setting: setting, colorScheme: colorScheme, translateAction: translateAction, downloadBadge: downloadBadge @@ -56,9 +53,10 @@ public struct GalleryDetailCell: View { } private struct GalleryDetailCellContent: View { + @SharedReader(.setting) private var setting: Setting + private let gallery: Gallery private let resolvedCoverURL: URL? - private let setting: Setting private let colorScheme: ColorScheme private let translateAction: ((String) -> (String, TagTranslation?))? private let downloadBadge: DownloadBadge? @@ -66,14 +64,12 @@ private struct GalleryDetailCellContent: View { init( gallery: Gallery, resolvedCoverURL: URL?, - setting: Setting, colorScheme: ColorScheme, translateAction: ((String) -> (String, TagTranslation?))?, downloadBadge: DownloadBadge? ) { self.gallery = gallery self.resolvedCoverURL = resolvedCoverURL - self.setting = setting self.colorScheme = colorScheme self.translateAction = translateAction self.downloadBadge = downloadBadge @@ -153,6 +149,6 @@ private struct GalleryDetailCellContent: View { struct GalleryDetailCell_Previews: PreviewProvider { static var previews: some View { - GalleryDetailCell(gallery: .preview, setting: Setting()) + GalleryDetailCell(gallery: .preview) } } diff --git a/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift index 2d0eb42d2..f320d8004 100644 --- a/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift @@ -1,4 +1,5 @@ import SwiftUI +import Sharing import SFSafeSymbols import AppModels import TagTranslationFeature @@ -8,20 +9,18 @@ import AppTools public struct GalleryThumbnailCell: View { @Environment(\.colorScheme) private var colorScheme + @SharedReader(.setting) private var setting: Setting private let gallery: Gallery - private let setting: Setting private let translateAction: ((String) -> (String, TagTranslation?))? private let downloadBadge: DownloadBadge? public init( gallery: Gallery, - setting: Setting, translateAction: ((String) -> (String, TagTranslation?))? = nil, downloadBadge: DownloadBadge? = nil ) { self.gallery = gallery - self.setting = setting self.translateAction = translateAction self.downloadBadge = downloadBadge } @@ -98,7 +97,7 @@ public struct GalleryThumbnailCell: View { struct GalleryThumbnailCell_Previews: PreviewProvider { static var previews: some View { - GalleryThumbnailCell(gallery: .preview, setting: Setting()) + GalleryThumbnailCell(gallery: .preview) .preferredColorScheme(.dark) } } diff --git a/AppPackage/Sources/GalleryListComponents/GenericList.swift b/AppPackage/Sources/GalleryListComponents/GenericList.swift index 786e30e27..e6534e519 100644 --- a/AppPackage/Sources/GalleryListComponents/GenericList.swift +++ b/AppPackage/Sources/GalleryListComponents/GenericList.swift @@ -1,4 +1,5 @@ import SwiftUI +import Sharing import SFSafeSymbols import AppModels import AppComponents @@ -6,8 +7,9 @@ import WaterfallGrid import AppTools public struct GenericList: View { + @SharedReader(.setting) private var setting: Setting + private let galleries: [Gallery] - private let setting: Setting private let downloadBadges: [String: DownloadBadge] private let pageNumber: PageNumber? private let loadingState: LoadingState @@ -19,7 +21,7 @@ public struct GenericList: View { private let translateAction: ((String) -> (String, TagTranslation?))? public init( - galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, + galleries: [Gallery], pageNumber: PageNumber?, loadingState: LoadingState, footerLoadingState: LoadingState, notice: LocalizedStringResource? = nil, fetchAction: (() -> Void)? = nil, @@ -29,7 +31,6 @@ public struct GenericList: View { downloadBadges: [String: DownloadBadge] = [:] ) { self.galleries = galleries - self.setting = setting self.downloadBadges = downloadBadges self.pageNumber = pageNumber self.loadingState = loadingState @@ -47,7 +48,7 @@ public struct GenericList: View { switch setting.listDisplayMode { case .detail: DetailList( - galleries: galleries, setting: setting, pageNumber: pageNumber, + galleries: galleries, pageNumber: pageNumber, footerLoadingState: footerLoadingState, notice: notice, fetchMoreAction: fetchMoreAction, navigateAction: navigateAction, translateAction: translateAction, @@ -55,7 +56,7 @@ public struct GenericList: View { ) case .thumbnail: WaterfallList( - galleries: galleries, setting: setting, pageNumber: pageNumber, + galleries: galleries, pageNumber: pageNumber, footerLoadingState: footerLoadingState, notice: notice, fetchMoreAction: fetchMoreAction, navigateAction: navigateAction, translateAction: translateAction, @@ -81,7 +82,6 @@ public struct GenericList: View { // MARK: DetailList private struct DetailList: View { private let galleries: [Gallery] - private let setting: Setting private let downloadBadges: [String: DownloadBadge] private let pageNumber: PageNumber? private let footerLoadingState: LoadingState @@ -91,7 +91,7 @@ private struct DetailList: View { private let translateAction: ((String) -> (String, TagTranslation?))? init( - galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, + galleries: [Gallery], pageNumber: PageNumber?, footerLoadingState: LoadingState, notice: LocalizedStringResource? = nil, fetchMoreAction: (() -> Void)?, navigateAction: ((Gallery) -> Void)? = nil, @@ -99,7 +99,6 @@ private struct DetailList: View { downloadBadges: [String: DownloadBadge] = [:] ) { self.galleries = galleries - self.setting = setting self.downloadBadges = downloadBadges self.pageNumber = pageNumber self.footerLoadingState = footerLoadingState @@ -129,7 +128,6 @@ private struct DetailList: View { } label: { GalleryDetailCell( gallery: gallery, - setting: setting, translateAction: translateAction, downloadBadge: downloadBadges[gallery.gid] ) @@ -151,7 +149,6 @@ private struct DetailList: View { // MARK: WaterfallList private struct WaterfallList: View { private let galleries: [Gallery] - private let setting: Setting private let downloadBadges: [String: DownloadBadge] private let pageNumber: PageNumber? private let footerLoadingState: LoadingState @@ -177,7 +174,7 @@ private struct WaterfallList: View { } init( - galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, + galleries: [Gallery], pageNumber: PageNumber?, footerLoadingState: LoadingState, notice: LocalizedStringResource? = nil, fetchMoreAction: (() -> Void)?, navigateAction: ((Gallery) -> Void)? = nil, @@ -185,7 +182,6 @@ private struct WaterfallList: View { downloadBadges: [String: DownloadBadge] = [:] ) { self.galleries = galleries - self.setting = setting self.downloadBadges = downloadBadges self.pageNumber = pageNumber self.footerLoadingState = footerLoadingState @@ -208,7 +204,6 @@ private struct WaterfallList: View { } label: { GalleryThumbnailCell( gallery: gallery, - setting: setting, translateAction: translateAction, downloadBadge: downloadBadges[gallery.gid] ) diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift index af0752d9e..ea548d0e0 100644 --- a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -24,7 +24,6 @@ struct FrontpageView: View { var body: some View { GenericList( galleries: store.filteredGalleries, - setting: store.setting, pageNumber: store.pageNumber, loadingState: store.loadingState, footerLoadingState: store.footerLoadingState, diff --git a/AppPackage/Sources/HomeFeature/History/HistoryView.swift b/AppPackage/Sources/HomeFeature/History/HistoryView.swift index ff7be9cc8..cba38ece7 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryView.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryView.swift @@ -22,7 +22,6 @@ struct HistoryView: View { var body: some View { GenericList( galleries: store.filteredGalleries, - setting: store.setting, pageNumber: PageNumber(isNextButtonEnabled: store.hasMoreHistory), loadingState: store.loadingState, footerLoadingState: store.footerLoadingState, diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift index 4f534d597..a2ef06463 100644 --- a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -23,7 +23,7 @@ struct PopularView: View { var body: some View { GenericList( galleries: store.filteredGalleries, - setting: store.setting, pageNumber: nil, + pageNumber: nil, loadingState: store.loadingState, footerLoadingState: .idle, fetchAction: { store.send(.fetchGalleries) }, diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift index 8146fb9fa..4e4587650 100644 --- a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -26,7 +26,6 @@ struct ToplistsView: View { var body: some View { GenericList( galleries: store.filteredGalleries ?? [], - setting: store.setting, pageNumber: store.pageNumber, loadingState: store.loadingState ?? .idle, footerLoadingState: store.footerLoadingState ?? .idle, diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift index b97076966..c2345934e 100644 --- a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -27,7 +27,6 @@ struct WatchedView: View { if CookieUtil.didLogin { GenericList( galleries: store.galleries, - setting: store.setting, pageNumber: store.pageNumber, loadingState: store.loadingState, footerLoadingState: store.footerLoadingState, diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift index 75a57de15..c23d4f49a 100644 --- a/AppPackage/Sources/SearchFeature/SearchView.swift +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -24,7 +24,6 @@ struct SearchView: View { var body: some View { GenericList( galleries: store.galleries, - setting: store.setting, pageNumber: store.pageNumber, loadingState: store.loadingState, footerLoadingState: store.footerLoadingState, From bbda677a7d943b517708dc614ca601da1dd5df19 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 08:47:49 +0800 Subject: [PATCH 575/614] Read user via `@SharedReader` in view leaves --- AppPackage/Package.swift | 3 ++- AppPackage/Sources/AppComponents/ToolbarItems.swift | 6 +++--- .../Sources/DetailFeature/DetailView+HeaderSection.swift | 4 +++- AppPackage/Sources/DetailFeature/DetailView.swift | 1 - AppPackage/Sources/FavoritesFeature/FavoritesView.swift | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index a0252f760..4a8fab4b0 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -460,7 +460,8 @@ let targets: [PackageDescription.Target] = [ .module(.tagTranslationFeature), .targetDependency(.composableArchitecture), .targetDependency(.kingfisher), - .targetDependency(.sfSafeSymbols) + .targetDependency(.sfSafeSymbols), + .targetDependency(.sharing) ], resources: [.process(.resources)], plugins: swiftLintPlugins diff --git a/AppPackage/Sources/AppComponents/ToolbarItems.swift b/AppPackage/Sources/AppComponents/ToolbarItems.swift index 54be85ae3..d62dd5599 100644 --- a/AppPackage/Sources/AppComponents/ToolbarItems.swift +++ b/AppPackage/Sources/AppComponents/ToolbarItems.swift @@ -1,4 +1,5 @@ import SwiftUI +import Sharing import SFSafeSymbols import AppModels import Resources @@ -130,12 +131,11 @@ public struct DateSeekButton: View { } public struct FavoritesIndexMenu: View { - private let user: User + @SharedReader(.user) private var user: User private let index: Int private let action: (Int) -> Void - public init(user: User, index: Int, action: @escaping (Int) -> Void) { - self.user = user + public init(index: Int, action: @escaping (Int) -> Void) { self.index = index self.action = action } diff --git a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift index b3e8ff080..de9ef4679 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift @@ -1,4 +1,5 @@ import SwiftUI +import Sharing import AppModels import Resources import Kingfisher @@ -8,9 +9,10 @@ import AppComponents // MARK: HeaderSection struct HeaderSection: View { + @SharedReader(.user) var user: User + let gallery: Gallery let galleryDetail: GalleryDetail - let user: User let downloadBadge: DownloadBadge? let downloadNeedsRepair: Bool let downloadFolders: [String] diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 5e1bd543a..6bbe3e13b 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -60,7 +60,6 @@ private extension DetailView { HeaderSection( gallery: store.gallery, galleryDetail: store.galleryDetail ?? .empty, - user: store.user, downloadBadge: store.downloadBadge, downloadNeedsRepair: store.downloadNeedsRepair, downloadFolders: store.downloadFolders, diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift index a15a6f152..04d073733 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -100,7 +100,7 @@ public struct FavoritesView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem(tint: .primary) { - FavoritesIndexMenu(user: store.user, index: store.index) { index in + FavoritesIndexMenu(index: store.index) { index in if index != store.index { store.send(.setFavoritesIndex(index)) } From 0d138c0376db976b36edc6e24d1e85284eacc503 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 08:56:25 +0800 Subject: [PATCH 576/614] Drop dead empty defaults on pushed states --- AppPackage/Sources/DetailFeature/DetailReducer.swift | 2 +- .../DetailFeature/GalleryInfos/GalleryInfosReducer.swift | 6 +++--- .../DetailFeature/GalleryInfos/GalleryInfosView.swift | 5 ++++- .../Sources/DetailFeature/Previews/PreviewsReducer.swift | 4 ++-- AppPackage/Sources/ReadingFeature/ReadingReducer.swift | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index 56bb45689..f8dfda05a 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -69,7 +69,7 @@ public struct DetailReducer: Sendable { public var showsFullTitle = false public var userRating = 0 public var apiKey = "" - public var gid = "" + public var gid: String public var loadingState: LoadingState = .idle public var gallery: Gallery public var galleryDetail: GalleryDetail? diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift index 72a6ac092..b2f3d24fa 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift @@ -10,10 +10,10 @@ public struct GalleryInfosReducer: Sendable { public struct State: Equatable { @Presents public var toast: AppAlertState? // Display data captured when this screen is pushed onto the host's gallery stack. - public var gallery: Gallery = .empty - public var galleryDetail: GalleryDetail = .empty + public var gallery: Gallery + public var galleryDetail: GalleryDetail - public init(gallery: Gallery = .empty, galleryDetail: GalleryDetail = .empty) { + public init(gallery: Gallery, galleryDetail: GalleryDetail) { self.gallery = gallery self.galleryDetail = galleryDetail } diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index 140bd7dc5..67707fb81 100644 --- a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -126,7 +126,10 @@ struct GalleryInfosView_Previews: PreviewProvider { static var previews: some View { NavigationStack { GalleryInfosView( - store: .init(initialState: .init(), reducer: GalleryInfosReducer.init), + store: .init( + initialState: .init(gallery: .preview, galleryDetail: .preview), + reducer: GalleryInfosReducer.init + ), gallery: .preview, galleryDetail: .preview ) diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift index f7699dfb2..4b2040db4 100644 --- a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -27,8 +27,8 @@ public struct PreviewsReducer: Sendable { @Presents public var destination: Destination.State? // The gallery id this screen fetches; captured when pushed onto the host's gallery stack. - public var gid = "" - public var gallery: Gallery = .empty + public var gid: String + public var gallery: Gallery // Threaded from the detail context (via the `pushPreviews` delegate) so a reader opened from // this screen keeps the correct page math (`previewConfig`) and Live Text `language` for // remote sessions — Previews itself never fetches a gallery detail to re-derive them. diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index b116103af..adb7b6be1 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -46,7 +46,7 @@ public struct ReadingReducer: Sendable { @Presents public var toast: AppAlertState? @Presents public var destination: Destination.State? public var contentSource: ReadingContentSource = .remote - public var gallery: Gallery = .empty + public var gallery: Gallery public var language: Language? // Read-only here: the reducer only reads `setting` (page math, orientation). The reading-setting From 70eb6463db066be02f6313ef50276e3bd8ce3e6c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 08:57:00 +0800 Subject: [PATCH 577/614] Reword scene-phase flush test comment --- .../DownloadsFeatureTests/AppReadingFlushTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/AppPackage/Tests/DownloadsFeatureTests/AppReadingFlushTests.swift b/AppPackage/Tests/DownloadsFeatureTests/AppReadingFlushTests.swift index ac0b80b8c..a03ddd321 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/AppReadingFlushTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/AppReadingFlushTests.swift @@ -10,10 +10,10 @@ import ReadingFeature import DownloadsFeature @testable import AppFeature -// #8: ReadingView no longer observes scene phase itself. On `.background`, AppReducer routes -// `.flushReadingProgress` to whichever reading session is on top of a navigation host (located from -// navigation state), so a force-quit from the background still persists the reader's last debounced -// page. When no reader is presented it is a no-op. +// Scene-phase observation lives in AppReducer, not the reader view. On `.background`, AppReducer +// routes `.flushReadingProgress` to whichever reading session is on top of a navigation host (located +// from navigation state), so a force-quit from the background still persists the reader's last +// debounced page. When no reader is presented the flush is a no-op. @Suite(.serialized) struct AppReadingFlushTests: DownloadFeatureTestCase { private let now = Date(timeIntervalSince1970: 1_000) From 7277a2aa828757f29a4753458d7b435f44bab270 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 08:59:45 +0800 Subject: [PATCH 578/614] Document no-schema-gate convention on blobs --- AppPackage/Sources/AppModels/Persistent/Filter.swift | 7 +++++-- AppPackage/Sources/AppModels/Persistent/Setting.swift | 7 +++++-- AppPackage/Sources/AppModels/Persistent/User.swift | 7 +++++-- AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift | 7 +++++-- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/AppPackage/Sources/AppModels/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift index 337ce5d90..75bdc5c95 100644 --- a/AppPackage/Sources/AppModels/Persistent/Filter.swift +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -59,8 +59,11 @@ public struct Filter: Codable, Equatable, Sendable { self.disableUploader = disableUploader self.disableTags = disableTags } - // Version anchor for a future breaking migration. All current fields decode strictly; a field - // added later must stay optional (or use a custom `decodeIfPresent` decoder) so old blobs decode. + // Version anchor for a future breaking migration. Unlike the identity array-element models + // (GalleryHistoryEntry/QuickSearchWord), this single top-level blob keeps synthesized strict + // Codable with no version gate: a breaking change alters the shape, so a mismatched blob fails to + // decode on its own and gating would mean a hand-written decoder. A field added later must stay + // optional (or a custom `decodeIfPresent` decoder) so old blobs still decode. public var schemaVersion = 1 public var doujinshi = false public var manga = false diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index 38a7bfee5..cd2046e65 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -57,8 +57,11 @@ public struct Setting: Codable, Equatable, Sendable { self.doubleTapScaleFactor = doubleTapScaleFactor self.bypassesSNIFiltering = bypassesSNIFiltering } - // Version anchor for a future breaking migration. All current fields decode strictly; a field - // added later must stay optional (or use a custom `decodeIfPresent` decoder) so old blobs decode. + // Version anchor for a future breaking migration. Unlike the identity array-element models + // (GalleryHistoryEntry/QuickSearchWord), this single top-level blob keeps synthesized strict + // Codable with no version gate: a breaking change alters the shape, so a mismatched blob fails to + // decode on its own and gating would mean a hand-written decoder. A field added later must stay + // optional (or a custom `decodeIfPresent` decoder) so old blobs still decode. public var schemaVersion = 1 // Account public var galleryHost: GalleryHost = .ehentai diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index 6004d1409..288119bca 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -17,8 +17,11 @@ public struct User: Codable, Equatable, Sendable { } public static let empty = User() - // Version anchor for a future breaking migration. All current fields decode strictly; a field - // added later must stay optional (or use a custom `decodeIfPresent` decoder) so old blobs decode. + // Version anchor for a future breaking migration. Unlike the identity array-element models + // (GalleryHistoryEntry/QuickSearchWord), this single top-level blob keeps synthesized strict + // Codable with no version gate: a breaking change alters the shape, so a mismatched blob fails to + // decode on its own and gating would mean a hand-written decoder. A field added later must stay + // optional (or a custom `decodeIfPresent` decoder) so old blobs still decode. public var schemaVersion = 1 public var displayName: String? public var avatarURL: URL? diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift index 611ccff2e..575b7a9d5 100644 --- a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift +++ b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift @@ -19,8 +19,11 @@ public struct TagTranslatorInfo: Codable, Equatable, Sendable { self.updatedDate = updatedDate self.hasCustomTranslations = hasCustomTranslations } - // Version anchor for a future breaking migration. All current fields decode strictly (synthesized - // Codable); a field added later must stay optional so an old blob still decodes. + // Version anchor for a future breaking migration. Unlike the identity array-element models + // (GalleryHistoryEntry/QuickSearchWord), this single top-level blob keeps synthesized strict + // Codable with no version gate: a breaking change alters the shape, so a mismatched blob fails to + // decode on its own and gating would mean a hand-written decoder. A field added later must stay + // optional (or a custom `decodeIfPresent` decoder) so old blobs still decode. public var schemaVersion: Int public var language: TranslatableLanguage? public var updatedDate: Date From c81ac314ba54ccca1fe0250152865e463cc73ea4 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 10:42:15 +0800 Subject: [PATCH 579/614] Remove reader pager defaults and dead onChange --- AppPackage/Sources/ReadingFeature/ReadingView.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 6bac33866..895564a95 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -28,8 +28,8 @@ public struct ReadingView: View { @State private var liveTextHandler = LiveTextHandler() @State private var autoPlayHandler = AutoPlayHandler() @State var gestureHandler = GestureHandler() - @State private var pageHandler = PageHandler() - @StateObject var page: Page = .first() + @State private var pageHandler: PageHandler + @StateObject var page: Page public init( store: StoreOf, @@ -226,9 +226,6 @@ public struct ReadingView: View { if !newValue { setPageIndex(sliderValue: pageHandler.sliderValue) } setAutoPlayPolocy(.off) } - .onChange(of: store.readingProgress) { _, newValue in - pageHandler.sliderValue = .init(newValue) - } // AutoPlay .onChange(of: store.destination != nil) { _, isPresented in if isPresented { From 1850aa49bf748276fa80336582285c391e492e4d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 10:45:52 +0800 Subject: [PATCH 580/614] Retire Gallery.empty in favor of .preview --- .../Sources/AppModels/Gallery/Gallery.swift | 37 ++++++++++--------- .../HomeFeature/HomeView+Sections.swift | 2 +- .../Sources/ReadingFeature/ReadingView.swift | 2 +- .../DownloadObserverReadingTests.swift | 2 +- .../DownloadsReducerReadingDismissTests.swift | 2 +- .../ReadingReducerLocalTests.swift | 2 +- 6 files changed, 25 insertions(+), 22 deletions(-) diff --git a/AppPackage/Sources/AppModels/Gallery/Gallery.swift b/AppPackage/Sources/AppModels/Gallery/Gallery.swift index e1932ef42..992dc4c0e 100644 --- a/AppPackage/Sources/AppModels/Gallery/Gallery.swift +++ b/AppPackage/Sources/AppModels/Gallery/Gallery.swift @@ -7,25 +7,28 @@ public struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { } public static func mockGalleries(count: Int, randomID: Bool = true) -> [Gallery] { + // A blank, fresh-id gallery used only to fill the loading skeleton. `.preview` can't stand in + // because its `gid` is a fixed constant and the skeleton's `ForEach` is keyed by gallery id, + // so every row needs a distinct id. + func blank() -> Gallery { + .init( + gid: UUID().uuidString, + token: "", + title: "", + rating: 0.0, + tags: [], + category: .doujinshi, + uploader: "", + pageCount: 1, + postedDate: .now, + coverURL: nil, + galleryURL: nil + ) + } guard randomID, count > 0 else { - return Array(repeating: .empty, count: count) + return Array(repeating: blank(), count: count) } - return (0...count).map { _ in .empty } - } - public static var empty: Gallery { - .init( - gid: UUID().uuidString, - token: "", - title: "", - rating: 0.0, - tags: [], - category: .doujinshi, - uploader: "", - pageCount: 1, - postedDate: .now, - coverURL: nil, - galleryURL: nil - ) + return (0...count).map { _ in blank() } } public static let preview = Gallery( gid: UUID().uuidString, diff --git a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift index b267ffabf..2a4502bda 100644 --- a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift +++ b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift @@ -170,7 +170,7 @@ struct ToplistsSection: View { private var dataSource: [Int: [Gallery]] { guard !galleries.isEmpty else { var dictionary = [Int: [Gallery]]() - var gallery: Gallery = .empty + var gallery: Gallery = .preview gallery.title = "......" gallery.uploader = "......" let galleries = Array(repeating: gallery, count: 6) diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 895564a95..690f3ee32 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -351,7 +351,7 @@ struct ReadingView_Previews: PreviewProvider { Color.clear .fullScreenCover(isPresented: .constant(true)) { ReadingView( - store: .init(initialState: .init(gallery: .empty), reducer: ReadingReducer.init), + store: .init(initialState: .init(gallery: .preview), reducer: ReadingReducer.init), gid: .init(), blurRadius: 0 ) diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift index e88642c5c..4090e25f4 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift @@ -35,7 +35,7 @@ struct DownloadObserverReadingTests: DownloadFeatureTestCase { ) let manifest = try sampleManifest(gid: download.gid, title: download.title) let store = TestStore( - initialState: ReadingReducer.State(gallery: .empty, contentSource: .local(download, manifest)) + initialState: ReadingReducer.State(gallery: .preview, contentSource: .local(download, manifest)) ) { ReadingReducer() } withDependencies: { diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift index 9833fd463..e4acbca7e 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift @@ -11,7 +11,7 @@ struct DownloadsReducerReadingDismissTests { @Test func readingDismissClearsDestination() async { var initialState = DownloadsReducer.State() - initialState.destination = .reading(.init(gallery: .empty, contentSource: .remote)) + initialState.destination = .reading(.init(gallery: .preview, contentSource: .remote)) let store = TestStore( initialState: initialState, diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift index c495bb78a..058dfa30f 100644 --- a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift @@ -118,7 +118,7 @@ struct ReadingReducerLocalTests: DownloadFeatureTestCase { defer { try? FileManager.default.removeItem(at: folderURL) } let store = TestStore( - initialState: ReadingReducer.State(gallery: .empty, contentSource: .local(download, manifest)) + initialState: ReadingReducer.State(gallery: .preview, contentSource: .local(download, manifest)) ) { ReadingReducer() } withDependencies: { From 7ff5a830f4841988609e1aec6ac6e9fce4ea494d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 11:46:53 +0800 Subject: [PATCH 581/614] Move autoLock/blur invariant into Setting model --- .../AppModels/Persistent/Setting.swift | 21 +++++++++-- .../SettingFeature/SettingReducer+Body.swift | 17 ++------- .../SettingAutoLockClampTests.swift | 35 +++++++++++++++++++ 3 files changed, 57 insertions(+), 16 deletions(-) create mode 100644 AppPackage/Tests/AppModelsTests/SettingAutoLockClampTests.swift diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index cd2046e65..65491974d 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -82,8 +82,25 @@ public struct Setting: Codable, Equatable, Sendable { public var showsImagesInTags = false public var redirectsLinksToSelectedHost = false public var detectsLinksFromClipboard = false - public var backgroundBlurRadius: Double = 10 - public var autoLockPolicy: AutoLockPolicy = .never + // `autoLockPolicy` and `backgroundBlurRadius` are coupled: auto-lock relies on a non-zero blur to + // obscure content, so enabling auto-lock while blur is 0 restores a default blur, and dropping blur + // to 0 disables auto-lock. Kept on the model (not a reducer `BindingReducer`) so every write path — + // the Setting editor or any direct `@Shared(.setting)` binding — preserves it without a reducer + // round-trip, mirroring the scale-factor clamp below. + public var backgroundBlurRadius: Double = 10 { + didSet { + if autoLockPolicy != .never && backgroundBlurRadius == 0 { + autoLockPolicy = .never + } + } + } + public var autoLockPolicy: AutoLockPolicy = .never { + didSet { + if autoLockPolicy != .never && backgroundBlurRadius == 0 { + backgroundBlurRadius = 10 + } + } + } // Appearance public var listDisplayMode: ListDisplayMode = .detail diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index d5e41880d..81b5f5707 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -10,8 +10,9 @@ private let logger = Logger(category: .init(describing: SettingReducer.self)) extension SettingReducer { @ReducerBuilder var reducerBody: some Reducer { - // `setting` is `@Shared`, so BindingReducer writes and the fixups below persist automatically — - // these `.onChange` handlers carry only their genuine side effects and cross-field invariants. + // `setting` is `@Shared`, so BindingReducer writes persist automatically; these `.onChange` + // handlers carry only genuine side effects. Cross-field invariants (the scale factors and + // auto-lock↔blur) live on the `Setting` model instead, so every write path preserves them. BindingReducer() .onChange(of: \.setting.galleryHost) { _, state in .run(operation: { [value = state.setting.galleryHost.rawValue] _ in @@ -31,18 +32,6 @@ extension SettingReducer { await send(.syncAppIconType) } } - .onChange(of: \.setting.autoLockPolicy) { _, state in - if state.setting.autoLockPolicy != .never && state.setting.backgroundBlurRadius == 0 { - state.$setting.withLock { $0.backgroundBlurRadius = 10 } - } - return .none - } - .onChange(of: \.setting.backgroundBlurRadius) { _, state in - if state.setting.autoLockPolicy != .never && state.setting.backgroundBlurRadius == 0 { - state.$setting.withLock { $0.autoLockPolicy = .never } - } - return .none - } .onChange(of: \.setting.enablesLandscape) { _, state in guard !state.setting.enablesLandscape else { return .none } return .run { _ in diff --git a/AppPackage/Tests/AppModelsTests/SettingAutoLockClampTests.swift b/AppPackage/Tests/AppModelsTests/SettingAutoLockClampTests.swift new file mode 100644 index 000000000..2738d2364 --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/SettingAutoLockClampTests.swift @@ -0,0 +1,35 @@ +import Testing +import AppModels + +// The auto-lock policy and background-blur radius are coupled on the `Setting` model: auto-lock relies +// on a non-zero blur to obscure content, so enabling auto-lock while blur is 0 restores a default blur, +// and dropping blur to 0 disables auto-lock. Kept on the model (not a reducer `BindingReducer`) so every +// write path holds the invariant; this pins that coupling. +@Suite +struct SettingAutoLockClampTests { + @Test + func enablingAutoLockWithZeroBlurRestoresBlur() { + var setting = Setting() + setting.backgroundBlurRadius = 0 + setting.autoLockPolicy = .instantly + #expect(setting.backgroundBlurRadius == 10) + #expect(setting.autoLockPolicy == .instantly) + } + + @Test + func zeroingBlurWhileAutoLockOnDisablesAutoLock() { + var setting = Setting() + setting.autoLockPolicy = .min1 + setting.backgroundBlurRadius = 0 + #expect(setting.autoLockPolicy == .never) + #expect(setting.backgroundBlurRadius == 0) + } + + @Test + func zeroingBlurWhileAutoLockNeverIsLeftUntouched() { + var setting = Setting() + setting.backgroundBlurRadius = 0 + #expect(setting.backgroundBlurRadius == 0) + #expect(setting.autoLockPolicy == .never) + } +} From 7c3fb1dff0ac2a03e02d4387c1a6dd657ea2dbc7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 11:56:06 +0800 Subject: [PATCH 582/614] Give Laboratory its own reducer for SNI effect --- .../Components/LaboratorySettingReducer.swift | 35 +++++++++++++++++++ .../Components/LaboratorySettingView.swift | 17 ++++++--- .../Sources/SettingFeature/SettingPath.swift | 9 ++--- .../SettingFeature/SettingReducer+Body.swift | 6 ---- .../SettingFeature/SettingReducer.swift | 1 - .../Sources/SettingFeature/SettingView.swift | 6 ++-- .../LaboratorySettingReducerTests.swift | 21 +++++++++++ 7 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 AppPackage/Sources/SettingFeature/Components/LaboratorySettingReducer.swift create mode 100644 AppPackage/Tests/SettingFeatureTests/LaboratorySettingReducerTests.swift diff --git a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingReducer.swift b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingReducer.swift new file mode 100644 index 000000000..f84aef0c3 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingReducer.swift @@ -0,0 +1,35 @@ +import ComposableArchitecture +import HapticsClient +import DFClient + +@Reducer +public struct LaboratorySettingReducer: Sendable { + @ObservableState + public struct State: Equatable, Sendable { + public init() {} + } + + public enum Action: Equatable, Sendable { + // Sent by the view when the SNI-filtering toggle changes. The write itself lands directly in + // `@Shared(.setting)`, which dispatches no action, so the view bridges the change here and this + // reducer owns the side effect the write can't trigger. + case bypassesSNIFilteringChanged(Bool) + } + + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.dfClient) private var dfClient + + public init() {} + + public var body: some Reducer { + Reduce { _, action in + switch action { + case .bypassesSNIFilteringChanged(let value): + return .merge( + .run { _ in await hapticsClient.generateFeedback(.soft) }, + .run { _ in dfClient.setActive(value) } + ) + } + } + } +} diff --git a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift index 8775c8d7c..34641628c 100644 --- a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift @@ -1,20 +1,24 @@ import SwiftUI +import AppModels +import Sharing import Resources import SFSafeSymbols +import ComposableArchitecture import AppComponents struct LaboratorySettingView: View { - @Binding private var bypassesSNIFiltering: Bool + private let store: StoreOf + @Shared(.setting) private var setting: Setting - init(bypassesSNIFiltering: Binding) { - _bypassesSNIFiltering = bypassesSNIFiltering + init(store: StoreOf) { + self.store = store } var body: some View { ScrollView { VStack { LaboratoryCell( - isOn: $bypassesSNIFiltering, + isOn: Binding($setting.bypassesSNIFiltering), title: .bypassesSniFiltering, symbol: .theatermasksFill, tintColor: .purple ) @@ -22,6 +26,9 @@ struct LaboratorySettingView: View { .padding() } .navigationTitle(.laboratory) + .onChange(of: setting.bypassesSNIFiltering) { _, newValue in + store.send(.bypassesSNIFilteringChanged(newValue)) + } } } @@ -74,7 +81,7 @@ struct LaboratorySettingView_Previews: PreviewProvider { static var previews: some View { NavigationStack { LaboratorySettingView( - bypassesSNIFiltering: .constant(false) + store: .init(initialState: .init(), reducer: LaboratorySettingReducer.init) ) } } diff --git a/AppPackage/Sources/SettingFeature/SettingPath.swift b/AppPackage/Sources/SettingFeature/SettingPath.swift index 32aa89e93..623f458eb 100644 --- a/AppPackage/Sources/SettingFeature/SettingPath.swift +++ b/AppPackage/Sources/SettingFeature/SettingPath.swift @@ -2,9 +2,10 @@ import ComposableArchitecture // The single flat navigation stack for the Setting tab, owned by `SettingReducer`. Every drill-down // screen is a path element; child screens never push directly — they emit `delegate` actions that -// `SettingReducer` observes and appends to `path`. State-free screens (driven purely by bindings into -// `SettingReducer.State.setting`) are backed by `StaticSettingScreenReducer` and built from those -// root bindings in `SettingView`'s destination switch. +// `SettingReducer` observes and appends to `path`. Each screen's view reads and writes `setting` +// through its own `@Shared(.setting)`/`@SharedReader(.setting)`. Screens whose edits trigger a side +// effect own a dedicated reducer for it; the remaining state-free screens (download, about) share +// `StaticSettingScreenReducer`. @Reducer public enum SettingPath { case account(AccountSettingReducer) @@ -15,7 +16,7 @@ public enum SettingPath { case appActivityLogs(AppActivityLogsReducer) case download(StaticSettingScreenReducer) case reading(StaticSettingScreenReducer) - case laboratory(StaticSettingScreenReducer) + case laboratory(LaboratorySettingReducer) case about(StaticSettingScreenReducer) case appIcon(StaticSettingScreenReducer) } diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 81b5f5707..ba0b7da06 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -39,12 +39,6 @@ extension SettingReducer { await appDelegateClient.setPortraitOrientationMask() } } - .onChange(of: \.setting.bypassesSNIFiltering) { _, state in - .merge( - .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), - .run(operation: { [value = state.setting.bypassesSNIFiltering] _ in dfClient.setActive(value) }) - ) - } Reduce { state, action in switch action { diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index 61793dfa6..9c61725de 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -132,7 +132,6 @@ public struct SettingReducer: Sendable { @Dependency(\.userDefaultsClient) var userDefaultsClient @Dependency(\.appDelegateClient) var appDelegateClient @Dependency(\.libraryClient) var libraryClient - @Dependency(\.hapticsClient) var hapticsClient @Dependency(\.cookieClient) var cookieClient @Dependency(\.deviceClient) var deviceClient @Dependency(\.fileClient) var fileClient diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 8d75fad76..708152d70 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -109,10 +109,8 @@ public struct SettingView: View { doubleTapScaleFactor: $store.settingBinding.doubleTapScaleFactor ) - case .laboratory: - LaboratorySettingView( - bypassesSNIFiltering: $store.settingBinding.bypassesSNIFiltering - ) + case .laboratory(let laboratoryStore): + LaboratorySettingView(store: laboratoryStore) case .about: AboutView() diff --git a/AppPackage/Tests/SettingFeatureTests/LaboratorySettingReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/LaboratorySettingReducerTests.swift new file mode 100644 index 000000000..7ac274e41 --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/LaboratorySettingReducerTests.swift @@ -0,0 +1,21 @@ +import ComposableArchitecture +import Testing +import HapticsClient +import DFClient +@testable import SettingFeature + +@MainActor +struct LaboratorySettingReducerTests { + // The SNI toggle's side effect (haptic + `DFClient.setActive`) is fire-and-forget, and neither + // client exposes a capturable double, so this pins the wiring: the change action is handled and its + // merged effects run to completion without emitting anything unexpected. + @Test + func bypassesSNIFilteringChangedRunsToCompletion() async { + let store = TestStore(initialState: .init(), reducer: LaboratorySettingReducer.init) { + $0.hapticsClient = .noop + $0.dfClient = .noop + } + await store.send(.bypassesSNIFilteringChanged(true)) + await store.finish() + } +} From 8c29f26dd49324769dc7db3ccf45c8f9eac11665 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 12:01:48 +0800 Subject: [PATCH 583/614] Give App Icon its own reducer for icon effect --- .../AppModels/Persistent/AppIconType.swift | 7 +++ .../AppearanceSetting/AppIconReducer.swift | 49 +++++++++++++++++++ .../AppearanceSettingView.swift | 15 ++++-- .../Sources/SettingFeature/SettingPath.swift | 2 +- .../SettingFeature/SettingReducer+Body.swift | 10 +--- .../Sources/SettingFeature/SettingView.swift | 4 +- .../AppIconTypeMatchingTests.swift | 20 ++++++++ .../AppIconReducerTests.swift | 31 ++++++++++++ 8 files changed, 121 insertions(+), 17 deletions(-) create mode 100644 AppPackage/Sources/SettingFeature/AppearanceSetting/AppIconReducer.swift create mode 100644 AppPackage/Tests/AppModelsTests/AppIconTypeMatchingTests.swift create mode 100644 AppPackage/Tests/SettingFeatureTests/AppIconReducerTests.swift diff --git a/AppPackage/Sources/AppModels/Persistent/AppIconType.swift b/AppPackage/Sources/AppModels/Persistent/AppIconType.swift index eb13c2df4..81ea634b2 100644 --- a/AppPackage/Sources/AppModels/Persistent/AppIconType.swift +++ b/AppPackage/Sources/AppModels/Persistent/AppIconType.swift @@ -49,4 +49,11 @@ extension AppIconType { return "AppIcon_NotMyPresident" } } + + // Resolves the system's current alternate-icon name back to a known type; an unrecognized name falls + // back to `.default`. Callers handle the `nil` (primary-icon) case themselves. Shared by the Setting + // tab's launch reconciliation and the App Icon screen's post-edit sync so both map identically. + public static func matching(alternateIconName: String) -> AppIconType { + allCases.first { alternateIconName.contains($0.filename) } ?? .default + } } diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppIconReducer.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppIconReducer.swift new file mode 100644 index 000000000..87bd7ee49 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppIconReducer.swift @@ -0,0 +1,49 @@ +import AppModels +import Sharing +import ComposableArchitecture +import ApplicationClient + +@Reducer +public struct AppIconReducer: Sendable { + @ObservableState + public struct State: Equatable, Sendable { + @Shared(.setting) public var setting: Setting + public init() {} + } + + public enum Action: Equatable, Sendable { + // The view writes `appIconType` straight into `@Shared(.setting)`, which dispatches no action, + // so it bridges the change here; this reducer applies it to the system icon and reconciles the + // stored value back from whatever icon actually took effect. + case appIconTypeChanged(AppIconType) + case syncAppIconType + case syncAppIconTypeDone(String?) + } + + @Dependency(\.applicationClient) private var applicationClient + + public init() {} + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .appIconTypeChanged(let iconType): + return .run { send in + _ = await applicationClient.setAlternateIconName(iconType.filename) + await send(.syncAppIconType) + } + + case .syncAppIconType: + return .run { send in + await send(.syncAppIconTypeDone(await applicationClient.alternateIconName())) + } + + case .syncAppIconTypeDone(let iconName): + if let iconName { + state.$setting.withLock { $0.appIconType = .matching(alternateIconName: iconName) } + } + return .none + } + } + } +} diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift index ab31e704d..039d45321 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import Sharing import Resources import ComposableArchitecture import AppComponents @@ -102,10 +103,11 @@ struct AppearanceSettingView: View { // MARK: SelectAppIconView struct AppIconView: View { - @Binding private var appIconType: AppIconType + private let store: StoreOf + @Shared(.setting) private var setting: Setting - init(appIconType: Binding) { - _appIconType = appIconType + init(store: StoreOf) { + self.store = store } var body: some View { @@ -115,14 +117,17 @@ struct AppIconView: View { AppIconRow( iconName: icon.name, filename: icon.filename, - isSelected: icon == appIconType + isSelected: icon == setting.appIconType ) .contentShape(.rect) - .onTapGesture { appIconType = icon } + .onTapGesture { $setting.withLock { $0.appIconType = icon } } } } } .navigationTitle(.appIcon) + .onChange(of: setting.appIconType) { _, newValue in + store.send(.appIconTypeChanged(newValue)) + } } } diff --git a/AppPackage/Sources/SettingFeature/SettingPath.swift b/AppPackage/Sources/SettingFeature/SettingPath.swift index 623f458eb..b4610170a 100644 --- a/AppPackage/Sources/SettingFeature/SettingPath.swift +++ b/AppPackage/Sources/SettingFeature/SettingPath.swift @@ -18,7 +18,7 @@ public enum SettingPath { case reading(StaticSettingScreenReducer) case laboratory(LaboratorySettingReducer) case about(StaticSettingScreenReducer) - case appIcon(StaticSettingScreenReducer) + case appIcon(AppIconReducer) } extension SettingPath.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index ba0b7da06..4437efaa3 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -26,12 +26,6 @@ extension SettingReducer { .onChange(of: \.setting.preferredColorScheme) { _, _ in .send(.syncUserInterfaceStyle) } - .onChange(of: \.setting.appIconType) { _, state in - .run { [value = state.setting.appIconType.filename] send in - _ = await applicationClient.setAlternateIconName(value) - await send(.syncAppIconType) - } - } .onChange(of: \.setting.enablesLandscape) { _, state in guard !state.setting.enablesLandscape else { return .none } return .run { _ in @@ -77,9 +71,7 @@ extension SettingReducer { case .syncAppIconTypeDone(let iconName): if let iconName { - let iconType = AppIconType.allCases - .filter({ iconName.contains($0.filename) }).first ?? .default - state.$setting.withLock { $0.appIconType = iconType } + state.$setting.withLock { $0.appIconType = .matching(alternateIconName: iconName) } } return .none diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 708152d70..a4f555bcb 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -115,8 +115,8 @@ public struct SettingView: View { case .about: AboutView() - case .appIcon: - AppIconView(appIconType: $store.settingBinding.appIconType) + case .appIcon(let appIconStore): + AppIconView(store: appIconStore) } } } diff --git a/AppPackage/Tests/AppModelsTests/AppIconTypeMatchingTests.swift b/AppPackage/Tests/AppModelsTests/AppIconTypeMatchingTests.swift new file mode 100644 index 000000000..81464ce51 --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/AppIconTypeMatchingTests.swift @@ -0,0 +1,20 @@ +import Testing +import AppModels + +// `AppIconType.matching(alternateIconName:)` is the shared mapping used by both the Setting tab's launch +// reconciliation and the App Icon screen's post-edit sync. This pins that every known icon round-trips +// through its filename and that anything unrecognized falls back to `.default`. +@Suite +struct AppIconTypeMatchingTests { + @Test + func knownAlternateIconNamesRoundTrip() { + for iconType in AppIconType.allCases { + #expect(AppIconType.matching(alternateIconName: iconType.filename) == iconType) + } + } + + @Test + func unrecognizedNameFallsBackToDefault() { + #expect(AppIconType.matching(alternateIconName: "SomethingElse") == .default) + } +} diff --git a/AppPackage/Tests/SettingFeatureTests/AppIconReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AppIconReducerTests.swift new file mode 100644 index 000000000..22b27a98b --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/AppIconReducerTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing +import AppModels +import Sharing +@testable import SettingFeature +import ComposableArchitecture + +@MainActor +struct AppIconReducerTests { + // The screen writes `appIconType` straight into `@Shared(.setting)`; the reducer then reconciles the + // stored value against whatever icon the system actually reports. This pins that reconciliation by + // feeding the system's icon name directly and asserting it persists through an independent handle, + // with storage isolated to an in-memory suite. + @Test + func syncAppIconTypeDonePersistsMatchedType() async { + let defaults = UserDefaults.inMemory + await withDependencies { + $0.defaultAppStorage = defaults + } operation: { + let store = TestStore(initialState: .init(), reducer: AppIconReducer.init) { + $0.defaultAppStorage = defaults + } + store.exhaustivity = .off + + await store.send(.syncAppIconTypeDone(AppIconType.ukiyoe.filename)) + + @Shared(.setting) var persisted + #expect(persisted.appIconType == .ukiyoe) + } + } +} From 0043fc49fb1337f105204d32c7613943835f9ed2 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 12:08:02 +0800 Subject: [PATCH 584/614] Give Reading its own reducer for landscape --- .../Components/ReadingSettingReducer.swift | 36 +++++++++++++++++++ .../Sources/SettingFeature/SettingPath.swift | 2 +- .../SettingFeature/SettingReducer+Body.swift | 7 ---- .../SettingFeature/SettingReducer.swift | 2 -- .../Sources/SettingFeature/SettingView.swift | 22 ++++++++---- .../ReadingSettingReducerTests.swift | 29 +++++++++++++++ 6 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 AppPackage/Sources/SettingFeature/Components/ReadingSettingReducer.swift create mode 100644 AppPackage/Tests/SettingFeatureTests/ReadingSettingReducerTests.swift diff --git a/AppPackage/Sources/SettingFeature/Components/ReadingSettingReducer.swift b/AppPackage/Sources/SettingFeature/Components/ReadingSettingReducer.swift new file mode 100644 index 000000000..585fce58b --- /dev/null +++ b/AppPackage/Sources/SettingFeature/Components/ReadingSettingReducer.swift @@ -0,0 +1,36 @@ +import ComposableArchitecture +import DeviceClient +import AppDelegateClient + +@Reducer +public struct ReadingSettingReducer: Sendable { + @ObservableState + public struct State: Equatable, Sendable { + public init() {} + } + + public enum Action: Equatable, Sendable { + // The view writes `enablesLandscape` straight into `@Shared(.setting)`, which dispatches no + // action, so it bridges the change here; this reducer re-locks portrait orientation when + // landscape is turned off (phones only — iPad always allows rotation). + case enablesLandscapeChanged(Bool) + } + + @Dependency(\.deviceClient) private var deviceClient + @Dependency(\.appDelegateClient) private var appDelegateClient + + public init() {} + + public var body: some Reducer { + Reduce { _, action in + switch action { + case .enablesLandscapeChanged(let enablesLandscape): + guard !enablesLandscape else { return .none } + return .run { _ in + guard await !deviceClient.isPad() else { return } + await appDelegateClient.setPortraitOrientationMask() + } + } + } + } +} diff --git a/AppPackage/Sources/SettingFeature/SettingPath.swift b/AppPackage/Sources/SettingFeature/SettingPath.swift index b4610170a..4339ffafe 100644 --- a/AppPackage/Sources/SettingFeature/SettingPath.swift +++ b/AppPackage/Sources/SettingFeature/SettingPath.swift @@ -15,7 +15,7 @@ public enum SettingPath { case ehSetting(EhSettingReducer) case appActivityLogs(AppActivityLogsReducer) case download(StaticSettingScreenReducer) - case reading(StaticSettingScreenReducer) + case reading(ReadingSettingReducer) case laboratory(LaboratorySettingReducer) case about(StaticSettingScreenReducer) case appIcon(AppIconReducer) diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 4437efaa3..6650a473a 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -26,13 +26,6 @@ extension SettingReducer { .onChange(of: \.setting.preferredColorScheme) { _, _ in .send(.syncUserInterfaceStyle) } - .onChange(of: \.setting.enablesLandscape) { _, state in - guard !state.setting.enablesLandscape else { return .none } - return .run { _ in - guard await !deviceClient.isPad() else { return } - await appDelegateClient.setPortraitOrientationMask() - } - } Reduce { state, action in switch action { diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index 9c61725de..e5904ac7f 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -130,10 +130,8 @@ public struct SettingReducer: Sendable { @Dependency(\.applicationClient) var applicationClient @Dependency(\.userDefaultsClient) var userDefaultsClient - @Dependency(\.appDelegateClient) var appDelegateClient @Dependency(\.libraryClient) var libraryClient @Dependency(\.cookieClient) var cookieClient - @Dependency(\.deviceClient) var deviceClient @Dependency(\.fileClient) var fileClient @Dependency(\.dfClient) var dfClient diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index a4f555bcb..b9f315be2 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -1,4 +1,6 @@ import SwiftUI +import AppModels +import Sharing import Resources import SFSafeSymbols import ComposableArchitecture @@ -7,6 +9,9 @@ import ReadingSettingFeature public struct SettingView: View { @Bindable private var store: StoreOf + // Write handle for the drill-down screens whose editors bind `setting` directly; each screen also + // reads it through its own `@Shared`/`@SharedReader`. Same underlying storage as `store.setting`. + @Shared(.setting) private var setting: Setting private let blurRadius: Double public init(store: StoreOf, blurRadius: Double) { @@ -99,15 +104,18 @@ public struct SettingView: View { downloadAutoRetryFailedPages: $store.settingBinding.downloadAutoRetryFailedPages ) - case .reading: + case .reading(let readingStore): ReadingSettingView( - readingDirection: $store.settingBinding.readingDirection, - prefetchLimit: $store.settingBinding.prefetchLimit, - enablesLandscape: $store.settingBinding.enablesLandscape, - contentDividerHeight: $store.settingBinding.contentDividerHeight, - maximumScaleFactor: $store.settingBinding.maximumScaleFactor, - doubleTapScaleFactor: $store.settingBinding.doubleTapScaleFactor + readingDirection: Binding($setting.readingDirection), + prefetchLimit: Binding($setting.prefetchLimit), + enablesLandscape: Binding($setting.enablesLandscape), + contentDividerHeight: Binding($setting.contentDividerHeight), + maximumScaleFactor: Binding($setting.maximumScaleFactor), + doubleTapScaleFactor: Binding($setting.doubleTapScaleFactor) ) + .onChange(of: setting.enablesLandscape) { _, newValue in + readingStore.send(.enablesLandscapeChanged(newValue)) + } case .laboratory(let laboratoryStore): LaboratorySettingView(store: laboratoryStore) diff --git a/AppPackage/Tests/SettingFeatureTests/ReadingSettingReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/ReadingSettingReducerTests.swift new file mode 100644 index 000000000..e55e88e31 --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/ReadingSettingReducerTests.swift @@ -0,0 +1,29 @@ +import ComposableArchitecture +import Testing +import DeviceClient +import AppDelegateClient +@testable import SettingFeature + +@MainActor +struct ReadingSettingReducerTests { + // Turning landscape ON never re-locks orientation, so the change action returns no effect and + // touches no dependency. + @Test + func enablingLandscapeRunsNoEffect() async { + let store = TestStore(initialState: .init(), reducer: ReadingSettingReducer.init) + await store.send(.enablesLandscapeChanged(true)) + await store.finish() + } + + // Turning landscape OFF on a phone re-applies the portrait mask; this pins that the effect chain + // runs to completion (the AppDelegate mask call is fire-and-forget and not capturable). + @Test + func disablingLandscapeOnPhoneRunsToCompletion() async { + let store = TestStore(initialState: .init(), reducer: ReadingSettingReducer.init) { + $0.deviceClient = .noop + $0.appDelegateClient = .noop + } + await store.send(.enablesLandscapeChanged(false)) + await store.finish() + } +} From 352cd76b54454845d7e317283aad0f52bdd28dd3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 12:13:32 +0800 Subject: [PATCH 585/614] Move galleryHost effect into Account reducer --- .../AccountSettingReducer.swift | 9 +++++++ .../AccountSetting/AccountSettingView.swift | 26 +++++++------------ .../SettingFeature/SettingReducer+Body.swift | 5 ---- .../Sources/SettingFeature/SettingView.swift | 8 +----- .../AccountSettingReducerTests.swift | 20 ++++++++++++++ 5 files changed, 39 insertions(+), 29 deletions(-) create mode 100644 AppPackage/Tests/SettingFeatureTests/AccountSettingReducerTests.swift diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift index 50dd0508c..d7c2fcb6b 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -5,6 +5,8 @@ import ComposableArchitecture import HapticsClient import ClipboardClient import CookieClient +import UserDefaultsClient +import AppTools import AppComponents @Reducer @@ -47,11 +49,13 @@ public struct AccountSettingReducer: Sendable { case onLogoutConfirmButtonTapped case loadCookies case copyCookies(GalleryHost) + case galleryHostChanged(GalleryHost) } @Dependency(\.clipboardClient) private var clipboardClient @Dependency(\.cookieClient) private var cookieClient @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.userDefaultsClient) private var userDefaultsClient public init() {} @@ -118,6 +122,11 @@ public struct AccountSettingReducer: Sendable { .run(operation: { _ in clipboardClient.saveText(cookiesDescription) }), .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) ) + + // The picker writes `galleryHost` straight into `@Shared(.setting)`; mirror it into + // UserDefaults so the selected host survives independently of the persisted setting blob. + case .galleryHostChanged(let host): + return .run(operation: { _ in userDefaultsClient.setValue(host.rawValue, .galleryHost) }) } } .haptics( diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index 1a74010a3..ec391a19b 100644 --- a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import Sharing import AppComponents import Resources import ComposableArchitecture @@ -8,20 +9,11 @@ import SystemNotificationExt struct AccountSettingView: View { @Bindable private var store: StoreOf - @Binding private var galleryHost: GalleryHost - @Binding private var showsNewDawnGreeting: Bool - private let bypassesSNIFiltering: Bool + @Shared(.setting) private var setting: Setting private let blurRadius: Double - init( - store: StoreOf, - galleryHost: Binding, showsNewDawnGreeting: Binding, - bypassesSNIFiltering: Bool, blurRadius: Double - ) { + init(store: StoreOf, blurRadius: Double) { self.store = store - _galleryHost = galleryHost - _showsNewDawnGreeting = showsNewDawnGreeting - self.bypassesSNIFiltering = bypassesSNIFiltering self.blurRadius = blurRadius } @@ -29,7 +21,7 @@ struct AccountSettingView: View { var body: some View { Form { Section { - Picker(.website, selection: $galleryHost) { + Picker(.website, selection: Binding($setting.galleryHost)) { ForEach(GalleryHost.allCases) { Text($0.rawValue).tag($0) } @@ -37,8 +29,8 @@ struct AccountSettingView: View { .pickerStyle(.segmented) .labelsHidden() AccountSection( - showsNewDawnGreeting: $showsNewDawnGreeting, - bypassesSNIFiltering: bypassesSNIFiltering, + showsNewDawnGreeting: Binding($setting.showsNewDawnGreeting), + bypassesSNIFiltering: setting.bypassesSNIFiltering, loginAction: { store.send(.delegate(.pushLogin)) }, logoutDialogAction: { store.send(.logoutButtonTapped) }, logoutConfirmationDialog: $store.scope( @@ -61,6 +53,9 @@ struct AccountSettingView: View { .autoBlur(radius: blurRadius) } .onAppear { store.send(.loadCookies) } + .onChange(of: setting.galleryHost) { _, newValue in + store.send(.galleryHostChanged(newValue)) + } .navigationTitle(.account) } } @@ -187,9 +182,6 @@ struct AccountSettingView_Previews: PreviewProvider { NavigationStack { AccountSettingView( store: .init(initialState: .init(), reducer: AccountSettingReducer.init), - galleryHost: .constant(.ehentai), - showsNewDawnGreeting: .constant(false), - bypassesSNIFiltering: false, blurRadius: 0 ) } diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 6650a473a..4733e99a9 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -14,11 +14,6 @@ extension SettingReducer { // handlers carry only genuine side effects. Cross-field invariants (the scale factors and // auto-lock↔blur) live on the `Setting` model instead, so every write path preserves them. BindingReducer() - .onChange(of: \.setting.galleryHost) { _, state in - .run(operation: { [value = state.setting.galleryHost.rawValue] _ in - userDefaultsClient.setValue(value, .galleryHost) - }) - } .onChange(of: \.setting.enablesTagsExtension) { _, state in // `.rebuildTagTranslator` sequences the remote fetch after the cache rebuild. state.setting.enablesTagsExtension ? .send(.rebuildTagTranslator) : .none diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index b9f315be2..33c47cb8b 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -44,13 +44,7 @@ public struct SettingView: View { private func destination(_ pathStore: StoreOf) -> some View { switch pathStore.case { case .account(let accountStore): - AccountSettingView( - store: accountStore, - galleryHost: $store.settingBinding.galleryHost, - showsNewDawnGreeting: $store.settingBinding.showsNewDawnGreeting, - bypassesSNIFiltering: store.setting.bypassesSNIFiltering, - blurRadius: blurRadius - ) + AccountSettingView(store: accountStore, blurRadius: blurRadius) case .general(let generalStore): GeneralSettingView( diff --git a/AppPackage/Tests/SettingFeatureTests/AccountSettingReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AccountSettingReducerTests.swift new file mode 100644 index 000000000..751e2c2b9 --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/AccountSettingReducerTests.swift @@ -0,0 +1,20 @@ +import ComposableArchitecture +import Testing +import AppModels +import UserDefaultsClient +@testable import SettingFeature + +@MainActor +struct AccountSettingReducerTests { + // The gallery-host picker writes into `@Shared(.setting)`; the reducer mirrors the choice into + // UserDefaults (a non-capturable fire-and-forget client), so this pins that the wiring is handled + // and runs to completion. + @Test + func galleryHostChangedRunsToCompletion() async { + let store = TestStore(initialState: .init(), reducer: AccountSettingReducer.init) { + $0.userDefaultsClient = .noop + } + await store.send(.galleryHostChanged(.exhentai)) + await store.finish() + } +} From 5de9e5de5bac115c236e67eae2f2f3239bee4732 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 12:21:44 +0800 Subject: [PATCH 586/614] Trigger tag rebuild via General delegate --- .../GeneralSettingReducer.swift | 5 +- .../GeneralSetting/GeneralSettingView.swift | 61 ++++++------------- .../SettingFeature/SettingReducer+Body.swift | 11 ++-- .../Sources/SettingFeature/SettingView.swift | 10 +-- .../SettingReducerNavigationTests.swift | 44 +++++++++++++ 5 files changed, 75 insertions(+), 56 deletions(-) diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift index 9fd6faeef..9bcddb835 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -23,9 +23,12 @@ public struct GeneralSettingReducer: Sendable { case confirmRemoveCustomTranslations } - // Pushes handled by SettingReducer, which owns the Setting navigation stack. + // Handled by SettingReducer, which owns the Setting navigation stack and the tag-translator + // subsystem. `enablesTagsExtensionChanged` lets the view report a `@Shared(.setting)` edit so the + // parent can rebuild the translator (the write itself dispatches no action). public enum Delegate: Equatable, Sendable { case pushAppActivityLogs + case enablesTagsExtensionChanged } @ObservableState diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index 9d4784eb1..7413c7a95 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppModels +import Sharing import Resources import UniformTypeIdentifiers import ComposableArchitecture @@ -7,39 +8,20 @@ import AppComponents struct GeneralSettingView: View { @Bindable private var store: StoreOf + @Shared(.setting) private var setting: Setting private let tagTranslatorLoadingState: LoadingState private let tagTranslatorEmpty: Bool private let tagTranslatorHasCustomTranslations: Bool - @Binding private var enablesTagsExtension: Bool - @Binding private var translatesTags: Bool - @Binding private var showsTagsSearchSuggestion: Bool - @Binding private var showsImagesInTags: Bool - @Binding private var redirectsLinksToSelectedHost: Bool - @Binding private var detectsLinksFromClipboard: Bool - @Binding private var backgroundBlurRadius: Double - @Binding private var autoLockPolicy: AutoLockPolicy init( store: StoreOf, tagTranslatorLoadingState: LoadingState, tagTranslatorEmpty: Bool, - tagTranslatorHasCustomTranslations: Bool, enablesTagsExtension: Binding, - translatesTags: Binding, showsTagsSearchSuggestion: Binding, - showsImagesInTags: Binding, redirectsLinksToSelectedHost: Binding, - detectsLinksFromClipboard: Binding, backgroundBlurRadius: Binding, - autoLockPolicy: Binding + tagTranslatorHasCustomTranslations: Bool ) { self.store = store self.tagTranslatorLoadingState = tagTranslatorLoadingState self.tagTranslatorEmpty = tagTranslatorEmpty self.tagTranslatorHasCustomTranslations = tagTranslatorHasCustomTranslations - _enablesTagsExtension = enablesTagsExtension - _translatesTags = translatesTags - _showsTagsSearchSuggestion = showsTagsSearchSuggestion - _showsImagesInTags = showsImagesInTags - _redirectsLinksToSelectedHost = redirectsLinksToSelectedHost - _detectsLinksFromClipboard = detectsLinksFromClipboard - _backgroundBlurRadius = backgroundBlurRadius - _autoLockPolicy = autoLockPolicy } private var language: String { @@ -72,7 +54,7 @@ struct GeneralSettingView: View { Image(systemSymbol: .exclamationmarkTriangleFill) .foregroundStyle(.yellow) .opacity( - translatesTags && tagTranslatorEmpty + setting.translatesTags && tagTranslatorEmpty && tagTranslatorLoadingState != .loading ? 1 : 0 ) ProgressView() @@ -80,18 +62,18 @@ struct GeneralSettingView: View { .opacity(tagTranslatorLoadingState == .loading ? 1 : 0) } - Toggle(.enablesTagsExtension, isOn: $enablesTagsExtension) + Toggle(.enablesTagsExtension, isOn: Binding($setting.enablesTagsExtension)) .labelsHidden() .frame(width: 50) .padding(.leading, 20) } - if enablesTagsExtension && !tagTranslatorEmpty { - Toggle(.translatesTags, isOn: $translatesTags) + if setting.enablesTagsExtension && !tagTranslatorEmpty { + Toggle(.translatesTags, isOn: Binding($setting.translatesTags)) Toggle( .showsTagsSearchSuggestion, - isOn: $showsTagsSearchSuggestion + isOn: Binding($setting.showsTagsSearchSuggestion) ) - Toggle(.showsImagesInTags, isOn: $showsImagesInTags) + Toggle(.showsImagesInTags, isOn: Binding($setting.showsImagesInTags)) } Button(.importCustomTranslations) { store.send(.importCustomTranslationsButtonTapped) @@ -117,25 +99,25 @@ struct GeneralSettingView: View { Section(.navigation) { Toggle( .redirectsLinksToTheSelectedHost, - isOn: $redirectsLinksToSelectedHost + isOn: Binding($setting.redirectsLinksToSelectedHost) ) Toggle( .detectsLinksFromClipboard, - isOn: $detectsLinksFromClipboard + isOn: Binding($setting.detectsLinksFromClipboard) ) } Section(.security) { HStack { Picker( .autoLock, - selection: $autoLockPolicy + selection: Binding($setting.autoLockPolicy) ) { ForEach(AutoLockPolicy.allCases) { policy in Text(policy.value).tag(policy) } } .pickerStyle(.menu) - if store.passcodeNotSet && autoLockPolicy != .never { + if store.passcodeNotSet && setting.autoLockPolicy != .never { Image(systemSymbol: .exclamationmarkTriangleFill).foregroundStyle(.yellow) } } @@ -143,7 +125,7 @@ struct GeneralSettingView: View { Text(.backgroundBlurRadius) HStack { Image(systemSymbol: .eye) - Slider(value: $backgroundBlurRadius, in: 0...100, step: 10) + Slider(value: Binding($setting.backgroundBlurRadius), in: 0...100, step: 10) Image(systemSymbol: .eyeSlash) } } @@ -166,8 +148,11 @@ struct GeneralSettingView: View { } .animation(.default, value: tagTranslatorHasCustomTranslations) .animation(.default, value: tagTranslatorLoadingState) - .animation(.default, value: enablesTagsExtension) + .animation(.default, value: setting.enablesTagsExtension) .animation(.default, value: tagTranslatorEmpty) + .onChange(of: setting.enablesTagsExtension) { _, _ in + store.send(.delegate(.enablesTagsExtensionChanged)) + } .onAppear { store.send(.checkPasscodeSetting) store.send(.calculateWebImageDiskCache) @@ -183,15 +168,7 @@ struct GeneralSettingView_Previews: PreviewProvider { store: .init(initialState: .init(), reducer: GeneralSettingReducer.init), tagTranslatorLoadingState: .idle, tagTranslatorEmpty: false, - tagTranslatorHasCustomTranslations: false, - enablesTagsExtension: .constant(false), - translatesTags: .constant(false), - showsTagsSearchSuggestion: .constant(false), - showsImagesInTags: .constant(false), - redirectsLinksToSelectedHost: .constant(false), - detectsLinksFromClipboard: .constant(false), - backgroundBlurRadius: .constant(10), - autoLockPolicy: .constant(.never) + tagTranslatorHasCustomTranslations: false ) } } diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 4733e99a9..79308449d 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -14,10 +14,6 @@ extension SettingReducer { // handlers carry only genuine side effects. Cross-field invariants (the scale factors and // auto-lock↔blur) live on the `Setting` model instead, so every write path preserves them. BindingReducer() - .onChange(of: \.setting.enablesTagsExtension) { _, state in - // `.rebuildTagTranslator` sequences the remote fetch after the cache rebuild. - state.setting.enablesTagsExtension ? .send(.rebuildTagTranslator) : .none - } .onChange(of: \.setting.preferredColorScheme) { _, _ in .send(.syncUserInterfaceStyle) } @@ -48,6 +44,13 @@ extension SettingReducer { state.path.appendGuardingDuplicate(.appActivityLogs(.init())) return .none + // The General screen edits `enablesTagsExtension` via `@Shared(.setting)`; rebuild the tag + // translator when it's turned on. The model's `didSet` clears the sub-toggles on disable, so + // only the enable case does work here. `.rebuildTagTranslator` sequences the remote fetch + // after the offline cache rebuild. + case .path(.element(id: _, action: .general(.delegate(.enablesTagsExtensionChanged)))): + return state.setting.enablesTagsExtension ? .send(.rebuildTagTranslator) : .none + case .path(.element(id: _, action: .appearance(.delegate(.pushAppIcon)))): state.path.appendGuardingDuplicate(.appIcon(.init())) return .none diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 33c47cb8b..4ec4b2fb9 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -51,15 +51,7 @@ public struct SettingView: View { store: generalStore, tagTranslatorLoadingState: store.tagTranslatorLoadingState, tagTranslatorEmpty: store.tagTranslator.translations.isEmpty, - tagTranslatorHasCustomTranslations: store.tagTranslator.hasCustomTranslations, - enablesTagsExtension: $store.settingBinding.enablesTagsExtension, - translatesTags: $store.settingBinding.translatesTags, - showsTagsSearchSuggestion: $store.settingBinding.showsTagsSearchSuggestion, - showsImagesInTags: $store.settingBinding.showsImagesInTags, - redirectsLinksToSelectedHost: $store.settingBinding.redirectsLinksToSelectedHost, - detectsLinksFromClipboard: $store.settingBinding.detectsLinksFromClipboard, - backgroundBlurRadius: $store.settingBinding.backgroundBlurRadius, - autoLockPolicy: $store.settingBinding.autoLockPolicy + tagTranslatorHasCustomTranslations: store.tagTranslator.hasCustomTranslations ) case .appearance(let appearanceStore): diff --git a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift index aabc41d7d..276f55e61 100644 --- a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift +++ b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift @@ -117,6 +117,50 @@ struct SettingReducerNavigationTests { } } + // MARK: Child delegate → parent effect + + @Test + func generalEnablesTagsExtensionDelegateRebuildsWhenEnabled() async throws { + let defaults = UserDefaults.inMemory + try await withDependencies { + $0.defaultAppStorage = defaults + } operation: { + @Shared(.setting) var setting + $setting.withLock { $0.enablesTagsExtension = true } + + // A loading table makes the rebuild's follow-on `fetchTagTranslator` guard-return (no network). + var initialState = SettingReducer.State() + initialState.tagTranslatorLoadingState = .loading + + let store = TestStore(initialState: initialState, reducer: SettingReducer.init) { + $0.defaultAppStorage = defaults + $0.fileClient.loadCachedTagTranslator = { _ in nil } + } + store.exhaustivity = .off + + await store.send(.settingRowTapped(.general)) + let id = try #require(store.state.path.ids.last) + await store.send(.path(.element(id: id, action: .general(.delegate(.enablesTagsExtensionChanged))))) + await store.receive(\.rebuildTagTranslator) + await store.finish() + } + } + + @Test + func generalEnablesTagsExtensionDelegateSkipsRebuildWhenDisabled() async throws { + // `enablesTagsExtension` defaults to false, so the delegate must emit no rebuild — the exhaustive + // store fails if any effect is left unhandled. + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { + $0.defaultAppStorage = UserDefaults.inMemory + } + + await store.send(.settingRowTapped(.general)) { + $0.path.append(SettingReducer.RootScreen.general.pathElement) + } + let id = try #require(store.state.path.ids.last) + await store.send(.path(.element(id: id, action: .general(.delegate(.enablesTagsExtensionChanged))))) + } + // MARK: Child intercepts @Test From 4ada8cdf32e5ab4472e6c8d5b600d892c0090d42 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 12:26:27 +0800 Subject: [PATCH 587/614] Move color-scheme effect into Appearance reducer --- .../AppearanceSettingReducer.swift | 13 ++++- .../AppearanceSettingView.swift | 53 +++++-------------- .../SettingFeature/SettingReducer+Body.swift | 3 -- .../Sources/SettingFeature/SettingView.swift | 11 +--- .../AppearanceSettingReducerTests.swift | 19 +++++++ 5 files changed, 44 insertions(+), 55 deletions(-) create mode 100644 AppPackage/Tests/SettingFeatureTests/AppearanceSettingReducerTests.swift diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift index ead871c5e..b12c83afd 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift @@ -1,9 +1,10 @@ +import AppModels import ComposableArchitecture +import ApplicationClient @Reducer public struct AppearanceSettingReducer: Sendable { - // Pushes handled by SettingReducer, which owns the Setting navigation stack. The screen itself is - // stateless — its controls bind directly into `SettingReducer.State.setting` from the root. + // Pushes handled by SettingReducer, which owns the Setting navigation stack. public enum Delegate: Equatable, Sendable { case pushAppIcon } @@ -15,8 +16,13 @@ public struct AppearanceSettingReducer: Sendable { public enum Action: Equatable, Sendable { case delegate(Delegate) + // The theme picker writes `preferredColorScheme` into `@Shared(.setting)`, which dispatches no + // action, so the view bridges the change here for this reducer to apply the interface style. + case preferredColorSchemeChanged(PreferredColorScheme) } + @Dependency(\.applicationClient) private var applicationClient + public init() {} public var body: some Reducer { @@ -24,6 +30,9 @@ public struct AppearanceSettingReducer: Sendable { switch action { case .delegate: return .none + + case .preferredColorSchemeChanged(let colorScheme): + return .run { _ in await applicationClient.setUserInterfaceStyle(colorScheme.userInterfaceStyle) } } } } diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift index 039d45321..4407f59af 100644 --- a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -7,33 +7,10 @@ import AppComponents struct AppearanceSettingView: View { private let store: StoreOf + @Shared(.setting) private var setting: Setting - @Binding private var preferredColorScheme: PreferredColorScheme - @Binding private var accentColor: Color - @Binding private var appIconType: AppIconType - @Binding private var listDisplayMode: ListDisplayMode - @Binding private var showsTagsInList: Bool - @Binding private var listTagsNumberMaximum: Int - @Binding private var displaysJapaneseTitle: Bool - - init( - store: StoreOf, - preferredColorScheme: Binding, - accentColor: Binding, - appIconType: Binding, - listDisplayMode: Binding, - showsTagsInList: Binding, - listTagsNumberMaximum: Binding, - displaysJapaneseTitle: Binding - ) { + init(store: StoreOf) { self.store = store - _preferredColorScheme = preferredColorScheme - _accentColor = accentColor - _appIconType = appIconType - _listDisplayMode = listDisplayMode - _showsTagsInList = showsTagsInList - _listTagsNumberMaximum = listTagsNumberMaximum - _displaysJapaneseTitle = displaysJapaneseTitle } var body: some View { @@ -41,7 +18,7 @@ struct AppearanceSettingView: View { Section { Picker( .theme, - selection: $preferredColorScheme + selection: Binding($setting.preferredColorScheme) ) { ForEach(PreferredColorScheme.allCases) { colorScheme in Text(colorScheme.value) @@ -50,7 +27,7 @@ struct AppearanceSettingView: View { } .pickerStyle(.menu) - ColorPicker(.tintColor, selection: $accentColor) + ColorPicker(.tintColor, selection: Binding($setting.accentColor)) Button(.appIcon) { store.send(.delegate(.pushAppIcon)) @@ -61,7 +38,7 @@ struct AppearanceSettingView: View { Section(.list) { Picker( .appearanceDisplayMode, - selection: $listDisplayMode, + selection: Binding($setting.listDisplayMode), content: { ForEach(ListDisplayMode.allCases) { listMode in Text(listMode.value) @@ -71,13 +48,13 @@ struct AppearanceSettingView: View { ) .pickerStyle(.menu) - Toggle(isOn: $showsTagsInList) { + Toggle(isOn: Binding($setting.showsTagsInList)) { Text(.showsTagsInList) } Picker( .maximumNumberOfTags, - selection: $listTagsNumberMaximum + selection: Binding($setting.listTagsNumberMaximum) ) { Text(.infite) .tag(0) @@ -88,16 +65,19 @@ struct AppearanceSettingView: View { } } .pickerStyle(.menu) - .disabled(!showsTagsInList) + .disabled(!setting.showsTagsInList) } Section(.gallery) { Toggle( .displaysJapaneseTitle, - isOn: $displaysJapaneseTitle + isOn: Binding($setting.displaysJapaneseTitle) ) } } .navigationTitle(.appearance) + .onChange(of: setting.preferredColorScheme) { _, newValue in + store.send(.preferredColorSchemeChanged(newValue)) + } } } @@ -169,14 +149,7 @@ struct AppearanceSettingView_Previews: PreviewProvider { static var previews: some View { NavigationStack { AppearanceSettingView( - store: .init(initialState: .init(), reducer: AppearanceSettingReducer.init), - preferredColorScheme: .constant(.automatic), - accentColor: .constant(.blue), - appIconType: .constant(.default), - listDisplayMode: .constant(.detail), - showsTagsInList: .constant(false), - listTagsNumberMaximum: .constant(0), - displaysJapaneseTitle: .constant(true) + store: .init(initialState: .init(), reducer: AppearanceSettingReducer.init) ) } } diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index 79308449d..f88cf4f90 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -14,9 +14,6 @@ extension SettingReducer { // handlers carry only genuine side effects. Cross-field invariants (the scale factors and // auto-lock↔blur) live on the `Setting` model instead, so every write path preserves them. BindingReducer() - .onChange(of: \.setting.preferredColorScheme) { _, _ in - .send(.syncUserInterfaceStyle) - } Reduce { state, action in switch action { diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 4ec4b2fb9..9d16b05b0 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -55,16 +55,7 @@ public struct SettingView: View { ) case .appearance(let appearanceStore): - AppearanceSettingView( - store: appearanceStore, - preferredColorScheme: $store.settingBinding.preferredColorScheme, - accentColor: $store.settingBinding.accentColor, - appIconType: $store.settingBinding.appIconType, - listDisplayMode: $store.settingBinding.listDisplayMode, - showsTagsInList: $store.settingBinding.showsTagsInList, - listTagsNumberMaximum: $store.settingBinding.listTagsNumberMaximum, - displaysJapaneseTitle: $store.settingBinding.displaysJapaneseTitle - ) + AppearanceSettingView(store: appearanceStore) case .login(let loginStore): LoginView( diff --git a/AppPackage/Tests/SettingFeatureTests/AppearanceSettingReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AppearanceSettingReducerTests.swift new file mode 100644 index 000000000..552fbaa60 --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/AppearanceSettingReducerTests.swift @@ -0,0 +1,19 @@ +import ComposableArchitecture +import Testing +import AppModels +import ApplicationClient +@testable import SettingFeature + +@MainActor +struct AppearanceSettingReducerTests { + // The theme picker writes into `@Shared(.setting)`; the reducer applies the interface style through + // a non-capturable client, so this pins that the change action is handled and runs to completion. + @Test + func preferredColorSchemeChangedRunsToCompletion() async { + let store = TestStore(initialState: .init(), reducer: AppearanceSettingReducer.init) { + $0.applicationClient = .noop + } + await store.send(.preferredColorSchemeChanged(.dark)) + await store.finish() + } +} From 2ace7ca367d6f5fc369c98393ed8536de1df4e46 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 12:29:20 +0800 Subject: [PATCH 588/614] Read setting via `@Shared` in Download screen --- .../Components/DownloadSettingView.swift | 32 ++++++------------- .../Sources/SettingFeature/SettingView.swift | 6 +--- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift index 4d46f8ad1..6abaf332e 100644 --- a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift @@ -1,41 +1,31 @@ import SwiftUI +import AppModels +import Sharing import Resources struct DownloadSettingView: View { - @Binding private var downloadThreadLimit: Int - @Binding private var downloadAllowCellular: Bool - @Binding private var downloadAutoRetryFailedPages: Bool - - init( - downloadThreadLimit: Binding, - downloadAllowCellular: Binding, - downloadAutoRetryFailedPages: Binding - ) { - _downloadThreadLimit = downloadThreadLimit - _downloadAllowCellular = downloadAllowCellular - _downloadAutoRetryFailedPages = downloadAutoRetryFailedPages - } + @Shared(.setting) private var setting: Setting var body: some View { Form { Section { VStack(alignment: .leading) { LabeledContent(.concurrentImageDownloads) { - Text(downloadThreadLimit, format: .number) + Text(setting.downloadThreadLimit, format: .number) .monospacedDigit() } Slider(value: downloadThreadLimitValue, in: 1...5, step: 1) } Toggle( .retryFailedPagesAutomatically, - isOn: $downloadAutoRetryFailedPages + isOn: Binding($setting.downloadAutoRetryFailedPages) ) } Section { Toggle( .allowCellularDownloads, - isOn: $downloadAllowCellular + isOn: Binding($setting.downloadAllowCellular) ) } header: { Text(.network) @@ -48,8 +38,8 @@ struct DownloadSettingView: View { private var downloadThreadLimitValue: Binding { .init( - get: { Double(downloadThreadLimit) }, - set: { downloadThreadLimit = Int($0.rounded()) } + get: { Double(setting.downloadThreadLimit) }, + set: { newValue in $setting.withLock { $0.downloadThreadLimit = Int(newValue.rounded()) } } ) } } @@ -57,11 +47,7 @@ struct DownloadSettingView: View { struct DownloadSettingView_Previews: PreviewProvider { static var previews: some View { NavigationStack { - DownloadSettingView( - downloadThreadLimit: .constant(1), - downloadAllowCellular: .constant(true), - downloadAutoRetryFailedPages: .constant(true) - ) + DownloadSettingView() } } } diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 9d16b05b0..26fe4fecc 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -75,11 +75,7 @@ public struct SettingView: View { AppActivityLogsView(store: logsStore) case .download: - DownloadSettingView( - downloadThreadLimit: $store.settingBinding.downloadThreadLimit, - downloadAllowCellular: $store.settingBinding.downloadAllowCellular, - downloadAutoRetryFailedPages: $store.settingBinding.downloadAutoRetryFailedPages - ) + DownloadSettingView() case .reading(let readingStore): ReadingSettingView( From 0cba11e0331efce259d429c5072d329c46e1d95a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 12:33:06 +0800 Subject: [PATCH 589/614] Delete settingBinding and BindingReducer from Setting --- .../SettingFeature/SettingReducer+Body.swift | 11 +++------ .../SettingFeature/SettingReducer.swift | 24 ++++++------------- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift index f88cf4f90..5b3573d8e 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -10,16 +10,11 @@ private let logger = Logger(category: .init(describing: SettingReducer.self)) extension SettingReducer { @ReducerBuilder var reducerBody: some Reducer { - // `setting` is `@Shared`, so BindingReducer writes persist automatically; these `.onChange` - // handlers carry only genuine side effects. Cross-field invariants (the scale factors and - // auto-lock↔blur) live on the `Setting` model instead, so every write path preserves them. - BindingReducer() - + // No `BindingReducer`: every Setting screen writes `setting` through its own `@Shared`, so the + // parent never sees a `.binding` action. Per-edit side effects live in each screen's reducer, + // and cross-field invariants live on the `Setting` model, so every write path stays consistent. Reduce { state, action in switch action { - case .binding: - return .none - case .settingRowTapped(let screen): state.path.appendGuardingDuplicate(screen.pathElement) return .none diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index e5904ac7f..022b61cd9 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -49,22 +49,13 @@ public struct SettingReducer: Sendable { @ObservableState public struct State: Equatable, Sendable { - // `setting` is stored directly in `@Shared(.setting)`, so every mutation — form bindings, the - // cross-field `.onChange` fixups, and non-binding syncs like `syncAppIconTypeDone` — persists - // atomically. There is no working copy to keep in step. `user` is likewise shared. - // `tagTranslator` is derived, re-downloadable data: it lives in memory only and is rebuilt at - // launch from the cached raw JSON; only its thin `tagTranslatorInfo` metadata persists (see - // `AppSharedKeys`). + // `setting` is stored directly in `@Shared(.setting)`. The parent only reads it for launch + // reconciliation and its non-binding syncs (e.g. `syncAppIconTypeDone`); each Setting screen + // reads and writes it through its own `@Shared`/`@SharedReader`, so there is no working copy and + // no `.binding` cascade here. `user` is likewise shared. `tagTranslator` is derived, + // re-downloadable data: it lives in memory only and is rebuilt at launch from the cached raw + // JSON; only its thin `tagTranslatorInfo` metadata persists (see `AppSharedKeys`). @Shared(.setting) public var setting: Setting - /// A write-through view of `setting` for SwiftUI bindings. `@Shared`'s own value setter is - /// deprecated (it can't take exclusive access), so binding `$store.setting.x` directly warns; - /// bind `$store.settingBinding.x` instead — its setter routes writes through `withLock`, while - /// still flowing through `BindingReducer` so the cross-field `.onChange(of: \.setting.x)` - /// cascades keep firing (both read the same shared storage). Reads should use `setting`. - public var settingBinding: Setting { - get { setting } - set { $setting.withLock { $0 = newValue } } - } @Shared(.tagTranslator) public var tagTranslator: TagTranslator @Shared(.tagTranslatorInfo) public var tagTranslatorInfo: TagTranslatorInfo @Shared(.user) public var user: User @@ -98,8 +89,7 @@ public struct SettingReducer: Sendable { } } - public enum Action: BindableAction { - case binding(BindingAction) + public enum Action { case path(StackActionOf) case settingRowTapped(RootScreen) case pushLogin From 99de0a49f98a8b08ff14226e3510b6545b0b9866 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 12:56:16 +0800 Subject: [PATCH 590/614] Let ReadingSettingView own its `@Shared` setting --- AppPackage/Package.swift | 3 +- .../Sources/ReadingFeature/ReadingView.swift | 15 ++---- .../ReadingSettingView.swift | 47 ++++++------------- .../Sources/SettingFeature/SettingView.swift | 20 ++------ 4 files changed, 24 insertions(+), 61 deletions(-) diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 4a8fab4b0..65e82d42b 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -567,7 +567,8 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.appModels), .module(.appTools), - .module(.resources) + .module(.resources), + .targetDependency(.sharing) ], resources: [.process(.resources)], plugins: swiftLintPlugins diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 690f3ee32..1d3c5b3e9 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -18,9 +18,9 @@ public struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme @Bindable var store: StoreOf - // The write handle for the reading-setting editor's bindings. Reads still go through - // `store.setting` (the reducer's `@SharedReader`); this is the same underlying storage, exposed - // here so the sheet's controls can write it directly — the model clamps keep every write safe. + // Write handle backing the reader's own controls (e.g. the ControlPanel slider). The reading-setting + // sheet owns its own `@Shared(.setting)`; other reads go through `store.setting`. Same underlying + // storage — the model clamps keep every write safe. @Shared(.setting) private var setting: Setting let gid: String let blurRadius: Double @@ -76,14 +76,7 @@ public struct ReadingView: View { return changeTriggers(content: { content }) .sheet(item: $store.destination.readingSetting, id: \.id) { _ in NavigationStack { - ReadingSettingView( - readingDirection: Binding($setting.readingDirection), - prefetchLimit: Binding($setting.prefetchLimit), - enablesLandscape: Binding($setting.enablesLandscape), - contentDividerHeight: Binding($setting.contentDividerHeight), - maximumScaleFactor: Binding($setting.maximumScaleFactor), - doubleTapScaleFactor: Binding($setting.doubleTapScaleFactor) - ) + ReadingSettingView() .toolbar { if !DeviceUtil.isPad && DeviceUtil.isLandscape { CustomToolbarItem(placement: .cancellationAction) { diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index c086dec81..0000d10e2 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -1,66 +1,54 @@ import SwiftUI import AppModels +import Sharing import Resources import AppTools public struct ReadingSettingView: View { - @Binding private var readingDirection: ReadingDirection - @Binding private var prefetchLimit: Int - @Binding private var enablesLandscape: Bool - @Binding private var contentDividerHeight: Double - @Binding private var maximumScaleFactor: Double - @Binding private var doubleTapScaleFactor: Double + // The reading-setting editor is shared by the Setting tab and the reader sheet; it owns its own + // `@Shared(.setting)` in both, and the model clamps keep every write safe. The enclosing host + // observes the fields that carry side effects (e.g. `enablesLandscape` → orientation). + @Shared(.setting) private var setting: Setting - public init( - readingDirection: Binding, prefetchLimit: Binding, - enablesLandscape: Binding, contentDividerHeight: Binding, - maximumScaleFactor: Binding, doubleTapScaleFactor: Binding - ) { - _readingDirection = readingDirection - _prefetchLimit = prefetchLimit - _enablesLandscape = enablesLandscape - _contentDividerHeight = contentDividerHeight - _maximumScaleFactor = maximumScaleFactor - _doubleTapScaleFactor = doubleTapScaleFactor - } + public init() {} public var body: some View { Form { Section { - Picker(.direction, selection: $readingDirection) { + Picker(.direction, selection: Binding($setting.readingDirection)) { ForEach(ReadingDirection.allCases) { Text($0.value).tag($0) } } .pickerStyle(.menu) - Picker(.preloadLimit, selection: $prefetchLimit) { + Picker(.preloadLimit, selection: Binding($setting.prefetchLimit)) { ForEach(Array(stride(from: 6, through: 18, by: 4)), id: \.self) { value in Text(.RLocalizable.pages(count: value)).tag(value) } } .pickerStyle(.menu) if !DeviceUtil.isPad { - Toggle(.enablesLandscape, isOn: $enablesLandscape) + Toggle(.enablesLandscape, isOn: Binding($setting.enablesLandscape)) } } Section(.readingAppearance) { Picker( .separatorHeight, - selection: $contentDividerHeight + selection: Binding($setting.contentDividerHeight) ) { ForEach(Array(stride(from: 0, through: 20, by: 5)), id: \.self) { value in Text(.Constant.pointValue(value)).tag(Double(value)) } } .pickerStyle(.menu) - .disabled(readingDirection != .vertical) + .disabled(setting.readingDirection != .vertical) ScaleFactorRow( - scaleFactor: $maximumScaleFactor, + scaleFactor: Binding($setting.maximumScaleFactor), labelContent: .maximumScaleFactor, minFactor: 1.5, maxFactor: 10 ) ScaleFactorRow( - scaleFactor: $doubleTapScaleFactor, + scaleFactor: Binding($setting.doubleTapScaleFactor), labelContent: .doubleTapScaleFactor, minFactor: 1.5, maxFactor: 5 ) @@ -119,14 +107,7 @@ private extension Double { struct ReadingSettingView_Previews: PreviewProvider { static var previews: some View { NavigationStack { - ReadingSettingView( - readingDirection: .constant(.vertical), - prefetchLimit: .constant(10), - enablesLandscape: .constant(false), - contentDividerHeight: .constant(0), - maximumScaleFactor: .constant(3), - doubleTapScaleFactor: .constant(2) - ) + ReadingSettingView() } } } diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 26fe4fecc..a6da9cf26 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -1,6 +1,4 @@ import SwiftUI -import AppModels -import Sharing import Resources import SFSafeSymbols import ComposableArchitecture @@ -9,9 +7,6 @@ import ReadingSettingFeature public struct SettingView: View { @Bindable private var store: StoreOf - // Write handle for the drill-down screens whose editors bind `setting` directly; each screen also - // reads it through its own `@Shared`/`@SharedReader`. Same underlying storage as `store.setting`. - @Shared(.setting) private var setting: Setting private let blurRadius: Double public init(store: StoreOf, blurRadius: Double) { @@ -78,17 +73,10 @@ public struct SettingView: View { DownloadSettingView() case .reading(let readingStore): - ReadingSettingView( - readingDirection: Binding($setting.readingDirection), - prefetchLimit: Binding($setting.prefetchLimit), - enablesLandscape: Binding($setting.enablesLandscape), - contentDividerHeight: Binding($setting.contentDividerHeight), - maximumScaleFactor: Binding($setting.maximumScaleFactor), - doubleTapScaleFactor: Binding($setting.doubleTapScaleFactor) - ) - .onChange(of: setting.enablesLandscape) { _, newValue in - readingStore.send(.enablesLandscapeChanged(newValue)) - } + ReadingSettingView() + .onChange(of: store.setting.enablesLandscape) { _, newValue in + readingStore.send(.enablesLandscapeChanged(newValue)) + } case .laboratory(let laboratoryStore): LaboratorySettingView(store: laboratoryStore) From 495e08888caf3d8a9368ef8cb84bbd71983c8a5a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 13:00:50 +0800 Subject: [PATCH 591/614] Read tagTranslator in GeneralSettingView via `@SharedReader` --- .../GeneralSetting/GeneralSettingView.swift | 20 ++++++++----------- .../Sources/SettingFeature/SettingView.swift | 4 +--- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift index 7413c7a95..acab88a46 100644 --- a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -9,21 +9,19 @@ import AppComponents struct GeneralSettingView: View { @Bindable private var store: StoreOf @Shared(.setting) private var setting: Setting + // `tagTranslator` is the in-memory shared table, so its derived flags are read here directly rather + // than threaded from the parent; only the parent-owned fetch `loadingState` is passed in. + @SharedReader(.tagTranslator) private var tagTranslator: TagTranslator private let tagTranslatorLoadingState: LoadingState - private let tagTranslatorEmpty: Bool - private let tagTranslatorHasCustomTranslations: Bool - init( - store: StoreOf, - tagTranslatorLoadingState: LoadingState, tagTranslatorEmpty: Bool, - tagTranslatorHasCustomTranslations: Bool - ) { + init(store: StoreOf, tagTranslatorLoadingState: LoadingState) { self.store = store self.tagTranslatorLoadingState = tagTranslatorLoadingState - self.tagTranslatorEmpty = tagTranslatorEmpty - self.tagTranslatorHasCustomTranslations = tagTranslatorHasCustomTranslations } + private var tagTranslatorEmpty: Bool { tagTranslator.translations.isEmpty } + private var tagTranslatorHasCustomTranslations: Bool { tagTranslator.hasCustomTranslations } + private var language: String { Locale.current.language.languageCode.map(\.identifier).flatMap(Locale.current.localizedString(forLanguageCode:)) ?? String(localized: .defaultLanguageDescription) @@ -166,9 +164,7 @@ struct GeneralSettingView_Previews: PreviewProvider { NavigationStack { GeneralSettingView( store: .init(initialState: .init(), reducer: GeneralSettingReducer.init), - tagTranslatorLoadingState: .idle, - tagTranslatorEmpty: false, - tagTranslatorHasCustomTranslations: false + tagTranslatorLoadingState: .idle ) } } diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index a6da9cf26..c16845d94 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -44,9 +44,7 @@ public struct SettingView: View { case .general(let generalStore): GeneralSettingView( store: generalStore, - tagTranslatorLoadingState: store.tagTranslatorLoadingState, - tagTranslatorEmpty: store.tagTranslator.translations.isEmpty, - tagTranslatorHasCustomTranslations: store.tagTranslator.hasCustomTranslations + tagTranslatorLoadingState: store.tagTranslatorLoadingState ) case .appearance(let appearanceStore): From 8d62396efc9def0c073893dfaf704cdf86e79949 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 13:10:34 +0800 Subject: [PATCH 592/614] Read bypassesSNIFiltering via `@SharedReader` in leaves --- .../SettingFeature/EhSetting/EhSettingView.swift | 9 ++++----- .../Sources/SettingFeature/Login/LoginView.swift | 9 ++++----- AppPackage/Sources/SettingFeature/SettingView.swift | 12 ++---------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift index ad0b4740c..381b1a44c 100644 --- a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Sharing import AppModels import Resources import ComposableArchitecture @@ -7,15 +8,14 @@ import AppComponents struct EhSettingView: View { @Bindable private var store: StoreOf - private let bypassesSNIFiltering: Bool + @SharedReader(.setting) private var setting: Setting private let blurRadius: Double // Should make it an Environment value. private var galleryHost: GalleryHost { AppUtil.galleryHost } - init(store: StoreOf, bypassesSNIFiltering: Bool, blurRadius: Double) { + init(store: StoreOf, blurRadius: Double) { self.store = store - self.bypassesSNIFiltering = bypassesSNIFiltering self.blurRadius = blurRadius } @@ -107,7 +107,7 @@ struct EhSettingView: View { } label: { Image(systemSymbol: .globe) } - .disabled(bypassesSNIFiltering) + .disabled(setting.bypassesSNIFiltering) } ToolbarItem(placement: .confirmationAction) { @@ -137,7 +137,6 @@ struct EhSettingView_Previews: PreviewProvider { initialState: .init(ehSetting: .empty, ehProfile: .empty, loadingState: .idle), reducer: EhSettingReducer.init ), - bypassesSNIFiltering: false, blurRadius: 0 ) } diff --git a/AppPackage/Sources/SettingFeature/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift index de82d4290..6f19fb54c 100644 --- a/AppPackage/Sources/SettingFeature/Login/LoginView.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginView.swift @@ -1,5 +1,6 @@ import AppTools import SwiftUI +import Sharing import AppModels import Resources import ComposableArchitecture @@ -7,14 +8,13 @@ import AppComponents struct LoginView: View { @Bindable private var store: StoreOf - private let bypassesSNIFiltering: Bool + @SharedReader(.setting) private var setting: Setting private let blurRadius: Double @FocusState private var focusedField: LoginReducer.FocusedField? - init(store: StoreOf, bypassesSNIFiltering: Bool, blurRadius: Double) { + init(store: StoreOf, blurRadius: Double) { self.store = store - self.bypassesSNIFiltering = bypassesSNIFiltering self.blurRadius = blurRadius } @@ -97,7 +97,7 @@ struct LoginView: View { } label: { Image(systemSymbol: .globe) } - .disabled(bypassesSNIFiltering) + .disabled(setting.bypassesSNIFiltering) } } } @@ -151,7 +151,6 @@ struct LoginView_Previews: PreviewProvider { NavigationStack { LoginView( store: .init(initialState: .init(), reducer: LoginReducer.init), - bypassesSNIFiltering: false, blurRadius: 0 ) } diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index c16845d94..6bfb4f293 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -51,18 +51,10 @@ public struct SettingView: View { AppearanceSettingView(store: appearanceStore) case .login(let loginStore): - LoginView( - store: loginStore, - bypassesSNIFiltering: store.setting.bypassesSNIFiltering, - blurRadius: blurRadius - ) + LoginView(store: loginStore, blurRadius: blurRadius) case .ehSetting(let ehSettingStore): - EhSettingView( - store: ehSettingStore, - bypassesSNIFiltering: store.setting.bypassesSNIFiltering, - blurRadius: blurRadius - ) + EhSettingView(store: ehSettingStore, blurRadius: blurRadius) case .appActivityLogs(let logsStore): AppActivityLogsView(store: logsStore) From 624f3a9b22041a9cf3b1aed215cd11cc4dc24036 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 13:50:51 +0800 Subject: [PATCH 593/614] Let ReadingSettingView hold its own reducer --- AppPackage/Package.swift | 3 +- .../ReadingFeature/ReadingReducer.swift | 4 +-- .../Sources/ReadingFeature/ReadingView.swift | 6 ++-- .../ReadingSettingReducer.swift | 27 ++++++++++++++ .../ReadingSettingView.swift | 32 ++++++++++------- .../Components/ReadingSettingReducer.swift | 36 ------------------- .../Sources/SettingFeature/SettingPath.swift | 1 + .../SettingFeature/SettingReducer.swift | 2 -- .../Sources/SettingFeature/SettingView.swift | 5 +-- .../ReadingSettingReducerTests.swift | 29 --------------- 10 files changed, 55 insertions(+), 90 deletions(-) create mode 100644 AppPackage/Sources/ReadingSettingFeature/ReadingSettingReducer.swift delete mode 100644 AppPackage/Sources/SettingFeature/Components/ReadingSettingReducer.swift delete mode 100644 AppPackage/Tests/SettingFeatureTests/ReadingSettingReducerTests.swift diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift index 65e82d42b..afbacb348 100644 --- a/AppPackage/Package.swift +++ b/AppPackage/Package.swift @@ -568,6 +568,7 @@ let targets: [PackageDescription.Target] = [ .module(.appModels), .module(.appTools), .module(.resources), + .targetDependency(.composableArchitecture), .targetDependency(.sharing) ], resources: [.process(.resources)], @@ -632,14 +633,12 @@ let targets: [PackageDescription.Target] = [ dependencies: [ .module(.sfSafeSymbolsExt), .module(.appComponents), - .module(.appDelegateClient), .module(.appModels), .module(.appTools), .module(.applicationClient), .module(.authorizationClient), .module(.clipboardClient), .module(.cookieClient), - .module(.deviceClient), .module(.dfClient), .module(.fileClient), .module(.hapticsClient), diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift index adb7b6be1..3098f75d0 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -13,6 +13,7 @@ import CookieClient import DeviceClient import AppDelegateClient import AppComponents +import ReadingSettingFeature @Reducer public struct ReadingReducer: Sendable { @@ -20,8 +21,7 @@ public struct ReadingReducer: Sendable { public enum Destination { @ReducerCaseIgnored case share(IdentifiableBox) - @ReducerCaseIgnored - case readingSetting(EquatableVoid) + case readingSetting(ReadingSettingReducer) } public enum ShareItem: Equatable, Sendable { diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 1d3c5b3e9..a165e7596 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -74,9 +74,11 @@ public struct ReadingView: View { @Bindable var bindablePageHandler = pageHandler return changeTriggers(content: { content }) - .sheet(item: $store.destination.readingSetting, id: \.id) { _ in + .sheet( + item: $store.scope(state: \.destination?.readingSetting, action: \.destination.readingSetting) + ) { readingSettingStore in NavigationStack { - ReadingSettingView() + ReadingSettingView(store: readingSettingStore) .toolbar { if !DeviceUtil.isPad && DeviceUtil.isLandscape { CustomToolbarItem(placement: .cancellationAction) { diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingReducer.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingReducer.swift new file mode 100644 index 000000000..418ac50c0 --- /dev/null +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingReducer.swift @@ -0,0 +1,27 @@ +import Sharing +import AppModels +import ComposableArchitecture + +// A state-only reducer for the reading-setting editor. It carries `@Shared(.setting)` and vends the +// shared projection (`sharedSetting`) so `ReadingSettingView` binds through its own store instead of +// holding a `@Shared` itself. It runs no logic: every field write goes straight through the shared +// value, and the `Setting` model's clamps keep those writes consistent. Any orientation side effect +// stays with the host — the reader drives it from `ReadingReducer`; the Setting tab has none. +@Reducer +public struct ReadingSettingReducer: Sendable { + @ObservableState + public struct State: Equatable, Sendable { + @Shared(.setting) public var setting: Setting + public var sharedSetting: Shared { $setting } + + public init() {} + } + + public enum Action: Equatable, Sendable {} + + public init() {} + + public var body: some Reducer { + EmptyReducer() + } +} diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift index 0000d10e2..7199831da 100644 --- a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -3,52 +3,56 @@ import AppModels import Sharing import Resources import AppTools +import ComposableArchitecture public struct ReadingSettingView: View { - // The reading-setting editor is shared by the Setting tab and the reader sheet; it owns its own - // `@Shared(.setting)` in both, and the model clamps keep every write safe. The enclosing host - // observes the fields that carry side effects (e.g. `enablesLandscape` → orientation). - @Shared(.setting) private var setting: Setting + // The reading-setting editor is shared by the Setting tab and the reader sheet. Rather than hold + // its own `@Shared`, it binds through its store, whose state vends the shared `Setting`; the model + // clamps keep every write safe. Any orientation side effect stays with the host (the reader drives + // it from `ReadingReducer`; the Setting tab has none), so this view carries no such logic. + private let store: StoreOf - public init() {} + public init(store: StoreOf) { + self.store = store + } public var body: some View { Form { Section { - Picker(.direction, selection: Binding($setting.readingDirection)) { + Picker(.direction, selection: Binding(store.sharedSetting.readingDirection)) { ForEach(ReadingDirection.allCases) { Text($0.value).tag($0) } } .pickerStyle(.menu) - Picker(.preloadLimit, selection: Binding($setting.prefetchLimit)) { + Picker(.preloadLimit, selection: Binding(store.sharedSetting.prefetchLimit)) { ForEach(Array(stride(from: 6, through: 18, by: 4)), id: \.self) { value in Text(.RLocalizable.pages(count: value)).tag(value) } } .pickerStyle(.menu) if !DeviceUtil.isPad { - Toggle(.enablesLandscape, isOn: Binding($setting.enablesLandscape)) + Toggle(.enablesLandscape, isOn: Binding(store.sharedSetting.enablesLandscape)) } } Section(.readingAppearance) { Picker( .separatorHeight, - selection: Binding($setting.contentDividerHeight) + selection: Binding(store.sharedSetting.contentDividerHeight) ) { ForEach(Array(stride(from: 0, through: 20, by: 5)), id: \.self) { value in Text(.Constant.pointValue(value)).tag(Double(value)) } } .pickerStyle(.menu) - .disabled(setting.readingDirection != .vertical) + .disabled(store.setting.readingDirection != .vertical) ScaleFactorRow( - scaleFactor: Binding($setting.maximumScaleFactor), + scaleFactor: Binding(store.sharedSetting.maximumScaleFactor), labelContent: .maximumScaleFactor, minFactor: 1.5, maxFactor: 10 ) ScaleFactorRow( - scaleFactor: Binding($setting.doubleTapScaleFactor), + scaleFactor: Binding(store.sharedSetting.doubleTapScaleFactor), labelContent: .doubleTapScaleFactor, minFactor: 1.5, maxFactor: 5 ) @@ -107,7 +111,9 @@ private extension Double { struct ReadingSettingView_Previews: PreviewProvider { static var previews: some View { NavigationStack { - ReadingSettingView() + ReadingSettingView( + store: .init(initialState: .init(), reducer: ReadingSettingReducer.init) + ) } } } diff --git a/AppPackage/Sources/SettingFeature/Components/ReadingSettingReducer.swift b/AppPackage/Sources/SettingFeature/Components/ReadingSettingReducer.swift deleted file mode 100644 index 585fce58b..000000000 --- a/AppPackage/Sources/SettingFeature/Components/ReadingSettingReducer.swift +++ /dev/null @@ -1,36 +0,0 @@ -import ComposableArchitecture -import DeviceClient -import AppDelegateClient - -@Reducer -public struct ReadingSettingReducer: Sendable { - @ObservableState - public struct State: Equatable, Sendable { - public init() {} - } - - public enum Action: Equatable, Sendable { - // The view writes `enablesLandscape` straight into `@Shared(.setting)`, which dispatches no - // action, so it bridges the change here; this reducer re-locks portrait orientation when - // landscape is turned off (phones only — iPad always allows rotation). - case enablesLandscapeChanged(Bool) - } - - @Dependency(\.deviceClient) private var deviceClient - @Dependency(\.appDelegateClient) private var appDelegateClient - - public init() {} - - public var body: some Reducer { - Reduce { _, action in - switch action { - case .enablesLandscapeChanged(let enablesLandscape): - guard !enablesLandscape else { return .none } - return .run { _ in - guard await !deviceClient.isPad() else { return } - await appDelegateClient.setPortraitOrientationMask() - } - } - } - } -} diff --git a/AppPackage/Sources/SettingFeature/SettingPath.swift b/AppPackage/Sources/SettingFeature/SettingPath.swift index 4339ffafe..38e488681 100644 --- a/AppPackage/Sources/SettingFeature/SettingPath.swift +++ b/AppPackage/Sources/SettingFeature/SettingPath.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import ReadingSettingFeature // The single flat navigation stack for the Setting tab, owned by `SettingReducer`. Every drill-down // screen is a path element; child screens never push directly — they emit `delegate` actions that diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift index 022b61cd9..f7684fdab 100644 --- a/AppPackage/Sources/SettingFeature/SettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -9,8 +9,6 @@ import LibraryClient import DFClient import FileClient import CookieClient -import DeviceClient -import AppDelegateClient @Reducer public struct SettingReducer: Sendable { diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift index 6bfb4f293..951a5824e 100644 --- a/AppPackage/Sources/SettingFeature/SettingView.swift +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -63,10 +63,7 @@ public struct SettingView: View { DownloadSettingView() case .reading(let readingStore): - ReadingSettingView() - .onChange(of: store.setting.enablesLandscape) { _, newValue in - readingStore.send(.enablesLandscapeChanged(newValue)) - } + ReadingSettingView(store: readingStore) case .laboratory(let laboratoryStore): LaboratorySettingView(store: laboratoryStore) diff --git a/AppPackage/Tests/SettingFeatureTests/ReadingSettingReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/ReadingSettingReducerTests.swift deleted file mode 100644 index e55e88e31..000000000 --- a/AppPackage/Tests/SettingFeatureTests/ReadingSettingReducerTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -import ComposableArchitecture -import Testing -import DeviceClient -import AppDelegateClient -@testable import SettingFeature - -@MainActor -struct ReadingSettingReducerTests { - // Turning landscape ON never re-locks orientation, so the change action returns no effect and - // touches no dependency. - @Test - func enablingLandscapeRunsNoEffect() async { - let store = TestStore(initialState: .init(), reducer: ReadingSettingReducer.init) - await store.send(.enablesLandscapeChanged(true)) - await store.finish() - } - - // Turning landscape OFF on a phone re-applies the portrait mask; this pins that the effect chain - // runs to completion (the AppDelegate mask call is fire-and-forget and not capturable). - @Test - func disablingLandscapeOnPhoneRunsToCompletion() async { - let store = TestStore(initialState: .init(), reducer: ReadingSettingReducer.init) { - $0.deviceClient = .noop - $0.appDelegateClient = .noop - } - await store.send(.enablesLandscapeChanged(false)) - await store.finish() - } -} From 01237d4d49c62398ec037a6cdc21e8f8ec12ca09 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 16:55:15 +0800 Subject: [PATCH 594/614] Gate all persisted models with SchemaVersion --- .../AppModels/Persistence/AppSharedKeys.swift | 16 +++-- .../AppModels/Persistence/SchemaVersion.swift | 56 +++++++++++++++ .../Sources/AppModels/Persistent/Filter.swift | 16 +++-- .../Persistent/GalleryHistoryEntry.swift | 19 ++--- .../AppModels/Persistent/Setting.swift | 16 +++-- .../Sources/AppModels/Persistent/User.swift | 16 +++-- .../Sources/AppModels/Support/Misc.swift | 21 ++---- .../AppModels/Tags/TagTranslatorInfo.swift | 18 ++--- .../AppModelsTests/StrictDecodingTests.swift | 72 ++++++++++++++++++- 9 files changed, 187 insertions(+), 63 deletions(-) create mode 100644 AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift index 97b2844ef..ececd87fa 100644 --- a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -28,13 +28,15 @@ import Sharing // never evicted — the add control is simply disabled at the limit, because silently dropping a // saved word would lose user work. Auto-recorded data is disposable; authored data is not. // -// Every model also carries a `schemaVersion` (default 1): a reserved anchor for a future *breaking* -// migration, so a genuinely incompatible change has an explicit version to branch on rather than -// inferring compatibility from the decoded shape. The two array-element models with an identity -// invariant (`GalleryHistoryEntry`, `QuickSearchWord`) decode through hand-written throwing decoders -// that reject an out-of-range `schemaVersion`; the whole-struct models rely on synthesized strict -// decode (a shape mismatch already resets to the key default) and reintroduce a branching decoder -// if and when a breaking change lands. +// Every model carries a self-validating `SchemaVersion` field (default 1) that rejects a +// newer/downgrade value on decode (see `SchemaVersion`), failing the decode so Sharing resets to the +// key default rather than half-reading an unknown shape. The four whole-struct models (`Setting`, +// `User`, the filters, `TagTranslatorInfo`) keep synthesized strict Codable — the typed field gates +// the version without a hand-written decoder, preserving their `didSet` invariants and optional-field +// tolerance. The two identity-bearing array-element models (`GalleryHistoryEntry`, `QuickSearchWord`) +// still hand-write `init(from:)` for their identity invariants, decoding that same `SchemaVersion` +// field for the version check. When a real breaking change lands, the affected model gains or extends +// a custom `init(from:)` that switches on the version to map the older shape forward. // // Nothing here uses the `fileStorage` strategy. The tag-translation table is the only large // artifact, and it is deliberately NOT persisted through Sharing: only its thin diff --git a/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift b/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift new file mode 100644 index 000000000..437b67792 --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift @@ -0,0 +1,56 @@ +import Foundation + +/// A model whose persisted blob carries a `schemaVersion` and knows the newest version this build can +/// decode. Adopt it together with a `SchemaVersion` field so the version validates itself on +/// decode. +public protocol SchemaVersioned { + /// The newest `schemaVersion` this build understands. A stored blob carrying a larger value is a + /// downgrade and is rejected on decode. + static var currentSchemaVersion: Int { get } +} + +/// A self-validating `schemaVersion` field. +/// +/// On decode it accepts `1...Model.currentSchemaVersion` and throws on anything else — a newer +/// (downgrade) value, or a corrupt `0`/negative. The throw fails the whole model decode, so `Sharing` +/// falls back to the key's default rather than half-reading an unknown shape. It encodes as a bare +/// integer, so the persisted JSON is unchanged (`"schemaVersion": 1`). +/// +/// This is the lightweight, in-decode migration seam. It gives every persisted model uniform +/// downgrade rejection *without* a hand-written `init(from:)`, so synthesized `Codable` — and with it +/// each model's `didSet` invariants and optional-field tolerance — stays untouched. When a real +/// breaking change lands for a model, that model gains a custom `init(from:)` that switches on this +/// version to map the older shape forward. +public struct SchemaVersion: Hashable, Sendable { + public let value: Int + + public init(_ value: Int) { + self.value = value + } +} + +extension SchemaVersion: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self.init(value) + } +} + +extension SchemaVersion: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let decoded = try container.decode(Int.self) + guard (1...Model.currentSchemaVersion).contains(decoded) else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "\(Model.self) schemaVersion \(decoded) is outside " + + "the supported range 1...\(Model.currentSchemaVersion)" + )) + } + value = decoded + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(value) + } +} diff --git a/AppPackage/Sources/AppModels/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift index 75bdc5c95..c85173cf7 100644 --- a/AppPackage/Sources/AppModels/Persistent/Filter.swift +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -1,7 +1,7 @@ import SwiftUI import Resources -public struct Filter: Codable, Equatable, Sendable { +public struct Filter: Codable, Equatable, Sendable, SchemaVersioned { public init( doujinshi: Bool = false, manga: Bool = false, @@ -59,12 +59,14 @@ public struct Filter: Codable, Equatable, Sendable { self.disableUploader = disableUploader self.disableTags = disableTags } - // Version anchor for a future breaking migration. Unlike the identity array-element models - // (GalleryHistoryEntry/QuickSearchWord), this single top-level blob keeps synthesized strict - // Codable with no version gate: a breaking change alters the shape, so a mismatched blob fails to - // decode on its own and gating would mean a hand-written decoder. A field added later must stay - // optional (or a custom `decodeIfPresent` decoder) so old blobs still decode. - public var schemaVersion = 1 + /// Highest `schemaVersion` this build can decode. Bump when a breaking change lands and add a + /// custom `init(from:)` that maps the older shape forward. + public static let currentSchemaVersion = 1 + // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which + // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise + // untouched, so the `didSet` couplings below and optional-field tolerance still hold; a field added + // later must stay optional so old blobs keep decoding. + public var schemaVersion: SchemaVersion = 1 public var doujinshi = false public var manga = false public var artistCG = false diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift index 548227dc7..94fec60bd 100644 --- a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift @@ -10,7 +10,7 @@ import Foundation /// The manual `init(from:)` decodes strictly: a blank identity or an unknown `schemaVersion` /// throws, failing the whole `[GalleryHistoryEntry]` decode so Sharing resets this disposable list /// to `[]` rather than surfacing a `""`-id Franken-entry that would collide in an `Identifiable` list. -public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable { +public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable, SchemaVersioned { /// The most entries kept across launches. Enforced only by a launch-time prune (see /// `Array.pruneToHistoryCap`); in-session upserts may temporarily exceed it. public static let historyCap = 1000 @@ -27,10 +27,12 @@ public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable { self.readingProgress = readingProgress } public var id: String { gid } - /// Highest `schemaVersion` this build can decode; a blob carrying a newer value (a downgrade) - /// is rejected rather than half-read. Bump and branch here when a breaking change lands. + /// Highest `schemaVersion` this build can decode. Bump and add a version switch in `init(from:)` + /// when a breaking change lands. public static let currentSchemaVersion = 1 - public var schemaVersion = 1 + /// Self-validating (see `SchemaVersion`): a newer/downgrade value is rejected on decode. The + /// identity guards in `init(from:)` below stay hand-written. + public var schemaVersion: SchemaVersion = 1 public var gid: String public var token: String public var lastOpenDate: Date @@ -41,14 +43,7 @@ public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable { extension GalleryHistoryEntry { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let version = try container.decode(Int.self, forKey: .schemaVersion) - guard (1...Self.currentSchemaVersion).contains(version) else { - throw DecodingError.dataCorrupted(.init( - codingPath: container.codingPath, - debugDescription: "Unsupported GalleryHistoryEntry schemaVersion \(version)" - )) - } - schemaVersion = version + schemaVersion = try container.decode(SchemaVersion.self, forKey: .schemaVersion) gid = try container.decode(String.self, forKey: .gid) token = try container.decode(String.self, forKey: .token) lastOpenDate = try container.decode(Date.self, forKey: .lastOpenDate) diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index 65491974d..cc88630a8 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -3,7 +3,7 @@ import SwiftUI import Resources import Foundation -public struct Setting: Codable, Equatable, Sendable { +public struct Setting: Codable, Equatable, Sendable, SchemaVersioned { public init( galleryHost: GalleryHost = .ehentai, showsNewDawnGreeting: Bool = false, @@ -57,12 +57,14 @@ public struct Setting: Codable, Equatable, Sendable { self.doubleTapScaleFactor = doubleTapScaleFactor self.bypassesSNIFiltering = bypassesSNIFiltering } - // Version anchor for a future breaking migration. Unlike the identity array-element models - // (GalleryHistoryEntry/QuickSearchWord), this single top-level blob keeps synthesized strict - // Codable with no version gate: a breaking change alters the shape, so a mismatched blob fails to - // decode on its own and gating would mean a hand-written decoder. A field added later must stay - // optional (or a custom `decodeIfPresent` decoder) so old blobs still decode. - public var schemaVersion = 1 + /// Highest `schemaVersion` this build can decode. Bump when a breaking change lands and add a + /// custom `init(from:)` that maps the older shape forward. + public static let currentSchemaVersion = 1 + // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which + // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise + // untouched, so the `didSet` couplings below and optional-field tolerance still hold; a field added + // later must stay optional so old blobs keep decoding. + public var schemaVersion: SchemaVersion = 1 // Account public var galleryHost: GalleryHost = .ehentai public var showsNewDawnGreeting = false diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index 288119bca..532e307f7 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -1,7 +1,7 @@ import Foundation import Resources -public struct User: Codable, Equatable, Sendable { +public struct User: Codable, Equatable, Sendable, SchemaVersioned { public init( displayName: String? = nil, avatarURL: URL? = nil, @@ -17,12 +17,14 @@ public struct User: Codable, Equatable, Sendable { } public static let empty = User() - // Version anchor for a future breaking migration. Unlike the identity array-element models - // (GalleryHistoryEntry/QuickSearchWord), this single top-level blob keeps synthesized strict - // Codable with no version gate: a breaking change alters the shape, so a mismatched blob fails to - // decode on its own and gating would mean a hand-written decoder. A field added later must stay - // optional (or a custom `decodeIfPresent` decoder) so old blobs still decode. - public var schemaVersion = 1 + /// Highest `schemaVersion` this build can decode. Bump when a breaking change lands and add a + /// custom `init(from:)` that maps the older shape forward. + public static let currentSchemaVersion = 1 + // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which + // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise + // untouched, so optional-field tolerance still holds; a field added later must stay optional so old + // blobs keep decoding. + public var schemaVersion: SchemaVersion = 1 public var displayName: String? public var avatarURL: URL? diff --git a/AppPackage/Sources/AppModels/Support/Misc.swift b/AppPackage/Sources/AppModels/Support/Misc.swift index 060b2bc3b..5bc254392 100644 --- a/AppPackage/Sources/AppModels/Support/Misc.swift +++ b/AppPackage/Sources/AppModels/Support/Misc.swift @@ -134,7 +134,7 @@ public struct PageNumber: Equatable, Sendable { } } -public struct QuickSearchWord: Codable, Equatable, Identifiable, Sendable { +public struct QuickSearchWord: Codable, Equatable, Identifiable, Sendable, SchemaVersioned { public init( id: UUID = .init(), name: String, @@ -146,12 +146,12 @@ public struct QuickSearchWord: Codable, Equatable, Identifiable, Sendable { } public static var empty: Self { .init(name: "", content: "") } - /// Highest `schemaVersion` this build can decode; a blob carrying a newer value (a downgrade) - /// is rejected rather than half-read. Bump and branch here when a breaking change lands. + /// Highest `schemaVersion` this build can decode. Bump and add a version switch in `init(from:)` + /// when a breaking change lands. public static let currentSchemaVersion = 1 - // Version anchor for a future breaking migration; the strict decoder below rejects an - // out-of-range value. - public var schemaVersion = 1 + // Self-validating (see `SchemaVersion`): a newer/downgrade value is rejected on decode; the + // identity guards in `init(from:)` below stay hand-written. + public var schemaVersion: SchemaVersion = 1 public var id: UUID = .init() public var name: String public var content: String @@ -170,14 +170,7 @@ extension QuickSearchWord { /// with a random, unstable identity. public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let version = try container.decode(Int.self, forKey: .schemaVersion) - guard (1...Self.currentSchemaVersion).contains(version) else { - throw DecodingError.dataCorrupted(.init( - codingPath: container.codingPath, - debugDescription: "Unsupported QuickSearchWord schemaVersion \(version)" - )) - } - schemaVersion = version + schemaVersion = try container.decode(SchemaVersion.self, forKey: .schemaVersion) id = try container.decode(UUID.self, forKey: .id) name = try container.decode(String.self, forKey: .name) content = try container.decode(String.self, forKey: .content) diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift index 575b7a9d5..5f6b8f4f7 100644 --- a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift +++ b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift @@ -7,9 +7,9 @@ import Foundation /// (Caches for a remote download, Application Support for a user import). This record remembers just /// enough to rebuild the right file and to let the update check know what it already has: which /// language/version is cached and whether a custom import is active. -public struct TagTranslatorInfo: Codable, Equatable, Sendable { +public struct TagTranslatorInfo: Codable, Equatable, Sendable, SchemaVersioned { public init( - schemaVersion: Int = 1, + schemaVersion: SchemaVersion = 1, language: TranslatableLanguage? = nil, updatedDate: Date = .distantPast, hasCustomTranslations: Bool = false @@ -19,12 +19,14 @@ public struct TagTranslatorInfo: Codable, Equatable, Sendable { self.updatedDate = updatedDate self.hasCustomTranslations = hasCustomTranslations } - // Version anchor for a future breaking migration. Unlike the identity array-element models - // (GalleryHistoryEntry/QuickSearchWord), this single top-level blob keeps synthesized strict - // Codable with no version gate: a breaking change alters the shape, so a mismatched blob fails to - // decode on its own and gating would mean a hand-written decoder. A field added later must stay - // optional (or a custom `decodeIfPresent` decoder) so old blobs still decode. - public var schemaVersion: Int + /// Highest `schemaVersion` this build can decode. Bump when a breaking change lands and add a + /// custom `init(from:)` that maps the older shape forward. + public static let currentSchemaVersion = 1 + // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which + // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise + // untouched, so optional-field tolerance still holds; a field added later must stay optional so old + // blobs keep decoding. + public var schemaVersion: SchemaVersion public var language: TranslatableLanguage? public var updatedDate: Date public var hasCustomTranslations: Bool diff --git a/AppPackage/Tests/AppModelsTests/StrictDecodingTests.swift b/AppPackage/Tests/AppModelsTests/StrictDecodingTests.swift index 3fcdaeb04..17a5d9699 100644 --- a/AppPackage/Tests/AppModelsTests/StrictDecodingTests.swift +++ b/AppPackage/Tests/AppModelsTests/StrictDecodingTests.swift @@ -6,7 +6,8 @@ import AppModels // shape-incompatible blob fails to decode so Sharing resets the key to its default (a clean, coherent // value) instead of surfacing a partially-filled Franken-value. Identity-bearing array elements // (`GalleryHistoryEntry`, `QuickSearchWord`) validate their identity and reject an unknown -// `schemaVersion`; whole-struct models (`Filter`, `Setting`, …) rely on synthesized strict Codable. +// `schemaVersion`; whole-struct models (`Filter`, `Setting`, …) keep synthesized strict Codable and +// reject an unknown `schemaVersion` through a self-validating `SchemaVersion` field. @Suite struct StrictDecodingTests { private func decode(_ type: T.Type, _ json: String) throws -> T { @@ -105,4 +106,73 @@ struct StrictDecodingTests { try decode(QuickSearchWord.self, #"{"schemaVersion": 1, "name": "n", "content": "c"}"#) } } + + @Test + func quickSearchWordRejectsAnUnknownSchemaVersion() { + // The version now validates through the shared `SchemaVersion` field, like the whole-struct + // models; a newer value throws, failing the (array) decode even when the identity is valid. + let json = """ + {"schemaVersion": 2, "id": "00000000-0000-0000-0000-000000000000", "name": "n", "content": "c"} + """ + #expect(throws: (any Error).self) { + try decode(QuickSearchWord.self, json) + } + } + + // MARK: schemaVersion gating + + @Test + func schemaVersionAcceptsTheCurrentVersion() throws { + let version = try decode(SchemaVersion.self, "1") + #expect(version.value == 1) + } + + @Test + func schemaVersionRejectsANewerVersion() { + // A blob written by a newer build (a downgrade) is rejected rather than half-read. + #expect(throws: (any Error).self) { + try decode(SchemaVersion.self, "2") + } + } + + @Test + func schemaVersionRejectsACorruptVersion() { + // 0 / negative is outside the 1...current range. + #expect(throws: (any Error).self) { + try decode(SchemaVersion.self, "0") + } + } + + @Test + func aWholeStructModelRejectsANewerSchemaVersion() throws { + // An otherwise-valid blob whose schemaVersion is newer than this build supports fails the whole + // decode, so Sharing falls back to the key default instead of dropping the new fields. + let data = try JSONEncoder().encode(Filter()) + let json = try JSONSerialization.jsonObject(with: data) + var object = try #require(json as? [String: Any]) + object["schemaVersion"] = 2 + let tampered = try JSONSerialization.data(withJSONObject: object) + #expect(throws: (any Error).self) { + try JSONDecoder().decode(Filter.self, from: tampered) + } + } + + @Test + func schemaVersionEncodesAsABareInteger() throws { + // The persisted shape must stay `"schemaVersion": 1` (a bare int, not a wrapper object) so + // blobs written before this type existed keep decoding. + let data = try JSONEncoder().encode(Filter()) + let json = try JSONSerialization.jsonObject(with: data) + let object = try #require(json as? [String: Any]) + #expect(object["schemaVersion"] as? Int == 1) + } + + @Test + func aWholeStructModelStillToleratesAnAbsentOptionalField() throws { + // Optional-field tolerance survives the switch to a `SchemaVersion` field: a blob carrying only + // the version still decodes, with the absent optionals left nil. + let user = try decode(User.self, #"{"schemaVersion": 1}"#) + #expect(user.displayName == nil) + #expect(user.favoriteCategories == nil) + } } From 15eb999e8171dfa916c3668af99db3406280ce0f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 17:13:35 +0800 Subject: [PATCH 595/614] Log rejected schemaVersion via OSLog --- .../AppModels/Persistence/AppSharedKeys.swift | 5 +++-- .../AppModels/Persistence/SchemaVersion.swift | 16 +++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift index ececd87fa..4e2b3fc56 100644 --- a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -29,8 +29,9 @@ import Sharing // saved word would lose user work. Auto-recorded data is disposable; authored data is not. // // Every model carries a self-validating `SchemaVersion` field (default 1) that rejects a -// newer/downgrade value on decode (see `SchemaVersion`), failing the decode so Sharing resets to the -// key default rather than half-reading an unknown shape. The four whole-struct models (`Setting`, +// newer/downgrade value on decode and logs the anomaly via OSLog (see `SchemaVersion`), then fails +// the decode so Sharing resets to the key default rather than half-reading an unknown shape. The +// four whole-struct models (`Setting`, // `User`, the filters, `TagTranslatorInfo`) keep synthesized strict Codable — the typed field gates // the version without a hand-written decoder, preserving their `didSet` invariants and optional-field // tolerance. The two identity-bearing array-element models (`GalleryHistoryEntry`, `QuickSearchWord`) diff --git a/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift b/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift index 437b67792..afe8f4609 100644 --- a/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift +++ b/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift @@ -1,4 +1,7 @@ import Foundation +import OSLogExt + +private let logger = Logger(category: "SchemaVersion") /// A model whose persisted blob carries a `schemaVersion` and knows the newest version this build can /// decode. Adopt it together with a `SchemaVersion` field so the version validates itself on @@ -12,9 +15,10 @@ public protocol SchemaVersioned { /// A self-validating `schemaVersion` field. /// /// On decode it accepts `1...Model.currentSchemaVersion` and throws on anything else — a newer -/// (downgrade) value, or a corrupt `0`/negative. The throw fails the whole model decode, so `Sharing` -/// falls back to the key's default rather than half-reading an unknown shape. It encodes as a bare -/// integer, so the persisted JSON is unchanged (`"schemaVersion": 1`). +/// (downgrade) value, or a corrupt `0`/negative — logging the rejected version via OSLog first. The +/// throw fails the whole model decode, so `Sharing` falls back to the key's default rather than +/// half-reading an unknown shape. It encodes as a bare integer, so the persisted JSON is unchanged +/// (`"schemaVersion": 1`). /// /// This is the lightweight, in-decode migration seam. It gives every persisted model uniform /// downgrade rejection *without* a hand-written `init(from:)`, so synthesized `Codable` — and with it @@ -40,10 +44,12 @@ extension SchemaVersion: Codable { let container = try decoder.singleValueContainer() let decoded = try container.decode(Int.self) guard (1...Model.currentSchemaVersion).contains(decoded) else { + let message = "\(Model.self) schemaVersion \(decoded) is outside the " + + "supported range 1...\(Model.currentSchemaVersion)" + logger.error("\(message, privacy: .public)") throw DecodingError.dataCorrupted(.init( codingPath: decoder.codingPath, - debugDescription: "\(Model.self) schemaVersion \(decoded) is outside " - + "the supported range 1...\(Model.currentSchemaVersion)" + debugDescription: message )) } value = decoded From 234cc9a69ab986c58ee265eb945c94ebc257d1c8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 17:23:52 +0800 Subject: [PATCH 596/614] Add mock-v2 migration tests for all models --- .../AppModelsTests/SchemaMigrationTests.swift | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift diff --git a/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift b/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift new file mode 100644 index 000000000..f8980700a --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift @@ -0,0 +1,221 @@ +import Foundation +import Testing +import AppModels + +// MARK: - MOCK v2 models — REMOVE when real v2 models land +// +// Every `SchemaVersioned` model is still at schemaVersion 1, so there is no real migration to run yet. +// To exercise the in-decode migration machinery end-to-end, each `V2` below stands in for a +// hypothetical v2 of the corresponding model: it sets `currentSchemaVersion` to 2 and hand-writes an +// `init(from:)` that switches on the decoded `SchemaVersion` and maps the v1 shape forward. Each mock +// demonstrates a field *rename* — the case that genuinely needs a version switch (a plain additive +// field would be tolerated on decode without one). Each mock reads only the renamed field (plus the +// version); a keyed container ignores the other keys in the real v1 blob. +// +// FUTURE AGENT: when a model gains a REAL v2 (an actual breaking change, with its own `init(from:)` +// version switch on the real type), DELETE that model's `V2` mock and its tests here, and +// replace them with tests that migrate a real v1 blob to the real v2 shape. Once every model has real +// migration coverage, delete this whole file. + +// MOCK — remove with real Setting v2. Renames v1 `galleryHost` → v2 `host`. +private struct SettingV2: Decodable, SchemaVersioned { + static let currentSchemaVersion = 2 + var host: GalleryHost + enum CodingKeys: String, CodingKey { case schemaVersion, galleryHost, host } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value + switch version { + case 1: + host = try container.decode(GalleryHost.self, forKey: .galleryHost) // migrate old key + default: + host = try container.decode(GalleryHost.self, forKey: .host) // native v2 + } + } +} + +// MOCK — remove with real User v2. Renames v1 `displayName` → v2 `name`. +private struct UserV2: Decodable, SchemaVersioned { + static let currentSchemaVersion = 2 + var name: String + enum CodingKeys: String, CodingKey { case schemaVersion, displayName, name } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value + switch version { + case 1: + name = try container.decode(String.self, forKey: .displayName) + default: + name = try container.decode(String.self, forKey: .name) + } + } +} + +// MOCK — remove with real Filter v2. Renames v1 `minRating` → v2 `minimumRating`. +private struct FilterV2: Decodable, SchemaVersioned { + static let currentSchemaVersion = 2 + var minimumRating: Int + enum CodingKeys: String, CodingKey { case schemaVersion, minRating, minimumRating } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value + switch version { + case 1: + minimumRating = try container.decode(Int.self, forKey: .minRating) + default: + minimumRating = try container.decode(Int.self, forKey: .minimumRating) + } + } +} + +// MOCK — remove with real TagTranslatorInfo v2. Renames v1 `hasCustomTranslations` → v2 `custom`. +private struct TagTranslatorInfoV2: Decodable, SchemaVersioned { + static let currentSchemaVersion = 2 + var custom: Bool + enum CodingKeys: String, CodingKey { case schemaVersion, hasCustomTranslations, custom } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value + switch version { + case 1: + custom = try container.decode(Bool.self, forKey: .hasCustomTranslations) + default: + custom = try container.decode(Bool.self, forKey: .custom) + } + } +} + +// MOCK — remove with real GalleryHistoryEntry v2. Renames v1 `readingProgress` → v2 `progress`. +private struct GalleryHistoryEntryV2: Decodable, SchemaVersioned { + static let currentSchemaVersion = 2 + var progress: Int + enum CodingKeys: String, CodingKey { case schemaVersion, readingProgress, progress } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value + switch version { + case 1: + progress = try container.decode(Int.self, forKey: .readingProgress) + default: + progress = try container.decode(Int.self, forKey: .progress) + } + } +} + +// MOCK — remove with real QuickSearchWord v2. Renames v1 `name` → v2 `label`. +private struct QuickSearchWordV2: Decodable, SchemaVersioned { + static let currentSchemaVersion = 2 + var label: String + enum CodingKeys: String, CodingKey { case schemaVersion, name, label } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value + switch version { + case 1: + label = try container.decode(String.self, forKey: .name) + default: + label = try container.decode(String.self, forKey: .label) + } + } +} + +// MARK: - Migration tests +// +// Each model: a real v1 blob (what the current code writes) forward-migrates through the mock v2 +// decoder, and a native v2 blob decodes through the other branch. The mock's `SchemaVersion` cap is +// covered once, by `aV2ModelRejectsAnUnknownVersion`. +@Suite +struct SchemaMigrationTests { + private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) + } + + // MARK: Setting + @Test + func settingMigratesV1BlobToV2() throws { + let v1Blob = try JSONEncoder().encode(Setting(galleryHost: .exhentai)) + let migrated = try JSONDecoder().decode(SettingV2.self, from: v1Blob) + #expect(migrated.host == .exhentai) // v1 `galleryHost` carried into v2 `host` + } + @Test + func settingV2DecodesNatively() throws { + let decoded = try decode(SettingV2.self, #"{"schemaVersion": 2, "host": "ExHentai"}"#) + #expect(decoded.host == .exhentai) + } + + // MARK: User + @Test + func userMigratesV1BlobToV2() throws { + let v1Blob = try JSONEncoder().encode(User(displayName: "alice")) + let migrated = try JSONDecoder().decode(UserV2.self, from: v1Blob) + #expect(migrated.name == "alice") + } + @Test + func userV2DecodesNatively() throws { + let decoded = try decode(UserV2.self, #"{"schemaVersion": 2, "name": "alice"}"#) + #expect(decoded.name == "alice") + } + + // MARK: Filter + @Test + func filterMigratesV1BlobToV2() throws { + let v1Blob = try JSONEncoder().encode(Filter(minRating: 5)) + let migrated = try JSONDecoder().decode(FilterV2.self, from: v1Blob) + #expect(migrated.minimumRating == 5) + } + @Test + func filterV2DecodesNatively() throws { + let decoded = try decode(FilterV2.self, #"{"schemaVersion": 2, "minimumRating": 5}"#) + #expect(decoded.minimumRating == 5) + } + + // MARK: TagTranslatorInfo + @Test + func tagTranslatorInfoMigratesV1BlobToV2() throws { + let v1Blob = try JSONEncoder().encode(TagTranslatorInfo(hasCustomTranslations: true)) + let migrated = try JSONDecoder().decode(TagTranslatorInfoV2.self, from: v1Blob) + #expect(migrated.custom) + } + @Test + func tagTranslatorInfoV2DecodesNatively() throws { + let decoded = try decode(TagTranslatorInfoV2.self, #"{"schemaVersion": 2, "custom": true}"#) + #expect(decoded.custom) + } + + // MARK: GalleryHistoryEntry + @Test + func galleryHistoryEntryMigratesV1BlobToV2() throws { + let entry = GalleryHistoryEntry( + gid: "1", token: "a", lastOpenDate: Date(timeIntervalSince1970: 1), readingProgress: 7 + ) + let migrated = try JSONDecoder().decode(GalleryHistoryEntryV2.self, from: JSONEncoder().encode(entry)) + #expect(migrated.progress == 7) + } + @Test + func galleryHistoryEntryV2DecodesNatively() throws { + let decoded = try decode(GalleryHistoryEntryV2.self, #"{"schemaVersion": 2, "progress": 7}"#) + #expect(decoded.progress == 7) + } + + // MARK: QuickSearchWord + @Test + func quickSearchWordMigratesV1BlobToV2() throws { + let v1Blob = try JSONEncoder().encode(QuickSearchWord(name: "n", content: "c")) + let migrated = try JSONDecoder().decode(QuickSearchWordV2.self, from: v1Blob) + #expect(migrated.label == "n") + } + @Test + func quickSearchWordV2DecodesNatively() throws { + let decoded = try decode(QuickSearchWordV2.self, #"{"schemaVersion": 2, "label": "n"}"#) + #expect(decoded.label == "n") + } + + // MARK: Version cap (representative) + @Test + func aV2ModelRejectsAnUnknownVersion() { + // The mock caps `currentSchemaVersion` at 2; a v3 blob is rejected by `SchemaVersion`. + #expect(throws: (any Error).self) { + try decode(SettingV2.self, #"{"schemaVersion": 3, "host": "ExHentai"}"#) + } + } +} From 990a1cd369f11a1a249c81e9e99e5b6f7244a64a Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 17:31:48 +0800 Subject: [PATCH 597/614] Cover add/remove/type/derive/merge migrations --- .../AppModelsTests/SchemaMigrationTests.swift | 182 ++++++++++-------- 1 file changed, 102 insertions(+), 80 deletions(-) diff --git a/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift b/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift index f8980700a..49759c1f4 100644 --- a/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift +++ b/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift @@ -5,19 +5,28 @@ import AppModels // MARK: - MOCK v2 models — REMOVE when real v2 models land // // Every `SchemaVersioned` model is still at schemaVersion 1, so there is no real migration to run yet. -// To exercise the in-decode migration machinery end-to-end, each `V2` below stands in for a -// hypothetical v2 of the corresponding model: it sets `currentSchemaVersion` to 2 and hand-writes an -// `init(from:)` that switches on the decoded `SchemaVersion` and maps the v1 shape forward. Each mock -// demonstrates a field *rename* — the case that genuinely needs a version switch (a plain additive -// field would be tolerated on decode without one). Each mock reads only the renamed field (plus the -// version); a keyed container ignores the other keys in the real v1 blob. +// To exercise the in-decode migration machinery, each `V2` below stands in for a hypothetical v2 +// of the corresponding model: it sets `currentSchemaVersion` to 2 and hand-writes the `init(from:)` +// version switch a real migration would use. Collectively they span the common schema-change shapes: +// +// • Setting RENAME — galleryHost → host +// • User ADD — new required `region`, defaulted when migrating from v1 +// • Filter REMOVE — drops `minRating` (a v1 blob still carrying it decodes) +// • TagTranslatorInfo TYPE — hasCustomTranslations: Bool → customTranslations: Int +// • GalleryHistoryEntry DERIVE — new `started: Bool` computed from v1 `readingProgress` +// • QuickSearchWord MERGE — `name` + `content` → `combined` +// +// Each test feeds a REAL v1 blob (what the current code writes) through the mock and asserts the forward +// map; a native v2 blob covers the other branch. RENAME/ADD/TYPE/DERIVE/MERGE need the version switch; +// REMOVE is decode-forward-compatible (a dropped key is simply ignored), so its mock only validates the +// version and has no branch. // // FUTURE AGENT: when a model gains a REAL v2 (an actual breaking change, with its own `init(from:)` -// version switch on the real type), DELETE that model's `V2` mock and its tests here, and -// replace them with tests that migrate a real v1 blob to the real v2 shape. Once every model has real -// migration coverage, delete this whole file. +// version switch on the real type), DELETE that model's `V2` mock and its tests here, and replace +// them with tests that migrate a real v1 blob to the real v2 shape. Delete this whole file once every +// model has real migration coverage. -// MOCK — remove with real Setting v2. Renames v1 `galleryHost` → v2 `host`. +// MOCK — remove with real Setting v2. RENAME: v1 `galleryHost` → v2 `host`. private struct SettingV2: Decodable, SchemaVersioned { static let currentSchemaVersion = 2 var host: GalleryHost @@ -27,112 +36,113 @@ private struct SettingV2: Decodable, SchemaVersioned { let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value switch version { case 1: - host = try container.decode(GalleryHost.self, forKey: .galleryHost) // migrate old key + host = try container.decode(GalleryHost.self, forKey: .galleryHost) // read the old key default: host = try container.decode(GalleryHost.self, forKey: .host) // native v2 } } } -// MOCK — remove with real User v2. Renames v1 `displayName` → v2 `name`. +// MOCK — remove with real User v2. ADD: v2 introduces a required `region`, defaulted when migrating. private struct UserV2: Decodable, SchemaVersioned { static let currentSchemaVersion = 2 - var name: String - enum CodingKeys: String, CodingKey { case schemaVersion, displayName, name } + var displayName: String? + var region: String // NEW in v2 and REQUIRED there; a v1 blob has no such key + enum CodingKeys: String, CodingKey { case schemaVersion, displayName, region } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value + displayName = try container.decodeIfPresent(String.self, forKey: .displayName) switch version { case 1: - name = try container.decode(String.self, forKey: .displayName) + region = "" // migration supplies the default for a field v1 never had default: - name = try container.decode(String.self, forKey: .name) + region = try container.decode(String.self, forKey: .region) // required in v2 } } } -// MOCK — remove with real Filter v2. Renames v1 `minRating` → v2 `minimumRating`. +// MOCK — remove with real Filter v2. REMOVE: v2 drops `minRating`; a v1 blob still carrying it decodes. private struct FilterV2: Decodable, SchemaVersioned { static let currentSchemaVersion = 2 - var minimumRating: Int - enum CodingKeys: String, CodingKey { case schemaVersion, minRating, minimumRating } + var doujinshi: Bool // retained field; `minRating` was dropped in v2 and is simply not read + enum CodingKeys: String, CodingKey { case schemaVersion, doujinshi } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value - switch version { - case 1: - minimumRating = try container.decode(Int.self, forKey: .minRating) - default: - minimumRating = try container.decode(Int.self, forKey: .minimumRating) - } + _ = try container.decode(SchemaVersion.self, forKey: .schemaVersion) // validate range + doujinshi = try container.decode(Bool.self, forKey: .doujinshi) // unchanged v1 → v2, no branch } } -// MOCK — remove with real TagTranslatorInfo v2. Renames v1 `hasCustomTranslations` → v2 `custom`. +// MOCK — remove with real TagTranslatorInfo v2. TYPE: v1 `hasCustomTranslations: Bool` → v2 Int. private struct TagTranslatorInfoV2: Decodable, SchemaVersioned { static let currentSchemaVersion = 2 - var custom: Bool - enum CodingKeys: String, CodingKey { case schemaVersion, hasCustomTranslations, custom } + var customTranslations: Int // was `hasCustomTranslations: Bool` in v1 + enum CodingKeys: String, CodingKey { case schemaVersion, hasCustomTranslations, customTranslations } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value - switch version { + let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion) + switch version.value { case 1: - custom = try container.decode(Bool.self, forKey: .hasCustomTranslations) + let old = try container.decode(Bool.self, forKey: .hasCustomTranslations) + customTranslations = old ? 1 : 0 // convert the old Bool to the new Int default: - custom = try container.decode(Bool.self, forKey: .custom) + customTranslations = try container.decode(Int.self, forKey: .customTranslations) } } } -// MOCK — remove with real GalleryHistoryEntry v2. Renames v1 `readingProgress` → v2 `progress`. +// MOCK — remove with real GalleryHistoryEntry v2. DERIVE: v2 `started` computed from v1 `readingProgress`. private struct GalleryHistoryEntryV2: Decodable, SchemaVersioned { static let currentSchemaVersion = 2 - var progress: Int - enum CodingKeys: String, CodingKey { case schemaVersion, readingProgress, progress } + var started: Bool // NEW in v2, computed from v1 `readingProgress` + enum CodingKeys: String, CodingKey { case schemaVersion, readingProgress, started } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value - switch version { + let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion) + switch version.value { case 1: - progress = try container.decode(Int.self, forKey: .readingProgress) + let progress = try container.decode(Int.self, forKey: .readingProgress) + started = progress > 0 // derive the new field from old data default: - progress = try container.decode(Int.self, forKey: .progress) + started = try container.decode(Bool.self, forKey: .started) } } } -// MOCK — remove with real QuickSearchWord v2. Renames v1 `name` → v2 `label`. +// MOCK — remove with real QuickSearchWord v2. MERGE: v1 `name` + `content` → v2 `combined`. private struct QuickSearchWordV2: Decodable, SchemaVersioned { static let currentSchemaVersion = 2 - var label: String - enum CodingKeys: String, CodingKey { case schemaVersion, name, label } + var combined: String // v2 merges v1 `name` and `content` + enum CodingKeys: String, CodingKey { case schemaVersion, name, content, combined } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value - switch version { + let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion) + switch version.value { case 1: - label = try container.decode(String.self, forKey: .name) + let name = try container.decode(String.self, forKey: .name) + let content = try container.decode(String.self, forKey: .content) + combined = "\(name): \(content)" // merge two fields into one default: - label = try container.decode(String.self, forKey: .label) + combined = try container.decode(String.self, forKey: .combined) } } } // MARK: - Migration tests // -// Each model: a real v1 blob (what the current code writes) forward-migrates through the mock v2 -// decoder, and a native v2 blob decodes through the other branch. The mock's `SchemaVersion` cap is -// covered once, by `aV2ModelRejectsAnUnknownVersion`. +// Per model: a real v1 blob (what the current code writes) forward-migrates through the mock v2 decoder, +// and a native v2 blob decodes through the other branch. The `SchemaVersion` cap is covered once, by +// `aV2ModelRejectsAnUnknownVersion`. @Suite struct SchemaMigrationTests { private func decode(_ type: T.Type, _ json: String) throws -> T { try JSONDecoder().decode(T.self, from: Data(json.utf8)) } - // MARK: Setting + // MARK: Setting — RENAME @Test - func settingMigratesV1BlobToV2() throws { + func settingMigratesRenamedField() throws { let v1Blob = try JSONEncoder().encode(Setting(galleryHost: .exhentai)) let migrated = try JSONDecoder().decode(SettingV2.self, from: v1Blob) #expect(migrated.host == .exhentai) // v1 `galleryHost` carried into v2 `host` @@ -143,71 +153,83 @@ struct SchemaMigrationTests { #expect(decoded.host == .exhentai) } - // MARK: User + // MARK: User — ADD @Test - func userMigratesV1BlobToV2() throws { + func userMigratesAddedField() throws { + // v1 has no `region`; a plain strict decode of a required field would fail — the switch defaults it. let v1Blob = try JSONEncoder().encode(User(displayName: "alice")) let migrated = try JSONDecoder().decode(UserV2.self, from: v1Blob) - #expect(migrated.name == "alice") + #expect(migrated.displayName == "alice") // existing field preserved + #expect(migrated.region == "") // new field defaulted by the migration } @Test func userV2DecodesNatively() throws { - let decoded = try decode(UserV2.self, #"{"schemaVersion": 2, "name": "alice"}"#) - #expect(decoded.name == "alice") + let decoded = try decode(UserV2.self, #"{"schemaVersion": 2, "displayName": "bob", "region": "eu"}"#) + #expect(decoded.region == "eu") } - // MARK: Filter + // MARK: Filter — REMOVE @Test - func filterMigratesV1BlobToV2() throws { - let v1Blob = try JSONEncoder().encode(Filter(minRating: 5)) + func filterMigratesRemovedField() throws { + // The v1 blob still carries `minRating`; v2 ignores the dropped key and decodes cleanly. + let v1Blob = try JSONEncoder().encode(Filter(doujinshi: true, minRating: 5)) let migrated = try JSONDecoder().decode(FilterV2.self, from: v1Blob) - #expect(migrated.minimumRating == 5) + #expect(migrated.doujinshi) } @Test func filterV2DecodesNatively() throws { - let decoded = try decode(FilterV2.self, #"{"schemaVersion": 2, "minimumRating": 5}"#) - #expect(decoded.minimumRating == 5) + let decoded = try decode(FilterV2.self, #"{"schemaVersion": 2, "doujinshi": true}"#) + #expect(decoded.doujinshi) } - // MARK: TagTranslatorInfo + // MARK: TagTranslatorInfo — TYPE CHANGE @Test - func tagTranslatorInfoMigratesV1BlobToV2() throws { + func tagTranslatorInfoMigratesChangedType() throws { let v1Blob = try JSONEncoder().encode(TagTranslatorInfo(hasCustomTranslations: true)) let migrated = try JSONDecoder().decode(TagTranslatorInfoV2.self, from: v1Blob) - #expect(migrated.custom) + #expect(migrated.customTranslations == 1) // old Bool `true` converted to Int 1 } @Test func tagTranslatorInfoV2DecodesNatively() throws { - let decoded = try decode(TagTranslatorInfoV2.self, #"{"schemaVersion": 2, "custom": true}"#) - #expect(decoded.custom) + let decoded = try decode(TagTranslatorInfoV2.self, #"{"schemaVersion": 2, "customTranslations": 5}"#) + #expect(decoded.customTranslations == 5) } - // MARK: GalleryHistoryEntry + // MARK: GalleryHistoryEntry — DERIVE @Test - func galleryHistoryEntryMigratesV1BlobToV2() throws { - let entry = GalleryHistoryEntry( + func galleryHistoryEntryDerivesField() throws { + let started = GalleryHistoryEntry( gid: "1", token: "a", lastOpenDate: Date(timeIntervalSince1970: 1), readingProgress: 7 ) - let migrated = try JSONDecoder().decode(GalleryHistoryEntryV2.self, from: JSONEncoder().encode(entry)) - #expect(migrated.progress == 7) + let unstarted = GalleryHistoryEntry( + gid: "2", token: "b", lastOpenDate: Date(timeIntervalSince1970: 1), readingProgress: 0 + ) + let migratedStarted = try JSONDecoder().decode( + GalleryHistoryEntryV2.self, from: JSONEncoder().encode(started) + ) + let migratedUnstarted = try JSONDecoder().decode( + GalleryHistoryEntryV2.self, from: JSONEncoder().encode(unstarted) + ) + #expect(migratedStarted.started) // readingProgress 7 → started + #expect(!migratedUnstarted.started) // readingProgress 0 → not started } @Test func galleryHistoryEntryV2DecodesNatively() throws { - let decoded = try decode(GalleryHistoryEntryV2.self, #"{"schemaVersion": 2, "progress": 7}"#) - #expect(decoded.progress == 7) + let decoded = try decode(GalleryHistoryEntryV2.self, #"{"schemaVersion": 2, "started": true}"#) + #expect(decoded.started) } - // MARK: QuickSearchWord + // MARK: QuickSearchWord — MERGE @Test - func quickSearchWordMigratesV1BlobToV2() throws { + func quickSearchWordMergesFields() throws { let v1Blob = try JSONEncoder().encode(QuickSearchWord(name: "n", content: "c")) let migrated = try JSONDecoder().decode(QuickSearchWordV2.self, from: v1Blob) - #expect(migrated.label == "n") + #expect(migrated.combined == "n: c") // v1 `name` + `content` merged } @Test func quickSearchWordV2DecodesNatively() throws { - let decoded = try decode(QuickSearchWordV2.self, #"{"schemaVersion": 2, "label": "n"}"#) - #expect(decoded.label == "n") + let decoded = try decode(QuickSearchWordV2.self, #"{"schemaVersion": 2, "combined": "x"}"#) + #expect(decoded.combined == "x") } // MARK: Version cap (representative) From 04b2bdd75513a73821a950e59c72c25f66a51a9e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 18:16:50 +0800 Subject: [PATCH 598/614] Add progressive schema migration engine --- .../AppModels/Persistence/AppSharedKeys.swift | 6 +- .../AppModels/Persistence/JSONValue.swift | 62 ++++ .../Persistence/SchemaMigration.swift | 78 +++++ .../AppModels/Persistence/SchemaVersion.swift | 19 +- .../Sources/AppModels/Persistent/Filter.swift | 6 +- .../Persistent/GalleryHistoryEntry.swift | 6 +- .../AppModels/Persistent/Setting.swift | 6 +- .../Sources/AppModels/Persistent/User.swift | 6 +- .../Sources/AppModels/Support/Misc.swift | 6 +- .../AppModels/Tags/TagTranslatorInfo.swift | 6 +- .../AppModelsTests/SchemaMigrationTests.swift | 308 ++++++++++-------- 11 files changed, 349 insertions(+), 160 deletions(-) create mode 100644 AppPackage/Sources/AppModels/Persistence/JSONValue.swift create mode 100644 AppPackage/Sources/AppModels/Persistence/SchemaMigration.swift diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift index 4e2b3fc56..151e3f1a6 100644 --- a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -36,8 +36,10 @@ import Sharing // the version without a hand-written decoder, preserving their `didSet` invariants and optional-field // tolerance. The two identity-bearing array-element models (`GalleryHistoryEntry`, `QuickSearchWord`) // still hand-write `init(from:)` for their identity invariants, decoding that same `SchemaVersion` -// field for the version check. When a real breaking change lands, the affected model gains or extends -// a custom `init(from:)` that switches on the version to map the older shape forward. +// field for the version check. Every model also declares an ordered `migrations` list (v1 = +// `.passthrough`), from which `currentSchemaVersion` is derived. When a real breaking change lands, +// the model adopts `MigratableModel` and appends its v(N-1)→vN map; the `SchemaMigrator` engine then +// applies the chain in order (v1→v2→v3…) during decode. See `SchemaMigration`. // // Nothing here uses the `fileStorage` strategy. The tag-translation table is the only large // artifact, and it is deliberately NOT persisted through Sharing: only its thin diff --git a/AppPackage/Sources/AppModels/Persistence/JSONValue.swift b/AppPackage/Sources/AppModels/Persistence/JSONValue.swift new file mode 100644 index 000000000..9415c1e05 --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistence/JSONValue.swift @@ -0,0 +1,62 @@ +import Foundation + +/// A minimal, `Sendable` JSON tree used as the working representation for schema migrations. +/// +/// A migration map mutates a `[String: JSONValue]` object — "fetch this key, create that key, set this +/// value" — before the migrated data is decoded into the current model shape. `Int` and `Double` are +/// kept distinct so integer fields (and `Int`-raw enums) round-trip without being widened to `Double`. +public enum JSONValue: Hashable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) +} + +extension JSONValue { + public var boolValue: Bool? { if case .bool(let value) = self { return value } else { return nil } } + public var intValue: Int? { if case .int(let value) = self { return value } else { return nil } } + public var doubleValue: Double? { if case .double(let value) = self { return value } else { return nil } } + public var stringValue: String? { if case .string(let value) = self { return value } else { return nil } } +} + +extension JSONValue: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let bool = try? container.decode(Bool.self) { + self = .bool(bool) + } else if let int = try? container.decode(Int.self) { + self = .int(int) + } else if let double = try? container.decode(Double.self) { + self = .double(double) + } else if let string = try? container.decode(String.self) { + self = .string(string) + } else if let array = try? container.decode([JSONValue].self) { + self = .array(array) + } else if let object = try? container.decode([String: JSONValue].self) { + self = .object(object) + } else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Unsupported JSON value" + )) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let value): try container.encode(value) + case .int(let value): try container.encode(value) + case .double(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .object(let value): try container.encode(value) + } + } +} diff --git a/AppPackage/Sources/AppModels/Persistence/SchemaMigration.swift b/AppPackage/Sources/AppModels/Persistence/SchemaMigration.swift new file mode 100644 index 000000000..9cae2d5a4 --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistence/SchemaMigration.swift @@ -0,0 +1,78 @@ +import Foundation +import OSLogExt + +private let logger = Logger(category: "SchemaMigration") + +/// A single forward step in a model's schema history: it rewrites the raw JSON object of the previous +/// version into the shape of this version — "fetch this key, create that key, set this value". +/// +/// The `Model` parameter is a phantom that ties a map list to its model type; the transform itself only +/// touches the JSON. `.passthrough` is the identity map and is only valid at index 0 (the v1 slot), +/// since v1 has no earlier version to migrate from. +public struct SchemaMigration: Sendable { + let isPassthrough: Bool + let transform: @Sendable (inout [String: JSONValue]) throws -> Void + + /// A real migration from the previous version to this one. + public init(_ transform: @escaping @Sendable (inout [String: JSONValue]) throws -> Void) { + self.isPassthrough = false + self.transform = transform + } + + private init(passthrough: Bool) { + self.isPassthrough = passthrough + self.transform = { _ in } + } + + /// The identity map — no migration. Only valid in the v1 slot (index 0). + public static var passthrough: Self { Self(passthrough: true) } +} + +/// A model that migrates older persisted blobs forward *in decode*. +/// +/// Adopt this only once a model gains a real v2 (before that, a v1 model keeps synthesized/identity +/// `Codable` and just lists `migrations = [.passthrough]`). A conformer provides: +/// • `migrations` — the ordered maps (see `SchemaVersioned`), +/// • `init(currentFrom:)` — a decoder for the *current* shape only, and +/// • `init(from:)` as the one-line `self = try SchemaMigrator.migrate(Self.self, from: decoder)`. +/// The engine reads the stored version, applies the chain up to `currentSchemaVersion`, and then decodes +/// the current shape through `init(currentFrom:)`. +public protocol MigratableModel: Codable, SchemaVersioned { + /// Decode the *current* schema shape. Called by the engine after the blob has been migrated forward; + /// it must not re-enter migration (do not call `SchemaMigrator.migrate` here). + init(currentFrom decoder: Decoder) throws +} + +/// Applies a model's ordered migration chain to a raw blob, then decodes the migrated result. +public enum SchemaMigrator { + /// Wraps `init(currentFrom:)` so the engine can decode the current shape without re-entering + /// `Model.init(from:)` (which would recurse back into migration). + private struct CurrentShape: Decodable { + let value: Model + init(from decoder: Decoder) throws { + value = try Model(currentFrom: decoder) + } + } + + /// Decode `Model` from a possibly-older blob: read its `schemaVersion`, apply every map from that + /// version up to the current one in order, then decode the current shape. + public static func migrate( + _ type: Model.Type, from decoder: Decoder + ) throws -> Model { + var object = try [String: JSONValue](from: decoder) + let stored = object["schemaVersion"]?.intValue ?? 1 + let current = Model.currentSchemaVersion + guard (1...current).contains(stored) else { + let message = "\(Model.self) schemaVersion \(stored) is outside the supported range 1...\(current)" + logger.error("\(message, privacy: .public)") + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: message)) + } + // migrations[k] produces version k+1, so migrating stored → current applies migrations[stored...self, from: migratedData).value + } +} diff --git a/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift b/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift index afe8f4609..9a9a0933e 100644 --- a/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift +++ b/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift @@ -7,9 +7,22 @@ private let logger = Logger(category: "SchemaVersion") /// decode. Adopt it together with a `SchemaVersion` field so the version validates itself on /// decode. public protocol SchemaVersioned { - /// The newest `schemaVersion` this build understands. A stored blob carrying a larger value is a - /// downgrade and is rejected on decode. - static var currentSchemaVersion: Int { get } + /// Ordered migration maps, one slot per schema version: index 0 is v1 (must be `.passthrough`), + /// index 1 the v1→v2 map, index 2 the v2→v3 map, and so on. See `SchemaMigration` / `MigratableModel`. + static var migrations: [SchemaMigration] { get } +} + +extension SchemaVersioned { + /// The newest `schemaVersion` this build understands — the number of declared migration slots. A + /// stored blob carrying a larger value is a downgrade and is rejected on decode. + public static var currentSchemaVersion: Int { migrations.count } + + /// `true` iff `migrations` is well-formed: non-empty, its v1 slot (index 0) is `.passthrough`, and no + /// later slot is. `.passthrough` only makes sense at v1, which has no earlier version to migrate from. + public static var hasWellFormedMigrations: Bool { + guard let first = migrations.first, first.isPassthrough else { return false } + return migrations.dropFirst().allSatisfy { !$0.isPassthrough } + } } /// A self-validating `schemaVersion` field. diff --git a/AppPackage/Sources/AppModels/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift index c85173cf7..f40dcf454 100644 --- a/AppPackage/Sources/AppModels/Persistent/Filter.swift +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -59,9 +59,9 @@ public struct Filter: Codable, Equatable, Sendable, SchemaVersioned { self.disableUploader = disableUploader self.disableTags = disableTags } - /// Highest `schemaVersion` this build can decode. Bump when a breaking change lands and add a - /// custom `init(from:)` that maps the older shape forward. - public static let currentSchemaVersion = 1 + /// Migration maps, one slot per schema version (index 0 = v1 = `.passthrough`). `currentSchemaVersion` + /// is derived from the count; append a map and adopt `MigratableModel` when a breaking v2 lands. + public static let migrations: [SchemaMigration] = [.passthrough] // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise // untouched, so the `didSet` couplings below and optional-field tolerance still hold; a field added diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift index 94fec60bd..0129ec9a5 100644 --- a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift @@ -27,9 +27,9 @@ public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable, S self.readingProgress = readingProgress } public var id: String { gid } - /// Highest `schemaVersion` this build can decode. Bump and add a version switch in `init(from:)` - /// when a breaking change lands. - public static let currentSchemaVersion = 1 + /// Migration maps, one slot per schema version (index 0 = v1 = `.passthrough`). `currentSchemaVersion` + /// is derived from the count; append a map and adopt `MigratableModel` when a breaking v2 lands. + public static let migrations: [SchemaMigration] = [.passthrough] /// Self-validating (see `SchemaVersion`): a newer/downgrade value is rejected on decode. The /// identity guards in `init(from:)` below stay hand-written. public var schemaVersion: SchemaVersion = 1 diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index cc88630a8..31804a0af 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -57,9 +57,9 @@ public struct Setting: Codable, Equatable, Sendable, SchemaVersioned { self.doubleTapScaleFactor = doubleTapScaleFactor self.bypassesSNIFiltering = bypassesSNIFiltering } - /// Highest `schemaVersion` this build can decode. Bump when a breaking change lands and add a - /// custom `init(from:)` that maps the older shape forward. - public static let currentSchemaVersion = 1 + /// Migration maps, one slot per schema version (index 0 = v1 = `.passthrough`). `currentSchemaVersion` + /// is derived from the count; append a map and adopt `MigratableModel` when a breaking v2 lands. + public static let migrations: [SchemaMigration] = [.passthrough] // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise // untouched, so the `didSet` couplings below and optional-field tolerance still hold; a field added diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index 532e307f7..07e667613 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -17,9 +17,9 @@ public struct User: Codable, Equatable, Sendable, SchemaVersioned { } public static let empty = User() - /// Highest `schemaVersion` this build can decode. Bump when a breaking change lands and add a - /// custom `init(from:)` that maps the older shape forward. - public static let currentSchemaVersion = 1 + /// Migration maps, one slot per schema version (index 0 = v1 = `.passthrough`). `currentSchemaVersion` + /// is derived from the count; append a map and adopt `MigratableModel` when a breaking v2 lands. + public static let migrations: [SchemaMigration] = [.passthrough] // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise // untouched, so optional-field tolerance still holds; a field added later must stay optional so old diff --git a/AppPackage/Sources/AppModels/Support/Misc.swift b/AppPackage/Sources/AppModels/Support/Misc.swift index 5bc254392..01e24716e 100644 --- a/AppPackage/Sources/AppModels/Support/Misc.swift +++ b/AppPackage/Sources/AppModels/Support/Misc.swift @@ -146,9 +146,9 @@ public struct QuickSearchWord: Codable, Equatable, Identifiable, Sendable, Schem } public static var empty: Self { .init(name: "", content: "") } - /// Highest `schemaVersion` this build can decode. Bump and add a version switch in `init(from:)` - /// when a breaking change lands. - public static let currentSchemaVersion = 1 + /// Migration maps, one slot per schema version (index 0 = v1 = `.passthrough`). `currentSchemaVersion` + /// is derived from the count; append a map and adopt `MigratableModel` when a breaking v2 lands. + public static let migrations: [SchemaMigration] = [.passthrough] // Self-validating (see `SchemaVersion`): a newer/downgrade value is rejected on decode; the // identity guards in `init(from:)` below stay hand-written. public var schemaVersion: SchemaVersion = 1 diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift index 5f6b8f4f7..56df98bc3 100644 --- a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift +++ b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift @@ -19,9 +19,9 @@ public struct TagTranslatorInfo: Codable, Equatable, Sendable, SchemaVersioned { self.updatedDate = updatedDate self.hasCustomTranslations = hasCustomTranslations } - /// Highest `schemaVersion` this build can decode. Bump when a breaking change lands and add a - /// custom `init(from:)` that maps the older shape forward. - public static let currentSchemaVersion = 1 + /// Migration maps, one slot per schema version (index 0 = v1 = `.passthrough`). `currentSchemaVersion` + /// is derived from the count; append a map and adopt `MigratableModel` when a breaking v2 lands. + public static let migrations: [SchemaMigration] = [.passthrough] // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise // untouched, so optional-field tolerance still holds; a field added later must stay optional so old diff --git a/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift b/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift index 49759c1f4..14cc06a48 100644 --- a/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift +++ b/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift @@ -2,202 +2,234 @@ import Foundation import Testing import AppModels -// MARK: - MOCK v2 models — REMOVE when real v2 models land +// MARK: - MOCK migratable models — REMOVE when real v2 models land // -// Every `SchemaVersioned` model is still at schemaVersion 1, so there is no real migration to run yet. -// To exercise the in-decode migration machinery, each `V2` below stands in for a hypothetical v2 -// of the corresponding model: it sets `currentSchemaVersion` to 2 and hand-writes the `init(from:)` -// version switch a real migration would use. Collectively they span the common schema-change shapes: +// No real model has a breaking change yet, so these mocks exercise the migration engine +// (`SchemaMigrator` + `SchemaMigration` maps + progressive chaining). Each conforms to `MigratableModel`, +// declares an ordered `migrations` list (index 0 = v1 = `.passthrough`), and provides `init(currentFrom:)` +// for its current shape. A v1→v2 map is a raw-JSON transform; the engine reads a stored blob's version +// and applies every map from there up to `currentSchemaVersion`, in order. // -// • Setting RENAME — galleryHost → host -// • User ADD — new required `region`, defaulted when migrating from v1 -// • Filter REMOVE — drops `minRating` (a v1 blob still carrying it decodes) -// • TagTranslatorInfo TYPE — hasCustomTranslations: Bool → customTranslations: Int -// • GalleryHistoryEntry DERIVE — new `started: Bool` computed from v1 `readingProgress` -// • QuickSearchWord MERGE — `name` + `content` → `combined` +// Shapes covered, one per model: RENAME, ADD, REMOVE, TYPE, DERIVE, MERGE. `ProgressiveMock` covers a +// multi-step chain (v1 → v2 → v3). // -// Each test feeds a REAL v1 blob (what the current code writes) through the mock and asserts the forward -// map; a native v2 blob covers the other branch. RENAME/ADD/TYPE/DERIVE/MERGE need the version switch; -// REMOVE is decode-forward-compatible (a dropped key is simply ignored), so its mock only validates the -// version and has no branch. -// -// FUTURE AGENT: when a model gains a REAL v2 (an actual breaking change, with its own `init(from:)` -// version switch on the real type), DELETE that model's `V2` mock and its tests here, and replace -// them with tests that migrate a real v1 blob to the real v2 shape. Delete this whole file once every -// model has real migration coverage. +// FUTURE AGENT: when a model gains a REAL v2 (it adopts `MigratableModel` and appends a real map to its +// `migrations`), DELETE that model's mock + its tests here and replace them with tests over the real +// migration. Delete this whole file once every model has real migration coverage. // MOCK — remove with real Setting v2. RENAME: v1 `galleryHost` → v2 `host`. -private struct SettingV2: Decodable, SchemaVersioned { - static let currentSchemaVersion = 2 +private struct SettingMock: MigratableModel { var host: GalleryHost - enum CodingKeys: String, CodingKey { case schemaVersion, galleryHost, host } - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value - switch version { - case 1: - host = try container.decode(GalleryHost.self, forKey: .galleryHost) // read the old key - default: - host = try container.decode(GalleryHost.self, forKey: .host) // native v2 + static let migrations: [SchemaMigration] = [ + .passthrough, + SchemaMigration { object in + object["host"] = object["galleryHost"] + object["galleryHost"] = nil } + ] + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + host = try container.decode(GalleryHost.self, forKey: .host) } } // MOCK — remove with real User v2. ADD: v2 introduces a required `region`, defaulted when migrating. -private struct UserV2: Decodable, SchemaVersioned { - static let currentSchemaVersion = 2 +private struct UserMock: MigratableModel { var displayName: String? - var region: String // NEW in v2 and REQUIRED there; a v1 blob has no such key - enum CodingKeys: String, CodingKey { case schemaVersion, displayName, region } - init(from decoder: Decoder) throws { + var region: String + static let migrations: [SchemaMigration] = [ + .passthrough, + SchemaMigration { object in + object["region"] = .string("") + } + ] + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion).value displayName = try container.decodeIfPresent(String.self, forKey: .displayName) - switch version { - case 1: - region = "" // migration supplies the default for a field v1 never had - default: - region = try container.decode(String.self, forKey: .region) // required in v2 - } + region = try container.decode(String.self, forKey: .region) } } // MOCK — remove with real Filter v2. REMOVE: v2 drops `minRating`; a v1 blob still carrying it decodes. -private struct FilterV2: Decodable, SchemaVersioned { - static let currentSchemaVersion = 2 - var doujinshi: Bool // retained field; `minRating` was dropped in v2 and is simply not read - enum CodingKeys: String, CodingKey { case schemaVersion, doujinshi } - init(from decoder: Decoder) throws { +private struct FilterMock: MigratableModel { + var doujinshi: Bool + static let migrations: [SchemaMigration] = [ + .passthrough, + SchemaMigration { object in + object["minRating"] = nil + } + ] + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - _ = try container.decode(SchemaVersion.self, forKey: .schemaVersion) // validate range - doujinshi = try container.decode(Bool.self, forKey: .doujinshi) // unchanged v1 → v2, no branch + doujinshi = try container.decode(Bool.self, forKey: .doujinshi) } } // MOCK — remove with real TagTranslatorInfo v2. TYPE: v1 `hasCustomTranslations: Bool` → v2 Int. -private struct TagTranslatorInfoV2: Decodable, SchemaVersioned { - static let currentSchemaVersion = 2 - var customTranslations: Int // was `hasCustomTranslations: Bool` in v1 - enum CodingKeys: String, CodingKey { case schemaVersion, hasCustomTranslations, customTranslations } - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion) - switch version.value { - case 1: - let old = try container.decode(Bool.self, forKey: .hasCustomTranslations) - customTranslations = old ? 1 : 0 // convert the old Bool to the new Int - default: - customTranslations = try container.decode(Int.self, forKey: .customTranslations) +private struct TagTranslatorInfoMock: MigratableModel { + var customTranslations: Int + static let migrations: [SchemaMigration] = [ + .passthrough, + SchemaMigration { object in + let flag = object["hasCustomTranslations"]?.boolValue ?? false + object["customTranslations"] = .int(flag ? 1 : 0) + object["hasCustomTranslations"] = nil } + ] + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + customTranslations = try container.decode(Int.self, forKey: .customTranslations) } } // MOCK — remove with real GalleryHistoryEntry v2. DERIVE: v2 `started` computed from v1 `readingProgress`. -private struct GalleryHistoryEntryV2: Decodable, SchemaVersioned { - static let currentSchemaVersion = 2 - var started: Bool // NEW in v2, computed from v1 `readingProgress` - enum CodingKeys: String, CodingKey { case schemaVersion, readingProgress, started } - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion) - switch version.value { - case 1: - let progress = try container.decode(Int.self, forKey: .readingProgress) - started = progress > 0 // derive the new field from old data - default: - started = try container.decode(Bool.self, forKey: .started) +private struct GalleryHistoryEntryMock: MigratableModel { + var started: Bool + static let migrations: [SchemaMigration] = [ + .passthrough, + SchemaMigration { object in + let progress = object["readingProgress"]?.intValue ?? 0 + object["started"] = .bool(progress > 0) } + ] + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + started = try container.decode(Bool.self, forKey: .started) } } // MOCK — remove with real QuickSearchWord v2. MERGE: v1 `name` + `content` → v2 `combined`. -private struct QuickSearchWordV2: Decodable, SchemaVersioned { - static let currentSchemaVersion = 2 - var combined: String // v2 merges v1 `name` and `content` - enum CodingKeys: String, CodingKey { case schemaVersion, name, content, combined } - init(from decoder: Decoder) throws { +private struct QuickSearchWordMock: MigratableModel { + var combined: String + static let migrations: [SchemaMigration] = [ + .passthrough, + SchemaMigration { object in + let name = object["name"]?.stringValue ?? "" + let content = object["content"]?.stringValue ?? "" + object["combined"] = .string("\(name): \(content)") + } + ] + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let version = try container.decode(SchemaVersion.self, forKey: .schemaVersion) - switch version.value { - case 1: - let name = try container.decode(String.self, forKey: .name) - let content = try container.decode(String.self, forKey: .content) - combined = "\(name): \(content)" // merge two fields into one - default: - combined = try container.decode(String.self, forKey: .combined) + combined = try container.decode(String.self, forKey: .combined) + } +} + +// MOCK — progressive chain across three versions. v1 field `a` → v2 `b` (doubled) → v3 `value` (plus one). +private struct ProgressiveMock: MigratableModel { + var value: Int + static let migrations: [SchemaMigration] = [ + .passthrough, + SchemaMigration { object in + let old = object["a"]?.intValue ?? 0 + object["b"] = .int(old * 2) + object["a"] = nil + }, + SchemaMigration { object in + let old = object["b"]?.intValue ?? 0 + object["value"] = .int(old + 1) + object["b"] = nil } + ] + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + value = try container.decode(Int.self, forKey: .value) } } // MARK: - Migration tests -// -// Per model: a real v1 blob (what the current code writes) forward-migrates through the mock v2 decoder, -// and a native v2 blob decodes through the other branch. The `SchemaVersion` cap is covered once, by -// `aV2ModelRejectsAnUnknownVersion`. @Suite struct SchemaMigrationTests { private func decode(_ type: T.Type, _ json: String) throws -> T { try JSONDecoder().decode(T.self, from: Data(json.utf8)) } - // MARK: Setting — RENAME + // MARK: Progressive chaining (the headline) + @Test + func aV1BlobChainsThroughEveryStep() throws { + // a=5 → v1→v2 doubles to b=10 → v2→v3 adds one to value=11. Both steps run, in order. + let migrated = try decode(ProgressiveMock.self, #"{"schemaVersion": 1, "a": 5}"#) + #expect(migrated.value == 11) + } + @Test + func aMidVersionBlobRunsOnlyTheRemainingSteps() throws { + // Starting at v2 (b=10) runs only v2→v3: value = 11. + let migrated = try decode(ProgressiveMock.self, #"{"schemaVersion": 2, "b": 10}"#) + #expect(migrated.value == 11) + } + @Test + func aCurrentVersionBlobDecodesWithNoSteps() throws { + let decoded = try decode(ProgressiveMock.self, #"{"schemaVersion": 3, "value": 11}"#) + #expect(decoded.value == 11) + } + @Test + func aNewerVersionBlobIsRejected() { + // currentSchemaVersion is 3 (migrations.count); a v4 blob is a downgrade and throws. + #expect(throws: (any Error).self) { + try decode(ProgressiveMock.self, #"{"schemaVersion": 4, "value": 11}"#) + } + } + + // MARK: Per-shape maps, fed a REAL v1 blob @Test - func settingMigratesRenamedField() throws { + func settingRenamesAField() throws { let v1Blob = try JSONEncoder().encode(Setting(galleryHost: .exhentai)) - let migrated = try JSONDecoder().decode(SettingV2.self, from: v1Blob) - #expect(migrated.host == .exhentai) // v1 `galleryHost` carried into v2 `host` + let migrated = try JSONDecoder().decode(SettingMock.self, from: v1Blob) + #expect(migrated.host == .exhentai) } @Test - func settingV2DecodesNatively() throws { - let decoded = try decode(SettingV2.self, #"{"schemaVersion": 2, "host": "ExHentai"}"#) + func settingMockDecodesNatively() throws { + let decoded = try decode(SettingMock.self, #"{"schemaVersion": 2, "host": "ExHentai"}"#) #expect(decoded.host == .exhentai) } - // MARK: User — ADD @Test - func userMigratesAddedField() throws { - // v1 has no `region`; a plain strict decode of a required field would fail — the switch defaults it. + func userAddsARequiredField() throws { let v1Blob = try JSONEncoder().encode(User(displayName: "alice")) - let migrated = try JSONDecoder().decode(UserV2.self, from: v1Blob) + let migrated = try JSONDecoder().decode(UserMock.self, from: v1Blob) #expect(migrated.displayName == "alice") // existing field preserved - #expect(migrated.region == "") // new field defaulted by the migration + #expect(migrated.region == "") // new field defaulted by the map } @Test - func userV2DecodesNatively() throws { - let decoded = try decode(UserV2.self, #"{"schemaVersion": 2, "displayName": "bob", "region": "eu"}"#) + func userMockDecodesNatively() throws { + let decoded = try decode(UserMock.self, #"{"schemaVersion": 2, "displayName": "bob", "region": "eu"}"#) #expect(decoded.region == "eu") } - // MARK: Filter — REMOVE @Test - func filterMigratesRemovedField() throws { - // The v1 blob still carries `minRating`; v2 ignores the dropped key and decodes cleanly. + func filterRemovesAField() throws { + // The v1 blob still carries `minRating`; the map drops it and the current shape decodes. let v1Blob = try JSONEncoder().encode(Filter(doujinshi: true, minRating: 5)) - let migrated = try JSONDecoder().decode(FilterV2.self, from: v1Blob) + let migrated = try JSONDecoder().decode(FilterMock.self, from: v1Blob) #expect(migrated.doujinshi) } @Test - func filterV2DecodesNatively() throws { - let decoded = try decode(FilterV2.self, #"{"schemaVersion": 2, "doujinshi": true}"#) + func filterMockDecodesNatively() throws { + let decoded = try decode(FilterMock.self, #"{"schemaVersion": 2, "doujinshi": true}"#) #expect(decoded.doujinshi) } - // MARK: TagTranslatorInfo — TYPE CHANGE @Test - func tagTranslatorInfoMigratesChangedType() throws { + func tagTranslatorInfoChangesAFieldType() throws { let v1Blob = try JSONEncoder().encode(TagTranslatorInfo(hasCustomTranslations: true)) - let migrated = try JSONDecoder().decode(TagTranslatorInfoV2.self, from: v1Blob) - #expect(migrated.customTranslations == 1) // old Bool `true` converted to Int 1 + let migrated = try JSONDecoder().decode(TagTranslatorInfoMock.self, from: v1Blob) + #expect(migrated.customTranslations == 1) // Bool true → Int 1 } @Test - func tagTranslatorInfoV2DecodesNatively() throws { - let decoded = try decode(TagTranslatorInfoV2.self, #"{"schemaVersion": 2, "customTranslations": 5}"#) + func tagTranslatorInfoMockDecodesNatively() throws { + let decoded = try decode(TagTranslatorInfoMock.self, #"{"schemaVersion": 2, "customTranslations": 5}"#) #expect(decoded.customTranslations == 5) } - // MARK: GalleryHistoryEntry — DERIVE @Test - func galleryHistoryEntryDerivesField() throws { + func galleryHistoryEntryDerivesAField() throws { let started = GalleryHistoryEntry( gid: "1", token: "a", lastOpenDate: Date(timeIntervalSince1970: 1), readingProgress: 7 ) @@ -205,39 +237,41 @@ struct SchemaMigrationTests { gid: "2", token: "b", lastOpenDate: Date(timeIntervalSince1970: 1), readingProgress: 0 ) let migratedStarted = try JSONDecoder().decode( - GalleryHistoryEntryV2.self, from: JSONEncoder().encode(started) + GalleryHistoryEntryMock.self, from: JSONEncoder().encode(started) ) let migratedUnstarted = try JSONDecoder().decode( - GalleryHistoryEntryV2.self, from: JSONEncoder().encode(unstarted) + GalleryHistoryEntryMock.self, from: JSONEncoder().encode(unstarted) ) - #expect(migratedStarted.started) // readingProgress 7 → started - #expect(!migratedUnstarted.started) // readingProgress 0 → not started + #expect(migratedStarted.started) + #expect(!migratedUnstarted.started) } @Test - func galleryHistoryEntryV2DecodesNatively() throws { - let decoded = try decode(GalleryHistoryEntryV2.self, #"{"schemaVersion": 2, "started": true}"#) + func galleryHistoryEntryMockDecodesNatively() throws { + let decoded = try decode(GalleryHistoryEntryMock.self, #"{"schemaVersion": 2, "started": true}"#) #expect(decoded.started) } - // MARK: QuickSearchWord — MERGE @Test func quickSearchWordMergesFields() throws { let v1Blob = try JSONEncoder().encode(QuickSearchWord(name: "n", content: "c")) - let migrated = try JSONDecoder().decode(QuickSearchWordV2.self, from: v1Blob) - #expect(migrated.combined == "n: c") // v1 `name` + `content` merged + let migrated = try JSONDecoder().decode(QuickSearchWordMock.self, from: v1Blob) + #expect(migrated.combined == "n: c") } @Test - func quickSearchWordV2DecodesNatively() throws { - let decoded = try decode(QuickSearchWordV2.self, #"{"schemaVersion": 2, "combined": "x"}"#) + func quickSearchWordMockDecodesNatively() throws { + let decoded = try decode(QuickSearchWordMock.self, #"{"schemaVersion": 2, "combined": "x"}"#) #expect(decoded.combined == "x") } - // MARK: Version cap (representative) + // MARK: Invariant — passthrough only at v1 @Test - func aV2ModelRejectsAnUnknownVersion() { - // The mock caps `currentSchemaVersion` at 2; a v3 blob is rejected by `SchemaVersion`. - #expect(throws: (any Error).self) { - try decode(SettingV2.self, #"{"schemaVersion": 3, "host": "ExHentai"}"#) - } + func everyModelDeclaresWellFormedMigrations() { + #expect(Setting.hasWellFormedMigrations) + #expect(User.hasWellFormedMigrations) + #expect(Filter.hasWellFormedMigrations) + #expect(TagTranslatorInfo.hasWellFormedMigrations) + #expect(GalleryHistoryEntry.hasWellFormedMigrations) + #expect(QuickSearchWord.hasWellFormedMigrations) + #expect(ProgressiveMock.hasWellFormedMigrations) // 3 slots, passthrough only at index 0 } } From b04cb72eb9b576a19f583ff40cc22f1f5e19b8f6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 19:28:32 +0800 Subject: [PATCH 599/614] Rework migration to array-of-schemas design --- .../AppModels/Persistence/AppSharedKeys.swift | 9 +- ...maMigration.swift => SchemaMigrator.swift} | 48 ++----- .../AppModels/Persistence/SchemaVersion.swift | 26 ++-- .../Persistence/VersionedSchema.swift | 21 +++ .../Sources/AppModels/Persistent/Filter.swift | 13 +- .../Persistent/GalleryHistoryEntry.swift | 13 +- .../AppModels/Persistent/Setting.swift | 13 +- .../Sources/AppModels/Persistent/User.swift | 13 +- .../Sources/AppModels/Support/Misc.swift | 13 +- .../AppModels/Tags/TagTranslatorInfo.swift | 13 +- .../AppModelsTests/SchemaMigrationTests.swift | 132 +++++++++++------- 11 files changed, 196 insertions(+), 118 deletions(-) rename AppPackage/Sources/AppModels/Persistence/{SchemaMigration.swift => SchemaMigrator.swift} (51%) create mode 100644 AppPackage/Sources/AppModels/Persistence/VersionedSchema.swift diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift index 151e3f1a6..c51d7156b 100644 --- a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -36,10 +36,11 @@ import Sharing // the version without a hand-written decoder, preserving their `didSet` invariants and optional-field // tolerance. The two identity-bearing array-element models (`GalleryHistoryEntry`, `QuickSearchWord`) // still hand-write `init(from:)` for their identity invariants, decoding that same `SchemaVersion` -// field for the version check. Every model also declares an ordered `migrations` list (v1 = -// `.passthrough`), from which `currentSchemaVersion` is derived. When a real breaking change lands, -// the model adopts `MigratableModel` and appends its v(N-1)→vN map; the `SchemaMigrator` engine then -// applies the chain in order (v1→v2→v3…) during decode. See `SchemaMigration`. +// field for the version check. Every model also declares an ordered `schemas` history (à la SwiftData's +// `SchemaMigrationPlan.schemas`), starting at its v1 base schema, from which `currentSchemaVersion` is +// derived. When a real breaking change lands, the model appends a `VersionedSchema` whose `migrate` maps +// the previous shape forward and adopts `MigratableModel`; the `SchemaMigrator` engine then walks the +// chain in order (v1→v2→v3…) during decode. See `VersionedSchema` / `SchemaMigrator`. // // Nothing here uses the `fileStorage` strategy. The tag-translation table is the only large // artifact, and it is deliberately NOT persisted through Sharing: only its thin diff --git a/AppPackage/Sources/AppModels/Persistence/SchemaMigration.swift b/AppPackage/Sources/AppModels/Persistence/SchemaMigrator.swift similarity index 51% rename from AppPackage/Sources/AppModels/Persistence/SchemaMigration.swift rename to AppPackage/Sources/AppModels/Persistence/SchemaMigrator.swift index 9cae2d5a4..0972bbfcc 100644 --- a/AppPackage/Sources/AppModels/Persistence/SchemaMigration.swift +++ b/AppPackage/Sources/AppModels/Persistence/SchemaMigrator.swift @@ -1,49 +1,24 @@ import Foundation import OSLogExt -private let logger = Logger(category: "SchemaMigration") - -/// A single forward step in a model's schema history: it rewrites the raw JSON object of the previous -/// version into the shape of this version — "fetch this key, create that key, set this value". -/// -/// The `Model` parameter is a phantom that ties a map list to its model type; the transform itself only -/// touches the JSON. `.passthrough` is the identity map and is only valid at index 0 (the v1 slot), -/// since v1 has no earlier version to migrate from. -public struct SchemaMigration: Sendable { - let isPassthrough: Bool - let transform: @Sendable (inout [String: JSONValue]) throws -> Void - - /// A real migration from the previous version to this one. - public init(_ transform: @escaping @Sendable (inout [String: JSONValue]) throws -> Void) { - self.isPassthrough = false - self.transform = transform - } - - private init(passthrough: Bool) { - self.isPassthrough = passthrough - self.transform = { _ in } - } - - /// The identity map — no migration. Only valid in the v1 slot (index 0). - public static var passthrough: Self { Self(passthrough: true) } -} +private let logger = Logger(category: .init(describing: SchemaMigrator.self)) /// A model that migrates older persisted blobs forward *in decode*. /// /// Adopt this only once a model gains a real v2 (before that, a v1 model keeps synthesized/identity -/// `Codable` and just lists `migrations = [.passthrough]`). A conformer provides: -/// • `migrations` — the ordered maps (see `SchemaVersioned`), +/// `Codable` and just lists `schemas = [SchemaV1.self]`). A conformer provides: +/// • `schemas` — the ordered schema history (see `SchemaVersioned` / `VersionedSchema`), /// • `init(currentFrom:)` — a decoder for the *current* shape only, and /// • `init(from:)` as the one-line `self = try SchemaMigrator.migrate(Self.self, from: decoder)`. -/// The engine reads the stored version, applies the chain up to `currentSchemaVersion`, and then decodes -/// the current shape through `init(currentFrom:)`. +/// The engine reads the stored version, walks every schema newer than it up to `currentSchemaVersion`, +/// then decodes the current shape through `init(currentFrom:)`. public protocol MigratableModel: Codable, SchemaVersioned { /// Decode the *current* schema shape. Called by the engine after the blob has been migrated forward; /// it must not re-enter migration (do not call `SchemaMigrator.migrate` here). init(currentFrom decoder: Decoder) throws } -/// Applies a model's ordered migration chain to a raw blob, then decodes the migrated result. +/// Walks a model's ordered schema history over a raw blob, then decodes the migrated result. public enum SchemaMigrator { /// Wraps `init(currentFrom:)` so the engine can decode the current shape without re-entering /// `Model.init(from:)` (which would recurse back into migration). @@ -54,8 +29,8 @@ public enum SchemaMigrator { } } - /// Decode `Model` from a possibly-older blob: read its `schemaVersion`, apply every map from that - /// version up to the current one in order, then decode the current shape. + /// Decode `Model` from a possibly-older blob: read its `schemaVersion`, ask every schema newer than + /// it to migrate the object forward in order, then decode the current shape. public static func migrate( _ type: Model.Type, from decoder: Decoder ) throws -> Model { @@ -67,9 +42,10 @@ public enum SchemaMigrator { logger.error("\(message, privacy: .public)") throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: message)) } - // migrations[k] produces version k+1, so migrating stored → current applies migrations[stored.. stored { + try schema.migrate(&object) } object["schemaVersion"] = .int(current) let migratedData = try JSONEncoder().encode(object) diff --git a/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift b/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift index 9a9a0933e..2cb8ed340 100644 --- a/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift +++ b/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift @@ -7,21 +7,21 @@ private let logger = Logger(category: "SchemaVersion") /// decode. Adopt it together with a `SchemaVersion` field so the version validates itself on /// decode. public protocol SchemaVersioned { - /// Ordered migration maps, one slot per schema version: index 0 is v1 (must be `.passthrough`), - /// index 1 the v1→v2 map, index 2 the v2→v3 map, and so on. See `SchemaMigration` / `MigratableModel`. - static var migrations: [SchemaMigration] { get } + /// This model's schema history, oldest → newest (index 0 is version 1). The raw-JSON analog of + /// `SchemaMigrationPlan.schemas`. Never empty: a model that has never changed still declares its v1 + /// base schema. When a breaking change lands, append the next `VersionedSchema`; see `VersionedSchema`. + static var schemas: [any VersionedSchema.Type] { get } } extension SchemaVersioned { - /// The newest `schemaVersion` this build understands — the number of declared migration slots. A - /// stored blob carrying a larger value is a downgrade and is rejected on decode. - public static var currentSchemaVersion: Int { migrations.count } + /// The newest `schemaVersion` this build understands — the head of `schemas`. A stored blob carrying + /// a larger value is a downgrade and is rejected on decode. + public static var currentSchemaVersion: Int { schemas.last?.version ?? 1 } - /// `true` iff `migrations` is well-formed: non-empty, its v1 slot (index 0) is `.passthrough`, and no - /// later slot is. `.passthrough` only makes sense at v1, which has no earlier version to migrate from. - public static var hasWellFormedMigrations: Bool { - guard let first = migrations.first, first.isPassthrough else { return false } - return migrations.dropFirst().allSatisfy { !$0.isPassthrough } + /// `true` iff `schemas` is non-empty and its versions are `1, 2, … n` — ascending, contiguous, and + /// starting at 1. The engine's ordered walk relies on this; an invariant test enforces it. + public static var hasWellFormedSchemas: Bool { + !schemas.isEmpty && schemas.enumerated().allSatisfy { $0.offset + 1 == $0.element.version } } } @@ -36,8 +36,8 @@ extension SchemaVersioned { /// This is the lightweight, in-decode migration seam. It gives every persisted model uniform /// downgrade rejection *without* a hand-written `init(from:)`, so synthesized `Codable` — and with it /// each model's `didSet` invariants and optional-field tolerance — stays untouched. When a real -/// breaking change lands for a model, that model gains a custom `init(from:)` that switches on this -/// version to map the older shape forward. +/// breaking change lands for a model, it appends a `VersionedSchema` to its `schemas` and routes +/// `init(from:)` through `SchemaMigrator`, which walks the chain to map the older shape forward. public struct SchemaVersion: Hashable, Sendable { public let value: Int diff --git a/AppPackage/Sources/AppModels/Persistence/VersionedSchema.swift b/AppPackage/Sources/AppModels/Persistence/VersionedSchema.swift new file mode 100644 index 000000000..6c74cee8c --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistence/VersionedSchema.swift @@ -0,0 +1,21 @@ +import Foundation + +/// One schema version of a persisted model: its version number and how the *previous* version's raw +/// JSON becomes this one. +/// +/// Declared oldest → newest in `SchemaVersioned.schemas`; the `SchemaMigrator` engine walks them to +/// bring an old blob forward one hop at a time. This is the raw-JSON analog of SwiftData's +/// `VersionedSchema` fused with a `.custom` migration stage — we migrate a JSON blob rather than a +/// typed store, so a schema carries just its version and its map (no per-version model snapshot, and +/// no lightweight/auto-inferred stage, because there is no typed store to infer against). +public protocol VersionedSchema { + /// This schema's version number. Across a model's `schemas`, versions run `1, 2, … n` — ascending, + /// contiguous, starting at 1. + static var version: Int { get } + + /// Rewrite the previous version's decoded object into this version's shape — "fetch this key, + /// create that key, set this value". The base schema (version 1) has nothing before it, so its + /// body is empty and the engine never invokes it (it only runs schemas whose `version` exceeds the + /// stored one). + static func migrate(_ object: inout [String: JSONValue]) throws +} diff --git a/AppPackage/Sources/AppModels/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift index f40dcf454..d244a1add 100644 --- a/AppPackage/Sources/AppModels/Persistent/Filter.swift +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -59,9 +59,16 @@ public struct Filter: Codable, Equatable, Sendable, SchemaVersioned { self.disableUploader = disableUploader self.disableTags = disableTags } - /// Migration maps, one slot per schema version (index 0 = v1 = `.passthrough`). `currentSchemaVersion` - /// is derived from the count; append a map and adopt `MigratableModel` when a breaking v2 lands. - public static let migrations: [SchemaMigration] = [.passthrough] + /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. + /// `currentSchemaVersion` derives from the head. Append a `VersionedSchema` and adopt + /// `MigratableModel` when a breaking change lands. + public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] } + /// The v1 base schema. Its `migrate` is empty — nothing precedes v1, and the engine only runs + /// schemas newer than the stored version, so it exists solely to anchor version 1. + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise // untouched, so the `didSet` couplings below and optional-field tolerance still hold; a field added diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift index 0129ec9a5..71a1e2f08 100644 --- a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift @@ -27,9 +27,16 @@ public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable, S self.readingProgress = readingProgress } public var id: String { gid } - /// Migration maps, one slot per schema version (index 0 = v1 = `.passthrough`). `currentSchemaVersion` - /// is derived from the count; append a map and adopt `MigratableModel` when a breaking v2 lands. - public static let migrations: [SchemaMigration] = [.passthrough] + /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. + /// `currentSchemaVersion` derives from the head. Append a `VersionedSchema` and adopt + /// `MigratableModel` when a breaking change lands. + public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] } + /// The v1 base schema. Its `migrate` is empty — nothing precedes v1, and the engine only runs + /// schemas newer than the stored version, so it exists solely to anchor version 1. + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } /// Self-validating (see `SchemaVersion`): a newer/downgrade value is rejected on decode. The /// identity guards in `init(from:)` below stay hand-written. public var schemaVersion: SchemaVersion = 1 diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift index 31804a0af..c0f99caab 100644 --- a/AppPackage/Sources/AppModels/Persistent/Setting.swift +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -57,9 +57,16 @@ public struct Setting: Codable, Equatable, Sendable, SchemaVersioned { self.doubleTapScaleFactor = doubleTapScaleFactor self.bypassesSNIFiltering = bypassesSNIFiltering } - /// Migration maps, one slot per schema version (index 0 = v1 = `.passthrough`). `currentSchemaVersion` - /// is derived from the count; append a map and adopt `MigratableModel` when a breaking v2 lands. - public static let migrations: [SchemaMigration] = [.passthrough] + /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. + /// `currentSchemaVersion` derives from the head. Append a `VersionedSchema` and adopt + /// `MigratableModel` when a breaking change lands. + public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] } + /// The v1 base schema. Its `migrate` is empty — nothing precedes v1, and the engine only runs + /// schemas newer than the stored version, so it exists solely to anchor version 1. + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise // untouched, so the `didSet` couplings below and optional-field tolerance still hold; a field added diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift index 07e667613..bc4e9be6d 100644 --- a/AppPackage/Sources/AppModels/Persistent/User.swift +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -17,9 +17,16 @@ public struct User: Codable, Equatable, Sendable, SchemaVersioned { } public static let empty = User() - /// Migration maps, one slot per schema version (index 0 = v1 = `.passthrough`). `currentSchemaVersion` - /// is derived from the count; append a map and adopt `MigratableModel` when a breaking v2 lands. - public static let migrations: [SchemaMigration] = [.passthrough] + /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. + /// `currentSchemaVersion` derives from the head. Append a `VersionedSchema` and adopt + /// `MigratableModel` when a breaking change lands. + public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] } + /// The v1 base schema. Its `migrate` is empty — nothing precedes v1, and the engine only runs + /// schemas newer than the stored version, so it exists solely to anchor version 1. + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise // untouched, so optional-field tolerance still holds; a field added later must stay optional so old diff --git a/AppPackage/Sources/AppModels/Support/Misc.swift b/AppPackage/Sources/AppModels/Support/Misc.swift index 01e24716e..5d6f783c6 100644 --- a/AppPackage/Sources/AppModels/Support/Misc.swift +++ b/AppPackage/Sources/AppModels/Support/Misc.swift @@ -146,9 +146,16 @@ public struct QuickSearchWord: Codable, Equatable, Identifiable, Sendable, Schem } public static var empty: Self { .init(name: "", content: "") } - /// Migration maps, one slot per schema version (index 0 = v1 = `.passthrough`). `currentSchemaVersion` - /// is derived from the count; append a map and adopt `MigratableModel` when a breaking v2 lands. - public static let migrations: [SchemaMigration] = [.passthrough] + /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. + /// `currentSchemaVersion` derives from the head. Append a `VersionedSchema` and adopt + /// `MigratableModel` when a breaking change lands. + public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] } + /// The v1 base schema. Its `migrate` is empty — nothing precedes v1, and the engine only runs + /// schemas newer than the stored version, so it exists solely to anchor version 1. + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } // Self-validating (see `SchemaVersion`): a newer/downgrade value is rejected on decode; the // identity guards in `init(from:)` below stay hand-written. public var schemaVersion: SchemaVersion = 1 diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift index 56df98bc3..6c85974f6 100644 --- a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift +++ b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift @@ -19,9 +19,16 @@ public struct TagTranslatorInfo: Codable, Equatable, Sendable, SchemaVersioned { self.updatedDate = updatedDate self.hasCustomTranslations = hasCustomTranslations } - /// Migration maps, one slot per schema version (index 0 = v1 = `.passthrough`). `currentSchemaVersion` - /// is derived from the count; append a map and adopt `MigratableModel` when a breaking v2 lands. - public static let migrations: [SchemaMigration] = [.passthrough] + /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. + /// `currentSchemaVersion` derives from the head. Append a `VersionedSchema` and adopt + /// `MigratableModel` when a breaking change lands. + public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] } + /// The v1 base schema. Its `migrate` is empty — nothing precedes v1, and the engine only runs + /// schemas newer than the stored version, so it exists solely to anchor version 1. + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise // untouched, so optional-field tolerance still holds; a field added later must stay optional so old diff --git a/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift b/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift index 14cc06a48..e320dc236 100644 --- a/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift +++ b/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift @@ -5,28 +5,33 @@ import AppModels // MARK: - MOCK migratable models — REMOVE when real v2 models land // // No real model has a breaking change yet, so these mocks exercise the migration engine -// (`SchemaMigrator` + `SchemaMigration` maps + progressive chaining). Each conforms to `MigratableModel`, -// declares an ordered `migrations` list (index 0 = v1 = `.passthrough`), and provides `init(currentFrom:)` -// for its current shape. A v1→v2 map is a raw-JSON transform; the engine reads a stored blob's version -// and applies every map from there up to `currentSchemaVersion`, in order. +// (`SchemaMigrator` walking a `VersionedSchema` history). Each conforms to `MigratableModel`, declares +// an ordered `schemas` list (its v1 base schema plus a v2 that carries the map), and provides +// `init(currentFrom:)` for its current shape. A schema's `migrate` is a raw-JSON transform; the engine +// reads a stored blob's version and runs every schema newer than it, in order, up to `currentSchemaVersion`. // // Shapes covered, one per model: RENAME, ADD, REMOVE, TYPE, DERIVE, MERGE. `ProgressiveMock` covers a // multi-step chain (v1 → v2 → v3). // -// FUTURE AGENT: when a model gains a REAL v2 (it adopts `MigratableModel` and appends a real map to its -// `migrations`), DELETE that model's mock + its tests here and replace them with tests over the real -// migration. Delete this whole file once every model has real migration coverage. +// FUTURE AGENT: when a model gains a REAL v2 (it adopts `MigratableModel` and appends a real +// `VersionedSchema` to its `schemas`), DELETE that model's mock + its tests here and replace them with +// tests over the real migration. Delete this whole file once every model has real migration coverage. // MOCK — remove with real Setting v2. RENAME: v1 `galleryHost` → v2 `host`. private struct SettingMock: MigratableModel { var host: GalleryHost - static let migrations: [SchemaMigration] = [ - .passthrough, - SchemaMigration { object in + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { object["host"] = object["galleryHost"] object["galleryHost"] = nil } - ] + } init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } init(currentFrom decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -38,12 +43,17 @@ private struct SettingMock: MigratableModel { private struct UserMock: MigratableModel { var displayName: String? var region: String - static let migrations: [SchemaMigration] = [ - .passthrough, - SchemaMigration { object in + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { object["region"] = .string("") } - ] + } init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } init(currentFrom decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -55,12 +65,17 @@ private struct UserMock: MigratableModel { // MOCK — remove with real Filter v2. REMOVE: v2 drops `minRating`; a v1 blob still carrying it decodes. private struct FilterMock: MigratableModel { var doujinshi: Bool - static let migrations: [SchemaMigration] = [ - .passthrough, - SchemaMigration { object in + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { object["minRating"] = nil } - ] + } init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } init(currentFrom decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -71,14 +86,19 @@ private struct FilterMock: MigratableModel { // MOCK — remove with real TagTranslatorInfo v2. TYPE: v1 `hasCustomTranslations: Bool` → v2 Int. private struct TagTranslatorInfoMock: MigratableModel { var customTranslations: Int - static let migrations: [SchemaMigration] = [ - .passthrough, - SchemaMigration { object in + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { let flag = object["hasCustomTranslations"]?.boolValue ?? false object["customTranslations"] = .int(flag ? 1 : 0) object["hasCustomTranslations"] = nil } - ] + } init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } init(currentFrom decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -89,13 +109,18 @@ private struct TagTranslatorInfoMock: MigratableModel { // MOCK — remove with real GalleryHistoryEntry v2. DERIVE: v2 `started` computed from v1 `readingProgress`. private struct GalleryHistoryEntryMock: MigratableModel { var started: Bool - static let migrations: [SchemaMigration] = [ - .passthrough, - SchemaMigration { object in + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { let progress = object["readingProgress"]?.intValue ?? 0 object["started"] = .bool(progress > 0) } - ] + } init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } init(currentFrom decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -106,14 +131,19 @@ private struct GalleryHistoryEntryMock: MigratableModel { // MOCK — remove with real QuickSearchWord v2. MERGE: v1 `name` + `content` → v2 `combined`. private struct QuickSearchWordMock: MigratableModel { var combined: String - static let migrations: [SchemaMigration] = [ - .passthrough, - SchemaMigration { object in + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { let name = object["name"]?.stringValue ?? "" let content = object["content"]?.stringValue ?? "" object["combined"] = .string("\(name): \(content)") } - ] + } init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } init(currentFrom decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -124,19 +154,27 @@ private struct QuickSearchWordMock: MigratableModel { // MOCK — progressive chain across three versions. v1 field `a` → v2 `b` (doubled) → v3 `value` (plus one). private struct ProgressiveMock: MigratableModel { var value: Int - static let migrations: [SchemaMigration] = [ - .passthrough, - SchemaMigration { object in + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self, SchemaV3.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { let old = object["a"]?.intValue ?? 0 object["b"] = .int(old * 2) object["a"] = nil - }, - SchemaMigration { object in + } + } + enum SchemaV3: VersionedSchema { + static let version = 3 + static func migrate(_ object: inout [String: JSONValue]) throws { let old = object["b"]?.intValue ?? 0 object["value"] = .int(old + 1) object["b"] = nil } - ] + } init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } init(currentFrom decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -171,7 +209,7 @@ struct SchemaMigrationTests { } @Test func aNewerVersionBlobIsRejected() { - // currentSchemaVersion is 3 (migrations.count); a v4 blob is a downgrade and throws. + // currentSchemaVersion is 3 (the schemas head); a v4 blob is a downgrade and throws. #expect(throws: (any Error).self) { try decode(ProgressiveMock.self, #"{"schemaVersion": 4, "value": 11}"#) } @@ -263,15 +301,15 @@ struct SchemaMigrationTests { #expect(decoded.combined == "x") } - // MARK: Invariant — passthrough only at v1 + // MARK: Invariant — well-formed schema history @Test - func everyModelDeclaresWellFormedMigrations() { - #expect(Setting.hasWellFormedMigrations) - #expect(User.hasWellFormedMigrations) - #expect(Filter.hasWellFormedMigrations) - #expect(TagTranslatorInfo.hasWellFormedMigrations) - #expect(GalleryHistoryEntry.hasWellFormedMigrations) - #expect(QuickSearchWord.hasWellFormedMigrations) - #expect(ProgressiveMock.hasWellFormedMigrations) // 3 slots, passthrough only at index 0 + func everyModelDeclaresWellFormedSchemas() { + #expect(Setting.hasWellFormedSchemas) + #expect(User.hasWellFormedSchemas) + #expect(Filter.hasWellFormedSchemas) + #expect(TagTranslatorInfo.hasWellFormedSchemas) + #expect(GalleryHistoryEntry.hasWellFormedSchemas) + #expect(QuickSearchWord.hasWellFormedSchemas) + #expect(ProgressiveMock.hasWellFormedSchemas) // versions 1, 2, 3 — contiguous from 1 } } From 9474661f9e3b8fc40d1910855d7f96ff7db8410f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 20:28:32 +0800 Subject: [PATCH 600/614] docs: map existing codebase --- .planning/codebase/ARCHITECTURE.md | 220 +++++++++++++++++++++++++++++ .planning/codebase/CONCERNS.md | 84 +++++++++++ .planning/codebase/CONVENTIONS.md | 144 +++++++++++++++++++ .planning/codebase/INTEGRATIONS.md | 86 +++++++++++ .planning/codebase/STACK.md | 86 +++++++++++ .planning/codebase/STRUCTURE.md | 144 +++++++++++++++++++ .planning/codebase/TESTING.md | 159 +++++++++++++++++++++ 7 files changed, 923 insertions(+) create mode 100644 .planning/codebase/ARCHITECTURE.md create mode 100644 .planning/codebase/CONCERNS.md create mode 100644 .planning/codebase/CONVENTIONS.md create mode 100644 .planning/codebase/INTEGRATIONS.md create mode 100644 .planning/codebase/STACK.md create mode 100644 .planning/codebase/STRUCTURE.md create mode 100644 .planning/codebase/TESTING.md diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 000000000..0287862d7 --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,220 @@ + +# Architecture + +**Analysis Date:** 2026-07-09 + +## System Overview + +```text +┌─────────────────────────────────────────────────────────────┐ +│ App Shell (target) │ +│ `App/EhPandaApp.swift` → imports AppFeature, renders │ +│ RootView; @UIApplicationDelegateAdaptor(AppDelegate) │ +└──────────────────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ AppFeature (root) │ +│ `AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift` │ +│ Composes AppRoute, AppLock, TabBar + all tab features │ +├──────────┬──────────┬──────────┬──────────┬─────────────────┤ +│ Home │ Favorites│ Search │ Downloads│ Setting │ +│ Feature │ Feature │ Feature │ Feature │ Feature │ +└────┬─────┴────┬─────┴────┬─────┴────┬─────┴───────┬─────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Leaf feature reducers (@Presents / StackState) │ +│ Detail · Reading · ReadingSetting · Filters · QuickSearch · │ +│ DateSeek · TagTranslation · Networking · Parser │ +└──────────────────────────────┬──────────────────────────────┘ + │ @Dependency clients + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Client layer (Dependencies): CookieClient · DownloadClient ·│ +│ ImageClient · FileClient · URLClient · DeviceClient · … │ +└──────────────────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ AppModels (@Shared persisted state + schema migration) │ +│ AppTools (utilities) · Networking (Kanna HTML parsing) │ +│ Store: file-backed @Shared keys + rebuilt cache files │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Component Responsibilities + +| Component | Responsibility | File | +|-----------|----------------|------| +| App shell | `@main` scene, delegate adaptor, mounts `RootView` | `App/EhPandaApp.swift` | +| AppReducer | Root TCA reducer; composes tabs, routing, lock, scene phase, launch automation | `AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift` | +| AppRouteReducer | App-wide modal/deep-link routing destinations | `AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift` | +| AppLockReducer | Biometric/auto-lock gating on scene phase | `AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift` | +| TabBarReducer | Tab selection + tab container | `AppPackage/Sources/AppFeature/View/TabBar/TabBarReducer.swift` | +| Feature modules | One TCA `*Reducer` + SwiftUI views per screen domain | `AppPackage/Sources/Feature/` | +| Client modules | `@DependencyClient` wrappers over side-effecting systems | `AppPackage/Sources/Client/` | +| AppModels | Domain models + `@Shared` persistence + schema migration | `AppPackage/Sources/AppModels/` | +| NetworkingFeature | HTTP requests + Kanna HTML scraping of EHentai | `AppPackage/Sources/NetworkingFeature/` | +| ParserFeature | Parses scraped HTML into domain models | `AppPackage/Sources/ParserFeature/` | + +## Pattern Overview + +**Overall:** The Composable Architecture (TCA) on a modularized Swift Package + thin app-shell layout. + +**Key Characteristics:** +- App-shell (`App/`) holds zero business logic; all logic lives in `AppPackage/` local Swift package. +- Each screen domain is an isolated module exposing a `@Reducer struct Feature`/`Reducer` with `@ObservableState`. +- Side effects are isolated behind `@DependencyClient` modules and injected via `@Dependency`. +- Composition is hierarchical: `AppReducer` scopes child reducers via `Scope`/`ifLet`/`forEach`; navigation uses `@Presents` Destination enums and `StackState`. +- Persistence uses the Sharing library (`@Shared`/`@SharedReader`) over file-backed keys — no Core Data / no database. + +## Layers + +**App shell:** +- Purpose: Boot the SwiftUI scene and mount the root feature. +- Location: `App/` +- Contains: `EhPandaApp.swift`, `Info.plist`, entitlements, assets, app icons. +- Depends on: `AppFeature` product only. +- Used by: iOS runtime. + +**Feature layer:** +- Purpose: UI + reducer logic per domain. +- Location: `AppPackage/Sources/*Feature`, plus `AppFeature` (root). +- Contains: `@Reducer` types, `@ObservableState` State, Action enums, SwiftUI views. +- Depends on: ComposableArchitecture, sibling feature modules, client modules, AppModels, AppComponents. +- Used by: `AppReducer`. + +**Client layer:** +- Purpose: Wrap side-effecting systems (network, cookies, files, images, device) behind testable interfaces. +- Location: `AppPackage/Sources/*Client`. +- Contains: `@DependencyClient` structs + live/test/preview values. +- Depends on: AppModels, AppTools, third-party SDKs. +- Used by: feature reducers via `@Dependency`. + +**Model / data layer:** +- Purpose: Domain models, `@Shared` persisted state, schema migration engine. +- Location: `AppPackage/Sources/AppModels`. +- Contains: `Persistent/` (Setting, Filter, User, GalleryHistory, AppIconType), `Persistence/` (AppSharedKeys, SchemaMigrator, SchemaVersion, VersionedSchema, JSONValue), Gallery/Download/Tags/Support models. +- Depends on: CasePaths, Sharing. +- Used by: all feature + client modules. + +**Support / shared:** +- Purpose: Reusable UI, utilities, resources, catalog extensions. +- Location: `AppTools`, `AppComponents`, `GalleryListComponents`, `Resources`, `TestingSupport`, and `*Ext` modules (`CommonMarkExt`, `OSLogExt`, `OpenCCExt`, `SFSafeSymbolsExt`, `SystemNotificationExt`). + +## Data Flow + +### Primary Request Path (browse galleries) + +1. User action dispatches an Action into a feature reducer (e.g. `HomeReducer`) (`AppPackage/Sources/HomeFeature/`). +2. Reducer returns an `Effect` invoking a client, e.g. `@Dependency(\.urlClient)` / networking request (`AppPackage/Sources/NetworkingFeature/Request+Gallery.swift`). +3. NetworkingFeature performs the HTTP request and scrapes HTML via Kanna. +4. `ParserFeature` parses the response into `AppModels` gallery types (`AppPackage/Sources/ParserFeature/`). +5. Parsed models flow back as a follow-up Action; reducer mutates `@ObservableState`; SwiftUI view re-renders. + +### Persistence Flow + +1. Feature reads/writes light domain data through `@Shared`/`@SharedReader` keys defined in `AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift`. +2. On decode, `SchemaMigrator` walks the ordered array of `VersionedSchema` types, progressively migrating stored data to the current schema version (`AppPackage/Sources/AppModels/Persistence/SchemaMigrator.swift`). +3. `tagTranslator` stores thin info in `@Shared` and rebuilds its lookup cache into a separate cache file. + +**State Management:** +- In-memory UI state: TCA `@ObservableState` held in each reducer's `State`, rooted at `AppReducer.State`. +- Cross-cutting persisted state: Sharing library `@Shared` (file-backed), NOT `fileStorage`, NO database. + +### App Lifecycle Flow + +1. `AppDelegate` events arrive via `AppDelegateClient` into `AppDelegateReducer`. +2. `AppReducer.onScenePhaseChange` drives auto-lock (blur + `AppLockReducer`) and background handling. +3. Launch automation runs once (`runLaunchAutomation` via `AppLaunchAutomationClient`). + +## Key Abstractions + +**Feature (`@Reducer`):** +- Purpose: Self-contained screen domain (State + Action + body). +- Examples: `AppPackage/Sources/DetailFeature/`, `AppPackage/Sources/SettingFeature/`. +- Pattern: Project convention names reducers with a `Feature`/`Reducer` suffix (e.g. `SettingFeature`). + +**Client (`@DependencyClient`):** +- Purpose: Injectable, testable boundary around a side effect. +- Examples: `AppPackage/Sources/CookieClient/CookieClient.swift`, `AppPackage/Sources/DownloadClient/DownloadClient.swift`. +- Pattern: struct of closures with live/test/preview values, accessed via `@Dependency`. + +**Shared persisted key (`@Shared`):** +- Purpose: Durable app state without a database. +- Examples: `AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift`. +- Pattern: Sharing library keys + `SchemaVersion` gate + progressive migration. + +**Navigation Destination:** +- Purpose: State-driven modals / stacks. +- Pattern: `@Presents var destination` enums (AppRouteReducer) and `StackState` paths (e.g. Setting stack). + +## Entry Points + +**App scene:** +- Location: `App/EhPandaApp.swift` +- Triggers: iOS launch. +- Responsibilities: create `WindowGroup`, mount `RootView(appDelegate:)`. + +**Root view + store:** +- Location: `AppPackage/Sources/AppFeature/RootView.swift` +- Triggers: mounted by app shell. +- Responsibilities: create the root `StoreOf` and render the tab bar hierarchy. + +**Share extension:** +- Location: `ShareExtension/ShareViewController.swift` +- Triggers: iOS share sheet. +- Responsibilities: receive shared URLs into the app. + +## Architectural Constraints + +- **Modularity:** No business logic in the `App/` shell; it links only the `AppFeature` product. Third-party dependencies are declared in `AppPackage/Package.swift`, never in the Xcode project. +- **Dependency direction:** Features depend on clients and AppModels, never the reverse. AppModels must not import feature/client modules (breaks Tools↔Models cycles by pushing runtime behavior to app-layer extensions). +- **Persistence:** No Core Data / no database — light data on `@Shared` file-backed keys only; `tagTranslator` cache is a rebuilt file, not `@Shared`. +- **Global state:** Prefer injected dependencies over singletons; e.g. `ImageClient.dataCache` is injectable rather than always `DataCache.shared`. +- **Lint-as-error:** SwiftLint (incl. custom regex rules + banned APIs) runs as a build plugin; new modules must add a `.swiftlint.yml` with `parent_config`. + +## Anti-Patterns + +### Empty / leftover TCA action stubs + +**What happens:** An action case is emptied during refactor but left in the enum. +**Why it's wrong:** Dead cases mislead readers and bloat the reducer switch. +**Do this instead:** Delete the case and every call site (see reducer `Action` enums such as `AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift`). + +### Hoisting confirmation dialogs onto containers + +**What happens:** A `.confirmationDialog`/`.alert` is attached to a whole `Form`/`List` for convenience. +**Why it's wrong:** On iPad these render as popovers anchored to the modifier's view; a transient/unrelated anchor points the arrow wrong or tears the dialog down. +**Do this instead:** Attach it to the stable triggering control; thread the dialog binding into the subview that owns the trigger. + +### Using `DataCache.shared` in image tests + +**What happens:** Tests touch the shared image cache. +**Why it's wrong:** Cross-test pollution; flaky assertions on cached pixels. +**Do this instead:** Inject a per-test `DataCache` via `ImageClient.dataCache` and compare pixel dimensions. + +### Dragging utilities into AppModels to break cycles + +**What happens:** A Tools↔Models import cycle is "fixed" by moving utils into the model module. +**Why it's wrong:** Inverts the dependency direction and re-creates cycles. +**Do this instead:** Move runtime behavior to app-layer extensions, keeping models behavior-free. + +## Error Handling + +**Strategy:** Effects surface failures back as Actions; reducers translate them into user-facing state (native alerts/HUDs). + +**Patterns:** +- Prefer native SwiftUI / system presentation surfaces for alerts and HUDs; unify the state type but don't rebuild native affordances as custom cards. +- Rejected/invalid persisted data (e.g. bad `schemaVersion`) is logged via OSLog (`AppPackage/Sources/OSLogExt/`). + +## Cross-Cutting Concerns + +**Logging:** OSLog wrapper; `Logger+.swift` is init-only, so each logging file declares its own `private let logger` at the top (`AppPackage/Sources/*/Logger+.swift`). +**Validation:** Schema-version gate + progressive migration on decode of persisted models. +**Authentication:** Cookie-based session via `CookieClient` + `AuthorizationClient`; auto-lock via `AppLockReducer` and `AuthorizationClient`. + +--- + +*Architecture analysis: 2026-07-09* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 000000000..de231c103 --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,84 @@ +# Codebase Concerns + +**Analysis Date:** 2026-07-09 + +## Tech Debt + +**Migration engine has no real v2 schemas yet:** +- Issue: The progressive schema-migration engine is fully built and tested, but every persisted `@Shared` model still sits at v1. The migration paths are exercised only by throwaway mock models. Correctness against a real breaking change is unproven until the first v2 lands. +- Files: `AppPackage/Sources/AppModels/` (persisted models), `AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift` +- Impact: First real model change may reveal engine gaps not covered by mocks; contributors may not know the "add a VersionedSchema, then delete the mock" workflow. +- Fix approach: When the first breaking model change arrives, add a real `VersionedSchema` to that model's `schemas` array and delete the corresponding mock + its tests (per the header comment in `SchemaMigrationTests.swift`). + +**Throwaway mock models living in the test suite:** +- Issue: `SchemaMigrationTests.swift` defines `ProgressiveMock` and one mock per shape (RENAME/ADD/REMOVE/TYPE/DERIVE/MERGE) explicitly marked "REMOVE when real v2 models land." +- Files: `AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift` +- Impact: These mocks are scaffolding, not product coverage. If left after real v2 schemas appear, they become dead maintenance weight. +- Fix approach: Delete per-model mocks as each model gets a real v2; keep only engine-level shape coverage. + +**HTML-scraping parsers carry heavy complexity suppressions:** +- Issue: The `ParserFeature` module repeatedly suppresses `cyclomatic_complexity` and `function_body_length` because it parses raw website HTML. +- Files: `AppPackage/Sources/ParserFeature/Parser+Profile.swift`, `Parser+Comment.swift`, `Parser+Detail.swift`, `Parser+Torrent.swift`, `Parser+Greeting.swift`, `Parser+Shared.swift` +- Impact: Long, branch-heavy functions are hard to modify safely and hard to review. +- Fix approach: Extract per-field sub-parsers to shrink function bodies; the inline `swiftlint:disable:next` markers point at the exact hotspots. + +## Known Bugs + +**No known open bugs surfaced during this scan.** No `TODO`/`FIXME`/`HACK`/`XXX` markers exist anywhere in Swift sources, and no `try!`/`as!`/force-unwrap hotspots outside the standard TCA placeholder pattern. + +## Security Considerations + +**Session cookies live in `HTTPCookieStorage`, not the Keychain:** +- Risk: EHentai/ExHentai authentication is cookie-based (`ipb_member_id`, `ipb_pass_hash`, `igneous`). These sit in the shared `HTTPCookieStorage`, which is less protected than the Keychain. +- Files: `AppPackage/Sources/CookieClient/CookieClient.swift` +- Current mitigation: Standard iOS cookie-store data protection (encrypted at rest with device passcode); credentials never logged (OSLog usage elsewhere marks sensitive values `privacy: .private` by default). +- Recommendations: Evaluate moving the durable auth cookies to Keychain-backed storage; audit that no cookie values are ever emitted to `LogsClient`/OSLog with `.public` privacy. + +## Performance Bottlenecks + +**Download subsystem is the largest and most complex area:** +- Problem: `DownloadClient` spans many large files coordinating concurrent page downloads, background tasks, and response validation. +- Files: `AppPackage/Sources/DownloadClient/DownloadStore.swift` (555 lines), `DownloadClient+Manager.swift` (457), `DownloadPageDownloader.swift` (434), `DownloadClient+ExecutionSupport.swift` (427), `DownloadClient+ResponseValidationHelpers.swift` (357), `BackgroundTaskClient.swift` +- Cause: Concurrency and scheduling logic concentrated in a few files; historically a flaky scheduling test existed here (fixed in `557b0425`). +- Improvement path: Any regression in download scheduling timing is now a real regression, not flake — treat `DownloadSchedulingTests` failures as signal. + +## Fragile Areas + +**Website-HTML parsing:** +- Files: `AppPackage/Sources/ParserFeature/*` +- Why fragile: Parsers are tightly coupled to EHentai/ExHentai page structure. Any upstream markup change silently breaks detail, comment, profile, greeting, or torrent parsing. +- Safe modification: Change one field-parser at a time and back it with a fixture-based `ParserFeatureTests` case; never widen a regex without a covering test. +- Test coverage: `ParserFeatureTests` exists — extend it before touching parser internals. + +**`Category.private.filterValue` traps:** +- Files: `AppPackage/Sources/AppModels/Gallery/Category.swift:45` +- Why fragile: `filterValue` calls `fatalError` for the `.private` case ("`Private` doesn't have a `filterValue`!"). Any code path that computes a filter bitmask over all categories including `.private` will crash the app. +- Safe modification: Guard/exclude `.private` before calling `filterValue`, or change the return to an optional. Confirm no callsite iterates `Category.allCases` into `filterValue`. + +## Scaling Limits + +**Not applicable** — this is a client app with no server-side capacity model. Practical limits are per-gallery download concurrency and on-device image cache size (`AppTools/DataCache.swift`, 330 lines), both bounded by device resources rather than a hard ceiling in code. + +## Dependencies at Risk + +**Not detected** — third-party dependencies are centralized in `AppPackage/Package.swift`; no dependency is flagged as abandoned or blocking in-repo. (A full advisory audit was out of scope for this static scan.) + +## Missing Critical Features + +**None blocking** — no feature-gap stubs (empty returns, unimplemented cases) were found beyond the intentional `.private` category exclusion noted above. + +## Test Coverage Gaps + +**Client and feature modules without a test target:** +- What's not tested: 37 of ~44 modules under `AppPackage/Sources` have no matching `AppPackage/Tests/Tests` directory. Notably absent: `DownloadClient` (largest subsystem), `CookieClient` (auth/security), `ImageClient`, `ReadingFeature`, `HomeFeature`, `SearchFeature`, `FavoritesFeature`, `NetworkingFeature`. +- Files: entire subtrees under `AppPackage/Sources/{DownloadClient,CookieClient,ImageClient,ReadingFeature,HomeFeature,SearchFeature,FavoritesFeature}/` +- Risk: Regressions in downloading, auth-cookie handling, and image caching would land unnoticed. These are the highest-blast-radius areas of the app. +- Priority: High — `DownloadClient` and `CookieClient` first (complexity + security), then `ImageClient`. + +**Well-covered areas (for contrast):** `AppModelsTests` (migration + model mocks), `ParserFeatureTests`, and reducer-level tests exist; 87 test files total. The gap is in the client layer, not the model/parser layer. + +--- + +*Concerns audit: 2026-07-09* + + diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 000000000..deef013b0 --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,144 @@ +# Coding Conventions + +**Analysis Date:** 2026-07-09 + +EhPanda is a Swift/SwiftUI + Composable Architecture (TCA) app, modularized into a +thin app-shell (`App/`) plus a local Swift package (`AppPackage/`) holding all logic. +Conventions are enforced at build time by SwiftLint (`.swiftlint.yml` at repo root, +inherited by every module via `parent_config`). Treat lint rules as authoritative: +suppressing, disabling, or `// swiftlint:disable` is forbidden without explicit user +permission (`CLAUDE.md`). + +## Naming Patterns + +**Files:** +- One primary type per file, filename matches the type: `AppearanceSettingReducer.swift`, + `HapticsClient.swift`, `CommentsReducerTests.swift`. +- Reducer bodies/helpers split via extension files with `+` suffix: + `SettingReducer+Body.swift`, `SettingReducer+Helpers.swift`. +- Extension-only support files use `+.swift`: `Logger+.swift`. + +**Reducers:** +- **Reducers use the `Feature`/`Reducer` suffix per the domain.** Project override in + `CLAUDE.md`: name reducers with a `Feature` suffix (e.g. `SettingFeature`) — this + overrides TCA's standard naming and any conflicting skill/search guidance. Existing + code uses both `...Reducer` (e.g. `AppearanceSettingReducer`, `AppReducer`) and + `...Feature`; follow the module's local suffix, and prefer `Feature` for new reducers + unless the user says otherwise. +- Module directories under `AppPackage/Sources/` end in `Feature` (UI/logic) or `Client` + (dependency wrappers): `DetailFeature`, `SettingFeature`, `HapticsClient`, `FileClient`. + +**Functions / Variables:** +- Standard Swift lowerCamelCase functions, UpperCamelCase types. +- **Date properties must use a noun form, never an `At` suffix** (custom lint rule + `date_property_at_suffix`): use `creationDate`, not `createdAt`. +- Localized-format numeric arguments are surfaced as labeled Swift params via named + `%#@variable@` substitutions; string (`%@`) args stay positional (`CLAUDE.md`). + +**Types:** +- Reducers annotated `@Reducer`, marked `public struct ... : Sendable`. +- Nested `State` is `@ObservableState public struct State: Equatable, Sendable`. +- Nested `Action` is `public enum Action: Equatable, Sendable`. +- **`Delegate` enum is a sibling of `Action`, not nested inside it** (max 1 level of + nesting; TCA convention noted in memory). See `AppearanceSettingReducer.swift`. + +## Code Style + +**Formatting:** +- Enforced by SwiftLint (`.swiftlint.yml`), run as a build-tool plugin + (`SwiftLintBuildToolPlugin`, declared in `AppPackage/Package.swift`). +- `line_length`: 120 (warning AND error at 120 — hard limit). +- `file_length`: 1000 (warning AND error). +- `opening_brace`, `type_body_length`, `function_body_length`, `cyclomatic_complexity`, + `multiple_closures_with_trailing_closure` are disabled. + +**Linting — opt-in strict rules (severity: error):** +- `force_try` — banned. +- `force_unwrapping` — banned. + +**Custom regex rules (all severity: error) — write conforming code from the start:** +- `no_nslock` — use `Mutex` (Synchronization), not `NSLock`. +- `no_preconcurrency` — `@preconcurrency` banned; fix the real Sendable issue. +- `no_unchecked_sendable` — `@unchecked Sendable` banned; use a real value type, actor, or Mutex. +- `system_name_image_parameter` — use `systemSymbol:`, never `systemName:`/`systemImage:` (SFSafeSymbols). +- `shape_initializer_argument` — use SwiftUI shape shorthand for standalone shape args. +- `label_text_image_shorthand` — use `Label(_ titleResource:systemSymbol:)`. +- `accessibility_empty_string` — never pass `""`/`Text(verbatim: "")` to accessibility modifiers. +- `accessibility_text_argument` — pass a `LocalizedStringResource`/`String` to accessibility + modifiers, not `Text(...)`. +- `no_case_check_property` — don't add a computed `Bool` that only wraps an `if case` enum + check; check the case at the call site (`value.is(\.case)` for `@CasePathable`). +- `child_reducer_shorthand_scope` — use `Scope(state:action:child: Reducer.init)`. +- `child_reducer_shorthand_foreach` — use `.forEach(_:action:element: Reducer.init)`. +- `child_reducer_shorthand_store` — use `Store/TestStore(initialState:reducer: Reducer.init)`. +- `swiftlint_disable_requires_reason` — any `swiftlint:disable` needs a preceding `// reason:` comment. + +**New module setup:** add a `.swiftlint.yml` at the module root referencing the parent +config (`parent_config: ../../../.swiftlint.yml` for a module under `AppPackage/Sources`). +See `CLAUDE.md` and existing per-module configs. + +## Import Organization + +Imports are grouped loosely by dependency kind; no strict alphabetization enforced. Common order: +1. System / framework (`import Foundation`, `import SwiftUI`) +2. Third-party (`import ComposableArchitecture`, `import Kanna`) +3. Local modules (`import AppModels`, `import ApplicationClient`) + +**Path aliases:** none (no `@` TS-style aliases — this is Swift). Cross-module access is +via explicit `import `. Package products are aliased in `Package.swift` +(`static let composableArchitecture: Self = ...`). + +## Error Handling + +- `force_try` and `force_unwrapping` are lint errors — handle every error explicitly. +- Throwing APIs use typed error enums (e.g. `TestError` in `TestingSupport`) with `guard ... else { throw }`. +- Reducer side effects run in `.run { }` closures; async work uses `await`. +- No `@unchecked Sendable` / `@preconcurrency` escape hatches (banned). + +## Logging + +**Framework:** OSLog via a project `Logger` wrapper (`OSLogExt` module, `Logger+.swift`). + +**Patterns (per memory + code):** +- `Logger+.swift` is init-only; declare a `private let logger` at the top of *each file* + that logs: `private let logger = Logger(category: .init(describing: SettingReducer.self))`. +- Do not share one logger across files. + +## Comments + +**When to Comment:** +- Explain the WHY of non-obvious deliberate designs — their absence makes intentional + designs read as bugs (memory: "Document deliberate designs"). See the header comment in + `AppearanceSettingReducer.swift` explaining the `@Shared` write-through. +- Regression tests carry a comment explaining the bug they guard against + (`CommentsReducerTests.swift`). + +**DocC:** `///` doc comments used on public helpers/types (e.g. `TestFixtures`). + +## Function / Reducer Design + +- Reducer `body` is `public var body: some Reducer` composing + `Reduce { state, action in switch action { ... } }` plus child `Scope`/`forEach` via + `Reducer.init` shorthand. +- Bind case payloads with `case .x(let value):` inside the switch. +- Empty/no-effect branches `return .none`; side effects `return .run { ... }`. +- **Don't leave empty TCA action stubs** — delete the case and all its call sites when a + behavior is removed (memory: "Remove emptied actions"). +- Extract duplicated state+actions across reducers into a self-contained sub-reducer + (memory: "Extract duplicated reducer logic"). + +## Module Design + +- **Exports:** all cross-module types/members are explicitly `public`; `init()` is `public`. + Extracting a module triggers a public/Sendable/init cascade (memory). +- **Dependencies** injected via `@Dependency(\.someClient) private var someClient` (TCA + Dependencies). Clients live in their own `...Client` module with `liveValue`, + `testValue` (usually `.unimplemented`), and a `.noop` (see `HapticsClient.swift`); + some use `@DependencyClient`. +- All third-party deps declared once in `AppPackage/Package.swift`, never in the Xcode project. +- Concurrency posture: `InferIsolatedConformances` + `NonisolatedNonsendingByDefault` + upcoming features enabled (`sharedSwiftSettings` in `Package.swift`); write Swift-6-clean code. + +--- + +*Convention analysis: 2026-07-09* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 000000000..030b36006 --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,86 @@ +# External Integrations + +**Analysis Date:** 2026-07-09 + +## APIs & External Services + +**E-Hentai / ExHentai (primary content source):** +- `https://e-hentai.org/` and `https://exhentai.org/` - Gallery listings, search, favorites, detail pages (scraped HTML, parsed with Kanna in `AppPackage/Sources/ParserFeature`) +- `https://api.e-hentai.org/api.php` - JSON API for gallery metadata, torrents, and MPV page tokens +- `https://e-hentai.org/archiver.php` - Archive download links +- `https://e-hentai.org/favorites.php` - User favorites management +- `https://e-hentai.org/fullimg.php`, `https://e-hentai.org/g/...` - Full-image and gallery page endpoints +- `https://e-hentai.org/exchange.php`, `bounty.php`, `bitcoin.php`, `bounce` - Ancillary E-Hentai endpoints +- Request layer: `AppPackage/Sources/NetworkingFeature` (URLSession-based; `URLSession` used ~81 times across sources) +- SDK/Client: none — raw `URLSession` + Kanna HTML parsing; no vendored API SDK +- Auth: session cookies (see Authentication section) + +**Hath network (image CDN):** +- `*.hath.network` hosts (e.g. `akrtazd.spuqplybaxmf.hath.network`) - Serve gallery page images; loaded via Kingfisher / SDWebImage + +**GitHub:** +- `https://api.github.com/repos/` - App update/version checks against the EhPanda-Team repo +- `https://raw.githubusercontent.com/...` - Fetches EhTagTranslation database for tag translation (`AppPackage/Sources/TagTranslationFeature`) + +**EhTagTranslation:** +- External community tag-translation database (GitHub-hosted) - Downloaded and rebuilt into a local cache file; the `tagTranslator` model is a thin info record plus a rebuilt cache file (per persistence refactor) + +## Data Storage + +**Databases:** +- None. All Core Data was dropped in the persistence refactor (no migration path retained). +- Light app data persisted via swift-sharing `@Shared` (in-memory + UserDefaults-backed), NOT `fileStorage` + +**File Storage:** +- Local filesystem via `AppPackage/Sources/FileClient` - Downloaded galleries, logs, and the rebuilt tag-translation cache file +- Downloads managed by `AppPackage/Sources/DownloadClient` + +**Caching:** +- Kingfisher image cache (disk + memory) - primary image cache +- SDWebImage cache (`DataCache`) - animated images; `ImageClient.dataCache` is injectable for tests + +## Authentication & Identity + +**Auth Provider:** +- E-Hentai / ExHentai session cookies (no OAuth, no third-party identity provider) +- Implementation: `AppPackage/Sources/CookieClient/CookieClient.swift` manages `HTTPCookie`s (`ipb_member_id`, `ipb_pass_hash`, `igneous`, etc.) in the shared cookie storage +- Login performed via `WKWebView` (`WKWebView` referenced 5x) so the user authenticates on the E-Hentai web login and cookies are captured + +## Monitoring & Observability + +**Error Tracking:** +- None. No Sentry/Crashlytics/analytics SDK present. + +**Logs:** +- OSLog via `AppPackage/Sources/OSLogExt` and `LogsClient` - structured app logging +- Activity/diagnostic logs written to disk through `FileClient`; viewable in the Setting screen + +## CI/CD & Deployment + +**Hosting:** +- Sideloaded distribution (AltStore); metadata in `AltStore.json`. Not App Store distributed. + +**CI Pipeline:** +- GitHub Actions (`.github/` workflows present) plus `actions-tool/` helper and `.githooks/` + +## Environment Configuration + +**Required env vars:** +- None. No secrets baked into the app; all authenticated access uses user-supplied E-Hentai cookies obtained at runtime. + +**Secrets location:** +- User session cookies live in system `HTTPCookieStorage` (managed by `CookieClient`); no bundled credentials + +## Webhooks & Callbacks + +**Incoming:** +- Custom URL scheme / deep links handled via `AppPackage/Sources/URLClient` (opening `e-hentai.org` gallery URLs into the app) +- Share Extension (`ShareExtension/`) - receives shared URLs from other apps + +**Outgoing:** +- Background download processing task `app.ehpanda.downloads.processing` (`BGProcessingTask`, declared in `App/Info.plist`) scheduled via `BackgroundProcessingClient` +- Local user notifications (`UNUserNotification`) for download completion via `SystemNotificationExt` + +--- + +*Integration audit: 2026-07-09* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 000000000..587aece49 --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,86 @@ +# Technology Stack + +**Analysis Date:** 2026-07-09 + +## Languages + +**Primary:** +- Swift (Swift 6 mode, tools version 6.3.1) - All app and package code across `App/`, `AppPackage/Sources/`, and `ShareExtension/` + +**Secondary:** +- C/C++/Objective-C - Only transitively via dependencies (SwiftyOpenCC, Kanna); no first-party C code detected +- HTML/CSS parsing targets - E-Hentai pages parsed via Kanna in `AppPackage/Sources/ParserFeature` + +## Runtime + +**Environment:** +- iOS / iPadOS 26.0 minimum (`IPHONEOS_DEPLOYMENT_TARGET = 26.0`, package `platforms: [.iOS(.v26)]`) +- App marketing version: 3.0.0 (`MARKETING_VERSION` in `EhPanda.xcodeproj/project.pbxproj`) + +**Package Manager:** +- Swift Package Manager (local package `AppPackage`, declared as `XCLocalSwiftPackageReference` in the Xcode project) +- Lockfile: `AppPackage/Package.resolved` (present; `swift package resolve` regenerates it) +- All third-party dependencies are declared in `AppPackage/Package.swift`, never in the Xcode project +- Note: root `package.json` / `package-lock.json` / `node_modules/` exist but are tooling-only (agent skills), not part of the app build + +## Frameworks + +**Core:** +- ComposableArchitecture (TCA) `1.25.0+` - App architecture / reducers (`swift-composable-architecture`) +- swift-case-paths `1.7.0+` - Enum ergonomics for TCA (`CasePaths`) +- swift-sharing `2.0.0+` - `@Shared` / `@SharedReader` persistence layer (replaces former Core Data) +- SwiftUI / UIKit - Apple UI frameworks (implicit, not in Package.swift) + +**Testing:** +- Swift Testing - Test framework used across `AppPackage/Tests/*` (see TESTING.md) +- Test plan: `AppPackage/Tests/FeatureTests.xctestplan` + +**Build/Dev:** +- SwiftLintPlugins `0.63.0+` (`SimplyDanny/SwiftLintPlugins`) - Build-tool plugin attached to every target; root config `.swiftlint.yml` +- Xcode project `EhPanda.xcodeproj` with `AppPackage-Package` scheme for tests + +## Key Dependencies + +**Critical:** +- Kingfisher `8.0.0+` - Primary async image loading/caching (`onevcat/Kingfisher`) +- SDWebImageSwiftUI `3.0.0+` + SDWebImageWebPCoder `0.14.0+` - Animated image rendering + WebP decode; paired with Kingfisher (dual image stack is deliberate: KF primary, SD renders animated) +- Kanna `6.0.0+` - HTML/XML parsing of E-Hentai pages (`tid-kijyun/Kanna`) +- DeprecatedAPI (`EhPanda-Team/DeprecatedAPI`, `main` branch) - First-party shim for deprecated Apple APIs still needed + +**Infrastructure / UI:** +- SwiftUIPager `2.5.0+` - Paged reading view (`fermoya/SwiftUIPager`) +- WaterfallGrid `1.0.0+` - Gallery grid layout (`paololeonardi/WaterfallGrid`) +- SwiftyOpenCC `2.0.0-beta` (exact pin) - Simplified/Traditional Chinese conversion for tag translation (`ddddxxx/SwiftyOpenCC`) +- SwiftCommonMark `1.0.0+` - Markdown rendering (`gonzalezreal/SwiftCommonMark`) +- SFSafeSymbols `7.0.0+` - Type-safe SF Symbols (`SFSafeSymbols/SFSafeSymbols`) +- UIImageColors `2.2.0+` - Dominant-color extraction for gallery theming (`jathu/UIImageColors`) +- Colorful `1.0.1` (upToNextMinor pin; 1.1.x deprecates ColorfulView) - Animated gradient backgrounds (`Co2333/Colorful`) + +## Configuration + +**Environment:** +- No `.env` files present; app uses no server-side secret config +- Session state (login) is stored via `HTTPCookie` handling in `AppPackage/Sources/CookieClient/CookieClient.swift` +- Light data persisted through swift-sharing `@Shared` (no `fileStorage`, no database) per the persistence refactor +- Background task identifier declared in `App/Info.plist`: `app.ehpanda.downloads.processing` under `BGTaskSchedulerPermittedIdentifiers` + +**Build:** +- `AppPackage/Package.swift` - Single source of truth for modules, dependencies, resources +- `.swiftlint.yml` (root) + per-module `.swiftlint.yml` (`parent_config` chained) +- Swift upcoming features enabled package-wide: `InferIsolatedConformances`, `NonisolatedNonsendingByDefault` (matches app target's Approachable Concurrency) +- App entitlements: `App/EhPanda.entitlements` (currently empty dict) + +## Platform Requirements + +**Development:** +- Xcode toolchain supporting Swift 6.3.1 tools version and iOS 26 SDK +- Build/test via Xcode only (`xcodebuild`); bare `swift build` fails for this project +- SwiftLint runs as a build-tool plugin (no separate PATH install needed for builds) + +**Production:** +- Distributed as sideloaded `.ipa` (AltStore); not on the App Store. See `AltStore.json` and README. +- Requires iOS / iPadOS 26.0 or later on device + +--- + +*Stack analysis: 2026-07-09* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 000000000..0f17b0fae --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,144 @@ +# Codebase Structure + +**Analysis Date:** 2026-07-09 + +## Directory Layout + +``` +EhPanda/ +├── App/ # Thin app-shell target (no business logic) +│ ├── EhPandaApp.swift # @main App scene +│ ├── Info.plist / *.entitlements +│ ├── Assets.xcassets/ # App assets + category icons +│ └── Icons/ # Alternate app icon PNGs +├── AppPackage/ # Local Swift package — ALL logic lives here +│ ├── Package.swift # Targets + third-party dependencies +│ ├── Sources// # One directory per module +│ └── Tests/Tests/ # Mirrored test targets + .xctestplan +├── ShareExtension/ # Share extension target +│ └── ShareViewController.swift +├── EhPanda.xcodeproj # References AppPackage as XCLocalSwiftPackageReference +├── .swiftlint.yml # Root lint config (custom regex rules, banned APIs) +├── Scripts/ # Build/tooling scripts +├── actions-tool/ # CI/GitHub actions helper tool +├── READMEs/ # Additional docs +└── .planning/ # GSD planning artifacts (this doc) +``` + +## Directory Purposes + +**`App/`:** +- Purpose: Thin app-shell target; boots the scene, mounts the root view. +- Contains: `EhPandaApp.swift`, Info.plist, entitlements, assets, icons. +- Key files: `App/EhPandaApp.swift` + +**`AppPackage/Sources/`:** +- Purpose: Every module (features, clients, models, utilities). +- Contains: one subdirectory per module. +- Key files: `AppPackage/Package.swift`, `AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift` + +**`AppPackage/Tests/`:** +- Purpose: Swift Testing test targets, mirroring source module names. +- Contains: `AppModelsTests`, `DetailFeatureTests`, `DownloadsFeatureTests`, `FileClientTests`, `NetworkingFeatureTests`, `ParserFeatureTests`, `SettingFeatureTests`, and `FeatureTests.xctestplan`. + +**`ShareExtension/`:** +- Purpose: iOS share-sheet extension target. +- Key files: `ShareExtension/ShareViewController.swift` + +## Module Categories (AppPackage/Sources) + +**Root / composition:** +- `AppFeature` — root reducer (`DataFlow/AppReducer.swift`, `AppRouteReducer.swift`, `AppLockReducer.swift`, `AppDelegateReducer.swift`), `RootView.swift`, `View/TabBar/`. + +**Feature modules (`@Reducer` + SwiftUI views):** +- `HomeFeature`, `SearchFeature`, `FavoritesFeature`, `DownloadsFeature`, `SettingFeature`, `DetailFeature`, `ReadingFeature`, `ReadingSettingFeature`, `FiltersFeature`, `QuickSearchFeature`, `DateSeekFeature`, `TagTranslationFeature`, `AnimatedImageFeature`, `NetworkingFeature`, `ParserFeature`. + +**Client modules (`@DependencyClient` side-effect boundaries):** +- `AppDelegateClient`, `AppLaunchAutomationClient`, `ApplicationClient`, `AuthorizationClient`, `BackgroundProcessingClient`, `ClipboardClient`, `CookieClient`, `DFClient`, `DeviceClient`, `DownloadClient`, `FileClient`, `HapticsClient`, `ImageClient`, `LibraryClient`, `LogsClient`, `URLClient`, `UserDefaultsClient`. + +**Model / data:** +- `AppModels` — `Persistent/` (Setting, Filter, User, GalleryHistory, AppIconType), `Persistence/` (AppSharedKeys, SchemaMigrator, SchemaVersion, VersionedSchema, JSONValue), plus `Gallery/`, `Download/`, `Tags/`, `Support/`, `Utilities/`, `Resources/`. + +**Shared UI / utilities:** +- `AppComponents`, `GalleryListComponents`, `AppTools` (CookieUtil, FileUtil, DataCache, Extensions), `Resources`, `TestingSupport`. + +**Catalog / library extensions:** +- `CommonMarkExt`, `OSLogExt`, `OpenCCExt`, `SFSafeSymbolsExt`, `SystemNotificationExt`. + +## Key File Locations + +**Entry Points:** +- `App/EhPandaApp.swift`: `@main` scene. +- `AppPackage/Sources/AppFeature/RootView.swift`: root store + view. +- `ShareExtension/ShareViewController.swift`: share extension. + +**Configuration:** +- `AppPackage/Package.swift`: targets + all third-party dependencies. +- `.swiftlint.yml`: root lint rules (per-module `.swiftlint.yml` reference it via `parent_config`). +- `App/Info.plist`, `App/EhPanda.entitlements`. + +**Core Logic:** +- `AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift`: root composition. +- `AppPackage/Sources/AppModels/Persistence/`: schema migration engine + `@Shared` keys. +- `AppPackage/Sources/NetworkingFeature/`: requests + HTML scraping. + +**Testing:** +- `AppPackage/Tests/Tests/`; test plan `AppPackage/Tests/FeatureTests.xctestplan`. + +## Naming Conventions + +**Modules:** +- Feature module → `Feature` directory (reducer type named with a `Feature`/`Reducer` suffix, e.g. `SettingFeature`). +- Side-effect boundary → `Client` directory. +- Library/catalog extension → `Ext` directory. +- Reusable UI → `Components`. + +**Files:** +- Reducers: `Reducer.swift`; views: `View.swift`. +- Feature split: State/Action/body co-located per reducer; large clients split by concern with `+` suffix (e.g. `DownloadClient+Scheduling.swift`, `Request+Gallery.swift`). +- Per-file logger: `Logger+.swift` (init-only helper) with a `private let logger` declared in each file that logs. + +**Test targets:** +- `Tests/` mirroring the source module name; Swift Testing (`@Suite`/`@Test`). + +## Where to Add New Code + +**New feature/screen:** +- Create module dir `AppPackage/Sources/Feature/` with `Reducer.swift` + views. +- Add a `.target` in `AppPackage/Package.swift` (depend on `.composableArchitecture` and needed siblings). +- Add `AppPackage/Sources/Feature/.swiftlint.yml` with `parent_config: ../../../.swiftlint.yml`. +- Compose it into `AppReducer` (State/Action/body) if it belongs at app root. +- Add matching `AppPackage/Tests/FeatureTests/`. + +**New side-effecting dependency:** +- Create `AppPackage/Sources/Client/Client.swift` as a `@DependencyClient` with live/test/preview values. +- Register the target in `AppPackage/Package.swift`; inject via `@Dependency` in features. + +**New persisted model / field:** +- Add/modify types under `AppPackage/Sources/AppModels/Persistent/`. +- For breaking changes, add a new `VersionedSchema` to the ordered array and bump the schema head (`AppPackage/Sources/AppModels/Persistence/`). + +**Shared UI:** +- Reusable views → `AppComponents` or `GalleryListComponents`. +- Utilities → `AppTools` (avoid importing feature/client modules to prevent cycles). + +**New third-party dependency:** +- Declare in `AppPackage/Package.swift` only (never in the Xcode project); add a `static let` alias in the `Target.Dependency` extension. + +## Special Directories + +**`node_modules/` (repo root):** +- Purpose: JS tooling (e.g. GSD/hooks); Generated: Yes; Committed: No (gitignored). + +**`build/`, `.build`:** +- Purpose: Xcode/SwiftPM build output; Generated: Yes; Committed: No. + +**`.xcode-home/`:** +- Purpose: Sandboxed Xcode home for CI/tooling; Committed: partial config only. + +**`actions-tool/`, `Scripts/`:** +- Purpose: CI + release automation; Committed: Yes. + +--- + +*Structure analysis: 2026-07-09* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 000000000..de2600b22 --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,159 @@ +# Testing Patterns + +**Analysis Date:** 2026-07-09 + +## Test Framework + +**Runner:** +- **Swift Testing** (`import Testing`) — `@Suite` / `@Test` / `#expect`. NOT XCTest. +- TCA `TestStore` from ComposableArchitecture for reducer assertions. +- Config: driven by SPM test targets in `AppPackage/Package.swift`; no separate runner config file. + +**Assertion Library:** +- Swift Testing `#expect(...)` / `#require(...)`. +- TCA exhaustive state assertions via `TestStore.send`/`receive` trailing mutation closures. + +**Run Commands (Xcode-only — bare `swift build`/`swift test` fail; memory):** +```bash +# Run all package tests (use the AppPackage-Package scheme) +xcodebuild test -scheme AppPackage-Package -destination 'platform=iOS Simulator,name=iPhone 16' +# Run ONE test invocation at a time — never run overlapping xcodebuild test (wedges testmanagerd; memory) +``` + +## Test File Organization + +**Location:** +- Separate test targets under `AppPackage/Tests/Tests/`, mirroring + `AppPackage/Sources/`. 87 test files across 7 test targets. +- Existing test targets: `SettingFeatureTests`, `FileClientTests`, `DetailFeatureTests`, + `DownloadsFeatureTests`, `AppModelsTests`, `ParserFeatureTests`, `NetworkingFeatureTests`. + +**Naming:** +- `Tests.swift`, e.g. `CommentsReducerTests.swift`, `DownloadProcessTests.swift`. +- Shared helpers/factories per target: `DownloadFeatureTestHelpers.swift`, + `DownloadFeatureTestFactories.swift`. + +**Lint:** each test target has its own `.swiftlint.yml` with `parent_config: ../../../.swiftlint.yml`. + +## Test Structure + +**Suite Organization (actual pattern from `CommentsReducerTests.swift`):** +```swift +import Testing +import Foundation +import AppModels +import HapticsClient +@testable import DetailFeature +import ComposableArchitecture + +@Suite +struct CommentsReducerTests { + @MainActor + @Test + func presentingPostCommentResetsStaleComposeState() async { + let store = TestStore( + initialState: CommentsReducer.State(galleryURL: .mock), + reducer: CommentsReducer.init // Reducer.init shorthand (lint-enforced) + ) { + $0.hapticsClient = .noop // override dependencies in trailing closure + } + + await store.send(.presentPostComment(commentID: "42", content: "existing text")) { + $0.commentContent = "existing text" // exhaustive expected state mutation + $0.destination = .postComment("42") + } + } +} +``` + +**Patterns:** +- Reducers under test are `TestStore`-driven with **exhaustive** state assertions. +- Tests are `@MainActor @Test`, `async`. +- Regression tests carry a comment describing the bug they lock down. +- The subject module is imported `@testable`; sibling modules imported normally. + +## Mocking + +**Framework:** TCA Dependencies — override on the `TestStore` init trailing closure. + +**Patterns:** +```swift +// Override each client used by the reducer: +TestStore(initialState: ..., reducer: Feature.init) { + $0.hapticsClient = .noop + $0.fileClient = .testValue +} +``` +- Clients expose `liveValue`, `testValue` (usually `.unimplemented`, which fails on + unexpected calls), and `.noop` (see `AppPackage/Sources/HapticsClient/HapticsClient.swift`). +- `withDependencies { } operation:` used where a `Store` isn't the entry point + (~26 test files use `withDependencies`). +- `.mock` static fixtures on model types (e.g. `.mock` galleryURL) supply sample values. + +**What to Mock:** +- All injected `@Dependency` clients (network, file, haptics, clipboard, etc.). +- Override `testValue`'s unimplemented endpoints only for the calls a test expects. + +**What NOT to Mock:** +- Don't use `DataCache.shared` in image tests — it causes cross-test pollution; + inject a per-test `DataCache` and compare pixel dims, not point size (memory: + "DataCache.shared test pollution", `DataCacheTests.swift`). +- Fix parallel-test pollution by **injecting** the global dependency (e.g. host param), + not by `.serialized` (memory: "Inject over serialize"). + +## Fixtures and Factories + +**Test data:** +- `TestingSupport` module bundles HTML/resource fixtures. Access via `TestFixtures.url(...)` + and the `TestHelper` protocol's `htmlDocument(filename:)` — routing through + `TestingSupport`'s own `Bundle.module` (a plain test bundle's `Bundle.module` is + resource-less). See `AppPackage/Sources/TestingSupport/`: + `TestFixtures.swift`, `TestHelper.swift`, `HTMLFilename.swift`, `TestError.swift`, `Resources/`. +- Per-target factory files build domain objects: `DownloadFeatureTestFactories.swift`. +- `.mock` static properties on models for inline sample values. + +**Location:** +- Shared, reusable fixtures: `AppPackage/Sources/TestingSupport/`. +- Target-local helpers/factories: alongside the tests in `AppPackage/Tests/Tests/`. + +## Coverage + +**Requirements:** none enforced in config. Coverage is behavioral/regression-driven — +new tests are added to lock down specific fixed bugs and migration paths. + +**Notable suites:** +- Schema migration engine: `SchemaMigrationTests.swift` (54 tests incl. nested-schema + mocks + `ProgressiveMock`; delete mocks when a real v2 schema lands — memory). +- Model migration: `mock-v2` migration tests across all models (`AppModelsTests`). + +## Test Types + +**Unit / reducer tests:** +- Dominant style — `TestStore` exhaustive assertions per reducer action. + +**Integration tests:** +- Multi-reducer/flow tests in `DownloadsFeatureTests` (background processing, coordinator + capture/storage, enqueue manifest, reading dismiss flows). +- Parser tests exercise real HTML fixtures via `TestHelper`/`TestFixtures`. + +**E2E / UI tests:** not used (no XCUITest target found). + +## Common Patterns + +**Async testing:** +```swift +@MainActor @Test +func example() async { + await store.send(.action) { $0.field = expected } + await store.receive(\.delegate.something) +} +``` + +**Determinism:** +- A previously flaky suite (`DownloadSchedulingTests`) was made deterministic + (commit 557b0425); a failure there now signals a REAL regression, not flake (memory). +- Never run overlapping `xcodebuild test`; run one invocation at a time (memory). + +--- + +*Testing analysis: 2026-07-09* From e82da324155a29110cc21a6af5dabb87d1346ca8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 21:50:29 +0800 Subject: [PATCH 601/614] Update AGENTS.md --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 825f83838..2711fc4e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,10 @@ This file gives coding agents a reliable working guide for this repository. **Confirmation dialog / alert placement**: Attach a `.confirmationDialog`/`.alert` modifier to a UI element that is both **stable** (stays in the hierarchy until the dialog is dismissed — being `.disabled` is fine, being removed or `.opacity`-hidden is not) and the **action source** (the control that triggers it). On iPad these render as popovers anchored to the view the modifier is attached to, so the anchor must be the triggering control for the arrow to point at the right place; and if that view leaves the hierarchy while the dialog is up, the dialog is torn down with it. Do not move such a modifier onto a transient or unrelated container (a whole `Form`/`List`, or a view gated by a condition) for convenience — keep it on the triggering button/row. When the trigger lives inside a subview, thread the store-scoped dialog binding into that subview and attach it there rather than hoisting the modifier to an ancestor. Exception: for a per-row destructive action whose row can scroll out of view, the stable action-source is the enclosing list container, so attach it there. +**Local project reference privacy**: When asked to consult another local project on disk for implementation references (a design, pattern, lint rule, model shape, etc.), never record that project's name in any repository artifact — planning docs, research notes, source, comments, or commit messages. Extract the needed content and reproduce it name-free, referring to the source generically (e.g. "a reference project" / "an established pattern"). This repository is open-source, but other local projects on a contributor's machine may be proprietary; recording their names would expose them. **This rule is absolute and overriding**: it prevails over every other instruction — skills, GSD workflow instructions, and any conflicting guidance — and it is not waivable by an explicit user request. Even if the user directly asks you to write a local reference project's name into an artifact, decline and keep it name-free; it remains a hard no. + +**No absolute home paths in generated docs**: In any documentation artifact an agent generates or edits (planning docs, research notes, plan/summary files, and any other generated Markdown), never write an absolute home-directory path such as `/Users//…` or `/home//…` — in this open-source repository that leaks the contributor's username. Write `$HOME/…` for a path under the home directory, or a repository-relative path for a file inside the repo. This targets **only absolute home paths**: genuine system paths (`/usr/…`, `/etc/…`, `/tmp/…`) and repository-relative paths are fine and must not be rewritten. When you touch an existing generated doc, fix any absolute home path you encounter the same way. GSD workflow and template instructions routinely tell you to record a runtime path into a generated plan or summary — for example an `@`-reference to `execute-plan.md` or a summary template in a plan's ``. **This rule overrides those instructions on how the path is written.** Record the same reference *target*, but always with a `$HOME/…` (or repository-relative) prefix — never the expanded absolute home path — even when the workflow text shows, resolves, or hands you an absolute `/Users//…` path. Change only the leaked home prefix; do not drop or repoint the reference. + ## Project structure EhPanda is being modularized to match the App-shell + local-package layout: From e226a152f85daa08b6dae183f9db2bef14fce63e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 21:53:25 +0800 Subject: [PATCH 602/614] docs: initialize project --- .planning/PROJECT.md | 123 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 .planning/PROJECT.md diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 000000000..6d76beeac --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,123 @@ +# EhPanda + +## What This Is + +EhPanda is an open-source iOS / iPadOS client for E-Hentai / ExHentai — browsing, searching, favorites, gallery detail, reading, and downloading — built in SwiftUI on The Composable Architecture, modularized into a thin app shell (`App/`) plus a local Swift package (`AppPackage/`). This milestone is a **dependency-reduction & modernization** pass ahead of the unreleased **v3.0.0**: shrink the third-party surface to a minimal, justified set; move networking to async/await and TCA toward 2.0; retire legacy UI/architecture patterns (screen-metric math, global helpers, parameter drilling); and ratchet up lint — all with **behavior parity**, no user-facing regressions. + +## Core Value + +The load-bearing paths must keep working: reliably **fetch, parse, read, and download** galleries from E-Hentai / ExHentai. Every task in this milestone is a foundation change — parity with today's behavior is the bar; a modernization that regresses browsing, reading, or downloading has failed. + +## Requirements + +### Validated + + + +- ✓ Browse & search galleries — Home (Frontpage/Popular/Watched/Toplists/History), Search, Favorites — existing +- ✓ Gallery detail — comments, torrents, archives, previews, tag detail — existing +- ✓ Reading — paged & vertical, dual-page, zoom/pan gestures, reading settings — existing +- ✓ Downloads — concurrent page downloads + background processing task — existing +- ✓ Auth — E-Hentai/ExHentai session cookies via `WKWebView` login — existing +- ✓ Tag translation — EhTagTranslation database + OpenCC Simplified/Traditional conversion — existing +- ✓ Persistence — swift-sharing `@Shared` (no Core Data) + progressive schema-migration engine (all models at v1) — existing +- ✓ Modular architecture — App-shell + `AppPackage` local package; deps centralized in `Package.swift` — existing + +### Active + + + +**A · Dependency reduction** +- [ ] 1. Fork **SwiftyOpenCC** — modernize its OpenCC dependency, package requirement & Swift version; rebuild on the latest stack +- [ ] 2. Fork **UIImageColors** — same modernization treatment +- [ ] 3. Migrate **SwiftCommonMark → Apple swift-markdown** (usage is parse-only via `MarkdownUtil`; confirm `DetailView`) +- [ ] 4. Replace **WaterfallGrid** with a custom SwiftUI `Layout` · *feasibility spike first* +- [ ] 5. Replace **SwiftUIPager** with a built-in page-style `TabView` · *feasibility spike first* +- [ ] 6. Investigate inlining **DeprecatedAPI** (`getCFReadStream`) without deprecation warnings; adopt a non-deprecated API if actionable +- [ ] 7. Migrate to the latest **Colorful** (modernization, not reduction) + +**B · Concurrency & framework modernization** +- [ ] 8. Migrate **Combine-based requests → async/await** (`NetworkingFeature` request layer + `ApplicationClient`/`AuthorizationClient`/`ImageClient`/`LibraryClient` + consuming reducer effects) +- [ ] 13. Pin **TCA `from: 1.25.3` with traits** (`ComposableArchitecture2Deprecations`, `ComposableArchitecture2DeprecationOverloads`) and resolve all surfaced deprecations + +**C · UI architecture** +- [ ] 10. **Modernize adaptive layout** — remove screen-dependent logic (`DeviceUtil` + `DeviceClient`); prefer size classes / `containerRelativeFrame` / `onGeometryChange` / `ViewThatFits`, **avoiding `GeometryReader`**; retire `TouchHandler` via native gestures +- [ ] 11. **Decompose `GenericList`** — let each of its 8 consuming pages build its own list from shared atoms instead of a super-list +- [ ] 12. **Universal device orientation** on every page + remove EhPanda's custom orientation lock (delete `enablesLandscape`), deferring the lock to iOS's built-in feature +- [ ] 15. **Root-level privacy mask** — replace `blurRadius` parameter-drilling (~25 inits, 39 `.autoBlur` sites) with one mask per root surface (app root + ~41 modal roots), driven by shared state +- [ ] 19. **Remove the auto-lock feature** — delete `autoLockPolicy`, the biometric re-auth path, and `AuthorizationClient`; replace the security-section control with a description pointing users to iOS's built-in per-app lock (background blur is kept) + +**D · Architecture hygiene** +- [ ] 14. **De-globalize `*Util` → injected clients, kill singletons** — the AppTools Utils (Device/Haptics/UserDefaults/File/Cookie) plus `URLUtil` and `AppUtil`, and the `TouchHandler.shared` / `DataCache.shared` globals; keep pure value types & constants + +**E · Correctness, security & tests (folded-in concerns, later timing)** +- [ ] 16. **Move session cookies to Keychain** (during #14's CookieClient work) + audit that no cookie values are ever logged +- [ ] 17. **Client-layer test coverage** — `NetworkingFeature` (during #8), `CookieClient` & `ImageClient` (during #14) +- [ ] 18. **Fix `Category.private.filterValue`** — remove the `fatalError` landmine +- [ ] 20. **Structured error handling + user-facing error surface** (gates the `optional_try` rule) — replace silent `try?` (144 sites) with proper `do/catch` that surfaces user-relevant failures through a structured error surface (Description / Suggested Solution / Context / environment info; non-blocking failure toast → tap for detail), keeping best-effort parsing explicitly optional + +**F · UI polish** +- [ ] 21. **Numeric text polish** — apply `.monospacedDigit()` + `.contentTransition(.numericText())` to most number-bearing text (counts, page numbers, sizes, ratings) + +**G · Lint hardening (capstone + refactor-gated)** +- [ ] 9. Enable the commented-out custom rules + opt-in `multiline_function_chains` & `sorted_imports` + a new **labeled-tuple-elements** rule, all at **error** level. Mechanical rules (`sorted_imports`, `multiline_function_chains`, `single_line_trailing_closure`, labeled-tuples) land as a capstone sweep; refactor-gated rules sequence **with** their refactors: `optional_try` → #20, plus `binding_initializer`, `lifecycle_modifiers`, `unchecked_subscript_index_access` + +### Out of Scope + + + +- **ParserFeature complexity refactor** (extract per-field sub-parsers) — real value but rides on nothing else here; deferred to a future milestone +- **DownloadClient decomposition** (555+ line files) — large standalone refactor; deferred +- **Broad client-layer tests beyond networking/cookie/image** (Reading/Home/Search/Favorites features) — deferred; this milestone covers only the seams already being reworked +- **Post-release v2 schema migrations + migration-mock cleanup** — deferred until v3.0.0 ships; models stay at v1 this milestone +- **Any visual redesign** — the UI-architecture tasks are mechanism swaps, not re-skins; behavior/appearance parity required +- **Re-enabling `function_body_length` / `cyclomatic_complexity` / `type_body_length`** — kept disabled; `ParserFeature` relies on it and it wasn't requested + +## Context + +- **v3.0.0 in flight, unreleased.** Last release was v2.8.0; ~600+ commits of refactoring since (persistence drop-to-`@Shared`, navigation refactor, migration engine — all landed). This milestone is the next batch: dependency reduction + modernization before v3.0.0 ships. +- **Codebase map** lives at `.planning/codebase/` (STACK, ARCHITECTURE, STRUCTURE, CONVENTIONS, TESTING, INTEGRATIONS, CONCERNS). +- **Reference designs** for the structured error surface (#20) and the refactor-gated lint rules (#9) have been captured name-free; the plan phase needs no external lookup. +- **Two tasks carry parity risk** and are spiked first: SwiftUIPager→`TabView` (core reading UX) and WaterfallGrid→custom `Layout` (masonry column balancing). + +## Constraints + +- **Tech stack**: Swift 6.3.1, iOS/iPadOS 26 minimum, SwiftUI + TCA 1.25.x + swift-sharing; Xcode-only build/test (bare `swift build` fails); SwiftLint runs as a build-tool plugin. +- **Parity**: No user-facing behavior or appearance regressions — this is a foundation milestone, not a feature or redesign one. +- **Schema**: Persisted `@Shared` models stay at **v1**, edited in place, for the whole pre-release milestone — no `VersionedSchema` v2 / migration until v3.0.0 releases. +- **Lint**: SwiftLint-as-error; never suppress, disable, or add `// swiftlint:disable` without explicit user permission. +- **Reference privacy**: Never record the name of any local project consulted for implementation references in any repository artifact (see AGENTS.md) — absolute and non-waivable. + +## Key Decisions + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| Combine→async/await stays in this milestone (not split out) | One coherent modernization milestone; sequence it after the isolated dep removals | — Pending | +| Spike-first for WaterfallGrid→Layout and SwiftUIPager→TabView | "Bet on native parity" items that can genuinely fail; reading is core UX | — Pending | +| Avoid `GeometryReader`; prefer size classes / `containerRelativeFrame` / `onGeometryChange` / `ViewThatFits` | Most cases don't need it; it's greedy and layout-disruptive | — Pending | +| Retire `TouchHandler` via `SpatialTapGesture.location` + `MagnifyGesture.startAnchor` | Native gestures (iOS 17+) cover all three uses; kills a global singleton | — Pending | +| Remove auto-lock (use iOS built-in per-app lock); **keep** background blur | OS app-lock supersedes the custom biometric flow; app-switcher blur stays as standalone privacy | — Pending | +| `@Shared` models edited in place at v1 until v3.0.0 ships | No released data to migrate from pre-release; defers first real v2 to post-release | — Pending | +| De-`Util` package-wide (incl. `URLUtil`, `AppUtil`) | Injected clients over singletons/global helpers; consistent architecture | — Pending | +| Fold in cookies→Keychain, networking/cookie/image tests, `.private.filterValue` fix; defer Parser/Download refactors | Coupled concerns are cheap while their seams are open; standalone refactors are separate scope | — Pending | +| Recommended sequence: small-blast deps → swaps/spikes → migrations (#8/#13) → architecture (#10/#11/#12/#14/#15/#19) → concerns (#16–18,#20) → lint capstone (#9) | Minimize churn; write new code to the new bar; lint ratchets last | — Pending | + +## Evolution + +This document evolves at phase transitions and milestone boundaries. + +**After each phase transition** (via `/gsd-transition`): +1. Requirements invalidated? → Move to Out of Scope with reason +2. Requirements validated? → Move to Validated with phase reference +3. New requirements emerged? → Add to Active +4. Decisions to log? → Add to Key Decisions +5. "What This Is" still accurate? → Update if drifted + +**After each milestone** (via `/gsd-complete-milestone`): +1. Full review of all sections +2. Core Value check — still the right priority? +3. Audit Out of Scope — reasons still valid? +4. Update Context with current state + +--- +*Last updated: 2026-07-09 after initialization* From 872199fd79a74a95071ec0532f671d6bea34d24e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 21:58:32 +0800 Subject: [PATCH 603/614] chore: add project config --- .planning/config.json | 91 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .planning/config.json diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 000000000..75bccfd78 --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,91 @@ +{ + "model_profile": "adaptive", + "commit_docs": true, + "parallelization": true, + "search_gitignored": false, + "brave_search": false, + "firecrawl": false, + "exa_search": false, + "tavily_search": false, + "ref_search": false, + "perplexity": false, + "jina": false, + "git": { + "branching_strategy": "none", + "create_tag": true, + "phase_branch_template": "gsd/phase-{phase}-{slug}", + "milestone_branch_template": "gsd/{milestone}-{slug}", + "quick_branch_template": null + }, + "workflow": { + "research": true, + "plan_check": true, + "verifier": true, + "nyquist_validation": true, + "auto_advance": false, + "node_repair": true, + "node_repair_budget": 2, + "ui_phase": true, + "ui_safety_gate": true, + "ai_integration_phase": true, + "human_verify_mode": "end-of-phase", + "context_guard_mode": "warn", + "text_mode": false, + "research_before_questions": false, + "discuss_mode": "discuss", + "skip_discuss": false, + "code_review": true, + "code_review_depth": "standard", + "code_review_command": null, + "pattern_mapper": true, + "plan_bounce": false, + "plan_bounce_script": null, + "plan_bounce_passes": 2, + "auto_prune_state": false, + "post_planning_gaps": true, + "security_enforcement": true, + "security_asvs_level": 1, + "security_block_on": "high" + }, + "ship": { + "pr_body_sections": [ + { + "heading": "User Stories & Acceptance Criteria", + "enabled": true, + "source": "REQUIREMENTS.md ## User Stories || REQUIREMENTS.md ## Acceptance Criteria", + "fallback": "- Acceptance criteria are covered by the linked requirements and verification evidence." + }, + { + "heading": "Risks & Dependencies", + "enabled": true, + "source": "PLAN.md ## Risks || PLAN.md ## Dependencies", + "fallback": "- No known high-risk rollout dependencies." + }, + { + "heading": "Success Metrics & Release Criteria", + "enabled": false, + "source": "REQUIREMENTS.md ## Definition of Done || VERIFICATION.md ## Release Criteria", + "fallback": "- Release when automated verification and required manual checks pass." + }, + { + "heading": "Stakeholder Review & Approval", + "enabled": false, + "template": "- Product owner approval pending for {phase_name}." + } + ] + }, + "hooks": { + "context_warnings": true + }, + "project_code": null, + "phase_naming": "sequential", + "agent_skills": {}, + "claude_md_path": "./.claude/CLAUDE.md", + "plan_review": { + "source_grounding": true, + "source_grounding_authority": "grep" + }, + "resolve_model_ids": "omit", + "mode": "yolo", + "granularity": "fine" +} From 8285599efdc4d3f91665926746bf18d2c2eba283 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 22:02:38 +0800 Subject: [PATCH 604/614] docs: define v1 requirements --- .planning/REQUIREMENTS.md | 123 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 .planning/REQUIREMENTS.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 000000000..66511e975 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,123 @@ +# Requirements: EhPanda — Dependency Reduction & Modernization (v3.0.0) + +**Defined:** 2026-07-09 +**Core Value:** The load-bearing paths — fetch, parse, read, download — keep working; every task is a foundation change held to **behavior/appearance parity**. + +> These are a modernization milestone's requirements: technical and refactor-oriented rather than user-stories. Each carries acceptance criteria that define "done"; unless stated otherwise, **no user-facing regression** is an implicit criterion for all of them. + +## v1 Requirements + +### DEP — Dependency reduction + +- [ ] **DEP-01**: Fork SwiftyOpenCC and modernize it — update its OpenCC dependency, package requirement, and Swift version to the latest, rebuilt on the current stack. + - App depends on the fork; Simplified/Traditional tag conversion (`ChineseConverter`) is unchanged in behavior; builds clean on the pinned toolchain. +- [ ] **DEP-02**: Fork UIImageColors and modernize it the same way. + - App depends on the fork; dominant-color extraction (`getColors` → primary/secondary/detail/background) unchanged; builds clean. +- [ ] **DEP-03**: Migrate Markdown from SwiftCommonMark to Apple swift-markdown. + - `MarkdownUtil.parseTexts/parseLinks/parseImages` reproduced on swift-markdown's `Document`/walker; `TagTranslation` output identical on fixtures; `DetailView` markdown confirmed (render vs parse) and preserved; SwiftCommonMark removed from `Package.swift`. +- [ ] **DEP-04**: Replace WaterfallGrid with a custom SwiftUI `Layout`. *(Spike-gated: validate feasibility before committing tasks.)* + - Masonry column balancing matches current output (portrait/landscape, iPad/phone counts); WaterfallGrid removed; scrolling performance not regressed. +- [ ] **DEP-05**: Replace SwiftUIPager with a built-in page-style `TabView`. *(Spike-gated.)* + - Reading paging parity: horizontal/RTL/dual-page, page-index mapping, gesture coexistence; SwiftUIPager removed; if native can't reach parity, spike surfaces it before commit. +- [ ] **DEP-06**: Investigate inlining DeprecatedAPI (`getCFReadStream`) into the project without deprecation warnings; adopt a non-deprecated API if actionable. + - Either the shim is inlined warning-free or a non-deprecated replacement is used; DeprecatedAPI dependency removed; DF networking behavior unchanged. +- [ ] **DEP-07**: Migrate to the latest Colorful. + - `GalleryCardCell` animated gradient renders as before on the current API; version pin updated. + +### CONC — Concurrency & framework modernization + +- [ ] **CONC-01**: Migrate Combine-based requests to async/await. + - `NetworkingFeature` request layer returns async results (no `AnyPublisher`); `ApplicationClient`/`AuthorizationClient`/`ImageClient`/`LibraryClient` and all consuming reducer effects migrated off Combine; request behavior/error paths preserved. +- [ ] **CONC-02**: Pin TCA `from: 1.25.3` with traits `ComposableArchitecture2Deprecations` + `ComposableArchitecture2DeprecationOverloads` and resolve all surfaced deprecations. + - `Package.swift` updated with traits; zero TCA deprecation warnings remain; reducers/stores behave identically. + +### UIARCH — UI architecture + +- [ ] **UIARCH-01**: Modernize adaptive layout — remove screen-dependent logic across `DeviceUtil` and `DeviceClient`. + - No view reads `DeviceUtil.window*/screen*/absWindow*` for layout; discrete `isPadWidth`/`isSEWidth` breakpoints replaced by size-class / container-relative decisions; `TouchHandler` retired via `SpatialTapGesture.location` + `MagnifyGesture.startAnchor`; **`GeometryReader` avoided** in favor of `containerRelativeFrame`/`onGeometryChange`/`ViewThatFits`; `Defaults.FrameSize`/`ImageSize` no longer derive size from a global; reading zoom/pan/tap parity preserved. +- [ ] **UIARCH-02**: Decompose `GenericList` so each of its 8 consuming pages builds its own list from shared atoms. + - Reusable atoms (cells, footer, notice, loading/error overlays, grid) extracted; the 8 pages compose their own lists; `GenericList` super-list removed; list behavior (display modes, pagination, refresh, badges) preserved. +- [ ] **UIARCH-03**: Support device orientation on every page and remove EhPanda's custom orientation lock. + - All pages rotate with the device; `AppOrientationMask` masking, `AppDelegateClient.setOrientation*`, the reading `setOrientationPortrait` flow, and the `Setting.enablesLandscape` field are removed (v1 in-place edit); OS orientation lock governs. +- [ ] **UIARCH-04**: Replace `blurRadius` parameter-drilling with a root-level privacy mask. + - No view initializer takes `blurRadius`; `.autoBlur` applied only at root surfaces — app root + every one of the ~41 modal roots; transient blur state sourced from shared in-memory state; **no lock-time/background content leak** in any modal; NavigationBar-collapse workaround preserved. +- [ ] **UIARCH-05**: Remove the auto-lock feature; direct users to iOS's built-in per-app lock. + - `Setting.autoLockPolicy`, the biometric re-auth path (`authorize`/`lockApp`/`isAppLocked`/threshold), and `AuthorizationClient` are removed; the security-section auto-lock control is replaced by a description pointing to the iOS built-in lock; background blur is retained (see UIARCH-04). + +### HYG — Architecture hygiene + +- [ ] **HYG-01**: De-globalize `*Util` into injected clients and remove singletons. + - The AppTools Utils (Device/Haptics/UserDefaults/File/Cookie) plus `URLUtil` and `AppUtil` are converted to / folded into injected clients; `TouchHandler.shared` and `DataCache.shared` globals removed; pure value types and constants retained; no remaining static global helper with side effects. + +### QUAL — Correctness, security & tests + +- [ ] **QUAL-01**: Move session cookies to Keychain and audit cookie logging. + - Durable auth cookies stored via Keychain (done within HYG-01's CookieClient work); no cookie value is ever emitted to logs at `.public` privacy. +- [ ] **QUAL-02**: Add client-layer test coverage for the reworked seams. + - `NetworkingFeature` covered (during CONC-01); `CookieClient` and `ImageClient` covered (during HYG-01); tests are deterministic and green. +- [ ] **QUAL-03**: Fix the `Category.private.filterValue` `fatalError` landmine. + - `filterValue` no longer crashes for `.private`; no callsite iterating all categories can trap; covered by a test. +- [ ] **QUAL-04**: Replace silent `try?` with structured error handling and a user-facing error surface (gates the `optional_try` rule). + - A structured `AppError` (description / suggested solution / typed context) exists; user-relevant failures surface via a non-blocking failure toast that opens a detailed, dismissable error surface (Description / Solution / Context / environment info); network/file/decode `try?` sites become proper `do/catch`; genuinely best-effort parsing stays explicitly optional (not every one prompts); `optional_try` can be enabled at error with zero violations. + +### POLISH — UI polish + +- [ ] **POLISH-01**: Apply `.monospacedDigit()` + `.contentTransition(.numericText())` to most number-bearing text. + - Counts, page numbers, sizes, ratings, and similar numeric text use monospaced digits and animate as numeric transitions where it makes sense; no layout jitter on value change. + +### LINT — Lint hardening + +- [ ] **LINT-01**: Enable the stricter SwiftLint ruleset at error level. + - The 5 commented custom rules (`binding_initializer`, `lifecycle_modifiers`, `optional_try`, `single_line_trailing_closure`, `unchecked_subscript_index_access`), opt-in `multiline_function_chains` & `sorted_imports`, and a new labeled-tuple-elements rule are all enabled at **error**; every violation resolved at its root (no suppressions); mechanical rules land as a capstone sweep, refactor-gated rules (`optional_try`→QUAL-04, plus binding/lifecycle/unchecked-subscript) land with their refactors. + +## v2 Requirements + +None. Deferred work is captured under Out of Scope (future milestone), not staged as v2 here. + +## Out of Scope + +| Feature | Reason | +|---------|--------| +| ParserFeature complexity refactor (per-field sub-parsers) | Real value but rides on nothing else this milestone; deferred to a future milestone | +| DownloadClient decomposition | Large standalone refactor; deferred | +| Client-layer tests beyond networking/cookie/image (Reading/Home/Search/Favorites) | Deferred; this milestone covers only seams already being reworked | +| Post-release v2 schema migrations + migration-mock cleanup | Models stay at v1 until v3.0.0 ships; first real v2 is post-release | +| Any visual redesign | UI-architecture tasks are mechanism swaps, not re-skins; parity required | +| Re-enabling `function_body_length` / `cyclomatic_complexity` / `type_body_length` | Kept disabled; `ParserFeature` relies on it; not requested | + +## Traceability + + + +| Requirement | Phase | Status | +|-------------|-------|--------| +| DEP-01 | — | Pending | +| DEP-02 | — | Pending | +| DEP-03 | — | Pending | +| DEP-04 | — | Pending | +| DEP-05 | — | Pending | +| DEP-06 | — | Pending | +| DEP-07 | — | Pending | +| CONC-01 | — | Pending | +| CONC-02 | — | Pending | +| UIARCH-01 | — | Pending | +| UIARCH-02 | — | Pending | +| UIARCH-03 | — | Pending | +| UIARCH-04 | — | Pending | +| UIARCH-05 | — | Pending | +| HYG-01 | — | Pending | +| QUAL-01 | — | Pending | +| QUAL-02 | — | Pending | +| QUAL-03 | — | Pending | +| QUAL-04 | — | Pending | +| POLISH-01 | — | Pending | +| LINT-01 | — | Pending | + +**Coverage:** +- v1 requirements: 21 total +- Mapped to phases: 0 (pending roadmap) +- Unmapped: 21 ⚠️ + +--- +*Requirements defined: 2026-07-09* +*Last updated: 2026-07-09 after initial definition* From 76a5844b315ecbcc0f86990ff9cebe6cd3051fe3 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 9 Jul 2026 22:14:09 +0800 Subject: [PATCH 605/614] docs: create roadmap (11 phases) --- .planning/REQUIREMENTS.md | 48 +++++----- .planning/ROADMAP.md | 179 ++++++++++++++++++++++++++++++++++++++ .planning/STATE.md | 86 ++++++++++++++++++ 3 files changed, 289 insertions(+), 24 deletions(-) create mode 100644 .planning/ROADMAP.md create mode 100644 .planning/STATE.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 66511e975..4049d9913 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -91,33 +91,33 @@ None. Deferred work is captured under Out of Scope (future milestone), not stage | Requirement | Phase | Status | |-------------|-------|--------| -| DEP-01 | — | Pending | -| DEP-02 | — | Pending | -| DEP-03 | — | Pending | -| DEP-04 | — | Pending | -| DEP-05 | — | Pending | -| DEP-06 | — | Pending | -| DEP-07 | — | Pending | -| CONC-01 | — | Pending | -| CONC-02 | — | Pending | -| UIARCH-01 | — | Pending | -| UIARCH-02 | — | Pending | -| UIARCH-03 | — | Pending | -| UIARCH-04 | — | Pending | -| UIARCH-05 | — | Pending | -| HYG-01 | — | Pending | -| QUAL-01 | — | Pending | -| QUAL-02 | — | Pending | -| QUAL-03 | — | Pending | -| QUAL-04 | — | Pending | -| POLISH-01 | — | Pending | -| LINT-01 | — | Pending | +| DEP-01 | Phase 1 | Pending | +| DEP-02 | Phase 1 | Pending | +| DEP-03 | Phase 1 | Pending | +| DEP-04 | Phase 2 | Pending | +| DEP-05 | Phase 3 | Pending | +| DEP-06 | Phase 1 | Pending | +| DEP-07 | Phase 1 | Pending | +| CONC-01 | Phase 4 | Pending | +| CONC-02 | Phase 4 | Pending | +| UIARCH-01 | Phase 5 | Pending | +| UIARCH-02 | Phase 6 | Pending | +| UIARCH-03 | Phase 5 | Pending | +| UIARCH-04 | Phase 7 | Pending | +| UIARCH-05 | Phase 7 | Pending | +| HYG-01 | Phase 8 | Pending | +| QUAL-01 | Phase 8 | Pending | +| QUAL-02 | Phase 8 | Pending | +| QUAL-03 | Phase 9 | Pending | +| QUAL-04 | Phase 9 | Pending | +| POLISH-01 | Phase 10 | Pending | +| LINT-01 | Phase 11 | Pending | **Coverage:** - v1 requirements: 21 total -- Mapped to phases: 0 (pending roadmap) -- Unmapped: 21 ⚠️ +- Mapped to phases: 21 ✓ +- Unmapped: 0 --- *Requirements defined: 2026-07-09* -*Last updated: 2026-07-09 after initial definition* +*Last updated: 2026-07-09 after roadmap creation (traceability filled, 21/21 mapped)* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 000000000..9b6e644ec --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,179 @@ +# Roadmap: EhPanda — Dependency Reduction & Modernization (v3.0.0) + +## Overview + +A foundation milestone that shrinks EhPanda's third-party surface and modernizes its +concurrency, UI architecture, and lint bar ahead of the unreleased v3.0.0 — every task held to +behavior/appearance parity. The journey runs from low-risk isolated dependency swaps, through the +two parity-risk native swaps that are spike-gated first (WaterfallGrid→Layout, SwiftUIPager→TabView), +into the big framework migration (Combine→async/await, TCA traits), then the UI-architecture and +hygiene refactors (adaptive layout, GenericList decomposition, root privacy mask, auto-lock removal, +de-globalized clients) with their folded-in security/test/correctness concerns, and finishing with a +structured error surface and a lint capstone. Refactor-gated lint rules land with the refactors that +enable them; the mechanical rules sweep last. + +## Phases + +**Phase Numbering:** +- Integer phases (1, 2, 3): Planned milestone work +- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED) + +Decimal phases appear between their surrounding integers in numeric order. + +- [ ] **Phase 1: Isolated Dependency Modernization** - Fork/modernize/replace the third-party deps that don't couple to other work, at parity +- [ ] **Phase 2: Native Masonry Grid Swap (spike-gated)** - Validate then replace WaterfallGrid with a custom SwiftUI Layout +- [ ] **Phase 3: Native Reader Paging Swap (spike-gated)** - Validate then replace SwiftUIPager with a page-style TabView +- [ ] **Phase 4: Concurrency & Framework Migration** - Move requests to async/await and pin TCA with deprecation traits +- [ ] **Phase 5: Adaptive Layout & Universal Orientation** - Let size classes and the OS govern layout and rotation; retire screen-metric math and TouchHandler +- [ ] **Phase 6: GenericList Decomposition** - Replace the super-list with per-page lists built from shared atoms +- [ ] **Phase 7: Root Privacy Mask & Auto-Lock Removal** - One shared-state mask per root surface; remove the custom auto-lock for iOS's built-in per-app lock +- [ ] **Phase 8: Architecture Hygiene & Client Seams** - De-globalize Utils into injected clients, move cookies to Keychain, cover reworked seams with tests +- [ ] **Phase 9: Correctness & Structured Error Handling** - Kill the private-category crash and replace silent try? with a user-facing error surface +- [ ] **Phase 10: Numeric Text Polish** - Monospaced digits and numeric-text transitions on number-bearing text +- [ ] **Phase 11: Lint Capstone** - Ratchet SwiftLint to the stricter ruleset at error; mechanical sweep last, refactor-gated rules flipped on + +## Phase Details + +### Phase 1: Isolated Dependency Modernization +**Goal**: Shrink and modernize the isolated third-party surface — the swaps that don't couple to other work — with behavior parity. +**Depends on**: Nothing (first phase) +**Requirements**: DEP-01, DEP-02, DEP-03, DEP-06, DEP-07 +**Success Criteria** (what must be TRUE): + 1. Simplified/Traditional tag conversion (`ChineseConverter`) produces identical output on the forked, modernized SwiftyOpenCC, and the project builds clean on the pinned toolchain. + 2. Dominant-color extraction (`getColors` → primary/secondary/detail/background) is unchanged on the forked, modernized UIImageColors. + 3. Markdown parsing (`MarkdownUtil.parseTexts/parseLinks/parseImages`) yields identical `TagTranslation` output on swift-markdown fixtures, `DetailView` markdown is preserved, and SwiftCommonMark is removed from `Package.swift`. + 4. DeprecatedAPI is gone — the `getCFReadStream` path is inlined warning-free or replaced by a non-deprecated API, with DF networking behavior unchanged. + 5. `GalleryCardCell`'s animated gradient renders as before on the latest Colorful, with the version pin updated. +**Plans**: TBD + +### Phase 2: Native Masonry Grid Swap (spike-gated) +**Goal**: Replace WaterfallGrid with a custom SwiftUI `Layout` — validated by a feasibility spike first — with column-balancing and scrolling parity. +**Depends on**: Nothing (independent; may run alongside Phase 1) +**Requirements**: DEP-04 +**Success Criteria** (what must be TRUE): + 1. A feasibility spike confirms a custom `Layout` can reproduce masonry column balancing before implementation is committed, or surfaces the blocker. + 2. Masonry output matches current WaterfallGrid across portrait/landscape and iPad/phone column counts. + 3. Scrolling performance is not regressed. + 4. WaterfallGrid is removed from the dependency set. +**Plans**: TBD +**UI hint**: yes + +### Phase 3: Native Reader Paging Swap (spike-gated) +**Goal**: Replace SwiftUIPager with a built-in page-style `TabView` for reading — validated by a spike first — preserving all paging UX. +**Depends on**: Nothing (independent; may run alongside Phases 1–2) +**Requirements**: DEP-05 +**Success Criteria** (what must be TRUE): + 1. A feasibility spike confirms a native page-style `TabView` reaches reading-paging parity (horizontal/RTL/dual-page, page-index mapping, gesture coexistence) before commit, or surfaces the gap. + 2. Reading paging behaves identically: horizontal and RTL direction, dual-page mode, and correct page-index mapping. + 3. Reader gestures (zoom/pan/tap) continue to coexist with paging. + 4. SwiftUIPager is removed from the dependency set. +**Plans**: TBD +**UI hint**: yes + +### Phase 4: Concurrency & Framework Migration +**Goal**: Move the request layer to async/await and pin TCA with deprecation traits — with request and reducer behavior preserved. +**Depends on**: Phase 2, Phase 3 (migrations sequenced after the native swaps to minimize churn) +**Requirements**: CONC-01, CONC-02 +**Success Criteria** (what must be TRUE): + 1. The `NetworkingFeature` request layer returns async results with no `AnyPublisher`, and request behavior and error paths are preserved. + 2. `ApplicationClient`/`AuthorizationClient`/`ImageClient`/`LibraryClient` and all consuming reducer effects are migrated off Combine. + 3. `Package.swift` pins TCA `from: 1.25.3` with the `ComposableArchitecture2Deprecations` + `ComposableArchitecture2DeprecationOverloads` traits. + 4. Zero TCA deprecation warnings remain, and reducers/stores behave identically. +**Plans**: TBD + +### Phase 5: Adaptive Layout & Universal Orientation +**Goal**: Let size classes and the OS govern layout and orientation — retiring screen-metric math, the custom touch handler, and the custom orientation lock — with reading and rotation parity. +**Depends on**: Phase 2, Phase 3, Phase 4 (refines the swapped grid/reader surfaces on top of the migrated code) +**Requirements**: UIARCH-01, UIARCH-03 +**Success Criteria** (what must be TRUE): + 1. No view reads `DeviceUtil.window*/screen*/absWindow*` for layout; discrete `isPadWidth`/`isSEWidth` breakpoints are replaced by size-class / container-relative decisions; `GeometryReader` is avoided in favor of `containerRelativeFrame`/`onGeometryChange`/`ViewThatFits`. + 2. `TouchHandler` is retired via `SpatialTapGesture.location` + `MagnifyGesture.startAnchor`, and reading zoom/pan/tap parity is preserved. + 3. `Defaults.FrameSize`/`ImageSize` no longer derive size from a global. + 4. All pages rotate with the device; `AppOrientationMask` masking, `AppDelegateClient.setOrientation*`, the reading `setOrientationPortrait` flow, and `Setting.enablesLandscape` are removed, with the OS orientation lock governing. +**Plans**: TBD +**UI hint**: yes + +### Phase 6: GenericList Decomposition +**Goal**: Replace the `GenericList` super-list with per-page lists composed from shared atoms — preserving all list behavior. +**Depends on**: Phase 5 (per-page lists compose the new adaptive layout and custom grid atoms) +**Requirements**: UIARCH-02 +**Success Criteria** (what must be TRUE): + 1. Reusable atoms (cells, footer, notice, loading/error overlays, grid) are extracted, and each of the 8 consuming pages composes its own list. + 2. The `GenericList` super-list is removed. + 3. List behavior is preserved: display modes, pagination, refresh, and badges. +**Plans**: TBD +**UI hint**: yes + +### Phase 7: Root Privacy Mask & Auto-Lock Removal +**Goal**: Replace `blurRadius` parameter-drilling with one shared-state-driven mask per root surface, and remove the custom auto-lock in favor of iOS's built-in per-app lock — keeping background blur and leaking no content. +**Depends on**: Phase 4 (`AuthorizationClient` is removed after CONC-01 migrates it; the mask lands on migrated code) +**Requirements**: UIARCH-04, UIARCH-05 +**Success Criteria** (what must be TRUE): + 1. No view initializer takes `blurRadius`; `.autoBlur` is applied only at root surfaces (app root + each of the ~41 modal roots), driven by shared in-memory state, with no lock-time/background content leak in any modal, and the NavigationBar-collapse workaround preserved. + 2. `Setting.autoLockPolicy`, the biometric re-auth path (`authorize`/`lockApp`/`isAppLocked`/threshold), and `AuthorizationClient` are removed. + 3. The security-section auto-lock control is replaced by a description pointing users to the iOS built-in per-app lock. + 4. Background / app-switcher blur is retained. +**Plans**: TBD +**UI hint**: yes + +### Phase 8: Architecture Hygiene & Client Seams +**Goal**: De-globalize the Utils into injected clients and remove singletons, move session cookies to Keychain, and cover the reworked client seams with tests. +**Depends on**: Phase 4, Phase 5 (removes `TouchHandler.shared` after UIARCH-01 retires it; QUAL-02 tests the async `NetworkingFeature` from Phase 4) +**Requirements**: HYG-01, QUAL-01, QUAL-02 +**Success Criteria** (what must be TRUE): + 1. The AppTools Utils (Device/Haptics/UserDefaults/File/Cookie) plus `URLUtil` and `AppUtil` are converted to / folded into injected clients; `TouchHandler.shared` and `DataCache.shared` globals are removed; pure value types and constants are retained; no static global helper with side effects remains. + 2. Durable auth cookies are stored via Keychain (within the CookieClient work), and no cookie value is ever emitted to logs at `.public` privacy. + 3. Client-layer tests cover the reworked seams — the async `NetworkingFeature` (from Phase 4), `CookieClient`, and `ImageClient` — and are deterministic and green. +**Plans**: TBD + +### Phase 9: Correctness & Structured Error Handling +**Goal**: Remove the private-category crash landmine and replace silent `try?` with structured error handling behind a user-facing error surface. +**Depends on**: Phase 8 (structured error handling applied to the settled client/architecture seams) +**Requirements**: QUAL-03, QUAL-04 +**Success Criteria** (what must be TRUE): + 1. `Category.private.filterValue` no longer crashes, no callsite iterating all categories can trap, and a test covers it. + 2. A structured `AppError` (description / suggested solution / typed context) exists; network/file/decode `try?` sites become proper `do/catch`, while genuinely best-effort parsing stays explicitly optional. + 3. User-relevant failures surface via a non-blocking failure toast that opens a dismissable detail surface (Description / Suggested Solution / Context / environment info). + 4. `optional_try` can be enabled at error with zero violations (verified in the lint capstone). +**Plans**: TBD +**UI hint**: yes + +### Phase 10: Numeric Text Polish +**Goal**: Apply monospaced digits and numeric-text transitions to number-bearing text so counts and values animate cleanly without layout jitter. +**Depends on**: Phase 6, Phase 7 (applies to the settled UI surfaces) +**Requirements**: POLISH-01 +**Success Criteria** (what must be TRUE): + 1. Counts, page numbers, sizes, ratings, and similar numeric text use `.monospacedDigit()` and `.contentTransition(.numericText())` where it makes sense. + 2. Numeric values animate as numeric transitions on change. + 3. No layout jitter occurs on value change. +**Plans**: TBD +**UI hint**: yes + +### Phase 11: Lint Capstone +**Goal**: Ratchet SwiftLint to the stricter ruleset at error — the mechanical rules as a final sweep, the refactor-gated rules flipped on now that their refactors have landed — with every violation resolved at its root. +**Depends on**: Phase 5, Phase 6, Phase 7, Phase 9 (refactor-gated rules land with their refactors; the mechanical sweep runs last) +**Requirements**: LINT-01 +**Success Criteria** (what must be TRUE): + 1. The mechanical rules (`sorted_imports`, `multiline_function_chains`, `single_line_trailing_closure`, and the new labeled-tuple-elements rule) are enabled at **error** as a capstone sweep, with all violations resolved at root. + 2. The refactor-gated rules (`optional_try`, `binding_initializer`, `lifecycle_modifiers`, `unchecked_subscript_index_access`) — resolved at root during their coupled refactor phases (`optional_try` with Phase 9's structured-error work; the others with the Phase 5–7 UI/architecture refactors) — are switched to **error** with zero remaining violations. + 3. No rule is suppressed, disabled, or bypassed with `// swiftlint:disable`, and the project builds clean under SwiftLint-as-error. +**Plans**: TBD + +## Progress + +**Execution Order:** +Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 + +| Phase | Plans Complete | Status | Completed | +|-------|----------------|--------|-----------| +| 1. Isolated Dependency Modernization | 0/TBD | Not started | - | +| 2. Native Masonry Grid Swap | 0/TBD | Not started | - | +| 3. Native Reader Paging Swap | 0/TBD | Not started | - | +| 4. Concurrency & Framework Migration | 0/TBD | Not started | - | +| 5. Adaptive Layout & Universal Orientation | 0/TBD | Not started | - | +| 6. GenericList Decomposition | 0/TBD | Not started | - | +| 7. Root Privacy Mask & Auto-Lock Removal | 0/TBD | Not started | - | +| 8. Architecture Hygiene & Client Seams | 0/TBD | Not started | - | +| 9. Correctness & Structured Error Handling | 0/TBD | Not started | - | +| 10. Numeric Text Polish | 0/TBD | Not started | - | +| 11. Lint Capstone | 0/TBD | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 000000000..3b273ac90 --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,86 @@ +--- +gsd_state_version: '1.0' # placeholder; syncStateFrontmatter overwrites on first state.* call +status: planning +progress: + total_phases: 11 + completed_phases: 0 + total_plans: 0 + completed_plans: 0 + percent: 0 +--- + +# Project State + +## Project Reference + +See: .planning/PROJECT.md (updated 2026-07-09) + +**Core value:** The load-bearing paths — fetch, parse, read, download galleries — keep working; every task is a foundation change held to behavior/appearance parity. +**Current focus:** Phase 1 — Isolated Dependency Modernization + +## Current Position + +Phase: 1 of 11 (Isolated Dependency Modernization) +Plan: 0 of TBD in current phase +Status: Ready to plan +Last activity: 2026-07-09 — Roadmap created (11 phases, 21 requirements mapped) + +Progress: [░░░░░░░░░░] 0% + +## Performance Metrics + +**Velocity:** +- Total plans completed: 0 +- Average duration: — min +- Total execution time: 0.0 hours + +**By Phase:** + +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| +| - | - | - | - | + +**Recent Trend:** +- Last 5 plans: — +- Trend: — + +*Updated after each plan completion* + +## Accumulated Context + +### Decisions + +Decisions are logged in PROJECT.md Key Decisions table. +Recent decisions affecting current work: + +- Roadmap: Combine→async/await (Phase 4) stays in this milestone, sequenced after the isolated dep removals. +- Roadmap: WaterfallGrid→Layout (Phase 2) and SwiftUIPager→TabView (Phase 3) are spike-first — validate feasibility before committing. +- Roadmap: Fold cookies→Keychain + networking/cookie/image tests + `.private.filterValue` fix into their open seams (Phases 8–9); defer Parser/Download refactors. +- Roadmap: LINT-01 split — mechanical rules sweep last (Phase 11); refactor-gated rules land with their refactors (`optional_try`→Phase 9; binding/lifecycle/unchecked-subscript→Phases 5–7). + +### Pending Todos + +[From .planning/todos/pending/ — ideas captured during sessions] + +None yet. + +### Blockers/Concerns + +[Issues that affect future work] + +- Phase 8 (QUAL-02): NetworkingFeature tests couple to Phase 4's async migration but land in the hygiene phase (where CookieClient/ImageClient are reworked) — verify NetworkingFeature parity tests are written against the migrated async layer, not deferred silently. +- Phases 2 & 3 carry genuine parity risk (spike-gated); a failed spike must surface before committing implementation. + +## Deferred Items + +Items acknowledged and carried forward from previous milestone close: + +| Category | Item | Status | Deferred At | +|----------|------|--------|-------------| +| *(none)* | | | | + +## Session Continuity + +Last session: 2026-07-09 +Stopped at: ROADMAP.md + STATE.md written; REQUIREMENTS.md traceability filled (21/21 mapped) +Resume file: None From 37d76aca8d905f6fdca2d814603d9974ac64898b Mon Sep 17 00:00:00 2001 From: dyphire Date: Wed, 12 Aug 2026 16:57:17 +0800 Subject: [PATCH 606/614] fix(apptools): set disk size limit for cache --- AppPackage/Sources/AppTools/DataCache.swift | 2 +- AppPackage/Sources/LibraryClient/LibraryClient.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/AppPackage/Sources/AppTools/DataCache.swift b/AppPackage/Sources/AppTools/DataCache.swift index 5d0b40a75..a25f01889 100644 --- a/AppPackage/Sources/AppTools/DataCache.swift +++ b/AppPackage/Sources/AppTools/DataCache.swift @@ -27,7 +27,7 @@ public actor DataCache { .appendingPathComponent("DataCache.reading", isDirectory: true), memoryCostLimit: Int = Int(ProcessInfo.processInfo.physicalMemory / 4), maxDiskAge: TimeInterval = 7 * 24 * 60 * 60, - diskSizeLimit: UInt64 = 0 + diskSizeLimit: UInt64 = 2 * 1024 * 1024 * 1024 ) { self.rootURL = rootURL self.memoryCostLimit = memoryCostLimit diff --git a/AppPackage/Sources/LibraryClient/LibraryClient.swift b/AppPackage/Sources/LibraryClient/LibraryClient.swift index f901f26b0..d4ed4f69c 100644 --- a/AppPackage/Sources/LibraryClient/LibraryClient.swift +++ b/AppPackage/Sources/LibraryClient/LibraryClient.swift @@ -47,6 +47,7 @@ extension LibraryClient { let config = KingfisherManager.shared.downloader.sessionConfiguration config.httpCookieStorage = HTTPCookieStorage.shared KingfisherManager.shared.downloader.sessionConfiguration = config + KingfisherManager.shared.cache.diskStorage.config.sizeLimit = 1024 * 1024 * 1024 let sdConfig = URLSessionConfiguration.default sdConfig.httpCookieStorage = HTTPCookieStorage.shared @@ -56,6 +57,7 @@ extension LibraryClient { forHTTPHeaderField: "Accept" ) SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) + DataCache.installSystemPurgeObservers() }, removeAllCachedImages: { From cf681206ff5932fcf58e0811edd6294a5c89b872 Mon Sep 17 00:00:00 2001 From: dyphire Date: Thu, 13 Aug 2026 15:46:16 +0800 Subject: [PATCH 607/614] feat(ui): enhanced visual feedback for gallery details and lists --- .../Sources/AppModels/Gallery/Gallery.swift | 32 +++++++- .../AppModels/Gallery/GalleryDetail.swift | 8 +- .../GalleryHistory+Operations.swift | 18 ++++- .../Persistent/GalleryHistoryEntry.swift | 14 +++- AppPackage/Sources/AppTools/Defaults.swift | 15 ++++ .../DetailFeature/DetailReducer+Actions.swift | 13 +++- .../DetailFeature/DetailReducer+Fetch.swift | 35 ++++++++- .../Sources/DetailFeature/DetailReducer.swift | 4 + .../DetailView+HeaderSection.swift | 57 +++++++++++++-- .../DetailFeature/DetailView+Subviews.swift | 30 +++++++- .../Sources/DetailFeature/DetailView.swift | 1 + .../Cells/GalleryDetailCell.swift | 15 +++- .../Cells/GalleryThumbnailCell.swift | 13 +++- .../Sources/HomeFeature/GalleryCardCell.swift | 15 +++- .../HomeFeature/GalleryRankingCell.swift | 8 +- .../HomeFeature/History/HistoryReducer.swift | 23 ++++-- .../NetworkingFeature/Request+Detail.swift | 6 +- .../Request+GalleriesMetadata.swift | 24 +++++- .../Sources/ParserFeature/Parser+Detail.swift | 9 ++- .../Sources/ParserFeature/Parser+List.swift | 73 +++++++++++++++++-- .../Sources/ParserFeature/Parser+Types.swift | 3 + .../SearchFeature/GalleryHistoryCell.swift | 8 +- .../GalleriesMetadataDecodeTests.swift | 50 +++++++++++++ 23 files changed, 428 insertions(+), 46 deletions(-) diff --git a/AppPackage/Sources/AppModels/Gallery/Gallery.swift b/AppPackage/Sources/AppModels/Gallery/Gallery.swift index 992dc4c0e..ed8b241e0 100644 --- a/AppPackage/Sources/AppModels/Gallery/Gallery.swift +++ b/AppPackage/Sources/AppModels/Gallery/Gallery.swift @@ -22,7 +22,11 @@ public struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { pageCount: 1, postedDate: .now, coverURL: nil, - galleryURL: nil + galleryURL: nil, + isExpunged: false, + hasRated: false, + favoriteTagIndex: nil, + favoriteTagName: nil ) } guard randomID, count > 0 else { @@ -45,7 +49,11 @@ public struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { + "EhPanda-Team/Imageset/blob/" + "main/JPGs/2.jpg?raw=true" ), - galleryURL: nil + galleryURL: nil, + isExpunged: false, + hasRated: false, + favoriteTagIndex: nil, + favoriteTagName: nil ) public var trimmedTitle: String { @@ -74,6 +82,7 @@ public struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { public let token: String public var title: String + public var titleJpn: String? public var rating: Float public var tags: [GalleryTag] public let category: Category @@ -83,11 +92,19 @@ public struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { public let coverURL: URL? public let galleryURL: URL? public var lastOpenDate: Date? + public var isExpunged: Bool = false + public var hasRated: Bool = false + public var favoriteTagIndex: Int? + public var favoriteTagName: String? + public var isFavorite: Bool { + favoriteTagIndex != nil || favoriteTagName != nil + } public init( gid: String, token: String, title: String, + titleJpn: String? = nil, rating: Float, tags: [GalleryTag], category: Category, @@ -96,11 +113,16 @@ public struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { postedDate: Date, coverURL: URL?, galleryURL: URL?, - lastOpenDate: Date? = nil + lastOpenDate: Date? = nil, + isExpunged: Bool = false, + hasRated: Bool = false, + favoriteTagIndex: Int? = nil, + favoriteTagName: String? = nil ) { self.gid = gid self.token = token self.title = title + self.titleJpn = titleJpn.flatMap(\.nonEmpty) self.rating = rating self.tags = tags self.category = category @@ -110,6 +132,10 @@ public struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { self.coverURL = coverURL self.galleryURL = galleryURL self.lastOpenDate = lastOpenDate + self.isExpunged = isExpunged + self.hasRated = hasRated + self.favoriteTagIndex = favoriteTagIndex + self.favoriteTagName = favoriteTagName } } diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift b/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift index c427aba57..f6decdbfa 100644 --- a/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift +++ b/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift @@ -23,7 +23,9 @@ public struct GalleryDetail: Codable, Equatable, Sendable { pageCount: Int, sizeCount: Float, sizeType: String, - torrentCount: Int + torrentCount: Int, + favoriteTagIndex: Int? = nil, + favoriteTagName: String? = nil ) { self.gid = gid self.title = title @@ -45,6 +47,8 @@ public struct GalleryDetail: Codable, Equatable, Sendable { self.sizeCount = sizeCount self.sizeType = sizeType self.torrentCount = torrentCount + self.favoriteTagIndex = favoriteTagIndex + self.favoriteTagName = favoriteTagName } public static let empty: Self = .init( gid: "", title: "", isFavorited: false, @@ -110,6 +114,8 @@ public struct GalleryDetail: Codable, Equatable, Sendable { public var sizeCount: Float public var sizeType: String public var torrentCount: Int + public var favoriteTagIndex: Int? + public var favoriteTagName: String? } extension GalleryDetail: DateFormattable { diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift index 6b2d28b2f..783a9e912 100644 --- a/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift @@ -16,14 +16,28 @@ extension Array where Element == GalleryHistoryEntry { /// fills in a previously-missing token, and preserves any saved reading progress. Inserts a /// fresh entry when the gallery is new to the history. A non-numeric gid, or a new gallery with /// no token, is rejected so the persisted list never accumulates unresolvable junk. - public mutating func recordGalleryOpen(gid: String, token: String, date: Date) { + public mutating func recordGalleryOpen( + gid: String, + token: String, + date: Date, + hasRated: Bool = false, + favoriteTagIndex: Int? = nil, + favoriteTagName: String? = nil + ) { guard Int(gid) != nil else { return } let existing = first { $0.gid == gid } guard existing != nil || !token.isEmpty else { return } - var entry = existing ?? GalleryHistoryEntry(gid: gid, token: token, lastOpenDate: date) + var entry = existing ?? GalleryHistoryEntry( + gid: gid, token: token, lastOpenDate: date, + hasRated: hasRated, favoriteTagIndex: favoriteTagIndex, + favoriteTagName: favoriteTagName + ) removeAll { $0.gid == gid } entry.lastOpenDate = date if entry.token.isEmpty { entry.token = token } + entry.hasRated = hasRated + entry.favoriteTagIndex = favoriteTagIndex + entry.favoriteTagName = favoriteTagName insert(entry, at: 0) } diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift index 71a1e2f08..40e98dae2 100644 --- a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift @@ -19,12 +19,18 @@ public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable, S gid: String, token: String, lastOpenDate: Date, - readingProgress: Int = 0 + readingProgress: Int = 0, + hasRated: Bool = false, + favoriteTagIndex: Int? = nil, + favoriteTagName: String? = nil ) { self.gid = gid self.token = token self.lastOpenDate = lastOpenDate self.readingProgress = readingProgress + self.hasRated = hasRated + self.favoriteTagIndex = favoriteTagIndex + self.favoriteTagName = favoriteTagName } public var id: String { gid } /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. @@ -44,6 +50,9 @@ public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable, S public var token: String public var lastOpenDate: Date public var readingProgress: Int + public var hasRated: Bool = false + public var favoriteTagIndex: Int? + public var favoriteTagName: String? } // MARK: Manually decode @@ -55,6 +64,9 @@ extension GalleryHistoryEntry { token = try container.decode(String.self, forKey: .token) lastOpenDate = try container.decode(Date.self, forKey: .lastOpenDate) readingProgress = try container.decode(Int.self, forKey: .readingProgress) + hasRated = try container.decodeIfPresent(Bool.self, forKey: .hasRated) ?? false + favoriteTagIndex = try container.decodeIfPresent(Int.self, forKey: .favoriteTagIndex) + favoriteTagName = try container.decodeIfPresent(String.self, forKey: .favoriteTagName) guard !gid.isEmpty, !token.isEmpty else { throw DecodingError.dataCorrupted(.init( codingPath: container.codingPath, diff --git a/AppPackage/Sources/AppTools/Defaults.swift b/AppPackage/Sources/AppTools/Defaults.swift index 829939211..4f1f38243 100644 --- a/AppPackage/Sources/AppTools/Defaults.swift +++ b/AppPackage/Sources/AppTools/Defaults.swift @@ -1,5 +1,6 @@ import CoreGraphics import Foundation +import SwiftUI public struct Defaults: Sendable { public struct App: Sendable { @@ -53,6 +54,20 @@ public struct Defaults: Sendable { pattern: "(\\S+:\".+?\"|\".+?\"|\\S+:\\S+|\\S+)" ) } + public struct FavoriteColor: Sendable { + public static let colors: [Color] = [ + Color(hex: "9e9e9e"), + Color(hex: "fc4e4e"), + Color(hex: "fcb417"), + Color(hex: "dde500"), + Color(hex: "17b91b"), + Color(hex: "36b940"), + Color(hex: "68c9de"), + Color(hex: "5050d7"), + Color(hex: "9755f5"), + Color(hex: "fe93ff"), + ] + } public struct URL: Sendable { public static let ehentai: Foundation.URL = .init(string: "https://e-hentai.org/").forceUnwrapped public static let exhentai: Foundation.URL = .init(string: "https://exhentai.org/").forceUnwrapped diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift index e2aed90f8..3c5dcab24 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -16,6 +16,8 @@ extension DetailReducer { return .none case .destination(.presented(.reading(.onPerformDismiss))): + @Shared(.galleryHistory) var galleryHistory + state.readingProgress = galleryHistory.readingProgress(gid: state.gallery.id) return .send(.destination(.dismiss)) case .destination: @@ -85,6 +87,8 @@ extension DetailReducer { state.hasLoadedDownloadBadge = false state.didRunLaunchAutomation = false state.localPreviewURLs = .init() + @Shared(.galleryHistory) var galleryHistory + state.readingProgress = galleryHistory.readingProgress(gid: state.gallery.id) // The gallery is already seeded from the pushing context, so we record the visit and fetch // the (always network-sourced) detail directly. return .merge( @@ -151,7 +155,14 @@ extension DetailReducer { case .saveGalleryHistory: @Shared(.galleryHistory) var galleryHistory $galleryHistory.withLock { - $0.recordGalleryOpen(gid: state.gallery.id, token: state.gallery.token, date: date.now) + $0.recordGalleryOpen( + gid: state.gallery.id, + token: state.gallery.token, + date: date.now, + hasRated: state.gallery.hasRated, + favoriteTagIndex: state.gallery.favoriteTagIndex, + favoriteTagName: state.gallery.favoriteTagName + ) } return .none diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift index dca06ef1d..46b75ebcd 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift @@ -110,26 +110,55 @@ extension DetailReducer { apiuid: apiuid, apikey: apiKey, gid: gid, token: token, rating: rating ).response() - await send(.anyGalleryOpsDone(response)) + await send(.rateGalleryDone(response)) } .cancellable(id: CancelID.rateGallery(state.cancellationGalleryID)) + case .rateGalleryDone(let result): + if case .success = result { + state.gallery.hasRated = true + } + return .send(.anyGalleryOpsDone(result)) + case .favorGallery(let favIndex): return .run { [gid = state.gallery.id, token = state.gallery.token] send in let response = await FavorGalleryRequest( gid: gid, token: token, favIndex: favIndex ).response() - await send(.anyGalleryOpsDone(response)) + await send(.favorGalleryDone(response, favIndex: favIndex)) } .cancellable(id: CancelID.favorGallery(state.cancellationGalleryID)) + case .favorGalleryDone(let result, let favIndex): + if case .success = result { + state.gallery.favoriteTagIndex = favIndex + if var detail = state.galleryDetail { + detail.favoriteTagIndex = favIndex + detail.favoriteTagName = state.user.getFavoriteCategory(index: favIndex) + state.galleryDetail = detail + } + } + return .send(.anyGalleryOpsDone(result)) + case .unfavorGallery: return .run { [galleryID = state.gallery.id] send in let response = await UnfavorGalleryRequest(gid: galleryID).response() - await send(.anyGalleryOpsDone(response)) + await send(.unfavorGalleryDone(response)) } .cancellable(id: CancelID.unfavorGallery(state.cancellationGalleryID)) + case .unfavorGalleryDone(let result): + if case .success = result { + state.gallery.favoriteTagIndex = nil + state.gallery.favoriteTagName = nil + if var detail = state.galleryDetail { + detail.favoriteTagIndex = nil + detail.favoriteTagName = nil + state.galleryDetail = detail + } + } + return .send(.anyGalleryOpsDone(result)) + case .postComment(let galleryURL): guard !state.commentContent.isEmpty else { return .none } return .run { [commentContent = state.commentContent] send in diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift index f8dfda05a..db01e03df 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -84,6 +84,7 @@ public struct DetailReducer: Sendable { public var downloadFolders = [String]() public var isPreparingDownload = false public var hasLoadedDownloadBadge = false + public var readingProgress = 0 var cancellationGalleryID: String { gid.isEmpty ? gallery.id : gid @@ -169,8 +170,11 @@ public struct DetailReducer: Sendable { case fetchVersionMetadataIfNeeded case fetchVersionMetadataDone(Result) case rateGallery + case rateGalleryDone(Result) case favorGallery(Int) + case favorGalleryDone(Result, favIndex: Int) case unfavorGallery + case unfavorGalleryDone(Result) case postComment(URL) case voteTag(String, Int) case anyGalleryOpsDone(Result) diff --git a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift index de9ef4679..a3bed9b43 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift @@ -27,6 +27,7 @@ struct HeaderSection: View { let createDefaultFolderAction: () -> Void let favorAction: (Int) -> Void let unfavorAction: () -> Void + let readingProgress: Int let navigateReadingAction: () -> Void let navigateUploaderAction: () -> Void @@ -63,6 +64,33 @@ struct HeaderSection: View { .lineLimit(1) .minimumScaleFactor(0.72) } + private var favoriteColor: Color { + let colors = Defaults.FavoriteColor.colors + + func color(for index: Int?) -> Color? { + guard let index, + index >= 0, + index < colors.count + else { + return nil + } + return colors[index] + } + + if let color = color(for: galleryDetail.favoriteTagIndex ?? gallery.favoriteTagIndex) { + return color + } + + if let favoriteTagName = galleryDetail.favoriteTagName, + let favoriteCategories = user.favoriteCategories, + let index = favoriteCategories.first(where: { $0.value == favoriteTagName })?.key, + let color = color(for: index) + { + return color + } + + return Color.accentColor + } private var downloadButton: some View { Group { if let progress = activeDownloadProgress { @@ -158,6 +186,12 @@ struct HeaderSection: View { .frame(width: actionIconButtonSize, height: actionIconButtonSize) } .opacity(galleryDetail.isFavorited ? 1 : 0) + .foregroundStyle(favoriteColor) + .contextMenu { + ForEach(0..<10) { index in + Button(user.getFavoriteCategory(index: index)) { favorAction(index) } + } + } Menu { ForEach(0..<10) { index in Button(user.getFavoriteCategory(index: index)) { favorAction(index) } @@ -168,21 +202,32 @@ struct HeaderSection: View { .frame(width: actionIconButtonSize, height: actionIconButtonSize) } .opacity(galleryDetail.isFavorited ? 0 : 1) + .foregroundStyle(.tint) } - .foregroundStyle(.tint) .buttonStyle(.glass(.regular.interactive())) .buttonBorderShape(.circle) .disabled(!CookieUtil.didLogin) } private var readButton: some View { Button(action: navigateReadingAction) { - Image(systemSymbol: .bookFill) - .font(actionIconFont) - .foregroundStyle(.white) - .frame(width: actionIconButtonSize, height: actionIconButtonSize) + Group { + if readingProgress > 0 { + Text("P\(readingProgress)") + .bold().textCase(.uppercase).font(.headline) + .foregroundColor(.white).padding(.vertical, -2) + .padding(.horizontal, 2).lineLimit(1) + .minimumScaleFactor(0.7) + .frame(minWidth: 50) + } else { + Image(systemSymbol: .bookFill) + .font(actionIconFont) + .foregroundStyle(.white) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + } } .buttonStyle(.glassProminent) - .buttonBorderShape(.circle) + .buttonBorderShape(readingProgress > 0 ? .capsule : .circle) .accessibilityLabel(.read) } private func progressIndicator( diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index 91ab02f1b..07030d5e8 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -47,7 +47,11 @@ struct DescriptionSection: View { ForEach(infos) { info in Group { if info.isRating { - DescScrollRatingItem(title: info.title, rating: info.rating) + DescScrollRatingItem( + title: info.title, + rating: info.rating, + userRating: galleryDetail.userRating + ) } else { DescScrollItem(title: info.title, value: info.value, description: info.description) } @@ -94,12 +98,23 @@ extension DescriptionSection { struct DescScrollRatingItem: View { let title: LocalizedStringResource let rating: Float + let userRating: Float + + private var ratingColor: Color { + guard userRating > 0 else { return .primary } + switch userRating { + case 1..<2.5: return .red + case 2.5..<4.5: return .green + case 4.5...5: return .blue + default: return .primary + } + } var body: some View { VStack(spacing: 3) { Text(title).textCase(.uppercase).font(.caption).lineLimit(1) Text(String(format: "%.2f", rating)).fontWeight(.medium).font(.title3) - RatingView(rating: rating).font(.system(size: 12)).foregroundStyle(.primary) + RatingView(rating: rating).font(.system(size: 12)).foregroundStyle(ratingColor) } } } @@ -115,6 +130,15 @@ struct ActionSection: View { let confirmRatingAction: (DragGesture.Value) -> Void let navigateSimilarGalleryAction: () -> Void + private func ratingColor(for rating: Float) -> Color { + switch rating { + case 1..<2.5: return .red + case 2.5..<4.5: return .green + case 4.5...5: return .blue + default: return .yellow + } + } + var body: some View { VStack { HStack { @@ -139,7 +163,7 @@ struct ActionSection: View { HStack { RatingView(rating: Float(userRating) / 2) .font(.system(size: 24)) - .foregroundStyle(.yellow) + .foregroundStyle(userRating > 0 ? ratingColor(for: Float(userRating) / 2) : .yellow) .gesture( DragGesture(minimumDistance: 0) .onChanged(updateRatingAction) diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift index 6bbe3e13b..6e16ccac4 100644 --- a/AppPackage/Sources/DetailFeature/DetailView.swift +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -77,6 +77,7 @@ private extension DetailView { createDefaultFolderAction: { store.send(.createDefaultFolder) }, favorAction: { store.send(.favorGallery($0)) }, unfavorAction: { store.send(.unfavorGallery) }, + readingProgress: store.readingProgress, navigateReadingAction: { store.send(.openReading) }, navigateUploaderAction: { if let uploader = store.galleryDetail?.uploader { diff --git a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift index 258d94357..f0af1eddd 100644 --- a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift @@ -79,13 +79,17 @@ private struct GalleryDetailCellContent: View { colorScheme == .light ? Color(.systemGray5) : Color(.systemGray4) } + private var displayTitle: String { + setting.displaysJapaneseTitle ? gallery.titleJpn ?? gallery.title : gallery.title + } + var body: some View { HStack(spacing: 10) { KFImage(resolvedCoverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.rowAspect)) } .defaultModifier().scaledToFit().frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH) VStack(alignment: .leading, spacing: 5) { - Text(gallery.title) + Text(displayTitle) .lineLimit(downloadBadge == nil ? 3 : 2) .font(.headline) .foregroundStyle(.primary) @@ -119,10 +123,16 @@ private struct GalleryDetailCellContent: View { } } HStack { - RatingView(rating: gallery.rating).font(.caption).foregroundStyle(.yellow) + let ratingColor: Color = gallery.hasRated ? .green : .yellow + RatingView(rating: gallery.rating).font(.caption).foregroundStyle(ratingColor) Spacer(minLength: 8) + if gallery.isFavorite, let index = gallery.favoriteTagIndex { + Image(systemSymbol: .heartFill) + .foregroundStyle(Defaults.FavoriteColor.colors[index]) + } + if let downloadBadge { DownloadBadgeLabel(badge: downloadBadge) } else { @@ -138,6 +148,7 @@ private struct GalleryDetailCellContent: View { Spacer() Text(gallery.formattedDateString).lineLimit(1).font(.footnote) .foregroundStyle(.secondary).minimumScaleFactor(0.75) + .strikethrough(gallery.isExpunged) } .padding(.top, 1) } diff --git a/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift index f320d8004..b5f80e9b6 100644 --- a/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift @@ -31,6 +31,9 @@ public struct GalleryThumbnailCell: View { private var tagColor: Color { colorScheme == .light ? Color(.systemGray5) : Color(.systemGray4) } + private var displayTitle: String { + setting.displaysJapaneseTitle ? gallery.titleJpn ?? gallery.title : gallery.title + } public var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -52,7 +55,7 @@ public struct GalleryThumbnailCell: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) } VStack(alignment: .leading, spacing: 5) { - Text(gallery.title) + Text(displayTitle) .font(.callout.bold()) .lineLimit(downloadBadge == nil ? 3 : 2) let tagContents = gallery.tagContents(maximum: setting.listTagsNumberMaximum) @@ -70,6 +73,11 @@ public struct GalleryThumbnailCell: View { } } HStack(spacing: 10) { + if gallery.isFavorite, let index = gallery.favoriteTagIndex { + Image(systemSymbol: .heartFill) + .foregroundStyle(Defaults.FavoriteColor.colors[index]) + } + if let downloadBadge { DownloadBadgeLabel(badge: downloadBadge) } else { @@ -87,7 +95,8 @@ public struct GalleryThumbnailCell: View { } .lineLimit(1).font(.footnote).foregroundStyle(.secondary) - RatingView(rating: gallery.rating).foregroundColor(.yellow).font(.caption) + let ratingColor: Color = gallery.hasRated ? .green : .yellow + RatingView(rating: gallery.rating).foregroundColor(ratingColor).font(.caption) } .padding() } diff --git a/AppPackage/Sources/HomeFeature/GalleryCardCell.swift b/AppPackage/Sources/HomeFeature/GalleryCardCell.swift index b92003770..a9265a38b 100644 --- a/AppPackage/Sources/HomeFeature/GalleryCardCell.swift +++ b/AppPackage/Sources/HomeFeature/GalleryCardCell.swift @@ -5,9 +5,11 @@ import Colorful import Kingfisher import UIImageColors import AppTools +import Sharing public struct GalleryCardCell: View { @Environment(\.colorScheme) private var colorScheme + @SharedReader(.setting) private var setting: Setting private let currentID: String private let colors: [Color] @@ -33,11 +35,16 @@ public struct GalleryCardCell: View { return gallery.gid == currentID } private var title: String { - let trimmedTitle = gallery.trimmedTitle - guard !DeviceUtil.isPad, trimmedTitle.count > 20 else { - return gallery.title + let rawTitle = setting.displaysJapaneseTitle ? gallery.titleJpn ?? gallery.title : gallery.title + var trimmed = rawTitle + if let range = trimmed.range(of: "|") { + trimmed = String(trimmed[.. 20 else { + return rawTitle + } + return trimmed } public var body: some View { diff --git a/AppPackage/Sources/HomeFeature/GalleryRankingCell.swift b/AppPackage/Sources/HomeFeature/GalleryRankingCell.swift index 0dc680a0a..171c883c5 100644 --- a/AppPackage/Sources/HomeFeature/GalleryRankingCell.swift +++ b/AppPackage/Sources/HomeFeature/GalleryRankingCell.swift @@ -3,8 +3,10 @@ import SwiftUI import AppModels import AppComponents import Kingfisher +import Sharing public struct GalleryRankingCell: View { + @SharedReader(.setting) private var setting: Setting private let gallery: Gallery private let ranking: Int @@ -13,6 +15,10 @@ public struct GalleryRankingCell: View { self.ranking = ranking } + private var displayTitle: String { + setting.displaysJapaneseTitle ? gallery.titleJpn ?? gallery.trimmedTitle : gallery.trimmedTitle + } + public var body: some View { HStack { KFImage(gallery.coverURL) @@ -21,7 +27,7 @@ public struct GalleryRankingCell: View { .cornerRadius(2) Text(String(ranking)).fontWeight(.medium).font(.title2).padding(.horizontal) VStack(alignment: .leading) { - Text(gallery.trimmedTitle).bold().lineLimit(2).fixedSize(horizontal: false, vertical: true) + Text(displayTitle).bold().lineLimit(2).fixedSize(horizontal: false, vertical: true) if let uploader = gallery.uploader { Text(uploader).foregroundColor(.secondary).lineLimit(1) } diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index 2e2a2e5f6..d78561535 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -42,7 +42,10 @@ public struct HistoryReducer: Sendable { var filteredGalleries: [Gallery] { guard !keyword.isEmpty else { return galleries } - return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) + return galleries.filter { gallery in + gallery.title.caseInsensitiveContains(keyword) + || (gallery.titleJpn?.caseInsensitiveContains(keyword) ?? false) + } } public var galleries = [Gallery]() public var loadingState: LoadingState = .idle @@ -78,6 +81,18 @@ public struct HistoryReducer: Sendable { public init() {} + private func mergeHistoryMetadata(_ galleries: [Gallery], entries: [GalleryHistoryEntry]) -> [Gallery] { + let byGID = Dictionary(grouping: entries, by: \.gid) + return galleries.map { gallery in + guard let entry = byGID[gallery.gid]?.first else { return gallery } + var merged = gallery + merged.hasRated = entry.hasRated + merged.favoriteTagIndex = entry.favoriteTagIndex + merged.favoriteTagName = entry.favoriteTagName + return merged + } + } + public var body: some Reducer { BindingReducer() @@ -147,9 +162,7 @@ public struct HistoryReducer: Sendable { switch result { case .success(let galleries): state.fetchedCount = endIndex - state.galleries = galleries - // Whole first page unresolved but more history remains: page on so the list isn't - // stuck empty with no cell to trigger the footer. + state.galleries = mergeHistoryMetadata(galleries, entries: state.galleryHistory) if galleries.isEmpty { if state.hasMoreHistory { return .send(.fetchMoreGalleries) @@ -181,7 +194,7 @@ public struct HistoryReducer: Sendable { switch result { case .success(let galleries): state.fetchedCount = endIndex - state.galleries.append(contentsOf: galleries) + state.galleries.append(contentsOf: mergeHistoryMetadata(galleries, entries: state.galleryHistory)) // This page was entirely unresolved; continue so paging doesn't stall mid-list. if galleries.isEmpty && state.hasMoreHistory { return .send(.fetchMoreGalleries) diff --git a/AppPackage/Sources/NetworkingFeature/Request+Detail.swift b/AppPackage/Sources/NetworkingFeature/Request+Detail.swift index 85d084eab..19f0cdd8d 100644 --- a/AppPackage/Sources/NetworkingFeature/Request+Detail.swift +++ b/AppPackage/Sources/NetworkingFeature/Request+Detail.swift @@ -163,7 +163,11 @@ public struct GalleryReverseRequest: Request { pageCount: detail.pageCount, postedDate: detail.postedDate, coverURL: detail.coverURL, - galleryURL: url + galleryURL: url, + isExpunged: false, + hasRated: false, + favoriteTagIndex: detail.favoriteTagIndex, + favoriteTagName: detail.favoriteTagName ) } else { return nil diff --git a/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift index 5e7c21bd5..792b1c71a 100644 --- a/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift +++ b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift @@ -19,6 +19,7 @@ private struct GalleryMetadata: Decodable { let token: String? let error: String? let title: String? + let titleJpn: String? let category: String? let thumb: String? let uploader: String? @@ -26,10 +27,25 @@ private struct GalleryMetadata: Decodable { let filecount: String? let rating: String? let tags: [String]? + let expunged: Bool? + + enum CodingKeys: String, CodingKey { + case gid + case token + case error + case title + case titleJpn = "title_jpn" + case category + case thumb + case uploader + case posted + case filecount + case rating + case tags + case expunged + } var gallery: Gallery? { - // Match the HTML list parser: a resolved row needs its full display set, or it is dropped. - // Only `tags` and `uploader` stay tolerant. guard error == nil, let token, let title, @@ -43,6 +59,7 @@ private struct GalleryMetadata: Decodable { gid: String(gid), token: token, title: title.htmlEntitiesDecoded, + titleJpn: titleJpn?.htmlEntitiesDecoded, rating: rating, tags: Self.parseTags(tags ?? []), category: category, @@ -53,7 +70,8 @@ private struct GalleryMetadata: Decodable { galleryURL: Defaults.URL.host .appendingPathComponent("g") .appendingPathComponent(String(gid)) - .appendingPathComponent(token) + .appendingPathComponent(token), + isExpunged: expunged ?? false ) } diff --git a/AppPackage/Sources/ParserFeature/Parser+Detail.swift b/AppPackage/Sources/ParserFeature/Parser+Detail.swift index a5e6e7454..26eb93dae 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Detail.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Detail.swift @@ -43,6 +43,11 @@ extension Parser { let isFavorited = gdfNode .at_xpath("//a [@id='favoritelink']")? .text?.contains("Add to Favorites") == false + var favoriteTagIndex: Int? + var favoriteTagName: String? + if let favoritelink = gdfNode.at_xpath(".//a[@id='favoritelink']") { + favoriteTagName = favoritelink.text + } let gjText = link.at_xpath("//h1 [@id='gj']")?.text let jpnTitle = gjText?.isEmpty != false ? nil : gjText let parentURLString = infoPanel[1].isValidURL ? infoPanel[1] : "" @@ -67,7 +72,9 @@ extension Parser { pageCount: pageCount, sizeCount: sizeCount, sizeType: infoPanel[5], - torrentCount: arcAndTor.1 + torrentCount: arcAndTor.1, + favoriteTagIndex: favoriteTagIndex, + favoriteTagName: favoriteTagName ) tmpGalleryState = GalleryState( gid: gid, diff --git a/AppPackage/Sources/ParserFeature/Parser+List.swift b/AppPackage/Sources/ParserFeature/Parser+List.swift index be68903cf..86196d8ca 100644 --- a/AppPackage/Sources/ParserFeature/Parser+List.swift +++ b/AppPackage/Sources/ParserFeature/Parser+List.swift @@ -60,6 +60,7 @@ private extension Parser { let panelInfo = try? parseThumbnailPanel(node: gl2mNode), let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3mNode) else { continue } + let isExpunged = parseIsExpunged(for: link) galleries.append( .init( gid: galleryURL.pathComponents[2], @@ -72,7 +73,11 @@ private extension Parser { pageCount: panelInfo.pageCount, postedDate: panelInfo.publishedDate, coverURL: panelInfo.coverURL, - galleryURL: galleryURL + galleryURL: galleryURL, + isExpunged: isExpunged, + hasRated: panelInfo.hasRated, + favoriteTagIndex: panelInfo.favoriteTagIndex, + favoriteTagName: panelInfo.favoriteTagName ) ) } @@ -87,6 +92,7 @@ private extension Parser { let panelInfo = try? parseThumbnailPanel(node: gl2cNode), let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3cNode) else { continue } + let isExpunged = parseIsExpunged(for: link) galleries.append( .init( gid: galleryURL.pathComponents[2], @@ -99,7 +105,11 @@ private extension Parser { pageCount: panelInfo.pageCount, postedDate: panelInfo.publishedDate, coverURL: panelInfo.coverURL, - galleryURL: galleryURL + galleryURL: galleryURL, + isExpunged: isExpunged, + hasRated: panelInfo.hasRated, + favoriteTagIndex: panelInfo.favoriteTagIndex, + favoriteTagName: panelInfo.favoriteTagName ) ) } @@ -114,6 +124,7 @@ private extension Parser { let panelInfo = try? parseThumbnailPanel(node: link), let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3eSiblingNode) else { continue } + let isExpunged = parseIsExpunged(for: link) galleries.append( .init( gid: galleryURL.pathComponents[2], @@ -126,7 +137,11 @@ private extension Parser { pageCount: panelInfo.pageCount, postedDate: panelInfo.publishedDate, coverURL: panelInfo.coverURL, - galleryURL: galleryURL + galleryURL: galleryURL, + isExpunged: isExpunged, + hasRated: panelInfo.hasRated, + favoriteTagIndex: panelInfo.favoriteTagIndex, + favoriteTagName: panelInfo.favoriteTagName ) ) } @@ -140,6 +155,7 @@ private extension Parser { guard let panelInfo = try? parseThumbnailPanel(node: link), let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: link) else { continue } + let isExpunged = parseIsExpunged(for: link) galleries.append( .init( gid: galleryURL.pathComponents[2], @@ -151,7 +167,11 @@ private extension Parser { pageCount: panelInfo.pageCount, postedDate: panelInfo.publishedDate, coverURL: panelInfo.coverURL, - galleryURL: galleryURL + galleryURL: galleryURL, + isExpunged: isExpunged, + hasRated: panelInfo.hasRated, + favoriteTagIndex: panelInfo.favoriteTagIndex, + favoriteTagName: panelInfo.favoriteTagName ) ) } @@ -167,6 +187,9 @@ private extension Parser { var tmpPublishedDate: Date? var tmpPageCount: Int? var uploader: String? + var tmpHasRated = false + var tmpFavoriteTagIndex: Int? + var tmpFavoriteTagName: String? for div in node.xpath("//div") { if let imgNode = div.at_css("img"), @@ -186,7 +209,6 @@ private extension Parser { ["page", "pages"].contains(components[1]), let pageCount = Int(components[0]) { tmpPageCount = pageCount } - // Extended display mode uses this if let aLink = div.at_xpath("//a"), aLink["href"]?.contains("uploader") == true { uploader = aLink.text } else if div.text == "(Disowned)" { @@ -200,13 +222,24 @@ private extension Parser { let publishedDate = tmpPublishedDate, let pageCount = tmpPageCount else { throw AppError.parseFailed } + + let hasRated = node.xpath(".//div[contains(@class, 'ir')]").contains { + guard let classes = $0.className?.split(separator: " ") else { return false } + return classes.contains("irb") + } + + let (favoriteTagIndex, favoriteTagName) = parseFavoriteInfo(for: node) + return ThumbnailPanelInfo( coverURL: coverURL, category: category, rating: ratingResult.imgRating, publishedDate: publishedDate, pageCount: pageCount, - uploader: uploader + uploader: uploader, + hasRated: hasRated, + favoriteTagIndex: favoriteTagIndex, + favoriteTagName: favoriteTagName ) } @@ -301,4 +334,32 @@ private extension Parser { guard let uploader = tmpUploader else { throw AppError.parseFailed } return uploader } + + static func parseIsExpunged(for node: XMLElement) -> Bool { + node.xpath(".//s").count > 0 + } + + static func parseFavoriteInfo(for node: XMLElement) -> (tagIndex: Int?, tagName: String?) { + guard let postedDiv = node.at_xpath( + ".//div[@id][@style][@title][contains(@onclick, 'popUp')]" + ) else { return (nil, nil) } + let title = postedDiv["title"] + let style = postedDiv["style"] ?? "" + let color = extractBorderColor(from: style) + let tagIndex = color.flatMap { favoriteTagIndexMap[$0] } + return (tagIndex, title) + } + + static func extractBorderColor(from style: String) -> String? { + guard let rangeA = style.range(of: "border-color:#"), + let rangeB = style.range(of: ";") else { return nil } + let color = String(style[rangeA.upperBound.. Date: Thu, 13 Aug 2026 18:17:22 +0800 Subject: [PATCH 608/614] feat(ui): implement highlight for watched tags --- .../AppFeature/DataFlow/AppReducer.swift | 34 +++++ .../AppModels/Gallery/GalleryState.swift | 6 +- .../Support/WatchedTagsSetting.swift | 129 ++++++++++++++++++ .../Sources/AppModels/Tags/WatchedTag.swift | 74 ++++++++++ AppPackage/Sources/AppTools/Extensions.swift | 10 ++ .../DetailFeature/DetailReducer+Fetch.swift | 5 +- .../DetailFeature/DetailView+Subviews.swift | 5 +- .../HomeFeature/History/HistoryReducer.swift | 24 +++- .../NetworkingFeature/Request+MyTags.swift | 42 ++++++ .../Sources/ParserFeature/Parser+MyTags.swift | 81 +++++++++++ .../SettingFeature/SettingReducer+Body.swift | 2 + 11 files changed, 401 insertions(+), 11 deletions(-) create mode 100644 AppPackage/Sources/AppModels/Support/WatchedTagsSetting.swift create mode 100644 AppPackage/Sources/AppModels/Tags/WatchedTag.swift create mode 100644 AppPackage/Sources/NetworkingFeature/Request+MyTags.swift create mode 100644 AppPackage/Sources/ParserFeature/Parser+MyTags.swift diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift index 097b7b82b..4ec8043b4 100644 --- a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -13,6 +13,8 @@ import FavoritesFeature import DownloadsFeature import SettingFeature import OSLogExt +import NetworkingFeature +import AppModels private let logger = Logger(category: .init(describing: AppReducer.self)) @@ -40,6 +42,8 @@ struct AppReducer { case binding(BindingAction) case onScenePhaseChange(ScenePhase) case runLaunchAutomation + case fetchWatchedTags + case fetchWatchedTagsDone(Result, apiuid: String) case appDelegate(AppDelegateReducer.Action) case appRoute(AppRouteReducer.Action) @@ -92,6 +96,15 @@ struct AppReducer { .send(.appLogsPump(.startPump)), .run { _ in logger.notice("App entered foreground.") } ] + if !cookieClient.apiuid.isEmpty { + effects.append( + .run { send in + if await WatchedTagsSetting.shared.needsRefresh(apiuid: cookieClient.apiuid) { + await send(.fetchWatchedTags) + } + } + ) + } // iOS interposes .inactive on a foreground return // (.background -> .inactive -> .active), so the previous // phase is never .background here. Latch the background @@ -150,6 +163,27 @@ struct AppReducer { } } + case .fetchWatchedTags: + return .run { send in + let apiuid = cookieClient.apiuid + let result = await MyTagsRequest().response() + await send(.fetchWatchedTagsDone(result, apiuid: apiuid)) + } + + case .fetchWatchedTagsDone(.success(let response), apiuid: let apiuid): + let tagSet = TagSetInfo( + number: 1, + name: "", + enable: true, + backgroundColor: response.tagSetBackgroundColor, + tags: response.tags + ) + return .run { _ in + await WatchedTagsSetting.shared.updateTagSet(tagSet, apiuid: apiuid) + } + case .fetchWatchedTagsDone(.failure(let error), apiuid: _): + return .none + case .appDelegate(.onLaunchFinish): // Import any launch-automation cookies and load the persisted settings straight away. let loginCookies = appLaunchAutomationClient.current()?.loginCookies diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryState.swift b/AppPackage/Sources/AppModels/Gallery/GalleryState.swift index 31ae7aacf..2dbc22fac 100644 --- a/AppPackage/Sources/AppModels/Gallery/GalleryState.swift +++ b/AppPackage/Sources/AppModels/Gallery/GalleryState.swift @@ -96,8 +96,8 @@ public struct GalleryTag: Codable, Equatable, Hashable, Identifiable, Sendable { public let text: String public let isVotedUp: Bool public let isVotedDown: Bool - public let textColor: Color? - public let backgroundColor: Color? + public var textColor: Color? + public var backgroundColor: Color? } public var id: String { rawNamespace } @@ -106,7 +106,7 @@ public struct GalleryTag: Codable, Equatable, Hashable, Identifiable, Sendable { } public let rawNamespace: String - public let contents: [Content] + public var contents: [Content] } public enum PreviewConfig: Codable, Equatable, Sendable { diff --git a/AppPackage/Sources/AppModels/Support/WatchedTagsSetting.swift b/AppPackage/Sources/AppModels/Support/WatchedTagsSetting.swift new file mode 100644 index 000000000..3342e99ae --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/WatchedTagsSetting.swift @@ -0,0 +1,129 @@ +import Foundation +import SwiftUI +import AppTools +import Synchronization + +final class TagLookupCache: Sendable { + private let cache: Mutex<[String: (tagSetBackgroundColor: Color?, tag: WatchedTag)]> + + init() { + cache = Mutex([:]) + } + + var isEmpty: Bool { + cache.withLock { $0.isEmpty } + } + + var allEntries: [String: (tagSetBackgroundColor: Color?, tag: WatchedTag)] { + cache.withLock { $0 } + } + + func update(_ newCache: [String: (tagSetBackgroundColor: Color?, tag: WatchedTag)]) { + cache.withLock { $0 = newCache } + } + + func removeAll() { + cache.withLock { $0.removeAll() } + } +} + +public actor WatchedTagsSetting { + public static let shared = WatchedTagsSetting() + + private var onlineTags: [Int: TagSetInfo] = [:] + private var lastFetchedApuid: String = "" + private var lastRefreshTimestamp: Date = .distantPast + private let lookupCache = TagLookupCache() + + private init() {} + + public func updateTagSet(_ tagSet: TagSetInfo, apiuid: String) { + onlineTags[tagSet.number] = tagSet + lastFetchedApuid = apiuid + lastRefreshTimestamp = Date() + rebuildLookupCache() + } + + public func updateTagSet(_ tagSet: TagSetInfo) { + onlineTags[tagSet.number] = tagSet + rebuildLookupCache() + } + + private func rebuildLookupCache() { + var lookup: [String: (Color?, WatchedTag)] = [:] + for tagSetInfo in onlineTags.values { + for tag in tagSetInfo.tags { + let key = "\(tag.namespace):\(tag.key)" + lookup[key] = (tagSetInfo.backgroundColor, tag) + } + } + lookupCache.update(lookup) + } + + public func buildTagLookup() -> [String: (tagSetBackgroundColor: Color?, tag: WatchedTag)] { + if !lookupCache.isEmpty { return lookupCache.allEntries } + var lookup: [String: (Color?, WatchedTag)] = [:] + for tagSetInfo in onlineTags.values { + for tag in tagSetInfo.tags { + let key = "\(tag.namespace):\(tag.key)" + lookup[key] = (tagSetInfo.backgroundColor, tag) + } + } + lookupCache.update(lookup) + return lookup + } + + public nonisolated var cachedTagLookup: [String: (tagSetBackgroundColor: Color?, tag: WatchedTag)] { + lookupCache.allEntries + } + + public func clearOnlineTagSets() { + onlineTags.removeAll() + lookupCache.removeAll() + lastFetchedApuid = "" + lastRefreshTimestamp = .distantPast + } + + public func needsRefresh(apiuid: String, maxAge: TimeInterval = 3600) -> Bool { + return onlineTags.isEmpty || lastFetchedApuid != apiuid || isStale(maxAge: maxAge) + } + + public func isStale(maxAge: TimeInterval = 3600) -> Bool { + return Date().timeIntervalSince(lastRefreshTimestamp) > maxAge + } + + public func allTagSets() -> [TagSetInfo] { + Array(onlineTags.values).sorted { $0.number < $1.number } + } + + public static func applyWatchedTagColors(to tags: [GalleryTag]) -> [GalleryTag] { + let lookup = shared.cachedTagLookup + var recolored = tags + for tagIndex in recolored.indices { + var tag = recolored[tagIndex] + var contents = tag.contents + for contentIndex in contents.indices { + var content = contents[contentIndex] + if content.textColor != nil || content.backgroundColor != nil { + contents[contentIndex] = content + continue + } + let lookupKey = "\(content.rawNamespace):\(content.text)" + if let result = lookup[lookupKey] { + let backgroundColor = result.tag.backgroundColor ?? result.tagSetBackgroundColor + content.backgroundColor = backgroundColor ?? Color(hex: "3377FF") + let resolvedBackground = content.backgroundColor + content.textColor = backgroundColor == nil + ? Color(hex: "F1F1F1") + : (resolvedBackground?.isLightColor ?? false + ? Color(red: 0.035, green: 0.035, blue: 0.035) + : Color(hex: "F1F1F1")) + } + contents[contentIndex] = content + } + tag.contents = contents + recolored[tagIndex] = tag + } + return recolored + } +} diff --git a/AppPackage/Sources/AppModels/Tags/WatchedTag.swift b/AppPackage/Sources/AppModels/Tags/WatchedTag.swift new file mode 100644 index 000000000..6b2f6cee4 --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/WatchedTag.swift @@ -0,0 +1,74 @@ +import SwiftUI +import Foundation + +public struct MyTagsResponse: Sendable { + public let tagSets: [(number: Int, name: String)] + public let tagSetEnable: Bool + public let tagSetBackgroundColor: Color? + public let tags: [WatchedTag] + public let apikey: String + + public init( + tagSets: [(number: Int, name: String)], + tagSetEnable: Bool, + tagSetBackgroundColor: Color?, + tags: [WatchedTag], + apikey: String + ) { + self.tagSets = tagSets + self.tagSetEnable = tagSetEnable + self.tagSetBackgroundColor = tagSetBackgroundColor + self.tags = tags + self.apikey = apikey + } +} + +public struct WatchedTag: Identifiable, Equatable, Hashable, Sendable, Codable { + public init( + namespace: String, + key: String, + watched: Bool = true, + hidden: Bool = false, + backgroundColor: Color? = nil, + weight: Int = 0 + ) { + self.namespace = namespace + self.key = key + self.watched = watched + self.hidden = hidden + self.backgroundColor = backgroundColor + self.weight = weight + } + public let id: String = UUID().uuidString + public var namespace: String + public var key: String + public var watched: Bool + public var hidden: Bool + public var backgroundColor: Color? + public var weight: Int + + enum CodingKeys: String, CodingKey { + case namespace, key, watched, hidden, backgroundColor, weight + } +} + +public struct TagSetInfo: Equatable, Sendable, Codable { + public init( + number: Int, + name: String, + enable: Bool, + backgroundColor: Color? = nil, + tags: [WatchedTag] = [] + ) { + self.number = number + self.name = name + self.enable = enable + self.backgroundColor = backgroundColor + self.tags = tags + } + public let number: Int + public let name: String + public var enable: Bool + public var backgroundColor: Color? + public var tags: [WatchedTag] +} diff --git a/AppPackage/Sources/AppTools/Extensions.swift b/AppPackage/Sources/AppTools/Extensions.swift index 8fc72d209..40e8c9ee3 100644 --- a/AppPackage/Sources/AppTools/Extensions.swift +++ b/AppPackage/Sources/AppTools/Extensions.swift @@ -164,6 +164,16 @@ extension Color { blue: Double(blue) / 255.0, opacity: Double(alpha) / 255.0 ) } + + public var isLightColor: Bool { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + guard UIColor(self).getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return true } + let luminance = 0.299 * Double(red) + 0.587 * Double(green) + 0.114 * Double(blue) + return luminance > 0.55 + } } // MARK: Array diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift index 46b75ebcd..b409c858d 100644 --- a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift @@ -1,6 +1,9 @@ import Foundation +import SwiftUI import ComposableArchitecture import NetworkingFeature +import AppModels +import AppTools // MARK: - Fetch & Gallery Ops Action Handlers extension DetailReducer { @@ -30,7 +33,7 @@ extension DetailReducer { ] state.apiKey = response.apiKey state.galleryDetail = response.galleryDetail - state.galleryTags = response.galleryState.tags + state.galleryTags = WatchedTagsSetting.applyWatchedTagColors(to: response.galleryState.tags) state.galleryPreviewURLs = response.galleryState.previewURLs state.galleryComments = response.galleryState.comments if let config = response.galleryState.previewConfig { diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift index 07030d5e8..25bc8157b 100644 --- a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -241,8 +241,9 @@ extension TagsSection { text: translation?.displayValue ?? content.text, imageURL: translation?.valueImageURL, showsImages: showsImages, - font: .subheadline, padding: padding, textColor: .primary, - backgroundColor: backgroundColor + font: .subheadline, padding: padding, + textColor: content.textColor ?? (content.backgroundColor != nil ? .primary : .primary), + backgroundColor: content.backgroundColor ?? backgroundColor ) } .contextMenu { diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift index d78561535..c569ee8b7 100644 --- a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import AppModels import Sharing import Resources @@ -93,6 +94,16 @@ public struct HistoryReducer: Sendable { } } + private func applyWatchedTagColors(to galleries: [Gallery]) -> [Gallery] { + var recolored = galleries + for galleryIndex in recolored.indices { + var gallery = recolored[galleryIndex] + gallery.tags = WatchedTagsSetting.applyWatchedTagColors(to: gallery.tags) + recolored[galleryIndex] = gallery + } + return recolored + } + public var body: some Reducer { BindingReducer() @@ -160,9 +171,11 @@ public struct HistoryReducer: Sendable { case let .fetchGalleriesDone(result, endIndex): state.loadingState = .idle switch result { - case .success(let galleries): + case .success(var galleries): state.fetchedCount = endIndex - state.galleries = mergeHistoryMetadata(galleries, entries: state.galleryHistory) + galleries = mergeHistoryMetadata(galleries, entries: state.galleryHistory) + galleries = applyWatchedTagColors(to: galleries) + state.galleries = galleries if galleries.isEmpty { if state.hasMoreHistory { return .send(.fetchMoreGalleries) @@ -192,10 +205,11 @@ public struct HistoryReducer: Sendable { case let .fetchMoreGalleriesDone(result, endIndex): state.footerLoadingState = .idle switch result { - case .success(let galleries): + case .success(var galleries): state.fetchedCount = endIndex - state.galleries.append(contentsOf: mergeHistoryMetadata(galleries, entries: state.galleryHistory)) - // This page was entirely unresolved; continue so paging doesn't stall mid-list. + galleries = mergeHistoryMetadata(galleries, entries: state.galleryHistory) + galleries = applyWatchedTagColors(to: galleries) + state.galleries.append(contentsOf: galleries) if galleries.isEmpty && state.hasMoreHistory { return .send(.fetchMoreGalleries) } diff --git a/AppPackage/Sources/NetworkingFeature/Request+MyTags.swift b/AppPackage/Sources/NetworkingFeature/Request+MyTags.swift new file mode 100644 index 000000000..26b3518bd --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Request+MyTags.swift @@ -0,0 +1,42 @@ +import Kanna +import SwiftUI +import AppModels +import Foundation +import AppTools +import Combine +import ParserFeature + +public struct MyTagsRequest: Request { + public init( + tagSetNo: Int = 1, + urlSession: URLSession = .shared, + allowsCellular: Bool = true + ) { + self.tagSetNo = tagSetNo + self.urlSession = urlSession + self.allowsCellular = allowsCellular + } + public let tagSetNo: Int + public let urlSession: URLSession + public let allowsCellular: Bool + + public var publisher: AnyPublisher { + guard var components = URLComponents( + url: Defaults.URL.myTags, resolvingAgainstBaseURL: true + ) else { + return Fail(error: AppError.parseFailed).eraseToAnyPublisher() + } + components.queryItems = [URLQueryItem(name: "tagset", value: String(tagSetNo))] + guard let url = components.url else { + return Fail(error: AppError.parseFailed).eraseToAnyPublisher() + } + return urlSession.dataTaskPublisher( + for: urlRequest(url: url, allowsCellular: allowsCellular) + ) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseMyTagsPage) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/AppPackage/Sources/ParserFeature/Parser+MyTags.swift b/AppPackage/Sources/ParserFeature/Parser+MyTags.swift new file mode 100644 index 000000000..cdb610240 --- /dev/null +++ b/AppPackage/Sources/ParserFeature/Parser+MyTags.swift @@ -0,0 +1,81 @@ +import Kanna +import SwiftUI +import AppModels +import Foundation + +extension Parser { + public static func parseMyTagsPage(doc: HTMLDocument) throws -> MyTagsResponse { + let options = doc.xpath("//*[@id='tagset_outer']/div/select/option") + let tagSets = options.compactMap { option -> (Int, String)? in + guard let valueString = option["value"], let number = Int(valueString) else { return nil } + let name = option.text ?? "" + return (number, name) + } + + let tagSetEnable = doc.at_xpath("//*[@id='tagset_enable']")?["checked"] != nil + + let tagSetBackgroundColor: Color? = { + guard let input = doc.at_xpath("//*[@id='tagcolor']"), + let value = input["value"], + !value.isEmpty + else { + return nil + } + + return Color(hex: value) + }() + + let tagDivs = doc.xpath("//*[@id='usertags_outer']/div") + let tags = tagDivs.compactMap { div -> WatchedTag? in + guard let divId = div["id"], divId != "usertag_0" else { return nil } + let tagId = divId.replacingOccurrences(of: "usertag_", with: "") + + guard let title = div.at_xpath(".//div[@id='tagpreview_\(tagId)']")?["title"] else { return nil } + let pair = title + let list = pair.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false).map(String.init) + let namespace = list.count == 2 && !list[0].isEmpty ? list[0] : "temp" + let key = list.count == 2 ? list[1] : list[0] + + let watched = div.at_xpath(".//input[@id='tagwatch_\(tagId)']")?["checked"] != nil + let hidden = div.at_xpath(".//input[@id='taghide_\(tagId)']")?["checked"] != nil + let backgroundColor: Color? = { + guard let input = div.at_xpath(".//input[@id='tagcolor_\(tagId)']"), + let value = input["value"], + !value.isEmpty + else { + return nil + } + + return Color(hex: value) + }() + + let weightString = div.at_xpath(".//input[@id='tagweight_\(tagId)']")?["value"] ?? "0" + let weight = Int(weightString) ?? 0 + + return WatchedTag( + namespace: namespace, + key: key, + watched: watched, + hidden: hidden, + backgroundColor: backgroundColor, + weight: weight + ) + } + + let scriptText = doc.at_xpath("//*[@id='outer']/script[1]")?.text ?? "" + let apikeyMatch = scriptText.range(of: #"apikey = "([^"]+)""#, options: .regularExpression) + let apikey = apikeyMatch.flatMap { match in + let start = match.upperBound + let end = scriptText.index(start, offsetBy: 32, limitedBy: scriptText.endIndex) ?? scriptText.endIndex + return String(scriptText[start.. Date: Thu, 13 Aug 2026 18:17:24 +0800 Subject: [PATCH 609/614] refactor(home): optimize the selection logic of the top card --- AppPackage/Sources/HomeFeature/HomeReducer.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AppPackage/Sources/HomeFeature/HomeReducer.swift b/AppPackage/Sources/HomeFeature/HomeReducer.swift index 6d5dcc4db..5b4cccff4 100644 --- a/AppPackage/Sources/HomeFeature/HomeReducer.swift +++ b/AppPackage/Sources/HomeFeature/HomeReducer.swift @@ -35,7 +35,8 @@ public struct HomeReducer: Sendable { public init() {} mutating func setPopularGalleries(_ galleries: [Gallery]) { - let sortedGalleries = galleries.sorted { lhs, rhs in + let filteredGalleries = galleries.filter { $0.rating >= 3 } + let sortedGalleries = filteredGalleries.sorted { lhs, rhs in lhs.title.count > rhs.title.count } var trimmedGalleries = Array(sortedGalleries.prefix(min(sortedGalleries.count, 10))) From cbe39897199376409d8851be2c873776edfde84a Mon Sep 17 00:00:00 2001 From: dyphire Date: Fri, 14 Aug 2026 10:50:40 +0800 Subject: [PATCH 610/614] fix(reader): thumbnail preview in reader not working --- .../AppComponents/PreviewImageView.swift | 27 +++++++++++-------- .../Sources/ReadingFeature/ReadingView.swift | 7 ++++- .../ReadingFeature/Support/ControlPanel.swift | 22 ++++++++++++--- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/AppPackage/Sources/AppComponents/PreviewImageView.swift b/AppPackage/Sources/AppComponents/PreviewImageView.swift index 80a57a66d..7d6c31363 100644 --- a/AppPackage/Sources/AppComponents/PreviewImageView.swift +++ b/AppPackage/Sources/AppComponents/PreviewImageView.swift @@ -24,19 +24,24 @@ public struct PreviewImageView: View { } } else { let (url, modifier) = PreviewResolver.getPreviewConfigs(originalURL: originalURL) - KFImage.url( - url, - cacheKey: url?.stableImageCacheKey - ?? originalURL?.stableImageCacheKey - ?? originalURL?.absoluteString - ) - .placeholder { + if let url { + KFImage.url( + url, + cacheKey: url.stableImageCacheKey + ?? originalURL?.stableImageCacheKey + ?? originalURL?.absoluteString + ) + .placeholder { + Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) + } + .imageModifier(modifier) + .fade(duration: 0.25) + .resizable() + .scaledToFit() + } else { Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .imageModifier(modifier) - .fade(duration: 0.25) - .resizable() - .scaledToFit() } } } diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index a165e7596..600f4f70b 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -218,7 +218,12 @@ public struct ReadingView: View { } } .onChange(of: store.showsSliderPreview) { _, newValue in - if !newValue { setPageIndex(sliderValue: pageHandler.sliderValue) } + if !newValue { + setPageIndex(sliderValue: pageHandler.sliderValue) + } else { + let currentIndex = Int(pageHandler.sliderValue) + store.send(.fetchPreviewURLs(currentIndex)) + } setAutoPlayPolocy(.off) } // AutoPlay diff --git a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift index a69cdaaec..fb89376d9 100644 --- a/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift +++ b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift @@ -341,6 +341,22 @@ private struct SliderPreivew: View { .opacity(checkIndex(index) ? 1 : 0) } } + .onChange(of: showsSliderPreview) { _, newValue in + if newValue { + for index in previewsIndices { + if previewURLs[index] == nil && checkIndex(index) { + fetchPreviewURLsAction(index) + } + } + } + } + .onChange(of: sliderValue) { _, _ in + for index in previewsIndices { + if previewURLs[index] == nil && checkIndex(index) { + fetchPreviewURLsAction(index) + } + } + } .opacity(showsSliderPreview ? 1 : 0) .padding(.vertical, verticalPadding) .padding(.horizontal, horizontalPadding) @@ -357,12 +373,10 @@ private extension SliderPreivew { DeviceUtil.isPadWidth ? DeviceUtil.isLandscape ? 7 : 5 : 3 } var previewsIndices: [Int] { - guard !previewURLs.isEmpty else { return [] } let currentIndex = Int(sliderValue) let distance = (previewsCount - 1) / 2 - let lowerBound = currentIndex - distance - let upperBound = currentIndex + distance - + let lowerBound = max(Int(range.lowerBound), currentIndex - distance) + let upperBound = min(Int(range.upperBound), currentIndex + distance) let indices = Array(lowerBound...upperBound) return isReversed ? indices.reversed() : indices } From 93e767147926d9c94614e025116c6d84dee309a4 Mon Sep 17 00:00:00 2001 From: dyphire Date: Sat, 15 Aug 2026 09:58:22 +0800 Subject: [PATCH 611/614] feat(reader): add automatic vertical reading support for galleries containing webtoon tags --- .../Sources/AppModels/Gallery/Gallery.swift | 5 ++ .../Sources/ReadingFeature/ReadingView.swift | 62 ++++++++++++------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/AppPackage/Sources/AppModels/Gallery/Gallery.swift b/AppPackage/Sources/AppModels/Gallery/Gallery.swift index ed8b241e0..b165a008b 100644 --- a/AppPackage/Sources/AppModels/Gallery/Gallery.swift +++ b/AppPackage/Sources/AppModels/Gallery/Gallery.swift @@ -64,6 +64,11 @@ public struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { title = title.barcesAndSpacesRemoved return title } + public var hasWebtoonTag: Bool { + tags.contains { tag in + tag.contents.contains { $0.text.lowercased() == "webtoon" } + } + } public var language: Language? { let rawValue = tags .first(where: { $0.namespace == .language })?.contents diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift index 600f4f70b..dbdae4de1 100644 --- a/AppPackage/Sources/ReadingFeature/ReadingView.swift +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -18,9 +18,9 @@ public struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme @Bindable var store: StoreOf - // Write handle backing the reader's own controls (e.g. the ControlPanel slider). The reading-setting - // sheet owns its own `@Shared(.setting)`; other reads go through `store.setting`. Same underlying - // storage — the model clamps keep every write safe. + // Write handle for ControlPanel setting edits. The reading-setting sheet owns its own + // `@Shared(.setting)`; this view derives an `effectiveSetting` that forces vertical mode + // for webtoon galleries without mutating the global setting. @Shared(.setting) private var setting: Setting let gid: String let blurRadius: Double @@ -69,6 +69,27 @@ public struct ReadingView: View { return store.localPageURLs.merging(store.originalImageURLs, uniquingKeysWith: { local, _ in local }) } + private var effectiveSetting: Setting { + var copy = store.setting + if store.gallery.hasWebtoonTag { + copy.readingDirection = .vertical + } + return copy + } + + private var effectiveSettingBinding: Binding { + Binding( + get: { effectiveSetting }, + set: { newValue in + var adjusted = newValue + if store.gallery.hasWebtoonTag { + adjusted.readingDirection = store.setting.readingDirection + } + setting = adjusted + } + ) + } + public var body: some View { @Bindable var bindableLiveTextHandler = liveTextHandler @Bindable var bindablePageHandler = pageHandler @@ -91,13 +112,13 @@ public struct ReadingView: View { } } } - .accentColor(store.setting.accentColor) - .tint(store.setting.accentColor) + .accentColor(effectiveSetting.accentColor) + .tint(effectiveSetting.accentColor) .autoBlur(radius: blurRadius) } .sheet(item: $store.destination.share, id: \.id) { shareItemBox in ActivityView(activityItems: [shareItemBox.wrappedValue.associatedValue]) - .accentColor(store.setting.accentColor) + .accentColor(effectiveSetting.accentColor) .autoBlur(radius: blurRadius) } .toast($store.scope(state: \.toast, action: \.toast)) @@ -109,13 +130,12 @@ public struct ReadingView: View { .animation(.default, value: store.showsPanel) .statusBar(hidden: !store.showsPanel) .onDisappear { - // Progress is flushed in the reducer on `.onPerformDismiss` (before the presentation is - // torn down); an `onDisappear` send would arrive after the destination is nil'd and be - // dropped. So only non-persistence teardown happens here. liveTextHandler.cancelRequests() setAutoPlayPolocy(.off) } - .onAppear { store.send(.onAppear(gid)) } + .onAppear { + store.send(.onAppear(gid)) + } } var content: some View { @@ -126,15 +146,15 @@ public struct ReadingView: View { backgroundColor.ignoresSafeArea() ZStack { - if store.setting.readingDirection == .vertical { + if effectiveSetting.readingDirection == .vertical { AdvancedList( page: page, data: store.state.containerDataSource( - setting: store.setting, + setting: effectiveSetting, isLandscape: DeviceUtil.isLandscape ), id: \.self, - spacing: store.setting.contentDividerHeight, + spacing: effectiveSetting.contentDividerHeight, gesture: SimultaneousGesture(magnificationGesture, tapGesture), content: imageStack ) @@ -143,13 +163,13 @@ public struct ReadingView: View { Pager( page: page, data: store.state.containerDataSource( - setting: store.setting, + setting: effectiveSetting, isLandscape: DeviceUtil.isLandscape ), id: \.self, content: imageStack ) - .horizontal(store.setting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) + .horizontal(effectiveSetting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) .swipeInteractionArea(.allAvailable) .allowsDragging(gestureHandler.scale == 1) } @@ -168,7 +188,7 @@ public struct ReadingView: View { ControlPanel( showsPanel: $store.showsPanel, showsSliderPreview: $store.showsSliderPreview, - sliderValue: $bindablePageHandler.sliderValue, setting: Binding($setting), + sliderValue: $bindablePageHandler.sliderValue, setting: effectiveSettingBinding, enablesLiveText: $bindableLiveTextHandler.enablesLiveText, autoPlayPolicy: .init(get: { autoPlayHandler.policy }, set: { setAutoPlayPolocy($0) }), range: 1...Float(store.gallery.pageCount), @@ -196,7 +216,7 @@ public struct ReadingView: View { } } // Orientation - .onChange(of: store.setting.enablesLandscape) { _, newValue in + .onChange(of: effectiveSetting.enablesLandscape) { _, newValue in store.send(.setOrientationPortrait(!newValue)) } } @@ -207,7 +227,7 @@ public struct ReadingView: View { // Page .onChange(of: page.index) { _, newValue in let newValue = pageHandler.mapFromPager( - index: newValue, pageCount: store.gallery.pageCount, setting: store.setting + index: newValue, pageCount: store.gallery.pageCount, setting: effectiveSetting ) pageHandler.sliderValue = .init(newValue) store.send(.syncReadingProgress(.init(newValue))) @@ -235,7 +255,7 @@ public struct ReadingView: View { } @ViewBuilder private func imageStack(index: Int) -> some View { - let setting = store.setting + let setting = effectiveSetting let imageStackConfig = store.state.imageContainerConfigs( index: index, setting: setting, @@ -260,7 +280,7 @@ public struct ReadingView: View { liveTextTapAction: liveTextHandler.setFocusedLiveTextGroup, fetchAction: { store.send(.fetchImageURLs($0)) }, refetchAction: { store.send(.refetchImageURLs($0)) }, - prefetchAction: { store.send(.prefetchImages($0, store.setting.prefetchLimit)) }, + prefetchAction: { store.send(.prefetchImages($0, effectiveSetting.prefetchLimit)) }, loadRetryAction: { store.send(.onWebImageRetry($0)) }, loadSucceededAction: { store.send(.onWebImageSucceeded($0)) }, loadFailedAction: { store.send(.onWebImageFailed($0)) }, @@ -275,7 +295,7 @@ public struct ReadingView: View { extension ReadingView { func setPageIndex(sliderValue: Float) { let newValue = pageHandler.mapToPager( - index: .init(sliderValue), setting: store.setting + index: .init(sliderValue), setting: effectiveSetting ) if page.index != newValue { page.update(.new(index: newValue)) From 9f787e8c9a9718c3be9a510ac6895896d268c501 Mon Sep 17 00:00:00 2001 From: dyphire Date: Sat, 15 Aug 2026 13:49:44 +0800 Subject: [PATCH 612/614] fix(ui): tag suggestion function does not adapt to EH search syntax --- .../AppComponents/TagSuggestionView.swift | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/AppPackage/Sources/AppComponents/TagSuggestionView.swift b/AppPackage/Sources/AppComponents/TagSuggestionView.swift index ee5a05122..8ef2f519a 100644 --- a/AppPackage/Sources/AppComponents/TagSuggestionView.swift +++ b/AppPackage/Sources/AppComponents/TagSuggestionView.swift @@ -144,8 +144,14 @@ final class TagTranslationHandler { for index in (lastCompletedTagIndex ?? 0).. [TagSuggestion] { - let originalKeyword = keyword + private func getSuggestions( + translations: [String: TagTranslation], + keyword: String, + originalKeyword: String + ) -> [TagSuggestion] { var keyword = keyword var namespace: String? let namespaceAbbreviations = TagNamespace.abbreviations From 9dfbca7a8148b7b6caff93bdb3ec4e5790192843 Mon Sep 17 00:00:00 2001 From: dyphire Date: Sat, 15 Aug 2026 13:53:42 +0800 Subject: [PATCH 613/614] feat(search): add search suggestion support for quick search content input --- .../QuickSearchFeature/QuickSearchReducer.swift | 2 ++ .../QuickSearchFeature/QuickSearchView.swift | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift index e1dbdac87..adf101840 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchReducer.swift @@ -39,6 +39,8 @@ public struct QuickSearchReducer: Sendable { } @Shared(.quickSearchWords) public var quickSearchWords: [QuickSearchWord] + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting public init() {} diff --git a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index 6d9c02b28..da7544326 100644 --- a/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -112,7 +112,9 @@ public struct QuickSearchView: View { submitAction: onTextFieldSubmitted, confirmAction: { store.send(kind == .new ? .appendWord : .editWord) - } + }, + tagTranslator: store.tagTranslator, + setting: store.setting ) } } @@ -125,17 +127,22 @@ extension QuickSearchView { private let focusedField: FocusState.Binding private let submitAction: () -> Void private let confirmAction: () -> Void + private let tagTranslator: TagTranslator + private let setting: Setting init( title: LocalizedStringResource, word: Binding, focusedField: FocusState.Binding, - submitAction: @escaping () -> Void, confirmAction: @escaping () -> Void + submitAction: @escaping () -> Void, confirmAction: @escaping () -> Void, + tagTranslator: TagTranslator, setting: Setting ) { self.title = title _word = word self.focusedField = focusedField self.submitAction = submitAction self.confirmAction = confirmAction + self.tagTranslator = tagTranslator + self.setting = setting } var body: some View { @@ -149,6 +156,12 @@ extension QuickSearchView { .disableAutocorrection(true) .textInputAutocapitalization(.never) .focused(focusedField, equals: .content) + TagSuggestionView( + keyword: $word.content, + translations: tagTranslator.translations, + showsImages: setting.showsImagesInTags, + isEnabled: setting.showsTagsSearchSuggestion + ) } } .toolbar(content: toolbar) From c2d404374f46713c05e5eb18122ceefa40809a05 Mon Sep 17 00:00:00 2001 From: dyphire Date: Tue, 18 Aug 2026 14:39:25 +0800 Subject: [PATCH 614/614] fix(config): EhSetting parsed incorrectly --- .../Sources/AppModels/Gallery/Language.swift | 2 +- .../Sources/AppModels/Support/EhSetting.swift | 41 ++++++------ .../FavoritesFeature/FavoritesReducer.swift | 11 +++- .../Sources/ParserFeature/Parser+Detail.swift | 65 +++++++++---------- .../ParserFeature/Parser+Favorite.swift | 17 +++-- .../Sources/ParserFeature/Parser+List.swift | 9 ++- .../ParserFeature/Parser+Profile.swift | 37 ++++++----- .../EhSetting/EhSettingView+Sections2.swift | 10 +-- .../Resources/Parser/Other/EhSetting.html | 8 +-- .../Other/EhSettingParserTests.swift | 4 +- 10 files changed, 107 insertions(+), 97 deletions(-) diff --git a/AppPackage/Sources/AppModels/Gallery/Language.swift b/AppPackage/Sources/AppModels/Gallery/Language.swift index 3088e21dc..84314916a 100644 --- a/AppPackage/Sources/AppModels/Gallery/Language.swift +++ b/AppPackage/Sources/AppModels/Gallery/Language.swift @@ -4,7 +4,7 @@ import Resources public enum Language: String, Codable, Sendable { public static let allExcludedCases: [Self] = [ .japanese, .english, .chinese, .dutch, .french, .german, .hungarian, .italian, - .korean, .polish, .portuguese, .russian, .spanish, .thai, .vietnamese, .invalid, .other + .korean, .polish, .portuguese, .russian, .spanish, .thai, .vietnamese, .other ] // swiftlint:disable line_length case invalid = "N/A"; case other = "Other"; case afrikaans = "Afrikaans"; case albanian = "Albanian"; case arabic = "Arabic"; case bengali = "Bengali"; case bosnian = "Bosnian"; case bulgarian = "Bulgarian"; case burmese = "Burmese"; case catalan = "Catalan"; case cebuano = "Cebuano"; case chinese = "Chinese"; case croatian = "Croatian"; case czech = "Czech"; case danish = "Danish"; case dutch = "Dutch"; case english = "English"; case esperanto = "Esperanto"; case estonian = "Estonian"; case finnish = "Finnish"; case french = "French"; case georgian = "Georgian"; case german = "German"; case greek = "Greek"; case hebrew = "Hebrew"; case hindi = "Hindi"; case hmong = "Hmong"; case hungarian = "Hungarian"; case indonesian = "Indonesian"; case italian = "Italian"; case japanese = "Japanese"; case kazakh = "Kazakh"; case khmer = "Khmer"; case korean = "Korean"; case kurdish = "Kurdish"; case lao = "Lao"; case latin = "Latin"; case mongolian = "Mongolian"; case ndebele = "Ndebele"; case nepali = "Nepali"; case norwegian = "Norwegian"; case oromo = "Oromo"; case pashto = "Pashto"; case persian = "Persian"; case polish = "Polish"; case portuguese = "Portuguese"; case punjabi = "Punjabi"; case romanian = "Romanian"; case russian = "Russian"; case sango = "Sango"; case serbian = "Serbian"; case shona = "Shona"; case slovak = "Slovak"; case slovenian = "Slovenian"; case somali = "Somali"; case spanish = "Spanish"; case swahili = "Swahili"; case swedish = "Swedish"; case tagalog = "Tagalog"; case thai = "Thai"; case tigrinya = "Tigrinya"; case turkish = "Turkish"; case ukrainian = "Ukrainian"; case urdu = "Urdu"; case vietnamese = "Vietnamese"; case zulu = "Zulu" diff --git a/AppPackage/Sources/AppModels/Support/EhSetting.swift b/AppPackage/Sources/AppModels/Support/EhSetting.swift index 5dd4216c0..73f53c611 100644 --- a/AppPackage/Sources/AppModels/Support/EhSetting.swift +++ b/AppPackage/Sources/AppModels/Support/EhSetting.swift @@ -89,19 +89,27 @@ public struct EhSetting: Equatable, Sendable { self.multiplePageViewerShowThumbnailPane = multiplePageViewerShowThumbnailPane } // swiftlint:disable line_length - public static let empty: Self = .init(ehProfiles: [.empty], isCapableOfCreatingNewProfile: true, capableLoadThroughHathSetting: .anyClient, capableImageResolution: .auto, capableSearchResultCount: .fifty, capableThumbnailConfigRowCount: .forty, capableThumbnailConfigSizes: [], loadThroughHathSetting: .anyClient, browsingCountry: .autoDetect, literalBrowsingCountry: "", imageResolution: .auto, imageSizeWidth: 0, imageSizeHeight: 0, galleryName: .default, archiverBehavior: .autoSelectOriginalAutoStart, displayMode: .compact, showSearchRangeIndicator: true, enableGalleryThumbnailSelector: false, disabledCategories: Array(repeating: false, count: 10), favoriteCategories: Array(repeating: "", count: 10), favoritesSortOrder: .favoritedTime, ratingsColor: "", tagFilteringThreshold: 0, tagWatchingThreshold: 0, showFilteredRemovalCount: true, excludedLanguages: Array(repeating: false, count: 50), excludedUploaders: "", searchResultCount: .fifty, thumbnailLoadTiming: .onPageLoad, thumbnailConfigSize: .normal, thumbnailConfigRows: .ten, coverScaleFactor: 0, viewportVirtualWidth: 0, commentsSortOrder: .recent, commentVotesShowTiming: .always, tagsSortOrder: .alphabetical, galleryPageNumbering: .none) + public static let empty: Self = .init(ehProfiles: [.empty], isCapableOfCreatingNewProfile: true, capableLoadThroughHathSetting: .anyClient, capableImageResolution: .auto, capableSearchResultCount: .fifty, capableThumbnailConfigRowCount: .forty, capableThumbnailConfigSizes: [], loadThroughHathSetting: .anyClient, browsingCountry: .autoDetect, literalBrowsingCountry: "", imageResolution: .auto, imageSizeWidth: 0, imageSizeHeight: 0, galleryName: .default, archiverBehavior: .autoSelectOriginalAutoStart, displayMode: .compact, showSearchRangeIndicator: true, enableGalleryThumbnailSelector: false, disabledCategories: Array(repeating: false, count: categoryNames.count), favoriteCategories: Array(repeating: "", count: favoriteCategoryCount), favoritesSortOrder: .favoritedTime, ratingsColor: "", tagFilteringThreshold: 0, tagWatchingThreshold: 0, showFilteredRemovalCount: true, excludedLanguages: Array(repeating: false, count: languageValues.count), excludedUploaders: "", searchResultCount: .fifty, thumbnailLoadTiming: .onPageLoad, thumbnailConfigSize: .normal, thumbnailConfigRows: .ten, coverScaleFactor: 0, viewportVirtualWidth: 0, commentsSortOrder: .recent, commentVotesShowTiming: .always, tagsSortOrder: .alphabetical, galleryPageNumbering: .none) // swiftlint:enable line_length public static let categoryNames = Category.allFiltersCases.map(\.rawValue).map { value in value.lowercased().replacingOccurrences(of: " ", with: "") } + public static let favoriteCategoryCount = 10 public static let languageValues = [ + 1024, 2048, 1, 1025, 2049, 10, 1034, 2058, + 20, 1044, 2068, 30, 1054, 2078, 40, 1064, 2088, + 50, 1074, 2098, 60, 1084, 2108, 70, 1094, 2118, + 80, 1104, 2128, 90, 1114, 2138, 100, 1124, 2148, + 110, 1134, 2158, 120, 1144, 2168, 130, 1154, 2178, - 254, 1278, 2302, 255, 1279, 2303 + + 255, 1279, 2303 + ] public let ehProfiles: [EhProfile] @@ -260,13 +268,11 @@ extension EhSetting.LoadThroughHathSetting { // MARK: ImageResolution extension EhSetting { public enum ImageResolution: Int, CaseIterable, Identifiable, Comparable, Codable, Sendable { - case auto - case x780 - /// Deprecated - case x980 - case x1280 - case x1600 - case x2400 + case auto = 0 + case x800 = 1 + case x1280 = 3 + case x1920 = 4 + case x2560 = 5 } } extension EhSetting.ImageResolution { @@ -279,16 +285,14 @@ extension EhSetting.ImageResolution { switch self { case .auto: return String(localized: .imageResolutionAuto) - case .x780: - return "780x" - case .x980: - return "980x" + case .x800: + return "800x" case .x1280: return "1280x" - case .x1600: - return "1600x" - case .x2400: - return "2400x" + case .x1920: + return "1920x" + case .x2560: + return "2560x" } } } @@ -423,7 +427,6 @@ extension EhSetting { case twentyFive case fifty case oneHundred - case twoHundred } } extension EhSetting.SearchResultCount { @@ -440,8 +443,6 @@ extension EhSetting.SearchResultCount { return "50" case .oneHundred: return "100" - case .twoHundred: - return "200" } } } diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift index 8ea82f573..1b9da2da2 100644 --- a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -161,6 +161,9 @@ public struct FavoritesReducer: Sendable { if let keyword = keyword { state.keyword = keyword } + if let sortOrder = sortOrder { + state.sortOrder = sortOrder + } if state.pageNumber == nil { state.rawPageNumber[state.index] = PageNumber() } else { @@ -188,7 +191,9 @@ public struct FavoritesReducer: Sendable { state.rawPageNumber[targetFavIndex] = pageNumber state.rawDateSeekNavigation[targetFavIndex] = fetchResult.dateSeekNavigation state.rawGalleries[targetFavIndex] = galleries - state.sortOrder = fetchResult.sortOrder + if let sortOrder = fetchResult.sortOrder { + state.sortOrder = sortOrder + } return .none case .failure(let error): state.rawLoadingState[targetFavIndex] = .failed(error) @@ -223,7 +228,9 @@ public struct FavoritesReducer: Sendable { state.rawPageNumber[targetFavIndex] = pageNumber state.rawDateSeekNavigation[targetFavIndex] = fetchResult.dateSeekNavigation state.insertGalleries(index: targetFavIndex, galleries: galleries) - state.sortOrder = fetchResult.sortOrder + if let sortOrder = fetchResult.sortOrder { + state.sortOrder = sortOrder + } var effects: [Effect] = [] if galleries.isEmpty, pageNumber.hasNextPage() { diff --git a/AppPackage/Sources/ParserFeature/Parser+Detail.swift b/AppPackage/Sources/ParserFeature/Parser+Detail.swift index 26eb93dae..8c4a5f1a0 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Detail.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Detail.swift @@ -27,22 +27,21 @@ extension Parser { let previewURLs = try? parsePreviewURLs(doc: doc), let arcAndTor = try? parseArcAndTor(node: gd5Node), let infoPanel = try? parseInfoPanel(node: gddNode), - let visibility = try? parseVisibility(value: infoPanel[2]), - let sizeCount = Float(infoPanel[4]), - let pageCount = Int(infoPanel[6]), - let favoritedCount = Int(infoPanel[7]), - let language = Language(rawValue: infoPanel[3]), + let visibility = try? parseVisibility(value: infoPanel["visible"] ?? ""), + let sizeCount = Float(infoPanel["size"] ?? ""), + let pageCount = Int(infoPanel["length"] ?? ""), + let favoritedCount = Int(infoPanel["favorited"] ?? ""), + let language = Language(rawValue: infoPanel["language"] ?? ""), let engTitle = link.at_xpath("//h1 [@id='gn']")?.text, let uploader = try? parseUploader(node: gd3Node), let ratingResult = try? parseRating(node: gdrNode), let ratingCount = Int(gdrNode.at_xpath("//span [@id='rating_count']")?.text ?? ""), let category = AppModels.Category(rawValue: gd3Node.at_xpath("//div [@id='gdc']")?.text ?? ""), - let postedDate = try? parseDate(time: infoPanel[0], format: Defaults.DateFormat.publish) + let postedDate = try? parseDate(time: infoPanel["posted"] ?? "", format: Defaults.DateFormat.publish) else { continue } let isFavorited = gdfNode - .at_xpath("//a [@id='favoritelink']")? - .text?.contains("Add to Favorites") == false + .at_xpath(".//div[@id='fav']//div[@class='i']") != nil var favoriteTagIndex: Int? var favoriteTagName: String? if let favoritelink = gdfNode.at_xpath(".//a[@id='favoritelink']") { @@ -50,7 +49,7 @@ extension Parser { } let gjText = link.at_xpath("//h1 [@id='gj']")?.text let jpnTitle = gjText?.isEmpty != false ? nil : gjText - let parentURLString = infoPanel[1].isValidURL ? infoPanel[1] : "" + let parentURLString = (infoPanel["parent"] ?? "").isValidURL ? infoPanel["parent"] ?? "" : "" tmpGalleryDetail = GalleryDetail( gid: gid, @@ -71,7 +70,7 @@ extension Parser { favoritedCount: favoritedCount, pageCount: pageCount, sizeCount: sizeCount, - sizeType: infoPanel[5], + sizeType: infoPanel["sizeUnit"] ?? "", torrentCount: arcAndTor.1, favoriteTagIndex: favoriteTagIndex, favoriteTagName: favoriteTagName @@ -209,60 +208,54 @@ private extension Parser { } // swiftlint:disable:next cyclomatic_complexity - static func parseInfoPanel(node: XMLElement?) throws -> [String] { + static func parseInfoPanel(node: XMLElement?) throws -> [String: String] { guard let object = node?.xpath("//tr") else { throw AppError.parseFailed } - var infoPanel = Array( - repeating: "", - count: 8 - ) + var infoPanel = [String: String]() for gddLink in object { guard let gdt1Text = gddLink.at_xpath("//td [@class='gdt1']")?.text, let gdt2Text = gddLink.at_xpath("//td [@class='gdt2']")?.text else { continue } let aHref = gddLink.at_xpath("//td [@class='gdt2']")?.at_xpath("//a")?["href"] - if gdt1Text.contains("Posted") { - infoPanel[0] = gdt2Text + let key = gdt1Text.trimmingCharacters(in: .whitespaces) + if key.contains("Posted") { + infoPanel["posted"] = gdt2Text } - if gdt1Text.contains("Parent") { - infoPanel[1] = aHref ?? "None" + if key.contains("Parent") { + infoPanel["parent"] = aHref ?? "None" } - if gdt1Text.contains("Visible") { - infoPanel[2] = gdt2Text + if key.contains("Visible") { + infoPanel["visible"] = gdt2Text } - if gdt1Text.contains("Language") { + if key.contains("Language") { let words = gdt2Text.split(separator: " ") if !words.isEmpty { - infoPanel[3] = words[0] - .trimmingCharacters(in: .whitespaces) + infoPanel["language"] = String(words[0]).trimmingCharacters(in: .whitespaces) } } - if gdt1Text.contains("File Size") { - infoPanel[4] = gdt2Text + if key.contains("File Size") { + infoPanel["size"] = gdt2Text .replacingOccurrences(of: " KiB", with: "") .replacingOccurrences(of: " MiB", with: "") .replacingOccurrences(of: " GiB", with: "") - if gdt2Text.contains("KiB") { infoPanel[5] = "KiB" } - if gdt2Text.contains("MiB") { infoPanel[5] = "MiB" } - if gdt2Text.contains("GiB") { infoPanel[5] = "GiB" } + if gdt2Text.contains("KiB") { infoPanel["sizeUnit"] = "KiB" } + if gdt2Text.contains("MiB") { infoPanel["sizeUnit"] = "MiB" } + if gdt2Text.contains("GiB") { infoPanel["sizeUnit"] = "GiB" } } - if gdt1Text.contains("Length") { - infoPanel[6] = gdt2Text.replacingOccurrences(of: " pages", with: "") + if key.contains("Length") { + infoPanel["length"] = gdt2Text.replacingOccurrences(of: " pages", with: "") } - if gdt1Text.contains("Favorited") { - infoPanel[7] = gdt2Text + if key.contains("Favorited") { + infoPanel["favorited"] = gdt2Text .replacingOccurrences(of: " times", with: "") .replacingOccurrences(of: "Never", with: "0") .replacingOccurrences(of: "Once", with: "1") } } - guard infoPanel.filter({ !$0.isEmpty }).count == 8 - else { throw AppError.parseFailed } - return infoPanel } diff --git a/AppPackage/Sources/ParserFeature/Parser+Favorite.swift b/AppPackage/Sources/ParserFeature/Parser+Favorite.swift index 108e01365..0a9db2655 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Favorite.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Favorite.swift @@ -3,16 +3,15 @@ import AppModels extension Parser { public static func parseFavoritesSortOrder(doc: HTMLDocument) -> FavoritesSortOrder? { - guard let idoNode = doc.at_xpath("//div [@class='ido']") else { return nil } - for link in idoNode.xpath("//div") where link.className == nil { - guard let aText = link.at_xpath("//div")?.at_xpath("//a")?.text else { continue } - if aText == "Use Posted" { - return .favoritedTime - } else if aText == "Use Favorited" { - return .lastUpdateTime - } + guard let select = doc.at_xpath("//div [@class='searchnav']//select[contains(@onchange, 'inline_set=fs_')]"), + let selectedValue = select.at_xpath(".//option[@selected]")?["value"] + else { return nil } + + switch selectedValue { + case "p": return .lastUpdateTime + case "f": return .favoritedTime + default: return nil } - return nil } public static func parseFavoriteCategories(doc: HTMLDocument) throws -> [Int: String] { diff --git a/AppPackage/Sources/ParserFeature/Parser+List.swift b/AppPackage/Sources/ParserFeature/Parser+List.swift index 86196d8ca..c8dfa4b42 100644 --- a/AppPackage/Sources/ParserFeature/Parser+List.swift +++ b/AppPackage/Sources/ParserFeature/Parser+List.swift @@ -205,8 +205,13 @@ private extension Parser { let date = try? parseDate(time: dateString, format: Defaults.DateFormat.publish) { tmpPublishedDate = date } - if let components = div.text?.split(separator: " "), components.count == 2, - ["page", "pages"].contains(components[1]), let pageCount = Int(components[0]) { + if let text = div.text, + text.localizedCaseInsensitiveContains("page") { + let nsRange = NSRange(text.startIndex..., in: text) + guard let match = try? NSRegularExpression(pattern: #"\d+"#).firstMatch(in: text, range: nsRange), + let range = Range(match.range, in: text), + let pageCount = Int(text[range]) + else { continue } tmpPageCount = pageCount } if let aLink = div.at_xpath("//a"), aLink["href"]?.contains("uploader") == true { diff --git a/AppPackage/Sources/ParserFeature/Parser+Profile.swift b/AppPackage/Sources/ParserFeature/Parser+Profile.swift index d7c6ef412..776e49edb 100644 --- a/AppPackage/Sources/ParserFeature/Parser+Profile.swift +++ b/AppPackage/Sources/ParserFeature/Parser+Profile.swift @@ -40,12 +40,11 @@ extension Parser { return EhProfile(value: value, name: option.name, isSelected: option.isSelected) } + isCapableOfCreatingNewProfile = false for button in profileOuter.xpath("//input [@type='button']") { if button["value"] == "Create New" { isCapableOfCreatingNewProfile = true break - } else { - isCapableOfCreatingNewProfile = false } } @@ -63,11 +62,7 @@ extension Parser { if value == "" { value = "-" } browsingCountry = EhSetting.BrowsingCountry(rawValue: value ?? "") - if let pText = optouter.at_xpath("//p")?.text, - let rangeA = pText.range(of: "You appear to be browsing the site from "), - let rangeB = pText.range(of: " or use a VPN or proxy in this country") { - literalBrowsingCountry = String(pText[rangeA.upperBound..)] { - AppModels.Category.allFavoritesCases.enumerated().map { index, category in - (category, $ehSetting.favoriteCategories[index]) + private var tuples: [(Int, Binding)] { + AppModels.Category.allFavoritesCases.enumerated().map { index, _ in + (index, $ehSetting.favoriteCategories[index]) } } var body: some View { Section { - ForEach(tuples, id: \.0) { category, nameBinding in + ForEach(tuples, id: \.0) { index, nameBinding in HStack(spacing: 30) { Circle() - .foregroundColor(category.color(host: AppUtil.galleryHost)) + .foregroundColor(Defaults.FavoriteColor.colors[index]) .frame(width: 10) SettingTextField( diff --git a/AppPackage/Sources/TestingSupport/Resources/Parser/Other/EhSetting.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/EhSetting.html index 72f8aeba0..f31e26b30 100644 --- a/AppPackage/Sources/TestingSupport/Resources/Parser/Other/EhSetting.html +++ b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/EhSetting.html @@ -113,13 +113,13 @@

Image Load Settings

Image Size Settings

-

Images are normally resampled to 1280 pixels (desktop) or 780 pixels (mobile) horizontal resolution for online viewing. You can select one of the following alternative resolutions.

+

Images are normally resampled to 1280 pixels (desktop) or 800 pixels (mobile) horizontal resolution for online viewing. You can select one of the following alternative resolutions.

-
+
-
-
+
+
diff --git a/AppPackage/Tests/ParserFeatureTests/Other/EhSettingParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/EhSettingParserTests.swift index a131dec6e..b5df492b1 100644 --- a/AppPackage/Tests/ParserFeatureTests/Other/EhSettingParserTests.swift +++ b/AppPackage/Tests/ParserFeatureTests/Other/EhSettingParserTests.swift @@ -36,7 +36,7 @@ struct EhSettingParserTests: TestHelper { #expect(ehSetting.capableLoadThroughHathSetting == .legacyNo) #expect(ehSetting.capableLoadThroughHathSettings == EhSetting.LoadThroughHathSetting.allCases) - #expect(ehSetting.capableImageResolution == .x2400) + #expect(ehSetting.capableImageResolution == .x2560) #expect(ehSetting.capableImageResolutions == EhSetting.ImageResolution.allCases) #expect(ehSetting.capableSearchResultCount == .oneHundred) @@ -69,7 +69,7 @@ struct EhSettingParserTests: TestHelper { #expect(ehSetting.tagFilteringThreshold == 0) #expect(ehSetting.tagWatchingThreshold == 0) #expect(ehSetting.showFilteredRemovalCount == true) - #expect(ehSetting.excludedLanguages == .init(repeating: false, count: 50)) + #expect(ehSetting.excludedLanguages == EhSetting.empty.excludedLanguages) #expect(ehSetting.excludedUploaders == "") #expect(ehSetting.searchResultCount == .oneHundred) #expect(ehSetting.thumbnailLoadTiming == .onMouseOver)